diff --git a/.cargo/config.toml b/.cargo/config.toml index 76cf725f9e2e2..1ca035a75d78c 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,6 +1,7 @@ [alias] cheats = "test -p foundry-cheatcodes-spec --features schema tests::" test-debugger = "test -p forge --test cli manual_debug_setup -- --include-ignored --nocapture" +bless-lints = "test -p forge --test ui -- --bless" # Increase the stack size to 10MB for Windows targets, which is in line with Linux # (whereas default for Windows is 1MB). diff --git a/.config/nextest.toml b/.config/nextest.toml index 86960c3abb1cd..9d4b2bc2eb490 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -1,18 +1,72 @@ [test-groups] -chisel-serial = { max-threads = 1 } +# The on-chain deploy+verify tests share one funded deployer per network and one Etherscan key. +# Running them concurrently collides on the account nonce and trips Etherscan's 3 calls/sec limit. +deploy-verify = { max-threads = 1 } +# The Tempo canaries fork public endpoints that rate limit concurrent replays. +tempo-canary = { max-threads = 1 } [profile.default] retries = { backoff = "exponential", count = 2, delay = "5s", jitter = true } -slow-timeout = { period = "1m", terminate-after = 3 } +slow-timeout = { period = "30s", terminate-after = 3 } +# Exclude flaky tests from regular CI runs - they run in nightly test-flaky workflow. +# Exclude the on-chain deploy+verify tests too - they spend testnet funds and depend on external +# verifier services, so they run in the nightly test-deploy-verify workflow. +default-filter = "not test(/flaky_/) and not test(/deploy_verify_/)" [[profile.default.overrides]] -filter = "test(/ext_integration|can_test_forge_std/)" +filter = "test(/ext_integration/)" slow-timeout = { period = "5m", terminate-after = 4 } +# The symbolic conformance and limits suites only do work with SYMBOLIC_CONFORMANCE and +# SYMBOLIC_LIMITS set, as the nightly test-flaky workflow does; the stateful invariant cases there +# take minutes in debug builds. +[[profile.default.overrides]] +filter = "test(/symbolic_conformance::|symbolic_limits::/)" +slow-timeout = { period = "5m", terminate-after = 4 } + +# Integration-style CLI tests can exceed the default 90s timeout on slower +# Windows, macOS, and ARM runners while shelling out to forge/solc/git. +[[profile.default.overrides]] +filter = "test(/(^|::)bind_event_module_name_shadowing$/) | test(/(^|::)bind_event_module_prefix_does_not_shadow_aliases$/) | test(/(^|::)can_bind_e2e$/) | test(/(^|::)test_clone_/) | test(/(^|::)can_deploy_and_simulate_25_txes_concurrently$/) | test(/(^|::)can_deploy_25_txes_concurrently_across_full_blocks$/) | test(/(^|::)can_deploy_25_txes_concurrently_with_interval_mining$/) | test(/(^|::)can_deploy_25_txes_concurrently_with_mixed_mining$/) | test(/(^|::)chisel_can_run_with_live_logs_flag$/) | test(/(^|::)session_wrapper_uses_project_config_for_cast_session$/)" +slow-timeout = { period = "5m", terminate-after = 4 } + +[[profile.default.overrides]] +filter = "test(/tempo_canary::/)" +test-group = "tempo-canary" + +# Do not re-run so that `cargo cheats` is ran locally. [[profile.default.overrides]] filter = "package(foundry-cheatcodes-spec)" retries = 0 -[[profile.default.overrides]] -filter = "package(chisel)" -test-group = "chisel-serial" +# Profile for running flaky tests (used by nightly CI and for local debugging) +# Run with: cargo nextest run --profile flaky +[profile.flaky] +default-filter = "test(/flaky_/)" +retries = { backoff = "exponential", count = 5, delay = "10s", jitter = true } +slow-timeout = { period = "60s", terminate-after = 3 } + +# The fork smoke tests report an unreachable endpoint by skipping instead of failing, so keep +# their output on success to show which chains a run actually exercised. +[[profile.flaky.overrides]] +filter = "test(/fork_chains::/)" +success-output = "final" + +# The network read tests report an unreachable endpoint or an unusable block by skipping instead +# of failing, so keep their output on success to show which networks a run actually exercised. +[[profile.flaky.overrides]] +filter = "test(/read_networks::/)" +success-output = "final" + +# Profile for the on-chain deploy+verify tests (used by the nightly test-deploy-verify workflow). +# Requires _RPC_URL plus TESTNET_DEPLOYER_PRIVATE_KEY to do anything. +# Run with: cargo nextest run --profile deploy-verify +[profile.deploy-verify] +default-filter = "test(/deploy_verify_/)" +retries = 1 +# A deploy plus verifier round trip is minutes, not seconds. +slow-timeout = { period = "120s", terminate-after = 5 } + +[[profile.deploy-verify.overrides]] +filter = "test(/deploy_verify_/)" +test-group = "deploy-verify" diff --git a/.devcontainer/Dockerfile.dev b/.devcontainer/Dockerfile.dev deleted file mode 100644 index c8e8935c91fda..0000000000000 --- a/.devcontainer/Dockerfile.dev +++ /dev/null @@ -1,84 +0,0 @@ -FROM ubuntu:22.04 - -ARG USERNAME=foundry -ARG USER_UID=1000 -ARG USER_GID=$USER_UID -ARG PYTHON_VERSION=3.11 -ARG NODE_MAJOR=20 -ARG VYPER_VERSION=0.4.3 - -ENV DEBIAN_FRONTEND=noninteractive -ENV CARGO_TERM_COLOR=always \ - RUST_BACKTRACE=full - -WORKDIR /workspace - -RUN apt-get update && apt-get install -y --no-install-recommends \ - # Build tools - build-essential \ - clang \ - lld \ - pkg-config \ - # Network/SSL - curl \ - ca-certificates \ - gnupg \ - libssl-dev \ - # Version control & utils - git \ - sudo \ - unzip \ - # Python - python${PYTHON_VERSION} \ - python3-pip \ - python${PYTHON_VERSION}-venv \ - # Add Node.js repo - && mkdir -p /etc/apt/keyrings \ - && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \ - && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list \ - # Update again after adding repo and install Node.js - && apt-get update && apt-get install -y --no-install-recommends \ - nodejs \ - # Clean up apt cache - && apt-get clean && rm -rf /var/lib/apt/lists/* - -# Ensure python points to the installed python version -RUN ln -sf /usr/bin/python${PYTHON_VERSION} /usr/bin/python && \ - ln -sf /usr/bin/python${PYTHON_VERSION} /usr/bin/python3 - -# Create non-root user with sudo privileges -RUN groupadd --gid $USER_GID $USERNAME \ - && useradd --uid $USER_UID --gid $USER_GID -m $USERNAME -s /bin/bash \ - # Setup sudo without password prompt - && echo "$USERNAME ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/$USERNAME \ - && chmod 0440 /etc/sudoers.d/$USERNAME \ - # Add user to the sudo group (standard practice) - && usermod -aG sudo $USERNAME - -# Switch to the non-root user -USER $USERNAME -WORKDIR /home/$USERNAME - -# --- User-specific installations --- - -# Install Bun -ENV BUN_INSTALL="/home/$USERNAME/.bun" -ENV PATH="$BUN_INSTALL/bin:$PATH" -RUN curl -fsSL https://bun.sh/install | bash - -# Install Rust & cargo-nextest -ENV CARGO_HOME="/home/$USERNAME/.cargo" -ENV RUSTUP_HOME="/home/$USERNAME/.rustup" -ENV PATH="$CARGO_HOME/bin:$PATH" -RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y \ - && cargo install cargo-nextest --locked - -# Install Vyper using pip -# Ensure pip user install directory is in PATH -ENV PYTHONUSERBASE="/home/$USERNAME/.local" -ENV PATH="$PYTHONUSERBASE/bin:$PATH" -RUN pip3 install --user vyper==${VYPER_VERSION} - -# Switch back to the main workspace directory -WORKDIR /workspace - diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json deleted file mode 100644 index 1c2eb26f5a9f6..0000000000000 --- a/.devcontainer/devcontainer.json +++ /dev/null @@ -1,49 +0,0 @@ -// For format details, see https://aka.ms/devcontainer.json. -{ - "name": "Foundry Development", - "build": { - "context": "..", - "dockerfile": "Dockerfile.dev" - }, - - "features": { - "ghcr.io/devcontainers/features/common-utils:2": { - "installZsh": true, - "configureZshAsDefaultShell": true, - "installOhMyZsh": true, - "upgradePackages": true - } - }, - - "forwardPorts": [], - - "postCreateCommand": "rustup default stable && rustup update", - - "customizations": { - "vscode": { - "extensions": [ - "rust-lang.rust-analyzer", - "serayuzgur.crates", - "tamasfe.even-better-toml", - "ms-python.python", - "dbaeumer.vscode-eslint", - "oven.bun-vscode" - ], - "settings": { - "rust-analyzer.checkOnSave": true, - "rust-analyzer.cargo.features": "all" - } - } - }, - - "remoteUser": "foundry", - - "workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=cached", - - "workspaceFolder": "/workspace", - - "mounts": [ - "source=${localEnv:HOME}/.cargo/registry,target=/home/foundry/.cargo/registry,type=bind,consistency=cached", - "source=${localEnv:HOME}/.cargo/git,target=/home/foundry/.cargo/git,type=bind,consistency=cached" - ] -} diff --git a/.gitattributes b/.gitattributes index c3c54b1cfce95..ef618843b001d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,9 +1,13 @@ crates/cheatcodes/assets/*.json linguist-generated -testdata/cheats/Vm.sol linguist-generated +testdata/utils/Vm.sol linguist-generated # See *.rs diff=rust crates/lint/testdata/* text eol=lf +crates/lint/testdata/*.stderr whitespace=-blank-at-eof +testdata/fixtures/**/* eol=lf dprint.json linguist-language=JSON-with-Comments -.devcontainer/devcontainer.json linguist-language=JSON-with-Comments \ No newline at end of file +.devcontainer/devcontainer.json linguist-language=JSON-with-Comments + +.env.example linguist-language=Dotenv \ No newline at end of file diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 63b25bbbcb260..ff4abfc12584b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @danipopes @klkvr @mattsse @grandizzy @yash-atreya @zerosnacks +* @danipopes @mattsse @grandizzy @0xrusowsky @mablr @figtracer @stevencartavia diff --git a/.github/CRATE_CHECKS_FAILURE_TEMPLATE.md b/.github/CRATE_CHECKS_FAILURE_TEMPLATE.md new file mode 100644 index 0000000000000..d5c891b3a1ed9 --- /dev/null +++ b/.github/CRATE_CHECKS_FAILURE_TEMPLATE.md @@ -0,0 +1,10 @@ +--- +title: "bug: nightly crate-checks workflow failed" +labels: P-normal, T-bug +--- + +The nightly crate-checks workflow has failed. Either one or more crates don't compile in isolation (`cargo hack check`), likely a missing dependency or feature flag masked by Cargo's workspace feature unification, or a unit test failed under default features, which regular CI never builds because it always enables `base` and `monad`. + +Check the [crate-checks workflow page]({{ env.WORKFLOW_URL }}) for details. + +This issue was raised by the workflow at `.github/workflows/crate-checks.yml`. diff --git a/.github/DEPLOY_VERIFY_FAILURE_TEMPLATE.md b/.github/DEPLOY_VERIFY_FAILURE_TEMPLATE.md new file mode 100644 index 0000000000000..589920d2480f2 --- /dev/null +++ b/.github/DEPLOY_VERIFY_FAILURE_TEMPLATE.md @@ -0,0 +1,14 @@ +--- +title: "bug: deploy + verify workflow failed" +labels: P-normal, T-bug +--- + +The nightly on-chain deploy + verify workflow has failed. This exercises `forge create` followed by `forge verify-contract` against Hoodi, Sepolia, Base Sepolia, Arbitrum Sepolia, Monad testnet and Robinhood testnet, using Etherscan, Sourcify and Blockscout. + +The most common cause is the shared deployer running out of testnet funds. The deployer is `0x14459e92f32B68125525B5e3cdF3A239618e40D8`, held in the `FUNDED_TESTNET_PKEY` secret, and the workflow logs its balance on every network before running the tests. Monad testnet drains fastest, at roughly 0.08 MON per run. Top it up from a faucet to fix. + +Other likely causes are a testnet RPC being down, a verifier service being unavailable, or Etherscan rate limiting. + +Check the [deploy + verify workflow page]({{ env.WORKFLOW_URL }}) for details. + +This issue was raised by the workflow at `.github/workflows/test-deploy-verify.yml`. diff --git a/.github/DEPLOY_VERIFY_REFUND_TEMPLATE.md b/.github/DEPLOY_VERIFY_REFUND_TEMPLATE.md new file mode 100644 index 0000000000000..524f04c1a33b9 --- /dev/null +++ b/.github/DEPLOY_VERIFY_REFUND_TEMPLATE.md @@ -0,0 +1,20 @@ +--- +title: "chore: refund the testnet deploy+verify deployer" +labels: P-normal, T-chore +--- + +The shared deployer for the nightly on-chain deploy + verify workflow is running low on at least one network. The tests may still be passing right now; this is raised early so the account can be topped up before they start failing. + +Refund `{{ env.DEPLOYER }}` on: + +``` +{{ env.LOW_NETWORKS }} +``` + +Each entry is `NETWORK=current(min threshold)`. Thresholds are set at roughly twenty more runs at the measured per-run burn, so there is room to act. Monad testnet drains fastest at about 0.08 MON per run; the L2s are effectively free and should only appear here if something has gone wrong. + +The key lives in the `FUNDED_TESTNET_PKEY` secret and holds testnet funds only. Public faucets cover every network involved. + +Check the [deploy + verify workflow page]({{ env.WORKFLOW_URL }}) for the full balance report. + +This issue was raised by the workflow at `.github/workflows/test-deploy-verify.yml`. diff --git a/.github/FLAKY_TEST_FAILURE_TEMPLATE.md b/.github/FLAKY_TEST_FAILURE_TEMPLATE.md new file mode 100644 index 0000000000000..1553914b7ad3f --- /dev/null +++ b/.github/FLAKY_TEST_FAILURE_TEMPLATE.md @@ -0,0 +1,10 @@ +--- +title: "bug: flaky tests workflow failed" +labels: P-normal, T-bug +--- + +The nightly flaky tests workflow has failed. This indicates external API rate limiting, RPC reliability issues, or other intermittent failures that may affect users. + +Check the [flaky tests workflow page]({{ env.WORKFLOW_URL }}) for details. + +This issue was raised by the workflow at `.github/workflows/test-flaky.yml`. diff --git a/.github/ISSUE_TEMPLATE/BUG-FORM.yml b/.github/ISSUE_TEMPLATE/BUG-FORM.yml index 5a5f7e7808994..281cf2fd254ff 100644 --- a/.github/ISSUE_TEMPLATE/BUG-FORM.yml +++ b/.github/ISSUE_TEMPLATE/BUG-FORM.yml @@ -1,58 +1,61 @@ name: Bug report description: File a bug report +type: Bug labels: ["T-bug", "T-needs-triage"] body: - - type: markdown - attributes: - value: | - Please ensure that the bug has not already been filed in the issue tracker. + - type: markdown + attributes: + value: | + Please ensure that the bug has not already been filed in the issue tracker. - Thanks for taking the time to report this bug! - - type: dropdown - attributes: - label: Component - description: What component is the bug in? - multiple: true - options: - - Forge - - Cast - - Anvil - - Foundryup - - Chisel - - Other (please describe) - validations: - required: true - - type: checkboxes - attributes: - label: Have you ensured that all of these are up to date? - options: - - label: Foundry - - label: Foundryup - - type: input - attributes: - label: What version of Foundry are you on? - placeholder: "Run forge --version and paste the output here" - - type: input - attributes: - label: What version of Foundryup are you on? - placeholder: "Run foundryup --version and paste the output here" - - type: input - attributes: - label: What command(s) is the bug in? - description: Leave empty if not relevant - placeholder: "For example: forge test" - - type: dropdown - attributes: - label: Operating System - description: What operating system are you on? - options: - - Windows - - macOS (Intel) - - macOS (Apple Silicon) - - Linux - - type: textarea - attributes: - label: Describe the bug - description: Please include relevant Solidity snippets as well if relevant. - validations: - required: true \ No newline at end of file + Thanks for taking the time to report this bug! + - type: dropdown + id: component + attributes: + label: Component + description: What component is the bug in? + multiple: true + options: + - Forge + - Cast + - Anvil + - Foundryup + - Chisel + - Other (please describe) + validations: + required: true + - type: checkboxes + attributes: + label: Have you ensured that all of these are up to date? + options: + - label: Foundry + - label: Foundryup + - type: input + attributes: + label: What version of Foundry are you on? + placeholder: "Run forge --version and paste the output here" + - type: input + attributes: + label: What version of Foundryup are you on? + placeholder: "Run foundryup --version and paste the output here" + - type: input + attributes: + label: What command(s) is the bug in? + description: Leave empty if not relevant + placeholder: "For example: forge test" + - type: dropdown + attributes: + label: Operating System + description: What operating system are you on? + options: + - Windows + - macOS (Intel) + - macOS (Apple Silicon) + - Linux + - type: textarea + id: description + attributes: + label: Describe the bug + description: Please include relevant Solidity snippets as well if relevant. + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/FEATURE-FORM.yml b/.github/ISSUE_TEMPLATE/FEATURE-FORM.yml index 581eb5c0d5eed..4268a73baa8b6 100644 --- a/.github/ISSUE_TEMPLATE/FEATURE-FORM.yml +++ b/.github/ISSUE_TEMPLATE/FEATURE-FORM.yml @@ -1,34 +1,37 @@ name: Feature request description: Suggest a feature +type: Feature labels: ["T-feature", "T-needs-triage"] body: - - type: markdown - attributes: - value: | - Please ensure that the feature has not already been requested in the issue tracker. + - type: markdown + attributes: + value: | + Please ensure that the feature has not already been requested in the issue tracker. - Thanks for helping us improve Foundry! - - type: dropdown - attributes: - label: Component - description: What component is the feature for? - multiple: true - options: - - Forge - - Cast - - Anvil - - Foundryup - - Chisel - - Other (please describe) - validations: - required: true - - type: textarea - attributes: - label: Describe the feature you would like - description: Please also describe what the feature is aiming to solve, if relevant. - validations: - required: true - - type: textarea - attributes: - label: Additional context - description: Add any other context to the feature (like screenshots, resources) + Thanks for helping us improve Foundry! + - type: dropdown + id: component + attributes: + label: Component + description: What component is the feature for? + multiple: true + options: + - Forge + - Cast + - Anvil + - Foundryup + - Chisel + - Other (please describe) + validations: + required: true + - type: textarea + id: description + attributes: + label: Describe the feature you would like + description: Please also describe what the feature is aiming to solve, if relevant. + validations: + required: true + - type: textarea + attributes: + label: Additional context + description: Add any other context to the feature (like screenshots, resources) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 393554ad7ebeb..3660fa19ab9d9 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -4,7 +4,7 @@ the requirements below. Bug fixes and new features should include tests. -Contributors guide: https://github.com/foundry-rs/foundry/blob/master/CONTRIBUTING.md +Contributors guide: https://github.com/foundry-rs/foundry/blob/HEAD/CONTRIBUTING.md The contributors guide includes instructions for running rustfmt and building the documentation. diff --git a/.github/TEMPO_NIGHTLY_FAILURE_TEMPLATE.md b/.github/TEMPO_NIGHTLY_FAILURE_TEMPLATE.md new file mode 100644 index 0000000000000..8f72899c63611 --- /dev/null +++ b/.github/TEMPO_NIGHTLY_FAILURE_TEMPLATE.md @@ -0,0 +1,10 @@ +--- +title: "bug: tempo nightly workflow failed" +labels: P-high, T-bug +--- + +The nightly Tempo workflow (mainnet and testnet checks) has failed. This indicates a regression in Foundry's Tempo support or an issue with the Tempo mainnet/testnet endpoints. + +Check the [tempo nightly workflow page]({{ env.WORKFLOW_URL }}) for details. + +This issue was raised by the workflow at `.github/workflows/ci-tempo.yml`. diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 0000000000000..1c190da815cdd --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,21 @@ +self-hosted-runner: + labels: + - depot-ubuntu-22.04-16 + - depot-ubuntu-22.04-arm-16 + - depot-ubuntu-24.04-4 + - depot-ubuntu-24.04-32 + - depot-ubuntu-latest-arm-16 + - depot-ubuntu-latest + - depot-ubuntu-latest-16 + - depot-macos-latest + - depot-windows-latest-16 + +config-variables: null + +paths: + .github/workflows/finalize-release.yml: + ignore: + - '^unexpected key "queue" for "concurrency" section\.' + .github/workflows/release.yml: + ignore: + - '^unexpected key "queue" for "concurrency" section\.' diff --git a/.github/benchmark-regression-thresholds.json b/.github/benchmark-regression-thresholds.json new file mode 100644 index 0000000000000..1504d48c0b4b6 --- /dev/null +++ b/.github/benchmark-regression-thresholds.json @@ -0,0 +1,20 @@ +{ + "schema_version": 1, + "kind": "foundry-nightly-benchmark-thresholds", + "calibration": { + "status": "provisional", + "snapshot": "Six retained same-run stable/nightly artifacts from 2026-07-08 through 2026-07-13." + }, + "benchmarks": { + "forge_build_no_cache/aave-aave-v4": {"warning_percent": 1, "regression_percent": 2, "alert": true}, + "forge_build_with_cache/aave-aave-v4": {"warning_percent": 5, "regression_percent": 10, "alert": true}, + "forge_test/aave-aave-v4": {"warning_percent": 5, "regression_percent": 10, "alert": true}, + "forge_fuzz_test/aave-aave-v4": {"warning_percent": 7.5, "regression_percent": 12.5, "alert": true}, + "forge_coverage/aave-aave-v4": { + "warning_percent": 5, + "regression_percent": 10, + "alert": false, + "note": "Advisory only: one timing sample per side." + } + } +} diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000000..a8cad1212c62c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,58 @@ +version: 2 +multi-ecosystem-groups: + code-weekly: + open-pull-requests-limit: 5 + schedule: + interval: weekly + day: monday + time: "09:00" + timezone: UTC +updates: + - package-ecosystem: npm + directory: /editors/vscode + patterns: ["*"] + multi-ecosystem-group: code-weekly + cooldown: + default-days: 7 + - package-ecosystem: cargo + directory: /editors/zed + allow: + - dependency-type: all + patterns: ["*"] + multi-ecosystem-group: code-weekly + cooldown: + default-days: 7 + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + time: "09:00" + timezone: UTC + cooldown: + default-days: 7 + groups: + ci-weekly: + patterns: + - '*' + - package-ecosystem: cargo + directory: / + allow: + - dependency-type: all + patterns: ["*"] + multi-ecosystem-group: code-weekly + cooldown: + default-days: 7 + ignore: + - dependency-name: syn + update-types: + - version-update:semver-major + - dependency-name: prettyplease + update-types: + - version-update:semver-minor + - dependency-name: p256 + update-types: + - version-update:semver-minor + - dependency-name: ecdsa + update-types: + - version-update:semver-minor diff --git a/.github/scripts/benchmark_clickhouse.py b/.github/scripts/benchmark_clickhouse.py new file mode 100644 index 0000000000000..54f085ea56f75 --- /dev/null +++ b/.github/scripts/benchmark_clickhouse.py @@ -0,0 +1,477 @@ +#!/usr/bin/env python3 +"""Build Foundry benchmark manifests and ingest trusted artifacts into ClickHouse.""" + +import argparse +import datetime as dt +import hashlib +import json +import math +import os +import pathlib +import platform +import re +import subprocess +import sys +import urllib.error +import urllib.parse +import urllib.request + + +MAX_JSON_BYTES = 16 * 1024 * 1024 +MAX_SAMPLES = 10_000 +RUN_SCHEMA = "foundry-benchmark-run/v1" +SUITE_SCHEMA = "foundry-benchmark-suite/v1" +INGESTER_VERSION = 1 +SHA_RE = re.compile(r"^[0-9a-f]{40}$") +IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +EXPECTED_SUITES = { + "ci:test": {"forge_test", "forge_fuzz_test"}, + "ci:isolate": {"forge_isolate_test"}, + "ci:build": {"forge_build_no_cache", "forge_build_with_cache"}, + "ci:coverage": {"forge_coverage"}, +} + + +def _reject_duplicate_keys(pairs): + obj = {} + for key, value in pairs: + if key in obj: + raise ValueError(f"duplicate JSON key: {key}") + obj[key] = value + return obj + + +def _reject_non_finite(value): + raise ValueError(f"non-finite JSON number: {value}") + + +def load_json(path): + path = pathlib.Path(path) + size = path.stat().st_size + if size > MAX_JSON_BYTES: + raise ValueError(f"{path} exceeds {MAX_JSON_BYTES} bytes") + with path.open(encoding="utf-8") as handle: + return json.load( + handle, + object_pairs_hook=_reject_duplicate_keys, + parse_constant=_reject_non_finite, + ) + + +def _command_version(*command): + return subprocess.run(command, check=True, capture_output=True, text=True).stdout.strip() + + +def _cpu_model(): + if sys.platform == "darwin": + return _command_version("sysctl", "-n", "machdep.cpu.brand_string") + cpuinfo = pathlib.Path("/proc/cpuinfo") + if cpuinfo.exists(): + for line in cpuinfo.read_text(encoding="utf-8").splitlines(): + if line.startswith("model name"): + return line.split(":", 1)[1].strip() + return platform.processor() or "unknown" + + +def build_manifest(fragment_paths, baseline, candidate): + if not SHA_RE.fullmatch(baseline) or not SHA_RE.fullmatch(candidate): + raise ValueError("baseline and candidate must be full lowercase Git commit SHAs") + + suites = [] + expected_keys = set() + for path in fragment_paths: + suite = load_json(path) + if suite.get("schema") != SUITE_SCHEMA: + raise ValueError(f"unsupported suite schema in {path}") + if not isinstance(suite.get("suite"), str) or not isinstance(suite.get("cases"), list): + raise ValueError(f"invalid suite manifest in {path}") + for case in suite["cases"]: + case_id = case.get("id") + if not isinstance(case_id, str) or not case_id: + raise ValueError(f"invalid benchmark case in {path}") + if case_id in expected_keys: + raise ValueError(f"duplicate benchmark case: {case_id}") + if set(case.get("versions", [])) != {"master", "local"}: + raise ValueError(f"benchmark case does not contain both comparison sides: {case_id}") + expected_keys.add(case_id) + suites.append(suite) + + if not expected_keys: + raise ValueError("benchmark manifest contains no cases") + + repository = os.environ["GITHUB_REPOSITORY"] + server_url = os.environ.get("GITHUB_SERVER_URL", "https://github.com") + run_id = int(os.environ["GITHUB_RUN_ID"]) + run_attempt = int(os.environ["GITHUB_RUN_ATTEMPT"]) + branch = os.environ.get("GITHUB_REF_NAME") + now = dt.datetime.now(dt.timezone.utc).isoformat().replace("+00:00", "Z") + return { + "schema": RUN_SCHEMA, + "project": "foundry", + "run": { + "repository": repository, + "workflow": os.environ.get("GITHUB_WORKFLOW", "Foundry Benchmarks"), + "workflow_file": ".github/workflows/benchmarks.yml", + "run_id": run_id, + "run_attempt": run_attempt, + "run_url": f"{server_url}/{repository}/actions/runs/{run_id}", + "event": os.environ.get("GITHUB_EVENT_NAME", "workflow_dispatch"), + "branch": branch, + "generated_at": now, + }, + "comparison": { + "kind": "source_vs_master", + "baseline_ref": "master", + "baseline_commit": baseline, + "candidate_ref": branch, + "candidate_commit": candidate, + }, + "environment": { + "build_profile": os.environ.get("FOUNDRY_BENCH_LOCAL_BUILD_PROFILE", "profiling"), + "runner_os": os.environ.get("RUNNER_OS", platform.system()), + "runner_arch": os.environ.get("RUNNER_ARCH", platform.machine()), + "runner_name": os.environ.get("RUNNER_NAME"), + "cpu_model": _cpu_model(), + "cpu_count": os.cpu_count(), + "rustc": _command_version("rustc", "--version"), + "hyperfine": _command_version("hyperfine", "--version"), + }, + "expected_result_keys": sorted(expected_keys), + "suites": suites, + } + + +def _require_string(value, field, *, allow_empty=False): + if not isinstance(value, str) or (not allow_empty and not value): + raise ValueError(f"{field} must be a non-empty string") + return value + + +def _require_sha(value, field): + value = _require_string(value, field) + if not SHA_RE.fullmatch(value): + raise ValueError(f"{field} must be a full lowercase Git commit SHA") + return value + + +def _number(value, field, *, nullable=False): + if value is None and nullable: + return None + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"{field} must be numeric") + value = float(value) + if not math.isfinite(value) or value < 0: + raise ValueError(f"{field} must be finite and non-negative") + return value + + +def _canonical_hash(value): + encoded = json.dumps(value, sort_keys=True, separators=(",", ":")).encode() + return hashlib.sha256(encoded).hexdigest() + + +def _trusted_metadata(): + required = [ + "TRUSTED_REPOSITORY", + "TRUSTED_WORKFLOW_FILE", + "TRUSTED_DEFAULT_BRANCH", + "TRUSTED_RUN_ID", + "TRUSTED_RUN_ATTEMPT", + "TRUSTED_RUN_URL", + "TRUSTED_HEAD_SHA", + "TRUSTED_RUN_STARTED_AT", + ] + missing = [name for name in required if not os.environ.get(name)] + if missing: + raise ValueError(f"missing trusted workflow metadata: {', '.join(missing)}") + started = dt.datetime.fromisoformat( + os.environ["TRUSTED_RUN_STARTED_AT"].replace("Z", "+00:00") + ).astimezone(dt.timezone.utc) + return { + "repository": os.environ["TRUSTED_REPOSITORY"], + "workflow_file": os.environ["TRUSTED_WORKFLOW_FILE"], + "default_branch": os.environ["TRUSTED_DEFAULT_BRANCH"], + "run_id": int(os.environ["TRUSTED_RUN_ID"]), + "run_attempt": int(os.environ["TRUSTED_RUN_ATTEMPT"]), + "run_url": os.environ["TRUSTED_RUN_URL"], + "head_sha": _require_sha(os.environ["TRUSTED_HEAD_SHA"], "trusted head SHA"), + "branch": os.environ.get("TRUSTED_HEAD_BRANCH") or None, + "started_at": started.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3], + "check_baseline_ancestor": True, + } + + +def build_rows(artifact_dir, trusted=None): + artifact_dir = pathlib.Path(artifact_dir) + trusted = trusted or _trusted_metadata() + manifest = load_json(artifact_dir / "benchmark-manifest.json") + baseline_summary = load_json(artifact_dir / "base-summary.json") + candidate_summary = load_json(artifact_dir / "candidate-summary.json") + + if manifest.get("schema") != RUN_SCHEMA or manifest.get("project") != "foundry": + raise ValueError("unsupported benchmark run manifest") + run = manifest.get("run", {}) + comparison = manifest.get("comparison", {}) + if run.get("repository") != trusted["repository"]: + raise ValueError("artifact repository does not match triggering workflow") + if run.get("workflow_file") != trusted["workflow_file"]: + raise ValueError("artifact came from an unexpected workflow") + if run.get("event") != "workflow_dispatch": + raise ValueError("artifact came from an unexpected event") + if run.get("run_id") != trusted["run_id"]: + raise ValueError("artifact run identity does not match triggering workflow") + artifact_attempt = run.get("run_attempt") + if ( + isinstance(artifact_attempt, bool) + or not isinstance(artifact_attempt, int) + or not 1 <= artifact_attempt <= trusted["run_attempt"] + ): + raise ValueError("artifact run attempt does not match triggering workflow") + + baseline_commit = _require_sha(comparison.get("baseline_commit"), "baseline commit") + candidate_commit = _require_sha(comparison.get("candidate_commit"), "candidate commit") + if candidate_commit != trusted["head_sha"]: + raise ValueError("candidate commit does not match triggering workflow") + if comparison.get("kind") != "source_vs_master": + raise ValueError("unsupported comparison kind") + if comparison.get("baseline_ref") != trusted["default_branch"]: + raise ValueError("baseline ref does not match the default branch") + if comparison.get("candidate_ref") != trusted["branch"]: + raise ValueError("candidate ref does not match the triggering branch") + if trusted.get("check_baseline_ancestor"): + ancestor = subprocess.run( + ["git", "merge-base", "--is-ancestor", baseline_commit, "HEAD"], + check=False, + ) + if ancestor.returncode != 0: + raise ValueError("baseline commit is not an ancestor of the current default branch") + + expected = manifest.get("expected_result_keys") + if not isinstance(expected, list) or not expected or any(not isinstance(key, str) for key in expected): + raise ValueError("manifest has invalid expected result keys") + if len(expected) != len(set(expected)): + raise ValueError("manifest contains duplicate expected result keys") + expected_set = set(expected) + if set(baseline_summary) != expected_set or set(candidate_summary) != expected_set: + raise ValueError("baseline and candidate summaries must exactly match the manifest") + + cases = {} + suites = manifest.get("suites", []) + if not isinstance(suites, list): + raise ValueError("manifest suites must be an array") + seen_suites = set() + for suite in suites: + if suite.get("schema") != SUITE_SCHEMA: + raise ValueError("unsupported suite schema") + suite_name = suite.get("suite") + if suite_name in seen_suites: + raise ValueError(f"duplicate benchmark suite: {suite_name}") + if suite_name not in EXPECTED_SUITES: + raise ValueError(f"unexpected benchmark suite: {suite_name}") + seen_suites.add(suite_name) + suite_benchmarks = {case.get("benchmark") for case in suite.get("cases", [])} + if suite_benchmarks != EXPECTED_SUITES[suite_name]: + raise ValueError(f"benchmark suite is incomplete: {suite_name}") + for case in suite.get("cases", []): + case_id = case.get("id") + if case_id in cases: + raise ValueError(f"duplicate benchmark case: {case_id}") + cases[case_id] = case + if seen_suites != set(EXPECTED_SUITES): + raise ValueError("benchmark manifest does not contain every required suite") + if set(cases) != expected_set: + raise ValueError("suite cases must exactly match expected result keys") + + rows = [] + environment = manifest.get("environment", {}) + cpu_count = environment.get("cpu_count") + if isinstance(cpu_count, bool) or not isinstance(cpu_count, int) or not 1 <= cpu_count <= 65535: + raise ValueError("CPU count must be an integer between 1 and 65535") + for side_role, summary, tool_ref, tool_commit, tool_channel in [ + ("baseline", baseline_summary, comparison.get("baseline_ref"), baseline_commit, "master"), + ("candidate", candidate_summary, comparison.get("candidate_ref"), candidate_commit, "local"), + ]: + for case_id in sorted(expected): + case = cases[case_id] + result = summary[case_id] + if not isinstance(result, dict): + raise ValueError(f"result must be an object: {case_id}") + workload_commit = _require_sha(case.get("workload_commit"), "workload commit") + samples = result.get("times") + if not isinstance(samples, list) or not samples or len(samples) > MAX_SAMPLES: + raise ValueError(f"invalid timing samples: {case_id}") + samples = [_number(value, f"{case_id}.times") for value in samples] + exit_codes = result.get("exit_codes") or [] + if not isinstance(exit_codes, list) or any( + isinstance(code, bool) + or not isinstance(code, int) + or not -(2**31) <= code < 2**31 + for code in exit_codes + ): + raise ValueError(f"invalid exit codes: {case_id}") + symbolic = result.get("symbolic") or {} + if not isinstance(symbolic, dict) or any( + isinstance(value, bool) + or not isinstance(value, int) + or not 0 <= value < 2**64 + for value in symbolic.values() + ): + raise ValueError(f"invalid symbolic counters: {case_id}") + + configuration = { + "benchmark": case.get("benchmark"), + "workload_repository": case.get("workload_repository"), + "workload_requested_ref": case.get("workload_requested_ref"), + "workload_commit": workload_commit, + "arguments": case.get("arguments"), + } + identity = { + "project": "foundry", + "repository": trusted["repository"], + "workflow_file": run["workflow_file"], + "run_id": trusted["run_id"], + "run_attempt": artifact_attempt, + "side_role": side_role, + "benchmark_case_id": case_id, + "workload_repository": case.get("workload_repository"), + "workload_commit": workload_commit, + } + rows.append({ + "project": "foundry", + "github_repository": trusted["repository"], + "workflow_file": run["workflow_file"], + "workflow_run_id": trusted["run_id"], + "workflow_run_attempt": artifact_attempt, + "workflow_run_url": trusted["run_url"], + "run_started_at": trusted["started_at"], + "branch": trusted.get("branch"), + "pr_number": None, + "comparison_kind": comparison["kind"], + "side_role": side_role, + "tool_channel": tool_channel, + "tool_ref": _require_string(tool_ref, f"{side_role} ref"), + "tool_commit": tool_commit, + "benchmark_case_id": case_id, + "benchmark_name": _require_string(case.get("benchmark"), "benchmark name"), + "benchmark_config_hash": _canonical_hash(configuration), + "workload_repository": _require_string( + case.get("workload_repository"), "workload repository" + ), + "workload_requested_ref": _require_string( + case.get("workload_requested_ref"), "workload requested ref" + ), + "workload_commit": workload_commit, + "mean_seconds": _number(result.get("mean"), f"{case_id}.mean"), + "stddev_seconds": _number( + result.get("stddev"), f"{case_id}.stddev", nullable=True + ), + "median_seconds": _number(result.get("median"), f"{case_id}.median"), + "user_seconds": _number(result.get("user"), f"{case_id}.user"), + "system_seconds": _number(result.get("system"), f"{case_id}.system"), + "min_seconds": _number(result.get("min"), f"{case_id}.min"), + "max_seconds": _number(result.get("max"), f"{case_id}.max"), + "samples_seconds": samples, + "exit_codes": exit_codes, + "parameters_json": json.dumps( + result.get("parameters"), sort_keys=True, separators=(",", ":") + ) if result.get("parameters") is not None else "", + "counters_u64": symbolic, + "metrics_f64": {}, + "build_profile": _require_string(environment.get("build_profile"), "build profile"), + "runner_os": _require_string(environment.get("runner_os"), "runner OS"), + "runner_arch": _require_string(environment.get("runner_arch"), "runner architecture"), + "cpu_model": _require_string(environment.get("cpu_model"), "CPU model"), + "cpu_count": cpu_count, + "rustc_version": _require_string(environment.get("rustc"), "rustc version"), + "hyperfine_version": _require_string( + environment.get("hyperfine"), "hyperfine version" + ), + "manifest_schema": RUN_SCHEMA, + "ingester_version": INGESTER_VERSION, + "result_id": _canonical_hash(identity), + }) + return rows + + +def upload_rows(rows): + host = os.environ.get("CLICKHOUSE_HOST") + user = os.environ.get("CLICKHOUSE_USER") + password = os.environ.get("CLICKHOUSE_PASSWORD") + if not host or not user or not password: + raise ValueError( + "CLICKHOUSE_HOST, CLICKHOUSE_USER, and CLICKHOUSE_PASSWORD are required" + ) + base_url = host if "://" in host else f"https://{host}:8443" + parsed = urllib.parse.urlsplit(base_url) + if parsed.scheme != "https" or not parsed.netloc: + raise ValueError("CLICKHOUSE_HOST must identify an HTTPS endpoint") + table = os.environ.get("CLICKHOUSE_TABLE", "benchmark_results") + if not IDENTIFIER_RE.fullmatch(table): + raise ValueError("CLICKHOUSE_TABLE must be an unquoted ClickHouse identifier") + query = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True) + query.extend([ + ("database", os.environ.get("CLICKHOUSE_DATABASE", "default")), + ("query", f"INSERT INTO {table} FORMAT JSONEachRow"), + ]) + url = urllib.parse.urlunsplit( + (parsed.scheme, parsed.netloc, parsed.path or "/", urllib.parse.urlencode(query), "") + ) + body = "".join( + json.dumps(row, separators=(",", ":")) + "\n" for row in rows + ).encode() + request = urllib.request.Request( + url, + data=body, + method="POST", + headers={ + "Content-Type": "application/x-ndjson", + "X-ClickHouse-User": user, + "X-ClickHouse-Key": password, + }, + ) + try: + with urllib.request.urlopen(request, timeout=60) as response: + response.read() + except urllib.error.HTTPError as error: + detail = error.read(4096).decode(errors="replace") + raise RuntimeError(f"ClickHouse insert failed ({error.code}): {detail}") from error + + +def main(): + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command", required=True) + + manifest_parser = subparsers.add_parser("manifest") + manifest_parser.add_argument("fragments", nargs="+") + manifest_parser.add_argument("--output", required=True) + manifest_parser.add_argument("--baseline", required=True) + manifest_parser.add_argument("--candidate", required=True) + + rows_parser = subparsers.add_parser("rows") + rows_parser.add_argument("artifact_dir") + rows_parser.add_argument("--output", default="-") + + upload_parser = subparsers.add_parser("upload") + upload_parser.add_argument("artifact_dir") + + args = parser.parse_args() + if args.command == "manifest": + manifest = build_manifest(args.fragments, args.baseline, args.candidate) + output = pathlib.Path(args.output) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + elif args.command == "rows": + rows = build_rows(args.artifact_dir) + content = "".join(json.dumps(row, separators=(",", ":")) + "\n" for row in rows) + if args.output == "-": + sys.stdout.write(content) + else: + pathlib.Path(args.output).write_text(content, encoding="utf-8") + else: + rows = build_rows(args.artifact_dir) + upload_rows(rows) + print(f"Inserted {len(rows)} benchmark rows into ClickHouse.") + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/bump-solar.sh b/.github/scripts/bump-solar.sh new file mode 100755 index 0000000000000..dbc1ae8fcdedf --- /dev/null +++ b/.github/scripts/bump-solar.sh @@ -0,0 +1,148 @@ +#!/bin/bash +set -euo pipefail + +# Bump all dependencies from the paradigmxyz/solar repository to the latest commit on main. +# All solar dependencies share the same commit revision. +# +# Usage: +# ./bump-solar.sh [--dry-run] +# +# Requirements: +# - gh (GitHub CLI) must be installed and authenticated +# +# Outputs (for GitHub Actions): +# Sets outputs in $GITHUB_OUTPUT if it exists: +# - current_rev: The current solar revision +# - latest_rev: The latest solar revision on main +# - latest_rev_short: The short latest solar revision +# - updated: "true" if dependencies were updated, "false" otherwise +# - changelog: Path to changelog file (if updated) + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +CARGO_TOML="$REPO_ROOT/Cargo.toml" + +DRY_RUN=false +if [[ "${1:-}" == "--dry-run" ]]; then + DRY_RUN=true + echo "Running in dry-run mode (no changes will be made)" +fi + +# Get the current solar revision from any dependency using paradigmxyz/solar +# They all share the same rev, so we just grab the first one +get_current_rev() { + grep -m 1 'git = "https://github.com/paradigmxyz/solar"' "$CARGO_TOML" | sed 's/.*rev = "\([^"]*\)".*/\1/' +} + +# Get the latest commit SHA from paradigmxyz/solar main branch +get_latest_rev() { + gh api repos/paradigmxyz/solar/commits/main --jq '.sha' +} + +# Generate changelog between two revisions +generate_changelog() { + local old_rev="$1" + local new_rev="$2" + local output_file="$3" + + echo "Fetching commits from ${old_rev:0:7} to ${new_rev:0:7}..." + + local commits + # shellcheck disable=SC2016 # Single quotes intentional for jq expression + commits=$(gh api "repos/paradigmxyz/solar/compare/${old_rev}...${new_rev}" \ + --jq '.commits[] | "- [`\(.sha[0:7])`](https://github.com/paradigmxyz/solar/commit/\(.sha)) \(.commit.message | split("\n")[0] | gsub("#(?[0-9]+)"; "[#\(.number)](https://github.com/paradigmxyz/solar/issues/\(.number))"))"') + + { + echo "## Solar Dependency Updates" + echo "" + echo "Bumped all \`solar*\` dependencies from [\`${old_rev:0:7}\`](https://github.com/paradigmxyz/solar/commit/${old_rev}) to [\`${new_rev:0:7}\`](https://github.com/paradigmxyz/solar/commit/${new_rev})." + echo "" + echo "### Commits" + echo "" + echo "$commits" + } > "$output_file" + + echo "Changelog written to $output_file" +} + +# Update Cargo.toml with new revision +update_cargo_toml() { + local old_rev="$1" + local new_rev="$2" + + sed -i "s|git = \"https://github.com/paradigmxyz/solar\", rev = \"$old_rev\"|git = \"https://github.com/paradigmxyz/solar\", rev = \"$new_rev\"|g" "$CARGO_TOML" + echo "Updated Cargo.toml: $old_rev -> $new_rev" +} + +# Resolve the new revision while preserving unrelated locked dependencies. +regenerate_lockfile() { + echo "" + echo "Updating Cargo.lock..." + if cargo update --workspace 2>&1; then + echo "Cargo.lock updated successfully" + set_output "lockfile_updated" "true" + else + echo "ERROR: Failed to update Cargo.lock" + set_output "lockfile_updated" "false" + return 1 + fi +} + +# Set GitHub Actions output if running in CI +set_output() { + local name="$1" + local value="$2" + + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "$name=$value" >> "$GITHUB_OUTPUT" + fi +} + +main() { + echo "=== Bump Solar Dependencies ===" + echo "" + + CURRENT_REV=$(get_current_rev) + echo "Current revision: $CURRENT_REV" + set_output "current_rev" "$CURRENT_REV" + + LATEST_REV=$(get_latest_rev) + echo "Latest revision: $LATEST_REV" + set_output "latest_rev" "$LATEST_REV" + set_output "latest_rev_short" "${LATEST_REV:0:7}" + + if [[ "$CURRENT_REV" == "$LATEST_REV" ]]; then + echo "" + echo "Already up to date. No changes needed." + set_output "updated" "false" + exit 0 + fi + + echo "" + echo "Update available: ${CURRENT_REV:0:7} -> ${LATEST_REV:0:7}" + + CHANGELOG_FILE="$REPO_ROOT/solar-changelog.md" + + echo "" + generate_changelog "$CURRENT_REV" "$LATEST_REV" "$CHANGELOG_FILE" + set_output "changelog" "$CHANGELOG_FILE" + + if [[ "$DRY_RUN" == "true" ]]; then + echo "" + echo "[DRY RUN] Would update Cargo.toml" + echo "[DRY RUN] Would update Cargo.lock, preserving unrelated dependencies" + echo "" + echo "=== Changelog Preview ===" + cat "$CHANGELOG_FILE" + else + update_cargo_toml "$CURRENT_REV" "$LATEST_REV" + regenerate_lockfile + fi + + set_output "updated" "true" + + echo "" + echo "=== Done ===" +} + +main "$@" diff --git a/.github/scripts/bump-tempo.sh b/.github/scripts/bump-tempo.sh new file mode 100755 index 0000000000000..55c77d4bf7147 --- /dev/null +++ b/.github/scripts/bump-tempo.sh @@ -0,0 +1,148 @@ +#!/bin/bash +set -euo pipefail + +# Bump all dependencies from the tempoxyz/tempo repository to the latest commit on main. +# All tempo dependencies share the same commit revision. +# +# Usage: +# ./bump-tempo.sh [--dry-run] +# +# Requirements: +# - gh (GitHub CLI) must be installed and authenticated +# +# Outputs (for GitHub Actions): +# Sets outputs in $GITHUB_OUTPUT if it exists: +# - current_rev: The current tempo revision +# - latest_rev: The latest tempo revision on main +# - latest_rev_short: The short latest tempo revision +# - updated: "true" if dependencies were updated, "false" otherwise +# - changelog: Path to changelog file (if updated) + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +CARGO_TOML="$REPO_ROOT/Cargo.toml" + +DRY_RUN=false +if [[ "${1:-}" == "--dry-run" ]]; then + DRY_RUN=true + echo "Running in dry-run mode (no changes will be made)" +fi + +# Get the current tempo revision from any dependency using tempoxyz/tempo +# They all share the same rev, so we just grab the first one +get_current_rev() { + grep 'git = "https://github.com/tempoxyz/tempo"' "$CARGO_TOML" | head -1 | sed 's/.*rev = "\([^"]*\)".*/\1/' +} + +# Get the latest commit SHA from tempoxyz/tempo main branch +get_latest_rev() { + gh api repos/tempoxyz/tempo/commits/main --jq '.sha' +} + +# Generate changelog between two revisions +generate_changelog() { + local old_rev="$1" + local new_rev="$2" + local output_file="$3" + + echo "Fetching commits from ${old_rev:0:7} to ${new_rev:0:7}..." + + local commits + # shellcheck disable=SC2016 # Single quotes intentional for jq expression + commits=$(gh api "repos/tempoxyz/tempo/compare/${old_rev}...${new_rev}" \ + --jq '.commits[] | "- [`\(.sha[0:7])`](https://github.com/tempoxyz/tempo/commit/\(.sha)) \(.commit.message | split("\n")[0] | gsub("#(?[0-9]+)"; "[#\(.number)](https://github.com/tempoxyz/tempo/issues/\(.number))"))"') + + { + echo "## Tempo Dependency Updates" + echo "" + echo "Bumped all \`tempo*\` dependencies from [\`${old_rev:0:7}\`](https://github.com/tempoxyz/tempo/commit/${old_rev}) to [\`${new_rev:0:7}\`](https://github.com/tempoxyz/tempo/commit/${new_rev})." + echo "" + echo "### Commits" + echo "" + echo "$commits" + } > "$output_file" + + echo "Changelog written to $output_file" +} + +# Update Cargo.toml with new revision +update_cargo_toml() { + local old_rev="$1" + local new_rev="$2" + + sed -i "s|git = \"https://github.com/tempoxyz/tempo\", rev = \"$old_rev\"|git = \"https://github.com/tempoxyz/tempo\", rev = \"$new_rev\"|g" "$CARGO_TOML" + echo "Updated Cargo.toml: $old_rev -> $new_rev" +} + +# Resolve the new revision while preserving unrelated locked dependencies. +regenerate_lockfile() { + echo "" + echo "Updating Cargo.lock..." + if cargo update --workspace 2>&1; then + echo "Cargo.lock updated successfully" + set_output "lockfile_updated" "true" + else + echo "ERROR: Failed to update Cargo.lock" + set_output "lockfile_updated" "false" + return 1 + fi +} + +# Set GitHub Actions output if running in CI +set_output() { + local name="$1" + local value="$2" + + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "$name=$value" >> "$GITHUB_OUTPUT" + fi +} + +main() { + echo "=== Bump Tempo Dependencies ===" + echo "" + + CURRENT_REV=$(get_current_rev) + echo "Current revision: $CURRENT_REV" + set_output "current_rev" "$CURRENT_REV" + + LATEST_REV=$(get_latest_rev) + echo "Latest revision: $LATEST_REV" + set_output "latest_rev" "$LATEST_REV" + set_output "latest_rev_short" "${LATEST_REV:0:7}" + + if [[ "$CURRENT_REV" == "$LATEST_REV" ]]; then + echo "" + echo "Already up to date. No changes needed." + set_output "updated" "false" + exit 0 + fi + + echo "" + echo "Update available: ${CURRENT_REV:0:7} -> ${LATEST_REV:0:7}" + + CHANGELOG_FILE="$REPO_ROOT/tempo-changelog.md" + + echo "" + generate_changelog "$CURRENT_REV" "$LATEST_REV" "$CHANGELOG_FILE" + set_output "changelog" "$CHANGELOG_FILE" + + if [[ "$DRY_RUN" == "true" ]]; then + echo "" + echo "[DRY RUN] Would update Cargo.toml" + echo "[DRY RUN] Would update Cargo.lock, preserving unrelated dependencies" + echo "" + echo "=== Changelog Preview ===" + cat "$CHANGELOG_FILE" + else + update_cargo_toml "$CURRENT_REV" "$LATEST_REV" + regenerate_lockfile + fi + + set_output "updated" "true" + + echo "" + echo "=== Done ===" +} + +main "$@" diff --git a/.github/scripts/combine-benchmarks.sh b/.github/scripts/combine-benchmarks.sh index ccccb3fd53fcf..f3b9597373c34 100755 --- a/.github/scripts/combine-benchmarks.sh +++ b/.github/scripts/combine-benchmarks.sh @@ -12,7 +12,9 @@ mkdir -p "$OUTPUT_DIR" # Define the benchmark files and their section names declare -A BENCHMARK_FILES=( ["forge_test_bench.md"]="Forge Test" - ["forge_build_bench.md"]="Forge Build" + ["forge_isolate_test_bench.md"]="Forge Test (Isolated)" + ["forge_symbolic_bench.md"]="Forge Symbolic Test" + ["forge_build_bench.md"]="Forge Build" ["forge_coverage_bench.md"]="Forge Coverage" ) @@ -21,7 +23,7 @@ extract_section() { local file=$1 local section=$2 local in_section=0 - + while IFS= read -r line; do if [[ "$line" =~ ^##[[:space:]]+"$section" ]]; then in_section=1 @@ -40,14 +42,14 @@ extract_summary_info() { local in_summary=0 local in_repos=0 local in_versions=0 - + while IFS= read -r line; do # Check for Summary section if [[ "$line" =~ ^##[[:space:]]+Summary ]]; then in_summary=1 continue fi - + # Check for Repositories Tested subsection if [[ $in_summary -eq 1 && "$line" =~ ^###[[:space:]]+Repositories[[:space:]]+Tested ]]; then in_repos=1 @@ -55,7 +57,7 @@ extract_summary_info() { echo continue fi - + # Check for Foundry Versions subsection if [[ $in_summary -eq 1 && "$line" =~ ^###[[:space:]]+Foundry[[:space:]]+Versions ]]; then in_repos=0 @@ -64,12 +66,12 @@ extract_summary_info() { echo continue fi - + # End of summary section if [[ $in_summary -eq 1 && "$line" =~ ^##[[:space:]] && ! "$line" =~ ^##[[:space:]]+Summary ]]; then break fi - + # Output repo or version lines if [[ ($in_repos -eq 1 || $in_versions -eq 1) && -n "$line" ]]; then echo "$line" @@ -83,7 +85,7 @@ extract_benchmark_table() { local section=$2 local in_section=0 local found_table=0 - + while IFS= read -r line; do if [[ "$line" =~ ^##[[:space:]]+"$section" ]]; then in_section=1 @@ -110,7 +112,6 @@ extract_benchmark_table() { # Function to extract system information extract_system_info() { local file=$1 - # Extract from System Information to end of file (EOF) awk '/^## System Information/ { found=1; next } found { print }' "$file" } @@ -126,61 +127,45 @@ EOF FIRST_FILE=1 SYSTEM_INFO="" -for bench_file in "forge_test_bench.md" "forge_build_bench.md" "forge_coverage_bench.md"; do +for bench_file in "forge_test_bench.md" "forge_isolate_test_bench.md" "forge_symbolic_bench.md" "forge_build_bench.md" "forge_coverage_bench.md"; do if [ -f "$OUTPUT_DIR/$bench_file" ]; then echo "Processing $bench_file..." - - # Get the section name - case "$bench_file" in - "forge_test_bench.md") - SECTION_NAME="Forge Test" - ;; - "forge_build_bench.md") - SECTION_NAME="Forge Build" - ;; - "forge_coverage_bench.md") - SECTION_NAME="Forge Coverage" - ;; - esac - - # Add section header - echo "## $SECTION_NAME" >> "$OUTPUT_DIR/LATEST.md" - echo >> "$OUTPUT_DIR/LATEST.md" - - # Add summary info (repos and versions) - extract_summary_info "$OUTPUT_DIR/$bench_file" >> "$OUTPUT_DIR/LATEST.md" - echo >> "$OUTPUT_DIR/LATEST.md" - - # Handle different benchmark types - if [[ "$bench_file" == "forge_test_bench.md" ]]; then - # Extract both Forge Test and Forge Fuzz Test tables - extract_benchmark_table "$OUTPUT_DIR/$bench_file" "Forge Test" >> "$OUTPUT_DIR/LATEST.md" - - # Check if Forge Fuzz Test section exists - if grep -q "^## Forge Fuzz Test" "$OUTPUT_DIR/$bench_file"; then - echo >> "$OUTPUT_DIR/LATEST.md" - echo "## Forge Fuzz Test" >> "$OUTPUT_DIR/LATEST.md" - echo >> "$OUTPUT_DIR/LATEST.md" - extract_benchmark_table "$OUTPUT_DIR/$bench_file" "Forge Fuzz Test" >> "$OUTPUT_DIR/LATEST.md" + + SECTION_NAME="${BENCHMARK_FILES[$bench_file]:-$bench_file}" + + # Grouped output for ShellCheck SC2129 + { + # Add section header + echo "## $SECTION_NAME" + echo + + # Add summary info + extract_summary_info "$OUTPUT_DIR/$bench_file" + echo + + # Handle benchmark tables + if [[ "$bench_file" == "forge_test_bench.md" ]]; then + extract_benchmark_table "$OUTPUT_DIR/$bench_file" "Forge Test" + echo + if grep -q "^## Forge Fuzz Test" "$OUTPUT_DIR/$bench_file"; then + echo "## Forge Fuzz Test" + echo + extract_benchmark_table "$OUTPUT_DIR/$bench_file" "Forge Fuzz Test" + fi + elif [[ "$bench_file" == "forge_build_bench.md" ]]; then + echo "### No Cache" + echo + extract_benchmark_table "$OUTPUT_DIR/$bench_file" "Forge Build (No Cache)" + echo + echo "### With Cache" + echo + extract_benchmark_table "$OUTPUT_DIR/$bench_file" "Forge Build (With Cache)" + else + extract_benchmark_table "$OUTPUT_DIR/$bench_file" "$SECTION_NAME" fi - elif [[ "$bench_file" == "forge_build_bench.md" ]]; then - # Extract No Cache table - echo "### No Cache" >> "$OUTPUT_DIR/LATEST.md" - echo >> "$OUTPUT_DIR/LATEST.md" - extract_benchmark_table "$OUTPUT_DIR/$bench_file" "Forge Build (No Cache)" >> "$OUTPUT_DIR/LATEST.md" - echo >> "$OUTPUT_DIR/LATEST.md" - - # Extract With Cache table - echo "### With Cache" >> "$OUTPUT_DIR/LATEST.md" - echo >> "$OUTPUT_DIR/LATEST.md" - extract_benchmark_table "$OUTPUT_DIR/$bench_file" "Forge Build (With Cache)" >> "$OUTPUT_DIR/LATEST.md" - else - # Extract the benchmark table for other types - extract_benchmark_table "$OUTPUT_DIR/$bench_file" "$SECTION_NAME" >> "$OUTPUT_DIR/LATEST.md" - fi - - echo >> "$OUTPUT_DIR/LATEST.md" - + echo + } >> "$OUTPUT_DIR/LATEST.md" + # Extract system info from first file only if [[ $FIRST_FILE -eq 1 ]]; then SYSTEM_INFO=$(extract_system_info "$OUTPUT_DIR/$bench_file") @@ -193,9 +178,11 @@ done # Add system information at the end if [[ -n "$SYSTEM_INFO" ]]; then - echo "## System Information" >> "$OUTPUT_DIR/LATEST.md" - echo >> "$OUTPUT_DIR/LATEST.md" - echo "$SYSTEM_INFO" >> "$OUTPUT_DIR/LATEST.md" + { + echo "## System Information" + echo + echo "$SYSTEM_INFO" + } >> "$OUTPUT_DIR/LATEST.md" fi -echo "Successfully combined benchmark results into $OUTPUT_DIR/LATEST.md" \ No newline at end of file +echo "Successfully combined benchmark results into $OUTPUT_DIR/LATEST.md" diff --git a/.github/scripts/commit-and-read-benchmarks.sh b/.github/scripts/commit-and-read-benchmarks.sh deleted file mode 100755 index 358b53a73155a..0000000000000 --- a/.github/scripts/commit-and-read-benchmarks.sh +++ /dev/null @@ -1,114 +0,0 @@ -#!/bin/bash -set -euo pipefail - -# Script to commit benchmark results and read them for GitHub Actions output -# Usage: ./commit-and-read-benchmarks.sh - -OUTPUT_DIR="${1:-benches}" -GITHUB_EVENT_NAME="${2:-pull_request}" -GITHUB_REPOSITORY="${3:-}" - -# Global variable for branch name -BRANCH_NAME="" - -# Function to commit benchmark results -commit_results() { - echo "Configuring git..." - git config --local user.email "action@github.com" - git config --local user.name "GitHub Action" - - # For PR runs, fetch and checkout the PR branch to ensure we're up to date - if [ "$GITHUB_EVENT_NAME" = "pull_request" ] && [ -n "${GITHUB_HEAD_REF:-}" ]; then - echo "Fetching latest changes for PR branch: $GITHUB_HEAD_REF" - git fetch origin "$GITHUB_HEAD_REF" - git checkout -B "$GITHUB_HEAD_REF" "origin/$GITHUB_HEAD_REF" - fi - - echo "Adding benchmark file..." - git add "$OUTPUT_DIR/LATEST.md" - - if git diff --staged --quiet; then - echo "No changes to commit" - else - echo "Committing benchmark results..." - git commit -m "chore(\`benches\`): update benchmark results - -🤖 Generated with [Foundry Benchmarks](https://github.com/${GITHUB_REPOSITORY}/actions) - -Co-Authored-By: github-actions " - - echo "Pushing to repository..." - if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then - # For manual runs, we're on a new branch - git push origin "$BRANCH_NAME" - elif [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then - # For PR runs, push to the PR branch - if [ -n "${GITHUB_HEAD_REF:-}" ]; then - echo "Pushing to PR branch: $GITHUB_HEAD_REF" - git push origin "$GITHUB_HEAD_REF" - else - echo "Error: GITHUB_HEAD_REF not set for pull_request event" - exit 1 - fi - else - # This workflow should only run on workflow_dispatch or pull_request - echo "Error: Unexpected event type: $GITHUB_EVENT_NAME" - echo "This workflow only supports 'workflow_dispatch' and 'pull_request' events" - exit 1 - fi - echo "Successfully pushed benchmark results" - fi -} - -# Function to read benchmark results and output for GitHub Actions -read_results() { - if [ -f "$OUTPUT_DIR/LATEST.md" ]; then - echo "Reading benchmark results..." - - # Output full results - { - echo 'results<> "$GITHUB_OUTPUT" - - # Format results for PR comment - echo "Formatting results for PR comment..." - FORMATTED_COMMENT=$("$(dirname "$0")/format-pr-comment.sh" "$OUTPUT_DIR/LATEST.md") - - { - echo 'pr_comment<> "$GITHUB_OUTPUT" - - echo "Successfully read and formatted benchmark results" - else - echo 'results=No benchmark results found.' >> "$GITHUB_OUTPUT" - echo 'pr_comment=No benchmark results found.' >> "$GITHUB_OUTPUT" - echo "Warning: No benchmark results found at $OUTPUT_DIR/LATEST.md" - fi -} - -# Main execution -echo "Starting benchmark results processing..." - -# Create new branch for manual runs -if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then - echo "Manual workflow run detected, creating new branch..." - BRANCH_NAME="benchmarks/results-$(date +%Y%m%d-%H%M%S)" - git checkout -b "$BRANCH_NAME" - echo "Created branch: $BRANCH_NAME" - - # Output branch name for later use - echo "branch_name=$BRANCH_NAME" >> "$GITHUB_OUTPUT" -fi - -# Always commit benchmark results -echo "Committing benchmark results..." -commit_results - -# Always read results for output -read_results - -echo "Benchmark results processing complete" \ No newline at end of file diff --git a/.github/scripts/commit-benchmark-results.sh b/.github/scripts/commit-benchmark-results.sh new file mode 100755 index 0000000000000..f7dba8980fd64 --- /dev/null +++ b/.github/scripts/commit-benchmark-results.sh @@ -0,0 +1,75 @@ +#!/bin/bash +set -euo pipefail + +# Script to commit and push benchmark results. +# +# This script is intended to run from the lightweight `publish-results` job, +# which checks out the repo with credentials and only operates on the +# trusted artifact produced by the benchmark job. Keeping the write-scoped +# token away from the bench job (which runs untrusted third-party builds) +# limits the blast radius of a compromised dependency. +# +# Usage: ./commit-benchmark-results.sh + +OUTPUT_DIR="${1:-benches}" +GITHUB_EVENT_NAME="${2:-workflow_dispatch}" +GITHUB_REPOSITORY="${3:-}" + +if [ ! -f "$OUTPUT_DIR/LATEST.md" ]; then + echo "Error: $OUTPUT_DIR/LATEST.md not found, nothing to commit" + exit 1 +fi + +echo "Configuring git..." +git config --local user.email "action@github.com" +git config --local user.name "GitHub Action" + +# Decide which branch to commit to based on the event. +BRANCH_NAME="" +case "$GITHUB_EVENT_NAME" in + workflow_dispatch) + echo "Manual workflow run detected, creating new branch..." + BRANCH_NAME="benchmarks/results-$(date +%Y%m%d-%H%M%S)" + git checkout -b "$BRANCH_NAME" + echo "Created branch: $BRANCH_NAME" + ;; + pull_request) + if [ -z "${GITHUB_HEAD_REF:-}" ]; then + echo "Error: GITHUB_HEAD_REF not set for pull_request event" + exit 1 + fi + echo "Fetching latest changes for PR branch: $GITHUB_HEAD_REF" + git fetch origin "$GITHUB_HEAD_REF" + git checkout -B "$GITHUB_HEAD_REF" "origin/$GITHUB_HEAD_REF" + BRANCH_NAME="$GITHUB_HEAD_REF" + ;; + *) + echo "Error: Unexpected event type: $GITHUB_EVENT_NAME" + echo "This workflow only supports 'workflow_dispatch' and 'pull_request' events" + exit 1 + ;; +esac + +# Always emit the branch name so downstream steps (e.g. PR creation) can use it. +echo "branch_name=$BRANCH_NAME" >> "$GITHUB_OUTPUT" + +echo "Adding benchmark file..." +git add "$OUTPUT_DIR/LATEST.md" + +if git diff --staged --quiet; then + echo "No changes to commit" + echo "committed=false" >> "$GITHUB_OUTPUT" + exit 0 +fi + +echo "Committing benchmark results..." +git commit -m "chore(\`benches\`): update benchmark results + +🤖 Generated with [Foundry Benchmarks](https://github.com/${GITHUB_REPOSITORY}/actions) + +Co-Authored-By: github-actions " + +echo "Pushing to repository..." +git push origin "$BRANCH_NAME" +echo "Successfully pushed benchmark results to $BRANCH_NAME" +echo "committed=true" >> "$GITHUB_OUTPUT" diff --git a/.github/scripts/compare-benchmark-thresholds.py b/.github/scripts/compare-benchmark-thresholds.py new file mode 100644 index 0000000000000..8b324f2ada8f1 --- /dev/null +++ b/.github/scripts/compare-benchmark-thresholds.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Compare nightly benchmark summaries using calibrated per-key thresholds.""" + +import argparse +import json +import math +import pathlib +import sys + + +def fail(message): + print(f"Comparator error: {message}", file=sys.stderr) + raise SystemExit(2) + + +def load_object(path, name): + try: + value = json.loads(pathlib.Path(path).read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + fail(f"cannot read {name}: {error}") + if not isinstance(value, dict): + fail(f"{name} must be a JSON object") + return value + + +def positive_number(value, name): + if ( + isinstance(value, bool) + or not isinstance(value, (int, float)) + or not math.isfinite(value) + or value <= 0 + ): + fail(f"{name} must be finite and positive") + return float(value) + + +def mean_of(value, key): + if isinstance(value, dict): + if "mean" not in value: + fail(f"{key}: result object has no mean") + value = value["mean"] + return positive_number(value, f"{key} mean") + + +def load_rules(path): + config = load_object(path, "threshold config") + if ( + config.get("schema_version") != 1 + or config.get("kind") != "foundry-nightly-benchmark-thresholds" + ): + fail("threshold config has unsupported schema_version or kind") + rules = config.get("benchmarks") + if not isinstance(rules, dict): + fail("threshold config benchmarks must be an object") + for key, rule in rules.items(): + if not isinstance(key, str) or not isinstance(rule, dict): + fail("each threshold rule must be a keyed object") + warning = positive_number(rule.get("warning_percent"), f"{key} warning_percent") + regression = positive_number( + rule.get("regression_percent"), f"{key} regression_percent" + ) + if warning > regression or not isinstance(rule.get("alert"), bool): + fail(f"{key}: invalid threshold ordering or alert value") + return rules + + +def duration(seconds): + if seconds < 0.001: + return f"{seconds * 1000:.2f}ms" + if seconds < 1: + return f"{seconds:.3f}s" + if seconds < 60: + return f"{seconds:.2f}s" + return f"{int(seconds // 60)}m {seconds % 60:.1f}s" + + +def compare(base, candidate, rules): + print("## Nightly Benchmark Regression Report\n") + print("| Benchmark | Stable | Nightly | Change |") + print("|-----------|--------:|---------:|--------|") + has_regression = False + has_missing = False + for key in sorted(base.keys() | candidate.keys() | rules.keys()): + has_base, has_candidate = key in base, key in candidate + if not has_base or not has_candidate: + has_missing = True + left = duration(mean_of(base[key], key)) if has_base else "N/A" + right = duration(mean_of(candidate[key], key)) if has_candidate else "N/A" + print(f"| `{key}` | {left} | {right} | ⚠️ Incomplete benchmark |") + continue + + baseline = mean_of(base[key], key) + nightly = mean_of(candidate[key], key) + delta = (nightly - baseline) / baseline * 100 + rule = rules.get(key) + if rule is None: + verdict = "⚪ Uncalibrated" + else: + warning = rule["warning_percent"] + regression = rule["regression_percent"] + alert = rule["alert"] + suffix = "" if alert else " (advisory)" + if delta >= regression: + verdict = "❌ Regression" + suffix + has_regression |= alert + elif delta >= warning: + verdict = "⚠️ Warning" + suffix + elif delta <= -warning: + verdict = "✅ Improvement" + suffix + else: + verdict = "⚪ Within threshold" + suffix + print( + f"| `{key}` | {duration(baseline)} | {duration(nightly)} | " + f"{delta:+.2f}% {verdict} |" + ) + + print( + "\nLegend: ❌ regression, ⚠️ warning/inconclusive, ✅ improvement, " + "⚪ within threshold or uncalibrated. Advisory results never alert." + ) + if has_missing: + return 2 + return 1 if has_regression else 0 + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("base") + parser.add_argument("candidate") + parser.add_argument("thresholds") + args = parser.parse_args() + base_path = pathlib.Path(args.base) + base = load_object(base_path, "baseline") if base_path.is_file() else {} + candidate_path = pathlib.Path(args.candidate) + candidate = load_object(candidate_path, "candidate") if candidate_path.is_file() else {} + rules = load_rules(args.thresholds) + return compare(base, candidate, rules) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/compare-nightly.sh b/.github/scripts/compare-nightly.sh new file mode 100755 index 0000000000000..ab5d38a47e9ab --- /dev/null +++ b/.github/scripts/compare-nightly.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Compare two benchmark JSON summaries and report regressions/improvements. +# +# Usage: compare-nightly.sh [warn_pct] [fail_pct] +# +# Wall time is lower-is-better. Thresholds apply to the raw percentage change, +# preserving the existing nightly alert contract. +# +# Env overrides (defaults preserve the nightly regression report): +# BASE_LABEL baseline column label (default: Stable) +# CANDIDATE_LABEL candidate column label (default: Nightly) +# REPORT_TITLE heading (default: ## Nightly Benchmark Regression Report) +# FAIL_ON_REGRESSION exit 1 on any regression (default: 1) +# +# Exits 0 if no regressions (or FAIL_ON_REGRESSION=0), 1 otherwise. +# Exits 0 gracefully when base.json is missing (first run / gap > 7 days). +set -euo pipefail + +BASE_JSON="${1:-}" +CAND_JSON="${2:-}" +WARN="${3:-1}" +FAIL="${4:-3}" + +BASE_JSON="$BASE_JSON" CAND_JSON="$CAND_JSON" WARN="$WARN" FAIL="$FAIL" \ +BASE_LABEL="${BASE_LABEL:-Stable}" CANDIDATE_LABEL="${CANDIDATE_LABEL:-Nightly}" \ +REPORT_TITLE="${REPORT_TITLE-## Nightly Benchmark Regression Report}" \ +FAIL_ON_REGRESSION="${FAIL_ON_REGRESSION:-1}" \ +python3 - <<'EOF' +import json, os, sys + +warn = float(os.environ["WARN"]) +fail = float(os.environ["FAIL"]) +fail_on_regression = os.environ["FAIL_ON_REGRESSION"] != "0" +base_label = os.environ["BASE_LABEL"] +cand_label = os.environ["CANDIDATE_LABEL"] + +base_path = os.environ.get("BASE_JSON", "") +base = json.load(open(base_path)) if base_path and os.path.isfile(base_path) else {} +with open(os.environ["CAND_JSON"]) as f: + cand = json.load(f) + +# Each value is either the current summary object ({"mean": ..., "stddev": ...}) +# or, for historical files, a bare mean-seconds float. +def mean_of(v): + return v["mean"] if isinstance(v, dict) else v + +def fmt_duration(seconds): + if seconds < 0.001: + return f"{seconds * 1000:.2f}ms" + if seconds < 1: + return f"{seconds:.3f}s" + if seconds < 60: + return f"{seconds:.2f}s" + minutes = int(seconds // 60) + return f"{minutes}m {seconds % 60:.1f}s" + +title = os.environ["REPORT_TITLE"] +if title: + print(title + "\n") +print(f"| Benchmark | {base_label} | {cand_label} | Change |") +print("|-----------|--------:|---------:|--------|") + +has_regression = False +for key in sorted(base.keys() | cand.keys()): + b = base.get(key) + c = cand.get(key) + if c is None: + print(f"| `{key}` | {fmt_duration(mean_of(b))} | N/A | ⚠️ Missing |") + has_regression = True + continue + if b is None: + print(f"| `{key}` | N/A | {fmt_duration(mean_of(c))} | 🆕 New |") + continue + bm, cm = mean_of(b), mean_of(c) + delta = (cm - bm) / bm * 100 + sign = "+" if delta > 0 else "" + if delta >= fail: + verdict = "❌" + has_regression = True + elif delta >= warn: + verdict = "⚠️" + elif delta <= -warn: + verdict = "✅" + else: + verdict = "⚪" + change = f"{sign}{delta:.2f}% {verdict}" + print(f"| `{key}` | {fmt_duration(bm)} | {fmt_duration(cm)} | {change} |") + +sys.exit(1 if has_regression and fail_on_regression else 0) +EOF diff --git a/.github/scripts/contracts/BatchCounter.sol b/.github/scripts/contracts/BatchCounter.sol new file mode 100644 index 0000000000000..5dbded961a7c4 --- /dev/null +++ b/.github/scripts/contracts/BatchCounter.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +contract BatchCounter { + uint256 public number; + + constructor(uint256 initialNumber) { + number = initialNumber; + } + + function setNumber(uint256 newNumber) public { + number = newNumber; + } + + function increment() public { + number++; + } +} diff --git a/.github/scripts/contracts/BatchRevertTest.s.sol.template b/.github/scripts/contracts/BatchRevertTest.s.sol.template new file mode 100644 index 0000000000000..ab033fcda7bb6 --- /dev/null +++ b/.github/scripts/contracts/BatchRevertTest.s.sol.template @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Script} from "forge-std/Script.sol"; + +interface ICounter { + function increment() external; + function setNumber(uint256 newNumber) external; + function number() external view returns (uint256); +} + +contract BatchRevertTestScript is Script { + function run() public { + address counter = ${REQUIRE_COUNTER}; + vm.startBroadcast(); + + // First call succeeds + ICounter(counter).setNumber(600); + // Second call fails (require > 100 fails with 50) + ICounter(counter).setNumber(50); + + vm.stopBroadcast(); + } +} diff --git a/.github/scripts/contracts/BatchTest.s.sol.template b/.github/scripts/contracts/BatchTest.s.sol.template new file mode 100644 index 0000000000000..730735d1cbfd6 --- /dev/null +++ b/.github/scripts/contracts/BatchTest.s.sol.template @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Script} from "forge-std/Script.sol"; + +interface ICounter { + function increment() external; + function setNumber(uint256 newNumber) external; + function number() external view returns (uint256); +} + +contract BatchTestScript is Script { + function run() public { + address counter = ${REQUIRE_COUNTER}; + vm.startBroadcast(); + + // Multiple calls that will be batched into a single transaction + ICounter(counter).setNumber(500); + ICounter(counter).increment(); + ICounter(counter).increment(); + ICounter(counter).increment(); + + vm.stopBroadcast(); + } +} diff --git a/.github/scripts/contracts/CounterWithRequire.sol b/.github/scripts/contracts/CounterWithRequire.sol new file mode 100644 index 0000000000000..fc23820915124 --- /dev/null +++ b/.github/scripts/contracts/CounterWithRequire.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +contract Counter { + uint256 public number; + + function setNumber(uint256 newNumber) public { + require(newNumber > 100, "bad number"); + number = newNumber; + } + + function increment() public { + number++; + } +} diff --git a/.github/scripts/contracts/DeployAndCall.s.sol b/.github/scripts/contracts/DeployAndCall.s.sol new file mode 100644 index 0000000000000..0a344ba1647e5 --- /dev/null +++ b/.github/scripts/contracts/DeployAndCall.s.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Script, console} from "forge-std/Script.sol"; +import {BatchCounter} from "../src/BatchCounter.sol"; + +contract DeployAndCallScript is Script { + function run() public { + vm.startBroadcast(); + + // Deploy contract (CREATE as first call) + BatchCounter counter = new BatchCounter(100); + + // Call the newly deployed contract in the same batch + counter.setNumber(200); + counter.increment(); + counter.increment(); + + // Final number should be 202 (200 + 2 increments) + console.log("Deployed BatchCounter at:", address(counter)); + + vm.stopBroadcast(); + } +} diff --git a/.github/scripts/create-tag.js b/.github/scripts/create-tag.js deleted file mode 100644 index 042f9950f6173..0000000000000 --- a/.github/scripts/create-tag.js +++ /dev/null @@ -1,14 +0,0 @@ -module.exports = async ({ github, context }, tagName) => { - try { - await github.rest.git.createRef({ - owner: context.repo.owner, - repo: context.repo.repo, - ref: `refs/tags/${tagName}`, - sha: context.sha, - force: true, - }); - } catch (err) { - console.error(`Failed to create tag: ${tagName}`); - console.error(err); - } -}; diff --git a/.github/scripts/finalize-release.py b/.github/scripts/finalize-release.py new file mode 100644 index 0000000000000..40fdb729d9598 --- /dev/null +++ b/.github/scripts/finalize-release.py @@ -0,0 +1,313 @@ +#!/usr/bin/env python3 +"""Safely publish a staged Foundry release and promote its moving image aliases.""" + +import argparse +import json +import os +import pathlib +import re +import subprocess +import sys +import tempfile + + +STABLE = re.compile(r"^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$") +RC = re.compile(r"^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-rc([1-9][0-9]*)$") +NIGHTLY = re.compile(r"^nightly-([0-9a-f]{40})$") +DIGEST = re.compile(r"^sha256:[0-9a-f]{64}$") + + +class ReleaseError(RuntimeError): + pass + + +class Commands: + def run(self, args, check=True): + try: + return subprocess.run(args, text=True, capture_output=True, check=check) + except subprocess.CalledProcessError as error: + detail = (error.stderr or error.stdout or "").strip() + raise ReleaseError(f"command failed ({' '.join(args)}): {detail}") from error + + def output(self, args): + return self.run(args).stdout.strip() + + +def parse_tag(tag, mode): + patterns = {"stable": (STABLE, RC), "nightly": (NIGHTLY,)}[mode] + for pattern in patterns: + if match := pattern.fullmatch(tag): + return pattern, tuple(int(value) for value in match.groups()) if pattern != NIGHTLY else match.group(1) + expected = "vX.Y.Z or vX.Y.Z-rcN" if mode == "stable" else "nightly-<40 lowercase hex SHA>" + raise ReleaseError(f"tag must be canonical {expected}: {tag}") + + +def image_digest(commands, reference): + raw = commands.output([ + "docker", "buildx", "imagetools", "inspect", reference, + "--format", "{{json .Manifest.Digest}}", + ]) + try: + digest = json.loads(raw) + except json.JSONDecodeError as error: + raise ReleaseError(f"could not parse digest for {reference}: {raw}") from error + if not isinstance(digest, str) or not DIGEST.fullmatch(digest): + raise ReleaseError(f"invalid digest for {reference}: {digest}") + return digest + + +def verify_exact(commands, image, tag, expected=None): + digest = image_digest(commands, f"{image}:{tag}") + if expected and digest != expected: + raise ReleaseError(f"staged tag {tag} resolves to {digest}, expected {expected}") + return digest + + +def releases(commands, repo): + pages = json.loads(commands.output([ + "gh", "api", "--paginate", "--slurp", f"repos/{repo}/releases?per_page=100", + ])) + return [release for page in pages for release in page] + + +def exact_release(all_releases, tag): + matches = [release for release in all_releases if release.get("tag_name") == tag] + if len(matches) != 1: + raise ReleaseError(f"expected exactly one GitHub release for {tag}, found {len(matches)}") + return matches[0] + + +def tag_commit(commands, repo, tag): + raw = commands.output(["gh", "api", f"repos/{repo}/git/ref/tags/{tag}", "--jq", ".object"]) + try: + target = json.loads(raw) + except json.JSONDecodeError as error: + raise ReleaseError(f"could not parse Git tag {tag}: {raw}") from error + seen = set() + while target.get("type") == "tag": + sha = target.get("sha") + if sha in seen: + raise ReleaseError(f"Git tag {tag} contains a tag-object cycle") + seen.add(sha) + raw = commands.output(["gh", "api", f"repos/{repo}/git/tags/{sha}", "--jq", ".object"]) + try: + target = json.loads(raw) + except json.JSONDecodeError as error: + raise ReleaseError(f"could not parse Git tag object {sha}: {raw}") from error + sha = target.get("sha") + if target.get("type") != "commit" or not isinstance(sha, str) or not re.fullmatch(r"[0-9a-f]{40}", sha): + raise ReleaseError(f"Git tag {tag} does not resolve to a full commit SHA") + return sha + + +def create_or_verify_tag(commands, repo, tag, commit): + args = [ + "gh", "api", "--method", "POST", f"repos/{repo}/git/refs", + "-f", f"ref=refs/tags/{tag}", "-f", f"sha={commit}", + ] + result = commands.run(args, check=False) + try: + actual = tag_commit(commands, repo, tag) + except ReleaseError as error: + detail = (result.stderr or result.stdout).strip() + raise ReleaseError(f"could not create or verify Git tag {tag}: {detail}") from error + if actual != commit: + raise ReleaseError(f"Git tag {tag} resolves to {actual}, expected {commit}") + + +def stable_aliases(candidate, all_releases): + version = tuple(int(value) for value in STABLE.fullmatch(candidate).groups()) + versions = [version] + for release in all_releases: + match = STABLE.fullmatch(release.get("tag_name", "")) + if match and not release.get("draft") and not release.get("prerelease"): + versions.append(tuple(int(value) for value in match.groups())) + aliases = [] + if version == max(value for value in versions if value[:2] == version[:2]): + aliases.append(f"v{version[0]}.{version[1]}") + if version == max(value for value in versions if value[0] == version[0]): + aliases.append(f"v{version[0]}") + if version == max(versions): + aliases.append("latest") + return aliases, version == max(versions) + + +def require_release_run(commands, repo, tag, commit): + pages = json.loads(commands.output([ + "gh", "api", "--paginate", "--slurp", + f"repos/{repo}/actions/workflows/release.yml/runs?event=push&head_sha={commit}&per_page=100", + ])) + matching = [ + run for page in pages for run in page.get("workflow_runs", []) + if run.get("head_sha") == commit and run.get("head_branch") == tag + ] + if not matching: + raise ReleaseError(f"no release.yml push run found for {tag} at {commit}") + latest = max(matching, key=lambda run: run["id"]) + if latest.get("conclusion") != "success": + raise ReleaseError(f"latest release.yml push run for {tag} at {commit} was not successful") + return latest["id"] + + +def release_run_digest(commands, repo, run_id): + with tempfile.TemporaryDirectory() as directory: + commands.run([ + "gh", "run", "download", str(run_id), "--repo", repo, + "--name", "release-docker-digest", "--dir", directory, + ]) + path = pathlib.Path(directory) / "release-docker-digest.txt" + if not path.is_file(): + raise ReleaseError(f"release workflow run {run_id} has no Docker digest artifact") + digest = path.read_text().strip() + if not DIGEST.fullmatch(digest): + raise ReleaseError(f"release workflow run {run_id} has invalid Docker digest {digest!r}") + return digest + + +def publish(commands, tag, prerelease, latest): + commands.run([ + "gh", "release", "edit", tag, "--draft=false", + f"--prerelease={'true' if prerelease else 'false'}", + f"--latest={'true' if latest else 'false'}", + ]) + + +def publish_staged(commands, release_tag, tag, commit): + commands.run([ + "gh", "release", "edit", release_tag, "--tag", tag, "--target", commit, "--draft=false", + "--prerelease=true", "--latest=false", + ]) + + +def promote(commands, image, digest, alias): + commands.run(["docker", "buildx", "imagetools", "create", "--tag", f"{image}:{alias}", f"{image}@{digest}"]) + actual = image_digest(commands, f"{image}:{alias}") + if actual != digest: + raise ReleaseError(f"alias {alias} resolves to {actual}, expected {digest}") + + +def finalize_stable(commands, repo, image, tag): + pattern, _ = parse_tag(tag, "stable") + all_releases = releases(commands, repo) + state = exact_release(all_releases, tag) + commit = commands.output(["git", "rev-parse", f"{tag}^{{commit}}"]) + if not re.fullmatch(r"[0-9a-f]{40}", commit): + raise ReleaseError(f"could not resolve {tag} to a full commit SHA") + run_id = require_release_run(commands, repo, tag, commit) + expected = release_run_digest(commands, repo, run_id) + digest = verify_exact(commands, image, tag, expected) + prerelease = pattern == RC + if state.get("tag_name") != tag or state.get("prerelease") != prerelease: + raise ReleaseError(f"release {tag} has unexpected state/classification") + aliases, latest = (stable_aliases(tag, all_releases) if pattern == STABLE else ([], False)) + if state.get("draft"): + publish(commands, tag, prerelease, latest) + if pattern == STABLE: + aliases, _ = stable_aliases(tag, releases(commands, repo)) + for alias in aliases: + promote(commands, image, digest, alias) + + +def current_nightly_revision(commands, image): + result = commands.run([ + "docker", "buildx", "imagetools", "inspect", f"{image}:nightly", + "--format", "{{json .Image}}", + ], check=False) + if result.returncode != 0: + detail = (result.stderr or result.stdout).lower() + if "manifest unknown" in detail or "not found" in detail: + return None + raise ReleaseError(f"could not inspect current nightly image: {detail.strip()}") + try: + images = json.loads(result.stdout) + revisions = { + data["config"]["Labels"]["org.opencontainers.image.revision"] + for data in images.values() + } + except (json.JSONDecodeError, AttributeError, KeyError, TypeError) as error: + raise ReleaseError("current nightly image has malformed revision metadata") from error + if len(revisions) != 1: + raise ReleaseError("current nightly image has inconsistent revision metadata") + revision = revisions.pop() + if not re.fullmatch(r"[0-9a-f]{40}", revision): + raise ReleaseError(f"current nightly image has invalid revision {revision!r}") + return revision + + +def nightly_eligible(commands, image, candidate_sha): + current = current_nightly_revision(commands, image) + if current is None or current == candidate_sha: + return True + result = commands.run(["git", "merge-base", "--is-ancestor", current, candidate_sha], check=False) + if result.returncode == 0: + return True + if result.returncode == 1: + return False + raise ReleaseError(f"could not compare nightly history {current}..{candidate_sha}: {(result.stderr or result.stdout).strip()}") + + +def finalize_nightly(commands, repo, image, tag, release_tag, expected=None): + _, sha = parse_tag(tag, "nightly") + if release_tag != f"staging-release-{sha}": + raise ReleaseError(f"staging release tag does not match nightly commit: {release_tag}") + digest = verify_exact(commands, image, tag, expected) + all_releases = releases(commands, repo) + final = [release for release in all_releases if release.get("tag_name") == tag] + staging = [release for release in all_releases if release.get("tag_name") == release_tag] + if len(final) > 1 or len(staging) > 1: + raise ReleaseError("found duplicate final or staging nightly releases") + if final and staging: + raise ReleaseError(f"both final release {tag} and staging release {release_tag} exist") + if final: + state = final[0] + if state.get("draft"): + raise ReleaseError(f"final nightly release {tag} unexpectedly exists as a draft") + elif staging: + state = staging[0] + if not state.get("draft"): + raise ReleaseError(f"staging nightly release {release_tag} is already published") + if state.get("target_commitish") != sha: + raise ReleaseError(f"staging nightly release {release_tag} targets an unexpected commit") + else: + raise ReleaseError(f"expected final release {tag} or staging release {release_tag}") + if not state.get("prerelease"): + raise ReleaseError(f"release {tag} has unexpected state/classification") + if final and tag_commit(commands, repo, tag) != sha: + raise ReleaseError(f"release tag {tag} does not resolve to its encoded commit") + if not nightly_eligible(commands, image, sha): + print(f"Not publishing nightly: {tag} is not a descendant of the current nightly image.") + return + if staging: + create_or_verify_tag(commands, repo, tag, sha) + promote(commands, image, digest, "nightly") + if staging: + publish_staged(commands, release_tag, tag, sha) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("mode", choices=("stable", "nightly")) + parser.add_argument("--tag", required=True) + parser.add_argument("--release-tag") + parser.add_argument("--digest") + parser.add_argument("--repo", default=os.environ.get("GITHUB_REPOSITORY")) + parser.add_argument("--image") + args = parser.parse_args() + if not args.repo: + parser.error("--repo or GITHUB_REPOSITORY is required") + image = args.image or f"ghcr.io/{args.repo}" + try: + if args.mode == "stable": + finalize_stable(Commands(), args.repo, image, args.tag) + else: + if not args.release_tag: + parser.error("--release-tag is required for nightly releases") + finalize_nightly(Commands(), args.repo, image, args.tag, args.release_tag, args.digest) + except ReleaseError as error: + print(f"error: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/format-pr-comment.sh b/.github/scripts/format-pr-comment.sh index 74ad095464aba..1430f9cde5151 100755 --- a/.github/scripts/format-pr-comment.sh +++ b/.github/scripts/format-pr-comment.sh @@ -11,24 +11,13 @@ if [ -z "$RESULTS_FILE" ] || [ ! -f "$RESULTS_FILE" ]; then exit 1 fi -# Read the file content -CONTENT=$(cat "$RESULTS_FILE") - -# Find where "## Forge Build" starts and split the content -# Extract everything before "## Forge Build" -BEFORE_FORGE_BUILD=$(echo "$CONTENT" | awk '/^## Forge Build$/ {exit} {print}') - -# Extract everything from "## Forge Build" onwards -FROM_FORGE_BUILD=$(echo "$CONTENT" | awk '/^## Forge Build$/ {found=1} found {print}') - -# Output the formatted comment with dropdown -cat << EOF -${BEFORE_FORGE_BUILD} - +# Keep the headline comparison compact; the generated absolute-time report is +# still available for auditing without dominating the PR conversation. +cat < -📈 View all benchmark results +Full benchmark results -${FROM_FORGE_BUILD} +$(cat "$RESULTS_FILE") -EOF \ No newline at end of file +EOF diff --git a/.github/scripts/format.sh b/.github/scripts/format.sh index 9bd1f950fdeaf..89e045b98b398 100755 --- a/.github/scripts/format.sh +++ b/.github/scripts/format.sh @@ -3,5 +3,10 @@ set -eo pipefail # We have to ignore at shell level because testdata/ is not a valid Foundry project, # so running `forge fmt` with `--root testdata` won't actually check anything -cargo run --bin forge -- fmt "$@" \ - $(find testdata -name '*.sol' ! -name Vm.sol ! -name console.sol) +sol_files=() +while IFS= read -r -d '' file; do + sol_files+=("$file") +done < <(find testdata -name '*.sol' ! -name Vm.sol ! -name console.sol -print0) + +# Run forge fmt on all found files +cargo run --locked --bin forge -- fmt "$@" "${sol_files[@]}" diff --git a/.github/scripts/matrices.py b/.github/scripts/matrices.py index 4ad0c3acc34dc..8f2ccd5355864 100755 --- a/.github/scripts/matrices.py +++ b/.github/scripts/matrices.py @@ -45,7 +45,8 @@ class Expanded: runner_label: str target: str svm_target_platform: str - flags: str + filter: str + partition_arg: str partition: int def __init__( @@ -54,49 +55,44 @@ def __init__( runner_label: str, target: str, svm_target_platform: str, - flags: str, + filter: str, + partition_arg: str, partition: int, ): self.name = name self.runner_label = runner_label self.target = target self.svm_target_platform = svm_target_platform - self.flags = flags + self.filter = filter + self.partition_arg = partition_arg self.partition = partition -profile = os.environ.get("PROFILE") is_pr = os.environ.get("EVENT_NAME") == "pull_request" -t_linux_x86 = Target("ubuntu-latest", "x86_64-unknown-linux-gnu", "linux-amd64") -# TODO: Figure out how to make this work -# t_linux_arm = Target("ubuntu-latest", "aarch64-unknown-linux-gnu", "linux-aarch64") -t_macos = Target("macos-latest", "aarch64-apple-darwin", "macosx-aarch64") -t_windows = Target("windows-latest", "x86_64-pc-windows-msvc", "windows-amd64") -targets = [t_linux_x86, t_windows] if is_pr else [t_linux_x86, t_macos, t_windows] +t_linux_x86 = Target( + "depot-ubuntu-latest-16", "x86_64-unknown-linux-gnu", "linux-amd64" +) +t_linux_arm = Target( + "depot-ubuntu-latest-arm-16", "aarch64-unknown-linux-gnu", "linux-aarch64" +) +t_macos = Target("depot-macos-latest", "aarch64-apple-darwin", "macosx-aarch64") +t_windows = Target("depot-windows-latest-16", "x86_64-pc-windows-msvc", "windows-amd64") +if is_pr: + targets = [t_linux_x86] +else: + targets = [t_linux_x86, t_linux_arm, t_macos, t_windows] config = [ Case( - name="unit", - filter="!kind(test)", + name="all", + filter="!test(/\\bext_integration/)", n_partitions=1, pr_cross_platform=True, ), Case( - name="integration", - filter="kind(test) & !test(/\\b(issue|ext_integration)/)", - n_partitions=3, - pr_cross_platform=True, - ), - Case( - name="integration / issue-repros", - filter="package(=forge) & test(/\\bissue/)", - n_partitions=2, - pr_cross_platform=False, - ), - Case( - name="integration / external", + name="external", filter="package(=forge) & test(/\\bext_integration/)", - n_partitions=2, + n_partitions=1, pr_cross_platform=False, ), ] @@ -115,14 +111,12 @@ def main(): os_str = f" ({target.target})" name = case.name - flags = f"-E '{case.filter}'" + partition_arg = "" if case.n_partitions > 1: s = f"{partition}/{case.n_partitions}" name += f" ({s})" - flags += f" --partition count:{s}" - - if profile == "isolate": - flags += " --features=isolate-by-default" + partition_arg = f"count:{s}" + name += os_str obj = Expanded( @@ -130,7 +124,8 @@ def main(): runner_label=target.runner_label, target=target.target, svm_target_platform=target.svm_target_platform, - flags=flags, + filter=case.filter, + partition_arg=partition_arg, partition=partition, ) expanded.append(vars(obj)) diff --git a/.github/scripts/move-tag.js b/.github/scripts/move-tag.js deleted file mode 100644 index d1fe815be57cc..0000000000000 --- a/.github/scripts/move-tag.js +++ /dev/null @@ -1,15 +0,0 @@ -module.exports = async ({ github, context }, tagName) => { - try { - await github.rest.git.updateRef({ - owner: context.repo.owner, - repo: context.repo.repo, - ref: `tags/${tagName}`, - sha: context.sha, - force: true, - }); - } catch (err) { - console.error(`Failed to move nightly tag.`); - console.error(`This should only happen the first time.`); - console.error(err); - } -}; diff --git a/.github/scripts/pgo.sh b/.github/scripts/pgo.sh new file mode 100755 index 0000000000000..8baa42ff9b2f4 --- /dev/null +++ b/.github/scripts/pgo.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${TARGET:?}" +: "${RUST_PROFILE:?}" +: "${OUT_DIR:?}" +: "${RUNNER_TEMP:?}" + +cd "$(dirname "${BASH_SOURCE[0]}")/../.." + +pgo_root="$(mktemp -d "$RUNNER_TEMP/foundry-pgo.XXXXXX")" +anvil_pid="" +cleanup() { + if [[ -n "$anvil_pid" ]] && kill -0 "$anvil_pid" 2>/dev/null; then + kill -INT "$anvil_pid" + wait "$anvil_pid" || true + fi + rm -rf "$pgo_root" +} +trap cleanup EXIT + +corpus="$pgo_root/corpus" +profiles_dir="$pgo_root/profiles" +pgo_target="$pgo_root/target" +pgo_home="$pgo_root/home" +mkdir -p "$corpus/default" "$profiles_dir" "$OUT_DIR" +cp -R testdata/default/{core,inline,cheats} "$corpus/default/" +cp -R testdata/utils "$corpus/" + +cat > "$corpus/foundry.toml" <<'EOF' +[profile.default] +test = "." +optimizer = true +solc = "0.8.36" +offline = true + +[profile.default.invariant] +depth = 32 + +[fmt] +ignore = ["utils/Vm.sol"] +EOF + +cat > "$corpus/solar-input.json" <<'EOF' +{ + "language": "Solidity", + "sources": { + "Counter.sol": { + "content": "pragma solidity ^0.8.0; contract Counter { uint256 public number; function setNumber(uint256 value) public { number = value; } }" + } + }, + "settings": { + "outputSelection": {"*": {"*": ["abi"]}} + } +} +EOF + +build_paths=( + default/{core,inline} + default/cheats/{Assert,Broadcast,Ec,ExpectEmit,Json,MappingStorageHooks,MockCalls}.t.sol + default/cheats/{MonadReserveBalance,MonadStaking,Parse,Prank,Rlp,Sign,Wallet}.t.sol +) +mapfile -t solar_sources < <( + find "$corpus/default/core" "$corpus/default/inline" -type f -name '*.sol' -print + for path in "${build_paths[@]:2}"; do + printf '%s\n' "$corpus/$path" + done +) + +solc="$pgo_home/.svm/0.8.36/solc-0.8.36" +mkdir -p "$(dirname "$solc")" +curl --proto '=https' --tlsv1.2 -fsSL --retry 3 -o "$solc" \ + https://binaries.soliditylang.org/linux-amd64/solc-linux-amd64-v0.8.36+commit.8a079791 +printf '%s %s\n' c8d35afdddc3cd2743ee88b8f25e0fecd16e2bdd5f2120f37e52cd9cc45ae0e6 \ + "$solc" | sha256sum --check +chmod +x "$solc" + +bins=(anvil cast chisel forge solar) +bin_args=(--workspace) +for name in "${bins[@]}"; do + bin_args+=(--bin "$name") +done + +export CFLAGS="${CFLAGS:+$CFLAGS }-fno-profile-generate -fno-profile-use" +export CXXFLAGS="${CXXFLAGS:+$CXXFLAGS }-fno-profile-generate -fno-profile-use" +# Training uses worker threads, so update LLVM profile counters atomically. +CARGO_INCREMENTAL=0 CARGO_TARGET_DIR="$pgo_target" \ + RUSTFLAGS="${RUSTFLAGS:+$RUSTFLAGS }-Cprofile-generate=$profiles_dir \ + -Cllvm-args=-instrprof-atomic-counter-update-all" \ + cargo build "$@" "${bin_args[@]}" + +bin_dir="$pgo_target/$TARGET/$RUST_PROFILE" +profile() { + local name="$1" + shift + LLVM_PROFILE_FILE="$profiles_dir/$name-%m-%p.profraw" HOME="$pgo_home" "$@" +} + +seed=0x00000000000000000000000000000000000000000000000000000000feedface +forge="$bin_dir/forge" +# Train cold and warm builds, unit tests, fuzzing, invariants, and formatting. +profile forge "$forge" build --root "$corpus" --no-lint -q "${build_paths[@]}" +profile forge "$forge" build --root "$corpus" --no-lint -q "${build_paths[@]}" +profile forge "$forge" test --root "$corpus" default/cheats/ExpectEmit.t.sol \ + --fuzz-seed "$seed" --threads 4 -q +profile forge "$forge" test --root "$corpus" default/cheats/Parse.t.sol \ + --fuzz-seed "$seed" --fuzz-runs 4096 --threads 4 -q +profile forge "$forge" test --root "$corpus" default/inline/InvariantInlineConf.t.sol \ + --fuzz-seed "$seed" --threads 4 -q +profile forge "$forge" fmt --root "$corpus" --check + +cast="$bin_dir/cast" +address=0x0000000000000000000000000000000000000001 +calldata="$(profile cast "$cast" calldata 'transfer(address,uint256)' "$address" 42)" +profile cast "$cast" abi-encode 'transfer(address,uint256)' "$address" 42 >/dev/null +profile cast "$cast" decode-calldata 'transfer(address,uint256)' "$calldata" >/dev/null +profile cast "$cast" keccak 'Foundry profile-guided optimization' >/dev/null +profile cast "$cast" wallet new-mnemonic --accounts 4 \ + --entropy 0xdf9bf37e6fcdf9bf37e6fcdf9bf37e3c --json >/dev/null + +chisel="$bin_dir/chisel" +profile chisel "$chisel" --offline eval 'type(uint256).max / 7' >/dev/null +profile chisel "$chisel" --offline eval 'keccak256(abi.encode(uint256(42), "foundry"))' \ + >/dev/null + +solar="$bin_dir/solar" +profile solar "$solar" --base-path "$corpus" --stop-after parsing "${solar_sources[@]}" +profile solar "$solar" --base-path "$corpus" --stop-after analysis "${solar_sources[@]}" +profile solar "$solar" --standard-json "$corpus/solar-input.json" >/dev/null + +anvil="$bin_dir/anvil" +anvil_log="$pgo_root/anvil.log" +LLVM_PROFILE_FILE="$profiles_dir/anvil-%m-%p.profraw" HOME="$pgo_home" \ + "$anvil" --port 18545 --silent >"$anvil_log" 2>&1 & +anvil_pid=$! +rpc_url=http://127.0.0.1:18545 +ready=false +for ((attempt = 0; attempt < 100; attempt++)); do + if curl -fsS -H 'content-type: application/json' \ + --data '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}' \ + "$rpc_url" >/dev/null; then + ready=true + break + fi + if ! kill -0 "$anvil_pid" 2>/dev/null; then + break + fi + sleep 0.1 +done +if [[ "$ready" != true ]]; then + cat "$anvil_log" + exit 1 +fi + +rpc() { + curl -fsS -H 'content-type: application/json' --data "$1" "$rpc_url" >/dev/null +} +for ((id = 1; id <= 64; id++)); do + rpc "{\"jsonrpc\":\"2.0\",\"id\":$id,\"method\":\"eth_getBlockByNumber\",\"params\":[\"latest\",false]}" + rpc "{\"jsonrpc\":\"2.0\",\"id\":$id,\"method\":\"eth_getBalance\",\"params\":[\"0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266\",\"latest\"]}" + rpc "{\"jsonrpc\":\"2.0\",\"id\":$id,\"method\":\"eth_estimateGas\",\"params\":[{\"from\":\"0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266\",\"to\":\"0x70997970c51812dc3a010c7d01b50e0d17dc79c8\",\"value\":\"0x1\"}]}" +done +for ((id = 1; id <= 8; id++)); do + rpc "{\"jsonrpc\":\"2.0\",\"id\":$id,\"method\":\"eth_sendTransaction\",\"params\":[{\"from\":\"0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266\",\"to\":\"0x70997970c51812dc3a010c7d01b50e0d17dc79c8\",\"value\":\"0x1\"}]}" +done +profile cast "$cast" block latest --rpc-url "$rpc_url" >/dev/null +profile cast "$cast" balance "$address" --rpc-url "$rpc_url" >/dev/null +kill -INT "$anvil_pid" +wait "$anvil_pid" +anvil_pid="" + +shopt -s nullglob +profiles=("$profiles_dir"/*.profraw) +for name in "${bins[@]}"; do + binary_profiles=("$profiles_dir/$name-"*.profraw) + if ((${#binary_profiles[@]} == 0)); then + echo "No $name profiles were generated" + exit 1 + fi +done +if ((${#profiles[@]} == 0)) || find "${profiles[@]}" -size 0 | grep -q .; then + echo "Incomplete profiling data" + exit 1 +fi + +llvm_profdata="$(dirname "$(rustc --print target-libdir)")/bin/llvm-profdata" +"$llvm_profdata" merge -o "$profiles_dir/foundry.profdata" "${profiles[@]}" +rm -rf "$pgo_target" + +CARGO_INCREMENTAL=0 CARGO_TARGET_DIR="$pgo_target" \ + RUSTFLAGS="${RUSTFLAGS:+$RUSTFLAGS }-Cprofile-use=$profiles_dir/foundry.profdata" \ + cargo build "$@" "${bin_args[@]}" +for name in "${bins[@]}"; do + cp "$bin_dir/$name" "$OUT_DIR/$name" +done + +HOME="$pgo_home" "$OUT_DIR/forge" test --root "$corpus" \ + default/cheats/ExpectEmit.t.sol --fuzz-seed "$seed" --threads 4 -q diff --git a/.github/scripts/prune-prereleases.js b/.github/scripts/prune-prereleases.js deleted file mode 100644 index d0d6bf465e81a..0000000000000 --- a/.github/scripts/prune-prereleases.js +++ /dev/null @@ -1,68 +0,0 @@ -// In case node 21 is not used. -function groupBy(array, keyOrIterator) { - var iterator; - - // use the function passed in, or create one - if(typeof keyOrIterator !== 'function') { - const key = String(keyOrIterator); - iterator = function (item) { return item[key]; }; - } else { - iterator = keyOrIterator; - } - - return array.reduce(function (memo, item) { - const key = iterator(item); - memo[key] = memo[key] || []; - memo[key].push(item); - return memo; - }, {}); -} - -module.exports = async ({ github, context }) => { - console.log("Pruning old prereleases"); - - // doc: https://docs.github.com/en/rest/releases/releases - const { data: releases } = await github.rest.repos.listReleases({ - owner: context.repo.owner, - repo: context.repo.repo, - }); - - let nightlies = releases.filter( - release => - // Only consider releases tagged `nightly-${SHA}` for deletion - release.tag_name.includes("nightly") && - release.tag_name !== "nightly" - ); - - // Pruning rules: - // 1. only keep the earliest (by created_at) release of the month - // 2. to keep the newest 30 nightlies (to make sure nightlies are kept until the next monthly release) - // Notes: - // - This addresses https://github.com/foundry-rs/foundry/issues/6732 - // - Name of the release may deviate from created_at due to the usage of different timezones. - - // Group releases by months. - // Per doc: - // > The latest release is the most recent non-prerelease, non-draft release, sorted by the created_at attribute. - const groups = groupBy(nightlies, i => i.created_at.slice(0, 7)); - const nightliesToPrune = Object.values(groups) - .reduce((acc, cur) => acc.concat(cur.slice(0, -1)), []) // rule 1 - .slice(30); // rule 2 - - for (const nightly of nightliesToPrune) { - console.log(`Deleting nightly: ${nightly.tag_name}`); - await github.rest.repos.deleteRelease({ - owner: context.repo.owner, - repo: context.repo.repo, - release_id: nightly.id, - }); - console.log(`Deleting nightly tag: ${nightly.tag_name}`); - await github.rest.git.deleteRef({ - owner: context.repo.owner, - repo: context.repo.repo, - ref: `tags/${nightly.tag_name}`, - }); - } - - console.log("Done."); -}; diff --git a/.github/scripts/read-benchmark-results.sh b/.github/scripts/read-benchmark-results.sh new file mode 100755 index 0000000000000..548611a7d204a --- /dev/null +++ b/.github/scripts/read-benchmark-results.sh @@ -0,0 +1,37 @@ +#!/bin/bash +set -euo pipefail + +# Script to read benchmark results and emit them as GitHub Actions outputs. +# This script performs no git operations — it only reads the combined +# benchmark file and writes outputs for the workflow to consume. +# +# Usage: ./read-benchmark-results.sh + +OUTPUT_DIR="${1:-benches}" + +echo "Reading benchmark results from $OUTPUT_DIR..." + +if [ -f "$OUTPUT_DIR/LATEST.md" ]; then + # Output full results + { + echo 'results<> "$GITHUB_OUTPUT" + + # Format results for PR comment + echo "Formatting results for PR comment..." + FORMATTED_COMMENT=$("$(dirname "$0")/format-pr-comment.sh" "$OUTPUT_DIR/LATEST.md") + + { + echo 'pr_comment<> "$GITHUB_OUTPUT" + + echo "Successfully read and formatted benchmark results" +else + echo 'results=No benchmark results found.' >> "$GITHUB_OUTPUT" + echo 'pr_comment=No benchmark results found.' >> "$GITHUB_OUTPUT" + echo "Warning: No benchmark results found at $OUTPUT_DIR/LATEST.md" +fi diff --git a/.github/scripts/setup-foundryup.sh b/.github/scripts/setup-foundryup.sh index 4ac30d64ea72d..b8a75f4d9960a 100755 --- a/.github/scripts/setup-foundryup.sh +++ b/.github/scripts/setup-foundryup.sh @@ -12,24 +12,8 @@ if [ ! -f "foundryup/foundryup" ]; then exit 1 fi -# Copy foundryup to a location in PATH -echo "Copying foundryup to /usr/local/bin..." -sudo cp foundryup/foundryup /usr/local/bin/foundryup -sudo chmod +x /usr/local/bin/foundryup - -# Verify foundryup is accessible -if ! command -v foundryup &> /dev/null; then - echo "Error: foundryup not found in PATH after installation" - exit 1 -fi - -echo "foundryup is now available at: $(which foundryup)" - -# Create foundry directories -echo "Creating foundry directories..." - # Use FOUNDRY_DIR if set, otherwise default to $HOME/.foundry -FOUNDRY_DIR="${FOUNDRY_DIR:-$HOME/.foundry}" +export FOUNDRY_DIR="${FOUNDRY_DIR:-$HOME/.foundry}" echo "Using FOUNDRY_DIR: $FOUNDRY_DIR" # Create all necessary directories @@ -37,9 +21,22 @@ mkdir -p "$FOUNDRY_DIR/bin" mkdir -p "$FOUNDRY_DIR/versions" mkdir -p "$FOUNDRY_DIR/share/man/man1" +# Copy foundryup to a user-writable location so it can replace itself with the Rust binary. +echo "Copying foundryup to $FOUNDRY_DIR/bin..." +cp foundryup/foundryup "$FOUNDRY_DIR/bin/foundryup" +chmod +x "$FOUNDRY_DIR/bin/foundryup" + # Export PATH for current session export PATH="$FOUNDRY_DIR/bin:$PATH" +# Verify foundryup is accessible. +if ! command -v foundryup &> /dev/null; then + echo "Error: foundryup not found in PATH after installation" + exit 1 +fi + +echo "foundryup is now available at: $(command -v foundryup)" + # Run foundryup to install default version echo "Installing default foundry version..." foundryup @@ -51,4 +48,4 @@ else echo "Warning: forge not found in PATH after installation" fi -echo "Foundry setup complete!" \ No newline at end of file +echo "Foundry setup complete!" diff --git a/.github/scripts/shellcheck.sh b/.github/scripts/shellcheck.sh new file mode 100755 index 0000000000000..5472767777808 --- /dev/null +++ b/.github/scripts/shellcheck.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash + +# runs shellcheck and prints GitHub Actions annotations for each warning and error +# https://github.com/koalaman/shellcheck + +IGNORE_DIRS=( + "./.git/*" + "./target/*" +) + +ignore_args=() +for dir in "${IGNORE_DIRS[@]}"; do + ignore_args+=(-not -path "$dir") +done + +# Also lint the extensionless foundryup scripts, which `*.sh` would miss. +find . \ + \( -name "*.sh" -o -path "./foundryup/foundryup" -o -path "./foundryup/install" \) \ + "${ignore_args[@]}" -exec shellcheck -f gcc {} + | \ + while IFS=: read -r file line col severity msg; do + level="warning" + [[ "$severity" == *error* ]] && level="error" + file="${file#./}" + echo "::${level} file=${file},line=${line},col=${col}::${file}:${line}:${col}:${msg}" + done + +exit "${PIPESTATUS[0]}" diff --git a/.github/scripts/tempo-check.sh b/.github/scripts/tempo-check.sh new file mode 100755 index 0000000000000..64acf983be9f2 --- /dev/null +++ b/.github/scripts/tempo-check.sh @@ -0,0 +1,1066 @@ +#!/bin/bash +set -euo pipefail + +# Get the directory where this script lives +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Non-verification tempo checks: local tests, fork tests, cast commands, DEX operations + +# Optional hardfork override. When unset, tools resolve the latest active Tempo hardfork. +HARDFORK="${TEMPO_HARDFORK:-latest}" +HARDFORK_UPPER=$(echo "$HARDFORK" | tr '[:lower:]' '[:upper:]') + +# Fee token address, defaults to native fee token +FEE_TOKEN="${TEMPO_FEE_TOKEN:-0x20c0000000000000000000000000000000000000}" + +# T6 receive-policy precompile. +RECEIVE_POLICY_GUARD="0xB10C000000000000000000000000000000000000" + +# Build fee token args if not using native token (array for safe expansion) +FEE_TOKEN_ARG=() +if [[ "$FEE_TOKEN" != "0x20c0000000000000000000000000000000000000" ]]; then + FEE_TOKEN_ARG=(--tempo.fee-token "$FEE_TOKEN") +fi + +echo -e "\n=== USING HARDFORK: $HARDFORK ===" +echo -e "=== USING FEE TOKEN: $FEE_TOKEN ===" + +# Fund an address and wait for the fee token balance to be non-zero +fund_and_wait() { + local addr="$1" + for i in {1..100}; do + OUT=$(cast rpc tempo_fundAddress "$addr" --rpc-url "$ETH_RPC_URL" 2>&1 || true) + if echo "$OUT" | jq -e 'arrays' >/dev/null 2>&1; then + echo "$OUT" | jq + break + fi + echo "[$i] $OUT" + sleep 0.2 + done + echo "Waiting for $addr to be funded..." + for i in {1..30}; do + BAL=$(cast call --rpc-url "$ETH_RPC_URL" "$FEE_TOKEN" 'balanceOf(address)(uint256)' "$addr" 2>/dev/null || echo "0") + if [[ "$BAL" != "0" && -n "$BAL" ]]; then + echo "Funded with $BAL fee tokens" + return 0 + fi + if [[ $i -eq 30 ]]; then + echo "ERROR: Funding timed out for $addr" + exit 1 + fi + sleep 1 + done +} + +wallet_json_field() { + local wallet_json="$1" + local field="$2" + jq -r --arg field "$field" '(.data // .)[0][$field]' <<<"$wallet_json" +} + +# Fork initialization can outlast a single upstream RPC's 60-second timeout. +wait_for_anvil() { + local pid="$1" port="$2" timeout="${3:-120}" + local deadline=$((SECONDS + timeout)) + + while (( SECONDS < deadline )); do + if ! kill -0 "$pid" 2>/dev/null; then + echo "ERROR: Anvil exited before serving RPC on port $port" >&2 + return 1 + fi + if cast client --rpc-url "http://127.0.0.1:$port" --rpc-timeout 1 >/dev/null 2>&1; then + echo "Anvil started successfully on port $port" + return 0 + fi + sleep 1 + done + + echo "ERROR: Anvil did not serve RPC on port $port within ${timeout}s" >&2 + return 1 +} + +echo -e "\n=== INIT TEMPO PROJECT ===" +tmp_dir=$(mktemp -d) +cd "$tmp_dir" +forge init -n tempo tempo-check +cd tempo-check + +echo -e "\n=== FORGE TEST (LOCAL) ===" +TEMPO_FEE_TOKEN='' forge test + +echo -e "\n=== FORGE SCRIPT (LOCAL) ===" +TEMPO_FEE_TOKEN='' forge script script/Mail.s.sol --sig "run(string)" "$(date +%s%N)" + +echo -e "\n=== START TEMPO DEVNET TESTS ===" + +# Export fee token for fork tests (templates use vm.envOr to read it) +export TEMPO_FEE_TOKEN="$FEE_TOKEN" + +echo -e "\n=== TEMPO VERSION ===" +cast client --rpc-url "$ETH_RPC_URL" + +echo -e "\n=== FORGE TEST (DEVNET) ===" +forge test --rpc-url "$ETH_RPC_URL" + +echo -e "\n=== FORGE SCRIPT (DEVNET) ===" +forge script ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} script/Mail.s.sol --sig "run(string)" "$(date +%s%N)" --rpc-url "$ETH_RPC_URL" + +echo -e "\n=== CREATE AND FUND ADDRESS ===" +wallet_json="$(cast wallet new --json)" +ADDR="$(wallet_json_field "$wallet_json" address)" +PK="$(wallet_json_field "$wallet_json" private_key)" +printf "address: %s\nprivate_key: %s\n" "$ADDR" "$PK" +fund_and_wait "$ADDR" + +echo -e "\n=== DEPLOY COUNTER ===" +cp "$SCRIPT_DIR/contracts/BatchCounter.sol" src/BatchCounter.sol +COUNTER=$(forge create ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} src/BatchCounter.sol:BatchCounter \ + --rpc-url "$ETH_RPC_URL" --private-key "$PK" --broadcast --json --constructor-args 0 \ + | jq -er '(.data // .).deployedTo | select(test("^0x[0-9a-fA-F]{40}$"))') +echo "Counter deployed at: $COUNTER" + +echo -e "\n=== ADD AlphaUSD FEE TOKEN LIQUIDITY ===" +if [[ ${#FEE_TOKEN_ARG[@]} -eq 0 ]]; then + cast send 0xfeec000000000000000000000000000000000000 'mint(address,address,uint256,address)' 0x20C0000000000000000000000000000000000001 0x20C0000000000000000000000000000000000000 1000000000 0x6c4143BEd3A13cf9E5E43d45C60aD816FC091d0c --private-key "$PK" --rpc-url "$ETH_RPC_URL" +else + echo "skipped (custom fee token set)" +fi + +echo -e "\n=== ADD BetaUSD FEE TOKEN LIQUIDITY ===" +if [[ ${#FEE_TOKEN_ARG[@]} -eq 0 ]]; then + cast send 0xfeec000000000000000000000000000000000000 'mint(address,address,uint256,address)' 0x20C0000000000000000000000000000000000002 0x20C0000000000000000000000000000000000000 1000000000 0x6c4143BEd3A13cf9E5E43d45C60aD816FC091d0c --private-key "$PK" --rpc-url "$ETH_RPC_URL" +else + echo "skipped (custom fee token set)" +fi + +echo -e "\n=== ADD ThetaUSD FEE TOKEN LIQUIDITY ===" +if [[ ${#FEE_TOKEN_ARG[@]} -eq 0 ]]; then + cast send 0xfeec000000000000000000000000000000000000 'mint(address,address,uint256,address)' 0x20C0000000000000000000000000000000000003 0x20C0000000000000000000000000000000000000 1000000000 0x6c4143BEd3A13cf9E5E43d45C60aD816FC091d0c --private-key "$PK" --rpc-url "$ETH_RPC_URL" +else + echo "skipped (custom fee token set)" +fi + +echo -e "\n=== CAST ERC20 TRANSFER WITH FEE TOKEN ===" +if [[ ${#FEE_TOKEN_ARG[@]} -eq 0 ]]; then + cast erc20 transfer --tempo.fee-token 0x20C0000000000000000000000000000000000002 0x20c0000000000000000000000000000000000002 0x4ef5DFf69C1514f4Dbf85aA4F9D95F804F64275F 123456 --rpc-url "$ETH_RPC_URL" --private-key "$PK" + cast erc20 transfer --tempo.fee-token 0x20C0000000000000000000000000000000000003 0x20c0000000000000000000000000000000000002 0x4ef5DFf69C1514f4Dbf85aA4F9D95F804F64275F 123456 --rpc-url "$ETH_RPC_URL" --private-key "$PK" +else + cast erc20 transfer ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} "${FEE_TOKEN}" 0x4ef5DFf69C1514f4Dbf85aA4F9D95F804F64275F 123456 --rpc-url "$ETH_RPC_URL" --private-key "$PK" +fi + +echo -e "\n=== CAST ERC20 APPROVE WITH FEE TOKEN ===" +if [[ ${#FEE_TOKEN_ARG[@]} -eq 0 ]]; then + cast erc20 approve --tempo.fee-token 0x20C0000000000000000000000000000000000002 0x20c0000000000000000000000000000000000002 0x4ef5DFf69C1514f4Dbf85aA4F9D95F804F64275F 123456 --rpc-url "$ETH_RPC_URL" --private-key "$PK" + cast erc20 approve --tempo.fee-token 0x20C0000000000000000000000000000000000003 0x20c0000000000000000000000000000000000002 0x4ef5DFf69C1514f4Dbf85aA4F9D95F804F64275F 123456 --rpc-url "$ETH_RPC_URL" --private-key "$PK" +else + echo "skipped (custom fee token set)" +fi + +echo -e "\n=== CAST SEND WITH FEE TOKEN ===" +if [[ ${#FEE_TOKEN_ARG[@]} -eq 0 ]]; then + cast send --tempo.fee-token 0x20C0000000000000000000000000000000000002 --rpc-url "$ETH_RPC_URL" "$COUNTER" 'increment()' --private-key "$PK" + cast send --tempo.fee-token 0x20C0000000000000000000000000000000000003 --rpc-url "$ETH_RPC_URL" "$COUNTER" 'increment()' --private-key "$PK" +else + cast send ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} --rpc-url "$ETH_RPC_URL" "$COUNTER" 'increment()' --private-key "$PK" +fi + +echo -e "\n=== CAST MKTX WITH FEE TOKEN ===" +cast mktx ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} --rpc-url "$ETH_RPC_URL" "$COUNTER" 'increment()' --private-key "$PK" + +echo -e "\n=== CAST MKTX WITH NONCE-KEY (2D Nonce) ===" +# Each nonce-key has its own nonce sequence starting at 0 +cast mktx ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} --rpc-url "$ETH_RPC_URL" "$COUNTER" 'increment()' --private-key "$PK" --nonce 0 --tempo.nonce-key 1 + +echo -e "\n=== CAST SEND WITH NONCE-KEY (2D Nonce) ===" +# Use a different nonce-key (2) with nonce 0 since each key starts fresh +cast send ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} --rpc-url "$ETH_RPC_URL" "$COUNTER" 'increment()' --private-key "$PK" --nonce 0 --tempo.nonce-key 2 + +echo -e "\n=== CAST MKTX WITH EXPIRING NONCE (TIP-1009) ===" +# Use the node's block timestamp to avoid clock skew between CI runner and devnet. +BLOCK_TS=$(cast block latest --rpc-url "$ETH_RPC_URL" -f timestamp) +cast mktx ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} --rpc-url "$ETH_RPC_URL" "$COUNTER" 'increment()' --private-key "$PK" --tempo.expiring-nonce --tempo.valid-before "$((BLOCK_TS + 25))" + +echo -e "\n=== CAST SEND WITH EXPIRING NONCE (TIP-1009) ===" +BLOCK_TS=$(cast block latest --rpc-url "$ETH_RPC_URL" -f timestamp) +cast send ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} --rpc-url "$ETH_RPC_URL" "$COUNTER" 'increment()' --private-key "$PK" --tempo.expiring-nonce --tempo.valid-before "$((BLOCK_TS + 25))" + +echo -e "\n=== CAST MKTX WITH EXPIRING NONCE + VALID-AFTER ===" +BLOCK_TS=$(cast block latest --rpc-url "$ETH_RPC_URL" -f timestamp) +cast mktx ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} --rpc-url "$ETH_RPC_URL" "$COUNTER" 'increment()' --private-key "$PK" --tempo.expiring-nonce --tempo.valid-before "$((BLOCK_TS + 25))" --tempo.valid-after "$((BLOCK_TS + 5))" + +echo -e "\n=== CAST SEND WITH EXPIRING NONCE + VALID-AFTER ===" +sleep 6 # Wait for valid_after to pass +BLOCK_TS=$(cast block latest --rpc-url "$ETH_RPC_URL" -f timestamp) +cast send ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} --rpc-url "$ETH_RPC_URL" "$COUNTER" 'increment()' --private-key "$PK" --tempo.expiring-nonce --tempo.valid-before "$((BLOCK_TS + 25))" --tempo.valid-after "$((BLOCK_TS - 1))" + +echo -e "\n=== SETUP ACCESS KEY ===" +# Create an access key for testing +access_wallet_json="$(cast wallet new --json)" +ACCESS_KEY="$(wallet_json_field "$access_wallet_json" private_key)" +ACCESS_KEY_ADDR="$(wallet_json_field "$access_wallet_json" address)" +printf "Access key address: %s\n" "$ACCESS_KEY_ADDR" + +# Authorize the access key on-chain first (required for gas estimation) +# Account Keychain precompile: 0xAAAAAAAA00000000000000000000000000000000 +# SignatureType: 0 = Secp256k1, Expiry: 1893456000 (year 2030), enforceLimits: false, limits: [], allowAnyCalls: true +if [[ "$HARDFORK" == "T2" ]]; then + # Legacy: authorizeKey with flat params (pre-T3) + cast send --rpc-url "$ETH_RPC_URL" 0xAAAAAAAA00000000000000000000000000000000 \ + 'authorizeKey(address,uint8,uint64,bool,(address,uint256)[])' \ + "$ACCESS_KEY_ADDR" 0 1893456000 false "[]" \ + --private-key "$PK" +else + # TIP-1011 (T3+): authorizeKey takes a KeyRestrictions struct + # KeyRestrictions = (uint64 expiry, bool enforceLimits, TokenLimit[] limits, bool allowAnyCalls, CallScope[] allowedCalls) + # TokenLimit = (address token, uint256 amount, uint64 period) + # CallScope = (address target, SelectorRule[] selectorRules) + # SelectorRule = (bytes4 selector, address[] recipients) + cast send --rpc-url "$ETH_RPC_URL" 0xAAAAAAAA00000000000000000000000000000000 \ + 'authorizeKey(address,uint8,(uint64,bool,(address,uint256,uint64)[],bool,(address,(bytes4,address[])[])[])) ' \ + "$ACCESS_KEY_ADDR" 0 "(1893456000,false,[],true,[])" \ + --private-key "$PK" +fi + +# Fund the access key address (needed for gas) +fund_and_wait "$ACCESS_KEY_ADDR" + +echo -e "\n=== CAST MKTX WITH ACCESS-KEY ===" +# Use original address as root account (access key signs on behalf of root) +cast mktx ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} --rpc-url "$ETH_RPC_URL" "$COUNTER" 'increment()' --tempo.access-key "$ACCESS_KEY" --tempo.root-account "$ADDR" + +echo -e "\n=== CAST SEND WITH ACCESS-KEY ===" +# Send transaction using the access key (Keychain signature wrapped in AA transaction) +cast send ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} --rpc-url "$ETH_RPC_URL" "$COUNTER" 'increment()' --tempo.access-key "$ACCESS_KEY" --tempo.root-account "$ADDR" + +# --- cast keychain subcommand tests --- + +echo -e "\n=== CAST KEYCHAIN: AUTHORIZE ===" +kc_wallet_json="$(cast wallet new --json)" +KC_KEY_PK="$(wallet_json_field "$kc_wallet_json" private_key)" +KC_KEY_ADDR="$(wallet_json_field "$kc_wallet_json" address)" +printf "Keychain key address: %s\n" "$KC_KEY_ADDR" + +cast keychain auth "$KC_KEY_ADDR" secp256k1 1893456000 \ + --rpc-url "$ETH_RPC_URL" --private-key "$PK" ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} + +echo -e "\n=== CAST KEYCHAIN: KEY-INFO ===" +KC_INFO=$(cast keychain info "$ADDR" "$KC_KEY_ADDR" --rpc-url "$ETH_RPC_URL") +echo "$KC_INFO" +echo "$KC_INFO" | grep -q "secp256k1" + +echo -e "\n=== CAST KEYCHAIN: KEY-INFO --json ===" +KC_INFO_JSON=$(cast keychain info "$ADDR" "$KC_KEY_ADDR" --rpc-url "$ETH_RPC_URL" --json) +echo "$KC_INFO_JSON" | jq -e '.data.signatureType == "secp256k1"' + +echo -e "\n=== CAST KEYCHAIN: AUTHORIZE WITH LIMIT ===" +kc_limited_json="$(cast wallet new --json)" +KC_LIMITED_ADDR="$(wallet_json_field "$kc_limited_json" address)" +cast keychain auth "$KC_LIMITED_ADDR" secp256k1 1893456000 \ + --limit "$FEE_TOKEN:1000000000" \ + --rpc-url "$ETH_RPC_URL" --private-key "$PK" ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} + +echo -e "\n=== CAST KEYCHAIN: REMAINING-LIMIT ===" +KC_REMAINING=$(cast keychain rl "$ADDR" "$KC_LIMITED_ADDR" "$FEE_TOKEN" --rpc-url "$ETH_RPC_URL") +echo "Remaining: $KC_REMAINING" +[[ "$KC_REMAINING" != "0" ]] || { echo "ERROR: expected non-zero limit"; exit 1; } + +echo -e "\n=== CAST KEYCHAIN: REMAINING-LIMIT --json ===" +KC_REMAINING_JSON=$(cast keychain rl "$ADDR" "$KC_LIMITED_ADDR" "$FEE_TOKEN" --rpc-url "$ETH_RPC_URL" --json) +echo "$KC_REMAINING_JSON" | jq -e '. != "0"' + +echo -e "\n=== CAST KEYCHAIN: UPDATE-LIMIT ===" +cast keychain ul "$KC_LIMITED_ADDR" "$FEE_TOKEN" 500000000 \ + --rpc-url "$ETH_RPC_URL" --private-key "$PK" ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} + +echo -e "\n=== CAST KEYCHAIN: VERIFY UPDATE-LIMIT ===" +KC_UPDATED=$(cast keychain rl "$ADDR" "$KC_LIMITED_ADDR" "$FEE_TOKEN" --rpc-url "$ETH_RPC_URL") +echo "Remaining after update: $KC_UPDATED" +[[ "$KC_UPDATED" == "500000000" ]] || { echo "ERROR: expected 500000000 after update-limit, got $KC_UPDATED"; exit 1; } + +echo -e "\n=== CAST KEYCHAIN: REVOKE ===" +cast keychain rev "$KC_KEY_ADDR" \ + --rpc-url "$ETH_RPC_URL" --private-key "$PK" ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} + +# Verify revocation +KC_INFO_REV=$(cast keychain info "$ADDR" "$KC_KEY_ADDR" --rpc-url "$ETH_RPC_URL") +echo "$KC_INFO_REV" +echo "$KC_INFO_REV" | grep -q "revoked" + +echo -e "\n=== CAST KEYCHAIN: REVOKED KEY REJECTION ===" +# Fund the revoked key so failure is due to revocation, not gas +fund_and_wait "$KC_KEY_ADDR" +if cast send ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} --rpc-url "$ETH_RPC_URL" \ + "$COUNTER" 'increment()' \ + --tempo.access-key "$KC_KEY_PK" --tempo.root-account "$ADDR" 2>&1; then + echo "ERROR: revoked key should have been rejected" + exit 1 +fi +echo "OK: revoked key correctly rejected" + +echo -e "\n=== CAST KEYCHAIN: DUPLICATE AUTHORIZE REJECTION ===" +# Try to authorize KC_LIMITED_ADDR again — should fail with KeyAlreadyExists +if cast keychain auth "$KC_LIMITED_ADDR" secp256k1 1893456000 \ + --rpc-url "$ETH_RPC_URL" --private-key "$PK" ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} 2>&1; then + echo "ERROR: duplicate authorize should have been rejected" + exit 1 +fi +echo "OK: duplicate authorize correctly rejected" + +if [[ "$HARDFORK_UPPER" == "T6" ]]; then + echo -e "\n=== CAST KEYCHAIN: T6 AUTHORIZE ADMIN ===" + kc_admin_json="$(cast wallet new --json)" + KC_ADMIN_ADDR="$(wallet_json_field "$kc_admin_json" address)" + cast keychain auth "$KC_ADMIN_ADDR" --admin \ + --rpc-url "$ETH_RPC_URL" --private-key "$PK" ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} + + echo -e "\n=== CAST KEYCHAIN: T6 IS-ADMIN ===" + KC_ADMIN_JSON=$(cast keychain is-admin "$ADDR" "$KC_ADMIN_ADDR" --rpc-url "$ETH_RPC_URL" --json) + echo "$KC_ADMIN_JSON" | jq + echo "$KC_ADMIN_JSON" | jq -e '.is_admin == true' + + echo -e "\n=== CAST KEYCHAIN: T6 ADMIN REJECTS LIMITS ===" + kc_admin_reject_json="$(cast wallet new --json)" + KC_ADMIN_REJECT_ADDR="$(wallet_json_field "$kc_admin_reject_json" address)" + if cast keychain auth "$KC_ADMIN_REJECT_ADDR" --admin --enforce-limits \ + --rpc-url "$ETH_RPC_URL" --private-key "$PK" ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} 2>&1; then + echo "ERROR: admin authorize with spending limits should have been rejected" + exit 1 + fi + echo "OK: admin authorize with spending limits correctly rejected" + + echo -e "\n=== CAST RECEIVE-POLICY: T6 SET REJECT-ALL POLICY ===" + rp_receiver_json="$(cast wallet new --json)" + RP_RECEIVER_ADDR="$(wallet_json_field "$rp_receiver_json" address)" + RP_RECEIVER_PK="$(wallet_json_field "$rp_receiver_json" private_key)" + rp_recovery_json="$(cast wallet new --json)" + RP_RECOVERY_ADDR="$(wallet_json_field "$rp_recovery_json" address)" + RP_RECOVERY_PK="$(wallet_json_field "$rp_recovery_json" private_key)" + rp_claim_json="$(cast wallet new --json)" + RP_CLAIM_ADDR="$(wallet_json_field "$rp_claim_json" address)" + printf "Receive-policy receiver: %s\nrecovery: %s\nclaim target: %s\n" \ + "$RP_RECEIVER_ADDR" "$RP_RECOVERY_ADDR" "$RP_CLAIM_ADDR" + + fund_and_wait "$RP_RECEIVER_ADDR" + fund_and_wait "$RP_RECOVERY_ADDR" + + cast receive-policy set 0 1 \ + --recovery-authority "$RP_RECOVERY_ADDR" \ + --rpc-url "$ETH_RPC_URL" --private-key "$RP_RECEIVER_PK" \ + ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} + + RP_POLICY_JSON=$(cast --json receive-policy get "$RP_RECEIVER_ADDR" --rpc-url "$ETH_RPC_URL") + echo "$RP_POLICY_JSON" | jq + echo "$RP_POLICY_JSON" | jq -e ' + .success == true and + .data.has_receive_policy == true and + .data.sender_policy_id == 0 and + .data.token_filter_id == 1 and + .data.recovery_mode == "authority" + ' + + echo -e "\n=== CAST RECEIVE-POLICY: T6 VALIDATE HELD TRANSFER ===" + RP_VALIDATE_JSON=$(cast --json receive-policy validate "$FEE_TOKEN" "$ADDR" "$RP_RECEIVER_ADDR" --rpc-url "$ETH_RPC_URL") + echo "$RP_VALIDATE_JSON" | jq + echo "$RP_VALIDATE_JSON" | jq -e ' + .success == true and + .data.authorized == false and + .data.blocked_reason == "receive_policy" and + .data.delivery_state == "held" + ' + + echo -e "\n=== CAST RECEIVE-POLICY: T6 BLOCKED TRANSFER RECEIPT ===" + RP_AMOUNT=77000 + RP_TRANSFER_JSON=$(cast erc20 transfer ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} "$FEE_TOKEN" "$RP_RECEIVER_ADDR" "$RP_AMOUNT" --rpc-url "$ETH_RPC_URL" --private-key "$PK" --json) + echo "$RP_TRANSFER_JSON" | jq + RECEIVE_POLICY_GUARD_LOWER=$(echo "$RECEIVE_POLICY_GUARD" | tr '[:upper:]' '[:lower:]') + RP_BLOCKED_DATA=$(echo "$RP_TRANSFER_JSON" | jq -r --arg guard "$RECEIVE_POLICY_GUARD_LOWER" ' + [.logs[]? | select((.address | ascii_downcase) == $guard) | .data][0] // empty + ') + if [[ -z "$RP_BLOCKED_DATA" ]]; then + echo "ERROR: TransferBlocked log not found in transfer receipt" + exit 1 + fi + mapfile -t RP_BLOCKED_FIELDS < <(cast abi-decode 'f()(uint256,uint8,bytes)' "$RP_BLOCKED_DATA") + # `cast abi-decode` pretty-prints large integers as "77000 [7.7e4]"; compare the raw numeric token. + RP_BLOCKED_AMOUNT="${RP_BLOCKED_FIELDS[0]%% *}" + RP_RECEIPT_VERSION="${RP_BLOCKED_FIELDS[1]}" + RP_RECEIPT="${RP_BLOCKED_FIELDS[2]}" + [[ "$RP_BLOCKED_AMOUNT" == "$RP_AMOUNT" ]] || { echo "ERROR: blocked amount mismatch: $RP_BLOCKED_AMOUNT"; exit 1; } + [[ "$RP_RECEIPT_VERSION" == "1" ]] || { echo "ERROR: expected receipt version 1, got $RP_RECEIPT_VERSION"; exit 1; } + + RP_RECEIPT_JSON=$(cast --json receive-policy receipt decode "$RP_RECEIPT") + echo "$RP_RECEIPT_JSON" | jq + echo "$RP_RECEIPT_JSON" | jq -e \ + --arg receipt "$RP_RECEIPT" \ + --arg token "$FEE_TOKEN" \ + --arg recovery "$RP_RECOVERY_ADDR" \ + --arg originator "$ADDR" \ + --arg recipient "$RP_RECEIVER_ADDR" ' + .success == true and + (.data.receipt | ascii_downcase) == ($receipt | ascii_downcase) and + (.data.token | ascii_downcase) == ($token | ascii_downcase) and + (.data.recovery_authority | ascii_downcase) == ($recovery | ascii_downcase) and + (.data.originator | ascii_downcase) == ($originator | ascii_downcase) and + (.data.recipient | ascii_downcase) == ($recipient | ascii_downcase) and + .data.blocked_reason == "receive_policy" and + .data.kind == "transfer" + ' + + RP_BALANCE_JSON=$(cast --json receive-policy receipt balance "$RP_RECEIPT" --rpc-url "$ETH_RPC_URL") + echo "$RP_BALANCE_JSON" | jq + echo "$RP_BALANCE_JSON" | jq -e --arg amount "$RP_AMOUNT" ' + .success == true and + .data.held_balance == $amount and + .data.delivery_state == "held" + ' + + echo -e "\n=== CAST RECEIVE-POLICY: T6 CLAIM BLOCKED RECEIPT ===" + cast receive-policy claim "$RP_CLAIM_ADDR" "$RP_RECEIPT" \ + --rpc-url "$ETH_RPC_URL" --private-key "$RP_RECOVERY_PK" \ + ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} + + RP_CLAIMED_BALANCE_JSON=$(cast --json receive-policy receipt balance "$RP_RECEIPT" --rpc-url "$ETH_RPC_URL") + echo "$RP_CLAIMED_BALANCE_JSON" | jq + echo "$RP_CLAIMED_BALANCE_JSON" | jq -e ' + .success == true and + .data.held_balance == "0" and + .data.delivery_state == "not_held" + ' +else + echo -e "\n=== SKIPPING T6 receive-policy/admin-key tests (HARDFORK=$HARDFORK) ===" +fi + +# --- T3+ set-scope tests --- +if [[ ! "$HARDFORK_UPPER" =~ ^T(0|1|1B|2)$ ]]; then + echo -e "\n=== CAST KEYCHAIN: SET-SCOPE ===" + # Provision a fresh unrestricted key through the AccountKeychain precompile. + kc_ss_json="$(cast wallet new --json)" + KC_SS_PK="$(wallet_json_field "$kc_ss_json" private_key)" + KC_SS_ADDR="$(wallet_json_field "$kc_ss_json" address)" + cast keychain auth "$KC_SS_ADDR" secp256k1 1893456000 \ + --rpc-url "$ETH_RPC_URL" --private-key "$PK" ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} + fund_and_wait "$KC_SS_ADDR" + + cast keychain ss "$KC_SS_ADDR" \ + --scope "$COUNTER" \ + --rpc-url "$ETH_RPC_URL" --private-key "$PK" ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} + echo "OK: set-scope applied" + + echo -e "\n=== CAST KEYCHAIN: SET-SCOPE ALLOWED ===" + cast send ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} --rpc-url "$ETH_RPC_URL" \ + "$COUNTER" 'increment()' \ + --tempo.access-key "$KC_SS_PK" --tempo.root-account "$ADDR" + echo "OK: set-scope key allowed to call permitted target" + + echo -e "\n=== CAST KEYCHAIN: SET-SCOPE BLOCKED ===" + if cast send ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} --rpc-url "$ETH_RPC_URL" \ + 0x4ef5DFf69C1514f4Dbf85aA4F9D95F804F64275F 'doesNotExist()' \ + --tempo.access-key "$KC_SS_PK" --tempo.root-account "$ADDR" 2>&1; then + echo "ERROR: set-scope key should have been blocked for disallowed target" + exit 1 + fi + echo "OK: set-scope key correctly blocked for disallowed target" + + echo -e "\n=== CAST KEYCHAIN: REMOVE-SCOPE ===" + cast keychain rs "$KC_SS_ADDR" "$COUNTER" \ + --rpc-url "$ETH_RPC_URL" --private-key "$PK" ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} + + echo -e "\n=== CAST KEYCHAIN: REMOVE-SCOPE BLOCKED ===" + if cast send ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} --rpc-url "$ETH_RPC_URL" \ + "$COUNTER" 'increment()' \ + --tempo.access-key "$KC_SS_PK" --tempo.root-account "$ADDR" 2>&1; then + echo "ERROR: call should have been blocked after remove-scope" + exit 1 + fi + echo "OK: call correctly blocked after remove-scope" +else + echo -e "\n=== SKIPPING T3+ set-scope tests (HARDFORK=$HARDFORK) ===" +fi + +# --- T3-only scope / call-restriction tests --- +if [[ "$HARDFORK" == "T3" ]]; then + echo -e "\n=== CAST KEYCHAIN: AUTHORIZE WITH --scope (ADDRESS ONLY, UNRESTRICTED) ===" + kc_scoped_json="$(cast wallet new --json)" + KC_SCOPED_PK="$(wallet_json_field "$kc_scoped_json" private_key)" + KC_SCOPED_ADDR="$(wallet_json_field "$kc_scoped_json" address)" + cast keychain auth "$KC_SCOPED_ADDR" secp256k1 1893456000 \ + --scope "$COUNTER" \ + --rpc-url "$ETH_RPC_URL" --private-key "$PK" ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} + + echo -e "\n=== CAST KEYCHAIN: SCOPE ALLOWED TARGET ===" + fund_and_wait "$KC_SCOPED_ADDR" + cast send ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} --rpc-url "$ETH_RPC_URL" \ + "$COUNTER" 'increment()' \ + --tempo.access-key "$KC_SCOPED_PK" --tempo.root-account "$ADDR" + echo "OK: scoped key allowed to call permitted target" + + echo -e "\n=== CAST KEYCHAIN: SCOPE BLOCKED TARGET ===" + if cast send ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} --rpc-url "$ETH_RPC_URL" \ + 0x4ef5DFf69C1514f4Dbf85aA4F9D95F804F64275F 'doesNotExist()' \ + --tempo.access-key "$KC_SCOPED_PK" --tempo.root-account "$ADDR" 2>&1; then + echo "ERROR: scoped key should have been blocked for disallowed target" + exit 1 + fi + echo "OK: scoped key correctly blocked for disallowed target" + + echo -e "\n=== CAST KEYCHAIN: AUTHORIZE WITH --scope + SELECTORS ===" + kc_sel_json="$(cast wallet new --json)" + KC_SEL_PK="$(wallet_json_field "$kc_sel_json" private_key)" + KC_SEL_ADDR="$(wallet_json_field "$kc_sel_json" address)" + cast keychain auth "$KC_SEL_ADDR" secp256k1 1893456000 \ + --scope "$FEE_TOKEN:transfer,approve" \ + --rpc-url "$ETH_RPC_URL" --private-key "$PK" ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} + echo "OK: authorized key with selector-scoped restrictions" + + echo -e "\n=== CAST KEYCHAIN: SELECTOR-SCOPED TRANSFER ALLOWED ===" + fund_and_wait "$KC_SEL_ADDR" + cast erc20 transfer ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} "$FEE_TOKEN" \ + 0x4ef5DFf69C1514f4Dbf85aA4F9D95F804F64275F 100 \ + --rpc-url "$ETH_RPC_URL" \ + --tempo.access-key "$KC_SEL_PK" --tempo.root-account "$ADDR" + echo "OK: selector-scoped key allowed transfer on permitted TIP-20" + + echo -e "\n=== CAST KEYCHAIN: AUTHORIZE WITH --scopes JSON ===" + kc_json_json="$(cast wallet new --json)" + KC_JSON_ADDR="$(wallet_json_field "$kc_json_json" address)" + cast keychain auth "$KC_JSON_ADDR" secp256k1 1893456000 \ + --scopes "[{\"target\":\"$FEE_TOKEN\",\"selectors\":[\"transfer\"]},{\"target\":\"${COUNTER}\"}]" \ + --rpc-url "$ETH_RPC_URL" --private-key "$PK" ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} + echo "OK: authorized key with --scopes JSON" + + echo -e "\n=== CAST KEYCHAIN: AUTHORIZE WITH MULTIPLE LIMITS ===" + kc_multi_json="$(cast wallet new --json)" + KC_MULTI_ADDR="$(wallet_json_field "$kc_multi_json" address)" + cast keychain auth "$KC_MULTI_ADDR" secp256k1 1893456000 \ + --limit "$FEE_TOKEN:1000000" \ + --limit "0x20C0000000000000000000000000000000000001:2000000" \ + --rpc-url "$ETH_RPC_URL" --private-key "$PK" ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} + + # Verify both limits + KC_MULTI_RL1=$(cast keychain rl "$ADDR" "$KC_MULTI_ADDR" "$FEE_TOKEN" --rpc-url "$ETH_RPC_URL") + KC_MULTI_RL2=$(cast keychain rl "$ADDR" "$KC_MULTI_ADDR" 0x20C0000000000000000000000000000000000001 --rpc-url "$ETH_RPC_URL") + echo "Limit 1: $KC_MULTI_RL1 (expected 1000000), Limit 2: $KC_MULTI_RL2 (expected 2000000)" + [[ "$KC_MULTI_RL1" == "1000000" ]] || { echo "ERROR: limit 1 mismatch"; exit 1; } + [[ "$KC_MULTI_RL2" == "2000000" ]] || { echo "ERROR: limit 2 mismatch"; exit 1; } + + echo -e "\n=== CAST KEYCHAIN: AUTHORIZE WITH RAW HEX SELECTOR ===" + kc_hex_json="$(cast wallet new --json)" + KC_HEX_PK="$(wallet_json_field "$kc_hex_json" private_key)" + KC_HEX_ADDR="$(wallet_json_field "$kc_hex_json" address)" + # increment() selector = 0xd09de08a + cast keychain auth "$KC_HEX_ADDR" secp256k1 1893456000 \ + --scope "${COUNTER}:0xd09de08a" \ + --rpc-url "$ETH_RPC_URL" --private-key "$PK" ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} + echo "OK: authorized key with raw hex selector" + + echo -e "\n=== CAST KEYCHAIN: RAW HEX SELECTOR ALLOWED ===" + fund_and_wait "$KC_HEX_ADDR" + cast send ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} --rpc-url "$ETH_RPC_URL" \ + "$COUNTER" 'increment()' \ + --tempo.access-key "$KC_HEX_PK" --tempo.root-account "$ADDR" + echo "OK: raw hex selector key allowed to call increment()" + + echo -e "\n=== CAST KEYCHAIN: AUTHORIZE WITH RECIPIENT RESTRICTION ===" + kc_recip_json="$(cast wallet new --json)" + KC_RECIP_PK="$(wallet_json_field "$kc_recip_json" private_key)" + KC_RECIP_ADDR="$(wallet_json_field "$kc_recip_json" address)" + # Only allow transfer to a specific recipient + ALLOWED_RECIPIENT="0x4ef5DFf69C1514f4Dbf85aA4F9D95F804F64275F" + cast keychain auth "$KC_RECIP_ADDR" secp256k1 1893456000 \ + --scope "$FEE_TOKEN:transfer@$ALLOWED_RECIPIENT" \ + --rpc-url "$ETH_RPC_URL" --private-key "$PK" ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} + echo "OK: authorized key with recipient-restricted transfer" + + echo -e "\n=== CAST KEYCHAIN: RECIPIENT-SCOPED TRANSFER ALLOWED ===" + fund_and_wait "$KC_RECIP_ADDR" + cast erc20 transfer ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} "$FEE_TOKEN" \ + "$ALLOWED_RECIPIENT" 100 \ + --rpc-url "$ETH_RPC_URL" \ + --tempo.access-key "$KC_RECIP_PK" --tempo.root-account "$ADDR" + echo "OK: recipient-scoped transfer allowed to permitted recipient" + + echo -e "\n=== CAST KEYCHAIN: --scopes JSON WITH RECIPIENTS ===" + kc_jsonr_json="$(cast wallet new --json)" + KC_JSONR_ADDR="$(wallet_json_field "$kc_jsonr_json" address)" + cast keychain auth "$KC_JSONR_ADDR" secp256k1 1893456000 \ + --scopes "[{\"target\":\"$FEE_TOKEN\",\"selectors\":[{\"selector\":\"transfer\",\"recipients\":[\"$ALLOWED_RECIPIENT\"]}]},{\"target\":\"${COUNTER}\"}]" \ + --rpc-url "$ETH_RPC_URL" --private-key "$PK" ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} + echo "OK: authorized key with --scopes JSON including recipients" +else + echo -e "\n=== SKIPPING T3-only scope/call-restriction tests (HARDFORK=$HARDFORK) ===" +fi # end T3-only scope tests + +echo -e "\n=== SETUP SPONSOR ===" +# Create a sponsor wallet for testing sponsored (gasless) transactions +sponsor_wallet_json="$(cast wallet new --json)" +SPONSOR_PK="$(wallet_json_field "$sponsor_wallet_json" private_key)" +SPONSOR_ADDR="$(wallet_json_field "$sponsor_wallet_json" address)" +printf "Sponsor address: %s\n" "$SPONSOR_ADDR" + +# Fund the sponsor address (sponsor pays gas) +fund_and_wait "$SPONSOR_ADDR" + +echo -e "\n=== CAST SEND WITH SPONSOR (--tempo.sponsor-signature) ===" +# Test sponsored transactions using pre-signed signature. +# Step 1: Get the fee_payer_signature_hash using --tempo.print-sponsor-hash +# Step 2: Sign it with the sponsor's private key +# Step 3: Send with --tempo.sponsor and --tempo.sponsor-signature + +# The sponsor digest commits to the full transaction, including nonce, gas limit and +# fees. Pin those fields explicitly so `cast mktx` and `cast send` build the identical +# transaction: refilling them from live chain state (basefee moves, gas estimates change) +# between the two invocations changes the digest and invalidates the pre-signed sponsor +# signature. The gas limit is pinned to a padded estimate; a limit below actual usage +# would make the transaction run out of gas during Tempo AA validation and be dropped +# without a receipt. +SPONSOR_TX_NONCE=$(cast nonce "$ADDR" --rpc-url "$ETH_RPC_URL") +SPONSOR_TX_GAS=$(cast estimate --rpc-url "$ETH_RPC_URL" \ + "$COUNTER" 'increment()' --from "$ADDR") +SPONSOR_TX_ARGS=( + "$COUNTER" 'increment()' + --nonce "$SPONSOR_TX_NONCE" --gas-limit $((2 * SPONSOR_TX_GAS)) + --gas-price 20gwei --priority-gas-price 1gwei +) + +# Step 1: Get the hash that the sponsor needs to sign +FEE_PAYER_HASH=$(cast mktx ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} --rpc-url "$ETH_RPC_URL" \ + "${SPONSOR_TX_ARGS[@]}" --private-key "$PK" \ + --tempo.print-sponsor-hash) +printf "Fee payer signature hash: %s\n" "$FEE_PAYER_HASH" + +# Step 2: Sponsor signs the hash +SPONSOR_SIG=$(cast wallet sign --private-key "$SPONSOR_PK" "$FEE_PAYER_HASH" --no-hash) +printf "Sponsor signature: %s\n" "$SPONSOR_SIG" + +# Step 3: Send the sponsored transaction with the signature. With --json, cast reports +# errors as a JSON envelope on stdout, which the command substitution captures; print the +# captured output on failure so the error is visible in CI logs. +if ! RECEIPT=$(cast send ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} --rpc-url "$ETH_RPC_URL" \ + "${SPONSOR_TX_ARGS[@]}" --private-key "$PK" \ + --tempo.sponsor "$SPONSOR_ADDR" --tempo.sponsor-signature "$SPONSOR_SIG" --json); then + echo "ERROR: sponsored cast send failed" + echo "Output: $RECEIPT" + exit 1 +fi + +# Verify the fee_payer in the receipt matches the sponsor address +RECEIPT_FEE_PAYER=$(echo "$RECEIPT" | jq -r '.feePayer // .fee_payer // empty') +if [[ -z "$RECEIPT_FEE_PAYER" ]]; then + echo "ERROR: feePayer not found in receipt" + echo "Receipt: $RECEIPT" + exit 1 +fi + +# Normalize addresses for comparison (lowercase) +RECEIPT_FEE_PAYER_LOWER=$(echo "$RECEIPT_FEE_PAYER" | tr '[:upper:]' '[:lower:]') +SPONSOR_ADDR_LOWER=$(echo "$SPONSOR_ADDR" | tr '[:upper:]' '[:lower:]') +if [[ "$RECEIPT_FEE_PAYER_LOWER" != "$SPONSOR_ADDR_LOWER" ]]; then + echo "ERROR: Receipt feePayer ($RECEIPT_FEE_PAYER) does not match sponsor ($SPONSOR_ADDR)" + exit 1 +fi +echo "SUCCESS: Receipt feePayer ($RECEIPT_FEE_PAYER) matches sponsor address" + +# Batch transaction tests (available on all hardforks) +echo -e "\n=== CAST BATCH-MKTX (NATIVE BATCHING) ===" +# Build a batch transaction with multiple calls as a single type 0x76 transaction +cast batch-mktx ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} --rpc-url "$ETH_RPC_URL" \ + --call "${COUNTER}::increment()" \ + --call "${COUNTER}::increment()" \ + --private-key "$PK" + +echo -e "\n=== CAST BATCH-SEND (NATIVE BATCHING) ===" +# Send a batch transaction with multiple calls as a single type 0x76 transaction +cast batch-send ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} --rpc-url "$ETH_RPC_URL" \ + --call "${COUNTER}::increment()" \ + --call "${COUNTER}::increment()" \ + --private-key "$PK" + +echo -e "\n=== CAST BATCH-SEND WITH VALUE SYNTAX (NATIVE BATCHING) ===" +# Test batch transaction with value syntax (currently using 0 value) +# TODO: Update to use non-zero value (e.g., 0.0001ether) once tempo#2294 is merged +# and the node supports per-call value transfers in batch transactions. +cast batch-send ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} --rpc-url "$ETH_RPC_URL" \ + --call "${COUNTER}:0:increment()" \ + --call "${COUNTER}::increment()" \ + --private-key "$PK" + +echo -e "\n=== DEPLOY COUNTER WITH REQUIRE ===" +# Use CounterWithRequire.sol (has require(newNumber > 100)) for batch revert testing +cp "$SCRIPT_DIR/contracts/CounterWithRequire.sol" src/Counter.sol +forge build +REQUIRE_COUNTER_OUTPUT=$(forge create ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} src/Counter.sol:Counter --rpc-url "$ETH_RPC_URL" --private-key "$PK" --broadcast 2>&1) +echo "Deploy output: $REQUIRE_COUNTER_OUTPUT" +# Extract address from human-readable output (avoids jq parse errors from stderr log pollution) +REQUIRE_COUNTER=$(echo "$REQUIRE_COUNTER_OUTPUT" | sed -n 's/.*Deployed to: \(0x[a-fA-F0-9]*\).*/\1/p') +if [[ "$REQUIRE_COUNTER" == "null" || -z "$REQUIRE_COUNTER" ]]; then + echo "ERROR: Failed to deploy Counter with require" + exit 1 +fi +echo "Counter with require deployed at: $REQUIRE_COUNTER" + +echo -e "\n=== CAST BATCH-SEND REVERT TEST ===" +# Test that batch reverts atomically when one call fails +# setNumber(1) fails because require(newNumber > 100) +if cast batch-send ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} --rpc-url "$ETH_RPC_URL" \ + --call "$REQUIRE_COUNTER::increment()" \ + --call "$REQUIRE_COUNTER::setNumber(uint256):1" \ + --private-key "$PK" 2>&1; then + echo "ERROR: Batch should have reverted but succeeded" + exit 1 +fi +echo "OK: Batch correctly reverted (setNumber(1) failed require > 100)" + +echo -e "\n=== CAST BATCH-SEND WITH ARGS AND ENCODED CALLDATA ===" +# Test batch with both function arguments and pre-encoded calldata +# First call: pre-encoded calldata for setNumber(200) +# Second call: function signature with args setNumber(101) +# Final number should be 101 (second call executes last) +ENCODED_CALLDATA=$(cast calldata "setNumber(uint256)" 200) +echo "Encoded calldata for setNumber(200): $ENCODED_CALLDATA" +cast batch-send ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} --rpc-url "$ETH_RPC_URL" \ + --call "$REQUIRE_COUNTER::$ENCODED_CALLDATA" \ + --call "$REQUIRE_COUNTER::setNumber(uint256):101" \ + --private-key "$PK" + +NUMBER=$(cast call --rpc-url "$ETH_RPC_URL" "$REQUIRE_COUNTER" "number()(uint256)") +echo "Counter number after batch: $NUMBER (expected: 101)" + +echo -e "\n=== FORGE SCRIPT --BATCH (NATIVE BATCHING) ===" +# Create a script that calls multiple contracts and batch them into a single tx +# Use template file and substitute REQUIRE_COUNTER address +sed "s/\${REQUIRE_COUNTER}/${REQUIRE_COUNTER}/" "$SCRIPT_DIR/contracts/BatchTest.s.sol.template" > script/BatchTest.s.sol + +# Get number before batch +NUMBER_BEFORE=$(cast call --rpc-url "$ETH_RPC_URL" "$REQUIRE_COUNTER" "number()(uint256)") +echo "Counter number before forge script --batch: $NUMBER_BEFORE" + +# Run forge script with --batch flag +forge script script/BatchTest.s.sol --broadcast --batch ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} --rpc-url "$ETH_RPC_URL" --private-key "$PK" + +# Verify all calls executed atomically +NUMBER_AFTER=$(cast call --rpc-url "$ETH_RPC_URL" "$REQUIRE_COUNTER" "number()(uint256)") +echo "Counter number after forge script --batch: $NUMBER_AFTER (expected: 503)" +if [[ "$NUMBER_AFTER" != "503" ]]; then + echo "ERROR: Expected number to be 503 (500 + 3 increments), got $NUMBER_AFTER" + exit 1 +fi +echo "OK: forge script --batch executed all calls atomically" + +echo -e "\n=== FORGE SCRIPT --BATCH WITH DEPLOY + CALLS ===" +# Test deploying a contract and calling it in the same batch transaction +# This tests the CREATE + CALL pattern (CREATE must be first) +cp "$SCRIPT_DIR/contracts/BatchCounter.sol" src/BatchCounter.sol +cp "$SCRIPT_DIR/contracts/DeployAndCall.s.sol" script/DeployAndCall.s.sol + +forge build + +# Build verification args if VERIFIER_URL is set (same pattern as tempo-deploy.sh) +VERIFY_ARG=() +if [[ -n "${VERIFIER_URL:-}" ]]; then + VERIFY_ARG=(--verify --retries 10 --delay 10) + echo "Will verify deployed contract via $VERIFIER_URL" +fi + +# Run forge script with --batch flag - deploys and calls atomically +forge script script/DeployAndCall.s.sol --broadcast --batch ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} ${VERIFY_ARG[@]+"${VERIFY_ARG[@]}"} --rpc-url "$ETH_RPC_URL" --private-key "$PK" + +echo "OK: forge script --batch with deploy + calls executed atomically" + +echo -e "\n=== FORGE SCRIPT --BATCH REVERT TEST ===" +# Test that batch reverts atomically when one call in the script fails +# Use template file and substitute REQUIRE_COUNTER address +sed "s/\${REQUIRE_COUNTER}/${REQUIRE_COUNTER}/" "$SCRIPT_DIR/contracts/BatchRevertTest.s.sol.template" > script/BatchRevertTest.s.sol + +NUMBER_BEFORE_REVERT=$(cast call --rpc-url "$ETH_RPC_URL" "$REQUIRE_COUNTER" "number()(uint256)") +echo "Counter number before batch revert test: $NUMBER_BEFORE_REVERT" + +if forge script script/BatchRevertTest.s.sol --broadcast --batch ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} --rpc-url "$ETH_RPC_URL" --private-key "$PK" 2>&1; then + echo "ERROR: Batch script should have reverted but succeeded" + exit 1 +fi + +# Verify number unchanged (atomic revert) +NUMBER_AFTER_REVERT=$(cast call --rpc-url "$ETH_RPC_URL" "$REQUIRE_COUNTER" "number()(uint256)") +echo "Counter number after batch revert: $NUMBER_AFTER_REVERT (expected: $NUMBER_BEFORE_REVERT - unchanged)" +if [[ "$NUMBER_AFTER_REVERT" != "$NUMBER_BEFORE_REVERT" ]]; then + echo "ERROR: Expected number to remain $NUMBER_BEFORE_REVERT after atomic revert, got $NUMBER_AFTER_REVERT" + exit 1 +fi +echo "OK: forge script --batch correctly reverted atomically" + +echo -e "\n=== DEPLOY HIGH GAS CONTRACT ===" +# Deploy a contract that can burn ~15M gas via cold storage writes (mapping) +# Each cold SSTORE to a new slot costs ~22,000 gas; ~650 iterations ≈ 15M gas +cat > src/GasBurner.sol <<'SOLEOF' +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; +contract GasBurner { + mapping(uint256 => uint256) public values; + function burn(uint256 iterations) public { + for (uint256 i; i < iterations; i++) { + values[i] = i; + } + } +} +SOLEOF +forge build + +GAS_BURNER_OUTPUT=$(forge create ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} src/GasBurner.sol:GasBurner --rpc-url "$ETH_RPC_URL" --private-key "$PK" --broadcast 2>&1) +echo "Deploy output: $GAS_BURNER_OUTPUT" +GAS_BURNER=$(echo "$GAS_BURNER_OUTPUT" | sed -n 's/.*Deployed to: \(0x[a-fA-F0-9]*\).*/\1/p') +GAS_BURNER_TX=$(echo "$GAS_BURNER_OUTPUT" | sed -n 's/.*Transaction hash: \(0x[a-fA-F0-9]*\).*/\1/p') +if [[ -z "$GAS_BURNER" ]]; then + echo "ERROR: Failed to deploy GasBurner" + exit 1 +fi +echo "GasBurner deployed at: $GAS_BURNER (tx: $GAS_BURNER_TX)" + +echo -e "\n=== CAST SEND HIGH GAS TX (~15M gas) ===" +GAS_BURN_RECEIPT=$(cast send ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} --rpc-url "$ETH_RPC_URL" \ + "$GAS_BURNER" 'burn(uint256)' 650 --gas-limit 15000000 --private-key "$PK" --json) +GAS_BURN_TX=$(echo "$GAS_BURN_RECEIPT" | jq -r '.transactionHash') +echo "High gas tx: $GAS_BURN_TX" +GAS_USED=$(echo "$GAS_BURN_RECEIPT" | jq -r '.gasUsed') +GAS_USED_DEC=$((GAS_USED)) +echo "Gas used: $GAS_USED_DEC" + +echo -e "\n=== DEPLOY LARGE CONTRACT ===" +# Deploy a contract at exactly the 24KB EIP-170 code size limit (24576 bytes deployed bytecode). +# 24174 bytes of padding + contract overhead = exactly 24576 bytes deployed. +PADDING=$(python3 -c "print('ff' * 24174)") +cat > src/MaxSizeContract.sol <&1) +echo "Deploy output: $MAX_SIZE_OUTPUT" +MAX_SIZE_ADDR=$(echo "$MAX_SIZE_OUTPUT" | sed -n 's/.*Deployed to: \(0x[a-fA-F0-9]*\).*/\1/p') +if [[ -z "$MAX_SIZE_ADDR" ]]; then + echo "ERROR: Failed to deploy MaxSizeContract" + exit 1 +fi +echo "MaxSizeContract deployed at: $MAX_SIZE_ADDR" + +# Verify deployed code size hits the 24KB EIP-170 limit +# cast code returns hex string with 0x prefix; subtract 2 for prefix, divide by 2 for bytes +CODE_HEX=$(cast code --rpc-url "$ETH_RPC_URL" "$MAX_SIZE_ADDR") +CODE_SIZE=$(( (${#CODE_HEX} - 2) / 2 )) +echo "Deployed code size: $CODE_SIZE bytes (limit: 24576)" +if [[ $CODE_SIZE -ne 24576 ]]; then + echo "ERROR: Deployed code size $CODE_SIZE != 24576 (expected exactly the EIP-170 limit)" + exit 1 +fi +echo "OK: Large contract deployed at exactly the EIP-170 limit ($CODE_SIZE bytes)" + +# Verify the contract is callable +PING_RESULT=$(cast call --rpc-url "$ETH_RPC_URL" "$MAX_SIZE_ADDR" 'ping()(uint256)') +if [[ "$PING_RESULT" != "1" ]]; then + echo "ERROR: ping() returned $PING_RESULT, expected 1" + exit 1 +fi +echo "OK: MaxSizeContract ping() returned 1" + +# Skip DEX/liquidity tests when using custom fee token (they assume multiple fee tokens) +if [[ ${#FEE_TOKEN_ARG[@]} -eq 0 ]]; then + echo -e "\n=== CHANGE USER DEFAULT FEE TOKEN ===" + cast send --rpc-url "$ETH_RPC_URL" 0xfeec000000000000000000000000000000000000 'setUserToken(address)' 0x20C0000000000000000000000000000000000002 --private-key "$PK" + cast send --rpc-url "$ETH_RPC_URL" 0xfeec000000000000000000000000000000000000 'setUserToken(address)' 0x20C0000000000000000000000000000000000000 --private-key "$PK" + + echo -e "\n=== ADD LIQUIDITY: APPROVE DEX ===" + cast erc20 approve 0x20c0000000000000000000000000000000000002 0xdec0000000000000000000000000000000000000 10000000000 --rpc-url "$ETH_RPC_URL" --private-key "$PK" + cast erc20 approve 0x20c0000000000000000000000000000000000000 0xdec0000000000000000000000000000000000000 10000000000 --rpc-url "$ETH_RPC_URL" --private-key "$PK" + + echo -e "\n=== ADD LIQUIDITY: PLACE BID ===" + cast send 0xdec0000000000000000000000000000000000000 "place(address,uint128,bool,int16)" 0x20c0000000000000000000000000000000000002 100000000 true 10 --private-key "$PK" -r "$ETH_RPC_URL" + + echo -e "\n=== ADD LIQUIDITY: PLACE ASK ===" + cast send 0xdec0000000000000000000000000000000000000 "place(address,uint128,bool,int16)" 0x20c0000000000000000000000000000000000002 100000000 false 10 --private-key "$PK" -r "$ETH_RPC_URL" + + echo -e "\n=== ADD LIQUIDITY: PLACE FLIP ===" + cast send 0xdec0000000000000000000000000000000000000 "placeFlip(address,uint128,bool,int16,int16)" 0x20c0000000000000000000000000000000000002 100000000 true -10 10 --private-key "$PK" -r "$ETH_RPC_URL" + + echo -e "\n=== ADD LIQUIDITY: SWAP EXACT AMOUNT IN ===" + cast send 0xdec0000000000000000000000000000000000000 "swapExactAmountIn(address,address,uint128,uint128)" 0x20c0000000000000000000000000000000000000 0x20c0000000000000000000000000000000000002 100000000 9000000 --private-key "$PK" -r "$ETH_RPC_URL" + + echo -e "\n=== ADD LIQUIDITY: SWAP EXACT AMOUNT OUT ===" + cast send 0xdec0000000000000000000000000000000000000 "swapExactAmountOut(address,address,uint128,uint128)" 0x20c0000000000000000000000000000000000002 0x20c0000000000000000000000000000000000000 9000000 100000000 --private-key "$PK" -r "$ETH_RPC_URL" +else + echo -e "\n=== CHANGE USER DEFAULT FEE TOKEN ===" + echo "skipped (custom fee token set)" + + echo -e "\n=== ADD LIQUIDITY: APPROVE DEX ===" + echo "skipped (custom fee token set)" + + echo -e "\n=== ADD LIQUIDITY: PLACE BID ===" + echo "skipped (custom fee token set)" + + echo -e "\n=== ADD LIQUIDITY: PLACE ASK ===" + echo "skipped (custom fee token set)" + + echo -e "\n=== ADD LIQUIDITY: PLACE FLIP ===" + echo "skipped (custom fee token set)" + + echo -e "\n=== ADD LIQUIDITY: SWAP EXACT AMOUNT IN ===" + echo "skipped (custom fee token set)" + + echo -e "\n=== ADD LIQUIDITY: SWAP EXACT AMOUNT OUT ===" + echo "skipped (custom fee token set)" +fi + +echo -e "\n=== ANVIL LOCAL TESTS ===" + +ANVIL_PORT=8546 +echo "Starting local anvil..." +# Exercise Anvil's latest-active Tempo default unless a historical hardfork was explicitly selected. +ANVIL_HARDFORK_ARGS=() +if [[ -n "${TEMPO_HARDFORK:-}" ]]; then + ANVIL_HARDFORK=$(echo "$HARDFORK" | tr '[:upper:]' '[:lower:]') + ANVIL_HARDFORK_ARGS=(--hardfork "$ANVIL_HARDFORK") +fi +anvil --tempo "${ANVIL_HARDFORK_ARGS[@]}" --port $ANVIL_PORT & +ANVIL_PID=$! + +# Ensure anvil is stopped on script exit +trap 'kill "$ANVIL_PID" 2>/dev/null || true' EXIT + +wait_for_anvil "$ANVIL_PID" "$ANVIL_PORT" + +ALICE_PK="0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + +echo -e "\n=== ANVIL LOCAL: DEPLOY COUNTER ===" +LOCAL_COUNTER=$(forge create --tempo.fee-token "$FEE_TOKEN" src/BatchCounter.sol:BatchCounter \ + --rpc-url "http://127.0.0.1:$ANVIL_PORT" --private-key "$ALICE_PK" --broadcast --json --constructor-args 0 \ + | jq -er '(.data // .).deployedTo | select(test("^0x[0-9a-fA-F]{40}$"))') +echo "Local Counter deployed at: $LOCAL_COUNTER" + +echo -e "\n=== ANVIL LOCAL: CHECK CLIENT VERSION ===" +cast client --rpc-url http://127.0.0.1:$ANVIL_PORT + +echo -e "\n=== ANVIL LOCAL: CHECK CHAIN ID ===" +cast chain-id --rpc-url http://127.0.0.1:$ANVIL_PORT + +echo -e "\n=== ANVIL LOCAL: FORGE TEST ===" +TEMPO_FEE_TOKEN="$FEE_TOKEN" forge test --rpc-url http://127.0.0.1:$ANVIL_PORT + +echo -e "\n=== ANVIL LOCAL: FORGE SCRIPT SIMULATE ===" +TEMPO_FEE_TOKEN="$FEE_TOKEN" forge script ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} script/Mail.s.sol --sig "run(string)" "$(date +%s%N)" --rpc-url http://127.0.0.1:$ANVIL_PORT --private-key "$ALICE_PK" + +echo -e "\n=== ANVIL LOCAL: FORGE SCRIPT BROADCAST ===" +TEMPO_FEE_TOKEN="$FEE_TOKEN" forge script ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} script/Mail.s.sol --sig "run(string)" "$(date +%s%N)" --rpc-url http://127.0.0.1:$ANVIL_PORT --private-key "$ALICE_PK" --broadcast + +echo -e "\n=== ANVIL LOCAL: CAST SEND ===" +cast send --tempo.fee-token "$FEE_TOKEN" --rpc-url http://127.0.0.1:$ANVIL_PORT "$LOCAL_COUNTER" 'increment()' --private-key "$ALICE_PK" + +echo -e "\n=== ANVIL LOCAL: ERC20 TRANSFER ===" +cast erc20 transfer --tempo.fee-token "$FEE_TOKEN" 0x20c0000000000000000000000000000000000000 0x4ef5DFf69C1514f4Dbf85aA4F9D95F804F64275F 123456 --rpc-url http://127.0.0.1:$ANVIL_PORT --private-key "$ALICE_PK" + +echo -e "\n=== ANVIL LOCAL: CAST SEND WITH NONCE-KEY (2D Nonce) ===" +cast send --tempo.fee-token "$FEE_TOKEN" --rpc-url http://127.0.0.1:$ANVIL_PORT "$LOCAL_COUNTER" 'increment()' --private-key "$ALICE_PK" --nonce 0 --tempo.nonce-key 100 + +echo -e "\n=== ANVIL LOCAL: CAST SEND WITH EXPIRING NONCE ===" +cast send --tempo.fee-token "$FEE_TOKEN" --rpc-url http://127.0.0.1:$ANVIL_PORT "$LOCAL_COUNTER" 'increment()' --private-key "$ALICE_PK" --tempo.expiring-nonce --tempo.valid-before "$(($(date +%s) + 25))" + +echo -e "\n=== ANVIL LOCAL: BATCH SEND ===" +cast batch-send --tempo.fee-token "$FEE_TOKEN" --rpc-url http://127.0.0.1:$ANVIL_PORT \ + --call "${LOCAL_COUNTER}::increment()" \ + --call "${LOCAL_COUNTER}::increment()" \ + --private-key "$ALICE_PK" + +# Stop anvil +kill "$ANVIL_PID" 2>/dev/null || true +trap - EXIT + +echo -e "\n=== ANVIL LOCAL TESTS COMPLETE ===" + +echo -e "\n=== ANVIL FORK TESTS ===" +# Use a fresh wallet for fork tests to avoid fee token exhaustion from prior devnet tests +echo -e "\n=== ANVIL FORK: CREATE AND FUND FRESH WALLET ===" +fork_wallet_json="$(cast wallet new --json)" +FORK_ADDR="$(wallet_json_field "$fork_wallet_json" address)" +FORK_PK="$(wallet_json_field "$fork_wallet_json" private_key)" +printf "Fork test address: %s\n" "$FORK_ADDR" +fund_and_wait "$FORK_ADDR" + +# Set the fee token on devnet before forking so the fork snapshot includes it +cast send --rpc-url "$ETH_RPC_URL" 0xfeec000000000000000000000000000000000000 \ + 'setUserToken(address)' "$FEE_TOKEN" --private-key "$FORK_PK" + +ANVIL_PORT=8547 +echo "Starting forked anvil..." +anvil --tempo "${ANVIL_HARDFORK_ARGS[@]}" --fork-url "$ETH_RPC_URL" --port $ANVIL_PORT --retries 10 --timeout 60000 & +ANVIL_PID=$! + +# Ensure anvil is stopped on script exit +trap 'kill "$ANVIL_PID" 2>/dev/null || true' EXIT + +wait_for_anvil "$ANVIL_PID" "$ANVIL_PORT" + +echo -e "\n=== ANVIL FORK: CHECK CLIENT VERSION ===" +cast client --rpc-url http://127.0.0.1:$ANVIL_PORT + +echo -e "\n=== ANVIL FORK: CHECK CHAIN ID ===" +cast chain-id --rpc-url http://127.0.0.1:$ANVIL_PORT + +echo -e "\n=== ANVIL FORK: CHECK BLOCK NUMBER ===" +cast block-number --rpc-url http://127.0.0.1:$ANVIL_PORT + +echo -e "\n=== ANVIL FORK: FORGE TEST ===" +TEMPO_FEE_TOKEN="$FEE_TOKEN" forge test --rpc-url http://127.0.0.1:$ANVIL_PORT + +echo -e "\n=== ANVIL FORK: FORGE SCRIPT SIMULATE ===" +TEMPO_FEE_TOKEN="$FEE_TOKEN" forge script --tempo.fee-token "$FEE_TOKEN" script/Mail.s.sol --sig "run(string)" "$(date +%s%N)" --rpc-url http://127.0.0.1:$ANVIL_PORT --private-key "$FORK_PK" + +echo -e "\n=== ANVIL FORK: FORGE SCRIPT BROADCAST ===" +TEMPO_FEE_TOKEN="$FEE_TOKEN" forge script --tempo.fee-token "$FEE_TOKEN" script/Mail.s.sol --sig "run(string)" "$(date +%s%N)" --rpc-url http://127.0.0.1:$ANVIL_PORT --private-key "$FORK_PK" --broadcast + +echo -e "\n=== ANVIL FORK: CAST SEND ===" +cast send --tempo.fee-token "$FEE_TOKEN" --rpc-url http://127.0.0.1:$ANVIL_PORT "$COUNTER" 'increment()' --private-key "$FORK_PK" + +echo -e "\n=== ANVIL FORK: ERC20 TRANSFER ===" +cast erc20 transfer --tempo.fee-token "$FEE_TOKEN" 0x20c0000000000000000000000000000000000000 0x4ef5DFf69C1514f4Dbf85aA4F9D95F804F64275F 123456 --rpc-url http://127.0.0.1:$ANVIL_PORT --private-key "$FORK_PK" + +echo -e "\n=== ANVIL FORK: CAST SEND WITH NONCE-KEY (2D Nonce) ===" +cast send --tempo.fee-token "$FEE_TOKEN" --rpc-url http://127.0.0.1:$ANVIL_PORT "$COUNTER" 'increment()' --private-key "$FORK_PK" --nonce 0 --tempo.nonce-key 100 + +echo -e "\n=== ANVIL FORK: CAST SEND WITH EXPIRING NONCE ===" +cast send --tempo.fee-token "$FEE_TOKEN" --rpc-url http://127.0.0.1:$ANVIL_PORT "$COUNTER" 'increment()' --private-key "$FORK_PK" --tempo.expiring-nonce --tempo.valid-before "$(($(date +%s) + 25))" + +echo -e "\n=== ANVIL FORK: BATCH SEND ===" +cast batch-send --tempo.fee-token "$FEE_TOKEN" --rpc-url http://127.0.0.1:$ANVIL_PORT \ + --call "${COUNTER}::increment()" \ + --call "${COUNTER}::increment()" \ + --private-key "$FORK_PK" + +# Stop anvil +kill "$ANVIL_PID" 2>/dev/null || true +trap - EXIT + +echo -e "\n=== ANVIL FORK TESTS COMPLETE ===" + +echo -e "\n=== CHISEL FORK TESTS ===" +# Test chisel forking the Tempo network - precompiles should be accessible from fork + +# Helper to check address has code via chisel fork +check_has_code() { + local name="$1" addr="$2" + local result + result=$(chisel --fork-url "$ETH_RPC_URL" eval "address($addr).code.length > 0" 2>&1 | grep "Value:" | awk '{print $NF}') + if [[ "$result" != "true" ]]; then + echo "ERROR: $name ($addr) should have code when forking Tempo" + exit 1 + fi + echo "OK: $name has code" +} + +check_has_code "PathUSD" "0x20C0000000000000000000000000000000000000" +check_has_code "AlphaUSD" "0x20C0000000000000000000000000000000000001" +check_has_code "Nonce" "0x4e4F4E4345000000000000000000000000000000" +check_has_code "AccountKeychain" "0xaAAAaaAA00000000000000000000000000000000" + +echo -e "\n=== CHISEL FORK TESTS COMPLETE ===" diff --git a/.github/scripts/tempo-deploy.sh b/.github/scripts/tempo-deploy.sh new file mode 100755 index 0000000000000..11df91eb26cec --- /dev/null +++ b/.github/scripts/tempo-deploy.sh @@ -0,0 +1,91 @@ +#!/bin/bash +set -euo pipefail + +# Deployment checks: forge script and forge create with optional --verify flag +# If VERIFIER_URL is set, adds --verify flag to deployment commands + +# Fee token address, defaults to native fee token +FEE_TOKEN="${TEMPO_FEE_TOKEN:-0x20c0000000000000000000000000000000000000}" + +# Build fee token args if not using native token (array for safe expansion) +FEE_TOKEN_ARG=() +if [[ "$FEE_TOKEN" != "0x20c0000000000000000000000000000000000000" ]]; then + FEE_TOKEN_ARG=(--tempo.fee-token "$FEE_TOKEN") +fi + +# If VERIFIER_URL is set, add the --verify flag to forge commands +VERIFY_ARG=() +if [[ -n "${VERIFIER_URL:-}" ]]; then + VERIFY_ARG=(--verify --retries 10 --delay 10) + echo -e "\n=== USING VERIFIER: $VERIFIER_URL ===" +else + echo -e "\n=== VERIFIER_URL not set, skipping verification ===" +fi + +echo -e "\n=== USING FEE TOKEN: $FEE_TOKEN ===" + +wallet_json_field() { + local wallet_json="$1" + local field="$2" + jq -r --arg field "$field" '(.data // .)[0][$field]' <<<"$wallet_json" +} + +echo -e "\n=== INIT TEMPO PROJECT ===" +tmp_dir=$(mktemp -d) +cd "$tmp_dir" +forge init -n tempo tempo-deploy +cd tempo-deploy + +if [[ -n "${PRIVATE_KEY:-}" ]]; then + echo -e "\n=== USING PROVIDED PRIVATE KEY ===" + PK="$PRIVATE_KEY" + ADDR="$(cast wallet address "$PK")" + printf "\naddress: %s\n" "$ADDR" +else + echo -e "\n=== CREATE AND FUND ADDRESS ===" + wallet_json="$(cast wallet new --json)" + ADDR="$(wallet_json_field "$wallet_json" address)" + PK="$(wallet_json_field "$wallet_json" private_key)" + + for i in {1..100}; do + OUT=$(cast rpc tempo_fundAddress "$ADDR" --rpc-url "$ETH_RPC_URL" 2>&1 || true) + + if echo "$OUT" | jq -e 'arrays' >/dev/null 2>&1; then + echo "$OUT" | jq + break + fi + + echo "[$i] $OUT" + sleep 0.2 + done + + printf "\naddress: %s\nprivate_key: %s\n" "$ADDR" "$PK" + + echo -e "\n=== WAIT FOR BLOCKS TO MINE ===" + sleep 5 +fi + +echo -e "\n=== FORGE SCRIPT DEPLOY ===" +forge script ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} script/Mail.s.sol --sig "run(string)" "$(date +%s%N)" --private-key "$PK" --rpc-url "$ETH_RPC_URL" --broadcast ${VERIFY_ARG[@]+"${VERIFY_ARG[@]}"} + +echo -e "\n=== FORGE SCRIPT DEPLOY WITH FEE TOKEN ===" +forge script ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} script/Mail.s.sol --sig "run(string)" "$(date +%s%N)" --private-key "$PK" --rpc-url "$ETH_RPC_URL" --broadcast ${VERIFY_ARG[@]+"${VERIFY_ARG[@]}"} + +echo -e "\n=== FORGE CREATE DEPLOY ===" +forge create ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} src/Mail.sol:Mail --private-key "$PK" --rpc-url "$ETH_RPC_URL" --broadcast ${VERIFY_ARG[@]+"${VERIFY_ARG[@]}"} --constructor-args "$FEE_TOKEN" + +echo -e "\n=== FORGE CREATE DEPLOY WITH FEE TOKEN ===" +CHAIN_ID=$(cast chain-id --rpc-url "$ETH_RPC_URL") +if [[ ${#FEE_TOKEN_ARG[@]} -eq 0 ]]; then + if [[ "$CHAIN_ID" == "4217" ]]; then + echo "Skipping alternate fee token test on mainnet (chain 4217)" + else + # Test alternate fee tokens only on testnet + forge create --tempo.fee-token 0x20C0000000000000000000000000000000000002 src/Mail.sol:Mail --private-key "$PK" --rpc-url "$ETH_RPC_URL" --broadcast ${VERIFY_ARG[@]+"${VERIFY_ARG[@]}"} --constructor-args "$FEE_TOKEN" + forge create --tempo.fee-token 0x20C0000000000000000000000000000000000003 src/Mail.sol:Mail --private-key "$PK" --rpc-url "$ETH_RPC_URL" --broadcast ${VERIFY_ARG[@]+"${VERIFY_ARG[@]}"} --constructor-args "$FEE_TOKEN" + fi +else + forge create ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} src/Mail.sol:Mail --private-key "$PK" --rpc-url "$ETH_RPC_URL" --broadcast ${VERIFY_ARG[@]+"${VERIFY_ARG[@]}"} --constructor-args "$FEE_TOKEN" +fi + +echo -e "\n=== DEPLOYMENT TESTS COMPLETE ===" diff --git a/.github/scripts/tempo-mpp.sh b/.github/scripts/tempo-mpp.sh new file mode 100755 index 0000000000000..b5c6baa3f3476 --- /dev/null +++ b/.github/scripts/tempo-mpp.sh @@ -0,0 +1,431 @@ +#!/usr/bin/env bash +# MPP (Machine Payments Protocol) end-to-end test script +# +# Prerequisites: +# - A Tempo Accounts `store.json`, or `TEMPO_ROTATE_WALLET=1` with an isolated `TEMPO_HOME` +# - Wallet funded with the configured balance-check token; Accounts may autoswap it into the +# server-selected channel token +# - Foundry binaries built: `cargo build --bin cast --bin forge --bin anvil --bin chisel` +# +# Usage: +# ./.github/scripts/tempo-mpp.sh [binary-dir] +# +# Examples: +# ./.github/scripts/tempo-mpp.sh # uses Foundry tools from PATH +# ./.github/scripts/tempo-mpp.sh ./target/debug # use debug builds + +set -euo pipefail + +# Moderato requires gateway authentication before returning its paid challenge. Keep the opt-in +# explicit; the channel assertions below still verify that payment was not bypassed. +if [ -n "${MPP_API_KEY:-}" ] && [ "${MPP_ALLOW_API_KEY:-0}" != "1" ]; then + echo "ERROR: MPP_API_KEY requires MPP_ALLOW_API_KEY=1 for the paid MPP e2e" >&2 + exit 1 +fi +if [ "${MPP_ALLOW_API_KEY:-0}" = "1" ] && [ -z "${MPP_API_KEY:-}" ]; then + echo "ERROR: MPP_ALLOW_API_KEY=1 requires MPP_API_KEY for gateway authentication" >&2 + exit 1 +fi + +BIN_DIR="${1:-}" +if [ -n "$BIN_DIR" ]; then + BIN_DIR="$(cd "$BIN_DIR" && pwd)" + CAST="$BIN_DIR/cast" + FORGE="$BIN_DIR/forge" + ANVIL="$BIN_DIR/anvil" + CHISEL="$BIN_DIR/chisel" +else + CAST="cast" + FORGE="forge" + ANVIL="anvil" + CHISEL="chisel" +fi +TEMPO_AUTO_FUND="${TEMPO_AUTO_FUND:-0}" +TEMPO_AUTO_FUND_ATTEMPTS="${TEMPO_AUTO_FUND_ATTEMPTS:-3}" +MIN_BALANCE="${MPP_MIN_BALANCE:-1000000}" +RPC_MPP="${MPP_RPC_URL:-https://rpc.mpp.moderato.tempo.xyz}" +RPC="${TEMPO_RPC_URL:-https://rpc.moderato.tempo.xyz}" +FUNDING_TOKEN="${MPP_FUNDING_TOKEN:-${MPP_TOKEN:-0x20c0000000000000000000000000000000000000}}" # PathUSD on Moderato + +if ! command -v "$CAST" &>/dev/null; then + echo "ERROR: cast binary not found at '$CAST'. Install with: foundryup" + exit 1 +fi +if ! command -v "$FORGE" &>/dev/null; then + echo "ERROR: forge binary not found at '$FORGE'. Install with: foundryup" + exit 1 +fi +if ! command -v "$ANVIL" &>/dev/null; then + echo "ERROR: anvil binary not found at '$ANVIL'. Install with: foundryup" + exit 1 +fi +if ! command -v "$CHISEL" &>/dev/null; then + echo "ERROR: chisel binary not found at '$CHISEL'. Install with: foundryup" + exit 1 +fi +if ! command -v jq &>/dev/null; then + echo "ERROR: jq is required" + exit 1 +fi +if ! command -v python3 &>/dev/null; then + echo "ERROR: python3 is required to inspect the local MPP channel store" + exit 1 +fi + +file_sha256() { + if command -v sha256sum &>/dev/null; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} + +CHAIN_ID=$("$CAST" chain-id --rpc-url "$RPC") +WALLET_DIR="${TEMPO_HOME:-$HOME/.tempo}/wallet" +STORE_PATH="$WALLET_DIR/store.json" +CHANNELS_DB="$WALLET_DIR/channels.db" +STORE_HASH_BEFORE="" + +TEMPO_ROTATE_WALLET="${TEMPO_ROTATE_WALLET:-0}" +if [ "$TEMPO_ROTATE_WALLET" = "1" ]; then + if [ -z "${TEMPO_HOME:-}" ]; then + echo "ERROR: TEMPO_ROTATE_WALLET=1 requires an explicit isolated TEMPO_HOME" + exit 1 + fi + if [ -e "$TEMPO_HOME/wallet/store.json" ]; then + echo "ERROR: refusing to rotate into a non-empty Tempo Accounts store at $TEMPO_HOME/wallet/store.json" + exit 1 + fi + + echo "Creating a fresh Tempo account and pending access key in store.json" + ROOT_WALLET_JSON=$("$CAST" wallet new --json) + ACCESS_WALLET_JSON=$("$CAST" wallet new --json) + ROOT_PRIVATE_KEY=$(printf '%s' "$ROOT_WALLET_JSON" | jq -r '(.data // .)[0].private_key') + ACCESS_PRIVATE_KEY=$(printf '%s' "$ACCESS_WALLET_JSON" | jq -r '(.data // .)[0].private_key') + WALLET=$(printf '%s' "$ROOT_WALLET_JSON" | jq -r '(.data // .)[0].address') + ACCESS_ADDRESS=$(printf '%s' "$ACCESS_WALLET_JSON" | jq -r '(.data // .)[0].address') + if [ -z "$ROOT_PRIVATE_KEY" ] || [ "$ROOT_PRIVATE_KEY" = "null" ] || + [ -z "$ACCESS_PRIVATE_KEY" ] || [ "$ACCESS_PRIVATE_KEY" = "null" ] || + [ -z "$WALLET" ] || [ "$WALLET" = "null" ] || + [ -z "$ACCESS_ADDRESS" ] || [ "$ACCESS_ADDRESS" = "null" ]; then + echo "ERROR: failed to parse an ephemeral key from 'cast wallet new --json'" + exit 1 + fi + AUTHORIZATION=$("$CAST" key-authorization sign "$ACCESS_ADDRESS" \ + --chain-id "$CHAIN_ID" \ + --private-key "$ROOT_PRIVATE_KEY") + "$CAST" tempo import-access-key \ + --account "$WALLET" \ + --access-key "$ACCESS_PRIVATE_KEY" \ + --authorization "$AUTHORIZATION" +else + if [ ! -f "$STORE_PATH" ]; then + echo "ERROR: Tempo Accounts store not found at $STORE_PATH" + exit 1 + fi + STORE_SUMMARY=$("$CAST" --json keychain list) + ACTIVE_ACCOUNT=$(jq -er '."tempo-cli.store".state.activeAccount' "$STORE_PATH") + WALLET=$(jq -er --argjson active "$ACTIVE_ACCOUNT" \ + '."tempo-cli.store".state.accounts[$active].address' "$STORE_PATH") + ACTIVE_KEY_AVAILABLE=$(printf '%s' "$STORE_SUMMARY" | jq -r \ + --argjson chain_id "$CHAIN_ID" \ + --arg wallet "$(printf '%s' "$WALLET" | tr '[:upper:]' '[:lower:]')" \ + 'any(.data[]; .chain_id == $chain_id and .has_key == true and (.wallet_address | ascii_downcase) == $wallet)') + if [ "$ACTIVE_KEY_AVAILABLE" != "true" ]; then + echo "ERROR: active account $WALLET has no locally signable chain $CHAIN_ID key in $STORE_PATH" + exit 1 + fi +fi +STORE_SUMMARY=$("$CAST" --json keychain list) +if [ -f "$STORE_PATH" ]; then + STORE_HASH_BEFORE=$(file_sha256 "$STORE_PATH") +fi +echo "Wallet: $WALLET" +echo "RPC: $RPC_MPP" +echo "" +WALLET_LOWER=$(printf '%s' "$WALLET" | tr '[:upper:]' '[:lower:]') +RPC_MPP_NORMALIZED="${RPC_MPP%/}" + +read_channel_state() { + if [ ! -s "$CHANNELS_DB" ]; then + echo "ERROR: MPP request succeeded but $CHANNELS_DB was not created" >&2 + return 1 + fi + python3 - "$CHANNELS_DB" <<'PY' +import json +import sqlite3 +import sys + +path = sys.argv[1] +connection = sqlite3.connect(f"file:{path}?mode=ro", uri=True) +row = connection.execute( + """ + SELECT channel_id, request_url, payer, token, authorized_signer, + escrow_contract, chain_id, deposit, cumulative_amount, state + FROM channels + WHERE state = 'active' AND session_protocol = 'v2' + ORDER BY last_used_at DESC + LIMIT 1 + """ +).fetchone() +if row is None: + raise SystemExit("channels.db has no active TIP-1034 session") +keys = ( + "channel_id", + "request_url", + "payer", + "token", + "authorized_signer", + "escrow_contract", + "chain_id", + "deposit", + "cumulative_amount", + "state", +) +print(json.dumps(dict(zip(keys, row)), separators=(",", ":"))) +PY +} + +assert_channel_state() { + local state="$1" + local payer token signer escrow chain_id request_url channel_id deposit cumulative + local signer_is_local onchain_state onchain_deposit + payer=$(printf '%s' "$state" | jq -r '.payer | ascii_downcase') + token=$(printf '%s' "$state" | jq -r '.token | ascii_downcase') + signer=$(printf '%s' "$state" | jq -r '.authorized_signer | ascii_downcase') + escrow=$(printf '%s' "$state" | jq -r '.escrow_contract | ascii_downcase') + chain_id=$(printf '%s' "$state" | jq -r '.chain_id') + request_url=$(printf '%s' "$state" | jq -r '.request_url | rtrimstr("/")') + channel_id=$(printf '%s' "$state" | jq -r '.channel_id') + deposit=$(printf '%s' "$state" | jq -r '.deposit') + cumulative=$(printf '%s' "$state" | jq -r '.cumulative_amount') + if [ "$payer" != "$WALLET_LOWER" ]; then + echo "ERROR: channels.db payer $payer does not match Accounts wallet $WALLET_LOWER" >&2 + return 1 + fi + if [[ ! "$token" =~ ^0x20c0[0-9a-f]{36}$ ]]; then + echo "ERROR: channels.db token $token is not a Tempo TIP-20 address" >&2 + return 1 + fi + if [ "$escrow" != "0x4d50500000000000000000000000000000000000" ]; then + echo "ERROR: channels.db escrow $escrow is not the TIP-1034 channel reserve" >&2 + return 1 + fi + if [ "$chain_id" != "$CHAIN_ID" ]; then + echo "ERROR: channels.db chain $chain_id does not match RPC chain $CHAIN_ID" >&2 + return 1 + fi + if [ "$request_url" != "$RPC_MPP_NORMALIZED" ]; then + echo "ERROR: channels.db request URL $request_url does not match $RPC_MPP_NORMALIZED" >&2 + return 1 + fi + if [ "$cumulative" -le 0 ] || [ "$deposit" -lt "$cumulative" ]; then + echo "ERROR: invalid channel accounting: cumulative=$cumulative deposit=$deposit" >&2 + return 1 + fi + signer_is_local=$(printf '%s' "$STORE_SUMMARY" | jq -r --arg signer "$signer" \ + 'any(.data[]; ((.key_address // "") | ascii_downcase) == $signer and .has_key == true)') + if [ "$signer_is_local" != "true" ]; then + echo "ERROR: channel signer $signer is not locally available in store.json" >&2 + return 1 + fi + onchain_state=$("$CAST" call --json \ + 0x4D50500000000000000000000000000000000000 \ + 'getChannelState(bytes32)((uint96,uint96,uint32))' \ + "$channel_id" \ + --rpc-url "$RPC") + onchain_deposit=$(printf '%s' "$onchain_state" | jq -r \ + '(if type == "object" then .data else . end)[0][1]') + if [ "$onchain_deposit" -lt "$deposit" ]; then + echo "ERROR: channels.db deposit $deposit exceeds on-chain deposit $onchain_deposit" >&2 + return 1 + fi +} + +# 1. Check balance before +echo "=== 1. Balance BEFORE ===" +BEFORE=$("$CAST" erc20 balance "$FUNDING_TOKEN" "$WALLET" --rpc-url "$RPC") +echo "$BEFORE" +BEFORE_RAW=$(echo "$BEFORE" | awk '{print $1}') + +if [ "$BEFORE_RAW" -lt "$MIN_BALANCE" ] && [ "$TEMPO_AUTO_FUND" = "1" ]; then + echo "Balance below threshold, requesting faucet funds for $WALLET_LOWER" + ATTEMPT=0 + while [ "$BEFORE_RAW" -lt "$MIN_BALANCE" ] && [ "$ATTEMPT" -lt "$TEMPO_AUTO_FUND_ATTEMPTS" ]; do + ATTEMPT=$((ATTEMPT + 1)) + echo "Faucet attempt $ATTEMPT/$TEMPO_AUTO_FUND_ATTEMPTS" + # Retry on a transient faucet error instead of aborting (set -e). + if ! "$CAST" rpc tempo_fundAddress "$WALLET_LOWER" --rpc-url "$RPC" >/dev/null; then + echo "Faucet RPC failed on attempt $ATTEMPT, retrying..." + sleep 2 + continue + fi + sleep 2 + BEFORE=$("$CAST" erc20 balance "$FUNDING_TOKEN" "$WALLET" --rpc-url "$RPC") + echo "$BEFORE" + BEFORE_RAW=$(echo "$BEFORE" | awk '{print $1}') + done +fi + +if [ "$BEFORE_RAW" -lt "$MIN_BALANCE" ]; then + echo "ERROR: Wallet balance too low for MPP e2e. Need at least $MIN_BALANCE units of $FUNDING_TOKEN, got $BEFORE_RAW. Refill the CI wallet." + exit 1 +fi + +# 2. Pay through a reusable MPP session. +echo "" +echo "=== 2. cast block-number (via MPP) ===" +BLOCK=$("$CAST" block-number --rpc-url "$RPC_MPP") +echo "Block: $BLOCK" +CHANNEL1=$(read_channel_state) +assert_channel_state "$CHANNEL1" +CHANNEL_ID1=$(printf '%s' "$CHANNEL1" | jq -r '.channel_id') +CUMULATIVE1=$(printf '%s' "$CHANNEL1" | jq -r '.cumulative_amount') +DEPOSIT1=$(printf '%s' "$CHANNEL1" | jq -r '.deposit') +echo "Channel: $CHANNEL_ID1 cumulative=$CUMULATIVE1 deposit=$DEPOSIT1" +if [ -e "$WALLET_DIR/sessions.toml" ]; then + echo "ERROR: legacy sessions.toml was created" >&2 + exit 1 +fi + +# 3. Check balance after +echo "" +echo "=== 3. Balance AFTER ===" +AFTER=$("$CAST" erc20 balance "$FUNDING_TOKEN" "$WALLET" --rpc-url "$RPC") +echo "$AFTER" + +AFTER_RAW=$(echo "$AFTER" | awk '{print $1}') +SPENT=$((BEFORE_RAW - AFTER_RAW)) +echo "Net balance delta: $SPENT units (zero is valid for a self-payment)" + +# 4. Verify that a second request advances the same reusable channel. +echo "" +echo "=== 4. Second cast session payment ===" +BEFORE2=$("$CAST" erc20 balance "$FUNDING_TOKEN" "$WALLET" --rpc-url "$RPC" | awk '{print $1}') +BLOCK2=$("$CAST" block-number --rpc-url "$RPC_MPP") +AFTER2=$("$CAST" erc20 balance "$FUNDING_TOKEN" "$WALLET" --rpc-url "$RPC" | awk '{print $1}') +SPENT2=$((BEFORE2 - AFTER2)) +CHANNEL2=$(read_channel_state) +assert_channel_state "$CHANNEL2" +CHANNEL_ID2=$(printf '%s' "$CHANNEL2" | jq -r '.channel_id') +CUMULATIVE2=$(printf '%s' "$CHANNEL2" | jq -r '.cumulative_amount') +DEPOSIT2=$(printf '%s' "$CHANNEL2" | jq -r '.deposit') +if [ "$CHANNEL_ID2" != "$CHANNEL_ID1" ]; then + echo "ERROR: second request replaced reusable channel $CHANNEL_ID1 with $CHANNEL_ID2" >&2 + exit 1 +fi +if [ "$CUMULATIVE2" -le "$CUMULATIVE1" ]; then + echo "ERROR: second request did not advance the cumulative voucher" >&2 + exit 1 +fi +echo "Block: $BLOCK2" +echo "Channel: $CHANNEL_ID2 cumulative=$CUMULATIVE2 deposit=$DEPOSIT2" +echo "Net balance delta: $SPENT2 units (zero is valid for a self-payment)" + +# 5. forge script via MPP +echo "" +echo "=== 5. forge script (via MPP) ===" +MPP_TEST_DIR=$(mktemp -d) +ANVIL_PID="" +cleanup() { + if [ -n "$ANVIL_PID" ] && kill -0 "$ANVIL_PID" 2>/dev/null; then + kill "$ANVIL_PID" 2>/dev/null || true + wait "$ANVIL_PID" 2>/dev/null || true + fi + if [ -n "${MPP_TEST_DIR:-}" ] && [ -d "$MPP_TEST_DIR" ]; then + rm -rf -- "$MPP_TEST_DIR" + fi +} +trap cleanup EXIT +(cd "$MPP_TEST_DIR" && "$FORGE" init --no-git --quiet) +cat > "$MPP_TEST_DIR/script/Mpp.s.sol" <<'SOL' +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; +import "forge-std/Script.sol"; +contract MppCheck is Script { + function run() public view { + console.log("block", block.number); + console.log("chain", block.chainid); + } +} +SOL +"$FORGE" script "$MPP_TEST_DIR/script/Mpp.s.sol" --rpc-url "$RPC_MPP" --root "$MPP_TEST_DIR" + +# 6. forge test with vm.createSelectFork via MPP +echo "" +echo "=== 6. forge test with createSelectFork (via MPP) ===" +cat > "$MPP_TEST_DIR/test/Mpp.t.sol" <"$ANVIL_LOG" 2>&1 & +ANVIL_PID=$! +ANVIL_READY=0 +# Fork bootstrap emits many paid requests. Allow five minutes for a cold runner. +for _ in $(seq 1 300); do + if "$CAST" block-number --rpc-url http://localhost:8555 >/dev/null 2>&1; then + ANVIL_READY=1 + break + fi + if ! kill -0 "$ANVIL_PID" 2>/dev/null; then + break + fi + sleep 1 +done +if [ "$ANVIL_READY" != "1" ]; then + echo "ERROR: anvil did not become ready" >&2 + cat "$ANVIL_LOG" >&2 + exit 1 +fi +echo "chain-id: $("$CAST" chain-id --rpc-url http://localhost:8555)" +kill "$ANVIL_PID" 2>/dev/null || true +wait "$ANVIL_PID" 2>/dev/null || true +ANVIL_PID="" + +# 8. chisel fork via MPP +echo "" +echo "=== 8. chisel --fork-url (via MPP) ===" +echo 'block.number' | "$CHISEL" --fork-url "$RPC_MPP" 2>&1 | grep -E "Decimal|Type" + +CHANNEL_FINAL=$(read_channel_state) +assert_channel_state "$CHANNEL_FINAL" +CHANNEL_ID_FINAL=$(printf '%s' "$CHANNEL_FINAL" | jq -r '.channel_id') +CUMULATIVE_FINAL=$(printf '%s' "$CHANNEL_FINAL" | jq -r '.cumulative_amount') +DEPOSIT_FINAL=$(printf '%s' "$CHANNEL_FINAL" | jq -r '.deposit') +if [ "$CHANNEL_ID_FINAL" != "$CHANNEL_ID1" ]; then + echo "ERROR: Foundry tools did not reuse channel $CHANNEL_ID1" >&2 + exit 1 +fi +if [ "$CUMULATIVE_FINAL" -le "$CUMULATIVE2" ]; then + echo "ERROR: Forge/Anvil/Chisel did not advance the cumulative voucher" >&2 + exit 1 +fi +if [ -e "$WALLET_DIR/sessions.toml" ]; then + echo "ERROR: legacy sessions.toml was created" >&2 + exit 1 +fi +if [ -n "$STORE_HASH_BEFORE" ]; then + STORE_HASH_AFTER=$(file_sha256 "$STORE_PATH") + if [ "$STORE_HASH_AFTER" != "$STORE_HASH_BEFORE" ]; then + echo "ERROR: MPP requests mutated the Tempo Accounts store" >&2 + exit 1 + fi +fi +echo "Final channel: $CHANNEL_ID_FINAL cumulative=$CUMULATIVE_FINAL deposit=$DEPOSIT_FINAL" + +echo "" +echo "=== Done ===" diff --git a/.github/scripts/tempo-wallet.sh b/.github/scripts/tempo-wallet.sh new file mode 100755 index 0000000000000..0595d46870a70 --- /dev/null +++ b/.github/scripts/tempo-wallet.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Tempo Accounts store tests. +# Exercises --from / --sender resolving a pending access key from store.json +# without requiring a private key on each command. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Fee token address, defaults to native fee token +FEE_TOKEN="${TEMPO_FEE_TOKEN:-0x20c0000000000000000000000000000000000000}" +TEMPO_WALLET_TEST_HOME="" +PROJECT_DIR="" + +cleanup() { + if [ -n "$PROJECT_DIR" ] && [ -d "$PROJECT_DIR" ]; then + rm -rf -- "$PROJECT_DIR" + fi + if [ -n "$TEMPO_WALLET_TEST_HOME" ] && [ -d "$TEMPO_WALLET_TEST_HOME" ]; then + rm -rf -- "$TEMPO_WALLET_TEST_HOME" + fi +} +trap cleanup EXIT + +if [ -z "${TEMPO_HOME:-}" ]; then + TEMPO_WALLET_TEST_HOME="$(mktemp -d)" + export TEMPO_HOME="$TEMPO_WALLET_TEST_HOME" +fi +if [ -e "$TEMPO_HOME/wallet/store.json" ]; then + echo "ERROR: refusing to replace the Tempo Accounts store at $TEMPO_HOME/wallet/store.json" + exit 1 +fi +if ! command -v jq &>/dev/null; then + echo "ERROR: jq is required" + exit 1 +fi + +FEE_TOKEN_ARG=() +if [[ "$FEE_TOKEN" != "0x20c0000000000000000000000000000000000000" ]]; then + FEE_TOKEN_ARG=(--tempo.fee-token "$FEE_TOKEN") +fi + +# Fund an address and wait for the fee token balance to be non-zero +fund_and_wait() { + local addr="$1" + for i in {1..100}; do + OUT=$(cast rpc tempo_fundAddress "$addr" --rpc-url "$ETH_RPC_URL" 2>&1 || true) + if echo "$OUT" | jq -e 'arrays' >/dev/null 2>&1; then + echo "$OUT" | jq + break + fi + echo "[$i] $OUT" + sleep 0.2 + done + echo "Waiting for $addr to be funded..." + for i in {1..30}; do + BAL=$(cast call --rpc-url "$ETH_RPC_URL" "$FEE_TOKEN" 'balanceOf(address)(uint256)' "$addr" 2>/dev/null || echo "0") + if [[ "$BAL" != "0" && -n "$BAL" ]]; then + echo "Funded with $BAL fee tokens" + return 0 + fi + if [[ $i -eq 30 ]]; then + echo "ERROR: Funding timed out for $addr" + exit 1 + fi + sleep 1 + done +} + +wallet_json_field() { + local wallet_json="$1" + local field="$2" + jq -r --arg field "$field" '(.data // .)[0][$field]' <<<"$wallet_json" +} + +echo -e "\n=== CREATE TEMPO ACCOUNT AND ACCESS KEY ===" +ROOT_WALLET_JSON="$(cast wallet new --json)" +ACCESS_WALLET_JSON="$(cast wallet new --json)" +WALLET_ADDR="$(wallet_json_field "$ROOT_WALLET_JSON" address)" +ROOT_PRIVATE_KEY="$(wallet_json_field "$ROOT_WALLET_JSON" private_key)" +ACCESS_ADDR="$(wallet_json_field "$ACCESS_WALLET_JSON" address)" +ACCESS_PRIVATE_KEY="$(wallet_json_field "$ACCESS_WALLET_JSON" private_key)" +CHAIN_ID="$(cast chain-id --rpc-url "$ETH_RPC_URL")" +printf "address: %s\n" "$WALLET_ADDR" + +echo -e "\n=== IMPORT ACCESS KEY INTO store.json ===" +AUTHORIZATION="$(cast key-authorization sign "$ACCESS_ADDR" \ + --chain-id "$CHAIN_ID" \ + --private-key "$ROOT_PRIVATE_KEY" \ + --bind-account "$WALLET_ADDR")" +cast tempo import-access-key \ + --account "$WALLET_ADDR" \ + --access-key "$ACCESS_PRIVATE_KEY" \ + --authorization "$AUTHORIZATION" +unset ROOT_WALLET_JSON ACCESS_WALLET_JSON ACCESS_PRIVATE_KEY AUTHORIZATION +echo "Written to $TEMPO_HOME/wallet/store.json" + +echo "=== Wallet: $WALLET_ADDR ===" +echo "=== RPC: $ETH_RPC_URL ===" +echo "=== Fee: $FEE_TOKEN ===" + +echo -e "\n=== FUND WALLET ===" +fund_and_wait "$WALLET_ADDR" + +echo -e "\n=== DEPLOY COUNTER ===" +PROJECT_DIR="$(mktemp -d)" +cd "$PROJECT_DIR" +forge init -n tempo tempo-wallet-test --quiet +cd tempo-wallet-test +cp "$SCRIPT_DIR/contracts/BatchCounter.sol" src/BatchCounter.sol +# Use the root key so the first --from send still exercises the pending access key. +COUNTER=$(forge create ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} src/BatchCounter.sol:BatchCounter \ + --rpc-url "$ETH_RPC_URL" --private-key "$ROOT_PRIVATE_KEY" --broadcast --json --constructor-args 0 \ + | jq -er '(.data // .).deployedTo | select(test("^0x[0-9a-fA-F]{40}$"))') +unset ROOT_PRIVATE_KEY +echo "Counter deployed at: $COUNTER" + +echo -e "\n=== CAST SEND WITH --from (Tempo Accounts store) ===" +cast send ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} --rpc-url "$ETH_RPC_URL" \ + "$COUNTER" 'increment()' \ + --from "$WALLET_ADDR" + +echo -e "\n=== CAST ERC20 TRANSFER WITH --from (Tempo Accounts store) ===" +cast erc20 transfer ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} \ + "$FEE_TOKEN" \ + 0x4ef5DFf69C1514f4Dbf85aA4F9D95F804F64275F 100 \ + --rpc-url "$ETH_RPC_URL" --from "$WALLET_ADDR" + +echo -e "\n=== FORGE SCRIPT WITH --sender (Tempo Accounts store) ===" + +cat > script/TempoAccounts.s.sol <<'SOL' +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; +import "forge-std/Script.sol"; +interface TIP20 { + function transfer(address to, uint256 amount) external returns (bool); +} +contract TempoAccountsScript is Script { + function run(address token) external { + vm.startBroadcast(); + require(TIP20(token).transfer(0x4ef5DFf69C1514f4Dbf85aA4F9D95F804F64275F, 1)); + vm.stopBroadcast(); + } +} +SOL +forge script ${FEE_TOKEN_ARG[@]+"${FEE_TOKEN_ARG[@]}"} script/TempoAccounts.s.sol \ + --sig "run(address)" "$FEE_TOKEN" \ + --sender "$WALLET_ADDR" --rpc-url "$ETH_RPC_URL" --broadcast + +echo -e "\n=== TEMPO WALLET TESTS COMPLETE ===" diff --git a/.github/scripts/tests/test_benchmark_clickhouse.py b/.github/scripts/tests/test_benchmark_clickhouse.py new file mode 100644 index 0000000000000..1e6c05f79a375 --- /dev/null +++ b/.github/scripts/tests/test_benchmark_clickhouse.py @@ -0,0 +1,230 @@ +import importlib.util +import json +import pathlib +import tempfile +import unittest +from unittest import mock + + +SCRIPT = pathlib.Path(__file__).parents[1] / "benchmark_clickhouse.py" +SPEC = importlib.util.spec_from_file_location("benchmark_clickhouse", SCRIPT) +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +BASE_SHA = "1" * 40 +CANDIDATE_SHA = "2" * 40 +WORKLOAD_SHA = "3" * 40 + + +def result(mean): + return { + "mean": mean, + "stddev": 0.1, + "median": mean, + "user": mean / 2, + "system": mean / 4, + "min": mean - 0.1, + "max": mean + 0.1, + "times": [mean - 0.1, mean, mean + 0.1], + } + + +def manifest(): + def case(benchmark): + return { + "id": f"{benchmark}/example-project", + "benchmark": benchmark, + "workload_name": "example-project", + "workload_repository": "example/project", + "workload_requested_ref": "v1.0.0", + "workload_commit": WORKLOAD_SHA, + "arguments": None, + "versions": ["master", "local"], + } + + suites = [ + { + "schema": MODULE.SUITE_SCHEMA, + "suite": suite, + "cases": [case(benchmark) for benchmark in sorted(benchmarks)], + } + for suite, benchmarks in MODULE.EXPECTED_SUITES.items() + ] + expected = sorted(case["id"] for suite in suites for case in suite["cases"]) + return { + "schema": MODULE.RUN_SCHEMA, + "project": "foundry", + "run": { + "repository": "foundry-rs/foundry", + "workflow": "Foundry Benchmarks", + "workflow_file": ".github/workflows/benchmarks.yml", + "run_id": 123, + "run_attempt": 1, + "run_url": "https://github.com/foundry-rs/foundry/actions/runs/123", + "event": "workflow_dispatch", + "branch": "feature", + }, + "comparison": { + "kind": "source_vs_master", + "baseline_ref": "master", + "baseline_commit": BASE_SHA, + "candidate_ref": "feature", + "candidate_commit": CANDIDATE_SHA, + }, + "environment": { + "build_profile": "profiling", + "runner_os": "Linux", + "runner_arch": "X64", + "cpu_model": "Example CPU", + "cpu_count": 32, + "rustc": "rustc 1.0.0", + "hyperfine": "hyperfine 1.0.0", + }, + "expected_result_keys": expected, + "suites": suites, + } + + +TRUSTED = { + "repository": "foundry-rs/foundry", + "workflow_file": ".github/workflows/benchmarks.yml", + "default_branch": "master", + "run_id": 123, + "run_attempt": 1, + "run_url": "https://github.com/foundry-rs/foundry/actions/runs/123", + "head_sha": CANDIDATE_SHA, + "branch": "feature", + "started_at": "2026-07-15 12:00:00.000", +} + + +class BenchmarkClickHouseTest(unittest.TestCase): + def artifact_dir(self, base=None, candidate=None, run_manifest=None): + temp = tempfile.TemporaryDirectory() + root = pathlib.Path(temp.name) + run_manifest = run_manifest or manifest() + default_base = {key: result(1.0) for key in run_manifest["expected_result_keys"]} + default_candidate = {key: result(1.1) for key in run_manifest["expected_result_keys"]} + values = { + "benchmark-manifest.json": run_manifest, + "base-summary.json": default_base if base is None else base, + "candidate-summary.json": default_candidate if candidate is None else candidate, + } + for name, value in values.items(): + (root / name).write_text(json.dumps(value), encoding="utf-8") + return temp, root + + def test_builds_versioned_run_manifest(self): + temp = tempfile.TemporaryDirectory() + self.addCleanup(temp.cleanup) + fragment = pathlib.Path(temp.name) / "suite.json" + suite = manifest()["suites"][0] + fragment.write_text(json.dumps(suite), encoding="utf-8") + environment = { + "GITHUB_REPOSITORY": "foundry-rs/foundry", + "GITHUB_RUN_ID": "123", + "GITHUB_RUN_ATTEMPT": "1", + "GITHUB_REF_NAME": "feature", + "GITHUB_EVENT_NAME": "workflow_dispatch", + } + with ( + mock.patch.dict(MODULE.os.environ, environment, clear=True), + mock.patch.object(MODULE, "_cpu_model", return_value="Example CPU"), + mock.patch.object(MODULE, "_command_version", return_value="tool 1.0"), + ): + run_manifest = MODULE.build_manifest([fragment], BASE_SHA, CANDIDATE_SHA) + self.assertEqual(run_manifest["schema"], MODULE.RUN_SCHEMA) + self.assertEqual(run_manifest["comparison"]["candidate_commit"], CANDIDATE_SHA) + self.assertEqual( + run_manifest["suites"][0]["cases"][0]["workload_commit"], WORKLOAD_SHA + ) + + def test_flattens_both_comparison_sides(self): + temp, root = self.artifact_dir() + self.addCleanup(temp.cleanup) + rows = MODULE.build_rows(root, TRUSTED) + self.assertEqual({row["side_role"] for row in rows}, {"baseline", "candidate"}) + self.assertEqual(rows[0]["mean_seconds"], 1.0) + self.assertEqual(rows[-1]["mean_seconds"], 1.1) + self.assertNotEqual(rows[0]["result_id"], rows[-1]["result_id"]) + self.assertEqual(rows[0]["workload_commit"], WORKLOAD_SHA) + + def test_result_ids_are_stable_but_attempts_are_distinct(self): + temp, root = self.artifact_dir() + self.addCleanup(temp.cleanup) + first = MODULE.build_rows(root, TRUSTED) + self.assertEqual(first, MODULE.build_rows(root, TRUSTED)) + + failed_jobs_rerun = MODULE.build_rows(root, {**TRUSTED, "run_attempt": 2}) + self.assertEqual(first, failed_jobs_rerun) + + rerun_manifest = manifest() + rerun_manifest["run"]["run_attempt"] = 2 + (root / "benchmark-manifest.json").write_text(json.dumps(rerun_manifest), encoding="utf-8") + rerun_trusted = {**TRUSTED, "run_attempt": 2} + rerun = MODULE.build_rows(root, rerun_trusted) + self.assertNotEqual(first[0]["result_id"], rerun[0]["result_id"]) + + with self.assertRaisesRegex(ValueError, "artifact run attempt"): + MODULE.build_rows(root, TRUSTED) + + def test_rejects_incomplete_comparison(self): + temp, root = self.artifact_dir(candidate={"unexpected": result(1.1)}) + self.addCleanup(temp.cleanup) + with self.assertRaisesRegex(ValueError, "exactly match"): + MODULE.build_rows(root, TRUSTED) + + def test_rejects_non_finite_timings(self): + bad = result(1.0) + bad["mean"] = float("nan") + base = {key: result(1.0) for key in manifest()["expected_result_keys"]} + base["forge_test/example-project"] = bad + temp, root = self.artifact_dir(base=base) + self.addCleanup(temp.cleanup) + with self.assertRaisesRegex(ValueError, "non-finite"): + MODULE.build_rows(root, TRUSTED) + + def test_preserves_symbolic_counters_as_integers(self): + symbolic = result(1.0) + symbolic["symbolic"] = {"solver_queries": 42, "solver_time_ms": 123} + base = {key: result(1.0) for key in manifest()["expected_result_keys"]} + base["forge_test/example-project"] = symbolic + temp, root = self.artifact_dir(base=base) + self.addCleanup(temp.cleanup) + rows = MODULE.build_rows(root, TRUSTED) + row = next(row for row in rows if row["benchmark_case_id"] == "forge_test/example-project") + self.assertEqual(row["counters_u64"]["solver_queries"], 42) + + def test_rejects_unsafe_clickhouse_table_name(self): + environment = { + "CLICKHOUSE_HOST": "clickhouse.example", + "CLICKHOUSE_USER": "writer", + "CLICKHOUSE_PASSWORD": "secret", + "CLICKHOUSE_TABLE": "results; DROP TABLE results", + } + with mock.patch.dict(MODULE.os.environ, environment, clear=True): + with self.assertRaisesRegex(ValueError, "identifier"): + MODULE.upload_rows([]) + + def test_uploads_to_reth_style_clickhouse_host(self): + environment = { + "CLICKHOUSE_HOST": "clickhouse.example", + "CLICKHOUSE_USER": "writer", + "CLICKHOUSE_PASSWORD": "secret", + } + response = mock.MagicMock() + response.__enter__.return_value.read.return_value = b"" + with mock.patch.dict(MODULE.os.environ, environment, clear=True): + with mock.patch.object(MODULE.urllib.request, "urlopen", return_value=response) as urlopen: + MODULE.upload_rows([{"result_id": "example"}]) + + request = urlopen.call_args.args[0] + self.assertTrue(request.full_url.startswith("https://clickhouse.example:8443/")) + self.assertIn("database=default", request.full_url) + self.assertIn("INSERT+INTO+benchmark_results+FORMAT+JSONEachRow", request.full_url) + self.assertEqual(request.data, b'{"result_id":"example"}\n') + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/tests/test_bump_lockfile.py b/.github/scripts/tests/test_bump_lockfile.py new file mode 100644 index 0000000000000..4a0f5aadfc43c --- /dev/null +++ b/.github/scripts/tests/test_bump_lockfile.py @@ -0,0 +1,103 @@ +import os +from pathlib import Path +import subprocess +import tempfile +import tomllib +import unittest + + +SCRIPTS = Path(__file__).parents[1] + + +class BumpLockfileTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + self.env = dict(os.environ, CARGO_HOME=str(self.root / "cargo-home")) + self.env.pop("GITHUB_OUTPUT", None) + self.project = self.root / "project" + self.project.mkdir() + (self.project / "src").mkdir() + (self.project / "src/lib.rs").write_text("") + self.target = self.make_dependency("target-dep") + self.unrelated = self.make_dependency("unrelated-dep") + self.write_manifest(self.revision(self.target)) + self.run_command("cargo", "generate-lockfile", cwd=self.project) + self.baseline = (self.project / "Cargo.lock").read_bytes() + self.advance(self.target) + self.advance(self.unrelated) + + def run_command(self, *args, cwd, check=True): + result = subprocess.run( + args, cwd=cwd, env=self.env, + text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + ) + if check: + self.assertEqual(result.returncode, 0, result.stdout) + return result + + def make_dependency(self, name): + path = self.root / name + path.mkdir() + (path / "src").mkdir() + (path / "src/lib.rs").write_text("pub const VALUE: u32 = 1;\n") + (path / "Cargo.toml").write_text( + f'[package]\nname = "{name}"\nversion = "0.1.0"\nedition = "2021"\n' + ) + self.run_command("git", "init", "--initial-branch=main", cwd=path) + self.run_command("git", "config", "user.name", "Test", cwd=path) + self.run_command("git", "config", "user.email", "test@example.invalid", cwd=path) + self.run_command("git", "add", ".", cwd=path) + self.run_command("git", "-c", "commit.gpgsign=false", "commit", "-m", "chore: initialize fixture", cwd=path) + return path + + def advance(self, path): + (path / "src/lib.rs").write_text("pub const VALUE: u32 = 2;\n") + self.run_command("git", "-c", "commit.gpgsign=false", "commit", "-am", "chore: advance fixture", cwd=path) + + def revision(self, path): + return self.run_command("git", "rev-parse", "HEAD", cwd=path).stdout.strip() + + def write_manifest(self, revision): + (self.project / "Cargo.toml").write_text( + '[workspace]\n[package]\nname = "consumer"\nversion = "0.1.0"\nedition = "2021"\n' + '[dependencies]\n' + f'target-dep = {{ git = "{self.target.as_uri()}", rev = "{revision}" }}\n' + f'unrelated-dep = {{ git = "{self.unrelated.as_uri()}", branch = "main" }}\n' + ) + + def update_lockfile(self, name): + # Exercise the actual shell function with real Cargo and local Git dependencies. + script = self.project / ".github/scripts" / name + script.parent.mkdir(parents=True, exist_ok=True) + source = (SCRIPTS / name).read_text() + self.assertTrue(source.endswith('main "$@"\n')) + script.write_text(source.removesuffix('main "$@"\n') + "regenerate_lockfile\n") + return self.run_command("bash", str(script), cwd=self.project, check=False) + + def test_preserves_unrelated_pin(self): + old = tomllib.loads(self.baseline.decode())["package"] + old_unrelated = next(p for p in old if p["name"] == "unrelated-dep") + self.write_manifest(self.revision(self.target)) + for name in ("bump-solar.sh", "bump-tempo.sh"): + with self.subTest(script=name): + (self.project / "Cargo.lock").write_bytes(self.baseline) + result = self.update_lockfile(name) + self.assertEqual(result.returncode, 0, result.stdout) + packages = tomllib.loads((self.project / "Cargo.lock").read_text())["package"] + self.assertEqual(next(p for p in packages if p["name"] == "unrelated-dep"), old_unrelated) + target = next(p for p in packages if p["name"] == "target-dep") + self.assertTrue(target["source"].endswith("#" + self.revision(self.target))) + + def test_resolution_failure_stops_script(self): + self.write_manifest("0" * 40) + for name in ("bump-solar.sh", "bump-tempo.sh"): + with self.subTest(script=name): + result = self.update_lockfile(name) + self.assertNotEqual(result.returncode, 0, result.stdout) + self.assertEqual((self.project / "Cargo.lock").read_bytes(), self.baseline) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/tests/test_compare_benchmark_thresholds.py b/.github/scripts/tests/test_compare_benchmark_thresholds.py new file mode 100644 index 0000000000000..7858ed7a4b452 --- /dev/null +++ b/.github/scripts/tests/test_compare_benchmark_thresholds.py @@ -0,0 +1,103 @@ +import json +import pathlib +import subprocess +import tempfile +import unittest + + +SCRIPT = pathlib.Path(__file__).parents[1] / "compare-benchmark-thresholds.py" + + +class CompareBenchmarkThresholdsTests(unittest.TestCase): + def run_compare(self, base, candidate, rules, candidate_exists=True): + with tempfile.TemporaryDirectory() as directory: + directory = pathlib.Path(directory) + base_path = directory / "base.json" + candidate_path = directory / "candidate.json" + base_path.write_text(json.dumps(base)) + if candidate_exists: + candidate_path.write_text(json.dumps(candidate)) + config_path = directory / "thresholds.json" + config_path.write_text(json.dumps({ + "schema_version": 1, + "kind": "foundry-nightly-benchmark-thresholds", + "benchmarks": rules, + })) + return subprocess.run( + ["python3", str(SCRIPT), str(base_path), str(candidate_path), str(config_path)], + text=True, + capture_output=True, + check=False, + ) + + @staticmethod + def rule(warning, regression, alert=True): + return {"warning_percent": warning, "regression_percent": regression, "alert": alert} + + def test_keys_use_different_thresholds_and_warning_does_not_alert(self): + result = self.run_compare( + {"strict": 100, "loose": 100}, {"strict": 103, "loose": 103}, + {"strict": self.rule(1, 2), "loose": self.rule(5, 10)}, + ) + self.assertEqual(result.returncode, 1) + self.assertIn("+3.00% ❌ Regression", result.stdout) + self.assertIn("+3.00% ⚪ Within threshold", result.stdout) + + warning = self.run_compare({"key": 100}, {"key": 106}, {"key": self.rule(5, 10)}) + self.assertEqual(warning.returncode, 0) + self.assertIn("Warning", warning.stdout) + + def test_advisory_and_uncalibrated_never_alert(self): + advisory = self.run_compare( + {"coverage": 100}, {"coverage": 120}, {"coverage": self.rule(5, 10, False)} + ) + self.assertEqual(advisory.returncode, 0) + self.assertIn("Regression (advisory)", advisory.stdout) + + uncalibrated = self.run_compare({"symbolic": 1}, {"symbolic": 2}, {}) + self.assertEqual(uncalibrated.returncode, 0) + self.assertIn("Uncalibrated", uncalibrated.stdout) + + def test_missing_results_are_execution_errors(self): + rules = { + "alerting": self.rule(5, 10), + "advisory": self.rule(5, 10, False), + } + missing_nightly = self.run_compare({"alerting": 1, "advisory": 1}, {}, rules) + self.assertEqual(missing_nightly.returncode, 2) + self.assertIn("⚠️ Incomplete benchmark", missing_nightly.stdout) + + missing_nightly_file = self.run_compare({"alerting": 1}, {}, rules, False) + self.assertEqual(missing_nightly_file.returncode, 2) + self.assertIn("⚠️ Incomplete benchmark", missing_nightly_file.stdout) + + missing_baseline = self.run_compare({}, {"alerting": 1}, rules) + self.assertEqual(missing_baseline.returncode, 2) + self.assertIn("⚠️ Incomplete benchmark", missing_baseline.stdout) + + def test_calibrated_key_missing_from_both_results_is_an_execution_error(self): + for alert in (True, False): + with self.subTest(alert=alert): + result = self.run_compare( + {"complete": 1}, {"complete": 1}, {"missing": self.rule(5, 10, alert)} + ) + self.assertEqual(result.returncode, 2) + self.assertEqual(result.stderr, "") + self.assertEqual(result.stdout.splitlines()[4:6], [ + "| `complete` | 1.00s | 1.00s | +0.00% ⚪ Uncalibrated |", + "| `missing` | N/A | N/A | ⚠️ Incomplete benchmark |", + ]) + + def test_malformed_config_and_input_exit_two(self): + config = self.run_compare({"key": 1}, {"key": 2}, {"key": self.rule(10, 5)}) + self.assertEqual(config.returncode, 2) + rules = {"key": self.rule(1, 2)} + bad_mean = self.run_compare({"key": {"mean": 0}}, {"key": 2}, rules) + self.assertEqual(bad_mean.returncode, 2) + null_candidate = self.run_compare({"key": 1}, {"key": None}, rules) + self.assertEqual(null_candidate.returncode, 2) + null_baseline = self.run_compare({"key": None}, {"key": 1}, rules) + self.assertEqual(null_baseline.returncode, 2) + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/tests/test_finalize_release.py b/.github/scripts/tests/test_finalize_release.py new file mode 100644 index 0000000000000..2487fcd37b561 --- /dev/null +++ b/.github/scripts/tests/test_finalize_release.py @@ -0,0 +1,377 @@ +import importlib.util +import json +import pathlib +import subprocess +import unittest + + +SCRIPT = pathlib.Path(__file__).parents[1] / "finalize-release.py" +SPEC = importlib.util.spec_from_file_location("finalize_release", SCRIPT) +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + +DIGEST = "sha256:" + "a" * 64 +OTHER_DIGEST = "sha256:" + "b" * 64 +RELEASES_COMMAND = ( + "gh", "api", "--paginate", "--slurp", "repos/r/releases?per_page=100", +) + + +class FakeCommands: + def __init__(self, results=None, artifact_digest=None): + self.results = results or {} + self.artifact_digest = artifact_digest + self.calls = [] + + def _result(self, args): + key = tuple(args) + if key not in self.results: + raise AssertionError(f"unexpected command: {args}") + value = self.results[key] + if isinstance(value, Exception): + raise value + return value + + def output(self, args): + self.calls.append(tuple(args)) + return self._result(args) + + def run(self, args, check=True): + self.calls.append(tuple(args)) + if args[:3] == ["gh", "run", "download"]: + if self.artifact_digest is None: + raise AssertionError(f"unexpected command: {args}") + directory = pathlib.Path(args[args.index("--dir") + 1]) + (directory / "release-docker-digest.txt").write_text(self.artifact_digest) + return subprocess.CompletedProcess(args, 0, "", "") + value = self._result(args) + if isinstance(value, subprocess.CompletedProcess): + return value + return subprocess.CompletedProcess(args, value, "", "") + + +def completed(args, code=0, stdout="", stderr=""): + return subprocess.CompletedProcess(args, code, stdout, stderr) + + +def release(tag, draft=False, prerelease=False, target_commitish=None): + value = {"tag_name": tag, "draft": draft, "prerelease": prerelease} + if target_commitish is not None: + value["target_commitish"] = target_commitish + return value + + +def release_pages(*values): + return json.dumps([list(values)]) + + +def digest_command(reference): + return ( + "docker", "buildx", "imagetools", "inspect", reference, + "--format", "{{json .Manifest.Digest}}", + ) + + +def tag_command(tag): + return ("gh", "api", f"repos/r/git/ref/tags/{tag}", "--jq", ".object") + + +def create_tag_command(tag, commit): + return ( + "gh", "api", "--method", "POST", "repos/r/git/refs", + "-f", f"ref=refs/tags/{tag}", "-f", f"sha={commit}", + ) + + +class ParsingTests(unittest.TestCase): + def test_strict_tags(self): + for tag in ("v0.1.10", "v1.2.3", "v1.2.3-rc1", "nightly-" + "a" * 40): + MODULE.parse_tag(tag, "nightly" if tag.startswith("nightly") else "stable") + for tag in ("v01.2.3", "v1.02.3", "v1.2.3-rc0", "v1.2.3-rc01", "nightly-" + "A" * 40): + with self.assertRaises(MODULE.ReleaseError): + MODULE.parse_tag(tag, "nightly" if tag.startswith("nightly") else "stable") + + def test_exact_release_rejects_missing_and_duplicates(self): + self.assertEqual(MODULE.exact_release([release("v1.0.0")], "v1.0.0")["tag_name"], "v1.0.0") + for values in ([], [release("v1.0.0"), release("v1.0.0", draft=True)]): + with self.assertRaisesRegex(MODULE.ReleaseError, "exactly one"): + MODULE.exact_release(values, "v1.0.0") + + +class StableTests(unittest.TestCase): + def test_numeric_alias_scopes_and_ignored_releases(self): + aliases, latest = MODULE.stable_aliases("v1.10.2", [ + release("v1.9.99"), release("v1.10.3", draft=True), + release("v2.0.0-rc1", prerelease=True), release("v2.0.0"), + ]) + self.assertEqual(aliases, ["v1.10", "v1"]) + self.assertFalse(latest) + + def test_older_candidate_can_advance_own_minor_only(self): + aliases, _ = MODULE.stable_aliases("v1.9.5", [release("v1.10.0")]) + self.assertEqual(aliases, ["v1.9"]) + + def test_exact_digest_same_and_conflicting(self): + command = digest_command("image:v1.2.3") + commands = FakeCommands({command: f'"{DIGEST}"'}) + self.assertEqual(MODULE.verify_exact(commands, "image", "v1.2.3", DIGEST), DIGEST) + with self.assertRaisesRegex(MODULE.ReleaseError, "expected"): + MODULE.verify_exact(FakeCommands({command: f'"{DIGEST}"'}), "image", "v1.2.3", OTHER_DIGEST) + + def test_release_run_requirement_uses_latest_matching_run(self): + command = ( + "gh", "api", "--paginate", "--slurp", + "repos/r/actions/workflows/release.yml/runs?event=push&head_sha=abc&per_page=100", + ) + runs = {"workflow_runs": [ + {"id": 1, "conclusion": "success", "head_sha": "abc", "head_branch": "v1.0.0"}, + {"id": 2, "conclusion": "success", "head_sha": "abc", "head_branch": "v1.0.0"}, + ]} + self.assertEqual(MODULE.require_release_run(FakeCommands({command: json.dumps([runs])}), "r", "v1.0.0", "abc"), 2) + runs["workflow_runs"][1]["conclusion"] = "failure" + with self.assertRaisesRegex(MODULE.ReleaseError, "latest .* was not successful"): + MODULE.require_release_run(FakeCommands({command: json.dumps([runs])}), "r", "v1.0.0", "abc") + + def test_rc_draft_is_published_without_aliases(self): + tag = "v1.2.3-rc1" + commit = "c" * 40 + run_command = ( + "gh", "api", "--paginate", "--slurp", + f"repos/r/actions/workflows/release.yml/runs?event=push&head_sha={commit}&per_page=100", + ) + publish = ("gh", "release", "edit", tag, "--draft=false", "--prerelease=true", "--latest=false") + commands = FakeCommands({ + RELEASES_COMMAND: release_pages(release(tag, draft=True, prerelease=True)), + ("git", "rev-parse", f"{tag}^{{commit}}"): commit, + run_command: json.dumps([{"workflow_runs": [ + {"id": 7, "conclusion": "success", "head_sha": commit, "head_branch": tag}, + ]}]), + digest_command(f"image:{tag}"): f'"{DIGEST}"', + publish: 0, + }, artifact_digest=DIGEST) + MODULE.finalize_stable(commands, "r", "image", tag) + self.assertIn(publish, commands.calls) + + def test_published_retry_does_not_edit_immutable_release(self): + tag = "v1.2.3" + commit = "c" * 40 + run_command = ( + "gh", "api", "--paginate", "--slurp", + f"repos/r/actions/workflows/release.yml/runs?event=push&head_sha={commit}&per_page=100", + ) + results = { + RELEASES_COMMAND: release_pages(release(tag)), + ("git", "rev-parse", f"{tag}^{{commit}}"): commit, + run_command: json.dumps([{"workflow_runs": [ + {"id": 7, "conclusion": "success", "head_sha": commit, "head_branch": tag}, + ]}]), + digest_command(f"image:{tag}"): f'"{DIGEST}"', + } + for alias in ("v1.2", "v1", "latest"): + results[("docker", "buildx", "imagetools", "create", "--tag", f"image:{alias}", f"image@{DIGEST}")] = 0 + results[digest_command(f"image:{alias}")] = f'"{DIGEST}"' + commands = FakeCommands(results, artifact_digest=DIGEST) + MODULE.finalize_stable(commands, "r", "image", tag) + self.assertFalse(any(call[:3] == ("gh", "release", "edit") for call in commands.calls)) + + +class NightlyTests(unittest.TestCase): + def image_data(self, *revisions): + return json.dumps({ + f"linux/platform{index}": { + "config": {"Labels": {"org.opencontainers.image.revision": revision}}, + } + for index, revision in enumerate(revisions) + }) + + def inspect_command(self): + return ( + "docker", "buildx", "imagetools", "inspect", "image:nightly", + "--format", "{{json .Image}}", + ) + + def test_current_alias_newer_same_stale_and_error(self): + candidate = "b" * 40 + older = "a" * 40 + inspect = self.inspect_command() + ancestry = ("git", "merge-base", "--is-ancestor", older, candidate) + self.assertTrue(MODULE.nightly_eligible(FakeCommands({ + inspect: completed(inspect, stdout=self.image_data(older, older)), ancestry: 0, + }), "image", candidate)) + self.assertTrue(MODULE.nightly_eligible(FakeCommands({ + inspect: completed(inspect, stdout=self.image_data(candidate, candidate)), + }), "image", candidate)) + self.assertFalse(MODULE.nightly_eligible(FakeCommands({ + inspect: completed(inspect, stdout=self.image_data(older, older)), ancestry: 1, + }), "image", candidate)) + with self.assertRaisesRegex(MODULE.ReleaseError, "inspect current nightly"): + MODULE.nightly_eligible(FakeCommands({ + inspect: completed(inspect, code=1, stderr="unauthorized"), + }), "image", candidate) + + def test_missing_alias_bootstraps_but_inconsistent_revisions_fail(self): + candidate = "b" * 40 + inspect = self.inspect_command() + self.assertTrue(MODULE.nightly_eligible(FakeCommands({ + inspect: completed(inspect, code=1, stderr="manifest unknown"), + }), "image", candidate)) + with self.assertRaisesRegex(MODULE.ReleaseError, "inconsistent"): + MODULE.nightly_eligible(FakeCommands({ + inspect: completed(inspect, stdout=self.image_data("a" * 40, "b" * 40)), + }), "image", candidate) + + def test_current_alias_is_promoted_before_draft_is_published(self): + candidate = "b" * 40 + tag = f"nightly-{candidate}" + release_tag = f"staging-release-{candidate}" + publish = ( + "gh", "release", "edit", release_tag, "--tag", tag, "--target", candidate, "--draft=false", + "--prerelease=true", "--latest=false", + ) + promote = ("docker", "buildx", "imagetools", "create", "--tag", "image:nightly", f"image@{DIGEST}") + commands = FakeCommands({ + digest_command(f"image:{tag}"): f'"{DIGEST}"', + RELEASES_COMMAND: release_pages( + release(release_tag, draft=True, prerelease=True, target_commitish=candidate), + ), + self.inspect_command(): completed(self.inspect_command(), stdout=self.image_data(candidate, candidate)), + create_tag_command(tag, candidate): 0, + tag_command(tag): json.dumps({"type": "commit", "sha": candidate}), + publish: 0, + promote: 0, + digest_command("image:nightly"): f'"{DIGEST}"', + }) + MODULE.finalize_nightly(commands, "r", "image", tag, release_tag, DIGEST) + self.assertLess(commands.calls.index(self.inspect_command()), commands.calls.index(publish)) + self.assertLess(commands.calls.index(promote), commands.calls.index(publish)) + + def test_existing_exact_final_tag_allows_publication_retry(self): + candidate = "b" * 40 + tag = f"nightly-{candidate}" + release_tag = f"staging-release-{candidate}" + publish = ( + "gh", "release", "edit", release_tag, "--tag", tag, "--target", candidate, "--draft=false", + "--prerelease=true", "--latest=false", + ) + promote = ("docker", "buildx", "imagetools", "create", "--tag", "image:nightly", f"image@{DIGEST}") + create = create_tag_command(tag, candidate) + commands = FakeCommands({ + digest_command(f"image:{tag}"): f'"{DIGEST}"', + RELEASES_COMMAND: release_pages( + release(release_tag, draft=True, prerelease=True, target_commitish=candidate), + ), + self.inspect_command(): completed(self.inspect_command(), stdout=self.image_data(candidate, candidate)), + create: completed(create, code=1, stderr="gh: Reference already exists (HTTP 422)"), + tag_command(tag): json.dumps({"type": "commit", "sha": candidate}), + publish: 0, + promote: 0, + digest_command("image:nightly"): f'"{DIGEST}"', + }) + MODULE.finalize_nightly(commands, "r", "image", tag, release_tag, DIGEST) + self.assertLess(commands.calls.index(promote), commands.calls.index(publish)) + self.assertIn(publish, commands.calls) + + def test_published_nightly_retry_still_promotes_alias(self): + candidate = "b" * 40 + tag = f"nightly-{candidate}" + release_tag = f"staging-release-{candidate}" + promote = ("docker", "buildx", "imagetools", "create", "--tag", "image:nightly", f"image@{DIGEST}") + commands = FakeCommands({ + digest_command(f"image:{tag}"): f'"{DIGEST}"', + RELEASES_COMMAND: release_pages(release(tag, prerelease=True)), + tag_command(tag): json.dumps({"type": "commit", "sha": candidate}), + self.inspect_command(): completed(self.inspect_command(), stdout=self.image_data(candidate, candidate)), + promote: 0, + digest_command("image:nightly"): f'"{DIGEST}"', + }) + MODULE.finalize_nightly(commands, "r", "image", tag, release_tag, DIGEST) + self.assertFalse(any(call[:3] == ("gh", "release", "edit") for call in commands.calls)) + self.assertIn(promote, commands.calls) + + def test_conflicting_final_and_staging_releases_fail(self): + candidate = "b" * 40 + tag = f"nightly-{candidate}" + release_tag = f"staging-release-{candidate}" + commands = FakeCommands({ + digest_command(f"image:{tag}"): f'"{DIGEST}"', + RELEASES_COMMAND: release_pages( + release(tag, prerelease=True), + release(release_tag, draft=True, prerelease=True, target_commitish=candidate), + ), + }) + with self.assertRaisesRegex(MODULE.ReleaseError, "both final release"): + MODULE.finalize_nightly(commands, "r", "image", tag, release_tag, DIGEST) + + def test_invalid_staging_release_states_fail(self): + candidate = "b" * 40 + tag = f"nightly-{candidate}" + release_tag = f"staging-release-{candidate}" + cases = ( + (release(release_tag, prerelease=True, target_commitish=candidate), "already published"), + (release(release_tag, draft=True, prerelease=True, target_commitish="a" * 40), "unexpected commit"), + ) + for state, message in cases: + with self.subTest(message=message): + commands = FakeCommands({ + digest_command(f"image:{tag}"): f'"{DIGEST}"', + RELEASES_COMMAND: release_pages(state), + }) + with self.assertRaisesRegex(MODULE.ReleaseError, message): + MODULE.finalize_nightly(commands, "r", "image", tag, release_tag, DIGEST) + + def test_stale_nightly_is_not_published(self): + candidate = "b" * 40 + current = "c" * 40 + tag = f"nightly-{candidate}" + release_tag = f"staging-release-{candidate}" + ancestry = ("git", "merge-base", "--is-ancestor", current, candidate) + commands = FakeCommands({ + digest_command(f"image:{tag}"): f'"{DIGEST}"', + RELEASES_COMMAND: release_pages( + release(release_tag, draft=True, prerelease=True, target_commitish=candidate), + ), + self.inspect_command(): completed(self.inspect_command(), stdout=self.image_data(current, current)), + ancestry: 1, + }) + MODULE.finalize_nightly(commands, "r", "image", tag, release_tag, DIGEST) + self.assertFalse(any(call[:3] == ("gh", "release", "edit") for call in commands.calls)) + self.assertFalse(any(call[:4] == ("gh", "api", "--method", "POST") for call in commands.calls)) + + def test_legacy_final_draft_fails(self): + candidate = "b" * 40 + tag = f"nightly-{candidate}" + release_tag = f"staging-release-{candidate}" + commands = FakeCommands({ + digest_command(f"image:{tag}"): f'"{DIGEST}"', + RELEASES_COMMAND: release_pages(release(tag, draft=True, prerelease=True)), + }) + with self.assertRaisesRegex(MODULE.ReleaseError, "unexpectedly exists as a draft"): + MODULE.finalize_nightly(commands, "r", "image", tag, release_tag, DIGEST) + + def test_existing_wrong_final_tag_blocks_staging_publication(self): + candidate = "b" * 40 + tag = f"nightly-{candidate}" + release_tag = f"staging-release-{candidate}" + create = create_tag_command(tag, candidate) + commands = FakeCommands({ + digest_command(f"image:{tag}"): f'"{DIGEST}"', + RELEASES_COMMAND: release_pages( + release(release_tag, draft=True, prerelease=True, target_commitish=candidate), + ), + self.inspect_command(): completed(self.inspect_command(), stdout=self.image_data(candidate, candidate)), + create: completed(create, code=1, stderr="gh: Reference already exists (HTTP 422)"), + tag_command(tag): json.dumps({"type": "commit", "sha": "a" * 40}), + }) + with self.assertRaisesRegex(MODULE.ReleaseError, "resolves to .* expected"): + MODULE.finalize_nightly(commands, "r", "image", tag, release_tag, DIGEST) + + def test_unrelated_staging_tag_fails_before_external_checks(self): + candidate = "b" * 40 + with self.assertRaisesRegex(MODULE.ReleaseError, "does not match nightly commit"): + MODULE.finalize_nightly( + FakeCommands(), "r", "image", f"nightly-{candidate}", "staging-release-unrelated", DIGEST, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/scripts/tests/test_tempo_check.py b/.github/scripts/tests/test_tempo_check.py new file mode 100644 index 0000000000000..c7006f55142f7 --- /dev/null +++ b/.github/scripts/tests/test_tempo_check.py @@ -0,0 +1,64 @@ +import pathlib +import re +import subprocess +import unittest + + +SCRIPT = pathlib.Path(__file__).parents[1] / "tempo-check.sh" + + +class TempoAnvilReadinessTests(unittest.TestCase): + def run_wait(self, setup, timeout="120"): + # Load only the helper, without running the live-chain checks. + helper = re.search(r"^wait_for_anvil\(\) \{.*?^\}", SCRIPT.read_text(), re.M | re.S) + self.assertIsNotNone(helper) + return subprocess.run( + ["bash", "-c", helper.group() + r''' +set -euo pipefail +sleep 60 & +pid=$! +trap 'kill "$pid" 2>/dev/null || true; wait "$pid" 2>/dev/null || true' EXIT +# Advance the readiness clock without waiting for real RPC timeouts. +sleep() { SECONDS=$((SECONDS + $1)); } +attempts=0 +cast() { + [[ "$*" == "client --rpc-url http://127.0.0.1:8547 --rpc-timeout 1" ]] || exit 99 + attempts=$((attempts + 1)) + (( attempts >= ready_after )) +} +eval "$1" +if wait_for_anvil "$pid" 8547 "$2"; then + result=0 +else + result=$? +fi +echo "attempts=$attempts" +exit "$result" +''', "bash", setup, timeout], + text=True, + capture_output=True, + timeout=5, + check=False, + ) + + def test_fork_startup_beyond_old_deadline(self): + result = self.run_wait("ready_after=13") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("Anvil started successfully on port 8547", result.stdout) + self.assertIn("attempts=13", result.stdout) + + def test_exited_child_fails_before_probing_rpc(self): + result = self.run_wait('ready_after=1; kill "$pid"; wait "$pid" || true') + self.assertEqual(result.returncode, 1) + self.assertIn("Anvil exited before serving RPC", result.stderr) + self.assertIn("attempts=0", result.stdout) + + def test_unresponsive_child_times_out(self): + result = self.run_wait("ready_after=1000", timeout="3") + self.assertEqual(result.returncode, 1) + self.assertIn("within 3s", result.stderr) + self.assertIn("attempts=3", result.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/benchmarks-clickhouse.yml b/.github/workflows/benchmarks-clickhouse.yml new file mode 100644 index 0000000000000..cd8787d9c10ff --- /dev/null +++ b/.github/workflows/benchmarks-clickhouse.yml @@ -0,0 +1,61 @@ +name: Ingest Foundry Benchmarks + +permissions: {} + +on: + workflow_run: # zizmor: ignore[dangerous-triggers] trusted default-branch ingestion boundary + workflows: [Foundry Benchmarks] + types: [completed] + +jobs: + ingest: + if: >- + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'workflow_dispatch' && + github.event.workflow_run.path == '.github/workflows/benchmarks.yml' + runs-on: ubuntu-latest + environment: bench + permissions: + actions: read + contents: read + steps: + # workflow_run executes this workflow from the default branch. Never check out + # the triggering branch because this job receives ClickHouse credentials. + - name: Checkout trusted ingester + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.repository.default_branch }} + fetch-depth: 0 + persist-credentials: false + + - name: Download benchmark results + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: benchmark-results + path: ${{ runner.temp }}/benchmark-results + github-token: ${{ github.token }} + run-id: ${{ github.event.workflow_run.id }} + + - name: Ingest source comparison + env: + ARTIFACT_DIR: ${{ runner.temp }}/benchmark-results + CLICKHOUSE_HOST: ${{ secrets.CLICKHOUSE_HOST }} + CLICKHOUSE_USER: ${{ secrets.CLICKHOUSE_USER }} + CLICKHOUSE_PASSWORD: ${{ secrets.CLICKHOUSE_PASSWORD }} + CLICKHOUSE_DATABASE: ${{ secrets.CLICKHOUSE_DATABASE || vars.CLICKHOUSE_DATABASE || 'default' }} + CLICKHOUSE_TABLE: ${{ vars.CLICKHOUSE_TABLE || 'benchmark_results' }} + TRUSTED_REPOSITORY: ${{ github.event.repository.full_name }} + TRUSTED_WORKFLOW_FILE: ${{ github.event.workflow_run.path }} + TRUSTED_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + TRUSTED_RUN_ID: ${{ github.event.workflow_run.id }} + TRUSTED_RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }} + TRUSTED_RUN_URL: ${{ github.event.workflow_run.html_url }} + TRUSTED_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + TRUSTED_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + TRUSTED_RUN_STARTED_AT: ${{ github.event.workflow_run.run_started_at || github.event.workflow_run.created_at }} + run: | + if [[ ! -f "$ARTIFACT_DIR/benchmark-manifest.json" ]]; then + echo "No source-comparison manifest; nothing to ingest." + exit 0 + fi + python3 .github/scripts/benchmark_clickhouse.py upload "$ARTIFACT_DIR" diff --git a/.github/workflows/benchmarks-dispatch.yml b/.github/workflows/benchmarks-dispatch.yml new file mode 100644 index 0000000000000..1fa12390fec27 --- /dev/null +++ b/.github/workflows/benchmarks-dispatch.yml @@ -0,0 +1,406 @@ +--- +# yamllint disable rule:line-length +name: bench (dispatch) + +permissions: {} + +'on': + issue_comment: + types: [created] + +concurrency: + group: bench-${{ github.event.issue.number }} + cancel-in-progress: false + +jobs: + publish: + # Gate on association here so unauthorized commenters never start a run. + if: >- + github.event.issue.pull_request && + contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) && + ( + github.event.comment.body == 'derek bench' || + startsWith(github.event.comment.body, 'derek bench ') || + github.event.comment.body == 'decofe bench' || + startsWith(github.event.comment.body, 'decofe bench ') || + github.event.comment.body == '@decofe bench' || + startsWith(github.event.comment.body, '@decofe bench ') + ) + runs-on: ubuntu-latest + # Scopes the EVENTS_* mTLS secrets; shared across all `bench` subcommands. + environment: bench + permissions: + pull-requests: write + steps: + - name: Validate request + id: request + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const usage = [ + '**Usage:** `derek bench [args]` (also `decofe bench ...`).\n', + '- `bench invariant [compare-ref=REF] [timeout=N] [workers=N] [trials=N] [paired-seeds=true|false] [benchmark-type=property|optimization]`\n', + '- `bench symex [compare-ref=REF] [timeout=N]`\n', + '- `bench test [compare-ref=REF] [timeout=N] [isolate=true|false]`\n', + '- `bench build [compare-ref=REF] [timeout=N] [cache=true|false]`\n', + '- `bench fuzz [compare-ref=REF] [timeout=N]`\n', + '- `bench coverage [compare-ref=REF] [timeout=N]`\n', + '- `bench all [compare-ref=REF] [timeout=N]`', + ].join(''); + const actor = context.payload.comment.user.login; + const trustedAssociations = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); + const association = context.payload.comment.author_association; + + const failWithComment = async (message) => { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: `cc @${actor}\n\n${message}\n\n${usage}`, + }); + core.setFailed(message); + }; + + // Defense-in-depth; the job-level `if` already gates on this. + if (!trustedAssociations.has(association)) { + core.setFailed('Unauthorized association.'); + return; + } + + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + }); + + const repoFullName = `${context.repo.owner}/${context.repo.repo}`; + if (pr.head.repo.full_name !== repoFullName) { + await failWithComment('bench only runs for branches in `foundry-rs/foundry`, not external forks.'); + return; + } + + // `derek bench [args]` + const body = context.payload.comment.body.trim(); + const prefix = /^(?:(?:@?decofe|derek)\s+)bench\b/i; + const afterBench = body.replace(prefix, '').trim(); + const subMatch = afterBench.match(/^([A-Za-z][A-Za-z-]*)\b/); + const subcommand = subMatch ? subMatch[1].toLowerCase() : ''; + const rawArgs = (subMatch ? afterBench.slice(subMatch[0].length) : afterBench).trim(); + + const supported = new Set(['invariant', 'symex', 'test', 'build', 'fuzz', 'coverage', 'all']); + if (!subcommand) { + await failWithComment('Missing bench subcommand.'); + return; + } + if (!supported.has(subcommand)) { + await failWithComment(`Unknown bench subcommand \`${subcommand}\`.`); + return; + } + + // Generic arg tokenizer (shared by all subcommands). + const parts = []; + const argRegex = /(\S+?="[^"]*"|\S+?='[^']*'|\S+?=\S+|\S+)/g; + let match; + while ((match = argRegex.exec(rawArgs)) !== null) parts.push(match[1]); + + const parseArgs = (defaults, stringArgs, intArgs, enumArgs) => { + const unknown = []; + const invalid = []; + for (const part of parts) { + const eq = part.indexOf('='); + if (eq === -1) { + unknown.push(part); + continue; + } + const key = part.slice(0, eq); + let value = part.slice(eq + 1); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + if (stringArgs.has(key)) { + defaults[key] = value; + } else if (intArgs.has(key)) { + if (value !== '' && !/^[1-9]\d*$/.test(value)) { + invalid.push(`\`${key}=${value}\` (must be a positive integer)`); + } else { + defaults[key] = value; + } + } else if (enumArgs[key]) { + if (!enumArgs[key].has(value)) { + invalid.push(`\`${key}=${value}\` (must be one of: ${Array.from(enumArgs[key]).join(', ')})`); + } else { + defaults[key] = value; + } + } else { + unknown.push(key); + } + } + return { unknown, invalid }; + }; + + const parseFoundryBenchArgs = (opts, boolArgs = new Set()) => { + const { unknown, invalid } = parseArgs( + opts, + new Set(['compare-ref']), + new Set(['timeout']), + Object.fromEntries(Array.from(boolArgs).map((key) => [key, new Set(['true', 'false'])])), + ); + + const safeRef = /^[A-Za-z0-9._/-]{1,128}$/; + if (!safeRef.test(opts['compare-ref'])) { + invalid.push("`compare-ref` may only contain letters, numbers, '.', '_', '-', and '/'"); + } + const timeout = Number(opts.timeout); + if (!Number.isInteger(timeout) || timeout < 60 || timeout > 1800) { + invalid.push('`timeout` must be between 60 and 1800 seconds'); + } + return { unknown, invalid }; + }; + + const buildFoundryBenchPayload = (opts, benchmarks) => ({ + repository: repoFullName, + event: 'foundry-bench', + data: { + subcommand, + benchmarks, + pr_number: String(context.issue.number), + head_repo: pr.head.repo.full_name, + actor, + foundry_git_ref: pr.head.sha, + compare_foundry_git_ref: opts['compare-ref'], + timeout_seconds: opts.timeout, + }, + }); + + const foundryBenchSummary = (opts, benchmarks, extra = []) => [ + `subcommand: \`${subcommand}\``, + `PR SHA: \`${pr.head.sha.slice(0, 12)}\``, + `compare-ref: \`${opts['compare-ref']}\``, + `timeout: \`${opts.timeout}s\``, + `benchmarks: \`${benchmarks}\``, + ...extra, + ].join(', '); + + const foundryBenchmarksBySubcommand = { + symex: 'forge_symbolic_test', + fuzz: 'forge_fuzz_test', + coverage: 'forge_coverage', + all: [ + 'forge_isolate_test', + 'forge_build_no_cache', + 'forge_fuzz_test', + 'forge_coverage', + 'forge_symbolic_test', + ].join(','), + }; + + let payload; + let summary; + + if (subcommand === 'invariant') { + const opts = { + 'compare-ref': 'master', + timeout: '3600', + workers: '', + trials: '6', + 'paired-seeds': 'false', + 'benchmark-type': 'property', + }; + const { unknown, invalid } = parseArgs( + opts, + new Set(['compare-ref']), + new Set(['timeout', 'workers', 'trials']), + { + 'benchmark-type': new Set(['property', 'optimization']), + 'paired-seeds': new Set(['true', 'false']), + }, + ); + + const safeRef = /^[A-Za-z0-9._/-]{1,128}$/; + if (!safeRef.test(opts['compare-ref'])) { + invalid.push("`compare-ref` may only contain letters, numbers, '.', '_', '-', and '/'"); + } + const timeout = Number(opts.timeout); + if (!Number.isInteger(timeout) || timeout < 60 || timeout > 14400) { + invalid.push('`timeout` must be between 60 and 14400 seconds'); + } + if (opts.workers) { + const workers = Number(opts.workers); + if (!Number.isInteger(workers) || workers < 1 || workers > 256) { + invalid.push('`workers` must be between 1 and 256'); + } + } + const trials = Number(opts.trials); + if (!Number.isInteger(trials) || trials < 1 || trials > 20) { + invalid.push('`trials` must be between 1 and 20'); + } + + const errors = []; + if (unknown.length) errors.push(`Unknown argument(s): \`${unknown.join('`, `')}\``); + if (invalid.length) errors.push(`Invalid value(s): ${invalid.join(', ')}`); + if (errors.length) { + await failWithComment(`Invalid \`bench invariant\` command\n\n${errors.join('\n')}`); + return; + } + + // PR identity + safe knobs only; the rest comes from server defaults. + payload = { + repository: repoFullName, + // Wire event matches the existing sensor/template; command is renamed. + event: 'scfuzzbench', + data: { + pr_number: String(context.issue.number), + head_repo: pr.head.repo.full_name, + actor, + foundry_git_ref: pr.head.sha, + foundry_label: `pr-${context.issue.number}`, + compare_foundry_git_ref: opts['compare-ref'], + compare_foundry_label: opts['compare-ref'].replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 64), + benchmark_type: opts['benchmark-type'], + timeout_seconds: opts.timeout, + workers: opts.workers, + trials: opts.trials, + paired_seeds: opts['paired-seeds'], + }, + }; + + summary = [ + `subcommand: \`invariant\``, + `PR SHA: \`${pr.head.sha.slice(0, 12)}\``, + `compare-ref: \`${opts['compare-ref']}\``, + `timeout: \`${opts.timeout}s\``, + opts.workers ? `workers: \`${opts.workers}\`` : 'workers: `default`', + `trials: \`${opts.trials}\``, + `paired-seeds: \`${opts['paired-seeds']}\``, + `benchmark-type: \`${opts['benchmark-type']}\``, + ].join(', '); + } + + if (subcommand !== 'invariant') { + const opts = { + 'compare-ref': 'master', + timeout: '600', + }; + + const boolArgs = new Set(); + if (subcommand === 'test') { + opts.isolate = 'true'; + boolArgs.add('isolate'); + } + if (subcommand === 'build') { + opts.cache = 'false'; + boolArgs.add('cache'); + } + + const { unknown, invalid } = parseFoundryBenchArgs(opts, boolArgs); + + let benchmarks = foundryBenchmarksBySubcommand[subcommand]; + const extra = []; + if (subcommand === 'test') { + benchmarks = opts.isolate === 'true' ? 'forge_isolate_test' : 'forge_test'; + extra.push(`isolate: \`${opts.isolate}\``); + } else if (subcommand === 'build') { + benchmarks = opts.cache === 'true' ? 'forge_build_with_cache' : 'forge_build_no_cache'; + extra.push(`cache: \`${opts.cache}\``); + } + + const errors = []; + if (unknown.length) errors.push(`Unknown argument(s): \`${unknown.join('`, `')}\``); + if (invalid.length) errors.push(`Invalid value(s): ${invalid.join(', ')}`); + if (errors.length) { + await failWithComment(`Invalid \`bench ${subcommand}\` command\n\n${errors.join('\n')}`); + return; + } + + // PR identity + safe knobs only; benchmark targets and pod sizing + // are owned by the server-side foundry-bench workflow. + payload = buildFoundryBenchPayload(opts, benchmarks); + summary = foundryBenchSummary(opts, benchmarks, extra); + } + + core.setOutput('actor', actor); + core.setOutput('subcommand', subcommand); + core.setOutput('summary', summary); + core.setOutput('payload-b64', Buffer.from(JSON.stringify(payload), 'utf8').toString('base64')); + + - name: Acknowledge request + id: ack + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + ACTOR: ${{ steps.request.outputs.actor }} + SUBCOMMAND: ${{ steps.request.outputs.subcommand }} + SUMMARY: ${{ steps.request.outputs.summary }} + with: + script: | + try { + await github.rest.reactions.createForIssueComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: context.payload.comment.id, + content: 'eyes', + }); + } catch (error) { + core.warning(`Could not add acknowledgement reaction: ${error.message}`); + } + + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const { data: comment } = await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: `cc @${process.env.ACTOR}\n\nbench (${process.env.SUBCOMMAND}) event queued. [View publisher run](${runUrl})\n\n**Config:** ${process.env.SUMMARY}`, + }); + core.setOutput('comment-id', String(comment.id)); + + - name: Publish event + id: publish + continue-on-error: true + env: + PAYLOAD_B64: ${{ steps.request.outputs.payload-b64 }} + EVENTS_KEY: ${{ secrets.EVENTS_KEY }} + EVENTS_CERT: ${{ secrets.EVENTS_CERT }} + EVENTS_URL: ${{ secrets.EVENTS_URL }} + EVENTS_AUTH: ${{ secrets.EVENTS_AUTH }} + run: | + set -euo pipefail + umask 077 + + printf '%s' "$EVENTS_KEY" > "${RUNNER_TEMP}/key" + printf '%s' "$EVENTS_CERT" > "${RUNNER_TEMP}/cert" + printf '%s' "$PAYLOAD_B64" \ + | base64 --decode \ + > "${RUNNER_TEMP}/bench-event.json" + + curl --fail-with-body --silent --show-error --globoff \ + --connect-timeout 10 --max-time 30 \ + -X POST "$EVENTS_URL" \ + -H "Content-Type: application/json" \ + -H "$EVENTS_AUTH" \ + --key "${RUNNER_TEMP}/key" \ + --cert "${RUNNER_TEMP}/cert" \ + -d @"${RUNNER_TEMP}/bench-event.json" + + - name: Update queued comment + if: ${{ always() && steps.ack.outputs.comment-id != '' }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + ACTOR: ${{ steps.request.outputs.actor }} + SUBCOMMAND: ${{ steps.request.outputs.subcommand }} + COMMENT_ID: ${{ steps.ack.outputs.comment-id }} + PUBLISH_OUTCOME: ${{ steps.publish.outcome }} + SUMMARY: ${{ steps.request.outputs.summary }} + with: + script: | + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const success = process.env.PUBLISH_OUTCOME === 'success'; + const body = success + ? `cc @${process.env.ACTOR}\n\nbench (${process.env.SUBCOMMAND}) event published. Results will be reported separately. [View publisher run](${runUrl})\n\n**Config:** ${process.env.SUMMARY}` + : `cc @${process.env.ACTOR}\n\nbench (${process.env.SUBCOMMAND}) event failed to publish. [View publisher run](${runUrl})\n\n**Config:** ${process.env.SUMMARY}`; + + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: Number(process.env.COMMENT_ID), + body, + }); + if (!success) core.setFailed('Failed to publish bench event'); diff --git a/.github/workflows/benchmarks-nightly.yml b/.github/workflows/benchmarks-nightly.yml new file mode 100644 index 0000000000000..3b49e0139c1b8 --- /dev/null +++ b/.github/workflows/benchmarks-nightly.yml @@ -0,0 +1,222 @@ +name: Nightly Benchmarks + +permissions: {} + +on: + schedule: + - cron: "0 2 * * *" # 2am UTC nightly + workflow_dispatch: # allow manual triggering for testing + +env: + RUSTC_WRAPPER: "sccache" + +jobs: + run-benchmarks: + name: Run Nightly Benchmarks + if: github.event_name != 'schedule' || github.repository == 'foundry-rs/foundry' + runs-on: depot-ubuntu-24.04-32 + permissions: + id-token: write + contents: read + outputs: + has_regression: ${{ steps.compare.outputs.has_regression }} + steps: + - name: Secure runner + uses: tempoxyz/gh-actions/actions/secure-runner@2ab28a31ae569be9f780c7675b684471060afa40 + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install build dependencies + run: | + sudo apt-get update + sudo apt-get install -y build-essential pkg-config z3 + + - name: Setup Rust toolchain + uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # v1 + with: + toolchain: stable + + - uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # v1 + + - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + + - name: Setup Foundry + env: + FOUNDRY_DIR: ${{ github.workspace }}/.foundry + GITHUB_WORKSPACE: ${{ github.workspace }} + run: | + ./.github/scripts/setup-foundryup.sh + printf '%s\n' "$GITHUB_WORKSPACE/.foundry/bin" >> "$GITHUB_PATH" + + - name: Build benchmark binary + run: cargo build --locked --release --bin foundry-bench + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + + - name: Install hyperfine + run: | + curl -L https://github.com/sharkdp/hyperfine/releases/download/v1.19.0/hyperfine-v1.19.0-x86_64-unknown-linux-gnu.tar.gz | tar xz + sudo mv hyperfine-v1.19.0-x86_64-unknown-linux-gnu/hyperfine /usr/local/bin/ + rm -rf hyperfine-v1.19.0-x86_64-unknown-linux-gnu + + # --- Stable benchmarks --- + + - name: Run forge test benchmarks (stable) + continue-on-error: true + env: + FOUNDRY_DIR: ${{ github.workspace }}/.foundry + run: benches/scripts/run-benchmark-suite.sh nightly test --versions stable --output-dir ./benches + + - name: Run forge fuzz test benchmarks (stable) + continue-on-error: true + env: + FOUNDRY_DIR: ${{ github.workspace }}/.foundry + run: benches/scripts/run-benchmark-suite.sh nightly fuzz --versions stable --output-dir ./benches + + - name: Run forge build benchmarks (stable) + continue-on-error: true + env: + FOUNDRY_DIR: ${{ github.workspace }}/.foundry + run: benches/scripts/run-benchmark-suite.sh nightly build --versions stable --output-dir ./benches + + - name: Run forge coverage benchmarks (stable) + continue-on-error: true + env: + FOUNDRY_DIR: ${{ github.workspace }}/.foundry + run: benches/scripts/run-benchmark-suite.sh nightly coverage --versions stable --output-dir ./benches + + - name: Run forge symbolic benchmarks (stable) + continue-on-error: true + env: + FOUNDRY_DIR: ${{ github.workspace }}/.foundry + run: benches/scripts/run-benchmark-suite.sh nightly symbolic --versions stable --output-dir ./benches + + # --- Nightly benchmarks --- + + - name: Run forge test benchmarks (nightly) + continue-on-error: true + env: + FOUNDRY_DIR: ${{ github.workspace }}/.foundry + run: benches/scripts/run-benchmark-suite.sh nightly test --versions nightly --output-dir ./benches + + - name: Run forge fuzz test benchmarks (nightly) + continue-on-error: true + env: + FOUNDRY_DIR: ${{ github.workspace }}/.foundry + run: benches/scripts/run-benchmark-suite.sh nightly fuzz --versions nightly --output-dir ./benches + + - name: Run forge build benchmarks (nightly) + continue-on-error: true + env: + FOUNDRY_DIR: ${{ github.workspace }}/.foundry + run: benches/scripts/run-benchmark-suite.sh nightly build --versions nightly --output-dir ./benches + + - name: Run forge coverage benchmarks (nightly) + continue-on-error: true + env: + FOUNDRY_DIR: ${{ github.workspace }}/.foundry + run: benches/scripts/run-benchmark-suite.sh nightly coverage --versions nightly --output-dir ./benches + + - name: Run forge symbolic benchmarks (nightly) + continue-on-error: true + env: + FOUNDRY_DIR: ${{ github.workspace }}/.foundry + run: benches/scripts/run-benchmark-suite.sh nightly symbolic --versions nightly --output-dir ./benches + + - name: Merge benchmark JSON results + run: | + DATE=$(date -u +%Y-%m-%d) + shopt -s nullglob + + stable_parts=( "benches/stable-${DATE}-"*.json ) + nightly_parts=( "benches/nightly-${DATE}-"*.json ) + + if [[ ${#stable_parts[@]} -eq 0 && ${#nightly_parts[@]} -eq 0 ]]; then + echo "No benchmark results produced — all steps failed." + exit 1 + fi + + if [[ ${#stable_parts[@]} -gt 0 ]]; then + jq -s 'add' "${stable_parts[@]}" > "benches/stable-${DATE}.json" + echo "Merged ${#stable_parts[@]} stable result file(s) into stable-${DATE}.json" + fi + + if [[ ${#nightly_parts[@]} -gt 0 ]]; then + jq -s 'add' "${nightly_parts[@]}" > "benches/nightly-${DATE}.json" + echo "Merged ${#nightly_parts[@]} nightly result file(s) into nightly-${DATE}.json" + fi + + - name: Compare stable vs nightly results + id: compare + run: | + DATE=$(date -u +%Y-%m-%d) + STABLE_JSON="benches/stable-${DATE}.json" + NIGHTLY_JSON="benches/nightly-${DATE}.json" + set +e + python3 ./.github/scripts/compare-benchmark-thresholds.py \ + "$STABLE_JSON" "$NIGHTLY_JSON" \ + .github/benchmark-regression-thresholds.json > regression.md 2>&1 + status=$? + set -e + if [[ $status -eq 0 ]]; then + echo "has_regression=false" >> "$GITHUB_OUTPUT" + elif [[ $status -eq 1 ]]; then + echo "has_regression=true" >> "$GITHUB_OUTPUT" + else + cat regression.md + exit "$status" + fi + cat regression.md + cat regression.md >> "$GITHUB_STEP_SUMMARY" + + - name: Upload benchmark results + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: bench-results + retention-days: 45 + path: | + benches/stable-*.json + benches/nightly-*.json + benches/common/ + regression.md + + report-regression: + name: Report Regression + needs: run-benchmarks + if: needs.run-benchmarks.outputs.has_regression == 'true' && github.event_name == 'schedule' + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - name: Download benchmark results + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: bench-results + path: results/ + + - name: Open regression issue + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + LABEL: T-perf + run: | + DATE=$(date -u +%Y-%m-%d) + BODY="$(cat results/regression.md) + + --- + + **Run**: [${{ github.run_id }}](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) + **Date**: ${DATE} + **Repo benchmarked**: \`aave/aave-v4\` (pinned commit) + **Threshold**: Per-benchmark calibrated thresholds from \`.github/benchmark-regression-thresholds.json\`" + + # `gh issue create` resolves labels before it creates anything, so a label that does not + # exist fails the whole step and the regression goes unreported. That silently dropped + # four consecutive nightly alerts, so fall back to filing the issue without the label. + ARGS=(--title "[Nightly Regression] ${DATE}" --body "$BODY" --repo "$GH_REPO") + gh issue create "${ARGS[@]}" --label "$LABEL" || gh issue create "${ARGS[@]}" diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 699d6287fb476..e6c3e44b7aeaa 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -1,68 +1,104 @@ name: Foundry Benchmarks +permissions: {} + on: workflow_dispatch: inputs: - pr_number: - description: "PR number to comment on (optional)" - required: false - type: string versions: - description: "Comma-separated list of Foundry versions to benchmark (e.g., stable,nightly,v1.0.0)" + description: "Comma-separated list of Foundry versions to benchmark (optional, defaults to 'master,local'). Include 'local' to compare this branch's HEAD against a source-built 'origin/master' baseline and post the comparison on the PR. Omit 'local' (e.g. 'stable,nightly') to track released versions and commit results." required: false type: string - default: "stable,nightly" repos: - description: "Comma-separated list of repos to benchmark (e.g., ithacaxyz/account:main,Vectorized/solady)" + description: "Comma-separated repos to benchmark. Each entry: org/repo[:rev]. This override applies to every suite, so extra arguments must be valid for test, build, and coverage. Leave empty to use the per-suite defaults." required: false type: string - default: "ithacaxyz/account:v0.3.2,Vectorized/solady:v0.1.22" - -permissions: - contents: write - pull-requests: write env: - ITHACAXYZ_ACCOUNT: "ithacaxyz/account:v0.3.2" - VECTORIZED_SOLADY: "Vectorized/solady:v0.1.22" - DEFAULT_REPOS: "ithacaxyz/account:v0.3.2,Vectorized/solady:v0.1.22" + DEFAULT_VERSIONS: "master,local" + RUSTC_WRAPPER: "sccache" jobs: run-benchmarks: name: Run All Benchmarks - runs-on: foundry-runner + runs-on: depot-ubuntu-24.04-32 + permissions: + id-token: write + contents: read + pull-requests: read + outputs: + is_source_comparison: ${{ steps.prep.outputs.is_source_comparison }} + # Source-built baseline/candidate use the profiling profile, forge only. + env: + FOUNDRY_BENCH_LOCAL_BUILD_PROFILE: profiling + FOUNDRY_BENCH_LOCAL_BUILD_BINS: forge steps: + - name: Secure runner + uses: tempoxyz/gh-actions/actions/secure-runner@2ab28a31ae569be9f780c7675b684471060afa40 - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # Full history so the origin/master baseline can be built from source. + fetch-depth: 0 - name: Install build dependencies run: | sudo apt-get update - sudo apt-get install -y build-essential pkg-config + sudo apt-get install -y build-essential pkg-config z3 - name: Setup Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Cache Rust dependencies - uses: Swatinem/rust-cache@v2 + uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # v1 with: - workspaces: | - ./ + toolchain: stable + + # When 'local' is requested, build origin/master from source in a separate + # worktree and benchmark it as the baseline (labelled 'master') against + # this branch (labelled 'local'). This compares against master itself, not + # the last stable release, so master-only regressions are not hidden. + - name: Prepare comparison + id: prep + env: + GH_TOKEN: ${{ github.token }} + REQUESTED_VERSIONS: ${{ github.event.inputs.versions || env.DEFAULT_VERSIONS }} + run: | + compact="${REQUESTED_VERSIONS//[[:space:]]/}" + if [[ ",$compact," == *,local,* ]]; then + git fetch --no-tags origin master:refs/remotes/origin/master + base_sha="$(git rev-parse origin/master)" + candidate_sha="$(git rev-parse HEAD)" + git worktree add --detach ../foundry-baseline "$base_sha" + echo "versions=master=../foundry-baseline,local" >> "$GITHUB_OUTPUT" + echo "is_source_comparison=true" >> "$GITHUB_OUTPUT" + printf '%s' "$base_sha" > benches/baseline-ref.txt + printf '%s' "$candidate_sha" > benches/candidate-ref.txt + git rev-list --count "${candidate_sha}..${base_sha}" > benches/behind-count.txt + pr_number=$(gh api --method GET "repos/$GITHUB_REPOSITORY/pulls" \ + -f "head=$GITHUB_REPOSITORY_OWNER:$GITHUB_REF_NAME" -f state=open \ + --jq '.[0].number // empty') + if [[ -n "$pr_number" ]]; then + echo "BENCHMARK_PR_NUMBER=$pr_number" >> "$GITHUB_ENV" + fi + echo "Comparing origin/master ($base_sha) against this branch." + else + echo "versions=$REQUESTED_VERSIONS" >> "$GITHUB_OUTPUT" + echo "is_source_comparison=false" >> "$GITHUB_OUTPUT" + fi + + - uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # v1 + + - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 - name: Setup Foundry env: FOUNDRY_DIR: ${{ github.workspace }}/.foundry + GITHUB_WORKSPACE: ${{ github.workspace }} run: | ./.github/scripts/setup-foundryup.sh - echo "${{ github.workspace }}/.foundry/bin" >> $GITHUB_PATH + printf '%s\n' "$GITHUB_WORKSPACE/.foundry/bin" >> "$GITHUB_PATH" - name: Build benchmark binary - run: cargo build --release --bin foundry-bench - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "24" + run: cargo build --locked --release --bin foundry-bench - name: Install hyperfine run: | @@ -73,94 +109,145 @@ jobs: - name: Run forge test benchmarks env: FOUNDRY_DIR: ${{ github.workspace }}/.foundry + VERSIONS: ${{ steps.prep.outputs.versions }} + REPOS: ${{ github.event.inputs.repos }} run: | - VERSIONS="${{ github.event.inputs.versions || 'stable,nightly' }}" - REPOS="${{ github.event.inputs.repos || env.DEFAULT_REPOS }}" + benches/scripts/run-benchmark-suite.sh ci test \ + --versions "$VERSIONS" --output-dir ./benches --repos "$REPOS" - ./target/release/foundry-bench --output-dir ./benches --force-install \ - --versions $VERSIONS \ - --repos $REPOS \ - --benchmarks forge_test,forge_fuzz_test \ - --output-file forge_test_bench.md + - name: Run forge isolate test benchmarks + env: + FOUNDRY_DIR: ${{ github.workspace }}/.foundry + VERSIONS: ${{ steps.prep.outputs.versions }} + REPOS: ${{ github.event.inputs.repos }} + run: | + benches/scripts/run-benchmark-suite.sh ci isolate \ + --versions "$VERSIONS" --output-dir ./benches --repos "$REPOS" + + # Temporarily disabled as long as the previous stable benchmark baseline + # does not support symbolic execution. + # - name: Run forge symbolic benchmarks + # env: + # FOUNDRY_DIR: ${{ github.workspace }}/.foundry + # VERSIONS: ${{ github.event.inputs.versions || env.DEFAULT_VERSIONS }} + # REPOS: ${{ github.event.inputs.repos }} + # run: | + # benches/scripts/run-benchmark-suite.sh ci symbolic \ + # --versions "$VERSIONS" --output-dir ./benches --repos "$REPOS" - name: Run forge build benchmarks env: FOUNDRY_DIR: ${{ github.workspace }}/.foundry + VERSIONS: ${{ steps.prep.outputs.versions }} + REPOS: ${{ github.event.inputs.repos }} run: | - VERSIONS="${{ github.event.inputs.versions || 'stable,nightly' }}" - REPOS="${{ github.event.inputs.repos || env.DEFAULT_REPOS }}" - - ./target/release/foundry-bench --output-dir ./benches --force-install \ - --versions $VERSIONS \ - --repos $REPOS \ - --benchmarks forge_build_no_cache,forge_build_with_cache \ - --output-file forge_build_bench.md + benches/scripts/run-benchmark-suite.sh ci build \ + --versions "$VERSIONS" --output-dir ./benches --repos "$REPOS" - name: Run forge coverage benchmarks env: FOUNDRY_DIR: ${{ github.workspace }}/.foundry + VERSIONS: ${{ steps.prep.outputs.versions }} + REPOS: ${{ github.event.inputs.repos }} run: | - VERSIONS="${{ github.event.inputs.versions || 'stable,nightly' }}" - # Coverage only runs on ithacaxyz/account:v0.3.2 - - ./target/release/foundry-bench --output-dir ./benches --force-install \ - --versions $VERSIONS \ - --repos ${{ env.ITHACAXYZ_ACCOUNT }} \ - --benchmarks forge_coverage \ - --output-file forge_coverage_bench.md + benches/scripts/run-benchmark-suite.sh ci coverage \ + --versions "$VERSIONS" --output-dir ./benches --repos "$REPOS" - name: Combine benchmark results run: ./.github/scripts/combine-benchmarks.sh benches - - name: Commit and read benchmark results - id: benchmark_results - env: - GITHUB_HEAD_REF: ${{ github.head_ref }} - run: ./.github/scripts/commit-and-read-benchmarks.sh benches "${{ github.event_name }}" "${{ github.repository }}" + - name: Print benchmark results + run: cat benches/LATEST.md + + # For a source comparison the bench binary writes one JSON summary per + # version (suffixed `-master` / `-local`). Merge each side's per-benchmark + # files into a single baseline/candidate summary for the verdict table. + - name: Merge benchmark JSON summaries + if: steps.prep.outputs.is_source_comparison == 'true' + run: | + shopt -s nullglob + base_parts=( benches/*_bench-master.json ) + cand_parts=( benches/*_bench-local.json ) + if [[ ${#base_parts[@]} -eq 0 || ${#cand_parts[@]} -eq 0 ]]; then + echo "::error::Missing per-version benchmark JSON summaries." + exit 1 + fi + jq -s 'add' "${base_parts[@]}" > benches/base-summary.json + jq -s 'add' "${cand_parts[@]}" > benches/candidate-summary.json + + - name: Build benchmark manifest + if: steps.prep.outputs.is_source_comparison == 'true' + run: | + shopt -s nullglob + manifests=( benches/*_bench-manifest.json ) + if [[ ${#manifests[@]} -eq 0 ]]; then + echo "::error::Missing benchmark suite manifests." + exit 1 + fi + python3 .github/scripts/benchmark_clickhouse.py manifest \ + --output benches/benchmark-manifest.json \ + --baseline "$(cat benches/baseline-ref.txt)" \ + --candidate "$(cat benches/candidate-ref.txt)" \ + "${manifests[@]}" - name: Upload benchmark results as artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: benchmark-results + # The symbolic benchmark artifact is omitted while the symbolic step is disabled. + # base/candidate summaries and baseline-ref exist only for source comparisons. path: | benches/forge_test_bench.md + benches/forge_isolate_test_bench.md benches/forge_build_bench.md benches/forge_coverage_bench.md benches/LATEST.md - - outputs: - branch_name: ${{ steps.benchmark_results.outputs.branch_name }} - pr_comment: ${{ steps.benchmark_results.outputs.pr_comment }} + benches/base-summary.json + benches/candidate-summary.json + benches/common/ + benches/benchmark-manifest.json + benches/baseline-ref.txt + benches/candidate-ref.txt + benches/behind-count.txt publish-results: name: Publish Results needs: run-benchmarks - runs-on: foundry-runner + runs-on: ubuntu-latest + # All git writes happen here, on a clean ubuntu-latest runner that has + # never executed third-party benchmark code. + permissions: + contents: write + pull-requests: write + steps: - - name: Checkout repository - uses: actions/checkout@v4 + - name: Checkout repository # zizmor: ignore[artipacked] persists credentials to push benchmark data + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: true - name: Download benchmark results - uses: actions/download-artifact@v4 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: benchmark-results path: benches/ - - name: Push branch for manual runs - if: github.event_name == 'workflow_dispatch' - run: | - git push origin "${{ needs.run-benchmarks.outputs.branch_name }}" - echo "Pushed branch: ${{ needs.run-benchmarks.outputs.branch_name }}" + - name: Commit benchmark results + id: commit_results + if: needs.run-benchmarks.outputs.is_source_comparison != 'true' + env: + GITHUB_HEAD_REF: ${{ github.head_ref }} + run: ./.github/scripts/commit-benchmark-results.sh benches "${{ github.event_name }}" "${{ github.repository }}" - name: Create PR for manual runs - if: github.event_name == 'workflow_dispatch' - uses: actions/github-script@v7 + if: github.event_name == 'workflow_dispatch' && steps.commit_results.outputs.committed == 'true' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + BRANCH_NAME: ${{ steps.commit_results.outputs.branch_name }} with: script: | - const branchName = '${{ needs.run-benchmarks.outputs.branch_name }}'; - const prComment = `${{ needs.run-benchmarks.outputs.pr_comment }}`; + const branchName = process.env.BRANCH_NAME; - // Create the pull request const { data: pr } = await github.rest.pulls.create({ owner: context.repo.owner, repo: context.repo.repo, @@ -171,8 +258,6 @@ jobs: This PR contains the latest benchmark results from a manual workflow run. - ${prComment} - --- 🤖 This PR was automatically generated by the [Foundry Benchmarks workflow](https://github.com/${{ github.repository }}/actions).` @@ -181,24 +266,94 @@ jobs: console.log(`Created PR #${pr.number}: ${pr.html_url}`); - name: Comment on PR - if: github.event.inputs.pr_number != '' || github.event_name == 'pull_request' - uses: actions/github-script@v7 + if: needs.run-benchmarks.outputs.is_source_comparison == 'true' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + REQUESTED_REPOS: ${{ github.event.inputs.repos || 'per-benchmark defaults' }} + REQUESTED_VERSIONS: ${{ github.event.inputs.versions || env.DEFAULT_VERSIONS }} with: script: | - const prNumber = ${{ github.event.inputs.pr_number || github.event.pull_request.number }}; - const prComment = `${{ needs.run-benchmarks.outputs.pr_comment }}`; - - const comment = `${prComment} - - --- + const { execFileSync } = require('child_process'); + const fs = require('fs'); - 🤖 This comment was automatically generated by the [Foundry Benchmarks workflow](https://github.com/${{ github.repository }}/actions). - - To run benchmarks manually: Go to [Actions](https://github.com/${{ github.repository }}/actions/workflows/benchmarks.yml) → "Run workflow"`; + const branch = context.ref.replace('refs/heads/', ''); + const { data: prs } = await github.rest.pulls.list({ + owner: context.repo.owner, + repo: context.repo.repo, + head: `${context.repo.owner}:${branch}`, + state: 'open', + }); - github.rest.issues.createComment({ - issue_number: prNumber, + if (prs.length === 0) { + console.log(`No open PR found for branch '${branch}', skipping comment.`); + return; + } + + const pr = prs[0]; + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const baseline = fs.readFileSync('benches/baseline-ref.txt', 'utf8').trim(); + const candidate = fs.readFileSync('benches/candidate-ref.txt', 'utf8').trim(); + const behind = Number(fs.readFileSync('benches/behind-count.txt', 'utf8').trim()); + const repoUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}`; + const baselineLabel = '[`master`](' + repoUrl + '/commit/' + baseline + ')'; + const candidateLabel = '[`' + pr.head.ref + '`](' + repoUrl + '/commit/' + candidate + ')'; + + const verdicts = execFileSync( + './.github/scripts/compare-nightly.sh', + ['benches/base-summary.json', 'benches/candidate-summary.json', '2', '2'], + { + env: { + ...process.env, + BASE_LABEL: baselineLabel, + CANDIDATE_LABEL: candidateLabel, + REPORT_TITLE: '', + FAIL_ON_REGRESSION: '0', + }, + }, + ).toString(); + + const formatted = execFileSync( + './.github/scripts/format-pr-comment.sh', + ['benches/LATEST.md'], + ).toString(); + + const warnings = []; + if (behind > 0) { + const noun = behind === 1 ? 'commit' : 'commits'; + warnings.push(`> ⚠️ Candidate is [**${behind} ${noun} behind \`master\`**](${repoUrl}/compare/${candidate}...${baseline}). Consider rebasing for accurate results.`); + } + if (pr.head.sha !== candidate) { + warnings.push(`> ⚠️ This report measured ${candidateLabel}, but the PR has since advanced to \`${pr.head.sha.slice(0, 12)}\`.`); + } + const warningBlock = warnings.length ? `\n${warnings.join('\n')}\n` : ''; + + const marker = ''; + const legend = '> Lower is better · ❌ regression (≥2%) · ✅ improvement (≤−2%) · ⚪ within 2%'; + const body = `${marker}\ncc @${pr.user.login}\n\n✅ Benchmark complete! [View workflow run](${runUrl})\n\n## Benchmark Results\n\n### Configuration\n- Versions: ${baselineLabel} vs ${candidateLabel}\n- Workflow input: \`versions=${process.env.REQUESTED_VERSIONS}; repos=${process.env.REQUESTED_REPOS}\`\n${warningBlock}\n${verdicts}\n${legend}\n\n${formatted}\n\n---\n\n🤖 Generated by the [Foundry Benchmarks workflow](${repoUrl}/actions/workflows/benchmarks.yml).`; + const comments = await github.paginate(github.rest.issues.listComments, { owner: context.repo.owner, repo: context.repo.repo, - body: comment + issue_number: pr.number, + per_page: 100, }); + const existing = comments.find(comment => + comment.user?.type === 'Bot' && comment.body?.includes(marker), + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + console.log(`Updated benchmark results on PR #${pr.number}`); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body, + }); + console.log(`Posted benchmark results to PR #${pr.number}`); + } diff --git a/.github/workflows/bump-forge-std.yml b/.github/workflows/bump-forge-std.yml index 137e8c465e914..ccc227b471687 100644 --- a/.github/workflows/bump-forge-std.yml +++ b/.github/workflows/bump-forge-std.yml @@ -2,21 +2,29 @@ name: bump-forge-std +permissions: {} + on: schedule: - - cron: "0 0 * * *" - workflow_dispatch: + - cron: "0 0 * * *" # Run daily at midnight UTC + workflow_dispatch: # Needed so we can run it manually jobs: update-tag: name: update forge-std tag + if: github.repository == 'foundry-rs/foundry' runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Fetch and update forge-std tag run: curl 'https://api.github.com/repos/foundry-rs/forge-std/tags' | jq '.[0].commit.sha' -jr > testdata/forge-std-rev - name: Create pull request - uses: peter-evans/create-pull-request@v5 + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: commit-message: "chore: bump forge-std version used for tests" title: "chore(tests): bump forge-std version" diff --git a/.github/workflows/bump-solar.yml b/.github/workflows/bump-solar.yml new file mode 100644 index 0000000000000..6caa42d2049c7 --- /dev/null +++ b/.github/workflows/bump-solar.yml @@ -0,0 +1,43 @@ +name: Bump Solar Dependencies + +permissions: {} + +on: + schedule: + # Run daily at 00:00 UTC + - cron: "0 0 * * *" + workflow_dispatch: + +jobs: + bump-solar: + if: github.repository == 'foundry-rs/foundry' + runs-on: ubuntu-latest + permissions: + id-token: write + contents: write + pull-requests: write + steps: + - name: Secure runner + uses: tempoxyz/gh-actions/actions/secure-runner@2ab28a31ae569be9f780c7675b684471060afa40 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Bump solar dependencies + id: bump + env: + GH_TOKEN: ${{ github.token }} + run: ./.github/scripts/bump-solar.sh + + - name: Create or update Pull Request + if: steps.bump.outputs.updated == 'true' + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + add-paths: | + Cargo.toml + Cargo.lock + commit-message: "chore(deps): bump solar dependencies to ${{ steps.bump.outputs.latest_rev_short }}" + title: "chore(deps): bump solar dependencies to ${{ steps.bump.outputs.latest_rev_short }}" + body-path: ${{ steps.bump.outputs.changelog }} + branch: deps/bump-solar diff --git a/.github/workflows/bump-tempo.yml b/.github/workflows/bump-tempo.yml new file mode 100644 index 0000000000000..e83611dd38c4f --- /dev/null +++ b/.github/workflows/bump-tempo.yml @@ -0,0 +1,43 @@ +name: Bump Tempo Dependencies + +permissions: {} + +on: + schedule: + # Run daily at 00:00 UTC + - cron: "0 0 * * *" + workflow_dispatch: + +jobs: + bump-tempo: + if: github.repository == 'foundry-rs/foundry' + runs-on: ubuntu-latest + permissions: + id-token: write + contents: write + pull-requests: write + steps: + - name: Secure runner + uses: tempoxyz/gh-actions/actions/secure-runner@2ab28a31ae569be9f780c7675b684471060afa40 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Bump tempo dependencies + id: bump + env: + GH_TOKEN: ${{ github.token }} + run: ./.github/scripts/bump-tempo.sh + + - name: Create or update Pull Request + if: steps.bump.outputs.updated == 'true' + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + add-paths: | + Cargo.toml + Cargo.lock + commit-message: "chore(deps): bump tempo dependencies to ${{ steps.bump.outputs.latest_rev_short }}" + title: "chore(deps): bump tempo dependencies to ${{ steps.bump.outputs.latest_rev_short }}" + body-path: ${{ steps.bump.outputs.changelog }} + branch: deps/bump-tempo diff --git a/.github/workflows/ci-mpp.yml b/.github/workflows/ci-mpp.yml new file mode 100644 index 0000000000000..ddd4081e39f13 --- /dev/null +++ b/.github/workflows/ci-mpp.yml @@ -0,0 +1,112 @@ +name: CI MPP + +permissions: {} + +on: + push: + branches: [master] + pull_request: + workflow_dispatch: + inputs: + network: + description: "Tempo network to check" + required: true + type: choice + default: "moderato" + options: + - moderato + - mainnet + - all + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + RUSTC_WRAPPER: "sccache" + +jobs: + mpp-check: + # Skip on PRs from forks (only run for branches in foundry-rs/foundry, i.e. maintainer PRs). + if: | + github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository + runs-on: depot-ubuntu-latest + timeout-minutes: 60 + permissions: + id-token: write + contents: read + steps: + - name: Secure runner + uses: tempoxyz/gh-actions/actions/secure-runner@2ab28a31ae569be9f780c7675b684471060afa40 + # Checkout the repository + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # v1 + with: + toolchain: stable + + - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + + # Build and install binaries + - name: Build and install Foundry binaries + run: | + cargo build --profile dev --locked --bin forge --bin cast --bin anvil --bin chisel + echo "${{ github.workspace }}/target/debug" >> "$GITHUB_PATH" + + - name: Restore bounded NanoUSD wallet + if: | + github.event_name == 'workflow_dispatch' && + (inputs.network == 'mainnet' || inputs.network == 'all') + env: + TEMPO_ACCOUNTS_STORE_JSON_B64: ${{ secrets.TEMPO_ACCOUNTS_STORE_JSON_B64 }} + TEMPO_HOME: ${{ runner.temp }}/tempo-mpp-mainnet + run: | + if [ -z "$TEMPO_ACCOUNTS_STORE_JSON_B64" ]; then + echo "TEMPO_ACCOUNTS_STORE_JSON_B64 is not configured" + exit 1 + fi + mkdir -p "$TEMPO_HOME/wallet" + printf '%s' "$TEMPO_ACCOUNTS_STORE_JSON_B64" | base64 --decode > "$TEMPO_HOME/wallet/store.json" + chmod 600 "$TEMPO_HOME/wallet/store.json" + + - name: Run Moderato MPP e2e test + if: | + github.event_name != 'workflow_dispatch' || + inputs.network == 'moderato' || + inputs.network == 'all' + env: + MPP_API_KEY: ${{ secrets.MPP_API_KEY }} + MPP_ALLOW_API_KEY: "1" + TEMPO_HOME_BASE: ${{ runner.temp }}/tempo-mpp-moderato + TEMPO_AUTO_FUND: "1" + TEMPO_ROTATE_WALLET: "1" + MPP_RPC_URL: https://rpc.mpp.moderato.tempo.xyz + TEMPO_RPC_URL: https://rpc.moderato.tempo.xyz + MPP_TOKEN: "0x20c0000000000000000000000000000000000000" + run: | + for attempt in 1 2 3; do + export TEMPO_HOME="${TEMPO_HOME_BASE}-${attempt}" + log="${RUNNER_TEMP}/tempo-mpp-${attempt}.log" + if ./.github/scripts/tempo-mpp.sh "$(which cast | xargs dirname)" 2>&1 | tee "$log"; then + exit 0 + fi + if ! grep -Eq 'HTTP error 402|replaced reusable channel|did not reuse channel' "$log"; then + exit 1 + fi + echo "Transient MPP gateway failure on attempt ${attempt}/3" + done + exit 1 + + - name: Run mainnet MPP e2e test + if: | + github.event_name == 'workflow_dispatch' && + (inputs.network == 'mainnet' || inputs.network == 'all') + env: + TEMPO_HOME: ${{ runner.temp }}/tempo-mpp-mainnet + MPP_RPC_URL: https://rpc.mpp.tempo.xyz + TEMPO_RPC_URL: https://rpc.tempo.xyz + MPP_TOKEN: "0x20c0000000000000000000008b4c619d2eedec7a" + run: ./.github/scripts/tempo-mpp.sh "$(which cast | xargs dirname)" diff --git a/.github/workflows/ci-tempo.yml b/.github/workflows/ci-tempo.yml new file mode 100644 index 0000000000000..f2634c4d58e0b --- /dev/null +++ b/.github/workflows/ci-tempo.yml @@ -0,0 +1,169 @@ +name: CI Tempo + +permissions: {} + +on: + push: + branches: [master] + pull_request: + schedule: + - cron: "0 2 * * *" # Run daily at 2 AM UTC (offset from other nightlies) + workflow_dispatch: + inputs: + network: + description: "Tempo network to check" + required: true + type: choice + options: + - testnet + - devnet + - mainnet + - all + scripts: + description: "Which scripts to run" + required: false + type: choice + default: "both" + options: + - check + - deploy + - both + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + RUSTC_WRAPPER: "sccache" + +jobs: + tempo-check: + # Skip on PRs from forks and Dependabot, which cannot access the RPC secrets. + if: | + (github.event_name != 'schedule' || github.repository == 'foundry-rs/foundry') && + (github.event_name != 'pull_request' || + (github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.user.login != 'dependabot[bot]')) + runs-on: depot-ubuntu-latest-16 + timeout-minutes: 60 + permissions: + id-token: write + contents: read + steps: + - name: Secure runner + uses: tempoxyz/gh-actions/actions/secure-runner@2ab28a31ae569be9f780c7675b684471060afa40 + # Checkout the repository + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # v1 + with: + toolchain: stable + + - name: Install Linux system dependencies + run: sudo apt-get update && sudo apt-get install --yes libudev-dev + + - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + + # Build and install binaries + - name: Build and install Foundry binaries + run: | + cargo build --profile dev --locked --bin forge --bin cast --bin anvil --bin chisel + echo "${{ github.workspace }}/target/debug" >> "$GITHUB_PATH" + + # PRs and pushes already replay the canaries in the nextest matrix (test.yml); the nightly + # run keeps them checked against upstream Tempo changes between PRs. + - name: Replay Tempo transaction canaries + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + env: + TEMPO_MAINNET_RPC_URL: ${{ secrets.TEMPO_MAINNET_RPC_URL }} + TEMPO_TESTNET_RPC_URL: ${{ secrets.TEMPO_TESTNET_RPC_URL }} + run: | + cargo test --locked -p anvil --test it tempo_canary:: -- --test-threads=1 + + - name: Check Tempo fork schedule compatibility + env: + TEMPO_MAINNET_RPC_URL: ${{ secrets.TEMPO_MAINNET_RPC_URL }} + TEMPO_TESTNET_RPC_URL: ${{ secrets.TEMPO_TESTNET_RPC_URL }} + TEMPO_DEVNET_RPC_URL: ${{ vars.TEMPO_DEVNET_CI != 'false' && secrets.TEMPO_DEVNET_RPC_URL || '' }} + run: | + cargo test --locked -p foundry-common --lib tempo::tests::test_fork_schedule_parses_configured_rpcs -- --exact --nocapture + + - name: Run Tempo check on mainnet + if: | + github.event.inputs.network == 'mainnet' || + github.event.inputs.network == 'all' + env: + ETH_RPC_URL: ${{ secrets.TEMPO_MAINNET_RPC_URL }} + TEMPO_FEE_TOKEN: "0x20c0000000000000000000000000000000000000" + VERIFIER_URL: ${{ secrets.VERIFIER_URL }} + PRIVATE_KEY: ${{ secrets.THROW_AWAY_MAINNET_PKEY }} + SCRIPTS: ${{ github.event.inputs.scripts || 'both' }} + run: | + if [ "$SCRIPTS" = "check" ] || [ "$SCRIPTS" = "both" ]; then + ./.github/scripts/tempo-check.sh + fi + if [ "$SCRIPTS" = "deploy" ] || [ "$SCRIPTS" = "both" ]; then + ./.github/scripts/tempo-deploy.sh + fi + + - name: Run Tempo check on testnet + if: | + github.event_name == 'schedule' || + github.event.inputs.network == 'testnet' || + github.event.inputs.network == 'all' + env: + ETH_RPC_URL: ${{ secrets.TEMPO_TESTNET_RPC_URL }} + VERIFIER_URL: ${{ secrets.VERIFIER_URL }} + SCRIPTS: ${{ github.event.inputs.scripts || 'both' }} + run: | + if [ "$SCRIPTS" = "check" ] || [ "$SCRIPTS" = "both" ]; then + ./.github/scripts/tempo-check.sh + fi + if [ "$SCRIPTS" = "deploy" ] || [ "$SCRIPTS" = "both" ]; then + ./.github/scripts/tempo-deploy.sh + fi + + - name: Run Tempo check on devnet + if: vars.TEMPO_DEVNET_CI != 'false' && (github.event_name == 'push' || github.event_name == 'pull_request' || github.event.inputs.network == 'devnet' || github.event.inputs.network == 'all') + env: + ETH_RPC_URL: ${{ secrets.TEMPO_DEVNET_RPC_URL }} + SCRIPTS: ${{ github.event.inputs.scripts || 'both' }} + run: | + if [ "$SCRIPTS" = "check" ] || [ "$SCRIPTS" = "both" ]; then + ./.github/scripts/tempo-check.sh + fi + if [ "$SCRIPTS" = "deploy" ] || [ "$SCRIPTS" = "both" ]; then + ./.github/scripts/tempo-deploy.sh + fi + + - name: Run Tempo wallet tests on devnet + if: vars.TEMPO_DEVNET_CI != 'false' && (github.event_name == 'push' || github.event_name == 'pull_request' || github.event.inputs.network == 'devnet' || github.event.inputs.network == 'all') + env: + ETH_RPC_URL: ${{ secrets.TEMPO_DEVNET_RPC_URL }} + TEMPO_HOME: ${{ runner.temp }}/tempo-wallet + run: ./.github/scripts/tempo-wallet.sh + + # If the nightly run fails, this will create an issue to signal so. + issue: + name: Open an issue + runs-on: ubuntu-latest + needs: [tempo-check] + if: failure() && github.event_name == 'schedule' + permissions: + contents: read + issues: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: JasonEtco/create-an-issue@1b14a70e4d8dc185e5cc76d3bec9eab20257b2c5 # v2.9.2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + WORKFLOW_URL: | + ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + with: + update_existing: true + filename: .github/TEMPO_NIGHTLY_FAILURE_TEMPLATE.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000000..7bf4a6f61b9b0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,304 @@ +name: CI + +permissions: {} + +on: + push: + branches: [master] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: full + RUSTC_WRAPPER: "sccache" + +jobs: + test: + uses: ./.github/workflows/test.yml + permissions: + id-token: write + contents: read + with: + profile: default + secrets: + ETHERSCAN_API_KEY: ${{ secrets.ETHERSCAN_API_KEY }} + ARBITRUM_RPC: ${{ secrets.ARBITRUM_RPC }} + ETH_SEPOLIA_RPC: ${{ secrets.ETH_SEPOLIA_RPC }} + TEMPO_MAINNET_RPC_URL: ${{ secrets.TEMPO_MAINNET_RPC_URL }} + TEMPO_TESTNET_RPC_URL: ${{ secrets.TEMPO_TESTNET_RPC_URL }} + + docs: + uses: ./.github/workflows/docs.yml + permissions: + contents: read + pages: write + id-token: write + + doctest: + runs-on: depot-ubuntu-24.04-4 + timeout-minutes: 30 + permissions: + id-token: write + contents: read + steps: + - name: Secure runner + uses: tempoxyz/gh-actions/actions/secure-runner@2ab28a31ae569be9f780c7675b684471060afa40 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # v1 + with: + toolchain: stable + - uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # v1 + - name: Install Linux system dependencies + run: sudo apt-get update && sudo apt-get install --yes libudev-dev + - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + cache-bin: false + prefix-key: v2-rust-doctest + - run: cargo test --workspace --doc --locked + + no-default-features: + runs-on: depot-ubuntu-latest + timeout-minutes: 30 + permissions: + id-token: write + contents: read + steps: + - name: Secure runner + uses: tempoxyz/gh-actions/actions/secure-runner@2ab28a31ae569be9f780c7675b684471060afa40 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # v1 + with: + toolchain: stable + - uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # v1 + - name: Install Linux system dependencies + run: sudo apt-get update && sudo apt-get install --yes libudev-dev + - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + cache-bin: false + prefix-key: v1-rust-no-default-features + - run: cargo build --workspace --no-default-features --locked + + touch-id-link: + name: touch-id link (macOS) + if: github.event_name == 'push' + runs-on: depot-macos-latest + timeout-minutes: 45 + permissions: + id-token: write + contents: read + env: + MACOSX_DEPLOYMENT_TARGET: "11.0" + steps: + - name: Secure runner + uses: tempoxyz/gh-actions/actions/secure-runner@2ab28a31ae569be9f780c7675b684471060afa40 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # v1 + with: + toolchain: stable + targets: aarch64-apple-darwin,x86_64-apple-darwin + - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + cache-bin: false + prefix-key: v1-rust-touch-id-macos + - name: Run native Touch ID regressions + run: | + cargo test --manifest-path crates/cast/Cargo.toml --lib --features touch-id --target aarch64-apple-darwin --locked cmd::wallet::tests + cargo test --manifest-path crates/cast/Cargo.toml --test cli --features touch-id --target aarch64-apple-darwin --locked wallet_change_password + - name: Check Intel Touch ID linking + run: | + cargo test --manifest-path crates/cast/Cargo.toml --test cli --features touch-id --target x86_64-apple-darwin --locked --no-run + cargo build --manifest-path crates/forge/Cargo.toml --features touch-id --target x86_64-apple-darwin --locked + - run: cargo build --manifest-path crates/forge/Cargo.toml --features touch-id --target aarch64-apple-darwin --locked + + typos: + runs-on: depot-ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: crate-ci/typos@00f422f3b19c57bc6338715ebfe3316d38768461 # v1.50.3 + + shellcheck: + runs-on: depot-ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Shellcheck + shell: bash + run: ./.github/scripts/shellcheck.sh + - name: foundryup tests + shell: bash + run: ./foundryup/test.sh + + github-scripts: + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Test GitHub scripts + run: | + python3 -m unittest discover -s .github/scripts/tests -p 'test_*.py' + + clippy: + runs-on: depot-ubuntu-latest + timeout-minutes: 30 + permissions: + id-token: write + contents: read + steps: + - name: Secure runner + uses: tempoxyz/gh-actions/actions/secure-runner@2ab28a31ae569be9f780c7675b684471060afa40 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # v1 + with: + toolchain: nightly + components: clippy + - uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # v1 + - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - run: cargo clippy --workspace --all-targets --all-features --locked + env: + RUSTFLAGS: -Dwarnings + + rustfmt: + runs-on: depot-ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # v1 + with: + toolchain: nightly + components: rustfmt + - run: cargo fmt --all --check + + forge-fmt: + runs-on: depot-ubuntu-latest + timeout-minutes: 30 + permissions: + id-token: write + contents: read + steps: + - name: Secure runner + uses: tempoxyz/gh-actions/actions/secure-runner@2ab28a31ae569be9f780c7675b684471060afa40 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # v1 + with: + toolchain: stable + - uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # v1 + - name: Install Linux system dependencies + run: sudo apt-get update && sudo apt-get install --yes libudev-dev + - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + cache-bin: false + prefix-key: v2-rust-forge-fmt + - name: forge fmt + shell: bash + run: ./.github/scripts/format.sh --check + + deny: + uses: tempoxyz/gh-actions/.github/workflows/rust-deny.yml@2ab28a31ae569be9f780c7675b684471060afa40 + permissions: + contents: read + id-token: write + with: + rust-toolchain: nightly + flags: --all-features --locked + + codeql: + name: analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + permissions: + security-events: write + actions: read + contents: read + strategy: + fail-fast: false + matrix: + include: + - language: actions + build-mode: none + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Initialize CodeQL + uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + with: + category: "/language:${{matrix.language}}" + + workflow-validation: + name: Workflow Validation + uses: tempoxyz/gh-actions/.github/workflows/scan-github-actions.yml@2ab28a31ae569be9f780c7675b684471060afa40 + permissions: + actions: read + contents: read + id-token: write + with: + pinact: true + ensure-secure-runner: false + + ci-success: + runs-on: ubuntu-latest + if: always() + permissions: {} + needs: + - test + - docs + - doctest + - no-default-features + - touch-id-link + - typos + - shellcheck + - github-scripts + - clippy + - rustfmt + - forge-fmt + - deny + - codeql + - workflow-validation + timeout-minutes: 30 + steps: + - name: Decide whether the needed jobs succeeded or failed + uses: re-actors/alls-green@b5b5b37504aa4183270bd3d855c52a67f212be35 # v1.3.0 + with: + allowed-skips: touch-id-link + jobs: ${{ toJSON(needs) }} diff --git a/.github/workflows/crate-checks.yml b/.github/workflows/crate-checks.yml new file mode 100644 index 0000000000000..4142aec0426d5 --- /dev/null +++ b/.github/workflows/crate-checks.yml @@ -0,0 +1,93 @@ +# Nightly job to verify each crate compiles independently (catches missing deps/features +# masked by Cargo's workspace feature unification). + +name: crate-checks + +permissions: {} + +on: + schedule: + - cron: "0 2 * * *" # Run daily at 2 AM UTC (offset from test-flaky) + workflow_dispatch: # Needed so we can run it manually + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: full + RUSTC_WRAPPER: "sccache" + +jobs: + crate-checks: + if: github.event_name != 'schedule' || github.repository == 'foundry-rs/foundry' + runs-on: depot-ubuntu-latest + timeout-minutes: 30 + permissions: + id-token: write + contents: read + steps: + - name: Secure runner + uses: tempoxyz/gh-actions/actions/secure-runner@2ab28a31ae569be9f780c7675b684471060afa40 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # v1 + with: + toolchain: stable + - uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # v1 + - uses: taiki-e/install-action@4cef1412cce204788f482e778a0b9187f9626a29 # v2.87.21 + with: + tool: cargo-hack + - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - run: cargo hack check --locked + + # Regular CI always tests with `--features base,monad`, so unit tests gated on those features + # being off only run here, with the default features a plain source build gets. + default-features-tests: + if: github.event_name != 'schedule' || github.repository == 'foundry-rs/foundry' + runs-on: depot-ubuntu-latest + timeout-minutes: 45 + permissions: + id-token: write + contents: read + steps: + - name: Secure runner + uses: tempoxyz/gh-actions/actions/secure-runner@2ab28a31ae569be9f780c7675b684471060afa40 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # v1 + with: + toolchain: stable + - uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # v1 + - name: Install Linux system dependencies + run: sudo apt-get update && sudo apt-get install --yes libudev-dev + - uses: taiki-e/install-action@4cef1412cce204788f482e778a0b9187f9626a29 # v2.87.21 + with: + tool: nextest + - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - run: >- + cargo nextest run --locked --lib + -p anvil -p cast -p chisel -p foundry-config + -p foundry-evm-core -p foundry-evm-hardforks -p foundry-evm-networks + + issue: + name: Open an issue + runs-on: ubuntu-latest + needs: [crate-checks, default-features-tests] + if: failure() + permissions: + contents: read + issues: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: JasonEtco/create-an-issue@1b14a70e4d8dc185e5cc76d3bec9eab20257b2c5 # v2.9.2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + WORKFLOW_URL: | + ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + with: + update_existing: true + filename: .github/CRATE_CHECKS_FAILURE_TEMPLATE.md diff --git a/.github/workflows/dependencies.yml b/.github/workflows/dependencies.yml deleted file mode 100644 index e452a68c80967..0000000000000 --- a/.github/workflows/dependencies.yml +++ /dev/null @@ -1,20 +0,0 @@ -# Runs `cargo update` periodically. - -name: dependencies - -on: - schedule: - # Run weekly - - cron: "0 0 * * SUN" - workflow_dispatch: -# Needed so we can run it manually - -permissions: - contents: write - pull-requests: write - -jobs: - update: - uses: ithacaxyz/ci/.github/workflows/cargo-update-pr.yml@main - secrets: - token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 25a96b82b06fe..44bd928a5583f 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -1,44 +1,92 @@ name: docker +permissions: {} + on: - # Trigger without any parameters a proactive rebuild - workflow_dispatch: {} + workflow_dispatch: + inputs: + tag_name: + default: nightly-dev + description: The tag we're building for + type: string workflow_call: inputs: tag_name: required: true type: string + linux-validated: + description: Caller has completed protected GNU/Linux builds of this same commit + default: false + type: boolean + outputs: + digest: + description: Immutable multi-platform image digest + value: ${{ jobs.build.outputs.digest }} + +concurrency: + group: docker-${{ github.repository }}-${{ inputs.tag_name }} + cancel-in-progress: false env: REGISTRY: ghcr.io - # Will resolve to foundry-rs/foundry IMAGE_NAME: ${{ github.repository }} + # Keep the base features in sync with `release.yml`. + RUST_PROFILE: dist + RUST_FEATURES: aws-kms,gcp-kms,turnkey,cli,asm-keccak,js-tracer,base,monad,optimism + jobs: + # Manual runs cannot opt out; reusable callers must gate on matching Linux builds. + validate-linux: + if: ${{ !inputs.linux-validated }} + runs-on: depot-ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + id-token: write + steps: + - name: Secure runner + uses: tempoxyz/gh-actions/actions/secure-runner@2ab28a31ae569be9f780c7675b684471060afa40 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # v1 + with: + toolchain: stable + - name: Validate locked Docker dependencies + shell: bash + run: | + # Use an empty cache so registry packages are fetched through Aegis. + CARGO_HOME="$(mktemp -d "$RUNNER_TEMP/docker-cargo.XXXXXX")" + export CARGO_HOME + cargo fetch --locked --target x86_64-unknown-linux-gnu --target aarch64-unknown-linux-gnu + build: - name: build and push - runs-on: Linux-22.04 + needs: validate-linux + if: ${{ !cancelled() && (needs.validate-linux.result == 'success' || (inputs.linux-validated && needs.validate-linux.result == 'skipped')) }} + name: build, sign, and stage + runs-on: depot-ubuntu-latest permissions: + attestations: write + artifact-metadata: write + actions: read + contents: read id-token: write packages: write - contents: read - timeout-minutes: 120 + timeout-minutes: 60 + outputs: + digest: ${{ steps.select.outputs.digest }} steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - name: Secure runner + uses: tempoxyz/gh-actions/actions/secure-runner@2ab28a31ae569be9f780c7675b684471060afa40 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - cache-on-failure: true - - name: Install cross - id: cross_main - run: | - cargo install cross + persist-credentials: false + # Login against a Docker registry except on PR # https://github.com/docker/login-action - name: Login into registry ${{ env.REGISTRY }} - # Ensure this doesn't trigger on PR's - if: github.event_name != 'pull_request' - uses: docker/login-action@v2 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -48,33 +96,152 @@ jobs: # https://github.com/docker/metadata-action - name: Extract Docker metadata id: meta - uses: docker/metadata-action@v4 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - # Creates an additional 'latest' or 'nightly' tag - # If the job is triggered via cron schedule, tag nightly and nightly-{SHA} - # If the job is triggered via workflow dispatch and on a master branch, tag branch and latest - # Otherwise, just tag as the branch name - - name: Finalize Docker Metadata - id: docker_tagging + - name: Check for an already staged exact image + id: existing + env: + IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + TAG: ${{ inputs.tag_name }} + shell: bash run: | - if [[ "${{ github.event_name }}" == 'schedule' ]]; then - echo "cron trigger, assigning nightly tag" - echo "docker_tags=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:nightly,${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:nightly-${GITHUB_SHA}" >> $GITHUB_OUTPUT - elif [[ "${GITHUB_REF##*/}" == "main" ]] || [[ ${GITHUB_REF##*/} == "master" ]]; then - echo "manual trigger from master/main branch, assigning latest tag" - echo "docker_tags=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${GITHUB_REF##*/},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" >> $GITHUB_OUTPUT + set -euo pipefail + inspect_error="$(mktemp)" + if digest="$(docker buildx imagetools inspect "$IMAGE:$TAG" --format '{{json .Manifest.Digest}}' 2>"$inspect_error" | tr -d '"')"; then + if [[ ! "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "::error::Exact tag $TAG has invalid digest $digest." + exit 1 + fi + { + printf 'exists=true\n' + printf 'digest=%s\n' "$digest" + } >> "$GITHUB_OUTPUT" + elif grep -Eiq 'manifest unknown|not found' "$inspect_error"; then + printf 'exists=false\n' >> "$GITHUB_OUTPUT" else - echo "Neither scheduled nor manual release from main branch. Just tagging as branch name" - echo "docker_tags=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${GITHUB_REF##*/}" >> $GITHUB_OUTPUT + cat "$inspect_error" >&2 + echo "::error::Could not determine whether exact tag $TAG exists." + exit 1 + fi + + - name: Set up Depot CLI + if: steps.existing.outputs.exists != 'true' + uses: depot/setup-action@91bc8495a33ebfc504ffc89e5674379ccf23c29c # v1.7.2 + + - name: Build and push Foundry image + id: build + if: steps.existing.outputs.exists != 'true' + uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0 + with: + build-args: | + RUST_PROFILE=${{ env.RUST_PROFILE }} + RUST_FEATURES=${{ env.RUST_FEATURES }} + TAG_NAME=${{ inputs.tag_name }} + VERGEN_GIT_SHA=${{ github.sha }} + project: 8gkbxxjrpw + context: . + # Depot implicitly tags an untagged multi-platform output as `latest` when merging. + outputs: type=image,name=ghcr.io/${{ github.repository }}:build-staging,push-by-digest=true,name-canonical=true,push=true + labels: ${{ steps.meta.outputs.labels }} + platforms: linux/amd64,linux/arm64 + no-cache: true + sbom: true + provenance: mode=max + + - name: Select and inspect image digest + id: select + env: + BUILT_DIGEST: ${{ steps.build.outputs.digest }} + EXISTING_DIGEST: ${{ steps.existing.outputs.digest }} + IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + shell: bash + run: | + set -euo pipefail + DIGEST="${EXISTING_DIGEST:-$BUILT_DIGEST}" + if [[ ! "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "::error::Invalid image digest: $DIGEST" + exit 1 + fi + docker buildx imagetools inspect "$IMAGE@$DIGEST" + printf 'digest=%s\n' "$DIGEST" >> "$GITHUB_OUTPUT" + + - name: Install cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + + - name: Verify already staged image + if: steps.existing.outputs.exists == 'true' + env: + GH_TOKEN: ${{ github.token }} + DIGEST: ${{ steps.select.outputs.digest }} + IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + EXPECTED_REVISION: ${{ github.sha }} + REPOSITORY: ${{ github.repository }} + SOURCE_REF: ${{ github.ref }} + shell: bash + run: | + set -euo pipefail + image_data="$(docker buildx imagetools inspect "$IMAGE@$DIGEST" --format '{{json .Image}}')" + revision="$(jq -er ' + [to_entries[].value.config.Labels["org.opencontainers.image.revision"]] | + unique | if length == 1 then .[0] else empty end + ' <<< "$image_data")" + if [[ "$revision" != "$EXPECTED_REVISION" ]]; then + echo "::error::Staged image revision $revision does not match $EXPECTED_REVISION." + exit 1 fi + docker_identity="https://github.com/${REPOSITORY}/.github/workflows/docker-publish.yml@${SOURCE_REF}" + cosign verify \ + --certificate-identity "$docker_identity" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + "$IMAGE@$DIGEST" + gh attestation verify "oci://$IMAGE@$DIGEST" \ + --repo "$REPOSITORY" \ + --signer-workflow "$REPOSITORY/.github/workflows/docker-publish.yml" \ + --source-ref "$SOURCE_REF" \ + --source-digest "$EXPECTED_REVISION" - # Log docker metadata to explicitly know what is being pushed - - name: Inspect Docker Metadata + - name: Sign image with cosign (keyless) + if: steps.existing.outputs.exists != 'true' + env: + DIGEST: ${{ steps.select.outputs.digest }} + IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + shell: bash run: | - echo "TAGS -> ${{ steps.docker_tagging.outputs.docker_tags }}" - echo "LABELS -> ${{ steps.meta.outputs.labels }}" + set -euo pipefail + cosign sign --yes "$IMAGE@$DIGEST" + + - name: Image build provenance attestation + if: steps.existing.outputs.exists != 'true' + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + subject-digest: ${{ steps.select.outputs.digest }} + push-to-registry: true - - name: Build and push foundry image - run: make DOCKER_IMAGE_NAME=${{ steps.docker_tagging.outputs.docker_tags }} CARGO_TAG_NAME=${{ inputs.tag_name }} PROFILE=maxperf docker-build-push + - name: Stage exact Docker tag + if: steps.existing.outputs.exists != 'true' + env: + DIGEST: ${{ steps.select.outputs.digest }} + IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + TAG: ${{ inputs.tag_name }} + shell: bash + run: | + set -euo pipefail + inspect_error="$(mktemp)" + if existing="$(docker buildx imagetools inspect "$IMAGE:$TAG" --format '{{json .Manifest.Digest}}' 2>"$inspect_error" | tr -d '"')"; then + if [[ "$existing" != "$DIGEST" ]]; then + echo "::error::Exact tag $TAG already resolves to conflicting digest $existing." + exit 1 + fi + echo "Exact tag $TAG already staged at $DIGEST." + elif grep -Eiq 'manifest unknown|not found' "$inspect_error"; then + docker buildx imagetools create --tag "$IMAGE:$TAG" "$IMAGE@$DIGEST" + else + cat "$inspect_error" >&2 + echo "::error::Could not determine whether exact tag $TAG exists." + exit 1 + fi + actual="$(docker buildx imagetools inspect "$IMAGE:$TAG" --format '{{json .Manifest.Digest}}' | tr -d '"')" + [[ "$actual" == "$DIGEST" ]] || { echo "::error::Exact tag verification failed: $actual"; exit 1; } diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000000000..bc598af443073 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,63 @@ +name: docs + +permissions: {} + +on: + workflow_call: + +concurrency: + group: docs-${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: full + RUSTC_WRAPPER: "sccache" + +jobs: + docs: + runs-on: depot-ubuntu-latest + timeout-minutes: 30 + permissions: + id-token: write + contents: read + steps: + - name: Secure runner + uses: tempoxyz/gh-actions/actions/secure-runner@2ab28a31ae569be9f780c7675b684471060afa40 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # v1 + with: + toolchain: nightly + - uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # v1 + - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - name: Build documentation + run: cargo doc --workspace --all-features --no-deps --document-private-items --locked + env: + RUSTDOCFLAGS: --cfg docsrs -D warnings --show-type-layout --generate-link-to-definition --enable-index-page -Zunstable-options + - name: Setup Pages + if: github.ref_name == 'master' && github.event_name == 'push' + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 + - name: Upload artifact + if: github.ref_name == 'master' && github.event_name == 'push' + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: ./target/doc + + deploy-docs: + if: github.ref_name == 'master' && github.event_name == 'push' + needs: [docs] + runs-on: depot-ubuntu-latest + timeout-minutes: 30 + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/.github/workflows/editors-publish.yml b/.github/workflows/editors-publish.yml new file mode 100644 index 0000000000000..26ccf3cc6545e --- /dev/null +++ b/.github/workflows/editors-publish.yml @@ -0,0 +1,60 @@ +name: Publish VS Code extension + +permissions: {} + +on: + workflow_dispatch: + inputs: + pre-release: + description: Publish as a pre-release version + type: boolean + default: false + dry-run: + description: Check Marketplace publish rights without publishing + type: boolean + default: false + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + vscode: + runs-on: depot-ubuntu-latest + timeout-minutes: 15 + environment: release + permissions: + id-token: write # Sign in to Azure and Open VSX. + contents: read + defaults: + run: + working-directory: editors/vscode + env: + PRE_RELEASE: ${{ inputs.pre-release && '--pre-release' || '' }} + steps: + - name: Secure runner + uses: tempoxyz/gh-actions/actions/secure-runner@2ab28a31ae569be9f780c7675b684471060afa40 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: npm + cache-dependency-path: editors/vscode/package-lock.json + - run: npm ci + - run: npm run check && npm test && npm run package -- ${PRE_RELEASE:+"$PRE_RELEASE"} + - uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0 + with: + client-id: ${{ vars.AZURE_CLIENT_ID }} + tenant-id: ${{ vars.AZURE_TENANT_ID }} + allow-no-subscriptions: true + - name: Check Marketplace publish rights + if: inputs.dry-run + run: npx vsce verify-pat --azure-credential + - name: Publish to the Visual Studio Marketplace + if: ${{ !inputs.dry-run }} + run: npx vsce publish --packagePath bundle/forge.vsix --azure-credential ${PRE_RELEASE:+"$PRE_RELEASE"} + - name: Publish to Open VSX + if: ${{ !inputs.dry-run && vars.OPEN_VSX_PUBLISH == 'true' }} + run: npx ovsx publish bundle/forge.vsix --trusted-publishing diff --git a/.github/workflows/editors.yml b/.github/workflows/editors.yml new file mode 100644 index 0000000000000..e64ac5985f19e --- /dev/null +++ b/.github/workflows/editors.yml @@ -0,0 +1,118 @@ +name: Editors + +permissions: {} + +on: + pull_request: + paths: + - "editors/**" + - ".vscode/**" + - ".github/workflows/editors.yml" + - "Cargo.toml" + - "Cargo.lock" + - "rustfmt.toml" + - "crates/forge/Cargo.toml" + - "crates/forge/src/cmd/lsp.rs" + - "crates/forge/src/cmd/lsp/**" + - "crates/forge/src/opts.rs" + - "crates/forge/tests/cli/lsp.rs" + - "crates/forge/tests/support/lsp_client.rs" + push: + branches: [master] + paths: + - "editors/**" + - ".github/workflows/editors.yml" + - "Cargo.toml" + - "Cargo.lock" + - "rustfmt.toml" + - "crates/forge/Cargo.toml" + - "crates/forge/src/cmd/lsp.rs" + - "crates/forge/src/cmd/lsp/**" + - "crates/forge/src/opts.rs" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + vscode: + runs-on: depot-ubuntu-latest + timeout-minutes: 30 + permissions: + id-token: write + contents: read + steps: + - name: Secure runner + uses: tempoxyz/gh-actions/actions/secure-runner@2ab28a31ae569be9f780c7675b684471060afa40 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: npm + cache-dependency-path: editors/vscode/package-lock.json + - uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # v1 + with: + toolchain: stable + - name: Install Forge and VS Code native dependencies + run: | + sudo apt-get update + sudo apt-get install --yes libudev-dev xvfb libgtk-3-0 libgbm1 libnss3 libasound2t64 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + cache-bin: false + prefix-key: v2-editor-forge + - run: npm ci + working-directory: editors/vscode + - run: npm run check && npm test && npm run bundle:check + working-directory: editors/vscode + - name: Package licenses without symlinks + working-directory: editors/vscode + run: | + rm LICENSE-MIT LICENSE-APACHE + printf '%s' '../../LICENSE-MIT' > LICENSE-MIT + printf '%s' '../../LICENSE-APACHE' > LICENSE-APACHE + npm run package + unzip -p bundle/forge.vsix extension/LICENSE-MIT | cmp - ../../LICENSE-MIT + unzip -p bundle/forge.vsix extension/LICENSE-APACHE | cmp - ../../LICENSE-APACHE + - run: cargo build --locked -p forge --bin forge + - run: cargo test --locked -p forge --test cli 'lsp::' + - name: Real Forge in VS Code Extension Development Host + run: | + xvfb-run -a npm run test:host + xvfb-run -a npm run test:launcher + working-directory: editors/vscode + env: + FORGE_PATH: ${{ github.workspace }}/target/debug/forge + + zed: + runs-on: depot-ubuntu-latest + timeout-minutes: 15 + permissions: + id-token: write + contents: read + steps: + - name: Secure runner + uses: tempoxyz/gh-actions/actions/secure-runner@2ab28a31ae569be9f780c7675b684471060afa40 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # v1 + with: + toolchain: nightly + components: rustfmt + - uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # v1 + with: + toolchain: stable + components: clippy + targets: wasm32-wasip2 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + workspaces: editors/zed + cache-bin: false + - run: cargo +nightly fmt --manifest-path editors/zed/Cargo.toml -- --check + - run: cargo clippy --locked --manifest-path editors/zed/Cargo.toml --all-targets -- -D warnings + - run: cargo test --locked --manifest-path editors/zed/Cargo.toml + - run: cargo build --locked --manifest-path editors/zed/Cargo.toml --target wasm32-wasip2 --release diff --git a/.github/workflows/finalize-release.yml b/.github/workflows/finalize-release.yml new file mode 100644 index 0000000000000..cc1c5d7cd377b --- /dev/null +++ b/.github/workflows/finalize-release.yml @@ -0,0 +1,55 @@ +name: finalize release + +permissions: {} + +on: + workflow_dispatch: + inputs: + tag_name: + description: Strict stable or RC tag to finalize + required: true + type: string + +concurrency: + group: release-stable-promotion + cancel-in-progress: false + queue: max + +jobs: + validate-ref: + name: Validate trusted ref + runs-on: ubuntu-24.04 + steps: + - name: Require master + env: + REF: ${{ github.ref }} + run: | + if [[ "$REF" != "refs/heads/master" ]]; then + echo "::error::Release finalization must run from refs/heads/master, not $REF." + exit 1 + fi + + finalize: + needs: validate-ref + runs-on: ubuntu-24.04 + environment: release + permissions: + actions: read + contents: write + packages: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + persist-credentials: false + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Finalize release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG_NAME: ${{ inputs.tag_name }} + run: python3 .github/scripts/finalize-release.py stable --tag "$TAG_NAME" diff --git a/.github/workflows/nextest.yml b/.github/workflows/nextest.yml deleted file mode 100644 index ac8ed5898dd15..0000000000000 --- a/.github/workflows/nextest.yml +++ /dev/null @@ -1,98 +0,0 @@ -# Reusable workflow for running tests via `cargo nextest` - -name: nextest - -on: - workflow_call: - inputs: - profile: - required: true - type: string - -concurrency: - group: tests-${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -env: - CARGO_TERM_COLOR: always - RUST_BACKTRACE: full - -jobs: - matrices: - name: build matrices - runs-on: ubuntu-latest - outputs: - test-matrix: ${{ steps.gen.outputs.test-matrix }} - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: "3.11" - - name: Generate matrices - id: gen - env: - EVENT_NAME: ${{ github.event_name }} - PROFILE: ${{ inputs.profile }} - run: | - output=$(python3 .github/scripts/matrices.py) - echo "::debug::test-matrix=$output" - echo "test-matrix=$output" >> $GITHUB_OUTPUT - - test: - name: test ${{ matrix.name }} - runs-on: ${{ matrix.runner_label }} - timeout-minutes: 60 - needs: matrices - strategy: - fail-fast: false - matrix: ${{ fromJson(needs.matrices.outputs.test-matrix) }} - env: - ETH_RPC_URL: https://reth-ethereum.ithaca.xyz/rpc - CARGO_PROFILE_DEV_DEBUG: 0 - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - target: ${{ matrix.target }} - - uses: taiki-e/install-action@nextest - - # External tests dependencies - - name: Setup Node.js - if: contains(matrix.name, 'external') - uses: actions/setup-node@v4 - with: - node-version: 20 - - name: Install Bun - if: contains(matrix.name, 'external') && !contains(matrix.runner_label, 'windows') - uses: oven-sh/setup-bun@v1 - with: - bun-version: latest - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: 3.11 - - name: Install Vyper - # Also update vyper version in .devcontainer/Dockerfile.dev - run: pip --version && pip install vyper==0.4.3 - - - name: Forge RPC cache - uses: actions/cache@v4 - with: - path: | - ~/.foundry/cache - ~/.config/.foundry/cache - key: rpc-cache-${{ hashFiles('crates/forge/tests/rpc-cache-keyfile') }} - - uses: Swatinem/rust-cache@v2 - with: - cache-on-failure: true - - name: Setup Git config - run: | - git config --global user.name "GitHub Actions Bot" - git config --global user.email "<>" - git config --global url."https://github.com/".insteadOf "git@github.com:" - - name: Test - env: - SVM_TARGET_PLATFORM: ${{ matrix.svm_target_platform }} - HTTP_ARCHIVE_URLS: ${{ secrets.HTTP_ARCHIVE_URLS }} - WS_ARCHIVE_URLS: ${{ secrets.WS_ARCHIVE_URLS }} - run: cargo nextest run ${{ matrix.flags }} diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml deleted file mode 100644 index 168f378f9673b..0000000000000 --- a/.github/workflows/nix.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: nix - -on: - schedule: - # Run weekly - - cron: "0 0 * * SUN" - workflow_dispatch: -# Needed so we can run it manually - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - # Opens a PR with an updated flake.lock file - update: - runs-on: ubuntu-latest - steps: - - uses: DeterminateSystems/determinate-nix-action@v3 - - uses: actions/checkout@v4 - - uses: DeterminateSystems/update-flake-lock@main - with: - pr-title: "Update flake.lock" - pr-labels: | - L-ignore - A-dependencies - - build: - strategy: - matrix: - runs-on: [ubuntu-latest, macos-latest] - runs-on: ${{ matrix.runs-on }} - steps: - - uses: DeterminateSystems/determinate-nix-action@v3 - - uses: actions/checkout@v4 - - - name: Update flake.lock - run: nix flake update - - - name: Activate nix env - run: nix develop -c echo Ok - - - name: Check that we can compile all crates - run: nix develop -c cargo check --all-targets diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5aa6e98a1c389..8ffb0cba240ea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,87 +1,327 @@ name: release +permissions: {} + on: push: tags: - - "stable" - - "rc" - - "rc-*" - "v*.*.*" schedule: - cron: "0 6 * * *" workflow_dispatch: +concurrency: + group: release-${{ github.repository }}-${{ github.ref }}-${{ github.sha }} + cancel-in-progress: false + env: CARGO_TERM_COLOR: always IS_NIGHTLY: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }} - PROFILE: maxperf - STABLE_VERSION: "v1.2.3" + + # Keep the base features in sync with `docker-publish.yml`. + # Platform-specific release features are added in the build step. + RUST_PROFILE: dist + RUST_FEATURES: aws-kms,gcp-kms,turnkey,cli,asm-keccak,js-tracer,base,monad,optimism jobs: prepare: name: Prepare release - runs-on: ubuntu-latest + if: github.event_name != 'schedule' || github.repository == 'foundry-rs/foundry' + runs-on: ubuntu-24.04 timeout-minutes: 30 + permissions: + contents: write + pull-requests: read outputs: tag_name: ${{ steps.release_info.outputs.tag_name }} + release_tag_name: ${{ steps.release_info.outputs.release_tag_name }} release_name: ${{ steps.release_info.outputs.release_name }} changelog: ${{ steps.build_changelog.outputs.changelog }} + skip_assets: ${{ steps.existing_release.outputs.skip_assets || 'false' }} steps: - - uses: actions/checkout@v4 + - name: Validate manual release ref + if: ${{ github.event_name == 'workflow_dispatch' && github.ref != 'refs/heads/master' }} + env: + REF: ${{ github.ref }} + run: | + echo "::error::Manual nightly releases must run from refs/heads/master, not $REF." + exit 1 + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false fetch-depth: 0 - name: Compute release name and tag id: release_info + shell: bash run: | + set -euo pipefail + stable_pattern='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' + rc_pattern='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-rc([1-9][0-9]*)$' + + previous_tag() { + local current="$1" + local pattern="$2" + { + while read -r tag; do + if [[ "$tag" =~ $pattern ]]; then + printf '%s\n' "$tag" + fi + done < <(git tag -l) + printf '%s\n' "$current" + } | + sort -V -u | + awk -v current="$current" ' + $0 == current { found=1 } + !found { previous=$0 } + END { print previous } + ' + } + if [[ ${IS_NIGHTLY} == 'true' ]]; then - echo "tag_name=nightly-${GITHUB_SHA}" >> $GITHUB_OUTPUT - echo "release_name=Nightly ($(date '+%Y-%m-%d'))" >> $GITHUB_OUTPUT + tag_name="nightly-${GITHUB_SHA}" + release_tag_name="staging-release-${GITHUB_SHA}" + release_name="Nightly ($(date '+%Y-%m-%d'))" + is_prerelease=true + # Find the previous nightly tag for changelog generation, + # sorted by tag creation date (most recent first). + from_tag=$(git for-each-ref --sort=-creatordate --count=1 \ + --format='%(refname:strip=2)' 'refs/tags/nightly-*') else - echo "tag_name=${GITHUB_REF_NAME}" >> $GITHUB_OUTPUT - echo "release_name=${GITHUB_REF_NAME}" >> $GITHUB_OUTPUT + tag_name="$GITHUB_REF_NAME" + release_tag_name="$tag_name" + release_name="$GITHUB_REF_NAME" + + if [[ "$GITHUB_REF_NAME" =~ $stable_pattern ]]; then + PREV_STABLE=$(previous_tag "$GITHUB_REF_NAME" "$stable_pattern") + if [[ -z "$PREV_STABLE" ]]; then + echo "::error::No preceding strict stable tag found for $GITHUB_REF_NAME." + exit 1 + fi + is_prerelease=false + from_tag="$PREV_STABLE" + elif [[ "$GITHUB_REF_NAME" =~ $rc_pattern ]]; then + core="v${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[3]}" + core_rc_pattern="^v${BASH_REMATCH[1]}\.${BASH_REMATCH[2]}\.${BASH_REMATCH[3]}-rc([1-9][0-9]*)$" + rc_number=${BASH_REMATCH[4]} + is_prerelease=true + + # Prefer the preceding strict RC for this version. For rc1, fall + # back to the preceding strict stable release. + PREV_RC=$(previous_tag "$GITHUB_REF_NAME" "$core_rc_pattern") + if [[ -n "$PREV_RC" ]]; then + from_tag="$PREV_RC" + elif [[ "$rc_number" == 1 ]]; then + from_tag=$(previous_tag "$core" "$stable_pattern") + else + echo "::error::No preceding strict RC tag found for $GITHUB_REF_NAME." + exit 1 + fi + if [[ -z "$from_tag" ]]; then + echo "::error::No preceding strict stable tag found for $GITHUB_REF_NAME." + exit 1 + fi + else + echo "::error::Release tag must be vX.Y.Z or vX.Y.Z-rcN without leading zeroes." + exit 1 + fi fi + { + printf 'tag_name=%s\n' "$tag_name" + printf 'release_tag_name=%s\n' "$release_tag_name" + printf 'release_name=%s\n' "$release_name" + printf 'is_prerelease=%s\n' "$is_prerelease" + printf 'from_tag=%s\n' "$from_tag" + } >> "$GITHUB_OUTPUT" - # Creates a `nightly-SHA` tag for this specific nightly - # This tag is used for this specific nightly version's release - # which allows users to roll back. It is also used to build - # the changelog. - - name: Create build-specific nightly tag + - name: Check existing nightly release + id: existing_release if: ${{ env.IS_NIGHTLY == 'true' }} - uses: actions/github-script@v7 env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} TAG_NAME: ${{ steps.release_info.outputs.tag_name }} - with: - script: | - const createTag = require('./.github/scripts/create-tag.js') - await createTag({ github, context }, process.env.TAG_NAME) + RELEASE_TAG_NAME: ${{ steps.release_info.outputs.release_tag_name }} + EXPECTED_SHA: ${{ github.sha }} + shell: bash + run: | + set -euo pipefail + skip_assets=false + releases="$(gh api --paginate --slurp "repos/$GH_REPO/releases?per_page=100" | jq 'add')" + final="$(jq -c --arg tag "$TAG_NAME" '[.[] | select(.tag_name == $tag)]' <<< "$releases")" + staging="$(jq -c --arg tag "$RELEASE_TAG_NAME" '[.[] | select(.tag_name == $tag)]' <<< "$releases")" + final_count="$(jq 'length' <<< "$final")" + staging_count="$(jq 'length' <<< "$staging")" + + if (( final_count > 1 || staging_count > 1 )); then + echo "::error::Found duplicate final or staging nightly releases." + exit 1 + fi + if (( final_count == 1 && staging_count == 1 )); then + echo "::error::Both final release $TAG_NAME and staging release $RELEASE_TAG_NAME exist." + exit 1 + fi + if (( final_count == 0 )); then + tag_error="$(mktemp)" + if final_ref="$(gh api "repos/$GH_REPO/git/ref/tags/$TAG_NAME" 2>"$tag_error")"; then + if [[ "$(jq -r '.object.type' <<< "$final_ref")" != "commit" || "$(jq -r '.object.sha' <<< "$final_ref")" != "$EXPECTED_SHA" ]]; then + echo "::error::Final nightly tag $TAG_NAME does not point to $EXPECTED_SHA." + exit 1 + fi + echo "Final nightly tag $TAG_NAME already points to $EXPECTED_SHA; allowing publication retry." + elif ! grep -q 'HTTP 404' "$tag_error"; then + cat "$tag_error" >&2 + echo "::error::Could not determine whether final nightly tag $TAG_NAME exists." + exit 1 + fi + fi + if (( final_count == 1 )); then + if [[ "$(jq -r '.[0].draft' <<< "$final")" == "true" ]]; then + echo "::error::Final nightly release $TAG_NAME unexpectedly exists as a draft." + exit 1 + fi + if [[ "$(jq -r '.[0].prerelease' <<< "$final")" != "true" ]]; then + echo "::error::Final nightly release $TAG_NAME is not a prerelease." + exit 1 + fi + echo "Published nightly release $TAG_NAME already exists; skipping assets and retrying finalization." + skip_assets=true + elif (( staging_count == 1 )); then + if [[ "$(jq -r '.[0].draft' <<< "$staging")" != "true" ]]; then + echo "::error::Staging nightly release $RELEASE_TAG_NAME is already published." + exit 1 + fi + if [[ "$(jq -r '.[0].prerelease' <<< "$staging")" != "true" ]]; then + echo "::error::Staging nightly release $RELEASE_TAG_NAME is not a prerelease." + exit 1 + fi + if [[ "$(jq -r '.[0].target_commitish' <<< "$staging")" != "$EXPECTED_SHA" ]]; then + echo "::error::Staging nightly release $RELEASE_TAG_NAME does not target $EXPECTED_SHA." + exit 1 + fi + echo "Draft nightly release $RELEASE_TAG_NAME already exists; continuing so assets can be uploaded." + fi + printf 'skip_assets=%s\n' "$skip_assets" >> "$GITHUB_OUTPUT" - name: Build changelog id: build_changelog - uses: mikepenz/release-changelog-builder-action@v4 + if: ${{ steps.existing_release.outputs.skip_assets != 'true' }} + uses: mikepenz/release-changelog-builder-action@cb021f9b36a51a7c6f18e4679b6fa2cb77a1260c # v6.3.0 with: configuration: "./.github/changelog.json" - fromTag: ${{ env.IS_NIGHTLY == 'true' && 'nightly' || env.STABLE_VERSION }} - toTag: ${{ steps.release_info.outputs.tag_name }} + fromTag: ${{ steps.release_info.outputs.from_tag || '' }} + # The canonical nightly tag is created only when the complete release + # is published, so use its commit directly while building the notes. + toTag: ${{ (env.IS_NIGHTLY == 'true' && github.sha) || steps.release_info.outputs.tag_name }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Create the GitHub release as a draft up-front so that all matrix jobs + # only upload assets to an existing draft. With immutable releases + # enabled, an immutable release is sealed when it is published, so all + # assets must be attached before that. Tagged releases are then left as + # a draft for the protected `finalize release` workflow; nightlies are + # published by the `publish-nightly` job at the end of this workflow. + - name: Create draft release + if: ${{ steps.existing_release.outputs.skip_assets != 'true' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + TAG_NAME: ${{ steps.release_info.outputs.release_tag_name }} + FINAL_TAG_NAME: ${{ steps.release_info.outputs.tag_name }} + RELEASE_NAME: ${{ steps.release_info.outputs.release_name }} + CHANGELOG: ${{ steps.build_changelog.outputs.changelog }} + EXPECTED_PRERELEASE: ${{ steps.release_info.outputs.is_prerelease }} + shell: bash + run: | + set -euo pipefail + if release_state="$(gh release view "$TAG_NAME" --json isDraft,isPrerelease,targetCommitish --jq '[.isDraft, .isPrerelease, .targetCommitish] | @tsv' 2>/dev/null)"; then + read -r is_draft is_prerelease target_commitish <<< "$release_state" + if [[ "$is_draft" == "true" ]]; then + if [[ "$is_prerelease" != "$EXPECTED_PRERELEASE" ]]; then + echo "::error::Draft release $TAG_NAME has prerelease=$is_prerelease; expected $EXPECTED_PRERELEASE." + exit 1 + fi + if [[ "$TAG_NAME" != "$FINAL_TAG_NAME" && "$target_commitish" != "$GITHUB_SHA" ]]; then + echo "::error::Draft release $TAG_NAME targets $target_commitish; expected $GITHUB_SHA." + exit 1 + fi + echo "Draft release $TAG_NAME already exists; reusing it." + exit 0 + fi + echo "::error::Release $TAG_NAME is already published; cannot upload more assets to an immutable release." + exit 1 + fi + notes_file="$(mktemp)" + printf '%s' "$CHANGELOG" > "$notes_file" + flags=(--draft --title "$RELEASE_NAME" --notes-file "$notes_file") + if [[ "$TAG_NAME" == "$FINAL_TAG_NAME" ]]; then + flags+=(--verify-tag) + else + flags+=(--target "$GITHUB_SHA") + fi + if [[ "$EXPECTED_PRERELEASE" == "true" ]]; then + flags+=(--prerelease) + fi + gh release create "$TAG_NAME" "${flags[@]}" + release-docker: name: Release Docker - needs: prepare + # The image is pushed during the Depot build, so wait for every native target. + needs: [prepare, release] + if: ${{ !cancelled() && needs.prepare.result == 'success' && (needs.release.result == 'success' || (needs.prepare.outputs.skip_assets == 'true' && needs.release.result == 'skipped')) }} uses: ./.github/workflows/docker-publish.yml + permissions: + actions: read + attestations: write + artifact-metadata: write + contents: read + id-token: write + packages: write with: tag_name: ${{ needs.prepare.outputs.tag_name }} + linux-validated: ${{ needs.release.result == 'success' }} + release-docker-metadata: + name: Record Docker digest + runs-on: ubuntu-24.04 + needs: [prepare, release-docker] + permissions: + contents: read + steps: + - name: Record release Docker digest + env: + DIGEST: ${{ needs.release-docker.outputs.digest }} + run: | + if [[ ! "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "::error::Invalid Docker digest: $DIGEST" + exit 1 + fi + printf '%s\n' "$DIGEST" > release-docker-digest.txt + - name: Upload release Docker digest + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-docker-digest + path: release-docker-digest.txt + retention-days: 90 + if-no-files-found: error + + # This job uploads assets to the draft release created in `prepare`. + # Tagged releases stay as drafts for the protected finalization workflow; + # nightlies are auto-published by the `publish-nightly` job below. Either + # way, GitHub's immutable-releases setting seals the release at publish. release: permissions: - id-token: write - contents: write attestations: write - name: ${{ matrix.target }} (${{ matrix.runner }}) + artifact-metadata: write + contents: write + id-token: write + name: release ${{ matrix.target }} (${{ matrix.runner }}) runs-on: ${{ matrix.runner }} timeout-minutes: 240 - needs: prepare + needs: [prepare] + if: ${{ needs.prepare.outputs.skip_assets != 'true' }} strategy: fail-fast: false matrix: @@ -90,63 +330,99 @@ jobs: # `target`: Rust build target triple # `platform` and `arch`: Used in tarball names # `svm`: target platform to use for the Solc binary: https://github.com/roynalnaruto/svm-rs/blob/84cbe0ac705becabdc13168bae28a45ad2299749/svm-builds/build.rs#L4-L24 - - runner: Linux-22.04 + # These are pinned to the oldest runner versions to support old libc/SDK versions. + - runner: depot-ubuntu-22.04-16 target: x86_64-unknown-linux-gnu svm_target_platform: linux-amd64 platform: linux arch: amd64 - - runner: Linux-22.04 + - runner: depot-ubuntu-22.04-16 target: x86_64-unknown-linux-musl svm_target_platform: linux-amd64 platform: alpine arch: amd64 - - runner: Linux-22.04 + - runner: depot-ubuntu-22.04-arm-16 target: aarch64-unknown-linux-gnu svm_target_platform: linux-aarch64 platform: linux arch: arm64 - - runner: Linux-22.04 + - runner: depot-ubuntu-22.04-16 target: aarch64-unknown-linux-musl svm_target_platform: linux-aarch64 platform: alpine arch: arm64 - # This is pinned to `macos-13-large` to support old SDK versions. - # If the runner is deprecated it should be pinned to the oldest available version of the runner. - - runner: macos-13-large + - runner: macos-15-large target: x86_64-apple-darwin svm_target_platform: macosx-amd64 platform: darwin arch: amd64 - - runner: macos-latest-large + - runner: macos-latest-xlarge target: aarch64-apple-darwin svm_target_platform: macosx-aarch64 platform: darwin arch: arm64 - - runner: Windows + - runner: depot-windows-latest-16 target: x86_64-pc-windows-msvc svm_target_platform: windows-amd64 platform: win32 arch: amd64 steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - name: Secure runner + uses: tempoxyz/gh-actions/actions/secure-runner@2ab28a31ae569be9f780c7675b684471060afa40 with: - targets: ${{ matrix.target }} - - uses: Swatinem/rust-cache@v2 + # Disable the pre-job hook too; a step condition alone does not skip it. + disable-enforcement: ${{ runner.os == 'macOS' }} + - name: Install Aegis on macOS + if: runner.os == 'macOS' + uses: tempoxyz/gh-actions/actions/aegis@2ab28a31ae569be9f780c7675b684471060afa40 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - key: ${{ matrix.target }} - cache-on-failure: true + persist-credentials: false + - uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # v1 + with: + toolchain: stable + targets: ${{ matrix.target }} + - uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # v1 + + # Include locked workspace dependencies not reached by the --bins build. + - name: Fetch locked dependencies + env: + TARGET: ${{ matrix.target }} + shell: bash + run: cargo fetch --locked --target "$TARGET" - - name: Apple M1 setup - if: matrix.target == 'aarch64-apple-darwin' + - name: Install LLVM profiling tools + if: matrix.target == 'x86_64-unknown-linux-gnu' + run: rustup component add llvm-tools-preview + + - name: macOS setup + if: runner.os == 'macOS' run: | - echo "SDKROOT=$(xcrun -sdk macosx --show-sdk-path)" >> $GITHUB_ENV - echo "MACOSX_DEPLOYMENT_TARGET=$(xcrun -sdk macosx --show-sdk-platform-version)" >> $GITHUB_ENV + { + printf 'SDKROOT=%s\n' "$(xcrun -sdk macosx --show-sdk-path)" + if [[ '${{ matrix.target }}' == 'x86_64-apple-darwin' ]]; then + printf 'MACOSX_DEPLOYMENT_TARGET=10.12\n' + else + # Apple Silicon and the Touch ID shim require macOS 11 or newer. + printf 'MACOSX_DEPLOYMENT_TARGET=11.0\n' + fi + if [[ '${{ matrix.target }}' == 'aarch64-apple-darwin' ]]; then + # Keep the release independent of Homebrew's libusb installation. + printf 'LIBUSB_STATIC=1\n' + # The ARM64 runner has less memory than the Intel large runner. + printf 'CARGO_BUILD_JOBS=2\n' + fi + } >> "$GITHUB_ENV" + xcrun --find clang + xcrun --find ld + xcrun -sdk macosx --show-sdk-version - name: cross setup - if: matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-gnu' + if: contains(matrix.target, 'musl') run: | - cargo install cross + cargo install cross --locked \ + --git https://github.com/cross-rs/cross \ + --rev 64b5bb4d3d34de062552b9a2093affe77b4ad16a - name: Build binaries env: @@ -154,12 +430,22 @@ jobs: SVM_TARGET_PLATFORM: ${{ matrix.svm_target_platform }} PLATFORM_NAME: ${{ matrix.platform }} TARGET: ${{ matrix.target }} - OUT_DIR: target/${{ matrix.target }}/${{ env.PROFILE }} + OUT_DIR: target/${{ matrix.target }}/${{ env.RUST_PROFILE }} shell: bash run: | set -eo pipefail - flags=(--target $TARGET --profile $PROFILE --bins - --no-default-features --features aws-kms,gcp-kms,cli,asm-keccak) + features="$RUST_FEATURES" + + # The Touch ID Swift shim requires macOS 11. Keep the packaged Intel + # artifact on its existing deployment target and enable Touch ID only + # for the Apple Silicon release artifact. + if [[ "$TARGET" == "aarch64-apple-darwin" ]]; then + features="${features},touch-id" + fi + + echo "Building $TARGET with features: $features" + flags=(--locked --target "$TARGET" --profile "$RUST_PROFILE" + --no-default-features --features "$features") # `jemalloc` is not fully supported on MSVC or aarch64 Linux. if [[ "$TARGET" != *msvc* && "$TARGET" != "aarch64-unknown-linux-gnu" ]]; then @@ -168,144 +454,253 @@ jobs: [[ "$TARGET" == *windows* ]] && ext=".exe" - if [[ "$TARGET" == *-musl || "$TARGET" == "aarch64-unknown-linux-gnu" ]]; then - cross build "${flags[@]}" + if [[ "$TARGET" == "x86_64-unknown-linux-gnu" ]]; then + .github/scripts/pgo.sh "${flags[@]}" + elif [[ "$TARGET" == *-musl ]]; then + cross build "${flags[@]}" --bins else - cargo build "${flags[@]}" + cargo build "${flags[@]}" --bins fi - bins=(anvil cast chisel forge) + host_target=$(rustc -vV | sed -n 's/^host: //p') + bins=(anvil cast chisel forge solar) for name in "${bins[@]}"; do - bin=$OUT_DIR/$name$ext - echo "" + bin="$OUT_DIR/$name$ext" + printf '\n' file "$bin" || true + if [[ "$TARGET" == "x86_64-apple-darwin" ]]; then + [[ "$(lipo -archs "$bin")" == 'x86_64' ]] + minos=$(otool -l "$bin" | awk ' + $1 == "cmd" && ($2 == "LC_BUILD_VERSION" || $2 == "LC_VERSION_MIN_MACOSX") { found = 1; next } + found && ($1 == "minos" || $1 == "version") { print $2; exit } + ') + echo "$name minimum macOS version: $minos" + [[ "$minos" == '10.12' || "$minos" == '10.12.0' ]] + fi du -h "$bin" || true ldd "$bin" || true - $bin --version || true - echo "${name}_bin_path=${bin}" >> $GITHUB_ENV + if [[ "$TARGET" == "$host_target" ]]; then + "$bin" --version + else + echo "Skipping execution of $name: target $TARGET differs from host $host_target" + fi + printf '%s_bin_path=%s\n' "$name" "$bin" >> "$GITHUB_ENV" done - name: Archive binaries id: artifacts env: PLATFORM_NAME: ${{ matrix.platform }} - OUT_DIR: target/${{ matrix.target }}/${{ env.PROFILE }} + OUT_DIR: target/${{ matrix.target }}/${{ env.RUST_PROFILE }} VERSION_NAME: ${{ (env.IS_NIGHTLY == 'true' && 'nightly') || needs.prepare.outputs.tag_name }} ARCH: ${{ matrix.arch }} shell: bash run: | if [[ "$PLATFORM_NAME" == "linux" || "$PLATFORM_NAME" == "alpine" ]]; then - tar -czvf "foundry_${VERSION_NAME}_${PLATFORM_NAME}_${ARCH}.tar.gz" -C $OUT_DIR forge cast anvil chisel - echo "file_name=foundry_${VERSION_NAME}_${PLATFORM_NAME}_${ARCH}.tar.gz" >> $GITHUB_OUTPUT + tar -czvf "foundry_${VERSION_NAME}_${PLATFORM_NAME}_${ARCH}.tar.gz" -C "$OUT_DIR" forge cast anvil chisel solar + file_name="foundry_${VERSION_NAME}_${PLATFORM_NAME}_${ARCH}.tar.gz" elif [ "$PLATFORM_NAME" == "darwin" ]; then # We need to use gtar here otherwise the archive is corrupt. # See: https://github.com/actions/virtual-environments/issues/2619 - gtar -czvf "foundry_${VERSION_NAME}_${PLATFORM_NAME}_${ARCH}.tar.gz" -C $OUT_DIR forge cast anvil chisel - echo "file_name=foundry_${VERSION_NAME}_${PLATFORM_NAME}_${ARCH}.tar.gz" >> $GITHUB_OUTPUT + gtar -czvf "foundry_${VERSION_NAME}_${PLATFORM_NAME}_${ARCH}.tar.gz" -C "$OUT_DIR" forge cast anvil chisel solar + file_name="foundry_${VERSION_NAME}_${PLATFORM_NAME}_${ARCH}.tar.gz" else - cd $OUT_DIR - 7z a -tzip "foundry_${VERSION_NAME}_${PLATFORM_NAME}_${ARCH}.zip" forge.exe cast.exe anvil.exe chisel.exe + cd "$OUT_DIR" + 7z a -tzip "foundry_${VERSION_NAME}_${PLATFORM_NAME}_${ARCH}.zip" forge.exe cast.exe anvil.exe chisel.exe solar.exe mv "foundry_${VERSION_NAME}_${PLATFORM_NAME}_${ARCH}.zip" ../../../ - echo "file_name=foundry_${VERSION_NAME}_${PLATFORM_NAME}_${ARCH}.zip" >> $GITHUB_OUTPUT + file_name="foundry_${VERSION_NAME}_${PLATFORM_NAME}_${ARCH}.zip" fi - echo "foundry_attestation=foundry_${VERSION_NAME}_${PLATFORM_NAME}_${ARCH}.attestation.txt" >> $GITHUB_OUTPUT + { + printf "file_name=%s\n" "$file_name" + printf "foundry_attestation=%s\n" "foundry_${VERSION_NAME}_${PLATFORM_NAME}_${ARCH}.attestation.txt" + printf "foundry_sbom=%s\n" "foundry_${VERSION_NAME}_${PLATFORM_NAME}_${ARCH}.spdx.json" + printf "foundry_checksum=%s\n" "foundry_${VERSION_NAME}_${PLATFORM_NAME}_${ARCH}.sha256" + printf "foundry_signature=%s\n" "foundry_${VERSION_NAME}_${PLATFORM_NAME}_${ARCH}.sigstore.json" + } >> "$GITHUB_OUTPUT" + + - name: Generate archive checksum + env: + FILE_NAME: ${{ steps.artifacts.outputs.file_name }} + FOUNDRY_CHECKSUM: ${{ steps.artifacts.outputs.foundry_checksum }} + shell: bash + run: | + set -euo pipefail + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$FILE_NAME" > "$FOUNDRY_CHECKSUM" + else + shasum -a 256 "$FILE_NAME" > "$FOUNDRY_CHECKSUM" + fi + cat "$FOUNDRY_CHECKSUM" + + - name: Install Syft + uses: anchore/sbom-action/download-syft@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 # v0.24.2 + + - name: Generate SBOM (SPDX) + env: + FOUNDRY_SBOM: ${{ steps.artifacts.outputs.foundry_sbom }} + VERSION_NAME: ${{ (env.IS_NIGHTLY == 'true' && 'nightly') || needs.prepare.outputs.tag_name }} + shell: bash + run: | + set -euo pipefail + syft scan dir:. \ + --source-name foundry \ + --source-version "$VERSION_NAME" \ + -o spdx-json="$FOUNDRY_SBOM" + + - name: Upload build artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + retention-days: 1 + name: ${{ steps.artifacts.outputs.file_name }} + path: ${{ steps.artifacts.outputs.file_name }} - name: Build man page id: man if: matrix.target == 'x86_64-unknown-linux-gnu' env: - OUT_DIR: target/${{ matrix.target }}/${{ env.PROFILE }} + OUT_DIR: target/${{ matrix.target }}/${{ env.RUST_PROFILE }} VERSION_NAME: ${{ (env.IS_NIGHTLY == 'true' && 'nightly') || needs.prepare.outputs.tag_name }} shell: bash run: | sudo apt-get -y install help2man - help2man -N $OUT_DIR/forge > forge.1 - help2man -N $OUT_DIR/cast > cast.1 - help2man -N $OUT_DIR/anvil > anvil.1 - help2man -N $OUT_DIR/chisel > chisel.1 + help2man -N "$OUT_DIR/forge" > forge.1 + help2man -N "$OUT_DIR/cast" > cast.1 + help2man -N "$OUT_DIR/anvil" > anvil.1 + help2man -N "$OUT_DIR/chisel" > chisel.1 + help2man -N "$OUT_DIR/solar" > solar.1 gzip forge.1 gzip cast.1 gzip anvil.1 gzip chisel.1 - tar -czvf "foundry_man_${VERSION_NAME}.tar.gz" forge.1.gz cast.1.gz anvil.1.gz chisel.1.gz - echo "foundry_man=foundry_man_${VERSION_NAME}.tar.gz" >> $GITHUB_OUTPUT + gzip solar.1 + tar -czvf "foundry_man_${VERSION_NAME}.tar.gz" forge.1.gz cast.1.gz anvil.1.gz chisel.1.gz solar.1.gz + printf 'foundry_man=%s\n' "foundry_man_${VERSION_NAME}.tar.gz" >> "$GITHUB_OUTPUT" - - name: Binaries attestation + - name: Binaries and archive provenance attestation id: attestation - uses: actions/attest-build-provenance@v2 + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 with: subject-path: | ${{ env.anvil_bin_path }} ${{ env.cast_bin_path }} ${{ env.chisel_bin_path }} ${{ env.forge_bin_path }} + ${{ env.solar_bin_path }} + ${{ steps.artifacts.outputs.file_name }} - - name: Record attestation URL + - name: Archive SBOM attestation + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: ${{ steps.artifacts.outputs.file_name }} + sbom-path: ${{ steps.artifacts.outputs.foundry_sbom }} + + - name: Install cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + + - name: Sign archive with cosign (keyless) + env: + FILE_NAME: ${{ steps.artifacts.outputs.file_name }} + FOUNDRY_SIGNATURE: ${{ steps.artifacts.outputs.foundry_signature }} + shell: bash run: | - echo "${{ steps.attestation.outputs.attestation-url }}" > ${{ steps.artifacts.outputs.foundry_attestation }} + set -euo pipefail + cosign sign-blob \ + --yes \ + --bundle "$FOUNDRY_SIGNATURE" \ + "$FILE_NAME" - # Creates the release for this specific version - - name: Create release - uses: softprops/action-gh-release@v2.2.2 - with: - name: ${{ needs.prepare.outputs.release_name }} - tag_name: ${{ needs.prepare.outputs.tag_name }} - prerelease: ${{ env.IS_NIGHTLY == 'true' }} - body: ${{ needs.prepare.outputs.changelog }} - files: | - ${{ steps.artifacts.outputs.file_name }} - ${{ steps.artifacts.outputs.foundry_attestation }} - ${{ steps.man.outputs.foundry_man }} + - name: Record attestation URL + env: + ATTESTATION_URL: ${{ steps.attestation.outputs.attestation-url }} + FOUNDRY_ATTESTATION: ${{ steps.artifacts.outputs.foundry_attestation }} + shell: bash + run: | + set -euo pipefail + printf '%s\n' "$ATTESTATION_URL" > "$FOUNDRY_ATTESTATION" - # If this is a nightly release, it also updates the release - # tagged `nightly` for compatibility with `foundryup` - - name: Update nightly release - if: ${{ env.IS_NIGHTLY == 'true' }} - uses: softprops/action-gh-release@v2.2.2 - with: - name: "Nightly" - tag_name: "nightly" - prerelease: true - body: ${{ needs.prepare.outputs.changelog }} - files: | - ${{ steps.artifacts.outputs.file_name }} - ${{ steps.artifacts.outputs.foundry_attestation }} - ${{ steps.man.outputs.foundry_man }} + # Upload assets to the draft release created in `prepare`. Tagged releases + # stay as drafts after this workflow finishes; a maintainer runs the + # protected finalization workflow. Nightlies are auto-published below. + - name: Upload assets to draft release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + TAG_NAME: ${{ needs.prepare.outputs.release_tag_name }} + FILE_NAME: ${{ steps.artifacts.outputs.file_name }} + FOUNDRY_ATTESTATION: ${{ steps.artifacts.outputs.foundry_attestation }} + FOUNDRY_SBOM: ${{ steps.artifacts.outputs.foundry_sbom }} + FOUNDRY_CHECKSUM: ${{ steps.artifacts.outputs.foundry_checksum }} + FOUNDRY_SIGNATURE: ${{ steps.artifacts.outputs.foundry_signature }} + FOUNDRY_MAN: ${{ steps.man.outputs.foundry_man }} + shell: bash + run: | + set -euo pipefail + files=( + "$FILE_NAME" + "$FOUNDRY_ATTESTATION" + "$FOUNDRY_SBOM" + "$FOUNDRY_CHECKSUM" + "$FOUNDRY_SIGNATURE" + ) + if [[ -n "${FOUNDRY_MAN:-}" ]]; then + files+=("$FOUNDRY_MAN") + fi + gh release upload "$TAG_NAME" "${files[@]}" --clobber - cleanup: - name: Release cleanup - runs-on: ubuntu-latest - timeout-minutes: 30 - needs: release - if: always() + # Auto-publish nightly releases once all assets have been uploaded so that + # foundryup and other consumers see them immediately. Tagged releases are + # left as drafts for the protected finalization workflow. + publish-nightly: + name: Publish nightly release + runs-on: ubuntu-24.04 + needs: [prepare, release-docker, release-docker-metadata, release] + if: ${{ always() && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && needs.prepare.result == 'success' && needs.release-docker.result == 'success' && needs.release-docker-metadata.result == 'success' && (needs.release.result == 'success' || (needs.prepare.outputs.skip_assets == 'true' && needs.release.result == 'skipped')) }} + concurrency: + group: release-nightly-promotion + cancel-in-progress: false + queue: max + permissions: + contents: write + packages: write steps: - - uses: actions/checkout@v4 - - # Moves the `nightly` tag to `HEAD` - - name: Move nightly tag - if: ${{ env.IS_NIGHTLY == 'true' }} - uses: actions/github-script@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - script: | - const moveTag = require('./.github/scripts/move-tag.js') - await moveTag({ github, context }, 'nightly') - - - name: Delete old nightlies - uses: actions/github-script@v7 + fetch-depth: 0 + persist-credentials: false + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: - script: | - const prunePrereleases = require('./.github/scripts/prune-prereleases.js') - await prunePrereleases({github, context}) + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Publish release and promote nightly image + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG_NAME: ${{ needs.prepare.outputs.tag_name }} + RELEASE_TAG_NAME: ${{ needs.prepare.outputs.release_tag_name }} + DIGEST: ${{ needs.release-docker.outputs.digest }} + shell: bash + run: python3 .github/scripts/finalize-release.py nightly --tag "$TAG_NAME" --release-tag "$RELEASE_TAG_NAME" --digest "$DIGEST" # If any of the jobs fail, this will create a high-priority issue to signal so. issue: name: Open an issue - runs-on: ubuntu-latest - needs: [prepare, release-docker, release, cleanup] + runs-on: ubuntu-24.04 + needs: [ + prepare, + release-docker, + release-docker-metadata, + release, + publish-nightly, + ] if: failure() + permissions: + contents: read + issues: write steps: - - uses: actions/checkout@v4 - - uses: JasonEtco/create-an-issue@v2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: JasonEtco/create-an-issue@1b14a70e4d8dc185e5cc76d3bec9eab20257b2c5 # v2.9.2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} WORKFLOW_URL: | diff --git a/.github/workflows/stale-prs.yml b/.github/workflows/stale-prs.yml new file mode 100644 index 0000000000000..760844e84489b --- /dev/null +++ b/.github/workflows/stale-prs.yml @@ -0,0 +1,32 @@ +# Marks pull requests as stale. + +name: stale PRs + +on: + workflow_dispatch: {} + schedule: + - cron: "30 1 * * *" + +permissions: {} + +jobs: + stale-prs: + if: github.repository == 'foundry-rs/foundry' + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 + with: + days-before-issue-stale: -1 + days-before-issue-close: -1 + days-before-pr-stale: 14 + days-before-pr-close: 7 + stale-pr-label: "stale" + exempt-pr-labels: "stop-stale" + stale-pr-message: "This pull request is stale because it has been open for 14 days with no activity. It will be closed in 7 days if there is no further activity." + close-pr-message: "This pull request was closed because it has been inactive for 7 days since being marked as stale." + labels-to-add-when-unstale: "stop-stale" + exempt-all-milestones: true + exempt-all-assignees: true diff --git a/.github/workflows/test-deploy-verify.yml b/.github/workflows/test-deploy-verify.yml new file mode 100644 index 0000000000000..c376b963d4aa6 --- /dev/null +++ b/.github/workflows/test-deploy-verify.yml @@ -0,0 +1,178 @@ +# Daily CI job running the on-chain deploy + verify tests against public testnets. +# +# These are excluded from regular CI via the nextest default-filter: they spend testnet funds from +# a shared deployer account and depend on three external verifier services (Etherscan, Sourcify, +# Blockscout), so they are too slow and too flaky to gate pull requests on. + +name: test-deploy-verify + +permissions: {} + +on: + schedule: + - cron: "0 3 * * *" # Run daily at 3 AM UTC (offset from the other nightlies) + workflow_dispatch: # Needed so we can run it manually + +concurrency: + # Never run two of these at once: they share one deployer account per network, and concurrent + # runs would collide on the account nonce. + group: ${{ github.workflow }} + cancel-in-progress: false + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: full + RUSTC_WRAPPER: "sccache" + +jobs: + test: + name: deploy + verify + if: github.event_name != 'schedule' || github.repository == 'foundry-rs/foundry' + runs-on: depot-ubuntu-latest-16 + timeout-minutes: 60 + permissions: + id-token: write + contents: read + outputs: + deployer: ${{ steps.balances.outputs.deployer }} + low: ${{ steps.balances.outputs.low }} + steps: + - name: Secure runner + uses: tempoxyz/gh-actions/actions/secure-runner@2ab28a31ae569be9f780c7675b684471060afa40 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # v1 + with: + toolchain: stable + - uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # v1 + - uses: taiki-e/install-action@4cef1412cce204788f482e778a0b9187f9626a29 # v2.87.21 + with: + tool: nextest + - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + + # Fatal and cheap: without the key every test below would quietly pass as a no-op, which + # is the exact failure mode this workflow exists to avoid. + - name: Check deployer key is configured + env: + TESTNET_DEPLOYER_PRIVATE_KEY: ${{ secrets.FUNDED_TESTNET_PKEY }} + shell: bash + run: | + if [[ -z "$TESTNET_DEPLOYER_PRIVATE_KEY" ]]; then + echo "::error::FUNDED_TESTNET_PKEY is not set; every deploy+verify test would silently no-op." + exit 1 + fi + echo "Deployer key is configured." + + # Running dry is the most likely failure, so balances go at the top of the log and anything + # under its threshold is reported to the refund job below. Never fail the run over this - the + # tests themselves are the signal, and one dry network should not stop the others. + - name: Report deployer balances + id: balances + continue-on-error: true + env: + HOODI_RPC_URL: ${{ secrets.HOODI_RPC_URL || 'https://ethereum-hoodi-rpc.publicnode.com' }} + SEPOLIA_RPC_URL: ${{ secrets.ETH_SEPOLIA_RPC || 'https://ethereum-sepolia-rpc.publicnode.com' }} + BASE_SEPOLIA_RPC_URL: ${{ secrets.BASE_SEPOLIA_RPC_URL || 'https://sepolia.base.org' }} + ARBITRUM_SEPOLIA_RPC_URL: ${{ secrets.ARBITRUM_SEPOLIA_RPC_URL || 'https://sepolia-rollup.arbitrum.io/rpc' }} + HYPEREVM_TESTNET_RPC_URL: ${{ secrets.HYPEREVM_TESTNET_RPC_URL || 'https://rpc.hyperliquid-testnet.xyz/evm' }} + MONAD_TESTNET_RPC_URL: ${{ secrets.MONAD_TESTNET_RPC_URL || 'https://testnet-rpc.monad.xyz' }} + ROBINHOOD_TESTNET_RPC_URL: ${{ secrets.ROBINHOOD_TESTNET_RPC_URL || 'https://rpc.testnet.chain.robinhood.com' }} + TESTNET_DEPLOYER_PRIVATE_KEY: ${{ secrets.FUNDED_TESTNET_PKEY }} + shell: bash + run: | + cargo build --locked --bin cast + cast=./target/debug/cast + addr=$("$cast" wallet address "$TESTNET_DEPLOYER_PRIVATE_KEY") + echo "Deployer: $addr" + echo "deployer=$addr" >> "$GITHUB_OUTPUT" + + # Thresholds are roughly 20 more runs at the measured per-run burn. Monad testnet is the + # expensive one at ~0.08 MON per run; the L2s are effectively free. + # HyperEVM starts with a conservative 0.1 HYPE refill threshold. + low="" + for spec in HOODI:0.05 SEPOLIA:0.05 BASE_SEPOLIA:0.01 ARBITRUM_SEPOLIA:0.01 MONAD_TESTNET:2.0 ROBINHOOD_TESTNET:0.01 HYPEREVM_TESTNET:0.1; do + net="${spec%%:*}" + min="${spec##*:}" + url_var="${net}_RPC_URL" + if ! bal=$("$cast" balance "$addr" --rpc-url "${!url_var}" 2>/dev/null); then + echo "::warning::$net: could not read balance" + continue + fi + human=$("$cast" from-wei "$bal") + if awk -v b="$human" -v m="$min" 'BEGIN { exit !(b + 0 < m + 0) }'; then + echo "::warning::$net: $human is below the $min top-up threshold" + low="$low $net=$human(min $min)" + else + echo "$net: $human" + fi + done + echo "low=${low# }" >> "$GITHUB_OUTPUT" + + - name: Deploy and verify on testnets + env: + SVM_TARGET_PLATFORM: linux-amd64 + # Read by `EnvExternalities::deploy_verify` as _RPC_URL. Public endpoints are + # fine here; the secrets exist so a network can be repointed without a code change. + HOODI_RPC_URL: ${{ secrets.HOODI_RPC_URL || 'https://ethereum-hoodi-rpc.publicnode.com' }} + SEPOLIA_RPC_URL: ${{ secrets.ETH_SEPOLIA_RPC || 'https://ethereum-sepolia-rpc.publicnode.com' }} + BASE_SEPOLIA_RPC_URL: ${{ secrets.BASE_SEPOLIA_RPC_URL || 'https://sepolia.base.org' }} + ARBITRUM_SEPOLIA_RPC_URL: ${{ secrets.ARBITRUM_SEPOLIA_RPC_URL || 'https://sepolia-rollup.arbitrum.io/rpc' }} + HYPEREVM_TESTNET_RPC_URL: ${{ secrets.HYPEREVM_TESTNET_RPC_URL || 'https://rpc.hyperliquid-testnet.xyz/evm' }} + MONAD_TESTNET_RPC_URL: ${{ secrets.MONAD_TESTNET_RPC_URL || 'https://testnet-rpc.monad.xyz' }} + ROBINHOOD_TESTNET_RPC_URL: ${{ secrets.ROBINHOOD_TESTNET_RPC_URL || 'https://rpc.testnet.chain.robinhood.com' }} + # Funded throwaway deployer, shared by every network. Holds testnet funds only, and is + # topped up by hand; a per-network override is possible via _PRIVATE_KEY. + TESTNET_DEPLOYER_PRIVATE_KEY: ${{ secrets.FUNDED_TESTNET_PKEY }} + # `etherscan_key` reads ETHERSCAN_API_KEY, which is a different variable from the + # ETHERSCAN_KEY that the regular test workflow exports for `foundry_test_utils::rpc`. + ETHERSCAN_API_KEY: ${{ secrets.ETHERSCAN_API_KEY }} + run: cargo nextest run --locked --profile deploy-verify --no-fail-fast + + # Raised before the account actually runs dry, so the tests keep passing while someone tops it + # up. Independent of whether the tests passed - a low balance is worth reporting either way. + refund: + name: Open a refund issue + runs-on: ubuntu-latest + needs: [test] + if: always() && github.event_name == 'schedule' && needs.test.outputs.low != '' + permissions: + contents: read + issues: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: JasonEtco/create-an-issue@1b14a70e4d8dc185e5cc76d3bec9eab20257b2c5 # v2.9.2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DEPLOYER: ${{ needs.test.outputs.deployer }} + LOW_NETWORKS: ${{ needs.test.outputs.low }} + WORKFLOW_URL: | + ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + with: + update_existing: true + filename: .github/DEPLOY_VERIFY_REFUND_TEMPLATE.md + + # If the nightly run fails, this will create a normal-priority issue to signal so. + issue: + name: Open an issue + runs-on: ubuntu-latest + needs: [test] + if: failure() && github.event_name == 'schedule' + permissions: + contents: read + issues: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: JasonEtco/create-an-issue@1b14a70e4d8dc185e5cc76d3bec9eab20257b2c5 # v2.9.2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + WORKFLOW_URL: | + ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + with: + update_existing: true + filename: .github/DEPLOY_VERIFY_FAILURE_TEMPLATE.md diff --git a/.github/workflows/test-flaky.yml b/.github/workflows/test-flaky.yml new file mode 100644 index 0000000000000..a2688538fbcd8 --- /dev/null +++ b/.github/workflows/test-flaky.yml @@ -0,0 +1,97 @@ +# Daily CI job to run flaky tests that are excluded from regular CI via nextest default-filter + +name: test-flaky + +permissions: {} + +on: + schedule: + - cron: "0 1 * * *" # Run daily at 1 AM UTC + workflow_dispatch: # Needed so we can run it manually + +concurrency: + group: tests-${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: full + RUSTC_WRAPPER: "sccache" + +jobs: + test: + name: flaky tests + if: github.event_name != 'schedule' || github.repository == 'foundry-rs/foundry' + runs-on: depot-ubuntu-latest-16 + timeout-minutes: 60 + permissions: + id-token: write + contents: read + steps: + - name: Secure runner + uses: tempoxyz/gh-actions/actions/secure-runner@2ab28a31ae569be9f780c7675b684471060afa40 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # v1 + with: + toolchain: stable + - uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # v1 + - uses: taiki-e/install-action@4cef1412cce204788f482e778a0b9187f9626a29 # v2.87.21 + with: + tool: nextest + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + - name: Install Vyper + # Also update vyper version in .devcontainer/Dockerfile.dev + run: pip --version && pip install vyper==0.4.3 + - name: Install Z3 + # Same pin as test.yml, for the symbolic suites below. + run: pip install z3-solver==5.1.0.0 && z3 --version + - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + - name: Test flaky tests + env: + SVM_TARGET_PLATFORM: linux-amd64 + ETHERSCAN_KEY: ${{ secrets.ETHERSCAN_API_KEY }} + ARBITRUM_RPC: ${{ secrets.ARBITRUM_RPC }} + ETH_SEPOLIA_RPC: ${{ secrets.ETH_SEPOLIA_RPC }} + CELO_RPC: ${{ secrets.CELO_RPC }} + GNOSIS_RPC: ${{ secrets.GNOSIS_RPC }} + HYPERLIQUID_RPC: ${{ secrets.HYPERLIQUID_RPC }} + ROBINHOOD_RPC: ${{ secrets.ROBINHOOD_RPC }} + run: cargo nextest run --locked --profile flaky --no-fail-fast + # Broader and slower symbolic coverage that PR CI skips; the tests return early unless these + # variables are set. + - name: Test symbolic conformance and limits + if: ${{ !cancelled() }} + env: + SVM_TARGET_PLATFORM: linux-amd64 + SYMBOLIC_CONFORMANCE: 1 + SYMBOLIC_LIMITS: 1 + run: >- + cargo nextest run --locked -p forge --test cli --no-fail-fast + -E 'test(/symbolic_conformance::|symbolic_limits::/)' + + # If any of the jobs fail, this will create a normal-priority issue to signal so. + issue: + name: Open an issue + runs-on: ubuntu-latest + needs: [test] + if: failure() + permissions: + contents: read + issues: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: JasonEtco/create-an-issue@1b14a70e4d8dc185e5cc76d3bec9eab20257b2c5 # v2.9.2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + WORKFLOW_URL: | + ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + with: + update_existing: true + filename: .github/FLAKY_TEST_FAILURE_TEMPLATE.md diff --git a/.github/workflows/test-isolate.yml b/.github/workflows/test-isolate.yml deleted file mode 100644 index 119a6bd55adec..0000000000000 --- a/.github/workflows/test-isolate.yml +++ /dev/null @@ -1,14 +0,0 @@ -# Daily CI job to run tests with isolation mode enabled by default - -name: test-isolate - -on: - schedule: - - cron: "0 0 * * *" - workflow_dispatch: - -jobs: - nextest: - uses: ./.github/workflows/nextest.yml - with: - profile: isolate diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6b2b2324ba04e..110d8eb94b419 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,132 +1,185 @@ +# Reusable workflow for running tests via `cargo nextest` + name: test +permissions: {} + on: - push: - branches: - - master - pull_request: + workflow_call: + inputs: + profile: + required: true + type: string + secrets: + ETHERSCAN_API_KEY: + required: false + ARBITRUM_RPC: + required: false + ETH_SEPOLIA_RPC: + required: false + TEMPO_MAINNET_RPC_URL: + required: false + TEMPO_TESTNET_RPC_URL: + required: false concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + group: tests-${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true env: CARGO_TERM_COLOR: always RUST_BACKTRACE: full + RUSTC_WRAPPER: "sccache" + RUST_MIN_STACK: 4194304 # 2 * the default (2 * 1024 * 1024) jobs: - nextest: - uses: ./.github/workflows/nextest.yml - with: - profile: default - secrets: inherit - - docs: + matrices: + name: build matrices runs-on: ubuntu-latest - timeout-minutes: 30 + permissions: + contents: read + outputs: + test-matrix: ${{ steps.gen.outputs.test-matrix }} steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@nightly - - uses: Swatinem/rust-cache@v2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - cache-on-failure: true - - name: Build documentation - run: cargo doc --workspace --all-features --no-deps --document-private-items - env: - RUSTDOCFLAGS: --cfg docsrs -D warnings --show-type-layout --generate-link-to-definition --enable-index-page -Zunstable-options - - name: Deploy documentation - uses: peaceiris/actions-gh-pages@v3 - if: github.event_name == 'push' && github.ref == 'refs/heads/master' + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: target/doc - force_orphan: true + python-version: "3.14" + - name: Generate matrices + id: gen + env: + EVENT_NAME: ${{ github.event_name }} + PROFILE: ${{ inputs.profile }} + shell: bash + run: | + output=$(python3 .github/scripts/matrices.py) + printf '::debug::test-matrix=%s\n' "$output" + printf 'test-matrix=%s\n' "$output" >> "$GITHUB_OUTPUT" - doctest: - runs-on: ubuntu-latest - timeout-minutes: 30 + test: + name: test ${{ matrix.name }} + runs-on: ${{ matrix.runner_label }} + timeout-minutes: 60 + permissions: + id-token: write + contents: read + needs: matrices + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.matrices.outputs.test-matrix) }} steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - name: Secure runner + uses: tempoxyz/gh-actions/actions/secure-runner@2ab28a31ae569be9f780c7675b684471060afa40 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - cache-on-failure: true - - run: cargo test --workspace --doc - - typos: - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - uses: actions/checkout@v4 - - uses: crate-ci/typos@v1 - - clippy: - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@clippy - - uses: Swatinem/rust-cache@v2 + persist-credentials: false + - uses: dtolnay/rust-toolchain@e2a55d2ffb04f378e9626c28d38b36d230d1e12f # v1 with: - cache-on-failure: true - - run: cargo clippy --workspace --all-targets --all-features - env: - RUSTFLAGS: -Dwarnings - - rustfmt: - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@nightly + toolchain: stable + target: ${{ matrix.target }} + - uses: rui314/setup-mold@10ca16bf91dc22e05ebdc935cad9c75ea248f621 # v1 + - name: Install Linux system dependencies + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install --yes libudev-dev + - name: Check out LCOV + if: runner.os == 'Linux' && !contains(matrix.name, 'external') + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: linux-test-project/lcov + ref: 5186f7394417292537d17b9a9a30f8c06bedc16c # v2.3.1 + path: .lcov + persist-credentials: false + - name: Set up LCOV report validation + if: runner.os == 'Linux' && !contains(matrix.name, 'external') + run: | + sudo apt-get install --yes libcapture-tiny-perl libdatetime-perl libtimedate-perl + echo "FOUNDRY_TEST_GENHTML=$GITHUB_WORKSPACE/.lcov/bin/genhtml" >> "$GITHUB_ENV" + .lcov/bin/genhtml --version + # External tests dependencies + - name: Setup Node.js + if: contains(matrix.name, 'external') + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + - name: Install Bun + if: contains(matrix.name, 'external') + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: latest + - name: Install pnpm for external fixtures + if: contains(matrix.name, 'external') + uses: tempoxyz/gh-actions/vendor/pnpm/action-setup@2ab28a31ae569be9f780c7675b684471060afa40 with: - components: rustfmt - - run: cargo fmt --all --check + version: 11.8.0 + run_install: false + - name: Setup Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + - name: Install Vyper + # Also update vyper version in .devcontainer/Dockerfile.dev + run: pip --version && pip install vyper==0.4.3 + - name: Install Z3 + # Pinned so the `forge test --symbolic` snapshot tests stay stable across solver versions. + if: runner.os == 'Linux' + run: pip install z3-solver==5.1.0.0 && z3 --version - forge-fmt: - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 + - name: Foundry test cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - cache-on-failure: true - - name: forge fmt + path: | + ~/.foundry/cache + ~/.config/.foundry/cache + testdata/cache + testdata/out + # Use a unique key for each run to always update the cache. + key: ${{ runner.os }}-foundry-${{ matrix.name }}-${{ github.run_id }} + restore-keys: | + ${{ runner.os }}-foundry-${{ matrix.name }}- + - name: Seed signatures used by trace snapshots shell: bash - run: ./.github/scripts/format.sh --check + run: | + python3 - <<'PYTHON' + import json + from pathlib import Path - crate-checks: - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: taiki-e/install-action@cargo-hack - - uses: Swatinem/rust-cache@v2 + path = Path.home() / ".foundry/cache/signatures" + cache = json.loads(path.read_text()) if path.exists() else {} + fixtures = json.loads(Path("testdata/fixtures/trace-signatures.json").read_text()) + for kind, signatures in fixtures.items(): + cache.setdefault(kind, {}).update(signatures) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(cache)) + PYTHON + - uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11 + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: - cache-on-failure: true - - run: cargo hack check - - deny: - uses: ithacaxyz/ci/.github/workflows/deny.yml@main - - ci-success: - runs-on: ubuntu-latest - if: always() - needs: - - nextest - - docs - - doctest - - typos - - clippy - - rustfmt - - forge-fmt - - crate-checks - - deny - timeout-minutes: 30 - steps: - - name: Decide whether the needed jobs succeeded or failed - uses: re-actors/alls-green@release/v1 + cache-bin: false + - uses: taiki-e/install-action@4cef1412cce204788f482e778a0b9187f9626a29 # v2.87.21 with: - jobs: ${{ toJSON(needs) }} + tool: nextest + - name: Setup Git config + run: | + git config --global user.name "GitHub Actions Bot" + git config --global user.email "<>" + git config --global url."https://github.com/".insteadOf "git@github.com:" + - name: Test + env: + SVM_TARGET_PLATFORM: ${{ matrix.svm_target_platform }} + ETHERSCAN_KEY: ${{ secrets.ETHERSCAN_API_KEY }} + ARBITRUM_RPC: ${{ secrets.ARBITRUM_RPC }} + ETH_SEPOLIA_RPC: ${{ secrets.ETH_SEPOLIA_RPC }} + TEMPO_MAINNET_RPC_URL: ${{ secrets.TEMPO_MAINNET_RPC_URL }} + TEMPO_TESTNET_RPC_URL: ${{ secrets.TEMPO_TESTNET_RPC_URL }} + NEXTEST_FILTER: ${{ matrix.filter }} + NEXTEST_PARTITION: ${{ matrix.partition_arg }} + shell: bash + run: | + args=(-E "$NEXTEST_FILTER") + if [[ -n "$NEXTEST_PARTITION" ]]; then + args+=(--partition "$NEXTEST_PARTITION") + fi + args+=(--no-fail-fast) + cargo nextest run --locked --features base,monad "${args[@]}" diff --git a/.gitignore b/.gitignore index 64af508ada43c..d0a3f60fed0f4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,27 @@ .DS_STORE /target* +/*.sol +CLAUDE.md + +# Foundry artifacts out/ snapshots/ -out.json + +# IDEs and extensions .idea -.vscode +**/.vscode/* +!/.vscode/launch.json +!/.vscode/tasks.json +!/editors/vscode/.vscode/launch.json +!/editors/vscode/.vscode/tasks.json .claude -CLAUDE.md +.zed + .env node_modules -dist -bin -_ \ No newline at end of file +_ +*.tgz +.vercel +.vite +.wrangler +*.zip diff --git a/.pinact.yaml b/.pinact.yaml new file mode 100644 index 0000000000000..2fb54b202e65f --- /dev/null +++ b/.pinact.yaml @@ -0,0 +1,10 @@ +version: 3 + +rules: + # Tempo's shared workflows are SHA-pinned but do not publish releases that + # pinact can use for version-comment or minimum-age verification. + - ignore: true + conditions: + - expr: | + ActionRepoFullName == "tempoxyz/gh-actions" + - expr: ActionVersion matches "^[0-9a-f]{40}$" diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000000000..00a3128d3656f --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,22 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Foundry Solidity Extension", + "type": "extensionHost", + "request": "launch", + "runtimeExecutable": "${execPath}", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}/editors/vscode", + "--user-data-dir=${workspaceFolder}/target/editor-dev/vscode-user-data", + "--extensions-dir=${workspaceFolder}/target/editor-dev/vscode-extensions", + "--disable-extensions", + "--skip-welcome", + "--skip-release-notes", + "${workspaceFolder}/target/editor-dev/project" + ], + "outFiles": ["${workspaceFolder}/editors/vscode/out/**/*.js"], + "preLaunchTask": "editors: prepare development host" + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000000000..62de0616b2c6f --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,41 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "editors: build forge", + "type": "process", + "command": "cargo", + "args": ["build", "--locked", "-p", "forge", "--bin", "forge"], + "problemMatcher": ["$rustc"] + }, + { + "label": "editors: install vscode dependencies", + "type": "process", + "command": "npm", + "args": ["ci"], + "options": {"cwd": "${workspaceFolder}/editors/vscode"}, + "problemMatcher": [] + }, + { + "label": "editors: compile vscode", + "type": "process", + "command": "npm", + "args": ["run", "compile"], + "options": {"cwd": "${workspaceFolder}/editors/vscode"}, + "problemMatcher": ["$tsc"] + }, + { + "label": "editors: prepare development host", + "type": "process", + "command": "node", + "args": ["${workspaceFolder}/editors/prepare-vscode.mjs"], + "dependsOrder": "sequence", + "dependsOn": [ + "editors: build forge", + "editors: install vscode dependencies", + "editors: compile vscode" + ], + "problemMatcher": [] + } + ] +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000000..98c4d13c2f169 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,242 @@ +# AGENTS.md + +Guidance for AI coding agents working in this repository. + +## Project Overview + +Foundry is a fast, portable, modular toolkit for Ethereum application development, +written in Rust. + +- `forge`: build, test, fuzz, debug, lint, and deploy Solidity contracts +- `cast`: command-line utilities for EVM contracts, transactions, and chain data +- `anvil`: local Ethereum development node +- `chisel`: Solidity REPL + +The repository is a Cargo workspace. Core crates live under `crates/`, Solidity +fixtures and integration test projects live under `testdata/`, and the +[developer documentation](docs/dev/README.md) defines documentation ownership +and indexes maintained cross-crate guides. + +## Commands + +```bash +cargo build --workspace # Build the workspace +cargo nextest run --workspace # Run tests +cargo +nightly fmt --all # Format Rust code +cargo +nightly fmt --all -- --check # Check Rust formatting +cargo +nightly clippy --workspace --all-targets --all-features # Lint Rust code +cargo deny check # Check dependencies +cargo shear # Check unused dependencies +``` + +Rust formatting uses nightly. + +## Architecture + +- `crates/forge`: Forge CLI and test/build workflows +- `crates/cast`: Cast CLI commands +- `crates/anvil`: local Ethereum node +- `crates/chisel`: Solidity REPL +- `crates/cheatcodes`: Forge cheatcode definitions and implementations +- `crates/common`: shared CLI, shell, compile, and terminal utilities +- `crates/config`: Foundry configuration +- `crates/debugger`: debugger support +- `crates/lint`: Solidity linter +- `crates/script`: script execution support +- `crates/verify`: contract verification support + +Foundry's EVM execution tooling is built around `revm`. Cheatcodes are calls to +the fixed cheatcode address and are dispatched through the cheatcode inspector. +For custom network work, follow the ownership, state-lifecycle, tool-dispatch, +and CI checklist in [`docs/dev/networks.md`](docs/dev/networks.md). + +For symbolic execution work under `crates/evm/symbolic`, read +`crates/evm/symbolic/AGENTS.md` before editing. + +## Testing + +- Add tests for code changes that fix behavior or add functionality. +- Use focused unit tests for small pure logic. +- Use integration tests for CLI behavior and larger workflows. +- Tests that use forking must contain `fork` in their name. +- Forge integration fixtures live under `testdata/`. +- Lint rule tests live under `crates/lint/testdata/` with blessed `.stderr` + output. + +For CLI and integration tests: + +- Put Forge CLI coverage under `crates/forge/tests/cli/` and Cast CLI coverage + under `crates/cast/tests/cli/`. +- Use the existing `#[forgetest]`, `#[forgetest_init]`, and `#[casttest]` + macros to create isolated test projects and command handles. +- Assert command output with snapbox helpers such as `assert_success()`, + `assert_failure()`, `stdout_eq(str![...])`, `stderr_eq(str![...])`, and + `assert_empty_stdout()`. +- For JSON output, use `assert_json_stdout(...)` or `assert_json_stderr(...)` + so comparisons are parsed as JSON and unordered where appropriate. +- Prefer full output snapshots with redactions over ad hoc `String::contains` + checks or manual `serde_json::Value` inspection. + +For lint rules: + +- Follow `docs/dev/lintrules.md#lint-writing-style` and `crates/lint/docs/README.md` for + lint names, diagnostic wording, and reference-page structure. +- Update `crates/lint/docs/.md` in the same Foundry PR as the lint. The Book's + `import:lints` workflow generates the published reference pages and navigation. +- Add a Solidity test file under `crates/lint/testdata/`. +- Use `//~WARN:` and `//~NOTE:` annotations for expected diagnostics. +- Regenerate blessed output with `cargo bless-lints`. +- Run lint UI tests with `cargo nextest run -p forge --test ui`. + +For script work, keep the two execution phases separate: `ScriptArgs::execute` +runs the script, while on-chain simulation only executes the collected +broadcastable transactions. `--resume` resumes publishing transactions; it does +not recreate the original `--broadcast` state. + +For fuzz or invariant corpus coverage work, `forge test --showmap-out ` +replays persisted corpus entries and writes AFL `showmap`-style coverage files. + +## CLI Output + +Follow [`docs/dev/output-channels.md`](docs/dev/output-channels.md), the canonical +stdout/stderr contract. Use the `foundry_common::io` `sh_*` and `prompt!` macros; +workspace Clippy configuration forbids direct `std::print*` and `std::eprint*`. + +## Configuration + +When adding or changing a `foundry.toml` setting: + +1. Define the field and its documentation in `crates/config`, including an + explicit default and any required serde behavior. Keep related settings in a + dedicated nested config type when they form a coherent section. +2. Wire the setting through every command that consumes it. If a CLI flag can + override the setting, resolve precedence in one shared place and test config, + CLI, and combined behavior. +3. Add focused config parsing and serialization tests. Update the `forge config` + and default-config snapshots when the serialized surface changes. +4. For renamed or moved settings, preserve compatibility when practical and add + a targeted deprecation warning that points to the canonical key. Test aliases, + profiles, inheritance, environment variables, collisions, and malformed values + where those providers are affected. +5. Document the setting in `foundry-rs/book` under + `src/pages/config/reference/`, including its section, type, default, environment + variable when supported, behavior, and a valid TOML example. Update the config + reference navigation and `default-config.mdx` in the same documentation PR. +6. Keep CLI option text in the Rust clap definition; the book's CLI reference is + generated from command help and should not be edited by hand. + +Use the implementation, defaults, and tests as the source of truth. Do not merge +new user-facing configuration without the corresponding book update. + +## Cheatcodes + +When adding a cheatcode: + +1. Add the Solidity definition in `crates/cheatcodes/spec/src/vm.rs`. +2. Implement the generated call type in `crates/cheatcodes/`. +3. Update `spec::Cheatcodes::new` if `Vm` gained a struct, enum, error, or event. +4. Run `cargo cheats` twice to update generated JSON assets. +5. Add an integration test under `testdata/default/cheats/`. + +Cheatcode functions and structs must be documented and function parameters must +be named. + +## Commit and PR Style + +Default format is conventional commits: + +```text +type: description +type(scope): description +type(scope)!: breaking description +``` + +Use `feat`, `fix`, `perf`, `chore`, `docs`, `test`, or `refactor`. Check recent +`git log` output before committing to match the repository's current style. + +- Use imperative mood. +- Keep the description under 50 characters when practical. +- Do not end the description with a period. +- Include a body for performance changes, bug fixes, and complex changes. +- For performance changes, include measurements. +- PR titles should follow the same format as commit messages. + +PR descriptions should explain what changed and why in flowing prose. Link +related issues and PRs when they exist. Include only real measurements, and do +not include validation/testing boilerplate such as "Validated with", "Tested +with", or command lists unless explicitly requested. Do not use templates, +bullet lists, or long essays. When writing PR bodies from scripts, use a file or +heredoc with real newlines; never pass escaped `\n` sequences. + +### Performance PRs + +When drafting or updating a PR body for a performance-related change, benchmark +the feature branch against `master` or the user-specified base before writing the +performance claims. + +- Use the local benchmark runners under `benches/` unless the user explicitly + asks for GitHub Actions or the Derek/decofe automation. +- Use `foundry-bench` when the claim is about elapsed time for a Foundry command + on an existing Solidity project: `forge build`, cached rebuilds, `forge test`, + fuzz-test replay, isolated tests, coverage, or focused symbolic tests. +- For invariant or campaign-style benchmarking, use `foundry-scfuzzbench`; this + is the local equivalent of the `derek bench invariant`/`decofe bench + invariant` PR flow, which publishes a `scfuzzbench` event. +- The local runners do not compare two local refs in one invocation. Run the + baseline and candidate separately, with identical benchmark inputs, timeout, + worker count, environment, target repository, and output schema. +- For branch-vs-base PR comparisons, use the profiling profile + (`FOUNDRY_BENCH_LOCAL_BUILD_PROFILE=profiling`) rather than an ad hoc debug or + release build. Keep ordinary `foundry-bench --versions local` comparisons on + the default release distribution profile. +- Include only benchmarks that exercise the changed path. Do not pad the PR body + with unrelated benchmark suites. +- Report both wall-time results and domain counters when available, for example + solver queries, reported solver time, throughput, coverage relscore/relcov, + or invariant findings. +- If results are neutral, noisy, or regress a secondary metric, state that + directly. Do not convert noise into a performance claim. +- Keep the PR body short: one paragraph explaining the optimization and why it + is correct, followed by a `### Results` table. +- Exact benchmark commands and result-table mechanics in `benches/README.md`. + +## Notes + +- The fragment-based changelog workflow has been removed. Do not create or + restore `.changelog/` or add changelog fragment files; release notes are + generated from pull requests. +- Use `RUST_LOG=` for debugging CLI internals, for example + `RUST_LOG=forge` or `RUST_LOG=cast`. +- Do not send spelling-only or grammar-only documentation PRs. +- Keep release feature lists aligned between the root `Makefile` and release + workflows when changing published CLI feature surfaces. + +## Code Style + +- Comments end with periods (except URLs) +- Files end with LF and trailing newline +- Follow existing patterns +- Never expose secrets + +### Rust + +- Generally add new Rust functions, methods, `impl` blocks, modules, imports, Cargo dependencies, and other items at the bottom of the relevant scope, section, or group. Constructors usually go at the top of an `impl` block. First check where similar items sit in the file and match the existing order, grouping, and style, including alphabetical order where used. +- Put doc comments before attributes, always: `/// ...` comes before `#[derive]`, `#[inline]`, `#[cfg]`, and every other attribute. +- Put module documentation at the top of the module file with inner doc comments (`//! ...`), not on the `mod` item in the parent module. +- NEVER put imports inside functions unless required for `#[cfg(...)]` gating. All imports go at the top of the file. +- Group all `use` imports together. Keep `pub use` imports in a separate group. For local module re-exports, write `mod x;` before `pub use x;`; for re-exporting another module or external crate, use `use x;`, then a blank line, then `pub use y;`, then a blank line before local `mod my_mod; pub use my_mod::*;`. +- Put imports used only by tests inside the relevant `#[cfg(test)]` module, merging them into its ordinary imports instead of adding `#[cfg(test)] use` items to the parent. Keep any additional feature or platform conditions on those imports. Retain parent-level test-gated imports or re-exports only when test-only helpers or multiple test modules need them there. +- Keep crate-level dependency anchors such as `#[cfg(test)] use cc as _;` at crate scope. +- Put conditional imports after unconditional imports, separated by a blank line. Group imports with the same `#[cfg(...)]` condition together, with a blank line between different conditions. Apply this to every feature, platform, and test gate, including imports in nested modules. Within each group, merge imports from the same crate when their conditions and other attributes match. Keep the full condition on each `use`; do not introduce import-only modules or macros to avoid repeated attributes. Apply the same ordering within the separate `pub use` group, keeping local re-exports after their module declarations. +- In test modules, always import the parent module with `use super::*`. +- In `Cargo.toml`, generally group optional dependencies for a feature together. Put a comment immediately above the group containing only the feature name, for example `# jit`. +- Prefer `let Some(x) = x else { return };` / `let Ok(x) = x else { return };` over `match x { Some(x) => x, _ => return }`. +- Use `let ... else` only for a single early-exit guard. When multiple conditions or patterns gate the same block, prefer a combined `if let` / `let` chain instead of several sequential `let ... else` statements. +- Use combined `if let` chains (`if let Some(x) = x && let Some(y) = y { ... }`) instead of nesting (`if let Some(x) = x { if let Some(y) = y { ... } }`). +- In loops, prefer an `if let` chain around the loop body over multiple `let ... else { continue };` statements when the body only runs if all patterns match. +- NEVER use `ref` / `ref mut` in patterns as the first resort. Always prefer borrowing the expression with `&` / `&mut` instead. +- Prefer map entry APIs such as `entry`, `or_insert`, and `or_insert_with` when multiple consecutive operations would otherwise look up and then insert or update the same key. +- Avoid specifying type hints in variables unless absolutely necessary (e.g. `HashMap<_, Vec<_>>` for `x.entry(y).or_default().push(z)` where type inference won't work). Rely on the compiler. +- When type hints are needed, prefer turbofish (`let x = Type::::new()`) over annotation (`let x: Type = Type::new()`). +- In tests, avoid `.contains` assertions for error/output strings when the project has snapshot testing support such as `snapbox`. Prefer exact snapshot assertions (`stderr_eq`, `stdout_eq`, `assert_data_eq!`, etc.) and use redactions only for genuinely variable parts. +- Always leave a blank line in between module doc-comments, items or item categories, unless in rare exceptions: it's a one-shot struct with one single impl block, or it's a list of impls that are all very similar. But in general blank line in between items is the norm but it's just unenforced. Items includes imports (together) too. The previous rules apply first. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000000000..47dc3e3d863cf --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c7e77044151b7..424bf5ba683fc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,8 +33,7 @@ around bugs and participate in reviewing PRs. ### Contributions Related to Spelling and Grammar -At this time, we will not be accepting contributions that only fix spelling or grammatical errors in documentation, code or -elsewhere. +At this time, we will not be accepting contributions that only fix spelling or grammatical errors in documentation, code or elsewhere. ### Asking for help @@ -82,49 +81,31 @@ the likelihood of the PR getting merged. Please also make sure that the following commands pass if you have changed the code: -```sh -cargo check --all -cargo test --all --all-features -cargo +nightly fmt -- --check -cargo +nightly clippy --all --all-targets --all-features -- -D warning -``` - -or alternatively: - ```sh make build make pr ``` -If you are working in VSCode, we recommend you install the [rust-analyzer](https://rust-analyzer.github.io/) extension, and use the following VSCode user settings: - -```json -"editor.formatOnSave": true, -"rust-analyzer.rustfmt.extraArgs": ["+nightly"], -"[rust]": { - "editor.defaultFormatter": "rust-lang.rust-analyzer" -} -``` +Contributor setup, editor configuration, validation commands, and maintained +implementation guides are indexed in the [developer documentation](docs/dev/README.md). If you are working on a larger feature, we encourage you to open up a draft pull request, to make sure that other contributors are not duplicating work. -If you would like to test the binaries built from your change, see [foundryup](https://github.com/foundry-rs/foundry/tree/master/foundryup). - -If you would like to use a debugger with breakpoints to debug a patch you might be working on, keep in mind we currently strip debug info for faster builds, which is _not_ the default. Therefore, to use a debugger, you need to enable it on the workspace [`Cargo.toml`'s `dev` profile](https://github.com/foundry-rs/foundry/tree/master/Cargo.toml#L15-L18). +If you would like to test the binaries built from your change, see [foundryup](https://github.com/foundry-rs/foundry/tree/HEAD/foundryup). -#### Adding tests +If you would like to use a debugger with breakpoints to debug a patch you might be working on, keep in mind we currently strip debug info for faster builds, which is _not_ the default. Therefore, to use a debugger, you need to enable it on the workspace [`Cargo.toml`'s `dev` profile](https://github.com/foundry-rs/foundry/tree/HEAD/Cargo.toml#L136-L139). -If the change being proposed alters code, it is either adding new functionality to Foundry, or fixing existing, broken functionality. -In both of these cases, the pull request should include one or more tests to ensure that Foundry does not regress -in the future. +#### Output channels (stdout vs. stderr) -Types of tests include: +User-facing output must follow the canonical +[stdout/stderr contract](docs/dev/output-channels.md). It also records the +required `foundry_common::io` macros and the current per-command migration state. -- **Unit tests**: Functions which have very specific tasks should be unit tested. -- **Integration tests**: For general purpose, far reaching functionality, integration tests should be added. - The best way to add a new integration test is to look at existing ones and follow the style. +#### Adding tests -Tests that use forking must contain "fork" in their name. +Code changes should include focused unit or integration coverage. The +[developer documentation](docs/dev/README.md#setup-and-validation) is the +canonical entry point for test commands and repository-specific test rules. #### Commits diff --git a/Cargo.lock b/Cargo.lock index 48f6e8efe1791..053b2926053d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "addr2line" -version = "0.24.2" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" dependencies = [ "gimli", ] @@ -17,6 +17,26 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", +] + [[package]] name = "aes" version = "0.8.4" @@ -24,8 +44,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", - "cipher", - "cpufeatures", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead 0.5.2", + "aes", + "cipher 0.4.4", + "ctr", + "ghash", + "subtle", ] [[package]] @@ -35,7 +69,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", - "getrandom 0.3.3", + "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -43,36 +77,85 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.3" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "allocator-api2" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "alloy" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a08ebfa51fdc243e0c6a662f5cf3428f04d1ad858e3fe77238cc78063eb86d" +dependencies = [ + "alloy-consensus", + "alloy-contract", + "alloy-core", + "alloy-eips", + "alloy-ens", + "alloy-genesis", + "alloy-network", + "alloy-provider", + "alloy-rpc-client", + "alloy-rpc-types", + "alloy-serde", + "alloy-signer", + "alloy-signer-local", + "alloy-transport", + "alloy-transport-http", + "alloy-trie", +] + [[package]] name = "alloy-chains" -version = "0.2.6" +version = "0.2.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4195a29a4b87137b2bb02105e746102873bc03561805cf45c0e510c961f160e6" +checksum = "c1c4f6f6f020f38a4ef079428b5c7009e913bf8102e4d65a90c6cb2cf93800eb" dependencies = [ "alloy-primitives", + "alloy-rlp", "num_enum", + "phf 0.14.0", "serde", - "strum 0.27.2", ] [[package]] name = "alloy-consensus" -version = "1.0.22" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3b746060277f3d7f9c36903bb39b593a741cb7afcb0044164c28f0e9b673f0" +checksum = "727016fed834c2392d014dd697ea473f3e47bcec31ba31ccffa9c1d18ed904e5" dependencies = [ "alloy-eips", "alloy-primitives", @@ -80,38 +163,42 @@ dependencies = [ "alloy-serde", "alloy-trie", "alloy-tx-macros", + "arbitrary", "auto_impl", + "borsh", "c-kzg", - "derive_more 2.0.1", + "derive_more", "either", "k256", "once_cell", - "rand 0.8.5", + "rand 0.8.8", "secp256k1 0.30.0", "serde", + "serde_json", "serde_with", - "thiserror 2.0.12", + "thiserror 2.0.21", ] [[package]] name = "alloy-consensus-any" -version = "1.0.22" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf98679329fa708fa809ea596db6d974da892b068ad45e48ac1956f582edf946" +checksum = "6ea5ae60bbb3ce9dc52685a70a3755ad4432045fd38079b763cfe5127c706134" dependencies = [ "alloy-consensus", "alloy-eips", "alloy-primitives", "alloy-rlp", "alloy-serde", + "arbitrary", "serde", ] [[package]] name = "alloy-contract" -version = "1.0.22" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a10e47f5305ea08c37b1772086c1573e9a0a257227143996841172d37d3831bb" +checksum = "8c807c974ae5ac07fa98431310b498d85f029da0d2f9a088b3ea673e5e33bb81" dependencies = [ "alloy-consensus", "alloy-dyn-abi", @@ -127,22 +214,37 @@ dependencies = [ "futures", "futures-util", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.21", + "tokio", + "tracing", + "wasmtimer", +] + +[[package]] +name = "alloy-core" +version = "1.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6a70852055d66af821d28497e690c95950020ed1ee4c7a0ab9b02a01ac75ee3" +dependencies = [ + "alloy-dyn-abi", + "alloy-json-abi", + "alloy-primitives", + "alloy-rlp", + "alloy-sol-types", ] [[package]] name = "alloy-dyn-abi" -version = "1.2.1" +version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b95b3deca680efc7e9cba781f1a1db352fa1ea50e6384a514944dcf4419e652" +checksum = "14b3b9990889736af898bd4e51f7d0c0228d6a9b9c3a5fecb3e6b25a7abc791c" dependencies = [ "alloy-json-abi", "alloy-primitives", "alloy-sol-type-parser", "alloy-sol-types", "arbitrary", - "derive_arbitrary", - "derive_more 2.0.1", + "derive_more", "itoa", "proptest", "serde", @@ -158,120 +260,179 @@ checksum = "741bdd7499908b3aa0b159bba11e71c8cddd009a2c2eb7a06e825f1ec87900a5" dependencies = [ "alloy-primitives", "alloy-rlp", + "arbitrary", "crc", + "rand 0.8.8", "serde", - "thiserror 2.0.12", + "thiserror 2.0.21", ] [[package]] name = "alloy-eip2930" -version = "0.2.1" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b82752a889170df67bbb36d42ca63c531eb16274f0d7299ae2a680facba17bd" +checksum = "e64579d931b3f8eacc7c9ab0b220e87e9c4816e5c724ede1947b55c2f8e92ae5" dependencies = [ "alloy-primitives", "alloy-rlp", + "arbitrary", + "borsh", + "rand 0.8.8", + "serde", +] + +[[package]] +name = "alloy-eip5792" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98353b4e204670901abc1ada1fa47ffecb778c0fdf38e8426e2d32d32e1a3556" +dependencies = [ + "alloy-primitives", + "alloy-serde", "serde", + "serde_json", ] [[package]] name = "alloy-eip7702" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d4769c6ffddca380b0070d71c8b7f30bed375543fe76bb2f74ec0acf4b7cd16" +checksum = "2919c5a56a1007492da313e7a3b6d45ef5edc5d33416fdec63c0d7a2702a0d20" dependencies = [ "alloy-primitives", "alloy-rlp", + "arbitrary", + "borsh", "k256", + "rand 0.8.8", + "serde", + "thiserror 2.0.21", +] + +[[package]] +name = "alloy-eip7928" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a12e1dc70236cfb8e79b96dd8544aed47c39e2f45dc6b51c3703e969e052faa" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "arbitrary", + "borsh", + "once_cell", + "serde", + "thiserror 2.0.21", +] + +[[package]] +name = "alloy-eip8141" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36422c14bd57dcd638e276c8c9d7ac09eb16598e3a9dbd34e4b55267d4565b5e" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "arbitrary", + "borsh", "serde", - "thiserror 2.0.12", + "thiserror 2.0.21", ] [[package]] name = "alloy-eips" -version = "1.0.22" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f562a81278a3ed83290e68361f2d1c75d018ae3b8589a314faf9303883e18ec9" +checksum = "e01905444bd2fc330673f11d2466c0f54e8488f7ce3132b6a5e6497916fca765" dependencies = [ "alloy-eip2124", "alloy-eip2930", "alloy-eip7702", + "alloy-eip7928", + "alloy-eip8141", "alloy-primitives", "alloy-rlp", "alloy-serde", + "arbitrary", "auto_impl", + "borsh", "c-kzg", - "derive_more 2.0.1", + "derive_more", "either", + "ethereum_ssz", + "ethereum_ssz_derive", "serde", + "serde_with", "sha2 0.10.9", ] [[package]] name = "alloy-ens" -version = "1.0.22" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb0cccb56364fd3ba0b886370d030e8056ea118e2c35a8f0d1292361fd40d00b" +checksum = "22fad0811d511f8bb0d64599b9ea885282f61be924e36cee3362667115482e2b" dependencies = [ "alloy-contract", "alloy-primitives", "alloy-provider", "alloy-sol-types", "async-trait", - "thiserror 2.0.12", + "thiserror 2.0.21", ] [[package]] name = "alloy-evm" -version = "0.14.0" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef2d6e0448bfd057a4438226b3d2fd547a0530fa4226217dfb1682d09f108bd4" +checksum = "7b6bd5f7e5ce9858bac99275e3f548c3b63bb3e5806da0142dc0b2f518d3ec4a" dependencies = [ "alloy-consensus", "alloy-eips", "alloy-hardforks", "alloy-primitives", + "alloy-rpc-types-engine", "alloy-rpc-types-eth", "alloy-sol-types", "auto_impl", - "derive_more 2.0.1", - "op-alloy-consensus 0.18.11", - "op-revm", + "derive_more", "revm", - "thiserror 2.0.12", + "thiserror 2.0.21", + "tracing", ] [[package]] name = "alloy-genesis" -version = "1.0.22" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc41384e9ab8c9b2fb387c52774d9d432656a28edcda1c2d4083e96051524518" +checksum = "53cfa407c1d874cb306187996e30eb551bd98025e60e913e69e298ed2c8ea045" dependencies = [ "alloy-eips", "alloy-primitives", "alloy-serde", "alloy-trie", + "borsh", "serde", + "serde_with", ] [[package]] name = "alloy-hardforks" -version = "0.2.12" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819a3620fe125e0fff365363315ee5e24c23169173b19747dfd6deba33db8990" +checksum = "09d5ff0394d9547798fa4b666c46e356d09a113f33d45fb3d75175681c1e2652" dependencies = [ "alloy-chains", "alloy-eip2124", "alloy-primitives", "auto_impl", "dyn-clone", + "serde", ] [[package]] name = "alloy-json-abi" -version = "1.2.1" +version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15516116086325c157c18261d768a20677f0f699348000ed391d4ad0dcb82530" +checksum = "858acd7fdad1e4a7057fd9c1b39c1f2cd6bcd57ebf3c56e2853c02ead049e816" dependencies = [ "alloy-primitives", "alloy-sol-type-parser", @@ -281,24 +442,36 @@ dependencies = [ [[package]] name = "alloy-json-rpc" -version = "1.0.22" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12c454fcfcd5d26ed3b8cae5933cbee9da5f0b05df19b46d4bd4446d1f082565" +checksum = "fff9cb760601d352d1e59aab5803c3fb6dc99824b6883f5c7a781aacd4b54c66" dependencies = [ "alloy-primitives", "alloy-sol-types", - "http 1.3.1", + "http 1.5.0", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.21", "tracing", ] +[[package]] +name = "alloy-monad-evm" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58ec5208b6ce98ea20d2bc681a681f888bf7a08b630c4ca7df791198ea80f283" +dependencies = [ + "alloy-evm", + "alloy-primitives", + "monad-revm", + "revm", +] + [[package]] name = "alloy-network" -version = "1.0.22" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d6d39eabe5c7b3d8f23ac47b0b683b99faa4359797114636c66e0743103d05" +checksum = "e11dfba13dac9e54576bf41e1b7461491367e0bce6a2a4293b9e40ffa3ebd5e9" dependencies = [ "alloy-consensus", "alloy-consensus-any", @@ -313,18 +486,18 @@ dependencies = [ "alloy-sol-types", "async-trait", "auto_impl", - "derive_more 2.0.1", + "derive_more", "futures-utils-wasm", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.21", ] [[package]] name = "alloy-network-primitives" -version = "1.0.22" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3704fa8b7ba9ba3f378d99b3d628c8bc8c2fc431b709947930f154e22a8368b6" +checksum = "3819bf6794d25f679d42c3f2b168e12cae08c56bb41d393cb6249b55e6d5933e" dependencies = [ "alloy-consensus", "alloy-eips", @@ -335,9 +508,8 @@ dependencies = [ [[package]] name = "alloy-op-evm" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98354b9c3d50de701a63693d5b6a37e468a93b970b2224f934dd745c727ef998" +version = "0.32.0" +source = "git+https://github.com/foundry-rs/optimism?rev=206d26cedb9d861b983d89fc71e1122187dfa877#206d26cedb9d861b983d89fc71e1122187dfa877" dependencies = [ "alloy-consensus", "alloy-eips", @@ -345,58 +517,61 @@ dependencies = [ "alloy-op-hardforks", "alloy-primitives", "auto_impl", - "op-alloy-consensus 0.18.11", + "op-alloy", "op-revm", "revm", + "thiserror 2.0.21", ] [[package]] name = "alloy-op-hardforks" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2090f21bb6df43e147d976e754bc9a007ca851badbfc6685377aa679b5f151d9" +version = "0.5.0" +source = "git+https://github.com/foundry-rs/optimism?rev=206d26cedb9d861b983d89fc71e1122187dfa877#206d26cedb9d861b983d89fc71e1122187dfa877" dependencies = [ "alloy-chains", "alloy-hardforks", + "alloy-primitives", "auto_impl", + "serde", ] [[package]] name = "alloy-primitives" -version = "1.2.1" +version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6177ed26655d4e84e00b65cb494d4e0b8830e7cae7ef5d63087d445a2600fb55" +checksum = "d5e9dbd49258ac3ab893a481d46be29b58be7f734dcac46cd80b6b13ee36566c" dependencies = [ "alloy-rlp", "arbitrary", "bytes", "cfg-if", "const-hex", - "derive_arbitrary", - "derive_more 2.0.1", - "foldhash", - "getrandom 0.3.3", - "hashbrown 0.15.4", - "indexmap 2.10.0", + "derive_more", + "fixed-cache", + "foldhash 0.2.0", + "getrandom 0.4.3", + "hashbrown 0.17.1", + "indexmap 2.14.2", "itoa", "k256", "keccak-asm", "paste", "proptest", - "proptest-derive", - "rand 0.9.1", + "proptest-derive 0.8.0", + "rand 0.9.5", + "rapidhash", "ruint", - "rustc-hash 2.1.1", + "rustc-hash", + "secp256k1 0.31.1", "serde", - "sha3", - "tiny-keccak", + "sha3 0.11.0", ] [[package]] name = "alloy-provider" -version = "1.0.22" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08800e8cbe70c19e2eb7cf3d7ff4b28bdd9b3933f8e1c8136c7d910617ba03bf" +checksum = "9f8250dbbc93fab5b00fe0d3c1db3ede67d2a5fdbae8255de70b34ae39da244b" dependencies = [ "alloy-chains", "alloy-consensus", @@ -424,14 +599,14 @@ dependencies = [ "either", "futures", "futures-utils-wasm", - "http 1.3.1", - "lru 0.13.0", + "http 1.5.0", + "lru", "parking_lot", - "pin-project 1.1.10", - "reqwest", + "pin-project", + "reqwest 0.13.5", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.21", "tokio", "tracing", "url", @@ -440,13 +615,14 @@ dependencies = [ [[package]] name = "alloy-pubsub" -version = "1.0.22" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae68457a2c2ead6bd7d7acb5bf5f1623324b1962d4f8e7b0250657a3c3ab0a0b" +checksum = "fcf12a49e488baefa21619f3ec2ce39f20857153aa1e91619fb5c1ce4d69d893" dependencies = [ "alloy-json-rpc", "alloy-primitives", "alloy-transport", + "auto_impl", "bimap", "futures", "parking_lot", @@ -461,9 +637,9 @@ dependencies = [ [[package]] name = "alloy-rlp" -version = "0.3.12" +version = "0.3.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f70d83b765fdc080dbcd4f4db70d8d23fe4761f2f02ebfa9146b833900634b4" +checksum = "24671b1f62edcf0f9b62994c7bf72cd621a04a4b99f5020ece1a647b40e2f103" dependencies = [ "alloy-rlp-derive", "arrayvec", @@ -472,20 +648,20 @@ dependencies = [ [[package]] name = "alloy-rlp-derive" -version = "0.3.12" +version = "0.3.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64b728d511962dda67c1bc7ea7c03736ec275ed2cf4c35d9585298ac9ccf3b73" +checksum = "9d4311c03125e8a18296504560b9de3d75ecbd0dcda7f71e6cf2a196d57e6fba" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] name = "alloy-rpc-client" -version = "1.0.22" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162301b5a57d4d8f000bf30f4dcb82f9f468f3e5e846eeb8598dd39e7886932c" +checksum = "1f73519dc8f4ab73dafe90ac47aec4b9055c421c762d770d911eed83fcbef28d" dependencies = [ "alloy-json-rpc", "alloy-primitives", @@ -495,8 +671,8 @@ dependencies = [ "alloy-transport-ipc", "alloy-transport-ws", "futures", - "pin-project 1.1.10", - "reqwest", + "pin-project", + "reqwest 0.13.5", "serde", "serde_json", "tokio", @@ -509,12 +685,13 @@ dependencies = [ [[package]] name = "alloy-rpc-types" -version = "1.0.22" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cd8ca94ae7e2b32cc3895d9981f3772aab0b4756aa60e9ed0bcfee50f0e1328" +checksum = "e30da7fc40ff208becb4ebe462a149045c8613f60ee84f65731a522c67c351fd" dependencies = [ "alloy-primitives", "alloy-rpc-types-anvil", + "alloy-rpc-types-debug", "alloy-rpc-types-engine", "alloy-rpc-types-eth", "alloy-rpc-types-trace", @@ -525,9 +702,9 @@ dependencies = [ [[package]] name = "alloy-rpc-types-anvil" -version = "1.0.22" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f3ff6a778ebda3deaed9af17930d678611afe1effa895c4260b61009c314f82" +checksum = "ad4774d898cc10846f65c426a385071f3e2872876d7f0e7836f1f75ffa54d448" dependencies = [ "alloy-primitives", "alloy-rpc-types-eth", @@ -537,48 +714,73 @@ dependencies = [ [[package]] name = "alloy-rpc-types-any" -version = "1.0.22" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "076b47e834b367d8618c52dd0a0d6a711ddf66154636df394805300af4923b8a" +checksum = "9c5d320312e4a9481b18185429e57fa161dbf9468414ba5667aff4ebe7bd37e7" dependencies = [ "alloy-consensus-any", + "alloy-network-primitives", + "alloy-primitives", "alloy-rpc-types-eth", "alloy-serde", + "serde", + "serde_json", +] + +[[package]] +name = "alloy-rpc-types-beacon" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01f0837510a7e7789f99cf86bc0d0e3b54cfa663f25de803dad613364eef9fd3" +dependencies = [ + "alloy-eips", + "alloy-primitives", + "alloy-rpc-types-engine", + "derive_more", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.21", ] [[package]] name = "alloy-rpc-types-debug" -version = "1.0.22" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94a2a86ad7b7d718c15e79d0779bd255561b6b22968dc5ed2e7c0fbc43bb55fe" +checksum = "ce17ebd75d90c490897ca320e34bd5c95e0d8620d35ba5842aee9e7b5a90ea21" dependencies = [ "alloy-primitives", + "alloy-rlp", + "derive_more", "serde", + "serde_with", ] [[package]] name = "alloy-rpc-types-engine" -version = "1.0.22" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ba838417c42e8f1fe5eb4f4bbfacb7b5d4b9e615b8d2e831b921e04bf0bed62" +checksum = "77b967576e97165fed3fae1cf6adad964e192293e4ee61d9187afe3ecaa12323" dependencies = [ "alloy-consensus", "alloy-eips", "alloy-primitives", "alloy-rlp", "alloy-serde", - "derive_more 2.0.1", + "derive_more", + "ethereum_ssz", + "ethereum_ssz_derive", "jsonwebtoken", - "rand 0.8.5", + "rand 0.8.8", "serde", "strum 0.27.2", ] [[package]] name = "alloy-rpc-types-eth" -version = "1.0.22" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c2f847e635ec0be819d06e2ada4bcc4e4204026a83c4bfd78ae8d550e027ae7" +checksum = "32c475e21fe8a8caa02166b326ed22207a1a0597fc9b0dcdfb46e2fe6f80dadf" dependencies = [ "alloy-consensus", "alloy-consensus-any", @@ -588,32 +790,49 @@ dependencies = [ "alloy-rlp", "alloy-serde", "alloy-sol-types", + "alloy-trie", + "arbitrary", "itertools 0.14.0", "serde", "serde_json", "serde_with", - "thiserror 2.0.12", + "thiserror 2.0.21", +] + +[[package]] +name = "alloy-rpc-types-mev" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c230cc2e3f19a76889664522ffc10d746292f43fa1f57a3a9e20fe011346203" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-primitives", + "alloy-rpc-types-eth", + "alloy-serde", + "serde", + "serde_json", ] [[package]] name = "alloy-rpc-types-trace" -version = "1.0.22" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fc58180302a94c934d455eeedb3ecb99cdc93da1dbddcdbbdb79dd6fe618b2a" +checksum = "e3d8edefcef7d45a3da4bb869bbee1ee4c71b7437b22b87794f0912c89b5702c" dependencies = [ "alloy-primitives", "alloy-rpc-types-eth", "alloy-serde", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.21", ] [[package]] name = "alloy-rpc-types-txpool" -version = "1.0.22" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9f089d78bb94148e0fcfda087d4ce5fd35a7002847b5e90610c0fcb140f7b4" +checksum = "2fcacd9928d9788790afcd1d3fd8d0206329d2ef2391070867bafcc20ecf278f" dependencies = [ "alloy-primitives", "alloy-rpc-types-eth", @@ -623,20 +842,21 @@ dependencies = [ [[package]] name = "alloy-serde" -version = "1.0.22" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae699248d02ade9db493bbdae61822277dc14ae0f82a5a4153203b60e34422a6" +checksum = "4f44f297d3b9e3ca2c3039e3c0ed69acde9b35edda15381cf78f725b7263fbe2" dependencies = [ "alloy-primitives", + "arbitrary", "serde", "serde_json", ] [[package]] name = "alloy-signer" -version = "1.0.22" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cf7d793c813515e2b627b19a15693960b3ed06670f9f66759396d06ebe5747b" +checksum = "e7de6fd34b5ba1789d482fa800dd97219b34765d6b07633a989a24d2dace770e" dependencies = [ "alloy-dyn-abi", "alloy-primitives", @@ -644,34 +864,36 @@ dependencies = [ "async-trait", "auto_impl", "either", - "elliptic-curve", + "elliptic-curve 0.13.8", "k256", - "thiserror 2.0.12", + "thiserror 2.0.21", ] [[package]] name = "alloy-signer-aws" -version = "1.0.22" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2169ae52e6ec638abbf45ceae0315522eaa554778b3f40040a9c36af70a7bb80" +checksum = "eb90c0ccca083cbf40570ebf2672a2f2b4476a86b03830d44a7c84762ccf4a1d" dependencies = [ "alloy-consensus", "alloy-network", "alloy-primitives", "alloy-signer", "async-trait", + "aws-config", "aws-sdk-kms", + "aws-smithy-types", "k256", - "spki", - "thiserror 2.0.12", + "spki 0.7.3", + "thiserror 2.0.21", "tracing", ] [[package]] name = "alloy-signer-gcp" -version = "1.0.22" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1e61cac6f668f4783bae90224928150ac631979d903db129ddf00ca77d4f716" +checksum = "690e7c2edb82601a7e95720a4902a7a611a6cac04e01977b004611fa7795d454" dependencies = [ "alloy-consensus", "alloy-network", @@ -680,16 +902,16 @@ dependencies = [ "async-trait", "gcloud-sdk", "k256", - "spki", - "thiserror 2.0.12", + "spki 0.7.3", + "thiserror 2.0.21", "tracing", ] [[package]] name = "alloy-signer-ledger" -version = "1.0.22" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57b67bd231209051d428426a149fdcc4cbc2ab413161e667ef1ccd4f586ca8d1" +checksum = "9fe9aa4c081552fae845e4748cb7bc59466634680f1701044c1c963cc5c389fe" dependencies = [ "alloy-consensus", "alloy-dyn-abi", @@ -700,16 +922,16 @@ dependencies = [ "async-trait", "coins-ledger", "futures-util", - "semver 1.0.26", - "thiserror 2.0.12", + "semver 1.0.28", + "thiserror 2.0.21", "tracing", ] [[package]] name = "alloy-signer-local" -version = "1.0.22" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51a424bc5a11df0d898ce0fd15906b88ebe2a6e4f17a514b51bc93946bb756bd" +checksum = "d6909c09d586c7b55eced688754d57dde8a6bcbae2b83e69a9b9250e74cfd79e" dependencies = [ "alloy-consensus", "alloy-network", @@ -720,65 +942,82 @@ dependencies = [ "coins-bip39", "eth-keystore", "k256", - "rand 0.8.5", - "thiserror 2.0.12", + "rand 0.8.8", + "thiserror 2.0.21", + "zeroize", ] [[package]] name = "alloy-signer-trezor" -version = "1.0.22" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db971aeb5431f947f67d5cda772cb5f255cd38bee4051b7444cad2e7c590d1ee" +checksum = "0851dc61ac32d77c45c4cb11ede80caae0da770eec3f04df223604aece7b75ca" dependencies = [ "alloy-consensus", "alloy-network", "alloy-primitives", "alloy-signer", "async-trait", - "semver 1.0.26", - "thiserror 2.0.12", + "semver 1.0.28", + "thiserror 2.0.21", "tracing", "trezor-client", ] +[[package]] +name = "alloy-signer-turnkey" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65fd12d3f3af069407007c45491953c86ec1f1404de2cac88e3ca5fd7c60ce23" +dependencies = [ + "alloy-consensus", + "alloy-network", + "alloy-primitives", + "alloy-signer", + "async-trait", + "thiserror 2.0.21", + "tracing", + "turnkey_client", +] + [[package]] name = "alloy-sol-macro" -version = "1.2.1" +version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a14f21d053aea4c6630687c2f4ad614bed4c81e14737a9b904798b24f30ea849" +checksum = "60dd79f578c3912f1fc2a150dbeb8110b8cfb976c60f98ebf6de9d5da5965a2b" dependencies = [ "alloy-sol-macro-expander", "alloy-sol-macro-input", - "proc-macro-error2", + "proc-macro-error3", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] name = "alloy-sol-macro-expander" -version = "1.2.1" +version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34d99282e7c9ef14eb62727981a985a01869e586d1dec729d3bb33679094c100" +checksum = "9edb8520f2f94275e1caa73c85207dcc78ec402a9a4c429240f9a0783a8f16c0" dependencies = [ "alloy-json-abi", "alloy-sol-macro-input", "const-hex", "heck", - "indexmap 2.10.0", - "proc-macro-error2", + "indexmap 2.14.2", + "proc-macro-error3", "proc-macro2", "quote", - "syn 2.0.104", + "sha3 0.11.0", + "syn 2.0.119", "syn-solidity", - "tiny-keccak", ] [[package]] name = "alloy-sol-macro-input" -version = "1.2.1" +version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eda029f955b78e493360ee1d7bd11e1ab9f2a220a5715449babc79d6d0a01105" +checksum = "66af2d9344882172993be5f5cbfd349fdfa52cb548f7af8715b446fb35ef6001" dependencies = [ "alloy-json-abi", "const-hex", @@ -788,15 +1027,15 @@ dependencies = [ "proc-macro2", "quote", "serde_json", - "syn 2.0.104", + "syn 2.0.119", "syn-solidity", ] [[package]] name = "alloy-sol-type-parser" -version = "1.2.1" +version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10db1bd7baa35bc8d4a1b07efbf734e73e5ba09f2580fb8cee3483a36087ceb2" +checksum = "77c2a8abc3334044013b23d5e438cc5dc802e5544cc42b713d0840d5185967cc" dependencies = [ "serde", "winnow", @@ -804,9 +1043,9 @@ dependencies = [ [[package]] name = "alloy-sol-types" -version = "1.2.1" +version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58377025a47d8b8426b3e4846a251f2c1991033b27f517aade368146f6ab1dfe" +checksum = "3e6f87cf007caa54e95455875fbd25879b008d0d9c7c340a2c258b3a3400a28c" dependencies = [ "alloy-json-abi", "alloy-primitives", @@ -816,20 +1055,20 @@ dependencies = [ [[package]] name = "alloy-transport" -version = "1.0.22" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f317d20f047b3de4d9728c556e2e9a92c9a507702d2016424cd8be13a74ca5e" +checksum = "e10be13ab13ddadcfaec3320cc6cee83548a1ae32ada214ff864604cbf5c6f77" dependencies = [ "alloy-json-rpc", - "alloy-primitives", + "auto_impl", "base64 0.22.1", - "derive_more 2.0.1", + "derive_more", "futures", "futures-utils-wasm", "parking_lot", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.21", "tokio", "tower", "tracing", @@ -839,13 +1078,14 @@ dependencies = [ [[package]] name = "alloy-transport-http" -version = "1.0.22" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff084ac7b1f318c87b579d221f11b748341d68b9ddaa4ffca5e62ed2b8cfefb4" +checksum = "df948f2f9530ed0325d789ad98aa08403aaeaf55ddd6eeaf0cda143e02afab39" dependencies = [ "alloy-json-rpc", "alloy-transport", - "reqwest", + "itertools 0.14.0", + "reqwest 0.13.5", "serde_json", "tower", "tracing", @@ -854,9 +1094,9 @@ dependencies = [ [[package]] name = "alloy-transport-ipc" -version = "1.0.22" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edb099cdad8ed2e6a80811cdf9bbf715ebf4e34c981b4a6e2d1f9daacbf8b218" +checksum = "a757d266eb7927d59a04878c99b450df0da49125e61d641f48fa7c6a82dd4418" dependencies = [ "alloy-json-rpc", "alloy-pubsub", @@ -864,7 +1104,8 @@ dependencies = [ "bytes", "futures", "interprocess", - "pin-project 1.1.10", + "memchr", + "pin-project", "serde", "serde_json", "tokio", @@ -873,76 +1114,98 @@ dependencies = [ ] [[package]] -name = "alloy-transport-ws" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e915e1250dc129ad48d264573ccd08e4716fdda564a772fd217875b8459aff9" +name = "alloy-transport-mpp" +version = "0.2.0" +source = "git+https://github.com/tempoxyz/mpp-rs?rev=965692a8a5457896b22698a177a371875b73cfc5#965692a8a5457896b22698a177a371875b73cfc5" dependencies = [ + "alloy-json-rpc", "alloy-pubsub", "alloy-transport", + "alloy-transport-ws", "futures", - "http 1.3.1", + "http 1.5.0", + "mpp", + "reqwest 0.13.5", "rustls", + "serde", "serde_json", + "thiserror 2.0.21", + "time", "tokio", "tokio-tungstenite", + "tower-service", "tracing", - "ws_stream_wasm", + "url", ] [[package]] -name = "alloy-trie" -version = "0.9.0" +name = "alloy-transport-ws" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bada1fc392a33665de0dc50d401a3701b62583c655e3522a323490a5da016962" +checksum = "8c799e5804cadb4a09e0ff21be230cc1025e5a21e02a41aa9c0022fdb41f830f" dependencies = [ - "alloy-primitives", - "alloy-rlp", - "arrayvec", - "derive_more 2.0.1", - "nybbles", + "alloy-pubsub", + "alloy-transport", + "futures", + "http 1.5.0", + "rustls", + "serde_json", + "tokio", + "tokio-tungstenite", + "tracing", + "url", + "ws_stream_wasm", +] + +[[package]] +name = "alloy-trie" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7895d0d2c946c0ae9253c227db77b9fc707383831058e6a5232fdfc774b28259" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "arbitrary", + "derive_arbitrary", + "derive_more", + "nybbles", + "proptest", + "proptest-derive 0.7.0", "serde", "smallvec", + "thiserror 2.0.21", "tracing", ] [[package]] name = "alloy-tx-macros" -version = "1.0.22" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1154c8187a5ff985c95a8b2daa2fedcf778b17d7668e5e50e556c4ff9c881154" +checksum = "60fa90c57738ace5b38106c9f743f3d6d47de09a82e82ecfc42702c1acf116ee" dependencies = [ - "alloy-primitives", - "darling 0.20.11", + "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] -name = "ammonia" -version = "4.1.1" +name = "ambassador" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6b346764dd0814805de8abf899fe03065bcee69bb1a4771c785817e39f3978f" +checksum = "e68de4cdc6006162265d0957edb4a860fe4e711b1dc17a5746fd95f952f08285" dependencies = [ - "cssparser", - "html5ever", - "maplit", - "tendril", - "url", + "itertools 0.10.5", + "proc-macro2", + "quote", + "syn 1.0.109", ] -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] @@ -952,17 +1215,27 @@ name = "annotate-snippets" version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "710e8eae58854cdc1790fcb56cca04d712a17be849eeb81da2a724bf4bae2bc4" +dependencies = [ + "anstyle", + "unicode-width 0.2.2", +] + +[[package]] +name = "annotate-snippets" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f211a51805bc641f3ad5b7664c77d2547af685cc33b4cd8d31964027a46f13f1" dependencies = [ "anstyle", "memchr", - "unicode-width 0.2.0", + "unicode-width 0.2.2", ] [[package]] name = "anstream" -version = "0.6.19" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -975,64 +1248,64 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.11" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-lossy" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04d3a5dc826f84d0ea11882bb8054ff7f3d482602e11bb181101303a279ea01f" +checksum = "d9ca7d0f520afcd6d817970d0b2d5fd7c630c75e7783cae046b8b8a783c5befa" dependencies = [ "anstyle", ] [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.1.3" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "anstyle-svg" -version = "0.1.9" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a43964079ef399480603125d5afae2b219aceffb77478956e25f17b9bc3435c" +checksum = "ab68e0b71ea68eb6b399ffe1957f26330aeed91f5e578d442a1c2f3df69f6ec1" dependencies = [ "anstyle", "anstyle-lossy", "anstyle-parse", "html-escape", - "unicode-width 0.2.0", + "unicode-width 0.2.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.9" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "anvil" -version = "1.3.0" +version = "1.8.6" dependencies = [ "alloy-chains", "alloy-consensus", @@ -1042,14 +1315,17 @@ dependencies = [ "alloy-evm", "alloy-genesis", "alloy-hardforks", + "alloy-monad-evm", "alloy-network", "alloy-op-evm", - "alloy-op-hardforks", "alloy-primitives", "alloy-provider", "alloy-pubsub", "alloy-rlp", "alloy-rpc-types", + "alloy-rpc-types-beacon", + "alloy-rpc-types-eth", + "alloy-rpc-types-mev", "alloy-serde", "alloy-signer", "alloy-signer-local", @@ -1061,11 +1337,20 @@ dependencies = [ "anvil-server", "async-trait", "axum", + "base-common-chains", + "base-common-consensus", + "base-common-evm", + "base-common-genesis", + "base-common-precompiles", + "base-common-rpc-types", + "base-consensus-upgrades", + "base-execution-eip8130", + "base-protocol", "chrono", "clap", "clap_complete", - "clap_complete_fig", "ctrlc", + "ethereum_ssz", "eyre", "fdlimit", "flate2", @@ -1073,56 +1358,67 @@ dependencies = [ "foundry-common", "foundry-config", "foundry-evm", - "foundry-evm-core", + "foundry-evm-networks", + "foundry-primitives", "foundry-test-utils", "futures", "hyper", - "itertools 0.14.0", - "op-alloy-consensus 0.17.2", + "imbl", + "itertools 0.15.0", + "monad-revm", + "op-alloy-consensus", "op-alloy-rpc-types", "op-revm", "parking_lot", - "rand 0.8.5", - "rand 0.9.1", + "rand 0.8.8", + "rand 0.9.5", + "reqwest 0.13.5", "revm", "revm-inspectors", "serde", "serde_json", "tempfile", - "thiserror 2.0.12", + "tempo-alloy", + "tempo-evm", + "tempo-hardfork", + "tempo-precompiles", + "tempo-primitives", + "tempo-revm", + "thiserror 2.0.21", "tokio", "tracing", - "tracing-subscriber 0.3.19", + "tracing-subscriber", "yansi", ] [[package]] name = "anvil-core" -version = "1.3.0" +version = "1.8.6" dependencies = [ "alloy-consensus", "alloy-dyn-abi", + "alloy-eip5792", "alloy-eips", "alloy-network", "alloy-primitives", "alloy-rlp", "alloy-rpc-types", + "alloy-rpc-types-mev", "alloy-serde", "bytes", "foundry-common", "foundry-evm", - "op-alloy-consensus 0.17.2", - "op-revm", - "rand 0.9.1", + "foundry-primitives", + "rand 0.9.5", "revm", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.21", ] [[package]] name = "anvil-rpc" -version = "1.3.0" +version = "1.8.6" dependencies = [ "serde", "serde_json", @@ -1130,90 +1426,104 @@ dependencies = [ [[package]] name = "anvil-server" -version = "1.3.0" +version = "1.8.6" dependencies = [ "anvil-rpc", - "async-trait", "axum", "bytes", "clap", "futures", "interprocess", "parking_lot", - "pin-project 1.1.10", + "pin-project", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.21", "tokio-util", - "tower-http", + "tower", + "tower-http 0.7.1", "tracing", ] [[package]] name = "anyhow" -version = "1.0.98" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] [[package]] name = "arbitrary" -version = "1.4.1" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" dependencies = [ "derive_arbitrary", ] [[package]] -name = "ariadne" -version = "0.5.1" +name = "arc-swap" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36f5e3dca4e09a6f340a61a0e9c7b61e030c69fc27bf29d73218f7e5e3b7638f" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" dependencies = [ - "unicode-width 0.1.14", - "yansi", + "rustversion", ] +[[package]] +name = "archery" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca55ee147b1926dbea904f50fe4902494e97bc742205abbbf10c709e43815f" + [[package]] name = "ark-bls12-381" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3df4dcc01ff89867cd86b0da835f23c3f02738353aaee7dde7495af71363b8d5" +checksum = "be2ede2c0c96fa37d5d3484e8a59fec566c4a52b8c84bf993eaa6c67d7225a4c" dependencies = [ "ark-ec", - "ark-ff 0.5.0", - "ark-serialize 0.5.0", - "ark-std 0.5.0", + "ark-ff 0.6.0", + "ark-serialize 0.6.0", + "ark-std 0.6.0", ] [[package]] name = "ark-bn254" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d69eab57e8d2663efa5c63135b2af4f396d66424f88954c21104125ab6b3e6bc" +checksum = "6bc66f96ebe2a17a499475b4f94791d379817592ef494171586967ffdc6f95db" dependencies = [ "ark-ec", - "ark-ff 0.5.0", + "ark-ff 0.6.0", "ark-r1cs-std", - "ark-std 0.5.0", + "ark-std 0.6.0", ] [[package]] name = "ark-ec" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d68f2d516162846c1238e755a7c4d131b892b70cc70c471a8e3ca3ed818fce" +checksum = "8352a2b2aedf6ba2cc38f7520fc51191d518dde96175c729af19f2d059f191c4" dependencies = [ "ahash", - "ark-ff 0.5.0", + "ark-ff 0.6.0", "ark-poly", - "ark-serialize 0.5.0", - "ark-std 0.5.0", + "ark-serialize 0.6.0", + "ark-std 0.6.0", "educe", "fnv", - "hashbrown 0.15.4", - "itertools 0.13.0", - "num-bigint", + "hashbrown 0.17.1", + "itertools 0.14.0", + "num-bigint 0.4.8", "num-integer", "num-traits", "zeroize", @@ -1230,7 +1540,7 @@ dependencies = [ "ark-serialize 0.3.0", "ark-std 0.3.0", "derivative", - "num-bigint", + "num-bigint 0.4.8", "num-traits", "paste", "rustc_version 0.3.3", @@ -1250,7 +1560,7 @@ dependencies = [ "derivative", "digest 0.10.7", "itertools 0.10.5", - "num-bigint", + "num-bigint 0.4.8", "num-traits", "paste", "rustc_version 0.4.1", @@ -1271,12 +1581,29 @@ dependencies = [ "digest 0.10.7", "educe", "itertools 0.13.0", - "num-bigint", + "num-bigint 0.4.8", "num-traits", "paste", "zeroize", ] +[[package]] +name = "ark-ff" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7a806ac6c8307b929df4645776290a50ee2aac754ad09d8bdf73391309e43af" +dependencies = [ + "ark-ff-asm 0.6.0", + "ark-ff-macros 0.6.0", + "ark-serialize 0.6.0", + "ark-std 0.6.0", + "digest 0.10.7", + "educe", + "num-bigint 0.4.8", + "num-traits", + "zeroize", +] + [[package]] name = "ark-ff-asm" version = "0.3.0" @@ -1304,7 +1631,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" dependencies = [ "quote", - "syn 2.0.104", + "syn 2.0.119", +] + +[[package]] +name = "ark-ff-asm" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1479009684adc073dff49a1025d3a7065b317a9ead25aaaca38cdc70058ba8a2" +dependencies = [ + "quote", + "syn 2.0.119", ] [[package]] @@ -1313,7 +1650,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db2fd794a08ccb318058009eefdf15bcaaaaf6f8161eb3345f907222bac38b20" dependencies = [ - "num-bigint", + "num-bigint 0.4.8", "num-traits", "quote", "syn 1.0.109", @@ -1325,7 +1662,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" dependencies = [ - "num-bigint", + "num-bigint 0.4.8", "num-traits", "proc-macro2", "quote", @@ -1338,40 +1675,54 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3" dependencies = [ - "num-bigint", + "num-bigint 0.4.8", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ark-ff-macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0691ed21ef00ef89c1e9bda832eba493dda3ec2f8d892fb25b705f73f06bb8" +dependencies = [ + "num-bigint 0.4.8", "num-traits", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] name = "ark-poly" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "579305839da207f02b89cd1679e50e67b4331e2f9294a57693e5051b7703fe27" +checksum = "75f55af10b672002b8d953e230282c51206842e20e5791a94432219b4201de5c" dependencies = [ "ahash", - "ark-ff 0.5.0", - "ark-serialize 0.5.0", - "ark-std 0.5.0", + "ark-ff 0.6.0", + "ark-serialize 0.6.0", + "ark-std 0.6.0", "educe", "fnv", - "hashbrown 0.15.4", + "hashbrown 0.17.1", ] [[package]] name = "ark-r1cs-std" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "941551ef1df4c7a401de7068758db6503598e6f01850bdb2cfdb614a1f9dbea1" +checksum = "291f1c6628bfcac79b0dc2adbe401aa9100e2e96daa971645e0b18fc94de9a98" dependencies = [ "ark-ec", - "ark-ff 0.5.0", + "ark-ff 0.6.0", "ark-relations", - "ark-std 0.5.0", + "ark-std 0.6.0", "educe", - "num-bigint", + "itertools 0.14.0", + "num-bigint 0.4.8", "num-integer", "num-traits", "tracing", @@ -1379,14 +1730,18 @@ dependencies = [ [[package]] name = "ark-relations" -version = "0.5.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec46ddc93e7af44bcab5230937635b06fb5744464dd6a7e7b083e80ebd274384" +checksum = "fe4c11c797a64b8a23e22bf4e77bf582ac27bb21395e3183a9a506ba2561e9f9" dependencies = [ - "ark-ff 0.5.0", - "ark-std 0.5.0", + "ark-ff 0.6.0", + "ark-poly", + "ark-serialize 0.6.0", + "ark-std 0.6.0", + "foldhash 0.1.5", + "indexmap 2.14.2", "tracing", - "tracing-subscriber 0.2.25", + "tracing-subscriber", ] [[package]] @@ -1407,7 +1762,7 @@ checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" dependencies = [ "ark-std 0.4.0", "digest 0.10.7", - "num-bigint", + "num-bigint 0.4.8", ] [[package]] @@ -1416,22 +1771,34 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7" dependencies = [ - "ark-serialize-derive", "ark-std 0.5.0", "arrayvec", "digest 0.10.7", - "num-bigint", + "num-bigint 0.4.8", +] + +[[package]] +name = "ark-serialize" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a74dd304fd536fb95d0a328e72be759209cc496a9da094c5bc56e5fea4f9e86b" +dependencies = [ + "ark-serialize-derive", + "ark-std 0.6.0", + "digest 0.10.7", + "num-bigint 0.4.8", + "serde_with", ] [[package]] name = "ark-serialize-derive" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "213888f660fddcca0d257e88e54ac05bca01885f258ccdf695bafd77031bb69d" +checksum = "4f153690697a2b91e5e1251ff98411ee5371500a111a0fd317a70e588eb300f9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] @@ -1441,7 +1808,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1df2c09229cbc5a028b1d70e00fdb2acee28b1055dfb5ca73eea49c5a25c4e7c" dependencies = [ "num-traits", - "rand 0.8.5", + "rand 0.8.8", ] [[package]] @@ -1451,7 +1818,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" dependencies = [ "num-traits", - "rand 0.8.5", + "rand 0.8.8", ] [[package]] @@ -1461,7 +1828,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a" dependencies = [ "num-traits", - "rand 0.8.5", + "rand 0.8.8", +] + +[[package]] +name = "ark-std" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "367c9c827ed431bff6868b7aa926e05b16eb46603cc8b6e768e4a5553fa1d155" +dependencies = [ + "num-traits", + "rand 0.8.8", ] [[package]] @@ -1477,28 +1854,39 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" dependencies = [ "serde", + "zeroize", ] [[package]] -name = "ascii-canvas" -version = "4.0.0" +name = "async-compression" +version = "0.4.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef1e3e699d84ab1b0911a1010c5c106aa34ae89aeac103be5ce0c3859db1e891" +checksum = "fb61aea1a7def73ee7c350a184f0e70b32c182344e2e75bf70c9b621b83417fd" dependencies = [ - "term", + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", ] [[package]] -name = "async-compression" -version = "0.4.27" +name = "async-lsp" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddb939d66e4ae03cee6091612804ba446b12878410cfa17f785f4dd67d4014e8" +checksum = "c8b8fd1e175c5ee3108095da452e816519de618beccea23aa053c00cf5e344b9" dependencies = [ - "flate2", - "futures-core", - "memchr", + "futures", + "lsp-types", "pin-project-lite", + "rustix", + "serde", + "serde_json", + "thiserror 2.0.21", "tokio", + "tower-layer", + "tower-service", + "tracing", + "waitpid-any", ] [[package]] @@ -1529,18 +1917,18 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] name = "async-trait" -version = "0.1.88" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 3.0.6", ] [[package]] @@ -1577,9 +1965,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "aurora-engine-modexp" -version = "1.2.0" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "518bc5745a6264b5fd7b09dffb9667e400ee9e2bbe18555fac75e1fe9afa0df9" +checksum = "5188e264926edbd2e90d61bf8b33aa3471db8acdf427fa37946f9c82898fe502" dependencies = [ "hex", "num", @@ -1593,20 +1981,20 @@ checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-config" -version = "1.8.2" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebd9b83179adf8998576317ce47785948bcff399ec5b15f4dfbdedd44ddf5b92" +checksum = "1b180a3c8b55960db3426d8964b8745e652466a1a49fe1a2eda828046d30b5e4" dependencies = [ "aws-credential-types", "aws-runtime", @@ -1618,13 +2006,14 @@ dependencies = [ "aws-smithy-json", "aws-smithy-runtime", "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", "aws-types", "bytes", "fastrand", "hex", - "http 1.3.1", - "ring", + "http 1.5.0", + "sha1", "time", "tokio", "tracing", @@ -1634,9 +2023,9 @@ dependencies = [ [[package]] name = "aws-credential-types" -version = "1.2.4" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b68c2194a190e1efc999612792e25b1ab3abfefe4306494efaaabc25933c0cbe" +checksum = "e93964ffdaf57857f544be3666a5f57570bb699e934700f11b49708f61bb556e" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", @@ -1646,32 +2035,33 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.13.2" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08b5d4e069cbc868041a64bd68dc8cb39a0d79585cd6c5a24caa8c2d622121be" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", + "untrusted 0.7.1", "zeroize", ] [[package]] name = "aws-lc-sys" -version = "0.30.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbfd150b5dbdb988bcc8fb1fe787eb6b7ee6180ca24da683b61ea5405f3d43ff" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" dependencies = [ - "bindgen", "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] name = "aws-runtime" -version = "1.5.9" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2090e664216c78e766b6bac10fe74d2f451c02441d43484cd76ac9a295075f7" +checksum = "c9007227e10b5fed2f3e0a2beff489211e2b5604c400b7a9d5d81ca9d64c24bb" dependencies = [ "aws-credential-types", "aws-sigv4", @@ -1682,109 +2072,126 @@ dependencies = [ "aws-smithy-types", "aws-types", "bytes", + "bytes-utils", "fastrand", - "http 0.2.12", - "http-body 0.4.6", + "http 1.5.0", + "http-body 1.1.0", "percent-encoding", "pin-project-lite", "tracing", - "uuid 1.17.0", + "uuid 1.26.1", ] [[package]] name = "aws-sdk-kms" -version = "1.79.0" +version = "1.114.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5603bd5e0487e90acdef4a9be019f55c841e8eb72d3cb2e88c1c112c67a59db" +checksum = "c0b7d906608ee41e7ddea9983577ba82200435644d567d63dc34e822e088b453" dependencies = [ + "arc-swap", "aws-credential-types", "aws-runtime", "aws-smithy-async", "aws-smithy-http", "aws-smithy-json", + "aws-smithy-observability", "aws-smithy-runtime", "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", "aws-types", "bytes", "fastrand", "http 0.2.12", + "http 1.5.0", "regex-lite", "tracing", ] [[package]] name = "aws-sdk-sso" -version = "1.76.0" +version = "1.105.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64bf26698dd6d238ef1486bdda46f22a589dc813368ba868dc3d94c8d27b56ba" +checksum = "6ffd0fbe7873cb548a7aa60f9573c268fff94155397fd4f14dc9f1ecaaab8516" dependencies = [ + "arc-swap", "aws-credential-types", "aws-runtime", "aws-smithy-async", "aws-smithy-http", "aws-smithy-json", + "aws-smithy-observability", "aws-smithy-runtime", "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", "aws-types", "bytes", "fastrand", "http 0.2.12", + "http 1.5.0", "regex-lite", "tracing", ] [[package]] name = "aws-sdk-ssooidc" -version = "1.77.0" +version = "1.107.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cd07ed1edd939fae854a22054299ae3576500f4e0fadc560ca44f9c6ea1664" +checksum = "175763eb222a46377df7aa257a3bca980ab3e96703fefc8f4d0b8da6ad2e254c" dependencies = [ + "arc-swap", "aws-credential-types", "aws-runtime", "aws-smithy-async", "aws-smithy-http", "aws-smithy-json", + "aws-smithy-observability", "aws-smithy-runtime", "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", "aws-types", "bytes", "fastrand", "http 0.2.12", + "http 1.5.0", "regex-lite", "tracing", ] [[package]] name = "aws-sdk-sts" -version = "1.78.0" +version = "1.110.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37f7766d2344f56d10d12f3c32993da36d78217f32594fe4fb8e57a538c1cdea" +checksum = "dd8b14781dfbff48984017d57167b6ea0b6471c6920ec52b44a2677c7feb3c13" dependencies = [ + "arc-swap", "aws-credential-types", "aws-runtime", "aws-smithy-async", "aws-smithy-http", "aws-smithy-json", + "aws-smithy-observability", "aws-smithy-query", "aws-smithy-runtime", "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", "aws-smithy-xml", "aws-types", "fastrand", "http 0.2.12", + "http 1.5.0", "regex-lite", "tracing", ] [[package]] name = "aws-sigv4" -version = "1.3.3" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddfb9021f581b71870a17eac25b52335b82211cdc092e02b6876b2bcefa61666" +checksum = "723c2234ad7511ceef63eab016b7ba6ff7c55590fefb96fa8467af014a07309f" dependencies = [ "aws-credential-types", "aws-smithy-http", @@ -1793,20 +2200,20 @@ dependencies = [ "bytes", "form_urlencoded", "hex", - "hmac", + "hmac 0.13.0", "http 0.2.12", - "http 1.3.1", + "http 1.5.0", "percent-encoding", - "sha2 0.10.9", + "sha2 0.11.0", "time", "tracing", ] [[package]] name = "aws-smithy-async" -version = "1.2.5" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e190749ea56f8c42bf15dd76c65e14f8f765233e6df9b0506d9d934ebef867c" +checksum = "f02e407fb3b54891734224b9ffac8a71fdd35f542500fa1af95754a6b2beb316" dependencies = [ "futures-util", "pin-project-lite", @@ -1815,18 +2222,19 @@ dependencies = [ [[package]] name = "aws-smithy-http" -version = "0.62.2" +version = "0.64.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c82ba4cab184ea61f6edaafc1072aad3c2a17dcf4c0fce19ac5694b90d8b5f" +checksum = "37843d9add67c3aff5856f409c6dc315d3cdff60f9c0cb5b670dab1e9920306d" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", "bytes", "bytes-utils", "futures-core", - "http 0.2.12", - "http 1.3.1", - "http-body 0.4.6", + "futures-util", + "http 1.5.0", + "http-body 1.1.0", + "http-body-util", "percent-encoding", "pin-project-lite", "pin-utils", @@ -1835,15 +2243,15 @@ dependencies = [ [[package]] name = "aws-smithy-http-client" -version = "1.0.6" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f108f1ca850f3feef3009bdcc977be201bca9a91058864d9de0684e64514bee0" +checksum = "635d23afda0a6ab48d666c4d447c4873e8d1e83518a2be2093122397e50b838e" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", "aws-smithy-types", "h2", - "http 1.3.1", + "http 1.5.0", "hyper", "hyper-rustls", "hyper-util", @@ -1852,56 +2260,64 @@ dependencies = [ "rustls-native-certs", "rustls-pki-types", "tokio", + "tokio-rustls", "tower", "tracing", ] [[package]] name = "aws-smithy-json" -version = "0.61.4" +version = "0.63.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a16e040799d29c17412943bdbf488fd75db04112d0c0d4b9290bacf5ae0014b9" +checksum = "3dc65a121adb4b33729919fcfa14fa36fb33c1555a8f06bb0e2188dbfdc1d9ef" dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", ] [[package]] name = "aws-smithy-observability" -version = "0.1.3" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9364d5989ac4dd918e5cc4c4bdcc61c9be17dcd2586ea7f69e348fc7c6cab393" +checksum = "8e86338c869539a581bf161247762a6e87f92c5c075060057b5ed6d06632ed0c" dependencies = [ "aws-smithy-runtime-api", ] [[package]] name = "aws-smithy-query" -version = "0.60.7" +version = "0.62.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2fbd61ceb3fe8a1cb7352e42689cec5335833cd9f94103a61e98f9bb61c64bb" +checksum = "512346c7212ab7436df2d77a16d976a468ae44a418835511d2a69269810aaf62" dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", + "aws-smithy-xml", "urlencoding", ] [[package]] name = "aws-smithy-runtime" -version = "1.8.4" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3aaec682eb189e43c8a19c3dab2fe54590ad5f2cc2d26ab27608a20f2acf81c" +checksum = "07505b34e8f4b3591a4fa69e9792b52289b95488dbbc68c3c0075b7bedb245e1" dependencies = [ "aws-smithy-async", "aws-smithy-http", "aws-smithy-http-client", "aws-smithy-observability", "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", "bytes", "fastrand", "http 0.2.12", - "http 1.3.1", + "http 1.5.0", "http-body 0.4.6", - "http-body 1.0.1", + "http-body 1.1.0", + "http-body-util", "pin-project-lite", "pin-utils", "tokio", @@ -1910,34 +2326,57 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api" -version = "1.8.3" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9852b9226cb60b78ce9369022c0df678af1cac231c882d5da97a0c4e03be6e67" +checksum = "3b98f2e1fd67ec06618f9c291e5e495a468e60519e44c9c1979cd0521f3affdb" dependencies = [ "aws-smithy-async", + "aws-smithy-runtime-api-macros", "aws-smithy-types", "bytes", "http 0.2.12", - "http 1.3.1", + "http 1.5.0", "pin-project-lite", "tokio", "tracing", "zeroize", ] +[[package]] +name = "aws-smithy-runtime-api-macros" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "221eaa237ddf1ca79b60d1372aad77e47f9c0ea5b3ce5099da8c61d027dc77b3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "aws-smithy-schema" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56e0a4e53127a632224e43633b0fe045fa9e1e3cfc68b9830f1115e103f910" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "http 1.5.0", +] + [[package]] name = "aws-smithy-types" -version = "1.3.2" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d498595448e43de7f4296b7b7a18a8a02c61ec9349128c80a368f7c3b4ab11a8" +checksum = "d6dc683efb34b9e755675b37fedbe0103141e5b6df7bdc9eb6967756a8c167d8" dependencies = [ "base64-simd", "bytes", "bytes-utils", "http 0.2.12", - "http 1.3.1", + "http 1.5.0", "http-body 0.4.6", - "http-body 1.0.1", + "http-body 1.1.0", "http-body-util", "itoa", "num-integer", @@ -1950,22 +2389,26 @@ dependencies = [ [[package]] name = "aws-smithy-xml" -version = "0.60.10" +version = "0.62.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3db87b96cb1b16c024980f133968d52882ca0daaee3a086c6decc500f6c99728" +checksum = "ce84f71c72fee2cbbadde6e7d082f5fb466e3a84733855295fa7aafd1b31b7d8" dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", "xmlparser", ] [[package]] name = "aws-types" -version = "1.3.7" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a322fec39e4df22777ed3ad8ea868ac2f94cd15e1a55f6ee8d8d6305057689a" +checksum = "eec1cd5469f328c782dc3e33d4153cf118a54e33cbb3356d60d16f89883e1f94" dependencies = [ "aws-credential-types", "aws-smithy-async", "aws-smithy-runtime-api", + "aws-smithy-schema", "aws-smithy-types", "rustc_version 0.4.1", "tracing", @@ -1973,17 +2416,17 @@ dependencies = [ [[package]] name = "axum" -version = "0.8.4" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "base64 0.22.1", "bytes", "form_urlencoded", "futures-util", - "http 1.3.1", - "http-body 1.0.1", + "http 1.5.0", + "http-body 1.1.0", "http-body-util", "hyper", "hyper-util", @@ -1993,8 +2436,7 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rustversion", - "serde", + "serde_core", "serde_json", "serde_path_to_error", "serde_urlencoded", @@ -2010,63 +2452,338 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.5.2" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http 1.3.1", - "http-body 1.0.1", + "http 1.5.0", + "http-body 1.1.0", "http-body-util", "mime", "pin-project-lite", - "rustversion", "sync_wrapper", "tower-layer", "tower-service", "tracing", ] -[[package]] -name = "az" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b7e4c2464d97fe331d41de9d5db0def0a96f4d823b8b32a2efd503578988973" - [[package]] name = "backtrace" -version = "0.3.75" +version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" dependencies = [ "addr2line", "cfg-if", "libc", - "miniz_oxide", + "miniz_oxide 0.8.9", "object", "rustc-demangle", - "windows-targets 0.52.6", + "windows-link", ] [[package]] -name = "base16ct" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" - -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - +name = "base-common-chains" +version = "0.0.0" +source = "git+https://github.com/foundry-rs/base?rev=2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72#2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72" +dependencies = [ + "alloy-chains", + "alloy-eips", + "alloy-genesis", + "alloy-hardforks", + "alloy-primitives", + "auto_impl", + "base-common-genesis", + "revm", + "spin", +] + +[[package]] +name = "base-common-consensus" +version = "0.0.0" +source = "git+https://github.com/foundry-rs/base?rev=2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72#2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-evm", + "alloy-network", + "alloy-primitives", + "alloy-rlp", + "alloy-rpc-types-eth", + "alloy-serde", + "alloy-sol-types", + "bytes", + "reth-codecs 0.6.0", + "reth-primitives-traits 0.6.0", + "reth-zstd-compressors 0.6.0", + "revm", + "serde", + "thiserror 2.0.21", +] + +[[package]] +name = "base-common-eip8130" +version = "0.0.0" +source = "git+https://github.com/foundry-rs/base?rev=2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72#2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72" +dependencies = [ + "alloy-primitives", + "base-common-consensus", + "thiserror 2.0.21", +] + +[[package]] +name = "base-common-evm" +version = "0.0.0" +source = "git+https://github.com/foundry-rs/base?rev=2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72#2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-evm", + "alloy-primitives", + "auto_impl", + "base-common-chains", + "base-common-consensus", + "base-common-flz", + "base-common-genesis", + "base-common-l1-fees", + "base-common-precompiles", + "base-execution-eip8130", + "base-metrics", + "base-precompile-storage", + "revm", + "serde", + "thiserror 2.0.21", +] + +[[package]] +name = "base-common-flz" +version = "0.0.0" +source = "git+https://github.com/foundry-rs/base?rev=2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72#2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72" + +[[package]] +name = "base-common-genesis" +version = "0.0.0" +source = "git+https://github.com/foundry-rs/base?rev=2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72#2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72" +dependencies = [ + "alloy-chains", + "alloy-consensus", + "alloy-eips", + "alloy-hardforks", + "alloy-primitives", + "alloy-sol-types", + "derive_more", + "serde", + "spin", + "thiserror 2.0.21", + "tracing", +] + +[[package]] +name = "base-common-l1-fees" +version = "0.0.0" +source = "git+https://github.com/foundry-rs/base?rev=2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72#2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72" +dependencies = [ + "alloy-primitives", + "base-common-flz", + "base-common-genesis", +] + +[[package]] +name = "base-common-network" +version = "0.0.0" +source = "git+https://github.com/foundry-rs/base?rev=2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72#2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72" +dependencies = [ + "alloy-consensus", + "alloy-network", + "alloy-primitives", + "alloy-provider", + "alloy-rpc-types-engine", + "alloy-transport", + "async-trait", + "base-common-consensus", + "base-common-rpc-types", + "base-common-rpc-types-engine", +] + +[[package]] +name = "base-common-precompiles" +version = "0.0.0" +source = "git+https://github.com/foundry-rs/base?rev=2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72#2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72" +dependencies = [ + "alloy-evm", + "alloy-primitives", + "alloy-sol-types", + "base-common-chains", + "base-common-eip8130", + "base-common-genesis", + "base-precompile-macros", + "base-precompile-storage", + "revm", +] + +[[package]] +name = "base-common-rpc-types" +version = "0.0.0" +source = "git+https://github.com/foundry-rs/base?rev=2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72#2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-evm", + "alloy-network", + "alloy-network-primitives", + "alloy-primitives", + "alloy-rpc-types-eth", + "alloy-serde", + "base-common-consensus", + "base-common-evm", + "derive_more", + "reth-primitives-traits 0.6.0", + "revm", + "serde", + "serde_json", +] + +[[package]] +name = "base-common-rpc-types-engine" +version = "0.0.0" +source = "git+https://github.com/foundry-rs/base?rev=2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72#2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-primitives", + "alloy-rlp", + "alloy-rpc-types-engine", + "alloy-serde", + "base-common-consensus", + "ethereum_ssz", + "ethereum_ssz_derive", + "serde", + "sha2 0.10.9", + "snap", + "thiserror 2.0.21", +] + +[[package]] +name = "base-consensus-upgrades" +version = "0.0.0" +source = "git+https://github.com/foundry-rs/base?rev=2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72#2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72" +dependencies = [ + "alloy-eips", + "alloy-primitives", + "base-common-consensus", +] + +[[package]] +name = "base-execution-eip8130" +version = "0.0.0" +source = "git+https://github.com/foundry-rs/base?rev=2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72#2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72" +dependencies = [ + "alloy-primitives", + "alloy-sol-types", + "base-common-consensus", + "base-common-eip8130", + "base-common-precompiles", + "base-precompile-macros", + "base-precompile-storage", + "base64 0.22.1", + "k256", + "p256 0.13.2", + "revm", + "sha2 0.10.9", + "thiserror 2.0.21", +] + +[[package]] +name = "base-metrics" +version = "0.0.0" +source = "git+https://github.com/foundry-rs/base?rev=2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72#2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72" + +[[package]] +name = "base-precompile-macros" +version = "0.0.0" +source = "git+https://github.com/foundry-rs/base?rev=2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72#2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72" +dependencies = [ + "alloy-primitives", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "base-precompile-storage" +version = "0.0.0" +source = "git+https://github.com/foundry-rs/base?rev=2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72#2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72" +dependencies = [ + "alloy-evm", + "alloy-primitives", + "alloy-sol-types", + "base-precompile-macros", + "derive_more", + "revm", + "thiserror 2.0.21", + "tracing", +] + +[[package]] +name = "base-protocol" +version = "0.0.0" +source = "git+https://github.com/foundry-rs/base?rev=2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72#2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72" +dependencies = [ + "alloc-no-stdlib", + "alloy-consensus", + "alloy-eips", + "alloy-genesis", + "alloy-primitives", + "alloy-rlp", + "alloy-rpc-types-engine", + "alloy-rpc-types-eth", + "ambassador", + "async-trait", + "base-common-consensus", + "base-common-genesis", + "base-common-rpc-types-engine", + "brotli", + "derive_more", + "miniz_oxide 0.9.1", + "serde", + "spin", + "thiserror 2.0.21", + "tracing", + "unsigned-varint", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64-simd" version = "0.8.0" @@ -2079,9 +2796,9 @@ dependencies = [ [[package]] name = "base64ct" -version = "1.8.0" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bech32" @@ -2095,29 +2812,6 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230c5f1ca6a325a32553f8640d31ac9b49f2411e901e427570154868b46da4f7" -[[package]] -name = "bindgen" -version = "0.69.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" -dependencies = [ - "bitflags 2.9.1", - "cexpr", - "clang-sys", - "itertools 0.12.1", - "lazy_static", - "lazycell", - "log", - "prettyplease", - "proc-macro2", - "quote", - "regex", - "rustc-hash 1.1.0", - "shlex", - "syn 2.0.104", - "which 4.4.2", -] - [[package]] name = "bit-set" version = "0.8.0" @@ -2133,20 +2827,40 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bitcoin-consensus-encoding" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9daa31138eb443d5751b207f3f64154e2bb09cd59960562ccc7a7112be38147f" +dependencies = [ + "bitcoin-internals", + "hex-conservative 1.3.0", + "serde", +] + +[[package]] +name = "bitcoin-internals" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8bea3a9f0cfece4564e37cb49a38cc245ca5184719e50d7d0dda3268722c4e2" + [[package]] name = "bitcoin-io" -version = "0.1.3" +version = "0.1.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b47c4ab7a93edb0c7198c5535ed9b52b63095f4e9b45279c6736cec4b856baf" +checksum = "bb5de036369d1ac59d3c1819ebc4d850f89466f5401c571a285b6ed564a4cb78" +dependencies = [ + "bitcoin-consensus-encoding", +] [[package]] name = "bitcoin_hashes" -version = "0.14.0" +version = "0.14.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb18c03d0db0247e147a21a6faafd5a7eb851c743db062de72018b6b7e8e4d16" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" dependencies = [ "bitcoin-io", - "hex-conservative", + "hex-conservative 0.2.3", ] [[package]] @@ -2157,18 +2871,18 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.1" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" dependencies = [ - "serde", + "serde_core", ] [[package]] name = "bitvec" -version = "1.0.1" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" dependencies = [ "funty", "radium", @@ -2177,6 +2891,20 @@ dependencies = [ "wyz", ] +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.1", + "zeroize", +] + [[package]] name = "block-buffer" version = "0.9.0" @@ -2195,11 +2923,29 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "blst" -version = "0.3.15" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fd49896f12ac9b6dcd7a5998466b9b58263a695a3dd1ecc1aaca2e12a90b080" +checksum = "c20659f9bbee16cbbd2f7393e40ab6309f5a98f76a2eb57a995ec508b72387fe" dependencies = [ "cc", "glob", @@ -2208,35 +2954,219 @@ dependencies = [ ] [[package]] -name = "bon" -version = "3.6.5" +name = "boa_ast" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33d9ef19ae5263a138da9a86871eca537478ab0332a7770bac7e3f08b801f89f" +checksum = "6339a700715bda376f5ea65c76e8fe8fc880930d8b0638cea68e7f3da6538e0a" dependencies = [ - "bon-macros", - "rustversion", + "bitflags 2.13.2", + "boa_interner", + "boa_macros", + "boa_string", + "indexmap 2.14.2", + "num-bigint 0.4.8", + "rustc-hash", ] [[package]] -name = "bon-macros" -version = "3.6.5" +name = "boa_engine" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "577ae008f2ca11ca7641bd44601002ee5ab49ef0af64846ce1ab6057218a5cc1" +checksum = "1521be326f8a5c8887e95d4ce7f002917a002a23f7b93b9a6a2bf50ed4157824" dependencies = [ - "darling 0.21.0", - "ident_case", - "prettyplease", - "proc-macro2", - "quote", - "rustversion", - "syn 2.0.104", + "aligned-vec", + "arrayvec", + "bitflags 2.13.2", + "boa_ast", + "boa_gc", + "boa_interner", + "boa_macros", + "boa_parser", + "boa_string", + "bytemuck", + "cfg-if", + "cow-utils", + "dashmap", + "dynify", + "fast-float2", + "float16", + "futures-channel", + "futures-concurrency", + "futures-lite", + "hashbrown 0.16.1", + "icu_normalizer", + "indexmap 2.14.2", + "intrusive-collections", + "itertools 0.14.0", + "num-bigint 0.4.8", + "num-integer", + "num-traits", + "num_enum", + "paste", + "portable-atomic", + "rand 0.9.5", + "regress", + "rustc-hash", + "ryu-js", + "serde", + "serde_json", + "small_btree", + "static_assertions", + "tag_ptr", + "tap", + "thin-vec", + "thiserror 2.0.21", + "time", + "xsum", ] [[package]] -name = "boxcar" -version = "0.2.13" +name = "boa_gc" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26c4925bc979b677330a8c7fe7a8c94af2dbb4a2d37b4a20a80d884400f46baa" +checksum = "17323a98cf2e631afacf1a6d659c1212c48a68bacfa85afab0a66ade80582e51" +dependencies = [ + "boa_macros", + "boa_string", + "hashbrown 0.16.1", + "thin-vec", +] + +[[package]] +name = "boa_interner" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20510b8b02bcde9b0a01cf34c0c308c56156503d1d91cdab4c8cfbd292b747ea" +dependencies = [ + "boa_gc", + "boa_macros", + "hashbrown 0.16.1", + "indexmap 2.14.2", + "once_cell", + "phf 0.13.1", + "rustc-hash", + "static_assertions", +] + +[[package]] +name = "boa_macros" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5822cb4f146d243060e588bc5a5f2e709683fdad3d7111f42c48e6b5c921d23d" +dependencies = [ + "cfg-if", + "cow-utils", + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure 0.13.2", +] + +[[package]] +name = "boa_parser" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd957fa9fa93e3a001a8aba5a5cd40c2bbfde486378be4c4b472fd304aaddb" +dependencies = [ + "bitflags 2.13.2", + "boa_ast", + "boa_interner", + "boa_macros", + "fast-float2", + "icu_properties", + "num-bigint 0.4.8", + "num-traits", + "regress", + "rustc-hash", +] + +[[package]] +name = "boa_string" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2da1d7f4a76fd9040788a122f0d807910800a7b86f5952e9244848c36511de" +dependencies = [ + "fast-float2", + "itoa", + "paste", + "rustc-hash", + "ryu-js", + "static_assertions", +] + +[[package]] +name = "bon" +version = "3.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60eafe0d77c3a2fc292c1d1346c3041b33c0a108085a2afabf672b70f69dbbc9" +dependencies = [ + "bon-macros", +] + +[[package]] +name = "bon-macros" +version = "3.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0f9631d8aaaee112c41985d675ef269e02acbd4f33122836af4f0c5f699ff6" +dependencies = [ + "darling 0.24.1", + "ident_case", + "prettyplease 0.3.0", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "boxcar" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f64beae40a84da1b4b26ff2761a5b895c12adc41dc25aaee1c4f2bbfe97a6e" + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] [[package]] name = "bs58" @@ -2250,20 +3180,26 @@ dependencies = [ [[package]] name = "bstr" -version = "1.12.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" dependencies = [ "memchr", - "regex-automata 0.4.9", - "serde", + "regex-automata", + "serde_core", ] [[package]] name = "bumpalo" -version = "3.19.0" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "by_address" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" [[package]] name = "byte-slice-cast" @@ -2273,9 +3209,23 @@ checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" [[package]] name = "bytemuck" -version = "1.23.1" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c76a5792e44e4abe34d3abf15636779261d45a7450612059293d1d2cfc63422" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] [[package]] name = "byteorder" @@ -2285,9 +3235,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.10.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" dependencies = [ "serde", ] @@ -2302,31 +3252,13 @@ dependencies = [ "either", ] -[[package]] -name = "bzip2" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" -dependencies = [ - "bzip2-sys", -] - -[[package]] -name = "bzip2-sys" -version = "0.1.13+1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" -dependencies = [ - "cc", - "pkg-config", -] - [[package]] name = "c-kzg" -version = "2.1.1" +version = "2.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7318cfa722931cb5fe0838b98d3ce5621e75f6a6408abc21721d80de9223f2e4" +checksum = "38d04308254695569fdb9bfe3bacc1c91837a670d0806605eb82d63748fbd3a6" dependencies = [ + "arbitrary", "blst", "cc", "glob", @@ -2338,46 +3270,41 @@ dependencies = [ [[package]] name = "camino" -version = "1.1.10" +version = "1.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0da45bc31171d8d6960122e222a67740df867c1dd53b4d51caa297084c185cab" +checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" dependencies = [ - "serde", + "serde_core", ] [[package]] name = "cargo-platform" -version = "0.1.9" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +checksum = "122ec45a44b270afd1402f351b782c676b173e3c3fb28d86ff7ebfb4d86a4ee4" dependencies = [ "serde", ] [[package]] name = "cargo_metadata" -version = "0.18.1" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d886547e41f740c616ae73108f6eb70afe6d940c7bc697cb30f13daec073037" +checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" dependencies = [ "camino", "cargo-platform", - "semver 1.0.26", + "semver 1.0.28", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.21", ] -[[package]] -name = "cassowary" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" - [[package]] name = "cast" -version = "1.3.0" +version = "1.8.6" dependencies = [ + "aes-gcm", "alloy-chains", "alloy-consensus", "alloy-contract", @@ -2391,46 +3318,67 @@ dependencies = [ "alloy-primitives", "alloy-provider", "alloy-rlp", + "alloy-rpc-client", "alloy-rpc-types", + "alloy-rpc-types-beacon", "alloy-serde", "alloy-signer", "alloy-signer-local", "alloy-sol-types", "alloy-transport", "anvil", + "axum", + "base-common-network", "chrono", "clap", "clap_complete", - "clap_complete_fig", "comfy-table", - "dunce", + "dirs", "evmole", "eyre", + "forge-fmt", "foundry-block-explorers", "foundry-cli", "foundry-common", "foundry-compilers", "foundry-config", + "foundry-debugger", "foundry-evm", - "foundry-evm-core", + "foundry-evm-networks", + "foundry-primitives", "foundry-test-utils", "foundry-wallets", "futures", - "itertools 0.14.0", - "op-alloy-consensus 0.17.2", + "hkdf", + "itertools 0.15.0", + "k256", + "libc", + "op-alloy-consensus", "op-alloy-flz", - "rand 0.8.5", - "rand 0.9.1", + "op-alloy-network", + "path-slash", + "rand 0.8.8", + "rand 0.9.5", "rayon", "regex", + "reqwest 0.13.5", "revm", + "rexpect", "rpassword", - "semver 1.0.26", + "semver 1.0.28", "serde", "serde_json", + "sha2 0.10.9", "tempfile", + "tempo-alloy", + "tempo-contracts", + "tempo-hardfork", + "tempo-primitives", "tokio", + "tower", "tracing", + "url", + "windows-sys 0.61.2", "yansi", ] @@ -2445,43 +3393,60 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.30" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "deec109607ca693028562ed836a5f1c4b8bd77755c4e132fc5ce11b0b6211ae7" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" dependencies = [ + "find-msvc-tools", "jobserver", "libc", "shlex", ] [[package]] -name = "cexpr" -version = "0.6.0" +name = "cfg-if" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" -dependencies = [ - "nom", -] +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] -name = "cfg-if" -version = "1.0.1" +name = "cfg_aliases" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] -name = "cfg_aliases" -version = "0.2.1" +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cipher 0.5.2", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chacha20poly1305" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb" +dependencies = [ + "aead 0.6.1", + "chacha20", + "cipher 0.5.2", + "poly1305", +] [[package]] name = "chisel" -version = "1.3.0" +version = "1.8.6" dependencies = [ "alloy-dyn-abi", "alloy-json-abi", "alloy-primitives", + "anvil", "clap", "dirs", "eyre", @@ -2491,30 +3456,31 @@ dependencies = [ "foundry-compilers", "foundry-config", "foundry-evm", - "foundry-solang-parser", - "regex", - "reqwest", - "revm", + "foundry-evm-networks", + "foundry-test-utils", + "itertools 0.15.0", + "reqwest 0.13.5", + "rexpect", "rustyline", - "semver 1.0.26", + "semver 1.0.28", "serde", "serde_json", - "solar-parse", - "strum 0.27.2", + "solar-compiler", + "tempfile", "time", + "tokio", "tracing", - "tracing-subscriber 0.3.19", + "tracing-subscriber", "walkdir", "yansi", ] [[package]] name = "chrono" -version = "0.4.41" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ - "android-tzdata", "iana-time-zone", "js-sys", "num-traits", @@ -2556,26 +3522,26 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", - "inout", + "crypto-common 0.1.7", + "inout 0.1.4", ] [[package]] -name = "clang-sys" -version = "1.8.1" +name = "cipher" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ - "glob", - "libc", - "libloading", + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout 0.2.2", ] [[package]] name = "clap" -version = "4.5.41" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be92d32e80243a54711e5d7ce823c35c41c9d929dc4ab58e1276f625841aadf9" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" dependencies = [ "clap_builder", "clap_derive", @@ -2583,9 +3549,9 @@ dependencies = [ [[package]] name = "clap-verbosity-flag" -version = "3.0.3" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeab6a5cdfc795a05538422012f20a5496f050223c91be4e5420bfd13c641fb1" +checksum = "9d92b1fab272fe943881b77cc6e920d6543e5b1bfadbd5ed81c7c5a755742394" dependencies = [ "clap", "log", @@ -2593,9 +3559,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.41" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707eab41e9622f9139419d573eca0900137718000c517d47da73045f54331c3d" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" dependencies = [ "anstream", "anstyle", @@ -2603,23 +3569,23 @@ dependencies = [ "strsim", "terminal_size", "unicase", - "unicode-width 0.2.0", + "unicode-width 0.2.2", ] [[package]] name = "clap_complete" -version = "4.5.55" +version = "4.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5abde44486daf70c5be8b8f8f1b66c49f86236edf6fa2abadb4d961c4c6229a" +checksum = "037e2a1a92236d0aff7e845093f64661d6df4c02c9fcc61a60e9e1d736fa392f" dependencies = [ "clap", ] [[package]] -name = "clap_complete_fig" -version = "4.5.2" +name = "clap_complete_nushell" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d494102c8ff3951810c72baf96910b980fb065ca5d3101243e6a8dc19747c86b" +checksum = "ffb66bc82eb9c92b1727310ae2c5868df22ae7cf46185bc5c544a4fa71955e49" dependencies = [ "clap", "clap_complete", @@ -2627,40 +3593,40 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.41" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.104", + "syn 3.0.6", ] [[package]] name = "clap_lex" -version = "0.7.5" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" [[package]] name = "clearscreen" -version = "4.0.2" +version = "4.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a8ab73a1c02b0c15597b22e09c7dc36e63b2f601f9d1e83ac0c3decd38b1ae" +checksum = "d669bb552908e336ad5681789752033b45566b7e591aeaac7a614e58e5d6d8f2" dependencies = [ - "nix 0.29.0", + "nix 0.31.3", "terminfo", - "thiserror 2.0.12", - "which 8.0.0", - "windows-sys 0.59.0", + "thiserror 2.0.21", + "which", + "windows-sys 0.61.2", ] [[package]] name = "cliclack" -version = "0.3.6" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c420bdc04c123a2df04d9c5a07289195f00007af6e45ab18f55e56dc7e04b8" +checksum = "134778b6a125729ff495c23ba32f64e0c26d61edd52f1f6715a0d036785cf30b" dependencies = [ "console", "indicatif", @@ -2681,13 +3647,19 @@ dependencies = [ [[package]] name = "cmake" -version = "0.1.54" +version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "coins-bip32" version = "0.12.0" @@ -2697,7 +3669,7 @@ dependencies = [ "bs58", "coins-core", "digest 0.10.7", - "hmac", + "hmac 0.12.1", "k256", "serde", "sha2 0.10.9", @@ -2712,10 +3684,10 @@ checksum = "74b169b26623ff17e9db37a539fe4f15342080df39f129ef7631df7683d6d9d4" dependencies = [ "bitvec", "coins-bip32", - "hmac", + "hmac 0.12.1", "once_cell", "pbkdf2 0.12.2", - "rand 0.8.5", + "rand 0.8.8", "sha2 0.10.9", "thiserror 1.0.69", ] @@ -2732,24 +3704,23 @@ dependencies = [ "const-hex", "digest 0.10.7", "generic-array", - "ripemd", + "ripemd 0.1.3", "serde", "sha2 0.10.9", - "sha3", + "sha3 0.10.9", "thiserror 1.0.69", ] [[package]] name = "coins-ledger" -version = "0.12.0" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab9bc0994d0aa0f4ade5f3a9baf4a8d936f250278c85a1124b401860454246ab" +checksum = "95d2ea3b8d4423c5c82f9aa4738f7e9d952f2a9c092104696feca7d821028bd2" dependencies = [ "async-trait", "byteorder", "cfg-if", "const-hex", - "getrandom 0.2.16", "hidapi-rusb", "js-sys", "log", @@ -2773,7 +3744,7 @@ dependencies = [ "eyre", "indenter", "once_cell", - "owo-colors 4.2.2", + "owo-colors", "tracing-error", ] @@ -2784,36 +3755,45 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427" dependencies = [ "once_cell", - "owo-colors 4.2.2", + "owo-colors", "tracing-core", "tracing-error", ] [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "colored" -version = "2.2.0" +version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "lazy_static", - "windows-sys 0.59.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", ] [[package]] name = "comfy-table" -version = "7.1.4" +version = "8.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a65ebfec4fb190b6f90e944a817d60499ee0744e582530e2c9900a22e591d9a" +checksum = "b96a46ad395a5e00676eddaca4a45ed78b29c08be5a878d35e4b071c625637dd" dependencies = [ "crossterm", "unicode-segmentation", - "unicode-width 0.2.0", + "unicode-width 0.2.2", ] [[package]] @@ -2823,101 +3803,281 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55b672471b4e9f9e95499ea597ff64941a309b2cdbffcc46f2cc5e2d971fd335" [[package]] -name = "compact_str" -version = "0.8.1" +name = "commonware-codec" +version = "2026.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +checksum = "4d1e1bd11366f81e304c737c098eb0c075cef59e21daff44c3227952c2f75b49" dependencies = [ - "castaway", + "bytes", "cfg-if", - "itoa", - "rustversion", - "ryu", - "static_assertions", + "commonware-codec-macros", + "commonware-macros", + "paste", + "rand_chacha 0.10.0", + "thiserror 2.0.21", ] [[package]] -name = "concurrent-queue" -version = "2.5.0" +name = "commonware-codec-macros" +version = "2026.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +checksum = "54ccca449c618a38da9b867c128ec1e7be55cfe0b69b8814c25daac229ac6cc9" dependencies = [ - "crossbeam-utils", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "console" -version = "0.15.11" +name = "commonware-cryptography" +version = "2026.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +checksum = "df6b593210847c596c6657f2fb033c57ac0ef2f7ce68559c0e79040a34b65b7e" dependencies = [ - "encode_unicode", - "libc", + "ahash", + "anyhow", + "aws-lc-rs", + "blake3", + "blst", + "bytes", + "cfg-if", + "chacha20poly1305", + "commonware-codec", + "commonware-formatting", + "commonware-macros", + "commonware-math", + "commonware-parallel", + "commonware-utils", + "cpufeatures 0.3.1", + "crc-fast", + "ctutils", + "curve25519-dalek", + "ecdsa 0.17.0", + "fixedbitset", + "getrandom 0.2.17", + "getrandom 0.4.3", + "hashbrown 0.17.1", + "num-rational", + "num-traits", "once_cell", - "unicode-width 0.2.0", - "windows-sys 0.59.0", + "p256 0.14.0", + "rand 0.10.3", + "rand_chacha 0.10.0", + "rand_core 0.10.1", + "sha2 0.11.0", + "thiserror 2.0.21", + "x25519-dalek", + "zeroize", ] [[package]] -name = "console_error_panic_hook" -version = "0.1.7" +name = "commonware-formatting" +version = "2026.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +checksum = "7cdfc3a5a45f024571d9abce2bdb5a52081f9f017e61b80e2bff89856361ceee" dependencies = [ - "cfg-if", - "wasm-bindgen", + "commonware-macros", + "const-hex", ] [[package]] -name = "const-hex" -version = "1.14.1" +name = "commonware-macros" +version = "2026.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83e22e0ed40b96a48d3db274f72fd365bd78f67af39b6bbd47e8a15e1c6207ff" +checksum = "3058bc70420515d6e92d99510ec2b2a2e1d029a6aced44d243c05ce8d60c6493" dependencies = [ - "cfg-if", - "cpufeatures", - "hex", - "proptest", - "serde", + "commonware-macros-impl", + "tokio", ] [[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - -[[package]] -name = "const_format" -version = "0.2.34" +name = "commonware-macros-impl" +version = "2026.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "126f97965c8ad46d6d9163268ff28432e8f6a1196a55578867832e3049df63dd" +checksum = "bbcf77c39d7486af7394b1d1c4d268d08a5763036b813a2e6aba2ee825b5bd45" dependencies = [ - "const_format_proc_macros", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", + "toml", ] [[package]] -name = "const_format_proc_macros" -version = "0.2.34" +name = "commonware-math" +version = "2026.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +checksum = "e0227b6d4c3e264894c92c682e532128dd22c21bf835c86a6ec6ba9cb15e3f87" dependencies = [ - "proc-macro2", - "quote", - "unicode-xid", + "bytes", + "commonware-codec", + "commonware-macros", + "commonware-parallel", + "commonware-utils", + "rand_core 0.10.1", ] [[package]] -name = "constant_time_eq" -version = "0.3.1" +name = "commonware-parallel" +version = "2026.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10bae95b6d977699c212691a996805e241936d8f958c73a1ebff0190376d1260" +dependencies = [ + "cfg-if", + "commonware-macros", + "dashmap", + "futures", + "rayon", +] + +[[package]] +name = "commonware-utils" +version = "2026.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a611d1e0f4285e396deab2e76cf0554f05fee53e9c3d69a53374ce069b88ab44" +dependencies = [ + "ahash", + "bytes", + "commonware-codec", + "commonware-formatting", + "commonware-macros", + "futures", + "getrandom 0.2.17", + "getrandom 0.3.4", + "getrandom 0.4.3", + "hashbrown 0.17.1", + "num-bigint 0.4.8", + "num-integer", + "num-rational", + "num-traits", + "parking_lot", + "pin-project", + "rand 0.10.3", + "thiserror 2.0.21", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "compression-codecs" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef16c47ba2797aa6a909cc37d39911f3a6743811fe7408ac0b0cc0276b656e9" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "console" +version = "0.16.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e96a4956774c13c126a8b5af4daa79384f4d826534c95a02d76afb39e2ab64e3" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width 0.2.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "const-hex" +version = "1.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e59eef12462b0f9b0a3620219be5d639afd79fe39dff0a42c3997061f9298b4" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "proptest", + "serde_core", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "constant_time_eq" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" [[package]] name = "convert_case" -version = "0.7.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" dependencies = [ "unicode-segmentation", ] @@ -2948,6 +4108,24 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cow-utils" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417bef24afe1460300965a25ff4a24b8b45ad011948302ec221e8a0a81eb2c79" + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -2957,44 +4135,84 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "cranelift-isle" +version = "0.136.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "379f10a451c9b1bf4f59e4ce38061c53ca2454783041a3c1fafa6deef3c37108" + [[package]] name = "crc" -version = "3.3.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" dependencies = [ "crc-catalog", ] [[package]] name = "crc-catalog" -version = "2.4.0" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc-fast" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +checksum = "e75b2483e97a5a7da73ac68a05b629f9c53cff58d8ed1c77866079e18b00dba5" +dependencies = [ + "digest 0.10.7", + "spin", +] [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crop" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fa28d21af9044d49bcfb1eceddeeb9fadc6a17207c6a1bf0d841ef0bd9ad36e" +dependencies = [ + "str_indices", +] + [[package]] name = "crossbeam-channel" -version = "0.5.15" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-deque" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -3002,30 +4220,32 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" [[package]] name = "crossterm" -version = "0.28.1" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.13.2", "crossterm_winapi", + "derive_more", + "document-features", "mio", "parking_lot", - "rustix 0.38.44", + "rustix", "signal-hook", "signal-hook-mio", "winapi", @@ -3059,36 +4279,41 @@ dependencies = [ ] [[package]] -name = "crypto-common" -version = "0.1.6" +name = "crypto-bigint" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" dependencies = [ - "generic-array", - "typenum", + "cpubits", + "ctutils", + "getrandom 0.4.3", + "hybrid-array", + "num-traits", + "rand_core 0.10.1", + "subtle", + "zeroize", ] [[package]] -name = "cssparser" -version = "0.35.0" +name = "crypto-common" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "cssparser-macros", - "dtoa-short", - "itoa", - "phf", - "smallvec", + "generic-array", + "rand_core 0.6.4", + "typenum", ] [[package]] -name = "cssparser-macros" -version = "0.6.1" +name = "crypto-common" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "quote", - "syn 2.0.104", + "getrandom 0.4.3", + "hybrid-array", + "rand_core 0.10.1", ] [[package]] @@ -3097,17 +4322,68 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" dependencies = [ - "cipher", + "cipher 0.4.4", ] [[package]] name = "ctrlc" -version = "3.4.7" +version = "3.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46f93780a459b7d656ef7f071fe699c4d3d2cb201c4b24d085b6ddc505276e73" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" dependencies = [ - "nix 0.30.1", - "windows-sys 0.59.0", + "dispatch2", + "nix 0.31.3", + "windows-sys 0.61.2", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", + "subtle", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "curve25519-dalek-derive", + "digest 0.11.3", + "fiat-crypto", + "rustc_version 0.4.1", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "curve25519-dalek-ng" +version = "4.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c359b7249347e46fb28804470d071c921156ad62b3eef5d34e2ba867533dec8" +dependencies = [ + "byteorder", + "digest 0.9.0", + "rand_core 0.6.4", + "subtle-ng", + "zeroize", ] [[package]] @@ -3122,12 +4398,22 @@ dependencies = [ [[package]] name = "darling" -version = "0.21.0" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a79c4acb1fd5fa3d9304be4c76e031c54d2e92d172a393e24b19a14fe8532fe9" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" dependencies = [ - "darling_core 0.21.0", - "darling_macro 0.21.0", + "darling_core 0.24.1", + "darling_macro 0.24.1", ] [[package]] @@ -3141,21 +4427,34 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] name = "darling_core" -version = "0.21.0" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74875de90daf30eb59609910b84d4d368103aaec4c924824c6799b28f77d6a1d" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "serde", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" dependencies = [ - "fnv", "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.104", + "syn 3.0.6", ] [[package]] @@ -3166,56 +4465,83 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] name = "darling_macro" -version = "0.21.0" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79f8e61677d5df9167cd85265f8e5f64b215cdea3fb55eebc3e622e44c7a146" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core 0.21.0", + "darling_core 0.23.0", "quote", - "syn 2.0.104", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core 0.24.1", + "quote", + "syn 3.0.6", ] [[package]] name = "dashmap" -version = "6.1.0" +version = "6.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" dependencies = [ + "arbitrary", "cfg-if", "crossbeam-utils", "hashbrown 0.14.5", "lock_api", "once_cell", "parking_lot_core", + "serde", ] [[package]] name = "data-encoding" -version = "2.9.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] -name = "dbus" -version = "0.9.7" +name = "defmt" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bb21987b9fb1613058ba3843121dd18b163b254d8a6e797e144cbac14d96d1b" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" dependencies = [ - "libc", - "libdbus-sys", - "winapi", + "bitflags 1.3.2", + "defmt-macros", ] [[package]] -name = "deflate64" -version = "0.1.9" +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.21", +] [[package]] name = "der" @@ -3223,19 +4549,42 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", + "der_derive", + "flagset", "pem-rfc7468", "zeroize", ] +[[package]] +name = "der" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" +dependencies = [ + "const-oid 0.10.2", + "zeroize", +] + +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "deranged" -version = "0.4.0" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", - "serde", + "serde_core", ] [[package]] @@ -3251,24 +4600,24 @@ dependencies = [ [[package]] name = "derive-where" -version = "1.5.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "510c292c8cf384b1a340b816a9a6cf2599eb8f566a44949024af88418000c50b" +checksum = "2e2b94854e8576378ccda7c8de8a66ed8b4e8acbd2c50ec3418ea6c8aaf4b567" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 3.0.6", ] [[package]] name = "derive_arbitrary" -version = "1.4.1" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] @@ -3289,7 +4638,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] @@ -3299,65 +4648,49 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.104", -] - -[[package]] -name = "derive_more" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" -dependencies = [ - "derive_more-impl 1.0.0", + "syn 2.0.119", ] [[package]] name = "derive_more" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" -dependencies = [ - "derive_more-impl 2.0.1", -] - -[[package]] -name = "derive_more-impl" -version = "1.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.104", - "unicode-xid", + "derive_more-impl", ] [[package]] name = "derive_more-impl" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ "convert_case", "proc-macro2", "quote", - "syn 2.0.104", + "rustc_version 0.4.1", + "syn 2.0.119", "unicode-xid", ] [[package]] name = "dialoguer" -version = "0.11.0" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" dependencies = [ "console", "shell-words", - "tempfile", - "thiserror 1.0.69", "zeroize", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.9.0" @@ -3374,20 +4707,51 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", - "const-oid", - "crypto-common", + "const-oid 0.9.6", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "digest-io" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2de63d600bc7fab91180bc17385f29b342468dc8ef2af09dceba450a293de3da" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "dirs" -version = "6.0.0" +version = "7.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" dependencies = [ "dirs-sys", ] +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + [[package]] name = "dirs-sys" version = "0.5.0" @@ -3396,26 +4760,58 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", - "windows-sys 0.60.2", + "redox_users 0.5.3", + "windows-sys 0.61.2", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "block2", + "libc", + "objc2", ] [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 3.0.6", ] [[package]] name = "doctest-file" -version = "1.0.0" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2db04e74f0a9a93103b50e90b96024c9b2bdca8bce6a632ec71b88736d3d359" + +[[package]] +name = "document-features" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aac81fa3e28d21450aa4d2ac065992ba96a1d7303efbce51a95f4fd175b67562" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] [[package]] name = "dotenvy" @@ -3430,31 +4826,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" [[package]] -name = "dtoa" -version = "1.0.10" +name = "dunce" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6add3b8cff394282be81f3fc1a0605db594ed69890078ca6e2cab1c408bcf04" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] -name = "dtoa-short" -version = "0.3.5" +name = "dyn-clone" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" -dependencies = [ - "dtoa", -] +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] -name = "dunce" -version = "1.0.5" +name = "dynify" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +checksum = "81acb15628a3e22358bf73de5e7e62360b8a777dbcb5fc9ac7dfa9ae73723747" +dependencies = [ + "dynify-macros", +] [[package]] -name = "dyn-clone" -version = "1.0.19" +name = "dynify-macros" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005" +checksum = "1ec431cd708430d5029356535259c5d645d60edd3d39c54e5eea9782d46caa7d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "ecdsa" @@ -3462,13 +4863,43 @@ version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "der", + "der 0.7.10", "digest 0.10.7", - "elliptic-curve", - "rfc6979", - "serdect", - "signature", - "spki", + "elliptic-curve 0.13.8", + "rfc6979 0.4.0", + "serdect 0.2.0", + "signature 2.2.0", + "spki 0.7.3", +] + +[[package]] +name = "ecdsa" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" +dependencies = [ + "der 0.8.2", + "digest 0.11.3", + "elliptic-curve 0.14.1", + "rfc6979 0.6.0", + "signature 3.0.0", + "spki 0.8.0", + "zeroize", +] + +[[package]] +name = "ed25519-consensus" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8465edc8ee7436ffea81d21a019b16676ee3db267aa8d5a8d729581ecf998b" +dependencies = [ + "curve25519-dalek-ng", + "hex", + "rand_core 0.6.4", + "serde", + "sha2 0.9.9", + "thiserror 1.0.69", + "zeroize", ] [[package]] @@ -3480,28 +4911,16 @@ dependencies = [ "enum-ordinalize", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" -dependencies = [ - "serde", -] - -[[package]] -name = "elasticlunr-rs" -version = "3.0.2" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41e83863a500656dfa214fee6682de9c5b9f03de6860fec531235ed2ae9f6571" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" dependencies = [ - "regex", "serde", - "serde_derive", - "serde_json", ] [[package]] @@ -3510,43 +4929,54 @@ version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ - "base16ct", - "crypto-bigint", + "base16ct 0.2.0", + "crypto-bigint 0.5.5", "digest 0.10.7", - "ff", + "ff 0.13.1", "generic-array", - "group", + "group 0.13.0", "pem-rfc7468", - "pkcs8", + "pkcs8 0.10.2", "rand_core 0.6.4", - "sec1", - "serdect", + "sec1 0.7.3", + "serdect 0.2.0", + "subtle", + "zeroize", +] + +[[package]] +name = "elliptic-curve" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" +dependencies = [ + "base16ct 1.0.0", + "crypto-bigint 0.7.5", + "crypto-common 0.2.2", + "digest 0.11.3", + "ff 0.14.0", + "group 0.14.0", + "hybrid-array", + "pkcs8 0.11.0", + "rand_core 0.10.1", + "sec1 0.8.1", "subtle", "zeroize", ] [[package]] name = "email-address-parser" -version = "2.0.0" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe19a4967eca30062be4abaf813d929ba48b3bfb21830367f7e1baae37f213a" +checksum = "e981c3b50d728bb498dd0f860a7228ef17e19efef5cc2c6e30d78ebce13bcaa7" dependencies = [ "console_error_panic_hook", "pest", "pest_derive", - "quick-xml 0.18.1", + "quick-xml 0.39.4", "wasm-bindgen", ] -[[package]] -name = "ena" -version = "0.14.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d248bdd43ce613d87415282f69b9bb99d947d290b10962dd6c56233312c2ad5" -dependencies = [ - "log", -] - [[package]] name = "encode_unicode" version = "1.0.0" @@ -3555,66 +4985,62 @@ checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" [[package]] name = "encoding_rs" -version = "0.8.35" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" dependencies = [ "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", ] [[package]] name = "endian-type" -version = "0.1.2" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" +checksum = "869b0adbda23651a9c5c0c3d270aac9fcb52e8622a8f2b17e57802d7791962f2" [[package]] name = "enum-ordinalize" -version = "4.3.0" +version = "4.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fea0dcfa4e54eeb516fe454635a95753ddd39acda650ce703031c6973e315dd5" +checksum = "89dd01549b09589510cf0647475075d12071456586d70f5c75c98ae2a5537677" dependencies = [ "enum-ordinalize-derive", ] [[package]] name = "enum-ordinalize-derive" -version = "4.3.1" +version = "4.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d28318a75d4aead5c4db25382e8ef717932d0346600cacae6357eb5941bc5ff" +checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 3.0.6", ] [[package]] -name = "env_filter" -version = "0.1.3" +name = "equator" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" dependencies = [ - "log", - "regex", + "equator-macro", ] [[package]] -name = "env_home" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" - -[[package]] -name = "env_logger" -version = "0.11.8" +name = "equator-macro" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" dependencies = [ - "anstream", - "anstyle", - "env_filter", - "jiff", - "log", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -3625,29 +5051,30 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "erased-serde" -version = "0.4.6" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e004d887f51fcb9fef17317a2f3525c887d8aa3f4f50fed920816a688284a5b7" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" dependencies = [ "serde", + "serde_core", "typeid", ] [[package]] name = "errno" -version = "0.3.13" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "error-code" -version = "3.3.2" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" [[package]] name = "eth-keystore" @@ -3659,18 +5086,58 @@ dependencies = [ "ctr", "digest 0.10.7", "hex", - "hmac", + "hmac 0.12.1", "pbkdf2 0.11.0", - "rand 0.8.5", + "rand 0.8.8", "scrypt", "serde", "serde_json", "sha2 0.10.9", - "sha3", + "sha3 0.10.9", "thiserror 1.0.69", "uuid 0.8.2", ] +[[package]] +name = "ethereum_serde_utils" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38df44a7a271ab43835678f9215b53cc2523e4714a215da6643d83dc110245da" +dependencies = [ + "alloy-primitives", + "hex", + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "ethereum_ssz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e462875ad8693755ea8913d6e905715c76ea4836e2254e18c9cf0f7a8f8c2a13" +dependencies = [ + "alloy-primitives", + "ethereum_serde_utils", + "itertools 0.14.0", + "serde", + "serde_derive", + "smallvec", + "typenum", +] + +[[package]] +name = "ethereum_ssz_derive" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf022360bdbe9456eda5f35718a50476d5b2a0d51a97ed4eae27420737a6fba" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "event-listener" version = "4.0.3" @@ -3684,9 +5151,9 @@ dependencies = [ [[package]] name = "evm-disassembler" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ded685d9f07315ff689ba56e7d84e6f1e782db19b531a46c34061a733bba7258" +checksum = "ace07bd9e99c61333fa7b95b264f0f814bdfc4ec6714f67ffd8e703b25edb2be" dependencies = [ "eyre", "hex", @@ -3694,30 +5161,50 @@ dependencies = [ [[package]] name = "evmole" -version = "0.8.0" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c29ecc930ee2ed03083436c2ddd7e5292c3c3bcda65f6a37369502d578a853f1" +checksum = "d6193260884881a920613c477a0b457af872b228b939ec26c60f7e66a5e0c508" dependencies = [ "alloy-dyn-abi", "alloy-primitives", - "indexmap 2.10.0", + "indexmap 2.14.2", + "minicbor", ] [[package]] name = "eyre" -version = "0.6.12" +version = "0.6.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +checksum = "c08309dbcc659c5549a24ddb9b27027640641b282ef5768267c7e675558986a3" dependencies = [ + "autocfg", "indenter", "once_cell", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fast-float2" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6e8948ce679d00a02a94739ea185595dca7118ed04feb991127e443bd3d761f" + [[package]] name = "fastrand" -version = "2.3.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "fastrlp" @@ -3748,7 +5235,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", - "rustix 1.0.8", + "rustix", "windows-sys 0.59.0", ] @@ -3773,21 +5260,52 @@ dependencies = [ ] [[package]] -name = "figment" -version = "0.10.19" +name = "ff" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" dependencies = [ - "atomic", - "parking_lot", - "pear", - "serde", - "tempfile", - "toml 0.8.23", + "rand_core 0.10.1", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + +[[package]] +name = "figment2" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87d63dee16df12076c7770919713c0b92f4e1c85eac828dc2ade0b6c998f016b" +dependencies = [ + "atomic", + "parking_lot", + "serde", + "tempfile", + "toml_edit", "uncased", "version_check", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "fixed-cache" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fe63500644ef0269fe6b744e7e5dc5c20b5eebf3d881bc2be53f194636f6583" +dependencies = [ + "equivalent", + "rapidhash", +] + [[package]] name = "fixed-hash" version = "0.8.0" @@ -3795,25 +5313,63 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534" dependencies = [ "byteorder", - "rand 0.8.5", + "rand 0.8.8", "rustc-hex", "static_assertions", ] +[[package]] +name = "fixed-map" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86ed19add84e8cb9e8cc5f7074de0324247149ffef0b851e215fb0edc50c229b" +dependencies = [ + "fixed-map-derive", + "serde", +] + +[[package]] +name = "fixed-map-derive" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dc7a9cb3326bafb80642c5ce99b39a2c0702d4bfa8ee8a3e773791a6cbe2407" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "fixedbitset" version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "flate2" -version = "1.1.2" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", - "miniz_oxide", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "float16" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9442b5e9a7997eace4990debae01c1b69a1a601b9eeef7fada6c3f3edeff4a1" +dependencies = [ + "cfg-if", + "rustc_version 0.4.1", ] [[package]] @@ -3828,11 +5384,18 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "forge" -version = "1.3.0" +version = "1.8.6" dependencies = [ "alloy-chains", + "alloy-consensus", "alloy-dyn-abi", "alloy-hardforks", "alloy-json-abi", @@ -3840,21 +5403,24 @@ dependencies = [ "alloy-primitives", "alloy-provider", "alloy-rpc-types", - "alloy-serde", "alloy-signer", "alloy-signer-local", "alloy-transport", "anvil", + "async-lsp", "axum", + "base-common-consensus", "chrono", "clap", "clap_complete", - "clap_complete_fig", "clearscreen", "comfy-table", + "ctrlc", + "dialoguer", "dunce", "evm-disassembler", "eyre", + "flate2", "forge-doc", "forge-fmt", "forge-lint", @@ -3869,127 +5435,126 @@ dependencies = [ "foundry-config", "foundry-debugger", "foundry-evm", - "foundry-evm-core", + "foundry-evm-networks", + "foundry-evm-symbolic", "foundry-linking", - "foundry-solang-parser", "foundry-test-utils", + "foundry-tui", "foundry-wallets", "futures", "globset", "indicatif", "inferno", - "itertools 0.14.0", + "itertools 0.15.0", "mockall", - "opener 0.7.2", + "opener", "parking_lot", - "paste", "path-slash", "proptest", "quick-junit", + "rand 0.9.5", "rayon", "regex", - "reqwest", + "reqwest 0.13.5", "revm", - "semver 1.0.26", + "rexpect", + "rustix", + "semver 1.0.28", "serde", "serde_json", "similar", "similar-asserts", - "solar-parse", - "solar-sema", + "solar-compiler", + "solar-lsp", "soldeer-commands", - "strum 0.27.2", + "soldeer-core", "svm-rs", "tempfile", - "thiserror 2.0.12", + "tempo-alloy", + "thiserror 2.0.21", "tokio", + "tokio-util", "toml_edit", - "tower-http", + "tower-http 0.7.1", "tracing", + "url", "watchexec", "watchexec-events", "watchexec-signals", + "which", "yansi", ] [[package]] name = "forge-doc" -version = "1.3.0" +version = "1.8.6" dependencies = [ "alloy-primitives", - "derive_more 2.0.1", "eyre", - "forge-fmt", "foundry-common", "foundry-compilers", "foundry-config", - "foundry-solang-parser", - "itertools 0.14.0", - "mdbook", + "markdown", + "path-slash", "rayon", - "regex", "serde", "serde_json", - "thiserror 2.0.12", - "toml 0.8.23", + "solar-compiler", + "tempfile", "tracing", ] [[package]] name = "forge-fmt" -version = "1.3.0" +version = "1.8.6" dependencies = [ - "alloy-primitives", - "ariadne", + "foundry-common", "foundry-config", - "foundry-solang-parser", - "itertools 0.14.0", - "similar-asserts", - "thiserror 2.0.12", - "toml 0.8.23", - "tracing", - "tracing-subscriber 0.3.19", + "foundry-test-utils", + "itertools 0.15.0", + "similar", + "snapbox", + "solar-compiler", + "toml", ] [[package]] name = "forge-lint" -version = "1.3.0" +version = "1.8.6" dependencies = [ + "alloy-primitives", + "eyre", "foundry-common", "foundry-compilers", "foundry-config", "heck", - "rayon", - "solar-ast", - "solar-data-structures", - "solar-interface", - "solar-parse", - "solar-sema", - "thiserror 2.0.12", + "solar-compiler", + "solar-lint", + "thiserror 2.0.21", ] [[package]] name = "forge-script" -version = "1.3.0" +version = "1.8.6" dependencies = [ "alloy-chains", "alloy-consensus", "alloy-dyn-abi", "alloy-eips", + "alloy-evm", "alloy-json-abi", "alloy-network", "alloy-primitives", "alloy-provider", "alloy-rpc-types", - "alloy-serde", "alloy-signer", + "alloy-signer-local", + "anvil", "clap", "dialoguer", - "dunce", "eyre", "forge-script-sequence", "forge-verify", - "foundry-block-explorers", "foundry-cheatcodes", "foundry-cli", "foundry-common", @@ -3997,17 +5562,22 @@ dependencies = [ "foundry-config", "foundry-debugger", "foundry-evm", + "foundry-evm-networks", "foundry-linking", "foundry-wallets", "futures", "indicatif", - "itertools 0.14.0", + "itertools 0.15.0", + "monad-revm", "parking_lot", - "revm-inspectors", - "semver 1.0.26", + "reqwest 0.13.5", + "semver 1.0.28", "serde", "serde_json", "tempfile", + "tempo-alloy", + "tempo-primitives", + "thiserror 2.0.21", "tokio", "tracing", "yansi", @@ -4015,7 +5585,7 @@ dependencies = [ [[package]] name = "forge-script-sequence" -version = "1.3.0" +version = "1.8.6" dependencies = [ "alloy-network", "alloy-primitives", @@ -4031,26 +5601,29 @@ dependencies = [ [[package]] name = "forge-sol-macro-gen" -version = "1.3.0" +version = "1.8.6" dependencies = [ + "alloy-json-abi", "alloy-sol-macro-expander", "alloy-sol-macro-input", "eyre", "foundry-common", "heck", - "prettyplease", + "prettyplease 0.2.37", "proc-macro2", "quote", - "serde_json", - "syn 2.0.104", + "rayon", + "syn 2.0.119", ] [[package]] name = "forge-verify" -version = "1.3.0" +version = "1.8.6" dependencies = [ + "alloy-consensus", "alloy-dyn-abi", "alloy-json-abi", + "alloy-network", "alloy-primitives", "alloy-provider", "alloy-rpc-types", @@ -4063,73 +5636,73 @@ dependencies = [ "foundry-compilers", "foundry-config", "foundry-evm", - "foundry-evm-core", + "foundry-evm-networks", "foundry-test-utils", "futures", - "itertools 0.14.0", + "itertools 0.15.0", + "monad-revm", "regex", - "reqwest", + "reqwest 0.13.5", "revm", - "semver 1.0.26", + "semver 1.0.28", "serde", "serde_json", "tempfile", "tokio", "tracing", + "url", "yansi", ] [[package]] name = "form_urlencoded" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] [[package]] name = "foundry-bench" -version = "0.1.0" +version = "1.8.6" dependencies = [ "chrono", "clap", "color-eyre", + "ctrlc", "eyre", "foundry-common", "foundry-compilers", - "foundry-config", "foundry-test-utils", - "num_cpus", + "libc", "once_cell", "rayon", "serde", "serde_json", "tempfile", - "tokio", ] [[package]] name = "foundry-block-explorers" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc107bbc3b4480995fdf337ca0ddedc631728175f418d3136ead9df8f4dc465e" +version = "0.24.0" +source = "git+https://github.com/foundry-rs/foundry-core?rev=39c03a49ade2f74a6e97e2af308283a42d78d302#39c03a49ade2f74a6e97e2af308283a42d78d302" dependencies = [ "alloy-chains", "alloy-json-abi", "alloy-primitives", "foundry-compilers", - "reqwest", - "semver 1.0.26", + "reqwest 0.13.5", + "semver 1.0.28", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.21", "tracing", ] [[package]] name = "foundry-cheatcodes" -version = "1.3.0" +version = "1.8.6" dependencies = [ "alloy-chains", "alloy-consensus", @@ -4146,9 +5719,10 @@ dependencies = [ "alloy-signer", "alloy-signer-local", "alloy-sol-types", - "base64 0.22.1", + "base64 0.23.1", "dialoguer", - "ecdsa", + "ecdsa 0.16.9", + "ed25519-consensus", "eyre", "forge-script-sequence", "foundry-cheatcodes-spec", @@ -4156,90 +5730,113 @@ dependencies = [ "foundry-compilers", "foundry-config", "foundry-evm-core", + "foundry-evm-fuzz", "foundry-evm-traces", "foundry-wallets", - "itertools 0.14.0", + "itertools 0.15.0", "jsonpath_lib", "k256", "memchr", - "p256", + "monad-revm", + "p256 0.13.2", "parking_lot", "proptest", - "rand 0.9.1", + "rand 0.9.5", "revm", - "revm-inspectors", - "semver 1.0.26", + "semver 1.0.28", "serde", "serde_json", - "thiserror 2.0.12", - "toml 0.8.23", + "solar-compiler", + "tempfile", + "tempo-alloy", + "tempo-contracts", + "tempo-hardfork", + "tempo-precompiles", + "tempo-primitives", + "thiserror 2.0.21", + "toml", + "toml_edit", "tracing", "walkdir", ] [[package]] name = "foundry-cheatcodes-spec" -version = "1.3.0" +version = "1.8.6" dependencies = [ "alloy-sol-types", "foundry-macros", - "schemars 0.8.22", + "schemars 1.2.2", "serde", "serde_json", ] [[package]] name = "foundry-cli" -version = "1.3.0" +version = "1.8.6" dependencies = [ "alloy-chains", "alloy-dyn-abi", "alloy-eips", "alloy-ens", "alloy-json-abi", + "alloy-network", "alloy-primitives", "alloy-provider", "alloy-rlp", + "alloy-signer", "cfg-if", "clap", + "clap_complete", + "clap_complete_nushell", "color-eyre", "dotenvy", - "dunce", "eyre", - "forge-fmt", - "foundry-block-explorers", + "foundry-cli-markdown", "foundry-common", "foundry-compilers", "foundry-config", - "foundry-debugger", "foundry-evm", + "foundry-evm-networks", "foundry-wallets", "futures", "indicatif", - "itertools 0.14.0", + "itertools 0.15.0", "mimalloc", + "path-slash", "rayon", "regex", "rustls", + "semver 1.0.28", "serde", "serde_json", - "solar-sema", + "solar-compiler", + "soldeer-commands", "strsim", - "strum 0.27.2", "tempfile", + "tempo-primitives", "tikv-jemallocator", "tokio", + "toml", "tracing", - "tracing-subscriber 0.3.19", + "tracing-subscriber", "tracing-tracy", - "tracy-client", "yansi", ] +[[package]] +name = "foundry-cli-markdown" +version = "1.8.6" +dependencies = [ + "clap", + "pretty_assertions", +] + [[package]] name = "foundry-common" -version = "1.3.0" +version = "1.8.6" dependencies = [ + "alloy-chains", "alloy-consensus", "alloy-dyn-abi", "alloy-eips", @@ -4249,17 +5846,23 @@ dependencies = [ "alloy-primitives", "alloy-provider", "alloy-pubsub", + "alloy-rlp", "alloy-rpc-client", "alloy-rpc-types", - "alloy-serde", + "alloy-rpc-types-engine", + "alloy-signer", + "alloy-signer-local", "alloy-sol-types", "alloy-transport", - "alloy-transport-http", "alloy-transport-ipc", + "alloy-transport-mpp", "alloy-transport-ws", "anstream", "anstyle", "axum", + "base-common-network", + "base-common-rpc-types", + "base64 0.23.1", "chrono", "ciborium", "clap", @@ -4271,31 +5874,41 @@ dependencies = [ "foundry-common-fmt", "foundry-compilers", "foundry-config", - "itertools 0.14.0", + "foundry-evm-hardforks", + "foundry-wallets", + "itertools 0.15.0", "jiff", + "k256", + "mpp", "num-format", + "op-alloy-network", + "op-alloy-rpc-types", "path-slash", "regex", - "reqwest", - "semver 1.0.26", + "reqwest 0.13.5", + "revm", + "semver 1.0.28", "serde", "serde_json", - "solar-parse", - "solar-sema", - "terminal_size", - "thiserror 2.0.12", + "sha2 0.11.0", + "solar-compiler", + "tempfile", + "tempo-alloy", + "tempo-primitives", + "thiserror 2.0.21", "tokio", "tower", "tracing", "url", - "vergen", + "vergen-gitcl", + "wait-timeout", "walkdir", "yansi", ] [[package]] name = "foundry-common-fmt" -version = "1.3.0" +version = "1.8.6" dependencies = [ "alloy-consensus", "alloy-dyn-abi", @@ -4303,47 +5916,50 @@ dependencies = [ "alloy-primitives", "alloy-rpc-types", "alloy-serde", + "base-common-consensus", + "base-common-rpc-types", "chrono", + "comfy-table", + "eyre", "foundry-macros", + "op-alloy-consensus", + "op-alloy-rpc-types", + "proptest", "revm", "serde", "serde_json", "similar-asserts", + "tempo-alloy", "yansi", ] [[package]] name = "foundry-compilers" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f163b01cdad921f139776084391db2f1f5fb206ce2395f1847f0c1e992a89a4f" +version = "0.21.0" +source = "git+https://github.com/foundry-rs/foundry-core?rev=39c03a49ade2f74a6e97e2af308283a42d78d302#39c03a49ade2f74a6e97e2af308283a42d78d302" dependencies = [ "alloy-json-abi", "alloy-primitives", "auto_impl", - "derive_more 1.0.0", - "dirs", + "derive_more", "dyn-clone", "foundry-compilers-artifacts", "foundry-compilers-core", "fs_extra", - "futures-util", - "home", - "itertools 0.14.0", + "itertools 0.15.0", "path-slash", - "rand 0.8.5", + "rand 0.10.3", "rayon", - "semver 1.0.26", + "semver 1.0.28", "serde", "serde_json", - "sha2 0.10.9", - "solar-parse", - "solar-sema", + "sha2 0.11.0", + "simdutf8", + "solar-compiler", "svm-rs", "svm-rs-builds", "tempfile", - "thiserror 2.0.12", - "tokio", + "thiserror 2.0.21", "tracing", "winnow", "yansi", @@ -4351,9 +5967,8 @@ dependencies = [ [[package]] name = "foundry-compilers-artifacts" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2676d70082ed23680fe2d08c0b750d5f7f2438c6d946f1cb140a76c5e5e0392" +version = "0.21.0" +source = "git+https://github.com/foundry-rs/foundry-core?rev=39c03a49ade2f74a6e97e2af308283a42d78d302#39c03a49ade2f74a6e97e2af308283a42d78d302" dependencies = [ "foundry-compilers-artifacts-solc", "foundry-compilers-artifacts-vyper", @@ -4361,60 +5976,54 @@ dependencies = [ [[package]] name = "foundry-compilers-artifacts-solc" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3ada94dc5946334bb08df574855ba345ab03ba8c6f233560c72c8d61fa9db80" +version = "0.21.0" +source = "git+https://github.com/foundry-rs/foundry-core?rev=39c03a49ade2f74a6e97e2af308283a42d78d302#39c03a49ade2f74a6e97e2af308283a42d78d302" dependencies = [ "alloy-json-abi", "alloy-primitives", "foundry-compilers-core", - "futures-util", + "memchr", "path-slash", "rayon", "regex", - "semver 1.0.26", + "semver 1.0.28", "serde", "serde_json", - "thiserror 2.0.12", - "tokio", + "thiserror 2.0.21", "tracing", - "walkdir", "yansi", ] [[package]] name = "foundry-compilers-artifacts-vyper" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "372052af72652e375a6e7eed22179bd8935114e25e1c5a8cca7f00e8f20bd94c" +version = "0.21.0" +source = "git+https://github.com/foundry-rs/foundry-core?rev=39c03a49ade2f74a6e97e2af308283a42d78d302#39c03a49ade2f74a6e97e2af308283a42d78d302" dependencies = [ "alloy-json-abi", "alloy-primitives", "foundry-compilers-artifacts-solc", "foundry-compilers-core", - "path-slash", - "semver 1.0.26", + "semver 1.0.28", "serde", ] [[package]] name = "foundry-compilers-core" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf0962c46855979300f6526ed57f987ccf6a025c2b92ce574b281d9cb2ef666b" +version = "0.21.0" +source = "git+https://github.com/foundry-rs/foundry-core?rev=39c03a49ade2f74a6e97e2af308283a42d78d302#39c03a49ade2f74a6e97e2af308283a42d78d302" dependencies = [ "alloy-primitives", - "cfg-if", "dunce", "fs_extra", "path-slash", "regex", - "semver 1.0.26", + "semver 1.0.28", "serde", "serde_json", + "simdutf8", "svm-rs", "tempfile", - "thiserror 2.0.12", + "thiserror 2.0.21", "tokio", "walkdir", "xxhash-rust", @@ -4422,7 +6031,7 @@ dependencies = [ [[package]] name = "foundry-config" -version = "1.3.0" +version = "1.8.6" dependencies = [ "alloy-chains", "alloy-primitives", @@ -4430,60 +6039,68 @@ dependencies = [ "dirs", "dunce", "eyre", - "figment", + "figment2", "foundry-block-explorers", "foundry-compilers", + "foundry-evm-hardforks", + "foundry-evm-networks", "glob", "globset", "heck", - "itertools 0.14.0", + "itertools 0.15.0", "mesc", - "number_prefix", "path-slash", + "rayon", "regex", - "reqwest", - "revm", - "semver 1.0.26", + "semver 1.0.28", "serde", "serde_json", + "shlex", "similar-asserts", - "solar-interface", - "solar-parse", + "snapbox", + "solar-compiler", "soldeer-core", + "strum 0.28.0", "tempfile", - "thiserror 2.0.12", - "toml 0.8.23", + "thiserror 2.0.21", + "toml", "toml_edit", "tracing", + "unit-prefix", "walkdir", + "which", "yansi", ] [[package]] name = "foundry-debugger" -version = "1.3.0" +version = "1.8.6" dependencies = [ + "alloy-dyn-abi", "alloy-primitives", + "clap", "crossterm", "eyre", "foundry-common", "foundry-compilers", "foundry-evm-core", "foundry-evm-traces", + "foundry-tui", "ratatui", "revm", - "revm-inspectors", "serde", "tracing", ] [[package]] name = "foundry-evm" -version = "1.3.0" +version = "1.8.6" dependencies = [ "alloy-dyn-abi", + "alloy-eips", "alloy-evm", "alloy-json-abi", + "alloy-monad-evm", "alloy-primitives", "alloy-rpc-types", "alloy-sol-types", @@ -4495,65 +6112,103 @@ dependencies = [ "foundry-evm-core", "foundry-evm-coverage", "foundry-evm-fuzz", + "foundry-evm-hardforks", + "foundry-evm-networks", + "foundry-evm-sancov", "foundry-evm-traces", "indicatif", + "monad-revm", "parking_lot", "proptest", + "rand 0.9.5", + "rayon", "revm", "revm-inspectors", "serde", "serde_json", - "thiserror 2.0.12", + "tempo-precompiles", + "tempo-primitives", + "tempo-revm", + "thiserror 2.0.21", + "tokio", "tracing", - "uuid 1.17.0", + "uuid 1.26.1", ] [[package]] name = "foundry-evm-abi" -version = "1.3.0" +version = "1.8.6" dependencies = [ "alloy-primitives", "alloy-sol-types", - "derive_more 2.0.1", + "derive_more", "foundry-common-fmt", "foundry-macros", - "itertools 0.14.0", + "itertools 0.15.0", ] [[package]] name = "foundry-evm-core" -version = "1.3.0" +version = "1.8.6" dependencies = [ "alloy-chains", "alloy-consensus", "alloy-dyn-abi", + "alloy-eips", "alloy-evm", "alloy-genesis", "alloy-hardforks", "alloy-json-abi", + "alloy-monad-evm", "alloy-network", "alloy-op-evm", "alloy-primitives", "alloy-provider", + "alloy-rlp", + "alloy-rpc-client", "alloy-rpc-types", + "alloy-serde", + "alloy-signer", + "alloy-signer-local", "alloy-sol-types", + "anvil", "auto_impl", + "base-common-chains", + "base-common-consensus", + "base-common-evm", + "base-common-network", + "base-common-precompiles", + "base-common-rpc-types", "eyre", "foundry-cheatcodes-spec", "foundry-common", + "foundry-compilers", "foundry-config", "foundry-evm-abi", + "foundry-evm-hardforks", + "foundry-evm-networks", "foundry-fork-db", "foundry-test-utils", "futures", - "itertools 0.14.0", + "itertools 0.15.0", + "monad-revm", + "op-alloy-consensus", + "op-alloy-network", + "op-alloy-rpc-types", "op-revm", "parking_lot", "revm", "revm-inspectors", "serde", "serde_json", - "thiserror 2.0.12", + "tempo-alloy", + "tempo-contracts", + "tempo-evm", + "tempo-hardfork", + "tempo-precompiles", + "tempo-primitives", + "tempo-revm", + "thiserror 2.0.21", "tokio", "tracing", "url", @@ -4561,7 +6216,7 @@ dependencies = [ [[package]] name = "foundry-evm-coverage" -version = "1.3.0" +version = "1.8.6" dependencies = [ "alloy-primitives", "eyre", @@ -4570,13 +6225,14 @@ dependencies = [ "foundry-evm-core", "rayon", "revm", - "semver 1.0.26", + "semver 1.0.28", + "solar-compiler", "tracing", ] [[package]] name = "foundry-evm-fuzz" -version = "1.3.0" +version = "1.8.6" dependencies = [ "alloy-dyn-abi", "alloy-json-abi", @@ -4588,53 +6244,131 @@ dependencies = [ "foundry-evm-core", "foundry-evm-coverage", "foundry-evm-traces", - "itertools 0.14.0", + "itertools 0.15.0", "parking_lot", "proptest", - "rand 0.9.1", + "rand 0.9.5", "revm", "serde", - "thiserror 2.0.12", + "solar-compiler", + "thiserror 2.0.21", "tracing", ] [[package]] -name = "foundry-evm-traces" -version = "1.3.0" +name = "foundry-evm-hardforks" +version = "1.8.6" dependencies = [ - "alloy-dyn-abi", - "alloy-json-abi", - "alloy-primitives", - "alloy-sol-types", - "eyre", - "foundry-block-explorers", - "foundry-common", + "alloy-chains", + "alloy-hardforks", + "alloy-op-hardforks", + "alloy-rpc-types", + "base-common-evm", + "base-common-genesis", "foundry-compilers", - "foundry-config", - "foundry-evm-core", - "foundry-linking", - "futures", - "itertools 0.14.0", - "rayon", + "monad-revm", + "op-revm", + "revm", + "serde", + "tempo-hardfork", +] + +[[package]] +name = "foundry-evm-networks" +version = "1.8.6" +dependencies = [ + "alloy-chains", + "alloy-eips", + "alloy-evm", + "alloy-op-hardforks", + "alloy-primitives", + "base-common-precompiles", + "clap", + "foundry-evm-hardforks", + "monad-revm", "revm", - "revm-inspectors", "serde", "serde_json", - "solar-parse", + "tempo-contracts", +] + +[[package]] +name = "foundry-evm-sancov" +version = "1.8.6" + +[[package]] +name = "foundry-evm-symbolic" +version = "1.8.6" +dependencies = [ + "alloy-dyn-abi", + "alloy-json-abi", + "alloy-primitives", + "alloy-signer", + "alloy-signer-local", + "alloy-sol-types", + "base64 0.23.1", + "foundry-cheatcodes-spec", + "foundry-common", + "foundry-config", + "foundry-evm", + "hashbrown 0.17.1", + "inturn", + "serde", + "serde_json", + "shlex", + "thiserror 2.0.21", + "tracing", + "wait-timeout", +] + +[[package]] +name = "foundry-evm-traces" +version = "1.8.6" +dependencies = [ + "alloy-dyn-abi", + "alloy-json-abi", + "alloy-primitives", + "alloy-sol-types", + "async-trait", + "base-common-precompiles", + "ciborium", + "eyre", + "foundry-block-explorers", + "foundry-common", + "foundry-compilers", + "foundry-config", + "foundry-evm-core", + "foundry-evm-hardforks", + "foundry-evm-networks", + "foundry-linking", + "futures", + "itertools 0.15.0", + "memchr", + "monad-revm", + "rayon", + "reqwest 0.13.5", + "revm", + "revm-inspectors", + "ron", + "serde", + "serde_json", + "snapbox", + "solar-compiler", "tempfile", + "tempo-contracts", + "tempo-precompiles", "tokio", "tracing", + "yansi", ] [[package]] name = "foundry-fork-db" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bdf390c3633b0eb14c6bb26a0aeb63ea0200f1350ccbe07493f23148f58c4a5" +version = "0.27.0" +source = "git+https://github.com/foundry-rs/foundry-core?rev=39c03a49ade2f74a6e97e2af308283a42d78d302#39c03a49ade2f74a6e97e2af308283a42d78d302" dependencies = [ "alloy-chains", "alloy-consensus", - "alloy-hardforks", "alloy-primitives", "alloy-provider", "alloy-rpc-types", @@ -4644,117 +6378,172 @@ dependencies = [ "revm", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.21", "tokio", "tracing", "url", + "zstd 0.14.0", ] [[package]] name = "foundry-linking" -version = "1.3.0" +version = "1.8.6" dependencies = [ "alloy-primitives", "foundry-compilers", - "semver 1.0.26", - "thiserror 2.0.12", + "rayon", + "semver 1.0.28", + "thiserror 2.0.21", ] [[package]] name = "foundry-macros" -version = "1.3.0" +version = "1.8.6" dependencies = [ - "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] -name = "foundry-solang-parser" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9645e75b89f977423690f3b4bfd8d84825e5fdabd7803cbce6d4a2c4d54972b4" +name = "foundry-primitives" +version = "1.8.6" dependencies = [ - "itertools 0.14.0", - "lalrpop", - "lalrpop-util", - "phf", - "thiserror 2.0.12", - "unicode-xid", + "alloy-consensus", + "alloy-eips", + "alloy-evm", + "alloy-network", + "alloy-op-evm", + "alloy-primitives", + "alloy-provider", + "alloy-rlp", + "alloy-rpc-types", + "alloy-rpc-types-eth", + "alloy-serde", + "alloy-signer", + "base-common-consensus", + "base-common-evm", + "base-common-rpc-types", + "derive_more", + "op-alloy-consensus", + "op-alloy-rpc-types", + "op-revm", + "revm", + "serde", + "serde_json", + "tempo-alloy", + "tempo-primitives", + "tempo-revm", +] + +[[package]] +name = "foundry-test-macros" +version = "1.8.6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] name = "foundry-test-utils" -version = "1.3.0" +version = "1.8.6" dependencies = [ + "alloy-chains", "alloy-primitives", "alloy-provider", + "axum", "eyre", "fd-lock", "foundry-block-explorers", "foundry-common", "foundry-compilers", "foundry-config", + "foundry-test-macros", "idna_adapter", "parking_lot", - "rand 0.9.1", + "rand 0.9.5", "regex", + "reqwest 0.13.5", "serde_json", "snapbox", + "svm-rs", "tempfile", "tokio", "tracing", - "tracing-subscriber 0.3.19", + "tracing-subscriber", "ui_test", - "zip-extract", +] + +[[package]] +name = "foundry-tui" +version = "1.8.6" +dependencies = [ + "crossterm", + "ratatui", ] [[package]] name = "foundry-wallets" -version = "1.3.0" +version = "0.1.0" +source = "git+https://github.com/foundry-rs/foundry-core?rev=39c03a49ade2f74a6e97e2af308283a42d78d302#39c03a49ade2f74a6e97e2af308283a42d78d302" dependencies = [ "alloy-consensus", "alloy-dyn-abi", "alloy-network", "alloy-primitives", + "alloy-rlp", "alloy-signer", "alloy-signer-aws", "alloy-signer-gcp", "alloy-signer-ledger", "alloy-signer-local", "alloy-signer-trezor", + "alloy-signer-turnkey", "alloy-sol-types", "async-trait", "aws-config", - "aws-sdk-kms", + "axum", "clap", "derive_builder", + "dirs", "eth-keystore", "eyre", - "foundry-config", - "gcloud-sdk", + "hidapi-rusb", "rpassword", + "rusb", "serde", - "thiserror 2.0.12", + "serde_json", + "tempfile", + "tempo-alloy", + "thiserror 2.0.21", + "time", "tokio", + "tower", + "tower-http 0.7.1", "tracing", + "uuid 1.26.1", + "webbrowser", + "zeroize", ] [[package]] name = "fragile" -version = "2.0.1" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" +checksum = "8878864ba14bb86e818a412bfd6f18f9eabd4ec0f008a28e8f7eb61db532fcf9" +dependencies = [ + "futures-core", +] [[package]] -name = "fs4" -version = "0.13.1" +name = "fs2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" dependencies = [ - "rustix 1.0.8", - "windows-sys 0.59.0", + "libc", + "winapi", ] [[package]] @@ -4778,21 +6567,11 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" -[[package]] -name = "futf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" -dependencies = [ - "mac", - "new_debug_unreachable", -] - [[package]] name = "futures" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -4805,25 +6584,38 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", ] +[[package]] +name = "futures-concurrency" +version = "7.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" +dependencies = [ + "fixedbitset", + "futures-core", + "futures-lite", + "pin-project", + "smallvec", +] + [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -4832,38 +6624,51 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 3.0.6", ] [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -4873,7 +6678,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -4885,9 +6689,9 @@ checksum = "42012b0f064e01aa58b545fe3727f90f7dd4020f4a3ea735b50344965f5a57e9" [[package]] name = "gcloud-sdk" -version = "0.27.3" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ac903b34cd86b6e3479924e8a9517edba8d5deebee0c1013353b05108ea9bd3" +checksum = "6b5b58d8683fa308be9bc58caece4972315a0b2547f9da16962511f0915d5b53" dependencies = [ "async-trait", "bytes", @@ -4896,33 +6700,34 @@ dependencies = [ "hyper", "jsonwebtoken", "once_cell", - "prost", - "prost-types", - "reqwest", + "prost 0.14.4", + "prost-types 0.14.4", + "reqwest 0.13.5", "secret-vault-value", "serde", "serde_json", "tokio", "tonic", + "tonic-prost", "tower", "tower-layer", - "tower-util", "tracing", "url", ] [[package]] name = "generator" -version = "0.8.5" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d18470a76cb7f8ff746cf1f7470914f900252ec36bbc40b569d74b1258446827" +checksum = "54ade96dc9003043bce7c035c85a9df5a858bfb2039c5a2e6fdf00f324f6c551" dependencies = [ "cc", "cfg-if", "libc", "log", "rustversion", - "windows", + "windows-link", + "windows-result", ] [[package]] @@ -4938,90 +6743,115 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "js-sys", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.3.3" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", - "wasi 0.14.2+wasi-0.2.4", + "r-efi 6.0.0", + "rand_core 0.10.1", "wasm-bindgen", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gimli" -version = "0.31.1" +version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] name = "glob" -version = "0.3.2" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "globset" -version = "0.4.16" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54a1028dfc5f5df5da8a56a73e6c153c9a9708ec57232470703592a3f18e49f5" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" dependencies = [ "aho-corasick", "bstr", "log", - "regex-automata 0.4.9", - "regex-syntax 0.8.5", + "regex-automata", + "regex-syntax", ] [[package]] -name = "gmp-mpfr-sys" -version = "1.6.5" +name = "group" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c66d61197a68f6323b9afa616cf83d55d69191e1bf364d4eb7d35ae18defe776" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ - "libc", - "windows-sys 0.59.0", + "ff 0.13.1", + "rand_core 0.6.4", + "subtle", ] [[package]] name = "group" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" dependencies = [ - "ff", - "rand_core 0.6.4", + "ff 0.14.0", + "rand_core 0.10.1", "subtle", ] [[package]] name = "h2" -version = "0.4.11" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17da50a276f1e01e0ba6c029e47b7100754904ee8a278f886546e98575380785" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" dependencies = [ "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", - "http 1.3.1", - "indexmap 2.10.0", + "http 1.5.0", + "indexmap 2.14.2", "slab", "tokio", "tokio-util", @@ -5030,29 +6860,20 @@ dependencies = [ [[package]] name = "half" -version = "2.6.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", + "zerocopy", ] [[package]] -name = "handlebars" -version = "6.3.2" +name = "hash-db" +version = "0.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "759e2d5aea3287cb1190c8ec394f42866cb5bf74fcbf213f354e3c856ea26098" -dependencies = [ - "derive_builder", - "log", - "num-order", - "pest", - "pest_derive", - "serde", - "serde_json", - "thiserror 2.0.12", -] +checksum = "d23bd4e7b5eda0d0f3a307e8b381fdc8ba9000f26fbe912250c0a4cc3956364a" [[package]] name = "hashbrown" @@ -5068,14 +6889,35 @@ checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" [[package]] name = "hashbrown" -version = "0.15.4" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", "serde", + "serde_core", +] + +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", ] [[package]] @@ -5086,28 +6928,40 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" [[package]] name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-conservative" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db3fef046dca3ca91ee1408a8c1b80ab777e80a4d308d1bf4e7adb3fcb047e08" dependencies = [ - "serde", + "arrayvec", ] [[package]] name = "hex-conservative" -version = "0.2.1" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5313b072ce3c597065a808dbf612c4c8e8590bdbf8b579508bf7a762c5eae6cd" +checksum = "271e0d19bcb473b6675739a2b536076b24a082316cb5199ad918edce10c599e8" dependencies = [ "arrayvec", ] +[[package]] +name = "hex-literal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1" + [[package]] name = "hidapi-rusb" version = "1.3.3" @@ -5120,6 +6974,15 @@ dependencies = [ "rusb", ] +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + [[package]] name = "hmac" version = "0.12.1" @@ -5130,33 +6993,28 @@ dependencies = [ ] [[package]] -name = "home" -version = "0.5.11" +name = "hmac" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "windows-sys 0.59.0", + "digest 0.11.3", ] [[package]] -name = "html-escape" -version = "0.2.13" +name = "home" +version = "0.5.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" dependencies = [ - "utf8-width", + "windows-sys 0.61.2", ] [[package]] -name = "html5ever" -version = "0.35.0" +name = "html-escape" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4" -dependencies = [ - "log", - "markup5ever", - "match_token", -] +checksum = "c9356095b4b41197bba32173600e1582792cda618f65d12f68e2e77d273413c5" [[package]] name = "http" @@ -5171,12 +7029,11 @@ dependencies = [ [[package]] name = "http" -version = "1.3.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", - "fnv", "itoa", ] @@ -5193,33 +7050,27 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", - "http 1.3.1", + "http 1.5.0", ] [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", - "http 1.3.1", - "http-body 1.0.1", + "http 1.5.0", + "http-body 1.1.0", "pin-project-lite", ] -[[package]] -name = "http-range-header" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" - [[package]] name = "httparse" version = "1.10.1" @@ -5232,18 +7083,30 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "subtle", + "typenum", + "zeroize", +] + [[package]] name = "hyper" -version = "1.6.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" dependencies = [ + "atomic-waker", "bytes", "futures-channel", - "futures-util", + "futures-core", "h2", - "http 1.3.1", - "http-body 1.0.1", + "http 1.5.0", + "http-body 1.1.0", "httparse", "httpdate", "itoa", @@ -5255,20 +7118,19 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" dependencies = [ - "http 1.3.1", + "http 1.5.0", "hyper", "hyper-util", "rustls", "rustls-native-certs", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.2", + "webpki-roots 1.0.9", ] [[package]] @@ -5286,17 +7148,17 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.15" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66d5bd4c6f02bf0542fad85d626775bab9258cf795a4256dcaf3161114d1df" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bytes", "futures-channel", - "futures-core", "futures-util", - "http 1.3.1", - "http-body 1.0.1", + "http 1.5.0", + "http-body 1.1.0", + "httparse", "hyper", "ipnet", "libc", @@ -5312,9 +7174,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.63" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -5334,6 +7196,95 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "serde", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b24a59706036ba941c9476a55cd57b82b77f38a3c667d637ee7cabbc85eaedc" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a97b8ac6235e69506e8dacfb2adf38461d2ce6d3e9bd9c94c4cbc3cd4400a4" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "serde", + "stable_deref_trait", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "ident_case" version = "1.0.1" @@ -5342,9 +7293,9 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", @@ -5373,20 +7324,41 @@ dependencies = [ [[package]] name = "ignore" -version = "0.4.23" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d89fd380afde86567dfba715db065673989d6253f42b88179abd3eae47bda4b" +checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f" dependencies = [ "crossbeam-deque", "globset", "log", "memchr", - "regex-automata 0.4.9", + "regex-automata", "same-file", "walkdir", "winapi-util", ] +[[package]] +name = "imbl" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46bad832b9b463ed9398b8506488cc2e3b897a9d44f13af115f382aac71f4fec" +dependencies = [ + "archery", + "equivalent", + "imbl-sized-chunks", + "rand_core 0.9.5", + "rand_xoshiro", + "version_check", + "wide", +] + +[[package]] +name = "imbl-sized-chunks" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0813be332553f857953298749fa19549e8b61b80589757c29b4e2a804fa9c6" + [[package]] name = "impl-codec" version = "0.6.0" @@ -5404,20 +7376,14 @@ checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] name = "indenter" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683" - -[[package]] -name = "index_vec" -version = "0.1.4" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44faf5bb8861a9c72e20d3fb0fdbd59233e43056e2b80475ab0aacdc2e781355" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" [[package]] name = "indexmap" @@ -5432,73 +7398,71 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.10.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "arbitrary", "equivalent", - "hashbrown 0.15.4", + "hashbrown 0.17.1", "serde", + "serde_core", ] [[package]] name = "indicatif" -version = "0.17.11" +version = "0.18.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" dependencies = [ "console", - "number_prefix", "portable-atomic", - "unicode-width 0.2.0", + "unicode-width 0.2.2", + "unit-prefix", "web-time", ] [[package]] name = "indoc" -version = "2.0.6" +version = "2.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] [[package]] name = "inferno" -version = "0.12.3" +version = "0.12.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e96d2465363ed2d81857759fc864cf6bb7997f79327aec028d65bd7989393685" +checksum = "0c460d4fa06223667240720ab69a8045133755ae6dfbe100cf481b95e3a014f1" dependencies = [ "ahash", "itoa", "log", "num-format", "once_cell", - "quick-xml 0.37.5", + "quick-xml 0.41.0", "rgb", "str_stack", ] -[[package]] -name = "inlinable_string" -version = "0.1.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" - [[package]] name = "inotify" -version = "0.11.0" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.13.2", "inotify-sys", "libc", ] [[package]] name = "inotify-sys" -version = "0.1.5" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" dependencies = [ "libc", ] @@ -5512,24 +7476,33 @@ dependencies = [ "generic-array", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + [[package]] name = "instability" -version = "0.3.9" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435d80800b936787d62688c927b6490e887c7ef5ff9ce922c6c6050fca75eb9a" +checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20" dependencies = [ - "darling 0.20.11", + "darling 0.24.1", "indoc", "proc-macro2", "quote", - "syn 2.0.104", + "syn 3.0.6", ] [[package]] name = "interprocess" -version = "2.2.3" +version = "2.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d941b405bd2322993887859a8ee6ac9134945a24ec5ec763a8a962fc64dfec2d" +checksum = "6cb02ffbf25e8ef0f4a95bff054eef75f6d1bf16919474cf2caa63b21997478a" dependencies = [ "doctest-file", "futures-core", @@ -5537,14 +7510,23 @@ dependencies = [ "recvmsg", "tokio", "widestring", - "windows-sys 0.52.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "intrusive-collections" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "189d0897e4cbe8c75efedf3502c18c887b05046e59d28404d4d8e46cbc4d1e86" +dependencies = [ + "memoffset 0.9.1", ] [[package]] name = "inturn" -version = "0.1.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62f17d4bce58d4380de6432e6b1a0ebb561dfbbe21fc123204870b7006189677" +checksum = "630b41dcc27d131bc9b501515a2cb3dfde2b0de4d8b53bd9c97180fb87696aa2" dependencies = [ "boxcar", "bumpalo", @@ -5553,49 +7535,28 @@ dependencies = [ "thread_local", ] -[[package]] -name = "io-uring" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013" -dependencies = [ - "bitflags 2.9.1", - "cfg-if", - "libc", -] - [[package]] name = "ipnet" -version = "2.11.0" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" - -[[package]] -name = "iri-string" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" -dependencies = [ - "memchr", - "serde", -] +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" [[package]] name = "is-terminal" -version = "0.4.16" +version = "0.4.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "is_terminal_polyfill" -version = "1.70.1" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" @@ -5633,70 +7594,125 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.15" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49" +checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" dependencies = [ + "defmt", + "jiff-core", "jiff-static", - "jiff-tzdb-platform", "log", "portable-atomic", "portable-atomic-util", - "serde", - "windows-sys 0.59.0", + "serde_core", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", ] [[package]] name = "jiff-static" -version = "0.2.15" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" +checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" dependencies = [ + "jiff-core", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] -name = "jiff-tzdb" -version = "0.1.4" +name = "jni" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1283705eb0a21404d2bfd6eef2a7593d240bc42a0bdb39db0ad6fa2ec026524" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] [[package]] -name = "jiff-tzdb-platform" -version = "0.1.3" +name = "jni-macros" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" dependencies = [ - "jiff-tzdb", + "proc-macro2", + "quote", + "rustc_version 0.4.1", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", ] [[package]] name = "jobserver" -version = "0.1.33" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ - "once_cell", + "cfg-if", + "futures-util", "wasm-bindgen", ] @@ -5713,17 +7729,20 @@ dependencies = [ [[package]] name = "jsonwebtoken" -version = "9.3.1" +version = "10.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" dependencies = [ + "aws-lc-rs", "base64 0.22.1", + "getrandom 0.2.17", "js-sys", "pem", - "ring", "serde", "serde_json", + "signature 2.2.0", "simple_asn1", + "zeroize", ] [[package]] @@ -5733,82 +7752,93 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" dependencies = [ "cfg-if", - "ecdsa", - "elliptic-curve", + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", "once_cell", - "serdect", + "serdect 0.2.0", "sha2 0.10.9", - "signature", + "signature 2.2.0", +] + +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.21", ] [[package]] name = "keccak" -version = "0.1.5" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures 0.2.17", +] + +[[package]] +name = "keccak" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" dependencies = [ - "cpufeatures", + "cfg-if", + "cpufeatures 0.3.1", ] [[package]] name = "keccak-asm" -version = "0.1.4" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "505d1856a39b200489082f90d897c3f07c455563880bc5952e38eabf731c83b6" +checksum = "dd5dc2c0d691cbf7595cde551ced329cca99c2387c2cbc97754c5d0cd045d3ee" dependencies = [ "digest 0.10.7", "sha3-asm", ] [[package]] -name = "kqueue" -version = "1.1.1" +name = "keccak-const" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" -dependencies = [ - "kqueue-sys", - "libc", -] +checksum = "57d8d8ce877200136358e0bbff3a77965875db3af755a11e1fa6b1b3e2df13ea" [[package]] -name = "kqueue-sys" -version = "1.0.4" +name = "konst" +version = "0.2.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" dependencies = [ - "bitflags 1.3.2", - "libc", + "konst_macro_rules", ] [[package]] -name = "lalrpop" -version = "0.22.2" +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + +[[package]] +name = "kqueue" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba4ebbd48ce411c1d10fb35185f5a51a7bfa3d8b24b4e330d30c9e3a34129501" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" dependencies = [ - "ascii-canvas", - "bit-set", - "ena", - "itertools 0.14.0", - "lalrpop-util", - "petgraph", - "regex", - "regex-syntax 0.8.5", - "sha3", - "string_cache", - "term", - "unicode-xid", - "walkdir", + "kqueue-sys", + "libc", ] [[package]] -name = "lalrpop-util" -version = "0.22.2" +name = "kqueue-sys" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5baa5e9ff84f1aefd264e6869907646538a52147a755d494517a8007fb48733" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "regex-automata 0.4.9", - "rustversion", + "bitflags 2.13.2", + "libc", ] [[package]] @@ -5817,12 +7847,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "lazycell" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" - [[package]] name = "levenshtein" version = "1.0.5" @@ -5831,100 +7855,43 @@ checksum = "db13adb97ab515a3691f56e4dbab09283d0b86cb45abd991d8634a9d6f501760" [[package]] name = "libc" -version = "0.2.174" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" - -[[package]] -name = "libdbus-sys" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06085512b750d640299b79be4bad3d2fa90a9c00b1fd9e1b46364f66f0485c72" -dependencies = [ - "cc", - "pkg-config", -] - -[[package]] -name = "libloading" -version = "0.8.8" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" -dependencies = [ - "cfg-if", - "windows-targets 0.53.2", -] +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libm" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libmimalloc-sys" -version = "0.1.43" +version = "0.1.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf88cd67e9de251c1781dbe2f641a1a3ad66eaae831b8a2c38fbdc5ddae16d4d" +checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9" dependencies = [ "cc", - "libc", ] [[package]] name = "libredox" -version = "0.1.6" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4488594b9328dee448adb906d8b126d9b7deb7cf5c22161ee591610bb1be83c0" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" dependencies = [ - "bitflags 2.9.1", "libc", ] [[package]] -name = "libsecp256k1" -version = "0.7.2" +name = "libsqlite3-sys" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79019718125edc905a079a70cfa5f3820bc76139fc91d6f9abc27ea2a887139" +checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a" dependencies = [ - "arrayref", - "base64 0.22.1", - "digest 0.9.0", - "libsecp256k1-core", - "libsecp256k1-gen-ecmult", - "libsecp256k1-gen-genmult", - "rand 0.8.5", - "serde", - "sha2 0.9.9", -] - -[[package]] -name = "libsecp256k1-core" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5be9b9bb642d8522a44d533eab56c16c738301965504753b03ad1de3425d5451" -dependencies = [ - "crunchy", - "digest 0.9.0", - "subtle", -] - -[[package]] -name = "libsecp256k1-gen-ecmult" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3038c808c55c87e8a172643a7d87187fc6c4174468159cb3090659d55bcb4809" -dependencies = [ - "libsecp256k1-core", -] - -[[package]] -name = "libsecp256k1-gen-genmult" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3db8d6ba2cec9eacc40e6e8ccc98931840301f1006e95647ceb2dd5c3aa06f7c" -dependencies = [ - "libsecp256k1-core", + "cc", + "pkg-config", + "vcpkg", ] [[package]] @@ -5940,32 +7907,47 @@ dependencies = [ ] [[package]] -name = "linux-raw-sys" -version = "0.4.15" +name = "line-clipping" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" +dependencies = [ + "bitflags 2.13.2", +] [[package]] name = "linux-raw-sys" -version = "0.9.4" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "litrs" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "lock_api" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "autocfg", "scopeguard", + "serde", ] [[package]] name = "log" -version = "0.4.27" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" dependencies = [ "value-bag", ] @@ -5980,112 +7962,64 @@ dependencies = [ "generator", "scoped-tls", "tracing", - "tracing-subscriber 0.3.19", + "tracing-subscriber", ] [[package]] name = "lru" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" -dependencies = [ - "hashbrown 0.15.4", -] - -[[package]] -name = "lru" -version = "0.13.0" +version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "227748d55f2f0ab4735d87fd623798cb6b664512fe979705f829c9f81c934465" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" dependencies = [ - "hashbrown 0.15.4", + "hashbrown 0.17.1", ] [[package]] name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - -[[package]] -name = "lzma-rs" -version = "0.3.0" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" -dependencies = [ - "byteorder", - "crc", -] +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" [[package]] -name = "lzma-sys" -version = "0.1.20" +name = "lsp-types" +version = "0.95.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +checksum = "8e34d33a8e9b006cd3fc4fe69a921affa097bae4bb65f76271f4644f9a334365" dependencies = [ - "cc", - "libc", - "pkg-config", + "bitflags 1.3.2", + "serde", + "serde_json", + "serde_repr", + "url", ] -[[package]] -name = "mac" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" - [[package]] name = "macro-string" -version = "0.1.4" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b27834086c65ec3f9387b096d66e99f221cf081c2b738042aa252bcd41204e3" +checksum = "59a9dbbfc75d2688ed057456ce8a3ee3f48d12eec09229f560f3643b9f275653" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", -] - -[[package]] -name = "maplit" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" - -[[package]] -name = "markup5ever" -version = "0.35.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3" -dependencies = [ - "log", - "tendril", - "web_atoms", + "syn 2.0.119", ] [[package]] -name = "match_cfg" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4" - -[[package]] -name = "match_token" -version = "0.35.0" +name = "markdown" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf" +checksum = "a5cab8f2cadc416a82d2e783a1946388b31654d391d1c7d92cc1f03e295b1deb" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.104", + "unicode-id", ] [[package]] name = "matchers" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" dependencies = [ - "regex-automata 0.1.10", + "regex-automata", ] [[package]] @@ -6094,40 +8028,11 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" -[[package]] -name = "mdbook" -version = "0.4.52" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93c284d2855916af7c5919cf9ad897cfc77d3c2db6f55429c7cfb769182030ec" -dependencies = [ - "ammonia", - "anyhow", - "chrono", - "clap", - "clap_complete", - "elasticlunr-rs", - "env_logger", - "handlebars", - "hex", - "log", - "memchr", - "opener 0.8.2", - "pulldown-cmark", - "regex", - "serde", - "serde_json", - "sha2 0.10.9", - "shlex", - "tempfile", - "toml 0.5.11", - "topological-sort", -] - [[package]] name = "memchr" -version = "2.7.5" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memoffset" @@ -6138,6 +8043,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mesc" version = "0.3.0" @@ -6149,6 +8063,27 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "metrics" +version = "0.24.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89550ee9f79e88fef3119de263694973a8adb26c21d75322164fb8c493039fe2" +dependencies = [ + "portable-atomic", + "rapidhash", +] + +[[package]] +name = "metrics-derive" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "161ab904c2c62e7bda0f7562bf22f96440ca35ff79e66c800cbac298f2f4f5ec" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "miette" version = "7.6.0" @@ -6168,14 +8103,14 @@ checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] name = "mimalloc" -version = "0.1.47" +version = "0.1.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1791cbe101e95af5764f06f20f6760521f7158f69dbf9d6baf941ee1bf6bc40" +checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862" dependencies = [ "libmimalloc-sys", ] @@ -6196,6 +8131,22 @@ dependencies = [ "unicase", ] +[[package]] +name = "minicbor" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c12b4033ffaa92fbf9df03df38d19324f52bad130dd223f811734a8006dd2d69" + +[[package]] +name = "minicbor-serde" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7abdec9761edcb452764c68564ae4b77ec62f6c1a361aa68f5de15fd788c679" +dependencies = [ + "minicbor", + "serde", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -6211,23 +8162,34 @@ dependencies = [ "adler2", ] +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "serde", + "simd-adler32", +] + [[package]] name = "mio" -version = "1.0.4" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", "log", - "wasi 0.11.1+wasi-snapshot-preview1", - "windows-sys 0.59.0", + "wasi", + "windows-sys 0.61.2", ] [[package]] name = "mockall" -version = "0.13.1" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39a6bfcc6c8c7eed5ee98b9c3e33adc726054389233e201c95dab2d41a3839d2" +checksum = "1a6ceddfe3ce334925e96bf420fdb2dcee5bed6c632a168ece622676dadeaf8a" dependencies = [ "cfg-if", "downcast", @@ -6239,29 +8201,101 @@ dependencies = [ [[package]] name = "mockall_derive" -version = "0.13.1" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ca3004c2efe9011bd4e461bd8256445052b9615405b4f7ea43fc8ca5c20898" +checksum = "9cfe16fbe8a314aeec0b861ac24e60b1e123e97634bab045475b9d6a18416fd8" dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] -name = "new_debug_unreachable" -version = "1.0.6" +name = "modular-bitfield" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2956e537fc68236d2aa048f55704f231cc93f1c4de42fe1ecb5bd7938061fc4a" +dependencies = [ + "modular-bitfield-impl", + "static_assertions", +] + +[[package]] +name = "modular-bitfield-impl" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59b43b4fd69e3437618106f7754f34021b831a514f9e1a98ae863cabcd8d8dad" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "monad-revm" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27654f9cc215f197ccf39165b6565d9099e65cd1b28314e07b241c6bc6731616" +dependencies = [ + "alloy-sol-types", + "auto_impl", + "revm", + "revm-interpreter", + "serde", +] + +[[package]] +name = "mpp" +version = "0.12.0" +source = "git+https://github.com/tempoxyz/mpp-rs?rev=965692a8a5457896b22698a177a371875b73cfc5#965692a8a5457896b22698a177a371875b73cfc5" +dependencies = [ + "alloy", + "async-stream", + "async-trait", + "base64 0.22.1", + "fs2", + "futures-core", + "futures-util", + "hex", + "hmac 0.13.0", + "http 1.5.0", + "p256 0.13.2", + "rand 0.10.3", + "reqwest 0.13.5", + "rusqlite", + "serde", + "serde_json", + "serde_json_canonicalizer", + "sha2 0.11.0", + "subtle", + "tempo-alloy", + "thiserror 2.0.21", + "time", + "tokio", + "tokio-tungstenite", + "uuid 1.26.1", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "ndk-context" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" [[package]] name = "newtype-uuid" -version = "1.2.4" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a17d82edb1c8a6c20c238747ae7aae9181133e766bc92cd2556fdd764407d0d1" +checksum = "0fa56da58097c7ae893c4097bb6d91bb5f7fa330053e07e033349db4d5f83ba8" dependencies = [ - "uuid 1.17.0", + "uuid 1.26.1", ] [[package]] @@ -6282,17 +8316,17 @@ dependencies = [ "bitflags 1.3.2", "cfg-if", "libc", - "memoffset", + "memoffset 0.7.1", "pin-utils", ] [[package]] name = "nix" -version = "0.29.0" +version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libc", @@ -6300,11 +8334,11 @@ dependencies = [ [[package]] name = "nix" -version = "0.30.1" +version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libc", @@ -6320,6 +8354,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nonmax" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51" +dependencies = [ + "serde", +] + [[package]] name = "normalize-line-endings" version = "0.3.0" @@ -6334,20 +8377,20 @@ checksum = "f5438dd2b2ff4c6df6e1ce22d825ed2fa93ee2922235cc45186991717f0a892d" [[package]] name = "normpath" -version = "1.3.0" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8911957c4b1549ac0dc74e30db9c8b0e66ddcd6d7acc33098f4c63a64a6d7ed" +checksum = "b11ce00d2594068e8a27c9146fdc5cf9f3ac38eb42c7cd34d05fea618873ac9f" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "notify" -version = "8.1.0" +version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3163f59cd3fa0e9ef8c32f242966a7b9994fd7378366099593e0e73077cd8c97" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.13.2", "fsevent-sys", "inotify", "kqueue", @@ -6361,18 +8404,20 @@ dependencies = [ [[package]] name = "notify-types" -version = "2.0.0" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e0826a989adedc2a244799e823aece04662b66609d96af8dff7ac6df9a8925d" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.13.2", +] [[package]] name = "nu-ansi-term" -version = "0.46.0" +version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "overload", - "winapi", + "windows-sys 0.61.2", ] [[package]] @@ -6381,7 +8426,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" dependencies = [ - "num-bigint", + "num-bigint 0.4.8", "num-complex", "num-integer", "num-iter", @@ -6391,9 +8436,20 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", + "serde", +] + +[[package]] +name = "num-bigint" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0" dependencies = [ "num-integer", "num-traits", @@ -6410,9 +8466,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-format" @@ -6426,46 +8482,30 @@ dependencies = [ [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] [[package]] name = "num-iter" -version = "0.1.45" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" dependencies = [ - "autocfg", "num-integer", "num-traits", ] -[[package]] -name = "num-modular" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17bb261bf36fa7d83f4c294f834e91256769097b3cb505d44831e0a179ac647f" - -[[package]] -name = "num-order" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "537b596b97c40fcf8056d153049eb22f481c17ebce72a513ec9286e4986d1bb6" -dependencies = [ - "num-modular", -] - [[package]] name = "num-rational" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" dependencies = [ - "num-bigint", + "num-bigint 0.4.8", "num-integer", "num-traits", ] @@ -6492,9 +8532,9 @@ dependencies = [ [[package]] name = "num_enum" -version = "0.7.4" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a973b4e44ce6cad84ce69d797acf9a044532e4184c4f267913d1b546a0727b7a" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" dependencies = [ "num_enum_derive", "rustversion", @@ -6502,14 +8542,14 @@ dependencies = [ [[package]] name = "num_enum_derive" -version = "0.7.4" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77e878c846a8abae00dd069496dbe8751b16ac1c3d6bd2a7283a938e8228f90d" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] @@ -6521,19 +8561,14 @@ dependencies = [ "libc", ] -[[package]] -name = "number_prefix" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" - [[package]] name = "nybbles" -version = "0.4.1" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "675b3a54e5b12af997abc8b6638b0aee51a28caedab70d4967e0d5db3a3f1d06" +checksum = "0d49ff0c0d00d4a502b39df9af3a525e1efeb14b9dabb5bb83335284c1309210" dependencies = [ "alloy-rlp", + "arbitrary", "cfg-if", "proptest", "ruint", @@ -6541,44 +8576,107 @@ dependencies = [ "smallvec", ] +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "object" -version = "0.36.7" +version = "0.37.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" dependencies = [ "memchr", ] [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" -version = "1.70.1" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "once_map" -version = "0.4.21" +version = "0.4.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bd2cae3bec3936bbed1ccc5a3343b3738858182419f9c0522c7260c80c430b0" +checksum = "b685c8311c9171d1bd2895222965d25616b2de2cb5819dd3504ed9250df9fecd" dependencies = [ "ahash", - "hashbrown 0.15.4", + "hashbrown 0.17.1", "parking_lot", "stable_deref_trait", ] +[[package]] +name = "op-alloy" +version = "2.0.0" +source = "git+https://github.com/foundry-rs/optimism?rev=206d26cedb9d861b983d89fc71e1122187dfa877#206d26cedb9d861b983d89fc71e1122187dfa877" +dependencies = [ + "op-alloy-consensus", + "op-alloy-network", + "op-alloy-provider", + "op-alloy-rpc-types", + "op-alloy-rpc-types-engine", +] + [[package]] name = "op-alloy-consensus" -version = "0.17.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2423a125ef2daa0d15dacc361805a0b6f76d6acfc6e24a1ff6473582087fe75" +version = "2.0.0" +source = "git+https://github.com/foundry-rs/optimism?rev=206d26cedb9d861b983d89fc71e1122187dfa877#206d26cedb9d861b983d89fc71e1122187dfa877" dependencies = [ "alloy-consensus", "alloy-eips", @@ -6587,58 +8685,95 @@ dependencies = [ "alloy-rlp", "alloy-rpc-types-eth", "alloy-serde", - "derive_more 2.0.1", + "bytes", + "derive_more", + "reth-codecs 0.7.1", + "reth-zstd-compressors 0.7.1", "serde", - "thiserror 2.0.12", + "serde_with", + "thiserror 2.0.21", ] [[package]] -name = "op-alloy-consensus" -version = "0.18.11" +name = "op-alloy-flz" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18986c5cf19a790b8b9e8c856a950b48ed6dd6a0259d0efd5f5c9bebbba1fc3a" +checksum = "a79f352fc3893dcd670172e615afef993a41798a1d3fc0db88a3e60ef2e70ecc" + +[[package]] +name = "op-alloy-network" +version = "2.0.0" +source = "git+https://github.com/foundry-rs/optimism?rev=206d26cedb9d861b983d89fc71e1122187dfa877#206d26cedb9d861b983d89fc71e1122187dfa877" dependencies = [ "alloy-consensus", - "alloy-eips", - "alloy-primitives", - "alloy-rlp", - "derive_more 2.0.1", - "thiserror 2.0.12", + "alloy-network", + "alloy-provider", + "alloy-rpc-types-eth", + "op-alloy-consensus", + "op-alloy-rpc-types", ] [[package]] -name = "op-alloy-flz" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a79f352fc3893dcd670172e615afef993a41798a1d3fc0db88a3e60ef2e70ecc" +name = "op-alloy-provider" +version = "2.0.0" +source = "git+https://github.com/foundry-rs/optimism?rev=206d26cedb9d861b983d89fc71e1122187dfa877#206d26cedb9d861b983d89fc71e1122187dfa877" +dependencies = [ + "alloy-network", + "alloy-primitives", + "alloy-provider", + "alloy-rpc-types-engine", + "alloy-transport", + "async-trait", + "op-alloy-rpc-types-engine", +] [[package]] name = "op-alloy-rpc-types" -version = "0.17.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f82a315004b6720fbf756afdcfdc97ea7ddbcdccfec86ea7df7562bb0da29a3f" +version = "2.0.0" +source = "git+https://github.com/foundry-rs/optimism?rev=206d26cedb9d861b983d89fc71e1122187dfa877#206d26cedb9d861b983d89fc71e1122187dfa877" dependencies = [ "alloy-consensus", "alloy-eips", + "alloy-network", "alloy-network-primitives", "alloy-primitives", "alloy-rpc-types-eth", "alloy-serde", - "derive_more 2.0.1", - "op-alloy-consensus 0.17.2", + "derive_more", + "op-alloy-consensus", + "reth-rpc-traits", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.21", +] + +[[package]] +name = "op-alloy-rpc-types-engine" +version = "2.0.0" +source = "git+https://github.com/foundry-rs/optimism?rev=206d26cedb9d861b983d89fc71e1122187dfa877#206d26cedb9d861b983d89fc71e1122187dfa877" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-primitives", + "alloy-rlp", + "alloy-rpc-types-engine", + "alloy-serde", + "ethereum_ssz", + "ethereum_ssz_derive", + "op-alloy-consensus", + "serde", + "sha2 0.10.9", + "snap", + "thiserror 2.0.21", ] [[package]] name = "op-revm" -version = "8.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee9ba9cab294a5ed02afd1a1060220762b3c52911acab635db33822e93f7276d" +version = "20.0.0" +source = "git+https://github.com/foundry-rs/optimism?rev=206d26cedb9d861b983d89fc71e1122187dfa877#206d26cedb9d861b983d89fc71e1122187dfa877" dependencies = [ "auto_impl", - "once_cell", + "op-alloy-consensus", "revm", "serde", ] @@ -6651,32 +8786,20 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "opener" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0812e5e4df08da354c851a3376fead46db31c2214f849d3de356d774d057681" -dependencies = [ - "bstr", - "dbus", - "normpath", - "windows-sys 0.59.0", -] - -[[package]] -name = "opener" -version = "0.8.2" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "771b9704f8cd8b424ec747a320b30b47517a6966ba2c7da90047c16f4a962223" +checksum = "b2b03ff07a220d0d0ec9a1f0f238951b7967a5a2e96aefcd21a117b1083415e9" dependencies = [ "bstr", "normpath", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "openssl-probe" -version = "0.1.6" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "option-ext" @@ -6690,23 +8813,20 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" -[[package]] -name = "overload" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" - [[package]] name = "owo-colors" -version = "3.5.0" +version = "4.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f" +checksum = "13c45bb4a6ae1280ec0803b1ef9d3455eb50f01efbbe1447ab020f1d54fba9d8" [[package]] -name = "owo-colors" -version = "4.2.2" +name = "oxc_index" +version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48dd4f4a2c8405440fd0462561f0e5806bd0f77e86f51c761481bdd4018b545e" +checksum = "191884bee6c3744909a51acc7d78d4ae370d817b25875b10642f632327b6296e" +dependencies = [ + "nonmax", +] [[package]] name = "p256" @@ -6714,19 +8834,57 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder 0.13.6", + "serdect 0.2.0", "sha2 0.10.9", ] [[package]] -name = "pad" -version = "0.1.6" +name = "p256" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ad9b889f1b12e0b9ee24db044b5129150d5eada288edc800f789928dc8c0e3" +checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" dependencies = [ - "unicode-width 0.1.14", + "ecdsa 0.17.0", + "elliptic-curve 0.14.1", + "primefield", + "primeorder 0.14.0", + "sha2 0.11.0", +] + +[[package]] +name = "palette" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" +dependencies = [ + "approx", + "libm", + "palette_derive", + "palette_math", +] + +[[package]] +name = "palette_derive" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", ] [[package]] @@ -6738,6 +8896,7 @@ dependencies = [ "arrayvec", "bitvec", "byte-slice-cast", + "bytes", "const_format", "impl-trait-for-tuples", "parity-scale-codec-derive", @@ -6754,7 +8913,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] @@ -6765,9 +8924,9 @@ checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] name = "parking_lot" -version = "0.12.4" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", "parking_lot_core", @@ -6775,15 +8934,15 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.11" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", - "windows-targets 0.52.6", + "windows-link", ] [[package]] @@ -6814,40 +8973,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ "digest 0.10.7", - "hmac", -] - -[[package]] -name = "pear" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" -dependencies = [ - "inlinable_string", - "pear_codegen", - "yansi", -] - -[[package]] -name = "pear_codegen" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" -dependencies = [ - "proc-macro2", - "proc-macro2-diagnostics", - "quote", - "syn 2.0.104", + "hmac 0.12.1", ] [[package]] name = "pem" -version = "3.0.5" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38af38e8470ac9dee3ce1bae1af9c1671fffc44ddfd8bd1d0a3445bf349a8ef3" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ "base64 0.22.1", - "serde", + "serde_core", ] [[package]] @@ -6861,26 +8997,25 @@ dependencies = [ [[package]] name = "percent-encoding" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.1" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1db05f56d34358a8b1066f67cbb203ee3e7ed2ba674a6263a1d5ec6db2204323" +checksum = "45d3aca230fad2e6f6317ca0a72724338c4960cb97168a85cdee66df4a9a21a8" dependencies = [ "memchr", - "thiserror 2.0.12", "ucd-trie", ] [[package]] name = "pest_derive" -version = "2.8.1" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb056d9e8ea77922845ec74a1c4e8fb17e7c218cc4fc11a15c5d25e189aa40bc" +checksum = "284b60557f2c4a2e72ad3f2d34d42685a2fa4a6a61d0d2a10c0ae2a5e916c2cf" dependencies = [ "pest", "pest_generator", @@ -6888,35 +9023,24 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.1" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87e404e638f781eb3202dc82db6760c8ae8a1eeef7fb3fa8264b2ef280504966" +checksum = "1d9d1f08a115309ee99268cf85e5228e0e56aa9caf8841ec12866b6be07c3109" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] name = "pest_meta" -version = "2.8.1" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edd1101f170f5903fde0914f899bb503d9ff5271d7ba76bbb70bea63690cc0d5" +checksum = "ed93ba1a9ffcca32130a5188701c81c0c49cf00d4b7c5007d5148951d743adcb" dependencies = [ "pest", - "sha2 0.10.9", -] - -[[package]] -name = "petgraph" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" -dependencies = [ - "fixedbitset", - "indexmap 2.10.0", ] [[package]] @@ -6935,8 +9059,27 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ - "phf_macros", - "phf_shared", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros 0.13.1", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_macros 0.14.0", + "phf_shared 0.14.0", "serde", ] @@ -6946,8 +9089,8 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.11.3", + "phf_shared 0.11.3", ] [[package]] @@ -6956,21 +9099,54 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ - "phf_shared", - "rand 0.8.5", + "phf_shared 0.11.3", + "rand 0.8.8", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" +dependencies = [ + "fastrand", + "phf_shared 0.14.0", ] [[package]] name = "phf_macros" -version = "0.11.3" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_macros" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.14.0", + "phf_shared 0.14.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] @@ -6983,50 +9159,48 @@ dependencies = [ ] [[package]] -name = "pin-project" -version = "0.4.30" +name = "phf_shared" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ef0f924a5ee7ea9cbcea77529dba45f8a9ba9f622419fe3386ca581a3ae9d5a" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" dependencies = [ - "pin-project-internal 0.4.30", + "siphasher", ] [[package]] -name = "pin-project" -version = "1.1.10" +name = "phf_shared" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" dependencies = [ - "pin-project-internal 1.1.10", + "siphasher", ] [[package]] -name = "pin-project-internal" -version = "0.4.30" +name = "pin-project" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "851c8d0ce9bebe43790dedfc86614c23494ac9f423dd618d3a61fc693eafe61e" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", + "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.10" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pin-utils" @@ -7040,57 +9214,101 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der", - "spki", + "der 0.7.10", + "spki 0.7.3", ] [[package]] -name = "pkg-config" -version = "0.3.32" +name = "pkcs8" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der 0.8.2", + "spki 0.8.0", +] [[package]] -name = "portable-atomic" -version = "1.11.1" +name = "pkg-config" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] -name = "portable-atomic-util" -version = "0.2.4" +name = "plain_hasher" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +checksum = "1e19e6491bdde87c2c43d70f4c194bc8a758f2eb732df00f61e43f7362e3b4cc" dependencies = [ - "portable-atomic", + "crunchy", ] [[package]] -name = "powerfmt" -version = "0.2.0" +name = "poly1305" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" +dependencies = [ + "cpufeatures 0.3.1", + "universal-hash 0.6.1", +] [[package]] -name = "ppv-lite86" -version = "0.2.21" +name = "polyval" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ - "zerocopy", + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash 0.5.1", ] [[package]] -name = "precomputed-hash" -version = "0.1.1" +name = "portable-atomic" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] [[package]] name = "predicates" -version = "3.1.3" +version = "3.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" dependencies = [ "anstyle", "predicates-core", @@ -7098,38 +9316,71 @@ dependencies = [ [[package]] name = "predicates-core" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" [[package]] name = "predicates-tree" -version = "1.0.12" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" dependencies = [ "predicates-core", "termtree", ] +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "prettydiff" -version = "0.7.0" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac17546d82912e64874e3d5b40681ce32eac4e5834344f51efcf689ff1550a65" +dependencies = [ + "owo-colors", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abec3fb083c10660b3854367697da94c674e9e82aa7511014dc958beeb7215e9" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ - "owo-colors 3.5.0", - "pad", + "proc-macro2", + "syn 2.0.119", ] [[package]] name = "prettyplease" -version = "0.2.35" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "061c1221631e079b26479d25bbf2275bfe5917ae8419cd7e34f13bfc2aa7539a" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" dependencies = [ "proc-macro2", - "syn 2.0.104", + "syn 3.0.6", +] + +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "crypto-bigint 0.7.5", + "crypto-common 0.2.2", + "ff 0.14.0", + "rand_core 0.10.1", + "subtle", + "zeroize", ] [[package]] @@ -7138,7 +9389,21 @@ version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" dependencies = [ - "elliptic-curve", + "elliptic-curve 0.13.8", + "serdect 0.2.0", +] + +[[package]] +name = "primeorder" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" +dependencies = [ + "elliptic-curve 0.14.1", + "once_cell", + "primefield", + "serdect 0.4.3", + "wnaf", ] [[package]] @@ -7154,66 +9419,53 @@ dependencies = [ [[package]] name = "proc-macro-crate" -version = "3.3.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ "toml_edit", ] [[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" +name = "proc-macro-error-attr3" +version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +checksum = "82366fd7d8b7a440d66d13418820c69df9b3908bcb1a0476d7f5ce5d12f5a04d" dependencies = [ "proc-macro2", "quote", ] [[package]] -name = "proc-macro-error2" -version = "2.0.1" +name = "proc-macro-error3" +version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +checksum = "b511283ea8a74b4b39447b128c5d00f03a356b7424554b13e298a5550100d9ac" dependencies = [ - "proc-macro-error-attr2", + "proc-macro-error-attr3", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] name = "proc-macro2" -version = "1.0.95" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] -[[package]] -name = "proc-macro2-diagnostics" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.104", - "version_check", - "yansi", -] - [[package]] name = "process-wrap" -version = "8.2.1" +version = "9.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3ef4f2f0422f23a82ec9f628ea2acd12871c81a9362b02c43c1aa86acfc3ba1" +checksum = "f95f6259ebd0640685b7cd26d635861f39208df0f89b2b61ff5cf76310fa54f6" dependencies = [ "futures", - "indexmap 2.10.0", - "nix 0.30.1", + "indexmap 2.14.2", + "nix 0.31.3", "tokio", "tracing", "windows", @@ -7221,72 +9473,124 @@ dependencies = [ [[package]] name = "proptest" -version = "1.7.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fcdab19deb5195a31cf7726a210015ff1496ba1464fd42cb4f537b8b01b471f" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set", "bit-vec", - "bitflags 2.9.1", - "lazy_static", + "bitflags 2.13.2", "num-traits", - "rand 0.9.1", + "rand 0.9.5", "rand_chacha 0.9.0", "rand_xorshift", - "regex-syntax 0.8.5", + "regex-syntax", "rusty-fork", "tempfile", "unarray", ] +[[package]] +name = "proptest-arbitrary-interop" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1981e49bd2432249da8b0e11e5557099a8e74690d6b94e721f7dc0bb7f3555f" +dependencies = [ + "arbitrary", + "proptest", +] + [[package]] name = "proptest-derive" -version = "0.5.1" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb6dc647500e84a25a85b100e76c85b8ace114c209432dc174f20aac11d4ed6c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "proptest-derive" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ee1c9ac207483d5e7db4940700de86a9aae46ef90c48b57f99fe7edb8345e49" +checksum = "c57924a81864dddafba92e1bf92f9bf82f97096c44489548a60e888e1547549b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] name = "prost" -version = "0.13.5" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "deb1435c188b76130da55f17a466d252ff7b1418b2ad3e037d127b94e3411f29" +dependencies = [ + "bytes", + "prost-derive 0.12.6", +] + +[[package]] +name = "prost" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" dependencies = [ "bytes", - "prost-derive", + "prost-derive 0.14.4", ] [[package]] name = "prost-derive" -version = "0.13.5" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" +dependencies = [ + "anyhow", + "itertools 0.12.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] name = "prost-types" -version = "0.13.5" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9091c90b0a32608e984ff2fa4091273cbdd755d54935c51d520887f4a1dbd5b0" +dependencies = [ + "prost 0.12.6", +] + +[[package]] +name = "prost-types" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" dependencies = [ - "prost", + "prost 0.14.4", ] [[package]] name = "protobuf" -version = "3.3.0" +version = "3.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b65f4a8ec18723a734e5dc09c173e0abf9690432da5340285d536edcb4dac190" +checksum = "d65a1d4ddae7d8b5de68153b48f6aa3bba8cb002b243dbdbc55a5afbc98f99f4" dependencies = [ "once_cell", "protobuf-support", @@ -7295,31 +9599,28 @@ dependencies = [ [[package]] name = "protobuf-support" -version = "3.3.0" +version = "3.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6872f4d4f4b98303239a2b5838f5bbbb77b01ffc892d627957f37a22d7cfe69c" +checksum = "3e36c2f31e0a47f9280fb347ef5e461ffcd2c52dd520d8e216b52f93b0b0d7d6" dependencies = [ "thiserror 1.0.69", ] [[package]] -name = "pulldown-cmark" -version = "0.10.3" +name = "quanta" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76979bea66e7875e7509c4ec5300112b316af87fa7a252ca91c448b32dfe3993" +checksum = "73be39b77b45d82a4d1a5793fe9a89a5c143ceaa6ad0d8417982566bb0f34e11" dependencies = [ - "bitflags 2.9.1", - "memchr", - "pulldown-cmark-escape", - "unicase", + "libc", + "portable-atomic", + "raw-cpuid", + "wasip1", + "wasip2", + "web-sys", + "windows-sys 0.61.2", ] -[[package]] -name = "pulldown-cmark-escape" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd348ff538bc9caeda7ee8cad2d1d48236a1f443c1fa3913c6a02fe0043b1dd3" - [[package]] name = "quick-error" version = "1.2.3" @@ -7328,52 +9629,52 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" [[package]] name = "quick-junit" -version = "0.5.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed1a693391a16317257103ad06a88c6529ac640846021da7c435a06fffdacd7" +checksum = "3eba1124f9c87c1b2f9e9665f0519ca75b09f9a5504e42e72d23cad8da63b52f" dependencies = [ "chrono", - "indexmap 2.10.0", + "indexmap 2.14.2", "newtype-uuid", - "quick-xml 0.37.5", + "quick-xml 0.41.0", "strip-ansi-escapes", - "thiserror 2.0.12", - "uuid 1.17.0", + "thiserror 2.0.21", + "uuid 1.26.1", ] [[package]] name = "quick-xml" -version = "0.18.1" +version = "0.39.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cc440ee4802a86e357165021e3e255a9143724da31db1e2ea540214c96a0f82" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" dependencies = [ "memchr", ] [[package]] name = "quick-xml" -version = "0.37.5" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", ] [[package]] name = "quinn" -version = "0.11.8" +version = "0.11.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "626214629cda6781b6dc1d316ba307189c85ba657213ce642d9c77670f8202c8" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" dependencies = [ "bytes", "cfg_aliases", "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash 2.1.1", + "rustc-hash", "rustls", "socket2", - "thiserror 2.0.12", + "thiserror 2.0.21", "tokio", "tracing", "web-time", @@ -7381,20 +9682,22 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.12" +version = "0.11.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49df843a9161c85bb8aae55f101bc0bac8bcafd637a620d9122fd7e0b2f7422e" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" dependencies = [ + "aws-lc-rs", "bytes", - "getrandom 0.3.3", + "getrandom 0.4.3", "lru-slab", - "rand 0.9.1", + "rand 0.10.3", + "rand_pcg", "ring", - "rustc-hash 2.1.1", + "rustc-hash", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.12", + "thiserror 2.0.21", "tinyvec", "tracing", "web-time", @@ -7402,23 +9705,23 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.13" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcebb1209ee276352ef14ff8732e24cc2b02bbac986cd74a4c81bcb2f9881970" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "quote" -version = "1.0.40" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -7429,6 +9732,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "radium" version = "0.7.0" @@ -7437,9 +9746,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "radix_trie" -version = "0.2.1" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +checksum = "3b4431027dcd37fc2a73ef740b5f233aa805897935b8bce0195e41bbf9a3289a" dependencies = [ "endian-type", "nibble_vec", @@ -7447,9 +9756,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.8.5" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -7459,15 +9768,26 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.1" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", - "rand_core 0.9.3", + "rand_core 0.9.5", "serde", ] +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -7485,7 +9805,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.3", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb" +dependencies = [ + "ppv-lite86", + "rand_core 0.10.1", ] [[package]] @@ -7494,54 +9824,142 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", ] [[package]] name = "rand_core" -version = "0.9.3" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.3.4", "serde", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rand_xorshift" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" dependencies = [ - "rand_core 0.9.3", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_xoshiro" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "rapidhash" +version = "4.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e" +dependencies = [ + "getrandom 0.3.4", + "rustversion", ] [[package]] name = "ratatui" -version = "0.29.0" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" dependencies = [ - "bitflags 2.9.1", - "cassowary", + "bitflags 2.13.2", "compact_str", + "hashbrown 0.17.1", + "itertools 0.14.0", + "kasuari", + "lru", + "palette", + "serde", + "strum 0.28.0", + "thiserror 2.0.21", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.2", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.2", + "hashbrown 0.17.1", "indoc", "instability", - "itertools 0.13.0", - "lru 0.12.5", - "paste", - "strum 0.26.3", + "itertools 0.14.0", + "line-clipping", + "ratatui-core", + "serde", + "strum 0.28.0", + "time", "unicode-segmentation", - "unicode-truncate", - "unicode-width 0.2.0", + "unicode-width 0.2.2", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.13.2", ] [[package]] name = "rayon" -version = "1.10.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ "either", "rayon-core", @@ -7549,165 +9967,852 @@ dependencies = [ [[package]] name = "rayon-core" -version = "1.12.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" dependencies = [ "crossbeam-deque", "crossbeam-utils", ] [[package]] -name = "recvmsg" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3edd4d5d42c92f0a659926464d4cce56b562761267ecf0f469d85b7de384175" +name = "recvmsg" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3edd4d5d42c92f0a659926464d4cce56b562761267ecf0f469d85b7de384175" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.21", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "regress" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2057b2325e68a893284d1538021ab90279adac1139957ca2a74426c6f118fb48" +dependencies = [ + "hashbrown 0.16.1", + "memchr", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "h2", + "http 1.5.0", + "http-body 1.1.0", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http 0.6.11", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.9", +] + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http 1.5.0", + "http-body 1.1.0", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http 0.6.11", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "reth-chain-state" +version = "2.5.2" +source = "git+https://github.com/paradigmxyz/reth?rev=5723a3f#5723a3fed5521fad105b44f86876580242f31be9" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-primitives", + "derive_more", + "metrics", + "parking_lot", + "pin-project", + "rand 0.9.5", + "reth-chainspec", + "reth-ethereum-primitives", + "reth-execution-types", + "reth-metrics", + "reth-primitives-traits 0.8.2", + "reth-trie", + "reth-trie-sparse", + "revm", + "serde", + "tokio", + "tokio-stream", + "tracing", +] + +[[package]] +name = "reth-chainspec" +version = "2.5.2" +source = "git+https://github.com/paradigmxyz/reth?rev=5723a3f#5723a3fed5521fad105b44f86876580242f31be9" +dependencies = [ + "alloy-chains", + "alloy-consensus", + "alloy-eips", + "alloy-evm", + "alloy-genesis", + "alloy-primitives", + "alloy-trie", + "auto_impl", + "derive_more", + "reth-ethereum-forks", + "reth-network-peers", + "reth-primitives-traits 0.8.2", + "serde_json", +] + +[[package]] +name = "reth-codecs" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f0dd7f542fbcae0e490ebe13f3eababcc05547375246ff7f62700b533cf3f01" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-genesis", + "alloy-primitives", + "alloy-trie", + "bytes", + "modular-bitfield", + "parity-scale-codec", + "reth-codecs-derive 0.6.0", + "reth-zstd-compressors 0.6.0", + "serde", +] + +[[package]] +name = "reth-codecs" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6bac09d9d84a412ae151149ec870d6eb7770335c5eb63f209453d81deff8d39" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-genesis", + "alloy-primitives", + "alloy-trie", + "bytes", + "modular-bitfield", + "parity-scale-codec", + "reth-codecs-derive 0.7.1", + "reth-zstd-compressors 0.7.1", + "serde", +] + +[[package]] +name = "reth-codecs" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a24479f12308e4ff8db606bc445fed267899a56661877231667f94eb0fd699" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-genesis", + "alloy-primitives", + "alloy-trie", + "arbitrary", + "bytes", + "modular-bitfield", + "parity-scale-codec", + "reth-codecs-derive 0.8.2", + "reth-zstd-compressors 0.8.2", + "serde", + "visibility", +] + +[[package]] +name = "reth-codecs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e508fcbb8cb775639dbbb56b81b62756d4322f28e76c60a52aa32575f993fe" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "reth-codecs-derive" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0101b1f474563c4c48c04fc2477199279a53d9c19b65301c6fd2131cf6e4a330" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "reth-codecs-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecf4de10362fdf18e80e9f3d096f617ce0725c85dc24e9deabb414c52089040c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "reth-consensus" +version = "2.5.2" +source = "git+https://github.com/paradigmxyz/reth?rev=5723a3f#5723a3fed5521fad105b44f86876580242f31be9" +dependencies = [ + "alloy-consensus", + "alloy-eip7928", + "alloy-primitives", + "auto_impl", + "reth-execution-types", + "reth-primitives-traits 0.8.2", + "thiserror 2.0.21", +] + +[[package]] +name = "reth-consensus-common" +version = "2.5.2" +source = "git+https://github.com/paradigmxyz/reth?rev=5723a3f#5723a3fed5521fad105b44f86876580242f31be9" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-primitives", + "reth-chainspec", + "reth-consensus", + "reth-primitives-traits 0.8.2", +] + +[[package]] +name = "reth-db-api" +version = "2.5.2" +source = "git+https://github.com/paradigmxyz/reth?rev=5723a3f#5723a3fed5521fad105b44f86876580242f31be9" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-primitives", + "arbitrary", + "arrayvec", + "bytes", + "derive_more", + "metrics", + "modular-bitfield", + "proptest", + "reth-codecs 0.8.2", + "reth-db-models", + "reth-ethereum-primitives", + "reth-primitives-traits 0.8.2", + "reth-prune-types", + "reth-stages-types", + "reth-storage-errors", + "reth-trie-common", + "roaring", + "serde", +] + +[[package]] +name = "reth-db-models" +version = "2.5.2" +source = "git+https://github.com/paradigmxyz/reth?rev=5723a3f#5723a3fed5521fad105b44f86876580242f31be9" +dependencies = [ + "alloy-eips", + "alloy-primitives", + "arbitrary", + "bytes", + "modular-bitfield", + "reth-codecs 0.8.2", + "reth-primitives-traits 0.8.2", + "serde", +] + +[[package]] +name = "reth-ethereum-consensus" +version = "2.5.2" +source = "git+https://github.com/paradigmxyz/reth?rev=5723a3f#5723a3fed5521fad105b44f86876580242f31be9" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-primitives", + "reth-chainspec", + "reth-consensus", + "reth-consensus-common", + "reth-execution-types", + "reth-primitives-traits 0.8.2", + "tracing", +] + +[[package]] +name = "reth-ethereum-forks" +version = "2.5.2" +source = "git+https://github.com/paradigmxyz/reth?rev=5723a3f#5723a3fed5521fad105b44f86876580242f31be9" +dependencies = [ + "alloy-eip2124", + "alloy-hardforks", + "alloy-primitives", + "auto_impl", + "once_cell", + "rustc-hash", +] + +[[package]] +name = "reth-ethereum-primitives" +version = "2.5.2" +source = "git+https://github.com/paradigmxyz/reth?rev=5723a3f#5723a3fed5521fad105b44f86876580242f31be9" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-rpc-types-eth", + "reth-codecs 0.8.2", + "reth-primitives-traits 0.8.2", + "serde", +] + +[[package]] +name = "reth-evm" +version = "2.5.2" +source = "git+https://github.com/paradigmxyz/reth?rev=5723a3f#5723a3fed5521fad105b44f86876580242f31be9" +dependencies = [ + "alloy-consensus", + "alloy-eip7928", + "alloy-eips", + "alloy-evm", + "alloy-primitives", + "auto_impl", + "derive_more", + "fixed-cache", + "futures-util", + "rayon", + "reth-execution-errors", + "reth-execution-types", + "reth-primitives-traits 0.8.2", + "reth-storage-api", + "reth-storage-errors", + "reth-trie-common", + "revm", +] + +[[package]] +name = "reth-evm-ethereum" +version = "2.5.2" +source = "git+https://github.com/paradigmxyz/reth?rev=5723a3f#5723a3fed5521fad105b44f86876580242f31be9" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-evm", + "alloy-primitives", + "alloy-rpc-types-engine", + "reth-chainspec", + "reth-ethereum-forks", + "reth-ethereum-primitives", + "reth-evm", + "reth-execution-types", + "reth-primitives-traits 0.8.2", + "reth-storage-errors", + "revm", +] + +[[package]] +name = "reth-execution-errors" +version = "2.5.2" +source = "git+https://github.com/paradigmxyz/reth?rev=5723a3f#5723a3fed5521fad105b44f86876580242f31be9" +dependencies = [ + "alloy-evm", + "alloy-primitives", + "alloy-rlp", + "nybbles", + "reth-storage-errors", + "thiserror 2.0.21", +] + +[[package]] +name = "reth-execution-types" +version = "2.5.2" +source = "git+https://github.com/paradigmxyz/reth?rev=5723a3f#5723a3fed5521fad105b44f86876580242f31be9" +dependencies = [ + "alloy-consensus", + "alloy-eip7928", + "alloy-eips", + "alloy-evm", + "alloy-primitives", + "alloy-rlp", + "derive_more", + "reth-ethereum-primitives", + "reth-primitives-traits 0.8.2", + "reth-trie-common", + "revm", + "serde", + "serde_with", +] + +[[package]] +name = "reth-metrics" +version = "2.5.2" +source = "git+https://github.com/paradigmxyz/reth?rev=5723a3f#5723a3fed5521fad105b44f86876580242f31be9" +dependencies = [ + "libc", + "metrics", + "metrics-derive", +] + +[[package]] +name = "reth-network-peers" +version = "2.5.2" +source = "git+https://github.com/paradigmxyz/reth?rev=5723a3f#5723a3fed5521fad105b44f86876580242f31be9" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "secp256k1 0.30.0", + "serde_with", + "thiserror 2.0.21", + "url", +] + +[[package]] +name = "reth-primitives-traits" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4666a8d5b246650b23e6767a45e46334d352557f473e91c2ea7211cc29291cca" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-genesis", + "alloy-primitives", + "alloy-rlp", + "alloy-rpc-types-eth", + "alloy-trie", + "bytes", + "dashmap", + "derive_more", + "once_cell", + "reth-codecs 0.6.0", + "revm-bytecode 42.0.0", + "revm-primitives 42.0.0", + "revm-state 42.0.0", + "secp256k1 0.30.0", + "serde", + "thiserror 2.0.21", +] + +[[package]] +name = "reth-primitives-traits" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18341fba74d004c7fad32f784488baaea9adebfde7a4f231a4d8d445aea70347" +dependencies = [ + "alloy-consensus", + "alloy-eip7928", + "alloy-eips", + "alloy-genesis", + "alloy-primitives", + "alloy-rlp", + "alloy-rpc-types-eth", + "alloy-trie", + "bytes", + "derive_more", + "once_cell", + "revm-bytecode 43.0.0", + "revm-primitives 43.0.0", + "revm-state 43.0.1", + "secp256k1 0.30.0", + "serde", + "thiserror 2.0.21", +] + +[[package]] +name = "reth-primitives-traits" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ba9e5768d35c074e997bd8c92938c8a52f3207c5ff97258eb2eca5d7a79dd5" +dependencies = [ + "alloy-consensus", + "alloy-eip7928", + "alloy-eips", + "alloy-genesis", + "alloy-primitives", + "alloy-rlp", + "alloy-rpc-types-eth", + "alloy-trie", + "arbitrary", + "byteorder", + "bytes", + "dashmap", + "derive_more", + "modular-bitfield", + "once_cell", + "proptest", + "proptest-arbitrary-interop", + "quanta", + "rayon", + "reth-codecs 0.8.2", + "revm-bytecode 43.0.0", + "revm-primitives 43.0.0", + "revm-state 43.0.1", + "secp256k1 0.30.0", + "serde", + "thiserror 2.0.21", +] + +[[package]] +name = "reth-prune-types" +version = "2.5.2" +source = "git+https://github.com/paradigmxyz/reth?rev=5723a3f#5723a3fed5521fad105b44f86876580242f31be9" +dependencies = [ + "alloy-primitives", + "arbitrary", + "derive_more", + "modular-bitfield", + "reth-codecs 0.8.2", + "serde", + "strum 0.27.2", + "thiserror 2.0.21", + "tracing", +] + +[[package]] +name = "reth-revm" +version = "2.5.2" +source = "git+https://github.com/paradigmxyz/reth?rev=5723a3f#5723a3fed5521fad105b44f86876580242f31be9" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "reth-primitives-traits 0.8.2", + "reth-storage-api", + "reth-storage-errors", + "revm", +] [[package]] -name = "redox_syscall" -version = "0.5.13" +name = "reth-rpc-traits" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6" +checksum = "a91f76618dd20285be5b450d54229d131d45778e6bc644f3c28e3a89d1e12ff6" dependencies = [ - "bitflags 2.9.1", + "alloy-consensus", + "alloy-network", + "alloy-primitives", + "alloy-rpc-types-eth", + "alloy-signer", + "reth-primitives-traits 0.7.1", + "thiserror 2.0.21", ] [[package]] -name = "redox_users" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +name = "reth-stages-types" +version = "2.5.2" +source = "git+https://github.com/paradigmxyz/reth?rev=5723a3f#5723a3fed5521fad105b44f86876580242f31be9" dependencies = [ - "getrandom 0.2.16", - "libredox", - "thiserror 2.0.12", + "alloy-primitives", + "arbitrary", + "bytes", + "modular-bitfield", + "reth-codecs 0.8.2", + "reth-trie-common", + "serde", ] [[package]] -name = "ref-cast" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a0ae411dbe946a674d89546582cea4ba2bb8defac896622d6496f14c23ba5cf" +name = "reth-static-file-types" +version = "2.5.2" +source = "git+https://github.com/paradigmxyz/reth?rev=5723a3f#5723a3fed5521fad105b44f86876580242f31be9" dependencies = [ - "ref-cast-impl", + "alloy-primitives", + "derive_more", + "fixed-map", + "reth-stages-types", + "serde", + "strum 0.27.2", + "tracing", ] [[package]] -name = "ref-cast-impl" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7" +name = "reth-storage-api" +version = "2.5.2" +source = "git+https://github.com/paradigmxyz/reth?rev=5723a3f#5723a3fed5521fad105b44f86876580242f31be9" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.104", + "alloy-consensus", + "alloy-eip7928", + "alloy-eips", + "alloy-primitives", + "alloy-rpc-types-engine", + "auto_impl", + "reth-chain-state", + "reth-chainspec", + "reth-db-models", + "reth-ethereum-primitives", + "reth-execution-types", + "reth-primitives-traits 0.8.2", + "reth-prune-types", + "reth-stages-types", + "reth-storage-errors", + "reth-trie-common", + "revm", + "serde_json", ] [[package]] -name = "regex" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +name = "reth-storage-errors" +version = "2.5.2" +source = "git+https://github.com/paradigmxyz/reth?rev=5723a3f#5723a3fed5521fad105b44f86876580242f31be9" dependencies = [ - "aho-corasick", - "memchr", - "regex-automata 0.4.9", - "regex-syntax 0.8.5", + "alloy-eips", + "alloy-primitives", + "alloy-rlp", + "derive_more", + "reth-codecs 0.8.2", + "reth-primitives-traits 0.8.2", + "reth-prune-types", + "reth-static-file-types", + "revm", + "thiserror 2.0.21", ] [[package]] -name = "regex-automata" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +name = "reth-trie" +version = "2.5.2" +source = "git+https://github.com/paradigmxyz/reth?rev=5723a3f#5723a3fed5521fad105b44f86876580242f31be9" dependencies = [ - "regex-syntax 0.6.29", + "alloy-consensus", + "alloy-eips", + "alloy-primitives", + "alloy-rlp", + "alloy-trie", + "auto_impl", + "itertools 0.14.0", + "parking_lot", + "reth-execution-errors", + "reth-primitives-traits 0.8.2", + "reth-stages-types", + "reth-storage-errors", + "reth-trie-common", + "reth-trie-sparse", + "revm", + "tracing", ] [[package]] -name = "regex-automata" -version = "0.4.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +name = "reth-trie-common" +version = "2.5.2" +source = "git+https://github.com/paradigmxyz/reth?rev=5723a3f#5723a3fed5521fad105b44f86876580242f31be9" dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax 0.8.5", + "alloy-consensus", + "alloy-primitives", + "alloy-rlp", + "alloy-rpc-types-eth", + "alloy-serde", + "alloy-trie", + "arbitrary", + "arrayvec", + "bytes", + "derive_more", + "hash-db", + "itertools 0.14.0", + "nybbles", + "plain_hasher", + "rayon", + "reth-codecs 0.8.2", + "reth-primitives-traits 0.8.2", + "revm", + "serde", + "serde_with", + "thiserror 2.0.21", ] [[package]] -name = "regex-lite" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a" +name = "reth-trie-sparse" +version = "2.5.2" +source = "git+https://github.com/paradigmxyz/reth?rev=5723a3f#5723a3fed5521fad105b44f86876580242f31be9" +dependencies = [ + "alloy-primitives", + "alloy-trie", + "either", + "rayon", + "reth-execution-errors", + "reth-primitives-traits 0.8.2", + "reth-trie-common", + "slotmap", + "smallvec", + "strum 0.27.2", + "tracing", +] [[package]] -name = "regex-syntax" -version = "0.6.29" +name = "reth-zstd-compressors" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" +checksum = "80addacffc5df9398c55919f8993922d2b688f2aa5753708fdd658ca3cc63e39" +dependencies = [ + "zstd 0.13.3", +] [[package]] -name = "regex-syntax" -version = "0.8.5" +name = "reth-zstd-compressors" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "dbdee2f0ad09dc32b43ab52641f8239766a66de4345834bad27d5cfa2f62864d" +dependencies = [ + "zstd 0.13.3", +] [[package]] -name = "reqwest" -version = "0.12.22" +name = "reth-zstd-compressors" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531" +checksum = "65d41037bdcd4d60ca6bbe4e65cc61bbaf29ef7235c2c614e59c25d5a94ac813" dependencies = [ - "async-compression", - "base64 0.22.1", - "bytes", - "encoding_rs", - "futures-channel", - "futures-core", - "futures-util", - "h2", - "http 1.3.1", - "http-body 1.0.1", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "mime", - "mime_guess", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-native-certs", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tokio-util", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", - "webpki-roots 1.0.2", + "zstd 0.13.3", ] [[package]] name = "revm" -version = "27.0.3" +version = "43.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70a84455f03d3480d4ed2e7271c15f2ec95b758e86d57cb8d258a8ff1c22e9a4" +checksum = "62b2fbb3bdce72fef4f156e2164bdfde44dc3aeebc5b4f31a413fba0c09c524c" dependencies = [ - "revm-bytecode", + "revm-bytecode 43.0.0", "revm-context", "revm-context-interface", "revm-database", @@ -7716,106 +10821,122 @@ dependencies = [ "revm-inspector", "revm-interpreter", "revm-precompile", - "revm-primitives", - "revm-state", + "revm-primitives 43.0.0", + "revm-state 43.0.1", ] [[package]] name = "revm-bytecode" -version = "6.0.1" +version = "42.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a685758a4f375ae9392b571014b9779cfa63f0d8eb91afb4626ddd958b23615" +checksum = "537356bdec80dbc04e4e6bb688b3441ff7cf18eecdcf3cac23150633c46467f1" dependencies = [ "bitvec", - "once_cell", - "phf", - "revm-primitives", + "phf 0.14.0", + "revm-primitives 42.0.0", + "serde", +] + +[[package]] +name = "revm-bytecode" +version = "43.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d38e16b72ec1c836ec8490562bfb21003165ea8bc496086bf345680a6e6c41" +dependencies = [ + "bitvec", + "paste", + "phf 0.14.0", + "revm-primitives 43.0.0", "serde", ] [[package]] name = "revm-context" -version = "8.0.3" +version = "43.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a990abf66b47895ca3e915d5f3652bb7c6a4cff6e5351fdf0fc2795171fd411c" +checksum = "9b06efea511a15ff8309acaa4c283239b444b461bbd2e9f0326c8586f45fb1da" dependencies = [ + "bitvec", "cfg-if", "derive-where", - "revm-bytecode", + "revm-bytecode 43.0.0", "revm-context-interface", "revm-database-interface", - "revm-primitives", - "revm-state", + "revm-primitives 43.0.0", + "revm-state 43.0.1", "serde", ] [[package]] name = "revm-context-interface" -version = "8.0.1" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a303a93102fceccec628265efd550ce49f2817b38ac3a492c53f7d524f18a1ca" +checksum = "3068385c68f6a3bd2e781dd81cefd25c98dd0a574de6c8ec2a90acc33318b2da" dependencies = [ "alloy-eip2930", "alloy-eip7702", "auto_impl", "either", "revm-database-interface", - "revm-primitives", - "revm-state", + "revm-primitives 43.0.0", + "revm-state 43.0.1", "serde", ] [[package]] name = "revm-database" -version = "7.0.1" +version = "43.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7db360729b61cc347f9c2f12adb9b5e14413aea58778cf9a3b7676c6a4afa115" +checksum = "8db6fb10912d7fba0161e91d4577ce71fa9726ec38a4d420e4242e504a47aa8f" dependencies = [ "alloy-eips", - "revm-bytecode", + "derive_more", + "either", + "revm-bytecode 43.0.0", "revm-database-interface", - "revm-primitives", - "revm-state", + "revm-primitives 43.0.0", + "revm-state 43.0.1", "serde", ] [[package]] name = "revm-database-interface" -version = "7.0.1" +version = "43.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8500194cad0b9b1f0567d72370795fd1a5e0de9ec719b1607fa1566a23f039a" +checksum = "5581ecadb3d0d1c18a91f1797d6b7c4c17720bea596e9e9ec90b95803db2bc77" dependencies = [ "auto_impl", "either", - "revm-primitives", - "revm-state", + "revm-primitives 43.0.0", + "revm-state 43.0.1", "serde", + "thiserror 2.0.21", ] [[package]] name = "revm-handler" -version = "8.0.3" +version = "43.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c35a17a38203976f97109e20eccf6732447ce6c9c42973bae42732b2e957ff" +checksum = "ba5102e9c02b9d27e7c98f449b495a2c2b2ec011209f4eeca16e054520ec5ac4" dependencies = [ "auto_impl", "derive-where", - "revm-bytecode", + "revm-bytecode 43.0.0", "revm-context", "revm-context-interface", "revm-database-interface", "revm-interpreter", "revm-precompile", - "revm-primitives", - "revm-state", + "revm-primitives 43.0.0", + "revm-state 43.0.1", "serde", ] [[package]] name = "revm-inspector" -version = "8.0.3" +version = "43.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e69abf6a076741bd5cd87b7d6c1b48be2821acc58932f284572323e81a8d4179" +checksum = "825cf946c1bf3178f6a93aae4e8064a0ebd505bc764a7fe3d49411e7242e4091" dependencies = [ "auto_impl", "either", @@ -7823,107 +10944,161 @@ dependencies = [ "revm-database-interface", "revm-handler", "revm-interpreter", - "revm-primitives", - "revm-state", + "revm-primitives 43.0.0", + "revm-state 43.0.1", "serde", "serde_json", ] [[package]] name = "revm-inspectors" -version = "0.26.5" +version = "0.44.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7b99a2332cf8eed9e9a22fffbf76dfadc99d2c45de6ae6431a1eb9f657dd97a" +checksum = "3584d48ecc82f4df573c0b68994a27f46da53e06dfae224e446b1aa868bc1665" dependencies = [ + "alloy-eip2930", "alloy-primitives", "alloy-rpc-types-eth", "alloy-rpc-types-trace", "alloy-sol-types", "anstyle", + "boa_engine", + "boa_gc", "colorchoice", + "num-bigint 0.4.8", "revm", + "ruint", "serde", "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.21", ] [[package]] name = "revm-interpreter" -version = "23.0.2" +version = "43.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95c4a9a1662d10b689b66b536ddc2eb1e89f5debfcabc1a2d7b8417a2fa47cd" +checksum = "aa32f5099a266329d1869d6f9bf4bb7894345d2b58c57d05396f67385d65f299" dependencies = [ - "revm-bytecode", + "revm-bytecode 43.0.0", "revm-context-interface", - "revm-primitives", + "revm-primitives 43.0.0", + "revm-state 43.0.1", "serde", ] [[package]] name = "revm-precompile" -version = "24.0.1" +version = "43.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b68d54a4733ac36bd29ee645c3c2e5e782fb63f199088d49e2c48c64a9fedc15" +checksum = "aa80b8cef09bead8c8b1afa7733b09fc1428f49158d8657c239268409d2a2bf7" dependencies = [ "ark-bls12-381", "ark-bn254", "ark-ec", - "ark-ff 0.5.0", - "ark-serialize 0.5.0", + "ark-ff 0.6.0", + "ark-serialize 0.6.0", "arrayref", "aurora-engine-modexp", "blst", "c-kzg", "cfg-if", "k256", - "libsecp256k1", - "once_cell", - "p256", - "revm-primitives", - "ripemd", - "rug", + "p256 0.13.2", + "revm-context-interface", + "revm-primitives 43.0.0", + "ripemd 0.2.0", "secp256k1 0.31.1", - "sha2 0.10.9", + "sha2 0.11.0", ] [[package]] name = "revm-primitives" -version = "20.0.0" +version = "42.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52cdf897b3418f2ee05bcade64985e5faed2dbaa349b2b5f27d3d6bfd10fff2a" +checksum = "2ef5564c1bce219fd08beca43d4f39fdcbc19193fa4f604c0c6b58950a13194a" dependencies = [ "alloy-primitives", - "num_enum", + "once_cell", + "serde", +] + +[[package]] +name = "revm-primitives" +version = "43.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1f4ba1a5757fe4398262290422c371556fcebf236f762f7ed7ff2c56768a63c" +dependencies = [ + "alloy-primitives", + "once_cell", "serde", ] [[package]] name = "revm-state" -version = "7.0.1" +version = "42.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "106fec5c634420118c7d07a6c37110186ae7f23025ceac3a5dbe182eea548363" +checksum = "2c4ae66db3abfcb565854dbfc95513bfa440b3dc390ddf8e50b0363d12a6fa79" dependencies = [ - "bitflags 2.9.1", - "revm-bytecode", - "revm-primitives", + "alloy-eip7928", + "bitflags 2.13.2", + "nonmax", + "revm-bytecode 42.0.0", + "revm-primitives 42.0.0", "serde", ] +[[package]] +name = "revm-state" +version = "43.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b2d2ec0be925c4c87ff4afae33af4b9f693d9b011f74d6b4d52267a62df0c49" +dependencies = [ + "alloy-eip7928", + "bitflags 2.13.2", + "nonmax", + "revm-bytecode 43.0.0", + "revm-primitives 43.0.0", + "serde", + "triomphe", +] + +[[package]] +name = "rexpect" +version = "0.6.2" +source = "git+https://github.com/rust-cli/rexpect?rev=2ed0b1898d7edaf6a85bedbae71a01cc578958fc#2ed0b1898d7edaf6a85bedbae71a01cc578958fc" +dependencies = [ + "comma", + "nix 0.30.1", + "regex", + "tempfile", + "thiserror 2.0.21", +] + [[package]] name = "rfc6979" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "hmac", + "hmac 0.12.1", "subtle", ] +[[package]] +name = "rfc6979" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" +dependencies = [ + "crypto-bigint 0.7.5", + "hmac 0.13.0", +] + [[package]] name = "rgb" -version = "0.8.52" +version = "0.8.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" dependencies = [ "bytemuck", ] @@ -7936,9 +11111,9 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.16", + "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -7951,6 +11126,15 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "ripemd" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dd4211456b4172d7e44261920c25acf07367c4f04bb5f5d54fc21b090d9b159" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "rlp" version = "0.5.2" @@ -7961,63 +11145,87 @@ dependencies = [ "rustc-hex", ] +[[package]] +name = "roaring" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18bd8a37d17a58532776dcdf6041ce64929adca78e8489d5cacbafe99229d3e1" +dependencies = [ + "bytemuck", + "byteorder", +] + +[[package]] +name = "ron" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3" +dependencies = [ + "bitflags 2.13.2", + "once_cell", + "serde", + "serde_derive", + "typeid", + "unicode-ident", +] + [[package]] name = "rpassword" -version = "7.4.0" +version = "7.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66d4c8b64f049c6721ec8ccec37ddfc3d641c4a7fca57e8f2a89de509c73df39" +checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" dependencies = [ "libc", "rtoolbox", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] -name = "rtoolbox" -version = "0.0.3" +name = "rsqlite-vfs" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7cc970b249fbe527d6e02e0a227762c9108b2f49d81094fe357ffc6d14d7f6f" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ - "libc", - "windows-sys 0.52.0", + "hashbrown 0.16.1", + "thiserror 2.0.21", ] [[package]] -name = "rug" -version = "1.27.0" +name = "rtoolbox" +version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4207e8d668e5b8eb574bda8322088ccd0d7782d3d03c7e8d562e82ed82bdcbc3" +checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d" dependencies = [ - "az", - "gmp-mpfr-sys", "libc", - "libm", + "windows-sys 0.61.2", ] [[package]] name = "ruint" -version = "1.15.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11256b5fe8c68f56ac6f39ef0720e592f33d2367a4782740d9c9142e889c7fb4" +checksum = "2973657b5127d510e230f5c63d2d106af9c8f79393d8b9f4647323e8196bdde5" dependencies = [ "alloy-rlp", "arbitrary", "ark-ff 0.3.0", "ark-ff 0.4.2", + "ark-ff 0.5.0", + "ark-ff 0.6.0", "bytes", "fastrlp 0.3.1", "fastrlp 0.4.0", - "num-bigint", + "num-bigint 0.4.8", "num-integer", "num-traits", "parity-scale-codec", "primitive-types", "proptest", - "rand 0.8.5", - "rand 0.9.1", + "rand 0.8.8", + "rand 0.9.5", "rlp", "ruint-macro", - "serde", + "serde_core", "valuable", "zeroize", ] @@ -8039,24 +11247,33 @@ dependencies = [ ] [[package]] -name = "rustc-demangle" -version = "0.1.25" +name = "rusqlite" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" +checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" +dependencies = [ + "bitflags 2.13.2", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] [[package]] -name = "rustc-hash" -version = "1.1.0" +name = "rustc-demangle" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" dependencies = [ - "rand 0.8.5", + "rand 0.9.5", ] [[package]] @@ -8080,7 +11297,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ - "semver 1.0.26", + "semver 1.0.28", ] [[package]] @@ -8090,42 +11307,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82fa69b198d894d84e23afde8e9ab2af4400b2cba20d6bf2b428a8b01c222c5a" dependencies = [ "serde", - "serde_json", - "thiserror 1.0.69", - "tracing", -] - -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags 2.9.1", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "serde_json", + "thiserror 1.0.69", + "tracing", ] [[package]] name = "rustix" -version = "1.0.8" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.13.2", "errno", "libc", - "linux-raw-sys 0.9.4", - "windows-sys 0.60.2", + "linux-raw-sys", + "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.23.29" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2491382039b29b9b11ff08b76ff6c97cf287671dbb74f0be44bda389fffe9bd1" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "log", @@ -8139,9 +11343,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.1" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -8151,37 +11355,64 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.12.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "web-time", "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + [[package]] name = "rustls-webpki" -version = "0.103.4" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] name = "rustversion" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "rusty-fork" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb3dcc6e454c328bb824492db107ab7c0ae8fcffe4ad210136ef014458c1bc4f" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" dependencies = [ "fnv", "quick-error", @@ -8191,31 +11422,45 @@ dependencies = [ [[package]] name = "rustyline" -version = "15.0.0" +version = "18.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee1e066dc922e513bda599c6ccb5f3bb2b0ea5870a579448f2622993f0a9a2f" +checksum = "53f6a737db68eb1a8ccff86b584b2fc13eca6a7bb6f78ebc7c529547e3ab9684" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.13.2", "cfg-if", "clipboard-win", - "fd-lock", "home", "libc", "log", "memchr", - "nix 0.29.0", + "nix 0.31.3", "radix_trie", "unicode-segmentation", - "unicode-width 0.2.0", + "unicode-width 0.2.2", "utf8parse", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "ryu-js" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" + +[[package]] +name = "safe_arch" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +dependencies = [ + "bytemuck", +] [[package]] name = "salsa20" @@ -8223,7 +11468,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" dependencies = [ - "cipher", + "cipher 0.4.4", ] [[package]] @@ -8246,23 +11491,11 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" -dependencies = [ - "windows-sys 0.59.0", -] - -[[package]] -name = "schemars" -version = "0.8.22" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ - "dyn-clone", - "schemars_derive", - "serde", - "serde_json", + "windows-sys 0.61.2", ] [[package]] @@ -8279,26 +11512,27 @@ dependencies = [ [[package]] name = "schemars" -version = "1.0.4" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ "dyn-clone", "ref-cast", + "schemars_derive", "serde", "serde_json", ] [[package]] name = "schemars_derive" -version = "0.8.22" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.104", + "syn 3.0.6", ] [[package]] @@ -8319,7 +11553,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f9e24d2b632954ded8ab2ef9fea0a0c769ea56ea98bddbafbad22caeeadf45d" dependencies = [ - "hmac", + "hmac 0.12.1", "pbkdf2 0.11.0", "salsa20", "sha2 0.10.9", @@ -8331,11 +11565,25 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ - "base16ct", - "der", + "base16ct 0.2.0", + "der 0.7.10", "generic-array", - "pkcs8", - "serdect", + "pkcs8 0.10.2", + "serdect 0.2.0", + "subtle", + "zeroize", +] + +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct 1.0.0", + "ctutils", + "der 0.8.2", + "hybrid-array", "subtle", "zeroize", ] @@ -8347,7 +11595,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" dependencies = [ "bitcoin_hashes", - "rand 0.8.5", + "rand 0.8.8", "secp256k1-sys 0.10.1", "serde", ] @@ -8359,7 +11607,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c3c81b43dc2d8877c216a3fccf76677ee1ebccd429566d3e67447290d0c42b2" dependencies = [ "bitcoin_hashes", - "rand 0.9.1", + "rand 0.9.5", "secp256k1-sys 0.11.0", ] @@ -8383,12 +11631,10 @@ dependencies = [ [[package]] name = "secret-vault-value" -version = "0.3.9" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc32a777b53b3433b974c9c26b6d502a50037f8da94e46cb8ce2ced2cfdfaea0" +checksum = "471de2a3d4b361569b862e04491696237381641ae5808f8e69ea08de991cd306" dependencies = [ - "prost", - "prost-types", "serde", "serde_json", "zeroize", @@ -8396,11 +11642,11 @@ dependencies = [ [[package]] name = "security-framework" -version = "3.2.0" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271720403f46ca04f7ba6f55d438f8bd878d6b8ca0a1046e8228c4145bcbb316" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.13.2", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -8409,9 +11655,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.14.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", @@ -8428,11 +11674,12 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.26" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" dependencies = [ "serde", + "serde_core", ] [[package]] @@ -8452,74 +11699,108 @@ checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 3.0.6", ] [[package]] name = "serde_derive_internals" -version = "0.29.1" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 3.0.6", ] [[package]] name = "serde_fmt" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1d4ddca14104cd60529e8c7f7ba71a2c8acd8f7f5cfcdc2faf97eeb7c3010a4" +checksum = "6e497af288b3b95d067a23a4f749f2861121ffcb2f6d8379310dcda040c345ed" dependencies = [ - "serde", + "serde_core", ] [[package]] name = "serde_json" -version = "1.0.141" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ - "indexmap 2.10.0", + "indexmap 2.14.2", "itoa", "memchr", - "ryu", "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_json_canonicalizer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe52319a927259afbfa5180c5157cd8167edfd3e8c254f9558c7fef44c5649f2" +dependencies = [ + "ryu-js", + "serde", + "serde_json", ] [[package]] name = "serde_path_to_error" -version = "0.1.17" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" dependencies = [ "itoa", "serde", + "serde_core", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", ] [[package]] name = "serde_spanned" -version = "0.6.9" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -8536,19 +11817,19 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.14.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2c45cd61fefa9db6f254525d46e392b852e0e61d9a1fd36e5bd183450a556d5" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" dependencies = [ "base64 0.22.1", + "bs58", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.10.0", + "indexmap 2.14.2", "schemars 0.9.0", - "schemars 1.0.4", - "serde", - "serde_derive", + "schemars 1.2.2", + "serde_core", "serde_json", "serde_with_macros", "time", @@ -8556,14 +11837,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.14.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de90945e6565ce0d9a25098082ed4ee4002e047cb59892c318d66821e14bb30f" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ - "darling 0.20.11", + "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] @@ -8572,18 +11853,28 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" dependencies = [ - "base16ct", + "base16ct 0.2.0", + "serde", +] + +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct 1.0.0", "serde", ] [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.10.7", ] @@ -8595,7 +11886,7 @@ checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" dependencies = [ "block-buffer 0.9.0", "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.9.0", "opaque-debug", ] @@ -8607,25 +11898,46 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest 0.10.7", ] +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + [[package]] name = "sha3" -version = "0.10.8" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" dependencies = [ "digest 0.10.7", - "keccak", + "keccak 0.1.6", +] + +[[package]] +name = "sha3" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +dependencies = [ + "digest 0.11.3", + "keccak 0.2.2", ] [[package]] name = "sha3-asm" -version = "0.1.4" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28efc5e327c837aa837c59eae585fc250715ef939ac32881bcc11677cd02d46" +checksum = "a6287fd675f713484342a89cbf0a386abef5f15919cfad607e5e1f19e1e15331" dependencies = [ "cc", "cfg-if", @@ -8642,15 +11954,15 @@ dependencies = [ [[package]] name = "shell-words" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook" @@ -8664,9 +11976,9 @@ dependencies = [ [[package]] name = "signal-hook-mio" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" dependencies = [ "libc", "mio", @@ -8675,10 +11987,11 @@ dependencies = [ [[package]] name = "signal-hook-registry" -version = "1.4.5" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] @@ -8692,17 +12005,43 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + [[package]] name = "simd-adler32" -version = "0.3.7" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version 0.4.1", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "similar" -version = "2.7.0" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +checksum = "4f66ca1f7aca2474dc10c942eb22feffc897735f54cd1db90138c2fddb490987" dependencies = [ "bstr", "unicode-segmentation", @@ -8710,9 +12049,9 @@ dependencies = [ [[package]] name = "similar-asserts" -version = "1.7.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b441962c817e33508847a22bd82f03a30cff43642dc2fae8b050566121eb9a" +checksum = "997e6ca38e97437973fc9f7f50a50d1274cacd874341a4960fea90067291038c" dependencies = [ "console", "similar", @@ -8720,48 +12059,73 @@ dependencies = [ [[package]] name = "simple_asn1" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ - "num-bigint", + "num-bigint 0.4.8", "num-traits", - "thiserror 2.0.12", + "thiserror 2.0.21", "time", ] [[package]] name = "siphasher" -version = "1.0.1" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" [[package]] name = "slab" -version = "0.4.10" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "small_btree" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d" +checksum = "0ba60d2df92ba73864714808ca68c059734853e6ab722b40e1cf543ebb3a057a" +dependencies = [ + "arrayvec", +] [[package]] name = "smallvec" -version = "1.15.1" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" dependencies = [ + "arbitrary", "serde", ] [[package]] name = "smawk" -version = "0.3.2" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100" + +[[package]] +name = "snap" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" +checksum = "199905e6153d6405f9728fe44daace35f8f837bbf830bb6e85fbd5828709a886" [[package]] name = "snapbox" -version = "0.6.21" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96dcfc4581e3355d70ac2ee14cfdf81dce3d85c85f1ed9e2c1d3013f53b3436b" +checksum = "8de56eb4784a2c5c1efede55a0f06fbf481bc12b42b355b9525c15db1e3581a8" dependencies = [ "anstream", "anstyle", @@ -8776,120 +12140,234 @@ dependencies = [ [[package]] name = "snapbox-macros" -version = "0.3.10" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16569f53ca23a41bb6f62e0a5084aa1661f4814a67fa33696a79073e03a664af" +checksum = "ed4a172e483585ebbc7c7f7d1705ca7e3f94f606ed78caa14805673189fd5455" dependencies = [ "anstream", ] [[package]] name = "socket2" -version = "0.5.10" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "solar" +version = "1.8.6" +dependencies = [ + "solar-cli", ] [[package]] name = "solar-ast" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f7f30449c304fd09db4637209dc73bde7ec203e6e5c691fc9eed26b68cd105a" +version = "0.2.0" +source = "git+https://github.com/paradigmxyz/solar?rev=271db3373c6c1cd14d66c6323b7f9ad4e906d045#271db3373c6c1cd14d66c6323b7f9ad4e906d045" dependencies = [ "alloy-primitives", "bumpalo", "either", - "num-bigint", "num-rational", - "semver 1.0.26", + "semver 1.0.28", "solar-data-structures", "solar-interface", "solar-macros", - "strum 0.27.2", - "typed-arena", + "strum 0.28.0", +] + +[[package]] +name = "solar-cli" +version = "0.2.0" +source = "git+https://github.com/paradigmxyz/solar?rev=271db3373c6c1cd14d66c6323b7f9ad4e906d045#271db3373c6c1cd14d66c6323b7f9ad4e906d045" +dependencies = [ + "alloy-json-abi", + "alloy-primitives", + "anstream", + "anstyle", + "bitflags 2.13.2", + "bs58", + "clap", + "indexmap 2.14.2", + "libc", + "mimalloc", + "rayon", + "semver 1.0.28", + "serde", + "serde_json", + "sha2 0.11.0", + "solar-codegen", + "solar-config", + "solar-data-structures", + "solar-interface", + "solar-sema", + "tikv-jemallocator", + "tracing", + "tracing-subscriber", + "tracing-tracy", +] + +[[package]] +name = "solar-codegen" +version = "0.2.0" +source = "git+https://github.com/paradigmxyz/solar?rev=271db3373c6c1cd14d66c6323b7f9ad4e906d045#271db3373c6c1cd14d66c6323b7f9ad4e906d045" +dependencies = [ + "alloy-primitives", + "arrayvec", + "cranelift-isle", + "derive_more", + "either", + "memchr", + "smallvec", + "solar-ast", + "solar-config", + "solar-data-structures", + "solar-interface", + "solar-parse", + "solar-sema", + "tracing", +] + +[[package]] +name = "solar-compiler" +version = "0.2.0" +source = "git+https://github.com/paradigmxyz/solar?rev=271db3373c6c1cd14d66c6323b7f9ad4e906d045#271db3373c6c1cd14d66c6323b7f9ad4e906d045" +dependencies = [ + "alloy-primitives", + "idna_adapter", + "solar-ast", + "solar-codegen", + "solar-config", + "solar-data-structures", + "solar-interface", + "solar-lint", + "solar-macros", + "solar-parse", + "solar-sema", ] [[package]] name = "solar-config" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "643ddf85ab917f5643ec47eb79ee6db6c6158cfaf7415e39840e154eecf7176f" +version = "0.2.0" +source = "git+https://github.com/paradigmxyz/solar?rev=271db3373c6c1cd14d66c6323b7f9ad4e906d045#271db3373c6c1cd14d66c6323b7f9ad4e906d045" dependencies = [ - "strum 0.27.2", + "alloy-primitives", + "clap", + "colorchoice", + "serde", + "strum 0.28.0", + "vergen-gitcl", ] [[package]] name = "solar-data-structures" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cc21b4df6061e1c825c16faf8e1f16c2341f4c46a2b2a60e03069c4453fc5ac" +version = "0.2.0" +source = "git+https://github.com/paradigmxyz/solar?rev=271db3373c6c1cd14d66c6323b7f9ad4e906d045#271db3373c6c1cd14d66c6323b7f9ad4e906d045" dependencies = [ "bumpalo", - "index_vec", - "indexmap 2.10.0", + "diff", + "indexmap 2.14.2", + "oxc_index", "parking_lot", "rayon", - "rustc-hash 2.1.1", + "rustc-hash", "smallvec", ] [[package]] name = "solar-interface" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb2571bfb2f54c5a24688afe6876682117e96f6fd35393398dbac43e5f6f7144" +version = "0.2.0" +source = "git+https://github.com/paradigmxyz/solar?rev=271db3373c6c1cd14d66c6323b7f9ad4e906d045#271db3373c6c1cd14d66c6323b7f9ad4e906d045" dependencies = [ - "annotate-snippets", + "annotate-snippets 0.12.16", "anstream", "anstyle", - "const-hex", - "derive_more 2.0.1", + "arc-swap", + "derive_more", "dunce", "inturn", - "itertools 0.14.0", - "itoa", - "match_cfg", + "itertools 0.10.5", "normalize-path", + "once_map", "rayon", "scoped-tls", + "semver 1.0.28", "serde", "serde_json", "solar-config", "solar-data-structures", "solar-macros", - "thiserror 2.0.12", + "thiserror 1.0.69", + "tracing", + "unicode-width 0.2.2", +] + +[[package]] +name = "solar-lint" +version = "0.2.0" +source = "git+https://github.com/paradigmxyz/solar?rev=271db3373c6c1cd14d66c6323b7f9ad4e906d045#271db3373c6c1cd14d66c6323b7f9ad4e906d045" +dependencies = [ + "rayon", + "solar-ast", + "solar-interface", + "solar-sema", +] + +[[package]] +name = "solar-lsp" +version = "0.2.0" +source = "git+https://github.com/paradigmxyz/solar?rev=271db3373c6c1cd14d66c6323b7f9ad4e906d045#271db3373c6c1cd14d66c6323b7f9ad4e906d045" +dependencies = [ + "arc-swap", + "async-lsp", + "crop", + "either", + "glob", + "lsp-types", + "memchr", + "normalize-path", + "serde", + "serde_json", + "solar-config", + "solar-interface", + "solar-parse", + "solar-sema", + "thiserror 1.0.69", + "tokio", + "tokio-util", + "toml_edit", + "tower", "tracing", - "unicode-width 0.2.0", ] [[package]] name = "solar-macros" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca57257a3b6ef16bd7995d23da3e661e89cebdae6fb9e42e4331ba0f033bd1d" +version = "0.2.0" +source = "git+https://github.com/paradigmxyz/solar?rev=271db3373c6c1cd14d66c6323b7f9ad4e906d045#271db3373c6c1cd14d66c6323b7f9ad4e906d045" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 3.0.6", ] [[package]] name = "solar-parse" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "081b4d9e2ddd3c7bb90079b3eb253b957ef9eb5c860ed6d6a4068884ec3a8b85" +version = "0.2.0" +source = "git+https://github.com/paradigmxyz/solar?rev=271db3373c6c1cd14d66c6323b7f9ad4e906d045#271db3373c6c1cd14d66c6323b7f9ad4e906d045" dependencies = [ "alloy-primitives", - "bitflags 2.9.1", + "bitflags 2.13.2", "bumpalo", - "itertools 0.14.0", + "itertools 0.10.5", "memchr", - "num-bigint", + "num-bigint 0.5.1", "num-rational", "num-traits", + "ruint", "smallvec", "solar-ast", "solar-data-structures", @@ -8899,45 +12377,45 @@ dependencies = [ [[package]] name = "solar-sema" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93b017d4017ee8324e669c6e5e6fe9a282ed07eb6171e5384ddd17b8a854766f" +version = "0.2.0" +source = "git+https://github.com/paradigmxyz/solar?rev=271db3373c6c1cd14d66c6323b7f9ad4e906d045#271db3373c6c1cd14d66c6323b7f9ad4e906d045" dependencies = [ "alloy-json-abi", "alloy-primitives", - "bitflags 2.9.1", + "bitflags 2.13.2", "bumpalo", - "derive_more 2.0.1", + "comfy-table", + "derive_more", "either", + "indexmap 2.14.2", + "num-bigint 0.5.1", + "num-traits", "once_map", "paste", "rayon", "serde", - "serde_json", "solar-ast", "solar-data-structures", "solar-interface", "solar-macros", "solar-parse", - "strum 0.27.2", + "strum 0.28.0", "thread_local", "tracing", - "typed-arena", ] [[package]] name = "soldeer-commands" -version = "0.5.4" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5234cb6edcb3ac81b73c4554391e47c78d5493ac17c59115d2e72b87746816" +checksum = "b1149d04975bf4da754f2cd9e0b3ab51f33899eb5ff80f4889d93222c60faaef" dependencies = [ "bon", "clap", "clap-verbosity-flag", "cliclack", - "derive_more 2.0.1", + "derive_more", "email-address-parser", - "env_logger", "path-slash", "rayon", "soldeer-core", @@ -8946,14 +12424,14 @@ dependencies = [ [[package]] name = "soldeer-core" -version = "0.5.4" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a67f45108aec81281be2c23000bde114d568ac3899026779f21656bbc3d85671" +checksum = "2ca40c75555686674209accf592806aaa42a9a518e87fc4cae03fd62e4efdccd" dependencies = [ "bon", "chrono", "const-hex", - "derive_more 2.0.1", + "derive_more", "dunce", "home", "ignore", @@ -8961,30 +12439,39 @@ dependencies = [ "path-slash", "rayon", "regex", - "reqwest", + "reqwest 0.13.5", "sanitize-filename", - "semver 1.0.26", + "semver 1.0.28", "serde", "serde_json", "sha2 0.10.9", - "thiserror 2.0.12", + "thiserror 2.0.21", "tokio", "toml_edit", - "uuid 1.17.0", + "uuid 1.26.1", "zip", - "zip-extract", ] [[package]] name = "spanned" -version = "0.3.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86af297923fbcfd107c20a189a6e9c872160df71a7190ae4a7a6c5dce4b2feb6" +checksum = "c92d4b0c055fde758f086eb4a6e73410247df8a3837fd606d2caeeaf72aa566d" dependencies = [ + "anyhow", "bstr", "color-eyre", ] +[[package]] +name = "spin" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" +dependencies = [ + "lock_api", +] + [[package]] name = "spki" version = "0.7.3" @@ -8992,14 +12479,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der 0.8.2", +] + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", ] [[package]] name = "stable_deref_trait" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "static_assertions" @@ -9008,35 +12517,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] -name = "str_stack" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9091b6114800a5f2141aee1d1b9d6ca3592ac062dc5decb3764ec5895a47b4eb" - -[[package]] -name = "string_cache" -version = "0.8.9" +name = "str_indices" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" -dependencies = [ - "new_debug_unreachable", - "parking_lot", - "phf_shared", - "precomputed-hash", - "serde", -] +checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" [[package]] -name = "string_cache_codegen" -version = "0.5.4" +name = "str_stack" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" -dependencies = [ - "phf_generator", - "phf_shared", - "proc-macro2", - "quote", -] +checksum = "7f446288b699d66d0fd2e30d1cfe7869194312524b3b9252594868ed26ef056a" [[package]] name = "strip-ansi-escapes" @@ -9055,45 +12545,44 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" -version = "0.26.3" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros 0.26.4", + "strum_macros 0.27.2", ] [[package]] name = "strum" -version = "0.27.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" dependencies = [ - "strum_macros 0.27.2", + "strum_macros 0.28.0", ] [[package]] name = "strum_macros" -version = "0.26.4" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ "heck", "proc-macro2", "quote", - "rustversion", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] name = "strum_macros" -version = "0.27.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] @@ -9102,36 +12591,43 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "subtle-ng" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "734676eb262c623cec13c3155096e08d1f8f29adce39ba17948b18dad1e54142" + [[package]] name = "sval" -version = "2.14.1" +version = "2.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cc9739f56c5d0c44a5ed45473ec868af02eb896af8c05f616673a31e1d1bb09" +checksum = "b81b254da21fe1fcc4e3a74fe39b46e25e3a863078f8b71c954d47f84889dbc6" [[package]] name = "sval_buffer" -version = "2.14.1" +version = "2.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f39b07436a8c271b34dad5070c634d1d3d76d6776e938ee97b4a66a5e8003d0b" +checksum = "50be352d2822ffafb59e3e2ddac9d5ee60f2eeadbb7b5a2a951b9f3651e87a6f" dependencies = [ "sval", "sval_ref", + "zerocopy", ] [[package]] name = "sval_dynamic" -version = "2.14.1" +version = "2.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffcb072d857431bf885580dacecf05ed987bac931230736739a79051dbf3499b" +checksum = "b048ca293b998d9a45659159f94a64063791e74cdc670164943dbb434405573d" dependencies = [ "sval", ] [[package]] name = "sval_fmt" -version = "2.14.1" +version = "2.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f214f427ad94a553e5ca5514c95c6be84667cbc5568cce957f03f3477d03d5c" +checksum = "e6b5888e40f80568733217f27b7317b845f463400ced36c424b1a804730e53b2" dependencies = [ "itoa", "ryu", @@ -9140,9 +12636,9 @@ dependencies = [ [[package]] name = "sval_json" -version = "2.14.1" +version = "2.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ed34b32e638dec9a99c8ac92d0aa1220d40041026b625474c2b6a4d6f4feb" +checksum = "e17664d6bb6b74947afaab9d7c991caa9bf5638d4dee16fcbef637f440796049" dependencies = [ "itoa", "ryu", @@ -9151,9 +12647,9 @@ dependencies = [ [[package]] name = "sval_nested" -version = "2.14.1" +version = "2.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14bae8fcb2f24fee2c42c1f19037707f7c9a29a0cda936d2188d48a961c4bb2a" +checksum = "07c059969ca5ca163ea7fef6c9661758973d17691aba92abdcf5c428f4ec122c" dependencies = [ "sval", "sval_buffer", @@ -9162,52 +12658,52 @@ dependencies = [ [[package]] name = "sval_ref" -version = "2.14.1" +version = "2.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4eaea3821d3046dcba81d4b8489421da42961889902342691fb7eab491d79e" +checksum = "42d6b29ff568c85c87561807f51d2adfff4b6016c6363133f7cd1652a12548f3" dependencies = [ "sval", ] [[package]] name = "sval_serde" -version = "2.14.1" +version = "2.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "172dd4aa8cb3b45c8ac8f3b4111d644cd26938b0643ede8f93070812b87fb339" +checksum = "8f33ec9edc42b12764d5c90ca0a1d84189c6bde81ed27507f1e661c6e4e05853" dependencies = [ - "serde", + "serde_core", "sval", "sval_nested", ] [[package]] name = "svm-rs" -version = "0.5.16" +version = "0.5.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62d304f1b54e9c83ec8f0537c9dd40d46344bd9142cc528d5242c4b6fe11ced0" +checksum = "fff93ed02d40750ad3994db17a8b684af101001128f3bb934041d1f048276b36" dependencies = [ "const-hex", + "digest-io", "dirs", - "fs4", - "reqwest", - "semver 1.0.26", + "reqwest 0.13.5", + "semver 1.0.28", "serde", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", "tempfile", - "thiserror 2.0.12", + "thiserror 2.0.21", "url", "zip", ] [[package]] name = "svm-rs-builds" -version = "0.5.16" +version = "0.5.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ed5035d2abae3cd98c201b116ff22a82861589c060f3e4b687ce951cf381a6e" +checksum = "75b1f441cb747ae19339af229724af9e431db6bb0612846450764f2e4e902613" dependencies = [ "const-hex", - "semver 1.0.26", + "semver 1.0.28", "serde_json", "svm-rs", ] @@ -9225,9 +12721,20 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.104" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" dependencies = [ "proc-macro2", "quote", @@ -9236,93 +12743,334 @@ dependencies = [ [[package]] name = "syn-solidity" -version = "1.2.1" +version = "1.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ac494e7266fcdd2ad80bf4375d55d27a117ea5c866c26d0e97fe5b3caeeb75" +checksum = "eb6a2e3c7f7a3e4e83d1752cec5d1e357ced0cf96e85419b6a07f227db3def3a" dependencies = [ "paste", "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tag_ptr" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0e973b34477b7823833469eb0f5a3a60370fef7a453e02d751b59180d0a5a05" + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tempo-alloy" +version = "1.11.0" +source = "git+https://github.com/tempoxyz/tempo?rev=6ef1f812c9ddcbe90a88ada73944012520942d26#6ef1f812c9ddcbe90a88ada73944012520942d26" +dependencies = [ + "alloy-consensus", + "alloy-contract", + "alloy-eips", + "alloy-json-rpc", + "alloy-network", + "alloy-primitives", + "alloy-provider", + "alloy-rlp", + "alloy-rpc-client", + "alloy-rpc-types-eth", + "alloy-serde", + "alloy-signer", + "alloy-signer-local", + "alloy-sol-types", + "alloy-transport", + "async-trait", + "base64 0.22.1", + "dashmap", + "derive_more", + "dirs-next", + "futures", + "http 1.5.0", + "p256 0.13.2", + "reqwest 0.13.5", + "serde", + "serde_json", + "sha2 0.10.9", + "tempo-chainspec", + "tempo-contracts", + "tempo-primitives", + "thiserror 2.0.21", + "tower", + "tracing", +] + +[[package]] +name = "tempo-chainspec" +version = "1.11.0" +source = "git+https://github.com/tempoxyz/tempo?rev=6ef1f812c9ddcbe90a88ada73944012520942d26#6ef1f812c9ddcbe90a88ada73944012520942d26" +dependencies = [ + "alloy-eips", + "alloy-evm", + "alloy-genesis", + "alloy-primitives", + "commonware-codec", + "commonware-cryptography", + "eyre", + "once_cell", + "paste", + "reth-chainspec", + "reth-network-peers", + "serde", + "serde_json", + "tempo-dkg-onchain-artifacts", + "tempo-hardfork", + "tempo-primitives", +] + +[[package]] +name = "tempo-contracts" +version = "1.11.0" +source = "git+https://github.com/tempoxyz/tempo?rev=6ef1f812c9ddcbe90a88ada73944012520942d26#6ef1f812c9ddcbe90a88ada73944012520942d26" +dependencies = [ + "alloy-contract", + "alloy-primitives", + "alloy-sol-types", + "serde", + "tempo-hardfork", +] + +[[package]] +name = "tempo-dkg-onchain-artifacts" +version = "1.14.0" +source = "git+https://github.com/tempoxyz/tempo?rev=6ef1f812c9ddcbe90a88ada73944012520942d26#6ef1f812c9ddcbe90a88ada73944012520942d26" +dependencies = [ + "bytes", + "commonware-codec", + "commonware-cryptography", + "commonware-utils", ] [[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +name = "tempo-evm" +version = "1.14.0" +source = "git+https://github.com/tempoxyz/tempo?rev=6ef1f812c9ddcbe90a88ada73944012520942d26#6ef1f812c9ddcbe90a88ada73944012520942d26" dependencies = [ - "futures-core", + "alloy-consensus", + "alloy-evm", + "alloy-primitives", + "alloy-rlp", + "alloy-sol-types", + "commonware-codec", + "derive_more", + "reth-chainspec", + "reth-consensus", + "reth-consensus-common", + "reth-ethereum-consensus", + "reth-evm", + "reth-evm-ethereum", + "reth-primitives-traits 0.8.2", + "reth-revm", + "tempo-chainspec", + "tempo-contracts", + "tempo-dkg-onchain-artifacts", + "tempo-precompiles", + "tempo-primitives", + "tempo-revm", + "thiserror 2.0.21", + "tracing", ] [[package]] -name = "system-configuration" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +name = "tempo-hardfork" +version = "1.11.0" +source = "git+https://github.com/tempoxyz/tempo?rev=6ef1f812c9ddcbe90a88ada73944012520942d26#6ef1f812c9ddcbe90a88ada73944012520942d26" dependencies = [ - "bitflags 2.9.1", - "core-foundation 0.9.4", - "system-configuration-sys", + "alloy-eips", + "alloy-evm", + "alloy-hardforks", + "paste", + "serde", ] [[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +name = "tempo-nitro-attestation" +version = "1.14.0" +source = "git+https://github.com/tempoxyz/tempo?rev=6ef1f812c9ddcbe90a88ada73944012520942d26#6ef1f812c9ddcbe90a88ada73944012520942d26" dependencies = [ - "core-foundation-sys", - "libc", + "arrayvec", + "aws-lc-rs", + "hex-literal", + "minicbor", + "minicbor-serde", + "serde", + "serde_with", + "x509-cert", ] [[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +name = "tempo-precompiles" +version = "1.14.0" +source = "git+https://github.com/tempoxyz/tempo?rev=6ef1f812c9ddcbe90a88ada73944012520942d26#6ef1f812c9ddcbe90a88ada73944012520942d26" +dependencies = [ + "alloy", + "alloy-evm", + "alloy-json-abi", + "alloy-primitives", + "bitflags 2.13.2", + "commonware-codec", + "commonware-cryptography", + "derive_more", + "itertools 0.14.0", + "keccak-const", + "paste", + "revm", + "scoped-tls", + "serde", + "serde_json", + "tempo-chainspec", + "tempo-contracts", + "tempo-nitro-attestation", + "tempo-precompiles-macros", + "tempo-primitives", + "thiserror 2.0.21", + "tracing", +] [[package]] -name = "tempfile" -version = "3.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +name = "tempo-precompiles-macros" +version = "1.14.0" +source = "git+https://github.com/tempoxyz/tempo?rev=6ef1f812c9ddcbe90a88ada73944012520942d26#6ef1f812c9ddcbe90a88ada73944012520942d26" dependencies = [ - "fastrand", - "getrandom 0.3.3", - "once_cell", - "rustix 1.0.8", - "windows-sys 0.59.0", + "alloy", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "tendril" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +name = "tempo-primitives" +version = "1.11.0" +source = "git+https://github.com/tempoxyz/tempo?rev=6ef1f812c9ddcbe90a88ada73944012520942d26#6ef1f812c9ddcbe90a88ada73944012520942d26" dependencies = [ - "futf", - "mac", - "utf-8", + "alloy-consensus", + "alloy-eips", + "alloy-evm", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "aws-lc-rs", + "base64 0.22.1", + "commonware-cryptography", + "derive_more", + "once_cell", + "p256 0.13.2", + "reth-codecs 0.8.2", + "reth-db-api", + "reth-ethereum-primitives", + "reth-primitives-traits 0.8.2", + "revm", + "serde", + "serde_json", + "sha2 0.10.9", + "tempo-contracts", ] [[package]] -name = "term" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a43bddab41f8626c7bdaab872bbba75f8df5847b516d77c569c746e2ae5eb746" +name = "tempo-revm" +version = "1.14.0" +source = "git+https://github.com/tempoxyz/tempo?rev=6ef1f812c9ddcbe90a88ada73944012520942d26#6ef1f812c9ddcbe90a88ada73944012520942d26" dependencies = [ - "windows-sys 0.60.2", + "alloy-consensus", + "alloy-evm", + "alloy-primitives", + "alloy-sol-types", + "auto_impl", + "derive_more", + "reth-evm", + "reth-storage-api", + "revm", + "tempo-chainspec", + "tempo-contracts", + "tempo-precompiles", + "tempo-primitives", + "thiserror 2.0.21", + "tracing", ] [[package]] name = "terminal_size" -version = "0.4.2" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45c6481c4829e4cc63825e62c49186a34538b7b2750b73b266581ffb612fb5ed" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ - "rustix 1.0.8", - "windows-sys 0.59.0", + "rustix", + "windows-sys 0.61.2", ] [[package]] @@ -9333,7 +13081,7 @@ checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" dependencies = [ "fnv", "nom", - "phf", + "phf 0.11.3", "phf_codegen", ] @@ -9351,9 +13099,15 @@ checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" dependencies = [ "smawk", "unicode-linebreak", - "unicode-width 0.2.0", + "unicode-width 0.2.2", ] +[[package]] +name = "thin-vec" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4568d7e143ec86d2021c338bae2afa88699e84b8e0af523626654fe6f03a1748" + [[package]] name = "thiserror" version = "1.0.69" @@ -9365,11 +13119,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.12" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl 2.0.12", + "thiserror-impl 2.0.21", ] [[package]] @@ -9380,25 +13134,25 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.12" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 3.0.6", ] [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] @@ -9414,9 +13168,9 @@ dependencies = [ [[package]] name = "tikv-jemalloc-sys" -version = "0.6.0+5.3.0-1-ge13ca993e8ccb9ba9847cc330696e02839f328f7" +version = "0.7.1+5.3.1-0-g81034ce1f1373e37dc865038e1bc8eeecf559ce8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd3c60906412afa9c2b5b5a48ca6a5abe5736aec9eb48ad05037a677e52e4e2d" +checksum = "1a2825c78386b4ae0314074867860ba9577875de945f05992c38815cbec327f0" dependencies = [ "cc", "libc", @@ -9424,9 +13178,9 @@ dependencies = [ [[package]] name = "tikv-jemallocator" -version = "0.6.0" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cec5ff18518d81584f477e9bfdf957f5bb0979b0bac3af4ca30b5b3ae2d2865" +checksum = "249f09e49ab1609436f34c776e84231bead18d6a955f119f939bdc1d847561bd" dependencies = [ "libc", "tikv-jemalloc-sys", @@ -9434,9 +13188,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.41" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", @@ -9444,87 +13198,77 @@ dependencies = [ "num-conv", "num_threads", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.4" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.22" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", ] [[package]] -name = "tiny-keccak" -version = "2.0.2" +name = "tinystr" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ - "crunchy", + "displaydoc", + "serde_core", + "zerovec", ] [[package]] name = "tinyvec" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.46.1" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc3a2344dafbe23a245241fe8b09735b521110d30fcefbbd5feb1797ca35d17" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ - "backtrace", "bytes", - "io-uring", "libc", "mio", "parking_lot", "pin-project-lite", "signal-hook-registry", - "slab", "socket2", "tokio-macros", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 3.0.6", ] [[package]] name = "tokio-rustls" -version = "0.26.2" +version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" dependencies = [ "rustls", "tokio", @@ -9532,9 +13276,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.17" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", @@ -9544,9 +13288,9 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.26.2" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" dependencies = [ "futures-util", "log", @@ -9560,90 +13304,94 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.15" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", + "libc", "pin-project-lite", "tokio", ] [[package]] name = "toml" -version = "0.5.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" -dependencies = [ - "serde", -] - -[[package]] -name = "toml" -version = "0.8.23" +version = "1.1.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" dependencies = [ - "indexmap 2.10.0", - "serde", + "indexmap 2.14.2", + "serde_core", "serde_spanned", "toml_datetime", - "toml_edit", + "toml_parser", + "toml_writer", + "winnow", ] [[package]] name = "toml_datetime" -version = "0.6.11" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ - "serde", + "serde_core", ] [[package]] name = "toml_edit" -version = "0.22.27" +version = "0.25.15+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" dependencies = [ - "indexmap 2.10.0", - "serde", + "indexmap 2.14.2", + "serde_core", "serde_spanned", "toml_datetime", - "toml_write", + "toml_parser", + "toml_writer", "winnow", ] [[package]] -name = "toml_write" -version = "0.1.2" +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tonic" -version = "0.13.1" +version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e581ba15a835f4d9ea06c55ab1bd4dce26fc53752c69a04aac00703bfb49ba9" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" dependencies = [ "async-trait", "axum", "base64 0.22.1", "bytes", "h2", - "http 1.3.1", - "http-body 1.0.1", + "http 1.5.0", + "http-body 1.1.0", "http-body-util", "hyper", "hyper-timeout", "hyper-util", "percent-encoding", - "pin-project 1.1.10", - "prost", - "rustls-native-certs", + "pin-project", "socket2", + "sync_wrapper", "tokio", "tokio-rustls", "tokio-stream", @@ -9651,23 +13399,29 @@ dependencies = [ "tower-layer", "tower-service", "tracing", + "webpki-roots 1.0.9", ] [[package]] -name = "topological-sort" -version = "0.2.2" +name = "tonic-prost" +version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea68304e134ecd095ac6c3574494fc62b909f416c4fca77e440530221e549d3d" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost 0.14.4", + "tonic", +] [[package]] name = "tower" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", - "indexmap 2.10.0", + "indexmap 2.14.2", "pin-project-lite", "slab", "sync_wrapper", @@ -9680,29 +13434,41 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.6" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.9.1", + "async-compression", + "bitflags 2.13.2", "bytes", "futures-core", "futures-util", - "http 1.3.1", - "http-body 1.0.1", + "http 1.5.0", + "http-body 1.1.0", "http-body-util", - "http-range-header", - "httpdate", - "iri-string", - "mime", - "mime_guess", - "percent-encoding", "pin-project-lite", "tokio", "tokio-util", "tower", "tower-layer", "tower-service", + "url", +] + +[[package]] +name = "tower-http" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "http 1.5.0", + "http-body 1.1.0", + "percent-encoding", + "pin-project-lite", + "tower-layer", + "tower-service", "tracing", ] @@ -9718,23 +13484,11 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" -[[package]] -name = "tower-util" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1093c19826d33807c72511e68f73b4a0469a3f22c2bd5f7d5212178b4b89674" -dependencies = [ - "futures-core", - "futures-util", - "pin-project 0.4.30", - "tower-service", -] - [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", @@ -9744,20 +13498,20 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", @@ -9770,7 +13524,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" dependencies = [ "tracing", - "tracing-subscriber 0.3.19", + "tracing-subscriber", ] [[package]] @@ -9786,23 +13540,14 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.2.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0d2eaa99c3c2e41547cfa109e910a68ea03823cccad4a0525dcbc9b01e8c71" -dependencies = [ - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.19" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "matchers", "nu-ansi-term", "once_cell", - "regex", + "regex-automata", "sharded-slab", "smallvec", "thread_local", @@ -9813,20 +13558,20 @@ dependencies = [ [[package]] name = "tracing-tracy" -version = "0.11.4" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eaa1852afa96e0fe9e44caa53dc0bd2d9d05e0f2611ce09f97f8677af56e4ba" +checksum = "0b326790213b869d948a5d1ea56472ed5b20c7cefde07684ee35d6b7a980fef1" dependencies = [ "tracing-core", - "tracing-subscriber 0.3.19", + "tracing-subscriber", "tracy-client", ] [[package]] name = "tracy-client" -version = "0.18.2" +version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef54005d3d760186fd662dad4b7bb27ecd5531cdef54d1573ebd3f20a9205ed7" +checksum = "d93fd676d8946eaeaff4881d26d2240a8d106fcaccbd47cb9f1389e3561ba490" dependencies = [ "loom", "once_cell", @@ -9836,28 +13581,37 @@ dependencies = [ [[package]] name = "tracy-client-sys" -version = "0.26.1" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "319c70195101a93f56db4c74733e272d720768e13471f400c78406a326b172b0" +checksum = "eef8a023d2b19f44e430da334f7dfb5e5db219bb83e2b4f56a09c1cb12abe915" dependencies = [ "cc", - "windows-targets 0.52.6", + "windows-targets 0.53.5", ] [[package]] name = "trezor-client" -version = "0.1.4" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10636211ab89c96ed2824adc5ec0d081e1080aeacc24c37abb318dcb31dcc779" +checksum = "87c38ca23d6089dafad1e51137b3dd04546c7b785c8058c1aada839432ec7694" dependencies = [ "byteorder", "hex", "protobuf", "rusb", - "thiserror 1.0.69", + "thiserror 2.0.21", "tracing", ] +[[package]] +name = "triomphe" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40688ea6389c8171614b25491f71d4a27946e0c7ce2da1c6de27e25abf1a0ae" +dependencies = [ + "serde", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -9866,28 +13620,61 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tungstenite" -version = "0.26.2" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ "bytes", "data-encoding", - "http 1.3.1", + "http 1.5.0", "httparse", "log", - "rand 0.9.1", + "rand 0.9.5", "rustls", "rustls-pki-types", "sha1", - "thiserror 2.0.12", - "utf-8", + "thiserror 2.0.21", ] [[package]] -name = "typed-arena" -version = "2.0.2" +name = "turnkey_api_key_stamper" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98a037f2b10b32ddabc83ac250e8a64b08df416b73615eb46bf503e6f3ef06f5" +dependencies = [ + "base64 0.22.1", + "hex", + "k256", + "p256 0.13.2", + "rand_core 0.6.4", + "serde", + "serde_json", + "thiserror 2.0.21", +] + +[[package]] +name = "turnkey_client" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764836e5d03f4b67127badba0d39bed5d046b50e896ae071b0dd47ce17c790ee" +dependencies = [ + "mime", + "prost 0.12.6", + "prost-types 0.12.6", + "reqwest 0.12.28", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.21", + "tokio", + "turnkey_api_key_stamper", +] + +[[package]] +name = "typed-path" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" [[package]] name = "typeid" @@ -9897,9 +13684,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.18.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "ucd-trie" @@ -9909,11 +13696,11 @@ checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "ui_test" -version = "0.29.2" +version = "0.30.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1211b1111c752c73b33073d2958072be08825fd97c9ab4d83444da361a06634b" +checksum = "8c8811281d587a786747c0c49245925016c07767bc996305bdd34d5ce076786a" dependencies = [ - "annotate-snippets", + "annotate-snippets 0.11.5", "anyhow", "bstr", "cargo-platform", @@ -9962,9 +13749,9 @@ dependencies = [ [[package]] name = "unicase" -version = "2.8.1" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] name = "unicode-bidi" @@ -9972,11 +13759,17 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" +[[package]] +name = "unicode-id" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65dd75ab2d9d379dbe86d4059e91d8b5a6e7054865eaba1ddbfc7a817ff96392" + [[package]] name = "unicode-ident" -version = "1.0.18" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" [[package]] name = "unicode-joining-type" @@ -9992,28 +13785,28 @@ checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" [[package]] name = "unicode-normalization" -version = "0.1.24" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" dependencies = [ "tinyvec", ] [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-truncate" -version = "1.1.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ - "itertools 0.13.0", + "itertools 0.14.0", "unicode-segmentation", - "unicode-width 0.1.14", + "unicode-width 0.2.2", ] [[package]] @@ -10024,15 +13817,53 @@ checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" [[package]] name = "unicode-width" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "unicode-xid" version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "unsigned-varint" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06" + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" [[package]] name = "untrusted" @@ -10042,13 +13873,15 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.4" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", + "serde_derive", ] [[package]] @@ -10058,16 +13891,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" [[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - -[[package]] -name = "utf8-width" -version = "0.1.7" +name = "utf16_iter" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" [[package]] name = "utf8_iter" @@ -10087,19 +13914,19 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", "serde", ] [[package]] name = "uuid" -version = "1.17.0" +version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.4.3", "js-sys", - "serde", + "serde_core", "wasm-bindgen", ] @@ -10111,9 +13938,9 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "value-bag" -version = "1.11.1" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "943ce29a8a743eb10d6082545d861b24f9d1b160b7d741e0f2cdf726bec909c5" +checksum = "2799ffb329a792ecfd902b71306c8a815a6ef1c0470fa9953a6aa4d4cecbe511" dependencies = [ "value-bag-serde1", "value-bag-sval2", @@ -10121,20 +13948,20 @@ dependencies = [ [[package]] name = "value-bag-serde1" -version = "1.11.1" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35540706617d373b118d550d41f5dfe0b78a0c195dc13c6815e92e2638432306" +checksum = "0941feceafbe7a8f59ea1096d45b97002884a41306315ad797b3684b63a81d8c" dependencies = [ "erased-serde", - "serde", + "serde_core", "serde_fmt", ] [[package]] name = "value-bag-sval2" -version = "1.11.1" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fe7e140a2658cc16f7ee7a86e413e803fc8f9b5127adc8755c19f9fefa63a52" +checksum = "839752af8179287d27eb2b94164641b1ede9e60ab7424163388dc21ebd0508cd" dependencies = [ "sval", "sval_buffer", @@ -10153,14 +13980,41 @@ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "vergen" -version = "8.3.2" +version = "10.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2990d9ea5967266ea0ccf413a4aa5c42a93dbcfda9cb49a97de6931726b12566" +checksum = "7bdf18a54cf91b4d98a8e8b67f6321606539fbcdcac02536286ad1de37b53fd2" dependencies = [ "anyhow", - "cfg-if", + "bon", + "rustversion", + "time", + "vergen-lib", +] + +[[package]] +name = "vergen-gitcl" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4961429ed12888cb3c6dd20f7dc9508c821091a3ba5fec0156ed5a654c1c4572" +dependencies = [ + "anyhow", + "bon", "rustversion", "time", + "vergen", + "vergen-lib", +] + +[[package]] +name = "vergen-lib" +version = "10.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1343066736be577e40cf72914ecd68b369f7033ce0f7db6b25a6d6f7fbb3ed70" +dependencies = [ + "anyhow", + "bon", + "rustversion", + "serde_json", ] [[package]] @@ -10169,6 +14023,17 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "visibility" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "vsimd" version = "0.8.0" @@ -10193,6 +14058,16 @@ dependencies = [ "libc", ] +[[package]] +name = "waitpid-any" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18aa3ce681e189f125c4c1e1388c03285e2fd434ef52c7203084012ac29c5e4a" +dependencies = [ + "rustix", + "windows-sys 0.59.0", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -10219,58 +14094,49 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "wasi" -version = "0.14.2+wasi-0.2.4" +name = "wasip1" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e26842486624357dbeb8f0381cf1fb42f022291fd787d4a816768fec8cc760" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen-rt", + "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.100" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn 2.0.104", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.50" +version = "0.4.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" dependencies = [ - "cfg-if", "js-sys", - "once_cell", + "tokio", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -10278,31 +14144,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ + "bumpalo", "proc-macro2", "quote", - "syn 2.0.104", - "wasm-bindgen-backend", + "syn 3.0.6", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] [[package]] name = "wasm-streams" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" dependencies = [ "futures-util", "js-sys", @@ -10313,9 +14179,9 @@ dependencies = [ [[package]] name = "wasmtimer" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8d49b5d6c64e8558d9b1b065014426f35c18de636895d24893dbbd329743446" +checksum = "1c598d6b99ea013e35844697fc4670d08339d5cda15588f193c6beedd12f644b" dependencies = [ "futures", "js-sys", @@ -10327,29 +14193,31 @@ dependencies = [ [[package]] name = "watchexec" -version = "8.0.1" +version = "8.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc35794a21139060aca512393e9b1a225fe48fc11edee65c84d6d76b25a53331" +checksum = "d941737150f9a9a3c73c399ecab8570517977c512fb2068388293e9f17fb2807" dependencies = [ "async-priority-channel", "atomic-take", "futures", + "libc", "miette", "normalize-path", "notify", - "thiserror 2.0.12", + "thiserror 2.0.21", "tokio", "tracing", "watchexec-events", "watchexec-signals", "watchexec-supervisor", + "windows-sys 0.61.2", ] [[package]] name = "watchexec-events" -version = "6.0.0" +version = "6.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c4a8973a20c7d30198a12272519163168a9ba8b687693ec9d1f027b75b860d1" +checksum = "13a38b76d05a5e217219a0dae9d328e9bebaf7951c65d7a0fc4fe2b97046a4a3" dependencies = [ "notify-types", "watchexec-signals", @@ -10357,20 +14225,20 @@ dependencies = [ [[package]] name = "watchexec-signals" -version = "5.0.0" +version = "5.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "377729679262964c27e6a28f360a84b7aedb172b59841301c1c77922305dfd83" +checksum = "6467339984783a78be0b068788bbec1dc6dab56316e4da3db9480ecf6a101ad0" dependencies = [ "miette", - "nix 0.30.1", - "thiserror 2.0.12", + "nix 0.31.3", + "thiserror 2.0.21", ] [[package]] name = "watchexec-supervisor" -version = "5.0.1" +version = "5.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92a45c50ea6b2795f3d070ad621618c8737bb98f6bc2eb4847e8e8e2ce2f446c" +checksum = "3ea7d764ea524ffd92213dcf8e4bca8fe603ebd3ef9e0caf07c3cae0461a5122" dependencies = [ "futures", "process-wrap", @@ -10382,9 +14250,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.77" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" dependencies = [ "js-sys", "wasm-bindgen", @@ -10401,15 +14269,28 @@ dependencies = [ ] [[package]] -name = "web_atoms" -version = "0.1.3" +name = "webbrowser" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414" +checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" dependencies = [ - "phf", - "phf_codegen", - "string_cache", - "string_cache_codegen", + "jni", + "log", + "ndk-context", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "url", + "web-sys", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", ] [[package]] @@ -10418,46 +14299,42 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.2", + "webpki-roots 1.0.9", ] [[package]] name = "webpki-roots" -version = "1.0.2" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" dependencies = [ "rustls-pki-types", ] [[package]] name = "which" -version = "4.4.2" +version = "8.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +checksum = "bae2f2b2b816647a1cab1acc91f5bd20812d53cb344382635ec2181940c8034f" dependencies = [ - "either", - "home", - "once_cell", - "rustix 0.38.44", + "libc", ] [[package]] -name = "which" -version = "8.0.0" +name = "wide" +version = "0.7.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d" +checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" dependencies = [ - "env_home", - "rustix 1.0.8", - "winsafe", + "bytemuck", + "safe_arch", ] [[package]] name = "widestring" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd7cf3379ca1aac9eea11fba24fd7e315d621f8dfe35c8d7d2be8b793726e07d" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" [[package]] name = "winapi" @@ -10477,11 +14354,11 @@ checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -10492,31 +14369,30 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows" -version = "0.61.3" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ "windows-collections", "windows-core", "windows-future", - "windows-link", "windows-numerics", ] [[package]] name = "windows-collections" -version = "0.2.0" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ "windows-core", ] [[package]] name = "windows-core" -version = "0.61.2" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", @@ -10527,9 +14403,9 @@ dependencies = [ [[package]] name = "windows-future" -version = "0.2.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ "windows-core", "windows-link", @@ -10538,37 +14414,37 @@ dependencies = [ [[package]] name = "windows-implement" -version = "0.60.0" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] name = "windows-interface" -version = "0.59.1" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 2.0.119", ] [[package]] name = "windows-link" -version = "0.1.3" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-numerics" -version = "0.2.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ "windows-core", "windows-link", @@ -10576,9 +14452,9 @@ dependencies = [ [[package]] name = "windows-registry" -version = "0.5.3" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ "windows-link", "windows-result", @@ -10587,18 +14463,18 @@ dependencies = [ [[package]] name = "windows-result" -version = "0.3.4" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ "windows-link", ] [[package]] name = "windows-strings" -version = "0.4.2" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ "windows-link", ] @@ -10627,7 +14503,16 @@ version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets 0.53.2", + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", ] [[package]] @@ -10648,25 +14533,26 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.53.2" +version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows_aarch64_gnullvm 0.53.0", - "windows_aarch64_msvc 0.53.0", - "windows_i686_gnu 0.53.0", - "windows_i686_gnullvm 0.53.0", - "windows_i686_msvc 0.53.0", - "windows_x86_64_gnu 0.53.0", - "windows_x86_64_gnullvm 0.53.0", - "windows_x86_64_msvc 0.53.0", + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] name = "windows-threading" -version = "0.1.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" dependencies = [ "windows-link", ] @@ -10679,9 +14565,9 @@ checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" [[package]] name = "windows_aarch64_msvc" @@ -10691,9 +14577,9 @@ checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_aarch64_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" [[package]] name = "windows_i686_gnu" @@ -10703,9 +14589,9 @@ checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] name = "windows_i686_gnu" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" [[package]] name = "windows_i686_gnullvm" @@ -10715,9 +14601,9 @@ checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" [[package]] name = "windows_i686_msvc" @@ -10727,9 +14613,9 @@ checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_i686_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" [[package]] name = "windows_x86_64_gnu" @@ -10739,9 +14625,9 @@ checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnu" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" [[package]] name = "windows_x86_64_gnullvm" @@ -10751,9 +14637,9 @@ checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" [[package]] name = "windows_x86_64_msvc" @@ -10763,34 +14649,52 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "windows_x86_64_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.12" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] [[package]] -name = "winsafe" -version = "0.0.19" +name = "wit-bindgen" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +dependencies = [ + "bitflags 2.13.2", +] [[package]] -name = "wit-bindgen-rt" -version = "0.39.0" +name = "wnaf" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa" dependencies = [ - "bitflags 2.9.1", + "ff 0.14.0", + "group 0.14.0", + "hybrid-array", + "primefield", ] +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + [[package]] name = "ws_stream_wasm" version = "0.7.5" @@ -10804,7 +14708,7 @@ dependencies = [ "pharos", "rustc_version 0.4.1", "send_wrapper", - "thiserror 2.0.12", + "thiserror 2.0.21", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -10819,6 +14723,28 @@ dependencies = [ "tap", ] +[[package]] +name = "x25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e8131a03190127fb2263afc72b322ecadae46b6ff8c6f399ff5d02f5559af6" +dependencies = [ + "curve25519-dalek", + "rand_core 0.10.1", + "zeroize", +] + +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid 0.9.6", + "der 0.7.10", + "spki 0.7.3", +] + [[package]] name = "xmlparser" version = "0.13.6" @@ -10826,19 +14752,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" [[package]] -name = "xxhash-rust" -version = "0.8.15" +name = "xsum" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" +checksum = "0637d3a5566a82fa5214bae89087bc8c9fb94cd8e8a3c07feb691bb8d9c632db" [[package]] -name = "xz2" -version = "0.1.7" +name = "xxhash-rust" +version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" -dependencies = [ - "lzma-sys", -] +checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7" [[package]] name = "yansi" @@ -10849,92 +14772,156 @@ dependencies = [ "is-terminal", ] +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure 0.13.2", +] + [[package]] name = "zerocopy" -version = "0.8.26" +version = "0.8.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.26" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 3.0.6", + "synstructure 0.14.0", ] [[package]] name = "zeroize" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.4.2" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "zerovec" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.104", + "syn 3.0.6", ] [[package]] name = "zip" -version = "2.4.2" +version = "8.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" dependencies = [ - "aes", - "arbitrary", - "bzip2", - "constant_time_eq", "crc32fast", - "crossbeam-utils", - "deflate64", - "displaydoc", "flate2", - "getrandom 0.3.3", - "hmac", - "indexmap 2.10.0", - "lzma-rs", + "indexmap 2.14.2", "memchr", - "pbkdf2 0.12.2", - "sha1", - "thiserror 2.0.12", - "time", - "xz2", - "zeroize", + "typed-path", "zopfli", - "zstd", ] [[package]] -name = "zip-extract" -version = "0.2.1" +name = "zlib-rs" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25a8c9e90f27d1435088a7b540b6cc8ae6ee525d992a695f16012d2f365b3d3c" -dependencies = [ - "log", - "thiserror 1.0.69", - "zip", -] +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zopfli" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edfc5ee405f504cd4984ecc6f14d02d55cfda60fa4b689434ef4102aae150cd7" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" dependencies = [ "bumpalo", "crc32fast", @@ -10948,23 +14935,41 @@ version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" dependencies = [ - "zstd-safe", + "zstd-safe 7.3.0", +] + +[[package]] +name = "zstd" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf06bd8162af0734b344780deb55b42a2429ae430870d13fcc12f238e880fe6e" +dependencies = [ + "zstd-safe 8.0.0", ] [[package]] name = "zstd-safe" -version = "7.2.4" +version = "7.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-safe" +version = "8.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +checksum = "ae42c0555055784c70058d19ba8e275528e8a99a706684868ace5da4e716a4ab" dependencies = [ "zstd-sys", ] [[package]] name = "zstd-sys" -version = "2.0.15+zstd.1.5.7" +version = "2.1.0+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb81183ddd97d0c74cedf1d50d85c8d08c1b8b68ee863bdee9e706eedba1a237" +checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" dependencies = [ "cc", "pkg-config", diff --git a/Cargo.toml b/Cargo.toml index 848376fcd052a..4ad6a73e4b1f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,24 +18,31 @@ members = [ "crates/evm/coverage/", "crates/evm/evm/", "crates/evm/fuzz/", + "crates/evm/sancov/", + "crates/evm/symbolic/", + "crates/evm/hardforks/", "crates/evm/traces/", "crates/fmt/", "crates/forge/", - "crates/script-sequence/", + "crates/lint/", "crates/macros/", + "crates/primitives/", + "crates/script-sequence/", + "crates/solar/", "crates/test-utils/", - "crates/lint/", + "crates/test-utils/macros/", + "crates/cli-markdown/", + "crates/tui/", ] resolver = "2" [workspace.package] -version = "1.3.0" +version = "1.8.6" edition = "2024" -# Remember to update clippy.toml as well -rust-version = "1.88" +rust-version = "1.98" authors = ["Foundry Contributors"] license = "MIT OR Apache-2.0" -homepage = "https://github.com/foundry-rs/foundry" +homepage = "https://getfoundry.sh" repository = "https://github.com/foundry-rs/foundry" exclude = ["benches/", "tests/", "test-data/", "testdata/"] @@ -45,23 +52,81 @@ branches_sharing_code = "warn" clear_with_drain = "warn" cloned_instead_of_copied = "warn" collection_is_never_read = "warn" -dbg-macro = "warn" +dbg_macro = "warn" +derive_partial_eq_without_eq = "warn" +empty_line_after_doc_comments = "warn" +empty_line_after_outer_attr = "warn" +enum_glob_use = "warn" +equatable_if_let = "warn" +explicit_into_iter_loop = "warn" explicit_iter_loop = "warn" -manual-string-new = "warn" -uninlined-format-args = "warn" -use-self = "warn" -redundant-clone = "warn" -octal-escapes = "allow" -# until is fixed -literal-string-with-formatting-args = "allow" +flat_map_option = "warn" +if_then_some_else_none = "warn" +implicit_clone = "warn" +if_not_else = "warn" +imprecise_flops = "warn" +iter_on_empty_collections = "warn" +iter_with_drain = "warn" +iter_without_into_iter = "warn" +large_stack_frames = "warn" +manual_assert = "warn" +manual_clamp = "warn" +manual_string_new = "warn" +missing_const_for_fn = "warn" +mutex_integer = "warn" +naive_bytecount = "warn" +needless_bitwise_bool = "warn" +needless_continue = "warn" +needless_for_each = "warn" +nonstandard_macro_braces = "warn" +option_as_ref_cloned = "warn" +path_buf_push_overwrite = "warn" +read_zero_byte_vec = "warn" +redundant_clone = "warn" +single_char_pattern = "warn" +redundant_else = "warn" +string_lit_as_bytes = "warn" +string_lit_chars_any = "warn" +suboptimal_flops = "warn" +suspicious_operation_groupings = "warn" +trailing_empty_array = "warn" +trait_duplication_in_bounds = "warn" +transmute_undefined_repr = "warn" +trivial_regex = "warn" +tuple_array_conversions = "warn" +uninhabited_references = "warn" +unnecessary_struct_initialization = "warn" +uninlined_format_args = "warn" +unnecessary_self_imports = "warn" +unnested_or_patterns = "warn" +unused_peekable = "warn" +unused_rounding = "warn" +use_self = "warn" +useless_let_if_seq = "warn" +while_float = "warn" +zero_sized_map_values = "warn" + +# Explicitly allowed nursery lints — too noisy or have known false positives. +as_ptr_cast_mut = "allow" +cognitive_complexity = "allow" +debug_assert_with_mut_call = "allow" +fallible_impl_from = "allow" +future_not_send = "allow" +needless_collect = "allow" +non_send_fields_in_send_ty = "allow" +redundant_pub_crate = "allow" +significant_drop_in_scrutinee = "allow" +significant_drop_tightening = "allow" +too_long_first_doc_paragraph = "allow" + +# Specific allows. result_large_err = "allow" [workspace.lints.rust] +rust_2018_idioms = { level = "deny", priority = -1 } +unused_must_use = "deny" redundant_imports = "warn" -redundant-lifetimes = "warn" -rust-2018-idioms = "warn" -unused-must-use = "warn" -# unreachable-pub = "warn" +redundant_lifetimes = "warn" [workspace.lints.rustdoc] all = "warn" @@ -74,6 +139,10 @@ all = "warn" debug = "line-tables-only" split-debuginfo = "unpacked" +[profile.test.package.tempo-precompiles] +# Historical Moderato ghost orders violate an upstream debug-only invariant. +debug-assertions = false + [profile.release] opt-level = 3 lto = "thin" @@ -87,17 +156,22 @@ codegen-units = 16 [profile.profiling] inherits = "release" debug = "full" -split-debuginfo = "unpacked" -strip = false +strip = "none" [profile.bench] inherits = "profiling" -[profile.maxperf] +[profile.dist] inherits = "release" lto = "fat" codegen-units = 1 +[profile.fuzz] +inherits = "release" +lto = "thin" +strip = "none" +debug = "line-tables-only" + # Speed up tests and dev build. [profile.dev.package] # Solc and artifacts. @@ -107,13 +181,17 @@ foundry-compilers.opt-level = 3 serde_json.opt-level = 3 serde.opt-level = 3 -foundry-solang-parser.opt-level = 3 -lalrpop-util.opt-level = 3 - +solar-compiler.opt-level = 3 solar-ast.opt-level = 3 solar-data-structures.opt-level = 3 solar-interface.opt-level = 3 solar-parse.opt-level = 3 +solar-sema.opt-level = 3 + +# CLI. +clap.opt-level = 3 +clap_builder.opt-level = 3 +clap_lex.opt-level = 3 # EVM. alloy-dyn-abi.opt-level = 3 @@ -121,29 +199,41 @@ alloy-json-abi.opt-level = 3 alloy-primitives.opt-level = 3 alloy-sol-type-parser.opt-level = 3 alloy-sol-types.opt-level = 3 -hashbrown.opt-level = 3 -foldhash.opt-level = 3 -keccak.opt-level = 3 +alloy-evm.opt-level = 3 + +bitvec.opt-level = 3 revm.opt-level = 3 -revm-primitives.opt-level = 3 -revm-interpreter.opt-level = 3 -revm-precompile.opt-level = 3 -revm-database-interface.opt-level = 3 -revm-database.opt-level = 3 revm-bytecode.opt-level = 3 -revm-state.opt-level = 3 -revm-context-interface.opt-level = 3 revm-context.opt-level = 3 +revm-context-interface.opt-level = 3 +revm-database.opt-level = 3 +revm-database-interface.opt-level = 3 +revm-handler.opt-level = 3 revm-inspector.opt-level = 3 +revm-interpreter.opt-level = 3 +revm-precompile.opt-level = 3 +revm-primitives.opt-level = 3 +revm-state.opt-level = 3 + +revm-inspectors.opt-level = 3 + ruint.opt-level = 3 + sha2.opt-level = 3 sha3.opt-level = 3 -tiny-keccak.opt-level = 3 -bitvec.opt-level = 3 +keccak.opt-level = 3 # Fuzzing. proptest.opt-level = 3 foundry-evm-fuzz.opt-level = 3 +rand.opt-level = 3 +rand_chacha.opt-level = 3 +ppv-lite86.opt-level = 3 + +# Backtraces. +addr2line.opt-level = 3 +backtrace.opt-level = 3 +gimli.opt-level = 3 # Forking. axum.opt-level = 3 @@ -152,151 +242,206 @@ axum.opt-level = 3 scrypt.opt-level = 3 # Misc. +hashbrown.opt-level = 3 +foldhash.opt-level = 3 rayon.opt-level = 3 +rayon-core.opt-level = 3 regex.opt-level = 3 regex-syntax.opt-level = 3 regex-automata.opt-level = 3 +walkdir.opt-level = 3 # Override packages which aren't perf-sensitive for faster compilation speed and smaller binary size. [profile.release.package] -alloy-sol-macro-expander.opt-level = "z" -figment.opt-level = "z" -foundry-compilers-artifacts-solc.opt-level = "z" -foundry-config.opt-level = "z" -html5ever.opt-level = "z" -mdbook.opt-level = "z" -prettyplease.opt-level = "z" -protobuf.opt-level = "z" -pulldown-cmark.opt-level = "z" -syn-solidity.opt-level = "z" -syn.opt-level = "z" -trezor-client.opt-level = "z" +alloy-sol-macro-expander.opt-level = "s" +figment2.opt-level = "s" +foundry-compilers-artifacts-solc.opt-level = "s" +foundry-config.opt-level = "s" +prettyplease.opt-level = "s" +protobuf.opt-level = "s" +syn-solidity.opt-level = "s" +syn.opt-level = "s" +trezor-client.opt-level = "s" + +# Networking stack (not perf-critical for CLI tools). +reqwest.opt-level = "s" +hyper.opt-level = "s" +hyper-util.opt-level = "s" +h2.opt-level = "s" +rustls.opt-level = "s" +tower.opt-level = "s" +tower-http.opt-level = "s" + +# Watch mode. +watchexec.opt-level = "s" +watchexec-events.opt-level = "s" +watchexec-signals.opt-level = "s" +watchexec-supervisor.opt-level = "s" + +# Soldeer package manager. +soldeer-commands.opt-level = "s" +soldeer-core.opt-level = "s" + +# Parsing / editing (not perf-sensitive in CLI). +toml_edit.opt-level = "s" +toml.opt-level = "s" + +# Block explorers / verification. +foundry-block-explorers.opt-level = "s" + +# Misc CLI dependencies. +clap_builder.opt-level = "s" +clap_complete.opt-level = "s" +comfy-table.opt-level = "s" +indicatif.opt-level = "s" +dialoguer.opt-level = "s" +ratatui.opt-level = "s" +crossterm.opt-level = "s" + +# Alloy JSON (parsing, not hot path). +alloy-json-abi.opt-level = "s" [workspace.dependencies] -anvil = { path = "crates/anvil" } -cast = { path = "crates/cast" } -chisel = { path = "crates/chisel" } -forge = { path = "crates/forge" } - -forge-doc = { path = "crates/doc" } -forge-fmt = { path = "crates/fmt" } -forge-lint = { path = "crates/lint" } -forge-verify = { path = "crates/verify" } -forge-script = { path = "crates/script" } -forge-sol-macro-gen = { path = "crates/sol-macro-gen" } -forge-script-sequence = { path = "crates/script-sequence" } -foundry-cheatcodes = { path = "crates/cheatcodes" } +anvil = { path = "crates/anvil", default-features = false } + +forge-doc = { path = "crates/doc", default-features = false } +forge-fmt = { path = "crates/fmt", default-features = false } +forge-lint = { path = "crates/lint", default-features = false } +forge-verify = { path = "crates/verify", default-features = false } +forge-script = { path = "crates/script", default-features = false } +forge-sol-macro-gen = { path = "crates/sol-macro-gen", default-features = false } +forge-script-sequence = { path = "crates/script-sequence", default-features = false } +foundry-cheatcodes = { path = "crates/cheatcodes", default-features = false } foundry-cheatcodes-spec = { path = "crates/cheatcodes/spec" } -foundry-cli = { path = "crates/cli" } -foundry-common = { path = "crates/common" } -foundry-common-fmt = { path = "crates/common/fmt" } +foundry-cli = { path = "crates/cli", default-features = false } +foundry-cli-markdown = { path = "crates/cli-markdown" } +foundry-common = { path = "crates/common", default-features = false } +foundry-common-fmt = { path = "crates/common/fmt", default-features = false } foundry-config = { path = "crates/config" } -foundry-debugger = { path = "crates/debugger" } -foundry-evm = { path = "crates/evm/evm" } +foundry-debugger = { path = "crates/debugger", default-features = false } +foundry-evm = { path = "crates/evm/evm", default-features = false } foundry-evm-abi = { path = "crates/evm/abi" } -foundry-evm-core = { path = "crates/evm/core" } -foundry-evm-coverage = { path = "crates/evm/coverage" } -foundry-evm-fuzz = { path = "crates/evm/fuzz" } -foundry-evm-traces = { path = "crates/evm/traces" } +foundry-evm-core = { path = "crates/evm/core", default-features = false } +foundry-evm-coverage = { path = "crates/evm/coverage", default-features = false } +foundry-evm-hardforks = { path = "crates/evm/hardforks", default-features = false } +foundry-evm-networks = { path = "crates/evm/networks", default-features = false } +foundry-evm-fuzz = { path = "crates/evm/fuzz", default-features = false } +foundry-evm-sancov = { path = "crates/evm/sancov" } +foundry-evm-symbolic = { path = "crates/evm/symbolic", default-features = false } +foundry-evm-traces = { path = "crates/evm/traces", default-features = false } foundry-macros = { path = "crates/macros" } -foundry-test-utils = { path = "crates/test-utils" } -foundry-wallets = { path = "crates/wallets" } +foundry-test-utils = { path = "crates/test-utils", default-features = false } +foundry-test-macros = { path = "crates/test-utils/macros" } +foundry-wallets = { version = "0.1.0", default-features = false } foundry-linking = { path = "crates/linking" } +foundry-primitives = { path = "crates/primitives", default-features = false } +foundry-tui = { path = "crates/tui", default-features = false } # solc & compilation utilities -foundry-block-explorers = { version = "0.20.0", default-features = false } -foundry-compilers = { version = "0.18.0", default-features = false } -foundry-fork-db = "0.16" -solang-parser = { version = "=0.3.9", package = "foundry-solang-parser" } -solar-ast = { version = "=0.1.5", default-features = false } -solar-parse = { version = "=0.1.5", default-features = false } -solar-interface = { version = "=0.1.5", default-features = false } -solar-sema = { version = "=0.1.5", default-features = false } -solar-data-structures = { version = "=0.1.5", default-features = false } +foundry-block-explorers = { version = "0.24.0", default-features = false } +foundry-compilers = { version = "0.21.0", default-features = false, features = [ + "rustls", + "svm-solc", +] } +foundry-fork-db = { version = "0.27.0", features = ["zstd"] } +imbl = "7" +solar = { package = "solar-compiler", version = "=0.2.0", default-features = false } +solar-cli = { version = "=0.2.0", default-features = false } +solar-lsp = { version = "=0.2.0", default-features = false } +solar-lint = { version = "=0.2.0", default-features = false } +svm = { package = "svm-rs", version = "0.5.28", default-features = false, features = [ + "rustls", +] } ## alloy -alloy-consensus = { version = "1.0.22", default-features = false } -alloy-contract = { version = "1.0.22", default-features = false } -alloy-eips = { version = "1.0.22", default-features = false } -alloy-ens = { version = "1.0.22", default-features = false } -alloy-genesis = { version = "1.0.22", default-features = false } -alloy-json-rpc = { version = "1.0.22", default-features = false } -alloy-network = { version = "1.0.22", default-features = false } -alloy-provider = { version = "1.0.22", default-features = false } -alloy-pubsub = { version = "1.0.22", default-features = false } -alloy-rpc-client = { version = "1.0.22", default-features = false } -alloy-rpc-types = { version = "1.0.22", default-features = true } -alloy-serde = { version = "1.0.22", default-features = false } -alloy-signer = { version = "1.0.22", default-features = false } -alloy-signer-aws = { version = "1.0.22", default-features = false } -alloy-signer-gcp = { version = "1.0.22", default-features = false } -alloy-signer-ledger = { version = "1.0.22", default-features = false } -alloy-signer-local = { version = "1.0.22", default-features = false } -alloy-signer-trezor = { version = "1.0.22", default-features = false } -alloy-transport = { version = "1.0.22", default-features = false } -alloy-transport-http = { version = "1.0.22", default-features = false } -alloy-transport-ipc = { version = "1.0.22", default-features = false } -alloy-transport-ws = { version = "1.0.22", default-features = false } -alloy-hardforks = { version = "0.2.12", default-features = false } -alloy-op-hardforks = { version = "0.2.12", default-features = false } +alloy-consensus = { version = "2.5.0", default-features = false } +alloy-contract = { version = "2.5.0", default-features = false } +alloy-eips = { version = "2.5.0", default-features = false } +alloy-eip5792 = { version = "2.5.0", default-features = false } +alloy-ens = { version = "2.5.0", default-features = false } +alloy-genesis = { version = "2.5.0", default-features = false } +alloy-json-rpc = { version = "2.5.0", default-features = false } +alloy-network = { version = "2.5.0", default-features = false } +alloy-provider = { version = "2.5.0", default-features = false } +alloy-pubsub = { version = "2.5.0", default-features = false } +alloy-rpc-client = { version = "2.5.0", default-features = false } +alloy-rpc-types = { version = "2.5.0", default-features = true } +alloy-rpc-types-beacon = { version = "2.5.0", default-features = true } +alloy-rpc-types-engine = { version = "2.5.0", default-features = false } +alloy-rpc-types-eth = { version = "2.5.0", default-features = false } +alloy-rpc-types-mev = { version = "2.5.0", default-features = false } +alloy-serde = { version = "2.5.0", default-features = false } +alloy-signer = { version = "2.5.0", default-features = false } +alloy-signer-local = { version = "2.5.0", default-features = false } +alloy-transport = { version = "2.5.0", default-features = false } +alloy-transport-ipc = { version = "2.5.0", default-features = false } +alloy-transport-ws = { version = "2.5.0", default-features = false } +alloy-hardforks = { version = "0.4.8", default-features = false } +alloy-op-hardforks = { version = "0.5.0", default-features = false } ## alloy-core -alloy-dyn-abi = "1.2.1" -alloy-json-abi = "1.2.1" -alloy-primitives = { version = "1.2.1", features = [ +alloy-dyn-abi = "1.7.0" +alloy-json-abi = "1.7.0" +alloy-primitives = { version = "1.7.0", features = [ "getrandom", "rand", "map-fxhash", "map-foldhash", ] } -alloy-sol-macro-expander = "1.2.1" -alloy-sol-macro-input = "1.2.1" -alloy-sol-types = "1.2.1" +alloy-sol-macro-expander = "1.7.0" +alloy-sol-macro-input = "1.7.0" +alloy-sol-types = "1.7.0" -alloy-chains = "0.2" +alloy-chains = "0.2.38" alloy-rlp = "0.3" alloy-trie = "0.9" ## op-alloy -op-alloy-consensus = "0.17.2" -op-alloy-rpc-types = "0.17.2" +op-alloy-consensus = "2.0.0" +op-alloy-network = "2.0.0" +op-alloy-rpc-types = "2.0.0" op-alloy-flz = "0.13.1" -## revm -revm = { version = "27.0.3", default-features = false } -revm-inspectors = { version = "0.26.5", features = ["serde"] } -op-revm = { version = "8.0.3", default-features = false } - ## alloy-evm -alloy-evm = "0.14.0" -alloy-op-evm = "0.14.0" +alloy-evm = "0.39.0" +alloy-op-evm = "0.32.0" + +# revm +revm = { version = "43.0.2", default-features = false } +revm-inspectors = { version = "0.44.1", features = ["serde"] } +op-revm = { git = "https://github.com/foundry-rs/optimism", rev = "206d26cedb9d861b983d89fc71e1122187dfa877", default-features = false } ## cli -anstream = "0.6" +anstream = "1.0" anstyle = "1.0" -terminal_size = "0.4" +dialoguer = { version = "0.12", default-features = false, features = [ + "password", +] } +clap_complete = "4" +clap_complete_nushell = "4" # macros proc-macro2 = "1.0" quote = "1.0" syn = "2.0" async-trait = "0.1" -derive_more = { version = "2.0", features = ["full"] } +async-lsp = { version = "0.2.4", default-features = false, features = ["omni-trait"] } +tokio-util = { version = "0.7", features = ["compat"] } +derive_more = { version = "2.1", features = ["full"] } thiserror = "2" # allocators mimalloc = "0.1" -tikv-jemallocator = "0.6" -tracy-client = "0.18" +tikv-jemallocator = "0.7" # misc auto_impl = "1" -aws-config = { version = "1", default-features = true } -aws-sdk-kms = { version = "1", default-features = false } -bytes = "1.10" +bytes = "1.11" walkdir = "2" +which = "8" +wait-timeout = "0.2" prettyplease = "0.2" -base64 = "0.22" +base64 = "0.23" chrono = { version = "0.4", default-features = false, features = [ "clock", "std", @@ -304,66 +449,142 @@ chrono = { version = "0.4", default-features = false, features = [ axum = "0.8" ciborium = "0.2" color-eyre = "0.6" -comfy-table = "7" -dirs = "6" +comfy-table = "8" +crossterm = "0.29" +dirs = "7" dunce = "1" -evm-disassembler = "0.5" -evmole = "0.8" +evm-disassembler = "0.6" +evmole = "0.9" eyre = "0.6" -figment = "0.10" -futures = "0.3" -hyper = "1.6" -indicatif = "0.17" -itertools = "0.14" +figment = { package = "figment2", version = "0.11" } +futures = { version = "0.3", default-features = false } +hashbrown = "0.17.1" +hyper = "1.8" +indicatif = "0.18" +inturn = "0.2.0" +itertools = "0.15" jsonpath_lib = "0.3" k256 = "0.13" mesc = "0.3" +memchr = "2.7" +markdown = "1.0" num-format = "0.4" parking_lot = "0.12" -proptest = "1.7.0" +proptest = "1.9.0" rand = "0.9" rand_08 = { package = "rand", version = "0.8" } -rand_chacha = "0.9.0" +ratatui = { version = "0.30", default-features = false } rayon = "1" regex = { version = "1", default-features = false } -reqwest = { version = "0.12", default-features = false, features = [ - "rustls-tls", - "rustls-tls-native-roots", -] } -rustls = "0.23" +reqwest = { version = "0.13", default-features = false, features = ["rustls"] } +# PTY-based CLI tests. +# https://github.com/rust-cli/rexpect/pull/106 +rexpect = { git = "https://github.com/rust-cli/rexpect", rev = "2ed0b1898d7edaf6a85bedbae71a01cc578958fc" } +ron = "0.12" +rustls = "0.23.45" semver = "1" serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0", features = ["arbitrary_precision"] } -similar-asserts = "1.7" -soldeer-commands = "=0.5.4" -soldeer-core = { version = "=0.5.4", features = ["serde"] } -strum = "0.27" -tempfile = "3.20" +shlex = "2" +similar-asserts = "2.0" +soldeer-commands = "=0.12.0" +soldeer-core = { version = "=0.12.0", features = ["serde"] } +strum = "0.28" +tempfile = "3.23" tokio = "1" -toml = "0.8" +toml = "1.1" +toml_edit = "0.25" tower = "0.5" -tower-http = "0.6" +tower-http = "0.7" tracing = "0.1" tracing-subscriber = "0.3" url = "2" -vergen = { version = "8", default-features = false } +vergen = { package = "vergen-gitcl", version = "10.0.0", default-features = false, features = [ + "build", + "cargo", +] } yansi = { version = "1.0", features = ["detect-tty", "detect-env"] } path-slash = "0.2" -jiff = "0.2" +jiff = { version = "0.2", default-features = false, features = [ + "std", + "perf-inline", +] } heck = "0.5" -uuid = "1.17.0" +uuid = "1.19.0" flate2 = "1.1" +ethereum_ssz = "0.10" + +# Tempo +alloy-transport-mpp = { git = "https://github.com/tempoxyz/mpp-rs", rev = "965692a8a5457896b22698a177a371875b73cfc5", default-features = false, features = [ + "aws-lc-rs", +] } +mpp = { git = "https://github.com/tempoxyz/mpp-rs", rev = "965692a8a5457896b22698a177a371875b73cfc5", default-features = false, features = [ + "tempo", + "client", + "sqlite", + "reqwest-rustls-tls", + "ws", +] } +tempo-hardfork = { git = "https://github.com/tempoxyz/tempo", rev = "6ef1f812c9ddcbe90a88ada73944012520942d26", default-features = false, features = [ + "serde", + "std", +] } +tempo-primitives = { git = "https://github.com/tempoxyz/tempo", rev = "6ef1f812c9ddcbe90a88ada73944012520942d26", default-features = false, features = [ + "serde", +] } +tempo-alloy = { git = "https://github.com/tempoxyz/tempo", rev = "6ef1f812c9ddcbe90a88ada73944012520942d26", default-features = false } +tempo-evm = { git = "https://github.com/tempoxyz/tempo", rev = "6ef1f812c9ddcbe90a88ada73944012520942d26", default-features = false } +tempo-revm = { git = "https://github.com/tempoxyz/tempo", rev = "6ef1f812c9ddcbe90a88ada73944012520942d26", default-features = false, features = [ + "serde", +] } +tempo-contracts = { git = "https://github.com/tempoxyz/tempo", rev = "6ef1f812c9ddcbe90a88ada73944012520942d26" } +tempo-precompiles = { git = "https://github.com/tempoxyz/tempo", rev = "6ef1f812c9ddcbe90a88ada73944012520942d26" } + +# Monad +alloy-monad-evm = { version = "0.8.0", default-features = false, features = [ + "std", + "memory_limit", +] } +monad-revm = { version = "0.8.0", default-features = false, features = [ + "std", + "serde", + "memory_limit", +] } + +# Base +base-common-chains = { git = "https://github.com/foundry-rs/base", rev = "2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72", default-features = false } +base-common-consensus = { git = "https://github.com/foundry-rs/base", rev = "2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72", default-features = false } +base-common-evm = { git = "https://github.com/foundry-rs/base", rev = "2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72", default-features = false } +base-common-genesis = { git = "https://github.com/foundry-rs/base", rev = "2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72", default-features = false } +base-common-network = { git = "https://github.com/foundry-rs/base", rev = "2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72", default-features = false } +base-common-precompiles = { git = "https://github.com/foundry-rs/base", rev = "2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72", default-features = false } +base-common-rpc-types = { git = "https://github.com/foundry-rs/base", rev = "2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72", default-features = false } +base-consensus-upgrades = { git = "https://github.com/foundry-rs/base", rev = "2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72", default-features = false } +base-execution-eip8130 = { git = "https://github.com/foundry-rs/base", rev = "2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72", default-features = false } +base-protocol = { git = "https://github.com/foundry-rs/base", rev = "2aa8bc46e6b4272ef3bf0905e1a5660ee2525e72", default-features = false } ## Pinned dependencies. Enabled for the workspace in crates/test-utils. +# testing +snapbox = { version = "1.2", features = ["json", "regex", "term-svg"] } + # Use unicode-rs which has a smaller binary size than the default ICU4X as the IDNA backend, used # by the `url` crate. # See the `idna_adapter` README.md for more details: https://docs.rs/crate/idna_adapter/latest idna_adapter = "=1.1.0" -# Avoid duplicating `zip 2` and `zip 3`. Remove once all `zip` dependencies are updated to `zip 3`. -zip-extract = "=0.2.1" [patch.crates-io] +solar-compiler = { git = "https://github.com/paradigmxyz/solar", rev = "271db3373c6c1cd14d66c6323b7f9ad4e906d045" } +solar-cli = { git = "https://github.com/paradigmxyz/solar", rev = "271db3373c6c1cd14d66c6323b7f9ad4e906d045" } +solar-lsp = { git = "https://github.com/paradigmxyz/solar", rev = "271db3373c6c1cd14d66c6323b7f9ad4e906d045" } +solar-lint = { git = "https://github.com/paradigmxyz/solar", rev = "271db3373c6c1cd14d66c6323b7f9ad4e906d045" } + +## foundry-core +foundry-wallets = { git = "https://github.com/foundry-rs/foundry-core", rev = "39c03a49ade2f74a6e97e2af308283a42d78d302" } +foundry-compilers = { git = "https://github.com/foundry-rs/foundry-core", rev = "39c03a49ade2f74a6e97e2af308283a42d78d302" } +foundry-block-explorers = { git = "https://github.com/foundry-rs/foundry-core", rev = "39c03a49ade2f74a6e97e2af308283a42d78d302" } +foundry-fork-db = { git = "https://github.com/foundry-rs/foundry-core", rev = "39c03a49ade2f74a6e97e2af308283a42d78d302" } + ## alloy-core # alloy-dyn-abi = { path = "../../alloy-rs/core/crates/dyn-abi" } # alloy-json-abi = { path = "../../alloy-rs/core/crates/json-abi" } @@ -376,47 +597,53 @@ zip-extract = "=0.2.1" # syn-solidity = { path = "../../alloy-rs/core/crates/syn-solidity" } ## alloy -# alloy-consensus = { git = "https://github.com/alloy-rs/alloy", rev = "7fab7ee" } -# alloy-contract = { git = "https://github.com/alloy-rs/alloy", rev = "7fab7ee" } -# alloy-eips = { git = "https://github.com/alloy-rs/alloy", rev = "7fab7ee" } -# alloy-genesis = { git = "https://github.com/alloy-rs/alloy", rev = "7fab7ee" } -# alloy-json-rpc = { git = "https://github.com/alloy-rs/alloy", rev = "7fab7ee" } -# alloy-network = { git = "https://github.com/alloy-rs/alloy", rev = "7fab7ee" } -# alloy-network-primitives = { git = "https://github.com/alloy-rs/alloy", rev = "7fab7ee" } -# alloy-provider = { git = "https://github.com/alloy-rs/alloy", rev = "7fab7ee" } -# alloy-pubsub = { git = "https://github.com/alloy-rs/alloy", rev = "7fab7ee" } -# alloy-rpc-client = { git = "https://github.com/alloy-rs/alloy", rev = "7fab7ee" } -# alloy-rpc-types = { git = "https://github.com/alloy-rs/alloy", rev = "7fab7ee" } -# alloy-rpc-types-eth = { git = "https://github.com/alloy-rs/alloy", rev = "7fab7ee" } -# alloy-serde = { git = "https://github.com/alloy-rs/alloy", rev = "7fab7ee" } -# alloy-signer = { git = "https://github.com/alloy-rs/alloy", rev = "7fab7ee" } -# alloy-signer-aws = { git = "https://github.com/alloy-rs/alloy", rev = "7fab7ee" } -# alloy-signer-gcp = { git = "https://github.com/alloy-rs/alloy", rev = "7fab7ee" } -# alloy-signer-ledger = { git = "https://github.com/alloy-rs/alloy", rev = "7fab7ee" } -# alloy-signer-local = { git = "https://github.com/alloy-rs/alloy", rev = "7fab7ee" } -# alloy-signer-trezor = { git = "https://github.com/alloy-rs/alloy", rev = "7fab7ee" } -# alloy-transport = { git = "https://github.com/alloy-rs/alloy", rev = "7fab7ee" } -# alloy-transport-http = { git = "https://github.com/alloy-rs/alloy", rev = "7fab7ee" } -# alloy-transport-ipc = { git = "https://github.com/alloy-rs/alloy", rev = "7fab7ee" } -# alloy-transport-ws = { git = "https://github.com/alloy-rs/alloy", rev = "7fab7ee" } +# alloy-consensus = { git = "https://github.com/alloy-rs/alloy", rev = "395521c" } +# alloy-consensus-any = { git = "https://github.com/alloy-rs/alloy", rev = "395521c" } +# alloy-contract = { git = "https://github.com/alloy-rs/alloy", rev = "395521c" } +# alloy-eips = { git = "https://github.com/alloy-rs/alloy", rev = "395521c" } +# alloy-eip5792 = { git = "https://github.com/alloy-rs/alloy", rev = "395521c" } +# alloy-ens = { git = "https://github.com/alloy-rs/alloy", rev = "395521c" } +# alloy-genesis = { git = "https://github.com/alloy-rs/alloy", rev = "395521c" } +# alloy-json-rpc = { git = "https://github.com/alloy-rs/alloy", rev = "395521c" } +# alloy-network = { git = "https://github.com/alloy-rs/alloy", rev = "395521c" } +# alloy-network-primitives = { git = "https://github.com/alloy-rs/alloy", rev = "395521c" } +# alloy-provider = { git = "https://github.com/alloy-rs/alloy", rev = "395521c" } +# alloy-pubsub = { git = "https://github.com/alloy-rs/alloy", rev = "395521c" } +# alloy-rpc-client = { git = "https://github.com/alloy-rs/alloy", rev = "395521c" } +# alloy-rpc-types = { git = "https://github.com/alloy-rs/alloy", rev = "395521c" } +# alloy-rpc-types-beacon = { git = "https://github.com/alloy-rs/alloy", rev = "395521c" } +# alloy-rpc-types-eth = { git = "https://github.com/alloy-rs/alloy", rev = "395521c" } +# alloy-rpc-types-trace = { git = "https://github.com/alloy-rs/alloy", rev = "395521c" } +# alloy-rpc-types-engine = { git = "https://github.com/alloy-rs/alloy", rev = "395521c" } +# alloy-serde = { git = "https://github.com/alloy-rs/alloy", rev = "395521c" } +# alloy-signer = { git = "https://github.com/alloy-rs/alloy", rev = "395521c" } +# alloy-signer-aws = { git = "https://github.com/alloy-rs/alloy", rev = "395521c" } +# alloy-signer-gcp = { git = "https://github.com/alloy-rs/alloy", rev = "395521c" } +# alloy-signer-ledger = { git = "https://github.com/alloy-rs/alloy", rev = "395521c" } +# alloy-signer-local = { git = "https://github.com/alloy-rs/alloy", rev = "395521c" } +# alloy-signer-trezor = { git = "https://github.com/alloy-rs/alloy", rev = "395521c" } +# alloy-signer-turnkey = { git = "https://github.com/alloy-rs/alloy", rev = "395521c" } +# alloy-transport = { git = "https://github.com/alloy-rs/alloy", rev = "395521c" } +# alloy-transport-http = { git = "https://github.com/alloy-rs/alloy", rev = "395521c" } +# alloy-transport-ipc = { git = "https://github.com/alloy-rs/alloy", rev = "395521c" } +# alloy-transport-ws = { git = "https://github.com/alloy-rs/alloy", rev = "395521c" } +# alloy-tx-macros = { git = "https://github.com/alloy-rs/alloy", rev = "395521c" } +# alloy = { git = "https://github.com/alloy-rs/alloy", rev = "395521c" } ## alloy-evm -# alloy-evm = { git = "https://github.com/alloy-rs/evm.git", rev = "7762adc" } -# alloy-op-evm = { git = "https://github.com/alloy-rs/evm.git", rev = "7762adc" } - -## revm -# revm = { git = "https://github.com/bluealloy/revm.git", rev = "b5808253" } -# op-revm = { git = "https://github.com/bluealloy/revm.git", rev = "b5808253" } -# revm-inspectors = { git = "https://github.com/paradigmxyz/revm-inspectors.git", rev = "956bc98" } - -## foundry -# foundry-block-explorers = { git = "https://github.com/foundry-rs/block-explorers.git", rev = "e09cb89" } -# foundry-compilers = { git = "https://github.com/foundry-rs/compilers.git", rev = "e4a9b04" } -# foundry-fork-db = { git = "https://github.com/foundry-rs/foundry-fork-db", rev = "4ed9afb" } - -## solar -# solar-ast = { git = "https://github.com/paradigmxyz/solar.git", branch = "main" } -# solar-parse = { git = "https://github.com/paradigmxyz/solar.git", branch = "main" } -# solar-interface = { git = "https://github.com/paradigmxyz/solar.git", branch = "main" } -# solar-sema = { git = "https://github.com/paradigmxyz/solar.git", branch = "main" } -# solar-data-structures = { git = "https://github.com/paradigmxyz/solar.git", branch = "main" } +# alloy-evm = { git = "https://github.com/paradigmxyz/evm.git", rev = "04d8e4a" } + +## op-revm / op-alloy / alloy-op-evm +alloy-op-evm = { git = "https://github.com/foundry-rs/optimism", rev = "206d26cedb9d861b983d89fc71e1122187dfa877" } +op-alloy-consensus = { git = "https://github.com/foundry-rs/optimism", rev = "206d26cedb9d861b983d89fc71e1122187dfa877" } +op-alloy-network = { git = "https://github.com/foundry-rs/optimism", rev = "206d26cedb9d861b983d89fc71e1122187dfa877" } +op-alloy-rpc-types = { git = "https://github.com/foundry-rs/optimism", rev = "206d26cedb9d861b983d89fc71e1122187dfa877" } +alloy-op-hardforks = { git = "https://github.com/foundry-rs/optimism", rev = "206d26cedb9d861b983d89fc71e1122187dfa877" } + +## tempo — unify crates.io versions (pulled by mpp/foundry-wallets) with git rev +tempo-primitives = { git = "https://github.com/tempoxyz/tempo", rev = "6ef1f812c9ddcbe90a88ada73944012520942d26" } +tempo-alloy = { git = "https://github.com/tempoxyz/tempo", rev = "6ef1f812c9ddcbe90a88ada73944012520942d26" } +tempo-contracts = { git = "https://github.com/tempoxyz/tempo", rev = "6ef1f812c9ddcbe90a88ada73944012520942d26" } + +[workspace.metadata.cargo-shear] +ignored = ["idna_adapter", "cast", "chisel", "forge", "alloy-contract"] diff --git a/Dockerfile b/Dockerfile index b3d2fa31f732c..761aaa73ee127 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,45 +1,81 @@ -# syntax=docker/dockerfile:1.4 +# syntax=docker/dockerfile:1 -FROM alpine:3.21 AS build-environment +FROM rust:1.98-bookworm@sha256:93ce27a88655056a51dbdd8f5f2d7ddc071c7b0070fb288a37b5a285fc83971e AS chef +WORKDIR /app -ARG TARGETARCH -WORKDIR /opt +RUN apt update && apt install -y build-essential libssl-dev git pkg-config curl perl +RUN set -eux; \ + BINSTALL_VERSION="v1.18.1"; \ + case "$(dpkg --print-architecture)" in \ + amd64) ARCH="x86_64-unknown-linux-musl"; SHA256="cf2a4b54494ea8555d6349685e9a301efc1051d9fba6308c76914b2486f8700f" ;; \ + arm64) ARCH="aarch64-unknown-linux-musl"; SHA256="c55962a0115f9716b709216de7f8bdd59d6ba8738779e60b051b4593f677717a" ;; \ + *) echo "unsupported architecture" >&2; exit 1 ;; \ + esac; \ + curl -L --proto '=https' --tlsv1.2 -sSf \ + "https://github.com/cargo-bins/cargo-binstall/releases/download/${BINSTALL_VERSION}/cargo-binstall-${ARCH}.tgz" \ + -o /tmp/cargo-binstall.tgz; \ + echo "${SHA256} /tmp/cargo-binstall.tgz" | sha256sum -c -; \ + tar -xzf /tmp/cargo-binstall.tgz -C /usr/local/cargo/bin cargo-binstall; \ + rm /tmp/cargo-binstall.tgz +RUN cargo binstall --locked --disable-telemetry --disable-strategies quick-install -y \ + cargo-chef@0.1.78 sccache@0.17.0 -RUN apk add clang lld curl build-base linux-headers git \ - && curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs > rustup.sh \ - && chmod +x ./rustup.sh \ - && ./rustup.sh -y +# Prepare the cargo-chef recipe. +FROM chef AS planner +COPY . . +RUN cargo chef prepare --recipe-path recipe.json -RUN [[ "$TARGETARCH" = "arm64" ]] && echo "export CFLAGS=-mno-outline-atomics" >> $HOME/.profile || true +# Build the project. +FROM chef AS builder +COPY --from=planner /app/recipe.json recipe.json -WORKDIR /opt/foundry -COPY . . +ARG RUST_PROFILE +ARG RUST_FEATURES + +ENV CARGO_INCREMENTAL=0 \ + RUSTC_WRAPPER=sccache \ + SCCACHE_DIR=/sccache -# see -RUN git update-index --force-write-index +# Build dependencies. +RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=shared \ + --mount=type=cache,target=/usr/local/cargo/git,sharing=shared \ + --mount=type=cache,target=$SCCACHE_DIR,sharing=shared \ + cargo chef cook --locked --recipe-path recipe.json --profile ${RUST_PROFILE} --no-default-features --features "${RUST_FEATURES}" + +ARG TAG_NAME="dev" +ENV TAG_NAME=$TAG_NAME +ARG VERGEN_GIT_SHA="ffffffffffffffffffffffffffffffffffffffff" +ENV VERGEN_GIT_SHA=$VERGEN_GIT_SHA + +# Build the project. +COPY . . +RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=shared \ + --mount=type=cache,target=/usr/local/cargo/git,sharing=shared \ + --mount=type=cache,target=$SCCACHE_DIR,sharing=shared \ + cargo build --locked --profile ${RUST_PROFILE} --no-default-features --features "${RUST_FEATURES}" \ + && sccache --show-stats || true -RUN --mount=type=cache,target=/root/.cargo/registry --mount=type=cache,target=/root/.cargo/git --mount=type=cache,target=/opt/foundry/target \ - source $HOME/.profile && cargo build --release --features cast/aws-kms,cast/gcp-kms,forge/aws-kms,forge/gcp-kms \ - && mkdir out \ - && mv target/release/forge out/forge \ - && mv target/release/cast out/cast \ - && mv target/release/anvil out/anvil \ - && mv target/release/chisel out/chisel \ - && strip out/forge \ - && strip out/cast \ - && strip out/chisel \ - && strip out/anvil; +# `dev` profile outputs to the `target/debug` directory. +RUN ln -s /app/target/debug /app/target/dev \ + && mkdir -p /app/output \ + && mv \ + /app/target/${RUST_PROFILE}/forge \ + /app/target/${RUST_PROFILE}/cast \ + /app/target/${RUST_PROFILE}/anvil \ + /app/target/${RUST_PROFILE}/chisel \ + /app/target/${RUST_PROFILE}/solar \ + /app/output/ -FROM alpine:3.21 AS foundry-client +FROM ubuntu:22.04@sha256:eb29ed27b0821dca09c2e28b39135e185fc1302036427d5f4d70a41ce8fd7659 AS runtime -RUN apk add --no-cache linux-headers git gcompat libstdc++ +# Install runtime dependencies. +RUN apt update && apt install -y git -COPY --from=build-environment /opt/foundry/out/forge /usr/local/bin/forge -COPY --from=build-environment /opt/foundry/out/cast /usr/local/bin/cast -COPY --from=build-environment /opt/foundry/out/anvil /usr/local/bin/anvil -COPY --from=build-environment /opt/foundry/out/chisel /usr/local/bin/chisel +COPY --from=builder /app/output/* /usr/local/bin/ -RUN adduser -Du 1000 foundry +RUN groupadd -g 1000 foundry && \ + useradd -m -u 1000 -g foundry foundry +USER foundry ENTRYPOINT ["/bin/sh", "-c"] diff --git a/Dockerfile.cross b/Dockerfile.cross deleted file mode 100644 index 3efdde14ae7c0..0000000000000 --- a/Dockerfile.cross +++ /dev/null @@ -1,28 +0,0 @@ -# This image is meant to enable cross-architecture builds. -# It assumes the foundry binaries have already been compiled for `$TARGETPLATFORM` and are -# locatable in `./dist/bin/$TARGETARCH` -FROM ubuntu:22.04 - -# Filled by docker buildx -ARG TARGETARCH - -RUN apt update && apt install -y git - -COPY ./dist/bin/$TARGETARCH/* /usr/local/bin/ - -RUN groupadd -g 1000 foundry && \ - useradd -m -u 1000 -g foundry foundry - -USER foundry - -ENTRYPOINT ["/bin/sh", "-c"] - -LABEL org.label-schema.build-date=$BUILD_DATE \ - org.label-schema.name="Foundry" \ - org.label-schema.description="Foundry" \ - org.label-schema.url="https://getfoundry.sh" \ - org.label-schema.vcs-ref=$VCS_REF \ - org.label-schema.vcs-url="https://github.com/foundry-rs/foundry.git" \ - org.label-schema.vendor="Foundry-rs" \ - org.label-schema.version=$VERSION \ - org.label-schema.schema-version="1.0" diff --git a/Makefile b/Makefile index e6c31e71752fe..7e3ca8e8ee10b 100644 --- a/Makefile +++ b/Makefile @@ -15,9 +15,9 @@ CARGO_TARGET_DIR ?= target # List of features to use when building. Can be overridden via the environment. # No jemalloc on Windows ifeq ($(OS),Windows_NT) - FEATURES ?= aws-kms gcp-kms cli asm-keccak + FEATURES ?= aws-kms gcp-kms turnkey cli asm-keccak base monad optimism else - FEATURES ?= jemalloc aws-kms gcp-kms cli asm-keccak + FEATURES ?= jemalloc aws-kms gcp-kms turnkey cli asm-keccak base monad optimism endif ##@ Help @@ -30,65 +30,39 @@ help: ## Display this help. .PHONY: build build: ## Build the project. - cargo build --features "$(FEATURES)" --profile "$(PROFILE)" - -# The following commands use `cross` to build a cross-compile. -# -# These commands require that: -# -# - `cross` is installed (`cargo install cross`). -# - Docker is running. -# - The current user is in the `docker` group. -# -# The resulting binaries will be created in the `target/` directory. -build-%: - cross build --target $* --features "$(FEATURES)" --profile "$(PROFILE)" - -.PHONY: docker-build-push -docker-build-push: docker-build-prepare ## Build and push a cross-arch Docker image tagged with DOCKER_IMAGE_NAME. - FEATURES="jemalloc aws-kms gcp-kms cli asm-keccak" $(MAKE) build-x86_64-unknown-linux-gnu - mkdir -p $(BIN_DIR)/amd64 - for bin in anvil cast chisel forge; do \ - cp $(CARGO_TARGET_DIR)/x86_64-unknown-linux-gnu/$(PROFILE)/$$bin $(BIN_DIR)/amd64/; \ - done - - FEATURES="aws-kms gcp-kms cli asm-keccak" $(MAKE) build-aarch64-unknown-linux-gnu - mkdir -p $(BIN_DIR)/arm64 - for bin in anvil cast chisel forge; do \ - cp $(CARGO_TARGET_DIR)/aarch64-unknown-linux-gnu/$(PROFILE)/$$bin $(BIN_DIR)/arm64/; \ - done - - docker buildx build --file ./Dockerfile.cross . \ - --platform linux/amd64,linux/arm64 \ - $(foreach tag,$(shell echo $(DOCKER_IMAGE_NAME) | tr ',' ' '),--tag $(tag)) \ - --provenance=false \ - --push - -.PHONY: docker-build-prepare -docker-build-prepare: ## Prepare the Docker build environment. - docker run --privileged --rm tonistiigi/binfmt:qemu-v7.0.0-28 --install amd64,arm64 - @if ! docker buildx inspect cross-builder &> /dev/null; then \ - echo "Creating a new buildx builder instance"; \ - docker buildx create --use --driver docker-container --name cross-builder; \ - else \ - echo "Using existing buildx builder instance"; \ - docker buildx use cross-builder; \ - fi + cargo build --locked --features "$(FEATURES)" --profile "$(PROFILE)" + +.PHONY: build-docker +build-docker: ## Build the docker image. + docker build . -t "$(DOCKER_IMAGE_NAME)" \ + --build-arg "RUST_PROFILE=$(PROFILE)" \ + --build-arg "RUST_FEATURES=$(FEATURES)" \ + --build-arg "TAG_NAME=dev" \ + --build-arg "VERGEN_GIT_SHA=$(shell git rev-parse HEAD)" ##@ Test +## Run unit/doc tests and generate html coverage report in `target/llvm-cov/html` folder. +## Notice that `llvm-cov` supports doc tests only in nightly builds because the `--doc` flag +## is unstable (https://github.com/taiki-e/cargo-llvm-cov/issues/2). +.PHONY: test-coverage +test-coverage: + cargo +nightly llvm-cov --no-report nextest --locked -E 'kind(test) & !test(/\b(issue|ext_integration|flaky_)/)' && \ + cargo +nightly llvm-cov --no-report --doc --locked && \ + cargo +nightly llvm-cov report --doctests --open + .PHONY: test-unit test-unit: ## Run unit tests. - cargo nextest run -E 'kind(test) & !test(/\b(issue|ext_integration)/)' + cargo nextest run --workspace --locked -E 'kind(test) & !test(/\b(issue|ext_integration|flaky_)/)' .PHONY: test-doc test-doc: ## Run doc tests. - cargo test --doc --workspace + cargo test --doc --workspace --locked .PHONY: test test: ## Run all tests. - make test-unit && \ - make test-doc + $(MAKE) test-unit && \ + $(MAKE) test-doc ##@ Linting @@ -103,37 +77,75 @@ lint-clippy: ## Run clippy on the codebase. --workspace \ --all-targets \ --all-features \ + --locked \ + -- -D warnings + +.PHONY: lint-clippy-fix +lint-clippy-fix: ## Run clippy on the codebase and fix warnings. + cargo +nightly clippy \ + --workspace \ + --all-targets \ + --all-features \ + --locked \ + --fix \ + --allow-dirty \ + --allow-staged \ -- -D warnings .PHONY: lint-typos lint-typos: ## Run typos on the codebase. @command -v typos >/dev/null || { \ - echo "typos not found. Please install it by running the command `cargo install typos-cli` or refer to the following link for more information: https://github.com/crate-ci/typos" \ + echo "typos not found. Please install it by running the command 'cargo install typos-cli' or refer to the following link for more information: https://github.com/crate-ci/typos"; \ exit 1; \ } typos .PHONY: lint lint: ## Run all linters. - make fmt && \ - make lint-clippy && \ - make lint-typos + $(MAKE) fmt && \ + $(MAKE) lint-clippy && \ + $(MAKE) lint-typos + +##@ Documentation + +.PHONY: doc +doc: ## Build the documentation. + RUSTDOCFLAGS="--cfg docsrs -D warnings -Zunstable-options --show-type-layout --generate-link-to-definition" \ + cargo +nightly doc \ + --workspace \ + --all-features \ + --document-private-items \ + --no-deps \ + --locked ##@ Other +.PHONY: lock +lock: ## Update the Cargo.lock file with the current dependencies. + cargo fetch + .PHONY: clean clean: ## Clean the project. cargo clean .PHONY: deny deny: ## Perform a `cargo` deny check. - cargo deny --all-features check all + cargo deny --locked --all-features check all + +.PHONY: check +check: ## Run a feature check on all crates and binaries. + cargo hack check --locked --feature-powerset --depth 1 + +.PHONY: shear +shear: ## Run `cargo shear` to check for unused dependencies. + cargo shear --locked .PHONY: pr pr: ## Run all checks and tests. - make deny && \ - make lint && \ - make test + $(MAKE) deny && \ + $(MAKE) lint && \ + $(MAKE) test && \ + $(MAKE) doc # dprint formatting commands .PHONY: dprint-fmt @@ -150,4 +162,4 @@ dprint-check: ## Check formatting with dprint echo "Installing dprint..."; \ cargo install dprint; \ fi - dprint check \ No newline at end of file + dprint check diff --git a/README.md b/README.md index 44e16b714062a..af4068f47acea 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,8 @@   [![Github Actions][gha-badge]][gha-url] [![Telegram Chat][tg-badge]][tg-url] [![Telegram Support][tg-support-badge]][tg-support-url] -![Foundry](https://img.shields.io/badge/Foundry-grey?style=flat&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAElElEQVR4nH1VUUhUaRg9984YdzBpkqR0Z210rIESIXSabEbcHgydrpNRRj00kWaztj0U1MOW0MOIbD300IvLMqBpMTGYxdoqyoRNDUESBDWwUuPugCSSsTM7u0Oj1/+efdiMcmnP2/fDd77D4f/OB6xCa2urQZbllVICYGtqanK1tLS4AdgAyAAgyzJaW1sNq/ulT4twOGw4fPiwAGDp7Ow8VV1d7bVarRWxWCw/k8mgsbExm0wmZ+Lx+M/Xr1//CcAsSVmSJH01McLhsAEAnE5nx+Tk5B/xeJxOp5N9fX2sqqqixWLhnTt36HA4GIvFGI1GU3V1df5Pe/9D1t7eHkgkEuzo6GBPT49WWloq7Ha7fujQITocDu7atUs3m83i6tWr2okTJ/jixQuePn265zPScDhskGUZe/fubXv8+DFv3rypbdiwQaxbt46RSIT79u3j0NAQb926RVVVOT4+TqvVyvz8fD0YDC5NTk6ysbHxlCRJ/5KSlAAURyKRTFNTkwAg7t69S5/Px76+Pq7GyMgI9+/fz9HRUQIQO3bsEKOjo38DsJCUJADw+/0BVVW7otHo8ps3b4yvXr3CxMQETCYTTCYTNE0DAOTl5SGXy0FRFOzZswdmsxkVFRXLNTU1xmg0+kNvb+/3AGAcGBiI7969Wwcg6urq+OTJE967d49btmzh9PT0R3WJRIKBQIDBYJBTU1NsaGggAGGz2fTe3t5fAeQZAWwuLi4uP3nypOT1emEwGFBeXo7a2losLCygoaEB/f39MJlMCIVCkCQJBw8ehNVqhcfjQXNzs1RSUiKtX7++DEAZqqqq3KFQiABYUFDAM2fOkCQXFxdJkvfv32dhYSG9Xi+vXbvG2dnZj4oDgQCLioqoKAqHhobodDq/Mc7NzUklJSUIBoOw2WzYtm0blpeXsWbNGkxMTODp06doa2vD4OAgNm7cCIvFApLQdR3nzp3Dzp078fLlSxQVFeHdu3cAgIpHjx69/zBUX5k+MDBAt9vNY8eOsbu7m6lUigcOHKDL5WImkyHJz9TGYrEcALsMIPn69esZTdMIgM+ePUNXVxdu376NsrIyuN1uXLp0CWazGcPDw3C5XFBVFWfPnkVNTQ18Pp+ezWY5MzPzO4DfAABHjhzpJslUKqVdvHiR4+PjbG9vZy6XI0kuLS0xmUxSCEGS9Pv9LC0tpdFoZGVlpSaEoM/nuwIAKx/7q5GRkb9CoZBQVVWcP3+ez58/J0mm02kODg7ywoULjMViTKfTtNvtXLt2LTdt2qTncrnlsbGxLICvSUqfrl5HJBLh1NTUkhBCJ8mFhQX29/dTVVUWFBTwwYMH1HWdly9fpqIoeiKRWJqfn2d1dXWnLMuf7zMAHD16tGd+fn7FZy2bzYrKykodAAFQVVV9cXFRkNTevn3Lubk5trS0XPnfxHE4HN8ODw+nV/yanp6mx+Ohx+P5aIMQgmNjY3/W1tZ+t5rsSwG7+fjx4/76+vrm7du32woLC00AkE6n38fj8ZmHDx/+cuPGjR8BJL8YsCtYdQIMALYqilKvKEo9APuHty+egH8A3GfFDJXmxmMAAAAASUVORK5CYII%3D&link=https%3A%2F%2Fbook.getfoundry.sh%2F) -[gha-badge]: https://img.shields.io/github/actions/workflow/status/foundry-rs/foundry/test.yml?branch=master +[gha-badge]: https://img.shields.io/github/actions/workflow/status/foundry-rs/foundry/test.yml?branch=master&style=flat-square [gha-url]: https://github.com/foundry-rs/foundry/actions [tg-badge]: https://img.shields.io/endpoint?color=neon&logo=telegram&label=chat&style=flat-square&url=https%3A%2F%2Ftg.sumanjay.workers.dev%2Ffoundry_rs [tg-url]: https://t.me/foundry_rs @@ -15,6 +14,7 @@ **[Install](https://getfoundry.sh/getting-started/installation)** | [Docs][foundry-docs] +| [Benchmarks](https://www.getfoundry.sh/benchmarks) | [Developer Guidelines](./docs/dev/README.md) | [Contributing](./CONTRIBUTING.md) | [Crate Docs](https://foundry-rs.github.io/foundry) @@ -23,341 +23,94 @@ --- -### Foundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust. +Blazing fast, portable and modular toolkit for Ethereum application development, written in Rust. -Foundry consists of: - -- [**Forge**](#forge): Build, test, fuzz, debug and deploy [Solidity][solidity] contracts, like Hardhat, Brownie, Ape. -- [**Cast**](#cast): A Swiss Army knife for interacting with EVM smart contracts, sending transactions and getting chain data. -- [**Anvil**](#anvil): Fast local Ethereum development node, akin to Hardhat Network, Tenderly. -- [**Chisel**](#chisel): Fast, utilitarian, and verbose Solidity REPL. - -**Need help getting started with Foundry? Read the [📖 Foundry Docs][foundry-docs]!** +- [**Forge**](https://getfoundry.sh/forge) — Build, test, fuzz, debug and deploy Solidity contracts. +- [**Cast**](https://getfoundry.sh/cast) — Swiss Army knife for interacting with EVM smart contracts, sending transactions and getting chain data. +- [**Anvil**](https://getfoundry.sh/anvil) — Fast local Ethereum development node. +- [**Chisel**](https://getfoundry.sh/chisel) — Fast, utilitarian and verbose Solidity REPL. ![Demo](.github/assets/demo.gif) -## Features - -- **High-Performance Compilation** - - - **Fast and Flexible**: Automatically detects and installs the required Solidity compiler version. - - **Solidity and Vyper Support**: Fully supports both Solidity and Vyper out-of-the-box. - - **Incremental Compilation**: Re-compiles only changed files, saving time. - - **Parallelized Pipeline**: Leverages multi-core systems for ultra-fast builds. - - **Broad Compatibility**: Supports non-standard directory structures, including [Hardhat repos](https://twitter.com/gakonst/status/1461289225337421829). - -- **Advanced Testing** - - - **No Context Switching**: Write tests directly in Solidity. - - **Fuzz Testing**: Quickly identify edge cases with input shrinking and counter-example generation. - - **Invariant Testing**: Ensure complex system properties hold across a wide range of inputs. - - **Debugging Made Easy**: Use [forge-std](https://github.com/foundry-rs/forge-std)'s `console.sol` for flexible debug logging. - - **Interactive Debugger**: Step through your Solidity code with Foundry's interactive debugger, making it easy to pinpoint issues. - -- **Powerful Runtime Features** - - - **RPC Forking**: Fast and efficient remote RPC forking backed by [Alloy][alloy]. - - **Lightweight & Portable**: No dependency on Nix or other package managers for installation. - -- **Streamlined CI/CD** - - - **Optimized CI**: Accelerate builds, run tests and execute scripts using [Foundry's GitHub action][foundry-gha]. - ## Installation -Getting started is very easy: - -Install `foundryup`: - -``` +```sh curl -L https://foundry.paradigm.xyz | bash -``` - -Next, run `foundryup`. - -It will automatically install the latest version of the precompiled binaries: [`forge`](#forge), [`cast`](#cast), [`anvil`](#anvil), and [`chisel`](#chisel). - -``` foundryup ``` -**Done!** - -For additional details see the [installation guide](https://getfoundry.sh/getting-started/installation) in the [Foundry Docs][foundry-docs]. - -If you're experiencing any issues while installing, check out [Getting Help](#getting-help) and the [FAQ](https://getfoundry.sh/faq). +See the [installation guide](https://getfoundry.sh/getting-started/installation) for more details. -## How Fast? +## Getting Started -Forge is quite fast at both compiling (leveraging `solc` with [foundry-compilers]) and testing. - -See the benchmarks below. Older benchmarks against [DappTools][dapptools] can be found in the [v0.2.0 announcement post][benchmark-post] and in the [Convex Shutdown Simulation][convex] repository. - -### Testing Benchmarks - -| Project | Type | [Forge 1.0][foundry-1.0] | [Forge 0.2][foundry-0.2] | DappTools | Speedup | -| --------------------------------------------- | -------------------- | ------------------------ | ------------------------ | --------- | -------------- | -| [vectorized/solady][solady] | Unit / Fuzz | 0.9s | 2.3s | - | 2.6x | -| [morpho-org/morpho-blue][morpho-blue] | Invariant | 0.7s | 1m43s | - | 147.1x | -| [morpho-org/morpho-blue-oracles][morpho-blue] | Integration (Cold) | 6.1s | 6.3s | - | 1.04x | -| [morpho-org/morpho-blue-oracles][morpho-blue] | Integration (Cached) | 0.6s | 0.9s | - | 1.50x | -| [transmissions11/solmate][solmate] | Unit / Fuzz | 2.7s | 2.8s | 6m34s | 1.03x / 140.0x | -| [reflexer-labs/geb][geb] | Unit / Fuzz | 0.2s | 0.4s | 23s | 2.0x / 57.5x | - -_In the above benchmarks, compilation was always skipped_ - -**Takeaway: Forge dramatically outperforms the competition, delivering blazing-fast execution speeds while continuously expanding its robust feature set.** - -### Compilation Benchmarks - -
- - - - - - - - - - -  - -
- -**Takeaway: Forge compilation is consistently faster than Hardhat by a factor of `2.1x` to `5.2x`, depending on the amount of caching involved.** - -## Forge - -Forge helps you build, test, fuzz, debug and deploy Solidity contracts. - -The best way to understand Forge is to simply try it (in less than 30 seconds!). - -First, let's initialize a new `counter` example repository: - -```sh -forge init counter -``` - -Next `cd` into `counter` and build : +Initialize a new project, build and test: ```sh +forge init counter && cd counter forge build -``` - -```console -[⠊] Compiling... -[⠔] Compiling 27 files with Solc 0.8.28 -[⠒] Solc 0.8.28 finished in 452.13ms -Compiler run successful! -``` - -Let's [test](https://getfoundry.sh/forge/tests#tests) our contracts: - -```sh forge test ``` -```console -[⠊] Compiling... -No files changed, compilation skipped - -Ran 2 tests for test/Counter.t.sol:CounterTest -[PASS] testFuzz_SetNumber(uint256) (runs: 256, μ: 31121, ~: 31277) -[PASS] test_Increment() (gas: 31293) -Suite result: ok. 2 passed; 0 failed; 0 skipped; finished in 5.35ms (4.86ms CPU time) - -Ran 1 test suite in 5.91ms (5.35ms CPU time): 2 tests passed, 0 failed, 0 skipped (2 total tests) -``` - -Finally, let's run our deployment script: - -```sh -forge script script/Counter.s.sol -``` - -```console -[⠊] Compiling... -No files changed, compilation skipped -Script ran successfully. -Gas used: 109037 - -If you wish to simulate on-chain transactions pass a RPC URL. -``` - -Run `forge --help` to explore the full list of available subcommands and their usage. - -More documentation can be found in the [forge](https://getfoundry.sh/forge/overview) section of the Foundry Docs. - -## Cast - -Cast is a Swiss Army knife for interacting with Ethereum applications from the command line. - -Here are a few examples of what you can do: - -**Check the latest block on Ethereum Mainnet**: +Interact with a live network: ```sh cast block-number --rpc-url https://eth.merkle.io -``` - -**Check the Ether balance of `vitalik.eth`** - -```sh cast balance vitalik.eth --ether --rpc-url https://eth.merkle.io ``` -**Replay and trace a transaction** - -```sh -cast run 0x9c32042f5e997e27e67f82583839548eb19dc78c4769ad6218657c17f2a5ed31 --rpc-url https://eth.merkle.io -``` - -Optionally, pass `--etherscan-api-key ` to decode transaction traces using verified source maps, providing more detailed and human-readable information. - ---- - -Run `cast --help` to explore the full list of available subcommands and their usage. - -More documentation can be found in the [cast](https://getfoundry.sh/cast/overview) section of the Foundry Docs. - -## Anvil - -Anvil is a fast local Ethereum development node. - -Let's fork Ethereum mainnet at the latest block: +Fork mainnet locally: ```sh anvil --fork-url https://eth.merkle.io ``` -You can use those same `cast` subcommands against your `anvil` instance: - -```sh -cast block-number -``` - ---- +Read the [Foundry Docs][foundry-docs] to learn more. -Run `anvil --help` to explore the full list of available features and their usage. +### Solidity editor integration -More documentation can be found in the [anvil](https://getfoundry.sh/anvil/overview) section of the Foundry Docs. +With VS Code installed, run `forge lsp` from your Solidity project's terminal. +Forge opens that project in a VS Code Extension Development Host with its bundled +Solidity extension. No Foundry checkout, extension build, or separate Solar +installation is needed. See the [editor guide](editors/README.md) for setup and +client development. -## Chisel +For other editors, configure the Solidity language server command as +`forge lsp --stdio`. Bare `forge lsp` also retains server mode when standard input +is redirected; `forge lsp --vscode` explicitly opens VS Code. Solar uses +`foundry.toml`, workspace folders, remappings, and `evm_version` from the project +automatically. -Chisel is a fast, utilitarian, and verbose Solidity REPL. +Solar's default flycheck runs `forge lint --json` with the same Forge executable +that started the server. Solar's existing `initializationOptions.forgePath` +option overrides that executable. -To use Chisel, simply type `chisel`. - -```sh -chisel -``` - -From here, start writing Solidity code! Chisel will offer verbose feedback on each input. - -Create a variable `a` and query it: - -```console -➜ uint256 a = 123; -➜ a -Type: uint256 -├ Hex: 0x7b -├ Hex (full word): 0x000000000000000000000000000000000000000000000000000000000000007b -└ Decimal: 123 -``` - -Finally, run `!source` to see `a` was applied: - -```solidity -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.28; - -import {Vm} from "forge-std/Vm.sol"; - -contract REPL { - Vm internal constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); - - /// @notice REPL contract entry point - function run() public { - uint256 a = 123; - } -} -``` - ---- - -Run `chisel --help` to explore the full list of available features and their usage. - -More documentation can be found in the [chisel](https://getfoundry.sh/chisel/overview) section of the Foundry Docs. - -## Configuration - -Foundry is highly configurable, allowing you to tailor it to your needs. Configuration is managed via a file called [`foundry.toml`](./crates/config) located in the root of your project or any parent directory. For a full list of configuration options, refer to the [config package documentation](./crates/config/README.md#all-options). - -**Profiles and Namespaces** - -- Configuration can be organized into **profiles**, which are arbitrarily namespaced for flexibility. -- The default profile is named `default`. Learn more in the [Default Profile section](./crates/config/README.md#default-profile). -- To select a different profile, set the `FOUNDRY_PROFILE` environment variable. -- Override specific settings using environment variables prefixed with `FOUNDRY_` (e.g., `FOUNDRY_SRC`). - ---- - -You can find additional [setup and configurations guides](https://getfoundry.sh/config/overview) in the [Foundry Docs][foundry-docs] and in the [config crate](./crates/config/README.md): - -- [Configuring with `foundry.toml`](https://getfoundry.sh/config/overview) -- [Setting up VSCode][vscode-setup] -- [Shell autocompletions][shell-setup] +`forge lsp` follows Forge's normal environment setup, global-option parsing, and +command dispatch. In server mode, project dotenv warnings use stderr, leaving +stdout reserved for the LSP transport. ## Contributing -See our [contributing guidelines](./CONTRIBUTING.md). +Contributions are welcome and highly appreciated. To get started, check out the [contributing guidelines](./CONTRIBUTING.md). -## Getting Help +Join our [Telegram][tg-url] to chat about the development of Foundry. -First, see if the answer to your question can be found in the [Foundy Docs][foundry-docs], or in the relevant crate. +## Support -If the answer is not there: +Having trouble? Check the [Foundry Docs][foundry-docs], join the [support Telegram][tg-support-url], or [open an issue](https://github.com/foundry-rs/foundry/issues/new). -- Join the [support Telegram][tg-support-url] to get help, or -- Open a [discussion](https://github.com/foundry-rs/foundry/discussions/new) with your question, or -- Open an issue with [the bug](https://github.com/foundry-rs/foundry/issues/new) +#### License -If you want to contribute, or follow along with contributor discussion, you can use our [main telegram](https://t.me/foundry_rs) to chat with us about the development of Foundry! + +Licensed under either of Apache License, Version +2.0 or MIT license at your option. + -## License - -Licensed under either of [Apache License](./LICENSE-APACHE), Version -2.0 or [MIT License](./LICENSE-MIT) at your option. +
+ Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in these crates by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions. + -## Acknowledgements - -- Foundry is a clean-room rewrite of the testing framework [DappTools][dapptools]. None of this would have been possible without the DappHub team's work over the years. -- [Matthias Seitz](https://twitter.com/mattsse_): Created [ethers-solc] (now [foundry-compilers]) which is the backbone of our compilation pipeline, as well as countless contributions to ethers, in particular the `abigen` macros. -- [Rohit Narurkar](https://twitter.com/rohitnarurkar): Created the Rust Solidity version manager [svm-rs](https://github.com/roynalnaruto/svm-rs) which we use to auto-detect and manage multiple Solidity versions. -- [Brock Elmore](https://twitter.com/brockjelmore): For extending the VM's cheatcodes and implementing [structured call tracing](https://github.com/foundry-rs/foundry/pull/192), a critical feature for debugging smart contract calls. -- All the other [contributors](https://github.com/foundry-rs/foundry/graphs/contributors) to the [ethers-rs](https://github.com/gakonst/ethers-rs), [alloy][alloy] & [foundry](https://github.com/foundry-rs/foundry) repositories and chatrooms. - -[solidity]: https://soliditylang.org/ [foundry-docs]: https://getfoundry.sh -[foundry-gha]: https://github.com/foundry-rs/foundry-toolchain -[foundry-compilers]: https://github.com/foundry-rs/compilers -[ethers-solc]: https://github.com/gakonst/ethers-rs/tree/master/ethers-solc/ -[solady]: https://github.com/Vectorized/solady -[openzeppelin]: https://github.com/OpenZeppelin/openzeppelin-contracts/tree/release-v5.1 -[morpho-blue]: https://github.com/morpho-org/morpho-blue -[foundry-compilers]: https://github.com/foundry-rs/compilers -[solmate]: https://github.com/transmissions11/solmate/ -[geb]: https://github.com/reflexer-labs/geb -[benchmark-post]: https://www.paradigm.xyz/2022/03/foundry-02#blazing-fast-compilation--testing -[convex]: https://github.com/mds1/convex-shutdown-simulation -[vscode-setup]: https://getfoundry.sh/config/vscode.html -[shell-setup]: https://getfoundry.sh/config/shell-autocompletion.html -[foundry-0.2]: https://github.com/foundry-rs/foundry/releases/tag/nightly-5b7e4cb3c882b28f3c32ba580de27ce7381f415a -[foundry-1.0]: https://github.com/foundry-rs/foundry/releases/tag/nightly-59f354c179f4e7f6d7292acb3d068815c79286d1 -[dapptools]: https://github.com/dapphub/dapptools -[alloy]: https://github.com/alloy-rs/alloy diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index bea27ad1140c0..0000000000000 --- a/SECURITY.md +++ /dev/null @@ -1,5 +0,0 @@ -# Security Policy - -## Reporting a Vulnerability - -Contact [security@ithaca.xyz](mailto:security@ithaca.xyz). diff --git a/benches/Cargo.toml b/benches/Cargo.toml index 752481377078d..f4d6972163717 100644 --- a/benches/Cargo.toml +++ b/benches/Cargo.toml @@ -1,29 +1,40 @@ [package] name = "foundry-bench" -version = "0.1.0" -edition = "2024" -license = "Apache-2.0 OR MIT" +description = "Foundry benchmark runner" + +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true + +[lints] +workspace = true [[bin]] name = "foundry-bench" path = "src/main.rs" +[[bin]] +name = "foundry-scfuzzbench" +path = "src/scfuzzbench.rs" + [dependencies] foundry-test-utils.workspace = true -foundry-config.workspace = true foundry-common.workspace = true foundry-compilers = { workspace = true, features = ["project-util"] } eyre.workspace = true color-eyre.workspace = true serde.workspace = true serde_json.workspace = true -tempfile.workspace = true -tokio = { workspace = true, features = ["full"] } chrono = { version = "0.4", features = ["serde"] } rayon.workspace = true clap = { version = "4", features = ["derive"] } -num_cpus = "1.17" once_cell = "1.21" +ctrlc = { version = "3", features = ["termination"] } +libc = "0.2" [dev-dependencies] -foundry-test-utils.workspace = true +tempfile.workspace = true diff --git a/benches/LATEST.md b/benches/LATEST.md index 9a140dda70573..ed0a620de3125 100644 --- a/benches/LATEST.md +++ b/benches/LATEST.md @@ -1,72 +1,121 @@ # 📊 Foundry Benchmark Results -**Generated at**: 2025-07-17 14:25:08 UTC +**Generated at**: 2026-08-26 18:51:58 UTC ## Forge Test ### Repositories Tested 1. [ithacaxyz/account](https://github.com/ithacaxyz/account) -2. [Vectorized/solady](https://github.com/Vectorized/solady) +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) +5. [aave/aave-v4](https://github.com/aave/aave-v4) + ### Foundry Versions -- **stable**: forge Version: 1.2.3-stable (a813a2c 2025-06-08) -- **nightly**: forge Version: 1.3.0-nightly (0af4341 2025-07-17) +- **v1.7.1**: forge Version: 1.7.1 (4072e48 2026-05-08) +- **v1.8.0**: forge Version: 1.8.0 (61ae26a 2026-08-26) -| Repository | stable | nightly | +| Repository | v1.7.1 | v1.8.0 | |------------|----------|----------| -| ithacaxyz-account | 5.12 s | 5.15 s | -| solady | 2.99 s | 2.87 s | +| ithacaxyz-account | 0.984 s | 1.03 s | +| vectorized-solady | 0.904 s | 0.764 s | +| uniswap-v4-core | 2.33 s | 2.18 s | +| sparkdotfi-spark-psm | 17.07 s | 14.42 s | +| aave-aave-v4 | 3m 1.5s | 2m 27.0s | ## Forge Fuzz Test -| Repository | stable | nightly | +| Repository | v1.7.1 | v1.8.0 | +|------------|----------|----------| +| ithacaxyz-account | 1.07 s | 0.955 s | +| vectorized-solady | 0.853 s | 0.699 s | +| uniswap-v4-core | 2.26 s | 2.00 s | +| sparkdotfi-spark-psm | 1.75 s | 1.54 s | +| aave-aave-v4 | 3m 2.8s | 2m 27.4s | + +## Forge Test (Isolated) + +### Repositories Tested + +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) +5. [aave/aave-v4](https://github.com/aave/aave-v4) + +### Foundry Versions + +- **v1.7.1**: forge Version: 1.7.1 (4072e48 2026-05-08) +- **v1.8.0**: forge Version: 1.8.0 (61ae26a 2026-08-26) + +| Repository | v1.7.1 | v1.8.0 | |------------|----------|----------| -| ithacaxyz-account | 5.45 s | 5.20 s | -| solady | 3.08 s | 3.00 s | +| ithacaxyz-account | 1.06 s | 1.05 s | +| vectorized-solady | 0.955 s | 0.879 s | +| uniswap-v4-core | 2.37 s | 2.17 s | +| sparkdotfi-spark-psm | 18.00 s | 14.45 s | +| aave-aave-v4 | 3m 34.7s | 2m 55.8s | ## Forge Build ### Repositories Tested 1. [ithacaxyz/account](https://github.com/ithacaxyz/account) -2. [Vectorized/solady](https://github.com/Vectorized/solady) +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) +5. [aave/aave-v4](https://github.com/aave/aave-v4) + ### Foundry Versions -- **stable**: forge Version: 1.2.3-stable (a813a2c 2025-06-08) -- **nightly**: forge Version: 1.3.0-nightly (0af4341 2025-07-17) +- **v1.7.1**: forge Version: 1.7.1 (4072e48 2026-05-08) +- **v1.8.0**: forge Version: 1.8.0 (61ae26a 2026-08-26) ### No Cache -| Repository | stable | nightly | +| Repository | v1.7.1 | v1.8.0 | |------------|----------|----------| -| ithacaxyz-account | 2.58 s | 2.83 s | -| solady | 3.88 s | 3.98 s | +| ithacaxyz-account | 23.43 s | 23.84 s | +| vectorized-solady | 12.28 s | 12.30 s | +| uniswap-v4-core | 1m 48.1s | 1m 48.8s | +| sparkdotfi-spark-psm | 13.08 s | 13.33 s | +| aave-aave-v4 | 3m 14.3s | 3m 14.1s | ### With Cache -| Repository | stable | nightly | +| Repository | v1.7.1 | v1.8.0 | |------------|----------|----------| -| ithacaxyz-account | 0.210 s | 0.443 s | -| solady | 0.093 s | 0.195 s | +| ithacaxyz-account | 0.214 s | 0.083 s | +| vectorized-solady | 0.107 s | 0.103 s | +| uniswap-v4-core | 0.138 s | 0.055 s | +| sparkdotfi-spark-psm | 0.147 s | 0.061 s | +| aave-aave-v4 | 0.290 s | 0.207 s | ## Forge Coverage ### Repositories Tested 1. [ithacaxyz/account](https://github.com/ithacaxyz/account) +2. [uniswap/v4-core](https://github.com/uniswap/v4-core) +3. [sparkdotfi/spark-psm](https://github.com/sparkdotfi/spark-psm) +4. [aave/aave-v4](https://github.com/aave/aave-v4) + ### Foundry Versions -- **stable**: forge Version: 1.2.3-stable (a813a2c 2025-06-08) -- **nightly**: forge Version: 1.3.0-nightly (0af4341 2025-07-17) +- **v1.7.1**: forge Version: 1.7.1 (4072e48 2026-05-08) +- **v1.8.0**: forge Version: 1.8.0 (61ae26a 2026-08-26) -| Repository | stable | nightly | +| Repository | v1.7.1 | v1.8.0 | |------------|----------|----------| -| ithacaxyz-account | 22.03 s | 21.98 s | +| ithacaxyz-account | 17.70 s | 17.83 s | +| uniswap-v4-core | 57.41 s | 58.64 s | +| sparkdotfi-spark-psm | 2m 9.5s | 2m 0.2s | +| aave-aave-v4 | 10m 28.4s | 8m 50.0s | ## System Information - -- **OS**: linux -- **CPU**: 8 -- **Rustc**: rustc 1.88.0 (6b00bc388 2025-06-23) +- **OS**: macos +- **CPU**: 18 +- **Rustc**: rustc 1.97.0 (2d8144b78 2026-07-07) diff --git a/benches/README.md b/benches/README.md index fee254a18067b..1c1ce3b24b952 100644 --- a/benches/README.md +++ b/benches/README.md @@ -33,6 +33,46 @@ Build and install the benchmark runner: cargo build --release --bin foundry-bench ``` +### Run CI benchmark suites locally + +The canonical CI and nightly benchmark definitions live in +`benches/scripts/run-benchmark-suite.sh`. List the available profiles and suites +with: + +```bash +benches/scripts/run-benchmark-suite.sh --list +``` + +| Profile | Suites run by CI | +| --- | --- | +| `ci` | `test`, `isolate`, `build`, `coverage` | +| `nightly` | `test`, `fuzz`, `build`, `coverage`, `symbolic` for both stable and nightly | + +The `ci:symbolic` suite is also defined for local use but is currently disabled +in the regular workflow because the previous stable baseline does not support +symbolic execution. + +After building the runner, invoke a suite from the repository root. These are +the same definitions used by CI, including pinned repositories, exclusions, +benchmark IDs, installation behavior, and output filenames: + +```bash +# Run the regular CI test suite against a local Foundry build. +benches/scripts/run-benchmark-suite.sh ci test \ + --versions local \ + --output-dir ./benches + +# Run the stable side of the nightly symbolic suite. +benches/scripts/run-benchmark-suite.sh nightly symbolic \ + --versions stable \ + --output-dir ./benches +``` + +Use `--dry-run` before the profile name to print the exact argument vector +without running the benchmark. Pass `--repos "org/repo[:rev][ ]"` +to replace a suite's repository list, matching the manual benchmark workflow's +override. An empty `--repos` value uses the suite default. + To install `foundry-bench` to your PATH: ```bash @@ -67,6 +107,9 @@ foundry-bench --benchmarks forge_fuzz_test # Run coverage benchmark foundry-bench --benchmarks forge_coverage +# Run focused symbolic tests and report solver counters +foundry-bench --repos Vectorized/solady:v0.1.26,SorellaLabs/angstrom:73b55b8eca667b9a50fa4d8b6a7f45ec647420f5,farcasterxyz/contracts:3f37e21db8e9c6319b4a3d5f62b1c514ef01c36b --benchmarks forge_symbolic_test + # Combine options foundry-bench \ --versions stable,nightly \ @@ -83,36 +126,221 @@ foundry-bench --verbose foundry-bench --output-dir ./results --output-file LATEST_RESULTS.md ``` +`forge_symbolic_test` keeps the existing focused commands for Solady, Angstrom, and Farcaster +and uses the generic symbolic command for other repositories. +Use `--symbolic-sidecar-output ` with exactly one `--versions` value to capture the +versioned per-run results. Like `--json-output`, the sidecar path is relative to `--output-dir`. + +For filtered test discovery, use `--benchmarks forge_test_filtered` with test filters +in the repository's extra arguments or `foundry.toml`. This keeps the project's +dynamic-linking configuration and warms the selected tests twice. The first warmup +forces cleanup using the same root and configuration as the tests, then populates +normal artifacts. The second populates ABI discovery for that cache. Timed runs +retain that warm cache. +The result is labeled `Forge Test (Warm Cache)` because without effective filters +it measures ordinary unfiltered tests; it does not guarantee ABI-discovery coverage. +No unfiltered build runs during setup. For example: + +```bash +foundry-bench --versions local --benchmarks forge_test_filtered \ + --repos "aave/aave-v4:1e8de8630dfeb26ad309d986eaec44c1ceb48a6d --match-contract EngineFlagsTest --match-test test_toBool_zero_returnsFalse --threads 1" +``` + +## Branch vs master PR-body workflow + +Use this workflow when preparing performance numbers for a PR body. It keeps the +baseline and candidate builds isolated and makes the final table easy to audit. + +### Command timing with foundry-bench + +Use `foundry-bench` when the performance claim is about elapsed time for a +Foundry command on an existing Solidity repository. It answers questions like: + +- Did this branch make `forge build` faster or slower? +- Did cached rebuilds change? +- Did `forge test` or `forge test --isolate` change? +- Did fuzz-test execution time change? +- Did `forge coverage --ir-minimum` change? +- Did focused symbolic test wall time or solver counters change? + +Do not use `foundry-bench` for long-running invariant campaign quality, +throughput, corpus, showmap, or differential-coverage comparisons. Use +`foundry-scfuzzbench` for those. + +Pick `BENCHMARKS` from the changed path: + +| Changed path | Suggested `BENCHMARKS` | +| --- | --- | +| Compiler, artifact caching, project graph, dependency resolution | `forge_build_no_cache,forge_build_with_cache` | +| Test runner, EVM execution, cheatcodes, traces used by tests | `forge_test` | +| Isolation semantics or per-test state reset | `forge_isolate_test` | +| Fuzz runner execution overhead, fuzz fixtures, corpus replay behavior | `forge_fuzz_test` | +| Coverage instrumentation or report generation | `forge_coverage` | +| Symbolic executor or SMT solving | `forge_symbolic_test` | + +Pick `REPOS` so the external project actually exercises the changed path. Use a +single focused repo first, then add more only if the result would otherwise be +unconvincing. The workflow accepts the same repo syntax as normal +`foundry-bench`: `org/repo[:rev][ ]`. + +Examples: + +```bash +# Test-runner or EVM execution change. +BENCHMARKS=forge_test +REPOS="ithacaxyz/account:v0.5.7" + +# Build/cache change. +BENCHMARKS=forge_build_no_cache,forge_build_with_cache +REPOS="vectorized/solady:v0.1.26" + +# Coverage change. +BENCHMARKS=forge_coverage +REPOS="uniswap/v4-core:46c6834698c48bc4a463a86d8420f4eb1d7f3b75" + +# Symbolic change with focused counters. +BENCHMARKS=forge_symbolic_test +REPOS="Vectorized/solady:v0.1.26" +``` + +Run the matched branch-vs-base timing comparison: + +```bash +BASE_REF=origin/master \ +BENCHMARKS=forge_test \ +REPOS="ithacaxyz/account:v0.5.7,vectorized/solady:v0.1.26" \ +benches/scripts/pr-bench.sh +``` + +The script builds the `foundry-bench` harness from the current checkout once, +then points it at each checked-out workspace with +`FOUNDRY_BENCH_WORKSPACE_ROOT`. The `local` version builds and activates that +workspace with `FOUNDRY_BENCH_LOCAL_BUILD_PROFILE=profiling` before each run. +By default, the PR script sets `FOUNDRY_BENCH_LOCAL_BUILD_BINS=forge` so forge +benchmarks do not build unused targets. Set it to a comma- or whitespace- +separated list such as `forge,cast,anvil,chisel` when a run needs more binaries. +Keep `REPOS`, `BENCHMARKS`, and any per-repo extra arguments identical for +`master` and `candidate`. Override `CANDIDATE_REF`, `RUN_ID`, or `BENCH_ROOT` +when needed. + +For PR bodies, reduce the two JSON/Markdown outputs into a concise table: + +```md +### Results + +| Benchmark | master | this PR | delta | +| --- | ---: | ---: | ---: | +| `forge_test/ithacaxyz-account` wall time | ... | ... | ... | +``` + +Include domain counters next to wall time when the benchmark produces them, for +example symbolic solver queries, reported solver time, invariant throughput, or +coverage relscore/relcov. If the delta is within noise, describe it as neutral +or inconclusive. + +## Running scfuzzbench Campaigns + +`foundry-scfuzzbench` runs a local scfuzzbench Foundry campaign, invokes the scfuzzbench +analysis/reporting pipeline, and copies stable artifacts into `/artifacts` for review by +humans or LLMs. + +```bash +cargo run -p foundry-bench --bin foundry-scfuzzbench -- \ + --target-repo https://github.com/Recon-Fuzz/aave-v4-scfuzzbench.git \ + --target-ref v0.5.6-recon \ + --benchmark-type property \ + --timeout-seconds 60 \ + --workers 1 \ + --output-dir /tmp/foundry-scfuzzbench-aave \ + --foundry-bin "$(command -v forge)" +``` + +To compare a feature branch against `master` locally, run two matched campaigns: +one with a `forge` binary built from `master`, and one with a `forge` binary +built from the candidate branch. The runner intentionally records each campaign +separately; compare the resulting artifact bundles when drafting the PR body. + +```bash +TARGET_REPO=https://github.com/Recon-Fuzz/aave-v4-scfuzzbench.git \ +TARGET_REF=v0.5.6-recon \ +BENCHMARK_TYPE=property \ +TIMEOUT_SECONDS=3600 \ +WORKERS=1 \ +benches/scripts/pr-scfuzzbench.sh +``` + +Use the same `TARGET_REPO`, `TARGET_REF`, `BENCHMARK_TYPE`, +`TIMEOUT_SECONDS`, `WORKERS`, and `FOUNDRY_TEST_ARGS` for both runs. For +optimization campaigns, pass the same `PROPERTIES_PATH` to both runs. Summarize +the PR body from `llm_summary.md`, `REPORT.md`, `summary.csv`, +`cumulative.csv`, and the differential coverage artifacts in each output +directory. Override `BASE_REF`, `CANDIDATE_REF`, `RUN_ID`, or `BENCH_ROOT` when +needed. + +By default the runner pins `https://github.com/tempoxyz/scfuzzbench.git@main`. Override that with +`--scfuzzbench-repo` and `--scfuzzbench-ref` while the scfuzzbench changes are being upstreamed. +Use `--foundry-bin` to benchmark an existing `forge`, `--foundry-ref` to build and benchmark a +Foundry ref from `--foundry-repo` (default `https://github.com/foundry-rs/foundry.git`), or neither +to use `forge` from `PATH`. The runner uses an isolated `HOME` so a selected `--foundry-bin` is not +shadowed by `~/.foundry/bin`. `--foundry-bin` must point to a file named `forge`; the runner resolves +`command -v forge` under the same campaign environment, verifies it is the selected binary, and +records the canonical path in `manifest.json`. + +Optimization campaigns require `--properties-path`, which is passed to scfuzzbench as +`SCFUZZBENCH_PROPERTIES_PATH` and must be relative to the target repository. If GNU `timeout` or +GNU-style `sed -i` is unavailable, the runner installs local shims in the work directory and prepends +it to the campaign `PATH`. On platforms where `date -Is` is unavailable, it also installs a local +`date` shim for scfuzzbench log timestamps. If `local-run.sh` exits non-zero, the runner stops before +analysis so a failed setup or campaign cannot be reported as a successful artifact bundle. + +The artifact bundle exposes: + +- `REPORT.md` +- `events.csv`, `summary.csv`, `cumulative.csv` +- throughput/progress CSVs +- `showmap_campaign_manifest.json` and `showmap_campaigns/` +- `differential_coverage_relscores.csv` +- `differential_coverage_relcov.csv` +- runner resource and broken invariant reports +- optional `lcov-diff/` outputs when scfuzzbench produces coverage-diff files +- `llm_summary.md` and `manifest.json`, including the selected canonical `forge` path, optional + Foundry repo/ref metadata, and optional `properties_path` + #### Command-line Options - `--versions ` - Comma-separated list of Foundry versions (default: stable,nightly) -- `--repos ` - Comma-separated list of repos in org/repo[:rev] format (default: ithacaxyz/account:v0.3.2,Vectorized/solady:v0.1.22) +- `--repos ` - Comma-separated list of repos in org/repo[:rev] format - `--benchmarks ` - Comma-separated list of benchmarks to run - `--force-install` - Force installation of Foundry versions - `--verbose` - Show detailed benchmark output -- `--output-dir ` - Directory for output files (default: benches) -- `--output-file ` - Name of the output file (default: LATEST.md) +- `--output-dir ` - Directory for output files (default: current working directory) +- `--output-file ` - Name of the Markdown output file. Defaults to `LATEST.md` + unless `--json-output` is set, in which case Markdown is omitted unless explicitly requested +- `--common-json-output ` - Write results using the common benchmark schema in + `benches/schema/benchmark-result-v1.schema.json` +- `--symbolic-sidecar-output ` - Write the opt-in v1 symbolic samples sidecar (requires exactly one version) ## Benchmark Structure -- `forge_test` - Benchmarks `forge test` command across repos +- `forge_test` - Benchmarks non-isolated `forge test` command across repos - `forge_build_no_cache` - Benchmarks `forge build` with clean cache - `forge_build_with_cache` - Benchmarks `forge build` with existing cache -- `forge_fuzz_test` - Benchmarks `forge test` with only fuzz tests (tests with parameters) +- `forge_fuzz_test` - Benchmarks non-isolated `forge test` with only fuzz tests (tests with parameters) - `forge_coverage` - Benchmarks `forge coverage --ir-minimum` command across repos +- `forge_isolate_test` - Benchmarks isolated `forge test` command across repos +- `forge_symbolic_test` - Benchmarks `forge test --symbolic` for any repository, with focused recipes for Solady, Angstrom, and Farcaster. Compatibility JSON retains the median-wall-run counter projection; the optional sidecar records all five aligned samples, exact tests/statuses, and solver counters. ## Configuration -The benchmark binary uses command-line arguments to configure which repositories and versions to test. The default repositories are: - -- `ithacaxyz/account:v0.3.2` -- `Vectorized/solady:v0.1.22` - -You can override these using the `--repos` flag with the format `org/repo[:rev]`. +The benchmark binary uses command-line arguments to configure which repositories and versions to +test. Its generic defaults track the main branches of Account and Solady. Reproducible CI repository +pins and per-repository arguments are defined by `run-benchmark-suite.sh`; use `--list` to discover +those suites. You can override repositories using `--repos` with the format `org/repo[:rev]`. ## Results -Benchmark results are saved to `benches/LATEST.md` (or custom output file specified with `--output-file`). The report includes: +Benchmark results are saved to `/LATEST.md` unless a custom output file is specified or +Markdown output is suppressed by `--json-output`. The report includes: - Summary of versions and repositories tested - Performance comparison tables for each benchmark type showing: @@ -123,6 +351,21 @@ Benchmark results are saved to `benches/LATEST.md` (or custom output file specif - System information (OS, CPU cores) - Detailed hyperfine benchmark results in JSON format +## ClickHouse Ingestion + +Successful `master` versus branch runs from the Foundry Benchmarks workflow are ingested by +`.github/workflows/benchmarks-clickhouse.yml`. The ClickHouse schema and migrations are managed by +the benchmark infrastructure. Configure the `bench` GitHub environment with `CLICKHOUSE_HOST`, +`CLICKHOUSE_USER`, and `CLICKHOUSE_PASSWORD` secrets. The optional `CLICKHOUSE_DATABASE` and +`CLICKHOUSE_TABLE` variables default to `default` and `benchmark_results`. `CLICKHOUSE_HOST` may be +a bare host (using HTTPS on port 8443) or a full HTTPS endpoint. The ClickHouse user only needs +`INSERT` access to the destination table. + +The ingester sends one `JSONEachRow` record per workflow attempt, comparison side, benchmark case, +and immutable workload commit. The versioned record includes raw timings, extensible counters, +source and workload provenance, runner metadata, and a deterministic `result_id`; infrastructure +owners use that contract to provision storage and release/trend views. + ## Troubleshooting 1. **Foundry version not found**: Use `--force-install` flag or manually install with `foundryup --install ` diff --git a/benches/fixtures/gas-report/foundry.toml b/benches/fixtures/gas-report/foundry.toml new file mode 100644 index 0000000000000..f605f8ed9958c --- /dev/null +++ b/benches/fixtures/gas-report/foundry.toml @@ -0,0 +1,10 @@ +[profile.default] +solc = "0.8.19" +evm_version = "paris" +optimizer = true +optimizer_runs = 200 +dynamic_test_linking = false +isolate = false +gas_limit = 1000000000 +[lint] +lint_on_build = false diff --git a/benches/fixtures/gas-report/test/Payload.t.sol b/benches/fixtures/gas-report/test/Payload.t.sol new file mode 100644 index 0000000000000..967c8d0385c67 --- /dev/null +++ b/benches/fixtures/gas-report/test/Payload.t.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +contract Payload { + function roundTrip(uint256[] calldata) external pure returns (uint256[] memory) { + assembly { + let n := sub(calldatasize(), 4) + calldatacopy(0, 4, n) + return(0, n) + } + } +} + +contract PayloadTest { + Payload payload; + + function setUp() public { + payload = new Payload(); + } + + function testPayload() public view { + address target = address(payload); + uint256 selector = uint32(Payload.roundTrip.selector); + assembly { + let p := mload(0x40) + mstore(p, shl(224, selector)) + mstore(add(p, 4), 32) + mstore(add(p, 36), 8192) + let end := add(p, add(68, mul(8192, 32))) + for { let q := add(p, 68) } lt(q, end) { q := add(q, 32) } { + mstore(q, not(0)) + } + for { let i := 0 } lt(i, 128) { i := add(i, 1) } { + if iszero(staticcall(gas(), target, p, sub(end, p), 0, 0)) { revert(0, 0) } + if iszero(eq(returndatasize(), sub(sub(end, p), 4))) { revert(0, 0) } + } + } + } +} diff --git a/benches/fixtures/symbolic/FarcasterNativeSymbolic.t.sol b/benches/fixtures/symbolic/FarcasterNativeSymbolic.t.sol new file mode 100644 index 0000000000000..9b88f72bfdf57 --- /dev/null +++ b/benches/fixtures/symbolic/FarcasterNativeSymbolic.t.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.21; + +import {Test} from "forge-std/Test.sol"; +import {Migration} from "../src/abstract/Migration.sol"; + +contract MigrationHarness is Migration { + constructor(uint24 gracePeriod, address migrator, address initialOwner) + Migration(gracePeriod, migrator, initialOwner) + {} +} + +contract FarcasterNativeSymbolicTest is Test { + function check_migrateOnlyMigrator( + uint24 gracePeriod, + address migrator, + address initialOwner, + address caller, + uint40 timestamp + ) public { + vm.assume(timestamp != 0); + + MigrationHarness migration = new MigrationHarness(gracePeriod, migrator, initialOwner); + + vm.warp(timestamp); + vm.prank(caller); + (bool success,) = address(migration).call(abi.encodeCall(Migration.migrate, ())); + + assert(success == (caller == migrator)); + if (success) { + assert(migration.isMigrated()); + assert(migration.migratedAt() == timestamp); + } + } + + function check_setMigratorOnlyOwner( + uint24 gracePeriod, + address migrator, + address initialOwner, + address caller, + address nextMigrator + ) public { + MigrationHarness migration = new MigrationHarness(gracePeriod, migrator, initialOwner); + + vm.prank(caller); + (bool success,) = + address(migration).call(abi.encodeCall(Migration.setMigrator, (nextMigrator))); + + assert(success == (caller == initialOwner)); + if (success) { + assert(migration.migrator() == nextMigrator); + } else { + assert(migration.migrator() == migrator); + } + } +} diff --git a/benches/schema/README.md b/benches/schema/README.md new file mode 100644 index 0000000000000..af7329849148d --- /dev/null +++ b/benches/schema/README.md @@ -0,0 +1,13 @@ +# Benchmark result schema + +`benchmark-result-v1.schema.json` defines a common benchmark result format. + +Each document identifies the repository and commit being measured, optional pull request metadata, the runner, and one or more named benchmarks. Each benchmark has wall time and may include memory, custom counters, gas, solver, and compiler metrics. + +Every metric records a numeric `value`, its `unit`, and the `statistic` represented by the value. Unknown or unmeasured fields are omitted rather than set to `null` or zero. + +Recommended units are `second` for wall time, `byte` for memory and sizes, `gas` for gas, and `count` for counters. Metric names within each group should be stable snake_case identifiers. + +`memory` represents peak resident memory for the benchmarked process tree. + +Consumers should vendor the schema at a pinned Foundry commit so validation does not depend on network access. Incompatible changes require a new schema version. diff --git a/benches/schema/benchmark-result-v1.schema.json b/benches/schema/benchmark-result-v1.schema.json new file mode 100644 index 0000000000000..69ce6eb1ad6a3 --- /dev/null +++ b/benches/schema/benchmark-result-v1.schema.json @@ -0,0 +1,69 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/foundry-rs/foundry/benches/schema/benchmark-result-v1.schema.json", + "title": "Benchmark result v1", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "repo", "commit", "runner", "benchmarks"], + "properties": { + "schema_version": { "const": 1 }, + "repo": { "$ref": "#/$defs/nonEmptyString" }, + "commit": { "$ref": "#/$defs/nonEmptyString" }, + "pr": { "type": "integer", "minimum": 1 }, + "runner": { "$ref": "#/$defs/runner" }, + "benchmarks": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/benchmark" } + } + }, + "$defs": { + "nonEmptyString": { + "type": "string", + "minLength": 1 + }, + "runner": { + "type": "object", + "additionalProperties": false, + "required": ["os", "arch"], + "properties": { + "os": { "$ref": "#/$defs/nonEmptyString" }, + "arch": { "$ref": "#/$defs/nonEmptyString" }, + "image": { "$ref": "#/$defs/nonEmptyString" }, + "cpu": { "$ref": "#/$defs/nonEmptyString" }, + "logical_cpus": { "type": "integer", "minimum": 1 }, + "total_memory_bytes": { "type": "integer", "minimum": 1 } + } + }, + "benchmark": { + "type": "object", + "additionalProperties": false, + "required": ["name", "wall_time"], + "properties": { + "name": { "$ref": "#/$defs/nonEmptyString" }, + "wall_time": { "$ref": "#/$defs/metric" }, + "memory": { "$ref": "#/$defs/metric" }, + "counters": { "$ref": "#/$defs/metricMap" }, + "gas": { "$ref": "#/$defs/metricMap" }, + "solver": { "$ref": "#/$defs/metricMap" }, + "compiler": { "$ref": "#/$defs/metricMap" } + } + }, + "metricMap": { + "type": "object", + "minProperties": 1, + "propertyNames": { "pattern": "^[a-z][a-z0-9_]*$" }, + "additionalProperties": { "$ref": "#/$defs/metric" } + }, + "metric": { + "type": "object", + "additionalProperties": false, + "required": ["value", "unit", "statistic"], + "properties": { + "value": { "type": "number" }, + "unit": { "$ref": "#/$defs/nonEmptyString" }, + "statistic": { "enum": ["mean", "median", "min", "max", "total", "point"] } + } + } + } +} diff --git a/benches/scripts/pr-bench.sh b/benches/scripts/pr-bench.sh new file mode 100755 index 0000000000000..b8b16fc6299e9 --- /dev/null +++ b/benches/scripts/pr-bench.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_REF="${BASE_REF:-origin/master}" +CANDIDATE_REF="${CANDIDATE_REF:-HEAD}" +BENCHMARKS="${BENCHMARKS:-forge_test}" +REPOS="${REPOS:-ithacaxyz/account:v0.5.7}" +RUN_ID="${RUN_ID:-$(date -u +%Y%m%d%H%M%S)}" +BENCH_ROOT="${BENCH_ROOT:-/tmp/foundry-pr-bench-${RUN_ID}}" +FOUNDRY_BENCH_LOCAL_BUILD_BINS="${FOUNDRY_BENCH_LOCAL_BUILD_BINS:-forge}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +cd "${REPO_ROOT}" + +if [[ "${BENCH_ROOT}" != /* ]]; then + BENCH_ROOT="${REPO_ROOT}/${BENCH_ROOT}" +fi +RUNNER_TARGET_DIR="${BENCH_ROOT}/runner-target" +RUNNER_BIN="${RUNNER_TARGET_DIR}/release/foundry-bench" + +if [[ -n "$(git status --porcelain)" ]]; then + printf 'error: working directory is dirty; commit or stash changes before benchmarking\n' >&2 + exit 1 +fi + +git fetch origin master + +mkdir -p "${BENCH_ROOT}/results" +git worktree add --detach "${BENCH_ROOT}/master" "${BASE_REF}" +git worktree add --detach "${BENCH_ROOT}/candidate" "${CANDIDATE_REF}" + +CARGO_TARGET_DIR="${RUNNER_TARGET_DIR}" cargo build --locked --release --bin foundry-bench + +for label in master candidate; do + ( + unset CARGO_TARGET_DIR + foundry_dir="${BENCH_ROOT}/${label}/.foundry" + FOUNDRY_BENCH_WORKSPACE_ROOT="${BENCH_ROOT}/${label}" \ + FOUNDRY_BENCH_LOCAL_BUILD_PROFILE=profiling \ + FOUNDRY_BENCH_LOCAL_BUILD_BINS="${FOUNDRY_BENCH_LOCAL_BUILD_BINS}" \ + FOUNDRY_DIR="${foundry_dir}" \ + PATH="${foundry_dir}/bin:${PATH}" \ + "${RUNNER_BIN}" \ + --versions local \ + --repos "${REPOS}" \ + --benchmarks "${BENCHMARKS}" \ + --output-dir "${BENCH_ROOT}/results" \ + --output-file "${label}.md" \ + --json-output "${label}.json" \ + --verbose + ) +done + +printf 'Benchmark results written to %s/results\n' "${BENCH_ROOT}" diff --git a/benches/scripts/pr-scfuzzbench.sh b/benches/scripts/pr-scfuzzbench.sh new file mode 100755 index 0000000000000..b6f3036fb0d1f --- /dev/null +++ b/benches/scripts/pr-scfuzzbench.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_REF="${BASE_REF:-origin/master}" +CANDIDATE_REF="${CANDIDATE_REF:-HEAD}" +TARGET_REPO="${TARGET_REPO:-https://github.com/Recon-Fuzz/aave-v4-scfuzzbench.git}" +TARGET_REF="${TARGET_REF:-v0.5.6-recon}" +BENCHMARK_TYPE="${BENCHMARK_TYPE:-property}" +TIMEOUT_SECONDS="${TIMEOUT_SECONDS:-3600}" +WORKERS="${WORKERS:-1}" +FOUNDRY_TEST_ARGS="${FOUNDRY_TEST_ARGS:-}" +PROPERTIES_PATH="${PROPERTIES_PATH:-}" +RUN_ID="${RUN_ID:-$(date -u +%Y%m%d%H%M%S)}" +BENCH_ROOT="${BENCH_ROOT:-/tmp/foundry-scfuzzbench-${RUN_ID}}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +cd "${REPO_ROOT}" + +if [[ "${BENCH_ROOT}" != /* ]]; then + BENCH_ROOT="${REPO_ROOT}/${BENCH_ROOT}" +fi +RUNNER_TARGET_DIR="${BENCH_ROOT}/runner-target" +RUNNER_BIN="${RUNNER_TARGET_DIR}/release/foundry-scfuzzbench" + +if [[ -n "$(git status --porcelain)" ]]; then + printf 'error: working directory is dirty; commit or stash changes before benchmarking\n' >&2 + exit 1 +fi + +git fetch origin master + +mkdir -p "${BENCH_ROOT}" +git worktree add --detach "${BENCH_ROOT}/master" "${BASE_REF}" +git worktree add --detach "${BENCH_ROOT}/candidate" "${CANDIDATE_REF}" + +( + cd "${REPO_ROOT}" + CARGO_TARGET_DIR="${RUNNER_TARGET_DIR}" cargo build --locked --release --bin foundry-scfuzzbench +) + +for label in master candidate; do + foundry_target_dir="${BENCH_ROOT}/${label}-target" + + ( + cd "${BENCH_ROOT}/${label}" + CARGO_TARGET_DIR="${foundry_target_dir}" cargo build --locked --profile profiling --bin forge + ) + + args=( + --target-repo "${TARGET_REPO}" + --target-ref "${TARGET_REF}" + --benchmark-type "${BENCHMARK_TYPE}" + --timeout-seconds "${TIMEOUT_SECONDS}" + --workers "${WORKERS}" + --output-dir "${BENCH_ROOT}/${label}-artifacts" + --foundry-bin "${foundry_target_dir}/profiling/forge" + ) + + if [[ -n "${FOUNDRY_TEST_ARGS}" ]]; then + args+=(--foundry-test-args "${FOUNDRY_TEST_ARGS}") + fi + + if [[ -n "${PROPERTIES_PATH}" ]]; then + args+=(--properties-path "${PROPERTIES_PATH}") + fi + + "${RUNNER_BIN}" "${args[@]}" +done + +printf 'scfuzzbench artifacts written under %s\n' "${BENCH_ROOT}" diff --git a/benches/scripts/run-benchmark-suite.sh b/benches/scripts/run-benchmark-suite.sh new file mode 100755 index 0000000000000..bf9ceaa1e85d3 --- /dev/null +++ b/benches/scripts/run-benchmark-suite.sh @@ -0,0 +1,232 @@ +#!/usr/bin/env bash +set -euo pipefail + +join_repositories() { + local IFS=, + printf '%s' "$*" +} + +CI_TEST_REPOSITORIES=$(join_repositories \ + "ithacaxyz/account:v0.5.7" \ + "vectorized/solady:v0.1.26 --nmc 'LifebuoyTest|LibBitTest|Base58Test|LibStringTest'" \ + "uniswap/v4-core:46c6834698c48bc4a463a86d8420f4eb1d7f3b75 --nmc 'TickMathTestTest'" \ + "sparkdotfi/spark-psm:v1.0.0 --nmc PSMInvariants_TimeBasedRateSetting_WithTransfers_WithPocketSetting") + +CI_ISOLATE_REPOSITORIES=$(join_repositories \ + "ithacaxyz/account:v0.5.7 --nmc SimulateExecuteTest" \ + "vectorized/solady:v0.1.26 --nmc 'SafeTransferLibTest|LifebuoyTest|LibBitTest|Base58Test|LibStringTest'" \ + "uniswap/v4-core:46c6834698c48bc4a463a86d8420f4eb1d7f3b75 --nmc 'TickMathTestTest'" \ + "sparkdotfi/spark-psm:v1.0.0 --nmc PSMInvariants_TimeBasedRateSetting_WithTransfers_WithPocketSetting") + +CI_BUILD_REPOSITORIES=$(join_repositories \ + "ithacaxyz/account:v0.5.7" \ + "vectorized/solady:v0.1.26" \ + "uniswap/v4-core:46c6834698c48bc4a463a86d8420f4eb1d7f3b75" \ + "sparkdotfi/spark-psm:v1.0.0") + +CI_COVERAGE_REPOSITORIES=$(join_repositories \ + "ithacaxyz/account:v0.5.7 --nmc SimulateExecuteTest" \ + "uniswap/v4-core:46c6834698c48bc4a463a86d8420f4eb1d7f3b75" \ + "sparkdotfi/spark-psm:v1.0.0 --nmc PSMInvariants_TimeBasedRateSetting_WithTransfers_WithPocketSetting") + +SYMBOLIC_REPOSITORIES=$(join_repositories \ + "Vectorized/solady:v0.1.26" \ + "SorellaLabs/angstrom:73b55b8eca667b9a50fa4d8b6a7f45ec647420f5" \ + "farcasterxyz/contracts:3f37e21db8e9c6319b4a3d5f62b1c514ef01c36b") + +NIGHTLY_REPOSITORIES="aave/aave-v4:1e8de8630dfeb26ad309d986eaec44c1ceb48a6d" +# The pinned Aave revision has three malformed expected revert payloads that exact matching rejects. +NIGHTLY_TEST_REPOSITORIES="${NIGHTLY_REPOSITORIES} --nmt 'test_(updateUser(RiskPremium|DynamicConfig)WithSig_revertsWith_SpokeNotRegistered|repayWithSig_revertsWith_ERC20InsufficientAllowance)'" + +SUITE_PROFILES=() +SUITE_NAMES=() +SUITE_BENCHMARKS=() +SUITE_REPOSITORIES=() +SUITE_MARKDOWN_OUTPUTS=() +SUITE_JSON_OUTPUTS=() +SUITE_FORCE_INSTALLS=() + +define_suite() { + SUITE_PROFILES+=("$1") + SUITE_NAMES+=("$2") + SUITE_BENCHMARKS+=("$3") + SUITE_REPOSITORIES+=("$4") + SUITE_MARKDOWN_OUTPUTS+=("$5") + SUITE_JSON_OUTPUTS+=("$6") + SUITE_FORCE_INSTALLS+=("$7") +} + +# Regular CI suites. Symbolic is defined for local use but currently disabled in the workflow. +define_suite ci test \ + "forge_test,forge_fuzz_test" "${CI_TEST_REPOSITORIES}" \ + "forge_test_bench.md" "forge_test_bench.json" true +define_suite ci isolate \ + "forge_isolate_test" "${CI_ISOLATE_REPOSITORIES}" \ + "forge_isolate_test_bench.md" "forge_isolate_test_bench.json" false +define_suite ci build \ + "forge_build_no_cache,forge_build_with_cache" "${CI_BUILD_REPOSITORIES}" \ + "forge_build_bench.md" "forge_build_bench.json" false +define_suite ci coverage \ + "forge_coverage" "${CI_COVERAGE_REPOSITORIES}" \ + "forge_coverage_bench.md" "forge_coverage_bench.json" false +define_suite ci symbolic \ + "forge_symbolic_test" "${SYMBOLIC_REPOSITORIES}" \ + "forge_symbolic_bench.md" "" false + +# Nightly suites run once for stable and once for nightly. +define_suite nightly test \ + "forge_test" "${NIGHTLY_TEST_REPOSITORIES}" \ + "" "{version}-{date}-forge_test.json" true +define_suite nightly fuzz \ + "forge_fuzz_test" "${NIGHTLY_TEST_REPOSITORIES}" \ + "" "{version}-{date}-forge_fuzz_test.json" false +define_suite nightly build \ + "forge_build_no_cache,forge_build_with_cache" "${NIGHTLY_REPOSITORIES}" \ + "" "{version}-{date}-forge_build.json" false +define_suite nightly coverage \ + "forge_coverage" "${NIGHTLY_TEST_REPOSITORIES}" \ + "" "{version}-{date}-forge_coverage.json" false +define_suite nightly symbolic \ + "forge_symbolic_test" "${SYMBOLIC_REPOSITORIES}" \ + "" "{version}-{date}-forge_symbolic.json" false + +usage() { + cat <<'EOF' +Usage: + benches/scripts/run-benchmark-suite.sh [--dry-run] \ + --versions --output-dir [--repos ] + benches/scripts/run-benchmark-suite.sh --list +EOF +} + +list_suites() { + local index + for ((index = 0; index < ${#SUITE_PROFILES[@]}; index++)); do + printf '%s:%s\n' "${SUITE_PROFILES[index]}" "${SUITE_NAMES[index]}" + printf ' benchmarks: %s\n' "${SUITE_BENCHMARKS[index]}" + printf ' repositories: %s\n' "${SUITE_REPOSITORIES[index]}" + done +} + +dry_run=false +if [[ ${1:-} == "--list" ]]; then + list_suites + exit 0 +fi +if [[ ${1:-} == "--dry-run" ]]; then + dry_run=true + shift +fi +if [[ $# -lt 2 ]]; then + usage >&2 + exit 2 +fi + +requested_profile=$1 +requested_suite=$2 +shift 2 + +versions= +output_dir= +repositories_override= +while [[ $# -gt 0 ]]; do + case $1 in + --versions) + [[ $# -ge 2 ]] || { usage >&2; exit 2; } + versions=$2 + shift 2 + ;; + --output-dir) + [[ $# -ge 2 ]] || { usage >&2; exit 2; } + output_dir=$2 + shift 2 + ;; + --repos) + [[ $# -ge 2 ]] || { usage >&2; exit 2; } + repositories_override=$2 + shift 2 + ;; + *) + printf 'error: unknown argument: %s\n' "$1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -z ${versions} || -z ${output_dir} ]]; then + printf 'error: --versions and --output-dir are required\n' >&2 + usage >&2 + exit 2 +fi + +found=false +benchmarks= +repositories= +markdown_output= +json_output= +force_install=false +for ((index = 0; index < ${#SUITE_PROFILES[@]}; index++)); do + if [[ ${SUITE_PROFILES[index]} == "${requested_profile}" && ${SUITE_NAMES[index]} == "${requested_suite}" ]]; then + benchmarks=${SUITE_BENCHMARKS[index]} + repositories=${SUITE_REPOSITORIES[index]} + markdown_output=${SUITE_MARKDOWN_OUTPUTS[index]} + json_output=${SUITE_JSON_OUTPUTS[index]} + force_install=${SUITE_FORCE_INSTALLS[index]} + found=true + break + fi +done + +if [[ ${found} != true ]]; then + printf 'error: unknown benchmark suite: %s:%s\n' "${requested_profile}" "${requested_suite}" >&2 + list_suites >&2 + exit 2 +fi + +if [[ -n ${repositories_override} ]]; then + repositories=${repositories_override} +fi + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +repo_root=$(cd "${script_dir}/../.." && pwd) +runner=${repo_root}/target/release/foundry-bench + +args=("${runner}" --output-dir "${output_dir}") +if [[ ${force_install} == true ]]; then + args+=(--force-install) +fi +args+=( + --versions "${versions}" + --repos "${repositories}" + --benchmarks "${benchmarks}" +) +if [[ -n ${markdown_output} ]]; then + args+=(--output-file "${markdown_output}") +fi +date=$(date -u +%Y-%m-%d) +if [[ -n ${json_output} ]]; then + json_output=${json_output//\{version\}/${versions}} + json_output=${json_output//\{date\}/${date}} + args+=(--json-output "${json_output}") + common_json_output=${json_output} +else + common_json_output="${requested_profile}-${requested_suite}-${versions}-${date}.json" +fi +args+=(--common-json-output "common/${common_json_output}") +if [[ -n ${json_output} && ${requested_profile} == ci ]]; then + manifest_output=${json_output%.*}-manifest.json + args+=( + --manifest-output "${manifest_output}" + --suite "${requested_profile}:${requested_suite}" + ) +fi +args+=(--verbose) + +if [[ ${dry_run} == true ]]; then + printf 'argv:\n' + printf ' %q\n' "${args[@]}" + exit 0 +fi + +"${args[@]}" diff --git a/benches/src/lib.rs b/benches/src/lib.rs index aa9c6ad318c54..0882566a53da1 100644 --- a/benches/src/lib.rs +++ b/benches/src/lib.rs @@ -1,18 +1,25 @@ -use crate::results::{HyperfineOutput, HyperfineResult}; +//! Foundry benchmark runner. + +use crate::{ + results::{HyperfineOutput, HyperfineResult}, + symbolic::{Fixture, Overlay, Sample, Sidecar}, +}; use eyre::{Result, WrapErr}; use foundry_common::{sh_eprintln, sh_println}; use foundry_compilers::project_util::TempProject; use foundry_test_utils::util::clone_remote; use once_cell::sync::Lazy; -use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; use std::{ - env, + collections::HashSet, + env, fs, path::{Path, PathBuf}, process::Command, str::FromStr, + time::Instant, }; pub mod results; +pub mod symbolic; /// Default number of runs for benchmarks pub const RUNS: u32 = 5; @@ -24,46 +31,53 @@ pub struct RepoConfig { pub org: String, pub repo: String, pub rev: String, + /// Optional extra arguments appended to every benchmark command for this + /// repo (e.g. `--nmc BrokenTest` to skip a broken test contract). + pub extra_args: Option, } impl FromStr for RepoConfig { type Err = eyre::Error; + /// Parse a repo spec of the form `org/repo[:rev][ ]`. + /// + /// Anything after the first whitespace is treated as extra arguments + /// appended to every benchmark command for this repo. fn from_str(spec: &str) -> Result { - // Split by ':' first to separate repo path from optional rev - let parts: Vec<&str> = spec.splitn(2, ':').collect(); - let repo_path = parts[0]; - let custom_rev = parts.get(1).copied(); - - // Now split the repo path by '/' - let path_parts: Vec<&str> = repo_path.split('/').collect(); - if path_parts.len() != 2 { - eyre::bail!("Invalid repo format '{}'. Expected 'org/repo' or 'org/repo:rev'", spec); - } - - let org = path_parts[0]; - let repo = path_parts[1]; + let spec = spec.trim(); + // Anything after the first whitespace is per-repo extra args. + let (head, extra_args) = match spec.split_once(char::is_whitespace) { + Some((head, rest)) => (head, Some(rest.trim().to_string())), + None => (spec, None), + }; - // Try to find this repo in BENCHMARK_REPOS to get the full config - let existing_config = BENCHMARK_REPOS.iter().find(|r| r.org == org && r.repo == repo); + let (repo_path, custom_rev) = match head.split_once(':') { + Some((path, rev)) => (path, Some(rev)), + None => (head, None), + }; - let config = if let Some(existing) = existing_config { - // Use existing config but allow custom rev to override - let mut config = existing.clone(); - if let Some(rev) = custom_rev { - config.rev = rev.to_string(); - } - config - } else { - // Create new config with custom rev or default - // Name should follow the format: org-repo (with hyphen) - RepoConfig { + let (org, repo) = repo_path.split_once('/').ok_or_else(|| { + eyre::eyre!("Invalid repo format '{spec}'. Expected 'org/repo' or 'org/repo:rev'") + })?; + + // Inherit defaults from BENCHMARK_REPOS when available, otherwise build + // a fresh config. Custom rev / extra args always override. + let mut config = BENCHMARK_REPOS + .iter() + .find(|r| r.org == org && r.repo == repo) + .cloned() + .unwrap_or_else(|| Self { name: format!("{org}-{repo}"), org: org.to_string(), repo: repo.to_string(), - rev: custom_rev.unwrap_or("main").to_string(), - } - }; + rev: "main".to_string(), + extra_args: None, + }); + + if let Some(rev) = custom_rev { + config.rev = rev.to_string(); + } + config.extra_args = extra_args; let _ = sh_println!("Parsed repo spec '{spec}' -> {config:?}"); Ok(config) @@ -78,12 +92,14 @@ pub fn default_benchmark_repos() -> Vec { org: "ithacaxyz".to_string(), repo: "account".to_string(), rev: "main".to_string(), + extra_args: None, }, RepoConfig { name: "solady".to_string(), org: "Vectorized".to_string(), repo: "solady".to_string(), rev: "main".to_string(), + extra_args: None, }, ] } @@ -113,6 +129,11 @@ pub struct BenchmarkProject { pub name: String, pub temp_project: TempProject, pub root_path: PathBuf, + /// Optional extra arguments appended to every benchmark command. + pub extra_args: Option, + pub org: String, + pub repo: String, + pub revision: String, } impl BenchmarkProject { @@ -139,7 +160,7 @@ impl BenchmarkProject { // Clone the repository let repo_url = format!("https://github.com/{}/{}.git", config.org, config.repo); - clone_remote(&repo_url, root); + clone_remote(&repo_url, root, true); // Checkout specific revision if provided if !config.rev.is_empty() && config.rev != "main" && config.rev != "master" { @@ -159,7 +180,32 @@ impl BenchmarkProject { Self::install_npm_dependencies(&root_path)?; sh_println!(" ✅ Project {} setup complete at {}", config.name, root); - Ok(BenchmarkProject { name: config.name.to_string(), root_path, temp_project }) + let revision = String::from_utf8( + Command::new("git") + .current_dir(&root_path) + .args(["rev-parse", "HEAD"]) + .output()? + .stdout, + )? + .trim() + .to_string(); + Ok(Self { + name: config.name.clone(), + root_path, + temp_project, + extra_args: config.extra_args.clone(), + org: config.org.clone(), + repo: config.repo.clone(), + revision, + }) + } + + /// Append `self.extra_args` to a benchmark shell command, if any. + fn cmd(&self, base: &str) -> String { + match self.extra_args.as_deref().map(str::trim).filter(|s| !s.is_empty()) { + Some(extra) => format!("{base} {extra}"), + None => base.to_string(), + } } /// Install npm dependencies if package.json exists @@ -175,13 +221,13 @@ impl BenchmarkProject { .status() .wrap_err("Failed to run npm install")?; - if !status.success() { + if status.success() { + sh_println!(" ✅ npm install completed successfully"); + } else { sh_println!( " ⚠️ Warning: npm install failed with exit code: {:?}", status.code() ); - } else { - sh_println!(" ✅ npm install completed successfully"); } } Ok(()) @@ -195,12 +241,14 @@ impl BenchmarkProject { /// * `command` - The command to benchmark /// * `runs` - Number of runs to perform /// * `setup` - Optional setup command to run before the benchmark series (e.g., "forge build") + /// * `prepare` - Optional prepare command to run before each timing run (e.g., "forge clean") /// * `conclude` - Optional conclude command to run after each timing run (e.g., cleanup) /// * `verbose` - Whether to show command output /// /// # Hyperfine flags used: /// * `--runs` - Number of timing runs /// * `--setup` - Execute before the benchmark series (not before each run) + /// * `--prepare` - Execute before each timing run /// * `--conclude` - Execute after each timing run /// * `--export-json` - Export results to JSON for parsing /// * `--shell=bash` - Use bash for shell command execution @@ -213,6 +261,7 @@ impl BenchmarkProject { command: &str, runs: u32, setup: Option<&str>, + prepare: Option<&str>, conclude: Option<&str>, verbose: bool, ) -> Result { @@ -240,6 +289,11 @@ impl BenchmarkProject { hyperfine_cmd.arg("--setup").arg(setup_cmd); } + // Add optional prepare command + if let Some(prepare_cmd) = prepare { + hyperfine_cmd.arg("--prepare").arg(prepare_cmd); + } + // Add optional conclude command if let Some(conclude_cmd) = conclude { hyperfine_cmd.arg("--conclude").arg(conclude_cmd); @@ -267,7 +321,7 @@ impl BenchmarkProject { output.results.into_iter().next().ok_or_else(|| eyre::eyre!("No results from hyperfine")) } - /// Benchmark forge test + /// Benchmark forge test without isolation. pub fn bench_forge_test( &self, version: &str, @@ -278,9 +332,36 @@ impl BenchmarkProject { self.hyperfine( "forge_test", version, - "forge test", + &self.cmd("FOUNDRY_DYNAMIC_TEST_LINKING=false FOUNDRY_ISOLATE=false forge test"), + runs, + Some("FOUNDRY_DYNAMIC_TEST_LINKING=false FOUNDRY_ISOLATE=false forge build"), + None, + None, + verbose, + ) + } + + /// Benchmarks tests after cleaning once and warming the selected workload. + /// + /// Keeps the project's dynamic linking configuration. Effective CLI or configuration + /// filters are needed to exercise partial-cache discovery. + pub fn bench_forge_test_filtered( + &self, + version: &str, + runs: u32, + verbose: bool, + ) -> Result { + let command = self.cmd("forge test"); + // Force cleanup in the first warmup using the same root/configuration as the tests. + // The second invocation warms discovery against the resulting normal artifacts. + let setup = format!("FOUNDRY_FORCE=true {command} && {command}"); + self.hyperfine( + "forge_test_filtered", + version, + &command, runs, - Some("forge build"), + Some(&setup), + None, None, verbose, ) @@ -293,8 +374,18 @@ impl BenchmarkProject { runs: u32, verbose: bool, ) -> Result { - // No setup needed, uses existing cache - self.hyperfine("forge_build_with_cache", version, "forge build", runs, None, None, verbose) + self.hyperfine( + "forge_build_with_cache", + version, + &self.cmd( + "FOUNDRY_DYNAMIC_TEST_LINKING=false FOUNDRY_LINT_LINT_ON_BUILD=false FOUNDRY_ISOLATE=false forge build", + ), + runs, + None, + Some("FOUNDRY_DYNAMIC_TEST_LINKING=false FOUNDRY_ISOLATE=false forge build"), + None, + verbose, + ) } /// Benchmark forge build without cache @@ -304,19 +395,22 @@ impl BenchmarkProject { runs: u32, verbose: bool, ) -> Result { - // Clean before the benchmark series + // Clean before each timing run self.hyperfine( "forge_build_no_cache", version, - "forge build", + &self.cmd( + "FOUNDRY_DYNAMIC_TEST_LINKING=false FOUNDRY_LINT_LINT_ON_BUILD=false FOUNDRY_ISOLATE=false forge build", + ), runs, - Some("forge clean"), + Some("FOUNDRY_DYNAMIC_TEST_LINKING=false FOUNDRY_ISOLATE=false forge clean"), None, + Some("FOUNDRY_DYNAMIC_TEST_LINKING=false FOUNDRY_ISOLATE=false forge clean"), verbose, ) } - /// Benchmark forge fuzz tests + /// Benchmark forge fuzz tests without isolation. pub fn bench_forge_fuzz_test( &self, version: &str, @@ -327,9 +421,12 @@ impl BenchmarkProject { self.hyperfine( "forge_fuzz_test", version, - r#"forge test --match-test "test[^(]*\([^)]+\)""#, + &self.cmd( + r#"FOUNDRY_DYNAMIC_TEST_LINKING=false FOUNDRY_ISOLATE=false forge test --match-test "test[^(]*\([^)]+\)""#, + ), runs, - Some("forge build"), + Some("FOUNDRY_DYNAMIC_TEST_LINKING=false FOUNDRY_ISOLATE=false forge build"), + None, None, verbose, ) @@ -342,19 +439,147 @@ impl BenchmarkProject { runs: u32, verbose: bool, ) -> Result { - // No setup needed, forge coverage builds internally - // Use --ir-minimum to avoid "Stack too deep" errors + // No setup needed, forge coverage builds internally. self.hyperfine( "forge_coverage", version, - "forge coverage --ir-minimum", + &self.cmd("FOUNDRY_DYNAMIC_TEST_LINKING=false FOUNDRY_ISOLATE=false forge coverage"), + runs, + None, + None, + None, + verbose, + ) + } + + /// Benchmark forge test with isolate mode + pub fn bench_forge_isolate_test( + &self, + version: &str, + runs: u32, + verbose: bool, + ) -> Result { + // Build before running tests + self.hyperfine( + "forge_isolate_test", + version, + &self.cmd("FOUNDRY_DYNAMIC_TEST_LINKING=false FOUNDRY_ISOLATE=true forge test"), runs, + Some("FOUNDRY_DYNAMIC_TEST_LINKING=false FOUNDRY_ISOLATE=true forge build"), None, None, verbose, ) } + /// Benchmark focused symbolic checks and collect symbolic solver counters. + pub fn bench_forge_symbolic_test( + &self, + _version: &str, + runs: u32, + verbose: bool, + ) -> Result { + let fixture = Fixture::identify(&self.org, &self.repo); + let command = self.cmd(&fixture.test_command()); + let build_command = fixture.build_command(); + let overlay = Overlay::install(&self.root_path, fixture)?; + let benchmark = (|| -> Result { + let status = Command::new("bash") + .current_dir(&self.root_path) + .args(["-lc", &build_command]) + .status() + .wrap_err("Failed to build project before symbolic benchmark")?; + if !status.success() { + eyre::bail!( + "forge build failed before symbolic benchmark with command: {}", + build_command + ); + } + + let mut times = Vec::with_capacity(runs as usize); + let mut samples = Vec::with_capacity(runs as usize); + let mut exit_codes = Vec::with_capacity(runs as usize); + + for _ in 0..runs { + let started = Instant::now(); + let output = Command::new("bash") + .current_dir(&self.root_path) + .args(["-lc", &command]) + .output() + .wrap_err("Failed to run forge symbolic benchmark")?; + let elapsed = started.elapsed().as_secs_f64(); + let exit_code = output.status.code().unwrap_or(-1); + if !matches!(exit_code, 0 | 1) { + let _ = sh_eprintln!("{}", String::from_utf8_lossy(&output.stderr)); + eyre::bail!( + "forge symbolic benchmark exited abnormally with code {exit_code}: {command}" + ); + } + times.push(elapsed); + exit_codes.push(exit_code); + + if verbose { + let _ = sh_println!("{}", String::from_utf8_lossy(&output.stderr)); + } + + let run = match symbolic::parse(&output.stdout) { + Ok(summary) => summary, + Err(err) => { + if !output.status.success() { + let _ = sh_eprintln!("{}", String::from_utf8_lossy(&output.stderr)); + eyre::bail!( + "forge symbolic benchmark failed with command: {command}; {err}" + ); + } + return Err(err); + } + }; + if !output.status.success() && verbose { + let _ = sh_eprintln!("{}", String::from_utf8_lossy(&output.stderr)); + } + samples.push(Sample { wall_time_seconds: elapsed, exit_code, run }); + } + + let symbolic = samples + .get(median_index(×)) + .map(|sample| symbolic::compatibility(&sample.run)) + .ok_or_else(|| eyre::eyre!("symbolic benchmark produced no runs"))?; + let sidecar = Sidecar::new( + fixture, + &format!("{}/{}", self.org, self.repo), + &self.revision, + &build_command, + &command, + samples, + ); + + Ok(HyperfineResult { + command, + mean: mean(×), + stddev: stddev(×), + median: median(×), + user: 0.0, + system: 0.0, + min: times.iter().copied().reduce(f64::min).unwrap_or_default(), + max: times.iter().copied().reduce(f64::max).unwrap_or_default(), + times, + exit_codes: Some(exit_codes), + parameters: None, + symbolic: Some(symbolic), + symbolic_sidecar: Some(sidecar), + }) + })(); + let cleanup = overlay.finish(); + match (benchmark, cleanup) { + (Ok(result), Ok(())) => Ok(result), + (Err(err), Ok(())) => Err(err), + (Ok(_), Err(cleanup_err)) => Err(cleanup_err), + (Err(err), Err(cleanup_err)) => Err(eyre::eyre!( + "{err}; additionally, symbolic fixture cleanup failed: {cleanup_err}" + )), + } + } + /// Get the root path of the project pub fn root(&self) -> &Path { &self.root_path @@ -370,18 +595,99 @@ impl BenchmarkProject { ) -> Result { match benchmark { "forge_test" => self.bench_forge_test(version, runs, verbose), + "forge_test_filtered" => self.bench_forge_test_filtered(version, runs, verbose), "forge_build_no_cache" => self.bench_forge_build_no_cache(version, runs, verbose), "forge_build_with_cache" => self.bench_forge_build_with_cache(version, runs, verbose), "forge_fuzz_test" => self.bench_forge_fuzz_test(version, runs, verbose), "forge_coverage" => self.bench_forge_coverage(version, runs, verbose), - _ => eyre::bail!("Unknown benchmark: {}", benchmark), + "forge_isolate_test" => self.bench_forge_isolate_test(version, runs, verbose), + "forge_symbolic_test" => self.bench_forge_symbolic_test(version, runs, verbose), + _ => { + eyre::bail!("Unknown benchmark: {}", benchmark); + } } } } -/// Switch to a specific foundry version +fn mean(values: &[f64]) -> f64 { + if values.is_empty() { + return 0.0; + } + values.iter().sum::() / values.len() as f64 +} + +fn median(values: &[f64]) -> f64 { + values.get(median_index(values)).copied().unwrap_or_default() +} + +fn median_index(values: &[f64]) -> usize { + let mut indices = (0..values.len()).collect::>(); + indices.sort_by(|&left, &right| values[left].total_cmp(&values[right])); + indices.get(indices.len() / 2).copied().unwrap_or_default() +} + +fn stddev(values: &[f64]) -> Option { + if values.len() < 2 { + return None; + } + let mean = mean(values); + let variance = + values.iter().map(|value| (value - mean).powi(2)).sum::() / values.len() as f64; + Some(variance.sqrt()) +} + +/// The workspace root, embedded at compile time. +/// `benches/` is one level below the workspace root. +const WORKSPACE_ROOT: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/.."); +const WORKSPACE_ROOT_ENV: &str = "FOUNDRY_BENCH_WORKSPACE_ROOT"; +const LOCAL_BUILD_PROFILE_ENV: &str = "FOUNDRY_BENCH_LOCAL_BUILD_PROFILE"; +const LOCAL_BUILD_BINS_ENV: &str = "FOUNDRY_BENCH_LOCAL_BUILD_BINS"; +const DEFAULT_LOCAL_BUILD_PROFILE: &str = "dist"; +const FOUNDRY_BINS: [&str; 4] = ["forge", "cast", "anvil", "chisel"]; + +/// Parse `--versions` entries into unique display names and optional source +/// workspaces. `name=path` builds Foundry from `path` and labels it `name`. +pub fn parse_version_specs(specs: &[String]) -> Result)>> { + let mut labels = HashSet::new(); + specs + .iter() + .map(|spec| { + let spec = spec.trim(); + let (name, source) = match spec.split_once('=') { + Some((name, path)) if !name.is_empty() && !path.is_empty() => { + (name, Some(PathBuf::from(path))) + } + Some(_) => { + eyre::bail!("invalid source version '{spec}'; expected name=path"); + } + None => (spec, None), + }; + if name.is_empty() + || name == "." + || name == ".." + || !name.chars().all(|c| c.is_ascii_alphanumeric() || "._-".contains(c)) + { + eyre::bail!( + "invalid version label '{name}'; use letters, numbers, '.', '_', or '-'" + ); + } + if !labels.insert(name.to_string()) { + eyre::bail!("duplicate version label '{name}'"); + } + Ok((name.to_string(), source)) + }) + .collect() +} + +/// Switch to a specific foundry version. +/// +/// The special keyword `local` builds and activates the current workspace. #[allow(unused_must_use)] pub fn switch_foundry_version(version: &str) -> Result<()> { + if version == "local" { + return install_local_workspace(&workspace_root()?); + } + let output = Command::new("foundryup") .args(["--use", version]) .output() @@ -404,6 +710,117 @@ pub fn switch_foundry_version(version: &str) -> Result<()> { Ok(()) } +/// Build and activate the shipped Foundry binaries from an explicit workspace, +/// without linking unused workspace binaries. Used to benchmark a baseline ref +/// checked out into a separate worktree. +#[allow(unused_must_use)] +pub fn install_local_workspace(workspace: &Path) -> Result<()> { + let profile = local_build_profile(); + let bins = local_build_bins()?; + sh_println!( + " Building local workspace at {} with {} profile for {}", + workspace.display(), + profile.to_string_lossy(), + bins.join(", ") + ); + + let mut cmd = Command::new("cargo"); + cmd.current_dir(workspace).args(["build", "--locked", "--profile"]).arg(&profile); + for bin in &bins { + cmd.args(["--bin", bin]); + } + + let status = cmd.status().wrap_err("Failed to build local Foundry workspace")?; + + if !status.success() { + eyre::bail!("local Foundry build failed"); + } + + activate_local_binaries(workspace, &profile, &bins)?; + sh_println!(" Successfully activated local {} build", profile.to_string_lossy()); + Ok(()) +} + +fn workspace_root() -> Result { + let workspace = env::var_os(WORKSPACE_ROOT_ENV) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(WORKSPACE_ROOT)); + std::fs::canonicalize(&workspace) + .wrap_err_with(|| format!("Failed to resolve workspace root {}", workspace.display())) +} + +fn local_build_profile() -> std::ffi::OsString { + env::var_os(LOCAL_BUILD_PROFILE_ENV) + .filter(|profile| !profile.is_empty()) + .unwrap_or_else(|| DEFAULT_LOCAL_BUILD_PROFILE.into()) +} + +fn local_build_bins() -> Result> { + let Some(raw_bins) = env::var_os(LOCAL_BUILD_BINS_ENV).filter(|bins| !bins.is_empty()) else { + return Ok(FOUNDRY_BINS.into_iter().map(String::from).collect()); + }; + + let bins = raw_bins + .to_string_lossy() + .split(|c: char| c == ',' || c.is_ascii_whitespace()) + .filter(|bin| !bin.is_empty()) + .map(str::to_owned) + .collect::>(); + + if bins.is_empty() { + eyre::bail!("{LOCAL_BUILD_BINS_ENV} did not contain any binary names"); + } + + Ok(bins) +} + +fn activate_local_binaries( + workspace: &Path, + profile: &std::ffi::OsStr, + bins: &[String], +) -> Result<()> { + let bin_dir = foundry_bin_dir()?; + fs::create_dir_all(&bin_dir).wrap_err_with(|| { + format!("Failed to create Foundry bin directory at {}", bin_dir.display()) + })?; + + let local_bin_dir = workspace.join("target").join(profile); + for bin in bins { + let bin_name = format!("{bin}{}", env::consts::EXE_SUFFIX); + let source = local_bin_dir.join(&bin_name); + let destination = bin_dir.join(&bin_name); + + if !source.exists() { + eyre::bail!("local Foundry binary not found at {}", source.display()); + } + + if fs::symlink_metadata(&destination).is_ok() { + fs::remove_file(&destination).wrap_err_with(|| { + format!("Failed to remove existing binary at {}", destination.display()) + })?; + } + + fs::copy(&source, &destination).wrap_err_with(|| { + format!("Failed to activate local binary {}", destination.display()) + })?; + } + + Ok(()) +} + +fn foundry_bin_dir() -> Result { + if let Some(foundry_dir) = env::var_os("FOUNDRY_DIR") { + return Ok(PathBuf::from(foundry_dir).join("bin")); + } + + let base_dir = env::var_os("XDG_CONFIG_HOME") + .or_else(|| env::var_os("HOME")) + .map(PathBuf::from) + .ok_or_else(|| eyre::eyre!("Neither FOUNDRY_DIR, XDG_CONFIG_HOME, nor HOME is set"))?; + + Ok(base_dir.join(".foundry").join("bin")) +} + /// Get the current forge version pub fn get_forge_version() -> Result { let output = Command::new("forge") @@ -421,6 +838,39 @@ pub fn get_forge_version() -> Result { Ok(version.lines().next().unwrap_or("unknown").to_string()) } +/// Get the commit of the active Forge binary. +pub fn get_forge_commit() -> Result { + let output = Command::new("forge") + .args(["--version"]) + .output() + .wrap_err("Failed to get forge version")?; + if !output.status.success() { + eyre::bail!("forge --version failed"); + } + let output = + String::from_utf8(output.stdout).wrap_err("Invalid UTF-8 in forge version output")?; + parse_forge_commit(&output) + .map(str::to_owned) + .ok_or_else(|| eyre::eyre!("forge --version did not report a commit")) +} + +fn parse_forge_commit(output: &str) -> Option<&str> { + output + .lines() + .find_map(|line| line.trim().strip_prefix("Commit SHA: ")) + .filter(|commit| !commit.is_empty()) + .or_else(|| { + output + .lines() + .next()? + .split_once('(')? + .1 + .split_whitespace() + .next() + .filter(|commit| !commit.is_empty()) + }) +} + /// Get the full forge version details including commit hash and date pub fn get_forge_version_details() -> Result { let output = Command::new("forge") @@ -454,43 +904,37 @@ pub fn get_forge_version_details() -> Result { } } -/// Get Foundry versions to benchmark from environment variable or default -/// -/// Reads from FOUNDRY_BENCH_VERSIONS environment variable if set, -/// otherwise returns the default versions from FOUNDRY_VERSIONS constant. -/// -/// The environment variable should be a comma-separated list of versions, -/// e.g., "stable,nightly,v1.2.0" -pub fn get_benchmark_versions() -> Vec { - if let Ok(versions_env) = env::var("FOUNDRY_BENCH_VERSIONS") { - versions_env.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect() - } else { - FOUNDRY_VERSIONS.iter().map(|&s| s.to_string()).collect() - } -} +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_and_validates_version_specs() { + let specs = vec!["stable".to_string(), "master=../foundry-baseline".to_string()]; + assert_eq!( + parse_version_specs(&specs).unwrap(), + vec![ + ("stable".to_string(), None), + ("master".to_string(), Some(PathBuf::from("../foundry-baseline"))) + ] + ); -/// Setup Repositories for benchmarking -pub fn setup_benchmark_repos() -> Vec<(RepoConfig, BenchmarkProject)> { - // Check for FOUNDRY_BENCH_REPOS environment variable - let repos = if let Ok(repos_env) = env::var("FOUNDRY_BENCH_REPOS") { - // Parse repo specs from the environment variable - // Format should be: "org1/repo1,org2/repo2" - repos_env - .split(',') - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(|s| s.parse::()) - .collect::>>() - .expect("Failed to parse FOUNDRY_BENCH_REPOS") - } else { - BENCHMARK_REPOS.clone() - }; + assert!( + parse_version_specs(&["local".to_string(), "local=/tmp/foundry".to_string()]).is_err() + ); + assert!(parse_version_specs(&["../master=/tmp/foundry".to_string()]).is_err()); + assert!(parse_version_specs(&["master=".to_string()]).is_err()); + } - repos - .par_iter() - .map(|repo_config| { - let project = BenchmarkProject::setup(repo_config).expect("Failed to setup project"); - (repo_config.clone(), project) - }) - .collect() + #[test] + fn parses_modern_and_legacy_forge_commits() { + assert_eq!( + parse_forge_commit("forge Version: 1.3.1\nCommit SHA: abcdef123456\n"), + Some("abcdef123456") + ); + assert_eq!( + parse_forge_commit("forge 0.2.0 (123456abcdef 2023-01-01T00:00:00Z)"), + Some("123456abcdef") + ); + } } diff --git a/benches/src/main.rs b/benches/src/main.rs index aca0e069d582e..4708755325993 100644 --- a/benches/src/main.rs +++ b/benches/src/main.rs @@ -1,21 +1,30 @@ use clap::Parser; use eyre::{Result, WrapErr}; use foundry_bench::{ - BENCHMARK_REPOS, BenchmarkProject, FOUNDRY_VERSIONS, RUNS, RepoConfig, get_forge_version, - get_forge_version_details, - results::{BenchmarkResults, HyperfineResult}, + BENCHMARK_REPOS, BenchmarkProject, FOUNDRY_VERSIONS, RUNS, RepoConfig, get_forge_commit, + get_forge_version, get_forge_version_details, install_local_workspace, parse_version_specs, + results::{BenchmarkResults, versioned_summary_filename}, switch_foundry_version, }; use foundry_common::sh_println; use rayon::prelude::*; -use std::{fs, path::PathBuf, process::Command, sync::Mutex}; +use serde::Serialize; +use std::{ + fs, + path::{Path, PathBuf}, + process::Command, + sync::Mutex, +}; -const ALL_BENCHMARKS: [&str; 5] = [ +const ALL_BENCHMARKS: [&str; 8] = [ "forge_test", + "forge_test_filtered", "forge_build_no_cache", "forge_build_with_cache", "forge_fuzz_test", "forge_coverage", + "forge_isolate_test", + "forge_symbolic_test", ]; /// Foundry Benchmark Runner @@ -38,26 +47,98 @@ struct Cli { #[clap(long, default_value = ".")] output_dir: PathBuf, - /// Name of the output file (default: LATEST.md) - #[clap(long, default_value = "LATEST.md")] - output_file: String, + /// Name of the output file. Defaults to LATEST.md unless --json-output is set + /// without this flag, in which case no Markdown is written. + #[clap(long)] + output_file: Option, + + /// Filename for a JSON summary (benchmark/repo -> wall-time stats and, when + /// available, symbolic solver counters). Resolved relative to --output-dir. + /// Consumed by the nightly regression comparison script. + #[clap(long)] + json_output: Option, + + /// Filename for results using the common benchmark JSON schema. + /// Resolved relative to --output-dir. + #[clap(long)] + common_json_output: Option, + + /// Filename for a machine-readable manifest describing the benchmark cases. + #[clap(long, requires = "suite")] + manifest_output: Option, + + /// Stable suite identifier included in --manifest-output. + #[clap(long, requires = "manifest_output")] + suite: Option, + + /// Filename for the opt-in versioned symbolic benchmark sidecar. + #[clap(long)] + symbolic_sidecar_output: Option, /// Run only specific benchmarks (comma-separated: - /// forge_test,forge_build_no_cache,forge_build_with_cache,forge_fuzz_test,forge_coverage) + /// forge_test,forge_test_filtered,forge_build_no_cache,forge_build_with_cache,forge_fuzz_test, + /// forge_coverage, forge_symbolic_test) #[clap(long, value_delimiter = ',')] benchmarks: Option>, - /// Run only on specific repositories (comma-separated in org/repo[:rev] format: - /// ithacaxyz/account,Vectorized/solady:main,foundry-rs/foundry:v1.0.0) + /// Comma-separated list of repositories to benchmark. + /// + /// Each entry has the form `org/repo[:rev][ ]`. Anything + /// after the first whitespace is appended to every benchmark command for + /// that repo (handy to skip a broken test contract via e.g. + /// `--nmc BrokenTest`). + /// + /// Examples: + /// `ithacaxyz/account:v0.5.7` + /// `vectorized/solady:v0.1.26 --nmc 'LifebuoyTest|LibBitTest'` #[clap(long, value_delimiter = ',')] repos: Option>, } /// Mutex to prevent concurrent foundryup calls static FOUNDRY_LOCK: Mutex<()> = Mutex::new(()); -fn switch_version_safe(version: &str) -> Result<()> { + +#[derive(Serialize)] +struct BenchmarkCaseManifest { + id: String, + benchmark: String, + workload_name: String, + workload_repository: String, + workload_requested_ref: String, + workload_commit: String, + arguments: Option, + versions: Vec, +} + +#[derive(Serialize)] +struct BenchmarkSuiteManifest { + schema: &'static str, + suite: String, + cases: Vec, +} + +/// Activate a version: build from `source` when the spec was `name=path`, +/// otherwise switch via foundryup (or build the default workspace for `local`). +fn activate_version_safe(name: &str, source: Option<&Path>) -> Result<()> { let _lock = FOUNDRY_LOCK.lock().unwrap(); - switch_foundry_version(version) + match source { + Some(path) => install_local_workspace(path), + None => switch_foundry_version(name), + } +} + +fn forge_test_supports_symbolic() -> Result { + let output = Command::new("forge") + .args(["test", "--help"]) + .output() + .wrap_err("Failed to run forge test --help")?; + + if !output.status.success() { + eyre::bail!("forge test --help failed"); + } + + Ok(String::from_utf8_lossy(&output.stdout).contains("--symbolic") + || String::from_utf8_lossy(&output.stderr).contains("--symbolic")) } #[allow(unused_must_use)] @@ -73,12 +154,18 @@ fn main() -> Result<()> { ); } - // Determine versions to test - let versions = if let Some(v) = cli.versions { + // Determine versions to test. Each entry is a display name, optionally + // `name=path` to build that ref from source (e.g. a baseline worktree). + let raw_versions = if let Some(v) = cli.versions { v } else { FOUNDRY_VERSIONS.iter().map(|&s| s.to_string()).collect() }; + let version_specs = parse_version_specs(&raw_versions)?; + let versions: Vec = version_specs.iter().map(|(name, _)| name.clone()).collect(); + if cli.symbolic_sidecar_output.is_some() && versions.len() != 1 { + eyre::bail!("--symbolic-sidecar-output requires exactly one --versions entry"); + } // Get repo configurations let repos = if let Some(repo_specs) = cli.repos.clone() { @@ -94,9 +181,16 @@ fn main() -> Result<()> { repos.iter().map(|r| format!("{}/{}", r.org, r.repo)).collect::>().join(", ") ); - // Install versions if requested + // Install versions if requested. Only released versions need pre-installing + // via foundryup; source-built specs (`name=path`) and `local` are built in + // the benchmark loop. if cli.force_install { - install_foundry_versions(&versions)?; + let installable: Vec = version_specs + .iter() + .filter(|(name, source)| source.is_none() && name != "local") + .map(|(name, _)| name.clone()) + .collect(); + install_foundry_versions(&installable)?; } // Determine benchmarks to run @@ -113,6 +207,8 @@ fn main() -> Result<()> { sh_println!("Running benchmarks: {}", benchmarks.join(", ")); let mut results = BenchmarkResults::new(); + let mut version_commits = std::collections::HashMap::new(); + let write_common_json = cli.common_json_output.is_some(); // Set the first version as baseline if let Some(first_version) = versions.first() { results.set_baseline_version(first_version.clone()); @@ -147,59 +243,77 @@ fn main() -> Result<()> { sh_println!("Will run {} benchmark tasks per version", benchmark_tasks.len()); // Run benchmarks for each version - for version in &versions { + for (version, source) in &version_specs { sh_println!("🔧 Switching to Foundry version: {version}"); - switch_version_safe(version)?; + activate_version_safe(version, source.as_deref())?; // Verify the switch and capture full version details let current = get_forge_version()?; sh_println!("Current version: {}", current.trim()); // Get and store the full version details with commit hash and date + if write_common_json { + version_commits.insert(version.clone(), get_forge_commit()?); + } let version_details = get_forge_version_details()?; results.add_version_details(version, version_details); + let supports_symbolic_test = if benchmarks.iter().any(|b| b == "forge_symbolic_test") { + forge_test_supports_symbolic()? + } else { + false + }; + sh_println!("Running benchmark tasks for version {version}..."); // Run all benchmarks sequentially - let version_results = benchmark_tasks - .iter() - .map(|(repo_config, project, benchmark)| -> Result<(String, String, HyperfineResult)> { - sh_println!("Running {} on {}/{}", benchmark, repo_config.org, repo_config.repo); - - // Determine runs based on benchmark type - let runs = match benchmark.as_str() { - "forge_coverage" => 1, // Coverage runs only once as an exception - _ => RUNS, // Use default RUNS constant for all other benchmarks - }; - - // Run the appropriate benchmark - let result = project.run(benchmark, version, runs, cli.verbose); - - match result { - Ok(hyperfine_result) => { - sh_println!( - " {} on {}/{}: {:.3}s ± {:.3}s", - benchmark, - repo_config.org, - repo_config.repo, - hyperfine_result.mean, - hyperfine_result.stddev.unwrap_or(0.0) - ); - Ok((repo_config.name.clone(), benchmark.clone(), hyperfine_result)) - } - Err(e) => { - eyre::bail!( - "Benchmark {} failed for {}/{}: {}", - benchmark, - repo_config.org, - repo_config.repo, - e - ); - } + let mut version_results = Vec::new(); + for (repo_config, project, benchmark) in &benchmark_tasks { + if benchmark == "forge_symbolic_test" && !supports_symbolic_test { + sh_println!( + "Skipping forge_symbolic_test on {}/{} for {version}: active forge does not support --symbolic", + repo_config.org, + repo_config.repo + ); + continue; + } + + sh_println!("Running {} on {}/{}", benchmark, repo_config.org, repo_config.repo); + + // Determine runs based on benchmark type + let runs = match benchmark.as_str() { + "forge_coverage" => 1, // Coverage runs only once as an exception + _ => RUNS, // Use default RUNS constant for all other benchmarks + }; + + // Run the appropriate benchmark + match project.run(benchmark, version, runs, cli.verbose) { + Ok(hyperfine_result) => { + sh_println!( + " {} on {}/{}: {:.3}s ± {:.3}s", + benchmark, + repo_config.org, + repo_config.repo, + hyperfine_result.mean, + hyperfine_result.stddev.unwrap_or(0.0) + ); + version_results.push(( + repo_config.name.clone(), + benchmark.clone(), + hyperfine_result, + )); + } + Err(e) => { + eyre::bail!( + "Benchmark {} failed for {}/{}: {}", + benchmark, + repo_config.org, + repo_config.repo, + e + ); } - }) - .collect::>>()?; + } + } // Add all collected results to the main results structure for (repo_name, benchmark, hyperfine_result) in version_results { @@ -207,16 +321,146 @@ fn main() -> Result<()> { } } - // Generate markdown report - sh_println!("📝 Generating report..."); - let markdown = results.generate_markdown(&versions, &repos); - let output_path = cli.output_dir.join(cli.output_file); - fs::write(&output_path, markdown).wrap_err("Failed to write output file")?; - sh_println!("✅ Report written to: {}", output_path.display()); + // Write Markdown report unless --json-output is set without an explicit --output-file. + let md_filename = match cli.output_file { + Some(f) => Some(f), + None if cli.json_output.is_none() => Some("LATEST.md".to_string()), + None => None, + }; + if let Some(filename) = md_filename { + sh_println!("📝 Generating report..."); + let markdown = results.generate_markdown(&versions, &repos); + fs::create_dir_all(&cli.output_dir).wrap_err("Failed to create output directory")?; + let output_path = cli.output_dir.join(filename); + fs::write(&output_path, markdown).wrap_err("Failed to write output file")?; + sh_println!("✅ Report written to: {}", output_path.display()); + } + + if let Some(json_filename) = cli.json_output { + fs::create_dir_all(&cli.output_dir).wrap_err("Failed to create output directory")?; + // The summary is keyed by "benchmark/repo", so it holds one version. + // Write one file per version (suffixed) when several are benchmarked. + if versions.len() <= 1 { + let summary = results.generate_json_summary(&versions); + let json = serde_json::to_string_pretty(&summary) + .wrap_err("Failed to serialize JSON summary")?; + let json_path = cli.output_dir.join(&json_filename); + fs::write(&json_path, json).wrap_err("Failed to write JSON summary")?; + sh_println!("✅ JSON summary written to: {}", json_path.display()); + } else { + for version in &versions { + let summary = results.generate_json_summary(std::slice::from_ref(version)); + let json = serde_json::to_string_pretty(&summary) + .wrap_err("Failed to serialize JSON summary")?; + // Preserve any directory component of --json-output. + let versioned = json_filename + .with_file_name(versioned_summary_filename(&json_filename, version)); + let json_path = cli.output_dir.join(versioned); + fs::write(&json_path, json).wrap_err("Failed to write JSON summary")?; + sh_println!("✅ JSON summary written to: {}", json_path.display()); + } + } + } + + if let Some(filename) = cli.common_json_output { + let repo = + std::env::var("GITHUB_REPOSITORY").unwrap_or_else(|_| "foundry-rs/foundry".to_string()); + let pr = github_pull_request(); + for version in &versions { + let commit = version_commits + .get(version) + .expect("commit captured for every benchmarked version") + .clone(); + let common = results.generate_common_result(version, repo.clone(), commit, pr); + if common.benchmarks.is_empty() { + eyre::bail!("no benchmark results produced for {version}"); + } + let output = if versions.len() == 1 { + filename.clone() + } else { + filename.with_file_name(versioned_summary_filename(&filename, version)) + }; + let path = cli.output_dir.join(output); + fs::create_dir_all(path.parent().unwrap_or(&cli.output_dir))?; + fs::write(&path, serde_json::to_string_pretty(&common)?)?; + sh_println!("✅ Common JSON result written to: {}", path.display()); + } + } + + if let Some(filename) = cli.manifest_output { + let mut cases = Vec::new(); + for (repo_config, project) in &projects { + for benchmark in &benchmarks { + let present_versions = versions + .iter() + .filter(|version| { + results + .data + .get(benchmark) + .and_then(|version_data| version_data.get(*version)) + .is_some_and(|repo_data| repo_data.contains_key(&repo_config.name)) + }) + .cloned() + .collect::>(); + if present_versions.is_empty() { + continue; + } + cases.push(BenchmarkCaseManifest { + id: format!("{benchmark}/{}", repo_config.name), + benchmark: benchmark.clone(), + workload_name: repo_config.name.clone(), + workload_repository: format!("{}/{}", project.org, project.repo), + workload_requested_ref: repo_config.rev.clone(), + workload_commit: project.revision.clone(), + arguments: repo_config.extra_args.clone(), + versions: present_versions, + }); + } + } + cases.sort_unstable_by(|a, b| a.id.cmp(&b.id)); + let manifest = BenchmarkSuiteManifest { + schema: "foundry-benchmark-suite/v1", + suite: cli.suite.expect("clap requires --suite with --manifest-output"), + cases, + }; + let path = cli.output_dir.join(filename); + fs::create_dir_all(path.parent().unwrap_or(&cli.output_dir))?; + fs::write(&path, serde_json::to_string_pretty(&manifest)? + "\n")?; + sh_println!("✅ Benchmark manifest written to: {}", path.display()); + } + + if let Some(filename) = cli.symbolic_sidecar_output { + let mut sidecars = std::collections::BTreeMap::new(); + for (benchmark, version_data) in &results.data { + if benchmark != "forge_symbolic_test" { + continue; + } + for version in &versions { + if let Some(repo_data) = version_data.get(version) { + for (repo, result) in repo_data { + if let Some(sidecar) = &result.symbolic_sidecar { + sidecars.insert(format!("forge_symbolic_test/{repo}"), sidecar); + } + } + } + } + } + if sidecars.is_empty() { + eyre::bail!("symbolic sidecar requested but no symbolic results were produced"); + } + let path = cli.output_dir.join(filename); + fs::create_dir_all(path.parent().unwrap_or(&cli.output_dir))?; + fs::write(&path, serde_json::to_string_pretty(&sidecars)?)?; + sh_println!("✅ Symbolic sidecar written to: {}", path.display()); + } Ok(()) } +fn github_pull_request() -> Option { + std::env::var("BENCHMARK_PR_NUMBER").ok()?.parse().ok() +} + #[allow(unused_must_use)] fn install_foundry_versions(versions: &[String]) -> Result<()> { sh_println!("Installing Foundry versions..."); diff --git a/benches/src/results.rs b/benches/src/results.rs index 3bc42685a42d5..c0c0e0aa1d1e0 100644 --- a/benches/src/results.rs +++ b/benches/src/results.rs @@ -1,11 +1,17 @@ -use crate::RepoConfig; +use crate::{RepoConfig, symbolic::Sidecar}; use eyre::Result; use serde::{Deserialize, Serialize}; -use std::{collections::HashMap, process::Command}; +use std::{ + collections::{BTreeMap, HashMap}, + path::Path, + process::Command, + thread, +}; /// Hyperfine benchmark result #[derive(Debug, Deserialize, Serialize)] pub struct HyperfineResult { + #[serde(skip_serializing)] pub command: String, pub mean: f64, pub stddev: Option, @@ -19,6 +25,32 @@ pub struct HyperfineResult { pub exit_codes: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub parameters: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub symbolic: Option, + #[serde(skip)] + pub symbolic_sidecar: Option, +} + +/// Aggregated symbolic counters for one benchmark run. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +pub struct SymbolicBenchmarkSummary { + pub tests: usize, + pub passed: usize, + pub failed: usize, + pub incomplete: usize, + pub paths: u64, + pub solver_queries: u64, + pub smt_queries: u64, + pub sat_queries: u64, + pub model_queries: u64, + pub sat_cache_hits: u64, + pub model_cache_hits: u64, + pub heuristic_witnesses: u64, + pub solver_time_ms: u64, + pub smt_input_bytes: u64, + pub smt_max_query_bytes: u64, + pub smt_build_time_ms: u64, + pub smt_max_query_time_ms: u64, } /// Hyperfine JSON output format @@ -38,6 +70,55 @@ pub struct BenchmarkResults { pub version_details: HashMap, } +/// Common benchmark result format. +#[derive(Debug, Serialize)] +pub struct CommonBenchmarkResult { + pub schema_version: u8, + pub repo: String, + pub commit: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub pr: Option, + pub runner: RunnerMetadata, + pub benchmarks: Vec, +} + +#[derive(Debug, Serialize)] +pub struct RunnerMetadata { + pub os: &'static str, + pub arch: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub image: Option, + pub logical_cpus: usize, +} + +impl Default for RunnerMetadata { + fn default() -> Self { + Self { + os: std::env::consts::OS, + arch: std::env::consts::ARCH, + image: std::env::var("ImageOS").ok(), + logical_cpus: thread::available_parallelism().map_or(1, |n| n.get()), + } + } +} + +#[derive(Debug, Serialize)] +pub struct CommonBenchmark { + pub name: String, + pub wall_time: Metric, + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub counters: BTreeMap, + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub solver: BTreeMap, +} + +#[derive(Debug, Serialize)] +pub struct Metric { + pub value: f64, + pub unit: &'static str, + pub statistic: &'static str, +} + impl BenchmarkResults { pub fn new() -> Self { Self::default() @@ -66,6 +147,125 @@ impl BenchmarkResults { self.version_details.insert(version.to_string(), details); } + /// Generate a JSON summary mapping `"benchmark/repo"` to its full + /// [`HyperfineResult`], including wall-time statistics and, when available, + /// aggregated symbolic solver counters. + /// + /// Consumed by the nightly regression comparison script. + pub fn generate_json_summary(&self, versions: &[String]) -> HashMap { + let mut summary = HashMap::new(); + for (benchmark_name, version_data) in &self.data { + for version in versions { + if let Some(repo_data) = version_data.get(version) { + for (repo_name, result) in repo_data { + let key = format!("{benchmark_name}/{repo_name}"); + summary.insert(key, result); + } + } + } + } + summary + } + + /// Generate one version's results in the common benchmark schema. + pub fn generate_common_result( + &self, + version: &str, + repo: String, + commit: String, + pr: Option, + ) -> CommonBenchmarkResult { + let mut benchmarks = Vec::new(); + for (benchmark_name, version_data) in &self.data { + if let Some(repo_data) = version_data.get(version) { + for (repo_name, result) in repo_data { + let mut counters = BTreeMap::new(); + let mut solver = BTreeMap::new(); + if let Some(symbolic) = &result.symbolic { + for (name, value) in [ + ("tests", symbolic.tests as f64), + ("passed", symbolic.passed as f64), + ("failed", symbolic.failed as f64), + ("incomplete", symbolic.incomplete as f64), + ("symbolic_paths", symbolic.paths as f64), + ] { + counters.insert(name.to_string(), Metric::total(value, "count")); + } + for (name, value, unit) in [ + ("queries", symbolic.solver_queries as f64, "count"), + ("smt_queries", symbolic.smt_queries as f64, "count"), + ("sat_queries", symbolic.sat_queries as f64, "count"), + ("model_queries", symbolic.model_queries as f64, "count"), + ("sat_cache_hits", symbolic.sat_cache_hits as f64, "count"), + ("model_cache_hits", symbolic.model_cache_hits as f64, "count"), + ("heuristic_witnesses", symbolic.heuristic_witnesses as f64, "count"), + ("time", symbolic.solver_time_ms as f64 / 1000.0, "second"), + ] { + if value != 0.0 { + solver.insert(name.to_string(), Metric::total(value, unit)); + } + } + if let Some(metrics) = result + .symbolic_sidecar + .as_ref() + .and_then(|sidecar| { + sidecar + .samples + .iter() + .find(|sample| sample.wall_time_seconds == result.median) + }) + .map(|sample| &sample.run.metrics) + { + for (name, value, unit, divisor) in [ + ("smt_input_size", metrics.smt_input_bytes, "byte", 1.0), + ("smt_build_time", metrics.smt_build_time_ms, "second", 1000.0), + ] { + if let Some(value) = value { + solver.insert( + name.to_string(), + Metric::total(value as f64 / divisor, unit), + ); + } + } + for (name, value, unit, divisor) in [ + ("smt_max_query_size", metrics.smt_max_query_bytes, "byte", 1.0), + ( + "smt_max_query_time", + metrics.smt_max_query_time_ms, + "second", + 1000.0, + ), + ] { + if let Some(value) = value { + solver.insert( + name.to_string(), + Metric::max(value as f64 / divisor, unit), + ); + } + } + } + } + benchmarks.push(CommonBenchmark { + name: format!("{benchmark_name}/{repo_name}"), + wall_time: Metric::mean(result.mean, "second"), + counters, + solver, + }); + } + } + } + benchmarks.sort_unstable_by(|a, b| a.name.cmp(&b.name)); + + CommonBenchmarkResult { + schema_version: 1, + repo, + commit, + pr, + runner: RunnerMetadata::default(), + benchmarks, + } + } + pub fn generate_markdown(&self, versions: &[String], repos: &[RepoConfig]) -> String { let mut output = String::new(); @@ -131,7 +331,10 @@ impl BenchmarkResults { // System info output.push_str("## System Information\n\n"); output.push_str(&format!("- **OS**: {}\n", std::env::consts::OS)); - output.push_str(&format!("- **CPU**: {}\n", num_cpus::get())); + output.push_str(&format!( + "- **CPU**: {}\n", + thread::available_parallelism().map_or(1, |n| n.get()) + )); output.push_str(&format!( "- **Rustc**: {}\n", get_rustc_version().unwrap_or_else(|_| "unknown".to_string()) @@ -177,6 +380,20 @@ impl BenchmarkResults { } } +impl Metric { + const fn mean(value: f64, unit: &'static str) -> Self { + Self { value, unit, statistic: "mean" } + } + + const fn total(value: f64, unit: &'static str) -> Self { + Self { value, unit, statistic: "total" } + } + + const fn max(value: f64, unit: &'static str) -> Self { + Self { value, unit, statistic: "max" } + } +} + /// Generate table rows for benchmark results /// /// This function creates the markdown table rows for each repository, @@ -218,24 +435,91 @@ fn get_benchmark_cell_content( // Check if we have data for this repository let Some(result) = repo_data.get(repo_name) { + if let Some(symbolic) = &result.symbolic { + let status = format_symbolic_status(symbolic); + let has_smt_size_metrics = symbolic.smt_queries == 0 + || symbolic.smt_input_bytes != 0 + || symbolic.smt_max_query_bytes != 0; + let smt_input_bytes = if has_smt_size_metrics { + format_bytes(symbolic.smt_input_bytes) + } else { + "n/a".to_string() + }; + let smt_max_query_bytes = if has_smt_size_metrics { + format_bytes(symbolic.smt_max_query_bytes) + } else { + "n/a".to_string() + }; + return format!( + "{}
{}, solver {}ms
SMT: {} queries, {} total, {} max", + format_duration_seconds(result.mean), + status, + symbolic.solver_time_ms, + symbolic.smt_queries, + smt_input_bytes, + smt_max_query_bytes + ); + } return format_duration_seconds(result.mean); } "N/A".to_string() } +/// Insert `version` before the extension of the `--json-output` filename, e.g. +/// `summary.json` + `local` -> `summary-local.json`. +pub fn versioned_summary_filename(json_output: &Path, version: &str) -> String { + let stem = + json_output.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); + let stem = if stem.is_empty() { "summary" } else { &stem }; + match json_output.extension() { + Some(ext) => format!("{stem}-{version}.{}", ext.to_string_lossy()), + None => format!("{stem}-{version}"), + } +} + pub fn format_benchmark_name(name: &str) -> String { match name { "forge_test" => "Forge Test", + "forge_test_filtered" => "Forge Test (Warm Cache)", "forge_build_no_cache" => "Forge Build (No Cache)", "forge_build_with_cache" => "Forge Build (With Cache)", "forge_fuzz_test" => "Forge Fuzz Test", "forge_coverage" => "Forge Coverage", + "forge_isolate_test" => "Forge Test (Isolated)", + "forge_symbolic_test" => "Forge Symbolic Test", _ => name, } .to_string() } +fn format_symbolic_status(symbolic: &SymbolicBenchmarkSummary) -> String { + let mut parts = Vec::new(); + if symbolic.passed != 0 { + parts.push(format!("{} pass", symbolic.passed)); + } + if symbolic.incomplete != 0 { + parts.push(format!("{} incomplete", symbolic.incomplete)); + } + if symbolic.failed != 0 { + parts.push(format!("{} fail", symbolic.failed)); + } + if parts.is_empty() { format!("{} tests", symbolic.tests) } else { parts.join(", ") } +} + +fn format_bytes(bytes: u64) -> String { + const KIB: f64 = 1024.0; + const MIB: f64 = KIB * 1024.0; + let bytes = bytes as f64; + if bytes < KIB { + format!("{bytes:.0} B") + } else if bytes < MIB { + format!("{:.1} KiB", bytes / KIB) + } else { + format!("{:.1} MiB", bytes / MIB) + } +} + pub fn format_duration_seconds(seconds: f64) -> String { if seconds < 0.001 { format!("{:.2} ms", seconds * 1000.0) @@ -255,3 +539,128 @@ pub fn get_rustc_version() -> Result { Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn hyperfine_result(command: &str, mean: f64) -> HyperfineResult { + HyperfineResult { + command: command.to_string(), + mean, + stddev: Some(0.02), + median: mean, + user: mean * 0.9, + system: mean * 0.1, + min: mean - 0.05, + max: mean + 0.05, + times: vec![mean - 0.01, mean, mean + 0.01], + exit_codes: None, + parameters: None, + symbolic: None, + symbolic_sidecar: None, + } + } + + #[test] + fn versioned_summary_filename_inserts_version() { + assert_eq!( + versioned_summary_filename(Path::new("forge_test_bench.json"), "local"), + "forge_test_bench-local.json" + ); + assert_eq!( + versioned_summary_filename(Path::new("summary.json"), "stable"), + "summary-stable.json" + ); + assert_eq!(versioned_summary_filename(Path::new("summary"), "nightly"), "summary-nightly"); + } + + #[test] + fn json_summaries_are_isolated_by_version() { + let mut results = BenchmarkResults::new(); + results.add_result("forge_test", "master", "solady", hyperfine_result("forge test", 1.0)); + results.add_result("forge_test", "local", "solady", hyperfine_result("forge test", 2.0)); + + let dir = tempfile::tempdir().unwrap(); + let output = Path::new("summary.json"); + for version in ["master", "local"] { + let summary = results.generate_json_summary(&[version.to_string()]); + let path = dir.path().join(versioned_summary_filename(output, version)); + std::fs::write(path, serde_json::to_vec(&summary).unwrap()).unwrap(); + } + + let master: serde_json::Value = + serde_json::from_slice(&std::fs::read(dir.path().join("summary-master.json")).unwrap()) + .unwrap(); + let local: serde_json::Value = + serde_json::from_slice(&std::fs::read(dir.path().join("summary-local.json")).unwrap()) + .unwrap(); + assert_eq!(master["forge_test/solady"]["mean"], 1.0); + assert_eq!(local["forge_test/solady"]["mean"], 2.0); + } + + #[test] + fn json_summary_includes_symbolic_counters() { + let mut results = BenchmarkResults::new(); + + // A symbolic run carries the aggregated solver counters. + let mut symbolic_run = hyperfine_result("forge test --symbolic --json", 1.2345); + symbolic_run.symbolic = Some(SymbolicBenchmarkSummary { + tests: 3, + passed: 3, + failed: 0, + incomplete: 0, + paths: 42, + solver_queries: 100, + smt_queries: 80, + sat_queries: 5, + model_queries: 2, + sat_cache_hits: 1, + model_cache_hits: 0, + heuristic_witnesses: 0, + solver_time_ms: 1234, + smt_input_bytes: 5000, + smt_max_query_bytes: 900, + smt_build_time_ms: 12, + smt_max_query_time_ms: 34, + }); + results.add_result("forge_symbolic_test", "local", "solady", symbolic_run); + + // A plain run has no symbolic block, so `symbolic` is skipped. + results.add_result("forge_test", "local", "solady", hyperfine_result("forge test", 2.5)); + + let summary = results.generate_json_summary(&["local".to_string()]); + let json = serde_json::to_string_pretty(&summary).unwrap(); + + // The serialized entry omits `command` but keeps every timing field. + assert!(!json.contains("\"command\"")); + assert!(json.contains("\"times\"")); + + // Symbolic run exposes wall-time stats and every solver counter. + let symbolic = &summary["forge_symbolic_test/solady"]; + assert_eq!(symbolic.mean, 1.2345); + let counters = symbolic.symbolic.as_ref().expect("symbolic counters present"); + assert_eq!(counters.solver_queries, 100); + assert_eq!(counters.smt_input_bytes, 5000); + assert_eq!(counters.passed, 3); + + // Plain run keeps timing stats but omits the symbolic block. + let plain = &summary["forge_test/solady"]; + assert_eq!(plain.mean, 2.5); + assert!(plain.symbolic.is_none()); + + let common = results.generate_common_result( + "local", + "foundry-rs/foundry".to_string(), + "0123456789abcdef".to_string(), + None, + ); + let common = serde_json::to_value(common).unwrap(); + assert_eq!(common["schema_version"], 1); + assert_eq!(common["repo"], "foundry-rs/foundry"); + assert_eq!(common["benchmarks"][0]["name"], "forge_symbolic_test/solady"); + assert_eq!(common["benchmarks"][0]["wall_time"]["unit"], "second"); + assert_eq!(common["benchmarks"][0]["solver"]["queries"]["value"], 100.0); + assert!(common.get("pr").is_none()); + } +} diff --git a/benches/src/scfuzzbench.rs b/benches/src/scfuzzbench.rs new file mode 100644 index 0000000000000..1d2f966fe117c --- /dev/null +++ b/benches/src/scfuzzbench.rs @@ -0,0 +1,1630 @@ +//! Run local Foundry scfuzzbench campaigns and collect deterministic artifacts. + +use clap::{Parser, ValueEnum}; +use eyre::{Context, Result}; +use foundry_common::sh_println; +use once_cell::sync::Lazy; +use serde_json::json; +use std::{ + collections::HashSet, + env, + ffi::{OsStr, OsString}, + fs, + path::{Path, PathBuf}, + process::{Child, Command, ExitStatus, Output, Stdio}, + sync::Mutex, +}; + +#[cfg(unix)] +use std::{os::unix::process::CommandExt, time::Duration}; + +const DEFAULT_SCFUZZBENCH_REPO: &str = "https://github.com/tempoxyz/scfuzzbench.git"; +const DEFAULT_SCFUZZBENCH_REF: &str = "main"; +const DEFAULT_FOUNDRY_REPO: &str = "https://github.com/foundry-rs/foundry.git"; +const OUTPUT_MARKER: &str = ".foundry-scfuzzbench-output"; +#[cfg(unix)] +const PROCESS_GROUP_GRACE: Duration = Duration::from_secs(2); + +#[cfg(unix)] +type ProcessGroupId = libc::pid_t; +#[cfg(not(unix))] +type ProcessGroupId = u32; + +static ACTIVE_PROCESS_GROUPS: Lazy>> = + Lazy::new(|| Mutex::new(HashSet::new())); + +const REQUIRED_DATA_ARTIFACTS: &[&str] = &[ + "REPORT.md", + "events.csv", + "summary.csv", + "cumulative.csv", + "throughput_samples.csv", + "throughput_summary.csv", + "progress_metrics_samples.csv", + "progress_metrics_summary.csv", + "showmap_campaign_manifest.json", + "differential_coverage_relscores.csv", + "differential_coverage_relcov.csv", + "runner_resource_summary.csv", + "runner_resource_timeseries.csv", + "runner_resource_usage.md", + "broken_invariants.csv", + "broken_invariants.md", +]; + +/// Run a local Foundry scfuzzbench campaign and collect deterministic artifacts. +#[derive(Parser, Debug)] +#[clap( + name = "foundry-scfuzzbench", + about = "Run Foundry scfuzzbench campaigns and collect analysis artifacts" +)] +struct Cli { + /// scfuzzbench repository to clone. + #[clap(long, default_value = DEFAULT_SCFUZZBENCH_REPO)] + scfuzzbench_repo: String, + + /// scfuzzbench branch, tag, or commit to pin. + #[clap(long, default_value = DEFAULT_SCFUZZBENCH_REF)] + scfuzzbench_ref: String, + + /// Target benchmark repository to run scfuzzbench against. + #[clap(long)] + target_repo: String, + + /// Target benchmark branch, tag, or commit to pin. + #[clap(long)] + target_ref: String, + + /// scfuzzbench benchmark type. + #[clap(long, value_enum)] + benchmark_type: BenchmarkType, + + /// Campaign timeout in seconds. + #[clap(long)] + timeout_seconds: u64, + + /// Number of Foundry worker threads. + #[clap(long)] + workers: Option, + + /// Deterministic output directory for work files and final artifacts. + #[clap(long)] + output_dir: PathBuf, + + /// Path to the forge binary to benchmark. Mutually exclusive with --foundry-ref. + #[clap(long, conflicts_with = "foundry_ref")] + foundry_bin: Option, + + /// Foundry branch, tag, or commit to build and benchmark. Mutually exclusive with + /// --foundry-bin. + #[clap(long, conflicts_with = "foundry_bin")] + foundry_ref: Option, + + /// Foundry repository to clone when --foundry-ref is used. + #[clap(long, default_value = DEFAULT_FOUNDRY_REPO)] + foundry_repo: String, + + /// Extra arguments passed to scfuzzbench as --foundry-test-args. + #[clap(long)] + foundry_test_args: Option, + + /// Target-repository-relative properties path passed as SCFUZZBENCH_PROPERTIES_PATH. + /// Required for optimization mode. + #[clap(long)] + properties_path: Option, + + /// Remove --output-dir before running if it already exists. + #[clap(long)] + force: bool, +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +enum BenchmarkType { + Property, + Optimization, +} + +impl BenchmarkType { + const fn as_str(self) -> &'static str { + match self { + Self::Property => "property", + Self::Optimization => "optimization", + } + } +} + +struct Dirs { + work: PathBuf, + raw: PathBuf, + data: PathBuf, + images: PathBuf, + artifacts: PathBuf, + home: PathBuf, + tools_bin: PathBuf, + scfuzzbench: PathBuf, + target_pin: PathBuf, + scfuzz_root: PathBuf, + scfuzz_work: PathBuf, + scfuzz_logs: PathBuf, + unzipped: PathBuf, + analysis_logs: PathBuf, +} + +impl Dirs { + fn new(output: PathBuf) -> Self { + let work = output.join("work"); + Self { + raw: output.join("raw"), + data: output.join("data"), + images: output.join("images"), + artifacts: output.join("artifacts"), + home: work.join("home"), + tools_bin: work.join("bin"), + scfuzzbench: work.join("scfuzzbench"), + target_pin: work.join("target-pin"), + scfuzz_root: work.join("scfuzz-root"), + scfuzz_work: work.join("scfuzz-work"), + scfuzz_logs: work.join("scfuzz-logs"), + unzipped: work.join("unzipped"), + analysis_logs: work.join("analysis-logs"), + work, + } + } + + fn create(&self) -> Result<()> { + for dir in [ + &self.work, + &self.raw, + &self.data, + &self.images, + &self.artifacts, + &self.home, + &self.tools_bin, + &self.scfuzz_root, + &self.scfuzz_work, + &self.scfuzz_logs, + &self.unzipped, + &self.analysis_logs, + ] { + fs::create_dir_all(dir) + .wrap_err_with(|| format!("failed to create {}", dir.display()))?; + } + Ok(()) + } +} + +struct RunEnv { + path: OsString, + home: PathBuf, +} + +impl RunEnv { + fn apply(&self, command: &mut Command) { + command.env("PATH", &self.path).env("HOME", &self.home); + } +} + +struct FoundrySelection { + mode: &'static str, + label: String, + bin: PathBuf, + repo: Option, + ref_name: Option, + commit: Option, + version_output: String, + env: RunEnv, +} + +struct RunMetadata<'a> { + scfuzzbench_commit: &'a str, + target_commit: &'a str, + run_id: &'a str, + campaign_exit_code: Option, +} + +fn main() -> Result<()> { + color_eyre::install()?; + ensure_supported_platform()?; + install_termination_handler()?; + let cli = Cli::parse(); + + validate_options(&cli)?; + preflight(&cli)?; + prepare_output_dir(&cli.output_dir, cli.force)?; + let dirs = Dirs::new(cli.output_dir.clone()); + dirs.create()?; + install_date_shim(&dirs.tools_bin)?; + install_timeout_shim(&dirs.tools_bin)?; + install_sed_shim(&dirs.tools_bin)?; + + let _ = sh_println!("📦 Cloning scfuzzbench"); + let scfuzzbench_commit = + clone_at(&cli.scfuzzbench_repo, &cli.scfuzzbench_ref, &dirs.scfuzzbench) + .wrap_err("failed to clone scfuzzbench")?; + + let _ = sh_println!("📦 Resolving target repository pin"); + let target_commit = clone_at(&cli.target_repo, &cli.target_ref, &dirs.target_pin) + .wrap_err("failed to clone target repository")?; + + let foundry = select_foundry(&cli, &dirs).wrap_err("failed to select Foundry binary")?; + let _ = sh_println!("🔨 Foundry: {}", foundry.version_output.trim()); + + let run_id = format!("foundry-scfuzzbench-{}", chrono::Utc::now().format("%Y%m%d%H%M%S")); + let campaign_status = run_campaign(&cli, &dirs, &foundry, &target_commit, &run_id) + .wrap_err("failed to run scfuzzbench campaign")?; + ensure_campaign_success(&campaign_status)?; + + validate_campaign_logs(&dirs)?; + + run_analysis(&cli, &dirs, &foundry, &run_id).wrap_err("failed to analyze campaign logs")?; + validate_differential_coverage(&dirs)?; + + let run_metadata = RunMetadata { + scfuzzbench_commit: &scfuzzbench_commit, + target_commit: &target_commit, + run_id: &run_id, + campaign_exit_code: campaign_status.code(), + }; + let mut missing = collect_artifacts(&dirs).wrap_err("failed to collect artifacts")?; + let summary_path = write_llm_summary(&cli, &dirs, &foundry, &run_metadata, &missing)?; + let manifest_path = write_manifest(&cli, &dirs, &foundry, &run_metadata)?; + missing.retain(|path| path != "manifest.json" && path != "llm_summary.md"); + + if !missing.is_empty() { + eyre::bail!( + "missing required scfuzzbench artifacts in {}: {}", + dirs.artifacts.display(), + missing.join(", ") + ); + } + + let _ = sh_println!("✅ Artifacts written to {}", dirs.artifacts.display()); + let _ = sh_println!(" manifest: {}", manifest_path.display()); + let _ = sh_println!(" LLM summary: {}", summary_path.display()); + Ok(()) +} + +#[cfg(unix)] +const fn ensure_supported_platform() -> Result<()> { + Ok(()) +} + +#[cfg(not(unix))] +fn ensure_supported_platform() -> Result<()> { + eyre::bail!("foundry-scfuzzbench requires a Unix-like platform with bash process groups"); +} + +fn ensure_campaign_success(status: &ExitStatus) -> Result<()> { + if status.success() { + return Ok(()); + } + eyre::bail!( + "scfuzzbench campaign failed ({status}); refusing to analyze incomplete campaign logs" + ); +} + +fn validate_options(cli: &Cli) -> Result<()> { + if matches!(cli.benchmark_type, BenchmarkType::Optimization) && cli.properties_path.is_none() { + eyre::bail!("--properties-path is required for --benchmark-type optimization"); + } + if let Some(properties_path) = &cli.properties_path { + if properties_path.is_absolute() { + eyre::bail!( + "--properties-path must be relative to the target repository: {}", + properties_path.display() + ); + } + if properties_path + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + { + eyre::bail!( + "--properties-path must not escape the target repository: {}", + properties_path.display() + ); + } + } + Ok(()) +} + +fn preflight(cli: &Cli) -> Result<()> { + for name in ["bash", "git", "make", "uv", "zip", "python3"] { + let status = Command::new("sh") + .arg("-c") + .arg(format!("command -v {name} >/dev/null 2>&1")) + .status() + .wrap_err_with(|| format!("failed to check for {name}"))?; + if !status.success() { + eyre::bail!("required command `{name}` was not found in PATH"); + } + } + if cli.foundry_ref.is_some() && !command_exists("cargo")? { + eyre::bail!("required command `cargo` was not found in PATH"); + } + Ok(()) +} + +fn prepare_output_dir(output_dir: &Path, force: bool) -> Result<()> { + if output_dir.exists() && fs::symlink_metadata(output_dir)?.file_type().is_symlink() { + eyre::bail!("refusing to use symlink output directory {}", output_dir.display()); + } + + if output_dir.exists() { + if !force && dir_has_entries(output_dir)? { + eyre::bail!( + "output directory {} already exists and is not empty; pass --force to remove it", + output_dir.display() + ); + } + if force { + if output_dir.parent().is_none() || output_dir == Path::new("/") { + eyre::bail!("refusing to remove unsafe output directory {}", output_dir.display()); + } + let marker = output_dir.join(OUTPUT_MARKER); + if dir_has_entries(output_dir)? && !marker.exists() { + eyre::bail!( + "refusing to remove {} because it is not marked as a foundry-scfuzzbench output directory", + output_dir.display() + ); + } + fs::remove_dir_all(output_dir) + .wrap_err_with(|| format!("failed to remove {}", output_dir.display()))?; + } + } + fs::create_dir_all(output_dir) + .wrap_err_with(|| format!("failed to create {}", output_dir.display()))?; + fs::write(output_dir.join(OUTPUT_MARKER), "foundry-scfuzzbench\n")?; + Ok(()) +} + +fn command_exists(name: &str) -> Result { + let status = Command::new("sh") + .arg("-c") + .arg(format!("command -v {name} >/dev/null 2>&1")) + .status() + .wrap_err_with(|| format!("failed to check for {name}"))?; + Ok(status.success()) +} + +fn make_executable(path: &Path) -> Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let mut permissions = fs::metadata(path)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions) + .wrap_err_with(|| format!("failed to chmod {}", path.display()))?; + } + #[cfg(not(unix))] + { + let _ = path; + } + Ok(()) +} + +fn install_termination_handler() -> Result<()> { + ctrlc::set_handler(|| { + terminate_active_process_groups(); + std::process::exit(130); + }) + .wrap_err("failed to install termination handler") +} + +fn install_date_shim(tools_bin: &Path) -> Result<()> { + let native_supports_iso_seconds = Command::new("date") + .arg("-Is") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .wrap_err("failed to check native date -Is support")? + .success(); + if native_supports_iso_seconds { + return Ok(()); + } + + fs::create_dir_all(tools_bin) + .wrap_err_with(|| format!("failed to create {}", tools_bin.display()))?; + let shim = tools_bin.join("date"); + let content = r#"#!/usr/bin/env bash +if [[ "$#" -eq 1 && ( "$1" == "-Is" || "$1" == "-Iseconds" ) ]]; then + exec python3 -c 'from datetime import datetime, timezone; print(datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"))' +fi +exec /bin/date "$@" +"#; + + fs::write(&shim, content).wrap_err_with(|| format!("failed to write {}", shim.display()))?; + make_executable(&shim)?; + Ok(()) +} + +fn install_timeout_shim(tools_bin: &Path) -> Result<()> { + if command_exists("timeout")? { + return Ok(()); + } + + fs::create_dir_all(tools_bin) + .wrap_err_with(|| format!("failed to create {}", tools_bin.display()))?; + let shim = tools_bin.join("timeout"); + + let content = if command_exists("gtimeout")? { + r#"#!/usr/bin/env bash +exec gtimeout "$@" +"# + .to_string() + } else { + r#"#!/usr/bin/env python3 +import os +import signal +import subprocess +import sys +import time + + +def parse_seconds(value): + if not value.endswith("s"): + raise ValueError(f"unsupported duration {value!r}; expected seconds ending in 's'") + return float(value[:-1]) + + +def main(argv): + if len(argv) < 5: + print("timeout shim supports: timeout --signal=SIGINT --kill-after=s s ", file=sys.stderr) + return 125 + + sigarg = argv[1] + killarg = argv[2] + duration_arg = argv[3] + command = argv[4:] + + if sigarg != "--signal=SIGINT": + print(f"unsupported timeout signal option: {sigarg}", file=sys.stderr) + return 125 + if not killarg.startswith("--kill-after="): + print(f"unsupported timeout kill-after option: {killarg}", file=sys.stderr) + return 125 + + try: + duration = parse_seconds(duration_arg) + grace = parse_seconds(killarg.split("=", 1)[1]) + except ValueError as exc: + print(str(exc), file=sys.stderr) + return 125 + + proc = subprocess.Popen(command, start_new_session=True) + try: + return proc.wait(timeout=duration) + except subprocess.TimeoutExpired: + try: + os.killpg(proc.pid, signal.SIGINT) + except ProcessLookupError: + pass + except PermissionError: + proc.send_signal(signal.SIGINT) + + deadline = time.monotonic() + grace + while time.monotonic() < deadline: + code = proc.poll() + if code is not None: + return 124 + time.sleep(0.1) + + try: + os.killpg(proc.pid, signal.SIGKILL) + except ProcessLookupError: + pass + except PermissionError: + proc.kill() + proc.wait() + return 124 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) +"# + .to_string() + }; + + fs::write(&shim, content).wrap_err_with(|| format!("failed to write {}", shim.display()))?; + make_executable(&shim)?; + Ok(()) +} + +fn install_sed_shim(tools_bin: &Path) -> Result<()> { + let native_supports_gnu_in_place = Command::new("sh") + .arg("-c") + .arg( + r#"tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/foundry-scfuzzbench-sed.XXXXXX") || exit 1 +trap 'rm -rf "$tmpdir"' EXIT +tmp="${tmpdir}/input" +printf 'foo\n' > "$tmp" +sed -i 's/foo/bar/' "$tmp" >/dev/null 2>&1 && grep -qx bar "$tmp" +"#, + ) + .status() + .wrap_err("failed to check native sed -i support")? + .success(); + if native_supports_gnu_in_place { + return Ok(()); + } + + fs::create_dir_all(tools_bin) + .wrap_err_with(|| format!("failed to create {}", tools_bin.display()))?; + let shim = tools_bin.join("sed"); + + let content = if command_exists("gsed")? { + r#"#!/usr/bin/env bash +exec gsed "$@" +"# + .to_string() + } else { + r#"#!/usr/bin/env bash +native_sed=/usr/bin/sed +if [[ ! -x "${native_sed}" ]]; then + native_sed=/bin/sed +fi + +if "${native_sed}" --version >/dev/null 2>&1; then + exec "${native_sed}" "$@" +fi + +if [[ "${1:-}" == "-i" ]]; then + shift + exec "${native_sed}" -i '' "$@" +fi + +exec "${native_sed}" "$@" +"# + .to_string() + }; + + fs::write(&shim, content).wrap_err_with(|| format!("failed to write {}", shim.display()))?; + make_executable(&shim)?; + Ok(()) +} + +fn clone_at(repo: &str, git_ref: &str, dest: &Path) -> Result { + fs::create_dir_all(dest).wrap_err_with(|| format!("failed to create {}", dest.display()))?; + + let mut init = Command::new("git"); + init.arg("init").arg(dest); + run_required(&mut init)?; + + let mut remote = Command::new("git"); + remote.current_dir(dest).args(["remote", "add", "origin", repo]); + run_required(&mut remote)?; + + let mut fetch_command = Command::new("git"); + fetch_command.current_dir(dest).args(["fetch", "--depth", "1", "origin", git_ref]); + let fetch = run_status(&mut fetch_command) + .wrap_err_with(|| format!("failed to fetch {repo}@{git_ref}"))?; + if !fetch.success() { + let mut fetch_full = Command::new("git"); + fetch_full.current_dir(dest).args(["fetch", "origin", git_ref]); + run_required(&mut fetch_full)?; + } + + let mut checkout = Command::new("git"); + checkout.current_dir(dest).args(["checkout", "--detach", "FETCH_HEAD"]); + run_required(&mut checkout)?; + + let mut rev_parse = Command::new("git"); + rev_parse.current_dir(dest).args(["rev-parse", "HEAD"]); + output_text(&mut rev_parse).map(|s| s.trim().to_string()) +} + +fn select_foundry(cli: &Cli, dirs: &Dirs) -> Result { + if let Some(foundry_bin) = &cli.foundry_bin { + let bin = foundry_bin + .canonicalize() + .wrap_err_with(|| format!("failed to canonicalize {}", foundry_bin.display()))?; + if !bin.is_file() { + eyre::bail!("--foundry-bin must point to a file: {}", bin.display()); + } + if bin.file_name() != Some(OsStr::new("forge")) { + eyre::bail!("--foundry-bin must point to a binary named `forge`: {}", bin.display()); + } + let bin_dir = bin + .parent() + .ok_or_else(|| eyre::eyre!("{} has no parent directory", bin.display()))? + .to_path_buf(); + let env = run_env(&dirs.tools_bin, Some(&bin_dir), &dirs.home)?; + validate_selected_forge(&bin, &env)?; + let version_output = forge_version(&env)?; + return Ok(FoundrySelection { + mode: "bin", + label: "foundry-bin".to_string(), + bin, + repo: None, + ref_name: None, + commit: None, + version_output, + env, + }); + } + + if let Some(foundry_ref) = &cli.foundry_ref { + let foundry_checkout = dirs.work.join("foundry"); + let foundry_commit = clone_at(&cli.foundry_repo, foundry_ref, &foundry_checkout)?; + + let mut build = Command::new("cargo"); + build.current_dir(&foundry_checkout).args([ + "build", + "--locked", + "--profile", + "dist", + "--bin", + "forge", + ]); + run_required(&mut build)?; + + let bin = foundry_checkout.join("target/dist/forge"); + let bin_dir = bin + .parent() + .ok_or_else(|| eyre::eyre!("{} has no parent directory", bin.display()))? + .to_path_buf(); + let env = run_env(&dirs.tools_bin, Some(&bin_dir), &dirs.home)?; + validate_selected_forge(&bin, &env)?; + let version_output = forge_version(&env)?; + let label = format!( + "foundry-ref-{}-{}", + sanitize_label(foundry_ref), + foundry_commit.chars().take(12).collect::() + ); + return Ok(FoundrySelection { + mode: "ref", + label, + bin: bin.canonicalize().unwrap_or(bin), + repo: Some(cli.foundry_repo.clone()), + ref_name: Some(foundry_ref.clone()), + commit: Some(foundry_commit), + version_output, + env, + }); + } + + let env = run_env(&dirs.tools_bin, None, &dirs.home)?; + let mut which_forge = Command::new("sh"); + which_forge.arg("-c").arg("command -v forge"); + env.apply(&mut which_forge); + let forge_path = output_text(&mut which_forge)?; + let bin = PathBuf::from(forge_path.trim()) + .canonicalize() + .wrap_err_with(|| format!("failed to canonicalize {}", forge_path.trim()))?; + validate_selected_forge(&bin, &env)?; + let version_output = forge_version(&env)?; + Ok(FoundrySelection { + mode: "path", + label: "foundry-path".to_string(), + bin, + repo: None, + ref_name: None, + commit: None, + version_output, + env, + }) +} + +fn run_env(tools_bin: &Path, bin_dir: Option<&Path>, home: &Path) -> Result { + let mut paths = Vec::new(); + paths.push(tools_bin.to_path_buf()); + if let Some(bin_dir) = bin_dir { + paths.push(bin_dir.to_path_buf()); + } + if let Some(existing) = env::var_os("PATH") { + paths.extend(env::split_paths(&existing)); + } + Ok(RunEnv { path: env::join_paths(paths)?, home: home.to_path_buf() }) +} + +fn validate_selected_forge(selected: &Path, env: &RunEnv) -> Result<()> { + let selected = selected.canonicalize().wrap_err_with(|| { + format!("failed to canonicalize selected forge {}", selected.display()) + })?; + if !selected.is_file() { + eyre::bail!("selected forge is not a file: {}", selected.display()); + } + if selected.file_name() != Some(OsStr::new("forge")) { + eyre::bail!("selected forge is not named `forge`: {}", selected.display()); + } + + let mut which_forge = Command::new("sh"); + which_forge.arg("-c").arg("command -v forge"); + env.apply(&mut which_forge); + let resolved = output_text(&mut which_forge)?; + let resolved = PathBuf::from(resolved.trim()) + .canonicalize() + .wrap_err_with(|| format!("failed to canonicalize resolved forge {}", resolved.trim()))?; + if resolved != selected { + eyre::bail!( + "selected forge {} does not match PATH-resolved forge {}", + selected.display(), + resolved.display() + ); + } + Ok(()) +} + +fn forge_version(env: &RunEnv) -> Result { + let mut command = Command::new("forge"); + env.apply(&mut command); + command.arg("--version"); + output_text(&mut command) +} + +fn run_campaign( + cli: &Cli, + dirs: &Dirs, + foundry: &FoundrySelection, + target_commit: &str, + run_id: &str, +) -> Result { + let _ = sh_println!("🚀 Running scfuzzbench campaign"); + let mut command = Command::new("bash"); + command + .current_dir(&dirs.scfuzzbench) + .arg("scripts/local-run.sh") + .args(["-f", "foundry"]) + .args(["-r", &cli.target_repo]) + .args(["-b", target_commit]) + .args(["-t", &cli.timeout_seconds.to_string()]) + .args(["-T", cli.benchmark_type.as_str()]); + + if let Some(workers) = cli.workers { + command.args(["-w", &workers.to_string()]); + command.env("FOUNDRY_THREADS", workers.to_string()); + } + if let Some(foundry_test_args) = cli.foundry_test_args.as_deref() { + command.args(["--foundry-test-args", foundry_test_args]); + } + if let Some(properties_path) = &cli.properties_path { + command.env("SCFUZZBENCH_PROPERTIES_PATH", properties_path); + } + + foundry.env.apply(&mut command); + command + .env("SCFUZZBENCH_ROOT", &dirs.scfuzz_root) + .env("SCFUZZBENCH_WORKDIR", &dirs.scfuzz_work) + .env("SCFUZZBENCH_LOG_DIR", &dirs.scfuzz_logs) + .env("SCFUZZBENCH_LOCAL_OUTPUT_DIR", &dirs.raw) + .env("SCFUZZBENCH_RUN_ID", run_id) + .env("SCFUZZBENCH_INSTANCE_ID", run_id) + .env("SCFUZZBENCH_FUZZER_LABEL", &foundry.label) + .env("FOUNDRY_LABEL", &foundry.label) + .env("SCFUZZBENCH_FOUNDRY_SHOWMAP", "1") + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + + run_status(&mut command).wrap_err("failed to execute scripts/local-run.sh") +} + +fn run_analysis(cli: &Cli, dirs: &Dirs, foundry: &FoundrySelection, run_id: &str) -> Result<()> { + let _ = sh_println!("📊 Running scfuzzbench analysis"); + let prepared_logs = dirs.unzipped.join(&foundry.label).join("logs"); + fs::create_dir_all(&prepared_logs) + .wrap_err_with(|| format!("failed to create {}", prepared_logs.display()))?; + copy_analysis_logs(&dirs.scfuzz_logs, &prepared_logs)?; + + make( + dirs, + &[ + OsString::from("results-prepare"), + make_var("UNZIPPED_DIR", &dirs.unzipped), + make_var("ANALYSIS_LOGS_DIR", &dirs.analysis_logs), + ], + )?; + make( + dirs, + &[ + OsString::from("results-analyze-filtered"), + make_var("ANALYSIS_LOGS_DIR", &dirs.analysis_logs), + make_var("ANALYSIS_OUT_DIR", &dirs.data), + make_str_var("RUN_ID", run_id), + ], + )?; + make( + dirs, + &[ + OsString::from("report-events-to-cumulative"), + make_var("ANALYSIS_LOGS_DIR", &dirs.analysis_logs), + make_var("ANALYSIS_OUT_DIR", &dirs.data), + make_var("EVENTS_CSV", &dirs.data.join("events.csv")), + make_var("CUMULATIVE_CSV", &dirs.data.join("cumulative.csv")), + make_str_var("RUN_ID", run_id), + ], + )?; + + let report_budget = format!("{:.3}", cli.timeout_seconds as f64 / 3600.0); + make( + dirs, + &[ + OsString::from("report-benchmark"), + make_var("ANALYSIS_LOGS_DIR", &dirs.analysis_logs), + make_var("ANALYSIS_OUT_DIR", &dirs.data), + make_var("REPORT_CSV", &dirs.data.join("cumulative.csv")), + make_var("REPORT_OUT_DIR", &dirs.data), + make_var("IMAGES_OUT_DIR", &dirs.images), + make_str_var("REPORT_BUDGET", &report_budget), + ], + )?; + make( + dirs, + &[ + OsString::from("report-invariant-overlap"), + make_var("ANALYSIS_LOGS_DIR", &dirs.analysis_logs), + make_var("ANALYSIS_OUT_DIR", &dirs.data), + make_var("EVENTS_CSV", &dirs.data.join("events.csv")), + make_var("IMAGES_OUT_DIR", &dirs.images), + make_str_var("REPORT_BUDGET", &report_budget), + ], + )?; + make( + dirs, + &[ + OsString::from("report-runner-metrics"), + make_var("ANALYSIS_LOGS_DIR", &dirs.analysis_logs), + make_var("ANALYSIS_OUT_DIR", &dirs.data), + make_var("IMAGES_OUT_DIR", &dirs.images), + make_str_var("RUN_ID", run_id), + make_str_var("REPORT_BUDGET", &report_budget), + ], + )?; + Ok(()) +} + +fn validate_campaign_logs(dirs: &Dirs) -> Result<()> { + let foundry_log = dirs.scfuzz_logs.join("foundry.log"); + ensure_non_empty_file(&foundry_log, "campaign foundry log")?; + + let commands_log = dirs.scfuzz_logs.join("runner_commands.log"); + ensure_non_empty_file(&commands_log, "campaign runner commands log")?; + let commands = fs::read_to_string(&commands_log) + .wrap_err_with(|| format!("failed to read {}", commands_log.display()))?; + if !commands.contains("forge test --mc CryticToFoundry") { + eyre::bail!( + "{} did not contain expected Foundry campaign command `forge test --mc CryticToFoundry`", + commands_log.display() + ); + } + Ok(()) +} + +fn validate_differential_coverage(dirs: &Dirs) -> Result<()> { + let manifest_path = dirs.data.join("showmap_campaign_manifest.json"); + ensure_non_empty_file(&manifest_path, "showmap campaign manifest")?; + let manifest: serde_json::Value = serde_json::from_str( + &fs::read_to_string(&manifest_path) + .wrap_err_with(|| format!("failed to read {}", manifest_path.display()))?, + ) + .wrap_err_with(|| format!("failed to parse {}", manifest_path.display()))?; + + let raw_trials = manifest.get("raw_trials").and_then(serde_json::Value::as_u64).unwrap_or(0); + if raw_trials == 0 { + eyre::bail!("{} has raw_trials=0", manifest_path.display()); + } + + let approaches = combined_approaches(&manifest_path, &manifest)?; + let has_covered_trial = approaches.iter().any(|entry| { + let trials = entry.get("trials").and_then(serde_json::Value::as_u64).unwrap_or(0); + let covered_edges = + entry.get("covered_edges").and_then(serde_json::Value::as_u64).unwrap_or(0); + trials > 0 && covered_edges > 0 + }); + if !has_covered_trial { + eyre::bail!( + "{} has no campaigns.combined approach with trials > 0 and covered_edges > 0", + manifest_path.display() + ); + } + + ensure_csv_has_data_row(&dirs.data.join("differential_coverage_relscores.csv"))?; + + let relcov = dirs.data.join("differential_coverage_relcov.csv"); + if approaches.len() > 1 { + ensure_csv_has_data_row(&relcov)?; + } else { + ensure_non_empty_file(&relcov, "differential coverage CSV")?; + } + Ok(()) +} + +fn combined_approaches<'a>( + manifest_path: &Path, + manifest: &'a serde_json::Value, +) -> Result> { + let combined = manifest + .get("campaigns") + .and_then(|campaigns| campaigns.get("combined")) + .and_then(serde_json::Value::as_object) + .ok_or_else(|| { + eyre::eyre!("{} does not contain campaigns.combined", manifest_path.display()) + })?; + + let approaches = match combined.get("approaches") { + Some(approaches) => approaches.as_object().ok_or_else(|| { + eyre::eyre!( + "{} campaigns.combined.approaches is not an object", + manifest_path.display() + ) + })?, + None => combined, + }; + + if approaches.is_empty() { + eyre::bail!("{} has empty campaigns.combined approaches", manifest_path.display()); + } + + Ok(approaches.values().collect()) +} + +fn ensure_non_empty_file(path: &Path, label: &str) -> Result<()> { + let metadata = + fs::metadata(path).wrap_err_with(|| format!("missing {label}: {}", path.display()))?; + if !metadata.is_file() || metadata.len() == 0 { + eyre::bail!("{label} is empty or not a file: {}", path.display()); + } + Ok(()) +} + +fn ensure_csv_has_data_row(path: &Path) -> Result<()> { + ensure_non_empty_file(path, "differential coverage CSV")?; + let contents = + fs::read_to_string(path).wrap_err_with(|| format!("failed to read {}", path.display()))?; + let non_empty_lines = contents.lines().filter(|line| !line.trim().is_empty()).count(); + if non_empty_lines < 2 { + eyre::bail!("{} has no data rows", path.display()); + } + Ok(()) +} + +fn make(dirs: &Dirs, args: &[OsString]) -> Result<()> { + let mut command = Command::new("make"); + command + .current_dir(&dirs.scfuzzbench) + .args(args) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + run_required(&mut command) +} + +fn make_var(name: &str, path: &Path) -> OsString { + let mut value = OsString::from(name); + value.push("="); + value.push(path.as_os_str()); + value +} + +fn make_str_var(name: &str, value: &str) -> OsString { + OsString::from(format!("{name}={value}")) +} + +fn collect_artifacts(dirs: &Dirs) -> Result> { + let _ = sh_println!("📁 Collecting deterministic artifact bundle"); + fs::create_dir_all(&dirs.artifacts)?; + + let mut missing = Vec::new(); + for artifact in REQUIRED_DATA_ARTIFACTS { + let src = dirs.data.join(artifact); + let dest = dirs.artifacts.join(artifact); + if src.exists() { + copy_path(&src, &dest)?; + } else { + missing.push((*artifact).to_string()); + } + } + + copy_if_exists( + &dirs.data.join("showmap_campaigns"), + &dirs.artifacts.join("showmap_campaigns"), + )?; + copy_if_exists(&dirs.images, &dirs.artifacts.join("images"))?; + collect_raw_archives(&dirs.raw, &dirs.artifacts.join("raw"))?; + collect_lcov_outputs(dirs, &dirs.artifacts.join("lcov-diff"))?; + + Ok(missing) +} + +fn collect_raw_archives(raw: &Path, dest: &Path) -> Result<()> { + let logs = find_named(raw, "logs.zip")?; + let corpus = find_named(raw, "corpus.zip")?; + if logs.is_empty() && corpus.is_empty() { + return Ok(()); + } + fs::create_dir_all(dest)?; + if let Some(path) = logs.first() { + fs::copy(path, dest.join("logs.zip"))?; + } + if let Some(path) = corpus.first() { + fs::copy(path, dest.join("corpus.zip"))?; + } + Ok(()) +} + +fn collect_lcov_outputs(dirs: &Dirs, dest: &Path) -> Result<()> { + let mut matches = Vec::new(); + for root in [&dirs.raw, &dirs.data, &dirs.work] { + find_lcov_like(root, &mut matches)?; + } + matches.sort(); + if matches.is_empty() { + return Ok(()); + } + fs::create_dir_all(dest)?; + for path in matches { + if let Some(name) = path.file_name() { + copy_path(&path, &dest.join(name))?; + } + } + Ok(()) +} + +fn write_manifest( + cli: &Cli, + dirs: &Dirs, + foundry: &FoundrySelection, + metadata: &RunMetadata<'_>, +) -> Result { + let artifacts = list_relative_files(&dirs.artifacts)?; + let manifest = json!({ + "scfuzzbench": { + "repo": &cli.scfuzzbench_repo, + "ref": &cli.scfuzzbench_ref, + "commit": metadata.scfuzzbench_commit, + }, + "target": { + "repo": &cli.target_repo, + "ref": &cli.target_ref, + "commit": metadata.target_commit, + }, + "foundry": { + "mode": foundry.mode, + "label": &foundry.label, + "bin": foundry.bin.display().to_string(), + "repo": foundry.repo.as_deref(), + "ref": foundry.ref_name.as_deref(), + "commit": foundry.commit.as_deref(), + "version_output": foundry.version_output.trim(), + }, + "campaign": { + "benchmark_type": cli.benchmark_type.as_str(), + "timeout_seconds": cli.timeout_seconds, + "workers": cli.workers, + "run_id": metadata.run_id, + "exit_code": metadata.campaign_exit_code, + "foundry_test_args": cli.foundry_test_args.as_deref(), + "properties_path": cli.properties_path.as_ref().map(|path| path.display().to_string()), + }, + "artifacts": artifacts, + }); + let path = dirs.artifacts.join("manifest.json"); + fs::write(&path, serde_json::to_string_pretty(&manifest)? + "\n")?; + Ok(path) +} + +fn write_llm_summary( + cli: &Cli, + dirs: &Dirs, + foundry: &FoundrySelection, + metadata: &RunMetadata<'_>, + missing: &[String], +) -> Result { + let mut lines = vec![ + "# Foundry scfuzzbench summary".to_string(), + String::new(), + format!( + "- scfuzzbench: `{}` @ `{}` (`{}`)", + cli.scfuzzbench_repo, cli.scfuzzbench_ref, metadata.scfuzzbench_commit + ), + format!( + "- target: `{}` @ `{}` (`{}`)", + cli.target_repo, cli.target_ref, metadata.target_commit + ), + format!("- foundry: `{}` ({})", foundry.version_output.trim(), foundry.mode), + format!("- benchmark type: `{}`", cli.benchmark_type.as_str()), + format!("- timeout seconds: `{}`", cli.timeout_seconds), + format!( + "- workers: `{}`", + cli.workers.map(|w| w.to_string()).unwrap_or_else(|| "default".to_string()) + ), + format!("- run id: `{}`", metadata.run_id), + format!( + "- campaign exit code: `{}`", + metadata + .campaign_exit_code + .map(|c| c.to_string()) + .unwrap_or_else(|| "signal/unknown".to_string()) + ), + format!( + "- required artifacts missing: `{}`", + if missing.is_empty() { "none".to_string() } else { missing.join(", ") } + ), + String::new(), + "## Primary artifacts".to_string(), + String::new(), + "- `REPORT.md`".to_string(), + "- `events.csv`, `summary.csv`, `cumulative.csv`".to_string(), + "- `showmap_campaign_manifest.json` and `showmap_campaigns/`".to_string(), + "- `differential_coverage_relscores.csv` and `differential_coverage_relcov.csv`" + .to_string(), + ]; + + let report = dirs.artifacts.join("REPORT.md"); + if report.exists() { + let preview = fs::read_to_string(&report) + .unwrap_or_default() + .lines() + .filter(|line| !line.trim().is_empty()) + .take(12) + .map(str::to_string) + .collect::>(); + if !preview.is_empty() { + lines.extend([String::new(), "## Report preview".to_string(), String::new()]); + lines.extend(preview); + } + } + + let path = dirs.artifacts.join("llm_summary.md"); + fs::write(&path, lines.join("\n") + "\n")?; + Ok(path) +} + +fn run_required(command: &mut Command) -> Result<()> { + let display = command_display(command); + let status = run_status(command)?; + if !status.success() { + eyre::bail!("command failed ({status}): {display}"); + } + Ok(()) +} + +fn run_status(command: &mut Command) -> Result { + let display = command_display(command); + let (mut child, mut guard) = spawn_guarded(command, &display)?; + let status = child.wait().wrap_err_with(|| format!("failed to wait for {display}"))?; + guard.finish(); + Ok(status) +} + +fn output_text(command: &mut Command) -> Result { + let display = command_display(command); + let output = guarded_output(command, &display)?; + if !output.status.success() { + eyre::bail!( + "command failed ({}): {}\nstdout:\n{}\nstderr:\n{}", + output.status, + display, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(String::from_utf8(output.stdout)?.trim().to_string()) +} + +fn guarded_output(command: &mut Command, display: &str) -> Result { + command.stdout(Stdio::piped()).stderr(Stdio::piped()); + let (child, mut guard) = spawn_guarded(command, display)?; + let output = + child.wait_with_output().wrap_err_with(|| format!("failed to wait for {display}"))?; + guard.finish(); + Ok(output) +} + +fn spawn_guarded(command: &mut Command, display: &str) -> Result<(Child, ActiveProcessGroup)> { + configure_process_group(command); + let child = command.spawn().wrap_err_with(|| format!("failed to execute {display}"))?; + let guard = ActiveProcessGroup::new(child.id() as ProcessGroupId); + Ok((child, guard)) +} + +#[cfg(unix)] +fn configure_process_group(command: &mut Command) { + command.process_group(0); +} + +#[cfg(not(unix))] +fn configure_process_group(_command: &mut Command) {} + +struct ActiveProcessGroup { + pgid: ProcessGroupId, + finished: bool, +} + +impl ActiveProcessGroup { + fn new(pgid: ProcessGroupId) -> Self { + ACTIVE_PROCESS_GROUPS.lock().expect("active process group lock poisoned").insert(pgid); + Self { pgid, finished: false } + } + + fn finish(&mut self) { + terminate_process_group(self.pgid); + self.finished = true; + unregister_active_process_group(self.pgid); + } +} + +impl Drop for ActiveProcessGroup { + fn drop(&mut self) { + unregister_active_process_group(self.pgid); + if !self.finished { + terminate_process_group(self.pgid); + } + } +} + +fn unregister_active_process_group(pgid: ProcessGroupId) { + ACTIVE_PROCESS_GROUPS.lock().expect("active process group lock poisoned").remove(&pgid); +} + +fn terminate_active_process_groups() { + let pgids = ACTIVE_PROCESS_GROUPS + .lock() + .expect("active process group lock poisoned") + .iter() + .copied() + .collect::>(); + for pgid in pgids { + terminate_process_group(pgid); + } +} + +#[cfg(unix)] +fn terminate_process_group(pgid: ProcessGroupId) { + if matches!(signal_process_group(pgid, libc::SIGINT), Ok(true)) { + std::thread::sleep(PROCESS_GROUP_GRACE); + let _ = signal_process_group(pgid, libc::SIGKILL); + } +} + +#[cfg(not(unix))] +fn terminate_process_group(_pgid: ProcessGroupId) {} + +#[cfg(unix)] +fn signal_process_group(pgid: ProcessGroupId, signal: libc::c_int) -> std::io::Result { + // SAFETY: negative pid targets the process group created for the child process. + let rc = unsafe { libc::kill(-pgid, signal) }; + if rc == 0 { + Ok(true) + } else { + let err = std::io::Error::last_os_error(); + if err.raw_os_error() == Some(libc::ESRCH) { Ok(false) } else { Err(err) } + } +} + +fn command_display(command: &Command) -> String { + let mut parts = vec![command.get_program().to_string_lossy().to_string()]; + parts.extend(command.get_args().map(|arg| arg.to_string_lossy().to_string())); + parts.join(" ") +} + +fn dir_has_entries(path: &Path) -> Result { + if !path.exists() { + return Ok(false); + } + Ok(fs::read_dir(path)?.next().is_some()) +} + +fn copy_if_exists(src: &Path, dest: &Path) -> Result<()> { + if src.exists() { + copy_path(src, dest)?; + } + Ok(()) +} + +fn copy_path(src: &Path, dest: &Path) -> Result<()> { + if src.is_dir() { + copy_dir(src, dest) + } else { + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent)?; + } + fs::copy(src, dest) + .wrap_err_with(|| format!("failed to copy {} to {}", src.display(), dest.display()))?; + Ok(()) + } +} + +fn copy_dir(src: &Path, dest: &Path) -> Result<()> { + if dest.exists() { + fs::remove_dir_all(dest)?; + } + fs::create_dir_all(dest)?; + copy_dir_contents(src, dest) +} + +fn copy_dir_contents(src: &Path, dest: &Path) -> Result<()> { + fs::create_dir_all(dest)?; + let mut entries = fs::read_dir(src) + .wrap_err_with(|| format!("failed to read {}", src.display()))? + .collect::>>()?; + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let src_path = entry.path(); + let dest_path = dest.join(entry.file_name()); + copy_path(&src_path, &dest_path)?; + } + Ok(()) +} + +fn copy_analysis_logs(src: &Path, dest: &Path) -> Result<()> { + fs::create_dir_all(dest)?; + let mut entries = fs::read_dir(src) + .wrap_err_with(|| format!("failed to read {}", src.display()))? + .collect::>>()?; + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let src_path = entry.path(); + let file_name = entry.file_name(); + let dest_path = dest.join(&file_name); + if src_path.is_dir() { + copy_analysis_logs(&src_path, &dest_path)?; + continue; + } + if is_showmap_log(&file_name) { + continue; + } + copy_path(&src_path, &dest_path)?; + } + Ok(()) +} + +fn is_showmap_log(file_name: &OsStr) -> bool { + let name = file_name.to_string_lossy(); + name == "foundry_showmap.log" || name.ends_with("_showmap.log") +} + +fn find_named(root: &Path, name: &str) -> Result> { + let mut out = Vec::new(); + find_named_inner(root, OsStr::new(name), &mut out)?; + out.sort(); + Ok(out) +} + +fn find_named_inner(root: &Path, name: &OsStr, out: &mut Vec) -> Result<()> { + if !root.exists() { + return Ok(()); + } + for entry in fs::read_dir(root)? { + let entry = entry?; + let path = entry.path(); + if path.file_name() == Some(name) { + out.push(path.clone()); + } + if path.is_dir() { + find_named_inner(&path, name, out)?; + } + } + Ok(()) +} + +fn find_lcov_like(root: &Path, out: &mut Vec) -> Result<()> { + if !root.exists() { + return Ok(()); + } + for entry in fs::read_dir(root)? { + let entry = entry?; + let path = entry.path(); + let name = entry.file_name().to_string_lossy().to_ascii_lowercase(); + if is_lcov_like_name(&name) { + out.push(path.clone()); + } else if path.is_dir() { + find_lcov_like(&path, out)?; + } + } + Ok(()) +} + +fn is_lcov_like_name(name: &str) -> bool { + name.contains("coverage-diff") + || name.contains("coverage_diff") + || name.split(|ch: char| !ch.is_ascii_alphanumeric()).any(|part| part == "lcov") +} + +fn list_relative_files(root: &Path) -> Result> { + let mut files = Vec::new(); + list_relative_files_inner(root, root, &mut files)?; + files.sort(); + Ok(files) +} + +fn list_relative_files_inner(root: &Path, current: &Path, files: &mut Vec) -> Result<()> { + if !current.exists() { + return Ok(()); + } + for entry in fs::read_dir(current)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + list_relative_files_inner(root, &path, files)?; + } else { + files.push(path.strip_prefix(root)?.display().to_string()); + } + } + Ok(()) +} + +fn sanitize_label(value: &str) -> String { + value + .chars() + .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' }) + .collect::() + .trim_matches('-') + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn base_cli() -> Cli { + Cli { + scfuzzbench_repo: DEFAULT_SCFUZZBENCH_REPO.to_string(), + scfuzzbench_ref: DEFAULT_SCFUZZBENCH_REF.to_string(), + target_repo: "https://github.com/example/target.git".to_string(), + target_ref: "main".to_string(), + benchmark_type: BenchmarkType::Property, + timeout_seconds: 60, + workers: None, + output_dir: PathBuf::from("out"), + foundry_bin: None, + foundry_ref: None, + foundry_repo: DEFAULT_FOUNDRY_REPO.to_string(), + foundry_test_args: None, + properties_path: None, + force: false, + } + } + + fn temp_dirs() -> (tempfile::TempDir, Dirs) { + let temp = tempfile::tempdir().expect("failed to create temp dir"); + let dirs = Dirs::new(temp.path().join("run")); + dirs.create().expect("failed to create scfuzzbench dirs"); + (temp, dirs) + } + + fn write_differential_coverage_inputs(dirs: &Dirs, manifest: serde_json::Value, relcov: &str) { + fs::write( + dirs.data.join("showmap_campaign_manifest.json"), + serde_json::to_vec_pretty(&manifest).expect("failed to serialize manifest"), + ) + .expect("failed to write manifest"); + fs::write( + dirs.data.join("differential_coverage_relscores.csv"), + "approach,score\nfoundry,1\n", + ) + .expect("failed to write relscores"); + fs::write(dirs.data.join("differential_coverage_relcov.csv"), relcov) + .expect("failed to write relcov"); + } + + #[test] + fn validates_repo_relative_properties_path() { + let mut cli = base_cli(); + cli.properties_path = Some(PathBuf::from("test/recon/Properties.sol")); + validate_options(&cli).expect("repo-relative properties path should be valid"); + } + + #[cfg(unix)] + #[test] + fn rejects_absolute_properties_path() { + let mut cli = base_cli(); + cli.properties_path = Some(PathBuf::from("/tmp/Properties.sol")); + let err = validate_options(&cli).expect_err("absolute properties path should fail"); + assert!(err.to_string().contains("must be relative")); + } + + #[test] + fn rejects_parent_dir_properties_path() { + let mut cli = base_cli(); + cli.properties_path = Some(PathBuf::from("../Properties.sol")); + let err = validate_options(&cli).expect_err("escaping properties path should fail"); + assert!(err.to_string().contains("must not escape")); + } + + #[cfg(unix)] + #[test] + fn non_zero_campaign_status_is_an_error() { + let status = + Command::new("sh").arg("-c").arg("exit 7").status().expect("failed to execute shell"); + let err = ensure_campaign_success(&status).expect_err("campaign failure should fail"); + assert!(err.to_string().contains("campaign failed")); + } + + #[test] + fn installed_sed_shim_accepts_gnu_no_backup_in_place_form() { + let temp = tempfile::tempdir().expect("failed to create temp dir"); + install_sed_shim(temp.path()).expect("failed to install sed shim"); + let shim = temp.path().join("sed"); + if !shim.exists() { + return; + } + + let input = temp.path().join("input"); + fs::write(&input, "foo\n").expect("failed to write sed input"); + let status = Command::new(&shim) + .args(["-i", "s/foo/bar/"]) + .arg(&input) + .status() + .expect("failed to run sed shim"); + assert!(status.success()); + assert_eq!(fs::read_to_string(&input).expect("failed to read sed output"), "bar\n"); + } + + #[test] + fn validates_nested_showmap_approaches_with_header_only_single_relcov() { + let (_temp, dirs) = temp_dirs(); + write_differential_coverage_inputs( + &dirs, + json!({ + "raw_trials": 1, + "campaigns": { + "combined": { + "approaches": { + "foundry": { + "trials": 1, + "covered_edges": 12 + } + } + } + } + }), + "approach,relative_coverage\n", + ); + + validate_differential_coverage(&dirs) + .expect("single-approach header-only relcov should be accepted"); + } + + #[test] + fn validates_multi_approach_relcov_rows() { + let (_temp, dirs) = temp_dirs(); + let manifest = json!({ + "raw_trials": 2, + "campaigns": { + "combined": { + "approaches": { + "foundry": { + "trials": 1, + "covered_edges": 12 + }, + "echidna": { + "trials": 1, + "covered_edges": 10 + } + } + } + } + }); + write_differential_coverage_inputs(&dirs, manifest.clone(), "approach,relative_coverage\n"); + + let err = validate_differential_coverage(&dirs) + .expect_err("multi-approach relcov should require data rows"); + assert!(err.to_string().contains("has no data rows")); + + write_differential_coverage_inputs( + &dirs, + manifest, + "approach,relative_coverage\nfoundry,1.0\n", + ); + validate_differential_coverage(&dirs) + .expect("multi-approach relcov with data rows should be accepted"); + } + + #[test] + fn does_not_collect_relcov_as_lcov_output() { + let (_temp, dirs) = temp_dirs(); + fs::write(dirs.data.join("differential_coverage_relcov.csv"), "header\n") + .expect("failed to write relcov"); + fs::write(dirs.data.join("coverage_diff.csv"), "diff\n") + .expect("failed to write coverage diff"); + fs::create_dir_all(dirs.raw.join("nested")).expect("failed to create raw nested dir"); + fs::write(dirs.raw.join("nested/run-lcov.info"), "lcov\n") + .expect("failed to write lcov file"); + + let dest = dirs.artifacts.join("lcov-diff"); + collect_lcov_outputs(&dirs, &dest).expect("failed to collect lcov outputs"); + + assert!(dest.join("coverage_diff.csv").exists()); + assert!(dest.join("run-lcov.info").exists()); + assert!(!dest.join("differential_coverage_relcov.csv").exists()); + } +} diff --git a/benches/src/symbolic.rs b/benches/src/symbolic.rs new file mode 100644 index 0000000000000..4a1f353cffac1 --- /dev/null +++ b/benches/src/symbolic.rs @@ -0,0 +1,549 @@ +//! Formal symbolic benchmark suite definitions and result parsing. + +use crate::results::SymbolicBenchmarkSummary; +use eyre::{Result, WrapErr}; +use serde::Serialize; +use serde_json::Value; +use std::{ + fs, + path::{Path, PathBuf}, +}; + +const FARCASTER_PATH: &str = "test/FarcasterNativeSymbolic.t.sol"; +const FARCASTER_TEST: &str = include_str!("../fixtures/symbolic/FarcasterNativeSymbolic.t.sol"); +const PREFIX: &str = + "FOUNDRY_DYNAMIC_TEST_LINKING=false FOUNDRY_LINT_LINT_ON_BUILD=false FOUNDRY_ISOLATE=false"; +const SOLADY_MATCH: &str = "check_(SaturatingAddEquivalence|SaturatingMulEquivalence|HasDuplicateHashmapCapacityTrickEquivalence|IsPermit2AndValueIsNotInfinityTrickEquivalence|IsNotUint256MaxTrickEquivalence|DelayRestriction|OperationStateDifferentialTrick|CarryBoundsTrick|SafeCastInt256ToIntTrickEquivalence|P256Normalized|AuxPackEquivalence|EcrecoverTrickEquivalence|EcrecoverLoopTrick)"; + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Fixture { + Solady, + Angstrom, + Farcaster, + Generic, +} + +impl Fixture { + pub const fn identify(org: &str, repo: &str) -> Self { + if org.eq_ignore_ascii_case("vectorized") && repo.eq_ignore_ascii_case("solady") { + Self::Solady + } else if org.eq_ignore_ascii_case("sorellalabs") && repo.eq_ignore_ascii_case("angstrom") { + Self::Angstrom + } else if org.eq_ignore_ascii_case("farcasterxyz") && repo.eq_ignore_ascii_case("contracts") + { + Self::Farcaster + } else { + Self::Generic + } + } + + pub fn build_command(self) -> String { + format!( + "{PREFIX} {}", + match self { + Self::Angstrom => "forge build --root contracts", + _ => "forge build", + } + ) + } + + pub fn test_command(self) -> String { + match self { + Self::Solady => { + format!("{PREFIX} forge test --symbolic --json --match-test '{SOLADY_MATCH}'") + } + Self::Angstrom => format!( + "{PREFIX} forge test --root contracts --symbolic --json --symbolic-timeout 5 --match-path test/libraries/X128MathLib.t.sol --match-test check_matchesSolady_fullMulX128" + ), + Self::Farcaster => format!( + "{PREFIX} forge test --symbolic --json --symbolic-timeout 5 --match-path '{FARCASTER_PATH}' --match-test 'check_'" + ), + Self::Generic => format!("{PREFIX} forge test --symbolic --json"), + } + } +} + +/// Error-safe installation of the Farcaster benchmark overlay. +pub struct Overlay { + path: Option, +} + +impl Overlay { + pub fn install(root: &Path, fixture: Fixture) -> Result { + if !matches!(fixture, Fixture::Farcaster) { + return Ok(Self { path: None }); + } + let path = root.join(FARCASTER_PATH); + if path.exists() { + eyre::bail!("refusing to overwrite existing {}", path.display()); + } + if let Err(write_err) = fs::write(&path, FARCASTER_TEST) { + if let Err(cleanup_err) = fs::remove_file(&path) + && cleanup_err.kind() != std::io::ErrorKind::NotFound + { + eyre::bail!( + "failed to install Farcaster symbolic fixture: {write_err}; failed to remove partially written {}: {cleanup_err}", + path.display() + ); + } + return Err(write_err).wrap_err("failed to install Farcaster symbolic fixture"); + } + Ok(Self { path: Some(path) }) + } + + pub fn finish(mut self) -> Result<()> { + self.cleanup() + } + + fn cleanup(&mut self) -> Result<()> { + if let Some(path) = &self.path { + fs::remove_file(path).wrap_err_with(|| { + format!("failed to remove symbolic fixture {}", path.display()) + })?; + self.path = None; + } + Ok(()) + } +} + +impl Drop for Overlay { + fn drop(&mut self) { + let _ = self.cleanup(); + } +} + +#[derive(Clone, Debug, Default, Serialize)] +pub struct Metrics { + pub paths: u64, + pub solver_queries: u64, + pub smt_queries: u64, + pub sat_queries: u64, + pub model_queries: u64, + pub sat_cache_hits: u64, + pub model_cache_hits: u64, + pub heuristic_witnesses: u64, + pub solver_time_ms: u64, + pub smt_input_bytes: Option, + pub smt_max_query_bytes: Option, + pub smt_build_time_ms: Option, + pub smt_max_query_time_ms: Option, + #[serde(skip)] + smt_input_bytes_compat: u64, + #[serde(skip)] + smt_max_query_bytes_compat: u64, + #[serde(skip)] + smt_build_time_ms_compat: u64, + #[serde(skip)] + smt_max_query_time_ms_compat: u64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum OutcomeStatus { + Passed, + Failed, + Incomplete, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct TestOutcome { + pub suite: String, + pub signature: String, + pub status: OutcomeStatus, +} + +impl TestOutcome { + fn identity(&self) -> String { + format!("{}::{}", self.suite, self.signature) + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct ParsedRun { + pub outcomes: Vec, + pub passed: usize, + pub failed: usize, + pub incomplete: usize, + pub metrics: Metrics, +} + +#[derive(Clone, Debug, Serialize)] +pub struct Sample { + pub wall_time_seconds: f64, + pub exit_code: i32, + #[serde(flatten)] + pub run: ParsedRun, +} + +#[derive(Clone, Debug, Serialize)] +pub struct Sidecar { + pub schema: &'static str, + pub schema_version: u32, + pub fixture: FixtureMetadata, + pub samples: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub struct FixtureMetadata { + pub name: Fixture, + pub repository: String, + pub revision: String, + pub build_command: String, + pub test_command: String, +} + +impl Sidecar { + pub fn new( + fixture: Fixture, + repository: &str, + revision: &str, + build_command: &str, + test_command: &str, + samples: Vec, + ) -> Self { + Self { + schema: "foundry:symbolic-benchmark@v1", + schema_version: 1, + fixture: FixtureMetadata { + name: fixture, + repository: repository.to_string(), + revision: revision.to_string(), + build_command: build_command.to_string(), + test_command: test_command.to_string(), + }, + samples, + } + } +} + +pub fn parse(stdout: &[u8]) -> Result { + let json: Value = + serde_json::from_slice(stdout).wrap_err("invalid forge test --json output")?; + let suites = json.as_object().ok_or_else(|| eyre::eyre!("expected JSON object"))?; + let stable = suites + .values() + .filter_map(|s| s.get("test_results").and_then(Value::as_object)) + .flat_map(|r| r.values()) + .any(|r| r.get("symbolic").is_some()); + let mut run = ParsedRun { + outcomes: Vec::new(), + passed: 0, + failed: 0, + incomplete: 0, + metrics: Metrics::default(), + }; + for (suite_name, suite) in suites { + let Some(results) = suite.get("test_results").and_then(Value::as_object) else { continue }; + for (identity, result) in results { + let (stats, status) = if stable { + let Some(symbolic) = result.get("symbolic") else { continue }; + if symbolic.get("schema_version").and_then(Value::as_u64) != Some(1) { + eyre::bail!("unknown or missing symbolic schema_version"); + } + let status = symbolic + .get("status") + .and_then(Value::as_str) + .ok_or_else(|| eyre::eyre!("missing symbolic.status"))?; + let status = match status { + "pass" => OutcomeStatus::Passed, + "fail_counterexample" => OutcomeStatus::Failed, + "incomplete" => OutcomeStatus::Incomplete, + _ => { + eyre::bail!("unknown symbolic.status {status}"); + } + }; + let stats = symbolic + .pointer("/solver/stats") + .filter(|stats| stats.is_object()) + .ok_or_else(|| eyre::eyre!("missing or invalid symbolic.solver.stats"))?; + (stats, status) + } else { + let Some(stats) = result.pointer("/kind/Symbolic") else { continue }; + let status = result.get("status").and_then(Value::as_str).unwrap_or_default(); + let reason = result.get("reason").and_then(Value::as_str).unwrap_or_default(); + let status = if status == "Success" { + OutcomeStatus::Passed + } else if reason.contains("incomplete symbolic execution") { + OutcomeStatus::Incomplete + } else { + OutcomeStatus::Failed + }; + (stats, status) + }; + run.outcomes.push(TestOutcome { + suite: suite_name.clone(), + signature: identity.clone(), + status, + }); + add_metrics(&mut run.metrics, stats, stable, run.outcomes.len() == 1)?; + } + } + if run.outcomes.is_empty() { + eyre::bail!("forge symbolic benchmark produced no symbolic test results"); + } + run.outcomes.sort_by_key(TestOutcome::identity); + run.passed = + run.outcomes.iter().filter(|outcome| outcome.status == OutcomeStatus::Passed).count(); + run.failed = + run.outcomes.iter().filter(|outcome| outcome.status == OutcomeStatus::Failed).count(); + run.incomplete = + run.outcomes.iter().filter(|outcome| outcome.status == OutcomeStatus::Incomplete).count(); + Ok(run) +} + +fn add_metrics(out: &mut Metrics, stats: &Value, stable: bool, first: bool) -> Result<()> { + macro_rules! add { + ($field:ident) => { + out.$field += required(stats, stringify!($field), stable)?; + }; + } + add!(paths); + add!(solver_queries); + add!(smt_queries); + add!(sat_queries); + add!(model_queries); + add!(sat_cache_hits); + add!(model_cache_hits); + add!(heuristic_witnesses); + add!(solver_time_ms); + optional_add( + &mut out.smt_input_bytes, + &mut out.smt_input_bytes_compat, + stats, + "smt_input_bytes", + first, + )?; + optional_add( + &mut out.smt_build_time_ms, + &mut out.smt_build_time_ms_compat, + stats, + "smt_build_time_ms", + first, + )?; + optional_max( + &mut out.smt_max_query_bytes, + &mut out.smt_max_query_bytes_compat, + stats, + "smt_max_query_bytes", + first, + )?; + optional_max( + &mut out.smt_max_query_time_ms, + &mut out.smt_max_query_time_ms_compat, + stats, + "smt_max_query_time_ms", + first, + )?; + Ok(()) +} + +fn required(v: &Value, key: &str, strict: bool) -> Result { + match v.get(key) { + Some(v) => v.as_u64().ok_or_else(|| eyre::eyre!("invalid metric {key}")), + None if strict => Err(eyre::eyre!("missing metric {key}")), + None => Ok(0), + } +} + +fn optional_add( + out: &mut Option, + compatibility: &mut u64, + v: &Value, + key: &str, + first: bool, +) -> Result<()> { + let value = v + .get(key) + .map(|n| n.as_u64().ok_or_else(|| eyre::eyre!("invalid metric {key}"))) + .transpose()?; + *compatibility += value.unwrap_or_default(); + *out = if first { value } else { (*out).zip(value).map(|(old, value)| old + value) }; + Ok(()) +} + +fn optional_max( + out: &mut Option, + compatibility: &mut u64, + v: &Value, + key: &str, + first: bool, +) -> Result<()> { + let value = v + .get(key) + .map(|n| n.as_u64().ok_or_else(|| eyre::eyre!("invalid metric {key}"))) + .transpose()?; + *compatibility = (*compatibility).max(value.unwrap_or_default()); + *out = if first { value } else { (*out).zip(value).map(|(old, value)| old.max(value)) }; + Ok(()) +} + +pub const fn compatibility(run: &ParsedRun) -> SymbolicBenchmarkSummary { + let m = &run.metrics; + SymbolicBenchmarkSummary { + tests: run.outcomes.len(), + passed: run.passed, + failed: run.failed, + incomplete: run.incomplete, + paths: m.paths, + solver_queries: m.solver_queries, + smt_queries: m.smt_queries, + sat_queries: m.sat_queries, + model_queries: m.model_queries, + sat_cache_hits: m.sat_cache_hits, + model_cache_hits: m.model_cache_hits, + heuristic_witnesses: m.heuristic_witnesses, + solver_time_ms: m.solver_time_ms, + smt_input_bytes: m.smt_input_bytes_compat, + smt_max_query_bytes: m.smt_max_query_bytes_compat, + smt_build_time_ms: m.smt_build_time_ms_compat, + smt_max_query_time_ms: m.smt_max_query_time_ms_compat, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn stable(status: &str, schema: u64) -> Vec { + serde_json::to_vec(&serde_json::json!({"test/FarcasterNativeSymbolic.t.sol:FarcasterNativeSymbolicTest":{"test_results":{ + "check_migrateOnlyMigrator(uint24,address,address,address,uint40)":{"symbolic":{"schema_version":schema,"status":status,"bounds":{},"solver":{"name":"z3","command":null,"portfolio":[],"stats":{"paths":1,"solver_queries":2,"smt_queries":3,"sat_queries":4,"model_queries":5,"sat_cache_hits":6,"model_cache_hits":7,"heuristic_witnesses":8,"solver_time_ms":9,"smt_input_bytes":10,"smt_max_query_bytes":11,"smt_build_time_ms":12,"smt_max_query_time_ms":13}}}}, + "check_setMigratorOnlyOwner(uint24,address,address,address,address)":{"symbolic":{"schema_version":schema,"status":"incomplete","bounds":{},"solver":{"name":"z3","command":null,"portfolio":[],"stats":{"paths":10,"solver_queries":20,"smt_queries":30,"sat_queries":40,"model_queries":50,"sat_cache_hits":60,"model_cache_hits":70,"heuristic_witnesses":80,"solver_time_ms":90}}}} + }}})).unwrap() + } + + #[test] + fn stable_parser_validates_statuses_and_missing_optional_metrics() { + let run = parse(&stable("pass", 1)).unwrap(); + assert_eq!((run.passed, run.failed, run.incomplete), (1, 0, 1)); + assert_eq!(run.metrics.paths, 11); + assert_eq!(run.metrics.solver_time_ms, 99); + assert_eq!(run.metrics.smt_max_query_bytes, None); + assert_eq!(run.metrics.smt_input_bytes, None); + assert_eq!(run.metrics.smt_build_time_ms, None); + assert_eq!(run.metrics.smt_max_query_time_ms, None); + assert_eq!( + run.outcomes[0].suite, + "test/FarcasterNativeSymbolic.t.sol:FarcasterNativeSymbolicTest" + ); + let compatibility = compatibility(&run); + assert_eq!(compatibility.tests, 2); + assert_eq!(compatibility.smt_input_bytes, 10); + } + + #[test] + fn rejects_unknown_stable_schema() { + assert!(parse(&stable("pass", 2)).is_err()); + } + + #[test] + fn aggregates_optional_metrics_only_when_every_test_reports_them() { + let first = serde_json::json!({ + "paths": 1, "solver_queries": 1, "smt_queries": 1, "sat_queries": 1, + "model_queries": 1, "sat_cache_hits": 1, "model_cache_hits": 1, + "heuristic_witnesses": 1, "solver_time_ms": 1, "smt_input_bytes": 10, + "smt_max_query_bytes": 8, "smt_build_time_ms": 2, "smt_max_query_time_ms": 4 + }); + let second = serde_json::json!({ + "paths": 1, "solver_queries": 1, "smt_queries": 1, "sat_queries": 1, + "model_queries": 1, "sat_cache_hits": 1, "model_cache_hits": 1, + "heuristic_witnesses": 1, "solver_time_ms": 1, "smt_input_bytes": 20, + "smt_max_query_bytes": 7, "smt_build_time_ms": 3, "smt_max_query_time_ms": 9 + }); + let mut metrics = Metrics::default(); + add_metrics(&mut metrics, &first, true, true).unwrap(); + add_metrics(&mut metrics, &second, true, false).unwrap(); + assert_eq!(metrics.smt_input_bytes, Some(30)); + assert_eq!(metrics.smt_build_time_ms, Some(5)); + assert_eq!(metrics.smt_max_query_bytes, Some(8)); + assert_eq!(metrics.smt_max_query_time_ms, Some(9)); + } + + #[test] + fn parses_whole_output_legacy_results() { + let stats = serde_json::json!({"paths": 1, "solver_queries": 2}); + let stdout = serde_json::to_vec(&serde_json::json!({"test/FarcasterNativeSymbolic.t.sol:FarcasterNativeSymbolicTest":{"test_results":{ + "check_migrateOnlyMigrator(uint24,address,address,address,uint40)":{ + "kind":{"Symbolic":stats},"status":"Success" + }, + "check_setMigratorOnlyOwner(uint24,address,address,address,address)":{ + "kind":{"Symbolic":stats},"status":"Failure", + "reason":"incomplete symbolic execution (Timeout)" + } + }}})) + .unwrap(); + let run = parse(&stdout).unwrap(); + assert_eq!((run.passed, run.failed, run.incomplete), (1, 0, 1)); + assert_eq!(run.metrics.paths, 2); + } + + #[test] + fn unknown_repository_uses_generic_symbolic_command() { + let fixture = Fixture::identify("example", "contracts"); + assert!(matches!(fixture, Fixture::Generic)); + assert_eq!(fixture.test_command(), format!("{PREFIX} forge test --symbolic --json")); + } + + #[test] + fn metrics_serialize_the_complete_v1_counter_set() { + let value = serde_json::to_value(Metrics::default()).unwrap(); + let keys = value + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect::>(); + assert_eq!( + keys, + std::collections::BTreeSet::from([ + "heuristic_witnesses", + "model_cache_hits", + "model_queries", + "paths", + "sat_cache_hits", + "sat_queries", + "smt_build_time_ms", + "smt_input_bytes", + "smt_max_query_bytes", + "smt_max_query_time_ms", + "smt_queries", + "solver_queries", + "solver_time_ms", + ]) + ); + } + + #[test] + fn sidecar_retains_samples_and_revision() { + let first = parse(&stable("pass", 1)).unwrap(); + let second = parse(&stable("fail_counterexample", 1)).unwrap(); + let samples = [first, second] + .into_iter() + .map(|run| Sample { wall_time_seconds: 1.0, exit_code: 0, run }) + .collect(); + let sidecar = Sidecar::new( + Fixture::Farcaster, + "farcasterxyz/contracts", + "test-revision", + "forge build", + "forge test --symbolic --json", + samples, + ); + assert_eq!(sidecar.fixture.revision, "test-revision"); + assert_eq!(sidecar.samples.len(), 2); + } + + #[test] + fn overlay_is_explicitly_removed_and_existing_file_is_preserved() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir(dir.path().join("test")).unwrap(); + let path = dir.path().join(FARCASTER_PATH); + let overlay = Overlay::install(dir.path(), Fixture::Farcaster).unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), FARCASTER_TEST); + overlay.finish().unwrap(); + assert!(!path.exists()); + fs::write(&path, "unexpected").unwrap(); + assert!(Overlay::install(dir.path(), Fixture::Farcaster).is_err()); + assert_eq!(fs::read_to_string(path).unwrap(), "unexpected"); + } +} diff --git a/clippy.toml b/clippy.toml index 9d0da8940971f..731817759020c 100644 --- a/clippy.toml +++ b/clippy.toml @@ -1,5 +1,3 @@ -msrv = "1.88" - # `bytes::Bytes` is included by default and `alloy_primitives::Bytes` is a wrapper around it, # so it is safe to ignore it as well. ignore-interior-mutability = ["bytes::Bytes", "alloy_primitives::Bytes"] diff --git a/crates/anvil/Cargo.toml b/crates/anvil/Cargo.toml index 3ad68fe3b4fb7..e09d760cd845c 100644 --- a/crates/anvil/Cargo.toml +++ b/crates/anvil/Cargo.toml @@ -20,29 +20,61 @@ required-features = ["cli"] [dependencies] # foundry internal -anvil-core = { path = "core" } +anvil-core = { path = "core", default-features = false } anvil-rpc = { path = "rpc" } anvil-server = { path = "server" } -foundry-cli.workspace = true +foundry-cli = { workspace = true, optional = true } foundry-common.workspace = true foundry-config.workspace = true foundry-evm.workspace = true -foundry-evm-core.workspace = true +foundry-evm-networks.workspace = true +alloy-monad-evm = { workspace = true, optional = true } +monad-revm = { workspace = true, optional = true } +foundry-primitives = { workspace = true, default-features = false } +tempo-hardfork.workspace = true +tempo-primitives.workspace = true +tempo-precompiles.workspace = true +tempo-evm.workspace = true +tempo-revm.workspace = true + +# Base +base-common-chains = { workspace = true, optional = true, features = ["std"] } +base-common-consensus = { workspace = true, optional = true, features = ["evm", "k256", "serde", "std"] } +base-common-evm = { workspace = true, optional = true, features = [ + "blst", + "c-kzg", + "memory_limit", + "optional_block_gas_limit", + "optional_eip3607", + "optional_no_base_fee", + "serde", + "std", +] } +base-common-genesis = { workspace = true, optional = true, features = ["std"] } +base-common-precompiles = { workspace = true, optional = true, features = ["std"] } +base-common-rpc-types = { workspace = true, optional = true, features = ["eip8130", "serde", "std"] } +base-consensus-upgrades = { workspace = true, optional = true, features = ["std"] } +base-execution-eip8130 = { workspace = true, optional = true } +base-protocol = { workspace = true, optional = true, features = ["std"] } # alloy -alloy-evm.workspace = true -alloy-op-evm.workspace = true +alloy-evm = { workspace = true, features = ["call-util"] } +alloy-op-evm = { workspace = true, optional = true } alloy-primitives = { workspace = true, features = ["serde"] } alloy-consensus = { workspace = true, features = ["k256", "kzg"] } alloy-contract = { workspace = true, features = ["pubsub"] } alloy-network.workspace = true alloy-eips.workspace = true +alloy-hardforks.workspace = true alloy-rlp.workspace = true alloy-signer = { workspace = true, features = ["eip712"] } alloy-signer-local = { workspace = true, features = ["mnemonic"] } alloy-sol-types = { workspace = true, features = ["std"] } alloy-dyn-abi = { workspace = true, features = ["std", "eip712"] } -alloy-rpc-types = { workspace = true, features = ["anvil", "trace", "txpool"] } +alloy-rpc-types = { workspace = true, features = ["anvil", "debug", "trace", "txpool"] } +alloy-rpc-types-beacon.workspace = true +alloy-rpc-types-eth.workspace = true +alloy-rpc-types-mev.workspace = true alloy-serde.workspace = true alloy-provider = { workspace = true, features = [ "reqwest", @@ -55,9 +87,8 @@ alloy-transport.workspace = true alloy-chains.workspace = true alloy-genesis.workspace = true alloy-trie.workspace = true -alloy-hardforks.workspace = true -alloy-op-hardforks.workspace = true -op-alloy-consensus = { workspace = true, features = ["serde"] } +op-alloy-consensus = { workspace = true, features = ["serde"], optional = true } +op-alloy-rpc-types = { workspace = true, optional = true } # revm revm = { workspace = true, features = [ @@ -67,7 +98,7 @@ revm = { workspace = true, features = [ "c-kzg", ] } revm-inspectors.workspace = true -op-revm.workspace = true +op-revm = { workspace = true, optional = true } # axum related axum.workspace = true @@ -91,8 +122,10 @@ thiserror.workspace = true yansi.workspace = true tempfile.workspace = true itertools.workspace = true +imbl.workspace = true rand_08.workspace = true eyre.workspace = true +ethereum_ssz.workspace = true # cli clap = { version = "4", features = [ @@ -100,29 +133,69 @@ clap = { version = "4", features = [ "env", "wrap_help", ], optional = true } -clap_complete = { version = "4", optional = true } +clap_complete = { workspace = true, optional = true } chrono.workspace = true ctrlc = { version = "3", optional = true } fdlimit = { version = "0.3", optional = true } -clap_complete_fig = "4" [dev-dependencies] alloy-provider = { workspace = true, features = ["txpool-api"] } alloy-pubsub.workspace = true rand.workspace = true +reqwest.workspace = true foundry-test-utils.workspace = true tokio = { workspace = true, features = ["full"] } -op-alloy-rpc-types.workspace = true +tempo-alloy.workspace = true [features] -default = ["cli", "jemalloc"] -asm-keccak = ["alloy-primitives/asm-keccak"] -jemalloc = ["foundry-cli/jemalloc"] -mimalloc = ["foundry-cli/mimalloc"] -tracy-allocator = ["foundry-cli/tracy-allocator"] +default = ["cli", "jemalloc", "asm-keccak", "optimism", "js-tracer"] +monad = [ + "dep:alloy-monad-evm", + "dep:monad-revm", + "foundry-config/monad", + "foundry-evm/monad", + "foundry-evm-networks/monad", + "foundry-cli?/monad", +] +base = [ + "dep:base-common-chains", + "dep:base-common-consensus", + "dep:base-common-evm", + "dep:base-common-genesis", + "dep:base-common-precompiles", + "dep:base-common-rpc-types", + "dep:base-consensus-upgrades", + "dep:base-execution-eip8130", + "dep:base-protocol", + "dep:op-alloy-consensus", + "dep:op-revm", + "anvil-core/base", + "foundry-primitives/base", + "foundry-common/base", + "foundry-config/base", + "foundry-evm/base", + "foundry-evm-networks/base", + "foundry-cli?/base", +] +optimism = [ + "dep:op-alloy-consensus", + "dep:op-alloy-rpc-types", + "dep:alloy-op-evm", + "dep:op-revm", + "anvil-core/optimism", + "foundry-primitives/optimism", + "foundry-common/optimism", + "foundry-evm/optimism", + "foundry-cli?/optimism", +] +asm-keccak = ["alloy-primitives/asm-keccak", "revm/asm-keccak"] +jemalloc = ["foundry-cli?/jemalloc"] +mimalloc = ["foundry-cli?/mimalloc"] +tracy-allocator = ["foundry-cli?/tracy-allocator"] cli = ["tokio/full", "cmd"] cmd = [ + "dep:foundry-cli", "clap", "clap_complete", "dep:fdlimit", @@ -130,3 +203,4 @@ cmd = [ "anvil-server/clap", "tokio/signal", ] +js-tracer = ["revm-inspectors/js-tracer"] diff --git a/crates/anvil/core/Cargo.toml b/crates/anvil/core/Cargo.toml index 0b64c630bda9b..b842a4938e25d 100644 --- a/crates/anvil/core/Cargo.toml +++ b/crates/anvil/core/Cargo.toml @@ -15,23 +15,24 @@ workspace = true [dependencies] foundry-common.workspace = true foundry-evm.workspace = true +foundry-primitives = { workspace = true, default-features = false } revm = { workspace = true, default-features = false, features = [ "std", "serde", "memory_limit", "c-kzg", ] } -op-revm.workspace = true alloy-primitives = { workspace = true, features = ["serde", "rlp"] } alloy-rpc-types = { workspace = true, features = ["anvil", "trace"] } +alloy-rpc-types-mev.workspace = true alloy-serde.workspace = true alloy-rlp.workspace = true alloy-eips.workspace = true +alloy-eip5792.workspace = true alloy-consensus = { workspace = true, features = ["k256", "kzg"] } -alloy-dyn-abi = { workspace = true, features = ["std", "eip712"] } -op-alloy-consensus = { workspace = true, features = ["serde"] } alloy-network.workspace = true +alloy-dyn-abi = { workspace = true, features = ["std", "eip712"] } serde.workspace = true serde_json.workspace = true bytes.workspace = true @@ -39,3 +40,12 @@ bytes.workspace = true # misc rand.workspace = true thiserror.workspace = true + +[features] +default = ["optimism"] +base = [] +optimism = [ + "foundry-primitives/optimism", + "foundry-common/optimism", + "foundry-evm/optimism", +] diff --git a/crates/anvil/core/src/eth/block.rs b/crates/anvil/core/src/eth/block.rs index 6787fe37bc458..5a5db79c7e19f 100644 --- a/crates/anvil/core/src/eth/block.rs +++ b/crates/anvil/core/src/eth/block.rs @@ -1,131 +1,134 @@ -use super::transaction::{TransactionInfo, TypedReceipt}; -use alloy_consensus::{EMPTY_OMMER_ROOT_HASH, Header, proofs::calculate_transaction_root}; -use alloy_primitives::{Address, B64, B256, Bloom, Bytes, U256}; -use alloy_rlp::{RlpDecodable, RlpEncodable}; +use super::transaction::TransactionInfo; +use crate::eth::transaction::MaybeImpersonatedTransaction; +use alloy_consensus::{ + BlockBody, EMPTY_OMMER_ROOT_HASH, Typed2718, proofs::ordered_trie_root_with_encoder, + transaction::RlpEcdsaEncodableTx, +}; +use alloy_eips::eip2718::Encodable2718; +use alloy_network::Network; +use foundry_primitives::{FoundryHeader, FoundryTxEnvelope}; -// Type alias to optionally support impersonated transactions -type Transaction = crate::eth::transaction::MaybeImpersonatedTransaction; +/// Type alias for a block containing potentially impersonated transactions. +pub type Block = + alloy_consensus::Block, H>; -/// Container type that gathers all block data +/// Container type that gathers all block data, generic over a [`Network`]. #[derive(Clone, Debug)] -pub struct BlockInfo { - pub block: Block, +pub struct BlockInfo { + pub block: Block, pub transactions: Vec, - pub receipts: Vec, + pub receipts: Vec, } -/// An Ethereum Block -#[derive(Clone, Debug, PartialEq, Eq, RlpEncodable, RlpDecodable)] -pub struct Block { - pub header: Header, - pub transactions: Vec, - pub ommers: Vec
, +/// A transaction that can be encoded for inclusion in a block body. +pub trait EncodableBlockTransaction: Encodable2718 { + /// Encodes the transaction in its canonical block-body form. + fn encode_2718_for_block(&self, out: &mut dyn bytes::BufMut); } -impl Block { - /// Creates a new block. - /// - /// Note: if the `impersonate-tx` feature is enabled this will also accept - /// `MaybeImpersonatedTransaction`. - pub fn new(partial_header: PartialHeader, transactions: impl IntoIterator) -> Self - where - T: Into, - { - let transactions: Vec<_> = transactions.into_iter().map(Into::into).collect(); - let transactions1: Vec = - transactions.clone().into_iter().map(Into::into).collect(); - let transactions_root = calculate_transaction_root(&transactions1); - - Self { - header: Header { - parent_hash: partial_header.parent_hash, - beneficiary: partial_header.beneficiary, - ommers_hash: EMPTY_OMMER_ROOT_HASH, - state_root: partial_header.state_root, - transactions_root, - receipts_root: partial_header.receipts_root, - logs_bloom: partial_header.logs_bloom, - difficulty: partial_header.difficulty, - number: partial_header.number, - gas_limit: partial_header.gas_limit, - gas_used: partial_header.gas_used, - timestamp: partial_header.timestamp, - extra_data: partial_header.extra_data, - mix_hash: partial_header.mix_hash, - withdrawals_root: partial_header.withdrawals_root, - blob_gas_used: partial_header.blob_gas_used, - excess_blob_gas: partial_header.excess_blob_gas, - parent_beacon_block_root: partial_header.parent_beacon_block_root, - nonce: partial_header.nonce, - base_fee_per_gas: partial_header.base_fee, - requests_hash: partial_header.requests_hash, - }, - transactions, - ommers: vec![], +impl EncodableBlockTransaction for FoundryTxEnvelope { + fn encode_2718_for_block(&self, out: &mut dyn bytes::BufMut) { + if let Self::Eip4844(tx) = self { + out.put_u8(self.ty()); + tx.tx().tx().rlp_encode_signed(tx.signature(), out); + } else { + self.encode_2718(out); } } } -/// Partial header definition without ommers hash and transactions root -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct PartialHeader { - pub parent_hash: B256, - pub beneficiary: Address, - pub state_root: B256, - pub receipts_root: B256, - pub logs_bloom: Bloom, - pub difficulty: U256, - pub number: u64, - pub gas_limit: u64, - pub gas_used: u64, - pub timestamp: u64, - pub extra_data: Bytes, - pub mix_hash: B256, - pub blob_gas_used: Option, - pub excess_blob_gas: Option, - pub parent_beacon_block_root: Option, - pub nonce: B64, - pub base_fee: Option, - pub withdrawals_root: Option, - pub requests_hash: Option, +/// Returns a block whose transactions use canonical block-body representations. +pub fn canonical_block(mut block: Block) -> Block { + block.body.transactions = block + .body + .transactions + .into_iter() + .map(|tx| tx.map(FoundryTxEnvelope::into_canonical)) + .collect(); + block } -impl From
for PartialHeader { - fn from(value: Header) -> Self { - Self { - parent_hash: value.parent_hash, - beneficiary: value.beneficiary, - state_root: value.state_root, - receipts_root: value.receipts_root, - logs_bloom: value.logs_bloom, - difficulty: value.difficulty, - number: value.number, - gas_limit: value.gas_limit, - gas_used: value.gas_used, - timestamp: value.timestamp, - extra_data: value.extra_data, - mix_hash: value.mix_hash, - nonce: value.nonce, - base_fee: value.base_fee_per_gas, - blob_gas_used: value.blob_gas_used, - excess_blob_gas: value.excess_blob_gas, - parent_beacon_block_root: value.parent_beacon_block_root, - requests_hash: value.requests_hash, - withdrawals_root: value.withdrawals_root, - } - } +/// Helper function to create a new block with Header and Anvil transactions, generic over the +/// transaction envelope with a default of [`FoundryTxEnvelope`]. +/// +/// Note: if the `impersonate-tx` feature is enabled this will also accept +/// `MaybeImpersonatedTransaction`. +pub fn create_block( + mut header: FoundryHeader, + transactions: impl IntoIterator, +) -> Block +where + Tx: EncodableBlockTransaction, + T: Into>, +{ + let transactions: Vec<_> = transactions.into_iter().map(Into::into).collect(); + let transactions_root = ordered_trie_root_with_encoder(&transactions, |tx, out| { + tx.as_ref().encode_2718_for_block(out) + }); + + header.set_transactions_root(transactions_root); + header.set_ommers_hash(EMPTY_OMMER_ROOT_HASH); + + let body = BlockBody { transactions, ommers: Vec::new(), withdrawals: None }; + Block::new(header, body) } #[cfg(test)] mod tests { + + use alloy_consensus::{ + BlobTransactionSidecar, BlobTransactionSidecarVariant, BlockHeader, Header, + SignableTransaction, TxEip4844, TxEip4844Variant, proofs::calculate_transaction_root, + }; use alloy_primitives::{ - b256, + Address, B64, B256, Bloom, Signature, U256, address, b256, hex::{self, FromHex}, }; use alloy_rlp::Decodable; use super::*; - use std::str::FromStr; + + fn assert_blob_transaction_root(sidecar: BlobTransactionSidecarVariant) { + let tx = TxEip4844 { + chain_id: 1, + nonce: 0, + gas_limit: 21_000, + max_fee_per_gas: 1, + max_priority_fee_per_gas: 1, + to: Address::ZERO, + value: U256::ZERO, + access_list: Default::default(), + blob_versioned_hashes: vec![B256::ZERO], + max_fee_per_blob_gas: 1, + input: Default::default(), + }; + let signature = Signature::new(U256::ONE, U256::ONE, false); + let canonical = FoundryTxEnvelope::Eip4844( + TxEip4844Variant::TxEip4844(tx.clone()).into_signed(signature), + ); + let pooled = FoundryTxEnvelope::Eip4844( + TxEip4844Variant::TxEip4844WithSidecar(tx.with_sidecar(sidecar)).into_signed(signature), + ); + + let canonical_root = calculate_transaction_root(&[canonical]); + let pooled_root = calculate_transaction_root(std::slice::from_ref(&pooled)); + let block = create_block(FoundryHeader::default(), [pooled]); + + assert_ne!(canonical_root, pooled_root); + assert_eq!(block.header.transactions_root(), canonical_root); + } + + #[test] + fn blob_transaction_root_uses_canonical_encoding() { + assert_blob_transaction_root(BlobTransactionSidecarVariant::Eip4844( + BlobTransactionSidecar::new( + vec![Default::default()], + vec![Default::default()], + vec![Default::default()], + ), + )); + assert_blob_transaction_root(BlobTransactionSidecarVariant::Eip7594(Default::default())); + } #[test] fn header_rlp_roundtrip() { @@ -151,6 +154,8 @@ mod tests { parent_beacon_block_root: Default::default(), base_fee_per_gas: None, requests_hash: None, + block_access_list_hash: None, + slot_number: None, }; let encoded = alloy_rlp::encode(&header); @@ -171,12 +176,12 @@ mod tests { let expected = hex::decode("f901f9a00000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000940000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000b90100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008208ae820d0582115c8215b3821a0a827788a00000000000000000000000000000000000000000000000000000000000000000880000000000000000").unwrap(); let mut data = vec![]; let header = Header { - parent_hash: B256::from_str("0000000000000000000000000000000000000000000000000000000000000000").unwrap(), - ommers_hash: B256::from_str("0000000000000000000000000000000000000000000000000000000000000000").unwrap(), - beneficiary: Address::from_str("0000000000000000000000000000000000000000").unwrap(), - state_root: B256::from_str("0000000000000000000000000000000000000000000000000000000000000000").unwrap(), - transactions_root: B256::from_str("0000000000000000000000000000000000000000000000000000000000000000").unwrap(), - receipts_root: B256::from_str("0000000000000000000000000000000000000000000000000000000000000000").unwrap(), + parent_hash: B256::ZERO, + ommers_hash: B256::ZERO, + beneficiary: Address::ZERO, + state_root: B256::ZERO, + transactions_root: B256::ZERO, + receipts_root: B256::ZERO, logs_bloom: Bloom::from_hex("00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000").unwrap(), difficulty: U256::from(2222), number: 0xd05u64, @@ -184,7 +189,7 @@ mod tests { gas_used: 0x15b3u64, timestamp: 0x1a0au64, extra_data: hex::decode("7788").unwrap().into(), - mix_hash: B256::from_str("0000000000000000000000000000000000000000000000000000000000000000").unwrap(), + mix_hash: B256::ZERO, withdrawals_root: None, blob_gas_used: None, excess_blob_gas: None, @@ -192,6 +197,8 @@ mod tests { nonce: B64::ZERO, base_fee_per_gas: None, requests_hash: None, + block_access_list_hash: None, + slot_number: None, }; header.encode(&mut data); @@ -204,12 +211,12 @@ mod tests { fn test_decode_block_header() { let data = hex::decode("f901f9a00000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000940000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000b90100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008208ae820d0582115c8215b3821a0a827788a00000000000000000000000000000000000000000000000000000000000000000880000000000000000").unwrap(); let expected = Header { - parent_hash: B256::from_str("0000000000000000000000000000000000000000000000000000000000000000").unwrap(), - ommers_hash: B256::from_str("0000000000000000000000000000000000000000000000000000000000000000").unwrap(), - beneficiary: Address::from_str("0000000000000000000000000000000000000000").unwrap(), - state_root: B256::from_str("0000000000000000000000000000000000000000000000000000000000000000").unwrap(), - transactions_root: B256::from_str("0000000000000000000000000000000000000000000000000000000000000000").unwrap(), - receipts_root: B256::from_str("0000000000000000000000000000000000000000000000000000000000000000").unwrap(), + parent_hash: B256::ZERO, + ommers_hash: B256::ZERO, + beneficiary: Address::ZERO, + state_root: B256::ZERO, + transactions_root: B256::ZERO, + receipts_root: B256::ZERO, logs_bloom: <[u8; 256]>::from_hex("00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000").unwrap().into(), difficulty: U256::from(2222), number: 0xd05u64, @@ -217,7 +224,7 @@ mod tests { gas_used: 0x15b3u64, timestamp: 0x1a0au64, extra_data: hex::decode("7788").unwrap().into(), - mix_hash: B256::from_str("0000000000000000000000000000000000000000000000000000000000000000").unwrap(), + mix_hash: B256::ZERO, nonce: B64::ZERO, withdrawals_root: None, blob_gas_used: None, @@ -225,6 +232,8 @@ mod tests { parent_beacon_block_root: None, base_fee_per_gas: None, requests_hash: None, + block_access_list_hash: None, + slot_number: None, }; let header = Header::decode(&mut data.as_slice()).unwrap(); assert_eq!(header, expected); @@ -236,12 +245,12 @@ mod tests { let expected_hash = b256!("0x6a251c7c3c5dca7b42407a3752ff48f3bbca1fab7f9868371d9918daf1988d1f"); let header = Header { - parent_hash: B256::from_str("e0a94a7a3c9617401586b1a27025d2d9671332d22d540e0af72b069170380f2a").unwrap(), - ommers_hash: B256::from_str("1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347").unwrap(), - beneficiary: Address::from_str("ba5e000000000000000000000000000000000000").unwrap(), - state_root: B256::from_str("ec3c94b18b8a1cff7d60f8d258ec723312932928626b4c9355eb4ab3568ec7f7").unwrap(), - transactions_root: B256::from_str("50f738580ed699f0469702c7ccc63ed2e51bc034be9479b7bff4e68dee84accf").unwrap(), - receipts_root: B256::from_str("29b0562f7140574dd0d50dee8a271b22e1a0a7b78fca58f7c60370d8317ba2a9").unwrap(), + parent_hash: b256!("e0a94a7a3c9617401586b1a27025d2d9671332d22d540e0af72b069170380f2a"), + ommers_hash: b256!("1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347"), + beneficiary: address!("ba5e000000000000000000000000000000000000"), + state_root: b256!("ec3c94b18b8a1cff7d60f8d258ec723312932928626b4c9355eb4ab3568ec7f7"), + transactions_root: b256!("50f738580ed699f0469702c7ccc63ed2e51bc034be9479b7bff4e68dee84accf"), + receipts_root: b256!("29b0562f7140574dd0d50dee8a271b22e1a0a7b78fca58f7c60370d8317ba2a9"), logs_bloom: Bloom::from_hex("00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000").unwrap(), difficulty: U256::from(0x020000), number: 1u64, @@ -249,7 +258,7 @@ mod tests { gas_used: U256::from(0x015534).to::(), timestamp: 0x079e, extra_data: hex::decode("42").unwrap().into(), - mix_hash: B256::from_str("0000000000000000000000000000000000000000000000000000000000000000").unwrap(), + mix_hash: B256::ZERO, nonce: B64::ZERO, base_fee_per_gas: Some(875), withdrawals_root: None, @@ -257,6 +266,8 @@ mod tests { excess_blob_gas: None, parent_beacon_block_root: None, requests_hash: None, + block_access_list_hash: None, + slot_number: None, }; assert_eq!(header.hash_slow(), expected_hash); } @@ -268,7 +279,7 @@ mod tests { let data = hex::decode("f9034df90348a0fbdbd8d2d0ac5f14bd5fa90e547fe6f1d15019c724f8e7b60972d381cd5d9cf8a01dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d4934794c9577e7945db22e38fc060909f2278c7746b0f9ba05017cfa3b0247e35197215ae8d610265ffebc8edca8ea66d6567eb0adecda867a056e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421a056e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421b9010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000018355bb7b871fffffffffffff808462bd0e1ab9014bf90148a00000000000000000000000000000000000000000000000000000000000000000f85494319fa8f1bc4e53410e92d10d918659b16540e60a945a573efb304d04c1224cd012313e827eca5dce5d94a9c831c5a268031176ebf5f3de5051e8cba0dbfe94c9577e7945db22e38fc060909f2278c7746b0f9b808400000000f8c9b841a6946f2d16f68338cbcbd8b117374ab421128ce422467088456bceba9d70c34106128e6d4564659cf6776c08a4186063c0a05f7cffd695c10cf26a6f301b67f800b8412b782100c18c35102dc0a37ece1a152544f04ad7dc1868d18a9570f744ace60870f822f53d35e89a2ea9709ccbf1f4a25ee5003944faa845d02dde0a41d5704601b841d53caebd6c8a82456e85c2806a9e08381f959a31fb94a77e58f00e38ad97b2e0355b8519ab2122662cbe022f2a4ef7ff16adc0b2d5dcd123181ec79705116db300a063746963616c2062797a616e74696e65206661756c7420746f6c6572616e6365880000000000000000c0c0").unwrap(); - let block = Block::decode(&mut data.as_slice()).unwrap(); + let block = ::decode(&mut data.as_slice()).unwrap(); // encode and check that it matches the original data let mut encoded = Vec::new(); diff --git a/crates/anvil/core/src/eth/mod.rs b/crates/anvil/core/src/eth/mod.rs index a879defd64e65..354387e9ab3ef 100644 --- a/crates/anvil/core/src/eth/mod.rs +++ b/crates/anvil/core/src/eth/mod.rs @@ -1,8 +1,12 @@ use crate::{eth::subscription::SubscriptionId, types::ReorgOptions}; -use alloy_primitives::{Address, B64, B256, Bytes, TxHash, U256}; +use alloy_primitives::{ + Address, B64, B256, Bytes, TxHash, U64, U256, + map::{HashMap, HashSet}, +}; use alloy_rpc_types::{ - BlockId, BlockNumberOrTag as BlockNumber, BlockOverrides, Filter, Index, + BlockId, BlockNumberOrTag as BlockNumber, BlockOverrides, Bundle, Filter, Index, StateContext, anvil::{Forking, MineOptions}, + erc4337::TransactionConditional, pubsub::{Params as SubscriptionParams, SubscriptionKind}, request::TransactionRequest, simulate::SimulatePayload, @@ -10,11 +14,14 @@ use alloy_rpc_types::{ trace::{ filter::TraceFilter, geth::{GethDebugTracingCallOptions, GethDebugTracingOptions}, + parity::TraceType, }, }; +use alloy_rpc_types_mev::EthCallBundle; use alloy_serde::WithOtherFields; use foundry_common::serde_helpers::{ - deserialize_number, deserialize_number_opt, deserialize_number_seq, + deserialize_number, deserialize_number_opt, deserialize_number_seq, deserialize_u64_seq, + deserialize_u64_seq_opt, }; pub mod block; @@ -27,11 +34,36 @@ use self::serde_helpers::*; /// Wrapper type that ensures the type is named `params` #[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] -pub struct Params { +pub struct Params { #[serde(default)] pub params: T, } +/// Parameters accepted by `eth_getTransactionCount`. +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(untagged)] +pub enum TransactionCountParams { + /// Address only. + Address((Address,)), + /// Standard address and block query. + Standard((Address, Option)), + /// EIP-8130 address, block, and nonce-key query. + #[cfg(feature = "base")] + Eip8130((Address, Option, U256)), +} + +impl TransactionCountParams { + /// Splits the request into address, block, and optional EIP-8130 nonce key. + pub const fn into_parts(self) -> (Address, Option, Option) { + match self { + Self::Address((address,)) => (address, None, None), + Self::Standard((address, block)) => (address, block, None), + #[cfg(feature = "base")] + Self::Eip8130((address, block, nonce_key)) => (address, block, Some(nonce_key)), + } + } +} + /// Represents ethereum JSON-RPC API #[derive(Clone, Debug, serde::Deserialize)] #[serde(tag = "method", content = "params")] @@ -43,6 +75,10 @@ pub enum EthRequest { #[serde(rename = "web3_sha3", with = "sequence")] Web3Sha3(Bytes), + /// Returns the current Ethereum protocol version. + #[serde(rename = "eth_protocolVersion", with = "empty_params")] + EthProtocolVersion(()), + #[serde(rename = "eth_chainId", with = "empty_params")] EthChainId(()), @@ -52,9 +88,16 @@ pub enum EthRequest { #[serde(rename = "net_listening", with = "empty_params")] NetListening(()), + /// Returns the number of hashes per second with which the node is mining. + #[serde(rename = "eth_hashrate", with = "empty_params")] + EthHashrate(()), + #[serde(rename = "eth_gasPrice", with = "empty_params")] EthGasPrice(()), + #[serde(rename = "eth_baseFee", with = "empty_params")] + EthBaseFee(()), + #[serde(rename = "eth_maxPriorityFeePerGas", with = "empty_params")] EthMaxPriorityFeePerGas(()), @@ -67,8 +110,12 @@ pub enum EthRequest { #[serde(rename = "eth_blockNumber", with = "empty_params")] EthBlockNumber(()), + /// Returns the client coinbase address. + #[serde(rename = "eth_coinbase", with = "empty_params")] + EthCoinbase(()), + #[serde(rename = "eth_getBalance")] - EthGetBalance(Address, Option), + EthGetBalance(Address, #[serde(default)] Option), #[serde(rename = "eth_getAccount")] EthGetAccount(Address, Option), @@ -77,19 +124,47 @@ pub enum EthRequest { EthGetAccountInfo(Address, Option), #[serde(rename = "eth_getStorageAt")] - EthGetStorageAt(Address, U256, Option), + EthGetStorageAt(Address, U256, #[serde(default)] Option), + + /// Returns storage values for multiple accounts and slots in a single call. + #[serde(rename = "eth_getStorageValues")] + EthGetStorageValues(HashMap>, #[serde(default)] Option), #[serde(rename = "eth_getBlockByHash")] EthGetBlockByHash(B256, bool), + #[serde(rename = "eth_getHeaderByHash", with = "sequence")] + EthGetHeaderByHash(B256), + #[serde(rename = "eth_getBlockByNumber")] EthGetBlockByNumber( #[serde(deserialize_with = "lenient_block_number::lenient_block_number")] BlockNumber, bool, ), + #[serde( + rename = "eth_getHeaderByNumber", + deserialize_with = "lenient_block_number::lenient_block_number_seq" + )] + EthGetHeaderByNumber(BlockNumber), + + #[serde(rename = "eth_getBlockAccessList", with = "sequence")] + EthGetBlockAccessList(BlockId), + + #[serde(rename = "eth_getBlockAccessListByBlockHash", with = "sequence")] + EthGetBlockAccessListByBlockHash(B256), + + #[serde( + rename = "eth_getBlockAccessListByBlockNumber", + deserialize_with = "lenient_block_number::lenient_block_number_seq" + )] + EthGetBlockAccessListByBlockNumber(BlockNumber), + + #[serde(rename = "eth_getBlockAccessListRaw", with = "sequence")] + EthGetBlockAccessListRaw(BlockId), + #[serde(rename = "eth_getTransactionCount")] - EthGetTransactionCount(Address, Option), + EthGetTransactionCount(TransactionCountParams), #[serde(rename = "eth_getBlockTransactionCountByHash", with = "sequence")] EthGetTransactionCountByHash(B256), @@ -110,12 +185,12 @@ pub enum EthRequest { EthGetUnclesCountByNumber(BlockNumber), #[serde(rename = "eth_getCode")] - EthGetCodeAt(Address, Option), + EthGetCodeAt(Address, #[serde(default)] Option), /// Returns the account and storage values of the specified account including the Merkle-proof. /// This call can be used to verify that the data you are pulling from is not tampered with. #[serde(rename = "eth_getProof")] - EthGetProof(Address, Vec, Option), + EthGetProof(Address, Vec, #[serde(default)] Option), /// The sign method calculates an Ethereum specific signature with: #[serde(rename = "eth_sign")] @@ -144,14 +219,27 @@ pub enum EthRequest { #[serde(rename = "eth_sendTransaction", with = "sequence")] EthSendTransaction(Box>), + #[serde(rename = "eth_resend")] + EthResend(Box>, Option, Option), + #[serde(rename = "eth_sendTransactionSync", with = "sequence")] EthSendTransactionSync(Box>), #[serde(rename = "eth_sendRawTransaction", with = "sequence")] EthSendRawTransaction(Bytes), - #[serde(rename = "eth_sendRawTransactionSync", with = "sequence")] - EthSendRawTransactionSync(Bytes), + #[serde(rename = "eth_sendRawTransactionSync")] + EthSendRawTransactionSync(Bytes, #[serde(default)] Option), + + #[serde(rename = "eth_sendRawTransactionConditional")] + EthSendRawTransactionConditional(Bytes, TransactionConditional), + + /// Signs a raw Tempo transaction as the node's fee payer (sponsor) without broadcasting it. + #[serde(rename = "eth_signRawTransaction", with = "sequence")] + EthSignRawTransaction(Bytes), + + #[serde(rename = "anvil_classifyTransaction", with = "sequence")] + AnvilClassifyTransaction(Bytes), #[serde(rename = "eth_call")] EthCall( @@ -161,11 +249,28 @@ pub enum EthRequest { #[serde(default)] Option>, ), + #[serde(rename = "eth_callMany")] + EthCallMany( + Vec>>, + #[serde(default)] Option, + #[serde(default)] Option, + ), + + #[serde(rename = "eth_callBundle", with = "sequence")] + EthCallBundle(EthCallBundle), + #[serde(rename = "eth_simulateV1")] - EthSimulateV1(SimulatePayload, #[serde(default)] Option), + EthSimulateV1( + SimulatePayload>, + #[serde(default)] Option, + ), #[serde(rename = "eth_createAccessList")] - EthCreateAccessList(WithOtherFields, #[serde(default)] Option), + EthCreateAccessList( + WithOtherFields, + #[serde(default)] Option, + #[serde(default)] Option, + ), #[serde(rename = "eth_estimateGas")] EthEstimateGas( @@ -175,9 +280,15 @@ pub enum EthRequest { #[serde(default)] Option>, ), + #[serde(rename = "eth_fillTransaction", with = "sequence")] + EthFillTransaction(WithOtherFields), + #[serde(rename = "eth_getTransactionByHash", with = "sequence")] EthGetTransactionByHash(TxHash), + #[serde(rename = "eth_pendingTransactions", with = "empty_params")] + EthPendingTransactions(()), + /// Returns the blob for a given blob versioned hash. #[serde(rename = "anvil_getBlobByHash", with = "sequence")] GetBlobByHash(B256), @@ -186,8 +297,16 @@ pub enum EthRequest { #[serde(rename = "anvil_getBlobsByTransactionHash", with = "sequence")] GetBlobByTransactionHash(TxHash), + /// Returns the genesis time for the chain + #[serde(rename = "anvil_getGenesisTime", with = "empty_params")] + GetGenesisTime(()), + + /// Returns the UNIX wall time in milliseconds when the current head was installed. + #[serde(rename = "anvil_getLastBlockWallTime", with = "empty_params")] + GetLastBlockWallTime(()), + #[serde(rename = "eth_getTransactionByBlockHashAndIndex")] - EthGetTransactionByBlockHashAndIndex(TxHash, Index), + EthGetTransactionByBlockHashAndIndex(B256, Index), #[serde(rename = "eth_getTransactionByBlockNumberAndIndex")] EthGetTransactionByBlockNumberAndIndex(BlockNumber, Index), @@ -196,7 +315,7 @@ pub enum EthRequest { EthGetRawTransactionByHash(TxHash), #[serde(rename = "eth_getRawTransactionByBlockHashAndIndex")] - EthGetRawTransactionByBlockHashAndIndex(TxHash, Index), + EthGetRawTransactionByBlockHashAndIndex(B256, Index), #[serde(rename = "eth_getRawTransactionByBlockNumberAndIndex")] EthGetRawTransactionByBlockNumberAndIndex(BlockNumber, Index), @@ -234,8 +353,8 @@ pub enum EthRequest { /// Creates a filter in the node, to notify when new pending transactions arrive. /// To check if the state has changed, call `eth_getFilterChanges`. - #[serde(rename = "eth_newPendingTransactionFilter", with = "empty_params")] - EthNewPendingTransactionFilter(()), + #[serde(rename = "eth_newPendingTransactionFilter", with = "optional_sequence")] + EthNewPendingTransactionFilter(Option), /// Returns an array of all logs matching filter with given id. #[serde(rename = "eth_getFilterLogs", with = "sequence")] @@ -264,10 +383,33 @@ pub enum EthRequest { #[serde(rename = "eth_syncing", with = "empty_params")] EthSyncing(()), + #[serde(rename = "eth_config", with = "empty_params")] + EthConfig(()), + /// geth's `debug_getRawTransaction` endpoint #[serde(rename = "debug_getRawTransaction", with = "sequence")] DebugGetRawTransaction(TxHash), + /// reth's `debug_getRawReceipts` endpoint. + #[serde(rename = "debug_getRawReceipts", with = "sequence")] + DebugGetRawReceipts(BlockId), + + /// reth's `debug_getRawTransactions` endpoint. + #[serde(rename = "debug_getRawTransactions", with = "sequence")] + DebugGetRawTransactions(BlockId), + + /// geth's `debug_getRawHeader` endpoint. + #[serde(rename = "debug_getRawHeader", with = "sequence")] + DebugGetRawHeader(BlockId), + + /// geth's `debug_getRawBlock` endpoint. + #[serde(rename = "debug_getRawBlock", with = "sequence")] + DebugGetRawBlock(BlockId), + + /// geth's `debug_clearTxpool` endpoint + #[serde(rename = "debug_clearTxpool", with = "empty_params")] + DebugClearTxpool(()), + /// geth's `debug_traceTransaction` endpoint #[serde(rename = "debug_traceTransaction")] DebugTraceTransaction(B256, #[serde(default)] GethDebugTracingOptions), @@ -284,6 +426,46 @@ pub enum EthRequest { #[serde(rename = "debug_codeByHash")] DebugCodeByHash(B256, #[serde(default)] Option), + /// reth's `debug_dbGet` endpoint + #[serde(rename = "debug_dbGet")] + DebugDbGet(String), + + /// reth's `debug_getModifiedAccountsByNumber` endpoint. + #[serde(rename = "debug_getModifiedAccountsByNumber")] + DebugGetModifiedAccountsByNumber(u64, u64), + + /// reth's `debug_freeOSMemory` endpoint. + #[serde(rename = "debug_freeOSMemory", with = "empty_params")] + DebugFreeOsMemory(()), + + /// reth's `debug_accountInfoAt` endpoint. + #[serde(rename = "debug_accountInfoAt")] + DebugAccountInfoAt(BlockId, Index, Address), + + /// reth's `debug_executionWitness` endpoint. + #[serde(rename = "debug_executionWitness", with = "sequence")] + DebugExecutionWitness(BlockNumber), + + /// geth's `debug_traceBlock` endpoint. + #[serde(rename = "debug_traceBlock")] + DebugTraceBlock(Bytes, #[serde(default)] GethDebugTracingOptions), + + /// geth's `debug_traceBlockByHash` endpoint + #[serde(rename = "debug_traceBlockByHash")] + DebugTraceBlockByHash(B256, #[serde(default)] GethDebugTracingOptions), + + /// geth's `debug_traceBlockByNumber` endpoint + #[serde(rename = "debug_traceBlockByNumber")] + DebugTraceBlockByNumber(BlockNumber, #[serde(default)] GethDebugTracingOptions), + + /// Trace call endpoint for parity's `trace_call`. + #[serde(rename = "trace_call")] + TraceCall( + WithOtherFields, + #[serde(default)] HashSet, + #[serde(default)] Option, + ), + /// Trace transaction endpoint for parity's `trace_transaction` #[serde(rename = "trace_transaction", with = "sequence")] TraceTransaction(B256), @@ -299,6 +481,40 @@ pub enum EthRequest { #[serde(rename = "trace_filter", with = "sequence")] TraceFilter(TraceFilter), + /// Trace get endpoint for parity's `trace_get`. + #[serde(rename = "trace_get")] + TraceGet(B256, #[serde(deserialize_with = "trace_address::deserialize")] Vec), + + /// Trace transaction endpoint for parity's `trace_replayBlockTransactions` + #[serde(rename = "trace_replayBlockTransactions")] + TraceReplayBlockTransactions( + #[serde(deserialize_with = "lenient_block_number::lenient_block_number")] BlockNumber, + HashSet, + ), + + /// Trace transaction endpoint for parity's `trace_replayTransaction`. + #[serde(rename = "trace_replayTransaction")] + TraceReplayTransaction(B256, HashSet), + + /// Opcode gas trace endpoint for reth's `trace_transactionOpcodeGas`. + #[serde(rename = "trace_transactionOpcodeGas", with = "sequence")] + TraceTransactionOpcodeGas(B256), + + /// Opcode gas trace endpoint for reth's `trace_blockOpcodeGas`. + #[serde(rename = "trace_blockOpcodeGas", with = "sequence")] + TraceBlockOpcodeGas(BlockId), + + /// Trace raw transaction endpoint for parity's `trace_rawTransaction`. + #[serde(rename = "trace_rawTransaction")] + TraceRawTransaction(Bytes, HashSet, #[serde(default)] Option), + + /// Trace call list endpoint for parity's `trace_callMany`. + #[serde(rename = "trace_callMany")] + TraceCallMany( + Vec<(WithOtherFields, HashSet)>, + #[serde(default)] Option, + ), + // Custom endpoints, they're not extracted to a separate type out of serde convenience /// send transactions impersonating specific account and contract addresses. #[serde( @@ -321,6 +537,11 @@ pub enum EthRequest { with = "sequence" )] AutoImpersonateAccount(bool), + + /// Registers a signature/address pair for faking `ecrecover` results + #[serde(rename = "anvil_impersonateSignature", with = "sequence")] + ImpersonateSignature(Bytes, Address), + /// Returns true if automatic mining is enabled, and false. #[serde(rename = "anvil_getAutomine", alias = "hardhat_getAutomine", with = "empty_params")] GetAutoMine(()), @@ -343,7 +564,11 @@ pub enum EthRequest { SetAutomine(bool), /// Sets the mining behavior to interval with the given interval (seconds) - #[serde(rename = "anvil_setIntervalMining", alias = "evm_setIntervalMining", with = "sequence")] + #[serde( + rename = "anvil_setIntervalMining", + alias = "evm_setIntervalMining", + deserialize_with = "deserialize_u64_seq" + )] SetIntervalMining(u64), /// Gets the current mining behavior @@ -394,6 +619,10 @@ pub enum EthRequest { )] DealERC20(Address, Address, #[serde(deserialize_with = "deserialize_number")] U256), + /// Modifies the TIP-20 balance of an account. + #[serde(rename = "anvil_dealTIP20")] + DealTIP20(Address, Address, #[serde(deserialize_with = "deserialize_number")] U256), + /// Sets the ERC20 allowance for a spender #[serde(rename = "anvil_setERC20Allowance")] SetERC20Allowance( @@ -425,8 +654,16 @@ pub enum EthRequest { #[serde(rename = "anvil_setCoinbase", alias = "hardhat_setCoinbase", with = "sequence")] SetCoinbase(Address), + /// Sets the `prevrandao` value of the next block + #[serde(rename = "anvil_setNextBlockPrevRandao", with = "sequence")] + SetNextBlockPrevRandao(B256), + + /// Sets the parent beacon block root of the next block + #[serde(rename = "anvil_setNextBlockParentBeaconBlockRoot", with = "sequence")] + SetNextBlockParentBeaconBlockRoot(B256), + /// Sets the chain id - #[serde(rename = "anvil_setChainId", with = "sequence")] + #[serde(rename = "anvil_setChainId", deserialize_with = "deserialize_u64_seq")] SetChainId(u64), /// Enable or disable logging @@ -525,7 +762,7 @@ pub enum EthRequest { /// Similar to `evm_increaseTime` but takes sets a block timestamp `interval`. /// /// The timestamp of the next block will be computed as `lastBlock_timestamp + interval`. - #[serde(rename = "anvil_setBlockTimestampInterval", with = "sequence")] + #[serde(rename = "anvil_setBlockTimestampInterval", deserialize_with = "deserialize_u64_seq")] EvmSetBlockTimeStampInterval(u64), /// Removes a `anvil_setBlockTimestampInterval` if it exists @@ -547,11 +784,6 @@ pub enum EthRequest { #[serde(rename = "eth_sendUnsignedTransaction", with = "sequence")] EthSendUnsignedTransaction(Box>), - /// Turn on call traces for transactions that are returned to the user when they execute a - /// transaction (instead of just txhash/receipt) - #[serde(rename = "anvil_enableTraces", with = "empty_params")] - EnableTraces(()), - /// Returns the number of transactions currently pending for inclusion in the next block(s), as /// well as the ones that are being scheduled for future execution only. /// Ref: @@ -570,6 +802,13 @@ pub enum EthRequest { #[serde(rename = "txpool_content", with = "empty_params")] TxPoolContent(()), + /// Returns the details of all transactions currently pending for inclusion in the next + /// block(s), as well as the ones that are being scheduled for future execution only, filtered + /// by sender. + /// Ref: + #[serde(rename = "txpool_contentFrom", with = "sequence")] + TxPoolContentFrom(Address), + /// Otterscan's `ots_getApiLevel` endpoint /// Otterscan currently requires this endpoint, even though it's not part of the ots_* /// @@ -650,6 +889,14 @@ pub enum EthRequest { #[serde(deserialize_with = "deserialize_number")] U256, ), + /// Returns the transaction by sender and nonce + /// Returns the full transaction data. + #[serde(rename = "eth_getTransactionBySenderAndNonce")] + EthGetTransactionBySenderAndNonce( + Address, + #[serde(deserialize_with = "deserialize_number")] U256, + ), + /// Otterscan's `ots_getTransactionBySenderAndNonce` endpoint /// Given an ETH contract address, returns the tx hash and the direct address who created the /// contract. @@ -665,30 +912,28 @@ pub enum EthRequest { Reorg(ReorgOptions), /// Rollback the chain - #[serde(rename = "anvil_rollback", with = "sequence")] + #[serde(rename = "anvil_rollback", deserialize_with = "deserialize_u64_seq_opt")] Rollback(Option), - /// Wallet - #[serde(rename = "wallet_getCapabilities", with = "empty_params")] - WalletGetCapabilities(()), + /// Sets the fee token for a user (Tempo-only) + #[serde(rename = "anvil_setFeeToken")] + SetFeeToken(Address, Address), - /// Wallet send_tx - #[serde( - rename = "wallet_sendTransaction", - alias = "odyssey_sendTransaction", - with = "sequence" - )] - WalletSendTransaction(Box>), - - /// Add an address to the [`DelegationCapability`] of the wallet - /// - /// [`DelegationCapability`]: wallet::DelegationCapability - #[serde(rename = "anvil_addCapability", with = "sequence")] - AnvilAddCapability(Address), + /// Sets the fee token for a validator (Tempo-only) + #[serde(rename = "anvil_setValidatorFeeToken")] + SetValidatorFeeToken(Address, Address), - /// Set the executor (sponsor) wallet - #[serde(rename = "anvil_setExecutor", with = "sequence")] - AnvilSetExecutor(String), + /// Mints FeeAMM liquidity for a token pair (Tempo-only) + #[serde(rename = "anvil_setFeeAmmLiquidity")] + SetFeeAmmLiquidity( + /// user_token + Address, + /// validator_token + Address, + /// amount + #[serde(deserialize_with = "deserialize_number")] + U256, + ), } /// Represents ethereum JSON-RPC API @@ -705,13 +950,30 @@ pub enum EthPubSub { } /// Container type for either a request or a pub sub -#[derive(Clone, Debug, serde::Deserialize)] -#[serde(untagged)] +#[derive(Clone, Debug)] pub enum EthRpcCall { Request(Box), PubSub(EthPubSub), } +impl<'de> serde::Deserialize<'de> for EthRpcCall { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = serde_json::Value::deserialize(deserializer)?; + match value.get("method").and_then(serde_json::Value::as_str) { + Some("eth_subscribe" | "eth_unsubscribe") => serde_json::from_value(value) + .map(Self::PubSub) + .map_err(::custom), + _ => serde_json::from_value(value) + .map(Box::new) + .map(Self::Request) + .map_err(::custom), + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -730,6 +992,14 @@ mod tests { let _req = serde_json::from_value::(value).unwrap(); } + #[test] + fn unknown_rpc_call_preserves_method_error() { + let err = serde_json::from_str::(r#"{"method":"no_such_method","params":[]}"#) + .unwrap_err(); + + assert!(err.to_string().contains("unknown variant")); + } + #[test] fn test_eth_accounts() { let s = r#"{"method": "eth_accounts", "params":[]}"#; @@ -772,6 +1042,48 @@ mod tests { let _req = serde_json::from_value::(value).unwrap(); } + #[test] + fn test_eth_pending_transactions() { + let s = r#"{"method": "eth_pendingTransactions", "params":[]}"#; + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + let _req = serde_json::from_value::(value).unwrap(); + } + + #[test] + fn test_eth_create_access_list_with_state_override() { + let s = r#"{"method":"eth_createAccessList","params":[{"to":"0x0000000000000000000000000000000000000001"},"latest",{"0x0000000000000000000000000000000000000001":{"code":"0x60005400"}}]}"#; + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + let _req = serde_json::from_value::(value).unwrap(); + } + + #[test] + fn test_eth_get_header_by_hash() { + let s = r#"{"method": "eth_getHeaderByHash", "params":["0x12e65af7b09d4e08ec0a7786ee606b1b6b710d3f9c39f73d7e065527e0e839d3"]}"#; + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + let _req = serde_json::from_value::(value).unwrap(); + } + + #[test] + fn test_txpool_content_from() { + let address = "0x7F0d15C7FAae65896648C8273B6d7E43f58Fa842"; + let s = format!(r#"{{"method": "txpool_contentFrom", "params":["{address}"]}}"#); + let value: serde_json::Value = serde_json::from_str(&s).unwrap(); + let req = serde_json::from_value::(value).unwrap(); + match req { + EthRequest::TxPoolContentFrom(from) => { + assert_eq!(from, address.parse::
().unwrap()); + } + _ => unreachable!(), + } + } + + #[test] + fn test_eth_get_header_by_number() { + let s = r#"{"method": "eth_getHeaderByNumber", "params":["latest"]}"#; + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + let _req = serde_json::from_value::(value).unwrap(); + } + #[test] fn test_eth_max_priority_fee() { let s = r#"{"method": "eth_maxPriorityFeePerGas", "params":[]}"#; @@ -779,6 +1091,49 @@ mod tests { let _req = serde_json::from_value::(value).unwrap(); } + #[test] + fn test_eth_send_raw_transaction_sync() { + let s = r#"{"method": "eth_sendRawTransactionSync", "params":["0xdeadbeef"]}"#; + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + let req = serde_json::from_value::(value).unwrap(); + match req { + EthRequest::EthSendRawTransactionSync(tx, timeout) => { + assert_eq!(tx, Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef])); + assert_eq!(timeout, None); + } + _ => unreachable!(), + } + + let s = r#"{"method": "eth_sendRawTransactionSync", "params":["0xdeadbeef", null]}"#; + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + let req = serde_json::from_value::(value).unwrap(); + match req { + EthRequest::EthSendRawTransactionSync(tx, timeout) => { + assert_eq!(tx, Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef])); + assert_eq!(timeout, None); + } + _ => unreachable!(), + } + + let s = r#"{"method": "eth_sendRawTransactionSync", "params":["0xdeadbeef", 1]}"#; + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + let req = serde_json::from_value::(value).unwrap(); + match req { + EthRequest::EthSendRawTransactionSync(tx, timeout) => { + assert_eq!(tx, Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef])); + assert_eq!(timeout, Some(1)); + } + _ => unreachable!(), + } + } + + #[test] + fn test_eth_base_fee() { + let s = r#"{"method": "eth_baseFee", "params":[]}"#; + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + let _req = serde_json::from_value::(value).unwrap(); + } + #[test] fn test_eth_syncing() { let s = r#"{"method": "eth_syncing", "params":[]}"#; @@ -816,6 +1171,16 @@ mod tests { let _req = serde_json::from_value::(value).unwrap(); } + #[test] + fn test_custom_get_last_block_wall_time() { + let s = r#"{"method": "anvil_getLastBlockWallTime", "params": []}"#; + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + assert!(matches!( + serde_json::from_value::(value).unwrap(), + EthRequest::GetLastBlockWallTime(()) + )); + } + #[test] fn test_custom_mine() { let s = r#"{"method": "anvil_mine", "params": []}"#; @@ -871,6 +1236,68 @@ mod tests { let _req = serde_json::from_value::(value).unwrap(); } + #[test] + fn test_numeric_params_accept_hex_and_decimal_strings() { + // Standard JSON-RPC clients (web3.js / ethers / viem) encode numeric params as hex + // strings. These methods keep their internal `u64` types, but use a U64-aware + // deserializer for RPC params. + let parse = |s: &str| { + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + serde_json::from_value::(value) + }; + + match parse(r#"{"method": "anvil_setIntervalMining", "params": [100]}"#).unwrap() { + EthRequest::SetIntervalMining(interval) => assert_eq!(interval, 100), + req => panic!("unexpected request: {req:?}"), + } + match parse(r#"{"method": "anvil_setIntervalMining", "params": 100}"#).unwrap() { + EthRequest::SetIntervalMining(interval) => assert_eq!(interval, 100), + req => panic!("unexpected request: {req:?}"), + } + match parse(r#"{"method": "anvil_setIntervalMining", "params": ["0x64"]}"#).unwrap() { + EthRequest::SetIntervalMining(interval) => assert_eq!(interval, 100), + req => panic!("unexpected request: {req:?}"), + } + match parse(r#"{"method": "anvil_setIntervalMining", "params": "0x64"}"#).unwrap() { + EthRequest::SetIntervalMining(interval) => assert_eq!(interval, 100), + req => panic!("unexpected request: {req:?}"), + } + match parse(r#"{"method": "anvil_setIntervalMining", "params": ["100"]}"#).unwrap() { + EthRequest::SetIntervalMining(interval) => assert_eq!(interval, 100), + req => panic!("unexpected request: {req:?}"), + } + match parse(r#"{"method": "anvil_setIntervalMining", "params": "100"}"#).unwrap() { + EthRequest::SetIntervalMining(interval) => assert_eq!(interval, 100), + req => panic!("unexpected request: {req:?}"), + } + match parse(r#"{"method": "anvil_setChainId", "params": ["0x539"]}"#).unwrap() { + EthRequest::SetChainId(chain_id) => assert_eq!(chain_id, 1337), + req => panic!("unexpected request: {req:?}"), + } + match parse(r#"{"method": "anvil_setBlockTimestampInterval", "params": ["0xa"]}"#).unwrap() + { + EthRequest::EvmSetBlockTimeStampInterval(interval) => assert_eq!(interval, 10), + req => panic!("unexpected request: {req:?}"), + } + match parse(r#"{"method": "anvil_rollback", "params": ["0x5"]}"#).unwrap() { + EthRequest::Rollback(depth) => assert_eq!(depth, Some(5)), + req => panic!("unexpected request: {req:?}"), + } + match parse(r#"{"method": "anvil_rollback", "params": []}"#).unwrap() { + EthRequest::Rollback(depth) => assert_eq!(depth, None), + req => panic!("unexpected request: {req:?}"), + } + match parse(r#"{"method": "anvil_rollback", "params": [null]}"#).unwrap() { + EthRequest::Rollback(depth) => assert_eq!(depth, None), + req => panic!("unexpected request: {req:?}"), + } + + assert!( + parse(r#"{"method": "anvil_setIntervalMining", "params": ["0x10000000000000000"]}"#) + .is_err() + ); + } + #[test] fn test_custom_drop_tx() { let s = r#"{"method": "anvil_dropTransaction", "params": @@ -1030,6 +1457,27 @@ mod tests { let _req = serde_json::from_value::(value).unwrap(); } + #[test] + fn test_simulate_preserves_transaction_extension_fields() { + let value = serde_json::json!({ + "method": "eth_simulateV1", + "params": [{ + "blockStateCalls": [{ + "calls": [{ + "to": "0x0000000000000000000000000000000000000001", + "tempoExtension": "preserved" + }] + }] + }] + }); + + let request = serde_json::from_value::(value).unwrap(); + let EthRequest::EthSimulateV1(payload, _) = request else { panic!() }; + let transaction = &payload.block_state_calls[0].calls[0]; + + assert_eq!(transaction.other["tempoExtension"], serde_json::json!("preserved")); + } + #[test] fn test_custom_set_code() { let s = r#"{"method": "anvil_setCode", "params": @@ -1088,6 +1536,20 @@ mod tests { let _req = serde_json::from_value::(value).unwrap(); } + #[test] + fn test_serde_custom_set_next_block_prevrandao() { + let s = r#"{"method": "anvil_setNextBlockPrevRandao", "params": ["0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"]}"#; + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + let _req = serde_json::from_value::(value).unwrap(); + } + + #[test] + fn test_serde_custom_set_next_block_parent_beacon_block_root() { + let s = r#"{"method": "anvil_setNextBlockParentBeaconBlockRoot", "params": ["0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"]}"#; + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + let _req = serde_json::from_value::(value).unwrap(); + } + #[test] fn test_serde_custom_logging() { let s = r#"{"method": "anvil_setLoggingEnabled", "params": [false]}"#; @@ -1347,6 +1809,37 @@ mod tests { let _req = serde_json::from_value::(value).unwrap(); } + #[test] + fn test_serde_raw_transaction_conditional() { + let s = r#"{"method": "eth_sendRawTransactionConditional", "params": +["0x4a3b0fce2cb9707b0baa68640cf2fe858c8bb4121b2a8cb904ff369d38a560ff", {"knownAccounts":{}}]}"#; + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + let _req = serde_json::from_value::(value).unwrap(); + } + + #[test] + fn test_eth_new_pending_transaction_filter() { + let s = r#"{"method": "eth_newPendingTransactionFilter", "params":[],"id":73}"#; + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + let req = serde_json::from_value::(value).unwrap(); + assert!(matches!(req, EthRequest::EthNewPendingTransactionFilter(None))); + + let s = r#"{"method": "eth_newPendingTransactionFilter", "params":[null],"id":73}"#; + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + let req = serde_json::from_value::(value).unwrap(); + assert!(matches!(req, EthRequest::EthNewPendingTransactionFilter(None))); + + let s = r#"{"method": "eth_newPendingTransactionFilter", "params":[false],"id":73}"#; + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + let req = serde_json::from_value::(value).unwrap(); + assert!(matches!(req, EthRequest::EthNewPendingTransactionFilter(Some(false)))); + + let s = r#"{"method": "eth_newPendingTransactionFilter", "params":[true],"id":73}"#; + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + let req = serde_json::from_value::(value).unwrap(); + assert!(matches!(req, EthRequest::EthNewPendingTransactionFilter(Some(true)))); + } + #[test] fn test_serde_eth_unsubscribe() { let s = r#"{"id": 1, "method": "eth_unsubscribe", "params": @@ -1360,6 +1853,7 @@ mod tests { let s = r#"{"id": 1, "method": "eth_subscribe", "params": ["newHeads"]}"#; let value: serde_json::Value = serde_json::from_str(s).unwrap(); let _req = serde_json::from_value::(value).unwrap(); + assert!(matches!(serde_json::from_str(s).unwrap(), EthRpcCall::PubSub(_))); let s = r#"{"id": 1, "method": "eth_subscribe", "params": ["logs", {"address": "0x8320fe7702b96808f7bbc0d4a888ed1468216cfd", "topics": @@ -1395,6 +1889,58 @@ mod tests { let _req = serde_json::from_value::(value).unwrap(); } + #[test] + fn test_serde_debug_raw_receipts() { + let s = r#"{"jsonrpc":"2.0","method":"debug_getRawReceipts","params":["latest"],"id":1}"#; + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + let _req = serde_json::from_value::(value).unwrap(); + + let s = r#"{"jsonrpc":"2.0","method":"debug_getRawReceipts","params":["0x3ed3a89bc10115a321aee238c02de214009f8532a65368e5df5eaf732ee7167c"],"id":1}"#; + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + let _req = serde_json::from_value::(value).unwrap(); + } + + #[test] + fn test_serde_debug_raw_transactions() { + let s = + r#"{"jsonrpc":"2.0","method":"debug_getRawTransactions","params":["latest"],"id":1}"#; + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + let _req = serde_json::from_value::(value).unwrap(); + + let s = r#"{"jsonrpc":"2.0","method":"debug_getRawTransactions","params":["0x3ed3a89bc10115a321aee238c02de214009f8532a65368e5df5eaf732ee7167c"],"id":1}"#; + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + let _req = serde_json::from_value::(value).unwrap(); + } + + #[test] + fn test_serde_debug_raw_header() { + let s = r#"{"jsonrpc":"2.0","method":"debug_getRawHeader","params":["latest"],"id":1}"#; + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + let _req = serde_json::from_value::(value).unwrap(); + + let s = r#"{"jsonrpc":"2.0","method":"debug_getRawHeader","params":["0x3ed3a89bc10115a321aee238c02de214009f8532a65368e5df5eaf732ee7167c"],"id":1}"#; + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + let _req = serde_json::from_value::(value).unwrap(); + } + + #[test] + fn test_serde_debug_raw_block() { + let s = r#"{"jsonrpc":"2.0","method":"debug_getRawBlock","params":["latest"],"id":1}"#; + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + let _req = serde_json::from_value::(value).unwrap(); + + let s = r#"{"jsonrpc":"2.0","method":"debug_getRawBlock","params":["0x3ed3a89bc10115a321aee238c02de214009f8532a65368e5df5eaf732ee7167c"],"id":1}"#; + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + let _req = serde_json::from_value::(value).unwrap(); + } + + #[test] + fn test_serde_debug_clear_txpool() { + let s = r#"{"jsonrpc":"2.0","method":"debug_clearTxpool","params":[],"id":1}"#; + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + let _req = serde_json::from_value::(value).unwrap(); + } + #[test] fn test_serde_debug_trace_transaction() { let s = r#"{"method": "debug_traceTransaction", "params": @@ -1414,6 +1960,64 @@ true}]}"#; let _req = serde_json::from_value::(value).unwrap(); } + #[test] + fn test_serde_debug_account_info_at() { + let s = r#"{"method": "debug_accountInfoAt", "params": ["0x1", "0x0", "0xd84de507f3fada7df80908082d3239466db55a71"]}"#; + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + let _req = serde_json::from_value::(value).unwrap(); + + let s = r#"{"method": "debug_accountInfoAt", "params": [{"blockHash": "0xd4e56740f876aef8c010b86a40d5f56745a118d0906a34e69aec8c0db1cb8fa3"}, 0, "0xd84de507f3fada7df80908082d3239466db55a71"]}"#; + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + let _req = serde_json::from_value::(value).unwrap(); + } + + #[test] + fn test_serde_debug_execution_witness() { + let s = r#"{"method": "debug_executionWitness", "params": ["0x1"]}"#; + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + let _req = serde_json::from_value::(value).unwrap(); + + let s = r#"{"method": "debug_executionWitness", "params": ["latest"]}"#; + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + let _req = serde_json::from_value::(value).unwrap(); + } + + #[test] + fn test_serde_trace_transaction_opcode_gas() { + let s = r#"{"method": "trace_transactionOpcodeGas", "params": ["0x4a3b0fce2cb9707b0baa68640cf2fe858c8bb4121b2a8cb904ff369d38a560ff"]}"#; + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + let _req = serde_json::from_value::(value).unwrap(); + } + + #[test] + fn test_serde_trace_replay_transaction() { + let s = r#"{"method":"trace_replayTransaction","params":["0x4a3b0fce2cb9707b0baa68640cf2fe858c8bb4121b2a8cb904ff369d38a560ff",["trace","stateDiff"]]}"#; + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + let _req = serde_json::from_value::(value).unwrap(); + } + + #[test] + fn test_serde_trace_block_opcode_gas() { + let s = r#"{"method": "trace_blockOpcodeGas", "params": ["0x1"]}"#; + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + let _req = serde_json::from_value::(value).unwrap(); + + let s = r#"{"method": "trace_blockOpcodeGas", "params": [{"blockHash": "0xd4e56740f876aef8c010b86a40d5f56745a118d0906a34e69aec8c0db1cb8fa3"}]}"#; + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + let _req = serde_json::from_value::(value).unwrap(); + } + + #[test] + fn test_serde_trace_raw_transaction() { + let s = r#"{"method": "trace_rawTransaction", "params": ["0x02f866827a690a65648252089470997970c51812dc3a010c7d01b50e0d17dc79c88203e980c001a0e4de88aefcf87ccb04466e60de66a83192e46aa26177d5ea35efbfd43fd0ecdca00e3148e0e8e0b9a6f9b329efd6e30c4a461920f3a27497be3dbefaba996601da", ["trace"]]}"#; + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + let _req = serde_json::from_value::(value).unwrap(); + + let s = r#"{"method": "trace_rawTransaction", "params": ["0x02f866827a690a65648252089470997970c51812dc3a010c7d01b50e0d17dc79c88203e980c001a0e4de88aefcf87ccb04466e60de66a83192e46aa26177d5ea35efbfd43fd0ecdca00e3148e0e8e0b9a6f9b329efd6e30c4a461920f3a27497be3dbefaba996601da", ["trace", "stateDiff", "vmTrace"], "latest"]}"#; + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + let _req = serde_json::from_value::(value).unwrap(); + } + #[test] fn test_serde_debug_trace_call() { let s = r#"{"method": "debug_traceCall", "params": [{"data":"0xcfae3217","from":"0xd84de507f3fada7df80908082d3239466db55a71","to":"0xcbe828fdc46e3b1c351ec90b1a5e7d9742c0398d"}]}"#; @@ -1435,6 +2039,80 @@ true}]}"#; let s = r#"{"method": "debug_traceCall", "params": [{"data":"0xcfae3217","from":"0xd84de507f3fada7df80908082d3239466db55a71","to":"0xcbe828fdc46e3b1c351ec90b1a5e7d9742c0398d"}, { "blockNumber": "0x0" }, {"disableStorage": true}]}"#; let value: serde_json::Value = serde_json::from_str(s).unwrap(); let _req = serde_json::from_value::(value).unwrap(); + + let s = r#"{"method": "debug_traceCall", "params": [{"data":"0xcfae3217","from":"0xd84de507f3fada7df80908082d3239466db55a71","to":"0xcbe828fdc46e3b1c351ec90b1a5e7d9742c0398d"}, { "blockNumber": "0x1" }, {"txIndex": "0x0"}]}"#; + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + let req = serde_json::from_value::(value).unwrap(); + match req { + EthRequest::DebugTraceCall(_, _, opts) => assert_eq!(opts.tx_index, Some(0)), + _ => panic!("expected debug_traceCall"), + } + } + + #[test] + fn test_serde_debug_get_modified_accounts_by_number() { + let s = r#"{"method": "debug_getModifiedAccountsByNumber", "params": [0, 1]}"#; + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + match serde_json::from_value::(value).unwrap() { + EthRequest::DebugGetModifiedAccountsByNumber(0, 1) => {} + req => panic!("unexpected request: {req:?}"), + } + } + + #[test] + fn test_serde_debug_free_os_memory() { + let s = r#"{"method": "debug_freeOSMemory", "params": []}"#; + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + match serde_json::from_value::(value).unwrap() { + EthRequest::DebugFreeOsMemory(()) => {} + req => panic!("unexpected request: {req:?}"), + } + } + + #[test] + fn test_serde_trace_call() { + let s = r#"{"method": "trace_call", "params": [{"data":"0xcfae3217","from":"0xd84de507f3fada7df80908082d3239466db55a71","to":"0xcbe828fdc46e3b1c351ec90b1a5e7d9742c0398d"}, ["trace", "stateDiff"], "latest"]}"#; + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + let _req = serde_json::from_value::(value).unwrap(); + + let s = r#"{"method": "trace_call", "params": [{"data":"0xcfae3217","from":"0xd84de507f3fada7df80908082d3239466db55a71","to":"0xcbe828fdc46e3b1c351ec90b1a5e7d9742c0398d"}]}"#; + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + let _req = serde_json::from_value::(value).unwrap(); + } + + #[test] + fn test_serde_trace_call_many() { + let s = r#"{"method": "trace_callMany", "params": [[[{"from":"0xd84de507f3fada7df80908082d3239466db55a71","to":"0xcbe828fdc46e3b1c351ec90b1a5e7d9742c0398d","value":"0x1"}, ["trace"]], [{"from":"0xd84de507f3fada7df80908082d3239466db55a71","to":"0xcbe828fdc46e3b1c351ec90b1a5e7d9742c0398d","value":"0x2"}, ["trace", "stateDiff"]]]]}"#; + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + let _req = serde_json::from_value::(value).unwrap(); + + let s = r#"{"method": "trace_callMany", "params": [[[{"from":"0xd84de507f3fada7df80908082d3239466db55a71","to":"0xcbe828fdc46e3b1c351ec90b1a5e7d9742c0398d"}, ["trace"]]], "latest"]}"#; + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + let _req = serde_json::from_value::(value).unwrap(); + } + + #[test] + fn test_serde_trace_get() { + let s = r#"{"method": "trace_get", "params": ["0x4a3b0fce2cb9707b0baa68640cf2fe858c8bb4121b2a8cb904ff369d38a560ff", ["0x6", "0xa0"]]}"#; + let req = serde_json::from_str::(s).unwrap(); + let EthRequest::TraceGet(_, indices) = req else { panic!("unexpected request {req:?}") }; + assert_eq!(indices.into_iter().map(usize::from).collect::>(), [6, 160]); + + for indices in + [r#"[6, 0]"#, r#"["6"]"#, r#"["0x00"]"#, r#"["0x06"]"#, r#"["0xA"]"#, r#"["0x"]"#] + { + let s = format!( + r#"{{"method": "trace_get", "params": ["0x4a3b0fce2cb9707b0baa68640cf2fe858c8bb4121b2a8cb904ff369d38a560ff", {indices}]}}"# + ); + serde_json::from_str::(&s).unwrap_err(); + } + } + + #[test] + fn test_serde_debug_trace_block() { + let s = r#"{"method":"debug_traceBlock","params":["0xc0",{"tracer":"callTracer"}]}"#; + let value: serde_json::Value = serde_json::from_str(s).unwrap(); + let _req = serde_json::from_value::(value).unwrap(); } #[test] @@ -1445,6 +2123,55 @@ true}]}"#; let _req = serde_json::from_value::(value).unwrap(); } + #[test] + fn test_serde_state_requests_without_block() { + let requests = [ + r#"{"method":"eth_getBalance","params":["0x295a70b2de5e3953354a6a8344e616ed314d7251"]}"#, + r#"{"method":"eth_getCode","params":["0x295a70b2de5e3953354a6a8344e616ed314d7251"]}"#, + r#"{"method":"eth_getStorageAt","params":["0x295a70b2de5e3953354a6a8344e616ed314d7251","0x0"]}"#, + r#"{"method":"eth_getStorageValues","params":[{"0x295a70b2de5e3953354a6a8344e616ed314d7251":["0x0000000000000000000000000000000000000000000000000000000000000000"]}]}"#, + r#"{"method":"eth_getTransactionCount","params":["0x295a70b2de5e3953354a6a8344e616ed314d7251"]}"#, + r#"{"method":"eth_getProof","params":["0x295a70b2de5e3953354a6a8344e616ed314d7251",[]]}"#, + ]; + + for request in requests { + if let EthRequest::EthGetTransactionCount(params) = + serde_json::from_str::(request).unwrap() + { + let (_, block, nonce_key) = params.into_parts(); + assert!(block.is_none()); + assert!(nonce_key.is_none()); + continue; + } + assert!(matches!( + serde_json::from_str::(request).unwrap(), + EthRequest::EthGetBalance(_, None) + | EthRequest::EthGetCodeAt(_, None) + | EthRequest::EthGetStorageAt(_, _, None) + | EthRequest::EthGetStorageValues(_, None) + | EthRequest::EthGetProof(_, _, None) + )); + } + } + + #[test] + fn test_serde_transaction_count_nonce_key_is_base_gated() { + let request = r#"{"method":"eth_getTransactionCount","params":["0x295a70b2de5e3953354a6a8344e616ed314d7251","latest","0x7"]}"#; + let result = serde_json::from_str::(request); + + #[cfg(feature = "base")] + { + let EthRequest::EthGetTransactionCount(params) = result.unwrap() else { + panic!("unexpected request variant") + }; + let (_, block, nonce_key) = params.into_parts(); + assert_eq!(block, Some(BlockId::latest())); + assert_eq!(nonce_key, Some(U256::from(7))); + } + #[cfg(not(feature = "base"))] + assert!(result.is_err()); + } + #[test] fn test_eth_call() { let req = r#"{"data":"0xcfae3217","from":"0xd84de507f3fada7df80908082d3239466db55a71","to":"0xcbe828fdc46e3b1c351ec90b1a5e7d9742c0398d"}"#; @@ -1466,6 +2193,18 @@ true}]}"#; let _req = serde_json::from_str::(s).unwrap(); } + #[test] + fn test_eth_call_many() { + let s = r#"{"method":"eth_callMany","params":[[{"transactions":[{"from":"0xd84de507f3fada7df80908082d3239466db55a71","to":"0xcbe828fdc46e3b1c351ec90b1a5e7d9742c0398d","data":"0xcfae3217"}]}],{"blockNumber":"latest"},{"0x0000000000000000000000000000000000000001":{"balance":"0x1"}}]}"#; + let _req = serde_json::from_str::(s).unwrap(); + } + + #[test] + fn test_eth_call_bundle() { + let s = r#"{"method":"eth_callBundle","params":[{"txs":["0x1234"],"blockNumber":"0x1","stateBlockNumber":"latest"}]}"#; + let _req = serde_json::from_str::(s).unwrap(); + } + #[test] fn test_serde_eth_balance() { let s = r#"{"method": "eth_getBalance", "params": diff --git a/crates/anvil/core/src/eth/serde_helpers.rs b/crates/anvil/core/src/eth/serde_helpers.rs index d111d32f1e3b4..905754c73bdf3 100644 --- a/crates/anvil/core/src/eth/serde_helpers.rs +++ b/crates/anvil/core/src/eth/serde_helpers.rs @@ -31,6 +31,26 @@ pub mod sequence { } } +/// A module that deserializes an optional single-item sequence. +pub mod optional_sequence { + use serde::{Deserialize, Deserializer, de::DeserializeOwned}; + + pub fn deserialize<'de, T, D>(d: D) -> Result, D::Error> + where + D: Deserializer<'de>, + T: DeserializeOwned, + { + let mut seq = Option::>>::deserialize(d)?.unwrap_or_default(); + if seq.len() > 1 { + return Err(serde::de::Error::custom(format!( + "expected params sequence with length 0 or 1 but got {}", + seq.len() + ))); + } + Ok(seq.pop().flatten()) + } +} + /// A module that deserializes `[]` optionally pub mod empty_params { use serde::{Deserialize, Deserializer}; @@ -68,3 +88,35 @@ pub mod lenient_block_number { Ok(num) } } + +/// Deserializes a `trace_get` trace address, whose indices must be minimal hex quantities. +pub mod trace_address { + use alloy_rpc_types::Index; + use serde::{Deserialize, Deserializer, de::Error}; + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + Vec::::deserialize(deserializer)? + .iter() + .map(|index| { + parse(index).ok_or_else(|| { + D::Error::custom(format!( + "invalid trace address index {index:?}, expected a hex quantity" + )) + }) + }) + .collect() + } + + /// Parses `^0x(0|[1-9a-f][0-9a-f]*)$`. + fn parse(index: &str) -> Option { + let digits = index.strip_prefix("0x")?; + let minimal = digits == "0" || !digits.starts_with('0'); + if !minimal || !digits.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) { + return None; + } + usize::from_str_radix(digits, 16).ok().map(Index::from) + } +} diff --git a/crates/anvil/core/src/eth/transaction/mod.rs b/crates/anvil/core/src/eth/transaction/mod.rs index a85d08e38c373..6ecb645dd4f2d 100644 --- a/crates/anvil/core/src/eth/transaction/mod.rs +++ b/crates/anvil/core/src/eth/transaction/mod.rs @@ -1,1068 +1,201 @@ //! Transaction related types use alloy_consensus::{ - Receipt, ReceiptEnvelope, ReceiptWithBloom, Signed, Transaction, TxEip1559, TxEip2930, - TxEnvelope, TxLegacy, TxReceipt, Typed2718, - transaction::{ - Recovered, TxEip7702, - eip4844::{TxEip4844, TxEip4844Variant, TxEip4844WithSidecar}, - }, + Transaction, Typed2718, + crypto::RecoveryError, + transaction::{SignerRecoverable, TxHashRef}, }; -use alloy_eips::eip2718::{Decodable2718, Eip2718Error, Encodable2718}; -use alloy_network::{AnyReceiptEnvelope, AnyRpcTransaction, AnyTransactionReceipt, AnyTxEnvelope}; -use alloy_primitives::{Address, B256, Bloom, Bytes, Log, Signature, TxHash, TxKind, U64, U256}; -use alloy_rlp::{Decodable, Encodable, Header, length_of_length}; -use alloy_rpc_types::{ - AccessList, ConversionError, Transaction as RpcTransaction, TransactionReceipt, - request::TransactionRequest, trace::otterscan::OtsReceipt, -}; -use alloy_serde::{OtherFields, WithOtherFields}; + +use alloy_eips::eip2718::Encodable2718; +use alloy_primitives::{Address, B256, Bytes, TxHash}; +use alloy_rlp::{Decodable, Encodable}; use bytes::BufMut; use foundry_evm::traces::CallTraceNode; -use op_alloy_consensus::{DEPOSIT_TX_TYPE_ID, TxDeposit}; -use op_revm::{OpTransaction, transaction::deposit::DepositTransactionParts}; -use revm::{context::TxEnv, interpreter::InstructionResult}; +use foundry_primitives::FoundryTxEnvelope; +use revm::interpreter::InstructionResult; use serde::{Deserialize, Serialize}; -use std::ops::{Deref, Mul}; - -/// Converts a [TransactionRequest] into a [TypedTransactionRequest]. -/// Should be removed once the call builder abstraction for providers is in place. -pub fn transaction_request_to_typed( - tx: WithOtherFields, -) -> Option { - let WithOtherFields:: { - inner: - TransactionRequest { - from, - to, - gas_price, - max_fee_per_gas, - max_priority_fee_per_gas, - max_fee_per_blob_gas, - blob_versioned_hashes, - gas, - value, - input, - nonce, - access_list, - sidecar, - transaction_type, - authorization_list, - chain_id: _, - }, - other, - } = tx; - - // Special case: OP-stack deposit tx - if transaction_type == Some(0x7E) || has_optimism_fields(&other) { - let mint = other.get_deserialized::("mint")?.map(|m| m.to::()).ok()?; - - return Some(TypedTransactionRequest::Deposit(TxDeposit { - from: from.unwrap_or_default(), - source_hash: other.get_deserialized::("sourceHash")?.ok()?, - to: to.unwrap_or_default(), - mint, - value: value.unwrap_or_default(), - gas_limit: gas.unwrap_or_default(), - is_system_transaction: other.get_deserialized::("isSystemTx")?.ok()?, - input: input.into_input().unwrap_or_default(), - })); - } - - // EIP7702 - if transaction_type == Some(4) || authorization_list.is_some() { - return Some(TypedTransactionRequest::EIP7702(TxEip7702 { - nonce: nonce.unwrap_or_default(), - max_fee_per_gas: max_fee_per_gas.unwrap_or_default(), - max_priority_fee_per_gas: max_priority_fee_per_gas.unwrap_or_default(), - gas_limit: gas.unwrap_or_default(), - value: value.unwrap_or(U256::ZERO), - input: input.into_input().unwrap_or_default(), - // requires to - to: to?.into_to()?, - chain_id: 0, - access_list: access_list.unwrap_or_default(), - authorization_list: authorization_list.unwrap(), - })); - } - - match ( - transaction_type, - gas_price, - max_fee_per_gas, - max_priority_fee_per_gas, - access_list.as_ref(), - max_fee_per_blob_gas, - blob_versioned_hashes.as_ref(), - sidecar.as_ref(), - to, - ) { - // legacy transaction - (Some(0), _, None, None, None, None, None, None, _) - | (None, Some(_), None, None, None, None, None, None, _) => { - Some(TypedTransactionRequest::Legacy(TxLegacy { - nonce: nonce.unwrap_or_default(), - gas_price: gas_price.unwrap_or_default(), - gas_limit: gas.unwrap_or_default(), - value: value.unwrap_or(U256::ZERO), - input: input.into_input().unwrap_or_default(), - to: to.unwrap_or_default(), - chain_id: None, - })) - } - // EIP2930 - (Some(1), _, None, None, _, None, None, None, _) - | (None, _, None, None, Some(_), None, None, None, _) => { - Some(TypedTransactionRequest::EIP2930(TxEip2930 { - nonce: nonce.unwrap_or_default(), - gas_price: gas_price.unwrap_or_default(), - gas_limit: gas.unwrap_or_default(), - value: value.unwrap_or(U256::ZERO), - input: input.into_input().unwrap_or_default(), - to: to.unwrap_or_default(), - chain_id: 0, - access_list: access_list.unwrap_or_default(), - })) - } - // EIP1559 - (Some(2), None, _, _, _, _, None, None, _) - | (None, None, Some(_), _, _, _, None, None, _) - | (None, None, _, Some(_), _, _, None, None, _) - | (None, None, None, None, None, _, None, None, _) => { - // Empty fields fall back to the canonical transaction schema. - Some(TypedTransactionRequest::EIP1559(TxEip1559 { - nonce: nonce.unwrap_or_default(), - max_fee_per_gas: max_fee_per_gas.unwrap_or_default(), - max_priority_fee_per_gas: max_priority_fee_per_gas.unwrap_or_default(), - gas_limit: gas.unwrap_or_default(), - value: value.unwrap_or(U256::ZERO), - input: input.into_input().unwrap_or_default(), - to: to.unwrap_or_default(), - chain_id: 0, - access_list: access_list.unwrap_or_default(), - })) - } - // EIP4844 - (Some(3), None, _, _, _, _, Some(_), _, to) => { - let tx = TxEip4844 { - nonce: nonce.unwrap_or_default(), - max_fee_per_gas: max_fee_per_gas.unwrap_or_default(), - max_priority_fee_per_gas: max_priority_fee_per_gas.unwrap_or_default(), - max_fee_per_blob_gas: max_fee_per_blob_gas.unwrap_or_default(), - gas_limit: gas.unwrap_or_default(), - value: value.unwrap_or(U256::ZERO), - input: input.into_input().unwrap_or_default(), - to: match to.unwrap_or(TxKind::Create) { - TxKind::Call(to) => to, - TxKind::Create => Address::ZERO, - }, - chain_id: 0, - access_list: access_list.unwrap_or_default(), - blob_versioned_hashes: blob_versioned_hashes.unwrap_or_default(), - }; - - if let Some(sidecar) = sidecar { - Some(TypedTransactionRequest::EIP4844(TxEip4844Variant::TxEip4844WithSidecar( - TxEip4844WithSidecar::from_tx_and_sidecar(tx, sidecar), - ))) - } else { - Some(TypedTransactionRequest::EIP4844(TxEip4844Variant::TxEip4844(tx))) - } - } - _ => None, - } -} +use std::ops::Deref; -pub fn has_optimism_fields(other: &OtherFields) -> bool { - other.contains_key("sourceHash") - && other.contains_key("mint") - && other.contains_key("isSystemTx") -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum TypedTransactionRequest { - Legacy(TxLegacy), - EIP2930(TxEip2930), - EIP1559(TxEip1559), - EIP7702(TxEip7702), - EIP4844(TxEip4844Variant), - Deposit(TxDeposit), -} - -/// A wrapper for [TypedTransaction] that allows impersonating accounts. +/// A wrapper for a transaction envelope that allows impersonating accounts. /// /// This is a helper that carries the `impersonated` sender so that the right hash -/// [TypedTransaction::impersonated_hash] can be created. +/// can be created. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -pub struct MaybeImpersonatedTransaction { - pub transaction: TypedTransaction, - pub impersonated_sender: Option
, +pub struct MaybeImpersonatedTransaction { + transaction: T, + impersonated_sender: Option
, +} + +impl Typed2718 for MaybeImpersonatedTransaction { + fn ty(&self) -> u8 { + self.transaction.ty() + } } -impl MaybeImpersonatedTransaction { +impl MaybeImpersonatedTransaction { /// Creates a new wrapper for the given transaction - pub fn new(transaction: TypedTransaction) -> Self { + pub const fn new(transaction: T) -> Self { Self { transaction, impersonated_sender: None } } /// Creates a new impersonated transaction wrapper using the given sender - pub fn impersonated(transaction: TypedTransaction, impersonated_sender: Address) -> Self { + pub const fn impersonated(transaction: T, impersonated_sender: Address) -> Self { Self { transaction, impersonated_sender: Some(impersonated_sender) } } + /// Returns whether the transaction is impersonated + pub const fn is_impersonated(&self) -> bool { + self.impersonated_sender.is_some() + } + + /// Returns the inner transaction. + pub fn into_inner(self) -> T { + self.transaction + } + + /// Maps the inner transaction while preserving impersonation metadata. + pub fn map(self, f: impl FnOnce(T) -> U) -> MaybeImpersonatedTransaction { + MaybeImpersonatedTransaction { + transaction: f(self.transaction), + impersonated_sender: self.impersonated_sender, + } + } +} + +impl MaybeImpersonatedTransaction { /// Recovers the Ethereum address which was used to sign the transaction. - pub fn recover(&self) -> Result { + pub fn recover(&self) -> Result { if let Some(sender) = self.impersonated_sender { return Ok(sender); } - self.transaction.recover() - } - - /// Returns whether the transaction is impersonated - pub fn is_impersonated(&self) -> bool { - self.impersonated_sender.is_some() + self.transaction.recover_signer() } - /// Returns the hash of the transaction + /// Returns the hash of the transaction. + /// + /// If the transaction is impersonated, returns a unique hash derived by appending the + /// impersonated sender address to the encoded transaction before hashing. pub fn hash(&self) -> B256 { if let Some(sender) = self.impersonated_sender { - return self.transaction.impersonated_hash(sender); + let mut buffer = Vec::new(); + self.transaction.encode(&mut buffer); + buffer.extend_from_slice(sender.as_ref()); + return B256::from_slice(alloy_primitives::utils::keccak256(&buffer).as_slice()); } - self.transaction.hash() + *self.transaction.tx_hash() } } -impl Encodable for MaybeImpersonatedTransaction { +impl Encodable2718 for MaybeImpersonatedTransaction { + fn encode_2718_len(&self) -> usize { + self.transaction.encode_2718_len() + } + + fn encode_2718(&self, out: &mut dyn BufMut) { + self.transaction.encode_2718(out) + } +} + +impl Encodable for MaybeImpersonatedTransaction { fn encode(&self, out: &mut dyn bytes::BufMut) { self.transaction.encode(out) } + + fn length(&self) -> usize { + self.transaction.length() + } } -impl From for TypedTransaction { - fn from(value: MaybeImpersonatedTransaction) -> Self { +impl From> for FoundryTxEnvelope { + fn from(value: MaybeImpersonatedTransaction) -> Self { value.transaction } } -impl From for MaybeImpersonatedTransaction { - fn from(value: TypedTransaction) -> Self { +impl From for MaybeImpersonatedTransaction { + fn from(value: T) -> Self { Self::new(value) } } -impl Decodable for MaybeImpersonatedTransaction { +impl Decodable for MaybeImpersonatedTransaction { fn decode(buf: &mut &[u8]) -> alloy_rlp::Result { - TypedTransaction::decode(buf).map(Self::new) + T::decode(buf).map(Self::new) } } -impl AsRef for MaybeImpersonatedTransaction { - fn as_ref(&self) -> &TypedTransaction { +impl AsRef for MaybeImpersonatedTransaction { + fn as_ref(&self) -> &T { &self.transaction } } -impl Deref for MaybeImpersonatedTransaction { - type Target = TypedTransaction; +impl Deref for MaybeImpersonatedTransaction { + type Target = T; fn deref(&self) -> &Self::Target { &self.transaction } } -impl From for RpcTransaction { - fn from(value: MaybeImpersonatedTransaction) -> Self { - let hash = value.hash(); - let sender = value.recover().unwrap_or_default(); - to_alloy_transaction_with_hash_and_sender(value.transaction, hash, sender) - } -} - -pub fn to_alloy_transaction_with_hash_and_sender( - transaction: TypedTransaction, - hash: B256, - from: Address, -) -> RpcTransaction { - match transaction { - TypedTransaction::Legacy(t) => { - let (tx, sig, _) = t.into_parts(); - RpcTransaction { - block_hash: None, - block_number: None, - transaction_index: None, - effective_gas_price: None, - inner: Recovered::new_unchecked( - TxEnvelope::Legacy(Signed::new_unchecked(tx, sig, hash)), - from, - ), - } - } - TypedTransaction::EIP2930(t) => { - let (tx, sig, _) = t.into_parts(); - RpcTransaction { - block_hash: None, - block_number: None, - transaction_index: None, - effective_gas_price: None, - inner: Recovered::new_unchecked( - TxEnvelope::Eip2930(Signed::new_unchecked(tx, sig, hash)), - from, - ), - } - } - TypedTransaction::EIP1559(t) => { - let (tx, sig, _) = t.into_parts(); - RpcTransaction { - block_hash: None, - block_number: None, - transaction_index: None, - effective_gas_price: None, - inner: Recovered::new_unchecked( - TxEnvelope::Eip1559(Signed::new_unchecked(tx, sig, hash)), - from, - ), - } - } - TypedTransaction::EIP4844(t) => { - let (tx, sig, _) = t.into_parts(); - RpcTransaction { - block_hash: None, - block_number: None, - transaction_index: None, - effective_gas_price: None, - inner: Recovered::new_unchecked( - TxEnvelope::Eip4844(Signed::new_unchecked(tx, sig, hash)), - from, - ), - } - } - TypedTransaction::EIP7702(t) => { - let (tx, sig, _) = t.into_parts(); - RpcTransaction { - block_hash: None, - block_number: None, - transaction_index: None, - effective_gas_price: None, - inner: Recovered::new_unchecked( - TxEnvelope::Eip7702(Signed::new_unchecked(tx, sig, hash)), - from, - ), - } - } - TypedTransaction::Deposit(_t) => { - unreachable!("cannot reach here, handled in `transaction_build` ") - } - } -} - /// Queued transaction #[derive(Clone, Debug, PartialEq, Eq)] -pub struct PendingTransaction { +pub struct PendingTransaction { /// The actual transaction - pub transaction: MaybeImpersonatedTransaction, + pub transaction: MaybeImpersonatedTransaction, /// the recovered sender of this transaction sender: Address, /// hash of `transaction`, so it can easily be reused with encoding and hashing again hash: TxHash, } -impl PendingTransaction { - pub fn new(transaction: TypedTransaction) -> Result { - let sender = transaction.recover()?; - let hash = transaction.hash(); - Ok(Self { transaction: MaybeImpersonatedTransaction::new(transaction), sender, hash }) - } - - pub fn with_impersonated(transaction: TypedTransaction, sender: Address) -> Self { - let hash = transaction.impersonated_hash(sender); - Self { - transaction: MaybeImpersonatedTransaction::impersonated(transaction, sender), - sender, - hash, - } - } - - pub fn nonce(&self) -> u64 { - self.transaction.nonce() - } - - pub fn hash(&self) -> &TxHash { +impl PendingTransaction { + pub const fn hash(&self) -> &TxHash { &self.hash } - pub fn sender(&self) -> &Address { + pub const fn sender(&self) -> &Address { &self.sender } - - /// Converts the [PendingTransaction] into the [TxEnv] context that [`revm`](foundry_evm) - /// expects. - /// - /// Base [`TxEnv`] is encapsulated in the [`op_revm::OpTransaction`] - pub fn to_revm_tx_env(&self) -> OpTransaction { - fn transact_to(kind: &TxKind) -> TxKind { - match kind { - TxKind::Call(c) => TxKind::Call(*c), - TxKind::Create => TxKind::Create, - } - } - - let caller = *self.sender(); - match &self.transaction.transaction { - TypedTransaction::Legacy(tx) => { - let chain_id = tx.tx().chain_id; - let TxLegacy { nonce, gas_price, gas_limit, value, to, input, .. } = tx.tx(); - OpTransaction::new(TxEnv { - caller, - kind: transact_to(to), - data: input.clone(), - chain_id, - nonce: *nonce, - value: (*value), - gas_price: *gas_price, - gas_priority_fee: None, - gas_limit: *gas_limit, - access_list: vec![].into(), - tx_type: 0, - ..Default::default() - }) - } - TypedTransaction::EIP2930(tx) => { - let TxEip2930 { - chain_id, - nonce, - gas_price, - gas_limit, - to, - value, - input, - access_list, - .. - } = tx.tx(); - OpTransaction::new(TxEnv { - caller, - kind: transact_to(to), - data: input.clone(), - chain_id: Some(*chain_id), - nonce: *nonce, - value: *value, - gas_price: *gas_price, - gas_priority_fee: None, - gas_limit: *gas_limit, - access_list: access_list.clone(), - tx_type: 1, - ..Default::default() - }) - } - TypedTransaction::EIP1559(tx) => { - let TxEip1559 { - chain_id, - nonce, - max_priority_fee_per_gas, - max_fee_per_gas, - gas_limit, - to, - value, - input, - access_list, - .. - } = tx.tx(); - OpTransaction::new(TxEnv { - caller, - kind: transact_to(to), - data: input.clone(), - chain_id: Some(*chain_id), - nonce: *nonce, - value: *value, - gas_price: *max_fee_per_gas, - gas_priority_fee: Some(*max_priority_fee_per_gas), - gas_limit: *gas_limit, - access_list: access_list.clone(), - tx_type: 2, - ..Default::default() - }) - } - TypedTransaction::EIP4844(tx) => { - let TxEip4844 { - chain_id, - nonce, - max_fee_per_blob_gas, - max_fee_per_gas, - max_priority_fee_per_gas, - gas_limit, - to, - value, - input, - access_list, - blob_versioned_hashes, - .. - } = tx.tx().tx(); - OpTransaction::new(TxEnv { - caller, - kind: TxKind::Call(*to), - data: input.clone(), - chain_id: Some(*chain_id), - nonce: *nonce, - value: *value, - gas_price: *max_fee_per_gas, - gas_priority_fee: Some(*max_priority_fee_per_gas), - max_fee_per_blob_gas: *max_fee_per_blob_gas, - blob_hashes: blob_versioned_hashes.clone(), - gas_limit: *gas_limit, - access_list: access_list.clone(), - tx_type: 3, - ..Default::default() - }) - } - TypedTransaction::EIP7702(tx) => { - let TxEip7702 { - chain_id, - nonce, - gas_limit, - max_fee_per_gas, - max_priority_fee_per_gas, - to, - value, - access_list, - authorization_list, - input, - } = tx.tx(); - - let mut tx = TxEnv { - caller, - kind: TxKind::Call(*to), - data: input.clone(), - chain_id: Some(*chain_id), - nonce: *nonce, - value: *value, - gas_price: *max_fee_per_gas, - gas_priority_fee: Some(*max_priority_fee_per_gas), - gas_limit: *gas_limit, - access_list: access_list.clone(), - tx_type: 4, - ..Default::default() - }; - tx.set_signed_authorization(authorization_list.clone()); - - OpTransaction::new(tx) - } - TypedTransaction::Deposit(tx) => { - let chain_id = tx.chain_id(); - let TxDeposit { - source_hash, - to, - mint, - value, - gas_limit, - is_system_transaction, - input, - .. - } = tx; - - let base = TxEnv { - caller, - kind: transact_to(to), - data: input.clone(), - chain_id, - nonce: 0, - value: *value, - gas_price: 0, - gas_priority_fee: None, - gas_limit: { *gas_limit }, - access_list: vec![].into(), - tx_type: DEPOSIT_TX_TYPE_ID, - ..Default::default() - }; - - let deposit = DepositTransactionParts { - source_hash: *source_hash, - mint: Some(*mint), - is_system_transaction: *is_system_transaction, - }; - - OpTransaction { base, deposit, enveloped_tx: None } - } - } - } -} - -/// Container type for signed, typed transactions. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum TypedTransaction { - /// Legacy transaction type - Legacy(Signed), - /// EIP-2930 transaction - EIP2930(Signed), - /// EIP-1559 transaction - EIP1559(Signed), - /// EIP-4844 transaction - EIP4844(Signed), - /// EIP-7702 transaction - EIP7702(Signed), - /// op-stack deposit transaction - Deposit(TxDeposit), -} - -impl TryFrom for TypedTransaction { - type Error = ConversionError; - - fn try_from(value: AnyRpcTransaction) -> Result { - let WithOtherFields { inner, .. } = value.0; - let from = inner.inner.signer(); - match inner.inner.into_inner() { - AnyTxEnvelope::Ethereum(tx) => match tx { - TxEnvelope::Legacy(tx) => Ok(Self::Legacy(tx)), - TxEnvelope::Eip2930(tx) => Ok(Self::EIP2930(tx)), - TxEnvelope::Eip1559(tx) => Ok(Self::EIP1559(tx)), - TxEnvelope::Eip4844(tx) => Ok(Self::EIP4844(tx)), - TxEnvelope::Eip7702(tx) => Ok(Self::EIP7702(tx)), - }, - AnyTxEnvelope::Unknown(mut tx) => { - // Try to convert to deposit transaction - if tx.ty() == DEPOSIT_TX_TYPE_ID { - tx.inner.fields.insert("from".to_string(), serde_json::to_value(from).unwrap()); - let deposit_tx = - tx.inner.fields.deserialize_into::().map_err(|e| { - ConversionError::Custom(format!( - "Failed to deserialize deposit tx: {e}" - )) - })?; - - return Ok(Self::Deposit(deposit_tx)); - }; - - Err(ConversionError::Custom("UnknownTxType".to_string())) - } - } - } } -impl TypedTransaction { - /// Returns true if the transaction uses dynamic fees: EIP1559, EIP4844 or EIP7702 - pub fn is_dynamic_fee(&self) -> bool { - matches!(self, Self::EIP1559(_) | Self::EIP4844(_) | Self::EIP7702(_)) - } - - pub fn gas_price(&self) -> u128 { - match self { - Self::Legacy(tx) => tx.tx().gas_price, - Self::EIP2930(tx) => tx.tx().gas_price, - Self::EIP1559(tx) => tx.tx().max_fee_per_gas, - Self::EIP4844(tx) => tx.tx().tx().max_fee_per_gas, - Self::EIP7702(tx) => tx.tx().max_fee_per_gas, - Self::Deposit(_) => 0, - } - } - - pub fn gas_limit(&self) -> u64 { - match self { - Self::Legacy(tx) => tx.tx().gas_limit, - Self::EIP2930(tx) => tx.tx().gas_limit, - Self::EIP1559(tx) => tx.tx().gas_limit, - Self::EIP4844(tx) => tx.tx().tx().gas_limit, - Self::EIP7702(tx) => tx.tx().gas_limit, - Self::Deposit(tx) => tx.gas_limit, - } - } - - pub fn value(&self) -> U256 { - U256::from(match self { - Self::Legacy(tx) => tx.tx().value, - Self::EIP2930(tx) => tx.tx().value, - Self::EIP1559(tx) => tx.tx().value, - Self::EIP4844(tx) => tx.tx().tx().value, - Self::EIP7702(tx) => tx.tx().value, - Self::Deposit(tx) => tx.value, - }) - } - - pub fn data(&self) -> &Bytes { - match self { - Self::Legacy(tx) => &tx.tx().input, - Self::EIP2930(tx) => &tx.tx().input, - Self::EIP1559(tx) => &tx.tx().input, - Self::EIP4844(tx) => &tx.tx().tx().input, - Self::EIP7702(tx) => &tx.tx().input, - Self::Deposit(tx) => &tx.input, - } - } - - /// Returns the transaction type - pub fn r#type(&self) -> Option { - match self { - Self::Legacy(_) => None, - Self::EIP2930(_) => Some(1), - Self::EIP1559(_) => Some(2), - Self::EIP4844(_) => Some(3), - Self::EIP7702(_) => Some(4), - Self::Deposit(_) => Some(0x7E), - } - } - - /// Max cost of the transaction - /// It is the gas limit multiplied by the gas price, - /// and if the transaction is EIP-4844, the result of (total blob gas cost * max fee per blob - /// gas) is also added - pub fn max_cost(&self) -> u128 { - let mut max_cost = (self.gas_limit() as u128).saturating_mul(self.gas_price()); - - if self.is_eip4844() { - max_cost = max_cost.saturating_add( - self.blob_gas() - .map(|g| g as u128) - .unwrap_or(0) - .mul(self.max_fee_per_blob_gas().unwrap_or(0)), - ) - } - - max_cost - } - - pub fn blob_gas(&self) -> Option { - match self { - Self::EIP4844(tx) => Some(tx.tx().tx().blob_gas()), - _ => None, - } - } - - pub fn sidecar(&self) -> Option<&TxEip4844WithSidecar> { - match self { - Self::EIP4844(signed_variant) => match signed_variant.tx() { - TxEip4844Variant::TxEip4844WithSidecar(with_sidecar) => Some(with_sidecar), - _ => None, - }, - _ => None, - } - } - - pub fn max_fee_per_blob_gas(&self) -> Option { - match self { - Self::EIP4844(tx) => Some(tx.tx().tx().max_fee_per_blob_gas), - _ => None, - } - } - - /// Returns a helper type that contains commonly used values as fields - pub fn essentials(&self) -> TransactionEssentials { - match self { - Self::Legacy(t) => TransactionEssentials { - kind: t.tx().to, - input: t.tx().input.clone(), - nonce: t.tx().nonce, - gas_limit: t.tx().gas_limit, - gas_price: Some(t.tx().gas_price), - max_fee_per_gas: None, - max_priority_fee_per_gas: None, - max_fee_per_blob_gas: None, - blob_versioned_hashes: None, - value: t.tx().value, - chain_id: t.tx().chain_id, - access_list: Default::default(), - }, - Self::EIP2930(t) => TransactionEssentials { - kind: t.tx().to, - input: t.tx().input.clone(), - nonce: t.tx().nonce, - gas_limit: t.tx().gas_limit, - gas_price: Some(t.tx().gas_price), - max_fee_per_gas: None, - max_priority_fee_per_gas: None, - max_fee_per_blob_gas: None, - blob_versioned_hashes: None, - value: t.tx().value, - chain_id: Some(t.tx().chain_id), - access_list: t.tx().access_list.clone(), - }, - Self::EIP1559(t) => TransactionEssentials { - kind: t.tx().to, - input: t.tx().input.clone(), - nonce: t.tx().nonce, - gas_limit: t.tx().gas_limit, - gas_price: None, - max_fee_per_gas: Some(t.tx().max_fee_per_gas), - max_priority_fee_per_gas: Some(t.tx().max_priority_fee_per_gas), - max_fee_per_blob_gas: None, - blob_versioned_hashes: None, - value: t.tx().value, - chain_id: Some(t.tx().chain_id), - access_list: t.tx().access_list.clone(), - }, - Self::EIP4844(t) => TransactionEssentials { - kind: TxKind::Call(t.tx().tx().to), - input: t.tx().tx().input.clone(), - nonce: t.tx().tx().nonce, - gas_limit: t.tx().tx().gas_limit, - gas_price: Some(t.tx().tx().max_fee_per_blob_gas), - max_fee_per_gas: Some(t.tx().tx().max_fee_per_gas), - max_priority_fee_per_gas: Some(t.tx().tx().max_priority_fee_per_gas), - max_fee_per_blob_gas: Some(t.tx().tx().max_fee_per_blob_gas), - blob_versioned_hashes: Some(t.tx().tx().blob_versioned_hashes.clone()), - value: t.tx().tx().value, - chain_id: Some(t.tx().tx().chain_id), - access_list: t.tx().tx().access_list.clone(), - }, - Self::EIP7702(t) => TransactionEssentials { - kind: TxKind::Call(t.tx().to), - input: t.tx().input.clone(), - nonce: t.tx().nonce, - gas_limit: t.tx().gas_limit, - gas_price: Some(t.tx().max_fee_per_gas), - max_fee_per_gas: Some(t.tx().max_fee_per_gas), - max_priority_fee_per_gas: Some(t.tx().max_priority_fee_per_gas), - max_fee_per_blob_gas: None, - blob_versioned_hashes: None, - value: t.tx().value, - chain_id: Some(t.tx().chain_id), - access_list: t.tx().access_list.clone(), - }, - Self::Deposit(t) => TransactionEssentials { - kind: t.to, - input: t.input.clone(), - nonce: 0, - gas_limit: t.gas_limit, - gas_price: Some(0), - max_fee_per_gas: None, - max_priority_fee_per_gas: None, - max_fee_per_blob_gas: None, - blob_versioned_hashes: None, - value: t.value, - chain_id: t.chain_id(), - access_list: Default::default(), - }, - } - } - - pub fn nonce(&self) -> u64 { - match self { - Self::Legacy(t) => t.tx().nonce, - Self::EIP2930(t) => t.tx().nonce, - Self::EIP1559(t) => t.tx().nonce, - Self::EIP4844(t) => t.tx().tx().nonce, - Self::EIP7702(t) => t.tx().nonce, - Self::Deposit(_t) => 0, - } - } - - pub fn chain_id(&self) -> Option { - match self { - Self::Legacy(t) => t.tx().chain_id, - Self::EIP2930(t) => Some(t.tx().chain_id), - Self::EIP1559(t) => Some(t.tx().chain_id), - Self::EIP4844(t) => Some(t.tx().tx().chain_id), - Self::EIP7702(t) => Some(t.tx().chain_id), - Self::Deposit(t) => t.chain_id(), - } - } - - pub fn as_legacy(&self) -> Option<&Signed> { - match self { - Self::Legacy(tx) => Some(tx), - _ => None, - } - } - - /// Returns true whether this tx is a legacy transaction - pub fn is_legacy(&self) -> bool { - matches!(self, Self::Legacy(_)) - } - - /// Returns true whether this tx is a EIP1559 transaction - pub fn is_eip1559(&self) -> bool { - matches!(self, Self::EIP1559(_)) - } - - /// Returns true whether this tx is a EIP2930 transaction - pub fn is_eip2930(&self) -> bool { - matches!(self, Self::EIP2930(_)) - } - - /// Returns true whether this tx is a EIP4844 transaction - pub fn is_eip4844(&self) -> bool { - matches!(self, Self::EIP4844(_)) - } - - /// Returns the hash of the transaction. - /// - /// Note: If this transaction has the Impersonated signature then this returns a modified unique - /// hash. This allows us to treat impersonated transactions as unique. - pub fn hash(&self) -> B256 { - match self { - Self::Legacy(t) => *t.hash(), - Self::EIP2930(t) => *t.hash(), - Self::EIP1559(t) => *t.hash(), - Self::EIP4844(t) => *t.hash(), - Self::EIP7702(t) => *t.hash(), - Self::Deposit(t) => t.tx_hash(), - } - } - - /// Returns the hash if the transaction is impersonated (using a fake signature) - /// - /// This appends the `address` before hashing it - pub fn impersonated_hash(&self, sender: Address) -> B256 { - let mut buffer = Vec::new(); - Encodable::encode(self, &mut buffer); - buffer.extend_from_slice(sender.as_ref()); - B256::from_slice(alloy_primitives::utils::keccak256(&buffer).as_slice()) - } - - /// Recovers the Ethereum address which was used to sign the transaction. - pub fn recover(&self) -> Result { - match self { - Self::Legacy(tx) => tx.recover_signer(), - Self::EIP2930(tx) => tx.recover_signer(), - Self::EIP1559(tx) => tx.recover_signer(), - Self::EIP4844(tx) => tx.recover_signer(), - Self::EIP7702(tx) => tx.recover_signer(), - Self::Deposit(tx) => Ok(tx.from), - } - } - - /// Returns what kind of transaction this is - pub fn kind(&self) -> TxKind { - match self { - Self::Legacy(tx) => tx.tx().to, - Self::EIP2930(tx) => tx.tx().to, - Self::EIP1559(tx) => tx.tx().to, - Self::EIP4844(tx) => TxKind::Call(tx.tx().tx().to), - Self::EIP7702(tx) => TxKind::Call(tx.tx().to), - Self::Deposit(tx) => tx.to, - } - } - - /// Returns the callee if this transaction is a call - pub fn to(&self) -> Option
{ - self.kind().to().copied() +impl PendingTransaction { + pub fn new(transaction: T) -> Result { + let transaction = MaybeImpersonatedTransaction::new(transaction); + let sender = transaction.recover()?; + let hash = transaction.hash(); + Ok(Self { transaction, sender, hash }) } - /// Returns the Signature of the transaction - pub fn signature(&self) -> Signature { - match self { - Self::Legacy(tx) => *tx.signature(), - Self::EIP2930(tx) => *tx.signature(), - Self::EIP1559(tx) => *tx.signature(), - Self::EIP4844(tx) => *tx.signature(), - Self::EIP7702(tx) => *tx.signature(), - Self::Deposit(_) => Signature::from_scalars_and_parity( - B256::with_last_byte(1), - B256::with_last_byte(1), - false, - ), - } + pub fn with_impersonated(transaction: T, sender: Address) -> Self { + let transaction = MaybeImpersonatedTransaction::impersonated(transaction, sender); + let hash = transaction.hash(); + Self { transaction, sender, hash } } -} - -impl Encodable for TypedTransaction { - fn encode(&self, out: &mut dyn bytes::BufMut) { - if !self.is_legacy() { - Header { list: false, payload_length: self.encode_2718_len() }.encode(out); - } - self.encode_2718(out); + /// Creates a pending transaction from an existing wrapper and authoritative sender. + pub fn with_sender(transaction: MaybeImpersonatedTransaction, sender: Address) -> Self { + let hash = transaction.hash(); + Self { transaction, sender, hash } } -} -impl Decodable for TypedTransaction { - fn decode(buf: &mut &[u8]) -> alloy_rlp::Result { - let mut h_decode_copy = *buf; - let header = alloy_rlp::Header::decode(&mut h_decode_copy)?; - - // Legacy TX - if header.list { - return Ok(TxEnvelope::decode(buf)?.into()); - } - - // Check byte after header - let ty = *h_decode_copy.first().ok_or(alloy_rlp::Error::Custom("empty slice"))?; - - if ty != 0x7E { - Ok(TxEnvelope::decode(buf)?.into()) + /// Converts a [`MaybeImpersonatedTransaction`] into a [`PendingTransaction`]. + pub fn from_maybe_impersonated( + transaction: MaybeImpersonatedTransaction, + ) -> Result { + if let Some(impersonated) = transaction.impersonated_sender { + Ok(Self::with_impersonated(transaction.transaction, impersonated)) } else { - Ok(Self::Deposit(TxDeposit::decode_2718(buf)?)) - } - } -} - -impl Typed2718 for TypedTransaction { - fn ty(&self) -> u8 { - self.r#type().unwrap_or(0) - } -} - -impl Encodable2718 for TypedTransaction { - fn encode_2718_len(&self) -> usize { - match self { - Self::Legacy(tx) => TxEnvelope::from(tx.clone()).encode_2718_len(), - Self::EIP2930(tx) => TxEnvelope::from(tx.clone()).encode_2718_len(), - Self::EIP1559(tx) => TxEnvelope::from(tx.clone()).encode_2718_len(), - Self::EIP4844(tx) => TxEnvelope::from(tx.clone()).encode_2718_len(), - Self::EIP7702(tx) => TxEnvelope::from(tx.clone()).encode_2718_len(), - Self::Deposit(tx) => 1 + tx.length(), - } - } - - fn encode_2718(&self, out: &mut dyn BufMut) { - match self { - Self::Legacy(tx) => TxEnvelope::from(tx.clone()).encode_2718(out), - Self::EIP2930(tx) => TxEnvelope::from(tx.clone()).encode_2718(out), - Self::EIP1559(tx) => TxEnvelope::from(tx.clone()).encode_2718(out), - Self::EIP4844(tx) => TxEnvelope::from(tx.clone()).encode_2718(out), - Self::EIP7702(tx) => TxEnvelope::from(tx.clone()).encode_2718(out), - Self::Deposit(tx) => { - tx.encode_2718(out); - } - } - } -} - -impl Decodable2718 for TypedTransaction { - fn typed_decode(ty: u8, buf: &mut &[u8]) -> Result { - if ty == 0x7E { - return Ok(Self::Deposit(TxDeposit::decode(buf)?)); - } - match TxEnvelope::typed_decode(ty, buf)? { - TxEnvelope::Eip2930(tx) => Ok(Self::EIP2930(tx)), - TxEnvelope::Eip1559(tx) => Ok(Self::EIP1559(tx)), - TxEnvelope::Eip4844(tx) => Ok(Self::EIP4844(tx)), - TxEnvelope::Eip7702(tx) => Ok(Self::EIP7702(tx)), - _ => unreachable!(), - } - } - - fn fallback_decode(buf: &mut &[u8]) -> Result { - match TxEnvelope::fallback_decode(buf)? { - TxEnvelope::Legacy(tx) => Ok(Self::Legacy(tx)), - _ => unreachable!(), + Self::new(transaction.transaction) } } } -impl From for TypedTransaction { - fn from(value: TxEnvelope) -> Self { - match value { - TxEnvelope::Legacy(tx) => Self::Legacy(tx), - TxEnvelope::Eip2930(tx) => Self::EIP2930(tx), - TxEnvelope::Eip1559(tx) => Self::EIP1559(tx), - TxEnvelope::Eip4844(tx) => Self::EIP4844(tx), - _ => unreachable!(), - } +impl PendingTransaction { + pub fn nonce(&self) -> u64 { + self.transaction.nonce() } } -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct TransactionEssentials { - pub kind: TxKind, - pub input: Bytes, - pub nonce: u64, - pub gas_limit: u64, - pub gas_price: Option, - pub max_fee_per_gas: Option, - pub max_priority_fee_per_gas: Option, - pub max_fee_per_blob_gas: Option, - pub blob_versioned_hashes: Option>, - pub value: U256, - pub chain_id: Option, - pub access_list: AccessList, -} - /// Represents all relevant information of an executed transaction #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct TransactionInfo { @@ -1078,642 +211,24 @@ pub struct TransactionInfo { pub gas_used: u64, } -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct DepositReceipt> { - #[serde(flatten)] - pub inner: ReceiptWithBloom, - #[serde(default, with = "alloy_serde::quantity::opt")] - pub deposit_nonce: Option, - #[serde(default, with = "alloy_serde::quantity::opt")] - pub deposit_receipt_version: Option, -} - -impl DepositReceipt { - fn payload_len(&self) -> usize { - self.inner.receipt.status.length() - + self.inner.receipt.cumulative_gas_used.length() - + self.inner.logs_bloom.length() - + self.inner.receipt.logs.length() - + self.deposit_nonce.map_or(0, |n| n.length()) - + self.deposit_receipt_version.map_or(0, |n| n.length()) - } - - /// Returns the rlp header for the receipt payload. - fn receipt_rlp_header(&self) -> alloy_rlp::Header { - alloy_rlp::Header { list: true, payload_length: self.payload_len() } - } - - /// Encodes the receipt data. - fn encode_fields(&self, out: &mut dyn BufMut) { - self.receipt_rlp_header().encode(out); - self.inner.status().encode(out); - self.inner.receipt.cumulative_gas_used.encode(out); - self.inner.logs_bloom.encode(out); - self.inner.receipt.logs.encode(out); - if let Some(n) = self.deposit_nonce { - n.encode(out); - } - if let Some(n) = self.deposit_receipt_version { - n.encode(out); - } - } - - /// Decodes the receipt payload - fn decode_receipt(buf: &mut &[u8]) -> alloy_rlp::Result { - let b: &mut &[u8] = &mut &**buf; - let rlp_head = alloy_rlp::Header::decode(b)?; - if !rlp_head.list { - return Err(alloy_rlp::Error::UnexpectedString); - } - let started_len = b.len(); - let remaining = |b: &[u8]| rlp_head.payload_length - (started_len - b.len()) > 0; - - let status = Decodable::decode(b)?; - let cumulative_gas_used = Decodable::decode(b)?; - let logs_bloom = Decodable::decode(b)?; - let logs: Vec = Decodable::decode(b)?; - let deposit_nonce = remaining(b).then(|| alloy_rlp::Decodable::decode(b)).transpose()?; - let deposit_nonce_version = - remaining(b).then(|| alloy_rlp::Decodable::decode(b)).transpose()?; - - let this = Self { - inner: ReceiptWithBloom { - receipt: Receipt { status, cumulative_gas_used, logs }, - logs_bloom, - }, - deposit_nonce, - deposit_receipt_version: deposit_nonce_version, - }; - - let consumed = started_len - b.len(); - if consumed != rlp_head.payload_length { - return Err(alloy_rlp::Error::ListLengthMismatch { - expected: rlp_head.payload_length, - got: consumed, - }); - } - - *buf = *b; - Ok(this) - } -} - -impl alloy_rlp::Encodable for DepositReceipt { - fn encode(&self, out: &mut dyn BufMut) { - self.encode_fields(out); - } - - fn length(&self) -> usize { - let payload_length = self.payload_len(); - payload_length + length_of_length(payload_length) - } -} - -impl alloy_rlp::Decodable for DepositReceipt { - fn decode(buf: &mut &[u8]) -> alloy_rlp::Result { - Self::decode_receipt(buf) - } -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "type")] -pub enum TypedReceipt> { - #[serde(rename = "0x0", alias = "0x00")] - Legacy(ReceiptWithBloom), - #[serde(rename = "0x1", alias = "0x01")] - EIP2930(ReceiptWithBloom), - #[serde(rename = "0x2", alias = "0x02")] - EIP1559(ReceiptWithBloom), - #[serde(rename = "0x3", alias = "0x03")] - EIP4844(ReceiptWithBloom), - #[serde(rename = "0x4", alias = "0x04")] - EIP7702(ReceiptWithBloom), - #[serde(rename = "0x7E", alias = "0x7e")] - Deposit(DepositReceipt), -} - -impl TypedReceipt { - pub fn as_receipt_with_bloom(&self) -> &ReceiptWithBloom { - match self { - Self::Legacy(r) - | Self::EIP1559(r) - | Self::EIP2930(r) - | Self::EIP4844(r) - | Self::EIP7702(r) => r, - Self::Deposit(r) => &r.inner, - } - } -} - -impl From> for ReceiptWithBloom { - fn from(value: TypedReceipt) -> Self { - match value { - TypedReceipt::Legacy(r) - | TypedReceipt::EIP1559(r) - | TypedReceipt::EIP2930(r) - | TypedReceipt::EIP4844(r) - | TypedReceipt::EIP7702(r) => r, - TypedReceipt::Deposit(r) => r.inner, - } - } -} - -impl From>> for OtsReceipt { - fn from(value: TypedReceipt>) -> Self { - let r#type = match value { - TypedReceipt::Legacy(_) => 0x00, - TypedReceipt::EIP2930(_) => 0x01, - TypedReceipt::EIP1559(_) => 0x02, - TypedReceipt::EIP4844(_) => 0x03, - TypedReceipt::EIP7702(_) => 0x04, - TypedReceipt::Deposit(_) => 0x7E, - } as u8; - let receipt = ReceiptWithBloom::>::from(value); - let status = receipt.status(); - let cumulative_gas_used = receipt.cumulative_gas_used(); - let logs = receipt.logs().to_vec(); - let logs_bloom = receipt.logs_bloom; - - Self { status, cumulative_gas_used, logs: Some(logs), logs_bloom: Some(logs_bloom), r#type } - } -} - -impl TypedReceipt { - pub fn cumulative_gas_used(&self) -> u64 { - self.as_receipt_with_bloom().cumulative_gas_used() - } - - pub fn logs_bloom(&self) -> &Bloom { - &self.as_receipt_with_bloom().logs_bloom - } - - pub fn logs(&self) -> &[Log] { - self.as_receipt_with_bloom().logs() - } -} - -impl From> for TypedReceipt> { - fn from(value: ReceiptEnvelope) -> Self { - match value { - ReceiptEnvelope::Legacy(r) => Self::Legacy(r), - ReceiptEnvelope::Eip2930(r) => Self::EIP2930(r), - ReceiptEnvelope::Eip1559(r) => Self::EIP1559(r), - ReceiptEnvelope::Eip4844(r) => Self::EIP4844(r), - _ => unreachable!(), - } - } -} - -impl Encodable for TypedReceipt { - fn encode(&self, out: &mut dyn bytes::BufMut) { - match self { - Self::Legacy(r) => r.encode(out), - receipt => { - let payload_len = match receipt { - Self::EIP2930(r) => r.length() + 1, - Self::EIP1559(r) => r.length() + 1, - Self::EIP4844(r) => r.length() + 1, - Self::Deposit(r) => r.length() + 1, - _ => unreachable!("receipt already matched"), - }; - - match receipt { - Self::EIP2930(r) => { - Header { list: true, payload_length: payload_len }.encode(out); - 1u8.encode(out); - r.encode(out); - } - Self::EIP1559(r) => { - Header { list: true, payload_length: payload_len }.encode(out); - 2u8.encode(out); - r.encode(out); - } - Self::EIP4844(r) => { - Header { list: true, payload_length: payload_len }.encode(out); - 3u8.encode(out); - r.encode(out); - } - Self::Deposit(r) => { - Header { list: true, payload_length: payload_len }.encode(out); - 0x7Eu8.encode(out); - r.encode(out); - } - _ => unreachable!("receipt already matched"), - } - } - } - } -} - -impl Decodable for TypedReceipt { - fn decode(buf: &mut &[u8]) -> alloy_rlp::Result { - use bytes::Buf; - use std::cmp::Ordering; - - // a receipt is either encoded as a string (non legacy) or a list (legacy). - // We should not consume the buffer if we are decoding a legacy receipt, so let's - // check if the first byte is between 0x80 and 0xbf. - let rlp_type = *buf - .first() - .ok_or(alloy_rlp::Error::Custom("cannot decode a receipt from empty bytes"))?; - - match rlp_type.cmp(&alloy_rlp::EMPTY_LIST_CODE) { - Ordering::Less => { - // strip out the string header - let _header = Header::decode(buf)?; - let receipt_type = *buf.first().ok_or(alloy_rlp::Error::Custom( - "typed receipt cannot be decoded from an empty slice", - ))?; - if receipt_type == 0x01 { - buf.advance(1); - ::decode(buf).map(TypedReceipt::EIP2930) - } else if receipt_type == 0x02 { - buf.advance(1); - ::decode(buf).map(TypedReceipt::EIP1559) - } else if receipt_type == 0x03 { - buf.advance(1); - ::decode(buf).map(TypedReceipt::EIP4844) - } else if receipt_type == 0x7E { - buf.advance(1); - ::decode(buf).map(TypedReceipt::Deposit) - } else { - Err(alloy_rlp::Error::Custom("invalid receipt type")) - } - } - Ordering::Equal => { - Err(alloy_rlp::Error::Custom("an empty list is not a valid receipt encoding")) - } - Ordering::Greater => { - ::decode(buf).map(TypedReceipt::Legacy) - } - } - } -} - -impl Typed2718 for TypedReceipt { - fn ty(&self) -> u8 { - match self { - Self::Legacy(_) => alloy_consensus::constants::LEGACY_TX_TYPE_ID, - Self::EIP2930(_) => alloy_consensus::constants::EIP2930_TX_TYPE_ID, - Self::EIP1559(_) => alloy_consensus::constants::EIP1559_TX_TYPE_ID, - Self::EIP4844(_) => alloy_consensus::constants::EIP4844_TX_TYPE_ID, - Self::EIP7702(_) => alloy_consensus::constants::EIP7702_TX_TYPE_ID, - Self::Deposit(_) => DEPOSIT_TX_TYPE_ID, - } - } -} - -impl Encodable2718 for TypedReceipt { - fn encode_2718_len(&self) -> usize { - match self { - Self::Legacy(r) => ReceiptEnvelope::Legacy(r.clone()).encode_2718_len(), - Self::EIP2930(r) => ReceiptEnvelope::Eip2930(r.clone()).encode_2718_len(), - Self::EIP1559(r) => ReceiptEnvelope::Eip1559(r.clone()).encode_2718_len(), - Self::EIP4844(r) => ReceiptEnvelope::Eip4844(r.clone()).encode_2718_len(), - Self::EIP7702(r) => 1 + r.length(), - Self::Deposit(r) => 1 + r.length(), - } - } - - fn encode_2718(&self, out: &mut dyn BufMut) { - if let Some(ty) = self.type_flag() { - out.put_u8(ty); - } - match self { - Self::Legacy(r) - | Self::EIP2930(r) - | Self::EIP1559(r) - | Self::EIP4844(r) - | Self::EIP7702(r) => r.encode(out), - Self::Deposit(r) => r.encode(out), - } - } -} - -impl Decodable2718 for TypedReceipt { - fn typed_decode(ty: u8, buf: &mut &[u8]) -> Result { - if ty == 0x7E { - return Ok(Self::Deposit(DepositReceipt::decode(buf)?)); - } - match ReceiptEnvelope::typed_decode(ty, buf)? { - ReceiptEnvelope::Eip2930(tx) => Ok(Self::EIP2930(tx)), - ReceiptEnvelope::Eip1559(tx) => Ok(Self::EIP1559(tx)), - ReceiptEnvelope::Eip4844(tx) => Ok(Self::EIP4844(tx)), - _ => unreachable!(), - } - } - - fn fallback_decode(buf: &mut &[u8]) -> Result { - match ReceiptEnvelope::fallback_decode(buf)? { - ReceiptEnvelope::Legacy(tx) => Ok(Self::Legacy(tx)), - _ => unreachable!(), - } - } -} - -pub type ReceiptResponse = TransactionReceipt>>; - -pub fn convert_to_anvil_receipt(receipt: AnyTransactionReceipt) -> Option { - let WithOtherFields { - inner: - TransactionReceipt { - transaction_hash, - transaction_index, - block_hash, - block_number, - gas_used, - contract_address, - effective_gas_price, - from, - to, - blob_gas_price, - blob_gas_used, - inner: AnyReceiptEnvelope { inner: receipt_with_bloom, r#type }, - }, - other, - } = receipt; - - Some(TransactionReceipt { - transaction_hash, - transaction_index, - block_hash, - block_number, - gas_used, - contract_address, - effective_gas_price, - from, - to, - blob_gas_price, - blob_gas_used, - inner: match r#type { - 0x00 => TypedReceipt::Legacy(receipt_with_bloom), - 0x01 => TypedReceipt::EIP2930(receipt_with_bloom), - 0x02 => TypedReceipt::EIP1559(receipt_with_bloom), - 0x03 => TypedReceipt::EIP4844(receipt_with_bloom), - 0x04 => TypedReceipt::EIP7702(receipt_with_bloom), - 0x7E => TypedReceipt::Deposit(DepositReceipt { - inner: receipt_with_bloom, - deposit_nonce: other - .get_deserialized::("depositNonce") - .transpose() - .ok()? - .map(|v| v.to()), - deposit_receipt_version: other - .get_deserialized::("depositReceiptVersion") - .transpose() - .ok()? - .map(|v| v.to()), - }), - _ => return None, - }, - }) -} - #[cfg(test)] mod tests { use super::*; - use alloy_primitives::{LogData, b256, hex}; - use std::str::FromStr; - - // - #[test] - fn test_receipt_convert() { - let s = r#"{"type":"0x4","status":"0x1","cumulativeGasUsed":"0x903fd1","logs":[{"address":"0x0000d9fcd47bf761e7287d8ee09917d7e2100000","topics":["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef","0x0000000000000000000000000000000000000000000000000000000000000000","0x000000000000000000000000234ce51365b9c417171b6dad280f49143e1b0547"],"data":"0x00000000000000000000000000000000000000000000032139b42c3431700000","blockHash":"0xd26b59c1d8b5bfa9362d19eb0da3819dfe0b367987a71f6d30908dd45e0d7a60","blockNumber":"0x159663e","blockTimestamp":"0x68411f7b","transactionHash":"0x17a6af73d1317e69cfc3cac9221bd98261d40f24815850a44dbfbf96652ae52a","transactionIndex":"0x22","logIndex":"0x158","removed":false}],"logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000000000000000008100000000000000000000000000000000000000000000000020000200000000000000800000000800000000000000010000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000","transactionHash":"0x17a6af73d1317e69cfc3cac9221bd98261d40f24815850a44dbfbf96652ae52a","transactionIndex":"0x22","blockHash":"0xd26b59c1d8b5bfa9362d19eb0da3819dfe0b367987a71f6d30908dd45e0d7a60","blockNumber":"0x159663e","gasUsed":"0x28ee7","effectiveGasPrice":"0x4bf02090","from":"0x234ce51365b9c417171b6dad280f49143e1b0547","to":"0x234ce51365b9c417171b6dad280f49143e1b0547","contractAddress":null}"#; - let receipt: AnyTransactionReceipt = serde_json::from_str(s).unwrap(); - let _converted = convert_to_anvil_receipt(receipt).unwrap(); - } - - #[test] - fn test_decode_call() { - let bytes_first = &mut &hex::decode("f86b02843b9aca00830186a094d3e8763675e4c425df46cc3b5c0f6cbdac39604687038d7ea4c68000802ba00eb96ca19e8a77102767a41fc85a36afd5c61ccb09911cec5d3e86e193d9c5aea03a456401896b1b6055311536bf00a718568c744d8c1f9df59879e8350220ca18").unwrap()[..]; - let decoded = TypedTransaction::decode(&mut &bytes_first[..]).unwrap(); - - let tx = TxLegacy { - nonce: 2u64, - gas_price: 1000000000u128, - gas_limit: 100000, - to: TxKind::Call(Address::from_slice( - &hex::decode("d3e8763675e4c425df46cc3b5c0f6cbdac396046").unwrap()[..], - )), - value: U256::from(1000000000000000u64), - input: Bytes::default(), - chain_id: Some(4), - }; - let signature = Signature::from_str("0eb96ca19e8a77102767a41fc85a36afd5c61ccb09911cec5d3e86e193d9c5ae3a456401896b1b6055311536bf00a718568c744d8c1f9df59879e8350220ca182b").unwrap(); + struct EncodableLength; - let tx = TypedTransaction::Legacy(Signed::new_unchecked( - tx, - signature, - b256!("0xa517b206d2223278f860ea017d3626cacad4f52ff51030dc9a96b432f17f8d34"), - )); - - assert_eq!(tx, decoded); - } - - #[test] - fn test_decode_create_goerli() { - // test that an example create tx from goerli decodes properly - let tx_bytes = - hex::decode("02f901ee05228459682f008459682f11830209bf8080b90195608060405234801561001057600080fd5b50610175806100206000396000f3fe608060405234801561001057600080fd5b506004361061002b5760003560e01c80630c49c36c14610030575b600080fd5b61003861004e565b604051610045919061011d565b60405180910390f35b60606020600052600f6020527f68656c6c6f2073746174656d696e64000000000000000000000000000000000060405260406000f35b600081519050919050565b600082825260208201905092915050565b60005b838110156100be5780820151818401526020810190506100a3565b838111156100cd576000848401525b50505050565b6000601f19601f8301169050919050565b60006100ef82610084565b6100f9818561008f565b93506101098185602086016100a0565b610112816100d3565b840191505092915050565b6000602082019050818103600083015261013781846100e4565b90509291505056fea264697066735822122051449585839a4ea5ac23cae4552ef8a96b64ff59d0668f76bfac3796b2bdbb3664736f6c63430008090033c080a0136ebffaa8fc8b9fda9124de9ccb0b1f64e90fbd44251b4c4ac2501e60b104f9a07eb2999eec6d185ef57e91ed099afb0a926c5b536f0155dd67e537c7476e1471") - .unwrap(); - let _decoded = TypedTransaction::decode(&mut &tx_bytes[..]).unwrap(); - } - - #[test] - fn can_recover_sender() { - // random mainnet tx: https://etherscan.io/tx/0x86718885c4b4218c6af87d3d0b0d83e3cc465df2a05c048aa4db9f1a6f9de91f - let bytes = hex::decode("02f872018307910d808507204d2cb1827d0094388c818ca8b9251b393131c08a736a67ccb19297880320d04823e2701c80c001a0cf024f4815304df2867a1a74e9d2707b6abda0337d2d54a4438d453f4160f190a07ac0e6b3bc9395b5b9c8b9e6d77204a236577a5b18467b9175c01de4faa208d9").unwrap(); - - let Ok(TypedTransaction::EIP1559(tx)) = TypedTransaction::decode(&mut &bytes[..]) else { - panic!("decoding TypedTransaction failed"); - }; - - assert_eq!( - tx.hash(), - &"0x86718885c4b4218c6af87d3d0b0d83e3cc465df2a05c048aa4db9f1a6f9de91f" - .parse::() - .unwrap() - ); - assert_eq!( - tx.recover_signer().unwrap(), - "0x95222290DD7278Aa3Ddd389Cc1E1d165CC4BAfe5".parse::
().unwrap() - ); - } - - // Test vector from https://sepolia.etherscan.io/tx/0x9a22ccb0029bc8b0ddd073be1a1d923b7ae2b2ea52100bae0db4424f9107e9c0 - // Blobscan: https://sepolia.blobscan.com/tx/0x9a22ccb0029bc8b0ddd073be1a1d923b7ae2b2ea52100bae0db4424f9107e9c0 - #[test] - fn test_decode_live_4844_tx() { - use alloy_primitives::{address, b256}; - - // https://sepolia.etherscan.io/getRawTx?tx=0x9a22ccb0029bc8b0ddd073be1a1d923b7ae2b2ea52100bae0db4424f9107e9c0 - let raw_tx = alloy_primitives::hex::decode("0x03f9011d83aa36a7820fa28477359400852e90edd0008252089411e9ca82a3a762b4b5bd264d4173a242e7a770648080c08504a817c800f8a5a0012ec3d6f66766bedb002a190126b3549fce0047de0d4c25cffce0dc1c57921aa00152d8e24762ff22b1cfd9f8c0683786a7ca63ba49973818b3d1e9512cd2cec4a0013b98c6c83e066d5b14af2b85199e3d4fc7d1e778dd53130d180f5077e2d1c7a001148b495d6e859114e670ca54fb6e2657f0cbae5b08063605093a4b3dc9f8f1a0011ac212f13c5dff2b2c6b600a79635103d6f580a4221079951181b25c7e654901a0c8de4cced43169f9aa3d36506363b2d2c44f6c49fc1fd91ea114c86f3757077ea01e11fdd0d1934eda0492606ee0bb80a7bf8f35cc5f86ec60fe5031ba48bfd544").unwrap(); - let res = TypedTransaction::decode(&mut raw_tx.as_slice()).unwrap(); - assert_eq!(res.r#type(), Some(3)); - - let tx = match res { - TypedTransaction::EIP4844(tx) => tx, - _ => unreachable!(), - }; - - assert_eq!(tx.tx().tx().to, address!("0x11E9CA82A3a762b4B5bd264d4173a242e7a77064")); - - assert_eq!( - tx.tx().tx().blob_versioned_hashes, - vec![ - b256!("0x012ec3d6f66766bedb002a190126b3549fce0047de0d4c25cffce0dc1c57921a"), - b256!("0x0152d8e24762ff22b1cfd9f8c0683786a7ca63ba49973818b3d1e9512cd2cec4"), - b256!("0x013b98c6c83e066d5b14af2b85199e3d4fc7d1e778dd53130d180f5077e2d1c7"), - b256!("0x01148b495d6e859114e670ca54fb6e2657f0cbae5b08063605093a4b3dc9f8f1"), - b256!("0x011ac212f13c5dff2b2c6b600a79635103d6f580a4221079951181b25c7e6549") - ] - ); - - let from = tx.recover_signer().unwrap(); - assert_eq!(from, address!("0xA83C816D4f9b2783761a22BA6FADB0eB0606D7B2")); - } - - #[test] - fn test_decode_encode_deposit_tx() { - // https://sepolia-optimism.etherscan.io/tx/0xbf8b5f08c43e4b860715cd64fc0849bbce0d0ea20a76b269e7bc8886d112fca7 - let tx_hash: TxHash = "0xbf8b5f08c43e4b860715cd64fc0849bbce0d0ea20a76b269e7bc8886d112fca7" - .parse::() - .unwrap(); - - // https://sepolia-optimism.etherscan.io/getRawTx?tx=0xbf8b5f08c43e4b860715cd64fc0849bbce0d0ea20a76b269e7bc8886d112fca7 - let raw_tx = alloy_primitives::hex::decode( - "7ef861a0dfd7ae78bf3c414cfaa77f13c0205c82eb9365e217b2daa3448c3156b69b27ac94778f2146f48179643473b82931c4cd7b8f153efd94778f2146f48179643473b82931c4cd7b8f153efd872386f26fc10000872386f26fc10000830186a08080", - ) - .unwrap(); - let dep_tx = TypedTransaction::decode(&mut raw_tx.as_slice()).unwrap(); - - let mut encoded = Vec::new(); - dep_tx.encode_2718(&mut encoded); - - assert_eq!(raw_tx, encoded); - - assert_eq!(tx_hash, dep_tx.hash()); - } - - #[test] - fn can_recover_sender_not_normalized() { - let bytes = hex::decode("f85f800182520894095e7baea6a6c7c4c2dfeb977efac326af552d870a801ba048b55bfa915ac795c431978d8a6a992b628d557da5ff759b307d495a36649353a0efffd310ac743f371de3b9f7f9cb56c0b28ad43601b4ab949f53faa07bd2c804").unwrap(); - - let Ok(TypedTransaction::Legacy(tx)) = TypedTransaction::decode(&mut &bytes[..]) else { - panic!("decoding TypedTransaction failed"); - }; - - assert_eq!(tx.tx().input, Bytes::from(b"")); - assert_eq!(tx.tx().gas_price, 1); - assert_eq!(tx.tx().gas_limit, 21000); - assert_eq!(tx.tx().nonce, 0); - if let TxKind::Call(to) = tx.tx().to { - assert_eq!( - to, - "0x095e7baea6a6c7c4c2dfeb977efac326af552d87".parse::
().unwrap() - ); - } else { - panic!("expected a call transaction"); + impl Encodable for EncodableLength { + fn encode(&self, _: &mut dyn BufMut) { + panic!("length should not encode") } - assert_eq!(tx.tx().value, U256::from(0x0au64)); - assert_eq!( - tx.recover_signer().unwrap(), - "0f65fe9276bc9a24ae7083ae28e2660ef72df99e".parse::
().unwrap() - ); - } - - #[test] - fn encode_legacy_receipt() { - let expected = hex::decode("f901668001b9010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f85ff85d940000000000000000000000000000000000000011f842a0000000000000000000000000000000000000000000000000000000000000deada0000000000000000000000000000000000000000000000000000000000000beef830100ff").unwrap(); - let mut data = vec![]; - let receipt = TypedReceipt::Legacy(ReceiptWithBloom { - receipt: Receipt { - status: false.into(), - cumulative_gas_used: 0x1, - logs: vec![Log { - address: Address::from_str("0000000000000000000000000000000000000011").unwrap(), - data: LogData::new_unchecked( - vec![ - B256::from_str( - "000000000000000000000000000000000000000000000000000000000000dead", - ) - .unwrap(), - B256::from_str( - "000000000000000000000000000000000000000000000000000000000000beef", - ) - .unwrap(), - ], - Bytes::from_str("0100ff").unwrap(), - ), - }], - }, - logs_bloom: [0; 256].into(), - }); - - receipt.encode(&mut data); - - // check that the rlp length equals the length of the expected rlp - assert_eq!(receipt.length(), expected.len()); - assert_eq!(data, expected); - } - - #[test] - fn decode_legacy_receipt() { - let data = hex::decode("f901668001b9010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f85ff85d940000000000000000000000000000000000000011f842a0000000000000000000000000000000000000000000000000000000000000deada0000000000000000000000000000000000000000000000000000000000000beef830100ff").unwrap(); - - let expected = TypedReceipt::Legacy(ReceiptWithBloom { - receipt: Receipt { - status: false.into(), - cumulative_gas_used: 0x1, - logs: vec![Log { - address: Address::from_str("0000000000000000000000000000000000000011").unwrap(), - data: LogData::new_unchecked( - vec![ - B256::from_str( - "000000000000000000000000000000000000000000000000000000000000dead", - ) - .unwrap(), - B256::from_str( - "000000000000000000000000000000000000000000000000000000000000beef", - ) - .unwrap(), - ], - Bytes::from_str("0100ff").unwrap(), - ), - }], - }, - logs_bloom: [0; 256].into(), - }); - - let receipt = TypedReceipt::decode(&mut &data[..]).unwrap(); - - assert_eq!(receipt, expected); + fn length(&self) -> usize { + 42 + } } #[test] - fn deser_to_type_tx() { - let tx = r#" - { - "EIP1559": { - "chainId": "0x7a69", - "nonce": "0x0", - "gas": "0x5209", - "maxFeePerGas": "0x77359401", - "maxPriorityFeePerGas": "0x1", - "to": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", - "value": "0x0", - "accessList": [], - "input": "0x", - "r": "0x85c2794a580da137e24ccc823b45ae5cea99371ae23ee13860fcc6935f8305b0", - "s": "0x41de7fa4121dab284af4453d30928241208bafa90cdb701fe9bc7054759fe3cd", - "yParity": "0x0", - "hash": "0x8c9b68e8947ace33028dba167354fde369ed7bbe34911b772d09b3c64b861515" - } - }"#; - - let _typed_tx: TypedTransaction = serde_json::from_str(tx).unwrap(); + fn rlp_length_delegates_to_inner_transaction() { + assert_eq!(MaybeImpersonatedTransaction::new(EncodableLength).length(), 42); } } diff --git a/crates/anvil/core/src/eth/wallet.rs b/crates/anvil/core/src/eth/wallet.rs index 72e085e568608..0fe703e1be0d0 100644 --- a/crates/anvil/core/src/eth/wallet.rs +++ b/crates/anvil/core/src/eth/wallet.rs @@ -1,63 +1,28 @@ -use alloy_primitives::{Address, ChainId, U64, map::HashMap}; -use serde::{Deserialize, Serialize}; - -/// The capability to perform [EIP-7702][eip-7702] delegations, sponsored by the sequencer. -/// -/// The sequencer will only perform delegations, and act on behalf of delegated accounts, if the -/// account delegates to one of the addresses specified within this capability. -/// -/// [eip-7702]: https://eips.ethereum.org/EIPS/eip-7702 -#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize, Default)] -pub struct DelegationCapability { - /// A list of valid delegation contracts. - pub addresses: Vec
, -} - -/// Wallet capabilities for a specific chain. -#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize, Default)] -pub struct Capabilities { - /// The capability to delegate. - pub delegation: DelegationCapability, -} - -/// A map of wallet capabilities per chain ID. -#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize, Default)] -pub struct WalletCapabilities(HashMap); - -impl WalletCapabilities { - /// Get the capabilities of the wallet API for the specified chain ID. - pub fn get(&self, chain_id: ChainId) -> Option<&Capabilities> { - self.0.get(&U64::from(chain_id)) - } - - pub fn insert(&mut self, chain_id: ChainId, capabilities: Capabilities) { - self.0.insert(U64::from(chain_id), capabilities); - } -} +pub use alloy_eip5792::*; #[derive(Debug, thiserror::Error)] pub enum WalletError { /// The transaction value is not 0. /// /// The value should be 0 to prevent draining the sequencer. - #[error("tx value not zero")] + #[error("transaction value must be zero for delegated transactions")] ValueNotZero, /// The from field is set on the transaction. /// /// Requests with the from field are rejected, since it is implied that it will always be the /// sequencer. - #[error("tx from field is set")] + #[error("transaction 'from' field should not be set for delegated transactions")] FromSet, /// The nonce field is set on the transaction. /// /// Requests with the nonce field set are rejected, as this is managed by the sequencer. - #[error("tx nonce is set")] + #[error("transaction nonce should not be set for delegated transactions")] NonceSet, /// An authorization item was invalid. /// /// The item is invalid if it tries to delegate an account to a contract that is not /// whitelisted. - #[error("invalid authorization address")] + #[error("invalid authorization address: contract is not whitelisted for delegation")] InvalidAuthorization, /// The to field of the transaction was invalid. /// @@ -66,14 +31,14 @@ pub enum WalletError { /// - There is no bytecode at the destination, or /// - The bytecode is not an EIP-7702 delegation designator, or /// - The delegation designator points to a contract that is not whitelisted - #[error("the destination of the transaction is not a delegated account")] + #[error("transaction destination is not a valid delegated account")] IllegalDestination, /// The transaction request was invalid. /// /// This is likely an internal error, as most of the request is built by the sequencer. - #[error("invalid tx request")] + #[error("invalid transaction request format")] InvalidTransactionRequest, /// An internal error occurred. - #[error("internal error")] + #[error("internal server error occurred")] InternalError, } diff --git a/crates/anvil/core/src/lib.rs b/crates/anvil/core/src/lib.rs index 5d51d4691da7c..82412439ddb7f 100644 --- a/crates/anvil/core/src/lib.rs +++ b/crates/anvil/core/src/lib.rs @@ -3,7 +3,7 @@ //! Core Ethereum types for Anvil. #![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] +#![cfg_attr(docsrs, feature(doc_cfg))] /// Various Ethereum types pub mod eth; diff --git a/crates/anvil/core/src/types.rs b/crates/anvil/core/src/types.rs index e0cf8762ad9af..7da7e49bfa125 100644 --- a/crates/anvil/core/src/types.rs +++ b/crates/anvil/core/src/types.rs @@ -1,5 +1,6 @@ use alloy_primitives::Bytes; use alloy_rpc_types::TransactionRequest; +use alloy_serde::WithOtherFields; use serde::Deserialize; /// Represents the options used in `anvil_reorg` @@ -15,6 +16,6 @@ pub struct ReorgOptions { #[serde(untagged)] #[expect(clippy::large_enum_variant)] pub enum TransactionData { - JSON(TransactionRequest), + JSON(WithOtherFields), Raw(Bytes), } diff --git a/crates/anvil/rpc/src/error.rs b/crates/anvil/rpc/src/error.rs index 4eec040acf59c..2e5877d5f2b8b 100644 --- a/crates/anvil/rpc/src/error.rs +++ b/crates/anvil/rpc/src/error.rs @@ -84,7 +84,7 @@ pub enum ErrorCode { InvalidParams, /// internal call error InternalError, - /// Failed to send transaction, See also + /// Failed to send transaction, See also TransactionRejected, /// Custom geth error code, ExecutionError, @@ -94,7 +94,7 @@ pub enum ErrorCode { impl ErrorCode { /// Returns the error code as `i64` - pub fn code(&self) -> i64 { + pub const fn code(&self) -> i64 { match *self { Self::ParseError => -32700, Self::InvalidRequest => -32600, diff --git a/crates/anvil/rpc/src/lib.rs b/crates/anvil/rpc/src/lib.rs index bd5382cee17ad..328f5f8564ee1 100644 --- a/crates/anvil/rpc/src/lib.rs +++ b/crates/anvil/rpc/src/lib.rs @@ -3,7 +3,7 @@ //! JSON-RPC types. #![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] +#![cfg_attr(docsrs, feature(doc_cfg))] /// JSON-RPC request bindings pub mod request; diff --git a/crates/anvil/rpc/src/request.rs b/crates/anvil/rpc/src/request.rs index 5cb8510b80f2b..eade8b25fe3cd 100644 --- a/crates/anvil/rpc/src/request.rs +++ b/crates/anvil/rpc/src/request.rs @@ -84,7 +84,7 @@ impl From for serde_json::Value { } } -fn no_params() -> RequestParams { +const fn no_params() -> RequestParams { RequestParams::None } @@ -113,7 +113,7 @@ impl fmt::Display for Id { } } -fn null_id() -> Id { +const fn null_id() -> Id { Id::Null } diff --git a/crates/anvil/rpc/src/response.rs b/crates/anvil/rpc/src/response.rs index 2fd01327d80a3..b7f2d9f478092 100644 --- a/crates/anvil/rpc/src/response.rs +++ b/crates/anvil/rpc/src/response.rs @@ -10,21 +10,20 @@ use serde::{Deserialize, Serialize}; pub struct RpcResponse { // JSON RPC version jsonrpc: Version, - #[serde(skip_serializing_if = "Option::is_none")] - id: Option, + id: Id, #[serde(flatten)] result: ResponseResult, } impl From for RpcResponse { fn from(e: RpcError) -> Self { - Self { jsonrpc: Version::V2, id: None, result: ResponseResult::Error(e) } + Self { jsonrpc: Version::V2, id: Id::Null, result: ResponseResult::Error(e) } } } impl RpcResponse { pub fn new(id: Id, content: impl Into) -> Self { - Self { jsonrpc: Version::V2, id: Some(id), result: content.into() } + Self { jsonrpc: Version::V2, id, result: content.into() } } pub fn invalid_request(id: Id) -> Self { @@ -50,7 +49,7 @@ impl ResponseResult { Self::Success(serde_json::to_value(&content).unwrap()) } - pub fn error(error: RpcError) -> Self { + pub const fn error(error: RpcError) -> Self { Self::Error(error) } } diff --git a/crates/anvil/server/Cargo.toml b/crates/anvil/server/Cargo.toml index c8856939c21f3..99da1cd373c55 100644 --- a/crates/anvil/server/Cargo.toml +++ b/crates/anvil/server/Cargo.toml @@ -35,12 +35,14 @@ tokio-util = { version = "0.7", features = ["codec"], optional = true } # misc serde_json.workspace = true serde.workspace = true -async-trait.workspace = true thiserror.workspace = true clap = { version = "4", features = ["derive", "env"], optional = true } pin-project = "1" +[dev-dependencies] +tower.workspace = true + [features] default = ["ipc"] ipc = ["dep:interprocess", "dep:bytes", "dep:tokio-util"] diff --git a/crates/anvil/server/src/config.rs b/crates/anvil/server/src/config.rs index dd15959b113a6..a6e9dd124080e 100644 --- a/crates/anvil/server/src/config.rs +++ b/crates/anvil/server/src/config.rs @@ -1,4 +1,5 @@ use crate::HeaderValue; +use axum::http::header::InvalidHeaderValue; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use std::str::FromStr; @@ -27,7 +28,7 @@ impl ServerConfig { } /// Whether to enable CORS. - pub fn set_cors(mut self, cors: bool) -> Self { + pub const fn set_cors(mut self, cors: bool) -> Self { self.no_cors = !cors; self } @@ -47,7 +48,7 @@ impl Default for ServerConfig { pub struct HeaderValueWrapper(pub HeaderValue); impl FromStr for HeaderValueWrapper { - type Err = ::Err; + type Err = InvalidHeaderValue; fn from_str(s: &str) -> Result { Ok(Self(s.parse()?)) diff --git a/crates/anvil/server/src/handler.rs b/crates/anvil/server/src/handler.rs index a04c2d647f105..ef033b3e13a69 100644 --- a/crates/anvil/server/src/handler.rs +++ b/crates/anvil/server/src/handler.rs @@ -1,12 +1,14 @@ use crate::RpcHandler; use anvil_rpc::{ error::RpcError, - request::{Request, RpcCall}, + request::{Id, Request, RpcCall, RpcMethodCall, Version}, response::{Response, RpcResponse}, }; use axum::{ Json, extract::{State, rejection::JsonRejection}, + http::StatusCode, + response::{IntoResponse, Response as AxumResponse}, }; use futures::{FutureExt, future}; @@ -15,16 +17,22 @@ use futures::{FutureExt, future}; pub async fn handle( State((handler, _)): State<(Http, Ws)>, request: Result, JsonRejection>, -) -> Json { - Json(match request { +) -> AxumResponse { + match request { Ok(Json(req)) => handle_request(req, handler) .await - .unwrap_or_else(|| Response::error(RpcError::invalid_request())), + .map(Json) + .map(IntoResponse::into_response) + .unwrap_or_else(|| StatusCode::NO_CONTENT.into_response()), + Err(JsonRejection::JsonSyntaxError(err)) => { + warn!(target: "rpc", ?err, "invalid request"); + Json(Response::error(RpcError::parse_error())).into_response() + } Err(err) => { warn!(target: "rpc", ?err, "invalid request"); - Response::error(RpcError::invalid_request()) + Json(Response::error(RpcError::invalid_request())).into_response() } - }) + } } /// Handle the JSON-RPC [Request] @@ -44,6 +52,9 @@ pub async fn handle_request( match req { 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())); + } future::join_all(calls.into_iter().map(move |call| handle_call(call, handler.clone()))) .map(responses_as_batch) .await @@ -59,7 +70,14 @@ async fn handle_call(call: RpcCall, handler: Handler) -> Op Some(handler.on_call(call).await) } RpcCall::Notification(notification) => { - trace!(target: "rpc", method = ?notification.method, "received rpc notification"); + let call = RpcMethodCall { + jsonrpc: notification.jsonrpc.unwrap_or(Version::V2), + method: notification.method, + params: notification.params, + id: Id::Null, + }; + trace!(target: "rpc", method = ?call.method, "handling rpc notification"); + drop(handler.on_call(call).await); None } RpcCall::Invalid { id } => { @@ -68,3 +86,185 @@ async fn handle_call(call: RpcCall, handler: Handler) -> Op } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ServerConfig, http_router}; + use anvil_rpc::{ + request::{RequestParams, RpcNotification}, + response::ResponseResult, + }; + use axum::{ + body::{Body, to_bytes}, + http::{Request as HttpRequest, header}, + }; + use std::{ + pin::pin, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + task::{Context, Poll, Waker}, + }; + use tower::ServiceExt; + + #[derive(Clone, Default)] + struct TestHandler { + requests: Arc, + } + + #[derive(Clone, Debug, serde::Deserialize)] + #[serde(tag = "method", content = "params")] + enum TypedRequest { + #[serde(rename = "known")] + Known(Vec), + } + + #[derive(Clone, Debug, serde::Deserialize)] + enum TestParam { + #[serde(rename = "allowed")] + Allowed, + } + + #[derive(Clone)] + struct TypedHandler; + + impl RpcHandler for TestHandler { + type Request = serde_json::Value; + + async fn on_request(&self, _request: Self::Request) -> ResponseResult { + self.requests.fetch_add(1, Ordering::Relaxed); + ResponseResult::success(()) + } + } + + impl RpcHandler for TypedHandler { + type Request = TypedRequest; + + async fn on_request(&self, request: Self::Request) -> ResponseResult { + let TypedRequest::Known(params) = request; + drop(params); + ResponseResult::success(()) + } + } + + fn notification() -> RpcCall { + RpcCall::Notification(RpcNotification { + jsonrpc: Some(Version::V2), + method: "record".to_owned(), + params: RequestParams::None, + }) + } + + fn method_call(id: i64) -> RpcCall { + RpcCall::MethodCall(RpcMethodCall { + jsonrpc: Version::V2, + method: "record".to_owned(), + params: RequestParams::None, + id: Id::Number(id), + }) + } + + fn typed_call(method: &str) -> RpcMethodCall { + RpcMethodCall { + jsonrpc: Version::V2, + method: method.to_owned(), + params: RequestParams::Array(vec![serde_json::json!("bogus")]), + id: Id::Number(1), + } + } + + fn run_ready(future: F) -> F::Output { + let waker = Waker::noop(); + let mut cx = Context::from_waker(waker); + let mut future = pin!(future); + match future.as_mut().poll(&mut cx) { + Poll::Ready(output) => output, + Poll::Pending => panic!("future unexpectedly pending"), + } + } + + #[test] + fn empty_batch_returns_invalid_request() { + let response = run_ready(handle_request(Request::Batch(vec![]), TestHandler::default())); + + assert_eq!(response, Some(Response::error(RpcError::invalid_request()))); + } + + #[test] + fn distinguishes_unknown_methods_from_unknown_parameter_variants() { + let unknown_method = run_ready(TypedHandler.on_call(typed_call("unknown"))); + let invalid_params = run_ready(TypedHandler.on_call(typed_call("known"))); + + assert_eq!( + serde_json::to_value(unknown_method).unwrap()["error"]["code"], + serde_json::json!(-32601) + ); + assert_eq!( + serde_json::to_value(invalid_params).unwrap()["error"]["code"], + serde_json::json!(-32602) + ); + } + + #[test] + fn notification_only_batch_executes_without_response() { + let handler = TestHandler::default(); + let response = run_ready(handle_request( + Request::Batch(vec![notification(), notification()]), + handler.clone(), + )); + + assert_eq!(response, None); + assert_eq!(handler.requests.load(Ordering::Relaxed), 2); + } + + #[test] + fn mixed_batch_executes_notification_without_including_response() { + let handler = TestHandler::default(); + let response = run_ready(handle_request( + Request::Batch(vec![notification(), method_call(1)]), + handler.clone(), + )); + + assert_eq!( + response, + Some(Response::Batch(vec![RpcResponse::new( + Id::Number(1), + ResponseResult::success(()) + )])) + ); + assert_eq!(handler.requests.load(Ordering::Relaxed), 2); + } + + #[test] + fn http_notification_returns_no_content_after_execution() { + let handler = TestHandler::default(); + let response = run_ready(handle( + State((handler.clone(), ())), + Ok(Json(Request::Single(notification()))), + )); + + assert_eq!(response.status(), StatusCode::NO_CONTENT); + assert!(run_ready(to_bytes(response.into_body(), usize::MAX)).unwrap().is_empty()); + assert_eq!(handler.requests.load(Ordering::Relaxed), 1); + } + + #[test] + fn malformed_json_returns_parse_error() { + let request = HttpRequest::post("/") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from("{")) + .unwrap(); + let response = run_ready( + http_router(ServerConfig::default(), TestHandler::default()).oneshot(request), + ) + .unwrap(); + let body = run_ready(to_bytes(response.into_body(), usize::MAX)).unwrap(); + + assert_eq!( + serde_json::from_slice::(&body).unwrap(), + Response::error(RpcError::parse_error()) + ); + } +} diff --git a/crates/anvil/server/src/ipc.rs b/crates/anvil/server/src/ipc.rs index 4a9133ee6dd58..cf304a152ea77 100644 --- a/crates/anvil/server/src/ipc.rs +++ b/crates/anvil/server/src/ipc.rs @@ -23,7 +23,7 @@ pub struct IpcEndpoint { impl IpcEndpoint { /// Creates a new endpoint with the given handler - pub fn new(handler: Handler, path: String) -> Self { + pub const fn new(handler: Handler, path: String) -> Self { Self { handler, path } } @@ -138,6 +138,7 @@ impl tokio_util::codec::Decoder for JsonRpcCodec { for idx in 0..buf.as_ref().len() { let byte = buf.as_ref()[idx]; + let mut malformed = false; if (byte == b'{' || byte == b'[') && !in_str { if depth == 0 { @@ -145,7 +146,11 @@ impl tokio_util::codec::Decoder for JsonRpcCodec { } depth += 1; } else if (byte == b'}' || byte == b']') && !in_str { - depth -= 1; + if depth == 0 { + malformed = true; + } else { + depth -= 1; + } } else if byte == b'"' && !is_escaped { in_str = !in_str; } else if is_whitespace(byte) { @@ -153,7 +158,7 @@ impl tokio_util::codec::Decoder for JsonRpcCodec { } is_escaped = byte == b'\\' && !is_escaped && in_str; - if depth == 0 && idx != start_idx && idx - start_idx + 1 > whitespaces { + if malformed || (depth == 0 && idx != start_idx && idx - start_idx + 1 > whitespaces) { let bts = buf.split_to(idx + 1); return match String::from_utf8(bts.as_ref().to_vec()) { Ok(val) => Ok(Some(val)), diff --git a/crates/anvil/server/src/lib.rs b/crates/anvil/server/src/lib.rs index 7d3c5b1d2cafe..2b8306ac3a6ea 100644 --- a/crates/anvil/server/src/lib.rs +++ b/crates/anvil/server/src/lib.rs @@ -1,7 +1,7 @@ //! Bootstrap [axum] RPC servers. #![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] +#![cfg_attr(docsrs, feature(doc_cfg))] #[macro_use] extern crate tracing; @@ -35,6 +35,58 @@ mod ws; #[cfg(feature = "ipc")] pub mod ipc; +/// Helper trait that is used to execute ethereum rpc calls +pub trait RpcHandler: Clone + Send + Sync + 'static { + /// The request type to expect + type Request: DeserializeOwned + Send + Sync + fmt::Debug; + + /// Invoked when the request was received + fn on_request(&self, request: Self::Request) -> impl Future + Send; + + /// Invoked for every incoming [`RpcMethodCall`]. Notifications are adapted to method calls + /// with an [`anvil_rpc::request::Id::Null`] identifier, and their responses are discarded. + /// + /// This will attempt to deserialize a `{ "method" : "", "params": "" }` message + /// into the `Request` type of this handler. If a `Request` instance was deserialized + /// successfully, [`Self::on_request`] will be invoked. + /// + /// **Note**: override this function if the expected `Request` deviates from `{ "method" : + /// "", "params": "" }` + fn on_call(&self, call: RpcMethodCall) -> impl Future + Send { + async move { + trace!(target: "rpc", id = ?call.id , method = ?call.method, params = ?call.params, "received method call"); + let RpcMethodCall { method, params, id, .. } = call; + + let params: serde_json::Value = params.into(); + let call = serde_json::json!({ + "method": &method, + "params": params + }); + + match serde_json::from_value::(call) { + Ok(req) => { + let result = self.on_request(req).await; + RpcResponse::new(id, result) + } + Err(err) => { + let err = err.to_string(); + let method_not_found = serde_json::from_value::( + serde_json::json!({ "method": &method }), + ) + .is_err_and(|err| err.to_string().contains("unknown variant")); + if method_not_found { + error!(target: "rpc", ?method, "failed to deserialize method due to unknown variant"); + RpcResponse::new(id, RpcError::method_not_found()) + } else { + error!(target: "rpc", ?method, ?err, "failed to deserialize method"); + RpcResponse::new(id, RpcError::invalid_params(err)) + } + } + } + } + } +} + /// Configures an [`axum::Router`] that handles JSON-RPC calls via both HTTP and WS. pub fn http_ws_router(config: ServerConfig, http: Http, ws: Ws) -> Router where @@ -78,49 +130,3 @@ fn router_inner( } router } - -/// Helper trait that is used to execute ethereum rpc calls -#[async_trait::async_trait] -pub trait RpcHandler: Clone + Send + Sync + 'static { - /// The request type to expect - type Request: DeserializeOwned + Send + Sync + fmt::Debug; - - /// Invoked when the request was received - async fn on_request(&self, request: Self::Request) -> ResponseResult; - - /// Invoked for every incoming `RpcMethodCall` - /// - /// This will attempt to deserialize a `{ "method" : "", "params": "" }` message - /// into the `Request` type of this handler. If a `Request` instance was deserialized - /// successfully, [`Self::on_request`] will be invoked. - /// - /// **Note**: override this function if the expected `Request` deviates from `{ "method" : - /// "", "params": "" }` - async fn on_call(&self, call: RpcMethodCall) -> RpcResponse { - trace!(target: "rpc", id = ?call.id , method = ?call.method, params = ?call.params, "received method call"); - let RpcMethodCall { method, params, id, .. } = call; - - let params: serde_json::Value = params.into(); - let call = serde_json::json!({ - "method": &method, - "params": params - }); - - match serde_json::from_value::(call) { - Ok(req) => { - let result = self.on_request(req).await; - RpcResponse::new(id, result) - } - Err(err) => { - let err = err.to_string(); - if err.contains("unknown variant") { - error!(target: "rpc", ?method, "failed to deserialize method due to unknown variant"); - RpcResponse::new(id, RpcError::method_not_found()) - } else { - error!(target: "rpc", ?method, ?err, "failed to deserialize method"); - RpcResponse::new(id, RpcError::invalid_params(err)) - } - } - } - } -} diff --git a/crates/anvil/server/src/pubsub.rs b/crates/anvil/server/src/pubsub.rs index 26d4126ea5389..463d9dabe4f85 100644 --- a/crates/anvil/server/src/pubsub.rs +++ b/crates/anvil/server/src/pubsub.rs @@ -18,7 +18,6 @@ use std::{ }; /// The general purpose trait for handling RPC requests and subscriptions -#[async_trait::async_trait] pub trait PubSubRpcHandler: Clone + Send + Sync + Unpin + 'static { /// The request type to expect type Request: DeserializeOwned + Send + Sync + fmt::Debug; @@ -28,7 +27,11 @@ pub trait PubSubRpcHandler: Clone + Send + Sync + Unpin + 'static { type Subscription: Stream + Send + Sync + Unpin; /// Invoked when the request was received - async fn on_request(&self, request: Self::Request, cx: PubSubContext) -> ResponseResult; + fn on_request( + &self, + request: Self::Request, + cx: PubSubContext, + ) -> impl Future + Send; } type Subscriptions = Arc>>; @@ -97,12 +100,11 @@ impl Clone for ContextAwareHandler { } } -#[async_trait::async_trait] impl RpcHandler for ContextAwareHandler { type Request = Handler::Request; - async fn on_request(&self, request: Self::Request) -> ResponseResult { - self.handler.on_request(request, self.context.clone()).await + fn on_request(&self, request: Self::Request) -> impl Future + Send { + self.handler.on_request(request, self.context.clone()) } } @@ -117,7 +119,7 @@ pub struct PubSubConnection { /// The established connection connection: Connection, /// currently in progress requests - processing: Vec + Send>>>, + processing: Vec> + Send>>>, /// pending messages to send pending: VecDeque, } @@ -133,21 +135,20 @@ impl PubSubConnection ContextAwareHandler { - ContextAwareHandler { handler: self.handler.clone(), context: self.context.clone() } - } - fn process_request(&mut self, req: serde_json::Result) { - let handler = self.compat_helper(); + let handler = + ContextAwareHandler { handler: self.handler.clone(), context: self.context.clone() }; self.processing.push(Box::pin(async move { match req { - Ok(req) => handle_request(req, handler) - .await - .unwrap_or_else(|| Response::error(RpcError::invalid_request())), + Ok(req) => handle_request(req, handler).await, Err(err) => { error!(target: "rpc", ?err, "invalid request"); - Response::error(RpcError::invalid_request()) + let err = if err.is_syntax() || err.is_eof() { + RpcError::parse_error() + } else { + RpcError::invalid_request() + }; + Some(Response::error(err)) } } })); @@ -221,13 +222,15 @@ where let mut progress = false; for n in (0..pin.processing.len()).rev() { let mut req = pin.processing.swap_remove(n); + #[allow(clippy::collapsible_match)] match req.poll_unpin(cx) { - Poll::Ready(resp) => { + Poll::Ready(Some(resp)) => { if let Ok(text) = serde_json::to_string(&resp) { pin.pending.push_back(text); progress = true; } } + Poll::Ready(None) => {} Poll::Pending => pin.processing.push(req), } } @@ -238,6 +241,7 @@ where 'outer: for n in (0..subscriptions.len()).rev() { let (id, mut sub) = subscriptions.swap_remove(n); 'inner: loop { + #[allow(clippy::collapsible_match)] match sub.poll_next_unpin(cx) { Poll::Ready(Some(res)) => { if let Ok(text) = serde_json::to_string(&res) { @@ -260,3 +264,87 @@ where } } } + +#[cfg(test)] +mod tests { + use super::*; + use anvil_rpc::{ + request::{RequestParams, RpcCall, RpcNotification, Version}, + response::RpcResponse, + }; + use std::{ + pin::pin, + sync::atomic::{AtomicUsize, Ordering}, + task::Waker, + }; + + #[derive(Clone, Default)] + struct TestHandler { + requests: Arc, + } + + impl PubSubRpcHandler for TestHandler { + type Request = serde_json::Value; + type SubscriptionId = u64; + type Subscription = futures::stream::Empty; + + async fn on_request( + &self, + _request: Self::Request, + _cx: PubSubContext, + ) -> ResponseResult { + self.requests.fetch_add(1, Ordering::Relaxed); + ResponseResult::success(serde_json::Value::Null) + } + } + + fn notification() -> RpcCall { + RpcCall::Notification(RpcNotification { + jsonrpc: Some(Version::V2), + method: "eth_subscribe".to_owned(), + params: RequestParams::None, + }) + } + + fn run_ready(future: F) -> F::Output { + let waker = Waker::noop(); + let mut cx = Context::from_waker(waker); + let mut future = pin!(future); + match future.as_mut().poll(&mut cx) { + Poll::Ready(output) => output, + Poll::Pending => panic!("future unexpectedly pending"), + } + } + + #[test] + fn process_request_keeps_empty_batch_invalid() { + let mut connection = PubSubConnection::new((), TestHandler::default()); + connection.process_request(Ok(Request::Batch(vec![]))); + + let response = run_ready(connection.processing.pop().unwrap()); + assert_eq!( + response, + Some(Response::Single(RpcResponse::from(RpcError::invalid_request()))) + ); + } + + #[test] + fn process_request_returns_parse_error_for_malformed_json() { + let mut connection = PubSubConnection::new((), TestHandler::default()); + connection.process_request(serde_json::from_str("{")); + + let response = run_ready(connection.processing.pop().unwrap()); + assert_eq!(response, Some(Response::error(RpcError::parse_error()))); + } + + #[test] + fn process_request_executes_notification_without_response() { + let handler = TestHandler::default(); + let mut connection = PubSubConnection::new((), handler.clone()); + connection.process_request(Ok(Request::Batch(vec![notification()]))); + + let response = run_ready(connection.processing.pop().unwrap()); + assert_eq!(response, None); + assert_eq!(handler.requests.load(Ordering::Relaxed), 1); + } +} diff --git a/crates/anvil/server/src/ws.rs b/crates/anvil/server/src/ws.rs index 531641455feae..a89d88a08e739 100644 --- a/crates/anvil/server/src/ws.rs +++ b/crates/anvil/server/src/ws.rs @@ -13,16 +13,6 @@ use std::{ task::{Context, Poll}, }; -/// Handles incoming Websocket upgrade -/// -/// This is the entrypoint invoked by the axum server for a websocket request -pub async fn handle_ws( - ws: WebSocketUpgrade, - State((_, handler)): State<(Http, Ws)>, -) -> Response { - ws.on_upgrade(|socket| PubSubConnection::new(SocketConn(socket), handler)) -} - #[pin_project::pin_project] struct SocketConn(#[pin] WebSocket); @@ -72,3 +62,13 @@ fn on_message(msg: Result) -> Result, Requ _ => Ok(None), } } + +/// Handles incoming Websocket upgrade +/// +/// This is the entrypoint invoked by the axum server for a websocket request +pub async fn handle_ws( + ws: WebSocketUpgrade, + State((_, handler)): State<(Http, Ws)>, +) -> Response { + ws.on_upgrade(|socket| PubSubConnection::new(SocketConn(socket), handler)) +} diff --git a/crates/anvil/src/args.rs b/crates/anvil/src/args.rs index ba828d41a9f66..30b792fe86c09 100644 --- a/crates/anvil/src/args.rs +++ b/crates/anvil/src/args.rs @@ -1,27 +1,29 @@ use crate::opts::{Anvil, AnvilSubcommand}; use clap::{CommandFactory, Parser}; use eyre::Result; -use foundry_cli::{handler, utils}; +use foundry_cli::utils; /// Run the `anvil` command line interface. pub fn run() -> Result<()> { - setup()?; + foundry_cli::opts::GlobalArgs::check_markdown_help::(); + + let warnings = setup()?; let mut args = Anvil::parse(); args.global.init()?; + for warning in warnings { + let _ = foundry_common::sh_warn!("{warning}"); + } args.node.evm.resolve_rpc_alias(); run_command(args) } /// Setup the exception handler and other utilities. -pub fn setup() -> Result<()> { - utils::install_crypto_provider(); - handler::install(); - utils::load_dotenv(); - utils::enable_paint(); +pub fn setup() -> Result> { + let warnings = utils::common_setup(); - Ok(()) + Ok(warnings) } /// Run the subcommand. @@ -36,12 +38,6 @@ pub fn run_command(args: Anvil) -> Result<()> { &mut std::io::stdout(), ); } - AnvilSubcommand::GenerateFigSpec => clap_complete::generate( - clap_complete_fig::Fig, - &mut Anvil::command(), - "anvil", - &mut std::io::stdout(), - ), } return Ok(()); } @@ -79,7 +75,9 @@ mod tests { let args: Anvil = Anvil::parse_from(["anvil", "completions", "bash"]); assert!(matches!( args.cmd, - Some(AnvilSubcommand::Completions { shell: clap_complete::Shell::Bash }) + Some(AnvilSubcommand::Completions { + shell: foundry_cli::clap::Shell::ClapCompleteShell(clap_complete::Shell::Bash) + }) )); } } diff --git a/crates/anvil/src/cmd.rs b/crates/anvil/src/cmd.rs index cd3879b2ce496..7f3dc76d05db0 100644 --- a/crates/anvil/src/cmd.rs +++ b/crates/anvil/src/cmd.rs @@ -1,17 +1,20 @@ use crate::{ - AccountGenerator, CHAIN_ID, EthereumHardfork, NodeConfig, - config::{DEFAULT_MNEMONIC, ForkChoice}, + AccountGenerator, CHAIN_ID, NodeConfig, + config::{DEFAULT_MNEMONIC, DEFAULT_SLOTS_IN_AN_EPOCH, ForkChoice}, eth::{EthApi, backend::db::SerializableState, pool::transactions::TransactionOrder}, }; use alloy_genesis::Genesis; -use alloy_op_hardforks::OpHardfork; -use alloy_primitives::{B256, U256, utils::Unit}; +use alloy_network::Network; +use alloy_primitives::{Address, B256, U256, map::HashMap, utils::Unit}; use alloy_signer_local::coins_bip39::{English, Mnemonic}; use anvil_server::ServerConfig; use clap::Parser; use core::fmt; use foundry_common::shell; use foundry_config::{Chain, Config, FigmentProviders}; +use foundry_evm::hardfork::FoundryHardfork; +use foundry_evm_networks::NetworkConfigs; +use foundry_primitives::FoundryReceiptEnvelope; use futures::FutureExt; use rand_08::{SeedableRng, rngs::StdRng}; use std::{ @@ -76,6 +79,14 @@ pub struct NodeArgs { #[arg(long)] pub derivation_path: Option, + /// The account used to sponsor Tempo fee-payer requests (`eth_signRawTransaction` and raw + /// transactions carrying the sponsorship placeholder). + /// + /// Must be an unlocked account. Only used on Tempo networks; defaults to the last dev + /// account. + #[arg(long = "tempo.fee-payer", value_name = "ADDRESS")] + pub tempo_fee_payer: Option
, + /// The EVM hardfork to use. /// /// Choose the hardfork by name, e.g. `prague`, `cancun`, `shanghai`, `paris`, `london`, etc... @@ -83,12 +94,22 @@ pub struct NodeArgs { #[arg(long)] pub hardfork: Option, + /// Override the Base activation-registry administrator. + #[cfg(feature = "base")] + #[arg(long, value_name = "ADDRESS")] + pub base_activation_admin: Option
, + /// Block time in seconds for interval mining. #[arg(short, long, visible_alias = "blockTime", value_name = "SECONDS", value_parser = duration_from_secs_f64)] pub block_time: Option, + /// Time in milliseconds to group ready transactions into one auto-mined block. + /// Set to 0 to disable the coalescing delay. + #[arg(long, value_name = "MILLISECONDS", default_value = "5")] + pub transaction_coalescing_window: u64, + /// Slots in an epoch - #[arg(long, value_name = "SLOTS_IN_AN_EPOCH", default_value_t = 32)] + #[arg(long, value_name = "SLOTS_IN_AN_EPOCH", default_value_t = DEFAULT_SLOTS_IN_AN_EPOCH)] pub slots_in_an_epoch: u64, /// Writes output of `anvil` as json to user-specified file. @@ -99,7 +120,9 @@ pub struct NodeArgs { #[arg(long, visible_alias = "no-mine", conflicts_with = "block_time")] pub no_mining: bool, - #[arg(long, visible_alias = "mixed-mining", requires = "block_time")] + /// Enable mixed mining mode. Blocks are mined on a timer (set by `--block-time`), + /// but also whenever a transaction is submitted. Requires `--block-time` to be set. + #[arg(long, requires = "block_time")] pub mixed_mining: bool, /// The hosts the server will listen on. @@ -167,6 +190,13 @@ pub struct NodeArgs { )] pub load_state: Option, + /// Fund specific accounts with custom balances on startup. + /// + /// Accepts multiple address:balance pairs where balance is in ETH. + /// Example: --fund-accounts 0x1234...5678:1000 0xabcd...ef01:5000 + #[arg(long, value_name = "ADDRESS:AMOUNT", value_delimiter = ' ', num_args = 1..)] + pub fund_accounts: Vec, + #[arg(long, help = IPC_HELP, value_name = "PATH", visible_alias = "ipcpath")] pub ipc: Option>, @@ -187,13 +217,21 @@ pub struct NodeArgs { #[arg(long)] pub transaction_block_keeper: Option, + /// Maximum number of transactions in a block. + #[arg(long)] + pub max_transactions: Option, + #[command(flatten)] pub evm: AnvilEvmArgs, #[command(flatten)] pub server_config: ServerConfig, - /// Path to the cache directory where states are stored. + /// Path to the cache directory where persisted states are stored (see + /// `--max-persisted-states`). + /// + /// Note: This does not affect the fork RPC cache location, which uses endpoint-specific files + /// under `~/.foundry/cache/rpc///`. #[arg(long, value_name = "PATH")] pub cache_path: Option, } @@ -215,23 +253,58 @@ impl NodeArgs { let compute_units_per_second = if self.evm.no_rate_limit { Some(u64::MAX) } else { self.evm.compute_units_per_second }; - let hardfork = match &self.hardfork { - Some(hf) => { - if self.evm.optimism { - Some(OpHardfork::from_str(hf)?.into()) - } else { - Some(EthereumHardfork::from_str(hf)?.into()) + // Validate that secondary fork URLs don't have conflicting block number suffixes + if self.evm.fork_url.len() > 1 { + for fork in &self.evm.fork_url[1..] { + if fork.block.is_some() { + eyre::bail!( + "Block number suffixes (@block) on secondary --fork-url values are not supported. \ + Use --fork-block-number to set the fork block for all endpoints." + ); } } + } + + let funded_accounts = self.parse_funded_accounts()?; + #[cfg(feature = "base")] + let base_activation_admin = self.base_activation_admin; + + let local_chain_id = self + .evm + .chain_id + .map(u64::from) + .or_else(|| self.init.as_ref().map(|genesis| genesis.config.chain_id)); + let inferred_network_chain_id = self + .evm + .fork_chain_id + .map(u64::from) + .or(if self.evm.fork_url.is_empty() { local_chain_id } else { None }); + let networks = if let Some(chain_id) = inferred_network_chain_id { + self.evm.networks.try_with_chain_id(chain_id).map_err(eyre::Report::msg)? + } else { + self.evm.networks + }; + + let hardfork = match &self.hardfork { + Some(hf) => Some(parse_hardfork(hf, &networks)?), None => None, }; + let networks = if let Some(hardfork) = hardfork { + networks.normalize_for_hardfork(hardfork).map_err(eyre::Report::msg)? + } else { + networks + }; - Ok(NodeConfig::default() + let config = NodeConfig::default() .with_gas_limit(self.evm.gas_limit) .disable_block_gas_limit(self.evm.disable_block_gas_limit) + .enable_tx_gas_limit(self.evm.enable_tx_gas_limit) .with_gas_price(self.evm.gas_price) .with_hardfork(hardfork) .with_blocktime(self.block_time) + .with_transaction_coalescing_window(Duration::from_millis( + self.transaction_coalescing_window, + )) .with_no_mining(self.no_mining) .with_mixed_mining(self.mixed_mining, self.block_time) .with_account_generator(self.account_generator())? @@ -245,25 +318,27 @@ impl NodeArgs { _ => self .evm .fork_url - .as_ref() + .first() .and_then(|f| f.block) .map(|num| ForkChoice::Block(num as i128)), }) .with_fork_headers(self.evm.fork_headers) .with_fork_chain_id(self.evm.fork_chain_id.map(u64::from).map(U256::from)) + .with_no_fork_node_info(self.evm.no_fork_node_info) + .with_no_bal(self.evm.no_bal) + .with_fork_state_by_number(self.evm.fork_state_by_number) .fork_request_timeout(self.evm.fork_request_timeout.map(Duration::from_millis)) .fork_request_retries(self.evm.fork_request_retries) .fork_retry_backoff(self.evm.fork_retry_backoff.map(Duration::from_millis)) .fork_compute_units_per_second(compute_units_per_second) - .with_eth_rpc_url(self.evm.fork_url.map(|fork| fork.url)) + .with_fork_urls(self.evm.fork_url.into_iter().map(|f| f.url).collect()) .with_base_fee(self.evm.block_base_fee_per_gas) .disable_min_priority_fee(self.evm.disable_min_priority_fee) - .with_storage_caching(self.evm.no_storage_caching) + .with_no_storage_caching(self.evm.no_storage_caching) .with_server_config(self.server_config) .with_host(self.host) .set_silent(shell::is_quiet()) .set_config_out(self.config_out) - .with_chain_id(self.evm.chain_id) .with_transaction_order(self.order) .with_genesis(self.init) .with_steps_tracing(self.evm.steps_tracing) @@ -276,13 +351,44 @@ impl NodeArgs { .set_pruned_history(self.prune_history) .with_init_state(self.load_state.or_else(|| self.state.and_then(|s| s.state))) .with_transaction_block_keeper(self.transaction_block_keeper) + .with_max_transactions(self.max_transactions) .with_max_persisted_states(self.max_persisted_states) - .with_optimism(self.evm.optimism) - .with_odyssey(self.evm.odyssey) + .with_chain_id(self.evm.chain_id) + // Restore the source-derived or explicitly selected network after applying the + // execution chain ID. Fork source discovery can refine an unresolved network later. + .with_networks(networks) + .with_tempo_fee_payer(self.tempo_fee_payer) .with_disable_default_create2_deployer(self.evm.disable_default_create2_deployer) + .with_disable_pool_balance_checks(self.evm.disable_pool_balance_checks) .with_slots_in_an_epoch(self.slots_in_an_epoch) .with_memory_limit(self.evm.memory_limit) - .with_cache_path(self.cache_path)) + .with_cache_path(self.cache_path) + .with_funded_accounts(funded_accounts); + #[cfg(feature = "base")] + let config = config.with_base_activation_admin(base_activation_admin); + Ok(config) + } + + fn parse_funded_accounts(&self) -> eyre::Result> { + let mut accounts = HashMap::default(); + for entry in &self.fund_accounts { + let parts: Vec<&str> = entry.split(':').collect(); + if parts.len() != 2 { + eyre::bail!( + "Invalid fund-accounts entry '{}'. Expected format: ADDRESS:AMOUNT", + entry + ); + } + let address = parts[0] + .parse::
() + .map_err(|e| eyre::eyre!("Invalid address '{}': {}", parts[0], e))?; + let amount: u64 = parts[1] + .parse() + .map_err(|e| eyre::eyre!("Invalid amount '{}': {}", parts[1], e))?; + let balance = Unit::ETHER.wei().saturating_mul(U256::from(amount)); + accounts.insert(address, balance); + } + Ok(accounts) } fn account_generator(&self) -> AccountGenerator { @@ -295,7 +401,14 @@ impl NodeArgs { let mut rng = rand_08::thread_rng(); let mnemonic = match Mnemonic::::new_with_count(&mut rng, count) { Ok(mnemonic) => mnemonic.to_phrase(), - Err(_) => DEFAULT_MNEMONIC.to_string(), + Err(err) => { + warn!(target: "node", ?count, %err, "failed to generate mnemonic, falling back to 12-word random mnemonic"); + // Fallback: generate a valid 12-word random mnemonic instead of using + // DEFAULT_MNEMONIC + Mnemonic::::new_with_count(&mut rng, 12) + .expect("valid default word count") + .to_phrase() + } }; generator = generator.phrase(mnemonic); } else if let Some(seed) = self.mnemonic_seed { @@ -309,16 +422,12 @@ impl NodeArgs { generator } - /// Returns the location where to dump the state to. - fn dump_state_path(&self) -> Option { - self.dump_state.as_ref().or_else(|| self.state.as_ref().map(|s| &s.path)).cloned() - } - /// Starts the node /// /// See also [crate::spawn()] pub async fn run(self) -> eyre::Result<()> { - let dump_state = self.dump_state_path(); + let dump_state = + self.dump_state.as_ref().or_else(|| self.state.as_ref().map(|s| &s.path)).cloned(); let dump_interval = self.state_interval.map(Duration::from_secs).unwrap_or(DEFAULT_DUMP_INTERVAL); let preserve_historical_states = self.preserve_historical_states; @@ -403,6 +512,10 @@ pub struct AnvilEvmArgs { /// Fetch state over a remote endpoint instead of starting from an empty state. /// /// If you want to fetch state from a specific block number, add a block number like `http://localhost:8545@1400000` or use the `--fork-block-number` argument. + /// + /// Multiple `--fork-url` flags can be provided to distribute requests across endpoints + /// using round-robin load balancing. On failure, the retry layer rotates to the next + /// endpoint. #[arg( long, short, @@ -410,7 +523,7 @@ pub struct AnvilEvmArgs { value_name = "URL", help_heading = "Fork config" )] - pub fork_url: Option, + pub fork_url: Vec, /// Headers to use for the rpc client, e.g. "User-Agent: test-agent" /// @@ -449,7 +562,7 @@ pub struct AnvilEvmArgs { )] pub fork_block_number: Option, - /// Fetch state from a specific transaction hash over a remote endpoint. + /// Fetch state from after a specific transaction hash has been applied over a remote endpoint. /// /// See --fork-url. #[arg( @@ -480,6 +593,32 @@ pub struct AnvilEvmArgs { )] pub fork_chain_id: Option, + /// Do not probe the fork endpoint with `anvil_nodeInfo` / `anvil_metadata`. + /// + /// Those calls detect a nested Anvil. Some public RPCs retry unknown methods for tens of + /// seconds instead of returning method-not-found, which delays listen until the probe + /// finishes. + #[arg(long, requires = "fork_url", help_heading = "Fork config")] + pub no_fork_node_info: bool, + + /// Disable pre-filling the fork cache with block access list (BAL) post-state. + /// + /// By default, eligible Ethereum forks briefly try fetching a BAL at the fork block. + /// Missing or invalid BALs fall back to ordinary state reads. This setting also applies + /// to subsequent resets and does not disable serving BAL RPC methods. + #[arg(long, help_heading = "Fork config")] + pub no_bal: bool, + + /// Read fork state by block number instead of by block hash. + /// + /// Use this for RPCs that cannot serve `eth_getBalance`, `eth_getCode`, `eth_getStorageAt` or + /// `eth_getTransactionCount` for a block hash. Block hash ancestry is still validated against + /// the fork block. + /// + /// See --fork-url. + #[arg(long, requires = "fork_url", help_heading = "Fork config")] + pub fork_state_by_number: bool, + /// Sets the number of assumed available compute units per second for this provider /// /// default value: 330 @@ -532,6 +671,10 @@ pub struct AnvilEvmArgs { )] pub disable_block_gas_limit: bool, + /// Enable the transaction gas limit check as imposed by EIP-7825 (Osaka hardfork). + #[arg(long, visible_alias = "tx-gas-limit", help_heading = "Environment config")] + pub enable_tx_gas_limit: bool, + /// EIP-170: Contract code size limit in bytes. Useful to increase this because of tests. To /// disable entirely, use `--disable-code-size-limit`. By default, it is 0x6000 (~25kb). #[arg(long, value_name = "CODE_SIZE", help_heading = "Environment config")] @@ -568,6 +711,10 @@ pub struct AnvilEvmArgs { pub chain_id: Option, /// Enable steps tracing used for debug calls returning geth-style traces + /// + /// Steps are recorded by replaying the transaction from its parent block's state, so they + /// are unavailable once that state is pruned or when loading a state dump created without + /// `--preserve-historical-states`. #[arg(long, visible_alias = "tracing")] pub steps_tracing: bool, @@ -584,49 +731,80 @@ pub struct AnvilEvmArgs { #[arg(long, visible_alias = "auto-unlock")] pub auto_impersonate: bool, - /// Run an Optimism chain - #[arg(long, visible_alias = "optimism")] - pub optimism: bool, - /// Disable the default create2 deployer #[arg(long, visible_alias = "no-create2")] pub disable_default_create2_deployer: bool, + /// Disable pool balance checks + #[arg(long)] + pub disable_pool_balance_checks: bool, + /// The memory limit per EVM execution in bytes. #[arg(long)] pub memory_limit: Option, - /// Enable Odyssey features - #[arg(long, alias = "alphanet")] - pub odyssey: bool, + #[command(flatten)] + pub networks: NetworkConfigs, } /// Resolves an alias passed as fork-url to the matching url defined in the rpc_endpoints section /// of the project configuration file. /// Does nothing if the fork-url is not a configured alias. +/// +/// When an alias maps to an `RpcEndpoint` with multiple `endpoints`, all URLs are expanded +/// into additional `--fork-url` entries for multi-endpoint load balancing. impl AnvilEvmArgs { pub fn resolve_rpc_alias(&mut self) { - if let Some(fork_url) = &self.fork_url - && let Ok(config) = Config::load_with_providers(FigmentProviders::Anvil) - && let Some(Ok(url)) = config.get_rpc_url_with_alias(&fork_url.url) - { - self.fork_url = Some(ForkUrl { url: url.to_string(), block: fork_url.block }); + if let Ok(config) = Config::load_with_providers(FigmentProviders::Anvil) { + let mut resolved_urls = Vec::new(); + for fork_url in &self.fork_url { + let mut endpoints = config.rpc_endpoints.clone().resolved(); + if let Some(endpoint) = endpoints.remove(&fork_url.url) { + // Alias matched — expand all URLs from the endpoint config + match endpoint.all_urls() { + Ok(urls) => { + for (i, url) in urls.into_iter().enumerate() { + resolved_urls.push(ForkUrl { + url, + // Only the first URL inherits the block suffix + block: if i == 0 { fork_url.block } else { None }, + }); + } + } + Err(e) => { + warn!(target: "node", alias=%fork_url.url, %e, "could not resolve all endpoints, using primary endpoint only"); + if let Ok(url) = endpoint.url() { + resolved_urls.push(ForkUrl { url, block: fork_url.block }); + } else { + resolved_urls.push(fork_url.clone()); + } + } + } + } else if let Some(Ok(url)) = config.get_rpc_url_with_alias(&fork_url.url) { + // Try mesc or other resolution + resolved_urls.push(ForkUrl { url: url.to_string(), block: fork_url.block }); + } else { + // Not an alias — keep as-is + resolved_urls.push(fork_url.clone()); + } + } + self.fork_url = resolved_urls; } } } /// Helper type to periodically dump the state of the chain to disk -struct PeriodicStateDumper { +struct PeriodicStateDumper { in_progress_dump: Option + Send + Sync + 'static>>>, - api: EthApi, + api: EthApi, dump_state: Option, preserve_historical_states: bool, interval: Interval, } -impl PeriodicStateDumper { +impl> PeriodicStateDumper { fn new( - api: EthApi, + api: EthApi, dump_state: Option, interval: Duration, preserve_historical_states: bool, @@ -643,14 +821,16 @@ impl PeriodicStateDumper { Self { in_progress_dump: None, api, dump_state, preserve_historical_states, interval } } - async fn dump(&self) { - if let Some(state) = self.dump_state.clone() { - Self::dump_state(self.api.clone(), state, self.preserve_historical_states).await + async fn dump(mut self) { + // Shutdown no longer polls the periodic future, so release its locks first. + self.in_progress_dump = None; + if let Some(state) = self.dump_state { + Self::dump_state(self.api, state, self.preserve_historical_states).await } } /// Infallible state dump - async fn dump_state(api: EthApi, dump_state: PathBuf, preserve_historical_states: bool) { + async fn dump_state(api: EthApi, dump_state: PathBuf, preserve_historical_states: bool) { trace!(path=?dump_state, "Dumping state on shutdown"); match api.serialized_state(preserve_historical_states).await { Ok(state) => { @@ -668,7 +848,7 @@ impl PeriodicStateDumper { } // An endless future that periodically dumps the state to disk if configured. -impl Future for PeriodicStateDumper { +impl> Future for PeriodicStateDumper { type Output = (); fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { @@ -775,6 +955,16 @@ impl FromStr for ForkUrl { } } +/// Parses a hardfork string against the active network configuration. +fn parse_hardfork(hf: &str, networks: &NetworkConfigs) -> eyre::Result { + if let Ok(hardfork) = FoundryHardfork::from_str(hf) { + networks.normalize_for_hardfork(hardfork).map_err(eyre::Report::msg)?; + return Ok(hardfork); + } + + networks.execution_network().parse_hardfork(hf).map_err(eyre::Report::msg) +} + /// Clap's value parser for genesis. Loads a genesis.json file. fn read_genesis_file(path: &str) -> Result { foundry_common::fs::read_json_file(path.as_ref()).map_err(|err| err.to_string()) @@ -791,7 +981,71 @@ fn duration_from_secs_f64(s: &str) -> Result { #[cfg(test)] mod tests { use super::*; + use foundry_evm::hardfork::EthereumHardfork; use std::{env, net::Ipv4Addr}; + use tempo_hardfork::TempoHardfork; + + #[cfg(feature = "base")] + use foundry_evm::hardforks::BaseUpgrade; + + #[cfg(feature = "optimism")] + use foundry_evm::hardfork::OpHardfork; + + #[tokio::test] + async fn final_dump_cancels_suspended_periodic_dump() { + for queued_for_mining in [false, true] { + let (api, _handle) = crate::spawn(NodeConfig::test().with_no_mining(true)).await; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("state.json"); + let mining = + if queued_for_mining { Some(api.backend.lock_mining().await) } else { None }; + let db = api.backend.get_db().write().await; + let mut dumper = PeriodicStateDumper::new( + api.clone(), + Some(path.clone()), + Duration::from_secs(60), + false, + ); + dumper.in_progress_dump = + Some(Box::pin(PeriodicStateDumper::dump_state(api.clone(), path.clone(), false))); + assert!(futures::poll!(&mut dumper).is_pending()); + + // Shutdown stops polling the periodic dumper before starting the final dump. + drop(db); + drop(mining); + tokio::time::timeout(Duration::from_secs(2), dumper.dump()) + .await + .expect("final dump waited for a suspended periodic dump"); + let state = foundry_common::fs::read_json_file::(&path).unwrap(); + assert_eq!(state.best_block_number, Some(0)); + } + } + + #[cfg(feature = "base")] + #[test] + fn base_chain_ids_select_native_base_unless_overridden() { + for chain_id in ["8453", "84532"] { + let config = + NodeArgs::parse_from(["anvil", "--chain-id", chain_id]).into_node_config().unwrap(); + assert!(config.networks.is_base()); + + let config = + NodeArgs::parse_from(["anvil", "--chain-id", chain_id, "--network", "ethereum"]) + .into_node_config() + .unwrap(); + assert!(config.networks.execution_network().is_ethereum()); + } + } + + #[test] + fn fork_bal_can_be_disabled() { + let args = + NodeArgs::try_parse_from(["anvil", "--fork-url", "http://localhost:8545", "--no-bal"]); + let config = args.unwrap().into_node_config().unwrap(); + assert!(config.no_bal); + assert!(!NodeArgs::parse_from(["anvil"]).into_node_config().unwrap().no_bal); + assert!(NodeArgs::parse_from(["anvil", "--no-bal"]).into_node_config().unwrap().no_bal); + } #[test] fn test_parse_fork_url() { @@ -830,6 +1084,7 @@ mod tests { assert_eq!(config.hardfork, Some(EthereumHardfork::Berlin.into())); } + #[cfg(feature = "optimism")] #[test] fn can_parse_optimism_hardfork() { let args: NodeArgs = @@ -838,6 +1093,389 @@ mod tests { assert_eq!(config.hardfork, Some(OpHardfork::Regolith.into())); } + #[test] + fn can_parse_tempo_hardfork_from_network() { + let args: NodeArgs = + NodeArgs::parse_from(["anvil", "--network", "tempo", "--hardfork", "T5"]); + let config = args.into_node_config().unwrap(); + + assert!(config.networks.is_tempo()); + assert_eq!(config.hardfork, Some(TempoHardfork::T5.into())); + } + + #[test] + fn can_parse_namespaced_tempo_hardfork() { + let args = NodeArgs::parse_from(["anvil", "--hardfork", "tempo:T5"]); + let config = args.into_node_config().unwrap(); + + assert!(config.networks.is_tempo()); + assert_eq!(config.hardfork, Some(TempoHardfork::T5.into())); + } + + #[cfg(feature = "base")] + #[test] + fn can_parse_base_hardfork_from_network() { + let args: NodeArgs = + NodeArgs::parse_from(["anvil", "--network", "base", "--hardfork", "Beryl"]); + let config = args.into_node_config().unwrap(); + + assert!(config.networks.is_base()); + assert_eq!(config.hardfork, Some(BaseUpgrade::Beryl.into())); + } + + #[cfg(feature = "base")] + #[test] + fn can_parse_namespaced_base_hardfork() { + let args = NodeArgs::parse_from(["anvil", "--hardfork", "base:Beryl"]); + let config = args.into_node_config().unwrap(); + + assert!(config.networks.is_base()); + assert_eq!(config.hardfork, Some(BaseUpgrade::Beryl.into())); + } + + #[cfg(feature = "base")] + #[test] + fn can_parse_base_activation_admin() { + let admin = Address::repeat_byte(0xaa); + let admin_arg = admin.to_string(); + let args = NodeArgs::parse_from([ + "anvil", + "--network", + "base", + "--hardfork", + "base:Beryl", + "--base-activation-admin", + admin_arg.as_str(), + ]); + let config = args.into_node_config().unwrap(); + + assert!(config.networks.is_base()); + assert_eq!(config.base_activation_admin, Some(admin)); + } + + #[cfg(feature = "optimism")] + #[test] + fn chain_id_infers_optimism_network_in_node_config() { + let args: NodeArgs = NodeArgs::parse_from(["anvil", "--chain-id", "10"]); + let config = args.into_node_config().unwrap(); + + assert!(config.networks.is_optimism()); + } + + #[test] + #[cfg(not(feature = "optimism"))] + fn chain_id_rejects_disabled_optimism_network() { + let args = NodeArgs::parse_from(["anvil", "--chain-id", "10"]); + let error = args.into_node_config().unwrap_err(); + + assert_eq!( + error.to_string(), + "cannot infer execution network from chain ID 10: network family `optimism` is not \ + enabled in this build" + ); + } + + #[test] + #[cfg(feature = "base")] + fn base_fork_chain_id_precedes_execution_chain_id_for_network() { + let args = NodeArgs::parse_from([ + "anvil", + "--fork-url", + "http://localhost:8545", + "--fork-block-number", + "1", + "--fork-chain-id", + "8453", + "--chain-id", + "1", + ]); + let config = args.into_node_config().unwrap(); + + assert!(config.networks.is_base()); + assert_eq!(config.get_chain_id(), 1); + } + + /// `anvil --chain-id 8453` resolved to Optimism before Base support existed and must keep + /// doing so in builds without the feature, which is what release binaries ship. + #[test] + #[cfg(all(not(feature = "base"), feature = "optimism"))] + fn chain_id_without_base_still_resolves_to_optimism() { + for chain_id in ["8453", "84532"] { + let args = NodeArgs::parse_from(["anvil", "--chain-id", chain_id]); + let config = args + .into_node_config() + .unwrap_or_else(|error| panic!("chain ID {chain_id} must still resolve: {error}")); + assert!(config.networks.is_optimism(), "chain ID {chain_id} must resolve to Optimism"); + } + } + + #[test] + #[cfg(not(feature = "monad"))] + fn chain_id_rejects_disabled_monad_network() { + for chain_id in ["143", "10143"] { + let args = NodeArgs::parse_from(["anvil", "--chain-id", chain_id]); + let error = args.into_node_config().unwrap_err(); + + assert_eq!( + error.to_string(), + format!( + "cannot infer execution network from chain ID {chain_id}: network family \ + `monad` is not enabled in this build" + ) + ); + } + } + + #[test] + fn explicit_ethereum_allows_base_chain_id() { + let args = NodeArgs::parse_from(["anvil", "--network", "ethereum", "--chain-id", "8453"]); + let config = args.into_node_config().unwrap(); + + assert_eq!(config.networks, NetworkConfigs::with_ethereum()); + assert_eq!(config.get_chain_id(), 8453); + } + + #[test] + #[cfg(not(feature = "monad"))] + fn explicit_ethereum_allows_monad_chain_id() { + let args = NodeArgs::parse_from(["anvil", "--network", "ethereum", "--chain-id", "143"]); + let config = args.into_node_config().unwrap(); + + assert_eq!(config.networks, NetworkConfigs::with_ethereum()); + assert_eq!(config.get_chain_id(), 143); + } + + #[test] + #[cfg(not(feature = "monad"))] + fn genesis_chain_id_rejects_disabled_monad_network() { + let mut args = NodeArgs::parse_from(["anvil"]); + let mut genesis = Genesis::default(); + genesis.config.chain_id = 143; + args.init = Some(genesis); + + let error = args.into_node_config().unwrap_err(); + + assert_eq!( + error.to_string(), + "cannot infer execution network from chain ID 143: network family `monad` is not \ + enabled in this build" + ); + } + + #[test] + #[cfg(feature = "base")] + fn genesis_chain_id_infers_base_network() { + let mut args = NodeArgs::parse_from(["anvil"]); + let mut genesis = Genesis::default(); + genesis.config.chain_id = 8453; + args.init = Some(genesis); + + let config = args.into_node_config().unwrap(); + assert!(config.networks.is_base()); + } + + /// Base chain IDs resolved to Optimism before Base support existed, so a build without the + /// `base` feature must keep resolving them that way rather than erroring. + #[test] + #[cfg(all(not(feature = "base"), feature = "optimism"))] + fn genesis_chain_id_without_base_still_infers_optimism() { + let mut args = NodeArgs::parse_from(["anvil"]); + let mut genesis = Genesis::default(); + genesis.config.chain_id = 8453; + args.init = Some(genesis); + + let config = args.into_node_config().unwrap(); + assert!(config.networks.is_optimism()); + } + + #[test] + #[cfg(not(feature = "monad"))] + fn explicit_network_overrides_genesis_chain_id_inference() { + let mut args = NodeArgs::parse_from(["anvil", "--network", "ethereum"]); + let mut genesis = Genesis::default(); + genesis.config.chain_id = 143; + args.init = Some(genesis); + + let config = args.into_node_config().unwrap(); + + assert_eq!(config.networks, NetworkConfigs::with_ethereum()); + assert_eq!(config.get_chain_id(), 143); + } + + #[test] + #[cfg(not(feature = "monad"))] + fn explicit_chain_id_precedes_genesis_network_inference() { + let mut args = NodeArgs::parse_from(["anvil", "--chain-id", "1"]); + let mut genesis = Genesis::default(); + genesis.config.chain_id = 143; + args.init = Some(genesis); + + let config = args.into_node_config().unwrap(); + + assert!(!config.networks.has_network_selection()); + assert_eq!(config.get_chain_id(), 1); + } + + #[test] + fn explicit_network_overrides_base_genesis_chain_id_inference() { + let mut args = NodeArgs::parse_from(["anvil", "--network", "ethereum"]); + let mut genesis = Genesis::default(); + genesis.config.chain_id = 8453; + args.init = Some(genesis); + + let config = args.into_node_config().unwrap(); + assert_eq!(config.networks, NetworkConfigs::with_ethereum()); + assert_eq!(config.get_chain_id(), 8453); + } + + #[test] + fn explicit_chain_id_precedes_base_genesis_network_inference() { + let mut args = NodeArgs::parse_from(["anvil", "--chain-id", "1"]); + let mut genesis = Genesis::default(); + genesis.config.chain_id = 8453; + args.init = Some(genesis); + + let config = args.into_node_config().unwrap(); + assert!(!config.networks.has_network_selection()); + assert_eq!(config.get_chain_id(), 1); + } + + #[test] + #[cfg(not(feature = "monad"))] + fn disabled_fork_chain_id_precedes_execution_chain_id() { + let args = NodeArgs::parse_from([ + "anvil", + "--fork-url", + "http://localhost:8545", + "--fork-block-number", + "1", + "--fork-chain-id", + "10143", + "--chain-id", + "1", + ]); + let error = args.into_node_config().unwrap_err(); + + assert!(error.to_string().contains( + "cannot infer execution network from chain ID 10143: network family `monad` is not \ + enabled in this build" + )); + } + + #[test] + fn fork_state_by_number_flag_sets_config() { + let args = NodeArgs::parse_from([ + "anvil", + "--fork-url", + "http://localhost:8545", + "--fork-state-by-number", + ]); + let config = args.into_node_config().unwrap(); + + assert!(config.fork_state_by_number); + } + + #[test] + fn unknown_chain_id_preserves_ethereum_fallback() { + let args = NodeArgs::parse_from(["anvil", "--chain-id", "98765432"]); + let config = args.into_node_config().unwrap(); + + assert!(!config.networks.has_network_selection()); + assert_eq!(config.get_chain_id(), 98_765_432); + } + + #[test] + fn chain_id_infers_tempo_network_for_hardfork() { + let args = NodeArgs::parse_from(["anvil", "--chain-id", "4217", "--hardfork", "T5"]); + let config = args.into_node_config().unwrap(); + + assert!(config.networks.is_tempo()); + assert_eq!(config.hardfork, Some(TempoHardfork::T5.into())); + } + + #[test] + fn fork_chain_id_infers_tempo_network_for_hardfork() { + let args = NodeArgs::parse_from([ + "anvil", + "--fork-url", + "http://localhost:8545", + "--fork-block-number", + "1", + "--fork-chain-id", + "4217", + "--hardfork", + "T5", + ]); + let config = args.into_node_config().unwrap(); + + assert!(config.networks.is_tempo()); + assert_eq!(config.hardfork, Some(TempoHardfork::T5.into())); + } + + #[test] + #[cfg(feature = "monad")] + fn chain_id_infers_monad_network_in_node_config() { + let args = NodeArgs::parse_from(["anvil", "--chain-id", "143"]); + let config = args.into_node_config().unwrap(); + + assert!(config.networks.is_monad()); + } + + #[test] + #[cfg(feature = "monad")] + fn fork_chain_id_precedes_execution_chain_id_for_network() { + let args = NodeArgs::parse_from([ + "anvil", + "--fork-url", + "http://localhost:8545", + "--fork-block-number", + "1", + "--fork-chain-id", + "10143", + "--chain-id", + "1", + ]); + let config = args.into_node_config().unwrap(); + + assert!(config.networks.is_monad()); + assert_eq!(config.get_chain_id(), 1); + } + + #[test] + fn fork_execution_chain_id_does_not_infer_source_network() { + let args = NodeArgs::parse_from([ + "anvil", + "--fork-url", + "http://localhost:8545", + "--chain-id", + "4217", + ]); + let config = args.into_node_config().unwrap(); + + assert!(config.networks.resolved_network().is_none()); + assert_eq!(config.get_chain_id(), 4217); + } + + #[test] + #[cfg(feature = "monad")] + fn can_parse_monad_hardfork() { + let args: NodeArgs = + NodeArgs::parse_from(["anvil", "--network", "monad", "--hardfork", "MonadNine"]); + let config = args.into_node_config().unwrap(); + assert_eq!(config.hardfork, Some(foundry_evm::hardfork::MonadHardfork::MonadNine.into())); + assert!(config.networks.is_monad()); + } + + #[test] + #[cfg(feature = "monad")] + fn monad_uses_monad_default_hardfork() { + let args: NodeArgs = NodeArgs::parse_from(["anvil", "--network", "monad"]); + let config = args.into_node_config().unwrap(); + assert_eq!(config.hardfork, None); + assert_eq!(config.get_hardfork(), foundry_evm::hardfork::MonadHardfork::default().into()); + assert!(config.networks.is_monad()); + } + #[test] fn cant_parse_invalid_hardfork() { let args: NodeArgs = NodeArgs::parse_from(["anvil", "--hardfork", "Regolith"]); @@ -884,6 +1522,16 @@ mod tests { assert!(args.is_err()); } + #[test] + fn can_parse_enable_tx_gas_limit() { + let args: NodeArgs = NodeArgs::parse_from(["anvil", "--enable-tx-gas-limit"]); + assert!(args.evm.enable_tx_gas_limit); + + // Also test the alias + let args: NodeArgs = NodeArgs::parse_from(["anvil", "--tx-gas-limit"]); + assert!(args.evm.enable_tx_gas_limit); + } + #[test] fn can_parse_disable_code_size_limit() { let args: NodeArgs = NodeArgs::parse_from(["anvil", "--disable-code-size-limit"]); @@ -929,4 +1577,94 @@ mod tests { ["::1", "1.1.1.1", "2.2.2.2"].map(|ip| ip.parse::().unwrap()).to_vec() ); } + + #[test] + fn can_parse_no_fork_node_info() { + let args = NodeArgs::parse_from([ + "anvil", + "--fork-url", + "http://localhost:8545", + "--no-fork-node-info", + ]); + assert!(args.evm.no_fork_node_info); + let config = args.into_node_config().unwrap(); + assert!(config.no_fork_node_info); + } + + #[test] + fn can_parse_multiple_fork_urls() { + let args: NodeArgs = NodeArgs::parse_from([ + "anvil", + "--fork-url", + "http://localhost:8545", + "--fork-url", + "http://localhost:8546", + "--fork-url", + "http://localhost:8547", + ]); + assert_eq!(args.evm.fork_url.len(), 3); + assert_eq!(args.evm.fork_url[0].url, "http://localhost:8545"); + assert_eq!(args.evm.fork_url[1].url, "http://localhost:8546"); + assert_eq!(args.evm.fork_url[2].url, "http://localhost:8547"); + + // Block suffix on first URL should work + let args: NodeArgs = NodeArgs::parse_from([ + "anvil", + "--fork-url", + "http://localhost:8545@1000000", + "--fork-url", + "http://localhost:8546", + ]); + assert_eq!(args.evm.fork_url[0].block, Some(1000000)); + assert_eq!(args.evm.fork_url[1].block, None); + } + + #[test] + fn rejects_block_suffix_on_secondary_fork_urls() { + let args: NodeArgs = NodeArgs::parse_from([ + "anvil", + "--fork-url", + "http://localhost:8545@1000000", + "--fork-url", + "http://localhost:8546@2000000", + ]); + let result = args.into_node_config(); + assert!(result.is_err()); + assert!( + result.unwrap_err().to_string().contains("Block number suffixes"), + "should reject block suffix on secondary fork URL" + ); + } + + #[test] + fn fork_dependent_args_require_fork_url() { + // All these args have `requires = "fork_url"` — they should fail without --fork-url + let cases = [ + vec!["anvil", "--fork-header", "X-Api-Key: test"], + vec!["anvil", "--timeout", "5000"], + vec!["anvil", "--retries", "3"], + vec!["anvil", "--fork-block-number", "100"], + vec!["anvil", "--fork-retry-backoff", "500"], + vec!["anvil", "--no-fork-node-info"], + ]; + for args in &cases { + let result = NodeArgs::try_parse_from(args); + assert!(result.is_err(), "expected error when using {:?} without --fork-url", args[1]); + } + } + + #[test] + fn transaction_coalescing_window_cli() { + for (args, expected) in [ + (vec!["anvil"], 5), + (vec!["anvil", "--transaction-coalescing-window", "0"], 0), + (vec!["anvil", "--transaction-coalescing-window", "20"], 20), + ] { + let config = NodeArgs::parse_from(args).into_node_config().unwrap(); + assert_eq!(config.transaction_coalescing_window, Duration::from_millis(expected)); + } + assert!( + NodeArgs::try_parse_from(["anvil", "--transaction-coalescing-window", "-1"]).is_err() + ); + } } diff --git a/crates/anvil/src/config.rs b/crates/anvil/src/config.rs index f7eccaed0faf8..06ef142ac4146 100644 --- a/crates/anvil/src/config.rs +++ b/crates/anvil/src/config.rs @@ -1,28 +1,32 @@ use crate::{ - EthereumHardfork, FeeManager, PrecompileFactory, + FeeManager, PrecompileFactory, eth::{ backend::{ db::{Db, SerializableState}, - env::Env, - fork::{ClientFork, ClientForkConfig}, + fork::{ + ClientFork, ClientForkConfig, ForkEndpointIdentity, ensure_fork_network_supported, + }, genesis::GenesisConfig, mem::fork_db::ForkedDatabase, time::duration_since_unix_epoch, }, fees::{INITIAL_BASE_FEE, INITIAL_GAS_PRICE}, - pool::transactions::{PoolTransaction, TransactionOrder}, + miner::INSTANT_COALESCE_WINDOW, + pool::transactions::TransactionOrder, }, - hardfork::{ChainHardfork, ethereum_hardfork_from_block_tag, spec_id_from_ethereum_hardfork}, - mem::{self, in_memory_db::MemDb}, + mem::{self, in_memory_db::StateRootDb}, }; use alloy_chains::Chain; use alloy_consensus::BlockHeader; +use alloy_eips::{BlockId, eip1559::BaseFeeParams, eip7840::BlobParams}; +use alloy_evm::EvmEnv; use alloy_genesis::Genesis; -use alloy_network::{AnyNetwork, TransactionResponse}; -use alloy_op_hardforks::OpHardfork; -use alloy_primitives::{BlockNumber, TxHash, U256, hex, map::HashMap, utils::Unit}; +use alloy_network::{AnyNetwork, AnyRpcBlock, BlockResponse, TransactionResponse}; +use alloy_primitives::{ + Address, B256, BlockNumber, TxHash, U256, hex, keccak256, map::HashMap, utils::Unit, +}; use alloy_provider::Provider; -use alloy_rpc_types::{Block, BlockNumberOrTag}; +use alloy_rpc_types::anvil::{Metadata, NodeInfo}; use alloy_signer::Signer; use alloy_signer_local::{ MnemonicBuilder, PrivateKeySigner, @@ -33,37 +37,48 @@ use anvil_server::ServerConfig; use eyre::{Context, Result}; use foundry_common::{ ALCHEMY_FREE_TIER_CUPS, NON_ARCHIVE_NODE_WARNING, REQUEST_TIMEOUT, - provider::{ProviderBuilder, RetryProvider}, + provider::{ProviderBuilder, RetryProvider, is_rpc_method_not_found, redact_url}, }; use foundry_config::Config; use foundry_evm::{ - backend::{BlockchainDb, BlockchainDbMeta, SharedBackend}, + backend::{ + BlockchainDb, BlockchainDbMeta, ForkBlock, SharedBackend, account_fetch_policy_for_source, + }, constants::DEFAULT_CREATE2_DEPLOYER, - utils::{apply_chain_and_block_specific_env_changes, get_blob_base_fee_update_fraction}, + hardfork::FoundryHardfork, + traces::{CallTraceDecoderBuilder, identifier::SignaturesIdentifier}, + utils::{ + apply_chain_and_block_specific_env_changes_for_chain, block_env_from_header, + get_blob_params, get_blob_params_by_hardfork, + }, }; -use foundry_evm_core::AsEnvMut; -use itertools::Itertools; -use op_revm::OpTransaction; +use foundry_evm_networks::{NetworkConfigs, NetworkVariant}; use parking_lot::RwLock; use rand_08::thread_rng; use revm::{ - context::{BlockEnv, CfgEnv, TxEnv}, + context::{BlockEnv, CfgEnv}, context_interface::block::BlobExcessGasAndPrice, primitives::hardfork::SpecId, }; use serde_json::{Value, json}; use std::{ fmt::Write as FmtWrite, - fs::File, - io, net::{IpAddr, Ipv4Addr}, path::PathBuf, sync::Arc, time::Duration, }; +use tempo_hardfork::{ + TempoHardfork, + constants::gas::{TEMPO_T0_BASE_FEE, TEMPO_T1_BASE_FEE}, +}; +use tempo_precompiles::TIP_FEE_MANAGER_ADDRESS; use tokio::sync::RwLock as TokioRwLock; use yansi::Paint; +#[cfg(feature = "base")] +use alloy_chains::NamedChain; + pub use foundry_common::version::SHORT_VERSION as VERSION_MESSAGE; /// Default port the rpc will open @@ -72,9 +87,116 @@ pub const NODE_PORT: u16 = 8545; pub const CHAIN_ID: u64 = 31337; /// The default gas limit for all transactions pub const DEFAULT_GAS_LIMIT: u64 = 30_000_000; +/// The default number of slots in an epoch used for safe/finalized block tags. +pub const DEFAULT_SLOTS_IN_AN_EPOCH: u64 = 32; /// Default mnemonic for dev accounts pub const DEFAULT_MNEMONIC: &str = "test test test test test test test test test test test junk"; +/// Resolves source-chain upgrades without changing another execution family's historical lookup. +pub(crate) fn source_hardfork( + _execution_network: NetworkVariant, + chain_id: u64, + timestamp: u64, +) -> Option { + #[cfg(feature = "base")] + if _execution_network.is_base() { + return _execution_network.historical_hardfork(chain_id, timestamp); + } + #[cfg(feature = "optimism")] + if _execution_network.is_optimism() + && let Some(hardfork) = _execution_network.historical_hardfork(chain_id, timestamp) + { + return Some(hardfork); + } + #[cfg(feature = "base")] + if matches!(NamedChain::try_from(chain_id), Ok(NamedChain::Base | NamedChain::BaseSepolia)) { + #[cfg(feature = "optimism")] + return foundry_evm::hardfork::OpHardfork::from_chain_and_timestamp( + Chain::from_id(chain_id), + timestamp, + ) + .map(FoundryHardfork::Optimism); + #[cfg(not(feature = "optimism"))] + return None; + } + FoundryHardfork::from_chain_and_timestamp(chain_id, timestamp) +} + +#[derive(Clone, Copy, Debug)] +struct ForkOverrides { + gas_limit: Option, + gas_price: Option, + base_fee: Option, +} + +struct StableForkSnapshot { + endpoint_identity: ForkEndpointIdentity, + state_is_mutable: bool, + block_number: u64, + transaction_replay: Option, + block: Option, + gas_price: u128, +} + +/// Keep optional identity probes from delaying startup on RPCs that stall on unknown methods. +const FORK_IDENTITY_PROBE_TIMEOUT: Duration = Duration::from_millis(500); + +/// Best-effort Anvil detection that becomes strict after positive identification. +/// +/// Before the first successful `anvil_nodeInfo` response, any probe failure means that the +/// optional capability is unavailable. Mandatory standard RPC reads still expose endpoint-wide +/// failures. Once a response or cached endpoint identity identifies Anvil, every later probe +/// RPC failure is returned so it cannot hide an endpoint reset or execution-profile change. +/// Probe timeouts always mean that the optional capability is unavailable. +#[derive(Clone, Copy, Debug, Default)] +struct AnvilNodeInfoProbe { + identified: bool, + skip: bool, + /// A failed probe could not distinguish an unsupported method from a mutable Anvil source. + inconclusive: bool, +} + +impl AnvilNodeInfoProbe { + const fn new(identified: bool, skip: bool) -> Self { + Self { identified, skip, inconclusive: false } + } + + async fn request(&mut self, provider: &RetryProvider) -> Result> { + if self.skip { + return Ok(None); + } + let Ok(response) = tokio::time::timeout( + FORK_IDENTITY_PROBE_TIMEOUT, + provider.raw_request::<_, NodeInfo>("anvil_nodeInfo".into(), ()), + ) + .await + else { + self.inconclusive = true; + return Ok(None); + }; + match response { + Ok(node_info) => { + self.identified = true; + Ok(Some(node_info)) + } + Err(error) if !self.identified => { + self.inconclusive |= !is_rpc_method_not_found(&error); + Ok(None) + } + Err(error) => { + Err(error).wrap_err("failed to determine network family from fork endpoint") + } + } + } +} + +/// One-shot source data for a transaction-hash fork replay. +#[derive(Clone, Debug)] +pub(crate) struct ForkTransactionReplay { + pub(crate) source_block: AnyRpcBlock, + pub(crate) target_index: usize, +} + /// The default IPC endpoint pub const DEFAULT_IPC_ENDPOINT: &str = if cfg!(unix) { "/tmp/anvil.ipc" } else { r"\\.\pipe\anvil.ipc" }; @@ -88,6 +210,18 @@ const BANNER: &str = r" \__,_| |_| |_| \_/ |_| |_| "; +fn fork_source_id(urls: &[String], headers: &[String]) -> B256 { + let mut encoded = Vec::new(); + for parts in [urls, headers] { + encoded.extend_from_slice(&(parts.len() as u64).to_be_bytes()); + for part in parts { + encoded.extend_from_slice(&(part.len() as u64).to_be_bytes()); + encoded.extend_from_slice(part.as_bytes()); + } + } + keccak256(encoded) +} + /// Configurations of the EVM node #[derive(Clone, Debug)] pub struct NodeConfig { @@ -97,6 +231,8 @@ pub struct NodeConfig { pub gas_limit: Option, /// If set to `true`, disables the block gas limit pub disable_block_gas_limit: bool, + /// If set to `true`, enables the tx gas limit as imposed by Osaka (EIP-7825) + pub enable_tx_gas_limit: bool, /// Default gas price for all txs pub gas_price: Option, /// Default base fee @@ -105,8 +241,8 @@ pub struct NodeConfig { pub disable_min_priority_fee: bool, /// Default blob excess gas and price pub blob_excess_gas_and_price: Option, - /// The hardfork to use - pub hardfork: Option, + /// The hardfork to force, or `None` to infer it from chain activation data. + pub hardfork: Option, /// Signer accounts that will be initialised with `genesis_balance` in the genesis block pub genesis_accounts: Vec, /// Native token balance of every genesis account in the genesis block @@ -117,9 +253,11 @@ pub struct NodeConfig { pub genesis_block_number: Option, /// Signer accounts that can sign messages/transactions from the EVM node pub signer_accounts: Vec, - /// Configured block time for the EVM chain. Use `None` to mine a new block for every tx + /// Configured block time for the EVM chain. Use `None` for instant/auto mining. pub block_time: Option, - /// Disable auto, interval mining mode uns use `MiningMode::None` instead + /// Window for grouping ready transactions in auto mining; zero disables coalescing. + pub transaction_coalescing_window: Duration, + /// Disable auto and interval mining mode and use `MiningMode::None` instead. pub no_mining: bool, /// Enables auto and interval mining mode pub mixed_mining: bool, @@ -127,14 +265,34 @@ pub struct NodeConfig { pub port: u16, /// maximum number of transactions in a block pub max_transactions: usize, - /// url of the rpc server that should be used for any rpc calls - pub eth_rpc_url: Option, + /// Fork URLs for RPC calls. The first entry is the primary endpoint. + /// When multiple URLs are provided, requests are distributed using + /// round-robin load balancing with retry-based failover. + pub fork_urls: Vec, /// pins the block number or transaction hash for the state fork pub fork_choice: Option, - /// headers to use with `eth_rpc_url` + /// headers to use with fork RPC endpoints pub fork_headers: Vec, /// specifies chain id for cache to skip fetching from remote in offline-start mode pub fork_chain_id: Option, + /// Skip `anvil_nodeInfo` / `anvil_metadata` probes against the fork URL. + pub no_fork_node_info: bool, + /// Disables opportunistic BAL post-state prefill of the remote fork cache. + pub no_bal: bool, + /// Address fork state reads by block number instead of by block hash. + pub fork_state_by_number: bool, + /// Chain ID discovered from the active fork source. + pub fork_source_chain_id: Option, + /// Chain ID exposed by the active fork endpoint. + pub fork_execution_chain_id: Option, + /// Whether the active fork endpoint has positively identified itself as Anvil. + pub(crate) fork_endpoint_is_anvil: bool, + /// Network family most recently inferred from a fork endpoint. + inferred_fork_network: Option, + /// Network configuration replaced by chain-ID inference, if any. + chain_id_network_base: Option, + /// User-provided gas settings captured before fork-derived values are materialized. + fork_overrides: Option, /// The generator used to generate the dev accounts pub account_generator: Option, /// whether to enable tracing @@ -177,26 +335,36 @@ pub struct NodeConfig { pub prune_history: PruneStateHistoryConfig, /// Max number of states cached on disk. pub max_persisted_states: Option, - /// The file where to load the state from + /// The initial state to apply and consume during startup. pub init_state: Option, /// max number of blocks with transactions in memory pub transaction_block_keeper: Option, /// Disable the default CREATE2 deployer pub disable_default_create2_deployer: bool, - /// Enable Optimism deposit transaction - pub enable_optimism: bool, + /// Disable pool balance checks + pub disable_pool_balance_checks: bool, /// Slots in an epoch pub slots_in_an_epoch: u64, /// The memory limit per EVM execution in bytes. pub memory_limit: Option, /// Factory used by `anvil` to extend the EVM's precompiles. pub precompile_factory: Option>, - /// Enable Odyssey features. - pub odyssey: bool, + /// Networks to enable features for. + pub networks: NetworkConfigs, + /// Overrides the Base activation-registry administrator. + #[cfg(feature = "base")] + pub base_activation_admin: Option
, + /// The account used to sponsor Tempo fee-payer requests. + /// + /// Must be an unlocked signer account. Defaults to the last dev account on Tempo networks. + pub tempo_fee_payer: Option
, /// Do not print log messages. pub silent: bool, - /// The path where states are cached. + /// The path where persisted states are cached (used with `max_persisted_states`). + /// This does not affect the fork RPC cache location. pub cache_path: Option, + /// Accounts to fund with specific balances on startup (address -> balance in wei). + pub funded_accounts: HashMap, } impl NodeConfig { @@ -248,6 +416,18 @@ Derivation path: {} ); } + if let Some(fee_payer) = self.tempo_fee_payer_address() { + let _ = write!( + s, + r#" + +Tempo Fee Payer +================== +{fee_payer} +"# + ); + } + if let Some(fork) = fork { let _ = write!( s, @@ -260,11 +440,21 @@ Block number: {} Block hash: {:?} Chain ID: {} "#, - fork.eth_rpc_url(), + fork.eth_rpc_url().as_deref().map(redact_url).unwrap_or_else(|| "none".to_string()), fork.block_number(), fork.block_hash(), - fork.chain_id() + fork.execution_chain_id() ); + if fork.chain_id() != fork.execution_chain_id() { + let _ = writeln!(s, "Source chain ID: {}", fork.chain_id()); + } + + if self.fork_urls.len() > 1 { + let _ = writeln!(s, "Endpoints: {}", self.fork_urls.len()); + for (i, url) in self.fork_urls.iter().enumerate() { + let _ = writeln!(s, " ({i}) {}", redact_url(url)); + } + } if let Some(tx_hash) = fork.transaction_hash() { let _ = writeln!(s, "Transaction hash: {tx_hash}"); @@ -363,7 +553,7 @@ Genesis Number for wallet in &self.genesis_accounts { available_accounts.push(format!("{:?}", wallet.address())); - private_keys.push(format!("0x{}", hex::encode(wallet.credential().to_bytes()))); + private_keys.push(hex::encode_prefixed(wallet.credential().to_bytes())); } if let Some(generator) = &self.account_generator { @@ -385,10 +575,11 @@ Genesis Number json!({ "available_accounts": available_accounts, "private_keys": private_keys, - "endpoint": fork.eth_rpc_url(), + "endpoint": fork.eth_rpc_url().as_deref().map(redact_url).unwrap_or_default(), "block_number": fork.block_number(), "block_hash": fork.block_hash(), - "chain_id": fork.chain_id(), + "chain_id": fork.execution_chain_id(), + "source_chain_id": fork.chain_id(), "wallet": wallet_description, "base_fee": format!("{}", self.get_base_fee()), "gas_price": format!("{}", self.get_gas_price()), @@ -416,6 +607,28 @@ impl NodeConfig { Self { enable_tracing: true, port: 0, silent: true, ..Default::default() } } + /// Returns a test config with Tempo network enabled. + #[doc(hidden)] + pub fn test_tempo() -> Self { + Self { networks: NetworkConfigs::with_tempo(), ..Self::test() } + } + + /// Returns a test config with Monad network enabled. + #[cfg(feature = "monad")] + #[doc(hidden)] + pub fn test_monad() -> Self { + Self { networks: NetworkConfigs::with_monad(), ..Self::test() } + } + + /// Returns a test config with Base network enabled. + #[cfg(feature = "base")] + #[doc(hidden)] + pub fn test_base() -> Self { + Self::test() + .with_networks(NetworkConfigs::with_base()) + .with_chain_id(Some(NamedChain::Base as u64)) + } + /// Returns a new config which does not initialize any accounts on node startup. pub fn empty_state() -> Self { Self { @@ -438,6 +651,7 @@ impl Default for NodeConfig { chain_id: None, gas_limit: None, disable_block_gas_limit: false, + enable_tx_gas_limit: false, gas_price: None, hardfork: None, signer_accounts: genesis_accounts.clone(), @@ -447,12 +661,12 @@ impl Default for NodeConfig { // 100ETH default balance genesis_balance: Unit::ETHER.wei().saturating_mul(U256::from(100u64)), block_time: None, + transaction_coalescing_window: INSTANT_COALESCE_WINDOW, no_mining: false, mixed_mining: false, port: NODE_PORT, - // TODO make this something dependent on block capacity max_transactions: 1_000, - eth_rpc_url: None, + fork_urls: vec![], fork_choice: None, account_generator: None, base_fee: None, @@ -474,6 +688,15 @@ impl Default for NodeConfig { fork_request_retries: 5, fork_retry_backoff: Duration::from_millis(1_000), fork_chain_id: None, + no_fork_node_info: false, + no_bal: false, + fork_state_by_number: false, + fork_source_chain_id: None, + fork_execution_chain_id: None, + fork_endpoint_is_anvil: false, + inferred_fork_network: None, + chain_id_network_base: None, + fork_overrides: None, // alchemy max cpus compute_units_per_second: ALCHEMY_FREE_TIER_CUPS, ipc_path: None, @@ -483,34 +706,70 @@ impl Default for NodeConfig { init_state: None, transaction_block_keeper: None, disable_default_create2_deployer: false, - enable_optimism: false, - slots_in_an_epoch: 32, + disable_pool_balance_checks: false, + slots_in_an_epoch: DEFAULT_SLOTS_IN_AN_EPOCH, memory_limit: None, precompile_factory: None, - odyssey: false, + networks: Default::default(), + #[cfg(feature = "base")] + base_activation_admin: None, + tempo_fee_payer: None, silent: false, cache_path: None, + funded_accounts: HashMap::default(), } } } impl NodeConfig { + /// Applies Tempo's safe default beneficiary for forked nodes while preserving + /// explicit coinbase selections. + pub(crate) fn apply_tempo_fork_beneficiary_default(&self, evm_env: &mut EvmEnv) { + if self.networks.is_tempo() + && !self.fork_urls.is_empty() + && evm_env.block_env.beneficiary.is_zero() + { + // Tempo mainnet maps the zero validator token to a DONOTUSE sentinel. + // Forked transactions with the default zero beneficiary can therefore + // fail fee collection before producing a receipt. Use the same neutral + // fee-recipient sentinel as Tempo's simulation path so validator token + // lookup falls back to the default PathUSD token unless the user has + // explicitly supplied a non-zero coinbase. + evm_env.block_env.beneficiary = TIP_FEE_MANAGER_ADDRESS; + } + } + /// Returns the memory limit of the node #[must_use] - pub fn with_memory_limit(mut self, mems_value: Option) -> Self { + pub const fn with_memory_limit(mut self, mems_value: Option) -> Self { self.memory_limit = mems_value; self } - /// Returns the base fee to use + + /// Returns the base fee to use. + /// + /// In Tempo mode, uses the hardfork-specific base fee (10 gwei pre-T1, 20 gwei T1+). pub fn get_base_fee(&self) -> u64 { + let default = if self.networks.is_tempo() { + tempo_default_base_fee(TempoHardfork::from(self.get_hardfork())) + } else { + INITIAL_BASE_FEE + }; self.base_fee .or_else(|| self.genesis.as_ref().and_then(|g| g.base_fee_per_gas.map(|g| g as u64))) - .unwrap_or(INITIAL_BASE_FEE) + .unwrap_or(default) } - /// Returns the base fee to use + /// Returns the gas price to use. + /// + /// In Tempo mode, defaults to the hardfork-specific base fee. pub fn get_gas_price(&self) -> u128 { - self.gas_price.unwrap_or(INITIAL_GAS_PRICE) + let default = if self.networks.is_tempo() { + tempo_default_base_fee(TempoHardfork::from(self.get_hardfork())) as u128 + } else { + INITIAL_GAS_PRICE + }; + self.gas_price.unwrap_or(default) } pub fn get_blob_excess_gas_and_price(&self) -> BlobExcessGasAndPrice { @@ -521,37 +780,35 @@ impl NodeConfig { self.genesis.as_ref().and_then(|g| g.excess_blob_gas).unwrap_or(0); BlobExcessGasAndPrice::new( excess_blob_gas, - get_blob_base_fee_update_fraction( - self.chain_id.unwrap_or(Chain::mainnet().id()), - self.get_genesis_timestamp(), - ), + self.get_blob_params().update_fraction as u64, ) } } + /// Returns the [`BlobParams`] that should be used. + pub fn get_blob_params(&self) -> BlobParams { + get_blob_params_by_hardfork(self.get_hardfork()) + } + /// Returns the hardfork to use - pub fn get_hardfork(&self) -> ChainHardfork { - if self.odyssey { - return ChainHardfork::Ethereum(EthereumHardfork::default()); - } + pub fn get_hardfork(&self) -> FoundryHardfork { if let Some(hardfork) = self.hardfork { return hardfork; } - if self.enable_optimism { - return OpHardfork::default().into(); - } - EthereumHardfork::default().into() + self.networks + .execution_network() + .hardfork_at(self.protocol_chain_id(), self.get_genesis_timestamp()) } /// Sets a custom code size limit #[must_use] - pub fn with_code_size_limit(mut self, code_size_limit: Option) -> Self { + pub const fn with_code_size_limit(mut self, code_size_limit: Option) -> Self { self.code_size_limit = code_size_limit; self } /// Disables code size limit #[must_use] - pub fn disable_code_size_limit(mut self, disable_code_size_limit: bool) -> Self { + pub const fn disable_code_size_limit(mut self, disable_code_size_limit: bool) -> Self { if disable_code_size_limit { self.code_size_limit = Some(usize::MAX); } @@ -583,14 +840,33 @@ impl NodeConfig { /// Returns the chain ID to use pub fn get_chain_id(&self) -> u64 { self.chain_id + .or(self.fork_execution_chain_id) .or_else(|| self.genesis.as_ref().map(|g| g.config.chain_id)) .unwrap_or(CHAIN_ID) } + /// Returns the chain ID that defines protocol behavior. + fn protocol_chain_id(&self) -> u64 { + self.fork_source_chain_id.unwrap_or_else(|| self.get_chain_id()) + } + /// Sets the chain id and updates all wallets pub fn set_chain_id(&mut self, chain_id: Option>) { + if let Some(base) = self.chain_id_network_base.take() { + self.networks = base; + } self.chain_id = chain_id.map(Into::into); let chain_id = self.get_chain_id(); + let base = self.networks; + let inferred = base.with_chain_id(chain_id); + if !base.has_network_selection() && inferred.has_network_selection() { + self.chain_id_network_base = Some(base); + } + self.networks = inferred; + self.update_wallet_chain_id(chain_id); + } + + pub(crate) fn update_wallet_chain_id(&mut self, chain_id: u64) { self.genesis_accounts.iter_mut().for_each(|wallet| { *wallet = wallet.clone().with_chain_id(Some(chain_id)); }); @@ -601,7 +877,7 @@ impl NodeConfig { /// Sets the gas limit #[must_use] - pub fn with_gas_limit(mut self, gas_limit: Option) -> Self { + pub const fn with_gas_limit(mut self, gas_limit: Option) -> Self { self.gas_limit = gas_limit; self } @@ -610,14 +886,23 @@ impl NodeConfig { /// /// If set to `true` block gas limit will not be enforced #[must_use] - pub fn disable_block_gas_limit(mut self, disable_block_gas_limit: bool) -> Self { + pub const fn disable_block_gas_limit(mut self, disable_block_gas_limit: bool) -> Self { self.disable_block_gas_limit = disable_block_gas_limit; self } + /// Enable tx gas limit check + /// + /// If set to `true`, enables the tx gas limit as imposed by Osaka (EIP-7825) + #[must_use] + pub const fn enable_tx_gas_limit(mut self, enable_tx_gas_limit: bool) -> Self { + self.enable_tx_gas_limit = enable_tx_gas_limit; + self + } + /// Sets the gas price #[must_use] - pub fn with_gas_price(mut self, gas_price: Option) -> Self { + pub const fn with_gas_price(mut self, gas_price: Option) -> Self { self.gas_price = gas_price; self } @@ -639,6 +924,15 @@ impl NodeConfig { self } + /// Sets the max number of transactions in a block + #[must_use] + pub const fn with_max_transactions(mut self, max_transactions: Option) -> Self { + if let Some(max_transactions) = max_transactions { + self.max_transactions = max_transactions; + } + self + } + /// Sets max number of blocks with transactions to keep in memory #[must_use] pub fn with_transaction_block_keeper>( @@ -651,14 +945,14 @@ impl NodeConfig { /// Sets the base fee #[must_use] - pub fn with_base_fee(mut self, base_fee: Option) -> Self { + pub const fn with_base_fee(mut self, base_fee: Option) -> Self { self.base_fee = base_fee; self } /// Disable the enforcement of a minimum suggested priority fee #[must_use] - pub fn disable_min_priority_fee(mut self, disable_min_priority_fee: bool) -> Self { + pub const fn disable_min_priority_fee(mut self, disable_min_priority_fee: bool) -> Self { self.disable_min_priority_fee = disable_min_priority_fee; self } @@ -704,7 +998,7 @@ impl NodeConfig { /// Sets the hardfork #[must_use] - pub fn with_hardfork(mut self, hardfork: Option) -> Self { + pub const fn with_hardfork(mut self, hardfork: Option) -> Self { self.hardfork = hardfork; self } @@ -756,23 +1050,30 @@ impl NodeConfig { self } + /// Sets the auto-mining coalescing window. Zero disables the delay. + #[must_use] + pub const fn with_transaction_coalescing_window(mut self, window: Duration) -> Self { + self.transaction_coalescing_window = window; + self + } + /// If set to `true` auto mining will be disabled #[must_use] - pub fn with_no_mining(mut self, no_mining: bool) -> Self { + pub const fn with_no_mining(mut self, no_mining: bool) -> Self { self.no_mining = no_mining; self } /// Sets the slots in an epoch #[must_use] - pub fn with_slots_in_an_epoch(mut self, slots_in_an_epoch: u64) -> Self { + pub const fn with_slots_in_an_epoch(mut self, slots_in_an_epoch: u64) -> Self { self.slots_in_an_epoch = slots_in_an_epoch; self } /// Sets the port to use #[must_use] - pub fn with_port(mut self, port: u16) -> Self { + pub const fn with_port(mut self, port: u16) -> Self { self.port = port; self } @@ -796,22 +1097,32 @@ impl NodeConfig { self } - /// Disables storage caching #[must_use] - pub fn no_storage_caching(self) -> Self { - self.with_storage_caching(true) + pub const fn with_no_storage_caching(mut self, no_storage_caching: bool) -> Self { + self.no_storage_caching = no_storage_caching; + self } + /// Sets the `eth_rpc_url` to use when forking (single endpoint convenience). #[must_use] - pub fn with_storage_caching(mut self, storage_caching: bool) -> Self { - self.no_storage_caching = storage_caching; + pub fn with_eth_rpc_url>(mut self, eth_rpc_url: Option) -> Self { + if let Some(url) = eth_rpc_url { + let fork_urls = vec![url.into()]; + if self.fork_urls != fork_urls { + self.fork_endpoint_is_anvil = false; + } + self.fork_urls = fork_urls; + } self } - /// Sets the `eth_rpc_url` to use when forking + /// Sets the fork URLs for load-balanced multi-endpoint forking. #[must_use] - pub fn with_eth_rpc_url>(mut self, eth_rpc_url: Option) -> Self { - self.eth_rpc_url = eth_rpc_url.map(Into::into); + pub fn with_fork_urls(mut self, fork_urls: Vec) -> Self { + if self.fork_urls != fork_urls { + self.fork_endpoint_is_anvil = false; + } + self.fork_urls = fork_urls; self } @@ -837,14 +1148,39 @@ impl NodeConfig { self } + /// Sets whether fork state reads are addressed by block number instead of by block hash. + #[must_use] + pub const fn with_fork_state_by_number(mut self, fork_state_by_number: bool) -> Self { + self.fork_state_by_number = fork_state_by_number; + self + } + + /// Disables opportunistic BAL post-state prefill of the remote fork cache. + #[must_use] + pub const fn with_no_bal(mut self, no_bal: bool) -> Self { + self.no_bal = no_bal; + self + } + /// Sets the `fork_chain_id` to use to fork off local cache from #[must_use] - pub fn with_fork_chain_id(mut self, fork_chain_id: Option) -> Self { + pub const fn with_fork_chain_id(mut self, fork_chain_id: Option) -> Self { self.fork_chain_id = fork_chain_id; self } - /// Sets the `fork_headers` to use with `eth_rpc_url` + /// Skip `anvil_nodeInfo` / `anvil_metadata` probes against the fork URL. + #[must_use] + pub const fn with_no_fork_node_info(mut self, no_fork_node_info: bool) -> Self { + self.no_fork_node_info = no_fork_node_info; + self + } + + const fn node_info_probe(&self, identified: bool) -> AnvilNodeInfoProbe { + AnvilNodeInfoProbe::new(identified, self.no_fork_node_info) + } + + /// Sets the `fork_headers` to use with fork RPC endpoints #[must_use] pub fn with_fork_headers(mut self, headers: Vec) -> Self { self.fork_headers = headers; @@ -853,7 +1189,7 @@ impl NodeConfig { /// Sets the `fork_request_timeout` to use for requests #[must_use] - pub fn fork_request_timeout(mut self, fork_request_timeout: Option) -> Self { + pub const fn fork_request_timeout(mut self, fork_request_timeout: Option) -> Self { if let Some(fork_request_timeout) = fork_request_timeout { self.fork_request_timeout = fork_request_timeout; } @@ -862,7 +1198,7 @@ impl NodeConfig { /// Sets the `fork_request_retries` to use for spurious networks #[must_use] - pub fn fork_request_retries(mut self, fork_request_retries: Option) -> Self { + pub const fn fork_request_retries(mut self, fork_request_retries: Option) -> Self { if let Some(fork_request_retries) = fork_request_retries { self.fork_request_retries = fork_request_retries; } @@ -871,7 +1207,7 @@ impl NodeConfig { /// Sets the initial `fork_retry_backoff` for rate limits #[must_use] - pub fn fork_retry_backoff(mut self, fork_retry_backoff: Option) -> Self { + pub const fn fork_retry_backoff(mut self, fork_retry_backoff: Option) -> Self { if let Some(fork_retry_backoff) = fork_retry_backoff { self.fork_retry_backoff = fork_retry_backoff; } @@ -882,7 +1218,10 @@ impl NodeConfig { /// /// See also, #[must_use] - pub fn fork_compute_units_per_second(mut self, compute_units_per_second: Option) -> Self { + pub const fn fork_compute_units_per_second( + mut self, + compute_units_per_second: Option, + ) -> Self { if let Some(compute_units_per_second) = compute_units_per_second { self.compute_units_per_second = compute_units_per_second; } @@ -891,35 +1230,35 @@ impl NodeConfig { /// Sets whether to enable tracing #[must_use] - pub fn with_tracing(mut self, enable_tracing: bool) -> Self { + pub const fn with_tracing(mut self, enable_tracing: bool) -> Self { self.enable_tracing = enable_tracing; self } /// Sets whether to enable steps tracing #[must_use] - pub fn with_steps_tracing(mut self, enable_steps_tracing: bool) -> Self { + pub const fn with_steps_tracing(mut self, enable_steps_tracing: bool) -> Self { self.enable_steps_tracing = enable_steps_tracing; self } /// Sets whether to print `console.log` invocations to stdout. #[must_use] - pub fn with_print_logs(mut self, print_logs: bool) -> Self { + pub const fn with_print_logs(mut self, print_logs: bool) -> Self { self.print_logs = print_logs; self } /// Sets whether to print traces to stdout. #[must_use] - pub fn with_print_traces(mut self, print_traces: bool) -> Self { + pub const fn with_print_traces(mut self, print_traces: bool) -> Self { self.print_traces = print_traces; self } /// Sets whether to enable autoImpersonate #[must_use] - pub fn with_auto_impersonate(mut self, enable_auto_impersonate: bool) -> Self { + pub const fn with_auto_impersonate(mut self, enable_auto_impersonate: bool) -> Self { self.enable_auto_impersonate = enable_auto_impersonate; self } @@ -938,7 +1277,7 @@ impl NodeConfig { } #[must_use] - pub fn with_transaction_order(mut self, transaction_order: TransactionOrder) -> Self { + pub const fn with_transaction_order(mut self, transaction_order: TransactionOrder) -> Self { self.transaction_order = transaction_order; self } @@ -954,41 +1293,56 @@ impl NodeConfig { /// Prints the config info pub fn print(&self, fork: Option<&ClientFork>) -> Result<()> { if let Some(path) = &self.config_out { - let file = io::BufWriter::new( - File::create(path).wrap_err("unable to create anvil config description file")?, - ); let value = self.as_json(fork); - serde_json::to_writer(file, &value).wrap_err("failed writing JSON")?; + foundry_common::fs::write_sensitive_json_file(path, &value) + .wrap_err("failed writing JSON")?; } if !self.silent { sh_println!("{}", self.as_string(fork))?; + if self.networks.is_celo() { + foundry_common::sh_warn!( + "CIP-64 uses native fee accounting; feeCurrency is preserved but token fees are not charged" + )?; + } } Ok(()) } - /// Returns the path where the cache file should be stored + /// Returns the endpoint-specific path where the cache file should be stored. /// - /// See also [ Config::foundry_block_cache_file()] + /// See also [`Config::foundry_block_cache_file`]. pub fn block_cache_path(&self, block: u64) -> Option { - if self.no_storage_caching || self.eth_rpc_url.is_none() { + self.block_cache_path_for_rpc(self.protocol_chain_id(), block, self.fork_urls.first()?) + } + + fn block_cache_path_for_rpc( + &self, + source_chain_id: u64, + block: u64, + rpc_url: &str, + ) -> Option { + if self.no_storage_caching || self.fork_urls.is_empty() { return None; } - let chain_id = self.get_chain_id(); - Config::foundry_block_cache_file(chain_id, block) + let rpc_url_hash = hex::encode(keccak256(rpc_url)); + Some( + Config::foundry_block_cache_file(source_chain_id, block)? + .with_file_name(format!("storage-{rpc_url_hash}.json")), + ) } - /// Sets whether to enable optimism support + /// Sets whether to disable the default create2 deployer #[must_use] - pub fn with_optimism(mut self, enable_optimism: bool) -> Self { - self.enable_optimism = enable_optimism; + pub const fn with_disable_default_create2_deployer(mut self, yes: bool) -> Self { + self.disable_default_create2_deployer = yes; self } - /// Sets whether to disable the default create2 deployer + /// Sets whether to disable pool balance checks #[must_use] - pub fn with_disable_default_create2_deployer(mut self, yes: bool) -> Self { - self.disable_default_create2_deployer = yes; + pub const fn with_disable_pool_balance_checks(mut self, yes: bool) -> Self { + self.disable_pool_balance_checks = yes; self } @@ -999,41 +1353,127 @@ impl NodeConfig { self } - /// Sets whether to enable Odyssey support + /// Enable features for provided networks. + #[must_use] + pub const fn with_networks(mut self, networks: NetworkConfigs) -> Self { + self.networks = networks; + self.inferred_fork_network = None; + self.chain_id_network_base = None; + self + } + + /// Enable Tempo network features. + #[must_use] + pub fn with_tempo(mut self) -> Self { + self.networks = NetworkConfigs::with_tempo(); + self.inferred_fork_network = None; + self.chain_id_network_base = None; + self + } + + /// Sets the account used to sponsor Tempo fee-payer requests. #[must_use] - pub fn with_odyssey(mut self, odyssey: bool) -> Self { - self.odyssey = odyssey; + pub const fn with_tempo_fee_payer(mut self, fee_payer: Option
) -> Self { + self.tempo_fee_payer = fee_payer; + self + } + + /// Returns the effective account used to sponsor Tempo fee-payer requests. + /// + /// Defaults to the last dev account so it rarely collides with the sender accounts commonly + /// used in tests, mirroring the dedicated sponsor account of hosted fee payer services. + /// Returns `None` on non-Tempo networks. + pub fn tempo_fee_payer_address(&self) -> Option
{ + if !self.networks.is_tempo() { + return None; + } + self.tempo_fee_payer.or_else(|| self.genesis_accounts.last().map(|wallet| wallet.address())) + } + + /// Enable Monad network features. + #[cfg(feature = "monad")] + #[must_use] + pub fn with_monad(mut self) -> Self { + self.networks = NetworkConfigs::with_monad(); + self.inferred_fork_network = None; + self.chain_id_network_base = None; + self + } + + /// Enable Base network features. + #[cfg(feature = "base")] + #[must_use] + pub fn with_base(mut self) -> Self { + self.networks = NetworkConfigs::with_base(); + self.inferred_fork_network = None; + self.chain_id_network_base = None; + self + } + + /// Sets the Base activation-registry administrator override. + #[cfg(feature = "base")] + #[must_use] + pub const fn with_base_activation_admin(mut self, admin: Option
) -> Self { + self.base_activation_admin = admin; + self + } + + /// Enable Optimism network features. + #[cfg(feature = "optimism")] + #[must_use] + pub fn with_optimism(mut self) -> Self { + self.networks = NetworkConfigs::with_optimism(); + self.inferred_fork_network = None; + self.chain_id_network_base = None; self } /// Makes the node silent to not emit anything on stdout #[must_use] - pub fn silent(self) -> Self { + pub const fn silent(self) -> Self { self.set_silent(true) } #[must_use] - pub fn set_silent(mut self, silent: bool) -> Self { + pub const fn set_silent(mut self, silent: bool) -> Self { self.silent = silent; self } - /// Sets the path where states are cached + /// Sets the path where persisted states are cached (used with `max_persisted_states`). + /// + /// Note: This does not control the fork RPC cache location, which uses endpoint-specific files + /// under `~/.foundry/cache/rpc///`. #[must_use] pub fn with_cache_path(mut self, cache_path: Option) -> Self { self.cache_path = cache_path; self } + /// Sets accounts to fund with custom balances on startup. + #[must_use] + pub fn with_funded_accounts(mut self, accounts: HashMap) -> Self { + self.funded_accounts = accounts; + self + } + /// Configures everything related to env, backend and database and returns the /// [Backend](mem::Backend) /// /// *Note*: only memory based backend for now - pub(crate) async fn setup(&mut self) -> Result { + pub(crate) async fn setup( + &mut self, + ) -> Result<(mem::Backend, Option)> + where + N: alloy_network::Network< + TxEnvelope = foundry_primitives::FoundryTxEnvelope, + ReceiptEnvelope = foundry_primitives::FoundryReceiptEnvelope, + >, + { // configure the revm environment - let mut cfg = CfgEnv::default(); - cfg.spec = self.get_hardfork().into(); + let mut cfg = CfgEnv::new(); + cfg.set_spec_and_mainnet_gas_params(self.get_hardfork().into()); cfg.chain_id = self.get_chain_id(); cfg.limit_contract_code_size = self.code_size_limit; @@ -1043,76 +1483,120 @@ impl NodeConfig { cfg.disable_eip3607 = true; cfg.disable_block_gas_limit = self.disable_block_gas_limit; + if !self.enable_tx_gas_limit { + cfg.tx_gas_limit_cap = Some(u64::MAX); + } + if let Some(value) = self.memory_limit { cfg.memory_limit = value; } let spec_id = cfg.spec; - let mut env = Env::new( + let mut evm_env = EvmEnv::new( cfg, BlockEnv { gas_limit: self.gas_limit(), basefee: self.get_base_fee(), ..Default::default() }, - OpTransaction { - base: TxEnv { chain_id: Some(self.get_chain_id()), ..Default::default() }, - ..Default::default() - }, - self.enable_optimism, ); + self.apply_tempo_fork_beneficiary_default(&mut evm_env); + + let genesis_timestamp = self.get_genesis_timestamp(); + let base_fee_params: BaseFeeParams = self.networks.base_fee_params(genesis_timestamp); + + // On Tempo, the base fee follows the chain's hardfork rules instead of EIP-1559. + let tempo_hardfork = + self.networks.is_tempo().then(|| TempoHardfork::from(self.get_hardfork())); + let fees = FeeManager::new( spec_id, self.get_base_fee(), !self.disable_min_priority_fee, self.get_gas_price(), self.get_blob_excess_gas_and_price(), + self.get_blob_params(), + base_fee_params, + tempo_hardfork, ); + #[cfg(feature = "optimism")] + if self.networks.is_optimism() { + fees.set_optimism_hardfork(self.get_hardfork().into()); + } - let (db, fork): (Arc>>, Option) = - if let Some(eth_rpc_url) = self.eth_rpc_url.clone() { - self.setup_fork_db(eth_rpc_url, &mut env, &fees).await? + let (db, fork, fork_transaction_replay) = + if let Some(eth_rpc_url) = self.fork_urls.first().cloned() { + self.setup_fork_db_with_replay(eth_rpc_url, &mut evm_env, &fees).await? } else { - (Arc::new(TokioRwLock::new(Box::::default())), None) + let track_history = self.prune_history.is_state_history_supported(); + let db: Arc>> = + Arc::new(TokioRwLock::new(Box::new(StateRootDb::new(track_history)))); + (db, None, None) }; // if provided use all settings of `genesis.json` if let Some(ref genesis) = self.genesis { // --chain-id flag gets precedence over the genesis.json chain id // - if self.chain_id.is_none() { - env.evm_env.cfg_env.chain_id = genesis.config.chain_id; + if self.chain_id.is_none() && fork.is_none() { + evm_env.cfg_env.chain_id = genesis.config.chain_id; } - env.evm_env.block_env.timestamp = U256::from(genesis.timestamp); + evm_env.block_env.timestamp = U256::from(genesis.timestamp); if let Some(base_fee) = genesis.base_fee_per_gas { - env.evm_env.block_env.basefee = base_fee.try_into()?; + evm_env.block_env.basefee = base_fee.try_into()?; } if let Some(number) = genesis.number { - env.evm_env.block_env.number = U256::from(number); + evm_env.block_env.number = U256::from(number); } - env.evm_env.block_env.beneficiary = genesis.coinbase; + evm_env.block_env.beneficiary = genesis.coinbase; + } + + // Fork setup initializes its own timestamp. For a local chain, keep the initial EVM and + // genesis block on the same resolved timestamp, so that calls against the latest block + // observe the genesis time, and timestamp-activated precompiles and validity windows + // behave the same before and after the first mined block. + if fork.is_none() { + evm_env.block_env.timestamp = U256::from(genesis_timestamp); } + self.apply_tempo_fork_beneficiary_default(&mut evm_env); + let genesis = GenesisConfig { number: self.get_genesis_number(), - timestamp: self.get_genesis_timestamp(), + timestamp: genesis_timestamp, balance: self.genesis_balance, accounts: self.genesis_accounts.iter().map(|acc| acc.address()).collect(), genesis_init: self.genesis.clone(), }; + let active_hardfork = fork + .as_ref() + .and_then(|fork| fork.config.read().hardfork) + .unwrap_or_else(|| self.get_hardfork()); + let mut decoder_builder = CallTraceDecoderBuilder::new() + .with_networks(self.networks) + .with_hardfork(Some(self.networks.executed_hardfork(active_hardfork))); + if self.print_traces { + // if traces should get printed we configure the decoder with the signatures cache + if let Ok(identifier) = SignaturesIdentifier::new(false) { + debug!(target: "node", "using signature identifier"); + decoder_builder = decoder_builder.with_signature_identifier(identifier); + } + } + // only memory based backend for now let backend = mem::Backend::with_genesis( db, - Arc::new(RwLock::new(env)), + Arc::new(RwLock::new(evm_env)), + self.networks, genesis, fees, Arc::new(RwLock::new(fork)), self.enable_steps_tracing, self.print_logs, self.print_traces, - self.odyssey, + Arc::new(decoder_builder.build()), self.prune_history, self.max_persisted_states, self.transaction_block_keeper, @@ -1124,18 +1608,28 @@ impl NodeConfig { // Writes the default create2 deployer to the backend, // if the option is not disabled and we are not forking. - if !self.disable_default_create2_deployer && self.eth_rpc_url.is_none() { + if !self.disable_default_create2_deployer && self.fork_urls.is_empty() { backend .set_create2_deployer(DEFAULT_CREATE2_DEPLOYER) .await .wrap_err("failed to create default create2 deployer")?; } - if let Some(state) = self.init_state.clone() { - backend.load_state(state).await.wrap_err("failed to load init state")?; + if let Some(fork) = backend.get_fork() { + let config = fork.config.read().clone(); + if !self + .fork_urls_match_context( + &config.fork_urls, + config.endpoint_identity, + config.block_number, + config.block_hash, + ) + .await? + { + eyre::bail!("fork endpoint changed while Anvil was being initialized"); + } } - - Ok(backend) + Ok((backend, fork_transaction_replay)) } /// Configures everything related to forking based on the passed `eth_rpc_url`: @@ -1147,13 +1641,317 @@ impl NodeConfig { pub async fn setup_fork_db( &mut self, eth_rpc_url: String, - env: &mut Env, + evm_env: &mut EvmEnv, fees: &FeeManager, ) -> Result<(Arc>>, Option)> { - let (db, config) = self.setup_fork_db_config(eth_rpc_url, env, fees).await?; + let (db, fork, replay) = self.setup_fork_db_with_replay(eth_rpc_url, evm_env, fees).await?; + eyre::ensure!(replay.is_none(), "transaction-hash fork replay requires full node startup"); + Ok((db, fork)) + } + + async fn setup_fork_db_with_replay( + &mut self, + eth_rpc_url: String, + evm_env: &mut EvmEnv, + fees: &FeeManager, + ) -> Result<(Arc>>, Option, Option)> + { + let (db, config, replay) = + self.setup_fork_db_config_with_replay(eth_rpc_url, evm_env, fees, None).await?; + if !self.no_bal && !self.no_fork_node_info { + config.prefill_cache(db.inner()).await; + } let db: Arc>> = Arc::new(TokioRwLock::new(Box::new(db))); let fork = ClientFork::new(config, Arc::clone(&db)); - Ok((db, Some(fork))) + Ok((db, Some(fork), replay)) + } + + fn fork_provider(&self, eth_rpc_url: &str) -> Result { + ProviderBuilder::new(eth_rpc_url) + .timeout(self.fork_request_timeout) + .initial_backoff(self.fork_retry_backoff.as_millis() as u64) + .compute_units_per_second(self.compute_units_per_second) + .max_retry(self.fork_request_retries) + .headers(self.fork_headers.clone()) + .build() + .wrap_err("failed to establish provider to fork url") + } + + async fn fork_endpoint_identity( + &self, + provider: &RetryProvider, + fallback_execution_chain_id: u64, + source_chain_id_override: Option, + node_info_probe: &mut AnvilNodeInfoProbe, + ) -> Result { + let Some(node_info) = node_info_probe.request(provider).await? else { + let source_chain_id = source_chain_id_override.unwrap_or(fallback_execution_chain_id); + let explicit_fallback = self.has_explicit_network_selection().then_some(self.networks); + let network_profile = NetworkConfigs::from_rpc_identity_profile_with_fallback( + source_chain_id, + None, + explicit_fallback, + ) + .map_err(eyre::Report::msg)?; + return Ok(ForkEndpointIdentity { + execution_chain_id: fallback_execution_chain_id, + source_chain_id, + network: network_profile.map(|profile| profile.execution_network()), + network_profile, + hardfork: None, + instance_id: None, + source_fork_block_number: None, + source_fork_block_hash: None, + }); + }; + + let ( + execution_chain_id, + source_chain_id, + instance_id, + source_fork_block_number, + source_fork_block_hash, + ) = match tokio::time::timeout( + FORK_IDENTITY_PROBE_TIMEOUT, + provider.raw_request::<_, Metadata>("anvil_metadata".into(), ()), + ) + .await + { + Ok(Ok(metadata)) => ( + metadata.chain_id, + source_chain_id_override.unwrap_or_else(|| { + metadata.forked_network.map(|fork| fork.chain_id).unwrap_or(metadata.chain_id) + }), + Some(metadata.instance_id), + metadata.forked_network.map(|fork| fork.fork_block_number), + metadata.forked_network.map(|fork| fork.fork_block_hash), + ), + response => { + if let Ok(Err(error)) = response + && !is_rpc_method_not_found(&error) + { + return Err(error).wrap_err("failed to retrieve Anvil fork source identity"); + } + ( + fallback_execution_chain_id, + source_chain_id_override.unwrap_or(fallback_execution_chain_id), + None, + None, + None, + ) + } + }; + let identity_chain_id = + if node_info.network.is_some() { execution_chain_id } else { source_chain_id }; + let explicit_fallback = self.has_explicit_network_selection().then_some(self.networks); + let network_profile = NetworkConfigs::from_rpc_identity_profile_with_fallback( + identity_chain_id, + Some(node_info.network.as_deref()), + explicit_fallback, + ) + .map_err(eyre::Report::msg)? + .ok_or_else(|| eyre::eyre!("Anvil metadata did not identify an execution profile"))?; + let network = network_profile.execution_network(); + let hardfork = network + .parse_hardfork(&node_info.hard_fork) + .map_err(eyre::Report::msg) + .wrap_err_with(|| { + format!("unsupported hardfork `{}` reported for `{network}`", node_info.hard_fork) + })?; + + Ok(ForkEndpointIdentity { + execution_chain_id, + source_chain_id, + network: Some(network), + network_profile: Some(network_profile), + hardfork: Some(hardfork), + instance_id, + source_fork_block_number, + source_fork_block_hash, + }) + } + + async fn resolved_fork_endpoint_identity( + &self, + provider: &RetryProvider, + node_info_probe: &mut AnvilNodeInfoProbe, + ) -> Result { + let identity = if let Some(chain_id) = self.fork_chain_id { + let chain_id = chain_id.to(); + // `fork_chain_id` avoids depending on `eth_chainId`, but an online Anvil endpoint can + // still expose authoritative family, hardfork, and instance metadata. Probe it so + // mirror validation and reset staging cannot mistake this node for its own upstream. + self.fork_endpoint_identity(provider, chain_id, Some(chain_id), node_info_probe).await + } else { + let execution_chain_id = + provider.get_chain_id().await.wrap_err("failed to fetch network chain ID")?; + self.fork_endpoint_identity(provider, execution_chain_id, None, node_info_probe).await + }?; + ensure_fork_network_supported(identity.source_chain_id)?; + Ok(identity) + } + + pub(crate) async fn replacement_fork_provider( + &self, + eth_rpc_url: &str, + expected: ForkEndpointIdentity, + block_number: u64, + block_hash: B256, + serving_instance_id: B256, + ) -> Result<(Arc, ForkEndpointIdentity)> { + let provider = Arc::new(self.fork_provider(eth_rpc_url)?); + let mut node_info_probe = self.node_info_probe(false); + for _ in 0..3 { + let before = + self.resolved_fork_endpoint_identity(&provider, &mut node_info_probe).await?; + eyre::ensure!( + before.instance_id != Some(serving_instance_id), + "cannot set Anvil's fork provider to its own RPC endpoint" + ); + let block = provider + .get_block(BlockId::number(block_number)) + .await + .wrap_err("failed to confirm active fork block on replacement endpoint")?; + let after = + self.resolved_fork_endpoint_identity(&provider, &mut node_info_probe).await?; + if before != after { + continue; + } + if !before.context_eq(expected) { + eyre::bail!("replacement fork endpoint has an incompatible execution context"); + } + let actual_hash = block.map(|block| block.header.hash); + if actual_hash != Some(block_hash) { + eyre::bail!( + "replacement fork endpoint does not contain active fork block {block_number} with hash {block_hash}" + ); + } + return Ok((provider, before)); + } + eyre::bail!( + "fork endpoint changed while its identity and active fork block were being resolved" + ); + } + + async fn stable_fork_snapshot( + &self, + provider: &Arc, + fork_overrides: ForkOverrides, + ) -> Result { + let mut node_info_probe = self.node_info_probe(self.fork_endpoint_is_anvil); + for _ in 0..3 { + let before = + self.resolved_fork_endpoint_identity(provider, &mut node_info_probe).await?; + let (block_number, transaction_replay) = if let Some(fork_choice) = &self.fork_choice { + derive_block_and_replay(fork_choice, provider).await.wrap_err( + "failed to derive fork block and transaction replay from fork choice", + )? + } else { + ( + find_latest_fork_block(provider) + .await + .wrap_err("failed to get fork block number")?, + None, + ) + }; + let block = provider + .get_block(BlockId::number(block_number)) + .await + .wrap_err("failed to get fork block")?; + let gas_price = if let Some(gas_price) = fork_overrides.gas_price { + gas_price + } else { + provider.get_gas_price().await.unwrap_or(INITIAL_GAS_PRICE) + }; + let after = + self.resolved_fork_endpoint_identity(provider, &mut node_info_probe).await?; + if before == after { + return Ok(StableForkSnapshot { + endpoint_identity: before, + state_is_mutable: node_info_probe.identified || node_info_probe.inconclusive, + block_number, + transaction_replay, + block, + gas_price, + }); + } + } + eyre::bail!( + "fork endpoint changed while its identity and block context were being resolved" + ); + } + + pub(crate) async fn fork_context_matches( + &self, + eth_rpc_url: &str, + expected: ForkEndpointIdentity, + block_number: u64, + block_hash: B256, + ) -> Result { + let provider = self.fork_provider(eth_rpc_url)?; + let mut node_info_probe = self.node_info_probe(expected.is_authoritative()); + for _ in 0..3 { + let before = + self.resolved_fork_endpoint_identity(&provider, &mut node_info_probe).await?; + let block = provider + .get_block(BlockId::number(block_number)) + .await + .wrap_err("failed to confirm fork block context")?; + let after = + self.resolved_fork_endpoint_identity(&provider, &mut node_info_probe).await?; + if before != after { + continue; + } + if before.source_chain_id != expected.source_chain_id { + eyre::bail!( + "fork endpoints must use the same chain ID: expected {}, got {} from {}", + expected.source_chain_id, + before.source_chain_id, + redact_url(eth_rpc_url) + ); + } + return Ok( + before == expected && block.is_some_and(|block| block.header.hash == block_hash) + ); + } + Ok(false) + } + + pub(crate) async fn fork_urls_match_context( + &self, + fork_urls: &[String], + expected: ForkEndpointIdentity, + block_number: u64, + block_hash: B256, + ) -> Result { + for eth_rpc_url in Self::fork_urls_requiring_revalidation(fork_urls, expected) { + if !self.fork_context_matches(eth_rpc_url, expected, block_number, block_hash).await? { + return Ok(false); + } + } + Ok(true) + } + + const fn fork_urls_requiring_revalidation( + fork_urls: &[String], + endpoint_identity: ForkEndpointIdentity, + ) -> &[String] { + if endpoint_identity.is_authoritative() || fork_urls.len() > 1 { fork_urls } else { &[] } + } + + pub(crate) fn has_explicit_network_selection(&self) -> bool { + let effective_network = + self.networks.resolved_network().unwrap_or(NetworkVariant::Ethereum); + self.networks.has_network_selection() + && self.chain_id_network_base.is_none() + && self.inferred_fork_network != Some(effective_network) + } + + const fn requires_primary_fork_revalidation( + &self, + endpoint_identity: ForkEndpointIdentity, + ) -> bool { + endpoint_identity.is_authoritative() || self.fork_urls.len() > 1 } /// Configures everything related to forking based on the passed `eth_rpc_url`: @@ -1164,60 +1962,101 @@ impl NodeConfig { pub async fn setup_fork_db_config( &mut self, eth_rpc_url: String, - env: &mut Env, + evm_env: &mut EvmEnv, fees: &FeeManager, - ) -> Result<(ForkedDatabase, ClientForkConfig)> { - debug!(target: "node", ?eth_rpc_url, "setting up fork db"); - let provider = Arc::new( - ProviderBuilder::new(ð_rpc_url) - .timeout(self.fork_request_timeout) - .initial_backoff(self.fork_retry_backoff.as_millis() as u64) - .compute_units_per_second(self.compute_units_per_second) - .max_retry(self.fork_request_retries) - .initial_backoff(1000) - .headers(self.fork_headers.clone()) - .build() - .wrap_err("failed to establish provider to fork url")?, - ); + ) -> Result<(ForkedDatabase, ClientForkConfig)> { + let (db, config, replay) = + self.setup_fork_db_config_with_replay(eth_rpc_url, evm_env, fees, None).await?; + eyre::ensure!(replay.is_none(), "transaction-hash fork replay requires full node startup"); + if !self.no_bal && !self.no_fork_node_info { + config.prefill_cache(db.inner()).await; + } + Ok((db, config)) + } - let (fork_block_number, fork_chain_id, force_transactions) = if let Some(fork_choice) = - &self.fork_choice - { - let (fork_block_number, force_transactions) = - derive_block_and_transactions(fork_choice, &provider).await.wrap_err( - "failed to derive fork block number and force transactions from fork choice", - )?; - let chain_id = if let Some(chain_id) = self.fork_chain_id { - Some(chain_id) - } else if self.hardfork.is_none() { - // Auto-adjust hardfork if not specified, but only if we're forking mainnet. - let chain_id = - provider.get_chain_id().await.wrap_err("failed to fetch network chain ID")?; - if alloy_chains::NamedChain::Mainnet == chain_id { - let hardfork: EthereumHardfork = - ethereum_hardfork_from_block_tag(fork_block_number); - - env.evm_env.cfg_env.spec = spec_id_from_ethereum_hardfork(hardfork); - self.hardfork = Some(ChainHardfork::Ethereum(hardfork)); - } - Some(U256::from(chain_id)) - } else { - None - }; + /// Configures a replacement fork while preserving the already-instantiated execution profile. + pub(crate) async fn setup_fork_db_config_for_reset( + &mut self, + eth_rpc_url: String, + evm_env: &mut EvmEnv, + fees: &FeeManager, + execution_profile: NetworkConfigs, + ) -> Result<(ForkedDatabase, ClientForkConfig)> { + let (db, config, replay) = self + .setup_fork_db_config_with_replay(eth_rpc_url, evm_env, fees, Some(execution_profile)) + .await?; + eyre::ensure!(replay.is_none(), "transaction-hash fork replay requires full node startup"); + Ok((db, config)) + } - (fork_block_number, chain_id, force_transactions) - } else { - // pick the last block number but also ensure it's not pending anymore - let bn = find_latest_fork_block(&provider) - .await - .wrap_err("failed to get fork block number")?; - (bn, None, None) - }; + pub(crate) async fn setup_fork_db_config_with_replay( + &mut self, + eth_rpc_url: String, + evm_env: &mut EvmEnv, + fees: &FeeManager, + fixed_execution_profile: Option, + ) -> Result<(ForkedDatabase, ClientForkConfig, Option)> { + debug!(target: "node", eth_rpc_url=%redact_url(ð_rpc_url), "setting up fork db"); + if self.fork_chain_id.is_some() { + eyre::ensure!( + self.fork_urls.len() == 1, + "multiple fork URLs cannot be validated with --fork-chain-id; remove \ + --fork-chain-id to validate every endpoint" + ); + } + let fork_overrides = *self.fork_overrides.get_or_insert(ForkOverrides { + gas_limit: self.gas_limit, + gas_price: self.gas_price, + base_fee: self.base_fee, + }); - let block = provider - .get_block(BlockNumberOrTag::Number(fork_block_number).into()) - .await - .wrap_err("failed to get fork block")?; + // Always bootstrap with the primary URL only to avoid race conditions + // where discovery calls (get_chain_id, find_latest_fork_block, get_block) + // hit different endpoints that may be at different chain tips. + let provider = Arc::new(self.fork_provider(ð_rpc_url)?); + + // Resolve identity, block, and fee data as one stable snapshot. An upstream Anvil can + // reset between any two RPC calls, so verify the endpoint identity on both sides. + let StableForkSnapshot { + endpoint_identity: fork_identity, + state_is_mutable, + block_number: fork_block_number, + transaction_replay: fork_transaction_replay, + block, + gas_price, + } = self.stable_fork_snapshot(&provider, fork_overrides).await?; + self.fork_endpoint_is_anvil = fork_identity.is_authoritative(); + + let target_network = fork_identity.network.unwrap_or(NetworkVariant::Ethereum); + let target_profile = fork_identity.network_profile.unwrap_or_default(); + if let Some(execution_profile) = fixed_execution_profile { + if !self.has_explicit_network_selection() { + eyre::ensure!( + execution_profile.supports_fork_source(&target_profile), + "cannot reset Anvil across network families ({} -> {}); start a new instance \ + with matching network configuration", + execution_profile.execution_profile_name(), + target_profile.execution_profile_name() + ); + } + self.networks = execution_profile; + } else if self.inferred_fork_network.is_some() { + eyre::ensure!( + self.networks.supports_fork_source(&target_profile), + "cannot reset Anvil across network families ({} -> {}); start a new instance \ + with matching network configuration", + self.networks.execution_profile_name(), + target_profile.execution_profile_name() + ); + } + let source_chain_id = fork_identity.source_chain_id; + self.fork_source_chain_id = Some(source_chain_id); + self.fork_execution_chain_id = Some(fork_identity.execution_chain_id); + if fixed_execution_profile.is_none() && !self.has_explicit_network_selection() { + self.networks = self.networks.with_rpc_profile(target_profile); + self.inferred_fork_network = Some(target_network); + self.chain_id_network_base = None; + } let block = if let Some(block) = block { block @@ -1235,129 +2074,249 @@ latest block number: {latest_block}" } eyre::bail!("{message}"); } - eyre::bail!("failed to get block for block number: {fork_block_number}") + eyre::bail!("failed to get block for block number: {fork_block_number}"); }; - let gas_limit = self.fork_gas_limit(&block); + if let Some(replay) = &fork_transaction_replay { + let source_header = replay.source_block.header(); + eyre::ensure!( + block.header.hash == source_header.parent_hash, + "fork transaction block {} at {} has parent {}, but fetched fork block at {} has \ + hash {}", + source_header.hash, + source_header.number, + source_header.parent_hash, + block.header.number, + block.header.hash + ); + eyre::ensure!( + block.header.number.checked_add(1) == Some(source_header.number), + "fork transaction block {} has number {}, but fetched parent {} has number {}", + source_header.hash, + source_header.number, + block.header.hash, + block.header.number + ); + } + + let gas_limit = self.fork_gas_limit_with_override(&block, fork_overrides.gas_limit); self.gas_limit = Some(gas_limit); - env.evm_env.block_env = BlockEnv { - number: U256::from(fork_block_number), - timestamp: U256::from(block.header.timestamp), - difficulty: block.header.difficulty, - // ensures prevrandao is set - prevrandao: Some(block.header.mix_hash.unwrap_or_default()), + // Cache identity must describe the remote fork block, not local execution overrides that + // can change after mining (for example, the locally advanced base fee). + let cache_block_env: BlockEnv = block_env_from_header(&block.header); + + evm_env.block_env = BlockEnv { gas_limit, - // Keep previous `coinbase` and `basefee` value - beneficiary: env.evm_env.block_env.beneficiary, - basefee: env.evm_env.block_env.basefee, - ..Default::default() + // Preserve configured local overrides while replacing fork-derived block values. + beneficiary: evm_env.block_env.beneficiary, + basefee: fork_overrides + .base_fee + .or_else(|| block.header.base_fee_per_gas()) + .unwrap_or_default(), + ..block_env_from_header(&block.header) }; + let override_chain_id = self.chain_id; + let execution_chain_id = override_chain_id.unwrap_or(fork_identity.execution_chain_id); + if override_chain_id.is_none() { + // Sign locally produced transactions for the chain ID exposed by the endpoint + // without turning the inferred value into an explicit execution override. + self.update_wallet_chain_id(fork_identity.execution_chain_id); + } + evm_env.cfg_env.chain_id = execution_chain_id; + + // Resolve the fork block's hardfork without materializing it into `self.hardfork`. + // That field represents the user's explicit override; keeping inference on the fork + // config lets a later reset re-resolve timestamp-based activations. + let effective_network = + self.networks.resolved_network().unwrap_or(NetworkVariant::Ethereum); + let endpoint_hardfork = fork_identity + .hardfork + .filter(|hardfork| hardfork.namespace() == effective_network.hardfork_namespace()); + let inferred_hardfork = endpoint_hardfork.or_else(|| { + effective_network.historical_hardfork(source_chain_id, block.header.timestamp()) + }); + let source_protocol_hardfork = fork_identity.hardfork.or_else(|| { + source_hardfork(effective_network, source_chain_id, block.header.timestamp()) + }); + let source_may_omit_blob_fields = source_protocol_hardfork + .map_or(self.hardfork.is_some(), |hardfork| SpecId::from(hardfork) < SpecId::CANCUN); + let fork_hardfork = self.hardfork.or(inferred_hardfork); + let effective_hardfork = fork_hardfork.unwrap_or_else(|| self.get_hardfork()); + let effective_spec = SpecId::from(effective_hardfork); + evm_env.cfg_env.set_spec_and_mainnet_gas_params(effective_spec); + fees.set_execution_rules( + effective_spec, + self.networks.base_fee_params(block.header.timestamp()), + self.networks.is_tempo().then(|| TempoHardfork::from(effective_hardfork)), + ); + #[cfg(feature = "optimism")] + if self.networks.is_optimism() { + fees.set_optimism_base_fee_rules(block.header.extra_data()); + } + // if not set explicitly we use the base fee of the latest block - if self.base_fee.is_none() { - if let Some(base_fee) = block.header.base_fee_per_gas { - self.base_fee = Some(base_fee); - env.evm_env.block_env.basefee = base_fee; - // this is the base fee of the current block, but we need the base fee of - // the next block - let next_block_base_fee = fees.get_next_block_base_fee_per_gas( - block.header.gas_used, - gas_limit, - block.header.base_fee_per_gas.unwrap_or_default(), - ); + self.base_fee = fork_overrides.base_fee.or_else(|| block.header.base_fee_per_gas()); + if let Some(base_fee) = fork_overrides.base_fee { + fees.set_base_fee(base_fee); + } else if let Some(base_fee) = block.header.base_fee_per_gas() { + // This is the base fee of the current block, but we need the base fee of the next + // block. + fees.set_base_fee(base_fee); + let next_block_base_fee = fees.get_next_block_base_fee_from_header(&block.header); + fees.set_base_fee(next_block_base_fee); + } else { + fees.set_base_fee(self.get_base_fee()); + } - // update next base fee - fees.set_base_fee(next_block_base_fee); - } - if let (Some(blob_excess_gas), Some(blob_gas_used)) = - (block.header.excess_blob_gas, block.header.blob_gas_used) - { - let blob_base_fee_update_fraction = get_blob_base_fee_update_fraction( - fork_chain_id - .unwrap_or_else(|| U256::from(Chain::mainnet().id())) - .saturating_to(), - block.header.timestamp, - ); + // Blob rules and fee state belong to the selected fork context even when the target block + // predates Cancun. Always replace both so a reset cannot retain the previous fork's + // schedule or excess gas. + let blob_params = get_blob_params(source_chain_id, block.header.timestamp()); + fees.set_blob_params(blob_params); + let blob_update_fraction = blob_params.update_fraction as u64; + let blob_excess_gas = block.header.excess_blob_gas().or_else(|| { + // Pre-Cancun headers, Polygon Bor headers, and Arbitrum Nitro headers omit the blob + // fields. REVM still requires a valid blob environment when executing with the Cancun + // spec; zero is the neutral excess-gas value. On Nitro this makes `BLOBBASEFEE` return + // `1`, although Nitro rejects the opcode; matching that requires Arbitrum-specific EVM + // handling. + (effective_spec >= SpecId::CANCUN + && ((source_may_omit_blob_fields && block.header.blob_gas_used().is_none()) + || Chain::from_id(source_chain_id).is_polygon() + || Chain::from_id(source_chain_id).is_arbitrum())) + .then_some(0) + }); + evm_env.block_env.blob_excess_gas_and_price = + blob_excess_gas.map(|excess| BlobExcessGasAndPrice::new(excess, blob_update_fraction)); + let next_block_blob_excess_gas = blob_excess_gas.map_or(0, |excess| { + self.networks.next_block_blob_excess_gas( + blob_params, + excess, + block.header.blob_gas_used().unwrap_or_default(), + block.header.base_fee_per_gas().unwrap_or_default(), + ) + }); + fees.set_blob_excess_gas_and_price(BlobExcessGasAndPrice::new( + next_block_blob_excess_gas, + blob_update_fraction, + )); - env.evm_env.block_env.blob_excess_gas_and_price = Some(BlobExcessGasAndPrice::new( - blob_excess_gas, - blob_base_fee_update_fraction, - )); + // Use the gas price captured in the stable endpoint snapshot. + self.gas_price = Some(gas_price); + fees.set_gas_price(gas_price); - let next_block_blob_excess_gas = - fees.get_next_block_blob_excess_gas(blob_excess_gas, blob_gas_used); + let block_hash = block.header.hash; + + // Apply changes such as difficulty -> prevrandao for the remote source chain. + apply_chain_and_block_specific_env_changes_for_chain::( + evm_env, + &block, + source_chain_id, + self.networks, + ); - fees.set_blob_excess_gas_and_price(BlobExcessGasAndPrice::new( - next_block_blob_excess_gas, - blob_base_fee_update_fraction, - )); + for mirror_url in self.fork_urls.iter().skip(1) { + if !self + .fork_context_matches(mirror_url, fork_identity, fork_block_number, block_hash) + .await? + { + eyre::bail!( + "fork fallback endpoint `{}` does not expose the primary endpoint's execution \ + and block context", + redact_url(mirror_url) + ); } } - - // use remote gas price - if self.gas_price.is_none() - && let Ok(gas_price) = provider.get_gas_price().await + if self.requires_primary_fork_revalidation(fork_identity) + && !self + .fork_context_matches(ð_rpc_url, fork_identity, fork_block_number, block_hash) + .await? { - self.gas_price = Some(gas_price); - fees.set_gas_price(gas_price); + eyre::bail!("primary fork endpoint changed while its context was being validated"); } - let block_hash = block.header.hash; - - let chain_id = if let Some(chain_id) = self.chain_id { - chain_id - } else { - let chain_id = if let Some(fork_chain_id) = fork_chain_id { - fork_chain_id.to() - } else { - provider.get_chain_id().await.wrap_err("failed to fetch network chain ID")? - }; - - // need to update the dev signers and env with the chain id - self.set_chain_id(Some(chain_id)); - env.evm_env.cfg_env.chain_id = chain_id; - env.tx.base.chain_id = chain_id.into(); - chain_id - }; - let override_chain_id = self.chain_id; - // apply changes such as difficulty -> prevrandao and chain specifics for current chain id - apply_chain_and_block_specific_env_changes::(env.as_env_mut(), &block); - - let meta = BlockchainDbMeta::new(env.evm_env.block_env.clone(), eth_rpc_url.clone()); - let block_chain_db = if self.fork_chain_id.is_some() { - BlockchainDb::new_skip_check(meta, self.block_cache_path(fork_block_number)) + let source_id = fork_source_id(&self.fork_urls, &self.fork_headers); + let account_fetch_policy = account_fetch_policy_for_source(source_chain_id, target_profile); + let meta = BlockchainDbMeta::new(cache_block_env, eth_rpc_url.clone()) + .with_fork_identity(block_hash, source_id) + .with_account_fetch_policy(account_fetch_policy); + let cache_path = + self.block_cache_path_for_rpc(source_chain_id, fork_block_number, ð_rpc_url); + let block_chain_db = BlockchainDb::new(meta, cache_path); + + // After bootstrap, rebuild the provider with round-robin if multiple URLs are + // configured. This ensures bootstrap used only the primary endpoint for consistency, + // while ongoing requests are distributed across all endpoints. + let provider = if self.fork_urls.len() > 1 { + let urls = self.fork_urls.iter().map(|url| redact_url(url)).collect::>(); + debug!(target: "node", ?urls, "using multi-endpoint round-robin provider"); + Arc::new( + ProviderBuilder::new(ð_rpc_url) + .timeout(self.fork_request_timeout) + .initial_backoff(self.fork_retry_backoff.as_millis() as u64) + .compute_units_per_second(self.compute_units_per_second) + .max_retry(self.fork_request_retries) + .headers(self.fork_headers.clone()) + .build_fallback(self.fork_urls.clone()) + .wrap_err("failed to establish round-robin provider to fork urls")?, + ) } else { - BlockchainDb::new(meta, self.block_cache_path(fork_block_number)) + provider }; // This will spawn the background thread that will use the provider to fetch // blockchain data from the other client - let backend = SharedBackend::spawn_backend( - Arc::clone(&provider), - block_chain_db.clone(), - Some(fork_block_number.into()), - ) - .await; + let anchor = ForkBlock::with_rpc_number( + evm_env.block_env.number.saturating_to(), + fork_block_number, + block_hash, + ); + let (backend, handler) = if self.fork_state_by_number { + SharedBackend::new_with_anchor_by_number( + Arc::clone(&provider), + block_chain_db.clone(), + anchor, + )? + } else { + SharedBackend::new_with_anchor(Arc::clone(&provider), block_chain_db.clone(), anchor)? + }; + // The handler gets its own thread and runtime: `SharedBackend` reads block the calling + // thread via `block_in_place`, so under enough concurrent reads they can occupy every + // thread of the node's runtime and leave none to poll the handler they wait on. + std::thread::Builder::new().name("fork-backend".into()).spawn(move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("failed to build fork backend runtime") + .block_on(handler) + })?; let config = ClientForkConfig { - eth_rpc_url, + fork_urls: self.fork_urls.clone(), block_number: fork_block_number, + evm_block_number: evm_env.block_env.number.saturating_to(), block_hash, transaction_hash: self.fork_choice.and_then(|fc| fc.transaction_hash()), provider, - chain_id, + chain_id: source_chain_id, + execution_chain_id, override_chain_id, - timestamp: block.header.timestamp, - base_fee: block.header.base_fee_per_gas.map(|g| g as u128), + fork_chain_id: self.fork_chain_id.map(|chain_id| chain_id.to()), + hardfork: Some(effective_hardfork), + endpoint_identity: fork_identity, + state_is_mutable, + timestamp: block.header.timestamp(), + base_fee: block.header.base_fee_per_gas().map(|g| g as u128), timeout: self.fork_request_timeout, retries: self.fork_request_retries, backoff: self.fork_retry_backoff, compute_units_per_second: self.compute_units_per_second, + headers: self.fork_headers.clone(), total_difficulty: block.header.total_difficulty.unwrap_or_default(), - blob_gas_used: block.header.blob_gas_used.map(|g| g as u128), - blob_excess_gas_and_price: env.evm_env.block_env.blob_excess_gas_and_price, - force_transactions, + blob_gas_used: block.header.blob_gas_used().map(|g| g as u128), + blob_excess_gas_and_price: evm_env.block_env.blob_excess_gas_and_price, }; debug!(target: "node", fork_number=config.block_number, fork_hash=%config.block_hash, "set up fork db"); @@ -1367,27 +2326,37 @@ latest block number: {latest_block}" // need to insert the forked block's hash db.insert_block_hash(U256::from(config.block_number), config.block_hash); - Ok((db, config)) + Ok((db, config, fork_transaction_replay)) } /// we only use the gas limit value of the block if it is non-zero and the block gas /// limit is enabled, since there are networks where this is not used and is always /// `0x0` which would inevitably result in `OutOfGas` errors as soon as the evm is about to record gas, See also - pub(crate) fn fork_gas_limit( + fn fork_gas_limit_with_override>( &self, - block: &Block, + block: &B, + gas_limit: Option, ) -> u64 { if !self.disable_block_gas_limit { - if let Some(gas_limit) = self.gas_limit { + if let Some(gas_limit) = gas_limit { return gas_limit; - } else if block.header.gas_limit() > 0 { - return block.header.gas_limit(); + } else if block.header().gas_limit() > 0 { + return block.header().gas_limit(); } } u64::MAX } + /// Restores user-provided gas settings after leaving fork mode. + pub(crate) const fn restore_fork_overrides(&mut self) { + if let Some(overrides) = self.fork_overrides { + self.gas_limit = overrides.gas_limit; + self.gas_price = overrides.gas_price; + self.base_fee = overrides.base_fee; + } + } + /// Returns the gas limit for a non forked anvil instance /// /// Checks the config for the `disable_block_gas_limit` flag @@ -1400,14 +2369,18 @@ latest block number: {latest_block}" } } +pub(crate) const fn tempo_default_base_fee(hardfork: TempoHardfork) -> u64 { + if hardfork.is_t1() { TEMPO_T1_BASE_FEE } else { TEMPO_T0_BASE_FEE } +} + /// If the fork choice is a block number, simply return it with an empty list of transactions. /// If the fork choice is a transaction hash, determine the block that the transaction was mined in, /// and return the block number before the fork block along with all transactions in the fork block /// that are before (and including) the fork transaction. -async fn derive_block_and_transactions( +async fn derive_block_and_replay( fork_choice: &ForkChoice, provider: &Arc, -) -> eyre::Result<(BlockNumber, Option>)> { +) -> eyre::Result<(BlockNumber, Option)> { match fork_choice { ForkChoice::Block(block_number) => { let block_number = *block_number; @@ -1424,36 +2397,90 @@ async fn derive_block_and_transactions( let transaction = provider .get_transaction_by_hash(transaction_hash.0.into()) .await? - .ok_or_else(|| eyre::eyre!("failed to get fork transaction by hash"))?; - let transaction_block_number = transaction.block_number.unwrap(); - - // Get the block pertaining to the fork transaction - let transaction_block = provider - .get_block_by_number(transaction_block_number.into()) - .full() - .await? - .ok_or_else(|| eyre::eyre!("failed to get fork block by number"))?; - - // Filter out transactions that are after the fork transaction - let filtered_transactions = transaction_block - .transactions - .as_transactions() - .ok_or_else(|| eyre::eyre!("failed to get transactions from full fork block"))? - .iter() - .take_while_inclusive(|&transaction| transaction.tx_hash() != transaction_hash.0) - .collect::>(); - - // Convert the transactions to PoolTransactions - let force_transactions = filtered_transactions - .iter() - .map(|&transaction| PoolTransaction::try_from(transaction.clone())) - .collect::, _>>() - .map_err(|e| eyre::eyre!("Err converting to pool transactions {e}"))?; - Ok((transaction_block_number.saturating_sub(1), Some(force_transactions))) + .ok_or_else(|| eyre::eyre!("fork transaction {transaction_hash} was not found"))?; + let transaction_block_number = transaction.block_number().ok_or_else(|| { + eyre::eyre!("fork transaction {transaction_hash} is not mined (no block number)") + })?; + let transaction_block_hash = transaction.block_hash().ok_or_else(|| { + eyre::eyre!("fork transaction {transaction_hash} is not mined (no block hash)") + })?; + + // Get the block pertaining to the fork transaction. + let transaction_block = + provider.get_block_by_hash(transaction_block_hash).full().await?.ok_or_else( + || { + eyre::eyre!( + "failed to get fork block {transaction_block_hash} for transaction \ + {transaction_hash}" + ) + }, + )?; + let replay = validate_fork_transaction_replay( + *transaction_hash, + &transaction, + transaction_block, + )?; + Ok((transaction_block_number.saturating_sub(1), Some(replay))) } } } +fn validate_fork_transaction_replay( + transaction_hash: TxHash, + transaction: &alloy_network::AnyRpcTransaction, + source_block: AnyRpcBlock, +) -> eyre::Result { + let source_hash = source_block.header.hash; + let source_number = source_block.header.number; + let transaction_block_hash = transaction.block_hash().ok_or_else(|| { + eyre::eyre!("fork transaction {transaction_hash} is not mined (no block hash)") + })?; + let transaction_block_number = transaction.block_number().ok_or_else(|| { + eyre::eyre!("fork transaction {transaction_hash} is not mined (no block number)") + })?; + + eyre::ensure!( + source_hash == transaction_block_hash, + "fork transaction {transaction_hash} reports block {transaction_block_hash}, but fetched \ + block hash is {source_hash}" + ); + eyre::ensure!( + source_number == transaction_block_number, + "fork transaction {transaction_hash} reports block number {transaction_block_number}, but \ + fetched block {source_hash} has number {source_number}" + ); + eyre::ensure!( + source_number > 0, + "fork transaction {transaction_hash} is in genesis block {source_hash}, which has no parent" + ); + + let transactions = source_block.transactions.as_transactions().ok_or_else(|| { + eyre::eyre!("fork block {source_hash} at {source_number} did not include full transactions") + })?; + let mut matches = + transactions.iter().enumerate().filter(|(_, tx)| tx.tx_hash() == transaction_hash); + let target_index = matches.next().map(|(index, _)| index).ok_or_else(|| { + eyre::eyre!( + "fork transaction {transaction_hash} is absent from block {source_hash} at \ + {source_number}" + ) + })?; + eyre::ensure!( + matches.next().is_none(), + "fork transaction {transaction_hash} occurs more than once in block {source_hash} at \ + {source_number}" + ); + if let Some(reported_index) = transaction.transaction_index() { + eyre::ensure!( + reported_index == target_index as u64, + "fork transaction {transaction_hash} reports index {reported_index}, but occurs at \ + index {target_index} in block {source_hash}" + ); + } + + Ok(ForkTransactionReplay { source_block, target_index }) +} + /// Fork delimiter used to specify which block or transaction to fork from. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ForkChoice { @@ -1467,7 +2494,7 @@ pub enum ForkChoice { impl ForkChoice { /// Returns the block number to fork from - pub fn block_number(&self) -> Option { + pub const fn block_number(&self) -> Option { match self { Self::Block(block_number) => Some(*block_number), Self::Transaction(_) => None, @@ -1475,7 +2502,7 @@ impl ForkChoice { } /// Returns the transaction hash to fork from - pub fn transaction_hash(&self) -> Option { + pub const fn transaction_hash(&self) -> Option { match self { Self::Block(_) => None, Self::Transaction(transaction_hash) => Some(*transaction_hash), @@ -1505,17 +2532,28 @@ pub struct PruneStateHistoryConfig { impl PruneStateHistoryConfig { /// Returns `true` if writing state history is supported - pub fn is_state_history_supported(&self) -> bool { - !self.enabled || self.max_memory_history.is_some() + pub const fn is_state_history_supported(&self) -> bool { + if !self.enabled { + return true; + } + + match self.max_memory_history { + Some(limit) => limit > 0, + None => false, + } } /// Returns true if this setting was enabled. - pub fn is_config_enabled(&self) -> bool { + pub const fn is_config_enabled(&self) -> bool { self.enabled } pub fn from_args(val: Option>) -> Self { - val.map(|max_memory_history| Self { enabled: true, max_memory_history }).unwrap_or_default() + val.map(|max_memory_history| Self { + enabled: true, + max_memory_history: max_memory_history.filter(|limit| *limit > 0), + }) + .unwrap_or_default() } } @@ -1575,11 +2613,14 @@ impl AccountGenerator { // use the derivation path let derivation_path = self.get_derivation_path(); + foundry_common::wallet::validate_bip32_path(derivation_path).map_err(|e| eyre::eyre!(e))?; let mut wallets = Vec::with_capacity(self.amount); for idx in 0..self.amount { - let builder = - builder.clone().derivation_path(format!("{derivation_path}{idx}")).unwrap(); + let idx = u32::try_from(idx).map_err(|_| eyre::eyre!("account index overflows u32"))?; + let full_path = foundry_common::wallet::derive_key_path_checked(derivation_path, idx) + .map_err(|e| eyre::eyre!(e))?; + let builder = builder.clone().derivation_path(full_path)?; let wallet = builder.build()?.with_chain_id(Some(self.chain_id)); wallets.push(wallet) } @@ -1624,6 +2665,93 @@ async fn find_latest_fork_block>( #[cfg(test)] mod tests { use super::*; + use foundry_evm::{hardfork::EthereumHardfork, hardforks::latest_active_tempo_hardfork}; + + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + + #[cfg(feature = "base")] + use foundry_evm::hardforks::BaseUpgrade; + + #[cfg(feature = "optimism")] + use foundry_evm::hardfork::OpHardfork; + + #[cfg(not(feature = "base"))] + use alloy_chains::NamedChain; + + #[cfg(feature = "base")] + #[tokio::test(flavor = "multi_thread")] + async fn base_chain_inference_uses_native_base_forks() { + for chain_id in [8453u64, 84532] { + let (origin_api, origin) = crate::spawn( + NodeConfig::test() + .with_chain_id(Some(chain_id)) + .with_genesis_timestamp(Some(1_710_374_401u64)), + ) + .await; + assert!(origin_api.backend.is_base()); + assert_eq!(origin_api.backend.hardfork(), BaseUpgrade::Ecotone.into()); + + let anonymous_url = foundry_test_utils::rpc::spawn_rpc_proxy_rejecting_method_after( + origin.http_endpoint(), + "anvil_nodeInfo", + 0, + ) + .await; + for endpoint in [origin.http_endpoint(), anonymous_url] { + let (api, _handle) = + crate::spawn(NodeConfig::test().with_eth_rpc_url(Some(endpoint))).await; + assert!(api.backend.is_base()); + assert_eq!(api.backend.hardfork(), BaseUpgrade::Ecotone.into()); + let fork = api.backend.get_fork().unwrap(); + assert_eq!( + fork.config.read().endpoint_identity.network, + Some(NetworkVariant::Base) + ); + } + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn fork_output_redacts_endpoint_credentials() { + let (_api, source) = crate::spawn(NodeConfig::test()).await; + let fork_url = source.http_endpoint().replacen("http://", "http://user:password@", 1) + + "/?api_key=secret"; + let mut config = NodeConfig::test().with_eth_rpc_url(Some(fork_url.clone())); + let (api, _handle) = crate::spawn(config.clone()).await; + config.fork_urls.push("https://mirror.example/private-api-key?token=secret".to_string()); + + let fork = api.backend.get_fork().unwrap(); + let node_info = api.anvil_node_info().await.unwrap(); + let output = config.as_string(Some(&fork)); + let temp = tempfile::tempdir().unwrap(); + let config_out = temp.path().join("config.json"); + config.config_out = Some(config_out.clone()); + config.print(Some(&fork)).unwrap(); + let json = serde_json::from_slice::(&std::fs::read(config_out).unwrap()).unwrap(); + + assert!(output.contains(&redact_url(&fork_url))); + assert!(output.contains("https://mirror.example/")); + assert!(!output.contains("user")); + assert!(!output.contains("password")); + assert!(!output.contains("private-api-key")); + assert!(!output.contains("secret")); + assert_eq!(json["endpoint"], redact_url(&fork_url)); + assert_eq!(node_info.fork_config.fork_url, Some(redact_url(&fork_url))); + assert!(!json.to_string().contains("password")); + assert!(!json.to_string().contains("secret")); + } + + #[test] + fn fork_source_identity_includes_all_urls_and_headers() { + let urls = ["http://primary".to_string(), "http://fallback".to_string()]; + let headers = ["Authorization: secret".to_string()]; + let identity = fork_source_id(&urls, &headers); + + assert_ne!(identity, fork_source_id(&urls[..1], &headers)); + assert_ne!(identity, fork_source_id(&urls, &[])); + assert_ne!(identity, fork_source_id(&[urls[1].clone(), urls[0].clone()], &headers)); + } #[test] fn test_prune_history() { @@ -1631,7 +2759,267 @@ mod tests { assert!(config.is_state_history_supported()); let config = PruneStateHistoryConfig::from_args(Some(None)); assert!(!config.is_state_history_supported()); + let config = PruneStateHistoryConfig::from_args(Some(Some(0))); + assert!(config.is_config_enabled()); + assert!(!config.is_state_history_supported()); let config = PruneStateHistoryConfig::from_args(Some(Some(10))); assert!(config.is_state_history_supported()); } + + #[test] + fn fork_cache_path_can_use_source_chain() { + let rpc_url = "http://localhost:8545"; + let mut config = NodeConfig::test() + .with_eth_rpc_url(Some(rpc_url.to_string())) + .with_chain_id(Some(1u64)); + let block = 42; + config.fork_source_chain_id = Some(143); + let expected = Config::foundry_block_cache_file(143, block) + .map(|path| path.with_file_name(format!("storage-{:x}.json", keccak256(rpc_url)))); + + assert_eq!(config.block_cache_path(block), expected); + assert_ne!( + config.block_cache_path_for_rpc(143, block, rpc_url), + config.block_cache_path_for_rpc(143, block, "http://localhost:8546") + ); + } + + #[test] + fn fork_execution_and_source_chain_ids_remain_distinct() { + let mut config = NodeConfig::test(); + config.fork_execution_chain_id = Some(1); + config.fork_source_chain_id = Some(143); + + assert_eq!(config.get_chain_id(), 1); + assert_eq!(config.protocol_chain_id(), 143); + } + + #[test] + fn fork_chain_id_is_only_an_offline_discovery_hint() { + let mut config = NodeConfig::test() + .with_chain_id(Some(31_337u64)) + .with_fork_chain_id(Some(U256::from(143))); + + assert_eq!(config.protocol_chain_id(), 31_337); + + config.fork_source_chain_id = Some(143); + assert_eq!(config.protocol_chain_id(), 143); + + config.fork_source_chain_id = None; + assert_eq!(config.protocol_chain_id(), 31_337); + } + + #[test] + fn fork_endpoint_revalidation_requires_authority_or_fallbacks() { + let anonymous = ForkEndpointIdentity { + execution_chain_id: 1, + source_chain_id: 1, + network: Some(NetworkVariant::Ethereum), + network_profile: Some(NetworkConfigs::default()), + hardfork: None, + instance_id: None, + source_fork_block_number: None, + source_fork_block_hash: None, + }; + let mut config = + NodeConfig::test().with_eth_rpc_url(Some("http://localhost:8545".to_string())); + + assert!(!anonymous.is_authoritative()); + assert!(!config.requires_primary_fork_revalidation(anonymous)); + + config.fork_urls.push("http://localhost:8546".to_string()); + assert!(config.requires_primary_fork_revalidation(anonymous)); + assert_eq!( + NodeConfig::fork_urls_requiring_revalidation(&config.fork_urls, anonymous), + config.fork_urls + ); + + config.fork_urls.pop(); + let authoritative = + ForkEndpointIdentity { hardfork: Some(EthereumHardfork::Prague.into()), ..anonymous }; + assert!(authoritative.is_authoritative()); + assert!(config.requires_primary_fork_revalidation(authoritative)); + assert_eq!( + NodeConfig::fork_urls_requiring_revalidation(&config.fork_urls, authoritative), + config.fork_urls + ); + } + + #[tokio::test] + async fn fork_authoritative_identity_keeps_node_info_probe_strict() { + let (_api, origin) = + crate::spawn(NodeConfig::test().with_chain_id(Some(NamedChain::Mainnet as u64))).await; + let fork_url = foundry_test_utils::rpc::spawn_rpc_proxy_rejecting_method_after( + origin.http_endpoint(), + "anvil_nodeInfo", + 0, + ) + .await; + let expected = ForkEndpointIdentity { + execution_chain_id: NamedChain::Mainnet as u64, + source_chain_id: NamedChain::Mainnet as u64, + network: Some(NetworkVariant::Ethereum), + network_profile: Some(NetworkConfigs::default()), + hardfork: Some(EthereumHardfork::Prague.into()), + instance_id: Some(B256::with_last_byte(1)), + source_fork_block_number: None, + source_fork_block_hash: None, + }; + + let error = NodeConfig::test() + .fork_context_matches(&fork_url, expected, 0, B256::ZERO) + .await + .unwrap_err(); + + assert!( + error.to_string().contains("failed to determine network family from fork endpoint"), + "{error}" + ); + } + + #[cfg(feature = "optimism")] + #[test] + fn set_chain_id_updates_network_config() { + let mut config = NodeConfig::test(); + config.set_chain_id(Some(10u64)); + + assert!(config.networks.is_optimism()); + } + + #[test] + fn chain_id_network_inference_is_replaceable_and_clearable() { + let mut config = NodeConfig::test(); + config.set_chain_id(Some(4217u64)); + assert!(config.networks.is_tempo()); + + config.set_chain_id(Some(NamedChain::Celo as u64)); + assert!(config.networks.is_celo()); + assert!(!config.networks.is_tempo()); + + config.set_chain_id(Some(1u64)); + assert!(!config.networks.has_network_selection()); + + config.set_chain_id(Some(4217u64)); + config.set_chain_id(None::); + assert!(!config.networks.has_network_selection()); + } + + #[test] + fn chain_id_preserves_explicit_network_selection() { + let mut config = NodeConfig::test_tempo(); + config.set_chain_id(Some(NamedChain::Celo as u64)); + + assert!(config.networks.is_tempo()); + assert!(!config.networks.is_celo()); + } + + #[test] + fn get_hardfork_on_tempo_never_returns_non_tempo_variant() { + // Post-Shanghai timestamp on Ethereum mainnet. + let shanghai_ts = 1_681_338_455u64; + + let config = NodeConfig::test_tempo() + .with_chain_id(Some(1u64)) + .with_genesis_timestamp(Some(shanghai_ts)); + + assert!(config.networks.is_tempo()); + assert!(matches!(config.get_hardfork(), FoundryHardfork::Tempo(_))); + } + + #[test] + fn get_hardfork_on_ethereum_uses_genesis_timestamp() { + let timestamp = EthereumHardfork::Shanghai.mainnet_activation_timestamp().unwrap(); + let config = + NodeConfig::test().with_chain_id(Some(1u64)).with_genesis_timestamp(Some(timestamp)); + + assert_eq!(config.get_hardfork(), FoundryHardfork::Ethereum(EthereumHardfork::Shanghai)); + } + + #[test] + #[cfg(feature = "optimism")] + fn get_hardfork_on_optimism_uses_genesis_timestamp() { + // OP Mainnet Canyon activation timestamp. + let timestamp = 1_704_992_401u64; + let config = NodeConfig::test() + .with_optimism() + .with_chain_id(Some(10u64)) + .with_genesis_timestamp(Some(timestamp)); + + assert_eq!(config.get_hardfork(), FoundryHardfork::Optimism(OpHardfork::Canyon)); + } + + #[test] + fn get_hardfork_on_local_tempo_defaults_to_latest_active() { + let config = NodeConfig::test_tempo(); + + assert_eq!(config.get_hardfork(), FoundryHardfork::Tempo(latest_active_tempo_hardfork())); + } + + #[test] + #[cfg(feature = "base")] + fn test_base_config_uses_base_network_and_chain_id() { + let config = NodeConfig::test_base(); + + assert!(config.networks.is_base()); + assert_eq!(config.get_chain_id(), NamedChain::Base as u64); + } + + #[test] + #[cfg(feature = "base")] + fn get_hardfork_on_base_fork_uses_source_chain_timestamp_mapping() { + let mut config = NodeConfig::test_base() + .with_chain_id(Some(1u64)) + .with_genesis_timestamp(Some(u64::MAX)); + config.fork_source_chain_id = Some(NamedChain::Base as u64); + + assert_eq!(config.get_chain_id(), 1); + assert_eq!(config.get_hardfork(), FoundryHardfork::Base(BaseUpgrade::Beryl)); + } + + #[test] + #[cfg(feature = "monad")] + fn get_hardfork_on_monad_fork_uses_source_chain_timestamp_mapping() { + let mut config = NodeConfig::test_monad() + .with_chain_id(Some(1u64)) + .with_genesis_timestamp(Some(1_763_648_999u64)); + config.fork_source_chain_id = Some(143); + + assert_eq!(config.get_chain_id(), 1); + assert_eq!( + config.get_hardfork(), + FoundryHardfork::Monad(foundry_evm::hardfork::MonadHardfork::MonadEight) + ); + } + + #[test] + fn account_generator_rejects_harden_bit_overflow_path() { + let err = AccountGenerator::new(1) + .phrase("test test test test test test test test test test test junk") + .derivation_path("m/44'/60'/0'/0/2147483648'") + .generate() + .unwrap_err() + .to_string(); + assert!(err.contains("harden bit"), "{err}"); + + assert!( + AccountGenerator::new(1) + .phrase("test test test test test test test test test test test junk") + .derivation_path("m/44'/60'/0'/0") + .generate() + .is_ok() + ); + } + + #[cfg(unix)] + #[test] + fn config_out_has_owner_only_permissions() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.json"); + let config = NodeConfig::test().set_config_out(Some(path.clone())); + + config.print(None).unwrap(); + + let mode = std::fs::metadata(path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + } } diff --git a/crates/anvil/src/server/error.rs b/crates/anvil/src/error.rs similarity index 100% rename from crates/anvil/src/server/error.rs rename to crates/anvil/src/error.rs diff --git a/crates/anvil/src/eth/api.rs b/crates/anvil/src/eth/api.rs index 74b04c1cd33d2..002b2a3e4ae91 100644 --- a/crates/anvil/src/eth/api.rs +++ b/crates/anvil/src/eth/api.rs @@ -1,119 +1,163 @@ use super::{ - backend::mem::{BlockRequest, DatabaseRef, State, state}, - sign::build_typed_transaction, + backend::mem::{ + BlockRequest, DatabaseRef, GasEstimateCallOptions, MonadReplayContext, State, + sanitize_simulation_blocks, + }, + preserve_simulation_request_fields, }; use crate::{ ClientFork, LoggingManager, Miner, MiningMode, StorageInfo, eth::{ backend::{ - self, - db::SerializableState, - mem::{MIN_CREATE_GAS, MIN_TRANSACTION_GAS}, - notifications::NewBlockNotifications, - validate::TransactionValidator, + self, db::SerializableState, mem::MIN_TRANSACTION_GAS, + notifications::ChainNotifications, validate::TransactionValidator, }, error::{ BlockchainError, FeeHistoryError, InvalidTransactionError, Result, ToRpcResponseResult, }, - fees::{FeeDetails, FeeHistoryCache, MIN_SUGGESTED_PRIORITY_FEE}, + fees::{ + FeeDetails, FeeHistoryCache, FeeHistoryCacheItem, MIN_SUGGESTED_PRIORITY_FEE, + REWARD_PERCENTILE_RESOLUTION, create_fee_history_cache_item, + }, macros::node_info, miner::FixedBlockTimeMiner, pool::{ - Pool, + Pool, PoolSnapshot, transactions::{ PoolTransaction, TransactionOrder, TransactionPriority, TxMarker, to_marker, }, }, - sign::{self, Signer}, + sign::Signer, }, filter::{EthFilter, Filters, LogsFilter}, mem::transaction_build, }; use alloy_consensus::{ - Account, Blob, - transaction::{Recovered, eip4844::TxEip4844Variant}, + Blob, BlockHeader, Transaction, TrieAccount, TxEip4844Variant, TxReceipt, Typed2718, + transaction::{Recovered, SignerRecoverable}, }; use alloy_dyn_abi::TypedData; -use alloy_eips::eip2718::Encodable2718; +use alloy_eips::{ + eip2718::{EIP4844_TX_TYPE_ID, Encodable2718}, + eip4844::DATA_GAS_PER_BLOB, + eip7910::{EthConfig, EthForkConfig}, +}; +use alloy_evm::overrides::{OverrideBlockHashes, apply_state_overrides}; use alloy_network::{ - AnyRpcBlock, AnyRpcTransaction, BlockResponse, Ethereum, NetworkWallet, TransactionBuilder, + AnyRpcBlock, AnyRpcHeader, AnyRpcTransaction, BlockResponse, Network, + NetworkTransactionBuilder, ReceiptResponse, TransactionBuilder, TransactionBuilder4844, TransactionResponse, eip2718::Decodable2718, }; use alloy_primitives::{ - Address, B64, B256, Bytes, Signature, TxHash, TxKind, U64, U256, - map::{HashMap, HashSet}, -}; -use alloy_provider::utils::{ - EIP1559_FEE_ESTIMATION_PAST_BLOCKS, EIP1559_FEE_ESTIMATION_REWARD_PERCENTILE, - eip1559_default_estimator, + Address, B64, B256, Bytes, TxHash, TxKind, U64, U256, + map::{AddressSet, B256Set, HashMap, HashSet}, }; +use alloy_rlp::{Encodable, Header, PayloadView}; use alloy_rpc_types::{ - AccessList, AccessListResult, BlockId, BlockNumberOrTag as BlockNumber, BlockTransactions, + AccessListResult, BlockId, BlockNumberOrTag as BlockNumber, BlockTransactions, EIP1186AccountProofResponse, FeeHistory, Filter, FilteredParams, Index, Log, Work, anvil::{ ForkedNetwork, Forking, Metadata, MineOptions, NodeEnvironment, NodeForkConfig, NodeInfo, }, + debug::ExecutionWitness, + erc4337::TransactionConditional, + pubsub::TransactionReceiptsParams, request::TransactionRequest, - simulate::{SimulatePayload, SimulatedBlock}, - state::{AccountOverride, EvmOverrides, StateOverridesBuilder}, + simulate::{MAX_SIMULATE_BLOCKS, SimulatePayload, SimulatedBlock}, + state::{AccountOverride, EvmOverrides, StateOverride, StateOverridesBuilder}, trace::{ filter::TraceFilter, - geth::{GethDebugTracingCallOptions, GethDebugTracingOptions, GethTrace}, - parity::LocalizedTransactionTrace, + geth::{GethDebugTracingCallOptions, GethDebugTracingOptions, GethTrace, TraceResult}, + opcode::{BlockOpcodeGas, TransactionOpcodeGas}, + parity::{ + LocalizedTransactionTrace, TraceResults, TraceResultsWithTransactionHash, TraceType, + }, }, - txpool::{TxpoolContent, TxpoolInspect, TxpoolInspectSummary, TxpoolStatus}, + txpool::{TxpoolContent, TxpoolContentFrom, TxpoolInspect, TxpoolInspectSummary, TxpoolStatus}, }; +use alloy_rpc_types_eth::{AccountInfo, Bundle, EthCallResponse, FillTransaction, StateContext}; +use alloy_rpc_types_mev::{EthCallBundle, EthCallBundleResponse}; use alloy_serde::WithOtherFields; use alloy_sol_types::{SolCall, SolValue, sol}; -use alloy_transport::TransportErrorKind; use anvil_core::{ eth::{ EthRequest, - block::BlockInfo, - transaction::{ - PendingTransaction, ReceiptResponse, TypedTransaction, TypedTransactionRequest, - transaction_request_to_typed, - }, - wallet::{WalletCapabilities, WalletError}, + block::{Block, BlockInfo, canonical_block}, + transaction::{MaybeImpersonatedTransaction, PendingTransaction}, }, types::{ReorgOptions, TransactionData}, }; -use anvil_rpc::{error::RpcError, response::ResponseResult}; -use foundry_common::provider::ProviderBuilder; +use anvil_rpc::{ + error::{ErrorCode, RpcError}, + response::ResponseResult, +}; +use foundry_common::{ + provider::redact_url, + tempo::{PaymentLaneClassification, PaymentLaneReason, classify_payment_lane}, + version::{COMMIT_SHA, SEMVER_VERSION}, +}; use foundry_evm::decode::RevertDecoder; +use foundry_primitives::{ + FoundryNetwork, FoundryReceiptEnvelope, FoundryTransactionRequest, FoundryTxEnvelope, + FoundryTxReceipt, FoundryTypedTx, +}; use futures::{ - StreamExt, + StreamExt, TryFutureExt, channel::{mpsc::Receiver, oneshot}, }; -use parking_lot::RwLock; +use parking_lot::{Mutex, RwLock}; use revm::{ - bytecode::Bytecode, context::BlockEnv, - context_interface::{block::BlobExcessGasAndPrice, result::Output}, + context_interface::{ + block::BlobExcessGasAndPrice, + result::{HaltReason, Output}, + }, database::CacheDB, - interpreter::{InstructionResult, return_ok, return_revert}, - primitives::eip7702::PER_EMPTY_ACCOUNT_COST, + interpreter::{InstructionResult, SuccessOrHalt, return_ok, return_revert}, }; use std::{sync::Arc, time::Duration}; -use tokio::sync::mpsc::{UnboundedReceiver, unbounded_channel}; +use tempo_hardfork::TempoHardfork; +use tempo_primitives::{AASigned, TEMPO_TX_TYPE_ID, transaction::FEE_PAYER_SIGNATURE_MARKER}; +use tokio::{ + sync::mpsc::{self, UnboundedReceiver, unbounded_channel}, + try_join, +}; + +#[cfg(feature = "base")] +use base_common_consensus::Eip8130Constants; +#[cfg(feature = "base")] +use base_common_evm::EIP8130_TRANSACTION_TYPE; +#[cfg(feature = "base")] +use base_common_precompiles::NonceManagerStorage; +#[cfg(feature = "base")] +use base_common_rpc_types::{BaseTransactionRequest, Eip8130Nonce}; +#[cfg(feature = "base")] +use base_execution_eip8130::{FeeCheck, IntrinsicGas}; /// The client version: `anvil/v{major}.{minor}.{patch}` pub const CLIENT_VERSION: &str = concat!("anvil/v", env!("CARGO_PKG_VERSION")); +#[derive(Clone, Copy)] +struct TransactionFeeDefaults { + gas_price: u128, + blob_gas_price: u128, +} + /// The entry point for executing eth api RPC call - The Eth RPC interface. /// /// This type is cheap to clone and can be used concurrently -#[derive(Clone)] -pub struct EthApi { +pub struct EthApi { /// The transaction pool - pool: Arc, + pool: Arc>, + /// Transaction pool state captured by `evm_snapshot`. + pool_snapshots: Arc>>>, /// Holds all blockchain related data /// In-Memory only for now - pub backend: Arc, + pub backend: Arc>, /// Whether this node is mining is_mining: bool, /// available signers - signers: Arc>>, + signers: Arc>>>, /// data required for `eth_feeHistory` fee_history_cache: FeeHistoryCache, /// max number of items kept in fee cache @@ -122,35 +166,67 @@ pub struct EthApi { /// /// This access is required in order to adjust miner settings based on requests received from /// custom RPC endpoints - miner: Miner, + miner: Miner, /// allows to enabled/disable logging logger: LoggingManager, /// Tracks all active filters - filters: Filters, + filters: Filters, /// How transactions are ordered in the pool transaction_order: Arc>, /// Whether we're listening for RPC calls net_listening: bool, /// The instance ID. Changes on every reset. instance_id: Arc>, + /// Serializes endpoint identity reads with reset transitions. + lifecycle_lock: Arc>, + /// Serializes reset preparation without blocking identity RPCs made by the target endpoint. + reset_lock: Arc>, + /// Serializes automatic nonce assignment through pool insertion for each sender. + nonce_locks: Arc>>>>, +} + +impl Clone for EthApi { + fn clone(&self) -> Self { + Self { + pool: self.pool.clone(), + pool_snapshots: self.pool_snapshots.clone(), + backend: self.backend.clone(), + is_mining: self.is_mining, + signers: self.signers.clone(), + fee_history_cache: self.fee_history_cache.clone(), + fee_history_limit: self.fee_history_limit, + miner: self.miner.clone(), + logger: self.logger.clone(), + filters: self.filters.clone(), + transaction_order: self.transaction_order.clone(), + net_listening: self.net_listening, + instance_id: self.instance_id.clone(), + lifecycle_lock: self.lifecycle_lock.clone(), + reset_lock: self.reset_lock.clone(), + nonce_locks: self.nonce_locks.clone(), + } + } } -impl EthApi { +// == impl EthApi generic methods == + +impl EthApi { /// Creates a new instance #[expect(clippy::too_many_arguments)] pub fn new( - pool: Arc, - backend: Arc, - signers: Arc>>, + pool: Arc>, + backend: Arc>, + signers: Arc>>>, fee_history_cache: FeeHistoryCache, fee_history_limit: u64, - miner: Miner, + miner: Miner, logger: LoggingManager, - filters: Filters, + filters: Filters, transactions_order: TransactionOrder, ) -> Self { Self { pool, + pool_snapshots: Arc::new(Mutex::new(HashMap::default())), backend, is_mining: true, signers, @@ -162,13 +238,1889 @@ impl EthApi { net_listening: true, transaction_order: Arc::new(RwLock::new(transactions_order)), instance_id: Arc::new(RwLock::new(B256::random())), + lifecycle_lock: Arc::new(tokio::sync::RwLock::new(())), + reset_lock: Arc::new(tokio::sync::Mutex::new(())), + nonce_locks: Default::default(), + } + } + + /// Returns the current gas price + pub fn gas_price(&self) -> u128 { + if self.backend.is_eip1559() { + if self.backend.is_min_priority_fee_enforced() { + (self.backend.base_fee() as u128).saturating_add(self.lowest_suggestion_tip()) + } else { + self.backend.base_fee() as u128 + } + } else { + self.backend.fees().raw_gas_price() + } + } + + /// Returns the suggested fee cap. + /// + /// Returns at least [MIN_SUGGESTED_PRIORITY_FEE] + fn lowest_suggestion_tip(&self) -> u128 { + let block_number = self.backend.best_number(); + let cache = self.fee_history_cache.lock(); + let latest_tip = self + .backend + .get_block_with_hash(block_number) + .and_then(|(_, hash)| cache.get(&block_number).filter(|item| item.block_hash == hash)) + .and_then(|item| item.rewards.iter().copied().min()); + + latest_tip + .or_else(|| { + cache + .iter() + .filter(|(number, item)| { + self.backend + .get_block_with_hash(**number) + .is_some_and(|(_, hash)| item.block_hash == hash) + }) + .flat_map(|(_, item)| item.rewards.iter().copied()) + .min() + }) + .map(|fee| fee.max(MIN_SUGGESTED_PRIORITY_FEE)) + .unwrap_or(MIN_SUGGESTED_PRIORITY_FEE) + } + + /// Returns true if auto mining is enabled, and false. + /// + /// Handler for ETH RPC call: `anvil_getAutomine` + pub fn anvil_get_auto_mine(&self) -> Result { + node_info!("anvil_getAutomine"); + Ok(self.miner.is_auto_mine()) + } + + /// Returns the value of mining interval, if set. + /// + /// Handler for ETH RPC call: `anvil_getIntervalMining`. + pub fn anvil_get_interval_mining(&self) -> Result> { + node_info!("anvil_getIntervalMining"); + Ok(self.miner.get_interval()) + } + + /// Enables or disables, based on the single boolean argument, the automatic mining of new + /// blocks with each new transaction submitted to the network. + /// + /// Handler for ETH RPC call: `evm_setAutomine` + pub async fn anvil_set_auto_mine(&self, enable_automine: bool) -> Result<()> { + node_info!("evm_setAutomine"); + if self.miner.is_auto_mine() { + if enable_automine { + return Ok(()); + } + self.miner.set_mining_mode(MiningMode::None); + } else if enable_automine { + let listener = self.pool.add_ready_listener(); + let window = self.backend.node_config.read().await.transaction_coalescing_window; + let mode = MiningMode::instant(1_000, listener).with_coalescing_window(window); + self.miner.set_mining_mode(mode); + } + Ok(()) + } + + /// Sets the mining behavior to interval with the given interval (seconds) + /// + /// Handler for ETH RPC call: `evm_setIntervalMining` + pub fn anvil_set_interval_mining(&self, secs: u64) -> Result<()> { + node_info!("evm_setIntervalMining"); + let mining_mode = if secs == 0 { + MiningMode::None + } else { + let block_time = Duration::from_secs(secs); + + // This ensures that memory limits are stricter in interval-mine mode + self.backend.update_interval_mine_block_time(block_time); + + MiningMode::FixedBlockTime(FixedBlockTimeMiner::new(block_time)) + }; + self.miner.set_mining_mode(mining_mode); + Ok(()) + } + + /// Removes transactions from the pool + /// + /// Handler for RPC call: `anvil_dropTransaction` + pub async fn anvil_drop_transaction(&self, tx_hash: B256) -> Result> { + node_info!("anvil_dropTransaction"); + Ok(self.pool.drop_transaction(tx_hash).map(|tx| tx.hash())) + } + + /// Removes all transactions from the pool + /// + /// Handler for RPC call: `anvil_dropAllTransactions` + pub async fn anvil_drop_all_transactions(&self) -> Result<()> { + node_info!("anvil_dropAllTransactions"); + self.pool.clear(); + Ok(()) + } + + /// Clears all transactions from the pool. + /// + /// Handler for RPC call: `debug_clearTxpool` + pub async fn debug_clear_txpool(&self) -> Result<()> { + node_info!("debug_clearTxpool"); + self.pool.clear(); + Ok(()) + } + + pub async fn anvil_set_chain_id(&self, chain_id: u64) -> Result<()> { + node_info!("anvil_setChainId"); + self.backend.set_chain_id(chain_id); + #[cfg(feature = "base")] + self.invalidate_base_eip8130_pool(); + Ok(()) + } + + /// Invalidates EIP-8130 admission state after out-of-band state mutation. + #[cfg(feature = "base")] + fn invalidate_base_eip8130_pool(&self) { + if self.backend.is_base() { + let _ = self.pool.clear_transaction_type(EIP8130_TRANSACTION_TYPE); + } + } + + /// Modifies the balance of an account. + /// + /// Handler for RPC call: `anvil_setBalance` + pub async fn anvil_set_balance(&self, address: Address, balance: U256) -> Result<()> { + node_info!("anvil_setBalance"); + self.backend.set_balance(address, balance).await?; + #[cfg(feature = "base")] + self.invalidate_base_eip8130_pool(); + Ok(()) + } + + /// Sets the code of a contract. + /// + /// Handler for RPC call: `anvil_setCode` + pub async fn anvil_set_code(&self, address: Address, code: Bytes) -> Result<()> { + node_info!("anvil_setCode"); + self.backend.set_code(address, code).await?; + #[cfg(feature = "base")] + self.invalidate_base_eip8130_pool(); + Ok(()) + } + + /// Sets the nonce of an address. + /// + /// Handler for RPC call: `anvil_setNonce` + pub async fn anvil_set_nonce(&self, address: Address, nonce: U256) -> Result<()> { + node_info!("anvil_setNonce"); + self.backend.set_nonce(address, nonce).await?; + #[cfg(feature = "base")] + self.invalidate_base_eip8130_pool(); + Ok(()) + } + + /// Writes a single slot of the account's storage. + /// + /// Handler for RPC call: `anvil_setStorageAt` + pub async fn anvil_set_storage_at( + &self, + address: Address, + slot: U256, + val: B256, + ) -> Result { + node_info!("anvil_setStorageAt"); + self.backend.set_storage_at(address, slot, val).await?; + #[cfg(feature = "base")] + self.invalidate_base_eip8130_pool(); + Ok(true) + } + + /// Enable or disable logging. + /// + /// Handler for RPC call: `anvil_setLoggingEnabled` + pub async fn anvil_set_logging(&self, enable: bool) -> Result<()> { + node_info!("anvil_setLoggingEnabled"); + self.logger.set_enabled(enable); + Ok(()) + } + + /// Set the minimum gas price for the node. + /// + /// Handler for RPC call: `anvil_setMinGasPrice` + pub async fn anvil_set_min_gas_price(&self, gas: U256) -> Result<()> { + node_info!("anvil_setMinGasPrice"); + if self.backend.is_eip1559() { + return Err(RpcError::invalid_params( + "anvil_setMinGasPrice is not supported when EIP-1559 is active", + ) + .into()); + } + self.backend.set_gas_price(gas.saturating_to()); + Ok(()) + } + + /// Sets the base fee of the next block. + /// + /// Handler for RPC call: `anvil_setNextBlockBaseFeePerGas` + pub async fn anvil_set_next_block_base_fee_per_gas(&self, basefee: U256) -> Result<()> { + node_info!("anvil_setNextBlockBaseFeePerGas"); + if !self.backend.is_eip1559() { + return Err(RpcError::invalid_params( + "anvil_setNextBlockBaseFeePerGas is only supported when EIP-1559 is active", + ) + .into()); + } + self.backend.set_base_fee(basefee.saturating_to()); + Ok(()) + } + + /// Sets the coinbase address. + /// + /// Handler for RPC call: `anvil_setCoinbase` + pub async fn anvil_set_coinbase(&self, address: Address) -> Result<()> { + node_info!("anvil_setCoinbase"); + self.backend.set_coinbase(address); + Ok(()) + } + + /// Sets the `prevrandao` value of the next block. + /// + /// This is a one-shot override: it applies to the next mined block only, after which anvil + /// resumes deriving `prevrandao` from the parent hash and block number. + /// + /// Handler for RPC call: `anvil_setNextBlockPrevRandao` + pub async fn anvil_set_next_block_prevrandao(&self, prevrandao: B256) -> Result<()> { + node_info!("anvil_setNextBlockPrevRandao"); + self.backend.set_next_block_prevrandao(prevrandao); + Ok(()) + } + + /// Sets the parent beacon block root of the next block. + /// + /// This is a one-shot override: it applies to the next mined block only, after which anvil + /// resumes using the zero root. From Cancun the root is stored by the EIP-4788 beacon roots + /// contract when the block is mined. + /// + /// Handler for RPC call: `anvil_setNextBlockParentBeaconBlockRoot` + pub async fn anvil_set_next_block_parent_beacon_block_root(&self, root: B256) -> Result<()> { + node_info!("anvil_setNextBlockParentBeaconBlockRoot"); + self.backend.set_next_block_parent_beacon_block_root(root); + Ok(()) + } + + /// Retrieves the Anvil node configuration params. + /// + /// Handler for RPC call: `anvil_nodeInfo` + pub async fn anvil_node_info(&self) -> Result { + node_info!("anvil_nodeInfo"); + let _lifecycle = self.lifecycle_lock.read().await; + + let evm_env = self.backend.evm_env().read().clone(); + let fork_config = self.backend.get_fork(); + let tx_order = self.transaction_order.read(); + let hard_fork = self.backend.hardfork().name(); + + Ok(NodeInfo { + current_block_number: self.backend.best_number(), + current_block_timestamp: evm_env.block_env.timestamp.saturating_to(), + current_block_hash: self.backend.best_hash(), + hard_fork, + transaction_order: match *tx_order { + TransactionOrder::Fifo => "fifo".to_string(), + TransactionOrder::Fees => "fees".to_string(), + }, + environment: NodeEnvironment { + base_fee: self.backend.base_fee() as u128, + chain_id: self.backend.chain_id().to::(), + gas_limit: self.backend.gas_limit(), + gas_price: self.gas_price(), + }, + fork_config: fork_config + .map(|fork| { + let config = fork.config.read(); + + NodeForkConfig { + fork_url: config.eth_rpc_url().map(redact_url), + fork_block_number: Some(config.block_number), + fork_retry_backoff: Some(config.backoff.as_millis()), + } + }) + .unwrap_or_default(), + // Always emit the execution profile. Older Anvil versions omitted plain Ethereum, so + // consumers must still treat `None` as legacy unspecified metadata. + network: Some(self.backend.execution_profile_name().to_string()), + }) + } + + /// Retrieves metadata about the Anvil instance. + /// + /// Handler for RPC call: `anvil_metadata` + pub async fn anvil_metadata(&self) -> Result { + node_info!("anvil_metadata"); + let _lifecycle = self.lifecycle_lock.read().await; + let fork_config = self.backend.get_fork(); + + Ok(Metadata { + client_version: CLIENT_VERSION.to_string(), + client_semver: Some(SEMVER_VERSION.to_string()), + client_commit_sha: Some(COMMIT_SHA.to_string()), + chain_id: self.backend.chain_id().to::(), + latest_block_hash: self.backend.best_hash(), + latest_block_number: self.backend.best_number(), + instance_id: *self.instance_id.read(), + forked_network: fork_config.map(|cfg| ForkedNetwork { + chain_id: cfg.chain_id(), + fork_block_number: cfg.block_number(), + fork_block_hash: cfg.block_hash(), + }), + snapshots: self.backend.list_state_snapshots(), + }) + } + + pub async fn anvil_remove_pool_transactions(&self, address: Address) -> Result<()> { + node_info!("anvil_removePoolTransactions"); + self.pool.remove_transactions_by_address(address); + Ok(()) + } + + /// Snapshot the state of the blockchain at the current block. + /// + /// Handler for RPC call: `evm_snapshot` + pub async fn evm_snapshot(&self) -> Result { + node_info!("evm_snapshot"); + let _lifecycle = self.lifecycle_lock.read().await; + let _mining = self.backend.lock_mining().await; + let pool = self.pool.snapshot(); + let id = self.backend.create_state_snapshot().await; + self.pool_snapshots.lock().insert(id, pool); + Ok(id) + } + + /// Jump forward in time by the given amount of time, in seconds. + /// + /// Handler for RPC call: `evm_increaseTime` + pub async fn evm_increase_time(&self, seconds: U256) -> Result { + node_info!("evm_increaseTime"); + Ok(self.backend.time().increase_time(seconds.try_into().unwrap_or(u64::MAX)) as i64) + } + + /// Similar to `evm_increaseTime` but takes the exact timestamp that you want in the next block + /// + /// Handler for RPC call: `evm_setNextBlockTimestamp` + pub fn evm_set_next_block_timestamp(&self, seconds: u64) -> Result<()> { + node_info!("evm_setNextBlockTimestamp"); + self.backend.time().set_next_block_timestamp(seconds) + } + + /// Sets the specific timestamp and returns the number of seconds between the given timestamp + /// and the current time. + /// + /// The `timestamp` is in seconds. The `evm_setTime` JSON-RPC method accepts both seconds and + /// milliseconds (values above 1e12 are treated as milliseconds); the RPC handler normalises + /// the input to seconds before calling this function. + /// + /// Handler for RPC call: `evm_setTime` + pub fn evm_set_time(&self, timestamp: u64) -> Result { + node_info!("evm_setTime"); + let now = self.backend.time().current_wall_timestamp(); + self.backend.time().set_time(timestamp); + + // number of seconds between the given timestamp and the current time. + let offset = timestamp.saturating_sub(now); + Ok(offset) + } + + /// Set the next block gas limit + /// + /// Handler for RPC call: `evm_setBlockGasLimit` + pub fn evm_set_block_gas_limit(&self, gas_limit: U256) -> Result { + node_info!("evm_setBlockGasLimit"); + self.backend.set_gas_limit(gas_limit.saturating_to()); + Ok(true) + } + + /// Sets an interval for the block timestamp + /// + /// Handler for RPC call: `anvil_setBlockTimestampInterval` + pub fn evm_set_block_timestamp_interval(&self, seconds: u64) -> Result<()> { + node_info!("anvil_setBlockTimestampInterval"); + self.backend.time().set_block_timestamp_interval(seconds); + Ok(()) + } + + /// Sets an interval for the block timestamp + /// + /// Handler for RPC call: `anvil_removeBlockTimestampInterval` + pub fn evm_remove_block_timestamp_interval(&self) -> Result { + node_info!("anvil_removeBlockTimestampInterval"); + Ok(self.backend.time().remove_block_timestamp_interval()) + } + + /// Sets the backend rpc url + /// + /// Handler for ETH RPC call: `anvil_setRpcUrl` + pub async fn anvil_set_rpc_url(&self, url: String) -> Result<()> { + node_info!("anvil_setRpcUrl"); + let _reset = self.reset_lock.lock().await; + let staged_fork = if let Some(fork) = self.backend.get_fork() { + let mut validation_config = self.backend.node_config.read().await.clone(); + let (expected_identity, block_number, block_hash) = { + let config = fork.config.read(); + (config.endpoint_identity, config.block_number, config.block_hash) + }; + // A new URL replaces an offline discovery hint. Resolve the actual source identity so + // unsupported networks cannot be hidden behind the previous endpoint's hint. + validation_config.fork_chain_id = None; + let (provider, endpoint_identity) = validation_config + .replacement_fork_provider( + &url, + expected_identity, + block_number, + block_hash, + self.instance_id(), + ) + .await?; + Some((fork, provider, endpoint_identity)) + } else { + None + }; + + let _lifecycle = self.lifecycle_lock.write().await; + let _mining = self.backend.lock_mining().await; + let mut node_config = self.backend.node_config.write().await; + if let Some((fork, provider, endpoint_identity)) = staged_fork { + let mut config = fork.config.write(); + trace!(target: "backend", "Updated fork rpc from \"{}\" to \"{}\"", config.eth_rpc_url().map(redact_url).unwrap_or_else(|| "none".to_string()), redact_url(&url)); + config.provider = provider; + config.fork_urls = vec![url.clone()]; + config.fork_chain_id = None; + config.endpoint_identity = endpoint_identity; + node_config.fork_endpoint_is_anvil = endpoint_identity.is_authoritative(); + } + // Keep node_config in sync so a subsequent URL-less fork reset uses the updated endpoint. + node_config.fork_urls = vec![url]; + node_config.fork_chain_id = None; + Ok(()) + } + + /// Returns the number of transactions currently pending for inclusion in the next block(s), as + /// well as the ones that are being scheduled for future execution only. + /// Ref: [Here](https://geth.ethereum.org/docs/rpc/ns-txpool#txpool_status) + /// + /// Handler for ETH RPC call: `txpool_status` + pub async fn txpool_status(&self) -> Result { + node_info!("txpool_status"); + Ok(self.pool.txpool_status()) + } + + /// Executes the future on a new blocking task. + async fn on_blocking_task(&self, c: C) -> Result + where + C: FnOnce(Self) -> F, + F: Future> + Send + 'static, + R: Send + 'static, + { + let (tx, rx) = oneshot::channel(); + let this = self.clone(); + let f = c(this); + tokio::task::spawn_blocking(move || { + tokio::runtime::Handle::current().block_on(async move { + let res = f.await; + let _ = tx.send(res); + }) + }); + rx.await.map_err(|_| BlockchainError::Internal("blocking task panicked".to_string()))? + } + + /// Updates the `TransactionOrder` + pub fn set_transaction_order(&self, order: TransactionOrder) { + *self.transaction_order.write() = order; + } + + /// Returns the chain ID used for transaction + pub fn chain_id(&self) -> u64 { + self.backend.chain_id().to::() + } + + /// Returns the configured fork, if any. + pub fn get_fork(&self) -> Option { + self.backend.get_fork() + } + + /// Returns the current instance's ID. + pub fn instance_id(&self) -> B256 { + *self.instance_id.read() + } + + /// Resets the instance ID. + pub fn reset_instance_id(&self) { + *self.instance_id.write() = B256::random(); + } + + /// Returns the first signer that can sign for the given address + #[expect(clippy::borrowed_box)] + pub fn get_signer(&self, address: Address) -> Option<&Box>> { + self.signers.iter().find(|signer| signer.is_signer_for(address)) + } + + /// Returns a new listeners for ready transactions + pub fn new_ready_transactions(&self) -> Receiver { + self.pool.add_ready_listener() + } + + /// Returns true if forked + pub fn is_fork(&self) -> bool { + self.backend.is_fork() + } + + /// Returns the current state root + pub async fn state_root(&self) -> Option { + self.backend.get_db().read().await.maybe_state_root() + } + + /// Returns true if the `addr` is currently impersonated + pub fn is_impersonated(&self, addr: Address) -> bool { + self.backend.cheats().is_impersonated(addr) + } + + /// Returns a new accessor for certain storage elements + pub fn storage_info(&self) -> StorageInfo { + StorageInfo::new(Arc::clone(&self.backend)) + } + + /// Handler for RPC call: `anvil_getBlobByHash` + #[allow(clippy::large_stack_frames)] + pub fn anvil_get_blob_by_versioned_hash( + &self, + hash: B256, + ) -> Result> { + node_info!("anvil_getBlobByHash"); + Ok(self.backend.get_blob_by_versioned_hash(hash)?) + } + + /// Handler for RPC call: `anvil_getBlobsByBlockId` + pub fn anvil_get_blobs_by_block_id( + &self, + block_id: impl Into, + versioned_hashes: Vec, + ) -> Result>> { + node_info!("anvil_getBlobsByBlockId"); + Ok(self.backend.get_blobs_by_block_id(block_id, versioned_hashes)?) + } + + /// Returns the genesis time for the Beacon chain + /// + /// Handler for Beacon API call: `GET /eth/v1/beacon/genesis` + pub fn anvil_get_genesis_time(&self) -> Result { + node_info!("anvil_getGenesisTime"); + Ok(self.backend.genesis_time()) + } + + /// Returns the UNIX wall time in milliseconds when the current head was installed. + /// + /// Handler for RPC call: `anvil_getLastBlockWallTime` + pub fn anvil_get_last_block_wall_time(&self) -> Result { + node_info!("anvil_getLastBlockWallTime"); + Ok(self.backend.time().last_block_wall_time()) + } + + /// Reset the fork to a fresh forked state, and optionally update the fork config. + /// + /// If `forking` is `None` then this will disable forking entirely. + /// + /// Handler for RPC call: `anvil_reset` + pub async fn anvil_reset(&self, forking: Option) -> Result<()> { + node_info!("anvil_reset"); + let _reset = self.reset_lock.lock().await; + if let Some(forking) = forking { + let staged = self.backend.prepare_fork_reset(forking, self.instance_id()).await?; + let _lifecycle = self.lifecycle_lock.write().await; + // Stop an old-context block before publishing the replacement DB and environment. + let _mining = self.backend.lock_mining().await; + self.backend.commit_fork_reset(staged).await?; + self.reset_instance_id(); + self.pool.clear(); + self.pool_snapshots.lock().clear(); + self.fee_history_cache.lock().clear(); + } else { + let _lifecycle = self.lifecycle_lock.write().await; + // Unlike fork preparation, memory preparation snapshots live local fee state and does + // no remote I/O. Keep that snapshot and its publication in one lifecycle transition. + let _mining = self.backend.lock_mining().await; + let staged = self.backend.prepare_memory_reset().await?; + self.backend.commit_memory_reset(staged).await?; + self.reset_instance_id(); + self.pool.clear(); + self.pool_snapshots.lock().clear(); + self.fee_history_cache.lock().clear(); + } + Ok(()) + } + + /// Revert the state of the blockchain to a previous snapshot. + /// Takes a single parameter, which is the snapshot id to revert to. + /// + /// Handler for RPC call: `evm_revert` + pub async fn evm_revert(&self, id: U256) -> Result + where + N::ReceiptEnvelope: TxReceipt, + { + node_info!("evm_revert"); + let _lifecycle = self.lifecycle_lock.read().await; + let _mining = self.backend.lock_mining().await; + let reverted = self.backend.revert_state_snapshot(id).await?; + if reverted { + let pool = { + let mut snapshots = self.pool_snapshots.lock(); + let pool = snapshots.remove(&id); + snapshots.retain(|snapshot_id, _| *snapshot_id < id); + pool + }; + if let Some(pool) = pool { + self.pool.restore(pool); + } + } + Ok(reverted) + } + + /// Send transactions impersonating specific account and contract addresses. + /// + /// Handler for ETH RPC call: `anvil_impersonateAccount` + pub async fn anvil_impersonate_account(&self, address: Address) -> Result<()> { + node_info!("anvil_impersonateAccount"); + self.backend.impersonate(address); + Ok(()) + } + + /// Stops impersonating an account if previously set with `anvil_impersonateAccount`. + /// + /// Handler for ETH RPC call: `anvil_stopImpersonatingAccount` + pub async fn anvil_stop_impersonating_account(&self, address: Address) -> Result<()> { + node_info!("anvil_stopImpersonatingAccount"); + self.backend.stop_impersonating(address); + Ok(()) + } + + /// If set to true will make every account impersonated + /// + /// Handler for ETH RPC call: `anvil_autoImpersonateAccount` + pub async fn anvil_auto_impersonate_account(&self, enabled: bool) -> Result<()> { + node_info!("anvil_autoImpersonateAccount"); + self.backend.auto_impersonate_account(enabled); + Ok(()) + } + + /// Registers a new address and signature pair to impersonate. + pub async fn anvil_impersonate_signature( + &self, + signature: Bytes, + address: Address, + ) -> Result<()> { + node_info!("anvil_impersonateSignature"); + self.backend.impersonate_signature(signature, address).await + } + + /// Returns a new block event stream that yields Notifications when a new block was added or + /// when logs were removed from the canonical chain due to a reorg + pub fn new_block_notifications(&self) -> ChainNotifications { + self.backend.new_block_notifications() + } + + /// Returns the current client version. + /// + /// Handler for ETH RPC call: `web3_clientVersion` + pub fn client_version(&self) -> Result { + node_info!("web3_clientVersion"); + Ok(CLIENT_VERSION.to_string()) + } + + /// Returns Keccak-256 (not the standardized SHA3-256) of the given data. + /// + /// Handler for ETH RPC call: `web3_sha3` + pub fn sha3(&self, bytes: Bytes) -> Result { + node_info!("web3_sha3"); + let hash = alloy_primitives::keccak256(bytes.as_ref()); + Ok(alloy_primitives::hex::encode_prefixed(&hash[..])) + } + + /// Returns protocol version encoded as a string (quotes are necessary). + /// + /// Handler for ETH RPC call: `eth_protocolVersion` + pub fn protocol_version(&self) -> Result { + node_info!("eth_protocolVersion"); + Ok(1) + } + + /// Returns the number of hashes per second that the node is mining with. + /// + /// Handler for ETH RPC call: `eth_hashrate` + pub fn hashrate(&self) -> Result { + node_info!("eth_hashrate"); + Ok(U256::ZERO) + } + + /// Returns the client coinbase address. + /// + /// Handler for ETH RPC call: `eth_coinbase` + pub fn author(&self) -> Result
{ + node_info!("eth_coinbase"); + Ok(self.backend.coinbase()) + } + + /// Returns true if client is actively mining new blocks. + /// + /// Handler for ETH RPC call: `eth_mining` + pub fn is_mining(&self) -> Result { + node_info!("eth_mining"); + Ok(self.is_mining) + } + + /// Returns the chain ID used for transaction signing at the + /// current best block. None is returned if not + /// available. + /// + /// Handler for ETH RPC call: `eth_chainId` + pub fn eth_chain_id(&self) -> Result> { + node_info!("eth_chainId"); + Ok(Some(self.backend.chain_id().to::())) + } + + /// Returns the same as `chain_id` + /// + /// Handler for ETH RPC call: `eth_networkId` + pub fn network_id(&self) -> Result> { + node_info!("eth_networkId"); + let chain_id = self.backend.chain_id().to::(); + Ok(Some(format!("{chain_id}"))) + } + + /// Returns true if client is actively listening for network connections. + /// + /// Handler for ETH RPC call: `net_listening` + pub fn net_listening(&self) -> Result { + node_info!("net_listening"); + Ok(self.net_listening) + } + + /// Returns the current gas price + fn eth_gas_price(&self) -> Result { + node_info!("eth_gasPrice"); + Ok(U256::from(self.gas_price())) + } + + /// Returns the base fee for the next block, or null before London. + /// + /// Handler for ETH RPC call: `eth_baseFee` + pub fn base_fee(&self) -> Result> { + node_info!("eth_baseFee"); + Ok(self.backend.is_eip1559().then(|| U256::from(self.backend.base_fee()))) + } + + /// Returns the excess blob gas and current blob gas price + pub fn excess_blob_gas_and_price(&self) -> Result> { + Ok(self.backend.excess_blob_gas_and_price()) + } + + /// Returns a fee per gas that is an estimate of how much you can pay as a priority fee, or + /// 'tip', to get a transaction included in the current block. + /// + /// Handler for ETH RPC call: `eth_maxPriorityFeePerGas` + pub fn gas_max_priority_fee_per_gas(&self) -> Result { + self.max_priority_fee_per_gas() + } + + /// Returns the base fee per blob required to send an EIP-4844 tx. + /// + /// Handler for ETH RPC call: `eth_blobBaseFee` + pub fn blob_base_fee(&self) -> Result { + Ok(U256::from(self.backend.fees().base_fee_per_blob_gas())) + } + + /// Returns the block gas limit + pub fn gas_limit(&self) -> U256 { + U256::from(self.backend.gas_limit()) + } + + /// Returns the accounts list + /// + /// Handler for ETH RPC call: `eth_accounts` + pub fn accounts(&self) -> Result> { + node_info!("eth_accounts"); + let mut unique = AddressSet::default(); + let mut accounts: Vec
= Vec::new(); + for signer in self.signers.iter() { + accounts.extend(signer.accounts().into_iter().filter(|acc| unique.insert(*acc))); + } + accounts.extend( + self.backend + .cheats() + .impersonated_accounts() + .into_iter() + .filter(|acc| unique.insert(*acc)), + ); + Ok(accounts.into_iter().collect()) + } + + /// Returns the number of most recent block. + /// + /// Handler for ETH RPC call: `eth_blockNumber` + pub fn block_number(&self) -> Result { + node_info!("eth_blockNumber"); + Ok(U256::from(self.backend.best_number())) + } + + /// Returns block with given hash. + /// + /// Handler for ETH RPC call: `eth_getBlockByHash` + pub async fn block_by_hash(&self, hash: B256) -> Result> { + node_info!("eth_getBlockByHash"); + self.backend.block_by_hash(hash).await + } + + /// Returns block header with given hash. + /// + /// Handler for ETH RPC call: `eth_getHeaderByHash` + pub async fn header_by_hash( + &self, + hash: B256, + ) -> Result>> { + node_info!("eth_getHeaderByHash"); + Ok(self.backend.block_by_hash(hash).await?.map(|block| { + let WithOtherFields { inner: block, other } = block.0; + WithOtherFields { inner: block.header, other } + })) + } + + /// Returns a _full_ block with given hash. + /// + /// Handler for ETH RPC call: `eth_getBlockByHash` + pub async fn block_by_hash_full(&self, hash: B256) -> Result> { + node_info!("eth_getBlockByHash"); + self.backend.block_by_hash_full(hash).await + } + + /// Returns the number of transactions in a block with given hash. + /// + /// Handler for ETH RPC call: `eth_getBlockTransactionCountByHash` + pub async fn block_transaction_count_by_hash(&self, hash: B256) -> Result> { + node_info!("eth_getBlockTransactionCountByHash"); + let block = self.backend.block_by_hash(hash).await?; + let txs = block.map(|b| match b.transactions() { + BlockTransactions::Full(txs) => U256::from(txs.len()), + BlockTransactions::Hashes(txs) => U256::from(txs.len()), + BlockTransactions::Uncle => U256::ZERO, + }); + Ok(txs) + } + + /// Returns the number of uncles in a block with given hash. + /// + /// Handler for ETH RPC call: `eth_getUncleCountByBlockHash` + pub async fn block_uncles_count_by_hash(&self, hash: B256) -> Result { + node_info!("eth_getUncleCountByBlockHash"); + let block = + self.backend.block_by_hash(hash).await?.ok_or(BlockchainError::BlockNotFound)?; + Ok(U256::from(block.uncles.len())) + } + + /// Returns the number of uncles in a block with given block number. + /// + /// Handler for ETH RPC call: `eth_getUncleCountByBlockNumber` + pub async fn block_uncles_count_by_number(&self, block_number: BlockNumber) -> Result { + node_info!("eth_getUncleCountByBlockNumber"); + let block = self + .backend + .block_by_number(block_number) + .await? + .ok_or(BlockchainError::BlockNotFound)?; + Ok(U256::from(block.uncles.len())) + } + + /// Signs data via [EIP-712](https://github.com/ethereum/EIPs/blob/master/EIPS/eip-712.md). + /// + /// Handler for ETH RPC call: `eth_signTypedData` + pub async fn sign_typed_data( + &self, + _address: Address, + _data: serde_json::Value, + ) -> Result { + node_info!("eth_signTypedData"); + Err(BlockchainError::RpcUnimplemented) + } + + /// Signs data via [EIP-712](https://github.com/ethereum/EIPs/blob/master/EIPS/eip-712.md). + /// + /// Handler for ETH RPC call: `eth_signTypedData_v3` + pub async fn sign_typed_data_v3( + &self, + _address: Address, + _data: serde_json::Value, + ) -> Result { + node_info!("eth_signTypedData_v3"); + Err(BlockchainError::RpcUnimplemented) + } + + /// Signs data via [EIP-712](https://github.com/ethereum/EIPs/blob/master/EIPS/eip-712.md), and includes full support of arrays and recursive data structures. + /// + /// Handler for ETH RPC call: `eth_signTypedData_v4` + pub async fn sign_typed_data_v4(&self, address: Address, data: &TypedData) -> Result { + node_info!("eth_signTypedData_v4"); + let signer = self.get_signer(address).ok_or(BlockchainError::NoSignerAvailable)?; + let signature = signer.sign_typed_data(address, data).await?; + let signature = alloy_primitives::hex::encode(signature.as_bytes()); + Ok(format!("0x{signature}")) + } + + /// The sign method calculates an Ethereum specific signature + /// + /// Handler for ETH RPC call: `eth_sign` + pub async fn sign(&self, address: Address, content: impl AsRef<[u8]>) -> Result { + node_info!("eth_sign"); + let signer = self.get_signer(address).ok_or(BlockchainError::NoSignerAvailable)?; + let signature = + alloy_primitives::hex::encode(signer.sign(address, content.as_ref()).await?.as_bytes()); + Ok(format!("0x{signature}")) + } + + /// Returns transaction at given block hash and index. + /// + /// Handler for ETH RPC call: `eth_getTransactionByBlockHashAndIndex` + pub async fn transaction_by_block_hash_and_index( + &self, + hash: B256, + index: Index, + ) -> Result> { + node_info!("eth_getTransactionByBlockHashAndIndex"); + self.backend.transaction_by_block_hash_and_index(hash, index).await + } + + /// Returns an uncles at given block and index. + /// + /// Handler for ETH RPC call: `eth_getUncleByBlockHashAndIndex` + pub async fn uncle_by_block_hash_and_index( + &self, + block_hash: B256, + idx: Index, + ) -> Result> { + node_info!("eth_getUncleByBlockHashAndIndex"); + let number = self.backend.ensure_block_number(Some(BlockId::hash(block_hash))).await?; + if let Some(fork) = self.get_fork() + && fork.predates_fork_inclusive(number) + { + return Ok(fork.uncle_by_block_hash_and_index(block_hash, idx.into()).await?); + } + // It's impossible to have uncles outside of fork mode + Ok(None) + } + + /// Returns an uncles at given block and index. + /// + /// Handler for ETH RPC call: `eth_getUncleByBlockNumberAndIndex` + pub async fn uncle_by_block_number_and_index( + &self, + block_number: BlockNumber, + idx: Index, + ) -> Result> { + node_info!("eth_getUncleByBlockNumberAndIndex"); + let number = self.backend.ensure_block_number(Some(BlockId::Number(block_number))).await?; + if let Some(fork) = self.get_fork() + && fork.predates_fork_inclusive(number) + { + return Ok(fork.uncle_by_block_number_and_index(number, idx.into()).await?); + } + // It's impossible to have uncles outside of fork mode + Ok(None) + } + + /// Returns the hash of the current block, the seedHash, and the boundary condition to be met. + /// + /// Handler for ETH RPC call: `eth_getWork` + pub fn work(&self) -> Result { + node_info!("eth_getWork"); + Err(BlockchainError::RpcUnimplemented) + } + + /// Returns the sync status, always be fails. + /// + /// Handler for ETH RPC call: `eth_syncing` + pub fn syncing(&self) -> Result { + node_info!("eth_syncing"); + Ok(false) + } + + /// Returns the current configuration of the chain. + /// This is useful for finding out what precompiles and system contracts are available. + /// + /// Note: the activation timestamp is always 0 as the configuration is set at genesis. + /// Note: the `fork_id` is always `0x00000000` as this node does not participate in any forking + /// on the network. + /// Note: the `next` and `last` fields are always `null` as this node does not participate in + /// any forking on the network. + /// + /// Handler for ETH RPC call: `eth_config` + pub fn config(&self) -> Result { + node_info!("eth_config"); + Ok(EthConfig { + current: EthForkConfig { + activation_time: 0, + blob_schedule: self.backend.blob_params(), + chain_id: self.backend.chain_id().to::(), + fork_id: Bytes::from_static(&[0; 4]), + precompiles: self.backend.precompiles(), + system_contracts: self.backend.system_contracts(), + }, + next: None, + last: None, + }) + } + + /// Used for submitting a proof-of-work solution. + /// + /// Handler for ETH RPC call: `eth_submitWork` + pub fn submit_work(&self, _: B64, _: B256, _: B256) -> Result { + node_info!("eth_submitWork"); + Err(BlockchainError::RpcUnimplemented) + } + + /// Used for submitting mining hashrate. + /// + /// Handler for ETH RPC call: `eth_submitHashrate` + pub fn submit_hashrate(&self, _: U256, _: B256) -> Result { + node_info!("eth_submitHashrate"); + Err(BlockchainError::RpcUnimplemented) + } + + /// Introduced in EIP-1559 for getting information on the appropriate priority fee to use. + /// + /// Handler for ETH RPC call: `eth_feeHistory` + pub async fn fee_history( + &self, + block_count: U256, + newest_block: BlockNumber, + reward_percentiles: Vec, + ) -> Result + where + N::ReceiptEnvelope: TxReceipt, + { + node_info!("eth_feeHistory"); + + if reward_percentiles.iter().any(|p| !(0.0..=100.0).contains(p)) + || reward_percentiles.windows(2).any(|pair| pair[0] >= pair[1]) + { + return Err(FeeHistoryError::InvalidRewardPercentiles.into()); + } + if block_count.is_zero() { + return Ok(FeeHistory::default()); + } + + // max number of blocks in the requested range + + let number = self.backend.convert_block_number(Some(newest_block)); + + // Snapshot the fork block once so the early return here and the pre-fork split below agree + // on the same value. Reading it a second time lets an `anvil_reset` move the fork between + // the reads: the newest block could clear this early return, then the split treats the + // whole range as pre-fork and tacks on a stray local next-block fee. + let fork = self.get_fork(); + let fork_block = fork.as_ref().map(|fork| fork.block_number()); + + // check if the number predates the fork, if in fork mode + if let (Some(fork), Some(fork_block)) = (fork.as_ref(), fork_block) { + // if we're still at the forked block we don't have any history and can't compute it + // efficiently, instead we fetch it from the fork + if number <= fork_block { + return fork + .fee_history(block_count.to(), BlockNumber::Number(number), &reward_percentiles) + .await + .map_err(BlockchainError::AlloyForkProvider); + } + } + + const MAX_BLOCK_COUNT: u64 = 1024u64; + let block_count = block_count.saturating_to::().min(MAX_BLOCK_COUNT); + + // highest and lowest block num in the requested range + let highest = number; + let lowest = highest.saturating_sub(block_count.saturating_sub(1)); + + // only support ranges that are in cache range + if lowest < self.backend.best_number().saturating_sub(self.fee_history_limit) { + return Err(FeeHistoryError::InvalidBlockRange.into()); + } + + let mut response = FeeHistory { + oldest_block: lowest, + base_fee_per_gas: Vec::new(), + gas_used_ratio: Vec::new(), + reward: Some(Default::default()), + base_fee_per_blob_gas: Default::default(), + blob_gas_used_ratio: Default::default(), + }; + let mut rewards = Vec::new(); + + // The early return above only delegates when the *newest* block predates the fork, but a + // range can dip below the fork block while its newest block is post-fork. Local storage + // has no pre-fork blocks, so resolving them via `backend.get_block` below would fail. + // Serve the pre-fork portion from the fork provider and compute only post-fork locally. + // Reuse the `fork_block` snapshot from above so this split stays consistent with the early + // return; we didn't return, so `fork_block < highest` and only a crossing range splits. + let local_lowest = if let (Some(fork), Some(fork_block)) = (fork.as_ref(), fork_block) { + if lowest <= fork_block { + let count_pre = fork_block - lowest + 1; + let pre = fork + .fee_history(count_pre, BlockNumber::Number(fork_block), &reward_percentiles) + .await + .map_err(BlockchainError::AlloyForkProvider)?; + merge_pre_fork_fee_history(&mut response, &mut rewards, pre, fork_block); + // Compute only post-fork blocks locally; the pre-fork portion is served above. + fork_block + 1 + } else { + lowest + } + } else { + lowest + }; + + { + let storage_info = self.storage_info(); + let blob_params = self.backend.blob_params(); + + // Snapshot the cached entries for the whole range under a single lock. The + // `FeeHistoryService` populates the cache asynchronously and can lag the chain head, + // so some entries may still be missing here. + let cached: Vec> = { + let cache = self.fee_history_cache.lock(); + (local_lowest..=highest).map(|n| cache.get(&n).cloned()).collect() + }; + + // Resolve the whole range into concrete items. Cache entries include their block hash + // so a reset or reorg that reuses a block number cannot return data from the old + // canonical chain. Compute misses and stale entries on demand without holding the + // lock. A block that can't be found is a hard error rather than a silently dropped + // entry, so `items` always holds exactly one entry per requested block. + let mut warmed: Vec<(u64, FeeHistoryCacheItem)> = Vec::new(); + let mut items: Vec = Vec::with_capacity(cached.len()); + for (cached_item, n) in cached.into_iter().zip(local_lowest..=highest) { + let hash = self + .backend + .block_hash_by_number(n) + .ok_or(FeeHistoryError::BlockNotFound(BlockNumber::Number(n)))?; + let item = match cached_item { + Some(item) if item.block_hash == hash => item, + _ => { + let block = self + .backend + .get_block_by_hash(hash) + .ok_or(FeeHistoryError::BlockNotFound(BlockNumber::Number(n)))?; + let header = block.header; + let (item, block_number) = create_fee_history_cache_item( + hash, + &header, + &storage_info, + blob_params, + ); + // The block was found above, but missing stored block/receipts make the + // helper hand back a zero-filled item. Error out rather than serve + // synthetic data for a block we already confirmed exists. + let block_number = block_number + .ok_or(FeeHistoryError::BlockNotFound(BlockNumber::Number(n)))?; + warmed.push((block_number, item.clone())); + item + } + }; + items.push(item); + } + + // Warm the cache with the on-demand entries under a single lock, then trim to the + // limit by dropping the oldest blocks so the fallback can't grow the cache unbounded. + if !warmed.is_empty() { + let mut cache = self.fee_history_cache.lock(); + for (block_number, item) in warmed { + cache.insert(block_number, item); + } + while cache.len() as u64 > self.fee_history_limit { + cache.pop_first(); + } + } + + for item in items { + response.base_fee_per_gas.push(item.base_fee); + response.base_fee_per_blob_gas.push(item.base_fee_per_blob_gas.unwrap_or(0)); + response.blob_gas_used_ratio.push(item.blob_gas_used_ratio); + response.gas_used_ratio.push(item.gas_used_ratio); + + // requested percentiles + if !reward_percentiles.is_empty() { + let mut block_rewards = Vec::new(); + for p in &reward_percentiles { + block_rewards.push(reward_at_percentile(&item.rewards, *p)); + } + rewards.push(block_rewards); + } + } + } + + response.reward = Some(rewards); + + // Both base-fee arrays include the block after the returned range. For a historical + // request, use that block's header; the fee manager tracks the block after the current + // chain head and would therefore append an unrelated value. Prefer an existing child even + // when `highest` was the head at the start of this request, since mining can advance it. + let next_number = highest + .checked_add(1) + .ok_or(FeeHistoryError::BlockNotFound(BlockNumber::Number(highest)))?; + let (next_base_fee, next_blob_base_fee) = self + .backend + .fee_history_next_fees(highest) + .await + .ok_or(FeeHistoryError::BlockNotFound(BlockNumber::Number(next_number)))?; + response.base_fee_per_gas.push(next_base_fee); + response.base_fee_per_blob_gas.push(next_blob_base_fee); + + Ok(response) + } + + /// Introduced in EIP-1159, a Geth-specific and simplified priority fee oracle. + /// Leverages the already existing fee history cache. + /// + /// Returns a suggestion for a gas tip cap for dynamic fee transactions. + /// + /// Handler for ETH RPC call: `eth_maxPriorityFeePerGas` + pub fn max_priority_fee_per_gas(&self) -> Result { + node_info!("eth_maxPriorityFeePerGas"); + Ok(U256::from(self.lowest_suggestion_tip())) + } + + /// Returns code by its hash + /// + /// Handler for RPC call: `debug_codeByHash` + pub async fn debug_code_by_hash( + &self, + hash: B256, + block_id: Option, + ) -> Result> { + node_info!("debug_codeByHash"); + self.backend.debug_code_by_hash(hash, block_id).await + } + + /// Returns the value associated with a key from the database + /// Only supports bytecode lookups. + /// + /// Handler for RPC call: `debug_dbGet` + pub async fn debug_db_get(&self, key: String) -> Result> { + node_info!("debug_dbGet"); + self.backend.debug_db_get(key).await + } + + /// Handles reth's no-op `debug_getModifiedAccountsByNumber` endpoint. + pub fn debug_get_modified_accounts_by_number( + &self, + _start_number: u64, + _end_number: u64, + ) -> Result<()> { + node_info!("debug_getModifiedAccountsByNumber"); + Ok(()) + } + + /// Handles reth's no-op `debug_freeOSMemory` endpoint. + pub fn debug_free_os_memory(&self) -> Result<()> { + node_info!("debug_freeOSMemory"); + Ok(()) + } + + /// Executes a transaction and returns requested parity trace results. + /// + /// Handler for RPC call: `trace_call` + pub async fn trace_call( + &self, + request: WithOtherFields, + trace_types: HashSet, + block_id: Option, + ) -> Result + where + N: Network, + { + node_info!("trace_call"); + let block_id = block_id.unwrap_or_default(); + let block_request = match &block_id { + BlockId::Number(BlockNumber::Pending) => { + let pending_txs = self.pool.ready_transactions().collect(); + BlockRequest::Pending(pending_txs) + } + _ => { + let number = self.backend.ensure_block_number(Some(block_id)).await?; + BlockRequest::Number(number) + } + }; + let inner = request.as_ref(); + let fees = FeeDetails::new( + inner.gas_price, + inner.max_fee_per_gas, + inner.max_priority_fee_per_gas, + inner.max_fee_per_blob_gas, + )? + .or_zero_fees(); + + self.backend.trace_call(request, fees, trace_types, block_request, block_id).await + } + + /// Returns traces for the transaction hash via parity's tracing endpoint + /// + /// Handler for RPC call: `trace_transaction` + pub async fn trace_transaction( + &self, + tx_hash: B256, + ) -> Result>> { + node_info!("trace_transaction"); + self.backend.trace_transaction(tx_hash).await + } + + /// Returns traces for the transaction hash via parity's tracing endpoint + /// + /// Handler for RPC call: `trace_block` + pub async fn trace_block(&self, block: BlockNumber) -> Result> { + node_info!("trace_block"); + self.backend.trace_block(block).await + } + + /// Returns filtered traces over blocks + /// + /// Handler for RPC call: `trace_filter` + pub async fn trace_filter( + &self, + filter: TraceFilter, + ) -> Result> { + node_info!("trace_filter"); + self.backend.trace_filter(filter).await + } + + /// Returns the transaction trace at the given trace address. + /// + /// Handler for RPC call: `trace_get`. + pub async fn trace_get( + &self, + hash: B256, + indices: Vec, + ) -> Result> { + node_info!("trace_get"); + self.backend.trace_get(hash, indices).await + } + + /// Replays all transactions in a block returning the requested traces for each transaction + /// + /// Handler for RPC call: `trace_replayBlockTransactions` + pub async fn trace_replay_block_transactions( + &self, + block: BlockNumber, + trace_types: HashSet, + ) -> Result>> { + node_info!("trace_replayBlockTransactions"); + self.backend.trace_replay_block_transactions(block, trace_types).await + } + + /// Replays a transaction returning the requested traces. + /// + /// Handler for RPC call: `trace_replayTransaction`. + pub async fn trace_replay_transaction( + &self, + transaction: B256, + trace_types: HashSet, + ) -> Result> { + node_info!("trace_replayTransaction"); + self.backend.trace_replay_transaction(transaction, trace_types).await + } +} + +impl> EthApi { + /// Returns the current state + pub async fn serialized_state( + &self, + preserve_historical_states: bool, + ) -> Result { + self.backend.serialized_state(preserve_historical_states).await + } +} + +// == impl EthApi anvil endpoints == + +impl EthApi { + /// Returns transaction by given block number and index. + /// + /// Handler for ETH RPC call: `eth_getTransactionByBlockNumberAndIndex` + pub async fn transaction_by_block_number_and_index( + &self, + block: BlockNumber, + idx: Index, + ) -> Result> { + node_info!("eth_getTransactionByBlockNumberAndIndex"); + if block.is_pending() { + return Ok(self.pending_block_full().await?.and_then(|block| { + let WithOtherFields { inner: block, .. } = block.0; + block.transactions.into_transactions().nth(idx.into()) + })); + } + + self.backend.transaction_by_block_number_and_index(block, idx).await + } + + /// Create a buffer that represents all state on the chain, which can be loaded to separate + /// process by calling `anvil_loadState` + /// + /// Handler for RPC call: `anvil_dumpState` + pub async fn anvil_dump_state( + &self, + preserve_historical_states: Option, + ) -> Result { + node_info!("anvil_dumpState"); + self.backend.dump_state(preserve_historical_states.unwrap_or(false)).await + } + + /// Append chain state buffer to current chain. Will overwrite any conflicting addresses or + /// storage. + /// + /// Handler for RPC call: `anvil_loadState` + pub async fn anvil_load_state(&self, buf: Bytes) -> Result { + node_info!("anvil_loadState"); + let loaded = self.backend.load_state_bytes(buf).await?; + #[cfg(feature = "base")] + if loaded { + self.invalidate_base_eip8130_pool(); + } + Ok(loaded) + } + + async fn block_request( + &self, + block_number: Option, + ) -> Result> { + let block_request = match block_number { + Some(BlockId::Number(BlockNumber::Pending)) => { + let pending_txs = self.pool.ready_transactions().collect(); + BlockRequest::Pending(pending_txs) + } + _ => { + let number = self.backend.ensure_block_number(block_number).await?; + BlockRequest::Number(number) + } + }; + Ok(block_request) + } + + /// Returns account information after replaying a block through a transaction index. + /// + /// Handler for RPC call: `debug_accountInfoAt`. + pub async fn debug_account_info_at( + &self, + block_id: BlockId, + tx_index: Index, + address: Address, + ) -> Result> { + node_info!("debug_accountInfoAt"); + self.backend.debug_account_info_at(block_id, tx_index, address).await + } + + /// Returns a best-effort execution witness for the given block, built from the full parent + /// state. See [`crate::eth::backend::mem::Backend::debug_execution_witness`] for the exact + /// contents and limitations. + /// + /// Handler for RPC call: `debug_executionWitness`. + pub async fn debug_execution_witness(&self, block: BlockNumber) -> Result { + node_info!("debug_executionWitness"); + self.backend.debug_execution_witness(block).await + } + + /// Returns opcode gas usage for a transaction. + /// + /// Handler for RPC call: `trace_transactionOpcodeGas`. + pub async fn trace_transaction_opcode_gas( + &self, + tx_hash: B256, + ) -> Result> { + node_info!("trace_transactionOpcodeGas"); + self.backend.trace_transaction_opcode_gas(tx_hash).await + } + + /// Returns opcode gas usage for all transactions in a block. + /// + /// Handler for RPC call: `trace_blockOpcodeGas`. + pub async fn trace_block_opcode_gas( + &self, + block_id: BlockId, + ) -> Result> { + node_info!("trace_blockOpcodeGas"); + self.backend.trace_block_opcode_gas(block_id).await + } + + /// Traces a raw signed transaction without importing it into the mempool. + /// + /// Handler for RPC call: `trace_rawTransaction`. + pub async fn trace_raw_transaction( + &self, + tx: Bytes, + trace_types: HashSet, + block_number: Option, + ) -> Result { + node_info!("trace_rawTransaction"); + + let mut data = tx.as_ref(); + if data.is_empty() { + return Err(BlockchainError::EmptyRawTransactionData); + } + + let transaction = FoundryTxEnvelope::decode_2718(&mut data) + .map_err(|_| BlockchainError::FailedToDecodeSignedTransaction)?; + self.ensure_typed_transaction_supported(&transaction)?; + + #[cfg(feature = "base")] + let is_eip8130 = transaction.is_eip8130(); + let pending_transaction = PendingTransaction::new(transaction).map_err(|error| { + #[cfg(feature = "base")] + if is_eip8130 { + return BlockchainError::Eip8130TransactionRejected(error.to_string()); + } + BlockchainError::RecoveryError(error) + })?; + let block_request = self.block_request(block_number).await?; + + self.backend + .trace_raw_transaction(pending_transaction, trace_types, Some(block_request)) + .await + } + + /// Increases the balance of an account. + /// + /// Handler for RPC call: `anvil_addBalance` + pub async fn anvil_add_balance(&self, address: Address, balance: U256) -> Result<()> { + node_info!("anvil_addBalance"); + self.backend.add_balance(address, balance).await?; + Ok(()) + } + + /// Returns a locally stored rollback ancestor, or the remote base of a Monad transaction-hash + /// fork when that integration is enabled. + async fn rollback_block(&self, number: u64) -> Result> { + if let Some(block) = self.backend.get_block(number) { + return Ok(Some(block)); + } + #[cfg(feature = "monad")] + { + if self.backend.is_monad() { + self.backend.monad_rollback_block(number).await + } else { + Ok(None) + } + } + #[cfg(not(feature = "monad"))] + { + Ok(None) + } + } + + /// Rewinds through the selected execution family's recovery path. + async fn rollback_backend( + &self, + common_block: Block, + mining_guard: &tokio::sync::MutexGuard<'_, ()>, + ) -> Result<()> { + #[cfg(feature = "monad")] + if self.backend.is_monad() { + return self.backend.rollback_monad(common_block, mining_guard).await; + } + self.backend.rollback(common_block, mining_guard).await + } + + /// Rollback the chain to a specific depth. + /// + /// e.g depth = 3 + /// A -> B -> C -> D -> E + /// A -> B + /// + /// Depth specifies the height to rollback the chain back to. Depth must not exceed the current + /// chain height, i.e. can't rollback past the genesis block. + /// + /// Handler for RPC call: `anvil_rollback` + pub async fn anvil_rollback(&self, depth: Option) -> Result<()> { + node_info!("anvil_rollback"); + let _lifecycle = self.lifecycle_lock.write().await; + let mining_guard = self.backend.lock_mining().await; + let depth = depth.unwrap_or(1); + + // Check reorg depth doesn't exceed current chain height + let current_height = self.backend.best_number(); + let common_height = current_height.checked_sub(depth).ok_or(BlockchainError::RpcError( + RpcError::invalid_params(format!( + "Rollback depth must not exceed current chain height: current height {current_height}, depth {depth}" + )), + ))?; + + // Get the common ancestor block + let common_block = + self.rollback_block(common_height).await?.ok_or(BlockchainError::BlockNotFound)?; + + self.rollback_backend(common_block, &mining_guard).await?; + Ok(()) + } + + /// Estimates the gas usage of the `request` with the state. + /// + /// This will execute the transaction request and find the best gas limit via binary search. + fn do_estimate_gas_with_state( + &self, + mut request: FoundryTransactionRequest, + state: &dyn DatabaseRef, + block_env: BlockEnv, + monad_context: Option, + ) -> Result { + // A blob request may carry only its sidecar. Take its versioned hashes first, so that the + // funds check and every execution below price the blob fee. + if request.as_ref().blob_versioned_hashes.is_none() { + request.as_mut().populate_blob_hashes(); + } + let inner = request.as_ref(); + let fees = FeeDetails::new( + inner.gas_price, + inner.max_fee_per_gas, + inner.max_priority_fee_per_gas, + inner.max_fee_per_blob_gas, + )? + .or_zero_fees(); + + #[cfg(feature = "base")] + if self.backend.is_base() && request.is_base() { + self.backend.ensure_base_eip8130_active_at(block_env.timestamp.saturating_to())?; + let gas_limit = block_env.gas_limit; + let result = self + .backend + .call_with_state_typed_gas_limit( + state, + request, + fees, + block_env, + GasEstimateCallOptions::new(gas_limit, false, monad_context), + ) + .try_into()?; + return match result { + GasEstimationCallResult::Success(gas) => Ok(gas), + GasEstimationCallResult::OutOfGas => { + Err(InvalidTransactionError::BasicOutOfGas(u128::from(gas_limit)).into()) + } + GasEstimationCallResult::Revert(output) => { + Err(InvalidTransactionError::Revert(output).into()) + } + GasEstimationCallResult::EvmError(error) => Err(BlockchainError::EvmError(error)), + }; + } + + // get the highest possible gas limit, either the request's set value or the currently + // configured gas limit + let mut highest_gas_limit = inner.gas.map_or(block_env.gas_limit.into(), |g| g as u128); + + // Tempo AA transactions pay fees in ERC-20 tokens, not ETH. Only treat requests as + // Tempo AA in Tempo mode, otherwise `feeToken` should not bypass ETH funds checks. + let is_tempo_aa_tx = self.backend.is_tempo() && request.is_tempo(); + let is_tempo_keychain = matches!( + &request, + FoundryTransactionRequest::Tempo(request) if request.key_id.is_some() + ); + let disable_fee_charge = is_tempo_keychain || inner.from.is_none(); + + // Requests without a sender historically estimate value transfers without requiring the + // default caller to hold ETH. Fund only the simulation overlay so the execution probe + // preserves that behavior, including transfers that need new-account state gas. + let mut funded_state; + let state = if !is_tempo_aa_tx + && inner.from.is_none() + && let Some(value) = inner.value + && !value.is_zero() + { + funded_state = CacheDB::new(state); + let mut caller = state.basic_ref(Address::ZERO)?.unwrap_or_default(); + caller.balance = caller.balance.max(value); + funded_state.insert_account_info(Address::ZERO, caller); + &funded_state as &dyn DatabaseRef + } else { + state + }; + + let gas_price = fees.gas_price.unwrap_or_default(); + // Check transfer value before any fast path, and cap gas limit by sender balance when the + // request has a non-zero gas price. Only enforce this for explicit senders: calls without + // `from` historically estimate against the default caller and must not be capped by the + // zero address balance. + if !is_tempo_aa_tx && let Some(from) = inner.from { + let mut available_funds = self.backend.get_balance_with_state(state, from)?; + if let Some(value) = inner.value { + if value > available_funds { + return Err(InvalidTransactionError::InsufficientFunds.into()); + } + // safe: value <= available_funds + available_funds -= value; + } + if gas_price > 0 { + // Blob gas is paid on top of execution gas, so reserve its maximum cost first. Like + // the call itself, pay no blob fee when no cap is given. + if inner.minimal_tx_type().is_eip4844() + && let Some(hashes) = &inner.blob_versioned_hashes + { + let max_fee_per_blob_gas = fees.max_fee_per_blob_gas.unwrap_or_default(); + let blob_gas = U256::from(hashes.len() as u64 * DATA_GAS_PER_BLOB); + let blob_cost = blob_gas.saturating_mul(U256::from(max_fee_per_blob_gas)); + if blob_cost >= available_funds { + return Err(InvalidTransactionError::InsufficientFunds.into()); + } + available_funds -= blob_cost; + } + // amount of gas the sender can afford with the `gas_price` + let allowance = + available_funds.checked_div(U256::from(gas_price)).unwrap_or_default(); + highest_gas_limit = std::cmp::min(highest_gas_limit, allowance.saturating_to()); + } + } + + // If the request is a simple native token transfer we can optimize + // We assume it's a transfer if we have no input data. + // Skip this optimization for Tempo mode since native ETH transfers are not allowed + // and Tempo AA transactions have higher intrinsic gas costs (~46k). + if !self.backend.is_tempo() { + let to = inner.to.as_ref().and_then(TxKind::to); + + // check certain fields to see if the request could be a simple transfer + let maybe_transfer = (inner.input.input().is_none() + || inner.input.input().is_some_and(|data| data.is_empty())) + && inner.authorization_list.is_none() + && inner.access_list.is_none() + && inner.blob_versioned_hashes.is_none(); + + if maybe_transfer + && let Some(to) = to + && !self.backend.is_precompile(to, &block_env) + && let Ok(target_code) = self.backend.get_code_with_state(&state, *to) + && target_code.as_ref().is_empty() + { + // Execute the transfer to account for fork-dependent gas costs and validation. + // With no bytecode or authorizations, a successful run receives no refunds and + // its gas used is the exact gas required. A new-account state charge can make + // this probe fail, in which case estimation continues below. + let probe = self.backend.call_with_state_typed_gas_limit( + &state, + request.clone(), + fees.clone(), + block_env.clone(), + GasEstimateCallOptions::new( + highest_gas_limit.min(MIN_TRANSACTION_GAS) as u64, + disable_fee_charge, + monad_context.clone(), + ), + ); + if let Ok(GasEstimationCallResult::Success(gas)) = probe.try_into() { + return Ok(gas); + } + } + } + + // execute the call without writing to db + let ethres = self.backend.call_with_state_typed_gas_limit( + &state, + request.clone(), + fees.clone(), + block_env.clone(), + GasEstimateCallOptions::new( + highest_gas_limit as u64, + disable_fee_charge, + monad_context.clone(), + ), + ); + + let gas_used = match ethres.try_into()? { + GasEstimationCallResult::Success(gas) => Ok(gas), + GasEstimationCallResult::OutOfGas => { + Err(InvalidTransactionError::BasicOutOfGas(highest_gas_limit).into()) + } + GasEstimationCallResult::Revert(output) => { + Err(InvalidTransactionError::Revert(output).into()) + } + GasEstimationCallResult::EvmError(err) => { + warn!(target: "node", "estimation failed due to {:?}", err); + Err(BlockchainError::EvmError(err)) + } + }?; + + // at this point we know the call succeeded but want to find the _best_ (lowest) gas the + // transaction succeeds with. we find this by doing a binary search over the + // possible range NOTE: this is the gas the transaction used, which is less than the + // transaction requires to succeed + + // Gas used is a lower bound on the gas required. Keep it inside the search interval so + // exact-cost transactions, including Amsterdam calls below 21,000 gas, are considered. + let mut lowest_gas_limit = gas_used.saturating_sub(1); + + // pick a point that's close to the estimated gas + let mut mid_gas_limit = + std::cmp::min(gas_used * 3, (highest_gas_limit + lowest_gas_limit) / 2); + + // Binary search for the ideal gas limit + while lowest_gas_limit + 1 < highest_gas_limit { + let ethres = self.backend.call_with_state_typed_gas_limit( + &state, + request.clone(), + fees.clone(), + block_env.clone(), + GasEstimateCallOptions::new( + mid_gas_limit as u64, + disable_fee_charge, + monad_context.clone(), + ), + ); + + match ethres.try_into()? { + GasEstimationCallResult::Success(_) => { + // If the transaction succeeded, we can set a ceiling for the highest gas limit + // at the current midpoint, as spending any more gas would + // make no sense (as the TX would still succeed). + highest_gas_limit = mid_gas_limit; + } + GasEstimationCallResult::OutOfGas + | GasEstimationCallResult::Revert(_) + | GasEstimationCallResult::EvmError(_) => { + // If the transaction failed, we can set a floor for the lowest gas limit at the + // current midpoint, as spending any less gas would make no + // sense (as the TX would still revert due to lack of gas). + // + // We don't care about the reason here, as we known that transaction is correct + // as it succeeded earlier + lowest_gas_limit = mid_gas_limit; + } + }; + // new midpoint + mid_gas_limit = (highest_gas_limit + lowest_gas_limit) / 2; } + + trace!(target : "node", "Estimated Gas for call {:?}", highest_gas_limit); + + Ok(highest_gas_limit) } /// Executes the [EthRequest] and returns an RPC [ResponseResult]. + #[allow(clippy::large_stack_frames)] pub async fn execute(&self, request: EthRequest) -> ResponseResult { trace!(target: "rpc::api", "executing eth request"); + // Fork reset and RPC URL replacement take the write lock internally after their fallible + // remote staging work. Memory reset takes it before staging because it snapshots live + // state. Identity, snapshot, and chain-rewind methods also lock internally to keep direct + // API callers safe without recursively acquiring this fair RwLock. + let _lifecycle = if matches!( + &request, + EthRequest::Reset(_) + | EthRequest::SetRpcUrl(_) + | EthRequest::NodeInfo(_) + | EthRequest::AnvilMetadata(_) + | EthRequest::EvmSnapshot(_) + | EthRequest::EvmRevert(_) + | EthRequest::Reorg(_) + | EthRequest::Rollback(_) + ) { + None + } else { + Some(self.lifecycle_lock.read().await) + }; let response = match request.clone() { + EthRequest::EthProtocolVersion(()) => self.protocol_version().to_rpc_result(), EthRequest::Web3ClientVersion(()) => self.client_version().to_rpc_result(), EthRequest::Web3Sha3(content) => self.sha3(content).to_rpc_result(), EthRequest::EthGetAccount(addr, block) => { @@ -183,25 +2135,37 @@ impl EthApi { EthRequest::EthGetTransactionByHash(hash) => { self.transaction_by_hash(hash).await.to_rpc_result() } + EthRequest::EthPendingTransactions(_) => { + self.pending_transactions().await.to_rpc_result() + } EthRequest::EthSendTransaction(request) => { self.send_transaction(*request).await.to_rpc_result() } + EthRequest::EthResend(request, gas_price, gas_limit) => { + self.resend_transaction(*request, gas_price, gas_limit).await.to_rpc_result() + } EthRequest::EthSendTransactionSync(request) => { self.send_transaction_sync(*request).await.to_rpc_result() } EthRequest::EthChainId(_) => self.eth_chain_id().to_rpc_result(), EthRequest::EthNetworkId(_) => self.network_id().to_rpc_result(), EthRequest::NetListening(_) => self.net_listening().to_rpc_result(), + EthRequest::EthHashrate(()) => self.hashrate().to_rpc_result(), EthRequest::EthGasPrice(_) => self.eth_gas_price().to_rpc_result(), + EthRequest::EthBaseFee(_) => self.base_fee().to_rpc_result(), EthRequest::EthMaxPriorityFeePerGas(_) => { self.gas_max_priority_fee_per_gas().to_rpc_result() } EthRequest::EthBlobBaseFee(_) => self.blob_base_fee().to_rpc_result(), EthRequest::EthAccounts(_) => self.accounts().to_rpc_result(), EthRequest::EthBlockNumber(_) => self.block_number().to_rpc_result(), + EthRequest::EthCoinbase(()) => self.author().to_rpc_result(), EthRequest::EthGetStorageAt(addr, slot, block) => { self.storage_at(addr, slot, block).await.to_rpc_result() } + EthRequest::EthGetStorageValues(requests, block) => { + self.storage_values(requests, block).await.to_rpc_result() + } EthRequest::EthGetBlockByHash(hash, full) => { if full { self.block_by_hash_full(hash).await.to_rpc_result() @@ -209,6 +2173,7 @@ impl EthApi { self.block_by_hash(hash).await.to_rpc_result() } } + EthRequest::EthGetHeaderByHash(hash) => self.header_by_hash(hash).await.to_rpc_result(), EthRequest::EthGetBlockByNumber(num, full) => { if full { self.block_by_number_full(num).await.to_rpc_result() @@ -216,8 +2181,36 @@ impl EthApi { self.block_by_number(num).await.to_rpc_result() } } - EthRequest::EthGetTransactionCount(addr, block) => { - self.transaction_count(addr, block).await.to_rpc_result() + EthRequest::EthGetHeaderByNumber(num) => { + self.header_by_number(num).await.to_rpc_result() + } + EthRequest::EthGetBlockAccessList(block_id) => { + self.block_access_list(block_id).await.to_rpc_result() + } + EthRequest::EthGetBlockAccessListByBlockHash(block_hash) => { + self.block_access_list_by_hash(block_hash).await.to_rpc_result() + } + EthRequest::EthGetBlockAccessListByBlockNumber(block_number) => { + self.block_access_list_by_number(block_number).await.to_rpc_result() + } + EthRequest::EthGetBlockAccessListRaw(block_id) => { + self.block_access_list_raw(block_id).await.to_rpc_result() + } + EthRequest::EthGetTransactionCount(params) => { + let (address, block, nonce_key) = params.into_parts(); + #[cfg(feature = "base")] + { + self.transaction_count_with_key(address, block, nonce_key).await.to_rpc_result() + } + #[cfg(not(feature = "base"))] + { + if nonce_key.is_some() { + return ResponseResult::Error(RpcError::invalid_params( + "nonce-key transaction counts require Base support", + )); + } + self.transaction_count(address, block).await.to_rpc_result() + } } EthRequest::EthGetTransactionCountByHash(hash) => { self.block_transaction_count_by_hash(hash).await.to_rpc_result() @@ -256,23 +2249,39 @@ impl EthApi { EthRequest::EthSendRawTransaction(tx) => { self.send_raw_transaction(tx).await.to_rpc_result() } - EthRequest::EthSendRawTransactionSync(tx) => { - self.send_raw_transaction_sync(tx).await.to_rpc_result() + EthRequest::EthSendRawTransactionSync(tx, timeout_ms) => { + self.send_raw_transaction_sync(tx, timeout_ms).await.to_rpc_result() + } + EthRequest::EthSendRawTransactionConditional(tx, condition) => { + self.send_raw_transaction_conditional(tx, condition).await.to_rpc_result() + } + EthRequest::EthSignRawTransaction(tx) => { + self.sign_raw_transaction(tx).await.to_rpc_result() + } + EthRequest::AnvilClassifyTransaction(tx) => { + self.anvil_classify_transaction(tx).to_rpc_result() } EthRequest::EthCall(call, block, state_override, block_overrides) => self .call(call, block, EvmOverrides::new(state_override, block_overrides)) .await .to_rpc_result(), + EthRequest::EthCallMany(bundles, state_context, state_override) => { + self.call_many(bundles, state_context, state_override).await.to_rpc_result() + } + EthRequest::EthCallBundle(bundle) => self.call_bundle(bundle).await.to_rpc_result(), EthRequest::EthSimulateV1(simulation, block) => { - self.simulate_v1(simulation, block).await.to_rpc_result() + self.simulate_v1_raw(simulation, block).await.to_rpc_result() } - EthRequest::EthCreateAccessList(call, block) => { - self.create_access_list(call, block).await.to_rpc_result() + EthRequest::EthCreateAccessList(call, block, state_override) => { + self.create_access_list(call, block, state_override).await.to_rpc_result() } EthRequest::EthEstimateGas(call, block, state_override, block_overrides) => self .estimate_gas(call, block, EvmOverrides::new(state_override, block_overrides)) .await .to_rpc_result(), + EthRequest::EthFillTransaction(request) => { + self.fill_transaction(request).await.to_rpc_result() + } EthRequest::EthGetRawTransactionByHash(hash) => { self.raw_transaction(hash).await.to_rpc_result() } @@ -282,6 +2291,10 @@ impl EthApi { EthRequest::GetBlobByTransactionHash(hash) => { self.anvil_get_blob_by_tx_hash(hash).to_rpc_result() } + EthRequest::GetGenesisTime(()) => self.anvil_get_genesis_time().to_rpc_result(), + EthRequest::GetLastBlockWallTime(()) => { + self.anvil_get_last_block_wall_time().to_rpc_result() + } EthRequest::EthGetRawTransactionByBlockHashAndIndex(hash, index) => { self.raw_transaction_by_block_hash_and_index(hash, index).await.to_rpc_result() } @@ -309,6 +2322,7 @@ impl EthApi { EthRequest::EthGetLogs(filter) => self.logs(filter).await.to_rpc_result(), EthRequest::EthGetWork(_) => self.work().to_rpc_result(), EthRequest::EthSyncing(_) => self.syncing().to_rpc_result(), + EthRequest::EthConfig(_) => self.config().to_rpc_result(), EthRequest::EthSubmitWork(nonce, pow, digest) => { self.submit_work(nonce, pow, digest).to_rpc_result() } @@ -322,6 +2336,16 @@ impl EthApi { EthRequest::DebugGetRawTransaction(hash) => { self.raw_transaction(hash).await.to_rpc_result() } + EthRequest::DebugGetRawReceipts(block) => { + self.raw_receipts(block).await.to_rpc_result() + } + EthRequest::DebugGetRawTransactions(block) => { + self.raw_transactions(block).await.to_rpc_result() + } + EthRequest::DebugGetRawHeader(block) => self.raw_header(block).await.to_rpc_result(), + EthRequest::DebugGetRawBlock(block) => self.raw_block(block).await.to_rpc_result(), + // non eth-standard rpc calls + EthRequest::DebugClearTxpool(_) => self.debug_clear_txpool().await.to_rpc_result(), // non eth-standard rpc calls EthRequest::DebugTraceTransaction(tx, opts) => { self.debug_trace_transaction(tx, opts).await.to_rpc_result() @@ -333,9 +2357,53 @@ impl EthApi { EthRequest::DebugCodeByHash(hash, block) => { self.debug_code_by_hash(hash, block).await.to_rpc_result() } + EthRequest::DebugDbGet(key) => self.debug_db_get(key).await.to_rpc_result(), + EthRequest::DebugGetModifiedAccountsByNumber(start_number, end_number) => { + self.debug_get_modified_accounts_by_number(start_number, end_number).to_rpc_result() + } + EthRequest::DebugFreeOsMemory(()) => self.debug_free_os_memory().to_rpc_result(), + EthRequest::DebugAccountInfoAt(block_id, tx_index, address) => { + self.debug_account_info_at(block_id, tx_index, address).await.to_rpc_result() + } + EthRequest::DebugExecutionWitness(block) => { + self.debug_execution_witness(block).await.to_rpc_result() + } + EthRequest::DebugTraceBlock(rlp_block, opts) => { + self.debug_trace_block(rlp_block, opts).await.to_rpc_result() + } + EthRequest::DebugTraceBlockByHash(block_hash, opts) => { + self.debug_trace_block_by_hash(block_hash, opts).await.to_rpc_result() + } + EthRequest::DebugTraceBlockByNumber(block_number, opts) => { + self.debug_trace_block_by_number(block_number, opts).await.to_rpc_result() + } + EthRequest::TraceCall(tx, trace_types, block) => { + self.trace_call(tx, trace_types, block).await.to_rpc_result() + } EthRequest::TraceTransaction(tx) => self.trace_transaction(tx).await.to_rpc_result(), EthRequest::TraceBlock(block) => self.trace_block(block).await.to_rpc_result(), EthRequest::TraceFilter(filter) => self.trace_filter(filter).await.to_rpc_result(), + EthRequest::TraceGet(hash, indices) => { + self.trace_get(hash, indices).await.to_rpc_result() + } + EthRequest::TraceReplayBlockTransactions(block, trace_types) => { + self.trace_replay_block_transactions(block, trace_types).await.to_rpc_result() + } + EthRequest::TraceReplayTransaction(transaction, trace_types) => { + self.trace_replay_transaction(transaction, trace_types).await.to_rpc_result() + } + EthRequest::TraceTransactionOpcodeGas(tx_hash) => { + self.trace_transaction_opcode_gas(tx_hash).await.to_rpc_result() + } + EthRequest::TraceBlockOpcodeGas(block_id) => { + self.trace_block_opcode_gas(block_id).await.to_rpc_result() + } + EthRequest::TraceRawTransaction(tx, trace_types, block_number) => { + self.trace_raw_transaction(tx, trace_types, block_number).await.to_rpc_result() + } + EthRequest::TraceCallMany(calls, block_number) => { + self.trace_call_many(calls, block_number).await.to_rpc_result() + } EthRequest::ImpersonateAccount(addr) => { self.anvil_impersonate_account(addr).await.to_rpc_result() } @@ -345,6 +2413,9 @@ impl EthApi { EthRequest::AutoImpersonateAccount(enable) => { self.anvil_auto_impersonate_account(enable).await.to_rpc_result() } + EthRequest::ImpersonateSignature(signature, address) => { + self.anvil_impersonate_signature(signature, address).await.to_rpc_result() + } EthRequest::GetAutoMine(()) => self.anvil_get_auto_mine().to_rpc_result(), EthRequest::Mine(blocks, interval) => { self.anvil_mine(blocks, interval).await.to_rpc_result() @@ -374,6 +2445,9 @@ impl EthApi { EthRequest::DealERC20(addr, token_addr, val) => { self.anvil_deal_erc20(addr, token_addr, val).await.to_rpc_result() } + EthRequest::DealTIP20(addr, token_addr, val) => { + self.anvil_deal_tip20(addr, token_addr, val).await.to_rpc_result() + } EthRequest::SetERC20Allowance(owner, spender, token_addr, val) => self .anvil_set_erc20_allowance(owner, spender, token_addr, val) .await @@ -388,6 +2462,12 @@ impl EthApi { self.anvil_set_storage_at(addr, slot, val).await.to_rpc_result() } EthRequest::SetCoinbase(addr) => self.anvil_set_coinbase(addr).await.to_rpc_result(), + EthRequest::SetNextBlockPrevRandao(prevrandao) => { + self.anvil_set_next_block_prevrandao(prevrandao).await.to_rpc_result() + } + EthRequest::SetNextBlockParentBeaconBlockRoot(root) => { + self.anvil_set_next_block_parent_beacon_block_root(root).await.to_rpc_result() + } EthRequest::SetChainId(id) => self.anvil_set_chain_id(id).await.to_rpc_result(), EthRequest::SetLogging(log) => self.anvil_set_logging(log).await.to_rpc_result(), EthRequest::SetMinGasPrice(gas) => { @@ -421,7 +2501,15 @@ impl EthApi { "The timestamp is too big", )); } - let time = timestamp.to::(); + // evm_setTime accepts either seconds or milliseconds for Ganache compatibility. + // Timestamps above 1e12 are interpreted as milliseconds and converted to + // seconds; below that threshold they are treated as seconds directly. + let raw = timestamp.to::(); + let time = if raw > 1_000_000_000_000 { + Duration::from_millis(raw).as_secs() + } else { + raw + }; self.evm_set_time(time).to_rpc_result() } EthRequest::EvmSetBlockGasLimit(gas_limit) => { @@ -439,22 +2527,24 @@ impl EthApi { EthRequest::EvmMineDetailed(mine) => { self.evm_mine_detailed(mine.and_then(|p| p.params)).await.to_rpc_result() } - EthRequest::SetRpcUrl(url) => self.anvil_set_rpc_url(url).to_rpc_result(), + EthRequest::SetRpcUrl(url) => self.anvil_set_rpc_url(url).await.to_rpc_result(), EthRequest::EthSendUnsignedTransaction(tx) => { self.eth_send_unsigned_transaction(*tx).await.to_rpc_result() } - EthRequest::EnableTraces(_) => self.anvil_enable_traces().await.to_rpc_result(), EthRequest::EthNewFilter(filter) => self.new_filter(filter).await.to_rpc_result(), EthRequest::EthGetFilterChanges(id) => self.get_filter_changes(&id).await, EthRequest::EthNewBlockFilter(_) => self.new_block_filter().await.to_rpc_result(), - EthRequest::EthNewPendingTransactionFilter(_) => { - self.new_pending_transaction_filter().await.to_rpc_result() + EthRequest::EthNewPendingTransactionFilter(full) => { + self.new_pending_transaction_filter(full.unwrap_or(false)).await.to_rpc_result() } EthRequest::EthGetFilterLogs(id) => self.get_filter_logs(&id).await.to_rpc_result(), EthRequest::EthUninstallFilter(id) => self.uninstall_filter(&id).await.to_rpc_result(), EthRequest::TxPoolStatus(_) => self.txpool_status().await.to_rpc_result(), EthRequest::TxPoolInspect(_) => self.txpool_inspect().await.to_rpc_result(), EthRequest::TxPoolContent(_) => self.txpool_content().await.to_rpc_result(), + EthRequest::TxPoolContentFrom(from) => { + self.txpool_content_from(from).await.to_rpc_result() + } EthRequest::ErigonGetHeaderByNumber(num) => { self.erigon_get_header_by_number(num).await.to_rpc_result() } @@ -487,6 +2577,9 @@ impl EthApi { EthRequest::OtsGetTransactionBySenderAndNonce(address, nonce) => { self.ots_get_transaction_by_sender_and_nonce(address, nonce).await.to_rpc_result() } + EthRequest::EthGetTransactionBySenderAndNonce(sender, nonce) => { + self.transaction_by_sender_and_nonce(sender, nonce).await.to_rpc_result() + } EthRequest::OtsGetContractCreator(address) => { self.ots_get_contract_creator(address).await.to_rpc_result() } @@ -497,14 +2590,16 @@ impl EthApi { self.anvil_reorg(reorg_options).await.to_rpc_result() } EthRequest::Rollback(depth) => self.anvil_rollback(depth).await.to_rpc_result(), - EthRequest::WalletGetCapabilities(()) => self.get_capabilities().to_rpc_result(), - EthRequest::WalletSendTransaction(tx) => { - self.wallet_send_transaction(*tx).await.to_rpc_result() + EthRequest::SetFeeToken(user, token) => { + self.anvil_set_fee_token(user, token).await.to_rpc_result() } - EthRequest::AnvilAddCapability(addr) => self.anvil_add_capability(addr).to_rpc_result(), - EthRequest::AnvilSetExecutor(executor_pk) => { - self.anvil_set_executor(executor_pk).to_rpc_result() + EthRequest::SetValidatorFeeToken(validator, token) => { + self.anvil_set_validator_fee_token(validator, token).await.to_rpc_result() } + EthRequest::SetFeeAmmLiquidity(user_token, validator_token, amount) => self + .anvil_set_fee_amm_liquidity(user_token, validator_token, amount) + .await + .to_rpc_result(), }; if let ResponseResult::Error(err) = &response { @@ -516,25 +2611,14 @@ impl EthApi { response } - fn sign_request( - &self, - from: &Address, - request: TypedTransactionRequest, - ) -> Result { - match request { - TypedTransactionRequest::Deposit(_) => { - let nil_signature = Signature::from_scalars_and_parity( - B256::with_last_byte(1), - B256::with_last_byte(1), - false, - ); - return build_typed_transaction(request, nil_signature); - } + fn sign_request(&self, from: &Address, typed_tx: FoundryTypedTx) -> Result { + match typed_tx { + #[cfg(feature = "optimism")] + FoundryTypedTx::Deposit(_) => return Ok(typed_tx.into_impersonated()), _ => { for signer in self.signers.iter() { if signer.accounts().contains(from) { - let signature = signer.sign_transaction(request.clone(), from)?; - return build_typed_transaction(request, signature); + return signer.sign_transaction_from(from, typed_tx); } } } @@ -542,178 +2626,16 @@ impl EthApi { Err(BlockchainError::NoSignerAvailable) } - async fn block_request(&self, block_number: Option) -> Result { - let block_request = match block_number { - Some(BlockId::Number(BlockNumber::Pending)) => { - let pending_txs = self.pool.ready_transactions().collect(); - BlockRequest::Pending(pending_txs) - } - _ => { - let number = self.backend.ensure_block_number(block_number).await?; - BlockRequest::Number(number) - } - }; - Ok(block_request) - } - async fn inner_raw_transaction(&self, hash: B256) -> Result> { match self.pool.get_transaction(hash) { Some(tx) => Ok(Some(tx.transaction.encoded_2718().into())), None => match self.backend.transaction_by_hash(hash).await? { - Some(tx) => Ok(Some(tx.inner.inner.encoded_2718().into())), + Some(tx) => encode_rpc_transaction(&tx).map(Some), None => Ok(None), }, } } - /// Returns the current client version. - /// - /// Handler for ETH RPC call: `web3_clientVersion` - pub fn client_version(&self) -> Result { - node_info!("web3_clientVersion"); - Ok(CLIENT_VERSION.to_string()) - } - - /// Returns Keccak-256 (not the standardized SHA3-256) of the given data. - /// - /// Handler for ETH RPC call: `web3_sha3` - pub fn sha3(&self, bytes: Bytes) -> Result { - node_info!("web3_sha3"); - let hash = alloy_primitives::keccak256(bytes.as_ref()); - Ok(alloy_primitives::hex::encode_prefixed(&hash[..])) - } - - /// Returns protocol version encoded as a string (quotes are necessary). - /// - /// Handler for ETH RPC call: `eth_protocolVersion` - pub fn protocol_version(&self) -> Result { - node_info!("eth_protocolVersion"); - Ok(1) - } - - /// Returns the number of hashes per second that the node is mining with. - /// - /// Handler for ETH RPC call: `eth_hashrate` - pub fn hashrate(&self) -> Result { - node_info!("eth_hashrate"); - Ok(U256::ZERO) - } - - /// Returns the client coinbase address. - /// - /// Handler for ETH RPC call: `eth_coinbase` - pub fn author(&self) -> Result
{ - node_info!("eth_coinbase"); - Ok(self.backend.coinbase()) - } - - /// Returns true if client is actively mining new blocks. - /// - /// Handler for ETH RPC call: `eth_mining` - pub fn is_mining(&self) -> Result { - node_info!("eth_mining"); - Ok(self.is_mining) - } - - /// Returns the chain ID used for transaction signing at the - /// current best block. None is returned if not - /// available. - /// - /// Handler for ETH RPC call: `eth_chainId` - pub fn eth_chain_id(&self) -> Result> { - node_info!("eth_chainId"); - Ok(Some(self.backend.chain_id().to::())) - } - - /// Returns the same as `chain_id` - /// - /// Handler for ETH RPC call: `eth_networkId` - pub fn network_id(&self) -> Result> { - node_info!("eth_networkId"); - let chain_id = self.backend.chain_id().to::(); - Ok(Some(format!("{chain_id}"))) - } - - /// Returns true if client is actively listening for network connections. - /// - /// Handler for ETH RPC call: `net_listening` - pub fn net_listening(&self) -> Result { - node_info!("net_listening"); - Ok(self.net_listening) - } - - /// Returns the current gas price - fn eth_gas_price(&self) -> Result { - node_info!("eth_gasPrice"); - Ok(U256::from(self.gas_price())) - } - - /// Returns the current gas price - pub fn gas_price(&self) -> u128 { - if self.backend.is_eip1559() { - if self.backend.is_min_priority_fee_enforced() { - (self.backend.base_fee() as u128).saturating_add(self.lowest_suggestion_tip()) - } else { - self.backend.base_fee() as u128 - } - } else { - self.backend.fees().raw_gas_price() - } - } - - /// Returns the excess blob gas and current blob gas price - pub fn excess_blob_gas_and_price(&self) -> Result> { - Ok(self.backend.excess_blob_gas_and_price()) - } - - /// Returns a fee per gas that is an estimate of how much you can pay as a priority fee, or - /// 'tip', to get a transaction included in the current block. - /// - /// Handler for ETH RPC call: `eth_maxPriorityFeePerGas` - pub fn gas_max_priority_fee_per_gas(&self) -> Result { - self.max_priority_fee_per_gas() - } - - /// Returns the base fee per blob required to send a EIP-4844 tx. - /// - /// Handler for ETH RPC call: `eth_blobBaseFee` - pub fn blob_base_fee(&self) -> Result { - Ok(U256::from(self.backend.fees().base_fee_per_blob_gas())) - } - - /// Returns the block gas limit - pub fn gas_limit(&self) -> U256 { - U256::from(self.backend.gas_limit()) - } - - /// Returns the accounts list - /// - /// Handler for ETH RPC call: `eth_accounts` - pub fn accounts(&self) -> Result> { - node_info!("eth_accounts"); - let mut unique = HashSet::new(); - let mut accounts: Vec
= Vec::new(); - for signer in self.signers.iter() { - accounts.extend(signer.accounts().into_iter().filter(|acc| unique.insert(*acc))); - } - accounts.extend( - self.backend - .cheats() - .impersonated_accounts() - .into_iter() - .filter(|acc| unique.insert(*acc)), - ); - Ok(accounts.into_iter().collect()) - } - - /// Returns the number of most recent block. - /// - /// Handler for ETH RPC call: `eth_blockNumber` - pub fn block_number(&self) -> Result { - node_info!("eth_blockNumber"); - Ok(U256::from(self.backend.best_number())) - } - /// Returns balance of the given account. /// /// Handler for ETH RPC call: `eth_getBalance` @@ -739,7 +2661,7 @@ impl EthApi { &self, address: Address, block_number: Option, - ) -> Result { + ) -> Result { node_info!("eth_getAccount"); let block_request = self.block_request(block_number).await?; @@ -755,23 +2677,35 @@ impl EthApi { } /// Returns the account information including balance, nonce, code and storage + /// + /// Note: This isn't support by all providers pub async fn get_account_info( &self, address: Address, block_number: Option, ) -> Result { node_info!("eth_getAccountInfo"); - let account = self - .backend - .get_account_at_block(address, Some(self.block_request(block_number).await?)) - .await?; - let code = - self.backend.get_code(address, Some(self.block_request(block_number).await?)).await?; - Ok(alloy_rpc_types::eth::AccountInfo { - balance: account.balance, - nonce: account.nonce, - code, - }) + + let block_request = self.block_request(block_number).await?; + if let BlockRequest::Number(number) = block_request + && let Some(fork) = self.get_fork() + { + trace!(target: "node", "get_account_info: fork block {}, requested block {number}", fork.block_number()); + if block_number.is_some_and(|block| !block.is_latest()) + && fork.predates_fork_inclusive(number) + { + if fork.requires_account_info() { + return Ok(fork.get_account_info(address, number).await?); + } + let balance = fork.get_balance(address, number).map_err(BlockchainError::from); + let code = fork.get_code(address, number).map_err(BlockchainError::from); + let nonce = fork.get_nonce(address, number).map_err(BlockchainError::from); + let (balance, code, nonce) = try_join!(balance, code, nonce)?; + return Ok(alloy_rpc_types::eth::AccountInfo { balance, nonce, code }); + } + } + + self.backend.get_account_info_at_block(address, Some(block_request)).await } /// Returns content of the storage at given address. /// @@ -798,20 +2732,49 @@ impl EthApi { self.backend.storage_at(address, index, Some(block_request)).await } - /// Returns block with given hash. - /// - /// Handler for ETH RPC call: `eth_getBlockByHash` - pub async fn block_by_hash(&self, hash: B256) -> Result> { - node_info!("eth_getBlockByHash"); - self.backend.block_by_hash(hash).await - } + /// Returns storage values for multiple accounts and slots in a single call. + /// + /// Handler for ETH RPC call: `eth_getStorageValues` + pub async fn storage_values( + &self, + requests: HashMap>, + block_number: Option, + ) -> Result>> { + node_info!("eth_getStorageValues"); + + if requests.is_empty() { + return Err(RpcError::invalid_params("empty request").into()); + } + + let total_slots: usize = requests.values().map(|s| s.len()).sum(); + if total_slots > 1024 { + return Err(BlockchainError::RpcError(RpcError::invalid_params(format!( + "total slot count {total_slots} exceeds limit 1024" + )))); + } + + let block_request = self.block_request(block_number).await?; + + // check if the number predates the fork, if in fork mode + if let BlockRequest::Number(number) = block_request + && let Some(fork) = self.get_fork() + && fork.predates_fork(number) + { + let mut result: HashMap> = HashMap::default(); + for (address, slots) in requests { + let mut values = Vec::with_capacity(slots.len()); + for slot in &slots { + let val = fork + .storage_at(address, (*slot).into(), Some(BlockNumber::Number(number))) + .await?; + values.push(B256::from(val)); + } + result.insert(address, values); + } + return Ok(result); + } - /// Returns a _full_ block with given hash. - /// - /// Handler for ETH RPC call: `eth_getBlockByHash` - pub async fn block_by_hash_full(&self, hash: B256) -> Result> { - node_info!("eth_getBlockByHash"); - self.backend.block_by_hash_full(hash).await + self.backend.storage_values(requests, Some(block_request)).await } /// Returns block with given number. @@ -819,24 +2782,140 @@ impl EthApi { /// Handler for ETH RPC call: `eth_getBlockByNumber` pub async fn block_by_number(&self, number: BlockNumber) -> Result> { node_info!("eth_getBlockByNumber"); - if number == BlockNumber::Pending { - return Ok(Some(self.pending_block().await)); + if number.is_pending() { + return Ok(Some(self.pending_block().await?)); } self.backend.block_by_number(number).await } + /// Returns block header with given number. + /// + /// Handler for ETH RPC call: `eth_getHeaderByNumber` + pub async fn header_by_number( + &self, + number: BlockNumber, + ) -> Result>> { + node_info!("eth_getHeaderByNumber"); + if number.is_pending() { + let WithOtherFields { inner: block, other } = self.pending_block().await?.0; + return Ok(Some(WithOtherFields { inner: block.header, other })); + } + + Ok(self.backend.block_by_number(number).await?.map(|block| { + let WithOtherFields { inner: block, other } = block.0; + WithOtherFields { inner: block.header, other } + })) + } + /// Returns a _full_ block with given number /// /// Handler for ETH RPC call: `eth_getBlockByNumber` pub async fn block_by_number_full(&self, number: BlockNumber) -> Result> { node_info!("eth_getBlockByNumber"); - if number == BlockNumber::Pending { - return Ok(self.pending_block_full().await); + if number.is_pending() { + return self.pending_block_full().await; } self.backend.block_by_number_full(number).await } + /// Returns the EIP-7928 block access list for a block. + /// + /// Handler for ETH RPC call: `eth_getBlockAccessList` + pub async fn block_access_list(&self, block_id: BlockId) -> Result> { + node_info!("eth_getBlockAccessList"); + let block_request = self.block_request(Some(block_id)).await?; + let BlockRequest::Number(number) = block_request else { return Ok(None) }; + + if let Some(fork) = self.get_fork() + && fork.predates_fork_inclusive(number) + { + return Ok(fork.block_access_list(block_id).await?); + } + + let block_access_list = match block_id { + BlockId::Hash(hash) => self.backend.block_access_list_by_hash(hash.block_hash), + BlockId::Number(_) => self.backend.block_access_list_by_number(number), + }; + Ok(block_access_list + .map(|bal| serde_json::to_value(bal).expect("BAL serialization is infallible"))) + } + + /// Returns the EIP-7928 block access list for a block hash. + /// + /// Handler for ETH RPC call: `eth_getBlockAccessListByBlockHash` + pub async fn block_access_list_by_hash( + &self, + block_hash: B256, + ) -> Result> { + node_info!("eth_getBlockAccessListByBlockHash"); + if let Some(block_access_list) = self.backend.block_access_list_by_hash(block_hash) { + return Ok(Some( + serde_json::to_value(block_access_list).expect("BAL serialization is infallible"), + )); + } + + let Some(fork) = self.get_fork() else { return Ok(None) }; + + // Only blocks we know to be mined after the fork point are guaranteed to be unknown + // upstream. Anything else, including hashes we cannot resolve locally, is left to the fork. + if let Some(block) = self.backend.get_block_by_hash(block_hash) + && !fork.predates_fork_inclusive(block.header.number) + { + return Ok(None); + } + + Ok(fork.block_access_list_by_hash(block_hash).await?) + } + + /// Returns the EIP-7928 block access list for a block number. + /// + /// Handler for ETH RPC call: `eth_getBlockAccessListByBlockNumber` + pub async fn block_access_list_by_number( + &self, + block_number: BlockNumber, + ) -> Result> { + node_info!("eth_getBlockAccessListByBlockNumber"); + let block_request = self.block_request(Some(BlockId::Number(block_number))).await?; + let BlockRequest::Number(number) = block_request else { return Ok(None) }; + + if let Some(fork) = self.get_fork() + && fork.predates_fork_inclusive(number) + { + return Ok(fork.block_access_list_by_number(block_number).await?); + } + + Ok(self + .backend + .block_access_list_by_number(number) + .map(|bal| serde_json::to_value(bal).expect("BAL serialization is infallible"))) + } + + /// Returns the raw EIP-7928 block access list for a block. + /// + /// Handler for ETH RPC call: `eth_getBlockAccessListRaw` + pub async fn block_access_list_raw(&self, block_id: BlockId) -> Result> { + node_info!("eth_getBlockAccessListRaw"); + let block_request = self.block_request(Some(block_id)).await?; + let BlockRequest::Number(number) = block_request else { return Ok(None) }; + + if let Some(fork) = self.get_fork() + && fork.predates_fork_inclusive(number) + { + return Ok(fork.block_access_list_raw(block_id).await?); + } + + let block_access_list = match block_id { + BlockId::Hash(hash) => self.backend.block_access_list_by_hash(hash.block_hash), + BlockId::Number(_) => self.backend.block_access_list_by_number(number), + }; + Ok(block_access_list.map(|bal| { + let mut encoded = Vec::new(); + alloy_rlp::encode_list(&bal, &mut encoded); + encoded.into() + })) + } + /// Returns the number of transactions sent from given address at given time (block number). /// /// Also checks the pending transactions if `block_number` is @@ -852,18 +2931,38 @@ impl EthApi { self.get_transaction_count(address, block_number).await.map(U256::from) } - /// Returns the number of transactions in a block with given hash. - /// - /// Handler for ETH RPC call: `eth_getBlockTransactionCountByHash` - pub async fn block_transaction_count_by_hash(&self, hash: B256) -> Result> { - node_info!("eth_getBlockTransactionCountByHash"); - let block = self.backend.block_by_hash(hash).await?; - let txs = block.map(|b| match b.transactions() { - BlockTransactions::Full(txs) => U256::from(txs.len()), - BlockTransactions::Hashes(txs) => U256::from(txs.len()), - BlockTransactions::Uncle => U256::from(0), - }); - Ok(txs) + /// Returns a protocol or EIP-8130 channel nonce. + #[cfg(feature = "base")] + pub async fn transaction_count_with_key( + &self, + address: Address, + block_number: Option, + nonce_key: Option, + ) -> Result { + let Some(nonce_key) = nonce_key else { + return self.transaction_count(address, block_number).await; + }; + if nonce_key.is_zero() { + return self.transaction_count(address, block_number).await; + } + + let block_request = self.block_request(block_number).await?; + let timestamp = self.backend.block_request_timestamp(&block_request).await?; + self.backend.ensure_base_eip8130_active_at(timestamp)?; + if nonce_key == Eip8130Constants::NONCE_KEY_MAX { + return Err(RpcError::invalid_params( + "nonce_key NONCE_KEY_MAX selects the expiring-nonce channel which has no \ + per-channel counter", + ) + .into()); + } + let slot = NonceManagerStorage::nonce_slot(address, nonce_key) + .map_err(|error| RpcError::invalid_params(error.to_string()))?; + let word = self + .backend + .storage_at(NonceManagerStorage::ADDRESS, slot, Some(block_request)) + .await?; + Ok(Eip8130Nonce::decode_channel_nonce(word.into())) } /// Returns the number of transactions in a block with given block number. @@ -874,43 +2973,21 @@ impl EthApi { block_number: BlockNumber, ) -> Result> { node_info!("eth_getBlockTransactionCountByNumber"); - let block_request = self.block_request(Some(block_number.into())).await?; - if let BlockRequest::Pending(txs) = block_request { - let block = self.backend.pending_block(txs).await; - return Ok(Some(U256::from(block.transactions.len()))); + if block_number.is_pending() { + let txs = self.pool.ready_transactions().collect(); + let block = self.backend.pending_block(txs).await?; + return Ok(Some(U256::from(block.block.body.transactions.len()))); } + let block = self.backend.block_by_number(block_number).await?; let txs = block.map(|b| match b.transactions() { BlockTransactions::Full(txs) => U256::from(txs.len()), BlockTransactions::Hashes(txs) => U256::from(txs.len()), - BlockTransactions::Uncle => U256::from(0), + BlockTransactions::Uncle => U256::ZERO, }); Ok(txs) } - /// Returns the number of uncles in a block with given hash. - /// - /// Handler for ETH RPC call: `eth_getUncleCountByBlockHash` - pub async fn block_uncles_count_by_hash(&self, hash: B256) -> Result { - node_info!("eth_getUncleCountByBlockHash"); - let block = - self.backend.block_by_hash(hash).await?.ok_or(BlockchainError::BlockNotFound)?; - Ok(U256::from(block.uncles.len())) - } - - /// Returns the number of uncles in a block with given block number. - /// - /// Handler for ETH RPC call: `eth_getUncleCountByBlockNumber` - pub async fn block_uncles_count_by_number(&self, block_number: BlockNumber) -> Result { - node_info!("eth_getUncleCountByBlockNumber"); - let block = self - .backend - .block_by_number(block_number) - .await? - .ok_or(BlockchainError::BlockNotFound)?; - Ok(U256::from(block.uncles.len())) - } - /// Returns the code at given address at given time (block number). /// /// Handler for ETH RPC call: `eth_getCode` @@ -953,76 +3030,23 @@ impl EthApi { Ok(proof) } - /// Signs data via [EIP-712](https://github.com/ethereum/EIPs/blob/master/EIPS/eip-712.md). - /// - /// Handler for ETH RPC call: `eth_signTypedData` - pub async fn sign_typed_data( - &self, - _address: Address, - _data: serde_json::Value, - ) -> Result { - node_info!("eth_signTypedData"); - Err(BlockchainError::RpcUnimplemented) - } - - /// Signs data via [EIP-712](https://github.com/ethereum/EIPs/blob/master/EIPS/eip-712.md). - /// - /// Handler for ETH RPC call: `eth_signTypedData_v3` - pub async fn sign_typed_data_v3( - &self, - _address: Address, - _data: serde_json::Value, - ) -> Result { - node_info!("eth_signTypedData_v3"); - Err(BlockchainError::RpcUnimplemented) - } - - /// Signs data via [EIP-712](https://github.com/ethereum/EIPs/blob/master/EIPS/eip-712.md), and includes full support of arrays and recursive data structures. - /// - /// Handler for ETH RPC call: `eth_signTypedData_v4` - pub async fn sign_typed_data_v4(&self, address: Address, data: &TypedData) -> Result { - node_info!("eth_signTypedData_v4"); - let signer = self.get_signer(address).ok_or(BlockchainError::NoSignerAvailable)?; - let signature = signer.sign_typed_data(address, data).await?; - let signature = alloy_primitives::hex::encode(signature.as_bytes()); - Ok(format!("0x{signature}")) - } - - /// The sign method calculates an Ethereum specific signature - /// - /// Handler for ETH RPC call: `eth_sign` - pub async fn sign(&self, address: Address, content: impl AsRef<[u8]>) -> Result { - node_info!("eth_sign"); - let signer = self.get_signer(address).ok_or(BlockchainError::NoSignerAvailable)?; - let signature = - alloy_primitives::hex::encode(signer.sign(address, content.as_ref()).await?.as_bytes()); - Ok(format!("0x{signature}")) - } - /// Signs a transaction /// /// Handler for ETH RPC call: `eth_signTransaction` pub async fn sign_transaction( &self, - mut request: WithOtherFields, + request: WithOtherFields, ) -> Result { node_info!("eth_signTransaction"); + let request = self.parse_transaction_request(request)?; - let from = request.from.map(Ok).unwrap_or_else(|| { + let from = request.from().map(Ok).unwrap_or_else(|| { self.accounts()?.first().copied().ok_or(BlockchainError::NoSignerAvailable) })?; - let (nonce, _) = self.request_nonce(&request, from).await?; - - if request.gas.is_none() { - // estimate if not provided - if let Ok(gas) = self.estimate_gas(request.clone(), None, EvmOverrides::default()).await - { - request.gas = Some(gas.to()); - } - } + let nonce = self.request_nonce_for_transaction(&request, from).await?; - let request = self.build_typed_tx_request(request, nonce)?; + let request = self.build_tx_request(request, nonce).await?; let signed_transaction = self.sign_request(&from, request)?.encoded_2718(); Ok(alloy_primitives::hex::encode_prefixed(signed_transaction)) @@ -1033,142 +3057,357 @@ impl EthApi { /// Handler for ETH RPC call: `eth_sendTransaction` pub async fn send_transaction( &self, - mut request: WithOtherFields, + request: WithOtherFields, ) -> Result { node_info!("eth_sendTransaction"); + let request = self.parse_transaction_request(request)?; - let from = request.from.map(Ok).unwrap_or_else(|| { + let from = request.from().map(Ok).unwrap_or_else(|| { self.accounts()?.first().copied().ok_or(BlockchainError::NoSignerAvailable) })?; - let (nonce, on_chain_nonce) = self.request_nonce(&request, from).await?; - - if request.gas.is_none() { - // estimate if not provided - if let Ok(gas) = self.estimate_gas(request.clone(), None, EvmOverrides::default()).await - { - request.gas = Some(gas.to()); - } - } + // Keep nonce selection and pool insertion atomic with respect to other requests from + // this sender. Explicit nonces retain the pool's normal replacement behavior. + let _nonce_guard = if request.nonce().is_none() { + let lock = self.nonce_locks.write().entry(from).or_default().clone(); + Some(lock.lock_owned().await) + } else { + None + }; + let nonce = self.request_nonce_for_transaction(&request, from).await?; - let request = self.build_typed_tx_request(request, nonce)?; + let typed_tx = self.build_tx_request(request, nonce).await?; // if the sender is currently impersonated we need to "bypass" signing let pending_transaction = if self.is_impersonated(from) { - let bypass_signature = self.impersonated_signature(&request); - let transaction = sign::build_typed_transaction(request, bypass_signature)?; + let transaction = typed_tx.into_impersonated(); self.ensure_typed_transaction_supported(&transaction)?; trace!(target : "node", ?from, "eth_sendTransaction: impersonating"); PendingTransaction::with_impersonated(transaction, from) } else { - let transaction = self.sign_request(&from, request)?; + let transaction = self.sign_request(&from, typed_tx)?; self.ensure_typed_transaction_supported(&transaction)?; PendingTransaction::new(transaction)? }; - // pre-validate - self.backend.validate_pool_transaction(&pending_transaction).await?; + self.add_pending_transaction(pending_transaction).await + } - let requires = required_marker(nonce, on_chain_nonce, from); - let provides = vec![to_marker(nonce, from)]; - debug_assert!(requires != provides); + /// Resends a pending transaction with an updated gas price or gas limit. + /// + /// Handler for ETH RPC call: `eth_resend` + pub async fn resend_transaction( + &self, + request: WithOtherFields, + gas_price: Option, + gas_limit: Option, + ) -> Result { + node_info!("eth_resend"); + let mut request = self.parse_transaction_request(request)?; + + let from = request.from().map(Ok).unwrap_or_else(|| { + self.accounts()?.first().copied().ok_or(BlockchainError::NoSignerAvailable) + })?; + let nonce = request.nonce().ok_or_else(|| { + BlockchainError::InvalidTransactionRequest( + "missing transaction nonce in transaction spec".to_string(), + ) + })?; + + if !self.pool.contains_sender_nonce(from, nonce) { + return Err(BlockchainError::TransactionNotFound); + } - self.add_pending_transaction(pending_transaction, requires, provides) + if let Some(gas_price) = gas_price.filter(|gas_price| !gas_price.is_zero()) { + request.set_gas_price(gas_price.saturating_to()); + } + + if let Some(gas_limit) = gas_limit.filter(|gas_limit| *gas_limit != U64::ZERO) { + request.set_gas_limit(gas_limit.to()); + } + + let typed_tx = self.build_tx_request(request, nonce).await?; + + let pending_transaction = if self.is_impersonated(from) { + let transaction = typed_tx.into_impersonated(); + self.ensure_typed_transaction_supported(&transaction)?; + trace!(target : "node", ?from, "eth_resend: impersonating"); + PendingTransaction::with_impersonated(transaction, from) + } else { + let transaction = self.sign_request(&from, typed_tx)?; + self.ensure_typed_transaction_supported(&transaction)?; + PendingTransaction::new(transaction)? + }; + + self.add_pending_transaction(pending_transaction).await } /// Waits for a transaction to be included in a block and returns its receipt (no timeout). - async fn await_transaction_inclusion(&self, hash: TxHash) -> Result { + async fn await_transaction_inclusion(&self, hash: TxHash) -> Result { let mut stream = self.new_block_notifications(); // Check if the transaction is already included before listening for new blocks. if let Some(receipt) = self.backend.transaction_receipt(hash).await? { return Ok(receipt); } while let Some(notification) = stream.next().await { - if let Some(block) = self.backend.get_block_by_hash(notification.hash) - && block.transactions.iter().any(|tx| tx.hash() == hash) + if let Some(new_block) = notification.as_new_block() + && let Some(block) = self.backend.get_block_by_hash(new_block.hash) + && block.body.transactions.iter().any(|tx| tx.hash() == hash) && let Some(receipt) = self.backend.transaction_receipt(hash).await? { return Ok(receipt); } } - Err(BlockchainError::Message("Failed to await transaction inclusion".to_string())) - } + Err(BlockchainError::Message("Failed to await transaction inclusion".to_string())) + } + + fn transaction_confirmation_timeout(timeout_ms: Option) -> Duration { + const TIMEOUT_DURATION: Duration = Duration::from_secs(30); + timeout_ms + .filter(|timeout_ms| *timeout_ms > 0) + .map(Duration::from_millis) + .map(|timeout| timeout.min(TIMEOUT_DURATION)) + .unwrap_or(TIMEOUT_DURATION) + } + + /// Waits for a transaction to be included in a block and returns its receipt, with timeout. + async fn check_transaction_inclusion( + &self, + hash: TxHash, + timeout_ms: Option, + ) -> Result { + let timeout_duration = Self::transaction_confirmation_timeout(timeout_ms); + tokio::time::timeout(timeout_duration, self.await_transaction_inclusion(hash)) + .await + .unwrap_or_else(|_elapsed| { + Err(BlockchainError::TransactionConfirmationTimeout { + hash, + duration: timeout_duration, + }) + }) + } + + /// Sends a transaction and waits for receipt + /// + /// Handler for ETH RPC call: `eth_sendTransactionSync` + pub async fn send_transaction_sync( + &self, + request: WithOtherFields, + ) -> Result { + node_info!("eth_sendTransactionSync"); + let hash = self.send_transaction(request).await?; + + let receipt = self.check_transaction_inclusion(hash, None).await?; + + Ok(receipt) + } + + /// Sends signed transaction, returning its hash. + /// + /// Handler for ETH RPC call: `eth_sendRawTransaction` + pub async fn send_raw_transaction(&self, tx: Bytes) -> Result { + node_info!("eth_sendRawTransaction"); + if tx.is_empty() { + return Err(BlockchainError::EmptyRawTransactionData); + } + + // Built-in Tempo fee payer: raw transactions submitted in the fee payer service encoding + // or carrying the sponsorship placeholder ask the node to sponsor them before submission + // (the sign-and-relay mode of the fee payer service contract). + let service_encoded = if self.backend.is_tempo() { + normalize_fee_payer_service_encoding(tx.as_ref()) + } else { + None + }; + let raw = service_encoded.map(Bytes::from).unwrap_or(tx); + + let transaction = if raw.first() == Some(&EIP4844_TX_TYPE_ID) { + // Pooled EIP-4844 decoding uses large stack frames for inline blobs. Isolate it from + // the already-large RPC dispatcher without increasing every worker's stack. + let raw = raw.clone(); + tokio::task::spawn_blocking(move || FoundryTxEnvelope::decode_2718(&mut raw.as_ref())) + .await + .map_err(|_| { + BlockchainError::Internal("transaction decoding task panicked".into()) + })? + } else { + FoundryTxEnvelope::decode_2718(&mut raw.as_ref()) + } + .map_err(|_| BlockchainError::FailedToDecodeSignedTransaction)?; + + self.ensure_typed_transaction_supported(&transaction)?; + + let transaction = match transaction { + FoundryTxEnvelope::Tempo(aa_tx) + if self.backend.is_tempo() + && aa_tx.tx().fee_payer_signature == Some(FEE_PAYER_SIGNATURE_MARKER) => + { + FoundryTxEnvelope::Tempo(self.sponsor_sign_tempo_transaction(aa_tx).await?) + } + transaction => transaction, + }; + + if self.backend.is_tempo() && TempoHardfork::from(self.backend.hardfork()).is_t5() { + let classification = classify_payment_lane(raw.as_ref()); + trace!(target: "node", tx = ?transaction.hash(), ?classification, "classified transaction lane"); + } + + #[cfg(feature = "base")] + let is_eip8130 = transaction.is_eip8130(); + let pending_transaction = PendingTransaction::new(transaction).map_err(|error| { + #[cfg(feature = "base")] + if is_eip8130 { + return BlockchainError::Eip8130TransactionRejected(error.to_string()); + } + BlockchainError::RecoveryError(error) + })?; - /// Waits for a transaction to be included in a block and returns its receipt, with timeout. - async fn check_transaction_inclusion(&self, hash: TxHash) -> Result { - const TIMEOUT_DURATION: Duration = Duration::from_secs(30); - tokio::time::timeout(TIMEOUT_DURATION, self.await_transaction_inclusion(hash)) - .await - .unwrap_or_else(|_elapsed| { - Err(BlockchainError::TransactionConfirmationTimeout { - hash, - duration: TIMEOUT_DURATION, - }) - }) + self.add_pending_transaction(pending_transaction).await } - /// Sends a transaction and waits for receipt + /// Sends a signed transaction with an ignored transaction condition. /// - /// Handler for ETH RPC call: `eth_sendTransactionSync` - pub async fn send_transaction_sync( + /// Handler for ETH RPC call: `eth_sendRawTransactionConditional` + pub async fn send_raw_transaction_conditional( &self, - request: WithOtherFields, - ) -> Result { - node_info!("eth_sendTransactionSync"); - let hash = self.send_transaction(request).await?; - - let receipt = self.check_transaction_inclusion(hash).await?; - - Ok(ReceiptResponse::from(receipt)) + tx: Bytes, + _condition: TransactionConditional, + ) -> Result { + node_info!("eth_sendRawTransactionConditional"); + self.send_raw_transaction(tx).await } - /// Sends signed transaction, returning its hash. - /// - /// Handler for ETH RPC call: `eth_sendRawTransaction` - pub async fn send_raw_transaction(&self, tx: Bytes) -> Result { - node_info!("eth_sendRawTransaction"); + /// Classifies a raw transaction with the active Anvil Tempo/T5 payment-lane classifier. + pub fn anvil_classify_transaction(&self, tx: Bytes) -> Result { + node_info!("anvil_classifyTransaction"); let mut data = tx.as_ref(); if data.is_empty() { return Err(BlockchainError::EmptyRawTransactionData); } - let transaction = TypedTransaction::decode_2718(&mut data) + FoundryTxEnvelope::decode_2718(&mut data) .map_err(|_| BlockchainError::FailedToDecodeSignedTransaction)?; - self.ensure_typed_transaction_supported(&transaction)?; - - let pending_transaction = PendingTransaction::new(transaction)?; - - // pre-validate - self.backend.validate_pool_transaction(&pending_transaction).await?; + Ok(self.classify_transaction_lane(tx.as_ref())) + } - let on_chain_nonce = self.backend.current_nonce(*pending_transaction.sender()).await?; - let from = *pending_transaction.sender(); - let nonce = pending_transaction.transaction.nonce(); - let requires = required_marker(nonce, on_chain_nonce, from); + fn classify_transaction_lane(&self, raw: &[u8]) -> PaymentLaneClassification { + if !self.backend.is_tempo() { + return PaymentLaneClassification::general(PaymentLaneReason::NotTempo); + } - let priority = self.transaction_priority(&pending_transaction.transaction); - let pool_transaction = PoolTransaction { - requires, - provides: vec![to_marker(nonce, *pending_transaction.sender())], - pending_transaction, - priority, - }; + if !TempoHardfork::from(self.backend.hardfork()).is_t5() { + return PaymentLaneClassification::general(PaymentLaneReason::T5NotActive); + } - let tx = self.pool.add_transaction(pool_transaction)?; - trace!(target: "node", "Added transaction: [{:?}] sender={:?}", tx.hash(), from); - Ok(*tx.hash()) + classify_payment_lane(raw) } /// Sends signed transaction, returning its receipt. /// /// Handler for ETH RPC call: `eth_sendRawTransactionSync` - pub async fn send_raw_transaction_sync(&self, tx: Bytes) -> Result { + pub async fn send_raw_transaction_sync( + &self, + tx: Bytes, + timeout_ms: Option, + ) -> Result { node_info!("eth_sendRawTransactionSync"); let hash = self.send_raw_transaction(tx).await?; - let receipt = self.check_transaction_inclusion(hash).await?; + let receipt = self.check_transaction_inclusion(hash, timeout_ms).await?; + + Ok(receipt) + } + + /// Signs a raw Tempo transaction as the node's fee payer (sponsor) and returns the fully + /// signed raw transaction without broadcasting it. + /// + /// This is the sign-only mode of the Tempo fee payer service contract, so + /// `cast send --sponsor-url` and the tempo SDK relay transport can point directly at this + /// node. Only available on Tempo networks. + /// + /// Handler for ETH RPC call: `eth_signRawTransaction` + pub async fn sign_raw_transaction(&self, tx: Bytes) -> Result { + node_info!("eth_signRawTransaction"); + if !self.backend.is_tempo() { + return Err(BlockchainError::RpcUnimplemented); + } + + if tx.is_empty() { + return Err(BlockchainError::EmptyRawTransactionData); + } + + // Fee payer service clients submit the request in the service encoding, which carries a + // `0x00` placeholder in the fee payer signature field. + let service_encoded = normalize_fee_payer_service_encoding(tx.as_ref()); + let mut data = service_encoded.as_deref().unwrap_or(tx.as_ref()); + + let transaction = FoundryTxEnvelope::decode_2718(&mut data) + .map_err(|_| BlockchainError::FailedToDecodeSignedTransaction)?; + + let FoundryTxEnvelope::Tempo(aa_tx) = transaction else { + return Err(RpcError::invalid_params( + "only Tempo (0x76) transactions can be fee-payer signed", + ) + .into()); + }; + + let signed = self.sponsor_sign_tempo_transaction(aa_tx).await?; + Ok(FoundryTxEnvelope::Tempo(signed).encoded_2718().into()) + } + + /// Fills the sponsor-owned fields of a sender-signed Tempo AA transaction. + /// + /// Mirrors the hosted Tempo fee payer service: the node's fee payer account selects the fee + /// token when the sender left it open and signs the fee payer digest. Only the fee token and + /// fee payer signature are touched, so the sender signature stays valid. + async fn sponsor_sign_tempo_transaction(&self, tx: AASigned) -> Result { + // The sender must have signed in the sponsored state (fee payer placeholder set): the + // sender signature commits to whether a fee payer is present, so sponsoring a + // transaction signed without the placeholder would invalidate it. + match tx.tx().fee_payer_signature { + Some(FEE_PAYER_SIGNATURE_MARKER) => {} + Some(_) => { + return Err( + RpcError::invalid_params("transaction is already fee-payer signed").into() + ); + } + None => { + return Err(RpcError::invalid_params( + "transaction does not request sponsorship; sign it with the fee payer \ + signature placeholder", + ) + .into()); + } + } + + let sender = tx.recover_signer().map_err(|_| { + BlockchainError::RpcError(RpcError::invalid_params( + "transaction must be signed by the sender before fee-payer signing", + )) + })?; + + let Some(sponsor) = self.backend.tempo_fee_payer().await else { + return Err(RpcError::invalid_params("no Tempo fee payer account available").into()); + }; + if sponsor == sender { + return Err(RpcError::invalid_params(format!( + "Tempo fee payer {sponsor} must not equal the transaction sender" + )) + .into()); + } + let signer = self.get_signer(sponsor).ok_or(BlockchainError::NoSignerAvailable)?; + + let (mut tx, sender_signature, _) = tx.into_parts(); + // The fee payer digest commits to the fee token, so it must be resolved first. The + // service encoding omits the sender's fee token preference, letting the sponsor pick the + // token it pays with. + if tx.fee_token.is_none() { + tx.fee_token = Some(self.backend.tempo_user_fee_token(sponsor).await?); + } + let digest = tx.fee_payer_signature_hash(sender); + tx.fee_payer_signature = Some(signer.sign_hash(sponsor, digest).await?); - Ok(ReceiptResponse::from(receipt)) + Ok(tx.into_signed(sender_signature)) } /// Call contract, returning the output data. @@ -1192,7 +3431,17 @@ impl EthApi { "not available on past forked blocks".to_string(), )); } - return Ok(fork.call(&request, Some(number.into())).await?); + return Ok(fork.call_raw(&request, Some(number.into())).await?); + } + + #[cfg(feature = "base")] + if self.backend.is_base() + && serde_json::to_value(&request) + .and_then(serde_json::from_value::) + .is_ok_and(|request| request.as_eip8130().is_some()) + { + let timestamp = self.backend.block_request_timestamp(&block_request).await?; + self.backend.ensure_base_eip8130_active_at(timestamp)?; } let fees = FeeDetails::new( @@ -1214,25 +3463,165 @@ impl EthApi { .await } + pub async fn call_many( + &self, + bundles: Vec>>, + state_context: Option, + state_override: Option, + ) -> Result>> { + node_info!("eth_callMany"); + let StateContext { transaction_index, block_number } = state_context.unwrap_or_default(); + if transaction_index.is_some_and(|index| index.index().is_some()) { + return Err(BlockchainError::RpcError(RpcError::invalid_params( + "transactionIndex is not supported for eth_callMany yet".to_string(), + ))); + } + + let block_request = self.block_request(block_number).await?; + if let BlockRequest::Number(number) = block_request + && let Some(fork) = self.get_fork() + && fork.predates_fork(number) + { + return Ok(fork + .call_many( + bundles, + Some(StateContext { transaction_index, block_number: Some(number.into()) }), + state_override, + ) + .await?); + } + + self.on_blocking_task(|this| async move { + this.backend.call_many(bundles, Some(block_request), state_override).await + }) + .await + } + + /// Simulates a bundle of signed transactions against the requested state. + /// + /// Handler for ETH RPC call: `eth_callBundle`. + pub async fn call_bundle(&self, bundle: EthCallBundle) -> Result { + node_info!("eth_callBundle"); + if bundle.txs.is_empty() { + return Err(BlockchainError::RpcError(RpcError::invalid_params( + "bundle missing txs".to_string(), + ))); + } + if bundle.block_number == 0 { + return Err(BlockchainError::RpcError(RpcError::invalid_params( + "bundle missing blockNumber".to_string(), + ))); + } + + let block_request = self.block_request(Some(bundle.state_block_number.into())).await?; + if let BlockRequest::Number(number) = block_request + && let Some(fork) = self.get_fork() + && fork.predates_fork(number) + { + return Ok(fork.call_bundle(bundle).await?); + } + + let transactions = bundle + .txs + .iter() + .map(|raw| { + let mut data = raw.as_ref(); + if data.is_empty() { + return Err(BlockchainError::EmptyRawTransactionData); + } + let transaction = FoundryTxEnvelope::decode_2718(&mut data) + .map_err(|_| BlockchainError::FailedToDecodeSignedTransaction)?; + self.ensure_typed_transaction_supported(&transaction)?; + PendingTransaction::new(transaction).map_err(Into::into) + }) + .collect::>>()?; + + self.on_blocking_task(|this| async move { + this.backend.call_bundle(bundle, transactions, Some(block_request)).await + }) + .await + } + pub async fn simulate_v1( &self, request: SimulatePayload, block_number: Option, ) -> Result>> { + self.simulate_v1_raw(preserve_simulation_request_fields(request), block_number).await + } + + pub(crate) async fn simulate_v1_raw( + &self, + mut request: SimulatePayload>, + block_number: Option, + ) -> Result>> { + const DEFAULT_BLOCK_INTERVAL_SECS: u64 = 12; + node_info!("eth_simulateV1"); - let block_request = self.block_request(block_number).await?; + if request.block_state_calls.is_empty() { + return Err(BlockchainError::RpcError(RpcError::invalid_params("empty input"))); + } + if request.block_state_calls.len() > MAX_SIMULATE_BLOCKS as usize { + return Err(BlockchainError::RpcError(RpcError { + code: ErrorCode::ServerError(-38026), + message: "too many blocks".into(), + data: None, + })); + } + let block_id = block_number; + let block_request = + self.block_request(block_number).await.map_err(|error| match error { + BlockchainError::BlockOutOfRange(_, _) | BlockchainError::BlockNotFound => { + BlockchainError::RpcError(RpcError { + code: ErrorCode::ServerError(-32000), + message: "header not found".into(), + data: None, + }) + } + error => error, + })?; + let block_interval = self.backend.time().block_timestamp_interval().unwrap_or_else(|| { + self.miner + .block_interval() + .map(|duration| { + duration + .as_secs() + .saturating_add(u64::from(duration.subsec_nanos() != 0)) + .max(1) + }) + .unwrap_or(DEFAULT_BLOCK_INTERVAL_SECS) + }); + // check if the number predates the fork, if in fork mode if let BlockRequest::Number(number) = block_request && let Some(fork) = self.get_fork() && fork.predates_fork(number) { - return Ok(fork.simulate_v1(&request, Some(number.into())).await?); + let block_id = match block_id { + Some(BlockId::Hash(hash)) => BlockId::Hash(hash), + _ => number.into(), + }; + let base_block = fork.fetch_block(block_id).await?.ok_or_else(|| { + BlockchainError::RpcError(RpcError { + code: ErrorCode::ServerError(-32000), + message: "header not found".into(), + data: None, + }) + })?; + request.block_state_calls = sanitize_simulation_blocks( + request.block_state_calls, + base_block.header.number(), + base_block.header.timestamp(), + block_interval, + )?; + return Ok(fork.simulate_v1(&request, Some(base_block.header.hash.into())).await?); } // this can be blocking for a bit, especially in forking mode // self.on_blocking_task(|this| async move { - let simulated_blocks = this.backend.simulate(request, Some(block_request)).await?; + let simulated_blocks = + this.backend.simulate_raw(request, Some(block_request), block_interval).await?; trace!(target : "node", "Simulate status {:?}", simulated_blocks); Ok(simulated_blocks) @@ -1255,8 +3644,9 @@ impl EthApi { /// Handler for ETH RPC call: `eth_createAccessList` pub async fn create_access_list( &self, - mut request: WithOtherFields, + request: WithOtherFields, block_number: Option, + state_override: Option, ) -> Result { node_info!("eth_createAccessList"); let block_request = self.block_request(block_number).await?; @@ -1265,37 +3655,64 @@ impl EthApi { && let Some(fork) = self.get_fork() && fork.predates_fork(number) { - return Ok(fork.create_access_list(&request, Some(number.into())).await?); + if state_override.is_some() { + return Err(BlockchainError::EvmOverrideError( + "not available on past forked blocks".to_string(), + )); + } + return Ok(fork.create_access_list_raw(&request, Some(number.into())).await?); + } + let typed_request = self.parse_transaction_request(request.clone())?; + + #[cfg(feature = "base")] + if self.backend.is_base() + && serde_json::to_value(&request) + .and_then(serde_json::from_value::) + .is_ok_and(|request| request.as_eip8130().is_some()) + { + let timestamp = self.backend.block_request_timestamp(&block_request).await?; + self.backend.ensure_base_eip8130_active_at(timestamp)?; + return Err(BlockchainError::InvalidTransactionRequest( + "eth_createAccessList does not support EIP-8130 transaction requests".to_string(), + )); } self.backend - .with_database_at(Some(block_request), |state, block_env| { - let (exit, out, _, access_list) = self.backend.build_access_list_with_state( - &state, - request.clone(), - FeeDetails::zero(), - block_env.clone(), - )?; - ensure_return_ok(exit, &out)?; - - // execute again but with access list set - request.access_list = Some(access_list.clone()); + .with_database_at_and_context(Some(block_request), |state, block_env, monad_context| { + let mut cache_db = CacheDB::new(state); + if let Some(state_override) = state_override { + apply_state_overrides(state_override.into_iter().collect(), &mut cache_db)?; + } - let (exit, out, gas_used, _) = self.backend.call_with_state( - &state, - request.clone(), + let (_, _, _, access_list) = + self.backend.build_access_list_with_state_and_context( + &cache_db, + request.clone(), + FeeDetails::zero(), + block_env.clone(), + monad_context.clone(), + )?; + + // Re-execute with the access list applied to get the post-AL gas usage. + // EVM failures (including reverts) are surfaced in the result's `error` + // field per the execution-apis `eth_createAccessList` spec, so callers + // can still inspect the traced slots when execution fails. + let (exit, _, gas_used, _) = self.backend.call_with_state_typed_access_list( + &cache_db, + typed_request, FeeDetails::zero(), block_env, + access_list.clone(), + monad_context, )?; - ensure_return_ok(exit, &out)?; Ok(AccessListResult { - access_list: AccessList(access_list.0), + access_list, gas_used: U256::from(gas_used), - error: None, + error: execution_error(exit), }) }) - .await? + .await } /// Estimate gas needed for execution of given contract. @@ -1309,306 +3726,230 @@ impl EthApi { overrides: EvmOverrides, ) -> Result { node_info!("eth_estimateGas"); - self.do_estimate_gas( - request, - block_number.or_else(|| Some(BlockNumber::Pending.into())), - overrides, - ) - .await - .map(U256::from) - } - - /// Handler for RPC call: `anvil_getBlobByHash` - pub fn anvil_get_blob_by_versioned_hash( - &self, - hash: B256, - ) -> Result> { - node_info!("anvil_getBlobByHash"); - Ok(self.backend.get_blob_by_versioned_hash(hash)?) - } - - /// Handler for RPC call: `anvil_getBlobsByTransactionHash` - pub fn anvil_get_blob_by_tx_hash(&self, hash: B256) -> Result>> { - node_info!("anvil_getBlobsByTransactionHash"); - Ok(self.backend.get_blob_by_tx_hash(hash)?) - } - - /// Get transaction by its hash. - /// - /// This will check the storage for a matching transaction, if no transaction exists in storage - /// this will also scan the mempool for a matching pending transaction - /// - /// Handler for ETH RPC call: `eth_getTransactionByHash` - pub async fn transaction_by_hash(&self, hash: B256) -> Result> { - node_info!("eth_getTransactionByHash"); - let mut tx = self.pool.get_transaction(hash).map(|pending| { - let from = *pending.sender(); - let tx = transaction_build( - Some(*pending.hash()), - pending.transaction, - None, - None, - Some(self.backend.base_fee()), - ); - - let WithOtherFields { inner: mut tx, other } = tx.0; - // we set the from field here explicitly to the set sender of the pending transaction, - // in case the transaction is impersonated. - tx.inner = Recovered::new_unchecked(tx.inner.into_inner(), from); - - AnyRpcTransaction(WithOtherFields { inner: tx, other }) - }); - if tx.is_none() { - tx = self.backend.transaction_by_hash(hash).await? - } - - Ok(tx) + self.do_estimate_gas(request, block_number.or_else(|| Some(BlockId::pending())), overrides) + .await + .map(U256::from) } - /// Returns transaction at given block hash and index. + /// Fills a transaction request with default values for missing fields. /// - /// Handler for ETH RPC call: `eth_getTransactionByBlockHashAndIndex` - pub async fn transaction_by_block_hash_and_index( - &self, - hash: B256, - index: Index, - ) -> Result> { - node_info!("eth_getTransactionByBlockHashAndIndex"); - self.backend.transaction_by_block_hash_and_index(hash, index).await - } - - /// Returns transaction by given block number and index. + /// This method populates missing transaction fields like nonce, gas limit, + /// chain ID, and fee parameters with appropriate defaults. /// - /// Handler for ETH RPC call: `eth_getTransactionByBlockNumberAndIndex` - pub async fn transaction_by_block_number_and_index( + /// Handler for ETH RPC call: `eth_fillTransaction` + pub async fn fill_transaction( &self, - block: BlockNumber, - idx: Index, - ) -> Result> { - node_info!("eth_getTransactionByBlockNumberAndIndex"); - self.backend.transaction_by_block_number_and_index(block, idx).await - } - - /// Returns transaction receipt by transaction hash. - /// - /// Handler for ETH RPC call: `eth_getTransactionReceipt` - pub async fn transaction_receipt(&self, hash: B256) -> Result> { - node_info!("eth_getTransactionReceipt"); - self.backend.transaction_receipt(hash).await - } - - /// Returns block receipts by block number. - /// - /// Handler for ETH RPC call: `eth_getBlockReceipts` - pub async fn block_receipts(&self, number: BlockId) -> Result>> { - node_info!("eth_getBlockReceipts"); - self.backend.block_receipts(number).await - } + request: WithOtherFields, + ) -> Result> { + node_info!("eth_fillTransaction"); + let mut request = self.parse_transaction_request(request)?; - /// Returns an uncles at given block and index. - /// - /// Handler for ETH RPC call: `eth_getUncleByBlockHashAndIndex` - pub async fn uncle_by_block_hash_and_index( - &self, - block_hash: B256, - idx: Index, - ) -> Result> { - node_info!("eth_getUncleByBlockHashAndIndex"); - let number = - self.backend.ensure_block_number(Some(BlockId::Hash(block_hash.into()))).await?; - if let Some(fork) = self.get_fork() - && fork.predates_fork_inclusive(number) - { - return Ok(fork.uncle_by_block_hash_and_index(block_hash, idx.into()).await?); - } - // It's impossible to have uncles outside of fork mode - Ok(None) - } + let from = match request.from() { + Some(from) => from, + None => self.accounts()?.first().copied().ok_or(BlockchainError::NoSignerAvailable)?, + }; - /// Returns an uncles at given block and index. - /// - /// Handler for ETH RPC call: `eth_getUncleByBlockNumberAndIndex` - pub async fn uncle_by_block_number_and_index( - &self, - block_number: BlockNumber, - idx: Index, - ) -> Result> { - node_info!("eth_getUncleByBlockNumberAndIndex"); - let number = self.backend.ensure_block_number(Some(BlockId::Number(block_number))).await?; - if let Some(fork) = self.get_fork() - && fork.predates_fork_inclusive(number) - { - return Ok(fork.uncle_by_block_number_and_index(number, idx.into()).await?); + let nonce = self.request_nonce_for_transaction(&request, from).await?; + + // Prefill gas limit with estimated gas and bubble up estimation errors directly. + if request.gas_limit().is_none() { + let estimated_gas = + self.do_estimate_gas_typed(request.clone(), Some(BlockId::pending())).await?; + request.set_gas_limit(estimated_gas as u64); } - // It's impossible to have uncles outside of fork mode - Ok(None) - } - /// Returns logs matching given filter object. - /// - /// Handler for ETH RPC call: `eth_getLogs` - pub async fn logs(&self, filter: Filter) -> Result> { - node_info!("eth_getLogs"); - self.backend.logs(filter).await + let typed_tx = self.build_tx_request(request, nonce).await?; + let tx = typed_tx.into_impersonated(); + + let raw = tx.encoded_2718().into(); + + let mut tx = + transaction_build(None, MaybeImpersonatedTransaction::new(tx), None, None, None); + + // Set the correct `from` address (overrides the recovered zero address from dummy + // signature) + tx.0.inner.inner = Recovered::new_unchecked(tx.0.inner.inner.into_inner(), from); + + Ok(FillTransaction { raw, tx }) } - /// Returns the hash of the current block, the seedHash, and the boundary condition to be met. - /// - /// Handler for ETH RPC call: `eth_getWork` - pub fn work(&self) -> Result { - node_info!("eth_getWork"); - Err(BlockchainError::RpcUnimplemented) + /// Handler for RPC call: `anvil_getBlobsByTransactionHash` + pub fn anvil_get_blob_by_tx_hash(&self, hash: B256) -> Result>> { + node_info!("anvil_getBlobsByTransactionHash"); + Ok(self.backend.get_blob_by_tx_hash(hash)?) } - /// Returns the sync status, always be fails. + /// Get transaction by its hash. /// - /// Handler for ETH RPC call: `eth_syncing` - pub fn syncing(&self) -> Result { - node_info!("eth_syncing"); - Ok(false) + /// This will check the storage for a matching transaction, if no transaction exists in storage + /// this will also scan the mempool for a matching pending transaction + /// + /// Handler for ETH RPC call: `eth_getTransactionByHash` + pub async fn transaction_by_hash(&self, hash: B256) -> Result> { + node_info!("eth_getTransactionByHash"); + let mut tx = + self.pool.get_transaction(hash).map(|pending| self.build_pool_transaction(pending)); + if tx.is_none() { + tx = self.backend.transaction_by_hash(hash).await? + } + + Ok(tx) } - /// Used for submitting a proof-of-work solution. - /// - /// Handler for ETH RPC call: `eth_submitWork` - pub fn submit_work(&self, _: B64, _: B256, _: B256) -> Result { - node_info!("eth_submitWork"); - Err(BlockchainError::RpcUnimplemented) + fn build_pool_transaction( + &self, + pending: PendingTransaction, + ) -> AnyRpcTransaction { + let from = *pending.sender(); + let tx = transaction_build( + Some(*pending.hash()), + pending.transaction, + None, + None, + Some(self.backend.base_fee()), + ); + + let WithOtherFields { inner: mut tx, other } = tx.0; + // we set the from field here explicitly to the set sender of the pending transaction, + // in case the transaction is impersonated. + tx.inner = Recovered::new_unchecked(tx.inner.into_inner(), from); + + AnyRpcTransaction(WithOtherFields { inner: tx, other }) } - /// Used for submitting mining hashrate. + /// Returns all ready transactions from the local pending pool. /// - /// Handler for ETH RPC call: `eth_submitHashrate` - pub fn submit_hashrate(&self, _: U256, _: B256) -> Result { - node_info!("eth_submitHashrate"); - Err(BlockchainError::RpcUnimplemented) + /// Handler for ETH RPC call: `eth_pendingTransactions` + pub async fn pending_transactions(&self) -> Result> { + node_info!("eth_pendingTransactions"); + Ok(self + .pool + .ready_transactions() + .map(|pending| self.build_pool_transaction(pending.pending_transaction.clone())) + .collect()) } - /// Introduced in EIP-1559 for getting information on the appropriate priority fee to use. + /// Returns the transaction by sender and nonce. /// - /// Handler for ETH RPC call: `eth_feeHistory` - pub async fn fee_history( + /// This will check the mempool for pending transactions first, then perform a binary search + /// over mined blocks to find the transaction. + /// + /// Handler for ETH RPC call: `eth_getTransactionBySenderAndNonce` + pub async fn transaction_by_sender_and_nonce( &self, - block_count: U256, - newest_block: BlockNumber, - reward_percentiles: Vec, - ) -> Result { - node_info!("eth_feeHistory"); - // max number of blocks in the requested range + sender: Address, + nonce: U256, + ) -> Result> { + node_info!("eth_getTransactionBySenderAndNonce"); - let current = self.backend.best_number(); - let slots_in_an_epoch = 32u64; + // check pending txs first + for pending_tx in self.pool.ready_transactions().chain(self.pool.pending_transactions()) { + if U256::from(pending_tx.pending_transaction.nonce()) == nonce + && *pending_tx.pending_transaction.sender() == sender + { + let tx = transaction_build( + Some(*pending_tx.pending_transaction.hash()), + pending_tx.pending_transaction.transaction.clone(), + None, + None, + Some(self.backend.base_fee()), + ); - let number = match newest_block { - BlockNumber::Latest | BlockNumber::Pending => current, - BlockNumber::Earliest => 0, - BlockNumber::Number(n) => n, - BlockNumber::Safe => current.saturating_sub(slots_in_an_epoch), - BlockNumber::Finalized => current.saturating_sub(slots_in_an_epoch * 2), - }; + let WithOtherFields { inner: mut tx, other } = tx.0; + // we set the from field here explicitly to the set sender of the pending + // transaction, in case the transaction is impersonated. + let from = *pending_tx.pending_transaction.sender(); + tx.inner = Recovered::new_unchecked(tx.inner.into_inner(), from); - // check if the number predates the fork, if in fork mode - if let Some(fork) = self.get_fork() { - // if we're still at the forked block we don't have any history and can't compute it - // efficiently, instead we fetch it from the fork - if fork.predates_fork_inclusive(number) { - return fork - .fee_history(block_count.to(), BlockNumber::Number(number), &reward_percentiles) - .await - .map_err(BlockchainError::AlloyForkProvider); + return Ok(Some(AnyRpcTransaction(WithOtherFields { inner: tx, other }))); } } - const MAX_BLOCK_COUNT: u64 = 1024u64; - let block_count = block_count.to::().min(MAX_BLOCK_COUNT); + let highest_nonce = self.transaction_count(sender, None).await?.saturating_to::(); + let target_nonce = nonce.saturating_to::(); - // highest and lowest block num in the requested range - let highest = number; - let lowest = highest.saturating_sub(block_count.saturating_sub(1)); + // if the nonce is higher or equal to the highest nonce, the transaction doesn't exist + if target_nonce >= highest_nonce { + return Ok(None); + } - // only support ranges that are in cache range - if lowest < self.backend.best_number().saturating_sub(self.fee_history_limit) { - return Err(FeeHistoryError::InvalidBlockRange.into()); + // no mined blocks yet + let latest_block = self.backend.best_number(); + if latest_block == 0 { + return Ok(None); } - let mut response = FeeHistory { - oldest_block: lowest, - base_fee_per_gas: Vec::new(), - gas_used_ratio: Vec::new(), - reward: Some(Default::default()), - base_fee_per_blob_gas: Default::default(), - blob_gas_used_ratio: Default::default(), - }; - let mut rewards = Vec::new(); + // binary search for the block containing the transaction + let mut low = 1u64; + let mut high = latest_block; + + while low <= high { + let mid = low + (high - low) / 2; + let mid_nonce = + self.transaction_count(sender, Some(mid.into())).await?.saturating_to::(); + + if mid_nonce > target_nonce { + high = mid - 1; + } else { + low = mid + 1; + } + } + // search in the target block + let target_block = low; + if target_block <= latest_block + && let Some(txs) = + self.backend.mined_transactions_by_block_number(target_block.into()).await { - let fee_history = self.fee_history_cache.lock(); - - // iter over the requested block range - for n in lowest..=highest { - // - if let Some(block) = fee_history.get(&n) { - response.base_fee_per_gas.push(block.base_fee); - response.base_fee_per_blob_gas.push(block.base_fee_per_blob_gas.unwrap_or(0)); - response.blob_gas_used_ratio.push(block.blob_gas_used_ratio); - response.gas_used_ratio.push(block.gas_used_ratio); - - // requested percentiles - if !reward_percentiles.is_empty() { - let mut block_rewards = Vec::new(); - let resolution_per_percentile: f64 = 2.0; - for p in &reward_percentiles { - let p = p.clamp(0.0, 100.0); - let index = ((p.round() / 2f64) * 2f64) * resolution_per_percentile; - let reward = block.rewards.get(index as usize).map_or(0, |r| *r); - block_rewards.push(reward); - } - rewards.push(block_rewards); - } + for tx in txs { + if tx.from() == sender && tx.nonce() == target_nonce { + return Ok(Some(tx)); } } } - response.reward = Some(rewards); - - // add the next block's base fee to the response - // The spec states that `base_fee_per_gas` "[..] includes the next block after the - // newest of the returned range, because this value can be derived from the - // newest block" - response.base_fee_per_gas.push(self.backend.fees().base_fee() as u128); - - // Same goes for the `base_fee_per_blob_gas`: - // > [..] includes the next block after the newest of the returned range, because this - // > value can be derived from the newest block. - response.base_fee_per_blob_gas.push(self.backend.fees().base_fee_per_blob_gas()); - - Ok(response) + Ok(None) } - /// Introduced in EIP-1159, a Geth-specific and simplified priority fee oracle. - /// Leverages the already existing fee history cache. - /// - /// Returns a suggestion for a gas tip cap for dynamic fee transactions. + /// Returns transaction receipt by transaction hash. /// - /// Handler for ETH RPC call: `eth_maxPriorityFeePerGas` - pub fn max_priority_fee_per_gas(&self) -> Result { - node_info!("eth_maxPriorityFeePerGas"); - Ok(U256::from(self.lowest_suggestion_tip())) + /// Handler for ETH RPC call: `eth_getTransactionReceipt` + pub async fn transaction_receipt(&self, hash: B256) -> Result> { + node_info!("eth_getTransactionReceipt"); + self.backend.transaction_receipt(hash).await } - /// Returns the suggested fee cap. + /// Returns block receipts by block number. /// - /// Returns at least [MIN_SUGGESTED_PRIORITY_FEE] - fn lowest_suggestion_tip(&self) -> u128 { - let block_number = self.backend.best_number(); - let latest_cached_block = self.fee_history_cache.lock().get(&block_number).cloned(); + /// Handler for ETH RPC call: `eth_getBlockReceipts` + pub async fn block_receipts(&self, number: BlockId) -> Result>> { + node_info!("eth_getBlockReceipts"); + if number == BlockId::pending() { + let transactions = self.pool.ready_transactions().collect::>(); + return Ok(Some(self.backend.pending_block_receipts(transactions).await?)); + } + + self.backend.block_receipts(number).await + } - match latest_cached_block { - Some(block) => block.rewards.iter().copied().min(), - None => self.fee_history_cache.lock().values().flat_map(|b| b.rewards.clone()).min(), + /// Returns logs matching given filter object. + /// + /// Handler for ETH RPC call: `eth_getLogs` + pub async fn logs(&self, filter: Filter) -> Result> { + node_info!("eth_getLogs"); + let best = self.backend.best_number(); + let to_block = + self.backend.convert_block_number(filter.block_option.get_to_block().copied()); + if to_block > best { + return Err(BlockchainError::BlockOutOfRange(best, to_block)); } - .map(|fee| fee.max(MIN_SUGGESTED_PRIORITY_FEE)) - .unwrap_or(MIN_SUGGESTED_PRIORITY_FEE) + let from_block = + self.backend.convert_block_number(filter.block_option.get_from_block().copied()); + if from_block > to_block { + return Err(RpcError::invalid_params("invalid block range params").into()); + } + + self.backend.logs(filter).await } /// Creates a filter object, based on filter options, to notify when the state changes (logs). @@ -1616,12 +3957,18 @@ impl EthApi { /// Handler for ETH RPC call: `eth_newFilter` pub async fn new_filter(&self, filter: Filter) -> Result { node_info!("eth_newFilter"); - // all logs that are already available that match the filter if the filter's block range is - // in the past - let historic = if filter.block_option.get_from_block().is_some() { - self.backend.logs(filter.clone()).await? - } else { - vec![] + // Valid future ranges have no historic logs to seed yet. + let historic = match filter.block_option.get_from_block() { + Some(BlockNumber::Number(from)) + if *from > self.backend.best_number() + && filter.block_option.get_to_block().is_none_or(|to| { + to.is_latest() || to.as_number().is_some_and(|to| to >= *from) + }) => + { + vec![] + } + Some(_) => self.backend.logs(filter.clone()).await?, + None => vec![], }; let filter = EthFilter::Logs(Box::new(LogsFilter { blocks: self.new_block_notifications(), @@ -1644,9 +3991,13 @@ impl EthApi { /// Creates a filter in the node, to notify when new pending transactions arrive. /// /// Handler for ETH RPC call: `eth_newPendingTransactionFilter` - pub async fn new_pending_transaction_filter(&self) -> Result { + pub async fn new_pending_transaction_filter(&self, full: bool) -> Result { node_info!("eth_newPendingTransactionFilter"); - let filter = EthFilter::PendingTransactions(self.new_ready_transactions()); + let filter = if full { + EthFilter::FullPendingTransactions(self.full_pending_transactions_filter()) + } else { + EthFilter::PendingTransactions(self.new_ready_transactions()) + }; Ok(self.filters.add_filter(filter).await) } @@ -1666,7 +4017,7 @@ impl EthApi { if let Some(filter) = self.filters.get_log_filter(id).await { self.backend.logs(filter).await } else { - Ok(Vec::new()) + Err(BlockchainError::FilterNotFound) } } @@ -1684,6 +4035,97 @@ impl EthApi { self.inner_raw_transaction(hash).await } + /// Returns EIP-2718 encoded raw receipts for the block. + /// + /// Handler for RPC call: `debug_getRawReceipts`. + pub async fn raw_receipts(&self, block: BlockId) -> Result> { + node_info!("debug_getRawReceipts"); + + // In fork mode, serve pre-fork blocks from the upstream provider. + if let BlockRequest::Number(number) = self.block_request(Some(block)).await? + && let Some(fork) = self.get_fork() + && fork.predates_fork_inclusive(number) + { + let receipts = fork.block_receipts(number).await?.unwrap_or_default(); + return Ok(receipts + .into_iter() + .map(|receipt| { + receipt.0.inner.inner.map_logs(|log| log.inner).encoded_2718().into() + }) + .collect()); + } + + let block = self.backend.get_block(block).ok_or(BlockchainError::BlockNotFound)?; + let receipts = self + .backend + .mined_receipts(block.header.hash_slow()) + .ok_or(BlockchainError::BlockNotFound)?; + Ok(receipts.into_iter().map(|receipt| receipt.encoded_2718().into()).collect()) + } + + /// Returns EIP-2718 encoded raw transactions for the block. + /// + /// Handler for RPC call: `debug_getRawTransactions`. + pub async fn raw_transactions(&self, block: BlockId) -> Result> { + node_info!("debug_getRawTransactions"); + + if let Some(block) = self.backend.get_block(block) { + return Ok(block + .body + .transactions + .into_iter() + .map(|tx| tx.into_inner().into_canonical().encoded_2718().into()) + .collect()); + } + + // In fork mode, serve pre-fork blocks from the upstream provider. Genuinely unknown or + // out-of-range blocks yield an empty result, mirroring reth; transport errors propagate. + let Some(fork) = self.get_fork() else { + return Ok(Vec::new()); + }; + let block = match block { + BlockId::Number(BlockNumber::Pending) => None, + BlockId::Number(number) => { + let number = self.backend.convert_block_number(Some(number)); + if !fork.predates_fork_inclusive(number) { + return Ok(Vec::new()); + } + fork.block_by_number_full(number).await? + } + BlockId::Hash(hash) => fork + .block_by_hash_full(hash.block_hash) + .await? + .filter(|block| fork.predates_fork_inclusive(block.header().number())), + }; + let Some(block) = block else { + return Ok(Vec::new()); + }; + let BlockTransactions::Full(txs) = block.transactions() else { + return Err(BlockchainError::Internal( + "fork provider returned a non-full block for a full block request".to_string(), + )); + }; + txs.iter().map(encode_rpc_transaction).collect() + } + + /// Returns RLP encoded raw block header. + /// + /// Handler for RPC call: `debug_getRawHeader`. + pub async fn raw_header(&self, block: BlockId) -> Result { + node_info!("debug_getRawHeader"); + let block = self.backend.get_block(block).ok_or(BlockchainError::BlockNotFound)?; + Ok(alloy_rlp::encode(&block.header).into()) + } + + /// Returns RLP encoded raw block. + /// + /// Handler for RPC call: `debug_getRawBlock`. + pub async fn raw_block(&self, block: BlockId) -> Result { + node_info!("debug_getRawBlock"); + let block = self.backend.get_block(block).ok_or(BlockchainError::BlockNotFound)?; + Ok(alloy_rlp::encode(canonical_block(block)).into()) + } + /// Returns EIP-2718 encoded raw transaction by block hash and index /// /// Handler for RPC call: `eth_getRawTransactionByBlockHashAndIndex` @@ -1708,7 +4150,7 @@ impl EthApi { index: Index, ) -> Result> { node_info!("eth_getRawTransactionByBlockNumberAndIndex"); - match self.backend.transaction_by_block_number_and_index(block_number, index).await? { + match self.transaction_by_block_number_and_index(block_number, index).await? { Some(tx) => self.inner_raw_transaction(tx.tx_hash()).await, None => Ok(None), } @@ -1726,154 +4168,108 @@ impl EthApi { self.backend.debug_trace_transaction(tx_hash, opts).await } - /// Returns traces for the transaction for geth's tracing endpoint - /// - /// Handler for RPC call: `debug_traceCall` - pub async fn debug_trace_call( - &self, - request: WithOtherFields, - block_number: Option, - opts: GethDebugTracingCallOptions, - ) -> Result { - node_info!("debug_traceCall"); - let block_request = self.block_request(block_number).await?; - let fees = FeeDetails::new( - request.gas_price, - request.max_fee_per_gas, - request.max_priority_fee_per_gas, - request.max_fee_per_blob_gas, - )? - .or_zero_fees(); - - let result: std::result::Result = - self.backend.call_with_tracing(request, fees, Some(block_request), opts).await; - result - } - - /// Returns code by its hash - /// - /// Handler for RPC call: `debug_codeByHash` - pub async fn debug_code_by_hash( - &self, - hash: B256, - block_id: Option, - ) -> Result> { - node_info!("debug_codeByHash"); - self.backend.debug_code_by_hash(hash, block_id).await - } - - /// Returns traces for the transaction hash via parity's tracing endpoint - /// - /// Handler for RPC call: `trace_transaction` - pub async fn trace_transaction(&self, tx_hash: B256) -> Result> { - node_info!("trace_transaction"); - self.backend.trace_transaction(tx_hash).await - } - - /// Returns traces for the transaction hash via parity's tracing endpoint - /// - /// Handler for RPC call: `trace_block` - pub async fn trace_block(&self, block: BlockNumber) -> Result> { - node_info!("trace_block"); - self.backend.trace_block(block).await - } - - /// Returns filtered traces over blocks + /// Returns traces for all transactions in an RLP-encoded block. /// - /// Handler for RPC call: `trace_filter` - pub async fn trace_filter( + /// Handler for RPC call: `debug_traceBlock` + pub async fn debug_trace_block( &self, - filter: TraceFilter, - ) -> Result> { - node_info!("trace_filter"); - self.backend.trace_filter(filter).await - } -} - -// == impl EthApi anvil endpoints == - -impl EthApi { - /// Send transactions impersonating specific account and contract addresses. - /// - /// Handler for ETH RPC call: `anvil_impersonateAccount` - pub async fn anvil_impersonate_account(&self, address: Address) -> Result<()> { - node_info!("anvil_impersonateAccount"); - self.backend.impersonate(address); - Ok(()) - } - - /// Stops impersonating an account if previously set with `anvil_impersonateAccount`. - /// - /// Handler for ETH RPC call: `anvil_stopImpersonatingAccount` - pub async fn anvil_stop_impersonating_account(&self, address: Address) -> Result<()> { - node_info!("anvil_stopImpersonatingAccount"); - self.backend.stop_impersonating(address); - Ok(()) + rlp_block: Bytes, + opts: GethDebugTracingOptions, + ) -> Result> { + node_info!("debug_traceBlock"); + self.backend.debug_trace_block(rlp_block, opts).await } - /// If set to true will make every account impersonated + /// Returns traces for all transactions in a block by hash. /// - /// Handler for ETH RPC call: `anvil_autoImpersonateAccount` - pub async fn anvil_auto_impersonate_account(&self, enabled: bool) -> Result<()> { - node_info!("anvil_autoImpersonateAccount"); - self.backend.auto_impersonate_account(enabled); - Ok(()) + /// Handler for RPC call: `debug_traceBlockByHash` + pub async fn debug_trace_block_by_hash( + &self, + block_hash: B256, + opts: GethDebugTracingOptions, + ) -> Result> { + node_info!("debug_traceBlockByHash"); + self.backend.debug_trace_block_by_hash(block_hash, opts).await } - /// Returns true if auto mining is enabled, and false. + /// Returns traces for all transactions in a block by number. /// - /// Handler for ETH RPC call: `anvil_getAutomine` - pub fn anvil_get_auto_mine(&self) -> Result { - node_info!("anvil_getAutomine"); - Ok(self.miner.is_auto_mine()) + /// Handler for RPC call: `debug_traceBlockByNumber` + pub async fn debug_trace_block_by_number( + &self, + block_number: BlockNumber, + opts: GethDebugTracingOptions, + ) -> Result> { + node_info!("debug_traceBlockByNumber"); + self.backend.debug_trace_block_by_number(block_number, opts).await } - /// Returns the value of mining interval, if set. + /// Returns traces for the transaction for geth's tracing endpoint /// - /// Handler for ETH RPC call: `anvil_getIntervalMining`. - pub fn anvil_get_interval_mining(&self) -> Result> { - node_info!("anvil_getIntervalMining"); - Ok(self.miner.get_interval()) + /// Handler for RPC call: `debug_traceCall` + pub async fn debug_trace_call( + &self, + request: WithOtherFields, + block_number: Option, + opts: GethDebugTracingCallOptions, + ) -> Result { + node_info!("debug_traceCall"); + let block_request = self.block_request(block_number).await?; + let fees = FeeDetails::new( + request.gas_price, + request.max_fee_per_gas, + request.max_priority_fee_per_gas, + request.max_fee_per_blob_gas, + )? + .or_zero_fees(); + + let result: std::result::Result = + self.backend.call_with_tracing(request, fees, Some(block_request), opts).await; + result } - /// Enables or disables, based on the single boolean argument, the automatic mining of new - /// blocks with each new transaction submitted to the network. + /// Traces calls sequentially on top of the same block. /// - /// Handler for ETH RPC call: `evm_setAutomine` - pub async fn anvil_set_auto_mine(&self, enable_automine: bool) -> Result<()> { - node_info!("evm_setAutomine"); - if self.miner.is_auto_mine() { - if enable_automine { - return Ok(()); - } - self.miner.set_mining_mode(MiningMode::None); - } else if enable_automine { - let listener = self.pool.add_ready_listener(); - let mode = MiningMode::instant(1_000, listener); - self.miner.set_mining_mode(mode); - } - Ok(()) + /// Handler for RPC call: `trace_callMany`. + pub async fn trace_call_many( + &self, + calls: Vec<(WithOtherFields, HashSet)>, + block_number: Option, + ) -> Result> { + node_info!("trace_callMany"); + let block_request = self.block_request(block_number).await?; + + self.backend.trace_call_many(calls, Some(block_request)).await } +} + +// == impl EthApi anvil endpoints == +impl EthApi { /// Mines a series of blocks. /// /// Handler for ETH RPC call: `anvil_mine` pub async fn anvil_mine(&self, num_blocks: Option, interval: Option) -> Result<()> { node_info!("anvil_mine"); - let interval = interval.map(|i| i.to::()); - let blocks = num_blocks.unwrap_or(U256::from(1)); + let interval = interval.map(|i| i.saturating_to::()); + let pin_timestamp = interval == Some(0); + let blocks = num_blocks.unwrap_or(U256::ONE); if blocks.is_zero() { return Ok(()); } self.on_blocking_task(|this| async move { // mine all the blocks - for _ in 0..blocks.to::() { + for _ in 0..blocks.saturating_to::() { // If we have an interval, jump forwards in time to the "next" timestamp - if let Some(interval) = interval { - this.backend.time().increase_time(interval); + let pending_increase = + interval.map(|interval| this.backend.time().apply_time_increase(interval)); + if let Err(error) = this.mine_one_with_timestamp_pinning(pin_timestamp).await { + if let Some(pending) = pending_increase { + this.backend.time().revert_time_increase(pending); + } + return Err(error); } - this.mine_one().await; } Ok(()) }) @@ -1882,85 +4278,6 @@ impl EthApi { Ok(()) } - /// Sets the mining behavior to interval with the given interval (seconds) - /// - /// Handler for ETH RPC call: `evm_setIntervalMining` - pub fn anvil_set_interval_mining(&self, secs: u64) -> Result<()> { - node_info!("evm_setIntervalMining"); - let mining_mode = if secs == 0 { - MiningMode::None - } else { - let block_time = Duration::from_secs(secs); - - // This ensures that memory limits are stricter in interval-mine mode - self.backend.update_interval_mine_block_time(block_time); - - MiningMode::FixedBlockTime(FixedBlockTimeMiner::new(block_time)) - }; - self.miner.set_mining_mode(mining_mode); - Ok(()) - } - - /// Removes transactions from the pool - /// - /// Handler for RPC call: `anvil_dropTransaction` - pub async fn anvil_drop_transaction(&self, tx_hash: B256) -> Result> { - node_info!("anvil_dropTransaction"); - Ok(self.pool.drop_transaction(tx_hash).map(|tx| tx.hash())) - } - - /// Removes all transactions from the pool - /// - /// Handler for RPC call: `anvil_dropAllTransactions` - pub async fn anvil_drop_all_transactions(&self) -> Result<()> { - node_info!("anvil_dropAllTransactions"); - self.pool.clear(); - Ok(()) - } - - /// Reset the fork to a fresh forked state, and optionally update the fork config. - /// - /// If `forking` is `None` then this will disable forking entirely. - /// - /// Handler for RPC call: `anvil_reset` - pub async fn anvil_reset(&self, forking: Option) -> Result<()> { - self.reset_instance_id(); - node_info!("anvil_reset"); - if let Some(forking) = forking { - // if we're resetting the fork we need to reset the instance id - self.backend.reset_fork(forking).await - } else { - // Reset to a fresh in-memory state - - self.backend.reset_to_in_mem().await - } - } - - pub async fn anvil_set_chain_id(&self, chain_id: u64) -> Result<()> { - node_info!("anvil_setChainId"); - self.backend.set_chain_id(chain_id); - Ok(()) - } - - /// Modifies the balance of an account. - /// - /// Handler for RPC call: `anvil_setBalance` - pub async fn anvil_set_balance(&self, address: Address, balance: U256) -> Result<()> { - node_info!("anvil_setBalance"); - self.backend.set_balance(address, balance).await?; - Ok(()) - } - - /// Increases the balance of an account. - /// - /// Handler for RPC call: `anvil_addBalance` - pub async fn anvil_add_balance(&self, address: Address, balance: U256) -> Result<()> { - node_info!("anvil_addBalance"); - let current_balance = self.backend.get_balance(address, None).await?; - self.backend.set_balance(address, current_balance + balance).await?; - Ok(()) - } - /// Helper function to find the storage slot for an ERC20 function call by testing slots /// from an access list until one produces the expected result. /// @@ -1987,7 +4304,7 @@ impl EthApi { // first collect all the slots that are used by the function call let access_list_result = - self.create_access_list(WithOtherFields::new(tx.clone()), None).await?; + self.create_access_list(WithOtherFields::new(tx.clone()), None, None).await?; let access_list = access_list_result.access_list; // iterate over all the accessed slots and try to find the one that contains the @@ -1997,9 +4314,8 @@ impl EthApi { continue; }; for slot in &item.storage_keys { - let account_override = AccountOverride::default().with_state_diff(std::iter::once( - (*slot, B256::from(expected_value.to_be_bytes())), - )); + let account_override = AccountOverride::default() + .with_state_diff(std::iter::once((*slot, B256::from(expected_value)))); let state_override = StateOverridesBuilder::default() .append(token_address, account_override) @@ -2039,245 +4355,103 @@ impl EthApi { ) -> Result<()> { node_info!("anvil_dealERC20"); - sol! { - #[sol(rpc)] - contract IERC20 { - function balanceOf(address target) external view returns (uint256); - } + if self.backend.is_tempo() + && self.backend.try_set_tip20_balance(address, token_address, balance).await? + { + return Ok(()); } - let calldata = IERC20::balanceOfCall { target: address }.abi_encode().into(); - - // Find the storage slot that contains the balance - let slot = - self.find_erc20_storage_slot(token_address, calldata, balance).await.map_err(|_| { - BlockchainError::Message("Unable to set ERC20 balance, no slot found".to_string()) - })?; - - // Set the storage slot to the desired balance - self.anvil_set_storage_at( - token_address, - U256::from_be_bytes(slot.0), - B256::from(balance.to_be_bytes()), - ) - .await?; - - Ok(()) - } - - /// Sets the ERC20 allowance for a spender - /// - /// Handler for RPC call: `anvil_set_erc20_allowance` - pub async fn anvil_set_erc20_allowance( - &self, - owner: Address, - spender: Address, - token_address: Address, - amount: U256, - ) -> Result<()> { - node_info!("anvil_setERC20Allowance"); - sol! { #[sol(rpc)] contract IERC20 { - function allowance(address owner, address spender) external view returns (uint256); + function balanceOf(address target) external view returns (uint256); } } - let calldata = IERC20::allowanceCall { owner, spender }.abi_encode().into(); - - // Find the storage slot that contains the allowance - let slot = - self.find_erc20_storage_slot(token_address, calldata, amount).await.map_err(|_| { - BlockchainError::Message("Unable to set ERC20 allowance, no slot found".to_string()) - })?; - - // Set the storage slot to the desired allowance - self.anvil_set_storage_at( - token_address, - U256::from_be_bytes(slot.0), - B256::from(amount.to_be_bytes()), - ) - .await?; - - Ok(()) - } - - /// Sets the code of a contract. - /// - /// Handler for RPC call: `anvil_setCode` - pub async fn anvil_set_code(&self, address: Address, code: Bytes) -> Result<()> { - node_info!("anvil_setCode"); - self.backend.set_code(address, code).await?; - Ok(()) - } - - /// Sets the nonce of an address. - /// - /// Handler for RPC call: `anvil_setNonce` - pub async fn anvil_set_nonce(&self, address: Address, nonce: U256) -> Result<()> { - node_info!("anvil_setNonce"); - self.backend.set_nonce(address, nonce).await?; - Ok(()) - } - - /// Writes a single slot of the account's storage. - /// - /// Handler for RPC call: `anvil_setStorageAt` - pub async fn anvil_set_storage_at( - &self, - address: Address, - slot: U256, - val: B256, - ) -> Result { - node_info!("anvil_setStorageAt"); - self.backend.set_storage_at(address, slot, val).await?; - Ok(true) - } - - /// Enable or disable logging. - /// - /// Handler for RPC call: `anvil_setLoggingEnabled` - pub async fn anvil_set_logging(&self, enable: bool) -> Result<()> { - node_info!("anvil_setLoggingEnabled"); - self.logger.set_enabled(enable); - Ok(()) - } - - /// Set the minimum gas price for the node. - /// - /// Handler for RPC call: `anvil_setMinGasPrice` - pub async fn anvil_set_min_gas_price(&self, gas: U256) -> Result<()> { - node_info!("anvil_setMinGasPrice"); - if self.backend.is_eip1559() { - return Err(RpcError::invalid_params( - "anvil_setMinGasPrice is not supported when EIP-1559 is active", - ) - .into()); - } - self.backend.set_gas_price(gas.to()); - Ok(()) - } + let calldata = IERC20::balanceOfCall { target: address }.abi_encode().into(); - /// Sets the base fee of the next block. - /// - /// Handler for RPC call: `anvil_setNextBlockBaseFeePerGas` - pub async fn anvil_set_next_block_base_fee_per_gas(&self, basefee: U256) -> Result<()> { - node_info!("anvil_setNextBlockBaseFeePerGas"); - if !self.backend.is_eip1559() { - return Err(RpcError::invalid_params( - "anvil_setNextBlockBaseFeePerGas is only supported when EIP-1559 is active", - ) - .into()); - } - self.backend.set_base_fee(basefee.to()); - Ok(()) - } + // Find the storage slot that contains the balance + let slot = + self.find_erc20_storage_slot(token_address, calldata, balance).await.map_err(|_| { + BlockchainError::Message("Unable to set ERC20 balance, no slot found".to_string()) + })?; + + // Set the storage slot to the desired balance + self.anvil_set_storage_at(token_address, slot.into(), balance.into()).await?; - /// Sets the coinbase address. - /// - /// Handler for RPC call: `anvil_setCoinbase` - pub async fn anvil_set_coinbase(&self, address: Address) -> Result<()> { - node_info!("anvil_setCoinbase"); - self.backend.set_coinbase(address); Ok(()) } - /// Create a buffer that represents all state on the chain, which can be loaded to separate - /// process by calling `anvil_loadState` + /// Deals TIP-20 tokens to an address. /// - /// Handler for RPC call: `anvil_dumpState` - pub async fn anvil_dump_state( + /// Handler for RPC call: `anvil_dealTIP20`. + pub async fn anvil_deal_tip20( &self, - preserve_historical_states: Option, - ) -> Result { - node_info!("anvil_dumpState"); - self.backend.dump_state(preserve_historical_states.unwrap_or(false)).await + address: Address, + token_address: Address, + balance: U256, + ) -> Result<()> { + node_info!("anvil_dealTIP20"); + self.ensure_tempo_mode()?; + self.backend.set_tip20_balance(address, token_address, balance).await?; + Ok(()) } - /// Returns the current state - pub async fn serialized_state( + /// Sets the ERC20 allowance for a spender + /// + /// Handler for RPC call: `anvil_set_erc20_allowance` + pub async fn anvil_set_erc20_allowance( &self, - preserve_historical_states: bool, - ) -> Result { - self.backend.serialized_state(preserve_historical_states).await - } + owner: Address, + spender: Address, + token_address: Address, + amount: U256, + ) -> Result<()> { + node_info!("anvil_setERC20Allowance"); - /// Append chain state buffer to current chain. Will overwrite any conflicting addresses or - /// storage. - /// - /// Handler for RPC call: `anvil_loadState` - pub async fn anvil_load_state(&self, buf: Bytes) -> Result { - node_info!("anvil_loadState"); - self.backend.load_state_bytes(buf).await - } + sol! { + #[sol(rpc)] + contract IERC20 { + function allowance(address owner, address spender) external view returns (uint256); + } + } - /// Retrieves the Anvil node configuration params. - /// - /// Handler for RPC call: `anvil_nodeInfo` - pub async fn anvil_node_info(&self) -> Result { - node_info!("anvil_nodeInfo"); + let calldata = IERC20::allowanceCall { owner, spender }.abi_encode().into(); - let env = self.backend.env().read(); - let fork_config = self.backend.get_fork(); - let tx_order = self.transaction_order.read(); - let hard_fork: &str = env.evm_env.cfg_env.spec.into(); + // Find the storage slot that contains the allowance + let slot = + self.find_erc20_storage_slot(token_address, calldata, amount).await.map_err(|_| { + BlockchainError::Message("Unable to set ERC20 allowance, no slot found".to_string()) + })?; - Ok(NodeInfo { - current_block_number: self.backend.best_number(), - current_block_timestamp: env.evm_env.block_env.timestamp.saturating_to(), - current_block_hash: self.backend.best_hash(), - hard_fork: hard_fork.to_string(), - transaction_order: match *tx_order { - TransactionOrder::Fifo => "fifo".to_string(), - TransactionOrder::Fees => "fees".to_string(), - }, - environment: NodeEnvironment { - base_fee: self.backend.base_fee() as u128, - chain_id: self.backend.chain_id().to::(), - gas_limit: self.backend.gas_limit(), - gas_price: self.gas_price(), - }, - fork_config: fork_config - .map(|fork| { - let config = fork.config.read(); + // Set the storage slot to the desired allowance + self.anvil_set_storage_at(token_address, slot.into(), amount.into()).await?; - NodeForkConfig { - fork_url: Some(config.eth_rpc_url.clone()), - fork_block_number: Some(config.block_number), - fork_retry_backoff: Some(config.backoff.as_millis()), - } - }) - .unwrap_or_default(), - }) + Ok(()) } - /// Retrieves metadata about the Anvil instance. - /// - /// Handler for RPC call: `anvil_metadata` - pub async fn anvil_metadata(&self) -> Result { - node_info!("anvil_metadata"); - let fork_config = self.backend.get_fork(); - - Ok(Metadata { - client_version: CLIENT_VERSION.to_string(), - chain_id: self.backend.chain_id().to::(), - latest_block_hash: self.backend.best_hash(), - latest_block_number: self.backend.best_number(), - instance_id: *self.instance_id.read(), - forked_network: fork_config.map(|cfg| ForkedNetwork { - chain_id: cfg.chain_id(), - fork_block_number: cfg.block_number(), - fork_block_hash: cfg.block_hash(), - }), - snapshots: self.backend.list_state_snapshots(), - }) - } + /// Recognizes a canonical Monad protocol envelope without requiring its reserved sender to be + /// globally impersonated or recoverable from the envelope signature. + #[cfg(feature = "monad")] + fn monad_protocol_reorg_transaction( + &self, + transaction: FoundryTxEnvelope, + ) -> Option> { + if !self.backend.is_monad() { + return None; + } - pub async fn anvil_remove_pool_transactions(&self, address: Address) -> Result<()> { - node_info!("anvil_removePoolTransactions"); - self.pool.remove_transactions_by_address(address); - Ok(()) + let pending = PendingTransaction::with_sender( + MaybeImpersonatedTransaction::new(transaction), + monad_revm::staking::constants::SYSTEM_ADDRESS, + ); + let tx_env: revm::context::TxEnv = crate::eth::backend::executor::build_tx_env_for_pending( + &pending, + self.backend.cheats(), + ); + foundry_evm::core::evm::protocol_system_call(&tx_env) + .is_ok_and(|call| call.is_some()) + .then_some(pending) } /// Reorg the chain to a specific depth and mine new blocks back to the canonical height. @@ -2295,8 +4469,23 @@ impl EthApi { /// Handler for RPC call: `anvil_reorg` pub async fn anvil_reorg(&self, options: ReorgOptions) -> Result<()> { node_info!("anvil_reorg"); + let _lifecycle = self.lifecycle_lock.write().await; + let mining_guard = self.backend.lock_mining().await; let depth = options.depth; - let tx_block_pairs = options.tx_block_pairs; + let mut tx_block_pairs = options.tx_block_pairs; + + if let Some((_, num)) = tx_block_pairs.iter().find(|(_, num)| *num >= depth) { + // A reorg of depth 0 mines no blocks, so there is nowhere to place the transactions. + let Some(last_block) = depth.checked_sub(1) else { + return Err(BlockchainError::RpcError(RpcError::invalid_params( + "Reorg depth must be at least 1 to include transactions", + ))); + }; + return Err(BlockchainError::RpcError(RpcError::invalid_params(format!( + "Block number for reorg tx will exceed the reorged chain height. Block number {num} must not exceed (depth-1) {last_block}" + )))); + } + tx_block_pairs.sort_by_key(|a| a.1); // Check reorg depth doesn't exceed current chain height let current_height = self.backend.best_number(); @@ -2308,45 +4497,75 @@ impl EthApi { // Get the common ancestor block let common_block = - self.backend.get_block(common_height).ok_or(BlockchainError::BlockNotFound)?; + self.rollback_block(common_height).await?.ok_or(BlockchainError::BlockNotFound)?; + + #[cfg(feature = "monad")] + let fee_defaults = if self.backend.is_monad() { + self.backend + .monad_rollback_fee_defaults(&common_block, self.lowest_suggestion_tip()) + .await + .map(|(gas_price, blob_gas_price)| TransactionFeeDefaults { + gas_price, + blob_gas_price, + }) + } else { + None + }; + #[cfg(not(feature = "monad"))] + let fee_defaults = None; // Convert the transaction requests to pool transactions if they exist, otherwise use empty // hashmap let block_pool_txs = if tx_block_pairs.is_empty() { HashMap::default() } else { - let mut pairs = tx_block_pairs; - - // Check the maximum block supplied number will not exceed the reorged chain height - if let Some((_, num)) = pairs.iter().find(|(_, num)| *num >= depth) { - return Err(BlockchainError::RpcError(RpcError::invalid_params(format!( - "Block number for reorg tx will exceed the reorged chain height. Block number {num} must not exceed (depth-1) {}", - depth - 1 - )))); - } - - // Sort by block number to make it easier to manage new nonces - pairs.sort_by_key(|a| a.1); - // Manage nonces for each signer // address -> cumulative nonce let mut nonces: HashMap = HashMap::default(); - let mut txs: HashMap>> = HashMap::default(); - for pair in pairs { + let mut txs: HashMap>>> = + HashMap::default(); + for pair in tx_block_pairs { let (tx_data, block_index) = pair; let pending = match tx_data { TransactionData::Raw(bytes) => { let mut data = bytes.as_ref(); - let decoded = TypedTransaction::decode_2718(&mut data) + let decoded = FoundryTxEnvelope::decode_2718(&mut data) .map_err(|_| BlockchainError::FailedToDecodeSignedTransaction)?; - PendingTransaction::new(decoded)? + if matches!(&decoded, FoundryTxEnvelope::Celo(_)) { + self.ensure_typed_transaction_supported(&decoded)?; + } + let protocol_pending = { + #[cfg(feature = "monad")] + { + self.monad_protocol_reorg_transaction(decoded.clone()) + } + #[cfg(not(feature = "monad"))] + { + None + } + }; + if let Some(pending) = protocol_pending { + pending + } else { + PendingTransaction::new(decoded)? + } } - TransactionData::JSON(req) => { - let mut tx_req = WithOtherFields::new(req); - let from = tx_req.from.map(Ok).unwrap_or_else(|| { + TransactionData::JSON(request) => { + let request = if request.transaction_type + == Some(foundry_primitives::CIP64_TX_TYPE) + || request + .other + .get("feeCurrency") + .is_some_and(|value| !value.is_null()) + { + self.parse_transaction_request(request)? + } else { + request.inner.into() + }; + let from = request.from().map(Ok).unwrap_or_else(|| { self.accounts()? .first() .copied() @@ -2357,155 +4576,61 @@ impl EthApi { let curr_nonce = nonces.entry(from).or_insert( self.get_transaction_count( from, - Some(common_block.header.number.into()), + Some(common_block.header.number().into()), ) .await?, ); - // Estimate gas - if tx_req.gas.is_none() - && let Ok(gas) = self - .estimate_gas(tx_req.clone(), None, EvmOverrides::default()) - .await - { - tx_req.gas = Some(gas.to()); - } - // Build typed transaction request - let typed = self.build_typed_tx_request(tx_req, *curr_nonce)?; + let typed_tx = self + .build_tx_request_with_fee_defaults(request, *curr_nonce, fee_defaults) + .await?; // Increment nonce *curr_nonce += 1; - // Handle signer and convert to pending transaction - if self.is_impersonated(from) { - let bypass_signature = self.impersonated_signature(&typed); - let transaction = - sign::build_typed_transaction(typed, bypass_signature)?; + let protocol_pending = { + #[cfg(feature = "monad")] + { + if from == monad_revm::staking::constants::SYSTEM_ADDRESS { + self.monad_protocol_reorg_transaction( + typed_tx.clone().into_impersonated(), + ) + } else { + None + } + } + #[cfg(not(feature = "monad"))] + { + None + } + }; + if let Some(pending) = protocol_pending { + pending + } else if self.is_impersonated(from) { + let transaction = typed_tx.into_impersonated(); self.ensure_typed_transaction_supported(&transaction)?; PendingTransaction::with_impersonated(transaction, from) } else { - let transaction = self.sign_request(&from, typed)?; + let transaction = self.sign_request(&from, typed_tx)?; self.ensure_typed_transaction_supported(&transaction)?; PendingTransaction::new(transaction)? } } }; - let pooled = PoolTransaction::new(pending); + let pooled = PoolTransaction::new(pending).with_replay(); txs.entry(block_index).or_default().push(Arc::new(pooled)); } txs }; - self.backend.reorg(depth, block_pool_txs, common_block).await?; - Ok(()) - } - - /// Rollback the chain to a specific depth. - /// - /// e.g depth = 3 - /// A -> B -> C -> D -> E - /// A -> B - /// - /// Depth specifies the height to rollback the chain back to. Depth must not exceed the current - /// chain height, i.e. can't rollback past the genesis block. - /// - /// Handler for RPC call: `anvil_rollback` - pub async fn anvil_rollback(&self, depth: Option) -> Result<()> { - node_info!("anvil_rollback"); - let depth = depth.unwrap_or(1); - - // Check reorg depth doesn't exceed current chain height - let current_height = self.backend.best_number(); - let common_height = current_height.checked_sub(depth).ok_or(BlockchainError::RpcError( - RpcError::invalid_params(format!( - "Rollback depth must not exceed current chain height: current height {current_height}, depth {depth}" - )), - ))?; - - // Get the common ancestor block - let common_block = - self.backend.get_block(common_height).ok_or(BlockchainError::BlockNotFound)?; - - self.backend.rollback(common_block).await?; - Ok(()) - } - - /// Snapshot the state of the blockchain at the current block. - /// - /// Handler for RPC call: `evm_snapshot` - pub async fn evm_snapshot(&self) -> Result { - node_info!("evm_snapshot"); - Ok(self.backend.create_state_snapshot().await) - } - - /// Revert the state of the blockchain to a previous snapshot. - /// Takes a single parameter, which is the snapshot id to revert to. - /// - /// Handler for RPC call: `evm_revert` - pub async fn evm_revert(&self, id: U256) -> Result { - node_info!("evm_revert"); - self.backend.revert_state_snapshot(id).await - } - - /// Jump forward in time by the given amount of time, in seconds. - /// - /// Handler for RPC call: `evm_increaseTime` - pub async fn evm_increase_time(&self, seconds: U256) -> Result { - node_info!("evm_increaseTime"); - Ok(self.backend.time().increase_time(seconds.try_into().unwrap_or(u64::MAX)) as i64) - } - - /// Similar to `evm_increaseTime` but takes the exact timestamp that you want in the next block - /// - /// Handler for RPC call: `evm_setNextBlockTimestamp` - pub fn evm_set_next_block_timestamp(&self, seconds: u64) -> Result<()> { - node_info!("evm_setNextBlockTimestamp"); - self.backend.time().set_next_block_timestamp(seconds) - } - - /// Sets the specific timestamp and returns the number of seconds between the given timestamp - /// and the current time. - /// - /// Handler for RPC call: `evm_setTime` - pub fn evm_set_time(&self, timestamp: u64) -> Result { - node_info!("evm_setTime"); - let now = self.backend.time().current_call_timestamp(); - self.backend.time().reset(timestamp); - - // number of seconds between the given timestamp and the current time. - let offset = timestamp.saturating_sub(now); - Ok(Duration::from_millis(offset).as_secs()) - } - - /// Set the next block gas limit - /// - /// Handler for RPC call: `evm_setBlockGasLimit` - pub fn evm_set_block_gas_limit(&self, gas_limit: U256) -> Result { - node_info!("evm_setBlockGasLimit"); - self.backend.set_gas_limit(gas_limit.to()); - Ok(true) - } - - /// Sets an interval for the block timestamp - /// - /// Handler for RPC call: `anvil_setBlockTimestampInterval` - pub fn evm_set_block_timestamp_interval(&self, seconds: u64) -> Result<()> { - node_info!("anvil_setBlockTimestampInterval"); - self.backend.time().set_block_timestamp_interval(seconds); + self.rollback_backend(common_block, &mining_guard).await?; + self.backend.reorg(depth, block_pool_txs, &mining_guard).await?; Ok(()) } - /// Sets an interval for the block timestamp - /// - /// Handler for RPC call: `anvil_removeBlockTimestampInterval` - pub fn evm_remove_block_timestamp_interval(&self) -> Result { - node_info!("anvil_removeBlockTimestampInterval"); - Ok(self.backend.time().remove_block_timestamp_interval()) - } - /// Mine blocks, instantly. /// /// Handler for RPC call: `evm_mine` @@ -2551,13 +4676,7 @@ impl EthApi { && let Some(output) = receipt.out { // insert revert reason if failure - if !receipt - .inner - .inner - .as_receipt_with_bloom() - .receipt - .status - .coerce_status() + if !receipt.inner.as_ref().status() && let Some(reason) = RevertDecoder::new().maybe_decode(&output, None) { tx.other.insert( @@ -2571,55 +4690,12 @@ impl EthApi { ); } } - block.transactions = BlockTransactions::Full(block_txs.to_vec()); + block.transactions = BlockTransactions::Full(block_txs.clone()); blocks.push(block); } } - Ok(blocks) - } - - /// Sets the reported block number - /// - /// Handler for ETH RPC call: `anvil_setBlock` - pub fn anvil_set_block(&self, block_number: u64) -> Result<()> { - node_info!("anvil_setBlock"); - self.backend.set_block_number(block_number); - Ok(()) - } - - /// Sets the backend rpc url - /// - /// Handler for ETH RPC call: `anvil_setRpcUrl` - pub fn anvil_set_rpc_url(&self, url: String) -> Result<()> { - node_info!("anvil_setRpcUrl"); - if let Some(fork) = self.backend.get_fork() { - let mut config = fork.config.write(); - // let interval = config.provider.get_interval(); - let new_provider = Arc::new( - ProviderBuilder::new(&url).max_retry(10).initial_backoff(1000).build().map_err( - |_| { - TransportErrorKind::custom_str( - format!("Failed to parse invalid url {url}").as_str(), - ) - }, - // TODO: Add interval - )?, // .interval(interval), - ); - config.provider = new_provider; - trace!(target: "backend", "Updated fork rpc from \"{}\" to \"{}\"", config.eth_rpc_url, url); - config.eth_rpc_url = url; - } - Ok(()) - } - - /// Turn on call traces for transactions that are returned to the user when they execute a - /// transaction (instead of just txhash/receipt) - /// - /// Handler for ETH RPC call: `anvil_enableTraces` - pub async fn anvil_enable_traces(&self) -> Result<()> { - node_info!("anvil_enableTraces"); - Err(BlockchainError::RpcUnimplemented) + Ok(blocks) } /// Execute a transaction regardless of signature status @@ -2630,37 +4706,21 @@ impl EthApi { request: WithOtherFields, ) -> Result { node_info!("eth_sendUnsignedTransaction"); + let request = self.parse_transaction_request(request)?; // either use the impersonated account of the request's `from` field - let from = request.from.ok_or(BlockchainError::NoSignerAvailable)?; + let from = request.from().ok_or(BlockchainError::NoSignerAvailable)?; - let (nonce, on_chain_nonce) = self.request_nonce(&request, from).await?; + let nonce = self.request_nonce_for_transaction(&request, from).await?; - let request = self.build_typed_tx_request(request, nonce)?; + let typed_tx = self.build_tx_request(request, nonce).await?; - let bypass_signature = self.impersonated_signature(&request); - let transaction = sign::build_typed_transaction(request, bypass_signature)?; + let transaction = typed_tx.into_impersonated(); self.ensure_typed_transaction_supported(&transaction)?; let pending_transaction = PendingTransaction::with_impersonated(transaction, from); - // pre-validate - self.backend.validate_pool_transaction(&pending_transaction).await?; - - let requires = required_marker(nonce, on_chain_nonce, from); - let provides = vec![to_marker(nonce, from)]; - - self.add_pending_transaction(pending_transaction, requires, provides) - } - - /// Returns the number of transactions currently pending for inclusion in the next block(s), as - /// well as the ones that are being scheduled for future execution only. - /// Ref: [Here](https://geth.ethereum.org/docs/rpc/ns-txpool#txpool_status) - /// - /// Handler for ETH RPC call: `txpool_status` - pub async fn txpool_status(&self) -> Result { - node_info!("txpool_status"); - Ok(self.pool.txpool_status()) + self.add_pending_transaction(pending_transaction).await } /// Returns a summary of all the transactions currently pending for inclusion in the next @@ -2673,10 +4733,10 @@ impl EthApi { node_info!("txpool_inspect"); let mut inspect = TxpoolInspect::default(); - fn convert(tx: Arc) -> TxpoolInspectSummary { + fn convert(tx: Arc>) -> TxpoolInspectSummary { let tx = &tx.pending_transaction.transaction; let to = tx.to(); - let gas_price = tx.gas_price(); + let gas_price = tx.max_fee_per_gas(); let value = tx.value(); let gas = tx.gas_limit(); TxpoolInspectSummary { to, value, gas, gas_price } @@ -2690,12 +4750,12 @@ impl EthApi { // with the same From address. for pending in self.pool.ready_transactions() { let entry = inspect.pending.entry(*pending.pending_transaction.sender()).or_default(); - let key = pending.pending_transaction.nonce().to_string(); + let key = txpool_transaction_key(&pending.pending_transaction); entry.insert(key, convert(pending)); } for queued in self.pool.pending_transactions() { - let entry = inspect.pending.entry(*queued.pending_transaction.sender()).or_default(); - let key = queued.pending_transaction.nonce().to_string(); + let entry = inspect.queued.entry(*queued.pending_transaction.sender()).or_default(); + let key = txpool_transaction_key(&queued.pending_transaction); entry.insert(key, convert(queued)); } Ok(inspect) @@ -2706,11 +4766,22 @@ impl EthApi { /// /// See [here](https://geth.ethereum.org/docs/rpc/ns-txpool#txpool_content) for more details /// - /// Handler for ETH RPC call: `txpool_inspect` + /// Handler for ETH RPC call: `txpool_content` pub async fn txpool_content(&self) -> Result> { node_info!("txpool_content"); + self.build_txpool_content(None) + } + + /// Builds the txpool content, optionally filtered to a single sender. + /// + /// When `filter` is `Some`, only transactions from that sender are converted, avoiding the + /// cost of building RPC transactions for unrelated senders. + fn build_txpool_content( + &self, + filter: Option
, + ) -> Result> { let mut content = TxpoolContent::::default(); - fn convert(tx: Arc) -> Result { + fn convert(tx: Arc>) -> Result { let from = *tx.pending_transaction.sender(); let tx = transaction_build( Some(tx.hash()), @@ -2732,172 +4803,45 @@ impl EthApi { } for pending in self.pool.ready_transactions() { - let entry = content.pending.entry(*pending.pending_transaction.sender()).or_default(); - let key = pending.pending_transaction.nonce().to_string(); + let sender = *pending.pending_transaction.sender(); + if filter.is_some_and(|from| from != sender) { + continue; + } + let entry = content.pending.entry(sender).or_default(); + let key = txpool_transaction_key(&pending.pending_transaction); entry.insert(key, convert(pending)?); } for queued in self.pool.pending_transactions() { - let entry = content.pending.entry(*queued.pending_transaction.sender()).or_default(); - let key = queued.pending_transaction.nonce().to_string(); + let sender = *queued.pending_transaction.sender(); + if filter.is_some_and(|from| from != sender) { + continue; + } + let entry = content.queued.entry(sender).or_default(); + let key = txpool_transaction_key(&queued.pending_transaction); entry.insert(key, convert(queued)?); } Ok(content) } -} -// ===== impl Wallet endpoints ===== -impl EthApi { - /// Get the capabilities of the wallet. + /// Returns the details of all transactions currently pending for inclusion in the next + /// block(s), as well as the ones that are being scheduled for future execution only, filtered + /// by sender. /// - /// See also [EIP-5792][eip-5792]. + /// See [here](https://geth.ethereum.org/docs/rpc/ns-txpool#txpool_contentFrom) for more details /// - /// [eip-5792]: https://eips.ethereum.org/EIPS/eip-5792 - pub fn get_capabilities(&self) -> Result { - node_info!("wallet_getCapabilities"); - Ok(self.backend.get_capabilities()) - } - - pub async fn wallet_send_transaction( + /// Handler for ETH RPC call: `txpool_contentFrom` + pub async fn txpool_content_from( &self, - mut request: WithOtherFields, - ) -> Result { - node_info!("wallet_sendTransaction"); - - // Validate the request - // reject transactions that have a non-zero value to prevent draining the executor. - if request.value.is_some_and(|val| val > U256::ZERO) { - return Err(WalletError::ValueNotZero.into()); - } - - // reject transactions that have from set, as this will be the executor. - if request.from.is_some() { - return Err(WalletError::FromSet.into()); - } - - // reject transaction requests that have nonce set, as this is managed by the executor. - if request.nonce.is_some() { - return Err(WalletError::NonceSet.into()); - } - - let capabilities = self.backend.get_capabilities(); - let valid_delegations: &[Address] = capabilities - .get(self.chain_id()) - .map(|caps| caps.delegation.addresses.as_ref()) - .unwrap_or_default(); - - if let Some(authorizations) = &request.authorization_list - && authorizations.iter().any(|auth| !valid_delegations.contains(&auth.address)) - { - return Err(WalletError::InvalidAuthorization.into()); - } - - // validate the destination address - match (request.authorization_list.is_some(), request.to) { - // if this is an eip-1559 tx, ensure that it is an account that delegates to a - // whitelisted address - (false, Some(TxKind::Call(addr))) => { - let acc = self.backend.get_account(addr).await?; - - let delegated_address = acc - .code - .map(|code| match code { - Bytecode::Eip7702(c) => c.address(), - _ => Address::ZERO, - }) - .unwrap_or_default(); - - // not a whitelisted address, or not an eip-7702 bytecode - if delegated_address == Address::ZERO - || !valid_delegations.contains(&delegated_address) - { - return Err(WalletError::IllegalDestination.into()); - } - } - // if it's an eip-7702 tx, let it through - (true, _) => (), - // create tx's disallowed - _ => return Err(WalletError::IllegalDestination.into()), - } - - let wallet = self.backend.executor_wallet().ok_or(WalletError::InternalError)?; - - let from = NetworkWallet::::default_signer_address(&wallet); - - let nonce = self.get_transaction_count(from, Some(BlockId::latest())).await?; - - request.nonce = Some(nonce); - - let chain_id = self.chain_id(); - - request.chain_id = Some(chain_id); - - request.from = Some(from); - - let gas_limit_fut = - self.estimate_gas(request.clone(), Some(BlockId::latest()), EvmOverrides::default()); - - let fees_fut = self.fee_history( - U256::from(EIP1559_FEE_ESTIMATION_PAST_BLOCKS), - BlockNumber::Latest, - vec![EIP1559_FEE_ESTIMATION_REWARD_PERCENTILE], - ); - - let (gas_limit, fees) = tokio::join!(gas_limit_fut, fees_fut); - - let gas_limit = gas_limit?; - let fees = fees?; - - request.gas = Some(gas_limit.to()); - - let base_fee = fees.latest_block_base_fee().unwrap_or_default(); - - let estimation = eip1559_default_estimator(base_fee, &fees.reward.unwrap_or_default()); - - request.max_fee_per_gas = Some(estimation.max_fee_per_gas); - request.max_priority_fee_per_gas = Some(estimation.max_priority_fee_per_gas); - request.gas_price = None; - - let envelope = request.build(&wallet).await.map_err(|_| WalletError::InternalError)?; - - self.send_raw_transaction(envelope.encoded_2718().into()).await - } - - /// Add an address to the delegation capability of wallet. - /// - /// This entails that the executor will now be able to sponsor transactions to this address. - pub fn anvil_add_capability(&self, address: Address) -> Result<()> { - node_info!("anvil_addCapability"); - self.backend.add_capability(address); - Ok(()) - } - - pub fn anvil_set_executor(&self, executor_pk: String) -> Result
{ - node_info!("anvil_setExecutor"); - self.backend.set_executor(executor_pk) + from: Address, + ) -> Result> { + node_info!("txpool_contentFrom"); + let mut content = self.build_txpool_content(Some(from))?; + Ok(content.remove_from(&from)) } } -impl EthApi { - /// Executes the future on a new blocking task. - async fn on_blocking_task(&self, c: C) -> Result - where - C: FnOnce(Self) -> F, - F: Future> + Send + 'static, - R: Send + 'static, - { - let (tx, rx) = oneshot::channel(); - let this = self.clone(); - let f = c(this); - tokio::task::spawn_blocking(move || { - tokio::runtime::Handle::current().block_on(async move { - let res = f.await; - let _ = tx.send(res); - }) - }); - rx.await.map_err(|_| BlockchainError::Internal("blocking task panicked".to_string()))? - } - +impl EthApi { /// Executes the `evm_mine` and returns the number of blocks mined async fn do_evm_mine(&self, opts: Option) -> Result { let mut blocks_to_mine = 1u64; @@ -2923,7 +4867,7 @@ impl EthApi { self.on_blocking_task(|this| async move { // mine all the blocks for _ in 0..blocks_to_mine { - this.mine_one().await; + this.mine_one().await?; } Ok(()) }) @@ -2949,218 +4893,149 @@ impl EthApi { "not available on past forked blocks".to_string(), )); } - return Ok(fork.estimate_gas(&request, Some(number.into())).await?); + return Ok(fork.estimate_gas_raw(&request, Some(number.into())).await?); } // this can be blocking for a bit, especially in forking mode // self.on_blocking_task(|this| async move { + let request = this.parse_transaction_request(request)?; this.backend - .with_database_at(Some(block_request), |state, mut block| { - let mut cache_db = CacheDB::new(state); - if let Some(state_overrides) = overrides.state { - state::apply_state_overrides( - state_overrides.into_iter().collect(), - &mut cache_db, - )?; - } - if let Some(block_overrides) = overrides.block { - state::apply_block_overrides(*block_overrides, &mut cache_db, &mut block); - } - this.do_estimate_gas_with_state(request, &cache_db as &dyn DatabaseRef, block) - }) - .await? + .with_database_at_and_context( + Some(block_request), + |state, mut block, monad_context| { + let mut cache_db = CacheDB::new(state); + if let Some(state_overrides) = overrides.state { + apply_state_overrides( + state_overrides.into_iter().collect(), + &mut cache_db, + )?; + } + if let Some(block_overrides) = overrides.block { + cache_db.apply_block_overrides(*block_overrides, &mut block); + } + this.do_estimate_gas_with_state(request, &cache_db, block, monad_context) + }, + ) + .await }) .await } - /// Estimates the gas usage of the `request` with the state. - /// - /// This will execute the transaction request and find the best gas limit via binary search. - fn do_estimate_gas_with_state( + async fn do_estimate_gas_typed( &self, - mut request: WithOtherFields, - state: &dyn DatabaseRef, - block_env: BlockEnv, + request: FoundryTransactionRequest, + block_number: Option, ) -> Result { - // If the request is a simple native token transfer we can optimize - // We assume it's a transfer if we have no input data. - let to = request.to.as_ref().and_then(TxKind::to); - - // check certain fields to see if the request could be a simple transfer - let maybe_transfer = (request.input.input().is_none() - || request.input.input().is_some_and(|data| data.is_empty())) - && request.authorization_list.is_none() - && request.access_list.is_none() - && request.blob_versioned_hashes.is_none(); - - if maybe_transfer - && let Some(to) = to - && let Ok(target_code) = self.backend.get_code_with_state(&state, *to) - && target_code.as_ref().is_empty() - { - return Ok(MIN_TRANSACTION_GAS); - } - - let fees = FeeDetails::new( - request.gas_price, - request.max_fee_per_gas, - request.max_priority_fee_per_gas, - request.max_fee_per_blob_gas, - )? - .or_zero_fees(); - - // get the highest possible gas limit, either the request's set value or the currently - // configured gas limit - let mut highest_gas_limit = request.gas.map_or(block_env.gas_limit.into(), |g| g as u128); - - let gas_price = fees.gas_price.unwrap_or_default(); - // If we have non-zero gas price, cap gas limit by sender balance - if gas_price > 0 - && let Some(from) = request.from - { - let mut available_funds = self.backend.get_balance_with_state(state, from)?; - if let Some(value) = request.value { - if value > available_funds { - return Err(InvalidTransactionError::InsufficientFunds.into()); - } - // safe: value < available_funds - available_funds -= value; - } - // amount of gas the sender can afford with the `gas_price` - let allowance = available_funds.checked_div(U256::from(gas_price)).unwrap_or_default(); - highest_gas_limit = std::cmp::min(highest_gas_limit, allowance.saturating_to()); - } - - let mut call_to_estimate = request.clone(); - call_to_estimate.gas = Some(highest_gas_limit as u64); - - // execute the call without writing to db - let ethres = - self.backend.call_with_state(&state, call_to_estimate, fees.clone(), block_env.clone()); - - let gas_used = match ethres.try_into()? { - GasEstimationCallResult::Success(gas) => Ok(gas), - GasEstimationCallResult::OutOfGas => { - Err(InvalidTransactionError::BasicOutOfGas(highest_gas_limit).into()) - } - GasEstimationCallResult::Revert(output) => { - Err(InvalidTransactionError::Revert(output).into()) - } - GasEstimationCallResult::EvmError(err) => { - warn!(target: "node", "estimation failed due to {:?}", err); - Err(BlockchainError::EvmError(err)) - } - }?; - - // at this point we know the call succeeded but want to find the _best_ (lowest) gas the - // transaction succeeds with. we find this by doing a binary search over the - // possible range NOTE: this is the gas the transaction used, which is less than the - // transaction requires to succeed - - // Get the starting lowest gas needed depending on the transaction kind. - let mut lowest_gas_limit = determine_base_gas_by_kind(&request); - - // pick a point that's close to the estimated gas - let mut mid_gas_limit = - std::cmp::min(gas_used * 3, (highest_gas_limit + lowest_gas_limit) / 2); - - // Binary search for the ideal gas limit - while (highest_gas_limit - lowest_gas_limit) > 1 { - request.gas = Some(mid_gas_limit as u64); - let ethres = self.backend.call_with_state( - &state, - request.clone(), - fees.clone(), - block_env.clone(), - ); - - match ethres.try_into()? { - GasEstimationCallResult::Success(_) => { - // If the transaction succeeded, we can set a ceiling for the highest gas limit - // at the current midpoint, as spending any more gas would - // make no sense (as the TX would still succeed). - highest_gas_limit = mid_gas_limit; - } - GasEstimationCallResult::OutOfGas - | GasEstimationCallResult::Revert(_) - | GasEstimationCallResult::EvmError(_) => { - // If the transaction failed, we can set a floor for the lowest gas limit at the - // current midpoint, as spending any less gas would make no - // sense (as the TX would still revert due to lack of gas). - // - // We don't care about the reason here, as we known that transaction is correct - // as it succeeded earlier - lowest_gas_limit = mid_gas_limit; - } - }; - // new midpoint - mid_gas_limit = (highest_gas_limit + lowest_gas_limit) / 2; - } - - trace!(target : "node", "Estimated Gas for call {:?}", highest_gas_limit); - - Ok(highest_gas_limit) - } - - /// Updates the `TransactionOrder` - pub fn set_transaction_order(&self, order: TransactionOrder) { - *self.transaction_order.write() = order; + let block_request = self.block_request(block_number).await?; + self.on_blocking_task(|this| async move { + this.backend + .with_database_at_and_context(Some(block_request), |state, block, monad_context| { + this.do_estimate_gas_with_state(request, &state, block, monad_context) + }) + .await + }) + .await } /// Returns the priority of the transaction based on the current `TransactionOrder` - fn transaction_priority(&self, tx: &TypedTransaction) -> TransactionPriority { + fn transaction_priority(&self, tx: &FoundryTxEnvelope) -> TransactionPriority { self.transaction_order.read().priority(tx) } - /// Returns the chain ID used for transaction - pub fn chain_id(&self) -> u64 { - self.backend.chain_id().to::() - } + /// Returns a listener for pending transactions, yielding full transactions + pub fn full_pending_transactions(&self) -> UnboundedReceiver { + let (tx, rx) = unbounded_channel(); + let mut hashes = self.new_ready_transactions(); - /// Returns the configured fork, if any. - pub fn get_fork(&self) -> Option { - self.backend.get_fork() - } + let this = self.clone(); - /// Returns the current instance's ID. - pub fn instance_id(&self) -> B256 { - *self.instance_id.read() - } + tokio::spawn(async move { + while let Some(hash) = hashes.next().await { + if let Ok(Some(txn)) = this.transaction_by_hash(hash).await + && tx.send(txn).is_err() + { + break; + } + } + }); - /// Resets the instance ID. - pub fn reset_instance_id(&self) { - *self.instance_id.write() = B256::random(); + rx } - /// Returns the first signer that can sign for the given address - #[expect(clippy::borrowed_box)] - pub fn get_signer(&self, address: Address) -> Option<&Box> { - self.signers.iter().find(|signer| signer.is_signer_for(address)) - } + /// Returns a bounded stream of full pending transactions for poll-based filters. + /// + /// Unlike [`Self::full_pending_transactions`], this applies backpressure via a bounded channel, + /// so an unpolled filter cannot buffer transactions without bound. + pub fn full_pending_transactions_filter(&self) -> mpsc::Receiver { + // Mirror the pool's ready-listener buffer so a full filter is bounded like a hash filter. + let (tx, rx) = mpsc::channel(2048); + let mut hashes = self.new_ready_transactions(); + let this = self.clone(); - /// Returns a new block event stream that yields Notifications when a new block was added - pub fn new_block_notifications(&self) -> NewBlockNotifications { - self.backend.new_block_notifications() - } + tokio::spawn(async move { + while let Some(hash) = hashes.next().await { + if let Ok(Some(txn)) = this.transaction_by_hash(hash).await + && tx.send(txn).await.is_err() + { + break; + } + } + }); - /// Returns a new listeners for ready transactions - pub fn new_ready_transactions(&self) -> Receiver { - self.pool.add_ready_listener() + rx } - /// Returns a listener for pending transactions, yielding full transactions - pub fn full_pending_transactions(&self) -> UnboundedReceiver { + /// Returns a listener for new block receipts. + pub fn transaction_receipts_subscription( + &self, + filter: TransactionReceiptsParams, + ) -> UnboundedReceiver> { let (tx, rx) = unbounded_channel(); - let mut hashes = self.new_ready_transactions(); - + let mut blocks = self.new_block_notifications(); let this = self.clone(); tokio::spawn(async move { - while let Some(hash) = hashes.next().await { - if let Ok(Some(txn)) = this.transaction_by_hash(hash).await - && tx.send(txn).is_err() - { + // Precompute the hash filter once. + let hash_filter = filter + .transaction_hashes + .filter(|hashes| !hashes.is_empty()) + .map(|hashes| hashes.into_iter().collect::()); + + loop { + let notification = tokio::select! { + biased; + // Exit when the subscriber unsubscribes, even while awaiting the next block. + _ = tx.closed() => break, + maybe_block = blocks.next() => match maybe_block { + Some(block) => block, + None => break, + }, + }; + + let Some(block) = notification.as_new_block() else { + continue; + }; + + let receipts = match this.block_receipts(BlockId::hash(block.hash)).await { + Ok(Some(mut receipts)) => { + if let Some(hashes) = &hash_filter { + receipts.retain(|receipt| hashes.contains(&receipt.transaction_hash())); + } + receipts + } + Ok(None) => continue, + Err(err) => { + trace!(target: "node", %err, "failed to build block receipts for subscription"); + continue; + } + }; + + if receipts.is_empty() { + continue; + } + + if tx.send(receipts).is_err() { break; } } @@ -3169,45 +5044,46 @@ impl EthApi { rx } - /// Returns a new accessor for certain storage elements - pub fn storage_info(&self) -> StorageInfo { - StorageInfo::new(Arc::clone(&self.backend)) - } - - /// Returns true if forked - pub fn is_fork(&self) -> bool { - self.backend.is_fork() + /// Mines exactly one block + pub async fn mine_one(&self) -> Result<()> { + self.mine_one_with_timestamp_pinning(false).await } - /// Mines exactly one block - pub async fn mine_one(&self) { + async fn mine_one_with_timestamp_pinning(&self, pin_timestamp: bool) -> Result<()> { + let _mining = self.backend.lock_mining().await; let transactions = self.pool.ready_transactions().collect::>(); - let outcome = self.backend.mine_block(transactions).await; + let outcome = self.backend.mine_block_locked(transactions, pin_timestamp).await?; trace!(target: "node", blocknumber = ?outcome.block_number, "mined block"); - self.pool.on_mined_block(outcome); + if self.pool.on_mined_block(outcome) { + self.miner.retry_ready_transactions(); + } + Ok(()) } /// Returns the pending block with tx hashes - async fn pending_block(&self) -> AnyRpcBlock { + async fn pending_block(&self) -> Result { let transactions = self.pool.ready_transactions().collect::>(); - let info = self.backend.pending_block(transactions).await; - self.backend.convert_block(info.block) + let info = self.backend.pending_block(transactions).await?; + Ok(self.backend.convert_block(info.block)) } /// Returns the full pending block with `Transaction` objects - async fn pending_block_full(&self) -> Option { + async fn pending_block_full(&self) -> Result> { let transactions = self.pool.ready_transactions().collect::>(); let BlockInfo { block, transactions, receipts: _ } = - self.backend.pending_block(transactions).await; + self.backend.pending_block(transactions).await?; let mut partial_block = self.backend.convert_block(block.clone()); - let mut block_transactions = Vec::with_capacity(block.transactions.len()); + let mut block_transactions = Vec::with_capacity(block.body.transactions.len()); let base_fee = self.backend.base_fee(); for info in transactions { - let tx = block.transactions.get(info.transaction_index as usize)?.clone(); + let Some(tx) = block.body.transactions.get(info.transaction_index as usize).cloned() + else { + return Ok(None); + }; let tx = transaction_build( Some(info.transaction_hash), @@ -3221,133 +5097,106 @@ impl EthApi { partial_block.transactions = BlockTransactions::from(block_transactions); - Some(partial_block) + Ok(Some(partial_block)) } - fn build_typed_tx_request( + /// Prepares transaction request by filling missing fields using Anvil's API, then attempts + /// to build a [`FoundryTypedTx`]. + async fn build_tx_request( &self, - request: WithOtherFields, + request: FoundryTransactionRequest, nonce: u64, - ) -> Result { - let chain_id = request.chain_id.unwrap_or_else(|| self.chain_id()); - let max_fee_per_gas = request.max_fee_per_gas; - let max_fee_per_blob_gas = request.max_fee_per_blob_gas; - let gas_price = request.gas_price; - - let gas_limit = request.gas.unwrap_or_else(|| self.backend.gas_limit()); - let from = request.from; - - let request = match transaction_request_to_typed(request) { - Some(TypedTransactionRequest::Legacy(mut m)) => { - m.nonce = nonce; - m.chain_id = Some(chain_id); - m.gas_limit = gas_limit; - if gas_price.is_none() { - m.gas_price = self.gas_price(); - } - TypedTransactionRequest::Legacy(m) - } - Some(TypedTransactionRequest::EIP2930(mut m)) => { - m.nonce = nonce; - m.chain_id = chain_id; - m.gas_limit = gas_limit; - if gas_price.is_none() { - m.gas_price = self.gas_price(); - } - TypedTransactionRequest::EIP2930(m) - } - Some(TypedTransactionRequest::EIP1559(mut m)) => { - m.nonce = nonce; - m.chain_id = chain_id; - m.gas_limit = gas_limit; - if max_fee_per_gas.is_none() { - m.max_fee_per_gas = self.gas_price(); - } - TypedTransactionRequest::EIP1559(m) - } - Some(TypedTransactionRequest::EIP7702(mut m)) => { - m.nonce = nonce; - m.chain_id = chain_id; - m.gas_limit = gas_limit; - if max_fee_per_gas.is_none() { - m.max_fee_per_gas = self.gas_price(); - } - TypedTransactionRequest::EIP7702(m) - } - Some(TypedTransactionRequest::EIP4844(m)) => { - TypedTransactionRequest::EIP4844(match m { - // We only accept the TxEip4844 variant which has the sidecar. - TxEip4844Variant::TxEip4844WithSidecar(mut m) => { - m.tx.nonce = nonce; - m.tx.chain_id = chain_id; - m.tx.gas_limit = gas_limit; - if max_fee_per_gas.is_none() { - m.tx.max_fee_per_gas = self.gas_price(); - } - if max_fee_per_blob_gas.is_none() { - m.tx.max_fee_per_blob_gas = self - .excess_blob_gas_and_price() - .unwrap_or_default() - .map_or(0, |g| g.blob_gasprice) - } - TxEip4844Variant::TxEip4844WithSidecar(m) - } - TxEip4844Variant::TxEip4844(mut tx) => { - if !self.backend.skip_blob_validation(from) { - return Err(BlockchainError::FailedToDecodeTransaction); - } + ) -> Result { + self.build_tx_request_with_fee_defaults(request, nonce, None).await + } - // Allows 4844 with no sidecar when impersonation is active. - tx.nonce = nonce; - tx.chain_id = chain_id; - tx.gas_limit = gas_limit; - if max_fee_per_gas.is_none() { - tx.max_fee_per_gas = self.gas_price(); - } - if max_fee_per_blob_gas.is_none() { - tx.max_fee_per_blob_gas = self - .excess_blob_gas_and_price() - .unwrap_or_default() - .map_or(0, |g| g.blob_gasprice) - } + async fn build_tx_request_with_fee_defaults( + &self, + mut request: FoundryTransactionRequest, + nonce: u64, + fee_defaults: Option, + ) -> Result { + let from = request.from().or(self.accounts()?.first().copied()); + if let Some(from) = from { + request.set_from(from); + } - TxEip4844Variant::TxEip4844(tx) + // Fill common fields for all tx types + request.chain_id().is_none().then(|| request.set_chain_id(self.chain_id())); + request.nonce().is_none().then(|| request.set_nonce(nonce)); + let is_tempo_batch = + matches!(&request, FoundryTransactionRequest::Tempo(tx) if !tx.calls.is_empty()); + if request.kind().is_none() && !is_tempo_batch { + request.set_kind(TxKind::default()); + } + if request.gas_limit().is_none() { + let fallback_gas_limit = { + let evm_env = self.backend.evm_env().read(); + self.backend.fallback_tx_gas_limit(&evm_env) + }; + let estimated_gas = self + .do_estimate_gas_typed(request.clone(), None) + .await + .map(|v| v as u64) + .unwrap_or_else(|_| { + if is_simple_transfer_request(request.as_ref()) { + MIN_TRANSACTION_GAS as u64 + } else { + fallback_gas_limit } - }) + }); + request.set_gas_limit(estimated_gas); + } + + // Fill missing tx type specific fields + if let Err((tx_type, _)) = request.missing_keys() { + let gas_price = || fee_defaults.map_or_else(|| self.gas_price(), |fees| fees.gas_price); + if tx_type.is_legacy() || tx_type.is_eip2930() { + request.gas_price().is_none().then(|| request.set_gas_price(gas_price())); } - Some(TypedTransactionRequest::Deposit(mut m)) => { - m.gas_limit = gas_limit; - TypedTransactionRequest::Deposit(m) + if tx_type.is_eip2930() { + request + .access_list() + .is_none() + .then(|| request.set_access_list(Default::default())); } - None => return Err(BlockchainError::FailedToDecodeTransaction), - }; - Ok(request) - } - - /// Returns true if the `addr` is currently impersonated - pub fn is_impersonated(&self, addr: Address) -> bool { - self.backend.cheats().is_impersonated(addr) - } + if tx_type.is_eip1559() + || tx_type.is_eip4844() + || tx_type.is_eip7702() + || tx_type.is_tempo() + || tx_type.is_celo() + { + request + .max_fee_per_gas() + .is_none() + .then(|| request.set_max_fee_per_gas(gas_price())); + request + .max_priority_fee_per_gas() + .is_none() + .then(|| request.set_max_priority_fee_per_gas(MIN_SUGGESTED_PRIORITY_FEE)); + } + if tx_type.is_eip4844() { + request.as_ref().max_fee_per_blob_gas().is_none().then(|| { + let blob_gas_price = fee_defaults.map_or_else( + || self.backend.fees().get_next_block_blob_base_fee_per_gas(), + |fees| fees.blob_gas_price, + ); + request.as_mut().set_max_fee_per_blob_gas(blob_gas_price) + }); + } + } - /// The signature used to bypass signing via the `eth_sendUnsignedTransaction` cheat RPC - fn impersonated_signature(&self, request: &TypedTransactionRequest) -> Signature { - match request { - // Only the legacy transaction type requires v to be in {27, 28}, thus - // requiring the use of Parity::NonEip155 - TypedTransactionRequest::Legacy(_) => Signature::from_scalars_and_parity( - B256::with_last_byte(1), - B256::with_last_byte(1), - false, - ), - TypedTransactionRequest::EIP2930(_) - | TypedTransactionRequest::EIP1559(_) - | TypedTransactionRequest::EIP7702(_) - | TypedTransactionRequest::EIP4844(_) - | TypedTransactionRequest::Deposit(_) => Signature::from_scalars_and_parity( - B256::with_last_byte(1), - B256::with_last_byte(1), - false, - ), + match request + .build_unsigned() + .map_err(|e| BlockchainError::InvalidTransactionRequest(e.to_string()))? + { + FoundryTypedTx::Eip4844(TxEip4844Variant::TxEip4844(_)) + if !self.backend.skip_blob_validation(from) => + { + // If blob validation is not skipped, reject TxEip4844 variant without sidecar. + Err(BlockchainError::FailedToDecodeTransaction) + } + res => Ok(res), } } @@ -3371,57 +5220,333 @@ impl EthApi { /// Returns the nonce for this request /// - /// This returns a tuple of `(request nonce, highest nonce)` - /// If the nonce field of the `request` is `None` then the tuple will be `(highest nonce, - /// highest nonce)`. - /// - /// This will also check the tx pool for pending transactions from the sender. - async fn request_nonce( - &self, - request: &TransactionRequest, - from: Address, - ) -> Result<(u64, u64)> { - let highest_nonce = - self.get_transaction_count(from, Some(BlockId::Number(BlockNumber::Pending))).await?; - let nonce = request.nonce.unwrap_or(highest_nonce); - - Ok((nonce, highest_nonce)) + /// If the nonce field of the `request` is `None`, this accounts for pending transactions from + /// the sender. + async fn request_nonce(&self, request: &TransactionRequest, from: Address) -> Result { + if let Some(nonce) = request.nonce { + Ok(nonce) + } else { + self.get_transaction_count(from, Some(BlockId::pending())).await + } } /// Adds the given transaction to the pool - fn add_pending_transaction( + async fn add_pending_transaction( &self, - pending_transaction: PendingTransaction, - requires: Vec, - provides: Vec, + pending_transaction: PendingTransaction, ) -> Result { + let _mining = self.backend.lock_mining().await; + self.backend.validate_pool_transaction(&pending_transaction).await?; + let from = *pending_transaction.sender(); + #[cfg(feature = "base")] + let base_markers = self.eip8130_nonce_markers(&pending_transaction).await?; + #[cfg(not(feature = "base"))] + let base_markers = None; + + let markers = if base_markers.is_some() { + base_markers + } else { + self.tempo_nonce_markers(&pending_transaction).await? + }; + let (requires, provides) = if let Some(markers) = markers { + markers + } else { + let nonce = pending_transaction.transaction.nonce(); + let on_chain_nonce = self.backend.current_nonce(from).await?; + (required_marker(nonce, on_chain_nonce, from), vec![to_marker(nonce, from)]) + }; + + debug_assert!(requires != provides); let priority = self.transaction_priority(&pending_transaction.transaction); let pool_transaction = - PoolTransaction { requires, provides, pending_transaction, priority }; + PoolTransaction { requires, provides, pending_transaction, priority, is_replay: false }; let tx = self.pool.add_transaction(pool_transaction)?; trace!(target: "node", "Added transaction: [{:?}] sender={:?}", tx.hash(), from); Ok(*tx.hash()) } - /// Returns the current state root - pub async fn state_root(&self) -> Option { - self.backend.get_db().read().await.maybe_state_root() + /// Returns Tempo lane/replay markers when the transaction does not use the protocol nonce. + async fn tempo_nonce_markers( + &self, + pending: &PendingTransaction, + ) -> Result, Vec)>> { + let FoundryTxEnvelope::Tempo(signed) = pending.transaction.as_ref() else { + return Ok(None); + }; + let tx = signed.tx(); + if tx.nonce_key.is_zero() { + return Ok(None); + } + if tx.nonce_key == U256::MAX { + return Ok(Some((Vec::new(), vec![pending.hash().to_vec()]))); + } + + let state_nonce = self.backend.tempo_nonce(*pending.sender(), tx.nonce_key, None).await?; + if tx.nonce < state_nonce { + return Err(InvalidTransactionError::NonceTooLow.into()); + } + + let requires = if tx.nonce == state_nonce { + Vec::new() + } else { + vec![tempo_nonce_marker(*pending.sender(), tx.nonce_key, tx.nonce - 1)] + }; + let provides = vec![tempo_nonce_marker(*pending.sender(), tx.nonce_key, tx.nonce)]; + Ok(Some((requires, provides))) + } + + /// Returns Base EIP-8130 channel/replay markers when the transaction does not use the + /// protocol nonce lane. + #[cfg(feature = "base")] + async fn eip8130_nonce_markers( + &self, + pending: &PendingTransaction, + ) -> Result, Vec)>> { + if let FoundryTxEnvelope::Eip8130(signed) = pending.transaction.as_ref() { + let tx = signed.tx(); + let now = self.backend.eip8130_pool_timestamp_ms(); + signed + .validate_admission_static(self.backend.chain_id().to()) + .map_err(|error| BlockchainError::Eip8130TransactionRejected(error.to_string()))?; + signed + .validate_timestamp(now) + .map_err(|error| BlockchainError::Eip8130TransactionRejected(error.to_string()))?; + if tx.nonce_key.is_zero() { + let state_nonce = self.backend.current_nonce(*pending.sender()).await?; + if tx.nonce_sequence < state_nonce { + return Err(InvalidTransactionError::NonceTooLow.into()); + } + let provides = vec![to_marker(tx.nonce_sequence, *pending.sender())]; + self.ensure_eip8130_replacement_price(pending, &provides)?; + self.ensure_eip8130_payer_reservation(pending, &provides).await?; + return Ok(None); + } + if tx.nonce_key == Eip8130Constants::NONCE_KEY_MAX { + let replay_id = tx.replay_id(*pending.sender()); + let slot = NonceManagerStorage::expiring_nonce_seen_slot(replay_id); + let word = + self.backend.storage_at(NonceManagerStorage::ADDRESS, slot, None).await?; + let recorded_expiry = (Into::::into(word) & U256::from(u64::MAX)).to::(); + if recorded_expiry > now { + return Err(BlockchainError::Eip8130TransactionRejected( + "expiring nonce replay has already been recorded".to_string(), + )); + } + let provides = vec![eip8130_replay_marker(replay_id)]; + self.ensure_eip8130_replacement_price(pending, &provides)?; + self.ensure_eip8130_payer_reservation(pending, &provides).await?; + return Ok(Some((Vec::new(), provides))); + } + + let slot = NonceManagerStorage::nonce_slot(*pending.sender(), tx.nonce_key) + .map_err(|error| BlockchainError::InvalidTransactionRequest(error.to_string()))?; + let word = self.backend.storage_at(NonceManagerStorage::ADDRESS, slot, None).await?; + let state_nonce = Eip8130Nonce::decode_channel_nonce(word.into()).to::(); + if tx.nonce_sequence < state_nonce { + return Err(InvalidTransactionError::NonceTooLow.into()); + } + + let requires = if tx.nonce_sequence == state_nonce { + Vec::new() + } else { + vec![eip8130_channel_marker(*pending.sender(), tx.nonce_key, tx.nonce_sequence - 1)] + }; + let provides = + vec![eip8130_channel_marker(*pending.sender(), tx.nonce_key, tx.nonce_sequence)]; + self.ensure_eip8130_replacement_price(pending, &provides)?; + self.ensure_eip8130_payer_reservation(pending, &provides).await?; + return Ok(Some((requires, provides))); + } + + Ok(None) + } + + /// Enforces Base's default ten-percent EIP-8130 replacement price bump. + #[cfg(feature = "base")] + fn ensure_eip8130_replacement_price( + &self, + pending: &PendingTransaction, + provides: &[TxMarker], + ) -> Result<()> { + let Some(existing) = self.pool.transaction_with_markers(*pending.sender(), provides) else { + return Ok(()); + }; + let FoundryTxEnvelope::Eip8130(incoming) = pending.transaction.as_ref() else { + return Ok(()); + }; + let FoundryTxEnvelope::Eip8130(current) = existing.pending_transaction.transaction.as_ref() + else { + return Ok(()); + }; + + const PRICE_BUMP_PERCENT: u128 = 10; + let bumped = + |price: u128| price.saturating_mul(100 + PRICE_BUMP_PERCENT).saturating_add(99) / 100; + if incoming.tx().max_fee_per_gas < bumped(current.tx().max_fee_per_gas) + || incoming.tx().max_priority_fee_per_gas + < bumped(current.tx().max_priority_fee_per_gas) + { + return Err( + crate::eth::error::PoolError::ReplacementUnderpriced(*pending.hash()).into() + ); + } + Ok(()) + } + + /// Reserves each payer's maximum EIP-8130 fee across pending transactions. + #[cfg(feature = "base")] + async fn ensure_eip8130_payer_reservation( + &self, + pending: &PendingTransaction, + replacing_markers: &[TxMarker], + ) -> Result<()> { + let FoundryTxEnvelope::Eip8130(incoming) = pending.transaction.as_ref() else { + return Ok(()); + }; + let payer = incoming.tx().payer.unwrap_or(*pending.sender()); + let incoming_cost = FeeCheck::max_fee_charge( + incoming.tx().gas_limit, + IntrinsicGas::max_payer_auth_cost(incoming) + .map_err(|error| BlockchainError::Eip8130TransactionRejected(error.to_string()))?, + incoming.tx().max_fee_per_gas, + ); + let mut reserved = U256::ZERO; + for pooled in self.pool.all_transactions() { + if pooled.provides == replacing_markers { + continue; + } + let FoundryTxEnvelope::Eip8130(signed) = + pooled.pending_transaction.transaction.as_ref() + else { + continue; + }; + if signed.tx().payer.unwrap_or(*pooled.pending_transaction.sender()) != payer { + continue; + } + let payer_auth = IntrinsicGas::max_payer_auth_cost(signed) + .map_err(|error| BlockchainError::Eip8130TransactionRejected(error.to_string()))?; + reserved = reserved.saturating_add(FeeCheck::max_fee_charge( + signed.tx().gas_limit, + payer_auth, + signed.tx().max_fee_per_gas, + )); + } + let balance = self.backend.get_account(payer).await?.balance; + let required = reserved.saturating_add(incoming_cost); + if balance < required { + return Err(BlockchainError::Eip8130TransactionRejected(format!( + "gas payer balance {balance} is below pending reservation {required}" + ))); + } + Ok(()) } /// additional validation against hardfork - fn ensure_typed_transaction_supported(&self, tx: &TypedTransaction) -> Result<()> { + fn ensure_typed_transaction_supported(&self, tx: &FoundryTxEnvelope) -> Result<()> { match &tx { - TypedTransaction::EIP2930(_) => self.backend.ensure_eip2930_active(), - TypedTransaction::EIP1559(_) => self.backend.ensure_eip1559_active(), - TypedTransaction::EIP4844(_) => self.backend.ensure_eip4844_active(), - TypedTransaction::EIP7702(_) => self.backend.ensure_eip7702_active(), - TypedTransaction::Deposit(_) => self.backend.ensure_op_deposits_active(), - TypedTransaction::Legacy(_) => Ok(()), + FoundryTxEnvelope::Eip2930(_) => self.backend.ensure_eip2930_active(), + FoundryTxEnvelope::Eip1559(_) => self.backend.ensure_eip1559_active(), + FoundryTxEnvelope::Eip4844(_) => self.backend.ensure_eip4844_active(), + FoundryTxEnvelope::Eip7702(_) => self.backend.ensure_eip7702_active(), + #[cfg(any(feature = "base", feature = "optimism"))] + FoundryTxEnvelope::Deposit(_) => self.backend.ensure_deposits_active(), + #[cfg(feature = "optimism")] + FoundryTxEnvelope::PostExec(_) => Err(BlockchainError::InvalidTransactionRequest( + "not implemented for post-exec tx".to_string(), + )), + #[cfg(feature = "base")] + FoundryTxEnvelope::Eip8130(_) => self.backend.ensure_base_eip8130_submission_active(), + FoundryTxEnvelope::Celo(_) => self.backend.ensure_cip64_active(), + FoundryTxEnvelope::Legacy(_) => Ok(()), + FoundryTxEnvelope::Tempo(_) => self.backend.ensure_tempo_active(), + } + } + + /// Sets the fee token for a user address. + /// + /// Handler for RPC call: `anvil_setFeeToken` + /// + /// Only supported when running in Tempo mode (`--tempo`). + pub async fn anvil_set_fee_token(&self, user: Address, token: Address) -> Result<()> { + node_info!("anvil_setFeeToken"); + self.ensure_tempo_mode()?; + self.backend.set_fee_token(user, token).await?; + Ok(()) + } + + /// Sets the fee token for a validator address. + /// + /// Handler for RPC call: `anvil_setValidatorFeeToken` + /// + /// Only supported when running in Tempo mode (`--tempo`). + pub async fn anvil_set_validator_fee_token( + &self, + validator: Address, + token: Address, + ) -> Result<()> { + node_info!("anvil_setValidatorFeeToken"); + self.ensure_tempo_mode()?; + self.backend.set_validator_fee_token(validator, token).await?; + Ok(()) + } + + /// Mints FeeAMM liquidity for a token pair. + /// + /// Handler for RPC call: `anvil_setFeeAmmLiquidity` + /// + /// Only supported when running in Tempo mode (`--tempo`). + pub async fn anvil_set_fee_amm_liquidity( + &self, + user_token: Address, + validator_token: Address, + amount: U256, + ) -> Result<()> { + node_info!("anvil_setFeeAmmLiquidity"); + self.ensure_tempo_mode()?; + self.backend.set_fee_amm_liquidity(user_token, validator_token, amount).await?; + Ok(()) + } + + /// Ensures anvil runs in Tempo mode (`--tempo`), the RPC method is unavailable otherwise. + fn ensure_tempo_mode(&self) -> Result<()> { + if self.backend.is_tempo() { Ok(()) } else { Err(BlockchainError::RpcUnimplemented) } + } + + fn parse_transaction_request( + &self, + request: WithOtherFields, + ) -> Result { + self.backend.parse_transaction_request(request) + } + + async fn request_nonce_for_transaction( + &self, + request: &FoundryTransactionRequest, + from: Address, + ) -> Result { + if let FoundryTransactionRequest::Tempo(request) = request + && let Some(nonce_key) = request.nonce_key.filter(|key| !key.is_zero()) + { + if let Some(nonce) = request.nonce() { + return Ok(nonce); + } + let nonce = self.backend.tempo_nonce(from, nonce_key, None).await?; + return Ok(nonce); } + self.request_nonce(request.as_ref(), from).await } } +fn is_simple_transfer_request(request: &TransactionRequest) -> bool { + request.to.as_ref().and_then(TxKind::to).is_some() + && (request.input.input().is_none() + || request.input.input().is_some_and(|data| data.is_empty())) + && request.authorization_list.is_none() + && request.access_list.is_none() + && request.blob_versioned_hashes.is_none() +} + fn required_marker(provided_nonce: u64, on_chain_nonce: u64, from: Address) -> Vec { if provided_nonce == on_chain_nonce { return Vec::new(); @@ -3430,53 +5555,124 @@ fn required_marker(provided_nonce: u64, on_chain_nonce: u64, from: Address) -> V if on_chain_nonce <= prev_nonce { vec![to_marker(prev_nonce, from)] } else { Vec::new() } } -fn convert_transact_out(out: &Option) -> Bytes { - match out { - None => Default::default(), - Some(Output::Call(out)) => out.to_vec().into(), - Some(Output::Create(out, _)) => out.to_vec().into(), +fn tempo_nonce_marker(sender: Address, nonce_key: U256, nonce: u64) -> TxMarker { + let mut marker = b"tempo-nonce".to_vec(); + marker.extend_from_slice(sender.as_slice()); + marker.extend_from_slice(&nonce_key.to_be_bytes::<32>()); + marker.extend_from_slice(&nonce.to_be_bytes()); + marker +} + +#[cfg(feature = "base")] +fn eip8130_channel_marker(sender: Address, nonce_key: U256, nonce_sequence: u64) -> TxMarker { + let mut marker = b"base-eip8130-channel".to_vec(); + marker.extend_from_slice(sender.as_slice()); + marker.extend_from_slice(&nonce_key.to_be_bytes::<32>()); + marker.extend_from_slice(&nonce_sequence.to_be_bytes()); + marker +} + +#[cfg(feature = "base")] +fn eip8130_replay_marker(replay_id: B256) -> TxMarker { + let mut marker = b"base-eip8130-replay".to_vec(); + marker.extend_from_slice(replay_id.as_slice()); + marker +} + +/// Rewrites the Tempo fee payer service encoding of a raw transaction into the standard envelope +/// encoding. +/// +/// Fee payer service clients submit sponsorship requests with a `0x00` placeholder in the fee +/// payer signature field (and the fee token left empty for the sponsor to fill), which the +/// standard envelope decoder rejects. The placeholder is rewritten to the standard encoding of +/// [`FEE_PAYER_SIGNATURE_MARKER`], preserving the sponsored signing state the sender committed +/// to. Returns the normalized raw transaction when the input carries the placeholder and `None` +/// for any other encoding. +fn normalize_fee_payer_service_encoding(raw: &[u8]) -> Option> { + let (tx_type, mut encoded_fields) = raw.split_first()?; + if *tx_type != TEMPO_TX_TYPE_ID { + return None; + } + let PayloadView::List(fields) = Header::decode_raw(&mut encoded_fields).ok()? else { + return None; + }; + if !encoded_fields.is_empty() { + return None; + } + // The fee payer signature is the twelfth field of a Tempo AA transaction. + if fields.get(11).is_none_or(|field| *field != [0x00]) { + return None; + } + + // The standard encoding of the marker signature, mirroring the fee payer signature closure of + // `TempoTransaction::rlp_encode_fields_default`. + let marker = FEE_PAYER_SIGNATURE_MARKER; + let mut marker_field = Vec::new(); + Header { list: true, payload_length: marker.rlp_rs_len() + marker.v().length() } + .encode(&mut marker_field); + marker.write_rlp_vrs(&mut marker_field, marker.v()); + + let mut payload = Vec::new(); + for (index, field) in fields.into_iter().enumerate() { + if index == 11 { + payload.extend_from_slice(&marker_field); + } else { + payload.extend_from_slice(field); + } + } + let mut normalized = vec![TEMPO_TX_TYPE_ID]; + Header { list: true, payload_length: payload.len() }.encode(&mut normalized); + normalized.extend_from_slice(&payload); + Some(normalized) +} + +/// EIP-2718 encodes a transaction held in its JSON-RPC form, reporting the types anvil cannot +/// encode as [`BlockchainError::UnsupportedTransactionEncoding`]. +fn encode_rpc_transaction(transaction: &AnyRpcTransaction) -> Result { + FoundryTxEnvelope::encode_rpc_2718(transaction) + .map_err(|_| BlockchainError::UnsupportedTransactionEncoding(transaction.ty())) +} + +fn txpool_transaction_key(pending_transaction: &PendingTransaction) -> String { + match pending_transaction.transaction.as_ref() { + #[cfg(feature = "base")] + FoundryTxEnvelope::Eip8130(signed) if !signed.tx().nonce_key.is_zero() => { + if signed.tx().nonce_key == Eip8130Constants::NONCE_KEY_MAX { + return signed.tx().replay_id(*pending_transaction.sender()).to_string(); + } + format!("{}:{}", signed.tx().nonce_key, signed.tx().nonce_sequence) + } + FoundryTxEnvelope::Tempo(tx) if !tx.tx().nonce_key.is_zero() => { + let tx = tx.tx(); + format!("{}:{}", tx.nonce_key, tx.nonce) + } + _ => pending_transaction.nonce().to_string(), } } +fn convert_transact_out(out: &Option) -> Bytes { + out.as_ref().map(Output::data).cloned().unwrap_or_default() +} + /// Returns an error if the `exit` code is _not_ ok fn ensure_return_ok(exit: InstructionResult, out: &Option) -> Result { let out = convert_transact_out(out); match exit { return_ok!() => Ok(out), - return_revert!() => Err(InvalidTransactionError::Revert(Some(out.0.into())).into()), + return_revert!() => Err(InvalidTransactionError::Revert(Some(out)).into()), reason => Err(BlockchainError::EvmError(reason)), } } -/// Determines the minimum gas needed for a transaction depending on the transaction kind. -fn determine_base_gas_by_kind(request: &WithOtherFields) -> u128 { - match transaction_request_to_typed(request.clone()) { - Some(request) => match request { - TypedTransactionRequest::Legacy(req) => match req.to { - TxKind::Call(_) => MIN_TRANSACTION_GAS, - TxKind::Create => MIN_CREATE_GAS, - }, - TypedTransactionRequest::EIP1559(req) => match req.to { - TxKind::Call(_) => MIN_TRANSACTION_GAS, - TxKind::Create => MIN_CREATE_GAS, - }, - TypedTransactionRequest::EIP7702(req) => { - MIN_TRANSACTION_GAS - + req.authorization_list.len() as u128 * PER_EMPTY_ACCOUNT_COST as u128 - } - TypedTransactionRequest::EIP2930(req) => match req.to { - TxKind::Call(_) => MIN_TRANSACTION_GAS, - TxKind::Create => MIN_CREATE_GAS, - }, - TypedTransactionRequest::EIP4844(_) => MIN_TRANSACTION_GAS, - TypedTransactionRequest::Deposit(req) => match req.to { - TxKind::Call(_) => MIN_TRANSACTION_GAS, - TxKind::Create => MIN_CREATE_GAS, - }, - }, - // Tighten the gas limit upwards if we don't know the transaction type to avoid deployments - // failing. - _ => MIN_CREATE_GAS, +/// Maps an EVM exit code to the optional `error` string reported in +/// `eth_createAccessList` results. +fn execution_error(exit: InstructionResult) -> Option { + match SuccessOrHalt::::from(exit) { + SuccessOrHalt::Success(_) => None, + SuccessOrHalt::Revert => Some("execution reverted".to_string()), + SuccessOrHalt::Halt(reason) => Some(reason.to_string()), + SuccessOrHalt::FatalExternalError => Some("fatal external error".to_string()), + SuccessOrHalt::Internal(_) => Some("internal EVM error".to_string()), } } @@ -3500,12 +5696,20 @@ impl TryFrom, u128, State)>> for GasEs Err(BlockchainError::InvalidTransaction(InvalidTransactionError::GasTooHigh(_))) => { Ok(Self::OutOfGas) } + // Tempo intrinsic gas errors come through as Message variants + Err(BlockchainError::Message(ref msg)) + if msg.contains("insufficient gas for intrinsic cost") => + { + Ok(Self::OutOfGas) + } Err(err) => Err(err), Ok((exit, output, gas, _)) => match exit { return_ok!() => Ok(Self::Success(gas)), // Revert opcodes: - InstructionResult::Revert => Ok(Self::Revert(output.map(|o| o.into_data()))), + InstructionResult::Revert => { + Ok(Self::Revert(Some(output.map(|o| o.into_data()).unwrap_or_default()))) + } InstructionResult::CallTooDeep | InstructionResult::OutOfFunds | InstructionResult::CreateInitCodeStartingEF00 @@ -3537,8 +5741,199 @@ impl TryFrom, u128, State)>> for GasEs | InstructionResult::CreateContractSizeLimit | InstructionResult::CreateContractStartingWithEF | InstructionResult::CreateInitCodeSizeLimit + | InstructionResult::InvalidImmediateEncoding | InstructionResult::FatalExternalError => Ok(Self::EvmError(exit)), }, } } } + +/// Appends the upstream part of a fee-history range that crosses the fork boundary. +fn merge_pre_fork_fee_history( + response: &mut FeeHistory, + rewards: &mut Vec>, + pre: FeeHistory, + fork_block: u64, +) { + // Upstream may return fewer blocks than requested when its available history starts later. + response.oldest_block = pre.oldest_block; + let count = fork_block.checked_sub(pre.oldest_block).map_or(0, |count| count.saturating_add(1)) + as usize; + + // Base-fee arrays include a trailing next-block entry. The caller computes that block locally. + response.base_fee_per_gas.extend(pre.base_fee_per_gas.into_iter().take(count)); + response.gas_used_ratio.extend(pre.gas_used_ratio.into_iter().take(count)); + if let Some(reward) = pre.reward { + rewards.extend(reward.into_iter().take(count)); + } + + // EIP-4844 fields may be omitted for pre-Cancun blocks. Pad to the actual returned count. + response.base_fee_per_blob_gas.extend(pre.base_fee_per_blob_gas.into_iter().take(count)); + response.base_fee_per_blob_gas.resize(count, 0); + response.blob_gas_used_ratio.extend(pre.blob_gas_used_ratio.into_iter().take(count)); + response.blob_gas_used_ratio.resize(count, 0.0); +} + +fn reward_at_percentile(rewards: &[u128], percentile: f64) -> u128 { + let index = (percentile * REWARD_PERCENTILE_RESOLUTION).round() as usize; + rewards.get(index).copied().unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{NodeConfig, spawn}; + + #[cfg(feature = "base")] + #[tokio::test(flavor = "multi_thread")] + async fn base_requests_are_rejected_without_execution() { + let (api, _handle) = spawn(NodeConfig::test()).await; + for request in [serde_json::json!({ "type": "0x79" }), serde_json::json!({ "calls": [[]] })] + { + let request = serde_json::from_value(request).unwrap(); + assert!(matches!( + api.parse_transaction_request(request), + Err(BlockchainError::BaseTransactionUnsupported) + )); + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn set_rpc_url_installs_context_equivalent_identity_with_new_instance() { + let genesis_timestamp = 1_700_000_000u64; + let (_origin_api, origin_handle) = + spawn(NodeConfig::test().with_genesis_timestamp(Some(genesis_timestamp))).await; + let (api, _handle) = + spawn(NodeConfig::test().with_eth_rpc_url(Some(origin_handle.http_endpoint()))).await; + let (target_api, target_handle) = + spawn(NodeConfig::test().with_genesis_timestamp(Some(genesis_timestamp))).await; + let target_url = target_handle.http_endpoint(); + let fork = api.backend.get_fork().unwrap(); + let identity_before = fork.config.read().endpoint_identity; + + api.anvil_set_rpc_url(target_url.clone()).await.unwrap(); + + let fork = api.backend.get_fork().unwrap(); + let config = fork.config.read(); + assert!(config.endpoint_identity.context_eq(identity_before)); + assert_ne!(config.endpoint_identity.instance_id, identity_before.instance_id); + assert_eq!(config.endpoint_identity.instance_id, Some(target_api.instance_id())); + assert_eq!(config.eth_rpc_url(), Some(target_url.as_str())); + } + + #[tokio::test(flavor = "multi_thread")] + async fn memory_reset_stages_live_fees_after_active_mining() { + let (api, _handle) = spawn(NodeConfig::test()).await; + api.backend.set_base_fee(123); + + // Model an in-flight miner that publishes its final fee update before completing. Reset + // must wait to snapshot that state until mining finishes. + let mining = api.backend.lock_mining().await; + let reset_api = api.clone(); + let reset = tokio::spawn(async move { reset_api.anvil_reset(None).await }); + + tokio::time::timeout(Duration::from_secs(5), async { + while api.lifecycle_lock.try_read().is_ok() { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + + api.backend.set_base_fee(456); + drop(mining); + tokio::time::timeout(Duration::from_secs(5), reset).await.unwrap().unwrap().unwrap(); + + assert_eq!(api.backend.evm_env().read().block_env.basefee, 456); + assert_eq!(api.base_fee().unwrap(), Some(U256::from(crate::eth::fees::INITIAL_BASE_FEE))); + } + + // Regression test for . + #[tokio::test(flavor = "multi_thread")] + async fn fee_history_is_complete_when_cache_entries_are_missing() { + let (api, _handle) = spawn(NodeConfig::test()).await; + let count = 10u64; + api.anvil_mine(Some(U256::from(count)), None).await.unwrap(); + + // Wait until the asynchronous service has consumed all block notifications, then clear + // its cache. With no notifications left to repopulate it, this deterministically exercises + // the RPC handler's fallback for canonical blocks whose cache entries are absent. + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if (1..=count).all(|number| api.fee_history_cache.lock().contains_key(&number)) { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + api.fee_history_cache.lock().clear(); + + let fee_history = + api.fee_history(U256::from(count), BlockNumber::Latest, vec![50.0]).await.unwrap(); + + assert_eq!(fee_history.oldest_block, 1); + assert_eq!(fee_history.gas_used_ratio.len(), count as usize); + assert_eq!(fee_history.blob_gas_used_ratio.len(), count as usize); + assert_eq!(fee_history.base_fee_per_gas.len(), count as usize + 1); + assert_eq!(fee_history.base_fee_per_blob_gas.len(), count as usize + 1); + let rewards = fee_history.reward.unwrap(); + assert_eq!(rewards.len(), count as usize); + assert!(rewards.iter().all(|reward| reward.len() == 1)); + } + + #[tokio::test(flavor = "multi_thread")] + async fn fee_history_rejects_invalid_reward_percentiles() { + let (api, _handle) = spawn(NodeConfig::test()).await; + + for percentiles in [vec![-0.5], vec![100.5], vec![50.0, 25.0], vec![50.0, 50.0]] { + let err = + api.fee_history(U256::ONE, BlockNumber::Latest, percentiles).await.unwrap_err(); + assert!(matches!( + err, + BlockchainError::FeeHistory(FeeHistoryError::InvalidRewardPercentiles) + )); + } + + for percentiles in [vec![], vec![0.0, 100.0]] { + api.fee_history(U256::ONE, BlockNumber::Latest, percentiles).await.unwrap(); + } + } + + #[test] + fn fractional_reward_percentiles_use_cache_resolution() { + let rewards = (0..=200).collect::>(); + + assert_eq!(reward_at_percentile(&rewards, 0.0), 0); + assert_eq!(reward_at_percentile(&rewards, 0.5), 1); + assert_eq!(reward_at_percentile(&rewards, 1.0), 2); + assert_eq!(reward_at_percentile(&rewards, 100.0), 200); + } + + #[test] + fn shortened_pre_fork_fee_history_uses_upstream_start() { + let requested_lowest = 4; + let fork_block = 10; + let pre = FeeHistory { + oldest_block: 5, + base_fee_per_gas: vec![1; 7], + gas_used_ratio: vec![0.0; 6], + reward: Some(vec![vec![]; 6]), + base_fee_per_blob_gas: vec![], + blob_gas_used_ratio: vec![], + }; + + let mut response = FeeHistory { oldest_block: requested_lowest, ..Default::default() }; + let mut rewards = Vec::new(); + + merge_pre_fork_fee_history(&mut response, &mut rewards, pre, fork_block); + + assert_eq!(response.oldest_block, 5); + assert_eq!(response.gas_used_ratio.len(), 6); + assert_eq!(response.base_fee_per_gas.len(), 6); + assert_eq!(response.blob_gas_used_ratio.len(), 6); + assert_eq!(response.base_fee_per_blob_gas.len(), 6); + assert_eq!(rewards.len(), 6); + } +} diff --git a/crates/anvil/src/eth/backend/cheats.rs b/crates/anvil/src/eth/backend/cheats.rs index dca01fe438a6d..089eca74e9335 100644 --- a/crates/anvil/src/eth/backend/cheats.rs +++ b/crates/anvil/src/eth/backend/cheats.rs @@ -1,8 +1,20 @@ //! Support for "cheat codes" / bypass functions -use alloy_primitives::{Address, map::AddressHashSet}; +use alloy_evm::precompiles::{Precompile, PrecompileInput}; +use alloy_primitives::{ + Address, B256, Bytes, + map::{AddressHashSet, foldhash::HashMap}, +}; use parking_lot::RwLock; -use std::sync::Arc; +use revm::precompile::{ + PrecompileHalt, PrecompileId, PrecompileOutput, PrecompileResult, call_eth_precompile, + secp256k1::ec_recover_run, utilities::right_pad, +}; +use std::{borrow::Cow, sync::Arc}; + +/// ID for the [`CheatEcrecover::precompile_id`] precompile. +static PRECOMPILE_ID_CHEAT_ECRECOVER: PrecompileId = + PrecompileId::Custom(Cow::Borrowed("cheat_ecrecover")); /// Manages user modifications that may affect the node's behavior /// @@ -16,25 +28,18 @@ pub struct CheatsManager { impl CheatsManager { /// Sets the account to impersonate /// - /// This also accepts the actual code hash if the address is a contract to bypass EIP-3607 - /// /// Returns `true` if the account is already impersonated pub fn impersonate(&self, addr: Address) -> bool { - trace!(target: "cheats", "Start impersonating {:?}", addr); - let mut state = self.state.write(); + trace!(target: "cheats", %addr, "start impersonating"); // When somebody **explicitly** impersonates an account we need to store it so we are able // to return it from `eth_accounts`. That's why we do not simply call `is_impersonated()` // which does not check that list when auto impersonation is enabled. - if state.impersonated_accounts.contains(&addr) { - // need to check if already impersonated, so we don't overwrite the code - return true; - } - state.impersonated_accounts.insert(addr) + !self.state.write().impersonated_accounts.insert(addr) } /// Removes the account that from the impersonated set pub fn stop_impersonating(&self, addr: &Address) { - trace!(target: "cheats", "Stop impersonating {:?}", addr); + trace!(target: "cheats", %addr, "stop impersonating"); self.state.write().impersonated_accounts.remove(addr); } @@ -63,6 +68,71 @@ impl CheatsManager { pub fn impersonated_accounts(&self) -> AddressHashSet { self.state.read().impersonated_accounts.clone() } + + /// Registers an override so that `ecrecover(signature)` returns `addr`. + pub fn add_recover_override(&self, sig: Bytes, addr: Address) { + self.state.write().signature_overrides.insert(sig, addr); + } + + /// If an override exists for `sig`, returns the address; otherwise `None`. + pub fn get_recover_override(&self, sig: &Bytes) -> Option
{ + self.state.read().signature_overrides.get(sig).copied() + } + + /// Returns true if any ecrecover overrides have been registered. + pub fn has_recover_overrides(&self) -> bool { + !self.state.read().signature_overrides.is_empty() + } + + /// Sets the `prevrandao` value to use for the next mined block. + /// + /// This is a one-shot override that is consumed by the next block and applies to that block + /// only. + pub fn set_next_block_prevrandao(&self, prevrandao: B256) { + trace!(target: "cheats", %prevrandao, "set next block prevrandao"); + self.state.write().next_block.prevrandao.replace(Some(prevrandao)); + } + + /// Sets the parent beacon block root to use for the next mined block. + /// + /// This is a one-shot override that is consumed by the next block and applies to that block + /// only. + pub fn set_next_block_parent_beacon_block_root(&self, root: B256) { + trace!(target: "cheats", %root, "set next block parent beacon block root"); + self.state.write().next_block.parent_beacon_block_root.replace(Some(root)); + } + + /// Returns the manually set values for the next block without consuming them. + pub(crate) fn next_block_overrides(&self) -> NextBlockOverrides { + self.state.read().next_block + } + + /// Takes the manually set `prevrandao` value for forced replay mining. + pub fn take_next_block_prevrandao(&self) -> Option { + self.state.write().next_block.prevrandao.replace(None) + } + + /// Consumes the overrides a committed block was built with, keeping any set since. + pub(crate) fn consume_next_block_overrides(&self, used: &NextBlockOverrides) { + let mut state = self.state.write(); + state.next_block.prevrandao.consume(&used.prevrandao); + state.next_block.parent_beacon_block_root.consume(&used.parent_beacon_block_root); + } + + /// Restores the overrides saved with a state snapshot. + pub(crate) fn restore_next_block_overrides(&self, saved: &NextBlockOverrides) { + let mut state = self.state.write(); + state.next_block.prevrandao.replace(saved.prevrandao.value); + state.next_block.parent_beacon_block_root.replace(saved.parent_beacon_block_root.value); + } + + /// Clears any manually set values for the next block. + /// + /// Used on reset/revert so a set-but-unmined override does not leak into a later block, + /// mirroring how the next-block timestamp override is cleared by `TimeManager::reset`. + pub fn clear_next_block_overrides(&self) { + self.restore_next_block_overrides(&NextBlockOverrides::default()); + } } /// Container type for all the state variables @@ -72,4 +142,119 @@ pub struct CheatsState { pub impersonated_accounts: AddressHashSet, /// If set to true will make the `is_impersonated` function always return true pub auto_impersonate_accounts: bool, + /// Overrides for ecrecover: Signature => Address + pub signature_overrides: HashMap, + /// Manually set values for the next mined block. + pub(crate) next_block: NextBlockOverrides, +} + +/// Values manually set for the next mined block. +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct NextBlockOverrides { + /// Set via `anvil_setNextBlockPrevRandao`. + pub(crate) prevrandao: NextBlockOverride, + /// Set via `anvil_setNextBlockParentBeaconBlockRoot`. + pub(crate) parent_beacon_block_root: NextBlockOverride, +} + +/// A one-shot override of a value of the next mined block. +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct NextBlockOverride { + pub(crate) value: Option, + /// Bumped on every replacement, so a block consumes only the value it was built with. + generation: u64, +} + +impl NextBlockOverride { + /// Replaces the value, returning the previous one. + const fn replace(&mut self, value: Option) -> Option { + self.generation = self.generation.wrapping_add(1); + std::mem::replace(&mut self.value, value) + } + + /// Clears the value if it is still the one `used` was taken from. + fn consume(&mut self, used: &Self) { + if self.generation == used.generation { + self.value = None; + } + } +} + +impl CheatEcrecover { + pub const fn new(cheats: Arc) -> Self { + Self { cheats } + } +} + +impl Precompile for CheatEcrecover { + fn call(&self, input: PrecompileInput<'_>) -> PrecompileResult { + if !self.cheats.has_recover_overrides() { + return Ok(call_eth_precompile( + ec_recover_run, + input.data, + input.gas(), + input.reservoir, + )); + } + + const ECRECOVER_BASE: u64 = 3_000; + if input.gas() < ECRECOVER_BASE { + return Ok(PrecompileOutput::halt(PrecompileHalt::OutOfGas, input.reservoir)); + } + let padded = right_pad::<128>(input.data); + let v = padded[63]; + let mut sig_bytes = [0u8; 65]; + sig_bytes[..64].copy_from_slice(&padded[64..128]); + sig_bytes[64] = v; + let sig_bytes_wrapped = Bytes::from(sig_bytes); + if let Some(addr) = self.cheats.get_recover_override(&sig_bytes_wrapped) { + let mut out = [0u8; 32]; + out[12..].copy_from_slice(addr.as_slice()); + return Ok(PrecompileOutput::new(ECRECOVER_BASE, out.into(), input.reservoir)); + } + Ok(call_eth_precompile(ec_recover_run, input.data, input.gas(), input.reservoir)) + } + + fn precompile_id(&self) -> &PrecompileId { + &PRECOMPILE_ID_CHEAT_ECRECOVER + } + + fn supports_caching(&self) -> bool { + false + } +} + +/// A custom ecrecover precompile that supports cheat-based signature overrides. +#[derive(Clone, Debug)] +pub struct CheatEcrecover { + cheats: Arc, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn candidate_consumes_only_its_next_block_overrides() { + let cheats = CheatsManager::default(); + let value = B256::with_last_byte(1); + cheats.set_next_block_prevrandao(value); + cheats.set_next_block_parent_beacon_block_root(value); + let used = cheats.next_block_overrides(); + + cheats.set_next_block_prevrandao(value); + cheats.consume_next_block_overrides(&used); + + let overrides = cheats.next_block_overrides(); + assert_eq!(overrides.prevrandao.value, Some(value)); + assert_eq!(overrides.parent_beacon_block_root.value, None); + } + + #[test] + fn impersonate_returns_false_then_true() { + let mgr = CheatsManager::default(); + let addr = Address::repeat_byte(1u8); + assert!(!mgr.impersonate(addr)); + assert!(mgr.impersonate(addr)); + } } diff --git a/crates/anvil/src/eth/backend/db.rs b/crates/anvil/src/eth/backend/db.rs index 4b26e23987da4..3d73e7e03a676 100644 --- a/crates/anvil/src/eth/backend/db.rs +++ b/crates/anvil/src/eth/backend/db.rs @@ -1,45 +1,91 @@ -//! Helper types for working with [revm](foundry_evm::revm) +//! Helper types for working with [revm] use crate::mem::storage::MinedTransaction; -use alloy_consensus::Header; -use alloy_primitives::{Address, B256, Bytes, U256, keccak256, map::HashMap}; +use alloy_consensus::BlockBody; +use alloy_eips::{eip4895::Withdrawals, eip7928::BlockAccessList}; +use alloy_evm::block::BalIndexedDatabase; +use alloy_network::Network; +use alloy_primitives::{ + Address, B256, Bytes, U256, keccak256, + map::{AddressMap, HashMap, U256Map}, +}; use alloy_rpc_types::BlockId; use anvil_core::eth::{ block::Block, - transaction::{MaybeImpersonatedTransaction, TransactionInfo, TypedReceipt, TypedTransaction}, + transaction::{MaybeImpersonatedTransaction, TransactionInfo}, }; use foundry_common::errors::FsPathError; use foundry_evm::backend::{ - BlockchainDb, DatabaseError, DatabaseResult, MemDb, RevertStateSnapshotAction, StateSnapshot, + BlockchainDb, DatabaseError, DatabaseResult, EmptyDBWrapper, MemDb, RevertStateSnapshotAction, + StateSnapshot, existing_account, }; +use foundry_primitives::{FoundryHeader, FoundryReceiptEnvelope, FoundryTxEnvelope}; use revm::{ Database, DatabaseCommit, bytecode::Bytecode, context::BlockEnv, - database::{CacheDB, DatabaseRef, DbAccount}, - primitives::KECCAK_EMPTY, - state::AccountInfo, + context_interface::block::BlobExcessGasAndPrice, + database::{AccountState, CacheDB, DatabaseRef, DbAccount, bal::BalState}, + primitives::{KECCAK_EMPTY, eip4844::BLOB_BASE_FEE_UPDATE_FRACTION_PRAGUE, hardfork::SpecId}, + state::{AccountInfo, bal::BlockAccessIndex}, }; use serde::{ Deserialize, Deserializer, Serialize, - de::{MapAccess, Visitor}, + de::{Error as DeError, MapAccess, Visitor}, }; +use serde_json::Value; use std::{ collections::BTreeMap, fmt::{self, Debug}, + fs::File, + io::BufReader, path::Path, }; +/// Number of preceding block hashes available to the EVM's `BLOCKHASH` opcode. +pub(crate) const BLOCKHASH_HISTORY: u64 = 256; + +/// Execution inputs needed to replay a locally stored Monad block faithfully. +#[cfg(feature = "monad")] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct MonadBlockReplayProfile { + /// Chain ID active when the block was executed. + pub execution_chain_id: u64, + /// Monad hardfork active when the block was executed. + pub hardfork: foundry_evm::hardfork::MonadHardfork, +} + +/// Inserts a block hash, discards entries outside the EVM-visible cache, and returns its head. +pub(crate) fn cache_block_hash(block_hashes: &mut U256Map, number: U256, hash: B256) -> U256 { + let head = block_hashes.keys().copied().max().map_or(number, |head| head.max(number)); + let min_number = head.saturating_sub(U256::from(BLOCKHASH_HISTORY)); + block_hashes.retain(|cached, _| *cached >= min_number && *cached <= head); + if number >= min_number { + block_hashes.insert(number, hash); + } + head +} + /// Helper trait get access to the full state data of the database pub trait MaybeFullDatabase: DatabaseRef + Debug { - /// Returns a reference to the database as a `dyn DatabaseRef`. - // TODO: Required until trait upcasting is stabilized: - fn as_dyn(&self) -> &dyn DatabaseRef; + fn maybe_as_full_db(&self) -> Option<&AddressMap> { + None + } - fn maybe_as_full_db(&self) -> Option<&HashMap> { + fn maybe_as_full_db_mut(&mut self) -> Option<&mut AddressMap> { None } + /// Returns an owned, recursively merged view of all available accounts. + fn maybe_full_db(&self) -> Option> { + self.maybe_as_full_db().cloned() + } + + /// Returns whether snapshots of this database use structural sharing. + fn is_persistent(&self) -> bool { + false + } + /// Clear the state and move it into a new `StateSnapshot`. fn clear_into_state_snapshot(&mut self) -> StateSnapshot; @@ -59,12 +105,16 @@ impl<'a, T: 'a + MaybeFullDatabase + ?Sized> MaybeFullDatabase for &'a T where &'a T: DatabaseRef, { - fn as_dyn(&self) -> &dyn DatabaseRef { - T::as_dyn(self) + fn maybe_as_full_db(&self) -> Option<&AddressMap> { + T::maybe_as_full_db(self) } - fn maybe_as_full_db(&self) -> Option<&HashMap> { - T::maybe_as_full_db(self) + fn maybe_full_db(&self) -> Option> { + T::maybe_full_db(self) + } + + fn is_persistent(&self) -> bool { + T::is_persistent(self) } fn clear_into_state_snapshot(&mut self) -> StateSnapshot { @@ -80,15 +130,188 @@ where fn init_from_state_snapshot(&mut self, _state_snapshot: StateSnapshot) {} } +impl MaybeFullDatabase for Box +where + Self: DatabaseRef, +{ + fn maybe_as_full_db(&self) -> Option<&AddressMap> { + T::maybe_as_full_db(self) + } + + fn maybe_as_full_db_mut(&mut self) -> Option<&mut AddressMap> { + T::maybe_as_full_db_mut(self) + } + + fn maybe_full_db(&self) -> Option> { + T::maybe_full_db(self) + } + + fn is_persistent(&self) -> bool { + T::is_persistent(self) + } + + fn clear_into_state_snapshot(&mut self) -> StateSnapshot { + T::clear_into_state_snapshot(self) + } + + fn read_as_state_snapshot(&self) -> StateSnapshot { + T::read_as_state_snapshot(self) + } + + fn clear(&mut self) { + T::clear(self) + } + + fn init_from_state_snapshot(&mut self, state_snapshot: StateSnapshot) { + T::init_from_state_snapshot(self, state_snapshot) + } +} + /// Helper trait to reset the DB if it's forked pub trait MaybeForkedDatabase { - fn maybe_reset(&mut self, _url: Option, block_number: BlockId) -> Result<(), String>; + fn maybe_reset(&mut self, _urls: Vec, block_number: BlockId) -> Result<(), String>; fn maybe_flush_cache(&self) -> Result<(), String>; fn maybe_inner(&self) -> Result<&BlockchainDb, String>; } +/// `dyn Db` satisfies all `alloy_evm::Database` requirements via its supertraits, but the +/// blanket impl has an implicit `Sized` bound. Provide an explicit impl. +impl alloy_evm::Database for dyn Db {} + +/// A wrapper around [`CacheDB`] that executes transactions at `spec`. +#[derive(Debug)] +pub struct AnvilCacheDB(pub CacheDB, BalState, SpecId); + +impl> AnvilCacheDB { + pub fn new(inner: T, spec: SpecId) -> Self { + Self(CacheDB::new(inner), BalState::new(), spec) + } + + /// Enables EIP-7928 block access list recording. + pub fn enable_bal_recording(&mut self) { + self.1 = BalState::new().with_bal_builder(); + } + + /// Takes the recorded EIP-7928 block access list, if recording was enabled. + pub fn take_block_access_list(&mut self) -> Option { + self.1.take_built_alloy_bal() + } +} + +impl> std::ops::Deref for AnvilCacheDB { + type Target = CacheDB; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl> std::ops::DerefMut for AnvilCacheDB { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl + fmt::Debug> Database for AnvilCacheDB { + type Error = DatabaseError; + + fn basic(&mut self, address: Address) -> Result, Self::Error> { + Ok(existing_account(self.2, self.0.basic(address)?)) + } + + fn code_by_hash(&mut self, code_hash: B256) -> Result { + self.0.code_by_hash(code_hash) + } + + fn storage(&mut self, address: Address, index: U256) -> Result { + self.0.storage(address, index) + } + + fn block_hash(&mut self, number: u64) -> Result { + self.0.block_hash(number) + } +} + +impl> DatabaseRef for AnvilCacheDB { + type Error = DatabaseError; + + fn basic_ref(&self, address: Address) -> Result, Self::Error> { + self.0.basic_ref(address) + } + + fn code_by_hash_ref(&self, code_hash: B256) -> Result { + self.0.code_by_hash_ref(code_hash) + } + + fn storage_ref(&self, address: Address, index: U256) -> Result { + self.0.storage_ref(address, index) + } + + fn block_hash_ref(&self, number: u64) -> Result { + self.0.block_hash_ref(number) + } +} + +impl + fmt::Debug> DatabaseCommit for AnvilCacheDB { + fn commit(&mut self, changes: revm::state::EvmState) { + self.1.commit(&changes); + self.0.commit(changes) + } +} + +impl + fmt::Debug> BalIndexedDatabase for AnvilCacheDB { + fn set_bal_index(&mut self, index: u64) { + self.1.bal_index = BlockAccessIndex::new(index); + } + + fn bump_bal_index(&mut self) { + self.1.bump_bal_index(); + } +} + +impl + fmt::Debug> BalIndexedDatabase + for &mut AnvilCacheDB +{ + fn set_bal_index(&mut self, index: u64) { + (**self).set_bal_index(index); + } + + fn bump_bal_index(&mut self) { + (**self).bump_bal_index(); + } +} + +/// A read-only view of a database that reports empty accounts as absent. +/// +/// Anvil's databases return a default [`AccountInfo`] for an account they don't hold (see +/// [`EmptyDBWrapper`]), so an absent account can't be told apart from an empty one. Since +/// EIP-161 an empty account can't be created and is deleted when touched, so this view lets a +/// pre-state lookup treat both as absent. An account that already exists while empty, from genesis +/// or before Spurious Dragon, is also reported as absent. +#[derive(Debug)] +pub(super) struct EmptyAsAbsentDb(pub(super) T); + +impl DatabaseRef for EmptyAsAbsentDb { + type Error = T::Error; + + fn basic_ref(&self, address: Address) -> Result, Self::Error> { + Ok(self.0.basic_ref(address)?.filter(|info| !info.is_empty())) + } + + fn code_by_hash_ref(&self, code_hash: B256) -> Result { + self.0.code_by_hash_ref(code_hash) + } + + fn storage_ref(&self, address: Address, index: U256) -> Result { + self.0.storage_ref(address, index) + } + + fn block_hash_ref(&self, number: u64) -> Result { + self.0.block_hash_ref(number) + } +} + /// This bundles all required revm traits pub trait Db: DatabaseRef @@ -119,7 +342,7 @@ pub trait Db: Ok(()) } - /// Sets the balance of the given address + /// Sets the code of the given address fn set_code(&mut self, address: Address, code: Bytes) -> DatabaseResult<()> { let mut info = self.basic(address)?.unwrap_or_default(); let code_hash = if code.as_ref().is_empty() { @@ -128,17 +351,20 @@ pub trait Db: B256::from_slice(&keccak256(code.as_ref())[..]) }; info.code_hash = code_hash; - info.code = Some(Bytecode::new_raw(alloy_primitives::Bytes(code.0))); + info.code = Some(Bytecode::new_raw(code)); self.insert_account(address, info); Ok(()) } - /// Sets the balance of the given address + /// Sets the storage value at the given slot for the address fn set_storage_at(&mut self, address: Address, slot: B256, val: B256) -> DatabaseResult<()>; /// inserts a blockhash for the given number fn insert_block_hash(&mut self, number: U256, hash: B256); + /// Replaces all cached block hashes. + fn set_block_hashes(&mut self, block_hashes: Vec<(U256, B256)>); + /// Write all chain data to serialized bytes buffer fn dump_state( &self, @@ -149,9 +375,9 @@ pub trait Db: historical_states: Option, ) -> DatabaseResult>; - /// Deserialize and add all chain data to the backend storage + /// Deserialize and add all accounts to the backend storage. fn load_state(&mut self, state: SerializableState) -> DatabaseResult { - for (addr, account) in state.accounts.into_iter() { + for (addr, account) in state.accounts { let old_account_nonce = DatabaseRef::basic_ref(self, addr) .ok() .and_then(|acc| acc.map(|acc| acc.nonce)) @@ -168,13 +394,14 @@ pub trait Db: code: if account.code.0.is_empty() { None } else { - Some(Bytecode::new_raw(alloy_primitives::Bytes(account.code.0))) + Some(Bytecode::new_raw(account.code)) }, nonce, + account_id: None, }, ); - for (k, v) in account.storage.into_iter() { + for (k, v) in account.storage { self.set_storage_at(addr, k, v)?; } } @@ -189,6 +416,9 @@ pub trait Db: /// Returns `true` if the state snapshot was reverted. fn revert_state(&mut self, state_snapshot: U256, action: RevertStateSnapshotAction) -> bool; + /// Deletes a state snapshot without reverting it. + fn delete_state_snapshot(&mut self, state_snapshot: U256) -> bool; + /// Returns the state root if possible to compute fn maybe_state_root(&self) -> Option { None @@ -198,18 +428,14 @@ pub trait Db: fn current_state(&self) -> StateDb; } -impl dyn Db { - // TODO: Required until trait upcasting is stabilized: - pub fn as_dbref(&self) -> &dyn DatabaseRef { - self.as_dyn() - } -} - /// Convenience impl only used to use any `Db` on the fly as the db layer for revm's CacheDB /// This is useful to create blocks without actually writing to the `Db`, but rather in the cache of /// the `CacheDB` see also /// [Backend::pending_block()](crate::eth::backend::mem::Backend::pending_block()) -impl + Send + Sync + Clone + fmt::Debug> Db for CacheDB { +impl Db for CacheDB +where + T: DatabaseRef + MaybeFullDatabase + Send + Sync + Clone + fmt::Debug, +{ fn insert_account(&mut self, address: Address, account: AccountInfo) { self.insert_account_info(address, account) } @@ -219,7 +445,11 @@ impl + Send + Sync + Clone + fmt::Debug> D } fn insert_block_hash(&mut self, number: U256, hash: B256) { - self.cache.block_hashes.insert(number, hash); + cache_block_hash(&mut self.cache.block_hashes, number, hash); + } + + fn set_block_hashes(&mut self, block_hashes: Vec<(U256, B256)>) { + self.cache.block_hashes = block_hashes.into_iter().collect(); } fn dump_state( @@ -241,18 +471,43 @@ impl + Send + Sync + Clone + fmt::Debug> D false } + fn delete_state_snapshot(&mut self, _state_snapshot: U256) -> bool { + false + } + + fn maybe_state_root(&self) -> Option { + self.maybe_full_db().map(|accounts| crate::mem::state::state_root(&accounts)) + } + fn current_state(&self) -> StateDb { StateDb::new(MemDb::default()) } } -impl + Debug> MaybeFullDatabase for CacheDB { - fn as_dyn(&self) -> &dyn DatabaseRef { - self +impl MaybeFullDatabase for CacheDB { + fn maybe_as_full_db(&self) -> Option<&AddressMap> { + Some(&self.cache.accounts) } - fn maybe_as_full_db(&self) -> Option<&HashMap> { - Some(&self.cache.accounts) + fn maybe_full_db(&self) -> Option> { + let mut accounts = self.db.maybe_full_db()?; + for (address, overlay) in &self.cache.accounts { + if overlay.account_state == AccountState::NotExisting { + accounts.remove(address); + continue; + } + if overlay.account_state.is_storage_cleared() { + accounts.insert(*address, overlay.clone()); + continue; + } + + let mut account = accounts.remove(address).unwrap_or_default(); + account.info = overlay.info.clone(); + account.account_state = overlay.account_state.clone(); + account.storage.extend(overlay.storage.clone()); + accounts.insert(*address, account); + } + Some(accounts) } fn clear_into_state_snapshot(&mut self) -> StateSnapshot { @@ -263,7 +518,7 @@ impl + Debug> MaybeFullDatabase for CacheD for (addr, mut acc) in db_accounts { account_storage.insert(addr, std::mem::take(&mut acc.storage)); let mut info = acc.info; - info.code = self.cache.contracts.remove(&info.code_hash); + info.code = self.cache.contracts.remove(&info.code_hash()); accounts.insert(addr, info); } let block_hashes = std::mem::take(&mut self.cache.block_hashes); @@ -271,15 +526,14 @@ impl + Debug> MaybeFullDatabase for CacheD } fn read_as_state_snapshot(&self) -> StateSnapshot { - let db_accounts = self.cache.accounts.clone(); let mut accounts = HashMap::default(); let mut account_storage = HashMap::default(); - for (addr, acc) in db_accounts { - account_storage.insert(addr, acc.storage.clone()); - let mut info = acc.info; - info.code = self.cache.contracts.get(&info.code_hash).cloned(); - accounts.insert(addr, info); + for (addr, acc) in &self.cache.accounts { + account_storage.insert(*addr, acc.storage.clone()); + let mut info = acc.info.clone(); + info.code = self.cache.contracts.get(&info.code_hash()).cloned(); + accounts.insert(*addr, info); } let block_hashes = self.cache.block_hashes.clone(); @@ -295,7 +549,7 @@ impl + Debug> MaybeFullDatabase for CacheD for (addr, mut acc) in accounts { if let Some(code) = acc.code.take() { - self.cache.contracts.insert(acc.code_hash, code); + self.cache.contracts.insert(acc.code_hash(), code); } self.cache.accounts.insert( addr, @@ -310,8 +564,22 @@ impl + Debug> MaybeFullDatabase for CacheD } } +impl MaybeFullDatabase for EmptyDBWrapper { + fn clear_into_state_snapshot(&mut self) -> StateSnapshot { + StateSnapshot::default() + } + + fn read_as_state_snapshot(&self) -> StateSnapshot { + StateSnapshot::default() + } + + fn clear(&mut self) {} + + fn init_from_state_snapshot(&mut self, _state_snapshot: StateSnapshot) {} +} + impl> MaybeForkedDatabase for CacheDB { - fn maybe_reset(&mut self, _url: Option, _block_number: BlockId) -> Result<(), String> { + fn maybe_reset(&mut self, _urls: Vec, _block_number: BlockId) -> Result<(), String> { Err("not supported".to_string()) } @@ -360,12 +628,16 @@ impl DatabaseRef for StateDb { } impl MaybeFullDatabase for StateDb { - fn as_dyn(&self) -> &dyn DatabaseRef { - self.0.as_dyn() + fn maybe_as_full_db(&self) -> Option<&AddressMap> { + self.0.maybe_as_full_db() } - fn maybe_as_full_db(&self) -> Option<&HashMap> { - self.0.maybe_as_full_db() + fn maybe_full_db(&self) -> Option> { + self.0.maybe_full_db() + } + + fn is_persistent(&self) -> bool { + self.0.is_persistent() } fn clear_into_state_snapshot(&mut self) -> StateSnapshot { @@ -385,19 +657,151 @@ impl MaybeFullDatabase for StateDb { } } +/// Legacy block environment from before v1.3. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct LegacyBlockEnv { + pub number: Option, + #[serde(alias = "coinbase")] + pub beneficiary: Option
, + pub timestamp: Option, + pub gas_limit: Option, + pub basefee: Option, + pub difficulty: Option, + pub prevrandao: Option, + pub blob_excess_gas_and_price: Option, +} + +/// Legacy blob excess gas and price structure from before v1.3. +#[derive(Debug, Deserialize)] +pub struct LegacyBlobExcessGasAndPrice { + pub excess_blob_gas: u64, + pub blob_gasprice: u128, +} + +/// Legacy string or u64 type from before v1.3. +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub enum StringOrU64 { + Hex(String), + Dec(u64), +} + +impl StringOrU64 { + pub fn to_u64(&self) -> Option { + match self { + Self::Dec(n) => Some(*n), + Self::Hex(s) => s.strip_prefix("0x").and_then(|s| u64::from_str_radix(s, 16).ok()), + } + } + + pub fn to_u256(&self) -> Option { + match self { + Self::Dec(n) => Some(U256::from(*n)), + Self::Hex(s) => s.strip_prefix("0x").and_then(|s| U256::from_str_radix(s, 16).ok()), + } + } +} + +/// Converts a `LegacyBlockEnv` to a `BlockEnv`, handling the conversion of legacy fields. +impl TryFrom for BlockEnv { + type Error = &'static str; + + fn try_from(legacy: LegacyBlockEnv) -> Result { + Ok(Self { + number: legacy.number.and_then(|v| v.to_u256()).unwrap_or(U256::ZERO), + beneficiary: legacy.beneficiary.unwrap_or(Address::ZERO), + timestamp: legacy.timestamp.and_then(|v| v.to_u256()).unwrap_or(U256::ONE), + gas_limit: legacy.gas_limit.and_then(|v| v.to_u64()).unwrap_or(u64::MAX), + basefee: legacy.basefee.and_then(|v| v.to_u64()).unwrap_or(0), + difficulty: legacy.difficulty.and_then(|v| v.to_u256()).unwrap_or(U256::ZERO), + prevrandao: legacy.prevrandao.or(Some(B256::ZERO)), + slot_num: 0, + blob_excess_gas_and_price: legacy + .blob_excess_gas_and_price + .map(|v| BlobExcessGasAndPrice { + excess_blob_gas: v.excess_blob_gas, + blob_gasprice: v.blob_gasprice, + }) + .or_else(|| { + Some(BlobExcessGasAndPrice::new(0, BLOB_BASE_FEE_UPDATE_FRACTION_PRAGUE)) + }), + }) + } +} + +/// Custom deserializer for `BlockEnv` that handles both v1.2 and v1.3+ formats. +fn deserialize_block_env_compat<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let value: Option = Option::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(None); + }; + + if let Ok(env) = BlockEnv::deserialize(&value) { + return Ok(Some(env)); + } + + let legacy: LegacyBlockEnv = serde_json::from_value(value).map_err(|e| { + D::Error::custom(format!("Legacy deserialization of `BlockEnv` failed: {e}")) + })?; + + Ok(Some(BlockEnv::try_from(legacy).map_err(D::Error::custom)?)) +} + +/// Custom deserializer for `best_block_number` that handles both v1.2 and v1.3+ formats. +fn deserialize_best_block_number_compat<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let value: Option = Option::deserialize(deserializer)?; + let Some(value) = value else { + return Ok(None); + }; + + let number = match value { + Value::Number(n) => n.as_u64(), + Value::String(s) => { + if let Some(s) = s.strip_prefix("0x") { + u64::from_str_radix(s, 16).ok() + } else { + s.parse().ok() + } + } + _ => None, + }; + + Ok(number) +} + #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct SerializableState { /// The block number of the state /// /// Note: This is an Option for backwards compatibility: + #[serde(deserialize_with = "deserialize_block_env_compat")] pub block: Option, pub accounts: BTreeMap, /// The best block number of the state, can be different from block number (Arbitrum chain). + #[serde(deserialize_with = "deserialize_best_block_number_compat")] pub best_block_number: Option, #[serde(default)] pub blocks: Vec, #[serde(default)] pub transactions: Vec, + /// Authoritative Monad senders and EIP-7702 authorities for locally stored blocks. + /// + /// This metadata can differ from transaction-body recovery when signature impersonation was + /// used, so it is preserved even while the corresponding transaction bodies are retained. + #[cfg(feature = "monad")] + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub monad_block_participants: BTreeMap>, + /// Execution profile used for each locally stored Monad block. + #[cfg(feature = "monad")] + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub monad_block_replay_profiles: BTreeMap, /// Historical states of accounts and storage at particular block hashes. /// /// Note: This is an Option for backwards compatibility. @@ -408,16 +812,23 @@ pub struct SerializableState { impl SerializableState { /// Loads the `Genesis` object from the given json file path pub fn load(path: impl AsRef) -> Result { - let path = path.as_ref(); + let mut path = path.as_ref().to_path_buf(); if path.is_dir() { - foundry_common::fs::read_json_file(&path.join("state.json")) - } else { - foundry_common::fs::read_json_file(path) + path = path.join("state.json"); } + + let file = File::open(&path).map_err(|err| FsPathError::read(err, &path))?; + serde_json::from_reader(BufReader::new(file)).map_err(|err| { + if err.is_io() { + FsPathError::read(err.into(), &path) + } else { + FsPathError::ReadJson { source: err, path } + } + }) } /// This is used as the clap `value_parser` implementation - #[allow(dead_code)] + #[cfg(feature = "cmd")] pub(crate) fn parse(path: &str) -> Result { Self::load(path).map_err(|err| err.to_string()) } @@ -472,44 +883,46 @@ where #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(untagged)] pub enum SerializableTransactionType { - TypedTransaction(TypedTransaction), - MaybeImpersonatedTransaction(MaybeImpersonatedTransaction), + TypedTransaction(FoundryTxEnvelope), + MaybeImpersonatedTransaction(MaybeImpersonatedTransaction), } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct SerializableBlock { - pub header: Header, + pub header: FoundryHeader, pub transactions: Vec, - pub ommers: Vec
, + pub ommers: Vec, + #[serde(default)] + pub withdrawals: Option, } impl From for SerializableBlock { fn from(block: Block) -> Self { Self { header: block.header, - transactions: block.transactions.into_iter().map(Into::into).collect(), - ommers: block.ommers.into_iter().collect(), + transactions: block.body.transactions.into_iter().map(Into::into).collect(), + ommers: block.body.ommers.into_iter().collect(), + withdrawals: block.body.withdrawals, } } } impl From for Block { fn from(block: SerializableBlock) -> Self { - Self { - header: block.header, - transactions: block.transactions.into_iter().map(Into::into).collect(), - ommers: block.ommers.into_iter().collect(), - } + let transactions = block.transactions.into_iter().map(Into::into).collect(); + let ommers = block.ommers; + let body = BlockBody { transactions, ommers, withdrawals: block.withdrawals }; + Self::new(block.header, body) } } -impl From for SerializableTransactionType { - fn from(transaction: MaybeImpersonatedTransaction) -> Self { +impl From> for SerializableTransactionType { + fn from(transaction: MaybeImpersonatedTransaction) -> Self { Self::MaybeImpersonatedTransaction(transaction) } } -impl From for MaybeImpersonatedTransaction { +impl From for MaybeImpersonatedTransaction { fn from(transaction: SerializableTransactionType) -> Self { match transaction { SerializableTransactionType::TypedTransaction(tx) => Self::new(tx), @@ -521,13 +934,15 @@ impl From for MaybeImpersonatedTransaction { #[derive(Clone, Debug, Serialize, Deserialize)] pub struct SerializableTransaction { pub info: TransactionInfo, - pub receipt: TypedReceipt, + pub receipt: FoundryReceiptEnvelope, pub block_hash: B256, pub block_number: u64, } -impl From for SerializableTransaction { - fn from(transaction: MinedTransaction) -> Self { +impl> From> + for SerializableTransaction +{ + fn from(transaction: MinedTransaction) -> Self { Self { info: transaction.info, receipt: transaction.receipt, @@ -537,7 +952,9 @@ impl From for SerializableTransaction { } } -impl From for MinedTransaction { +impl> From + for MinedTransaction +{ fn from(transaction: SerializableTransaction) -> Self { Self { info: transaction.info, @@ -569,6 +986,77 @@ impl IntoIterator for SerializableHistoricalStates { #[cfg(test)] mod test { use super::*; + use alloy_consensus::Header; + use std::fs; + + #[test] + fn loads_state_from_file_or_directory() { + let tmp = tempfile::tempdir().unwrap(); + let state_path = tmp.path().join("state.json"); + fs::write(&state_path, serde_json::to_vec(&SerializableState::default()).unwrap()).unwrap(); + + assert!(SerializableState::load(&state_path).unwrap().accounts.is_empty()); + assert!(SerializableState::load(tmp.path()).unwrap().accounts.is_empty()); + + fs::write(&state_path, b"not json").unwrap(); + let Err(FsPathError::ReadJson { path, .. }) = SerializableState::load(tmp.path()) else { + panic!("expected invalid JSON error") + }; + assert_eq!(path, state_path); + + let missing_path = tmp.path().join("missing.json"); + let Err(FsPathError::Read { path, .. }) = SerializableState::load(&missing_path) else { + panic!("expected file read error") + }; + assert_eq!(path, missing_path); + } + + #[test] + fn cache_db_full_state_merges_base_and_overlay() { + let preserved = Address::with_last_byte(1); + let updated = Address::with_last_byte(2); + let deleted = Address::with_last_byte(3); + let cleared = Address::with_last_byte(4); + let deleted_slot = U256::ONE; + let updated_slot = U256::from(2); + + let mut base = MemDb::default(); + base.insert_account(preserved, AccountInfo::from_balance(U256::ONE)); + base.insert_account(updated, AccountInfo::from_balance(U256::from(2))); + base.set_storage_at(updated, deleted_slot.into(), B256::with_last_byte(10)).unwrap(); + base.set_storage_at(updated, updated_slot.into(), B256::with_last_byte(11)).unwrap(); + base.insert_account(deleted, AccountInfo::from_balance(U256::from(3))); + base.insert_account(cleared, AccountInfo::from_balance(U256::from(4))); + base.set_storage_at(cleared, deleted_slot.into(), B256::with_last_byte(11)).unwrap(); + + let mut cache = CacheDB::new(base); + cache.insert_account_info(updated, AccountInfo::from_balance(U256::from(20))); + cache.insert_account_storage(updated, deleted_slot, U256::ZERO).unwrap(); + cache.insert_account_storage(updated, updated_slot, U256::from(12)).unwrap(); + cache.cache.accounts.insert(deleted, DbAccount::new_not_existing()); + cache.cache.accounts.insert( + cleared, + DbAccount { + info: AccountInfo::from_balance(U256::from(40)), + account_state: AccountState::StorageCleared, + ..Default::default() + }, + ); + + let accounts = cache.maybe_full_db().unwrap(); + assert_eq!(accounts[&preserved].info.balance, U256::ONE); + assert_eq!(accounts[&updated].info.balance, U256::from(20)); + assert_eq!(accounts[&updated].storage[&deleted_slot], U256::ZERO); + assert_eq!(accounts[&updated].storage[&updated_slot], U256::from(12)); + assert!(!accounts.contains_key(&deleted)); + assert!(accounts[&cleared].storage.is_empty()); + let mut expected = accounts; + expected.get_mut(&updated).unwrap().storage.remove(&deleted_slot); + assert_eq!( + cache.maybe_full_db().map(|accounts| crate::mem::state::state_root(&accounts)), + Some(crate::mem::state::state_root(&expected)) + ); + } #[test] fn test_deser_block() { @@ -595,21 +1083,20 @@ mod test { }, "transactions": [ { - "EIP1559": { - "chainId": "0x7a69", - "nonce": "0x0", - "gas": "0x5209", - "maxFeePerGas": "0x77359401", - "maxPriorityFeePerGas": "0x1", - "to": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", - "value": "0x0", - "accessList": [], - "input": "0x", - "r": "0x85c2794a580da137e24ccc823b45ae5cea99371ae23ee13860fcc6935f8305b0", - "s": "0x41de7fa4121dab284af4453d30928241208bafa90cdb701fe9bc7054759fe3cd", - "yParity": "0x0", - "hash": "0x8c9b68e8947ace33028dba167354fde369ed7bbe34911b772d09b3c64b861515" - } + "type": "0x2", + "chainId": "0x7a69", + "nonce": "0x0", + "gas": "0x5209", + "maxFeePerGas": "0x77359401", + "maxPriorityFeePerGas": "0x1", + "to": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", + "value": "0x0", + "accessList": [], + "input": "0x", + "r": "0x85c2794a580da137e24ccc823b45ae5cea99371ae23ee13860fcc6935f8305b0", + "s": "0x41de7fa4121dab284af4453d30928241208bafa90cdb701fe9bc7054759fe3cd", + "yParity": "0x0", + "hash": "0x8c9b68e8947ace33028dba167354fde369ed7bbe34911b772d09b3c64b861515" } ], "ommers": [] @@ -618,4 +1105,36 @@ mod test { let _block: SerializableBlock = serde_json::from_str(block).unwrap(); } + + #[test] + fn test_block_withdrawals_preserved() { + use alloy_eips::eip4895::Withdrawal; + + // create a block with withdrawals (like post-Shanghai blocks) + let withdrawal = Withdrawal { + index: 42, + validator_index: 123, + address: Address::repeat_byte(1), + amount: 1000, + }; + + let header = Header::default(); + let body = BlockBody { + transactions: vec![], + ommers: vec![], + withdrawals: Some(vec![withdrawal].into()), + }; + let block = Block::new(header.into(), body); + + // convert to SerializableBlock and back + let serializable = SerializableBlock::from(block); + let restored = Block::from(serializable); + + // withdrawals should be preserved + assert!(restored.body.withdrawals.is_some()); + let withdrawals = restored.body.withdrawals.unwrap(); + assert_eq!(withdrawals.len(), 1); + assert_eq!(withdrawals[0].index, 42); + assert_eq!(withdrawals[0].validator_index, 123); + } } diff --git a/crates/anvil/src/eth/backend/env.rs b/crates/anvil/src/eth/backend/env.rs deleted file mode 100644 index d4b8de797023d..0000000000000 --- a/crates/anvil/src/eth/backend/env.rs +++ /dev/null @@ -1,30 +0,0 @@ -use alloy_evm::EvmEnv; -use foundry_evm::EnvMut; -use foundry_evm_core::AsEnvMut; -use op_revm::OpTransaction; -use revm::context::{BlockEnv, CfgEnv, TxEnv}; - -/// Helper container type for [`EvmEnv`] and [`OpTransaction`]. -#[derive(Clone, Debug, Default)] -pub struct Env { - pub evm_env: EvmEnv, - pub tx: OpTransaction, - pub is_optimism: bool, -} - -/// Helper container type for [`EvmEnv`] and [`OpTransaction`]. -impl Env { - pub fn new(cfg: CfgEnv, block: BlockEnv, tx: OpTransaction, is_optimism: bool) -> Self { - Self { evm_env: EvmEnv { cfg_env: cfg, block_env: block }, tx, is_optimism } - } -} - -impl AsEnvMut for Env { - fn as_env_mut(&mut self) -> EnvMut<'_> { - EnvMut { - block: &mut self.evm_env.block_env, - cfg: &mut self.evm_env.cfg_env, - tx: &mut self.tx.base, - } - } -} diff --git a/crates/anvil/src/eth/backend/executor.rs b/crates/anvil/src/eth/backend/executor.rs index 5925e1db3e3f2..7930d76a6708b 100644 --- a/crates/anvil/src/eth/backend/executor.rs +++ b/crates/anvil/src/eth/backend/executor.rs @@ -1,512 +1,971 @@ use crate::{ - PrecompileFactory, eth::{ - backend::{ - db::Db, env::Env, mem::op_haltreason_to_instruction_result, - validate::TransactionValidator, - }, + backend::cheats::CheatsManager, error::InvalidTransactionError, - pool::transactions::PoolTransaction, + pool::transactions::{PoolTransaction, TxMarker}, }, - inject_precompiles, - mem::inspector::AnvilInspector, + mem::inspector::{AnvilInspector, InspectorTxConfig}, }; use alloy_consensus::{ - Receipt, ReceiptWithBloom, constants::EMPTY_WITHDRAWALS, proofs::calculate_receipt_root, + Eip658Value, Receipt, ReceiptWithBloom, Transaction, TransactionEnvelope, TxReceipt, + transaction::{Either, Recovered}, }; -use alloy_eips::{eip7685::EMPTY_REQUESTS_HASH, eip7840::BlobParams}; -use alloy_evm::{EthEvm, Evm, eth::EthEvmContext, precompiles::PrecompilesMap}; -use alloy_op_evm::OpEvm; -use alloy_primitives::{B256, Bloom, BloomInput, Log}; -use anvil_core::eth::{ - block::{Block, BlockInfo, PartialHeader}, - transaction::{ - DepositReceipt, PendingTransaction, TransactionInfo, TypedReceipt, TypedTransaction, +use alloy_eips::{ + Encodable2718, eip2935, eip4788, + eip6110::DEPOSIT_REQUEST_TYPE, + eip7685::Requests, + eip7702::{RecoveredAuthority, RecoveredAuthorization}, +}; +use alloy_evm::{ + Evm, FromRecoveredTx, FromTxWithEncoded, RecoveredTx, + block::{ + BalIndexedDatabase, BlockExecutionError, BlockExecutionResult, BlockExecutor, + BlockValidationError, ExecutableTx, GasOutput, StateDB, SystemCaller, TxResult, + }, + eth::{ + EthTxResult, + eip6110::parse_deposits_from_receipts, + receipt_builder::{ReceiptBuilder, ReceiptBuilderCtx}, + spec::EthExecutorSpec, }, + precompiles::PrecompilesMap, }; -use foundry_evm::{backend::DatabaseError, traces::CallTraceNode}; -use foundry_evm_core::either_evm::EitherEvm; -use op_revm::{L1BlockInfo, OpContext, precompiles::OpPrecompiles}; +use alloy_hardforks::{EthereumHardfork, EthereumHardforks, ForkCondition}; +use alloy_primitives::{Address, B256, Bytes, Log, U256, map::HashSet}; +use anvil_core::eth::transaction::{ + MaybeImpersonatedTransaction, PendingTransaction, TransactionInfo, +}; +use foundry_evm::core::{env::FoundryTransaction, evm::IntoInstructionResult}; +use foundry_primitives::{FoundryReceiptEnvelope, FoundryTxEnvelope, FoundryTxType}; use revm::{ - Database, DatabaseRef, Inspector, Journal, - context::{Block as RevmBlock, BlockEnv, CfgEnv, Evm as RevmEvm, JournalTr, LocalContext}, - context_interface::result::{EVMError, ExecutionResult, Output}, - database::WrapDatabaseRef, - handler::{EthPrecompiles, instructions::EthInstructions}, + Database, DatabaseCommit, + context::Block as RevmBlock, + context_interface::result::{ExecutionResult, Output, ResultAndState}, interpreter::InstructionResult, - precompile::{ - PrecompileSpecId, Precompiles, - secp256r1::{P256VERIFY, P256VERIFY_BASE_GAS_FEE}, - }, primitives::hardfork::SpecId, + state::{AccountInfo, EvmState}, }; -use std::{fmt::Debug, sync::Arc}; +use std::{fmt, fmt::Debug, mem::take, sync::Arc}; +use tempo_precompiles::{NONCE_PRECOMPILE_ADDRESS, nonce::NonceManager}; + +#[cfg(feature = "base")] +use base_common_consensus::Eip8130Receipt; +#[cfg(feature = "base")] +use base_common_evm::Eip8130PhaseStatuses; + +#[cfg(any(feature = "base", feature = "optimism"))] +use foundry_evm::hardfork::FoundryHardfork; + +#[cfg(any(feature = "base", feature = "optimism"))] +pub(crate) mod optimism; + +/// Determines whether an executor produces a complete block or a historical transaction prefix. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) enum BlockExecutionKind { + /// Apply all pre- and post-block transitions. + #[default] + Complete, + /// Apply block-start transitions, but do not drain post-block request queues. + TransactionPrefix, +} -/// Represents an executed transaction (transacted on the DB) -#[derive(Debug)] -pub struct ExecutedTransaction { - transaction: Arc, - exit_reason: InstructionResult, - out: Option, - gas_used: u64, - logs: Vec, - traces: Vec, - nonce: u64, +/// Ethereum-only consensus transition configuration for an Anvil block executor. +#[derive(Clone, Copy, Debug)] +pub(crate) struct EthereumBlockTransitions { + pub(crate) hardfork: EthereumHardfork, + pub(crate) deposit_contract_address: Address, + pub(crate) parent_beacon_block_root: Option, + pub(crate) execution_kind: BlockExecutionKind, } -// == impl ExecutedTransaction == +/// A hardfork specification whose configured Ethereum fork is active from genesis. +#[derive(Clone, Copy, Debug)] +struct ActiveEthereumSpec { + hardfork: EthereumHardfork, + deposit_contract_address: Address, +} -impl ExecutedTransaction { - /// Creates the receipt for the transaction - fn create_receipt(&self, cumulative_gas_used: &mut u64) -> TypedReceipt { - let logs = self.logs.clone(); - *cumulative_gas_used = cumulative_gas_used.saturating_add(self.gas_used); +impl EthereumHardforks for ActiveEthereumSpec { + fn ethereum_fork_activation(&self, fork: EthereumHardfork) -> ForkCondition { + if fork <= self.hardfork { ForkCondition::ZERO_TIMESTAMP } else { ForkCondition::Never } + } +} - // successful return see [Return] - let status_code = u8::from(self.exit_reason as u8 <= InstructionResult::SelfDestruct as u8); - let receipt_with_bloom: ReceiptWithBloom = Receipt { - status: (status_code == 1).into(), - cumulative_gas_used: *cumulative_gas_used, - logs, - } - .into(); - - match &self.transaction.pending_transaction.transaction.transaction { - TypedTransaction::Legacy(_) => TypedReceipt::Legacy(receipt_with_bloom), - TypedTransaction::EIP2930(_) => TypedReceipt::EIP2930(receipt_with_bloom), - TypedTransaction::EIP1559(_) => TypedReceipt::EIP1559(receipt_with_bloom), - TypedTransaction::EIP4844(_) => TypedReceipt::EIP4844(receipt_with_bloom), - TypedTransaction::EIP7702(_) => TypedReceipt::EIP7702(receipt_with_bloom), - TypedTransaction::Deposit(_tx) => TypedReceipt::Deposit(DepositReceipt { - inner: receipt_with_bloom, - deposit_nonce: Some(0), - deposit_receipt_version: Some(1), - }), - } +impl EthExecutorSpec for ActiveEthereumSpec { + fn deposit_contract_address(&self) -> Option
{ + Some(self.deposit_contract_address) } } -/// Represents the outcome of mining a new block -#[derive(Clone, Debug)] -pub struct ExecutedTransactions { - /// The block created after executing the `included` transactions - pub block: BlockInfo, - /// All transactions included in the block - pub included: Vec>, - /// All transactions that were invalid at the point of their execution and were not included in - /// the block - pub invalid: Vec>, +/// Applies canonical Ethereum block-start transitions in consensus order. +pub(crate) fn apply_ethereum_pre_execution_changes( + evm: &mut E, + parent_hash: B256, + transitions: EthereumBlockTransitions, +) -> Result<(), BlockExecutionError> +where + E: Evm, +{ + let mut caller = SystemCaller::new(ActiveEthereumSpec { + hardfork: transitions.hardfork, + deposit_contract_address: transitions.deposit_contract_address, + }); + caller.apply_blockhashes_contract_call(parent_hash, evm)?; + caller.apply_beacon_root_contract_call(transitions.parent_beacon_block_root, evm) } -/// An executor for a series of transactions -pub struct TransactionExecutor<'a, Db: ?Sized, V: TransactionValidator> { - /// where to insert the transactions - pub db: &'a mut Db, - /// type used to validate before inclusion - pub validator: &'a V, - /// all pending transactions - pub pending: std::vec::IntoIter>, - pub block_env: BlockEnv, - /// The configuration environment and spec id - pub cfg_env: CfgEnv, - pub parent_hash: B256, - /// Cumulative gas used by all executed transactions - pub gas_used: u64, - /// Cumulative blob gas used by all executed transactions - pub blob_gas_used: u64, - pub enable_steps_tracing: bool, - pub odyssey: bool, - pub optimism: bool, - pub print_logs: bool, - pub print_traces: bool, - /// Precompiles to inject to the EVM. - pub precompile_factory: Option>, - pub blob_params: BlobParams, +/// Collects deposits before draining the withdrawal and consolidation request queues. +pub(crate) fn apply_ethereum_post_execution_changes( + evm: &mut E, + transitions: EthereumBlockTransitions, + receipts: &[FoundryReceiptEnvelope], +) -> Result +where + E: Evm, +{ + if transitions.hardfork < EthereumHardfork::Prague { + return Ok(Requests::default()); + } + + let spec = ActiveEthereumSpec { + hardfork: transitions.hardfork, + deposit_contract_address: transitions.deposit_contract_address, + }; + let mut requests = Requests::default(); + append_deposit_requests(spec, receipts, &mut requests)?; + SystemCaller::new(spec).append_post_execution_changes(evm, &mut requests)?; + Ok(requests) } -impl TransactionExecutor<'_, DB, V> { - /// Executes all transactions and puts them in a new block with the provided `timestamp` - pub fn execute(mut self) -> ExecutedTransactions { - let mut transactions = Vec::new(); - let mut transaction_infos = Vec::new(); - let mut receipts = Vec::new(); - let mut bloom = Bloom::default(); - let mut cumulative_gas_used = 0u64; - let mut invalid = Vec::new(); - let mut included = Vec::new(); - let gas_limit = self.block_env.gas_limit; - let parent_hash = self.parent_hash; - let block_number = self.block_env.number; - let difficulty = self.block_env.difficulty; - let mix_hash = self.block_env.prevrandao; - let beneficiary = self.block_env.beneficiary; - let timestamp = self.block_env.timestamp; - let base_fee = if self.cfg_env.spec.is_enabled_in(SpecId::LONDON) { - Some(self.block_env.basefee) - } else { - None - }; +fn append_deposit_requests( + spec: ActiveEthereumSpec, + receipts: &[FoundryReceiptEnvelope], + requests: &mut Requests, +) -> Result<(), BlockExecutionError> { + let deposits = parse_deposits_from_receipts(spec, receipts)?; + if !deposits.is_empty() { + requests.push_request_with_type(DEPOSIT_REQUEST_TYPE, deposits); + } + Ok(()) +} - let is_shanghai = self.cfg_env.spec >= SpecId::SHANGHAI; - let is_cancun = self.cfg_env.spec >= SpecId::CANCUN; - let is_prague = self.cfg_env.spec >= SpecId::PRAGUE; - let excess_blob_gas = if is_cancun { self.block_env.blob_excess_gas() } else { None }; - let mut cumulative_blob_gas_used = if is_cancun { Some(0u64) } else { None }; - - for tx in self.into_iter() { - let tx = match tx { - TransactionExecutionOutcome::Executed(tx) => { - included.push(tx.transaction.clone()); - tx - } - TransactionExecutionOutcome::Exhausted(tx) => { - trace!(target: "backend", tx_gas_limit = %tx.pending_transaction.transaction.gas_limit(), ?tx, "block gas limit exhausting, skipping transaction"); - continue; - } - TransactionExecutionOutcome::BlobGasExhausted(tx) => { - trace!(target: "backend", blob_gas = %tx.pending_transaction.transaction.blob_gas().unwrap_or_default(), ?tx, "block blob gas limit exhausting, skipping transaction"); - continue; - } - TransactionExecutionOutcome::Invalid(tx, _) => { - trace!(target: "backend", ?tx, "skipping invalid transaction"); - invalid.push(tx); - continue; - } - TransactionExecutionOutcome::DatabaseError(_, err) => { - // Note: this is only possible in forking mode, if for example a rpc request - // failed - trace!(target: "backend", ?err, "Failed to execute transaction due to database error"); - continue; - } - }; - if is_cancun { - let tx_blob_gas = tx - .transaction - .pending_transaction - .transaction - .transaction - .blob_gas() - .unwrap_or(0); - cumulative_blob_gas_used = - Some(cumulative_blob_gas_used.unwrap_or(0u64).saturating_add(tx_blob_gas)); +/// Receipt builder for transaction types that do not require network-specific metadata. +#[derive(Debug, Default, Clone, Copy)] +#[non_exhaustive] +pub struct FoundryReceiptBuilder; + +impl FoundryReceiptBuilder { + #[cfg_attr(not(feature = "base"), allow(clippy::missing_const_for_fn))] + fn wrap_receipt( + tx_type: FoundryTxType, + receipt: ReceiptWithBloom, + ) -> FoundryReceiptEnvelope { + match tx_type { + FoundryTxType::Legacy => FoundryReceiptEnvelope::Legacy(receipt), + FoundryTxType::Eip2930 => FoundryReceiptEnvelope::Eip2930(receipt), + FoundryTxType::Eip1559 => FoundryReceiptEnvelope::Eip1559(receipt), + FoundryTxType::Eip4844 => FoundryReceiptEnvelope::Eip4844(receipt), + FoundryTxType::Eip7702 => FoundryReceiptEnvelope::Eip7702(receipt), + #[cfg(any(feature = "base", feature = "optimism"))] + FoundryTxType::Deposit => { + panic!("deposit receipts require fork-specific metadata") } - let receipt = tx.create_receipt(&mut cumulative_gas_used); + #[cfg(feature = "optimism")] + FoundryTxType::PostExec => FoundryReceiptEnvelope::PostExec(receipt), + #[cfg(feature = "base")] + FoundryTxType::Eip8130 => FoundryReceiptEnvelope::Eip8130(ReceiptWithBloom { + receipt: Eip8130Receipt::new(receipt.receipt, Eip8130PhaseStatuses::take()), + logs_bloom: receipt.logs_bloom, + }), + FoundryTxType::Tempo => FoundryReceiptEnvelope::Tempo(receipt), + FoundryTxType::Celo => { + FoundryReceiptEnvelope::Unknown(alloy_network::AnyReceiptEnvelope { + inner: receipt, + r#type: foundry_primitives::CIP64_TX_TYPE, + }) + } + } + } - let ExecutedTransaction { transaction, logs, out, traces, exit_reason: exit, .. } = tx; - build_logs_bloom(logs.clone(), &mut bloom); + /// Builds a typed receipt for an RPC-simulated transaction. + pub(crate) fn build_simulated_receipt( + tx_type: FoundryTxType, + result: &ExecutionResult, + logs: Vec, + cumulative_gas_used: u64, + ) -> FoundryReceiptEnvelope { + let receipt = + Receipt { status: Eip658Value::Eip658(result.is_success()), cumulative_gas_used, logs } + .with_bloom(); + Self::wrap_receipt(tx_type, receipt) + } +} - let contract_address = out.as_ref().and_then(|out| { - if let Output::Create(_, contract_address) = out { - trace!(target: "backend", "New contract deployed: at {:?}", contract_address); - *contract_address - } else { - None - } - }); - - let transaction_index = transaction_infos.len() as u64; - let info = TransactionInfo { - transaction_hash: transaction.hash(), - transaction_index, - from: *transaction.pending_transaction.sender(), - to: transaction.pending_transaction.transaction.to(), - contract_address, - traces, - exit, - out: out.map(Output::into_data), - nonce: tx.nonce, - gas_used: tx.gas_used, - }; - - transaction_infos.push(info); - receipts.push(receipt); - transactions.push(transaction.pending_transaction.transaction.clone()); +impl ReceiptBuilder for FoundryReceiptBuilder { + type Transaction = FoundryTxEnvelope; + type Receipt = FoundryReceiptEnvelope; + + fn build_receipt( + &self, + ctx: ReceiptBuilderCtx<'_, FoundryTxType, E>, + ) -> FoundryReceiptEnvelope { + let receipt = Receipt { + status: Eip658Value::Eip658(ctx.result.is_success()), + cumulative_gas_used: ctx.cumulative_gas_used, + logs: ctx.result.into_logs(), } + .with_bloom(); + Self::wrap_receipt(ctx.tx_type, receipt) + } +} - let receipts_root = calculate_receipt_root(&receipts); +/// Result of executing a transaction in [`AnvilBlockExecutor`]. +/// +/// Wraps [`EthTxResult`] with the depositor nonce when OP deposit receipts are enabled. +#[derive(Debug)] +pub struct AnvilTxResult { + pub inner: EthTxResult, + /// The sender nonce before a deposit transaction executed, `None` for other transactions. + #[cfg(any(feature = "base", feature = "optimism"))] + pub depositor_nonce: Option, +} - let partial_header = PartialHeader { - parent_hash, - beneficiary, - state_root: self.db.maybe_state_root().unwrap_or_default(), - receipts_root, - logs_bloom: bloom, - difficulty, - number: block_number.saturating_to(), - gas_limit, - gas_used: cumulative_gas_used, - timestamp: timestamp.saturating_to(), - extra_data: Default::default(), - mix_hash: mix_hash.unwrap_or_default(), - nonce: Default::default(), - base_fee, - parent_beacon_block_root: is_cancun.then_some(Default::default()), - blob_gas_used: cumulative_blob_gas_used, - excess_blob_gas, - withdrawals_root: is_shanghai.then_some(EMPTY_WITHDRAWALS), - requests_hash: is_prague.then_some(EMPTY_REQUESTS_HASH), - }; +impl TxResult for AnvilTxResult { + type HaltReason = H; - let block = Block::new(partial_header, transactions.clone()); - let block = BlockInfo { block, transactions: transaction_infos, receipts }; - ExecutedTransactions { block, included, invalid } + fn result(&self) -> &ResultAndState { + self.inner.result() } - fn env_for(&self, tx: &PendingTransaction) -> Env { - let mut tx_env = tx.to_revm_tx_env(); + fn into_result(self) -> ResultAndState { + self.inner.into_result() + } +} - if self.optimism { - tx_env.enveloped_tx = Some(alloy_rlp::encode(&tx.transaction.transaction).into()); - } +/// Block executor for Anvil that implements [`BlockExecutor`]. +/// +/// Wraps an EVM instance and produces [`FoundryReceiptEnvelope`] receipts. +/// Validation (gas limits, blob gas, transaction validity) is handled by the +/// caller before transactions are fed to this executor. +pub struct AnvilBlockExecutor { + /// The EVM instance used for execution. + evm: E, + /// Parent block hash — needed for EIP-2935 system call. + parent_hash: B256, + /// The active spec id, used to gate hardfork-specific behavior. + spec_id: SpecId, + /// Canonical Ethereum consensus transitions, disabled for other networks. + ethereum_transitions: Option, + /// Receipt builder. + receipt_builder: FoundryReceiptBuilder, + /// Receipts of executed transactions. + receipts: Vec, + /// Total gas used by transactions in this block. + gas_used: u64, + /// Blob gas used by the block. + blob_gas_used: u64, + /// Maximum blob gas available to transactions in this block. + max_blob_gas_per_block: u64, + /// Whether OP Jovian repurposes `blobGasUsed` for the DA footprint. + #[cfg(feature = "optimism")] + optimism_jovian: bool, + /// The OP-stack hardfork that gates deposit receipt metadata. + #[cfg(any(feature = "base", feature = "optimism"))] + deposit_hardfork: Option, + /// State changes captured for deferred publication. + state_changes: Option>, +} - Env::new(self.cfg_env.clone(), self.block_env.clone(), tx_env, self.optimism) +impl fmt::Debug for AnvilBlockExecutor { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut debug = f.debug_struct("AnvilBlockExecutor"); + debug + .field("evm", &self.evm) + .field("parent_hash", &self.parent_hash) + .field("spec_id", &self.spec_id) + .field("ethereum_transitions", &self.ethereum_transitions) + .field("gas_used", &self.gas_used) + .field("blob_gas_used", &self.blob_gas_used) + .field("max_blob_gas_per_block", &self.max_blob_gas_per_block); + #[cfg(feature = "optimism")] + debug.field("optimism_jovian", &self.optimism_jovian); + debug.field("receipts", &self.receipts.len()).finish_non_exhaustive() } } -/// Represents the result of a single transaction execution attempt -#[derive(Debug)] -pub enum TransactionExecutionOutcome { - /// Transaction successfully executed - Executed(ExecutedTransaction), - /// Invalid transaction not executed - Invalid(Arc, InvalidTransactionError), - /// Execution skipped because could exceed gas limit - Exhausted(Arc), - /// Execution skipped because it exceeded the blob gas limit - BlobGasExhausted(Arc), - /// When an error occurred during execution - DatabaseError(Arc, DatabaseError), -} +impl AnvilBlockExecutor { + /// Creates a new [`AnvilBlockExecutor`]. + pub(crate) const fn new( + evm: E, + parent_hash: B256, + spec_id: SpecId, + ethereum_transitions: Option, + ) -> Self { + Self { + evm, + parent_hash, + spec_id, + ethereum_transitions, + receipt_builder: FoundryReceiptBuilder, + receipts: Vec::new(), + gas_used: 0, + blob_gas_used: 0, + max_blob_gas_per_block: u64::MAX, + #[cfg(feature = "optimism")] + optimism_jovian: false, + #[cfg(any(feature = "base", feature = "optimism"))] + deposit_hardfork: None, + state_changes: None, + } + } -impl Iterator for &mut TransactionExecutor<'_, DB, V> { - type Item = TransactionExecutionOutcome; + /// Captures every committed changeset so callers can publish them after block execution. + pub(crate) fn with_state_changes(mut self) -> Self { + self.state_changes = Some(Vec::new()); + self + } - fn next(&mut self) -> Option { - let transaction = self.pending.next()?; - let sender = *transaction.pending_transaction.sender(); - let account = match self.db.basic(sender).map(|acc| acc.unwrap_or_default()) { - Ok(account) => account, - Err(err) => return Some(TransactionExecutionOutcome::DatabaseError(transaction, err)), - }; - let env = self.env_for(&transaction.pending_transaction); + /// Applies the active block blob gas limit. + pub(crate) const fn with_max_blob_gas_per_block(mut self, limit: u64) -> Self { + self.max_blob_gas_per_block = limit; + self + } - // check that we comply with the block's gas limit, if not disabled - let max_gas = self.gas_used.saturating_add(env.tx.base.gas_limit); - if !env.evm_env.cfg_env.disable_block_gas_limit && max_gas > env.evm_env.block_env.gas_limit - { - return Some(TransactionExecutionOutcome::Exhausted(transaction)); + /// Takes the captured changesets. + pub(crate) fn take_state_changes(&mut self) -> Vec { + self.state_changes.take().unwrap_or_default() + } +} + +impl AnvilBlockExecutor +where + E: Evm< + DB: StateDB + BalIndexedDatabase, + Tx: FromRecoveredTx + FromTxWithEncoded, + >, +{ + /// Executes a transaction without committing it, using the supplied network-specific + /// transaction entry point. + pub(crate) fn execute_transaction_without_commit_with( + &mut self, + tx: T, + transact: F, + ) -> Result, BlockExecutionError> + where + T: ExecutableTx, + F: FnOnce( + &mut E, + E::Tx, + B256, + ) -> Result, BlockExecutionError>, + { + let (tx_env, tx) = tx.into_parts(); + + let block_available_gas = self.evm.block().gas_limit() - self.gas_used; + if tx.tx().gas_limit() > block_available_gas { + return Err(BlockValidationError::TransactionGasLimitMoreThanAvailableBlockGas { + transaction_gas_limit: tx.tx().gas_limit(), + block_available_gas, + } + .into()); } - // check that we comply with the block's blob gas limit - let max_blob_gas = self.blob_gas_used.saturating_add( - transaction.pending_transaction.transaction.transaction.blob_gas().unwrap_or(0), + // The deposit nonce reported in the receipt is the sender nonce before the deposit ran, so + // it has to be read before the transaction executes. + #[cfg(any(feature = "base", feature = "optimism"))] + let depositor_nonce = if tx.tx().tx_type().is_deposit() { + let account = self + .evm + .db_mut() + .basic(*tx.signer()) + .map_err(BlockExecutionError::other)? + .unwrap_or_default(); + Some(account.nonce) + } else { + None + }; + let transaction_hash = tx.tx().trie_hash(); + #[cfg(feature = "optimism")] + let blob_gas_used = + optimism::blob_gas_used(self.evm.db_mut(), tx.tx(), self.optimism_jovian)?; + #[cfg(not(feature = "optimism"))] + let blob_gas_used = tx.tx().blob_gas_used().unwrap_or_default(); + #[cfg(feature = "optimism")] + let blob_gas_limit = block_blob_gas_limit( + self.optimism_jovian, + self.evm.block().gas_limit(), + self.max_blob_gas_per_block, ); - if max_blob_gas > self.blob_params.max_blob_gas_per_block() { - return Some(TransactionExecutionOutcome::BlobGasExhausted(transaction)); + #[cfg(not(feature = "optimism"))] + let blob_gas_limit = self.max_blob_gas_per_block; + if self.blob_gas_used.saturating_add(blob_gas_used) > blob_gas_limit { + return Err(BlockExecutionError::msg("block blob gas limit exceeded")); } + let result = transact(&mut self.evm, tx_env, transaction_hash)?; + + Ok(AnvilTxResult { + inner: EthTxResult { result, blob_gas_used, tx_type: tx.tx().tx_type() }, + #[cfg(any(feature = "base", feature = "optimism"))] + depositor_nonce, + }) + } +} - // validate before executing - if let Err(err) = self.validator.validate_pool_transaction_for( - &transaction.pending_transaction, - &account, - &env, - ) { - warn!(target: "backend", "Skipping invalid tx execution [{:?}] {}", transaction.hash(), err); - return Some(TransactionExecutionOutcome::Invalid(transaction, err)); +impl BlockExecutor for AnvilBlockExecutor +where + E: Evm< + DB: StateDB + BalIndexedDatabase, + Tx: FromRecoveredTx + FromTxWithEncoded, + >, +{ + type Transaction = FoundryTxEnvelope; + type Receipt = FoundryReceiptEnvelope; + type Evm = E; + type Result = AnvilTxResult; + + fn apply_pre_execution_changes(&mut self) -> Result<(), BlockExecutionError> { + if let Some(transitions) = self.ethereum_transitions { + // Historical fork-prefix publication needs the individual changesets after executing + // against its disposable overlay. Preserve canonical ordering while capturing them. + if let Some(state_changes) = &mut self.state_changes { + if transitions.hardfork >= EthereumHardfork::Prague { + let result = self + .evm + .transact_system_call( + eip4788::SYSTEM_ADDRESS, + eip2935::HISTORY_STORAGE_ADDRESS, + Bytes::from(self.parent_hash), + ) + .map_err(BlockExecutionError::other)?; + state_changes.push(result.state.clone()); + self.evm.db_mut().commit(result.state); + } + if transitions.hardfork >= EthereumHardfork::Cancun { + let parent_beacon_block_root = transitions + .parent_beacon_block_root + .ok_or(BlockValidationError::MissingParentBeaconBlockRoot)?; + let result = self + .evm + .transact_system_call( + eip4788::SYSTEM_ADDRESS, + eip4788::BEACON_ROOTS_ADDRESS, + Bytes::from(parent_beacon_block_root), + ) + .map_err(BlockExecutionError::other)?; + state_changes.push(result.state.clone()); + self.evm.db_mut().commit(result.state); + } + return Ok(()); + } + apply_ethereum_pre_execution_changes(&mut self.evm, self.parent_hash, transitions)?; } + Ok(()) + } - let nonce = account.nonce; + fn execute_transaction_without_commit( + &mut self, + tx: impl ExecutableTx, + ) -> Result { + self.execute_transaction_without_commit_with(tx, |evm, tx_env, transaction_hash| { + evm.transact(tx_env).map_err(|err| BlockExecutionError::evm(err, transaction_hash)) + }) + } - let mut inspector = AnvilInspector::default().with_tracing(); - if self.enable_steps_tracing { - inspector = inspector.with_steps_tracing(); - } - if self.print_logs { - inspector = inspector.with_log_collector(); + fn commit_transaction(&mut self, output: Self::Result) -> GasOutput { + let AnvilTxResult { + inner: EthTxResult { result: ResultAndState { result, state }, blob_gas_used, tx_type }, + #[cfg(any(feature = "base", feature = "optimism"))] + depositor_nonce, + } = output; + + let gas_used = result.tx_gas_used(); + self.gas_used += gas_used; + + if self.spec_id >= SpecId::CANCUN { + self.blob_gas_used = self.blob_gas_used.saturating_add(blob_gas_used); } - if self.print_traces { - inspector = inspector.with_trace_printer(); + + #[cfg(any(feature = "base", feature = "optimism"))] + let receipt = if let Some(depositor_nonce) = depositor_nonce { + optimism::build_mined_deposit_receipt( + result, + self.deposit_hardfork, + depositor_nonce, + self.gas_used, + ) + } else { + self.receipt_builder.build_receipt(ReceiptBuilderCtx { + tx_type, + evm: &self.evm, + result, + state: &state, + cumulative_gas_used: self.gas_used, + }) + }; + #[cfg(not(any(feature = "base", feature = "optimism")))] + let receipt = self.receipt_builder.build_receipt(ReceiptBuilderCtx { + tx_type, + evm: &self.evm, + result, + state: &state, + cumulative_gas_used: self.gas_used, + }); + + if let Some(state_changes) = &mut self.state_changes { + state_changes.push(state.clone()); } + self.receipts.push(receipt); + // EIP-7928 block access index 0 holds the pre-block system writes, transaction `i` is + // index `i + 1` and the post-block system writes follow the last transaction. + self.evm.db_mut().set_bal_index(self.receipts.len() as u64); + self.evm.db_mut().commit(state); - let exec_result = { - let mut evm = new_evm_with_inspector(&mut *self.db, &env, &mut inspector); + GasOutput::new(gas_used) + } - if self.odyssey { - inject_precompiles(&mut evm, vec![(P256VERIFY, P256VERIFY_BASE_GAS_FEE)]); + fn finish( + mut self, + ) -> Result<(Self::Evm, BlockExecutionResult), BlockExecutionError> + { + self.evm.db_mut().set_bal_index(self.receipts.len() as u64 + 1); + let requests = match self.ethereum_transitions { + Some(transitions) if transitions.execution_kind == BlockExecutionKind::Complete => { + apply_ethereum_post_execution_changes(&mut self.evm, transitions, &self.receipts)? } + _ => Requests::default(), + }; + Ok(( + self.evm, + BlockExecutionResult { + receipts: self.receipts, + requests, + gas_used: self.gas_used, + blob_gas_used: self.blob_gas_used, + }, + )) + } + + fn evm_mut(&mut self) -> &mut Self::Evm { + &mut self.evm + } + + fn evm(&self) -> &Self::Evm { + &self.evm + } + + fn receipts(&self) -> &[FoundryReceiptEnvelope] { + &self.receipts + } +} + +/// Result of executing pool transactions against a block executor. +pub struct ExecutedPoolTransactions { + /// Successfully included transactions. + pub included: Vec>>, + /// Transactions whose nonce was already consumed by the current state. + pub stale: Vec>>, + /// Transactions that failed validation. + pub invalid: Vec>>, + /// Transactions skipped because they're not yet valid (e.g., valid_after in the future). + /// These remain in the pool and should be retried later. + pub not_yet_valid: Vec>>, + /// Per-transaction execution info. + pub tx_info: Vec, + /// The raw pending transactions that were included (in order). + pub txs: Vec>, +} + +/// Gas-related configuration for pool transaction execution. +/// +/// Bundles parameters that cannot be derived from the generic `Evm` trait +/// (which doesn't expose `cfg()`), so callers construct this from `EvmEnv` +/// before calling [`execute_pool_transactions`]. +pub struct PoolTxGasConfig { + pub disable_block_gas_limit: bool, + /// Resolved transaction gas cap, or `None` when the caller disables this check. + pub enforced_tx_gas_limit_cap: Option, + pub max_blob_gas_per_block: u64, + pub is_cancun: bool, +} + +/// Executes a pool candidate through the block executor's ordinary transaction entry point. +pub(crate) fn execute_pool_transaction( + executor: &mut B, + tx_env: ::Tx, + recovered: Recovered, + _is_replay: bool, +) -> Result +where + B: BlockExecutor, +{ + executor.execute_transaction_without_commit((tx_env, recovered)) +} + +/// Executes pool transactions against a block executor, handling validation, +/// execution, commit, inspector drain, and result collection. +/// +/// This is the shared core of `do_mine_block` and `with_pending_block`. +#[allow(clippy::type_complexity)] +pub fn execute_pool_transactions( + executor: &mut B, + pool_transactions: &[Arc>], + gas_config: &PoolTxGasConfig, + inspector_config: &InspectorTxConfig, + cheats: &CheatsManager, + validator: &dyn Fn( + &PoolTransaction, + &AccountInfo, + ) -> Result<(), InvalidTransactionError>, + execute_transaction: &mut ExecuteTransaction, +) -> ExecutedPoolTransactions +where + B: BlockExecutor< + Transaction = FoundryTxEnvelope, + Evm: Evm< + DB: Database + Debug, + Inspector = AnvilInspector, + Precompiles = PrecompilesMap, + >, + >, + B::Receipt: TxReceipt, + ::HaltReason: Clone + IntoInstructionResult, + ::Tx: FromTxWithEncoded + FoundryTransaction, + ExecuteTransaction: FnMut( + &mut B, + ::Tx, + Recovered, + bool, + ) -> Result, +{ + let gas_limit = executor.evm().block().gas_limit(); + + let mut included = Vec::new(); + let mut stale = Vec::new(); + let mut invalid = Vec::new(); + let mut not_yet_valid = Vec::new(); + let mut tx_info: Vec = Vec::new(); + let mut transactions = Vec::new(); + let mut blob_gas_used = 0u64; + let mut unavailable_markers = HashSet::::default(); + + for pool_tx in pool_transactions { + let pending = &pool_tx.pending_transaction; + let sender = *pending.sender(); + if pool_tx.requires.iter().any(|marker| unavailable_markers.contains(marker)) { + trace!(target: "backend", "[{:?}] dependency was not included, skipping transaction", pool_tx.hash()); + unavailable_markers.extend(pool_tx.provides.iter().cloned()); + continue; + } - if let Some(factory) = &self.precompile_factory { - inject_precompiles(&mut evm, factory.precompiles()); + let account = match executor.evm_mut().db_mut().basic(sender).map(|a| a.unwrap_or_default()) + { + Ok(acc) => acc, + Err(err) => { + trace!(target: "backend", ?err, "db error for tx {:?}, skipping", pool_tx.hash()); + unavailable_markers.extend(pool_tx.provides.iter().cloned()); + continue; } + }; - trace!(target: "backend", "[{:?}] executing", transaction.hash()); - // transact and commit the transaction - match evm.transact_commit(env.tx) { - Ok(exec_result) => exec_result, - Err(err) => { - warn!(target: "backend", "[{:?}] failed to execute: {:?}", transaction.hash(), err); - match err { - EVMError::Database(err) => { - return Some(TransactionExecutionOutcome::DatabaseError( - transaction, - err, - )); - } - EVMError::Transaction(err) => { - return Some(TransactionExecutionOutcome::Invalid( - transaction, - err.into(), - )); - } - // This will correspond to prevrandao not set, and it should never happen. - // If it does, it's a bug. - e => panic!("failed to execute transaction: {e}"), + let transaction = pending.transaction.as_ref(); + let state_nonce = match transaction { + FoundryTxEnvelope::Tempo(tx) if tx.tx().nonce_key == U256::MAX => None, + FoundryTxEnvelope::Tempo(tx) if !tx.tx().nonce_key.is_zero() => { + let slot = NonceManager::new().nonces[sender][tx.tx().nonce_key].slot(); + match executor.evm_mut().db_mut().storage(NONCE_PRECOMPILE_ADDRESS, slot) { + Ok(nonce) => Some(nonce.saturating_to::()), + Err(err) => { + trace!(target: "backend", ?err, "db error for tx {:?}, skipping", pool_tx.hash()); + unavailable_markers.extend(pool_tx.provides.iter().cloned()); + continue; } } } + #[cfg(feature = "base")] + FoundryTxEnvelope::Eip8130(_) => None, + #[cfg(any(feature = "base", feature = "optimism"))] + FoundryTxEnvelope::Deposit(_) => None, + #[cfg(feature = "optimism")] + FoundryTxEnvelope::PostExec(_) => None, + _ => Some(account.nonce), }; - - if self.print_traces { - inspector.print_traces(); + if state_nonce.is_some_and(|nonce| transaction.nonce() < nonce) { + warn!(target: "backend", "Skipping stale tx [{:?}]", pool_tx.hash()); + stale.push(pool_tx.clone()); + continue; } - inspector.print_logs(); - let (exit_reason, gas_used, out, logs) = match exec_result { - ExecutionResult::Success { reason, gas_used, logs, output, .. } => { - (reason.into(), gas_used, Some(output), Some(logs)) - } - ExecutionResult::Revert { gas_used, output } => { - (InstructionResult::Revert, gas_used, Some(Output::Call(output)), None) - } - ExecutionResult::Halt { reason, gas_used } => { - (op_haltreason_to_instruction_result(reason), gas_used, None, None) - } - }; + if let FoundryTxEnvelope::Tempo(aa_tx) = transaction + && let Some(valid_after) = aa_tx.tx().valid_after + && U256::from(valid_after.get()) > executor.evm().block().timestamp() + { + trace!(target: "backend", "[{:?}] transaction not valid yet, will retry later", pool_tx.hash()); + not_yet_valid.push(pool_tx.clone()); + unavailable_markers.extend(pool_tx.provides.iter().cloned()); + continue; + } - if exit_reason == InstructionResult::OutOfGas { - // this currently useful for debugging estimations - warn!(target: "backend", "[{:?}] executed with out of gas", transaction.hash()) + let tx_env = + build_tx_env_for_pending::::Tx>(pending, cheats); + + // Gas limit checks + let cumulative_gas = + executor.receipts().last().map(|r| r.cumulative_gas_used()).unwrap_or(0); + let max_block_gas = cumulative_gas.saturating_add(pending.transaction.gas_limit()); + if !gas_config.disable_block_gas_limit && max_block_gas > gas_limit { + trace!(target: "backend", tx_gas_limit = %pending.transaction.gas_limit(), ?pool_tx, "block gas limit exhausting, skipping transaction"); + unavailable_markers.extend(pool_tx.provides.iter().cloned()); + continue; } - trace!(target: "backend", ?exit_reason, ?gas_used, "[{:?}] executed with out={:?}", transaction.hash(), out); + // Osaka EIP-7825 tx gas limit cap check + if let Some(tx_gas_limit_cap) = gas_config.enforced_tx_gas_limit_cap + && pending.transaction.gas_limit() > tx_gas_limit_cap + { + trace!(target: "backend", tx_gas_limit = %pending.transaction.gas_limit(), ?pool_tx, "transaction gas limit exhausting, skipping transaction"); + unavailable_markers.extend(pool_tx.provides.iter().cloned()); + continue; + } - // Track the total gas used for total gas per block checks - self.gas_used = self.gas_used.saturating_add(gas_used); + // Reject declared blob gas before execution. Network-specific accounting is checked again + // against the execution result below. + let declared_blob_gas = pending.transaction.blob_gas_used().unwrap_or(0); + if blob_gas_used.saturating_add(declared_blob_gas) > gas_config.max_blob_gas_per_block { + trace!(target: "backend", blob_gas = %declared_blob_gas, ?pool_tx, "block blob gas limit exhausting, skipping transaction"); + unavailable_markers.extend(pool_tx.provides.iter().cloned()); + continue; + } - // Track the total blob gas used for total blob gas per blob checks - if let Some(blob_gas) = transaction.pending_transaction.transaction.transaction.blob_gas() { - self.blob_gas_used = self.blob_gas_used.saturating_add(blob_gas); + // Validate + if let Err(err) = validator(pool_tx, &account) { + warn!(target: "backend", "Skipping invalid tx execution [{:?}] {}", pool_tx.hash(), err); + invalid.push(pool_tx.clone()); + continue; } - trace!(target: "backend::executor", "transacted [{:?}], result: {:?} gas {}", transaction.hash(), exit_reason, gas_used); + let nonce = account.nonce; - let tx = ExecutedTransaction { - transaction, - exit_reason, - out, - gas_used, - logs: logs.unwrap_or_default(), - traces: inspector.tracer.map(|t| t.into_traces().into_nodes()).unwrap_or_default(), - nonce, - }; + let recovered = Recovered::new_unchecked(pending.transaction.as_ref().clone(), sender); + trace!(target: "backend", "[{:?}] executing", pool_tx.hash()); + match execute_transaction(executor, tx_env, recovered, pool_tx.is_replay) { + Ok(result) => { + let exec_result = result.result().result.clone(); + let gas_used = result.result().result.tx_gas_used(); - Some(TransactionExecutionOutcome::Executed(tx)) - } -} + executor.commit_transaction(result); + + let (_, inspector, precompiles) = executor.evm_mut().components_mut(); + let traces = inspector.finish_transaction(inspector_config, precompiles); + + if gas_config.is_cancun { + blob_gas_used = blob_gas_used.saturating_add(declared_blob_gas); + } + + let (exit_reason, out, _logs) = match exec_result { + ExecutionResult::Success { reason, logs, output, .. } => { + (reason.into(), Some(output), logs) + } + ExecutionResult::Revert { output, .. } => { + (InstructionResult::Revert, Some(Output::Call(output)), Vec::new()) + } + ExecutionResult::Halt { reason, .. } => { + (reason.into_instruction_result(), None, Vec::new()) + } + }; -/// Inserts all logs into the bloom -fn build_logs_bloom(logs: Vec, bloom: &mut Bloom) { - for log in logs { - bloom.accrue(BloomInput::Raw(&log.address[..])); - for topic in log.topics() { - bloom.accrue(BloomInput::Raw(&topic[..])); + if exit_reason == InstructionResult::OutOfGas { + warn!(target: "backend", "[{:?}] executed with out of gas", pool_tx.hash()); + } + + trace!(target: "backend", ?exit_reason, ?gas_used, "[{:?}] executed with out={:?}", pool_tx.hash(), out); + trace!(target: "backend::executor", "transacted [{:?}], result: {:?} gas {}", pool_tx.hash(), exit_reason, gas_used); + + let contract_address = pending.transaction.to().is_none().then(|| { + let addr = sender.create(nonce); + trace!(target: "backend", "Contract creation tx: computed address {:?}", addr); + addr + }); + + // TODO: replace `TransactionInfo` with alloy receipt/transaction types + let transaction_index = tx_info.len() as u64; + let info = TransactionInfo { + transaction_hash: pool_tx.hash(), + transaction_index, + from: sender, + to: pending.transaction.to(), + contract_address, + traces, + exit: exit_reason, + out: out.map(Output::into_data), + nonce, + gas_used, + }; + + included.push(pool_tx.clone()); + tx_info.push(info); + transactions.push(pending.transaction.clone()); + } + Err(err) => { + executor.evm_mut().inspector_mut().discard_transaction(inspector_config); + if err.as_validation().is_some() { + warn!(target: "backend", "Skipping invalid tx [{:?}]: {}", pool_tx.hash(), err); + invalid.push(pool_tx.clone()); + } else { + unavailable_markers.extend(pool_tx.provides.iter().cloned()); + trace!(target: "backend", ?err, "tx execution error, skipping {:?}", pool_tx.hash()); + } + } } } + + ExecutedPoolTransactions { included, stale, invalid, not_yet_valid, tx_info, txs: transactions } } -/// Creates a database with given database and inspector, optionally enabling odyssey features. -pub fn new_evm_with_inspector( - db: DB, - env: &Env, - inspector: I, -) -> EitherEvm +/// Builds the EVM transaction env from a pending pool transaction. +pub fn build_tx_env_for_pending(tx: &PendingTransaction, cheats: &CheatsManager) -> T where - DB: Database + Debug, - I: Inspector> + Inspector>, + Tx: Transaction + Encodable2718, + T: FromTxWithEncoded + FoundryTransaction, { - if env.is_optimism { - let op_cfg = env.evm_env.cfg_env.clone().with_spec(op_revm::OpSpecId::ISTHMUS); - let op_context = OpContext { - journaled_state: { - let mut journal = Journal::new(db); - // Converting SpecId into OpSpecId - journal.set_spec_id(env.evm_env.cfg_env.spec); - journal - }, - block: env.evm_env.block_env.clone(), - cfg: op_cfg.clone(), - tx: env.tx.clone(), - chain: L1BlockInfo::default(), - local: LocalContext::default(), - error: Ok(()), - }; + let encoded = tx.transaction.encoded_2718().into(); + let mut tx_env: T = + FromTxWithEncoded::from_encoded_tx(tx.transaction.as_ref(), *tx.sender(), encoded); + + if let Some(signed_auths) = tx.transaction.authorization_list() + && cheats.has_recover_overrides() + { + let auth_list = tx_env.authorization_list_mut(); + let cheated_auths = signed_auths + .iter() + .zip(take(auth_list)) + .map(|(signed_auth, either_auth)| { + either_auth.right_and_then(|recovered_auth| { + if recovered_auth.authority().is_none() + && let Ok(signature) = signed_auth.signature() + && let Some(override_addr) = + cheats.get_recover_override(&signature.as_bytes().into()) + { + Either::Right(RecoveredAuthorization::new_unchecked( + recovered_auth.into_parts().0, + RecoveredAuthority::Valid(override_addr), + )) + } else { + Either::Right(recovered_auth) + } + }) + }) + .collect(); + *tx_env.authorization_list_mut() = cheated_auths; + } - let op_precompiles = OpPrecompiles::new_with_spec(op_cfg.spec).precompiles(); - let op_evm = op_revm::OpEvm(RevmEvm::new_with_inspector( - op_context, - inspector, - EthInstructions::default(), - PrecompilesMap::from_static(op_precompiles), - )); - - let op = OpEvm::new(op_evm, true); - - EitherEvm::Op(op) - } else { - let spec = env.evm_env.cfg_env.spec; - let eth_context = EthEvmContext { - journaled_state: { - let mut journal = Journal::new(db); - journal.set_spec_id(spec); - journal - }, - block: env.evm_env.block_env.clone(), - cfg: env.evm_env.cfg_env.clone(), - tx: env.tx.base.clone(), - chain: (), - local: LocalContext::default(), - error: Ok(()), - }; + tx_env +} - let eth_precompiles = EthPrecompiles { - precompiles: Precompiles::new(PrecompileSpecId::from_spec_id(spec)), - spec, - } - .precompiles; - let eth_evm = RevmEvm::new_with_inspector( - eth_context, - inspector, - EthInstructions::default(), - PrecompilesMap::from_static(eth_precompiles), - ); +/// Returns the block's blob gas budget. +/// +/// OP Jovian repurposes the block gas limit as the DA-footprint budget. Every other execution +/// profile uses the active EIP-4844 limit. +pub(crate) const fn block_blob_gas_limit( + optimism_jovian: bool, + block_gas_limit: u64, + max_blob_gas_per_block: u64, +) -> u64 { + if optimism_jovian { block_gas_limit } else { max_blob_gas_per_block } +} - let eth = EthEvm::new(eth_evm, true); +#[cfg(test)] +mod tests { + use super::*; + use alloy_eips::{ + eip6110::MAINNET_DEPOSIT_CONTRACT_ADDRESS, eip7002::WITHDRAWAL_REQUEST_TYPE, + eip7251::CONSOLIDATION_REQUEST_TYPE, + }; + use alloy_sol_types::{SolEvent, sol}; + + sol! { + event DepositEvent( + bytes pubkey, + bytes withdrawal_credentials, + bytes amount, + bytes signature, + bytes index + ); + } - EitherEvm::Eth(eth) + #[test] + fn prague_requests_use_consensus_order() { + let event = DepositEvent { + pubkey: Bytes::from(vec![0x11; 48]), + withdrawal_credentials: Bytes::from(vec![0x22; 32]), + amount: Bytes::from(vec![0x33; 8]), + signature: Bytes::from(vec![0x44; 96]), + index: Bytes::from(vec![0x55; 8]), + }; + let log = DepositEvent::encode_log(&Log { + address: MAINNET_DEPOSIT_CONTRACT_ADDRESS, + data: event, + }); + let receipt = + Receipt { status: Eip658Value::Eip658(true), cumulative_gas_used: 0, logs: vec![log] } + .with_bloom(); + let receipts = [FoundryReceiptEnvelope::Legacy(receipt)]; + let mut requests = Requests::default(); + + append_deposit_requests( + ActiveEthereumSpec { + hardfork: EthereumHardfork::Prague, + deposit_contract_address: MAINNET_DEPOSIT_CONTRACT_ADDRESS, + }, + &receipts, + &mut requests, + ) + .unwrap(); + requests.push_request_with_type(WITHDRAWAL_REQUEST_TYPE, [0xaa]); + requests.push_request_with_type(CONSOLIDATION_REQUEST_TYPE, [0xbb]); + + assert_eq!( + requests.iter().map(|request| request[0]).collect::>(), + [DEPOSIT_REQUEST_TYPE, WITHDRAWAL_REQUEST_TYPE, CONSOLIDATION_REQUEST_TYPE] + ); } -} -/// Creates a new EVM with the given inspector and wraps the database in a `WrapDatabaseRef`. -pub fn new_evm_with_inspector_ref<'db, DB, I>( - db: &'db DB, - env: &Env, - inspector: &'db mut I, -) -> EitherEvm, &'db mut I, PrecompilesMap> -where - DB: DatabaseRef + Debug + 'db + ?Sized, - I: Inspector>> - + Inspector>>, - WrapDatabaseRef<&'db DB>: Database, -{ - new_evm_with_inspector(WrapDatabaseRef(db), env, inspector) + #[test] + fn deposit_requests_use_configured_contract_address() { + let configured_address = Address::repeat_byte(0x42); + let event = DepositEvent { + pubkey: Bytes::from(vec![0x11; 48]), + withdrawal_credentials: Bytes::from(vec![0x22; 32]), + amount: Bytes::from(vec![0x33; 8]), + signature: Bytes::from(vec![0x44; 96]), + index: Bytes::from(vec![0x55; 8]), + }; + let configured_log = + DepositEvent::encode_log(&Log { address: configured_address, data: event.clone() }); + let mainnet_log = DepositEvent::encode_log(&Log { + address: MAINNET_DEPOSIT_CONTRACT_ADDRESS, + data: event, + }); + let receipt = Receipt { + status: Eip658Value::Eip658(true), + cumulative_gas_used: 0, + logs: vec![mainnet_log, configured_log], + } + .with_bloom(); + let receipts = [FoundryReceiptEnvelope::Legacy(receipt)]; + let mut requests = Requests::default(); + + append_deposit_requests( + ActiveEthereumSpec { + hardfork: EthereumHardfork::Prague, + deposit_contract_address: configured_address, + }, + &receipts, + &mut requests, + ) + .unwrap(); + + let request = requests.first().expect("configured deposit should be collected"); + assert_eq!(request[0], DEPOSIT_REQUEST_TYPE); + assert_eq!(request.len(), 1 + 48 + 32 + 8 + 96 + 8); + } } diff --git a/crates/anvil/src/eth/backend/executor/optimism.rs b/crates/anvil/src/eth/backend/executor/optimism.rs new file mode 100644 index 0000000000000..2a89f8907b4ad --- /dev/null +++ b/crates/anvil/src/eth/backend/executor/optimism.rs @@ -0,0 +1,143 @@ +//! OP-stack receipt construction for the Anvil block executor. + +use super::AnvilBlockExecutor; +use alloy_consensus::{Eip658Value, Receipt, ReceiptWithBloom}; +use alloy_primitives::Log; +use foundry_evm::hardfork::FoundryHardfork; +use foundry_primitives::FoundryReceiptEnvelope; +use op_alloy_consensus::{OpDepositReceipt, OpDepositReceiptWithBloom}; +use revm::context_interface::result::ExecutionResult; + +#[cfg(feature = "base")] +use foundry_evm::hardforks::BaseUpgrade; + +#[cfg(feature = "optimism")] +use alloy_consensus::Transaction; +#[cfg(feature = "optimism")] +use alloy_eips::Encodable2718; +#[cfg(feature = "optimism")] +use foundry_evm::hardfork::OpHardfork; +#[cfg(feature = "optimism")] +use foundry_primitives::FoundryTxEnvelope; +#[cfg(feature = "optimism")] +use op_revm::{L1BlockInfo, estimate_tx_compressed_size}; +#[cfg(feature = "optimism")] +use revm::Database; + +impl AnvilBlockExecutor { + /// Configures OP-specific block accounting without changing the shared constructor. + #[cfg(feature = "optimism")] + pub(crate) fn set_optimism_hardfork(&mut self, hardfork: FoundryHardfork) { + self.optimism_jovian = OpHardfork::from(hardfork) >= OpHardfork::Jovian; + self.deposit_hardfork = Some(hardfork); + } + + /// Records the Base upgrade that gates deposit receipt metadata. + #[cfg(feature = "base")] + pub(crate) const fn set_base_upgrade(&mut self, upgrade: BaseUpgrade) { + self.deposit_hardfork = Some(FoundryHardfork::Base(upgrade)); + } +} + +/// Returns the blob gas accounted for by an OP transaction under the active hardfork. +#[cfg(feature = "optimism")] +pub(crate) fn blob_gas_used( + db: &mut DB, + tx: &FoundryTxEnvelope, + jovian: bool, +) -> Result { + if !jovian || tx.is_deposit() { + return Ok(tx.blob_gas_used().unwrap_or_default()); + } + + let encoded = estimate_tx_compressed_size(tx.encoded_2718().as_ref()).saturating_div(1_000_000); + let scalar = L1BlockInfo::fetch_da_footprint_gas_scalar(db) + .map_err(alloy_evm::block::BlockExecutionError::other)?; + Ok(encoded.saturating_mul(u64::from(scalar))) +} + +/// Builds a mined OP deposit receipt and derives its fork-specific metadata. +/// +/// `depositor_nonce` is the sender nonce before the deposit executed. Without a configured +/// hardfork the receipt carries both fields, matching the network's default configuration. +pub(crate) fn build_mined_deposit_receipt( + result: ExecutionResult, + hardfork: Option, + depositor_nonce: u64, + cumulative_gas_used: u64, +) -> FoundryReceiptEnvelope { + let (deposit_nonce, deposit_receipt_version) = match hardfork { + Some(hardfork) => deposit_metadata(hardfork, depositor_nonce), + None => (Some(depositor_nonce), Some(1)), + }; + build_deposit_receipt(result, cumulative_gas_used, deposit_nonce, deposit_receipt_version) +} + +/// Builds an RPC-simulated OP deposit receipt and derives its fork-specific metadata. +pub(crate) fn build_simulated_deposit_receipt( + hardfork: FoundryHardfork, + caller_nonce: u64, + result: &ExecutionResult, + logs: Vec, + cumulative_gas_used: u64, +) -> FoundryReceiptEnvelope { + let (deposit_nonce, deposit_receipt_version) = deposit_metadata(hardfork, caller_nonce); + let receipt = + Receipt { status: Eip658Value::Eip658(result.is_success()), cumulative_gas_used, logs } + .with_bloom(); + wrap_deposit_receipt(receipt, deposit_nonce, deposit_receipt_version) +} + +/// Resolves the deposit nonce and receipt version active at `hardfork`. +/// +/// Base is an OP-stack chain, so it gates the same two fields on its own upgrade names. +fn deposit_metadata(hardfork: FoundryHardfork, caller_nonce: u64) -> (Option, Option) { + #[cfg(feature = "base")] + if matches!(hardfork, FoundryHardfork::Base(_)) { + let upgrade = BaseUpgrade::from(hardfork); + return ( + (upgrade >= BaseUpgrade::Regolith).then_some(caller_nonce), + (upgrade >= BaseUpgrade::Canyon).then_some(1), + ); + } + #[cfg(feature = "optimism")] + { + let hardfork = OpHardfork::from(hardfork); + ( + (hardfork >= OpHardfork::Regolith).then_some(caller_nonce), + (hardfork >= OpHardfork::Canyon).then_some(1), + ) + } + #[cfg(not(feature = "optimism"))] + (None, None) +} + +fn build_deposit_receipt( + result: ExecutionResult, + cumulative_gas_used: u64, + deposit_nonce: Option, + deposit_receipt_version: Option, +) -> FoundryReceiptEnvelope { + let receipt = Receipt { + status: Eip658Value::Eip658(result.is_success()), + cumulative_gas_used, + logs: result.into_logs(), + } + .with_bloom(); + wrap_deposit_receipt(receipt, deposit_nonce, deposit_receipt_version) +} + +fn wrap_deposit_receipt( + receipt: ReceiptWithBloom, + deposit_nonce: Option, + deposit_receipt_version: Option, +) -> FoundryReceiptEnvelope { + FoundryReceiptEnvelope::Deposit(OpDepositReceiptWithBloom { + receipt: OpDepositReceipt { + inner: receipt.receipt, + deposit_nonce, + deposit_receipt_version, + }, + logs_bloom: receipt.logs_bloom, + }) +} diff --git a/crates/anvil/src/eth/backend/fork.rs b/crates/anvil/src/eth/backend/fork.rs index e914d169a894c..317b52cbfd896 100644 --- a/crates/anvil/src/eth/backend/fork.rs +++ b/crates/anvil/src/eth/backend/fork.rs @@ -1,12 +1,16 @@ //! Support for forking off another client -use crate::eth::{backend::db::Db, error::BlockchainError, pool::transactions::PoolTransaction}; -use alloy_consensus::Account; +use crate::eth::{backend::db::Db, error::BlockchainError}; +use alloy_chains::NamedChain; +use alloy_consensus::{BlockHeader, TrieAccount}; use alloy_eips::eip2930::AccessListResult; -use alloy_network::{AnyRpcBlock, AnyRpcTransaction, BlockResponse, TransactionResponse}; +use alloy_network::{ + AnyNetwork, AnyRpcBlock, BlockResponse, Network, TransactionResponse, + primitives::HeaderResponse, +}; use alloy_primitives::{ Address, B256, Bytes, StorageValue, U256, - map::{FbHashMap, HashMap}, + map::{FbHashMap, HashMap, HashSet}, }; use alloy_provider::{ Provider, @@ -14,98 +18,105 @@ use alloy_provider::{ }; use alloy_rpc_types::{ BlockId, BlockNumberOrTag as BlockNumber, BlockTransactions, EIP1186AccountProofResponse, - FeeHistory, Filter, Log, + FeeHistory, Filter, FilterBlockOption, FilterSet, Index, Log, request::TransactionRequest, simulate::{SimulatePayload, SimulatedBlock}, + state::StateOverride, trace::{ - geth::{GethDebugTracingOptions, GethTrace}, - parity::LocalizedTransactionTrace as Trace, + geth::{GethDebugTracingCallOptions, GethDebugTracingOptions, GethTrace, TraceResult}, + opcode::{BlockOpcodeGas, TransactionOpcodeGas}, + parity::{ + LocalizedTransactionTrace as Trace, TraceResults, TraceResultsWithTransactionHash, + TraceType, + }, }, }; +use alloy_rpc_types_eth::{AccountInfo, Bundle, EthCallResponse, StateContext}; +use alloy_rpc_types_mev::{EthCallBundle, EthCallBundleResponse}; use alloy_serde::WithOtherFields; use alloy_transport::TransportError; -use anvil_core::eth::transaction::{ReceiptResponse, convert_to_anvil_receipt}; -use foundry_common::provider::{ProviderBuilder, RetryProvider}; +use foundry_common::provider::{RetryProvider, is_rpc_method_not_found}; +use foundry_evm::{ + backend::{AccountFetchPolicy, BlockchainDb, account_fetch_policy_for_source}, + fork::{cache_bal, validate_bal}, + hardfork::FoundryHardfork, +}; +use foundry_evm_networks::{NetworkConfigs, NetworkVariant}; +use foundry_primitives::FoundryTxReceipt; use parking_lot::{ RawRwLock, RwLock, lock_api::{RwLockReadGuard, RwLockWriteGuard}, }; -use revm::context_interface::block::BlobExcessGasAndPrice; +use revm::{context_interface::block::BlobExcessGasAndPrice, primitives::hardfork::SpecId}; use std::{sync::Arc, time::Duration}; use tokio::sync::RwLock as AsyncRwLock; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct ForkEndpointIdentity { + pub(crate) execution_chain_id: u64, + pub(crate) source_chain_id: u64, + pub(crate) network: Option, + pub(crate) network_profile: Option, + pub(crate) hardfork: Option, + pub(crate) instance_id: Option, + pub(crate) source_fork_block_number: Option, + pub(crate) source_fork_block_hash: Option, +} + +impl ForkEndpointIdentity { + /// Returns whether this identity was reported by an Anvil endpoint. + pub(crate) const fn is_authoritative(self) -> bool { + self.hardfork.is_some() + } + + /// Returns whether two endpoints expose the same fork execution context. + pub(crate) fn context_eq(self, other: Self) -> bool { + self.execution_chain_id == other.execution_chain_id + && self.source_chain_id == other.source_chain_id + && self.network == other.network + && self.network_profile == other.network_profile + && self.hardfork == other.hardfork + && self.source_fork_block_number == other.source_fork_block_number + && self.source_fork_block_hash == other.source_fork_block_hash + } +} + +/// Ensures Anvil's EVM backend can execute the resolved upstream source chain. +/// +/// Anvil's execution chain-ID override does not change the bytecode format in remote fork state. +pub(crate) fn ensure_fork_network_supported(chain_id: u64) -> Result<(), BlockchainError> { + if matches!(NamedChain::try_from(chain_id), Ok(NamedChain::ZkSync | NamedChain::ZkSyncTestnet)) + { + return Err(BlockchainError::UnsupportedForkNetwork { + chain_id, + reason: "Anvil's EVM backend cannot execute native EraVM bytecode; use `anvil-zksync` for zkSync Era forks", + }); + } + Ok(()) +} + /// Represents a fork of a remote client /// /// This type contains a subset of the [`EthApi`](crate::eth::EthApi) functions but will exclusively /// fetch the requested data from the remote client, if it wasn't already fetched. #[derive(Clone, Debug)] -pub struct ClientFork { +pub struct ClientFork { /// Contains the cached data - pub storage: Arc>, + pub storage: Arc>>, /// contains the info how the fork is configured // Wrapping this in a lock, ensures we can update this on the fly via additional custom RPC // endpoints - pub config: Arc>, + pub config: Arc>>, /// This also holds a handle to the underlying database pub database: Arc>>, } -impl ClientFork { +impl ClientFork { /// Creates a new instance of the fork - pub fn new(config: ClientForkConfig, database: Arc>>) -> Self { + pub fn new(config: ClientForkConfig, database: Arc>>) -> Self { Self { storage: Default::default(), config: Arc::new(RwLock::new(config)), database } } - /// Reset the fork to a fresh forked state, and optionally update the fork config - pub async fn reset( - &self, - url: Option, - block_number: impl Into, - ) -> Result<(), BlockchainError> { - let block_number = block_number.into(); - { - self.database - .write() - .await - .maybe_reset(url.clone(), block_number) - .map_err(BlockchainError::Internal)?; - } - - if let Some(url) = url { - self.config.write().update_url(url)?; - let override_chain_id = self.config.read().override_chain_id; - let chain_id = if let Some(chain_id) = override_chain_id { - chain_id - } else { - self.provider().get_chain_id().await? - }; - self.config.write().chain_id = chain_id; - } - - let provider = self.provider(); - let block = - provider.get_block(block_number).await?.ok_or(BlockchainError::BlockNotFound)?; - let block_hash = block.header.hash; - let timestamp = block.header.timestamp; - let base_fee = block.header.base_fee_per_gas; - let total_difficulty = block.header.total_difficulty.unwrap_or_default(); - - let number = block.header.number; - self.config.write().update_block( - number, - block_hash, - timestamp, - base_fee.map(|g| g as u128), - total_difficulty, - ); - - self.clear_cached_storage(); - - self.database.write().await.insert_block_hash(U256::from(number), block_hash); - - Ok(()) - } - /// Removes all data cached from previous responses pub fn clear_cached_storage(&self) { self.storage.write().clear() @@ -129,6 +140,25 @@ impl ClientFork { self.config.read().block_number } + /// Converts a local RPC block number to its EVM-visible number. + /// + /// Local mining advances both numbers once per block, preserving the fork root's offset. + pub fn evm_block_number(&self, rpc_number: u64) -> U256 { + let config = self.config.read(); + U256::from(rpc_number) + .saturating_add(U256::from(config.evm_block_number)) + .saturating_sub(U256::from(config.block_number)) + } + + /// Converts a local EVM-visible block number to its RPC number. + pub fn rpc_block_number(&self, evm_number: U256) -> u64 { + let config = self.config.read(); + evm_number + .saturating_add(U256::from(config.block_number)) + .saturating_sub(U256::from(config.evm_block_number)) + .saturating_to() + } + /// Returns the transaction hash we forked off of, if any. pub fn transaction_hash(&self) -> Option { self.config.read().transaction_hash @@ -146,23 +176,37 @@ impl ClientFork { self.config.read().block_hash } - pub fn eth_rpc_url(&self) -> String { - self.config.read().eth_rpc_url.clone() + pub fn eth_rpc_url(&self) -> Option { + self.config.read().eth_rpc_url().map(|s| s.to_string()) } pub fn chain_id(&self) -> u64 { self.config.read().chain_id } - fn provider(&self) -> Arc { + /// Returns whether this fork source requires the combined account-info RPC. + pub fn requires_account_info(&self) -> bool { + let config = self.config.read(); + account_fetch_policy_for_source( + config.chain_id, + config.endpoint_identity.network_profile.unwrap_or_default(), + ) == AccountFetchPolicy::RequireAccountInfo + } + + /// Returns the execution chain ID exposed by the forked node. + pub fn execution_chain_id(&self) -> u64 { + self.config.read().execution_chain_id + } + + fn provider(&self) -> Arc> { self.config.read().provider.clone() } - fn storage_read(&self) -> RwLockReadGuard<'_, RawRwLock, ForkedStorage> { + fn storage_read(&self) -> RwLockReadGuard<'_, RawRwLock, ForkedStorage> { self.storage.read() } - fn storage_write(&self) -> RwLockWriteGuard<'_, RawRwLock, ForkedStorage> { + fn storage_write(&self) -> RwLockWriteGuard<'_, RawRwLock, ForkedStorage> { self.storage.write() } @@ -186,53 +230,38 @@ impl ClientFork { self.provider().get_proof(address, keys).block_id(block_number.unwrap_or_default()).await } - /// Sends `eth_call` - pub async fn call( + /// Sends `eth_getBlockAccessList` + pub async fn block_access_list( &self, - request: &WithOtherFields, - block: Option, - ) -> Result { - let block = block.unwrap_or(BlockNumber::Latest); - let res = self.provider().call(request.clone()).block(block.into()).await?; - - Ok(res) + block_id: BlockId, + ) -> Result, TransportError> { + self.provider().raw_request("eth_getBlockAccessList".into(), (block_id,)).await } - /// Sends `eth_simulateV1` - pub async fn simulate_v1( + /// Sends `eth_getBlockAccessListByBlockHash` + pub async fn block_access_list_by_hash( &self, - request: &SimulatePayload, - block: Option, - ) -> Result>, TransportError> { - let mut simulate_call = self.provider().simulate(request); - if let Some(n) = block { - simulate_call = simulate_call.number(n.as_number().unwrap()); - } - - let res = simulate_call.await?; - - Ok(res) + block_hash: B256, + ) -> Result, TransportError> { + self.provider().raw_request("eth_getBlockAccessListByBlockHash".into(), (block_hash,)).await } - /// Sends `eth_estimateGas` - pub async fn estimate_gas( + /// Sends `eth_getBlockAccessListByBlockNumber` + pub async fn block_access_list_by_number( &self, - request: &WithOtherFields, - block: Option, - ) -> Result { - let block = block.unwrap_or_default(); - let res = self.provider().estimate_gas(request.clone()).block(block.into()).await?; - - Ok(res as u128) + block_number: BlockNumber, + ) -> Result, TransportError> { + self.provider() + .raw_request("eth_getBlockAccessListByBlockNumber".into(), (block_number,)) + .await } - /// Sends `eth_createAccessList` - pub async fn create_access_list( + /// Sends `eth_getBlockAccessListRaw`. + pub async fn block_access_list_raw( &self, - request: &WithOtherFields, - block: Option, - ) -> Result { - self.provider().create_access_list(request).block_id(block.unwrap_or_default().into()).await + block_id: BlockId, + ) -> Result, TransportError> { + self.provider().raw_request("eth_getBlockAccessListRaw".into(), (block_id,)).await } pub async fn storage_at( @@ -248,14 +277,15 @@ impl ClientFork { } pub async fn logs(&self, filter: &Filter) -> Result, TransportError> { - if let Some(logs) = self.storage_read().logs.get(filter).cloned() { + let key = LogsCacheKey::from(filter); + if let Some(logs) = self.storage_read().logs.get(&key).cloned() { return Ok(logs); } let logs = self.provider().get_logs(filter).await?; let mut storage = self.storage_write(); - storage.logs.insert(filter.clone(), logs.clone()); + storage.logs.insert(key, logs.clone()); Ok(logs) } @@ -274,7 +304,7 @@ impl ClientFork { let code = self.provider().get_code_at(address).block_id(block_id).await?; let mut storage = self.storage_write(); - storage.code_at.insert((address, blocknumber), code.clone().0.into()); + storage.code_at.insert((address, blocknumber), code.clone()); Ok(code) } @@ -288,6 +318,15 @@ impl ClientFork { self.provider().get_balance(address).block_id(blocknumber.into()).await } + pub async fn get_account_info( + &self, + address: Address, + blocknumber: u64, + ) -> Result { + trace!(target: "backend::fork", "get_account_info={:?}", address); + self.provider().get_account_info(address).block_id(blocknumber.into()).await + } + pub async fn get_nonce(&self, address: Address, block: u64) -> Result { trace!(target: "backend::fork", "get_nonce={:?}", address); self.provider().get_transaction_count(address).block_id(block.into()).await @@ -297,87 +336,55 @@ impl ClientFork { &self, address: Address, blocknumber: u64, - ) -> Result { + ) -> Result { trace!(target: "backend::fork", "get_account={:?}", address); self.provider().get_account(address).block_id(blocknumber.into()).await } - pub async fn transaction_by_block_number_and_index( + pub async fn trace_transaction( &self, - number: u64, - index: usize, - ) -> Result, TransportError> { - if let Some(block) = self.block_by_number(number).await? { - match block.transactions() { - BlockTransactions::Full(txs) => { - if let Some(tx) = txs.get(index) { - return Ok(Some(tx.clone())); - } - } - BlockTransactions::Hashes(hashes) => { - if let Some(tx_hash) = hashes.get(index) { - return self.transaction_by_hash(*tx_hash).await; - } - } - // TODO(evalir): Is it possible to reach this case? Should we support it - BlockTransactions::Uncle => panic!("Uncles not supported"), - } + hash: B256, + ) -> Result>, TransportError> { + if let Some(traces) = self.storage_read().transaction_traces.get(&hash).cloned() { + return Ok(Some(traces)); } - Ok(None) - } - pub async fn transaction_by_block_hash_and_index( - &self, - hash: B256, - index: usize, - ) -> Result, TransportError> { - if let Some(block) = self.block_by_hash(hash).await? { - match block.transactions() { - BlockTransactions::Full(txs) => { - if let Some(tx) = txs.get(index) { - return Ok(Some(tx.clone())); - } - } - BlockTransactions::Hashes(hashes) => { - if let Some(tx_hash) = hashes.get(index) { - return self.transaction_by_hash(*tx_hash).await; - } - } - // TODO(evalir): Is it possible to reach this case? Should we support it - BlockTransactions::Uncle => panic!("Uncles not supported"), - } + let traces = self + .provider() + .raw_request::<_, Option>>("trace_transaction".into(), (hash,)) + .await?; + + if let Some(traces) = &traces { + self.storage_write().transaction_traces.insert(hash, traces.clone()); } - Ok(None) + + Ok(traces) } - pub async fn transaction_by_hash( + pub async fn trace_transaction_opcode_gas( &self, hash: B256, - ) -> Result, TransportError> { - trace!(target: "backend::fork", "transaction_by_hash={:?}", hash); - if let tx @ Some(_) = self.storage_read().transactions.get(&hash).cloned() { - return Ok(tx); - } - - let tx = self.provider().get_transaction_by_hash(hash).await?; - if let Some(tx) = tx.clone() { - let mut storage = self.storage_write(); - storage.transactions.insert(hash, tx); - } - Ok(tx) + ) -> Result, TransportError> { + self.provider().raw_request("trace_transactionOpcodeGas".into(), (hash,)).await } - pub async fn trace_transaction(&self, hash: B256) -> Result, TransportError> { - if let Some(traces) = self.storage_read().transaction_traces.get(&hash).cloned() { - return Ok(traces); - } - - let traces = self.provider().trace_transaction(hash).await?.into_iter().collect::>(); - - let mut storage = self.storage_write(); - storage.transaction_traces.insert(hash, traces.clone()); + /// Sends `trace_call`. + pub async fn trace_call( + &self, + request: WithOtherFields, + trace_types: HashSet, + block: BlockId, + ) -> Result { + self.provider().raw_request("trace_call".into(), (request, trace_types, block)).await + } - Ok(traces) + /// Sends `trace_get`. + pub async fn trace_get( + &self, + hash: B256, + indices: Vec, + ) -> Result, TransportError> { + self.provider().raw_request("trace_get".into(), (hash, indices)).await } pub async fn debug_trace_transaction( @@ -385,18 +392,36 @@ impl ClientFork { hash: B256, opts: GethDebugTracingOptions, ) -> Result { - if let Some(traces) = self.storage_read().geth_transaction_traces.get(&hash).cloned() { - return Ok(traces); + if let Some(trace) = self + .storage_read() + .geth_transaction_traces + .get(&hash) + .and_then(|traces| traces.iter().find(|(cached_opts, _)| cached_opts == &opts)) + .map(|(_, trace)| trace.clone()) + { + return Ok(trace); } - let trace = self.provider().debug_trace_transaction(hash, opts).await?; + let trace = self.provider().debug_trace_transaction(hash, opts.clone()).await?; let mut storage = self.storage_write(); - storage.geth_transaction_traces.insert(hash, trace.clone()); + let traces = storage.geth_transaction_traces.entry(hash).or_default(); + if !traces.iter().any(|(cached_opts, _)| cached_opts == &opts) { + traces.push((opts, trace.clone())); + } Ok(trace) } + pub async fn debug_trace_call( + &self, + request: WithOtherFields, + block_id: BlockId, + opts: GethDebugTracingCallOptions, + ) -> Result { + self.provider().raw_request("debug_traceCall".into(), (request, block_id, opts)).await + } + pub async fn debug_code_by_hash( &self, code_hash: B256, @@ -405,6 +430,57 @@ impl ClientFork { self.provider().debug_code_by_hash(code_hash, block_id).await } + pub async fn debug_account_info_at( + &self, + block_id: BlockId, + tx_index: Index, + address: Address, + ) -> Result, TransportError> { + self.provider() + .raw_request("debug_accountInfoAt".into(), (block_id, tx_index, address)) + .await + } + + pub async fn debug_trace_block_by_hash( + &self, + block_hash: B256, + opts: GethDebugTracingOptions, + ) -> Result, TransportError> { + if let Some(traces) = self + .storage_read() + .geth_block_traces + .get(&block_hash) + .and_then(|traces| traces.iter().find(|(cached_opts, _)| cached_opts == &opts)) + .map(|(_, traces)| traces.clone()) + { + return Ok(traces); + } + + let trace_results = + self.provider().debug_trace_block_by_hash(block_hash, opts.clone()).await?; + + let mut storage = self.storage_write(); + let traces = storage.geth_block_traces.entry(block_hash).or_default(); + if !traces.iter().any(|(cached_opts, _)| cached_opts == &opts) { + traces.push((opts, trace_results.clone())); + } + + Ok(trace_results) + } + + pub async fn debug_trace_block_by_number( + &self, + number: u64, + opts: GethDebugTracingOptions, + ) -> Result, TransportError> { + if let Ok(Some(block)) = self.provider().get_block_by_number(number.into()).await { + let block_hash = block.header().hash(); + return self.debug_trace_block_by_hash(block_hash, opts).await; + } + + self.provider().debug_trace_block_by_number(number.into(), opts).await + } + pub async fn trace_block(&self, number: u64) -> Result, TransportError> { if let Some(traces) = self.storage_read().block_traces.get(&number).cloned() { return Ok(traces); @@ -419,68 +495,200 @@ impl ClientFork { Ok(traces) } - pub async fn transaction_receipt( + pub async fn trace_replay_block_transactions( + &self, + number: u64, + trace_types: HashSet, + ) -> Result, TransportError> { + // Forward to upstream provider for historical blocks. Use the typed trace API so the block + // and trace types are serialized in the format upstream providers expect. + self.provider() + .trace_replay_block_transactions(BlockId::number(number)) + .trace_types(trace_types) + .await + } + + pub async fn trace_replay_transaction( &self, hash: B256, - ) -> Result, BlockchainError> { - if let Some(receipt) = self.storage_read().transaction_receipts.get(&hash).cloned() { - return Ok(Some(receipt)); - } + trace_types: HashSet, + ) -> Result, TransportError> { + self.provider().raw_request("trace_replayTransaction".into(), (hash, trace_types)).await + } - if let Some(receipt) = self.provider().get_transaction_receipt(hash).await? { - let receipt = - convert_to_anvil_receipt(receipt).ok_or(BlockchainError::FailedToDecodeReceipt)?; - let mut storage = self.storage_write(); - storage.transaction_receipts.insert(hash, receipt.clone()); - return Ok(Some(receipt)); - } + pub async fn trace_block_opcode_gas( + &self, + block_id: BlockId, + ) -> Result, TransportError> { + self.provider().raw_request("trace_blockOpcodeGas".into(), (block_id,)).await + } - Ok(None) + /// Sends `eth_call` + pub async fn call( + &self, + request: &N::TransactionRequest, + block: Option, + ) -> Result { + let block = block.unwrap_or(BlockNumber::Latest); + let res = self.provider().call(request.clone()).block(block.into()).await?; + + Ok(res) } - pub async fn block_receipts( + /// Sends `eth_call` with a network-specific request. + pub async fn call_raw( + &self, + request: &WithOtherFields, + block: Option, + ) -> Result { + self.provider() + .raw_request("eth_call".into(), (request, block.unwrap_or(BlockNumber::Latest))) + .await + } + + /// Sends `eth_callMany` + pub async fn call_many( + &self, + bundles: Vec>>, + state_context: Option, + state_override: Option, + ) -> Result>, TransportError> { + self.provider() + .raw_request("eth_callMany".into(), (bundles, state_context, state_override)) + .await + } + + /// Sends `eth_callBundle`. + pub async fn call_bundle( + &self, + bundle: EthCallBundle, + ) -> Result { + self.provider().raw_request("eth_callBundle".into(), (bundle,)).await + } + + /// Sends `eth_simulateV1` + pub async fn simulate_v1( + &self, + request: &SimulatePayload>, + block: Option, + ) -> Result>, TransportError> { + self.provider().raw_request("eth_simulateV1".into(), (request, block)).await + } + + /// Sends `eth_estimateGas` + pub async fn estimate_gas( + &self, + request: &N::TransactionRequest, + block: Option, + ) -> Result { + let block = block.unwrap_or_default(); + let res = self.provider().estimate_gas(request.clone()).block(block.into()).await?; + + Ok(res as u128) + } + + /// Sends `eth_estimateGas` with a network-specific request. + pub async fn estimate_gas_raw( + &self, + request: &WithOtherFields, + block: Option, + ) -> Result { + let gas: U256 = self + .provider() + .raw_request("eth_estimateGas".into(), (request, block.unwrap_or_default())) + .await?; + Ok(gas.saturating_to()) + } + + /// Sends `eth_createAccessList` + pub async fn create_access_list( + &self, + request: &N::TransactionRequest, + block: Option, + ) -> Result { + self.provider().create_access_list(request).block_id(block.unwrap_or_default().into()).await + } + + /// Sends `eth_createAccessList` with a network-specific request. + pub async fn create_access_list_raw( + &self, + request: &WithOtherFields, + block: Option, + ) -> Result { + self.provider() + .raw_request("eth_createAccessList".into(), (request, block.unwrap_or_default())) + .await + } + + pub async fn transaction_by_block_number_and_index( &self, number: u64, - ) -> Result>, BlockchainError> { - if let receipts @ Some(_) = self.storage_read().block_receipts.get(&number).cloned() { - return Ok(receipts); - } + index: usize, + ) -> Result, TransportError> { + let block = self.block_by_number(number).await?; + self.transaction_at_block_index(block, index).await + } - // TODO Needs to be removed. - // Since alloy doesn't indicate in the result whether the block exists, - // this is being temporarily implemented in anvil. - if self.predates_fork_inclusive(number) { - let receipts = self.provider().get_block_receipts(BlockId::from(number)).await?; - let receipts = receipts - .map(|r| { - r.into_iter() - .map(|r| { - convert_to_anvil_receipt(r) - .ok_or(BlockchainError::FailedToDecodeReceipt) - }) - .collect::, _>>() - }) - .transpose()?; + pub async fn transaction_by_block_hash_and_index( + &self, + hash: B256, + index: usize, + ) -> Result, TransportError> { + let block = self.block_by_hash(hash).await?; + self.transaction_at_block_index(block, index).await + } - if let Some(receipts) = receipts.clone() { - let mut storage = self.storage_write(); - storage.block_receipts.insert(number, receipts); + async fn transaction_at_block_index( + &self, + block: Option, + index: usize, + ) -> Result, TransportError> { + if let Some(block) = block { + match block.transactions() { + BlockTransactions::Full(txs) => { + if let Some(tx) = txs.get(index) { + return Ok(Some(tx.clone())); + } + } + BlockTransactions::Hashes(hashes) => { + if let Some(tx_hash) = hashes.get(index) { + return self.transaction_by_hash(*tx_hash).await; + } + } + BlockTransactions::Uncle => {} } + } + Ok(None) + } - return Ok(receipts); + pub async fn transaction_by_hash( + &self, + hash: B256, + ) -> Result, TransportError> { + trace!(target: "backend::fork", "transaction_by_hash={:?}", hash); + if let tx @ Some(_) = self.storage_read().transactions.get(&hash).cloned() { + return Ok(tx); } - Ok(None) + let tx = self.provider().get_transaction_by_hash(hash).await?; + if let Some(tx) = tx.clone() { + let mut storage = self.storage_write(); + storage.transactions.insert(hash, tx); + } + Ok(tx) } - pub async fn block_by_hash(&self, hash: B256) -> Result, TransportError> { + pub async fn block_by_hash( + &self, + hash: B256, + ) -> Result, TransportError> { if let Some(mut block) = self.storage_read().blocks.get(&hash).cloned() { - block.transactions.convert_to_hashes(); + block.transactions_mut().convert_to_hashes(); return Ok(Some(block)); } Ok(self.fetch_full_block(hash).await?.map(|mut b| { - b.transactions.convert_to_hashes(); + b.transactions_mut().convert_to_hashes(); b })) } @@ -488,9 +696,11 @@ impl ClientFork { pub async fn block_by_hash_full( &self, hash: B256, - ) -> Result, TransportError> { - if let Some(block) = self.storage_read().blocks.get(&hash).cloned() { - return Ok(Some(self.convert_to_full_block(block))); + ) -> Result, TransportError> { + if let Some(block) = self.storage_read().blocks.get(&hash).cloned() + && let Some(block) = self.convert_to_full_block(block) + { + return Ok(Some(block)); } self.fetch_full_block(hash).await } @@ -498,20 +708,20 @@ impl ClientFork { pub async fn block_by_number( &self, block_number: u64, - ) -> Result, TransportError> { + ) -> Result, TransportError> { if let Some(mut block) = self .storage_read() .hashes .get(&block_number) .and_then(|hash| self.storage_read().blocks.get(hash).cloned()) { - block.transactions.convert_to_hashes(); + block.transactions_mut().convert_to_hashes(); return Ok(Some(block)); } let mut block = self.fetch_full_block(block_number).await?; if let Some(block) = &mut block { - block.transactions.convert_to_hashes(); + block.transactions_mut().convert_to_hashes(); } Ok(block) } @@ -519,27 +729,36 @@ impl ClientFork { pub async fn block_by_number_full( &self, block_number: u64, - ) -> Result, TransportError> { + ) -> Result, TransportError> { if let Some(block) = self .storage_read() .hashes .get(&block_number) .copied() .and_then(|hash| self.storage_read().blocks.get(&hash).cloned()) + && let Some(block) = self.convert_to_full_block(block) { - return Ok(Some(self.convert_to_full_block(block))); + return Ok(Some(block)); } self.fetch_full_block(block_number).await } + /// Fetches a block selected by its original identifier directly from the fork provider. + pub async fn fetch_block( + &self, + block_id: BlockId, + ) -> Result, TransportError> { + self.fetch_full_block(block_id).await + } + async fn fetch_full_block( &self, block_id: impl Into, - ) -> Result, TransportError> { + ) -> Result, TransportError> { if let Some(block) = self.provider().get_block(block_id.into()).full().await? { - let hash = block.header.hash; - let block_number = block.header.number; + let hash = block.header().hash(); + let block_number = block.header().number(); let mut storage = self.storage_write(); // also insert all transactions let block_txs = match block.transactions() { @@ -555,6 +774,74 @@ impl ClientFork { Ok(None) } + /// Converts a block of hashes into a full block + fn convert_to_full_block(&self, mut block: N::BlockResponse) -> Option { + let storage = self.storage.read(); + let transactions = block + .transactions() + .hashes() + .map(|hash| storage.transactions.get(&hash).cloned()) + .collect::>>()?; + *block.transactions_mut() = BlockTransactions::Full(transactions); + Some(block) + } +} + +impl ClientFork { + pub async fn transaction_receipt( + &self, + hash: B256, + ) -> Result, BlockchainError> { + if let Some(receipt) = self.storage_read().transaction_receipts.get(&hash).cloned() { + return Ok(Some(receipt)); + } + + if let Some(receipt) = self.provider().get_transaction_receipt(hash).await? { + let receipt = FoundryTxReceipt::try_from(receipt) + .map_err(|_| BlockchainError::FailedToDecodeReceipt)?; + let mut storage = self.storage_write(); + storage.transaction_receipts.insert(hash, receipt.clone()); + return Ok(Some(receipt)); + } + + Ok(None) + } + + pub async fn block_receipts( + &self, + number: u64, + ) -> Result>, BlockchainError> { + if let receipts @ Some(_) = self.storage_read().block_receipts.get(&number).cloned() { + return Ok(receipts); + } + + // TODO Needs to be removed. + // Since alloy doesn't indicate in the result whether the block exists, + // this is being temporarily implemented in anvil. + if self.predates_fork_inclusive(number) { + let receipts = self.provider().get_block_receipts(BlockId::from(number)).await?; + let receipts = receipts + .map(|r| { + r.into_iter() + .map(|r| { + FoundryTxReceipt::try_from(r) + .map_err(|_| BlockchainError::FailedToDecodeReceipt) + }) + .collect::, _>>() + }) + .transpose()?; + + if let Some(receipts) = receipts.clone() { + let mut storage = self.storage_write(); + storage.block_receipts.insert(number, receipts); + } + + return Ok(receipts); + } + + Ok(None) + } + pub async fn uncle_by_block_hash_and_index( &self, hash: B256, @@ -582,8 +869,8 @@ impl ClientFork { block: AnyRpcBlock, index: usize, ) -> Result, TransportError> { - let block_hash = block.header.hash; - let block_number = block.header.number; + let block_hash = block.header().hash(); + let block_number = block.header().number(); if let Some(uncles) = self.storage_read().uncles.get(&block_hash) { return Ok(uncles.get(index).cloned()); } @@ -600,43 +887,38 @@ impl ClientFork { self.storage_write().uncles.insert(block_hash, uncles.clone()); Ok(uncles.get(index).cloned()) } - - /// Converts a block of hashes into a full block - fn convert_to_full_block(&self, mut block: AnyRpcBlock) -> AnyRpcBlock { - let storage = self.storage.read(); - let block_txs_len = match block.transactions { - BlockTransactions::Full(ref txs) => txs.len(), - BlockTransactions::Hashes(ref hashes) => hashes.len(), - // TODO: Should this be supported at all? - BlockTransactions::Uncle => 0, - }; - let mut transactions = Vec::with_capacity(block_txs_len); - for tx in block.transactions.hashes() { - if let Some(tx) = storage.transactions.get(&tx).cloned() { - transactions.push(tx); - } - } - // TODO: fix once blocks have generic transactions - block.inner.transactions = BlockTransactions::Full(transactions); - - block - } } /// Contains all fork metadata #[derive(Clone, Debug)] -pub struct ClientForkConfig { - pub eth_rpc_url: String, +pub struct ClientForkConfig { + /// All fork URLs. The first entry is the primary endpoint. + /// When multiple URLs are present, requests are distributed using + /// round-robin load balancing with retry-based failover. + pub fork_urls: Vec, /// The block number of the forked block pub block_number: u64, + /// The EVM-visible block number of the fork root, which is the L1 number on Arbitrum. + pub evm_block_number: u64, /// The hash of the forked block pub block_hash: B256, /// The transaction hash we forked off of, if any. pub transaction_hash: Option, - // TODO make provider agnostic - pub provider: Arc, + pub provider: Arc>, + /// Chain ID of the remote fork source. pub chain_id: u64, + /// Chain ID exposed by the fork endpoint, including inherited execution overrides. + pub execution_chain_id: u64, + /// Explicit execution chain ID exposed by the local node. pub override_chain_id: Option, + /// User-provided source chain ID that avoids remote discovery. + pub fork_chain_id: Option, + /// The effective hardfork used to execute the forked block. + pub hardfork: Option, + /// Stable endpoint identity captured with the fork block. + pub(crate) endpoint_identity: ForkEndpointIdentity, + /// Discovery identified a local node or could not rule out mutable source state. + pub(crate) state_is_mutable: bool, /// The timestamp for the forked block pub timestamp: u64, /// The basefee of the forked block @@ -653,44 +935,30 @@ pub struct ClientForkConfig { pub backoff: Duration, /// available CUPS pub compute_units_per_second: u64, + /// Headers to include with RPC requests + pub headers: Vec, /// total difficulty of the chain until this block pub total_difficulty: U256, - /// Transactions to force include in the forked chain - pub force_transactions: Option>, } -impl ClientForkConfig { - /// Updates the provider URL - /// - /// # Errors - /// - /// This will fail if no new provider could be established (erroneous URL) - fn update_url(&mut self, url: String) -> Result<(), BlockchainError> { - // let interval = self.provider.get_interval(); - self.provider = Arc::new( - ProviderBuilder::new(url.as_str()) - .timeout(self.timeout) - // .timeout_retry(self.retries) - .max_retry(self.retries) - .initial_backoff(self.backoff.as_millis() as u64) - .compute_units_per_second(self.compute_units_per_second) - .build() - .map_err(|_| BlockchainError::InvalidUrl(url.clone()))?, // .interval(interval), - ); - trace!(target: "fork", "Updated rpc url {}", url); - self.eth_rpc_url = url; - Ok(()) +impl ClientForkConfig { + /// Returns the primary RPC URL (first entry in `fork_urls`). + pub fn eth_rpc_url(&self) -> Option<&str> { + self.fork_urls.first().map(|s| s.as_str()) } + /// Updates the block forked off `(block number, block hash, timestamp)` pub fn update_block( &mut self, block_number: u64, + evm_block_number: u64, block_hash: B256, timestamp: u64, base_fee: Option, total_difficulty: U256, ) { self.block_number = block_number; + self.evm_block_number = evm_block_number; self.block_hash = block_hash; self.timestamp = timestamp; self.base_fee = base_fee; @@ -699,28 +967,139 @@ impl ClientForkConfig { } } +impl ClientForkConfig { + /// Accepts only a single, immutable Ethereum source under Cancun deletion rules. + fn bal_eligible(&self) -> bool { + let identity = self.endpoint_identity; + !self.state_is_mutable + && self.fork_urls.len() == 1 + && !identity.is_authoritative() + && identity.network.is_none_or(|network| network.is_ethereum()) + && matches!( + NamedChain::try_from(identity.source_chain_id), + Ok(NamedChain::Mainnet + | NamedChain::Sepolia + | NamedChain::Holesky + | NamedChain::Hoodi) + ) + && matches!( + FoundryHardfork::from_chain_and_timestamp(identity.source_chain_id, self.timestamp), + Some(hardfork @ FoundryHardfork::Ethereum(_)) if SpecId::from(hardfork) >= SpecId::CANCUN + ) + } + + /// Prefills the remote cache before local overrides, without making BAL support mandatory. + pub(crate) async fn prefill_cache(&self, db: &BlockchainDb) { + if !self.bal_eligible() || db.meta().read().fork_hash != Some(self.block_hash) { + return; + } + + let prefill = async { + let Some(bal) = + self.provider.get_block_access_list(BlockId::hash(self.block_hash)).await? + else { + return Ok(()); + }; + let Some(block) = self.provider.get_block(BlockId::hash(self.block_hash)).await? else { + return Ok(()); + }; + eyre::ensure!(block.header.hash == self.block_hash, "fork block hash mismatch"); + validate_bal(&bal, block.transactions.len(), block.header.block_access_list_hash())?; + + // Anvil can mutate state without changing the block hash. Discard the BAL if + // the source became local or its identity is now inconclusive. + match self + .provider + .raw_request::<_, serde_json::Value>("anvil_nodeInfo".into(), ()) + .await + { + Err(error) if is_rpc_method_not_found(&error) => {} + _ => return Ok(()), + } + cache_bal(db.db(), bal); + Ok::<_, eyre::Report>(()) + }; + // Include retries and validation RPCs in the optional startup budget. + match tokio::time::timeout(Duration::from_millis(500), prefill).await { + Ok(Ok(())) => {} + Ok(Err(_)) => debug!(target: "node", "fork BAL prefill unavailable"), + Err(_) => debug!(target: "node", "fork BAL prefill timed out"), + } + } +} + +#[cfg(test)] +mod bal_tests; + /// Contains cached state fetched to serve EthApi requests /// /// This is used as a cache so repeated requests to the same data are not sent to the remote client -#[derive(Clone, Debug, Default)] -pub struct ForkedStorage { - pub uncles: FbHashMap<32, Vec>, - pub blocks: FbHashMap<32, AnyRpcBlock>, +#[derive(Clone, Debug)] +pub struct ForkedStorage { + pub uncles: FbHashMap<32, Vec>, + pub blocks: FbHashMap<32, N::BlockResponse>, pub hashes: HashMap, - pub transactions: FbHashMap<32, AnyRpcTransaction>, - pub transaction_receipts: FbHashMap<32, ReceiptResponse>, + pub transactions: FbHashMap<32, N::TransactionResponse>, + pub transaction_receipts: FbHashMap<32, FoundryTxReceipt>, pub transaction_traces: FbHashMap<32, Vec>, - pub logs: HashMap>, - pub geth_transaction_traces: FbHashMap<32, GethTrace>, + pub logs: HashMap>, + pub geth_transaction_traces: FbHashMap<32, Vec<(GethDebugTracingOptions, GethTrace)>>, + pub geth_block_traces: FbHashMap<32, Vec<(GethDebugTracingOptions, Vec)>>, pub block_traces: HashMap>, - pub block_receipts: HashMap>, + pub block_receipts: HashMap>, pub code_at: HashMap<(Address, u64), Bytes>, } -impl ForkedStorage { +impl Default for ForkedStorage { + fn default() -> Self { + Self { + uncles: Default::default(), + blocks: Default::default(), + hashes: Default::default(), + transactions: Default::default(), + transaction_receipts: Default::default(), + transaction_traces: Default::default(), + logs: Default::default(), + geth_transaction_traces: Default::default(), + geth_block_traces: Default::default(), + block_traces: Default::default(), + block_receipts: Default::default(), + code_at: Default::default(), + } + } +} + +impl ForkedStorage { /// Clears all data pub fn clear(&mut self) { // simply replace with a completely new, empty instance *self = Self::default() } } + +/// Cache key for a log [`Filter`]. +/// +/// [`Filter`] is not hashable because its address and topic sets iterate in arbitrary order, so +/// the key stores them sorted. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct LogsCacheKey { + block_option: FilterBlockOption, + address: Vec
, + topics: [Vec; 4], +} + +impl From<&Filter> for LogsCacheKey { + fn from(filter: &Filter) -> Self { + Self { + block_option: filter.block_option, + address: sorted_filter_set(&filter.address), + topics: filter.topics.each_ref().map(sorted_filter_set), + } + } +} + +fn sorted_filter_set(set: &FilterSet) -> Vec { + let mut values = set.iter().copied().collect::>(); + values.sort_unstable(); + values +} diff --git a/crates/anvil/src/eth/backend/fork/bal_tests.rs b/crates/anvil/src/eth/backend/fork/bal_tests.rs new file mode 100644 index 0000000000000..3863f12872b93 --- /dev/null +++ b/crates/anvil/src/eth/backend/fork/bal_tests.rs @@ -0,0 +1,350 @@ +//! Tests for BAL source eligibility, validation and fork cache insertion. + +use super::*; +use alloy_eips::eip7928::{ + AccountChanges, BalanceChange, BlockAccessIndex, CodeChange, NonceChange, SlotChanges, + StorageChange, compute_block_access_list_hash, +}; +use alloy_network::{AnyHeader, AnyRpcHeader}; +use alloy_primitives::bytes; +use alloy_provider::ProviderBuilder; +use alloy_rpc_types::Block; +use alloy_transport::mock::Asserter; +use foundry_evm::{backend::BlockchainDbMeta, hardfork::EthereumHardfork}; +use revm::{context::BlockEnv, state::AccountInfo}; + +fn database(hash: B256) -> BlockchainDb { + BlockchainDb::new( + BlockchainDbMeta::new(BlockEnv::default(), "http://localhost:8545".to_string()) + .with_fork_identity(hash, B256::ZERO), + None, + ) +} + +fn index(index: u64) -> BlockAccessIndex { + BlockAccessIndex::new(index) +} + +fn fork_config(asserter: Asserter, block_hash: B256) -> ClientForkConfig { + ClientForkConfig { + fork_urls: vec!["http://localhost:8545".to_string()], + block_number: 1, + evm_block_number: 1, + block_hash, + transaction_hash: None, + provider: Arc::new( + ProviderBuilder::<_, _, AnyNetwork>::default().connect_mocked_client(asserter), + ), + chain_id: 1, + execution_chain_id: 1, + override_chain_id: None, + fork_chain_id: None, + hardfork: None, + endpoint_identity: ForkEndpointIdentity { + execution_chain_id: 1, + source_chain_id: 1, + network: None, + network_profile: None, + hardfork: None, + instance_id: None, + source_fork_block_number: None, + source_fork_block_hash: None, + }, + state_is_mutable: false, + timestamp: 1_800_000_000, + base_fee: None, + blob_gas_used: None, + blob_excess_gas_and_price: None, + timeout: Duration::from_secs(1), + retries: 0, + backoff: Duration::ZERO, + compute_units_per_second: 0, + headers: vec![], + total_difficulty: U256::ZERO, + } +} + +#[tokio::test] +async fn fork_bal_skips_ineligible_sources_and_mismatched_cache() { + let hash = B256::repeat_byte(1); + for (mutable, multiple, wrong_block) in + [(true, false, false), (false, true, false), (false, false, true)] + { + let asserter = Asserter::new(); + let mut config = fork_config(asserter.clone(), hash); + config.state_is_mutable = mutable; + if multiple { + config.fork_urls.push("http://localhost:8546".to_string()); + } + asserter.push_success(&serde_json::Value::Null); + let db = database(if wrong_block { B256::ZERO } else { hash }); + + config.prefill_cache(&db).await; + + assert_eq!(asserter.read_q().len(), 1, "ineligible sources must not issue prefill RPCs"); + assert!(db.accounts().read().is_empty()); + assert!(db.storage().read().is_empty()); + } +} + +#[test] +fn fork_bal_uses_source_rules() { + let ethereum = Some(NetworkVariant::Ethereum); + for (chain_id, network, timestamp, eligible) in [ + (1, ethereum, 1_800_000_000, true), + (1, ethereum, 1_600_000_000, false), + (1, None, 1_800_000_000, true), + (11_155_111, ethereum, 1_800_000_000, true), + (17_000, ethereum, 1_800_000_000, true), + (560_048, ethereum, 1_800_000_000, true), + (31_337, ethereum, 1_800_000_000, false), + (42_161, ethereum, 1_800_000_000, false), + (1, Some(NetworkVariant::Tempo), 1_800_000_000, false), + ] { + let mut config = fork_config(Asserter::new(), B256::ZERO); + config.endpoint_identity.source_chain_id = chain_id; + config.endpoint_identity.network = network; + config.timestamp = timestamp; + // Local execution overrides must not make an ineligible source eligible. + config.hardfork = Some(EthereumHardfork::Amsterdam.into()); + config.execution_chain_id = 31_337; + config.override_chain_id = Some(31_337); + config.endpoint_identity.execution_chain_id = 31_337; + assert_eq!(config.bal_eligible(), eligible, "{chain_id}, {network:?}, {timestamp}"); + } +} + +fn rpc_error(asserter: &Asserter, code: i64) { + asserter.push_failure( + serde_json::from_value(serde_json::json!({"code": code, "message": "unavailable"})) + .unwrap(), + ); +} + +#[tokio::test] +async fn fork_bal_prefill_validates_before_caching() { + let hash = B256::repeat_byte(1); + let address = Address::repeat_byte(1); + for (bad_commitment, wrong_hash, identity_code) in [ + (false, false, -32601), + (true, false, -32601), + (false, true, -32601), + (false, false, -32603), + (false, false, 0), + ] { + let asserter = Asserter::new(); + let config = fork_config(asserter.clone(), hash); + let bal = vec![complete_account(address, bytes!("6000")).with_storage_change( + SlotChanges::new(U256::ONE, vec![StorageChange::new(index(1), U256::ONE)]), + )]; + let block = AnyRpcBlock::new( + Block::new( + AnyRpcHeader { + hash: if wrong_hash { B256::ZERO } else { hash }, + inner: AnyHeader { + number: config.block_number, + timestamp: config.timestamp, + block_access_list_hash: Some(if bad_commitment { + B256::ZERO + } else { + compute_block_access_list_hash(&bal) + }), + ..Default::default() + }, + ..Default::default() + }, + BlockTransactions::Hashes(vec![B256::repeat_byte(2)]), + ) + .into(), + ); + asserter.push_success(&bal); + asserter.push_success(&block); + if !bad_commitment && !wrong_hash { + if identity_code == 0 { + asserter.push_success(&serde_json::json!({})); + } else { + rpc_error(&asserter, identity_code); + } + } + let db = database(hash); + + config.prefill_cache(&db).await; + + assert!(asserter.read_q().is_empty()); + if bad_commitment || wrong_hash || identity_code != -32601 { + assert!(db.accounts().read().is_empty()); + assert!(db.storage().read().is_empty()); + } else { + assert_eq!(db.storage().read()[&address][&U256::ONE], U256::ONE); + let accounts = db.accounts().read(); + let account = &accounts[&address]; + assert_eq!(account.balance, U256::from(42)); + assert_eq!(account.nonce, 3); + assert_eq!(account.code.as_ref().unwrap().original_bytes(), bytes!("6000")); + } + } +} + +#[tokio::test] +async fn fork_bal_unavailable_does_not_fetch_block_or_change_cache() { + let hash = B256::repeat_byte(1); + for error in [None, Some(-32603), Some(-32601)] { + let asserter = Asserter::new(); + let config = fork_config(asserter.clone(), hash); + if let Some(code) = error { + rpc_error(&asserter, code); + } + if error.is_none() { + asserter.push_success(&serde_json::Value::Null); + } + // A following response must stay untouched when no BAL is available. + asserter.push_success(&serde_json::Value::Null); + let db = database(hash); + db.storage().write().entry(Address::ZERO).or_default().insert(U256::ZERO, U256::ONE); + let storage = db.storage().read().clone(); + + config.prefill_cache(&db).await; + + assert_eq!(asserter.read_q().len(), 1); + assert_eq!(*db.storage().read(), storage); + assert!(db.accounts().read().is_empty()); + } +} + +#[test] +fn fork_bal_seed_preserves_storage_boundaries() { + let hash = B256::repeat_byte(1); + let address = Address::repeat_byte(1); + let slot = U256::ONE; + let system_slot = U256::from(2); + let account = AccountChanges::new(address) + .with_storage_change(SlotChanges::new( + slot, + vec![ + StorageChange::new(index(0), U256::from(3)), + StorageChange::new(index(1), U256::from(4)), + StorageChange::new(index(3), U256::ZERO), + ], + )) + .with_storage_change(SlotChanges::new( + system_slot, + vec![StorageChange::new(index(0), U256::from(5))], + )); + let db = database(hash); + + cache_bal(db.db(), vec![account]); + + let storage = db.storage().read(); + assert_eq!(storage[&address][&slot], U256::ZERO); + assert_eq!(storage[&address][&system_slot], U256::from(5)); + assert!(db.accounts().read().is_empty()); + + let post_execution_slot = U256::from(3); + let post_execution = AccountChanges::new(address).with_storage_change(SlotChanges::new( + post_execution_slot, + vec![StorageChange::new(index(1), U256::from(42))], + )); + let db = database(hash); + validate_bal(&vec![post_execution.clone()], 0, None).unwrap(); + cache_bal(db.db(), vec![post_execution]); + assert_eq!(db.storage().read()[&address][&post_execution_slot], U256::from(42)); +} + +#[test] +fn fork_bal_seed_leaves_partial_accounts_and_reads_unknown() { + let hash = B256::repeat_byte(1); + let address = Address::repeat_byte(1); + let account = AccountChanges::new(address) + .with_balance_change(BalanceChange::new(index(1), U256::from(42))) + .with_storage_read(U256::from(2)); + let db = database(hash); + + cache_bal(db.db(), vec![account]); + + assert!(db.accounts().read().is_empty()); + assert!(db.storage().read().is_empty()); +} + +fn complete_account(address: Address, code: Bytes) -> AccountChanges { + AccountChanges::new(address) + .with_balance_change(BalanceChange::new(index(1), U256::from(42))) + .with_nonce_change(NonceChange::new(index(1), 3)) + .with_code_change(CodeChange::new(index(1), code)) +} + +#[test] +fn fork_bal_seed_preserves_cached_values_and_merges_slots() { + let hash = B256::repeat_byte(1); + let address = Address::repeat_byte(1); + let account = complete_account(address, Bytes::new()) + .with_storage_change(SlotChanges::new( + U256::ONE, + vec![StorageChange::new(index(1), U256::from(11))], + )) + .with_storage_change(SlotChanges::new( + U256::from(2), + vec![StorageChange::new(index(1), U256::from(22))], + )); + let db = database(hash); + let cached_account = AccountInfo { balance: U256::from(99), ..Default::default() }; + db.accounts().write().insert(address, cached_account.clone()); + db.storage().write().insert( + address, + [(U256::ONE, U256::from(101)), (U256::from(3), U256::from(303))].into_iter().collect(), + ); + + cache_bal(db.db(), vec![account]); + + assert_eq!(db.accounts().read()[&address], cached_account); + assert_eq!( + db.storage().read()[&address], + [ + (U256::ONE, U256::from(101)), + (U256::from(2), U256::from(22)), + (U256::from(3), U256::from(303)), + ] + .into_iter() + .collect() + ); +} + +#[test] +fn fork_bal_seed_keeps_final_account_code() { + let address = Address::repeat_byte(1); + let delegation = bytes!("ef01000000000000000000000000000000000000000042"); + for code in [bytes!("6000"), delegation, Bytes::new()] { + let db = database(B256::ZERO); + let account = complete_account(address, bytes!("6001")) + .with_code_change(CodeChange::new(index(2), code.clone())); + + cache_bal(db.db(), vec![account]); + + let accounts = db.accounts().read(); + let account = &accounts[&address]; + assert_eq!(account.balance, U256::from(42)); + assert_eq!(account.nonce, 3); + assert_eq!(account.code_hash(), alloy_primitives::keccak256(&code)); + assert_eq!(account.code.as_ref().unwrap().original_bytes(), code); + assert_eq!( + account.code.as_ref().unwrap().is_eip7702(), + code.starts_with(&[0xef, 0x01, 0x00]) + ); + } +} + +#[test] +fn fork_bal_seed_rejects_invalid_structure_hash_and_bytecode() { + let address = Address::repeat_byte(1); + let valid = vec![complete_account(address, Bytes::new())]; + let commitment = compute_block_access_list_hash(&valid); + assert!(validate_bal(&valid, 1, Some(commitment)).is_ok()); + assert!(validate_bal(&valid, 1, Some(B256::ZERO)).is_err()); + + let duplicate_accounts = vec![valid[0].clone(), valid[0].clone()]; + assert!(validate_bal(&duplicate_accounts, 1, None).is_err()); + // Invalid code must discard the seed even for an incomplete account or an earlier write. + let invalid_code = AccountChanges::new(address) + .with_code_change(CodeChange::new(index(0), bytes!("ef0100"))) + .with_code_change(CodeChange::new(index(1), Bytes::new())); + assert!(validate_bal(&vec![invalid_code], 1, None).is_err()); +} diff --git a/crates/anvil/src/eth/backend/genesis.rs b/crates/anvil/src/eth/backend/genesis.rs index 597dd1ee2d179..bc585b61f267b 100644 --- a/crates/anvil/src/eth/backend/genesis.rs +++ b/crates/anvil/src/eth/backend/genesis.rs @@ -5,7 +5,6 @@ use alloy_genesis::{Genesis, GenesisAccount}; use alloy_primitives::{Address, U256}; use foundry_evm::backend::DatabaseResult; use revm::{bytecode::Bytecode, primitives::KECCAK_EMPTY, state::AccountInfo}; -use tokio::sync::RwLockWriteGuard; /// Genesis settings #[derive(Clone, Debug, Default)] @@ -32,16 +31,14 @@ impl GenesisConfig { // we set this to empty so `Database::code_by_hash` doesn't get called code: Some(Default::default()), nonce: 0, + account_id: None, }; (address, info) }) } /// If an initial `genesis.json` was provided, this applies the account alloc to the db - pub fn apply_genesis_json_alloc( - &self, - mut db: RwLockWriteGuard<'_, Box>, - ) -> DatabaseResult<()> { + pub fn apply_genesis_json_alloc(&self, db: &mut dyn Db) -> DatabaseResult<()> { if let Some(ref genesis) = self.genesis_init { for (addr, mut acc) in genesis.alloc.clone() { let storage = std::mem::take(&mut acc.storage); @@ -65,6 +62,7 @@ impl GenesisConfig { nonce: nonce.unwrap_or_default(), code_hash: code.as_ref().map(|code| code.hash_slow()).unwrap_or(KECCAK_EMPTY), code, + account_id: None, } } } diff --git a/crates/anvil/src/eth/backend/info.rs b/crates/anvil/src/eth/backend/info.rs index 0f539f9373dcb..c739cd26d1de6 100644 --- a/crates/anvil/src/eth/backend/info.rs +++ b/crates/anvil/src/eth/backend/info.rs @@ -1,9 +1,10 @@ //! Handler that can get current storage related data use crate::mem::Backend; -use alloy_network::AnyRpcBlock; +use alloy_consensus::TxReceipt; +use alloy_network::{AnyRpcBlock, Network}; use alloy_primitives::B256; -use anvil_core::eth::{block::Block, transaction::TypedReceipt}; +use anvil_core::eth::block::Block; use std::{fmt, sync::Arc}; /// A type that can fetch data related to the ethereum storage. @@ -12,30 +13,20 @@ use std::{fmt, sync::Arc}; /// fetch ethereum storage related data // TODO(mattsee): once we have multiple Backend types, this should be turned into a trait #[derive(Clone)] -pub struct StorageInfo { - backend: Arc, +pub struct StorageInfo { + backend: Arc>, } -impl StorageInfo { - pub(crate) fn new(backend: Arc) -> Self { +impl StorageInfo { + pub(crate) const fn new(backend: Arc>) -> Self { Self { backend } } - /// Returns the receipts of the current block - pub fn current_receipts(&self) -> Option> { - self.backend.mined_receipts(self.backend.best_hash()) - } - /// Returns the current block pub fn current_block(&self) -> Option { self.backend.get_block(self.backend.best_number()) } - /// Returns the receipts of the block with the given hash - pub fn receipts(&self, hash: B256) -> Option> { - self.backend.mined_receipts(hash) - } - /// Returns the block with the given hash pub fn block(&self, hash: B256) -> Option { self.backend.get_block_by_hash(hash) @@ -48,7 +39,22 @@ impl StorageInfo { } } -impl fmt::Debug for StorageInfo { +impl StorageInfo +where + N::ReceiptEnvelope: TxReceipt, +{ + /// Returns the receipts of the current block + pub fn current_receipts(&self) -> Option> { + self.backend.mined_receipts(self.backend.best_hash()) + } + + /// Returns the receipts of the block with the given hash + pub fn receipts(&self, hash: B256) -> Option> { + self.backend.mined_receipts(hash) + } +} + +impl fmt::Debug for StorageInfo { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("StorageInfo").finish_non_exhaustive() } diff --git a/crates/anvil/src/eth/backend/mem/base.rs b/crates/anvil/src/eth/backend/mem/base.rs new file mode 100644 index 0000000000000..a3facdf74e634 --- /dev/null +++ b/crates/anvil/src/eth/backend/mem/base.rs @@ -0,0 +1,254 @@ +//! Base block preparation and transaction execution for the in-memory backend. + +use super::Backend; +use crate::eth::{ + backend::{ + db::{AnvilCacheDB, Db}, + executor::{BlockExecutionKind, ExecutedPoolTransactions}, + time::{PendingBlockTimestamp, TimeManager}, + }, + error::BlockchainError, + pool::transactions::PoolTransaction, +}; +use alloy_eips::{Decodable2718, Encodable2718}; +use alloy_evm::{Database, EthEvmFactory, Evm, EvmEnv, EvmFactory}; +use alloy_network::Network; +use alloy_primitives::{Address, B256, TxKind, U256}; +use anvil_core::eth::transaction::PendingTransaction; +use base_common_chains::ChainConfig; +use base_common_consensus::Predeploys; +use base_common_evm::{ + BaseContext, BaseEvmFactory, BaseHaltReason, BaseSpecId, BaseTime, BaseTransaction, + BaseUpgrade, L1BlockInfo, +}; +use base_common_genesis::RollupConfig; +use base_common_rpc_types::EIP8130_PRE_ZENITH_RPC_ERROR; +use base_consensus_upgrades::Jovian; +use base_protocol::{BaseTimeUpdateTx, L1BlockInfoJovian, L1BlockInfoTx}; +use foundry_evm::{backend::DatabaseError, hardfork::FoundryHardfork}; +use foundry_primitives::FoundryTxEnvelope; +use revm::{ + DatabaseRef, Inspector, + context::{ + TxEnv, + result::{ExecutionResult, HaltReason, Output, ResultAndState}, + }, + database::EmptyDB, + database_interface::WrapDatabaseRef, +}; +use std::sync::Arc; + +impl Backend { + /// Base path of [`Backend::transact_call_with_inspector_ref`]. + pub(super) fn transact_base_with_inspector_ref<'db, I, DB>( + &self, + db: &'db DB, + evm_env: &EvmEnv, + inspector: &mut I, + tx: BaseTransaction, + ) -> Result, BlockchainError> + where + DB: DatabaseRef + ?Sized, + I: Inspector>>, + WrapDatabaseRef<&'db DB>: Database, + { + let upgrade = self.base_upgrade_at_timestamp(evm_env.block_env.timestamp.saturating_to()); + if tx.eip8130.is_some() && upgrade < BaseUpgrade::Zenith { + return Err(BlockchainError::InvalidTransactionRequest( + EIP8130_PRE_ZENITH_RPC_ERROR.to_string(), + )); + } + let base_env = EvmEnv::new( + evm_env.cfg_env.clone().with_spec_and_mainnet_gas_params(BaseSpecId::new(upgrade)), + evm_env.block_env.clone(), + ); + let activation_admin = self.base_activation_admin().or_else(|| { + ChainConfig::activation_admin_address_for_upgrade_by_chain_id( + base_env.cfg_env.chain_id, + upgrade, + ) + }); + let factory = BaseEvmFactory::new(activation_admin); + let mut evm = factory.create_evm_with_inspector(WrapDatabaseRef(db), base_env, inspector); + evm.ctx_mut().cfg.tx_chain_id_check = true; + self.inject_precompiles(evm.precompiles_mut(), evm_env); + let result = Evm::transact_raw(&mut evm, tx)?; + Ok(ResultAndState { + result: result.result.map_haltreason(|halt| match halt { + BaseHaltReason::Base(eth) => eth, + BaseHaltReason::FailedDeposit => HaltReason::PrecompileError, + }), + state: result.state, + }) + } + + /// Rejects a fork whose protocol contracts would reject every Denim block's system deposits. + /// + /// The deposits run through the block executor against the fork state, so startup and resets + /// fail with a clear error instead of producing a node that cannot mine. + pub(super) fn ensure_fork_accepts_system_transactions( + &self, + db: &dyn Db, + parent_env: &EvmEnv, + parent_hash: B256, + hardfork: FoundryHardfork, + ) -> Result<(), DatabaseError> { + let mut evm_env = parent_env.clone(); + evm_env.block_env.number = evm_env.block_env.number.saturating_add(U256::from(1)); + let transactions = system_transactions( + db, + evm_env.block_env.number.saturating_to(), + evm_env.block_env.timestamp.saturating_to(), + parent_hash, + true, + )?; + let mut candidate_db = AnvilCacheDB::new(db, *evm_env.spec_id()); + self.execute_with_block_executor( + &mut candidate_db, + &evm_env, + parent_hash, + hardfork, + Some(B256::ZERO), + BlockExecutionKind::Complete, + &transactions, + &self.pool_tx_gas_config(&evm_env), + &self.inspector_tx_config(), + &|_, _| Ok(()), + ) + .and_then(|(executed, _)| validate_system_transactions(&transactions, &executed)) + .map_err(|err| { + DatabaseError::AnyRequest(Arc::new(eyre::eyre!( + "Denim system deposits fail on this fork; its L1Block likely predates Jovian (fork \ + at or after the Jovian upgrade, or use an earlier --hardfork): {err}" + ))) + }) + } +} + +/// Advances the persisted BaseTime phase, independently of the absolute block number. +fn next_millis_part(db: &dyn DatabaseRef) -> Result { + let parent = BaseTime::fetch_timestamp_millis_part(&mut WrapDatabaseRef(db))?; + if !BaseTimeUpdateTx::is_valid_timestamp_millis_part(parent) { + return Err(DatabaseError::AnyRequest(Arc::new(eyre::eyre!( + "invalid parent BaseTime millisecond component: {parent}" + )))); + } + Ok(parent + BaseTimeUpdateTx::BLOCK_INTERVAL_MILLIS) +} + +/// Prepares the whole-second carry and explicit time controls for a Denim child block. +pub(super) fn prepare_block_timestamp( + db: &dyn DatabaseRef, + time: &TimeManager, + parent_timestamp: u64, +) -> Result { + let carry = u64::from(next_millis_part(db)? >= 1_000); + Ok(time.prepare_next_timestamp_with_increment(carry, parent_timestamp.saturating_add(carry))) +} + +/// Builds the L1-info and BaseTime deposits from the candidate's parent database. +/// +/// Fork continuation retains the L1 origin and fee configuration, advancing only its sequence. +/// Standalone blocks use a deterministic synthetic origin because Anvil does not derive from L1. +pub(super) fn system_transactions( + db: &dyn DatabaseRef, + block_number: u64, + timestamp: u64, + parent_hash: B256, + fork: bool, +) -> Result>>, DatabaseError> { + let mut state = WrapDatabaseRef(db); + let fees = L1BlockInfo::try_fetch( + &mut state, + U256::from(block_number), + BaseSpecId::new(BaseUpgrade::Denim), + )?; + let origin = db.storage_ref(Predeploys::L1_BLOCK_INFO, U256::ZERO)?.to_be_bytes::<32>(); + let sequence_slot = + db.storage_ref(Predeploys::L1_BLOCK_INFO, U256::from(3))?.to_be_bytes::<32>(); + let sequence = u64::from_be_bytes(sequence_slot[24..32].try_into().unwrap()); + let batcher = db.storage_ref(Predeploys::L1_BLOCK_INFO, U256::from(4))?; + let (number, origin_timestamp, origin_hash, sequence) = if fork { + ( + u64::from_be_bytes(origin[24..32].try_into().unwrap()), + u64::from_be_bytes(origin[16..24].try_into().unwrap()), + B256::from(db.storage_ref(Predeploys::L1_BLOCK_INFO, U256::from(2))?), + sequence.checked_add(1).ok_or_else(|| { + DatabaseError::AnyRequest(Arc::new(eyre::eyre!("L1-info sequence number overflow"))) + })?, + ) + } else { + (block_number, timestamp, parent_hash, block_number) + }; + let l1_info = L1BlockInfoTx::Jovian(L1BlockInfoJovian::new( + number, + origin_timestamp, + fees.l1_base_fee.saturating_to(), + origin_hash, + sequence, + Address::from_word(B256::from(batcher)), + fees.l1_blob_base_fee.unwrap_or_default().saturating_to(), + fees.l1_blob_base_fee_scalar.unwrap_or_default().saturating_to(), + fees.l1_base_fee_scalar.saturating_to(), + fees.operator_fee_scalar.unwrap_or_default().saturating_to(), + fees.operator_fee_constant.unwrap_or_default().saturating_to(), + fees.da_footprint_gas_scalar.unwrap_or_default(), + )); + let mut upgrades = RollupConfig::default(); + upgrades.set_upgrade_activation_timestamp(BaseUpgrade::Regolith, 0); + let l1_info = l1_info.into_deposit_tx(&upgrades, timestamp); + let base_time = BaseTimeUpdateTx::new(next_millis_part(db)? % 1_000) + .expect("the parent BaseTime phase was validated") + .into_deposit_tx(block_number); + Ok([l1_info, base_time] + .into_iter() + .map(|deposit| { + let envelope = FoundryTxEnvelope::decode_2718(&mut deposit.encoded_2718().as_slice()) + .expect("Base and OP deposits share the canonical encoding"); + let pending = PendingTransaction::new(envelope).expect("deposit sender is explicit"); + Arc::new(PoolTransaction::new(pending)) + }) + .collect()) +} + +/// Installs the canonical L1Block runtime when a local chain has no deployment. +/// +/// Execute the pinned upgrade's constructor to obtain its runtime instead of carrying a second +/// bytecode artifact. Existing fork deployments and their storage are left intact. +pub(super) fn ensure_l1_block_predeploy(db: &mut dyn Db) -> Result<(), DatabaseError> { + if db.basic(Predeploys::L1_BLOCK_INFO)?.is_some_and(|info| !info.is_empty_code_hash()) { + return Ok(()); + } + let mut evm = EthEvmFactory::default().create_evm(EmptyDB::default(), EvmEnv::default()); + let result = evm + .transact_raw(TxEnv { + kind: TxKind::Create, + gas_limit: 1_000_000, + data: Jovian::l1_block_deployment_bytecode(), + ..Default::default() + }) + .map_err(|err| DatabaseError::AnyRequest(Arc::new(eyre::eyre!(err))))?; + let ExecutionResult::Success { output: Output::Create(code, _), .. } = result.result else { + return Err(DatabaseError::AnyRequest(Arc::new(eyre::eyre!( + "failed to initialize the L1Block runtime" + )))); + }; + db.set_code(Predeploys::L1_BLOCK_INFO, code) +} + +/// Rejects a candidate whose mandatory prefix was skipped or reverted during execution. +pub(super) fn validate_system_transactions( + expected: &[Arc>], + executed: &ExecutedPoolTransactions, +) -> Result<(), BlockchainError> { + for (index, transaction) in expected.iter().enumerate() { + if executed.included.get(index).is_none_or(|included| included.hash() != transaction.hash()) + || executed.tx_info.get(index).is_none_or(|info| !info.exit.is_ok()) + { + return Err(BlockchainError::Internal(format!( + "required Base system deposit at transaction index {index} failed" + ))); + } + } + Ok(()) +} diff --git a/crates/anvil/src/eth/backend/mem/cache.rs b/crates/anvil/src/eth/backend/mem/cache.rs index f14291d2b1485..524ef9cbd74bb 100644 --- a/crates/anvil/src/eth/backend/mem/cache.rs +++ b/crates/anvil/src/eth/backend/mem/cache.rs @@ -55,24 +55,21 @@ impl DiskStateCache { } } - /// Stores the snapshot for the given hash + /// Stores the snapshot for the given hash synchronously. /// - /// Note: this writes the state on a new spawned task - /// - /// Caution: this requires a running tokio Runtime. - pub fn write(&mut self, hash: B256, state: StateSnapshot) { - self.with_cache_file(hash, |file| { - tokio::task::spawn_blocking(move || { - match foundry_common::fs::write_json_file(&file, &state) { - Ok(_) => { - trace!(target: "backend", ?hash, "wrote state json file"); - } - Err(err) => { - error!(target: "backend", %err, ?hash, "Failed to load state snapshot"); - } - }; - }); - }); + /// Returns `true` if the write was successful, `false` otherwise. + pub fn write(&mut self, hash: B256, state: &StateSnapshot) -> bool { + self.with_cache_file(hash, |file| match foundry_common::fs::write_json_file(&file, state) { + Ok(_) => { + trace!(target: "backend", ?hash, "wrote state json file"); + true + } + Err(err) => { + error!(target: "backend", %err, ?hash, "Failed to write state snapshot"); + false + } + }) + .unwrap_or(false) } /// Loads the snapshot file for the given hash diff --git a/crates/anvil/src/eth/backend/mem/fork_db.rs b/crates/anvil/src/eth/backend/mem/fork_db.rs index 107695b6b1544..b1c4c5197a929 100644 --- a/crates/anvil/src/eth/backend/mem/fork_db.rs +++ b/crates/anvil/src/eth/backend/mem/fork_db.rs @@ -1,24 +1,40 @@ use crate::eth::backend::db::{ Db, MaybeForkedDatabase, MaybeFullDatabase, SerializableAccountRecord, SerializableBlock, SerializableHistoricalStates, SerializableState, SerializableTransaction, StateDb, + cache_block_hash, }; -use alloy_primitives::{Address, B256, U256, map::HashMap}; +use alloy_network::Network; +use alloy_primitives::{Address, B256, U256, map::AddressMap}; use alloy_rpc_types::BlockId; use foundry_evm::{ backend::{ - BlockchainDb, DatabaseError, DatabaseResult, RevertStateSnapshotAction, StateSnapshot, + BlockchainDb, DatabaseResult, RevertStateSnapshotAction, SharedBackend, StateSnapshot, }, fork::database::ForkDbStateSnapshot, }; use revm::{ context::BlockEnv, - database::{Database, DatabaseRef, DbAccount}, + database::{Database, DbAccount}, state::AccountInfo, }; pub use foundry_evm::fork::database::ForkedDatabase; -impl Db for ForkedDatabase { +impl MaybeFullDatabase for SharedBackend { + fn clear_into_state_snapshot(&mut self) -> StateSnapshot { + StateSnapshot::default() + } + + fn read_as_state_snapshot(&self) -> StateSnapshot { + StateSnapshot::default() + } + + fn clear(&mut self) {} + + fn init_from_state_snapshot(&mut self, _state_snapshot: StateSnapshot) {} +} + +impl Db for ForkedDatabase { fn insert_account(&mut self, address: Address, account: AccountInfo) { self.database_mut().insert_account(address, account) } @@ -30,7 +46,11 @@ impl Db for ForkedDatabase { } fn insert_block_hash(&mut self, number: U256, hash: B256) { - self.inner().block_hashes().write().insert(number, hash); + cache_block_hash(&mut self.database_mut().cache.block_hashes, number, hash); + } + + fn set_block_hashes(&mut self, block_hashes: Vec<(U256, B256)>) { + self.database_mut().cache.block_hashes = block_hashes.into_iter().collect(); } fn dump_state( @@ -52,7 +72,7 @@ impl Db for ForkedDatabase { let code = if let Some(code) = v.info.code { code } else { - db.code_by_hash(v.info.code_hash)? + db.code_by_hash(v.info.code_hash())? }; Ok(( k, @@ -71,6 +91,10 @@ impl Db for ForkedDatabase { best_block_number: Some(best_number), blocks, transactions, + #[cfg(feature = "monad")] + monad_block_participants: Default::default(), + #[cfg(feature = "monad")] + monad_block_replay_profiles: Default::default(), historical_states, })) } @@ -83,18 +107,26 @@ impl Db for ForkedDatabase { self.revert_state_snapshot(id, action) } + fn delete_state_snapshot(&mut self, id: U256) -> bool { + self.state_snapshots().lock().remove_at(id).is_some() + } + fn current_state(&self) -> StateDb { StateDb::new(self.create_state_snapshot()) } } -impl MaybeFullDatabase for ForkedDatabase { - fn as_dyn(&self) -> &dyn DatabaseRef { - self +impl MaybeFullDatabase for ForkedDatabase { + fn maybe_as_full_db(&self) -> Option<&AddressMap> { + Some(&self.database().cache.accounts) } - fn maybe_as_full_db(&self) -> Option<&HashMap> { - Some(&self.database().cache.accounts) + fn maybe_as_full_db_mut(&mut self) -> Option<&mut AddressMap> { + Some(&mut self.database_mut().cache.accounts) + } + + fn maybe_full_db(&self) -> Option> { + None } fn clear_into_state_snapshot(&mut self) -> StateSnapshot { @@ -127,21 +159,31 @@ impl MaybeFullDatabase for ForkedDatabase { } } -impl MaybeFullDatabase for ForkDbStateSnapshot { - fn as_dyn(&self) -> &dyn DatabaseRef { - self +impl MaybeFullDatabase for ForkDbStateSnapshot { + fn maybe_as_full_db(&self) -> Option<&AddressMap> { + Some(&self.local.cache.accounts) } - fn maybe_as_full_db(&self) -> Option<&HashMap> { - Some(&self.local.cache.accounts) + fn maybe_full_db(&self) -> Option> { + None } fn clear_into_state_snapshot(&mut self) -> StateSnapshot { - std::mem::take(&mut self.state_snapshot) + let mut state_snapshot = std::mem::take(&mut self.state_snapshot); + let local_state_snapshot = self.local.clear_into_state_snapshot(); + state_snapshot.accounts.extend(local_state_snapshot.accounts); + state_snapshot.storage.extend(local_state_snapshot.storage); + state_snapshot.block_hashes.extend(local_state_snapshot.block_hashes); + state_snapshot } fn read_as_state_snapshot(&self) -> StateSnapshot { - self.state_snapshot.clone() + let mut state_snapshot = self.state_snapshot.clone(); + let local_state_snapshot = self.local.read_as_state_snapshot(); + state_snapshot.accounts.extend(local_state_snapshot.accounts); + state_snapshot.storage.extend(local_state_snapshot.storage); + state_snapshot.block_hashes.extend(local_state_snapshot.block_hashes); + state_snapshot } fn clear(&mut self) { @@ -154,9 +196,9 @@ impl MaybeFullDatabase for ForkDbStateSnapshot { } } -impl MaybeForkedDatabase for ForkedDatabase { - fn maybe_reset(&mut self, url: Option, block_number: BlockId) -> Result<(), String> { - self.reset(url, block_number) +impl MaybeForkedDatabase for ForkedDatabase { + fn maybe_reset(&mut self, urls: Vec, block_number: BlockId) -> Result<(), String> { + self.reset(urls, block_number) } fn maybe_flush_cache(&self) -> Result<(), String> { diff --git a/crates/anvil/src/eth/backend/mem/in_memory_db.rs b/crates/anvil/src/eth/backend/mem/in_memory_db.rs index fb6667ac8674a..d398cfc0c2135 100644 --- a/crates/anvil/src/eth/backend/mem/in_memory_db.rs +++ b/crates/anvil/src/eth/backend/mem/in_memory_db.rs @@ -2,24 +2,548 @@ use crate::{ eth::backend::db::{ - Db, MaybeForkedDatabase, MaybeFullDatabase, SerializableAccountRecord, SerializableBlock, - SerializableHistoricalStates, SerializableState, SerializableTransaction, StateDb, + BLOCKHASH_HISTORY, Db, MaybeForkedDatabase, MaybeFullDatabase, SerializableAccountRecord, + SerializableBlock, SerializableHistoricalStates, SerializableState, + SerializableTransaction, StateDb, cache_block_hash, }, - mem::state::state_root, + mem::state::{StateRootCache, state_root}, +}; +use alloy_primitives::{ + Address, B256, U256, + map::{AddressMap, B256Map, HashSet}, }; -use alloy_primitives::{Address, B256, U256, map::HashMap}; use alloy_rpc_types::BlockId; -use foundry_evm::backend::{BlockchainDb, DatabaseResult, StateSnapshot}; +use foundry_evm::backend::{BlockchainDb, DatabaseError, DatabaseResult, StateSnapshot}; +use imbl::HashMap as PersistentMap; +use parking_lot::Mutex; use revm::{ + Database, DatabaseCommit, + bytecode::Bytecode, context::BlockEnv, - database::{DatabaseRef, DbAccount}, - state::AccountInfo, + database::{AccountState, DatabaseRef, DbAccount}, + state::{Account, AccountInfo}, }; +use std::sync::OnceLock; // reexport for convenience pub use foundry_evm::backend::MemDb; use foundry_evm::backend::RevertStateSnapshotAction; +/// An in-memory database that incrementally maintains Anvil's mined-block state root. +#[derive(Debug, Default)] +pub struct StateRootDb { + inner: MemDb, + state_root: Mutex, + history: Mutex, + /// Live cache head used to make sequential block-hash insertion constant-time. + block_hash_head: Option, +} + +impl StateRootDb { + /// Creates a new database, optionally tracking the state used by block-history snapshots. + /// + /// Anvil disables history tracking when state history is pruned, since historical snapshots + /// are never taken in that mode and the recorded dirty sets would accumulate without ever + /// being drained. With tracking disabled, [`Db::current_state`] still returns a correct, + /// freshly built snapshot. + pub fn new(track_history: bool) -> Self { + Self { + history: Mutex::new(HistoricalStateCache { + disabled: !track_history, + ..Default::default() + }), + ..Default::default() + } + } + + fn normalize_block_hashes(&mut self) { + let block_hashes = &mut self.inner.inner.cache.block_hashes; + let Some(head) = block_hashes.keys().copied().max() else { + self.block_hash_head = None; + return; + }; + let min_number = head.saturating_sub(U256::from(BLOCKHASH_HISTORY)); + block_hashes.retain(|cached, _| *cached >= min_number && *cached <= head); + self.block_hash_head = Some(head); + } +} + +/// Incrementally maintained, structurally shared state used by block-history snapshots. +#[derive(Debug, Default)] +struct HistoricalStateCache { + state: Option, + dirty: AddressMap, + /// Disables recording and snapshot caching; set when historical snapshots are never taken. + disabled: bool, +} + +#[derive(Debug, Default)] +struct DirtyHistoricalAccount { + storage: HashSet, + reset_storage: bool, +} + +impl HistoricalStateCache { + fn record_changes(&mut self, changes: &AddressMap) { + if self.disabled { + return; + } + for (address, account) in changes { + if !account.is_touched() { + continue; + } + + let dirty = self.dirty.entry(*address).or_default(); + dirty.reset_storage |= account.is_created() || account.is_selfdestructed(); + dirty.storage.extend(account.changed_storage_slots().map(|(slot, _)| *slot)); + } + } + + fn record_account(&mut self, address: Address) { + if self.disabled { + return; + } + self.dirty.entry(address).or_default(); + } + + fn record_storage(&mut self, address: Address, slot: U256) { + if self.disabled { + return; + } + self.dirty.entry(address).or_default().storage.insert(slot); + } + + fn record_block_hash(&mut self, number: U256, hash: B256, is_next: bool) { + if self.disabled { + return; + } + let Some(state) = &mut self.state else { return }; + if is_next { + let min_number = number.saturating_sub(U256::from(BLOCKHASH_HISTORY)); + if min_number > U256::ZERO { + state.block_hashes.remove(&(min_number - U256::ONE)); + } + state.block_hashes.insert(number, hash); + return; + } + + let head = state.block_hashes.keys().copied().max().map_or(number, |head| head.max(number)); + let min_number = head.saturating_sub(U256::from(BLOCKHASH_HISTORY)); + state.block_hashes.retain(|cached, _| *cached >= min_number && *cached <= head); + if number >= min_number { + state.block_hashes.insert(number, hash); + } + } + + fn invalidate(&mut self) { + self.state = None; + self.dirty.clear(); + } + + fn snapshot(&mut self, db: &MemDb) -> PersistentStateDb { + if self.disabled { + return PersistentStateDb::from_mem_db(db); + } + let Some(state) = &mut self.state else { + let state = PersistentStateDb::from_mem_db(db); + self.state = Some(state.clone()); + self.dirty.clear(); + return state; + }; + + for (address, dirty) in std::mem::take(&mut self.dirty) { + let Some(account) = db.inner.cache.accounts.get(&address) else { + state.accounts.remove(&address); + continue; + }; + + let mut storage = if dirty.reset_storage { + account.storage.iter().map(|(slot, value)| (*slot, *value)).collect() + } else { + state + .accounts + .get(&address) + .map(|account| account.storage.clone()) + .unwrap_or_default() + }; + if !dirty.reset_storage { + for slot in dirty.storage { + if let Some(value) = account.storage.get(&slot) { + storage.insert(slot, *value); + } else { + storage.remove(&slot); + } + } + } + + let info = account_info_with_code(&account.info, &db.inner.cache.contracts); + if let Some(code) = &info.code { + state.contracts.insert(info.code_hash(), code.clone()); + } + state.accounts.insert( + address, + PersistentAccount { info, account_state: account.account_state.clone(), storage }, + ); + } + + state.full = OnceLock::new(); + state.clone() + } +} + +#[derive(Clone, Debug, Default)] +struct PersistentAccount { + info: AccountInfo, + account_state: AccountState, + storage: PersistentMap, +} + +/// A read-only historical state whose maps are cheap structural-sharing clones. +#[derive(Clone, Debug, Default)] +struct PersistentStateDb { + accounts: PersistentMap, + contracts: PersistentMap, + block_hashes: PersistentMap, + #[allow(clippy::type_complexity)] + full: OnceLock>, +} + +impl PersistentStateDb { + fn from_mem_db(db: &MemDb) -> Self { + let contracts = db + .inner + .cache + .contracts + .iter() + .map(|(hash, code)| (*hash, code.clone())) + .collect::>(); + let accounts = db + .inner + .cache + .accounts + .iter() + .map(|(address, account)| { + ( + *address, + PersistentAccount { + info: account_info_with_code(&account.info, &db.inner.cache.contracts), + account_state: account.account_state.clone(), + storage: account + .storage + .iter() + .map(|(slot, value)| (*slot, *value)) + .collect(), + }, + ) + }) + .collect(); + let block_hashes = + db.inner.cache.block_hashes.iter().map(|(number, hash)| (*number, *hash)).collect(); + Self { accounts, contracts, block_hashes, full: OnceLock::new() } + } + + fn state_snapshot(&self) -> StateSnapshot { + StateSnapshot { + accounts: self + .accounts + .iter() + .filter(|(_, account)| account.account_state != AccountState::NotExisting) + .map(|(address, account)| (*address, account.info.clone())) + .collect(), + storage: self + .accounts + .iter() + .filter(|(_, account)| account.account_state != AccountState::NotExisting) + .map(|(address, account)| { + ( + *address, + account.storage.iter().map(|(slot, value)| (*slot, *value)).collect(), + ) + }) + .collect(), + block_hashes: self.block_hashes.iter().map(|(number, hash)| (*number, *hash)).collect(), + } + } + + fn full_db(&self) -> AddressMap { + self.accounts + .iter() + .filter(|(_, account)| account.account_state != AccountState::NotExisting) + .map(|(address, account)| { + ( + *address, + DbAccount { + info: account.info.clone(), + account_state: account.account_state.clone(), + storage: account + .storage + .iter() + .map(|(slot, value)| (*slot, *value)) + .collect(), + }, + ) + }) + .collect() + } +} + +fn account_info_with_code(info: &AccountInfo, contracts: &B256Map) -> AccountInfo { + let mut info = info.clone(); + if info.code.is_none() { + info.code = contracts.get(&info.code_hash()).cloned(); + } + info +} + +impl DatabaseRef for PersistentStateDb { + type Error = DatabaseError; + + fn basic_ref(&self, address: Address) -> DatabaseResult> { + Ok(match self.accounts.get(&address) { + Some(account) if account.account_state == AccountState::NotExisting => None, + Some(account) => Some(account.info.clone()), + None => Some(AccountInfo::default()), + }) + } + + fn code_by_hash_ref(&self, code_hash: B256) -> DatabaseResult { + Ok(self.contracts.get(&code_hash).cloned().unwrap_or_default()) + } + + fn storage_ref(&self, address: Address, index: U256) -> DatabaseResult { + Ok(self + .accounts + .get(&address) + .and_then(|account| account.storage.get(&index).copied()) + .unwrap_or_default()) + } + + fn block_hash_ref(&self, number: u64) -> DatabaseResult { + Ok(self.block_hashes.get(&U256::from(number)).copied().unwrap_or_default()) + } +} + +impl MaybeFullDatabase for PersistentStateDb { + fn maybe_as_full_db(&self) -> Option<&AddressMap> { + Some(self.full.get_or_init(|| self.full_db())) + } + + fn is_persistent(&self) -> bool { + true + } + + fn clear_into_state_snapshot(&mut self) -> StateSnapshot { + let snapshot = self.state_snapshot(); + self.clear(); + snapshot + } + + fn read_as_state_snapshot(&self) -> StateSnapshot { + self.state_snapshot() + } + + fn clear(&mut self) { + *self = Self::default(); + } + + fn init_from_state_snapshot(&mut self, snapshot: StateSnapshot) { + let StateSnapshot { accounts, mut storage, block_hashes } = snapshot; + let mut contracts = PersistentMap::new(); + let accounts = accounts + .into_iter() + .map(|(address, info)| { + if let Some(code) = &info.code { + contracts.insert(info.code_hash(), code.clone()); + } + let storage = storage + .remove(&address) + .unwrap_or_default() + .into_iter() + .collect::>(); + (address, PersistentAccount { info, account_state: AccountState::None, storage }) + }) + .collect(); + let block_hashes = block_hashes.into_iter().collect(); + *self = Self { accounts, contracts, block_hashes, full: OnceLock::new() }; + } +} + +impl DatabaseRef for StateRootDb { + type Error = DatabaseError; + + fn basic_ref(&self, address: Address) -> Result, Self::Error> { + self.inner.basic_ref(address) + } + + fn code_by_hash_ref(&self, code_hash: B256) -> Result { + self.inner.code_by_hash_ref(code_hash) + } + + fn storage_ref(&self, address: Address, index: U256) -> Result { + self.inner.storage_ref(address, index) + } + + fn block_hash_ref(&self, number: u64) -> Result { + self.inner.block_hash_ref(number) + } +} + +impl Database for StateRootDb { + type Error = DatabaseError; + + fn basic(&mut self, address: Address) -> Result, Self::Error> { + self.state_root.get_mut().record_account(address); + self.history.get_mut().record_account(address); + self.inner.basic(address) + } + + fn code_by_hash(&mut self, code_hash: B256) -> Result { + self.inner.code_by_hash(code_hash) + } + + fn storage(&mut self, address: Address, index: U256) -> Result { + self.state_root.get_mut().record_storage(address, index); + self.history.get_mut().record_storage(address, index); + self.inner.storage(address, index) + } + + fn block_hash(&mut self, number: u64) -> Result { + self.inner.block_hash(number) + } +} + +impl DatabaseCommit for StateRootDb { + fn commit(&mut self, changes: revm::state::EvmState) { + self.state_root.get_mut().record_changes(&changes); + self.history.get_mut().record_changes(&changes); + self.inner.commit(changes); + } +} + +impl Db for StateRootDb { + fn insert_account(&mut self, address: Address, account: AccountInfo) { + self.state_root.get_mut().record_account(address); + self.history.get_mut().record_account(address); + Db::insert_account(&mut self.inner, address, account); + } + + fn set_storage_at(&mut self, address: Address, slot: B256, val: B256) -> DatabaseResult<()> { + let storage_slot = slot.into(); + self.state_root.get_mut().record_storage(address, storage_slot); + self.history.get_mut().record_storage(address, storage_slot); + Db::set_storage_at(&mut self.inner, address, slot, val) + } + + fn insert_block_hash(&mut self, number: U256, hash: B256) { + let is_next = + self.block_hash_head.is_some_and(|head| number == head.saturating_add(U256::ONE)); + if is_next { + let min_number = number.saturating_sub(U256::from(BLOCKHASH_HISTORY)); + if min_number > U256::ZERO { + self.inner.inner.cache.block_hashes.remove(&(min_number - U256::ONE)); + } + self.inner.inner.cache.block_hashes.insert(number, hash); + self.block_hash_head = Some(number); + } else { + self.block_hash_head = + Some(cache_block_hash(&mut self.inner.inner.cache.block_hashes, number, hash)); + } + self.history.get_mut().record_block_hash(number, hash, is_next); + } + + fn set_block_hashes(&mut self, block_hashes: Vec<(U256, B256)>) { + Db::set_block_hashes(&mut self.inner, block_hashes); + self.normalize_block_hashes(); + self.history.get_mut().invalidate(); + } + + fn dump_state( + &self, + at: BlockEnv, + best_number: u64, + blocks: Vec, + transactions: Vec, + historical_states: Option, + ) -> DatabaseResult> { + Db::dump_state(&self.inner, at, best_number, blocks, transactions, historical_states) + } + + fn snapshot_state(&mut self) -> U256 { + Db::snapshot_state(&mut self.inner) + } + + fn revert_state(&mut self, id: U256, action: RevertStateSnapshotAction) -> bool { + let reverted = Db::revert_state(&mut self.inner, id, action); + if reverted { + self.state_root.get_mut().invalidate(); + self.history.get_mut().invalidate(); + self.block_hash_head = self.inner.inner.cache.block_hashes.keys().copied().max(); + } + reverted + } + + fn delete_state_snapshot(&mut self, id: U256) -> bool { + Db::delete_state_snapshot(&mut self.inner, id) + } + + fn maybe_state_root(&self) -> Option { + Some(self.state_root.lock().root(&self.inner.inner.cache.accounts)) + } + + fn current_state(&self) -> StateDb { + StateDb::new(self.history.lock().snapshot(&self.inner)) + } +} + +impl MaybeFullDatabase for StateRootDb { + fn maybe_as_full_db(&self) -> Option<&AddressMap> { + MaybeFullDatabase::maybe_as_full_db(&self.inner) + } + + fn maybe_full_db(&self) -> Option> { + MaybeFullDatabase::maybe_full_db(&self.inner) + } + + fn clear_into_state_snapshot(&mut self) -> StateSnapshot { + self.state_root.get_mut().invalidate(); + self.history.get_mut().invalidate(); + self.block_hash_head = None; + MaybeFullDatabase::clear_into_state_snapshot(&mut self.inner) + } + + fn read_as_state_snapshot(&self) -> StateSnapshot { + MaybeFullDatabase::read_as_state_snapshot(&self.inner) + } + + fn clear(&mut self) { + self.state_root.get_mut().invalidate(); + self.history.get_mut().invalidate(); + self.block_hash_head = None; + MaybeFullDatabase::clear(&mut self.inner) + } + + fn init_from_state_snapshot(&mut self, snapshot: StateSnapshot) { + MaybeFullDatabase::init_from_state_snapshot(&mut self.inner, snapshot); + self.state_root.get_mut().invalidate(); + self.history.get_mut().invalidate(); + self.normalize_block_hashes(); + } +} + +impl MaybeForkedDatabase for StateRootDb { + fn maybe_reset(&mut self, urls: Vec, block_number: BlockId) -> Result<(), String> { + self.inner.maybe_reset(urls, block_number) + } + + fn maybe_flush_cache(&self) -> Result<(), String> { + self.inner.maybe_flush_cache() + } + + fn maybe_inner(&self) -> Result<&BlockchainDb, String> { + self.inner.maybe_inner() + } +} + impl Db for MemDb { fn insert_account(&mut self, address: Address, account: AccountInfo) { self.inner.insert_account_info(address, account) @@ -30,7 +554,11 @@ impl Db for MemDb { } fn insert_block_hash(&mut self, number: U256, hash: B256) { - self.inner.cache.block_hashes.insert(number, hash); + cache_block_hash(&mut self.inner.cache.block_hashes, number, hash); + } + + fn set_block_hashes(&mut self, block_hashes: Vec<(U256, B256)>) { + self.inner.cache.block_hashes = block_hashes.into_iter().collect(); } fn dump_state( @@ -51,7 +579,7 @@ impl Db for MemDb { let code = if let Some(code) = v.info.code { code } else { - self.inner.code_by_hash_ref(v.info.code_hash)? + self.inner.code_by_hash_ref(v.info.code_hash())? }; Ok(( k, @@ -71,6 +599,10 @@ impl Db for MemDb { best_block_number: Some(best_number), blocks, transactions, + #[cfg(feature = "monad")] + monad_block_participants: Default::default(), + #[cfg(feature = "monad")] + monad_block_replay_profiles: Default::default(), historical_states, })) } @@ -96,6 +628,10 @@ impl Db for MemDb { } } + fn delete_state_snapshot(&mut self, id: U256) -> bool { + self.state_snapshots.remove_at(id).is_some() + } + fn maybe_state_root(&self) -> Option { Some(state_root(&self.inner.cache.accounts)) } @@ -106,11 +642,7 @@ impl Db for MemDb { } impl MaybeFullDatabase for MemDb { - fn as_dyn(&self) -> &dyn DatabaseRef { - self - } - - fn maybe_as_full_db(&self) -> Option<&HashMap> { + fn maybe_as_full_db(&self) -> Option<&AddressMap> { Some(&self.inner.cache.accounts) } @@ -132,7 +664,7 @@ impl MaybeFullDatabase for MemDb { } impl MaybeForkedDatabase for MemDb { - fn maybe_reset(&mut self, _url: Option, _block_number: BlockId) -> Result<(), String> { + fn maybe_reset(&mut self, _urls: Vec, _block_number: BlockId) -> Result<(), String> { Err("not supported".to_string()) } @@ -149,7 +681,7 @@ impl MaybeForkedDatabase for MemDb { mod tests { use super::*; use alloy_primitives::{Bytes, address}; - use revm::{bytecode::Bytecode, primitives::KECCAK_EMPTY}; + use revm::primitives::KECCAK_EMPTY; use std::collections::BTreeMap; // verifies that all substantial aspects of a loaded account remain the same after an account @@ -168,11 +700,10 @@ mod tests { code_hash: KECCAK_EMPTY, code: Some(contract_code.clone()), nonce: 1234, + account_id: None, }, ); - dump_db - .set_storage_at(test_addr, U256::from(1234567).into(), U256::from(1).into()) - .unwrap(); + dump_db.set_storage_at(test_addr, U256::from(1234567).into(), U256::ONE.into()).unwrap(); // blocks dumping/loading tested in storage.rs let state = dump_db @@ -187,9 +718,9 @@ mod tests { let loaded_account = load_db.basic_ref(test_addr).unwrap().unwrap(); assert_eq!(loaded_account.balance, U256::from(123456)); - assert_eq!(load_db.code_by_hash_ref(loaded_account.code_hash).unwrap(), contract_code); + assert_eq!(load_db.code_by_hash_ref(loaded_account.code_hash()).unwrap(), contract_code); assert_eq!(loaded_account.nonce, 1234); - assert_eq!(load_db.storage_ref(test_addr, U256::from(1234567)).unwrap(), U256::from(1)); + assert_eq!(load_db.storage_ref(test_addr, U256::from(1234567)).unwrap(), U256::ONE); } // verifies that multiple accounts can be loaded at a time, and storage is merged within those @@ -210,10 +741,11 @@ mod tests { code_hash: KECCAK_EMPTY, code: Some(contract_code.clone()), nonce: 1234, + account_id: None, }, ); - db.set_storage_at(test_addr, U256::from(1234567).into(), U256::from(1).into()).unwrap(); + db.set_storage_at(test_addr, U256::from(1234567).into(), U256::ONE.into()).unwrap(); db.set_storage_at(test_addr, U256::from(1234568).into(), U256::from(2).into()).unwrap(); let mut new_state = SerializableState::default(); @@ -235,7 +767,7 @@ mod tests { test_addr, SerializableAccountRecord { balance: U256::from(100100), - code: contract_code.bytes()[..contract_code.len()].to_vec().into(), + code: contract_code.original_bytes(), nonce: 100, storage: new_storage, }, @@ -249,9 +781,198 @@ mod tests { assert_eq!(loaded_account2.nonce, 1); assert_eq!(loaded_account.balance, U256::from(100100)); - assert_eq!(db.code_by_hash_ref(loaded_account.code_hash).unwrap(), contract_code); + assert_eq!(db.code_by_hash_ref(loaded_account.code_hash()).unwrap(), contract_code); assert_eq!(loaded_account.nonce, 1234); - assert_eq!(db.storage_ref(test_addr, U256::from(1234567)).unwrap(), U256::from(1)); + assert_eq!(db.storage_ref(test_addr, U256::from(1234567)).unwrap(), U256::ONE); assert_eq!(db.storage_ref(test_addr, U256::from(1234568)).unwrap(), U256::from(5)); } + + #[test] + fn incremental_state_root_matches_full_rebuild() { + let address = address!("0000000000000000000000000000000000002935"); + let deleted = Address::with_last_byte(1); + let mut db = StateRootDb::default(); + db.insert_account(address, AccountInfo::default()); + db.insert_account(deleted, AccountInfo::from_balance(U256::ONE)); + + assert_eq!(db.maybe_state_root(), Some(state_root(&db.inner.inner.cache.accounts))); + + // Model the EIP-2935 history contract filling one new ring-buffer slot per block. + for slot in 0..1_024 { + db.set_storage_at(address, U256::from(slot).into(), B256::from(U256::from(slot + 1))) + .unwrap(); + let _ = db.maybe_state_root().unwrap(); + } + + db.set_balance(address, U256::from(42)).unwrap(); + db.set_storage_at(address, U256::from(7).into(), B256::ZERO).unwrap(); + db.set_storage_at(address, U256::from(8).into(), B256::from(U256::from(2_048))).unwrap(); + db.inner.inner.cache.accounts.get_mut(&deleted).unwrap().account_state = + AccountState::NotExisting; + db.state_root.get_mut().record_account(deleted); + assert_eq!(db.maybe_state_root(), Some(state_root(&db.inner.inner.cache.accounts))); + + let snapshot = db.snapshot_state(); + db.set_balance(address, U256::from(43)).unwrap(); + assert!(db.revert_state(snapshot, RevertStateSnapshotAction::RevertRemove)); + assert_eq!(db.maybe_state_root(), Some(state_root(&db.inner.inner.cache.accounts))); + } + + #[test] + fn evm_block_hash_cache_is_bounded() { + let mut db = StateRootDb::default(); + for number in 0..1_024 { + db.insert_block_hash(U256::from(number), B256::from(U256::from(number))); + } + + let block_hashes = &db.inner.inner.cache.block_hashes; + assert_eq!(block_hashes.len(), BLOCKHASH_HISTORY as usize + 1); + assert!(!block_hashes.contains_key(&U256::from(766))); + assert!(block_hashes.contains_key(&U256::from(767))); + assert!(block_hashes.contains_key(&U256::from(768))); + assert!(block_hashes.contains_key(&U256::from(1_023))); + + let snapshot = db.snapshot_state(); + db.insert_block_hash(U256::from(1_024), B256::from(U256::from(1_024))); + assert!(db.revert_state(snapshot, RevertStateSnapshotAction::RevertRemove)); + db.insert_block_hash(U256::from(1_024), B256::from(U256::from(1_024))); + + let block_hashes = &db.inner.inner.cache.block_hashes; + assert_eq!(block_hashes.len(), BLOCKHASH_HISTORY as usize + 1); + assert!(!block_hashes.contains_key(&U256::from(767))); + assert!(block_hashes.contains_key(&U256::from(768))); + assert!(block_hashes.contains_key(&U256::from(1_024))); + } + + #[test] + fn oversized_seeded_block_hash_caches_are_normalized() { + let block_hashes = (0..=1_000) + .map(|number| (U256::from(number), B256::from(U256::from(number)))) + .collect::>(); + + let mut db = StateRootDb::default(); + db.set_block_hashes(block_hashes.clone()); + assert_block_hash_window(&db, 744, 1_000); + db.insert_block_hash(U256::from(1_001), B256::from(U256::from(1_001))); + assert_block_hash_window(&db, 745, 1_001); + + let mut snapshot_source = MemDb::default(); + snapshot_source.set_block_hashes(block_hashes); + let snapshot = snapshot_source.read_as_state_snapshot(); + let mut restored = StateRootDb::default(); + restored.init_from_state_snapshot(snapshot); + assert_block_hash_window(&restored, 744, 1_000); + restored.insert_block_hash(U256::from(1_001), B256::from(U256::from(1_001))); + assert_block_hash_window(&restored, 745, 1_001); + } + + fn assert_block_hash_window(db: &StateRootDb, min: u64, head: u64) { + let block_hashes = &db.inner.inner.cache.block_hashes; + assert_eq!(block_hashes.len(), BLOCKHASH_HISTORY as usize + 1); + assert!( + block_hashes + .keys() + .all(|number| *number >= U256::from(min) && *number <= U256::from(head)) + ); + assert!(block_hashes.contains_key(&U256::from(min))); + assert!(block_hashes.contains_key(&U256::from(head))); + } + + #[test] + fn evm_block_hash_cache_is_bounded_across_block_number_jumps() { + let mut db = StateRootDb::default(); + // Initialize the persistent historical-state cache as well as the live EVM cache. + db.current_state(); + + for number in [0, 516, 400] { + db.insert_block_hash(U256::from(number), B256::from(U256::from(number))); + } + + // An out-of-order insertion within the active window must not discard the current head. + let block_hashes = &db.inner.inner.cache.block_hashes; + assert_eq!(block_hashes.len(), 2); + assert!(block_hashes.contains_key(&U256::from(400))); + assert!(block_hashes.contains_key(&U256::from(516))); + + db.insert_block_hash(U256::from(774), B256::from(U256::from(774))); + + let block_hashes = &db.inner.inner.cache.block_hashes; + assert_eq!(block_hashes.len(), 1); + assert!(block_hashes.contains_key(&U256::from(774))); + + let historical = db.history.get_mut().state.as_ref().unwrap(); + assert_eq!(historical.block_hashes.len(), 1); + assert!(historical.block_hashes.contains_key(&U256::from(774))); + } + + #[test] + fn historical_states_are_persistent_and_isolated() { + let address = address!("0000000000000000000000000000000000002935"); + let slot = U256::ONE; + let mut db = StateRootDb::default(); + db.insert_account(address, AccountInfo::from_balance(U256::ONE)); + + let first = db.current_state(); + assert!(first.is_persistent()); + + db.set_balance(address, U256::from(2)).unwrap(); + db.set_storage_at(address, slot.into(), B256::with_last_byte(3)).unwrap(); + let second = db.current_state(); + + assert_eq!(first.basic_ref(address).unwrap().unwrap().balance, U256::ONE); + assert_eq!(first.storage_ref(address, slot).unwrap(), U256::ZERO); + assert_eq!(second.basic_ref(address).unwrap().unwrap().balance, U256::from(2)); + assert_eq!(second.storage_ref(address, slot).unwrap(), U256::from(3)); + } + + #[test] + fn historical_missing_accounts_match_live_state() { + let missing = Address::with_last_byte(1); + let deleted = Address::with_last_byte(2); + let mut db = StateRootDb::default(); + let historical = db.current_state(); + + let live_account = db.basic_ref(missing).unwrap(); + assert_eq!(live_account, Some(AccountInfo::default())); + assert_eq!(historical.basic_ref(missing).unwrap(), live_account); + + db.insert_account(deleted, AccountInfo::from_balance(U256::ONE)); + db.inner.inner.cache.accounts.get_mut(&deleted).unwrap().account_state = + AccountState::NotExisting; + db.history.get_mut().record_account(deleted); + let historical = db.current_state(); + assert_eq!(historical.basic_ref(deleted).unwrap(), None); + assert!(!historical.maybe_as_full_db().unwrap().contains_key(&deleted)); + assert!(!historical.read_as_state_snapshot().accounts.contains_key(&deleted)); + + let mut fresh = StateRootDb::default(); + fresh.insert_account(deleted, AccountInfo::from_balance(U256::ONE)); + fresh.inner.inner.cache.accounts.get_mut(&deleted).unwrap().account_state = + AccountState::NotExisting; + let historical = fresh.current_state(); + assert_eq!(historical.basic_ref(deleted).unwrap(), None); + assert!(!historical.maybe_as_full_db().unwrap().contains_key(&deleted)); + } + + #[test] + fn disabled_history_tracking_records_nothing() { + let address = address!("0000000000000000000000000000000000002935"); + let slot = U256::ONE; + let mut db = StateRootDb::new(false); + + db.insert_account(address, AccountInfo::from_balance(U256::ONE)); + db.set_storage_at(address, slot.into(), B256::with_last_byte(2)).unwrap(); + db.basic(address).unwrap(); + db.storage(address, slot).unwrap(); + db.maybe_state_root().unwrap(); + + assert!(db.history.get_mut().dirty.is_empty()); + assert!(db.history.get_mut().state.is_none()); + + // `current_state` must still produce a correct snapshot without caching it. + let historical = db.current_state(); + assert_eq!(historical.basic_ref(address).unwrap().unwrap().balance, U256::ONE); + assert_eq!(historical.storage_ref(address, slot).unwrap(), U256::from(2)); + assert!(db.history.get_mut().state.is_none()); + } } diff --git a/crates/anvil/src/eth/backend/mem/inspector.rs b/crates/anvil/src/eth/backend/mem/inspector.rs index 6e5964fc3f94a..dd9e239764ef7 100644 --- a/crates/anvil/src/eth/backend/mem/inspector.rs +++ b/crates/anvil/src/eth/backend/mem/inspector.rs @@ -1,24 +1,29 @@ //! Anvil specific [`revm::Inspector`] implementation use crate::eth::macros::node_info; -use alloy_primitives::{Address, Log, U256}; +use alloy_evm::precompiles::PrecompilesMap; +use alloy_primitives::{Address, B256, Log, LogData, U256}; +use alloy_sol_types::SolValue; use foundry_evm::{ call_inspectors, decode::decode_console_logs, inspectors::{LogCollector, TracingInspector}, traces::{ - CallTraceDecoder, SparsedTraceArena, TracingInspectorConfig, render_trace_arena_inner, + CallTraceDecoder, CallTraceNode, SparsedTraceArena, TracingInspectorConfig, + render_trace_arena_inner, }, }; use revm::{ Inspector, - context::ContextTr, + context::{ContextTr, JournalTr}, inspector::JournalExt, interpreter::{ - CallInputs, CallOutcome, CreateInputs, CreateOutcome, Interpreter, + CallInputs, CallOutcome, CreateInputs, CreateOutcome, CreateScheme, Interpreter, interpreter::EthInterpreter, }, }; +use revm_inspectors::transfer::{TRANSFER_EVENT_TOPIC, TRANSFER_LOG_EMITTER, TransferInspector}; +use std::sync::Arc; /// The [`revm::Inspector`] used when transacting in the evm #[derive(Clone, Debug, Default)] @@ -27,30 +32,170 @@ pub struct AnvilInspector { pub tracer: Option, /// Collects all `console.sol` logs pub log_collector: Option, + /// Collects all internal ETH transfers as ERC20 transfer events. + pub transfer: Option, + /// Collects canonical and synthetic transfer logs for an `eth_simulateV1` response. + simulation_logs: Option, +} + +#[derive(Clone, Debug)] +struct SimulationLog { + log: Log, + index: u64, + canonical: bool, +} + +/// Collects simulation response logs without inserting synthetic logs into EVM state. +#[derive(Clone, Debug, Default)] +struct SimulationLogCollector { + logs: Vec, + checkpoints: Vec, + next_index: u64, + trace_transfers: bool, + journal_log_count: usize, +} + +impl SimulationLogCollector { + fn push_log(&mut self, log: Log, canonical: bool) { + self.logs.push(SimulationLog { log, index: self.next_index, canonical }); + self.next_index += 1; + } + + fn push_canonical_log(&mut self, log: Log, journal_log_count: usize) { + self.push_log(log, true); + self.journal_log_count = journal_log_count; + } + + fn sync_journal_logs(&mut self, logs: &[Log]) { + self.journal_log_count = self.journal_log_count.min(logs.len()); + for log in &logs[self.journal_log_count..] { + self.push_log(log.clone(), true); + } + self.journal_log_count = logs.len(); + } + + fn push_transfer(&mut self, from: Address, to: Address, value: U256) { + if !self.trace_transfers || value.is_zero() { + return; + } + self.push_log( + Log { + address: TRANSFER_LOG_EMITTER, + data: LogData::new_unchecked( + vec![ + TRANSFER_EVENT_TOPIC, + B256::from_slice(&from.abi_encode()), + B256::from_slice(&to.abi_encode()), + ], + value.abi_encode().into(), + ), + }, + false, + ); + } + + fn frame_start(&mut self) { + self.checkpoints.push(self.logs.len()); + } + + fn frame_end(&mut self, success: bool, journal_log_count: usize) { + let checkpoint = self.checkpoints.pop().expect("execution frame checkpoint exists"); + if !success { + self.logs.truncate(checkpoint); + } + self.journal_log_count = journal_log_count; + } + + fn append_remaining_canonical_logs(&mut self, canonical_logs: &[Log]) { + let mut canonical_logs = canonical_logs.iter(); + for collected in self.logs.iter().filter(|log| log.canonical) { + let canonical = + canonical_logs.next().expect("collected canonical log exists in result"); + assert_eq!(&collected.log, canonical, "collected canonical logs preserve ordering"); + } + for log in canonical_logs { + self.push_log(log.clone(), true); + } + } +} + +/// Configuration for per-transaction inspector lifecycle. +#[derive(Clone, Debug)] +pub struct InspectorTxConfig { + /// Whether to print traces to stdout. + pub print_traces: bool, + /// Whether to print logs to stdout. + pub print_logs: bool, + /// Decoder for populating trace labels. + pub call_trace_decoder: Arc, } impl AnvilInspector { + /// Finish a transaction: print traces/logs, drain the tracer, and reset for the next tx. + /// + /// Returns the collected call trace nodes from the finished transaction. + pub fn finish_transaction( + &mut self, + config: &InspectorTxConfig, + precompiles: &PrecompilesMap, + ) -> Vec { + // Print before draining so the tracer is still populated. + if config.print_traces { + self.print_traces(config.call_trace_decoder.clone()); + } + self.print_logs(); + + let mut traces = + self.tracer.take().map(|t| t.into_traces().into_nodes()).unwrap_or_default(); + // Record inclusion using the executing EVM's map, while retaining the full Geth graph. + for node in &mut traces { + if node.parent.is_some() && !node.trace.kind.is_any_create() { + node.trace.maybe_precompile = Some( + node.trace.value.is_zero() && precompiles.get(&node.trace.address).is_some(), + ); + } + } + + self.reset_transaction(config); + + traces + } + + /// Discards a transaction's traces/logs and resets the inspector without printing them. + pub fn discard_transaction(&mut self, config: &InspectorTxConfig) { + self.reset_transaction(config); + } + + /// Resets per-transaction collectors for the next transaction. + fn reset_transaction(&mut self, config: &InspectorTxConfig) { + // Reinstall tracer for next tx. + self.tracer = Some(TracingInspector::new(TracingInspectorConfig::all().set_steps(false))); + + // Reset log collector for next tx. + self.log_collector = config.print_logs.then(|| LogCollector::Capture { logs: Vec::new() }); + } + /// Called after the inspecting the evm /// /// This will log all `console.sol` logs pub fn print_logs(&self) { - if let Some(collector) = &self.log_collector { - print_logs(&collector.logs); + if let Some(LogCollector::Capture { logs }) = &self.log_collector { + print_logs(logs); } } /// Consumes the type and prints the traces. - pub fn into_print_traces(mut self) { + pub fn into_print_traces(mut self, decoder: Arc) { if let Some(a) = self.tracer.take() { - print_traces(a) + print_traces(a, decoder); } } /// Called after the inspecting the evm /// This will log all traces - pub fn print_traces(&self) { + pub fn print_traces(&self, decoder: Arc) { if let Some(a) = self.tracer.clone() { - print_traces(a) + print_traces(a, decoder); } } @@ -60,23 +205,52 @@ impl AnvilInspector { self } + /// Configures the `TracingInspector` [`revm::Inspector`] pub fn with_tracing_config(mut self, config: TracingInspectorConfig) -> Self { self.tracer = Some(TracingInspector::new(config)); self } - /// Enables steps recording for `Tracer`. - pub fn with_steps_tracing(mut self) -> Self { - self.tracer = Some(TracingInspector::new(TracingInspectorConfig::all().with_state_diffs())); + /// Configures the `Tracer` [`revm::Inspector`] with a log collector + pub fn with_log_collector(mut self) -> Self { + self.log_collector = Some(LogCollector::Capture { logs: Vec::new() }); self } - /// Configures the `Tracer` [`revm::Inspector`] with a log collector - pub fn with_log_collector(mut self) -> Self { - self.log_collector = Some(Default::default()); + /// Configures the `Tracer` [`revm::Inspector`] with a transfer event collector + pub fn with_transfers(mut self) -> Self { + self.transfer = Some(TransferInspector::new(false).with_logs(true)); self } + /// Collects canonical and synthetic transfer logs for an `eth_simulateV1` response. + pub fn with_simulation_logs(mut self, trace_transfers: bool) -> Self { + self.simulation_logs = + Some(SimulationLogCollector { trace_transfers, ..Default::default() }); + self + } + + /// Takes the collected `eth_simulateV1` response logs and attempted log count. + pub fn take_simulation_logs( + &mut self, + canonical_logs: &[Log], + success: bool, + ) -> Option<(Vec<(u64, Log)>, u64)> { + self.simulation_logs.take().map(|mut collector| { + if success { + collector.append_remaining_canonical_logs(canonical_logs); + } else { + // A top-level revert can discard logs without producing an enclosing call frame + // callback. Preserve the attempted count for subsequent log indices. + collector.logs.clear(); + } + ( + collector.logs.into_iter().map(|log| (log.index, log.log)).collect(), + collector.next_index, + ) + }) + } + /// Configures the `Tracer` [`revm::Inspector`] with a trace printer pub fn with_trace_printer(mut self) -> Self { self.tracer = Some(TracingInspector::new(TracingInspectorConfig::all().with_state_diffs())); @@ -91,19 +265,19 @@ impl AnvilInspector { /// # Panics /// /// If called outside tokio runtime -fn print_traces(tracer: TracingInspector) { +fn print_traces(tracer: TracingInspector, decoder: Arc) { let arena = tokio::task::block_in_place(move || { tokio::runtime::Handle::current().block_on(async move { let mut arena = tracer.into_traces(); - let decoder = CallTraceDecoder::new(); decoder.populate_traces(arena.nodes_mut()).await; arena }) }); - let traces = SparsedTraceArena { arena, ignored: Default::default() }; - node_info!("Traces:"); - node_info!("{}", render_trace_arena_inner(&traces, false, true)); + let traces = + SparsedTraceArena { arena, ignored: Default::default(), diagnostics: Default::default() }; + let trace = render_trace_arena_inner(&traces, false, true); + node_info!(Traces = %format!("\n{}", trace)); } impl Inspector for AnvilInspector @@ -111,12 +285,18 @@ where CTX: ContextTr, { fn initialize_interp(&mut self, interp: &mut Interpreter, ecx: &mut CTX) { + if let Some(collector) = &mut self.simulation_logs { + collector.sync_journal_logs(ecx.journal().logs()); + } call_inspectors!([&mut self.tracer], |inspector| { inspector.initialize_interp(interp, ecx); }); } fn step(&mut self, interp: &mut Interpreter, ecx: &mut CTX) { + if let Some(collector) = &mut self.simulation_logs { + collector.sync_journal_logs(ecx.journal().logs()); + } call_inspectors!([&mut self.tracer], |inspector| { inspector.step(interp, ecx); }); @@ -126,19 +306,44 @@ where call_inspectors!([&mut self.tracer], |inspector| { inspector.step_end(interp, ecx); }); + if let Some(collector) = &mut self.simulation_logs { + collector.sync_journal_logs(ecx.journal().logs()); + } } - fn log(&mut self, interp: &mut Interpreter, ecx: &mut CTX, log: Log) { + #[allow(clippy::redundant_clone)] + fn log(&mut self, ecx: &mut CTX, log: Log) { call_inspectors!([&mut self.tracer, &mut self.log_collector], |inspector| { - // TODO: rm the log.clone - inspector.log(interp, ecx, log.clone()); + inspector.log(ecx, log.clone()); }); + if let Some(collector) = &mut self.simulation_logs { + collector.push_canonical_log(log, ecx.journal().logs().len()); + } + } + + #[allow(clippy::redundant_clone)] + fn log_full(&mut self, interp: &mut Interpreter, ecx: &mut CTX, log: Log) { + call_inspectors!([&mut self.tracer, &mut self.log_collector], |inspector| { + inspector.log_full(interp, ecx, log.clone()); + }); + if let Some(collector) = &mut self.simulation_logs { + collector.push_canonical_log(log, ecx.journal().logs().len()); + } } fn call(&mut self, ecx: &mut CTX, inputs: &mut CallInputs) -> Option { + if let Some(collector) = &mut self.simulation_logs { + collector.sync_journal_logs(ecx.journal().logs()); + collector.frame_start(); + if inputs.scheme.is_call() + && let Some(value) = inputs.transfer_value() + { + collector.push_transfer(inputs.transfer_from(), inputs.transfer_to(), value); + } + } call_inspectors!( #[ret] - [&mut self.tracer, &mut self.log_collector], + [&mut self.tracer, &mut self.log_collector, &mut self.transfer], |inspector| inspector.call(ecx, inputs).map(Some), ); None @@ -148,14 +353,28 @@ where if let Some(tracer) = &mut self.tracer { tracer.call_end(ecx, inputs, outcome); } + if let Some(collector) = &mut self.simulation_logs { + collector.sync_journal_logs(ecx.journal().logs()); + collector.frame_end(outcome.instruction_result().is_ok(), ecx.journal().logs().len()); + } } fn create(&mut self, ecx: &mut CTX, inputs: &mut CreateInputs) -> Option { - if let Some(tracer) = &mut self.tracer - && let Some(out) = tracer.create(ecx, inputs) - { - return Some(out); + if let Some(collector) = &mut self.simulation_logs { + collector.sync_journal_logs(ecx.journal().logs()); + collector.frame_start(); + if matches!(inputs.scheme(), CreateScheme::Create | CreateScheme::Create2 { .. }) + && let Ok(account) = ecx.journal_mut().load_account(inputs.caller()) + { + let address = inputs.created_address(account.data.info.nonce); + collector.push_transfer(inputs.caller(), address, inputs.value()); + } } + call_inspectors!( + #[ret] + [&mut self.tracer, &mut self.transfer], + |inspector| inspector.create(ecx, inputs).map(Some), + ); None } @@ -163,12 +382,21 @@ where if let Some(tracer) = &mut self.tracer { tracer.create_end(ecx, inputs, outcome); } + if let Some(collector) = &mut self.simulation_logs { + collector.sync_journal_logs(ecx.journal().logs()); + collector.frame_end( + outcome.instruction_result().is_ok() && outcome.address.is_some(), + ecx.journal().logs().len(), + ); + } } - #[inline] fn selfdestruct(&mut self, contract: Address, target: Address, value: U256) { - if let Some(tracer) = &mut self.tracer { - >::selfdestruct(tracer, contract, target, value); + call_inspectors!([&mut self.tracer, &mut self.transfer], |inspector| { + Inspector::::selfdestruct(inspector, contract, target, value) + }); + if let Some(collector) = &mut self.simulation_logs { + collector.push_transfer(contract, target, value); } } } diff --git a/crates/anvil/src/eth/backend/mem/mod.rs b/crates/anvil/src/eth/backend/mem/mod.rs index 06db1676e549f..116d07ba2c71d 100644 --- a/crates/anvil/src/eth/backend/mem/mod.rs +++ b/crates/anvil/src/eth/backend/mem/mod.rs @@ -1,142 +1,878 @@ //! In-memory blockchain backend. - -use self::state::trie_storage; +use self::{in_memory_db::StateRootDb, state::trie_storage}; use crate::{ ForkChoice, NodeConfig, PrecompileFactory, - config::PruneStateHistoryConfig, + config::{ForkTransactionReplay, PruneStateHistoryConfig, source_hardfork}, eth::{ backend::{ - cheats::CheatsManager, - db::{Db, MaybeFullDatabase, SerializableState}, - env::Env, - executor::{ExecutedTransactions, TransactionExecutor}, - fork::ClientFork, + cheats::{CheatEcrecover, CheatsManager, NextBlockOverrides}, + db::{ + AnvilCacheDB, BLOCKHASH_HISTORY, Db, EmptyAsAbsentDb, MaybeFullDatabase, + SerializableState, StateDb, + }, + executor::{ + AnvilBlockExecutor, BlockExecutionKind, EthereumBlockTransitions, + ExecutedPoolTransactions, FoundryReceiptBuilder, PoolTxGasConfig, + apply_ethereum_post_execution_changes, apply_ethereum_pre_execution_changes, + block_blob_gas_limit, build_tx_env_for_pending, execute_pool_transaction, + execute_pool_transactions, + }, + fork::{ClientFork, ForkEndpointIdentity}, genesis::GenesisConfig, mem::{ - state::{storage_root, trie_accounts}, + state::{ + StateRootCache, state_root, state_trie_witness, storage_root, trie_accounts, + }, storage::MinedTransactionReceipt, }, - notifications::{NewBlockNotification, NewBlockNotifications}, - time::{TimeManager, utc_from_secs}, + notifications::{ChainNotification, ChainNotifications, NewBlockNotification}, + replay::{ + ExecutedHistoricalReplay, HistoricalReplayTransaction, + PreparedForkTransactionReplay, execute_historical_replay, + prepare_fork_transaction_replay, + }, + tempo::AnvilStorageProvider, + time::{PendingBlockTimestamp, TimeManager, TimeSnapshot, utc_from_secs}, validate::TransactionValidator, }, error::{BlockchainError, ErrDetail, InvalidTransactionError}, - fees::{FeeDetails, FeeManager, MIN_SUGGESTED_PRIORITY_FEE}, + fees::{FeeDetails, FeeManager, FeeSnapshot, MIN_SUGGESTED_PRIORITY_FEE}, macros::node_info, pool::transactions::PoolTransaction, - sign::build_typed_transaction, + preserve_simulation_request_fields, }, - inject_precompiles, mem::{ - inspector::AnvilInspector, + inspector::{AnvilInspector, InspectorTxConfig}, storage::{BlockchainStorage, InMemoryBlockStates, MinedBlockOutcome}, }, }; use alloy_chains::NamedChain; use alloy_consensus::{ - Account, Blob, BlockHeader, EnvKzgSettings, Header, Receipt, ReceiptWithBloom, Signed, - Transaction as TransactionTrait, TxEnvelope, + Blob, BlockBody, BlockHeader, EnvKzgSettings, Header, Signed, Transaction as TransactionTrait, + TransactionEnvelope, TrieAccount, TxEip4844Variant, TxEnvelope, TxReceipt, Typed2718, + constants::EMPTY_WITHDRAWALS, proofs::{calculate_receipt_root, calculate_transaction_root}, transaction::Recovered, }; -use alloy_eips::{eip1559::BaseFeeParams, eip4844::kzg_to_versioned_hash, eip7840::BlobParams}; -use alloy_evm::{Database, Evm, eth::EthEvmContext, precompiles::PrecompilesMap}; +use alloy_eips::{ + BlockNumHash, Encodable2718, eip2935, eip4788, + eip4844::{DATA_GAS_PER_BLOB, kzg_to_versioned_hash}, + eip6110::MAINNET_DEPOSIT_CONTRACT_ADDRESS, + eip7002, eip7251, + eip7685::EMPTY_REQUESTS_HASH, + eip7840::BlobParams, + eip7910::SystemContract, + eip7928::{BlockAccessList, EMPTY_BLOCK_ACCESS_LIST_HASH, compute_block_access_list_hash}, +}; +use alloy_evm::{ + Database, EthEvmFactory, Evm, EvmEnv, EvmFactory, FromTxWithEncoded, + block::{BalIndexedDatabase, BlockExecutionResult, BlockExecutor, StateDB}, + eth::{EthEvm, EthEvmContext}, + overrides::{OverrideBlockHashes, apply_state_overrides}, + precompiles::{DynPrecompile, MovePrecompileError, Precompile, PrecompilesMap}, +}; +use alloy_genesis::Genesis; use alloy_network::{ AnyHeader, AnyRpcBlock, AnyRpcHeader, AnyRpcTransaction, AnyTxEnvelope, AnyTxType, - EthereumWallet, UnknownTxEnvelope, UnknownTypedTransaction, + BlockResponse, Network, NetworkTransactionBuilder, ReceiptResponse, UnknownTxEnvelope, + UnknownTypedTransaction, }; use alloy_primitives::{ - Address, B256, Bytes, TxHash, TxKind, U64, U256, address, hex, keccak256, logs_bloom, - map::HashMap, utils::Unit, + Address, B256, Bloom, Bytes, Signature, TxKind, U256, address, hex, keccak256, + map::{AddressMap, B256Set, HashMap, HashSet}, }; +use alloy_rlp::{Decodable, Encodable}; use alloy_rpc_types::{ - AccessList, Block as AlloyBlock, BlockId, BlockNumberOrTag as BlockNumber, BlockTransactions, - EIP1186AccountProofResponse as AccountProof, EIP1186StorageProof as StorageProof, Filter, - Header as AlloyHeader, Index, Log, Transaction, TransactionReceipt, + AccessList, Block as AlloyBlock, BlockId, BlockNumberOrTag as BlockNumber, BlockOverrides, + BlockTransactions, EIP1186AccountProofResponse as AccountProof, + EIP1186StorageProof as StorageProof, Filter, Header as AlloyHeader, Index, Log, Transaction, + TransactionReceipt, anvil::Forking, + debug::ExecutionWitness, request::TransactionRequest, serde_helpers::JsonStorageKey, - simulate::{SimBlock, SimCallResult, SimulatePayload, SimulatedBlock}, - state::EvmOverrides, + simulate::{ + MAX_SIMULATE_BLOCKS, SimBlock, SimCallResult, SimulateError, SimulatePayload, + SimulatedBlock, + }, + state::{EvmOverrides, StateOverride}, trace::{ filter::TraceFilter, geth::{ - GethDebugBuiltInTracerType, GethDebugTracerType, GethDebugTracingCallOptions, - GethDebugTracingOptions, GethTrace, NoopFrame, + CallConfig, FourByteFrame, GethDebugBuiltInTracerType, GethDebugTracerConfig, + GethDebugTracerType, GethDebugTracingCallOptions, GethDebugTracingOptions, GethTrace, + NoopFrame, TraceResult, + }, + opcode::{BlockOpcodeGas, TransactionOpcodeGas}, + parity::{ + LocalizedTransactionTrace, TraceResults, TraceResultsWithTransactionHash, TraceType, }, - parity::LocalizedTransactionTrace, }, }; +use alloy_rpc_types_eth::{AccountInfo as RpcAccountInfo, Bundle, EthCallResponse}; +use alloy_rpc_types_mev::{EthCallBundle, EthCallBundleResponse, EthCallBundleTransactionResult}; use alloy_serde::{OtherFields, WithOtherFields}; -use alloy_signer::Signature; -use alloy_signer_local::PrivateKeySigner; -use alloy_trie::{HashBuilder, Nibbles, proof::ProofRetainer}; +use alloy_sol_types::SolCall; +use alloy_trie::{HashBuilder, Nibbles, proof::ProofRetainer, root::state_root_ref_unhashed}; use anvil_core::eth::{ - block::{Block, BlockInfo}, - transaction::{ - DepositReceipt, MaybeImpersonatedTransaction, PendingTransaction, ReceiptResponse, - TransactionInfo, TypedReceipt, TypedTransaction, has_optimism_fields, - transaction_request_to_typed, - }, - wallet::{Capabilities, DelegationCapability, WalletCapabilities}, + block::{Block, BlockInfo, canonical_block, create_block}, + transaction::{MaybeImpersonatedTransaction, PendingTransaction, TransactionInfo}, }; -use anvil_rpc::error::RpcError; +use anvil_rpc::error::{ErrorCode, RpcError}; use chrono::Datelike; use eyre::{Context, Result}; use flate2::{Compression, read::GzDecoder, write::GzEncoder}; use foundry_evm::{ - backend::{DatabaseError, DatabaseResult, RevertStateSnapshotAction}, - constants::DEFAULT_CREATE2_DEPLOYER_RUNTIME_CODE, + backend::{BlockchainDb, DatabaseError, DatabaseResult, RevertStateSnapshotAction}, + constants::{DEFAULT_CREATE2_DEPLOYER, DEFAULT_CREATE2_DEPLOYER_RUNTIME_CODE}, + core::precompiles::EC_RECOVER, decode::RevertDecoder, + hardfork::{EthereumHardfork, FoundryHardfork}, inspectors::AccessListInspector, - traces::TracingInspectorConfig, - utils::{get_blob_base_fee_update_fraction, get_blob_base_fee_update_fraction_by_spec_id}, + traces::{ + CallTraceDecoder, FourByteInspector, GethTraceBuilder, TracingInspector, + TracingInspectorConfig, + }, + utils::{ + apply_chain_specific_tx_replay_env_changes_for_chain, block_env_from_header, + get_blob_base_fee_update_fraction, get_blob_base_fee_update_fraction_by_spec_id, + get_blob_params_by_hardfork, + }, }; -use foundry_evm_core::either_evm::EitherEvm; -use futures::channel::mpsc::{UnboundedSender, unbounded}; -use op_alloy_consensus::DEPOSIT_TX_TYPE_ID; -use op_revm::{ - OpContext, OpHaltReason, OpTransaction, transaction::deposit::DepositTransactionParts, +use foundry_evm_networks::{NetworkConfigs, apply_bsc_p256_precompile, arbitrum}; +use foundry_primitives::{ + FoundryHeader, FoundryNetwork, FoundryReceiptEnvelope, FoundryTransactionRequest, + FoundryTxEnvelope, FoundryTxReceipt, TempoTransactionRequest, }; -use parking_lot::{Mutex, RwLock}; +use futures::channel::mpsc::{UnboundedSender, unbounded}; +use parking_lot::{Mutex, RwLock, RwLockUpgradableReadGuard}; use revm::{ - DatabaseCommit, Inspector, - context::{Block as RevmBlock, BlockEnv, TxEnv}, + Database as RevmDatabase, DatabaseCommit, Inspector, + context::{Block as RevmBlock, BlockEnv, Cfg, CfgEnv, ContextSetters, ContextTr, TxEnv}, context_interface::{ + JournalTr, Transaction as _, block::BlobExcessGasAndPrice, - result::{ExecutionResult, Output, ResultAndState}, + result::{ + EVMError, ExecutionResult, HaltReason, InvalidTransaction, Output, ResultAndState, + }, + transaction::TransactionType, + }, + database::{ + AccountState, CacheDB, DbAccount, WrapDatabaseRef, + bal::{BalDatabase, BalState}, }, - database::{CacheDB, WrapDatabaseRef}, - interpreter::InstructionResult, - precompile::secp256r1::{P256VERIFY, P256VERIFY_BASE_GAS_FEE}, - primitives::{KECCAK_EMPTY, hardfork::SpecId}, - state::AccountInfo, + handler::{ + EthFrame, EvmTr, EvmTrError, FrameResult, FrameTr, Handler as EvmHandler, validation, + }, + inspector::{InspectorEvmTr, InspectorHandler}, + interpreter::{InstructionResult, interpreter::EthInterpreter, interpreter_action::FrameInit}, + precompile::{PrecompileSpecId, Precompiles}, + primitives::{eip2780, hardfork::SpecId}, + state::{Account, AccountInfo, EvmState, EvmStorageSlot, TransactionId}, }; -use revm_inspectors::transfer::TransferInspector; +use revm_inspectors::opcode::OpcodeGasInspector; use std::{ collections::BTreeMap, - fmt::Debug, + fmt::{self, Debug}, io::{Read, Write}, - ops::Not, - path::PathBuf, - sync::Arc, + marker::PhantomData, + ops::Mul, + path::{Path, PathBuf}, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, time::Duration, }; use storage::{Blockchain, DEFAULT_HISTORY_LIMIT, MinedTransaction}; -use tokio::sync::RwLock as AsyncRwLock; +use tempo_evm::{TempoPoolValidationEvm, evm::TempoEvmFactory}; +use tempo_hardfork::TempoHardfork; +use tempo_precompiles::{ + NONCE_PRECOMPILE_ADDRESS, TIP_FEE_MANAGER_ADDRESS, extend_tempo_precompiles, + nonce::NonceManager, + storage::{Handler, StorageActions, StorageCtx}, + tip_fee_manager::{IFeeManager, TipFeeManager}, + tip20::{ITIP20, TIP20Token}, + tip20_factory::TIP20Factory, +}; +use tempo_primitives::{ + AASigned, SignatureType, TEMPO_TX_TYPE_ID, TempoSignature, + transaction::{ + Call, KeychainSignature, PrimitiveSignature, RecoveredTempoAuthorization, + tt_signature::{P256SignatureWithPreHash, WebAuthnSignature}, + }, +}; +use tempo_revm::{ + ExecutionContext, TempoBatchCallEnv, TempoBlockEnv, TempoInvalidTransaction, TempoTxEnv, + evm::TempoContext, gas_params::tempo_gas_params, +}; +#[cfg(test)] +use tokio::sync::Notify; +use tokio::{sync::RwLock as AsyncRwLock, task::JoinSet}; + +#[cfg(any(feature = "base", feature = "optimism"))] +use foundry_primitives::get_deposit_tx_parts; +#[cfg(any(feature = "base", feature = "optimism"))] +use op_alloy_consensus::DEPOSIT_TX_TYPE_ID; +#[cfg(any(feature = "base", feature = "optimism"))] +use op_revm::transaction::deposit::DepositTransactionParts; + +#[cfg(feature = "base")] +use alloy_hardforks::ForkCondition; +#[cfg(feature = "base")] +use base_common_chains::{ChainConfig, ChainUpgrades}; +#[cfg(feature = "base")] +use base_common_consensus::{ + BaseTransactionInfo, BaseTxEnvelope, EIP8130_REJECTION_MSG, Eip8130Constants, Predeploys, +}; +#[cfg(feature = "base")] +use base_common_evm::{ + BaseContext, BaseEvmFactory, BaseSpecId, BaseTime, BaseTransaction, DEPOSIT_TRANSACTION_TYPE, + DepositTransactionParts as BaseDepositTransactionParts, EIP8130_TRANSACTION_TYPE, + Eip8130PhaseStatuses, L1BlockInfo, ensure_create2_deployer, ensure_eip8130_system_accounts, +}; +#[cfg(feature = "base")] +use base_common_genesis::RollupConfig; +#[cfg(feature = "base")] +use base_common_precompiles::NonceManagerStorage; +#[cfg(feature = "base")] +use base_common_rpc_types::{ + EIP8130_PRE_ZENITH_RPC_ERROR, Eip8130Nonce, Eip8130ReceiptFields, + Transaction as BaseRpcTransaction, +}; +#[cfg(feature = "base")] +use base_execution_eip8130::{FeeCheck, IntrinsicGas, IntrinsicGasInput}; +#[cfg(feature = "base")] +use foundry_evm::{ + core::{constants::SYSTEM_PRECOMPILE_STUB, evm::base_code_sentinel_addresses}, + hardfork::BaseUpgrade, +}; +#[cfg(feature = "base")] +use revm::inspector::NoOpInspector; + +#[cfg(feature = "optimism")] +use alloy_op_evm::{OpEvmContext, OpEvmFactory, OpTx}; +#[cfg(feature = "optimism")] +use foundry_evm::hardfork::OpHardfork; +#[cfg(feature = "optimism")] +use foundry_evm_networks::NetworkVariant; +#[cfg(feature = "optimism")] +use op_alloy_consensus::POST_EXEC_TX_TYPE_ID; +#[cfg(feature = "optimism")] +use op_revm::OpTransaction; + +/// Network-specific transaction data produced by [`Backend::build_call_env_with_base`]. +#[derive(Default, Clone, Debug)] +struct CallTransactionInfo { + /// OP-compatible deposit fields shared by Base and Optimism RPC requests. + #[cfg(any(feature = "base", feature = "optimism"))] + deposit: DepositTransactionParts, +} + +/// Fully prepared fork replacement awaiting an atomic backend commit. +pub(crate) struct StagedForkReset { + node_config: NodeConfig, + db: Box, + fees: FeeManager, + evm_env: EvmEnv, + fork: ClientFork, + timestamp: u64, + discard_old_cached_state: bool, + invalidated_cache_namespaces: Vec, + flush_old_cache: bool, + cache_lease: StagedForkCacheLease, +} + +/// Fully prepared in-memory replacement awaiting an atomic backend commit. +pub(crate) struct StagedMemoryReset { + node_config: NodeConfig, + db: Box, + fees: FeeManager, + evm_env: EvmEnv, + hardfork: FoundryHardfork, + storage: BlockchainStorage, + timestamp: u64, + flush_old_cache: bool, +} + +/// Identifies the endpoint that supplied the most recently committed fork. +#[derive(Clone, Debug, PartialEq, Eq)] +struct ForkCacheSource { + rpc_url: String, + endpoint_identity: ForkEndpointIdentity, +} + +impl ForkCacheSource { + fn from_fork(fork: &ClientFork) -> Option { + let config = fork.config.read(); + Some(Self { + rpc_url: config.eth_rpc_url()?.to_string(), + endpoint_identity: config.endpoint_identity, + }) + } + + fn authoritative_identity_changed_at_same_url( + &self, + rpc_url: &str, + endpoint_identity: ForkEndpointIdentity, + ) -> bool { + // `hardfork` is populated only when `anvil_nodeInfo` succeeds, which makes the complete + // endpoint identity authoritative. Anonymous RPC endpoints intentionally retain Foundry's + // existing cache behavior when reused through the same URL. + let authoritative = + self.endpoint_identity.is_authoritative() || endpoint_identity.is_authoritative(); + self.rpc_url == rpc_url && authoritative && self.endpoint_identity != endpoint_identity + } +} + +/// Identifies one endpoint's persisted cache files across all blocks of a source chain. +#[derive(Clone, Debug, PartialEq, Eq)] +struct ForkCacheNamespace { + chain_cache_dir: PathBuf, + file_name: String, +} + +impl ForkCacheNamespace { + fn new(source_chain_id: u64, rpc_url: &str) -> Option { + Some(Self { + chain_cache_dir: foundry_config::Config::foundry_chain_cache_dir(source_chain_id)?, + file_name: format!("storage-{:x}.json", keccak256(rpc_url)), + }) + } + + fn invalidate(&self) -> Result<(), BlockchainError> { + let entries = match std::fs::read_dir(&self.chain_cache_dir) { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(err) => { + return Err(BlockchainError::Internal(format!( + "failed to inspect fork cache at {}: {err}", + self.chain_cache_dir.display() + ))); + } + }; + + for entry in entries { + let entry = entry.map_err(|err| { + BlockchainError::Internal(format!( + "failed to inspect fork cache at {}: {err}", + self.chain_cache_dir.display() + )) + })?; + let file_type = entry.file_type().map_err(|err| { + BlockchainError::Internal(format!( + "failed to inspect fork cache entry at {}: {err}", + entry.path().display() + )) + })?; + if !file_type.is_dir() { + continue; + } + let cache_path = entry.path().join(&self.file_name); + if let Err(err) = std::fs::remove_file(&cache_path) + && err.kind() != std::io::ErrorKind::NotFound + { + return Err(BlockchainError::Internal(format!( + "failed to invalidate fork cache at {}: {err}", + cache_path.display() + ))); + } + } + Ok(()) + } +} + +/// Keeps a staged fork from persisting remote state unless it is committed. +#[derive(Clone, Debug, Default)] +struct StagedForkCacheLease(Option>); + +#[derive(Debug)] +struct StagedForkCacheLeaseInner { + db: BlockchainDb, + cache_path: PathBuf, + armed: AtomicBool, +} + +impl StagedForkCacheLease { + fn new(db: BlockchainDb, cache_path: Option) -> Self { + Self(cache_path.map(|cache_path| { + Arc::new(StagedForkCacheLeaseInner { db, cache_path, armed: AtomicBool::new(true) }) + })) + } + + fn for_db(db: &BlockchainDb) -> Self { + Self::new(db.clone(), db.cache().cache_path().map(Path::to_path_buf)) + } + + fn disarm(&self) { + if let Some(inner) = &self.0 { + inner.armed.store(false, Ordering::Release); + } + } + + fn rollback(&self) -> Result<(), BlockchainError> { + let Some(inner) = &self.0 else { return Ok(()) }; + // A clone owned by an in-flight database user must outlive that user's SharedBackend. + // Leave cleanup armed for the final owner instead of racing its eventual cache flush. + if Arc::strong_count(inner) == 1 && inner.armed.load(Ordering::Acquire) { + inner.invalidate()?; + inner.armed.store(false, Ordering::Release); + } + Ok(()) + } +} + +impl StagedForkCacheLeaseInner { + fn invalidate(&self) -> Result<(), BlockchainError> { + self.db.db().clear(); + self.db.cache().flush(); + if let Err(err) = std::fs::remove_file(&self.cache_path) + && err.kind() != std::io::ErrorKind::NotFound + { + return Err(BlockchainError::Internal(format!( + "failed to invalidate fork cache at {}: {err}", + self.cache_path.display() + ))); + } + Ok(()) + } +} + +impl Drop for StagedForkCacheLeaseInner { + fn drop(&mut self) { + if self.armed.swap(false, Ordering::AcqRel) + && let Err(err) = self.invalidate() + { + warn!(target: "backend", %err, "failed to roll back staged fork cache"); + } + } +} + +/// Couples an asynchronous staged-database user to its rollback lease. +/// +/// Fields drop in declaration order, so the database handle (and its SharedBackend) is released +/// before the lease can perform final cache cleanup. +struct StagedForkDbUser { + db: Option>>, + cache_lease: StagedForkCacheLease, +} + +impl Clone for StagedForkDbUser { + fn clone(&self) -> Self { + Self { db: self.db.clone(), cache_lease: self.cache_lease.clone() } + } +} + +impl StagedForkDbUser { + const fn db(&self) -> &Arc> { + self.db.as_ref().expect("staged fork database must be present until drop") + } +} + +impl Drop for StagedForkDbUser { + fn drop(&mut self) { + // Explicitly release the SharedBackend before `cache_lease` can invalidate its cache. + drop(self.db.take()); + } +} + +#[cfg(feature = "monad")] +pub(crate) type MonadReplayContext = monad_revm::MonadChainContext; +// Opaque stand-in that keeps feature-independent replay context plumbing type-stable. +#[cfg(not(feature = "monad"))] +#[derive(Clone)] +pub(crate) struct MonadReplayContext; + +#[cfg(feature = "monad")] +enum MonadExecutionContext<'a> { + Exact(Box), + Next(&'a mut MonadReplayContext), +} + +#[cfg(not(feature = "monad"))] +struct MonadExecutionContext<'a> { + _marker: std::marker::PhantomData<&'a mut MonadReplayContext>, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum EnvelopeExecutionKind { + #[default] + Transaction, + Replay, +} + +#[cfg_attr(not(feature = "monad"), allow(dead_code))] +struct EnvelopeExecution<'a> { + monad_context: Option>, + kind: EnvelopeExecutionKind, + hardfork: FoundryHardfork, +} + +impl<'a> EnvelopeExecution<'a> { + const fn transaction( + monad_context: Option>, + hardfork: FoundryHardfork, + ) -> Self { + Self { monad_context, kind: EnvelopeExecutionKind::Transaction, hardfork } + } + + const fn replay( + monad_context: Option>, + hardfork: FoundryHardfork, + ) -> Self { + Self { monad_context, kind: EnvelopeExecutionKind::Replay, hardfork } + } +} + +#[cfg(feature = "monad")] +fn monad_execution_context_at( + context: Option<&MonadReplayContext>, + current_tx_index: usize, +) -> Option> { + context.map(|context| { + let mut context = context.clone(); + context.current_tx_index = current_tx_index; + MonadExecutionContext::Exact(Box::new(context)) + }) +} + +#[cfg(not(feature = "monad"))] +const fn monad_execution_context_at( + _context: Option<&MonadReplayContext>, + _current_tx_index: usize, +) -> Option> { + None +} + +#[cfg(feature = "monad")] +const fn next_monad_context(context: &mut MonadReplayContext) -> MonadExecutionContext<'_> { + MonadExecutionContext::Next(context) +} + +#[cfg(not(feature = "monad"))] +const fn next_monad_context(_context: &mut MonadReplayContext) -> MonadExecutionContext<'_> { + MonadExecutionContext { _marker: std::marker::PhantomData } +} + +/// Maximum cumulative gas available to one `eth_simulateV1` request. +const SIMULATE_GAS_CAP: u64 = 50_000_000; +const SEPOLIA_DEPOSIT_CONTRACT_ADDRESS: Address = + address!("7f02c3e3c98b133055b8b348b2ac625669ed295d"); +const HOLESKY_DEPOSIT_CONTRACT_ADDRESS: Address = Address::repeat_byte(0x42); -use super::executor::new_evm_with_inspector_ref; +/// Fixed transaction context for direct Tempo RPC simulations. +const TEMPO_RPC_SIMULATION_CONTEXT: B256 = B256::new(*b"TEMPO_RPC_SIMULATION_MPP_CONTEXT"); +/// Ethereum handler that skips blob fee cap validation for non-validating calls with a zero cap. +struct SimulationHandler { + _phantom: PhantomData<(EVM, ERROR, FRAME)>, +} + +impl Default for SimulationHandler { + fn default() -> Self { + Self { _phantom: PhantomData } + } +} + +impl EvmHandler for SimulationHandler +where + EVM: EvmTr< + Context: ContextTr< + Block = BlockEnv, + Tx = TxEnv, + Journal: JournalTr, + > + ContextSetters, + Frame = FRAME, + >, + ERROR: EvmTrError, + FRAME: FrameTr, +{ + type Evm = EVM; + type Error = ERROR; + type HaltReason = HaltReason; + + fn validate_env(&self, evm: &mut Self::Evm) -> Result<(), Self::Error> { + let skip_blob_fee_check = evm.ctx_ref().cfg().is_base_fee_check_disabled() + && evm.ctx_ref().tx().tx_type == 3 + && evm.ctx_ref().tx().max_fee_per_blob_gas == 0; + if !skip_blob_fee_check { + return validation::validate_env(evm.ctx()); + } + + let block = evm.ctx_ref().block().clone(); + let mut validation_block = block.clone(); + if let Some(blob_gas_and_price) = &mut validation_block.blob_excess_gas_and_price { + blob_gas_and_price.blob_gasprice = 0; + } + evm.ctx().set_block(validation_block); + let result = validation::validate_env(evm.ctx()); + evm.ctx().set_block(block); + result + } +} + +impl InspectorHandler for SimulationHandler> +where + EVM: InspectorEvmTr< + Context: ContextTr< + Block = BlockEnv, + Tx = TxEnv, + Journal: JournalTr, + > + ContextSetters, + Frame = EthFrame, + Inspector: Inspector<::Context, EthInterpreter>, + >, + ERROR: EvmTrError, +{ + type IT = EthInterpreter; +} + +#[derive(Clone)] +enum CallTxEnv { + Eth(TxEnv), + #[cfg(feature = "base")] + Base(Box>), + #[cfg(feature = "monad")] + Monad(TxEnv), + #[cfg(feature = "optimism")] + Op(OpTransaction), + Tempo(TempoTxEnv), +} + +impl CallTxEnv { + #[cfg_attr(not(feature = "js-tracer"), allow(dead_code))] + const fn base(&self) -> &TxEnv { + match self { + Self::Eth(tx) => tx, + #[cfg(feature = "base")] + Self::Base(tx) => &tx.base, + #[cfg(feature = "monad")] + Self::Monad(tx) => tx, + #[cfg(feature = "optimism")] + Self::Op(tx) => &tx.base, + Self::Tempo(tx) => &tx.inner, + } + } + + const fn base_mut(&mut self) -> &mut TxEnv { + match self { + Self::Eth(tx) => tx, + #[cfg(feature = "base")] + Self::Base(tx) => &mut tx.base, + #[cfg(feature = "monad")] + Self::Monad(tx) => tx, + #[cfg(feature = "optimism")] + Self::Op(tx) => &mut tx.base, + Self::Tempo(tx) => &mut tx.inner, + } + } + + fn into_base(self) -> TxEnv { + match self { + Self::Eth(tx) => tx, + #[cfg(feature = "base")] + Self::Base(tx) => tx.base, + #[cfg(feature = "monad")] + Self::Monad(tx) => tx, + #[cfg(feature = "optimism")] + Self::Op(tx) => tx.base, + Self::Tempo(tx) => tx.inner, + } + } + + fn uses_protocol_call_nonce(&self) -> bool { + match self { + Self::Eth(tx) => tx.kind.is_call(), + #[cfg(feature = "base")] + Self::Base(tx) => { + tx.eip8130.is_none() + && tx.base.tx_type != DEPOSIT_TRANSACTION_TYPE + && tx.base.kind.is_call() + } + #[cfg(feature = "monad")] + Self::Monad(tx) => tx.kind.is_call(), + #[cfg(feature = "optimism")] + Self::Op(tx) => tx.base.kind.is_call(), + Self::Tempo(tx) => tx.tempo_tx_env.as_ref().map_or_else( + || tx.inner.kind.is_call(), + |aa| { + aa.nonce_key.is_zero() + && aa.aa_calls.first().is_some_and(|call| call.to.is_call()) + }, + ), + } + } +} + +fn apply_tempo_envelope_identity(tx_env: &mut CallTxEnv, simulated_tx: Option<&AASigned>) { + if let (CallTxEnv::Tempo(tx_env), Some(simulated_tx)) = (tx_env, simulated_tx) { + tx_env.unique_tx_identifier = Some(simulated_tx.expiring_nonce_hash(tx_env.inner.caller)); + if let Some(batch) = &mut tx_env.tempo_tx_env { + batch.tx_hash = *simulated_tx.hash(); + } + } +} + +struct PreparedCall { + evm_env: EvmEnv, + tx_env: CallTxEnv, + simulated_tempo_tx: Option, + #[cfg(feature = "base")] + simulated_envelope: Option, +} + +#[derive(Default)] +struct TypedCallOverrides { + gas_limit: Option, + access_list: Option, + disable_fee_charge: bool, +} + +pub(crate) struct GasEstimateCallOptions { + gas_limit: u64, + disable_fee_charge: bool, + monad_context: Option, +} + +impl GasEstimateCallOptions { + pub(crate) const fn new( + gas_limit: u64, + disable_fee_charge: bool, + monad_context: Option, + ) -> Self { + Self { gas_limit, disable_fee_charge, monad_context } + } +} + +/// Marker trait that abstracts over the per-network inspector trait bounds +/// required by the in-memory backend. The OP bound is only included when the +/// `optimism` feature is enabled. +#[cfg(all(feature = "base", feature = "optimism", feature = "monad"))] +pub trait BackendInspector: + Inspector> + + Inspector> + + Inspector> + + Inspector> + + Inspector> +{ +} +#[cfg(all(feature = "base", feature = "optimism", feature = "monad"))] +impl BackendInspector for T where + T: Inspector> + + Inspector> + + Inspector> + + Inspector> + + Inspector> +{ +} +#[cfg(all(feature = "base", feature = "optimism", not(feature = "monad")))] +pub trait BackendInspector: + Inspector> + + Inspector> + + Inspector> + + Inspector> +{ +} +#[cfg(all(feature = "base", feature = "optimism", not(feature = "monad")))] +impl BackendInspector for T where + T: Inspector> + + Inspector> + + Inspector> + + Inspector> +{ +} +#[cfg(all(feature = "base", not(feature = "optimism"), feature = "monad"))] +pub trait BackendInspector: + Inspector> + + Inspector> + + Inspector> + + Inspector> +{ +} +#[cfg(all(feature = "base", not(feature = "optimism"), feature = "monad"))] +impl BackendInspector for T where + T: Inspector> + + Inspector> + + Inspector> + + Inspector> +{ +} +#[cfg(all(feature = "base", not(feature = "optimism"), not(feature = "monad")))] +pub trait BackendInspector: + Inspector> + Inspector> + Inspector> +{ +} +#[cfg(all(feature = "base", not(feature = "optimism"), not(feature = "monad")))] +impl BackendInspector for T where + T: Inspector> + Inspector> + Inspector> +{ +} +#[cfg(all(not(feature = "base"), feature = "optimism", feature = "monad"))] +pub trait BackendInspector: + Inspector> + + Inspector> + + Inspector> + + Inspector> +{ +} +#[cfg(all(not(feature = "base"), feature = "optimism", feature = "monad"))] +impl BackendInspector for T where + T: Inspector> + + Inspector> + + Inspector> + + Inspector> +{ +} +#[cfg(all(not(feature = "base"), feature = "optimism", not(feature = "monad")))] +pub trait BackendInspector: + Inspector> + Inspector> + Inspector> +{ +} +#[cfg(all(not(feature = "base"), feature = "optimism", not(feature = "monad")))] +impl BackendInspector for T where + T: Inspector> + Inspector> + Inspector> +{ +} +#[cfg(all(not(feature = "base"), not(feature = "optimism"), feature = "monad"))] +pub trait BackendInspector: + Inspector> + + Inspector> + + Inspector> +{ +} +#[cfg(all(not(feature = "base"), not(feature = "optimism"), feature = "monad"))] +impl BackendInspector for T where + T: Inspector> + + Inspector> + + Inspector> +{ +} +#[cfg(all(not(feature = "base"), not(feature = "optimism"), not(feature = "monad")))] +pub trait BackendInspector: + Inspector> + Inspector> +{ +} +#[cfg(all(not(feature = "base"), not(feature = "optimism"), not(feature = "monad")))] +impl BackendInspector for T where + T: Inspector> + Inspector> +{ +} +#[cfg(feature = "base")] +mod base; pub mod cache; pub mod fork_db; pub mod in_memory_db; pub mod inspector; +#[cfg(feature = "monad")] +mod monad; +#[cfg(feature = "optimism")] +pub mod optimism; pub mod state; pub mod storage; -/// Helper trait that combines DatabaseRef with Debug. +/// Helper trait that combines revm::DatabaseRef with Debug. /// This is needed because alloy-evm requires Debug on Database implementations. -/// Specific implementation for dyn Db since trait object upcasting is not stable. +/// With trait upcasting now stable, we can now upcast from this trait to revm::DatabaseRef. pub trait DatabaseRef: revm::DatabaseRef + Debug {} impl DatabaseRef for T where T: revm::DatabaseRef + Debug {} impl DatabaseRef for dyn crate::eth::backend::db::Db {} @@ -145,34 +881,188 @@ impl DatabaseRef for dyn crate::eth::backend::db::Db {} pub const MIN_TRANSACTION_GAS: u128 = 21000; // Gas per transaction creating a contract. pub const MIN_CREATE_GAS: u128 = 53000; -// Executor -pub const EXECUTOR: Address = address!("0x6634F723546eCc92277e8a2F93d4f248bf1189ea"); -pub const EXECUTOR_PK: &str = "0x502d47e1421cb9abef497096728e69f07543232b93ef24de4998e18b5fd9ba0f"; -// P256 Batch Delegation Contract: https://odyssey-explorer.ithaca.xyz/address/0x35202a6E6317F3CC3a177EeEE562D3BcDA4a6FcC -pub const P256_DELEGATION_CONTRACT: Address = - address!("0x35202a6e6317f3cc3a177eeee562d3bcda4a6fcc"); -// Runtime code of the P256 delegation contract -pub const P256_DELEGATION_RUNTIME_CODE: &[u8] = &hex!( - "60806040526004361015610018575b361561001657005b005b5f3560e01c806309c5eabe146100c75780630cb6aaf1146100c257806330f6a8e5146100bd5780635fce1927146100b8578063641cdfe2146100b357806376ba882d146100ae5780638d80ff0a146100a9578063972ce4bc146100a4578063a78fc2441461009f578063a82e44e01461009a5763b34893910361000e576108e1565b6108b5565b610786565b610646565b6105ba565b610529565b6103f8565b6103a2565b61034c565b6102c0565b61020b565b634e487b7160e01b5f52604160045260245ffd5b6040810190811067ffffffffffffffff8211176100fc57604052565b6100cc565b6080810190811067ffffffffffffffff8211176100fc57604052565b60a0810190811067ffffffffffffffff8211176100fc57604052565b90601f8019910116810190811067ffffffffffffffff8211176100fc57604052565b6040519061016a608083610139565b565b67ffffffffffffffff81116100fc57601f01601f191660200190565b9291926101948261016c565b916101a26040519384610139565b8294818452818301116101be578281602093845f960137010152565b5f80fd5b9080601f830112156101be578160206101dd93359101610188565b90565b60206003198201126101be576004359067ffffffffffffffff82116101be576101dd916004016101c2565b346101be57610219366101e0565b3033036102295761001690610ae6565b636f6a1b8760e11b5f5260045ffd5b634e487b7160e01b5f52603260045260245ffd5b5f54811015610284575f8080526005919091027f290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e5630191565b610238565b8054821015610284575f52600560205f20910201905f90565b906040516102af816100e0565b602060018294805484520154910152565b346101be5760203660031901126101be576004355f548110156101be576102e69061024c565b5060ff815416600182015491610306600360ff60028401541692016102a2565b926040519215158352602083015260028110156103385760a09260209160408401528051606084015201516080820152f35b634e487b7160e01b5f52602160045260245ffd5b346101be575f3660031901126101be576020600254604051908152f35b6004359063ffffffff821682036101be57565b6064359063ffffffff821682036101be57565b6084359063ffffffff821682036101be57565b346101be5760203660031901126101be576103bb610369565b303303610229576103cb9061024c565b50805460ff19169055005b60609060231901126101be57602490565b60609060831901126101be57608490565b346101be5760803660031901126101be57610411610369565b60205f61041d366103d6565b60015461043161042c82610a0b565b600155565b60405184810191825260e086901b6001600160e01b031916602083015261046581602484015b03601f198101835282610139565b51902060ff61047660408401610a19565b161583146104fe576104b2601b925b85813591013590604051948594859094939260ff6060936080840197845216602083015260408201520152565b838052039060015afa156104f9575f51306001600160a01b03909116036104ea576104df6100169161024c565b50805460ff19169055565b638baa579f60e01b5f5260045ffd5b610a27565b6104b2601c92610485565b60409060031901126101be57600490565b6044359060028210156101be57565b346101be5760803660031901126101be5761054336610509565b61054b61051a565b606435903033036102295761059192610580610587926040519461056e86610101565b60018652602086015260408501610a32565b36906105f3565b6060820152610a3e565b5f545f1981019081116105b55760405163ffffffff919091168152602090f35b0390f35b6109f7565b6100166105c6366101e0565b610ae6565b60409060231901126101be57604051906105e4826100e0565b60243582526044356020830152565b91908260409103126101be5760405161060b816100e0565b6020808294803584520135910152565b6084359081151582036101be57565b60a4359081151582036101be57565b359081151582036101be57565b346101be5760a03660031901126101be5760043567ffffffffffffffff81116101be576106779036906004016101c2565b610680366105cb565b61068861037c565b61069061061b565b906002546106a56106a082610a0b565b600255565b6040516106bb8161045788602083019586610b6a565b51902091610747575b6106d06106d69161024c565b50610b7b565b906106e86106e48351151590565b1590565b610738576020820151801515908161072e575b5061071f576107129260606106e493015191610ce3565b6104ea5761001690610ae6565b632572e3a960e01b5f5260045ffd5b905042115f6106fb565b637dd286d760e11b5f5260045ffd5b905f61077361045761076760209460405192839187830160209181520190565b60405191828092610b58565b039060025afa156104f9575f51906106c4565b346101be5760e03660031901126101be576107a036610509565b6107a861051a565b6064359060205f6107b8366103e7565b6001546107c761042c82610a0b565b60408051808601928352883560208401528589013591830191909152606082018790526107f78160808401610457565b51902060ff61080860408401610a19565b161583146108aa5760408051918252601b602083015282359082015290830135606082015280608081015b838052039060015afa156104f9575f51306001600160a01b03909116036104ea5761087a926105806105879261086761015b565b6001815294602086015260408501610a32565b6105b161089361088a5f54610ad8565b63ffffffff1690565b60405163ffffffff90911681529081906020820190565b610833601c92610485565b346101be575f3660031901126101be576020600154604051908152f35b359061ffff821682036101be57565b346101be5760c03660031901126101be5760043567ffffffffffffffff81116101be576109129036906004016101c2565b61091b366105cb565b906064359167ffffffffffffffff83116101be5760a060031984360301126101be576040516109498161011d565b836004013567ffffffffffffffff81116101be5761096d90600436918701016101c2565b8152602484013567ffffffffffffffff81116101be57840193366023860112156101be5760846109db916109ae610016973690602460048201359101610188565b60208501526109bf604482016108d2565b60408501526109d0606482016108d2565b606085015201610639565b60808201526109e861038f565b916109f161062a565b93610bc3565b634e487b7160e01b5f52601160045260245ffd5b5f1981146105b55760010190565b3560ff811681036101be5790565b6040513d5f823e3d90fd5b60028210156103385752565b5f54680100000000000000008110156100fc57806001610a6192015f555f610289565b610ac557610a7e82511515829060ff801983541691151516179055565b6020820151600182015560028101604083015160028110156103385761016a9360039260609260ff8019835416911617905501519101906020600191805184550151910155565b634e487b7160e01b5f525f60045260245ffd5b5f198101919082116105b557565b80519060205b828110610af857505050565b808201805160f81c600182015160601c91601581015160358201519384915f9493845f14610b4257505050506001146101be575b15610b3a5701605501610aec565b3d5f803e3d5ffd5b5f95508594506055019130811502175af1610b2c565b805191908290602001825e015f815290565b6020906101dd939281520190610b58565b90604051610b8881610101565b6060610bbe6003839560ff8154161515855260018101546020860152610bb860ff60028301541660408701610a32565b016102a2565b910152565b93909192600254610bd66106a082610a0b565b604051610bec8161045789602083019586610b6a565b51902091610c50575b6106d0610c019161024c565b91610c0f6106e48451151590565b6107385760208301518015159081610c46575b5061071f57610c399360606106e494015192610e0d565b6104ea5761016a90610ae6565b905042115f610c22565b905f610c7061045761076760209460405192839187830160209181520190565b039060025afa156104f9575f5190610bf5565b3d15610cad573d90610c948261016c565b91610ca26040519384610139565b82523d5f602084013e565b606090565b8051601f101561028457603f0190565b8051602010156102845760400190565b908151811015610284570160200190565b5f9291839260208251920151906020815191015191604051936020850195865260408501526060840152608083015260a082015260a08152610d2660c082610139565b519060145afa610d34610c83565b81610d74575b81610d43575090565b600160f81b91506001600160f81b031990610d6f90610d6190610cb2565b516001600160f81b03191690565b161490565b80516020149150610d3a565b60405190610d8f604083610139565b6015825274113a3cb832911d113bb2b130baba34371733b2ba1160591b6020830152565b9061016a6001610de3936040519485916c1131b430b63632b733b2911d1160991b6020840152602d830190610b58565b601160f91b815203601e19810185520183610139565b610e069060209392610b58565b9081520190565b92919281516025815110908115610f0a575b50610ef957610e2c610d80565b90610e596106e460208501938451610e53610e4c606089015161ffff1690565b61ffff1690565b91610f9b565b610f01576106e4610e8d610e88610457610e83610ea1956040519283916020830160209181520190565b611012565b610db3565b8351610e53610e4c604088015161ffff1690565b610ef9575f610eb96020925160405191828092610b58565b039060025afa156104f9575f610ee360209261076783519151610457604051938492888401610df9565b039060025afa156104f9576101dd915f51610ce3565b505050505f90565b50505050505f90565b610f2b9150610f1e610d616106e492610cc2565b6080850151151590610f31565b5f610e1f565b906001600160f81b0319600160f81b831601610f955780610f85575b610f8057601f60fb1b600160fb1b821601610f69575b50600190565b600160fc1b90811614610f7c575f610f63565b5f90565b505f90565b50600160fa1b8181161415610f4d565b50505f90565b80519282515f5b858110610fb457505050505050600190565b8083018084116105b5578281101561100757610fe56001600160f81b0319610fdc8488610cd2565b51169187610cd2565b516001600160f81b03191603610ffd57600101610fa2565b5050505050505f90565b505050505050505f90565b80516060929181611021575050565b9092506003600284010460021b604051937f4142434445464748494a4b4c4d4e4f505152535455565758595a616263646566601f527f6768696a6b6c6d6e6f707172737475767778797a303132333435363738392d5f603f52602085019282860191602083019460208284010190600460038351955f85525b0191603f8351818160121c16515f538181600c1c1651600153818160061c165160025316516003535f5181520190878210156110db5760049060039061109a565b5095505f93600393604092520160405206600204809303613d3d60f01b81525203825256fea26469706673582212200ba93b78f286a25ece47e9403c47be9862f9b8b70ba1a95098667b90c47308b064736f6c634300081a0033" -); -// Experimental ERC20 -pub const EXP_ERC20_CONTRACT: Address = address!("0x238c8CD93ee9F8c7Edf395548eF60c0d2e46665E"); -// Runtime code of the experimental ERC20 contract -pub const EXP_ERC20_RUNTIME_CODE: &[u8] = &hex!( - "60806040526004361015610010575b005b5f3560e01c806306fdde03146106f7578063095ea7b31461068c57806318160ddd1461066757806323b872dd146105a15780632bb7c5951461050e578063313ce567146104f35780633644e5151461045557806340c10f191461043057806370a08231146103fe5780637ecebe00146103cc57806395d89b4114610366578063a9059cbb146102ea578063ad0c8fdd146102ad578063d505accf146100fb5763dd62ed3e0361000e57346100f75760403660031901126100f7576100d261075c565b6100da610772565b602052637f5e9f20600c525f5260206034600c2054604051908152f35b5f80fd5b346100f75760e03660031901126100f75761011461075c565b61011c610772565b6084359160643560443560ff851685036100f757610138610788565b60208101906e04578706572696d656e74455243323608c1b8252519020908242116102a0576040519360018060a01b03169460018060a01b03169565383775081901600e52855f5260c06020600c20958654957f8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f8252602082019586528660408301967fc89efdaa54c0f20c7adf612882df0950f5a951637e0307cdcb4c672f298b8bc688528b6060850198468a528c608087019330855260a08820602e527f6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9885252528688525260a082015220604e526042602c205f5260ff1660205260a43560405260c43560605260208060805f60015afa93853d5103610293577f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b92594602094019055856303faf4f960a51b176040526034602c2055a3005b63ddafbaef5f526004601cfd5b631a15a3cc5f526004601cfd5b5f3660031901126100f7576103e834023481046103e814341517156102d65761000e90336107ac565b634e487b7160e01b5f52601160045260245ffd5b346100f75760403660031901126100f75761030361075c565b602435906387a211a2600c52335f526020600c2080548084116103595783900390555f526020600c20818154019055602052600c5160601c335f51602061080d5f395f51905f52602080a3602060405160018152f35b63f4d678b85f526004601cfd5b346100f7575f3660031901126100f757604051604081019080821067ffffffffffffffff8311176103b8576103b491604052600381526204558560ec1b602082015260405191829182610732565b0390f35b634e487b7160e01b5f52604160045260245ffd5b346100f75760203660031901126100f7576103e561075c565b6338377508600c525f52602080600c2054604051908152f35b346100f75760203660031901126100f75761041761075c565b6387a211a2600c525f52602080600c2054604051908152f35b346100f75760403660031901126100f75761000e61044c61075c565b602435906107ac565b346100f7575f3660031901126100f757602060a0610471610788565b828101906e04578706572696d656e74455243323608c1b8252519020604051907f8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f8252838201527fc89efdaa54c0f20c7adf612882df0950f5a951637e0307cdcb4c672f298b8bc6604082015246606082015230608082015220604051908152f35b346100f7575f3660031901126100f757602060405160128152f35b346100f75760203660031901126100f7576004356387a211a2600c52335f526020600c2090815490818111610359575f80806103e88487839688039055806805345cdf77eb68f44c54036805345cdf77eb68f44c5580835282335f51602061080d5f395f51905f52602083a304818115610598575b3390f11561058d57005b6040513d5f823e3d90fd5b506108fc610583565b346100f75760603660031901126100f7576105ba61075c565b6105c2610772565b604435908260601b33602052637f5e9f208117600c526034600c20908154918219610643575b506387a211a2915017600c526020600c2080548084116103595783900390555f526020600c20818154019055602052600c5160601c9060018060a01b03165f51602061080d5f395f51905f52602080a3602060405160018152f35b82851161065a57846387a211a293039055856105e8565b6313be252b5f526004601cfd5b346100f7575f3660031901126100f75760206805345cdf77eb68f44c54604051908152f35b346100f75760403660031901126100f7576106a561075c565b60243590602052637f5e9f20600c52335f52806034600c20555f52602c5160601c337f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b92560205fa3602060405160018152f35b346100f7575f3660031901126100f7576103b4610712610788565b6e04578706572696d656e74455243323608c1b6020820152604051918291825b602060409281835280519182918282860152018484015e5f828201840152601f01601f1916010190565b600435906001600160a01b03821682036100f757565b602435906001600160a01b03821682036100f757565b604051906040820182811067ffffffffffffffff8211176103b857604052600f8252565b6805345cdf77eb68f44c548281019081106107ff576805345cdf77eb68f44c556387a211a2600c525f526020600c20818154019055602052600c5160601c5f5f51602061080d5f395f51905f52602080a3565b63e5cfe9575f526004601cfdfeddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3efa2646970667358221220fbe302881d9891005ba1448ba48547cc1cb17dea1a5c4011dfcb035de325bb1d64736f6c634300081b0033" -); +/// Standalone Base L1 base fee, in wei. +#[cfg(feature = "base")] +const DEFAULT_BASE_L1_BASE_FEE: u64 = 1_000_000_000; +/// Standalone Base L1 base-fee scalar, with six decimal places. +#[cfg(feature = "base")] +const DEFAULT_BASE_L1_FEE_SCALAR: u32 = 1_000_000; + +/// Installs the BaseTime proxy and links its Denim implementation. +/// +/// Standalone genesis owns the proxy admin and always resets it. A fork must not rewrite an +/// inherited deployment, so the proxy code and admin are only seeded when the account has no code. +#[cfg(feature = "base")] +fn ensure_base_time_predeploy( + mut db: &mut dyn crate::eth::backend::db::Db, + reset_admin: bool, +) -> Result<(), DatabaseError> { + base::ensure_l1_block_predeploy(db)?; + let mut proxy = db.basic(Predeploys::BASE_TIME)?.unwrap_or_default(); + let missing_code = proxy.is_empty_code_hash(); + if missing_code { + let code = revm::state::Bytecode::new_raw(BaseTime::proxy_bytecode()); + proxy.code_hash = code.hash_slow(); + proxy.code = Some(code); + db.insert_account(Predeploys::BASE_TIME, proxy); + } + if reset_admin || missing_code { + db.set_storage_at( + Predeploys::BASE_TIME, + B256::from(BaseTime::ADMIN_SLOT.to_be_bytes::<32>()), + B256::from(U256::from_be_slice(Predeploys::PROXY_ADMIN.as_slice()).to_be_bytes::<32>()), + )?; + } + let upgrades = ChainUpgrades::new([(BaseUpgrade::Denim, ForkCondition::Timestamp(1))]); + BaseTime::ensure_predeploy(upgrades, 1, &mut db) + .map_err(|err| DatabaseError::AnyRequest(Arc::new(eyre::eyre!(err)))) +} + +fn tempo_nonce( + state: &dyn DatabaseRef, + caller: Address, + nonce_key: U256, +) -> Result { + if nonce_key.is_zero() { + return Ok(state.basic_ref(caller)?.map(|account| account.nonce).unwrap_or_default()); + } + if nonce_key == U256::MAX { + return Ok(0); + } + let slot = NonceManager::new().nonces[caller][nonce_key].slot(); + Ok(state.storage_ref(NONCE_PRECOMPILE_ADDRESS, slot)?.saturating_to()) +} + +fn mock_tempo_signature( + key_type: SignatureType, + key_data: Option, + key_id: Option
, + caller: Address, + is_t1c: bool, +) -> TempoSignature { + let signature = match key_type { + SignatureType::Secp256k1 => { + PrimitiveSignature::Secp256k1(Signature::new(U256::ZERO, U256::ZERO, false)) + } + SignatureType::P256 => PrimitiveSignature::P256(P256SignatureWithPreHash { + r: B256::ZERO, + s: B256::ZERO, + pub_key_x: B256::ZERO, + pub_key_y: B256::ZERO, + pre_hash: false, + }), + SignatureType::WebAuthn => { + const CLIENT_JSON: &str = r#"{"type":"webauthn.get","challenge":"","origin":""}"#; + const AUTH_DATA_SIZE: usize = 37; + const MIN_SIZE: usize = AUTH_DATA_SIZE + CLIENT_JSON.len(); + const DEFAULT_SIZE: usize = 800; + const MAX_SIZE: usize = 8192; + + let size = key_data + .as_deref() + .and_then(|data| match data.len() { + 1 => Some(data[0] as usize), + 2 => Some(u16::from_be_bytes([data[0], data[1]]) as usize), + 4 => Some(u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize), + _ => None, + }) + .unwrap_or(DEFAULT_SIZE) + .clamp(MIN_SIZE, MAX_SIZE); + let mut webauthn_data = vec![0u8; AUTH_DATA_SIZE]; + webauthn_data[32] = 0x01; + let padding = "x".repeat(size - MIN_SIZE); + webauthn_data.extend_from_slice( + format!(r#"{{"type":"webauthn.get","challenge":"","origin":"{padding}"}}"#) + .as_bytes(), + ); + PrimitiveSignature::WebAuthn(WebAuthnSignature { + webauthn_data: webauthn_data.into(), + r: B256::ZERO, + s: B256::ZERO, + pub_key_x: B256::ZERO, + pub_key_y: B256::ZERO, + }) + } + }; + + if key_id.is_some() { + let signature = if is_t1c { + KeychainSignature::new(caller, signature) + } else { + KeychainSignature::new_v1(caller, signature) + }; + TempoSignature::Keychain(signature) + } else { + TempoSignature::Primitive(signature) + } +} + +fn call_config_from_tracer_config( + tracer_config: GethDebugTracerConfig, +) -> Result { + let mut tracer_config = tracer_config.into_json(); + if let Some(config) = tracer_config.as_object_mut() + && !config.contains_key("onlyTopCall") + && let Some(only_top_level_call) = config.remove("onlyTopLevelCall") + { + config.insert("onlyTopCall".to_string(), only_top_level_call); + } + + GethDebugTracerConfig(tracer_config).into_call_config() +} + +/// Builds Parity trace results for `result`, with `db` as the state before the transaction. +/// +/// The state diff treats empty accounts as absent, so created and newly funded accounts are +/// marked as added. +fn parity_trace_results( + inspector: TracingInspector, + result: &ResultAndState, + trace_types: &HashSet, + db: impl revm::DatabaseRef, +) -> Result { + inspector + .into_parity_builder() + .into_trace_results_with_state(result, trace_types, EmptyAsAbsentDb(db)) + .map_err(Into::into) +} + +/// Rejects the `pending` tag for block trace methods, which only trace mined blocks. +/// +/// Resolving it to the latest block would present mined transactions as the pending block. +fn ensure_mined_trace_block(block: BlockNumber) -> Result<(), BlockchainError> { + if block.is_pending() { + return Err(BlockchainError::RpcError(RpcError::invalid_params( + "pending block traces are not supported", + ))); + } + Ok(()) +} pub type State = foundry_evm::utils::StateChangeset; +#[derive(Clone, Debug, Default)] +struct SimulationPrecompileOverrides { + moves: Vec<(Address, Address)>, +} + /// A block request, which includes the Pool Transactions if it's Pending -#[derive(Debug)] -pub enum BlockRequest { - Pending(Vec>), +pub enum BlockRequest { + Pending(Vec>>), Number(u64), } -impl BlockRequest { - pub fn block_number(&self) -> BlockNumber { +impl fmt::Debug for BlockRequest { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Pending(txs) => f.debug_tuple("Pending").field(&txs.len()).finish(), + Self::Number(n) => f.debug_tuple("Number").field(n).finish(), + } + } +} + +impl BlockRequest { + pub const fn block_number(&self) -> BlockNumber { match *self { Self::Pending(_) => BlockNumber::Pending, Self::Number(n) => BlockNumber::Number(n), @@ -180,9 +1070,23 @@ impl BlockRequest { } } +struct StateSnapshot { + block_number: u64, + block_hash: B256, + fees: FeeSnapshot, + time: TimeSnapshot, + next_block: NextBlockOverrides, +} + +#[cfg(test)] +#[derive(Clone)] +struct MiningCommitHook { + reached: Arc, + resume: Arc, +} + /// Gives access to the [revm::Database] -#[derive(Clone, Debug)] -pub struct Backend { +pub struct Backend { /// Access to [`revm::Database`] abstraction. /// /// This will be used in combination with [`alloy_evm::Evm`] and is responsible for feeding @@ -204,13 +1108,22 @@ pub struct Backend { /// executed. db: Arc>>, /// stores all block related data in memory. - blockchain: Blockchain, + blockchain: Blockchain, /// Historic states of previous blocks. states: Arc>, - /// Env data of the chain - env: Arc>, + /// EVM environment data of the chain (block env, cfg env). + evm_env: Arc>, + /// Network configuration (optimism, custom precompiles, etc.) + networks: NetworkConfigs, + /// Base activation-registry administrator override. + #[cfg(feature = "base")] + base_activation_admin: Option
, + /// The active hardfork. + hardfork: Arc>, /// This is set if this is currently forked off another client. fork: Arc>>, + /// The last source that supplied the live fork backend, retained across memory resets. + last_fork_cache_source: Arc>>, /// Provides time related info, like timestamp. time: TimeManager, /// Contains state of custom overrides. @@ -219,263 +1132,84 @@ pub struct Backend { fees: FeeManager, /// Initialised genesis. genesis: GenesisConfig, - /// Listeners for new blocks that get notified when a new block was imported. - new_block_listeners: Arc>>>, + /// Listeners for new blocks that get notified when a new block was imported or when logs were + /// removed from the canonical chain due to a reorg. + new_block_listeners: Arc>>>, /// Keeps track of active state snapshots at a specific block. - active_state_snapshots: Arc>>, + active_state_snapshots: Arc>>, enable_steps_tracing: bool, print_logs: bool, print_traces: bool, - odyssey: bool, + /// Recorder used for decoding traces, used together with print_traces. + call_trace_decoder: Arc>>, /// How to keep history state prune_state_history_config: PruneStateHistoryConfig, /// max number of blocks with transactions in memory transaction_block_keeper: Option, - node_config: Arc>, + pub(crate) node_config: Arc>, /// Slots in an epoch slots_in_an_epoch: u64, /// Precompiles to inject to the EVM. precompile_factory: Option>, /// Prevent race conditions during mining mining: Arc>, - // === wallet === // - capabilities: Arc>, - executor_wallet: Arc>>, + /// Test-only synchronization point after database commit and before canonical publication. + #[cfg(test)] + mining_commit_hook: Arc>>, + /// Disable pool balance checks + disable_pool_balance_checks: bool, + /// Keeps startup fork-cache rollback armed until startup initialization completes. + /// + /// This must remain the final field so all other backend-held database references are released + /// before a rejected startup invalidates its cache. + startup_fork_cache_user: StagedForkDbUser>, } -impl Backend { - /// Initialises the balance of the given accounts - #[expect(clippy::too_many_arguments)] - pub async fn with_genesis( - db: Arc>>, - env: Arc>, - genesis: GenesisConfig, - fees: FeeManager, - fork: Arc>>, - enable_steps_tracing: bool, - print_logs: bool, - print_traces: bool, - odyssey: bool, - prune_state_history_config: PruneStateHistoryConfig, - max_persisted_states: Option, - transaction_block_keeper: Option, - automine_block_time: Option, - cache_path: Option, - node_config: Arc>, - ) -> Result { - // if this is a fork then adjust the blockchain storage - let blockchain = if let Some(fork) = fork.read().as_ref() { - trace!(target: "backend", "using forked blockchain at {}", fork.block_number()); - Blockchain::forked(fork.block_number(), fork.block_hash(), fork.total_difficulty()) - } else { - let env = env.read(); - Blockchain::new( - &env, - env.evm_env.cfg_env.spec, - fees.is_eip1559().then(|| fees.base_fee()), - genesis.timestamp, - genesis.number, - ) - }; - - let start_timestamp = if let Some(fork) = fork.read().as_ref() { - fork.timestamp() - } else { - genesis.timestamp - }; - - let mut states = if prune_state_history_config.is_config_enabled() { - // if prune state history is enabled, configure the state cache only for memory - prune_state_history_config - .max_memory_history - .map(|limit| InMemoryBlockStates::new(limit, 0)) - .unwrap_or_default() - .memory_only() - } else if max_persisted_states.is_some() { - max_persisted_states - .map(|limit| InMemoryBlockStates::new(DEFAULT_HISTORY_LIMIT, limit)) - .unwrap_or_default() - } else { - Default::default() - }; - - if let Some(cache_path) = cache_path { - states = states.disk_path(cache_path); - } - - let (slots_in_an_epoch, precompile_factory) = { - let cfg = node_config.read().await; - (cfg.slots_in_an_epoch, cfg.precompile_factory.clone()) - }; - - let (capabilities, executor_wallet) = if odyssey { - // Insert account that sponsors the delegated txs. And deploy P256 delegation contract. - let mut db = db.write().await; - - let _ = db.set_code( - P256_DELEGATION_CONTRACT, - Bytes::from_static(P256_DELEGATION_RUNTIME_CODE), - ); - - // Insert EXP ERC20 contract - let _ = db.set_code(EXP_ERC20_CONTRACT, Bytes::from_static(EXP_ERC20_RUNTIME_CODE)); - - let init_balance = Unit::ETHER.wei().saturating_mul(U256::from(10_000)); // 10K ETH - - // Add ETH - let _ = db.set_balance(EXP_ERC20_CONTRACT, init_balance); - let _ = db.set_balance(EXECUTOR, init_balance); - - let mut capabilities = WalletCapabilities::default(); - - let chain_id = env.read().evm_env.cfg_env.chain_id; - capabilities.insert( - chain_id, - Capabilities { - delegation: DelegationCapability { addresses: vec![P256_DELEGATION_CONTRACT] }, - }, - ); - - let signer: PrivateKeySigner = EXECUTOR_PK.parse().unwrap(); - - let executor_wallet = EthereumWallet::new(signer); - - (capabilities, Some(executor_wallet)) - } else { - (WalletCapabilities::default(), None) - }; - - let backend = Self { - db, - blockchain, - states: Arc::new(RwLock::new(states)), - env, - fork, - time: TimeManager::new(start_timestamp), - cheats: Default::default(), - new_block_listeners: Default::default(), - fees, - genesis, - active_state_snapshots: Arc::new(Mutex::new(Default::default())), - enable_steps_tracing, - print_logs, - print_traces, - odyssey, - prune_state_history_config, - transaction_block_keeper, - node_config, - slots_in_an_epoch, - precompile_factory, - mining: Arc::new(tokio::sync::Mutex::new(())), - capabilities: Arc::new(RwLock::new(capabilities)), - executor_wallet: Arc::new(RwLock::new(executor_wallet)), - }; - - if let Some(interval_block_time) = automine_block_time { - backend.update_interval_mine_block_time(interval_block_time); +impl Clone for Backend { + fn clone(&self) -> Self { + Self { + db: self.db.clone(), + blockchain: self.blockchain.clone(), + states: self.states.clone(), + evm_env: self.evm_env.clone(), + networks: self.networks, + #[cfg(feature = "base")] + base_activation_admin: self.base_activation_admin, + hardfork: self.hardfork.clone(), + fork: self.fork.clone(), + last_fork_cache_source: self.last_fork_cache_source.clone(), + time: self.time.clone(), + cheats: self.cheats.clone(), + fees: self.fees.clone(), + genesis: self.genesis.clone(), + new_block_listeners: self.new_block_listeners.clone(), + active_state_snapshots: self.active_state_snapshots.clone(), + enable_steps_tracing: self.enable_steps_tracing, + print_logs: self.print_logs, + print_traces: self.print_traces, + call_trace_decoder: self.call_trace_decoder.clone(), + prune_state_history_config: self.prune_state_history_config, + transaction_block_keeper: self.transaction_block_keeper, + node_config: self.node_config.clone(), + slots_in_an_epoch: self.slots_in_an_epoch, + precompile_factory: self.precompile_factory.clone(), + mining: self.mining.clone(), + #[cfg(test)] + mining_commit_hook: self.mining_commit_hook.clone(), + disable_pool_balance_checks: self.disable_pool_balance_checks, + startup_fork_cache_user: self.startup_fork_cache_user.clone(), } - - // Note: this can only fail in forking mode, in which case we can't recover - backend.apply_genesis().await.wrap_err("failed to create genesis")?; - Ok(backend) - } - - /// Writes the CREATE2 deployer code directly to the database at the address provided. - pub async fn set_create2_deployer(&self, address: Address) -> DatabaseResult<()> { - self.set_code(address, Bytes::from_static(DEFAULT_CREATE2_DEPLOYER_RUNTIME_CODE)).await?; - - Ok(()) - } - - /// Get the capabilities of the wallet. - /// - /// Currently the only capability is [`DelegationCapability`]. - /// - /// [`DelegationCapability`]: anvil_core::eth::wallet::DelegationCapability - pub(crate) fn get_capabilities(&self) -> WalletCapabilities { - self.capabilities.read().clone() - } - - /// Updates memory limits that should be more strict when auto-mine is enabled - pub(crate) fn update_interval_mine_block_time(&self, block_time: Duration) { - self.states.write().update_interval_mine_block_time(block_time) - } - - pub(crate) fn executor_wallet(&self) -> Option { - self.executor_wallet.read().clone() - } - - /// Adds an address to the [`DelegationCapability`] of the wallet. - pub(crate) fn add_capability(&self, address: Address) { - let chain_id = self.env.read().evm_env.cfg_env.chain_id; - let mut capabilities = self.capabilities.write(); - let mut capability = capabilities.get(chain_id).cloned().unwrap_or_default(); - capability.delegation.addresses.push(address); - capabilities.insert(chain_id, capability); - } - - pub(crate) fn set_executor(&self, executor_pk: String) -> Result { - let signer: PrivateKeySigner = - executor_pk.parse().map_err(|_| RpcError::invalid_params("Invalid private key"))?; - - let executor = signer.address(); - let wallet = EthereumWallet::new(signer); - - *self.executor_wallet.write() = Some(wallet); - - Ok(executor) } +} - /// Applies the configured genesis settings - /// - /// This will fund, create the genesis accounts - async fn apply_genesis(&self) -> Result<(), DatabaseError> { - trace!(target: "backend", "setting genesis balances"); - - if self.fork.read().is_some() { - // fetch all account first - let mut genesis_accounts_futures = Vec::with_capacity(self.genesis.accounts.len()); - for address in self.genesis.accounts.iter().copied() { - let db = Arc::clone(&self.db); - - // The forking Database backend can handle concurrent requests, we can fetch all dev - // accounts concurrently by spawning the job to a new task - genesis_accounts_futures.push(tokio::task::spawn(async move { - let db = db.read().await; - let info = db.basic_ref(address)?.unwrap_or_default(); - Ok::<_, DatabaseError>((address, info)) - })); - } - - let genesis_accounts = futures::future::join_all(genesis_accounts_futures).await; - - let mut db = self.db.write().await; - - for res in genesis_accounts { - let (address, mut info) = res.unwrap()?; - info.balance = self.genesis.balance; - db.insert_account(address, info.clone()); - } - } else { - let mut db = self.db.write().await; - for (account, info) in self.genesis.account_infos() { - db.insert_account(account, info); - } - - // insert the new genesis hash to the database so it's available for the next block in - // the evm - db.insert_block_hash(U256::from(self.best_number()), self.best_hash()); - } - - let db = self.db.write().await; - // apply the genesis.json alloc - self.genesis.apply_genesis_json_alloc(db)?; - - trace!(target: "backend", "set genesis balances"); - - Ok(()) +impl fmt::Debug for Backend { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Backend").finish_non_exhaustive() } +} +// Methods that are generic over any Network. +impl Backend { /// Sets the account to impersonate /// /// Returns `true` if the account is already impersonated @@ -484,8 +1218,7 @@ impl Backend { return true; } // Ensure EIP-3607 is disabled - let mut env = self.env.write(); - env.evm_env.cfg_env.disable_eip3607 = true; + self.evm_env.write().cfg_env.disable_eip3607 = true; self.cheats.impersonate(addr) } @@ -506,11 +1239,26 @@ impl Backend { self.fork.read().clone() } + /// Marks startup fork-cache writes as belonging to the validated live backend. + pub(crate) fn commit_startup_fork_cache(&self) { + self.startup_fork_cache_user.cache_lease.disarm(); + } + /// Returns the database pub fn get_db(&self) -> &Arc>> { &self.db } + /// Locks block production while a backend-wide lifecycle transition is committed. + pub(crate) async fn lock_mining(&self) -> tokio::sync::MutexGuard<'_, ()> { + self.mining.lock().await + } + + /// Locks block production with a guard that can be moved into a mining task. + pub(crate) async fn lock_mining_owned(self: &Arc) -> tokio::sync::OwnedMutexGuard<()> { + self.mining.clone().lock_owned().await + } + /// Returns the `AccountInfo` from the database pub async fn get_account(&self, address: Address) -> DatabaseResult { Ok(self.db.read().await.basic_ref(address)?.unwrap_or_default()) @@ -521,187 +1269,24 @@ impl Backend { self.fork.read().is_some() } - /// Resets the fork to a fresh state - pub async fn reset_fork(&self, forking: Forking) -> Result<(), BlockchainError> { - if !self.is_fork() { - if let Some(eth_rpc_url) = forking.clone().json_rpc_url { - let mut env = self.env.read().clone(); - - let (db, config) = { - let mut node_config = self.node_config.write().await; - - // we want to force the correct base fee for the next block during - // `setup_fork_db_config` - node_config.base_fee.take(); - - node_config.setup_fork_db_config(eth_rpc_url, &mut env, &self.fees).await? - }; - - *self.db.write().await = Box::new(db); - - let fork = ClientFork::new(config, Arc::clone(&self.db)); - - *self.env.write() = env; - *self.fork.write() = Some(fork); - } else { - return Err(RpcError::invalid_params( - "Forking not enabled and RPC URL not provided to start forking", - ) - .into()); - } - } - - if let Some(fork) = self.get_fork() { - let block_number = - forking.block_number.map(BlockNumber::from).unwrap_or(BlockNumber::Latest); - // reset the fork entirely and reapply the genesis config - fork.reset(forking.json_rpc_url.clone(), block_number).await?; - let fork_block_number = fork.block_number(); - let fork_block = fork - .block_by_number(fork_block_number) - .await? - .ok_or(BlockchainError::BlockNotFound)?; - // update all settings related to the forked block - { - if let Some(fork_url) = forking.json_rpc_url { - self.reset_block_number(fork_url, fork_block_number).await?; - } else { - // If rpc url is unspecified, then update the fork with the new block number and - // existing rpc url, this updates the cache path - { - let maybe_fork_url = { self.node_config.read().await.eth_rpc_url.clone() }; - if let Some(fork_url) = maybe_fork_url { - self.reset_block_number(fork_url, fork_block_number).await?; - } - } - - let gas_limit = self.node_config.read().await.fork_gas_limit(&fork_block); - let mut env = self.env.write(); - - env.evm_env.cfg_env.chain_id = fork.chain_id(); - env.evm_env.block_env = BlockEnv { - number: U256::from(fork_block_number), - timestamp: U256::from(fork_block.header.timestamp), - gas_limit, - difficulty: fork_block.header.difficulty, - prevrandao: Some(fork_block.header.mix_hash.unwrap_or_default()), - // Keep previous `beneficiary` and `basefee` value - beneficiary: env.evm_env.block_env.beneficiary, - basefee: env.evm_env.block_env.basefee, - ..env.evm_env.block_env.clone() - }; - - // this is the base fee of the current block, but we need the base fee of - // the next block - let next_block_base_fee = self.fees.get_next_block_base_fee_per_gas( - fork_block.header.gas_used, - gas_limit, - fork_block.header.base_fee_per_gas.unwrap_or_default(), - ); - - self.fees.set_base_fee(next_block_base_fee); - } - - // reset the time to the timestamp of the forked block - self.time.reset(fork_block.header.timestamp); - - // also reset the total difficulty - self.blockchain.storage.write().total_difficulty = fork.total_difficulty(); - } - // reset storage - *self.blockchain.storage.write() = BlockchainStorage::forked( - fork.block_number(), - fork.block_hash(), - fork.total_difficulty(), - ); - self.states.write().clear(); - self.db.write().await.clear(); - - self.apply_genesis().await?; - - trace!(target: "backend", "reset fork"); - - Ok(()) - } else { - Err(RpcError::invalid_params("Forking not enabled").into()) - } - } - - /// Resets the backend to a fresh in-memory state, clearing all existing data - pub async fn reset_to_in_mem(&self) -> Result<(), BlockchainError> { - // Clear the fork if any exists - *self.fork.write() = None; - - // Get environment and genesis config - let env = self.env.read().clone(); - let genesis_timestamp = self.genesis.timestamp; - let genesis_number = self.genesis.number; - let spec_id = self.spec_id(); - - // Reset environment to genesis state - { - let mut env = self.env.write(); - env.evm_env.block_env.number = U256::from(genesis_number); - env.evm_env.block_env.timestamp = U256::from(genesis_timestamp); - // Reset other block env fields to their defaults - env.evm_env.block_env.basefee = self.fees.base_fee(); - env.evm_env.block_env.prevrandao = Some(B256::ZERO); - } - - // Clear all storage and reinitialize with genesis - let base_fee = if self.fees.is_eip1559() { Some(self.fees.base_fee()) } else { None }; - *self.blockchain.storage.write() = - BlockchainStorage::new(&env, spec_id, base_fee, genesis_timestamp, genesis_number); - self.states.write().clear(); - - // Clear the database - self.db.write().await.clear(); - - // Reset time manager - self.time.reset(genesis_timestamp); - - // Reset fees to initial state - if self.fees.is_eip1559() { - self.fees.set_base_fee(crate::eth::fees::INITIAL_BASE_FEE); - } - - self.fees.set_gas_price(crate::eth::fees::INITIAL_GAS_PRICE); - - // Reapply genesis configuration - self.apply_genesis().await?; - - trace!(target: "backend", "reset to fresh in-memory state"); - + /// Writes the CREATE2 deployer code directly to the database at the address provided. + pub async fn set_create2_deployer(&self, address: Address) -> DatabaseResult<()> { + self.set_code(address, Bytes::from_static(DEFAULT_CREATE2_DEPLOYER_RUNTIME_CODE)).await?; Ok(()) } - async fn reset_block_number( - &self, - fork_url: String, - fork_block_number: u64, - ) -> Result<(), BlockchainError> { - let mut node_config = self.node_config.write().await; - node_config.fork_choice = Some(ForkChoice::Block(fork_block_number as i128)); - - let mut env = self.env.read().clone(); - let (forked_db, client_fork_config) = - node_config.setup_fork_db_config(fork_url, &mut env, &self.fees).await?; - - *self.db.write().await = Box::new(forked_db); - let fork = ClientFork::new(client_fork_config, Arc::clone(&self.db)); - *self.fork.write() = Some(fork); - *self.env.write() = env; - - Ok(()) + /// Updates memory limits that should be more strict when auto-mine is enabled + pub(crate) fn update_interval_mine_block_time(&self, block_time: Duration) { + self.states.write().update_interval_mine_block_time(block_time) } /// Returns the `TimeManager` responsible for timestamps - pub fn time(&self) -> &TimeManager { + pub const fn time(&self) -> &TimeManager { &self.time } /// Returns the `CheatsManager` responsible for executing cheatcodes - pub fn cheats(&self) -> &CheatsManager { + pub const fn cheats(&self) -> &CheatsManager { &self.cheats } @@ -713,13 +1298,13 @@ impl Backend { } /// Returns the `FeeManager` that manages fee/pricings - pub fn fees(&self) -> &FeeManager { + pub const fn fees(&self) -> &FeeManager { &self.fees } - /// The env data of the blockchain - pub fn env(&self) -> &Arc> { - &self.env + /// The EVM environment data of the blockchain + pub const fn evm_env(&self) -> &Arc> { + &self.evm_env } /// Returns the current best hash of the chain @@ -734,22 +1319,36 @@ impl Backend { /// Sets the block number pub fn set_block_number(&self, number: u64) { - let mut env = self.env.write(); - env.evm_env.block_env.number = U256::from(number); + self.evm_env.write().block_env.number = U256::from(number); } /// Returns the client coinbase address. pub fn coinbase(&self) -> Address { - self.env.read().evm_env.block_env.beneficiary + self.evm_env.read().block_env.beneficiary } /// Returns the client coinbase address. pub fn chain_id(&self) -> U256 { - U256::from(self.env.read().evm_env.cfg_env.chain_id) + U256::from(self.evm_env.read().cfg_env.chain_id) + } + + /// Returns the chain ID that defines protocol behavior. + fn protocol_chain_id(&self) -> u64 { + self.get_fork().map_or_else(|| self.evm_env.read().cfg_env.chain_id, |fork| fork.chain_id()) } pub fn set_chain_id(&self, chain_id: u64) { - self.env.write().evm_env.cfg_env.chain_id = chain_id; + self.evm_env.write().cfg_env.chain_id = chain_id; + } + + /// Returns the genesis data for the Beacon API. + pub const fn genesis_time(&self) -> u64 { + self.genesis.timestamp + } + + /// Returns the configured genesis block number. + pub const fn genesis_number(&self) -> u64 { + self.genesis.number } /// Returns balance of the given account. @@ -764,7 +1363,23 @@ impl Backend { /// Sets the coinbase address pub fn set_coinbase(&self, address: Address) { - self.env.write().evm_env.block_env.beneficiary = address; + self.evm_env.write().block_env.beneficiary = address; + } + + /// Sets the `prevrandao` value to use for the next mined block. + /// + /// This is a one-shot override that is consumed by the next block; afterwards anvil resumes its + /// default per-block `prevrandao` derivation. + pub fn set_next_block_prevrandao(&self, prevrandao: B256) { + self.cheats.set_next_block_prevrandao(prevrandao); + } + + /// Sets the parent beacon block root to use for the next mined block. + /// + /// This is a one-shot override that is consumed by the next block; afterwards anvil resumes + /// using the zero root. + pub fn set_next_block_parent_beacon_block_root(&self, root: B256) { + self.cheats.set_next_block_parent_beacon_block_root(root); } /// Sets the nonce of the given address @@ -777,9 +1392,16 @@ impl Backend { self.db.write().await.set_balance(address, balance) } + /// Increases the balance of the given address, saturating at `U256::MAX`. + pub(crate) async fn add_balance(&self, address: Address, balance: U256) -> DatabaseResult<()> { + let mut db = self.db.write().await; + let current_balance = db.basic(address)?.unwrap_or_default().balance; + db.set_balance(address, current_balance.saturating_add(balance)) + } + /// Sets the code of the given address pub async fn set_code(&self, address: Address, code: Bytes) -> DatabaseResult<()> { - self.db.write().await.set_code(address, code.0.into()) + self.db.write().await.set_code(address, code) } /// Sets the value for the given slot of the given address @@ -794,7 +1416,7 @@ impl Backend { /// Returns the configured specid pub fn spec_id(&self) -> SpecId { - self.env.read().evm_env.cfg_env.spec + *self.evm_env.read().spec_id() } /// Returns true for post London @@ -822,72 +1444,504 @@ impl Backend { (self.spec_id() as u8) >= (SpecId::PRAGUE as u8) } - /// Returns true if op-stack deposits are active - pub fn is_optimism(&self) -> bool { - self.env.read().is_optimism + /// Returns true if op-stack deposits are active. + /// + /// Always `false` when built without the `optimism` feature. + pub const fn is_optimism(&self) -> bool { + self.networks.is_optimism() } - /// Returns [`BlobParams`] corresponding to the current spec. - pub fn blob_params(&self) -> BlobParams { - let spec_id = self.env.read().evm_env.cfg_env.spec; - - if spec_id >= SpecId::OSAKA { - return BlobParams::osaka(); - } - - if spec_id >= SpecId::PRAGUE { - return BlobParams::prague(); - } + /// Returns true if Tempo network mode is active + pub const fn is_tempo(&self) -> bool { + self.networks.is_tempo() + } - BlobParams::cancun() + /// Returns whether Celo compatibility is enabled. + pub const fn is_celo(&self) -> bool { + self.networks.is_celo() } - /// Returns an error if EIP1559 is not active (pre Berlin) - pub fn ensure_eip1559_active(&self) -> Result<(), BlockchainError> { - if self.is_eip1559() { - return Ok(()); - } - Err(BlockchainError::EIP1559TransactionUnsupportedAtHardfork) + /// Returns true if Monad network mode is active + pub const fn is_monad(&self) -> bool { + self.networks.is_monad() } - /// Returns an error if EIP1559 is not active (pre muirGlacier) - pub fn ensure_eip2930_active(&self) -> Result<(), BlockchainError> { - if self.is_eip2930() { - return Ok(()); - } - Err(BlockchainError::EIP2930TransactionUnsupportedAtHardfork) + /// Returns the active execution profile name. + pub const fn execution_profile_name(&self) -> &'static str { + self.networks.execution_profile_name() } - pub fn ensure_eip4844_active(&self) -> Result<(), BlockchainError> { - if self.is_eip4844() { - return Ok(()); + /// Reconstructs a locally mined transaction using its authoritative stored sender. + fn pending_mined_transaction( + &self, + transaction: MaybeImpersonatedTransaction, + ) -> Result, BlockchainError> { + #[cfg(feature = "monad")] + if self.is_monad() { + return Self::monad_pending_mined_transaction_from_storage( + &self.blockchain.storage.read(), + transaction, + ); } - Err(BlockchainError::EIP4844TransactionUnsupportedAtHardfork) + Ok(PendingTransaction::from_maybe_impersonated(transaction)?) } - pub fn ensure_eip7702_active(&self) -> Result<(), BlockchainError> { - if self.is_eip7702() { - return Ok(()); - } - Err(BlockchainError::EIP7702TransactionUnsupportedAtHardfork) + #[cfg(not(feature = "monad"))] + fn active_monad_context_for_mined_block( + &self, + _block: &Block, + ) -> Result, BlockchainError> { + Ok(None) } - /// Returns an error if op-stack deposits are not active - pub fn ensure_op_deposits_active(&self) -> Result<(), BlockchainError> { + #[cfg(not(feature = "monad"))] + fn active_monad_context_before_mined_transaction( + &self, + _block: &Block, + _current_tx_index: usize, + ) -> Result, BlockchainError> { + Ok(None) + } + + /// Returns true if Base network mode is active. + #[cfg(feature = "base")] + pub const fn is_base(&self) -> bool { + self.networks.is_base() + } + + /// Returns the active hardfork. + pub fn hardfork(&self) -> FoundryHardfork { + if let Some(hardfork) = + self.fork.read().as_ref().and_then(|fork| fork.config.read().hardfork) + { + return hardfork; + } + *self.hardfork.read() + } + + /// Returns canonical Ethereum transition configuration only for an Ethereum network. + fn ethereum_block_transitions( + &self, + hardfork: FoundryHardfork, + parent_beacon_block_root: Option, + execution_kind: BlockExecutionKind, + ) -> Option { + if self.is_optimism() || self.is_tempo() { + return None; + } + let FoundryHardfork::Ethereum(hardfork) = hardfork else { return None }; + Some(EthereumBlockTransitions { + hardfork, + deposit_contract_address: self.ethereum_deposit_contract_address(), + parent_beacon_block_root, + execution_kind, + }) + } + + /// Returns the configured deposit contract, then the canonical address for known chains. + fn ethereum_deposit_contract_address(&self) -> Address { + if let Some(address) = self + .genesis + .genesis_init + .as_ref() + .and_then(|genesis| genesis.config.deposit_contract_address) + { + return address; + } + + match NamedChain::try_from(self.evm_env.read().cfg_env.chain_id) { + Ok(NamedChain::Sepolia) => SEPOLIA_DEPOSIT_CONTRACT_ADDRESS, + Ok(NamedChain::Holesky) => HOLESKY_DEPOSIT_CONTRACT_ADDRESS, + // Hoodi shares the mainnet address; other chains use Alloy's mainnet fallback. + _ => MAINNET_DEPOSIT_CONTRACT_ADDRESS, + } + } + + /// Returns the active Tempo hardfork. + pub fn tempo_hardfork(&self) -> TempoHardfork { + TempoHardfork::from(self.hardfork()) + } + + /// Returns the active Monad hardfork. + #[cfg(feature = "monad")] + pub fn monad_hardfork(&self) -> monad_revm::MonadHardfork { + monad_revm::MonadHardfork::from(self.hardfork()) + } + + /// Returns the active Base upgrade. + #[cfg(feature = "base")] + pub fn base_upgrade(&self) -> BaseUpgrade { + match self.hardfork() { + FoundryHardfork::Base(upgrade) => upgrade, + _ => BaseUpgrade::Azul, + } + } + + /// Returns the configured Base activation-registry administrator override. + #[cfg(feature = "base")] + pub const fn base_activation_admin(&self) -> Option
{ + self.base_activation_admin + } + + /// Returns the Base upgrade active at `timestamp`. + #[cfg(feature = "base")] + pub fn base_upgrade_at_timestamp(&self, timestamp: u64) -> BaseUpgrade { + if self.is_fork() { + BaseUpgrade::from_chain_and_timestamp(self.chain_id().to(), timestamp) + .unwrap_or_else(|| self.base_upgrade()) + } else { + self.base_upgrade() + } + } + + /// Prepares the clock for a candidate block without consuming its time controls. + fn prepare_block_timestamp( + &self, + db: &dyn revm::DatabaseRef, + parent_timestamp: u64, + pin_timestamp: bool, + ) -> Result { + #[cfg(feature = "base")] + if self.is_base() && self.base_upgrade() >= BaseUpgrade::Denim { + return base::prepare_block_timestamp(db, &self.time, parent_timestamp); + } + let _ = (db, parent_timestamp); + Ok(self.time.prepare_next_timestamp(pin_timestamp)) + } + + /// Builds the required deposits from the same parent state used to prepare the clock. + #[cfg(feature = "base")] + fn base_system_transactions( + &self, + db: &dyn revm::DatabaseRef, + block_number: u64, + timestamp: u64, + parent_hash: B256, + ) -> Result>>, DatabaseError> { + if self.is_base() && self.base_upgrade() >= BaseUpgrade::Denim { + base::system_transactions(db, block_number, timestamp, parent_hash, self.is_fork()) + } else { + Ok(Vec::new()) + } + } + + /// Returns the head timestamp snapshot used for EIP-8130 pool admission. + #[cfg(feature = "base")] + pub fn eip8130_pool_timestamp(&self) -> u64 { + self.evm_env.read().block_env.timestamp.saturating_to() + } + + /// Returns the EIP-8130 pool admission snapshot in Unix milliseconds. + /// + /// EIP-8130 validity windows are millisecond timestamps evaluated against + /// `block.timestamp * 1000`, so protocol validation must use this rather than the + /// second-valued block timestamp. + #[cfg(feature = "base")] + pub fn eip8130_pool_timestamp_ms(&self) -> u64 { + self.eip8130_pool_timestamp().saturating_mul(1_000) + } + + /// Returns whether a Tempo hardfork is active on this backend. + pub fn is_tempo_hardfork_active(&self, hardfork: TempoHardfork) -> bool { + self.is_tempo() && self.tempo_hardfork() >= hardfork + } + + /// Returns the precompiles for the current spec. + pub fn precompiles(&self) -> BTreeMap { + let spec_id = self.spec_id(); + let mut precompiles = + PrecompilesMap::from_static(Precompiles::new(PrecompileSpecId::from_spec_id(spec_id))); + let chain_id = self.protocol_chain_id(); + let timestamp = self.evm_env.read().block_env.timestamp.saturating_to(); + apply_bsc_p256_precompile(&mut precompiles, chain_id, timestamp); + + let mut precompiles_map = BTreeMap::::default(); + for address in precompiles.addresses() { + let precompile = precompiles.get(address).expect("precompile address must resolve"); + precompiles_map.insert(precompile.precompile_id().name().to_string(), *address); + } + + // Extend with configured network precompiles. + precompiles_map.extend(self.networks.precompiles(Some(self.hardfork()))); + + if let Some(factory) = &self.precompile_factory { + for (address, precompile) in factory.precompiles() { + precompiles_map.insert(precompile.precompile_id().to_string(), address); + } + } + + precompiles_map + } + + /// Returns whether the address is an active precompile in the given block environment. + pub fn is_precompile(&self, address: &Address, block_env: &BlockEnv) -> bool { + let mut evm_env = self.evm_env.read().clone(); + evm_env.block_env = block_env.clone(); + let mut precompiles = PrecompilesMap::from_static(Precompiles::new( + PrecompileSpecId::from_spec_id(self.spec_id()), + )); + self.inject_precompiles(&mut precompiles, &evm_env); + precompiles.get(address).is_some() + } + + /// Returns the system contracts for the current spec. + pub fn system_contracts(&self) -> BTreeMap { + let mut system_contracts = BTreeMap::::default(); + + let spec_id = self.spec_id(); + + if spec_id >= SpecId::CANCUN { + system_contracts.extend(SystemContract::cancun()); + } + + if spec_id >= SpecId::PRAGUE { + system_contracts.extend(SystemContract::prague(None)); + } + + system_contracts + } + + /// Returns the active [`BlobParams`]. + pub fn blob_params(&self) -> BlobParams { + self.fees.blob_params() + } + + fn simulation_blob_params_at_timestamp(&self, timestamp: u64) -> BlobParams { + let configured_hardfork = self.hardfork(); + if let FoundryHardfork::Ethereum( + configured + @ (EthereumHardfork::Osaka | EthereumHardfork::Bpo1 | EthereumHardfork::Bpo2), + ) = configured_hardfork + && let Some(hardfork) = FoundryHardfork::from_chain_and_timestamp( + self.evm_env.read().cfg_env.chain_id, + timestamp, + ) + && let FoundryHardfork::Ethereum( + scheduled @ (EthereumHardfork::Osaka + | EthereumHardfork::Bpo1 + | EthereumHardfork::Bpo2), + ) = hardfork + { + let hardfork = match (configured, scheduled) { + (EthereumHardfork::Bpo2, _) | (_, EthereumHardfork::Bpo2) => EthereumHardfork::Bpo2, + (EthereumHardfork::Bpo1, _) | (_, EthereumHardfork::Bpo1) => EthereumHardfork::Bpo1, + _ => EthereumHardfork::Osaka, + }; + return get_blob_params_by_hardfork(hardfork.into()); + } + get_blob_params_by_hardfork(configured_hardfork) + } + + #[cfg(feature = "optimism")] + fn is_optimism_jovian_at_header( + &self, + header: &H, + decoded: Option, + ) -> bool { + if !self.is_optimism() { + return false; + } + if let Some(jovian) = decoded { + return jovian; + } + if !header.extra_data().is_empty() { + return false; + } + let hardfork = if self.get_fork().is_some() { + NetworkVariant::Optimism + .historical_hardfork(self.protocol_chain_id(), header.timestamp()) + .unwrap_or_else(|| self.hardfork()) + } else { + self.hardfork() + }; + OpHardfork::from(hardfork) >= OpHardfork::Jovian + } + + #[cfg(not(feature = "optimism"))] + const fn is_optimism_jovian_at_header( + &self, + _header: &H, + _decoded: Option, + ) -> bool { + false + } + + /// Returns an error if EIP1559 is not active (pre Berlin) + pub fn ensure_eip1559_active(&self) -> Result<(), BlockchainError> { + if self.is_eip1559() { + return Ok(()); + } + Err(BlockchainError::EIP1559TransactionUnsupportedAtHardfork) + } + + /// Returns an error if EIP1559 is not active (pre muirGlacier) + pub fn ensure_eip2930_active(&self) -> Result<(), BlockchainError> { + if self.is_eip2930() { + return Ok(()); + } + Err(BlockchainError::EIP2930TransactionUnsupportedAtHardfork) + } + + pub fn ensure_eip4844_active(&self) -> Result<(), BlockchainError> { + if self.is_eip4844() { + return Ok(()); + } + Err(BlockchainError::EIP4844TransactionUnsupportedAtHardfork) + } + + pub fn ensure_eip7702_active(&self) -> Result<(), BlockchainError> { + if self.is_eip7702() { + return Ok(()); + } + Err(BlockchainError::EIP7702TransactionUnsupportedAtHardfork) + } + + /// Returns an error if deposit transactions are not active. + #[cfg(any(feature = "base", feature = "optimism"))] + pub const fn ensure_deposits_active(&self) -> Result<(), BlockchainError> { + #[cfg(feature = "base")] + if self.is_base() { + return Ok(()); + } + #[cfg(feature = "optimism")] if self.is_optimism() { return Ok(()); } Err(BlockchainError::DepositTransactionUnsupported) } + /// Returns an error if Base EIP-8130 transactions are not active. + #[cfg(feature = "base")] + pub fn ensure_base_eip8130_active(&self) -> Result<(), BlockchainError> { + let timestamp = self.evm_env.read().block_env.timestamp.saturating_to(); + self.ensure_base_eip8130_active_at(timestamp) + } + + /// Returns an error if Base EIP-8130 RPC features are not active at `timestamp`. + #[cfg(feature = "base")] + pub fn ensure_base_eip8130_active_at(&self, timestamp: u64) -> Result<(), BlockchainError> { + let upgrade = self.base_upgrade_at_timestamp(timestamp); + if self.is_base() && upgrade >= BaseUpgrade::Zenith { + return Ok(()); + } + Err(BlockchainError::InvalidTransactionRequest(EIP8130_PRE_ZENITH_RPC_ERROR.to_string())) + } + + /// Returns an admission error if Base EIP-8130 submissions are not active. + #[cfg(feature = "base")] + pub fn ensure_base_eip8130_submission_active(&self) -> Result<(), BlockchainError> { + if self.is_base() && self.base_upgrade() >= BaseUpgrade::Zenith { + return Ok(()); + } + Err(BlockchainError::Eip8130TransactionRejected(EIP8130_REJECTION_MSG.to_string())) + } + + /// Returns an error if Tempo transactions are not active + pub const fn ensure_tempo_active(&self) -> Result<(), BlockchainError> { + if self.is_tempo() { + return Ok(()); + } + Err(BlockchainError::TempoTransactionUnsupported) + } + + /// Returns an error unless CIP-64 native-fee compatibility is active. + pub fn ensure_cip64_active(&self) -> Result<(), BlockchainError> { + if !self.is_celo() { + return Err(BlockchainError::InvalidTransactionRequest( + "CIP-64 transactions require Celo mode (--celo)".into(), + )); + } + self.ensure_eip1559_active() + } + + /// Builds the [`InspectorTxConfig`] from the backend's current settings. + fn inspector_tx_config(&self) -> InspectorTxConfig { + InspectorTxConfig { + print_traces: self.print_traces, + print_logs: self.print_logs, + call_trace_decoder: self.call_trace_decoder(), + } + } + + /// Returns a trace decoder configured for the currently resolved hardfork. + fn call_trace_decoder(&self) -> Arc { + let hardfork = Some(self.networks.executed_hardfork(self.hardfork())); + let decoder = self.call_trace_decoder.read(); + if decoder.hardfork() == hardfork { + return Arc::clone(&decoder); + } + drop(decoder); + + let mut decoder = self.call_trace_decoder.write(); + let mut updated = decoder.as_ref().clone(); + updated.set_hardfork(hardfork); + *decoder = Arc::new(updated); + Arc::clone(&decoder) + } + + /// Builds the [`PoolTxGasConfig`] from the given EVM environment. + fn pool_tx_gas_config(&self, evm_env: &EvmEnv) -> PoolTxGasConfig { + let spec_id = *evm_env.spec_id(); + let is_cancun = spec_id >= SpecId::CANCUN; + let blob_params = self.blob_params(); + PoolTxGasConfig { + disable_block_gas_limit: evm_env.cfg_env.disable_block_gas_limit, + enforced_tx_gas_limit_cap: evm_env + .cfg_env + .tx_gas_limit_cap + .is_none() + .then(|| self.tx_gas_limit_cap(evm_env)), + max_blob_gas_per_block: blob_params.max_blob_gas_per_block(), + is_cancun, + } + } + + #[cfg(feature = "monad")] + fn monad_cfg_env(&self, evm_env: &EvmEnv) -> Option { + if !self.is_monad() { + return None; + } + + let hardfork = monad_revm::MonadHardfork::from(self.hardfork()); + Some(monad_revm::MonadCfgEnv::from(evm_env.cfg_env.clone().with_spec_and_gas_params( + hardfork, + monad_revm::instructions::monad_gas_params(hardfork), + ))) + } + + fn tx_gas_limit_cap(&self, evm_env: &EvmEnv) -> u64 { + #[cfg(feature = "monad")] + if let Some(cfg) = self.monad_cfg_env(evm_env) { + return cfg.tx_gas_limit_cap(); + } + evm_env.cfg_env.tx_gas_limit_cap() + } + + pub(crate) fn fallback_tx_gas_limit(&self, evm_env: &EvmEnv) -> u64 { + let block_gas_limit = evm_env.block_env.gas_limit; + if evm_env.cfg_env.tx_gas_limit_cap.is_none() { + block_gas_limit.min(self.tx_gas_limit_cap(evm_env)) + } else { + block_gas_limit + } + } + + fn max_initcode_size(&self, evm_env: &EvmEnv) -> usize { + #[cfg(feature = "monad")] + if let Some(cfg) = self.monad_cfg_env(evm_env) { + return cfg.max_initcode_size(); + } + evm_env.cfg_env.max_initcode_size() + } + /// Returns the block gas limit pub fn gas_limit(&self) -> u64 { - self.env.read().evm_env.block_env.gas_limit + self.evm_env.read().block_env.gas_limit } /// Sets the block gas limit pub fn set_gas_limit(&self, gas_limit: u64) { - self.env.write().evm_env.block_env.gas_limit = gas_limit; + self.evm_env.write().block_env.gas_limit = gas_limit; } /// Returns the current base fee @@ -896,7 +1950,7 @@ impl Backend { } /// Returns whether the minimum suggested priority fee is enforced - pub fn is_min_priority_fee_enforced(&self) -> bool { + pub const fn is_min_priority_fee_enforced(&self) -> bool { self.fees.is_min_priority_fee_enforced() } @@ -934,2213 +1988,7911 @@ impl Backend { let hash = self.best_hash(); let id = self.db.write().await.snapshot_state(); trace!(target: "backend", "creating snapshot {} at {}", id, num); - self.active_state_snapshots.lock().insert(id, (num, hash)); + self.active_state_snapshots.lock().insert( + id, + StateSnapshot { + block_number: num, + block_hash: hash, + fees: self.fees.snapshot(), + time: self.time.snapshot(), + next_block: self.cheats.next_block_overrides(), + }, + ); id } - /// Reverts the state to the state snapshot identified by the given `id`. - pub async fn revert_state_snapshot(&self, id: U256) -> Result { - let block = { self.active_state_snapshots.lock().remove(&id) }; - if let Some((num, hash)) = block { - let best_block_hash = { - // revert the storage that's newer than the snapshot - let current_height = self.best_number(); - let mut storage = self.blockchain.storage.write(); - - for n in ((num + 1)..=current_height).rev() { - trace!(target: "backend", "reverting block {}", n); - if let Some(hash) = storage.hashes.remove(&n) - && let Some(block) = storage.blocks.remove(&hash) - { - for tx in block.transactions { - let _ = storage.transactions.remove(&tx.hash()); - } - } - } - - storage.best_number = num; - storage.best_hash = hash; - hash - }; - let block = - self.block_by_hash(best_block_hash).await?.ok_or(BlockchainError::BlockNotFound)?; - - let reset_time = block.header.timestamp; - self.time.reset(reset_time); + pub fn list_state_snapshots(&self) -> BTreeMap { + self.active_state_snapshots + .lock() + .iter() + .map(|(&id, snapshot)| (id, (snapshot.block_number, snapshot.block_hash))) + .collect() + } - let mut env = self.env.write(); - env.evm_env.block_env = BlockEnv { - number: U256::from(num), - timestamp: U256::from(block.header.timestamp), - difficulty: block.header.difficulty, - // ensures prevrandao is set - prevrandao: Some(block.header.mix_hash.unwrap_or_default()), - gas_limit: block.header.gas_limit, - // Keep previous `beneficiary` and `basefee` value - beneficiary: env.evm_env.block_env.beneficiary, - basefee: env.evm_env.block_env.basefee, - ..Default::default() - } - } - Ok(self.db.write().await.revert_state(id, RevertStateSnapshotAction::RevertRemove)) + /// Returns the EVM-visible number for a locally mined RPC block. + fn evm_block_number(&self, rpc_number: u64) -> U256 { + self.get_fork() + .map_or_else(|| U256::from(rpc_number), |fork| fork.evm_block_number(rpc_number)) } - pub fn list_state_snapshots(&self) -> BTreeMap { - self.active_state_snapshots.lock().clone().into_iter().collect() + /// Restores the execution number separately from the RPC header number. + fn block_env_from_header(&self, header: &impl BlockHeader) -> BlockEnv { + let mut block = block_env_from_header::(header); + block.number = self.evm_block_number(header.number()); + if let Some(excess_blob_gas) = header.excess_blob_gas() { + block.set_blob_excess_gas_and_price( + excess_blob_gas, + self.blob_params().update_fraction as u64, + ); + } + block } - /// Get the current state. - pub async fn serialized_state( + /// Returns the environment for the next block + fn next_evm_env( &self, - preserve_historical_states: bool, - ) -> Result { - let at = self.env.read().evm_env.block_env.clone(); - let best_number = self.blockchain.storage.read().best_number; - let blocks = self.blockchain.storage.read().serialized_blocks(); - let transactions = self.blockchain.storage.read().serialized_transactions(); - let historical_states = if preserve_historical_states { - Some(self.states.write().serialized_states()) + db: &dyn revm::DatabaseRef, + ) -> Result { + let mut evm_env = self.evm_env.read().clone(); + // increase block number for this block + evm_env.block_env.number = evm_env.block_env.number.saturating_add(U256::ONE); + evm_env.block_env.basefee = self.base_fee(); + evm_env.block_env.blob_excess_gas_and_price = self.excess_blob_gas_and_price(); + let pending = + self.prepare_block_timestamp(db, evm_env.block_env.timestamp.saturating_to(), false)?; + evm_env.block_env.timestamp = U256::from(pending.timestamp); + Ok(evm_env) + } + + /// Returns the environment for replaying transactions from a historical block. + fn tx_replay_evm_env(&self, block: &Block) -> (EvmEnv, FoundryHardfork) { + let mut evm_env = self.evm_env.read().clone(); + evm_env.block_env = self.block_env_from_header(&block.header); + let hardfork = self.hardfork(); + #[cfg(feature = "monad")] + let hardfork = if self.is_monad() { + let block_hash = block.header.hash_slow(); + let fallback = crate::eth::backend::db::MonadBlockReplayProfile { + execution_chain_id: evm_env.cfg_env.chain_id, + hardfork: self.monad_hardfork(), + }; + let profile = self + .blockchain + .storage + .read() + .monad_block_replay_profiles + .get(&block_hash) + .copied() + .unwrap_or(fallback); + evm_env.cfg_env.chain_id = profile.execution_chain_id; + evm_env.cfg_env.spec = profile.hardfork.into(); + profile.hardfork.into() } else { - None + hardfork }; - - let state = self.db.read().await.dump_state( - at, - best_number, - blocks, - transactions, - historical_states, - )?; - state.ok_or_else(|| { - RpcError::invalid_params("Dumping state not supported with the current configuration") - .into() - }) + apply_chain_specific_tx_replay_env_changes_for_chain( + &mut evm_env, + self.protocol_chain_id(), + ); + (evm_env, hardfork) } - /// Write all chain data to serialized bytes buffer - pub async fn dump_state( + /// Creates the database and environment for replaying a locally mined block. + /// + /// An empty block execution applies protocol-level pre-execution changes, such as the + /// EIP-2935 parent hash system call, through the same network-specific executor used while + /// mining. + fn prepare_block_replay<'a>( &self, - preserve_historical_states: bool, - ) -> Result { - let state = self.serialized_state(preserve_historical_states).await?; - let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); - encoder - .write_all(&serde_json::to_vec(&state).unwrap_or_default()) - .map_err(|_| BlockchainError::DataUnavailable)?; - Ok(encoder.finish().unwrap_or_default().into()) + block: &Block, + parent_state: &'a StateDb, + ) -> Result<(CacheDB<&'a StateDb>, EvmEnv, FoundryHardfork), BlockchainError> { + self.prepare_block_replay_with_db(block, parent_state) } - /// Apply [SerializableState] data to the backend storage. - pub async fn load_state(&self, state: SerializableState) -> Result { - // load the blocks and transactions into the storage - self.blockchain.storage.write().load_blocks(state.blocks.clone()); - self.blockchain.storage.write().load_transactions(state.transactions.clone()); - // reset the block env - if let Some(block) = state.block.clone() { - self.env.write().evm_env.block_env = block.clone(); - - // Set the current best block number. - // Defaults to block number for compatibility with existing state files. - let fork_num_and_hash = self.get_fork().map(|f| (f.block_number(), f.block_hash())); + /// Creates an overlay and applies block-start transitions for a locally mined block replay. + fn prepare_block_replay_with_db( + &self, + block: &Block, + db: DB, + ) -> Result<(CacheDB, EvmEnv, FoundryHardfork), BlockchainError> + where + DB: DatabaseRef + Debug, + { + let (evm_env, hardfork) = self.tx_replay_evm_env(block); + let mut cache_db = AnvilCacheDB::new(db, *evm_env.spec_id()); + let inspector_tx_config = self.inspector_tx_config(); + let gas_config = self.pool_tx_gas_config(&evm_env); + + self.execute_with_block_executor( + &mut cache_db, + &evm_env, + block.header.parent_hash, + hardfork, + block.header.parent_beacon_block_root, + BlockExecutionKind::TransactionPrefix, + &[], + &gas_config, + &inspector_tx_config, + &|_, _| Ok(()), + )?; - let best_number = state.best_block_number.unwrap_or(block.number.saturating_to()); - if let Some((number, hash)) = fork_num_and_hash { - trace!(target: "backend", state_block_number=?best_number, fork_block_number=?number); - // If the state.block_number is greater than the fork block number, set best number - // to the state block number. - // Ref: https://github.com/foundry-rs/foundry/issues/9539 - if best_number > number { - self.blockchain.storage.write().best_number = best_number; - let best_hash = - self.blockchain.storage.read().hash(best_number.into()).ok_or_else( - || { - BlockchainError::RpcError(RpcError::internal_error_with(format!( - "Best hash not found for best number {best_number}", - ))) - }, - )?; - self.blockchain.storage.write().best_hash = best_hash; - } else { - // If loading state file on a fork, set best number to the fork block number. - // Ref: https://github.com/foundry-rs/foundry/pull/9215#issue-2618681838 - self.blockchain.storage.write().best_number = number; - self.blockchain.storage.write().best_hash = hash; - } - } else { - self.blockchain.storage.write().best_number = best_number; - - // Set the current best block hash; - let best_hash = - self.blockchain.storage.read().hash(best_number.into()).ok_or_else(|| { - BlockchainError::RpcError(RpcError::internal_error_with(format!( - "Best hash not found for best number {best_number}", - ))) - })?; + Ok((cache_db.0, evm_env, hardfork)) + } - self.blockchain.storage.write().best_hash = best_hash; - } + /// Replays the stored transaction prefix `[0, end)` into an existing block overlay. + fn replay_mined_transaction_prefix( + &self, + cache_db: &mut CacheDB, + evm_env: &EvmEnv, + hardfork: FoundryHardfork, + block: &Block, + end: usize, + ) -> Result<(), BlockchainError> + where + DB: DatabaseRef + Debug, + { + let monad_context = self.active_monad_context_for_mined_block(block)?; + for (index, transaction) in block.body.transactions[..end].iter().enumerate() { + let pending = self.pending_mined_transaction(transaction.clone())?; + let mut inspector = AnvilInspector::default(); + let transaction_context = monad_execution_context_at(monad_context.as_ref(), index); + let (result, _) = self.replay_envelope_with_inspector_ref_and_context( + cache_db, + evm_env, + &mut inspector, + &pending, + EnvelopeExecution::replay(transaction_context, hardfork), + )?; + cache_db.commit(result.state); } + Ok(()) + } - if let Some(latest) = state.blocks.iter().max_by_key(|b| b.header.number) { - let header = &latest.header; - let next_block_base_fee = self.fees.get_next_block_base_fee_per_gas( - header.gas_used, - header.gas_limit, - header.base_fee_per_gas.unwrap_or_default(), - ); - let next_block_excess_blob_gas = self.fees.get_next_block_blob_excess_gas( - header.excess_blob_gas.unwrap_or_default(), - header.blob_gas_used.unwrap_or_default(), - ); + /// Builds [`Inspector`] with the configured options. + fn build_inspector(&self) -> AnvilInspector { + let mut inspector = AnvilInspector::default(); - // update next base fee - self.fees.set_base_fee(next_block_base_fee); + if self.print_logs { + inspector = inspector.with_log_collector(); + } + if self.print_traces { + inspector = inspector.with_trace_printer(); + } - self.fees.set_blob_excess_gas_and_price(BlobExcessGasAndPrice::new( - next_block_excess_blob_gas, - get_blob_base_fee_update_fraction( - self.env.read().evm_env.cfg_env.chain_id, - header.timestamp, - ), - )); - } + inspector + } - if !self.db.write().await.load_state(state.clone())? { - return Err(RpcError::invalid_params( - "Loading state not supported with the current configuration", - ) - .into()); + /// Builds an inspector configured for block mining (tracing always enabled). + fn build_mining_inspector(&self) -> AnvilInspector { + let mut inspector = AnvilInspector::default().with_tracing(); + if self.print_logs { + inspector = inspector.with_log_collector(); } - - if let Some(historical_states) = state.historical_states { - self.states.write().load_states(historical_states); + if self.print_traces { + inspector = inspector.with_trace_printer(); } + inspector + } - Ok(true) + /// Returns a new block event stream that yields Notifications when a new block was added or + /// when logs were removed from the canonical chain due to a reorg + pub fn new_block_notifications(&self) -> ChainNotifications { + let (tx, rx) = unbounded(); + self.new_block_listeners.lock().push(tx); + trace!(target: "backed", "added new block listener"); + rx } - /// Deserialize and add all chain data to the backend storage - pub async fn load_state_bytes(&self, buf: Bytes) -> Result { - let orig_buf = &buf.0[..]; - let mut decoder = GzDecoder::new(orig_buf); - let mut decoded_data = Vec::new(); + /// Returns the number of new-block listeners. Closed listeners are pruned lazily on the next + /// new block notification. + pub fn new_block_listeners_count(&self) -> usize { + self.new_block_listeners.lock().len() + } - let state: SerializableState = serde_json::from_slice(if decoder.header().is_some() { - decoder - .read_to_end(decoded_data.as_mut()) - .map_err(|_| BlockchainError::FailedToDecodeStateDump)?; - &decoded_data - } else { - &buf.0 - }) - .map_err(|_| BlockchainError::FailedToDecodeStateDump)?; + /// Notifies all `new_block_listeners` about the new block + fn notify_on_new_block(&self, header: Header, hash: B256) { + // cleanup closed notification streams first, if the channel is closed we can remove the + // sender half for the set + self.new_block_listeners.lock().retain(|tx| !tx.is_closed()); - self.load_state(state).await - } + let notification = + ChainNotification::Block(NewBlockNotification { hash, header: Arc::new(header) }); - /// Returns the environment for the next block - fn next_env(&self) -> Env { - let mut env = self.env.read().clone(); - // increase block number for this block - env.evm_env.block_env.number = env.evm_env.block_env.number.saturating_add(U256::from(1)); - env.evm_env.block_env.basefee = self.base_fee(); - env.evm_env.block_env.timestamp = U256::from(self.time.current_call_timestamp()); - env + self.new_block_listeners + .lock() + .retain(|tx| tx.unbounded_send(notification.clone()).is_ok()); } - /// Creates an EVM instance with optionally injected precompiles. - fn new_evm_with_inspector_ref<'db, I, DB>( - &self, - db: &'db DB, - env: &Env, - inspector: &'db mut I, - ) -> EitherEvm, &'db mut I, PrecompilesMap> - where - DB: DatabaseRef + ?Sized, - I: Inspector>> - + Inspector>>, - WrapDatabaseRef<&'db DB>: Database, - { - let mut evm = new_evm_with_inspector_ref(db, env, inspector); - - if self.odyssey { - inject_precompiles(&mut evm, vec![(P256VERIFY, P256VERIFY_BASE_GAS_FEE)]); - } + /// Notifies all `new_block_listeners` about the logs that were removed from the canonical + /// chain due to a reorg. + fn notify_on_removed_logs(&self, logs: Vec) { + // cleanup closed notification streams first, if the channel is closed we can remove the + // sender half for the set + self.new_block_listeners.lock().retain(|tx| !tx.is_closed()); - if let Some(factory) = &self.precompile_factory { - inject_precompiles(&mut evm, factory.precompiles()); - } + let notification = ChainNotification::RemovedLogs(Arc::new(logs)); - evm + self.new_block_listeners + .lock() + .retain(|tx| tx.unbounded_send(notification.clone()).is_ok()); } - /// executes the transactions without writing to the underlying database - pub async fn inspect_tx( - &self, - tx: Arc, - ) -> Result< - (InstructionResult, Option, u64, State, Vec), - BlockchainError, - > { - let mut env = self.next_env(); - env.tx = tx.pending_transaction.to_revm_tx_env(); - - if env.is_optimism { - env.tx.enveloped_tx = - Some(alloy_rlp::encode(&tx.pending_transaction.transaction.transaction).into()); + /// Returns the block number for the given block id + pub fn convert_block_number(&self, block: Option) -> u64 { + let current = self.best_number(); + // The chain starts at the configured genesis number, so `earliest` and the epoch-based + // tags never resolve below it. + let genesis = self.genesis_number(); + match block.unwrap_or(BlockNumber::Latest) { + BlockNumber::Latest | BlockNumber::Pending => current, + BlockNumber::Earliest => genesis, + BlockNumber::Number(num) => num, + BlockNumber::Safe => current.saturating_sub(self.slots_in_an_epoch).max(genesis), + BlockNumber::Finalized => { + current.saturating_sub(self.slots_in_an_epoch * 2).max(genesis) + } } + } - let db = self.db.read().await; - let mut inspector = self.build_inspector(); - let mut evm = self.new_evm_with_inspector_ref(&**db, &env, &mut inspector); - let ResultAndState { result, state } = evm.transact(env.tx)?; - let (exit_reason, gas_used, out, logs) = match result { - ExecutionResult::Success { reason, gas_used, logs, output, .. } => { - (reason.into(), gas_used, Some(output), Some(logs)) - } - ExecutionResult::Revert { gas_used, output } => { - (InstructionResult::Revert, gas_used, Some(Output::Call(output)), None) - } - ExecutionResult::Halt { reason, gas_used } => { - let eth_reason = op_haltreason_to_instruction_result(reason); - (eth_reason, gas_used, None, None) - } - }; + /// Returns the canonical hash for the given block number. + pub(crate) fn block_hash_by_number(&self, number: u64) -> Option { + self.blockchain.hash(BlockId::number(number), self.slots_in_an_epoch) + } - drop(evm); - inspector.print_logs(); + /// Returns the block and its hash for the given id + pub(crate) fn get_block_with_hash(&self, id: impl Into) -> Option<(Block, B256)> { + let hash = self.blockchain.hash(id.into(), self.slots_in_an_epoch)?; + let block = self.get_block_by_hash(hash)?; + Some((block, hash)) + } - if self.print_traces { - inspector.print_traces(); - } + pub fn get_block(&self, id: impl Into) -> Option { + self.get_block_with_hash(id).map(|(block, _)| block) + } - Ok((exit_reason, out, gas_used, state, logs.unwrap_or_default())) + pub fn get_block_by_hash(&self, hash: B256) -> Option { + self.blockchain.get_block_by_hash(&hash) } - /// Creates the pending block + /// Returns the base fees for the block after a fee history range. /// - /// This will execute all transaction in the order they come but will not mine the block - pub async fn pending_block(&self, pool_transactions: Vec>) -> BlockInfo { - self.with_pending_block(pool_transactions, |_, block| block).await + /// Mining publishes a new canonical block before advancing the fee manager. Holding the mining + /// lock makes choosing between an existing child and the current head's pending fees atomic + /// with that publication sequence. + pub(crate) async fn fee_history_next_fees(&self, highest: u64) -> Option<(u128, u128)> { + let _mining_guard = self.mining.lock().await; + let next_number = highest.checked_add(1)?; + if let Some(block) = self.get_block(next_number) { + Some(( + block.header.base_fee_per_gas.unwrap_or_default() as u128, + block.header.blob_fee(self.blob_params()).unwrap_or_default(), + )) + } else if highest == self.best_number() { + Some((self.fees().base_fee() as u128, self.fees().base_fee_per_blob_gas())) + } else { + None + } } - /// Creates the pending block - /// - /// This will execute all transaction in the order they come but will not mine the block - pub async fn with_pending_block( + /// Returns the traces for the given transaction + pub(crate) fn mined_parity_trace_transaction( &self, - pool_transactions: Vec>, - f: F, - ) -> T - where - F: FnOnce(Box, BlockInfo) -> T, - { - let db = self.db.read().await; - let env = self.next_env(); - - let mut cache_db = CacheDB::new(&*db); + hash: B256, + ) -> Option> { + self.blockchain.storage.read().transactions.get(&hash).map(|tx| tx.parity_traces()) + } + /// Returns the traces for the given block + pub(crate) fn mined_parity_trace_block( + &self, + block: u64, + ) -> Option> { + let block = self.get_block(block)?; + let mut traces = vec![]; let storage = self.blockchain.storage.read(); + for tx in block.body.transactions { + if let Some(mined_tx) = storage.transactions.get(&tx.hash()) { + traces.extend(mined_tx.parity_traces()); + } + } + Some(traces) + } - let executor = TransactionExecutor { - db: &mut cache_db, - validator: self, - pending: pool_transactions.into_iter(), - block_env: env.evm_env.block_env.clone(), - cfg_env: env.evm_env.cfg_env, - parent_hash: storage.best_hash, - gas_used: 0, - blob_gas_used: 0, - enable_steps_tracing: self.enable_steps_tracing, - print_logs: self.print_logs, - print_traces: self.print_traces, - precompile_factory: self.precompile_factory.clone(), - odyssey: self.odyssey, - optimism: self.is_optimism(), - blob_params: self.blob_params(), - }; - - // create a new pending block - let executed = executor.execute(); - f(Box::new(cache_db), executed.block) + /// Returns the mined transaction for the given hash + pub(crate) fn mined_transaction(&self, hash: B256) -> Option> { + self.blockchain.storage.read().transactions.get(&hash).cloned() } - /// Mines a new block and stores it. - /// - /// this will execute all transaction in the order they come in and return all the markers they - /// provide. - pub async fn mine_block( + /// Overrides the given signature to impersonate the specified address during ecrecover. + pub async fn impersonate_signature( &self, - pool_transactions: Vec>, - ) -> MinedBlockOutcome { - self.do_mine_block(pool_transactions).await + signature: Bytes, + address: Address, + ) -> Result<(), BlockchainError> { + self.cheats.add_recover_override(signature, address); + Ok(()) } - async fn do_mine_block( + /// Returns code by its hash + pub async fn debug_code_by_hash( &self, - pool_transactions: Vec>, - ) -> MinedBlockOutcome { - let _mining_guard = self.mining.lock().await; - trace!(target: "backend", "creating new block with {} transactions", pool_transactions.len()); + code_hash: B256, + block_id: Option, + ) -> Result, BlockchainError> { + if let Ok(code) = self.db.read().await.code_by_hash_ref(code_hash) { + return Ok(Some(code.original_bytes())); + } + if let Some(fork) = self.get_fork() { + return Ok(fork.debug_code_by_hash(code_hash, block_id).await?); + } - let (outcome, header, block_hash) = { - let current_base_fee = self.base_fee(); - let current_excess_blob_gas_and_price = self.excess_blob_gas_and_price(); + Ok(None) + } - let mut env = self.env.read().clone(); + /// Returns the value associated with a key from the database + /// Currently only supports bytecode lookups. + /// + /// Based on Reth implementation: + /// + /// Key should be: 0x63 (1-byte prefix) + 32 bytes (code_hash) + /// Total key length must be 33 bytes. + pub async fn debug_db_get(&self, key: String) -> Result, BlockchainError> { + let key_bytes = if key.starts_with("0x") { + hex::decode(&key) + .map_err(|_| BlockchainError::Message("Invalid hex key".to_string()))? + } else { + key.into_bytes() + }; - if env.evm_env.block_env.basefee == 0 { - // this is an edge case because the evm fails if `tx.effective_gas_price < base_fee` - // 0 is only possible if it's manually set - env.evm_env.cfg_env.disable_base_fee = true; - } + // Validate key length: must be 33 bytes (1 byte prefix + 32 bytes code hash) + if key_bytes.len() != 33 { + return Err(BlockchainError::Message(format!( + "Invalid key length: expected 33 bytes, got {}", + key_bytes.len() + ))); + } - let block_number = self.blockchain.storage.read().best_number.saturating_add(1); + // Check for bytecode prefix (0x63 = 'c' in ASCII) + if key_bytes[0] != 0x63 { + return Err(BlockchainError::Message( + "Key prefix must be 0x63 for code hash lookups".to_string(), + )); + } - // increase block number for this block - if is_arbitrum(env.evm_env.cfg_env.chain_id) { - // Temporary set `env.block.number` to `block_number` for Arbitrum chains. - env.evm_env.block_env.number = U256::from(block_number); - } else { - env.evm_env.block_env.number = - env.evm_env.block_env.number.saturating_add(U256::from(1)); - } + let code_hash = B256::from_slice(&key_bytes[1..33]); - env.evm_env.block_env.basefee = current_base_fee; - env.evm_env.block_env.blob_excess_gas_and_price = current_excess_blob_gas_and_price; + // Use the existing debug_code_by_hash method to retrieve the bytecode + self.debug_code_by_hash(code_hash, None).await + } - // pick a random value for prevrandao - env.evm_env.block_env.prevrandao = Some(B256::random()); + fn mined_block_by_hash(&self, hash: B256) -> Option { + let block = self.blockchain.get_block_by_hash(&hash)?; + Some(self.convert_block_with_hash(block, Some(hash))) + } - let best_hash = self.blockchain.storage.read().best_hash; + pub(crate) async fn mined_transactions_by_block_number( + &self, + number: BlockNumber, + ) -> Option> { + if let Some(block) = self.get_block(number) { + return self.mined_transactions_in_block(&block); + } + None + } - if self.prune_state_history_config.is_state_history_supported() { - let db = self.db.read().await.current_state(); - // store current state before executing all transactions - self.states.write().insert(best_hash, db); - } + /// Returns all transactions given a block + pub(crate) fn mined_transactions_in_block( + &self, + block: &Block, + ) -> Option> { + let mut transactions = Vec::with_capacity(block.body.transactions.len()); + let base_fee = block.header.base_fee_per_gas(); + let storage = self.blockchain.storage.read(); + for hash in block.body.transactions.iter().map(|tx| tx.hash()) { + let info = storage.transactions.get(&hash)?.info.clone(); + let tx = block.body.transactions.get(info.transaction_index as usize)?.clone(); - let (executed_tx, block_hash) = { - let mut db = self.db.write().await; + let tx = transaction_build(Some(hash), tx, Some(block), Some(info), base_fee); + transactions.push(tx); + } + Some(transactions) + } - // finally set the next block timestamp, this is done just before execution, because - // there can be concurrent requests that can delay acquiring the db lock and we want - // to ensure the timestamp is as close as possible to the actual execution. - env.evm_env.block_env.timestamp = U256::from(self.time.next_timestamp()); - - let executor = TransactionExecutor { - db: &mut **db, - validator: self, - pending: pool_transactions.into_iter(), - block_env: env.evm_env.block_env.clone(), - cfg_env: env.evm_env.cfg_env.clone(), - parent_hash: best_hash, - gas_used: 0, - blob_gas_used: 0, - enable_steps_tracing: self.enable_steps_tracing, - print_logs: self.print_logs, - print_traces: self.print_traces, - odyssey: self.odyssey, - precompile_factory: self.precompile_factory.clone(), - optimism: self.is_optimism(), - blob_params: self.blob_params(), - }; - let executed_tx = executor.execute(); + pub fn mined_block_by_number(&self, number: BlockNumber) -> Option { + let (block, hash) = self.get_block_with_hash(number)?; + let mut block = self.convert_block_with_hash(block, Some(hash)); + block.transactions.convert_to_hashes(); + Some(block) + } - // we also need to update the new blockhash in the db itself - let block_hash = executed_tx.block.block.header.hash_slow(); - db.insert_block_hash(U256::from(executed_tx.block.block.header.number), block_hash); + pub fn get_full_block(&self, id: impl Into) -> Option { + let (block, hash) = self.get_block_with_hash(id)?; + let transactions = self.mined_transactions_in_block(&block)?; + let mut block = self.convert_block_with_hash(block, Some(hash)); + block.inner.transactions = BlockTransactions::Full(transactions); + Some(block) + } - (executed_tx, block_hash) - }; + /// Takes a block as it's stored internally and returns the eth api conform block format. + pub fn convert_block(&self, block: Block) -> AnyRpcBlock { + self.convert_block_with_hash(block, None) + } - // create the new block with the current timestamp - let ExecutedTransactions { block, included, invalid } = executed_tx; - let BlockInfo { block, transactions, receipts } = block; + /// Takes a block as it's stored internally and returns the eth api conform block format. + /// If `known_hash` is provided, it will be used instead of computing `hash_slow()`. + pub fn convert_block_with_hash(&self, block: Block, known_hash: Option) -> AnyRpcBlock { + let transactions = block.body.transactions.iter().map(|tx| tx.hash()).collect(); + let block = canonical_block(block); + let size = U256::from(block.length() as u32); + let header = block.header; + + let hash = known_hash.unwrap_or_else(|| header.hash_slow()); + let number = header.number(); + let withdrawals_root = header.withdrawals_root(); + let tempo_fields = header + .as_tempo() + .map(|header| { + ( + header.timestamp_millis(), + header.general_gas_limit, + header.shared_gas_limit, + header.timestamp_millis_part, + ) + }) + .or_else(|| { + self.is_tempo() + .then(|| (header.timestamp().saturating_mul(1000), header.gas_limit(), 0, 0)) + }); - let header = block.header.clone(); + let block = AlloyBlock { + header: AlloyHeader { + inner: AnyHeader::from(header.into_inner()), + hash, + total_difficulty: Some(self.total_difficulty()), + size: Some(size), + }, + transactions: alloy_rpc_types::BlockTransactions::Hashes(transactions), + uncles: vec![], + withdrawals: withdrawals_root.map(|_| Default::default()), + }; - trace!( - target: "backend", - "Mined block {} with {} tx {:?}", - block_number, - transactions.len(), - transactions.iter().map(|tx| tx.transaction_hash).collect::>() + let mut block = WithOtherFields::new(block); + + // If Arbitrum, apply chain specifics to converted block. + if is_arbitrum(self.protocol_chain_id()) { + // Set `l1BlockNumber` field. + block.other.insert( + "l1BlockNumber".to_string(), + serde_json::json!(self.evm_block_number(number)), ); - let mut storage = self.blockchain.storage.write(); - // update block metadata - storage.best_number = block_number; - storage.best_hash = block_hash; - // Difficulty is removed and not used after Paris (aka TheMerge). Value is replaced with - // prevrandao. https://github.com/bluealloy/revm/blob/1839b3fce8eaeebb85025576f2519b80615aca1e/crates/interpreter/src/instructions/host_env.rs#L27 - if !self.is_eip3675() { - storage.total_difficulty = - storage.total_difficulty.saturating_add(header.difficulty); - } + } - storage.blocks.insert(block_hash, block); - storage.hashes.insert(block_number, block_hash); + if let Some(( + timestamp_millis, + general_gas_limit, + shared_gas_limit, + timestamp_millis_part, + )) = tempo_fields + { + block.other.insert( + "timestampMillis".to_string(), + serde_json::Value::String(format!("0x{timestamp_millis:x}")), + ); + block.other.insert( + "mainBlockGeneralGasLimit".to_string(), + serde_json::Value::String(format!("0x{general_gas_limit:x}")), + ); + block.other.insert( + "sharedGasLimit".to_string(), + serde_json::Value::String(format!("0x{shared_gas_limit:x}")), + ); + block.other.insert( + "timestampMillisPart".to_string(), + serde_json::Value::String(format!("0x{timestamp_millis_part:x}")), + ); + } - node_info!(""); - // insert all transactions - for (info, receipt) in transactions.into_iter().zip(receipts) { - // log some tx info - node_info!(" Transaction: {:?}", info.transaction_hash); - if let Some(contract) = &info.contract_address { - node_info!(" Contract created: {contract}"); - } - node_info!(" Gas used: {}", receipt.cumulative_gas_used()); - if !info.exit.is_ok() { - let r = RevertDecoder::new().decode( - info.out.as_ref().map(|b| &b[..]).unwrap_or_default(), - Some(info.exit), - ); - node_info!(" Error: reverted with: {r}"); - } - node_info!(""); + AnyRpcBlock::from(block) + } - let mined_tx = MinedTransaction { info, receipt, block_hash, block_number }; - storage.transactions.insert(mined_tx.info.transaction_hash, mined_tx); - } + pub async fn block_by_hash(&self, hash: B256) -> Result, BlockchainError> { + trace!(target: "backend", "get block by hash {:?}", hash); + if let tx @ Some(_) = self.mined_block_by_hash(hash) { + return Ok(tx); + } - // remove old transactions that exceed the transaction block keeper - if let Some(transaction_block_keeper) = self.transaction_block_keeper - && storage.blocks.len() > transaction_block_keeper - { - let to_clear = block_number - .saturating_sub(transaction_block_keeper.try_into().unwrap_or(u64::MAX)); - storage.remove_block_transactions_by_number(to_clear) - } + if let Some(fork) = self.get_fork() { + return Ok(fork.block_by_hash(hash).await?); + } - // we intentionally set the difficulty to `0` for newer blocks - env.evm_env.block_env.difficulty = U256::from(0); + Ok(None) + } - // update env with new values - *self.env.write() = env; + pub async fn block_by_hash_full( + &self, + hash: B256, + ) -> Result, BlockchainError> { + trace!(target: "backend", "get block by hash {:?}", hash); + if let tx @ Some(_) = self.get_full_block(hash) { + return Ok(tx); + } - let timestamp = utc_from_secs(header.timestamp); + if let Some(fork) = self.get_fork() { + return Ok(fork.block_by_hash_full(hash).await?); + } - node_info!(" Block Number: {}", block_number); - node_info!(" Block Hash: {:?}", block_hash); - if timestamp.year() > 9999 { - // rf2822 panics with more than 4 digits - node_info!(" Block Time: {:?}\n", timestamp.to_rfc3339()); - } else { - node_info!(" Block Time: {:?}\n", timestamp.to_rfc2822()); - } + Ok(None) + } - let outcome = MinedBlockOutcome { block_number, included, invalid }; + pub async fn block_by_number( + &self, + number: BlockNumber, + ) -> Result, BlockchainError> { + trace!(target: "backend", "get block by number {:?}", number); + if let tx @ Some(_) = self.mined_block_by_number(number) { + return Ok(tx); + } - (outcome, header, block_hash) - }; - let next_block_base_fee = self.fees.get_next_block_base_fee_per_gas( - header.gas_used, - header.gas_limit, - header.base_fee_per_gas.unwrap_or_default(), - ); - let next_block_excess_blob_gas = self.fees.get_next_block_blob_excess_gas( - header.excess_blob_gas.unwrap_or_default(), - header.blob_gas_used.unwrap_or_default(), - ); + if let Some(fork) = self.get_fork() { + let number = self.convert_block_number(Some(number)); + if fork.predates_fork_inclusive(number) { + return Ok(fork.block_by_number(number).await?); + } + } - // update next base fee - self.fees.set_base_fee(next_block_base_fee); + Ok(None) + } - self.fees.set_blob_excess_gas_and_price(BlobExcessGasAndPrice::new( - next_block_excess_blob_gas, - get_blob_base_fee_update_fraction_by_spec_id(*self.env.read().evm_env.spec_id()), - )); + pub async fn block_by_number_full( + &self, + number: BlockNumber, + ) -> Result, BlockchainError> { + trace!(target: "backend", "get block by number {:?}", number); + if let tx @ Some(_) = self.get_full_block(number) { + return Ok(tx); + } - // notify all listeners - self.notify_on_new_block(header, block_hash); + if let Some(fork) = self.get_fork() { + let number = self.convert_block_number(Some(number)); + if fork.predates_fork_inclusive(number) { + return Ok(fork.block_by_number_full(number).await?); + } + } - outcome + Ok(None) } - /// Executes the [TransactionRequest] without writing to the DB + /// Converts the `BlockNumber` into a numeric value /// /// # Errors /// - /// Returns an error if the `block_number` is greater than the current height - pub async fn call( + /// returns an error if the requested number is larger than the current height + pub async fn ensure_block_number>( &self, - request: WithOtherFields, - fee_details: FeeDetails, - block_request: Option, - overrides: EvmOverrides, - ) -> Result<(InstructionResult, Option, u128, State), BlockchainError> { - self.with_database_at(block_request, |state, mut block| { - let block_number = block.number; - let (exit, out, gas, state) = { - let mut cache_db = CacheDB::new(state); - if let Some(state_overrides) = overrides.state { - state::apply_state_overrides(state_overrides.into_iter().collect(), &mut cache_db)?; - } - if let Some(block_overrides) = overrides.block { - state::apply_block_overrides(*block_overrides, &mut cache_db, &mut block); - } - self.call_with_state(&cache_db as &dyn DatabaseRef, request, fee_details, block) - }?; - trace!(target: "backend", "call return {:?} out: {:?} gas {} on block {}", exit, out, gas, block_number); - Ok((exit, out, gas, state)) - }).await? + block_id: Option, + ) -> Result { + let current = self.best_number(); + let requested = match block_id.map(Into::into).unwrap_or(BlockId::latest()) { + BlockId::Hash(hash) => { + self.block_by_hash(hash.block_hash) + .await? + .ok_or(BlockchainError::BlockNotFound)? + .header + .number + } + BlockId::Number(num) => self.convert_block_number(Some(num)), + }; + + if requested > current { + Err(BlockchainError::BlockOutOfRange(current, requested)) + } else { + Ok(requested) + } } - /// ## EVM settings - /// - /// This modifies certain EVM settings to mirror geth's `SkipAccountChecks` when transacting requests, see also: : - /// - /// - `disable_eip3607` is set to `true` - /// - `disable_base_fee` is set to `true` - /// - `nonce` check is skipped if `request.nonce` is None - fn build_call_env( + /// Returns the timestamp represented by a block request. + #[cfg(feature = "base")] + pub async fn block_request_timestamp( &self, - request: WithOtherFields, - fee_details: FeeDetails, - block_env: BlockEnv, - ) -> Env { - let tx_type = request.minimal_tx_type() as u8; - - let WithOtherFields:: { - inner: - TransactionRequest { - from, - to, - gas, - value, - input, - access_list, - blob_versioned_hashes, - authorization_list, - nonce, - sidecar: _, - chain_id, - transaction_type, - .. // Rest of the gas fees related fields are taken from `fee_details` - }, - other, - } = request; - - let FeeDetails { - gas_price, - max_fee_per_gas, - max_priority_fee_per_gas, - max_fee_per_blob_gas, - } = fee_details; + block_request: &BlockRequest, + ) -> Result { + match block_request { + BlockRequest::Pending(_) => Ok(self.time.current_call_timestamp()), + BlockRequest::Number(number) => self + .block_by_number(BlockNumber::Number(*number)) + .await? + .map(|block| block.header.timestamp()) + .ok_or(BlockchainError::BlockNotFound), + } + } - let gas_limit = gas.unwrap_or(block_env.gas_limit); - let mut env = self.env.read().clone(); - env.evm_env.block_env = block_env; - // we want to disable this in eth_call, since this is common practice used by other node - // impls and providers - env.evm_env.cfg_env.disable_block_gas_limit = true; + /// Injects all configured precompiles into the given precompile map. + /// + /// This applies five layers: + /// 1. Network-specific precompiles (e.g. Tempo, OP) + /// 2. Chain- and timestamp-specific precompiles + /// 3. User-provided precompiles via [`PrecompileFactory`] + /// 4. Cheatcode ecrecover overrides (if active) + /// 5. Block-specific precompiles (e.g. ArbSys) + fn inject_precompiles(&self, precompiles: &mut PrecompilesMap, evm_env: &EvmEnv) { + self.inject_configured_precompiles(precompiles, evm_env); + + if let Some(block_number) = self.arbitrum_block_number(evm_env) { + self.inject_arbitrum_precompile_at_block(precompiles, block_number); + } + } - // The basefee should be ignored for calls against state for - // - eth_call - // - eth_estimateGas - // - eth_createAccessList - // - tracing - env.evm_env.cfg_env.disable_base_fee = true; + fn inject_configured_precompiles(&self, precompiles: &mut PrecompilesMap, evm_env: &EvmEnv) { + self.networks.inject_precompiles(precompiles); + apply_bsc_p256_precompile( + precompiles, + self.protocol_chain_id(), + evm_env.block_env.timestamp.saturating_to(), + ); - let gas_price = gas_price.or(max_fee_per_gas).unwrap_or_else(|| { - self.fees().raw_gas_price().saturating_add(MIN_SUGGESTED_PRIORITY_FEE) - }); - let caller = from.unwrap_or_default(); - let to = to.as_ref().and_then(TxKind::to); - let blob_hashes = blob_versioned_hashes.unwrap_or_default(); - let mut base = TxEnv { - caller, - gas_limit, - gas_price, - gas_priority_fee: max_priority_fee_per_gas, - max_fee_per_blob_gas: max_fee_per_blob_gas - .or_else(|| { - if !blob_hashes.is_empty() { - env.evm_env.block_env.blob_gasprice() - } else { - Some(0) - } - }) - .unwrap_or_default(), - kind: match to { - Some(addr) => TxKind::Call(*addr), - None => TxKind::Create, - }, - tx_type, - value: value.unwrap_or_default(), - data: input.into_input().unwrap_or_default(), - chain_id: Some(chain_id.unwrap_or(self.env.read().evm_env.cfg_env.chain_id)), - access_list: access_list.unwrap_or_default(), - blob_hashes, - ..Default::default() - }; - base.set_signed_authorization(authorization_list.unwrap_or_default()); - env.tx = OpTransaction { base, ..Default::default() }; + if let Some(factory) = &self.precompile_factory { + factory.install(precompiles); + } - if let Some(nonce) = nonce { - env.tx.base.nonce = nonce; - } else { - // Disable nonce check in revm - env.evm_env.cfg_env.disable_nonce_check = true; - } - - if env.evm_env.block_env.basefee == 0 { - // this is an edge case because the evm fails if `tx.effective_gas_price < base_fee` - // 0 is only possible if it's manually set - env.evm_env.cfg_env.disable_base_fee = true; - } - - // Deposit transaction? - if transaction_type == Some(DEPOSIT_TX_TYPE_ID) && has_optimism_fields(&other) { - let deposit = DepositTransactionParts { - source_hash: other - .get_deserialized::("sourceHash") - .map(|sh| sh.unwrap_or_default()) - .unwrap_or_default(), - mint: other - .get_deserialized::("mint") - .map(|m| m.unwrap_or_default()) - .or(None), - is_system_transaction: other - .get_deserialized::("isSystemTx") - .map(|st| st.unwrap_or_default()) - .unwrap_or_default(), - }; - env.tx.deposit = deposit; + let cheats = Arc::new(self.cheats.clone()); + if cheats.has_recover_overrides() { + let cheat_ecrecover = CheatEcrecover::new(Arc::clone(&cheats)); + precompiles.apply_precompile(&EC_RECOVER, move |_| { + Some(DynPrecompile::new_stateful( + cheat_ecrecover.precompile_id().clone(), + move |input| cheat_ecrecover.call(input), + )) + }); } + } - env + fn inject_arbitrum_precompile_at_block( + &self, + precompiles: &mut PrecompilesMap, + block_number: u64, + ) { + precompiles.apply_precompile(&arbitrum::ARB_SYS_ADDRESS, move |_| { + Some(arbitrum::arb_sys_precompile(block_number)) + }); } - /// Builds [`Inspector`] with the configured options. - fn build_inspector(&self) -> AnvilInspector { - let mut inspector = AnvilInspector::default(); + fn simulation_precompile_overrides( + &self, + state_overrides: Option<&StateOverride>, + evm_env: &EvmEnv, + ) -> Result { + let mut moves = state_overrides + .into_iter() + .flatten() + .filter_map(|(source, account)| { + account.move_precompile_to.map(|destination| (*source, destination)) + }) + .collect::>(); + moves.sort_unstable(); + if moves.is_empty() { + return Ok(SimulationPrecompileOverrides::default()); + } + #[cfg(feature = "base")] + if self.is_base() { + return Err(simulate_rpc_error( + -32000, + "precompile moves are not supported on this network", + )); + } + if self.is_optimism() || self.is_tempo() || self.is_monad() { + return Err(simulate_rpc_error( + -32000, + "precompile moves are not supported on this network", + )); + } - if self.print_logs { - inspector = inspector.with_log_collector(); + let mut precompiles = PrecompilesMap::from_static(Precompiles::new( + PrecompileSpecId::from_spec_id(*evm_env.spec_id()), + )); + self.inject_precompiles(&mut precompiles, evm_env); + let precompile_addresses = precompiles.addresses().copied().collect::>(); + + // Validate every source first so invalid-source errors take precedence over the more + // specific move errors below. + for (source, _) in &moves { + if !precompile_addresses.contains(source) { + return Err(simulate_rpc_error( + -32000, + format!("account {source} is not a precompile"), + )); + } } - if self.print_traces { - inspector = inspector.with_trace_printer(); + for (source, destination) in &moves { + if source == destination { + return Err(simulate_rpc_error( + -38022, + format!("cannot move precompile {source} to itself"), + )); + } + } + let mut destinations = Vec::with_capacity(moves.len()); + for (_, destination) in &moves { + if destinations.contains(destination) { + return Err(simulate_rpc_error( + -38023, + format!("multiple precompiles moved to {destination}"), + )); + } + destinations.push(*destination); } - inspector + Ok(SimulationPrecompileOverrides { moves }) } - /// Simulates the payload by executing the calls in request. - pub async fn simulate( + fn apply_simulation_precompile_overrides( &self, - request: SimulatePayload, - block_request: Option, - ) -> Result>, BlockchainError> { - self.with_database_at(block_request, |state, mut block_env| { - let SimulatePayload { - block_state_calls, - trace_transfers, - validation, - return_full_transactions, - } = request; - let mut cache_db = CacheDB::new(state); - let mut block_res = Vec::with_capacity(block_state_calls.len()); + precompiles: &mut PrecompilesMap, + overrides: &SimulationPrecompileOverrides, + ) -> Result { + let warm_addresses = precompiles.addresses().copied().collect(); + precompiles.move_precompiles(overrides.moves.iter().copied()).map_err( + |MovePrecompileError::NotAPrecompile(address)| { + simulate_rpc_error(-32000, format!("account {address} is not a precompile")) + }, + )?; - // execute the blocks - for block in block_state_calls { - let SimBlock { block_overrides, state_overrides, calls } = block; - let mut call_res = Vec::with_capacity(calls.len()); - let mut log_index = 0; - let mut gas_used = 0; - let mut transactions = Vec::with_capacity(calls.len()); - let mut logs= Vec::new(); + // A dynamic lookup must not restore a precompile removed from its protocol address. + let moved_sources = + Arc::new(overrides.moves.iter().map(|(source, _)| *source).collect::>()); + precompiles.map_precompile_lookup(move |address, previous| { + if moved_sources.contains(address) { + None + } else { + previous.and_then(|lookup| lookup.lookup(address)) + } + }); + Ok(warm_addresses) + } + + fn inject_tempo_precompiles( + &self, + evm: &mut tempo_evm::evm::TempoEvm, + evm_env: &EvmEnv, + ) where + DB: Database, + I: Inspector>, + { + self.inject_configured_precompiles(evm.precompiles_mut(), evm_env); + // Re-extend Tempo precompiles, preserving shared non-creditable slots. + let cfg = evm.ctx().cfg.clone(); + let non_creditable_slots = evm.non_creditable_slots(); + extend_tempo_precompiles( + evm.precompiles_mut(), + &cfg, + StorageActions::disabled(), + non_creditable_slots, + ); + } + + /// Executes a call with the Ethereum EVM. + /// + /// Creates an Ethereum EVM, injects precompiles, and transacts with a + /// plain [`TxEnv`]. + fn transact_eth_with_inspector_ref<'db, I, DB>( + &self, + db: &'db DB, + evm_env: &EvmEnv, + inspector: &mut I, + tx_env: TxEnv, + ) -> Result, BlockchainError> + where + DB: DatabaseRef + ?Sized, + I: Inspector>>, + WrapDatabaseRef<&'db DB>: Database, + { + self.transact_eth_with_inspector_ref_and_precompile_overrides( + db, + evm_env, + inspector, + tx_env, + &SimulationPrecompileOverrides::default(), + ) + } + + fn transact_eth_with_inspector_ref_and_precompile_overrides<'db, I, DB>( + &self, + db: &'db DB, + evm_env: &EvmEnv, + inspector: &mut I, + tx_env: TxEnv, + overrides: &SimulationPrecompileOverrides, + ) -> Result, BlockchainError> + where + DB: DatabaseRef + ?Sized, + I: Inspector>>, + WrapDatabaseRef<&'db DB>: Database, + { + let mut evm = self.prepare_eth_evm(db, evm_env, inspector, overrides)?; + Ok(evm.transact(tx_env)?) + } + + fn transact_eth_simulation_with_inspector_ref<'db, I, DB>( + &self, + db: &'db DB, + evm_env: &EvmEnv, + inspector: &mut I, + tx_env: TxEnv, + overrides: &SimulationPrecompileOverrides, + ) -> Result, BlockchainError> + where + DB: DatabaseRef + ?Sized, + I: Inspector>>, + WrapDatabaseRef<&'db DB>: Database, + { + let evm = self.prepare_eth_evm(db, evm_env, inspector, overrides)?; + let mut evm = evm.into_inner(); + ContextSetters::set_tx(evm.ctx_mut(), tx_env); + let mut handler = SimulationHandler::< + _, + revm::context::result::EVMError, + EthFrame, + >::default(); + let result = handler.inspect_run(&mut evm)?; + let state = evm.ctx_mut().journal_mut().finalize(); + Ok(ResultAndState { result, state }) + } + + /// Creates an Ethereum EVM with the active precompiles and simulation overrides. + fn prepare_eth_evm<'db, 'inspector, I, DB>( + &self, + db: &'db DB, + evm_env: &EvmEnv, + inspector: &'inspector mut I, + overrides: &SimulationPrecompileOverrides, + ) -> Result, &'inspector mut I, PrecompilesMap>, BlockchainError> + where + DB: DatabaseRef + ?Sized, + I: Inspector>>, + WrapDatabaseRef<&'db DB>: Database, + { + let mut evm = EthEvmFactory::default().create_evm_with_inspector( + WrapDatabaseRef(db), + evm_env.clone(), + inspector, + ); + self.inject_precompiles(evm.precompiles_mut(), evm_env); + if !overrides.moves.is_empty() { + let warm_addresses = + self.apply_simulation_precompile_overrides(evm.precompiles_mut(), overrides)?; + // EIP-2929 warms protocol precompile addresses, not simulation-only destinations. + evm.ctx_mut().journal_mut().warm_precompiles(&warm_addresses); + } + Ok(evm) + } + + /// Executes an envelope through the active network EVM with optional Monad block context. + /// + /// Returns both the execution result and the base [`TxEnv`]. + fn transact_envelope_with_inspector_ref_and_context<'db, I, DB>( + &self, + db: &'db DB, + evm_env: &EvmEnv, + inspector: &mut I, + pending: &PendingTransaction, + #[cfg_attr(not(feature = "monad"), allow(unused_variables))] monad_context: Option< + MonadExecutionContext<'_>, + >, + ) -> Result<(ResultAndState, TxEnv), BlockchainError> + where + DB: DatabaseRef + ?Sized, + I: BackendInspector>, + WrapDatabaseRef<&'db DB>: Database, + { + self.transact_envelope_with_inspector_ref_and_context_kind( + db, + evm_env, + inspector, + pending, + EnvelopeExecution::transaction(monad_context, self.hardfork()), + ) + } + + /// Replays a mined envelope through the active network's canonical replay entry point. + fn replay_envelope_with_inspector_ref_and_context<'db, I, DB>( + &self, + db: &'db DB, + evm_env: &EvmEnv, + inspector: &mut I, + pending: &PendingTransaction, + execution: EnvelopeExecution<'_>, + ) -> Result<(ResultAndState, TxEnv), BlockchainError> + where + DB: DatabaseRef + ?Sized, + I: BackendInspector>, + WrapDatabaseRef<&'db DB>: Database, + { + self.transact_envelope_with_inspector_ref_and_context_kind( + db, evm_env, inspector, pending, execution, + ) + } + + fn transact_envelope_with_inspector_ref_and_context_kind<'db, I, DB>( + &self, + db: &'db DB, + evm_env: &EvmEnv, + inspector: &mut I, + pending: &PendingTransaction, + #[cfg_attr(not(feature = "monad"), allow(unused_variables))] execution: EnvelopeExecution< + '_, + >, + ) -> Result<(ResultAndState, TxEnv), BlockchainError> + where + DB: DatabaseRef + ?Sized, + I: BackendInspector>, + WrapDatabaseRef<&'db DB>: Database, + { + #[cfg(any(feature = "base", feature = "optimism"))] + let (tx, sender) = (pending.transaction.as_ref(), *pending.sender()); + #[cfg(feature = "base")] + if self.is_base() { + let base_tx: BaseTransaction = + FromTxWithEncoded::from_encoded_tx(tx, sender, tx.encoded_2718().into()); + let base = base_tx.base.clone(); + let result = self.transact_base_with_inspector_ref(db, evm_env, inspector, base_tx)?; + return Ok((result, base)); + } + if self.is_tempo() { + let tx_env: TempoTxEnv = build_tx_env_for_pending(pending, self.cheats()); + let base = tx_env.inner.clone(); + let result = self.transact_tempo_with_inspector_ref(db, evm_env, inspector, tx_env)?; + return Ok((result, base)); + } + #[cfg(feature = "optimism")] + if self.is_optimism() { + let op_tx: OpTransaction = + FromTxWithEncoded::from_encoded_tx(tx, sender, tx.encoded_2718().into()); + let base = op_tx.base.clone(); + let spec = self.hardfork().into(); + let result = + self.transact_op_with_inspector_ref(db, evm_env, inspector, op_tx, spec)?; + return Ok((result, base)); + } + let tx_env: TxEnv = build_tx_env_for_pending(pending, self.cheats()); + let base = tx_env.clone(); + #[cfg(feature = "monad")] + let result = if self.is_monad() { + let context = monad::resolve_execution_context(execution.monad_context, &tx_env); + self.transact_monad_with_inspector_ref( + db, + evm_env, + inspector, + tx_env, + monad::PreparedExecution { + context, + kind: execution.kind, + hardfork: monad_revm::MonadHardfork::from(execution.hardfork), + }, + )? + } else { + self.transact_eth_with_inspector_ref(db, evm_env, inspector, tx_env)? + }; + #[cfg(not(feature = "monad"))] + let result = self.transact_eth_with_inspector_ref(db, evm_env, inspector, tx_env)?; + Ok((result, base)) + } + + /// Builds the Tempo [`EvmEnv`] (spec, gas params, [`TempoBlockEnv`]) from a base + /// env. + fn build_tempo_evm_env( + evm_env: &EvmEnv, + hardfork: TempoHardfork, + ) -> EvmEnv { + EvmEnv::new( + evm_env.cfg_env.clone().with_spec_and_gas_params(hardfork, tempo_gas_params(hardfork)), + TempoBlockEnv { + inner: evm_env.block_env.clone(), + timestamp_millis_part: 0, + ..Default::default() + }, + ) + } + + /// Creates a Tempo EVM, injects precompiles, and transacts with a native [`TempoTxEnv`]. + fn transact_tempo_with_inspector_ref<'db, I, DB>( + &self, + db: &'db DB, + evm_env: &EvmEnv, + inspector: &mut I, + tx_env: TempoTxEnv, + ) -> Result, BlockchainError> + where + DB: DatabaseRef + ?Sized, + I: Inspector>>, + WrapDatabaseRef<&'db DB>: Database, + { + let tempo_env = Self::build_tempo_evm_env(evm_env, self.tempo_hardfork()); + let mut evm = TempoEvmFactory::default().create_evm_with_inspector( + WrapDatabaseRef(db), + tempo_env, + inspector, + ); + self.inject_tempo_precompiles(&mut evm, evm_env); + Ok(evm.transact(tx_env)?) + } + + /// Creates a concrete EVM + [`AnvilBlockExecutor`], runs pre-execution changes, and + /// executes pool transactions. Returns the execution results and drops the EVM. + #[allow(clippy::too_many_arguments, clippy::type_complexity)] + fn execute_with_block_executor( + &self, + db: DB, + evm_env: &EvmEnv, + parent_hash: B256, + hardfork: FoundryHardfork, + parent_beacon_block_root: Option, + execution_kind: BlockExecutionKind, + pool_transactions: &[Arc>], + gas_config: &PoolTxGasConfig, + inspector_tx_config: &InspectorTxConfig, + validator: &dyn Fn( + &PoolTransaction, + &AccountInfo, + ) -> Result<(), InvalidTransactionError>, + ) -> Result< + (ExecutedPoolTransactions, BlockExecutionResult), + BlockchainError, + > + where + DB: StateDB + BalIndexedDatabase, + { + let spec_id = *evm_env.spec_id(); + #[cfg(feature = "monad")] + if self.is_monad() { + return self.execute_with_monad_block_executor( + db, + evm_env, + parent_hash, + spec_id, + hardfork.into(), + pool_transactions, + gas_config, + inspector_tx_config, + validator, + ); + } + + let inspector = self.build_mining_inspector(); + let transitions = + self.ethereum_block_transitions(hardfork, parent_beacon_block_root, execution_kind); + + macro_rules! execute { + ($executor:expr) => {{ + let mut executor = $executor; + executor + .apply_pre_execution_changes() + .map_err(|err| BlockchainError::Internal(err.to_string()))?; + let pool_result = execute_pool_transactions( + &mut executor, + pool_transactions, + gas_config, + inspector_tx_config, + self.cheats(), + validator, + &mut execute_pool_transaction, + ); + let (_, block_result) = + executor.finish().map_err(|err| BlockchainError::Internal(err.to_string()))?; + Ok((pool_result, block_result)) + }}; + } + + #[cfg(feature = "base")] + if self.is_base() { + let configured = match hardfork { + FoundryHardfork::Base(upgrade) => upgrade, + _ => BaseUpgrade::Azul, + }; + let upgrade = if self.is_fork() { + BaseUpgrade::from_chain_and_timestamp( + evm_env.cfg_env.chain_id, + evm_env.block_env.timestamp.saturating_to(), + ) + .unwrap_or(configured) + } else { + configured + }; + let cfg = + evm_env.cfg_env.clone().with_spec_and_mainnet_gas_params(BaseSpecId::new(upgrade)); + let activation_admin = self.base_activation_admin().or_else(|| { + ChainConfig::activation_admin_address_for_upgrade_by_chain_id(cfg.chain_id, upgrade) + }); + let base_env = EvmEnv::new(cfg, evm_env.block_env.clone()); + let mut evm = BaseEvmFactory::new(activation_admin) + .create_evm_with_inspector(db, base_env, inspector); + evm.ctx_mut().cfg.tx_chain_id_check = true; + self.inject_precompiles(evm.precompiles_mut(), evm_env); + let mut executor = AnvilBlockExecutor::new(evm, parent_hash, spec_id, transitions) + .with_max_blob_gas_per_block(gas_config.max_blob_gas_per_block); + executor.set_base_upgrade(upgrade); + return execute!(executor); + } + + #[cfg(feature = "optimism")] + if self.is_optimism() { + let op_env = EvmEnv::new( + evm_env.cfg_env.clone().with_spec_and_mainnet_gas_params(hardfork.into()), + evm_env.block_env.clone(), + ); + let mut evm = + OpEvmFactory::::default().create_evm_with_inspector(db, op_env, inspector); + self.inject_precompiles(evm.precompiles_mut(), evm_env); + let mut executor = AnvilBlockExecutor::new(evm, parent_hash, spec_id, transitions) + .with_max_blob_gas_per_block(gas_config.max_blob_gas_per_block); + executor.set_optimism_hardfork(hardfork); + return execute!(executor); + } + + if self.is_tempo() { + let tempo_env = Self::build_tempo_evm_env(evm_env, self.tempo_hardfork()); + let mut evm = + TempoEvmFactory::default().create_evm_with_inspector(db, tempo_env, inspector); + self.inject_precompiles(evm.precompiles_mut(), evm_env); + let executor = AnvilBlockExecutor::new(evm, parent_hash, spec_id, transitions) + .with_max_blob_gas_per_block(gas_config.max_blob_gas_per_block); + return execute!(executor); + } + let mut evm = + EthEvmFactory::default().create_evm_with_inspector(db, evm_env.clone(), inspector); + self.inject_precompiles(evm.precompiles_mut(), evm_env); + let executor = AnvilBlockExecutor::new(evm, parent_hash, spec_id, transitions) + .with_max_blob_gas_per_block(gas_config.max_blob_gas_per_block); + execute!(executor) + } + + /// Applies Ethereum block-start transitions to a disposable simulation candidate. + fn apply_simulation_pre_execution_changes( + &self, + db: DB, + evm_env: &EvmEnv, + parent_hash: B256, + transitions: EthereumBlockTransitions, + ) -> Result<(), BlockchainError> + where + DB: StateDB, + { + let inspector = self.build_mining_inspector(); + let mut evm = + EthEvmFactory::default().create_evm_with_inspector(db, evm_env.clone(), inspector); + self.inject_precompiles(evm.precompiles_mut(), evm_env); + apply_ethereum_pre_execution_changes(&mut evm, parent_hash, transitions) + .map_err(|err| BlockchainError::Internal(err.to_string())) + } + + /// Applies Ethereum post-block transitions to a disposable simulation candidate. + fn apply_simulation_post_execution_changes( + &self, + db: DB, + evm_env: &EvmEnv, + transitions: EthereumBlockTransitions, + receipts: &[FoundryReceiptEnvelope], + ) -> Result + where + DB: StateDB, + { + let inspector = self.build_mining_inspector(); + let mut evm = + EthEvmFactory::default().create_evm_with_inspector(db, evm_env.clone(), inspector); + self.inject_precompiles(evm.precompiles_mut(), evm_env); + apply_ethereum_post_execution_changes(&mut evm, transitions, receipts) + .map_err(|err| BlockchainError::Internal(err.to_string())) + } + + /// ## EVM settings + /// + /// This modifies certain EVM settings to mirror geth's `SkipAccountChecks` when transacting requests, see also: : + /// + /// - `disable_eip3607` is set to `true` + /// - the base fee is zero and its check disabled for a zero-fee call, while a priced call must + /// pay at least the base fee + /// - the blob base fee is zero for a blob call without a blob fee cap + /// - `tx_gas_limit_cap` is set to `Some(u64::MAX)` indicating no gas limit cap + /// - `nonce` check is skipped + fn build_call_env_with_base( + &self, + request: WithOtherFields, + fee_details: FeeDetails, + block_env: BlockEnv, + base_evm_env: Option<&EvmEnv>, + ) -> (EvmEnv, TxEnv, CallTransactionInfo) { + let tx_type = request.minimal_tx_type() as u8; + #[cfg(any(feature = "base", feature = "optimism"))] + let mut transaction_info = CallTransactionInfo::default(); + #[cfg(not(any(feature = "base", feature = "optimism")))] + let transaction_info = CallTransactionInfo::default(); + + let WithOtherFields:: { + inner: + TransactionRequest { + from, + to, + gas, + value, + input, + access_list, + blob_versioned_hashes, + authorization_list, + nonce, + sidecar: _, + chain_id, + .. // Rest of the gas fees related fields are taken from `fee_details` + }, + other, + } = request; + + let FeeDetails { + gas_price, + max_fee_per_gas, + max_priority_fee_per_gas, + max_fee_per_blob_gas, + } = fee_details; + + let gas_limit = gas.unwrap_or(block_env.gas_limit); + let mut evm_env = base_evm_env.cloned().unwrap_or_else(|| self.evm_env.read().clone()); + evm_env.block_env = block_env; + // we want to disable this in eth_call, since this is common practice used by other node + // impls and providers + evm_env.cfg_env.disable_block_gas_limit = true; + evm_env.cfg_env.tx_gas_limit_cap = Some(u64::MAX); + + // Disable nonce check in revm + evm_env.cfg_env.disable_nonce_check = true; + + let gas_price = gas_price.or(max_fee_per_gas).unwrap_or_else(|| { + self.fees().raw_gas_price().saturating_add(MIN_SUGGESTED_PRIORITY_FEE) + }); + // A zero-fee call runs with a zero base fee, as in geth's eth_call, so BASEFEE never + // exceeds the price the call pays. A priced call keeps the base fee check, so a price + // below the base fee is rejected as it is for a transaction. + if gas_price == 0 { + evm_env.block_env.basefee = 0; + } + // Set the check on every call, because a cloned base environment can carry a disabled + // check. Pre-London blocks have no protocol base fee, so they skip the check too. + evm_env.cfg_env.disable_base_fee = + evm_env.block_env.basefee == 0 || evm_env.cfg_env.spec < SpecId::LONDON; + let caller = from.unwrap_or_default(); + let to = to.as_ref().and_then(TxKind::to); + let blob_hashes = blob_versioned_hashes.unwrap_or_default(); + // As in geth's eth_call, a blob call without a blob fee cap runs at a zero blob base fee + // and pays no blob fee, while other calls keep the block's blob base fee. + let is_blob_call = !blob_hashes.is_empty() || max_fee_per_blob_gas.is_some(); + let max_fee_per_blob_gas = max_fee_per_blob_gas.unwrap_or_default(); + if is_blob_call + && max_fee_per_blob_gas == 0 + && let Some(blob) = evm_env.block_env.blob_excess_gas_and_price.as_mut() + { + blob.blob_gasprice = 0; + } + let mut tx_env = TxEnv { + caller, + gas_limit, + gas_price, + gas_priority_fee: max_priority_fee_per_gas, + max_fee_per_blob_gas, + kind: match to { + Some(addr) => TxKind::Call(*addr), + None => TxKind::Create, + }, + tx_type, + value: value.unwrap_or_default(), + data: input.into_input().unwrap_or_default(), + chain_id: Some(chain_id.unwrap_or(evm_env.cfg_env.chain_id)), + access_list: access_list.unwrap_or_default(), + blob_hashes, + ..Default::default() + }; + tx_env.set_signed_authorization(authorization_list.unwrap_or_default()); + + if let Some(nonce) = nonce { + tx_env.nonce = nonce; + } + + // Deposit transaction? (only valid when a deposit-capable network is active) + #[cfg(any(feature = "base", feature = "optimism"))] + { + transaction_info.deposit = if self.ensure_deposits_active().is_ok() + && let Ok(deposit) = get_deposit_tx_parts(&other) + { + deposit + } else { + DepositTransactionParts::default() + }; + } + #[cfg(not(any(feature = "base", feature = "optimism")))] + { + // `other` carries OP-only deposit fields; consumed only when feature is enabled. + let _ = &other; + } + + (evm_env, tx_env, transaction_info) + } + + fn prepare_call_env( + &self, + state: &dyn DatabaseRef, + request: WithOtherFields, + fee_details: FeeDetails, + block_env: BlockEnv, + ) -> Result { + self.prepare_call_env_from_base(state, request, fee_details, block_env, None) + } + + fn prepare_call_env_from_base( + &self, + state: &dyn DatabaseRef, + request: WithOtherFields, + fee_details: FeeDetails, + block_env: BlockEnv, + base_evm_env: Option<&EvmEnv>, + ) -> Result { + let gas_omitted = request.gas.is_none(); + let request = self.parse_transaction_request(request)?; + let mut prepared = self.prepare_typed_call_env_with_base( + state, + request, + fee_details, + block_env, + base_evm_env, + )?; + // Without a gas limit, a priced call gets at most the gas its sender can pay for, rather + // than failing the funds check for the default limit. Tempo pays fees in tokens instead, + // and OP deposits mint funds before paying for execution. + let cap_by_balance = match &prepared.tx_env { + CallTxEnv::Tempo(_) => false, + #[cfg(feature = "optimism")] + CallTxEnv::Op(tx) => tx.tx_type() != DEPOSIT_TX_TYPE_ID, + _ => true, + }; + let min_gas = if prepared.evm_env.cfg_env.enable_amsterdam_eip2780 { + eip2780::TX_BASE_COST + } else { + MIN_TRANSACTION_GAS as u64 + }; + let tx_env = prepared.tx_env.base_mut(); + if gas_omitted && cap_by_balance && tx_env.gas_price > 0 { + let balance = + state.basic_ref(tx_env.caller)?.map(|info| info.balance).unwrap_or_default(); + let upfront = tx_env.value.saturating_add(tx_env.calc_max_data_fee()); + // A sender that cannot pay for the cheapest transaction keeps the default limit, so + // the call fails the funds check. + if let Some(allowance) = balance + .checked_sub(upfront) + .map(|available| available / U256::from(tx_env.gas_price)) + && allowance >= U256::from(min_gas) + { + tx_env.gas_limit = tx_env.gas_limit.min(allowance.saturating_to()); + } + } + Ok(prepared) + } + + const fn base_call_tx_env(&self, tx_env: TxEnv) -> CallTxEnv { + #[cfg(feature = "monad")] + if self.is_monad() { + return CallTxEnv::Monad(tx_env); + } + CallTxEnv::Eth(tx_env) + } + + fn prepare_base_call_env_with_base( + &self, + request: WithOtherFields, + fee_details: FeeDetails, + block_env: BlockEnv, + base_evm_env: Option<&EvmEnv>, + ) -> Result { + let (evm_env, tx_env, transaction_info) = + self.build_call_env_with_base(request, fee_details, block_env, base_evm_env); + + #[cfg(feature = "base")] + if self.is_base() { + let mut base_tx = BaseTransaction::new(tx_env); + if base_tx.base.tx_type == DEPOSIT_TRANSACTION_TYPE { + let deposit = &transaction_info.deposit; + base_tx.deposit = BaseDepositTransactionParts::new( + deposit.source_hash, + deposit.mint, + deposit.is_system_transaction, + ); + } else { + base_tx.enveloped_tx = Some(Bytes::new()); + } + return Ok(PreparedCall { + evm_env, + tx_env: CallTxEnv::Base(Box::new(base_tx)), + simulated_tempo_tx: None, + simulated_envelope: None, + }); + } + + #[cfg(feature = "optimism")] + let tx_env = if self.is_optimism() { + CallTxEnv::Op(OpTransaction { + base: tx_env, + deposit: transaction_info.deposit, + enveloped_tx: Some(Bytes::new()), + }) + } else if self.is_tempo() { + CallTxEnv::Tempo(TempoTxEnv::from(tx_env)) + } else { + self.base_call_tx_env(tx_env) + }; + #[cfg(not(feature = "optimism"))] + let tx_env = { + let _ = transaction_info; + if self.is_tempo() { + CallTxEnv::Tempo(TempoTxEnv::from(tx_env)) + } else { + self.base_call_tx_env(tx_env) + } + }; + Ok(PreparedCall { + evm_env, + tx_env, + simulated_tempo_tx: None, + #[cfg(feature = "base")] + simulated_envelope: None, + }) + } + + /// Classifies an RPC request according to the active network. + pub(crate) fn parse_transaction_request( + &self, + request: WithOtherFields, + ) -> Result { + request + .input + .unique_input() + .map_err(|err| BlockchainError::RpcError(RpcError::invalid_params(err.to_string())))?; + // No transaction carries both fee styles, so reject the request rather than pick one. + if request.gas_price.is_some() + && (request.max_fee_per_gas.is_some() || request.max_priority_fee_per_gas.is_some()) + { + return Err(BlockchainError::ConflictingFeeFields); + } + let transaction_type = request.transaction_type; + if transaction_type == Some(foundry_primitives::CIP64_TX_TYPE) + || request.other.get("feeCurrency").is_some_and(|value| !value.is_null()) + { + self.ensure_cip64_active()?; + return FoundryTransactionRequest::new(request) + .map_err(|err| BlockchainError::InvalidTransactionRequest(err.to_string())); + } + #[cfg(feature = "base")] + if self.is_base() { + let parsed = FoundryTransactionRequest::try_from(request.clone()).map_err( + |err: serde_json::Error| { + BlockchainError::InvalidTransactionRequest(err.to_string()) + }, + )?; + if parsed.is_base() { + return Ok(parsed); + } + if transaction_type == Some(DEPOSIT_TX_TYPE_ID) + || get_deposit_tx_parts(&request.other).is_ok() + { + return Ok(FoundryTransactionRequest::Op(request)); + } + return Ok(FoundryTransactionRequest::Ethereum(request.into_inner())); + } + #[cfg(feature = "base")] + if transaction_type == Some(foundry_primitives::FoundryTxType::Eip8130.into()) + || matches!( + FoundryTransactionRequest::try_from(request.clone()), + Ok(FoundryTransactionRequest::Base(_)) + ) + { + return Err(BlockchainError::BaseTransactionUnsupported); + } + if !self.is_tempo() && transaction_type != Some(TEMPO_TX_TYPE_ID) { + #[cfg(feature = "optimism")] + if transaction_type == Some(DEPOSIT_TX_TYPE_ID) + || transaction_type == Some(POST_EXEC_TX_TYPE_ID) + || get_deposit_tx_parts(&request.other).is_ok() + { + return Ok(FoundryTransactionRequest::Op(request)); + } + return Ok(FoundryTransactionRequest::Ethereum(request.into_inner())); + } + + let parsed: FoundryTransactionRequest = + FoundryTransactionRequest::new(request).map_err(|err: serde_json::Error| { + BlockchainError::InvalidTransactionRequest(err.to_string()) + })?; + if parsed.is_tempo() { + self.ensure_tempo_active()?; + } + if parsed.is_tempo() + && self.is_tempo() + && transaction_type.is_some_and(|ty| ty != TEMPO_TX_TYPE_ID) + { + return Err(BlockchainError::FailedToDecodeTransaction); + } + Ok(parsed) + } + + fn build_tempo_request_env( + request: TempoTransactionRequest, + mut base: TxEnv, + hardfork: TempoHardfork, + ) -> Result<(TempoTxEnv, AASigned), BlockchainError> { + let fee_payer = request.fee_payer_signature.map(|_| { + request.clone().build_aa().ok().and_then(|tx| tx.recover_fee_payer(base.caller).ok()) + }); + + // Build the response representation separately so the mocked execution signature does not + // leak into RPC output. + let mut response_request = request.clone(); + response_request.inner.from = Some(base.caller); + response_request.inner.gas = Some(base.gas_limit); + response_request.inner.nonce = Some(base.nonce); + response_request.inner.chain_id = base.chain_id; + response_request.inner.max_fee_per_gas = Some(base.gas_price); + response_request.inner.max_priority_fee_per_gas = + Some(base.gas_priority_fee.unwrap_or_default()); + response_request.inner.access_list = Some(base.access_list.clone()); + if response_request.calls.is_empty() + && response_request.inner.to.is_none() + && !base.data.is_empty() + { + response_request.inner.to = Some(base.kind); + } + let response_tx = response_request + .build_aa() + .map_err(|err| BlockchainError::InvalidTransactionRequest(err.to_string()))?; + let response_tx = response_tx.into_signed(TempoSignature::default()); + let key_type = request.key_type.unwrap_or(SignatureType::Secp256k1); + let key_data = request.key_data.clone(); + let key_id = request.key_id; + let signature = + mock_tempo_signature(key_type, key_data, key_id, base.caller, hardfork.is_t1c()); + let mut calls = request.calls; + if let Some(to) = request.inner.to { + calls.push(Call { + to, + value: request.inner.value.unwrap_or_default(), + input: request.inner.input.into_input().unwrap_or_default(), + }); + } else if calls.is_empty() && !base.data.is_empty() { + // Alloy represents an omitted top-level `to` as `None`; preserve Ethereum CREATE + // semantics by materializing it as the final Tempo call. + calls.push(Call { to: base.kind, value: base.value, input: base.data.clone() }); + } + if let Some(first_call) = calls.first() { + base.kind = first_call.to; + base.value = first_call.value; + base.data = first_call.input.clone(); + } + let tx_env = TempoTxEnv { + fee_token: request.fee_token, + is_system_tx: false, + execution_context: ExecutionContext::Simulation, + unique_tx_identifier: Some(TEMPO_RPC_SIMULATION_CONTEXT), + fee_payer, + tempo_tx_env: Some(Box::new(TempoBatchCallEnv { + aa_calls: calls, + signature, + tempo_authorization_list: request + .tempo_authorization_list + .into_iter() + .map(RecoveredTempoAuthorization::new) + .collect(), + nonce_key: request.nonce_key.unwrap_or_default(), + key_authorization: request.key_authorization, + signature_hash: B256::ZERO, + tx_hash: B256::ZERO, + valid_before: request.valid_before.map(|value| value.get()), + valid_after: request.valid_after.map(|value| value.get()), + override_key_id: key_id, + expiring_nonce_idx: None, + })), + inner: base, + }; + Ok((tx_env, response_tx)) + } + + fn prepare_typed_call_env( + &self, + state: &dyn DatabaseRef, + request: FoundryTransactionRequest, + fee_details: FeeDetails, + block_env: BlockEnv, + ) -> Result { + self.prepare_typed_call_env_with_base(state, request, fee_details, block_env, None) + } + + fn prepare_typed_call_env_with_base( + &self, + state: &dyn DatabaseRef, + request: FoundryTransactionRequest, + fee_details: FeeDetails, + block_env: BlockEnv, + base_evm_env: Option<&EvmEnv>, + ) -> Result { + match request { + FoundryTransactionRequest::Tempo(tempo_request) => { + self.ensure_tempo_active()?; + let mut tempo_request = *tempo_request; + if tempo_request.inner.nonce.is_none() { + let caller = tempo_request.inner.from.unwrap_or_default(); + tempo_request.inner.nonce = Some(tempo_nonce( + state, + caller, + tempo_request.nonce_key.unwrap_or_default(), + )?); + } + let inner = WithOtherFields::new(tempo_request.inner.clone()); + let (evm_env, base, _) = + self.build_call_env_with_base(inner, fee_details, block_env, base_evm_env); + let (tx_env, simulated_tempo_tx) = + Self::build_tempo_request_env(tempo_request, base, self.tempo_hardfork())?; + Ok(PreparedCall { + evm_env, + tx_env: CallTxEnv::Tempo(tx_env), + simulated_tempo_tx: Some(simulated_tempo_tx), + #[cfg(feature = "base")] + simulated_envelope: None, + }) + } + #[cfg(feature = "base")] + FoundryTransactionRequest::Base(request) => { + self.ensure_base_eip8130_active_at(block_env.timestamp.saturating_to())?; + let gas_limit = block_env.gas_limit; + let (evm_env, _, _) = self.build_call_env_with_base( + WithOtherFields::new(request.as_ref().clone()), + fee_details, + block_env, + base_evm_env, + ); + let tx = request + .to_eip8130_simulation_tx(self.chain_id().to(), gas_limit) + .ok_or_else(|| { + BlockchainError::InvalidTransactionRequest( + "invalid EIP-8130 simulation request: missing or conflicting sender, \ + oversized authentication data, or invalid payer authenticator" + .to_string(), + ) + })?; + let simulated_envelope = tx + .eip8130 + .as_ref() + .map(|parts| FoundryTxEnvelope::Eip8130(parts.signed.clone())); + Ok(PreparedCall { + evm_env, + tx_env: CallTxEnv::Base(Box::new(tx)), + simulated_tempo_tx: None, + simulated_envelope, + }) + } + FoundryTransactionRequest::Ethereum(mut request) + | FoundryTransactionRequest::Celo(foundry_primitives::Cip64TransactionRequest { + inner: mut request, + .. + }) => { + // Tempo charges the account-creation cost for nonce 0, so an omitted nonce must + // not default to it. + if self.is_tempo() && request.nonce.is_none() { + request.nonce = + Some(tempo_nonce(state, request.from.unwrap_or_default(), U256::ZERO)?); + } + self.prepare_base_call_env_with_base( + WithOtherFields::new(request), + fee_details, + block_env, + base_evm_env, + ) + } + #[cfg(any(feature = "base", feature = "optimism"))] + FoundryTransactionRequest::Op(request) => { + self.prepare_base_call_env_with_base(request, fee_details, block_env, base_evm_env) + } + } + } + + fn transact_call_with_inspector_ref<'db, I, DB>( + &self, + db: &'db DB, + evm_env: &EvmEnv, + inspector: &mut I, + tx_env: CallTxEnv, + #[cfg_attr(not(feature = "monad"), allow(unused_variables))] monad_context: Option< + MonadExecutionContext<'_>, + >, + ) -> Result, BlockchainError> + where + DB: DatabaseRef + ?Sized, + I: BackendInspector>, + WrapDatabaseRef<&'db DB>: Database, + { + self.transact_call_with_inspector_ref_at_hardfork( + db, + evm_env, + inspector, + tx_env, + monad_context, + self.hardfork(), + ) + } + + fn transact_call_with_inspector_ref_at_hardfork<'db, I, DB>( + &self, + db: &'db DB, + evm_env: &EvmEnv, + inspector: &mut I, + tx_env: CallTxEnv, + #[cfg_attr(not(feature = "monad"), allow(unused_variables))] monad_context: Option< + MonadExecutionContext<'_>, + >, + #[cfg_attr(not(feature = "monad"), allow(unused_variables))] hardfork: FoundryHardfork, + ) -> Result, BlockchainError> + where + DB: DatabaseRef + ?Sized, + I: BackendInspector>, + WrapDatabaseRef<&'db DB>: Database, + { + match tx_env { + CallTxEnv::Eth(tx_env) => { + self.transact_eth_with_inspector_ref(db, evm_env, inspector, tx_env) + } + #[cfg(feature = "base")] + CallTxEnv::Base(tx_env) => { + self.transact_base_with_inspector_ref(db, evm_env, inspector, *tx_env) + } + #[cfg(feature = "monad")] + CallTxEnv::Monad(tx_env) => { + let context = monad::resolve_execution_context(monad_context, &tx_env); + self.transact_monad_with_inspector_ref( + db, + evm_env, + inspector, + tx_env, + monad::PreparedExecution { + context, + kind: EnvelopeExecutionKind::Transaction, + hardfork: monad_revm::MonadHardfork::from(hardfork), + }, + ) + } + #[cfg(feature = "optimism")] + CallTxEnv::Op(tx_env) => { + let spec = self.hardfork().into(); + self.transact_op_with_inspector_ref(db, evm_env, inspector, tx_env, spec) + } + CallTxEnv::Tempo(tx_env) => { + self.transact_tempo_with_inspector_ref(db, evm_env, inspector, tx_env) + } + } + } + + pub fn call_with_state( + &self, + state: &dyn DatabaseRef, + request: WithOtherFields, + fee_details: FeeDetails, + block_env: BlockEnv, + ) -> Result<(InstructionResult, Option, u128, State), BlockchainError> { + self.call_with_state_and_context(state, request, fee_details, block_env, None) + } + + pub(crate) fn call_with_state_and_context( + &self, + state: &dyn DatabaseRef, + request: WithOtherFields, + fee_details: FeeDetails, + block_env: BlockEnv, + mut monad_context: Option, + ) -> Result<(InstructionResult, Option, u128, State), BlockchainError> { + let mut inspector = self.build_inspector(); + let PreparedCall { evm_env, tx_env, .. } = + self.prepare_call_env(state, request, fee_details, block_env)?; + let ResultAndState { result, state } = self.transact_call_with_inspector_ref( + state, + &evm_env, + &mut inspector, + tx_env, + monad_context.as_mut().map(next_monad_context), + )?; + + let (exit_reason, gas_used, out, _logs) = unpack_execution_result(result); + inspector.print_logs(); + + if self.print_traces { + inspector.into_print_traces(self.call_trace_decoder()); + } + + Ok((exit_reason, out, gas_used as u128, state)) + } + + pub(crate) fn call_with_state_typed_gas_limit( + &self, + state: &dyn DatabaseRef, + request: FoundryTransactionRequest, + fee_details: FeeDetails, + block_env: BlockEnv, + options: GasEstimateCallOptions, + ) -> Result<(InstructionResult, Option, u128, State), BlockchainError> { + let GasEstimateCallOptions { gas_limit, disable_fee_charge, monad_context } = options; + self.call_with_state_typed_inner( + state, + request, + fee_details, + block_env, + TypedCallOverrides { + gas_limit: Some(gas_limit), + disable_fee_charge, + ..Default::default() + }, + monad_context, + ) + } + + pub(crate) fn call_with_state_typed_access_list( + &self, + state: &dyn DatabaseRef, + request: FoundryTransactionRequest, + fee_details: FeeDetails, + block_env: BlockEnv, + access_list: AccessList, + monad_context: Option, + ) -> Result<(InstructionResult, Option, u128, State), BlockchainError> { + self.call_with_state_typed_inner( + state, + request, + fee_details, + block_env, + TypedCallOverrides { access_list: Some(access_list), ..Default::default() }, + monad_context, + ) + } + + fn call_with_state_typed_inner( + &self, + state: &dyn DatabaseRef, + request: FoundryTransactionRequest, + fee_details: FeeDetails, + block_env: BlockEnv, + overrides: TypedCallOverrides, + mut monad_context: Option, + ) -> Result<(InstructionResult, Option, u128, State), BlockchainError> { + // Estimation probes do not return or print traces, but still collect console logs. + let mut inspector = AnvilInspector::default(); + if self.print_logs { + inspector = inspector.with_log_collector(); + } + let block_fees = (block_env.basefee, block_env.blob_excess_gas_and_price); + let PreparedCall { mut evm_env, mut tx_env, .. } = + self.prepare_typed_call_env(state, request, fee_details, block_env)?; + evm_env.cfg_env.disable_fee_charge = overrides.disable_fee_charge; + if let Some(gas_limit) = overrides.gas_limit { + tx_env.base_mut().gas_limit = gas_limit; + } + if let Some(access_list) = overrides.access_list { + let tx_env = tx_env.base_mut(); + Self::use_block_fees(&mut evm_env, tx_env, block_fees); + tx_env.access_list = access_list; + if tx_env.tx_type == TransactionType::Legacy as u8 { + tx_env.tx_type = TransactionType::Eip2930 as u8; + } + } + let ResultAndState { result, state } = self.transact_call_with_inspector_ref( + state, + &evm_env, + &mut inspector, + tx_env, + monad_context.as_mut().map(next_monad_context), + )?; + let (exit_reason, gas_used, out, _logs) = unpack_execution_result(result); + inspector.print_logs(); + Ok((exit_reason, out, gas_used as u128, state)) + } + + pub fn build_access_list_with_state( + &self, + state: &dyn DatabaseRef, + request: WithOtherFields, + fee_details: FeeDetails, + block_env: BlockEnv, + ) -> Result<(InstructionResult, Option, u64, AccessList), BlockchainError> { + self.build_access_list_with_state_and_context(state, request, fee_details, block_env, None) + } + + pub(crate) fn build_access_list_with_state_and_context( + &self, + state: &dyn DatabaseRef, + request: WithOtherFields, + fee_details: FeeDetails, + block_env: BlockEnv, + mut monad_context: Option, + ) -> Result<(InstructionResult, Option, u64, AccessList), BlockchainError> { + let mut inspector = + AccessListInspector::new(request.access_list.clone().unwrap_or_default()); + + let block_fees = (block_env.basefee, block_env.blob_excess_gas_and_price); + let PreparedCall { mut evm_env, mut tx_env, .. } = + self.prepare_call_env(state, request, fee_details, block_env)?; + Self::use_block_fees(&mut evm_env, tx_env.base_mut(), block_fees); + let ResultAndState { result, state: _ } = self.transact_call_with_inspector_ref( + state, + &evm_env, + &mut inspector, + tx_env, + monad_context.as_mut().map(next_monad_context), + )?; + let (exit_reason, gas_used, out, _logs) = unpack_execution_result(result); + let access_list = inspector.access_list(); + #[cfg(feature = "monad")] + let access_list = if self.is_monad() { + monad::normalize_access_list(access_list, self.monad_hardfork()) + } else { + access_list + }; + Ok((exit_reason, out, gas_used, access_list)) + } + + /// Restores the block's base and blob base fees that a zero-fee call env clears. Like geth, + /// access lists are built at the block's fees. + fn use_block_fees( + evm_env: &mut EvmEnv, + tx_env: &mut TxEnv, + (basefee, blob_excess_gas_and_price): (u64, Option), + ) { + evm_env.block_env.basefee = basefee; + evm_env.block_env.blob_excess_gas_and_price = blob_excess_gas_and_price; + if !tx_env.blob_hashes.is_empty() && tx_env.max_fee_per_blob_gas == 0 { + tx_env.max_fee_per_blob_gas = evm_env.block_env.blob_gasprice().unwrap_or_default(); + } + } + + fn arbitrum_block_number(&self, evm_env: &EvmEnv) -> Option { + if !arbitrum::is_arbitrum_chain(self.protocol_chain_id()) { + return None; + } + + let env_block = evm_env.block_env.number; + Some( + self.get_fork() + .map_or_else(|| env_block.saturating_to(), |fork| fork.rpc_block_number(env_block)), + ) + } + + pub fn get_code_with_state( + &self, + state: &dyn DatabaseRef, + address: Address, + ) -> Result { + trace!(target: "backend", "get code for {:?}", address); + let account = state.basic_ref(address)?.unwrap_or_default(); + if account.is_empty_code_hash() { + // if the code hash is `KECCAK_EMPTY`, we check no further + return Ok(Default::default()); + } + let code = if let Some(code) = account.code { + code + } else { + state.code_by_hash_ref(account.code_hash())? + }; + Ok(code.original_bytes()) + } + + pub fn get_balance_with_state( + &self, + state: D, + address: Address, + ) -> Result + where + D: DatabaseRef, + { + trace!(target: "backend", "get balance for {:?}", address); + Ok(state.basic_ref(address)?.unwrap_or_default().balance) + } + + pub async fn transaction_by_block_number_and_index( + &self, + number: BlockNumber, + index: Index, + ) -> Result, BlockchainError> { + if let Some(block) = self.mined_block_by_number(number) { + return Ok(self.mined_transaction_by_block_hash_and_index(block.header.hash, index)); + } + + if let Some(fork) = self.get_fork() { + let number = self.convert_block_number(Some(number)); + if fork.predates_fork(number) { + return Ok(fork + .transaction_by_block_number_and_index(number, index.into()) + .await?); + } + } + + Ok(None) + } + + pub async fn transaction_by_block_hash_and_index( + &self, + hash: B256, + index: Index, + ) -> Result, BlockchainError> { + if let tx @ Some(_) = self.mined_transaction_by_block_hash_and_index(hash, index) { + return Ok(tx); + } + + if let Some(fork) = self.get_fork() { + return Ok(fork.transaction_by_block_hash_and_index(hash, index.into()).await?); + } + + Ok(None) + } + + pub fn mined_transaction_by_block_hash_and_index( + &self, + block_hash: B256, + index: Index, + ) -> Option { + let (info, block, tx) = { + let storage = self.blockchain.storage.read(); + let block = storage.blocks.get(&block_hash).cloned()?; + let index: usize = index.into(); + let tx = block.body.transactions.get(index)?.clone(); + let info = storage.transactions.get(&tx.hash())?.info.clone(); + (info, block, tx) + }; + + Some(transaction_build( + Some(info.transaction_hash), + tx, + Some(&block), + Some(info), + block.header.base_fee_per_gas(), + )) + } + + pub async fn transaction_by_hash( + &self, + hash: B256, + ) -> Result, BlockchainError> { + trace!(target: "backend", "transaction_by_hash={:?}", hash); + if let tx @ Some(_) = self.mined_transaction_by_hash(hash) { + return Ok(tx); + } + + if let Some(fork) = self.get_fork() { + return fork + .transaction_by_hash(hash) + .await + .map_err(BlockchainError::AlloyForkProvider); + } + + Ok(None) + } + + pub fn mined_transaction_by_hash(&self, hash: B256) -> Option { + let (info, block) = { + let storage = self.blockchain.storage.read(); + let MinedTransaction { info, block_hash, .. } = + storage.transactions.get(&hash)?.clone(); + let block = storage.blocks.get(&block_hash).cloned()?; + (info, block) + }; + let tx = block.body.transactions.get(info.transaction_index as usize)?.clone(); + + Some(transaction_build( + Some(info.transaction_hash), + tx, + Some(&block), + Some(info), + block.header.base_fee_per_gas(), + )) + } + + /// Returns the traces for the given transaction + pub async fn trace_transaction( + &self, + hash: B256, + ) -> Result>, BlockchainError> { + if let Some(traces) = self.mined_parity_trace_transaction(hash) { + return Ok(Some(traces)); + } + + if let Some(fork) = self.get_fork() { + return Ok(fork.trace_transaction(hash).await?); + } + + Ok(None) + } + + /// Returns the transaction trace at the given trace address, where `[]` is the root call. + pub async fn trace_get( + &self, + hash: B256, + indices: Vec, + ) -> Result, BlockchainError> { + if let Some(traces) = self.mined_parity_trace_transaction(hash) { + let trace_address = indices.iter().copied().map(usize::from).collect::>(); + return Ok(traces.into_iter().find(|trace| trace.trace.trace_address == trace_address)); + } + + if let Some(fork) = self.get_fork() { + return Ok(fork.trace_get(hash, indices).await?); + } + + Ok(None) + } + + /// Returns the traces for the given block + pub async fn trace_block( + &self, + block: BlockNumber, + ) -> Result, BlockchainError> { + ensure_mined_trace_block(block)?; + let number = self.convert_block_number(Some(block)); + if let Some(traces) = self.mined_parity_trace_block(number) { + return Ok(traces); + } + + if let Some(fork) = self.get_fork() + && fork.predates_fork_inclusive(number) + { + return Ok(fork.trace_block(number).await?); + } + + Err(BlockchainError::BlockNotFound) + } + + /// Executes a transaction call and returns requested parity trace results. + pub async fn trace_call( + &self, + request: WithOtherFields, + fee_details: FeeDetails, + trace_types: HashSet, + block_request: BlockRequest, + block_id: BlockId, + ) -> Result + where + Self: TransactionValidator, + N: Network, + { + if let BlockRequest::Number(number) = &block_request + && let Some(fork) = self.get_fork() + && fork.predates_fork(*number) + { + // Delegate the resolved block number so tags (`latest`/`pending`/`safe`/ + // `finalized`) are resolved against the fork's head instead of drifting with + // the upstream chain. Hashes are forwarded unchanged. + let resolved = match block_id { + BlockId::Hash(_) => block_id, + _ => BlockId::number(*number), + }; + return Ok(fork.trace_call(request, trace_types, resolved).await?); + } + + self.with_database_at_and_context(Some(block_request), |state, block, mut monad_context| { + let cache_db = CacheDB::new(state); + let mut inspector = + TracingInspector::new(TracingInspectorConfig::from_parity_config(&trace_types)); + let PreparedCall { evm_env, tx_env, .. } = + self.prepare_call_env(&cache_db, request, fee_details, block)?; + let result = self.transact_call_with_inspector_ref( + &cache_db, + &evm_env, + &mut inspector, + tx_env, + monad_context.as_mut().map(next_monad_context), + )?; + + parity_trace_results(inspector, &result, &trace_types, &cache_db) + }) + .await + } + + /// Replays all transactions in a block and returns the requested traces for each transaction, + /// or `None` if the block is unknown. + pub async fn trace_replay_block_transactions( + &self, + block: BlockNumber, + trace_types: HashSet, + ) -> Result>, BlockchainError> { + ensure_mined_trace_block(block)?; + let block_number = self.convert_block_number(Some(block)); + + // Try mined blocks first + if let Some(results) = + self.mined_parity_trace_replay_block_transactions(block_number, &trace_types)? + { + return Ok(Some(results)); + } + + // Fallback to fork if block predates fork + if let Some(fork) = self.get_fork() + && fork.predates_fork_inclusive(block_number) + { + return Ok(Some( + fork.trace_replay_block_transactions(block_number, trace_types).await?, + )); + } + + Ok(None) + } + + /// Replays a mined transaction and returns the requested traces, or `None` if the transaction + /// is unknown. + pub async fn trace_replay_transaction( + &self, + hash: B256, + trace_types: HashSet, + ) -> Result, BlockchainError> { + let mined = self.blockchain.storage.read().transactions.contains_key(&hash); + + // If the transaction was mined locally, replay it locally. Do not fall + // through to the fork when the local replay fails; that would misreport + // a local data problem as an upstream transaction lookup. + let full_trace = if mined { + let inspector = + TracingInspector::new(TracingInspectorConfig::from_parity_config(&trace_types)); + self.replay_tx_with_inspector(hash, inspector, |result, cache_db, inspector, _, _| { + parity_trace_results(inspector, &result, &trace_types, &cache_db) + })?? + } else if let Some(fork) = self.get_fork() { + // Not known locally: forward to the fork if present. + let Some(full_trace) = fork.trace_replay_transaction(hash, trace_types).await? else { + return Ok(None); + }; + full_trace + } else { + return Ok(None); + }; + + Ok(Some(TraceResultsWithTransactionHash { transaction_hash: hash, full_trace })) + } + + /// Traces a raw transaction without committing it to the chain state or mempool. + pub async fn trace_raw_transaction( + &self, + pending_transaction: PendingTransaction, + trace_types: HashSet, + block_request: Option>, + ) -> Result + where + N: Network, + { + let trace_config = TracingInspectorConfig::from_parity_config(&trace_types); + + self.with_database_at_and_context(block_request, |state, block_env, mut monad_context| { + let cache_db = CacheDB::new(state); + let mut evm_env = self.evm_env.read().clone(); + evm_env.block_env = block_env; + // A signed transaction cannot be impersonated, so reject senders with code that is not + // an EIP-7702 delegation, as block execution would. + evm_env.cfg_env.disable_eip3607 = false; + + let mut inspector = TracingInspector::new(trace_config); + let (result, _) = self.transact_envelope_with_inspector_ref_and_context( + &cache_db, + &evm_env, + &mut inspector, + &pending_transaction, + monad_context.as_mut().map(next_monad_context), + )?; + + parity_trace_results(inspector, &result, &trace_types, &cache_db) + }) + .await + } + + /// Traces calls sequentially against a shared in-memory state. + pub async fn trace_call_many( + &self, + calls: Vec<(WithOtherFields, HashSet)>, + block_request: Option>, + ) -> Result, BlockchainError> + where + N: Network, + { + self.with_database_at_and_context(block_request, |state, block_env, mut monad_context| { + let mut cache_db = CacheDB::new(state); + let mut results = Vec::with_capacity(calls.len()); + let mut calls = calls.into_iter().peekable(); + + while let Some((request, trace_types)) = calls.next() { + let fee_details = FeeDetails::new( + request.gas_price, + request.max_fee_per_gas, + request.max_priority_fee_per_gas, + request.max_fee_per_blob_gas, + )? + .or_zero_fees(); + let PreparedCall { evm_env, mut tx_env, simulated_tempo_tx, .. } = + self.prepare_call_env(&cache_db, request, fee_details, block_env.clone())?; + apply_tempo_envelope_identity(&mut tx_env, simulated_tempo_tx.as_ref()); + + let trace_config = TracingInspectorConfig::from_parity_config(&trace_types); + let mut inspector = TracingInspector::new(trace_config); + let result = self.transact_call_with_inspector_ref( + &cache_db, + &evm_env, + &mut inspector, + tx_env, + monad_context.as_mut().map(next_monad_context), + )?; + + let trace_result = + parity_trace_results(inspector, &result, &trace_types, &cache_db)?; + results.push(trace_result); + + if calls.peek().is_some() { + cache_db.commit(result.state); + } + } + + Ok(results) + }) + .await + } + + /// Returns the trace results for all transactions in a mined block by replaying them + fn mined_parity_trace_replay_block_transactions( + &self, + block_number: u64, + trace_types: &HashSet, + ) -> Result>, BlockchainError> { + let Some(block) = self.get_block(block_number) else { return Ok(None) }; + + // Blocks without transactions, such as genesis, have nothing to replay. + if block.body.transactions.is_empty() { + return Ok(Some(Vec::new())); + } + + // Execute this in the context of the parent state + let parent_hash = block.header.parent_hash; + let trace_config = TracingInspectorConfig::from_parity_config(trace_types); + + let read_guard = self.states.upgradable_read(); + if let Some(state) = read_guard.get_state(&parent_hash) { + self.replay_block_transactions_with_inspector(&block, state, trace_config, trace_types) + .map(Some) + } else { + let mut write_guard = RwLockUpgradableReadGuard::upgrade(read_guard); + let Some(state) = write_guard.get_on_disk_state(&parent_hash) else { + return Err(BlockchainError::HistoricalStateUnavailable(block.header.number())); + }; + self.replay_block_transactions_with_inspector(&block, state, trace_config, trace_types) + .map(Some) + } + } + + /// Replays all transactions in a block with the tracing inspector to generate TraceResults + fn replay_block_transactions_with_inspector( + &self, + block: &Block, + parent_state: &StateDb, + trace_config: TracingInspectorConfig, + trace_types: &HashSet, + ) -> Result, BlockchainError> { + let (mut cache_db, evm_env, hardfork) = self.prepare_block_replay(block, parent_state)?; + let mut results = Vec::new(); + let monad_context = self.active_monad_context_for_mined_block(block)?; + + // Execute each transaction in the block with tracing + for tx_envelope in &block.body.transactions { + let tx_hash = tx_envelope.hash(); + + // Create a fresh inspector for this transaction + let mut inspector = TracingInspector::new(trace_config); + + // Prepare transaction environment and execute + let pending_tx = self.pending_mined_transaction(tx_envelope.clone())?; + let transaction_context = + monad_execution_context_at(monad_context.as_ref(), results.len()); + let (result, _) = self.replay_envelope_with_inspector_ref_and_context( + &cache_db, + &evm_env, + &mut inspector, + &pending_tx, + EnvelopeExecution::replay(transaction_context, hardfork), + )?; + + // Build TraceResults from the inspector and execution result + let full_trace = parity_trace_results(inspector, &result, trace_types, &cache_db)?; + + results.push(TraceResultsWithTransactionHash { transaction_hash: tx_hash, full_trace }); + + // Commit the state changes for the next transaction + cache_db.commit(result.state); + } + + Ok(results) + } + + fn replay_tx_with_inspector( + &self, + hash: B256, + mut inspector: I, + f: F, + ) -> Result + where + for<'a> I: BackendInspector>>> + 'a, + for<'a> F: + FnOnce(ResultAndState, CacheDB>, I, TxEnv, EvmEnv) -> T, + { + let block = { + let storage = self.blockchain.storage.read(); + let MinedTransaction { block_hash, .. } = + storage.transactions.get(&hash).ok_or(BlockchainError::TransactionNotFound)?; + + storage.blocks.get(block_hash).cloned().ok_or(BlockchainError::BlockNotFound)? + }; + + let index = block + .body + .transactions + .iter() + .position(|tx| tx.hash() == hash) + .expect("transaction not found in block"); + + let trace = |parent_state: &StateDb| -> Result { + let (mut cache_db, evm_env, hardfork) = + self.prepare_block_replay_with_db(&block, Box::new(parent_state))?; + self.replay_mined_transaction_prefix(&mut cache_db, &evm_env, hardfork, &block, index)?; + + let target_tx = block.body.transactions[index].clone(); + let target_tx = self.pending_mined_transaction(target_tx)?; + let monad_context = self.active_monad_context_for_mined_block(&block)?; + let transaction_context = monad_execution_context_at(monad_context.as_ref(), index); + let (result, base_tx_env) = self.replay_envelope_with_inspector_ref_and_context( + &cache_db, + &evm_env, + &mut inspector, + &target_tx, + EnvelopeExecution::replay(transaction_context, hardfork), + )?; + + Ok(f(result, cache_db, inspector, base_tx_env, evm_env)) + }; + + let read_guard = self.states.upgradable_read(); + if let Some(state) = read_guard.get_state(&block.header.parent_hash) { + trace(state) + } else { + let mut write_guard = RwLockUpgradableReadGuard::upgrade(read_guard); + let state = write_guard + .get_on_disk_state(&block.header.parent_hash) + .ok_or(BlockchainError::HistoricalStateUnavailable(block.header.number()))?; + trace(state) + } + } + + // Returns the traces matching a given filter + pub async fn trace_filter( + &self, + filter: TraceFilter, + ) -> Result, BlockchainError> { + let matcher = filter.matcher(); + let best_number = self.best_number(); + let start = filter.from_block.unwrap_or(best_number); + let end = filter.to_block.unwrap_or(best_number); + + if start > best_number || end > best_number { + return Err(BlockchainError::BlockNotFound); + } + if start > end { + return Err(BlockchainError::RpcError(RpcError::invalid_params( + "invalid block range, ensure that to block is greater than from block".to_string(), + ))); + } + + let dist = end - start; + if dist > 300 { + return Err(BlockchainError::RpcError(RpcError::invalid_params( + "block range too large, currently limited to 300".to_string(), + ))); + } + + // Accumulate tasks for block range + let mut trace_tasks = vec![]; + for num in start..=end { + trace_tasks.push(self.trace_block(num.into())); + } + + // Execute tasks and filter traces + let traces = futures::future::try_join_all(trace_tasks).await?; + let filtered_traces = + traces.into_iter().flatten().filter(|trace| matcher.matches(&trace.trace)); + + // Apply after and count + let filtered_traces: Vec<_> = if let Some(after) = filter.after { + filtered_traces.skip(after as usize).collect() + } else { + filtered_traces.collect() + }; + + let filtered_traces: Vec<_> = if let Some(count) = filter.count { + filtered_traces.into_iter().take(count as usize).collect() + } else { + filtered_traces + }; + + Ok(filtered_traces) + } + + pub fn get_blobs_by_block_id( + &self, + id: impl Into, + versioned_hashes: Vec, + ) -> Result>> { + Ok(self.get_block(id).map(|block| { + block + .body + .transactions + .iter() + .filter_map(|tx| tx.as_ref().sidecar()) + .flat_map(|sidecar| { + sidecar.sidecar.blobs().iter().zip(sidecar.sidecar.commitments().iter()) + }) + .filter(|(_, commitment)| { + // Filter blobs by versioned_hashes if provided + versioned_hashes.is_empty() + || versioned_hashes.contains(&kzg_to_versioned_hash(commitment.as_slice())) + }) + .map(|(blob, _)| *blob) + .collect() + })) + } + + #[allow(clippy::large_stack_frames)] + pub fn get_blob_by_versioned_hash(&self, hash: B256) -> Result> { + let storage = self.blockchain.storage.read(); + for block in storage.blocks.values() { + for tx in &block.body.transactions { + let typed_tx = tx.as_ref(); + if let Some(sidecar) = typed_tx.sidecar() { + for versioned_hash in sidecar.sidecar.versioned_hashes() { + if versioned_hash == hash + && let Some(index) = + sidecar.sidecar.commitments().iter().position(|commitment| { + kzg_to_versioned_hash(commitment.as_slice()) == *hash + }) + && let Some(blob) = sidecar.sidecar.blobs().get(index) + { + return Ok(Some(*blob)); + } + } + } + } + } + Ok(None) + } + + /// Initialises the balance of the given accounts + #[expect(clippy::too_many_arguments)] + pub async fn with_genesis( + db: Arc>>, + env: Arc>, + networks: NetworkConfigs, + genesis: GenesisConfig, + fees: FeeManager, + fork: Arc>>, + enable_steps_tracing: bool, + print_logs: bool, + print_traces: bool, + call_trace_decoder: Arc, + prune_state_history_config: PruneStateHistoryConfig, + max_persisted_states: Option, + transaction_block_keeper: Option, + automine_block_time: Option, + cache_path: Option, + node_config: Arc>, + ) -> Result { + let last_fork_cache_source = fork.read().as_ref().and_then(ForkCacheSource::from_fork); + // if this is a fork then adjust the blockchain storage + let blockchain = if let Some(fork) = fork.read().as_ref() { + trace!(target: "backend", "using forked blockchain at {}", fork.block_number()); + Blockchain::forked(fork.block_number(), fork.block_hash(), fork.total_difficulty()) + } else { + let header = if let Some(genesis) = genesis.genesis_init.as_ref() { + genesis_json_header(genesis) + } else { + genesis_header( + &env.read(), + fees.is_eip1559().then(|| fees.base_fee()), + genesis.timestamp, + genesis.number, + ) + }; + Blockchain::new(foundry_header(&networks, header)) + }; + + // Sync EVM block.number with genesis for non-fork mode. + // Fork mode syncs in setup_fork_db_config() instead. + if fork.read().is_none() { + env.write().block_env.number = U256::from(genesis.number); + + // The genesis block keeps its base fee, but the next block must already follow Tempo's + // rules (e.g. T7 clamps the seed down to the cap). Fork mode seeds this from the fork + // block instead. + if fees.tempo_hardfork().is_some() { + let env = env.read(); + let next_base_fee = fees.get_next_block_base_fee_per_gas( + 0, + env.block_env.gas_limit, + env.block_env.basefee, + ); + drop(env); + fees.set_base_fee(next_base_fee); + } + } + + let start_timestamp = if let Some(fork) = fork.read().as_ref() { + fork.timestamp() + } else { + genesis.timestamp + }; + + let mut states = if prune_state_history_config.is_config_enabled() { + // if prune state history is enabled, configure the state cache only for memory + prune_state_history_config + .max_memory_history + .map(|limit| InMemoryBlockStates::new(limit, 0)) + .unwrap_or_default() + .memory_only() + } else if max_persisted_states.is_some() { + max_persisted_states + .map(|limit| InMemoryBlockStates::new(DEFAULT_HISTORY_LIMIT, limit)) + .unwrap_or_default() + } else { + Default::default() + }; + + if let Some(cache_path) = cache_path { + states = states.disk_path(cache_path); + } + + let (slots_in_an_epoch, precompile_factory, disable_pool_balance_checks, hardfork) = { + let cfg = node_config.read().await; + ( + cfg.slots_in_an_epoch, + cfg.precompile_factory.clone(), + cfg.disable_pool_balance_checks, + cfg.get_hardfork(), + ) + }; + let startup_cache_lease = if fork.read().is_some() { + let db = db.read().await; + let inner = db + .maybe_inner() + .map_err(|err| eyre::eyre!("fork database is missing its cache backend: {err}"))?; + StagedForkCacheLease::for_db(inner) + } else { + StagedForkCacheLease::default() + }; + let startup_fork_cache_user = + StagedForkDbUser { db: Some(Arc::clone(&db)), cache_lease: startup_cache_lease }; + #[cfg(feature = "base")] + let base_activation_admin = node_config.read().await.base_activation_admin; + + let backend = Self { + db, + blockchain, + states: Arc::new(RwLock::new(states)), + evm_env: env, + networks, + #[cfg(feature = "base")] + base_activation_admin, + hardfork: Arc::new(RwLock::new(hardfork)), + fork, + last_fork_cache_source: Arc::new(RwLock::new(last_fork_cache_source)), + time: TimeManager::new(start_timestamp), + cheats: Default::default(), + new_block_listeners: Default::default(), + fees, + genesis, + active_state_snapshots: Arc::new(Mutex::new(Default::default())), + enable_steps_tracing, + print_logs, + print_traces, + call_trace_decoder: Arc::new(RwLock::new(call_trace_decoder)), + prune_state_history_config, + transaction_block_keeper, + node_config, + slots_in_an_epoch, + precompile_factory, + mining: Arc::new(tokio::sync::Mutex::new(())), + #[cfg(test)] + mining_commit_hook: Default::default(), + disable_pool_balance_checks, + startup_fork_cache_user, + }; + + #[cfg(feature = "monad")] + let monad_fork = + if backend.networks.is_monad() { backend.fork.read().clone() } else { None }; + #[cfg(feature = "monad")] + if let Some(fork) = monad_fork { + monad::cache_fork_context(&fork).await?; + } + + if let Some(interval_block_time) = automine_block_time { + backend.update_interval_mine_block_time(interval_block_time); + } + + // Note: this can only fail in forking mode, in which case we can't recover + backend.apply_genesis().await.wrap_err("failed to create genesis")?; + Ok(backend) + } + + /// Applies the configured genesis settings + /// + /// This will fund, create the genesis accounts + async fn apply_genesis(&self) -> Result<(), DatabaseError> { + trace!(target: "backend", "setting genesis balances"); + + if self.fork.read().is_some() { + let parent_env = self.evm_env.read().clone(); + return self + .apply_fork_genesis( + Arc::clone(&self.db), + self.startup_fork_cache_user.cache_lease.clone(), + &parent_env, + self.best_hash(), + self.hardfork(), + ) + .await; + } + + let mut db = self.db.write().await; + for (account, info) in self.genesis.account_infos() { + db.insert_account(account, info); + } + + // insert the new genesis hash to the database so it's available for the next block in + // the evm + db.insert_block_hash(U256::from(self.best_number()), self.best_hash()); + + if let Some(transitions) = + self.ethereum_block_transitions(self.hardfork(), None, BlockExecutionKind::Complete) + { + if transitions.hardfork >= EthereumHardfork::Cancun { + db.set_code(eip4788::BEACON_ROOTS_ADDRESS, eip4788::BEACON_ROOTS_CODE.clone())?; + } + if transitions.hardfork >= EthereumHardfork::Prague { + db.set_code( + eip2935::HISTORY_STORAGE_ADDRESS, + eip2935::HISTORY_STORAGE_CODE.clone(), + )?; + db.set_code( + eip7002::WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, + eip7002::WITHDRAWAL_REQUEST_PREDEPLOY_CODE.clone(), + )?; + db.set_code( + eip7251::CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS, + eip7251::CONSOLIDATION_REQUEST_PREDEPLOY_CODE.clone(), + )?; + } + } + // apply the genesis.json alloc + self.genesis.apply_genesis_json_alloc(&mut **db)?; + drop(db); + self.apply_funded_accounts(&self.db).await?; + + // Seed Base protocol accounts and one-time state transitions for standalone nodes. + #[cfg(feature = "base")] + if self.is_base() && !self.is_fork() { + let upgrade = self.base_upgrade(); + Self::apply_base_genesis(&mut **self.db.write().await, upgrade)?; + } + + // Initialize Tempo precompiles and fee tokens when in Tempo mode (not in fork mode). + // In fork mode, precompiles are inherited from the forked origin. + if self.networks.is_tempo() && !self.is_fork() { + let chain_id = self.evm_env.read().cfg_env.chain_id; + let timestamp = self.genesis.timestamp; + let test_accounts: Vec
= self.genesis.accounts.clone(); + let hardfork = self.tempo_hardfork(); + let mut db = self.db.write().await; + crate::eth::backend::tempo::initialize_tempo_precompiles( + &mut **db, + chain_id, + timestamp, + &test_accounts, + hardfork, + ) + .map_err(|e| { + tracing::error!(target: "backend", "failed to initialize Tempo precompiles: {e}"); + DatabaseError::AnyRequest(Arc::new(eyre::eyre!("{e}"))) + })?; + trace!(target: "backend", "initialized Tempo precompiles and fee tokens for {} accounts", test_accounts.len()); + } + + trace!(target: "backend", "set genesis balances"); + + Ok(()) + } + + /// Applies genesis allocations to a fork database before it becomes live. + async fn apply_fork_genesis( + &self, + db: Arc>>, + cache_lease: StagedForkCacheLease, + parent_env: &EvmEnv, + parent_hash: B256, + hardfork: FoundryHardfork, + ) -> Result<(), DatabaseError> { + let user = StagedForkDbUser { db: Some(db), cache_lease }; + let mut genesis_accounts = JoinSet::new(); + for address in self.genesis.accounts.iter().copied() { + let task_user = StagedForkDbUser { + db: Some(Arc::clone(user.db())), + cache_lease: user.cache_lease.clone(), + }; + + // The fork database can fetch independent accounts concurrently. + genesis_accounts.spawn(async move { + let db = task_user.db().read().await; + let info = db.basic_ref(address)?.unwrap_or_default(); + Ok::<_, DatabaseError>((address, info)) + }); + } + + let mut account_infos = Vec::with_capacity(self.genesis.accounts.len()); + while let Some(result) = genesis_accounts.join_next().await { + match result { + Ok(Ok(account)) => account_infos.push(account), + Ok(Err(err)) => { + genesis_accounts.shutdown().await; + return Err(err); + } + Err(err) => { + genesis_accounts.shutdown().await; + return Err(DatabaseError::AnyRequest(Arc::new(eyre::eyre!( + "fork genesis account task failed: {err}" + )))); + } + } + } + let mut db_guard = user.db().write().await; + for (address, mut info) in account_infos { + info.balance = self.genesis.balance; + db_guard.insert_account(address, info); + } + self.genesis.apply_genesis_json_alloc(&mut **db_guard)?; + // A fork taken before the remote chain activated Denim has no BaseTime deployment, but + // Denim blocks mined locally still send the BaseTime deposit. Existing deployments and + // their admin are preserved. + #[cfg(feature = "base")] + if matches!(hardfork, FoundryHardfork::Base(upgrade) if upgrade >= BaseUpgrade::Denim) { + ensure_base_time_predeploy(&mut **db_guard, false)?; + self.ensure_fork_accepts_system_transactions( + &**db_guard, + parent_env, + parent_hash, + hardfork, + )?; + } + #[cfg(not(feature = "base"))] + let _ = (parent_env, parent_hash, hardfork); + drop(db_guard); + self.apply_funded_accounts(user.db()).await + } + + /// Applies explicit `--fund` balances while preserving account metadata inherited from a fork. + async fn apply_funded_accounts( + &self, + db: &Arc>>, + ) -> Result<(), DatabaseError> { + let funded_accounts = self.node_config.read().await.funded_accounts.clone(); + let mut accounts = Vec::with_capacity(funded_accounts.len()); + for (address, balance) in funded_accounts { + let mut info = db.read().await.basic_ref(address)?.unwrap_or_default(); + info.balance = balance; + accounts.push((address, info)); + } + let mut db = db.write().await; + for (address, info) in accounts { + db.insert_account(address, info); + } + Ok(()) + } + + /// Seeds Base protocol accounts and one-time state transitions for a standalone node. + #[cfg(feature = "base")] + fn apply_base_genesis(mut db: &mut dyn Db, upgrade: BaseUpgrade) -> Result<(), DatabaseError> { + for address in [ + Predeploys::L1_BLOCK_INFO, + Predeploys::SEQUENCER_FEE_VAULT, + Predeploys::BASE_FEE_VAULT, + Predeploys::L1_FEE_VAULT, + Predeploys::OPERATOR_FEE_VAULT, + Predeploys::BASE_TIME, + ] { + if db.basic(address)?.is_none() { + db.insert_account(address, AccountInfo::default()); + } + } + + db.set_storage_at( + Predeploys::L1_BLOCK_INFO, + L1BlockInfo::L1_BASE_FEE_SLOT.into(), + U256::from(DEFAULT_BASE_L1_BASE_FEE).into(), + )?; + db.set_storage_at( + Predeploys::L1_BLOCK_INFO, + L1BlockInfo::ECOTONE_L1_BLOB_BASE_FEE_SLOT.into(), + B256::with_last_byte(1), + )?; + let mut l1_fee_scalars = [0u8; 32]; + l1_fee_scalars + [L1BlockInfo::BASE_FEE_SCALAR_OFFSET..L1BlockInfo::BASE_FEE_SCALAR_OFFSET + 4] + .copy_from_slice(&DEFAULT_BASE_L1_FEE_SCALAR.to_be_bytes()); + db.set_storage_at( + Predeploys::L1_BLOCK_INFO, + L1BlockInfo::ECOTONE_L1_FEE_SCALARS_SLOT.into(), + B256::from(l1_fee_scalars), + )?; + + if upgrade >= BaseUpgrade::Canyon { + let upgrades = ChainUpgrades::new([(BaseUpgrade::Canyon, ForkCondition::Timestamp(1))]); + ensure_create2_deployer(upgrades, 1, &mut db)?; + } + if upgrade >= BaseUpgrade::Denim { + ensure_base_time_predeploy(&mut *db, true)?; + } + if upgrade >= BaseUpgrade::Zenith { + // Zenith is genesis-only and is not stored by ChainUpgrades. + let mut upgrades = RollupConfig::default(); + upgrades.set_upgrade_activation_timestamp(BaseUpgrade::Zenith, 0); + ensure_eip8130_system_accounts(upgrades, 1, &mut db)?; + } + + // Give the installed Base precompiles a sentinel byte so Solidity's `extcodesize` + // check on high-level calls to functions without return data does not revert in the + // caller. `ensure_eip8130_system_accounts` only covers the Zenith nonce manager. + for address in base_code_sentinel_addresses(upgrade) { + let mut account = db.basic(address)?.unwrap_or_default(); + if account.code.as_ref().is_none_or(|code| code.is_empty()) { + let code = + revm::state::Bytecode::new_legacy(Bytes::from_static(SYSTEM_PRECOMPILE_STUB)); + account.set_code(code); + db.insert_account(address, account); + } + } + + Ok(()) + } + + /// Populates a detached in-memory database from explicit reset inputs. + #[allow(clippy::too_many_arguments)] + fn populate_memory_db( + db: &mut dyn Db, + genesis: &GenesisConfig, + funded_accounts: &HashMap, + hardfork: FoundryHardfork, + chain_id: u64, + is_tempo: bool, + tempo_hardfork: Option, + genesis_hash: B256, + install_create2_deployer: bool, + ) -> Result<(), DatabaseError> { + for (account, info) in genesis.account_infos() { + db.insert_account(account, info); + } + db.insert_block_hash(U256::from(genesis.number), genesis_hash); + + if let FoundryHardfork::Ethereum(hardfork) = hardfork { + if hardfork >= EthereumHardfork::Cancun { + db.set_code(eip4788::BEACON_ROOTS_ADDRESS, eip4788::BEACON_ROOTS_CODE.clone())?; + } + if hardfork >= EthereumHardfork::Prague { + db.set_code( + eip2935::HISTORY_STORAGE_ADDRESS, + eip2935::HISTORY_STORAGE_CODE.clone(), + )?; + db.set_code( + eip7002::WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, + eip7002::WITHDRAWAL_REQUEST_PREDEPLOY_CODE.clone(), + )?; + db.set_code( + eip7251::CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS, + eip7251::CONSOLIDATION_REQUEST_PREDEPLOY_CODE.clone(), + )?; + } + } + + genesis.apply_genesis_json_alloc(db)?; + for (&address, &balance) in funded_accounts { + let mut info = db.basic_ref(address)?.unwrap_or_default(); + info.balance = balance; + db.insert_account(address, info); + } + + if is_tempo { + let hardfork = tempo_hardfork.ok_or_else(|| { + DatabaseError::AnyRequest(Arc::new(eyre::eyre!( + "missing Tempo hardfork during memory reset" + ))) + })?; + crate::eth::backend::tempo::initialize_tempo_precompiles( + db, + chain_id, + genesis.timestamp, + &genesis.accounts, + hardfork, + ) + .map_err(|err| DatabaseError::AnyRequest(Arc::new(eyre::eyre!("{err}"))))?; + } + + if install_create2_deployer { + db.set_code( + DEFAULT_CREATE2_DEPLOYER, + Bytes::from_static(DEFAULT_CREATE2_DEPLOYER_RUNTIME_CODE), + )?; + } + + Ok(()) + } + + /// Prepares a fresh fork without mutating the live backend. + pub(crate) async fn prepare_fork_reset( + &self, + forking: Forking, + serving_instance_id: B256, + ) -> Result { + let previous_fork = self.get_fork(); + let previous_source = self + .last_fork_cache_source + .read() + .clone() + .or_else(|| previous_fork.as_ref().and_then(ForkCacheSource::from_fork)); + let configured_rpc_urls = self.node_config.read().await.fork_urls.clone(); + let rpc_url_was_provided = forking.json_rpc_url.is_some(); + let target_rpc_urls = if let Some(url) = forking.json_rpc_url { + vec![url] + } else if !configured_rpc_urls.is_empty() { + configured_rpc_urls + } else { + previous_fork + .as_ref() + .map(|fork| fork.config.read().fork_urls.clone()) + .filter(|urls| !urls.is_empty()) + .ok_or_else(|| { + RpcError::invalid_params( + "Forking not enabled and RPC URL not provided to start forking", + ) + })? + }; + let flush_old_cache = previous_fork.is_some(); + if flush_old_cache { + // Staging opens a separate BlockchainDb from disk. Persist the live remote cache first + // so an unchanged source and block can reuse it without copying locally modified state. + self.db.write().await.maybe_flush_cache().map_err(BlockchainError::Internal)?; + } + + for _ in 0..3 { + if let Some(staged) = self + .stage_fork_reset( + &target_rpc_urls, + forking.block_number, + serving_instance_id, + previous_source.clone(), + flush_old_cache, + rpc_url_was_provided, + ) + .await? + { + return Ok(staged); + } + } + Err(BlockchainError::Internal( + "fork endpoint changed while the replacement was being staged".to_string(), + )) + } + + /// Builds and validates one complete fork replacement without mutating the live backend. + async fn stage_fork_reset( + &self, + target_rpc_urls: &[String], + block_number: Option, + serving_instance_id: B256, + previous_source: Option, + flush_old_cache: bool, + rpc_url_was_provided: bool, + ) -> Result, BlockchainError> { + let target_rpc_url = target_rpc_urls.first().ok_or_else(|| { + BlockchainError::Internal("at least one fork URL is required".to_string()) + })?; + let mut staged_config = self.node_config.read().await.clone(); + if rpc_url_was_provided { + staged_config.fork_chain_id = None; + } + let configured_endpoint_is_anvil = staged_config.fork_endpoint_is_anvil + && staged_config.fork_urls.contains(target_rpc_url); + let cached_endpoint_is_anvil = previous_source.as_ref().is_some_and(|source| { + source.rpc_url == *target_rpc_url && source.endpoint_identity.is_authoritative() + }); + staged_config.fork_endpoint_is_anvil = + configured_endpoint_is_anvil || cached_endpoint_is_anvil; + staged_config.fork_urls = target_rpc_urls.to_vec(); + staged_config.fork_choice = block_number.map(|number| ForkChoice::Block(number as i128)); + let mut staged_env = self.evm_env.read().clone(); + staged_config.apply_tempo_fork_beneficiary_default(&mut staged_env); + let staged_fees = self.fees.detached(); + let (mut staged_db, staged_client_config) = staged_config + .setup_fork_db_config_for_reset( + target_rpc_url.clone(), + &mut staged_env, + &staged_fees, + self.networks, + ) + .await?; + let cache_lease = StagedForkCacheLease::for_db(staged_db.inner()); + let cache_identity_changed = previous_source.as_ref().is_some_and(|source| { + source.authoritative_identity_changed_at_same_url( + target_rpc_url, + staged_client_config.endpoint_identity, + ) + }); + if cache_identity_changed { + staged_db.clear_into_state_snapshot(); + staged_db.insert_block_hash( + U256::from(staged_client_config.block_number), + staged_client_config.block_hash, + ); + } + if !staged_config.no_bal && !staged_config.no_fork_node_info { + staged_client_config.prefill_cache(staged_db.inner()).await; + } + let mut invalidated_cache_namespaces = Vec::new(); + if cache_identity_changed && !staged_config.no_storage_caching { + if let Some(source) = &previous_source + && let Some(namespace) = ForkCacheNamespace::new( + source.endpoint_identity.source_chain_id, + target_rpc_url, + ) + { + invalidated_cache_namespaces.push(namespace); + } + if let Some(namespace) = + ForkCacheNamespace::new(staged_client_config.chain_id, target_rpc_url) + && !invalidated_cache_namespaces.contains(&namespace) + { + invalidated_cache_namespaces.push(namespace); + } + } + let discard_old_cached_state = + cache_identity_changed && flush_old_cache && !invalidated_cache_namespaces.is_empty(); + let staged_db: Arc>> = + Arc::new(AsyncRwLock::new(Box::new(staged_db))); + let staged_fork = ClientFork::new(staged_client_config.clone(), Arc::clone(&staged_db)); + let attempt = async { + let target_networks = + staged_client_config.endpoint_identity.network_profile.unwrap_or_default(); + if !staged_config.has_explicit_network_selection() + && !self.networks.supports_fork_source(&target_networks) + { + return Err(RpcError::invalid_params(format!( + "cannot reset Anvil across network families ({} -> {}); start a new \ + instance with matching network configuration", + self.execution_profile_name(), + target_networks.execution_profile_name() + )) + .into()); + } + if staged_client_config.endpoint_identity.instance_id == Some(serving_instance_id) { + return Err( + RpcError::invalid_params("cannot reset Anvil to its own RPC endpoint").into() + ); + } + let fork_block = staged_fork + .block_by_number(staged_fork.block_number()) + .await? + .ok_or(BlockchainError::BlockNotFound)?; + if fork_block.header.hash != staged_client_config.block_hash { + return Ok(None); + } + self.apply_fork_genesis( + Arc::clone(&staged_db), + cache_lease.clone(), + &staged_env, + staged_client_config.block_hash, + staged_client_config.hardfork.unwrap_or_else(|| staged_config.get_hardfork()), + ) + .await?; + + #[cfg(feature = "monad")] + if self.is_monad() { + monad::cache_fork_context(&staged_fork).await?; + } + + if !staged_config + .fork_urls_match_context( + target_rpc_urls, + staged_client_config.endpoint_identity, + staged_client_config.block_number, + staged_client_config.block_hash, + ) + .await? + { + return Ok(None); + } + + Ok(Some((fork_block, staged_fork.storage.read().clone()))) + } + .await; + drop(staged_fork); + let (fork_block, staged_storage) = match attempt { + Ok(Some(staged)) => staged, + Ok(None) => { + drop(staged_db); + self.rollback_staged_fork_cache(cache_lease, flush_old_cache).await?; + return Ok(None); + } + Err(err) => { + drop(staged_db); + self.rollback_staged_fork_cache(cache_lease, flush_old_cache).await?; + return Err(err); + } + }; + let staged_db = match Arc::try_unwrap(staged_db) { + Ok(staged_db) => staged_db.into_inner(), + Err(staged_db) => { + drop(staged_db); + self.rollback_staged_fork_cache(cache_lease, flush_old_cache).await?; + return Err(BlockchainError::Internal( + "staged fork database still has active references".to_string(), + )); + } + }; + + let fork = ClientFork::new(staged_client_config, Arc::clone(&self.db)); + *fork.storage.write() = staged_storage; + Ok(Some(StagedForkReset { + node_config: staged_config, + db: staged_db, + fees: staged_fees, + evm_env: staged_env, + fork, + timestamp: fork_block.header.timestamp(), + discard_old_cached_state, + invalidated_cache_namespaces, + flush_old_cache, + cache_lease, + })) + } + + async fn rollback_staged_fork_cache( + &self, + cache_lease: StagedForkCacheLease, + restore_live_cache: bool, + ) -> Result<(), BlockchainError> { + let rollback_err = cache_lease.rollback().err(); + // If immediate invalidation failed, dropping the final lease retries cleanup before the + // live cache is restored at a potentially shared path. + drop(cache_lease); + let restore_err = + if restore_live_cache { self.db.read().await.maybe_flush_cache().err() } else { None }; + match (rollback_err, restore_err) { + (None, None) => Ok(()), + (Some(err), None) => Err(err), + (None, Some(err)) => Err(BlockchainError::Internal(format!( + "failed to restore the live fork cache after staged reset rollback: {err}" + ))), + (Some(rollback), Some(restore)) => Err(BlockchainError::Internal(format!( + "{rollback}; restoring the live fork cache also failed: {restore}" + ))), + } + } + + /// Atomically publishes a fully prepared fork replacement. + pub(crate) async fn commit_fork_reset( + &self, + staged: StagedForkReset, + ) -> Result<(), BlockchainError> { + let fork_block_number = staged.fork.block_number(); + let fork_block_hash = staged.fork.block_hash(); + let fork_total_difficulty = staged.fork.total_difficulty(); + let fork_cache_source = ForkCacheSource::from_fork(&staged.fork); + + // Acquire asynchronous write guards before flushing and replacing the live database so no + // old-context request can populate its cache between those operations. + let mut node_config = self.node_config.write().await; + let mut db = self.db.write().await; + if staged.flush_old_cache { + db.maybe_flush_cache().map_err(BlockchainError::Internal)?; + } + if let Err(err) = + staged.invalidated_cache_namespaces.iter().try_for_each(ForkCacheNamespace::invalidate) + { + // Prevent the rejected staged backend from flushing partial target state, then restore + // the still-live cache after the endpoint namespace cleanup failed. + let StagedForkReset { db: staged_db, cache_lease, flush_old_cache, .. } = staged; + drop(staged_db); + let rollback_err = cache_lease.rollback().err(); + drop(cache_lease); + let restore_err = if flush_old_cache { db.maybe_flush_cache().err() } else { None }; + let mut message = err.to_string(); + if let Some(err) = rollback_err { + message.push_str(&format!("; staged cache rollback also failed: {err}")); + } + if let Some(err) = restore_err { + message.push_str(&format!("; restoring the live fork cache also failed: {err}")); + } + return Err(BlockchainError::Internal(message)); + } + if staged.discard_old_cached_state { + db.clear_into_state_snapshot(); + } + staged.cache_lease.disarm(); + let StagedForkReset { + node_config: staged_node_config, + db: staged_db, + fees, + evm_env, + fork, + timestamp, + .. + } = staged; + *node_config = staged_node_config; + *db = staged_db; + self.fees.replace_from(&fees); + *self.evm_env.write() = evm_env; + *self.fork.write() = Some(fork); + *self.last_fork_cache_source.write() = fork_cache_source; + *self.blockchain.storage.write() = + BlockchainStorage::forked(fork_block_number, fork_block_hash, fork_total_difficulty); + self.states.write().clear(); + self.active_state_snapshots.lock().clear(); + self.time.reset(timestamp); + self.cheats.clear_next_block_overrides(); + + trace!(target: "backend", "reset fork"); + Ok(()) + } + + /// Builds a complete in-memory replacement without mutating the live backend. + pub(crate) async fn prepare_memory_reset( + &self, + ) -> Result, BlockchainError> { + let reset_from_fork = self.is_fork(); + let genesis_timestamp = self.genesis.timestamp; + let genesis_number = self.genesis.number; + let mut staged_config = self.node_config.read().await.clone(); + staged_config.fork_source_chain_id = None; + staged_config.fork_execution_chain_id = None; + staged_config.fork_endpoint_is_anvil = false; + staged_config.restore_fork_overrides(); + let local_chain_id = staged_config.get_chain_id(); + staged_config.update_wallet_chain_id(local_chain_id); + let ( + local_gas_limit, + local_base_fee, + local_base_fee_is_explicit, + local_gas_price, + local_blob_params, + local_blob_excess_gas_and_price, + local_beneficiary, + install_create2_deployer, + ) = { + ( + staged_config.gas_limit(), + staged_config.get_base_fee(), + staged_config.base_fee.is_some() + || staged_config + .genesis + .as_ref() + .is_some_and(|genesis| genesis.base_fee_per_gas.is_some()), + staged_config.get_gas_price(), + staged_config.get_blob_params(), + staged_config.get_blob_excess_gas_and_price(), + staged_config.genesis.as_ref().map(|genesis| genesis.coinbase).unwrap_or_default(), + !staged_config.disable_default_create2_deployer, + ) + }; + + let local_hardfork = staged_config.get_hardfork(); + let local_spec = SpecId::from(local_hardfork); + let local_tempo_hardfork = + self.networks.is_tempo().then(|| TempoHardfork::from(local_hardfork)); + let staged_fees = self.fees.detached(); + staged_fees.set_execution_rules( + local_spec, + self.networks.base_fee_params(genesis_timestamp), + local_tempo_hardfork, + ); + #[cfg(feature = "optimism")] + if self.networks.is_optimism() { + staged_fees.set_optimism_hardfork(local_hardfork.into()); + } + staged_fees.set_blob_params(local_blob_params); + staged_fees.set_blob_excess_gas_and_price(local_blob_excess_gas_and_price); + + // Explicit local configuration always wins, while Tempo configuration uses the + // hardfork's own seed. An implicit in-memory Ethereum chain otherwise keeps its live base + // fee in the reset genesis; returning from a fork restores the local default instead of + // leaking remote fee state. Compute this up front so the env, storage, and fee manager all + // agree. + let preserve_live_base_fee = + !reset_from_fork && !local_base_fee_is_explicit && local_tempo_hardfork.is_none(); + let genesis_base_fee = + if preserve_live_base_fee { staged_fees.base_fee() } else { local_base_fee }; + + let mut staged_cfg = CfgEnv::new(); + staged_cfg.set_spec_and_mainnet_gas_params(local_spec); + staged_cfg.chain_id = local_chain_id; + staged_cfg.limit_contract_code_size = staged_config.code_size_limit; + staged_cfg.disable_eip3607 = true; + staged_cfg.disable_block_gas_limit = staged_config.disable_block_gas_limit; + if !staged_config.enable_tx_gas_limit { + staged_cfg.tx_gas_limit_cap = Some(u64::MAX); + } + if let Some(memory_limit) = staged_config.memory_limit { + staged_cfg.memory_limit = memory_limit; + } + let staged_env = EvmEnv::new( + staged_cfg, + BlockEnv { + number: U256::from(genesis_number), + beneficiary: local_beneficiary, + timestamp: U256::from(genesis_timestamp), + gas_limit: local_gas_limit, + basefee: genesis_base_fee, + prevrandao: Some(B256::ZERO), + ..Default::default() + }, + ); + + let base_fee = staged_fees.is_eip1559().then_some(genesis_base_fee); + let header = genesis_header(&staged_env, base_fee, genesis_timestamp, genesis_number); + let staged_storage = BlockchainStorage::new(foundry_header(&self.networks, header)); + + // Seed the next block's fee state. Tempo always advances through its hardfork rule, an + // implicit in-memory Ethereum reset restores Anvil's default, and explicit or + // fork-to-memory Ethereum resets retain the local configured value. + staged_fees.set_base_fee(genesis_base_fee); + if staged_fees.is_eip1559() { + let next_base_fee = if staged_fees.tempo_hardfork().is_some() { + staged_fees.get_next_block_base_fee_per_gas( + 0, + staged_env.block_env.gas_limit, + genesis_base_fee, + ) + } else if preserve_live_base_fee { + crate::eth::fees::INITIAL_BASE_FEE + } else { + genesis_base_fee + }; + staged_fees.set_base_fee(next_base_fee); + } + staged_fees.set_gas_price(local_gas_price); + + let mut staged_db: Box = Box::new(StateRootDb::new( + self.prune_state_history_config.is_state_history_supported(), + )); + Self::populate_memory_db( + &mut *staged_db, + &self.genesis, + &staged_config.funded_accounts, + local_hardfork, + local_chain_id, + self.networks.is_tempo(), + local_tempo_hardfork, + staged_storage.genesis_hash, + install_create2_deployer, + )?; + #[cfg(feature = "base")] + if self.is_base() { + let upgrade = match local_hardfork { + FoundryHardfork::Base(upgrade) => upgrade, + _ => BaseUpgrade::Azul, + }; + Self::apply_base_genesis(&mut *staged_db, upgrade)?; + } + + Ok(StagedMemoryReset { + node_config: staged_config, + db: staged_db, + fees: staged_fees, + evm_env: staged_env, + hardfork: local_hardfork, + storage: staged_storage, + timestamp: genesis_timestamp, + flush_old_cache: reset_from_fork, + }) + } + + /// Atomically publishes a fully prepared in-memory replacement. + pub(crate) async fn commit_memory_reset( + &self, + staged: StagedMemoryReset, + ) -> Result<(), BlockchainError> { + let StagedMemoryReset { + node_config, + db, + fees, + evm_env, + hardfork, + storage, + timestamp, + flush_old_cache, + } = staged; + let mut live_config = self.node_config.write().await; + let mut live_db = self.db.write().await; + if flush_old_cache { + live_db.maybe_flush_cache().map_err(BlockchainError::Internal)?; + } + + *live_config = node_config; + *live_db = db; + self.fees.replace_from(&fees); + *self.evm_env.write() = evm_env; + *self.hardfork.write() = hardfork; + *self.fork.write() = None; + *self.blockchain.storage.write() = storage; + self.states.write().clear(); + self.active_state_snapshots.lock().clear(); + self.time.reset(timestamp); + self.cheats.clear_next_block_overrides(); + trace!(target: "backend", "reset to fresh in-memory state"); + Ok(()) + } + + /// Reverts the state to the state snapshot identified by the given `id`. + pub async fn revert_state_snapshot(&self, id: U256) -> Result + where + N::ReceiptEnvelope: TxReceipt, + { + let Some((num, hash, fees, time, next_block)) = + self.active_state_snapshots.lock().get(&id).map(|snapshot| { + ( + snapshot.block_number, + snapshot.block_hash, + snapshot.fees, + snapshot.time, + snapshot.next_block, + ) + }) + else { + return Ok(false); + }; + let block = self.block_by_hash(hash).await?.ok_or(BlockchainError::BlockNotFound)?; + let removed_logs = self.removed_logs_since(num); + if !self.db.write().await.revert_state(id, RevertStateSnapshotAction::RevertRemove) { + return Ok(false); + } + { + let mut snapshots = self.active_state_snapshots.lock(); + snapshots.remove(&id); + snapshots.retain(|snapshot_id, _| *snapshot_id < id); + } + // Revert the storage that's newer than the snapshot. + let removed_blocks = self.blockchain.storage.write().unwind_to(num, hash); + let removed_hashes: Vec<_> = removed_blocks.iter().map(|b| b.header.hash_slow()).collect(); + self.states.write().remove_block_states(&removed_hashes); + if !removed_logs.is_empty() { + self.notify_on_removed_logs(removed_logs); + } + + self.time.restore(time); + self.cheats.restore_next_block_overrides(&next_block); + + { + let mut env = self.evm_env.write(); + env.block_env = BlockEnv { + number: self.evm_block_number(num), + timestamp: U256::from(block.header.timestamp()), + difficulty: block.header.difficulty(), + // ensures prevrandao is set + prevrandao: Some(block.header.mix_hash().unwrap_or_default()), + gas_limit: block.header.gas_limit(), + // Keep previous `beneficiary` and `basefee` value + beneficiary: env.block_env.beneficiary, + basefee: env.block_env.basefee, + ..Default::default() + }; + } + self.fees.restore(fees); + Ok(true) + } + + /// executes the transactions without writing to the underlying database + pub async fn inspect_tx( + &self, + tx: Arc>, + ) -> Result< + (InstructionResult, Option, u64, State, Vec), + BlockchainError, + > { + let db = self.db.read().await; + let evm_env = self.next_evm_env(&**db)?; + let mut inspector = self.build_inspector(); + #[cfg(feature = "monad")] + let mut monad_context = self + .is_monad() + .then(|| self.monad_context_for_child_of(self.best_hash())) + .transpose()?; + #[cfg(not(feature = "monad"))] + let mut monad_context = None; + let (ResultAndState { result, state }, _) = self + .transact_envelope_with_inspector_ref_and_context( + &**db, + &evm_env, + &mut inspector, + &tx.pending_transaction, + monad_context.as_mut().map(next_monad_context), + )?; + let (exit_reason, gas_used, out, logs) = unpack_execution_result(result); + + inspector.print_logs(); + + if self.print_traces { + inspector.print_traces(self.call_trace_decoder()); + } + + Ok((exit_reason, out, gas_used, state, logs)) + } +} + +impl Backend +where + N::ReceiptEnvelope: TxReceipt, +{ + /// Returns all `Log`s mined by the node that were emitted in the `block` and match the `Filter` + fn mined_logs_for_block(&self, filter: Filter, block: Block, block_hash: B256) -> Vec { + let mut all_logs = Vec::new(); + let mut block_log_index = 0u32; + + let storage = self.blockchain.storage.read(); + + for tx in block.body.transactions { + let Some(tx) = storage.transactions.get(&tx.hash()) else { + continue; + }; + + let logs = tx.receipt.logs(); + let transaction_hash = tx.info.transaction_hash; + + for log in logs { + if filter.matches(log) { + all_logs.push(Log { + inner: log.clone(), + block_hash: Some(block_hash), + block_number: Some(block.header.number()), + block_timestamp: Some(block.header.timestamp()), + transaction_hash: Some(transaction_hash), + transaction_index: Some(tx.info.transaction_index), + log_index: Some(block_log_index as u64), + removed: false, + }); + } + block_log_index += 1; + } + } + all_logs + } + + /// Returns all logs of the blocks with a number greater than `block_number`, marked as + /// removed. + /// + /// This captures the logs of blocks that are about to be unwound before their transactions + /// and receipts are cleared from storage, so they can be re-delivered to log subscriptions + /// and filters with `removed: true`. + fn removed_logs_since(&self, block_number: u64) -> Vec { + let storage = self.blockchain.storage.read(); + let mut all_logs = Vec::new(); + + for num in (block_number + 1)..=storage.best_number { + if let Some(hash) = storage.hashes.get(&num) + && let Some(block) = storage.blocks.get(hash) + { + let mut block_log_index = 0u64; + for tx in &block.body.transactions { + if let Some(tx) = storage.transactions.get(&tx.hash()) { + for log in tx.receipt.logs() { + all_logs.push(Log { + inner: log.clone(), + block_hash: Some(*hash), + block_number: Some(num), + block_timestamp: Some(block.header.timestamp()), + transaction_hash: Some(tx.info.transaction_hash), + transaction_index: Some(tx.info.transaction_index), + log_index: Some(block_log_index), + removed: true, + }); + block_log_index += 1; + } + } + } + } + } + + all_logs + } + + /// Returns the logs of the block that match the filter + async fn logs_for_block( + &self, + filter: Filter, + hash: B256, + ) -> Result, BlockchainError> { + if let Some(block) = self.blockchain.get_block_by_hash(&hash) { + return Ok(self.mined_logs_for_block(filter, block, hash)); + } + + if let Some(fork) = self.get_fork() { + return Ok(fork.logs(&filter).await?); + } + + Err(BlockchainError::UnknownBlock) + } + + /// Returns the logs that match the filter in the given range of blocks + async fn logs_for_range( + &self, + filter: &Filter, + mut from: u64, + to: u64, + ) -> Result, BlockchainError> { + let mut all_logs = Vec::new(); + + // get the range that predates the fork if any + if let Some(fork) = self.get_fork() { + let to_on_fork = if fork.predates_fork(to) { + to + } else { + // adjust the ranges + fork.block_number() + }; + + if fork.predates_fork_inclusive(from) { + // this data is only available on the forked client + let filter = filter.clone().from_block(from).to_block(to_on_fork); + all_logs = fork.logs(&filter).await?; + + // update the range + from = fork.block_number() + 1; + } + } + + for number in from..=to { + if let Some((block, hash)) = self.get_block_with_hash(number) { + all_logs.extend(self.mined_logs_for_block(filter.clone(), block, hash)); + } + } + + Ok(all_logs) + } + + /// Returns the logs according to the filter + pub async fn logs(&self, filter: Filter) -> Result, BlockchainError> { + trace!(target: "backend", "get logs [{:?}]", filter); + if let Some(hash) = filter.get_block_hash() { + self.logs_for_block(filter, hash).await + } else { + let best = self.best_number(); + let to_block = + self.convert_block_number(filter.block_option.get_to_block().copied()).min(best); + let from_block = + self.convert_block_number(filter.block_option.get_from_block().copied()); + if from_block > best { + return Err(BlockchainError::BlockOutOfRange(best, from_block)); + } + + self.logs_for_range(&filter, from_block, to_block).await + } + } + + /// Returns all receipts of the block + pub fn mined_receipts(&self, hash: B256) -> Option> { + let storage = self.blockchain.storage.read(); + let block = storage.blocks.get(&hash)?; + block + .body + .transactions + .iter() + .map(|transaction| { + storage.transactions.get(&transaction.hash()).map(|tx| tx.receipt.clone()) + }) + .collect() + } + + /// Returns the EIP-7928 block access list stored for a locally mined block hash. + pub fn block_access_list_by_hash(&self, hash: B256) -> Option { + self.blockchain.storage.read().block_access_lists.get(&hash).cloned() + } + + /// Returns the EIP-7928 block access list stored for a locally mined block number. + pub fn block_access_list_by_number(&self, number: u64) -> Option { + let storage = self.blockchain.storage.read(); + storage.hashes.get(&number).and_then(|hash| storage.block_access_lists.get(hash)).cloned() + } +} + +// Mining methods — generic over N: Network, with Foundry-associated-type bounds for now. +impl Backend +where + Self: TransactionValidator, + N: Network, +{ + /// Mines a new block and stores it. + /// + /// this will execute all transaction in the order they come in and return all the markers they + /// provide. + pub async fn mine_block( + &self, + pool_transactions: Vec>>, + ) -> Result, BlockchainError> { + self.do_mine_block(pool_transactions).await + } + + /// Mines a new block while the caller holds the mining lock. + pub(crate) async fn mine_block_locked( + &self, + pool_transactions: Vec>>, + pin_timestamp: bool, + ) -> Result, BlockchainError> { + self.do_mine_block_locked(pool_transactions, pin_timestamp).await + } + + /// Replays a transaction-hash fork prefix before the live pool and miner are created. + pub(crate) async fn apply_fork_transaction_replay( + &self, + replay: ForkTransactionReplay, + ) -> Result<()> { + let source_chain_id = self.protocol_chain_id(); + let arbitrum_block_numbers = is_arbitrum(source_chain_id).then(|| { + ( + replay.source_block.header().number(), + arbitrum_replay_block_number(&replay.source_block), + ) + }); + let prepared = prepare_fork_transaction_replay(replay, self.is_monad(), self.is_celo())?; + let fallback_execution_chain_id = self + .get_fork() + .map(|fork| fork.execution_chain_id()) + .unwrap_or_else(|| self.chain_id().to()); + let execution_chain_id = prepared.execution_chain_id(fallback_execution_chain_id)?; + let PreparedForkTransactionReplay { transactions, timestamp, parent_beacon_block_root } = + prepared; + eyre::ensure!(!transactions.is_empty(), "fork transaction replay prefix is empty"); + let next_timestamp = timestamp.checked_add(1).ok_or_else(|| { + eyre::eyre!("fork transaction replay timestamp cannot be incremented") + })?; + + let _mining_guard = self.mining.lock().await; + let current_base_fee = self.base_fee(); + let current_excess_blob_gas_and_price = self.excess_blob_gas_and_price(); + let mut evm_env = self.evm_env.read().clone(); + if evm_env.block_env.basefee == 0 { + evm_env.cfg_env.disable_base_fee = true; + } + + let best_number = self.blockchain.storage.read().best_number; + let block_number = best_number.saturating_add(1); + evm_env.block_env.number = evm_env.block_env.number.saturating_add(U256::ONE); + evm_env.block_env.basefee = current_base_fee; + evm_env.block_env.blob_excess_gas_and_price = current_excess_blob_gas_and_price; + evm_env.block_env.timestamp = U256::from(timestamp); + + let best_hash = self.blockchain.storage.read().best_hash; + let mut prevrandao_input = [0u8; 40]; + prevrandao_input[..32].copy_from_slice(best_hash.as_slice()); + prevrandao_input[32..].copy_from_slice(&block_number.to_le_bytes()); + evm_env.block_env.prevrandao = Some( + self.cheats.take_next_block_prevrandao().unwrap_or_else(|| keccak256(prevrandao_input)), + ); + + let mut replay_env = evm_env.clone(); + let arbitrum_rpc_block_number = arbitrum_block_numbers.map(|(rpc, evm)| { + replay_env.block_env.number = evm; + rpc + }); + replay_env.cfg_env.chain_id = execution_chain_id; + apply_chain_specific_tx_replay_env_changes_for_chain(&mut replay_env, source_chain_id); + let inspector_tx_config = self.inspector_tx_config(); + + let scheduled_hardfork = + source_hardfork(self.networks.execution_network(), source_chain_id, timestamp); + #[cfg(feature = "monad")] + let mut monad_replay = self + .prepare_monad_fork_replay( + source_chain_id, + execution_chain_id, + timestamp, + best_hash, + &transactions, + ) + .await?; + #[cfg(feature = "monad")] + let hardfork = monad_replay + .as_ref() + .map(monad::ForkReplay::hardfork) + .unwrap_or_else(|| scheduled_hardfork.unwrap_or_else(|| self.hardfork())); + #[cfg(not(feature = "monad"))] + let hardfork = scheduled_hardfork.unwrap_or_else(|| self.hardfork()); + if !self.is_optimism() && !self.is_tempo() { + replay_env.cfg_env.spec = SpecId::from(hardfork); + } + let replay_spec = SpecId::from(hardfork); + // Cancun-family execution requires blob excess gas even for non-blob transactions. Some + // OP Stack source headers omit it, so use the neutral value for historical replay. + if !self.is_tempo() + && replay_spec >= SpecId::CANCUN + && replay_env.block_env.blob_excess_gas_and_price.is_none() + { + replay_env.block_env.blob_excess_gas_and_price = Some(BlobExcessGasAndPrice::new( + 0, + get_blob_base_fee_update_fraction_by_spec_id(replay_spec), + )); + } + + #[cfg(feature = "monad")] + let monad_context = monad_replay.as_mut().and_then(monad::ForkReplay::take_context); + #[cfg(not(feature = "monad"))] + let monad_context = None; + + let (block_info, state_changes, block_hash) = { + let db = self.db.read().await; + let mut overlay = AnvilCacheDB::new(&**db, *replay_env.spec_id()); + let ExecutedHistoricalReplay { + block_result, + transactions, + transaction_infos, + state_changes, + } = self.execute_with_replay_block_executor( + &mut overlay, + &replay_env, + best_hash, + arbitrum_rpc_block_number, + hardfork, + parent_beacon_block_root, + &transactions, + &inspector_tx_config, + monad_context, + )?; + let state_root = overlay.maybe_state_root().unwrap_or_default(); + let block_info = self.build_block_info( + &replay_env, + best_hash, + block_number, + state_root, + block_result, + transactions, + transaction_infos, + parent_beacon_block_root, + None, + ); + let block_hash = block_info.block.header.hash_slow(); + (block_info, state_changes, block_hash) + }; + + if self.prune_state_history_config.is_state_history_supported() { + let state = self.db.read().await.current_state(); + self.states.write().insert(best_hash, state); + } + + { + let mut db = self.db.write().await; + for state in state_changes { + db.commit(state); + } + db.insert_block_hash(U256::from(block_number), block_hash); + } + + let BlockInfo { block, transactions, receipts } = block_info; + let header = block.header.clone(); + { + let mut storage = self.blockchain.storage.write(); + storage.best_number = block_number; + storage.best_hash = block_hash; + if !self.is_eip3675() { + storage.total_difficulty = + storage.total_difficulty.saturating_add(header.difficulty); + } + storage.blocks.insert(block_hash, block); + storage.hashes.insert(block_number, block_hash); + #[cfg(feature = "monad")] + if let Some(replay) = &mut monad_replay { + replay.store_metadata(&mut storage, block_hash); + } + for (info, receipt) in transactions.into_iter().zip(receipts) { + let mined_tx = MinedTransaction { info, receipt, block_hash, block_number }; + storage.transactions.insert(mined_tx.info.transaction_hash, mined_tx); + } + + if let Some(transaction_block_keeper) = self.transaction_block_keeper + && storage.blocks.len() > transaction_block_keeper + { + let to_clear = block_number + .saturating_sub(transaction_block_keeper.try_into().unwrap_or(u64::MAX)); + storage.remove_block_transactions_by_number(to_clear) + } + } + + #[cfg(feature = "monad")] + if let Some(replay) = &monad_replay { + self.finalize_monad_fork_replay(replay, &mut evm_env); + } + + evm_env.block_env.difficulty = U256::ZERO; + *self.evm_env.write() = evm_env; + self.time.reset(timestamp); + self.time.set_next_block_timestamp(next_timestamp)?; + + #[cfg(feature = "optimism")] + if self.is_optimism() { + self.fees.set_optimism_base_fee_rules(header.extra_data()); + } + let next_block_base_fee = self.fees.get_next_block_base_fee_from_header(&header); + let next_block_excess_blob_gas = self.networks.next_block_blob_excess_gas( + self.fees.blob_params(), + header.excess_blob_gas.unwrap_or_default(), + header.blob_gas_used.unwrap_or_default(), + header.base_fee_per_gas.unwrap_or_default(), + ); + self.fees.set_base_fee(next_block_base_fee); + self.fees.set_blob_excess_gas_and_price(BlobExcessGasAndPrice::new( + next_block_excess_blob_gas, + get_blob_base_fee_update_fraction_by_spec_id(*self.evm_env.read().spec_id()), + )); + self.notify_on_new_block(header.into_inner(), block_hash); + + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + fn execute_with_replay_block_executor( + &self, + db: DB, + evm_env: &EvmEnv, + parent_hash: B256, + arbitrum_rpc_block_number: Option, + hardfork: FoundryHardfork, + parent_beacon_block_root: Option, + transactions: &[HistoricalReplayTransaction], + inspector_tx_config: &InspectorTxConfig, + #[cfg_attr(not(feature = "monad"), allow(unused_variables))] monad_context: Option< + MonadReplayContext, + >, + ) -> Result + where + DB: StateDB + BalIndexedDatabase, + { + #[cfg(feature = "monad")] + if self.is_monad() { + return self.execute_with_monad_replay_block_executor( + db, + evm_env, + parent_hash, + hardfork.into(), + transactions, + inspector_tx_config, + monad_context + .ok_or_else(|| eyre::eyre!("Monad replay ancestor context is unavailable"))?, + ); + } + + let inspector = self.build_mining_inspector(); + let spec_id = *evm_env.spec_id(); + let transitions = self.ethereum_block_transitions( + hardfork, + parent_beacon_block_root, + BlockExecutionKind::TransactionPrefix, + ); + + macro_rules! execute { + ($executor:expr) => {{ + let mut executor = $executor; + executor + .apply_pre_execution_changes() + .wrap_err("failed to apply replay block-start transitions")?; + let (stored_transactions, transaction_infos) = + execute_historical_replay(&mut executor, transactions, inspector_tx_config)?; + let state_changes = executor.take_state_changes(); + let (_, block_result) = + executor.finish().wrap_err("failed to finish replay block execution")?; + Ok(ExecutedHistoricalReplay { + block_result, + transactions: stored_transactions, + transaction_infos, + state_changes, + }) + }}; + } + + #[cfg(feature = "base")] + if self.is_base() { + let upgrade = + self.base_upgrade_at_timestamp(evm_env.block_env.timestamp.saturating_to()); + let cfg = + evm_env.cfg_env.clone().with_spec_and_mainnet_gas_params(BaseSpecId::new(upgrade)); + let activation_admin = self.base_activation_admin().or_else(|| { + ChainConfig::activation_admin_address_for_upgrade_by_chain_id(cfg.chain_id, upgrade) + }); + let base_env = EvmEnv::new(cfg, evm_env.block_env.clone()); + let mut evm = BaseEvmFactory::new(activation_admin) + .create_evm_with_inspector(db, base_env, inspector); + evm.ctx_mut().cfg.tx_chain_id_check = true; + self.inject_precompiles(evm.precompiles_mut(), evm_env); + if let Some(block_number) = arbitrum_rpc_block_number { + self.inject_arbitrum_precompile_at_block(evm.precompiles_mut(), block_number); + } + let mut executor = AnvilBlockExecutor::new(evm, parent_hash, spec_id, transitions) + .with_state_changes(); + executor.set_base_upgrade(upgrade); + return execute!(executor); + } + + #[cfg(feature = "optimism")] + if self.is_optimism() { + let op_env = EvmEnv::new( + evm_env.cfg_env.clone().with_spec_and_mainnet_gas_params(hardfork.into()), + evm_env.block_env.clone(), + ); + let mut evm = + OpEvmFactory::::default().create_evm_with_inspector(db, op_env, inspector); + self.inject_precompiles(evm.precompiles_mut(), evm_env); + if let Some(block_number) = arbitrum_rpc_block_number { + self.inject_arbitrum_precompile_at_block(evm.precompiles_mut(), block_number); + } + // Historical replay has no local blob budget. OP still configures its Jovian DA budget. + let mut executor = AnvilBlockExecutor::new(evm, parent_hash, spec_id, transitions) + .with_state_changes(); + executor.set_optimism_hardfork(hardfork); + return execute!(executor); + } + + if self.is_tempo() { + let tempo_env = Self::build_tempo_evm_env(evm_env, self.tempo_hardfork()); + let mut evm = + TempoEvmFactory::default().create_evm_with_inspector(db, tempo_env, inspector); + self.inject_precompiles(evm.precompiles_mut(), evm_env); + if let Some(block_number) = arbitrum_rpc_block_number { + self.inject_arbitrum_precompile_at_block(evm.precompiles_mut(), block_number); + } + let executor = AnvilBlockExecutor::new(evm, parent_hash, spec_id, transitions) + .with_state_changes(); + return execute!(executor); + } + + let mut evm = + EthEvmFactory::default().create_evm_with_inspector(db, evm_env.clone(), inspector); + self.inject_precompiles(evm.precompiles_mut(), evm_env); + if let Some(block_number) = arbitrum_rpc_block_number { + self.inject_arbitrum_precompile_at_block(evm.precompiles_mut(), block_number); + } + let executor = + AnvilBlockExecutor::new(evm, parent_hash, spec_id, transitions).with_state_changes(); + execute!(executor) + } + + /// Builds a [`BlockInfo`] from the EVM environment, execution results, and transactions. + #[allow(clippy::too_many_arguments)] + fn build_block_info( + &self, + evm_env: &EvmEnv, + parent_hash: B256, + number: u64, + state_root: B256, + block_result: BlockExecutionResult, + transactions: Vec>, + transaction_infos: Vec, + parent_beacon_block_root: Option, + block_access_list: Option<&BlockAccessList>, + ) -> BlockInfo { + let spec_id = *evm_env.spec_id(); + let is_shanghai = spec_id >= SpecId::SHANGHAI; + let is_cancun = spec_id >= SpecId::CANCUN; + let is_prague = spec_id >= SpecId::PRAGUE; + + let receipts_root = calculate_receipt_root(&block_result.receipts); + let cumulative_blob_gas_used = is_cancun.then_some(block_result.blob_gas_used); + let bloom = block_result.receipts.iter().fold(Bloom::default(), |mut b, r| { + b.accrue_bloom(r.logs_bloom()); + b + }); + + let header = Header { + parent_hash, + ommers_hash: Default::default(), + beneficiary: evm_env.block_env.beneficiary, + state_root, + transactions_root: Default::default(), + receipts_root, + logs_bloom: bloom, + difficulty: evm_env.block_env.difficulty, + number, + gas_limit: evm_env.block_env.gas_limit, + gas_used: block_result.gas_used, + timestamp: evm_env.block_env.timestamp.saturating_to(), + extra_data: self.fees.base_fee_extra_data(), + mix_hash: evm_env.block_env.prevrandao.unwrap_or_default(), + nonce: Default::default(), + base_fee_per_gas: (spec_id >= SpecId::LONDON).then_some(evm_env.block_env.basefee), + parent_beacon_block_root: is_cancun + .then(|| parent_beacon_block_root.unwrap_or_default()), + blob_gas_used: cumulative_blob_gas_used, + excess_blob_gas: if is_cancun { evm_env.block_env.blob_excess_gas() } else { None }, + withdrawals_root: is_shanghai.then_some(EMPTY_WITHDRAWALS), + requests_hash: is_prague.then(|| block_result.requests.requests_hash()), + block_access_list_hash: block_access_list + .map(|bal| compute_block_access_list_hash(bal.as_slice())), + slot_number: None, + }; + + let block = create_block(foundry_header(&self.networks, header), transactions); + BlockInfo { block, transactions: transaction_infos, receipts: block_result.receipts } + } + + async fn do_mine_block( + &self, + pool_transactions: Vec>>, + ) -> Result, BlockchainError> { + let _mining_guard = self.mining.lock().await; + self.do_mine_block_locked(pool_transactions, false).await + } + + /// Mines a block while the caller holds the mining lock. + async fn do_mine_block_locked( + &self, + pool_transactions: Vec>>, + pin_timestamp: bool, + ) -> Result, BlockchainError> { + trace!(target: "backend", "creating new block with {} transactions", pool_transactions.len()); + + let (outcome, header, block_hash) = { + let current_base_fee = self.base_fee(); + let current_excess_blob_gas_and_price = self.excess_blob_gas_and_price(); + + let mut evm_env = self.evm_env.read().clone(); + #[cfg(feature = "monad")] + let execution_chain_id = evm_env.cfg_env.chain_id; + let hardfork = self.hardfork(); + + if evm_env.block_env.basefee == 0 { + // this is an edge case because the evm fails if `tx.effective_gas_price < base_fee` + // 0 is only possible if it's manually set + evm_env.cfg_env.disable_base_fee = true; + } + + let block_number = self.blockchain.storage.read().best_number.saturating_add(1); + + // Advance the EVM number independently of the RPC header number. On Arbitrum + // forks this preserves the L1 number read by NUMBER while the header tracks L2. + evm_env.block_env.number = evm_env.block_env.number.saturating_add(U256::ONE); + + evm_env.block_env.basefee = current_base_fee; + evm_env.block_env.blob_excess_gas_and_price = current_excess_blob_gas_and_price; + + let best_hash = self.blockchain.storage.read().best_hash; + + let mut input = [0u8; 40]; + input[..32].copy_from_slice(best_hash.as_slice()); + input[32..].copy_from_slice(&block_number.to_le_bytes()); + // Use the values set via `anvil_setNextBlockPrevRandao` and + // `anvil_setNextBlockParentBeaconBlockRoot` for this block if provided, otherwise + // derive `prevrandao` from the parent hash and block number and use the zero root. + // The overrides are consumed once the block is committed so they only apply to it. + let next_block = self.cheats.next_block_overrides(); + evm_env.block_env.prevrandao = + Some(next_block.prevrandao.value.unwrap_or_else(|| keccak256(input))); + let parent_beacon_block_root = + Some(next_block.parent_beacon_block_root.value.unwrap_or_default()); + + let ( + db_guard, + block_info, + included, + stale, + invalid, + not_yet_valid, + block_hash, + parent_state, + next_block_base_fee, + next_block_blob_fees, + block_access_list, + ) = { + let mut db = self.db.write().await; + + // finally set the next block timestamp, this is done just before execution, because + // there can be concurrent requests that can delay acquiring the db lock and we want + // to ensure the timestamp is as close as possible to the actual execution. + let pending_timestamp = self.prepare_block_timestamp( + &**db, + evm_env.block_env.timestamp.saturating_to(), + pin_timestamp, + )?; + let block_timestamp = pending_timestamp.timestamp; + evm_env.block_env.timestamp = U256::from(block_timestamp); + + // Forced historical transactions bypass pool admission and are replayed while + // mining. Keep this exception local to the disposable mining environment. + let mut mining_evm_env = evm_env.clone(); + if pool_transactions.iter().any(|tx| tx.is_replay) { + apply_chain_specific_tx_replay_env_changes_for_chain( + &mut mining_evm_env, + self.protocol_chain_id(), + ); + } + + #[cfg(feature = "base")] + let (protocol_transactions, system_transaction_count) = { + let mut transactions = self.base_system_transactions( + &**db, + block_number, + block_timestamp, + best_hash, + )?; + let system_transaction_count = transactions.len(); + transactions.extend(pool_transactions); + (transactions, system_transaction_count) + }; + #[cfg(not(feature = "base"))] + let protocol_transactions = pool_transactions; + + let inspector_tx_config = self.inspector_tx_config(); + let gas_config = self.pool_tx_gas_config(&mining_evm_env); + + let mut candidate_db = AnvilCacheDB::new(&**db, *mining_evm_env.spec_id()); + if matches!( + hardfork, + FoundryHardfork::Ethereum(hardfork) if hardfork >= EthereumHardfork::Amsterdam + ) { + candidate_db.enable_bal_recording(); + } + let (pool_result, block_result) = self.execute_with_block_executor( + &mut candidate_db, + &mining_evm_env, + best_hash, + hardfork, + parent_beacon_block_root, + BlockExecutionKind::Complete, + &protocol_transactions, + &gas_config, + &inspector_tx_config, + &|pool_tx, account| { + let validation_env = + if pool_tx.is_replay { &mining_evm_env } else { &evm_env }; + self.validate_mining_pool_transaction_for(pool_tx, account, validation_env) + }, + )?; + let block_access_list = candidate_db.take_block_access_list(); + + #[cfg(feature = "base")] + base::validate_system_transactions( + &protocol_transactions[..system_transaction_count], + &pool_result, + )?; + + #[cfg_attr(not(feature = "base"), allow(unused_mut))] + let mut included = pool_result.included; + // System deposits are generated per block rather than taken from the pool. Report + // only pool transactions so the pool does not treat every block as progress. + #[cfg(feature = "base")] + included.drain(..system_transaction_count); + let stale = pool_result.stale; + let invalid = pool_result.invalid; + let not_yet_valid = pool_result.not_yet_valid; + + let CacheDB { cache, db: _ } = candidate_db.0; + let parent_state = self + .prune_state_history_config + .is_state_history_supported() + .then(|| db.current_state()); + commit_cache(&mut **db, cache)?; + let state_root = db.maybe_state_root().unwrap_or_default(); + let block_info = self.build_block_info( + &mining_evm_env, + best_hash, + block_number, + state_root, + block_result, + pool_result.txs, + pool_result.tx_info, + parent_beacon_block_root, + block_access_list.as_ref(), + ); + + // Update the new blockhash in the db itself. + let block_hash = block_info.block.header.hash_slow(); + db.insert_block_hash(U256::from(block_info.block.header.number()), block_hash); + self.time.commit_next_timestamp(pending_timestamp); + self.cheats.consume_next_block_overrides(&next_block); + + let header = &block_info.block.header; + let next_block_base_fee = self.fees.get_next_block_base_fee_from_header(header); + let next_block_excess_blob_gas = self.networks.next_block_blob_excess_gas( + self.fees.blob_params(), + header.excess_blob_gas.unwrap_or_default(), + header.blob_gas_used.unwrap_or_default(), + header.base_fee_per_gas.unwrap_or_default(), + ); + let next_block_blob_fees = BlobExcessGasAndPrice::new( + next_block_excess_blob_gas, + self.fees.blob_params().update_fraction as u64, + ); + + ( + db, + block_info, + included, + stale, + invalid, + not_yet_valid, + block_hash, + parent_state, + next_block_base_fee, + next_block_blob_fees, + block_access_list, + ) + }; + + // create the new block with the current timestamp + let BlockInfo { block, transactions, receipts } = block_info; + + let header = block.header.clone(); + #[cfg(feature = "monad")] + let monad_participants = self.is_monad().then(|| { + let tx_envs = included + .iter() + .map(|pool_tx| { + build_tx_env_for_pending::( + &pool_tx.pending_transaction, + self.cheats(), + ) + }) + .collect::>(); + foundry_evm::core::evm::monad_block_participants(&tx_envs) + }); + + if let Some(parent_state) = parent_state { + self.states.write().insert(best_hash, parent_state); + } + + trace!( + target: "backend", + "Mined block {} with {} tx {:?}", + block_number, + transactions.len(), + transactions.iter().map(|tx| tx.transaction_hash).collect::>() + ); + #[cfg(test)] + let mining_commit_hook = { self.mining_commit_hook.lock().clone() }; + #[cfg(test)] + if let Some(hook) = mining_commit_hook { + hook.reached.notify_one(); + hook.resume.notified().await; + } + let mut storage = self.blockchain.storage.write(); + // update block metadata + storage.best_number = block_number; + storage.best_hash = block_hash; + // Difficulty is removed and not used after Paris (aka TheMerge). Value is replaced with + // prevrandao. https://github.com/bluealloy/revm/blob/1839b3fce8eaeebb85025576f2519b80615aca1e/crates/interpreter/src/instructions/host_env.rs#L27 + if !self.is_eip3675() { + storage.total_difficulty = + storage.total_difficulty.saturating_add(header.difficulty); + } + + storage.blocks.insert(block_hash, block); + storage.hashes.insert(block_number, block_hash); + if let Some(block_access_list) = block_access_list { + storage.block_access_lists.insert(block_hash, block_access_list); + } + #[cfg(feature = "monad")] + if let Some(participants) = monad_participants { + monad::store_block_metadata( + &mut storage, + block_hash, + participants, + execution_chain_id, + hardfork, + ); + } - // apply state overrides before executing the transactions - if let Some(state_overrides) = state_overrides { - state::apply_state_overrides(state_overrides, &mut cache_db)?; + node_info!(""); + // insert all transactions + for (info, receipt) in transactions.into_iter().zip(receipts) { + // log some tx info + node_info!(" Transaction: {:?}", info.transaction_hash); + if let Some(contract) = &info.contract_address { + node_info!(" Contract created: {contract}"); } - if let Some(block_overrides) = block_overrides { - state::apply_block_overrides(block_overrides, &mut cache_db, &mut block_env); + node_info!(" Gas used: {}", info.gas_used); + if !info.exit.is_ok() { + let r = RevertDecoder::new().decode( + info.out.as_ref().map(|b| &b[..]).unwrap_or_default(), + Some(info.exit), + ); + node_info!(" Error: reverted with: {r}"); } + node_info!(""); - // execute all calls in that block - for (req_idx, request) in calls.into_iter().enumerate() { - let fee_details = FeeDetails::new( - request.gas_price, - request.max_fee_per_gas, - request.max_priority_fee_per_gas, - request.max_fee_per_blob_gas, - )? - .or_zero_fees(); + let mined_tx = MinedTransaction { info, receipt, block_hash, block_number }; + storage.transactions.insert(mined_tx.info.transaction_hash, mined_tx); + } - let mut env = self.build_call_env( - WithOtherFields::new(request.clone()), - fee_details, - block_env.clone(), - ); + // remove old transactions that exceed the transaction block keeper + if let Some(transaction_block_keeper) = self.transaction_block_keeper + && storage.blocks.len() > transaction_block_keeper + { + let to_clear = block_number + .saturating_sub(transaction_block_keeper.try_into().unwrap_or(u64::MAX)); + storage.remove_block_transactions_by_number(to_clear) + } - // Always disable EIP-3607 - env.evm_env.cfg_env.disable_eip3607 = true; + // Live execution readers acquire the database read lock before cloning the block + // environment, next-block fee state, and canonical head. Publish the complete snapshot + // while retaining the database write lock so readers observe either the parent or newly + // mined state. The database is always the outer lock, and no path holds a storage, + // environment, or fee guard while waiting for it, so these acquisitions cannot form a + // lock cycle. + evm_env.block_env.difficulty = U256::ZERO; + *self.evm_env.write() = evm_env; + self.fees.set_next_block_fees(next_block_base_fee, next_block_blob_fees); + drop(storage); + drop(db_guard); - if !validation { - env.evm_env.cfg_env.disable_base_fee = !validation; - env.evm_env.block_env.basefee = 0; - } + self.time.mark_block_created(); - // transact - let ResultAndState { result, state } = if trace_transfers { - // prepare inspector to capture transfer inside the evm so they are - // recorded and included in logs - let mut inspector = TransferInspector::new(false).with_logs(true); - let mut evm= self.new_evm_with_inspector_ref( - &cache_db as &dyn DatabaseRef, - &env, - &mut inspector, - ); + let timestamp = utc_from_secs(header.timestamp); - trace!(target: "backend", env=?env.evm_env, spec=?env.evm_env.spec_id(),"simulate evm env"); - evm.transact(env.tx)? - } else { - let mut inspector = self.build_inspector(); - let mut evm = self.new_evm_with_inspector_ref( - &cache_db as &dyn DatabaseRef, - &env, - &mut inspector, - ); - trace!(target: "backend", env=?env.evm_env, spec=?env.evm_env.spec_id(),"simulate evm env"); - evm.transact(env.tx)? - }; - trace!(target: "backend", ?result, ?request, "simulate call"); + node_info!(" Block Number: {}", block_number); + node_info!(" Block Hash: {:?}", block_hash); + if timestamp.year() > 9999 { + // rf2822 panics with more than 4 digits + node_info!(" Block Time: {:?}\n", timestamp.to_rfc3339()); + } else { + node_info!(" Block Time: {:?}\n", timestamp.to_rfc2822()); + } - // commit the transaction - cache_db.commit(state); - gas_used += result.gas_used(); + let outcome = + MinedBlockOutcome { block_number, included, stale, invalid, not_yet_valid }; - // TODO: this is likely incomplete - // create the transaction from a request - let from = request.from.unwrap_or_default(); - let request = - transaction_request_to_typed(WithOtherFields::new(request)).unwrap(); - let tx = build_typed_transaction( - request, - Signature::new(Default::default(), Default::default(), false), - )?; - let tx_hash = tx.hash(); - let rpc_tx = transaction_build( - None, - MaybeImpersonatedTransaction::impersonated(tx, from), - None, - None, - Some(block_env.basefee), - ); - transactions.push(rpc_tx); + (outcome, header, block_hash) + }; + // notify all listeners + self.notify_on_new_block(header.into_inner(), block_hash); - let return_data = result.output().cloned().unwrap_or_default(); - let sim_res = SimCallResult { - return_data, - gas_used: result.gas_used(), - status: result.is_success(), - error: result.is_success().not().then(|| { - alloy_rpc_types::simulate::SimulateError { - code: -3200, - message: "execution failed".to_string(), - } - }), - logs: result.clone() - .into_logs() - .into_iter() - .enumerate() - .map(|(idx, log)| Log { - inner: log, - block_number: Some(block_env.number.saturating_to()), - block_timestamp: Some(block_env.timestamp.saturating_to()), - transaction_index: Some(req_idx as u64), - log_index: Some((idx + log_index) as u64), - removed: false, + Ok(outcome) + } - block_hash: None, - transaction_hash: Some(tx_hash), - }) - .collect(), - }; - logs.extend(sim_res.logs.clone().iter().map(|log| log.inner.clone())); - log_index += sim_res.logs.len(); - call_res.push(sim_res); + /// Mines replacement blocks after the caller has rewound the chain for a reorg. + /// + /// The caller must hold the mining lock across ancestor selection, rollback, and this method. + pub async fn reorg( + &self, + depth: u64, + tx_pairs: HashMap>>>, + _mining_guard: &tokio::sync::MutexGuard<'_, ()>, + ) -> Result<(), BlockchainError> { + // Create the new reorged chain, filling the blocks with transactions if supplied + for i in 0..depth { + let to_be_mined = tx_pairs.get(&i).cloned().unwrap_or_else(Vec::new); + let outcome = self.do_mine_block_locked(to_be_mined, false).await?; + node_info!( + " Mined reorg block number {}. With {} valid txs and with invalid {} txs", + outcome.block_number, + outcome.included.len(), + outcome.invalid.len() + ); + } + + Ok(()) + } + + /// Creates the pending block + /// + /// This will execute all transaction in the order they come but will not mine the block + pub async fn pending_block( + &self, + pool_transactions: Vec>>, + ) -> Result, BlockchainError> { + self.with_pending_block(pool_transactions, |_, block| block).await + } + + /// Creates the pending block + /// + /// This will execute all transaction in the order they come but will not mine the block + pub async fn with_pending_block( + &self, + pool_transactions: Vec>>, + f: F, + ) -> Result + where + F: FnOnce(Box, BlockInfo) -> T, + { + let db = self.db.read().await; + let evm_env = self.next_evm_env(&**db)?; + + let mut cache_db = AnvilCacheDB::new(&*db, *evm_env.spec_id()); + + let parent_hash = self.blockchain.storage.read().best_hash; + let block_number = self.best_number().saturating_add(1); + + #[cfg(feature = "base")] + let (pool_transactions, system_transaction_count) = { + let mut transactions = self.base_system_transactions( + &**db, + block_number, + evm_env.block_env.timestamp.saturating_to(), + parent_hash, + )?; + let system_transaction_count = transactions.len(); + transactions.extend(pool_transactions); + (transactions, system_transaction_count) + }; + // Read the next block's beacon root override without consuming it, so the pending block + // sees the root the next mined block will use. + let parent_beacon_block_root = Some( + self.cheats.next_block_overrides().parent_beacon_block_root.value.unwrap_or_default(), + ); + + let inspector_tx_config = self.inspector_tx_config(); + let gas_config = self.pool_tx_gas_config(&evm_env); + + let (pool_result, block_result) = self.execute_with_block_executor( + &mut cache_db, + &evm_env, + parent_hash, + self.hardfork(), + parent_beacon_block_root, + BlockExecutionKind::Complete, + &pool_transactions, + &gas_config, + &inspector_tx_config, + &|pool_tx, account| { + self.validate_pool_transaction_for(&pool_tx.pending_transaction, account, &evm_env) + }, + )?; + + #[cfg(feature = "base")] + base::validate_system_transactions( + &pool_transactions[..system_transaction_count], + &pool_result, + )?; + + // Extract inner CacheDB (which implements MaybeFullDatabase) + let cache_db = cache_db.0; + + let state_root = cache_db.maybe_state_root().unwrap_or_default(); + let block_info = self.build_block_info( + &evm_env, + parent_hash, + block_number, + state_root, + block_result, + pool_result.txs, + pool_result.tx_info, + parent_beacon_block_root, + None, + ); + + Ok(f(Box::new(cache_db), block_info)) + } + + /// Returns the ERC20/TIP20 token balance for an account. + /// + /// Calls `balanceOf(address)` on the token contract. Returns `U256::ZERO` if + /// the call fails (e.g. the token contract doesn't exist). + pub async fn get_fee_token_balance( + &self, + token: Address, + account: Address, + ) -> Result { + // balanceOf(address) selector: 0x70a08231 + let mut calldata = vec![0x70, 0xa0, 0x82, 0x31]; + // ABI-encode the address (left-padded to 32 bytes) + calldata.extend_from_slice(&[0u8; 12]); + calldata.extend_from_slice(account.as_slice()); + + let request = WithOtherFields::new(TransactionRequest { + from: Some(Address::ZERO), + to: Some(TxKind::Call(token)), + input: calldata.into(), + ..Default::default() + }); + + let fee_details = FeeDetails::zero(); + let (exit, out, _, _) = self.call(request, fee_details, None, Default::default()).await?; + + // Check if call succeeded + if exit != InstructionResult::Return && exit != InstructionResult::Stop { + // Return zero balance if call failed (token might not exist) + return Ok(U256::ZERO); + } + + // Decode U256 from output + match out { + Some(Output::Call(data)) if data.len() >= 32 => Ok(U256::from_be_slice(&data[..32])), + _ => Ok(U256::ZERO), + } + } + + /// Returns the account used to sponsor Tempo fee-payer requests handled by this node. + /// + /// Returns `None` on non-Tempo networks. + pub async fn tempo_fee_payer(&self) -> Option
{ + if !self.is_tempo() { + return None; + } + self.node_config.read().await.tempo_fee_payer_address() + } + + /// Returns the fee token an account pays with, as stored in the Tempo fee manager. + /// + /// Falls back to PathUSD when the account has no stored preference or the lookup fails. + pub async fn tempo_user_fee_token(&self, account: Address) -> Result { + let calldata = IFeeManager::userTokensCall { user: account }.abi_encode(); + + let request = WithOtherFields::new(TransactionRequest { + from: Some(Address::ZERO), + to: Some(TxKind::Call(TIP_FEE_MANAGER_ADDRESS)), + input: calldata.into(), + ..Default::default() + }); + + let (exit, out, _, _) = + self.call(request, FeeDetails::zero(), None, Default::default()).await?; + + let token = if exit == InstructionResult::Return + && let Some(Output::Call(data)) = out + { + IFeeManager::userTokensCall::abi_decode_returns(&data).unwrap_or(Address::ZERO) + } else { + Address::ZERO + }; + + if token.is_zero() { + return Ok(foundry_evm::core::tempo::PATH_USD_ADDRESS); + } + Ok(token) + } + + /// Executes the [TransactionRequest] without writing to the DB + /// + /// # Errors + /// + /// Returns an error if the `block_number` is greater than the current height + pub async fn call( + &self, + request: WithOtherFields, + fee_details: FeeDetails, + block_request: Option>, + overrides: EvmOverrides, + ) -> Result<(InstructionResult, Option, u128, State), BlockchainError> { + self.with_database_at_and_context(block_request, |state, mut block, monad_context| { + let block_number = block.number; + let (exit, out, gas, state) = { + let mut cache_db = CacheDB::new(state); + if let Some(state_overrides) = overrides.state { + apply_state_overrides(state_overrides.into_iter().collect(), &mut cache_db)?; + } + if let Some(block_overrides) = overrides.block { + cache_db.apply_block_overrides(*block_overrides, &mut block); } + self.call_with_state_and_context( + &cache_db, + request, + fee_details, + block, + monad_context, + ) + }?; + trace!(target: "backend", "call return {:?} out: {:?} gas {} on block {}", exit, out, gas, block_number); + Ok((exit, out, gas, state)) + }) + .await + } + + pub async fn call_with_tracing( + &self, + request: WithOtherFields, + fee_details: FeeDetails, + block_request: Option>, + opts: GethDebugTracingCallOptions, + ) -> Result { + let GethDebugTracingCallOptions { + tracing_options, + block_overrides, + state_overrides, + tx_index, + } = opts; + + if let Some(tx_index) = tx_index { + return self + .call_with_tracing_at_tx_index( + request, + fee_details, + block_request, + tx_index, + tracing_options, + state_overrides, + block_overrides, + ) + .await; + } + + self.with_database_at_and_context(block_request, |state, block, monad_context| { + let cache_db = CacheDB::new(state); + self.trace_call_with_state( + request, + fee_details, + block, + cache_db, + tracing_options, + state_overrides, + block_overrides, + monad_context, + None, + ) + }) + .await + } + + #[allow(clippy::too_many_arguments)] + async fn call_with_tracing_at_tx_index( + &self, + request: WithOtherFields, + fee_details: FeeDetails, + block_request: Option>, + tx_index: u64, + tracing_options: GethDebugTracingOptions, + state_overrides: Option, + block_overrides: Option, + ) -> Result { + let tx_index = usize::try_from(tx_index).map_err(|_| { + BlockchainError::RpcError(RpcError::invalid_params(format!( + "tx_index {tx_index} does not fit in usize" + ))) + })?; + let block_number = match block_request { + Some(BlockRequest::Pending(_)) => { + return Err(BlockchainError::RpcError(RpcError::invalid_params( + "tx_index is not supported for pending blocks".to_string(), + ))); + } + Some(BlockRequest::Number(number)) => number, + None => self.best_number(), + }; + let block_id = BlockId::number(block_number); + + if let Some(block) = self.get_block(block_id) { + return self.mined_trace_call_at_tx_index( + request, + fee_details, + &block, + tx_index, + tracing_options, + state_overrides, + block_overrides, + ); + } + + if let Some(fork) = self.get_fork() + && fork.predates_fork_inclusive(block_number) + { + let opts = GethDebugTracingCallOptions { + tracing_options, + state_overrides, + block_overrides, + tx_index: Some(tx_index as u64), + }; + return Ok(fork.debug_trace_call(request, block_id, opts).await?); + } + + Err(BlockchainError::BlockNotFound) + } + + #[allow(clippy::too_many_arguments)] + fn mined_trace_call_at_tx_index( + &self, + request: WithOtherFields, + fee_details: FeeDetails, + block: &Block, + tx_index: usize, + tracing_options: GethDebugTracingOptions, + state_overrides: Option, + block_overrides: Option, + ) -> Result { + let transaction_count = block.body.transactions.len(); + if tx_index >= transaction_count { + return Err(BlockchainError::RpcError(RpcError::invalid_params(format!( + "tx_index {tx_index} out of bounds for block with {transaction_count} transactions" + )))); + } + + let trace = |parent_state: &StateDb| -> Result { + let db = Box::new(parent_state) as Box; + let (mut cache_db, evm_env, hardfork) = self.prepare_block_replay_with_db(block, db)?; + self.replay_mined_transaction_prefix( + &mut cache_db, + &evm_env, + hardfork, + block, + tx_index, + )?; + self.trace_call_with_state( + request, + fee_details, + evm_env.block_env.clone(), + cache_db, + tracing_options, + state_overrides, + block_overrides, + self.active_monad_context_before_mined_transaction(block, tx_index)?, + Some((evm_env, hardfork)), + ) + }; + + let read_guard = self.states.upgradable_read(); + if let Some(state) = read_guard.get_state(&block.header.parent_hash) { + trace(state) + } else { + let mut write_guard = RwLockUpgradableReadGuard::upgrade(read_guard); + let state = write_guard + .get_on_disk_state(&block.header.parent_hash) + .ok_or(BlockchainError::HistoricalStateUnavailable(block.header.number()))?; + trace(state) + } + } + + #[allow(clippy::too_many_arguments)] + fn trace_call_with_state( + &self, + request: WithOtherFields, + fee_details: FeeDetails, + mut block: BlockEnv, + mut cache_db: CacheDB>, + tracing_options: GethDebugTracingOptions, + state_overrides: Option, + block_overrides: Option, + mut monad_context: Option, + historical_execution: Option<(EvmEnv, FoundryHardfork)>, + ) -> Result { + let GethDebugTracingOptions { config, tracer, tracer_config, .. } = tracing_options; + let block_number = block.number; + let base_evm_env = historical_execution.as_ref().map(|(evm_env, _)| evm_env); + let hardfork = historical_execution + .as_ref() + .map(|(_, hardfork)| *hardfork) + .unwrap_or_else(|| self.hardfork()); + + if let Some(state_overrides) = state_overrides { + apply_state_overrides(state_overrides, &mut cache_db)?; + } + if let Some(block_overrides) = block_overrides { + cache_db.apply_block_overrides(block_overrides, &mut block); + } + + if let Some(tracer) = tracer { + return match tracer { + GethDebugTracerType::BuiltInTracer(tracer) => match tracer { + GethDebugBuiltInTracerType::CallTracer => { + let call_config = call_config_from_tracer_config(tracer_config) + .map_err(|e| RpcError::invalid_params(e.to_string()))?; + + let mut inspector = self.build_inspector().with_tracing_config( + TracingInspectorConfig::from_geth_call_config(&call_config), + ); + + let PreparedCall { evm_env, tx_env, .. } = self + .prepare_call_env_from_base( + &cache_db, + request, + fee_details, + block, + base_evm_env, + )?; + let ResultAndState { result, state: _ } = self + .transact_call_with_inspector_ref_at_hardfork( + &cache_db, + &evm_env, + &mut inspector, + tx_env, + monad_context.as_mut().map(next_monad_context), + hardfork, + )?; + + inspector.print_logs(); + if self.print_traces { + inspector.print_traces(self.call_trace_decoder()); + } + + let tracing_inspector = inspector.tracer.expect("tracer disappeared"); + + Ok(tracing_inspector + .into_geth_builder() + .geth_call_traces(call_config, result.tx_gas_used()) + .into()) + } + GethDebugBuiltInTracerType::PreStateTracer => { + let pre_state_config = tracer_config + .into_pre_state_config() + .map_err(|e| RpcError::invalid_params(e.to_string()))?; - let transactions_envelopes: Vec = transactions - .iter() - .map(|tx| AnyTxEnvelope::from(tx.clone())) - .collect(); - let header = Header { - logs_bloom: logs_bloom(logs.iter()), - transactions_root: calculate_transaction_root(&transactions_envelopes), - receipts_root: calculate_receipt_root(&transactions_envelopes), - parent_hash: Default::default(), - beneficiary: block_env.beneficiary, - state_root: Default::default(), - difficulty: Default::default(), - number: block_env.number.saturating_to(), - gas_limit: block_env.gas_limit, - gas_used, - timestamp: block_env.timestamp.saturating_to(), - extra_data: Default::default(), - mix_hash: Default::default(), - nonce: Default::default(), - base_fee_per_gas: Some(block_env.basefee), - withdrawals_root: None, - blob_gas_used: None, - excess_blob_gas: None, - parent_beacon_block_root: None, - requests_hash: None, - ..Default::default() - }; - let mut block = alloy_rpc_types::Block { - header: AnyRpcHeader { - hash: header.hash_slow(), - inner: header.into(), - total_difficulty: None, - size: None, - }, - uncles: vec![], - transactions: BlockTransactions::Full(transactions), - withdrawals: None, - }; + let mut inspector = TracingInspector::new( + TracingInspectorConfig::from_geth_prestate_config(&pre_state_config), + ); - if !return_full_transactions { - block.transactions.convert_to_hashes(); + let PreparedCall { evm_env, tx_env, .. } = self + .prepare_call_env_from_base( + &cache_db, + request, + fee_details, + block, + base_evm_env, + )?; + let result = self.transact_call_with_inspector_ref_at_hardfork( + &cache_db, + &evm_env, + &mut inspector, + tx_env, + monad_context.as_mut().map(next_monad_context), + hardfork, + )?; + + Ok(inspector + .into_geth_builder() + .geth_prestate_traces(&result, &pre_state_config, cache_db)? + .into()) + } + GethDebugBuiltInTracerType::NoopTracer => Ok(NoopFrame::default().into()), + GethDebugBuiltInTracerType::FourByteTracer + | GethDebugBuiltInTracerType::MuxTracer + | GethDebugBuiltInTracerType::FlatCallTracer + | GethDebugBuiltInTracerType::Erc7562Tracer + | GethDebugBuiltInTracerType::StateGasTracer => { + Err(RpcError::invalid_params("unsupported tracer type").into()) + } + }, + #[cfg(not(feature = "js-tracer"))] + GethDebugTracerType::JsTracer(_) => { + Err(RpcError::invalid_params("unsupported tracer type").into()) } + #[cfg(feature = "js-tracer")] + GethDebugTracerType::JsTracer(code) => { + let config = tracer_config.into_json(); + let mut inspector = + revm_inspectors::tracing::js::JsInspector::new(code, config) + .map_err(|err| BlockchainError::Message(err.to_string()))?; + + let PreparedCall { evm_env, tx_env, .. } = self.prepare_call_env_from_base( + &cache_db, + request, + fee_details, + block.clone(), + base_evm_env, + )?; + let result = self.transact_call_with_inspector_ref_at_hardfork( + &cache_db, + &evm_env, + &mut inspector, + tx_env.clone(), + monad_context.as_mut().map(next_monad_context), + hardfork, + )?; + let res = inspector + .json_result(result, tx_env.base(), &block, &cache_db) + .map_err(|err| BlockchainError::Message(err.to_string()))?; - for res in &mut call_res { - res.logs.iter_mut().for_each(|log| { - log.block_hash = Some(block.header.hash); - }); + Ok(GethTrace::JS(res)) } + }; + } - let simulated_block = SimulatedBlock { - inner: AnyRpcBlock::new(WithOtherFields::new(block)), - calls: call_res, - }; + // defaults to StructLog tracer used since no tracer is specified + let mut inspector = self + .build_inspector() + .with_tracing_config(TracingInspectorConfig::from_geth_config(&config)); + + let PreparedCall { evm_env, tx_env, .. } = + self.prepare_call_env_from_base(&cache_db, request, fee_details, block, base_evm_env)?; + let ResultAndState { result, state: _ } = self + .transact_call_with_inspector_ref_at_hardfork( + &cache_db, + &evm_env, + &mut inspector, + tx_env, + monad_context.as_mut().map(next_monad_context), + hardfork, + )?; - // update block env - block_env.number += U256::from(1); - block_env.timestamp += U256::from(12); - block_env.basefee = simulated_block - .inner - .header - .next_block_base_fee(BaseFeeParams::ethereum()) - .unwrap_or_default(); + let (exit_reason, gas_used, out, _logs) = unpack_execution_result(result); - block_res.push(simulated_block); - } + let tracing_inspector = inspector.tracer.expect("tracer disappeared"); + let return_value = out.as_ref().map(|o| o.data()).cloned().unwrap_or_default(); - Ok(block_res) - }) - .await? + trace!(target: "backend", ?exit_reason, ?out, %gas_used, %block_number, "trace call"); + + let res = tracing_inspector + .into_geth_builder() + .geth_traces(gas_used, return_value, config) + .into(); + + Ok(res) } - pub fn call_with_state( + /// Helper function to execute a closure with the database at a specific block + pub async fn with_database_at( &self, - state: &dyn DatabaseRef, - request: WithOtherFields, - fee_details: FeeDetails, - block_env: BlockEnv, - ) -> Result<(InstructionResult, Option, u128, State), BlockchainError> { - let mut inspector = self.build_inspector(); + block_request: Option>, + f: F, + ) -> Result + where + F: FnOnce(Box, BlockEnv) -> T, + { + let block_number = match block_request { + Some(BlockRequest::Pending(pool_transactions)) => { + let result = self + .with_pending_block(pool_transactions, |state, block| { + let block = block.block; + f(state, self.block_env_from_header(&block.header)) + }) + .await?; + return Ok(result); + } + Some(BlockRequest::Number(bn)) => Some(BlockNumber::Number(bn)), + None => None, + }; + let block_number = self.convert_block_number(block_number); + let current_number = self.best_number(); + + // Reject requests for future blocks that don't exist yet + if block_number > current_number { + return Err(BlockchainError::BlockOutOfRange(current_number, block_number)); + } + + // The head can advance while waiting for the database lock. Serve the live state only if + // the requested block is still the head or no state history is kept. Otherwise hold the + // lock so the requested block's state cannot be pruned before it is read below. + let _head_guard = if block_number == current_number { + let db = self.db.read().await; + if self.best_number() == block_number + || !self.prune_state_history_config.is_state_history_supported() + { + let block = self.evm_env.read().block_env.clone(); + return Ok(f(Box::new(&**db), block)); + } + Some(db) + } else { + None + }; + + if let Some((block_hash, block)) = self + .block_by_number(BlockNumber::Number(block_number)) + .await? + .map(|block| (block.header.hash, block)) + { + let read_guard = self.states.upgradable_read(); + if let Some(state_db) = read_guard.get_state(&block_hash) { + return Ok(f(Box::new(state_db), self.block_env_from_header(&block.header))); + } - let env = self.build_call_env(request, fee_details, block_env); - let mut evm = self.new_evm_with_inspector_ref(state, &env, &mut inspector); - let ResultAndState { result, state } = evm.transact(env.tx)?; - let (exit_reason, gas_used, out) = match result { - ExecutionResult::Success { reason, gas_used, output, .. } => { - (reason.into(), gas_used, Some(output)) + let mut write_guard = RwLockUpgradableReadGuard::upgrade(read_guard); + if let Some(state) = write_guard.get_on_disk_state(&block_hash) { + return Ok(f(Box::new(state), self.block_env_from_header(&block.header))); } - ExecutionResult::Revert { gas_used, output } => { - (InstructionResult::Revert, gas_used, Some(Output::Call(output))) + } + + warn!(target: "backend", "Not historic state found for block={}", block_number); + Err(BlockchainError::BlockOutOfRange(self.best_number(), block_number)) + } + + /// Executes a closure with both state and network context at a specific block. + pub(crate) async fn with_database_at_and_context( + &self, + block_request: Option>, + f: F, + ) -> Result + where + F: FnOnce( + Box, + BlockEnv, + Option, + ) -> Result, + { + let block_number = match block_request { + Some(BlockRequest::Pending(pool_transactions)) => { + return self + .with_pending_block(pool_transactions, |state, block_info| { + let context = self.active_monad_context_before_mined_transaction( + &block_info.block, + block_info.block.body.transactions.len(), + )?; + let block_env = self.block_env_from_header(&block_info.block.header); + f(state, block_env, context) + }) + .await?; } - ExecutionResult::Halt { reason, gas_used } => { - (op_haltreason_to_instruction_result(reason), gas_used, None) + Some(BlockRequest::Number(number)) => Some(BlockNumber::Number(number)), + None => None, + }; + let block_number = self.convert_block_number(block_number); + let current_number = self.best_number(); + + if block_number > current_number { + return Err(BlockchainError::BlockOutOfRange(current_number, block_number)); + } + + #[cfg(feature = "monad")] + let context = if self.is_monad() { + Some(self.monad_context_for_child_of_block_number(block_number).await?) + } else { + None + }; + #[cfg(not(feature = "monad"))] + let context = None; + + // See `with_database_at`: re-check the head under the read guard. + let _head_guard = if block_number == current_number { + let db = self.db.read().await; + if self.best_number() == block_number + || !self.prune_state_history_config.is_state_history_supported() + { + let block = self.evm_env.read().block_env.clone(); + return f(Box::new(&**db), block, context); } + Some(db) + } else { + None }; - drop(evm); - inspector.print_logs(); - if self.print_traces { - inspector.into_print_traces(); + if let Some((block_hash, block)) = self + .block_by_number(BlockNumber::Number(block_number)) + .await? + .map(|block| (block.header.hash, block)) + { + let read_guard = self.states.upgradable_read(); + if let Some(state_db) = read_guard.get_state(&block_hash) { + return f(Box::new(state_db), self.block_env_from_header(&block.header), context); + } + + let mut write_guard = RwLockUpgradableReadGuard::upgrade(read_guard); + if let Some(state) = write_guard.get_on_disk_state(&block_hash) { + return f(Box::new(state), self.block_env_from_header(&block.header), context); + } } - Ok((exit_reason, out, gas_used as u128, state)) + warn!(target: "backend", "Not historic state found for block={}", block_number); + Err(BlockchainError::BlockOutOfRange(self.best_number(), block_number)) } - pub async fn call_with_tracing( + pub async fn storage_at( &self, - request: WithOtherFields, - fee_details: FeeDetails, - block_request: Option, - opts: GethDebugTracingCallOptions, - ) -> Result { - let GethDebugTracingCallOptions { tracing_options, block_overrides, state_overrides } = - opts; - let GethDebugTracingOptions { config, tracer, tracer_config, .. } = tracing_options; + address: Address, + index: U256, + block_request: Option>, + ) -> Result { + self.with_database_at(block_request, |db, _| { + trace!(target: "backend", "get storage for {:?} at {:?}", address, index); + let val = db.storage_ref(address, index)?; + Ok(val.into()) + }) + .await? + } - self.with_database_at(block_request, |state, mut block| { - let block_number = block.number; + pub async fn tempo_nonce( + &self, + caller: Address, + nonce_key: U256, + block_request: Option>, + ) -> Result { + self.with_database_at(block_request, |state, _| tempo_nonce(&state, caller, nonce_key)) + .await? + } - let mut cache_db = CacheDB::new(state); - if let Some(state_overrides) = state_overrides { - state::apply_state_overrides(state_overrides, &mut cache_db)?; - } - if let Some(block_overrides) = block_overrides { - state::apply_block_overrides(block_overrides, &mut cache_db, &mut block); + /// Returns storage values for multiple accounts and slots in a single call. + pub async fn storage_values( + &self, + requests: HashMap>, + block_request: Option>, + ) -> Result>, BlockchainError> { + self.with_database_at(block_request, |db, _| { + trace!(target: "backend", "get storage values for {} addresses", requests.len()); + let mut result: HashMap> = HashMap::default(); + for (address, slots) in &requests { + let mut values = Vec::with_capacity(slots.len()); + for slot in slots { + let val = db.storage_ref(*address, (*slot).into())?; + values.push(val.into()); + } + result.insert(*address, values); } + Ok(result) + }) + .await? + } - if let Some(tracer) = tracer { - return match tracer { - GethDebugTracerType::BuiltInTracer(tracer) => match tracer { - GethDebugBuiltInTracerType::CallTracer => { - let call_config = tracer_config - .into_call_config() - .map_err(|e| RpcError::invalid_params(e.to_string()))?; + /// Returns the code of the address + /// + /// If the code is not present and fork mode is enabled then this will try to fetch it from the + /// forked client + pub async fn get_code( + &self, + address: Address, + block_request: Option>, + ) -> Result { + self.with_database_at(block_request, |db, _| self.get_code_with_state(&db, address)).await? + } - let mut inspector = self.build_inspector().with_tracing_config( - TracingInspectorConfig::from_geth_call_config(&call_config), - ); + /// Returns the balance of the address + /// + /// If the requested number predates the fork then this will fetch it from the endpoint + pub async fn get_balance( + &self, + address: Address, + block_request: Option>, + ) -> Result { + self.with_database_at(block_request, |db, _| self.get_balance_with_state(db, address)) + .await? + } - let env = self.build_call_env(request, fee_details, block); - let mut evm = self.new_evm_with_inspector_ref( - &cache_db as &dyn DatabaseRef, - &env, - &mut inspector, - ); - let ResultAndState { result, state: _ } = evm.transact(env.tx)?; + pub async fn get_account_at_block( + &self, + address: Address, + block_request: Option>, + ) -> Result { + self.with_database_at(block_request, |block_db, _| { + let db = block_db.maybe_as_full_db().ok_or(BlockchainError::DataUnavailable)?; + let account = db.get(&address).cloned().unwrap_or_default(); + let storage_root = storage_root(&account.storage); + let code_hash = account.info.code_hash(); + let balance = account.info.balance; + let nonce = account.info.nonce; + Ok(TrieAccount { balance, nonce, code_hash, storage_root }) + }) + .await? + } - drop(evm); - let tracing_inspector = inspector.tracer.expect("tracer disappeared"); + pub async fn get_account_info_at_block( + &self, + address: Address, + block_request: Option>, + ) -> Result { + self.with_database_at(block_request, |db, _| { + let account = db.basic_ref(address)?.unwrap_or_default(); + let code = self.get_code_with_state(&db, address)?; + Ok(RpcAccountInfo { balance: account.balance, nonce: account.nonce, code }) + }) + .await? + } - Ok(tracing_inspector - .into_geth_builder() - .geth_call_traces(call_config, result.gas_used()) - .into()) - } - GethDebugBuiltInTracerType::NoopTracer => Ok(NoopFrame::default().into()), - GethDebugBuiltInTracerType::FourByteTracer - | GethDebugBuiltInTracerType::PreStateTracer - | GethDebugBuiltInTracerType::MuxTracer - | GethDebugBuiltInTracerType::FlatCallTracer => { - Err(RpcError::invalid_params("unsupported tracer type").into()) - } - }, + /// Returns the nonce of the address + /// + /// If the requested number predates the fork then this will fetch it from the endpoint + pub async fn get_nonce( + &self, + address: Address, + block_request: BlockRequest, + ) -> Result { + if let BlockRequest::Pending(pool_transactions) = &block_request + && let Some(value) = get_pool_transactions_nonce(pool_transactions, address) + { + return Ok(value); + } + let final_block_request = match block_request { + BlockRequest::Pending(_) => BlockRequest::Number(self.best_number()), + BlockRequest::Number(bn) => BlockRequest::Number(bn), + }; - GethDebugTracerType::JsTracer(_code) => { - Err(RpcError::invalid_params("unsupported tracer type").into()) - } - }; - } + self.with_database_at(Some(final_block_request), |db, _| { + trace!(target: "backend", "get nonce for {:?}", address); + Ok(db.basic_ref(address)?.unwrap_or_default().nonce) + }) + .await? + } + + /// Traces the transaction with the js tracer + #[cfg(feature = "js-tracer")] + pub async fn trace_tx_with_js_tracer( + &self, + hash: B256, + code: String, + opts: GethDebugTracingOptions, + ) -> Result { + let GethDebugTracingOptions { tracer_config, .. } = opts; + let config = tracer_config.into_json(); + let inspector = revm_inspectors::tracing::js::JsInspector::new(code, config) + .map_err(|err| BlockchainError::Message(err.to_string()))?; + let trace = self.replay_tx_with_inspector( + hash, + inspector, + |result, cache_db, mut inspector, tx_env, evm_env| { + inspector + .json_result( + result, + &alloy_evm::IntoTxEnv::into_tx_env(tx_env), + evm_env.block_env(), + &cache_db, + ) + .map_err(|e| BlockchainError::Message(e.to_string())) + }, + )??; + Ok(GethTrace::JS(trace)) + } - // defaults to StructLog tracer used since no tracer is specified - let mut inspector = self - .build_inspector() - .with_tracing_config(TracingInspectorConfig::from_geth_config(&config)); + /// Prove an account's existence or nonexistence in the state trie. + /// + /// Returns a merkle proof of the account's trie node, `account_key` == keccak(address) + pub async fn prove_account_at( + &self, + address: Address, + keys: Vec, + block_request: Option>, + ) -> Result { + let block_number = block_request.as_ref().map(|r| r.block_number()); - let env = self.build_call_env(request, fee_details, block); - let mut evm = self.new_evm_with_inspector_ref( - &cache_db as &dyn DatabaseRef, - &env, - &mut inspector, - ); - let ResultAndState { result, state: _ } = evm.transact(env.tx)?; + self.with_database_at(block_request, |block_db, _| { + trace!(target: "backend", "get proof for {:?} at {:?}", address, block_number); + let db = block_db.maybe_as_full_db().ok_or(BlockchainError::DataUnavailable)?; + let account = db.get(&address).cloned().unwrap_or_default(); - let (exit_reason, gas_used, out) = match result { - ExecutionResult::Success { reason, gas_used, output, .. } => { - (reason.into(), gas_used, Some(output)) - } - ExecutionResult::Revert { gas_used, output } => { - (InstructionResult::Revert, gas_used, Some(Output::Call(output))) - } - ExecutionResult::Halt { reason, gas_used } => { - (op_haltreason_to_instruction_result(reason), gas_used, None) - } - }; + let mut builder = HashBuilder::default() + .with_proof_retainer(ProofRetainer::new(vec![Nibbles::unpack(keccak256(address))])); + + for (key, account) in trie_accounts(db) { + builder.add_leaf(key, &account); + } - drop(evm); - let tracing_inspector = inspector.tracer.expect("tracer disappeared"); - let return_value = out.as_ref().map(|o| o.data().clone()).unwrap_or_default(); + let _ = builder.root(); - trace!(target: "backend", ?exit_reason, ?out, %gas_used, %block_number, "trace call"); + let proof = builder + .take_proof_nodes() + .into_nodes_sorted() + .into_iter() + .map(|(_, v)| v) + .collect(); + let (storage_hash, storage_proofs) = prove_storage(&account.storage, &keys); - let res = tracing_inspector - .into_geth_builder() - .geth_traces(gas_used, return_value, config) - .into(); + let account_proof = AccountProof { + address, + balance: account.info.balance, + nonce: account.info.nonce, + code_hash: account.info.code_hash(), + storage_hash, + account_proof: proof, + storage_proof: keys + .into_iter() + .zip(storage_proofs) + .map(|(key, proof)| { + let storage_key: U256 = key.into(); + let value = account.storage.get(&storage_key).copied().unwrap_or_default(); + StorageProof { key: JsonStorageKey::Hash(key), value, proof } + }) + .collect(), + }; - Ok(res) + Ok(account_proof) }) .await? } +} - pub fn build_access_list_with_state( +impl Backend +where + N: Network, +{ + /// Returns opcode gas usage for the given transaction. + pub async fn trace_transaction_opcode_gas( &self, - state: &dyn DatabaseRef, - request: WithOtherFields, - fee_details: FeeDetails, - block_env: BlockEnv, - ) -> Result<(InstructionResult, Option, u64, AccessList), BlockchainError> { - let mut inspector = - AccessListInspector::new(request.access_list.clone().unwrap_or_default()); - - let env = self.build_call_env(request, fee_details, block_env); - let mut evm = self.new_evm_with_inspector_ref(state, &env, &mut inspector); - let ResultAndState { result, state: _ } = evm.transact(env.tx)?; - let (exit_reason, gas_used, out) = match result { - ExecutionResult::Success { reason, gas_used, output, .. } => { - (reason.into(), gas_used, Some(output)) - } - ExecutionResult::Revert { gas_used, output } => { - (InstructionResult::Revert, gas_used, Some(Output::Call(output))) - } - ExecutionResult::Halt { reason, gas_used } => { - (op_haltreason_to_instruction_result(reason), gas_used, None) - } - }; - drop(evm); - let access_list = inspector.access_list(); - Ok((exit_reason, out, gas_used, access_list)) - } - - /// returns all receipts for the given transactions - fn get_receipts(&self, tx_hashes: impl IntoIterator) -> Vec { - let storage = self.blockchain.storage.read(); - let mut receipts = vec![]; + hash: B256, + ) -> Result, BlockchainError> { + match self.replay_tx_with_inspector( + hash, + OpcodeGasInspector::new(), + move |_, _, inspector, _, _| TransactionOpcodeGas { + transaction_hash: hash, + opcode_gas: inspector.opcode_gas_iter().collect(), + }, + ) { + Ok(trace) => Ok(Some(trace)), + Err(BlockchainError::TransactionNotFound) => { + if let Some(fork) = self.get_fork() { + return Ok(fork.trace_transaction_opcode_gas(hash).await?); + } - for hash in tx_hashes { - if let Some(tx) = storage.transactions.get(&hash) { - receipts.push(tx.receipt.clone()); + Ok(None) } + Err(err) => Err(err), } - - receipts } - /// Returns the logs of the block that match the filter - async fn logs_for_block( + /// Returns opcode gas usage for all transactions in the given block. + pub async fn trace_block_opcode_gas( &self, - filter: Filter, - hash: B256, - ) -> Result, BlockchainError> { - if let Some(block) = self.blockchain.get_block_by_hash(&hash) { - return Ok(self.mined_logs_for_block(filter, block)); + block_id: BlockId, + ) -> Result, BlockchainError> { + if let Some((block, block_hash)) = self.get_block_with_hash(block_id) { + return self.mined_block_opcode_gas(&block, block_hash).map(Some); } if let Some(fork) = self.get_fork() { - return Ok(fork.logs(&filter).await?); + let number = self.ensure_block_number(Some(block_id)).await?; + if fork.predates_fork_inclusive(number) { + // Delegate the resolved block number so tags (`latest`/`pending`/`safe`/ + // `finalized`) are resolved against the fork's head instead of drifting with + // the upstream chain. Hashes are forwarded unchanged. + let resolved = match block_id { + BlockId::Hash(_) => block_id, + _ => BlockId::number(number), + }; + return Ok(fork.trace_block_opcode_gas(resolved).await?); + } } - Ok(Vec::new()) + Err(BlockchainError::BlockNotFound) } - /// Returns all `Log`s mined by the node that were emitted in the `block` and match the `Filter` - fn mined_logs_for_block(&self, filter: Filter, block: Block) -> Vec { - let mut all_logs = Vec::new(); - let block_hash = block.header.hash_slow(); - let mut block_log_index = 0u32; - - let storage = self.blockchain.storage.read(); - - for tx in block.transactions { - let Some(tx) = storage.transactions.get(&tx.hash()) else { - continue; - }; + fn mined_block_opcode_gas( + &self, + block: &Block, + block_hash: B256, + ) -> Result { + // Genesis has no parent state or protocol pre-execution to replay. + if block.header.number() == self.genesis_number() { + return Ok(BlockOpcodeGas { + block_hash, + block_number: block.header.number(), + transactions: Vec::new(), + }); + } - let logs = tx.receipt.logs(); - let transaction_hash = tx.info.transaction_hash; + let parent_hash = block.header.parent_hash; + + let trace = |parent_state: &StateDb| -> Result, BlockchainError> { + let (mut cache_db, evm_env, hardfork) = + self.prepare_block_replay(block, parent_state)?; + let mut transactions = Vec::with_capacity(block.body.transactions.len()); + let monad_context = self.active_monad_context_for_mined_block(block)?; + + for tx_envelope in &block.body.transactions { + let mut inspector = OpcodeGasInspector::new(); + let pending_tx = self.pending_mined_transaction(tx_envelope.clone())?; + let transaction_context = + monad_execution_context_at(monad_context.as_ref(), transactions.len()); + let (result, _) = self.replay_envelope_with_inspector_ref_and_context( + &cache_db, + &evm_env, + &mut inspector, + &pending_tx, + EnvelopeExecution::replay(transaction_context, hardfork), + )?; + + transactions.push(TransactionOpcodeGas { + transaction_hash: tx_envelope.hash(), + opcode_gas: inspector.opcode_gas_iter().collect(), + }); - for log in logs { - if filter.matches(log) { - all_logs.push(Log { - inner: log.clone(), - block_hash: Some(block_hash), - block_number: Some(block.header.number), - block_timestamp: Some(block.header.timestamp), - transaction_hash: Some(transaction_hash), - transaction_index: Some(tx.info.transaction_index), - log_index: Some(block_log_index as u64), - removed: false, - }); - } - block_log_index += 1; + cache_db.commit(result.state); } - } - all_logs - } - - /// Returns the logs that match the filter in the given range of blocks - async fn logs_for_range( - &self, - filter: &Filter, - mut from: u64, - to: u64, - ) -> Result, BlockchainError> { - let mut all_logs = Vec::new(); - // get the range that predates the fork if any - if let Some(fork) = self.get_fork() { - let mut to_on_fork = to; + Ok(transactions) + }; - if !fork.predates_fork(to) { - // adjust the ranges - to_on_fork = fork.block_number(); - } + let read_guard = self.states.upgradable_read(); + let transactions = if let Some(state) = read_guard.get_state(&parent_hash) { + trace(state)? + } else { + let mut write_guard = RwLockUpgradableReadGuard::upgrade(read_guard); + let state = write_guard + .get_on_disk_state(&parent_hash) + .ok_or(BlockchainError::HistoricalStateUnavailable(block.header.number()))?; + trace(state)? + }; - if fork.predates_fork_inclusive(from) { - // this data is only available on the forked client - let filter = filter.clone().from_block(from).to_block(to_on_fork); - all_logs = fork.logs(&filter).await?; + Ok(BlockOpcodeGas { block_hash, block_number: block.header.number(), transactions }) + } - // update the range - from = fork.block_number() + 1; - } + /// Returns a best-effort execution witness for the given block, in the same format as reth's + /// `debug_executionWitness`. + /// + /// Anvil does not track which state a block's execution actually touched, so this returns a + /// witness for the entire parent state instead: the RLP encoding of every node of the parent + /// state trie (including all storage tries), all contract codes, and the preimages of all + /// account addresses and storage slots. This is a strict superset of the minimal witness, so + /// stateless re-execution of the block against it works, but the witness size grows with the + /// total state instead of the state accessed by the block. + /// + /// Limitations: + /// - Not supported while forking: only remotely accessed accounts are known locally, and the + /// locally computed state roots do not match the remote chain's roots. + /// - The parent block's state must still be available in the state history, i.e. it must not + /// have been discarded via `--prune-history`. + /// - The genesis block has no witness since it has no parent state. + /// - `headers` contains the ancestor headers within the 256 block `BLOCKHASH` window that are + /// known locally, which may be fewer than 256. + pub async fn debug_execution_witness( + &self, + block: BlockNumber, + ) -> Result { + let number = self.convert_block_number(Some(block)); + let best = self.best_number(); + if number > best { + return Err(BlockchainError::BlockOutOfRange(best, number)); } + let Some(parent) = number.checked_sub(1) else { + return Err(BlockchainError::Message( + "genesis block has no parent state to build a witness from".to_string(), + )); + }; - for number in from..=to { - if let Some(block) = self.get_block(number) { - all_logs.extend(self.mined_logs_for_block(filter.clone(), block)); - } + let mut headers = Vec::new(); + for ancestor in (number.saturating_sub(BLOCKHASH_HISTORY)..number).rev() { + let Some(block) = self.get_block(ancestor) else { break }; + headers.push(alloy_rlp::encode(&block.header).into()); } - Ok(all_logs) - } + self.with_database_at(Some(BlockRequest::Number(parent)), |state, _| { + let Some(accounts) = state.maybe_full_db() else { + return Err(BlockchainError::Message( + "debug_executionWitness is not supported while forking".to_string(), + )); + }; - /// Returns the logs according to the filter - pub async fn logs(&self, filter: Filter) -> Result, BlockchainError> { - trace!(target: "backend", "get logs [{:?}]", filter); - if let Some(hash) = filter.get_block_hash() { - self.logs_for_block(filter, hash).await - } else { - let best = self.best_number(); - let to_block = - self.convert_block_number(filter.block_option.get_to_block().copied()).min(best); - let from_block = - self.convert_block_number(filter.block_option.get_from_block().copied()); - if from_block > best { - // requested log range does not exist yet - return Ok(vec![]); + let (_, nodes) = state_trie_witness(&accounts); + let mut codes = Vec::new(); + let mut seen_codes = B256Set::default(); + let mut keys = Vec::new(); + for (address, account) in &accounts { + keys.push(Bytes::copy_from_slice(address.as_slice())); + for slot in account.storage.keys() { + keys.push(Bytes::from(slot.to_be_bytes::<32>())); + } + if !account.info.is_empty_code_hash() && seen_codes.insert(account.info.code_hash()) + { + let code = match &account.info.code { + Some(code) => code.original_bytes(), + None => state.code_by_hash_ref(account.info.code_hash())?.original_bytes(), + }; + codes.push(code); + } } + keys.sort_unstable(); + keys.dedup(); - self.logs_for_range(&filter, from_block, to_block).await - } + Ok(ExecutionWitness { state: nodes, codes, keys, headers }) + }) + .await? } - pub async fn block_by_hash(&self, hash: B256) -> Result, BlockchainError> { - trace!(target: "backend", "get block by hash {:?}", hash); - if let tx @ Some(_) = self.mined_block_by_hash(hash) { - return Ok(tx); + /// Returns account information after replaying a block through the transaction at `tx_index`. + pub async fn debug_account_info_at( + &self, + block_id: BlockId, + tx_index: Index, + address: Address, + ) -> Result, BlockchainError> { + if let Some((block, _)) = self.get_block_with_hash(block_id) { + return self.mined_debug_account_info_at(&block, tx_index, address).map(Some); } if let Some(fork) = self.get_fork() { - return Ok(fork.block_by_hash(hash).await?); + let number = self.ensure_block_number(Some(block_id)).await?; + if fork.predates_fork_inclusive(number) { + // Delegate the resolved block number so tags (`latest`/`pending`/`safe`/ + // `finalized`) are resolved against the fork's head instead of drifting with + // the upstream chain. Hashes are forwarded unchanged. + let resolved = match block_id { + BlockId::Hash(_) => block_id, + _ => BlockId::number(number), + }; + return Ok(fork.debug_account_info_at(resolved, tx_index, address).await?); + } } - Ok(None) + Err(BlockchainError::BlockNotFound) } - pub async fn block_by_hash_full( + fn mined_debug_account_info_at( &self, - hash: B256, - ) -> Result, BlockchainError> { - trace!(target: "backend", "get block by hash {:?}", hash); - if let tx @ Some(_) = self.get_full_block(hash) { - return Ok(tx); - } - - if let Some(fork) = self.get_fork() { - return Ok(fork.block_by_hash_full(hash).await?); + block: &Block, + tx_index: Index, + address: Address, + ) -> Result { + let tx_index = tx_index.0; + let transaction_count = block.body.transactions.len(); + if tx_index >= transaction_count { + return Err(BlockchainError::RpcError(RpcError::invalid_params(format!( + "tx_index {tx_index} out of bounds for block with {transaction_count} transactions" + )))); } - Ok(None) - } - - fn mined_block_by_hash(&self, hash: B256) -> Option { - let block = self.blockchain.get_block_by_hash(&hash)?; - Some(self.convert_block(block)) - } + let trace = |parent_state: &StateDb| -> Result { + let (mut cache_db, evm_env, hardfork) = + self.prepare_block_replay_with_db(block, Box::new(parent_state))?; + self.replay_mined_transaction_prefix( + &mut cache_db, + &evm_env, + hardfork, + block, + tx_index + 1, + )?; + let account = revm::DatabaseRef::basic_ref(&cache_db, address)?.unwrap_or_default(); + let code = self.get_code_with_state(&cache_db, address)?; + Ok(RpcAccountInfo { balance: account.balance, nonce: account.nonce, code }) + }; - pub(crate) async fn mined_transactions_by_block_number( - &self, - number: BlockNumber, - ) -> Option> { - if let Some(block) = self.get_block(number) { - return self.mined_transactions_in_block(&block); + let read_guard = self.states.upgradable_read(); + if let Some(state) = read_guard.get_state(&block.header.parent_hash) { + trace(state) + } else { + let mut write_guard = RwLockUpgradableReadGuard::upgrade(read_guard); + let state = write_guard + .get_on_disk_state(&block.header.parent_hash) + .ok_or(BlockchainError::HistoricalStateUnavailable(block.header.number()))?; + trace(state) } - None } - /// Returns all transactions given a block - pub(crate) fn mined_transactions_in_block( + /// Rollback the chain to a common height. + /// + /// The state of the chain is rewound using `rewind` to the common block, including the db, + /// storage, and env. + pub async fn rollback( &self, - block: &Block, - ) -> Option> { - let mut transactions = Vec::with_capacity(block.transactions.len()); - let base_fee = block.header.base_fee_per_gas; - let storage = self.blockchain.storage.read(); - for hash in block.transactions.iter().map(|tx| tx.hash()) { - let info = storage.transactions.get(&hash)?.info.clone(); - let tx = block.transactions.get(info.transaction_index as usize)?.clone(); + common_block: Block, + _mining_guard: &tokio::sync::MutexGuard<'_, ()>, + ) -> Result<(), BlockchainError> { + let hash = common_block.header.hash_slow(); - let tx = transaction_build(Some(hash), tx, Some(block), Some(info), base_fee); - transactions.push(tx); - } - Some(transactions) - } + // Get the database at the common block + let (common_state, replace_accounts) = { + let return_state_or_throw_err = + |db: Option<&StateDb>| -> Result, BlockchainError> { + let state_db = db.ok_or(BlockchainError::DataUnavailable)?; + let db_full = + state_db.maybe_as_full_db().ok_or(BlockchainError::DataUnavailable)?; + Ok(db_full.clone()) + }; - pub async fn block_by_number( - &self, - number: BlockNumber, - ) -> Result, BlockchainError> { - trace!(target: "backend", "get block by number {:?}", number); - if let tx @ Some(_) = self.mined_block_by_number(number) { - return Ok(tx); - } + let read_guard = self.states.upgradable_read(); + if let Some(db) = read_guard.get_state(&hash) { + (return_state_or_throw_err(Some(db))?, true) + } else { + let mut write_guard = RwLockUpgradableReadGuard::upgrade(read_guard); + (return_state_or_throw_err(write_guard.get_on_disk_state(&hash))?, false) + } + }; - if let Some(fork) = self.get_fork() { - let number = self.convert_block_number(Some(number)); - if fork.predates_fork_inclusive(number) { - return Ok(fork.block_by_number(number).await?); + // Acquire the only awaited mutation lock before publishing any part of the rewind. + let mut db = self.db.write().await; + { + // Collect the logs of the blocks that are about to be removed from the canonical + // chain, while their transactions and receipts are still in storage + let removed_logs = self.removed_logs_since(common_block.header.number()); + + // Unwind the storage back to the common ancestor first + let removed_blocks = + self.blockchain.storage.write().unwind_to(common_block.header.number(), hash); + + // Clean up in-memory and on-disk states for removed blocks + let removed_hashes: Vec<_> = + removed_blocks.iter().map(|b| b.header.hash_slow()).collect(); + self.states.write().remove_block_states(&removed_hashes); + + // Notify all log subscriptions and filters about the removed logs, so they receive + // them again marked as removed, before any new chain notifications are emitted + if !removed_logs.is_empty() { + self.notify_on_removed_logs(removed_logs); } + + // Set environment back to common block + let mut env = self.evm_env.write(); + env.block_env.number = self.evm_block_number(common_block.header.number()); + env.block_env.timestamp = U256::from(common_block.header.timestamp()); + env.block_env.gas_limit = common_block.header.gas_limit(); + env.block_env.difficulty = common_block.header.difficulty(); + env.block_env.prevrandao = common_block.header.mix_hash(); + + self.time.reset(env.block_env.timestamp.saturating_to()); + // drop any pending next-block overrides so they do not leak into a block + self.cheats.clear_next_block_overrides(); } - Ok(None) - } + // Only collect the last 256 block hashes since that's all BLOCKHASH can access. + let block_hashes: Vec<_> = { + let storage = self.blockchain.storage.read(); + let min_block = common_block.header.number().saturating_sub(256); + storage + .hashes + .iter() + .filter(|(num, _)| **num >= min_block) + .map(|(&num, &hash)| (num, hash)) + .collect() + }; - pub async fn block_by_number_full( - &self, - number: BlockNumber, - ) -> Result, BlockchainError> { - trace!(target: "backend", "get block by number {:?}", number); - if let tx @ Some(_) = self.get_full_block(number) { - return Ok(tx); - } + db.clear(); - if let Some(fork) = self.get_fork() { - let number = self.convert_block_number(Some(number)); - if fork.predates_fork_inclusive(number) { - return Ok(fork.block_by_number_full(number).await?); + if replace_accounts && let Some(accounts) = db.maybe_as_full_db_mut() { + *accounts = common_state; + } else { + // Insert account info before storage to prevent fork-mode RPC fetches after clear. + for (address, acc) in common_state { + db.insert_account(address, acc.info); + for (key, value) in acc.storage { + db.set_storage_at(address, key.into(), value.into())?; + } } } - Ok(None) - } + // Restore block hashes from blockchain storage (now unwound, contains only valid blocks). + for (block_num, hash) in block_hashes { + db.insert_block_hash(U256::from(block_num), hash); + } - pub fn get_block(&self, id: impl Into) -> Option { - let hash = match id.into() { - BlockId::Hash(hash) => hash.block_hash, - BlockId::Number(number) => { - let storage = self.blockchain.storage.read(); - let slots_in_an_epoch = self.slots_in_an_epoch; - match number { - BlockNumber::Latest => storage.best_hash, - BlockNumber::Earliest => storage.genesis_hash, - BlockNumber::Pending => return None, - BlockNumber::Number(num) => *storage.hashes.get(&num)?, - BlockNumber::Safe => { - if storage.best_number > (slots_in_an_epoch) { - *storage.hashes.get(&(storage.best_number - (slots_in_an_epoch)))? - } else { - storage.genesis_hash // treat the genesis block as safe "by definition" - } - } - BlockNumber::Finalized => { - if storage.best_number > (slots_in_an_epoch * 2) { - *storage.hashes.get(&(storage.best_number - (slots_in_an_epoch * 2)))? - } else { - storage.genesis_hash - } - } - } - } - }; - self.get_block_by_hash(hash) + Ok(()) } - pub fn get_block_by_hash(&self, hash: B256) -> Option { - self.blockchain.get_block_by_hash(&hash) - } + /// Returns the traces for the given transaction + pub async fn debug_trace_transaction( + &self, + hash: B256, + opts: GethDebugTracingOptions, + ) -> Result { + #[cfg(feature = "js-tracer")] + if let Some(tracer_type) = opts.tracer.as_ref() + && tracer_type.is_js() + { + return self + .trace_tx_with_js_tracer(hash, tracer_type.as_str().to_string(), opts.clone()) + .await; + } + + if let Some(trace) = self.mined_geth_trace_transaction(hash, opts.clone()).await { + return trace; + } - pub fn mined_block_by_number(&self, number: BlockNumber) -> Option { - let block = self.get_block(number)?; - let mut block = self.convert_block(block); - block.transactions.convert_to_hashes(); - Some(block) + if let Some(fork) = self.get_fork() { + return Ok(fork.debug_trace_transaction(hash, opts).await?); + } + + Err(BlockchainError::TransactionNotFound) } - pub fn get_full_block(&self, id: impl Into) -> Option { - let block = self.get_block(id)?; - let transactions = self.mined_transactions_in_block(&block)?; - let mut block = self.convert_block(block); - block.inner.transactions = BlockTransactions::Full(transactions); + /// Returns geth-style traces for all transactions in an RLP-encoded block. + pub async fn debug_trace_block( + &self, + rlp_block: Bytes, + opts: GethDebugTracingOptions, + ) -> Result, BlockchainError> { + let mut rlp = rlp_block.as_ref(); + let block = Block::::decode(&mut rlp).map_err(|err| { + BlockchainError::RpcError(RpcError::invalid_params(format!( + "failed to decode block: {err}" + ))) + })?; + if !rlp.is_empty() { + return Err(BlockchainError::RpcError(RpcError::invalid_params( + "failed to decode block: trailing bytes".to_string(), + ))); + } - Some(block) + self.debug_trace_block_by_hash(block.header.hash_slow(), opts).await } - /// Takes a block as it's stored internally and returns the eth api conform block format. - pub fn convert_block(&self, block: Block) -> AnyRpcBlock { - let size = U256::from(alloy_rlp::encode(&block).len() as u32); + /// Returns geth-style traces for all transactions in a block by hash. + pub async fn debug_trace_block_by_hash( + &self, + block_hash: B256, + opts: GethDebugTracingOptions, + ) -> Result, BlockchainError> { + if let Some(block) = self.blockchain.get_block_by_hash(&block_hash) { + return Ok(self.debug_trace_mined_block(&block, opts).await); + } - let Block { header, transactions, .. } = block; + if let Some(fork) = self.get_fork() { + return Ok(fork.debug_trace_block_by_hash(block_hash, opts).await?); + } - let hash = header.hash_slow(); - let Header { number, withdrawals_root, .. } = header; + Err(BlockchainError::BlockNotFound) + } - let block = AlloyBlock { - header: AlloyHeader { - inner: AnyHeader::from(header), - hash, - total_difficulty: Some(self.total_difficulty()), - size: Some(size), - }, - transactions: alloy_rpc_types::BlockTransactions::Hashes( - transactions.into_iter().map(|tx| tx.hash()).collect(), - ), - uncles: vec![], - withdrawals: withdrawals_root.map(|_| Default::default()), - }; + /// Returns geth-style traces for all transactions in a block by number. + pub async fn debug_trace_block_by_number( + &self, + block_number: BlockNumber, + opts: GethDebugTracingOptions, + ) -> Result, BlockchainError> { + let number = self.convert_block_number(Some(block_number)); - let mut block = WithOtherFields::new(block); + if let Some(block) = self.get_block(BlockId::number(number)) { + return Ok(self.debug_trace_mined_block(&block, opts).await); + } - // If Arbitrum, apply chain specifics to converted block. - if is_arbitrum(self.env.read().evm_env.cfg_env.chain_id) { - // Set `l1BlockNumber` field. - block.other.insert("l1BlockNumber".to_string(), number.into()); + if let Some(fork) = self.get_fork() { + return Ok(fork.debug_trace_block_by_number(number, opts).await?); } - AnyRpcBlock::from(block) + Err(BlockchainError::BlockNotFound) } - /// Converts the `BlockNumber` into a numeric value - /// - /// # Errors - /// - /// returns an error if the requested number is larger than the current height - pub async fn ensure_block_number>( + async fn debug_trace_mined_block( &self, - block_id: Option, - ) -> Result { - let current = self.best_number(); - let requested = - match block_id.map(Into::into).unwrap_or(BlockId::Number(BlockNumber::Latest)) { - BlockId::Hash(hash) => { - self.block_by_hash(hash.block_hash) - .await? - .ok_or(BlockchainError::BlockNotFound)? - .header - .number + block: &Block, + opts: GethDebugTracingOptions, + ) -> Vec { + if block.body.transactions.is_empty() { + return Vec::new(); + } + + // A single transaction has no prefix replay to share. + if block.body.transactions.len() > 1 { + let traces = match &opts.tracer { + Some(GethDebugTracerType::BuiltInTracer( + GethDebugBuiltInTracerType::CallTracer, + )) => { + call_config_from_tracer_config(opts.tracer_config.clone()).ok().map(|config| { + self.replay_block_geth_traces( + block, + TracingInspectorConfig::from_geth_call_config(&config), + |inspector, gas_used, _| { + inspector.geth_builder().geth_call_traces(config, gas_used).into() + }, + ) + }) } - BlockId::Number(num) => match num { - BlockNumber::Latest | BlockNumber::Pending => current, - BlockNumber::Earliest => U64::ZERO.to::(), - BlockNumber::Number(num) => num, - BlockNumber::Safe => current.saturating_sub(self.slots_in_an_epoch), - BlockNumber::Finalized => current.saturating_sub(self.slots_in_an_epoch * 2), - }, + // Without steps tracing, struct logs come from the traces stored while mining. + None if self.enable_steps_tracing => Some(self.replay_block_geth_traces( + block, + TracingInspectorConfig::from_geth_config(&opts.config), + |inspector, gas_used, output| { + inspector.geth_builder().geth_traces(gas_used, output, opts.config).into() + }, + )), + _ => None, }; - - if requested > current { - Err(BlockchainError::BlockOutOfRange(current, requested)) - } else { - Ok(requested) + if let Some(Ok(traces)) = traces { + return traces; + } } - } - pub fn convert_block_number(&self, block: Option) -> u64 { - let current = self.best_number(); - match block.unwrap_or(BlockNumber::Latest) { - BlockNumber::Latest | BlockNumber::Pending => current, - BlockNumber::Earliest => 0, - BlockNumber::Number(num) => num, - BlockNumber::Safe => current.saturating_sub(self.slots_in_an_epoch), - BlockNumber::Finalized => current.saturating_sub(self.slots_in_an_epoch * 2), + // Preserve per-transaction errors and fork lookup when sequential replay is unavailable. + let mut traces = Vec::with_capacity(block.body.transactions.len()); + for tx in &block.body.transactions { + let tx_hash = tx.hash(); + traces.push(match self.debug_trace_transaction(tx_hash, opts.clone()).await { + Ok(result) => TraceResult::Success { result, tx_hash: Some(tx_hash) }, + Err(error) => { + TraceResult::Error { error: error.to_string(), tx_hash: Some(tx_hash) } + } + }); } + traces } - /// Helper function to execute a closure with the database at a specific block - pub async fn with_database_at( + /// Replays a local block once, keeping each transaction's committed state for the next call. + fn replay_block_geth_traces( &self, - block_request: Option, - f: F, - ) -> Result - where - F: FnOnce(Box, BlockEnv) -> T, - { - let block_number = match block_request { - Some(BlockRequest::Pending(pool_transactions)) => { - let result = self - .with_pending_block(pool_transactions, |state, block| { - let block = block.block; - let block = BlockEnv { - number: U256::from(block.header.number), - beneficiary: block.header.beneficiary, - timestamp: U256::from(block.header.timestamp), - difficulty: block.header.difficulty, - prevrandao: Some(block.header.mix_hash), - basefee: block.header.base_fee_per_gas.unwrap_or_default(), - gas_limit: block.header.gas_limit, - ..Default::default() - }; - f(state, block) - }) - .await; - return Ok(result); - } - Some(BlockRequest::Number(bn)) => Some(BlockNumber::Number(bn)), - None => None, + block: &Block, + tracing_config: TracingInspectorConfig, + build_trace: impl Fn(&TracingInspector, u64, Bytes) -> GethTrace, + ) -> Result, BlockchainError> { + let outcomes = { + let storage = self.blockchain.storage.read(); + let block_hash = block.header.hash_slow(); + block + .body + .transactions + .iter() + .enumerate() + .map(|(index, tx)| { + let hash = tx.hash(); + let mined = storage + .transactions + .get(&hash) + .ok_or(BlockchainError::TransactionNotFound)?; + if mined.block_hash != block_hash + || mined.info.transaction_index as usize != index + || mined.info.transaction_hash != hash + { + return Err(BlockchainError::TransactionNotFound); + } + Ok((mined.info.gas_used, mined.info.out.clone().unwrap_or_default())) + }) + .collect::, BlockchainError>>()? }; - let block_number = self.convert_block_number(block_number); - if block_number < self.env.read().evm_env.block_env.number.saturating_to() { - if let Some((block_hash, block)) = self - .block_by_number(BlockNumber::Number(block_number)) - .await? - .map(|block| (block.header.hash, block)) - && let Some(state) = self.states.write().get(&block_hash) + let trace = |parent_state: &StateDb| -> Result, BlockchainError> { + let (mut cache_db, evm_env, hardfork) = + self.prepare_block_replay(block, parent_state)?; + let monad_context = self.active_monad_context_for_mined_block(block)?; + let mut traces = Vec::with_capacity(block.body.transactions.len()); + for (index, (tx, (gas_used, output))) in + block.body.transactions.iter().zip(&outcomes).enumerate() { - let block = BlockEnv { - number: U256::from(block_number), - beneficiary: block.header.beneficiary, - timestamp: U256::from(block.header.timestamp), - difficulty: block.header.difficulty, - prevrandao: block.header.mix_hash, - basefee: block.header.base_fee_per_gas.unwrap_or_default(), - gas_limit: block.header.gas_limit, - ..Default::default() - }; - return Ok(f(Box::new(state), block)); + let mut inspector = TracingInspector::new(tracing_config); + let pending = self.pending_mined_transaction(tx.clone())?; + let transaction_context = monad_execution_context_at(monad_context.as_ref(), index); + let (result, _) = self.replay_envelope_with_inspector_ref_and_context( + &cache_db, + &evm_env, + &mut inspector, + &pending, + EnvelopeExecution::replay(transaction_context, hardfork), + )?; + traces.push(TraceResult::Success { + result: build_trace(&inspector, *gas_used, output.clone()), + tx_hash: Some(tx.hash()), + }); + cache_db.commit(result.state); } + Ok(traces) + }; - warn!(target: "backend", "Not historic state found for block={}", block_number); - return Err(BlockchainError::BlockOutOfRange( - self.env.read().evm_env.block_env.number.saturating_to(), - block_number, - )); + let read_guard = self.states.upgradable_read(); + if let Some(state) = read_guard.get_state(&block.header.parent_hash) { + trace(state) + } else { + let mut write_guard = RwLockUpgradableReadGuard::upgrade(read_guard); + let state = write_guard + .get_on_disk_state(&block.header.parent_hash) + .ok_or(BlockchainError::HistoricalStateUnavailable(block.header.number()))?; + trace(state) } - - let db = self.db.read().await; - let block = self.env.read().evm_env.block_env.clone(); - Ok(f(Box::new(&**db), block)) } - pub async fn storage_at( + fn geth_trace( &self, - address: Address, - index: U256, - block_request: Option, - ) -> Result { - self.with_database_at(block_request, |db, _| { - trace!(target: "backend", "get storage for {:?} at {:?}", address, index); - let val = db.storage_ref(address, index)?; - Ok(val.into()) - }) - .await? - } + tx: &MinedTransaction, + opts: GethDebugTracingOptions, + ) -> Result { + let GethDebugTracingOptions { config, tracer, tracer_config, .. } = opts; + + if let Some(tracer) = tracer { + match tracer { + GethDebugTracerType::BuiltInTracer(tracer) => match tracer { + GethDebugBuiltInTracerType::FourByteTracer => { + let inspector = FourByteInspector::default(); + let res = self.replay_tx_with_inspector( + tx.info.transaction_hash, + inspector, + |_, _, inspector, _, _| FourByteFrame::from(inspector).into(), + )?; + return Ok(res); + } + GethDebugBuiltInTracerType::CallTracer => { + return match call_config_from_tracer_config(tracer_config) { + Ok(call_config) => { + let inspector = TracingInspector::new( + TracingInspectorConfig::from_geth_call_config(&call_config), + ); + let frame = self.replay_tx_with_inspector( + tx.info.transaction_hash, + inspector, + |_, _, inspector, _, _| { + inspector + .geth_builder() + .geth_call_traces(call_config, tx.info.gas_used) + .into() + }, + )?; + Ok(frame) + } + Err(e) => Err(RpcError::invalid_params(e.to_string()).into()), + }; + } + GethDebugBuiltInTracerType::PreStateTracer => { + return match tracer_config.into_pre_state_config() { + Ok(pre_state_config) => { + let inspector = TracingInspector::new( + TracingInspectorConfig::from_geth_prestate_config( + &pre_state_config, + ), + ); + let frame = self.replay_tx_with_inspector( + tx.info.transaction_hash, + inspector, + |state, db, inspector, _, _| { + inspector.geth_builder().geth_prestate_traces( + &state, + &pre_state_config, + db, + ) + }, + )??; + Ok(frame.into()) + } + Err(e) => Err(RpcError::invalid_params(e.to_string()).into()), + }; + } + GethDebugBuiltInTracerType::NoopTracer + | GethDebugBuiltInTracerType::MuxTracer + | GethDebugBuiltInTracerType::Erc7562Tracer + | GethDebugBuiltInTracerType::FlatCallTracer + | GethDebugBuiltInTracerType::StateGasTracer => {} + }, + GethDebugTracerType::JsTracer(_code) => {} + } - /// Returns the code of the address - /// - /// If the code is not present and fork mode is enabled then this will try to fetch it from the - /// forked client - pub async fn get_code( - &self, - address: Address, - block_request: Option, - ) -> Result { - self.with_database_at(block_request, |db, _| self.get_code_with_state(&db, address)).await? - } + return Ok(NoopFrame::default().into()); + } - pub fn get_code_with_state( - &self, - state: &dyn DatabaseRef, - address: Address, - ) -> Result { - trace!(target: "backend", "get code for {:?}", address); - let account = state.basic_ref(address)?.unwrap_or_default(); - if account.code_hash == KECCAK_EMPTY { - // if the code hash is `KECCAK_EMPTY`, we check no further - return Ok(Default::default()); + // default structlog tracer + let return_value = tx.info.out.clone().unwrap_or_default(); + + // Steps are not recorded when mining because they would be kept for every transaction, so + // replay the transaction to record only what this request asks for. + if self.enable_steps_tracing { + let inspector = + TracingInspector::new(TracingInspectorConfig::from_geth_config(&config)); + return self.replay_tx_with_inspector( + tx.info.transaction_hash, + inspector, + |_, _, inspector, _, _| { + inspector + .geth_builder() + .geth_traces(tx.info.gas_used, return_value, config) + .into() + }, + ); } - let code = if let Some(code) = account.code { - code - } else { - state.code_by_hash_ref(account.code_hash)? - }; - Ok(code.bytes()[..code.len()].to_vec().into()) - } - /// Returns the balance of the address - /// - /// If the requested number predates the fork then this will fetch it from the endpoint - pub async fn get_balance( - &self, - address: Address, - block_request: Option, - ) -> Result { - self.with_database_at(block_request, |db, _| self.get_balance_with_state(db, address)) - .await? + Ok(GethTraceBuilder::new_borrowed(&tx.info.traces) + .geth_traces(tx.info.gas_used, return_value, config) + .into()) } - pub async fn get_account_at_block( + async fn mined_geth_trace_transaction( &self, - address: Address, - block_request: Option, - ) -> Result { - self.with_database_at(block_request, |block_db, _| { - let db = block_db.maybe_as_full_db().ok_or(BlockchainError::DataUnavailable)?; - let account = db.get(&address).cloned().unwrap_or_default(); - let storage_root = storage_root(&account.storage); - let code_hash = account.info.code_hash; - let balance = account.info.balance; - let nonce = account.info.nonce; - Ok(Account { balance, nonce, code_hash, storage_root }) - }) - .await? + hash: B256, + opts: GethDebugTracingOptions, + ) -> Option> { + self.blockchain.storage.read().transactions.get(&hash).map(|tx| self.geth_trace(tx, opts)) } - pub fn get_balance_with_state( + pub async fn transaction_receipt( &self, - state: D, - address: Address, - ) -> Result - where - D: DatabaseRef, - { - trace!(target: "backend", "get balance for {:?}", address); - Ok(state.basic_ref(address)?.unwrap_or_default().balance) - } + hash: B256, + ) -> Result, BlockchainError> { + if let Some(receipt) = self.mined_transaction_receipt(hash) { + return Ok(Some(receipt.inner)); + } - /// Returns the nonce of the address - /// - /// If the requested number predates the fork then this will fetch it from the endpoint - pub async fn get_nonce( - &self, - address: Address, - block_request: BlockRequest, - ) -> Result { - if let BlockRequest::Pending(pool_transactions) = &block_request - && let Some(value) = get_pool_transactions_nonce(pool_transactions, address) - { - return Ok(value); + if let Some(fork) = self.get_fork() { + let receipt = fork.transaction_receipt(hash).await?; + let number = self.convert_block_number( + receipt.clone().and_then(|r| r.block_number()).map(BlockNumber::from), + ); + + if fork.predates_fork_inclusive(number) { + return Ok(receipt); + } } - let final_block_request = match block_request { - BlockRequest::Pending(_) => BlockRequest::Number(self.best_number()), - BlockRequest::Number(bn) => BlockRequest::Number(bn), + + Ok(None) + } + + /// Returns all transaction receipts of the block + pub fn mined_block_receipts(&self, id: impl Into) -> Option> { + let storage = self.blockchain.storage.read(); + let hash = match id.into() { + BlockId::Hash(hash) => hash.block_hash, + BlockId::Number(number) => storage.hash(number, self.slots_in_an_epoch)?, }; + let block = storage.blocks.get(&hash)?.clone(); + + if block.body.transactions.iter().enumerate().any(|(index, transaction)| { + storage.transactions.get(&transaction.hash()).is_none_or(|transaction| { + transaction.block_hash != hash + || transaction.info.transaction_index as usize != index + }) + }) { + drop(storage); + return block + .body + .transactions + .into_iter() + .map(|transaction| { + self.mined_transaction_receipt(transaction.hash()).map(|receipt| receipt.inner) + }) + .collect(); + } - self.with_database_at(Some(final_block_request), |db, _| { - trace!(target: "backend", "get nonce for {:?}", address); - Ok(db.basic_ref(address)?.unwrap_or_default().nonce) - }) - .await? + let mut receipts = Vec::with_capacity(block.body.transactions.len()); + let mut next_log_index = 0; + + for block_transaction in &block.body.transactions { + let transaction = storage.transactions.get(&block_transaction.hash())?; + let log_count = transaction.receipt.logs().len(); + let receipt = self.build_mined_transaction_receipt( + &transaction.info, + transaction.receipt.clone(), + transaction.block_hash, + &block, + next_log_index, + ); + receipts.push(receipt.inner); + next_log_index += log_count; + } + + Some(receipts) } - /// Returns the traces for the given transaction - pub async fn trace_transaction( + /// Returns the transaction receipt for the given hash + pub(crate) fn mined_transaction_receipt( &self, hash: B256, - ) -> Result, BlockchainError> { - if let Some(traces) = self.mined_parity_trace_transaction(hash) { - return Ok(traces); - } - - if let Some(fork) = self.get_fork() { - return Ok(fork.trace_transaction(hash).await?); + ) -> Option> { + let storage = self.blockchain.storage.read(); + let transaction = storage.transactions.get(&hash)?; + + let index = transaction.info.transaction_index as usize; + let block = storage.blocks.get(&transaction.block_hash)?; + let mut next_log_index = 0; + for block_transaction in &block.body.transactions[..index] { + next_log_index += + storage.transactions.get(&block_transaction.hash())?.receipt.logs().len(); } - Ok(vec![]) + Some(self.build_mined_transaction_receipt( + &transaction.info, + transaction.receipt.clone(), + transaction.block_hash, + block, + next_log_index, + )) } - /// Returns the traces for the given transaction - pub(crate) fn mined_parity_trace_transaction( + fn build_mined_transaction_receipt( &self, - hash: B256, - ) -> Option> { - self.blockchain.storage.read().transactions.get(&hash).map(|tx| tx.parity_traces()) - } + info: &TransactionInfo, + tx_receipt: FoundryReceiptEnvelope, + block_hash: B256, + block: &Block, + next_log_index: usize, + ) -> MinedTransactionReceipt { + let transaction = block.body.transactions[info.transaction_index as usize].clone(); - /// Returns the traces for the given transaction - pub(crate) fn mined_transaction(&self, hash: B256) -> Option { - self.blockchain.storage.read().transactions.get(&hash).cloned() + // Cancun specific + let excess_blob_gas = block.header.excess_blob_gas(); + let blob_gas_used = transaction.blob_gas_used(); + let blob_gas_price = blob_gas_used + .map(|_| alloy_eips::eip4844::calc_blob_gasprice(excess_blob_gas.unwrap_or_default())); + + let effective_gas_price = transaction.effective_gas_price(block.header.base_fee_per_gas()); + + #[cfg(feature = "base")] + let eip8130_phase_statuses = tx_receipt.eip8130_phase_statuses().to_vec(); + let tx_receipt = tx_receipt.convert_logs_rpc( + BlockNumHash::new(block.header.number(), block_hash), + block.header.timestamp(), + info.transaction_hash, + info.transaction_index, + next_log_index, + ); + + let receipt = TransactionReceipt { + inner: tx_receipt, + transaction_hash: info.transaction_hash, + transaction_index: Some(info.transaction_index), + block_number: Some(block.header.number()), + gas_used: info.gas_used, + contract_address: info.contract_address, + effective_gas_price, + block_hash: Some(block_hash), + from: info.from, + to: info.to, + blob_gas_price, + blob_gas_used, + }; + + // Include timestamp in receipt to avoid extra block lookups (e.g., in Otterscan API) + let mut inner = FoundryTxReceipt::with_timestamp(receipt, block.header.timestamp()); + if let FoundryTxEnvelope::Celo(tx) = &*transaction { + inner + .0 + .other + .insert("feeCurrency".into(), serde_json::to_value(tx.tx().fee_currency).unwrap()); + } + if self.is_tempo() { + let fee_payer = match &*transaction { + FoundryTxEnvelope::Tempo(tx) => match tx.tx().recover_fee_payer(info.from) { + Ok(fee_payer) => fee_payer, + Err(error) => { + warn!( + target: "backend", + %error, + tx_hash = ?info.transaction_hash, + "failed to recover Tempo fee payer for mined receipt" + ); + info.from + } + }, + _ => info.from, + }; + inner = inner.with_fee_payer(fee_payer); + + // Match Tempo's receipt conversion: the final log of every non-free + // transaction is the fee token transfer to TIPFeeManager. + if inner.effective_gas_price() > 0 + && inner.gas_used() > 0 + && let Some(fee_token) = inner.0.inner.logs().last().map(|log| log.address()) + { + inner = inner.with_fee_token(fee_token); + } + } + #[cfg(feature = "base")] + if self.is_base() + && let FoundryTxEnvelope::Eip8130(tx) = transaction.as_ref() + { + let fields = Eip8130ReceiptFields { + payer: Some(tx.tx().payer.unwrap_or(info.from)), + phase_statuses: eip8130_phase_statuses, + metadata: (!tx.tx().metadata.is_empty()).then(|| tx.tx().metadata.clone()), + }; + inner + .0 + .other + .extend(OtherFields::try_from(fields).expect("EIP-8130 receipt fields serialize")); + } + MinedTransactionReceipt { inner, out: info.out.clone() } } - /// Returns the traces for the given block - pub(crate) fn mined_parity_trace_block( + /// Executes the pending block and returns its transaction receipts. + pub async fn pending_block_receipts( &self, - block: u64, - ) -> Option> { - let block = self.get_block(block)?; - let mut traces = vec![]; - let storage = self.blockchain.storage.read(); - for tx in block.transactions { - traces.extend(storage.transactions.get(&tx.hash())?.parity_traces()); + pool_transactions: Vec>>, + ) -> Result, BlockchainError> { + let BlockInfo { block, transactions, receipts } = + self.pending_block(pool_transactions).await?; + let block_hash = block.header.hash_slow(); + let mut pending_receipts = Vec::with_capacity(receipts.len()); + let mut next_log_index = 0; + + for (info, receipt) in transactions.iter().zip(receipts) { + let log_count = receipt.logs().len(); + let receipt = self.build_mined_transaction_receipt( + info, + receipt, + block_hash, + &block, + next_log_index, + ); + pending_receipts.push(receipt.inner); + next_log_index += log_count; } - Some(traces) + + Ok(pending_receipts) } - /// Returns the traces for the given transaction - pub async fn debug_trace_transaction( + /// Returns the blocks receipts for the given number + pub async fn block_receipts( &self, - hash: B256, - opts: GethDebugTracingOptions, - ) -> Result { - if let Some(trace) = self.mined_geth_trace_transaction(hash, opts.clone()) { - return trace; + number: BlockId, + ) -> Result>, BlockchainError> { + if let Some(receipts) = self.mined_block_receipts(number) { + return Ok(Some(receipts)); } if let Some(fork) = self.get_fork() { - return Ok(fork.debug_trace_transaction(hash, opts).await?); - } + let number = match self.ensure_block_number(Some(number)).await { + Err(_) => return Ok(None), + Ok(n) => n, + }; - Ok(GethTrace::Default(Default::default())) - } + if fork.predates_fork_inclusive(number) { + let receipts = fork.block_receipts(number).await?; - /// Returns code by its hash - pub async fn debug_code_by_hash( - &self, - code_hash: B256, - block_id: Option, - ) -> Result, BlockchainError> { - if let Some(fork) = self.get_fork() { - return Ok(fork.debug_code_by_hash(code_hash, block_id).await?); + return Ok(receipts); + } } Ok(None) } +} - fn mined_geth_trace_transaction( +impl> Backend { + /// Get the current state. + pub async fn serialized_state( &self, - hash: B256, - opts: GethDebugTracingOptions, - ) -> Option> { - self.blockchain.storage.read().transactions.get(&hash).map(|tx| tx.geth_trace(opts)) + preserve_historical_states: bool, + ) -> Result { + // Keep account state and head metadata coherent across mining and state replacement. + let _mining_guard = self.mining.lock().await; + let at = self.evm_env.read().block_env.clone(); + #[cfg(feature = "monad")] + let mut monad_block_participants = BTreeMap::new(); + #[cfg(feature = "monad")] + let mut monad_block_replay_profiles = BTreeMap::new(); + let (best_number, blocks, transactions) = { + let storage = self.blockchain.storage.read(); + #[cfg(feature = "monad")] + if self.is_monad() { + monad_block_participants = storage + .monad_block_participants + .iter() + .filter(|(hash, _)| storage.blocks.contains_key(*hash)) + .map(|(hash, participants)| (*hash, participants.iter().copied().collect())) + .collect(); + monad_block_replay_profiles = storage + .monad_block_replay_profiles + .iter() + .filter(|(hash, _)| storage.blocks.contains_key(*hash)) + .map(|(hash, profile)| (*hash, *profile)) + .collect(); + } + (storage.best_number, storage.serialized_blocks(), storage.serialized_transactions()) + }; + let historical_states = + preserve_historical_states.then(|| self.states.write().serialized_states()); + + let state = self + .db + .read() + .await + .dump_state(at, best_number, blocks, transactions, historical_states)? + .ok_or_else(|| { + BlockchainError::RpcError(RpcError::invalid_params( + "Dumping state not supported with the current configuration", + )) + })?; + #[cfg(feature = "monad")] + let state = { + let mut state = state; + state.monad_block_participants = monad_block_participants; + state.monad_block_replay_profiles = monad_block_replay_profiles; + state + }; + Ok(state) } - /// Returns the traces for the given block - pub async fn trace_block( + /// Write all chain data to serialized bytes buffer + pub async fn dump_state( &self, - block: BlockNumber, - ) -> Result, BlockchainError> { - let number = self.convert_block_number(Some(block)); - if let Some(traces) = self.mined_parity_trace_block(number) { - return Ok(traces); + preserve_historical_states: bool, + ) -> Result { + let state = self.serialized_state(preserve_historical_states).await?; + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder + .write_all(&serde_json::to_vec(&state).unwrap_or_default()) + .map_err(|_| BlockchainError::DataUnavailable)?; + Ok(encoder.finish().unwrap_or_default().into()) + } + + /// Apply [SerializableState] data to the backend storage. + pub async fn load_state(&self, mut state: SerializableState) -> Result { + let _mining_guard = self.mining.lock().await; + let mut block_env = state.block.take(); + let mut fork_block_boundary = None; + let mut selected_head = None; + let mut selected_header = None; + let mut checkpoint = None; + let fork_head = self.get_fork().map(|f| (f.block_number(), f.block_hash(), f.timestamp())); + if let Some(block) = &mut block_env { + if self.is_tempo() && self.is_fork() && block.beneficiary.is_zero() { + block.beneficiary = TIP_FEE_MANAGER_ADDRESS; + } + // Set the current best block number. + // Defaults to block number for compatibility with existing state files. + let best_number = state.best_block_number.unwrap_or(block.number.saturating_to()); + let (selected_best_number, selected_best_hash) = if let Some((number, hash, _)) = + fork_head + { + trace!(target: "backend", state_block_number=?best_number, fork_block_number=?number); + // If the state.block_number is greater than the fork block number, set best number + // to the state block number. + // Ref: https://github.com/foundry-rs/foundry/issues/9539 + if best_number > number { + (best_number, None) + } else { + // If loading state file on a fork, set best number to the fork block number. + // Ref: https://github.com/foundry-rs/foundry/pull/9215#issue-2618681838 + fork_block_boundary = Some(number); + (number, Some(hash)) + } + } else { + (best_number, None) + }; + + let best_hash = if let Some(hash) = selected_best_hash { + selected_header = state + .blocks + .iter() + .rev() + .find(|block| block.header.hash_slow() == hash) + .map(|block| block.header.clone()); + hash + } else if state.blocks.is_empty() { + let spec_id = self.spec_id(); + let is_cancun = spec_id >= SpecId::CANCUN; + let parent_hash = selected_best_number + .checked_sub(1) + .and_then(|number| self.blockchain.storage.read().hashes.get(&number).copied()) + .unwrap_or_default(); + let header = Header { + parent_hash, + beneficiary: block.beneficiary, + difficulty: block.difficulty, + number: selected_best_number, + gas_limit: block.gas_limit, + timestamp: block.timestamp.saturating_to(), + mix_hash: block.prevrandao.unwrap_or_default(), + base_fee_per_gas: (spec_id >= SpecId::LONDON).then_some(block.basefee), + parent_beacon_block_root: is_cancun.then_some(Default::default()), + blob_gas_used: is_cancun.then_some(0), + excess_blob_gas: if is_cancun { block.blob_excess_gas() } else { None }, + withdrawals_root: (spec_id >= SpecId::SHANGHAI).then_some(EMPTY_WITHDRAWALS), + requests_hash: (spec_id >= SpecId::PRAGUE).then_some(EMPTY_REQUESTS_HASH), + ..Default::default() + }; + let header = foundry_header(&self.networks, header); + let best_hash = header.hash_slow(); + selected_header = Some(header.clone()); + checkpoint = Some(create_block( + header, + Vec::>::new(), + )); + warn!( + target: "backend", + block_number = selected_best_number, + "state dump has no block history; created a synthetic checkpoint block" + ); + best_hash + } else if let Some(header) = state + .blocks + .iter() + .rev() + .find(|block| block.header.number() == selected_best_number) + .map(|block| block.header.clone()) + { + let best_hash = header.hash_slow(); + selected_header = Some(header); + best_hash + } else { + return Err(BlockchainError::RpcError(RpcError::internal_error_with(format!( + "Best hash not found for best number {selected_best_number}", + )))); + }; + + selected_head = Some((selected_best_number, best_hash)); } - if let Some(fork) = self.get_fork() - && fork.predates_fork(number) + // Stage the complete chain update first. Besides keeping blocks and transactions atomic for + // concurrent readers, this ensures validation failures cannot leave a partially loaded + // chain behind. + let blocks = std::mem::take(&mut state.blocks); + let transactions = std::mem::take(&mut state.transactions); + let mut storage = self.blockchain.storage.read().clone(); + storage.load_blocks(blocks, fork_block_boundary); + if !self.is_celo() + && storage.blocks.values().any(|block| { + block + .body + .transactions + .iter() + .any(|tx| matches!(tx.as_ref(), FoundryTxEnvelope::Celo(_))) + }) { - return Ok(fork.trace_block(number).await?); + self.ensure_cip64_active()?; + } + storage.load_transactions(transactions, fork_block_boundary); + if let Some(checkpoint) = checkpoint { + storage.insert_block(checkpoint); + } + if let Some((number, hash)) = selected_head { + storage.hashes.insert(number, hash); + storage.best_number = number; + storage.best_hash = hash; + storage.hashes.retain(|block_number, _| *block_number <= number); } - Ok(vec![]) - } + #[cfg(feature = "monad")] + if self.is_monad() { + for (hash, profile) in &state.monad_block_replay_profiles { + if storage.blocks.contains_key(hash) { + storage.monad_block_replay_profiles.insert(*hash, *profile); + } + } + for (hash, participants) in &state.monad_block_participants { + if storage.blocks.contains_key(hash) { + storage + .monad_block_participants + .insert(*hash, participants.iter().copied().collect()); + } + } + self.rebuild_monad_block_participant_cache(&mut storage)?; + // Reject state that cannot supply the ancestor metadata required by the next block + // before changing the live chain, EVM environment, or database. + self.monad_context_for_child_of_in_storage(&storage, storage.best_hash)?; + } - pub async fn transaction_receipt( - &self, - hash: B256, - ) -> Result, BlockchainError> { - if let Some(receipt) = self.mined_transaction_receipt(hash) { - return Ok(Some(receipt.inner)); + // Re-anchor block time to the canonical head selected above so the next blocks continue + // its timeline: the saved one when the loaded head stays canonical, the fork's when the + // state file is at or below the fork block. Resolve the timestamp from staged storage so a + // later validation or database failure cannot modify the live clock. + let canonical_timestamp = match fork_head { + Some((_, fork_hash, fork_timestamp)) if storage.best_hash == fork_hash => { + Some(fork_timestamp) + } + _ => storage.blocks.get(&storage.best_hash).map(|block| block.header.timestamp), + }; + + if let Some(block) = block_env.as_mut() { + // Keep NUMBER aligned with the canonical local head chosen above. Arbitrum state dumps + // can intentionally keep BlockEnv.number distinct from the best L2 block number. + if !is_arbitrum(self.protocol_chain_id()) + && let Some((number, _)) = selected_head + { + block.number = U256::from(number); + } } - if let Some(fork) = self.get_fork() { - let receipt = fork.transaction_receipt(hash).await?; - let number = self.convert_block_number( - receipt.clone().and_then(|r| r.block_number).map(BlockNumber::from), + let next_fees = selected_header.as_ref().map(|header| { + let parent_fees = self.fees.get_parent_header_fees(header); + let next_block_excess_blob_gas = self.networks.next_block_blob_excess_gas( + self.fees.blob_params(), + header.excess_blob_gas().unwrap_or_default(), + header.blob_gas_used().unwrap_or_default(), + header.base_fee_per_gas().unwrap_or_default(), + ); + let blob_excess_gas_and_price = BlobExcessGasAndPrice::new( + next_block_excess_blob_gas, + get_blob_base_fee_update_fraction( + self.evm_env.read().cfg_env.chain_id, + header.timestamp, + ), ); + (parent_fees, blob_excess_gas_and_price) + }); - if fork.predates_fork_inclusive(number) { - return Ok(receipt); + let historical_states = state.historical_states.take(); + let mut db = self.db.write().await; + let db_snapshot = db.snapshot_state(); + let load_result = (|| -> Result<(), BlockchainError> { + if !db.load_state(state)? { + return Err(RpcError::invalid_params( + "Loading state not supported with the current configuration", + ) + .into()); + } + // A pre-Denim dump has no BaseTime deployment, but Denim blocks still send its + // deposit. + #[cfg(feature = "base")] + if self.is_base() && self.base_upgrade() >= BaseUpgrade::Denim { + ensure_base_time_predeploy(&mut **db, false)?; } + Ok(()) + })(); + if let Err(err) = load_result { + db.revert_state(db_snapshot, RevertStateSnapshotAction::RevertRemove); + return Err(err); } + db.delete_state_snapshot(db_snapshot); + drop(db); + + // Backfill the EVM-level block hash cache from the freshly loaded blocks so that the + // BLOCKHASH opcode stays consistent after loading state. Reuses the hashes already + // computed by `load_blocks` above. Only collect the last 256 blocks since that's all + // BLOCKHASH can access. + let block_hashes = { + let min_block = storage.best_number.saturating_sub(256); + storage + .hashes + .iter() + .filter(|(num, _)| (min_block..=storage.best_number).contains(*num)) + .map(|(&num, &hash)| (U256::from(num), hash)) + .collect() + }; - Ok(None) - } + *self.blockchain.storage.write() = storage; + if let Some(timestamp) = canonical_timestamp { + self.time.reset(timestamp); + } + if let Some(block_env) = block_env { + self.evm_env.write().block_env = block_env; + } + if let Some((parent_fees, blob_excess_gas_and_price)) = next_fees { + #[cfg(feature = "optimism")] + if self.is_optimism() { + self.fees.set_optimism_base_fee_rules(&parent_fees.extra_data); + } + self.fees.set_base_fee(parent_fees.base_fee); + self.fees.set_blob_excess_gas_and_price(blob_excess_gas_and_price); + } - // Returns the traces matching a given filter - pub async fn trace_filter( - &self, - filter: TraceFilter, - ) -> Result, BlockchainError> { - let matcher = filter.matcher(); - let start = filter.from_block.unwrap_or(0); - let end = filter.to_block.unwrap_or_else(|| self.best_number()); + self.db.write().await.set_block_hashes(block_hashes); - if start > end { - return Err(BlockchainError::RpcError(RpcError::invalid_params( - "invalid block range, ensure that to block is greater than from block".to_string(), - ))); + if let Some(historical_states) = historical_states { + self.states.write().load_states(historical_states); } - let dist = end - start; - if dist > 300 { - return Err(BlockchainError::RpcError(RpcError::invalid_params( - "block range too large, currently limited to 300".to_string(), - ))); - } + Ok(true) + } - // Accumulate tasks for block range - let mut trace_tasks = vec![]; - for num in start..=end { - trace_tasks.push(self.trace_block(num.into())); + /// Deserialize and add all chain data to the backend storage + pub async fn load_state_bytes(&self, buf: Bytes) -> Result { + let orig_buf = &buf.0[..]; + let mut decoder = GzDecoder::new(orig_buf); + let mut decoded_data = Vec::new(); + + let state: SerializableState = serde_json::from_slice(if decoder.header().is_some() { + decoder + .read_to_end(decoded_data.as_mut()) + .map_err(|_| BlockchainError::FailedToDecodeStateDump)?; + &decoded_data + } else { + &buf.0 + }) + .map_err(|_| BlockchainError::FailedToDecodeStateDump)?; + + self.load_state(state).await + } +} + +impl Backend { + /// Simulates a bundle of signed transactions and returns Flashbots-compatible results. + pub async fn call_bundle( + &self, + bundle: EthCallBundle, + transactions: Vec>, + block_request: Option>, + ) -> Result { + let EthCallBundle { + block_number, + coinbase, + timestamp, + gas_limit, + difficulty, + base_fee, + .. + } = bundle; + + let blob_gas_used = transactions + .iter() + .filter_map(|transaction| transaction.transaction.blob_gas_used()) + .sum::(); + let max_blob_gas = self.blob_params().max_blob_gas_per_block(); + if blob_gas_used > max_blob_gas { + return Err(BlockchainError::RpcError(RpcError::invalid_params(format!( + "blob gas usage exceeds the limit of {max_blob_gas} gas per block." + )))); } - // Execute tasks and filter traces - let traces = futures::future::try_join_all(trace_tasks).await?; - let filtered_traces = - traces.into_iter().flatten().filter(|trace| matcher.matches(&trace.trace)); + self.with_database_at_and_context( + block_request, + |state, mut block_env, mut monad_context| { + let state_block_number = block_env.number.to::(); + block_env.number = U256::from(block_number); + block_env.timestamp = timestamp + .map(U256::from) + .unwrap_or_else(|| block_env.timestamp.saturating_add(U256::from(12))); + if let Some(coinbase) = coinbase { + block_env.beneficiary = coinbase; + } + if let Some(gas_limit) = gas_limit { + block_env.gas_limit = gas_limit; + } + if let Some(difficulty) = difficulty { + block_env.difficulty = difficulty; + } + if let Some(base_fee) = base_fee { + block_env.basefee = base_fee.try_into().unwrap_or(u64::MAX); + } - // Apply after and count - let filtered_traces: Vec<_> = if let Some(after) = filter.after { - filtered_traces.skip(after as usize).collect() - } else { - filtered_traces.collect() - }; + let mut evm_env = self.evm_env.read().clone(); + evm_env.block_env = block_env; + let coinbase = evm_env.block_env.beneficiary; + let base_fee = evm_env.block_env.basefee; + let mut cache_db = CacheDB::new(state); + let initial_coinbase = revm::DatabaseRef::basic_ref(&cache_db, coinbase)? + .map(|account| account.balance) + .unwrap_or_default(); + let mut coinbase_balance_before_tx = initial_coinbase; + let mut coinbase_balance_after_tx = initial_coinbase; + let mut total_gas_used = 0u64; + let mut total_gas_fees = U256::ZERO; + let mut bundle_hash = alloy_primitives::Keccak256::new(); + let mut results = Vec::with_capacity(transactions.len()); + + for transaction in transactions { + let sender = *transaction.sender(); + let tx = transaction.transaction.as_ref(); + let tx_hash = tx.hash(); + bundle_hash.update(tx_hash); - let filtered_traces: Vec<_> = if let Some(count) = filter.count { - filtered_traces.into_iter().take(count as usize).collect() - } else { - filtered_traces - }; + let mut inspector = self.build_inspector(); + let (ResultAndState { result, state }, _) = self + .transact_envelope_with_inspector_ref_and_context( + &cache_db, + &evm_env, + &mut inspector, + &transaction, + monad_context.as_mut().map(next_monad_context), + )?; + + let gas_price = tx.effective_tip_per_gas(base_fee).unwrap_or_default(); + let gas_used = result.tx_gas_used(); + let gas_fees = U256::from(gas_used) * U256::from(gas_price); + total_gas_used += gas_used; + total_gas_fees += gas_fees; + + coinbase_balance_after_tx = state + .get(&coinbase) + .map(|account| account.info.balance) + .unwrap_or(coinbase_balance_before_tx); + let coinbase_diff = + coinbase_balance_after_tx.saturating_sub(coinbase_balance_before_tx); + let eth_sent_to_coinbase = coinbase_diff.saturating_sub(gas_fees); + coinbase_balance_before_tx = coinbase_balance_after_tx; + + let output = result.output().cloned().unwrap_or_default(); + let (value, revert) = if result.is_success() { + (Some(output), None) + } else { + (None, Some(output)) + }; + + results.push(EthCallBundleTransactionResult { + coinbase_diff, + eth_sent_to_coinbase, + from_address: sender, + gas_fees, + gas_price: U256::from(gas_price), + gas_used, + to_address: tx.to(), + tx_hash, + value, + revert, + }); + cache_db.commit(state); + } - Ok(filtered_traces) + let coinbase_diff = coinbase_balance_after_tx.saturating_sub(initial_coinbase); + let eth_sent_to_coinbase = coinbase_diff.saturating_sub(total_gas_fees); + let bundle_gas_price = + coinbase_diff.checked_div(U256::from(total_gas_used)).unwrap_or_default(); + + Ok(EthCallBundleResponse { + bundle_hash: bundle_hash.finalize(), + bundle_gas_price, + coinbase_diff, + eth_sent_to_coinbase, + gas_fees: total_gas_fees, + results, + state_block_number, + total_gas_used, + }) + }, + ) + .await } - /// Returns all receipts of the block - pub fn mined_receipts(&self, hash: B256) -> Option> { - let block = self.mined_block_by_hash(hash)?; - let mut receipts = Vec::new(); - let storage = self.blockchain.storage.read(); - for tx in block.transactions.hashes() { - let receipt = storage.transactions.get(&tx)?.receipt.clone(); - receipts.push(receipt); + /// Executes bundles of call requests and returns each call output. + pub async fn call_many( + &self, + bundles: Vec>>, + block_request: Option>, + state_override: Option, + ) -> Result>, BlockchainError> { + if bundles.is_empty() { + return Err(BlockchainError::RpcError(RpcError::invalid_params( + "bundles are empty.".to_string(), + ))); } - Some(receipts) - } - - /// Returns all transaction receipts of the block - pub fn mined_block_receipts(&self, id: impl Into) -> Option> { - let mut receipts = Vec::new(); - let block = self.get_block(id)?; - for transaction in block.transactions { - let receipt = self.mined_transaction_receipt(transaction.hash())?; - receipts.push(receipt.inner); - } + self.with_database_at_and_context( + block_request, + |state, mut block_env, mut monad_context| { + let mut cache_db = CacheDB::new(state); + if let Some(state_override) = state_override { + apply_state_overrides(state_override, &mut cache_db)?; + } - Some(receipts) - } + let mut results = Vec::with_capacity(bundles.len()); + for bundle in bundles { + let Bundle { transactions, block_override } = bundle; + if let Some(block_override) = block_override { + cache_db.apply_block_overrides(block_override, &mut block_env); + } - /// Returns the transaction receipt for the given hash - pub(crate) fn mined_transaction_receipt(&self, hash: B256) -> Option { - let MinedTransaction { info, receipt: tx_receipt, block_hash, .. } = - self.blockchain.get_transaction_by_hash(&hash)?; + let mut bundle_results = Vec::with_capacity(transactions.len()); + for request in transactions { + let fee_details = FeeDetails::new( + request.gas_price, + request.max_fee_per_gas, + request.max_priority_fee_per_gas, + request.max_fee_per_blob_gas, + )? + .or_zero_fees(); + let PreparedCall { evm_env, mut tx_env, simulated_tempo_tx, .. } = self + .prepare_call_env(&cache_db, request, fee_details, block_env.clone())?; + apply_tempo_envelope_identity(&mut tx_env, simulated_tempo_tx.as_ref()); - let index = info.transaction_index as usize; - let block = self.blockchain.get_block_by_hash(&block_hash)?; - let transaction = block.transactions[index].clone(); + let mut inspector = self.build_inspector(); + let ResultAndState { result, state } = self + .transact_call_with_inspector_ref( + &cache_db, + &evm_env, + &mut inspector, + tx_env, + monad_context.as_mut().map(next_monad_context), + )?; - // Cancun specific - let excess_blob_gas = block.header.excess_blob_gas; - let blob_gas_price = - alloy_eips::eip4844::calc_blob_gasprice(excess_blob_gas.unwrap_or_default()); - let blob_gas_used = transaction.blob_gas(); - - let effective_gas_price = match transaction.transaction { - TypedTransaction::Legacy(t) => t.tx().gas_price, - TypedTransaction::EIP2930(t) => t.tx().gas_price, - TypedTransaction::EIP1559(t) => block - .header - .base_fee_per_gas - .map_or(self.base_fee() as u128, |g| g as u128) - .saturating_add(t.tx().max_priority_fee_per_gas), - TypedTransaction::EIP4844(t) => block - .header - .base_fee_per_gas - .map_or(self.base_fee() as u128, |g| g as u128) - .saturating_add(t.tx().tx().max_priority_fee_per_gas), - TypedTransaction::EIP7702(t) => block - .header - .base_fee_per_gas - .map_or(self.base_fee() as u128, |g| g as u128) - .saturating_add(t.tx().max_priority_fee_per_gas), - TypedTransaction::Deposit(_) => 0_u128, - }; + let output = result.output().cloned().unwrap_or_default(); + let response = if result.is_success() { + EthCallResponse { value: Some(output), error: None } + } else { + let error = RevertDecoder::new() + .maybe_decode(&output, None) + .unwrap_or_else(|| "execution failed".to_string()); + EthCallResponse { value: None, error: Some(error) } + }; - let receipts = self.get_receipts(block.transactions.iter().map(|tx| tx.hash())); - let next_log_index = receipts[..index].iter().map(|r| r.logs().len()).sum::(); + cache_db.commit(state); + bundle_results.push(response); + } - let receipt = tx_receipt.as_receipt_with_bloom().receipt.clone(); - let receipt = Receipt { - status: receipt.status, - cumulative_gas_used: receipt.cumulative_gas_used, - logs: receipt - .logs - .into_iter() - .enumerate() - .map(|(index, log)| alloy_rpc_types::Log { - inner: log, - block_hash: Some(block_hash), - block_number: Some(block.header.number), - block_timestamp: Some(block.header.timestamp), - transaction_hash: Some(info.transaction_hash), - transaction_index: Some(info.transaction_index), - log_index: Some((next_log_index + index) as u64), - removed: false, - }) - .collect(), - }; - let receipt_with_bloom = - ReceiptWithBloom { receipt, logs_bloom: tx_receipt.as_receipt_with_bloom().logs_bloom }; - - let inner = match tx_receipt { - TypedReceipt::EIP1559(_) => TypedReceipt::EIP1559(receipt_with_bloom), - TypedReceipt::Legacy(_) => TypedReceipt::Legacy(receipt_with_bloom), - TypedReceipt::EIP2930(_) => TypedReceipt::EIP2930(receipt_with_bloom), - TypedReceipt::EIP4844(_) => TypedReceipt::EIP4844(receipt_with_bloom), - TypedReceipt::EIP7702(_) => TypedReceipt::EIP7702(receipt_with_bloom), - TypedReceipt::Deposit(r) => TypedReceipt::Deposit(DepositReceipt { - inner: receipt_with_bloom, - deposit_nonce: r.deposit_nonce, - deposit_receipt_version: r.deposit_receipt_version, - }), - }; - - let inner = TransactionReceipt { - inner, - transaction_hash: info.transaction_hash, - transaction_index: Some(info.transaction_index), - block_number: Some(block.header.number), - gas_used: info.gas_used, - contract_address: info.contract_address, - effective_gas_price, - block_hash: Some(block_hash), - from: info.from, - to: info.to, - blob_gas_price: Some(blob_gas_price), - blob_gas_used, - }; + results.push(bundle_results); + block_env.number = block_env.number.saturating_add(U256::ONE); + block_env.timestamp = block_env.timestamp.saturating_add(U256::ONE); + #[cfg(feature = "monad")] + self::monad::advance_block_context(&mut monad_context); + } - Some(MinedTransactionReceipt { inner, out: info.out.map(|o| o.0.into()) }) + Ok(results) + }, + ) + .await } - /// Returns the blocks receipts for the given number - pub async fn block_receipts( + /// Simulates the payload by executing the calls in request. + pub async fn simulate( &self, - number: BlockId, - ) -> Result>, BlockchainError> { - if let Some(receipts) = self.mined_block_receipts(number) { - return Ok(Some(receipts)); - } + request: SimulatePayload, + block_request: Option>, + block_interval: u64, + ) -> Result>, BlockchainError> { + self.simulate_raw( + preserve_simulation_request_fields(request), + block_request, + block_interval, + ) + .await + } - if let Some(fork) = self.get_fork() { - let number = match self.ensure_block_number(Some(number)).await { - Err(_) => return Ok(None), - Ok(n) => n, + /// Simulates a payload while preserving transaction extension fields. + pub(crate) async fn simulate_raw( + &self, + request: SimulatePayload>, + block_request: Option>, + block_interval: u64, + ) -> Result>, BlockchainError> { + let simulate_at = |state: Box, + base_block_env: BlockEnv, + base_number, + base_timestamp, + base_hash, + base_fee, + mut monad_context: Option, + base_base_fee_per_gas, + base_excess_blob_gas, + base_blob_gas_used, + base_fee_extra_data: Bytes, + optimism_jovian: bool| { + let SimulatePayload { + block_state_calls, + trace_transfers, + validation, + return_full_transactions, + } = request; + let block_state_calls = sanitize_simulation_blocks( + block_state_calls, + base_number, + base_timestamp, + block_interval, + )?; + let mut cache_db = BalDatabase::new(CacheDB::new(state)); + cache_db.cache.block_hashes.insert(U256::from(base_number), base_hash); + let mut block_res = Vec::with_capacity(block_state_calls.len()); + // A single block cannot amortize building an incremental trie. + let cache_state_roots = block_state_calls.len() > 1; + let mut state_root_cache = None::<(StateRootCache, AddressMap)>; + let mut parent_hash = base_hash; + let mut next_base_fee = base_fee; + let mut inherited_block_env = base_block_env; + let (is_merge, is_cancun, is_amsterdam, tx_gas_limit_cap) = { + let cfg_env = &self.evm_env.read().cfg_env; + ( + cfg_env.spec >= SpecId::MERGE, + cfg_env.spec >= SpecId::CANCUN, + cfg_env.spec >= SpecId::AMSTERDAM, + cfg_env.tx_gas_limit_cap(), + ) }; + let mut parent_base_fee_per_gas = base_base_fee_per_gas; + let mut parent_excess_blob_gas = base_excess_blob_gas; + let mut parent_blob_gas_used = base_blob_gas_used; + let mut rpc_gas_budget = SIMULATE_GAS_CAP; - if fork.predates_fork_inclusive(number) { - let receipts = fork.block_receipts(number).await?; + // execute the blocks + for block in block_state_calls { + let SimBlock { block_overrides, state_overrides, calls } = block; + let mut block_env = inherited_block_env.clone(); + let overridden_beacon_root = + block_overrides.as_ref().and_then(|overrides| overrides.beacon_root); + let block_timestamp = block_overrides + .as_ref() + .and_then(|overrides| overrides.time) + .unwrap_or_else(|| block_env.timestamp.saturating_to()); + let blob_params = self.simulation_blob_params_at_timestamp(block_timestamp); + if is_cancun { + let excess_blob_gas = self.networks.next_block_blob_excess_gas( + blob_params, + parent_excess_blob_gas, + parent_blob_gas_used, + parent_base_fee_per_gas, + ); + block_env.set_blob_excess_gas_and_price( + excess_blob_gas, + blob_params.update_fraction as u64, + ); + } else { + block_env.blob_excess_gas_and_price = None; + } + block_env.basefee = if validation { next_base_fee } else { 0 }; + block_env.prevrandao = Some(B256::ZERO); + if is_merge && !is_arbitrum(self.protocol_chain_id()) { + block_env.difficulty = U256::ZERO; + } + let mut call_res = Vec::with_capacity(calls.len()); + let mut log_index = 0; + let mut cumulative_gas_used = 0; + let mut block_regular_gas_used = 0; + let mut block_state_gas_used = 0; + let mut block_blob_gas_used = 0u64; + let mut transactions = Vec::with_capacity(calls.len()); + let mut transaction_envelopes = Vec::with_capacity(calls.len()); + let mut receipts = Vec::with_capacity(calls.len()); + let overridden_block_hashes = block_overrides + .as_ref() + .and_then(|overrides| overrides.block_hash.as_ref()) + .map(|overrides| { + overrides + .keys() + .map(|number| { + let number = U256::from(*number); + (number, cache_db.cache.block_hashes.get(&number).copied()) + }) + .collect::>() + }) + .unwrap_or_default(); - return Ok(receipts); - } - } + if let Some(block_overrides) = block_overrides { + cache_db.apply_block_overrides(block_overrides, &mut block_env); + } + // Simulation overrides and RPC results use L2 numbers, while Arbitrum + // execution reads the corresponding L1 number through NUMBER. + let rpc_block_number = block_env.number.saturating_to(); + block_env.number = self.evm_block_number(rpc_block_number); + let simulation_evm_env = + EvmEnv::new(self.evm_env.read().cfg_env.clone(), block_env.clone()); + let spec_id = *simulation_evm_env.spec_id(); + let ethereum_transitions = self + .ethereum_block_transitions(self.hardfork(), None, BlockExecutionKind::Complete) + .map(|mut transitions| { + transitions.parent_beacon_block_root = (transitions.hardfork + >= EthereumHardfork::Cancun) + .then_some(overridden_beacon_root.unwrap_or_default()); + transitions + }); + let precompile_overrides = self.simulation_precompile_overrides( + state_overrides.as_ref(), + &simulation_evm_env, + )?; + + // Apply state overrides after validating precompile moves against this block's + // active precompile set. + if let Some(mut state_overrides) = state_overrides { + state_overrides.retain(|_, account| { + account.balance.is_some() + || account.nonce.is_some() + || account.code.is_some() + || account.state.is_some() + || account + .state_diff + .as_ref() + .is_some_and(|state_diff| !state_diff.is_empty()) + }); + let previously_deleted = previously_deleted_accounts( + &cache_db.cache.accounts, + state_overrides.keys().copied(), + ); + apply_state_overrides(state_overrides, &mut cache_db.db)?; + preserve_deleted_storage(&mut cache_db.cache.accounts, previously_deleted); + } - Ok(None) - } + // Overrides define the starting state, so only execution contributes BAL writes. + if ethereum_transitions + .is_some_and(|transitions| transitions.hardfork >= EthereumHardfork::Amsterdam) + { + cache_db.bal_state = BalState::new().with_bal_builder(); + } - pub async fn transaction_by_block_number_and_index( - &self, - number: BlockNumber, - index: Index, - ) -> Result, BlockchainError> { - if let Some(block) = self.mined_block_by_number(number) { - return Ok(self.mined_transaction_by_block_hash_and_index(block.header.hash, index)); - } + if let Some(transitions) = ethereum_transitions { + self.apply_simulation_pre_execution_changes( + &mut cache_db, + &simulation_evm_env, + parent_hash, + transitions, + )?; + } - if let Some(fork) = self.get_fork() { - let number = self.convert_block_number(Some(number)); - if fork.predates_fork(number) { - return Ok(fork - .transaction_by_block_number_and_index(number, index.into()) - .await?); - } - } + cache_db.bump_bal_index(); - Ok(None) - } + // execute all calls in that block + for (req_idx, mut request) in calls.into_iter().enumerate() { + #[cfg(feature = "base")] + if request.transaction_type == Some(EIP8130_TRANSACTION_TYPE) { + return Err(BlockchainError::InvalidTransactionRequest( + "eth_simulateV1 does not accept EIP-8130 transaction requests" + .to_string(), + )); + } - pub async fn transaction_by_block_hash_and_index( - &self, - hash: B256, - index: Index, - ) -> Result, BlockchainError> { - if let tx @ Some(_) = self.mined_transaction_by_block_hash_and_index(hash, index) { - return Ok(tx); - } + let classified_request = self.parse_transaction_request(request.clone())?; + #[cfg(feature = "base")] + if classified_request.is_base() { + return Err(BlockchainError::InvalidTransactionRequest( + "eth_simulateV1 does not accept EIP-8130 transaction requests" + .to_string(), + )); + } + let is_ethereum_request = classified_request.is_ethereum(); + let mut parsed_request = self.is_tempo().then_some(classified_request); + if is_ethereum_request { + request.populate_blob_hashes(); + let preferred_type = request.preferred_type(); + request.transaction_type = Some(preferred_type as u8); + request.trim_conflicting_keys(); + request.populate_blob_hashes(); + } + let request_blob_gas_used = if is_ethereum_request && !optimism_jovian { + u64::try_from(request.blob_versioned_hashes.as_ref().map_or(0, Vec::len)) + .unwrap_or(u64::MAX) + .saturating_mul(DATA_GAS_PER_BLOB) + } else { + 0 + }; + let max_blob_gas = block_blob_gas_limit( + optimism_jovian, + block_env.gas_limit, + blob_params.max_blob_gas_per_block(), + ); + if !optimism_jovian + && block_blob_gas_used.saturating_add(request_blob_gas_used) > max_blob_gas + { + return Err(BlockchainError::RpcError(RpcError::invalid_params(format!( + "blob gas usage exceeds the limit of {max_blob_gas} gas per block." + )))); + } + block_blob_gas_used = block_blob_gas_used.saturating_add(request_blob_gas_used); + + let inner = request.as_ref(); + let remaining_regular_gas = + block_env.gas_limit.saturating_sub(block_regular_gas_used); + let remaining_state_gas = + block_env.gas_limit.saturating_sub(block_state_gas_used); + let remaining_gas = if is_amsterdam { + remaining_regular_gas.min(remaining_state_gas) + } else { + block_env.gas_limit.saturating_sub(cumulative_gas_used) + }; + let requested_gas = inner.gas.unwrap_or(remaining_gas); + let exceeds_gas_limit = if is_amsterdam { + let requested_regular_gas = requested_gas.min(tx_gas_limit_cap); + requested_regular_gas > remaining_regular_gas + || requested_gas > remaining_state_gas + } else { + requested_gas > remaining_gas + }; + if exceeds_gas_limit { + return Err(BlockchainError::RpcError(RpcError { + code: ErrorCode::ServerError(-38015), + message: format!( + "block gas limit exceeded: remaining {remaining_gas}, requested {requested_gas}" + ) + .into(), + data: None, + })); + } + let execution_gas_limit = requested_gas.min(rpc_gas_budget); + let preserve_signed_gas = matches!( + &parsed_request, + Some(FoundryTransactionRequest::Tempo(request)) + if request.fee_payer_signature.is_some() + ); + request.gas = Some(execution_gas_limit); + if !preserve_signed_gas && let Some(parsed_request) = &mut parsed_request { + parsed_request.as_mut().gas = Some(execution_gas_limit); + } - if let Some(fork) = self.get_fork() { - return Ok(fork.transaction_by_block_hash_and_index(hash, index.into()).await?); - } + let caller = request.from.unwrap_or_default(); + let caller_nonce = RevmDatabase::basic(&mut cache_db.db, caller)? + .map(|account| account.nonce) + .unwrap_or_default(); + let tempo_nonce_key = + parsed_request.as_ref().and_then(|request| match request { + FoundryTransactionRequest::Tempo(request) => request.nonce_key, + _ => None, + }); + if request.nonce.is_none() { + let nonce = tempo_nonce_key.map_or(Ok(caller_nonce), |nonce_key| { + tempo_nonce(&cache_db.db, caller, nonce_key) + })?; + request.nonce = Some(nonce); + if let Some(parsed_request) = &mut parsed_request { + parsed_request.as_mut().nonce = Some(nonce); + } + } - Ok(None) - } + if is_ethereum_request { + let mut canonical_request = + FoundryTransactionRequest::Ethereum(request.inner.clone()); + canonical_request.prep_for_submission(); + request.inner = canonical_request.as_ref().clone(); + if let Some(parsed_request) = &mut parsed_request { + *parsed_request = canonical_request; + } + } - pub fn mined_transaction_by_block_hash_and_index( - &self, - block_hash: B256, - index: Index, - ) -> Option { - let (info, block, tx) = { - let storage = self.blockchain.storage.read(); - let block = storage.blocks.get(&block_hash).cloned()?; - let index: usize = index.into(); - let tx = block.transactions.get(index)?.clone(); - let info = storage.transactions.get(&tx.hash())?.info.clone(); - (info, block, tx) - }; + let fee_details = FeeDetails::new( + request.gas_price, + request.max_fee_per_gas, + request.max_priority_fee_per_gas, + request.max_fee_per_blob_gas, + )? + .or_zero_fees(); - Some(transaction_build( - Some(info.transaction_hash), - tx, - Some(&block), - Some(info), - block.header.base_fee_per_gas, - )) - } + let PreparedCall { + mut evm_env, + mut tx_env, + simulated_tempo_tx, + #[cfg(feature = "base")] + simulated_envelope, + } = if let Some(parsed_request) = parsed_request { + self.prepare_typed_call_env( + &cache_db.db, + parsed_request, + fee_details, + block_env.clone(), + )? + } else { + self.prepare_call_env( + &cache_db.db, + request.clone(), + fee_details, + block_env.clone(), + )? + }; + tx_env.base_mut().gas_limit = execution_gas_limit; + apply_tempo_envelope_identity(&mut tx_env, simulated_tempo_tx.as_ref()); + if !validation + && tempo_nonce_key.is_none_or(|key| key.is_zero()) + && request.nonce == Some(u64::MAX) + { + tx_env.base_mut().nonce = 0; + } + let uses_protocol_call_nonce = tx_env.uses_protocol_call_nonce(); + #[cfg(feature = "base")] + let simulated_envelope = simulated_envelope + .or_else(|| simulated_tempo_tx.map(FoundryTxEnvelope::Tempo)); + #[cfg(not(feature = "base"))] + let simulated_envelope = simulated_tempo_tx.map(FoundryTxEnvelope::Tempo); + + if is_amsterdam { + // Ensure simulated Amsterdam calls use EIP-8037's split gas schedule. + let spec = evm_env.cfg_env.spec; + evm_env.cfg_env.set_spec_and_mainnet_gas_params(spec); + } - pub async fn transaction_by_hash( - &self, - hash: B256, - ) -> Result, BlockchainError> { - trace!(target: "backend", "transaction_by_hash={:?}", hash); - if let tx @ Some(_) = self.mined_transaction_by_hash(hash) { - return Ok(tx); - } + // Always disable EIP-3607 + evm_env.cfg_env.disable_eip3607 = true; + + // Simulated blocks keep their own base and blob base fees, which are what + // BASEFEE and BLOBBASEFEE read and what validation checks fees against. + evm_env.block_env.basefee = block_env.basefee; + evm_env.block_env.blob_excess_gas_and_price = + block_env.blob_excess_gas_and_price; + // Without validation, calls run at any price against the simulated block's + // fees. + evm_env.cfg_env.disable_base_fee = !validation; + if validation { + evm_env.cfg_env.disable_nonce_check = false; + evm_env.cfg_env.disable_block_gas_limit = false; + } - if let Some(fork) = self.get_fork() { - return fork - .transaction_by_hash(hash) - .await - .map_err(BlockchainError::AlloyForkProvider); - } + let mut inspector = self.build_inspector(); - Ok(None) - } + // transact + inspector = inspector.with_simulation_logs(trace_transfers); + trace!(target: "backend", env=?evm_env, spec=?evm_env.spec_id(),"simulate evm env"); + let execution_result = match tx_env { + CallTxEnv::Eth(tx_env) + if !validation + && tx_env.tx_type == 3 + && tx_env.max_fee_per_blob_gas == 0 => + { + self.transact_eth_simulation_with_inspector_ref( + &cache_db.db, + &evm_env, + &mut inspector, + tx_env, + &precompile_overrides, + ) + } + tx_env if precompile_overrides.moves.is_empty() => self + .transact_call_with_inspector_ref( + &cache_db.db, + &evm_env, + &mut inspector, + tx_env, + monad_context.as_mut().map(next_monad_context), + ), + tx_env => self.transact_eth_with_inspector_ref_and_precompile_overrides( + &cache_db.db, + &evm_env, + &mut inspector, + tx_env.into_base(), + &precompile_overrides, + ), + }; + let ResultAndState { result, mut state } = match execution_result { + Err(BlockchainError::InvalidTransaction(error)) => { + return Err(simulate_transaction_error(error)); + } + result => result?, + }; + if !validation + && caller_nonce == u64::MAX + && uses_protocol_call_nonce + && let Some(account) = state.get_mut(&caller) + { + account.info.nonce = 0; + } + trace!(target: "backend", ?result, ?request, "simulate call"); - pub fn mined_transaction_by_hash(&self, hash: B256) -> Option { - let (info, block) = { - let storage = self.blockchain.storage.read(); - let MinedTransaction { info, block_hash, .. } = - storage.transactions.get(&hash)?.clone(); - let block = storage.blocks.get(&block_hash).cloned()?; - (info, block) - }; - let tx = block.transactions.get(info.transaction_index as usize)?.clone(); + let canonical_logs = result.clone().into_logs(); + let (response_logs, attempted_log_count) = inspector + .take_simulation_logs(&canonical_logs, result.is_success()) + .expect("simulation log collector is installed"); + inspector.print_logs(); + if self.print_traces { + inspector.into_print_traces(self.call_trace_decoder()); + } - Some(transaction_build( - Some(info.transaction_hash), - tx, - Some(&block), - Some(info), - block.header.base_fee_per_gas, - )) - } + // REVM turns a previously deleted account into `Touched` when a later call + // recreates it without storage. Preserve the cleared-storage provenance so + // subsequent calls and the recursively merged state cannot reload old slots. + let previously_deleted = previously_deleted_accounts( + &cache_db.cache.accounts, + state.keys().copied(), + ); + + rpc_gas_budget = rpc_gas_budget.saturating_sub(result.tx_gas_used()); + cumulative_gas_used = cumulative_gas_used.saturating_add(result.tx_gas_used()); + block_regular_gas_used = block_regular_gas_used + .saturating_add(result.gas().block_regular_gas_used()); + block_state_gas_used = + block_state_gas_used.saturating_add(result.gas().block_state_gas_used()); + + // create the transaction from a request + let from = caller; + request.sidecar = None; + let tx = if let Some(envelope) = simulated_envelope { + MaybeImpersonatedTransaction::impersonated(envelope, from) + } else { + if request.to.is_none() { + request.to = Some(TxKind::Create); + } + let mut request = self.parse_transaction_request(request)?; + request.prep_for_submission(); + let typed_tx = request.build_unsigned().map_err(|e| { + BlockchainError::InvalidTransactionRequest(e.to_string()) + })?; + MaybeImpersonatedTransaction::impersonated( + typed_tx.into_impersonated(), + from, + ) + }; + let tx_hash = tx.as_ref().hash(); + #[cfg(feature = "optimism")] + if optimism_jovian { + let tx_blob_gas = crate::eth::backend::executor::optimism::blob_gas_used( + &mut cache_db.db, + tx.as_ref(), + true, + ) + .map_err(|err| BlockchainError::Internal(err.to_string()))?; + if block_blob_gas_used.saturating_add(tx_blob_gas) > max_blob_gas { + return Err(BlockchainError::RpcError(RpcError::invalid_params( + format!( + "blob gas usage exceeds the limit of {max_blob_gas} gas per block." + ), + ))); + } + block_blob_gas_used = block_blob_gas_used.saturating_add(tx_blob_gas); + } + + // Commit after calculating the footprint so the scalar comes from pre-tx + // state, matching the upstream OP block executor. + cache_db.commit(state); + cache_db.bump_bal_index(); + preserve_deleted_storage(&mut cache_db.cache.accounts, previously_deleted); + #[cfg(any(feature = "base", feature = "optimism"))] + let receipt = if tx.as_ref().is_deposit() { + crate::eth::backend::executor::optimism::build_simulated_deposit_receipt( + self.hardfork(), + caller_nonce, + &result, + canonical_logs.clone(), + cumulative_gas_used, + ) + } else { + FoundryReceiptBuilder::build_simulated_receipt( + tx.as_ref().tx_type(), + &result, + canonical_logs.clone(), + cumulative_gas_used, + ) + }; + #[cfg(not(any(feature = "base", feature = "optimism")))] + let receipt = FoundryReceiptBuilder::build_simulated_receipt( + tx.as_ref().tx_type(), + &result, + canonical_logs.clone(), + cumulative_gas_used, + ); + receipts.push(receipt); + transaction_envelopes.push(tx.as_ref().clone()); + let rpc_tx = + transaction_build(Some(tx_hash), tx, None, None, Some(block_env.basefee)); + transactions.push(rpc_tx); + + let return_data = if result.is_success() { + result.output().cloned().unwrap_or_default() + } else { + Bytes::new() + }; + let sim_res = SimCallResult { + return_data, + gas_used: result.tx_gas_used(), + max_used_gas: Some( + result.gas().total_gas_spent().max(result.gas().floor_gas()), + ), + status: result.is_success(), + error: match &result { + ExecutionResult::Success { .. } => None, + ExecutionResult::Revert { output, .. } => { + let message = RevertDecoder::new() + .maybe_decode(output, None) + .map(|reason| format!("execution reverted: {reason}")) + .unwrap_or_else(|| "execution reverted".to_string()); + Some(SimulateError { + code: SimulateError::EXECUTION_REVERTED_CODE, + message, + data: Some(output.clone()), + }) + } + ExecutionResult::Halt { reason, .. } => Some(SimulateError { + code: SimulateError::VM_EXECUTION_ERROR_CODE, + message: if matches!(reason, HaltReason::OutOfGas(_)) { + "out of gas".to_string() + } else { + format!("vm execution error: {reason}") + }, + data: None, + }), + }, + logs: response_logs + .into_iter() + .map(|(idx, log)| Log { + inner: log, + block_number: Some(rpc_block_number), + block_timestamp: Some(block_env.timestamp.saturating_to()), + transaction_index: Some(req_idx as u64), + log_index: Some(idx + log_index), + removed: false, - pub fn get_blob_by_tx_hash(&self, hash: B256) -> Result>> { - // Try to get the mined transaction by hash - if let Some(tx) = self.mined_transaction_by_hash(hash) - && let Ok(typed_tx) = TypedTransaction::try_from(tx) - && let Some(sidecar) = typed_tx.sidecar() - { - return Ok(Some(sidecar.sidecar.blobs.clone())); - } + block_hash: None, + transaction_hash: Some(tx_hash), + }) + .collect(), + }; + log_index += attempted_log_count; + call_res.push(sim_res); + } - Ok(None) - } + for (number, hash) in overridden_block_hashes { + if let Some(hash) = hash { + cache_db.cache.block_hashes.insert(number, hash); + } else { + cache_db.cache.block_hashes.remove(&number); + } + } - pub fn get_blob_by_versioned_hash(&self, hash: B256) -> Result> { - let storage = self.blockchain.storage.read(); - for block in storage.blocks.values() { - for tx in &block.transactions { - let typed_tx = tx.as_ref(); - if let Some(sidecar) = typed_tx.sidecar() { - for versioned_hash in sidecar.sidecar.versioned_hashes() { - if versioned_hash == hash - && let Some(index) = - sidecar.sidecar.commitments.iter().position(|commitment| { - kzg_to_versioned_hash(commitment.as_slice()) == *hash + let gas_used = if is_amsterdam { + block_regular_gas_used.max(block_state_gas_used) + } else { + cumulative_gas_used + }; + let requests = if let Some(transitions) = ethereum_transitions { + self.apply_simulation_post_execution_changes( + &mut cache_db, + &simulation_evm_env, + transitions, + &receipts, + )? + } else { + Default::default() + }; + + // Fork databases are partial, so their synthetic blocks use a zero state root. + let incremental = state_root_cache.as_mut().is_some_and(|(cache, previous)| { + cache.record_overlay(&cache_db.cache.accounts, previous) + }); + let state_root = if incremental { + let (cache, previous) = state_root_cache.as_mut().unwrap(); + let root = cache.root(&cache_db.cache.accounts); + previous.clone_from(&cache_db.cache.accounts); + root + } else { + cache_db + .maybe_full_db() + .map(|accounts| { + // A deleted base account can retain storage written by + // anvil_setStorageAt. A later touch exposes it in the full merge, + // although it has no trie leaf. Keep the full rebuild in that case. + if cache_state_roots + && accounts.values().all(|account| { + account.account_state != AccountState::NotExisting + || account.storage.is_empty() }) - && let Some(blob) = sidecar.sidecar.blobs.get(index) - { - return Ok(Some(*blob)); - } + { + let (cache, previous) = + state_root_cache.get_or_insert_with(Default::default); + let root = cache.root(&accounts); + previous.clone_from(&cache_db.cache.accounts); + root + } else { + state_root_cache = None; + state_root(&accounts) + } + }) + .unwrap_or_default() + }; + let block_access_list_hash = cache_db + .bal_state + .take_built_alloy_bal() + .map(|bal| compute_block_access_list_hash(bal.as_slice())); + let header = Header { + block_access_list_hash, + logs_bloom: receipts.iter().fold(Bloom::ZERO, |mut bloom, receipt| { + bloom.accrue_bloom(receipt.logs_bloom()); + bloom + }), + transactions_root: calculate_transaction_root(&transaction_envelopes), + receipts_root: calculate_receipt_root(&receipts), + parent_hash, + beneficiary: block_env.beneficiary, + state_root, + difficulty: block_env.difficulty, + number: rpc_block_number, + gas_limit: block_env.gas_limit, + gas_used, + timestamp: block_env.timestamp.saturating_to(), + extra_data: base_fee_extra_data.clone(), + mix_hash: block_env.prevrandao.unwrap_or_default(), + nonce: Default::default(), + base_fee_per_gas: (spec_id >= SpecId::LONDON).then_some(block_env.basefee), + withdrawals_root: (spec_id >= SpecId::SHANGHAI).then_some(EMPTY_WITHDRAWALS), + blob_gas_used: is_cancun.then_some(block_blob_gas_used), + excess_blob_gas: if is_cancun { block_env.blob_excess_gas() } else { None }, + parent_beacon_block_root: ethereum_transitions.and_then(|transitions| { + (transitions.hardfork >= EthereumHardfork::Cancun) + .then_some(transitions.parent_beacon_block_root.unwrap_or_default()) + }), + requests_hash: ethereum_transitions.and_then(|transitions| { + (transitions.hardfork >= EthereumHardfork::Prague) + .then(|| requests.requests_hash()) + }), + ..Default::default() + }; + let block_hash = header.hash_slow(); + let withdrawals = (spec_id >= SpecId::SHANGHAI).then_some(Default::default()); + let size = U256::from( + BlockBody { + transactions: transaction_envelopes, + ommers: vec![], + withdrawals: withdrawals.clone(), } + .into_block(header.clone()) + .length(), + ); + for (transaction_index, transaction) in transactions.iter_mut().enumerate() { + transaction.block_hash = Some(block_hash); + transaction.block_number = Some(header.number); + transaction.transaction_index = Some(transaction_index as u64); + transaction.block_timestamp = Some(header.timestamp); } - } - } - Ok(None) - } + let mut block = alloy_rpc_types::Block { + header: AnyRpcHeader { + hash: block_hash, + inner: header.into(), + total_difficulty: None, + size: Some(size), + }, + uncles: vec![], + transactions: BlockTransactions::Full(transactions), + withdrawals, + }; - /// Prove an account's existence or nonexistence in the state trie. - /// - /// Returns a merkle proof of the account's trie node, `account_key` == keccak(address) - pub async fn prove_account_at( - &self, - address: Address, - keys: Vec, - block_request: Option, - ) -> Result { - let block_number = block_request.as_ref().map(|r| r.block_number()); + if !return_full_transactions { + block.transactions.convert_to_hashes(); + } - self.with_database_at(block_request, |block_db, _| { - trace!(target: "backend", "get proof for {:?} at {:?}", address, block_number); - let db = block_db.maybe_as_full_db().ok_or(BlockchainError::DataUnavailable)?; - let account = db.get(&address).cloned().unwrap_or_default(); + for res in &mut call_res { + res.logs.iter_mut().for_each(|log| { + log.block_hash = Some(block.header.hash); + }); + } - let mut builder = HashBuilder::default() - .with_proof_retainer(ProofRetainer::new(vec![Nibbles::unpack(keccak256(address))])); + let mut block = AnyRpcBlock::new(WithOtherFields::new(block)); + if is_arbitrum(self.protocol_chain_id()) { + block + .other + .insert("l1BlockNumber".to_string(), serde_json::json!(block_env.number)); + } + let simulated_block = SimulatedBlock { inner: block, calls: call_res }; + + parent_hash = block_hash; + cache_db.cache.block_hashes.insert(U256::from(rpc_block_number), block_hash); + inherited_block_env.beneficiary = block_env.beneficiary; + inherited_block_env.difficulty = block_env.difficulty; + inherited_block_env.gas_limit = block_env.gas_limit; + // Route through the fee manager so Tempo chains use their own base fee rules. + let header = &simulated_block.inner.header; + next_base_fee = self.fees.calculate_next_block_base_fee_from_header(&header.inner); + parent_base_fee_per_gas = header.base_fee_per_gas().unwrap_or_default(); + parent_excess_blob_gas = header.excess_blob_gas().unwrap_or_default(); + parent_blob_gas_used = header.blob_gas_used().unwrap_or_default(); - for (key, account) in trie_accounts(db) { - builder.add_leaf(key, &account); + block_res.push(simulated_block); + #[cfg(feature = "monad")] + self::monad::advance_block_context(&mut monad_context); } - let _ = builder.root(); - - let proof = builder - .take_proof_nodes() - .into_nodes_sorted() - .into_iter() - .map(|(_, v)| v) - .collect(); - let storage_proofs = prove_storage(&account.storage, &keys); + Ok(block_res) + }; - let account_proof = AccountProof { - address, - balance: account.info.balance, - nonce: account.info.nonce, - code_hash: account.info.code_hash, - storage_hash: storage_root(&account.storage), - account_proof: proof, - storage_proof: keys - .into_iter() - .zip(storage_proofs) - .map(|(key, proof)| { - let storage_key: U256 = key.into(); - let value = account.storage.get(&storage_key).copied().unwrap_or_default(); - StorageProof { key: JsonStorageKey::Hash(key), value, proof } - }) - .collect(), - }; + match block_request { + Some(BlockRequest::Pending(pool_transactions)) => { + self.with_pending_block(pool_transactions, |state, block| { + let header = &block.block.header; + let parent_fees = self.fees.calculate_parent_header_fees(header); + let optimism_jovian = + self.is_optimism_jovian_at_header(header, parent_fees.optimism_jovian); + let monad_context = self.active_monad_context_before_mined_transaction( + &block.block, + block.block.body.transactions.len(), + )?; + #[cfg(feature = "monad")] + let monad_context = { + let mut monad_context = monad_context; + self::monad::advance_block_context(&mut monad_context); + monad_context + }; + simulate_at( + state, + self.block_env_from_header(header), + header.number(), + header.timestamp(), + header.hash_slow(), + parent_fees.base_fee, + monad_context, + header.base_fee_per_gas().unwrap_or_default(), + header.excess_blob_gas().unwrap_or_default(), + header.blob_gas_used().unwrap_or_default(), + parent_fees.extra_data, + optimism_jovian, + ) + }) + .await? + } + block_request => { + let base_block_number = match block_request.as_ref() { + Some(BlockRequest::Number(number)) => BlockNumber::Number(*number), + Some(BlockRequest::Pending(_)) => unreachable!(), + None => BlockNumber::Latest, + }; + let base_block = self + .block_by_number(base_block_number) + .await? + .ok_or(BlockchainError::BlockNotFound)?; + let base_number = base_block.header.number(); + let base_timestamp = base_block.header.timestamp(); + let base_hash = base_block.header.hash; + let parent_fees = self.fees.calculate_parent_header_fees(&base_block.header.inner); + let optimism_jovian = self.is_optimism_jovian_at_header( + &base_block.header.inner, + parent_fees.optimism_jovian, + ); + + #[cfg(feature = "monad")] + let monad_context = if self.is_monad() { + Some(self.monad_context_for_child_of_block_number(base_number).await?) + } else { + None + }; + #[cfg(not(feature = "monad"))] + let monad_context = None; + + self.with_database_at(block_request, |state, block_env| { + simulate_at( + state, + block_env, + base_number, + base_timestamp, + base_hash, + parent_fees.base_fee, + monad_context, + base_block.header.base_fee_per_gas().unwrap_or_default(), + base_block.header.excess_blob_gas().unwrap_or_default(), + base_block.header.blob_gas_used().unwrap_or_default(), + parent_fees.extra_data, + optimism_jovian, + ) + }) + .await? + } + } + } - Ok(account_proof) + pub fn get_blob_by_tx_hash(&self, hash: B256) -> Result>> { + let storage = self.blockchain.storage.read(); + Ok(storage.transactions.get(&hash).and_then(|mined| { + storage + .blocks + .get(&mined.block_hash)? + .body + .transactions + .get(mined.info.transaction_index as usize)? + .as_ref() + .sidecar() + .map(|sidecar| sidecar.sidecar.blobs().to_vec()) + })) + } + + /// Sets the fee token for a user address (Tempo-only). + pub async fn set_fee_token(&self, user: Address, token: Address) -> DatabaseResult<()> { + self.with_tempo_storage(|| { + let mut fee_manager = TipFeeManager::new(); + fee_manager + .set_user_token(user, IFeeManager::setUserTokenCall { token }) + .map_err(tempo_db_err) }) - .await? + .await } - /// Returns a new block event stream - pub fn new_block_notifications(&self) -> NewBlockNotifications { - let (tx, rx) = unbounded(); - self.new_block_listeners.lock().push(tx); - trace!(target: "backed", "added new block listener"); - rx + /// Sets the fee token for a validator address (Tempo-only). + pub async fn set_validator_fee_token( + &self, + validator: Address, + token: Address, + ) -> DatabaseResult<()> { + self.with_tempo_storage(|| { + let mut fee_manager = TipFeeManager::new(); + // Use Address::ZERO as beneficiary so the check `sender != beneficiary` passes + fee_manager + .set_validator_token( + validator, + IFeeManager::setValidatorTokenCall { token }, + Address::ZERO, + ) + .map_err(tempo_db_err) + }) + .await } - /// Notifies all `new_block_listeners` about the new block - fn notify_on_new_block(&self, header: Header, hash: B256) { - // cleanup closed notification streams first, if the channel is closed we can remove the - // sender half for the set - self.new_block_listeners.lock().retain(|tx| !tx.is_closed()); - - let notification = NewBlockNotification { hash, header: Arc::new(header) }; - - self.new_block_listeners - .lock() - .retain(|tx| tx.unbounded_send(notification.clone()).is_ok()); + /// Mints FeeAMM liquidity for a token pair (Tempo-only). + pub async fn set_fee_amm_liquidity( + &self, + user_token: Address, + validator_token: Address, + amount: U256, + ) -> DatabaseResult<()> { + // T3+ rejects minting to the zero address. + let admin = Address::repeat_byte(0x11); + self.with_tempo_storage(|| { + // Mint the required tokens to admin so it can provide liquidity. + // grant_role_internal bypasses the caller check, matching genesis seeding. + for &token_address in &[user_token, validator_token] { + let mut token = TIP20Token::from_address(token_address).map_err(tempo_db_err)?; + token + .grant_role_internal(admin, TIP20Token::issuer_role()) + .map_err(tempo_db_err)?; + token.mint(admin, ITIP20::mintCall { to: admin, amount }).map_err(tempo_db_err)?; + } + let mut fee_manager = TipFeeManager::new(); + fee_manager + .mint(admin, user_token, validator_token, amount, admin) + .map_err(tempo_db_err)?; + Ok(()) + }) + .await } - /// Reorg the chain to a common height and execute blocks to build new chain. - /// - /// The state of the chain is rewound using `rewind` to the common block, including the db, - /// storage, and env. - /// - /// Finally, `do_mine_block` is called to create the new chain. - pub async fn reorg( + /// Sets an account's balance for a deployed TIP-20 token (Tempo-only). + pub async fn set_tip20_balance( &self, - depth: u64, - tx_pairs: HashMap>>, - common_block: Block, - ) -> Result<(), BlockchainError> { - self.rollback(common_block).await?; - // Create the new reorged chain, filling the blocks with transactions if supplied - for i in 0..depth { - let to_be_mined = tx_pairs.get(&i).cloned().unwrap_or_else(Vec::new); - let outcome = self.do_mine_block(to_be_mined).await; - node_info!( - " Mined reorg block number {}. With {} valid txs and with invalid {} txs", - outcome.block_number, - outcome.included.len(), - outcome.invalid.len() - ); + address: Address, + token_address: Address, + balance: U256, + ) -> DatabaseResult<()> { + if self.try_set_tip20_balance(address, token_address, balance).await? { + return Ok(()); } - Ok(()) + Err(tempo_db_err(format!("address {token_address} is not a deployed TIP-20 token"))) } - /// Rollback the chain to a common height. - /// - /// The state of the chain is rewound using `rewind` to the common block, including the db, - /// storage, and env. - pub async fn rollback(&self, common_block: Block) -> Result<(), BlockchainError> { - // Get the database at the common block - let common_state = { - let mut state = self.states.write(); - let state_db = state - .get(&common_block.header.hash_slow()) - .ok_or(BlockchainError::DataUnavailable)?; - let db_full = state_db.maybe_as_full_db().ok_or(BlockchainError::DataUnavailable)?; - db_full.clone() - }; - - { - // Set state to common state - self.db.write().await.clear(); - for (address, acc) in common_state { - for (key, value) in acc.storage { - self.db.write().await.set_storage_at(address, key.into(), value.into())?; - } - self.db.write().await.insert_account(address, acc.info); + /// Sets an account's balance if the address is a deployed TIP-20 token (Tempo-only). + pub async fn try_set_tip20_balance( + &self, + address: Address, + token_address: Address, + balance: U256, + ) -> DatabaseResult { + self.with_tempo_storage(|| { + if !TIP20Factory::new().is_tip20(token_address).map_err(tempo_db_err)? { + return Ok(false); } - } - - { - // Unwind the storage back to the common ancestor - self.blockchain - .storage - .write() - .unwind_to(common_block.header.number, common_block.header.hash_slow()); - // Set environment back to common block - let mut env = self.env.write(); - env.evm_env.block_env.number = U256::from(common_block.header.number); - env.evm_env.block_env.timestamp = U256::from(common_block.header.timestamp); - env.evm_env.block_env.gas_limit = common_block.header.gas_limit; - env.evm_env.block_env.difficulty = common_block.header.difficulty; - env.evm_env.block_env.prevrandao = Some(common_block.header.mix_hash); + let mut token = TIP20Token::from_address(token_address).map_err(tempo_db_err)?; + token.balances[address].write(balance).map_err(tempo_db_err)?; + Ok(true) + }) + .await + } - self.time.reset(env.evm_env.block_env.timestamp.saturating_to()); - } - Ok(()) + /// Runs `f` inside a Tempo storage context initialized from the current state + /// (Tempo-only). + async fn with_tempo_storage(&self, f: impl FnOnce() -> R) -> R { + let hardfork = self.hardfork(); + // One consistent snapshot of the current env to build the storage context. + let (chain_id, timestamp, block_number) = { + let env = self.evm_env.read(); + (env.cfg_env.chain_id, env.block_env.timestamp, env.block_env.number.to::()) + }; + let mut db = self.db.write().await; + let mut storage = AnvilStorageProvider::new( + &mut **db, + chain_id, + timestamp, + block_number, + hardfork.into(), + ); + StorageCtx::enter(&mut storage, f) } } +/// Converts a Tempo error into an anvil [`DatabaseError`]. +fn tempo_db_err(e: E) -> DatabaseError { + DatabaseError::AnyRequest(Arc::new(eyre::eyre!("{e}"))) +} + /// Get max nonce from transaction pool by address. fn get_pool_transactions_nonce( - pool_transactions: &[Arc], + pool_transactions: &[Arc>], address: Address, ) -> Option { if let Some(highest_nonce) = pool_transactions .iter() - .filter(|tx| *tx.pending_transaction.sender() == address) + .filter(|tx| { + *tx.pending_transaction.sender() == address + && !tx.pending_transaction.transaction.as_ref().has_nonzero_tempo_nonce_key() + }) .map(|tx| tx.pending_transaction.nonce()) .max() { @@ -3150,159 +9902,471 @@ fn get_pool_transactions_nonce( None } +impl Backend +where + N: Network, +{ + /// Validates a transaction candidate selected for mining. + fn validate_mining_pool_transaction_for( + &self, + pool_tx: &PoolTransaction, + account: &AccountInfo, + evm_env: &EvmEnv, + ) -> Result<(), InvalidTransactionError> { + #[cfg(feature = "monad")] + if self.validate_monad_mining_pool_transaction_for(pool_tx, account, evm_env)? { + return Ok(()); + } + + self.validate_pool_transaction_for(&pool_tx.pending_transaction, account, evm_env) + } + + /// Runs Tempo's transaction-pool validation for `pending` against the latest state. + /// + /// This is the pipeline the node runs before admitting a transaction: it resolves the fee + /// token the way execution will, including a token inferred from the called TIP20 or DEX + /// swap and a preference the transaction itself sets, charges intrinsic gas, and checks the + /// fee payer's balance against the maximum fee with execution's rounding, all without + /// committing state. + async fn validate_tempo_pool_transaction( + &self, + pending: &PendingTransaction, + evm_env: &EvmEnv, + ) -> Result<(), BlockchainError> { + let tx = pending.transaction.as_ref(); + let tx_env: TempoTxEnv = + FromTxWithEncoded::from_encoded_tx(tx, *pending.sender(), tx.encoded_2718().into()); + let evm_env = evm_env.clone(); + self.with_database_at(None, move |state, _| { + let cache_db = CacheDB::new(state); + let mut inspector = self.build_inspector(); + let mut evm = TempoEvmFactory::default().create_evm_with_inspector( + WrapDatabaseRef(&cache_db), + Self::build_tempo_evm_env(&evm_env, self.tempo_hardfork()), + &mut inspector, + ); + self.inject_tempo_precompiles(&mut evm, &evm_env); + evm.configure_for_pool(); + let (result, _) = evm.validate_pool_transaction(tx_env); + result.map(drop).map_err(|err| match err { + // Tempo reports a fee token shortfall through the native funds error; name the + // token balance instead, since the sender holds no native balance to speak of. + EVMError::Transaction(TempoInvalidTransaction::EthInvalidTransaction( + InvalidTransaction::LackOfFundForMaxFee { fee, balance }, + )) => InvalidTransactionError::TempoInsufficientFeeTokenBalance { + balance: *balance, + required: *fee, + } + .into(), + err => err.into(), + }) + }) + .await? + } +} + #[async_trait::async_trait] -impl TransactionValidator for Backend { +impl TransactionValidator for Backend +where + N: Network, +{ async fn validate_pool_transaction( &self, - tx: &PendingTransaction, + tx: &PendingTransaction, ) -> Result<(), BlockchainError> { let address = *tx.sender(); let account = self.get_account(address).await?; - let env = self.next_env(); - Ok(self.validate_pool_transaction_for(tx, &account, &env)?) + #[cfg_attr(not(feature = "base"), allow(unused_mut))] + let mut evm_env = self.next_evm_env(&**self.db.read().await)?; + #[cfg(feature = "base")] + if tx.transaction.as_ref().is_eip8130() { + evm_env.block_env.timestamp = U256::from(self.eip8130_pool_timestamp()); + } + + #[cfg(feature = "base")] + if let FoundryTxEnvelope::Eip8130(signed) = tx.transaction.as_ref() { + let body = signed.tx(); + let now = self.eip8130_pool_timestamp_ms(); + signed + .validate_admission_static(self.chain_id().to()) + .map_err(|error| BlockchainError::Eip8130TransactionRejected(error.to_string()))?; + signed + .validate_timestamp(now) + .map_err(|error| BlockchainError::Eip8130TransactionRejected(error.to_string()))?; + + let (nonce_key_first_use, ready_for_execution) = + if body.nonce_key == Eip8130Constants::NONCE_KEY_MAX { + (false, true) + } else if body.nonce_key.is_zero() { + (account.nonce == 0, body.nonce_sequence == account.nonce) + } else { + let slot = NonceManagerStorage::nonce_slot(address, body.nonce_key).map_err( + |error| BlockchainError::Eip8130TransactionRejected(error.to_string()), + )?; + let word = self.storage_at(NonceManagerStorage::ADDRESS, slot, None).await?; + let channel_nonce = Eip8130Nonce::decode_channel_nonce(word.into()).to::(); + (channel_nonce == 0, body.nonce_sequence == channel_nonce) + }; + let intrinsic = IntrinsicGas::compute( + signed, + &signed.encoded_2718(), + &IntrinsicGasInput::worst_case( + nonce_key_first_use, + IntrinsicGasInput::sender_auto_delegated(&body.account_changes), + body.payer.is_some(), + ), + ) + .map_err(|error| BlockchainError::Eip8130TransactionRejected(error.to_string()))?; + if intrinsic.execution_gas_available(body.gas_limit).is_none() { + return Err(BlockchainError::Eip8130TransactionRejected( + "sender intrinsic gas exceeds transaction gas limit".to_string(), + )); + } + + let payer = body.payer.unwrap_or(address); + let payer_account = + if payer == address { account.clone() } else { self.get_account(payer).await? }; + FeeCheck::validate_balance( + payer_account.balance, + body.gas_limit, + intrinsic.payer_auth, + body.max_fee_per_gas, + ) + .map_err(|error| BlockchainError::Eip8130TransactionRejected(error.to_string()))?; + + // Run once against read-only state so authorization, account changes, replay checks, + // and payer rules fail at admission instead of after entering the pool. A buffered + // nonce is the sole expected inclusion error: Base's pool accepts it until the lane + // gap is filled, while every earlier authorize/apply step has still been validated. + let db = self.db.read().await; + let mut inspector = NoOpInspector; + let validation = self.transact_envelope_with_inspector_ref_and_context( + &**db, + &evm_env, + &mut inspector, + tx, + None, + ); + Eip8130PhaseStatuses::clear(); + match validation { + Ok(_) => {} + Err(BlockchainError::Eip8130TransactionRejected(reason)) + if !ready_for_execution && reason.contains("is ahead of the channel nonce") => { + } + Err(error) => return Err(error), + } + } + + // Tempo AA: validate time bounds (async checks) + if let FoundryTxEnvelope::Tempo(aa_tx) = tx.transaction.as_ref() { + let tempo_tx = aa_tx.tx(); + let current_time = evm_env.block_env.timestamp.saturating_to::(); + + // Reject if valid_before is expired or too close to current time (< 3 seconds) + const AA_VALID_BEFORE_MIN_SECS: u64 = 3; + if let Some(valid_before) = tempo_tx.valid_before.map(|v| v.get()) { + let min_allowed = current_time.saturating_add(AA_VALID_BEFORE_MIN_SECS); + if valid_before <= min_allowed { + return Err(InvalidTransactionError::TempoValidBeforeExpired { + valid_before, + min_allowed, + } + .into()); + } + + let hardfork = self.tempo_hardfork(); + if hardfork.is_t1() && tempo_tx.is_expiring_nonce_tx() { + let max_expiry_secs = hardfork.expiring_nonce_max_expiry_secs(); + let max_allowed = current_time.saturating_add(max_expiry_secs); + if valid_before > max_allowed { + return Err(InvalidTransactionError::TempoValidBeforeTooFar { + valid_before, + max_expiry_secs, + max_allowed, + } + .into()); + } + } + } + + // Reject if valid_after is too far in the future. Mirrors Tempo's default pool limit + // (`DEFAULT_AA_VALID_AFTER_MAX_SECS`), which is aligned with its queued transaction + // lifetime. + const AA_VALID_AFTER_MAX_SECS: u64 = 120; + if let Some(valid_after) = tempo_tx.valid_after.map(|v| v.get()) { + let max_allowed = current_time.saturating_add(AA_VALID_AFTER_MAX_SECS); + if valid_after > max_allowed { + return Err(InvalidTransactionError::TempoValidAfterTooFar { + valid_after, + max_valid_after_secs: AA_VALID_AFTER_MAX_SECS, + max_allowed, + } + .into()); + } + } + } + + self.validate_pool_transaction_for(tx, &account, &evm_env)?; + + // Tempo charges gas in a fee token for every transaction type, never in the native token, + // so the node's own pool validation replaces the native balance check skipped above. + if self.is_tempo() && !self.disable_pool_balance_checks { + self.validate_tempo_pool_transaction(tx, &evm_env).await?; + } + + Ok(()) } fn validate_pool_transaction_for( &self, - pending: &PendingTransaction, + pending: &PendingTransaction, account: &AccountInfo, - env: &Env, + evm_env: &EvmEnv, ) -> Result<(), InvalidTransactionError> { let tx = &pending.transaction; if let Some(tx_chain_id) = tx.chain_id() { let chain_id = self.chain_id(); if chain_id.to::() != tx_chain_id { - if let Some(legacy) = tx.as_legacy() { + if let FoundryTxEnvelope::Legacy(tx) = tx.as_ref() { // - if env.evm_env.cfg_env.spec >= SpecId::SPURIOUS_DRAGON - && legacy.tx().chain_id.is_none() - { - warn!(target: "backend", ?chain_id, ?tx_chain_id, "incompatible EIP155-based V"); + if evm_env.cfg_env.spec >= SpecId::SPURIOUS_DRAGON && tx.chain_id().is_none() { + debug!(target: "backend", ?chain_id, ?tx_chain_id, "incompatible EIP155-based V"); return Err(InvalidTransactionError::IncompatibleEIP155); } } else { - warn!(target: "backend", ?chain_id, ?tx_chain_id, "invalid chain id"); + debug!(target: "backend", ?chain_id, ?tx_chain_id, "invalid chain id"); return Err(InvalidTransactionError::InvalidChainId); } } } - if tx.gas_limit() < MIN_TRANSACTION_GAS as u64 { - warn!(target: "backend", "[{:?}] gas too low", tx.hash()); - return Err(InvalidTransactionError::GasTooLow); + // Reject native value transfers on Tempo networks + if self.is_tempo() && !tx.value().is_zero() { + warn!(target: "backend", "[{:?}] native value transfer not allowed in Tempo mode", tx.hash()); + return Err(InvalidTransactionError::TempoNativeValueTransfer); } - // Check gas limit, iff block gas limit is set. - if !env.evm_env.cfg_env.disable_block_gas_limit - && tx.gas_limit() > env.evm_env.block_env.gas_limit + // Tempo AA T5: cap authorization list size + if self.is_tempo_hardfork_active(TempoHardfork::T5) + && let FoundryTxEnvelope::Tempo(aa_tx) = tx.as_ref() { - warn!(target: "backend", "[{:?}] gas too high", tx.hash()); - return Err(InvalidTransactionError::GasTooHigh(ErrDetail { - detail: String::from("tx.gas_limit > env.block.gas_limit"), - })); + const MAX_TEMPO_AUTHORIZATIONS: usize = 16; + let auth_count = aa_tx.tx().tempo_authorization_list.len(); + if auth_count > MAX_TEMPO_AUTHORIZATIONS { + warn!(target: "backend", "[{:?}] Tempo tx has too many authorizations: {}", tx.hash(), auth_count); + return Err(InvalidTransactionError::TempoTooManyAuthorizations { + count: auth_count, + max: MAX_TEMPO_AUTHORIZATIONS, + }); + } } - // check nonce - let is_deposit_tx = - matches!(&pending.transaction.transaction, TypedTransaction::Deposit(_)); - let nonce = tx.nonce(); - if nonce < account.nonce && !is_deposit_tx { - warn!(target: "backend", "[{:?}] nonce too low", tx.hash()); - return Err(InvalidTransactionError::NonceTooLow); + #[cfg(feature = "base")] + if let FoundryTxEnvelope::Eip8130(signed) = pending.transaction.as_ref() { + signed + .validate_admission_static(evm_env.cfg_env.chain_id) + .map_err(|error| InvalidTransactionError::Eip8130(error.to_string()))?; + signed + .validate_timestamp( + evm_env.block_env.timestamp.saturating_to::().saturating_mul(1_000), + ) + .map_err(|error| InvalidTransactionError::Eip8130(error.to_string()))?; } - if env.evm_env.cfg_env.spec >= SpecId::LONDON { - if tx.gas_price() < env.evm_env.block_env.basefee.into() && !is_deposit_tx { - warn!(target: "backend", "max fee per gas={}, too low, block basefee={}",tx.gas_price(), env.evm_env.block_env.basefee); - return Err(InvalidTransactionError::FeeCapTooLow); - } - - if let (Some(max_priority_fee_per_gas), Some(max_fee_per_gas)) = - (tx.essentials().max_priority_fee_per_gas, tx.essentials().max_fee_per_gas) - && max_priority_fee_per_gas > max_fee_per_gas - { - warn!(target: "backend", "max priority fee per gas={}, too high, max fee per gas={}", max_priority_fee_per_gas, max_fee_per_gas); - return Err(InvalidTransactionError::TipAboveFeeCap); - } + // Nonce validation — skip for deposits (L1→L2), EIP-8130, and Tempo. + #[cfg(any(feature = "base", feature = "optimism"))] + let is_deposit_tx = pending.transaction.as_ref().is_deposit(); + #[cfg(not(any(feature = "base", feature = "optimism")))] + let is_deposit_tx = false; + #[cfg(feature = "base")] + let is_eip8130_tx = pending.transaction.as_ref().is_eip8130(); + #[cfg(not(feature = "base"))] + let is_eip8130_tx = false; + let is_tempo_tx = pending.transaction.as_ref().is_tempo(); + let nonce = tx.nonce(); + if nonce < account.nonce && !is_deposit_tx && !is_eip8130_tx && !is_tempo_tx { + debug!(target: "backend", "[{:?}] nonce too low", tx.hash()); + return Err(InvalidTransactionError::NonceTooLow); } - // EIP-4844 Cancun hard fork validation steps - if env.evm_env.cfg_env.spec >= SpecId::CANCUN && tx.transaction.is_eip4844() { - // Light checks first: see if the blob fee cap is too low. - if let Some(max_fee_per_blob_gas) = tx.essentials().max_fee_per_blob_gas - && let Some(blob_gas_and_price) = &env.evm_env.block_env.blob_excess_gas_and_price - && max_fee_per_blob_gas < blob_gas_and_price.blob_gasprice - { - warn!(target: "backend", "max fee per blob gas={}, too low, block blob gas price={}", max_fee_per_blob_gas, blob_gas_and_price.blob_gasprice); - return Err(InvalidTransactionError::BlobFeeCapTooLow); - } + #[cfg(feature = "monad")] + self.validate_monad_transaction_type(tx)?; + // EIP-4844 structural validation + if evm_env.cfg_env.spec >= SpecId::CANCUN && tx.is_eip4844() { // Heavy (blob validation) checks - let tx = match &tx.transaction { - TypedTransaction::EIP4844(tx) => tx.tx(), + let blob_tx = match tx.as_ref() { + FoundryTxEnvelope::Eip4844(tx) => tx.tx(), _ => unreachable!(), }; - let blob_count = tx.tx().blob_versioned_hashes.len(); + let blob_count = blob_tx.tx().blob_versioned_hashes.len(); // Ensure there are blob hashes. if blob_count == 0 { return Err(InvalidTransactionError::NoBlobHashes); } - // Ensure the tx does not exceed the max blobs per block. - let max_blob_count = self.blob_params().max_blob_count as usize; - if blob_count > max_blob_count { - return Err(InvalidTransactionError::TooManyBlobs(blob_count, max_blob_count)); + // Ensure the tx does not exceed the max blobs per transaction. + let max_blobs_per_tx = self.blob_params().max_blobs_per_tx as usize; + if blob_count > max_blobs_per_tx { + return Err(InvalidTransactionError::TooManyBlobs(blob_count, max_blobs_per_tx)); } // Check for any blob validation errors if not impersonating. if !self.skip_blob_validation(Some(*pending.sender())) - && let Err(err) = tx.validate(EnvKzgSettings::default().get()) + && let Err(err) = blob_tx.validate(EnvKzgSettings::default().get()) { return Err(InvalidTransactionError::BlobTransactionValidationError(err)); } } - let max_cost = tx.max_cost(); - let value = tx.value(); + // EIP-3860 initcode size validation, respects --code-size-limit / --disable-code-size-limit + if evm_env.cfg_env.spec >= SpecId::SHANGHAI && tx.kind().is_create() { + let max_initcode_size = evm_env + .cfg_env + .limit_contract_code_size + .map(|limit| limit.saturating_mul(2)) + .unwrap_or_else(|| self.max_initcode_size(evm_env)); + if tx.input().len() > max_initcode_size { + return Err(InvalidTransactionError::MaxInitCodeSizeExceeded); + } + } + + // Balance and fee related checks + if !self.disable_pool_balance_checks { + // Gas limit validation. Under EIP-2780 a transaction's intrinsic gas starts at a lower + // base, which execution then checks in full. + let min_gas = if evm_env.cfg_env.enable_amsterdam_eip2780 { + eip2780::TX_BASE_COST + } else { + MIN_TRANSACTION_GAS as u64 + }; + if tx.gas_limit() < min_gas { + debug!(target: "backend", "[{:?}] gas too low", tx.hash()); + return Err(InvalidTransactionError::GasTooLow); + } + + // Check tx gas limit against block gas limit, if block gas limit is set. + if !evm_env.cfg_env.disable_block_gas_limit + && tx.gas_limit() > evm_env.block_env.gas_limit + { + debug!(target: "backend", "[{:?}] gas too high", tx.hash()); + return Err(InvalidTransactionError::GasTooHigh(ErrDetail { + detail: String::from("tx.gas_limit > env.block.gas_limit"), + })); + } + + // Check tx gas limit against tx gas limit cap (Osaka hard fork and later). + if evm_env.cfg_env.tx_gas_limit_cap.is_none() + && tx.gas_limit() > self.tx_gas_limit_cap(evm_env) + { + debug!(target: "backend", "[{:?}] gas too high", tx.hash()); + return Err(InvalidTransactionError::GasTooHigh(ErrDetail { + detail: String::from("tx.gas_limit > resolved tx gas limit cap"), + })); + } + + // EIP-1559 fee validation (London hard fork and later). + if evm_env.cfg_env.spec >= SpecId::LONDON { + if tx.max_fee_per_gas() < evm_env.block_env.basefee.into() && !is_deposit_tx { + debug!(target: "backend", "max fee per gas={}, too low, block basefee={}", tx.max_fee_per_gas(), evm_env.block_env.basefee); + return Err(InvalidTransactionError::FeeCapTooLow); + } - match &tx.transaction { - TypedTransaction::Deposit(deposit_tx) => { - // Deposit transactions - // https://specs.optimism.io/protocol/deposits.html#execution - // 1. no gas cost check required since already have prepaid gas from L1 - // 2. increment account balance by deposited amount before checking for sufficient - // funds `tx.value <= existing account value + deposited value` - if value > account.balance + U256::from(deposit_tx.mint) { - warn!(target: "backend", "[{:?}] insufficient balance={}, required={} account={:?}", tx.hash(), account.balance + U256::from(deposit_tx.mint), value, *pending.sender()); - return Err(InvalidTransactionError::InsufficientFunds); + if !evm_env.cfg_env.disable_priority_fee_check + && let (Some(max_priority_fee_per_gas), max_fee_per_gas) = + (tx.as_ref().max_priority_fee_per_gas(), tx.as_ref().max_fee_per_gas()) + && max_priority_fee_per_gas > max_fee_per_gas + { + debug!(target: "backend", "max priority fee per gas={}, too high, max fee per gas={}", max_priority_fee_per_gas, max_fee_per_gas); + return Err(InvalidTransactionError::TipAboveFeeCap); } } - _ => { - // check sufficient funds: `gas * price + value` - let req_funds = max_cost.checked_add(value.saturating_to()).ok_or_else(|| { - warn!(target: "backend", "[{:?}] cost too high", tx.hash()); - InvalidTransactionError::InsufficientFunds - })?; - if account.balance < U256::from(req_funds) { - warn!(target: "backend", "[{:?}] insufficient allowance={}, required={} account={:?}", tx.hash(), account.balance, req_funds, *pending.sender()); - return Err(InvalidTransactionError::InsufficientFunds); + + // EIP-4844 blob fee validation + if evm_env.cfg_env.spec >= SpecId::CANCUN + && tx.is_eip4844() + && let Some(max_fee_per_blob_gas) = tx.max_fee_per_blob_gas() + && let Some(blob_gas_and_price) = &evm_env.block_env.blob_excess_gas_and_price + && max_fee_per_blob_gas < blob_gas_and_price.blob_gasprice + { + debug!(target: "backend", "max fee per blob gas={}, too low, block blob gas price={}", max_fee_per_blob_gas, blob_gas_and_price.blob_gasprice); + return Err(InvalidTransactionError::BlobFeeCapTooLow( + max_fee_per_blob_gas, + blob_gas_and_price.blob_gasprice, + )); + } + + let value = tx.value(); + match tx.as_ref() { + #[cfg(any(feature = "base", feature = "optimism"))] + FoundryTxEnvelope::Deposit(deposit_tx) => { + // Deposit transactions + // https://specs.optimism.io/protocol/deposits.html#execution + // 1. no gas cost check required since already have prepaid gas from L1 + // 2. increment account balance by deposited amount before checking for + // sufficient funds `tx.value <= existing account value + deposited value` + if value > account.balance + U256::from(deposit_tx.mint) { + debug!(target: "backend", "[{:?}] insufficient balance={}, required={} account={:?}", tx.hash(), account.balance + U256::from(deposit_tx.mint), value, *pending.sender()); + return Err(InvalidTransactionError::InsufficientFunds); + } + } + #[cfg(feature = "base")] + FoundryTxEnvelope::Eip8130(_) => { + // EIP-8130 affordability is checked asynchronously against + // the resolved payer, including payer-authentication gas. + } + FoundryTxEnvelope::Tempo(_) => { + // Tempo AA transactions pay gas with fee tokens, not ETH. + // Fee token balance is validated in validate_pool_transaction (async). + } + _ if self.is_tempo() => { + // Every transaction on Tempo pays gas in a fee token; forked mainnet senders + // hold no native balance at all. The fee token balance is validated in + // validate_pool_transaction (async). + } + #[cfg(feature = "monad")] + _ if self.validate_monad_transaction_funds(pending, account, evm_env)? => {} + _ => { + let max_cost = (tx.gas_limit() as u128) + .saturating_mul(tx.max_fee_per_gas()) + .saturating_add( + tx.blob_gas_used() + .map(|g| g as u128) + .unwrap_or(0) + .mul(tx.max_fee_per_blob_gas().unwrap_or(0)), + ); + // check sufficient funds: `gas * price + value` + let req_funds = + max_cost.checked_add(value.saturating_to()).ok_or_else(|| { + debug!(target: "backend", "[{:?}] cost too high", tx.hash()); + InvalidTransactionError::InsufficientFunds + })?; + if account.balance < U256::from(req_funds) { + debug!(target: "backend", "[{:?}] insufficient balance={}, required={} account={:?}", tx.hash(), account.balance, req_funds, *pending.sender()); + return Err(InvalidTransactionError::InsufficientFunds); + } } } } - Ok(()) } fn validate_for( &self, - tx: &PendingTransaction, + tx: &PendingTransaction, account: &AccountInfo, - env: &Env, + evm_env: &EvmEnv, ) -> Result<(), InvalidTransactionError> { - self.validate_pool_transaction_for(tx, account, env)?; + self.validate_pool_transaction_for(tx, account, evm_env)?; + // EIP-8130 counts nonces per channel in the nonce manager, so `tx.nonce()` is not + // comparable to the account's protocol nonce. Admission already validated the channel. + #[cfg(feature = "base")] + if tx.transaction.as_ref().is_eip8130() { + return Ok(()); + } if tx.nonce() > account.nonce { return Err(InvalidTransactionError::NonceTooHigh); } @@ -3310,15 +10374,45 @@ impl TransactionValidator for Backend { } } +/// Replaces the cached hash of a [`Signed`] transaction, preserving the inner tx and signature. +fn rehash(signed: Signed, hash: B256) -> Signed +where + T: alloy_consensus::transaction::RlpEcdsaEncodableTx, +{ + let (t, sig, _) = signed.into_parts(); + Signed::new_unchecked(t, sig, hash) +} + +fn build_rpc_transaction( + envelope: AnyTxEnvelope, + from: Address, + block: Option<&Block>, + info: Option<&TransactionInfo>, + effective_gas_price: Option, +) -> AnyRpcTransaction { + let tx = Transaction { + inner: Recovered::new_unchecked(envelope, from), + block_hash: block.map(|block| block.header.hash_slow()), + block_number: block.map(|block| block.header.number()), + transaction_index: info.map(|info| info.transaction_index), + effective_gas_price, + block_timestamp: block.map(|block| block.header.timestamp()), + }; + AnyRpcTransaction::from(WithOtherFields::new(tx)) +} + /// Creates a `AnyRpcTransaction` as it's expected for the `eth` RPC api from storage data pub fn transaction_build( tx_hash: Option, - eth_transaction: MaybeImpersonatedTransaction, + eth_transaction: MaybeImpersonatedTransaction, block: Option<&Block>, info: Option, base_fee: Option, ) -> AnyRpcTransaction { - if let TypedTransaction::Deposit(ref deposit_tx) = eth_transaction.transaction { + let mined_from = info.as_ref().map(|info| info.from); + + #[cfg(any(feature = "base", feature = "optimism"))] + if let FoundryTxEnvelope::Deposit(deposit_tx) = eth_transaction.as_ref() { let dep_tx = deposit_tx; let ser = serde_json::to_value(dep_tx).expect("could not serialize TxDeposit"); @@ -3340,21 +10434,17 @@ pub fn transaction_build( }; let envelope = AnyTxEnvelope::Unknown(UnknownTxEnvelope { - hash: eth_transaction.hash(), + hash: tx_hash.unwrap_or_else(|| eth_transaction.hash()), inner, }); - let tx = Transaction { - inner: Recovered::new_unchecked(envelope, deposit_tx.from), - block_hash: block - .as_ref() - .map(|block| B256::from(keccak256(alloy_rlp::encode(&block.header)))), - block_number: block.as_ref().map(|block| block.header.number), - transaction_index: info.as_ref().map(|info| info.transaction_index), - effective_gas_price: None, - }; - - return AnyRpcTransaction::from(WithOtherFields::new(tx)); + return build_rpc_transaction( + envelope, + mined_from.unwrap_or(deposit_tx.from), + block, + info.as_ref(), + None, + ); } Err(_) => { error!(target: "backend", "failed to serialize deposit transaction"); @@ -3362,75 +10452,103 @@ pub fn transaction_build( } } - let mut transaction: Transaction = eth_transaction.clone().into(); + #[cfg(feature = "base")] + if let FoundryTxEnvelope::Eip8130(eip8130_tx) = eth_transaction.as_ref() { + let from = mined_from.unwrap_or_else(|| eth_transaction.recover().unwrap_or_default()); + let rpc_tx = BaseRpcTransaction::from_transaction( + Recovered::new_unchecked(BaseTxEnvelope::Eip8130(eip8130_tx.clone()), from), + BaseTransactionInfo::default(), + ); + let mut fields = OtherFields::try_from( + serde_json::to_value(rpc_tx) + .expect("could not serialize canonical EIP-8130 transaction"), + ) + .expect("EIP-8130 transaction must serialize to an object"); + for key in + ["hash", "from", "blockHash", "blockNumber", "transactionIndex", "effectiveGasPrice"] + { + fields.remove(key); + } + let envelope = AnyTxEnvelope::Unknown(UnknownTxEnvelope { + hash: eth_transaction.hash(), + inner: UnknownTypedTransaction { + ty: AnyTxType(EIP8130_TRANSACTION_TYPE), + fields, + memo: Default::default(), + }, + }); + return build_rpc_transaction(envelope, from, block, info.as_ref(), None); + } + + if let FoundryTxEnvelope::Celo(tx) = eth_transaction.as_ref() { + let mut fields = + OtherFields::try_from(serde_json::to_value(tx).expect("valid CIP-64 transaction")) + .expect("CIP-64 transaction serializes to an object"); + fields.remove("hash"); + let envelope = AnyTxEnvelope::Unknown(UnknownTxEnvelope { + hash: tx_hash.unwrap_or_else(|| eth_transaction.hash()), + inner: UnknownTypedTransaction { + ty: AnyTxType(foundry_primitives::CIP64_TX_TYPE), + fields, + memo: Default::default(), + }, + }); + let from = mined_from.unwrap_or_else(|| eth_transaction.recover().unwrap_or_default()); + let effective_gas_price = block.map(|_| eth_transaction.effective_gas_price(base_fee)); + return build_rpc_transaction(envelope, from, block, info.as_ref(), effective_gas_price); + } + if let FoundryTxEnvelope::Tempo(tempo_tx) = eth_transaction.as_ref() { + let from = mined_from.unwrap_or_else(|| eth_transaction.recover().unwrap_or_default()); + let ser = serde_json::to_value(tempo_tx).expect("could not serialize Tempo transaction"); + let maybe_tempo_fields = OtherFields::try_from(ser); - let effective_gas_price = if !eth_transaction.is_dynamic_fee() { - transaction.effective_gas_price(base_fee) - } else if block.is_none() && info.is_none() { - // transaction is not mined yet, gas price is considered just `max_fee_per_gas` - transaction.max_fee_per_gas() - } else { - // if transaction is already mined, gas price is considered base fee + priority - // fee: the effective gas price. - let base_fee = base_fee.map_or(0u128, |g| g as u128); - let max_priority_fee_per_gas = transaction.max_priority_fee_per_gas().unwrap_or(0); + match maybe_tempo_fields { + Ok(fields) => { + let inner = UnknownTypedTransaction { + ty: AnyTxType(TEMPO_TX_TYPE_ID), + fields, + memo: Default::default(), + }; - base_fee.saturating_add(max_priority_fee_per_gas) - }; + let envelope = AnyTxEnvelope::Unknown(UnknownTxEnvelope { + hash: tx_hash.unwrap_or_else(|| eth_transaction.hash()), + inner, + }); - transaction.effective_gas_price = Some(effective_gas_price); + return build_rpc_transaction(envelope, from, block, info.as_ref(), None); + } + Err(_) => { + error!(target: "backend", "failed to serialize tempo transaction"); + } + } + } - let envelope = transaction.inner; + let from = mined_from.unwrap_or_else(|| eth_transaction.recover().unwrap_or_default()); + let effective_gas_price = eth_transaction.effective_gas_price(base_fee); // if a specific hash was provided we update the transaction's hash // This is important for impersonated transactions since they all use the // `BYPASS_SIGNATURE` which would result in different hashes // Note: for impersonated transactions this only concerns pending transactions because - // there's // no `info` yet. - let hash = tx_hash.unwrap_or(*envelope.tx_hash()); - - let envelope = match envelope.into_inner() { - TxEnvelope::Legacy(signed_tx) => { - let (t, sig, _) = signed_tx.into_parts(); - let new_signed = Signed::new_unchecked(t, sig, hash); - AnyTxEnvelope::Ethereum(TxEnvelope::Legacy(new_signed)) - } - TxEnvelope::Eip1559(signed_tx) => { - let (t, sig, _) = signed_tx.into_parts(); - let new_signed = Signed::new_unchecked(t, sig, hash); - AnyTxEnvelope::Ethereum(TxEnvelope::Eip1559(new_signed)) - } - TxEnvelope::Eip2930(signed_tx) => { - let (t, sig, _) = signed_tx.into_parts(); - let new_signed = Signed::new_unchecked(t, sig, hash); - AnyTxEnvelope::Ethereum(TxEnvelope::Eip2930(new_signed)) - } - TxEnvelope::Eip4844(signed_tx) => { - let (t, sig, _) = signed_tx.into_parts(); - let new_signed = Signed::new_unchecked(t, sig, hash); - AnyTxEnvelope::Ethereum(TxEnvelope::Eip4844(new_signed)) - } - TxEnvelope::Eip7702(signed_tx) => { - let (t, sig, _) = signed_tx.into_parts(); - let new_signed = Signed::new_unchecked(t, sig, hash); - AnyTxEnvelope::Ethereum(TxEnvelope::Eip7702(new_signed)) + // there's no `info` yet. + let hash = tx_hash.unwrap_or_else(|| eth_transaction.hash()); + + let eth_envelope = FoundryTxEnvelope::from(eth_transaction) + .try_into_eth() + .expect("non-standard transactions are handled above"); + + let envelope = match eth_envelope { + TxEnvelope::Legacy(s) => AnyTxEnvelope::Ethereum(TxEnvelope::Legacy(rehash(s, hash))), + TxEnvelope::Eip1559(s) => AnyTxEnvelope::Ethereum(TxEnvelope::Eip1559(rehash(s, hash))), + TxEnvelope::Eip2930(s) => AnyTxEnvelope::Ethereum(TxEnvelope::Eip2930(rehash(s, hash))), + TxEnvelope::Eip4844(s) => { + let s = if block.is_some() { s.map(TxEip4844Variant::drop_sidecar) } else { s }; + AnyTxEnvelope::Ethereum(TxEnvelope::Eip4844(rehash(s, hash))) } + TxEnvelope::Eip7702(s) => AnyTxEnvelope::Ethereum(TxEnvelope::Eip7702(rehash(s, hash))), }; - let tx = Transaction { - inner: Recovered::new_unchecked( - envelope, - eth_transaction.recover().expect("can recover signed tx"), - ), - block_hash: block - .as_ref() - .map(|block| B256::from(keccak256(alloy_rlp::encode(&block.header)))), - block_number: block.as_ref().map(|block| block.header.number), - transaction_index: info.as_ref().map(|info| info.transaction_index), - // deprecated - effective_gas_price: Some(effective_gas_price), - }; - AnyRpcTransaction::from(WithOtherFields::new(tx)) + build_rpc_transaction(envelope, from, block, info.as_ref(), Some(effective_gas_price)) } /// Prove a storage key's existence or nonexistence in the account's storage trie. @@ -3438,7 +10556,10 @@ pub fn transaction_build( /// `storage_key` is the hash of the desired storage key, meaning /// this will only work correctly under a secure trie. /// `storage_key` == keccak(key) -pub fn prove_storage(storage: &HashMap, keys: &[B256]) -> Vec> { +pub fn prove_storage( + storage: &alloy_primitives::map::U256Map, + keys: &[B256], +) -> (B256, Vec>) { let keys: Vec<_> = keys.iter().map(|key| Nibbles::unpack(keccak256(key))).collect(); let mut builder = HashBuilder::default().with_proof_retainer(ProofRetainer::new(keys.clone())); @@ -3447,7 +10568,7 @@ pub fn prove_storage(storage: &HashMap, keys: &[B256]) -> Vec, keys: &[B256]) -> Vec bool { @@ -3470,9 +10591,1092 @@ pub fn is_arbitrum(chain_id: u64) -> bool { false } -pub fn op_haltreason_to_instruction_result(op_reason: OpHaltReason) -> InstructionResult { - match op_reason { - OpHaltReason::Base(eth_h) => eth_h.into(), - OpHaltReason::FailedDeposit => InstructionResult::Stop, +/// Commits a fully executed candidate cache to the live database. +fn commit_cache(db: &mut dyn Db, cache: revm::database::Cache) -> Result<(), BlockchainError> { + let revm::database::Cache { accounts, contracts, .. } = cache; + let mut changes = EvmState::default(); + for (address, db_account) in accounts { + if db_account.account_state == AccountState::None { + continue; + } + + let DbAccount { mut info, account_state, storage } = db_account; + // `CacheDB` also records absent-account reads as `NotExisting`. They are not state changes + // and must not become synthetic selfdestructs in the live database. + if account_state == AccountState::NotExisting && db.basic(address)?.is_none() { + continue; + } + if info.code.is_none() { + info.code = contracts.get(&info.code_hash()).cloned(); + } + let mut account = Account::from(info); + account.mark_touch(); + match account_state { + AccountState::NotExisting => account.mark_selfdestruct(), + AccountState::StorageCleared => account.mark_created(), + AccountState::Touched => {} + AccountState::None => unreachable!(), + } + for (slot, value) in storage { + let original = if account_state.is_storage_cleared() { + U256::ZERO + } else { + db.storage(address, slot)? + }; + account + .storage + .insert(slot, EvmStorageSlot::new_changed(original, value, TransactionId::ZERO)); + } + changes.insert(address, account); + } + db.commit(changes); + Ok(()) +} + +fn simulate_rpc_error(code: i64, message: impl Into) -> BlockchainError { + BlockchainError::RpcError(RpcError { + code: ErrorCode::from(code), + message: message.into().into(), + data: None, + }) +} + +fn previously_deleted_accounts( + accounts: &AddressMap, + addresses: impl IntoIterator, +) -> Vec
{ + addresses + .into_iter() + .filter(|address| { + accounts + .get(address) + .is_some_and(|account| account.account_state == AccountState::NotExisting) + }) + .collect() +} + +fn preserve_deleted_storage( + accounts: &mut AddressMap, + previously_deleted: Vec
, +) { + for address in previously_deleted { + if let Some(account) = accounts.get_mut(&address) + && account.account_state != AccountState::NotExisting + { + account.account_state = AccountState::StorageCleared; + } + } +} + +fn simulate_transaction_error(error: InvalidTransactionError) -> BlockchainError { + let code = match &error { + InvalidTransactionError::NonceTooLow => -38010, + InvalidTransactionError::NonceTooHigh => -38011, + InvalidTransactionError::NonceMaxValue => -32603, + InvalidTransactionError::FeeCapTooLow => -38012, + InvalidTransactionError::GasTooLow | InvalidTransactionError::GasTooHigh(_) => -38013, + InvalidTransactionError::InsufficientFunds + | InvalidTransactionError::InsufficientFundsForTransfer => -38014, + _ => return BlockchainError::InvalidTransaction(error), + }; + + simulate_rpc_error(code, format!("err: {error}")) +} + +pub(in crate::eth) fn sanitize_simulation_blocks( + blocks: Vec>, + base_number: u64, + base_timestamp: u64, + block_interval: u64, +) -> Result>, BlockchainError> { + let block_interval = block_interval.max(1); + let mut sanitized = Vec::with_capacity(blocks.len()); + let mut previous_number = base_number; + let mut previous_timestamp = base_timestamp; + + for mut block in blocks { + let mut overrides = block.block_overrides.take().unwrap_or_default(); + let default_number = previous_number.checked_add(1).ok_or_else(|| { + simulate_rpc_error(-38020, "block number overflow while constructing sequence") + })?; + let number = + overrides.number.map(|number| number.saturating_to()).unwrap_or(default_number); + + if number <= previous_number { + return Err(simulate_rpc_error( + -38020, + format!("block numbers must be in order: {number} <= {previous_number}"), + )); + } + + let gap = number - previous_number - 1; + let remaining = MAX_SIMULATE_BLOCKS as usize - sanitized.len(); + if gap as usize >= remaining { + return Err(simulate_rpc_error(-38026, "too many blocks")); + } + + for offset in 0..gap { + let timestamp = previous_timestamp.checked_add(block_interval).ok_or_else(|| { + simulate_rpc_error(-38021, "block timestamp overflow while filling number gap") + })?; + sanitized.push(SimBlock { + block_overrides: Some(BlockOverrides { + number: Some(U256::from(default_number + offset)), + time: Some(timestamp), + ..Default::default() + }), + state_overrides: None, + calls: Vec::new(), + }); + previous_timestamp = timestamp; + } + + let timestamp = match overrides.time { + Some(timestamp) => timestamp, + None => previous_timestamp.checked_add(block_interval).ok_or_else(|| { + simulate_rpc_error(-38021, "block timestamp overflow while constructing sequence") + })?, + }; + if timestamp <= previous_timestamp { + return Err(simulate_rpc_error( + -38021, + format!("block timestamps must be in order: {timestamp} <= {previous_timestamp}"), + )); + } + + overrides.number = Some(U256::from(number)); + overrides.time = Some(timestamp); + block.block_overrides = Some(overrides); + sanitized.push(block); + previous_number = number; + previous_timestamp = timestamp; + } + + Ok(sanitized) +} + +/// Unpacks an [`ExecutionResult`] into its exit reason, gas used, output, and logs. +fn unpack_execution_result( + result: ExecutionResult, +) -> (InstructionResult, u64, Option, Vec) { + match result { + ExecutionResult::Success { reason, gas, output, logs, .. } => { + (reason.into(), gas.tx_gas_used(), Some(output), logs) + } + ExecutionResult::Revert { gas, output, logs, .. } => { + (InstructionResult::Revert, gas.tx_gas_used(), Some(Output::Call(output)), logs) + } + ExecutionResult::Halt { reason, gas, logs, .. } => { + (reason.into_instruction_result(), gas.tx_gas_used(), None, logs) + } + } +} + +fn arbitrum_replay_block_number(block: &AnyRpcBlock) -> U256 { + block + .other + .get("l1BlockNumber") + .cloned() + .and_then(|number| serde_json::from_value(number).ok()) + .unwrap_or_else(|| U256::from(block.header().number())) +} + +/// Converts a halt reason into an [`InstructionResult`]. +/// +/// Abstracts over network-specific halt reason types (`HaltReason`, `OpHaltReason`) +/// so that anvil code doesn't need to match on each variant directly. +pub use foundry_evm::core::evm::IntoInstructionResult; + +/// Creates an Ethereum-shaped genesis header from the EVM environment. +fn genesis_header( + evm_env: &EvmEnv, + base_fee: Option, + timestamp: u64, + genesis_number: u64, +) -> Header { + let spec_id = *evm_env.spec_id(); + Header { + timestamp, + base_fee_per_gas: base_fee, + gas_limit: evm_env.block_env.gas_limit, + beneficiary: evm_env.block_env.beneficiary, + difficulty: evm_env.block_env.difficulty, + blob_gas_used: evm_env.block_env.blob_excess_gas_and_price.as_ref().map(|_| 0), + excess_blob_gas: evm_env.block_env.blob_excess_gas(), + number: genesis_number, + parent_beacon_block_root: (spec_id >= SpecId::CANCUN).then_some(Default::default()), + withdrawals_root: (spec_id >= SpecId::SHANGHAI).then_some(EMPTY_WITHDRAWALS), + requests_hash: (spec_id >= SpecId::PRAGUE).then_some(EMPTY_REQUESTS_HASH), + ..Default::default() + } +} + +/// Creates an Ethereum header from a `genesis.json` configuration. +fn genesis_json_header(genesis: &Genesis) -> Header { + let number = genesis.number.unwrap_or_default(); + let timestamp = genesis.timestamp; + let is_london = genesis.config.is_london_active_at_block(number); + let is_shanghai = genesis.config.is_shanghai_active_at_block_and_timestamp(number, timestamp); + let is_cancun = genesis.config.is_cancun_active_at_block_and_timestamp(number, timestamp); + let is_prague = genesis.config.prague_time.is_some_and(|fork| fork <= timestamp); + let is_amsterdam = genesis.config.amsterdam_time.is_some_and(|fork| fork <= timestamp); + + Header { + number, + parent_hash: genesis.parent_hash.unwrap_or_default(), + gas_limit: genesis.gas_limit, + difficulty: genesis.difficulty, + nonce: genesis.nonce.into(), + extra_data: genesis.extra_data.clone(), + state_root: state_root_ref_unhashed(&genesis.alloc), + timestamp, + mix_hash: genesis.mix_hash, + beneficiary: genesis.coinbase, + base_fee_per_gas: is_london.then(|| { + genesis + .base_fee_per_gas + .map(|fee| fee as u64) + .unwrap_or(crate::eth::fees::INITIAL_BASE_FEE) + }), + withdrawals_root: is_shanghai.then_some(EMPTY_WITHDRAWALS), + parent_beacon_block_root: is_cancun.then_some(B256::ZERO), + blob_gas_used: is_cancun.then_some(genesis.blob_gas_used.unwrap_or_default()), + excess_blob_gas: is_cancun.then_some(genesis.excess_blob_gas.unwrap_or_default()), + requests_hash: is_prague.then_some(EMPTY_REQUESTS_HASH), + block_access_list_hash: is_amsterdam.then_some(EMPTY_BLOCK_ACCESS_LIST_HASH), + slot_number: is_amsterdam.then_some(genesis.slot_number.unwrap_or_default()), + ..Default::default() + } +} + +/// Wraps an Ethereum-shaped header in the selected network's consensus header. +fn foundry_header(networks: &NetworkConfigs, header: Header) -> FoundryHeader { + if networks.is_tempo() { FoundryHeader::tempo(header) } else { header.into() } +} + +#[cfg(test)] +mod tests { + use super::{ + BlockRequest, FeeDetails, ForkCacheNamespace, ForkCacheSource, InstructionResult, + MiningCommitHook, Output, StagedForkCacheLease, StagedForkDbUser, + arbitrum_replay_block_number, + }; + use crate::{NodeConfig, config::ForkTransactionReplay, spawn}; + use alloy_network::{AnyHeader, AnyRpcBlock, AnyRpcHeader, TransactionBuilder}; + use alloy_primitives::{Address, B256, Bytes, U256}; + use alloy_provider::Provider; + use alloy_rpc_types::{Block, BlockTransactions, TransactionRequest, state::EvmOverrides}; + use alloy_serde::WithOtherFields; + use foundry_config::NamedChain; + use foundry_evm::{ + backend::{BlockchainDb, BlockchainDbMeta}, + hardfork::{EthereumHardfork, FoundryHardfork}, + }; + use foundry_evm_networks::arbitrum; + use std::sync::Arc; + use tempfile::tempdir; + use tokio::sync::Notify; + + #[cfg(feature = "base")] + use base_common_precompiles::{ActivationRegistryStorage, B20FactoryStorage}; + #[cfg(feature = "base")] + use foundry_evm::{hardforks::BaseUpgrade, traces::CallTraceDecoderBuilder}; + + #[cfg(all(feature = "base", feature = "optimism"))] + use alloy_consensus::Header; + #[cfg(all(feature = "base", feature = "optimism"))] + use foundry_evm::hardfork::OpHardfork; + #[cfg(all(feature = "base", feature = "optimism"))] + use foundry_evm_networks::NetworkConfigs; + + fn test_cache_db(cache_path: std::path::PathBuf) -> BlockchainDb { + let db = BlockchainDb::new(BlockchainDbMeta::default(), Some(cache_path)); + db.block_hashes().write().insert(U256::ZERO, B256::repeat_byte(0x11)); + db.cache().flush(); + db + } + + #[test] + fn arbitrum_transaction_replay_uses_l1_block_number() { + let header = AnyHeader { number: 75_219_831, ..Default::default() }; + let mut block = AnyRpcBlock::new( + Block::new( + AnyRpcHeader::from_sealed(header.seal(B256::ZERO)), + BlockTransactions::Full(Vec::new()), + ) + .into(), + ); + block.other.insert("l1BlockNumber".to_string(), serde_json::json!("0x10276d3")); + + assert_eq!(arbitrum_replay_block_number(&block), U256::from(16_938_707)); + } + + #[cfg(all(feature = "base", feature = "optimism"))] + #[tokio::test(flavor = "multi_thread")] + async fn optimism_fork_uses_optimism_schedule_for_base_headers() { + let timestamp = 1_710_374_401; + let (_, origin) = spawn( + NodeConfig::test_base() + .with_hardfork(Some(BaseUpgrade::Ecotone.into())) + .with_genesis_timestamp(Some(timestamp)), + ) + .await; + let (api, _) = spawn( + NodeConfig::test() + .with_networks(NetworkConfigs::with_optimism()) + .with_eth_rpc_url(Some(origin.http_endpoint())), + ) + .await; + + assert!(api.backend.is_optimism()); + assert_eq!(api.backend.hardfork(), OpHardfork::Ecotone.into()); + assert!( + !api.backend + .is_optimism_jovian_at_header(&Header { timestamp, ..Default::default() }, None) + ); + } + + #[tokio::test] + async fn fork_arbitrum_transaction_replay_preserves_rpc_block_number() { + const GENESIS_BLOCK: u64 = 101; + const L1_BLOCK: u64 = 10; + const REPLAY_BLOCK: u64 = GENESIS_BLOCK + 1; + + let config = || { + NodeConfig::test() + .with_chain_id(Some(NamedChain::Arbitrum as u64)) + .with_genesis_block_number(Some(GENESIS_BLOCK)) + }; + let (_source_api, source_handle) = spawn(config()).await; + let source_provider = source_handle.http_provider(); + let sender = source_provider.get_accounts().await.unwrap()[0]; + let receipt = source_provider + .send_transaction(WithOtherFields::new( + TransactionRequest::default() + .with_from(sender) + .with_to(arbitrum::ARB_SYS_ADDRESS) + .with_input(Bytes::from(arbitrum::ARB_BLOCK_NUMBER_SELECTOR)), + )) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + let mut source_block = source_provider + .get_block_by_hash(receipt.block_hash.unwrap()) + .full() + .await + .unwrap() + .unwrap(); + source_block + .other + .insert("l1BlockNumber".to_string(), serde_json::json!(format!("0x{L1_BLOCK:x}"))); + + let (replay_api, _replay_handle) = spawn(config()).await; + replay_api + .backend + .apply_fork_transaction_replay(ForkTransactionReplay { source_block, target_index: 0 }) + .await + .unwrap(); + + let expected = arbitrum::arb_block_number_output(REPLAY_BLOCK); + let replayed = + replay_api.backend.mined_transaction_receipt(receipt.transaction_hash).unwrap(); + assert_eq!(replayed.out.unwrap(), expected); + assert_eq!(replay_api.block_number().unwrap(), U256::from(REPLAY_BLOCK)); + + let output = replay_api + .call( + WithOtherFields::new( + TransactionRequest::default() + .with_to(arbitrum::ARB_SYS_ADDRESS) + .with_input(Bytes::from(arbitrum::ARB_BLOCK_NUMBER_SELECTOR)), + ), + None, + EvmOverrides::default(), + ) + .await + .unwrap(); + assert_eq!(output, expected); + } + + fn test_endpoint_identity( + hardfork: Option, + instance_id: Option, + ) -> super::ForkEndpointIdentity { + super::ForkEndpointIdentity { + execution_chain_id: 1, + source_chain_id: 1, + network: None, + network_profile: None, + hardfork, + instance_id, + source_fork_block_number: None, + source_fork_block_hash: None, + } + } + + #[tokio::test] + async fn missing_snapshot_block_does_not_change_head() { + let (api, _handle) = spawn(NodeConfig::test()).await; + let snapshot_hash = api.backend.best_hash(); + let snapshot = api.backend.create_state_snapshot().await; + api.mine_one().await.unwrap(); + let best_number = api.backend.best_number(); + let best_hash = api.backend.best_hash(); + let head_wall_time = api.backend.time().last_block_wall_time(); + + api.backend.blockchain.storage.write().blocks.remove(&snapshot_hash); + let err = api.backend.revert_state_snapshot(snapshot).await.unwrap_err(); + + assert!(matches!(err, super::BlockchainError::BlockNotFound)); + assert_eq!(api.backend.best_number(), best_number); + assert_eq!(api.backend.best_hash(), best_hash); + assert_eq!(api.backend.time().last_block_wall_time(), head_wall_time); + } + + #[tokio::test(flavor = "multi_thread")] + async fn live_execution_snapshot_is_coherent_during_mining() { + let (api, handle) = spawn(NodeConfig::test().with_no_mining(true)).await; + let sender = handle.dev_wallets().next().unwrap().address(); + let recipient = Address::repeat_byte(0x11); + let contract = Address::repeat_byte(0x22); + + // Return the recipient balance followed by NUMBER. At block 0 both are zero; at block 1, + // after the queued transfer is mined, both are one. + let mut code = vec![0x73]; + code.extend_from_slice(recipient.as_slice()); + code.extend_from_slice(&[ + 0x31, 0x60, 0x00, 0x52, 0x43, 0x60, 0x20, 0x52, 0x60, 0x40, 0x60, 0x00, 0xf3, + ]); + api.anvil_set_code(contract, code.into()).await.unwrap(); + api.send_transaction(WithOtherFields::new( + TransactionRequest::default().from(sender).to(recipient).value(U256::ONE), + )) + .await + .unwrap(); + + let resolved_head = api.backend.best_number(); + let db_guard = api.backend.db.write().await; + + // Polling while holding the database makes the lock queue deterministic: mining owns the + // first waiter and the call that already resolved `latest` owns the second. + let mining_api = api.clone(); + let mut mining = Box::pin(async move { mining_api.mine_one().await }); + assert!(futures::poll!(mining.as_mut()).is_pending()); + + let request = WithOtherFields::new(TransactionRequest::default().to(contract)); + let mut call = Box::pin(api.backend.call( + request, + FeeDetails::zero(), + Some(BlockRequest::Number(resolved_head)), + EvmOverrides::default(), + )); + assert!(futures::poll!(call.as_mut()).is_pending()); + + let mut pending_block = Box::pin(api.backend.pending_block(Vec::new())); + assert!(futures::poll!(pending_block.as_mut()).is_pending()); + + let mut state = Box::pin(api.serialized_state(false)); + assert!(futures::poll!(state.as_mut()).is_pending()); + + // Pause mining after the database commit but before canonical publication without locking + // storage, so an incorrectly unblocked pending reader can observe the old parent. + let hook = + MiningCommitHook { reached: Arc::new(Notify::new()), resume: Arc::new(Notify::new()) }; + *api.backend.mining_commit_hook.lock() = Some(hook.clone()); + drop(db_guard); + let mining = tokio::spawn(mining); + hook.reached.notified().await; + + // Mining retains the database write lock while waiting to publish the canonical head, so + // neither a live call nor a pending block can observe the partially published snapshot. + assert!(futures::poll!(call.as_mut()).is_pending()); + assert!(futures::poll!(pending_block.as_mut()).is_pending()); + assert!(futures::poll!(state.as_mut()).is_pending()); + + hook.resume.notify_one(); + mining.await.unwrap().unwrap(); + + let (exit, output, _, _) = call.await.unwrap(); + let pending_block = pending_block.await.unwrap(); + let state = state.await.unwrap(); + + assert_eq!(state.accounts[&recipient].balance, U256::ONE); + assert_eq!(state.block.unwrap().number, U256::ONE); + assert_eq!(state.best_block_number, Some(1)); + + assert_eq!(exit, InstructionResult::Return); + let Some(Output::Call(output)) = output else { panic!("call did not return data") }; + assert_eq!(output.len(), 64); + let balance = U256::from_be_slice(&output[..32]); + let block_number = U256::from_be_slice(&output[32..]); + // The call requested block 0, so it sees block 0's balance and number. + assert_eq!((balance, block_number), (U256::ZERO, U256::ZERO)); + + assert_eq!(pending_block.block.header.number, api.backend.best_number() + 1); + assert_eq!(pending_block.block.header.parent_hash, api.backend.best_hash()); + assert_eq!(pending_block.block.header.base_fee_per_gas, Some(875_175_000)); + } + + /// Reads the recipient balance at head block 0 while block 1, which funds it, is being mined. + async fn read_head_balance_while_mining(config: NodeConfig) -> U256 { + let (api, handle) = spawn(config.with_no_mining(true)).await; + let sender = handle.dev_wallets().next().unwrap().address(); + let recipient = Address::repeat_byte(0x11); + api.send_transaction(WithOtherFields::new( + TransactionRequest::default().from(sender).to(recipient).value(U256::from(1)), + )) + .await + .unwrap(); + + // Pause mining after the next block's state is committed while it still holds the + // database write lock and before it publishes the new head. + let hook = + MiningCommitHook { reached: Arc::new(Notify::new()), resume: Arc::new(Notify::new()) }; + *api.backend.mining_commit_hook.lock() = Some(hook.clone()); + let mining_api = api.clone(); + let mining = tokio::spawn(async move { mining_api.mine_one().await }); + hook.reached.notified().await; + + // The read names the current head, block 0, and queues behind the miner's write lock. + let head = api.backend.best_number(); + assert_eq!(head, 0); + let mut balance = + Box::pin(api.backend.get_balance(recipient, Some(BlockRequest::Number(head)))); + assert!(futures::poll!(balance.as_mut()).is_pending()); + + hook.resume.notify_one(); + mining.await.unwrap().unwrap(); + assert_eq!(api.backend.best_number(), 1); + + balance.await.unwrap() + } + + #[tokio::test(flavor = "multi_thread")] + async fn head_number_read_waiting_on_mining_keeps_requested_state() { + // The read must be answered from block 0, not from block 1 mined while it waited. + assert_eq!(read_head_balance_while_mining(NodeConfig::test()).await, U256::ZERO); + } + + #[tokio::test(flavor = "multi_thread")] + async fn head_number_read_waiting_on_mining_without_history_reads_live_state() { + // Without state history block 0's state is gone, so the read is served from the live state + // instead of failing. + let config = NodeConfig::test().set_pruned_history(Some(None)); + assert_eq!(read_head_balance_while_mining(config).await, U256::from(1)); + } + + struct CacheFlushingDb(BlockchainDb); + + impl Drop for CacheFlushingDb { + fn drop(&mut self) { + self.0.block_hashes().write().insert(U256::ONE, B256::repeat_byte(0x22)); + self.0.cache().flush(); + } + } + + #[test] + fn staged_fork_cache_lease_waits_for_last_owner() { + let root = tempdir().unwrap(); + let block_cache_dir = root.path().join("1"); + let cache_path = block_cache_dir.join("storage.json"); + let sibling_cache_path = block_cache_dir.join("storage-sibling.json"); + let db = test_cache_db(cache_path.clone()); + std::fs::write(&sibling_cache_path, b"sibling").unwrap(); + let lease = StagedForkCacheLease::new(db.clone(), Some(cache_path.clone())); + let last_user = lease.clone(); + + lease.rollback().unwrap(); + assert!(!db.block_hashes().read().is_empty()); + drop(lease); + assert!(!db.block_hashes().read().is_empty()); + + drop(last_user); + assert!(db.block_hashes().read().is_empty()); + assert!(!cache_path.exists()); + assert!(sibling_cache_path.exists()); + } + + #[tokio::test] + async fn staged_fork_cache_lease_survives_task_cancellation() { + let root = tempdir().unwrap(); + let block_cache_dir = root.path().join("1"); + let cache_path = block_cache_dir.join("storage.json"); + let cache_db = test_cache_db(cache_path.clone()); + let lease = StagedForkCacheLease::new(cache_db.clone(), Some(cache_path.clone())); + let db = Arc::new(tokio::sync::RwLock::new(CacheFlushingDb(cache_db.clone()))); + let task_user = StagedForkDbUser { db: Some(Arc::clone(&db)), cache_lease: lease.clone() }; + drop(db); + let task = tokio::spawn(async move { + let _db = task_user.db().read().await; + std::future::pending::<()>().await; + }); + tokio::task::yield_now().await; + + // Precise closure capture must not detach the lease from the task's database handle. + assert_eq!(Arc::strong_count(lease.0.as_ref().unwrap()), 2); + lease.rollback().unwrap(); + drop(lease); + + task.abort(); + assert!(task.await.unwrap_err().is_cancelled()); + + assert!(cache_db.block_hashes().read().is_empty()); + assert!(!cache_path.exists()); + } + + #[test] + fn staged_fork_cache_lease_disarm_preserves_committed_cache() { + let root = tempdir().unwrap(); + let block_cache_dir = root.path().join("1"); + let cache_path = block_cache_dir.join("storage.json"); + let db = test_cache_db(cache_path.clone()); + let lease = StagedForkCacheLease::new(db.clone(), Some(cache_path.clone())); + + lease.disarm(); + drop(lease); + + assert!(!db.block_hashes().read().is_empty()); + assert!(cache_path.exists()); + } + + #[test] + fn fork_cache_namespace_preserves_sibling_endpoints() { + let root = tempdir().unwrap(); + let target_file = "storage-target.json"; + let sibling_file = "storage-sibling.json"; + let regular_cache_entry = root.path().join("latest.json"); + std::fs::write(®ular_cache_entry, b"ordinary RPC cache entry").unwrap(); + for block in ["1", "2"] { + let block_dir = root.path().join(block); + std::fs::create_dir_all(&block_dir).unwrap(); + std::fs::write(block_dir.join(target_file), b"target").unwrap(); + std::fs::write(block_dir.join(sibling_file), b"sibling").unwrap(); + } + let namespace = ForkCacheNamespace { + chain_cache_dir: root.path().to_path_buf(), + file_name: target_file.to_string(), + }; + + namespace.invalidate().unwrap(); + + for block in ["1", "2"] { + let block_dir = root.path().join(block); + assert!(!block_dir.join(target_file).exists()); + assert!(block_dir.join(sibling_file).exists()); + } + assert!(regular_cache_entry.exists()); + } + + #[test] + fn startup_fork_cache_user_rolls_back_after_database_flush() { + let root = tempdir().unwrap(); + let block_cache_dir = root.path().join("1"); + let cache_path = block_cache_dir.join("storage.json"); + let cache_db = test_cache_db(cache_path.clone()); + let user = StagedForkDbUser { + db: Some(Arc::new(tokio::sync::RwLock::new(CacheFlushingDb(cache_db.clone())))), + cache_lease: StagedForkCacheLease::new(cache_db.clone(), Some(cache_path.clone())), + }; + + drop(user); + + assert!(cache_db.block_hashes().read().is_empty()); + assert!(!cache_path.exists()); + } + + #[test] + fn startup_fork_cache_user_preserves_cache_after_commit() { + let root = tempdir().unwrap(); + let block_cache_dir = root.path().join("1"); + let cache_path = block_cache_dir.join("storage.json"); + let cache_db = test_cache_db(cache_path.clone()); + let user = StagedForkDbUser { + db: Some(Arc::new(tokio::sync::RwLock::new(CacheFlushingDb(cache_db.clone())))), + cache_lease: StagedForkCacheLease::new(cache_db.clone(), Some(cache_path.clone())), + }; + user.cache_lease.disarm(); + + drop(user); + + assert_eq!(cache_db.block_hashes().read().get(&U256::ONE), Some(&B256::repeat_byte(0x22))); + assert!(cache_path.exists()); + } + + #[test] + fn fork_cache_source_invalidates_only_same_url_authoritative_replacements() { + let anonymous = test_endpoint_identity(None, None); + let anonymous_source = ForkCacheSource { + rpc_url: "http://localhost".to_string(), + endpoint_identity: anonymous, + }; + let mut changed_anonymous = anonymous; + changed_anonymous.source_chain_id = 2; + + assert!( + !anonymous_source + .authoritative_identity_changed_at_same_url("http://localhost", changed_anonymous) + ); + assert!( + !anonymous_source + .authoritative_identity_changed_at_same_url("http://mirror", anonymous) + ); + + let hardfork = Some(FoundryHardfork::Ethereum(EthereumHardfork::Prague)); + let authoritative = test_endpoint_identity(hardfork, Some(B256::repeat_byte(0x22))); + let authoritative_source = ForkCacheSource { + rpc_url: "http://localhost".to_string(), + endpoint_identity: authoritative, + }; + let replacement = test_endpoint_identity(hardfork, Some(B256::repeat_byte(0x33))); + + assert!( + !authoritative_source + .authoritative_identity_changed_at_same_url("http://localhost", authoritative) + ); + assert!( + authoritative_source + .authoritative_identity_changed_at_same_url("http://localhost", replacement) + ); + assert!( + anonymous_source + .authoritative_identity_changed_at_same_url("http://localhost", authoritative) + ); + assert!( + authoritative_source + .authoritative_identity_changed_at_same_url("http://localhost", anonymous) + ); + } + + #[tokio::test] + async fn test_deterministic_block_mining() { + // Test that mine_block produces deterministic block hashes with same initial conditions + let genesis_timestamp = 1743944919u64; + + // Create two identical backends + let config_a = NodeConfig::test().with_genesis_timestamp(genesis_timestamp.into()); + let config_b = NodeConfig::test().with_genesis_timestamp(genesis_timestamp.into()); + + let (api_a, _handle_a) = spawn(config_a).await; + let (api_b, _handle_b) = spawn(config_b).await; + + // Mine empty blocks (no transactions) on both backends + let outcome_a_1 = api_a.backend.mine_block(vec![]).await.unwrap(); + let outcome_b_1 = api_b.backend.mine_block(vec![]).await.unwrap(); + + // Both should mine the same block number + assert_eq!(outcome_a_1.block_number, outcome_b_1.block_number); + + // Get the actual blocks to compare hashes + let block_a_1 = + api_a.block_by_number(outcome_a_1.block_number.into()).await.unwrap().unwrap(); + let block_b_1 = + api_b.block_by_number(outcome_b_1.block_number.into()).await.unwrap().unwrap(); + + // The block hashes should be identical + assert_eq!( + block_a_1.header.hash, block_b_1.header.hash, + "Block hashes should be deterministic. Got {} vs {}", + block_a_1.header.hash, block_b_1.header.hash + ); + + // Mine another block to ensure it remains deterministic + let outcome_a_2 = api_a.backend.mine_block(vec![]).await.unwrap(); + let outcome_b_2 = api_b.backend.mine_block(vec![]).await.unwrap(); + + let block_a_2 = + api_a.block_by_number(outcome_a_2.block_number.into()).await.unwrap().unwrap(); + let block_b_2 = + api_b.block_by_number(outcome_b_2.block_number.into()).await.unwrap().unwrap(); + + assert_eq!( + block_a_2.header.hash, block_b_2.header.hash, + "Second block hashes should also be deterministic. Got {} vs {}", + block_a_2.header.hash, block_b_2.header.hash + ); + + // Ensure the blocks are different (sanity check) + assert_ne!( + block_a_1.header.hash, block_a_2.header.hash, + "Different blocks should have different hashes" + ); + } + + #[cfg(feature = "monad")] + #[tokio::test] + async fn monad_load_state_rebuilds_participant_cache() { + use alloy_network::TransactionBuilder as _; + use alloy_provider::Provider as _; + + let config = || { + NodeConfig::test_monad() + .with_hardfork(Some(foundry_evm::hardfork::MonadHardfork::MonadNine.into())) + }; + let (api, handle) = spawn(config()).await; + let provider = handle.http_provider(); + let accounts = provider.get_accounts().await.unwrap(); + let sender = accounts[0]; + + let receipt = provider + .send_transaction( + alloy_rpc_types::TransactionRequest::default() + .with_from(sender) + .with_to(accounts[1]) + .with_value(U256::ONE) + .into(), + ) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + let block_hash = receipt.block_hash.unwrap(); + let state = api.serialized_state(false).await.unwrap(); + + let (loaded_api, _handle) = spawn(config()).await; + loaded_api.backend.load_state(state).await.unwrap(); + + let storage = loaded_api.backend.blockchain.storage.read(); + let participants = storage.monad_block_participants.get(&block_hash).unwrap(); + assert!(participants.contains(&sender)); + } + + #[cfg(feature = "monad")] + #[tokio::test] + async fn monad_load_state_restores_pruned_participant_cache() { + use alloy_consensus::BlockHeader as _; + use alloy_network::TransactionBuilder as _; + use alloy_provider::Provider as _; + + let config = || { + NodeConfig::test_monad() + .with_hardfork(Some(foundry_evm::hardfork::MonadHardfork::MonadNine.into())) + .with_transaction_block_keeper(Some(1usize)) + }; + let (api, handle) = spawn(config()).await; + let provider = handle.http_provider(); + let accounts = provider.get_accounts().await.unwrap(); + let sender = accounts[0]; + + let first_receipt = provider + .send_transaction( + alloy_rpc_types::TransactionRequest::default() + .with_from(sender) + .with_to(accounts[1]) + .with_value(U256::ONE) + .into(), + ) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + let second_receipt = provider + .send_transaction( + alloy_rpc_types::TransactionRequest::default() + .with_from(sender) + .with_to(accounts[1]) + .with_value(U256::ONE) + .into(), + ) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + let first_block_hash = first_receipt.block_hash.unwrap(); + let second_block_hash = second_receipt.block_hash.unwrap(); + + { + let storage = api.backend.blockchain.storage.read(); + let first_block = storage.blocks.get(&first_block_hash).unwrap(); + assert!(first_block.body.transactions.is_empty()); + assert_ne!( + first_block.header.transactions_root(), + alloy_consensus::constants::EMPTY_ROOT_HASH + ); + assert!(storage.monad_block_participants[&first_block_hash].contains(&sender)); + assert!(!storage.blocks[&second_block_hash].body.transactions.is_empty()); + } + + let state = api.serialized_state(false).await.unwrap(); + assert!(state.monad_block_participants[&first_block_hash].contains(&sender)); + assert_eq!(state.monad_block_participants.len(), 2); + assert!(state.monad_block_participants[&second_block_hash].contains(&sender)); + + let (loaded_api, _handle) = spawn(config()).await; + loaded_api.backend.load_state(state).await.unwrap(); + + { + let storage = loaded_api.backend.blockchain.storage.read(); + assert!(storage.monad_block_participants[&first_block_hash].contains(&sender)); + assert!(storage.monad_block_participants[&second_block_hash].contains(&sender)); + } + + let outcome = loaded_api.backend.mine_block(vec![]).await.unwrap(); + assert_eq!(outcome.block_number, 3); + } + + #[cfg(feature = "monad")] + #[tokio::test] + async fn monad_load_state_rejection_is_atomic() { + use alloy_consensus::BlockHeader as _; + use alloy_network::TransactionBuilder as _; + use alloy_provider::Provider as _; + + let config = || { + NodeConfig::test_monad() + .with_hardfork(Some(foundry_evm::hardfork::MonadHardfork::MonadNine.into())) + .with_transaction_block_keeper(Some(1usize)) + }; + let (source_api, source_handle) = spawn(config()).await; + let source_provider = source_handle.http_provider(); + let accounts = source_provider.get_accounts().await.unwrap(); + let sentinel = alloy_primitives::Address::repeat_byte(0x77); + + source_api.anvil_set_balance(sentinel, U256::from(123)).await.unwrap(); + for nonce in 0..2 { + source_provider + .send_transaction( + alloy_rpc_types::TransactionRequest::default() + .with_from(accounts[0]) + .with_to(accounts[1]) + .with_nonce(nonce) + .with_value(U256::ONE) + .into(), + ) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + } + + let mut invalid_state = source_api.serialized_state(false).await.unwrap(); + let pruned_block_hash = invalid_state + .blocks + .iter() + .find(|block| { + block.transactions.is_empty() + && block.header.transactions_root() + != alloy_consensus::constants::EMPTY_ROOT_HASH + }) + .unwrap() + .header + .hash_slow(); + assert!(invalid_state.monad_block_participants.remove(&pruned_block_hash).is_some()); + + let (target_api, _handle) = spawn(config()).await; + target_api.anvil_set_balance(sentinel, U256::from(7)).await.unwrap(); + target_api.mine_one().await.unwrap(); + let original_best_hash = target_api.backend.best_hash(); + let original_best_number = target_api.backend.best_number(); + let original_block_env = target_api.backend.evm_env.read().block_env.clone(); + let original_balance = target_api.backend.current_balance(sentinel).await.unwrap(); + + let err = target_api.backend.load_state(invalid_state).await.unwrap_err(); + assert!(matches!(err, super::BlockchainError::DataUnavailable)); + assert_eq!(target_api.backend.best_hash(), original_best_hash); + assert_eq!(target_api.backend.best_number(), original_best_number); + assert_eq!(target_api.backend.evm_env.read().block_env, original_block_env); + assert_eq!(target_api.backend.current_balance(sentinel).await.unwrap(), original_balance); + + let outcome = target_api.backend.mine_block(vec![]).await.unwrap(); + assert_eq!(outcome.block_number, original_best_number + 1); + } + + #[tokio::test] + async fn trace_decoder_follows_executed_hardfork_for_cross_namespace_override() { + let (api, _) = spawn( + NodeConfig::test_tempo() + .with_hardfork(Some(FoundryHardfork::Ethereum(EthereumHardfork::Prague))), + ) + .await; + + let decoder = api.backend.call_trace_decoder(); + assert_eq!(decoder.hardfork(), Some(FoundryHardfork::Tempo(api.backend.tempo_hardfork()))); + // The refresh compares the same coerced value, so repeated calls stay stable. + assert!(Arc::ptr_eq(&decoder, &api.backend.call_trace_decoder())); + } + + #[cfg(feature = "monad")] + #[tokio::test] + async fn monad_trace_decoder_follows_resolved_hardfork() { + let (monad_eight, _) = spawn( + NodeConfig::test_monad() + .with_hardfork(Some(foundry_evm::hardfork::MonadHardfork::MonadEight.into())), + ) + .await; + let stale_monad_nine = foundry_evm::traces::CallTraceDecoderBuilder::new() + .with_hardfork(Some(foundry_evm::hardfork::MonadHardfork::MonadNine.into())) + .build(); + *monad_eight.backend.call_trace_decoder.write() = Arc::new(stale_monad_nine); + + let decoder = monad_eight.backend.call_trace_decoder(); + assert_eq!( + decoder.hardfork(), + Some(foundry_evm::hardfork::MonadHardfork::MonadEight.into()) + ); + assert!( + !decoder + .labels + .contains_key(&monad_revm::reserve_balance::abi::RESERVE_BALANCE_ADDRESS) + ); + + let (monad_nine, _) = spawn( + NodeConfig::test_monad() + .with_hardfork(Some(foundry_evm::hardfork::MonadHardfork::MonadNine.into())), + ) + .await; + let stale_monad_eight = foundry_evm::traces::CallTraceDecoderBuilder::new() + .with_hardfork(Some(foundry_evm::hardfork::MonadHardfork::MonadEight.into())) + .build(); + *monad_nine.backend.call_trace_decoder.write() = Arc::new(stale_monad_eight); + + let decoder = monad_nine.backend.call_trace_decoder(); + assert_eq!( + decoder.hardfork(), + Some(foundry_evm::hardfork::MonadHardfork::MonadNine.into()) + ); + assert_eq!( + decoder + .labels + .get(&monad_revm::reserve_balance::abi::RESERVE_BALANCE_ADDRESS) + .map(String::as_str), + Some("ReserveBalance") + ); + } + + #[cfg(feature = "base")] + #[tokio::test] + async fn base_trace_decoder_follows_resolved_upgrade() { + let (azul, _) = + spawn(NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Azul.into()))).await; + let stale_beryl = + CallTraceDecoderBuilder::new().with_hardfork(Some(BaseUpgrade::Beryl.into())).build(); + *azul.backend.call_trace_decoder.write() = Arc::new(stale_beryl); + + let decoder = azul.backend.call_trace_decoder(); + assert_eq!(decoder.hardfork(), Some(BaseUpgrade::Azul.into())); + assert!(!decoder.labels.contains_key(&B20FactoryStorage::ADDRESS)); + assert!(!decoder.labels.contains_key(&ActivationRegistryStorage::ADDRESS)); + + let (beryl, _) = + spawn(NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Beryl.into()))).await; + let stale_azul = + CallTraceDecoderBuilder::new().with_hardfork(Some(BaseUpgrade::Azul.into())).build(); + *beryl.backend.call_trace_decoder.write() = Arc::new(stale_azul); + + let decoder = beryl.backend.call_trace_decoder(); + assert_eq!(decoder.hardfork(), Some(BaseUpgrade::Beryl.into())); + assert_eq!( + decoder.labels.get(&B20FactoryStorage::ADDRESS).map(String::as_str), + Some("B20Factory") + ); + assert_eq!( + decoder.labels.get(&ActivationRegistryStorage::ADDRESS).map(String::as_str), + Some("ActivationRegistry") + ); } } diff --git a/crates/anvil/src/eth/backend/mem/monad.rs b/crates/anvil/src/eth/backend/mem/monad.rs new file mode 100644 index 0000000000000..5b7cd596bb18f --- /dev/null +++ b/crates/anvil/src/eth/backend/mem/monad.rs @@ -0,0 +1,934 @@ +//! Monad-specific execution and context helpers for the in-memory backend. + +use super::{ + AnvilInspector, Backend, ClientFork, DatabaseRef, EnvelopeExecutionKind, MonadExecutionContext, + storage::BlockchainStorage, +}; +use crate::eth::{ + backend::{ + db::MonadBlockReplayProfile, + executor::{ + AnvilBlockExecutor, AnvilTxResult, ExecutedPoolTransactions, PoolTxGasConfig, + build_tx_env_for_pending, execute_pool_transactions, + }, + replay::{ + ExecutedHistoricalReplay, HistoricalReplayTransaction, execute_historical_replay_with, + }, + }, + error::{BlockchainError, InvalidTransactionError}, + fees::FeeManager, + pool::transactions::PoolTransaction, +}; +use alloy_consensus::{ + BlockHeader, Header, Transaction as _, constants::EMPTY_ROOT_HASH, transaction::Recovered, +}; +use alloy_eips::eip1559::BaseFeeParams; +use alloy_evm::{ + Database, Evm, EvmEnv, EvmFactory, RecoveredTx, + block::{ + BalIndexedDatabase, BlockExecutionError, BlockExecutionResult, BlockExecutor, StateDB, + }, +}; +use alloy_monad_evm::{MonadContext, MonadEvm, MonadEvmFactory}; +use alloy_network::{BlockResponse, Network}; +use alloy_primitives::{B256, U256}; +use alloy_rpc_types::{AccessList, BlockNumberOrTag as BlockNumber, BlockTransactions}; +use anvil_core::eth::{ + block::Block, + transaction::{MaybeImpersonatedTransaction, PendingTransaction}, +}; +use eyre::{Context, Result}; +use foundry_evm::{ + backend::DatabaseError, + core::{ + FoundryChain, FromAnyRpcTransaction, + evm::{ + MonadBlockParticipants, monad_block_participants, monad_context_from_participants, + protocol_system_call, try_transact_monad_system_replay, + }, + }, + hardfork::FoundryHardfork, + utils::get_blob_params, +}; +use foundry_primitives::{FoundryReceiptEnvelope, FoundryTxEnvelope}; +use monad_revm::{MonadChainContext, MonadHardfork, instructions::monad_gas_params}; +use revm::{ + Inspector, + context::{Transaction, TxEnv}, + context_interface::{ + block::BlobExcessGasAndPrice, + result::{HaltReason, InvalidTransaction, ResultAndState}, + transaction::AuthorizationTr, + }, + database_interface::WrapDatabaseRef, + primitives::hardfork::SpecId, + state::AccountInfo, +}; +use std::sync::Arc; +use tracing::debug; + +/// Resolved Monad inputs, including a transaction context for standalone calls. +pub(super) struct PreparedExecution { + pub(super) context: MonadChainContext, + pub(super) kind: EnvelopeExecutionKind, + pub(super) hardfork: MonadHardfork, +} + +pub(super) struct ForkReplay { + hardfork: FoundryHardfork, + context: Option, + participants: MonadBlockParticipants, + execution_chain_id: u64, + source_chain_id: u64, + timestamp: u64, + inferred_hardfork: bool, +} + +impl ForkReplay { + pub(super) const fn hardfork(&self) -> FoundryHardfork { + self.hardfork + } + + pub(super) const fn take_context(&mut self) -> Option { + self.context.take() + } + + pub(super) fn store_metadata( + &mut self, + storage: &mut BlockchainStorage, + block_hash: B256, + ) { + store_block_metadata( + storage, + block_hash, + std::mem::take(&mut self.participants), + self.execution_chain_id, + self.hardfork, + ); + } +} + +/// Monad execution state staged while rewinding to a retained canonical block. +pub(super) struct MonadRollbackProfile { + hardfork: FoundryHardfork, + fees: FeeManager, + block_blob_excess_gas_and_price: Option, + publish_inferred_hardfork: bool, +} + +impl MonadRollbackProfile { + /// Rewinds cfg/spec, gas params, and the retained block's fee environment. + pub(super) fn apply_env(&self, env: &mut EvmEnv, common_block: &Block) { + env.cfg_env.set_spec_and_mainnet_gas_params(SpecId::from(self.hardfork)); + env.block_env.basefee = common_block.header.base_fee_per_gas.unwrap_or_default(); + env.block_env.blob_excess_gas_and_price = self.block_blob_excess_gas_and_price; + } +} + +pub(super) fn normalize_access_list( + mut access_list: AccessList, + hardfork: MonadHardfork, +) -> AccessList { + if MonadHardfork::MonadTen.is_enabled_in(hardfork) { + for item in &mut access_list.0 { + item.storage_keys.sort_unstable(); + item.storage_keys.dedup_by_key(|slot| { + monad_revm::page::page_index(U256::from_be_slice(slot.as_slice())) + }); + } + } + access_list +} + +/// Caches the fork blocks needed to construct the next Monad block's ancestor context. +pub(super) async fn cache_fork_context(fork: &ClientFork) -> Result<(), BlockchainError> { + let block = + fork.block_by_hash_full(fork.block_hash()).await?.ok_or(BlockchainError::BlockNotFound)?; + let parent_hash = block.header().parent_hash(); + if !parent_hash.is_zero() { + fork.block_by_hash_full(parent_hash).await?.ok_or(BlockchainError::BlockNotFound)?; + } + Ok(()) +} + +/// Executes one Monad pool candidate and restores its context if execution fails before inclusion. +fn execute_pool_transaction( + executor: &mut AnvilBlockExecutor>, + tx_env: TxEnv, + recovered: Recovered, + is_replay: bool, +) -> Result, BlockExecutionError> +where + DB: StateDB + BalIndexedDatabase, +{ + prepare_transaction(executor.evm_mut(), &tx_env); + let result = (|| { + if !is_replay { + return executor.execute_transaction_without_commit((tx_env, recovered)); + } + match protocol_system_call(&tx_env) { + Ok(None) => return executor.execute_transaction_without_commit((tx_env, recovered)), + Ok(Some(_)) => {} + Err(err) => return Err(BlockExecutionError::msg(err)), + } + executor.execute_transaction_without_commit_with( + (tx_env, recovered), + |evm, tx_env, transaction_hash| { + try_transact_monad_system_replay(evm, &tx_env) + .map_err(|err| { + BlockExecutionError::msg(format!( + "failed to replay Monad transaction {transaction_hash}: {err}" + )) + })? + .ok_or_else(|| { + BlockExecutionError::msg(format!( + "Monad transaction {transaction_hash} is not a canonical replay envelope" + )) + }) + }, + ) + })(); + if result.is_err() { + rollback_transaction(executor.evm_mut()); + } + result +} + +/// Adds a candidate transaction to the current Monad block context. +fn append_transaction(chain: &mut MonadChainContext, tx: &TxEnv) { + chain.current_tx_index = chain.current_block_senders.len(); + chain.current_block_senders.push(tx.caller()); + chain.current_block_authorities.push( + tx.authorization_list().filter_map(|authorization| authorization.authority()).collect(), + ); +} + +pub(super) fn prepare_transaction( + evm: &mut MonadEvm, + tx: &TxEnv, +) { + append_transaction(&mut evm.ctx_mut().chain, tx); +} + +pub(super) fn resolve_execution_context( + context: Option>, + tx: &TxEnv, +) -> MonadChainContext { + match context { + Some(MonadExecutionContext::Exact(context)) => *context, + Some(MonadExecutionContext::Next(context)) => { + append_transaction(context, tx); + context.clone() + } + None => MonadChainContext::for_transaction(tx), + } +} + +pub(super) fn advance_block_context(context: &mut Option) { + let Some(context) = context else { return }; + let current = context + .current_block_senders + .iter() + .copied() + .chain(context.current_block_authorities.iter().flatten().copied()) + .collect(); + context.grandparent_senders_and_authorities = + std::mem::replace(&mut context.parent_senders_and_authorities, current); + context.current_block_senders.clear(); + context.current_block_authorities.clear(); + context.current_tx_index = 0; +} + +/// Removes a candidate transaction that failed before block inclusion. +pub(super) fn rollback_transaction( + evm: &mut MonadEvm, +) { + let chain = &mut evm.ctx_mut().chain; + chain.current_block_senders.pop(); + chain.current_block_authorities.pop(); + chain.current_tx_index = chain.current_block_senders.len(); +} + +impl Backend { + /// Returns the remote rollback ancestor for a Monad transaction-hash fork. + pub(crate) async fn monad_rollback_block( + &self, + number: u64, + ) -> Result, BlockchainError> { + let Some(fork) = self.get_fork().filter(|fork| { + fork.transaction_hash().is_some() && fork.predates_fork_inclusive(number) + }) else { + return Ok(None); + }; + let Some(block) = fork.block_by_number(number).await? else { + return Ok(None); + }; + let header = Header::try_from(block.header().inner.clone()) + .map_err(|err| BlockchainError::Internal(err.to_string()))?; + Ok(Some(Block { header: header.into(), body: Default::default() })) + } + + /// Prepares the Monad-specific inputs for replaying a historical transaction prefix. + pub(super) async fn prepare_monad_fork_replay( + &self, + source_chain_id: u64, + execution_chain_id: u64, + timestamp: u64, + parent_hash: B256, + transactions: &[HistoricalReplayTransaction], + ) -> Result> { + if !self.is_monad() { + return Ok(None); + } + + let explicit_hardfork = self.node_config.read().await.hardfork; + // The target block's schedule can cross a hardfork boundary after the selected parent. + // Explicit overrides still take precedence over timestamp inference. + let hardfork = explicit_hardfork + .or_else(|| { + MonadHardfork::from_chain_and_timestamp(source_chain_id, timestamp) + .map(FoundryHardfork::Monad) + }) + .unwrap_or_else(|| self.hardfork()); + let context = self.monad_context_for_child_of_block_hash(parent_hash).await?; + let participants = + monad_block_participants(&self.monad_historical_replay_tx_envs(transactions)); + + Ok(Some(ForkReplay { + hardfork, + context: Some(context), + participants, + execution_chain_id, + source_chain_id, + timestamp, + inferred_hardfork: explicit_hardfork.is_none(), + })) + } + + /// Applies the Monad execution rules selected for a completed fork replay. + pub(super) fn finalize_monad_fork_replay(&self, replay: &ForkReplay, evm_env: &mut EvmEnv) { + let spec_id = SpecId::from(replay.hardfork()); + evm_env.cfg_env.set_spec_and_mainnet_gas_params(spec_id); + self.fees.set_execution_rules( + spec_id, + self.networks.base_fee_params(replay.timestamp), + None, + ); + self.fees.set_blob_params(get_blob_params(replay.source_chain_id, replay.timestamp)); + + if replay.inferred_hardfork { + *self.hardfork.write() = replay.hardfork(); + if let Some(fork) = self.fork.read().clone() { + fork.config.write().hardfork = Some(replay.hardfork()); + } + } + } + + /// Stages the Monad fork protocol profile and next-block fee state for a retained block. + pub(super) async fn prepare_monad_rollback_profile( + &self, + common_block: &Block, + ) -> Option { + // Local nodes keep their configured profile, even when the execution chain ID changes. + let fork = self.get_fork()?; + + let explicit_hardfork = self.node_config.read().await.hardfork; + let block_hash = common_block.header.hash_slow(); + let stored_hardfork = self + .blockchain + .storage + .read() + .monad_block_replay_profiles + .get(&block_hash) + .map(|profile| profile.hardfork); + let endpoint_hardfork = { + let config = fork.config.read(); + if config.block_number != common_block.header.number() + || config.block_hash != block_hash + { + None + } else { + match config.endpoint_identity.hardfork { + Some(FoundryHardfork::Monad(hardfork)) => Some(hardfork), + _ => None, + } + } + }; + let source_chain_id = fork.chain_id(); + let scheduled_hardfork = MonadHardfork::from_chain_and_timestamp( + source_chain_id, + common_block.header.timestamp(), + ); + let hardfork = explicit_hardfork.unwrap_or_else(|| { + FoundryHardfork::Monad( + stored_hardfork + .or(endpoint_hardfork) + .or(scheduled_hardfork) + .unwrap_or_else(|| self.monad_hardfork()), + ) + }); + let spec_id = SpecId::from(hardfork); + let timestamp = common_block.header.timestamp(); + let blob_params = get_blob_params(source_chain_id, timestamp); + let block_base_fee = common_block.header.base_fee_per_gas.unwrap_or_default(); + + let fees = self.fees.detached(); + fees.set_execution_rules(spec_id, BaseFeeParams::ethereum(), None); + fees.set_blob_params(blob_params); + // The fee manager stores values for the next block. Seed it with the retained block while + // deriving those values so the zero-base-fee sentinel and Osaka blob target are applied to + // the correct parent. + fees.set_base_fee(block_base_fee); + let next_block_base_fee = fees.get_next_block_base_fee_from_header(&common_block.header); + let next_block_excess_blob_gas = blob_params.next_block_excess_blob_gas_osaka( + common_block.header.excess_blob_gas.unwrap_or_default(), + common_block.header.blob_gas_used.unwrap_or_default(), + block_base_fee, + ); + fees.set_base_fee(next_block_base_fee); + fees.set_blob_excess_gas_and_price(BlobExcessGasAndPrice::new( + next_block_excess_blob_gas, + blob_params.update_fraction as u64, + )); + + Some(MonadRollbackProfile { + hardfork, + fees, + block_blob_excess_gas_and_price: common_block.header.excess_blob_gas.map(|excess| { + BlobExcessGasAndPrice::new(excess, blob_params.update_fraction as u64) + }), + publish_inferred_hardfork: explicit_hardfork.is_none(), + }) + } + + /// Returns transaction fee defaults for the retained block's staged profile. + pub(crate) async fn monad_rollback_fee_defaults( + &self, + common_block: &Block, + suggested_tip: u128, + ) -> Option<(u128, u128)> { + let profile = self.prepare_monad_rollback_profile(common_block).await?; + let gas_price = if profile.fees.is_eip1559() { + let base_fee = profile.fees.base_fee() as u128; + if profile.fees.is_min_priority_fee_enforced() { + base_fee.saturating_add(suggested_tip) + } else { + base_fee + } + } else { + profile.fees.raw_gas_price() + }; + Some((gas_price, profile.fees.get_next_block_blob_base_fee_per_gas())) + } + + /// Rewinds state and publishes the Monad profile selected for the retained block. + pub(crate) async fn rollback_monad( + &self, + common_block: Block, + mining_guard: &tokio::sync::MutexGuard<'_, ()>, + ) -> Result<(), BlockchainError> + where + N: Network, + { + let profile = self.prepare_monad_rollback_profile(&common_block).await; + self.rollback(common_block.clone(), mining_guard).await?; + if let Some(profile) = profile { + profile.apply_env(&mut self.evm_env.write(), &common_block); + self.publish_monad_rollback_profile(profile); + } + Ok(()) + } + + /// Publishes the staged Monad fee rules and inferred hardfork after a rewind. + pub(super) fn publish_monad_rollback_profile(&self, profile: MonadRollbackProfile) { + self.fees.replace_from(&profile.fees); + if profile.publish_inferred_hardfork { + *self.hardfork.write() = profile.hardfork; + if let Some(fork) = self.fork.read().clone() { + fork.config.write().hardfork = Some(profile.hardfork); + } + } + } + + /// Validates Monad-specific transaction type restrictions. + pub(super) const fn validate_monad_transaction_type( + &self, + tx: &FoundryTxEnvelope, + ) -> Result<(), InvalidTransactionError> { + if self.is_monad() && tx.is_eip4844() { + return Err(InvalidTransactionError::MonadBlobTransactionUnsupported); + } + Ok(()) + } + + /// Validates Monad's gas-only transaction balance requirement. + /// + /// Returns whether the transaction was validated as a Monad transaction. + pub(super) fn validate_monad_transaction_funds( + &self, + pending: &PendingTransaction, + account: &AccountInfo, + evm_env: &EvmEnv, + ) -> Result { + if !self.is_monad() { + return Ok(false); + } + + let tx = &pending.transaction; + let effective_gas_price = tx.effective_gas_price(Some(evm_env.block_env.basefee)); + let required = U256::from(tx.gas_limit()) * U256::from(effective_gas_price); + if account.balance < required { + debug!(target: "backend", "[{:?}] insufficient balance={}, required={} account={:?}", tx.hash(), account.balance, required, *pending.sender()); + return Err(InvalidTransactionError::InsufficientFunds); + } + Ok(true) + } + + /// Validates a forced Monad protocol transaction selected for mining. + /// + /// Returns whether the transaction was recognized and fully validated as a protocol call. + pub(super) fn validate_monad_mining_pool_transaction_for( + &self, + pool_tx: &PoolTransaction, + account: &AccountInfo, + evm_env: &EvmEnv, + ) -> Result { + if !self.is_monad() || !pool_tx.is_replay { + return Ok(false); + } + + let tx_env: TxEnv = build_tx_env_for_pending(&pool_tx.pending_transaction, self.cheats()); + let Some(system_call) = protocol_system_call(&tx_env).map_err(|err| { + InvalidTransactionError::Revm(InvalidTransaction::Str(err.to_string().into())) + })? + else { + return Ok(false); + }; + if system_call.chain_id.is_some_and(|chain_id| chain_id != evm_env.cfg_env.chain_id) { + return Err(InvalidTransactionError::InvalidChainId); + } + if system_call.nonce < account.nonce { + return Err(InvalidTransactionError::NonceTooLow); + } + if system_call.nonce > account.nonce { + return Err(InvalidTransactionError::NonceTooHigh); + } + if system_call.nonce == u64::MAX { + return Err(InvalidTransactionError::NonceMaxValue); + } + Ok(true) + } + + /// Executes a candidate block through a concrete Monad EVM. + #[allow(clippy::too_many_arguments, clippy::type_complexity)] + pub(super) fn execute_with_monad_block_executor( + &self, + db: DB, + evm_env: &EvmEnv, + parent_hash: B256, + spec_id: SpecId, + hardfork: MonadHardfork, + pool_transactions: &[Arc>], + gas_config: &PoolTxGasConfig, + inspector_tx_config: &crate::mem::inspector::InspectorTxConfig, + validator: &dyn Fn( + &PoolTransaction, + &AccountInfo, + ) -> Result<(), InvalidTransactionError>, + ) -> Result< + (ExecutedPoolTransactions, BlockExecutionResult), + BlockchainError, + > + where + DB: StateDB + BalIndexedDatabase, + { + let monad_env = Self::build_monad_evm_env(evm_env, hardfork); + let inspector = self.build_mining_inspector(); + let mut evm = + MonadEvmFactory::default().create_evm_with_inspector(db, monad_env, inspector); + let transaction_context = self + .monad_context_for_child_of(parent_hash) + .expect("Monad ancestor context must be available before block execution"); + evm.ctx_mut().chain = transaction_context; + self.inject_precompiles(evm.precompiles_mut(), evm_env); + + let mut executor = AnvilBlockExecutor::new(evm, parent_hash, spec_id, None) + .with_max_blob_gas_per_block(gas_config.max_blob_gas_per_block); + executor + .apply_pre_execution_changes() + .map_err(|err| BlockchainError::Internal(err.to_string()))?; + let pool_result = execute_pool_transactions( + &mut executor, + pool_transactions, + gas_config, + inspector_tx_config, + self.cheats(), + validator, + &mut execute_pool_transaction, + ); + let (evm, block_result) = + executor.finish().map_err(|err| BlockchainError::Internal(err.to_string()))?; + drop(evm); + Ok((pool_result, block_result)) + } + + /// Executes a historical transaction prefix through a concrete Monad EVM. + #[allow(clippy::too_many_arguments)] + pub(super) fn execute_with_monad_replay_block_executor( + &self, + db: DB, + evm_env: &EvmEnv, + parent_hash: B256, + hardfork: MonadHardfork, + transactions: &[HistoricalReplayTransaction], + inspector_tx_config: &crate::mem::inspector::InspectorTxConfig, + transaction_context: MonadChainContext, + ) -> Result + where + DB: StateDB + BalIndexedDatabase, + { + let monad_env = Self::build_monad_evm_env(evm_env, hardfork); + let inspector = self.build_mining_inspector(); + let mut evm = + MonadEvmFactory::default().create_evm_with_inspector(db, monad_env, inspector); + evm.ctx_mut().chain = transaction_context; + self.inject_precompiles(evm.precompiles_mut(), evm_env); + + let mut executor = AnvilBlockExecutor::new(evm, parent_hash, *evm_env.spec_id(), None) + .with_state_changes(); + executor + .apply_pre_execution_changes() + .wrap_err("failed to apply replay block-start transitions")?; + let (transactions, transaction_infos) = execute_historical_replay_with( + &mut executor, + transactions, + inspector_tx_config, + |evm, tx_env, _transaction_hash| { + prepare_transaction(evm, &tx_env); + let result = match try_transact_monad_system_replay(evm, &tx_env) + .map_err(BlockExecutionError::msg) + { + Ok(Some(result)) => Ok(result), + Ok(None) => evm.transact(tx_env).map_err(BlockExecutionError::msg), + Err(err) => Err(err), + }; + if result.is_err() { + rollback_transaction(evm); + } + result + }, + )?; + let state_changes = executor.take_state_changes(); + let (evm, block_result) = + executor.finish().wrap_err("failed to finish replay block execution")?; + drop(evm); + Ok(ExecutedHistoricalReplay { + block_result, + transactions, + transaction_infos, + state_changes, + }) + } + + /// Reconstructs a locally mined transaction using its authoritative stored sender. + pub(super) fn monad_pending_mined_transaction_from_storage( + storage: &BlockchainStorage, + transaction: MaybeImpersonatedTransaction, + ) -> Result, BlockchainError> { + let transaction_hash = transaction.hash(); + let mined = + storage.transactions.get(&transaction_hash).ok_or(BlockchainError::DataUnavailable)?; + Ok(PendingTransaction::with_sender(transaction, mined.info.from)) + } + + /// Converts retained local transactions using their authoritative mined senders. + fn monad_tx_envs_from_storage( + &self, + storage: &BlockchainStorage, + transactions: &[MaybeImpersonatedTransaction], + ) -> Result, BlockchainError> { + transactions + .iter() + .map(|transaction| { + let pending = Self::monad_pending_mined_transaction_from_storage( + storage, + transaction.clone(), + )?; + Ok(build_tx_env_for_pending::(&pending, self.cheats())) + }) + .collect() + } + + /// Converts a historical replay prefix using its prepared authoritative senders. + pub(super) fn monad_historical_replay_tx_envs( + &self, + transactions: &[HistoricalReplayTransaction], + ) -> Vec { + transactions + .iter() + .map(|replay| { + let pending = PendingTransaction::with_sender( + MaybeImpersonatedTransaction::new(replay.transaction.tx().clone()), + replay.transaction.signer(), + ); + build_tx_env_for_pending::(&pending, self.cheats()) + }) + .collect() + } + + /// Returns a block's number, parent hash, and cached participants. + fn monad_block_participants_from_storage( + &self, + storage: &BlockchainStorage, + hash: B256, + ) -> Result<(u64, B256, MonadBlockParticipants), BlockchainError> { + if let Some(block) = storage.blocks.get(&hash) { + let participants = match storage.monad_block_participants.get(&hash) { + Some(participants) => participants.clone(), + None if block.body.transactions.is_empty() + && block.header.transactions_root() != EMPTY_ROOT_HASH => + { + return Err(BlockchainError::DataUnavailable); + } + None => monad_block_participants( + &self.monad_tx_envs_from_storage(storage, &block.body.transactions)?, + ), + }; + return Ok((block.header.number(), block.header.parent_hash, participants)); + } + + let fork = self.get_fork().ok_or(BlockchainError::BlockNotFound)?; + let block = fork + .storage + .read() + .blocks + .get(&hash) + .cloned() + .ok_or(BlockchainError::DataUnavailable)?; + let BlockTransactions::Full(transactions) = block.transactions() else { + return Err(BlockchainError::DataUnavailable); + }; + let tx_envs = transactions + .iter() + .map(TxEnv::from_any_rpc_transaction) + .collect::>>()?; + Ok(( + block.header().number(), + block.header().parent_hash(), + monad_block_participants(&tx_envs), + )) + } + + fn monad_block_participants( + &self, + hash: B256, + ) -> Result<(u64, B256, MonadBlockParticipants), BlockchainError> { + self.monad_block_participants_from_storage(&self.blockchain.storage.read(), hash) + } + + /// Rebuilds participant metadata for locally stored blocks with transaction bodies. + pub(super) fn rebuild_monad_block_participant_cache( + &self, + storage: &mut BlockchainStorage, + ) -> Result<(), BlockchainError> { + let participants = storage + .blocks + .iter() + .filter(|(hash, block)| { + !storage.monad_block_participants.contains_key(*hash) + && (!block.body.transactions.is_empty() + || block.header.transactions_root() == EMPTY_ROOT_HASH) + }) + .map(|(hash, block)| (*hash, block.body.transactions.clone())) + .map(|(hash, transactions)| { + let tx_envs = self.monad_tx_envs_from_storage(storage, &transactions)?; + Ok((hash, monad_block_participants(&tx_envs))) + }) + .collect::, BlockchainError>>()?; + + for (hash, participants) in participants { + if storage.blocks.contains_key(&hash) { + storage.monad_block_participants.insert(hash, participants); + } + } + Ok(()) + } + + /// Builds the initial context for a block whose parent is `parent_hash`. + pub(super) fn monad_context_for_child_of( + &self, + parent_hash: B256, + ) -> Result { + self.monad_context_for_child_of_in_storage(&self.blockchain.storage.read(), parent_hash) + } + + /// Builds the initial context from staged storage. + pub(super) fn monad_context_for_child_of_in_storage( + &self, + storage: &BlockchainStorage, + parent_hash: B256, + ) -> Result { + let (_, grandparent_hash, parent) = + self.monad_block_participants_from_storage(storage, parent_hash)?; + let grandparent = if grandparent_hash.is_zero() { + MonadBlockParticipants::default() + } else { + self.monad_block_participants_from_storage(storage, grandparent_hash)?.2 + }; + Ok(monad_context_from_participants(grandparent, parent, &[], 0)) + } + + async fn monad_context_for_child_of_block( + &self, + block: alloy_network::AnyRpcBlock, + block_hash: B256, + ) -> Result { + let parent_hash = block.header().parent_hash(); + if !parent_hash.is_zero() { + self.block_by_hash_full(parent_hash).await?.ok_or(BlockchainError::DataUnavailable)?; + } + self.monad_context_for_child_of(block_hash) + } + + /// Fetches the full blocks required to build context on top of `block_number`. + pub(super) async fn monad_context_for_child_of_block_number( + &self, + block_number: u64, + ) -> Result { + let block = self + .block_by_number_full(BlockNumber::Number(block_number)) + .await? + .ok_or(BlockchainError::BlockNotFound)?; + let block_hash = block.header().hash; + self.monad_context_for_child_of_block(block, block_hash).await + } + + /// Fetches the full blocks required to build context on top of `block_hash`. + pub(super) async fn monad_context_for_child_of_block_hash( + &self, + block_hash: B256, + ) -> Result { + let block = + self.block_by_hash_full(block_hash).await?.ok_or(BlockchainError::BlockNotFound)?; + self.monad_context_for_child_of_block(block, block_hash).await + } + + fn monad_context_for_mined_transactions( + &self, + block: &Block, + current: &[TxEnv], + current_tx_index: usize, + ) -> Result { + let (_, grandparent_hash, parent) = + self.monad_block_participants(block.header.parent_hash)?; + let grandparent = if grandparent_hash.is_zero() { + MonadBlockParticipants::default() + } else { + self.monad_block_participants(grandparent_hash)?.2 + }; + Ok(monad_context_from_participants(grandparent, parent, current, current_tx_index)) + } + + fn monad_context_for_mined_block( + &self, + block: &Block, + ) -> Result { + let current = self.monad_tx_envs_from_storage( + &self.blockchain.storage.read(), + &block.body.transactions, + )?; + self.monad_context_for_mined_transactions(block, ¤t, 0) + } + + pub(super) fn active_monad_context_for_mined_block( + &self, + block: &Block, + ) -> Result, BlockchainError> { + self.is_monad().then(|| self.monad_context_for_mined_block(block)).transpose() + } + + fn monad_context_before_mined_transaction( + &self, + block: &Block, + current_tx_index: usize, + ) -> Result { + let current = self.monad_tx_envs_from_storage( + &self.blockchain.storage.read(), + &block.body.transactions, + )?; + if current_tx_index > current.len() { + return Err(BlockchainError::DataUnavailable); + } + self.monad_context_for_mined_transactions( + block, + ¤t[..current_tx_index], + current_tx_index, + ) + } + + pub(super) fn active_monad_context_before_mined_transaction( + &self, + block: &Block, + current_tx_index: usize, + ) -> Result, BlockchainError> { + self.is_monad() + .then(|| self.monad_context_before_mined_transaction(block, current_tx_index)) + .transpose() + } + + /// Builds the Monad [`EvmEnv`] (spec and gas params) from a base env. + pub(super) fn build_monad_evm_env( + evm_env: &EvmEnv, + hardfork: MonadHardfork, + ) -> EvmEnv { + EvmEnv::new( + evm_env.cfg_env.clone().with_spec_and_gas_params(hardfork, monad_gas_params(hardfork)), + evm_env.block_env.clone(), + ) + } + + /// Monad path of [`Backend::transact_call_with_inspector_ref`]. + pub(super) fn transact_monad_with_inspector_ref<'db, I, DB>( + &self, + db: &'db DB, + evm_env: &EvmEnv, + inspector: &mut I, + tx_env: TxEnv, + execution: PreparedExecution, + ) -> Result, BlockchainError> + where + DB: DatabaseRef + ?Sized, + I: Inspector>>, + WrapDatabaseRef<&'db DB>: Database, + { + let monad_env = Self::build_monad_evm_env(evm_env, execution.hardfork); + let factory = MonadEvmFactory::default(); + let mut evm = factory.create_evm_with_inspector(WrapDatabaseRef(db), monad_env, inspector); + evm.ctx_mut().chain = execution.context; + self.inject_configured_precompiles(evm.precompiles_mut(), evm_env); + match execution.kind { + EnvelopeExecutionKind::Transaction => Ok(evm.transact(tx_env)?), + EnvelopeExecutionKind::Replay => { + if let Some(result) = try_transact_monad_system_replay(&mut evm, &tx_env)? { + Ok(result) + } else { + Ok(evm.transact(tx_env)?) + } + } + } + } +} + +pub(super) fn store_block_metadata( + storage: &mut BlockchainStorage, + block_hash: B256, + participants: MonadBlockParticipants, + execution_chain_id: u64, + hardfork: FoundryHardfork, +) { + storage.monad_block_participants.insert(block_hash, participants); + storage.monad_block_replay_profiles.insert( + block_hash, + MonadBlockReplayProfile { execution_chain_id, hardfork: MonadHardfork::from(hardfork) }, + ); +} diff --git a/crates/anvil/src/eth/backend/mem/optimism.rs b/crates/anvil/src/eth/backend/mem/optimism.rs new file mode 100644 index 0000000000000..28dd49b459da8 --- /dev/null +++ b/crates/anvil/src/eth/backend/mem/optimism.rs @@ -0,0 +1,56 @@ +//! Optimism-specific transact helpers for the in-memory backend. + +use super::Backend; +use crate::eth::error::BlockchainError; +use alloy_evm::{Database, Evm, EvmEnv, EvmFactory}; +use alloy_network::Network; +use alloy_op_evm::{OpEvmContext, OpEvmFactory, OpTx}; +use foundry_evm::backend::DatabaseError; +use op_revm::{OpHaltReason, OpSpecId, OpTransaction}; +use revm::{ + DatabaseRef, Inspector, + context::{ + TxEnv, + result::{HaltReason, ResultAndState}, + }, + database_interface::WrapDatabaseRef, +}; + +impl Backend { + /// Optimism path of [`Backend::transact_call_with_inspector_ref`]. + /// + /// Creates an OP EVM, injects precompiles, transacts, and maps the + /// OP-specific halt reason back to the shared [`HaltReason`]. + pub(super) fn transact_op_with_inspector_ref<'db, I, DB>( + &self, + db: &'db DB, + evm_env: &EvmEnv, + inspector: &mut I, + tx_env: OpTransaction, + spec: OpSpecId, + ) -> Result, BlockchainError> + where + DB: DatabaseRef + ?Sized, + I: Inspector>>, + WrapDatabaseRef<&'db DB>: Database, + { + let op_env = EvmEnv::new( + evm_env.cfg_env.clone().with_spec_and_mainnet_gas_params(spec), + evm_env.block_env.clone(), + ); + let mut evm = OpEvmFactory::default().create_evm_with_inspector( + WrapDatabaseRef(db), + op_env, + inspector, + ); + self.inject_configured_precompiles(evm.precompiles_mut(), evm_env); + let result = evm.transact(OpTx(tx_env))?; + Ok(ResultAndState { + result: result.result.map_haltreason(|h| match h { + OpHaltReason::Base(eth) => eth, + OpHaltReason::FailedDeposit => HaltReason::PrecompileError, + }), + state: result.state, + }) + } +} diff --git a/crates/anvil/src/eth/backend/mem/state.rs b/crates/anvil/src/eth/backend/mem/state.rs index 437f7974c1754..0bb2ef369a274 100644 --- a/crates/anvil/src/eth/backend/mem/state.rs +++ b/crates/anvil/src/eth/backend/mem/state.rs @@ -1,17 +1,470 @@ //! Support for generating the state root for memdb storage -use crate::eth::error::BlockchainError; -use alloy_primitives::{Address, B256, U256, keccak256, map::HashMap}; +use alloy_primitives::{ + B256, Bytes, U256, keccak256, + map::{AddressMap, B256Map, HashSet, U256Map}, +}; use alloy_rlp::Encodable; -use alloy_rpc_types::{BlockOverrides, state::StateOverride}; -use alloy_trie::{HashBuilder, Nibbles}; -use foundry_evm::backend::DatabaseError; +use alloy_trie::{ + EMPTY_ROOT_HASH, HashBuilder, Nibbles, TrieMask, + nodes::{BranchNodeRef, ExtensionNodeRef, LeafNodeRef, RlpNode}, +}; use revm::{ - bytecode::Bytecode, - context::BlockEnv, - database::{CacheDB, DatabaseRef, DbAccount}, - state::AccountInfo, + database::{AccountState, DbAccount}, + state::{Account, AccountInfo}, }; +use std::{array, mem}; + +/// Incrementally maintains the state trie used for block headers. +/// +/// The old state-root path rebuilt and sorted every account and storage trie after each block. +/// That made EIP-2935's block-hash storage contract turn mining into linear work as its ring was +/// populated. This cache keeps an in-memory Merkle Patricia trie and only rehashes paths changed +/// by the latest block. +#[derive(Debug, Default)] +pub struct StateRootCache { + trie: Option, + dirty: AddressMap, + /// Reused while encoding dirty trie nodes. + rlp_buf: Vec, +} + +#[derive(Debug, Default)] +struct DirtyAccount { + storage: HashSet, + reset_storage: bool, +} + +impl StateRootCache { + /// Records changes that will be committed to the database. + pub fn record_changes(&mut self, changes: &AddressMap) { + for (address, account) in changes { + if !account.is_touched() { + continue; + } + + let dirty = self.dirty.entry(*address).or_default(); + dirty.reset_storage |= account.is_created() || account.is_selfdestructed(); + dirty.storage.extend(account.changed_storage_slots().map(|(slot, _)| *slot)); + } + } + + /// Records an account-info change or a database load caused by `basic`. + pub fn record_account(&mut self, address: alloy_primitives::Address) { + self.dirty.entry(address).or_default(); + } + + /// Records a storage change or a database load caused by `storage`. + pub fn record_storage(&mut self, address: alloy_primitives::Address, slot: U256) { + self.dirty.entry(address).or_default().storage.insert(slot); + } + + /// Records a cumulative overlay relative to the overlay used for the previous root. + /// Returns false and invalidates the cache if entries disappeared or storage no longer + /// replaces the base. The caller must then supply full, merged state to `root`. + /// The base must not contain storage-bearing `NotExisting` accounts: their storage is + /// omitted from the trie but can reappear when a full database merge touches them. + pub fn record_overlay( + &mut self, + accounts: &AddressMap, + previous: &AddressMap, + ) -> bool { + let incremental = previous.iter().all(|(address, previous)| { + accounts.get(address).is_some_and(|account| { + matches!( + account.account_state, + AccountState::StorageCleared | AccountState::NotExisting + ) || (!matches!( + previous.account_state, + AccountState::StorageCleared | AccountState::NotExisting + ) && previous.storage.keys().all(|slot| account.storage.contains_key(slot))) + }) + }); + if !incremental { + self.invalidate(); + return false; + } + + for (address, account) in accounts { + let dirty = self.dirty.entry(*address).or_default(); + if account.account_state.is_storage_cleared() { + dirty.reset_storage = true; + } else { + dirty.storage.extend(account.storage.keys().copied()); + } + } + true + } + + /// Invalidates the trie after wholesale database replacement or clearing. + pub fn invalidate(&mut self) { + self.trie = None; + self.dirty.clear(); + } + + /// Returns the current root, applying only changes recorded since the previous call. + /// After initialization from full state, `accounts` may be a partial overlay containing + /// every dirty address; uncached accounts and storage slots retain their previous values. + pub fn root(&mut self, accounts: &AddressMap) -> B256 { + let Self { trie, dirty, rlp_buf } = self; + if trie.is_none() { + *trie = Some(IncrementalStateTrie::from_accounts(accounts, rlp_buf)); + dirty.clear(); + return trie.as_mut().unwrap().root(rlp_buf); + } + + let trie = trie.as_mut().unwrap(); + for (address, dirty) in mem::take(dirty) { + let hashed_address = keccak256(address); + let Some(account) = accounts + .get(&address) + .filter(|account| account.account_state != AccountState::NotExisting) + else { + trie.accounts.remove(hashed_address); + trie.storage.remove(&hashed_address); + continue; + }; + + let storage_trie = if dirty.reset_storage { + trie.storage + .entry(hashed_address) + .insert_entry(IncrementalTrie::from_storage(&account.storage)) + .into_mut() + } else { + let storage_trie = trie.storage.entry(hashed_address).or_default(); + for slot in dirty.storage { + let key = keccak256(slot.to_be_bytes::<32>()); + if let Some(value) = account.storage.get(&slot).filter(|value| !value.is_zero()) + { + storage_trie.insert(key, alloy_rlp::encode(value)); + } else { + storage_trie.remove(key); + } + } + storage_trie + }; + let storage_root = storage_trie.root_with_buf(rlp_buf); + trie.accounts.insert( + hashed_address, + trie_account_rlp_with_storage_root(&account.info, storage_root), + ); + } + + trie.root(rlp_buf) + } +} + +#[derive(Debug, Default)] +struct IncrementalStateTrie { + accounts: IncrementalTrie, + storage: B256Map, +} + +impl IncrementalStateTrie { + fn from_accounts(accounts: &AddressMap, rlp_buf: &mut Vec) -> Self { + let mut trie = Self::default(); + for (address, account) in accounts { + if account.account_state == AccountState::NotExisting { + continue; + } + let hashed_address = keccak256(address); + let mut storage_trie = IncrementalTrie::from_storage(&account.storage); + let storage_root = storage_trie.root_with_buf(rlp_buf); + trie.accounts.insert( + hashed_address, + trie_account_rlp_with_storage_root(&account.info, storage_root), + ); + trie.storage.insert(hashed_address, storage_trie); + } + trie + } + + fn root(&mut self, rlp_buf: &mut Vec) -> B256 { + self.accounts.root_with_buf(rlp_buf) + } +} + +/// A mutable Merkle Patricia trie that caches the RLP reference for every unchanged node. +#[derive(Debug, Default)] +struct IncrementalTrie { + root: TrieNode, +} + +impl IncrementalTrie { + fn from_storage(storage: &U256Map) -> Self { + let mut trie = Self::default(); + for (slot, value) in storage.iter().filter(|(_, value)| !value.is_zero()) { + trie.insert(keccak256(slot.to_be_bytes::<32>()), alloy_rlp::encode(value)); + } + trie + } + + fn insert(&mut self, key: B256, value: Vec) { + self.root.insert(Nibbles::unpack(key), value); + } + + fn remove(&mut self, key: B256) { + self.root.remove(Nibbles::unpack(key)); + } + + #[cfg(test)] + fn root(&mut self) -> B256 { + self.root_with_buf(&mut Vec::new()) + } + + fn root_with_buf(&mut self, rlp_buf: &mut Vec) -> B256 { + let Some(root) = self.root.rlp(rlp_buf) else { return EMPTY_ROOT_HASH }; + root.as_hash().unwrap_or_else(|| keccak256(root.as_ref())) + } +} + +#[derive(Debug, Default)] +struct TrieNode { + kind: TrieNodeKind, + rlp: Option, +} + +#[derive(Debug, Default)] +enum TrieNodeKind { + #[default] + Empty, + Leaf { + path: Nibbles, + value: Vec, + }, + Extension { + path: Nibbles, + child: Box, + }, + Branch { + children: [Option>; 16], + }, +} + +impl TrieNode { + const fn leaf(path: Nibbles, value: Vec) -> Self { + Self { kind: TrieNodeKind::Leaf { path, value }, rlp: None } + } + + fn extension(path: Nibbles, child: Self) -> Self { + debug_assert!(!path.is_empty()); + Self { kind: TrieNodeKind::Extension { path, child: Box::new(child) }, rlp: None } + } + + const fn branch(children: [Option>; 16]) -> Self { + Self { kind: TrieNodeKind::Branch { children }, rlp: None } + } + + fn empty_children() -> [Option>; 16] { + Default::default() + } + + fn insert(&mut self, key: Nibbles, value: Vec) { + let kind = mem::take(&mut self.kind); + self.rlp = None; + *self = match kind { + TrieNodeKind::Empty => Self::leaf(key, value), + TrieNodeKind::Leaf { path, value: old_value } => { + let common = path.common_prefix_length(&key); + if common == path.len() { + debug_assert_eq!(common, key.len()); + Self::leaf(path, value) + } else { + let mut children = Self::empty_children(); + let old_index = path.get(common).unwrap() as usize; + let new_index = key.get(common).unwrap() as usize; + children[old_index] = + Some(Box::new(Self::leaf(path.slice(common + 1..), old_value))); + children[new_index] = + Some(Box::new(Self::leaf(key.slice(common + 1..), value))); + let branch = Self::branch(children); + if common == 0 { branch } else { Self::extension(path.slice(..common), branch) } + } + } + TrieNodeKind::Extension { path, mut child } => { + let common = path.common_prefix_length(&key); + if common == path.len() { + child.insert(key.slice(common..), value); + Self::extension(path, *child) + } else { + let mut children = Self::empty_children(); + let old_index = path.get(common).unwrap() as usize; + let old_path = path.slice(common + 1..); + let old_child = if old_path.is_empty() { + *child + } else { + Self::extension(old_path, *child) + }; + children[old_index] = Some(Box::new(old_child)); + + let new_index = key.get(common).unwrap() as usize; + children[new_index] = + Some(Box::new(Self::leaf(key.slice(common + 1..), value))); + let branch = Self::branch(children); + if common == 0 { branch } else { Self::extension(path.slice(..common), branch) } + } + } + TrieNodeKind::Branch { mut children } => { + let index = key.first().expect("trie keys have equal lengths") as usize; + children[index] + .get_or_insert_with(|| Box::new(Self::default())) + .insert(key.slice(1..), value); + Self::branch(children) + } + }; + } + + fn remove(&mut self, key: Nibbles) { + let kind = mem::take(&mut self.kind); + self.rlp = None; + *self = match kind { + TrieNodeKind::Empty => Self::default(), + TrieNodeKind::Leaf { path, value } => { + if path == key { + Self::default() + } else { + Self::leaf(path, value) + } + } + TrieNodeKind::Extension { path, mut child } => { + if key.starts_with(&path) { + child.remove(key.slice(path.len()..)); + Self::normalize_extension(path, *child) + } else { + Self::extension(path, *child) + } + } + TrieNodeKind::Branch { mut children } => { + let index = key.first().expect("trie keys have equal lengths") as usize; + if let Some(child) = &mut children[index] { + child.remove(key.slice(1..)); + if matches!(child.kind, TrieNodeKind::Empty) { + children[index] = None; + } + } + Self::normalize_branch(children) + } + }; + } + + fn normalize_extension(path: Nibbles, child: Self) -> Self { + match child.kind { + TrieNodeKind::Empty => Self::default(), + TrieNodeKind::Leaf { path: child_path, value } => { + Self::leaf(path.join(&child_path), value) + } + TrieNodeKind::Extension { path: child_path, child } => { + Self::extension(path.join(&child_path), *child) + } + TrieNodeKind::Branch { children } => Self::extension(path, Self::branch(children)), + } + } + + fn normalize_branch(mut children: [Option>; 16]) -> Self { + let mut indexes = + children.iter().enumerate().filter_map(|(index, child)| child.as_ref().map(|_| index)); + let Some(index) = indexes.next() else { return Self::default() }; + if indexes.next().is_some() { + return Self::branch(children); + } + + let child = *children[index].take().unwrap(); + let prefix = Nibbles::from_nibbles([index as u8]); + match child.kind { + TrieNodeKind::Empty => unreachable!("empty branch children are removed"), + TrieNodeKind::Leaf { path, value } => Self::leaf(prefix.join(&path), value), + TrieNodeKind::Extension { path, child } => Self::extension(prefix.join(&path), *child), + TrieNodeKind::Branch { children } => Self::extension(prefix, Self::branch(children)), + } + } + + fn rlp(&mut self, out: &mut Vec) -> Option { + if let Some(rlp) = &self.rlp { + return Some(rlp.clone()); + } + + let rlp = self.encode(out)?; + self.rlp = Some(rlp.clone()); + Some(rlp) + } + + /// Encodes this node into `out`, returning its RLP reference without consulting the cache. + fn encode(&mut self, out: &mut Vec) -> Option { + let rlp = match &mut self.kind { + TrieNodeKind::Empty => return None, + TrieNodeKind::Leaf { path, value } => { + out.clear(); + LeafNodeRef::new(path, value).rlp(out) + } + TrieNodeKind::Extension { path, child } => { + let child = child.rlp(out).expect("extension nodes have a child"); + out.clear(); + ExtensionNodeRef::new(path, child.as_ref()).rlp(out) + } + TrieNodeKind::Branch { children } => { + let mut stack: [RlpNode; 16] = array::from_fn(|_| RlpNode::default()); + let mut stack_len = 0; + let mut state_mask = TrieMask::default(); + for (index, child) in children.iter_mut().enumerate() { + if let Some(child) = child { + stack[stack_len] = child.rlp(out).expect("branch children are not empty"); + stack_len += 1; + state_mask.set_bit(index as u8); + } + } + out.clear(); + BranchNodeRef::new(&stack[..stack_len], state_mask).rlp(out) + } + }; + Some(rlp) + } + + /// Appends the RLP encoding of this node and all of its descendants to `nodes`. + fn collect_nodes(&mut self, rlp_buf: &mut Vec, nodes: &mut Vec) { + match &mut self.kind { + TrieNodeKind::Empty => return, + TrieNodeKind::Leaf { .. } => {} + TrieNodeKind::Extension { child, .. } => child.collect_nodes(rlp_buf, nodes), + TrieNodeKind::Branch { children } => { + for child in children.iter_mut().flatten() { + child.collect_nodes(rlp_buf, nodes); + } + } + } + self.encode(rlp_buf); + nodes.push(Bytes::copy_from_slice(rlp_buf)); + } +} + +/// Builds the state trie for the given accounts and returns its root together with the RLP +/// encoding of every node of the account trie and all storage tries. +/// +/// The node set is a witness for any execution against this state: it is a strict superset of +/// the nodes touched by any particular block. +pub fn state_trie_witness(accounts: &AddressMap) -> (B256, Vec) { + let mut rlp_buf = Vec::new(); + let mut account_trie = IncrementalTrie::default(); + let mut nodes = Vec::new(); + for (address, account) in accounts { + if account.account_state == AccountState::NotExisting { + continue; + } + + // Keep only one storage trie alive while collecting the witness. + let mut storage_trie = IncrementalTrie::from_storage(&account.storage); + let storage_root = storage_trie.root_with_buf(&mut rlp_buf); + storage_trie.root.collect_nodes(&mut rlp_buf, &mut nodes); + account_trie.insert( + keccak256(address), + trie_account_rlp_with_storage_root(&account.info, storage_root), + ); + } + account_trie.root.collect_nodes(&mut rlp_buf, &mut nodes); + let root = account_trie.root_with_buf(&mut rlp_buf); + nodes.sort_unstable(); + nodes.dedup(); + (root, nodes) +} pub fn build_root(values: impl IntoIterator)>) -> B256 { let mut builder = HashBuilder::default(); @@ -22,150 +475,218 @@ pub fn build_root(values: impl IntoIterator)>) -> B256 } /// Builds state root from the given accounts -pub fn state_root(accounts: &HashMap) -> B256 { +pub fn state_root(accounts: &AddressMap) -> B256 { build_root(trie_accounts(accounts)) } /// Builds storage root from the given storage -pub fn storage_root(storage: &HashMap) -> B256 { +pub fn storage_root(storage: &U256Map) -> B256 { build_root(trie_storage(storage)) } /// Builds iterator over stored key-value pairs ready for storage trie root calculation. -pub fn trie_storage(storage: &HashMap) -> Vec<(Nibbles, Vec)> { +pub fn trie_storage(storage: &U256Map) -> Vec<(Nibbles, Vec)> { let mut storage = storage .iter() + .filter(|(_, value)| !value.is_zero()) .map(|(key, value)| { let data = alloy_rlp::encode(value); (Nibbles::unpack(keccak256(key.to_be_bytes::<32>())), data) }) .collect::>(); - storage.sort_by(|(key1, _), (key2, _)| key1.cmp(key2)); + storage.sort_by_key(|(key, _)| *key); storage } /// Builds iterator over stored key-value pairs ready for account trie root calculation. -pub fn trie_accounts(accounts: &HashMap) -> Vec<(Nibbles, Vec)> { - let mut accounts = accounts +pub fn trie_accounts(accounts: &AddressMap) -> Vec<(Nibbles, Vec)> { + let mut accounts: Vec<(Nibbles, Vec)> = accounts .iter() + .filter(|(_, account)| account.account_state != AccountState::NotExisting) .map(|(address, account)| { let data = trie_account_rlp(&account.info, &account.storage); (Nibbles::unpack(keccak256(*address)), data) }) - .collect::>(); - accounts.sort_by(|(key1, _), (key2, _)| key1.cmp(key2)); + .collect(); + accounts.sort_by_key(|(key, _)| *key); accounts } /// Returns the RLP for this account. -pub fn trie_account_rlp(info: &AccountInfo, storage: &HashMap) -> Vec { +pub fn trie_account_rlp(info: &AccountInfo, storage: &U256Map) -> Vec { + trie_account_rlp_with_storage_root(info, storage_root(storage)) +} + +/// Returns the RLP for this account with an already computed storage root. +fn trie_account_rlp_with_storage_root(info: &AccountInfo, storage_root: B256) -> Vec { let mut out: Vec = Vec::new(); - let list: [&dyn Encodable; 4] = - [&info.nonce, &info.balance, &storage_root(storage), &info.code_hash]; + let list: [&dyn Encodable; 4] = [&info.nonce, &info.balance, &storage_root, &info.code_hash()]; alloy_rlp::encode_list::<_, dyn Encodable>(&list, &mut out); out } -/// Applies the given state overrides to the given CacheDB -pub fn apply_state_overrides( - overrides: StateOverride, - cache_db: &mut CacheDB, -) -> Result<(), BlockchainError> -where - D: DatabaseRef, -{ - for (account, account_overrides) in &overrides { - let mut account_info = cache_db.basic_ref(*account)?.unwrap_or_default(); +#[cfg(test)] +mod tests { + use super::*; - if let Some(nonce) = account_overrides.nonce { - account_info.nonce = nonce; - } - if let Some(code) = &account_overrides.code { - account_info.code = Some(Bytecode::new_raw(code.to_vec().into())); - } - if let Some(balance) = account_overrides.balance { - account_info.balance = balance; - } + #[test] + fn canonical_roots_omit_zero_storage_and_non_existing_accounts() { + let mut storage = U256Map::default(); + storage.insert(U256::ONE, U256::ZERO); + assert_eq!(storage_root(&storage), EMPTY_ROOT_HASH); - cache_db.insert_account_info(*account, account_info); + let mut accounts = AddressMap::default(); + accounts + .insert(alloy_primitives::Address::with_last_byte(1), DbAccount::new_not_existing()); + assert_eq!(state_root(&accounts), EMPTY_ROOT_HASH); + assert_eq!(StateRootCache::default().root(&accounts), EMPTY_ROOT_HASH); + } - // We ensure that not both state and state_diff are set. - // If state is set, we must mark the account as "NewlyCreated", so that the old storage - // isn't read from - match (&account_overrides.state, &account_overrides.state_diff) { - (Some(_), Some(_)) => { - return Err(BlockchainError::StateOverrideError( - "state and state_diff can't be used together".to_string(), - )); - } - (None, None) => (), - (Some(new_account_state), None) => { - cache_db.replace_account_storage( - *account, - new_account_state - .iter() - .map(|(key, value)| ((*key).into(), (*value).into())) - .collect(), - )?; - } - (None, Some(account_state_diff)) => { - for (key, value) in account_state_diff { - cache_db.insert_account_storage(*account, (*key).into(), (*value).into())?; - } - } + #[test] + fn incremental_roots_preserve_uncached_state_and_replace_cleared_storage() { + let address = alloy_primitives::Address::with_last_byte(1); + let untouched = alloy_primitives::Address::with_last_byte(2); + let [one, two, three, four] = [1u64, 2, 3, 4].map(U256::from); + let original = DbAccount { + info: AccountInfo { balance: U256::from(100), ..Default::default() }, + account_state: AccountState::None, + storage: [(one, U256::from(11)), (two, U256::from(22))].into_iter().collect(), + }; + let mut full = AddressMap::from_iter([(address, original.clone()), (untouched, original)]); + let mut cache = StateRootCache::default(); + assert_eq!(cache.root(&full), state_root(&full)); + let mut previous = AddressMap::default(); + let mut check = |overlay: &AddressMap, full: &AddressMap| { + assert!(cache.record_overlay(overlay, &previous)); + assert_eq!(cache.root(overlay), state_root(full)); + previous.clone_from(overlay); }; - } - Ok(()) -} -/// Applies the given block overrides to the env and updates overridden block hashes in the db. -pub fn apply_block_overrides( - overrides: BlockOverrides, - cache_db: &mut CacheDB, - env: &mut BlockEnv, -) { - let BlockOverrides { - number, - difficulty, - time, - gas_limit, - coinbase, - random, - base_fee, - block_hash, - } = overrides; + // An ordinary write must retain storage and accounts absent from the overlay. + let mut overlay = AddressMap::from_iter([( + address, + DbAccount { + info: full[&address].info.clone(), + account_state: AccountState::Touched, + storage: [(one, U256::from(33))].into_iter().collect(), + }, + )]); + full.get_mut(&address).unwrap().storage.insert(one, U256::from(33)); + check(&overlay, &full); - if let Some(block_hashes) = block_hash { - // override block hashes - cache_db - .cache - .block_hashes - .extend(block_hashes.into_iter().map(|(num, hash)| (U256::from(num), hash))) - } + // Zero removes a leaf originally present only in the base; a new slot adds one. + overlay.get_mut(&address).unwrap().storage.extend([(two, U256::ZERO), (three, four)]); + full.get_mut(&address).unwrap().storage.remove(&two); + full.get_mut(&address).unwrap().storage.insert(three, four); + check(&overlay, &full); - if let Some(number) = number { - env.number = number.saturating_to(); - } - if let Some(difficulty) = difficulty { - env.difficulty = difficulty; - } - if let Some(time) = time { - env.timestamp = U256::from(time); - } - if let Some(gas_limit) = gas_limit { - env.gas_limit = gas_limit; + // A balance-only override must not clear unread storage. + overlay.get_mut(&address).unwrap().info.balance = U256::from(101); + full.get_mut(&address).unwrap().info.balance = U256::from(101); + check(&overlay, &full); + + // A deleted account must lose both its account leaf and its storage trie. + overlay.insert(address, DbAccount::new_not_existing()); + full.remove(&address); + check(&overlay, &full); + + // Recreate, then replace storage again without changing StorageCleared. Each replacement + // must discard the previous slot set, including slots introduced by earlier blocks. + for slot in [three, four] { + let account = DbAccount { + info: AccountInfo { balance: U256::from(102), ..Default::default() }, + account_state: AccountState::StorageCleared, + storage: [(slot, U256::from(55))].into_iter().collect(), + }; + overlay.insert(address, account.clone()); + full.insert(address, account); + check(&overlay, &full); + } } - if let Some(coinbase) = coinbase { - env.beneficiary = coinbase; + + #[test] + fn overlay_roots_rebuild_when_base_storage_is_exposed_again() { + let address = alloy_primitives::Address::with_last_byte(1); + let [one, two] = [1u64, 2].map(U256::from); + let base = DbAccount { + info: AccountInfo { balance: U256::from(100), ..Default::default() }, + storage: [(one, U256::from(11)), (two, U256::from(22))].into_iter().collect(), + ..Default::default() + }; + for account_state in + [AccountState::Touched, AccountState::StorageCleared, AccountState::NotExisting] + { + let storage = if account_state == AccountState::NotExisting { + U256Map::default() + } else { + [(one, U256::from(33))].into_iter().collect() + }; + let previous = AddressMap::from_iter([( + address, + DbAccount { account_state: account_state.clone(), storage, ..base.clone() }, + )]); + // Retain every overlay key when losing StorageCleared: the newly exposed base slot + // still requires a rebuild. NotExisting likewise has no keys to lose. + let storage = if account_state.is_storage_cleared() { + previous[&address].storage.clone() + } else { + U256Map::default() + }; + let mut merged = base.clone(); + merged.storage.extend(storage.clone()); + let overlay = AddressMap::from_iter([( + address, + DbAccount { account_state: AccountState::Touched, storage, ..base.clone() }, + )]); + let mut cache = StateRootCache::default(); + cache.root(&previous); + assert!(!cache.record_overlay(&overlay, &previous)); + let full = AddressMap::from_iter([(address, merged)]); + assert_eq!(cache.root(&full), state_root(&full)); + assert!(!cache.record_overlay(&AddressMap::default(), &overlay)); + } } - if let Some(random) = random { - env.prevrandao = Some(random); + + fn rebuilt_root(values: &B256Map>) -> B256 { + let mut leaves = values + .iter() + .map(|(key, value)| (Nibbles::unpack(*key), value.clone())) + .collect::>(); + leaves.sort_by_key(|(key, _)| *key); + build_root(leaves) } - if let Some(base_fee) = base_fee { - env.basefee = base_fee.saturating_to(); + + #[test] + fn incremental_trie_matches_full_rebuild() { + let mut trie = IncrementalTrie::default(); + let mut values = B256Map::default(); + assert_eq!(trie.root(), EMPTY_ROOT_HASH); + + for index in 0..128 { + let key = keccak256(U256::from(index).to_be_bytes::<32>()); + let value = alloy_rlp::encode(U256::from(index + 1)); + trie.insert(key, value.clone()); + values.insert(key, value); + assert_eq!(trie.root(), rebuilt_root(&values)); + } + + for index in (0..128).step_by(3) { + let key = keccak256(U256::from(index).to_be_bytes::<32>()); + let value = alloy_rlp::encode(U256::from(index + 1_000)); + trie.insert(key, value.clone()); + values.insert(key, value); + assert_eq!(trie.root(), rebuilt_root(&values)); + } + + for index in (0..128).rev() { + let key = keccak256(U256::from(index).to_be_bytes::<32>()); + trie.remove(key); + values.remove(&key); + assert_eq!(trie.root(), rebuilt_root(&values)); + } } } diff --git a/crates/anvil/src/eth/backend/mem/storage.rs b/crates/anvil/src/eth/backend/mem/storage.rs index 83349ea049b05..d1dba7dda940a 100644 --- a/crates/anvil/src/eth/backend/mem/storage.rs +++ b/crates/anvil/src/eth/backend/mem/storage.rs @@ -5,14 +5,13 @@ use crate::eth::{ MaybeFullDatabase, SerializableBlock, SerializableHistoricalStates, SerializableTransaction, StateDb, }, - env::Env, mem::cache::DiskStateCache, }, - error::BlockchainError, pool::transactions::PoolTransaction, }; -use alloy_consensus::constants::EMPTY_WITHDRAWALS; -use alloy_eips::eip7685::EMPTY_REQUESTS_HASH; +use alloy_consensus::BlockHeader; +use alloy_eips::eip7928::BlockAccessList; +use alloy_network::Network; use alloy_primitives::{ B256, Bytes, U256, map::{B256HashMap, HashMap}, @@ -20,28 +19,24 @@ use alloy_primitives::{ use alloy_rpc_types::{ BlockId, BlockNumberOrTag, TransactionInfo as RethTransactionInfo, trace::{ - geth::{ - FourByteFrame, GethDebugBuiltInTracerType, GethDebugTracerType, - GethDebugTracingOptions, GethTrace, NoopFrame, - }, otterscan::{InternalOperation, OperationType}, parity::LocalizedTransactionTrace, }, }; use anvil_core::eth::{ - block::{Block, PartialHeader}, - transaction::{MaybeImpersonatedTransaction, ReceiptResponse, TransactionInfo, TypedReceipt}, + block::{Block, create_block}, + transaction::{MaybeImpersonatedTransaction, TransactionInfo}, }; -use anvil_rpc::error::RpcError; use foundry_evm::{ backend::MemDb, traces::{ - CallKind, FourByteInspector, GethTraceBuilder, ParityTraceBuilder, TracingInspectorConfig, + CallKind, CallTraceNode, ParityTraceBuilder, TraceMemberOrder, TracingInspectorConfig, }, }; +use foundry_primitives::{FoundryHeader, FoundryReceiptEnvelope, FoundryTxEnvelope}; use parking_lot::RwLock; -use revm::{context::Block as RevmBlock, primitives::hardfork::SpecId}; use std::{collections::VecDeque, fmt, path::PathBuf, sync::Arc, time::Duration}; + // use yansi::Paint; // === various limits in number of blocks === @@ -55,7 +50,10 @@ const MAX_ON_DISK_HISTORY_LIMIT: usize = 3_600; pub struct InMemoryBlockStates { /// The states at a certain block states: B256HashMap, - /// states which data is moved to disk + /// Older states in the secondary history tier. + /// + /// Structurally shared states remain here directly; other database types move their data to + /// disk and keep an empty state object here for loading it. on_disk_states: B256HashMap, /// How many states to store at most in_memory_limit: usize, @@ -76,6 +74,7 @@ pub struct InMemoryBlockStates { impl InMemoryBlockStates { /// Creates a new instance with limited slots pub fn new(in_memory_limit: usize, on_disk_limit: usize) -> Self { + let in_memory_limit = in_memory_limit.max(1); Self { states: Default::default(), on_disk_states: Default::default(), @@ -89,7 +88,7 @@ impl InMemoryBlockStates { } /// Configures no disk caching - pub fn memory_only(mut self) -> Self { + pub const fn memory_only(mut self) -> Self { self.max_on_disk_limit = 0; self } @@ -103,20 +102,21 @@ impl InMemoryBlockStates { /// This modifies the `limit` what to keep stored in memory. /// /// This will ensure the new limit adjusts based on the block time. - /// The lowest blocktime is 1s which should increase the limit slightly + /// The lowest blocktime is 1s which should increase the limit slightly. + /// Memory-only caches retain their configured limit. pub fn update_interval_mine_block_time(&mut self, block_time: Duration) { let block_time = block_time.as_secs(); // for block times lower than 2s we increase the mem limit since we're mining _small_ blocks // very fast // this will gradually be decreased once the max limit was reached - if block_time <= 2 { + if block_time <= 2 && !self.is_memory_only() { self.in_memory_limit = DEFAULT_HISTORY_LIMIT * 3; self.enforce_limits(); } } /// Returns true if only memory caching is supported. - fn is_memory_only(&self) -> bool { + const fn is_memory_only(&self) -> bool { self.max_on_disk_limit == 0 } @@ -125,9 +125,8 @@ impl InMemoryBlockStates { /// When the configured limit for the number of states that can be stored in memory is reached, /// the oldest state is removed. /// - /// Since we keep a snapshot of the entire state as history, the size of the state will increase - /// with the transactions processed. To counter this, we gradually decrease the cache limit with - /// the number of states/blocks until we reached the `min_limit`. + /// Database types without structural sharing gradually move snapshots to disk as the chain + /// grows. Structurally shared snapshots move into the secondary tier without serialization. /// /// When a state that was previously written to disk is requested, it is simply read from disk. pub fn insert(&mut self, hash: B256, state: StateDb) { @@ -155,10 +154,26 @@ impl InMemoryBlockStates { { // only write to disk if supported if !self.is_memory_only() { - let state_snapshot = state.0.clear_into_state_snapshot(); - self.disk_cache.write(hash, state_snapshot); - self.on_disk_states.insert(hash, state); - self.oldest_on_disk.push_back(hash); + if state.is_persistent() { + self.on_disk_states.insert(hash, state); + self.oldest_on_disk.push_back(hash); + continue; + } + + let state_snapshot = state.serialize_state(); + if self.disk_cache.write(hash, &state_snapshot) { + state.clear(); + // Write succeeded, move state to on-disk tracking + self.on_disk_states.insert(hash, state); + self.oldest_on_disk.push_back(hash); + } else { + // Write failed, keep state in memory to avoid data loss + self.states.insert(hash, state); + self.present.push_front(hash); + // Increase limit temporarily to prevent infinite retry loop + self.in_memory_limit = self.in_memory_limit.saturating_add(1); + break; + } } } } @@ -166,41 +181,69 @@ impl InMemoryBlockStates { // enforce on disk limit and purge the oldest state cached on disk while !self.is_memory_only() && self.oldest_on_disk.len() >= self.max_on_disk_limit { // evict the oldest block - if let Some(hash) = self.oldest_on_disk.pop_front() { - self.on_disk_states.remove(&hash); + if let Some(hash) = self.oldest_on_disk.pop_front() + && self.on_disk_states.remove(&hash).is_some_and(|state| !state.is_persistent()) + { self.disk_cache.remove(hash); } } } - /// Returns the state for the given `hash` if present - pub fn get(&mut self, hash: &B256) -> Option<&StateDb> { - self.states.get(hash).or_else(|| { - if let Some(state) = self.on_disk_states.get_mut(hash) - && let Some(cached) = self.disk_cache.read(*hash) - { - state.init_from_state_snapshot(cached); + /// Returns the in-memory state for the given `hash` if present + pub fn get_state(&self, hash: &B256) -> Option<&StateDb> { + self.states.get(hash) + } + + /// Returns on-disk state for the given `hash` if present + pub fn get_on_disk_state(&mut self, hash: &B256) -> Option<&StateDb> { + if let Some(state) = self.on_disk_states.get_mut(hash) { + if state.is_persistent() { return Some(state); } - None - }) + + let cached = self.disk_cache.read(*hash)?; + state.init_from_state_snapshot(cached); + return Some(state); + } + + None } /// Sets the maximum number of stats we keep in memory - pub fn set_cache_limit(&mut self, limit: usize) { + pub const fn set_cache_limit(&mut self, limit: usize) { + let limit = if limit == 0 { 1 } else { limit }; self.in_memory_limit = limit; + self.min_in_memory_limit = + if limit < MIN_HISTORY_LIMIT { limit } else { MIN_HISTORY_LIMIT }; } /// Clears all entries pub fn clear(&mut self) { self.states.clear(); - self.on_disk_states.clear(); self.present.clear(); - for on_disk in std::mem::take(&mut self.oldest_on_disk) { - self.disk_cache.remove(on_disk) + self.oldest_on_disk.clear(); + for (hash, state) in std::mem::take(&mut self.on_disk_states) { + if !state.is_persistent() { + self.disk_cache.remove(hash); + } } } + /// Removes states for the given block hashes. + /// + /// This is used during chain rollback to clean up states for blocks that are no longer part + /// of the canonical chain. + pub fn remove_block_states(&mut self, hashes: &[B256]) { + for hash in hashes { + self.states.remove(hash); + if self.on_disk_states.remove(hash).is_some_and(|state| !state.is_persistent()) { + self.disk_cache.remove(*hash); + } + } + self.present.retain(|h| !hashes.contains(h)); + self.oldest_on_disk.retain(|h| !hashes.contains(h)); + } + /// Serialize all states to a list of serializable historical states pub fn serialized_states(&mut self) -> SerializableHistoricalStates { // Get in-memory states @@ -211,11 +254,14 @@ impl InMemoryBlockStates { .collect::>(); // Get on-disk state snapshots - self.on_disk_states.iter().for_each(|(hash, _)| { - if let Some(state_snapshot) = self.disk_cache.read(*hash) { + for (hash, state) in &mut self.on_disk_states { + if state.is_persistent() { + states.push((*hash, state.serialize_state())); + } else if let Some(state_snapshot) = self.disk_cache.read(*hash) { states.push((*hash, state_snapshot)); } - }); + } + states.sort_unstable_by_key(|(hash, _)| *hash); SerializableHistoricalStates::new(states) } @@ -251,9 +297,11 @@ impl Default for InMemoryBlockStates { /// Stores the blockchain data (blocks, transactions) #[derive(Clone, Debug)] -pub struct BlockchainStorage { +pub struct BlockchainStorage { /// all stored blocks (block hash -> block) pub blocks: B256HashMap, + /// EIP-7928 block access lists for locally mined blocks. + pub block_access_lists: B256HashMap, /// mapping from block number -> block hash pub hashes: HashMap, /// The current best hash @@ -262,45 +310,36 @@ pub struct BlockchainStorage { pub best_number: u64, /// genesis hash of the chain pub genesis_hash: B256, + /// genesis block number of the chain + pub genesis_number: u64, /// Mapping from the transaction hash to a tuple containing the transaction as well as the /// transaction receipt - pub transactions: B256HashMap, + pub transactions: B256HashMap>, /// The total difficulty of the chain until this block pub total_difficulty: U256, + /// Monad senders and authorities retained even when old transaction bodies are pruned. + #[cfg(feature = "monad")] + pub monad_block_participants: B256HashMap, + /// Execution profile used when each locally stored Monad block was created. + #[cfg(feature = "monad")] + pub monad_block_replay_profiles: B256HashMap, } -impl BlockchainStorage { - /// Creates a new storage with a genesis block - pub fn new( - env: &Env, - spec_id: SpecId, - base_fee: Option, - timestamp: u64, - genesis_number: u64, - ) -> Self { - let is_shanghai = spec_id >= SpecId::SHANGHAI; - let is_cancun = spec_id >= SpecId::CANCUN; - let is_prague = spec_id >= SpecId::PRAGUE; - - // create a dummy genesis block - let partial_header = PartialHeader { - timestamp, - base_fee, - gas_limit: env.evm_env.block_env.gas_limit, - beneficiary: env.evm_env.block_env.beneficiary, - difficulty: env.evm_env.block_env.difficulty, - blob_gas_used: env.evm_env.block_env.blob_excess_gas_and_price.as_ref().map(|_| 0), - excess_blob_gas: env.evm_env.block_env.blob_excess_gas(), - number: genesis_number, - parent_beacon_block_root: is_cancun.then_some(Default::default()), - withdrawals_root: is_shanghai.then_some(EMPTY_WITHDRAWALS), - requests_hash: is_prague.then_some(EMPTY_REQUESTS_HASH), - ..Default::default() - }; - let block = Block::new::(partial_header, vec![]); +impl BlockchainStorage { + /// Removes all metadata associated with a locally stored Monad block. + #[cfg(feature = "monad")] + fn remove_monad_block_metadata(&mut self, block_hash: &B256) { + self.monad_block_participants.remove(block_hash); + self.monad_block_replay_profiles.remove(block_hash); + } + + /// Creates a new storage with a genesis block. + pub fn new(header: FoundryHeader) -> Self { + let block = + create_block(header, Vec::>::new()); let genesis_hash = block.header.hash_slow(); let best_hash = genesis_hash; - let best_number = genesis_number; + let best_number = block.header.number(); let mut blocks = B256HashMap::default(); blocks.insert(genesis_hash, block); @@ -309,12 +348,18 @@ impl BlockchainStorage { hashes.insert(best_number, genesis_hash); Self { blocks, + block_access_lists: Default::default(), hashes, best_hash, best_number, genesis_hash, + genesis_number: best_number, transactions: Default::default(), total_difficulty: Default::default(), + #[cfg(feature = "monad")] + monad_block_participants: Default::default(), + #[cfg(feature = "monad")] + monad_block_replay_profiles: Default::default(), } } @@ -324,12 +369,18 @@ impl BlockchainStorage { Self { blocks: B256HashMap::default(), + block_access_lists: Default::default(), hashes, best_hash: block_hash, best_number: block_number, genesis_hash: Default::default(), + genesis_number: 0, transactions: Default::default(), total_difficulty, + #[cfg(feature = "monad")] + monad_block_participants: Default::default(), + #[cfg(feature = "monad")] + monad_block_replay_profiles: Default::default(), } } @@ -341,11 +392,18 @@ impl BlockchainStorage { let mut removed = vec![]; let best_num: u64 = self.best_number; for i in (block_number + 1)..=best_num { - if let Some(hash) = self.hashes.remove(&i) - && let Some(block) = self.blocks.remove(&hash) - { - self.remove_block_transactions_by_number(block.header.number); - removed.push(block); + if let Some(hash) = self.hashes.get(&i).copied() { + // First remove the block's transactions while the mappings still exist + self.remove_block_transactions_by_number(i); + + // Now remove the block from storage (may already be empty of txs) and drop mapping + if let Some(block) = self.blocks.remove(&hash) { + removed.push(block); + } + self.block_access_lists.remove(&hash); + #[cfg(feature = "monad")] + self.remove_monad_block_metadata(&hash); + self.hashes.remove(&i); } } self.best_hash = block_hash; @@ -356,12 +414,18 @@ impl BlockchainStorage { pub fn empty() -> Self { Self { blocks: Default::default(), + block_access_lists: Default::default(), hashes: Default::default(), best_hash: Default::default(), best_number: Default::default(), genesis_hash: Default::default(), + genesis_number: Default::default(), transactions: Default::default(), total_difficulty: Default::default(), + #[cfg(feature = "monad")] + monad_block_participants: Default::default(), + #[cfg(feature = "monad")] + monad_block_replay_profiles: Default::default(), } } @@ -374,64 +438,111 @@ impl BlockchainStorage { /// Removes all stored transactions for the given block hash pub fn remove_block_transactions(&mut self, block_hash: B256) { + self.block_access_lists.remove(&block_hash); if let Some(block) = self.blocks.get_mut(&block_hash) { - for tx in &block.transactions { + for tx in &block.body.transactions { self.transactions.remove(&tx.hash()); } - block.transactions.clear(); + block.body.transactions.clear(); + } + } + + /// Serialize all blocks in storage + pub fn serialized_blocks(&self) -> Vec { + let mut blocks = self.blocks.iter().collect::>(); + blocks.sort_unstable_by_key(|(hash, block)| { + let hash = **hash; + let number = block.header.number(); + let is_canonical = self.hashes.get(&number).is_some_and(|canonical| *canonical == hash); + (number, is_canonical, hash) + }); + blocks.into_iter().map(|(_, block)| block.clone().into()).collect() + } + + /// Adds a block to storage and returns its hash. + pub fn insert_block(&mut self, block: Block) -> B256 { + let block_hash = block.header.hash_slow(); + let block_number = block.header.number(); + self.blocks.insert(block_hash, block); + self.hashes.insert(block_number, block_hash); + + // Update genesis_hash if we are loading the genesis block, so that + // Finalized/Safe/Earliest block tag lookups return the correct hash. The genesis + // number can be non-zero when configured via `--block-number`. + // See: https://github.com/foundry-rs/foundry/issues/12645 + if block_number == self.genesis_number { + self.genesis_hash = block_hash; + } + + block_hash + } + + /// Deserialize and add blocks above the fork boundary to the backend storage. + pub fn load_blocks( + &mut self, + serializable_blocks: Vec, + fork_boundary: Option, + ) { + for serializable_block in serializable_blocks { + let block: Block = serializable_block.into(); + if fork_boundary.is_some_and(|boundary| block.header.number() <= boundary) { + continue; + } + self.insert_block(block); } } -} -impl BlockchainStorage { /// Returns the hash for [BlockNumberOrTag] - pub fn hash(&self, number: BlockNumberOrTag) -> Option { - let slots_in_an_epoch = 32; + pub fn hash(&self, number: BlockNumberOrTag, slots_in_an_epoch: u64) -> Option { match number { BlockNumberOrTag::Latest => Some(self.best_hash), BlockNumberOrTag::Earliest => Some(self.genesis_hash), BlockNumberOrTag::Pending => None, BlockNumberOrTag::Number(num) => self.hashes.get(&num).copied(), BlockNumberOrTag::Safe => { - if self.best_number > (slots_in_an_epoch) { - self.hashes.get(&(self.best_number - (slots_in_an_epoch))).copied() + if self.best_number.saturating_sub(slots_in_an_epoch) > self.genesis_number { + self.hashes.get(&(self.best_number - slots_in_an_epoch)).copied() } else { - Some(self.genesis_hash) // treat the genesis block as safe "by definition" + Some(self.genesis_hash) } } BlockNumberOrTag::Finalized => { - if self.best_number > (slots_in_an_epoch * 2) { - self.hashes.get(&(self.best_number - (slots_in_an_epoch * 2))).copied() + if self.best_number.saturating_sub(slots_in_an_epoch * 2) > self.genesis_number { + self.hashes.get(&(self.best_number - slots_in_an_epoch * 2)).copied() } else { Some(self.genesis_hash) } } } } +} - pub fn serialized_blocks(&self) -> Vec { - self.blocks.values().map(|block| block.clone().into()).collect() - } - +impl> BlockchainStorage { pub fn serialized_transactions(&self) -> Vec { - self.transactions.values().map(|tx: &MinedTransaction| tx.clone().into()).collect() - } - - /// Deserialize and add all blocks data to the backend storage - pub fn load_blocks(&mut self, serializable_blocks: Vec) { - for serializable_block in &serializable_blocks { - let block: Block = serializable_block.clone().into(); - let block_hash = block.header.hash_slow(); - let block_number = block.header.number; - self.blocks.insert(block_hash, block); - self.hashes.insert(block_number, block_hash); - } + let mut transactions = self + .transactions + .values() + .map(|tx: &MinedTransaction| SerializableTransaction::from(tx.clone())) + .collect::>(); + transactions.sort_unstable_by_key(|tx| { + (tx.block_number, tx.info.transaction_index, tx.info.transaction_hash) + }); + transactions } - /// Deserialize and add all blocks data to the backend storage - pub fn load_transactions(&mut self, serializable_transactions: Vec) { - for serializable_transaction in &serializable_transactions { - let transaction: MinedTransaction = serializable_transaction.clone().into(); + /// Deserialize and add transactions above the fork boundary to the backend storage. + pub fn load_transactions( + &mut self, + serializable_transactions: Vec, + fork_boundary: Option, + ) { + for serializable_transaction in serializable_transactions { + if fork_boundary + .is_some_and(|boundary| serializable_transaction.block_number <= boundary) + { + continue; + } + let transaction: MinedTransaction = serializable_transaction.into(); self.transactions.insert(transaction.info.transaction_hash, transaction); } } @@ -439,29 +550,15 @@ impl BlockchainStorage { /// A simple in-memory blockchain #[derive(Clone, Debug)] -pub struct Blockchain { +pub struct Blockchain { /// underlying storage that supports concurrent reads - pub storage: Arc>, + pub storage: Arc>>, } -impl Blockchain { - /// Creates a new storage with a genesis block - pub fn new( - env: &Env, - spec_id: SpecId, - base_fee: Option, - timestamp: u64, - genesis_number: u64, - ) -> Self { - Self { - storage: Arc::new(RwLock::new(BlockchainStorage::new( - env, - spec_id, - base_fee, - timestamp, - genesis_number, - ))), - } +impl Blockchain { + /// Creates a new storage with a genesis block. + pub fn new(header: FoundryHeader) -> Self { + Self { storage: Arc::new(RwLock::new(BlockchainStorage::new(header))) } } pub fn forked(block_number: u64, block_hash: B256, total_difficulty: U256) -> Self { @@ -475,10 +572,10 @@ impl Blockchain { } /// returns the header hash of given block - pub fn hash(&self, id: BlockId) -> Option { + pub fn hash(&self, id: BlockId, slots_in_an_epoch: u64) -> Option { match id { BlockId::Hash(h) => Some(h.block_hash), - BlockId::Number(num) => self.storage.read().hash(num), + BlockId::Number(num) => self.storage.read().hash(num, slots_in_an_epoch), } } @@ -486,7 +583,7 @@ impl Blockchain { self.storage.read().blocks.get(hash).cloned() } - pub fn get_transaction_by_hash(&self, hash: &B256) -> Option { + pub fn get_transaction_by_hash(&self, hash: &B256) -> Option> { self.storage.read().transactions.get(hash).cloned() } @@ -497,31 +594,62 @@ impl Blockchain { } /// Represents the outcome of mining a new block -#[derive(Clone, Debug)] -pub struct MinedBlockOutcome { +pub struct MinedBlockOutcome { /// The block that was mined pub block_number: u64, /// All transactions included in the block - pub included: Vec>, + pub included: Vec>>, + /// Transactions whose nonce was already consumed by the current state. + pub stale: Vec>>, /// All transactions that were attempted to be included but were invalid at the time of /// execution - pub invalid: Vec>, + pub invalid: Vec>>, + /// Transactions skipped because they're not yet valid (e.g., valid_after in the future). + /// These remain in the pool and should be retried later. + pub not_yet_valid: Vec>>, +} + +impl Clone for MinedBlockOutcome { + fn clone(&self) -> Self { + Self { + block_number: self.block_number, + included: self.included.clone(), + stale: self.stale.clone(), + invalid: self.invalid.clone(), + not_yet_valid: self.not_yet_valid.clone(), + } + } +} + +impl fmt::Debug for MinedBlockOutcome { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("MinedBlockOutcome") + .field("block_number", &self.block_number) + .field("included", &self.included.len()) + .field("stale", &self.stale.len()) + .field("invalid", &self.invalid.len()) + .field("not_yet_valid", &self.not_yet_valid.len()) + .finish() + } } /// Container type for a mined transaction #[derive(Clone, Debug)] -pub struct MinedTransaction { +pub struct MinedTransaction { pub info: TransactionInfo, - pub receipt: TypedReceipt, + pub receipt: N::ReceiptEnvelope, pub block_hash: B256, pub block_number: u64, } -impl MinedTransaction { - /// Returns the traces of the transaction for `trace_transaction` +impl MinedTransaction { + /// Returns the traces of the transaction for `trace_transaction`. + /// + /// Like simulated traces, these omit nested zero-value precompile calls identified during + /// execution, regardless of subsequent changes to the node configuration. pub fn parity_traces(&self) -> Vec { ParityTraceBuilder::new( - self.info.traces.clone(), + exclude_precompile_calls(self.info.traces.clone()), None, TracingInspectorConfig::default_parity(), ) @@ -531,6 +659,7 @@ impl MinedTransaction { block_hash: Some(self.block_hash), block_number: Some(self.block_number), base_fee: None, + block_timestamp: None, }) } @@ -546,64 +675,53 @@ impl MinedTransaction { CallKind::Create2 => OperationType::OpCreate2, _ => return None, }; - let mut from = node.trace.caller; - let mut to = node.trace.address; - let mut value = node.trace.value; - if node.is_selfdestruct() { - from = node.trace.address; - to = node.trace.selfdestruct_refund_target.unwrap_or_default(); - value = node.trace.selfdestruct_transferred_value.unwrap_or_default(); - } + let (from, to, value) = if node.is_selfdestruct() { + ( + node.trace.address, + node.trace.selfdestruct_refund_target.unwrap_or_default(), + node.trace.selfdestruct_transferred_value.unwrap_or_default(), + ) + } else { + (node.trace.caller, node.trace.address, node.trace.value) + }; Some(InternalOperation { r#type, from, to, value }) }) .collect() } +} - pub fn geth_trace(&self, opts: GethDebugTracingOptions) -> Result { - let GethDebugTracingOptions { config, tracer, tracer_config, .. } = opts; - - if let Some(tracer) = tracer { - match tracer { - GethDebugTracerType::BuiltInTracer(tracer) => match tracer { - GethDebugBuiltInTracerType::FourByteTracer => { - let inspector = FourByteInspector::default(); - return Ok(FourByteFrame::from(inspector).into()); - } - GethDebugBuiltInTracerType::CallTracer => { - return match tracer_config.into_call_config() { - Ok(call_config) => Ok(GethTraceBuilder::new(self.info.traces.clone()) - .geth_call_traces(call_config, self.receipt.cumulative_gas_used()) - .into()), - Err(e) => Err(RpcError::invalid_params(e.to_string()).into()), - }; +/// Detaches precompile calls identified during execution from the call graph, as the tracing +/// inspector does when configured to exclude precompile calls. +/// +/// Mined transactions keep these calls for the Geth-style traces, which include them. +fn exclude_precompile_calls(mut nodes: Vec) -> Vec { + for idx in 0..nodes.len() { + if nodes[idx].is_precompile() + && let Some(parent) = nodes[idx].parent + && let Some(position) = nodes[parent].children.iter().position(|&child| child == idx) + { + let parent = &mut nodes[parent]; + parent.children.remove(position); + parent.ordering.retain_mut(|member| match member { + TraceMemberOrder::Call(child) if *child == position => false, + TraceMemberOrder::Call(child) => { + if *child > position { + *child -= 1; } - GethDebugBuiltInTracerType::PreStateTracer - | GethDebugBuiltInTracerType::NoopTracer - | GethDebugBuiltInTracerType::MuxTracer - | GethDebugBuiltInTracerType::FlatCallTracer => {} - }, - GethDebugTracerType::JsTracer(_code) => {} - } - - return Ok(NoopFrame::default().into()); + true + } + _ => true, + }); } - - // default structlog tracer - Ok(GethTraceBuilder::new(self.info.traces.clone()) - .geth_traces( - self.receipt.cumulative_gas_used(), - self.info.out.clone().unwrap_or_default(), - config, - ) - .into()) } + nodes } /// Intermediary Anvil representation of a receipt #[derive(Clone, Debug)] -pub struct MinedTransactionReceipt { +pub struct MinedTransactionReceipt { /// The actual json rpc receipt object - pub inner: ReceiptResponse, + pub inner: N::ReceiptResponse, /// Output data for the transaction pub out: Option, } @@ -611,11 +729,13 @@ pub struct MinedTransactionReceipt { #[cfg(test)] mod tests { use super::*; - use crate::eth::backend::db::Db; + use crate::eth::backend::{db::Db, mem::in_memory_db::StateRootDb}; + use alloy_consensus::Header; use alloy_primitives::{Address, hex}; use alloy_rlp::Decodable; - use anvil_core::eth::transaction::TypedTransaction; - use revm::{database::DatabaseRef, state::AccountInfo}; + use foundry_primitives::FoundryNetwork; + use revm::{database::DatabaseRef, interpreter::InstructionResult, state::AccountInfo}; + use tempo_primitives::TempoHeader; #[test] fn test_interval_update() { @@ -624,6 +744,25 @@ mod tests { assert_eq!(storage.in_memory_limit, DEFAULT_HISTORY_LIMIT * 3); } + #[test] + fn test_interval_update_preserves_memory_only_limit() { + for limit in [1, 8, DEFAULT_HISTORY_LIMIT * 4] { + let mut storage = InMemoryBlockStates::new(limit, 0); + for number in 0..limit + 2 { + storage.insert(B256::from(U256::from(number)), StateDb::new(MemDb::default())); + } + + for seconds in [3, 1, 2] { + storage.update_interval_mine_block_time(Duration::from_secs(seconds)); + assert_eq!(storage.in_memory_limit, limit); + assert_eq!(storage.states.len(), limit); + assert!(storage.on_disk_states.is_empty()); + assert!(storage.get_state(&B256::ZERO).is_none()); + assert!(storage.get_state(&B256::with_last_byte(2)).is_some()); + } + } + } + #[test] fn test_init_state_limits() { let mut storage = InMemoryBlockStates::default(); @@ -645,13 +784,23 @@ mod tests { assert_eq!(storage.in_memory_limit, 1); assert_eq!(storage.min_in_memory_limit, 1); assert_eq!(storage.max_on_disk_limit, 2); + + storage = InMemoryBlockStates::new(0, 0); + assert!(storage.is_memory_only()); + assert_eq!(storage.in_memory_limit, 1); + assert_eq!(storage.min_in_memory_limit, 1); + assert_eq!(storage.max_on_disk_limit, 0); + + storage.set_cache_limit(0); + assert_eq!(storage.in_memory_limit, 1); + assert_eq!(storage.min_in_memory_limit, 1); } #[tokio::test(flavor = "multi_thread")] async fn can_read_write_cached_state() { let mut storage = InMemoryBlockStates::new(1, MAX_ON_DISK_HISTORY_LIMIT); - let one = B256::from(U256::from(1)); - let two = B256::from(U256::from(2)); + let one = B256::with_last_byte(1); + let two = B256::with_last_byte(2); let mut state = MemDb::default(); let addr = Address::random(); @@ -666,12 +815,35 @@ mod tests { assert_eq!(storage.on_disk_states.len(), 1); assert!(storage.on_disk_states.contains_key(&one)); - let loaded = storage.get(&one).unwrap(); + let loaded = storage.get_on_disk_state(&one).unwrap(); let acc = loaded.basic_ref(addr).unwrap().unwrap(); assert_eq!(acc.balance, U256::from(1337u64)); } + #[test] + fn persistent_states_do_not_use_disk_cache() { + let mut storage = InMemoryBlockStates::new(1, MAX_ON_DISK_HISTORY_LIMIT); + let one = B256::with_last_byte(1); + let two = B256::with_last_byte(2); + let address = Address::random(); + let mut db = StateRootDb::default(); + + db.insert_account(address, AccountInfo::from_balance(U256::ONE)); + storage.insert(one, db.current_state()); + db.set_balance(address, U256::from(2)).unwrap(); + storage.insert(two, db.current_state()); + + assert!(storage.disk_cache.temp_dir.is_none()); + assert!(storage.on_disk_states.get(&one).unwrap().is_persistent()); + assert_eq!( + storage.get_on_disk_state(&one).unwrap().basic_ref(address).unwrap().unwrap().balance, + U256::ONE + ); + storage.remove_block_states(&[one]); + assert!(storage.disk_cache.temp_dir.is_none()); + } + #[tokio::test(flavor = "multi_thread")] async fn can_decrease_state_cache_size() { let limit = 15; @@ -691,45 +863,341 @@ mod tests { // wait for files to be flushed tokio::time::sleep(std::time::Duration::from_secs(1)).await; - assert_eq!(storage.on_disk_states.len(), num_states - storage.min_in_memory_limit); + let on_disk_states_len = num_states - storage.min_in_memory_limit; + + assert_eq!(storage.on_disk_states.len(), on_disk_states_len); assert_eq!(storage.present.len(), storage.min_in_memory_limit); for idx in 0..num_states { let hash = B256::from(U256::from(idx)); let addr = Address::from_word(hash); - let loaded = storage.get(&hash).unwrap(); + + let loaded = if idx < on_disk_states_len { + storage.get_on_disk_state(&hash).unwrap() + } else { + storage.get_state(&hash).unwrap() + }; + let acc = loaded.basic_ref(addr).unwrap().unwrap(); let balance = (idx * 2) as u64; assert_eq!(acc.balance, U256::from(balance)); } } + #[test] + fn test_remove_block_states_on_rollback() { + let mut storage = InMemoryBlockStates::new(10, MAX_ON_DISK_HISTORY_LIMIT); + + // Insert 5 states + let hashes: Vec = (0..5) + .map(|i| { + let hash = B256::from(U256::from(i)); + let mut state = MemDb::default(); + let addr = Address::from_word(hash); + state.insert_account(addr, AccountInfo::from_balance(U256::from(i * 100))); + storage.insert(hash, StateDb::new(state)); + hash + }) + .collect(); + + assert_eq!(storage.present.len(), 5); + + // Simulate rollback: remove the last 3 blocks + let removed_hashes = &hashes[2..]; + storage.remove_block_states(removed_hashes); + + // Only the first 2 states should remain + assert_eq!(storage.present.len(), 2); + assert!(storage.get_state(&hashes[0]).is_some()); + assert!(storage.get_state(&hashes[1]).is_some()); + for h in removed_hashes { + assert!(storage.get_state(h).is_none()); + assert!(!storage.present.contains(h)); + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_remove_block_states_cleans_disk_cache() { + // Use limit=1 to force states to disk + let mut storage = InMemoryBlockStates::new(1, MAX_ON_DISK_HISTORY_LIMIT); + + let hash_a = B256::with_last_byte(1); + let hash_b = B256::with_last_byte(2); + + storage.insert(hash_a, StateDb::new(MemDb::default())); + storage.insert(hash_b, StateDb::new(MemDb::default())); + + // Wait for disk flush + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + assert!(storage.on_disk_states.contains_key(&hash_a)); + + // Remove hash_a (on disk) + storage.remove_block_states(&[hash_a]); + + assert!(!storage.on_disk_states.contains_key(&hash_a)); + assert!(!storage.oldest_on_disk.contains(&hash_a)); + assert!(storage.get_on_disk_state(&hash_a).is_none()); + } + // verifies that blocks and transactions in BlockchainStorage remain the same when dumped and // reloaded #[test] fn test_storage_dump_reload_cycle() { - let mut dump_storage = BlockchainStorage::empty(); + let mut dump_storage = BlockchainStorage::::empty(); - let partial_header = PartialHeader { gas_limit: 123456, ..Default::default() }; + let header = Header { gas_limit: 123456, ..Default::default() }; let bytes_first = &mut &hex::decode("f86b02843b9aca00830186a094d3e8763675e4c425df46cc3b5c0f6cbdac39604687038d7ea4c68000802ba00eb96ca19e8a77102767a41fc85a36afd5c61ccb09911cec5d3e86e193d9c5aea03a456401896b1b6055311536bf00a718568c744d8c1f9df59879e8350220ca18").unwrap()[..]; - let tx: MaybeImpersonatedTransaction = - TypedTransaction::decode(&mut &bytes_first[..]).unwrap().into(); - let block = - Block::new::(partial_header.clone(), vec![tx.clone()]); + let tx: MaybeImpersonatedTransaction = + FoundryTxEnvelope::decode(&mut &bytes_first[..]).unwrap().into(); + let block = create_block(header.clone().into(), vec![tx.clone()]); let block_hash = block.header.hash_slow(); dump_storage.blocks.insert(block_hash, block); let serialized_blocks = dump_storage.serialized_blocks(); let serialized_transactions = dump_storage.serialized_transactions(); - let mut load_storage = BlockchainStorage::empty(); + let mut load_storage = BlockchainStorage::::empty(); - load_storage.load_blocks(serialized_blocks); - load_storage.load_transactions(serialized_transactions); + load_storage.load_blocks(serialized_blocks, None); + load_storage.load_transactions(serialized_transactions, None); let loaded_block = load_storage.blocks.get(&block_hash).unwrap(); - assert_eq!(loaded_block.header.gas_limit, { partial_header.gas_limit }); - let loaded_tx = loaded_block.transactions.first().unwrap(); + assert_eq!(loaded_block.header.gas_limit(), header.gas_limit()); + let loaded_tx = loaded_block.body.transactions.first().unwrap(); assert_eq!(loaded_tx, &tx); } + + #[test] + fn serialized_blocks_puts_canonical_block_last() { + let block = |timestamp| { + create_block( + Header { number: 1, timestamp, ..Default::default() }.into(), + Vec::>::new(), + ) + }; + let block_a = block(1); + let block_b = block(2); + let (canonical, stale) = if block_a.header.hash_slow() < block_b.header.hash_slow() { + (block_a, block_b) + } else { + (block_b, block_a) + }; + + let mut storage = BlockchainStorage::::empty(); + let stale_hash = storage.insert_block(stale); + let canonical_hash = storage.insert_block(canonical); + assert!(canonical_hash < stale_hash); + + let mut loaded = BlockchainStorage::::empty(); + loaded.load_blocks(storage.serialized_blocks(), None); + assert_eq!(loaded.hashes.get(&1), Some(&canonical_hash)); + } + + #[test] + fn serialized_transactions_are_sorted() { + let transaction = |block_number, transaction_index, transaction_hash| MinedTransaction::< + FoundryNetwork, + > { + info: TransactionInfo { + transaction_hash, + transaction_index, + from: Address::ZERO, + to: None, + contract_address: None, + traces: Vec::new(), + exit: InstructionResult::Stop, + out: None, + nonce: 0, + gas_used: 0, + }, + receipt: FoundryReceiptEnvelope::Legacy(Default::default()), + block_hash: B256::ZERO, + block_number, + }; + let first = B256::with_last_byte(1); + let second = B256::with_last_byte(2); + let third = B256::with_last_byte(3); + let fourth = B256::with_last_byte(4); + let mut storage = BlockchainStorage::::empty(); + for transaction in [ + transaction(2, 0, fourth), + transaction(1, 1, third), + transaction(1, 0, second), + transaction(1, 0, first), + ] { + storage.transactions.insert(transaction.info.transaction_hash, transaction); + } + + let hashes = storage + .serialized_transactions() + .into_iter() + .map(|transaction| transaction.info.transaction_hash) + .collect::>(); + assert_eq!(hashes, [first, second, third, fourth]); + } + + #[test] + fn serialized_historical_states_are_sorted() { + let hashes = [3, 1, 2].map(|number| B256::from(U256::from(number))); + let mut states = InMemoryBlockStates::default(); + for hash in hashes { + states.insert(hash, StateDb::new(MemDb::default())); + } + + let serialized_hashes = + states.serialized_states().into_iter().map(|(hash, _)| hash).collect::>(); + assert_eq!(serialized_hashes, [hashes[1], hashes[2], hashes[0]]); + } + + #[test] + fn test_tempo_storage_dump_reload_cycle() { + let mut dump_storage = BlockchainStorage::::empty(); + let header = TempoHeader { + general_gas_limit: 30_000_000, + shared_gas_limit: 1_000_000, + timestamp_millis_part: 123, + inner: Header { number: 7, gas_limit: 30_000_000, timestamp: 42, ..Default::default() }, + consensus_context: None, + }; + let block = create_block( + header.into(), + Vec::>::new(), + ); + let expected_header = block.header.clone(); + let block_hash = block.header.hash_slow(); + dump_storage.blocks.insert(block_hash, block); + + let serialized = serde_json::to_string(&dump_storage.serialized_blocks()).unwrap(); + let blocks: Vec = serde_json::from_str(&serialized).unwrap(); + let mut load_storage = BlockchainStorage::::empty(); + load_storage.load_blocks(blocks, None); + + let loaded_block = load_storage.blocks.get(&block_hash).unwrap(); + assert_eq!(loaded_block.header, expected_header); + assert_eq!(loaded_block.header.as_tempo().unwrap().shared_gas_limit, 1_000_000); + assert_eq!(load_storage.hashes.get(&7), Some(&block_hash)); + } + + #[test] + fn test_hash_safe_with_non_zero_genesis_number() { + let mut storage = BlockchainStorage::::new( + Header { number: 100, ..Default::default() }.into(), + ); + + for best_number in [100, 131, 132] { + storage.best_number = best_number; + assert_eq!(storage.hash(BlockNumberOrTag::Safe, 32), Some(storage.genesis_hash)); + } + } + + #[test] + fn test_hash_finalized_with_non_zero_genesis_number() { + let mut storage = BlockchainStorage::::new( + Header { number: 100, ..Default::default() }.into(), + ); + + for best_number in [100, 163, 164] { + storage.best_number = best_number; + assert_eq!(storage.hash(BlockNumberOrTag::Finalized, 32), Some(storage.genesis_hash)); + } + } + + #[test] + fn test_hash_safe_finalized_with_deep_non_zero_genesis() { + let mut storage = BlockchainStorage::::new( + Header { number: 100, ..Default::default() }.into(), + ); + storage.best_number = 200; + let safe_hash = B256::repeat_byte(0xab); + let finalized_hash = B256::repeat_byte(0xcd); + storage.hashes.insert(168, safe_hash); + storage.hashes.insert(136, finalized_hash); + + assert_eq!(storage.hash(BlockNumberOrTag::Safe, 32), Some(safe_hash)); + assert_eq!(storage.hash(BlockNumberOrTag::Finalized, 32), Some(finalized_hash)); + } + + #[test] + fn test_hash_safe_finalized_with_zero_genesis() { + let mut storage = BlockchainStorage::::new(Header::default().into()); + + for (tag, offset) in [(BlockNumberOrTag::Safe, 32), (BlockNumberOrTag::Finalized, 64)] { + for best_number in [0, offset - 1, offset] { + storage.best_number = best_number; + assert_eq!(storage.hash(tag, 32), Some(storage.genesis_hash)); + } + + storage.best_number = offset + 1; + assert_eq!(storage.hash(tag, 32), None); + let hash = B256::repeat_byte(0xab); + storage.hashes.insert(1, hash); + assert_eq!(storage.hash(tag, 32), Some(hash)); + storage.hashes.remove(&1); + } + } + + #[test] + fn test_hash_safe_finalized_fork_preserves_local_miss() { + let mut storage = BlockchainStorage::::forked( + 23_000_000, + B256::repeat_byte(0xab), + U256::ZERO, + ); + + for (tag, number) in + [(BlockNumberOrTag::Safe, 22_999_968), (BlockNumberOrTag::Finalized, 22_999_936)] + { + assert_eq!(storage.hash(tag, 32), None); + let hash = B256::repeat_byte(0xcd); + storage.hashes.insert(number, hash); + assert_eq!(storage.hash(tag, 32), Some(hash)); + } + } + + // Regression test for https://github.com/foundry-rs/foundry/issues/12645: + // when a non-zero genesis number is configured (e.g. `--block-number 73 --load-state ...`), + // `load_blocks` must set `genesis_hash` to the loaded block matching `genesis_number`, + // not the hardcoded block 0. + #[test] + fn test_load_blocks_sets_genesis_hash_with_non_zero_genesis_number() { + const GENESIS_NUMBER: u64 = 73; + + // Build a serialized block at the configured genesis number. + let header = Header { number: GENESIS_NUMBER, gas_limit: 123456, ..Default::default() }; + let block = create_block( + header.into(), + Vec::>::new(), + ); + let block_hash = block.header.hash_slow(); + let serialized_blocks: Vec = vec![block.into()]; + + // Simulate a fresh storage started with `--block-number 73`: the dummy block created by + // `new()` is hash X, and `genesis_number` is 73. Loading a state snapshot whose genesis + // is also 73 must rewrite `genesis_hash` to the loaded block's hash. + let mut load_storage = BlockchainStorage::::empty(); + load_storage.genesis_number = GENESIS_NUMBER; + let dummy_genesis_hash = B256::repeat_byte(0xab); + load_storage.genesis_hash = dummy_genesis_hash; + + load_storage.load_blocks(serialized_blocks, None); + + assert_eq!(load_storage.genesis_hash, block_hash); + assert_ne!(load_storage.genesis_hash, dummy_genesis_hash); + + // Sanity check: with the old hardcoded `block_number == 0` logic, `genesis_hash` would + // never be updated when no block 0 is present, so the dummy hash would leak through. + let mut sanity_storage = BlockchainStorage::::empty(); + sanity_storage.genesis_number = 0; + sanity_storage.genesis_hash = dummy_genesis_hash; + + let header_only_73 = + Header { number: GENESIS_NUMBER, gas_limit: 123456, ..Default::default() }; + let block_73 = create_block( + header_only_73.into(), + Vec::>::new(), + ); + sanity_storage.load_blocks(vec![block_73.into()], None); + assert_eq!(sanity_storage.genesis_hash, dummy_genesis_hash); + } } diff --git a/crates/anvil/src/eth/backend/mod.rs b/crates/anvil/src/eth/backend/mod.rs index be2c90b576800..b80a342e1e759 100644 --- a/crates/anvil/src/eth/backend/mod.rs +++ b/crates/anvil/src/eth/backend/mod.rs @@ -1,6 +1,6 @@ //! blockchain Backend -/// [revm](foundry_evm::revm) related types +/// [revm] related types pub mod db; /// In-memory Backend pub mod mem; @@ -8,10 +8,11 @@ pub mod mem; pub mod cheats; pub mod time; -pub mod env; pub mod executor; pub mod fork; pub mod genesis; pub mod info; pub mod notifications; +pub(crate) mod replay; +pub mod tempo; pub mod validate; diff --git a/crates/anvil/src/eth/backend/notifications.rs b/crates/anvil/src/eth/backend/notifications.rs index 795de0cca9a55..5093424491745 100644 --- a/crates/anvil/src/eth/backend/notifications.rs +++ b/crates/anvil/src/eth/backend/notifications.rs @@ -2,9 +2,32 @@ use alloy_consensus::Header; use alloy_primitives::B256; +use alloy_rpc_types::Log; use futures::channel::mpsc::UnboundedReceiver; use std::sync::Arc; +/// A notification that's emitted when the canonical chain was updated. +#[derive(Clone, Debug)] +pub enum ChainNotification { + /// A new block was added to the canonical chain. + Block(NewBlockNotification), + /// Logs of blocks that were removed from the canonical chain due to a reorg. + /// + /// The logs are marked as `removed` and retain the metadata of the removed blocks they were + /// originally included in. + RemovedLogs(Arc>), +} + +impl ChainNotification { + /// Returns the [`NewBlockNotification`] if this notification is for an imported block. + pub const fn as_new_block(&self) -> Option<&NewBlockNotification> { + match self { + Self::Block(block) => Some(block), + Self::RemovedLogs(_) => None, + } + } +} + /// A notification that's emitted when a new block was imported #[derive(Clone, Debug)] pub struct NewBlockNotification { @@ -14,5 +37,5 @@ pub struct NewBlockNotification { pub header: Arc
, } -/// Type alias for a receiver that receives [NewBlockNotification] -pub type NewBlockNotifications = UnboundedReceiver; +/// Type alias for a receiver that receives [ChainNotification] +pub type ChainNotifications = UnboundedReceiver; diff --git a/crates/anvil/src/eth/backend/replay.rs b/crates/anvil/src/eth/backend/replay.rs new file mode 100644 index 0000000000000..c527d1184fa8a --- /dev/null +++ b/crates/anvil/src/eth/backend/replay.rs @@ -0,0 +1,401 @@ +//! Transaction-hash fork replay preparation and execution. + +use crate::{ + config::ForkTransactionReplay, + eth::backend::executor::AnvilBlockExecutor, + mem::inspector::{AnvilInspector, InspectorTxConfig}, +}; +use alloy_consensus::{ + BlockHeader, Transaction, Typed2718, + transaction::{Recovered, SignerRecoverable, TxHashRef}, +}; +use alloy_evm::{ + Evm, FromRecoveredTx, FromTxWithEncoded, RecoveredTx, + block::{ + BalIndexedDatabase, BlockExecutionError, BlockExecutionResult, BlockExecutor, StateDB, + TxResult, + }, + precompiles::PrecompilesMap, +}; +use alloy_network::{BlockResponse, TransactionResponse}; +use alloy_primitives::B256; +use anvil_core::eth::transaction::{MaybeImpersonatedTransaction, TransactionInfo}; +use eyre::{Context, Result}; +use foundry_common::sh_warn; +use foundry_evm::core::evm::IntoInstructionResult; +use foundry_primitives::{FoundryReceiptEnvelope, FoundryTxEnvelope, FoundryTxType}; +use revm::{ + Database, + context_interface::result::{ExecutionResult, Output}, + interpreter::InstructionResult, + state::EvmState, +}; + +/// A source transaction prepared for direct historical execution. +#[derive(Clone, Debug)] +pub(crate) struct HistoricalReplayTransaction { + pub(crate) transaction: Recovered, + /// Where this transaction sat in the source block, for diagnostics only. + /// + /// The replayed block holds just the transactions anvil executed, so this is not its index + /// there; storage keys receipts and traces by position in that block. + pub(crate) source_index: usize, +} + +/// A validated transaction prefix together with its source block execution inputs. +pub(crate) struct PreparedForkTransactionReplay { + pub(crate) transactions: Vec, + pub(crate) timestamp: u64, + pub(crate) parent_beacon_block_root: Option, +} + +impl PreparedForkTransactionReplay { + /// Resolves the execution chain ID encoded by the source prefix. + /// + /// Unprotected legacy prefixes inherit the execution identity exposed by the endpoint. + pub(crate) fn execution_chain_id(&self, fallback: u64) -> Result { + let mut resolved = None; + for replay in &self.transactions { + let Some(chain_id) = replay.transaction.tx().chain_id() else { continue }; + if let Some(expected) = resolved { + eyre::ensure!( + chain_id == expected, + "source transaction at index {} uses chain ID {chain_id}, expected {expected}", + replay.source_index + ); + } else { + resolved = Some(chain_id); + } + } + Ok(resolved.unwrap_or(fallback)) + } +} + +/// The complete result of executing a historical prefix against an overlay. +pub(crate) struct ExecutedHistoricalReplay { + pub(crate) block_result: BlockExecutionResult, + pub(crate) transactions: Vec>, + pub(crate) transaction_infos: Vec, + pub(crate) state_changes: Vec, +} + +/// Converts and validates every source-prefix transaction before database execution. +pub(crate) fn prepare_fork_transaction_replay( + replay: ForkTransactionReplay, + #[cfg_attr(not(feature = "monad"), allow(unused_variables))] trust_monad_protocol_sender: bool, + allow_cip64: bool, +) -> Result { + let source_hash = replay.source_block.header().hash; + let source_number = replay.source_block.header().number; + let timestamp = replay.source_block.header().timestamp(); + let parent_beacon_block_root = replay.source_block.header().parent_beacon_block_root(); + let source_transactions = replay + .source_block + .transactions() + .as_transactions() + .expect("full source block validated during resolution"); + + let target_index = replay.target_index; + let transactions = source_transactions + .iter() + .take(target_index.saturating_add(1)) + .enumerate() + .map(|(source_index, source_transaction)| { + let source_transaction_hash = source_transaction.tx_hash(); + // Chains anvil can fork but not execute, such as Arbitrum and its Orbit rollups, mint + // their own transaction types; Arbitrum opens every block with an `ArbitrumInternalTx`. + // Those carry no EVM semantics anvil could apply, so the prefix skips them instead of + // failing the whole replay. The requested transaction itself must still be executable, + // otherwise the resulting fork would not be the state the caller asked for. + if FoundryTxType::try_from(source_transaction.ty()).is_err() + || (source_transaction.ty() == foundry_primitives::CIP64_TX_TYPE && !allow_cip64) + { + eyre::ensure!( + source_index != target_index, + "fork transaction {source_transaction_hash} in block {source_hash} \ + ({source_number}) has type 0x{:x}, which anvil cannot execute", + source_transaction.ty(), + ); + sh_warn!( + "skipping source transaction {source_transaction_hash} at index \ + {source_index} with unsupported type 0x{:x}; replayed state will not \ + include its effects", + source_transaction.ty(), + )?; + return Ok(None); + } + let transaction = FoundryTxEnvelope::try_from(source_transaction.clone()) + .wrap_err_with(|| { + format!( + "failed to convert source transaction {source_transaction_hash} at index \ + {source_index} in block {source_hash} ({source_number})" + ) + })?; + eyre::ensure!( + transaction.tx_hash() == &source_transaction_hash, + "converted source transaction at index {source_index} in block {source_hash} \ + ({source_number}) changed hash from {source_transaction_hash} to {}", + transaction.tx_hash() + ); + #[cfg(feature = "monad")] + let sender = if trust_monad_protocol_sender + && source_transaction.from() == monad_revm::staking::constants::SYSTEM_ADDRESS + { + source_transaction.from() + } else { + transaction.recover_signer().wrap_err_with(|| { + format!( + "failed to recover sender for source transaction \ + {source_transaction_hash} at index {source_index} in block {source_hash} \ + ({source_number})" + ) + })? + }; + #[cfg(not(feature = "monad"))] + let sender = transaction.recover_signer().wrap_err_with(|| { + format!( + "failed to recover sender for source transaction {source_transaction_hash} at \ + index {source_index} in block {source_hash} ({source_number})" + ) + })?; + Ok(Some(HistoricalReplayTransaction { + transaction: Recovered::new_unchecked(transaction, sender), + source_index, + })) + }) + .filter_map(Result::transpose) + .collect::>()?; + + Ok(PreparedForkTransactionReplay { transactions, timestamp, parent_beacon_block_root }) +} + +/// Executes a prepared prefix strictly and captures changesets for deferred publication. +pub(crate) fn execute_historical_replay( + executor: &mut AnvilBlockExecutor, + transactions: &[HistoricalReplayTransaction], + inspector_config: &InspectorTxConfig, +) -> Result<(Vec>, Vec)> +where + E: Evm< + DB: StateDB + BalIndexedDatabase, + Inspector = AnvilInspector, + Precompiles = PrecompilesMap, + Tx: FromRecoveredTx + FromTxWithEncoded, + >, + E::HaltReason: Clone + IntoInstructionResult, +{ + execute_historical_replay_with( + executor, + transactions, + inspector_config, + |evm, tx_env, transaction_hash| { + evm.transact(tx_env).map_err(|err| BlockExecutionError::evm(err, transaction_hash)) + }, + ) +} + +/// Executes a prepared prefix using a caller-selected network transaction entry point. +pub(crate) fn execute_historical_replay_with( + executor: &mut AnvilBlockExecutor, + transactions: &[HistoricalReplayTransaction], + inspector_config: &InspectorTxConfig, + mut transact: F, +) -> Result<(Vec>, Vec)> +where + E: Evm< + DB: StateDB + BalIndexedDatabase, + Inspector = AnvilInspector, + Precompiles = PrecompilesMap, + Tx: FromRecoveredTx + FromTxWithEncoded, + >, + E::HaltReason: Clone + IntoInstructionResult, + F: FnMut( + &mut E, + E::Tx, + B256, + ) -> Result< + revm::context_interface::result::ResultAndState, + BlockExecutionError, + >, +{ + let mut stored_transactions = Vec::with_capacity(transactions.len()); + let mut transaction_infos = Vec::with_capacity(transactions.len()); + + for (execution_index, replay) in transactions.iter().enumerate() { + let transaction = replay.transaction.tx(); + let transaction_hash = *transaction.tx_hash(); + let sender = replay.transaction.signer(); + let nonce = executor + .evm_mut() + .db_mut() + .basic(sender) + .wrap_err_with(|| { + format!( + "database error preparing source transaction {transaction_hash} at index {}", + replay.source_index + ) + })? + .unwrap_or_default() + .nonce; + + let result = executor + .execute_transaction_without_commit_with( + replay.transaction.clone().into_encoded(), + &mut transact, + ) + .map_err(|err| { + eyre::eyre!( + "failed to execute source transaction {transaction_hash} at index {}: {err}", + replay.source_index + ) + })?; + let execution_result = result.result().result.clone(); + let gas_used = execution_result.tx_gas_used(); + executor.commit_transaction(result); + + let (_, inspector, precompiles) = executor.evm_mut().components_mut(); + let traces = inspector.finish_transaction(inspector_config, precompiles); + let (exit_reason, out) = match execution_result { + ExecutionResult::Success { reason, output, .. } => (reason.into(), Some(output)), + ExecutionResult::Revert { output, .. } => { + (InstructionResult::Revert, Some(Output::Call(output))) + } + ExecutionResult::Halt { reason, .. } => (reason.into_instruction_result(), None), + }; + let contract_address = transaction.to().is_none().then(|| sender.create(nonce)); + transaction_infos.push(TransactionInfo { + transaction_hash, + transaction_index: execution_index as u64, + from: sender, + to: transaction.to(), + contract_address, + traces, + exit: exit_reason, + out: out.map(Output::into_data), + nonce, + gas_used, + }); + stored_transactions.push(MaybeImpersonatedTransaction::new(transaction.clone())); + } + + Ok((stored_transactions, transaction_infos)) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_consensus::{SignableTransaction, TxEip1559}; + use alloy_network::AnyRpcBlock; + use alloy_primitives::{Signature, U256}; + use alloy_rpc_types::BlockTransactions; + use foundry_primitives::TxCip64; + + /// A real signed legacy transaction, so signer recovery and the hash check pass. + const LEGACY_TX: &str = r#"{ + "type": "0x0", + "chainId": "0x1", + "nonce": "0x0", + "gas": "0x5208", + "gasPrice": "0x1", + "to": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", + "value": "0x1", + "input": "0x", + "r": "0x85c2794a580da137e24ccc823b45ae5cea99371ae23ee13860fcc6935f8305b0", + "s": "0x41de7fa4121dab284af4453d30928241208bafa90cdb701fe9bc7054759fe3cd", + "v": "0x1b", + "hash": "0x8c9b68e8947ace33028dba167354fde369ed7bbe34911b772d09b3c64b861515", + "from": "0xa1e4380a3b1f749673e270229993ee55f35663b4", + "blockHash": "0x1111111111111111111111111111111111111111111111111111111111111111", + "blockNumber": "0x1", + "transactionIndex": "0x1" + }"#; + + /// An `ArbitrumInternalTx`, the type Arbitrum opens each block with. + const ARBITRUM_INTERNAL_TX: &str = r#"{ + "type": "0x6a", + "chainId": "0xa4b1", + "nonce": "0x0", + "gas": "0x0", + "gasPrice": "0x0", + "to": "0x00000000000000000000000000000000000a4b05", + "value": "0x0", + "input": "0x6bf6a42d", + "hash": "0x2222222222222222222222222222222222222222222222222222222222222222", + "from": "0x00000000000000000000000000000000000a4b05", + "blockHash": "0x1111111111111111111111111111111111111111111111111111111111111111", + "blockNumber": "0x1", + "transactionIndex": "0x0" + }"#; + + fn source_block() -> AnyRpcBlock { + let json = format!( + r#"{{ + "hash": "0x1111111111111111111111111111111111111111111111111111111111111111", + "parentHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "sha3Uncles": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", + "miner": "0x0000000000000000000000000000000000000000", + "stateRoot": "0x0000000000000000000000000000000000000000000000000000000000000000", + "transactionsRoot": "0x0000000000000000000000000000000000000000000000000000000000000000", + "receiptsRoot": "0x0000000000000000000000000000000000000000000000000000000000000000", + "logsBloom": "0x{bloom}", + "difficulty": "0x0", + "number": "0x1", + "gasLimit": "0x1c9c380", + "gasUsed": "0x5208", + "timestamp": "0x64", + "extraData": "0x", + "mixHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "nonce": "0x0000000000000000", + "uncles": [], + "transactions": [{ARBITRUM_INTERNAL_TX}, {LEGACY_TX}] + }}"#, + bloom = "0".repeat(512), + ); + serde_json::from_str(&json).unwrap() + } + + fn replay_for(target_index: usize) -> ForkTransactionReplay { + ForkTransactionReplay { source_block: source_block(), target_index } + } + + #[test] + fn skips_unsupported_prefix_transactions() { + let prepared = prepare_fork_transaction_replay(replay_for(1), false, false).unwrap(); + + // The Arbitrum-typed transaction at index 0 is dropped, and the standard one keeps its + // position in the source block. + assert_eq!(prepared.transactions.len(), 1); + assert_eq!(prepared.transactions[0].source_index, 1); + } + + #[test] + fn rejects_unsupported_target_transaction() { + let Err(err) = prepare_fork_transaction_replay(replay_for(0), false, false) else { + panic!("expected the unsupported target transaction to be rejected"); + }; + assert!(err.to_string().contains("0x6a"), "unexpected error: {err}"); + } + + #[test] + fn fork_replay_cip64_requires_celo_mode() { + let envelope = FoundryTxEnvelope::Celo( + TxCip64 { inner: TxEip1559 { chain_id: 1, ..Default::default() }, fee_currency: None } + .into_signed(Signature::new(U256::ONE, U256::from(2), false)), + ); + let mut transaction = serde_json::to_value(&envelope).unwrap(); + transaction["from"] = serde_json::to_value(envelope.recover_signer().unwrap()).unwrap(); + let mut block = source_block(); + block.transactions = BlockTransactions::Full(vec![ + serde_json::from_value(transaction).unwrap(), + serde_json::from_str(LEGACY_TX).unwrap(), + ]); + let replay = + |target_index| ForkTransactionReplay { source_block: block.clone(), target_index }; + let prepared = prepare_fork_transaction_replay(replay(1), false, false).unwrap(); + assert_eq!(prepared.transactions.len(), 1); + assert_eq!(prepared.transactions[0].source_index, 1); + assert!(prepare_fork_transaction_replay(replay(0), false, false).is_err()); + let prepared = prepare_fork_transaction_replay(replay(0), false, true).unwrap(); + assert_eq!(prepared.transactions[0].transaction.tx(), &envelope); + } +} diff --git a/crates/anvil/src/eth/backend/tempo.rs b/crates/anvil/src/eth/backend/tempo.rs new file mode 100644 index 0000000000000..30d777d04f92d --- /dev/null +++ b/crates/anvil/src/eth/backend/tempo.rs @@ -0,0 +1,334 @@ +//! Tempo precompile and fee token initialization for Anvil. +//! +//! When running in Tempo mode, Anvil needs to set up Tempo-specific precompiles +//! and fee tokens (PathUSD, AlphaUSD, BetaUSD, ThetaUSD) to enable proper +//! transaction validation. +//! +//! This module provides a storage provider adapter for Anvil's `Db` trait and +//! uses the shared initialization logic from `foundry-evm-core`. + +use alloy_primitives::{Address, B256, U256, address}; +use foundry_evm::core::tempo::{ + ALPHA_USD_ADDRESS, BETA_USD_ADDRESS, PATH_USD_ADDRESS, THETA_USD_ADDRESS, + initialize_tempo_genesis_at_hardfork, +}; +use revm::{ + DatabaseRef, + context::{BlockEnv, journaled_state::JournalCheckpoint}, + state::{AccountInfo, Bytecode}, +}; +use std::collections::HashMap; +use tempo_hardfork::TempoHardfork; +use tempo_precompiles::{ + TIP_FEE_MANAGER_ADDRESS, + account_keychain::{ + AccountKeychain, + IAccountKeychain::{KeyRestrictions, SignatureType}, + }, + error::TempoPrecompileError, + storage::{PrecompileStorageProvider, StorageCtx}, + tip_fee_manager::{IFeeManager, TipFeeManager}, + tip20::{ITIP20, TIP20Token}, +}; +use tempo_primitives::TempoBlockEnv; + +use super::db::Db; + +/// Sender address used for genesis initialization. +const SENDER: Address = address!("0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38"); +/// Admin address used for genesis initialization. +const ADMIN: Address = address!("0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f"); + +/// Storage provider adapter for Anvil's Db to work with Tempo precompiles. +pub struct AnvilStorageProvider<'a> { + db: &'a mut dyn Db, + chain_id: u64, + block_env: TempoBlockEnv, + gas_used: u64, + gas_refunded: i64, + reservoir: u64, + tip1060_storage_credits_enabled: bool, + transient: HashMap<(Address, U256), U256>, + hardfork: TempoHardfork, +} + +impl<'a> AnvilStorageProvider<'a> { + pub fn new( + db: &'a mut dyn Db, + chain_id: u64, + timestamp: U256, + block_number: u64, + hardfork: TempoHardfork, + ) -> Self { + Self { + db, + chain_id, + block_env: TempoBlockEnv { + inner: BlockEnv { + timestamp, + number: U256::from(block_number), + ..Default::default() + }, + ..Default::default() + }, + gas_used: 0, + gas_refunded: 0, + reservoir: 0, + tip1060_storage_credits_enabled: hardfork.is_t7(), + transient: HashMap::new(), + hardfork, + } + } +} + +impl PrecompileStorageProvider for AnvilStorageProvider<'_> { + fn spec(&self) -> TempoHardfork { + self.hardfork + } + + fn chain_id(&self) -> u64 { + self.chain_id + } + + fn block_env(&self) -> &TempoBlockEnv { + &self.block_env + } + + fn set_code(&mut self, address: Address, code: Bytecode) -> Result<(), TempoPrecompileError> { + self.db.insert_account(address, AccountInfo::from_bytecode(code)); + Ok(()) + } + + fn with_account_info( + &mut self, + address: Address, + f: &mut dyn FnMut(&AccountInfo), + ) -> Result<(), TempoPrecompileError> { + if let Some(info) = + self.db.basic_ref(address).map_err(|e| TempoPrecompileError::Fatal(e.to_string()))? + { + f(&info); + Ok(()) + } else { + Err(TempoPrecompileError::Fatal(format!("account '{address}' not found"))) + } + } + + fn account_code(&mut self, address: Address) -> Result<(B256, Bytecode), TempoPrecompileError> { + let Some(info) = + self.db.basic_ref(address).map_err(|e| TempoPrecompileError::Fatal(e.to_string()))? + else { + return Ok((B256::ZERO, Bytecode::default())); + }; + let code_hash = info.code_hash(); + let code = if let Some(code) = info.code { + code + } else { + self.db + .code_by_hash_ref(code_hash) + .map_err(|e| TempoPrecompileError::Fatal(e.to_string()))? + }; + Ok((code_hash, code)) + } + + fn sstore( + &mut self, + address: Address, + key: U256, + value: U256, + ) -> Result<(), TempoPrecompileError> { + self.db + .set_storage_at(address, B256::from(key), B256::from(value)) + .map_err(|e| TempoPrecompileError::Fatal(e.to_string())) + } + + fn sload(&mut self, address: Address, key: U256) -> Result { + revm::Database::storage(self.db, address, key) + .map_err(|e| TempoPrecompileError::Fatal(e.to_string())) + } + + fn tstore( + &mut self, + address: Address, + key: U256, + value: U256, + ) -> Result<(), TempoPrecompileError> { + self.transient.insert((address, key), value); + Ok(()) + } + + fn tload(&mut self, address: Address, key: U256) -> Result { + Ok(self.transient.get(&(address, key)).copied().unwrap_or(U256::ZERO)) + } + + fn emit_event( + &mut self, + _address: Address, + _event: alloy_primitives::LogData, + ) -> Result<(), TempoPrecompileError> { + Ok(()) + } + + fn deduct_gas(&mut self, gas: u64) -> Result<(), TempoPrecompileError> { + self.gas_used = self.gas_used.saturating_add(gas); + Ok(()) + } + + fn gas_used(&self) -> u64 { + self.gas_used + } + + fn state_gas_used(&self) -> u64 { + 0 + } + + fn state_gas_spilled(&self) -> u64 { + 0 + } + + fn gas_limit(&self) -> u64 { + u64::MAX + } + + fn gas_refunded(&self) -> i64 { + self.gas_refunded + } + + fn reservoir(&self) -> u64 { + self.reservoir + } + + fn refund_gas(&mut self, gas: i64) { + self.gas_refunded = self.gas_refunded.saturating_add(gas); + } + + fn is_static(&self) -> bool { + false + } + + fn checkpoint(&mut self) -> JournalCheckpoint { + JournalCheckpoint { log_i: 0, journal_i: 0, selfdestructed_i: 0 } + } + + fn checkpoint_commit(&mut self, _checkpoint: JournalCheckpoint) {} + + fn checkpoint_revert(&mut self, _checkpoint: JournalCheckpoint) {} + + fn amsterdam_eip8037_enabled(&self) -> bool { + false + } + + fn set_tip1060_storage_credits(&mut self, enabled: bool) { + self.tip1060_storage_credits_enabled = enabled && self.hardfork.is_t7(); + } +} + +/// Initialize Tempo precompiles and fee tokens for Anvil. +/// +/// This sets up the same precompiles and tokens as Tempo's genesis, enabling +/// proper fee token validation for transactions. +/// +/// Additionally, mints fee tokens to the provided test accounts so they can +/// send transactions in Tempo mode. +pub fn initialize_tempo_precompiles( + db: &mut dyn Db, + chain_id: u64, + timestamp: u64, + test_accounts: &[Address], + hardfork: TempoHardfork, +) -> Result<(), TempoPrecompileError> { + let timestamp = U256::from(timestamp); + + let mut storage = AnvilStorageProvider::new(db, chain_id, timestamp, 0, hardfork); + + // Initialize base Tempo genesis (precompiles and tokens) + initialize_tempo_genesis_at_hardfork(&mut storage, ADMIN, SENDER, hardfork)?; + + // Mint fee tokens to test accounts + // u64::MAX per account - safe since u128::MAX can hold ~18 quintillion u64::MAX values + let mint_amount = U256::from(u64::MAX); + let tokens = [PATH_USD_ADDRESS, ALPHA_USD_ADDRESS, BETA_USD_ADDRESS, THETA_USD_ADDRESS]; + + StorageCtx::enter(&mut storage, || -> Result<(), TempoPrecompileError> { + // Mint fee tokens to test accounts + for &token_address in &tokens { + let mut token = TIP20Token::from_address(token_address)?; + for &account in test_accounts { + token.mint(ADMIN, ITIP20::mintCall { to: account, amount: mint_amount })?; + } + } + + // Register secp256k1 keys for test accounts in the AccountKeychain + // This allows them to sign Tempo transactions using their private keys. + // The key ID is the account address itself (standard for secp256k1 keys). + let mut keychain = AccountKeychain::new(); + for &account in test_accounts { + // Seed tx_origin so ensure_admin_caller passes on T2+ (requires + // tx_origin != zero && tx_origin == msg_sender). + keychain.set_tx_origin(account)?; + keychain.authorize_key( + account, // msg_sender (root account authorizes its own key) + account, // key ID = account address for secp256k1 + SignatureType::Secp256k1, + KeyRestrictions { + expiry: u64::MAX, // never expires + enforceLimits: false, // no spending limits + limits: vec![], + allowAnyCalls: true, + allowedCalls: vec![], + }, + None, + )?; + } + + // Initialize TipFeeManager and set default fee tokens for test accounts + // Alice (0) -> AlphaUSD, Bob (1) -> BetaUSD, Charlie (2) -> ThetaUSD, others -> PathUSD + let mut fee_manager = TipFeeManager::new(); + fee_manager.initialize()?; + + for (i, &account) in test_accounts.iter().enumerate() { + let fee_token = match i { + 0 => ALPHA_USD_ADDRESS, // Alice + 1 => BETA_USD_ADDRESS, // Bob + 2 => THETA_USD_ADDRESS, // Charlie + _ => PATH_USD_ADDRESS, // Everyone else + }; + fee_manager + .set_user_token(account, IFeeManager::setUserTokenCall { token: fee_token })?; + } + + // Mint fee tokens to the FeeManager contract for liquidity operations + for &token_address in &tokens { + let mut token = TIP20Token::from_address(token_address)?; + token.mint( + ADMIN, + ITIP20::mintCall { to: TIP_FEE_MANAGER_ADDRESS, amount: mint_amount }, + )?; + } + + // Mint pairwise FeeAMM liquidity for all fee token pairs (both directions) + // This enables EIP-1559/legacy transactions by allowing fee swaps between tokens + // Liquidity amount: 10^10 tokens (matching Tempo genesis) + let liquidity_amount = U256::from(10u64.pow(10)); + + // Create bidirectional liquidity pools between all fee tokens + // Pools are directional: user_token -> validator_token + for &user_token in &tokens { + for &validator_token in &tokens { + if user_token != validator_token { + fee_manager.mint( + ADMIN, + user_token, + validator_token, + liquidity_amount, + ADMIN, + )?; + } + } + } + + Ok(()) + })?; + + Ok(()) +} diff --git a/crates/anvil/src/eth/backend/time.rs b/crates/anvil/src/eth/backend/time.rs index 175f2792430b3..1cf3bee77adea 100644 --- a/crates/anvil/src/eth/backend/time.rs +++ b/crates/anvil/src/eth/backend/time.rs @@ -5,34 +5,62 @@ use chrono::{DateTime, Utc}; use parking_lot::RwLock; use std::{sync::Arc, time::Duration}; -/// Returns the `Utc` datetime for the given seconds since unix epoch -pub fn utc_from_secs(secs: u64) -> DateTime { - DateTime::from_timestamp(secs as i64, 0).unwrap() -} - /// Manages block time #[derive(Clone, Debug)] pub struct TimeManager { - /// tracks the overall applied timestamp offset - offset: Arc>, - /// The timestamp of the last block header - last_timestamp: Arc>, - /// Contains the next timestamp to use - /// if this is set then the next time `[TimeManager::current_timestamp()]` is called this value - /// will be taken and returned. After which the `offset` will be updated accordingly - next_exact_timestamp: Arc>>, - /// The interval to use when determining the next block's timestamp - interval: Arc>>, + state: Arc>, +} + +/// Timestamp controls that must be read and committed atomically. +#[derive(Debug, Default)] +struct TimeState { + offset: i128, + /// Explicit increases not yet committed, separate from the wall-clock offset. + time_increase: u64, + offset_reset_generation: u64, + last_timestamp: u64, + last_block_wall_time: u64, + next_exact_timestamp: Option, + interval: Option, + next_override_generation: u64, +} + +#[derive(Clone, Copy, Debug)] +struct TimestampOverride { + timestamp: u64, + generation: u64, +} + +/// A timestamp prepared for a candidate block but not yet committed. +#[derive(Clone, Copy, Debug)] +pub(crate) struct PendingBlockTimestamp { + pub(crate) timestamp: u64, + exact_generation: Option, + prepared_offset: i128, + offset_reset_generation: u64, + next_offset: Option, + time_increase: u64, +} + +/// Time controls captured by an in-memory state snapshot. +#[derive(Clone, Copy, Debug)] +pub(crate) struct TimeSnapshot { + offset: i128, + last_timestamp: u64, + next_block_timestamp: Option, + time_increase: u64, +} + +/// A temporary additive time increase that can be rolled back after failed manual mining. +#[derive(Clone, Copy, Debug)] +pub(crate) struct PendingTimeIncrease { + seconds: u64, + offset_reset_generation: u64, } impl TimeManager { pub fn new(start_timestamp: u64) -> Self { - let time_manager = Self { - last_timestamp: Default::default(), - offset: Default::default(), - next_exact_timestamp: Default::default(), - interval: Default::default(), - }; + let time_manager = Self { state: Default::default() }; time_manager.reset(start_timestamp); time_manager } @@ -40,22 +68,82 @@ impl TimeManager { /// Resets the current time manager to the given timestamp, resetting the offsets and /// next block timestamp option pub fn reset(&self, start_timestamp: u64) { - let current = duration_since_unix_epoch().as_secs() as i128; - *self.last_timestamp.write() = start_timestamp; - *self.offset.write() = (start_timestamp as i128) - current; - self.next_exact_timestamp.write().take(); + self.reset_timestamp(start_timestamp, true, None, None, 0); + } + + /// Sets the current timestamp without changing when the current head was installed. + pub fn set_time(&self, timestamp: u64) { + self.reset_timestamp(timestamp, false, None, None, 0); + } + + /// Restores the timestamp and offset captured by a state snapshot. + pub(crate) fn restore(&self, snapshot: TimeSnapshot) { + self.reset_timestamp( + snapshot.last_timestamp, + true, + Some(snapshot.offset), + snapshot.next_block_timestamp, + snapshot.time_increase, + ); + } + + fn reset_timestamp( + &self, + start_timestamp: u64, + mark_new_head: bool, + offset: Option, + next_block_timestamp: Option, + time_increase: u64, + ) { + let current = duration_since_unix_epoch(); + let mut state = self.state.write(); + state.last_timestamp = start_timestamp; + state.time_increase = time_increase; + if mark_new_head { + state.last_block_wall_time = current.as_millis().try_into().unwrap_or(u64::MAX); + } + state.offset = + offset.unwrap_or_else(|| (start_timestamp as i128) - current.as_secs() as i128); + state.offset_reset_generation = state.offset_reset_generation.wrapping_add(1); + state.next_override_generation = state.next_override_generation.wrapping_add(1); + state.next_exact_timestamp = next_block_timestamp.map(|timestamp| TimestampOverride { + timestamp, + generation: state.next_override_generation, + }); } pub fn offset(&self) -> i128 { - *self.offset.read() + self.state.read().offset + } + + pub(crate) fn snapshot(&self) -> TimeSnapshot { + let state = self.state.read(); + TimeSnapshot { + offset: state.offset, + last_timestamp: state.last_timestamp, + next_block_timestamp: state.next_exact_timestamp.map(|override_| override_.timestamp), + time_increase: state.time_increase, + } + } + + /// Returns the UNIX wall time in milliseconds when the current head was installed. + pub fn last_block_wall_time(&self) -> u64 { + self.state.read().last_block_wall_time + } + + /// Records that a new latest block was created. + pub(crate) fn mark_block_created(&self) { + self.state.write().last_block_wall_time = + duration_since_unix_epoch().as_millis().try_into().unwrap_or(u64::MAX); } /// Adds the given `offset` to the already tracked offset and returns the result - fn add_offset(&self, offset: i128) -> i128 { - let mut current = self.offset.write(); - let next = current.saturating_add(offset); + fn add_offset(&self, offset: u64) -> i128 { + let mut state = self.state.write(); + let next = state.offset.saturating_add(offset as i128); trace!(target: "time", "adding timestamp offset={}, total={}", offset, next); - *current = next; + state.offset = next; + state.time_increase = state.time_increase.saturating_add(offset); next } @@ -63,19 +151,39 @@ impl TimeManager { /// /// This will apply a permanent offset to the natural UNIX Epoch timestamp pub fn increase_time(&self, seconds: u64) -> i128 { - self.add_offset(seconds as i128) + self.add_offset(seconds) + } + + /// Applies a temporary increase that can be rolled back if mining fails. + pub(crate) fn apply_time_increase(&self, seconds: u64) -> PendingTimeIncrease { + let mut state = self.state.write(); + state.offset = state.offset.saturating_add(seconds as i128); + state.time_increase = state.time_increase.saturating_add(seconds); + PendingTimeIncrease { seconds, offset_reset_generation: state.offset_reset_generation } } - /// Sets the exact timestamp to use in the next block - /// Fails if it's before (or at the same time) the last timestamp + /// Reverts a temporary increase unless an absolute time reset superseded it. + pub(crate) fn revert_time_increase(&self, pending: PendingTimeIncrease) { + let mut state = self.state.write(); + if state.offset_reset_generation == pending.offset_reset_generation { + state.offset = state.offset.saturating_sub(pending.seconds as i128); + state.time_increase = state.time_increase.saturating_sub(pending.seconds); + } + } + + /// Sets the exact timestamp to use in the next block. + /// Fails if it's before the last timestamp. pub fn set_next_block_timestamp(&self, timestamp: u64) -> Result<(), BlockchainError> { trace!(target: "time", "override next timestamp {}", timestamp); - if timestamp < *self.last_timestamp.read() { + let mut state = self.state.write(); + if timestamp < state.last_timestamp { return Err(BlockchainError::TimestampError(format!( "{timestamp} is lower than previous block's timestamp" ))); } - self.next_exact_timestamp.write().replace(timestamp); + state.next_override_generation = state.next_override_generation.wrapping_add(1); + state.next_exact_timestamp = + Some(TimestampOverride { timestamp, generation: state.next_override_generation }); Ok(()) } @@ -85,12 +193,17 @@ impl TimeManager { /// be set starting with the current timestamp. pub fn set_block_timestamp_interval(&self, interval: u64) { trace!(target: "time", "set interval {}", interval); - self.interval.write().replace(interval); + self.state.write().interval = Some(interval); + } + + /// Returns the configured block timestamp interval. + pub(crate) fn block_timestamp_interval(&self) -> Option { + self.state.read().interval } /// Removes the interval if it exists pub fn remove_block_timestamp_interval(&self) -> bool { - if self.interval.write().take().is_some() { + if self.state.write().interval.take().is_some() { trace!(target: "time", "removed interval"); true } else { @@ -99,45 +212,143 @@ impl TimeManager { } /// Computes the next timestamp without updating internals - fn compute_next_timestamp(&self) -> (u64, Option) { - let current = duration_since_unix_epoch().as_secs() as i128; - let last_timestamp = *self.last_timestamp.read(); + fn compute_next_timestamp( + state: &TimeState, + current: i128, + pin_timestamp: bool, + ) -> (u64, Option, Option) { + let exact_timestamp = state.next_exact_timestamp; + let last_timestamp = state.last_timestamp; + + let (mut next_timestamp, update_offset) = if let Some(next) = exact_timestamp { + (next.timestamp, true) + } else if let Some(interval) = state.interval { + (last_timestamp.saturating_add(interval), false) + } else { + // A burst of blocks within one wall-clock second leaves `last_timestamp` ahead of + // `wall + offset`; keep a pending explicit increase on top of the logical clock so the + // monotonicity clamp below does not swallow it. + let wall = current.saturating_add(state.offset) as u64; + (wall.max(last_timestamp.saturating_add(state.time_increase)), false) + }; + // Equal timestamps are only allowed when explicitly requested (exact override, an + // interval, or a zero-interval mine such as `anvil_mine(n, 0)`). On the default path + // timestamps must strictly increase. The zero-interval flag only allows equality: the + // timestamp itself still comes from the wall clock or the configured interval. + let allow_equal = exact_timestamp.is_some() || pin_timestamp || state.interval.is_some(); + let too_low = if allow_equal { + next_timestamp < last_timestamp + } else { + next_timestamp <= last_timestamp + }; + if too_low { + next_timestamp = last_timestamp.saturating_add(1); + } + let next_offset = update_offset.then_some((next_timestamp as i128) - current); + (next_timestamp, exact_timestamp.map(|exact| exact.generation), next_offset) + } + + /// Prepares the next timestamp without consuming a one-shot override. + pub(crate) fn prepare_next_timestamp(&self, pin_timestamp: bool) -> PendingBlockTimestamp { + self.prepare_next_timestamp_inner(None, pin_timestamp) + } - let (mut next_timestamp, update_offset) = - if let Some(next) = *self.next_exact_timestamp.read() { - (next, true) - } else if let Some(interval) = *self.interval.read() { - (last_timestamp.saturating_add(interval), false) + /// Prepares a logical block clock, using explicit controls instead of elapsed wall time. + /// + /// `increment` is the whole-second carry supplied by the chain's block schedule. The + /// minimum keeps a sub-second rollover monotonic even when an override repeats the parent. + #[cfg(any(feature = "base", test))] + pub(crate) fn prepare_next_timestamp_with_increment( + &self, + increment: u64, + minimum: u64, + ) -> PendingBlockTimestamp { + self.prepare_next_timestamp_inner(Some((increment, minimum)), false) + } + + fn prepare_next_timestamp_inner( + &self, + schedule: Option<(u64, u64)>, + pin_timestamp: bool, + ) -> PendingBlockTimestamp { + let current = duration_since_unix_epoch().as_secs() as i128; + let state = self.state.read(); + let (timestamp, exact_generation, next_offset) = + if let Some((increment, minimum)) = schedule { + let timestamp = state + .next_exact_timestamp + .map_or_else( + || { + state + .last_timestamp + .saturating_add(state.interval.unwrap_or(increment)) + .saturating_add(state.time_increase) + }, + |next| next.timestamp, + ) + .max(minimum); + ( + timestamp, + state.next_exact_timestamp.map(|next| next.generation), + Some(timestamp as i128 - current), + ) } else { - (current.saturating_add(self.offset()) as u64, false) + Self::compute_next_timestamp(&state, current, pin_timestamp) }; - // Ensures that the timestamp is always increasing - if next_timestamp < last_timestamp { - next_timestamp = last_timestamp + 1; + PendingBlockTimestamp { + timestamp, + exact_generation, + prepared_offset: state.offset, + offset_reset_generation: state.offset_reset_generation, + next_offset, + time_increase: state.time_increase, + } + } + + /// Commits a timestamp after its candidate block finalized successfully. + pub(crate) fn commit_next_timestamp(&self, pending: PendingBlockTimestamp) { + let mut state = self.state.write(); + if pending.exact_generation.is_some_and(|generation| { + state.next_exact_timestamp.is_some_and(|exact| exact.generation == generation) + }) { + state.next_exact_timestamp = None; + } + if let Some(next_offset) = pending.next_offset + && state.offset_reset_generation == pending.offset_reset_generation + { + let concurrent_offset = state.offset.saturating_sub(pending.prepared_offset); + state.offset = next_offset.saturating_add(concurrent_offset); + } + if state.offset_reset_generation == pending.offset_reset_generation { + state.time_increase = state.time_increase.saturating_sub(pending.time_increase); + state.last_timestamp = pending.timestamp; } - let next_offset = update_offset.then_some((next_timestamp as i128) - current); - (next_timestamp, next_offset) } /// Returns the current timestamp and updates the underlying offset and interval accordingly pub fn next_timestamp(&self) -> u64 { - let (next_timestamp, next_offset) = self.compute_next_timestamp(); - // Make sure we reset the `next_exact_timestamp` - self.next_exact_timestamp.write().take(); - if let Some(next_offset) = next_offset { - *self.offset.write() = next_offset; - } - *self.last_timestamp.write() = next_timestamp; - next_timestamp + let pending = self.prepare_next_timestamp(false); + self.commit_next_timestamp(pending); + pending.timestamp + } + + /// Returns `now + offset` without the next-block monotonicity floor. + pub(crate) fn current_wall_timestamp(&self) -> u64 { + let current = duration_since_unix_epoch().as_secs() as i128; + current.saturating_add(self.state.read().offset) as u64 } /// Returns the current timestamp for a call that does _not_ update the value pub fn current_call_timestamp(&self) -> u64 { - let (next_timestamp, _) = self.compute_next_timestamp(); - next_timestamp + self.prepare_next_timestamp(false).timestamp } } +/// Returns the `Utc` datetime for the given seconds since unix epoch +pub fn utc_from_secs(secs: u64) -> DateTime { + DateTime::from_timestamp(secs as i64, 0).unwrap_or(DateTime::::MAX_UTC) +} + /// Returns the current duration since unix epoch. pub fn duration_since_unix_epoch() -> Duration { use std::time::SystemTime; @@ -145,3 +356,216 @@ pub fn duration_since_unix_epoch() -> Duration { now.duration_since(SystemTime::UNIX_EPOCH) .unwrap_or_else(|err| panic!("Current time {now:?} is invalid: {err:?}")) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn candidate_consumes_only_its_timestamp_override() { + let time = TimeManager::new(1); + time.set_next_block_timestamp(100).unwrap(); + let pending = time.prepare_next_timestamp(false); + + time.set_next_block_timestamp(100).unwrap(); + time.commit_next_timestamp(pending); + + let state = time.state.read(); + assert_eq!(state.last_timestamp, 100); + assert_eq!(state.next_exact_timestamp.unwrap().timestamp, 100); + } + + #[test] + fn candidate_commit_preserves_concurrent_time_increase() { + let time = TimeManager::new(1); + time.set_next_block_timestamp(100).unwrap(); + let pending = time.prepare_next_timestamp(false); + + time.increase_time(10); + time.commit_next_timestamp(pending); + + let state = time.state.read(); + assert_eq!(state.last_timestamp, 100); + assert_eq!(state.offset, pending.next_offset.unwrap() + 10); + } + + #[test] + fn candidate_commit_preserves_concurrent_time_reset() { + let time = TimeManager::new(1); + time.set_next_block_timestamp(100).unwrap(); + let pending = time.prepare_next_timestamp(false); + + time.reset(1_000); + let reset_offset = time.offset(); + time.commit_next_timestamp(pending); + + let state = time.state.read(); + assert_eq!(state.last_timestamp, 1_000); + assert_eq!(state.offset, reset_offset); + } + + #[test] + fn failed_temporary_increase_preserves_concurrent_time_reset() { + let time = TimeManager::new(1); + let pending = time.apply_time_increase(60); + + time.reset(1_000); + let reset_offset = time.offset(); + time.revert_time_increase(pending); + + assert_eq!(time.offset(), reset_offset); + } + + #[test] + fn default_path_timestamps_strictly_increase() { + let state = TimeState { last_timestamp: 1_000, ..Default::default() }; + + assert_eq!(TimeManager::compute_next_timestamp(&state, 999, false).0, 1_001); + assert_eq!(TimeManager::compute_next_timestamp(&state, 1_000, false).0, 1_001); + assert_eq!(TimeManager::compute_next_timestamp(&state, 1_002, false).0, 1_002); + } + + #[test] + fn default_path_advances_after_time_increase() { + let state = TimeState { offset: 10_000, last_timestamp: 11_000, ..Default::default() }; + + assert_eq!(TimeManager::compute_next_timestamp(&state, 1_000, false).0, 11_001); + } + + #[test] + fn default_path_preserves_time_increase_after_burst() { + // A burst within one wall-clock second leaves `last_timestamp` ahead of wall time. + let state = TimeState { + offset: 10, + last_timestamp: 1_099, + time_increase: 10, + ..Default::default() + }; + + assert_eq!(TimeManager::compute_next_timestamp(&state, 1_000, false).0, 1_109); + } + + #[test] + fn burst_then_increase_time_keeps_full_increase() { + let time = TimeManager::new(1_000); + for _ in 0..100 { + time.next_timestamp(); + } + let last = time.next_timestamp(); + + let _ = time.apply_time_increase(10); + assert_eq!(time.next_timestamp(), last + 10); + // the increase is consumed: a plain block advances by one second again + assert_eq!(time.next_timestamp(), last + 11); + } + + #[test] + fn wall_timestamp_has_no_next_block_floor() { + let time = TimeManager::new(1_000); + time.state.write().last_timestamp = 5_000; + + assert_eq!(time.current_call_timestamp(), 5_001); + assert!(time.current_wall_timestamp() < 1_100); + } + + #[test] + fn explicit_zero_interval_honours_pending_time_increase() { + let state = TimeState { last_timestamp: 1_000, time_increase: 3_600, ..Default::default() }; + + assert_eq!(TimeManager::compute_next_timestamp(&state, 1_001, true).0, 4_600); + } + + #[test] + fn explicit_zero_interval_allows_equal_timestamp() { + let state = TimeState { last_timestamp: 1_000, ..Default::default() }; + + assert_eq!(TimeManager::compute_next_timestamp(&state, 1_000, true).0, 1_000); + assert_eq!(TimeManager::compute_next_timestamp(&state, 1_000, false).0, 1_001); + } + + #[test] + fn explicit_zero_interval_keeps_wall_clock_progress() { + let state = TimeState { last_timestamp: 1_000, ..Default::default() }; + + assert_eq!(TimeManager::compute_next_timestamp(&state, 1_002, true).0, 1_002); + } + + #[test] + fn explicit_zero_interval_keeps_persistent_interval() { + let state = TimeState { last_timestamp: 1_000, interval: Some(12), ..Default::default() }; + + assert_eq!(TimeManager::compute_next_timestamp(&state, 1_000, true).0, 1_012); + } + + #[test] + fn zero_interval_still_allows_equal_timestamps() { + let time = TimeManager::new(1_000); + time.set_block_timestamp_interval(0); + assert_eq!(time.next_timestamp(), 1_000); + } + + #[test] + fn exact_next_timestamp_may_equal_last() { + let time = TimeManager::new(1_000); + time.set_next_block_timestamp(1_000).unwrap(); + assert_eq!(time.next_timestamp(), 1_000); + assert!(time.next_timestamp() > 1_000); + } + + #[test] + fn logical_clock_ignores_wall_time_and_preserves_explicit_controls() { + let time = TimeManager::new(100); + // Move the wall-clock fallback without issuing an explicit time increase. + time.state.write().offset += 1_000; + assert!(time.current_call_timestamp() >= 1_100); + assert_eq!(time.prepare_next_timestamp_with_increment(0, 100).timestamp, 100); + + time.increase_time(10); + let pending = time.prepare_next_timestamp_with_increment(0, 100); + assert_eq!(pending.timestamp, 110); + assert_eq!(time.prepare_next_timestamp_with_increment(0, 100).timestamp, 110); + time.increase_time(7); + time.commit_next_timestamp(pending); + let pending = time.prepare_next_timestamp_with_increment(1, 111); + assert_eq!(pending.timestamp, 118); + time.commit_next_timestamp(pending); + assert_eq!(time.prepare_next_timestamp_with_increment(0, 118).timestamp, 118); + + time.set_block_timestamp_interval(2); + assert_eq!(time.prepare_next_timestamp_with_increment(0, 118).timestamp, 120); + time.set_next_block_timestamp(200).unwrap(); + let pending = time.prepare_next_timestamp_with_increment(0, 118); + assert_eq!(pending.timestamp, 200); + time.set_next_block_timestamp(300).unwrap(); + time.commit_next_timestamp(pending); + assert_eq!(time.prepare_next_timestamp_with_increment(0, 200).timestamp, 300); + } + + #[test] + fn logical_clock_restores_pending_increases_and_absolute_resets() { + let time = TimeManager::new(100); + time.set_time(500); + time.increase_time(10); + let snapshot = time.snapshot(); + time.commit_next_timestamp(time.prepare_next_timestamp_with_increment(0, 100)); + time.restore(snapshot); + assert_eq!(time.prepare_next_timestamp_with_increment(0, 100).timestamp, 510); + + let pending = time.prepare_next_timestamp_with_increment(0, 100); + time.set_time(1_000); + time.increase_time(20); + time.commit_next_timestamp(pending); + assert_eq!(time.prepare_next_timestamp_with_increment(0, 510).timestamp, 1_020); + } + + #[test] + fn logical_clock_reverts_failed_manual_time_increase() { + let time = TimeManager::new(100); + let increase = time.apply_time_increase(60); + assert_eq!(time.prepare_next_timestamp_with_increment(0, 100).timestamp, 160); + time.revert_time_increase(increase); + assert_eq!(time.prepare_next_timestamp_with_increment(0, 100).timestamp, 100); + time.set_next_block_timestamp(100).unwrap(); + assert_eq!(time.prepare_next_timestamp_with_increment(1, 101).timestamp, 101); + } +} diff --git a/crates/anvil/src/eth/backend/validate.rs b/crates/anvil/src/eth/backend/validate.rs index 0a22ea5f5a880..858602787ca60 100644 --- a/crates/anvil/src/eth/backend/validate.rs +++ b/crates/anvil/src/eth/backend/validate.rs @@ -1,30 +1,28 @@ //! Support for validating transactions at certain stages -use crate::eth::{ - backend::env::Env, - error::{BlockchainError, InvalidTransactionError}, -}; +use crate::eth::error::{BlockchainError, InvalidTransactionError}; +use alloy_evm::EvmEnv; use anvil_core::eth::transaction::PendingTransaction; use revm::state::AccountInfo; /// A trait for validating transactions #[async_trait::async_trait] -pub trait TransactionValidator { +pub trait TransactionValidator { /// Validates the transaction's validity when it comes to nonce, payment /// /// This is intended to be checked before the transaction makes it into the pool and whether it /// should rather be outright rejected if the sender has insufficient funds. async fn validate_pool_transaction( &self, - tx: &PendingTransaction, + tx: &PendingTransaction, ) -> Result<(), BlockchainError>; /// Validates the transaction against a specific account before entering the pool fn validate_pool_transaction_for( &self, - tx: &PendingTransaction, + tx: &PendingTransaction, account: &AccountInfo, - env: &Env, + evm_env: &EvmEnv, ) -> Result<(), InvalidTransactionError>; /// Validates the transaction against a specific account @@ -32,8 +30,8 @@ pub trait TransactionValidator { /// This should succeed if the transaction is ready to be executed fn validate_for( &self, - tx: &PendingTransaction, + tx: &PendingTransaction, account: &AccountInfo, - env: &Env, + evm_env: &EvmEnv, ) -> Result<(), InvalidTransactionError>; } diff --git a/crates/anvil/src/eth/error.rs b/crates/anvil/src/eth/error.rs deleted file mode 100644 index a9bea5f098ce6..0000000000000 --- a/crates/anvil/src/eth/error.rs +++ /dev/null @@ -1,554 +0,0 @@ -//! Aggregated error type for this module - -use crate::eth::pool::transactions::PoolTransaction; -use alloy_primitives::{B256, Bytes, SignatureError}; -use alloy_rpc_types::BlockNumberOrTag; -use alloy_signer::Error as SignerError; -use alloy_transport::TransportError; -use anvil_core::eth::wallet::WalletError; -use anvil_rpc::{ - error::{ErrorCode, RpcError}, - response::ResponseResult, -}; -use foundry_evm::{backend::DatabaseError, decode::RevertDecoder}; -use op_revm::OpTransactionError; -use revm::{ - context_interface::result::{EVMError, InvalidHeader, InvalidTransaction}, - interpreter::InstructionResult, -}; -use serde::Serialize; -use tokio::time::Duration; - -pub(crate) type Result = std::result::Result; - -#[derive(Debug, thiserror::Error)] -pub enum BlockchainError { - #[error(transparent)] - Pool(#[from] PoolError), - #[error("No signer available")] - NoSignerAvailable, - #[error("Chain Id not available")] - ChainIdNotAvailable, - #[error("Invalid input: `max_priority_fee_per_gas` greater than `max_fee_per_gas`")] - InvalidFeeInput, - #[error("Transaction data is empty")] - EmptyRawTransactionData, - #[error("Failed to decode signed transaction")] - FailedToDecodeSignedTransaction, - #[error("Failed to decode transaction")] - FailedToDecodeTransaction, - #[error("Failed to decode receipt")] - FailedToDecodeReceipt, - #[error("Failed to decode state")] - FailedToDecodeStateDump, - #[error("Prevrandao not in th EVM's environment after merge")] - PrevrandaoNotSet, - #[error(transparent)] - SignatureError(#[from] SignatureError), - #[error(transparent)] - SignerError(#[from] SignerError), - #[error("Rpc Endpoint not implemented")] - RpcUnimplemented, - #[error("Rpc error {0:?}")] - RpcError(RpcError), - #[error(transparent)] - InvalidTransaction(#[from] InvalidTransactionError), - #[error(transparent)] - FeeHistory(#[from] FeeHistoryError), - #[error(transparent)] - AlloyForkProvider(#[from] TransportError), - #[error("EVM error {0:?}")] - EvmError(InstructionResult), - #[error("Evm override error: {0}")] - EvmOverrideError(String), - #[error("Invalid url {0:?}")] - InvalidUrl(String), - #[error("Internal error: {0:?}")] - Internal(String), - #[error("BlockOutOfRangeError: block height is {0} but requested was {1}")] - BlockOutOfRange(u64, u64), - #[error("Resource not found")] - BlockNotFound, - #[error("Required data unavailable")] - DataUnavailable, - #[error("Trie error: {0}")] - TrieError(String), - #[error("{0}")] - UintConversion(&'static str), - #[error("State override error: {0}")] - StateOverrideError(String), - #[error("Timestamp error: {0}")] - TimestampError(String), - #[error(transparent)] - DatabaseError(#[from] DatabaseError), - #[error( - "EIP-1559 style fee params (maxFeePerGas or maxPriorityFeePerGas) received but they are not supported by the current hardfork.\n\nYou can use them by running anvil with '--hardfork london' or later." - )] - EIP1559TransactionUnsupportedAtHardfork, - #[error( - "Access list received but is not supported by the current hardfork.\n\nYou can use it by running anvil with '--hardfork berlin' or later." - )] - EIP2930TransactionUnsupportedAtHardfork, - #[error( - "EIP-4844 fields received but is not supported by the current hardfork.\n\nYou can use it by running anvil with '--hardfork cancun' or later." - )] - EIP4844TransactionUnsupportedAtHardfork, - #[error( - "EIP-7702 fields received but is not supported by the current hardfork.\n\nYou can use it by running anvil with '--hardfork prague' or later." - )] - EIP7702TransactionUnsupportedAtHardfork, - #[error( - "op-stack deposit tx received but is not supported.\n\nYou can use it by running anvil with '--optimism'." - )] - DepositTransactionUnsupported, - #[error("UnknownTransactionType not supported ")] - UnknownTransactionType, - #[error("Excess blob gas not set.")] - ExcessBlobGasNotSet, - #[error("{0}")] - Message(String), - #[error("Transaction {hash} was added to the mempool but wasn't confirmed within {duration:?}")] - TransactionConfirmationTimeout { - /// Hash of the transaction that timed out - hash: B256, - /// Duration that was waited before timing out - duration: Duration, - }, -} - -impl From for BlockchainError { - fn from(err: eyre::Report) -> Self { - Self::Message(err.to_string()) - } -} - -impl From for BlockchainError { - fn from(err: RpcError) -> Self { - Self::RpcError(err) - } -} - -impl From> for BlockchainError -where - T: Into, -{ - fn from(err: EVMError) -> Self { - match err { - EVMError::Transaction(err) => InvalidTransactionError::from(err).into(), - EVMError::Header(err) => match err { - InvalidHeader::ExcessBlobGasNotSet => Self::ExcessBlobGasNotSet, - InvalidHeader::PrevrandaoNotSet => Self::PrevrandaoNotSet, - }, - EVMError::Database(err) => err.into(), - EVMError::Custom(err) => Self::Message(err), - } - } -} - -impl From> for BlockchainError -where - T: Into, -{ - fn from(err: EVMError) -> Self { - match err { - EVMError::Transaction(err) => match err { - OpTransactionError::Base(err) => InvalidTransactionError::from(err).into(), - OpTransactionError::DepositSystemTxPostRegolith => { - Self::DepositTransactionUnsupported - } - OpTransactionError::HaltedDepositPostRegolith => { - Self::DepositTransactionUnsupported - } - }, - EVMError::Header(err) => match err { - InvalidHeader::ExcessBlobGasNotSet => Self::ExcessBlobGasNotSet, - InvalidHeader::PrevrandaoNotSet => Self::PrevrandaoNotSet, - }, - EVMError::Database(err) => err.into(), - EVMError::Custom(err) => Self::Message(err), - } - } -} - -impl From for BlockchainError { - fn from(value: WalletError) -> Self { - match value { - WalletError::ValueNotZero => Self::Message("tx value not zero".to_string()), - WalletError::FromSet => Self::Message("tx from field is set".to_string()), - WalletError::NonceSet => Self::Message("tx nonce is set".to_string()), - WalletError::InvalidAuthorization => { - Self::Message("invalid authorization address".to_string()) - } - WalletError::IllegalDestination => Self::Message( - "the destination of the transaction is not a delegated account".to_string(), - ), - WalletError::InternalError => Self::Message("internal error".to_string()), - WalletError::InvalidTransactionRequest => { - Self::Message("invalid tx request".to_string()) - } - } - } -} - -/// Errors that can occur in the transaction pool -#[derive(Debug, thiserror::Error)] -pub enum PoolError { - #[error("Transaction with cyclic dependent transactions")] - CyclicTransaction, - /// Thrown if a replacement transaction's gas price is below the already imported transaction - #[error("Tx: [{0:?}] insufficient gas price to replace existing transaction")] - ReplacementUnderpriced(Box), - #[error("Tx: [{0:?}] already Imported")] - AlreadyImported(Box), -} - -/// Errors that can occur with `eth_feeHistory` -#[derive(Debug, thiserror::Error)] -pub enum FeeHistoryError { - #[error("requested block range is out of bounds")] - InvalidBlockRange, - #[error("could not find newest block number requested: {0}")] - BlockNotFound(BlockNumberOrTag), -} - -#[derive(Debug)] -pub struct ErrDetail { - pub detail: String, -} - -/// An error due to invalid transaction -#[derive(Debug, thiserror::Error)] -pub enum InvalidTransactionError { - /// returned if the nonce of a transaction is lower than the one present in the local chain. - #[error("nonce too low")] - NonceTooLow, - /// returned if the nonce of a transaction is higher than the next one expected based on the - /// local chain. - #[error("Nonce too high")] - NonceTooHigh, - /// Returned if the nonce of a transaction is too high - /// Incrementing the nonce would lead to invalid state (overflow) - #[error("nonce has max value")] - NonceMaxValue, - /// thrown if the transaction sender doesn't have enough funds for a transfer - #[error("insufficient funds for transfer")] - InsufficientFundsForTransfer, - /// thrown if creation transaction provides the init code bigger than init code size limit. - #[error("max initcode size exceeded")] - MaxInitCodeSizeExceeded, - /// Represents the inability to cover max cost + value (account balance too low). - #[error("Insufficient funds for gas * price + value")] - InsufficientFunds, - /// Thrown when calculating gas usage - #[error("gas uint64 overflow")] - GasUintOverflow, - /// returned if the transaction is specified to use less gas than required to start the - /// invocation. - #[error("intrinsic gas too low")] - GasTooLow, - /// returned if the transaction gas exceeds the limit - #[error("intrinsic gas too high -- {}",.0.detail)] - GasTooHigh(ErrDetail), - /// Thrown to ensure no one is able to specify a transaction with a tip higher than the total - /// fee cap. - #[error("max priority fee per gas higher than max fee per gas")] - TipAboveFeeCap, - /// Thrown post London if the transaction's fee is less than the base fee of the block - #[error("max fee per gas less than block base fee")] - FeeCapTooLow, - /// Thrown during estimate if caller has insufficient funds to cover the tx. - #[error("Out of gas: gas required exceeds allowance: {0:?}")] - BasicOutOfGas(u128), - /// Thrown if executing a transaction failed during estimate/call - #[error("execution reverted: {0:?}")] - Revert(Option), - /// Thrown if the sender of a transaction is a contract. - #[error("sender not an eoa")] - SenderNoEOA, - /// Thrown when a tx was signed with a different chain_id - #[error("invalid chain id for signer")] - InvalidChainId, - /// Thrown when a legacy tx was signed for a different chain - #[error("Incompatible EIP-155 transaction, signed for another chain")] - IncompatibleEIP155, - /// Thrown when an access list is used before the berlin hard fork. - #[error("Access lists are not supported before the Berlin hardfork")] - AccessListNotSupported, - /// Thrown when the block's `blob_gas_price` is greater than tx-specified - /// `max_fee_per_blob_gas` after Cancun. - #[error("Block `blob_gas_price` is greater than tx-specified `max_fee_per_blob_gas`")] - BlobFeeCapTooLow, - /// Thrown when we receive a tx with `blob_versioned_hashes` and we're not on the Cancun hard - /// fork. - #[error("Block `blob_versioned_hashes` is not supported before the Cancun hardfork")] - BlobVersionedHashesNotSupported, - /// Thrown when `max_fee_per_blob_gas` is not supported for blocks before the Cancun hardfork. - #[error("`max_fee_per_blob_gas` is not supported for blocks before the Cancun hardfork.")] - MaxFeePerBlobGasNotSupported, - /// Thrown when there are no `blob_hashes` in the transaction, and it is an EIP-4844 tx. - #[error("`blob_hashes` are required for EIP-4844 transactions")] - NoBlobHashes, - #[error("too many blobs in one transaction, have: {0}, max: {1}")] - TooManyBlobs(usize, usize), - /// Thrown when there's a blob validation error - #[error(transparent)] - BlobTransactionValidationError(#[from] alloy_consensus::BlobTransactionValidationError), - /// Thrown when Blob transaction is a create transaction. `to` must be present. - #[error("Blob transaction can't be a create transaction. `to` must be present.")] - BlobCreateTransaction, - /// Thrown when Blob transaction contains a versioned hash with an incorrect version. - #[error("Blob transaction contains a versioned hash with an incorrect version")] - BlobVersionNotSupported, - /// Thrown when there are no `blob_hashes` in the transaction. - #[error("There should be at least one blob in a Blob transaction.")] - EmptyBlobs, - /// Thrown when an access list is used before the berlin hard fork. - #[error("EIP-7702 authorization lists are not supported before the Prague hardfork")] - AuthorizationListNotSupported, - #[error("Transaction gas limit is greater than the block gas limit, gas_limit: {0}, cap: {1}")] - TxGasLimitGreaterThanCap(u64, u64), - /// Forwards error from the revm - #[error(transparent)] - Revm(revm::context_interface::result::InvalidTransaction), - /// Deposit transaction error post regolith - #[error("op-deposit failure post regolith")] - DepositTxErrorPostRegolith, -} - -impl From for InvalidTransactionError { - fn from(err: InvalidTransaction) -> Self { - match err { - InvalidTransaction::InvalidChainId => Self::InvalidChainId, - InvalidTransaction::PriorityFeeGreaterThanMaxFee => Self::TipAboveFeeCap, - InvalidTransaction::GasPriceLessThanBasefee => Self::FeeCapTooLow, - InvalidTransaction::CallerGasLimitMoreThanBlock => { - Self::GasTooHigh(ErrDetail { detail: String::from("CallerGasLimitMoreThanBlock") }) - } - InvalidTransaction::CallGasCostMoreThanGasLimit { .. } => { - Self::GasTooHigh(ErrDetail { detail: String::from("CallGasCostMoreThanGasLimit") }) - } - InvalidTransaction::GasFloorMoreThanGasLimit { .. } => { - Self::GasTooHigh(ErrDetail { detail: String::from("CallGasCostMoreThanGasLimit") }) - } - InvalidTransaction::RejectCallerWithCode => Self::SenderNoEOA, - InvalidTransaction::LackOfFundForMaxFee { .. } => Self::InsufficientFunds, - InvalidTransaction::OverflowPaymentInTransaction => Self::GasUintOverflow, - InvalidTransaction::NonceOverflowInTransaction => Self::NonceMaxValue, - InvalidTransaction::CreateInitCodeSizeLimit => Self::MaxInitCodeSizeExceeded, - InvalidTransaction::NonceTooHigh { .. } => Self::NonceTooHigh, - InvalidTransaction::NonceTooLow { .. } => Self::NonceTooLow, - InvalidTransaction::AccessListNotSupported => Self::AccessListNotSupported, - InvalidTransaction::BlobGasPriceGreaterThanMax => Self::BlobFeeCapTooLow, - InvalidTransaction::BlobVersionedHashesNotSupported => { - Self::BlobVersionedHashesNotSupported - } - InvalidTransaction::MaxFeePerBlobGasNotSupported => Self::MaxFeePerBlobGasNotSupported, - InvalidTransaction::BlobCreateTransaction => Self::BlobCreateTransaction, - InvalidTransaction::BlobVersionNotSupported => Self::BlobVersionNotSupported, - InvalidTransaction::EmptyBlobs => Self::EmptyBlobs, - InvalidTransaction::TooManyBlobs { have, max } => Self::TooManyBlobs(have, max), - InvalidTransaction::AuthorizationListNotSupported => { - Self::AuthorizationListNotSupported - } - InvalidTransaction::TxGasLimitGreaterThanCap { gas_limit, cap } => { - Self::TxGasLimitGreaterThanCap(gas_limit, cap) - } - - InvalidTransaction::AuthorizationListInvalidFields - | InvalidTransaction::Eip1559NotSupported - | InvalidTransaction::Eip2930NotSupported - | InvalidTransaction::Eip4844NotSupported - | InvalidTransaction::Eip7702NotSupported - | InvalidTransaction::EmptyAuthorizationList - | InvalidTransaction::Eip7873NotSupported - | InvalidTransaction::Eip7873MissingTarget - | InvalidTransaction::MissingChainId => Self::Revm(err), - } - } -} - -impl From for InvalidTransactionError { - fn from(value: OpTransactionError) -> Self { - match value { - OpTransactionError::Base(err) => err.into(), - OpTransactionError::DepositSystemTxPostRegolith - | OpTransactionError::HaltedDepositPostRegolith => Self::DepositTxErrorPostRegolith, - } - } -} -/// Helper trait to easily convert results to rpc results -pub(crate) trait ToRpcResponseResult { - fn to_rpc_result(self) -> ResponseResult; -} - -/// Converts a serializable value into a `ResponseResult` -pub fn to_rpc_result(val: T) -> ResponseResult { - match serde_json::to_value(val) { - Ok(success) => ResponseResult::Success(success), - Err(err) => { - error!(%err, "Failed serialize rpc response"); - ResponseResult::error(RpcError::internal_error()) - } - } -} - -impl ToRpcResponseResult for Result { - fn to_rpc_result(self) -> ResponseResult { - match self { - Ok(val) => to_rpc_result(val), - Err(err) => match err { - BlockchainError::Pool(err) => { - error!(%err, "txpool error"); - match err { - PoolError::CyclicTransaction => { - RpcError::transaction_rejected("Cyclic transaction detected") - } - PoolError::ReplacementUnderpriced(_) => { - RpcError::transaction_rejected("replacement transaction underpriced") - } - PoolError::AlreadyImported(_) => { - RpcError::transaction_rejected("transaction already imported") - } - } - } - BlockchainError::NoSignerAvailable => { - RpcError::invalid_params("No Signer available") - } - BlockchainError::ChainIdNotAvailable => { - RpcError::invalid_params("Chain Id not available") - } - BlockchainError::TransactionConfirmationTimeout { .. } => { - RpcError::internal_error_with("Transaction confirmation timeout") - } - BlockchainError::InvalidTransaction(err) => match err { - InvalidTransactionError::Revert(data) => { - // this mimics geth revert error - let mut msg = "execution reverted".to_string(); - if let Some(reason) = data - .as_ref() - .and_then(|data| RevertDecoder::new().maybe_decode(data, None)) - { - msg = format!("{msg}: {reason}"); - } - RpcError { - // geth returns this error code on reverts, See - code: ErrorCode::ExecutionError, - message: msg.into(), - data: serde_json::to_value(data).ok(), - } - } - InvalidTransactionError::GasTooLow => { - // - RpcError { - code: ErrorCode::ServerError(-32000), - message: err.to_string().into(), - data: None, - } - } - InvalidTransactionError::GasTooHigh(_) => { - // - RpcError { - code: ErrorCode::ServerError(-32000), - message: err.to_string().into(), - data: None, - } - } - _ => RpcError::transaction_rejected(err.to_string()), - }, - BlockchainError::FeeHistory(err) => RpcError::invalid_params(err.to_string()), - BlockchainError::EmptyRawTransactionData => { - RpcError::invalid_params("Empty transaction data") - } - BlockchainError::FailedToDecodeSignedTransaction => { - RpcError::invalid_params("Failed to decode transaction") - } - BlockchainError::FailedToDecodeTransaction => { - RpcError::invalid_params("Failed to decode transaction") - } - BlockchainError::FailedToDecodeReceipt => { - RpcError::invalid_params("Failed to decode receipt") - } - BlockchainError::FailedToDecodeStateDump => { - RpcError::invalid_params("Failed to decode state dump") - } - BlockchainError::SignerError(err) => RpcError::invalid_params(err.to_string()), - BlockchainError::SignatureError(err) => RpcError::invalid_params(err.to_string()), - BlockchainError::RpcUnimplemented => { - RpcError::internal_error_with("Not implemented") - } - BlockchainError::PrevrandaoNotSet => RpcError::internal_error_with(err.to_string()), - BlockchainError::RpcError(err) => err, - BlockchainError::InvalidFeeInput => RpcError::invalid_params( - "Invalid input: `max_priority_fee_per_gas` greater than `max_fee_per_gas`", - ), - BlockchainError::AlloyForkProvider(err) => { - error!(target: "backend", %err, "fork provider error"); - match err { - TransportError::ErrorResp(err) => RpcError { - code: ErrorCode::from(err.code), - message: err.message, - data: err.data.and_then(|data| serde_json::to_value(data).ok()), - }, - err => RpcError::internal_error_with(format!("Fork Error: {err:?}")), - } - } - err @ BlockchainError::EvmError(_) => { - RpcError::internal_error_with(err.to_string()) - } - err @ BlockchainError::EvmOverrideError(_) => { - RpcError::invalid_params(err.to_string()) - } - err @ BlockchainError::InvalidUrl(_) => RpcError::invalid_params(err.to_string()), - BlockchainError::Internal(err) => RpcError::internal_error_with(err), - err @ BlockchainError::BlockOutOfRange(_, _) => { - RpcError::invalid_params(err.to_string()) - } - err @ BlockchainError::BlockNotFound => RpcError { - // - code: ErrorCode::ServerError(-32001), - message: err.to_string().into(), - data: None, - }, - err @ BlockchainError::DataUnavailable => { - RpcError::internal_error_with(err.to_string()) - } - err @ BlockchainError::TrieError(_) => { - RpcError::internal_error_with(err.to_string()) - } - BlockchainError::UintConversion(err) => RpcError::invalid_params(err), - err @ BlockchainError::StateOverrideError(_) => { - RpcError::invalid_params(err.to_string()) - } - err @ BlockchainError::TimestampError(_) => { - RpcError::invalid_params(err.to_string()) - } - BlockchainError::DatabaseError(err) => { - RpcError::internal_error_with(err.to_string()) - } - err @ BlockchainError::EIP1559TransactionUnsupportedAtHardfork => { - RpcError::invalid_params(err.to_string()) - } - err @ BlockchainError::EIP2930TransactionUnsupportedAtHardfork => { - RpcError::invalid_params(err.to_string()) - } - err @ BlockchainError::EIP4844TransactionUnsupportedAtHardfork => { - RpcError::invalid_params(err.to_string()) - } - err @ BlockchainError::EIP7702TransactionUnsupportedAtHardfork => { - RpcError::invalid_params(err.to_string()) - } - err @ BlockchainError::DepositTransactionUnsupported => { - RpcError::invalid_params(err.to_string()) - } - err @ BlockchainError::ExcessBlobGasNotSet => { - RpcError::invalid_params(err.to_string()) - } - err @ BlockchainError::Message(_) => RpcError::internal_error_with(err.to_string()), - err @ BlockchainError::UnknownTransactionType => { - RpcError::invalid_params(err.to_string()) - } - } - .into(), - } - } -} diff --git a/crates/anvil/src/eth/error/base.rs b/crates/anvil/src/eth/error/base.rs new file mode 100644 index 0000000000000..9d947e948ec74 --- /dev/null +++ b/crates/anvil/src/eth/error/base.rs @@ -0,0 +1,33 @@ +//! Base-specific error conversions. + +use super::{BlockchainError, InvalidTransactionError}; +use base_common_evm::BaseTransactionError; +use revm::context_interface::result::{EVMError, InvalidHeader}; + +impl From> for BlockchainError +where + T: Into, +{ + fn from(error: EVMError) -> Self { + match error { + EVMError::Transaction(error) => match error { + BaseTransactionError::Base(error) => InvalidTransactionError::from(error).into(), + BaseTransactionError::DepositSystemTxPostRegolith + | BaseTransactionError::HaltedDepositPostRegolith => { + Self::DepositTransactionUnsupported + } + BaseTransactionError::MissingEnvelopedTx => { + Self::InvalidTransaction(InvalidTransactionError::MissingEnvelopedTx) + } + BaseTransactionError::Eip8130(reason) => Self::Eip8130TransactionRejected(reason), + }, + EVMError::Header(error) => match error { + InvalidHeader::ExcessBlobGasNotSet => Self::ExcessBlobGasNotSet, + InvalidHeader::PrevrandaoNotSet => Self::PrevrandaoNotSet, + }, + EVMError::Database(error) => error.into(), + EVMError::Custom(error) => Self::Message(error), + EVMError::CustomAny(error) => Self::Message(error.to_string()), + } + } +} diff --git a/crates/anvil/src/eth/error/mod.rs b/crates/anvil/src/eth/error/mod.rs new file mode 100644 index 0000000000000..db31602e3f64b --- /dev/null +++ b/crates/anvil/src/eth/error/mod.rs @@ -0,0 +1,690 @@ +//! Aggregated error type for this module + +use alloy_consensus::crypto::RecoveryError; +use alloy_evm::overrides::StateOverrideError; +use alloy_primitives::{B256, Bytes, SignatureError, TxHash, U256}; +use alloy_rpc_types::BlockNumberOrTag; +use alloy_signer::Error as SignerError; +use alloy_transport::TransportError; +use anvil_core::eth::wallet::WalletError; +use anvil_rpc::{ + error::{ErrorCode, RpcError}, + response::ResponseResult, +}; +use foundry_evm::{backend::DatabaseError, decode::RevertDecoder}; +use revm::{ + context_interface::result::{EVMError, InvalidHeader, InvalidTransaction}, + interpreter::InstructionResult, +}; +use serde::Serialize; +use tempo_revm::TempoInvalidTransaction; +use tokio::time::Duration; + +#[cfg(feature = "base")] +mod base; +#[cfg(feature = "optimism")] +mod optimism; + +pub(crate) type Result = std::result::Result; + +#[derive(Debug, thiserror::Error)] +pub enum BlockchainError { + #[error(transparent)] + Pool(#[from] PoolError), + #[error("No signer available")] + NoSignerAvailable, + #[error("Chain Id not available")] + ChainIdNotAvailable, + #[error("Invalid input: `max_priority_fee_per_gas` greater than `max_fee_per_gas`")] + InvalidFeeInput, + #[error("both gasPrice and (maxFeePerGas or maxPriorityFeePerGas) specified")] + ConflictingFeeFields, + #[error("Transaction data is empty")] + EmptyRawTransactionData, + #[error("Failed to decode signed transaction")] + FailedToDecodeSignedTransaction, + #[error("Failed to decode transaction")] + FailedToDecodeTransaction, + #[error("Failed to decode receipt")] + FailedToDecodeReceipt, + #[error("Cannot EIP-2718 encode transaction type 0x{0:x}")] + UnsupportedTransactionEncoding(u8), + #[error("Failed to decode state")] + FailedToDecodeStateDump, + #[error("Prevrandao not in the EVM's environment after merge")] + PrevrandaoNotSet, + #[error(transparent)] + SignatureError(#[from] SignatureError), + #[error(transparent)] + RecoveryError(#[from] RecoveryError), + #[error(transparent)] + SignerError(#[from] SignerError), + #[error("Rpc Endpoint not implemented")] + RpcUnimplemented, + #[error("Rpc error {0:?}")] + RpcError(RpcError), + #[error(transparent)] + InvalidTransaction(#[from] InvalidTransactionError), + #[error(transparent)] + FeeHistory(#[from] FeeHistoryError), + #[error(transparent)] + AlloyForkProvider(#[from] TransportError), + #[error("EVM error {0:?}")] + EvmError(InstructionResult), + #[error("Evm override error: {0}")] + EvmOverrideError(String), + #[error("Invalid url {0:?}")] + InvalidUrl(String), + #[error("unsupported fork network for chain {chain_id}: {reason}")] + UnsupportedForkNetwork { chain_id: u64, reason: &'static str }, + #[error("Internal error: {0:?}")] + Internal(String), + #[error("BlockOutOfRangeError: block height is {0} but requested was {1}")] + BlockOutOfRange(u64, u64), + #[error("Resource not found")] + BlockNotFound, + #[error("unknown block")] + UnknownBlock, + /// Thrown when a requested transaction is not found + #[error("transaction not found")] + TransactionNotFound, + #[error("Required data unavailable")] + DataUnavailable, + /// Thrown when replaying a mined block requires a parent state that is no longer stored. + #[error("historical state needed to replay block {0} is not available")] + HistoricalStateUnavailable(u64), + #[error("Trie error: {0}")] + TrieError(String), + #[error("{0}")] + UintConversion(&'static str), + #[error("State override error: {0}")] + StateOverrideError(String), + #[error("Timestamp error: {0}")] + TimestampError(String), + #[error(transparent)] + DatabaseError(#[from] DatabaseError), + #[error( + "EIP-1559 style fee params (maxFeePerGas or maxPriorityFeePerGas) received but they are not supported by the current hardfork.\n\nYou can use them by running anvil with '--hardfork london' or later." + )] + EIP1559TransactionUnsupportedAtHardfork, + #[error( + "Access list received but is not supported by the current hardfork.\n\nYou can use it by running anvil with '--hardfork berlin' or later." + )] + EIP2930TransactionUnsupportedAtHardfork, + #[error( + "EIP-4844 fields received but is not supported by the current hardfork.\n\nYou can use it by running anvil with '--hardfork cancun' or later." + )] + EIP4844TransactionUnsupportedAtHardfork, + #[error( + "EIP-7702 fields received but is not supported by the current hardfork.\n\nYou can use it by running anvil with '--hardfork prague' or later." + )] + EIP7702TransactionUnsupportedAtHardfork, + // Base is an OP-stack chain and uses the same deposit envelope, so only the hint that names + // the flags able to enable it varies with the compiled-in families. + #[cfg_attr( + all(feature = "base", feature = "optimism"), + error( + "op-stack deposit tx received but is not supported.\n\nYou can use it by running anvil with '--optimism' or '--network base'." + ) + )] + #[cfg_attr( + all(feature = "base", not(feature = "optimism")), + error( + "op-stack deposit tx received but is not supported.\n\nYou can use it by running anvil with '--network base'." + ) + )] + #[cfg_attr( + not(feature = "base"), + error( + "op-stack deposit tx received but is not supported.\n\nYou can use it by running anvil with '--optimism'." + ) + )] + DepositTransactionUnsupported, + #[error( + "tempo transaction received but is not supported.\n\nYou can use it by running anvil with '--tempo'." + )] + TempoTransactionUnsupported, + #[cfg(feature = "base")] + #[error("Base transactions require native Base execution; run Anvil with --network base")] + BaseTransactionUnsupported, + #[error("Unknown transaction type not supported")] + UnknownTransactionType, + #[error("Excess blob gas not set.")] + ExcessBlobGasNotSet, + #[error("{0}")] + Message(String), + #[error("Transaction {hash} was added to the mempool but wasn't confirmed within {duration:?}")] + TransactionConfirmationTimeout { + /// Hash of the transaction that timed out + hash: B256, + /// Duration that was waited before timing out + duration: Duration, + }, + #[error("Invalid transaction request: {0}")] + InvalidTransactionRequest(String), + #[cfg(feature = "base")] + #[error("EIP-8130 transaction rejected: {0}")] + Eip8130TransactionRejected(String), + #[error("filter not found")] + FilterNotFound, +} + +impl From for BlockchainError { + fn from(err: eyre::Report) -> Self { + Self::Message(err.to_string()) + } +} + +impl From for BlockchainError { + fn from(err: RpcError) -> Self { + Self::RpcError(err) + } +} + +impl From> for BlockchainError +where + T: Into, +{ + fn from(err: EVMError) -> Self { + match err { + EVMError::Transaction(err) => InvalidTransactionError::from(err).into(), + EVMError::Header(err) => match err { + InvalidHeader::ExcessBlobGasNotSet => Self::ExcessBlobGasNotSet, + InvalidHeader::PrevrandaoNotSet => Self::PrevrandaoNotSet, + }, + EVMError::Database(err) => err.into(), + EVMError::Custom(err) => Self::Message(err), + EVMError::CustomAny(err) => Self::Message(err.to_string()), + } + } +} + +impl From> for BlockchainError +where + T: Into, +{ + fn from(err: EVMError) -> Self { + match err { + EVMError::Transaction(err) => match err { + TempoInvalidTransaction::EthInvalidTransaction(err) => { + InvalidTransactionError::from(err).into() + } + err => Self::Message(format!("tempo transaction error: {err}")), + }, + EVMError::Header(err) => match err { + InvalidHeader::ExcessBlobGasNotSet => Self::ExcessBlobGasNotSet, + InvalidHeader::PrevrandaoNotSet => Self::PrevrandaoNotSet, + }, + EVMError::Database(err) => err.into(), + EVMError::Custom(err) => Self::Message(err), + EVMError::CustomAny(err) => Self::Message(err.to_string()), + } + } +} + +impl From for BlockchainError { + fn from(value: WalletError) -> Self { + Self::Message(value.to_string()) + } +} + +impl From> for BlockchainError +where + E: Into, +{ + fn from(value: StateOverrideError) -> Self { + match value { + StateOverrideError::InvalidBytecode(err) => Self::StateOverrideError(err.to_string()), + StateOverrideError::BothStateAndStateDiff(addr) => Self::StateOverrideError(format!( + "state and state_diff can't be used together for account {addr}", + )), + StateOverrideError::Database(err) => err.into(), + } + } +} + +/// Errors that can occur in the transaction pool +#[derive(Debug, thiserror::Error)] +pub enum PoolError { + #[error("Transaction with cyclic dependent transactions")] + CyclicTransaction, + /// Thrown if a replacement transaction's gas price is below the already imported transaction + #[error("Tx: [{0:?}] insufficient gas price to replace existing transaction")] + ReplacementUnderpriced(TxHash), + #[error("Tx: [{0:?}] already Imported")] + AlreadyImported(TxHash), +} + +/// Errors that can occur with `eth_feeHistory` +#[derive(Debug, thiserror::Error)] +pub enum FeeHistoryError { + #[error("requested block range is out of bounds")] + InvalidBlockRange, + #[error("reward percentiles must be strictly increasing and between 0 and 100")] + InvalidRewardPercentiles, + #[error("could not find block number requested: {0}")] + BlockNotFound(BlockNumberOrTag), +} + +#[derive(Debug)] +pub struct ErrDetail { + pub detail: String, +} + +/// An error due to invalid transaction +#[derive(Debug, thiserror::Error)] +pub enum InvalidTransactionError { + /// returned if the nonce of a transaction is lower than the one present in the local chain. + #[error("nonce too low")] + NonceTooLow, + /// returned if the nonce of a transaction is higher than the next one expected based on the + /// local chain. + #[error("Nonce too high")] + NonceTooHigh, + /// Returned if the nonce of a transaction is too high + /// Incrementing the nonce would lead to invalid state (overflow) + #[error("nonce has max value")] + NonceMaxValue, + /// thrown if the transaction sender doesn't have enough funds for a transfer + #[error("insufficient funds for transfer")] + InsufficientFundsForTransfer, + /// thrown if creation transaction provides the init code bigger than init code size limit. + #[error("max initcode size exceeded")] + MaxInitCodeSizeExceeded, + /// Represents the inability to cover max cost + value (account balance too low). + #[error("Insufficient funds for gas * price + value")] + InsufficientFunds, + /// Thrown when calculating gas usage + #[error("gas uint64 overflow")] + GasUintOverflow, + /// returned if the transaction is specified to use less gas than required to start the + /// invocation. + #[error("intrinsic gas too low")] + GasTooLow, + /// returned if the transaction gas exceeds the limit + #[error("intrinsic gas too high -- {}",.0.detail)] + GasTooHigh(ErrDetail), + /// Thrown to ensure no one is able to specify a transaction with a tip higher than the total + /// fee cap. + #[error("max priority fee per gas higher than max fee per gas")] + TipAboveFeeCap, + /// Thrown post London if the transaction's fee is less than the base fee of the block + #[error("max fee per gas less than block base fee")] + FeeCapTooLow, + /// Thrown during estimate if caller has insufficient funds to cover the tx. + #[error("Out of gas: gas required exceeds allowance: {0:?}")] + BasicOutOfGas(u128), + /// Thrown if executing a transaction failed during estimate/call + #[error("execution reverted: {0:?}")] + Revert(Option), + /// Thrown if the sender of a transaction is a contract. + #[error("sender not an eoa")] + SenderNoEOA, + /// Thrown when a tx was signed with a different chain_id + #[error("invalid chain id for signer")] + InvalidChainId, + /// Thrown when a legacy tx was signed for a different chain + #[error("Incompatible EIP-155 transaction, signed for another chain")] + IncompatibleEIP155, + /// Thrown when an access list is used before the berlin hard fork. + #[error("Access lists are not supported before the Berlin hardfork")] + AccessListNotSupported, + /// Thrown when the block's `blob_gas_price` is greater than tx-specified + /// `max_fee_per_blob_gas` after Cancun. + #[error("Block `blob_gas_price` is greater than tx-specified `max_fee_per_blob_gas`")] + BlobFeeCapTooLow(u128, u128), + /// Thrown when we receive a tx with `blob_versioned_hashes` and we're not on the Cancun hard + /// fork. + #[error("Block `blob_versioned_hashes` is not supported before the Cancun hardfork")] + BlobVersionedHashesNotSupported, + /// Thrown when `max_fee_per_blob_gas` is not supported for blocks before the Cancun hardfork. + #[error("`max_fee_per_blob_gas` is not supported for blocks before the Cancun hardfork.")] + MaxFeePerBlobGasNotSupported, + /// Thrown when there are no `blob_hashes` in the transaction, and it is an EIP-4844 tx. + #[error("`blob_hashes` are required for EIP-4844 transactions")] + NoBlobHashes, + #[error("too many blobs in one transaction, have: {0}, max: {1}")] + TooManyBlobs(usize, usize), + /// Thrown when there's a blob validation error + #[error(transparent)] + BlobTransactionValidationError(#[from] alloy_consensus::BlobTransactionValidationError), + /// Thrown when Blob transaction is a create transaction. `to` must be present. + #[error("Blob transaction can't be a create transaction. `to` must be present.")] + BlobCreateTransaction, + /// Thrown when Blob transaction contains a versioned hash with an incorrect version. + #[error("Blob transaction contains a versioned hash with an incorrect version")] + BlobVersionNotSupported, + /// Thrown when a blob transaction is submitted on a Monad network. + #[error("EIP-4844 blob transactions are not supported on Monad")] + MonadBlobTransactionUnsupported, + /// Thrown when there are no `blob_hashes` in the transaction. + #[error("There should be at least one blob in a Blob transaction.")] + EmptyBlobs, + /// Thrown when an access list is used before the berlin hard fork. + #[error("EIP-7702 authorization lists are not supported before the Prague hardfork")] + AuthorizationListNotSupported, + #[error("Transaction gas limit is greater than the block gas limit, gas_limit: {0}, cap: {1}")] + TxGasLimitGreaterThanCap(u64, u64), + /// Forwards error from the revm + #[error(transparent)] + Revm(revm::context_interface::result::InvalidTransaction), + /// Deposit transaction error post regolith + #[error("op-deposit failure post regolith")] + DepositTxErrorPostRegolith, + /// Missing enveloped transaction + #[error("missing enveloped transaction")] + MissingEnvelopedTx, + /// EIP-8130 transaction failed block-inclusion validation. + #[cfg(feature = "base")] + #[error("EIP-8130 transaction rejected: {0}")] + Eip8130(String), + /// Native ETH value transfers are not allowed in Tempo mode + #[error("native value transfer not allowed in Tempo mode")] + TempoNativeValueTransfer, + /// Tempo transaction valid_before is expired or too close to current time + #[error("Tempo tx valid_before ({valid_before}) must be > current time + 3s ({min_allowed})")] + TempoValidBeforeExpired { valid_before: u64, min_allowed: u64 }, + /// Tempo expiring nonce transaction valid_before is too far in the future. + #[error( + "Tempo expiring nonce tx valid_before ({valid_before}) must be <= current time + {max_expiry_secs}s ({max_allowed})" + )] + TempoValidBeforeTooFar { valid_before: u64, max_expiry_secs: u64, max_allowed: u64 }, + /// Tempo transaction valid_after is too far in the future + #[error( + "Tempo tx valid_after ({valid_after}) must be <= current time + {max_valid_after_secs}s ({max_allowed})" + )] + TempoValidAfterTooFar { valid_after: u64, max_valid_after_secs: u64, max_allowed: u64 }, + /// Tempo transaction has too many authorizations + #[error("Tempo tx has too many authorizations ({count}), max allowed is {max}")] + TempoTooManyAuthorizations { count: usize, max: usize }, + /// Tempo transaction fee payer has insufficient fee token balance + #[error("insufficient fee token balance: have {balance}, need {required}")] + TempoInsufficientFeeTokenBalance { balance: U256, required: U256 }, +} + +impl From for InvalidTransactionError { + fn from(err: InvalidTransaction) -> Self { + match err { + InvalidTransaction::InvalidChainId => Self::InvalidChainId, + InvalidTransaction::PriorityFeeGreaterThanMaxFee => Self::TipAboveFeeCap, + InvalidTransaction::GasPriceLessThanBasefee => Self::FeeCapTooLow, + InvalidTransaction::CallerGasLimitMoreThanBlock => { + Self::GasTooHigh(ErrDetail { detail: String::from("CallerGasLimitMoreThanBlock") }) + } + InvalidTransaction::CallGasCostMoreThanGasLimit { .. } => { + Self::GasTooHigh(ErrDetail { detail: String::from("CallGasCostMoreThanGasLimit") }) + } + InvalidTransaction::GasFloorMoreThanGasLimit { .. } => { + Self::GasTooHigh(ErrDetail { detail: String::from("GasFloorMoreThanGasLimit") }) + } + InvalidTransaction::RejectCallerWithCode => Self::SenderNoEOA, + InvalidTransaction::LackOfFundForMaxFee { .. } => Self::InsufficientFunds, + InvalidTransaction::OverflowPaymentInTransaction => Self::GasUintOverflow, + InvalidTransaction::NonceOverflowInTransaction => Self::NonceMaxValue, + InvalidTransaction::CreateInitCodeSizeLimit => Self::MaxInitCodeSizeExceeded, + InvalidTransaction::NonceTooHigh { .. } => Self::NonceTooHigh, + InvalidTransaction::NonceTooLow { .. } => Self::NonceTooLow, + InvalidTransaction::AccessListNotSupported => Self::AccessListNotSupported, + InvalidTransaction::BlobGasPriceGreaterThanMax { + block_blob_gas_price, + tx_max_fee_per_blob_gas, + } => Self::BlobFeeCapTooLow(block_blob_gas_price, tx_max_fee_per_blob_gas), + InvalidTransaction::BlobVersionedHashesNotSupported => { + Self::BlobVersionedHashesNotSupported + } + InvalidTransaction::MaxFeePerBlobGasNotSupported => Self::MaxFeePerBlobGasNotSupported, + InvalidTransaction::BlobCreateTransaction => Self::BlobCreateTransaction, + InvalidTransaction::BlobVersionNotSupported => Self::BlobVersionNotSupported, + InvalidTransaction::EmptyBlobs => Self::EmptyBlobs, + InvalidTransaction::TooManyBlobs { have, max } => Self::TooManyBlobs(have, max), + InvalidTransaction::AuthorizationListNotSupported => { + Self::AuthorizationListNotSupported + } + InvalidTransaction::TxGasLimitGreaterThanCap { gas_limit, cap } => { + Self::TxGasLimitGreaterThanCap(gas_limit, cap) + } + + InvalidTransaction::AuthorizationListInvalidFields + | InvalidTransaction::Eip1559NotSupported + | InvalidTransaction::Eip2930NotSupported + | InvalidTransaction::Eip4844NotSupported + | InvalidTransaction::Eip7702NotSupported + | InvalidTransaction::EmptyAuthorizationList + | InvalidTransaction::Eip7873NotSupported + | InvalidTransaction::Eip7873MissingTarget + | InvalidTransaction::MissingChainId + | InvalidTransaction::Str(_) => Self::Revm(err), + } + } +} + +/// Helper trait to easily convert results to rpc results +pub(crate) trait ToRpcResponseResult { + fn to_rpc_result(self) -> ResponseResult; +} + +/// Converts a serializable value into a `ResponseResult` +pub fn to_rpc_result(val: T) -> ResponseResult { + match serde_json::to_value(val) { + Ok(success) => ResponseResult::Success(success), + Err(err) => { + error!(%err, "Failed serialize rpc response"); + ResponseResult::error(RpcError::internal_error()) + } + } +} + +impl ToRpcResponseResult for Result { + fn to_rpc_result(self) -> ResponseResult { + match self { + Ok(val) => to_rpc_result(val), + Err(err) => match err { + BlockchainError::Pool(err) => { + error!(%err, "txpool error"); + match err { + PoolError::CyclicTransaction => { + RpcError::transaction_rejected("Cyclic transaction detected") + } + PoolError::ReplacementUnderpriced(_) => { + RpcError::transaction_rejected("replacement transaction underpriced") + } + PoolError::AlreadyImported(_) => { + RpcError::transaction_rejected("transaction already imported") + } + } + } + BlockchainError::NoSignerAvailable => { + RpcError::invalid_params("No Signer available") + } + BlockchainError::ChainIdNotAvailable => { + RpcError::invalid_params("Chain Id not available") + } + BlockchainError::TransactionConfirmationTimeout { hash, .. } => RpcError { + code: ErrorCode::ServerError(4), + message: "Transaction confirmation timeout".into(), + data: Some(serde_json::Value::String(hash.to_string())), + }, + BlockchainError::InvalidTransaction(err) => match err { + InvalidTransactionError::Revert(data) => { + // this mimics geth revert error + let mut msg = "execution reverted".to_string(); + if let Some(reason) = data + .as_ref() + .and_then(|data| RevertDecoder::new().maybe_decode(data, None)) + { + msg = format!("{msg}: {reason}"); + } + RpcError { + // geth returns this error code on reverts, See + code: ErrorCode::ExecutionError, + message: msg.into(), + data: serde_json::to_value(data).ok(), + } + } + InvalidTransactionError::GasTooLow => { + // + RpcError { + code: ErrorCode::ServerError(-32000), + message: err.to_string().into(), + data: None, + } + } + InvalidTransactionError::GasTooHigh(_) => { + // + RpcError { + code: ErrorCode::ServerError(-32000), + message: err.to_string().into(), + data: None, + } + } + _ => RpcError::transaction_rejected(err.to_string()), + }, + BlockchainError::FeeHistory(err) => RpcError::invalid_params(err.to_string()), + BlockchainError::EmptyRawTransactionData => { + RpcError::invalid_params("Empty transaction data") + } + BlockchainError::FailedToDecodeSignedTransaction => { + RpcError::invalid_params("Failed to decode transaction") + } + BlockchainError::FailedToDecodeTransaction => { + RpcError::invalid_params("Failed to decode transaction") + } + BlockchainError::FailedToDecodeReceipt => { + RpcError::invalid_params("Failed to decode receipt") + } + BlockchainError::UnsupportedTransactionEncoding(_) => { + RpcError::internal_error_with(err.to_string()) + } + BlockchainError::FailedToDecodeStateDump => { + RpcError::invalid_params("Failed to decode state dump") + } + BlockchainError::SignerError(err) => RpcError::invalid_params(err.to_string()), + BlockchainError::SignatureError(err) => RpcError::invalid_params(err.to_string()), + BlockchainError::RpcUnimplemented => { + RpcError::internal_error_with("Not implemented") + } + BlockchainError::PrevrandaoNotSet => RpcError::internal_error_with(err.to_string()), + BlockchainError::RpcError(err) => err, + BlockchainError::InvalidFeeInput => RpcError::invalid_params( + "Invalid input: `max_priority_fee_per_gas` greater than `max_fee_per_gas`", + ), + err @ BlockchainError::ConflictingFeeFields => { + RpcError::invalid_params(err.to_string()) + } + BlockchainError::AlloyForkProvider(err) => { + error!(target: "backend", %err, "fork provider error"); + match err { + TransportError::ErrorResp(err) => RpcError { + code: ErrorCode::from(err.code), + message: err.message, + data: err.data.and_then(|data| serde_json::to_value(data).ok()), + }, + err => RpcError::internal_error_with(format!("Fork Error: {err:?}")), + } + } + err @ BlockchainError::EvmError(_) => RpcError { + // VM halts are execution failures, not JSON-RPC server faults. REVERT has a + // dedicated code/data path above; other halts, such as invalid opcode, do not. + code: ErrorCode::TransactionRejected, + message: err.to_string().into(), + data: None, + }, + #[cfg(feature = "base")] + err @ BlockchainError::Eip8130TransactionRejected(_) => RpcError { + code: ErrorCode::TransactionRejected, + message: err.to_string().into(), + data: None, + }, + err @ BlockchainError::EvmOverrideError(_) => { + RpcError::invalid_params(err.to_string()) + } + err @ BlockchainError::InvalidUrl(_) => RpcError::invalid_params(err.to_string()), + err @ BlockchainError::UnsupportedForkNetwork { .. } => { + RpcError::invalid_params(err.to_string()) + } + BlockchainError::Internal(err) => RpcError::internal_error_with(err), + err @ BlockchainError::BlockOutOfRange(_, _) => { + RpcError::invalid_params(err.to_string()) + } + err @ BlockchainError::BlockNotFound => RpcError { + // + code: ErrorCode::ServerError(-32001), + message: err.to_string().into(), + data: None, + }, + err @ BlockchainError::TransactionNotFound => RpcError { + code: ErrorCode::ServerError(-32001), + message: err.to_string().into(), + data: None, + }, + err @ BlockchainError::DataUnavailable => { + RpcError::internal_error_with(err.to_string()) + } + err @ BlockchainError::HistoricalStateUnavailable(_) => RpcError { + code: ErrorCode::ServerError(-32000), + message: err.to_string().into(), + data: None, + }, + err @ BlockchainError::TrieError(_) => { + RpcError::internal_error_with(err.to_string()) + } + BlockchainError::UintConversion(err) => RpcError::invalid_params(err), + err @ BlockchainError::StateOverrideError(_) => { + RpcError::invalid_params(err.to_string()) + } + err @ BlockchainError::TimestampError(_) => { + RpcError::invalid_params(err.to_string()) + } + BlockchainError::DatabaseError(err) => { + RpcError::internal_error_with(err.to_string()) + } + err @ BlockchainError::EIP1559TransactionUnsupportedAtHardfork => { + RpcError::invalid_params(err.to_string()) + } + err @ BlockchainError::EIP2930TransactionUnsupportedAtHardfork => { + RpcError::invalid_params(err.to_string()) + } + err @ BlockchainError::EIP4844TransactionUnsupportedAtHardfork => { + RpcError::invalid_params(err.to_string()) + } + err @ BlockchainError::EIP7702TransactionUnsupportedAtHardfork => { + RpcError::invalid_params(err.to_string()) + } + err @ BlockchainError::DepositTransactionUnsupported => { + RpcError::invalid_params(err.to_string()) + } + #[cfg(feature = "base")] + err @ BlockchainError::BaseTransactionUnsupported => { + RpcError::invalid_params(err.to_string()) + } + err @ BlockchainError::TempoTransactionUnsupported => { + RpcError::invalid_params(err.to_string()) + } + err @ BlockchainError::ExcessBlobGasNotSet => { + RpcError::invalid_params(err.to_string()) + } + err @ BlockchainError::Message(_) => RpcError::internal_error_with(err.to_string()), + err @ BlockchainError::UnknownTransactionType => { + RpcError::invalid_params(err.to_string()) + } + err @ BlockchainError::InvalidTransactionRequest(_) => { + RpcError::invalid_params(err.to_string()) + } + err @ BlockchainError::RecoveryError(_) => { + RpcError::invalid_params(err.to_string()) + } + BlockchainError::FilterNotFound => RpcError { + code: ErrorCode::ServerError(-32000), + message: "filter not found".into(), + data: None, + }, + err @ BlockchainError::UnknownBlock => RpcError { + code: ErrorCode::ServerError(-32000), + message: err.to_string().into(), + data: None, + }, + } + .into(), + } + } +} diff --git a/crates/anvil/src/eth/error/optimism.rs b/crates/anvil/src/eth/error/optimism.rs new file mode 100644 index 0000000000000..1207fde30a72a --- /dev/null +++ b/crates/anvil/src/eth/error/optimism.rs @@ -0,0 +1,62 @@ +//! Optimism-specific error conversions for [`BlockchainError`] and +//! [`InvalidTransactionError`]. + +use super::{BlockchainError, InvalidTransactionError}; +use op_revm::OpTransactionError; +use revm::context_interface::result::{EVMError, InvalidHeader}; + +impl From> for BlockchainError +where + T: Into, +{ + fn from(err: EVMError) -> Self { + match err { + EVMError::Transaction(err) => match err { + OpTransactionError::Base(err) => InvalidTransactionError::from(err).into(), + OpTransactionError::DepositSystemTxPostRegolith => { + Self::DepositTransactionUnsupported + } + OpTransactionError::HaltedDepositPostRegolith => { + Self::DepositTransactionUnsupported + } + OpTransactionError::MissingEnvelopedTx => Self::InvalidTransaction(err.into()), + }, + EVMError::Header(err) => match err { + InvalidHeader::ExcessBlobGasNotSet => Self::ExcessBlobGasNotSet, + InvalidHeader::PrevrandaoNotSet => Self::PrevrandaoNotSet, + }, + EVMError::Database(err) => err.into(), + EVMError::Custom(err) => Self::Message(err), + EVMError::CustomAny(err) => Self::Message(err.to_string()), + } + } +} + +impl From> for BlockchainError +where + T: Into, +{ + fn from(err: EVMError) -> Self { + match err { + EVMError::Transaction(err) => { + let op_err: OpTransactionError = err.0; + EVMError::::Transaction(op_err).into() + } + EVMError::Header(err) => EVMError::::Header(err).into(), + EVMError::Database(err) => err.into(), + EVMError::Custom(err) => Self::Message(err), + EVMError::CustomAny(err) => Self::Message(err.to_string()), + } + } +} + +impl From for InvalidTransactionError { + fn from(value: OpTransactionError) -> Self { + match value { + OpTransactionError::Base(err) => err.into(), + OpTransactionError::DepositSystemTxPostRegolith + | OpTransactionError::HaltedDepositPostRegolith => Self::DepositTxErrorPostRegolith, + OpTransactionError::MissingEnvelopedTx => Self::MissingEnvelopedTx, + } + } +} diff --git a/crates/anvil/src/eth/fees.rs b/crates/anvil/src/eth/fees.rs index d5ec10459eab9..4eea5303eb59f 100644 --- a/crates/anvil/src/eth/fees.rs +++ b/crates/anvil/src/eth/fees.rs @@ -1,30 +1,42 @@ +use crate::eth::{ + backend::{info::StorageInfo, notifications::ChainNotifications}, + error::BlockchainError, +}; +use alloy_consensus::{BlockHeader, Transaction, TxReceipt}; +use alloy_eips::{calc_next_block_base_fee, eip1559::BaseFeeParams, eip7840::BlobParams}; +use alloy_network::Network; +use alloy_primitives::{B256, Bytes}; +use futures::StreamExt; +use parking_lot::{Mutex, RwLock}; +use revm::{context_interface::block::BlobExcessGasAndPrice, primitives::hardfork::SpecId}; use std::{ collections::BTreeMap, fmt, pin::Pin, - sync::Arc, + sync::{Arc, LazyLock}, task::{Context, Poll}, }; +use tempo_hardfork::{TempoHardfork, constants::gas::tempo_t7_next_block_base_fee}; -use alloy_consensus::Header; -use alloy_eips::{ - calc_next_block_base_fee, eip1559::BaseFeeParams, eip7691::MAX_BLOBS_PER_BLOCK_ELECTRA, - eip7840::BlobParams, -}; -use alloy_primitives::B256; -use anvil_core::eth::transaction::TypedTransaction; -use futures::StreamExt; -use parking_lot::{Mutex, RwLock}; -use revm::{context_interface::block::BlobExcessGasAndPrice, primitives::hardfork::SpecId}; +#[cfg(feature = "optimism")] +use foundry_evm::hardfork::OpHardfork; -use crate::eth::{ - backend::{info::StorageInfo, notifications::NewBlockNotifications}, - error::BlockchainError, -}; +#[cfg(feature = "optimism")] +mod optimism; /// Maximum number of entries in the fee history cache pub const MAX_FEE_HISTORY_CACHE_SIZE: u64 = 2048u64; +/// Number of cached reward samples per percentile. +pub(crate) const REWARD_PERCENTILE_RESOLUTION: f64 = 2.0; + +/// Percentile list from 0.0 to 100.0 with a 0.5 resolution (201 points). +/// +/// Constant across blocks, so it is computed once instead of being rebuilt on every +/// `create_fee_history_cache_item` call. +static REWARD_PERCENTILES: LazyLock> = + LazyLock::new(|| (0..=200).map(|index| index as f64 / REWARD_PERCENTILE_RESOLUTION).collect()); + /// Initial base fee for EIP-1559 blocks. pub const INITIAL_BASE_FEE: u64 = 1_000_000_000; @@ -37,107 +49,311 @@ pub const BASE_FEE_CHANGE_DENOMINATOR: u128 = 8; /// Minimum suggested priority fee pub const MIN_SUGGESTED_PRIORITY_FEE: u128 = 1e9 as u128; -pub fn default_elasticity() -> f64 { - 1f64 / BaseFeeParams::ethereum().elasticity_multiplier as f64 -} - /// Stores the fee related information #[derive(Clone, Debug)] pub struct FeeManager { - /// Hardfork identifier - spec_id: SpecId, - /// Tracks the base fee for the next block post London - /// - /// This value will be updated after a new block was mined - base_fee: Arc>, + /// Fee state published as one coherent execution context. + state: Arc>, /// Whether the minimum suggested priority fee is enforced is_min_priority_fee_enforced: bool, - /// Tracks the excess blob gas, and the base fee, for the next block post Cancun - /// - /// This value will be updated after a new block was mined - blob_excess_gas_and_price: Arc>, - /// The base price to use Pre London - /// - /// This will be constant value unless changed manually - gas_price: Arc>, - elasticity: Arc>, +} + +#[derive(Clone, Copy, Debug)] +struct FeeRules { + spec_id: SpecId, + base_fee: BaseFeeRules, + /// The active Tempo hardfork, set only when running a Tempo chain. + tempo_hardfork: Option, +} + +#[derive(Clone, Copy, Debug)] +enum BaseFeeRules { + Standard(BaseFeeParams), + #[cfg(feature = "optimism")] + Optimism { + inherited: Option, + fallback: BaseFeeParams, + }, +} + +impl BaseFeeRules { + const fn params(self) -> BaseFeeParams { + match self { + Self::Standard(params) => params, + #[cfg(feature = "optimism")] + Self::Optimism { inherited, fallback } => { + if let Some(rules) = inherited { + rules.params() + } else { + fallback + } + } + } + } + + #[allow(clippy::missing_const_for_fn)] // Optimism extra-data encoding is not const. + fn extra_data(self) -> Bytes { + match self { + Self::Standard(_) => Bytes::new(), + #[cfg(feature = "optimism")] + Self::Optimism { inherited, .. } => { + inherited.map_or_else(Bytes::new, optimism::OptimismBaseFeeRules::extra_data) + } + } + } + + fn parent_header_fees(self, header: &H) -> ParentHeaderFees { + match self { + Self::Standard(params) => ParentHeaderFees { + base_fee: calc_next_block_base_fee( + header.gas_used(), + header.gas_limit(), + header.base_fee_per_gas().unwrap_or_default(), + params, + ), + ..Default::default() + }, + #[cfg(feature = "optimism")] + Self::Optimism { fallback, .. } => { + let inherited = optimism::OptimismBaseFeeRules::decode(header.extra_data()); + ParentHeaderFees { + base_fee: inherited.map_or_else( + || { + calc_next_block_base_fee( + header.gas_used(), + header.gas_limit(), + header.base_fee_per_gas().unwrap_or_default(), + fallback, + ) + }, + |rules| rules.next_block_base_fee(header), + ), + extra_data: inherited + .map_or_else(Bytes::new, optimism::OptimismBaseFeeRules::extra_data), + optimism_jovian: inherited.map(optimism::OptimismBaseFeeRules::is_jovian), + } + } + } + } +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct ParentHeaderFees { + /// Base fee inherited by the child block. + pub(crate) base_fee: u64, + /// Dynamic fee parameters inherited by the child block. + pub(crate) extra_data: Bytes, + /// Whether the decoded Optimism fee parameters activate Jovian. + pub(crate) optimism_jovian: Option, +} + +#[derive(Clone, Copy, Debug)] +struct FeeState { + rules: FeeRules, + blob_params: BlobParams, + /// Base fee for the next block. + base_fee: u64, + /// Excess blob gas and price for the next block. + blob_excess_gas_and_price: BlobExcessGasAndPrice, + /// Legacy gas price. + gas_price: u128, +} + +/// Chain-derived fee state for the next block. +#[derive(Clone, Copy, Debug)] +pub(crate) struct FeeSnapshot { + base_fee: u64, + blob_excess_gas_and_price: BlobExcessGasAndPrice, } impl FeeManager { + #[allow(clippy::too_many_arguments)] pub fn new( spec_id: SpecId, base_fee: u64, is_min_priority_fee_enforced: bool, gas_price: u128, blob_excess_gas_and_price: BlobExcessGasAndPrice, + blob_params: BlobParams, + base_fee_params: BaseFeeParams, + tempo_hardfork: Option, ) -> Self { Self { - spec_id, - base_fee: Arc::new(RwLock::new(base_fee)), + state: Arc::new(RwLock::new(FeeState { + rules: FeeRules { + spec_id, + base_fee: BaseFeeRules::Standard(base_fee_params), + tempo_hardfork, + }, + blob_params, + base_fee, + blob_excess_gas_and_price, + gas_price, + })), is_min_priority_fee_enforced, - gas_price: Arc::new(RwLock::new(gas_price)), - blob_excess_gas_and_price: Arc::new(RwLock::new(blob_excess_gas_and_price)), - elasticity: Arc::new(RwLock::new(default_elasticity())), } } + /// Creates an independent copy suitable for staging a fork reset. + pub(crate) fn detached(&self) -> Self { + Self { + state: Arc::new(RwLock::new(*self.state.read())), + is_min_priority_fee_enforced: self.is_min_priority_fee_enforced, + } + } + + /// Replaces all mutable fee state with a staged manager's values. + pub(crate) fn replace_from(&self, other: &Self) { + *self.state.write() = *other.state.read(); + } + + /// Captures the chain-derived fee state for the next block. + pub(crate) fn snapshot(&self) -> FeeSnapshot { + let state = self.state.read(); + FeeSnapshot { + base_fee: state.base_fee, + blob_excess_gas_and_price: state.blob_excess_gas_and_price, + } + } + + /// Restores the chain-derived fee state for the next block. + pub(crate) fn restore(&self, snapshot: FeeSnapshot) { + let mut state = self.state.write(); + state.base_fee = snapshot.base_fee; + state.blob_excess_gas_and_price = snapshot.blob_excess_gas_and_price; + } + + /// Atomically publishes the chain-derived fee state for the next block. + pub(crate) fn set_next_block_fees( + &self, + base_fee: u64, + blob_excess_gas_and_price: BlobExcessGasAndPrice, + ) { + trace!( + target: "backend::fees", + ?base_fee, + ?blob_excess_gas_and_price, + "updated next block fees" + ); + let mut state = self.state.write(); + state.base_fee = base_fee; + state.blob_excess_gas_and_price = blob_excess_gas_and_price; + } + + /// Returns the active Tempo hardfork, if running a Tempo chain. + pub fn tempo_hardfork(&self) -> Option { + self.state.read().rules.tempo_hardfork + } + + /// Atomically replaces all execution-dependent fee rules. + pub fn set_execution_rules( + &self, + spec_id: SpecId, + base_fee_params: BaseFeeParams, + tempo_hardfork: Option, + ) { + self.state.write().rules = + FeeRules { spec_id, base_fee: BaseFeeRules::Standard(base_fee_params), tempo_hardfork }; + } + + /// Applies the dynamic EIP-1559 parameters encoded in an Optimism-family parent header. + #[cfg(feature = "optimism")] + pub(crate) fn set_optimism_base_fee_rules(&self, extra_data: &[u8]) { + let mut state = self.state.write(); + let fallback = match state.rules.base_fee { + BaseFeeRules::Standard(params) | BaseFeeRules::Optimism { fallback: params, .. } => { + params + } + }; + state.rules.base_fee = BaseFeeRules::Optimism { + inherited: optimism::OptimismBaseFeeRules::decode(extra_data), + fallback, + }; + } + + /// Initializes Optimism-family fee rules for a node that is not inheriting a fork header. + #[cfg(feature = "optimism")] + pub(crate) fn set_optimism_hardfork(&self, hardfork: OpHardfork) { + let mut state = self.state.write(); + let fallback = state.rules.base_fee.params(); + state.rules.base_fee = BaseFeeRules::Optimism { + inherited: optimism::OptimismBaseFeeRules::for_hardfork(hardfork, fallback), + fallback, + }; + } + + /// Returns the Optimism-family EIP-1559 parameters inherited by locally built blocks. + pub(crate) fn base_fee_extra_data(&self) -> Bytes { + self.state.read().rules.base_fee.extra_data() + } + pub fn elasticity(&self) -> f64 { - *self.elasticity.read() + 1f64 / self.state.read().rules.base_fee.params().elasticity_multiplier as f64 } /// Returns true for post London pub fn is_eip1559(&self) -> bool { - (self.spec_id as u8) >= (SpecId::LONDON as u8) + (self.state.read().rules.spec_id as u8) >= (SpecId::LONDON as u8) } pub fn is_eip4844(&self) -> bool { - (self.spec_id as u8) >= (SpecId::CANCUN as u8) + (self.state.read().rules.spec_id as u8) >= (SpecId::CANCUN as u8) } /// Calculates the current blob gas price pub fn blob_gas_price(&self) -> u128 { - if self.is_eip4844() { self.base_fee_per_blob_gas() } else { 0 } + let state = self.state.read(); + if (state.rules.spec_id as u8) >= (SpecId::CANCUN as u8) { + state.blob_excess_gas_and_price.blob_gasprice + } else { + 0 + } } pub fn base_fee(&self) -> u64 { - if self.is_eip1559() { *self.base_fee.read() } else { 0 } + let state = self.state.read(); + if (state.rules.spec_id as u8) >= (SpecId::LONDON as u8) { state.base_fee } else { 0 } } - pub fn is_min_priority_fee_enforced(&self) -> bool { + pub const fn is_min_priority_fee_enforced(&self) -> bool { self.is_min_priority_fee_enforced } /// Raw base gas price pub fn raw_gas_price(&self) -> u128 { - *self.gas_price.read() + self.state.read().gas_price } pub fn excess_blob_gas_and_price(&self) -> Option { - if self.is_eip4844() { Some(*self.blob_excess_gas_and_price.read()) } else { None } + let state = self.state.read(); + ((state.rules.spec_id as u8) >= (SpecId::CANCUN as u8)) + .then_some(state.blob_excess_gas_and_price) } pub fn base_fee_per_blob_gas(&self) -> u128 { - if self.is_eip4844() { self.blob_excess_gas_and_price.read().blob_gasprice } else { 0 } + let state = self.state.read(); + if (state.rules.spec_id as u8) >= (SpecId::CANCUN as u8) { + state.blob_excess_gas_and_price.blob_gasprice + } else { + 0 + } } /// Returns the current gas price pub fn set_gas_price(&self, price: u128) { - let mut gas = self.gas_price.write(); - *gas = price; + self.state.write().gas_price = price; } /// Returns the current base fee pub fn set_base_fee(&self, fee: u64) { trace!(target: "backend::fees", "updated base fee {:?}", fee); - let mut base = self.base_fee.write(); - *base = fee; + self.state.write().base_fee = fee; } /// Sets the current blob excess gas and price pub fn set_blob_excess_gas_and_price(&self, blob_excess_gas_and_price: BlobExcessGasAndPrice) { trace!(target: "backend::fees", "updated blob base fee {:?}", blob_excess_gas_and_price); - let mut base = self.blob_excess_gas_and_price.write(); - *base = blob_excess_gas_and_price; + self.state.write().blob_excess_gas_and_price = blob_excess_gas_and_price; } /// Calculates the base fee for the next block @@ -147,55 +363,164 @@ impl FeeManager { gas_limit: u64, last_fee_per_gas: u64, ) -> u64 { + let state = self.state.read(); // It's naturally impossible for base fee to be 0; // It means it was set by the user deliberately and therefore we treat it as a constant. // Therefore, we skip the base fee calculation altogether and we return 0. - if self.base_fee() == 0 { + if (state.rules.spec_id as u8) < (SpecId::LONDON as u8) || state.base_fee == 0 { + return 0; + } + calculate_next_block_base_fee_per_gas(state.rules, gas_used, gas_limit, last_fee_per_gas) + } + + /// Calculates the next block base fee from the parent block without applying the configured + /// zero-fee sentinel. + #[cfg(test)] + pub(crate) fn calculate_next_block_base_fee_per_gas( + &self, + gas_used: u64, + gas_limit: u64, + last_fee_per_gas: u64, + ) -> u64 { + let rules = self.state.read().rules; + if (rules.spec_id as u8) < (SpecId::LONDON as u8) { + return 0; + } + calculate_next_block_base_fee_per_gas(rules, gas_used, gas_limit, last_fee_per_gas) + } + + /// Calculates the next block base fee from a complete parent header. + pub(crate) fn get_next_block_base_fee_from_header(&self, header: &H) -> u64 { + let state = self.state.read(); + if (state.rules.spec_id as u8) < (SpecId::LONDON as u8) || state.base_fee == 0 { + return 0; + } + calculate_parent_header_fees(state.rules, header).base_fee + } + + /// Returns all fee metadata inherited from a parent header, honoring the configured zero-fee + /// sentinel. + pub(crate) fn get_parent_header_fees(&self, header: &H) -> ParentHeaderFees { + let state = self.state.read(); + let mut fees = calculate_parent_header_fees(state.rules, header); + if (state.rules.spec_id as u8) < (SpecId::LONDON as u8) || state.base_fee == 0 { + fees.base_fee = 0; + } + fees + } + + /// Calculates the next block base fee from a complete parent header without applying the + /// configured zero-fee sentinel. + pub(crate) fn calculate_next_block_base_fee_from_header( + &self, + header: &H, + ) -> u64 { + let rules = self.state.read().rules; + if (rules.spec_id as u8) < (SpecId::LONDON as u8) { return 0; } - calculate_next_block_base_fee(gas_used, gas_limit, last_fee_per_gas) + calculate_parent_header_fees(rules, header).base_fee + } + + /// Returns all fee metadata inherited from a parent header without applying the configured + /// zero-fee sentinel. + pub(crate) fn calculate_parent_header_fees( + &self, + header: &H, + ) -> ParentHeaderFees { + let rules = self.state.read().rules; + let mut fees = calculate_parent_header_fees(rules, header); + if (rules.spec_id as u8) < (SpecId::LONDON as u8) { + fees.base_fee = 0; + } + fees } - /// Calculates the next block blob base fee, using the provided excess blob gas - pub fn get_next_block_blob_base_fee_per_gas(&self, excess_blob_gas: u128) -> u128 { - alloy_eips::eip4844::calc_blob_gasprice(excess_blob_gas as u64) + /// Calculates the next block blob base fee. + pub fn get_next_block_blob_base_fee_per_gas(&self) -> u128 { + let state = self.state.read(); + state.blob_params.calc_blob_fee(state.blob_excess_gas_and_price.excess_blob_gas) } - /// Calculates the next block blob excess gas, using the provided parent blob gas used and - /// parent blob excess gas - pub fn get_next_block_blob_excess_gas(&self, blob_gas_used: u64, blob_excess_gas: u64) -> u64 { - alloy_eips::eip4844::calc_excess_blob_gas(blob_gas_used, blob_excess_gas) + /// Configures the blob params + pub fn set_blob_params(&self, blob_params: BlobParams) { + self.state.write().blob_params = blob_params; } + + /// Returns the active [`BlobParams`] + pub fn blob_params(&self) -> BlobParams { + self.state.read().blob_params + } +} + +fn calculate_next_block_base_fee_per_gas( + rules: FeeRules, + gas_used: u64, + gas_limit: u64, + last_fee_per_gas: u64, +) -> u64 { + // Tempo replaces EIP-1559 with its own hardfork-specific base fee rules. + if let Some(hardfork) = rules.tempo_hardfork { + return tempo_next_block_base_fee(hardfork, gas_used, last_fee_per_gas); + } + calc_next_block_base_fee(gas_used, gas_limit, last_fee_per_gas, rules.base_fee.params()) +} + +fn calculate_parent_header_fees(rules: FeeRules, header: &H) -> ParentHeaderFees { + if let Some(hardfork) = rules.tempo_hardfork { + return ParentHeaderFees { + base_fee: tempo_next_block_base_fee( + hardfork, + header.gas_used(), + header.base_fee_per_gas().unwrap_or_default(), + ), + ..Default::default() + }; + } + rules.base_fee.parent_header_fees(header) } -/// Calculate base fee for next block. [EIP-1559](https://github.com/ethereum/EIPs/blob/master/EIPS/eip-1559.md) spec -pub fn calculate_next_block_base_fee(gas_used: u64, gas_limit: u64, base_fee: u64) -> u64 { - calc_next_block_base_fee(gas_used, gas_limit, base_fee, BaseFeeParams::ethereum()) +/// Computes the next block's base fee for a Tempo chain. +/// +/// - T7+: the TIP-1067 dynamic controller, an EIP-1559 update against a fixed 10M gas target +/// clamped to `[floor, cap]`. +/// - Pre-T7: the fixed hardfork base fee (10 gwei pre-T1, 20 gwei T1+). +fn tempo_next_block_base_fee(hardfork: TempoHardfork, gas_used: u64, parent_base_fee: u64) -> u64 { + if hardfork.is_t7() { + return tempo_t7_next_block_base_fee(parent_base_fee, gas_used); + } + crate::config::tempo_default_base_fee(hardfork) } /// An async service that takes care of the `FeeHistory` cache -pub struct FeeHistoryService { - /// blob parameters for the current spec - blob_params: BlobParams, +pub struct FeeHistoryService +where + N::ReceiptEnvelope: TxReceipt, +{ + /// Live fee rules, including blob parameters replaced by fork resets. + fees: FeeManager, /// incoming notifications about new blocks - new_blocks: NewBlockNotifications, + new_blocks: ChainNotifications, /// contains all fee history related entries cache: FeeHistoryCache, /// number of items to consider fee_history_limit: u64, /// a type that can fetch ethereum-storage data - storage_info: StorageInfo, + storage_info: StorageInfo, } -impl FeeHistoryService { - pub fn new( - blob_params: BlobParams, - new_blocks: NewBlockNotifications, +impl FeeHistoryService +where + N::ReceiptEnvelope: TxReceipt, +{ + pub const fn new( + fees: FeeManager, + new_blocks: ChainNotifications, cache: FeeHistoryCache, - storage_info: StorageInfo, + storage_info: StorageInfo, ) -> Self { Self { - blob_params, + fees, new_blocks, cache, fee_history_limit: MAX_FEE_HISTORY_CACHE_SIZE, @@ -204,12 +529,12 @@ impl FeeHistoryService { } /// Returns the configured history limit - pub fn fee_history_limit(&self) -> u64 { + pub const fn fee_history_limit(&self) -> u64 { self.fee_history_limit } /// Inserts a new cache entry for the given block - pub(crate) fn insert_cache_entry_for_block(&self, hash: B256, header: &Header) { + pub(crate) fn insert_cache_entry_for_block(&self, hash: B256, header: &impl BlockHeader) { let (result, block_number) = self.create_cache_entry(hash, header); self.insert_cache_entry(result, block_number); } @@ -218,129 +543,146 @@ impl FeeHistoryService { fn create_cache_entry( &self, hash: B256, - header: &Header, + header: &impl BlockHeader, ) -> (FeeHistoryCacheItem, Option) { - // percentile list from 0.0 to 100.0 with a 0.5 resolution. - // this will create 200 percentile points - let reward_percentiles: Vec = { - let mut percentile: f64 = 0.0; - (0..=200) - .map(|_| { - let val = percentile; - percentile += 0.5; - val - }) - .collect() - }; - - let mut block_number: Option = None; - let base_fee = header.base_fee_per_gas.map(|g| g as u128).unwrap_or_default(); - let excess_blob_gas = header.excess_blob_gas.map(|g| g as u128); - let blob_gas_used = header.blob_gas_used.map(|g| g as u128); - let base_fee_per_blob_gas = header.blob_fee(self.blob_params); - - let mut item = FeeHistoryCacheItem { - base_fee, - gas_used_ratio: 0f64, - blob_gas_used_ratio: 0f64, - rewards: Vec::new(), - excess_blob_gas, - base_fee_per_blob_gas, - blob_gas_used, - }; + create_fee_history_cache_item(hash, header, &self.storage_info, self.fees.blob_params()) + } - let current_block = self.storage_info.block(hash); - let current_receipts = self.storage_info.receipts(hash); - - if let (Some(block), Some(receipts)) = (current_block, current_receipts) { - block_number = Some(block.header.number); - - let gas_used = block.header.gas_used as f64; - let blob_gas_used = block.header.blob_gas_used.map(|g| g as f64); - item.gas_used_ratio = gas_used / block.header.gas_limit as f64; - item.blob_gas_used_ratio = - blob_gas_used.map(|g| g / MAX_BLOBS_PER_BLOCK_ELECTRA as f64).unwrap_or(0 as f64); - - // extract useful tx info (gas_used, effective_reward) - let mut transactions: Vec<(_, _)> = receipts - .iter() - .enumerate() - .map(|(i, receipt)| { - let gas_used = receipt.cumulative_gas_used(); - let effective_reward = match block.transactions.get(i).map(|tx| &tx.transaction) - { - Some(TypedTransaction::Legacy(t)) => { - t.tx().gas_price.saturating_sub(base_fee) - } - Some(TypedTransaction::EIP2930(t)) => { - t.tx().gas_price.saturating_sub(base_fee) - } - Some(TypedTransaction::EIP1559(t)) => t - .tx() - .max_priority_fee_per_gas - .min(t.tx().max_fee_per_gas.saturating_sub(base_fee)), - // TODO: This probably needs to be extended to extract 4844 info. - Some(TypedTransaction::EIP4844(t)) => t - .tx() - .tx() - .max_priority_fee_per_gas - .min(t.tx().tx().max_fee_per_gas.saturating_sub(base_fee)), - Some(TypedTransaction::EIP7702(t)) => t - .tx() - .max_priority_fee_per_gas - .min(t.tx().max_fee_per_gas.saturating_sub(base_fee)), - Some(TypedTransaction::Deposit(_)) => 0, - None => 0, - }; + fn insert_cache_entry(&self, item: FeeHistoryCacheItem, block_number: Option) { + insert_fee_history_cache_item(&self.cache, item, block_number, self.fee_history_limit); + } +} - (gas_used, effective_reward) - }) - .collect(); - - // sort by effective reward asc - transactions.sort_by(|(_, a), (_, b)| a.cmp(b)); - - // calculate percentile rewards - item.rewards = reward_percentiles - .into_iter() - .filter_map(|p| { - let target_gas = (p * gas_used / 100f64) as u64; - let mut sum_gas = 0; - for (gas_used, effective_reward) in transactions.iter().copied() { - sum_gas += gas_used; - if target_gas <= sum_gas { - return Some(effective_reward); - } - } - None - }) - .collect(); - } else { - item.rewards = reward_percentiles.iter().map(|_| 0).collect(); +/// Inserts an entry into the fee history cache and trims it back to `fee_history_limit`. +/// +/// Used by the async [`FeeHistoryService`]. The `eth_feeHistory` fallback applies the same bounded +/// insertion policy to a batch under one lock. +pub(crate) fn insert_fee_history_cache_item( + cache: &FeeHistoryCache, + item: FeeHistoryCacheItem, + block_number: Option, + fee_history_limit: u64, +) { + if let Some(block_number) = block_number { + trace!(target: "fees", "insert new history item={:?} for {}", item, block_number); + let mut cache = cache.lock(); + cache.insert(block_number, item); + + // Trim to the cache limit by dropping the oldest entries (smallest block numbers). + // `pop_first` is saturating and correct regardless of insertion order, unlike the + // previous index math which could underflow when the `eth_feeHistory` fallback inserts + // entries out of order. + while cache.len() as u64 > fee_history_limit { + cache.pop_first(); } - (item, block_number) } +} - fn insert_cache_entry(&self, item: FeeHistoryCacheItem, block_number: Option) { - if let Some(block_number) = block_number { - trace!(target: "fees", "insert new history item={:?} for {}", item, block_number); - let mut cache = self.cache.lock(); - cache.insert(block_number, item); - - // adhere to cache limit - let pop_next = block_number.saturating_sub(self.fee_history_limit); - - let num_remove = (cache.len() as u64).saturating_sub(self.fee_history_limit); - for num in 0..num_remove { - let key = pop_next - num; - cache.remove(&key); - } +/// Calculates percentile rewards from transactions sorted by effective reward. +/// +/// [`REWARD_PERCENTILES`] must remain ascending because the transaction cursor never rewinds. +fn reward_percentiles(transactions: &[(u64, u128)], block_gas_used: f64) -> Vec { + let mut rewards = Vec::with_capacity(REWARD_PERCENTILES.len()); + let mut transactions = transactions.iter().copied(); + let Some((mut cumulative_gas, mut current_reward)) = transactions.next() else { + return rewards; + }; + + for &percentile in REWARD_PERCENTILES.iter() { + let target_gas = (percentile * block_gas_used / 100f64) as u64; + while target_gas > cumulative_gas { + let Some((tx_gas_used, effective_reward)) = transactions.next() else { return rewards }; + cumulative_gas += tx_gas_used; + current_reward = effective_reward; } + rewards.push(current_reward); } + + rewards +} + +/// Builds the [`FeeHistoryCacheItem`] for a single block. +/// +/// Shared by the async [`FeeHistoryService`] and by `eth_feeHistory` itself: the service can lag +/// the chain head (it only runs when the node task is polled), so the RPC handler computes any +/// missing entry on demand with the same logic instead of returning a short response. +pub(crate) fn create_fee_history_cache_item( + hash: B256, + header: &impl BlockHeader, + storage_info: &StorageInfo, + blob_params: BlobParams, +) -> (FeeHistoryCacheItem, Option) +where + N::ReceiptEnvelope: TxReceipt, +{ + let mut block_number: Option = None; + let base_fee = header.base_fee_per_gas().unwrap_or_default(); + let excess_blob_gas = header.excess_blob_gas().map(|g| g as u128); + let blob_gas_used = header.blob_gas_used().map(|g| g as u128); + let base_fee_per_blob_gas = header.blob_fee(blob_params); + + let mut item = FeeHistoryCacheItem { + block_hash: hash, + base_fee: base_fee as u128, + gas_used_ratio: 0f64, + blob_gas_used_ratio: 0f64, + rewards: Vec::new(), + excess_blob_gas, + base_fee_per_blob_gas, + blob_gas_used, + }; + + let current_block = storage_info.block(hash); + let current_receipts = storage_info.receipts(hash); + + if let (Some(block), Some(receipts)) = (current_block, current_receipts) { + block_number = Some(block.header.number()); + + let gas_used = block.header.gas_used() as f64; + let blob_gas_used = block.header.blob_gas_used().map(|g| g as f64); + let gas_limit = block.header.gas_limit() as f64; + item.gas_used_ratio = if gas_limit == 0.0 { 0.0 } else { gas_used / gas_limit }; + item.blob_gas_used_ratio = blob_gas_used + .map(|g| { + let max = blob_params.max_blob_gas_per_block() as f64; + if max == 0.0 { 0.0 } else { g / max } + }) + .unwrap_or(0.0); + + // extract useful tx info (gas_used, effective_reward) + let mut transactions: Vec<(_, _)> = receipts + .iter() + .enumerate() + .map(|(i, receipt)| { + let cumulative = receipt.cumulative_gas_used(); + let prev_cumulative = if i > 0 { receipts[i - 1].cumulative_gas_used() } else { 0 }; + let gas_used = cumulative - prev_cumulative; + let effective_reward = block + .body + .transactions + .get(i) + .map(|tx| tx.as_ref().effective_tip_per_gas(base_fee).unwrap_or(0)) + .unwrap_or(0); + + (gas_used, effective_reward) + }) + .collect(); + + // sort by effective reward asc + transactions.sort_by_key(|(_, reward)| *reward); + + item.rewards = reward_percentiles(&transactions, gas_used); + } else { + item.rewards = vec![0; REWARD_PERCENTILES.len()]; + } + (item, block_number) } // An endless future that listens for new blocks and updates the cache -impl Future for FeeHistoryService { +impl Future for FeeHistoryService +where + N::ReceiptEnvelope: TxReceipt, +{ type Output = (); fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { @@ -348,7 +690,9 @@ impl Future for FeeHistoryService { while let Poll::Ready(Some(notification)) = pin.new_blocks.poll_next_unpin(cx) { // add the imported block. - pin.insert_cache_entry_for_block(notification.hash, notification.header.as_ref()); + if let Some(block) = notification.as_new_block() { + pin.insert_cache_entry_for_block(block.hash, block.header.as_ref()); + } } Poll::Pending @@ -360,6 +704,7 @@ pub type FeeHistoryCache = Arc>>; /// A single item in the whole fee history cache #[derive(Clone, Debug)] pub struct FeeHistoryCacheItem { + pub block_hash: B256, pub base_fee: u128, pub gas_used_ratio: f64, pub base_fee_per_blob_gas: Option, @@ -379,7 +724,7 @@ pub struct FeeDetails { impl FeeDetails { /// All values zero - pub fn zero() -> Self { + pub const fn zero() -> Self { Self { gas_price: Some(0), max_fee_per_gas: Some(0), @@ -389,20 +734,19 @@ impl FeeDetails { } /// If neither `gas_price` nor `max_fee_per_gas` is `Some`, this will set both to `0` - pub fn or_zero_fees(self) -> Self { + pub const fn or_zero_fees(self) -> Self { let Self { gas_price, max_fee_per_gas, max_priority_fee_per_gas, max_fee_per_blob_gas } = self; let no_fees = gas_price.is_none() && max_fee_per_gas.is_none(); let gas_price = if no_fees { Some(0) } else { gas_price }; let max_fee_per_gas = if no_fees { Some(0) } else { max_fee_per_gas }; - let max_fee_per_blob_gas = if no_fees { None } else { max_fee_per_blob_gas }; Self { gas_price, max_fee_per_gas, max_priority_fee_per_gas, max_fee_per_blob_gas } } /// Turns this type into a tuple - pub fn split(self) -> (Option, Option, Option, Option) { + pub const fn split(self) -> (Option, Option, Option, Option) { let Self { gas_price, max_fee_per_gas, max_priority_fee_per_gas, max_fee_per_blob_gas } = self; (gas_price, max_fee_per_gas, max_priority_fee_per_gas, max_fee_per_blob_gas) @@ -416,29 +760,16 @@ impl FeeDetails { max_fee_per_blob_gas: Option, ) -> Result { match (request_gas_price, request_max_fee, request_priority, max_fee_per_blob_gas) { - (gas_price, None, None, None) => { - // Legacy request, all default to gas price. + (Some(_), Some(_), _, _) | (Some(_), _, Some(_), _) => { + Err(BlockchainError::ConflictingFeeFields) + } + (gas_price, None, None, max_fee_per_blob_gas) => { + // Legacy request, all default to gas price. A blob fee cap is priced separately. Ok(Self { gas_price, max_fee_per_gas: gas_price, max_priority_fee_per_gas: gas_price, - max_fee_per_blob_gas: None, - }) - } - (_, max_fee, max_priority, None) => { - // eip-1559 - // Ensure `max_priority_fee_per_gas` is less or equal to `max_fee_per_gas`. - if let Some(max_priority) = max_priority { - let max_fee = max_fee.unwrap_or_default(); - if max_priority > max_fee { - return Err(BlockchainError::InvalidFeeInput); - } - } - Ok(Self { - gas_price: max_fee, - max_fee_per_gas: max_fee, - max_priority_fee_per_gas: max_priority, - max_fee_per_blob_gas: None, + max_fee_per_blob_gas, }) } (_, max_fee, max_priority, max_fee_per_blob_gas) => { @@ -453,7 +784,8 @@ impl FeeDetails { Ok(Self { gas_price: max_fee, max_fee_per_gas: max_fee, - max_priority_fee_per_gas: max_priority, + // A fee cap without a tip pays the base fee only. + max_priority_fee_per_gas: max_priority.or(max_fee.map(|_| 0)), max_fee_per_blob_gas, }) } @@ -471,3 +803,144 @@ impl fmt::Debug for FeeDetails { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn reward_percentiles_reference( + transactions: &[(u64, u128)], + block_gas_used: f64, + ) -> Vec { + REWARD_PERCENTILES + .iter() + .filter_map(|&percentile| { + let target_gas = (percentile * block_gas_used / 100f64) as u64; + let mut cumulative_gas = 0; + for (tx_gas_used, effective_reward) in transactions.iter().copied() { + cumulative_gas += tx_gas_used; + if target_gas <= cumulative_gas { + return Some(effective_reward); + } + } + None + }) + .collect() + } + + fn assert_reward_percentiles_match(transactions: &mut [(u64, u128)], gas_used: u64) { + transactions.sort_by_key(|(_, reward)| *reward); + assert_eq!( + reward_percentiles(transactions, gas_used as f64), + reward_percentiles_reference(transactions, gas_used as f64) + ); + } + + fn fee_manager(spec_id: SpecId) -> FeeManager { + FeeManager::new( + spec_id, + INITIAL_BASE_FEE, + true, + INITIAL_GAS_PRICE, + BlobExcessGasAndPrice::new_with_spec(0, SpecId::CANCUN), + BlobParams::cancun(), + BaseFeeParams::ethereum(), + None, + ) + } + + #[test] + fn raw_next_base_fee_respects_london_activation() { + let berlin = fee_manager(SpecId::BERLIN); + assert_eq!( + berlin.calculate_next_block_base_fee_per_gas(30_000_000, 30_000_000, INITIAL_BASE_FEE), + 0 + ); + + let london = fee_manager(SpecId::LONDON); + assert_ne!( + london.calculate_next_block_base_fee_per_gas(30_000_000, 30_000_000, INITIAL_BASE_FEE), + 0 + ); + } + + #[cfg(feature = "optimism")] + #[test] + fn pre_london_parent_fees_preserve_optimism_metadata() { + let fees = fee_manager(SpecId::BERLIN); + let jovian = [1, 0, 0, 0, 250, 0, 0, 0, 2, 0, 0, 0, 0, 0, 76, 75, 64]; + fees.set_optimism_base_fee_rules(&jovian); + let header = alloy_consensus::Header { extra_data: jovian.into(), ..Default::default() }; + + let parent_fees = fees.get_parent_header_fees(&header); + assert_eq!(parent_fees.base_fee, 0); + assert_eq!(parent_fees.extra_data.as_ref(), jovian); + assert_eq!(parent_fees.optimism_jovian, Some(true)); + } + + #[test] + fn fee_details_default_missing_tip_to_zero() { + let fees = FeeDetails::new(None, Some(5), None, None).unwrap(); + assert_eq!(fees.split(), (Some(5), Some(5), Some(0), None)); + } + + #[test] + fn reward_percentile_sweep_preserves_boundaries_and_empty_results() { + assert_reward_percentiles_match(&mut [], 0); + + let mut transactions = [(5, 1), (0, 2), (5, 3)]; + assert_reward_percentiles_match(&mut transactions, 1_000); + assert_eq!(reward_percentiles(&transactions, 1_000f64), [1, 1, 3]); + + let mut transactions = [(0, 10), (1, 20)]; + assert_reward_percentiles_match(&mut transactions, 1); + let rewards = reward_percentiles(&transactions, 1f64); + assert_eq!(&rewards[..200], &[10; 200]); + assert_eq!(rewards[200], 20); + } + + #[test] + fn reward_percentile_sweep_matches_reference_for_randomized_inputs() { + let mut state = 0x4d59_5df4_d0f3_3173u64; + for _ in 0..2_000 { + let len = (next_random(&mut state) % 129) as usize; + let mut transactions = (0..len) + .map(|_| { + let gas_used = next_random(&mut state) % 100; + let effective_reward = (next_random(&mut state) % 16) as u128; + (gas_used, effective_reward) + }) + .collect::>(); + let total_gas = transactions.iter().map(|(gas_used, _)| gas_used).sum::(); + let header_gas_used = match next_random(&mut state) % 4 { + 0 => total_gas, + 1 => next_random(&mut state) % (total_gas.saturating_add(1)), + 2 => total_gas.saturating_add(next_random(&mut state) % 1_000), + _ => 0, + }; + + assert_reward_percentiles_match(&mut transactions, header_gas_used); + } + } + + fn next_random(state: &mut u64) -> u64 { + *state = state.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1); + *state + } + + #[test] + fn fee_details_reject_gas_price_with_dynamic_fees() { + for (max_fee, priority) in [(Some(2), None), (None, Some(1)), (Some(2), Some(1))] { + assert!(matches!( + FeeDetails::new(Some(2), max_fee, priority, None), + Err(BlockchainError::ConflictingFeeFields) + )); + } + } + + #[test] + fn fee_details_price_legacy_blob_calls_by_gas_price() { + let fees = FeeDetails::new(Some(2), None, None, Some(1)).unwrap(); + assert_eq!(fees.split(), (Some(2), Some(2), Some(2), Some(1))); + } +} diff --git a/crates/anvil/src/eth/fees/optimism.rs b/crates/anvil/src/eth/fees/optimism.rs new file mode 100644 index 0000000000000..5843839756794 --- /dev/null +++ b/crates/anvil/src/eth/fees/optimism.rs @@ -0,0 +1,203 @@ +//! Optimism-specific dynamic EIP-1559 fee rules. + +use alloy_consensus::BlockHeader; +use alloy_eips::{calc_next_block_base_fee, eip1559::BaseFeeParams}; +use alloy_primitives::Bytes; +use foundry_evm::hardfork::OpHardfork; +use op_alloy_consensus::{ + decode_holocene_extra_data, decode_jovian_extra_data, encode_holocene_extra_data, + encode_jovian_extra_data, +}; + +/// Header-derived EIP-1559 rules introduced by the Optimism Holocene and Jovian upgrades. +#[derive(Clone, Copy, Debug)] +pub(super) enum OptimismBaseFeeRules { + Holocene { params: BaseFeeParams }, + Jovian { params: BaseFeeParams, min_base_fee: u64 }, +} + +impl OptimismBaseFeeRules { + pub(super) fn for_hardfork(hardfork: OpHardfork, params: BaseFeeParams) -> Option { + if hardfork >= OpHardfork::Jovian { + Some(Self::Jovian { params, min_base_fee: 0 }) + } else if hardfork >= OpHardfork::Holocene { + Some(Self::Holocene { params }) + } else { + None + } + } + + pub(super) fn decode(extra_data: &[u8]) -> Option { + if let Ok((elasticity, denominator, min_base_fee)) = decode_jovian_extra_data(extra_data) { + return Some(Self::Jovian { + params: BaseFeeParams::new(denominator as u128, elasticity as u128), + min_base_fee, + }); + } + if let Ok((elasticity, denominator)) = decode_holocene_extra_data(extra_data) { + return Some(Self::Holocene { + params: BaseFeeParams::new(denominator as u128, elasticity as u128), + }); + } + None + } + + pub(super) const fn params(self) -> BaseFeeParams { + match self { + Self::Holocene { params, .. } | Self::Jovian { params, .. } => params, + } + } + + pub(super) fn extra_data(self) -> Bytes { + match self { + Self::Holocene { params } => encode_holocene_extra_data([0; 8].into(), params), + Self::Jovian { params, min_base_fee } => { + encode_jovian_extra_data([0; 8].into(), params, min_base_fee) + } + } + .expect("optimism fee parameters fit canonical extra data") + } + + pub(super) const fn is_jovian(self) -> bool { + matches!(self, Self::Jovian { .. }) + } + + pub(super) fn next_block_base_fee(self, header: &H) -> u64 { + let gas_used = match self { + Self::Holocene { .. } => header.gas_used(), + Self::Jovian { .. } => { + header.gas_used().max(header.blob_gas_used().unwrap_or_default()) + } + }; + let next_base_fee = calc_next_block_base_fee( + gas_used, + header.gas_limit(), + header.base_fee_per_gas().unwrap_or_default(), + self.params(), + ); + match self { + Self::Jovian { min_base_fee, .. } => next_base_fee.max(min_base_fee), + Self::Holocene { .. } => next_base_fee, + } + } +} + +#[cfg(test)] +mod tests { + use alloy_consensus::Header; + + use super::*; + + #[test] + fn header_base_fee_rules_match_upstream_blocks() { + struct Case { + extra_data: &'static [u8], + gas_limit: u64, + gas_used: u64, + blob_gas_used: u64, + base_fee: u64, + expected: u64, + } + + // Captured parent/child pairs from Base and OP Mainnet across Holocene and Jovian. + let cases = [ + Case { + extra_data: &[1, 0, 0, 0, 100, 0, 0, 0, 5, 0, 0, 0, 0, 0, 76, 75, 64], + gas_limit: 0x17d7_8400, + gas_used: 0x132_0096, + blob_gas_used: 0x37_5b00, + base_fee: 0x4c_4b40, + expected: 0x4c_4b40, + }, + Case { + extra_data: &[1, 0, 0, 0, 250, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0], + gas_limit: 0x262_5a00, + gas_used: 0xa5_646d, + blob_gas_used: 0x22_cd60, + base_fee: 0x196, + expected: 0x196, + }, + Case { + extra_data: &[0, 0, 0, 0, 50, 0, 0, 0, 2], + gas_limit: 0x858_3b00, + gas_used: 0x311_2ab9, + blob_gas_used: 0, + base_fee: 0xc_bdf6, + expected: 0xc_acae, + }, + Case { + extra_data: &[0, 0, 0, 0, 250, 0, 0, 0, 2], + gas_limit: 0x262_5a00, + gas_used: 0xe6_2170, + blob_gas_used: 0, + base_fee: 0xd87, + expected: 0xd84, + }, + ]; + + for case in cases { + let rules = OptimismBaseFeeRules::decode(case.extra_data).unwrap(); + let header = Header { + gas_limit: case.gas_limit, + gas_used: case.gas_used, + blob_gas_used: Some(case.blob_gas_used), + base_fee_per_gas: Some(case.base_fee), + ..Default::default() + }; + + assert_eq!(rules.next_block_base_fee(&header), case.expected); + assert_eq!(rules.extra_data().as_ref(), case.extra_data); + } + } + + #[test] + fn jovian_base_fee_uses_blob_gas_when_greater() { + let extra_data = [1, 0, 0, 0, 250, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0]; + let rules = OptimismBaseFeeRules::decode(&extra_data).unwrap(); + let header = Header { + gas_limit: 10_000_000_000, + gas_used: 1_000_000_000, + blob_gas_used: Some(5_000_000_000), + base_fee_per_gas: Some(super::super::INITIAL_BASE_FEE), + ..Default::default() + }; + + let expected = calc_next_block_base_fee( + header.blob_gas_used.unwrap(), + header.gas_limit, + header.base_fee_per_gas.unwrap(), + BaseFeeParams::new(250, 2), + ); + assert_eq!(rules.next_block_base_fee(&header), expected); + } + + #[test] + fn supplied_header_overrides_cached_optimism_rules() { + let holocene = [0, 0, 0, 0, 50, 0, 0, 0, 2]; + let jovian = [1, 0, 0, 0, 250, 0, 0, 0, 2, 0, 0, 0, 0, 0, 76, 75, 64]; + let fallback = BaseFeeParams::ethereum(); + + for (cached, supplied) in + [(jovian.as_slice(), holocene.as_slice()), (holocene.as_slice(), jovian.as_slice())] + { + let supplied_rules = OptimismBaseFeeRules::decode(supplied).unwrap(); + let header = Header { + gas_limit: 10_000_000_000, + gas_used: 1_000_000_000, + blob_gas_used: Some(5_000_000_000), + base_fee_per_gas: Some(super::super::INITIAL_BASE_FEE), + extra_data: Bytes::copy_from_slice(supplied), + ..Default::default() + }; + let rules = super::super::BaseFeeRules::Optimism { + inherited: Some(OptimismBaseFeeRules::decode(cached).unwrap()), + fallback, + }; + let parent_fees = rules.parent_header_fees(&header); + + assert_eq!(parent_fees.base_fee, supplied_rules.next_block_base_fee(&header)); + assert_eq!(parent_fees.extra_data.as_ref(), supplied); + assert_eq!(parent_fees.optimism_jovian, Some(supplied_rules.is_jovian())); + } + } +} diff --git a/crates/anvil/src/eth/miner.rs b/crates/anvil/src/eth/miner.rs index f2cf842b7d3b9..5eedc8e3d6a1e 100644 --- a/crates/anvil/src/eth/miner.rs +++ b/crates/anvil/src/eth/miner.rs @@ -10,48 +10,62 @@ use futures::{ use parking_lot::{RawRwLock, RwLock, lock_api::RwLockWriteGuard}; use std::{ fmt, - sync::Arc, - task::{Context, Poll, ready}, + marker::PhantomData, + pin::Pin, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, + task::{Context, Poll}, time::Duration, }; -use tokio::time::{Interval, MissedTickBehavior}; +use tokio::time::{Interval, MissedTickBehavior, Sleep}; -#[derive(Clone, Debug)] -pub struct Miner { +/// Window for grouping concurrently-submitted transactions into one instant-mined block. +/// Scoped to batch/in-process concurrency; not a guarantee for independent external clients. +pub(crate) const INSTANT_COALESCE_WINDOW: Duration = Duration::from_millis(5); + +pub struct Miner { /// The mode this miner currently operates in mode: Arc>, + /// Identifies the current mode so stale candidate failures cannot modify its replacement. + generation: Arc, /// used for task wake up when the mining mode was forcefully changed /// /// This will register the task so we can manually wake it up if the mining mode was changed inner: Arc, - /// Transactions included into the pool before any others are. - /// Done once on startup. - force_transactions: Option>>, + /// Transaction type handled by the associated pool. + transaction: PhantomData T>, +} + +impl Clone for Miner { + fn clone(&self) -> Self { + Self { + mode: self.mode.clone(), + generation: self.generation.clone(), + inner: self.inner.clone(), + transaction: PhantomData, + } + } +} + +impl fmt::Debug for Miner { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Miner").field("mode", &self.mode).finish_non_exhaustive() + } } -impl Miner { +impl Miner { /// Returns a new miner with that operates in the given `mode`. pub fn new(mode: MiningMode) -> Self { Self { mode: Arc::new(RwLock::new(mode)), + generation: Default::default(), inner: Default::default(), - force_transactions: None, + transaction: PhantomData, } } - /// Provide transactions that will cause a block to be mined with transactions - /// as soon as the miner is polled. - /// Providing an empty list of transactions will cause the miner to mine an empty block assuming - /// there are not other transactions in the pool. - pub fn with_forced_transactions( - mut self, - force_transactions: Option>, - ) -> Self { - self.force_transactions = - force_transactions.map(|tx| tx.into_iter().map(Arc::new).collect()); - self - } - /// Returns the write lock of the mining mode pub fn mode_write(&self) -> RwLockWriteGuard<'_, RawRwLock, MiningMode> { self.mode.write() @@ -71,34 +85,84 @@ impl Miner { None } + /// Returns the configured block interval for fixed or mixed mining. + pub(crate) fn block_interval(&self) -> Option { + let mode = self.mode.read(); + match &*mode { + MiningMode::FixedBlockTime(miner) | MiningMode::Mixed(_, miner) => { + Some(miner.interval.period()) + } + MiningMode::None | MiningMode::Auto(_) => None, + } + } + /// Sets the mining mode to operate in pub fn set_mining_mode(&self, mode: MiningMode) { let new_mode = format!("{mode:?}"); - let mode = std::mem::replace(&mut *self.mode_write(), mode); + let mut current = self.mode_write(); + let mode = std::mem::replace(&mut *current, mode); + self.generation.fetch_add(1, Ordering::Relaxed); + drop(current); trace!(target: "miner", "updated mining mode from {:?} to {}", mode, new_mode); self.inner.wake(); } + /// Resets the mode that launched a failed candidate. + pub(crate) fn handle_failed_candidate(&self, generation: u64) { + let mut mode = self.mode.write(); + if self.generation.load(Ordering::Relaxed) != generation { + if let MiningMode::Auto(miner) | MiningMode::Mixed(miner, _) = &mut *mode { + miner.has_pending_txs = Some(true); + miner.coalesce = None; + } + return; + } + // Selecting the candidate already cleared `has_pending_txs`, so keep any retry requested + // since then, e.g. by a block mined via `evm_mine` before this failure was processed. + if let MiningMode::Auto(miner) | MiningMode::Mixed(miner, _) = &mut *mode { + miner.coalesce = None; + } + match &mut *mode { + MiningMode::FixedBlockTime(miner) | MiningMode::Mixed(_, miner) => { + let period = miner.interval.period(); + *miner = FixedBlockTimeMiner::new(period); + } + MiningMode::None | MiningMode::Auto(_) => {} + } + } + /// polls the [Pool] and returns those transactions that should be put in a block according to /// the current mode. /// /// May return an empty list, if no transactions are ready. - pub fn poll( + pub(crate) fn poll( &mut self, - pool: &Arc, + pool: &Arc>, cx: &mut Context<'_>, - ) -> Poll>> { + ) -> Poll> { self.inner.register(cx); - let next = ready!(self.mode.write().poll(pool, cx)); - if let Some(mut transactions) = self.force_transactions.take() { - transactions.extend(next); - Poll::Ready(transactions) - } else { - Poll::Ready(next) + let mut mode = self.mode.write(); + let generation = self.generation.load(Ordering::Relaxed); + mode.poll(pool, cx).map(|transactions| MiningWork { transactions, generation }) + } + + /// Makes the instant miner select ready transactions again without waiting for a new one. + /// + /// Used when a mined block left ready transactions behind, e.g. because it ran out of gas. + pub(crate) fn retry_ready_transactions(&self) { + if let MiningMode::Auto(miner) | MiningMode::Mixed(miner, _) = &mut *self.mode.write() { + miner.has_pending_txs = Some(true); + self.inner.wake(); } } } +/// Transactions selected by a specific mining mode generation. +pub(crate) struct MiningWork { + pub(crate) transactions: Vec>>, + pub(crate) generation: u64, +} + /// A Mining mode that does nothing #[derive(Debug)] pub struct MinerInner { @@ -145,6 +209,8 @@ impl MiningMode { max_transactions, has_pending_txs: None, rx: listener.fuse(), + coalesce: None, + coalesce_window: INSTANT_COALESCE_WINDOW, }) } @@ -154,17 +220,34 @@ impl MiningMode { pub fn mixed(max_transactions: usize, listener: Receiver, duration: Duration) -> Self { Self::Mixed( - ReadyTransactionMiner { max_transactions, has_pending_txs: None, rx: listener.fuse() }, + ReadyTransactionMiner { + max_transactions, + has_pending_txs: None, + rx: listener.fuse(), + coalesce: None, + coalesce_window: INSTANT_COALESCE_WINDOW, + }, FixedBlockTimeMiner::new(duration), ) } + /// Sets the window for grouping ready transactions in auto or mixed mining mode. + /// A zero window mines ready transactions without a coalescing timer. + #[must_use] + pub fn with_coalescing_window(mut self, window: Duration) -> Self { + if let Self::Auto(miner) | Self::Mixed(miner, _) = &mut self { + miner.coalesce_window = window; + miner.coalesce = None; + } + self + } + /// polls the [Pool] and returns those transactions that should be put in a block, if any. - pub fn poll( + pub fn poll( &mut self, - pool: &Arc, + pool: &Arc>, cx: &mut Context<'_>, - ) -> Poll>> { + ) -> Poll>>> { match self { Self::None => Poll::Pending, Self::Auto(miner) => miner.poll(pool, cx), @@ -217,7 +300,11 @@ impl FixedBlockTimeMiner { Self { interval } } - fn poll(&mut self, pool: &Arc, cx: &mut Context<'_>) -> Poll>> { + fn poll( + &mut self, + pool: &Arc>, + cx: &mut Context<'_>, + ) -> Poll>>> { if self.interval.poll_tick(cx).is_ready() { // drain the pool return Poll::Ready(pool.ready_transactions().collect()); @@ -240,24 +327,50 @@ pub struct ReadyTransactionMiner { has_pending_txs: Option, /// Receives hashes of transactions that are ready rx: Fuse>, + /// Delay for accumulating ready transactions; zero disables coalescing. + coalesce_window: Duration, + /// Active coalescing timer; while pending, ready txs are accumulated. + coalesce: Option>>, } impl ReadyTransactionMiner { - fn poll(&mut self, pool: &Arc, cx: &mut Context<'_>) -> Poll>> { + fn poll( + &mut self, + pool: &Arc>, + cx: &mut Context<'_>, + ) -> Poll>>> { // always drain the notification stream so that we're woken up as soon as there's a new tx + let mut saw_new_ready = false; while let Poll::Ready(Some(_hash)) = self.rx.poll_next_unpin(cx) { + saw_new_ready = true; + } + + // Arm the coalescing window only on fresh notifications to avoid delaying + // consecutive chunks when draining a backlog larger than `max_transactions`. + if saw_new_ready { self.has_pending_txs = Some(true); + if self.coalesce.is_none() && !self.coalesce_window.is_zero() { + self.coalesce = Some(Box::pin(tokio::time::sleep(self.coalesce_window))); + } } if self.has_pending_txs == Some(false) { return Poll::Pending; } + if let Some(sleep) = self.coalesce.as_mut() + && sleep.as_mut().poll(cx).is_pending() + { + return Poll::Pending; + } + self.coalesce = None; + let transactions = pool.ready_transactions().take(self.max_transactions).collect::>(); - // there are pending transactions if we didn't drain the pool - self.has_pending_txs = Some(transactions.len() >= self.max_transactions); + // Whether ready txs left behind are worth another block is decided once this block is + // mined, see `Miner::retry_ready_transactions`. + self.has_pending_txs = Some(false); if transactions.is_empty() { return Poll::Pending; @@ -274,3 +387,80 @@ impl fmt::Debug for ReadyTransactionMiner { .finish_non_exhaustive() } } + +#[cfg(test)] +mod tests { + use super::*; + use futures::{channel::mpsc, future::poll_fn}; + + #[test] + fn stale_failure_resumes_replacement_autominer() { + let (_tx, rx) = mpsc::channel(1); + let miner = Miner::<()>::new(MiningMode::None); + miner.set_mining_mode(MiningMode::instant(1, rx)); + + miner.handle_failed_candidate(0); + + let mode = miner.mode.read(); + let MiningMode::Auto(auto) = &*mode else { panic!("expected auto mining") }; + assert_eq!(auto.has_pending_txs, Some(true)); + } + + #[test] + fn failure_keeps_retry_requested_after_candidate_selection() { + let (_tx, rx) = mpsc::channel(1); + let miner = Miner::<()>::new(MiningMode::instant(1, rx)); + miner.retry_ready_transactions(); + + miner.handle_failed_candidate(0); + + let mode = miner.mode.read(); + let MiningMode::Auto(auto) = &*mode else { panic!("expected auto mining") }; + assert_eq!(auto.has_pending_txs, Some(true)); + } + + #[tokio::test] + async fn failed_fixed_candidate_rearms_interval() { + let mut miner = Miner::<()>::new(MiningMode::interval(Duration::from_millis(10))); + let pool = Arc::new(Pool::default()); + tokio::time::timeout(Duration::from_secs(1), poll_fn(|cx| miner.poll(&pool, cx))) + .await + .unwrap(); + + miner.handle_failed_candidate(0); + + tokio::time::timeout(Duration::from_secs(1), poll_fn(|cx| miner.poll(&pool, cx))) + .await + .unwrap(); + } + + #[tokio::test] + async fn coalescing_window_controls_fresh_notifications() { + for mixed in [false, true] { + for window in [Duration::ZERO, Duration::from_secs(60)] { + let (mut tx, rx) = mpsc::channel(1); + let mode = if mixed { + MiningMode::mixed(1, rx, Duration::from_secs(120)) + } else { + MiningMode::instant(1, rx) + }; + let mut mode = mode.with_coalescing_window(window); + let pool = Arc::new(Pool::<()>::default()); + tx.try_send(TxHash::ZERO).unwrap(); + let mut cx = Context::from_waker(futures::task::noop_waker_ref()); + assert!(mode.poll(&pool, &mut cx).is_pending()); + let auto = match &mode { + MiningMode::Auto(auto) | MiningMode::Mixed(auto, _) => auto, + _ => unreachable!(), + }; + if window.is_zero() { + assert!(auto.coalesce.is_none()); + assert_eq!(auto.has_pending_txs, Some(false)); + } else { + assert!(auto.coalesce.is_some()); + assert_eq!(auto.has_pending_txs, Some(true)); + } + } + } + } +} diff --git a/crates/anvil/src/eth/mod.rs b/crates/anvil/src/eth/mod.rs index 393a9ff213306..16f73eaeefa13 100644 --- a/crates/anvil/src/eth/mod.rs +++ b/crates/anvil/src/eth/mod.rs @@ -1,14 +1,37 @@ -pub mod api; -pub mod otterscan; -pub mod sign; -pub use api::EthApi; +use alloy_rpc_types::{ + TransactionRequest, + simulate::{SimBlock, SimulatePayload}, +}; +use alloy_serde::WithOtherFields; +pub mod api; pub mod backend; - pub mod error; - pub mod fees; pub(crate) mod macros; pub mod miner; +pub mod otterscan; pub mod pool; +pub mod sign; pub mod util; + +pub use api::EthApi; + +pub(crate) fn preserve_simulation_request_fields( + request: SimulatePayload, +) -> SimulatePayload> { + SimulatePayload { + block_state_calls: request + .block_state_calls + .into_iter() + .map(|block| SimBlock { + block_overrides: block.block_overrides, + state_overrides: block.state_overrides, + calls: block.calls.into_iter().map(WithOtherFields::new).collect(), + }) + .collect(), + trace_transfers: request.trace_transfers, + validation: request.validation, + return_full_transactions: request.return_full_transactions, + } +} diff --git a/crates/anvil/src/eth/otterscan/api.rs b/crates/anvil/src/eth/otterscan/api.rs index 135375d01f4a3..c9334b30ee028 100644 --- a/crates/anvil/src/eth/otterscan/api.rs +++ b/crates/anvil/src/eth/otterscan/api.rs @@ -3,10 +3,10 @@ use crate::eth::{ error::{BlockchainError, Result}, macros::node_info, }; -use alloy_consensus::Transaction as TransactionTrait; +use alloy_consensus::{BlockHeader, Transaction as TransactionTrait}; use alloy_network::{ AnyHeader, AnyRpcBlock, AnyRpcHeader, AnyRpcTransaction, AnyTxEnvelope, BlockResponse, - TransactionResponse, + ReceiptResponse, TransactionResponse, }; use alloy_primitives::{Address, B256, Bytes, U256}; use alloy_rpc_types::{ @@ -19,10 +19,11 @@ use alloy_rpc_types::{ parity::{Action, CreateAction, CreateOutput, TraceOutput}, }, }; +use foundry_primitives::FoundryNetwork; use futures::future::join_all; use itertools::Itertools; -impl EthApi { +impl EthApi { /// Otterscan currently requires this endpoint, even though it's not part of the `ots_*`. /// Ref: /// @@ -32,7 +33,7 @@ impl EthApi { &self, number: BlockNumber, ) -> Result> { - node_info!("ots_getApiLevel"); + node_info!("erigon_getHeaderByNumber"); self.backend.block_by_number(number).await } @@ -74,6 +75,7 @@ impl EthApi { .backend .trace_transaction(hash) .await? + .unwrap_or_default() .into_iter() .filter_map(|trace| TraceEntry::from_transaction_trace(&trace.trace)) .collect(); @@ -85,9 +87,9 @@ impl EthApi { node_info!("ots_getTransactionError"); if let Some(receipt) = self.backend.mined_transaction_receipt(hash) - && !receipt.inner.inner.as_receipt_with_bloom().receipt.status.coerce_status() + && !receipt.inner.as_ref().status() { - return Ok(receipt.out.map(|b| b.0.into()).unwrap_or(Bytes::default())); + return Ok(receipt.out.unwrap_or_default()); } Ok(Bytes::default()) @@ -154,9 +156,12 @@ impl EthApi { let best = self.backend.best_number(); // we go from given block (defaulting to best) down to first block - // considering only post-fork + // considering only post-fork (or post-genesis in non-fork mode) let from = if block_number == 0 { best } else { block_number - 1 }; - let to = self.get_fork().map(|f| f.block_number() + 1).unwrap_or(1); + let to = self + .get_fork() + .map(|f| f.block_number() + 1) + .unwrap_or_else(|| self.backend.genesis_number() + 1); let first_page = from >= best; let mut last_page = false; @@ -197,8 +202,11 @@ impl EthApi { node_info!("ots_searchTransactionsAfter"); let best = self.backend.best_number(); - // we go from the first post-fork block, up to the tip - let first_block = self.get_fork().map(|f| f.block_number() + 1).unwrap_or(1); + // we go from the first post-fork (or post-genesis) block, up to the tip + let first_block = self + .get_fork() + .map(|f| f.block_number() + 1) + .unwrap_or_else(|| self.backend.genesis_number() + 1); let from = if block_number == 0 { first_block } else { block_number + 1 }; let to = best; @@ -247,7 +255,10 @@ impl EthApi { ) -> Result> { node_info!("ots_getTransactionBySenderAndNonce"); - let from = self.get_fork().map(|f| f.block_number() + 1).unwrap_or_default(); + let from = self + .get_fork() + .map(|f| f.block_number() + 1) + .unwrap_or_else(|| self.backend.genesis_number() + 1); let to = self.backend.best_number(); for n in (from..=to).rev() { @@ -332,9 +343,9 @@ impl EthApi { }) .collect::>>()?; - let total_fees = receipts - .iter() - .fold(0, |acc, receipt| acc + (receipt.gas_used as u128) * receipt.effective_gas_price); + let total_fees = receipts.iter().fold(0, |acc, receipt| { + acc + (receipt.gas_used() as u128) * receipt.effective_gas_price() + }); let Block { header, uncles, transactions, withdrawals } = block.into_inner(); @@ -363,6 +374,9 @@ impl EthApi { return Err(BlockchainError::DataUnavailable); } + // Preserve the total transaction count before pagination. + let transaction_count = block.transactions().len(); + block.transactions = match block.transactions() { BlockTransactions::Full(txs) => BlockTransactions::Full( txs.iter().skip(page * page_size).take(page_size).cloned().collect(), @@ -375,11 +389,12 @@ impl EthApi { let receipt_futs = block.transactions.hashes().map(|hash| self.transaction_receipt(hash)); - let receipts = join_all(receipt_futs.map(|r| async { + // Reuse timestamp from the block we already have + let timestamp = block.header.timestamp(); + + let receipts = join_all(receipt_futs.map(|r| async move { if let Ok(Some(r)) = r.await { - let block = self.block_by_number(r.block_number.unwrap().into()).await?; - let timestamp = block.ok_or(BlockchainError::BlockNotFound)?.header.timestamp; - let receipt = r.map_inner(OtsReceipt::from); + let receipt = r.as_ref().inner.clone().map_inner(OtsReceipt::from); Ok(OtsTransactionReceipt { receipt, timestamp: Some(timestamp) }) } else { Err(BlockchainError::BlockNotFound) @@ -389,7 +404,6 @@ impl EthApi { .into_iter() .collect::>>()?; - let transaction_count = block.transactions().len(); let fullblock = OtsBlock { block: block.inner.clone(), transaction_count }; let ots_block_txs = OtsBlockTransactions { fullblock, receipts }; @@ -418,9 +432,15 @@ impl EthApi { let receipts = join_all(receipt_futs.map(|r| async { if let Ok(Some(r)) = r.await { - let block = self.block_by_number(r.block_number.unwrap().into()).await?; - let timestamp = block.ok_or(BlockchainError::BlockNotFound)?.header.timestamp; - let receipt = r.map_inner(OtsReceipt::from); + // Try to get timestamp from receipt's other fields first (set by mined receipts), + // fallback to block lookup for fork receipts that may not have it + let timestamp = if let Some(ts) = r.block_timestamp() { + ts + } else { + let block = self.block_by_number(r.block_number().unwrap().into()).await?; + block.ok_or(BlockchainError::BlockNotFound)?.header.timestamp() + }; + let receipt = r.as_ref().inner.clone().map_inner(OtsReceipt::from); Ok(OtsTransactionReceipt { receipt, timestamp: Some(timestamp) }) } else { Err(BlockchainError::BlockNotFound) diff --git a/crates/anvil/src/eth/pool/mod.rs b/crates/anvil/src/eth/pool/mod.rs index 2fa26621ff4b8..aac1c044cfee2 100644 --- a/crates/anvil/src/eth/pool/mod.rs +++ b/crates/anvil/src/eth/pool/mod.rs @@ -36,6 +36,7 @@ use crate::{ }, mem::storage::MinedBlockOutcome, }; +use alloy_consensus::Transaction; use alloy_primitives::{Address, TxHash}; use alloy_rpc_types::txpool::TxpoolStatus; use anvil_core::eth::transaction::PendingTransaction; @@ -43,85 +44,82 @@ use futures::channel::mpsc::{Receiver, Sender, channel}; use parking_lot::{Mutex, RwLock}; use std::{collections::VecDeque, fmt, sync::Arc}; +#[cfg(feature = "base")] +use alloy_consensus::Typed2718; + pub mod transactions; /// Transaction pool that performs validation. -#[derive(Default)] -pub struct Pool { +pub struct Pool { /// processes all pending transactions - inner: RwLock, + inner: RwLock>, /// listeners for new ready transactions transaction_listener: Mutex>>, } +/// An independent snapshot of a transaction pool. +#[derive(Debug)] +pub(crate) struct PoolSnapshot { + inner: PoolInner, +} + +impl Default for Pool { + fn default() -> Self { + Self { inner: RwLock::new(PoolInner::default()), transaction_listener: Default::default() } + } +} + // == impl Pool == -impl Pool { +impl Pool { + /// Returns an independent snapshot of the pool. + pub(crate) fn snapshot(&self) -> PoolSnapshot { + let pool = self.inner.read(); + PoolSnapshot { + inner: PoolInner { + ready_transactions: pool.ready_transactions.snapshot(), + pending_transactions: pool.pending_transactions.snapshot(), + }, + } + } + + /// Restores the pool to a previous snapshot. + pub(crate) fn restore(&self, snapshot: PoolSnapshot) { + let ready = { + let mut pool = self.inner.write(); + *pool = snapshot.inner; + pool.ready_transactions().map(|tx| tx.hash()).collect::>() + }; + for hash in ready { + self.notify_listener(hash); + } + } + /// Returns an iterator that yields all transactions that are currently ready - pub fn ready_transactions(&self) -> TransactionsIterator { + pub fn ready_transactions(&self) -> TransactionsIterator { self.inner.read().ready_transactions() } /// Returns all transactions that are not ready to be included in a block yet - pub fn pending_transactions(&self) -> Vec> { + pub fn pending_transactions(&self) -> Vec>> { self.inner.read().pending_transactions.transactions().collect() } - /// Returns the _pending_ transaction for that `hash` if it exists in the mempool - pub fn get_transaction(&self, hash: TxHash) -> Option { - self.inner.read().get_transaction(hash) + /// Returns every ready and queued transaction. + #[cfg(feature = "base")] + pub fn all_transactions(&self) -> Vec>> { + let pool = self.inner.read(); + pool.pending_transactions.transactions().chain(pool.ready_transactions()).collect() } /// Returns the number of tx that are ready and queued for further execution pub fn txpool_status(&self) -> TxpoolStatus { // Note: naming differs here compared to geth's `TxpoolStatus` - let pending: u64 = self.ready_transactions().count().try_into().unwrap_or(0); + let pending: u64 = self.inner.read().ready_transactions.len().try_into().unwrap_or(0); let queued: u64 = self.inner.read().pending_transactions.len().try_into().unwrap_or(0); TxpoolStatus { pending, queued } } - /// Invoked when a set of transactions ([Self::ready_transactions()]) was executed. - /// - /// This will remove the transactions from the pool. - pub fn on_mined_block(&self, outcome: MinedBlockOutcome) -> PruneResult { - let MinedBlockOutcome { block_number, included, invalid } = outcome; - - // remove invalid transactions from the pool - self.remove_invalid(invalid.into_iter().map(|tx| tx.hash()).collect()); - - // prune all the markers the mined transactions provide - let res = self - .prune_markers(block_number, included.into_iter().flat_map(|tx| tx.provides.clone())); - trace!(target: "txpool", "pruned transaction markers {:?}", res); - res - } - - /// Removes ready transactions for the given iterator of identifying markers. - /// - /// For each marker we can remove transactions in the pool that either provide the marker - /// directly or are a dependency of the transaction associated with that marker. - pub fn prune_markers( - &self, - block_number: u64, - markers: impl IntoIterator, - ) -> PruneResult { - debug!(target: "txpool", ?block_number, "pruning transactions"); - self.inner.write().prune_markers(markers) - } - - /// Adds a new transaction to the pool - pub fn add_transaction(&self, tx: PoolTransaction) -> Result { - let added = self.inner.write().add_transaction(tx)?; - if let AddedTransaction::Ready(ref ready) = added { - self.notify_listener(ready.hash); - // also notify promoted transactions - for promoted in ready.promoted.iter().copied() { - self.notify_listener(promoted); - } - } - Ok(added) - } - /// Adds a new transaction listener to the pool that gets notified about every new ready /// transaction pub fn add_ready_listener(&self) -> Receiver { @@ -136,13 +134,40 @@ impl Pool { self.inner.read().contains(tx_hash) } + /// Returns true if this pool contains a transaction from `sender` with `nonce`. + pub fn contains_sender_nonce(&self, sender: Address, nonce: u64) -> bool + where + T: Transaction, + { + self.inner + .read() + .transactions_by_sender(sender) + .any(|tx| tx.pending_transaction.nonce() == nonce) + } + + /// Returns a transaction from `sender` that provides exactly `markers`. + #[cfg(feature = "base")] + pub fn transaction_with_markers( + &self, + sender: Address, + markers: &[TxMarker], + ) -> Option>> { + self.inner.read().transactions_by_sender(sender).find(|tx| tx.provides == markers) + } + + /// Removes all transactions from the pool + pub fn clear(&self) { + let mut pool = self.inner.write(); + pool.clear(); + } + /// Remove the given transactions from the pool - pub fn remove_invalid(&self, tx_hashes: Vec) -> Vec> { + pub fn remove_invalid(&self, tx_hashes: Vec) -> Vec>> { self.inner.write().remove_invalid(tx_hashes) } /// Remove transactions by sender - pub fn remove_transactions_by_address(&self, sender: Address) -> Vec> { + pub fn remove_transactions_by_address(&self, sender: Address) -> Vec>> { self.inner.write().remove_transactions_by_address(sender) } @@ -151,25 +176,32 @@ impl Pool { /// This is similar to `[Pool::remove_invalid()]` but for a single transaction. /// /// **Note**: this will also drop any transaction that depend on the `tx` - pub fn drop_transaction(&self, tx: TxHash) -> Option> { + pub fn drop_transaction(&self, tx: TxHash) -> Option>> { trace!(target: "txpool", "Dropping transaction: [{:?}]", tx); let removed = { let mut pool = self.inner.write(); - pool.ready_transactions.remove_with_markers(vec![tx], None) + let mut removed = pool.ready_transactions.remove_with_markers(vec![tx], None); + let invalidated = removed.iter().flat_map(|tx| tx.provides.iter().cloned()); + removed.extend(pool.pending_transactions.remove_with_dependents(vec![tx], invalidated)); + removed }; - trace!(target: "txpool", "Dropped transactions: {:?}", removed); + trace!(target: "txpool", "Dropped transactions: {:?}", removed.iter().map(|tx| tx.hash()).collect::>()); - let mut dropped = None; - if !removed.is_empty() { - dropped = removed.into_iter().find(|t| *t.pending_transaction.hash() == tx); + if removed.is_empty() { + None + } else { + removed.into_iter().find(|t| *t.pending_transaction.hash() == tx) } - dropped } - /// Removes all transactions from the pool - pub fn clear(&self) { - let mut pool = self.inner.write(); - pool.clear(); + /// Notifies listeners if the transaction was added to the ready queue. + fn notify_ready(&self, tx: &AddedTransaction) { + if let AddedTransaction::Ready(ready) = tx { + self.notify_listener(ready.hash); + for promoted in ready.promoted.iter().copied() { + self.notify_listener(promoted); + } + } } /// notifies all listeners about the transaction @@ -200,20 +232,121 @@ impl Pool { } } +impl Pool { + /// Returns the _pending_ transaction for that `hash` if it exists in the mempool + pub fn get_transaction(&self, hash: TxHash) -> Option> { + self.inner.read().get_transaction(hash) + } +} + +impl Pool { + /// Invoked when a set of transactions ([Self::ready_transactions()]) was executed. + /// + /// This will remove the transactions from the pool. + /// + /// Returns `true` if ready transactions left behind by the block can be included by mining + /// again right away, e.g. because the block hit `max_transactions` or ran out of gas. + pub fn on_mined_block(self: &Arc, outcome: MinedBlockOutcome) -> bool { + let MinedBlockOutcome { block_number, included, stale, invalid, not_yet_valid } = outcome; + // Requiring txs to leave the pool keeps this retry from mining empty blocks for txs that + // can never be included. Not-yet-valid txs and their dependents are retried by the delayed + // re-notify. + let made_progress = !included.is_empty() || !stale.is_empty() || !invalid.is_empty(); + let retry_ready = made_progress && not_yet_valid.is_empty(); + + // remove invalid transactions from the pool + self.remove_invalid(invalid.into_iter().map(|tx| tx.hash()).collect()); + + // Prune mined and stale markers; both are satisfied by the resulting state. + let res = self.prune_markers( + block_number, + included.into_iter().chain(stale).flat_map(|tx| tx.provides.clone()), + ); + trace!(target: "txpool", "pruned transaction markers {:?}", res); + + // Re-notify the miner about not-yet-valid transactions so they'll be retried. + // Delay by 1 second to let time advance before the next mining attempt. + if !not_yet_valid.is_empty() { + let tx_hashes: Vec<_> = not_yet_valid.iter().map(|tx| tx.hash()).collect(); + let pool = Arc::clone(self); + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + for hash in tx_hashes { + trace!(target: "txpool", "re-notifying for not-yet-valid tx: {:?}", hash); + pool.notify_listener(hash); + } + }); + } + + retry_ready && !self.inner.read().ready_transactions.is_empty() + } + + /// Removes ready transactions for the given iterator of identifying markers. + /// + /// For each marker we can remove transactions in the pool that either provide the marker + /// directly or are a dependency of the transaction associated with that marker. + pub fn prune_markers( + &self, + block_number: u64, + markers: impl IntoIterator, + ) -> PruneResult { + debug!(target: "txpool", ?block_number, "pruning transactions"); + let res = self.inner.write().prune_markers(markers); + for tx in &res.promoted { + self.notify_ready(tx); + } + res + } + + /// Adds a new transaction to the pool + pub fn add_transaction( + &self, + tx: PoolTransaction, + ) -> Result, PoolError> { + let added = self.inner.write().add_transaction(tx)?; + self.notify_ready(&added); + Ok(added) + } +} + +#[cfg(feature = "base")] +impl Pool { + /// Removes every transaction with the given EIP-2718 type. + pub fn clear_transaction_type(&self, tx_type: u8) -> Vec>> { + let hashes = { + let pool = self.inner.read(); + pool.pending_transactions + .transactions() + .chain(pool.ready_transactions()) + .filter_map(|tx| { + (tx.pending_transaction.transaction.ty() == tx_type).then_some(tx.hash()) + }) + .collect() + }; + self.remove_invalid(hashes) + } +} + /// A Transaction Pool /// /// Contains all transactions that are ready to be executed -#[derive(Debug, Default)] -struct PoolInner { - ready_transactions: ReadyTransactions, - pending_transactions: PendingTransactions, +#[derive(Debug)] +struct PoolInner { + ready_transactions: ReadyTransactions, + pending_transactions: PendingTransactions, +} + +impl Default for PoolInner { + fn default() -> Self { + Self { ready_transactions: Default::default(), pending_transactions: Default::default() } + } } // == impl PoolInner == -impl PoolInner { +impl PoolInner { /// Returns an iterator over transactions that are ready. - fn ready_transactions(&self) -> TransactionsIterator { + fn ready_transactions(&self) -> TransactionsIterator { self.ready_transactions.get_transactions() } @@ -223,23 +356,11 @@ impl PoolInner { self.pending_transactions.clear(); } - /// checks both pools for the matching transaction - /// - /// Returns `None` if the transaction does not exist in the pool - fn get_transaction(&self, hash: TxHash) -> Option { - if let Some(pending) = self.pending_transactions.get(&hash) { - return Some(pending.transaction.pending_transaction.clone()); - } - Some( - self.ready_transactions.get(&hash)?.transaction.transaction.pending_transaction.clone(), - ) - } - /// Returns an iterator over all transactions in the pool filtered by the sender pub fn transactions_by_sender( &self, sender: Address, - ) -> impl Iterator> + '_ { + ) -> impl Iterator>> + '_ { let pending_txs = self .pending_transactions .transactions() @@ -258,14 +379,68 @@ impl PoolInner { self.pending_transactions.contains(tx_hash) || self.ready_transactions.contains(tx_hash) } - fn add_transaction(&mut self, tx: PoolTransaction) -> Result { + /// Remove the given transactions from the pool + fn remove_invalid(&mut self, tx_hashes: Vec) -> Vec>> { + // early exit in case there is no invalid transactions. + if tx_hashes.is_empty() { + return vec![]; + } + trace!(target: "txpool", "Removing invalid transactions: {:?}", tx_hashes); + + let mut removed = self.ready_transactions.remove_with_markers(tx_hashes.clone(), None); + removed.extend(self.pending_transactions.remove(tx_hashes)); + + trace!(target: "txpool", "Removed invalid transactions: {:?}", removed.iter().map(|tx| tx.hash()).collect::>()); + + removed + } + + /// Remove transactions by sender address + fn remove_transactions_by_address(&mut self, sender: Address) -> Vec>> { + let tx_hashes = + self.transactions_by_sender(sender).map(move |tx| tx.hash()).collect::>(); + + if tx_hashes.is_empty() { + return vec![]; + } + + trace!(target: "txpool", "Removing transactions: {:?}", tx_hashes); + + let mut removed = self.ready_transactions.remove_with_markers(tx_hashes.clone(), None); + removed.extend(self.pending_transactions.remove(tx_hashes)); + + trace!(target: "txpool", "Removed transactions: {:?}", removed.iter().map(|tx| tx.hash()).collect::>()); + + removed + } +} + +impl PoolInner { + /// checks both pools for the matching transaction + /// + /// Returns `None` if the transaction does not exist in the pool + fn get_transaction(&self, hash: TxHash) -> Option> { + if let Some(pending) = self.pending_transactions.get(&hash) { + return Some(pending.transaction.pending_transaction.clone()); + } + Some( + self.ready_transactions.get(&hash)?.transaction.transaction.pending_transaction.clone(), + ) + } +} + +impl PoolInner { + fn add_transaction( + &mut self, + tx: PoolTransaction, + ) -> Result, PoolError> { if self.contains(&tx.hash()) { - warn!(target: "txpool", "[{:?}] Already imported", tx.hash()); - return Err(PoolError::AlreadyImported(Box::new(tx))); + debug!(target: "txpool", "[{:?}] Already imported", tx.hash()); + return Err(PoolError::AlreadyImported(tx.hash())); } let tx = PendingPoolTransaction::new(tx, self.ready_transactions.provided_markers()); - trace!(target: "txpool", "[{:?}] {:?}", tx.transaction.hash(), tx); + trace!(target: "txpool", "[{:?}] ready={}", tx.transaction.hash(), tx.is_ready()); // If all markers are not satisfied import to future if !tx.is_ready() { @@ -279,8 +454,8 @@ impl PoolInner { /// Adds the transaction to the ready queue fn add_ready_transaction( &mut self, - tx: PendingPoolTransaction, - ) -> Result { + tx: PendingPoolTransaction, + ) -> Result, PoolError> { let hash = tx.transaction.hash(); trace!(target: "txpool", "adding ready transaction [{:?}]", hash); let mut ready = ReadyTransaction::new(hash); @@ -312,9 +487,8 @@ impl PoolInner { debug!(target: "txpool", "[{:?}] Failed to add tx: {:?}", current_hash, err); return Err(err); - } else { - ready.discarded.push(current_hash); } + ready.discarded.push(current_hash); } } is_new_tx = false; @@ -335,7 +509,7 @@ impl PoolInner { /// /// This will effectively remove those transactions that satisfy the markers and transactions /// from the pending queue might get promoted to if the markers unlock them. - pub fn prune_markers(&mut self, markers: impl IntoIterator) -> PruneResult { + pub fn prune_markers(&mut self, markers: impl IntoIterator) -> PruneResult { let mut imports = vec![]; let mut pruned = vec![]; @@ -361,54 +535,19 @@ impl PoolInner { PruneResult { pruned, failed, promoted } } - - /// Remove the given transactions from the pool - pub fn remove_invalid(&mut self, tx_hashes: Vec) -> Vec> { - // early exit in case there is no invalid transactions. - if tx_hashes.is_empty() { - return vec![]; - } - trace!(target: "txpool", "Removing invalid transactions: {:?}", tx_hashes); - - let mut removed = self.ready_transactions.remove_with_markers(tx_hashes.clone(), None); - removed.extend(self.pending_transactions.remove(tx_hashes)); - - trace!(target: "txpool", "Removed invalid transactions: {:?}", removed); - - removed - } - - /// Remove transactions by sender address - pub fn remove_transactions_by_address(&mut self, sender: Address) -> Vec> { - let tx_hashes = - self.transactions_by_sender(sender).map(move |tx| tx.hash()).collect::>(); - - if tx_hashes.is_empty() { - return vec![]; - } - - trace!(target: "txpool", "Removing transactions: {:?}", tx_hashes); - - let mut removed = self.ready_transactions.remove_with_markers(tx_hashes.clone(), None); - removed.extend(self.pending_transactions.remove(tx_hashes)); - - trace!(target: "txpool", "Removed transactions: {:?}", removed); - - removed - } } /// Represents the outcome of a prune -pub struct PruneResult { +pub struct PruneResult { /// a list of added transactions that a pruned marker satisfied - pub promoted: Vec, + pub promoted: Vec>, /// all transactions that failed to be promoted and now are discarded pub failed: Vec, /// all transactions that were pruned from the ready pool - pub pruned: Vec>, + pub pruned: Vec>>, } -impl fmt::Debug for PruneResult { +impl fmt::Debug for PruneResult { fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { write!(fmt, "PruneResult {{ ")?; write!( @@ -428,7 +567,7 @@ impl fmt::Debug for PruneResult { } #[derive(Clone, Debug)] -pub struct ReadyTransaction { +pub struct ReadyTransaction { /// the hash of the submitted transaction hash: TxHash, /// transactions promoted to the ready queue @@ -436,10 +575,10 @@ pub struct ReadyTransaction { /// transaction that failed and became discarded discarded: Vec, /// Transactions removed from the Ready pool - removed: Vec>, + removed: Vec>>, } -impl ReadyTransaction { +impl ReadyTransaction { pub fn new(hash: TxHash) -> Self { Self { hash, @@ -451,9 +590,9 @@ impl ReadyTransaction { } #[derive(Clone, Debug)] -pub enum AddedTransaction { +pub enum AddedTransaction { /// transaction was successfully added and being processed - Ready(ReadyTransaction), + Ready(ReadyTransaction), /// Transaction was successfully added but not yet queued for processing Pending { /// the hash of the submitted transaction @@ -461,8 +600,8 @@ pub enum AddedTransaction { }, } -impl AddedTransaction { - pub fn hash(&self) -> &TxHash { +impl AddedTransaction { + pub const fn hash(&self) -> &TxHash { match self { Self::Ready(tx) => &tx.hash, Self::Pending { hash } => hash, diff --git a/crates/anvil/src/eth/pool/transactions.rs b/crates/anvil/src/eth/pool/transactions.rs index 90063aadb5195..67d383ea8be48 100644 --- a/crates/anvil/src/eth/pool/transactions.rs +++ b/crates/anvil/src/eth/pool/transactions.rs @@ -1,23 +1,25 @@ use crate::eth::{error::PoolError, util::hex_fmt_many}; +use alloy_consensus::{ + Transaction, Typed2718, + crypto::RecoveryError, + transaction::{SignerRecoverable, TxHashRef}, +}; use alloy_network::AnyRpcTransaction; use alloy_primitives::{ Address, TxHash, map::{HashMap, HashSet}, }; -use anvil_core::eth::transaction::{PendingTransaction, TypedTransaction}; +use alloy_rlp::Encodable; +use anvil_core::eth::transaction::PendingTransaction; use parking_lot::RwLock; use std::{cmp::Ordering, collections::BTreeSet, fmt, str::FromStr, sync::Arc, time::Instant}; /// A unique identifying marker for a transaction pub type TxMarker = Vec; -/// creates an unique identifier for aan (`nonce` + `Address`) combo -pub fn to_marker(nonce: u64, from: Address) -> TxMarker { - let mut data = [0u8; 28]; - data[..8].copy_from_slice(&nonce.to_le_bytes()[..]); - data[8..].copy_from_slice(&from.0[..]); - data.to_vec() -} +/// Result type for replaced transactions: the replaced pool transactions and the hashes they +/// unlock. +type ReplacedTransactions = (Vec>>, Vec); /// Modes that determine the transaction ordering of the mempool /// @@ -36,10 +38,10 @@ pub enum TransactionOrder { impl TransactionOrder { /// Returns the priority of the transactions - pub fn priority(&self, tx: &TypedTransaction) -> TransactionPriority { + pub fn priority(&self, tx: &T) -> TransactionPriority { match self { Self::Fifo => TransactionPriority::default(), - Self::Fees => TransactionPriority(tx.gas_price()), + Self::Fees => TransactionPriority(tx.max_fee_per_gas()), } } } @@ -67,85 +69,123 @@ pub struct TransactionPriority(pub u128); /// Internal Transaction type #[derive(Clone, PartialEq, Eq)] -pub struct PoolTransaction { +pub struct PoolTransaction { /// the pending eth transaction - pub pending_transaction: PendingTransaction, + pub pending_transaction: PendingTransaction, /// Markers required by the transaction pub requires: Vec, /// Markers that this transaction provides pub provides: Vec, /// priority of the transaction pub priority: TransactionPriority, + /// Whether this transaction is being replayed from chain history. + pub is_replay: bool, } // == impl PoolTransaction == -impl PoolTransaction { - pub fn new(transaction: PendingTransaction) -> Self { +impl PoolTransaction { + pub const fn new(transaction: PendingTransaction) -> Self { Self { pending_transaction: transaction, requires: vec![], provides: vec![], priority: TransactionPriority(0), + is_replay: false, } } + + /// Marks this transaction as a historical replay. + pub const fn with_replay(mut self) -> Self { + self.is_replay = true; + self + } + /// Returns the hash of this transaction - pub fn hash(&self) -> TxHash { + pub const fn hash(&self) -> TxHash { *self.pending_transaction.hash() } +} - /// Returns the gas pric of this transaction - pub fn gas_price(&self) -> u128 { - self.pending_transaction.transaction.gas_price() +impl PoolTransaction { + /// Returns the max fee per gas of this transaction + pub fn max_fee_per_gas(&self) -> u128 { + self.pending_transaction.transaction.max_fee_per_gas() } +} +impl PoolTransaction { /// Returns the type of the transaction pub fn tx_type(&self) -> u8 { - self.pending_transaction.transaction.r#type().unwrap_or_default() + self.pending_transaction.transaction.ty() } } -impl fmt::Debug for PoolTransaction { +impl fmt::Debug for PoolTransaction { fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { write!(fmt, "Transaction {{ ")?; - write!(fmt, "hash: {:?}, ", &self.pending_transaction.hash())?; + write!(fmt, "hash: {:?}, ", self.pending_transaction.hash())?; write!(fmt, "requires: [{}], ", hex_fmt_many(self.requires.iter()))?; write!(fmt, "provides: [{}], ", hex_fmt_many(self.provides.iter()))?; - write!(fmt, "raw tx: {:?}", &self.pending_transaction)?; + write!(fmt, "raw tx: {:?}", self.pending_transaction)?; write!(fmt, "}}")?; Ok(()) } } -impl TryFrom for PoolTransaction { +impl TryFrom for PoolTransaction +where + T: SignerRecoverable + TxHashRef + Encodable + TryFrom, + >::Error: Into, + RecoveryError: Into, +{ type Error = eyre::Error; fn try_from(value: AnyRpcTransaction) -> Result { - let typed_transaction = TypedTransaction::try_from(value)?; + let typed_transaction = T::try_from(value).map_err(Into::into)?; let pending_transaction = PendingTransaction::new(typed_transaction)?; Ok(Self { pending_transaction, requires: vec![], provides: vec![], priority: TransactionPriority(0), + is_replay: false, }) } } + /// A waiting pool of transaction that are pending, but not yet ready to be included in a new block. /// /// Keeps a set of transactions that are waiting for other transactions -#[derive(Clone, Debug, Default)] -pub struct PendingTransactions { +#[derive(Clone, Debug)] +pub struct PendingTransactions { /// markers that aren't yet provided by any transaction required_markers: HashMap>, /// mapping of the markers of a transaction to the hash of the transaction waiting_markers: HashMap, TxHash>, /// the transactions that are not ready yet are waiting for another tx to finish - waiting_queue: HashMap, + waiting_queue: HashMap>, } -// == impl PendingTransactions == +impl Default for PendingTransactions { + fn default() -> Self { + Self { + required_markers: Default::default(), + waiting_markers: Default::default(), + waiting_queue: Default::default(), + } + } +} + +impl PendingTransactions { + /// Returns an independent snapshot of the pending transactions. + pub(super) fn snapshot(&self) -> Self { + Self { + required_markers: self.required_markers.clone(), + waiting_markers: self.waiting_markers.clone(), + waiting_queue: self.waiting_queue.clone(), + } + } -impl PendingTransactions { /// Returns the number of transactions that are currently waiting pub fn len(&self) -> usize { self.waiting_queue.len() @@ -163,52 +203,17 @@ impl PendingTransactions { } /// Returns an iterator over all transactions in the waiting pool - pub fn transactions(&self) -> impl Iterator> + '_ { + pub fn transactions(&self) -> impl Iterator>> + '_ { self.waiting_queue.values().map(|tx| tx.transaction.clone()) } - /// Adds a transaction to Pending queue of transactions - pub fn add_transaction(&mut self, tx: PendingPoolTransaction) -> Result<(), PoolError> { - assert!(!tx.is_ready(), "transaction must not be ready"); - assert!( - !self.waiting_queue.contains_key(&tx.transaction.hash()), - "transaction is already added" - ); - - if let Some(replace) = self - .waiting_markers - .get(&tx.transaction.provides) - .and_then(|hash| self.waiting_queue.get(hash)) - { - // check if underpriced - if tx.transaction.gas_price() < replace.transaction.gas_price() { - warn!(target: "txpool", "pending replacement transaction underpriced [{:?}]", tx.transaction.hash()); - return Err(PoolError::ReplacementUnderpriced(Box::new( - tx.transaction.as_ref().clone(), - ))); - } - } - - // add all missing markers - for marker in &tx.missing_markers { - self.required_markers.entry(marker.clone()).or_default().insert(tx.transaction.hash()); - } - - // also track identifying markers - self.waiting_markers.insert(tx.transaction.provides.clone(), tx.transaction.hash()); - // add tx to the queue - self.waiting_queue.insert(tx.transaction.hash(), tx); - - Ok(()) - } - /// Returns true if given transaction is part of the queue pub fn contains(&self, hash: &TxHash) -> bool { self.waiting_queue.contains_key(hash) } /// Returns the transaction for the hash if it's pending - pub fn get(&self, hash: &TxHash) -> Option<&PendingPoolTransaction> { + pub fn get(&self, hash: &TxHash) -> Option<&PendingPoolTransaction> { self.waiting_queue.get(hash) } @@ -219,7 +224,7 @@ impl PendingTransactions { pub fn mark_and_unlock( &mut self, markers: impl IntoIterator>, - ) -> Vec { + ) -> Vec> { let mut unlocked_ready = Vec::new(); for mark in markers { let mark = mark.as_ref(); @@ -244,7 +249,7 @@ impl PendingTransactions { /// Removes the transactions associated with the given hashes /// /// Returns all removed transactions. - pub fn remove(&mut self, hashes: Vec) -> Vec> { + pub fn remove(&mut self, hashes: Vec) -> Vec>> { let mut removed = vec![]; for hash in hashes { if let Some(waiting_tx) = self.waiting_queue.remove(&hash) { @@ -265,26 +270,113 @@ impl PendingTransactions { } removed } + + /// Removes transactions and their transitive dependents from the waiting pool. + pub fn remove_with_dependents( + &mut self, + hashes: Vec, + invalidated: impl IntoIterator, + ) -> Vec>> { + let mut required_by = HashMap::>::default(); + for (hash, tx) in &self.waiting_queue { + for marker in &tx.transaction.requires { + required_by.entry(marker.clone()).or_default().push(*hash); + } + } + + let mut to_remove = HashSet::::default(); + let mut markers = invalidated.into_iter().collect::>(); + for hash in hashes { + if to_remove.insert(hash) + && let Some(tx) = self.waiting_queue.get(&hash) + { + markers.extend(tx.transaction.provides.iter().cloned()); + } + } + + while let Some(marker) = markers.pop() { + if let Some(dependents) = required_by.remove(&marker) { + for hash in dependents { + if to_remove.insert(hash) + && let Some(tx) = self.waiting_queue.get(&hash) + { + markers.extend(tx.transaction.provides.iter().cloned()); + } + } + } + } + + self.remove(to_remove.into_iter().collect()) + } +} + +impl PendingTransactions { + /// Adds a transaction to Pending queue of transactions + pub fn add_transaction(&mut self, tx: PendingPoolTransaction) -> Result<(), PoolError> { + assert!(!tx.is_ready(), "transaction must not be ready"); + assert!( + !self.waiting_queue.contains_key(&tx.transaction.hash()), + "transaction is already added" + ); + + let replaced_hash = if let Some(replace) = self + .waiting_markers + .get(&tx.transaction.provides) + .and_then(|hash| self.waiting_queue.get(hash)) + { + // check if underpriced + if tx.transaction.max_fee_per_gas() <= replace.transaction.max_fee_per_gas() { + warn!(target: "txpool", "pending replacement transaction underpriced [{:?}]", tx.transaction.hash()); + return Err(PoolError::ReplacementUnderpriced(tx.transaction.hash())); + } + Some(replace.transaction.hash()) + } else { + None + }; + // Remove old markers before inserting the replacement, which shares their keys. + if let Some(replaced_hash) = replaced_hash { + self.remove(vec![replaced_hash]); + } + + // add all missing markers + for marker in &tx.missing_markers { + self.required_markers.entry(marker.clone()).or_default().insert(tx.transaction.hash()); + } + + // also track identifying markers + self.waiting_markers.insert(tx.transaction.provides.clone(), tx.transaction.hash()); + // add tx to the queue + self.waiting_queue.insert(tx.transaction.hash(), tx); + + Ok(()) + } } /// A transaction in the pool -#[derive(Clone)] -pub struct PendingPoolTransaction { - pub transaction: Arc, +pub struct PendingPoolTransaction { + pub transaction: Arc>, /// markers required and have not been satisfied yet by other transactions in the pool pub missing_markers: HashSet, /// timestamp when the tx was added pub added_at: Instant, } -// == impl PendingTransaction == +impl Clone for PendingPoolTransaction { + fn clone(&self) -> Self { + Self { + transaction: Arc::clone(&self.transaction), + missing_markers: self.missing_markers.clone(), + added_at: self.added_at, + } + } +} -impl PendingPoolTransaction { - /// Creates a new `PendingTransaction`. +impl PendingPoolTransaction { + /// Creates a new `PendingPoolTransaction`. /// /// Determines the markers that are still missing before this transaction can be moved to the /// ready queue. - pub fn new(transaction: PoolTransaction, provided: &HashMap) -> Self { + pub fn new(transaction: PoolTransaction, provided: &HashMap) -> Self { let missing_markers = transaction .requires .iter() @@ -309,7 +401,7 @@ impl PendingPoolTransaction { } } -impl fmt::Debug for PendingPoolTransaction { +impl fmt::Debug for PendingPoolTransaction { fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { write!(fmt, "PendingTransaction {{ ")?; write!(fmt, "added_at: {:?}, ", self.added_at)?; @@ -319,19 +411,17 @@ impl fmt::Debug for PendingPoolTransaction { } } -pub struct TransactionsIterator { - all: HashMap, - awaiting: HashMap, - independent: BTreeSet, +pub struct TransactionsIterator { + all: HashMap>, + awaiting: HashMap)>, + independent: BTreeSet>, _invalid: HashSet, } -// == impl TransactionsIterator == - -impl TransactionsIterator { +impl TransactionsIterator { /// Depending on number of satisfied requirements insert given ref /// either to awaiting set or to best set. - fn independent_or_awaiting(&mut self, satisfied: usize, tx_ref: PoolTransactionRef) { + fn independent_or_awaiting(&mut self, satisfied: usize, tx_ref: PoolTransactionRef) { if satisfied >= tx_ref.transaction.requires.len() { // If we have satisfied all deps insert to best self.independent.insert(tx_ref); @@ -342,8 +432,8 @@ impl TransactionsIterator { } } -impl Iterator for TransactionsIterator { - type Item = Arc; +impl Iterator for TransactionsIterator { + type Item = Arc>; fn next(&mut self) -> Option { loop { @@ -377,8 +467,8 @@ impl Iterator for TransactionsIterator { } /// transactions that are ready to be included in a block. -#[derive(Clone, Debug, Default)] -pub struct ReadyTransactions { +#[derive(Clone, Debug)] +pub struct ReadyTransactions { /// keeps track of transactions inserted in the pool /// /// this way we can determine when transactions where submitted to the pool @@ -386,17 +476,36 @@ pub struct ReadyTransactions { /// markers that are provided by `ReadyTransaction`s provided_markers: HashMap, /// transactions that are ready - ready_tx: Arc>>, + ready_tx: Arc>>>, /// independent transactions that can be included directly and don't require other transactions /// Sorted by their id - independent_transactions: BTreeSet, + independent_transactions: BTreeSet>, +} + +impl Default for ReadyTransactions { + fn default() -> Self { + Self { + id: 0, + provided_markers: Default::default(), + ready_tx: Default::default(), + independent_transactions: Default::default(), + } + } } -// == impl ReadyTransactions == +impl ReadyTransactions { + /// Returns an independent snapshot of the ready transactions. + pub(super) fn snapshot(&self) -> Self { + Self { + id: self.id, + provided_markers: self.provided_markers.clone(), + ready_tx: Arc::new(RwLock::new(self.ready_tx.read().clone())), + independent_transactions: self.independent_transactions.clone(), + } + } -impl ReadyTransactions { /// Returns an iterator over all transactions - pub fn get_transactions(&self) -> TransactionsIterator { + pub fn get_transactions(&self) -> TransactionsIterator { TransactionsIterator { all: self.ready_tx.read().clone(), independent: self.independent_transactions.clone(), @@ -417,125 +526,34 @@ impl ReadyTransactions { self.ready_tx.read().contains_key(hash) } + /// Returns the number of ready transactions without cloning the snapshot + pub fn len(&self) -> usize { + self.ready_tx.read().len() + } + + /// Returns true if there are no ready transactions + pub fn is_empty(&self) -> bool { + self.ready_tx.read().is_empty() + } + /// Returns the transaction for the hash if it's in the ready pool but not yet mined - pub fn get(&self, hash: &TxHash) -> Option { + pub fn get(&self, hash: &TxHash) -> Option> { self.ready_tx.read().get(hash).cloned() } - pub fn provided_markers(&self) -> &HashMap { + pub const fn provided_markers(&self) -> &HashMap { &self.provided_markers } - fn next_id(&mut self) -> u64 { + const fn next_id(&mut self) -> u64 { let id = self.id; self.id = self.id.wrapping_add(1); id } - /// Adds a new transactions to the ready queue. - /// - /// # Panics - /// - /// If the pending transaction is not ready ([`PendingPoolTransaction::is_ready`]) - /// or the transaction is already included. - pub fn add_transaction( - &mut self, - tx: PendingPoolTransaction, - ) -> Result>, PoolError> { - assert!(tx.is_ready(), "transaction must be ready",); - assert!( - !self.ready_tx.read().contains_key(&tx.transaction.hash()), - "transaction already included" - ); - - let (replaced_tx, unlocks) = self.replaced_transactions(&tx.transaction)?; - - let id = self.next_id(); - let hash = tx.transaction.hash(); - - let mut independent = true; - let mut requires_offset = 0; - let mut ready = self.ready_tx.write(); - // Add links to transactions that unlock the current one - for mark in &tx.transaction.requires { - // Check if the transaction that satisfies the mark is still in the queue. - if let Some(other) = self.provided_markers.get(mark) { - let tx = ready.get_mut(other).expect("hash included;"); - tx.unlocks.push(hash); - // tx still depends on other tx - independent = false; - } else { - requires_offset += 1; - } - } - - // update markers - for mark in tx.transaction.provides.iter().cloned() { - self.provided_markers.insert(mark, hash); - } - - let transaction = PoolTransactionRef { id, transaction: tx.transaction }; - - // add to the independent set - if independent { - self.independent_transactions.insert(transaction.clone()); - } - - // insert to ready queue - ready.insert(hash, ReadyTransaction { transaction, unlocks, requires_offset }); - - Ok(replaced_tx) - } - - /// Removes and returns those transactions that got replaced by the `tx` - fn replaced_transactions( - &mut self, - tx: &PoolTransaction, - ) -> Result<(Vec>, Vec), PoolError> { - // check if we are replacing transactions - let remove_hashes: HashSet<_> = - tx.provides.iter().filter_map(|mark| self.provided_markers.get(mark)).collect(); - - // early exit if we are not replacing anything. - if remove_hashes.is_empty() { - return Ok((Vec::new(), Vec::new())); - } - - // check if we're replacing the same transaction and if it can be replaced - - let mut unlocked_tx = Vec::new(); - { - // construct a list of unlocked transactions - // also check for transactions that shouldn't be replaced because underpriced - let ready = self.ready_tx.read(); - for to_remove in remove_hashes.iter().filter_map(|hash| ready.get(*hash)) { - // if we're attempting to replace a transaction that provides the exact same markers - // (addr + nonce) then we check for gas price - if to_remove.provides() == tx.provides { - // check if underpriced - if tx.pending_transaction.transaction.gas_price() <= to_remove.gas_price() { - warn!(target: "txpool", "ready replacement transaction underpriced [{:?}]", tx.hash()); - return Err(PoolError::ReplacementUnderpriced(Box::new(tx.clone()))); - } else { - trace!(target: "txpool", "replacing ready transaction [{:?}] with higher gas price [{:?}]", to_remove.transaction.transaction.hash(), tx.hash()); - } - } - - unlocked_tx.extend(to_remove.unlocks.iter().copied()) - } - } - - let remove_hashes = remove_hashes.into_iter().copied().collect::>(); - - let new_provides = tx.provides.iter().cloned().collect::>(); - let removed_tx = self.remove_with_markers(remove_hashes, Some(new_provides)); - - Ok((removed_tx, unlocked_tx)) - } - /// Removes the transactions from the ready queue and returns the removed transactions. /// This will also remove all transactions that depend on those. - pub fn clear_transactions(&mut self, tx_hashes: &[TxHash]) -> Vec> { + pub fn clear_transactions(&mut self, tx_hashes: &[TxHash]) -> Vec>> { self.remove_with_markers(tx_hashes.to_vec(), None) } @@ -543,7 +561,7 @@ impl ReadyTransactions { /// /// This will also remove all transactions that lead to the transaction that provides the /// marker. - pub fn prune_tags(&mut self, marker: TxMarker) -> Vec> { + pub fn prune_tags(&mut self, marker: TxMarker) -> Vec>> { let mut removed_tx = vec![]; // the markers to remove @@ -572,11 +590,7 @@ impl ReadyTransactions { if let Some(idx) = tx2.unlocks.iter().position(|i| i == &hash) { tx2.unlocks.swap_remove(idx); } - if tx2.unlocks.is_empty() { - Some(tx2.transaction.transaction.provides.clone()) - } else { - None - } + tx2.unlocks.is_empty().then(|| tx2.transaction.transaction.provides.clone()) }; // find previous transactions @@ -620,14 +634,14 @@ impl ReadyTransactions { &mut self, mut tx_hashes: Vec, marker_filter: Option>, - ) -> Vec> { + ) -> Vec>> { let mut removed = Vec::new(); let mut ready = self.ready_tx.write(); while let Some(hash) = tx_hashes.pop() { if let Some(mut tx) = ready.remove(&hash) { let invalidated = tx.transaction.transaction.provides.iter().filter(|mark| { - marker_filter.as_ref().map(|filter| !filter.contains(&**mark)).unwrap_or(true) + marker_filter.as_ref().is_none_or(|filter| !filter.contains(&**mark)) }); let mut removed_some_marks = false; @@ -639,11 +653,11 @@ impl ReadyTransactions { // remove from unlocks for mark in &tx.transaction.transaction.requires { - if let Some(hash) = self.provided_markers.get(mark) - && let Some(tx) = ready.get_mut(hash) - && let Some(idx) = tx.unlocks.iter().position(|i| i == hash) + if let Some(provider_hash) = self.provided_markers.get(mark) + && let Some(provider_tx) = ready.get_mut(provider_hash) + && let Some(idx) = provider_tx.unlocks.iter().position(|i| i == &hash) { - tx.unlocks.swap_remove(idx); + provider_tx.unlocks.swap_remove(idx); } } @@ -664,30 +678,139 @@ impl ReadyTransactions { } } +impl ReadyTransactions { + /// Adds a new transactions to the ready queue. + /// + /// # Panics + /// + /// If the pending transaction is not ready ([`PendingPoolTransaction::is_ready`]) + /// or the transaction is already included. + pub fn add_transaction( + &mut self, + tx: PendingPoolTransaction, + ) -> Result>>, PoolError> { + assert!(tx.is_ready(), "transaction must be ready",); + assert!( + !self.ready_tx.read().contains_key(&tx.transaction.hash()), + "transaction already included" + ); + + let (replaced_tx, unlocks) = self.replaced_transactions(&tx.transaction)?; + + let id = self.next_id(); + let hash = tx.transaction.hash(); + + let mut independent = true; + let mut requires_offset = 0; + let mut ready = self.ready_tx.write(); + // Add links to transactions that unlock the current one + for mark in &tx.transaction.requires { + // Check if the transaction that satisfies the mark is still in the queue. + if let Some(other) = self.provided_markers.get(mark) { + let tx = ready.get_mut(other).expect("hash included;"); + tx.unlocks.push(hash); + // tx still depends on other tx + independent = false; + } else { + requires_offset += 1; + } + } + + // update markers + for mark in tx.transaction.provides.iter().cloned() { + self.provided_markers.insert(mark, hash); + } + + let transaction = PoolTransactionRef { id, transaction: tx.transaction }; + + // add to the independent set + if independent { + self.independent_transactions.insert(transaction.clone()); + } + + // insert to ready queue + ready.insert(hash, ReadyTransaction { transaction, unlocks, requires_offset }); + + Ok(replaced_tx) + } + + /// Removes and returns those transactions that got replaced by the `tx` + fn replaced_transactions( + &mut self, + tx: &PoolTransaction, + ) -> Result, PoolError> { + // check if we are replacing transactions + let remove_hashes: HashSet<_> = + tx.provides.iter().filter_map(|mark| self.provided_markers.get(mark)).collect(); + + // early exit if we are not replacing anything. + if remove_hashes.is_empty() { + return Ok((Vec::new(), Vec::new())); + } + + // check if we're replacing the same transaction and if it can be replaced + let mut unlocked_tx = Vec::new(); + { + // construct a list of unlocked transactions + // also check for transactions that shouldn't be replaced because underpriced + let ready = self.ready_tx.read(); + for to_remove in remove_hashes.iter().filter_map(|hash| ready.get(*hash)) { + // if we're attempting to replace a transaction that provides the exact same markers + // (addr + nonce) then we check for gas price + if to_remove.provides() == tx.provides { + // check if underpriced + if tx.pending_transaction.transaction.max_fee_per_gas() + <= to_remove.max_fee_per_gas() + { + warn!(target: "txpool", "ready replacement transaction underpriced [{:?}]", tx.hash()); + return Err(PoolError::ReplacementUnderpriced(tx.hash())); + } + trace!(target: "txpool", "replacing ready transaction [{:?}] with higher gas price [{:?}]", to_remove.transaction.transaction.hash(), tx.hash()); + } + + unlocked_tx.extend(to_remove.unlocks.iter().copied()) + } + } + + let remove_hashes = remove_hashes.into_iter().copied().collect::>(); + + let new_provides = tx.provides.iter().cloned().collect::>(); + let removed_tx = self.remove_with_markers(remove_hashes, Some(new_provides)); + + Ok((removed_tx, unlocked_tx)) + } +} + /// A reference to a transaction in the pool -#[derive(Clone, Debug)] -pub struct PoolTransactionRef { +#[derive(Debug)] +pub struct PoolTransactionRef { /// actual transaction - pub transaction: Arc, + pub transaction: Arc>, /// identifier used to internally compare the transaction in the pool pub id: u64, } -impl Eq for PoolTransactionRef {} +impl Clone for PoolTransactionRef { + fn clone(&self) -> Self { + Self { transaction: Arc::clone(&self.transaction), id: self.id } + } +} + +impl Eq for PoolTransactionRef {} -impl PartialEq for PoolTransactionRef { +impl PartialEq for PoolTransactionRef { fn eq(&self, other: &Self) -> bool { self.cmp(other) == Ordering::Equal } } -impl PartialOrd for PoolTransactionRef { +impl PartialOrd for PoolTransactionRef { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } -impl Ord for PoolTransactionRef { +impl Ord for PoolTransactionRef { fn cmp(&self, other: &Self) -> Ordering { self.transaction .priority @@ -696,26 +819,46 @@ impl Ord for PoolTransactionRef { } } -#[derive(Clone, Debug)] -pub struct ReadyTransaction { +#[derive(Debug)] +pub struct ReadyTransaction { /// ref to the actual transaction - pub transaction: PoolTransactionRef, + pub transaction: PoolTransactionRef, /// tracks the transactions that get unlocked by this transaction pub unlocks: Vec, /// amount of required markers that are inherently provided pub requires_offset: usize, } -impl ReadyTransaction { +impl Clone for ReadyTransaction { + fn clone(&self) -> Self { + Self { + transaction: self.transaction.clone(), + unlocks: self.unlocks.clone(), + requires_offset: self.requires_offset, + } + } +} + +impl ReadyTransaction { pub fn provides(&self) -> &[TxMarker] { &self.transaction.transaction.provides } +} - pub fn gas_price(&self) -> u128 { - self.transaction.transaction.gas_price() +impl ReadyTransaction { + pub fn max_fee_per_gas(&self) -> u128 { + self.transaction.transaction.max_fee_per_gas() } } +/// creates an unique identifier for aan (`nonce` + `Address`) combo +pub fn to_marker(nonce: u64, from: Address) -> TxMarker { + let mut data = [0u8; 28]; + data[..8].copy_from_slice(&nonce.to_le_bytes()[..]); + data[8..].copy_from_slice(&from.0[..]); + data.to_vec() +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/anvil/src/eth/sign.rs b/crates/anvil/src/eth/sign.rs index 57f273953c620..476169c3a24cf 100644 --- a/crates/anvil/src/eth/sign.rs +++ b/crates/anvil/src/eth/sign.rs @@ -1,15 +1,15 @@ use crate::eth::error::BlockchainError; use alloy_consensus::SignableTransaction; use alloy_dyn_abi::TypedData; -use alloy_network::TxSignerSync; +use alloy_network::{Network, TxSignerSync}; use alloy_primitives::{Address, B256, Signature, map::AddressHashMap}; use alloy_signer::Signer as AlloySigner; use alloy_signer_local::PrivateKeySigner; -use anvil_core::eth::transaction::{TypedTransaction, TypedTransactionRequest}; +use foundry_primitives::{FoundryTxEnvelope, FoundryTypedTx}; -/// A transaction signer +/// Network-agnostic signing: messages, typed data, and hashes. #[async_trait::async_trait] -pub trait Signer: Send + Sync { +pub trait MessageSigner: Send + Sync { /// returns the available accounts for this signer fn accounts(&self) -> Vec
; @@ -31,13 +31,22 @@ pub trait Signer: Send + Sync { /// Signs the given hash. async fn sign_hash(&self, address: Address, hash: B256) -> Result; +} - /// signs a transaction request using the given account in request - fn sign_transaction( +/// A transaction signer, generic over the network. +/// +/// Modelled after alloy's `NetworkWallet`: the +/// [`sign_transaction_from`](Signer::sign_transaction_from) method takes an +/// unsigned transaction and returns the fully-signed envelope in one step. +pub trait Signer: MessageSigner { + /// Signs an unsigned transaction and returns the signed envelope. + /// + /// Mirrors `NetworkWallet::sign_transaction_from`. + fn sign_transaction_from( &self, - request: TypedTransactionRequest, - address: &Address, - ) -> Result; + sender: &Address, + tx: N::UnsignedTx, + ) -> Result; } /// Maintains developer keys @@ -55,7 +64,7 @@ impl DevSigner { } #[async_trait::async_trait] -impl Signer for DevSigner { +impl MessageSigner for DevSigner { fn accounts(&self) -> Vec
{ self.addresses.clone() } @@ -90,51 +99,86 @@ impl Signer for DevSigner { Ok(signer.sign_hash(&hash).await?) } +} - fn sign_transaction( +impl Signer for DevSigner { + fn sign_transaction_from( &self, - request: TypedTransactionRequest, - address: &Address, - ) -> Result { - let signer = self.accounts.get(address).ok_or(BlockchainError::NoSignerAvailable)?; - match request { - TypedTransactionRequest::Legacy(mut tx) => Ok(signer.sign_transaction_sync(&mut tx)?), - TypedTransactionRequest::EIP2930(mut tx) => Ok(signer.sign_transaction_sync(&mut tx)?), - TypedTransactionRequest::EIP1559(mut tx) => Ok(signer.sign_transaction_sync(&mut tx)?), - TypedTransactionRequest::EIP7702(mut tx) => Ok(signer.sign_transaction_sync(&mut tx)?), - TypedTransactionRequest::EIP4844(mut tx) => Ok(signer.sign_transaction_sync(&mut tx)?), - TypedTransactionRequest::Deposit(_) => { + sender: &Address, + tx: FoundryTypedTx, + ) -> Result { + let mut signer = + self.accounts.get(sender).ok_or(BlockchainError::NoSignerAvailable)?.clone(); + // The transaction is authoritative for its chain ID. Developer wallets are created with + // the node's initial chain ID, which can later change through a reset or + // `anvil_setChainId`. + signer.set_chain_id(None); + let envelope = match tx { + FoundryTypedTx::Legacy(mut t) => { + let sig = signer.sign_transaction_sync(&mut t)?; + FoundryTxEnvelope::Legacy(t.into_signed(sig)) + } + FoundryTypedTx::Eip2930(mut t) => { + let sig = signer.sign_transaction_sync(&mut t)?; + FoundryTxEnvelope::Eip2930(t.into_signed(sig)) + } + FoundryTypedTx::Celo(mut t) => { + let sig = signer.sign_transaction_sync(&mut t)?; + FoundryTxEnvelope::Celo(t.into_signed(sig)) + } + FoundryTypedTx::Eip1559(mut t) => { + let sig = signer.sign_transaction_sync(&mut t)?; + FoundryTxEnvelope::Eip1559(t.into_signed(sig)) + } + FoundryTypedTx::Eip7702(mut t) => { + let sig = signer.sign_transaction_sync(&mut t)?; + FoundryTxEnvelope::Eip7702(t.into_signed(sig)) + } + FoundryTypedTx::Eip4844(mut t) => { + let sig = signer.sign_transaction_sync(&mut t)?; + FoundryTxEnvelope::Eip4844(t.into_signed(sig)) + } + #[cfg(any(feature = "base", feature = "optimism"))] + FoundryTypedTx::Deposit(_) => { unreachable!("op deposit txs should not be signed") } - } + #[cfg(feature = "optimism")] + FoundryTypedTx::PostExec(_) => { + unreachable!("op post-exec txs should not be signed") + } + #[cfg(feature = "base")] + FoundryTypedTx::Eip8130(_) => { + unreachable!("EIP-8130 requires a signed raw transaction envelope") + } + FoundryTypedTx::Tempo(mut t) => { + let sig = signer.sign_transaction_sync(&mut t)?; + FoundryTxEnvelope::Tempo(t.into_signed(sig.into())) + } + }; + Ok(envelope) } } -/// converts the `request` into a [`TypedTransactionRequest`] with the given signature -/// -/// # Errors -/// -/// This will fail if the `signature` contains an erroneous recovery id. -pub fn build_typed_transaction( - request: TypedTransactionRequest, - signature: Signature, -) -> Result { - let tx = match request { - TypedTransactionRequest::Legacy(tx) => TypedTransaction::Legacy(tx.into_signed(signature)), - TypedTransactionRequest::EIP2930(tx) => { - TypedTransaction::EIP2930(tx.into_signed(signature)) - } - TypedTransactionRequest::EIP1559(tx) => { - TypedTransaction::EIP1559(tx.into_signed(signature)) - } - TypedTransactionRequest::EIP7702(tx) => { - TypedTransaction::EIP7702(tx.into_signed(signature)) - } - TypedTransactionRequest::EIP4844(tx) => { - TypedTransaction::EIP4844(tx.into_signed(signature)) - } - TypedTransactionRequest::Deposit(tx) => TypedTransaction::Deposit(tx), - }; - - Ok(tx) +#[cfg(test)] +mod tests { + use super::*; + use alloy_consensus::TxLegacy; + + #[test] + fn dev_signer_uses_transaction_chain_id() { + let mut account = PrivateKeySigner::random(); + account.set_chain_id(Some(1)); + let sender = account.address(); + let signer = DevSigner::new(vec![account]); + let tx = TxLegacy { chain_id: Some(56), ..Default::default() }; + + let FoundryTxEnvelope::Legacy(signed) = + signer.sign_transaction_from(&sender, FoundryTypedTx::Legacy(tx)).unwrap() + else { + panic!("expected legacy transaction") + }; + + assert_eq!(signed.tx().chain_id, Some(56)); + assert_eq!(signed.recover_signer().unwrap(), sender); + } } diff --git a/crates/anvil/src/evm.rs b/crates/anvil/src/evm.rs deleted file mode 100644 index 76e570268bb31..0000000000000 --- a/crates/anvil/src/evm.rs +++ /dev/null @@ -1,284 +0,0 @@ -use std::fmt::Debug; - -use alloy_evm::{ - Database, Evm, - eth::EthEvmContext, - precompiles::{DynPrecompile, PrecompileInput, PrecompilesMap}, -}; -use foundry_evm_core::either_evm::EitherEvm; -use op_revm::OpContext; -use revm::{Inspector, precompile::PrecompileWithAddress}; - -/// Object-safe trait that enables injecting extra precompiles when using -/// `anvil` as a library. -pub trait PrecompileFactory: Send + Sync + Unpin + Debug { - /// Returns a set of precompiles to extend the EVM with. - fn precompiles(&self) -> Vec<(PrecompileWithAddress, u64)>; -} - -/// Inject precompiles into the EVM dynamically. -pub fn inject_precompiles( - evm: &mut EitherEvm, - precompiles: Vec<(PrecompileWithAddress, u64)>, -) where - DB: Database, - I: Inspector> + Inspector>, -{ - for (PrecompileWithAddress(addr, func), gas) in precompiles { - evm.precompiles_mut().apply_precompile(&addr, move |_| { - Some(DynPrecompile::from(move |input: PrecompileInput<'_>| func(input.data, gas))) - }); - } -} - -#[cfg(test)] -mod tests { - use std::convert::Infallible; - - use alloy_evm::{EthEvm, Evm, EvmEnv, eth::EthEvmContext, precompiles::PrecompilesMap}; - use alloy_op_evm::OpEvm; - use alloy_primitives::{Address, Bytes, TxKind, U256, address}; - use foundry_evm_core::either_evm::EitherEvm; - use itertools::Itertools; - use op_revm::{L1BlockInfo, OpContext, OpSpecId, OpTransaction, precompiles::OpPrecompiles}; - use revm::{ - Journal, - context::{CfgEnv, Evm as RevmEvm, JournalTr, LocalContext, TxEnv}, - database::{EmptyDB, EmptyDBTyped}, - handler::{EthPrecompiles, instructions::EthInstructions}, - inspector::NoOpInspector, - interpreter::interpreter::EthInterpreter, - precompile::{ - PrecompileOutput, PrecompileResult, PrecompileSpecId, PrecompileWithAddress, - Precompiles, - }, - primitives::hardfork::SpecId, - }; - - use crate::{PrecompileFactory, inject_precompiles}; - - // A precompile activated in the `Prague` spec. - const ETH_PRAGUE_PRECOMPILE: Address = address!("0x0000000000000000000000000000000000000011"); - - // A precompile activated in the `Isthmus` spec. - const OP_ISTHMUS_PRECOMPILE: Address = address!("0x0000000000000000000000000000000000000100"); - - // A custom precompile address and payload for testing. - const PRECOMPILE_ADDR: Address = address!("0x0000000000000000000000000000000000000071"); - const PAYLOAD: &[u8] = &[0xde, 0xad, 0xbe, 0xef]; - - #[derive(Debug)] - struct CustomPrecompileFactory; - - impl PrecompileFactory for CustomPrecompileFactory { - fn precompiles(&self) -> Vec<(PrecompileWithAddress, u64)> { - vec![( - PrecompileWithAddress::from(( - PRECOMPILE_ADDR, - custom_echo_precompile as fn(&[u8], u64) -> PrecompileResult, - )), - 1000, - )] - } - } - - /// Custom precompile that echoes the input data. - /// In this example it uses `0xdeadbeef` as the input data, returning it as output. - fn custom_echo_precompile(input: &[u8], _gas_limit: u64) -> PrecompileResult { - Ok(PrecompileOutput { bytes: Bytes::copy_from_slice(input), gas_used: 0 }) - } - - /// Creates a new EVM instance with the custom precompile factory. - fn create_eth_evm( - spec: SpecId, - ) -> (foundry_evm::Env, EitherEvm, NoOpInspector, PrecompilesMap>) - { - let eth_env = foundry_evm::Env { - evm_env: EvmEnv { block_env: Default::default(), cfg_env: CfgEnv::new_with_spec(spec) }, - tx: TxEnv { - kind: TxKind::Call(PRECOMPILE_ADDR), - data: PAYLOAD.into(), - ..Default::default() - }, - }; - - let eth_evm_context = EthEvmContext { - journaled_state: Journal::new(EmptyDB::default()), - block: eth_env.evm_env.block_env.clone(), - cfg: eth_env.evm_env.cfg_env.clone(), - tx: eth_env.tx.clone(), - chain: (), - local: LocalContext::default(), - error: Ok(()), - }; - - let eth_precompiles = EthPrecompiles { - precompiles: Precompiles::new(PrecompileSpecId::from_spec_id(spec)), - spec, - } - .precompiles; - let eth_evm = EitherEvm::Eth(EthEvm::new( - RevmEvm::new_with_inspector( - eth_evm_context, - NoOpInspector, - EthInstructions::>::default(), - PrecompilesMap::from_static(eth_precompiles), - ), - true, - )); - - (eth_env, eth_evm) - } - - /// Creates a new OP EVM instance with the custom precompile factory. - fn create_op_evm( - spec: SpecId, - op_spec: OpSpecId, - ) -> ( - crate::eth::backend::env::Env, - EitherEvm, NoOpInspector, PrecompilesMap>, - ) { - let op_env = crate::eth::backend::env::Env { - evm_env: EvmEnv { block_env: Default::default(), cfg_env: CfgEnv::new_with_spec(spec) }, - tx: OpTransaction:: { - base: TxEnv { - kind: TxKind::Call(PRECOMPILE_ADDR), - data: PAYLOAD.into(), - ..Default::default() - }, - ..Default::default() - }, - is_optimism: true, - }; - - let mut chain = L1BlockInfo::default(); - - if op_spec == OpSpecId::ISTHMUS { - chain.operator_fee_constant = Some(U256::from(0)); - chain.operator_fee_scalar = Some(U256::from(0)); - } - - let op_cfg = op_env.evm_env.cfg_env.clone().with_spec(op_spec); - let op_evm_context = OpContext { - journaled_state: { - let mut journal = Journal::new(EmptyDB::default()); - // Converting SpecId into OpSpecId - journal.set_spec_id(op_env.evm_env.cfg_env.spec); - journal - }, - block: op_env.evm_env.block_env.clone(), - cfg: op_cfg.clone(), - tx: op_env.tx.clone(), - chain, - local: LocalContext::default(), - error: Ok(()), - }; - - let op_precompiles = OpPrecompiles::new_with_spec(op_cfg.spec).precompiles(); - let op_evm = EitherEvm::Op(OpEvm::new( - op_revm::OpEvm(RevmEvm::new_with_inspector( - op_evm_context, - NoOpInspector, - EthInstructions::>::default(), - PrecompilesMap::from_static(op_precompiles), - )), - true, - )); - - (op_env, op_evm) - } - - #[test] - fn build_eth_evm_with_extra_precompiles_default_spec() { - let (env, mut evm) = create_eth_evm(SpecId::default()); - - // Check that the Prague precompile IS present when using the default spec. - assert!(evm.precompiles().addresses().contains(Ð_PRAGUE_PRECOMPILE)); - - assert!(!evm.precompiles().addresses().contains(&PRECOMPILE_ADDR)); - - inject_precompiles(&mut evm, CustomPrecompileFactory.precompiles()); - - assert!(evm.precompiles().addresses().contains(&PRECOMPILE_ADDR)); - - let result = match &mut evm { - EitherEvm::Eth(eth_evm) => eth_evm.transact(env.tx).unwrap(), - _ => unreachable!(), - }; - - assert!(result.result.is_success()); - assert_eq!(result.result.output(), Some(&PAYLOAD.into())); - } - - #[test] - fn build_eth_evm_with_extra_precompiles_london_spec() { - let (env, mut evm) = create_eth_evm(SpecId::LONDON); - - // Check that the Prague precompile IS NOT present when using the London spec. - assert!(!evm.precompiles().addresses().contains(Ð_PRAGUE_PRECOMPILE)); - - assert!(!evm.precompiles().addresses().contains(&PRECOMPILE_ADDR)); - - inject_precompiles(&mut evm, CustomPrecompileFactory.precompiles()); - - assert!(evm.precompiles().addresses().contains(&PRECOMPILE_ADDR)); - - let result = match &mut evm { - EitherEvm::Eth(eth_evm) => eth_evm.transact(env.tx).unwrap(), - _ => unreachable!(), - }; - - assert!(result.result.is_success()); - assert_eq!(result.result.output(), Some(&PAYLOAD.into())); - } - - #[test] - fn build_op_evm_with_extra_precompiles_default_spec() { - let (env, mut evm) = create_op_evm(SpecId::default(), OpSpecId::default()); - - // Check that the Isthmus precompile IS present when using the default spec. - assert!(evm.precompiles().addresses().contains(&OP_ISTHMUS_PRECOMPILE)); - - // Check that the Prague precompile IS present when using the default spec. - assert!(evm.precompiles().addresses().contains(Ð_PRAGUE_PRECOMPILE)); - - assert!(!evm.precompiles().addresses().contains(&PRECOMPILE_ADDR)); - - inject_precompiles(&mut evm, CustomPrecompileFactory.precompiles()); - - assert!(evm.precompiles().addresses().contains(&PRECOMPILE_ADDR)); - - let result = match &mut evm { - EitherEvm::Op(op_evm) => op_evm.transact(env.tx).unwrap(), - _ => unreachable!(), - }; - - assert!(result.result.is_success()); - assert_eq!(result.result.output(), Some(&PAYLOAD.into())); - } - - #[test] - fn build_op_evm_with_extra_precompiles_bedrock_spec() { - let (env, mut evm) = create_op_evm(SpecId::default(), OpSpecId::BEDROCK); - - // Check that the Isthmus precompile IS NOT present when using the `OpSpecId::BEDROCK` spec. - assert!(!evm.precompiles().addresses().contains(&OP_ISTHMUS_PRECOMPILE)); - - // Check that the Prague precompile IS NOT present when using the `OpSpecId::BEDROCK` spec. - assert!(!evm.precompiles().addresses().contains(Ð_PRAGUE_PRECOMPILE)); - - assert!(!evm.precompiles().addresses().contains(&PRECOMPILE_ADDR)); - - inject_precompiles(&mut evm, CustomPrecompileFactory.precompiles()); - - assert!(evm.precompiles().addresses().contains(&PRECOMPILE_ADDR)); - - let result = match &mut evm { - EitherEvm::Op(op_evm) => op_evm.transact(env.tx).unwrap(), - _ => unreachable!(), - }; - - assert!(result.result.is_success()); - assert_eq!(result.result.output(), Some(&PAYLOAD.into())); - } -} diff --git a/crates/anvil/src/evm/mod.rs b/crates/anvil/src/evm/mod.rs new file mode 100644 index 0000000000000..7cdc94791a875 --- /dev/null +++ b/crates/anvil/src/evm/mod.rs @@ -0,0 +1,199 @@ +use alloy_evm::precompiles::{DynPrecompile, PrecompilesMap}; +use alloy_primitives::Address; +use std::fmt::Debug; + +#[cfg(feature = "optimism")] +mod optimism; + +/// Object-safe trait that enables injecting extra precompiles when using +/// `anvil` as a library. +pub trait PrecompileFactory: Send + Sync + Unpin + Debug { + /// Returns a set of precompiles to extend the EVM with. + fn precompiles(&self) -> Vec<(Address, DynPrecompile)>; + + /// Installs precompiles into the EVM precompile map. + fn install(&self, precompiles: &mut PrecompilesMap) { + precompiles.extend_precompiles(self.precompiles()); + } +} + +#[cfg(test)] +mod tests { + + use crate::PrecompileFactory; + use alloy_evm::{ + EthEvm, Evm, + eth::EthEvmContext, + precompiles::{DynPrecompile, PrecompilesMap}, + }; + use alloy_primitives::{Address, Bytes, TxKind, address}; + use itertools::Itertools; + use revm::{ + Journal, + context::{BlockEnv, CfgEnv, Evm as RevmEvm, JournalTr, LocalContext, TxEnv}, + database::EmptyDB, + handler::{EthPrecompiles, instructions::EthInstructions}, + inspector::NoOpInspector, + interpreter::interpreter::EthInterpreter, + precompile::{PrecompileOutput, PrecompileSpecId, PrecompileStatus, Precompiles}, + primitives::hardfork::SpecId, + }; + + // A precompile activated in the `Prague` spec (BLS12-381 G2 map). + pub(super) const ETH_PRAGUE_PRECOMPILE: Address = Address::with_last_byte(0x11); + + // A precompile activated in the `Osaka` spec (EIP-7951). + const ETH_OSAKA_PRECOMPILE: Address = address!("0x0000000000000000000000000000000000000100"); + + // A custom precompile address and payload for testing. + pub(super) const PRECOMPILE_ADDR: Address = Address::with_last_byte(0x71); + const DYNAMIC_PRECOMPILE_ADDR: Address = address!("0xdead000000000000000000000000000000000071"); + const DYNAMIC_PRECOMPILE_PREFIX: [u8; 2] = [0xde, 0xad]; + pub(super) const PAYLOAD: &[u8] = &[0xde, 0xad, 0xbe, 0xef]; + + fn echo_precompile() -> DynPrecompile { + use alloy_evm::precompiles::PrecompileInput; + DynPrecompile::from(|input: PrecompileInput<'_>| { + Ok(PrecompileOutput { + status: PrecompileStatus::Success, + bytes: Bytes::copy_from_slice(input.data), + gas_used: 0, + gas_refunded: 0, + state_gas_used: 0, + state_gas_spilled: 0, + reservoir: input.reservoir, + }) + }) + } + + #[derive(Debug)] + pub(super) struct CustomPrecompileFactory; + + impl PrecompileFactory for CustomPrecompileFactory { + fn precompiles(&self) -> Vec<(Address, DynPrecompile)> { + vec![(PRECOMPILE_ADDR, echo_precompile())] + } + } + + #[derive(Debug)] + struct DynamicLookupPrecompileFactory; + + impl PrecompileFactory for DynamicLookupPrecompileFactory { + fn precompiles(&self) -> Vec<(Address, DynPrecompile)> { + Vec::new() + } + + fn install(&self, precompiles: &mut PrecompilesMap) { + precompiles.set_precompile_lookup(|address: &Address| { + address.as_slice().starts_with(&DYNAMIC_PRECOMPILE_PREFIX).then(echo_precompile) + }); + } + } + + /// Creates a new Eth EVM instance. + fn create_eth_evm(spec: SpecId) -> (TxEnv, EthEvm) { + let tx_env = TxEnv { + kind: TxKind::Call(PRECOMPILE_ADDR), + data: PAYLOAD.into(), + ..Default::default() + }; + + let eth_evm_context = EthEvmContext { + journaled_state: Journal::new(EmptyDB::default()), + block: BlockEnv::default(), + cfg: CfgEnv::new_with_spec(spec), + tx: tx_env.clone(), + chain: (), + local: LocalContext::new(), + error: Ok(()), + }; + + let eth_precompiles = EthPrecompiles { + precompiles: Precompiles::new(PrecompileSpecId::from_spec_id(spec)), + spec, + } + .precompiles; + let eth_evm = EthEvm::new( + RevmEvm::new_with_inspector( + eth_evm_context, + NoOpInspector, + EthInstructions::>::new_mainnet_with_spec( + spec, + ), + PrecompilesMap::from_static(eth_precompiles), + ), + true, + ); + + (tx_env, eth_evm) + } + + #[test] + fn build_eth_evm_with_extra_precompiles_osaka_spec() { + let (tx_env, mut evm) = create_eth_evm(SpecId::OSAKA); + + assert!(evm.precompiles().addresses().contains(Ð_OSAKA_PRECOMPILE)); + assert!(evm.precompiles().addresses().contains(Ð_PRAGUE_PRECOMPILE)); + assert!(!evm.precompiles().addresses().contains(&PRECOMPILE_ADDR)); + + CustomPrecompileFactory.install(evm.precompiles_mut()); + + assert!(evm.precompiles().addresses().contains(&PRECOMPILE_ADDR)); + + let result = evm.transact(tx_env).unwrap(); + assert!(result.result.is_success()); + assert_eq!(result.result.output(), Some(&PAYLOAD.into())); + } + + #[test] + fn build_eth_evm_with_extra_precompiles_london_spec() { + let (tx_env, mut evm) = create_eth_evm(SpecId::LONDON); + + assert!(!evm.precompiles().addresses().contains(Ð_OSAKA_PRECOMPILE)); + assert!(!evm.precompiles().addresses().contains(Ð_PRAGUE_PRECOMPILE)); + assert!(!evm.precompiles().addresses().contains(&PRECOMPILE_ADDR)); + + CustomPrecompileFactory.install(evm.precompiles_mut()); + + assert!(evm.precompiles().addresses().contains(&PRECOMPILE_ADDR)); + + let result = evm.transact(tx_env).unwrap(); + assert!(result.result.is_success()); + assert_eq!(result.result.output(), Some(&PAYLOAD.into())); + } + + #[test] + fn build_eth_evm_with_extra_precompiles_prague_spec() { + let (tx_env, mut evm) = create_eth_evm(SpecId::PRAGUE); + + assert!(!evm.precompiles().addresses().contains(Ð_OSAKA_PRECOMPILE)); + assert!(evm.precompiles().addresses().contains(Ð_PRAGUE_PRECOMPILE)); + assert!(!evm.precompiles().addresses().contains(&PRECOMPILE_ADDR)); + + CustomPrecompileFactory.install(evm.precompiles_mut()); + + assert!(evm.precompiles().addresses().contains(&PRECOMPILE_ADDR)); + + let result = evm.transact(tx_env).unwrap(); + assert!(result.result.is_success()); + assert_eq!(result.result.output(), Some(&PAYLOAD.into())); + } + + #[test] + fn factory_install_supports_dynamic_lookup() { + let (mut tx_env, mut evm) = create_eth_evm(SpecId::PRAGUE); + tx_env.kind = TxKind::Call(DYNAMIC_PRECOMPILE_ADDR); + + assert!(!evm.precompiles().addresses().contains(&DYNAMIC_PRECOMPILE_ADDR)); + assert!(evm.precompiles().get(&DYNAMIC_PRECOMPILE_ADDR).is_none()); + + DynamicLookupPrecompileFactory.install(evm.precompiles_mut()); + + assert!(!evm.precompiles().addresses().contains(&DYNAMIC_PRECOMPILE_ADDR)); + assert!(evm.precompiles().get(&DYNAMIC_PRECOMPILE_ADDR).is_some()); + + let result = evm.transact(tx_env).unwrap(); + assert!(result.result.is_success()); + assert_eq!(result.result.output(), Some(&PAYLOAD.into())); + } +} diff --git a/crates/anvil/src/evm/optimism.rs b/crates/anvil/src/evm/optimism.rs new file mode 100644 index 0000000000000..cc3d0541e97c5 --- /dev/null +++ b/crates/anvil/src/evm/optimism.rs @@ -0,0 +1,86 @@ +//! Optimism-specific EVM helpers. + +#[cfg(test)] +mod tests { + + use super::super::tests::{ + CustomPrecompileFactory, ETH_PRAGUE_PRECOMPILE, PAYLOAD, PRECOMPILE_ADDR, + }; + use crate::PrecompileFactory; + use alloy_evm::{Evm, EvmEnv, EvmFactory, precompiles::PrecompilesMap}; + use alloy_op_evm::{OpEvm, OpEvmFactory, OpTx}; + use alloy_primitives::{Address, TxKind, U256, address}; + use itertools::Itertools; + use op_revm::{OpSpecId, OpTransaction}; + use revm::{ + context::{BlockEnv, CfgEnv, TxEnv}, + database::EmptyDB, + inspector::NoOpInspector, + primitives::hardfork::SpecId, + }; + + // A precompile activated in the `Isthmus` spec. + const OP_ISTHMUS_PRECOMPILE: Address = address!("0x0000000000000000000000000000000000000100"); + + /// Creates a new OP EVM instance. + fn create_op_evm( + _spec: SpecId, + op_spec: OpSpecId, + ) -> (OpTx, OpEvm) { + let tx = OpTx(OpTransaction:: { + base: TxEnv { + kind: TxKind::Call(PRECOMPILE_ADDR), + data: PAYLOAD.into(), + ..Default::default() + }, + ..Default::default() + }); + + let mut evm = OpEvmFactory::::default().create_evm_with_inspector( + EmptyDB::default(), + EvmEnv::new(CfgEnv::new_with_spec(op_spec), BlockEnv::default()), + NoOpInspector, + ); + + if op_spec == OpSpecId::ISTHMUS { + evm.ctx_mut().chain.operator_fee_constant = Some(U256::ZERO); + evm.ctx_mut().chain.operator_fee_scalar = Some(U256::ZERO); + } + + (tx, evm) + } + + #[test] + fn build_op_evm_with_extra_precompiles_isthmus_spec() { + let (tx, mut evm) = create_op_evm(SpecId::OSAKA, OpSpecId::ISTHMUS); + + assert!(evm.precompiles().addresses().contains(&OP_ISTHMUS_PRECOMPILE)); + assert!(evm.precompiles().addresses().contains(Ð_PRAGUE_PRECOMPILE)); + assert!(!evm.precompiles().addresses().contains(&PRECOMPILE_ADDR)); + + CustomPrecompileFactory.install(evm.precompiles_mut()); + + assert!(evm.precompiles().addresses().contains(&PRECOMPILE_ADDR)); + + let result = evm.transact(tx).unwrap(); + assert!(result.result.is_success()); + assert_eq!(result.result.output(), Some(&PAYLOAD.into())); + } + + #[test] + fn build_op_evm_with_extra_precompiles_bedrock_spec() { + let (tx, mut evm) = create_op_evm(SpecId::OSAKA, OpSpecId::BEDROCK); + + assert!(!evm.precompiles().addresses().contains(&OP_ISTHMUS_PRECOMPILE)); + assert!(!evm.precompiles().addresses().contains(Ð_PRAGUE_PRECOMPILE)); + assert!(!evm.precompiles().addresses().contains(&PRECOMPILE_ADDR)); + + CustomPrecompileFactory.install(evm.precompiles_mut()); + + assert!(evm.precompiles().addresses().contains(&PRECOMPILE_ADDR)); + + let result = evm.transact(tx).unwrap(); + assert!(result.result.is_success()); + assert_eq!(result.result.output(), Some(&PAYLOAD.into())); + } +} diff --git a/crates/anvil/src/filter.rs b/crates/anvil/src/filter.rs index 9d1e0958e99fe..0e6773821e6aa 100644 --- a/crates/anvil/src/filter.rs +++ b/crates/anvil/src/filter.rs @@ -1,13 +1,21 @@ //! Support for polling based filters use crate::{ StorageInfo, - eth::{backend::notifications::NewBlockNotifications, error::ToRpcResponseResult}, - pubsub::filter_logs, + eth::{ + backend::notifications::{ChainNotification, ChainNotifications}, + error::ToRpcResponseResult, + }, + pubsub::{filter_logs, filter_removed_logs}, }; +use alloy_consensus::TxReceipt; +use alloy_network::{AnyRpcTransaction, Network}; use alloy_primitives::{TxHash, map::HashMap}; use alloy_rpc_types::{Filter, FilteredParams, Log}; use anvil_core::eth::subscription::SubscriptionId; -use anvil_rpc::response::ResponseResult; +use anvil_rpc::{ + error::{ErrorCode, RpcError}, + response::ResponseResult, +}; use futures::{Stream, StreamExt, channel::mpsc::Receiver}; use std::{ pin::Pin, @@ -15,26 +23,37 @@ use std::{ task::{Context, Poll}, time::{Duration, Instant}, }; -use tokio::sync::Mutex; +use tokio::sync::{Mutex, mpsc}; /// Type alias for filters identified by their id and their expiration timestamp -type FilterMap = Arc>>; +type FilterMap = Arc, Instant)>>>; /// timeout after which to remove an active filter if it wasn't polled since then pub const ACTIVE_FILTER_TIMEOUT_SECS: u64 = 60 * 5; /// Contains all registered filters -#[derive(Clone, Debug)] -pub struct Filters { +pub struct Filters { /// all currently active filters - active_filters: FilterMap, + active_filters: FilterMap, /// How long we keep a live the filter after the last poll keepalive: Duration, } -impl Filters { +impl Clone for Filters { + fn clone(&self) -> Self { + Self { active_filters: self.active_filters.clone(), keepalive: self.keepalive } + } +} + +impl std::fmt::Debug for Filters { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Filters").field("keepalive", &self.keepalive).finish_non_exhaustive() + } +} + +impl Filters { /// Adds a new `EthFilter` to the set - pub async fn add_filter(&self, filter: EthFilter) -> String { + pub async fn add_filter(&self, filter: EthFilter) -> String { let id = new_id(); trace!(target: "node::filter", "Adding new filter id {}", id); let mut filters = self.active_filters.lock().await; @@ -42,22 +61,6 @@ impl Filters { id } - pub async fn get_filter_changes(&self, id: &str) -> ResponseResult { - { - let mut filters = self.active_filters.lock().await; - if let Some((filter, deadline)) = filters.get_mut(id) { - let resp = filter - .next() - .await - .unwrap_or_else(|| ResponseResult::success(Vec::<()>::new())); - *deadline = self.next_deadline(); - return resp; - } - } - warn!(target: "node::filter", "No filter found for {}", id); - ResponseResult::success(Vec::<()>::new()) - } - /// Returns the original `Filter` of an `eth_newFilter` pub async fn get_log_filter(&self, id: &str) -> Option { let filters = self.active_filters.lock().await; @@ -68,13 +71,13 @@ impl Filters { } /// Removes the filter identified with the `id` - pub async fn uninstall_filter(&self, id: &str) -> Option { + pub async fn uninstall_filter(&self, id: &str) -> Option> { trace!(target: "node::filter", "Uninstalling filter id {}", id); self.active_filters.lock().await.remove(id).map(|(f, _)| f) } /// The duration how long to keep alive stale filters - pub fn keep_alive(&self) -> Duration { + pub const fn keep_alive(&self) -> Duration { self.keepalive } @@ -98,7 +101,32 @@ impl Filters { } } -impl Default for Filters { +impl Filters +where + N::ReceiptEnvelope: TxReceipt, +{ + pub async fn get_filter_changes(&self, id: &str) -> ResponseResult { + { + let mut filters = self.active_filters.lock().await; + if let Some((filter, deadline)) = filters.get_mut(id) { + let resp = filter + .next() + .await + .unwrap_or_else(|| ResponseResult::success(Vec::<()>::new())); + *deadline = self.next_deadline(); + return resp; + } + } + warn!(target: "node::filter", "No filter found for {}", id); + ResponseResult::error(RpcError { + code: ErrorCode::ServerError(-32000), + message: "filter not found".into(), + data: None, + }) + } +} + +impl Default for Filters { fn default() -> Self { Self { active_filters: Arc::new(Default::default()), @@ -113,14 +141,28 @@ fn new_id() -> String { } /// Represents a poll based filter -#[derive(Debug)] -pub enum EthFilter { - Logs(Box), - Blocks(NewBlockNotifications), +pub enum EthFilter { + Logs(Box>), + Blocks(ChainNotifications), PendingTransactions(Receiver), + FullPendingTransactions(mpsc::Receiver), } -impl Stream for EthFilter { +impl std::fmt::Debug for EthFilter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Logs(_) => f.debug_tuple("Logs").finish(), + Self::Blocks(_) => f.debug_tuple("Blocks").finish(), + Self::PendingTransactions(_) => f.debug_tuple("PendingTransactions").finish(), + Self::FullPendingTransactions(_) => f.debug_tuple("FullPendingTransactions").finish(), + } + } +} + +impl Stream for EthFilter +where + N::ReceiptEnvelope: TxReceipt, +{ type Item = ResponseResult; fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { @@ -129,8 +171,10 @@ impl Stream for EthFilter { Self::Logs(logs) => Poll::Ready(Some(Ok(logs.poll(cx)).to_rpc_result())), Self::Blocks(blocks) => { let mut new_blocks = Vec::new(); - while let Poll::Ready(Some(block)) = blocks.poll_next_unpin(cx) { - new_blocks.push(block.hash); + while let Poll::Ready(Some(notification)) = blocks.poll_next_unpin(cx) { + if let Some(block) = notification.as_new_block() { + new_blocks.push(block.hash); + } } Poll::Ready(Some(Ok(new_blocks).to_rpc_result())) } @@ -141,17 +185,23 @@ impl Stream for EthFilter { } Poll::Ready(Some(Ok(new_txs).to_rpc_result())) } + Self::FullPendingTransactions(tx) => { + let mut new_txs = Vec::new(); + while let Poll::Ready(Some(tx)) = tx.poll_recv(cx) { + new_txs.push(tx); + } + Poll::Ready(Some(Ok(new_txs).to_rpc_result())) + } } } } /// Listens for new blocks and matching logs emitted in that block -#[derive(Debug)] -pub struct LogsFilter { +pub struct LogsFilter { /// listener for new blocks - pub blocks: NewBlockNotifications, + pub blocks: ChainNotifications, /// accessor for block storage - pub storage: StorageInfo, + pub storage: StorageInfo, /// matcher with all provided filter params pub filter: FilteredParams, /// existing logs that matched the filter when the listener was installed @@ -160,15 +210,31 @@ pub struct LogsFilter { pub historic: Option>, } -impl LogsFilter { +impl std::fmt::Debug for LogsFilter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LogsFilter").field("filter", &self.filter).finish_non_exhaustive() + } +} + +impl LogsFilter +where + N::ReceiptEnvelope: TxReceipt, +{ /// Returns all the logs since the last time this filter was polled pub fn poll(&mut self, cx: &mut Context<'_>) -> Vec { let mut logs = self.historic.take().unwrap_or_default(); - while let Poll::Ready(Some(block)) = self.blocks.poll_next_unpin(cx) { - let b = self.storage.block(block.hash); - let receipts = self.storage.receipts(block.hash); - if let (Some(receipts), Some(block)) = (receipts, b) { - logs.extend(filter_logs(block, receipts, &self.filter)) + while let Poll::Ready(Some(notification)) = self.blocks.poll_next_unpin(cx) { + match notification { + ChainNotification::Block(block) => { + let b = self.storage.block(block.hash); + let receipts = self.storage.receipts(block.hash); + if let (Some(receipts), Some(block)) = (receipts, b) { + logs.extend(filter_logs(block, receipts, &self.filter)) + } + } + ChainNotification::RemovedLogs(removed) => { + logs.extend(filter_removed_logs(&removed, &self.filter)) + } } } logs diff --git a/crates/anvil/src/hardfork.rs b/crates/anvil/src/hardfork.rs deleted file mode 100644 index f5e063aafc625..0000000000000 --- a/crates/anvil/src/hardfork.rs +++ /dev/null @@ -1,124 +0,0 @@ -use alloy_hardforks::EthereumHardfork; -use alloy_op_hardforks::OpHardfork::{self}; -use alloy_rpc_types::BlockNumberOrTag; - -use op_revm::OpSpecId; -use revm::primitives::hardfork::SpecId; - -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum ChainHardfork { - Ethereum(EthereumHardfork), - Optimism(OpHardfork), -} - -impl From for ChainHardfork { - fn from(value: EthereumHardfork) -> Self { - Self::Ethereum(value) - } -} - -impl From for ChainHardfork { - fn from(value: OpHardfork) -> Self { - Self::Optimism(value) - } -} - -impl From for SpecId { - fn from(fork: ChainHardfork) -> Self { - match fork { - ChainHardfork::Ethereum(hardfork) => spec_id_from_ethereum_hardfork(hardfork), - ChainHardfork::Optimism(hardfork) => spec_id_from_optimism_hardfork(hardfork).into(), - } - } -} - -/// Map an EthereumHardfork enum into its corresponding SpecId. -pub fn spec_id_from_ethereum_hardfork(hardfork: EthereumHardfork) -> SpecId { - match hardfork { - EthereumHardfork::Frontier => SpecId::FRONTIER, - EthereumHardfork::Homestead => SpecId::HOMESTEAD, - EthereumHardfork::Dao => SpecId::DAO_FORK, - EthereumHardfork::Tangerine => SpecId::TANGERINE, - EthereumHardfork::SpuriousDragon => SpecId::SPURIOUS_DRAGON, - EthereumHardfork::Byzantium => SpecId::BYZANTIUM, - EthereumHardfork::Constantinople => SpecId::CONSTANTINOPLE, - EthereumHardfork::Petersburg => SpecId::PETERSBURG, - EthereumHardfork::Istanbul => SpecId::ISTANBUL, - EthereumHardfork::MuirGlacier => SpecId::MUIR_GLACIER, - EthereumHardfork::Berlin => SpecId::BERLIN, - EthereumHardfork::London => SpecId::LONDON, - EthereumHardfork::ArrowGlacier => SpecId::ARROW_GLACIER, - EthereumHardfork::GrayGlacier => SpecId::GRAY_GLACIER, - EthereumHardfork::Paris => SpecId::MERGE, - EthereumHardfork::Shanghai => SpecId::SHANGHAI, - EthereumHardfork::Cancun => SpecId::CANCUN, - EthereumHardfork::Prague => SpecId::PRAGUE, - EthereumHardfork::Osaka => SpecId::OSAKA, - } -} - -/// Map an OptimismHardfork enum into its corresponding OpSpecId. -pub fn spec_id_from_optimism_hardfork(hardfork: OpHardfork) -> OpSpecId { - match hardfork { - OpHardfork::Bedrock => OpSpecId::BEDROCK, - OpHardfork::Regolith => OpSpecId::REGOLITH, - OpHardfork::Canyon => OpSpecId::CANYON, - OpHardfork::Ecotone => OpSpecId::ECOTONE, - OpHardfork::Fjord => OpSpecId::FJORD, - OpHardfork::Granite => OpSpecId::GRANITE, - OpHardfork::Holocene => OpSpecId::HOLOCENE, - OpHardfork::Isthmus => OpSpecId::ISTHMUS, - OpHardfork::Interop => OpSpecId::INTEROP, - } -} - -/// Convert a `BlockNumberOrTag` into an `EthereumHardfork`. -pub fn ethereum_hardfork_from_block_tag(block: impl Into) -> EthereumHardfork { - let num = match block.into() { - BlockNumberOrTag::Earliest => 0, - BlockNumberOrTag::Number(num) => num, - _ => u64::MAX, - }; - - EthereumHardfork::from_mainnet_block_number(num) -} - -#[cfg(test)] -mod tests { - use super::*; - use alloy_hardforks::ethereum::mainnet::*; - #[allow(unused_imports)] - use alloy_rpc_types::BlockNumberOrTag; - - #[test] - fn test_ethereum_spec_id_mapping() { - assert_eq!(spec_id_from_ethereum_hardfork(EthereumHardfork::Frontier), SpecId::FRONTIER); - assert_eq!(spec_id_from_ethereum_hardfork(EthereumHardfork::Homestead), SpecId::HOMESTEAD); - - // Test latest hardforks - assert_eq!(spec_id_from_ethereum_hardfork(EthereumHardfork::Cancun), SpecId::CANCUN); - assert_eq!(spec_id_from_ethereum_hardfork(EthereumHardfork::Prague), SpecId::PRAGUE); - } - - #[test] - fn test_optimism_spec_id_mapping() { - assert_eq!(spec_id_from_optimism_hardfork(OpHardfork::Bedrock), OpSpecId::BEDROCK); - assert_eq!(spec_id_from_optimism_hardfork(OpHardfork::Regolith), OpSpecId::REGOLITH); - - // Test latest hardforks - assert_eq!(spec_id_from_optimism_hardfork(OpHardfork::Holocene), OpSpecId::HOLOCENE); - assert_eq!(spec_id_from_optimism_hardfork(OpHardfork::Interop), OpSpecId::INTEROP); - } - - #[test] - fn test_hardfork_from_block_tag_numbers() { - assert_eq!( - ethereum_hardfork_from_block_tag(MAINNET_HOMESTEAD_BLOCK - 1), - EthereumHardfork::Frontier - ); - assert_eq!( - ethereum_hardfork_from_block_tag(MAINNET_LONDON_BLOCK + 1), - EthereumHardfork::London - ); - } -} diff --git a/crates/anvil/src/lib.rs b/crates/anvil/src/lib.rs index a23b414ef01cc..47f1ef1318f61 100644 --- a/crates/anvil/src/lib.rs +++ b/crates/anvil/src/lib.rs @@ -1,9 +1,11 @@ //! Anvil is a fast local Ethereum development node. -#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] #![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![cfg_attr(docsrs, feature(doc_cfg))] +#![recursion_limit = "256"] use crate::{ + error::{NodeError, NodeResult}, eth::{ EthApi, backend::{info::StorageInfo, mem}, @@ -14,20 +16,18 @@ use crate::{ }, filter::Filters, logging::{LoggingManager, NodeLogLayer}, - server::error::{NodeError, NodeResult}, service::NodeService, shutdown::Signal, tasks::TaskManager, }; -use alloy_eips::eip7840::BlobParams; use alloy_primitives::{Address, U256}; use alloy_signer_local::PrivateKeySigner; use eth::backend::fork::ClientFork; -use eyre::Result; +use eyre::{Result, WrapErr}; use foundry_common::provider::{ProviderBuilder, RetryProvider}; +use foundry_primitives::FoundryNetwork; use futures::{FutureExt, TryFutureExt}; use parking_lot::Mutex; -use revm::primitives::hardfork::SpecId; use server::try_spawn_ipc; use std::{ net::SocketAddr, @@ -39,6 +39,12 @@ use tokio::{ runtime::Handle, task::{JoinError, JoinHandle}, }; +use tracing_subscriber::EnvFilter; + +#[cfg(feature = "optimism")] +use op_alloy_rpc_types as _; + +pub use foundry_evm::hardfork::EthereumHardfork; /// contains the background service that drives the node mod service; @@ -48,13 +54,12 @@ pub use config::{ AccountGenerator, CHAIN_ID, DEFAULT_GAS_LIMIT, ForkChoice, NodeConfig, VERSION_MESSAGE, }; -mod hardfork; -pub use alloy_hardforks::EthereumHardfork; +mod error; /// ethereum related implementations pub mod eth; /// Evm related abstractions mod evm; -pub use evm::{PrecompileFactory, inject_precompiles}; +pub use evm::PrecompileFactory; /// support for polling filters pub mod filter; @@ -110,7 +115,7 @@ extern crate tracing; /// # Ok(()) /// # } /// ``` -pub async fn spawn(config: NodeConfig) -> (EthApi, NodeHandle) { +pub async fn spawn(config: NodeConfig) -> (EthApi, NodeHandle) { try_spawn(config).await.expect("failed to spawn node") } @@ -134,11 +139,26 @@ pub async fn spawn(config: NodeConfig) -> (EthApi, NodeHandle) { /// # Ok(()) /// # } /// ``` -pub async fn try_spawn(mut config: NodeConfig) -> Result<(EthApi, NodeHandle)> { +pub async fn try_spawn(mut config: NodeConfig) -> Result<(EthApi, NodeHandle)> { let logger = if config.enable_tracing { init_tracing() } else { Default::default() }; logger.set_enabled(!config.silent); - let backend = Arc::new(config.setup().await?); + let init_state = config.init_state.take(); + let (backend, fork_transaction_replay) = config.setup::().await?; + + if let Some(state) = init_state { + backend.load_state(state).await.wrap_err("failed to load init state")?; + } + + if let Some(replay) = fork_transaction_replay { + backend + .apply_fork_transaction_replay(replay) + .await + .wrap_err("failed to replay fork transaction prefix")?; + } + + backend.commit_startup_fork_cache(); + let backend = Arc::new(backend); if config.enable_auto_impersonate { backend.auto_impersonate_account(true); @@ -176,14 +196,10 @@ pub async fn try_spawn(mut config: NodeConfig) -> Result<(EthApi, NodeHandle)> { MiningMode::instant(max_transactions, listener) }; - let miner = match &fork { - Some(fork) => { - Miner::new(mode).with_forced_transactions(fork.config.read().force_transactions.clone()) - } - _ => Miner::new(mode), - }; + let miner = Miner::new(mode.with_coalescing_window(config.transaction_coalescing_window)); - let dev_signer: Box = Box::new(DevSigner::new(signer_accounts)); + let dev_signer: Box> = + Box::new(DevSigner::new(signer_accounts)); let mut signers = vec![dev_signer]; if let Some(genesis) = genesis { let genesis_signers = genesis @@ -199,11 +215,7 @@ pub async fn try_spawn(mut config: NodeConfig) -> Result<(EthApi, NodeHandle)> { let fee_history_cache = Arc::new(Mutex::new(Default::default())); let fee_history_service = FeeHistoryService::new( - match backend.spec_id() { - SpecId::OSAKA => BlobParams::osaka(), - SpecId::PRAGUE => BlobParams::prague(), - _ => BlobParams::cancun(), - }, + backend.fees().clone(), backend.new_block_notifications(), Arc::clone(&fee_history_cache), StorageInfo::new(Arc::clone(&backend)), @@ -279,9 +291,9 @@ pub struct NodeHandle { /// The address of the running rpc server. addresses: Vec, /// Join handle for the Node Service. - pub node_service: JoinHandle>, + node_service: JoinHandle>, /// Join handles (one per socket) for the Anvil server. - pub servers: Vec>>, + servers: Vec>>, /// The future that joins the ipc server, if any. ipc_task: Option, /// A signal that fires the shutdown, fired on drop. @@ -296,12 +308,19 @@ impl Drop for NodeHandle { if let Some(signal) = self._signal.take() { let _ = signal.fire(); } + self.node_service.abort(); + for server in &self.servers { + server.abort(); + } + if let Some(ipc_task) = &self.ipc_task { + ipc_task.abort(); + } } } impl NodeHandle { /// The [NodeConfig] the node was launched with. - pub fn config(&self) -> &NodeConfig { + pub const fn config(&self) -> &NodeConfig { &self.config } @@ -378,7 +397,7 @@ impl NodeHandle { } /// Native token balance of every genesis account in the genesis block. - pub fn genesis_balance(&self) -> U256 { + pub const fn genesis_balance(&self) -> U256 { self.config.genesis_balance } @@ -388,14 +407,14 @@ impl NodeHandle { } /// Returns the shutdown signal. - pub fn shutdown_signal(&self) -> &Option { + pub const fn shutdown_signal(&self) -> &Option { &self._signal } /// Returns mutable access to the shutdown signal. /// /// This can be used to extract the Signal. - pub fn shutdown_signal_mut(&mut self) -> &mut Option { + pub const fn shutdown_signal_mut(&mut self) -> &mut Option { &mut self._signal } @@ -414,7 +433,7 @@ impl NodeHandle { /// /// # } /// ``` - pub fn task_manager(&self) -> &TaskManager { + pub const fn task_manager(&self) -> &TaskManager { &self.task_manager } } @@ -429,9 +448,8 @@ impl Future for NodeHandle { if let Some(mut ipc) = pin.ipc_task.take() { if let Poll::Ready(res) = ipc.poll_unpin(cx) { return Poll::Ready(res.map(|()| Ok(()))); - } else { - pin.ipc_task = Some(ipc); } + pin.ipc_task = Some(ipc); } // poll the node service task @@ -455,10 +473,23 @@ pub fn init_tracing() -> LoggingManager { use tracing_subscriber::prelude::*; let manager = LoggingManager::default(); - // check whether `RUST_LOG` is explicitly set - let _ = if std::env::var("RUST_LOG").is_ok() { - tracing_subscriber::Registry::default() - .with(tracing_subscriber::EnvFilter::from_default_env()) + + let _ = if let Ok(rust_log_val) = std::env::var("RUST_LOG") + && !rust_log_val.contains('=') + { + // Mutate the given filter to include `node` logs if it is not already present. + // This prevents the unexpected behaviour of not seeing any node logs if a RUST_LOG + // is already present that doesn't set it. + let rust_log_val = if rust_log_val.contains("node") { + rust_log_val + } else { + format!("{rust_log_val},node=info") + }; + + let env_filter: EnvFilter = + rust_log_val.parse().expect("failed to parse modified RUST_LOG"); + tracing_subscriber::registry() + .with(env_filter) .with(tracing_subscriber::fmt::layer()) .try_init() } else { diff --git a/crates/anvil/src/logging.rs b/crates/anvil/src/logging.rs index 9f0a8d85e9920..31737aa4a3e07 100644 --- a/crates/anvil/src/logging.rs +++ b/crates/anvil/src/logging.rs @@ -22,7 +22,7 @@ pub struct NodeLogLayer { impl NodeLogLayer { /// Returns a new instance of this layer - pub fn new(state: LoggingManager) -> Self { + pub const fn new(state: LoggingManager) -> Self { Self { state } } } diff --git a/crates/anvil/src/opts.rs b/crates/anvil/src/opts.rs index 10b40a5c97c9a..57effe93e2e78 100644 --- a/crates/anvil/src/opts.rs +++ b/crates/anvil/src/opts.rs @@ -3,9 +3,15 @@ use clap::{Parser, Subcommand}; use foundry_cli::opts::GlobalArgs; use foundry_common::version::{LONG_VERSION, SHORT_VERSION}; -/// A fast local Ethereum development node. +/// A fast local Ethereum development node +/// +/// Examples: +/// - anvil (start a local node on 127.0.0.1:8545) +/// - anvil --fork-url $RPC_URL (fork the latest state of a live network) +/// - anvil --block-time 12 (mine a new block every 12 seconds) +/// - anvil --state state.json (load state if it exists and dump it on exit) #[derive(Parser)] -#[command(name = "anvil", version = SHORT_VERSION, long_version = LONG_VERSION, next_display_order = None)] +#[command(verbatim_doc_comment, name = "anvil", version = SHORT_VERSION, long_version = LONG_VERSION, next_display_order = None)] pub struct Anvil { /// Include the global arguments. #[command(flatten)] @@ -24,10 +30,6 @@ pub enum AnvilSubcommand { #[command(visible_alias = "com")] Completions { #[arg(value_enum)] - shell: clap_complete::Shell, + shell: foundry_cli::clap::Shell, }, - - /// Generate Fig autocompletion spec. - #[command(visible_alias = "fig")] - GenerateFigSpec, } diff --git a/crates/anvil/src/pubsub.rs b/crates/anvil/src/pubsub.rs index b6d8e93a53de4..f9f92f0728ee7 100644 --- a/crates/anvil/src/pubsub.rs +++ b/crates/anvil/src/pubsub.rs @@ -1,12 +1,17 @@ use crate::{ StorageInfo, - eth::{backend::notifications::NewBlockNotifications, error::to_rpc_result}, + eth::{ + backend::notifications::{ChainNotification, ChainNotifications}, + error::to_rpc_result, + }, }; -use alloy_network::AnyRpcTransaction; +use alloy_consensus::{BlockHeader, TxReceipt}; +use alloy_network::{AnyRpcTransaction, Network}; use alloy_primitives::{B256, TxHash}; use alloy_rpc_types::{FilteredParams, Log, Transaction, pubsub::SubscriptionResult}; -use anvil_core::eth::{block::Block, subscription::SubscriptionId, transaction::TypedReceipt}; +use anvil_core::eth::{block::Block, subscription::SubscriptionId}; use anvil_rpc::{request::Version, response::ResponseResult}; +use foundry_primitives::FoundryTxReceipt; use futures::{Stream, StreamExt, channel::mpsc::Receiver, ready}; use serde::Serialize; use std::{ @@ -17,16 +22,27 @@ use std::{ use tokio::sync::mpsc::UnboundedReceiver; /// Listens for new blocks and matching logs emitted in that block -#[derive(Debug)] -pub struct LogsSubscription { - pub blocks: NewBlockNotifications, - pub storage: StorageInfo, +pub struct LogsSubscription { + pub blocks: ChainNotifications, + pub storage: StorageInfo, pub filter: FilteredParams, pub queued: VecDeque, pub id: SubscriptionId, } -impl LogsSubscription { +impl std::fmt::Debug for LogsSubscription { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LogsSubscription") + .field("filter", &self.filter) + .field("id", &self.id) + .finish_non_exhaustive() + } +} + +impl LogsSubscription +where + N::ReceiptEnvelope: TxReceipt, +{ fn poll(&mut self, cx: &mut Context<'_>) -> Poll> { loop { if let Some(log) = self.queued.pop_front() { @@ -37,19 +53,28 @@ impl LogsSubscription { return Poll::Ready(Some(EthSubscriptionResponse::new(params))); } - if let Some(block) = ready!(self.blocks.poll_next_unpin(cx)) { - let b = self.storage.block(block.hash); - let receipts = self.storage.receipts(block.hash); - if let (Some(receipts), Some(block)) = (receipts, b) { - let logs = filter_logs(block, receipts, &self.filter); - if logs.is_empty() { - // this ensures we poll the receiver until it is pending, in which case the - // underlying `UnboundedReceiver` will register the new waker, see - // [`futures::channel::mpsc::UnboundedReceiver::poll_next()`] - continue; + if let Some(notification) = ready!(self.blocks.poll_next_unpin(cx)) { + let logs = match notification { + ChainNotification::Block(block) => { + let b = self.storage.block(block.hash); + let receipts = self.storage.receipts(block.hash); + if let (Some(receipts), Some(block)) = (receipts, b) { + filter_logs(block, receipts, &self.filter) + } else { + continue; + } + } + ChainNotification::RemovedLogs(logs) => { + filter_removed_logs(&logs, &self.filter) } - self.queued.extend(logs) + }; + if logs.is_empty() { + // this ensures we poll the receiver until it is pending, in which case the + // underlying `UnboundedReceiver` will register the new waker, see + // [`futures::channel::mpsc::UnboundedReceiver::poll_next()`] + continue; } + self.queued.extend(logs) } else { return Poll::Ready(None); } @@ -69,7 +94,7 @@ pub struct EthSubscriptionResponse { } impl EthSubscriptionResponse { - pub fn new(params: EthSubscriptionParams) -> Self { + pub const fn new(params: EthSubscriptionParams) -> Self { Self { jsonrpc: Version::V2, method: "eth_subscription", params } } } @@ -83,15 +108,32 @@ pub struct EthSubscriptionParams { } /// Represents an ethereum Websocket subscription -#[derive(Debug)] -pub enum EthSubscription { - Logs(Box), - Header(NewBlockNotifications, StorageInfo, SubscriptionId), +pub enum EthSubscription { + Logs(Box>), + Header(ChainNotifications, StorageInfo, SubscriptionId), PendingTransactions(Receiver, SubscriptionId), FullPendingTransactions(UnboundedReceiver, SubscriptionId), + Syncing(Option), + TransactionReceipts(UnboundedReceiver>, SubscriptionId), +} + +impl std::fmt::Debug for EthSubscription { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Logs(_) => f.debug_tuple("Logs").finish(), + Self::Header(..) => f.debug_tuple("Header").finish(), + Self::PendingTransactions(..) => f.debug_tuple("PendingTransactions").finish(), + Self::FullPendingTransactions(..) => f.debug_tuple("FullPendingTransactions").finish(), + Self::Syncing(_) => f.debug_tuple("Syncing").finish(), + Self::TransactionReceipts(..) => f.debug_tuple("TransactionReceipts").finish(), + } + } } -impl EthSubscription { +impl EthSubscription +where + N::ReceiptEnvelope: TxReceipt, +{ fn poll_response(&mut self, cx: &mut Context<'_>) -> Poll> { match self { Self::Logs(listener) => listener.poll(cx), @@ -100,8 +142,10 @@ impl EthSubscription { // underlying `UnboundedReceiver` will register the new waker, see // [`futures::channel::mpsc::UnboundedReceiver::poll_next()`] loop { - if let Some(block) = ready!(blocks.poll_next_unpin(cx)) { - if let Some(block) = storage.eth_block(block.hash) { + if let Some(notification) = ready!(blocks.poll_next_unpin(cx)) { + if let Some(block) = notification.as_new_block() + && let Some(block) = storage.eth_block(block.hash) + { let params = EthSubscriptionParams { subscription: id.clone(), result: to_rpc_result(block), @@ -130,11 +174,29 @@ impl EthSubscription { }); Poll::Ready(res) } + Self::Syncing(id) => { + if let Some(id) = id.take() { + let params = + EthSubscriptionParams { subscription: id, result: to_rpc_result(false) }; + return Poll::Ready(Some(EthSubscriptionResponse::new(params))); + } + Poll::Pending + } + Self::TransactionReceipts(receipts, id) => { + let res = ready!(receipts.poll_recv(cx)).map(to_rpc_result).map(|result| { + let params = EthSubscriptionParams { subscription: id.clone(), result }; + EthSubscriptionResponse::new(params) + }); + Poll::Ready(res) + } } } } -impl Stream for EthSubscription { +impl Stream for EthSubscription +where + N::ReceiptEnvelope: TxReceipt, +{ type Item = serde_json::Value; fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { @@ -147,7 +209,10 @@ impl Stream for EthSubscription { } /// Returns all the logs that match the given filter -pub fn filter_logs(block: Block, receipts: Vec, filter: &FilteredParams) -> Vec { +pub fn filter_logs(block: Block, receipts: Vec, filter: &FilteredParams) -> Vec +where + R: TxReceipt, +{ /// Determines whether to add this log fn add_log( block_hash: B256, @@ -156,7 +221,7 @@ pub fn filter_logs(block: Block, receipts: Vec, filter: &FilteredP params: &FilteredParams, ) -> bool { if params.filter.is_some() { - let block_number = block.header.number; + let block_number = block.header.number(); if !params.filter_block_range(block_number) || !params.filter_block_hash(block_hash) || !params.filter_address(&l.address) @@ -172,18 +237,18 @@ pub fn filter_logs(block: Block, receipts: Vec, filter: &FilteredP let mut logs = vec![]; let mut log_index: u32 = 0; for (receipt_index, receipt) in receipts.into_iter().enumerate() { - let transaction_hash = block.transactions[receipt_index].hash(); + let transaction_hash = block.body.transactions[receipt_index].hash(); for log in receipt.logs() { if add_log(block_hash, log, &block, filter) { logs.push(Log { inner: log.clone(), block_hash: Some(block_hash), - block_number: Some(block.header.number), + block_number: Some(block.header.number()), transaction_hash: Some(transaction_hash), transaction_index: Some(receipt_index as u64), log_index: Some(log_index as u64), removed: false, - block_timestamp: Some(block.header.timestamp), + block_timestamp: Some(block.header.timestamp()), }); } log_index += 1; @@ -191,3 +256,20 @@ pub fn filter_logs(block: Block, receipts: Vec, filter: &FilteredP } logs } + +/// Returns all logs of reorged out blocks that match the given filter. +/// +/// The logs are expected to already be marked as `removed` and carry the metadata of the block +/// they were originally included in, so the filter is applied against that metadata. +pub fn filter_removed_logs(logs: &[Log], filter: &FilteredParams) -> Vec { + logs.iter() + .filter(|log| { + filter.filter.is_none() + || (filter.filter_block_range(log.block_number.unwrap_or_default()) + && filter.filter_block_hash(log.block_hash.unwrap_or_default()) + && filter.filter_address(&log.inner.address) + && filter.filter_topics(log.inner.topics())) + }) + .cloned() + .collect() +} diff --git a/crates/anvil/src/server/beacon/error.rs b/crates/anvil/src/server/beacon/error.rs new file mode 100644 index 0000000000000..0a08e870ad70e --- /dev/null +++ b/crates/anvil/src/server/beacon/error.rs @@ -0,0 +1,132 @@ +//! Beacon API error types + +use axum::{ + Json, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde::{Deserialize, Serialize}; +use std::{ + borrow::Cow, + fmt::{self, Display}, +}; + +/// Represents a Beacon API error response +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BeaconError { + /// HTTP status code + #[serde(skip)] + pub status_code: u16, + /// Error code + pub code: BeaconErrorCode, + /// Error message + pub message: Cow<'static, str>, +} + +impl BeaconError { + /// Creates a new beacon error with the given code + pub fn new(code: BeaconErrorCode, message: impl Into>) -> Self { + let status_code = code.status_code(); + Self { status_code, code, message: message.into() } + } + + /// Helper function to create a 400 Bad Request error for invalid block ID + pub fn invalid_block_id(block_id: impl Display) -> Self { + Self::new(BeaconErrorCode::BadRequest, format!("Invalid block ID: {block_id}")) + } + + /// Helper function to create a 404 Not Found error for block not found + pub fn block_not_found() -> Self { + Self::new(BeaconErrorCode::NotFound, "Block not found") + } + + /// Helper function to create a 500 Internal Server Error + pub fn internal_error() -> Self { + Self::new(BeaconErrorCode::InternalError, "Internal server error") + } + + /// Helper function to create a 410 Gone error for deprecated endpoints + pub fn deprecated_endpoint_with_hint(hint: impl Display) -> Self { + Self::new(BeaconErrorCode::Gone, format!("This endpoint is deprecated. {hint}")) + } + + /// Converts to an Axum response + pub fn into_response(self) -> Response { + let status = + StatusCode::from_u16(self.status_code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + + ( + status, + Json(serde_json::json!({ + "code": self.code as u16, + "message": self.message, + })), + ) + .into_response() + } +} + +impl fmt::Display for BeaconError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}: {}", self.code.as_str(), self.message) + } +} + +impl std::error::Error for BeaconError {} + +impl IntoResponse for BeaconError { + fn into_response(self) -> Response { + Self::into_response(self) + } +} + +/// Beacon API error codes following the beacon chain specification +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[repr(u16)] +pub enum BeaconErrorCode { + BadRequest = 400, + NotFound = 404, + Gone = 410, + InternalError = 500, +} + +impl BeaconErrorCode { + /// Returns the HTTP status code for this error + pub const fn status_code(&self) -> u16 { + *self as u16 + } + + /// Returns a string representation of the error code + pub const fn as_str(&self) -> &'static str { + match self { + Self::BadRequest => "Bad Request", + Self::NotFound => "Not Found", + Self::Gone => "Gone", + Self::InternalError => "Internal Server Error", + } + } +} + +impl fmt::Display for BeaconErrorCode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_beacon_error_codes() { + assert_eq!(BeaconErrorCode::BadRequest.status_code(), 400); + assert_eq!(BeaconErrorCode::NotFound.status_code(), 404); + assert_eq!(BeaconErrorCode::InternalError.status_code(), 500); + } + + #[test] + fn test_beacon_error_display() { + let err = BeaconError::invalid_block_id("current"); + assert_eq!(err.to_string(), "Bad Request: Invalid block ID: current"); + } +} diff --git a/crates/anvil/src/server/beacon/handlers.rs b/crates/anvil/src/server/beacon/handlers.rs new file mode 100644 index 0000000000000..73d533fa606c3 --- /dev/null +++ b/crates/anvil/src/server/beacon/handlers.rs @@ -0,0 +1,162 @@ +use super::{error::BeaconError, utils::must_be_ssz}; +use crate::eth::EthApi; +use alloy_eips::BlockId; +use alloy_network::Network; +use alloy_primitives::{B256, aliases::B32}; +use alloy_rpc_types_beacon::{ + genesis::{GenesisData, GenesisResponse}, + sidecar::GetBlobsResponse, +}; +use axum::{ + Json, + extract::{Path, Query, State}, + http::HeaderMap, + response::{IntoResponse, Response}, +}; +use ssz::Encode; +use std::{collections::HashMap, str::FromStr as _}; + +/// Handles incoming Beacon API requests for blob sidecars +/// +/// This endpoint is deprecated. Use `GET /eth/v1/beacon/blobs/{block_id}` instead. +/// +/// GET /eth/v1/beacon/blob_sidecars/{block_id} +pub async fn handle_get_blob_sidecars( + State(_api): State>, + Path(_block_id): Path, + Query(_params): Query>, +) -> Response { + BeaconError::deprecated_endpoint_with_hint("Use `GET /eth/v1/beacon/blobs/{block_id}` instead.") + .into_response() +} + +/// Handles incoming Beacon API requests for blobs +/// +/// GET /eth/v1/beacon/blobs/{block_id} +pub async fn handle_get_blobs( + headers: HeaderMap, + State(api): State>, + Path(block_id): Path, + Query(versioned_hashes): Query>, +) -> Response { + // Parse block_id from path parameter + let Ok(block_id) = BlockId::from_str(&block_id) else { + return BeaconError::invalid_block_id(block_id).into_response(); + }; + + // Parse versioned hashes from query parameters + // Supports comma-separated format: ?versioned_hashes=0x...,0x... + let versioned_hashes: Vec = match versioned_hashes.get("versioned_hashes") { + Some(s) => { + let mut hashes = Vec::new(); + for hash in s.split(',') { + let hash = hash.trim(); + if hash.is_empty() { + continue; + } + match B256::from_str(hash) { + Ok(h) => hashes.push(h), + Err(_) => { + return BeaconError::new( + super::error::BeaconErrorCode::BadRequest, + format!("Invalid versioned hash: {hash}"), + ) + .into_response(); + } + } + } + hashes + } + None => Vec::new(), + }; + + // Get the blob sidecars using existing EthApi logic + match api.anvil_get_blobs_by_block_id(block_id, versioned_hashes) { + Ok(Some(blobs)) => { + if must_be_ssz(&headers) { + blobs.as_ssz_bytes().into_response() + } else { + Json(GetBlobsResponse { + execution_optimistic: false, + finalized: false, + data: blobs, + }) + .into_response() + } + } + Ok(None) => BeaconError::block_not_found().into_response(), + Err(_) => BeaconError::internal_error().into_response(), + } +} + +/// Handles incoming Beacon API requests for genesis details +/// +/// Only returns the `genesis_time`, other fields are set to zero. +/// +/// GET /eth/v1/beacon/genesis +pub async fn handle_get_genesis(State(api): State>) -> Response { + match api.anvil_get_genesis_time() { + Ok(genesis_time) => Json(GenesisResponse { + data: GenesisData { + genesis_time, + genesis_validators_root: B256::ZERO, + genesis_fork_version: B32::ZERO, + }, + }) + .into_response(), + Err(_) => BeaconError::internal_error().into_response(), + } +} +#[cfg(test)] +mod tests { + use super::*; + use axum::http::HeaderValue; + + fn header_map_with_accept(accept: &str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert(axum::http::header::ACCEPT, HeaderValue::from_str(accept).unwrap()); + headers + } + + #[test] + fn test_must_be_ssz() { + let test_cases = vec![ + (None, false, "no Accept header"), + (Some("application/json"), false, "JSON only"), + (Some("application/octet-stream"), true, "octet-stream only"), + (Some("application/octet-stream;q=1.0,application/json;q=0.9"), true, "SSZ preferred"), + ( + Some("application/json;q=1.0,application/octet-stream;q=0.9"), + false, + "JSON preferred", + ), + (Some("application/octet-stream;q=0.5,application/json;q=0.5"), false, "equal quality"), + ( + Some("text/html;q=0.9, application/octet-stream;q=1.0, application/json;q=0.8"), + true, + "multiple types", + ), + ( + Some("application/octet-stream ; q=1.0 , application/json ; q=0.9"), + true, + "whitespace handling", + ), + (Some("application/octet-stream, application/json;q=0.9"), true, "default quality"), + ]; + + for (accept_header, expected, description) in test_cases { + let headers = match accept_header { + None => HeaderMap::new(), + Some(header) => header_map_with_accept(header), + }; + assert_eq!( + must_be_ssz(&headers), + expected, + "Test case '{}' failed: expected {}, got {}", + description, + expected, + !expected + ); + } + } +} diff --git a/crates/anvil/src/server/beacon/mod.rs b/crates/anvil/src/server/beacon/mod.rs new file mode 100644 index 0000000000000..f0138747c8b00 --- /dev/null +++ b/crates/anvil/src/server/beacon/mod.rs @@ -0,0 +1,22 @@ +//! Beacon Node REST API implementation for Anvil. + +use axum::{Router, routing::get}; + +use crate::eth::EthApi; +use alloy_network::Network; + +mod error; +mod handlers; +mod utils; + +/// Configures an [`axum::Router`] that handles Beacon REST API calls. +pub fn router(api: EthApi) -> Router { + Router::new() + .route( + "/eth/v1/beacon/blob_sidecars/{block_id}", + get(handlers::handle_get_blob_sidecars::), + ) + .route("/eth/v1/beacon/blobs/{block_id}", get(handlers::handle_get_blobs::)) + .route("/eth/v1/beacon/genesis", get(handlers::handle_get_genesis::)) + .with_state(api) +} diff --git a/crates/anvil/src/server/beacon/utils.rs b/crates/anvil/src/server/beacon/utils.rs new file mode 100644 index 0000000000000..b4851cbac9122 --- /dev/null +++ b/crates/anvil/src/server/beacon/utils.rs @@ -0,0 +1,36 @@ +use axum::http::header; +use hyper::HeaderMap; + +/// Helper function to determine if the Accept header indicates a preference for SSZ (octet-stream) +/// over JSON. +pub fn must_be_ssz(headers: &HeaderMap) -> bool { + headers.get(header::ACCEPT).and_then(|v| v.to_str().ok()).is_some_and(|accept_str| { + let mut octet_stream_q = 0.0; + let mut json_q = 0.0; + + // Parse each media type in the Accept header + for media_type in accept_str.split(',') { + let media_type = media_type.trim(); + let quality = media_type + .split(';') + .find_map(|param| { + let param = param.trim(); + if let Some(q) = param.strip_prefix("q=") { + q.parse::().ok() + } else { + None + } + }) + .unwrap_or(1.0); // Default quality factor is 1.0 + + if media_type.starts_with("application/octet-stream") { + octet_stream_q = quality; + } else if media_type.starts_with("application/json") { + json_q = quality; + } + } + + // Prefer octet-stream if it has higher quality factor + octet_stream_q > json_q + }) +} diff --git a/crates/anvil/src/server/handler.rs b/crates/anvil/src/server/handler.rs deleted file mode 100644 index e880e5e5628a9..0000000000000 --- a/crates/anvil/src/server/handler.rs +++ /dev/null @@ -1,141 +0,0 @@ -//! Contains RPC handlers -use crate::{ - EthApi, - eth::error::to_rpc_result, - pubsub::{EthSubscription, LogsSubscription}, -}; -use alloy_rpc_types::{ - FilteredParams, - pubsub::{Params, SubscriptionKind}, -}; -use anvil_core::eth::{EthPubSub, EthRequest, EthRpcCall, subscription::SubscriptionId}; -use anvil_rpc::{error::RpcError, response::ResponseResult}; -use anvil_server::{PubSubContext, PubSubRpcHandler, RpcHandler}; - -/// A `RpcHandler` that expects `EthRequest` rpc calls via http -#[derive(Clone)] -pub struct HttpEthRpcHandler { - /// Access to the node - api: EthApi, -} - -impl HttpEthRpcHandler { - /// Creates a new instance of the handler using the given `EthApi` - pub fn new(api: EthApi) -> Self { - Self { api } - } -} - -#[async_trait::async_trait] -impl RpcHandler for HttpEthRpcHandler { - type Request = EthRequest; - - async fn on_request(&self, request: Self::Request) -> ResponseResult { - self.api.execute(request).await - } -} - -/// A `RpcHandler` that expects `EthRequest` rpc calls and `EthPubSub` via pubsub connection -#[derive(Clone)] -pub struct PubSubEthRpcHandler { - /// Access to the node - api: EthApi, -} - -impl PubSubEthRpcHandler { - /// Creates a new instance of the handler using the given `EthApi` - pub fn new(api: EthApi) -> Self { - Self { api } - } - - /// Invoked for an ethereum pubsub rpc call - async fn on_pub_sub(&self, pubsub: EthPubSub, cx: PubSubContext) -> ResponseResult { - let id = SubscriptionId::random_hex(); - trace!(target: "rpc::ws", "received pubsub request {:?}", pubsub); - match pubsub { - EthPubSub::EthUnSubscribe(id) => { - trace!(target: "rpc::ws", "canceling subscription {:?}", id); - let canceled = cx.remove_subscription(&id).is_some(); - ResponseResult::Success(canceled.into()) - } - EthPubSub::EthSubscribe(kind, raw_params) => { - let filter = match &*raw_params { - Params::None => None, - Params::Logs(filter) => Some(filter.clone()), - Params::Bool(_) => None, - }; - let params = FilteredParams::new(filter.map(|b| *b)); - - let subscription = match kind { - SubscriptionKind::Logs => { - if raw_params.is_bool() { - return ResponseResult::Error(RpcError::invalid_params( - "Expected params for logs subscription", - )); - } - - trace!(target: "rpc::ws", "received logs subscription {:?}", params); - let blocks = self.api.new_block_notifications(); - let storage = self.api.storage_info(); - EthSubscription::Logs(Box::new(LogsSubscription { - blocks, - storage, - filter: params, - queued: Default::default(), - id: id.clone(), - })) - } - SubscriptionKind::NewHeads => { - trace!(target: "rpc::ws", "received header subscription"); - let blocks = self.api.new_block_notifications(); - let storage = self.api.storage_info(); - EthSubscription::Header(blocks, storage, id.clone()) - } - SubscriptionKind::NewPendingTransactions => { - trace!(target: "rpc::ws", "received pending transactions subscription"); - match *raw_params { - Params::Bool(true) => EthSubscription::FullPendingTransactions( - self.api.full_pending_transactions(), - id.clone(), - ), - Params::Bool(false) | Params::None => { - EthSubscription::PendingTransactions( - self.api.new_ready_transactions(), - id.clone(), - ) - } - _ => { - return ResponseResult::Error(RpcError::invalid_params( - "Expected boolean parameter for newPendingTransactions", - )); - } - } - } - SubscriptionKind::Syncing => { - return RpcError::internal_error_with("Not implemented").into(); - } - }; - - cx.add_subscription(id.clone(), subscription); - - trace!(target: "rpc::ws", "created new subscription: {:?}", id); - to_rpc_result(id) - } - } - } -} - -#[async_trait::async_trait] -impl PubSubRpcHandler for PubSubEthRpcHandler { - type Request = EthRpcCall; - type SubscriptionId = SubscriptionId; - type Subscription = EthSubscription; - - async fn on_request(&self, request: Self::Request, cx: PubSubContext) -> ResponseResult { - trace!(target: "rpc", "received pubsub request {:?}", request); - match request { - EthRpcCall::Request(request) => self.api.execute(*request).await, - EthRpcCall::PubSub(pubsub) => self.on_pub_sub(pubsub, cx).await, - } - } -} diff --git a/crates/anvil/src/server/mod.rs b/crates/anvil/src/server/mod.rs index 18e3edd8161ea..7be6fb6045de4 100644 --- a/crates/anvil/src/server/mod.rs +++ b/crates/anvil/src/server/mod.rs @@ -1,15 +1,17 @@ -//! Contains the code to launch an Ethereum RPC server. +//! This module provides the infrastructure to launch an Ethereum JSON-RPC server +//! (via HTTP, WebSocket, and IPC) and Beacon Node REST API. use crate::{EthApi, IpcTask}; use anvil_server::{ServerConfig, ipc::IpcEndpoint}; use axum::Router; +use foundry_primitives::FoundryNetwork; use futures::StreamExt; -use handler::{HttpEthRpcHandler, PubSubEthRpcHandler}; +use rpc_handlers::{HttpEthRpcHandler, PubSubEthRpcHandler}; use std::{io, net::SocketAddr, pin::pin}; -use tokio::net::TcpListener; +use tokio::{net::TcpListener, task::JoinSet}; -pub mod error; -mod handler; +mod beacon; +mod rpc_handlers; /// Configures a server that handles [`EthApi`] related JSON-RPC calls via HTTP and WS. /// @@ -17,7 +19,7 @@ mod handler; /// future that runs it. pub async fn serve( addr: SocketAddr, - api: EthApi, + api: EthApi, config: ServerConfig, ) -> io::Result>> { let tcp_listener = TcpListener::bind(addr).await?; @@ -27,17 +29,26 @@ pub async fn serve( /// Configures a server that handles [`EthApi`] related JSON-RPC calls via HTTP and WS. pub async fn serve_on( tcp_listener: TcpListener, - api: EthApi, + api: EthApi, config: ServerConfig, ) -> io::Result<()> { axum::serve(tcp_listener, router(api, config).into_make_service()).await } -/// Configures an [`axum::Router`] that handles [`EthApi`] related JSON-RPC calls via HTTP and WS. -pub fn router(api: EthApi, config: ServerConfig) -> Router { +/// Configures an [`axum::Router`] that handles [`EthApi`] related JSON-RPC calls via HTTP and WS, +/// and Beacon REST API calls. +pub fn router(api: EthApi, config: ServerConfig) -> Router { let http = HttpEthRpcHandler::new(api.clone()); - let ws = PubSubEthRpcHandler::new(api); - anvil_server::http_ws_router(config, http, ws) + let ws = PubSubEthRpcHandler::new(api.clone()); + + // JSON-RPC router + let rpc_router = anvil_server::http_ws_router(config, http, ws); + + // Beacon REST API router + let beacon_router = beacon::router(api); + + // Merge the routers + rpc_router.merge(beacon_router) } /// Launches an ipc server at the given path in a new task @@ -46,21 +57,32 @@ pub fn router(api: EthApi, config: ServerConfig) -> Router { /// /// Panics if setting up the IPC connection was unsuccessful. #[track_caller] -pub fn spawn_ipc(api: EthApi, path: String) -> IpcTask { +pub fn spawn_ipc(api: EthApi, path: String) -> IpcTask { try_spawn_ipc(api, path).expect("failed to establish ipc connection") } /// Launches an ipc server at the given path in a new task. -pub fn try_spawn_ipc(api: EthApi, path: String) -> io::Result { +pub fn try_spawn_ipc(api: EthApi, path: String) -> io::Result { let handler = PubSubEthRpcHandler::new(api); let ipc = IpcEndpoint::new(handler, path); let incoming = ipc.incoming()?; let task = tokio::task::spawn(async move { let mut incoming = pin!(incoming); - while let Some(stream) = incoming.next().await { - trace!(target: "ipc", "new ipc connection"); - tokio::task::spawn(stream); + let mut connections = JoinSet::new(); + loop { + tokio::select! { + stream = incoming.next() => { + let Some(stream) = stream else { break }; + trace!(target: "ipc", "new ipc connection"); + connections.spawn(stream); + } + result = connections.join_next(), if !connections.is_empty() => { + if let Some(Err(err)) = result { + warn!(target: "ipc", %err, "IPC connection task failed"); + } + } + } } }); diff --git a/crates/anvil/src/server/rpc_handlers.rs b/crates/anvil/src/server/rpc_handlers.rs new file mode 100644 index 0000000000000..abba9c708b21c --- /dev/null +++ b/crates/anvil/src/server/rpc_handlers.rs @@ -0,0 +1,162 @@ +//! Contains RPC handlers +use crate::{ + EthApi, + eth::error::to_rpc_result, + pubsub::{EthSubscription, LogsSubscription}, +}; +use alloy_rpc_types::{ + FilteredParams, + pubsub::{Params, SubscriptionKind}, +}; +use anvil_core::eth::{EthPubSub, EthRequest, EthRpcCall, subscription::SubscriptionId}; +use anvil_rpc::{error::RpcError, response::ResponseResult}; +use anvil_server::{PubSubContext, PubSubRpcHandler, RpcHandler}; +use foundry_primitives::FoundryNetwork; + +/// A `RpcHandler` that expects `EthRequest` rpc calls via http +#[derive(Clone)] +pub struct HttpEthRpcHandler { + /// Access to the node + api: EthApi, +} + +impl HttpEthRpcHandler { + /// Creates a new instance of the handler using the given `EthApi` + pub const fn new(api: EthApi) -> Self { + Self { api } + } +} + +impl RpcHandler for HttpEthRpcHandler { + type Request = EthRequest; + + fn on_request(&self, request: Self::Request) -> impl Future + Send { + self.api.execute(request) + } +} + +/// A `RpcHandler` that expects `EthRequest` rpc calls and `EthPubSub` via pubsub connection +#[derive(Clone)] +pub struct PubSubEthRpcHandler { + /// Access to the node + api: EthApi, +} + +impl PubSubEthRpcHandler { + /// Creates a new instance of the handler using the given `EthApi` + pub const fn new(api: EthApi) -> Self { + Self { api } + } + + /// Invoked for an ethereum pubsub rpc call + async fn on_pub_sub(&self, pubsub: EthPubSub, cx: PubSubContext) -> ResponseResult { + let id = SubscriptionId::random_hex(); + trace!(target: "rpc::ws", "received pubsub request {:?}", pubsub); + match pubsub { + EthPubSub::EthUnSubscribe(id) => { + trace!(target: "rpc::ws", "canceling subscription {:?}", id); + let canceled = cx.remove_subscription(&id).is_some(); + ResponseResult::Success(canceled.into()) + } + EthPubSub::EthSubscribe(kind, raw_params) => { + let filter = match &*raw_params { + Params::None => None, + Params::Logs(filter) => Some(filter.clone()), + Params::Bool(_) => None, + Params::TransactionReceipts(_) => None, + }; + let params = FilteredParams::new(filter.map(|b| *b)); + + let subscription = match kind { + SubscriptionKind::Logs => { + if raw_params.is_bool() { + return ResponseResult::Error(RpcError::invalid_params( + "Expected params for logs subscription", + )); + } + + trace!(target: "rpc::ws", "received logs subscription {:?}", params); + let blocks = self.api.new_block_notifications(); + let storage = self.api.storage_info(); + EthSubscription::Logs(Box::new(LogsSubscription { + blocks, + storage, + filter: params, + queued: Default::default(), + id: id.clone(), + })) + } + SubscriptionKind::NewHeads => { + trace!(target: "rpc::ws", "received header subscription"); + let blocks = self.api.new_block_notifications(); + let storage = self.api.storage_info(); + EthSubscription::Header(blocks, storage, id.clone()) + } + SubscriptionKind::NewPendingTransactions => { + trace!(target: "rpc::ws", "received pending transactions subscription"); + match *raw_params { + Params::Bool(true) => EthSubscription::FullPendingTransactions( + self.api.full_pending_transactions(), + id.clone(), + ), + Params::Bool(false) | Params::None => { + EthSubscription::PendingTransactions( + self.api.new_ready_transactions(), + id.clone(), + ) + } + _ => { + return ResponseResult::Error(RpcError::invalid_params( + "Expected boolean parameter for newPendingTransactions", + )); + } + } + } + SubscriptionKind::TransactionReceipts => { + trace!(target: "rpc::ws", "received transaction receipts subscription"); + match *raw_params { + Params::None => EthSubscription::TransactionReceipts( + self.api.transaction_receipts_subscription(Default::default()), + id.clone(), + ), + Params::TransactionReceipts(filter) => { + EthSubscription::TransactionReceipts( + self.api.transaction_receipts_subscription(filter), + id.clone(), + ) + } + _ => { + return ResponseResult::Error(RpcError::invalid_params( + "Expected transactionReceipts params", + )); + } + } + } + SubscriptionKind::Syncing => { + trace!(target: "rpc::ws", "received syncing subscription"); + EthSubscription::Syncing(Some(id.clone())) + } + }; + + cx.add_subscription(id.clone(), subscription); + + trace!(target: "rpc::ws", "created new subscription: {:?}", id); + to_rpc_result(id) + } + } + } +} + +impl PubSubRpcHandler for PubSubEthRpcHandler { + type Request = EthRpcCall; + type SubscriptionId = SubscriptionId; + type Subscription = EthSubscription; + + async fn on_request(&self, request: Self::Request, cx: PubSubContext) -> ResponseResult { + trace!(target: "rpc", "received pubsub request {:?}", request); + match request { + EthRpcCall::Request(request) => self.api.execute(*request).await, + EthRpcCall::PubSub(pubsub) => self.on_pub_sub(pubsub, cx).await, + } + } +} diff --git a/crates/anvil/src/service.rs b/crates/anvil/src/service.rs index 51b534c93b1d9..94873f69c2780 100644 --- a/crates/anvil/src/service.rs +++ b/crates/anvil/src/service.rs @@ -3,13 +3,15 @@ use crate::{ NodeResult, eth::{ - fees::FeeHistoryService, - miner::Miner, - pool::{Pool, transactions::PoolTransaction}, + backend::validate::TransactionValidator, error::BlockchainError, fees::FeeHistoryService, + miner::Miner, pool::Pool, }, filter::Filters, - mem::{Backend, storage::MinedBlockOutcome}, + mem::Backend, }; +use alloy_consensus::TxReceipt; +use alloy_network::Network; +use foundry_primitives::{FoundryReceiptEnvelope, FoundryTxEnvelope}; use futures::{FutureExt, Stream, StreamExt}; use std::{ collections::VecDeque, @@ -25,34 +27,41 @@ use tokio::{task::JoinHandle, time::Interval}; /// transactions for the next block, then those transactions are handed off to the backend to /// construct a new block, if all transactions were successfully included in a new block they get /// purged from the `Pool`. -pub struct NodeService { +pub struct NodeService +where + N::ReceiptEnvelope: TxReceipt, +{ /// The pool that holds all transactions. - pool: Arc, + pool: Arc>, /// Creates new blocks. - block_producer: BlockProducer, + block_producer: BlockProducer, /// The miner responsible to select transactions from the `pool`. - miner: Miner, + miner: Miner, /// Maintenance task for fee history related tasks. - fee_history: FeeHistoryService, + fee_history: FeeHistoryService, /// Tracks all active filters - filters: Filters, + filters: Filters, /// The interval at which to check for filters that need to be evicted filter_eviction_interval: Interval, } -impl NodeService { +impl NodeService +where + Backend: TransactionValidator, + N: Network, +{ pub fn new( - pool: Arc, - backend: Arc, - miner: Miner, - fee_history: FeeHistoryService, - filters: Filters, + pool: Arc>, + backend: Arc>, + miner: Miner, + fee_history: FeeHistoryService, + filters: Filters, ) -> Self { let start = tokio::time::Instant::now() + filters.keep_alive(); let filter_eviction_interval = tokio::time::interval_at(start, filters.keep_alive()); Self { + block_producer: BlockProducer::new(backend, pool.clone(), miner.clone()), pool, - block_producer: BlockProducer::new(backend), miner, fee_history, filter_eviction_interval, @@ -61,7 +70,11 @@ impl NodeService { } } -impl Future for NodeService { +impl Future for NodeService +where + Backend: TransactionValidator, + N: Network, +{ type Output = NodeResult<()>; fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { @@ -71,15 +84,26 @@ impl Future for NodeService { // producer loop { // advance block production until pending - while let Poll::Ready(Some(outcome)) = pin.block_producer.poll_next_unpin(cx) { - trace!(target: "node", "mined block {}", outcome.block_number); - // prune the transactions from the pool - pin.pool.on_mined_block(outcome); + while let Poll::Ready(Some(result)) = pin.block_producer.poll_next_unpin(cx) { + match result { + BlockProduction::Mined(block_number) => { + trace!(target: "node", "mined block {block_number}"); + } + BlockProduction::Failed(generation) => { + pin.miner.handle_failed_candidate(generation); + break; + } + BlockProduction::Skipped => {} + } } - if let Poll::Ready(transactions) = pin.miner.poll(&pin.pool, cx) { + // Do not select snapshots while another candidate is in flight. This leaves newer + // ready notifications in the miner so a failed candidate cannot discard their work. + if pin.block_producer.is_idle() + && let Poll::Ready(work) = pin.miner.poll(&pin.pool, cx) + { // miner returned a set of transaction that we feed to the producer - pin.block_producer.queued.push_back(transactions); + pin.block_producer.queued.push_back(work); } else { // no progress made break; @@ -100,25 +124,59 @@ impl Future for NodeService { } } +type MiningResult = (Result, BlockchainError>, Arc>, u64); + +enum BlockProduction { + Mined(u64), + Failed(u64), + Skipped, +} + /// A type that exclusively mines one block at a time #[must_use = "streams do nothing unless polled"] -struct BlockProducer { +struct BlockProducer { /// Holds the backend if no block is being mined - idle_backend: Option>, + idle_backend: Option>>, + /// Pool used to select transactions while holding the mining lock. + pool: Arc>, + /// Miner to wake when a mined block leaves ready transactions behind. + miner: Miner, /// Single active future that mines a new block - block_mining: Option)>>, + block_mining: Option>>, /// backlog of sets of transactions ready to be mined - queued: VecDeque>>, + queued: VecDeque>, } -impl BlockProducer { - fn new(backend: Arc) -> Self { - Self { idle_backend: Some(backend), block_mining: None, queued: Default::default() } +impl BlockProducer +where + Backend: TransactionValidator, + N: Network, +{ + fn new( + backend: Arc>, + pool: Arc>, + miner: Miner, + ) -> Self { + Self { + idle_backend: Some(backend), + pool, + miner, + block_mining: None, + queued: Default::default(), + } + } + + fn is_idle(&self) -> bool { + self.idle_backend.is_some() && self.block_mining.is_none() && self.queued.is_empty() } } -impl Stream for BlockProducer { - type Item = MinedBlockOutcome; +impl Stream for BlockProducer +where + Backend: TransactionValidator + Send + Sync + 'static, + N: Network + 'static, +{ + type Item = BlockProduction; fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { let pin = self.get_mut(); @@ -126,17 +184,35 @@ impl Stream for BlockProducer { if !pin.queued.is_empty() { // only spawn a building task if there's none in progress already if let Some(backend) = pin.idle_backend.take() { - let transactions = pin.queued.pop_front().expect("not empty; qed"); + let work = pin.queued.pop_front().expect("not empty; qed"); + let generation = work.generation; + let selected = work.transactions.len(); + let pool = pin.pool.clone(); + let miner = pin.miner.clone(); // we spawn this on as blocking task because this can be blocking for a while in // forking mode, because of all the rpc calls to fetch the required state let handle = tokio::runtime::Handle::current(); let mining = tokio::task::spawn_blocking(move || { handle.block_on(async move { + let mining_guard = backend.lock_mining_owned().await; + let transactions = + pool.ready_transactions().take(selected).collect::>(); + if selected > 0 && transactions.is_empty() { + return (Ok(None), backend, generation); + } trace!(target: "miner", "creating new block"); - let block = backend.mine_block(transactions).await; - trace!(target: "miner", "created new block: {}", block.block_number); - (block, backend) + let result = + backend.mine_block_locked(transactions, false).await.map(|outcome| { + let block_number = outcome.block_number; + if pool.on_mined_block(outcome) { + miner.retry_ready_transactions(); + } + trace!(target: "miner", "created new block: {block_number}"); + Some(block_number) + }); + drop(mining_guard); + (result, backend, generation) }) }); pin.block_mining = Some(mining); @@ -146,17 +222,26 @@ impl Stream for BlockProducer { if let Some(mut mining) = pin.block_mining.take() { if let Poll::Ready(res) = mining.poll_unpin(cx) { return match res { - Ok((outcome, backend)) => { + Ok((Ok(Some(block_number)), backend, _)) => { + pin.idle_backend = Some(backend); + Poll::Ready(Some(BlockProduction::Mined(block_number))) + } + Ok((Ok(None), backend, _)) => { + pin.idle_backend = Some(backend); + Poll::Ready(Some(BlockProduction::Skipped)) + } + Ok((Err(error), backend, generation)) => { pin.idle_backend = Some(backend); - Poll::Ready(Some(outcome)) + pin.queued.clear(); + warn!(target: "miner", %error, "failed to finalize block"); + Poll::Ready(Some(BlockProduction::Failed(generation))) } Err(err) => { panic!("miner task failed: {err}"); } }; - } else { - pin.block_mining = Some(mining) } + pin.block_mining = Some(mining) } Poll::Pending diff --git a/crates/anvil/src/tasks/block_listener.rs b/crates/anvil/src/tasks/block_listener.rs index 0dc5cb1dbaf6c..d9cb562b135cb 100644 --- a/crates/anvil/src/tasks/block_listener.rs +++ b/crates/anvil/src/tasks/block_listener.rs @@ -7,11 +7,15 @@ use std::{ task::{Context, Poll}, }; -/// A Future that will execute a given `task` for each new block that +/// A future that executes a given `task` for the latest block available on the stream. +/// +/// Available blocks are coalesced and the latest block is processed after the active task +/// completes. pub struct BlockListener { stream: St, task_factory: F, task: Option>>, + stream_done: bool, on_shutdown: Shutdown, } @@ -20,8 +24,8 @@ where St: Stream, F: Fn(::Item) -> Fut, { - pub fn new(on_shutdown: Shutdown, block_stream: St, task_factory: F) -> Self { - Self { stream: block_stream, task_factory, task: None, on_shutdown } + pub const fn new(on_shutdown: Shutdown, block_stream: St, task_factory: F) -> Self { + Self { stream: block_stream, task_factory, task: None, stream_done: false, on_shutdown } } } @@ -40,14 +44,28 @@ where return Poll::Ready(()); } + if let Some(mut task) = pin.task.take() + && task.poll_unpin(cx).is_pending() + { + pin.task = Some(task); + return Poll::Pending; + } + + if pin.stream_done { + return Poll::Ready(()); + } + let mut block = None; // drain the stream - while let Poll::Ready(maybe_block) = pin.stream.poll_next_unpin(cx) { - if maybe_block.is_none() { - // stream complete - return Poll::Ready(()); + loop { + match pin.stream.poll_next_unpin(cx) { + Poll::Ready(Some(next_block)) => block = Some(next_block), + Poll::Ready(None) => { + pin.stream_done = true; + break; + } + Poll::Pending => break, } - block = maybe_block; } if let Some(block) = block { @@ -59,6 +77,56 @@ where { pin.task = Some(task); } - Poll::Pending + + if pin.stream_done && pin.task.is_none() { Poll::Ready(()) } else { Poll::Pending } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::shutdown; + use futures::{ + channel::{mpsc, oneshot}, + task::noop_waker, + }; + use parking_lot::Mutex; + use std::sync::Arc; + + #[test] + fn waits_for_active_task_before_processing_latest_block() { + let (_signal, shutdown) = shutdown::signal(); + let (tx, rx) = mpsc::unbounded(); + let (release_tx, release_rx) = oneshot::channel(); + let release_rx = Arc::new(Mutex::new(Some(release_rx))); + let processed = Arc::new(Mutex::new(Vec::new())); + let task_release_rx = release_rx; + let task_processed = processed.clone(); + let mut listener = Box::pin(BlockListener::new(shutdown, rx, move |block| { + let release_rx = task_release_rx.clone(); + let processed = task_processed.clone(); + async move { + let release_rx = (block == 1).then(|| release_rx.lock().take().unwrap()); + if let Some(release_rx) = release_rx { + let _ = release_rx.await; + } + processed.lock().push(block); + } + })); + let waker = noop_waker(); + let mut cx = Context::from_waker(&waker); + + tx.unbounded_send(1).unwrap(); + assert!(listener.as_mut().poll(&mut cx).is_pending()); + + tx.unbounded_send(2).unwrap(); + tx.unbounded_send(3).unwrap(); + assert!(listener.as_mut().poll(&mut cx).is_pending()); + assert!(processed.lock().is_empty()); + + drop(tx); + release_tx.send(()).unwrap(); + assert!(listener.as_mut().poll(&mut cx).is_ready()); + assert_eq!(*processed.lock(), [1, 3]); } } diff --git a/crates/anvil/src/tasks/mod.rs b/crates/anvil/src/tasks/mod.rs index 55496bb9e681d..e460af7b41c6c 100644 --- a/crates/anvil/src/tasks/mod.rs +++ b/crates/anvil/src/tasks/mod.rs @@ -3,10 +3,12 @@ #![allow(rustdoc::private_doc_tests)] use crate::{EthApi, shutdown::Shutdown, tasks::block_listener::BlockListener}; -use alloy_network::{AnyHeader, AnyNetwork}; +use alloy_consensus::BlockHeader; +use alloy_network::{BlockResponse, Network}; use alloy_primitives::B256; use alloy_provider::Provider; use alloy_rpc_types::anvil::Forking; +use foundry_primitives::FoundryNetwork; use futures::StreamExt; use std::fmt; use tokio::{runtime::Handle, task::JoinHandle}; @@ -24,7 +26,7 @@ pub struct TaskManager { impl TaskManager { /// Creates a new instance of the task manager - pub fn new(tokio_handle: Handle, on_shutdown: Shutdown) -> Self { + pub const fn new(tokio_handle: Handle, on_shutdown: Shutdown) -> Self { Self { tokio_handle, on_shutdown } } @@ -38,16 +40,21 @@ impl TaskManager { self.tokio_handle.spawn(task) } - /// Spawns the blocking task. - pub fn spawn_blocking(&self, task: impl Future + Send + 'static) { + /// Spawns the blocking task and returns a handle to it. + /// + /// Returning the `JoinHandle` allows callers to cancel the task or await its completion. + pub fn spawn_blocking( + &self, + task: impl Future + Send + 'static, + ) -> JoinHandle<()> { let handle = self.tokio_handle.clone(); self.tokio_handle.spawn_blocking(move || { handle.block_on(task); - }); + }) } - /// Spawns a new task that listens for new blocks and resets the forked provider for every new - /// block + /// Spawns a new task that listens for new blocks and resets the forked provider to the latest + /// available block. /// /// ``` /// use alloy_network::Ethereum; @@ -58,14 +65,15 @@ impl TaskManager { /// let endpoint = "http://...."; /// let (api, handle) = spawn(NodeConfig::default().with_eth_rpc_url(Some(endpoint))).await; /// - /// let provider = RootProvider::connect_builtin(endpoint).await.unwrap(); + /// let provider = RootProvider::connect(endpoint).await.unwrap(); /// - /// handle.task_manager().spawn_reset_on_new_polled_blocks(provider, api); + /// handle.task_manager().spawn_reset_on_new_polled_blocks::(provider, api); /// # } /// ``` - pub fn spawn_reset_on_new_polled_blocks

(&self, provider: P, api: EthApi) + pub fn spawn_reset_on_new_polled_blocks(&self, provider: P, api: EthApi) where - P: Provider + Clone + Unpin + 'static, + N: Network, + P: Provider + Clone + Unpin + 'static, { self.spawn_block_poll_listener(provider.clone(), move |hash| { let provider = provider.clone(); @@ -75,7 +83,7 @@ impl TaskManager { let _ = api .anvil_reset(Some(Forking { json_rpc_url: None, - block_number: Some(block.header.number), + block_number: Some(block.header().number()), })) .await; } @@ -84,11 +92,12 @@ impl TaskManager { } /// Spawns a new [`BlockListener`] task that listens for new blocks (poll-based) See also - /// [`Provider::watch_blocks`] and executes the future the `task_factory` returns for the new - /// block hash - pub fn spawn_block_poll_listener(&self, provider: P, task_factory: F) + /// [`Provider::watch_blocks`] and executes the future the `task_factory` returns for the latest + /// available block hash. + pub fn spawn_block_poll_listener(&self, provider: P, task_factory: F) where - P: Provider + 'static, + N: Network, + P: Provider + 'static, F: Fn(B256) -> Fut + Unpin + Send + Sync + 'static, Fut: Future + Send, { @@ -104,8 +113,8 @@ impl TaskManager { }); } - /// Spawns a new task that listens for new blocks and resets the forked provider for every new - /// block + /// Spawns a new task that listens for new blocks and resets the forked provider to the latest + /// available block. /// /// ``` /// use alloy_network::Ethereum; @@ -115,23 +124,24 @@ impl TaskManager { /// # async fn t() { /// let (api, handle) = spawn(NodeConfig::default().with_eth_rpc_url(Some("http://...."))).await; /// - /// let provider = RootProvider::connect_builtin("ws://...").await.unwrap(); + /// let provider = RootProvider::connect("ws://...").await.unwrap(); /// - /// handle.task_manager().spawn_reset_on_subscribed_blocks(provider, api); + /// handle.task_manager().spawn_reset_on_subscribed_blocks::(provider, api); /// /// # } /// ``` - pub fn spawn_reset_on_subscribed_blocks

(&self, provider: P, api: EthApi) + pub fn spawn_reset_on_subscribed_blocks(&self, provider: P, api: EthApi) where - P: Provider + 'static, + N: Network, + P: Provider + 'static, { - self.spawn_block_subscription(provider, move |header| { + self.spawn_block_subscription(provider, move |header: N::HeaderResponse| { let api = api.clone(); async move { let _ = api .anvil_reset(Some(Forking { json_rpc_url: None, - block_number: Some(header.number), + block_number: Some(header.number()), })) .await; } @@ -140,11 +150,12 @@ impl TaskManager { /// Spawns a new [`BlockListener`] task that listens for new blocks (via subscription) See also /// [`Provider::subscribe_blocks()`] and executes the future the `task_factory` returns for the - /// new block hash - pub fn spawn_block_subscription(&self, provider: P, task_factory: F) + /// latest available block. + pub fn spawn_block_subscription(&self, provider: P, task_factory: F) where - P: Provider + 'static, - F: Fn(alloy_rpc_types::Header) -> Fut + Unpin + Send + Sync + 'static, + N: Network, + P: Provider + 'static, + F: Fn(N::HeaderResponse) -> Fut + Unpin + Send + Sync + 'static, Fut: Future + Send, { let shutdown = self.on_shutdown.clone(); diff --git a/crates/anvil/test-data/state-dump-legacy-stress.json b/crates/anvil/test-data/state-dump-legacy-stress.json index b7a18c94cad40..fa51109c2fffc 100644 --- a/crates/anvil/test-data/state-dump-legacy-stress.json +++ b/crates/anvil/test-data/state-dump-legacy-stress.json @@ -1 +1 @@ -{"block":{"number":5,"beneficiary":"0x0000000000000000000000000000000000000000","timestamp":1722941643,"gas_limit":30000000,"basefee":316710010,"difficulty":"0x0","prevrandao":"0xe7ef87fc7c2090741a6749a087e4ca8092cb4d07136008799e4ebeac3b69e34a","blob_excess_gas_and_price":{"excess_blob_gas":0,"blob_gasprice":1}},"accounts":{"0x0000000000000000000000000000000000000000":{"nonce":0,"balance":"0x1088aa62285a00","code":"0x","storage":{}},"0x108f53faf774d7c4c56f5bce9ca6e605ce8aeadd":{"nonce":1,"balance":"0x0","code":"0x6080604052600080357fffffffff0000000000000000000000000000000000000000000000000000000016905060008160e01c610251565b60006379ba509782101561015e5781631627540c811461009857632a952b2d81146100b457633659cfe681146100d0576350c946fe81146100ec576353a47bb781146101085763625ca21c81146101245763718fe928811461014057610158565b7347d08dad17ccb558b3ea74b1a0e73a9cc804a9dc9150610158565b738138ef7cf908021d117e542120b7a390650161079150610158565b7347d08dad17ccb558b3ea74b1a0e73a9cc804a9dc9150610158565b738138ef7cf908021d117e542120b7a390650161079150610158565b7347d08dad17ccb558b3ea74b1a0e73a9cc804a9dc9150610158565b738138ef7cf908021d117e542120b7a390650161079150610158565b7347d08dad17ccb558b3ea74b1a0e73a9cc804a9dc91505b5061024c565b816379ba509781146101a657638da5cb5b81146101c25763aaf10f4281146101de5763c7f62cda81146101fa5763daa250be81146102165763deba1b9881146102325761024a565b7347d08dad17ccb558b3ea74b1a0e73a9cc804a9dc915061024a565b7347d08dad17ccb558b3ea74b1a0e73a9cc804a9dc915061024a565b7347d08dad17ccb558b3ea74b1a0e73a9cc804a9dc915061024a565b7347d08dad17ccb558b3ea74b1a0e73a9cc804a9dc915061024a565b738138ef7cf908021d117e542120b7a39065016107915061024a565b738138ef7cf908021d117e542120b7a3906501610791505b505b919050565b61025a81610037565b915050600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff16036102ce57816040517fc2a825f50000000000000000000000000000000000000000000000000000000081526004016102c5919061032f565b60405180910390fd5b3660008037600080366000845af43d6000803e80600081146102ef573d6000f35b3d6000fd5b60007fffffffff0000000000000000000000000000000000000000000000000000000082169050919050565b610329816102f4565b82525050565b60006020820190506103446000830184610320565b9291505056fea264697066735822122017a4b7fdaaab3897a7b47abaed8d2ee92d558883d3bb2a8454f9601b2ab2c3db64736f6c63430008150033","storage":{}},"0x14dc79964da2c08b23698b3d3cc7ca32193d9955":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0x15d34aaf54267db7d7c367839aaf71a00a2c6a65":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0x19ba1fac55eea44d12a01372a8eb0c2ebbf9ca21":{"nonce":1,"balance":"0x21e19df7c2963f0ac6b","code":"0x","storage":{}},"0x19c6ab860dbe2bc433574193a4409770a8748bf6":{"nonce":1,"balance":"0x21e19df8da6b7bdc410","code":"0x","storage":{}},"0x23618e81e3f5cdf7f54c3d65f7fbc0abf5b21e8f":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0x40567ec443c1d1872af5155755ac3803cc3fe61e":{"nonce":1,"balance":"0x21e19da82562f921b40","code":"0x","storage":{}},"0x47d08dad17ccb558b3ea74b1a0e73a9cc804a9dc":{"nonce":1,"balance":"0x0","code":"0x608060405234801561001057600080fd5b50600436106100885760003560e01c806379ba50971161005b57806379ba5097146100ed5780638da5cb5b146100f7578063aaf10f4214610115578063c7f62cda1461013357610088565b80631627540c1461008d5780633659cfe6146100a957806353a47bb7146100c5578063718fe928146100e3575b600080fd5b6100a760048036038101906100a29190610d25565b61014f565b005b6100c360048036038101906100be9190610d25565b6102d0565b005b6100cd6102e4565b6040516100da9190610d61565b60405180910390f35b6100eb610317565b005b6100f56103fe565b005b6100ff61058b565b60405161010c9190610d61565b60405180910390f35b61011d6105be565b60405161012a9190610d61565b60405180910390f35b61014d60048036038101906101489190610d25565b6105f1565b005b61015761084c565b600061016161081b565b9050600073ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff16036101c9576040517fd92e233d00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b8060010160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1603610252576040517fa88ee57700000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b818160010160006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055507f906a1c6bd7e3091ea86693dd029a831c19049ce77f1dce2ce0bab1cacbabce22826040516102c49190610d61565b60405180910390a15050565b6102d861084c565b6102e1816108c5565b50565b60006102ee61081b565b60010160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16905090565b600061032161081b565b90503373ffffffffffffffffffffffffffffffffffffffff168160010160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16146103b757336040517fa0e5a0d70000000000000000000000000000000000000000000000000000000081526004016103ae9190610d61565b60405180910390fd5b60008160010160006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff16021790555050565b600061040861081b565b905060008160010160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1690508073ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff16146104a357336040517fa0e5a0d700000000000000000000000000000000000000000000000000000000815260040161049a9190610d61565b60405180910390fd5b7fb532073b38c83145e3e5135377a08bf9aab55bc0fd7c1179cd4fb995d2a5159c8260000160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16826040516104f8929190610d7c565b60405180910390a1808260000160006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff16021790555060008260010160006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055505050565b600061059561081b565b60000160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16905090565b60006105c8610b05565b60000160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16905090565b60006105fb610b05565b905060018160000160146101000a81548160ff02191690831515021790555060008160000160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff169050828260000160006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff16021790555060008373ffffffffffffffffffffffffffffffffffffffff163073ffffffffffffffffffffffffffffffffffffffff16633659cfe6846040516024016106cc9190610d61565b604051602081830303815290604052915060e01b6020820180517bffffffffffffffffffffffffffffffffffffffffffffffffffffffff838183161783525050505060405161071b9190610e16565b600060405180830381855af49150503d8060008114610756576040519150601f19603f3d011682016040523d82523d6000602084013e61075b565b606091505b505090508015806107c357508173ffffffffffffffffffffffffffffffffffffffff16610786610b05565b60000160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614155b156107fa576040517fa1cfa5a800000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b60008360000160146101000a81548160ff0219169083151502179055600080fd5b60008060405160200161082d90610eb0565b6040516020818303038152906040528051906020012090508091505090565b610854610b36565b73ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff16146108c357336040517f8e4a23d60000000000000000000000000000000000000000000000000000000081526004016108ba9190610d61565b60405180910390fd5b565b600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff160361092b576040517fd92e233d00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b61093481610b69565b61097557806040517f8a8b41ec00000000000000000000000000000000000000000000000000000000815260040161096c9190610d61565b60405180910390fd5b600061097f610b05565b90508060000160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1603610a0a576040517fa88ee57700000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b8060000160149054906101000a900460ff16158015610a2e5750610a2d82610b7c565b5b15610a7057816040517f15504301000000000000000000000000000000000000000000000000000000008152600401610a679190610d61565b60405180910390fd5b818160000160006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055503073ffffffffffffffffffffffffffffffffffffffff167f5d611f318680d00598bb735d61bacf0c514c6b50e1e5ad30040a4df2b12791c783604051610af99190610d61565b60405180910390a25050565b600080604051602001610b1790610f42565b6040516020818303038152906040528051906020012090508091505090565b6000610b4061081b565b60000160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16905090565b600080823b905060008111915050919050565b60008060003073ffffffffffffffffffffffffffffffffffffffff163073ffffffffffffffffffffffffffffffffffffffff1663c7f62cda86604051602401610bc59190610d61565b604051602081830303815290604052915060e01b6020820180517bffffffffffffffffffffffffffffffffffffffffffffffffffffffff8381831617835250505050604051610c149190610e16565b600060405180830381855af49150503d8060008114610c4f576040519150601f19603f3d011682016040523d82523d6000602084013e610c54565b606091505b509150915081158015610cb9575063a1cfa5a860e01b604051602001610c7a9190610faf565b6040516020818303038152906040528051906020012081604051602001610ca19190610e16565b60405160208183030381529060405280519060200120145b92505050919050565b600080fd5b600073ffffffffffffffffffffffffffffffffffffffff82169050919050565b6000610cf282610cc7565b9050919050565b610d0281610ce7565b8114610d0d57600080fd5b50565b600081359050610d1f81610cf9565b92915050565b600060208284031215610d3b57610d3a610cc2565b5b6000610d4984828501610d10565b91505092915050565b610d5b81610ce7565b82525050565b6000602082019050610d766000830184610d52565b92915050565b6000604082019050610d916000830185610d52565b610d9e6020830184610d52565b9392505050565b600081519050919050565b600081905092915050565b60005b83811015610dd9578082015181840152602081019050610dbe565b60008484015250505050565b6000610df082610da5565b610dfa8185610db0565b9350610e0a818560208601610dbb565b80840191505092915050565b6000610e228284610de5565b915081905092915050565b600082825260208201905092915050565b7f696f2e73796e7468657469782e636f72652d636f6e7472616374732e4f776e6160008201527f626c650000000000000000000000000000000000000000000000000000000000602082015250565b6000610e9a602383610e2d565b9150610ea582610e3e565b604082019050919050565b60006020820190508181036000830152610ec981610e8d565b9050919050565b7f696f2e73796e7468657469782e636f72652d636f6e7472616374732e50726f7860008201527f7900000000000000000000000000000000000000000000000000000000000000602082015250565b6000610f2c602183610e2d565b9150610f3782610ed0565b604082019050919050565b60006020820190508181036000830152610f5b81610f1f565b9050919050565b60007fffffffff0000000000000000000000000000000000000000000000000000000082169050919050565b6000819050919050565b610fa9610fa482610f62565b610f8e565b82525050565b6000610fbb8284610f98565b6004820191508190509291505056fea264697066735822122023a7c33d7b91dce35ffbcf8837693364ab22a3905d0fc00016833e5fac45ca2f64736f6c63430008110033","storage":{"0x5c7865864a2a990d80b5bb5c40e7b73a029960dc711fbb56120dfab976e92ea3":"0x0"}},"0x4e59b44847b379578588920ca78fbf26c0b4956c":{"nonce":2,"balance":"0x0","code":"0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe03601600081602082378035828234f58015156039578182fd5b8082525050506014600cf3","storage":{}},"0x70997970c51812dc3a010c7d01b50e0d17dc79c8":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0x8138ef7cf908021d117e542120b7a39065016107":{"nonce":1,"balance":"0x0","code":"0x608060405234801561001057600080fd5b50600436106100575760003560e01c80632a952b2d1461005c57806350c946fe14610085578063625ca21c146100a5578063daa250be146100c6578063deba1b98146100d9575b600080fd5b61006f61006a366004613a63565b6100ec565b60405161007c9190613a7c565b60405180910390f35b610098610093366004613a63565b61011c565b60405161007c9190613b21565b6100b86100b3366004613c92565b610276565b60405190815260200161007c565b61006f6100d4366004613d5f565b6102bb565b6100b86100e7366004613c92565b6102d6565b6100f46139e4565b6040805160008082526020820190815281830190925261011691849190610310565b92915050565b6101416040805160608101909152806000815260200160608152602001606081525090565b61014a82610ab6565b60408051606081019091528154909190829060ff16600981111561017057610170613aa7565b600981111561018157610181613aa7565b815260200160018201805461019590613dc2565b80601f01602080910402602001604051908101604052809291908181526020018280546101c190613dc2565b801561020e5780601f106101e35761010080835404028352916020019161020e565b820191906000526020600020905b8154815290600101906020018083116101f157829003601f168201915b505050505081526020016002820180548060200260200160405190810160405280929190818152602001828054801561026657602002820191906000526020600020905b815481526020019060010190808311610252575b5050505050815250509050919050565b600080604051806060016040528086600981111561029657610296613aa7565b81526020018581526020018481525090506102b081610ac1565b9150505b9392505050565b6102c36139e4565b6102ce848484610310565b949350505050565b60008060405180606001604052808660098111156102f6576102f6613aa7565b81526020018581526020018481525090506102b081610acc565b6103186139e4565b81518351146103a05760408051634bab873760e11b81526004810191909152600d60448201526c72756e74696d6556616c75657360981b606482015260806024820152602260848201527f6d7573742062652073616d65206c656e6774682061732072756e74696d654b6560a482015261797360f01b60c482015260e4015b60405180910390fd5b60006103ab85610c26565b805490915060ff1660018160098111156103c7576103c7613aa7565b036104755761046c6103da838787610c84565b8360010180546103e990613dc2565b80601f016020809104026020016040519081016040528092919081815260200182805461041590613dc2565b80156104625780601f1061043757610100808354040283529160200191610462565b820191906000526020600020905b81548152906001019060200180831161044557829003601f168201915b5050505050610d46565b925050506102b4565b600281600981111561048957610489613aa7565b036105305761046c61049c838787610c84565b8360010180546104ab90613dc2565b80601f01602080910402602001604051908101604052809291908181526020018280546104d790613dc2565b80156105245780601f106104f957610100808354040283529160200191610524565b820191906000526020600020905b81548152906001019060200180831161050757829003601f168201915b50505050508787610ebb565b600381600981111561054457610544613aa7565b036105de5761046c82600101805461055b90613dc2565b80601f016020809104026020016040519081016040528092919081815260200182805461058790613dc2565b80156105d45780601f106105a9576101008083540402835291602001916105d4565b820191906000526020600020905b8154815290600101906020018083116105b757829003601f168201915b5050505050610f59565b60048160098111156105f2576105f2613aa7565b0361068c5761046c82600101805461060990613dc2565b80601f016020809104026020016040519081016040528092919081815260200182805461063590613dc2565b80156106825780601f1061065757610100808354040283529160200191610682565b820191906000526020600020905b81548152906001019060200180831161066557829003601f168201915b5050505050611087565b60058160098111156106a0576106a0613aa7565b0361073a5761046c8260010180546106b790613dc2565b80601f01602080910402602001604051908101604052809291908181526020018280546106e390613dc2565b80156107305780601f1061070557610100808354040283529160200191610730565b820191906000526020600020905b81548152906001019060200180831161071357829003601f168201915b505050505061131e565b600981600981111561074e5761074e613aa7565b036107ea5761046c82600101805461076590613dc2565b80601f016020809104026020016040519081016040528092919081815260200182805461079190613dc2565b80156107de5780601f106107b3576101008083540402835291602001916107de565b820191906000526020600020905b8154815290600101906020018083116107c157829003601f168201915b505050505086866114b5565b60068160098111156107fe576107fe613aa7565b036108a35761046c610811838787610c84565b83600101805461082090613dc2565b80601f016020809104026020016040519081016040528092919081815260200182805461084c90613dc2565b80156108995780601f1061086e57610100808354040283529160200191610899565b820191906000526020600020905b81548152906001019060200180831161087c57829003601f168201915b50505050506115c7565b60078160098111156108b7576108b7613aa7565b036109ec576040805160608101909152825461046c91908490829060ff1660098111156108e6576108e6613aa7565b60098111156108f7576108f7613aa7565b815260200160018201805461090b90613dc2565b80601f016020809104026020016040519081016040528092919081815260200182805461093790613dc2565b80156109845780601f1061095957610100808354040283529160200191610984565b820191906000526020600020905b81548152906001019060200180831161096757829003601f168201915b50505050508152602001600282018054806020026020016040519081016040528092919081815260200182805480156109dc57602002820191906000526020600020905b8154815260200190600101908083116109c8575b5050505050815250508686611728565b6008816009811115610a0057610a00613aa7565b03610a9a5761046c826001018054610a1790613dc2565b80601f0160208091040260200160405190810160405280929190818152602001828054610a4390613dc2565b8015610a905780601f10610a6557610100808354040283529160200191610a90565b820191906000526020600020905b815481529060010190602001808311610a7357829003601f168201915b50505050506118a5565b6040516323a9bbc960e01b815260048101879052602401610397565b600061011682610c26565b6000610116826118ea565b6000610ad782610ac1565b9050610ae28161192a565b15610b35577fcb64985827770858ec421ad26da7e558c757541643036ce44d6b4eb9e8e5dc5e81836000015184602001518560400151604051610b289493929190613e32565b60405180910390a1919050565b610b3e82611a8c565b610b5d578160405163382bbbc960e11b81526004016103979190613b21565b60005b826040015151811015610bd957610b9383604001518281518110610b8657610b86613e6a565b602002602001015161192a565b610bd15782604001518181518110610bad57610bad613e6a565b6020026020010151604051632f19f96160e11b815260040161039791815260200190565b600101610b60565b50610be382611c31565b8351602085015160408087015190519395507fcb64985827770858ec421ad26da7e558c757541643036ce44d6b4eb9e8e5dc5e9450610b28938693929190613e32565b604080516020808201839052606082018190527f696f2e73796e7468657469782e6f7261636c652d6d616e616765722e4e6f6465608080840191909152828401949094528251808303909401845260a0909101909152815191012090565b600283015460609067ffffffffffffffff811115610ca457610ca4613b9a565b604051908082528060200260200182016040528015610cdd57816020015b610cca6139e4565b815260200190600190039081610cc25790505b50905060005b6002850154811015610d3e57610d19856002018281548110610d0757610d07613e6a565b90600052602060002001548585610310565b828281518110610d2b57610d2b613e6a565b6020908102919091010152600101610ce3565b509392505050565b610d4e6139e4565b600082806020019051810190610d649190613e80565b90506000816008811115610d7a57610d7a613aa7565b03610d9057610d8884611ca5565b915050610116565b6001816008811115610da457610da4613aa7565b03610db257610d8884611d0d565b6002816008811115610dc657610dc6613aa7565b03610dd457610d8884611d90565b6003816008811115610de857610de8613aa7565b03610df657610d8884611e13565b6004816008811115610e0a57610e0a613aa7565b03610e1857610d8884611ec9565b6005816008811115610e2c57610e2c613aa7565b03610e3a57610d8884612009565b6006816008811115610e4e57610e4e613aa7565b03610e5c57610d88846120e4565b6007816008811115610e7057610e70613aa7565b03610e7e57610d888461220c565b6008816008811115610e9257610e92613aa7565b03610ea057610d88846122ce565b80604051631be413d360e11b81526004016103979190613ea1565b610ec36139e4565b600084806020019051810190610ed99190613ed3565b604051631ecba7c360e31b81529091506001600160a01b0382169063f65d3e1890610f0e908990899089908990600401613ef0565b608060405180830381865afa158015610f2b573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190610f4f9190613f91565b9695505050505050565b610f616139e4565b600080600084806020019051810190610f7a9190613fe8565b92509250925060008390506000806000836001600160a01b031663feaf968c6040518163ffffffff1660e01b815260040160a060405180830381865afa158015610fc8573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190610fec9190614041565b509350509250925060008660001461100f5761100a8585858a6123c7565b611011565b825b905060128660ff161161103b5761103661102f60ff881660126140a7565b82906124c2565b611053565b61105361104c601260ff89166140a7565b82906124dc565b9050604051806080016040528082815260200183815260200160008152602001600081525098505050505050505050919050565b61108f6139e4565b600080600080600080878060200190518101906110ac91906140ba565b604080516002808252606082018352979d50959b50939950919750955093506000929060208301908036833701905050905081816000815181106110f2576110f2613e6a565b602002602001019063ffffffff16908163ffffffff168152505060008160018151811061112157611121613e6a565b63ffffffff9092166020928302919091019091015260405163883bdbfd60e01b81526000906001600160a01b0385169063883bdbfd90611165908590600401614143565b600060405180830381865afa158015611182573d6000803e3d6000fd5b505050506040513d6000823e601f3d908101601f191682016040526111aa91908101906141f5565b5090506000816000815181106111c2576111c2613e6a565b6020026020010151826001815181106111dd576111dd613e6a565b60200260200101516111ef91906142c1565b9050600061121761120563ffffffff87166124f6565b61120f9084614304565b60060b61252d565b905060008260060b12801561124c575061123b63ffffffff8616612569565b612569565b8260060b6112499190614342565b15155b1561125f578061125b81614356565b9150505b600061126d6012600a61445d565b9050600061128061123684848f8f612593565b905060006112908a60ff16612569565b61129c8c60ff16612569565b6112a6919061446c565b905060008082136112d1576112cc6112c56112c084614493565b612686565b84906124dc565b6112e4565b6112e46112dd83612686565b84906124c2565b905060405180608001604052808281526020014281526020016000815260200160008152509e505050505050505050505050505050919050565b6113266139e4565b60008060008480602001905181019061133f91906144bf565b91945092509050826000826113bc576040516396834ad360e01b8152600481018590526001600160a01b038316906396834ad390602401608060405180830381865afa158015611393573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906113b791906144f5565b611425565b604051639474f45b60e01b8152600481018590526001600160a01b03831690639474f45b90602401608060405180830381865afa158015611401573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061142591906144f5565b90506000816040015160030b601261143d919061456f565b90506000808213611467576114626114576112c084614493565b845160070b906124dc565b61147e565b61147e61147383612686565b845160070b906124c2565b9050604051806080016040528082815260200184606001518152602001600081526020016000815250975050505050505050919050565b6114bd6139e4565b6000806000868060200190518101906114d69190614597565b92509250925060005b8651811015611545578681815181106114fa576114fa613e6a565b6020026020010151717374616c656e657373546f6c6572616e636560701b0361153d5785818151811061152f5761152f613e6a565b602002602001015160001c91505b6001016114df565b5060408051600180825281830190925260009160208083019080368337019050509050828160008151811061157c5761157c613e6a565b602002602001018181525050836001838360405160200161159f939291906145ce565b60408051601f198184030181529082905263cf2cabdf60e01b82526103979291600401614603565b6115cf6139e4565b6000828060200190518101906115e59190614627565b90506000846000815181106115fc576115fc613e6a565b602002602001015160000151905060008560018151811061161f5761161f613e6a565b6020026020010151600001519050808214611702576000611653601261164d611648858761446c565b6126a9565b906124c2565b905082158061167b5750611666836126a9565b6116709082614640565b61167985612569565b125b15611700576002875111156116b0578660028151811061169d5761169d613e6a565b6020026020010151945050505050610116565b826000036116d15760405163014cc07160e01b815260040160405180910390fd5b6116da836126a9565b6116e49082614640565b60405163dcac091960e01b815260040161039791815260200190565b505b8560008151811061171557611715613e6a565b6020026020010151935050505092915050565b6117306139e4565b6000846020015180602001905181019061174a9190614627565b905060005b84518110156117bc5784818151811061176a5761176a613e6a565b6020026020010151717374616c656e657373546f6c6572616e636560701b036117b4576117ad8482815181106117a2576117a2613e6a565b602002602001015190565b91506117bc565b60010161174f565b50600085604001516000815181106117d6576117d6613e6a565b6020026020010151905060006117ed828787610310565b60208101519091506117ff84426140a7565b1161180e5792506102b4915050565b86604001515160010361187157866040015160008151811061183257611832613e6a565b602002602001015181600001518260200151604051631808066560e21b8152600401610397939291909283526020830191909152604082015260600190565b61189a876040015160018151811061188b5761188b613e6a565b60200260200101518787610310565b979650505050505050565b6118ad6139e4565b6040518060800160405280838060200190518101906118cc9190614627565b81526020014281526020016000815260200160008152509050919050565b600081600001518260200151836040015160405160200161190d9392919061466e565b604051602081830303815290604052805190602001209050919050565b60008061193683610c26565b60408051606081019091528154909190829060ff16600981111561195c5761195c613aa7565b600981111561196d5761196d613aa7565b815260200160018201805461198190613dc2565b80601f01602080910402602001604051908101604052809291908181526020018280546119ad90613dc2565b80156119fa5780601f106119cf576101008083540402835291602001916119fa565b820191906000526020600020905b8154815290600101906020018083116119dd57829003601f168201915b5050505050815260200160028201805480602002602001604051908101604052809291908181526020018280548015611a5257602002820191906000526020600020905b815481526020019060010190808311611a3e575b505050505081525050905060006009811115611a7057611a70613aa7565b81516009811115611a8357611a83613aa7565b14159392505050565b6000600182516009811115611aa357611aa3613aa7565b1480611ac15750600682516009811115611abf57611abf613aa7565b145b80611ade5750600782516009811115611adc57611adc613aa7565b145b15611aee57611aec826126c1565b505b600182516009811115611b0357611b03613aa7565b03611b11576101168261284a565b600282516009811115611b2657611b26613aa7565b03611b3457610116826128a5565b600382516009811115611b4957611b49613aa7565b03611b575761011682612973565b600482516009811115611b6c57611b6c613aa7565b03611b7a5761011682612aae565b600582516009811115611b8f57611b8f613aa7565b03611b9d5761011682612e92565b600982516009811115611bb257611bb2613aa7565b03611bc05761011682612fcb565b600682516009811115611bd557611bd5613aa7565b03611be3576101168261300e565b600782516009811115611bf857611bf8613aa7565b03611c065761011682613052565b600882516009811115611c1b57611c1b613aa7565b03611c295761011682613078565b506000919050565b600080611c3d836118ea565b9050611c4881610c26565b8351815491935090839060ff19166001836009811115611c6a57611c6a613aa7565b021790555060208301516001830190611c8390826146ed565b5060408301518051611c9f916002850191602090910190613a0c565b50915091565b611cad6139e4565b60005b8251811015611d07578160200151838281518110611cd057611cd0613e6a565b6020026020010151602001511115611cff57828181518110611cf457611cf4613e6a565b602002602001015191505b600101611cb0565b50919050565b611d156139e4565b81600081518110611d2857611d28613e6a565b602002602001015190506000600190505b8251811015611d07578160000151838281518110611d5957611d59613e6a565b6020026020010151600001511215611d8857828181518110611d7d57611d7d613e6a565b602002602001015191505b600101611d39565b611d986139e4565b81600081518110611dab57611dab613e6a565b602002602001015190506000600190505b8251811015611d07578160000151838281518110611ddc57611ddc613e6a565b6020026020010151600001511315611e0b57828181518110611e0057611e00613e6a565b602002602001015191505b600101611dbc565b611e1b6139e4565b60005b8251811015611e9557828181518110611e3957611e39613e6a565b60200260200101516000015182600001818151611e56919061456f565b9052508251839082908110611e6d57611e6d613e6a565b60200260200101516020015182602001818151611e8a91906147ad565b905250600101611e1e565b50611ea08251612569565b8151611eac9190614640565b815281516020820151611ebf91906147c0565b6020820152919050565b611ed16139e4565b611eed826000611ee86001865161123691906140a7565b6130a4565b60028251611efb91906147d4565b600003611fd65760408051600280825260608201909252600091816020015b611f226139e4565b815260200190600190039081611f1a57905050905082600160028551611f4891906147c0565b611f5291906140a7565b81518110611f6257611f62613e6a565b602002602001015181600081518110611f7d57611f7d613e6a565b60200260200101819052508260028451611f9791906147c0565b81518110611fa757611fa7613e6a565b602002602001015181600181518110611fc257611fc2613e6a565b60200260200101819052506102b481611e13565b8160028351611fe591906147c0565b81518110611ff557611ff5613e6a565b60200260200101519050919050565b919050565b6120116139e4565b8160008151811061202457612024613e6a565b60209081029190910101515181528151829060009061204557612045613e6a565b6020908102919091018101518101519082015260015b82518110156120d25782818151811061207657612076613e6a565b6020026020010151600001518260000181815161209391906147e8565b90525082518390829081106120aa576120aa613e6a565b602002602001015160200151826020018181516120c791906147ad565b90525060010161205b565b5081518160200151611ebf91906147c0565b6120ec6139e4565b816000815181106120ff576120ff613e6a565b60209081029190910101515181528151829060009061212057612120613e6a565b6020908102919091018101518101519082015260015b82518110156120d25782818151811061215157612151613e6a565b60200260200101516000015160000361219e5782818151811061217657612176613e6a565b6020026020010151600001516040516338ee04a760e01b815260040161039791815260200190565b8281815181106121b0576121b0613e6a565b602002602001015160000151826000018181516121cd9190614640565b90525082518390829081106121e4576121e4613e6a565b6020026020010151602001518260200181815161220191906147ad565b905250600101612136565b6122146139e4565b8160008151811061222757612227613e6a565b60209081029190910101515181528151829060009061224857612248613e6a565b6020908102919091018101518101519082015260015b82518110156120d25761229083828151811061227c5761227c613e6a565b602090810291909101015151835190613264565b825282518390829081106122a6576122a6613e6a565b602002602001015160200151826020018181516122c391906147ad565b90525060010161225e565b6122d66139e4565b816000815181106122e9576122e9613e6a565b60209081029190910101515181528151829060009061230a5761230a613e6a565b6020908102919091018101518101519082015260015b82518110156120d25782818151811061233b5761233b613e6a565b6020026020010151600001516000036123605782818151811061217657612176613e6a565b61238983828151811061237557612375613e6a565b602090810291909101015151835190613283565b8252825183908290811061239f5761239f613e6a565b602002602001015160200151826020018181516123bc91906147ad565b905250600101612320565b6000826001826123d785426140a7565b90505b69ffffffffffffffffffff8716156124a3576001600160a01b038816639a6fc8f561240489614818565b6040516001600160e01b031960e084901b16815269ffffffffffffffffffff8216600482015290995060240160a060405180830381865afa925050508015612469575060408051601f3d908101601f1916820190925261246691810190614041565b60015b156124a357858210156124805750505050506124a3565b61248a848961456f565b97508661249681614834565b97505050505050506123da565b6124ac82612569565b6124b69084614640565b98975050505050505050565b60006124d261123683600a61484d565b6102b490846147e8565b60006124ec61123683600a61484d565b6102b49084614640565b6000667fffffffffffff66ffffffffffffff83161115612529576040516329d2678160e21b815260040160405180910390fd5b5090565b6000627fffff19600683900b128061254b5750627fffff600683900b135b1561252957604051630d962f7960e21b815260040160405180910390fd5b60006001600160ff1b038211156125295760405163677c430560e11b815260040160405180910390fd5b60008061259f86613298565b90506fffffffffffffffffffffffffffffffff6001600160a01b0382161161261c5760006125d66001600160a01b03831680614859565b9050836001600160a01b0316856001600160a01b03161061260557612600600160c01b87836136cd565b612614565b6126148187600160c01b6136cd565b92505061267d565b600061263b6001600160a01b03831680680100000000000000006136cd565b9050836001600160a01b0316856001600160a01b03161061266a57612665600160801b87836136cd565b612679565b6126798187600160801b6136cd565b9250505b50949350505050565b6000808212156125295760405163029f024d60e31b815260040160405180910390fd5b600080821215612529576126bc82614493565b610116565b6000805b8260400151518110156128415760006126fa846040015183815181106126ed576126ed613e6a565b6020026020010151610ab6565b60408051606081019091528154909190829060ff16600981111561272057612720613aa7565b600981111561273157612731613aa7565b815260200160018201805461274590613dc2565b80601f016020809104026020016040519081016040528092919081815260200182805461277190613dc2565b80156127be5780601f10612793576101008083540402835291602001916127be565b820191906000526020600020905b8154815290600101906020018083116127a157829003601f168201915b505050505081526020016002820180548060200260200160405190810160405280929190818152602001828054801561281657602002820191906000526020600020905b815481526020019060010190808311612802575b505050505081525050905061282a81611a8c565b612838575060009392505050565b506001016126c5565b50600192915050565b60006002826040015151101561286257506000919050565b81602001515160201461287757506000919050565b600082602001518060200190518101906128919190614627565b905060088111156128415750600092915050565b6000602082602001515110156128bd57506000919050565b600082602001518060200190518101906128d79190613ed3565b90506128ea816306e7ea3960e21b6138e2565b6128f75750600092915050565b604051633b70a5bf60e21b81526001600160a01b0382169063edc296fc90612923908690600401613b21565b6020604051808303816000875af1158015612942573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906129669190614870565b6128415750600092915050565b6040810151516000901561298957506000919050565b81602001515160601461299e57506000919050565b60008083602001518060200190518101906129b99190613fe8565b92505091506000829050806001600160a01b031663feaf968c6040518163ffffffff1660e01b815260040160a060405180830381865afa158015612a01573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612a259190614041565b5050505050806001600160a01b031663313ce5676040518163ffffffff1660e01b8152600401602060405180830381865afa158015612a68573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612a8c919061488b565b60ff168260ff1614612aa357506000949350505050565b506001949350505050565b60408101515160009015612ac457506000919050565b81602001515160c014612ad957506000919050565b6000806000806000808760200151806020019051810190612afa91906140ba565b9550955095509550955095508360ff16866001600160a01b031663313ce5676040518163ffffffff1660e01b8152600401602060405180830381865afa158015612b48573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612b6c919061488b565b60ff1614612b8257506000979650505050505050565b8260ff16856001600160a01b031663313ce5676040518163ffffffff1660e01b8152600401602060405180830381865afa158015612bc4573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612be8919061488b565b60ff1614612bfe57506000979650505050505050565b6000826001600160a01b0316630dfe16816040518163ffffffff1660e01b8152600401602060405180830381865afa158015612c3e573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612c629190613ed3565b90506000836001600160a01b031663d21220a76040518163ffffffff1660e01b8152600401602060405180830381865afa158015612ca4573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612cc89190613ed3565b9050876001600160a01b0316826001600160a01b0316148015612cfc5750866001600160a01b0316816001600160a01b0316145b158015612d385750866001600160a01b0316826001600160a01b0316148015612d365750876001600160a01b0316816001600160a01b0316145b155b15612d4d575060009998505050505050505050565b60128660ff161180612d62575060128560ff16115b15612d77575060009998505050505050505050565b8263ffffffff16600003612d95575060009998505050505050505050565b6040805160028082526060820183526000926020830190803683370190505090508381600081518110612dca57612dca613e6a565b602002602001019063ffffffff16908163ffffffff1681525050600081600181518110612df957612df9613e6a565b63ffffffff9092166020928302919091019091015260405163883bdbfd60e01b81526001600160a01b0386169063883bdbfd90612e3a908490600401614143565b600060405180830381865afa158015612e57573d6000803e3d6000fd5b505050506040513d6000823e601f3d908101601f19168201604052612e7f91908101906141f5565b5060019c9b505050505050505050505050565b60408101515160009015612ea857506000919050565b816020015151606014612ebd57506000919050565b60008060008460200151806020019051810190612eda91906144bf565b919450925090508281612f55576040516396834ad360e01b8152600481018490526001600160a01b038216906396834ad390602401608060405180830381865afa158015612f2c573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612f5091906144f5565b612fbe565b604051639474f45b60e01b8152600481018490526001600160a01b03821690639474f45b90602401608060405180830381865afa158015612f9a573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612fbe91906144f5565b5060019695505050505050565b60408101515160009015612fe157506000919050565b816020015151606014612ff657506000919050565b8160200151806020019051810190612aa39190614597565b60008160400151516002148061302957508160400151516003145b61303557506000919050565b81602001515160201461304a57506000919050565b506001919050565b600081604001515160011480613029575081604001515160021461303557506000919050565b6040810151516000901561308e57506000919050565b6020826020015151101561304a57506000919050565b81818082036130b4575050505050565b6000856130da60026130c6888861446c565b6130d09190614640565b6112c0908861456f565b815181106130ea576130ea613e6a565b60200260200101516000015190505b818313613236575b808661310c85612686565b8151811061311c5761311c613e6a565b60200260200101516000015112156131405782613138816148a6565b935050613101565b8561314a83612686565b8151811061315a5761315a613e6a565b60200260200101516000015181121561317f5781613177816148be565b925050613140565b818313613231578561319083612686565b815181106131a0576131a0613e6a565b6020026020010151866131b285612686565b815181106131c2576131c2613e6a565b6020026020010151876131d486612686565b815181106131e4576131e4613e6a565b60200260200101886131f586612686565b8151811061320557613205613e6a565b602002602001018290528290525050828061321f906148a6565b935050818061322d906148be565b9250505b6130f9565b81851215613249576132498686846130a4565b8383121561325c5761325c8684866130a4565b505050505050565b6000670de0b6b3a764000061327983856147e8565b6102b49190614640565b600081613279670de0b6b3a7640000856147e8565b60008060008360020b126132b8576132b3600284900b612686565b6132c8565b6132c86112c0600285900b614493565b90506132e36112c06132dd620d89e7196148db565b60020b90565b8111156133165760405162461bcd60e51b81526020600482015260016024820152601560fa1b6044820152606401610397565b60008160011660000361332d57600160801b61333f565b6ffffcb933bd6fad37aa2d162d1a5940015b70ffffffffffffffffffffffffffffffffff169050600282161561337e576080613379826ffff97272373d413259a46990580e213a614859565b901c90505b60048216156133a85760806133a3826ffff2e50f5f656932ef12357cf3c7fdcc614859565b901c90505b60088216156133d25760806133cd826fffe5caca7e10e4e61c3624eaa0941cd0614859565b901c90505b60108216156133fc5760806133f7826fffcb9843d60f6159c9db58835c926644614859565b901c90505b6020821615613426576080613421826fff973b41fa98c081472e6896dfb254c0614859565b901c90505b604082161561345057608061344b826fff2ea16466c96a3843ec78b326b52861614859565b901c90505b608082161561347a576080613475826ffe5dee046a99a2a811c461f1969c3053614859565b901c90505b6101008216156134a55760806134a0826ffcbe86c7900a88aedcffc83b479aa3a4614859565b901c90505b6102008216156134d05760806134cb826ff987a7253ac413176f2b074cf7815e54614859565b901c90505b6104008216156134fb5760806134f6826ff3392b0822b70005940c7a398e4b70f3614859565b901c90505b610800821615613526576080613521826fe7159475a2c29b7443b29c7fa6e889d9614859565b901c90505b61100082161561355157608061354c826fd097f3bdfd2022b8845ad8f792aa5825614859565b901c90505b61200082161561357c576080613577826fa9f746462d870fdf8a65dc1f90e061e5614859565b901c90505b6140008216156135a75760806135a2826f70d869a156d2a1b890bb3df62baf32f7614859565b901c90505b6180008216156135d25760806135cd826f31be135f97d08fd981231505542fcfa6614859565b901c90505b620100008216156135fe5760806135f9826f09aa508b5b7a84e1c677de54f3e99bc9614859565b901c90505b62020000821615613629576080613624826e5d6af8dedb81196699c329225ee604614859565b901c90505b6204000082161561365357608061364e826d2216e584f5fa1ea926041bedfe98614859565b901c90505b6208000082161561367b576080613676826b048a170391f7dc42444e8fa2614859565b901c90505b60008460020b131561369657613693816000196147c0565b90505b6102ce6136a8640100000000836147d4565b156136b45760016136b7565b60005b6136c89060ff16602084901c6147ad565b6139ba565b6000808060001985870985870292508281108382030391505080600003613749576000841161373e5760405162461bcd60e51b815260206004820152601960248201527f48616e646c65206e6f6e2d6f766572666c6f77206361736573000000000000006044820152606401610397565b5082900490506102b4565b8084116137985760405162461bcd60e51b815260206004820152601960248201527f70726576656e74732064656e6f6d696e61746f72203d3d2030000000000000006044820152606401610397565b60008486880980840393811190920391905060006137d06137b887612569565b6137c188612569565b6137ca90614493565b16612686565b9586900495938490049360008190030460010190506137ef8184614859565b909317926000613800876003614859565b600218905061380f8188614859565b61381a9060026140a7565b6138249082614859565b90506138308188614859565b61383b9060026140a7565b6138459082614859565b90506138518188614859565b61385c9060026140a7565b6138669082614859565b90506138728188614859565b61387d9060026140a7565b6138879082614859565b90506138938188614859565b61389e9060026140a7565b6138a89082614859565b90506138b48188614859565b6138bf9060026140a7565b6138c99082614859565b90506138d58186614859565b9998505050505050505050565b604080516001600160e01b0319831660248083019190915282518083039091018152604490910182526020810180516001600160e01b03166301ffc9a760e01b1790529051600091829182916001600160a01b0387169161394391906148fd565b6000604051808303816000865af19150503d8060008114613980576040519150601f19603f3d011682016040523d82523d6000602084013e613985565b606091505b50915091508161399a57600092505050610116565b80516000036139ae57600092505050610116565b60200151949350505050565b60006001600160a01b038211156125295760405163dccde8ed60e01b815260040160405180910390fd5b6040518060800160405280600081526020016000815260200160008152602001600081525090565b828054828255906000526020600020908101928215613a47579160200282015b82811115613a47578251825591602001919060010190613a2c565b506125299291505b808211156125295760008155600101613a4f565b600060208284031215613a7557600080fd5b5035919050565b8151815260208083015190820152604080830151908201526060808301519082015260808101610116565b634e487b7160e01b600052602160045260246000fd5b600a8110613acd57613acd613aa7565b9052565b60005b83811015613aec578181015183820152602001613ad4565b50506000910152565b60008151808452613b0d816020860160208601613ad1565b601f01601f19169290920160200192915050565b60006020808352613b358184018551613abd565b8084015160606040850152613b4d6080850182613af5565b6040860151858203601f19016060870152805180835290840192506000918401905b80831015613b8f5783518252928401926001929092019190840190613b6f565b509695505050505050565b634e487b7160e01b600052604160045260246000fd5b6040516080810167ffffffffffffffff81118282101715613bd357613bd3613b9a565b60405290565b604051601f8201601f1916810167ffffffffffffffff81118282101715613c0257613c02613b9a565b604052919050565b600067ffffffffffffffff821115613c2457613c24613b9a565b5060051b60200190565b600082601f830112613c3f57600080fd5b81356020613c54613c4f83613c0a565b613bd9565b8083825260208201915060208460051b870101935086841115613c7657600080fd5b602086015b84811015613b8f5780358352918301918301613c7b565b600080600060608486031215613ca757600080fd5b8335600a8110613cb657600080fd5b925060208481013567ffffffffffffffff80821115613cd457600080fd5b818701915087601f830112613ce857600080fd5b813581811115613cfa57613cfa613b9a565b613d0c601f8201601f19168501613bd9565b8181528985838601011115613d2057600080fd5b818585018683013760009181019094015291935060408601359180831115613d4757600080fd5b5050613d5586828701613c2e565b9150509250925092565b600080600060608486031215613d7457600080fd5b83359250602084013567ffffffffffffffff80821115613d9357600080fd5b613d9f87838801613c2e565b93506040860135915080821115613db557600080fd5b50613d5586828701613c2e565b600181811c90821680613dd657607f821691505b602082108103611d0757634e487b7160e01b600052602260045260246000fd5b60008151808452602080850194506020840160005b83811015613e2757815187529582019590820190600101613e0b565b509495945050505050565b848152613e426020820185613abd565b608060408201526000613e586080830185613af5565b828103606084015261189a8185613df6565b634e487b7160e01b600052603260045260246000fd5b600060208284031215613e9257600080fd5b8151600981106102b457600080fd5b6020810160098310613eb557613eb5613aa7565b91905290565b6001600160a01b0381168114613ed057600080fd5b50565b600060208284031215613ee557600080fd5b81516102b481613ebb565b608080825285518282018190526000919060209060a0850190828a01855b82811015613f5257613f42848351805182526020810151602083015260408101516040830152606081015160608301525050565b9285019290840190600101613f0e565b5050508481036020860152613f678189613af5565b925050508281036040840152613f7d8186613df6565b9050828103606084015261189a8185613df6565b600060808284031215613fa357600080fd5b613fab613bb0565b825181526020830151602082015260408301516040820152606083015160608201528091505092915050565b805160ff8116811461200457600080fd5b600080600060608486031215613ffd57600080fd5b835161400881613ebb565b6020850151909350915061401e60408501613fd7565b90509250925092565b805169ffffffffffffffffffff8116811461200457600080fd5b600080600080600060a0868803121561405957600080fd5b61406286614027565b945060208601519350604086015192506060860151915061408560808701614027565b90509295509295909350565b634e487b7160e01b600052601160045260246000fd5b8181038181111561011657610116614091565b60008060008060008060c087890312156140d357600080fd5b86516140de81613ebb565b60208801519096506140ef81613ebb565b94506140fd60408801613fd7565b935061410b60608801613fd7565b9250608087015161411b81613ebb565b60a088015190925063ffffffff8116811461413557600080fd5b809150509295509295509295565b6020808252825182820181905260009190848201906040850190845b8181101561418157835163ffffffff168352928401929184019160010161415f565b50909695505050505050565b600082601f83011261419e57600080fd5b815160206141ae613c4f83613c0a565b8083825260208201915060208460051b8701019350868411156141d057600080fd5b602086015b84811015613b8f5780516141e881613ebb565b83529183019183016141d5565b6000806040838503121561420857600080fd5b825167ffffffffffffffff8082111561422057600080fd5b818501915085601f83011261423457600080fd5b81516020614244613c4f83613c0a565b82815260059290921b8401810191818101908984111561426357600080fd5b948201945b838610156142915785518060060b81146142825760008081fd5b82529482019490820190614268565b918801519196509093505050808211156142aa57600080fd5b506142b78582860161418d565b9150509250929050565b600682810b9082900b03667fffffffffffff198112667fffffffffffff8213171561011657610116614091565b634e487b7160e01b600052601260045260246000fd5b60008160060b8360060b8061431b5761431b6142ee565b667fffffffffffff1982146000198214161561433957614339614091565b90059392505050565b600082614351576143516142ee565b500790565b60008160020b627fffff19810361436f5761436f614091565b6000190192915050565b600181815b808511156143b457816000190482111561439a5761439a614091565b808516156143a757918102915b93841c939080029061437e565b509250929050565b6000826143cb57506001610116565b816143d857506000610116565b81600181146143ee57600281146143f857614414565b6001915050610116565b60ff84111561440957614409614091565b50506001821b610116565b5060208310610133831016604e8410600b8410161715614437575081810a610116565b6144418383614379565b806000190482111561445557614455614091565b029392505050565b60006102b460ff8416836143bc565b818103600083128015838313168383128216171561448c5761448c614091565b5092915050565b6000600160ff1b82016144a8576144a8614091565b5060000390565b8051801515811461200457600080fd5b6000806000606084860312156144d457600080fd5b83516144df81613ebb565b6020850151909350915061401e604085016144af565b60006080828403121561450757600080fd5b61450f613bb0565b82518060070b811461452057600080fd5b8152602083015167ffffffffffffffff8116811461453d57600080fd5b60208201526040830151600381900b811461455757600080fd5b60408201526060928301519281019290925250919050565b808201828112600083128015821682158216171561458f5761458f614091565b505092915050565b6000806000606084860312156145ac57600080fd5b83516145b781613ebb565b602085015160409095015190969495509392505050565b60ff8416815267ffffffffffffffff831660208201526060604082015260006145fa6060830184613df6565b95945050505050565b6001600160a01b03831681526040602082018190526000906102ce90830184613af5565b60006020828403121561463957600080fd5b5051919050565b60008261464f5761464f6142ee565b600160ff1b82146000198414161561466957614669614091565b500590565b6146788185613abd565b60606020820152600061468e6060830185613af5565b8281036040840152610f4f8185613df6565b601f8211156146e8576000816000526020600020601f850160051c810160208610156146c95750805b601f850160051c820191505b8181101561325c578281556001016146d5565b505050565b815167ffffffffffffffff81111561470757614707613b9a565b61471b816147158454613dc2565b846146a0565b602080601f83116001811461475057600084156147385750858301515b600019600386901b1c1916600185901b17855561325c565b600085815260208120601f198616915b8281101561477f57888601518255948401946001909101908401614760565b508582101561479d5787850151600019600388901b60f8161c191681555b5050505050600190811b01905550565b8082018082111561011657610116614091565b6000826147cf576147cf6142ee565b500490565b6000826147e3576147e36142ee565b500690565b80820260008212600160ff1b8414161561480457614804614091565b818105831482151761011657610116614091565b600069ffffffffffffffffffff82168061436f5761436f614091565b60006001820161484657614846614091565b5060010190565b60006102b483836143bc565b808202811582820484141761011657610116614091565b60006020828403121561488257600080fd5b6102b4826144af565b60006020828403121561489d57600080fd5b6102b482613fd7565b60006001600160ff1b01820161484657614846614091565b6000600160ff1b82016148d3576148d3614091565b506000190190565b60008160020b627fffff1981036148f4576148f4614091565b60000392915050565b6000825161490f818460208701613ad1565b919091019291505056fea264697066735822122074f32fef384fdc296b0859f1c1f941c8e736c6cb972aa9e2b894956ebd6a80b364736f6c63430008160033","storage":{}},"0x83a0444b93927c3afcbe46e522280390f748e171":{"nonce":1,"balance":"0x0","code":"0x6080604052366100135761001161001d565b005b61001b61001d565b005b6000610027610093565b90503660008037600080366000845af43d6000803e806000811461004a573d6000f35b3d6000fd5b600080823b905060008111915050919050565b6000806040516020016100749061017a565b6040516020818303038152906040528051906020012090508091505090565b600061009d6100c6565b60000160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16905090565b6000806040516020016100d89061020c565b6040516020818303038152906040528051906020012090508091505090565b600082825260208201905092915050565b7f696f2e73796e7468657469782e636f72652d636f6e7472616374732e4f776e6160008201527f626c650000000000000000000000000000000000000000000000000000000000602082015250565b60006101646023836100f7565b915061016f82610108565b604082019050919050565b6000602082019050818103600083015261019381610157565b9050919050565b7f696f2e73796e7468657469782e636f72652d636f6e7472616374732e50726f7860008201527f7900000000000000000000000000000000000000000000000000000000000000602082015250565b60006101f66021836100f7565b91506102018261019a565b604082019050919050565b60006020820190508181036000830152610225816101e9565b905091905056fea2646970667358221220800da1f73cebd5e4afa07496d9bca6b6c4f526bdd3f4014ec15c70fe3a1c441364736f6c63430008110033","storage":{"0x5a648c35a2f5512218b4683cf10e03f5b7c9dc7346e1bf77d304ae97f60f592b":"0x108f53faf774d7c4c56f5bce9ca6e605ce8aeadd","0x5c7865864a2a990d80b5bb5c40e7b73a029960dc711fbb56120dfab976e92ea3":"0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266"}},"0x90f79bf6eb2c4f870365e785982e1f101e93b906":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0x976ea74026e726554db657fa54763abd0c3a0aa9":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0x9965507d1a55bcc2695c58ba16fb37d819b0a4dc":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0xa0ee7a142d267c1f36714e4a8f75612f20a79720":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0xc67e2bd3108604cf0168c0e5ef9cd6d78b9bb14b":{"nonce":1,"balance":"0x21e19c6edb7e2445f20","code":"0x","storage":{}},"0xeb045d78d273107348b0300c01d29b7552d622ab":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266":{"nonce":1,"balance":"0x21e19e08b86820a43ea","code":"0x","storage":{}}},"best_block_number":5,"blocks":[{"header":{"parentHash":"0x08abe6e453727534d8dd708843a7522b7d500338bdfe2402ca105dcdb05eebe9","sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347","miner":"0x0000000000000000000000000000000000000000","stateRoot":"0xcd346446ed010523161f40a5f2b512def549bfb79e165b4354488738416481f2","transactionsRoot":"0xb3a4689832e0b599260ae70362ffcf224b60571b35ff8836904a3d81e2675d66","receiptsRoot":"0x2d13fdc120ab90536fed583939de7fb68b64926a306c1f629593ca9c2c93b198","logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","difficulty":"0x0","number":"0x1","gasLimit":"0x1c9c380","gasUsed":"0x3ea90d","timestamp":"0x66b200ca","mixHash":"0x0000000000000000000000000000000000000000000000000000000000000000","nonce":"0x0000000000000000","baseFeePerGas":"0x2e0b6260","blobGasUsed":"0x0","excessBlobGas":"0x0","extraData":"0x"},"transactions":[{"EIP1559":{"chainId":"0x343a","nonce":"0x0","gas":"0x3ea90d","maxFeePerGas":"0x83215600","maxPriorityFeePerGas":"0x3b9aca00","value":"0x0","accessList":[],"input":"0x608060405234801561001057600080fd5b5061494f806100206000396000f3fe608060405234801561001057600080fd5b50600436106100575760003560e01c80632a952b2d1461005c57806350c946fe14610085578063625ca21c146100a5578063daa250be146100c6578063deba1b98146100d9575b600080fd5b61006f61006a366004613a63565b6100ec565b60405161007c9190613a7c565b60405180910390f35b610098610093366004613a63565b61011c565b60405161007c9190613b21565b6100b86100b3366004613c92565b610276565b60405190815260200161007c565b61006f6100d4366004613d5f565b6102bb565b6100b86100e7366004613c92565b6102d6565b6100f46139e4565b6040805160008082526020820190815281830190925261011691849190610310565b92915050565b6101416040805160608101909152806000815260200160608152602001606081525090565b61014a82610ab6565b60408051606081019091528154909190829060ff16600981111561017057610170613aa7565b600981111561018157610181613aa7565b815260200160018201805461019590613dc2565b80601f01602080910402602001604051908101604052809291908181526020018280546101c190613dc2565b801561020e5780601f106101e35761010080835404028352916020019161020e565b820191906000526020600020905b8154815290600101906020018083116101f157829003601f168201915b505050505081526020016002820180548060200260200160405190810160405280929190818152602001828054801561026657602002820191906000526020600020905b815481526020019060010190808311610252575b5050505050815250509050919050565b600080604051806060016040528086600981111561029657610296613aa7565b81526020018581526020018481525090506102b081610ac1565b9150505b9392505050565b6102c36139e4565b6102ce848484610310565b949350505050565b60008060405180606001604052808660098111156102f6576102f6613aa7565b81526020018581526020018481525090506102b081610acc565b6103186139e4565b81518351146103a05760408051634bab873760e11b81526004810191909152600d60448201526c72756e74696d6556616c75657360981b606482015260806024820152602260848201527f6d7573742062652073616d65206c656e6774682061732072756e74696d654b6560a482015261797360f01b60c482015260e4015b60405180910390fd5b60006103ab85610c26565b805490915060ff1660018160098111156103c7576103c7613aa7565b036104755761046c6103da838787610c84565b8360010180546103e990613dc2565b80601f016020809104026020016040519081016040528092919081815260200182805461041590613dc2565b80156104625780601f1061043757610100808354040283529160200191610462565b820191906000526020600020905b81548152906001019060200180831161044557829003601f168201915b5050505050610d46565b925050506102b4565b600281600981111561048957610489613aa7565b036105305761046c61049c838787610c84565b8360010180546104ab90613dc2565b80601f01602080910402602001604051908101604052809291908181526020018280546104d790613dc2565b80156105245780601f106104f957610100808354040283529160200191610524565b820191906000526020600020905b81548152906001019060200180831161050757829003601f168201915b50505050508787610ebb565b600381600981111561054457610544613aa7565b036105de5761046c82600101805461055b90613dc2565b80601f016020809104026020016040519081016040528092919081815260200182805461058790613dc2565b80156105d45780601f106105a9576101008083540402835291602001916105d4565b820191906000526020600020905b8154815290600101906020018083116105b757829003601f168201915b5050505050610f59565b60048160098111156105f2576105f2613aa7565b0361068c5761046c82600101805461060990613dc2565b80601f016020809104026020016040519081016040528092919081815260200182805461063590613dc2565b80156106825780601f1061065757610100808354040283529160200191610682565b820191906000526020600020905b81548152906001019060200180831161066557829003601f168201915b5050505050611087565b60058160098111156106a0576106a0613aa7565b0361073a5761046c8260010180546106b790613dc2565b80601f01602080910402602001604051908101604052809291908181526020018280546106e390613dc2565b80156107305780601f1061070557610100808354040283529160200191610730565b820191906000526020600020905b81548152906001019060200180831161071357829003601f168201915b505050505061131e565b600981600981111561074e5761074e613aa7565b036107ea5761046c82600101805461076590613dc2565b80601f016020809104026020016040519081016040528092919081815260200182805461079190613dc2565b80156107de5780601f106107b3576101008083540402835291602001916107de565b820191906000526020600020905b8154815290600101906020018083116107c157829003601f168201915b505050505086866114b5565b60068160098111156107fe576107fe613aa7565b036108a35761046c610811838787610c84565b83600101805461082090613dc2565b80601f016020809104026020016040519081016040528092919081815260200182805461084c90613dc2565b80156108995780601f1061086e57610100808354040283529160200191610899565b820191906000526020600020905b81548152906001019060200180831161087c57829003601f168201915b50505050506115c7565b60078160098111156108b7576108b7613aa7565b036109ec576040805160608101909152825461046c91908490829060ff1660098111156108e6576108e6613aa7565b60098111156108f7576108f7613aa7565b815260200160018201805461090b90613dc2565b80601f016020809104026020016040519081016040528092919081815260200182805461093790613dc2565b80156109845780601f1061095957610100808354040283529160200191610984565b820191906000526020600020905b81548152906001019060200180831161096757829003601f168201915b50505050508152602001600282018054806020026020016040519081016040528092919081815260200182805480156109dc57602002820191906000526020600020905b8154815260200190600101908083116109c8575b5050505050815250508686611728565b6008816009811115610a0057610a00613aa7565b03610a9a5761046c826001018054610a1790613dc2565b80601f0160208091040260200160405190810160405280929190818152602001828054610a4390613dc2565b8015610a905780601f10610a6557610100808354040283529160200191610a90565b820191906000526020600020905b815481529060010190602001808311610a7357829003601f168201915b50505050506118a5565b6040516323a9bbc960e01b815260048101879052602401610397565b600061011682610c26565b6000610116826118ea565b6000610ad782610ac1565b9050610ae28161192a565b15610b35577fcb64985827770858ec421ad26da7e558c757541643036ce44d6b4eb9e8e5dc5e81836000015184602001518560400151604051610b289493929190613e32565b60405180910390a1919050565b610b3e82611a8c565b610b5d578160405163382bbbc960e11b81526004016103979190613b21565b60005b826040015151811015610bd957610b9383604001518281518110610b8657610b86613e6a565b602002602001015161192a565b610bd15782604001518181518110610bad57610bad613e6a565b6020026020010151604051632f19f96160e11b815260040161039791815260200190565b600101610b60565b50610be382611c31565b8351602085015160408087015190519395507fcb64985827770858ec421ad26da7e558c757541643036ce44d6b4eb9e8e5dc5e9450610b28938693929190613e32565b604080516020808201839052606082018190527f696f2e73796e7468657469782e6f7261636c652d6d616e616765722e4e6f6465608080840191909152828401949094528251808303909401845260a0909101909152815191012090565b600283015460609067ffffffffffffffff811115610ca457610ca4613b9a565b604051908082528060200260200182016040528015610cdd57816020015b610cca6139e4565b815260200190600190039081610cc25790505b50905060005b6002850154811015610d3e57610d19856002018281548110610d0757610d07613e6a565b90600052602060002001548585610310565b828281518110610d2b57610d2b613e6a565b6020908102919091010152600101610ce3565b509392505050565b610d4e6139e4565b600082806020019051810190610d649190613e80565b90506000816008811115610d7a57610d7a613aa7565b03610d9057610d8884611ca5565b915050610116565b6001816008811115610da457610da4613aa7565b03610db257610d8884611d0d565b6002816008811115610dc657610dc6613aa7565b03610dd457610d8884611d90565b6003816008811115610de857610de8613aa7565b03610df657610d8884611e13565b6004816008811115610e0a57610e0a613aa7565b03610e1857610d8884611ec9565b6005816008811115610e2c57610e2c613aa7565b03610e3a57610d8884612009565b6006816008811115610e4e57610e4e613aa7565b03610e5c57610d88846120e4565b6007816008811115610e7057610e70613aa7565b03610e7e57610d888461220c565b6008816008811115610e9257610e92613aa7565b03610ea057610d88846122ce565b80604051631be413d360e11b81526004016103979190613ea1565b610ec36139e4565b600084806020019051810190610ed99190613ed3565b604051631ecba7c360e31b81529091506001600160a01b0382169063f65d3e1890610f0e908990899089908990600401613ef0565b608060405180830381865afa158015610f2b573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190610f4f9190613f91565b9695505050505050565b610f616139e4565b600080600084806020019051810190610f7a9190613fe8565b92509250925060008390506000806000836001600160a01b031663feaf968c6040518163ffffffff1660e01b815260040160a060405180830381865afa158015610fc8573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190610fec9190614041565b509350509250925060008660001461100f5761100a8585858a6123c7565b611011565b825b905060128660ff161161103b5761103661102f60ff881660126140a7565b82906124c2565b611053565b61105361104c601260ff89166140a7565b82906124dc565b9050604051806080016040528082815260200183815260200160008152602001600081525098505050505050505050919050565b61108f6139e4565b600080600080600080878060200190518101906110ac91906140ba565b604080516002808252606082018352979d50959b50939950919750955093506000929060208301908036833701905050905081816000815181106110f2576110f2613e6a565b602002602001019063ffffffff16908163ffffffff168152505060008160018151811061112157611121613e6a565b63ffffffff9092166020928302919091019091015260405163883bdbfd60e01b81526000906001600160a01b0385169063883bdbfd90611165908590600401614143565b600060405180830381865afa158015611182573d6000803e3d6000fd5b505050506040513d6000823e601f3d908101601f191682016040526111aa91908101906141f5565b5090506000816000815181106111c2576111c2613e6a565b6020026020010151826001815181106111dd576111dd613e6a565b60200260200101516111ef91906142c1565b9050600061121761120563ffffffff87166124f6565b61120f9084614304565b60060b61252d565b905060008260060b12801561124c575061123b63ffffffff8616612569565b612569565b8260060b6112499190614342565b15155b1561125f578061125b81614356565b9150505b600061126d6012600a61445d565b9050600061128061123684848f8f612593565b905060006112908a60ff16612569565b61129c8c60ff16612569565b6112a6919061446c565b905060008082136112d1576112cc6112c56112c084614493565b612686565b84906124dc565b6112e4565b6112e46112dd83612686565b84906124c2565b905060405180608001604052808281526020014281526020016000815260200160008152509e505050505050505050505050505050919050565b6113266139e4565b60008060008480602001905181019061133f91906144bf565b91945092509050826000826113bc576040516396834ad360e01b8152600481018590526001600160a01b038316906396834ad390602401608060405180830381865afa158015611393573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906113b791906144f5565b611425565b604051639474f45b60e01b8152600481018590526001600160a01b03831690639474f45b90602401608060405180830381865afa158015611401573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061142591906144f5565b90506000816040015160030b601261143d919061456f565b90506000808213611467576114626114576112c084614493565b845160070b906124dc565b61147e565b61147e61147383612686565b845160070b906124c2565b9050604051806080016040528082815260200184606001518152602001600081526020016000815250975050505050505050919050565b6114bd6139e4565b6000806000868060200190518101906114d69190614597565b92509250925060005b8651811015611545578681815181106114fa576114fa613e6a565b6020026020010151717374616c656e657373546f6c6572616e636560701b0361153d5785818151811061152f5761152f613e6a565b602002602001015160001c91505b6001016114df565b5060408051600180825281830190925260009160208083019080368337019050509050828160008151811061157c5761157c613e6a565b602002602001018181525050836001838360405160200161159f939291906145ce565b60408051601f198184030181529082905263cf2cabdf60e01b82526103979291600401614603565b6115cf6139e4565b6000828060200190518101906115e59190614627565b90506000846000815181106115fc576115fc613e6a565b602002602001015160000151905060008560018151811061161f5761161f613e6a565b6020026020010151600001519050808214611702576000611653601261164d611648858761446c565b6126a9565b906124c2565b905082158061167b5750611666836126a9565b6116709082614640565b61167985612569565b125b15611700576002875111156116b0578660028151811061169d5761169d613e6a565b6020026020010151945050505050610116565b826000036116d15760405163014cc07160e01b815260040160405180910390fd5b6116da836126a9565b6116e49082614640565b60405163dcac091960e01b815260040161039791815260200190565b505b8560008151811061171557611715613e6a565b6020026020010151935050505092915050565b6117306139e4565b6000846020015180602001905181019061174a9190614627565b905060005b84518110156117bc5784818151811061176a5761176a613e6a565b6020026020010151717374616c656e657373546f6c6572616e636560701b036117b4576117ad8482815181106117a2576117a2613e6a565b602002602001015190565b91506117bc565b60010161174f565b50600085604001516000815181106117d6576117d6613e6a565b6020026020010151905060006117ed828787610310565b60208101519091506117ff84426140a7565b1161180e5792506102b4915050565b86604001515160010361187157866040015160008151811061183257611832613e6a565b602002602001015181600001518260200151604051631808066560e21b8152600401610397939291909283526020830191909152604082015260600190565b61189a876040015160018151811061188b5761188b613e6a565b60200260200101518787610310565b979650505050505050565b6118ad6139e4565b6040518060800160405280838060200190518101906118cc9190614627565b81526020014281526020016000815260200160008152509050919050565b600081600001518260200151836040015160405160200161190d9392919061466e565b604051602081830303815290604052805190602001209050919050565b60008061193683610c26565b60408051606081019091528154909190829060ff16600981111561195c5761195c613aa7565b600981111561196d5761196d613aa7565b815260200160018201805461198190613dc2565b80601f01602080910402602001604051908101604052809291908181526020018280546119ad90613dc2565b80156119fa5780601f106119cf576101008083540402835291602001916119fa565b820191906000526020600020905b8154815290600101906020018083116119dd57829003601f168201915b5050505050815260200160028201805480602002602001604051908101604052809291908181526020018280548015611a5257602002820191906000526020600020905b815481526020019060010190808311611a3e575b505050505081525050905060006009811115611a7057611a70613aa7565b81516009811115611a8357611a83613aa7565b14159392505050565b6000600182516009811115611aa357611aa3613aa7565b1480611ac15750600682516009811115611abf57611abf613aa7565b145b80611ade5750600782516009811115611adc57611adc613aa7565b145b15611aee57611aec826126c1565b505b600182516009811115611b0357611b03613aa7565b03611b11576101168261284a565b600282516009811115611b2657611b26613aa7565b03611b3457610116826128a5565b600382516009811115611b4957611b49613aa7565b03611b575761011682612973565b600482516009811115611b6c57611b6c613aa7565b03611b7a5761011682612aae565b600582516009811115611b8f57611b8f613aa7565b03611b9d5761011682612e92565b600982516009811115611bb257611bb2613aa7565b03611bc05761011682612fcb565b600682516009811115611bd557611bd5613aa7565b03611be3576101168261300e565b600782516009811115611bf857611bf8613aa7565b03611c065761011682613052565b600882516009811115611c1b57611c1b613aa7565b03611c295761011682613078565b506000919050565b600080611c3d836118ea565b9050611c4881610c26565b8351815491935090839060ff19166001836009811115611c6a57611c6a613aa7565b021790555060208301516001830190611c8390826146ed565b5060408301518051611c9f916002850191602090910190613a0c565b50915091565b611cad6139e4565b60005b8251811015611d07578160200151838281518110611cd057611cd0613e6a565b6020026020010151602001511115611cff57828181518110611cf457611cf4613e6a565b602002602001015191505b600101611cb0565b50919050565b611d156139e4565b81600081518110611d2857611d28613e6a565b602002602001015190506000600190505b8251811015611d07578160000151838281518110611d5957611d59613e6a565b6020026020010151600001511215611d8857828181518110611d7d57611d7d613e6a565b602002602001015191505b600101611d39565b611d986139e4565b81600081518110611dab57611dab613e6a565b602002602001015190506000600190505b8251811015611d07578160000151838281518110611ddc57611ddc613e6a565b6020026020010151600001511315611e0b57828181518110611e0057611e00613e6a565b602002602001015191505b600101611dbc565b611e1b6139e4565b60005b8251811015611e9557828181518110611e3957611e39613e6a565b60200260200101516000015182600001818151611e56919061456f565b9052508251839082908110611e6d57611e6d613e6a565b60200260200101516020015182602001818151611e8a91906147ad565b905250600101611e1e565b50611ea08251612569565b8151611eac9190614640565b815281516020820151611ebf91906147c0565b6020820152919050565b611ed16139e4565b611eed826000611ee86001865161123691906140a7565b6130a4565b60028251611efb91906147d4565b600003611fd65760408051600280825260608201909252600091816020015b611f226139e4565b815260200190600190039081611f1a57905050905082600160028551611f4891906147c0565b611f5291906140a7565b81518110611f6257611f62613e6a565b602002602001015181600081518110611f7d57611f7d613e6a565b60200260200101819052508260028451611f9791906147c0565b81518110611fa757611fa7613e6a565b602002602001015181600181518110611fc257611fc2613e6a565b60200260200101819052506102b481611e13565b8160028351611fe591906147c0565b81518110611ff557611ff5613e6a565b60200260200101519050919050565b919050565b6120116139e4565b8160008151811061202457612024613e6a565b60209081029190910101515181528151829060009061204557612045613e6a565b6020908102919091018101518101519082015260015b82518110156120d25782818151811061207657612076613e6a565b6020026020010151600001518260000181815161209391906147e8565b90525082518390829081106120aa576120aa613e6a565b602002602001015160200151826020018181516120c791906147ad565b90525060010161205b565b5081518160200151611ebf91906147c0565b6120ec6139e4565b816000815181106120ff576120ff613e6a565b60209081029190910101515181528151829060009061212057612120613e6a565b6020908102919091018101518101519082015260015b82518110156120d25782818151811061215157612151613e6a565b60200260200101516000015160000361219e5782818151811061217657612176613e6a565b6020026020010151600001516040516338ee04a760e01b815260040161039791815260200190565b8281815181106121b0576121b0613e6a565b602002602001015160000151826000018181516121cd9190614640565b90525082518390829081106121e4576121e4613e6a565b6020026020010151602001518260200181815161220191906147ad565b905250600101612136565b6122146139e4565b8160008151811061222757612227613e6a565b60209081029190910101515181528151829060009061224857612248613e6a565b6020908102919091018101518101519082015260015b82518110156120d25761229083828151811061227c5761227c613e6a565b602090810291909101015151835190613264565b825282518390829081106122a6576122a6613e6a565b602002602001015160200151826020018181516122c391906147ad565b90525060010161225e565b6122d66139e4565b816000815181106122e9576122e9613e6a565b60209081029190910101515181528151829060009061230a5761230a613e6a565b6020908102919091018101518101519082015260015b82518110156120d25782818151811061233b5761233b613e6a565b6020026020010151600001516000036123605782818151811061217657612176613e6a565b61238983828151811061237557612375613e6a565b602090810291909101015151835190613283565b8252825183908290811061239f5761239f613e6a565b602002602001015160200151826020018181516123bc91906147ad565b905250600101612320565b6000826001826123d785426140a7565b90505b69ffffffffffffffffffff8716156124a3576001600160a01b038816639a6fc8f561240489614818565b6040516001600160e01b031960e084901b16815269ffffffffffffffffffff8216600482015290995060240160a060405180830381865afa925050508015612469575060408051601f3d908101601f1916820190925261246691810190614041565b60015b156124a357858210156124805750505050506124a3565b61248a848961456f565b97508661249681614834565b97505050505050506123da565b6124ac82612569565b6124b69084614640565b98975050505050505050565b60006124d261123683600a61484d565b6102b490846147e8565b60006124ec61123683600a61484d565b6102b49084614640565b6000667fffffffffffff66ffffffffffffff83161115612529576040516329d2678160e21b815260040160405180910390fd5b5090565b6000627fffff19600683900b128061254b5750627fffff600683900b135b1561252957604051630d962f7960e21b815260040160405180910390fd5b60006001600160ff1b038211156125295760405163677c430560e11b815260040160405180910390fd5b60008061259f86613298565b90506fffffffffffffffffffffffffffffffff6001600160a01b0382161161261c5760006125d66001600160a01b03831680614859565b9050836001600160a01b0316856001600160a01b03161061260557612600600160c01b87836136cd565b612614565b6126148187600160c01b6136cd565b92505061267d565b600061263b6001600160a01b03831680680100000000000000006136cd565b9050836001600160a01b0316856001600160a01b03161061266a57612665600160801b87836136cd565b612679565b6126798187600160801b6136cd565b9250505b50949350505050565b6000808212156125295760405163029f024d60e31b815260040160405180910390fd5b600080821215612529576126bc82614493565b610116565b6000805b8260400151518110156128415760006126fa846040015183815181106126ed576126ed613e6a565b6020026020010151610ab6565b60408051606081019091528154909190829060ff16600981111561272057612720613aa7565b600981111561273157612731613aa7565b815260200160018201805461274590613dc2565b80601f016020809104026020016040519081016040528092919081815260200182805461277190613dc2565b80156127be5780601f10612793576101008083540402835291602001916127be565b820191906000526020600020905b8154815290600101906020018083116127a157829003601f168201915b505050505081526020016002820180548060200260200160405190810160405280929190818152602001828054801561281657602002820191906000526020600020905b815481526020019060010190808311612802575b505050505081525050905061282a81611a8c565b612838575060009392505050565b506001016126c5565b50600192915050565b60006002826040015151101561286257506000919050565b81602001515160201461287757506000919050565b600082602001518060200190518101906128919190614627565b905060088111156128415750600092915050565b6000602082602001515110156128bd57506000919050565b600082602001518060200190518101906128d79190613ed3565b90506128ea816306e7ea3960e21b6138e2565b6128f75750600092915050565b604051633b70a5bf60e21b81526001600160a01b0382169063edc296fc90612923908690600401613b21565b6020604051808303816000875af1158015612942573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906129669190614870565b6128415750600092915050565b6040810151516000901561298957506000919050565b81602001515160601461299e57506000919050565b60008083602001518060200190518101906129b99190613fe8565b92505091506000829050806001600160a01b031663feaf968c6040518163ffffffff1660e01b815260040160a060405180830381865afa158015612a01573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612a259190614041565b5050505050806001600160a01b031663313ce5676040518163ffffffff1660e01b8152600401602060405180830381865afa158015612a68573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612a8c919061488b565b60ff168260ff1614612aa357506000949350505050565b506001949350505050565b60408101515160009015612ac457506000919050565b81602001515160c014612ad957506000919050565b6000806000806000808760200151806020019051810190612afa91906140ba565b9550955095509550955095508360ff16866001600160a01b031663313ce5676040518163ffffffff1660e01b8152600401602060405180830381865afa158015612b48573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612b6c919061488b565b60ff1614612b8257506000979650505050505050565b8260ff16856001600160a01b031663313ce5676040518163ffffffff1660e01b8152600401602060405180830381865afa158015612bc4573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612be8919061488b565b60ff1614612bfe57506000979650505050505050565b6000826001600160a01b0316630dfe16816040518163ffffffff1660e01b8152600401602060405180830381865afa158015612c3e573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612c629190613ed3565b90506000836001600160a01b031663d21220a76040518163ffffffff1660e01b8152600401602060405180830381865afa158015612ca4573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612cc89190613ed3565b9050876001600160a01b0316826001600160a01b0316148015612cfc5750866001600160a01b0316816001600160a01b0316145b158015612d385750866001600160a01b0316826001600160a01b0316148015612d365750876001600160a01b0316816001600160a01b0316145b155b15612d4d575060009998505050505050505050565b60128660ff161180612d62575060128560ff16115b15612d77575060009998505050505050505050565b8263ffffffff16600003612d95575060009998505050505050505050565b6040805160028082526060820183526000926020830190803683370190505090508381600081518110612dca57612dca613e6a565b602002602001019063ffffffff16908163ffffffff1681525050600081600181518110612df957612df9613e6a565b63ffffffff9092166020928302919091019091015260405163883bdbfd60e01b81526001600160a01b0386169063883bdbfd90612e3a908490600401614143565b600060405180830381865afa158015612e57573d6000803e3d6000fd5b505050506040513d6000823e601f3d908101601f19168201604052612e7f91908101906141f5565b5060019c9b505050505050505050505050565b60408101515160009015612ea857506000919050565b816020015151606014612ebd57506000919050565b60008060008460200151806020019051810190612eda91906144bf565b919450925090508281612f55576040516396834ad360e01b8152600481018490526001600160a01b038216906396834ad390602401608060405180830381865afa158015612f2c573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612f5091906144f5565b612fbe565b604051639474f45b60e01b8152600481018490526001600160a01b03821690639474f45b90602401608060405180830381865afa158015612f9a573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612fbe91906144f5565b5060019695505050505050565b60408101515160009015612fe157506000919050565b816020015151606014612ff657506000919050565b8160200151806020019051810190612aa39190614597565b60008160400151516002148061302957508160400151516003145b61303557506000919050565b81602001515160201461304a57506000919050565b506001919050565b600081604001515160011480613029575081604001515160021461303557506000919050565b6040810151516000901561308e57506000919050565b6020826020015151101561304a57506000919050565b81818082036130b4575050505050565b6000856130da60026130c6888861446c565b6130d09190614640565b6112c0908861456f565b815181106130ea576130ea613e6a565b60200260200101516000015190505b818313613236575b808661310c85612686565b8151811061311c5761311c613e6a565b60200260200101516000015112156131405782613138816148a6565b935050613101565b8561314a83612686565b8151811061315a5761315a613e6a565b60200260200101516000015181121561317f5781613177816148be565b925050613140565b818313613231578561319083612686565b815181106131a0576131a0613e6a565b6020026020010151866131b285612686565b815181106131c2576131c2613e6a565b6020026020010151876131d486612686565b815181106131e4576131e4613e6a565b60200260200101886131f586612686565b8151811061320557613205613e6a565b602002602001018290528290525050828061321f906148a6565b935050818061322d906148be565b9250505b6130f9565b81851215613249576132498686846130a4565b8383121561325c5761325c8684866130a4565b505050505050565b6000670de0b6b3a764000061327983856147e8565b6102b49190614640565b600081613279670de0b6b3a7640000856147e8565b60008060008360020b126132b8576132b3600284900b612686565b6132c8565b6132c86112c0600285900b614493565b90506132e36112c06132dd620d89e7196148db565b60020b90565b8111156133165760405162461bcd60e51b81526020600482015260016024820152601560fa1b6044820152606401610397565b60008160011660000361332d57600160801b61333f565b6ffffcb933bd6fad37aa2d162d1a5940015b70ffffffffffffffffffffffffffffffffff169050600282161561337e576080613379826ffff97272373d413259a46990580e213a614859565b901c90505b60048216156133a85760806133a3826ffff2e50f5f656932ef12357cf3c7fdcc614859565b901c90505b60088216156133d25760806133cd826fffe5caca7e10e4e61c3624eaa0941cd0614859565b901c90505b60108216156133fc5760806133f7826fffcb9843d60f6159c9db58835c926644614859565b901c90505b6020821615613426576080613421826fff973b41fa98c081472e6896dfb254c0614859565b901c90505b604082161561345057608061344b826fff2ea16466c96a3843ec78b326b52861614859565b901c90505b608082161561347a576080613475826ffe5dee046a99a2a811c461f1969c3053614859565b901c90505b6101008216156134a55760806134a0826ffcbe86c7900a88aedcffc83b479aa3a4614859565b901c90505b6102008216156134d05760806134cb826ff987a7253ac413176f2b074cf7815e54614859565b901c90505b6104008216156134fb5760806134f6826ff3392b0822b70005940c7a398e4b70f3614859565b901c90505b610800821615613526576080613521826fe7159475a2c29b7443b29c7fa6e889d9614859565b901c90505b61100082161561355157608061354c826fd097f3bdfd2022b8845ad8f792aa5825614859565b901c90505b61200082161561357c576080613577826fa9f746462d870fdf8a65dc1f90e061e5614859565b901c90505b6140008216156135a75760806135a2826f70d869a156d2a1b890bb3df62baf32f7614859565b901c90505b6180008216156135d25760806135cd826f31be135f97d08fd981231505542fcfa6614859565b901c90505b620100008216156135fe5760806135f9826f09aa508b5b7a84e1c677de54f3e99bc9614859565b901c90505b62020000821615613629576080613624826e5d6af8dedb81196699c329225ee604614859565b901c90505b6204000082161561365357608061364e826d2216e584f5fa1ea926041bedfe98614859565b901c90505b6208000082161561367b576080613676826b048a170391f7dc42444e8fa2614859565b901c90505b60008460020b131561369657613693816000196147c0565b90505b6102ce6136a8640100000000836147d4565b156136b45760016136b7565b60005b6136c89060ff16602084901c6147ad565b6139ba565b6000808060001985870985870292508281108382030391505080600003613749576000841161373e5760405162461bcd60e51b815260206004820152601960248201527f48616e646c65206e6f6e2d6f766572666c6f77206361736573000000000000006044820152606401610397565b5082900490506102b4565b8084116137985760405162461bcd60e51b815260206004820152601960248201527f70726576656e74732064656e6f6d696e61746f72203d3d2030000000000000006044820152606401610397565b60008486880980840393811190920391905060006137d06137b887612569565b6137c188612569565b6137ca90614493565b16612686565b9586900495938490049360008190030460010190506137ef8184614859565b909317926000613800876003614859565b600218905061380f8188614859565b61381a9060026140a7565b6138249082614859565b90506138308188614859565b61383b9060026140a7565b6138459082614859565b90506138518188614859565b61385c9060026140a7565b6138669082614859565b90506138728188614859565b61387d9060026140a7565b6138879082614859565b90506138938188614859565b61389e9060026140a7565b6138a89082614859565b90506138b48188614859565b6138bf9060026140a7565b6138c99082614859565b90506138d58186614859565b9998505050505050505050565b604080516001600160e01b0319831660248083019190915282518083039091018152604490910182526020810180516001600160e01b03166301ffc9a760e01b1790529051600091829182916001600160a01b0387169161394391906148fd565b6000604051808303816000865af19150503d8060008114613980576040519150601f19603f3d011682016040523d82523d6000602084013e613985565b606091505b50915091508161399a57600092505050610116565b80516000036139ae57600092505050610116565b60200151949350505050565b60006001600160a01b038211156125295760405163dccde8ed60e01b815260040160405180910390fd5b6040518060800160405280600081526020016000815260200160008152602001600081525090565b828054828255906000526020600020908101928215613a47579160200282015b82811115613a47578251825591602001919060010190613a2c565b506125299291505b808211156125295760008155600101613a4f565b600060208284031215613a7557600080fd5b5035919050565b8151815260208083015190820152604080830151908201526060808301519082015260808101610116565b634e487b7160e01b600052602160045260246000fd5b600a8110613acd57613acd613aa7565b9052565b60005b83811015613aec578181015183820152602001613ad4565b50506000910152565b60008151808452613b0d816020860160208601613ad1565b601f01601f19169290920160200192915050565b60006020808352613b358184018551613abd565b8084015160606040850152613b4d6080850182613af5565b6040860151858203601f19016060870152805180835290840192506000918401905b80831015613b8f5783518252928401926001929092019190840190613b6f565b509695505050505050565b634e487b7160e01b600052604160045260246000fd5b6040516080810167ffffffffffffffff81118282101715613bd357613bd3613b9a565b60405290565b604051601f8201601f1916810167ffffffffffffffff81118282101715613c0257613c02613b9a565b604052919050565b600067ffffffffffffffff821115613c2457613c24613b9a565b5060051b60200190565b600082601f830112613c3f57600080fd5b81356020613c54613c4f83613c0a565b613bd9565b8083825260208201915060208460051b870101935086841115613c7657600080fd5b602086015b84811015613b8f5780358352918301918301613c7b565b600080600060608486031215613ca757600080fd5b8335600a8110613cb657600080fd5b925060208481013567ffffffffffffffff80821115613cd457600080fd5b818701915087601f830112613ce857600080fd5b813581811115613cfa57613cfa613b9a565b613d0c601f8201601f19168501613bd9565b8181528985838601011115613d2057600080fd5b818585018683013760009181019094015291935060408601359180831115613d4757600080fd5b5050613d5586828701613c2e565b9150509250925092565b600080600060608486031215613d7457600080fd5b83359250602084013567ffffffffffffffff80821115613d9357600080fd5b613d9f87838801613c2e565b93506040860135915080821115613db557600080fd5b50613d5586828701613c2e565b600181811c90821680613dd657607f821691505b602082108103611d0757634e487b7160e01b600052602260045260246000fd5b60008151808452602080850194506020840160005b83811015613e2757815187529582019590820190600101613e0b565b509495945050505050565b848152613e426020820185613abd565b608060408201526000613e586080830185613af5565b828103606084015261189a8185613df6565b634e487b7160e01b600052603260045260246000fd5b600060208284031215613e9257600080fd5b8151600981106102b457600080fd5b6020810160098310613eb557613eb5613aa7565b91905290565b6001600160a01b0381168114613ed057600080fd5b50565b600060208284031215613ee557600080fd5b81516102b481613ebb565b608080825285518282018190526000919060209060a0850190828a01855b82811015613f5257613f42848351805182526020810151602083015260408101516040830152606081015160608301525050565b9285019290840190600101613f0e565b5050508481036020860152613f678189613af5565b925050508281036040840152613f7d8186613df6565b9050828103606084015261189a8185613df6565b600060808284031215613fa357600080fd5b613fab613bb0565b825181526020830151602082015260408301516040820152606083015160608201528091505092915050565b805160ff8116811461200457600080fd5b600080600060608486031215613ffd57600080fd5b835161400881613ebb565b6020850151909350915061401e60408501613fd7565b90509250925092565b805169ffffffffffffffffffff8116811461200457600080fd5b600080600080600060a0868803121561405957600080fd5b61406286614027565b945060208601519350604086015192506060860151915061408560808701614027565b90509295509295909350565b634e487b7160e01b600052601160045260246000fd5b8181038181111561011657610116614091565b60008060008060008060c087890312156140d357600080fd5b86516140de81613ebb565b60208801519096506140ef81613ebb565b94506140fd60408801613fd7565b935061410b60608801613fd7565b9250608087015161411b81613ebb565b60a088015190925063ffffffff8116811461413557600080fd5b809150509295509295509295565b6020808252825182820181905260009190848201906040850190845b8181101561418157835163ffffffff168352928401929184019160010161415f565b50909695505050505050565b600082601f83011261419e57600080fd5b815160206141ae613c4f83613c0a565b8083825260208201915060208460051b8701019350868411156141d057600080fd5b602086015b84811015613b8f5780516141e881613ebb565b83529183019183016141d5565b6000806040838503121561420857600080fd5b825167ffffffffffffffff8082111561422057600080fd5b818501915085601f83011261423457600080fd5b81516020614244613c4f83613c0a565b82815260059290921b8401810191818101908984111561426357600080fd5b948201945b838610156142915785518060060b81146142825760008081fd5b82529482019490820190614268565b918801519196509093505050808211156142aa57600080fd5b506142b78582860161418d565b9150509250929050565b600682810b9082900b03667fffffffffffff198112667fffffffffffff8213171561011657610116614091565b634e487b7160e01b600052601260045260246000fd5b60008160060b8360060b8061431b5761431b6142ee565b667fffffffffffff1982146000198214161561433957614339614091565b90059392505050565b600082614351576143516142ee565b500790565b60008160020b627fffff19810361436f5761436f614091565b6000190192915050565b600181815b808511156143b457816000190482111561439a5761439a614091565b808516156143a757918102915b93841c939080029061437e565b509250929050565b6000826143cb57506001610116565b816143d857506000610116565b81600181146143ee57600281146143f857614414565b6001915050610116565b60ff84111561440957614409614091565b50506001821b610116565b5060208310610133831016604e8410600b8410161715614437575081810a610116565b6144418383614379565b806000190482111561445557614455614091565b029392505050565b60006102b460ff8416836143bc565b818103600083128015838313168383128216171561448c5761448c614091565b5092915050565b6000600160ff1b82016144a8576144a8614091565b5060000390565b8051801515811461200457600080fd5b6000806000606084860312156144d457600080fd5b83516144df81613ebb565b6020850151909350915061401e604085016144af565b60006080828403121561450757600080fd5b61450f613bb0565b82518060070b811461452057600080fd5b8152602083015167ffffffffffffffff8116811461453d57600080fd5b60208201526040830151600381900b811461455757600080fd5b60408201526060928301519281019290925250919050565b808201828112600083128015821682158216171561458f5761458f614091565b505092915050565b6000806000606084860312156145ac57600080fd5b83516145b781613ebb565b602085015160409095015190969495509392505050565b60ff8416815267ffffffffffffffff831660208201526060604082015260006145fa6060830184613df6565b95945050505050565b6001600160a01b03831681526040602082018190526000906102ce90830184613af5565b60006020828403121561463957600080fd5b5051919050565b60008261464f5761464f6142ee565b600160ff1b82146000198414161561466957614669614091565b500590565b6146788185613abd565b60606020820152600061468e6060830185613af5565b8281036040840152610f4f8185613df6565b601f8211156146e8576000816000526020600020601f850160051c810160208610156146c95750805b601f850160051c820191505b8181101561325c578281556001016146d5565b505050565b815167ffffffffffffffff81111561470757614707613b9a565b61471b816147158454613dc2565b846146a0565b602080601f83116001811461475057600084156147385750858301515b600019600386901b1c1916600185901b17855561325c565b600085815260208120601f198616915b8281101561477f57888601518255948401946001909101908401614760565b508582101561479d5787850151600019600388901b60f8161c191681555b5050505050600190811b01905550565b8082018082111561011657610116614091565b6000826147cf576147cf6142ee565b500490565b6000826147e3576147e36142ee565b500690565b80820260008212600160ff1b8414161561480457614804614091565b818105831482151761011657610116614091565b600069ffffffffffffffffffff82168061436f5761436f614091565b60006001820161484657614846614091565b5060010190565b60006102b483836143bc565b808202811582820484141761011657610116614091565b60006020828403121561488257600080fd5b6102b4826144af565b60006020828403121561489d57600080fd5b6102b482613fd7565b60006001600160ff1b01820161484657614846614091565b6000600160ff1b82016148d3576148d3614091565b506000190190565b60008160020b627fffff1981036148f4576148f4614091565b60000392915050565b6000825161490f818460208701613ad1565b919091019291505056fea264697066735822122074f32fef384fdc296b0859f1c1f941c8e736c6cb972aa9e2b894956ebd6a80b364736f6c63430008160033","r":"0x1","s":"0x1","yParity":"0x0","hash":"0xbc73db80bf4b8784ba10a8910a0b7ef85f6846d102b41dd990969ea205335354"}}],"ommers":[]},{"header":{"parentHash":"0x026ae0c6ae91f186a9befa1ac8be30eea35e30e77de51a731085221e5cd39209","sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347","miner":"0x0000000000000000000000000000000000000000","stateRoot":"0xb6003e7ba07a15a9e35f63daa484728ec4ceeded0c4d10ac1b04e9552d412b3c","transactionsRoot":"0x6e4969a136061ca7a390d12830d47a151585325a8d396819fb2b958ff85e9f8f","receiptsRoot":"0xc3e81df67d3e2a6c8345a954ef250cfcc41abcc2292a5aa263071124533fc9ad","logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","difficulty":"0x0","number":"0x3","gasLimit":"0x1c9c380","gasUsed":"0x3c0f6","timestamp":"0x66b200ce","mixHash":"0x0000000000000000000000000000000000000000000000000000000000000000","nonce":"0x0000000000000000","baseFeePerGas":"0x18993a68","blobGasUsed":"0x0","excessBlobGas":"0x0","extraData":"0x"},"transactions":[{"EIP1559":{"chainId":"0x343a","nonce":"0x0","gas":"0x3c0f6","maxFeePerGas":"0x5d4285cd","maxPriorityFeePerGas":"0x3b9aca00","value":"0x0","accessList":[],"input":"0x608060405234801561001057600080fd5b50610380806100206000396000f3fe6080604052600080357fffffffff0000000000000000000000000000000000000000000000000000000016905060008160e01c610251565b60006379ba509782101561015e5781631627540c811461009857632a952b2d81146100b457633659cfe681146100d0576350c946fe81146100ec576353a47bb781146101085763625ca21c81146101245763718fe928811461014057610158565b7347d08dad17ccb558b3ea74b1a0e73a9cc804a9dc9150610158565b738138ef7cf908021d117e542120b7a390650161079150610158565b7347d08dad17ccb558b3ea74b1a0e73a9cc804a9dc9150610158565b738138ef7cf908021d117e542120b7a390650161079150610158565b7347d08dad17ccb558b3ea74b1a0e73a9cc804a9dc9150610158565b738138ef7cf908021d117e542120b7a390650161079150610158565b7347d08dad17ccb558b3ea74b1a0e73a9cc804a9dc91505b5061024c565b816379ba509781146101a657638da5cb5b81146101c25763aaf10f4281146101de5763c7f62cda81146101fa5763daa250be81146102165763deba1b9881146102325761024a565b7347d08dad17ccb558b3ea74b1a0e73a9cc804a9dc915061024a565b7347d08dad17ccb558b3ea74b1a0e73a9cc804a9dc915061024a565b7347d08dad17ccb558b3ea74b1a0e73a9cc804a9dc915061024a565b7347d08dad17ccb558b3ea74b1a0e73a9cc804a9dc915061024a565b738138ef7cf908021d117e542120b7a39065016107915061024a565b738138ef7cf908021d117e542120b7a3906501610791505b505b919050565b61025a81610037565b915050600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff16036102ce57816040517fc2a825f50000000000000000000000000000000000000000000000000000000081526004016102c5919061032f565b60405180910390fd5b3660008037600080366000845af43d6000803e80600081146102ef573d6000f35b3d6000fd5b60007fffffffff0000000000000000000000000000000000000000000000000000000082169050919050565b610329816102f4565b82525050565b60006020820190506103446000830184610320565b9291505056fea264697066735822122017a4b7fdaaab3897a7b47abaed8d2ee92d558883d3bb2a8454f9601b2ab2c3db64736f6c63430008150033","r":"0x1","s":"0x1","yParity":"0x0","hash":"0x2476e039803622aeb040f924f04c493f559aed3d6c9372ab405cb33c8c695328"}}],"ommers":[]},{"header":{"parentHash":"0x3d22100ac0ee8d5cde334f7f926191a861b0648971ebc179547df28a0224c6d0","sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347","miner":"0x0000000000000000000000000000000000000000","stateRoot":"0x9511d4711e5c30a72b0bff38a261daa75dcc5ba8b772d970a5c742244b4c861b","transactionsRoot":"0xba5fff578d3d6c2cd63acbe9bca353eaa6fe22a5c408956eff49106e0a96c507","receiptsRoot":"0xbae111f01cb07677e3a8c5031546138407c01bc964d3493d732dc4edf47d36d3","logsBloom":"0x00000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000020000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000001000000000000000000000400000001000010000000000000000000000000000000000000000000000000000000000000000000000000","difficulty":"0x0","number":"0x5","gasLimit":"0x1c9c380","gasUsed":"0xcae7","timestamp":"0x66b200cb","mixHash":"0x0000000000000000000000000000000000000000000000000000000000000000","nonce":"0x0000000000000000","baseFeePerGas":"0x12e09c7a","blobGasUsed":"0x0","excessBlobGas":"0x0","extraData":"0x"},"transactions":[{"EIP1559":{"chainId":"0x343a","nonce":"0x0","gas":"0xcc4d","maxFeePerGas":"0x557e5ec4","maxPriorityFeePerGas":"0x3b9aca00","to":"0x83a0444b93927c3afcbe46e522280390f748e171","value":"0x0","accessList":[],"input":"0x3659cfe6000000000000000000000000108f53faf774d7c4c56f5bce9ca6e605ce8aeadd","r":"0x1","s":"0x1","yParity":"0x0","hash":"0xf88e7b19ee347145c257e0cf7ac4ecc2bae83ca79d7edaa231e71d3213aeb151"}}],"ommers":[]},{"header":{"parentHash":"0x08abe6e453727534d8dd708843a7522b7d500338bdfe2402ca105dcdb05eebe9","sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347","miner":"0x0000000000000000000000000000000000000000","stateRoot":"0x9c8eaf493f8b4edce2ba1647343eadcc0989cf461e712c0a6253ff2ca1842bb7","transactionsRoot":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421","receiptsRoot":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421","logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","difficulty":"0x0","number":"0x1","gasLimit":"0x1c9c380","gasUsed":"0x0","timestamp":"0x66b200ca","mixHash":"0x0000000000000000000000000000000000000000000000000000000000000000","nonce":"0x0000000000000000","baseFeePerGas":"0x3b9aca00","blobGasUsed":"0x0","excessBlobGas":"0x0","extraData":"0x"},"transactions":[],"ommers":[]},{"header":{"parentHash":"0xdd07c07470e1deff3749831f0f1ad8d4b6e35505e83b3c6ea14181716197cd8a","sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347","miner":"0x0000000000000000000000000000000000000000","stateRoot":"0x29aa352e71b139e83b397bdd3dcf9b65d74770edaf3a9624d0dbc4f96f868680","transactionsRoot":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421","receiptsRoot":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421","logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","difficulty":"0x0","number":"0x2","gasLimit":"0x1c9c380","gasUsed":"0x0","timestamp":"0x66b200cb","mixHash":"0x0000000000000000000000000000000000000000000000000000000000000000","nonce":"0x0000000000000000","baseFeePerGas":"0x24a1ab52","blobGasUsed":"0x0","excessBlobGas":"0x0","extraData":"0x"},"transactions":[],"ommers":[]},{"header":{"parentHash":"0x0000000000000000000000000000000000000000000000000000000000000000","sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347","miner":"0x0000000000000000000000000000000000000000","stateRoot":"0x0000000000000000000000000000000000000000000000000000000000000000","transactionsRoot":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421","receiptsRoot":"0x0000000000000000000000000000000000000000000000000000000000000000","logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","difficulty":"0x0","number":"0x0","gasLimit":"0x1c9c380","gasUsed":"0x0","timestamp":"0x66b200c9","mixHash":"0x0000000000000000000000000000000000000000000000000000000000000000","nonce":"0x0000000000000000","baseFeePerGas":"0x3b9aca00","blobGasUsed":"0x0","excessBlobGas":"0x0","extraData":"0x"},"transactions":[],"ommers":[]},{"header":{"parentHash":"0xf6930be4847cac5017bbcbec2756eed19f36b4196526a98a88e311c296e3a9be","sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347","miner":"0x0000000000000000000000000000000000000000","stateRoot":"0x29aa352e71b139e83b397bdd3dcf9b65d74770edaf3a9624d0dbc4f96f868680","transactionsRoot":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421","receiptsRoot":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421","logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","difficulty":"0x0","number":"0x1","gasLimit":"0x1c9c380","gasUsed":"0x0","timestamp":"0x66b200cc","mixHash":"0x0000000000000000000000000000000000000000000000000000000000000000","nonce":"0x0000000000000000","baseFeePerGas":"0x200d75e8","blobGasUsed":"0x0","excessBlobGas":"0x0","extraData":"0x"},"transactions":[],"ommers":[]},{"header":{"parentHash":"0x08abe6e453727534d8dd708843a7522b7d500338bdfe2402ca105dcdb05eebe9","sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347","miner":"0x0000000000000000000000000000000000000000","stateRoot":"0xb6003e7ba07a15a9e35f63daa484728ec4ceeded0c4d10ac1b04e9552d412b3c","transactionsRoot":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421","receiptsRoot":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421","logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","difficulty":"0x0","number":"0x4","gasLimit":"0x1c9c380","gasUsed":"0x0","timestamp":"0x66b200ca","mixHash":"0x0000000000000000000000000000000000000000000000000000000000000000","nonce":"0x0000000000000000","baseFeePerGas":"0x1592fbf9","blobGasUsed":"0x0","excessBlobGas":"0x0","extraData":"0x"},"transactions":[],"ommers":[]},{"header":{"parentHash":"0x149d41e3b89d8324cef3feff98ef308e97bafe8745cc8461c60172bc7d4c44ba","sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347","miner":"0x0000000000000000000000000000000000000000","stateRoot":"0x510f2275449c013534a25ad0b13c867caf720947b68bcbcd4863f7b172a5d023","transactionsRoot":"0x0b44110186e52ff0ceb6b0776ca2992c94144a4ed712eef65ea038260ef0fcc7","receiptsRoot":"0xc2823b8eb4730d9f2657137cc2ddc2c4f22ab68e0ab826236cf6a1551ca2b3a5","logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","difficulty":"0x0","number":"0x2","gasLimit":"0x1c9c380","gasUsed":"0xe61f9","timestamp":"0x66b200cb","mixHash":"0x0000000000000000000000000000000000000000000000000000000000000000","nonce":"0x0000000000000000","baseFeePerGas":"0x342770c0","blobGasUsed":"0x0","excessBlobGas":"0x0","extraData":"0x"},"transactions":[{"EIP1559":{"chainId":"0x343a","nonce":"0x0","gas":"0xe94d1","maxFeePerGas":"0x83215600","maxPriorityFeePerGas":"0x3b9aca00","to":"0x4e59b44847b379578588920ca78fbf26c0b4956c","value":"0x0","accessList":[],"input":"0x4786e4342646b3ba97c1790b6cf5a55087a36240b22570f5d3a5d6bcc929d93b608060405234801561001057600080fd5b5060008061002661006d60201b61081b1760201c565b60000160006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff16021790555050610141565b60008060405160200161007f90610121565b6040516020818303038152906040528051906020012090508091505090565b600082825260208201905092915050565b7f696f2e73796e7468657469782e636f72652d636f6e7472616374732e4f776e6160008201527f626c650000000000000000000000000000000000000000000000000000000000602082015250565b600061010b60238361009e565b9150610116826100af565b604082019050919050565b6000602082019050818103600083015261013a816100fe565b9050919050565b611000806101506000396000f3fe608060405234801561001057600080fd5b50600436106100885760003560e01c806379ba50971161005b57806379ba5097146100ed5780638da5cb5b146100f7578063aaf10f4214610115578063c7f62cda1461013357610088565b80631627540c1461008d5780633659cfe6146100a957806353a47bb7146100c5578063718fe928146100e3575b600080fd5b6100a760048036038101906100a29190610d25565b61014f565b005b6100c360048036038101906100be9190610d25565b6102d0565b005b6100cd6102e4565b6040516100da9190610d61565b60405180910390f35b6100eb610317565b005b6100f56103fe565b005b6100ff61058b565b60405161010c9190610d61565b60405180910390f35b61011d6105be565b60405161012a9190610d61565b60405180910390f35b61014d60048036038101906101489190610d25565b6105f1565b005b61015761084c565b600061016161081b565b9050600073ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff16036101c9576040517fd92e233d00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b8060010160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1603610252576040517fa88ee57700000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b818160010160006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055507f906a1c6bd7e3091ea86693dd029a831c19049ce77f1dce2ce0bab1cacbabce22826040516102c49190610d61565b60405180910390a15050565b6102d861084c565b6102e1816108c5565b50565b60006102ee61081b565b60010160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16905090565b600061032161081b565b90503373ffffffffffffffffffffffffffffffffffffffff168160010160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16146103b757336040517fa0e5a0d70000000000000000000000000000000000000000000000000000000081526004016103ae9190610d61565b60405180910390fd5b60008160010160006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff16021790555050565b600061040861081b565b905060008160010160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1690508073ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff16146104a357336040517fa0e5a0d700000000000000000000000000000000000000000000000000000000815260040161049a9190610d61565b60405180910390fd5b7fb532073b38c83145e3e5135377a08bf9aab55bc0fd7c1179cd4fb995d2a5159c8260000160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16826040516104f8929190610d7c565b60405180910390a1808260000160006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff16021790555060008260010160006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055505050565b600061059561081b565b60000160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16905090565b60006105c8610b05565b60000160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16905090565b60006105fb610b05565b905060018160000160146101000a81548160ff02191690831515021790555060008160000160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff169050828260000160006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff16021790555060008373ffffffffffffffffffffffffffffffffffffffff163073ffffffffffffffffffffffffffffffffffffffff16633659cfe6846040516024016106cc9190610d61565b604051602081830303815290604052915060e01b6020820180517bffffffffffffffffffffffffffffffffffffffffffffffffffffffff838183161783525050505060405161071b9190610e16565b600060405180830381855af49150503d8060008114610756576040519150601f19603f3d011682016040523d82523d6000602084013e61075b565b606091505b505090508015806107c357508173ffffffffffffffffffffffffffffffffffffffff16610786610b05565b60000160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614155b156107fa576040517fa1cfa5a800000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b60008360000160146101000a81548160ff0219169083151502179055600080fd5b60008060405160200161082d90610eb0565b6040516020818303038152906040528051906020012090508091505090565b610854610b36565b73ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff16146108c357336040517f8e4a23d60000000000000000000000000000000000000000000000000000000081526004016108ba9190610d61565b60405180910390fd5b565b600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff160361092b576040517fd92e233d00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b61093481610b69565b61097557806040517f8a8b41ec00000000000000000000000000000000000000000000000000000000815260040161096c9190610d61565b60405180910390fd5b600061097f610b05565b90508060000160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1603610a0a576040517fa88ee57700000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b8060000160149054906101000a900460ff16158015610a2e5750610a2d82610b7c565b5b15610a7057816040517f15504301000000000000000000000000000000000000000000000000000000008152600401610a679190610d61565b60405180910390fd5b818160000160006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055503073ffffffffffffffffffffffffffffffffffffffff167f5d611f318680d00598bb735d61bacf0c514c6b50e1e5ad30040a4df2b12791c783604051610af99190610d61565b60405180910390a25050565b600080604051602001610b1790610f42565b6040516020818303038152906040528051906020012090508091505090565b6000610b4061081b565b60000160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16905090565b600080823b905060008111915050919050565b60008060003073ffffffffffffffffffffffffffffffffffffffff163073ffffffffffffffffffffffffffffffffffffffff1663c7f62cda86604051602401610bc59190610d61565b604051602081830303815290604052915060e01b6020820180517bffffffffffffffffffffffffffffffffffffffffffffffffffffffff8381831617835250505050604051610c149190610e16565b600060405180830381855af49150503d8060008114610c4f576040519150601f19603f3d011682016040523d82523d6000602084013e610c54565b606091505b509150915081158015610cb9575063a1cfa5a860e01b604051602001610c7a9190610faf565b6040516020818303038152906040528051906020012081604051602001610ca19190610e16565b60405160208183030381529060405280519060200120145b92505050919050565b600080fd5b600073ffffffffffffffffffffffffffffffffffffffff82169050919050565b6000610cf282610cc7565b9050919050565b610d0281610ce7565b8114610d0d57600080fd5b50565b600081359050610d1f81610cf9565b92915050565b600060208284031215610d3b57610d3a610cc2565b5b6000610d4984828501610d10565b91505092915050565b610d5b81610ce7565b82525050565b6000602082019050610d766000830184610d52565b92915050565b6000604082019050610d916000830185610d52565b610d9e6020830184610d52565b9392505050565b600081519050919050565b600081905092915050565b60005b83811015610dd9578082015181840152602081019050610dbe565b60008484015250505050565b6000610df082610da5565b610dfa8185610db0565b9350610e0a818560208601610dbb565b80840191505092915050565b6000610e228284610de5565b915081905092915050565b600082825260208201905092915050565b7f696f2e73796e7468657469782e636f72652d636f6e7472616374732e4f776e6160008201527f626c650000000000000000000000000000000000000000000000000000000000602082015250565b6000610e9a602383610e2d565b9150610ea582610e3e565b604082019050919050565b60006020820190508181036000830152610ec981610e8d565b9050919050565b7f696f2e73796e7468657469782e636f72652d636f6e7472616374732e50726f7860008201527f7900000000000000000000000000000000000000000000000000000000000000602082015250565b6000610f2c602183610e2d565b9150610f3782610ed0565b604082019050919050565b60006020820190508181036000830152610f5b81610f1f565b9050919050565b60007fffffffff0000000000000000000000000000000000000000000000000000000082169050919050565b6000819050919050565b610fa9610fa482610f62565b610f8e565b82525050565b6000610fbb8284610f98565b6004820191508190509291505056fea264697066735822122023a7c33d7b91dce35ffbcf8837693364ab22a3905d0fc00016833e5fac45ca2f64736f6c63430008110033","r":"0x1","s":"0x1","yParity":"0x0","hash":"0x4feae6769d748b4f0f7c9bf21d782236c88f13906789a3ec602961296e4c3e43"}}],"ommers":[]},{"header":{"parentHash":"0xb3535af5103fd1c2bbd6dc7ff23f0799037a6542c231ebcb85abd776560fa512","sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347","miner":"0x0000000000000000000000000000000000000000","stateRoot":"0x23d74fb99ff6e42cbb5c33f92b078e37be6af2b6092459b103ff7059a6517ebc","transactionsRoot":"0x9eab45eca206fe11c107ea985c7d02fcfa442836aea3e04ba11dc4df587d5aa6","receiptsRoot":"0xe25abcfa973db8c55f73292137c626430de130a382ad4466337fefb0f7c8fde0","logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","difficulty":"0x0","number":"0x2","gasLimit":"0x1c9c380","gasUsed":"0x3ce3f","timestamp":"0x66b200cd","mixHash":"0x0000000000000000000000000000000000000000000000000000000000000000","nonce":"0x0000000000000000","baseFeePerGas":"0x1c0bc72b","blobGasUsed":"0x0","excessBlobGas":"0x0","extraData":"0x"},"transactions":[{"EIP1559":{"chainId":"0x343a","nonce":"0x0","gas":"0x3d8a8","maxFeePerGas":"0x6211577c","maxPriorityFeePerGas":"0x3b9aca00","to":"0x4e59b44847b379578588920ca78fbf26c0b4956c","value":"0x0","accessList":[],"input":"0x4786e4342646b3ba97c1790b6cf5a55087a36240b22570f5d3a5d6bcc929d93b608060405234801561001057600080fd5b5060405161068538038061068583398181016040528101906100329190610275565b818181600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff160361009b576040517fd92e233d00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b6100ae8161019d60201b61004f1760201c565b6100ef57806040517f8a8b41ec0000000000000000000000000000000000000000000000000000000081526004016100e691906102c4565b60405180910390fd5b806100fe6101b060201b60201c565b60000160006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff16021790555050806101536101e160201b6100621760201c565b60000160006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff16021790555050505050610414565b600080823b905060008111915050919050565b6000806040516020016101c290610362565b6040516020818303038152906040528051906020012090508091505090565b6000806040516020016101f3906103f4565b6040516020818303038152906040528051906020012090508091505090565b600080fd5b600073ffffffffffffffffffffffffffffffffffffffff82169050919050565b600061024282610217565b9050919050565b61025281610237565b811461025d57600080fd5b50565b60008151905061026f81610249565b92915050565b6000806040838503121561028c5761028b610212565b5b600061029a85828601610260565b92505060206102ab85828601610260565b9150509250929050565b6102be81610237565b82525050565b60006020820190506102d960008301846102b5565b92915050565b600082825260208201905092915050565b7f696f2e73796e7468657469782e636f72652d636f6e7472616374732e50726f7860008201527f7900000000000000000000000000000000000000000000000000000000000000602082015250565b600061034c6021836102df565b9150610357826102f0565b604082019050919050565b6000602082019050818103600083015261037b8161033f565b9050919050565b7f696f2e73796e7468657469782e636f72652d636f6e7472616374732e4f776e6160008201527f626c650000000000000000000000000000000000000000000000000000000000602082015250565b60006103de6023836102df565b91506103e982610382565b604082019050919050565b6000602082019050818103600083015261040d816103d1565b9050919050565b610262806104236000396000f3fe6080604052366100135761001161001d565b005b61001b61001d565b005b6000610027610093565b90503660008037600080366000845af43d6000803e806000811461004a573d6000f35b3d6000fd5b600080823b905060008111915050919050565b6000806040516020016100749061017a565b6040516020818303038152906040528051906020012090508091505090565b600061009d6100c6565b60000160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16905090565b6000806040516020016100d89061020c565b6040516020818303038152906040528051906020012090508091505090565b600082825260208201905092915050565b7f696f2e73796e7468657469782e636f72652d636f6e7472616374732e4f776e6160008201527f626c650000000000000000000000000000000000000000000000000000000000602082015250565b60006101646023836100f7565b915061016f82610108565b604082019050919050565b6000602082019050818103600083015261019381610157565b9050919050565b7f696f2e73796e7468657469782e636f72652d636f6e7472616374732e50726f7860008201527f7900000000000000000000000000000000000000000000000000000000000000602082015250565b60006101f66021836100f7565b91506102018261019a565b604082019050919050565b60006020820190508181036000830152610225816101e9565b905091905056fea2646970667358221220800da1f73cebd5e4afa07496d9bca6b6c4f526bdd3f4014ec15c70fe3a1c441364736f6c6343000811003300000000000000000000000047d08dad17ccb558b3ea74b1a0e73a9cc804a9dc000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266","r":"0x1","s":"0x1","yParity":"0x0","hash":"0xb6794d5c7abed6f91d447e8efb72ef2580595a6d7c8dee57ba1dbb330970146a"}}],"ommers":[]},{"header":{"parentHash":"0x08abe6e453727534d8dd708843a7522b7d500338bdfe2402ca105dcdb05eebe9","sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347","miner":"0x0000000000000000000000000000000000000000","stateRoot":"0x510f2275449c013534a25ad0b13c867caf720947b68bcbcd4863f7b172a5d023","transactionsRoot":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421","receiptsRoot":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421","logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","difficulty":"0x0","number":"0x3","gasLimit":"0x1c9c380","gasUsed":"0x0","timestamp":"0x66b200ca","mixHash":"0x0000000000000000000000000000000000000000000000000000000000000000","nonce":"0x0000000000000000","baseFeePerGas":"0x29dd5614","blobGasUsed":"0x0","excessBlobGas":"0x0","extraData":"0x"},"transactions":[],"ommers":[]}]} \ No newline at end of file +{"block":{"number":5,"beneficiary":"0x0000000000000000000000000000000000000000","timestamp":1722941643,"gas_limit":30000000,"basefee":316710010,"difficulty":"0x0","prevrandao":"0xe7ef87fc7c2090741a6749a087e4ca8092cb4d07136008799e4ebeac3b69e34a","blob_excess_gas_and_price":{"excess_blob_gas":0,"blob_gasprice":1}},"accounts":{"0x0000000000000000000000000000000000000000":{"nonce":0,"balance":"0x1088aa62285a00","code":"0x","storage":{}},"0x108f53faf774d7c4c56f5bce9ca6e605ce8aeadd":{"nonce":1,"balance":"0x0","code":"0x6080604052600080357fffffffff0000000000000000000000000000000000000000000000000000000016905060008160e01c610251565b60006379ba509782101561015e5781631627540c811461009857632a952b2d81146100b457633659cfe681146100d0576350c946fe81146100ec576353a47bb781146101085763625ca21c81146101245763718fe928811461014057610158565b7347d08dad17ccb558b3ea74b1a0e73a9cc804a9dc9150610158565b738138ef7cf908021d117e542120b7a390650161079150610158565b7347d08dad17ccb558b3ea74b1a0e73a9cc804a9dc9150610158565b738138ef7cf908021d117e542120b7a390650161079150610158565b7347d08dad17ccb558b3ea74b1a0e73a9cc804a9dc9150610158565b738138ef7cf908021d117e542120b7a390650161079150610158565b7347d08dad17ccb558b3ea74b1a0e73a9cc804a9dc91505b5061024c565b816379ba509781146101a657638da5cb5b81146101c25763aaf10f4281146101de5763c7f62cda81146101fa5763daa250be81146102165763deba1b9881146102325761024a565b7347d08dad17ccb558b3ea74b1a0e73a9cc804a9dc915061024a565b7347d08dad17ccb558b3ea74b1a0e73a9cc804a9dc915061024a565b7347d08dad17ccb558b3ea74b1a0e73a9cc804a9dc915061024a565b7347d08dad17ccb558b3ea74b1a0e73a9cc804a9dc915061024a565b738138ef7cf908021d117e542120b7a39065016107915061024a565b738138ef7cf908021d117e542120b7a3906501610791505b505b919050565b61025a81610037565b915050600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff16036102ce57816040517fc2a825f50000000000000000000000000000000000000000000000000000000081526004016102c5919061032f565b60405180910390fd5b3660008037600080366000845af43d6000803e80600081146102ef573d6000f35b3d6000fd5b60007fffffffff0000000000000000000000000000000000000000000000000000000082169050919050565b610329816102f4565b82525050565b60006020820190506103446000830184610320565b9291505056fea264697066735822122017a4b7fdaaab3897a7b47abaed8d2ee92d558883d3bb2a8454f9601b2ab2c3db64736f6c63430008150033","storage":{}},"0x14dc79964da2c08b23698b3d3cc7ca32193d9955":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0x15d34aaf54267db7d7c367839aaf71a00a2c6a65":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0x19ba1fac55eea44d12a01372a8eb0c2ebbf9ca21":{"nonce":1,"balance":"0x21e19df7c2963f0ac6b","code":"0x","storage":{}},"0x19c6ab860dbe2bc433574193a4409770a8748bf6":{"nonce":1,"balance":"0x21e19df8da6b7bdc410","code":"0x","storage":{}},"0x23618e81e3f5cdf7f54c3d65f7fbc0abf5b21e8f":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0x40567ec443c1d1872af5155755ac3803cc3fe61e":{"nonce":1,"balance":"0x21e19da82562f921b40","code":"0x","storage":{}},"0x47d08dad17ccb558b3ea74b1a0e73a9cc804a9dc":{"nonce":1,"balance":"0x0","code":"0x608060405234801561001057600080fd5b50600436106100885760003560e01c806379ba50971161005b57806379ba5097146100ed5780638da5cb5b146100f7578063aaf10f4214610115578063c7f62cda1461013357610088565b80631627540c1461008d5780633659cfe6146100a957806353a47bb7146100c5578063718fe928146100e3575b600080fd5b6100a760048036038101906100a29190610d25565b61014f565b005b6100c360048036038101906100be9190610d25565b6102d0565b005b6100cd6102e4565b6040516100da9190610d61565b60405180910390f35b6100eb610317565b005b6100f56103fe565b005b6100ff61058b565b60405161010c9190610d61565b60405180910390f35b61011d6105be565b60405161012a9190610d61565b60405180910390f35b61014d60048036038101906101489190610d25565b6105f1565b005b61015761084c565b600061016161081b565b9050600073ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff16036101c9576040517fd92e233d00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b8060010160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1603610252576040517fa88ee57700000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b818160010160006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055507f906a1c6bd7e3091ea86693dd029a831c19049ce77f1dce2ce0bab1cacbabce22826040516102c49190610d61565b60405180910390a15050565b6102d861084c565b6102e1816108c5565b50565b60006102ee61081b565b60010160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16905090565b600061032161081b565b90503373ffffffffffffffffffffffffffffffffffffffff168160010160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16146103b757336040517fa0e5a0d70000000000000000000000000000000000000000000000000000000081526004016103ae9190610d61565b60405180910390fd5b60008160010160006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff16021790555050565b600061040861081b565b905060008160010160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1690508073ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff16146104a357336040517fa0e5a0d700000000000000000000000000000000000000000000000000000000815260040161049a9190610d61565b60405180910390fd5b7fb532073b38c83145e3e5135377a08bf9aab55bc0fd7c1179cd4fb995d2a5159c8260000160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16826040516104f8929190610d7c565b60405180910390a1808260000160006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff16021790555060008260010160006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055505050565b600061059561081b565b60000160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16905090565b60006105c8610b05565b60000160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16905090565b60006105fb610b05565b905060018160000160146101000a81548160ff02191690831515021790555060008160000160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff169050828260000160006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff16021790555060008373ffffffffffffffffffffffffffffffffffffffff163073ffffffffffffffffffffffffffffffffffffffff16633659cfe6846040516024016106cc9190610d61565b604051602081830303815290604052915060e01b6020820180517bffffffffffffffffffffffffffffffffffffffffffffffffffffffff838183161783525050505060405161071b9190610e16565b600060405180830381855af49150503d8060008114610756576040519150601f19603f3d011682016040523d82523d6000602084013e61075b565b606091505b505090508015806107c357508173ffffffffffffffffffffffffffffffffffffffff16610786610b05565b60000160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614155b156107fa576040517fa1cfa5a800000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b60008360000160146101000a81548160ff0219169083151502179055600080fd5b60008060405160200161082d90610eb0565b6040516020818303038152906040528051906020012090508091505090565b610854610b36565b73ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff16146108c357336040517f8e4a23d60000000000000000000000000000000000000000000000000000000081526004016108ba9190610d61565b60405180910390fd5b565b600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff160361092b576040517fd92e233d00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b61093481610b69565b61097557806040517f8a8b41ec00000000000000000000000000000000000000000000000000000000815260040161096c9190610d61565b60405180910390fd5b600061097f610b05565b90508060000160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1603610a0a576040517fa88ee57700000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b8060000160149054906101000a900460ff16158015610a2e5750610a2d82610b7c565b5b15610a7057816040517f15504301000000000000000000000000000000000000000000000000000000008152600401610a679190610d61565b60405180910390fd5b818160000160006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055503073ffffffffffffffffffffffffffffffffffffffff167f5d611f318680d00598bb735d61bacf0c514c6b50e1e5ad30040a4df2b12791c783604051610af99190610d61565b60405180910390a25050565b600080604051602001610b1790610f42565b6040516020818303038152906040528051906020012090508091505090565b6000610b4061081b565b60000160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16905090565b600080823b905060008111915050919050565b60008060003073ffffffffffffffffffffffffffffffffffffffff163073ffffffffffffffffffffffffffffffffffffffff1663c7f62cda86604051602401610bc59190610d61565b604051602081830303815290604052915060e01b6020820180517bffffffffffffffffffffffffffffffffffffffffffffffffffffffff8381831617835250505050604051610c149190610e16565b600060405180830381855af49150503d8060008114610c4f576040519150601f19603f3d011682016040523d82523d6000602084013e610c54565b606091505b509150915081158015610cb9575063a1cfa5a860e01b604051602001610c7a9190610faf565b6040516020818303038152906040528051906020012081604051602001610ca19190610e16565b60405160208183030381529060405280519060200120145b92505050919050565b600080fd5b600073ffffffffffffffffffffffffffffffffffffffff82169050919050565b6000610cf282610cc7565b9050919050565b610d0281610ce7565b8114610d0d57600080fd5b50565b600081359050610d1f81610cf9565b92915050565b600060208284031215610d3b57610d3a610cc2565b5b6000610d4984828501610d10565b91505092915050565b610d5b81610ce7565b82525050565b6000602082019050610d766000830184610d52565b92915050565b6000604082019050610d916000830185610d52565b610d9e6020830184610d52565b9392505050565b600081519050919050565b600081905092915050565b60005b83811015610dd9578082015181840152602081019050610dbe565b60008484015250505050565b6000610df082610da5565b610dfa8185610db0565b9350610e0a818560208601610dbb565b80840191505092915050565b6000610e228284610de5565b915081905092915050565b600082825260208201905092915050565b7f696f2e73796e7468657469782e636f72652d636f6e7472616374732e4f776e6160008201527f626c650000000000000000000000000000000000000000000000000000000000602082015250565b6000610e9a602383610e2d565b9150610ea582610e3e565b604082019050919050565b60006020820190508181036000830152610ec981610e8d565b9050919050565b7f696f2e73796e7468657469782e636f72652d636f6e7472616374732e50726f7860008201527f7900000000000000000000000000000000000000000000000000000000000000602082015250565b6000610f2c602183610e2d565b9150610f3782610ed0565b604082019050919050565b60006020820190508181036000830152610f5b81610f1f565b9050919050565b60007fffffffff0000000000000000000000000000000000000000000000000000000082169050919050565b6000819050919050565b610fa9610fa482610f62565b610f8e565b82525050565b6000610fbb8284610f98565b6004820191508190509291505056fea264697066735822122023a7c33d7b91dce35ffbcf8837693364ab22a3905d0fc00016833e5fac45ca2f64736f6c63430008110033","storage":{"0x5c7865864a2a990d80b5bb5c40e7b73a029960dc711fbb56120dfab976e92ea3":"0x0"}},"0x4e59b44847b379578588920ca78fbf26c0b4956c":{"nonce":2,"balance":"0x0","code":"0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe03601600081602082378035828234f58015156039578182fd5b8082525050506014600cf3","storage":{}},"0x70997970c51812dc3a010c7d01b50e0d17dc79c8":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0x8138ef7cf908021d117e542120b7a39065016107":{"nonce":1,"balance":"0x0","code":"0x608060405234801561001057600080fd5b50600436106100575760003560e01c80632a952b2d1461005c57806350c946fe14610085578063625ca21c146100a5578063daa250be146100c6578063deba1b98146100d9575b600080fd5b61006f61006a366004613a63565b6100ec565b60405161007c9190613a7c565b60405180910390f35b610098610093366004613a63565b61011c565b60405161007c9190613b21565b6100b86100b3366004613c92565b610276565b60405190815260200161007c565b61006f6100d4366004613d5f565b6102bb565b6100b86100e7366004613c92565b6102d6565b6100f46139e4565b6040805160008082526020820190815281830190925261011691849190610310565b92915050565b6101416040805160608101909152806000815260200160608152602001606081525090565b61014a82610ab6565b60408051606081019091528154909190829060ff16600981111561017057610170613aa7565b600981111561018157610181613aa7565b815260200160018201805461019590613dc2565b80601f01602080910402602001604051908101604052809291908181526020018280546101c190613dc2565b801561020e5780601f106101e35761010080835404028352916020019161020e565b820191906000526020600020905b8154815290600101906020018083116101f157829003601f168201915b505050505081526020016002820180548060200260200160405190810160405280929190818152602001828054801561026657602002820191906000526020600020905b815481526020019060010190808311610252575b5050505050815250509050919050565b600080604051806060016040528086600981111561029657610296613aa7565b81526020018581526020018481525090506102b081610ac1565b9150505b9392505050565b6102c36139e4565b6102ce848484610310565b949350505050565b60008060405180606001604052808660098111156102f6576102f6613aa7565b81526020018581526020018481525090506102b081610acc565b6103186139e4565b81518351146103a05760408051634bab873760e11b81526004810191909152600d60448201526c72756e74696d6556616c75657360981b606482015260806024820152602260848201527f6d7573742062652073616d65206c656e6774682061732072756e74696d654b6560a482015261797360f01b60c482015260e4015b60405180910390fd5b60006103ab85610c26565b805490915060ff1660018160098111156103c7576103c7613aa7565b036104755761046c6103da838787610c84565b8360010180546103e990613dc2565b80601f016020809104026020016040519081016040528092919081815260200182805461041590613dc2565b80156104625780601f1061043757610100808354040283529160200191610462565b820191906000526020600020905b81548152906001019060200180831161044557829003601f168201915b5050505050610d46565b925050506102b4565b600281600981111561048957610489613aa7565b036105305761046c61049c838787610c84565b8360010180546104ab90613dc2565b80601f01602080910402602001604051908101604052809291908181526020018280546104d790613dc2565b80156105245780601f106104f957610100808354040283529160200191610524565b820191906000526020600020905b81548152906001019060200180831161050757829003601f168201915b50505050508787610ebb565b600381600981111561054457610544613aa7565b036105de5761046c82600101805461055b90613dc2565b80601f016020809104026020016040519081016040528092919081815260200182805461058790613dc2565b80156105d45780601f106105a9576101008083540402835291602001916105d4565b820191906000526020600020905b8154815290600101906020018083116105b757829003601f168201915b5050505050610f59565b60048160098111156105f2576105f2613aa7565b0361068c5761046c82600101805461060990613dc2565b80601f016020809104026020016040519081016040528092919081815260200182805461063590613dc2565b80156106825780601f1061065757610100808354040283529160200191610682565b820191906000526020600020905b81548152906001019060200180831161066557829003601f168201915b5050505050611087565b60058160098111156106a0576106a0613aa7565b0361073a5761046c8260010180546106b790613dc2565b80601f01602080910402602001604051908101604052809291908181526020018280546106e390613dc2565b80156107305780601f1061070557610100808354040283529160200191610730565b820191906000526020600020905b81548152906001019060200180831161071357829003601f168201915b505050505061131e565b600981600981111561074e5761074e613aa7565b036107ea5761046c82600101805461076590613dc2565b80601f016020809104026020016040519081016040528092919081815260200182805461079190613dc2565b80156107de5780601f106107b3576101008083540402835291602001916107de565b820191906000526020600020905b8154815290600101906020018083116107c157829003601f168201915b505050505086866114b5565b60068160098111156107fe576107fe613aa7565b036108a35761046c610811838787610c84565b83600101805461082090613dc2565b80601f016020809104026020016040519081016040528092919081815260200182805461084c90613dc2565b80156108995780601f1061086e57610100808354040283529160200191610899565b820191906000526020600020905b81548152906001019060200180831161087c57829003601f168201915b50505050506115c7565b60078160098111156108b7576108b7613aa7565b036109ec576040805160608101909152825461046c91908490829060ff1660098111156108e6576108e6613aa7565b60098111156108f7576108f7613aa7565b815260200160018201805461090b90613dc2565b80601f016020809104026020016040519081016040528092919081815260200182805461093790613dc2565b80156109845780601f1061095957610100808354040283529160200191610984565b820191906000526020600020905b81548152906001019060200180831161096757829003601f168201915b50505050508152602001600282018054806020026020016040519081016040528092919081815260200182805480156109dc57602002820191906000526020600020905b8154815260200190600101908083116109c8575b5050505050815250508686611728565b6008816009811115610a0057610a00613aa7565b03610a9a5761046c826001018054610a1790613dc2565b80601f0160208091040260200160405190810160405280929190818152602001828054610a4390613dc2565b8015610a905780601f10610a6557610100808354040283529160200191610a90565b820191906000526020600020905b815481529060010190602001808311610a7357829003601f168201915b50505050506118a5565b6040516323a9bbc960e01b815260048101879052602401610397565b600061011682610c26565b6000610116826118ea565b6000610ad782610ac1565b9050610ae28161192a565b15610b35577fcb64985827770858ec421ad26da7e558c757541643036ce44d6b4eb9e8e5dc5e81836000015184602001518560400151604051610b289493929190613e32565b60405180910390a1919050565b610b3e82611a8c565b610b5d578160405163382bbbc960e11b81526004016103979190613b21565b60005b826040015151811015610bd957610b9383604001518281518110610b8657610b86613e6a565b602002602001015161192a565b610bd15782604001518181518110610bad57610bad613e6a565b6020026020010151604051632f19f96160e11b815260040161039791815260200190565b600101610b60565b50610be382611c31565b8351602085015160408087015190519395507fcb64985827770858ec421ad26da7e558c757541643036ce44d6b4eb9e8e5dc5e9450610b28938693929190613e32565b604080516020808201839052606082018190527f696f2e73796e7468657469782e6f7261636c652d6d616e616765722e4e6f6465608080840191909152828401949094528251808303909401845260a0909101909152815191012090565b600283015460609067ffffffffffffffff811115610ca457610ca4613b9a565b604051908082528060200260200182016040528015610cdd57816020015b610cca6139e4565b815260200190600190039081610cc25790505b50905060005b6002850154811015610d3e57610d19856002018281548110610d0757610d07613e6a565b90600052602060002001548585610310565b828281518110610d2b57610d2b613e6a565b6020908102919091010152600101610ce3565b509392505050565b610d4e6139e4565b600082806020019051810190610d649190613e80565b90506000816008811115610d7a57610d7a613aa7565b03610d9057610d8884611ca5565b915050610116565b6001816008811115610da457610da4613aa7565b03610db257610d8884611d0d565b6002816008811115610dc657610dc6613aa7565b03610dd457610d8884611d90565b6003816008811115610de857610de8613aa7565b03610df657610d8884611e13565b6004816008811115610e0a57610e0a613aa7565b03610e1857610d8884611ec9565b6005816008811115610e2c57610e2c613aa7565b03610e3a57610d8884612009565b6006816008811115610e4e57610e4e613aa7565b03610e5c57610d88846120e4565b6007816008811115610e7057610e70613aa7565b03610e7e57610d888461220c565b6008816008811115610e9257610e92613aa7565b03610ea057610d88846122ce565b80604051631be413d360e11b81526004016103979190613ea1565b610ec36139e4565b600084806020019051810190610ed99190613ed3565b604051631ecba7c360e31b81529091506001600160a01b0382169063f65d3e1890610f0e908990899089908990600401613ef0565b608060405180830381865afa158015610f2b573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190610f4f9190613f91565b9695505050505050565b610f616139e4565b600080600084806020019051810190610f7a9190613fe8565b92509250925060008390506000806000836001600160a01b031663feaf968c6040518163ffffffff1660e01b815260040160a060405180830381865afa158015610fc8573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190610fec9190614041565b509350509250925060008660001461100f5761100a8585858a6123c7565b611011565b825b905060128660ff161161103b5761103661102f60ff881660126140a7565b82906124c2565b611053565b61105361104c601260ff89166140a7565b82906124dc565b9050604051806080016040528082815260200183815260200160008152602001600081525098505050505050505050919050565b61108f6139e4565b600080600080600080878060200190518101906110ac91906140ba565b604080516002808252606082018352979d50959b50939950919750955093506000929060208301908036833701905050905081816000815181106110f2576110f2613e6a565b602002602001019063ffffffff16908163ffffffff168152505060008160018151811061112157611121613e6a565b63ffffffff9092166020928302919091019091015260405163883bdbfd60e01b81526000906001600160a01b0385169063883bdbfd90611165908590600401614143565b600060405180830381865afa158015611182573d6000803e3d6000fd5b505050506040513d6000823e601f3d908101601f191682016040526111aa91908101906141f5565b5090506000816000815181106111c2576111c2613e6a565b6020026020010151826001815181106111dd576111dd613e6a565b60200260200101516111ef91906142c1565b9050600061121761120563ffffffff87166124f6565b61120f9084614304565b60060b61252d565b905060008260060b12801561124c575061123b63ffffffff8616612569565b612569565b8260060b6112499190614342565b15155b1561125f578061125b81614356565b9150505b600061126d6012600a61445d565b9050600061128061123684848f8f612593565b905060006112908a60ff16612569565b61129c8c60ff16612569565b6112a6919061446c565b905060008082136112d1576112cc6112c56112c084614493565b612686565b84906124dc565b6112e4565b6112e46112dd83612686565b84906124c2565b905060405180608001604052808281526020014281526020016000815260200160008152509e505050505050505050505050505050919050565b6113266139e4565b60008060008480602001905181019061133f91906144bf565b91945092509050826000826113bc576040516396834ad360e01b8152600481018590526001600160a01b038316906396834ad390602401608060405180830381865afa158015611393573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906113b791906144f5565b611425565b604051639474f45b60e01b8152600481018590526001600160a01b03831690639474f45b90602401608060405180830381865afa158015611401573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061142591906144f5565b90506000816040015160030b601261143d919061456f565b90506000808213611467576114626114576112c084614493565b845160070b906124dc565b61147e565b61147e61147383612686565b845160070b906124c2565b9050604051806080016040528082815260200184606001518152602001600081526020016000815250975050505050505050919050565b6114bd6139e4565b6000806000868060200190518101906114d69190614597565b92509250925060005b8651811015611545578681815181106114fa576114fa613e6a565b6020026020010151717374616c656e657373546f6c6572616e636560701b0361153d5785818151811061152f5761152f613e6a565b602002602001015160001c91505b6001016114df565b5060408051600180825281830190925260009160208083019080368337019050509050828160008151811061157c5761157c613e6a565b602002602001018181525050836001838360405160200161159f939291906145ce565b60408051601f198184030181529082905263cf2cabdf60e01b82526103979291600401614603565b6115cf6139e4565b6000828060200190518101906115e59190614627565b90506000846000815181106115fc576115fc613e6a565b602002602001015160000151905060008560018151811061161f5761161f613e6a565b6020026020010151600001519050808214611702576000611653601261164d611648858761446c565b6126a9565b906124c2565b905082158061167b5750611666836126a9565b6116709082614640565b61167985612569565b125b15611700576002875111156116b0578660028151811061169d5761169d613e6a565b6020026020010151945050505050610116565b826000036116d15760405163014cc07160e01b815260040160405180910390fd5b6116da836126a9565b6116e49082614640565b60405163dcac091960e01b815260040161039791815260200190565b505b8560008151811061171557611715613e6a565b6020026020010151935050505092915050565b6117306139e4565b6000846020015180602001905181019061174a9190614627565b905060005b84518110156117bc5784818151811061176a5761176a613e6a565b6020026020010151717374616c656e657373546f6c6572616e636560701b036117b4576117ad8482815181106117a2576117a2613e6a565b602002602001015190565b91506117bc565b60010161174f565b50600085604001516000815181106117d6576117d6613e6a565b6020026020010151905060006117ed828787610310565b60208101519091506117ff84426140a7565b1161180e5792506102b4915050565b86604001515160010361187157866040015160008151811061183257611832613e6a565b602002602001015181600001518260200151604051631808066560e21b8152600401610397939291909283526020830191909152604082015260600190565b61189a876040015160018151811061188b5761188b613e6a565b60200260200101518787610310565b979650505050505050565b6118ad6139e4565b6040518060800160405280838060200190518101906118cc9190614627565b81526020014281526020016000815260200160008152509050919050565b600081600001518260200151836040015160405160200161190d9392919061466e565b604051602081830303815290604052805190602001209050919050565b60008061193683610c26565b60408051606081019091528154909190829060ff16600981111561195c5761195c613aa7565b600981111561196d5761196d613aa7565b815260200160018201805461198190613dc2565b80601f01602080910402602001604051908101604052809291908181526020018280546119ad90613dc2565b80156119fa5780601f106119cf576101008083540402835291602001916119fa565b820191906000526020600020905b8154815290600101906020018083116119dd57829003601f168201915b5050505050815260200160028201805480602002602001604051908101604052809291908181526020018280548015611a5257602002820191906000526020600020905b815481526020019060010190808311611a3e575b505050505081525050905060006009811115611a7057611a70613aa7565b81516009811115611a8357611a83613aa7565b14159392505050565b6000600182516009811115611aa357611aa3613aa7565b1480611ac15750600682516009811115611abf57611abf613aa7565b145b80611ade5750600782516009811115611adc57611adc613aa7565b145b15611aee57611aec826126c1565b505b600182516009811115611b0357611b03613aa7565b03611b11576101168261284a565b600282516009811115611b2657611b26613aa7565b03611b3457610116826128a5565b600382516009811115611b4957611b49613aa7565b03611b575761011682612973565b600482516009811115611b6c57611b6c613aa7565b03611b7a5761011682612aae565b600582516009811115611b8f57611b8f613aa7565b03611b9d5761011682612e92565b600982516009811115611bb257611bb2613aa7565b03611bc05761011682612fcb565b600682516009811115611bd557611bd5613aa7565b03611be3576101168261300e565b600782516009811115611bf857611bf8613aa7565b03611c065761011682613052565b600882516009811115611c1b57611c1b613aa7565b03611c295761011682613078565b506000919050565b600080611c3d836118ea565b9050611c4881610c26565b8351815491935090839060ff19166001836009811115611c6a57611c6a613aa7565b021790555060208301516001830190611c8390826146ed565b5060408301518051611c9f916002850191602090910190613a0c565b50915091565b611cad6139e4565b60005b8251811015611d07578160200151838281518110611cd057611cd0613e6a565b6020026020010151602001511115611cff57828181518110611cf457611cf4613e6a565b602002602001015191505b600101611cb0565b50919050565b611d156139e4565b81600081518110611d2857611d28613e6a565b602002602001015190506000600190505b8251811015611d07578160000151838281518110611d5957611d59613e6a565b6020026020010151600001511215611d8857828181518110611d7d57611d7d613e6a565b602002602001015191505b600101611d39565b611d986139e4565b81600081518110611dab57611dab613e6a565b602002602001015190506000600190505b8251811015611d07578160000151838281518110611ddc57611ddc613e6a565b6020026020010151600001511315611e0b57828181518110611e0057611e00613e6a565b602002602001015191505b600101611dbc565b611e1b6139e4565b60005b8251811015611e9557828181518110611e3957611e39613e6a565b60200260200101516000015182600001818151611e56919061456f565b9052508251839082908110611e6d57611e6d613e6a565b60200260200101516020015182602001818151611e8a91906147ad565b905250600101611e1e565b50611ea08251612569565b8151611eac9190614640565b815281516020820151611ebf91906147c0565b6020820152919050565b611ed16139e4565b611eed826000611ee86001865161123691906140a7565b6130a4565b60028251611efb91906147d4565b600003611fd65760408051600280825260608201909252600091816020015b611f226139e4565b815260200190600190039081611f1a57905050905082600160028551611f4891906147c0565b611f5291906140a7565b81518110611f6257611f62613e6a565b602002602001015181600081518110611f7d57611f7d613e6a565b60200260200101819052508260028451611f9791906147c0565b81518110611fa757611fa7613e6a565b602002602001015181600181518110611fc257611fc2613e6a565b60200260200101819052506102b481611e13565b8160028351611fe591906147c0565b81518110611ff557611ff5613e6a565b60200260200101519050919050565b919050565b6120116139e4565b8160008151811061202457612024613e6a565b60209081029190910101515181528151829060009061204557612045613e6a565b6020908102919091018101518101519082015260015b82518110156120d25782818151811061207657612076613e6a565b6020026020010151600001518260000181815161209391906147e8565b90525082518390829081106120aa576120aa613e6a565b602002602001015160200151826020018181516120c791906147ad565b90525060010161205b565b5081518160200151611ebf91906147c0565b6120ec6139e4565b816000815181106120ff576120ff613e6a565b60209081029190910101515181528151829060009061212057612120613e6a565b6020908102919091018101518101519082015260015b82518110156120d25782818151811061215157612151613e6a565b60200260200101516000015160000361219e5782818151811061217657612176613e6a565b6020026020010151600001516040516338ee04a760e01b815260040161039791815260200190565b8281815181106121b0576121b0613e6a565b602002602001015160000151826000018181516121cd9190614640565b90525082518390829081106121e4576121e4613e6a565b6020026020010151602001518260200181815161220191906147ad565b905250600101612136565b6122146139e4565b8160008151811061222757612227613e6a565b60209081029190910101515181528151829060009061224857612248613e6a565b6020908102919091018101518101519082015260015b82518110156120d25761229083828151811061227c5761227c613e6a565b602090810291909101015151835190613264565b825282518390829081106122a6576122a6613e6a565b602002602001015160200151826020018181516122c391906147ad565b90525060010161225e565b6122d66139e4565b816000815181106122e9576122e9613e6a565b60209081029190910101515181528151829060009061230a5761230a613e6a565b6020908102919091018101518101519082015260015b82518110156120d25782818151811061233b5761233b613e6a565b6020026020010151600001516000036123605782818151811061217657612176613e6a565b61238983828151811061237557612375613e6a565b602090810291909101015151835190613283565b8252825183908290811061239f5761239f613e6a565b602002602001015160200151826020018181516123bc91906147ad565b905250600101612320565b6000826001826123d785426140a7565b90505b69ffffffffffffffffffff8716156124a3576001600160a01b038816639a6fc8f561240489614818565b6040516001600160e01b031960e084901b16815269ffffffffffffffffffff8216600482015290995060240160a060405180830381865afa925050508015612469575060408051601f3d908101601f1916820190925261246691810190614041565b60015b156124a357858210156124805750505050506124a3565b61248a848961456f565b97508661249681614834565b97505050505050506123da565b6124ac82612569565b6124b69084614640565b98975050505050505050565b60006124d261123683600a61484d565b6102b490846147e8565b60006124ec61123683600a61484d565b6102b49084614640565b6000667fffffffffffff66ffffffffffffff83161115612529576040516329d2678160e21b815260040160405180910390fd5b5090565b6000627fffff19600683900b128061254b5750627fffff600683900b135b1561252957604051630d962f7960e21b815260040160405180910390fd5b60006001600160ff1b038211156125295760405163677c430560e11b815260040160405180910390fd5b60008061259f86613298565b90506fffffffffffffffffffffffffffffffff6001600160a01b0382161161261c5760006125d66001600160a01b03831680614859565b9050836001600160a01b0316856001600160a01b03161061260557612600600160c01b87836136cd565b612614565b6126148187600160c01b6136cd565b92505061267d565b600061263b6001600160a01b03831680680100000000000000006136cd565b9050836001600160a01b0316856001600160a01b03161061266a57612665600160801b87836136cd565b612679565b6126798187600160801b6136cd565b9250505b50949350505050565b6000808212156125295760405163029f024d60e31b815260040160405180910390fd5b600080821215612529576126bc82614493565b610116565b6000805b8260400151518110156128415760006126fa846040015183815181106126ed576126ed613e6a565b6020026020010151610ab6565b60408051606081019091528154909190829060ff16600981111561272057612720613aa7565b600981111561273157612731613aa7565b815260200160018201805461274590613dc2565b80601f016020809104026020016040519081016040528092919081815260200182805461277190613dc2565b80156127be5780601f10612793576101008083540402835291602001916127be565b820191906000526020600020905b8154815290600101906020018083116127a157829003601f168201915b505050505081526020016002820180548060200260200160405190810160405280929190818152602001828054801561281657602002820191906000526020600020905b815481526020019060010190808311612802575b505050505081525050905061282a81611a8c565b612838575060009392505050565b506001016126c5565b50600192915050565b60006002826040015151101561286257506000919050565b81602001515160201461287757506000919050565b600082602001518060200190518101906128919190614627565b905060088111156128415750600092915050565b6000602082602001515110156128bd57506000919050565b600082602001518060200190518101906128d79190613ed3565b90506128ea816306e7ea3960e21b6138e2565b6128f75750600092915050565b604051633b70a5bf60e21b81526001600160a01b0382169063edc296fc90612923908690600401613b21565b6020604051808303816000875af1158015612942573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906129669190614870565b6128415750600092915050565b6040810151516000901561298957506000919050565b81602001515160601461299e57506000919050565b60008083602001518060200190518101906129b99190613fe8565b92505091506000829050806001600160a01b031663feaf968c6040518163ffffffff1660e01b815260040160a060405180830381865afa158015612a01573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612a259190614041565b5050505050806001600160a01b031663313ce5676040518163ffffffff1660e01b8152600401602060405180830381865afa158015612a68573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612a8c919061488b565b60ff168260ff1614612aa357506000949350505050565b506001949350505050565b60408101515160009015612ac457506000919050565b81602001515160c014612ad957506000919050565b6000806000806000808760200151806020019051810190612afa91906140ba565b9550955095509550955095508360ff16866001600160a01b031663313ce5676040518163ffffffff1660e01b8152600401602060405180830381865afa158015612b48573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612b6c919061488b565b60ff1614612b8257506000979650505050505050565b8260ff16856001600160a01b031663313ce5676040518163ffffffff1660e01b8152600401602060405180830381865afa158015612bc4573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612be8919061488b565b60ff1614612bfe57506000979650505050505050565b6000826001600160a01b0316630dfe16816040518163ffffffff1660e01b8152600401602060405180830381865afa158015612c3e573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612c629190613ed3565b90506000836001600160a01b031663d21220a76040518163ffffffff1660e01b8152600401602060405180830381865afa158015612ca4573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612cc89190613ed3565b9050876001600160a01b0316826001600160a01b0316148015612cfc5750866001600160a01b0316816001600160a01b0316145b158015612d385750866001600160a01b0316826001600160a01b0316148015612d365750876001600160a01b0316816001600160a01b0316145b155b15612d4d575060009998505050505050505050565b60128660ff161180612d62575060128560ff16115b15612d77575060009998505050505050505050565b8263ffffffff16600003612d95575060009998505050505050505050565b6040805160028082526060820183526000926020830190803683370190505090508381600081518110612dca57612dca613e6a565b602002602001019063ffffffff16908163ffffffff1681525050600081600181518110612df957612df9613e6a565b63ffffffff9092166020928302919091019091015260405163883bdbfd60e01b81526001600160a01b0386169063883bdbfd90612e3a908490600401614143565b600060405180830381865afa158015612e57573d6000803e3d6000fd5b505050506040513d6000823e601f3d908101601f19168201604052612e7f91908101906141f5565b5060019c9b505050505050505050505050565b60408101515160009015612ea857506000919050565b816020015151606014612ebd57506000919050565b60008060008460200151806020019051810190612eda91906144bf565b919450925090508281612f55576040516396834ad360e01b8152600481018490526001600160a01b038216906396834ad390602401608060405180830381865afa158015612f2c573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612f5091906144f5565b612fbe565b604051639474f45b60e01b8152600481018490526001600160a01b03821690639474f45b90602401608060405180830381865afa158015612f9a573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612fbe91906144f5565b5060019695505050505050565b60408101515160009015612fe157506000919050565b816020015151606014612ff657506000919050565b8160200151806020019051810190612aa39190614597565b60008160400151516002148061302957508160400151516003145b61303557506000919050565b81602001515160201461304a57506000919050565b506001919050565b600081604001515160011480613029575081604001515160021461303557506000919050565b6040810151516000901561308e57506000919050565b6020826020015151101561304a57506000919050565b81818082036130b4575050505050565b6000856130da60026130c6888861446c565b6130d09190614640565b6112c0908861456f565b815181106130ea576130ea613e6a565b60200260200101516000015190505b818313613236575b808661310c85612686565b8151811061311c5761311c613e6a565b60200260200101516000015112156131405782613138816148a6565b935050613101565b8561314a83612686565b8151811061315a5761315a613e6a565b60200260200101516000015181121561317f5781613177816148be565b925050613140565b818313613231578561319083612686565b815181106131a0576131a0613e6a565b6020026020010151866131b285612686565b815181106131c2576131c2613e6a565b6020026020010151876131d486612686565b815181106131e4576131e4613e6a565b60200260200101886131f586612686565b8151811061320557613205613e6a565b602002602001018290528290525050828061321f906148a6565b935050818061322d906148be565b9250505b6130f9565b81851215613249576132498686846130a4565b8383121561325c5761325c8684866130a4565b505050505050565b6000670de0b6b3a764000061327983856147e8565b6102b49190614640565b600081613279670de0b6b3a7640000856147e8565b60008060008360020b126132b8576132b3600284900b612686565b6132c8565b6132c86112c0600285900b614493565b90506132e36112c06132dd620d89e7196148db565b60020b90565b8111156133165760405162461bcd60e51b81526020600482015260016024820152601560fa1b6044820152606401610397565b60008160011660000361332d57600160801b61333f565b6ffffcb933bd6fad37aa2d162d1a5940015b70ffffffffffffffffffffffffffffffffff169050600282161561337e576080613379826ffff97272373d413259a46990580e213a614859565b901c90505b60048216156133a85760806133a3826ffff2e50f5f656932ef12357cf3c7fdcc614859565b901c90505b60088216156133d25760806133cd826fffe5caca7e10e4e61c3624eaa0941cd0614859565b901c90505b60108216156133fc5760806133f7826fffcb9843d60f6159c9db58835c926644614859565b901c90505b6020821615613426576080613421826fff973b41fa98c081472e6896dfb254c0614859565b901c90505b604082161561345057608061344b826fff2ea16466c96a3843ec78b326b52861614859565b901c90505b608082161561347a576080613475826ffe5dee046a99a2a811c461f1969c3053614859565b901c90505b6101008216156134a55760806134a0826ffcbe86c7900a88aedcffc83b479aa3a4614859565b901c90505b6102008216156134d05760806134cb826ff987a7253ac413176f2b074cf7815e54614859565b901c90505b6104008216156134fb5760806134f6826ff3392b0822b70005940c7a398e4b70f3614859565b901c90505b610800821615613526576080613521826fe7159475a2c29b7443b29c7fa6e889d9614859565b901c90505b61100082161561355157608061354c826fd097f3bdfd2022b8845ad8f792aa5825614859565b901c90505b61200082161561357c576080613577826fa9f746462d870fdf8a65dc1f90e061e5614859565b901c90505b6140008216156135a75760806135a2826f70d869a156d2a1b890bb3df62baf32f7614859565b901c90505b6180008216156135d25760806135cd826f31be135f97d08fd981231505542fcfa6614859565b901c90505b620100008216156135fe5760806135f9826f09aa508b5b7a84e1c677de54f3e99bc9614859565b901c90505b62020000821615613629576080613624826e5d6af8dedb81196699c329225ee604614859565b901c90505b6204000082161561365357608061364e826d2216e584f5fa1ea926041bedfe98614859565b901c90505b6208000082161561367b576080613676826b048a170391f7dc42444e8fa2614859565b901c90505b60008460020b131561369657613693816000196147c0565b90505b6102ce6136a8640100000000836147d4565b156136b45760016136b7565b60005b6136c89060ff16602084901c6147ad565b6139ba565b6000808060001985870985870292508281108382030391505080600003613749576000841161373e5760405162461bcd60e51b815260206004820152601960248201527f48616e646c65206e6f6e2d6f766572666c6f77206361736573000000000000006044820152606401610397565b5082900490506102b4565b8084116137985760405162461bcd60e51b815260206004820152601960248201527f70726576656e74732064656e6f6d696e61746f72203d3d2030000000000000006044820152606401610397565b60008486880980840393811190920391905060006137d06137b887612569565b6137c188612569565b6137ca90614493565b16612686565b9586900495938490049360008190030460010190506137ef8184614859565b909317926000613800876003614859565b600218905061380f8188614859565b61381a9060026140a7565b6138249082614859565b90506138308188614859565b61383b9060026140a7565b6138459082614859565b90506138518188614859565b61385c9060026140a7565b6138669082614859565b90506138728188614859565b61387d9060026140a7565b6138879082614859565b90506138938188614859565b61389e9060026140a7565b6138a89082614859565b90506138b48188614859565b6138bf9060026140a7565b6138c99082614859565b90506138d58186614859565b9998505050505050505050565b604080516001600160e01b0319831660248083019190915282518083039091018152604490910182526020810180516001600160e01b03166301ffc9a760e01b1790529051600091829182916001600160a01b0387169161394391906148fd565b6000604051808303816000865af19150503d8060008114613980576040519150601f19603f3d011682016040523d82523d6000602084013e613985565b606091505b50915091508161399a57600092505050610116565b80516000036139ae57600092505050610116565b60200151949350505050565b60006001600160a01b038211156125295760405163dccde8ed60e01b815260040160405180910390fd5b6040518060800160405280600081526020016000815260200160008152602001600081525090565b828054828255906000526020600020908101928215613a47579160200282015b82811115613a47578251825591602001919060010190613a2c565b506125299291505b808211156125295760008155600101613a4f565b600060208284031215613a7557600080fd5b5035919050565b8151815260208083015190820152604080830151908201526060808301519082015260808101610116565b634e487b7160e01b600052602160045260246000fd5b600a8110613acd57613acd613aa7565b9052565b60005b83811015613aec578181015183820152602001613ad4565b50506000910152565b60008151808452613b0d816020860160208601613ad1565b601f01601f19169290920160200192915050565b60006020808352613b358184018551613abd565b8084015160606040850152613b4d6080850182613af5565b6040860151858203601f19016060870152805180835290840192506000918401905b80831015613b8f5783518252928401926001929092019190840190613b6f565b509695505050505050565b634e487b7160e01b600052604160045260246000fd5b6040516080810167ffffffffffffffff81118282101715613bd357613bd3613b9a565b60405290565b604051601f8201601f1916810167ffffffffffffffff81118282101715613c0257613c02613b9a565b604052919050565b600067ffffffffffffffff821115613c2457613c24613b9a565b5060051b60200190565b600082601f830112613c3f57600080fd5b81356020613c54613c4f83613c0a565b613bd9565b8083825260208201915060208460051b870101935086841115613c7657600080fd5b602086015b84811015613b8f5780358352918301918301613c7b565b600080600060608486031215613ca757600080fd5b8335600a8110613cb657600080fd5b925060208481013567ffffffffffffffff80821115613cd457600080fd5b818701915087601f830112613ce857600080fd5b813581811115613cfa57613cfa613b9a565b613d0c601f8201601f19168501613bd9565b8181528985838601011115613d2057600080fd5b818585018683013760009181019094015291935060408601359180831115613d4757600080fd5b5050613d5586828701613c2e565b9150509250925092565b600080600060608486031215613d7457600080fd5b83359250602084013567ffffffffffffffff80821115613d9357600080fd5b613d9f87838801613c2e565b93506040860135915080821115613db557600080fd5b50613d5586828701613c2e565b600181811c90821680613dd657607f821691505b602082108103611d0757634e487b7160e01b600052602260045260246000fd5b60008151808452602080850194506020840160005b83811015613e2757815187529582019590820190600101613e0b565b509495945050505050565b848152613e426020820185613abd565b608060408201526000613e586080830185613af5565b828103606084015261189a8185613df6565b634e487b7160e01b600052603260045260246000fd5b600060208284031215613e9257600080fd5b8151600981106102b457600080fd5b6020810160098310613eb557613eb5613aa7565b91905290565b6001600160a01b0381168114613ed057600080fd5b50565b600060208284031215613ee557600080fd5b81516102b481613ebb565b608080825285518282018190526000919060209060a0850190828a01855b82811015613f5257613f42848351805182526020810151602083015260408101516040830152606081015160608301525050565b9285019290840190600101613f0e565b5050508481036020860152613f678189613af5565b925050508281036040840152613f7d8186613df6565b9050828103606084015261189a8185613df6565b600060808284031215613fa357600080fd5b613fab613bb0565b825181526020830151602082015260408301516040820152606083015160608201528091505092915050565b805160ff8116811461200457600080fd5b600080600060608486031215613ffd57600080fd5b835161400881613ebb565b6020850151909350915061401e60408501613fd7565b90509250925092565b805169ffffffffffffffffffff8116811461200457600080fd5b600080600080600060a0868803121561405957600080fd5b61406286614027565b945060208601519350604086015192506060860151915061408560808701614027565b90509295509295909350565b634e487b7160e01b600052601160045260246000fd5b8181038181111561011657610116614091565b60008060008060008060c087890312156140d357600080fd5b86516140de81613ebb565b60208801519096506140ef81613ebb565b94506140fd60408801613fd7565b935061410b60608801613fd7565b9250608087015161411b81613ebb565b60a088015190925063ffffffff8116811461413557600080fd5b809150509295509295509295565b6020808252825182820181905260009190848201906040850190845b8181101561418157835163ffffffff168352928401929184019160010161415f565b50909695505050505050565b600082601f83011261419e57600080fd5b815160206141ae613c4f83613c0a565b8083825260208201915060208460051b8701019350868411156141d057600080fd5b602086015b84811015613b8f5780516141e881613ebb565b83529183019183016141d5565b6000806040838503121561420857600080fd5b825167ffffffffffffffff8082111561422057600080fd5b818501915085601f83011261423457600080fd5b81516020614244613c4f83613c0a565b82815260059290921b8401810191818101908984111561426357600080fd5b948201945b838610156142915785518060060b81146142825760008081fd5b82529482019490820190614268565b918801519196509093505050808211156142aa57600080fd5b506142b78582860161418d565b9150509250929050565b600682810b9082900b03667fffffffffffff198112667fffffffffffff8213171561011657610116614091565b634e487b7160e01b600052601260045260246000fd5b60008160060b8360060b8061431b5761431b6142ee565b667fffffffffffff1982146000198214161561433957614339614091565b90059392505050565b600082614351576143516142ee565b500790565b60008160020b627fffff19810361436f5761436f614091565b6000190192915050565b600181815b808511156143b457816000190482111561439a5761439a614091565b808516156143a757918102915b93841c939080029061437e565b509250929050565b6000826143cb57506001610116565b816143d857506000610116565b81600181146143ee57600281146143f857614414565b6001915050610116565b60ff84111561440957614409614091565b50506001821b610116565b5060208310610133831016604e8410600b8410161715614437575081810a610116565b6144418383614379565b806000190482111561445557614455614091565b029392505050565b60006102b460ff8416836143bc565b818103600083128015838313168383128216171561448c5761448c614091565b5092915050565b6000600160ff1b82016144a8576144a8614091565b5060000390565b8051801515811461200457600080fd5b6000806000606084860312156144d457600080fd5b83516144df81613ebb565b6020850151909350915061401e604085016144af565b60006080828403121561450757600080fd5b61450f613bb0565b82518060070b811461452057600080fd5b8152602083015167ffffffffffffffff8116811461453d57600080fd5b60208201526040830151600381900b811461455757600080fd5b60408201526060928301519281019290925250919050565b808201828112600083128015821682158216171561458f5761458f614091565b505092915050565b6000806000606084860312156145ac57600080fd5b83516145b781613ebb565b602085015160409095015190969495509392505050565b60ff8416815267ffffffffffffffff831660208201526060604082015260006145fa6060830184613df6565b95945050505050565b6001600160a01b03831681526040602082018190526000906102ce90830184613af5565b60006020828403121561463957600080fd5b5051919050565b60008261464f5761464f6142ee565b600160ff1b82146000198414161561466957614669614091565b500590565b6146788185613abd565b60606020820152600061468e6060830185613af5565b8281036040840152610f4f8185613df6565b601f8211156146e8576000816000526020600020601f850160051c810160208610156146c95750805b601f850160051c820191505b8181101561325c578281556001016146d5565b505050565b815167ffffffffffffffff81111561470757614707613b9a565b61471b816147158454613dc2565b846146a0565b602080601f83116001811461475057600084156147385750858301515b600019600386901b1c1916600185901b17855561325c565b600085815260208120601f198616915b8281101561477f57888601518255948401946001909101908401614760565b508582101561479d5787850151600019600388901b60f8161c191681555b5050505050600190811b01905550565b8082018082111561011657610116614091565b6000826147cf576147cf6142ee565b500490565b6000826147e3576147e36142ee565b500690565b80820260008212600160ff1b8414161561480457614804614091565b818105831482151761011657610116614091565b600069ffffffffffffffffffff82168061436f5761436f614091565b60006001820161484657614846614091565b5060010190565b60006102b483836143bc565b808202811582820484141761011657610116614091565b60006020828403121561488257600080fd5b6102b4826144af565b60006020828403121561489d57600080fd5b6102b482613fd7565b60006001600160ff1b01820161484657614846614091565b6000600160ff1b82016148d3576148d3614091565b506000190190565b60008160020b627fffff1981036148f4576148f4614091565b60000392915050565b6000825161490f818460208701613ad1565b919091019291505056fea264697066735822122074f32fef384fdc296b0859f1c1f941c8e736c6cb972aa9e2b894956ebd6a80b364736f6c63430008160033","storage":{}},"0x83a0444b93927c3afcbe46e522280390f748e171":{"nonce":1,"balance":"0x0","code":"0x6080604052366100135761001161001d565b005b61001b61001d565b005b6000610027610093565b90503660008037600080366000845af43d6000803e806000811461004a573d6000f35b3d6000fd5b600080823b905060008111915050919050565b6000806040516020016100749061017a565b6040516020818303038152906040528051906020012090508091505090565b600061009d6100c6565b60000160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16905090565b6000806040516020016100d89061020c565b6040516020818303038152906040528051906020012090508091505090565b600082825260208201905092915050565b7f696f2e73796e7468657469782e636f72652d636f6e7472616374732e4f776e6160008201527f626c650000000000000000000000000000000000000000000000000000000000602082015250565b60006101646023836100f7565b915061016f82610108565b604082019050919050565b6000602082019050818103600083015261019381610157565b9050919050565b7f696f2e73796e7468657469782e636f72652d636f6e7472616374732e50726f7860008201527f7900000000000000000000000000000000000000000000000000000000000000602082015250565b60006101f66021836100f7565b91506102018261019a565b604082019050919050565b60006020820190508181036000830152610225816101e9565b905091905056fea2646970667358221220800da1f73cebd5e4afa07496d9bca6b6c4f526bdd3f4014ec15c70fe3a1c441364736f6c63430008110033","storage":{"0x5a648c35a2f5512218b4683cf10e03f5b7c9dc7346e1bf77d304ae97f60f592b":"0x108f53faf774d7c4c56f5bce9ca6e605ce8aeadd","0x5c7865864a2a990d80b5bb5c40e7b73a029960dc711fbb56120dfab976e92ea3":"0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266"}},"0x90f79bf6eb2c4f870365e785982e1f101e93b906":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0x976ea74026e726554db657fa54763abd0c3a0aa9":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0x9965507d1a55bcc2695c58ba16fb37d819b0a4dc":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0xa0ee7a142d267c1f36714e4a8f75612f20a79720":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0xc67e2bd3108604cf0168c0e5ef9cd6d78b9bb14b":{"nonce":1,"balance":"0x21e19c6edb7e2445f20","code":"0x","storage":{}},"0xeb045d78d273107348b0300c01d29b7552d622ab":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266":{"nonce":1,"balance":"0x21e19e08b86820a43ea","code":"0x","storage":{}}},"best_block_number":5,"blocks":[{"header":{"parentHash":"0x08abe6e453727534d8dd708843a7522b7d500338bdfe2402ca105dcdb05eebe9","sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347","miner":"0x0000000000000000000000000000000000000000","stateRoot":"0xcd346446ed010523161f40a5f2b512def549bfb79e165b4354488738416481f2","transactionsRoot":"0xb3a4689832e0b599260ae70362ffcf224b60571b35ff8836904a3d81e2675d66","receiptsRoot":"0x2d13fdc120ab90536fed583939de7fb68b64926a306c1f629593ca9c2c93b198","logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","difficulty":"0x0","number":"0x1","gasLimit":"0x1c9c380","gasUsed":"0x3ea90d","timestamp":"0x66b200ca","mixHash":"0x0000000000000000000000000000000000000000000000000000000000000000","nonce":"0x0000000000000000","baseFeePerGas":"0x2e0b6260","blobGasUsed":"0x0","excessBlobGas":"0x0","extraData":"0x"},"transactions":[{"chainId":"0x343a","nonce":"0x0","gas":"0x3ea90d","maxFeePerGas":"0x83215600","maxPriorityFeePerGas":"0x3b9aca00","value":"0x0","accessList":[],"input":"0x608060405234801561001057600080fd5b5061494f806100206000396000f3fe608060405234801561001057600080fd5b50600436106100575760003560e01c80632a952b2d1461005c57806350c946fe14610085578063625ca21c146100a5578063daa250be146100c6578063deba1b98146100d9575b600080fd5b61006f61006a366004613a63565b6100ec565b60405161007c9190613a7c565b60405180910390f35b610098610093366004613a63565b61011c565b60405161007c9190613b21565b6100b86100b3366004613c92565b610276565b60405190815260200161007c565b61006f6100d4366004613d5f565b6102bb565b6100b86100e7366004613c92565b6102d6565b6100f46139e4565b6040805160008082526020820190815281830190925261011691849190610310565b92915050565b6101416040805160608101909152806000815260200160608152602001606081525090565b61014a82610ab6565b60408051606081019091528154909190829060ff16600981111561017057610170613aa7565b600981111561018157610181613aa7565b815260200160018201805461019590613dc2565b80601f01602080910402602001604051908101604052809291908181526020018280546101c190613dc2565b801561020e5780601f106101e35761010080835404028352916020019161020e565b820191906000526020600020905b8154815290600101906020018083116101f157829003601f168201915b505050505081526020016002820180548060200260200160405190810160405280929190818152602001828054801561026657602002820191906000526020600020905b815481526020019060010190808311610252575b5050505050815250509050919050565b600080604051806060016040528086600981111561029657610296613aa7565b81526020018581526020018481525090506102b081610ac1565b9150505b9392505050565b6102c36139e4565b6102ce848484610310565b949350505050565b60008060405180606001604052808660098111156102f6576102f6613aa7565b81526020018581526020018481525090506102b081610acc565b6103186139e4565b81518351146103a05760408051634bab873760e11b81526004810191909152600d60448201526c72756e74696d6556616c75657360981b606482015260806024820152602260848201527f6d7573742062652073616d65206c656e6774682061732072756e74696d654b6560a482015261797360f01b60c482015260e4015b60405180910390fd5b60006103ab85610c26565b805490915060ff1660018160098111156103c7576103c7613aa7565b036104755761046c6103da838787610c84565b8360010180546103e990613dc2565b80601f016020809104026020016040519081016040528092919081815260200182805461041590613dc2565b80156104625780601f1061043757610100808354040283529160200191610462565b820191906000526020600020905b81548152906001019060200180831161044557829003601f168201915b5050505050610d46565b925050506102b4565b600281600981111561048957610489613aa7565b036105305761046c61049c838787610c84565b8360010180546104ab90613dc2565b80601f01602080910402602001604051908101604052809291908181526020018280546104d790613dc2565b80156105245780601f106104f957610100808354040283529160200191610524565b820191906000526020600020905b81548152906001019060200180831161050757829003601f168201915b50505050508787610ebb565b600381600981111561054457610544613aa7565b036105de5761046c82600101805461055b90613dc2565b80601f016020809104026020016040519081016040528092919081815260200182805461058790613dc2565b80156105d45780601f106105a9576101008083540402835291602001916105d4565b820191906000526020600020905b8154815290600101906020018083116105b757829003601f168201915b5050505050610f59565b60048160098111156105f2576105f2613aa7565b0361068c5761046c82600101805461060990613dc2565b80601f016020809104026020016040519081016040528092919081815260200182805461063590613dc2565b80156106825780601f1061065757610100808354040283529160200191610682565b820191906000526020600020905b81548152906001019060200180831161066557829003601f168201915b5050505050611087565b60058160098111156106a0576106a0613aa7565b0361073a5761046c8260010180546106b790613dc2565b80601f01602080910402602001604051908101604052809291908181526020018280546106e390613dc2565b80156107305780601f1061070557610100808354040283529160200191610730565b820191906000526020600020905b81548152906001019060200180831161071357829003601f168201915b505050505061131e565b600981600981111561074e5761074e613aa7565b036107ea5761046c82600101805461076590613dc2565b80601f016020809104026020016040519081016040528092919081815260200182805461079190613dc2565b80156107de5780601f106107b3576101008083540402835291602001916107de565b820191906000526020600020905b8154815290600101906020018083116107c157829003601f168201915b505050505086866114b5565b60068160098111156107fe576107fe613aa7565b036108a35761046c610811838787610c84565b83600101805461082090613dc2565b80601f016020809104026020016040519081016040528092919081815260200182805461084c90613dc2565b80156108995780601f1061086e57610100808354040283529160200191610899565b820191906000526020600020905b81548152906001019060200180831161087c57829003601f168201915b50505050506115c7565b60078160098111156108b7576108b7613aa7565b036109ec576040805160608101909152825461046c91908490829060ff1660098111156108e6576108e6613aa7565b60098111156108f7576108f7613aa7565b815260200160018201805461090b90613dc2565b80601f016020809104026020016040519081016040528092919081815260200182805461093790613dc2565b80156109845780601f1061095957610100808354040283529160200191610984565b820191906000526020600020905b81548152906001019060200180831161096757829003601f168201915b50505050508152602001600282018054806020026020016040519081016040528092919081815260200182805480156109dc57602002820191906000526020600020905b8154815260200190600101908083116109c8575b5050505050815250508686611728565b6008816009811115610a0057610a00613aa7565b03610a9a5761046c826001018054610a1790613dc2565b80601f0160208091040260200160405190810160405280929190818152602001828054610a4390613dc2565b8015610a905780601f10610a6557610100808354040283529160200191610a90565b820191906000526020600020905b815481529060010190602001808311610a7357829003601f168201915b50505050506118a5565b6040516323a9bbc960e01b815260048101879052602401610397565b600061011682610c26565b6000610116826118ea565b6000610ad782610ac1565b9050610ae28161192a565b15610b35577fcb64985827770858ec421ad26da7e558c757541643036ce44d6b4eb9e8e5dc5e81836000015184602001518560400151604051610b289493929190613e32565b60405180910390a1919050565b610b3e82611a8c565b610b5d578160405163382bbbc960e11b81526004016103979190613b21565b60005b826040015151811015610bd957610b9383604001518281518110610b8657610b86613e6a565b602002602001015161192a565b610bd15782604001518181518110610bad57610bad613e6a565b6020026020010151604051632f19f96160e11b815260040161039791815260200190565b600101610b60565b50610be382611c31565b8351602085015160408087015190519395507fcb64985827770858ec421ad26da7e558c757541643036ce44d6b4eb9e8e5dc5e9450610b28938693929190613e32565b604080516020808201839052606082018190527f696f2e73796e7468657469782e6f7261636c652d6d616e616765722e4e6f6465608080840191909152828401949094528251808303909401845260a0909101909152815191012090565b600283015460609067ffffffffffffffff811115610ca457610ca4613b9a565b604051908082528060200260200182016040528015610cdd57816020015b610cca6139e4565b815260200190600190039081610cc25790505b50905060005b6002850154811015610d3e57610d19856002018281548110610d0757610d07613e6a565b90600052602060002001548585610310565b828281518110610d2b57610d2b613e6a565b6020908102919091010152600101610ce3565b509392505050565b610d4e6139e4565b600082806020019051810190610d649190613e80565b90506000816008811115610d7a57610d7a613aa7565b03610d9057610d8884611ca5565b915050610116565b6001816008811115610da457610da4613aa7565b03610db257610d8884611d0d565b6002816008811115610dc657610dc6613aa7565b03610dd457610d8884611d90565b6003816008811115610de857610de8613aa7565b03610df657610d8884611e13565b6004816008811115610e0a57610e0a613aa7565b03610e1857610d8884611ec9565b6005816008811115610e2c57610e2c613aa7565b03610e3a57610d8884612009565b6006816008811115610e4e57610e4e613aa7565b03610e5c57610d88846120e4565b6007816008811115610e7057610e70613aa7565b03610e7e57610d888461220c565b6008816008811115610e9257610e92613aa7565b03610ea057610d88846122ce565b80604051631be413d360e11b81526004016103979190613ea1565b610ec36139e4565b600084806020019051810190610ed99190613ed3565b604051631ecba7c360e31b81529091506001600160a01b0382169063f65d3e1890610f0e908990899089908990600401613ef0565b608060405180830381865afa158015610f2b573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190610f4f9190613f91565b9695505050505050565b610f616139e4565b600080600084806020019051810190610f7a9190613fe8565b92509250925060008390506000806000836001600160a01b031663feaf968c6040518163ffffffff1660e01b815260040160a060405180830381865afa158015610fc8573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190610fec9190614041565b509350509250925060008660001461100f5761100a8585858a6123c7565b611011565b825b905060128660ff161161103b5761103661102f60ff881660126140a7565b82906124c2565b611053565b61105361104c601260ff89166140a7565b82906124dc565b9050604051806080016040528082815260200183815260200160008152602001600081525098505050505050505050919050565b61108f6139e4565b600080600080600080878060200190518101906110ac91906140ba565b604080516002808252606082018352979d50959b50939950919750955093506000929060208301908036833701905050905081816000815181106110f2576110f2613e6a565b602002602001019063ffffffff16908163ffffffff168152505060008160018151811061112157611121613e6a565b63ffffffff9092166020928302919091019091015260405163883bdbfd60e01b81526000906001600160a01b0385169063883bdbfd90611165908590600401614143565b600060405180830381865afa158015611182573d6000803e3d6000fd5b505050506040513d6000823e601f3d908101601f191682016040526111aa91908101906141f5565b5090506000816000815181106111c2576111c2613e6a565b6020026020010151826001815181106111dd576111dd613e6a565b60200260200101516111ef91906142c1565b9050600061121761120563ffffffff87166124f6565b61120f9084614304565b60060b61252d565b905060008260060b12801561124c575061123b63ffffffff8616612569565b612569565b8260060b6112499190614342565b15155b1561125f578061125b81614356565b9150505b600061126d6012600a61445d565b9050600061128061123684848f8f612593565b905060006112908a60ff16612569565b61129c8c60ff16612569565b6112a6919061446c565b905060008082136112d1576112cc6112c56112c084614493565b612686565b84906124dc565b6112e4565b6112e46112dd83612686565b84906124c2565b905060405180608001604052808281526020014281526020016000815260200160008152509e505050505050505050505050505050919050565b6113266139e4565b60008060008480602001905181019061133f91906144bf565b91945092509050826000826113bc576040516396834ad360e01b8152600481018590526001600160a01b038316906396834ad390602401608060405180830381865afa158015611393573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906113b791906144f5565b611425565b604051639474f45b60e01b8152600481018590526001600160a01b03831690639474f45b90602401608060405180830381865afa158015611401573d6000803e3d6000fd5b505050506040513d601f19601f8201168201806040525081019061142591906144f5565b90506000816040015160030b601261143d919061456f565b90506000808213611467576114626114576112c084614493565b845160070b906124dc565b61147e565b61147e61147383612686565b845160070b906124c2565b9050604051806080016040528082815260200184606001518152602001600081526020016000815250975050505050505050919050565b6114bd6139e4565b6000806000868060200190518101906114d69190614597565b92509250925060005b8651811015611545578681815181106114fa576114fa613e6a565b6020026020010151717374616c656e657373546f6c6572616e636560701b0361153d5785818151811061152f5761152f613e6a565b602002602001015160001c91505b6001016114df565b5060408051600180825281830190925260009160208083019080368337019050509050828160008151811061157c5761157c613e6a565b602002602001018181525050836001838360405160200161159f939291906145ce565b60408051601f198184030181529082905263cf2cabdf60e01b82526103979291600401614603565b6115cf6139e4565b6000828060200190518101906115e59190614627565b90506000846000815181106115fc576115fc613e6a565b602002602001015160000151905060008560018151811061161f5761161f613e6a565b6020026020010151600001519050808214611702576000611653601261164d611648858761446c565b6126a9565b906124c2565b905082158061167b5750611666836126a9565b6116709082614640565b61167985612569565b125b15611700576002875111156116b0578660028151811061169d5761169d613e6a565b6020026020010151945050505050610116565b826000036116d15760405163014cc07160e01b815260040160405180910390fd5b6116da836126a9565b6116e49082614640565b60405163dcac091960e01b815260040161039791815260200190565b505b8560008151811061171557611715613e6a565b6020026020010151935050505092915050565b6117306139e4565b6000846020015180602001905181019061174a9190614627565b905060005b84518110156117bc5784818151811061176a5761176a613e6a565b6020026020010151717374616c656e657373546f6c6572616e636560701b036117b4576117ad8482815181106117a2576117a2613e6a565b602002602001015190565b91506117bc565b60010161174f565b50600085604001516000815181106117d6576117d6613e6a565b6020026020010151905060006117ed828787610310565b60208101519091506117ff84426140a7565b1161180e5792506102b4915050565b86604001515160010361187157866040015160008151811061183257611832613e6a565b602002602001015181600001518260200151604051631808066560e21b8152600401610397939291909283526020830191909152604082015260600190565b61189a876040015160018151811061188b5761188b613e6a565b60200260200101518787610310565b979650505050505050565b6118ad6139e4565b6040518060800160405280838060200190518101906118cc9190614627565b81526020014281526020016000815260200160008152509050919050565b600081600001518260200151836040015160405160200161190d9392919061466e565b604051602081830303815290604052805190602001209050919050565b60008061193683610c26565b60408051606081019091528154909190829060ff16600981111561195c5761195c613aa7565b600981111561196d5761196d613aa7565b815260200160018201805461198190613dc2565b80601f01602080910402602001604051908101604052809291908181526020018280546119ad90613dc2565b80156119fa5780601f106119cf576101008083540402835291602001916119fa565b820191906000526020600020905b8154815290600101906020018083116119dd57829003601f168201915b5050505050815260200160028201805480602002602001604051908101604052809291908181526020018280548015611a5257602002820191906000526020600020905b815481526020019060010190808311611a3e575b505050505081525050905060006009811115611a7057611a70613aa7565b81516009811115611a8357611a83613aa7565b14159392505050565b6000600182516009811115611aa357611aa3613aa7565b1480611ac15750600682516009811115611abf57611abf613aa7565b145b80611ade5750600782516009811115611adc57611adc613aa7565b145b15611aee57611aec826126c1565b505b600182516009811115611b0357611b03613aa7565b03611b11576101168261284a565b600282516009811115611b2657611b26613aa7565b03611b3457610116826128a5565b600382516009811115611b4957611b49613aa7565b03611b575761011682612973565b600482516009811115611b6c57611b6c613aa7565b03611b7a5761011682612aae565b600582516009811115611b8f57611b8f613aa7565b03611b9d5761011682612e92565b600982516009811115611bb257611bb2613aa7565b03611bc05761011682612fcb565b600682516009811115611bd557611bd5613aa7565b03611be3576101168261300e565b600782516009811115611bf857611bf8613aa7565b03611c065761011682613052565b600882516009811115611c1b57611c1b613aa7565b03611c295761011682613078565b506000919050565b600080611c3d836118ea565b9050611c4881610c26565b8351815491935090839060ff19166001836009811115611c6a57611c6a613aa7565b021790555060208301516001830190611c8390826146ed565b5060408301518051611c9f916002850191602090910190613a0c565b50915091565b611cad6139e4565b60005b8251811015611d07578160200151838281518110611cd057611cd0613e6a565b6020026020010151602001511115611cff57828181518110611cf457611cf4613e6a565b602002602001015191505b600101611cb0565b50919050565b611d156139e4565b81600081518110611d2857611d28613e6a565b602002602001015190506000600190505b8251811015611d07578160000151838281518110611d5957611d59613e6a565b6020026020010151600001511215611d8857828181518110611d7d57611d7d613e6a565b602002602001015191505b600101611d39565b611d986139e4565b81600081518110611dab57611dab613e6a565b602002602001015190506000600190505b8251811015611d07578160000151838281518110611ddc57611ddc613e6a565b6020026020010151600001511315611e0b57828181518110611e0057611e00613e6a565b602002602001015191505b600101611dbc565b611e1b6139e4565b60005b8251811015611e9557828181518110611e3957611e39613e6a565b60200260200101516000015182600001818151611e56919061456f565b9052508251839082908110611e6d57611e6d613e6a565b60200260200101516020015182602001818151611e8a91906147ad565b905250600101611e1e565b50611ea08251612569565b8151611eac9190614640565b815281516020820151611ebf91906147c0565b6020820152919050565b611ed16139e4565b611eed826000611ee86001865161123691906140a7565b6130a4565b60028251611efb91906147d4565b600003611fd65760408051600280825260608201909252600091816020015b611f226139e4565b815260200190600190039081611f1a57905050905082600160028551611f4891906147c0565b611f5291906140a7565b81518110611f6257611f62613e6a565b602002602001015181600081518110611f7d57611f7d613e6a565b60200260200101819052508260028451611f9791906147c0565b81518110611fa757611fa7613e6a565b602002602001015181600181518110611fc257611fc2613e6a565b60200260200101819052506102b481611e13565b8160028351611fe591906147c0565b81518110611ff557611ff5613e6a565b60200260200101519050919050565b919050565b6120116139e4565b8160008151811061202457612024613e6a565b60209081029190910101515181528151829060009061204557612045613e6a565b6020908102919091018101518101519082015260015b82518110156120d25782818151811061207657612076613e6a565b6020026020010151600001518260000181815161209391906147e8565b90525082518390829081106120aa576120aa613e6a565b602002602001015160200151826020018181516120c791906147ad565b90525060010161205b565b5081518160200151611ebf91906147c0565b6120ec6139e4565b816000815181106120ff576120ff613e6a565b60209081029190910101515181528151829060009061212057612120613e6a565b6020908102919091018101518101519082015260015b82518110156120d25782818151811061215157612151613e6a565b60200260200101516000015160000361219e5782818151811061217657612176613e6a565b6020026020010151600001516040516338ee04a760e01b815260040161039791815260200190565b8281815181106121b0576121b0613e6a565b602002602001015160000151826000018181516121cd9190614640565b90525082518390829081106121e4576121e4613e6a565b6020026020010151602001518260200181815161220191906147ad565b905250600101612136565b6122146139e4565b8160008151811061222757612227613e6a565b60209081029190910101515181528151829060009061224857612248613e6a565b6020908102919091018101518101519082015260015b82518110156120d25761229083828151811061227c5761227c613e6a565b602090810291909101015151835190613264565b825282518390829081106122a6576122a6613e6a565b602002602001015160200151826020018181516122c391906147ad565b90525060010161225e565b6122d66139e4565b816000815181106122e9576122e9613e6a565b60209081029190910101515181528151829060009061230a5761230a613e6a565b6020908102919091018101518101519082015260015b82518110156120d25782818151811061233b5761233b613e6a565b6020026020010151600001516000036123605782818151811061217657612176613e6a565b61238983828151811061237557612375613e6a565b602090810291909101015151835190613283565b8252825183908290811061239f5761239f613e6a565b602002602001015160200151826020018181516123bc91906147ad565b905250600101612320565b6000826001826123d785426140a7565b90505b69ffffffffffffffffffff8716156124a3576001600160a01b038816639a6fc8f561240489614818565b6040516001600160e01b031960e084901b16815269ffffffffffffffffffff8216600482015290995060240160a060405180830381865afa925050508015612469575060408051601f3d908101601f1916820190925261246691810190614041565b60015b156124a357858210156124805750505050506124a3565b61248a848961456f565b97508661249681614834565b97505050505050506123da565b6124ac82612569565b6124b69084614640565b98975050505050505050565b60006124d261123683600a61484d565b6102b490846147e8565b60006124ec61123683600a61484d565b6102b49084614640565b6000667fffffffffffff66ffffffffffffff83161115612529576040516329d2678160e21b815260040160405180910390fd5b5090565b6000627fffff19600683900b128061254b5750627fffff600683900b135b1561252957604051630d962f7960e21b815260040160405180910390fd5b60006001600160ff1b038211156125295760405163677c430560e11b815260040160405180910390fd5b60008061259f86613298565b90506fffffffffffffffffffffffffffffffff6001600160a01b0382161161261c5760006125d66001600160a01b03831680614859565b9050836001600160a01b0316856001600160a01b03161061260557612600600160c01b87836136cd565b612614565b6126148187600160c01b6136cd565b92505061267d565b600061263b6001600160a01b03831680680100000000000000006136cd565b9050836001600160a01b0316856001600160a01b03161061266a57612665600160801b87836136cd565b612679565b6126798187600160801b6136cd565b9250505b50949350505050565b6000808212156125295760405163029f024d60e31b815260040160405180910390fd5b600080821215612529576126bc82614493565b610116565b6000805b8260400151518110156128415760006126fa846040015183815181106126ed576126ed613e6a565b6020026020010151610ab6565b60408051606081019091528154909190829060ff16600981111561272057612720613aa7565b600981111561273157612731613aa7565b815260200160018201805461274590613dc2565b80601f016020809104026020016040519081016040528092919081815260200182805461277190613dc2565b80156127be5780601f10612793576101008083540402835291602001916127be565b820191906000526020600020905b8154815290600101906020018083116127a157829003601f168201915b505050505081526020016002820180548060200260200160405190810160405280929190818152602001828054801561281657602002820191906000526020600020905b815481526020019060010190808311612802575b505050505081525050905061282a81611a8c565b612838575060009392505050565b506001016126c5565b50600192915050565b60006002826040015151101561286257506000919050565b81602001515160201461287757506000919050565b600082602001518060200190518101906128919190614627565b905060088111156128415750600092915050565b6000602082602001515110156128bd57506000919050565b600082602001518060200190518101906128d79190613ed3565b90506128ea816306e7ea3960e21b6138e2565b6128f75750600092915050565b604051633b70a5bf60e21b81526001600160a01b0382169063edc296fc90612923908690600401613b21565b6020604051808303816000875af1158015612942573d6000803e3d6000fd5b505050506040513d601f19601f820116820180604052508101906129669190614870565b6128415750600092915050565b6040810151516000901561298957506000919050565b81602001515160601461299e57506000919050565b60008083602001518060200190518101906129b99190613fe8565b92505091506000829050806001600160a01b031663feaf968c6040518163ffffffff1660e01b815260040160a060405180830381865afa158015612a01573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612a259190614041565b5050505050806001600160a01b031663313ce5676040518163ffffffff1660e01b8152600401602060405180830381865afa158015612a68573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612a8c919061488b565b60ff168260ff1614612aa357506000949350505050565b506001949350505050565b60408101515160009015612ac457506000919050565b81602001515160c014612ad957506000919050565b6000806000806000808760200151806020019051810190612afa91906140ba565b9550955095509550955095508360ff16866001600160a01b031663313ce5676040518163ffffffff1660e01b8152600401602060405180830381865afa158015612b48573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612b6c919061488b565b60ff1614612b8257506000979650505050505050565b8260ff16856001600160a01b031663313ce5676040518163ffffffff1660e01b8152600401602060405180830381865afa158015612bc4573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612be8919061488b565b60ff1614612bfe57506000979650505050505050565b6000826001600160a01b0316630dfe16816040518163ffffffff1660e01b8152600401602060405180830381865afa158015612c3e573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612c629190613ed3565b90506000836001600160a01b031663d21220a76040518163ffffffff1660e01b8152600401602060405180830381865afa158015612ca4573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612cc89190613ed3565b9050876001600160a01b0316826001600160a01b0316148015612cfc5750866001600160a01b0316816001600160a01b0316145b158015612d385750866001600160a01b0316826001600160a01b0316148015612d365750876001600160a01b0316816001600160a01b0316145b155b15612d4d575060009998505050505050505050565b60128660ff161180612d62575060128560ff16115b15612d77575060009998505050505050505050565b8263ffffffff16600003612d95575060009998505050505050505050565b6040805160028082526060820183526000926020830190803683370190505090508381600081518110612dca57612dca613e6a565b602002602001019063ffffffff16908163ffffffff1681525050600081600181518110612df957612df9613e6a565b63ffffffff9092166020928302919091019091015260405163883bdbfd60e01b81526001600160a01b0386169063883bdbfd90612e3a908490600401614143565b600060405180830381865afa158015612e57573d6000803e3d6000fd5b505050506040513d6000823e601f3d908101601f19168201604052612e7f91908101906141f5565b5060019c9b505050505050505050505050565b60408101515160009015612ea857506000919050565b816020015151606014612ebd57506000919050565b60008060008460200151806020019051810190612eda91906144bf565b919450925090508281612f55576040516396834ad360e01b8152600481018490526001600160a01b038216906396834ad390602401608060405180830381865afa158015612f2c573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612f5091906144f5565b612fbe565b604051639474f45b60e01b8152600481018490526001600160a01b03821690639474f45b90602401608060405180830381865afa158015612f9a573d6000803e3d6000fd5b505050506040513d601f19601f82011682018060405250810190612fbe91906144f5565b5060019695505050505050565b60408101515160009015612fe157506000919050565b816020015151606014612ff657506000919050565b8160200151806020019051810190612aa39190614597565b60008160400151516002148061302957508160400151516003145b61303557506000919050565b81602001515160201461304a57506000919050565b506001919050565b600081604001515160011480613029575081604001515160021461303557506000919050565b6040810151516000901561308e57506000919050565b6020826020015151101561304a57506000919050565b81818082036130b4575050505050565b6000856130da60026130c6888861446c565b6130d09190614640565b6112c0908861456f565b815181106130ea576130ea613e6a565b60200260200101516000015190505b818313613236575b808661310c85612686565b8151811061311c5761311c613e6a565b60200260200101516000015112156131405782613138816148a6565b935050613101565b8561314a83612686565b8151811061315a5761315a613e6a565b60200260200101516000015181121561317f5781613177816148be565b925050613140565b818313613231578561319083612686565b815181106131a0576131a0613e6a565b6020026020010151866131b285612686565b815181106131c2576131c2613e6a565b6020026020010151876131d486612686565b815181106131e4576131e4613e6a565b60200260200101886131f586612686565b8151811061320557613205613e6a565b602002602001018290528290525050828061321f906148a6565b935050818061322d906148be565b9250505b6130f9565b81851215613249576132498686846130a4565b8383121561325c5761325c8684866130a4565b505050505050565b6000670de0b6b3a764000061327983856147e8565b6102b49190614640565b600081613279670de0b6b3a7640000856147e8565b60008060008360020b126132b8576132b3600284900b612686565b6132c8565b6132c86112c0600285900b614493565b90506132e36112c06132dd620d89e7196148db565b60020b90565b8111156133165760405162461bcd60e51b81526020600482015260016024820152601560fa1b6044820152606401610397565b60008160011660000361332d57600160801b61333f565b6ffffcb933bd6fad37aa2d162d1a5940015b70ffffffffffffffffffffffffffffffffff169050600282161561337e576080613379826ffff97272373d413259a46990580e213a614859565b901c90505b60048216156133a85760806133a3826ffff2e50f5f656932ef12357cf3c7fdcc614859565b901c90505b60088216156133d25760806133cd826fffe5caca7e10e4e61c3624eaa0941cd0614859565b901c90505b60108216156133fc5760806133f7826fffcb9843d60f6159c9db58835c926644614859565b901c90505b6020821615613426576080613421826fff973b41fa98c081472e6896dfb254c0614859565b901c90505b604082161561345057608061344b826fff2ea16466c96a3843ec78b326b52861614859565b901c90505b608082161561347a576080613475826ffe5dee046a99a2a811c461f1969c3053614859565b901c90505b6101008216156134a55760806134a0826ffcbe86c7900a88aedcffc83b479aa3a4614859565b901c90505b6102008216156134d05760806134cb826ff987a7253ac413176f2b074cf7815e54614859565b901c90505b6104008216156134fb5760806134f6826ff3392b0822b70005940c7a398e4b70f3614859565b901c90505b610800821615613526576080613521826fe7159475a2c29b7443b29c7fa6e889d9614859565b901c90505b61100082161561355157608061354c826fd097f3bdfd2022b8845ad8f792aa5825614859565b901c90505b61200082161561357c576080613577826fa9f746462d870fdf8a65dc1f90e061e5614859565b901c90505b6140008216156135a75760806135a2826f70d869a156d2a1b890bb3df62baf32f7614859565b901c90505b6180008216156135d25760806135cd826f31be135f97d08fd981231505542fcfa6614859565b901c90505b620100008216156135fe5760806135f9826f09aa508b5b7a84e1c677de54f3e99bc9614859565b901c90505b62020000821615613629576080613624826e5d6af8dedb81196699c329225ee604614859565b901c90505b6204000082161561365357608061364e826d2216e584f5fa1ea926041bedfe98614859565b901c90505b6208000082161561367b576080613676826b048a170391f7dc42444e8fa2614859565b901c90505b60008460020b131561369657613693816000196147c0565b90505b6102ce6136a8640100000000836147d4565b156136b45760016136b7565b60005b6136c89060ff16602084901c6147ad565b6139ba565b6000808060001985870985870292508281108382030391505080600003613749576000841161373e5760405162461bcd60e51b815260206004820152601960248201527f48616e646c65206e6f6e2d6f766572666c6f77206361736573000000000000006044820152606401610397565b5082900490506102b4565b8084116137985760405162461bcd60e51b815260206004820152601960248201527f70726576656e74732064656e6f6d696e61746f72203d3d2030000000000000006044820152606401610397565b60008486880980840393811190920391905060006137d06137b887612569565b6137c188612569565b6137ca90614493565b16612686565b9586900495938490049360008190030460010190506137ef8184614859565b909317926000613800876003614859565b600218905061380f8188614859565b61381a9060026140a7565b6138249082614859565b90506138308188614859565b61383b9060026140a7565b6138459082614859565b90506138518188614859565b61385c9060026140a7565b6138669082614859565b90506138728188614859565b61387d9060026140a7565b6138879082614859565b90506138938188614859565b61389e9060026140a7565b6138a89082614859565b90506138b48188614859565b6138bf9060026140a7565b6138c99082614859565b90506138d58186614859565b9998505050505050505050565b604080516001600160e01b0319831660248083019190915282518083039091018152604490910182526020810180516001600160e01b03166301ffc9a760e01b1790529051600091829182916001600160a01b0387169161394391906148fd565b6000604051808303816000865af19150503d8060008114613980576040519150601f19603f3d011682016040523d82523d6000602084013e613985565b606091505b50915091508161399a57600092505050610116565b80516000036139ae57600092505050610116565b60200151949350505050565b60006001600160a01b038211156125295760405163dccde8ed60e01b815260040160405180910390fd5b6040518060800160405280600081526020016000815260200160008152602001600081525090565b828054828255906000526020600020908101928215613a47579160200282015b82811115613a47578251825591602001919060010190613a2c565b506125299291505b808211156125295760008155600101613a4f565b600060208284031215613a7557600080fd5b5035919050565b8151815260208083015190820152604080830151908201526060808301519082015260808101610116565b634e487b7160e01b600052602160045260246000fd5b600a8110613acd57613acd613aa7565b9052565b60005b83811015613aec578181015183820152602001613ad4565b50506000910152565b60008151808452613b0d816020860160208601613ad1565b601f01601f19169290920160200192915050565b60006020808352613b358184018551613abd565b8084015160606040850152613b4d6080850182613af5565b6040860151858203601f19016060870152805180835290840192506000918401905b80831015613b8f5783518252928401926001929092019190840190613b6f565b509695505050505050565b634e487b7160e01b600052604160045260246000fd5b6040516080810167ffffffffffffffff81118282101715613bd357613bd3613b9a565b60405290565b604051601f8201601f1916810167ffffffffffffffff81118282101715613c0257613c02613b9a565b604052919050565b600067ffffffffffffffff821115613c2457613c24613b9a565b5060051b60200190565b600082601f830112613c3f57600080fd5b81356020613c54613c4f83613c0a565b613bd9565b8083825260208201915060208460051b870101935086841115613c7657600080fd5b602086015b84811015613b8f5780358352918301918301613c7b565b600080600060608486031215613ca757600080fd5b8335600a8110613cb657600080fd5b925060208481013567ffffffffffffffff80821115613cd457600080fd5b818701915087601f830112613ce857600080fd5b813581811115613cfa57613cfa613b9a565b613d0c601f8201601f19168501613bd9565b8181528985838601011115613d2057600080fd5b818585018683013760009181019094015291935060408601359180831115613d4757600080fd5b5050613d5586828701613c2e565b9150509250925092565b600080600060608486031215613d7457600080fd5b83359250602084013567ffffffffffffffff80821115613d9357600080fd5b613d9f87838801613c2e565b93506040860135915080821115613db557600080fd5b50613d5586828701613c2e565b600181811c90821680613dd657607f821691505b602082108103611d0757634e487b7160e01b600052602260045260246000fd5b60008151808452602080850194506020840160005b83811015613e2757815187529582019590820190600101613e0b565b509495945050505050565b848152613e426020820185613abd565b608060408201526000613e586080830185613af5565b828103606084015261189a8185613df6565b634e487b7160e01b600052603260045260246000fd5b600060208284031215613e9257600080fd5b8151600981106102b457600080fd5b6020810160098310613eb557613eb5613aa7565b91905290565b6001600160a01b0381168114613ed057600080fd5b50565b600060208284031215613ee557600080fd5b81516102b481613ebb565b608080825285518282018190526000919060209060a0850190828a01855b82811015613f5257613f42848351805182526020810151602083015260408101516040830152606081015160608301525050565b9285019290840190600101613f0e565b5050508481036020860152613f678189613af5565b925050508281036040840152613f7d8186613df6565b9050828103606084015261189a8185613df6565b600060808284031215613fa357600080fd5b613fab613bb0565b825181526020830151602082015260408301516040820152606083015160608201528091505092915050565b805160ff8116811461200457600080fd5b600080600060608486031215613ffd57600080fd5b835161400881613ebb565b6020850151909350915061401e60408501613fd7565b90509250925092565b805169ffffffffffffffffffff8116811461200457600080fd5b600080600080600060a0868803121561405957600080fd5b61406286614027565b945060208601519350604086015192506060860151915061408560808701614027565b90509295509295909350565b634e487b7160e01b600052601160045260246000fd5b8181038181111561011657610116614091565b60008060008060008060c087890312156140d357600080fd5b86516140de81613ebb565b60208801519096506140ef81613ebb565b94506140fd60408801613fd7565b935061410b60608801613fd7565b9250608087015161411b81613ebb565b60a088015190925063ffffffff8116811461413557600080fd5b809150509295509295509295565b6020808252825182820181905260009190848201906040850190845b8181101561418157835163ffffffff168352928401929184019160010161415f565b50909695505050505050565b600082601f83011261419e57600080fd5b815160206141ae613c4f83613c0a565b8083825260208201915060208460051b8701019350868411156141d057600080fd5b602086015b84811015613b8f5780516141e881613ebb565b83529183019183016141d5565b6000806040838503121561420857600080fd5b825167ffffffffffffffff8082111561422057600080fd5b818501915085601f83011261423457600080fd5b81516020614244613c4f83613c0a565b82815260059290921b8401810191818101908984111561426357600080fd5b948201945b838610156142915785518060060b81146142825760008081fd5b82529482019490820190614268565b918801519196509093505050808211156142aa57600080fd5b506142b78582860161418d565b9150509250929050565b600682810b9082900b03667fffffffffffff198112667fffffffffffff8213171561011657610116614091565b634e487b7160e01b600052601260045260246000fd5b60008160060b8360060b8061431b5761431b6142ee565b667fffffffffffff1982146000198214161561433957614339614091565b90059392505050565b600082614351576143516142ee565b500790565b60008160020b627fffff19810361436f5761436f614091565b6000190192915050565b600181815b808511156143b457816000190482111561439a5761439a614091565b808516156143a757918102915b93841c939080029061437e565b509250929050565b6000826143cb57506001610116565b816143d857506000610116565b81600181146143ee57600281146143f857614414565b6001915050610116565b60ff84111561440957614409614091565b50506001821b610116565b5060208310610133831016604e8410600b8410161715614437575081810a610116565b6144418383614379565b806000190482111561445557614455614091565b029392505050565b60006102b460ff8416836143bc565b818103600083128015838313168383128216171561448c5761448c614091565b5092915050565b6000600160ff1b82016144a8576144a8614091565b5060000390565b8051801515811461200457600080fd5b6000806000606084860312156144d457600080fd5b83516144df81613ebb565b6020850151909350915061401e604085016144af565b60006080828403121561450757600080fd5b61450f613bb0565b82518060070b811461452057600080fd5b8152602083015167ffffffffffffffff8116811461453d57600080fd5b60208201526040830151600381900b811461455757600080fd5b60408201526060928301519281019290925250919050565b808201828112600083128015821682158216171561458f5761458f614091565b505092915050565b6000806000606084860312156145ac57600080fd5b83516145b781613ebb565b602085015160409095015190969495509392505050565b60ff8416815267ffffffffffffffff831660208201526060604082015260006145fa6060830184613df6565b95945050505050565b6001600160a01b03831681526040602082018190526000906102ce90830184613af5565b60006020828403121561463957600080fd5b5051919050565b60008261464f5761464f6142ee565b600160ff1b82146000198414161561466957614669614091565b500590565b6146788185613abd565b60606020820152600061468e6060830185613af5565b8281036040840152610f4f8185613df6565b601f8211156146e8576000816000526020600020601f850160051c810160208610156146c95750805b601f850160051c820191505b8181101561325c578281556001016146d5565b505050565b815167ffffffffffffffff81111561470757614707613b9a565b61471b816147158454613dc2565b846146a0565b602080601f83116001811461475057600084156147385750858301515b600019600386901b1c1916600185901b17855561325c565b600085815260208120601f198616915b8281101561477f57888601518255948401946001909101908401614760565b508582101561479d5787850151600019600388901b60f8161c191681555b5050505050600190811b01905550565b8082018082111561011657610116614091565b6000826147cf576147cf6142ee565b500490565b6000826147e3576147e36142ee565b500690565b80820260008212600160ff1b8414161561480457614804614091565b818105831482151761011657610116614091565b600069ffffffffffffffffffff82168061436f5761436f614091565b60006001820161484657614846614091565b5060010190565b60006102b483836143bc565b808202811582820484141761011657610116614091565b60006020828403121561488257600080fd5b6102b4826144af565b60006020828403121561489d57600080fd5b6102b482613fd7565b60006001600160ff1b01820161484657614846614091565b6000600160ff1b82016148d3576148d3614091565b506000190190565b60008160020b627fffff1981036148f4576148f4614091565b60000392915050565b6000825161490f818460208701613ad1565b919091019291505056fea264697066735822122074f32fef384fdc296b0859f1c1f941c8e736c6cb972aa9e2b894956ebd6a80b364736f6c63430008160033","r":"0x1","s":"0x1","yParity":"0x0","hash":"0xbc73db80bf4b8784ba10a8910a0b7ef85f6846d102b41dd990969ea205335354","type":"0x2"}],"ommers":[]},{"header":{"parentHash":"0x026ae0c6ae91f186a9befa1ac8be30eea35e30e77de51a731085221e5cd39209","sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347","miner":"0x0000000000000000000000000000000000000000","stateRoot":"0xb6003e7ba07a15a9e35f63daa484728ec4ceeded0c4d10ac1b04e9552d412b3c","transactionsRoot":"0x6e4969a136061ca7a390d12830d47a151585325a8d396819fb2b958ff85e9f8f","receiptsRoot":"0xc3e81df67d3e2a6c8345a954ef250cfcc41abcc2292a5aa263071124533fc9ad","logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","difficulty":"0x0","number":"0x3","gasLimit":"0x1c9c380","gasUsed":"0x3c0f6","timestamp":"0x66b200ce","mixHash":"0x0000000000000000000000000000000000000000000000000000000000000000","nonce":"0x0000000000000000","baseFeePerGas":"0x18993a68","blobGasUsed":"0x0","excessBlobGas":"0x0","extraData":"0x"},"transactions":[{"chainId":"0x343a","nonce":"0x0","gas":"0x3c0f6","maxFeePerGas":"0x5d4285cd","maxPriorityFeePerGas":"0x3b9aca00","value":"0x0","accessList":[],"input":"0x608060405234801561001057600080fd5b50610380806100206000396000f3fe6080604052600080357fffffffff0000000000000000000000000000000000000000000000000000000016905060008160e01c610251565b60006379ba509782101561015e5781631627540c811461009857632a952b2d81146100b457633659cfe681146100d0576350c946fe81146100ec576353a47bb781146101085763625ca21c81146101245763718fe928811461014057610158565b7347d08dad17ccb558b3ea74b1a0e73a9cc804a9dc9150610158565b738138ef7cf908021d117e542120b7a390650161079150610158565b7347d08dad17ccb558b3ea74b1a0e73a9cc804a9dc9150610158565b738138ef7cf908021d117e542120b7a390650161079150610158565b7347d08dad17ccb558b3ea74b1a0e73a9cc804a9dc9150610158565b738138ef7cf908021d117e542120b7a390650161079150610158565b7347d08dad17ccb558b3ea74b1a0e73a9cc804a9dc91505b5061024c565b816379ba509781146101a657638da5cb5b81146101c25763aaf10f4281146101de5763c7f62cda81146101fa5763daa250be81146102165763deba1b9881146102325761024a565b7347d08dad17ccb558b3ea74b1a0e73a9cc804a9dc915061024a565b7347d08dad17ccb558b3ea74b1a0e73a9cc804a9dc915061024a565b7347d08dad17ccb558b3ea74b1a0e73a9cc804a9dc915061024a565b7347d08dad17ccb558b3ea74b1a0e73a9cc804a9dc915061024a565b738138ef7cf908021d117e542120b7a39065016107915061024a565b738138ef7cf908021d117e542120b7a3906501610791505b505b919050565b61025a81610037565b915050600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff16036102ce57816040517fc2a825f50000000000000000000000000000000000000000000000000000000081526004016102c5919061032f565b60405180910390fd5b3660008037600080366000845af43d6000803e80600081146102ef573d6000f35b3d6000fd5b60007fffffffff0000000000000000000000000000000000000000000000000000000082169050919050565b610329816102f4565b82525050565b60006020820190506103446000830184610320565b9291505056fea264697066735822122017a4b7fdaaab3897a7b47abaed8d2ee92d558883d3bb2a8454f9601b2ab2c3db64736f6c63430008150033","r":"0x1","s":"0x1","yParity":"0x0","hash":"0x2476e039803622aeb040f924f04c493f559aed3d6c9372ab405cb33c8c695328","type":"0x2"}],"ommers":[]},{"header":{"parentHash":"0x3d22100ac0ee8d5cde334f7f926191a861b0648971ebc179547df28a0224c6d0","sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347","miner":"0x0000000000000000000000000000000000000000","stateRoot":"0x9511d4711e5c30a72b0bff38a261daa75dcc5ba8b772d970a5c742244b4c861b","transactionsRoot":"0xba5fff578d3d6c2cd63acbe9bca353eaa6fe22a5c408956eff49106e0a96c507","receiptsRoot":"0xbae111f01cb07677e3a8c5031546138407c01bc964d3493d732dc4edf47d36d3","logsBloom":"0x00000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000020000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000001000000000000000000000400000001000010000000000000000000000000000000000000000000000000000000000000000000000000","difficulty":"0x0","number":"0x5","gasLimit":"0x1c9c380","gasUsed":"0xcae7","timestamp":"0x66b200cb","mixHash":"0x0000000000000000000000000000000000000000000000000000000000000000","nonce":"0x0000000000000000","baseFeePerGas":"0x12e09c7a","blobGasUsed":"0x0","excessBlobGas":"0x0","extraData":"0x"},"transactions":[{"chainId":"0x343a","nonce":"0x0","gas":"0xcc4d","maxFeePerGas":"0x557e5ec4","maxPriorityFeePerGas":"0x3b9aca00","to":"0x83a0444b93927c3afcbe46e522280390f748e171","value":"0x0","accessList":[],"input":"0x3659cfe6000000000000000000000000108f53faf774d7c4c56f5bce9ca6e605ce8aeadd","r":"0x1","s":"0x1","yParity":"0x0","hash":"0xf88e7b19ee347145c257e0cf7ac4ecc2bae83ca79d7edaa231e71d3213aeb151","type":"0x2"}],"ommers":[]},{"header":{"parentHash":"0x08abe6e453727534d8dd708843a7522b7d500338bdfe2402ca105dcdb05eebe9","sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347","miner":"0x0000000000000000000000000000000000000000","stateRoot":"0x9c8eaf493f8b4edce2ba1647343eadcc0989cf461e712c0a6253ff2ca1842bb7","transactionsRoot":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421","receiptsRoot":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421","logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","difficulty":"0x0","number":"0x1","gasLimit":"0x1c9c380","gasUsed":"0x0","timestamp":"0x66b200ca","mixHash":"0x0000000000000000000000000000000000000000000000000000000000000000","nonce":"0x0000000000000000","baseFeePerGas":"0x3b9aca00","blobGasUsed":"0x0","excessBlobGas":"0x0","extraData":"0x"},"transactions":[],"ommers":[]},{"header":{"parentHash":"0xdd07c07470e1deff3749831f0f1ad8d4b6e35505e83b3c6ea14181716197cd8a","sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347","miner":"0x0000000000000000000000000000000000000000","stateRoot":"0x29aa352e71b139e83b397bdd3dcf9b65d74770edaf3a9624d0dbc4f96f868680","transactionsRoot":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421","receiptsRoot":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421","logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","difficulty":"0x0","number":"0x2","gasLimit":"0x1c9c380","gasUsed":"0x0","timestamp":"0x66b200cb","mixHash":"0x0000000000000000000000000000000000000000000000000000000000000000","nonce":"0x0000000000000000","baseFeePerGas":"0x24a1ab52","blobGasUsed":"0x0","excessBlobGas":"0x0","extraData":"0x"},"transactions":[],"ommers":[]},{"header":{"parentHash":"0x0000000000000000000000000000000000000000000000000000000000000000","sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347","miner":"0x0000000000000000000000000000000000000000","stateRoot":"0x0000000000000000000000000000000000000000000000000000000000000000","transactionsRoot":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421","receiptsRoot":"0x0000000000000000000000000000000000000000000000000000000000000000","logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","difficulty":"0x0","number":"0x0","gasLimit":"0x1c9c380","gasUsed":"0x0","timestamp":"0x66b200c9","mixHash":"0x0000000000000000000000000000000000000000000000000000000000000000","nonce":"0x0000000000000000","baseFeePerGas":"0x3b9aca00","blobGasUsed":"0x0","excessBlobGas":"0x0","extraData":"0x"},"transactions":[],"ommers":[]},{"header":{"parentHash":"0xf6930be4847cac5017bbcbec2756eed19f36b4196526a98a88e311c296e3a9be","sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347","miner":"0x0000000000000000000000000000000000000000","stateRoot":"0x29aa352e71b139e83b397bdd3dcf9b65d74770edaf3a9624d0dbc4f96f868680","transactionsRoot":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421","receiptsRoot":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421","logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","difficulty":"0x0","number":"0x1","gasLimit":"0x1c9c380","gasUsed":"0x0","timestamp":"0x66b200cc","mixHash":"0x0000000000000000000000000000000000000000000000000000000000000000","nonce":"0x0000000000000000","baseFeePerGas":"0x200d75e8","blobGasUsed":"0x0","excessBlobGas":"0x0","extraData":"0x"},"transactions":[],"ommers":[]},{"header":{"parentHash":"0x08abe6e453727534d8dd708843a7522b7d500338bdfe2402ca105dcdb05eebe9","sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347","miner":"0x0000000000000000000000000000000000000000","stateRoot":"0xb6003e7ba07a15a9e35f63daa484728ec4ceeded0c4d10ac1b04e9552d412b3c","transactionsRoot":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421","receiptsRoot":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421","logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","difficulty":"0x0","number":"0x4","gasLimit":"0x1c9c380","gasUsed":"0x0","timestamp":"0x66b200ca","mixHash":"0x0000000000000000000000000000000000000000000000000000000000000000","nonce":"0x0000000000000000","baseFeePerGas":"0x1592fbf9","blobGasUsed":"0x0","excessBlobGas":"0x0","extraData":"0x"},"transactions":[],"ommers":[]},{"header":{"parentHash":"0x149d41e3b89d8324cef3feff98ef308e97bafe8745cc8461c60172bc7d4c44ba","sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347","miner":"0x0000000000000000000000000000000000000000","stateRoot":"0x510f2275449c013534a25ad0b13c867caf720947b68bcbcd4863f7b172a5d023","transactionsRoot":"0x0b44110186e52ff0ceb6b0776ca2992c94144a4ed712eef65ea038260ef0fcc7","receiptsRoot":"0xc2823b8eb4730d9f2657137cc2ddc2c4f22ab68e0ab826236cf6a1551ca2b3a5","logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","difficulty":"0x0","number":"0x2","gasLimit":"0x1c9c380","gasUsed":"0xe61f9","timestamp":"0x66b200cb","mixHash":"0x0000000000000000000000000000000000000000000000000000000000000000","nonce":"0x0000000000000000","baseFeePerGas":"0x342770c0","blobGasUsed":"0x0","excessBlobGas":"0x0","extraData":"0x"},"transactions":[{"chainId":"0x343a","nonce":"0x0","gas":"0xe94d1","maxFeePerGas":"0x83215600","maxPriorityFeePerGas":"0x3b9aca00","to":"0x4e59b44847b379578588920ca78fbf26c0b4956c","value":"0x0","accessList":[],"input":"0x4786e4342646b3ba97c1790b6cf5a55087a36240b22570f5d3a5d6bcc929d93b608060405234801561001057600080fd5b5060008061002661006d60201b61081b1760201c565b60000160006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff16021790555050610141565b60008060405160200161007f90610121565b6040516020818303038152906040528051906020012090508091505090565b600082825260208201905092915050565b7f696f2e73796e7468657469782e636f72652d636f6e7472616374732e4f776e6160008201527f626c650000000000000000000000000000000000000000000000000000000000602082015250565b600061010b60238361009e565b9150610116826100af565b604082019050919050565b6000602082019050818103600083015261013a816100fe565b9050919050565b611000806101506000396000f3fe608060405234801561001057600080fd5b50600436106100885760003560e01c806379ba50971161005b57806379ba5097146100ed5780638da5cb5b146100f7578063aaf10f4214610115578063c7f62cda1461013357610088565b80631627540c1461008d5780633659cfe6146100a957806353a47bb7146100c5578063718fe928146100e3575b600080fd5b6100a760048036038101906100a29190610d25565b61014f565b005b6100c360048036038101906100be9190610d25565b6102d0565b005b6100cd6102e4565b6040516100da9190610d61565b60405180910390f35b6100eb610317565b005b6100f56103fe565b005b6100ff61058b565b60405161010c9190610d61565b60405180910390f35b61011d6105be565b60405161012a9190610d61565b60405180910390f35b61014d60048036038101906101489190610d25565b6105f1565b005b61015761084c565b600061016161081b565b9050600073ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff16036101c9576040517fd92e233d00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b8060010160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1603610252576040517fa88ee57700000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b818160010160006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055507f906a1c6bd7e3091ea86693dd029a831c19049ce77f1dce2ce0bab1cacbabce22826040516102c49190610d61565b60405180910390a15050565b6102d861084c565b6102e1816108c5565b50565b60006102ee61081b565b60010160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16905090565b600061032161081b565b90503373ffffffffffffffffffffffffffffffffffffffff168160010160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16146103b757336040517fa0e5a0d70000000000000000000000000000000000000000000000000000000081526004016103ae9190610d61565b60405180910390fd5b60008160010160006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff16021790555050565b600061040861081b565b905060008160010160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1690508073ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff16146104a357336040517fa0e5a0d700000000000000000000000000000000000000000000000000000000815260040161049a9190610d61565b60405180910390fd5b7fb532073b38c83145e3e5135377a08bf9aab55bc0fd7c1179cd4fb995d2a5159c8260000160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16826040516104f8929190610d7c565b60405180910390a1808260000160006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff16021790555060008260010160006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055505050565b600061059561081b565b60000160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16905090565b60006105c8610b05565b60000160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16905090565b60006105fb610b05565b905060018160000160146101000a81548160ff02191690831515021790555060008160000160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff169050828260000160006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff16021790555060008373ffffffffffffffffffffffffffffffffffffffff163073ffffffffffffffffffffffffffffffffffffffff16633659cfe6846040516024016106cc9190610d61565b604051602081830303815290604052915060e01b6020820180517bffffffffffffffffffffffffffffffffffffffffffffffffffffffff838183161783525050505060405161071b9190610e16565b600060405180830381855af49150503d8060008114610756576040519150601f19603f3d011682016040523d82523d6000602084013e61075b565b606091505b505090508015806107c357508173ffffffffffffffffffffffffffffffffffffffff16610786610b05565b60000160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff1614155b156107fa576040517fa1cfa5a800000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b60008360000160146101000a81548160ff0219169083151502179055600080fd5b60008060405160200161082d90610eb0565b6040516020818303038152906040528051906020012090508091505090565b610854610b36565b73ffffffffffffffffffffffffffffffffffffffff163373ffffffffffffffffffffffffffffffffffffffff16146108c357336040517f8e4a23d60000000000000000000000000000000000000000000000000000000081526004016108ba9190610d61565b60405180910390fd5b565b600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff160361092b576040517fd92e233d00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b61093481610b69565b61097557806040517f8a8b41ec00000000000000000000000000000000000000000000000000000000815260040161096c9190610d61565b60405180910390fd5b600061097f610b05565b90508060000160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff168273ffffffffffffffffffffffffffffffffffffffff1603610a0a576040517fa88ee57700000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b8060000160149054906101000a900460ff16158015610a2e5750610a2d82610b7c565b5b15610a7057816040517f15504301000000000000000000000000000000000000000000000000000000008152600401610a679190610d61565b60405180910390fd5b818160000160006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055503073ffffffffffffffffffffffffffffffffffffffff167f5d611f318680d00598bb735d61bacf0c514c6b50e1e5ad30040a4df2b12791c783604051610af99190610d61565b60405180910390a25050565b600080604051602001610b1790610f42565b6040516020818303038152906040528051906020012090508091505090565b6000610b4061081b565b60000160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16905090565b600080823b905060008111915050919050565b60008060003073ffffffffffffffffffffffffffffffffffffffff163073ffffffffffffffffffffffffffffffffffffffff1663c7f62cda86604051602401610bc59190610d61565b604051602081830303815290604052915060e01b6020820180517bffffffffffffffffffffffffffffffffffffffffffffffffffffffff8381831617835250505050604051610c149190610e16565b600060405180830381855af49150503d8060008114610c4f576040519150601f19603f3d011682016040523d82523d6000602084013e610c54565b606091505b509150915081158015610cb9575063a1cfa5a860e01b604051602001610c7a9190610faf565b6040516020818303038152906040528051906020012081604051602001610ca19190610e16565b60405160208183030381529060405280519060200120145b92505050919050565b600080fd5b600073ffffffffffffffffffffffffffffffffffffffff82169050919050565b6000610cf282610cc7565b9050919050565b610d0281610ce7565b8114610d0d57600080fd5b50565b600081359050610d1f81610cf9565b92915050565b600060208284031215610d3b57610d3a610cc2565b5b6000610d4984828501610d10565b91505092915050565b610d5b81610ce7565b82525050565b6000602082019050610d766000830184610d52565b92915050565b6000604082019050610d916000830185610d52565b610d9e6020830184610d52565b9392505050565b600081519050919050565b600081905092915050565b60005b83811015610dd9578082015181840152602081019050610dbe565b60008484015250505050565b6000610df082610da5565b610dfa8185610db0565b9350610e0a818560208601610dbb565b80840191505092915050565b6000610e228284610de5565b915081905092915050565b600082825260208201905092915050565b7f696f2e73796e7468657469782e636f72652d636f6e7472616374732e4f776e6160008201527f626c650000000000000000000000000000000000000000000000000000000000602082015250565b6000610e9a602383610e2d565b9150610ea582610e3e565b604082019050919050565b60006020820190508181036000830152610ec981610e8d565b9050919050565b7f696f2e73796e7468657469782e636f72652d636f6e7472616374732e50726f7860008201527f7900000000000000000000000000000000000000000000000000000000000000602082015250565b6000610f2c602183610e2d565b9150610f3782610ed0565b604082019050919050565b60006020820190508181036000830152610f5b81610f1f565b9050919050565b60007fffffffff0000000000000000000000000000000000000000000000000000000082169050919050565b6000819050919050565b610fa9610fa482610f62565b610f8e565b82525050565b6000610fbb8284610f98565b6004820191508190509291505056fea264697066735822122023a7c33d7b91dce35ffbcf8837693364ab22a3905d0fc00016833e5fac45ca2f64736f6c63430008110033","r":"0x1","s":"0x1","yParity":"0x0","hash":"0x4feae6769d748b4f0f7c9bf21d782236c88f13906789a3ec602961296e4c3e43","type":"0x2"}],"ommers":[]},{"header":{"parentHash":"0xb3535af5103fd1c2bbd6dc7ff23f0799037a6542c231ebcb85abd776560fa512","sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347","miner":"0x0000000000000000000000000000000000000000","stateRoot":"0x23d74fb99ff6e42cbb5c33f92b078e37be6af2b6092459b103ff7059a6517ebc","transactionsRoot":"0x9eab45eca206fe11c107ea985c7d02fcfa442836aea3e04ba11dc4df587d5aa6","receiptsRoot":"0xe25abcfa973db8c55f73292137c626430de130a382ad4466337fefb0f7c8fde0","logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","difficulty":"0x0","number":"0x2","gasLimit":"0x1c9c380","gasUsed":"0x3ce3f","timestamp":"0x66b200cd","mixHash":"0x0000000000000000000000000000000000000000000000000000000000000000","nonce":"0x0000000000000000","baseFeePerGas":"0x1c0bc72b","blobGasUsed":"0x0","excessBlobGas":"0x0","extraData":"0x"},"transactions":[{"chainId":"0x343a","nonce":"0x0","gas":"0x3d8a8","maxFeePerGas":"0x6211577c","maxPriorityFeePerGas":"0x3b9aca00","to":"0x4e59b44847b379578588920ca78fbf26c0b4956c","value":"0x0","accessList":[],"input":"0x4786e4342646b3ba97c1790b6cf5a55087a36240b22570f5d3a5d6bcc929d93b608060405234801561001057600080fd5b5060405161068538038061068583398181016040528101906100329190610275565b818181600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff160361009b576040517fd92e233d00000000000000000000000000000000000000000000000000000000815260040160405180910390fd5b6100ae8161019d60201b61004f1760201c565b6100ef57806040517f8a8b41ec0000000000000000000000000000000000000000000000000000000081526004016100e691906102c4565b60405180910390fd5b806100fe6101b060201b60201c565b60000160006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff16021790555050806101536101e160201b6100621760201c565b60000160006101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff16021790555050505050610414565b600080823b905060008111915050919050565b6000806040516020016101c290610362565b6040516020818303038152906040528051906020012090508091505090565b6000806040516020016101f3906103f4565b6040516020818303038152906040528051906020012090508091505090565b600080fd5b600073ffffffffffffffffffffffffffffffffffffffff82169050919050565b600061024282610217565b9050919050565b61025281610237565b811461025d57600080fd5b50565b60008151905061026f81610249565b92915050565b6000806040838503121561028c5761028b610212565b5b600061029a85828601610260565b92505060206102ab85828601610260565b9150509250929050565b6102be81610237565b82525050565b60006020820190506102d960008301846102b5565b92915050565b600082825260208201905092915050565b7f696f2e73796e7468657469782e636f72652d636f6e7472616374732e50726f7860008201527f7900000000000000000000000000000000000000000000000000000000000000602082015250565b600061034c6021836102df565b9150610357826102f0565b604082019050919050565b6000602082019050818103600083015261037b8161033f565b9050919050565b7f696f2e73796e7468657469782e636f72652d636f6e7472616374732e4f776e6160008201527f626c650000000000000000000000000000000000000000000000000000000000602082015250565b60006103de6023836102df565b91506103e982610382565b604082019050919050565b6000602082019050818103600083015261040d816103d1565b9050919050565b610262806104236000396000f3fe6080604052366100135761001161001d565b005b61001b61001d565b005b6000610027610093565b90503660008037600080366000845af43d6000803e806000811461004a573d6000f35b3d6000fd5b600080823b905060008111915050919050565b6000806040516020016100749061017a565b6040516020818303038152906040528051906020012090508091505090565b600061009d6100c6565b60000160009054906101000a900473ffffffffffffffffffffffffffffffffffffffff16905090565b6000806040516020016100d89061020c565b6040516020818303038152906040528051906020012090508091505090565b600082825260208201905092915050565b7f696f2e73796e7468657469782e636f72652d636f6e7472616374732e4f776e6160008201527f626c650000000000000000000000000000000000000000000000000000000000602082015250565b60006101646023836100f7565b915061016f82610108565b604082019050919050565b6000602082019050818103600083015261019381610157565b9050919050565b7f696f2e73796e7468657469782e636f72652d636f6e7472616374732e50726f7860008201527f7900000000000000000000000000000000000000000000000000000000000000602082015250565b60006101f66021836100f7565b91506102018261019a565b604082019050919050565b60006020820190508181036000830152610225816101e9565b905091905056fea2646970667358221220800da1f73cebd5e4afa07496d9bca6b6c4f526bdd3f4014ec15c70fe3a1c441364736f6c6343000811003300000000000000000000000047d08dad17ccb558b3ea74b1a0e73a9cc804a9dc000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266","r":"0x1","s":"0x1","yParity":"0x0","hash":"0xb6794d5c7abed6f91d447e8efb72ef2580595a6d7c8dee57ba1dbb330970146a","type":"0x2"}],"ommers":[]},{"header":{"parentHash":"0x08abe6e453727534d8dd708843a7522b7d500338bdfe2402ca105dcdb05eebe9","sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347","miner":"0x0000000000000000000000000000000000000000","stateRoot":"0x510f2275449c013534a25ad0b13c867caf720947b68bcbcd4863f7b172a5d023","transactionsRoot":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421","receiptsRoot":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421","logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","difficulty":"0x0","number":"0x3","gasLimit":"0x1c9c380","gasUsed":"0x0","timestamp":"0x66b200ca","mixHash":"0x0000000000000000000000000000000000000000000000000000000000000000","nonce":"0x0000000000000000","baseFeePerGas":"0x29dd5614","blobGasUsed":"0x0","excessBlobGas":"0x0","extraData":"0x"},"transactions":[],"ommers":[]}]} \ No newline at end of file diff --git a/crates/anvil/test-data/state-dump-legacy.json b/crates/anvil/test-data/state-dump-legacy.json index 26fa019b20fb5..e7b8544dfb6f1 100644 --- a/crates/anvil/test-data/state-dump-legacy.json +++ b/crates/anvil/test-data/state-dump-legacy.json @@ -1 +1 @@ -{"block":{"number":2,"beneficiary":"0x0000000000000000000000000000000000000000","timestamp":1724762147,"gas_limit":30000000,"basefee":875175000,"difficulty":"0x0","prevrandao":"0xb92480171c0235f8c6710a4047d7ee14a3be58c630839fb4422826ff3a013e44","blob_excess_gas_and_price":{"excess_blob_gas":0,"blob_gasprice":1}},"accounts":{"0x0000000000000000000000000000000000000000":{"nonce":0,"balance":"0xa410","code":"0x","storage":{}},"0x14dc79964da2c08b23698b3d3cc7ca32193d9955":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0x15d34aaf54267db7d7c367839aaf71a00a2c6a65":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0x23618e81e3f5cdf7f54c3d65f7fbc0abf5b21e8f":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0x4e59b44847b379578588920ca78fbf26c0b4956c":{"nonce":0,"balance":"0x0","code":"0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe03601600081602082378035828234f58015156039578182fd5b8082525050506014600cf3","storage":{}},"0x70997970c51812dc3a010c7d01b50e0d17dc79c8":{"nonce":1,"balance":"0x21e19e0b90393da9b38","code":"0x","storage":{}},"0x90f79bf6eb2c4f870365e785982e1f101e93b906":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0x976ea74026e726554db657fa54763abd0c3a0aa9":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0x9965507d1a55bcc2695c58ba16fb37d819b0a4dc":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0xa0ee7a142d267c1f36714e4a8f75612f20a79720":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266":{"nonce":1,"balance":"0x21e19e0b6a140b55df8","code":"0x","storage":{}}},"best_block_number":2,"blocks":[{"header":{"parentHash":"0xceb0fe420d6f14a8eeec4319515b89acbb0bb4861cad9983d529ab4b1e4af929","sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347","miner":"0x0000000000000000000000000000000000000000","stateRoot":"0xe1423fd180478ab4fd05a7103277d64496b15eb914ecafe71eeec871b552efd1","transactionsRoot":"0x2b5598ef261e5f88e4303bb2b3986b3d5c0ebf4cd9977daebccae82a6469b988","receiptsRoot":"0xf78dfb743fbd92ade140711c8bbc542b5e307f0ab7984eff35d751969fe57efa","logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","difficulty":"0x0","number":"0x2","gasLimit":"0x1c9c380","gasUsed":"0x5208","timestamp":"0x66cdc823","mixHash":"0x0000000000000000000000000000000000000000000000000000000000000000","nonce":"0x0000000000000000","baseFeePerGas":"0x342a1c58","blobGasUsed":"0x0","excessBlobGas":"0x0","extraData":"0x"},"transactions":[{"EIP1559":{"chainId":"0x7a69","nonce":"0x0","gas":"0x5209","maxFeePerGas":"0x77359401","maxPriorityFeePerGas":"0x1","to":"0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266","value":"0x0","accessList":[],"input":"0x","r":"0x85c2794a580da137e24ccc823b45ae5cea99371ae23ee13860fcc6935f8305b0","s":"0x41de7fa4121dab284af4453d30928241208bafa90cdb701fe9bc7054759fe3cd","yParity":"0x0","hash":"0x8c9b68e8947ace33028dba167354fde369ed7bbe34911b772d09b3c64b861515"}}],"ommers":[]},{"header":{"parentHash":"0x0000000000000000000000000000000000000000000000000000000000000000","sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347","miner":"0x0000000000000000000000000000000000000000","stateRoot":"0x0000000000000000000000000000000000000000000000000000000000000000","transactionsRoot":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421","receiptsRoot":"0x0000000000000000000000000000000000000000000000000000000000000000","logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","difficulty":"0x0","number":"0x0","gasLimit":"0x1c9c380","gasUsed":"0x0","timestamp":"0x66cdc80e","mixHash":"0x0000000000000000000000000000000000000000000000000000000000000000","nonce":"0x0000000000000000","baseFeePerGas":"0x3b9aca00","blobGasUsed":"0x0","excessBlobGas":"0x0","extraData":"0x"},"transactions":[],"ommers":[]},{"header":{"parentHash":"0xa00dc0c9ee9a888e67ea32d8772f8cc28eff62448c9ec985ee941fcbc921ba59","sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347","miner":"0x0000000000000000000000000000000000000000","stateRoot":"0x6e5f60b37eeaece7dedfc42cc394731a0ae3ed3d3be93c402780b2e23e141175","transactionsRoot":"0x9ceaeb1b16b924afbf4bf4df4c2c49dc9cfbe23ac7a40bf26a704158ea2d352f","receiptsRoot":"0xf78dfb743fbd92ade140711c8bbc542b5e307f0ab7984eff35d751969fe57efa","logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","difficulty":"0x0","number":"0x1","gasLimit":"0x1c9c380","gasUsed":"0x5208","timestamp":"0x66cdc814","mixHash":"0x0000000000000000000000000000000000000000000000000000000000000000","nonce":"0x0000000000000000","baseFeePerGas":"0x3b9aca00","blobGasUsed":"0x0","excessBlobGas":"0x0","extraData":"0x"},"transactions":[{"EIP1559":{"chainId":"0x7a69","nonce":"0x0","gas":"0x5209","maxFeePerGas":"0x77359401","maxPriorityFeePerGas":"0x1","to":"0x70997970c51812dc3a010c7d01b50e0d17dc79c8","value":"0x0","accessList":[],"input":"0x","r":"0x703a4b4d6dbff2fa2345df73263df2098faa7214863b5ec82c4c07162d87b853","s":"0x17dea762c4ce600ad1d9d2c9ae6dd35b9e526d03c875f868ad0792fd4fad72e0","yParity":"0x0","hash":"0xf8d5fb22350f52ae8c30cd7f6969eb73de849c8dc010f4215d4c5c24824fe2b3"}}],"ommers":[]}]} \ No newline at end of file +{"block":{"number":2,"beneficiary":"0x0000000000000000000000000000000000000000","timestamp":1724762147,"gas_limit":30000000,"basefee":875175000,"difficulty":"0x0","prevrandao":"0xb92480171c0235f8c6710a4047d7ee14a3be58c630839fb4422826ff3a013e44","blob_excess_gas_and_price":{"excess_blob_gas":0,"blob_gasprice":1}},"accounts":{"0x0000000000000000000000000000000000000000":{"nonce":0,"balance":"0xa410","code":"0x","storage":{}},"0x14dc79964da2c08b23698b3d3cc7ca32193d9955":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0x15d34aaf54267db7d7c367839aaf71a00a2c6a65":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0x23618e81e3f5cdf7f54c3d65f7fbc0abf5b21e8f":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0x4e59b44847b379578588920ca78fbf26c0b4956c":{"nonce":0,"balance":"0x0","code":"0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe03601600081602082378035828234f58015156039578182fd5b8082525050506014600cf3","storage":{}},"0x70997970c51812dc3a010c7d01b50e0d17dc79c8":{"nonce":1,"balance":"0x21e19e0b90393da9b38","code":"0x","storage":{}},"0x90f79bf6eb2c4f870365e785982e1f101e93b906":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0x976ea74026e726554db657fa54763abd0c3a0aa9":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0x9965507d1a55bcc2695c58ba16fb37d819b0a4dc":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0xa0ee7a142d267c1f36714e4a8f75612f20a79720":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266":{"nonce":1,"balance":"0x21e19e0b6a140b55df8","code":"0x","storage":{}}},"best_block_number":2,"blocks":[{"header":{"parentHash":"0xceb0fe420d6f14a8eeec4319515b89acbb0bb4861cad9983d529ab4b1e4af929","sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347","miner":"0x0000000000000000000000000000000000000000","stateRoot":"0xe1423fd180478ab4fd05a7103277d64496b15eb914ecafe71eeec871b552efd1","transactionsRoot":"0x2b5598ef261e5f88e4303bb2b3986b3d5c0ebf4cd9977daebccae82a6469b988","receiptsRoot":"0xf78dfb743fbd92ade140711c8bbc542b5e307f0ab7984eff35d751969fe57efa","logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","difficulty":"0x0","number":"0x2","gasLimit":"0x1c9c380","gasUsed":"0x5208","timestamp":"0x66cdc823","mixHash":"0x0000000000000000000000000000000000000000000000000000000000000000","nonce":"0x0000000000000000","baseFeePerGas":"0x342a1c58","blobGasUsed":"0x0","excessBlobGas":"0x0","extraData":"0x"},"transactions":[{"chainId":"0x7a69","nonce":"0x0","gas":"0x5209","maxFeePerGas":"0x77359401","maxPriorityFeePerGas":"0x1","to":"0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266","value":"0x0","accessList":[],"input":"0x","r":"0x85c2794a580da137e24ccc823b45ae5cea99371ae23ee13860fcc6935f8305b0","s":"0x41de7fa4121dab284af4453d30928241208bafa90cdb701fe9bc7054759fe3cd","yParity":"0x0","hash":"0x8c9b68e8947ace33028dba167354fde369ed7bbe34911b772d09b3c64b861515","type":"0x2"}],"ommers":[]},{"header":{"parentHash":"0x0000000000000000000000000000000000000000000000000000000000000000","sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347","miner":"0x0000000000000000000000000000000000000000","stateRoot":"0x0000000000000000000000000000000000000000000000000000000000000000","transactionsRoot":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421","receiptsRoot":"0x0000000000000000000000000000000000000000000000000000000000000000","logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","difficulty":"0x0","number":"0x0","gasLimit":"0x1c9c380","gasUsed":"0x0","timestamp":"0x66cdc80e","mixHash":"0x0000000000000000000000000000000000000000000000000000000000000000","nonce":"0x0000000000000000","baseFeePerGas":"0x3b9aca00","blobGasUsed":"0x0","excessBlobGas":"0x0","extraData":"0x"},"transactions":[],"ommers":[]},{"header":{"parentHash":"0xa00dc0c9ee9a888e67ea32d8772f8cc28eff62448c9ec985ee941fcbc921ba59","sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347","miner":"0x0000000000000000000000000000000000000000","stateRoot":"0x6e5f60b37eeaece7dedfc42cc394731a0ae3ed3d3be93c402780b2e23e141175","transactionsRoot":"0x9ceaeb1b16b924afbf4bf4df4c2c49dc9cfbe23ac7a40bf26a704158ea2d352f","receiptsRoot":"0xf78dfb743fbd92ade140711c8bbc542b5e307f0ab7984eff35d751969fe57efa","logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","difficulty":"0x0","number":"0x1","gasLimit":"0x1c9c380","gasUsed":"0x5208","timestamp":"0x66cdc814","mixHash":"0x0000000000000000000000000000000000000000000000000000000000000000","nonce":"0x0000000000000000","baseFeePerGas":"0x3b9aca00","blobGasUsed":"0x0","excessBlobGas":"0x0","extraData":"0x"},"transactions":[{"chainId":"0x7a69","nonce":"0x0","gas":"0x5209","maxFeePerGas":"0x77359401","maxPriorityFeePerGas":"0x1","to":"0x70997970c51812dc3a010c7d01b50e0d17dc79c8","value":"0x0","accessList":[],"input":"0x","r":"0x703a4b4d6dbff2fa2345df73263df2098faa7214863b5ec82c4c07162d87b853","s":"0x17dea762c4ce600ad1d9d2c9ae6dd35b9e526d03c875f868ad0792fd4fad72e0","yParity":"0x0","hash":"0xf8d5fb22350f52ae8c30cd7f6969eb73de849c8dc010f4215d4c5c24824fe2b3","type":"0x2"}],"ommers":[]}]} \ No newline at end of file diff --git a/crates/anvil/test-data/state-dump.json b/crates/anvil/test-data/state-dump.json index 4b7e66b17f56d..e7a1e37f62809 100644 --- a/crates/anvil/test-data/state-dump.json +++ b/crates/anvil/test-data/state-dump.json @@ -1 +1,314 @@ -{"block":{"number":2,"beneficiary":"0x0000000000000000000000000000000000000000","timestamp":1724763179,"gas_limit":30000000,"basefee":875175000,"difficulty":"0x0","prevrandao":"0xdb639d7f8af4f0ff2aa9cc49861820e72f5f8bfeeed677d1e3569f6b1625df4a","blob_excess_gas_and_price":{"excess_blob_gas":0,"blob_gasprice":1}},"accounts":{"0x0000000000000000000000000000000000000000":{"nonce":0,"balance":"0xa410","code":"0x","storage":{}},"0x14dc79964da2c08b23698b3d3cc7ca32193d9955":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0x15d34aaf54267db7d7c367839aaf71a00a2c6a65":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0x23618e81e3f5cdf7f54c3d65f7fbc0abf5b21e8f":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0x4e59b44847b379578588920ca78fbf26c0b4956c":{"nonce":0,"balance":"0x0","code":"0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe03601600081602082378035828234f58015156039578182fd5b8082525050506014600cf3","storage":{}},"0x70997970c51812dc3a010c7d01b50e0d17dc79c8":{"nonce":1,"balance":"0x21e19e0b90393da9b38","code":"0x","storage":{}},"0x90f79bf6eb2c4f870365e785982e1f101e93b906":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0x976ea74026e726554db657fa54763abd0c3a0aa9":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0x9965507d1a55bcc2695c58ba16fb37d819b0a4dc":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0xa0ee7a142d267c1f36714e4a8f75612f20a79720":{"nonce":0,"balance":"0x21e19e0c9bab2400000","code":"0x","storage":{}},"0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266":{"nonce":1,"balance":"0x21e19e0b6a140b55df8","code":"0x","storage":{}}},"best_block_number":2,"blocks":[{"header":{"parentHash":"0x0000000000000000000000000000000000000000000000000000000000000000","sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347","miner":"0x0000000000000000000000000000000000000000","stateRoot":"0x0000000000000000000000000000000000000000000000000000000000000000","transactionsRoot":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421","receiptsRoot":"0x0000000000000000000000000000000000000000000000000000000000000000","logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","difficulty":"0x0","number":"0x0","gasLimit":"0x1c9c380","gasUsed":"0x0","timestamp":"0x66cdcc25","mixHash":"0x0000000000000000000000000000000000000000000000000000000000000000","nonce":"0x0000000000000000","baseFeePerGas":"0x3b9aca00","blobGasUsed":"0x0","excessBlobGas":"0x0","extraData":"0x"},"transactions":[],"ommers":[]},{"header":{"parentHash":"0x3a52101c98a4319c419681131d3585d70a6cf13a9af25136be20d451eed5480a","sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347","miner":"0x0000000000000000000000000000000000000000","stateRoot":"0x6e5f60b37eeaece7dedfc42cc394731a0ae3ed3d3be93c402780b2e23e141175","transactionsRoot":"0x9ceaeb1b16b924afbf4bf4df4c2c49dc9cfbe23ac7a40bf26a704158ea2d352f","receiptsRoot":"0xf78dfb743fbd92ade140711c8bbc542b5e307f0ab7984eff35d751969fe57efa","logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","difficulty":"0x0","number":"0x1","gasLimit":"0x1c9c380","gasUsed":"0x5208","timestamp":"0x66cdcc29","mixHash":"0x0000000000000000000000000000000000000000000000000000000000000000","nonce":"0x0000000000000000","baseFeePerGas":"0x3b9aca00","blobGasUsed":"0x0","excessBlobGas":"0x0","extraData":"0x"},"transactions":[{"transaction":{"EIP1559":{"chainId":"0x7a69","nonce":"0x0","gas":"0x5209","maxFeePerGas":"0x77359401","maxPriorityFeePerGas":"0x1","to":"0x70997970c51812dc3a010c7d01b50e0d17dc79c8","value":"0x0","accessList":[],"input":"0x","r":"0x703a4b4d6dbff2fa2345df73263df2098faa7214863b5ec82c4c07162d87b853","s":"0x17dea762c4ce600ad1d9d2c9ae6dd35b9e526d03c875f868ad0792fd4fad72e0","yParity":"0x0","hash":"0xf8d5fb22350f52ae8c30cd7f6969eb73de849c8dc010f4215d4c5c24824fe2b3"}},"impersonated_sender":null}],"ommers":[]},{"header":{"parentHash":"0x0d575f9ca968cd483549172245483a12343afc3cabef80f0fa39855b10b98c70","sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347","miner":"0x0000000000000000000000000000000000000000","stateRoot":"0xe1423fd180478ab4fd05a7103277d64496b15eb914ecafe71eeec871b552efd1","transactionsRoot":"0x2b5598ef261e5f88e4303bb2b3986b3d5c0ebf4cd9977daebccae82a6469b988","receiptsRoot":"0xf78dfb743fbd92ade140711c8bbc542b5e307f0ab7984eff35d751969fe57efa","logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","difficulty":"0x0","number":"0x2","gasLimit":"0x1c9c380","gasUsed":"0x5208","timestamp":"0x66cdcc2b","mixHash":"0x0000000000000000000000000000000000000000000000000000000000000000","nonce":"0x0000000000000000","baseFeePerGas":"0x342a1c58","blobGasUsed":"0x0","excessBlobGas":"0x0","extraData":"0x"},"transactions":[{"transaction":{"EIP1559":{"chainId":"0x7a69","nonce":"0x0","gas":"0x5209","maxFeePerGas":"0x77359401","maxPriorityFeePerGas":"0x1","to":"0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266","value":"0x0","accessList":[],"input":"0x","r":"0x85c2794a580da137e24ccc823b45ae5cea99371ae23ee13860fcc6935f8305b0","s":"0x41de7fa4121dab284af4453d30928241208bafa90cdb701fe9bc7054759fe3cd","yParity":"0x0","hash":"0x8c9b68e8947ace33028dba167354fde369ed7bbe34911b772d09b3c64b861515"}},"impersonated_sender":null}],"ommers":[]}],"transactions":[{"info":{"transaction_hash":"0xf8d5fb22350f52ae8c30cd7f6969eb73de849c8dc010f4215d4c5c24824fe2b3","transaction_index":0,"from":"0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266","to":"0x70997970c51812dc3a010c7d01b50e0d17dc79c8","contract_address":null,"traces":[{"parent":null,"children":[],"idx":0,"trace":{"depth":0,"success":true,"caller":"0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266","address":"0x70997970c51812dc3a010c7d01b50e0d17dc79c8","maybe_precompile":null,"selfdestruct_refund_target":null,"selfdestruct_transferred_value":null,"kind":"CALL","value":"0x0","data":"0x","output":"0x","gas_used":0,"gas_limit":1,"status":"Stop","steps":[],"decoded":{"label":null,"return_data":null,"call_data":null}},"logs":[],"ordering":[]}],"exit":"Stop","out":"0x","nonce":0,"gas_used":21000},"receipt":{"type":"0x2","status":"0x1","cumulativeGasUsed":"0x5208","logs":[],"logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"},"block_hash":"0x0d575f9ca968cd483549172245483a12343afc3cabef80f0fa39855b10b98c70","block_number":1},{"info":{"transaction_hash":"0x8c9b68e8947ace33028dba167354fde369ed7bbe34911b772d09b3c64b861515","transaction_index":0,"from":"0x70997970c51812dc3a010c7d01b50e0d17dc79c8","to":"0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266","contract_address":null,"traces":[{"parent":null,"children":[],"idx":0,"trace":{"depth":0,"success":true,"caller":"0x70997970c51812dc3a010c7d01b50e0d17dc79c8","address":"0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266","maybe_precompile":null,"selfdestruct_refund_target":null,"selfdestruct_transferred_value":null,"kind":"CALL","value":"0x0","data":"0x","output":"0x","gas_used":0,"gas_limit":1,"status":"Stop","steps":[],"decoded":{"label":null,"return_data":null,"call_data":null}},"logs":[],"ordering":[]}],"exit":"Stop","out":"0x","nonce":0,"gas_used":21000},"receipt":{"type":"0x2","status":"0x1","cumulativeGasUsed":"0x5208","logs":[],"logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"},"block_hash":"0x1f435a603c1bf6d544a90156b572b96d7a1730028422d800839bae78bb3506d0","block_number":2}]} \ No newline at end of file +{ + "block": { + "number": 2, + "beneficiary": "0x0000000000000000000000000000000000000000", + "timestamp": 1724763179, + "gas_limit": 30000000, + "basefee": 875175000, + "difficulty": "0x0", + "prevrandao": "0xdb639d7f8af4f0ff2aa9cc49861820e72f5f8bfeeed677d1e3569f6b1625df4a", + "blob_excess_gas_and_price": { + "excess_blob_gas": 0, + "blob_gasprice": 1 + } + }, + "accounts": { + "0x0000000000000000000000000000000000000000": { + "nonce": 0, + "balance": "0xa410", + "code": "0x", + "storage": {} + }, + "0x14dc79964da2c08b23698b3d3cc7ca32193d9955": { + "nonce": 0, + "balance": "0x21e19e0c9bab2400000", + "code": "0x", + "storage": {} + }, + "0x15d34aaf54267db7d7c367839aaf71a00a2c6a65": { + "nonce": 0, + "balance": "0x21e19e0c9bab2400000", + "code": "0x", + "storage": {} + }, + "0x23618e81e3f5cdf7f54c3d65f7fbc0abf5b21e8f": { + "nonce": 0, + "balance": "0x21e19e0c9bab2400000", + "code": "0x", + "storage": {} + }, + "0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc": { + "nonce": 0, + "balance": "0x21e19e0c9bab2400000", + "code": "0x", + "storage": {} + }, + "0x4e59b44847b379578588920ca78fbf26c0b4956c": { + "nonce": 0, + "balance": "0x0", + "code": "0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe03601600081602082378035828234f58015156039578182fd5b8082525050506014600cf3", + "storage": {} + }, + "0x70997970c51812dc3a010c7d01b50e0d17dc79c8": { + "nonce": 1, + "balance": "0x21e19e0b90393da9b38", + "code": "0x", + "storage": {} + }, + "0x90f79bf6eb2c4f870365e785982e1f101e93b906": { + "nonce": 0, + "balance": "0x21e19e0c9bab2400000", + "code": "0x", + "storage": {} + }, + "0x976ea74026e726554db657fa54763abd0c3a0aa9": { + "nonce": 0, + "balance": "0x21e19e0c9bab2400000", + "code": "0x", + "storage": {} + }, + "0x9965507d1a55bcc2695c58ba16fb37d819b0a4dc": { + "nonce": 0, + "balance": "0x21e19e0c9bab2400000", + "code": "0x", + "storage": {} + }, + "0xa0ee7a142d267c1f36714e4a8f75612f20a79720": { + "nonce": 0, + "balance": "0x21e19e0c9bab2400000", + "code": "0x", + "storage": {} + }, + "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266": { + "nonce": 1, + "balance": "0x21e19e0b6a140b55df8", + "code": "0x", + "storage": {} + } + }, + "best_block_number": 2, + "blocks": [ + { + "header": { + "parentHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "sha3Uncles": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", + "miner": "0x0000000000000000000000000000000000000000", + "stateRoot": "0x0000000000000000000000000000000000000000000000000000000000000000", + "transactionsRoot": "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", + "receiptsRoot": "0x0000000000000000000000000000000000000000000000000000000000000000", + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "difficulty": "0x0", + "number": "0x0", + "gasLimit": "0x1c9c380", + "gasUsed": "0x0", + "timestamp": "0x66cdcc25", + "mixHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "nonce": "0x0000000000000000", + "baseFeePerGas": "0x3b9aca00", + "blobGasUsed": "0x0", + "excessBlobGas": "0x0", + "extraData": "0x" + }, + "transactions": [], + "ommers": [] + }, + { + "header": { + "parentHash": "0x3a52101c98a4319c419681131d3585d70a6cf13a9af25136be20d451eed5480a", + "sha3Uncles": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", + "miner": "0x0000000000000000000000000000000000000000", + "stateRoot": "0x6e5f60b37eeaece7dedfc42cc394731a0ae3ed3d3be93c402780b2e23e141175", + "transactionsRoot": "0x9ceaeb1b16b924afbf4bf4df4c2c49dc9cfbe23ac7a40bf26a704158ea2d352f", + "receiptsRoot": "0xf78dfb743fbd92ade140711c8bbc542b5e307f0ab7984eff35d751969fe57efa", + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "difficulty": "0x0", + "number": "0x1", + "gasLimit": "0x1c9c380", + "gasUsed": "0x5208", + "timestamp": "0x66cdcc29", + "mixHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "nonce": "0x0000000000000000", + "baseFeePerGas": "0x3b9aca00", + "blobGasUsed": "0x0", + "excessBlobGas": "0x0", + "extraData": "0x" + }, + "transactions": [ + { + "transaction": { + "chainId": "0x7a69", + "nonce": "0x0", + "gas": "0x5209", + "maxFeePerGas": "0x77359401", + "maxPriorityFeePerGas": "0x1", + "to": "0x70997970c51812dc3a010c7d01b50e0d17dc79c8", + "value": "0x0", + "accessList": [], + "input": "0x", + "r": "0x703a4b4d6dbff2fa2345df73263df2098faa7214863b5ec82c4c07162d87b853", + "s": "0x17dea762c4ce600ad1d9d2c9ae6dd35b9e526d03c875f868ad0792fd4fad72e0", + "yParity": "0x0", + "hash": "0xf8d5fb22350f52ae8c30cd7f6969eb73de849c8dc010f4215d4c5c24824fe2b3", + "type": "0x2" + }, + "impersonated_sender": null + } + ], + "ommers": [] + }, + { + "header": { + "parentHash": "0x0d575f9ca968cd483549172245483a12343afc3cabef80f0fa39855b10b98c70", + "sha3Uncles": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", + "miner": "0x0000000000000000000000000000000000000000", + "stateRoot": "0xe1423fd180478ab4fd05a7103277d64496b15eb914ecafe71eeec871b552efd1", + "transactionsRoot": "0x2b5598ef261e5f88e4303bb2b3986b3d5c0ebf4cd9977daebccae82a6469b988", + "receiptsRoot": "0xf78dfb743fbd92ade140711c8bbc542b5e307f0ab7984eff35d751969fe57efa", + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "difficulty": "0x0", + "number": "0x2", + "gasLimit": "0x1c9c380", + "gasUsed": "0x5208", + "timestamp": "0x66cdcc2b", + "mixHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "nonce": "0x0000000000000000", + "baseFeePerGas": "0x342a1c58", + "blobGasUsed": "0x0", + "excessBlobGas": "0x0", + "extraData": "0x" + }, + "transactions": [ + { + "transaction": { + "chainId": "0x7a69", + "nonce": "0x0", + "gas": "0x5209", + "maxFeePerGas": "0x77359401", + "maxPriorityFeePerGas": "0x1", + "to": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", + "value": "0x0", + "accessList": [], + "input": "0x", + "r": "0x85c2794a580da137e24ccc823b45ae5cea99371ae23ee13860fcc6935f8305b0", + "s": "0x41de7fa4121dab284af4453d30928241208bafa90cdb701fe9bc7054759fe3cd", + "yParity": "0x0", + "hash": "0x8c9b68e8947ace33028dba167354fde369ed7bbe34911b772d09b3c64b861515", + "type": "0x2" + }, + "impersonated_sender": null + } + ], + "ommers": [] + } + ], + "transactions": [ + { + "info": { + "transaction_hash": "0xf8d5fb22350f52ae8c30cd7f6969eb73de849c8dc010f4215d4c5c24824fe2b3", + "transaction_index": 0, + "from": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", + "to": "0x70997970c51812dc3a010c7d01b50e0d17dc79c8", + "contract_address": null, + "traces": [ + { + "parent": null, + "children": [], + "idx": 0, + "trace": { + "depth": 0, + "success": true, + "caller": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", + "address": "0x70997970c51812dc3a010c7d01b50e0d17dc79c8", + "maybe_precompile": null, + "selfdestruct_refund_target": null, + "selfdestruct_transferred_value": null, + "kind": "CALL", + "value": "0x0", + "data": "0x", + "output": "0x", + "gas_used": 0, + "gas_limit": 1, + "status": "Stop", + "steps": [], + "decoded": { + "label": null, + "return_data": null, + "call_data": null + }, + "gas_refund_counter": 0 + }, + "logs": [], + "ordering": [] + } + ], + "exit": "Stop", + "out": "0x", + "nonce": 0, + "gas_used": 21000 + }, + "receipt": { + "type": "0x2", + "status": "0x1", + "cumulativeGasUsed": "0x5208", + "logs": [], + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + "block_hash": "0x0d575f9ca968cd483549172245483a12343afc3cabef80f0fa39855b10b98c70", + "block_number": 1 + }, + { + "info": { + "transaction_hash": "0x8c9b68e8947ace33028dba167354fde369ed7bbe34911b772d09b3c64b861515", + "transaction_index": 0, + "from": "0x70997970c51812dc3a010c7d01b50e0d17dc79c8", + "to": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", + "contract_address": null, + "traces": [ + { + "parent": null, + "children": [], + "idx": 0, + "trace": { + "depth": 0, + "success": true, + "caller": "0x70997970c51812dc3a010c7d01b50e0d17dc79c8", + "address": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", + "maybe_precompile": null, + "selfdestruct_refund_target": null, + "selfdestruct_transferred_value": null, + "kind": "CALL", + "value": "0x0", + "data": "0x", + "output": "0x", + "gas_used": 0, + "gas_limit": 1, + "status": "Stop", + "steps": [], + "decoded": { + "label": null, + "return_data": null, + "call_data": null + }, + "gas_refund_counter": 0 + }, + "logs": [], + "ordering": [] + } + ], + "exit": "Stop", + "out": "0x", + "nonce": 0, + "gas_used": 21000 + }, + "receipt": { + "type": "0x2", + "status": "0x1", + "cumulativeGasUsed": "0x5208", + "logs": [], + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + "block_hash": "0x1f435a603c1bf6d544a90156b572b96d7a1730028422d800839bae78bb3506d0", + "block_number": 2 + } + ] +} diff --git a/crates/anvil/tests/it/abi.rs b/crates/anvil/tests/it/abi.rs index 9bd3b84e9d934..56938805fab23 100644 --- a/crates/anvil/tests/it/abi.rs +++ b/crates/anvil/tests/it/abi.rs @@ -1,6 +1,15 @@ -//! commonly used sol generated types +//! Shared contract bindings and bytecode for integration tests. + +use alloy_primitives::{Bytes, bytes}; use alloy_sol_types::sol; +/// Counter runtime that increments storage slot zero on every call. +pub(crate) const COUNTER_RUNTIME_CODE: Bytes = bytes!("60005460010160005500"); + +/// Creation code that sets slot zero to one and installs [`COUNTER_RUNTIME_CODE`]. +pub(crate) const COUNTER_INIT_CODE: Bytes = + bytes!("6001600055600a6011600039600a6000f360005460010160005500"); + sol!( #[sol(rpc)] Greeter, diff --git a/crates/anvil/tests/it/anvil.rs b/crates/anvil/tests/it/anvil.rs index 7450d26f82867..83a6cba933d5d 100644 --- a/crates/anvil/tests/it/anvil.rs +++ b/crates/anvil/tests/it/anvil.rs @@ -2,10 +2,47 @@ use alloy_consensus::EMPTY_ROOT_HASH; use alloy_eips::BlockNumberOrTag; -use alloy_hardforks::EthereumHardfork; -use alloy_primitives::Address; +use alloy_network::{ReceiptResponse, TransactionBuilder}; +use alloy_primitives::{Address, B256, U256, bytes, hex}; use alloy_provider::Provider; +use alloy_rpc_types::{BlockId, TransactionRequest}; +use alloy_sol_types::SolCall; use anvil::{NodeConfig, spawn}; +use foundry_evm::{core::precompiles::P256_VERIFY, hardfork::EthereumHardfork}; + +const BSC_MAINNET_CHAIN_ID: u64 = 56; +const BSC_MAINNET_HABER_TIMESTAMP: u64 = 1_718_863_500; +const P256_INPUT: [u8; 160] = hex!( + "4cee90eb86eaa050036147a12d49004b6b9c72bd725d39d4785011fe190f0b4d\ + a73bd4903f0ce3b639bbbf6e8e80d16931ff4bcf5993d58468e8fb19086e8cac\ + 36dbcd03009df8c59286b162af3bd7fcc0450c9aa81be5d10d312af6c66b1d604\ + aebd3099c618202fcfe16ae7770b0c49ab5eadf74b754204a3bb6060e44eff376\ + 18b065f9832de4ca6ca971a7a1adc826d0f7c00181a5fb2ddf79ae00b4e10e" +); +// Copies calldata into memory, calls P256, and stores its returned word in slot zero. +const P256_CALLER_CODE: [u8; 26] = hex!("366000600037602060003660006101005afa5060005160005500"); + +#[tokio::test(flavor = "multi_thread")] +async fn dropping_handle_releases_http_listener() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let addr = *handle.socket_address(); + + drop(handle); + + tokio::time::timeout(std::time::Duration::from_secs(1), async { + loop { + match tokio::net::TcpListener::bind(addr).await { + Ok(listener) => { + drop(listener); + break; + } + Err(_) => tokio::time::sleep(std::time::Duration::from_millis(10)).await, + } + } + }) + .await + .expect("HTTP listener should shut down after NodeHandle is dropped"); +} #[tokio::test(flavor = "multi_thread")] async fn test_can_change_mining_mode() { @@ -72,6 +109,117 @@ async fn test_can_set_genesis_timestamp() { ); } +#[tokio::test(flavor = "multi_thread")] +async fn bsc_haber_p256_is_available_for_calls_and_mining() { + let (api, handle) = spawn( + NodeConfig::test() + .with_chain_id(Some(BSC_MAINNET_CHAIN_ID)) + .with_hardfork(Some(EthereumHardfork::Cancun.into())) + .with_genesis_timestamp(Some(BSC_MAINNET_HABER_TIMESTAMP)), + ) + .await; + let provider = handle.http_provider(); + + let tx = TransactionRequest::default().with_to(P256_VERIFY).with_input(P256_INPUT); + assert_eq!( + api.config().unwrap().current.precompiles.values().find(|&&address| address == P256_VERIFY), + Some(&P256_VERIFY) + ); + for block in [BlockId::latest(), BlockId::number(0_u64), BlockId::pending()] { + let output = provider.call(tx.clone().into()).block(block).await.unwrap(); + assert_eq!(output.as_ref(), B256::with_last_byte(1).as_slice()); + } + + let caller = Address::random(); + api.anvil_set_code(caller, P256_CALLER_CODE.into()).await.unwrap(); + let from = handle.dev_accounts().next().unwrap(); + let receipt = provider + .send_transaction( + TransactionRequest::default() + .with_from(from) + .with_to(caller) + .with_input(P256_INPUT) + .with_gas_limit(100_000) + .into(), + ) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert!(receipt.status()); + assert_eq!(provider.get_storage_at(caller, U256::ZERO).await.unwrap(), U256::ONE); +} + +#[tokio::test(flavor = "multi_thread")] +async fn bsc_fork_execution_chain_override_preserves_p256() { + let (_origin_api, origin_handle) = spawn( + NodeConfig::test() + .with_chain_id(Some(BSC_MAINNET_CHAIN_ID)) + .with_hardfork(Some(EthereumHardfork::Cancun.into())) + .with_genesis_timestamp(Some(BSC_MAINNET_HABER_TIMESTAMP)), + ) + .await; + let (api, handle) = spawn( + NodeConfig::test() + .with_chain_id(Some(1u64)) + .with_no_storage_caching(true) + .with_eth_rpc_url(Some(origin_handle.http_endpoint())), + ) + .await; + let provider = handle.http_provider(); + + assert_eq!(provider.get_chain_id().await.unwrap(), 1); + assert!( + api.config().unwrap().current.precompiles.values().any(|&address| address == P256_VERIFY) + ); + let output = provider + .call(TransactionRequest::default().with_to(P256_VERIFY).with_input(P256_INPUT).into()) + .await + .unwrap(); + assert_eq!(output.as_ref(), B256::with_last_byte(1).as_slice()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn bsc_default_timestamp_enables_p256_immediately() { + let (api, handle) = spawn( + NodeConfig::test() + .with_chain_id(Some(BSC_MAINNET_CHAIN_ID)) + .with_hardfork(Some(EthereumHardfork::Cancun.into())), + ) + .await; + let provider = handle.http_provider(); + + assert!( + api.config().unwrap().current.precompiles.values().any(|&address| address == P256_VERIFY) + ); + let output = provider + .call(TransactionRequest::default().with_to(P256_VERIFY).with_input(P256_INPUT).into()) + .await + .unwrap(); + assert_eq!(output.as_ref(), B256::with_last_byte(1).as_slice()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn bsc_pre_haber_eth_config_omits_p256() { + let (api, _handle) = spawn( + NodeConfig::test() + .with_chain_id(Some(BSC_MAINNET_CHAIN_ID)) + .with_hardfork(Some(EthereumHardfork::Osaka.into())) + .with_genesis_timestamp(Some(BSC_MAINNET_HABER_TIMESTAMP - 1)), + ) + .await; + + assert!( + !api.config() + .unwrap() + .current + .precompiles + .values() + .any(|&address| { address == P256_VERIFY }) + ); +} + #[tokio::test(flavor = "multi_thread")] async fn test_can_use_default_genesis_timestamp() { let (_api, handle) = spawn(NodeConfig::test()).await; @@ -85,7 +233,7 @@ async fn test_can_handle_large_timestamp() { let (api, _handle) = spawn(NodeConfig::test()).await; let num = 317071597274; api.evm_set_next_block_timestamp(num).unwrap(); - api.mine_one().await; + api.mine_one().await.unwrap(); let block = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); assert_eq!(block.header.timestamp, num); @@ -95,7 +243,7 @@ async fn test_can_handle_large_timestamp() { async fn test_shanghai_fields() { let (api, _handle) = spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Shanghai.into()))).await; - api.mine_one().await; + api.mine_one().await.unwrap(); let block = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); assert_eq!(block.header.withdrawals_root, Some(EMPTY_ROOT_HASH)); @@ -108,7 +256,7 @@ async fn test_shanghai_fields() { async fn test_cancun_fields() { let (api, _handle) = spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Cancun.into()))).await; - api.mine_one().await; + api.mine_one().await.unwrap(); let block = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); assert_eq!(block.header.withdrawals_root, Some(EMPTY_ROOT_HASH)); @@ -135,3 +283,117 @@ async fn test_can_use_default_genesis_block_number() { assert_eq!(0, provider.get_block(0.into()).await.unwrap().unwrap().header.number); } + +/// `earliest`, `safe` and `finalized` resolve within the chain when it starts at a non-zero +/// genesis number. +#[tokio::test(flavor = "multi_thread")] +async fn test_block_tags_respect_genesis_block_number() { + let genesis_number = 1000u64; + let (api, handle) = + spawn(NodeConfig::test().with_genesis_block_number(Some(genesis_number))).await; + let provider = handle.http_provider(); + let account = handle.dev_accounts().next().unwrap(); + + api.anvil_mine(Some(U256::from(5)), None).await.unwrap(); + assert_eq!(provider.get_block_number().await.unwrap(), genesis_number + 5); + + let latest = provider.get_balance(account).await.unwrap(); + for tag in [BlockNumberOrTag::Earliest, BlockNumberOrTag::Safe, BlockNumberOrTag::Finalized] { + let balance = provider.get_balance(account).block_id(tag.into()).await.unwrap(); + assert_eq!(balance, latest, "{tag} should resolve to a block of this chain"); + let block = provider.get_block(tag.into()).await.unwrap().unwrap(); + assert_eq!(block.header.number, genesis_number, "{tag} should resolve to genesis"); + } +} + +/// Verify that genesis block number affects both RPC and EVM execution layer. +#[tokio::test(flavor = "multi_thread")] +async fn test_number_opcode_reflects_genesis_block_number() { + let genesis_number: u64 = 4242; + let (api, handle) = + spawn(NodeConfig::test().with_genesis_block_number(Some(genesis_number))).await; + let provider = handle.http_provider(); + + // RPC layer should return configured genesis number + let bn = provider.get_block_number().await.unwrap(); + assert_eq!(bn, genesis_number); + + // Deploy bytecode that returns block.number + // 0x43 (NUMBER) 0x5f (PUSH0) 0x52 (MSTORE) 0x60 0x20 (PUSH1 0x20) 0x5f (PUSH0) 0xf3 (RETURN) + let target = Address::random(); + api.anvil_set_code(target, bytes!("435f5260205ff3")).await.unwrap(); + + // EVM execution should reflect genesis number (+ 1 for pending block) + let tx = alloy_rpc_types::TransactionRequest::default().with_to(target); + let out = provider.call(tx.into()).await.unwrap(); + let returned = U256::from_be_slice(out.as_ref()); + assert_eq!(returned, U256::from(genesis_number + 1)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_anvil_recover_signature() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + alloy_sol_types::sol! { + #[sol(rpc)] + contract TestRecover { + function testRecover(bytes32 hash, uint8 v, bytes32 r, bytes32 s, address expected) external pure { + address recovered = ecrecover(hash, v, r, s); + require(recovered == expected, "ecrecover failed: address mismatch"); + } + } + } + let bytecode = hex::decode( + "0x60808060405234601557610125908161001a8239f35b5f80fdfe60808060405260043610156011575f80fd5b5f3560e01c63bff0b743146023575f80fd5b3460eb5760a036600319011260eb5760243560ff811680910360eb576084356001600160a01b038116929083900360eb5760805f916020936004358252848201526044356040820152606435606082015282805260015afa1560e0575f516001600160a01b031603609057005b60405162461bcd60e51b815260206004820152602260248201527f65637265636f766572206661696c65643a2061646472657373206d69736d61746044820152610c6d60f31b6064820152608490fd5b6040513d5f823e3d90fd5b5f80fdfea264697066735822122006368b42bca31c97f2c409a1cc5186dc899d4255ecc28db7bbb0ad285dc82ae464736f6c634300081c0033", + ).unwrap(); + + let tx = TransactionRequest::default().with_deploy_code(bytecode); + let receipt = provider.send_transaction(tx.into()).await.unwrap().get_receipt().await.unwrap(); + let contract_address = receipt.contract_address().unwrap(); + let contract = TestRecover::new(contract_address, &provider); + + let sig = alloy_primitives::hex::decode("11".repeat(65)).unwrap(); + let r = B256::from_slice(&sig[0..32]); + let s = B256::from_slice(&sig[32..64]); + let v = sig[64]; + let fake_hash = B256::random(); + let expected = alloy_primitives::address!("0x1234567890123456789012345678901234567890"); + api.anvil_impersonate_signature(sig.clone().into(), expected).await.unwrap(); + let result = contract.testRecover(fake_hash, v, r, s, expected).call().await; + assert!(result.is_ok(), "ecrecover failed: {:?}", result.err()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fake_signature_transaction() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + alloy_sol_types::sol! { + #[sol(rpc)] + contract TestRecover { + function testRecover(bytes32 hash, uint8 v, bytes32 r, bytes32 s, address expected) external pure { + address recovered = ecrecover(hash, v, r, s); + require(recovered == expected, "ecrecover failed: address mismatch"); + } + } + } + let bytecode = hex::decode( + "0x60808060405234601557610125908161001a8239f35b5f80fdfe60808060405260043610156011575f80fd5b5f3560e01c63bff0b743146023575f80fd5b3460eb5760a036600319011260eb5760243560ff811680910360eb576084356001600160a01b038116929083900360eb5760805f916020936004358252848201526044356040820152606435606082015282805260015afa1560e0575f516001600160a01b031603609057005b60405162461bcd60e51b815260206004820152602260248201527f65637265636f766572206661696c65643a2061646472657373206d69736d61746044820152610c6d60f31b6064820152608490fd5b6040513d5f823e3d90fd5b5f80fdfea264697066735822122006368b42bca31c97f2c409a1cc5186dc899d4255ecc28db7bbb0ad285dc82ae464736f6c634300081c0033", + ).unwrap(); + + let tx = TransactionRequest::default().with_deploy_code(bytecode); + let _receipt = provider.send_transaction(tx.into()).await.unwrap().get_receipt().await.unwrap(); + + let sig = alloy_primitives::hex::decode("11".repeat(65)).unwrap(); + let r = B256::from_slice(&sig[0..32]); + let s = B256::from_slice(&sig[32..64]); + let v = sig[64]; + let fake_hash = B256::random(); + let expected = alloy_primitives::address!("0x1234567890123456789012345678901234567890"); + api.anvil_impersonate_signature(sig.clone().into(), expected).await.unwrap(); + let calldata = TestRecover::testRecoverCall { hash: fake_hash, v, r, s, expected }.abi_encode(); + let tx = TransactionRequest::default().with_input(calldata); + let pending = provider.send_transaction(tx.into()).await.unwrap(); + let result = pending.get_receipt().await; + + assert!(result.is_ok(), "ecrecover failed: {:?}", result.err()); +} diff --git a/crates/anvil/tests/it/anvil_api.rs b/crates/anvil/tests/it/anvil_api.rs index 77089e4bdb080..d482c6835fee8 100644 --- a/crates/anvil/tests/it/anvil_api.rs +++ b/crates/anvil/tests/it/anvil_api.rs @@ -5,39 +5,137 @@ use crate::{ fork::fork_config, utils::http_provider_with_signer, }; +use alloy_chains::NamedChain; use alloy_consensus::{SignableTransaction, TxEip1559}; -use alloy_hardforks::EthereumHardfork; -use alloy_network::{EthereumWallet, TransactionBuilder, TxSignerSync}; -use alloy_primitives::{Address, Bytes, TxKind, U256, address, fixed_bytes, utils::Unit}; +use alloy_eips::{eip2718::Decodable2718, eip4788::BEACON_ROOTS_ADDRESS}; +use alloy_network::{EthereumWallet, ReceiptResponse, TransactionBuilder, TxSignerSync}; +use alloy_primitives::{Address, B256, Bytes, TxKind, U256, address, b256, fixed_bytes, keccak256}; use alloy_provider::{Provider, ext::TxPoolApi}; use alloy_rpc_types::{ BlockId, BlockNumberOrTag, TransactionRequest, anvil::{ ForkedNetwork, Forking, Metadata, MineOptions, NodeEnvironment, NodeForkConfig, NodeInfo, }, + trace::parity::TraceType, }; use alloy_serde::WithOtherFields; use anvil::{ NodeConfig, - eth::{ - api::CLIENT_VERSION, - backend::mem::{EXECUTOR, P256_DELEGATION_CONTRACT, P256_DELEGATION_RUNTIME_CODE}, - }, + eth::{api::CLIENT_VERSION, fees::INITIAL_BASE_FEE, pool::transactions::PoolTransaction}, spawn, }; use anvil_core::{ - eth::{ - EthRequest, - wallet::{Capabilities, DelegationCapability, WalletCapabilities}, - }, + eth::{EthRequest, transaction::PendingTransaction}, types::{ReorgOptions, TransactionData}, }; -use revm::primitives::hardfork::SpecId; +use foundry_common::{ + provider::redact_url, + version::{COMMIT_SHA, SEMVER_VERSION}, +}; +use foundry_evm::hardfork::EthereumHardfork; +use foundry_evm_networks::NetworkConfigs; +use foundry_primitives::FoundryTxEnvelope; +use futures::{FutureExt, StreamExt}; +use tempo_hardfork::TempoHardfork; + use std::{ str::FromStr, + sync::Arc, time::{Duration, SystemTime}, }; +#[tokio::test(flavor = "multi_thread")] +async fn fork_resets_allow_celo_as_a_non_monad_source() { + let (_ethereum_api, ethereum_origin) = spawn(NodeConfig::test()).await; + let (_celo_api, celo_origin) = spawn( + NodeConfig::test() + .with_chain_id(Some(NamedChain::Celo as u64)) + .with_networks(NetworkConfigs::with_celo()), + ) + .await; + + let (ethereum_fork, _) = spawn( + NodeConfig::test() + .with_no_storage_caching(true) + .with_eth_rpc_url(Some(ethereum_origin.http_endpoint())) + .with_fork_block_number(Some(0u64)), + ) + .await; + ethereum_fork + .anvil_reset(Some(Forking { + json_rpc_url: Some(celo_origin.http_endpoint()), + block_number: Some(0), + })) + .await + .unwrap(); + let node_info = ethereum_fork.anvil_node_info().await.unwrap(); + assert_eq!(node_info.network.as_deref(), Some("ethereum")); + assert_eq!(node_info.fork_config.fork_url, Some(redact_url(&celo_origin.http_endpoint()))); + + let (celo_fork, _) = spawn( + NodeConfig::test() + .with_no_storage_caching(true) + .with_eth_rpc_url(Some(celo_origin.http_endpoint())) + .with_fork_block_number(Some(0u64)), + ) + .await; + celo_fork + .anvil_reset(Some(Forking { + json_rpc_url: Some(ethereum_origin.http_endpoint()), + block_number: Some(0), + })) + .await + .unwrap(); + let node_info = celo_fork.anvil_node_info().await.unwrap(); + assert_eq!(node_info.network.as_deref(), Some("celo")); + assert_eq!(node_info.fork_config.fork_url, Some(redact_url(ðereum_origin.http_endpoint()))); +} + +#[tokio::test(flavor = "multi_thread")] +async fn nested_custom_chain_celo_fork_preserves_execution_profile() { + let custom_chain_id = 98_765_432u64; + let (origin_api, origin) = spawn( + NodeConfig::test() + .with_chain_id(Some(custom_chain_id)) + .with_networks(NetworkConfigs::with_celo()), + ) + .await; + assert_eq!(origin_api.anvil_node_info().await.unwrap().network.as_deref(), Some("celo")); + + let (nested_api, _) = spawn( + NodeConfig::test() + .with_no_storage_caching(true) + .with_eth_rpc_url(Some(origin.http_endpoint())) + .with_fork_block_number(Some(0u64)), + ) + .await; + + assert_eq!(nested_api.anvil_node_info().await.unwrap().network.as_deref(), Some("celo")); + assert_eq!( + nested_api.config().unwrap().current.precompiles.get("celo transfer"), + Some(&Address::with_last_byte(0xfd)) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn fork_fee_rules_follow_exact_endpoint_hardfork() { + let (_origin_api, origin_handle) = + spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Berlin.into()))).await; + let (fork_api, _) = spawn( + NodeConfig::test() + .with_no_storage_caching(true) + .with_base_fee(Some(INITIAL_BASE_FEE)) + .with_eth_rpc_url(Some(origin_handle.http_endpoint())) + .with_fork_block_number(Some(0u64)), + ) + .await; + + let info = fork_api.anvil_node_info().await.unwrap(); + assert_eq!(info.hard_fork, "Berlin"); + assert_eq!(info.environment.base_fee, 0); + assert_eq!(fork_api.base_fee().unwrap(), None); +} + #[tokio::test(flavor = "multi_thread")] async fn can_set_gas_price() { let (api, handle) = @@ -57,7 +155,7 @@ async fn can_set_block_gas_limit() { let block_gas_limit = U256::from(1337); assert!(api.evm_set_block_gas_limit(block_gas_limit).unwrap()); // Mine a new block, and check the new block gas limit - api.mine_one().await; + api.mine_one().await.unwrap(); let latest_block = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); assert_eq!(block_gas_limit.to::(), latest_block.header.gas_limit); } @@ -293,6 +391,35 @@ async fn can_mine_manually() { } } +#[tokio::test(flavor = "multi_thread")] +async fn get_last_block_wall_time_updates_after_mining() { + let before_spawn = + SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_millis() as u64; + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + let genesis_wall_time: u64 = + provider.raw_request("anvil_getLastBlockWallTime".into(), ()).await.unwrap(); + let after_spawn = + SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_millis() as u64; + assert!((before_spawn..=after_spawn).contains(&genesis_wall_time)); + + api.evm_set_time(api.backend.time().current_call_timestamp() + 60).unwrap(); + let unchanged_wall_time: u64 = + provider.raw_request("anvil_getLastBlockWallTime".into(), ()).await.unwrap(); + assert_eq!(unchanged_wall_time, genesis_wall_time); + + tokio::time::sleep(Duration::from_millis(2)).await; + let before_mining = + SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_millis() as u64; + api.mine_one().await.unwrap(); + + let block_wall_time: u64 = + provider.raw_request("anvil_getLastBlockWallTime".into(), ()).await.unwrap(); + let after_mining = + SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap().as_millis() as u64; + assert!((before_mining..=after_mining).contains(&block_wall_time)); +} + #[tokio::test(flavor = "multi_thread")] async fn test_set_next_timestamp() { let (api, handle) = spawn(NodeConfig::test()).await; @@ -431,11 +558,7 @@ async fn test_can_set_storage_bsc_fork() { let busd_contract = BUSD::new(busd_addr, &provider); - let balance = busd_contract - .balanceOf(address!("0x0000000000000000000000000000000000000000")) - .call() - .await - .unwrap(); + let balance = busd_contract.balanceOf(Address::ZERO).call().await.unwrap(); assert_eq!(balance, U256::from(12345u64)); } @@ -449,13 +572,13 @@ async fn can_get_node_info() { let block_number = provider.get_block_number().await.unwrap(); let block = provider.get_block(BlockId::from(block_number)).await.unwrap().unwrap(); - let hard_fork: &str = SpecId::PRAGUE.into(); + let hard_fork = NodeConfig::test().get_hardfork().name(); let expected_node_info = NodeInfo { current_block_number: 0_u64, - current_block_timestamp: 1, + current_block_timestamp: block.header.timestamp, current_block_hash: block.header.hash, - hard_fork: hard_fork.to_string(), + hard_fork, transaction_order: "fees".to_owned(), environment: NodeEnvironment { base_fee: U256::from_str("0x3b9aca00").unwrap().to(), @@ -468,6 +591,7 @@ async fn can_get_node_info() { fork_block_number: None, fork_retry_backoff: None, }, + network: Some("ethereum".to_string()), }; assert_eq!(node_info, expected_node_info); @@ -490,6 +614,8 @@ async fn can_get_metadata() { latest_block_number: block_number, chain_id, client_version: CLIENT_VERSION.to_string(), + client_semver: Some(SEMVER_VERSION.to_string()), + client_commit_sha: Some(COMMIT_SHA.to_string()), instance_id: api.instance_id(), forked_network: None, snapshots: Default::default(), @@ -515,6 +641,8 @@ async fn can_get_metadata_on_fork() { latest_block_number: block_number, chain_id, client_version: CLIENT_VERSION.to_string(), + client_semver: Some(SEMVER_VERSION.to_string()), + client_commit_sha: Some(COMMIT_SHA.to_string()), instance_id: api.instance_id(), forked_network: Some(ForkedNetwork { chain_id, @@ -585,6 +713,18 @@ async fn test_set_chain_id() { let chain_id = provider.get_chain_id().await.unwrap(); assert_eq!(chain_id, 1234); + + let from = handle.dev_accounts().next().unwrap(); + let receipt = provider + .send_transaction(WithOtherFields::new( + TransactionRequest::default().from(from).to(Address::random()).value(U256::ONE), + )) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert!(receipt.status()); } // @@ -593,20 +733,283 @@ async fn test_fork_revert_next_block_timestamp() { let (api, _handle) = spawn(fork_config()).await; // Mine a new block, and check the new block gas limit - api.mine_one().await; + api.mine_one().await.unwrap(); let latest_block = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); let state_snapshot = api.evm_snapshot().await.unwrap(); - api.mine_one().await; + api.mine_one().await.unwrap(); api.evm_revert(state_snapshot).await.unwrap(); let block = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); assert_eq!(block, latest_block); - api.mine_one().await; + api.mine_one().await.unwrap(); let block = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); assert!(block.header.timestamp >= latest_block.header.timestamp); } +#[tokio::test(flavor = "multi_thread")] +async fn test_revert_invalidates_newer_snapshots() { + let (api, _handle) = spawn(NodeConfig::test()).await; + let initial_block = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + + let first_snapshot = api.evm_snapshot().await.unwrap(); + api.mine_one().await.unwrap(); + let second_snapshot = api.evm_snapshot().await.unwrap(); + + assert!(api.evm_revert(first_snapshot).await.unwrap()); + assert!(!api.evm_revert(second_snapshot).await.unwrap()); + + let latest_block = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert_eq!(latest_block, initial_block); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_revert_restores_time_offset() { + let (api, _handle) = spawn(NodeConfig::test()).await; + let initial = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + + api.evm_increase_time(U256::from(100)).await.unwrap(); + let snapshot = api.evm_snapshot().await.unwrap(); + api.evm_increase_time(U256::from(200)).await.unwrap(); + assert!(api.evm_revert(snapshot).await.unwrap()); + api.mine_one().await.unwrap(); + + let block = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert!(block.header.timestamp >= initial.header.timestamp + 100); + assert!(block.header.timestamp < initial.header.timestamp + 200); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_revert_restores_next_block_timestamp() { + let (api, _handle) = spawn(NodeConfig::test()).await; + let initial = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + let next_timestamp = initial.header.timestamp + 1_000; + + api.evm_set_next_block_timestamp(next_timestamp).unwrap(); + let snapshot = api.evm_snapshot().await.unwrap(); + api.mine_one().await.unwrap(); + assert!(api.evm_revert(snapshot).await.unwrap()); + api.mine_one().await.unwrap(); + + let block = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert_eq!(block.header.timestamp, next_timestamp); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_revert_restores_next_block_base_fee() { + let (api, _handle) = spawn(NodeConfig::test()).await; + let base_fee = api.base_fee().unwrap(); + let state_snapshot = api.evm_snapshot().await.unwrap(); + + api.mine_one().await.unwrap(); + let first_block = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert_ne!(api.base_fee().unwrap(), base_fee); + api.evm_revert(state_snapshot).await.unwrap(); + assert_eq!(api.base_fee().unwrap(), base_fee); + + api.mine_one().await.unwrap(); + let remined_block = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert_eq!(remined_block.header.base_fee_per_gas, first_block.header.base_fee_per_gas); +} + +// Tests that `anvil_setNextBlockPrevRandao` overrides the `prevrandao` (block header `mixHash`) of +// the next mined block, and that the override is one-shot: subsequent blocks fall back to anvil's +// default per-block `prevrandao` derivation. +#[tokio::test(flavor = "multi_thread")] +async fn test_set_next_block_prevrandao() { + let (api, _handle) = spawn(NodeConfig::test()).await; + + let prevrandao = b256!("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"); + + api.anvil_set_next_block_prevrandao(prevrandao).await.unwrap(); + api.mine_one().await.unwrap(); + + let block = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert_eq!(block.header.mix_hash, Some(prevrandao)); + + // the override only applies to a single block: the next block uses the default derivation + api.mine_one().await.unwrap(); + let next = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert_ne!(next.header.mix_hash, Some(prevrandao)); +} + +// Tests that the `prevrandao` set via `anvil_setNextBlockPrevRandao` is observed by the EVM: the +// `PREVRANDAO` opcode (exposed through `Multicall::getCurrentBlockDifficulty`) returns the manually +// set value for the overridden block. +#[tokio::test(flavor = "multi_thread")] +async fn test_set_next_block_prevrandao_evm() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + // deploying the contract auto-mines a block + let multicall = Multicall::deploy(&provider).await.unwrap(); + + let prevrandao = b256!("0x00000000000000000000000000000000000000000000000000000000deadbeef"); + api.anvil_set_next_block_prevrandao(prevrandao).await.unwrap(); + api.mine_one().await.unwrap(); + + // post-merge the `PREVRANDAO` opcode (0x44) returns the current block's `prevrandao` + let difficulty = multicall.getCurrentBlockDifficulty().call().await.unwrap(); + assert_eq!(difficulty, Into::::into(prevrandao)); + + let block = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert_eq!(block.header.mix_hash, Some(prevrandao)); +} + +// Tests that a `prevrandao` override set via `anvil_setNextBlockPrevRandao` but not yet mined is +// dropped by `anvil_reset`, so the pending value cannot leak into a block mined after the reset. +#[tokio::test(flavor = "multi_thread")] +async fn test_set_next_block_prevrandao_cleared_on_reset() { + let (api, _handle) = spawn(NodeConfig::test()).await; + + let prevrandao = b256!("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"); + + api.anvil_set_next_block_prevrandao(prevrandao).await.unwrap(); + // resetting must drop the pending override before it is consumed by a block + api.anvil_reset(None).await.unwrap(); + api.mine_one().await.unwrap(); + + let block = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert_ne!(block.header.mix_hash, Some(prevrandao)); +} + +// Tests that a `prevrandao` override set via `anvil_setNextBlockPrevRandao` but not yet mined is +// dropped by `evm_revert`, so the pending value cannot leak into a block mined after the revert. +#[tokio::test(flavor = "multi_thread")] +async fn test_set_next_block_prevrandao_cleared_on_revert() { + let (api, _handle) = spawn(NodeConfig::test()).await; + + let prevrandao = b256!("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"); + + let state_snapshot = api.evm_snapshot().await.unwrap(); + api.anvil_set_next_block_prevrandao(prevrandao).await.unwrap(); + // reverting must drop the pending override before it is consumed by a block + api.evm_revert(state_snapshot).await.unwrap(); + api.mine_one().await.unwrap(); + + let block = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert_ne!(block.header.mix_hash, Some(prevrandao)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_set_next_block_prevrandao_restored_on_revert() { + let (api, _handle) = spawn(NodeConfig::test()).await; + let prevrandao = b256!("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"); + + api.anvil_set_next_block_prevrandao(prevrandao).await.unwrap(); + let state_snapshot = api.evm_snapshot().await.unwrap(); + api.mine_one().await.unwrap(); + assert!(api.evm_revert(state_snapshot).await.unwrap()); + api.mine_one().await.unwrap(); + + let block = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert_eq!(block.header.mix_hash, Some(prevrandao)); +} + +// Tests that `anvil_setNextBlockParentBeaconBlockRoot` sets the parent beacon block root of the +// next mined block only, and that the EIP-4788 contract stores it for that block's timestamp. +#[tokio::test(flavor = "multi_thread")] +async fn test_set_next_block_parent_beacon_block_root() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + let root = b256!("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"); + api.anvil_set_next_block_parent_beacon_block_root(root).await.unwrap(); + api.mine_one().await.unwrap(); + + let block = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert_eq!(block.header.parent_beacon_block_root, Some(root)); + let stored = provider + .call(WithOtherFields::new( + TransactionRequest::default() + .to(BEACON_ROOTS_ADDRESS) + .input(U256::from(block.header.timestamp).to_be_bytes::<32>().to_vec().into()), + )) + .block(BlockId::latest()) + .await + .unwrap(); + assert_eq!(stored, Bytes::from(root.0)); + + api.mine_one().await.unwrap(); + let next = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert_eq!(next.header.parent_beacon_block_root, Some(B256::ZERO)); +} + +// Tests that the pending block sees a parent beacon block root override without consuming it, so +// pending calls observe the root the next mined block will use. +#[tokio::test(flavor = "multi_thread")] +async fn test_set_next_block_parent_beacon_block_root_pending() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + let root = b256!("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"); + api.anvil_set_next_block_parent_beacon_block_root(root).await.unwrap(); + // Pin the next timestamp: the pending block and the pending call each read it, and the + // EIP-4788 contract stores the root under it. + let latest = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + api.evm_set_next_block_timestamp(latest.header.timestamp + 100).unwrap(); + + let pending = api.block_by_number(BlockNumberOrTag::Pending).await.unwrap().unwrap(); + assert_eq!(pending.header.parent_beacon_block_root, Some(root)); + let stored = provider + .call(WithOtherFields::new( + TransactionRequest::default() + .to(BEACON_ROOTS_ADDRESS) + .input(U256::from(pending.header.timestamp).to_be_bytes::<32>().to_vec().into()), + )) + .block(BlockId::pending()) + .await + .unwrap(); + assert_eq!(stored, Bytes::from(root.0)); + + api.mine_one().await.unwrap(); + let block = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert_eq!(block.header.parent_beacon_block_root, Some(root)); +} + +// Tests that a parent beacon block root override is dropped by `evm_revert` when set after the +// snapshot and by `anvil_reset`, and restored by `evm_revert` when set before the snapshot. +#[tokio::test(flavor = "multi_thread")] +async fn test_set_next_block_parent_beacon_block_root_reset_and_revert() { + let (api, _handle) = spawn(NodeConfig::test()).await; + let root = b256!("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"); + let mined_root = async || { + let block = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + block.header.parent_beacon_block_root + }; + + let snapshot = api.evm_snapshot().await.unwrap(); + api.anvil_set_next_block_parent_beacon_block_root(root).await.unwrap(); + assert!(api.evm_revert(snapshot).await.unwrap()); + api.mine_one().await.unwrap(); + assert_eq!(mined_root().await, Some(B256::ZERO)); + + api.anvil_set_next_block_parent_beacon_block_root(root).await.unwrap(); + let snapshot = api.evm_snapshot().await.unwrap(); + api.mine_one().await.unwrap(); + assert!(api.evm_revert(snapshot).await.unwrap()); + api.mine_one().await.unwrap(); + assert_eq!(mined_root().await, Some(root)); + + api.anvil_set_next_block_parent_beacon_block_root(root).await.unwrap(); + api.anvil_reset(None).await.unwrap(); + api.mine_one().await.unwrap(); + assert_eq!(mined_root().await, Some(B256::ZERO)); +} + +// Tests that blocks before Cancun have no parent beacon block root to override. +#[tokio::test(flavor = "multi_thread")] +async fn test_set_next_block_parent_beacon_block_root_pre_cancun() { + let config = NodeConfig::test().with_hardfork(Some(EthereumHardfork::Shanghai.into())); + let (api, _handle) = spawn(config).await; + let root = b256!("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"); + api.anvil_set_next_block_parent_beacon_block_root(root).await.unwrap(); + api.mine_one().await.unwrap(); + + let block = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert_eq!(block.header.parent_beacon_block_root, None); +} + // test that after a snapshot revert, the env block is reset // to its correct value (block number, etc.) #[tokio::test(flavor = "multi_thread")] @@ -615,11 +1018,11 @@ async fn test_fork_revert_call_latest_block_timestamp() { let provider = handle.http_provider(); // Mine a new block, and check the new block gas limit - api.mine_one().await; + api.mine_one().await.unwrap(); let latest_block = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); let state_snapshot = api.evm_snapshot().await.unwrap(); - api.mine_one().await; + api.mine_one().await.unwrap(); api.evm_revert(state_snapshot).await.unwrap(); let multicall_contract = @@ -629,7 +1032,7 @@ async fn test_fork_revert_call_latest_block_timestamp() { assert_eq!(timestamp, U256::from(latest_block.header.timestamp)); let difficulty = multicall_contract.getCurrentBlockDifficulty().call().await.unwrap(); - assert_eq!(difficulty, U256::from(latest_block.header.difficulty)); + assert_eq!(difficulty, latest_block.header.difficulty); let gaslimit = multicall_contract.getCurrentBlockGasLimit().call().await.unwrap(); assert_eq!(gaslimit, U256::from(latest_block.header.gas_limit)); @@ -667,14 +1070,14 @@ async fn can_remove_pool_transactions() { } #[tokio::test(flavor = "multi_thread")] -async fn test_reorg() { +async fn flaky_test_reorg() { let (api, handle) = spawn(NodeConfig::test()).await; let provider = handle.http_provider(); let accounts = handle.dev_wallets().collect::>(); - // Test calls - // Populate chain + // Populate with deterministic block boundaries: disable automine, batch 2 txs, mine. + api.anvil_set_auto_mine(false).await.unwrap(); for i in 0..10 { let tx = TransactionRequest::default() .to(accounts[0].address()) @@ -689,7 +1092,10 @@ async fn test_reorg() { .from(accounts[2].address()); let tx = WithOtherFields::new(tx); api.send_transaction(tx).await.unwrap(); + + api.evm_mine(None).await.unwrap(); } + api.anvil_set_auto_mine(true).await.unwrap(); // Define transactions let mut txs = vec![]; @@ -698,26 +1104,29 @@ async fn test_reorg() { let to = accounts[i + 1].address(); for j in 0..5 { let tx = TransactionRequest::default().from(from).to(to).value(U256::from(j)); - txs.push((TransactionData::JSON(tx), i as u64)); + txs.push((TransactionData::JSON(tx.into()), i as u64)); } } let prev_height = provider.get_block_number().await.unwrap(); - api.anvil_reorg(ReorgOptions { depth: 7, tx_block_pairs: txs }).await.unwrap(); + let depth = 7u64; + api.anvil_reorg(ReorgOptions { depth, tx_block_pairs: txs }).await.unwrap(); let reorged_height = provider.get_block_number().await.unwrap(); assert_eq!(reorged_height, prev_height); - // The first 3 reorged blocks should have 5 transactions each - for num in 14..17 { + // The 3 reorged blocks (one per `tx_block_pairs` group) should each hold 5 txs. + let first_reorged = prev_height - depth + 1; + for num in first_reorged..first_reorged + 3 { let block = provider.get_block_by_number(num.into()).full().await.unwrap(); let block = block.unwrap(); assert_eq!(block.transactions.len(), 5); } - // Verify that historic blocks are still accessible - for num in (0..14).rev() { - let _ = provider.get_block_by_number(num.into()).full().await.unwrap(); + // Verify that historic blocks below the reorged range are still accessible + for num in (0..first_reorged).rev() { + let block = provider.get_block_by_number(num.into()).full().await.unwrap(); + assert!(block.is_some(), "Historic block {num} should be accessible after reorg"); } // Send a few more transaction to verify the chain can still progress @@ -753,8 +1162,8 @@ async fn test_reorg() { let value = storage.getValue().call().await.unwrap(); assert_eq!("initial value".to_string(), value); - api.mine_one().await; - api.mine_one().await; + api.mine_one().await.unwrap(); + api.mine_one().await.unwrap(); // Test raw transaction data let mut tx = TxEip1559 { @@ -789,111 +1198,540 @@ async fn test_reorg() { let res = api .anvil_reorg(ReorgOptions { depth: 1, - tx_block_pairs: vec![(TransactionData::JSON(TransactionRequest::default()), 10)], + tx_block_pairs: vec![(TransactionData::JSON(TransactionRequest::default().into()), 10)], }) .await; assert!(res.is_err()); } #[tokio::test(flavor = "multi_thread")] -async fn test_rollback() { - let (api, handle) = spawn(NodeConfig::test()).await; +async fn can_replay_arbitrum_transaction_with_priority_fee_above_max_fee() { + let (api, handle) = + spawn(NodeConfig::test().with_chain_id(Some(NamedChain::Arbitrum as u64))).await; let provider = handle.http_provider(); + let accounts = handle.dev_wallets().collect::>(); + api.mine_one().await.unwrap(); - // Mine 5 blocks - for _ in 0..5 { - api.mine_one().await; + let recipient = accounts[1].address(); + let value = U256::from(100); + let mut tx = TxEip1559 { + chain_id: api.chain_id(), + to: TxKind::Call(recipient), + value, + max_priority_fee_per_gas: 3_000_000_000, + max_fee_per_gas: 2_000_000_000, + gas_limit: 21_000, + ..Default::default() + }; + let signature = accounts[0].sign_transaction_sync(&mut tx).unwrap(); + let tx = tx.into_signed(signature); + let tx_hash = *tx.hash(); + let mut encoded = vec![]; + tx.eip2718_encode(&mut encoded); + + let balance_before = provider.get_balance(recipient).await.unwrap(); + api.anvil_reorg(ReorgOptions { + depth: 1, + tx_block_pairs: vec![(TransactionData::Raw(encoded.into()), 0)], + }) + .await + .unwrap(); + let balance_after = provider.get_balance(recipient).await.unwrap(); + + assert_eq!(balance_after, balance_before + value); + + let trace = api + .trace_replay_transaction(tx_hash, [TraceType::Trace].into_iter().collect()) + .await + .unwrap() + .unwrap(); + assert!(!trace.full_trace.trace.is_empty()); + + let mut tx = TxEip1559 { + chain_id: api.chain_id(), + nonce: 1, + to: TxKind::Call(recipient), + max_priority_fee_per_gas: 3_000_000_000, + max_fee_per_gas: 2_000_000_000, + gas_limit: 21_000, + ..Default::default() + }; + let signature = accounts[0].sign_transaction_sync(&mut tx).unwrap(); + let tx = tx.into_signed(signature); + let mut encoded = vec![]; + tx.eip2718_encode(&mut encoded); + + let err = provider.send_raw_transaction(&encoded).await.unwrap_err(); + assert!(err.to_string().contains("max priority fee per gas higher than max fee per gas")); + + let pending = + PendingTransaction::new(FoundryTxEnvelope::decode_2718(&mut encoded.as_slice()).unwrap()) + .unwrap(); + let ordinary_hash = *pending.hash(); + let ordinary = Arc::new(PoolTransaction::new(pending)); + + let mut tx = TxEip1559 { + chain_id: api.chain_id(), + to: TxKind::Call(accounts[2].address()), + value: U256::ONE, + max_priority_fee_per_gas: 3_000_000_000, + max_fee_per_gas: 2_000_000_000, + gas_limit: 21_000, + ..Default::default() + }; + let signature = accounts[1].sign_transaction_sync(&mut tx).unwrap(); + let tx = tx.into_signed(signature); + let replay_hash = *tx.hash(); + let mut encoded = vec![]; + tx.eip2718_encode(&mut encoded); + let pending = + PendingTransaction::new(FoundryTxEnvelope::decode_2718(&mut encoded.as_slice()).unwrap()) + .unwrap(); + + let replay = Arc::new(PoolTransaction::new(pending).with_replay()); + let outcome = api.backend.mine_block(vec![replay, ordinary]).await.unwrap(); + assert_eq!(outcome.included.len(), 1); + assert_eq!(outcome.included[0].hash(), replay_hash); + assert_eq!(outcome.invalid.len(), 1); + assert_eq!(outcome.invalid[0].hash(), ordinary_hash); +} + +#[derive(Clone, Copy)] +enum FinalizationFailure { + RevertingSystemCall, + MalformedDeposit, +} + +async fn assert_failed_mining_is_atomic(failure: FinalizationFailure) { + let config = NodeConfig::test() + .with_hardfork(Some(EthereumHardfork::Prague.into())) + .with_base_fee(Some(0)); + let clean_config = config.clone(); + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + let accounts = handle.dev_wallets().collect::>(); + let sender = accounts[0].address(); + let contract = match failure { + FinalizationFailure::RevertingSystemCall => { + address!("0x1000000000000000000000000000000000000000") + } + FinalizationFailure::MalformedDeposit => { + address!("0x00000000219ab540356cbb839cbe05303d7705fa") + } + }; + api.anvil_set_auto_mine(false).await.unwrap(); + api.anvil_set_code(contract, Bytes::from_static(&[0x60, 0x01, 0x5f, 0x55, 0x00])) + .await + .unwrap(); + + match failure { + FinalizationFailure::RevertingSystemCall => { + api.anvil_set_code( + address!("0x00000961ef480eb55e80d19ad83579a64c007002"), + Bytes::from_static(&[0x5f, 0x5f, 0xfd]), + ) + .await + .unwrap(); + } + FinalizationFailure::MalformedDeposit => { + let mut code = vec![0x60, 0x01, 0x5f, 0x55, 0x7f]; + code.extend_from_slice( + keccak256(b"DepositEvent(bytes,bytes,bytes,bytes,bytes)").as_slice(), + ); + code.extend_from_slice(&[0x5f, 0x5f, 0xa1, 0x00]); + api.anvil_set_code(contract, code.into()).await.unwrap(); + } } - // Get block 4 for later comparison - let block4 = provider.get_block(4.into()).await.unwrap().unwrap(); + let genesis = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + let best_hash = api.backend.best_hash(); + let base_fee = api.base_fee().unwrap(); + let blob_fee = api.excess_blob_gas_and_price().unwrap(); + let balance = provider.get_balance(sender).await.unwrap(); + let nonce = provider.get_transaction_count(sender).await.unwrap(); + let storage = provider.get_storage_at(contract, U256::ZERO).await.unwrap(); + let mut notifications = api.new_block_notifications(); + let request = + TransactionRequest::default().from(sender).to(contract).gas_price(0).gas_limit(100_000); + api.send_transaction(WithOtherFields::new(request.clone())).await.unwrap(); + + let error = api.mine_one().await.unwrap_err(); + let message = error.to_string(); + match failure { + FinalizationFailure::RevertingSystemCall => { + assert!(message.contains("execution reverted"), "{message}"); + } + FinalizationFailure::MalformedDeposit => { + assert!(message.contains("deposit") || message.contains("decode"), "{message}"); + } + } - // Rollback with None should rollback 1 block - api.anvil_rollback(None).await.unwrap(); + let latest = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert_eq!(api.backend.best_number(), 0); + assert_eq!(api.backend.best_hash(), best_hash); + assert_eq!(latest.header.hash, genesis.header.hash); + assert_eq!(latest.header.state_root, genesis.header.state_root); + assert_eq!(api.base_fee().unwrap(), base_fee); + assert_eq!(api.excess_blob_gas_and_price().unwrap(), blob_fee); + assert_eq!(provider.get_balance(sender).await.unwrap(), balance); + assert_eq!(provider.get_transaction_count(sender).await.unwrap(), nonce); + assert_eq!(provider.get_storage_at(contract, U256::ZERO).await.unwrap(), storage); + assert_eq!(api.txpool_status().await.unwrap().pending, 1); + assert!(notifications.next().now_or_never().is_none()); + + // A successful retry must produce the same root as a node that never executed the failed + // candidate, proving recovery publication matches a clean execution. + let valid_code = Bytes::from_static(&[0x60, 0x01, 0x5f, 0x55, 0x00]); + api.anvil_set_code(contract, valid_code.clone()).await.unwrap(); + if matches!(failure, FinalizationFailure::RevertingSystemCall) { + api.anvil_set_code(address!("0x00000961ef480eb55e80d19ad83579a64c007002"), Bytes::new()) + .await + .unwrap(); + } - // Assert we're at block 4 and the block contents are kept the same - let head = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); - assert_eq!(head, block4); + let (clean_api, _clean_handle) = spawn(clean_config).await; + clean_api.anvil_set_auto_mine(false).await.unwrap(); + clean_api.anvil_set_code(contract, valid_code).await.unwrap(); + if matches!(failure, FinalizationFailure::RevertingSystemCall) { + let withdrawals = address!("0x00000961ef480eb55e80d19ad83579a64c007002"); + clean_api + .anvil_set_code(withdrawals, Bytes::from_static(&[0x5f, 0x5f, 0xfd])) + .await + .unwrap(); + clean_api.anvil_set_code(withdrawals, Bytes::new()).await.unwrap(); + } + clean_api.send_transaction(WithOtherFields::new(request)).await.unwrap(); - // Get block 1 for comparison - let block1 = provider.get_block(1.into()).await.unwrap().unwrap(); + api.mine_one().await.unwrap(); + clean_api.mine_one().await.unwrap(); + let recovered = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + let clean = clean_api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert_eq!(recovered.header.state_root, clean.header.state_root); +} - // Rollback to block 1 - let depth = 3; // from block 4 to block 1 - api.anvil_rollback(Some(depth)).await.unwrap(); +#[tokio::test(flavor = "multi_thread")] +async fn reverting_prague_transition_rolls_back_mining() { + assert_failed_mining_is_atomic(FinalizationFailure::RevertingSystemCall).await; +} - // Assert we're at block 1 and the block contents are kept the same - let head = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); - assert_eq!(head, block1); +#[tokio::test(flavor = "multi_thread")] +async fn malformed_deposit_request_rolls_back_mining() { + assert_failed_mining_is_atomic(FinalizationFailure::MalformedDeposit).await; } -// === wallet endpoints === // #[tokio::test(flavor = "multi_thread")] -async fn can_get_wallet_capabilities() { - let (api, handle) = spawn(NodeConfig::test().with_odyssey(true)).await; +async fn manual_mining_rpcs_preserve_controls_on_failure() { + let prevrandao = b256!("0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"); + for method in ["anvil_mine", "evm_mine", "evm_mine_detailed"] { + let config = NodeConfig::test() + .with_hardfork(Some(EthereumHardfork::Prague.into())) + .with_base_fee(Some(0)); + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + let withdrawals = address!("0x00000961ef480eb55e80d19ad83579a64c007002"); + api.anvil_set_code(withdrawals, Bytes::from_static(&[0x5f, 0x5f, 0xfd])).await.unwrap(); + let genesis = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + let next_timestamp = genesis.header.timestamp + 100; + api.evm_set_next_block_timestamp(next_timestamp).unwrap(); + api.anvil_set_next_block_prevrandao(prevrandao).await.unwrap(); + + let result: std::result::Result = if method == "anvil_mine" { + provider.raw_request(method.into(), (U256::ONE,)).await + } else { + provider + .raw_request( + method.into(), + (MineOptions::Options { timestamp: None, blocks: Some(1) },), + ) + .await + }; + let error = result.unwrap_err().to_string(); + assert!(error.contains("execution reverted"), "{method}: {error}"); + assert_eq!(api.backend.best_number(), 0, "{method}"); + + api.anvil_set_code(withdrawals, Bytes::new()).await.unwrap(); + api.mine_one().await.unwrap(); + let block = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert_eq!(block.header.timestamp, next_timestamp, "{method}"); + assert_eq!(block.header.mix_hash, Some(prevrandao), "{method}"); + + api.evm_increase_time(U256::ONE).await.unwrap(); + api.mine_one().await.unwrap(); + let following = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert_ne!(following.header.mix_hash, Some(prevrandao), "{method}"); + assert_eq!(following.header.timestamp, next_timestamp + 1, "{method}"); + } +} - let provider = handle.http_provider(); +#[tokio::test(flavor = "multi_thread")] +async fn failed_anvil_mine_preserves_interval_adjustment() { + let config = NodeConfig::test() + .with_hardfork(Some(EthereumHardfork::Prague.into())) + .with_base_fee(Some(0)); + let (api, _) = spawn(config).await; + let withdrawals = address!("0x00000961ef480eb55e80d19ad83579a64c007002"); + api.anvil_set_code(withdrawals, Bytes::from_static(&[0x5f, 0x5f, 0xfd])).await.unwrap(); + let genesis = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + api.evm_set_block_timestamp_interval(17).unwrap(); + + api.anvil_mine(Some(U256::from(2)), Some(U256::from(60))).await.unwrap_err(); + api.anvil_set_code(withdrawals, Bytes::new()).await.unwrap(); + api.mine_one().await.unwrap(); + let block = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert_eq!(block.header.timestamp, genesis.header.timestamp + 17); + assert!(api.evm_remove_block_timestamp_interval().unwrap()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn instant_mining_reselects_from_live_pool_after_failure() { + for drop_failed_transaction in [false, true] { + let config = NodeConfig::test() + .with_hardfork(Some(EthereumHardfork::Prague.into())) + .with_base_fee(Some(0)); + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + let accounts = handle.dev_wallets().collect::>(); + let withdrawals = address!("0x00000961ef480eb55e80d19ad83579a64c007002"); + api.anvil_set_code(withdrawals, Bytes::from_static(&[0x5f, 0x5f, 0xfd])).await.unwrap(); + + let first = TransactionRequest::default() + .from(accounts[0].address()) + .to(accounts[1].address()) + .value(U256::ONE); + let first_hash = api.send_transaction(WithOtherFields::new(first)).await.unwrap(); + tokio::time::sleep(Duration::from_millis(100)).await; + assert_eq!(api.backend.best_number(), 0); + assert!(provider.get_transaction_receipt(first_hash).await.unwrap().is_none()); + + if drop_failed_transaction { + assert_eq!(api.anvil_drop_transaction(first_hash).await.unwrap(), Some(first_hash)); + } + api.anvil_set_code(withdrawals, Bytes::new()).await.unwrap(); + let second = TransactionRequest::default() + .from(accounts[1].address()) + .to(accounts[2].address()) + .value(U256::ONE); + let second_hash = api.send_transaction(WithOtherFields::new(second)).await.unwrap(); + + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if provider.get_transaction_receipt(second_hash).await.unwrap().is_some() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + let first_receipt = provider.get_transaction_receipt(first_hash).await.unwrap(); + assert_eq!(first_receipt.is_some(), !drop_failed_transaction); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn fixed_and_mixed_mining_failures_remain_bounded() { + for mixed in [false, true] { + let config = NodeConfig::test() + .with_hardfork(Some(EthereumHardfork::Prague.into())) + .with_base_fee(Some(0)) + .with_mixed_mining(mixed, Some(Duration::from_millis(100))); + let (api, _handle) = spawn(config).await; + let withdrawals = address!("0x00000961ef480eb55e80d19ad83579a64c007002"); + api.anvil_set_code(withdrawals, Bytes::from_static(&[0x5f, 0x5f, 0xfd])).await.unwrap(); + tokio::time::sleep(Duration::from_millis(250)).await; + assert_eq!(api.backend.best_number(), 0, "mixed={mixed}"); + + api.anvil_set_code(withdrawals, Bytes::new()).await.unwrap(); + tokio::time::timeout(Duration::from_secs(5), async { + while api.backend.best_number() == 0 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap_or_else(|_| panic!("scheduler did not recover, mixed={mixed}")); + assert_eq!(api.backend.best_number(), 1, "mixed={mixed}"); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_reorg_zero_depth_with_transactions_is_rejected() { + let (api, _handle) = spawn(NodeConfig::test()).await; + api.mine_one().await.unwrap(); + let tip = api.block_number().unwrap(); + + // Nothing is mined by a reorg of depth 0, so a transaction can't be placed anywhere. + let err = api + .anvil_reorg(ReorgOptions { + depth: 0, + tx_block_pairs: vec![(TransactionData::JSON(TransactionRequest::default().into()), 0)], + }) + .await + .unwrap_err(); + assert!(err.to_string().contains("Reorg depth must be at least 1"), "{err}"); - let init_sponsor_bal = provider.get_balance(EXECUTOR).await.unwrap(); + // Block numbers are still checked against the last reorged block. + let err = api + .anvil_reorg(ReorgOptions { + depth: 1, + tx_block_pairs: vec![(TransactionData::JSON(TransactionRequest::default().into()), 1)], + }) + .await + .unwrap_err(); + assert!(err.to_string().contains("must not exceed (depth-1) 0"), "{err}"); - let expected_bal = Unit::ETHER.wei().saturating_mul(U256::from(10_000)); - assert_eq!(init_sponsor_bal, expected_bal); + assert_eq!(api.block_number().unwrap(), tip); +} - let p256_code = provider.get_code_at(P256_DELEGATION_CONTRACT).await.unwrap(); +#[tokio::test(flavor = "multi_thread")] +async fn test_reorg_blockhash_opcode_consistency() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); - assert_eq!(p256_code, Bytes::from_static(P256_DELEGATION_RUNTIME_CODE)); + let multicall = Multicall::deploy(&provider).await.unwrap(); - let capabilities = api.get_capabilities().unwrap(); + api.evm_mine(Some(MineOptions::Options { timestamp: None, blocks: Some(200) })).await.unwrap(); - let mut expect_caps = WalletCapabilities::default(); - let cap: Capabilities = Capabilities { - delegation: DelegationCapability { addresses: vec![P256_DELEGATION_CONTRACT] }, - }; - expect_caps.insert(api.chain_id(), cap); + let tip_before_reorg = api.block_number().unwrap().to::(); + + let mut cached_hashes: Vec<(u64, alloy_primitives::B256, alloy_primitives::B256)> = Vec::new(); + for i in 1..=10 { + let block_num = tip_before_reorg - i; + let rpc_hash = + provider.get_block_by_number(block_num.into()).await.unwrap().unwrap().header.hash; + let opcode_hash = multicall.getBlockHash(U256::from(block_num)).call().await.unwrap(); + assert_eq!(rpc_hash, opcode_hash, "RPC and BLOCKHASH opcode should match before reorg"); + cached_hashes.push((block_num, rpc_hash, opcode_hash)); + } + + let tx = TransactionRequest::default(); + api.anvil_reorg(ReorgOptions { + depth: 5, + tx_block_pairs: vec![(TransactionData::JSON(tx.into()), 0)], + }) + .await + .unwrap(); - assert_eq!(capabilities, expect_caps); + api.mine_one().await.unwrap(); + + for (block_num, rpc_before, opcode_before) in &cached_hashes { + let rpc_after = + provider.get_block_by_number((*block_num).into()).await.unwrap().unwrap().header.hash; + let opcode_after = multicall.getBlockHash(U256::from(*block_num)).call().await.unwrap(); + if *block_num <= tip_before_reorg.saturating_sub(5) { + assert_eq!( + rpc_after, *rpc_before, + "Block {block_num}: hash should not change for non-reorged blocks" + ); + assert_eq!( + opcode_after, *opcode_before, + "Block {block_num}: BLOCKHASH should not change for non-reorged blocks" + ); + } + assert_eq!( + rpc_after, opcode_after, + "Block {block_num}: RPC ({rpc_after}) and BLOCKHASH opcode ({opcode_after}) should match after reorg" + ); + } } #[tokio::test(flavor = "multi_thread")] -async fn can_add_capability() { - let (api, _handle) = spawn(NodeConfig::test().with_odyssey(true)).await; +async fn test_reorg_deep_blockhash_consistency() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); - let init_capabilities = api.get_capabilities().unwrap(); + let multicall = Multicall::deploy(&provider).await.unwrap(); - let mut expect_caps = WalletCapabilities::default(); - let cap: Capabilities = Capabilities { - delegation: DelegationCapability { addresses: vec![P256_DELEGATION_CONTRACT] }, - }; - expect_caps.insert(api.chain_id(), cap); + // Mine 300 blocks (more than BLOCKHASH limit of 256) + api.evm_mine(Some(MineOptions::Options { timestamp: None, blocks: Some(300) })).await.unwrap(); - assert_eq!(init_capabilities, expect_caps); + let tip_before_reorg = api.block_number().unwrap().to::(); + assert!(tip_before_reorg > 256, "Need more than 256 blocks for this test"); - let new_cap_addr = Address::with_last_byte(1); + // Check blocks within the 256 window before reorg + let mut cached_hashes: Vec<(u64, alloy_primitives::B256)> = Vec::new(); + for i in [1, 10, 100, 200, 255] { + let block_num = tip_before_reorg - i; + let rpc_hash = + provider.get_block_by_number(block_num.into()).await.unwrap().unwrap().header.hash; + let opcode_hash = multicall.getBlockHash(U256::from(block_num)).call().await.unwrap(); + assert_eq!(rpc_hash, opcode_hash, "RPC and BLOCKHASH opcode should match before reorg"); + cached_hashes.push((block_num, rpc_hash)); + } - api.anvil_add_capability(new_cap_addr).unwrap(); + // Perform a deep reorg (50 blocks) + let tx = TransactionRequest::default(); + api.anvil_reorg(ReorgOptions { + depth: 50, + tx_block_pairs: vec![(TransactionData::JSON(tx.into()), 0)], + }) + .await + .unwrap(); - let capabilities = api.get_capabilities().unwrap(); + api.mine_one().await.unwrap(); - let cap: Capabilities = Capabilities { - delegation: DelegationCapability { - addresses: vec![P256_DELEGATION_CONTRACT, new_cap_addr], - }, - }; - expect_caps.insert(api.chain_id(), cap); + let tip_after_reorg = api.block_number().unwrap().to::(); + + // Verify blocks still in the 256 window have consistent hashes + for (block_num, rpc_before) in &cached_hashes { + // Skip blocks that were reorged + if *block_num > tip_after_reorg - 50 { + continue; + } + let rpc_after = + provider.get_block_by_number((*block_num).into()).await.unwrap().unwrap().header.hash; + let opcode_after = multicall.getBlockHash(U256::from(*block_num)).call().await.unwrap(); + assert_eq!( + rpc_after, *rpc_before, + "Block {block_num}: hash should not change for non-reorged blocks" + ); + assert_eq!( + rpc_after, opcode_after, + "Block {block_num}: RPC and BLOCKHASH should match after deep reorg" + ); + } - assert_eq!(capabilities, expect_caps); + // Verify BLOCKHASH returns 0 for blocks outside the 256 window + let old_block = tip_after_reorg.saturating_sub(257); + if old_block > 0 { + let opcode_hash = multicall.getBlockHash(U256::from(old_block)).call().await.unwrap(); + assert_eq!( + opcode_hash, + alloy_primitives::B256::ZERO, + "BLOCKHASH should return 0 for blocks outside 256 window" + ); + } } #[tokio::test(flavor = "multi_thread")] -async fn can_set_executor() { - let (api, _handle) = spawn(NodeConfig::test().with_odyssey(true)).await; +async fn test_rollback() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + // Mine 5 blocks + for _ in 0..5 { + api.mine_one().await.unwrap(); + } + + // Get block 4 for later comparison + let block4 = provider.get_block(4.into()).await.unwrap().unwrap(); - let expected_addr = address!("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"); - let pk = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80".to_string(); + // Rollback with None should rollback 1 block + api.anvil_rollback(None).await.unwrap(); + + // Assert we're at block 4 and the block contents are kept the same + let head = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + assert_eq!(head, block4); - let executor = api.anvil_set_executor(pk).unwrap(); + // Get block 1 for comparison + let block1 = provider.get_block(1.into()).await.unwrap().unwrap(); - assert_eq!(executor, expected_addr); + // Rollback to block 1 + let depth = 3; // from block 4 to block 1 + api.anvil_rollback(Some(depth)).await.unwrap(); + + // Assert we're at block 1 and the block contents are kept the same + let head = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + assert_eq!(head, block1); } #[tokio::test(flavor = "multi_thread")] @@ -901,8 +1739,8 @@ async fn test_arb_get_block() { let (api, _handle) = spawn(NodeConfig::test().with_chain_id(Some(421611u64))).await; // Mine two blocks - api.mine_one().await; - api.mine_one().await; + api.mine_one().await.unwrap(); + api.mine_one().await.unwrap(); let best_number = api.block_number().unwrap().to::(); @@ -928,7 +1766,7 @@ async fn test_mine_blk_with_prev_timestamp() { // mock timestamp api.evm_set_next_block_timestamp(init_timestamp).unwrap(); - api.mine_one().await; + api.mine_one().await.unwrap(); let block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); @@ -942,7 +1780,7 @@ async fn test_mine_blk_with_prev_timestamp() { tokio::time::sleep(Duration::from_secs(1)).await; // Subsequent block should have a greater timestamp than previous block - api.mine_one().await; + api.mine_one().await.unwrap(); let block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); @@ -955,7 +1793,6 @@ async fn test_mine_blk_with_prev_timestamp() { } // increase time by 0 seconds i.e next_block_timestamp = prev_block_timestamp -// api.evm_increase_time(0).unwrap(); #[tokio::test(flavor = "multi_thread")] async fn test_increase_time_by_zero() { let (api, handle) = spawn(NodeConfig::test()).await; @@ -966,9 +1803,9 @@ async fn test_increase_time_by_zero() { let init_number = init_blk.header.number; let init_timestamp = init_blk.header.timestamp; - let _ = api.evm_increase_time(U256::ZERO).await; + api.evm_set_next_block_timestamp(init_timestamp).unwrap(); - api.mine_one().await; + api.mine_one().await.unwrap(); let block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); @@ -1001,41 +1838,60 @@ async fn evm_mine_blk_with_same_timestamp() { assert_eq!(next_blk_timestamp, init_timestamp); } -// mine 4 blocks instantly. #[tokio::test(flavor = "multi_thread")] -async fn test_mine_blk_with_same_timestamp() { - let (api, handle) = spawn(NodeConfig::test()).await; - let provider = handle.http_provider(); +async fn test_mine_blocks_with_increasing_timestamps() { + let (api, _) = spawn(NodeConfig::test()).await; + let init_block = api.block_by_number(0.into()).await.unwrap().unwrap(); - let init_blk = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + api.anvil_mine(Some(U256::from(4)), None).await.unwrap(); - let init_number = init_blk.header.number; - let init_timestamp = init_blk.header.timestamp; + let mut timestamps = vec![init_block.header.timestamp]; + for number in 1..=4 { + let block = api.block_by_number(number.into()).await.unwrap().unwrap(); + timestamps.push(block.header.timestamp); + } - // Mine 4 blocks instantly - let _ = api.anvil_mine(Some(U256::from(4)), None).await; + assert!(timestamps.windows(2).all(|window| window[0] < window[1]), "{timestamps:#?}"); +} - let latest_blk_num = api.block_number().unwrap().to::(); +#[tokio::test(flavor = "multi_thread")] +async fn test_mine_blocks_with_zero_interval() { + let (api, _) = spawn(NodeConfig::test()).await; + let init_block = api.block_by_number(0.into()).await.unwrap().unwrap(); - assert_eq!(latest_blk_num, init_number + 4); + api.anvil_mine(Some(U256::from(4)), Some(U256::ZERO)).await.unwrap(); - let mut blk_futs = vec![]; - for i in 1..=4 { - blk_futs.push(provider.get_block(i.into()).into_future()); + // A zero interval allows equal timestamps but never goes backwards; the wall clock decides. + let mut prev = init_block.header.timestamp; + for number in 1..=4 { + let block = api.block_by_number(number.into()).await.unwrap().unwrap(); + assert!(block.header.timestamp >= prev, "block {number} went backwards"); + prev = block.header.timestamp; } + assert!(prev <= init_block.header.timestamp + 1, "blocks should share the wall-clock second"); - let timestamps = futures::future::join_all(blk_futs) - .await - .into_iter() - .map(|blk| blk.unwrap().unwrap().header.timestamp) - .collect::>(); - - // All timestamps should be equal. Allow for 1 second difference. - assert!(timestamps.windows(2).all(|w| w[0] == w[1]), "{timestamps:#?}"); - assert!( - timestamps[0] == init_timestamp || timestamps[0] == init_timestamp + 1, - "{timestamps:#?} != {init_timestamp}" - ); + api.anvil_mine(Some(U256::from(1)), None).await.unwrap(); + let next_block = api.block_by_number(5.into()).await.unwrap().unwrap(); + assert!(next_block.header.timestamp > prev); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_mine_blocks_increase_after_time_jump() { + let (api, _) = spawn(NodeConfig::test()).await; + let init_block = api.block_by_number(0.into()).await.unwrap().unwrap(); + + api.anvil_mine(Some(U256::from(1)), Some(U256::from(10_000))).await.unwrap(); + api.anvil_mine(Some(U256::from(3)), None).await.unwrap(); + + let mut timestamps = Vec::with_capacity(4); + for number in 1..=4 { + let block = api.block_by_number(number.into()).await.unwrap().unwrap(); + timestamps.push(block.header.timestamp); + } + + // A positive interval also adds to the wall-clock offset, so a slow run may add a second. + assert!(timestamps[0] >= init_block.header.timestamp + 10_000, "{timestamps:#?}"); + assert!(timestamps.windows(2).all(|window| window[0] < window[1]), "{timestamps:#?}"); } // @@ -1056,6 +1912,27 @@ async fn test_mine_first_block_with_interval() { assert_eq!(second_block.header.timestamp, init_timestamp + 120); } +#[tokio::test(flavor = "multi_thread")] +async fn test_safe_and_finalized_use_configured_slots_in_epoch() { + let (api, _) = spawn(NodeConfig::test().with_slots_in_an_epoch(2)).await; + + api.anvil_mine(Some(U256::from(5)), None).await.unwrap(); + tokio::time::sleep(Duration::from_millis(100)).await; + + let safe_block = api.block_by_number(BlockNumberOrTag::Safe).await.unwrap().unwrap(); + assert_eq!(safe_block.header.number, 3); + + let finalized_block = api.block_by_number(BlockNumberOrTag::Finalized).await.unwrap().unwrap(); + assert_eq!(finalized_block.header.number, 1); + + let safe_history = api.fee_history(U256::ONE, BlockNumberOrTag::Safe, vec![]).await.unwrap(); + assert_eq!(safe_history.oldest_block, 3); + + let finalized_history = + api.fee_history(U256::ONE, BlockNumberOrTag::Finalized, vec![]).await.unwrap(); + assert_eq!(finalized_history.oldest_block, 1); +} + #[tokio::test(flavor = "multi_thread")] async fn test_anvil_reset_non_fork() { let (api, handle) = spawn(NodeConfig::test()).await; @@ -1071,7 +1948,7 @@ async fn test_anvil_reset_non_fork() { // Mine some blocks and make transactions for _ in 0..5 { - api.mine_one().await; + api.mine_one().await.unwrap(); } // Send a transaction @@ -1112,7 +1989,7 @@ async fn test_anvil_reset_non_fork() { assert_eq!(to_balance_after_reset, U256::ZERO); // Test we can continue mining after reset - api.mine_one().await; + api.mine_one().await.unwrap(); let new_block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); assert_eq!(new_block.header.number, 1); } @@ -1128,7 +2005,7 @@ async fn test_anvil_reset_fork_to_non_fork() { // Mine some blocks for _ in 0..3 { - api.mine_one().await; + api.mine_one().await.unwrap(); } // Reset to non-fork mode @@ -1143,7 +2020,264 @@ async fn test_anvil_reset_fork_to_non_fork() { assert_eq!(block.header.number, 0); // Verify we can still mine blocks - api.mine_one().await; + api.mine_one().await.unwrap(); let new_block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); assert_eq!(new_block.header.number, 1); } + +#[tokio::test(flavor = "multi_thread")] +async fn can_get_node_info_tempo_t0() { + let config = NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T0.into())); + let (api, handle) = spawn(config).await; + + let node_info = api.anvil_node_info().await.unwrap(); + + let provider = handle.http_provider(); + + let block_number = provider.get_block_number().await.unwrap(); + let block = provider.get_block(BlockId::from(block_number)).await.unwrap().unwrap(); + + let expected_node_info = NodeInfo { + current_block_number: 0_u64, + current_block_timestamp: block.header.timestamp, + current_block_hash: block.header.hash, + hard_fork: "T0".to_string(), + transaction_order: "fees".to_owned(), + environment: NodeEnvironment { + base_fee: 10_000_000_000, + chain_id: 31337, + gas_limit: 30_000_000, + gas_price: 11_000_000_000, + }, + fork_config: NodeForkConfig { + fork_url: None, + fork_block_number: None, + fork_retry_backoff: None, + }, + network: Some("tempo".to_string()), + }; + + assert_eq!(node_info, expected_node_info); +} + +#[tokio::test(flavor = "multi_thread")] +async fn can_get_node_info_tempo_t5_from_chain_timestamp() { + let timestamp = TempoHardfork::T5.mainnet_activation_timestamp().unwrap(); + let config = NodeConfig::test_tempo() + .with_chain_id(Some(4217u64)) + .with_genesis_timestamp(Some(timestamp)); + let (api, _handle) = spawn(config).await; + + let node_info = api.anvil_node_info().await.unwrap(); + + assert_eq!(node_info.hard_fork, "T5"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn can_get_node_info_tempo_t1() { + let config = NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T1.into())); + let (api, handle) = spawn(config).await; + + let node_info = api.anvil_node_info().await.unwrap(); + + let provider = handle.http_provider(); + + let block_number = provider.get_block_number().await.unwrap(); + let block = provider.get_block(BlockId::from(block_number)).await.unwrap().unwrap(); + + let expected_node_info = NodeInfo { + current_block_number: 0_u64, + current_block_timestamp: block.header.timestamp, + current_block_hash: block.header.hash, + hard_fork: "T1".to_string(), + transaction_order: "fees".to_owned(), + environment: NodeEnvironment { + base_fee: 20_000_000_000, + chain_id: 31337, + gas_limit: 30_000_000, + gas_price: 21_000_000_000, + }, + fork_config: NodeForkConfig { + fork_url: None, + fork_block_number: None, + fork_retry_backoff: None, + }, + network: Some("tempo".to_string()), + }; + + assert_eq!(node_info, expected_node_info); +} + +#[tokio::test(flavor = "multi_thread")] +#[cfg(feature = "monad")] +async fn can_get_node_info_monad() { + let config = NodeConfig::test_monad() + .with_hardfork(Some(foundry_evm::hardfork::MonadHardfork::MonadEight.into())); + let (api, handle) = spawn(config).await; + + let node_info = api.anvil_node_info().await.unwrap(); + + let provider = handle.http_provider(); + + let block_number = provider.get_block_number().await.unwrap(); + let block = provider.get_block(BlockId::from(block_number)).await.unwrap().unwrap(); + + let expected_node_info = NodeInfo { + current_block_number: 0_u64, + current_block_timestamp: block.header.timestamp, + current_block_hash: block.header.hash, + hard_fork: "MonadEight".to_string(), + transaction_order: "fees".to_owned(), + environment: NodeEnvironment { + base_fee: U256::from_str("0x3b9aca00").unwrap().to(), + chain_id: 31337, + gas_limit: 30_000_000, + gas_price: U256::from_str("0x77359400").unwrap().to(), + }, + fork_config: NodeForkConfig { + fork_url: None, + fork_block_number: None, + fork_retry_backoff: None, + }, + network: Some("monad".to_string()), + }; + + assert_eq!(node_info, expected_node_info); +} + +#[tokio::test(flavor = "multi_thread")] +#[cfg(feature = "optimism")] +async fn can_get_node_info_optimism() { + let (api, _) = spawn(NodeConfig::test().with_optimism()).await; + + assert_eq!(api.anvil_node_info().await.unwrap().network.as_deref(), Some("optimism")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn can_get_default_base_fee_tempo_t0() { + let config = NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T0.into())); + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + + api.mine_one().await.unwrap(); + + let block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + assert_eq!( + block.header.base_fee_per_gas, + Some(10_000_000_000), + "T0 base fee should be 10 billion attodollars" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn can_get_automine_state() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + // Queried over RPC so the request name, parameter handling and dispatch arm are covered too, + // not just the handler. + let automine = + async || provider.client().request_noparams::("anvil_getAutomine").await.unwrap(); + + assert!(automine().await); + + api.anvil_set_auto_mine(false).await.unwrap(); + assert!(!automine().await); + + // Interval mining is a separate mode and also reports automining as disabled. + api.anvil_set_interval_mining(1).unwrap(); + assert!(!automine().await); + + api.anvil_set_auto_mine(true).await.unwrap(); + assert!(automine().await); +} + +#[tokio::test(flavor = "multi_thread")] +async fn can_drop_all_transactions() { + let (api, handle) = spawn(NodeConfig::test()).await; + api.anvil_set_auto_mine(false).await.unwrap(); + + let provider = handle.http_provider(); + let sender = handle.dev_wallets().next().unwrap().address(); + + for nonce in 0..3u64 { + api.send_transaction(WithOtherFields::new( + TransactionRequest::default() + .with_from(sender) + .with_to(Address::repeat_byte(0x22)) + .with_value(U256::ONE) + .with_nonce(nonce), + )) + .await + .unwrap(); + } + assert_eq!(provider.txpool_status().await.unwrap().pending, 3); + + let _: () = provider.client().request_noparams("anvil_dropAllTransactions").await.unwrap(); + + let status = provider.txpool_status().await.unwrap(); + assert_eq!(status.pending, 0); + assert_eq!(status.queued, 0); + + // Dropping an empty pool is a no-op rather than an error. + let _: () = provider.client().request_noparams("anvil_dropAllTransactions").await.unwrap(); + + // The chain did not advance as a result of dropping. + assert_eq!(provider.get_block_number().await.unwrap(), 0); +} + +#[tokio::test(flavor = "multi_thread")] +async fn can_send_unsigned_transaction() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + // Any address can send, without a signer and without impersonating it first. + let sender = Address::repeat_byte(0x77); + let recipient = Address::repeat_byte(0x88); + api.anvil_set_balance(sender, U256::from(1e18 as u64)).await.unwrap(); + + // Mine explicitly: the handler returns as soon as the transaction is pooled. + api.anvil_set_auto_mine(false).await.unwrap(); + let hash: B256 = provider + .client() + .request( + "eth_sendUnsignedTransaction", + (WithOtherFields::new( + TransactionRequest::default() + .with_from(sender) + .with_to(recipient) + .with_value(U256::from(1234)), + ),), + ) + .await + .unwrap(); + api.mine_one().await.unwrap(); + + let receipt = provider.get_transaction_receipt(hash).await.unwrap().unwrap(); + assert!(receipt.status()); + assert_eq!(receipt.from, sender); + assert_eq!(provider.get_balance(recipient).await.unwrap(), U256::from(1234)); + assert_eq!(provider.get_transaction_count(sender).await.unwrap(), 1); +} + +#[tokio::test(flavor = "multi_thread")] +async fn send_unsigned_transaction_requires_a_sender() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + let err = provider + .client() + .request::<_, B256>( + "eth_sendUnsignedTransaction", + (WithOtherFields::new( + TransactionRequest::default() + .with_to(Address::repeat_byte(0x99)) + .with_value(U256::ONE), + ),), + ) + .await + .unwrap_err(); + let err = err.as_error_resp().unwrap(); + assert_eq!(err.code, -32602); + assert!(err.message.contains("No Signer available"), "unexpected error: {err}"); +} diff --git a/crates/anvil/tests/it/api.rs b/crates/anvil/tests/it/api.rs index 59ed3818f7b4c..cef89f042ef50 100644 --- a/crates/anvil/tests/it/api.rs +++ b/crates/anvil/tests/it/api.rs @@ -1,23 +1,31 @@ //! general eth api tests use crate::{ - abi::{Multicall, SimpleStorage}, + abi::{Multicall, SimpleStorage, VendingMachine}, utils::{connect_pubsub_with_wallet, http_provider, http_provider_with_signer}, }; -use alloy_consensus::{SignableTransaction, Transaction, TxEip1559}; -use alloy_network::{EthereumWallet, TransactionBuilder, TxSignerSync}; +use alloy_consensus::{ + BlobTransactionSidecar, SidecarBuilder, SignableTransaction, SimpleCoder, Transaction, + TxEip1559, +}; +use alloy_network::{ + AnyRpcHeader, EthereumWallet, ReceiptResponse, TransactionBuilder, TransactionBuilder4844, + TxSignerSync, +}; use alloy_primitives::{ - Address, B256, ChainId, U256, b256, bytes, + Address, B256, Bytes, ChainId, Keccak256, U256, bytes, keccak256, map::{AddressHashMap, B256HashMap, HashMap}, }; -use alloy_provider::Provider; +use alloy_provider::{PendingTransactionConfig, Provider}; use alloy_rpc_types::{ - BlockId, BlockNumberOrTag, BlockTransactions, request::TransactionRequest, - state::AccountOverride, + BlockId, BlockNumberOrTag, BlockOverrides, BlockTransactions, debug::ExecutionWitness, + erc4337::TransactionConditional, request::TransactionRequest, state::AccountOverride, }; +use alloy_rpc_types_eth::{Bundle, EthCallResponse}; +use alloy_rpc_types_mev::{EthCallBundle, EthCallBundleResponse}; use alloy_serde::WithOtherFields; +use alloy_sol_types::SolCall; use anvil::{CHAIN_ID, EthereumHardfork, NodeConfig, eth::api::CLIENT_VERSION, spawn}; -use foundry_test_utils::rpc; use futures::join; use std::time::Duration; @@ -26,7 +34,7 @@ async fn can_get_block_number() { let (api, handle) = spawn(NodeConfig::test()).await; let block_num = api.block_number().unwrap(); - assert_eq!(block_num, U256::from(0)); + assert_eq!(block_num, U256::ZERO); let provider = handle.http_provider(); @@ -35,23 +43,85 @@ async fn can_get_block_number() { } #[tokio::test(flavor = "multi_thread")] -async fn can_dev_get_balance() { +async fn unknown_block_transaction_count_is_none() { let (_api, handle) = spawn(NodeConfig::test()).await; let provider = handle.http_provider(); + let count: Option = provider + .client() + .request("eth_getBlockTransactionCountByNumber", (BlockNumberOrTag::Number(1),)) + .await + .unwrap(); + + assert!(count.is_none()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn can_dev_get_balance() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + let genesis_balance = handle.genesis_balance(); for acc in handle.genesis_accounts() { let balance = provider.get_balance(acc).await.unwrap(); assert_eq!(balance, genesis_balance); } + + let address = handle.genesis_accounts().next().unwrap(); + let db = api.backend.get_db().write().await; + let mut first = Box::pin(api.anvil_add_balance(address, U256::ONE)); + let mut second = Box::pin(api.anvil_add_balance(address, U256::from(2))); + assert!(futures::poll!(first.as_mut()).is_pending()); + assert!(futures::poll!(second.as_mut()).is_pending()); + drop(db); + + let (first, second) = join!(first, second); + first.unwrap(); + second.unwrap(); + assert_eq!(provider.get_balance(address).await.unwrap(), genesis_balance + U256::from(3)); } #[tokio::test(flavor = "multi_thread")] async fn can_get_price() { - let (_api, handle) = spawn(NodeConfig::test()).await; + let (api, handle) = spawn(NodeConfig::test()).await; let provider = handle.http_provider(); - let _ = provider.get_gas_price().await.unwrap(); + let gas_price = provider.get_gas_price().await.unwrap(); + assert!(gas_price > 0); + assert_eq!(gas_price, api.gas_price()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn can_get_base_fee() { + let (api, handle) = + spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::London.into()))).await; + let provider = handle.http_provider(); + + let base_fee: Option = provider.client().request("eth_baseFee", ()).await.unwrap(); + assert_eq!(base_fee, api.base_fee().unwrap()); + + let expected = U256::from(1234); + api.anvil_set_next_block_base_fee_per_gas(expected).await.unwrap(); + + let base_fee: Option = provider.client().request("eth_baseFee", ()).await.unwrap(); + assert_eq!(base_fee, Some(expected)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn can_get_null_base_fee_before_london() { + let (api, handle) = + spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Berlin.into()))).await; + let provider = handle.http_provider(); + + let base_fee: Option = provider.client().request("eth_baseFee", ()).await.unwrap(); + assert_eq!(base_fee, None); + + api.anvil_reset(None).await.unwrap(); + + let base_fee: Option = provider.client().request("eth_baseFee", ()).await.unwrap(); + assert_eq!(base_fee, None); + let genesis = provider.get_block(BlockId::number(0)).await.unwrap().unwrap(); + assert_eq!(genesis.header.base_fee_per_gas, None); } #[tokio::test(flavor = "multi_thread")] @@ -59,7 +129,12 @@ async fn can_get_accounts() { let (_api, handle) = spawn(NodeConfig::test()).await; let provider = handle.http_provider(); - let _ = provider.get_accounts().await.unwrap(); + let accounts = provider.get_accounts().await.unwrap(); + let dev_accounts: Vec<_> = handle.dev_accounts().collect(); + assert_eq!(accounts.len(), dev_accounts.len()); + for account in dev_accounts { + assert!(accounts.contains(&account), "Missing dev account {account}"); + } } #[tokio::test(flavor = "multi_thread")] @@ -95,10 +170,14 @@ async fn can_modify_chain_id() { #[tokio::test(flavor = "multi_thread")] async fn can_get_network_id() { - let (api, _handle) = spawn(NodeConfig::test()).await; + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + let network_id = api.network_id().unwrap().unwrap(); + assert_eq!(network_id, CHAIN_ID.to_string()); - let chain_id = api.network_id().unwrap().unwrap(); - assert_eq!(chain_id, CHAIN_ID.to_string()); + let provider_network_id = provider.get_net_version().await.unwrap(); + assert_eq!(provider_network_id, CHAIN_ID); } #[tokio::test(flavor = "multi_thread")] @@ -127,6 +206,101 @@ async fn can_get_block_by_number() { assert_eq!(block.transactions.len(), 1); } +#[tokio::test(flavor = "multi_thread")] +async fn can_get_header_by_number() { + let (_api, handle) = spawn(NodeConfig::test()).await; + + let accounts: Vec<_> = handle.dev_wallets().collect(); + let signer: EthereumWallet = accounts[0].clone().into(); + let from = accounts[0].address(); + let to = accounts[1].address(); + + let provider = http_provider_with_signer(&handle.http_endpoint(), signer); + + let tx = TransactionRequest::default().with_from(from).with_to(to).with_value(U256::ONE); + provider.send_transaction(WithOtherFields::new(tx)).await.unwrap().get_receipt().await.unwrap(); + + let block = provider.get_block(BlockId::number(1)).await.unwrap().unwrap(); + let header: Option = + provider.client().request("eth_getHeaderByNumber", ("0x1",)).await.unwrap(); + let header = header.unwrap(); + assert_eq!(header.hash, block.header.hash); + assert_eq!(header.number, block.header.number); + assert_eq!(header.transactions_root, block.header.transactions_root); + + let missing: Option = + provider.client().request("eth_getHeaderByNumber", ("0x2",)).await.unwrap(); + assert_eq!(missing, None); +} + +#[tokio::test(flavor = "multi_thread")] +async fn can_get_header_by_hash() { + let (_api, handle) = spawn(NodeConfig::test()).await; + + let accounts: Vec<_> = handle.dev_wallets().collect(); + let signer: EthereumWallet = accounts[0].clone().into(); + let from = accounts[0].address(); + let to = accounts[1].address(); + + let provider = http_provider_with_signer(&handle.http_endpoint(), signer); + + let tx = TransactionRequest::default().with_from(from).with_to(to).with_value(U256::ONE); + provider.send_transaction(WithOtherFields::new(tx)).await.unwrap().get_receipt().await.unwrap(); + + let block = provider.get_block(BlockId::number(1)).await.unwrap().unwrap(); + let header: Option = + provider.client().request("eth_getHeaderByHash", (block.header.hash,)).await.unwrap(); + let header = header.unwrap(); + assert_eq!(header.hash, block.header.hash); + assert_eq!(header.number, block.header.number); + assert_eq!(header.transactions_root, block.header.transactions_root); + + let missing: Option = + provider.client().request("eth_getHeaderByHash", (B256::ZERO,)).await.unwrap(); + assert_eq!(missing, None); +} + +#[tokio::test(flavor = "multi_thread")] +async fn can_resolve_safe_and_finalized_block_tags_with_configured_epoch_slots() { + let slots_in_an_epoch = 3; + let (api, handle) = spawn(NodeConfig::test().with_slots_in_an_epoch(slots_in_an_epoch)).await; + let provider = handle.http_provider(); + + api.anvil_mine(Some(U256::from(8)), None).await.unwrap(); + let latest = provider.get_block_number().await.unwrap(); + assert_eq!(latest, 8); + + let safe = provider.get_block(BlockId::safe()).await.unwrap().unwrap(); + assert_eq!(safe.header.number, latest - slots_in_an_epoch); + + let finalized = provider.get_block(BlockId::finalized()).await.unwrap().unwrap(); + assert_eq!(finalized.header.number, latest - slots_in_an_epoch * 2); + + let fee_history = api.fee_history(U256::ONE, BlockNumberOrTag::Safe, vec![]).await.unwrap(); + assert_eq!(fee_history.oldest_block, latest - slots_in_an_epoch); +} + +#[tokio::test(flavor = "multi_thread")] +async fn can_resolve_safe_and_finalized_block_tags_to_genesis_before_configured_epoch() { + let (api, handle) = spawn(NodeConfig::test().with_slots_in_an_epoch(3)).await; + let provider = handle.http_provider(); + + api.anvil_mine(Some(U256::from(2)), None).await.unwrap(); + let genesis = provider.get_block(BlockId::number(0)).await.unwrap().unwrap(); + + let safe = provider.get_block(BlockId::safe()).await.unwrap().unwrap(); + assert_eq!(safe.header.number, genesis.header.number); + assert_eq!(safe.header.hash, genesis.header.hash); + + let finalized = provider.get_block(BlockId::finalized()).await.unwrap().unwrap(); + assert_eq!(finalized.header.number, genesis.header.number); + assert_eq!(finalized.header.hash, genesis.header.hash); + + let fee_history = + api.fee_history(U256::ONE, BlockNumberOrTag::Finalized, vec![]).await.unwrap(); + assert_eq!(fee_history.oldest_block, genesis.header.number); +} + #[tokio::test(flavor = "multi_thread")] async fn can_get_pending_block() { let (api, handle) = spawn(NodeConfig::test()).await; @@ -161,10 +335,16 @@ async fn can_get_pending_block() { let block = provider.get_block(BlockId::pending()).full().await.unwrap().unwrap(); assert_eq!(block.header.number, 1); assert_eq!(block.transactions.len(), 1); + + let receipts = provider.get_block_receipts(BlockId::pending()).await.unwrap().unwrap(); + assert_eq!(receipts.len(), 1); + assert_eq!(receipts[0].transaction_hash(), *pending.tx_hash()); + assert_eq!(receipts[0].block_number(), Some(block.header.number)); + assert_eq!(receipts[0].block_hash(), Some(block.header.hash)); } #[tokio::test(flavor = "multi_thread")] -async fn can_estimate_gas_with_undersized_max_fee_per_gas() { +async fn rejects_estimate_gas_with_undersized_max_fee_per_gas() { let (api, handle) = spawn(NodeConfig::test()).await; let wallet = handle.dev_wallets().next().unwrap(); let signer: EthereumWallet = wallet.clone().into(); @@ -185,15 +365,17 @@ async fn can_estimate_gas_with_undersized_max_fee_per_gas() { assert!(undersized_max_fee_per_gas < latest_block_base_fee_per_gas); - let estimated_gas = simple_storage_contract + let err = simple_storage_contract .setValue("new_value".to_string()) .max_fee_per_gas(undersized_max_fee_per_gas.into()) .from(wallet.address()) .estimate_gas() .await - .unwrap(); - - assert!(estimated_gas > 0); + .unwrap_err(); + assert_eq!( + err.to_string(), + "server returned an error response: error code -32003: max fee per gas less than block base fee" + ); } #[tokio::test(flavor = "multi_thread")] @@ -228,7 +410,7 @@ async fn can_call_on_pending_block() { // Ensure that we can get the block_number from the pending contract let Multicall::aggregateReturn { blockNumber: ret_block_number, .. } = contract.aggregate(vec![]).block(BlockId::pending()).call().await.unwrap(); - assert_eq!(ret_block_number, U256::from(1)); + assert_eq!(ret_block_number, U256::ONE); let accounts: Vec

= handle.dev_wallets().map(|w| w.address()).collect(); @@ -236,7 +418,7 @@ async fn can_call_on_pending_block() { api.anvil_set_coinbase(accounts[i % accounts.len()]).await.unwrap(); api.evm_set_block_gas_limit(U256::from(30_000_000 + i)).unwrap(); - api.anvil_mine(Some(U256::from(1)), None).await.unwrap(); + api.anvil_mine(Some(U256::ONE), None).await.unwrap(); tokio::time::sleep(Duration::from_millis(100)).await; } @@ -272,7 +454,7 @@ async fn can_call_on_pending_block() { } #[tokio::test(flavor = "multi_thread")] -async fn can_call_with_undersized_max_fee_per_gas() { +async fn rejects_call_with_undersized_max_fee_per_gas() { let (api, handle) = spawn(NodeConfig::test()).await; let wallet = handle.dev_wallets().next().unwrap(); let signer: EthereumWallet = wallet.clone().into(); @@ -292,14 +474,17 @@ async fn can_call_with_undersized_max_fee_per_gas() { assert!(undersized_max_fee_per_gas < latest_block_base_fee_per_gas); - let last_sender = simple_storage_contract + let err = simple_storage_contract .lastSender() .max_fee_per_gas(undersized_max_fee_per_gas.into()) .from(wallet.address()) .call() .await - .unwrap(); - assert_eq!(last_sender, Address::ZERO); + .unwrap_err(); + assert_eq!( + err.to_string(), + "server returned an error response: error code -32003: max fee per gas less than block base fee" + ); } #[tokio::test(flavor = "multi_thread")] @@ -377,6 +562,192 @@ async fn can_call_with_state_override() { assert_eq!(value, ""); } +#[tokio::test(flavor = "multi_thread")] +async fn can_call_many() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let wallet = handle.dev_wallets().next().unwrap(); + let signer: EthereumWallet = wallet.clone().into(); + let from = wallet.address(); + + let provider = http_provider_with_signer(&handle.http_endpoint(), signer); + + let simple_storage_contract = + SimpleStorage::deploy(&provider, "initial".to_string()).await.unwrap(); + + let set_value = simple_storage_contract.setValue("updated".to_string()); + let get_value = simple_storage_contract.getValue(); + let transactions = vec![ + WithOtherFields::new( + TransactionRequest::default() + .with_from(from) + .with_to(*simple_storage_contract.address()) + .with_input(set_value.calldata().clone()), + ), + WithOtherFields::new( + TransactionRequest::default() + .with_from(from) + .with_to(*simple_storage_contract.address()) + .with_input(get_value.calldata().clone()), + ), + ]; + + let response: Vec> = provider + .client() + .request( + "eth_callMany", + (vec![ + Bundle { transactions, block_override: None }, + Bundle { transactions: Vec::new(), block_override: None }, + ],), + ) + .await + .unwrap(); + + assert_eq!(response.len(), 2); + assert_eq!(response[0].len(), 2); + assert!(response[1].is_empty()); + assert_eq!(response[0][0].error, None); + let output = response[0][1].clone().ensure_ok().unwrap(); + let value = SimpleStorage::getValueCall::abi_decode_returns(&output).unwrap(); + assert_eq!(value, "updated"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn call_many_progresses_block_context() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + let probe = Address::repeat_byte(0x42); + // Return NUMBER followed by TIMESTAMP. + api.anvil_set_code(probe, bytes!("435f524260205260405ff3")).await.unwrap(); + + let request = || { + WithOtherFields::new(TransactionRequest::default().with_to(probe).with_gas_limit(100_000)) + }; + let bundles = vec![ + Bundle { + transactions: vec![request()], + block_override: Some(BlockOverrides { + number: Some(U256::from(99)), + time: Some(1_234), + ..Default::default() + }), + }, + Bundle { transactions: Vec::new(), block_override: None }, + Bundle { transactions: vec![request()], block_override: None }, + ]; + + let response: Vec> = + provider.client().request("eth_callMany", (bundles,)).await.unwrap(); + let first = response[0][0].clone().ensure_ok().unwrap(); + let third = response[2][0].clone().ensure_ok().unwrap(); + + assert_eq!(U256::from_be_slice(&first[..32]), U256::from(99)); + assert_eq!(U256::from_be_slice(&first[32..]), U256::from(1_234)); + assert_eq!(U256::from_be_slice(&third[..32]), U256::from(101)); + assert_eq!(U256::from_be_slice(&third[32..]), U256::from(1_236)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn can_call_bundle() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + let wallets = handle.dev_wallets().collect::>(); + let sender = wallets[1].address(); + let coinbase = wallets[2].address(); + let initial_coinbase_balance = provider.get_balance(coinbase).await.unwrap(); + let fees = provider.estimate_eip1559_fees().await.unwrap(); + let base_fee = api.base_fee().unwrap().unwrap().to::(); + + let mut raw_transactions = Vec::new(); + let mut transaction_hashes = Vec::new(); + let mut expected_gas_fees = U256::ZERO; + for (nonce, value) in [(0, 1), (1, 2)] { + let mut tx = TxEip1559 { + chain_id: CHAIN_ID, + nonce, + gas_limit: 21_000, + max_fee_per_gas: fees.max_fee_per_gas, + max_priority_fee_per_gas: fees.max_priority_fee_per_gas, + to: coinbase.into(), + value: U256::from(value), + ..Default::default() + }; + let signature = wallets[1].sign_transaction_sync(&mut tx).unwrap(); + let tx = tx.into_signed(signature); + let gas_price = tx.effective_tip_per_gas(base_fee).unwrap(); + expected_gas_fees += U256::from(gas_price) * U256::from(21_000); + transaction_hashes.push(*tx.hash()); + + let mut encoded = Vec::new(); + tx.eip2718_encode(&mut encoded); + raw_transactions.push(encoded.into()); + } + + let response: EthCallBundleResponse = provider + .client() + .request( + "eth_callBundle", + (EthCallBundle { + txs: raw_transactions.clone(), + block_number: 1, + state_block_number: BlockNumberOrTag::Latest, + coinbase: Some(coinbase), + ..Default::default() + },), + ) + .await + .unwrap(); + + let mut expected_bundle_hash = Keccak256::new(); + for hash in transaction_hashes { + expected_bundle_hash.update(hash); + } + assert_eq!(response.bundle_hash, expected_bundle_hash.finalize()); + assert_eq!(response.state_block_number, 0); + assert_eq!(response.total_gas_used, 42_000); + assert_eq!(response.gas_fees, expected_gas_fees); + assert_eq!(response.eth_sent_to_coinbase, U256::from(3)); + assert_eq!(response.coinbase_diff, expected_gas_fees + U256::from(3)); + assert_eq!( + response.bundle_gas_price, + response.coinbase_diff / U256::from(response.total_gas_used) + ); + assert_eq!(response.results.len(), 2); + assert_eq!(response.results[0].from_address, sender); + assert_eq!(response.results[0].to_address, Some(coinbase)); + assert_eq!(response.results[0].eth_sent_to_coinbase, U256::ONE); + assert_eq!(response.results[1].eth_sent_to_coinbase, U256::from(2)); + assert!(response.results.iter().all(|result| result.revert.is_none())); + + assert_eq!(provider.get_balance(coinbase).await.unwrap(), initial_coinbase_balance); + + let empty_bundle: Result = provider + .client() + .request( + "eth_callBundle", + (EthCallBundle { + block_number: 1, + state_block_number: BlockNumberOrTag::Latest, + ..Default::default() + },), + ) + .await; + assert!(empty_bundle.unwrap_err().to_string().contains("bundle missing txs")); + + let zero_block: Result = provider + .client() + .request( + "eth_callBundle", + (EthCallBundle { + txs: vec![raw_transactions[0].clone()], + state_block_number: BlockNumberOrTag::Latest, + ..Default::default() + },), + ) + .await; + assert!(zero_block.unwrap_err().to_string().contains("bundle missing blockNumber")); +} + #[tokio::test(flavor = "multi_thread")] async fn can_mine_while_mining() { let (api, _) = spawn(NodeConfig::test()).await; @@ -431,9 +802,135 @@ async fn can_send_raw_tx_sync() { let mut encoded = Vec::new(); tx.eip2718_encode(&mut encoded); - let receipt = api.send_raw_transaction_sync(encoded.into()).await.unwrap(); - assert_eq!(receipt.from, wallets[1].address()); - assert_eq!(receipt.to, tx.to()); + let receipt = api.send_raw_transaction_sync(encoded.into(), None).await.unwrap(); + assert_eq!(receipt.from(), wallets[1].address()); + assert_eq!(receipt.to(), tx.to()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn can_parse_raw_tx_sync_timeout() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + let one_arg: Result = provider + .client() + .request("eth_sendRawTransactionSync", (alloy_primitives::Bytes::default(),)) + .await; + assert!(one_arg.unwrap_err().to_string().contains("Empty transaction data")); + + let null_timeout: Result = provider + .client() + .request( + "eth_sendRawTransactionSync", + (alloy_primitives::Bytes::default(), Option::::None), + ) + .await; + assert!(null_timeout.unwrap_err().to_string().contains("Empty transaction data")); + + let timeout: Result = provider + .client() + .request("eth_sendRawTransactionSync", (alloy_primitives::Bytes::default(), 1u64)) + .await; + assert!(timeout.unwrap_err().to_string().contains("Empty transaction data")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn raw_tx_sync_timeout_returns_transaction_hash() { + let (_api, handle) = spawn(NodeConfig::test().with_no_mining(true)).await; + let provider = http_provider(&handle.http_endpoint()); + + let wallets = handle.dev_wallets().collect::>(); + let eip1559_est = provider.estimate_eip1559_fees().await.unwrap(); + let mut tx = TxEip1559 { + max_fee_per_gas: eip1559_est.max_fee_per_gas, + max_priority_fee_per_gas: eip1559_est.max_priority_fee_per_gas, + gas_limit: 100000, + chain_id: 31337, + to: alloy_primitives::TxKind::Call(wallets[0].address()), + ..Default::default() + }; + let signature = wallets[1].sign_transaction_sync(&mut tx).unwrap(); + let tx = tx.into_signed(signature); + let expected_hash = *tx.hash(); + let mut encoded = Vec::new(); + tx.eip2718_encode(&mut encoded); + + let response: Result = provider + .client() + .request("eth_sendRawTransactionSync", (alloy_primitives::Bytes::from(encoded), 1u64)) + .await; + let error = response.unwrap_err(); + assert_eq!(error.tx_hash_data(), Some(expected_hash)); + let error = error.as_error_resp().unwrap(); + assert_eq!(error.code, 4); + assert_eq!(error.message, "Transaction confirmation timeout"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn can_send_raw_transaction_conditional() { + let node_config = NodeConfig::test().with_hardfork(Some(EthereumHardfork::Prague.into())); + let (_api, handle) = spawn(node_config).await; + let provider = http_provider(&handle.http_endpoint()); + + let wallets = handle.dev_wallets().collect::>(); + let eip1559_est = provider.estimate_eip1559_fees().await.unwrap(); + + let from = wallets[0].address(); + let mut tx = TxEip1559 { + max_fee_per_gas: eip1559_est.max_fee_per_gas, + max_priority_fee_per_gas: eip1559_est.max_priority_fee_per_gas, + gas_limit: 100000, + chain_id: 31337, + to: alloy_primitives::TxKind::Call(from), + input: bytes!("11112222"), + ..Default::default() + }; + let signature = wallets[1].sign_transaction_sync(&mut tx).unwrap(); + + let tx = tx.into_signed(signature); + let mut encoded = Vec::new(); + tx.eip2718_encode(&mut encoded); + + let tx_hash = provider + .raw_request( + "eth_sendRawTransactionConditional".into(), + ( + alloy_primitives::Bytes::from(encoded), + TransactionConditional { block_number_min: Some(u64::MAX), ..Default::default() }, + ), + ) + .await + .unwrap(); + + let tx_hash = provider + .watch_pending_transaction(PendingTransactionConfig::new(tx_hash)) + .await + .unwrap() + .await + .unwrap(); + + let receipt = provider.get_transaction_receipt(tx_hash).await.unwrap().unwrap(); + assert_eq!(receipt.from(), wallets[1].address()); + assert_eq!(receipt.to(), tx.to()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn ignores_raw_transaction_conditional_prestate() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + let result: std::result::Result = provider + .raw_request( + "eth_sendRawTransactionConditional".into(), + ( + alloy_primitives::Bytes::default(), + TransactionConditional { block_number_min: Some(u64::MAX), ..Default::default() }, + ), + ) + .await; + + let err = result.unwrap_err().to_string(); + assert!(err.contains("Empty transaction data"), "{err}"); } #[tokio::test(flavor = "multi_thread")] @@ -452,17 +949,323 @@ async fn can_send_tx_sync() { .with_input(logger_bytecode); let receipt = api.send_transaction_sync(WithOtherFields::new(tx)).await.unwrap(); - assert_eq!(receipt.from, wallets[0].address()); + assert_eq!(receipt.from(), wallets[0].address()); +} + +// A fork resolves code it has not loaded yet through the upstream's `debug_codeByHash`. +#[tokio::test(flavor = "multi_thread")] +async fn can_get_code_by_hash_from_fork() { + let (origin_api, origin) = spawn(NodeConfig::test()).await; + let code = Bytes::from(B256::random()); + let code_hash = keccak256(&code); + origin_api.anvil_set_code(Address::random(), code.clone()).await.unwrap(); + assert_eq!(origin_api.debug_code_by_hash(code_hash, None).await.unwrap(), Some(code.clone())); + + let (api, _) = spawn(NodeConfig::test().with_eth_rpc_url(Some(origin.http_endpoint()))).await; + assert_eq!(api.debug_code_by_hash(code_hash, None).await.unwrap(), Some(code)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn can_debug_get_modified_accounts_by_number() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + let response: serde_json::Value = provider + .raw_request("debug_getModifiedAccountsByNumber".into(), (0u64, 1u64)) + .await + .unwrap(); + assert_eq!(response, serde_json::Value::Null); +} + +#[tokio::test(flavor = "multi_thread")] +async fn can_debug_free_os_memory() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + let response: serde_json::Value = + provider.raw_request("debug_freeOSMemory".into(), ()).await.unwrap(); + assert_eq!(response, serde_json::Value::Null); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fill_transaction_fills_chain_id() { + let (api, handle) = spawn(NodeConfig::test()).await; + let wallet = handle.dev_wallets().next().unwrap(); + let from = wallet.address(); + + let tx_req = TransactionRequest::default() + .with_from(from) + .with_to(Address::random()) + .with_gas_limit(21_000); + + let filled = api.fill_transaction(WithOtherFields::new(tx_req)).await.unwrap(); + + // Should fill with the chain id from provider + assert!(filled.tx.chain_id().is_some()); + assert_eq!(filled.tx.chain_id().unwrap(), CHAIN_ID); } #[tokio::test(flavor = "multi_thread")] -async fn can_get_code_by_hash() { - let (api, _) = - spawn(NodeConfig::test().with_eth_rpc_url(Some(rpc::next_http_archive_rpc_url()))).await; +async fn test_fill_transaction_fills_nonce() { + let (api, handle) = spawn(NodeConfig::test()).await; - // The code hash for DEFAULT_CREATE2_DEPLOYER_RUNTIME_CODE - let code_hash = b256!("2fa86add0aed31f33a762c9d88e807c475bd51d0f52bd0955754b2608f7e4989"); + let accounts: Vec<_> = handle.dev_wallets().collect(); + let signer: EthereumWallet = accounts[0].clone().into(); + let from = accounts[0].address(); + let to = accounts[1].address(); - let code = api.debug_code_by_hash(code_hash, None).await.unwrap(); - assert_eq!(&code.unwrap(), foundry_evm::constants::DEFAULT_CREATE2_DEPLOYER_RUNTIME_CODE); + let provider = http_provider_with_signer(&handle.http_endpoint(), signer); + + // Send a transaction to increment nonce + let tx = TransactionRequest::default().with_from(from).with_to(to).with_value(U256::from(100)); + let tx = WithOtherFields::new(tx); + provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + + // Now the account should have nonce 1 + let tx_req = TransactionRequest::default() + .with_from(from) + .with_to(to) + .with_value(U256::from(1000)) + .with_gas_limit(21_000); + + let filled = api.fill_transaction(WithOtherFields::new(tx_req)).await.unwrap(); + + assert_eq!(filled.tx.nonce(), 1); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fill_transaction_preserves_provided_fields() { + let (api, handle) = spawn(NodeConfig::test()).await; + let wallet = handle.dev_wallets().next().unwrap(); + let from = wallet.address(); + + let provided_nonce = 100u64; + let provided_gas_limit = 50_000u64; + + let tx_req = TransactionRequest::default() + .with_from(from) + .with_to(Address::random()) + .with_value(U256::from(1000)) + .with_nonce(provided_nonce) + .with_gas_limit(provided_gas_limit); + + let filled = api.fill_transaction(WithOtherFields::new(tx_req)).await.unwrap(); + + // Should preserve the provided nonce and gas limit + assert_eq!(filled.tx.nonce(), provided_nonce); + assert_eq!(filled.tx.gas_limit(), provided_gas_limit); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fill_transaction_fills_all_missing_fields() { + let (api, handle) = spawn(NodeConfig::test()).await; + let wallet = handle.dev_wallets().next().unwrap(); + let from = wallet.address(); + + // Create a simple transfer transaction with minimal fields + let tx_req = TransactionRequest::default().with_from(from).with_to(Address::random()); + + let filled = api.fill_transaction(WithOtherFields::new(tx_req)).await.unwrap(); + + // Should fill all required fields and be EIP-1559 + assert!(filled.tx.is_eip1559()); + assert!(filled.tx.gas_limit() > 0); + assert!(filled.tx.max_fee_per_gas() > 0); + assert!(filled.tx.max_priority_fee_per_gas().is_some()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fill_transaction_eip4844_blob_fee() { + let node_config = NodeConfig::test().with_hardfork(Some(EthereumHardfork::Cancun.into())); + let (api, handle) = spawn(node_config).await; + let wallet = handle.dev_wallets().next().unwrap(); + let from = wallet.address(); + + let mut builder = SidecarBuilder::::new(); + builder.ingest(b"dummy blob"); + let sidecar: BlobTransactionSidecar = builder.build().unwrap(); + + // EIP-4844 blob transaction with sidecar but no blob fee + let mut tx_req = TransactionRequest::default().with_from(from).with_to(Address::random()); + tx_req.sidecar = Some(sidecar.into()); + tx_req.transaction_type = Some(3); // EIP-4844 + + let filled = api.fill_transaction(WithOtherFields::new(tx_req)).await.unwrap(); + + // Blob transaction should have max_fee_per_blob_gas filled + assert!( + filled.tx.max_fee_per_blob_gas().is_some(), + "max_fee_per_blob_gas should be filled for blob tx" + ); + assert!(filled.tx.blob_versioned_hashes().is_some(), "blob_versioned_hashes should be present"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fill_transaction_eip4844_preserves_blob_fee() { + let node_config = NodeConfig::test().with_hardfork(Some(EthereumHardfork::Cancun.into())); + let (api, handle) = spawn(node_config).await; + let wallet = handle.dev_wallets().next().unwrap(); + let from = wallet.address(); + + let provided_blob_fee = 5_000_000u128; + + let mut builder = SidecarBuilder::::new(); + builder.ingest(b"dummy blob"); + let sidecar: BlobTransactionSidecar = builder.build().unwrap(); + + // EIP-4844 blob transaction with blob fee already set + let mut tx_req = TransactionRequest::default() + .with_from(from) + .with_to(Address::random()) + .with_max_fee_per_blob_gas(provided_blob_fee); + tx_req.sidecar = Some(sidecar.into()); + tx_req.transaction_type = Some(3); // EIP-4844 + + let filled = api.fill_transaction(WithOtherFields::new(tx_req)).await.unwrap(); + + // Should preserve the provided blob fee + assert_eq!( + filled.tx.max_fee_per_blob_gas(), + Some(provided_blob_fee), + "should preserve provided max_fee_per_blob_gas" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fill_transaction_non_blob_tx_no_blob_fee() { + let (api, handle) = spawn(NodeConfig::test()).await; + let wallet = handle.dev_wallets().next().unwrap(); + let from = wallet.address(); + + // EIP-1559 transaction without blob fields + let mut tx_req = TransactionRequest::default().with_from(from).with_to(Address::random()); + tx_req.transaction_type = Some(2); // EIP-1559 + + let filled = api.fill_transaction(WithOtherFields::new(tx_req)).await.unwrap(); + + // Non-blob transaction should NOT have blob fee filled + assert!( + filled.tx.max_fee_per_blob_gas().is_none(), + "max_fee_per_blob_gas should not be set for non-blob tx" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fill_transaction_reverts_on_gas_estimation_failure() { + let (api, handle) = spawn(NodeConfig::test()).await; + + let accounts: Vec<_> = handle.dev_wallets().collect(); + let signer: EthereumWallet = accounts[0].clone().into(); + let from = accounts[0].address(); + + let provider = http_provider_with_signer(&handle.http_endpoint(), signer); + + // Deploy VendingMachine contract + let contract = VendingMachine::deploy(&provider).await.unwrap(); + let contract_address = *contract.address(); + + // Call buy function with insufficient ether + let tx_req = TransactionRequest::default() + .with_from(from) + .with_to(contract_address) + .with_input(VendingMachine::buyCall { amount: U256::from(10) }.abi_encode()); + + // fill_transaction should fail because gas estimation fails due to revert + let result = api.fill_transaction(WithOtherFields::new(tx_req)).await; + + assert!(result.is_err(), "fill_transaction should return an error when gas estimation fails"); + let error_message = result.unwrap_err().to_string(); + assert!( + error_message.contains("execution reverted"), + "Error should indicate a revert, got: {error_message}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_send_transaction_uses_valid_fallback_gas_on_osaka() { + let (api, handle) = spawn( + NodeConfig::test() + .with_hardfork(Some(EthereumHardfork::Osaka.into())) + .enable_tx_gas_limit(true), + ) + .await; + + let accounts: Vec<_> = handle.dev_wallets().collect(); + let signer: EthereumWallet = accounts[0].clone().into(); + let from = accounts[0].address(); + + let provider = http_provider_with_signer(&handle.http_endpoint(), signer); + + // Deploy VendingMachine contract. + let contract = VendingMachine::deploy(&provider).await.unwrap(); + let contract_address = *contract.address(); + + // Call buy with insufficient ether and without gas limit. + let tx_req = TransactionRequest::default() + .with_from(from) + .with_to(contract_address) + .with_input(VendingMachine::buyCall { amount: U256::from(10) }.abi_encode()); + + let receipt = api.send_transaction_sync(WithOtherFields::new(tx_req)).await.unwrap(); + assert!(!receipt.status(), "transaction should preserve send-path revert semantics"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn can_get_execution_witness() { + let (api, handle) = spawn(NodeConfig::test()).await; + + let accounts: Vec<_> = handle.dev_wallets().collect(); + let signer: EthereumWallet = accounts[0].clone().into(); + let from = accounts[0].address(); + let to = accounts[1].address(); + + let provider = http_provider_with_signer(&handle.http_endpoint(), signer); + + // Deploy a contract so the witness contains code and storage. + let contract = SimpleStorage::deploy(&provider, "initial".to_string()).await.unwrap(); + let receipt = contract + .setValue("updated".to_string()) + .from(from) + .send() + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + let number = receipt.block_number.unwrap(); + + // No witness exists for the genesis block since it has no parent state. + api.debug_execution_witness(BlockNumberOrTag::Number(0)).await.unwrap_err(); + + let witness: ExecutionWitness = provider + .client() + .request("debug_executionWitness", (BlockNumberOrTag::Number(number),)) + .await + .unwrap(); + + // The witness contains the full parent state trie, so the parent state root must be among the + // collected nodes. + let parent = provider.get_block(BlockId::number(number - 1)).await.unwrap().unwrap(); + assert!(witness.state.iter().any(|node| keccak256(node) == parent.header.state_root)); + + // Preimages of the touched account addresses and the deployed code are included. + assert!(witness.keys.iter().any(|key| key.as_ref() == from.as_slice())); + assert!(witness.keys.iter().any(|key| key.as_ref() == to.as_slice())); + assert!(witness.keys.iter().any(|key| key.len() == 32)); + assert!(!witness.codes.is_empty()); + + // Ancestor headers start at the parent block. + assert_eq!(witness.headers.len(), number as usize); + assert_eq!(keccak256(&witness.headers[0]), parent.header.hash); + + let tx = TransactionRequest::default().with_from(from).with_to(to).with_value(U256::ONE); + provider.send_transaction(WithOtherFields::new(tx)).await.unwrap().get_receipt().await.unwrap(); + + // Witnesses for older blocks are served from the state history. + let historical: ExecutionWitness = provider + .client() + .request("debug_executionWitness", (BlockNumberOrTag::Number(number),)) + .await + .unwrap(); + assert_eq!(historical, witness); } diff --git a/crates/anvil/tests/it/base.rs b/crates/anvil/tests/it/base.rs new file mode 100644 index 0000000000000..9bda50d1ae946 --- /dev/null +++ b/crates/anvil/tests/it/base.rs @@ -0,0 +1,1994 @@ +//! Tests for Base chain support. + +use crate::utils::http_provider_with_signer; +use alloy_consensus::{Sealed, Typed2718}; +use alloy_eips::{Decodable2718, Encodable2718}; +use alloy_network::{EthereumWallet, ReceiptResponse, TransactionBuilder}; +use alloy_primitives::{Address, B256, Bytes, TxKind, U256, address, b256, keccak256}; +use alloy_provider::{ + Provider, + ext::{DebugApi, TxPoolApi}, +}; +use alloy_rpc_types::{ + BlockId, TransactionRequest, + anvil::Forking, + trace::geth::{ + CallConfig, GethDebugBuiltInTracerType, GethDebugTracerType, GethDebugTracingCallOptions, + GethDebugTracingOptions, GethTrace, + }, +}; +use alloy_serde::WithOtherFields; +use alloy_signer::SignerSync; +use alloy_signer_local::PrivateKeySigner; +use alloy_sol_types::{SolCall, sol}; +use anvil::{NodeConfig, spawn}; +use base_common_consensus::{ + BaseTimeDepositSource, Call, DepositSourceDomain, Eip8130Constants, Eip8130Contracts, + Eip8130Signed, Predeploys, SystemAddresses, TxEip8130, +}; +use base_common_evm::BaseTime; +use base_common_precompiles::NonceManagerStorage; +use base_protocol::{BaseTimeUpdateTx, L1BlockInfoTx}; +use foundry_common::provider::RetryProvider; +use foundry_config::Config; +use foundry_evm::{hardforks::BaseUpgrade, opts::EvmOpts}; +use foundry_evm_networks::NetworkConfigs; +use foundry_primitives::FoundryTxEnvelope; +use op_alloy_consensus::TxDeposit; +use std::time::Duration; + +const ACTIVATION_REGISTRY: Address = address!("8453000000000000000000000000000000000001"); +const MAINNET_BERYL_ACTIVATION_ADMIN: Address = + address!("ce3a3bee7e72e2a24079f3c0cb3b97740ed425a9"); +const NONCE_MANAGER: Address = address!("813000000000000000000000000000000000aa01"); +const BASE_CREATE2_DEPLOYER: Address = address!("13b0D85CcB8bf860b6b79AF3029fCA081AE9beF2"); + +sol! { + interface IBaseTime { + error BaseTime_InvalidTimestampMillisPart(); + error BaseTime_NotDepositor(); + + function timestampMillisPart() external view returns (uint16); + function timestampMs() external view returns (uint64); + function setTimestampMillisPart(uint16 timestampMillisPart) external; + } +} + +fn eip8130_envelope_with( + signer: &PrivateKeySigner, + calls: Vec>, + metadata: Bytes, +) -> FoundryTxEnvelope { + let tx = TxEip8130 { + chain_id: 8453, + sender: None, + nonce_key: U256::ZERO, + nonce_sequence: 0, + valid_after: 0, + valid_before: 0, + max_priority_fee_per_gas: 0, + max_fee_per_gas: 1_000_000_000, + gas_limit: 200_000, + account_changes: Vec::new(), + calls, + metadata, + payer: None, + }; + let signature = signer.sign_hash_sync(&tx.sender_signature_hash()).unwrap(); + FoundryTxEnvelope::Eip8130(Eip8130Signed::new(tx, signature.as_bytes().into(), Bytes::new())) +} + +fn eip8130_envelope(signer: &PrivateKeySigner) -> FoundryTxEnvelope { + eip8130_envelope_with(signer, Vec::new(), Bytes::new()) +} + +fn malformed_configured_eip8130_envelope(signer: &PrivateKeySigner) -> FoundryTxEnvelope { + malformed_configured_eip8130_envelope_with_nonce(signer, U256::ZERO, 0) +} + +fn malformed_configured_eip8130_envelope_with_nonce( + signer: &PrivateKeySigner, + nonce_key: U256, + nonce_sequence: u64, +) -> FoundryTxEnvelope { + let tx = TxEip8130 { + chain_id: 8453, + sender: Some(signer.address()), + nonce_key, + nonce_sequence, + valid_after: 0, + valid_before: 0, + max_priority_fee_per_gas: 0, + max_fee_per_gas: 1_000_000_000, + gas_limit: 200_000, + account_changes: Vec::new(), + calls: Vec::new(), + metadata: Bytes::new(), + payer: None, + }; + let bare_auth = signer.sign_hash_sync(&tx.sender_signature_hash()).unwrap().as_bytes().into(); + FoundryTxEnvelope::Eip8130(Eip8130Signed::new(tx, bare_auth, Bytes::new())) +} + +fn eip8130_envelope_with_nonce( + signer: &PrivateKeySigner, + nonce_key: U256, + nonce_sequence: u64, + valid_before: u64, +) -> FoundryTxEnvelope { + eip8130_envelope_with_nonce_and_fee( + signer, + nonce_key, + nonce_sequence, + valid_before, + 1_000_000_000, + ) +} + +fn eip8130_envelope_with_nonce_and_fee( + signer: &PrivateKeySigner, + nonce_key: U256, + nonce_sequence: u64, + valid_before: u64, + max_fee_per_gas: u128, +) -> FoundryTxEnvelope { + let tx = TxEip8130 { + chain_id: 8453, + sender: None, + nonce_key, + nonce_sequence, + valid_after: 0, + valid_before, + max_priority_fee_per_gas: 0, + max_fee_per_gas, + gas_limit: 200_000, + account_changes: Vec::new(), + calls: Vec::new(), + metadata: Bytes::new(), + payer: None, + }; + let signature = signer.sign_hash_sync(&tx.sender_signature_hash()).unwrap(); + FoundryTxEnvelope::Eip8130(Eip8130Signed::new(tx, signature.as_bytes().into(), Bytes::new())) +} + +fn eip8130_envelope_with_channel_calls( + signer: &PrivateKeySigner, + nonce_key: U256, + calls: Vec>, +) -> FoundryTxEnvelope { + let tx = TxEip8130 { + chain_id: 8453, + sender: None, + nonce_key, + nonce_sequence: 0, + valid_after: 0, + valid_before: 0, + max_priority_fee_per_gas: 0, + max_fee_per_gas: 1_000_000_000, + gas_limit: 200_000, + account_changes: Vec::new(), + calls, + metadata: Bytes::new(), + payer: None, + }; + let signature = signer.sign_hash_sync(&tx.sender_signature_hash()).unwrap(); + FoundryTxEnvelope::Eip8130(Eip8130Signed::new(tx, signature.as_bytes().into(), Bytes::new())) +} + +fn sponsored_eip8130_envelope( + sender: &PrivateKeySigner, + payer: &PrivateKeySigner, +) -> FoundryTxEnvelope { + sponsored_eip8130_envelope_with_nonce(sender, payer, U256::ZERO, 0) +} + +fn sponsored_eip8130_envelope_with_nonce( + sender: &PrivateKeySigner, + payer: &PrivateKeySigner, + nonce_key: U256, + nonce_sequence: u64, +) -> FoundryTxEnvelope { + let tx = TxEip8130 { + chain_id: 8453, + sender: None, + nonce_key, + nonce_sequence, + valid_after: 0, + valid_before: 0, + max_priority_fee_per_gas: 0, + max_fee_per_gas: 1_000_000_000, + gas_limit: 200_000, + account_changes: Vec::new(), + calls: Vec::new(), + metadata: Bytes::new(), + payer: Some(payer.address()), + }; + let sender_auth = + sender.sign_hash_sync(&tx.sender_signature_hash()).unwrap().as_bytes().to_vec(); + let payer_signature = payer.sign_hash_sync(&tx.payer_signature_hash(sender.address())).unwrap(); + let mut payer_auth = Eip8130Constants::K1_AUTHENTICATOR.to_vec(); + payer_auth.extend_from_slice(&payer_signature.as_bytes()); + FoundryTxEnvelope::Eip8130(Eip8130Signed::new(tx, sender_auth.into(), payer_auth.into())) +} + +fn eip8130_simulation_request(sender: Address) -> WithOtherFields { + serde_json::from_value(serde_json::json!({ + "from": sender, + "type": "0x79", + "calls": [], + "maxFeePerGas": "0x3b9aca00", + "gas": "0x30d40" + })) + .unwrap() +} + +fn eip8130_simulation_request_with_call( + sender: Address, + target: Address, +) -> WithOtherFields { + serde_json::from_value(serde_json::json!({ + "from": sender, + "calls": [[{ "to": target, "data": "0x" }]], + "maxFeePerGas": "0x3b9aca00", + "gas": "0x30d40" + })) + .unwrap() +} + +fn eip8130_auth_blob(authenticator: Address, data_len: usize) -> Bytes { + let mut blob = authenticator.to_vec(); + blob.resize(blob.len() + data_len, 0xff); + blob.into() +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_node_info_and_call_use_native_evm() { + let config = NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Beryl.into())); + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + + let node_info = api.anvil_node_info().await.unwrap(); + assert_eq!(node_info.network.as_deref(), Some("base")); + assert_eq!(node_info.hard_fork, "Beryl"); + + let selector = &keccak256("admin()")[..4]; + let output = provider + .call( + TransactionRequest::default() + .with_to(ACTIVATION_REGISTRY) + .with_input(Bytes::copy_from_slice(selector)) + .into(), + ) + .await + .unwrap(); + assert_eq!(output.len(), 32); + assert_eq!(Address::from_slice(&output[12..]), MAINNET_BERYL_ACTIVATION_ADMIN); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_activation_admin_override_is_used() { + let admin = Address::repeat_byte(0xaa); + let config = NodeConfig::test_base() + .with_hardfork(Some(BaseUpgrade::Beryl.into())) + .with_base_activation_admin(Some(admin)); + let (_api, handle) = spawn(config).await; + let output = handle + .http_provider() + .call( + TransactionRequest::default() + .with_to(ACTIVATION_REGISTRY) + .with_input(Bytes::copy_from_slice(&keccak256("admin()")[..4])) + .into(), + ) + .await + .unwrap(); + + assert_eq!(Address::from_slice(&output[12..]), admin); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_azul_excludes_beryl_precompiles() { + let config = NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Azul.into())); + let (_api, handle) = spawn(config).await; + let output = handle + .http_provider() + .call( + TransactionRequest::default() + .with_to(ACTIVATION_REGISTRY) + .with_input(Bytes::copy_from_slice(&keccak256("admin()")[..4])) + .into(), + ) + .await + .unwrap(); + + assert!(output.is_empty()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_anvil_node_info_infers_native_network() { + let (_api, handle) = spawn(NodeConfig::test_base()).await; + let mut evm_opts = Config::figment().extract::().unwrap(); + evm_opts.fork_url = Some(handle.http_endpoint()); + assert_eq!(evm_opts.networks, NetworkConfigs::default()); + + evm_opts.infer_network_from_fork().await.unwrap(); + + assert!(evm_opts.networks.is_base()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_fork_call_and_trace_use_native_evm() { + let source_config = NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Beryl.into())); + let (source_api, source_handle) = spawn(source_config).await; + source_api.mine_one().await.unwrap(); + let target_config = NodeConfig::test_base() + .with_hardfork(Some(BaseUpgrade::Beryl.into())) + .with_eth_rpc_url(Some(source_handle.http_endpoint())); + let (_target_api, target_handle) = spawn(target_config).await; + let provider = target_handle.http_provider(); + let selector = Bytes::copy_from_slice(&keccak256("admin()")[..4]); + let call = TransactionRequest::default().with_to(ACTIVATION_REGISTRY).with_input(selector); + + let output = provider.call(WithOtherFields::new(call.clone())).await.unwrap(); + assert_eq!(Address::from_slice(&output[12..]), MAINNET_BERYL_ACTIVATION_ADMIN); + + let trace = provider + .debug_trace_call( + WithOtherFields::new(call), + BlockId::latest(), + GethDebugTracingCallOptions::default(), + ) + .await + .unwrap(); + let GethTrace::Default(frame) = trace else { panic!("expected default trace") }; + assert!(!frame.failed); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_standalone_mines_ordinary_transaction() { + let config = NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Beryl.into())); + let (_api, handle) = spawn(config).await; + let accounts: Vec<_> = handle.dev_wallets().collect(); + let from = accounts[0].address(); + let to = accounts[1].address(); + let signer: EthereumWallet = accounts[0].clone().into(); + let provider = http_provider_with_signer(&handle.http_endpoint(), signer); + let value = U256::from(1_234); + let before = provider.get_balance(to).await.unwrap(); + + let receipt = provider + .send_transaction( + TransactionRequest::default() + .with_from(from) + .with_to(to) + .with_value(value) + .with_gas_limit(21_000) + .into(), + ) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + + assert!(receipt.status()); + assert_eq!(receipt.from(), from); + assert_eq!(receipt.to(), Some(to)); + assert_eq!(provider.get_balance(to).await.unwrap(), before + value); + assert!(provider.get_balance(Predeploys::L1_FEE_VAULT).await.unwrap() > U256::ZERO); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_standalone_denim_initializes_base_time() { + let config = NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Denim.into())); + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + + assert!(!provider.get_code_at(Predeploys::BASE_TIME).await.unwrap().is_empty()); + + let millis_part = provider + .call( + TransactionRequest::default() + .with_to(Predeploys::BASE_TIME) + .with_input(IBaseTime::timestampMillisPartCall {}.abi_encode()) + .into(), + ) + .block(BlockId::latest()) + .await + .unwrap(); + assert_eq!(IBaseTime::timestampMillisPartCall::abi_decode_returns(&millis_part).unwrap(), 0); + + // Mine through the protocol path. The BaseTime deposit is sent by the canonical depositor, + // whose nonce must not be reused by a test transaction. + api.mine_one().await.unwrap(); + + let millis_part = provider + .call( + TransactionRequest::default() + .with_to(Predeploys::BASE_TIME) + .with_input(IBaseTime::timestampMillisPartCall {}.abi_encode()) + .into(), + ) + .block(BlockId::latest()) + .await + .unwrap(); + assert_eq!(IBaseTime::timestampMillisPartCall::abi_decode_returns(&millis_part).unwrap(), 200); + + let timestamp_ms = provider + .call( + TransactionRequest::default() + .with_to(Predeploys::BASE_TIME) + .with_input(IBaseTime::timestampMsCall {}.abi_encode()) + .into(), + ) + .block(BlockId::latest()) + .await + .unwrap(); + let timestamp_ms = IBaseTime::timestampMsCall::abi_decode_returns(×tamp_ms).unwrap(); + let block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + assert_eq!(timestamp_ms, block.header.timestamp * 1_000 + 200); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_standalone_denim_mines_base_time_updates() { + let config = NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Denim.into())); + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + + for expected_millis_part in [200, 400, 600, 800, 0] { + api.mine_one().await.unwrap(); + let output = provider + .call( + TransactionRequest::default() + .with_to(Predeploys::BASE_TIME) + .with_input(IBaseTime::timestampMillisPartCall {}.abi_encode()) + .into(), + ) + .block(BlockId::latest()) + .await + .unwrap(); + assert_eq!( + IBaseTime::timestampMillisPartCall::abi_decode_returns(&output).unwrap(), + expected_millis_part + ); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_standalone_denim_system_transactions_are_valid() { + let config = NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Denim.into())); + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + + api.mine_one().await.unwrap(); + + let block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + let hashes = block.transactions.hashes().collect::>(); + assert_eq!(hashes.len(), 2, "Denim blocks contain the L1-info and BaseTime deposits"); + + let l1_info = FoundryTxEnvelope::decode_2718( + &mut api.raw_transaction(hashes[0]).await.unwrap().unwrap().as_ref(), + ) + .unwrap(); + let FoundryTxEnvelope::Deposit(l1_info) = l1_info else { + panic!("tx[0] must be the L1-info deposit"); + }; + assert_eq!(l1_info.from, SystemAddresses::DEPOSITOR_ACCOUNT); + assert_eq!(l1_info.to, TxKind::Call(Predeploys::L1_BLOCK_INFO)); + assert!(!l1_info.is_system_transaction); + let info = L1BlockInfoTx::decode_calldata(&l1_info.input).unwrap(); + assert_eq!(info.id().number, block.header.number); + assert_eq!(info.time(), block.header.timestamp); + assert_eq!( + provider.get_storage_at(Predeploys::L1_BLOCK_INFO, U256::ZERO).await.unwrap(), + U256::from(block.header.number) | (U256::from(block.header.timestamp) << 64) + ); + + let base_time = FoundryTxEnvelope::decode_2718( + &mut api.raw_transaction(hashes[1]).await.unwrap().unwrap().as_ref(), + ) + .unwrap(); + let FoundryTxEnvelope::Deposit(base_time) = base_time else { + panic!("tx[1] must be the BaseTime deposit"); + }; + assert_eq!(base_time.from, SystemAddresses::DEPOSITOR_ACCOUNT); + assert_eq!(base_time.to, TxKind::Call(Predeploys::BASE_TIME)); + assert_eq!( + base_time.encoded_2718(), + BaseTimeUpdateTx::new(200).unwrap().into_deposit_tx(block.header.number).encoded_2718() + ); + assert_eq!( + base_time.source_hash, + DepositSourceDomain::BaseTime(BaseTimeDepositSource { block_number: block.header.number }) + .source_hash() + ); + + // Block hashes are canonical deposit hashes and resolve through the transaction APIs. + for hash in hashes { + let raw = api.raw_transaction(hash).await.unwrap().unwrap(); + assert_eq!(keccak256(&raw), hash); + let receipt = provider.get_transaction_receipt(hash).await.unwrap().unwrap(); + assert_eq!(receipt.transaction_hash, hash); + assert!(receipt.status()); + assert!(provider.get_transaction_by_hash(hash).await.unwrap().is_some()); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_standalone_denim_pending_state_includes_system_transactions() { + let config = NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Denim.into())); + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + let call = WithOtherFields::new( + TransactionRequest::default() + .with_to(Predeploys::BASE_TIME) + .with_input(IBaseTime::timestampMillisPartCall {}.abi_encode()), + ); + + for _ in 0..4 { + api.mine_one().await.unwrap(); + } + let latest = provider.call(call.clone()).block(BlockId::latest()).await.unwrap(); + assert_eq!(IBaseTime::timestampMillisPartCall::abi_decode_returns(&latest).unwrap(), 800); + + // Block 5 rolls over to a new second; pending state must already reflect its deposit. + let pending = provider.call(call.clone()).block(BlockId::pending()).await.unwrap(); + assert_eq!(IBaseTime::timestampMillisPartCall::abi_decode_returns(&pending).unwrap(), 0); + + api.mine_one().await.unwrap(); + let mined = provider.call(call).block(BlockId::latest()).await.unwrap(); + assert_eq!(mined, pending); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_fork_denim_preserves_l1_fee_state() { + let source_config = NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Beryl.into())); + let (source_api, source_handle) = spawn(source_config).await; + // Give the source chain L1 fee parameters that differ from the standalone defaults. + for slot in 0..9u64 { + let value = match slot { + 3 => (U256::from(1_234_567) << 96) | (U256::from(7_654) << 64) | U256::from(17), + 8 => (U256::from(650) << 96) | (U256::from(42) << 64) | U256::from(789), + _ => U256::from(0x1234_5678_u64 + slot), + }; + source_api + .anvil_set_storage_at(Predeploys::L1_BLOCK_INFO, U256::from(slot), B256::from(value)) + .await + .unwrap(); + } + source_api.mine_one().await.unwrap(); + assert!( + source_handle.http_provider().get_code_at(Predeploys::BASE_TIME).await.unwrap().is_empty() + ); + + let target_config = NodeConfig::test_base() + .with_hardfork(Some(BaseUpgrade::Denim.into())) + .with_eth_rpc_url(Some(source_handle.http_endpoint())); + let (target_api, target_handle) = spawn(target_config).await; + let provider = target_handle.http_provider(); + + let l1_block_state = async || { + let mut slots = Vec::new(); + for slot in 0..9u64 { + slots.push( + provider.get_storage_at(Predeploys::L1_BLOCK_INFO, U256::from(slot)).await.unwrap(), + ); + } + slots + }; + let mut expected = l1_block_state().await; + // Continuing the same L1 origin advances its sequence and retains all fee fields. + expected[3] += U256::ONE; + target_api.mine_one().await.unwrap(); + assert_eq!(l1_block_state().await, expected, "fork L1Block fee state must be preserved"); + + // Both protocol deposits prefix forked Denim blocks and execute successfully. + let block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + let hashes = block.transactions.hashes().collect::>(); + assert_eq!(hashes.len(), 2); + for (index, hash) in hashes.into_iter().enumerate() { + let receipt = provider.get_transaction_receipt(hash).await.unwrap().unwrap(); + assert!(receipt.status()); + assert_eq!(receipt.transaction_index, Some(index as u64)); + } + let millis_part = provider + .call(WithOtherFields::new( + TransactionRequest::default() + .with_to(Predeploys::BASE_TIME) + .with_input(IBaseTime::timestampMillisPartCall {}.abi_encode()), + )) + .block(BlockId::latest()) + .await + .unwrap(); + assert_eq!(IBaseTime::timestampMillisPartCall::abi_decode_returns(&millis_part).unwrap(), 200); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_standalone_mines_deposit_transaction() { + let config = NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Beryl.into())); + let (_api, handle) = spawn(config).await; + let provider = handle.http_provider(); + let accounts: Vec<_> = handle.dev_wallets().collect(); + let from = accounts[0].address(); + let to = accounts[1].address(); + let sender_before = provider.get_balance(from).await.unwrap(); + let recipient_before = provider.get_balance(to).await.unwrap(); + let mint = 1_000; + let value = U256::from(600); + let envelope = FoundryTxEnvelope::Deposit(Sealed::new(TxDeposit { + source_hash: B256::with_last_byte(1), + from, + to: TxKind::Call(to), + mint, + value, + gas_limit: 100_000, + is_system_transaction: false, + input: Bytes::new(), + })); + + let pending = provider.send_raw_transaction(&envelope.encoded_2718()).await.unwrap(); + let receipt = pending.get_receipt().await.unwrap(); + + assert!(receipt.status()); + assert_eq!(provider.get_balance(from).await.unwrap(), sender_before + U256::from(mint) - value); + assert_eq!(provider.get_balance(to).await.unwrap(), recipient_before + value); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_standalone_includes_failed_deposit_transaction() { + let config = NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Beryl.into())); + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + let from = handle.dev_wallets().next().unwrap().address(); + let target = Address::repeat_byte(0xcc); + api.anvil_set_code(target, Bytes::from_static(&[0xfe])).await.unwrap(); + let sender_before = provider.get_balance(from).await.unwrap(); + let envelope = FoundryTxEnvelope::Deposit(Sealed::new(TxDeposit { + source_hash: B256::with_last_byte(2), + from, + to: TxKind::Call(target), + mint: 1_000, + value: U256::from(600), + gas_limit: 100_000, + is_system_transaction: false, + input: Bytes::new(), + })); + + let receipt = provider + .send_raw_transaction(&envelope.encoded_2718()) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + + assert!(!receipt.status()); + assert_eq!(provider.get_balance(from).await.unwrap(), sender_before + U256::from(1_000)); + assert_eq!(provider.get_balance(target).await.unwrap(), U256::ZERO); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_eip8130_call_and_estimate_are_read_only() { + let config = NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Zenith.into())); + let (_api, handle) = spawn(config).await; + let provider = handle.http_provider(); + let sender = handle.dev_wallets().next().unwrap().address(); + let request = eip8130_simulation_request(sender); + let balance_before = provider.get_balance(sender).await.unwrap(); + let nonce_before = provider.get_transaction_count(sender).await.unwrap(); + + let output = provider.call(request.clone()).await.unwrap(); + let estimate = provider.estimate_gas(request).await.unwrap(); + let access_list_error = + provider.create_access_list(&eip8130_simulation_request(sender)).await.unwrap_err(); + + assert!(output.is_empty()); + assert!(estimate > 0); + assert!(access_list_error.to_string().contains("does not support EIP-8130")); + assert_eq!(provider.get_balance(sender).await.unwrap(), balance_before); + assert_eq!(provider.get_transaction_count(sender).await.unwrap(), nonce_before); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_eip8130_estimate_includes_sponsored_payer_auth() { + let config = NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Zenith.into())); + let (_api, handle) = spawn(config).await; + let provider = handle.http_provider(); + let wallets: Vec<_> = handle.dev_wallets().collect(); + let sender = wallets[0].address(); + let payer = wallets[1].address(); + let self_pay = eip8130_simulation_request(sender); + let sponsored = serde_json::from_value(serde_json::json!({ + "from": sender, + "payer": payer, + "calls": [], + "maxFeePerGas": "0x3b9aca00", + "gas": "0x30d40" + })) + .unwrap(); + + let self_pay_gas = provider.estimate_gas(self_pay).await.unwrap(); + let sponsored_gas = provider.estimate_gas(sponsored).await.unwrap(); + + assert!( + sponsored_gas > self_pay_gas, + "self-pay estimate {self_pay_gas}, sponsored estimate {sponsored_gas}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_eip8130_estimate_prices_authentication_scheme() { + let config = NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Zenith.into())); + let (_api, handle) = spawn(config).await; + let provider = handle.http_provider(); + let sender = handle.dev_wallets().next().unwrap().address(); + let k1 = provider.estimate_gas(eip8130_simulation_request(sender)).await.unwrap(); + let p256 = provider + .estimate_gas( + serde_json::from_value(serde_json::json!({ + "from": sender, + "calls": [], + "senderAuth": eip8130_auth_blob(Eip8130Contracts::P256_AUTHENTICATOR, 128), + "maxFeePerGas": "0x3b9aca00", + "gas": "0x30d40" + })) + .unwrap(), + ) + .await + .unwrap(); + let webauthn = provider + .estimate_gas( + serde_json::from_value(serde_json::json!({ + "from": sender, + "calls": [], + "senderAuth": eip8130_auth_blob( + Eip8130Contracts::WEBAUTHN_AUTHENTICATOR, + 1024 + ), + "maxFeePerGas": "0x3b9aca00", + "gas": "0x30d40" + })) + .unwrap(), + ) + .await + .unwrap(); + + assert!(p256 > k1, "P-256 estimate {p256} must exceed K1 {k1}"); + assert!(webauthn > p256, "WebAuthn estimate {webauthn} must exceed P-256 {p256}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_eip8130_simulation_and_nonce_key_are_rejected_before_zenith() { + let config = NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Cobalt.into())); + let (_api, handle) = spawn(config).await; + let provider = handle.http_provider(); + let sender = handle.dev_wallets().next().unwrap().address(); + + let call_error = provider.call(eip8130_simulation_request(sender)).await.unwrap_err(); + let estimate_error = + provider.estimate_gas(eip8130_simulation_request(sender)).await.unwrap_err(); + let access_list_error = + provider.create_access_list(&eip8130_simulation_request(sender)).await.unwrap_err(); + let nonce_error = provider + .raw_request::<_, U256>("eth_getTransactionCount".into(), (sender, "latest", U256::ONE)) + .await + .unwrap_err(); + + for error in [ + call_error.to_string(), + estimate_error.to_string(), + access_list_error.to_string(), + nonce_error.to_string(), + ] { + assert!(error.contains("not active before the Zenith hard fork"), "{error}"); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_eip8130_estimate_validates_sender() { + let config = NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Zenith.into())); + let (_api, handle) = spawn(config).await; + let provider = handle.http_provider(); + let wallets: Vec<_> = handle.dev_wallets().collect(); + let sender = wallets[0].address(); + let other = wallets[1].address(); + let missing = serde_json::from_value(serde_json::json!({ + "type": "0x79", + "calls": [], + "maxFeePerGas": "0x3b9aca00", + "gas": "0x30d40" + })) + .unwrap(); + let error = provider.estimate_gas(missing).await.unwrap_err(); + assert!(error.to_string().contains("invalid EIP-8130 simulation request"), "{error}"); + + let explicit_sender = serde_json::from_value(serde_json::json!({ + "sender": sender, + "calls": [], + "maxFeePerGas": "0x3b9aca00", + "gas": "0x30d40" + })) + .unwrap(); + assert!(provider.estimate_gas(explicit_sender).await.unwrap() > 0); + + let mismatch = serde_json::from_value(serde_json::json!({ + "from": sender, + "sender": other, + "calls": [], + "maxFeePerGas": "0x3b9aca00", + "gas": "0x30d40" + })) + .unwrap(); + let error = provider.estimate_gas(mismatch).await.unwrap_err(); + assert!(error.to_string().contains("invalid EIP-8130 simulation request"), "{error}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_eip8130_estimate_surfaces_phase_revert() { + let config = NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Zenith.into())); + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + let sender = handle.dev_wallets().next().unwrap().address(); + let target = Address::repeat_byte(0xee); + api.anvil_set_code(target, Bytes::from_static(&[0x60, 0x00, 0x60, 0x00, 0xfd])).await.unwrap(); + let request = eip8130_simulation_request_with_call(sender, target); + + let error = provider.estimate_gas(request).await.unwrap_err(); + + assert!(error.to_string().contains("revert"), "{error}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_eip8130_debug_trace_call_inspects_protocol_calls() { + let config = NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Zenith.into())); + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + let sender = handle.dev_wallets().next().unwrap().address(); + let target = address!("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee01"); + api.anvil_set_code(target, Bytes::from_static(&[0x60, 0x01, 0x50, 0x00])).await.unwrap(); + + let trace = provider + .debug_trace_call( + eip8130_simulation_request_with_call(sender, target), + BlockId::latest(), + GethDebugTracingCallOptions::default().with_tracing_options( + GethDebugTracingOptions::default() + .with_tracer(GethDebugTracerType::from(GethDebugBuiltInTracerType::CallTracer)) + .with_call_config(CallConfig::default()), + ), + ) + .await + .unwrap(); + + let GethTrace::CallTracer(frame) = trace else { panic!("expected call trace") }; + assert_eq!(frame.calls.len(), 1); + assert_eq!(frame.calls[0].to, Some(target)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_eip8130_debug_trace_transaction_inspects_protocol_calls() { + let config = NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Zenith.into())); + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + let signer = handle.dev_wallets().next().unwrap().clone(); + let target = address!("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee02"); + api.anvil_set_code(target, Bytes::from_static(&[0x60, 0x01, 0x50, 0x00])).await.unwrap(); + let receipt = provider + .send_raw_transaction( + &eip8130_envelope_with( + &signer, + vec![vec![Call { to: target, data: Bytes::new() }]], + Bytes::new(), + ) + .encoded_2718(), + ) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + + let trace = provider + .debug_trace_transaction( + receipt.transaction_hash(), + GethDebugTracingOptions::default() + .with_tracer(GethDebugTracerType::from(GethDebugBuiltInTracerType::CallTracer)) + .with_call_config(CallConfig::default()), + ) + .await + .unwrap(); + + let GethTrace::CallTracer(frame) = trace else { panic!("expected call trace") }; + assert_eq!(frame.calls.len(), 1); + assert_eq!(frame.calls[0].to, Some(target)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_eip8130_nonce_key_rpc_reads_channel_state() { + let config = NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Zenith.into())); + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + let sender = handle.dev_wallets().next().unwrap().address(); + let nonce_key = U256::from(7); + let slot = NonceManagerStorage::nonce_slot(sender, nonce_key).unwrap(); + api.anvil_set_storage_at( + NonceManagerStorage::ADDRESS, + slot, + B256::from(B256::with_last_byte(42).0), + ) + .await + .unwrap(); + + let protocol = provider.get_transaction_count(sender).await.unwrap(); + let protocol_with_key = provider + .raw_request::<_, U256>("eth_getTransactionCount".into(), (sender, "latest", U256::ZERO)) + .await + .unwrap(); + let channel = provider + .raw_request::<_, U256>("eth_getTransactionCount".into(), (sender, "latest", nonce_key)) + .await + .unwrap(); + let max_error = provider + .raw_request::<_, U256>("eth_getTransactionCount".into(), (sender, "latest", U256::MAX)) + .await + .unwrap_err(); + + assert_eq!(protocol_with_key, U256::from(protocol)); + assert_eq!(channel, U256::from(42)); + assert!(max_error.to_string().contains("no per-channel counter"), "{max_error}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_eip8130_txpool_keeps_independent_nonce_channels() { + let config = NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Zenith.into())); + let (api, handle) = spawn(config).await; + api.anvil_set_auto_mine(false).await.unwrap(); + let provider = handle.http_provider(); + let signer = handle.dev_wallets().next().unwrap().clone(); + let sender = signer.address(); + let first_key = U256::ONE; + let second_key = U256::from(2); + + let first = provider + .send_raw_transaction(&eip8130_envelope_with_nonce(&signer, first_key, 0, 0).encoded_2718()) + .await + .unwrap(); + let second = provider + .send_raw_transaction( + &eip8130_envelope_with_nonce(&signer, second_key, 0, 0).encoded_2718(), + ) + .await + .unwrap(); + let status = provider.txpool_status().await.unwrap(); + assert_eq!(status.pending, 2); + let content = provider.txpool_content().await.unwrap(); + let pending = content.pending.get(&sender).unwrap(); + assert!(pending.contains_key("1:0")); + assert!(pending.contains_key("2:0")); + + api.mine_one().await.unwrap(); + assert!(first.get_receipt().await.unwrap().status()); + assert!(second.get_receipt().await.unwrap().status()); + + for nonce_key in [first_key, second_key] { + let nonce = provider + .raw_request::<_, U256>("eth_getTransactionCount".into(), (sender, "latest", nonce_key)) + .await + .unwrap(); + assert_eq!(nonce, U256::ONE); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_eip8130_txpool_orders_channel_heads_by_fee() { + let config = NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Zenith.into())); + let (api, handle) = spawn(config).await; + api.anvil_set_auto_mine(false).await.unwrap(); + let provider = handle.http_provider(); + let signer = handle.dev_wallets().next().unwrap().clone(); + + let low = provider + .send_raw_transaction( + &eip8130_envelope_with_nonce_and_fee(&signer, U256::ONE, 0, 0, 1_000_000_000) + .encoded_2718(), + ) + .await + .unwrap(); + let high = provider + .send_raw_transaction( + &eip8130_envelope_with_nonce_and_fee(&signer, U256::from(2), 0, 0, 2_000_000_000) + .encoded_2718(), + ) + .await + .unwrap(); + + api.mine_one().await.unwrap(); + let high = high.get_receipt().await.unwrap(); + let low = low.get_receipt().await.unwrap(); + // System deposits occupy the prefix of a Denim block; assert the channel ordering without + // assuming that the first user transaction is at block index zero. + assert_eq!(low.transaction_index, high.transaction_index.map(|index| index + 1)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_eip8130_txpool_promotes_filled_channel_gap() { + let config = NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Zenith.into())); + let (api, handle) = spawn(config).await; + api.anvil_set_auto_mine(false).await.unwrap(); + let provider = handle.http_provider(); + let signer = handle.dev_wallets().next().unwrap().clone(); + let sender = signer.address(); + let nonce_key = U256::from(11); + + let sequence_one = provider + .send_raw_transaction(&eip8130_envelope_with_nonce(&signer, nonce_key, 1, 0).encoded_2718()) + .await + .unwrap(); + let status = provider.txpool_status().await.unwrap(); + assert_eq!(status.pending, 0); + assert_eq!(status.queued, 1); + + let sequence_zero = provider + .send_raw_transaction(&eip8130_envelope_with_nonce(&signer, nonce_key, 0, 0).encoded_2718()) + .await + .unwrap(); + let status = provider.txpool_status().await.unwrap(); + assert_eq!(status.pending, 2); + assert_eq!(status.queued, 0); + + api.mine_one().await.unwrap(); + assert!(sequence_zero.get_receipt().await.unwrap().status()); + assert!(sequence_one.get_receipt().await.unwrap().status()); + let nonce = provider + .raw_request::<_, U256>("eth_getTransactionCount".into(), (sender, "latest", nonce_key)) + .await + .unwrap(); + assert_eq!(nonce, U256::from(2)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_eip8130_txpool_enforces_lane_replacement_price() { + let config = NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Zenith.into())); + let (api, handle) = spawn(config).await; + api.anvil_set_auto_mine(false).await.unwrap(); + let provider = handle.http_provider(); + let signer = handle.dev_wallets().next().unwrap().clone(); + let nonce_key = U256::from(12); + let original = provider + .send_raw_transaction( + &eip8130_envelope_with_nonce_and_fee(&signer, nonce_key, 0, 0, 1_000_000_000) + .encoded_2718(), + ) + .await + .unwrap(); + let original_hash = *original.tx_hash(); + let error = provider + .send_raw_transaction( + &eip8130_envelope_with_nonce_and_fee(&signer, nonce_key, 0, 0, 1_050_000_000) + .encoded_2718(), + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("replacement transaction underpriced"), "{error}"); + assert_eq!(provider.txpool_status().await.unwrap().pending, 1); + + let replacement = provider + .send_raw_transaction( + &eip8130_envelope_with_nonce_and_fee(&signer, nonce_key, 0, 0, 2_000_000_000) + .encoded_2718(), + ) + .await + .unwrap(); + let replacement_hash = *replacement.tx_hash(); + assert_ne!(original_hash, replacement_hash); + assert_eq!(provider.txpool_status().await.unwrap().pending, 1); + + api.mine_one().await.unwrap(); + assert!(provider.get_transaction_receipt(original_hash).await.unwrap().is_none()); + assert!(replacement.get_receipt().await.unwrap().status()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_eip8130_txpool_replaces_nonce_free_by_replay_id() { + let genesis_timestamp = + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); + let config = NodeConfig::test_base() + .with_hardfork(Some(BaseUpgrade::Zenith.into())) + .with_genesis_timestamp(Some(genesis_timestamp)); + let (api, handle) = spawn(config).await; + api.anvil_set_auto_mine(false).await.unwrap(); + let provider = handle.http_provider(); + let signer = handle.dev_wallets().next().unwrap().clone(); + let now = api.anvil_node_info().await.unwrap().current_block_timestamp; + let valid_before = (now + 10) * 1_000; + let original = provider + .send_raw_transaction( + &eip8130_envelope_with_nonce_and_fee( + &signer, + Eip8130Constants::NONCE_KEY_MAX, + 0, + valid_before, + 1_000_000_000, + ) + .encoded_2718(), + ) + .await + .unwrap(); + let original_hash = *original.tx_hash(); + let replacement = provider + .send_raw_transaction( + &eip8130_envelope_with_nonce_and_fee( + &signer, + Eip8130Constants::NONCE_KEY_MAX, + 0, + valid_before, + 2_000_000_000, + ) + .encoded_2718(), + ) + .await + .unwrap(); + let independent = provider + .send_raw_transaction( + &eip8130_envelope_with_nonce_and_fee( + &signer, + Eip8130Constants::NONCE_KEY_MAX, + 0, + valid_before + 1, + 1_000_000_000, + ) + .encoded_2718(), + ) + .await + .unwrap(); + let replacement_hash = *replacement.tx_hash(); + let independent_hash = *independent.tx_hash(); + assert_eq!(provider.txpool_status().await.unwrap().pending, 2); + + api.mine_one().await.unwrap(); + assert!(provider.get_transaction_receipt(original_hash).await.unwrap().is_none()); + assert!(provider.get_transaction_receipt(replacement_hash).await.unwrap().is_some()); + assert!(provider.get_transaction_receipt(independent_hash).await.unwrap().is_some()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_eip8130_rejects_mined_nonce_free_replay_at_admission() { + let genesis_timestamp = + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); + let config = NodeConfig::test_base() + .with_hardfork(Some(BaseUpgrade::Zenith.into())) + .with_genesis_timestamp(Some(genesis_timestamp)); + let (_api, handle) = spawn(config).await; + let provider = handle.http_provider(); + let signer = handle.dev_wallets().next().unwrap().clone(); + let valid_before = (genesis_timestamp + 20) * 1_000; + provider + .send_raw_transaction( + &eip8130_envelope_with_nonce_and_fee( + &signer, + Eip8130Constants::NONCE_KEY_MAX, + 0, + valid_before, + 1_000_000_000, + ) + .encoded_2718(), + ) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + + let error = provider + .send_raw_transaction( + &eip8130_envelope_with_nonce_and_fee( + &signer, + Eip8130Constants::NONCE_KEY_MAX, + 0, + valid_before, + 2_000_000_000, + ) + .encoded_2718(), + ) + .await + .unwrap_err(); + + assert!(error.to_string().contains("replay"), "{error}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_eip8130_txpool_drops_expired_nonce_free_transaction() { + let config = NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Zenith.into())); + let (api, handle) = spawn(config).await; + api.anvil_set_auto_mine(false).await.unwrap(); + let provider = handle.http_provider(); + let signer = handle.dev_wallets().next().unwrap().clone(); + let now = api.anvil_node_info().await.unwrap().current_block_timestamp; + let valid_before = (now + 1) * 1_000; + let pending = provider + .send_raw_transaction( + &eip8130_envelope_with_nonce(&signer, Eip8130Constants::NONCE_KEY_MAX, 0, valid_before) + .encoded_2718(), + ) + .await + .unwrap(); + let hash = *pending.tx_hash(); + + api.evm_increase_time(U256::from(2)).await.unwrap(); + api.mine_one().await.unwrap(); + + assert!(provider.get_transaction_receipt(hash).await.unwrap().is_none()); + let status = provider.txpool_status().await.unwrap(); + assert_eq!(status.pending, 0); + assert_eq!(status.queued, 0); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_eip8130_snapshot_revert_restores_channel_nonce() { + let config = NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Zenith.into())); + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + let signer = handle.dev_wallets().next().unwrap().clone(); + let sender = signer.address(); + let nonce_key = U256::from(13); + let snapshot = api.evm_snapshot().await.unwrap(); + + provider + .send_raw_transaction(&eip8130_envelope_with_nonce(&signer, nonce_key, 0, 0).encoded_2718()) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + let nonce = provider + .raw_request::<_, U256>("eth_getTransactionCount".into(), (sender, "latest", nonce_key)) + .await + .unwrap(); + assert_eq!(nonce, U256::ONE); + + assert!(api.evm_revert(snapshot).await.unwrap()); + let nonce = provider + .raw_request::<_, U256>("eth_getTransactionCount".into(), (sender, "latest", nonce_key)) + .await + .unwrap(); + assert_eq!(nonce, U256::ZERO); + + let valid_before = (api.anvil_node_info().await.unwrap().current_block_timestamp + 100) * 1_000; + let receipt = provider + .send_raw_transaction( + &eip8130_envelope_with_nonce(&signer, nonce_key, 0, valid_before).encoded_2718(), + ) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert!(receipt.status()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_eip8130_snapshot_revert_clears_pending_transactions() { + let config = NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Zenith.into())); + let (api, handle) = spawn(config).await; + api.anvil_set_auto_mine(false).await.unwrap(); + let provider = handle.http_provider(); + let signer = handle.dev_wallets().next().unwrap().clone(); + let snapshot = api.evm_snapshot().await.unwrap(); + let _pending = provider + .send_raw_transaction( + &eip8130_envelope_with_nonce(&signer, U256::from(15), 0, 0).encoded_2718(), + ) + .await + .unwrap(); + assert_eq!(provider.txpool_status().await.unwrap().pending, 1); + + assert!(api.evm_revert(snapshot).await.unwrap()); + + let status = provider.txpool_status().await.unwrap(); + assert_eq!(status.pending, 0); + assert_eq!(status.queued, 0); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_eip8130_state_cheat_clears_pending_transactions() { + let config = NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Zenith.into())); + let (api, handle) = spawn(config).await; + api.anvil_set_auto_mine(false).await.unwrap(); + let provider = handle.http_provider(); + let signer = handle.dev_wallets().next().unwrap().clone(); + let _pending = provider + .send_raw_transaction( + &eip8130_envelope_with_nonce(&signer, U256::from(16), 0, 0).encoded_2718(), + ) + .await + .unwrap(); + assert_eq!(provider.txpool_status().await.unwrap().pending, 1); + + api.anvil_set_balance(signer.address(), U256::ONE).await.unwrap(); + + let status = provider.txpool_status().await.unwrap(); + assert_eq!(status.pending, 0); + assert_eq!(status.queued, 0); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_standalone_mines_eip8130_transaction() { + let config = NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Zenith.into())); + let (_api, handle) = spawn(config).await; + let provider = handle.http_provider(); + let signer = handle.dev_wallets().next().unwrap().clone(); + let sender = signer.address(); + let before = provider.get_balance(sender).await.unwrap(); + assert_eq!(provider.get_code_at(NONCE_MANAGER).await.unwrap().as_ref(), &[0xef]); + let envelope = eip8130_envelope(&signer); + + let receipt = provider + .send_raw_transaction(&envelope.encoded_2718()) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + + assert!(receipt.status()); + let receipt_json = serde_json::to_value(&receipt).unwrap(); + assert!(receipt_json.get("phaseStatuses").is_none(), "{receipt_json}"); + assert_eq!(receipt_json["payer"], serde_json::to_value(sender).unwrap()); + let mined = provider.get_transaction_by_hash(receipt.tx_hash()).await.unwrap().unwrap(); + assert_eq!(mined.ty(), 0x79); + let mined_json = serde_json::to_value(mined).unwrap(); + assert_eq!(mined_json["tx"]["nonceKey"], "0x0", "{mined_json}"); + assert!(mined_json["tx"].get("calls").is_some()); + assert!(mined_json.get("senderAuth").is_some()); + assert!(provider.get_balance(sender).await.unwrap() < before); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_eip8130_rejects_protocol_nonce_replay_at_admission() { + let config = NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Zenith.into())); + let (_api, handle) = spawn(config).await; + let provider = handle.http_provider(); + let signer = handle.dev_wallets().next().unwrap().clone(); + provider + .send_raw_transaction(&eip8130_envelope(&signer).encoded_2718()) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + + let error = provider + .send_raw_transaction( + &eip8130_envelope_with(&signer, Vec::new(), Bytes::from_static(&[0x01])).encoded_2718(), + ) + .await + .unwrap_err(); + + assert!(error.to_string().contains("below the channel nonce"), "{error}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_eip8130_rejects_invalid_auth_at_admission() { + let config = NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Zenith.into())); + let (_api, handle) = spawn(config).await; + let provider = handle.http_provider(); + let signer = handle.dev_wallets().next().unwrap().clone(); + + let error = provider + .send_raw_transaction(&malformed_configured_eip8130_envelope(&signer).encoded_2718()) + .await + .unwrap_err(); + + assert!(error.to_string().contains("EIP-8130 transaction rejected"), "{error}"); + assert_eq!(provider.txpool_status().await.unwrap().pending, 0); + + let envelope = malformed_configured_eip8130_envelope_with_nonce(&signer, U256::ONE, 1); + let error = provider.send_raw_transaction(&envelope.encoded_2718()).await.unwrap_err(); + + assert!(error.to_string().contains("EIP-8130 transaction rejected"), "{error}"); + assert_eq!(provider.txpool_status().await.unwrap().queued, 0); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_eip8130_receipt_reports_phase_statuses_and_metadata() { + let config = NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Zenith.into())); + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + let signer = handle.dev_wallets().next().unwrap().clone(); + let sender = signer.address(); + let target = Address::repeat_byte(0xdd); + api.anvil_set_code(target, Bytes::from_static(&[0x00])).await.unwrap(); + let envelope = eip8130_envelope_with( + &signer, + vec![vec![Call { to: target, data: Bytes::new() }]], + Bytes::from_static(&[0xaa]), + ); + + let receipt = provider + .send_raw_transaction(&envelope.encoded_2718()) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + let value = serde_json::to_value(receipt).unwrap(); + + assert_eq!(value["phaseStatuses"], serde_json::json!(["0x1"])); + assert_eq!(value["payer"], serde_json::to_value(sender).unwrap()); + assert_eq!(value["metadata"], "0xaa"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_eip8130_sponsored_receipt_reports_payer() { + let config = NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Zenith.into())); + let (_api, handle) = spawn(config).await; + let provider = handle.http_provider(); + let sender = PrivateKeySigner::from_bytes(&B256::with_last_byte(0x42)).unwrap(); + let payer = handle.dev_wallets().next().unwrap().clone(); + assert_eq!(provider.get_balance(sender.address()).await.unwrap(), U256::ZERO); + let payer_before = provider.get_balance(payer.address()).await.unwrap(); + + let receipt = provider + .send_raw_transaction(&sponsored_eip8130_envelope(&sender, &payer).encoded_2718()) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert!(receipt.status()); + let value = serde_json::to_value(receipt).unwrap(); + + assert_eq!(value["payer"], serde_json::to_value(payer.address()).unwrap()); + assert_eq!(provider.get_balance(sender.address()).await.unwrap(), U256::ZERO); + assert!(provider.get_balance(payer.address()).await.unwrap() < payer_before); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_eip8130_sponsored_tx_rejects_unfunded_payer() { + let config = NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Zenith.into())); + let (_api, handle) = spawn(config).await; + let provider = handle.http_provider(); + let sender = handle.dev_wallets().next().unwrap().clone(); + let payer = PrivateKeySigner::from_bytes(&B256::with_last_byte(0x43)).unwrap(); + assert_eq!(provider.get_balance(payer.address()).await.unwrap(), U256::ZERO); + + let error = provider + .send_raw_transaction(&sponsored_eip8130_envelope(&sender, &payer).encoded_2718()) + .await + .unwrap_err(); + + assert!(error.to_string().contains("gas payer balance"), "{error}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_eip8130_txpool_reserves_pending_payer_balance() { + let config = NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Zenith.into())); + let (api, handle) = spawn(config).await; + api.anvil_set_auto_mine(false).await.unwrap(); + let provider = handle.http_provider(); + let sender = PrivateKeySigner::from_bytes(&B256::with_last_byte(0x44)).unwrap(); + let payer = handle.dev_wallets().next().unwrap().clone(); + api.anvil_set_balance(payer.address(), U256::from(300_000_000_000_000u64)).await.unwrap(); + let _first = provider + .send_raw_transaction( + &sponsored_eip8130_envelope_with_nonce(&sender, &payer, U256::from(30), 0) + .encoded_2718(), + ) + .await + .unwrap(); + + let error = provider + .send_raw_transaction( + &sponsored_eip8130_envelope_with_nonce(&sender, &payer, U256::from(31), 0) + .encoded_2718(), + ) + .await + .unwrap_err(); + + assert!(error.to_string().contains("pending reservation"), "{error}"); + assert_eq!(provider.txpool_status().await.unwrap().pending, 1); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_eip8130_receipt_reports_partial_phase_revert() { + let config = NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Zenith.into())); + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + let signer = handle.dev_wallets().next().unwrap().clone(); + let success = address!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1"); + let reverter = address!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa2"); + api.anvil_set_code(success, Bytes::from_static(&[0x00])).await.unwrap(); + api.anvil_set_code(reverter, Bytes::from_static(&[0x60, 0x00, 0x60, 0x00, 0xfd])) + .await + .unwrap(); + let envelope = eip8130_envelope_with( + &signer, + vec![ + vec![Call { to: success, data: Bytes::new() }], + vec![Call { to: reverter, data: Bytes::new() }], + ], + Bytes::new(), + ); + + let receipt = provider + .send_raw_transaction(&envelope.encoded_2718()) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + let value = serde_json::to_value(receipt).unwrap(); + + assert_eq!(value["status"], "0x0"); + assert_eq!(value["phaseStatuses"], serde_json::json!(["0x1", "0x0"])); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_eip8130_same_block_receipts_keep_phase_statuses_isolated() { + let config = NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Zenith.into())); + let (api, handle) = spawn(config).await; + api.anvil_set_auto_mine(false).await.unwrap(); + let provider = handle.http_provider(); + let signer = handle.dev_wallets().next().unwrap().clone(); + let success = address!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa3"); + let reverter = address!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa4"); + api.anvil_set_code(success, Bytes::from_static(&[0x00])).await.unwrap(); + api.anvil_set_code(reverter, Bytes::from_static(&[0x60, 0x00, 0x60, 0x00, 0xfd])) + .await + .unwrap(); + let first = provider + .send_raw_transaction( + &eip8130_envelope_with_channel_calls( + &signer, + U256::from(20), + vec![vec![Call { to: success, data: Bytes::new() }]], + ) + .encoded_2718(), + ) + .await + .unwrap(); + let second = provider + .send_raw_transaction( + &eip8130_envelope_with_channel_calls( + &signer, + U256::from(21), + vec![ + vec![Call { to: success, data: Bytes::new() }], + vec![Call { to: reverter, data: Bytes::new() }], + ], + ) + .encoded_2718(), + ) + .await + .unwrap(); + + api.mine_one().await.unwrap(); + let first = serde_json::to_value(first.get_receipt().await.unwrap()).unwrap(); + let second = serde_json::to_value(second.get_receipt().await.unwrap()).unwrap(); + + assert_eq!(first["phaseStatuses"], serde_json::json!(["0x1"])); + assert_eq!(second["phaseStatuses"], serde_json::json!(["0x1", "0x0"])); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_beryl_rejects_eip8130_transaction() { + let config = NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Beryl.into())); + let (_api, handle) = spawn(config).await; + let signer = handle.dev_wallets().next().unwrap().clone(); + + let err = handle + .http_provider() + .send_raw_transaction(&eip8130_envelope(&signer).encoded_2718()) + .await + .unwrap_err(); + let nonce_error = handle + .http_provider() + .raw_request::<_, U256>( + "eth_getTransactionCount".into(), + (signer.address(), "latest", U256::ONE), + ) + .await + .unwrap_err(); + + assert!(err.to_string().contains("gated behind Zenith"), "{err}"); + assert!( + nonce_error.to_string().contains("not active before the Zenith hard fork"), + "{nonce_error}" + ); +} + +#[cfg(feature = "optimism")] +#[tokio::test(flavor = "multi_thread")] +async fn base_eip8130_is_rejected_by_non_base_networks() { + let configs = + [NodeConfig::test(), NodeConfig::test().with_optimism(), NodeConfig::test_tempo()]; + + for config in configs { + let (_api, handle) = spawn(config).await; + let signer = handle.dev_wallets().next().unwrap().clone(); + let error = handle + .http_provider() + .send_raw_transaction(&eip8130_envelope(&signer).encoded_2718()) + .await + .unwrap_err(); + + assert!(error.to_string().contains("gated behind Zenith"), "{error}"); + } +} + +async fn base_time_ms(provider: &RetryProvider, block: BlockId) -> u64 { + let result = provider + .call(WithOtherFields::new( + TransactionRequest::default() + .with_to(Predeploys::BASE_TIME) + .with_input(IBaseTime::timestampMsCall {}.abi_encode()), + )) + .block(block) + .await + .unwrap(); + IBaseTime::timestampMsCall::abi_decode_returns(&result).unwrap() +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_denim_interval_cadence() { + let config = NodeConfig::test_base() + .with_hardfork(Some(BaseUpgrade::Denim.into())) + .with_genesis_timestamp(Some(1_000u64)) + .with_genesis_block_number(Some(17u64)) + .with_blocktime(Some(Duration::from_millis(200))); + let (_api, handle) = spawn(config).await; + let provider = handle.http_provider(); + tokio::time::timeout(Duration::from_secs(10), async { + while provider.get_block_number().await.unwrap() < 27 { + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) + .await + .unwrap(); + for number in 17..=27u64 { + assert_eq!(base_time_ms(&provider, number.into()).await, 1_000_000 + (number - 17) * 200); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_fork_denim_continues_every_parent_phase() { + let config = NodeConfig::test_base() + .with_hardfork(Some(BaseUpgrade::Denim.into())) + .with_genesis_block_number(Some(2u64)); + let (source_api, source_handle) = spawn(config).await; + let admin = B256::from(U256::from(0x1234)); + source_api + .anvil_set_storage_at(Predeploys::BASE_TIME, BaseTime::ADMIN_SLOT, admin) + .await + .unwrap(); + let source_provider = source_handle.http_provider(); + for _ in 0..5 { + let before = base_time_ms(&source_provider, BlockId::latest()).await; + let config = NodeConfig::test_base() + .with_hardfork(Some(BaseUpgrade::Denim.into())) + .with_eth_rpc_url(Some(source_handle.http_endpoint())); + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + assert_eq!( + provider.get_storage_at(Predeploys::BASE_TIME, BaseTime::ADMIN_SLOT).await.unwrap(), + U256::from_be_bytes(admin.0) + ); + assert_eq!(base_time_ms(&provider, BlockId::pending()).await, before + 200); + api.mine_one().await.unwrap(); + assert_eq!(base_time_ms(&provider, BlockId::latest()).await, before + 200); + api.anvil_reset(Some(Forking { json_rpc_url: None, block_number: None })).await.unwrap(); + assert_eq!(base_time_ms(&provider, BlockId::pending()).await, before + 200); + api.mine_one().await.unwrap(); + assert_eq!(base_time_ms(&provider, BlockId::latest()).await, before + 200); + source_api.mine_one().await.unwrap(); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_denim_time_controls_and_state_restore() { + let config = NodeConfig::test_base() + .with_hardfork(Some(BaseUpgrade::Denim.into())) + .with_genesis_timestamp(Some(1_000u64)); + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + for _ in 0..4 { + api.mine_one().await.unwrap(); + } + assert_eq!(base_time_ms(&provider, BlockId::latest()).await, 1_000_800); + api.evm_increase_time(U256::from(10)).await.unwrap(); + let snapshot = api.evm_snapshot().await.unwrap(); + assert_eq!(base_time_ms(&provider, BlockId::pending()).await, 1_011_000); + assert_eq!(base_time_ms(&provider, BlockId::pending()).await, 1_011_000); + api.mine_one().await.unwrap(); + assert_eq!(base_time_ms(&provider, BlockId::latest()).await, 1_011_000); + api.mine_one().await.unwrap(); + assert_eq!(base_time_ms(&provider, BlockId::latest()).await, 1_011_200); + assert!(api.evm_revert(snapshot).await.unwrap()); + api.mine_one().await.unwrap(); + assert_eq!(base_time_ms(&provider, BlockId::latest()).await, 1_011_000); + + api.evm_set_next_block_timestamp(2_000).unwrap(); + assert_eq!(base_time_ms(&provider, BlockId::pending()).await, 2_000_200); + api.mine_one().await.unwrap(); + api.mine_one().await.unwrap(); + assert_eq!(base_time_ms(&provider, BlockId::latest()).await, 2_000_400); + api.evm_set_block_timestamp_interval(2).unwrap(); + api.mine_one().await.unwrap(); + assert_eq!(base_time_ms(&provider, BlockId::latest()).await, 2_002_600); + api.evm_remove_block_timestamp_interval().unwrap(); + api.evm_set_time(3_000).unwrap(); + api.mine_one().await.unwrap(); + assert_eq!(base_time_ms(&provider, BlockId::latest()).await, 3_000_800); + + let dump = api.anvil_dump_state(Some(true)).await.unwrap(); + let (loaded_api, loaded_handle) = + spawn(NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Denim.into()))).await; + loaded_api.anvil_load_state(dump).await.unwrap(); + loaded_api.mine_one().await.unwrap(); + assert_eq!(base_time_ms(&loaded_handle.http_provider(), BlockId::latest()).await, 3_001_000); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_denim_failed_deposit_preserves_state_and_time_override() { + let config = NodeConfig::test_base() + .with_hardfork(Some(BaseUpgrade::Denim.into())) + .with_genesis_timestamp(Some(1_000u64)); + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + let code = provider.get_code_at(Predeploys::BASE_TIME).await.unwrap(); + api.anvil_set_code(Predeploys::BASE_TIME, Bytes::from_static(&[0x5f, 0x5f, 0xfd])) + .await + .unwrap(); + api.evm_set_next_block_timestamp(2_000).unwrap(); + let error = api.mine_one().await.unwrap_err(); + assert_eq!( + error.to_string(), + "Internal error: \"required Base system deposit at transaction index 1 failed\"" + ); + assert_eq!(provider.get_block_number().await.unwrap(), 0); + assert_eq!( + provider.get_storage_at(Predeploys::L1_BLOCK_INFO, U256::ZERO).await.unwrap(), + U256::ZERO + ); + api.anvil_set_code(Predeploys::BASE_TIME, code).await.unwrap(); + api.mine_one().await.unwrap(); + assert_eq!(base_time_ms(&provider, BlockId::latest()).await, 2_000_200); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_denim_system_deposits_do_not_retry_unfittable_transaction() { + let config = NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Denim.into())); + let (api, handle) = spawn(config).await; + let accounts: Vec<_> = handle.dev_wallets().collect(); + let signer: EthereumWallet = accounts[0].clone().into(); + let provider = http_provider_with_signer(&handle.http_endpoint(), signer); + + // The system deposits consume block gas first, so this transaction cannot fit in a Denim + // block. Their inclusion must not count as pool progress and retrigger automine. + let pending = provider + .send_transaction( + TransactionRequest::default() + .with_from(accounts[0].address()) + .with_to(accounts[1].address()) + .with_value(U256::from(1)) + .with_gas_limit(api.gas_limit().to()) + .into(), + ) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(10), async { + while provider.get_block_number().await.unwrap() == 0 { + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) + .await + .unwrap(); + // A retry loop would keep mining deposit-only blocks during this window. + tokio::time::sleep(Duration::from_millis(500)).await; + + assert_eq!(provider.get_block_number().await.unwrap(), 1); + let block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + assert_eq!(block.transactions.len(), 2); + assert!(provider.get_transaction_receipt(*pending.tx_hash()).await.unwrap().is_none()); + assert_eq!(provider.txpool_status().await.unwrap().pending, 1); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_denim_memory_reset_reinstalls_base_time() { + let config = NodeConfig::test_base() + .with_hardfork(Some(BaseUpgrade::Denim.into())) + .with_genesis_timestamp(Some(1_000u64)); + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + api.mine_one().await.unwrap(); + + api.anvil_reset(None).await.unwrap(); + for expected in [1_000_200, 1_000_400, 1_000_600, 1_000_800, 1_001_000] { + api.mine_one().await.unwrap(); + assert_eq!(base_time_ms(&provider, BlockId::latest()).await, expected); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_denim_load_pre_denim_state_installs_base_time() { + let (source, _source_handle) = + spawn(NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Beryl.into()))).await; + source.mine_one().await.unwrap(); + let dump = source.anvil_dump_state(Some(true)).await.unwrap(); + + let (api, handle) = + spawn(NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Denim.into()))).await; + let provider = handle.http_provider(); + api.anvil_load_state(dump).await.unwrap(); + let parent = provider.get_block(BlockId::latest()).await.unwrap().unwrap().header.timestamp; + for (index, millis) in [200, 400, 600, 800, 1_000].into_iter().enumerate() { + api.mine_one().await.unwrap(); + assert_eq!( + base_time_ms(&provider, BlockId::latest()).await, + parent * 1_000 + millis, + "block {index} after loading a pre-Denim dump" + ); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_denim_rejected_state_load_is_atomic() { + let config = || NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Denim.into())); + let sentinel = Address::repeat_byte(0x77); + + let (source, _source_handle) = spawn(config()).await; + source.anvil_set_balance(sentinel, U256::from(123)).await.unwrap(); + source.mine_one().await.unwrap(); + source.mine_one().await.unwrap(); + let mut invalid_state = source.backend.serialized_state(false).await.unwrap(); + let base_time = invalid_state.accounts.get_mut(&Predeploys::BASE_TIME).unwrap(); + base_time + .storage + .insert(B256::from(BaseTime::IMPLEMENTATION_SLOT.to_be_bytes::<32>()), B256::ZERO); + base_time.storage.insert( + B256::from(BaseTime::ADMIN_SLOT.to_be_bytes::<32>()), + B256::from(U256::from(0xdead).to_be_bytes::<32>()), + ); + + let (target, target_handle) = spawn(config()).await; + target.anvil_set_balance(sentinel, U256::from(7)).await.unwrap(); + target.mine_one().await.unwrap(); + let original_hash = target.backend.best_hash(); + let original_number = target.backend.best_number(); + + target.backend.load_state(invalid_state).await.unwrap_err(); + assert_eq!(target.backend.best_hash(), original_hash); + assert_eq!(target.backend.best_number(), original_number); + assert_eq!(target.backend.current_balance(sentinel).await.unwrap(), U256::from(7)); + + target.mine_one().await.unwrap(); + assert_eq!(target.backend.best_number(), original_number + 1); + assert_eq!(base_time_ms(&target_handle.http_provider(), BlockId::latest()).await % 1_000, 400); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_inferred_denim_fork_resets_to_pre_denim() { + let (denim, denim_handle) = + spawn(NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Denim.into()))).await; + denim.mine_one().await.unwrap(); + let (beryl, beryl_handle) = + spawn(NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Beryl.into()))).await; + beryl.mine_one().await.unwrap(); + + let (api, handle) = + spawn(NodeConfig::test_base().with_eth_rpc_url(Some(denim_handle.http_endpoint()))).await; + assert_eq!(api.backend.hardfork(), BaseUpgrade::Denim.into()); + + api.anvil_reset(Some(Forking { + json_rpc_url: Some(beryl_handle.http_endpoint()), + block_number: None, + })) + .await + .unwrap(); + assert_eq!(api.backend.hardfork(), BaseUpgrade::Beryl.into()); + assert!(handle.http_provider().get_code_at(Predeploys::BASE_TIME).await.unwrap().is_empty()); + + api.mine_one().await.unwrap(); + let block = handle.http_provider().get_block(BlockId::latest()).await.unwrap().unwrap(); + assert!(block.transactions.is_empty()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_fork_denim_rejects_pre_jovian_l1_block() { + let (source, source_handle) = + spawn(NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Beryl.into()))).await; + let denim_fork = |block: u64| { + NodeConfig::test_base() + .with_hardfork(Some(BaseUpgrade::Denim.into())) + .with_eth_rpc_url(Some(source_handle.http_endpoint())) + .with_fork_block_number(Some(block)) + .with_no_storage_caching(true) + }; + source.mine_one().await.unwrap(); + let (api, _handle) = spawn(denim_fork(1)).await; + + // From block 2, L1Block stands in for an implementation without the Jovian setter. + source + .anvil_set_code(Predeploys::L1_BLOCK_INFO, Bytes::from_static(&[0x5f, 0x5f, 0xfd])) + .await + .unwrap(); + source.mine_one().await.unwrap(); + + let Err(error) = anvil::try_spawn(denim_fork(2)).await else { + panic!("a Denim fork whose L1Block rejects the L1-info deposit must not start"); + }; + assert_eq!( + format!("{error:#}"), + "failed to create genesis: failed to process AnyRequest: Denim system deposits fail on \ + this fork; its L1Block likely predates Jovian (fork at or after the Jovian upgrade, or \ + use an earlier --hardfork): Internal error: \"required Base system deposit at transaction \ + index 0 failed\"" + ); + + // Resetting a running node onto that block is rejected and leaves it mining on block 1. + let error = api + .anvil_reset(Some(Forking { json_rpc_url: None, block_number: Some(2) })) + .await + .unwrap_err(); + assert_eq!( + error.to_string(), + "failed to process AnyRequest: Denim system deposits fail on this fork; its L1Block \ + likely predates Jovian (fork at or after the Jovian upgrade, or use an earlier \ + --hardfork): Internal error: \"required Base system deposit at transaction index 0 \ + failed\"" + ); + api.mine_one().await.unwrap(); + assert_eq!(api.block_number().unwrap(), U256::from(2)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_standalone_denim_pending_receipts_include_system_transactions() { + let config = NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Denim.into())); + let (_api, handle) = spawn(config).await; + let provider = handle.http_provider(); + + let block = provider.get_block(BlockId::pending()).await.unwrap().unwrap(); + let receipts = provider.get_block_receipts(BlockId::pending()).await.unwrap().unwrap(); + assert_eq!(receipts.len(), 2, "the pending block always contains the system deposits"); + assert_eq!( + receipts.iter().map(|receipt| receipt.transaction_hash).collect::>(), + block.transactions.hashes().collect::>() + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn base_reset_restores_genesis_state() { + let config = NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Zenith.into())); + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + let read_state = async || { + let mut slots = Vec::new(); + for slot in [1u64, 3, 7] { + slots.push( + provider.get_storage_at(Predeploys::L1_BLOCK_INFO, U256::from(slot)).await.unwrap(), + ); + } + let mut code_hashes = Vec::new(); + for address in [BASE_CREATE2_DEPLOYER, NONCE_MANAGER, ACTIVATION_REGISTRY] { + code_hashes.push(keccak256(provider.get_code_at(address).await.unwrap())); + } + (slots, code_hashes) + }; + + let genesis = read_state().await; + assert_eq!( + genesis, + ( + vec![U256::from(1_000_000_000u64), U256::from(1_000_000u64) << 96, U256::ONE], + vec![ + b256!("0xb0550b5b431e30d38000efb7107aaa0ade03d48a7198a140edda9d27134468b2"), + keccak256([0xef]), + keccak256([0xef]), + ], + ) + ); + + api.anvil_reset(None).await.unwrap(); + + assert_eq!(read_state().await, genesis); +} diff --git a/crates/anvil/tests/it/beacon_api.rs b/crates/anvil/tests/it/beacon_api.rs new file mode 100644 index 0000000000000..2f865dd20e80a --- /dev/null +++ b/crates/anvil/tests/it/beacon_api.rs @@ -0,0 +1,259 @@ +use crate::utils::http_provider; +use alloy_consensus::{Blob, BlobTransactionSidecar, SidecarBuilder, SimpleCoder, Transaction}; +use alloy_network::{TransactionBuilder, TransactionBuilder4844}; +use alloy_primitives::{B256, FixedBytes, U256, b256}; +use alloy_provider::Provider; +use alloy_rpc_types::TransactionRequest; +use alloy_rpc_types_beacon::{genesis::GenesisResponse, sidecar::GetBlobsResponse}; +use alloy_serde::WithOtherFields; +use anvil::{NodeConfig, spawn}; +use foundry_evm::hardfork::EthereumHardfork; +use ssz::Decode; + +#[tokio::test(flavor = "multi_thread")] +async fn test_beacon_api_get_blob_sidecars() { + let node_config = NodeConfig::test().with_hardfork(Some(EthereumHardfork::Cancun.into())); + let (_api, handle) = spawn(node_config).await; + + // Test Beacon API endpoint using HTTP client + let client = reqwest::Client::new(); + let url = format!("{}/eth/v1/beacon/blob_sidecars/latest", handle.http_endpoint()); + + // This endpoint is deprecated, so we expect a 410 Gone response + let response = client.get(&url).send().await.unwrap(); + assert_eq!( + response.text().await.unwrap(), + r#"{"code":410,"message":"This endpoint is deprecated. Use `GET /eth/v1/beacon/blobs/{block_id}` instead."}"#, + "Expected deprecation message for blob_sidecars endpoint" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_beacon_api_get_blobs() { + let node_config = NodeConfig::test().with_hardfork(Some(EthereumHardfork::Cancun.into())); + let (api, handle) = spawn(node_config).await; + + // Disable auto-mining so we can include multiple transactions in the same block + api.anvil_set_auto_mine(false).await.unwrap(); + + let wallets = handle.dev_wallets().collect::>(); + let from = wallets[0].address(); + let to = wallets[1].address(); + + let provider = http_provider(&handle.http_endpoint()); + + let eip1559_est = provider.estimate_eip1559_fees().await.unwrap(); + let gas_price = provider.get_gas_price().await.unwrap(); + + // Create multiple blob transactions to be included in the same block + let blob_data = + [b"Hello Beacon API - Blob 1", b"Hello Beacon API - Blob 2", b"Hello Beacon API - Blob 3"]; + + let mut pending_txs = Vec::new(); + + // Send all transactions without waiting for receipts + for (i, data) in blob_data.iter().enumerate() { + let sidecar: SidecarBuilder = SidecarBuilder::from_slice(data.as_slice()); + let sidecar = sidecar.build::().unwrap(); + + let tx = TransactionRequest::default() + .with_from(from) + .with_to(to) + .with_nonce(i as u64) + .with_max_fee_per_blob_gas(gas_price + 1) + .with_max_fee_per_gas(eip1559_est.max_fee_per_gas) + .with_max_priority_fee_per_gas(eip1559_est.max_priority_fee_per_gas) + .with_blob_sidecar_4844(sidecar) + .value(U256::from(100)); + + let tx = WithOtherFields::new(tx); + + let pending = provider.send_transaction(tx).await.unwrap(); + pending_txs.push(pending); + } + + // Mine a block to include all transactions + api.evm_mine(None).await.unwrap(); + + // Get receipts for all transactions + let mut receipts = Vec::new(); + for pending in pending_txs { + let receipt = pending.get_receipt().await.unwrap(); + receipts.push(receipt); + } + + // Verify all transactions were included in the same block + let block_number = receipts[0].block_number.unwrap(); + for (i, receipt) in receipts.iter().enumerate() { + assert_eq!( + receipt.block_number.unwrap(), + block_number, + "Transaction {i} was not included in block {block_number}" + ); + } + + // Extract the actual versioned hashes from the mined transactions + let mut actual_versioned_hashes = Vec::new(); + for receipt in &receipts { + let tx = provider.get_transaction_by_hash(receipt.transaction_hash).await.unwrap().unwrap(); + if let Some(blob_versioned_hashes) = tx.blob_versioned_hashes() { + actual_versioned_hashes.extend(blob_versioned_hashes.iter().copied()); + } + } + + // Test Beacon API endpoint using HTTP client + let client = reqwest::Client::new(); + let url = format!("{}/eth/v1/beacon/blobs/{}", handle.http_endpoint(), block_number); + + let response = client.get(&url).send().await.unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::OK); + assert_eq!( + response.headers().get("content-type").and_then(|h| h.to_str().ok()), + Some("application/json"), + "Expected application/json content-type header" + ); + + let blobs_response: GetBlobsResponse = response.json().await.unwrap(); + // Verify response structure + assert!(!blobs_response.execution_optimistic); + assert!(!blobs_response.finalized); + + // Verify we have blob data from all transactions + assert_eq!(blobs_response.data.len(), 3, "Expected 3 blobs from 3 transactions"); + + // Test response with SSZ encoding + let url = format!("{}/eth/v1/beacon/blobs/{}", handle.http_endpoint(), block_number); + let response = client + .get(&url) + .header(axum::http::header::ACCEPT, "application/octet-stream") + .send() + .await + .unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::OK); + assert_eq!( + response.headers().get("content-type").and_then(|h| h.to_str().ok()), + Some("application/octet-stream"), + "Expected application/octet-stream content-type header" + ); + + let body_bytes = response.bytes().await.unwrap(); + + // Decode the SSZ-encoded blobs in a spawned thread with larger stack to handle recursion + let decoded_blobs = std::thread::Builder::new() + .stack_size(8 * 1024 * 1024) // 8MB stack for SSZ decoding of large blobs + .spawn(move || Vec::::from_ssz_bytes(&body_bytes)) + .expect("Failed to spawn decode thread") + .join() + .expect("Decode thread panicked") + .expect("Failed to decode SSZ-encoded blobs"); + + // Verify we got exactly 3 blobs + assert_eq!( + decoded_blobs.len(), + 3, + "Expected 3 blobs from SSZ-encoded response, got {}", + decoded_blobs.len() + ); + + // Verify the decoded blobs match the JSON response blobs + for (i, (decoded, json)) in decoded_blobs.iter().zip(blobs_response.data.iter()).enumerate() { + assert_eq!(decoded, json, "Blob {i} mismatch between SSZ and JSON responses"); + } + + // Test filtering with versioned_hashes query parameter - single hash + let url = format!( + "{}/eth/v1/beacon/blobs/{}?versioned_hashes={}", + handle.http_endpoint(), + block_number, + actual_versioned_hashes[1] + ); + let response = client.get(&url).send().await.unwrap(); + let status = response.status(); + if status != reqwest::StatusCode::OK { + let error_body = response.text().await.unwrap(); + panic!("Expected OK status, got {status}: {error_body}"); + } + let blobs_response: GetBlobsResponse = response.json().await.unwrap(); + assert_eq!( + blobs_response.data.len(), + 1, + "Expected 1 blob when filtering by single versioned_hash" + ); + + // Test filtering with versioned_hashes query parameter - multiple versioned_hashes + // (comma-separated) + let url = format!( + "{}/eth/v1/beacon/blobs/{}?versioned_hashes={},{}", + handle.http_endpoint(), + block_number, + actual_versioned_hashes[0], + actual_versioned_hashes[2] + ); + let response = client.get(&url).send().await.unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::OK); + let blobs_response: GetBlobsResponse = response.json().await.unwrap(); + assert_eq!( + blobs_response.data.len(), + 2, + "Expected 2 blobs when filtering by two versioned_hashes" + ); + + // Test filtering with non-existent versioned_hash + let non_existent_hash = + b256!("0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); + let url = format!( + "{}/eth/v1/beacon/blobs/{}?versioned_hashes={}", + handle.http_endpoint(), + block_number, + non_existent_hash + ); + let response = client.get(&url).send().await.unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::OK); + let blobs_response: GetBlobsResponse = response.json().await.unwrap(); + assert_eq!( + blobs_response.data.len(), + 0, + "Expected 0 blobs when filtering by non-existent versioned_hash" + ); + + // Test with special block identifiers + let test_ids = vec!["latest", "finalized", "safe", "earliest"]; + for block_id in test_ids { + let url = format!("{}/eth/v1/beacon/blobs/{}", handle.http_endpoint(), block_id); + assert_eq!(client.get(&url).send().await.unwrap().status(), reqwest::StatusCode::OK); + } + let url = format!("{}/eth/v1/beacon/blobs/pending", handle.http_endpoint()); + assert_eq!(client.get(&url).send().await.unwrap().status(), reqwest::StatusCode::NOT_FOUND); + + // Test with hex block number + let url = format!("{}/eth/v1/beacon/blobs/0x{block_number:x}", handle.http_endpoint()); + let response = client.get(&url).send().await.unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::OK); + + // Test with non-existent block + let url = format!("{}/eth/v1/beacon/blobs/999999", handle.http_endpoint()); + let response = client.get(&url).send().await.unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::NOT_FOUND); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_beacon_api_get_genesis() { + let node_config = NodeConfig::test().with_hardfork(Some(EthereumHardfork::Cancun.into())); + let (_api, handle) = spawn(node_config).await; + + // Test Beacon API genesis endpoint using HTTP client + let client = reqwest::Client::new(); + let url = format!("{}/eth/v1/beacon/genesis", handle.http_endpoint()); + + let response = client.get(&url).send().await.unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::OK); + + let genesis_response: GenesisResponse = response.json().await.unwrap(); + + assert!(genesis_response.data.genesis_time > 0); + assert_eq!(genesis_response.data.genesis_validators_root, B256::ZERO); + assert_eq!( + genesis_response.data.genesis_fork_version, + FixedBytes::from([0x00, 0x00, 0x00, 0x00]) + ); +} diff --git a/crates/anvil/tests/it/block_index.rs b/crates/anvil/tests/it/block_index.rs new file mode 100644 index 0000000000000..435c79cb4c39a --- /dev/null +++ b/crates/anvil/tests/it/block_index.rs @@ -0,0 +1,243 @@ +//! Tests for the block-and-index transaction and uncle endpoints. +//! +//! Anvil never produces uncles, so the uncle endpoints exist for compatibility and forward to the +//! forked node for blocks that predate the fork. + +use crate::utils::http_provider_with_signer; +use alloy_network::{EthereumWallet, TransactionBuilder}; +use alloy_primitives::{Address, B256, Bytes, U256}; +use alloy_provider::Provider; +use alloy_rpc_types::{BlockNumberOrTag, Index, TransactionRequest}; +use alloy_serde::WithOtherFields; +use anvil::{NodeConfig, spawn}; +use foundry_test_utils::rpc::spawn_rpc_proxy_canned_method; +use serde_json::{Value, json}; +use std::sync::atomic::Ordering; + +/// Mines a block holding a single transfer and returns its hash, number and transaction hash. +async fn mine_block_with_transfer(handle: &anvil::NodeHandle) -> (B256, u64, B256) { + let wallet = handle.dev_wallets().next().unwrap(); + let sender = wallet.address(); + let signer: EthereumWallet = wallet.into(); + let provider = http_provider_with_signer(&handle.http_endpoint(), signer); + + let receipt = provider + .send_transaction(WithOtherFields::new( + TransactionRequest::default() + .with_from(sender) + .with_to(Address::repeat_byte(0x11)) + .with_value(U256::ONE), + )) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + + let block = provider.get_block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + (block.header.hash, block.header.number, receipt.transaction_hash) +} + +#[tokio::test(flavor = "multi_thread")] +async fn transaction_by_block_hash_and_index_matches_the_mined_transaction() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let (block_hash, _number, tx_hash) = mine_block_with_transfer(&handle).await; + let provider = handle.http_provider(); + + let tx: Option = provider + .client() + .request("eth_getTransactionByBlockHashAndIndex", (block_hash, Index::from(0))) + .await + .unwrap(); + let tx = tx.expect("expected a transaction at index 0"); + assert_eq!(tx["hash"].as_str().unwrap().parse::().unwrap(), tx_hash); + + // Indexing past the end of the block is not an error. + let missing: Option = provider + .client() + .request("eth_getTransactionByBlockHashAndIndex", (block_hash, Index::from(1))) + .await + .unwrap(); + assert_eq!(missing, None); + + let unknown_block: Option = provider + .client() + .request("eth_getTransactionByBlockHashAndIndex", (B256::random(), Index::from(0))) + .await + .unwrap(); + assert_eq!(unknown_block, None); +} + +#[tokio::test(flavor = "multi_thread")] +async fn raw_transaction_by_block_and_index_matches_by_hash() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let (block_hash, number, tx_hash) = mine_block_with_transfer(&handle).await; + let provider = handle.http_provider(); + + let expected: Option = + provider.client().request("eth_getRawTransactionByHash", (tx_hash,)).await.unwrap(); + let expected = expected.expect("expected a raw transaction"); + + let by_hash: Option = provider + .client() + .request("eth_getRawTransactionByBlockHashAndIndex", (block_hash, Index::from(0))) + .await + .unwrap(); + assert_eq!(by_hash, Some(expected.clone())); + + let by_number: Option = provider + .client() + .request( + "eth_getRawTransactionByBlockNumberAndIndex", + (BlockNumberOrTag::Number(number), Index::from(0)), + ) + .await + .unwrap(); + assert_eq!(by_number, Some(expected)); + + // Out-of-range indices report no transaction rather than failing. + let missing: Option = provider + .client() + .request( + "eth_getRawTransactionByBlockNumberAndIndex", + (BlockNumberOrTag::Number(number), Index::from(7)), + ) + .await + .unwrap(); + assert_eq!(missing, None); +} + +#[tokio::test(flavor = "multi_thread")] +async fn pending_transaction_by_block_number_and_index() { + let (api, handle) = spawn(NodeConfig::test()).await; + let wallet = handle.dev_wallets().next().unwrap(); + let sender = wallet.address(); + let signer: EthereumWallet = wallet.into(); + let provider = http_provider_with_signer(&handle.http_endpoint(), signer); + api.anvil_set_auto_mine(false).await.unwrap(); + + let pending = provider + .send_transaction(WithOtherFields::new( + TransactionRequest::default() + .with_from(sender) + .with_to(Address::repeat_byte(0x11)) + .with_value(U256::ONE), + )) + .await + .unwrap() + .register() + .await + .unwrap(); + let tx_hash = *pending.tx_hash(); + + let tx: Option = provider + .client() + .request( + "eth_getTransactionByBlockNumberAndIndex", + (BlockNumberOrTag::Pending, Index::from(0)), + ) + .await + .unwrap(); + let tx = tx.expect("expected a pending transaction at index 0"); + assert_eq!(tx["hash"].as_str().unwrap().parse::().unwrap(), tx_hash); + + let expected: Option = + provider.client().request("eth_getRawTransactionByHash", (tx_hash,)).await.unwrap(); + let expected = expected.expect("expected a raw pending transaction"); + let raw: Option = provider + .client() + .request( + "eth_getRawTransactionByBlockNumberAndIndex", + (BlockNumberOrTag::Pending, Index::from(0)), + ) + .await + .unwrap(); + assert_eq!(raw, Some(expected)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn uncle_endpoints_report_no_uncles() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + api.mine_one().await.unwrap(); + let block = provider.get_block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + let (hash, number) = (block.header.hash, block.header.number); + + let count: U256 = + provider.client().request("eth_getUncleCountByBlockHash", (hash,)).await.unwrap(); + assert_eq!(count, U256::ZERO); + + let count: U256 = provider + .client() + .request("eth_getUncleCountByBlockNumber", (BlockNumberOrTag::Number(number),)) + .await + .unwrap(); + assert_eq!(count, U256::ZERO); + + let uncle: Option = provider + .client() + .request("eth_getUncleByBlockHashAndIndex", (hash, Index::from(0))) + .await + .unwrap(); + assert_eq!(uncle, None); + + let uncle: Option = provider + .client() + .request( + "eth_getUncleByBlockNumberAndIndex", + (BlockNumberOrTag::Number(number), Index::from(0)), + ) + .await + .unwrap(); + assert_eq!(uncle, None); +} + +#[tokio::test(flavor = "multi_thread")] +async fn uncle_count_rejects_unknown_blocks() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + // `BlockNotFound` surfaces as the JSON-RPC resource-not-found code. + let err = provider + .client() + .request::<_, U256>("eth_getUncleCountByBlockHash", (B256::random(),)) + .await + .unwrap_err(); + assert_eq!(err.as_error_resp().unwrap().code, -32001); + + let err = provider + .client() + .request::<_, U256>("eth_getUncleCountByBlockNumber", (BlockNumberOrTag::Number(9999),)) + .await + .unwrap_err(); + assert_eq!(err.as_error_resp().unwrap().code, -32001); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_uncle_by_index_skips_locally_mined_blocks() { + let (_origin_api, origin_handle) = spawn(NodeConfig::test()).await; + let (proxy, calls) = spawn_rpc_proxy_canned_method( + origin_handle.http_endpoint(), + "eth_getUncleByBlockNumberAndIndex", + json!(null), + ) + .await; + + let (api, handle) = spawn(NodeConfig::test().with_eth_rpc_url(Some(proxy))).await; + let provider = handle.http_provider(); + + api.mine_one().await.unwrap(); + let local = provider.get_block_number().await.unwrap(); + + let uncle: Option = provider + .client() + .request( + "eth_getUncleByBlockNumberAndIndex", + (BlockNumberOrTag::Number(local), Index::from(0)), + ) + .await + .unwrap(); + assert_eq!(uncle, None); + assert_eq!(calls.load(Ordering::Relaxed), 0, "locally mined block should not be forwarded"); +} diff --git a/crates/anvil/tests/it/celo.rs b/crates/anvil/tests/it/celo.rs new file mode 100644 index 0000000000000..c4688ac590dc4 --- /dev/null +++ b/crates/anvil/tests/it/celo.rs @@ -0,0 +1,237 @@ +//! CIP-64 envelope compatibility with native fee accounting. + +use alloy_consensus::SignableTransaction; +use alloy_network::{ReceiptResponse, TxSignerSync, eip2718::Encodable2718}; +use alloy_primitives::{Address, Signature, U256, address, bytes}; +use alloy_provider::{PendingTransactionBuilder, Provider}; +use alloy_serde::WithOtherFields; +use anvil::{NodeConfig, spawn}; +use anvil_core::types::{ReorgOptions, TransactionData}; +use foundry_evm_networks::NetworkConfigs; +use foundry_primitives::{FoundryTransactionRequest, FoundryTxEnvelope, FoundryTypedTx, TxCip64}; +use serde_json::json; + +#[tokio::test(flavor = "multi_thread")] +async fn cip64_fill_raw_send_and_native_fees() { + let (api, handle) = spawn(NodeConfig::test().with_networks(NetworkConfigs::with_celo())).await; + let provider = handle.http_provider(); + let wallets = handle.dev_wallets().collect::>(); + let sender = wallets[0].address(); + let recipient = wallets[1].address(); + let currency = address!("2222222222222222222222222222222222222222"); + let request = json!({"from":sender,"to":recipient,"value":"0x1","feeCurrency":currency}); + let filled = api.fill_transaction(serde_json::from_value(request).unwrap()).await.unwrap(); + let filled_json = serde_json::to_value(&filled.tx).unwrap(); + assert_eq!(filled_json["feeCurrency"], json!(currency)); + assert_eq!(filled_json["type"], "0x7b"); + assert!(filled_json.get("gasPrice").is_none()); + let request: FoundryTransactionRequest = serde_json::from_value(filled_json).unwrap(); + let FoundryTypedTx::Celo(mut tx) = request.build_typed_tx().unwrap() else { + panic!("CIP-64 request") + }; + let signature = wallets[0].sign_transaction_sync(&mut tx).unwrap(); + let signed = tx.into_signed(signature); + let expected_hash = *signed.hash(); + let balance = provider.get_balance(sender).await.unwrap(); + let snapshot = api.evm_snapshot().await.unwrap(); + let envelope = FoundryTxEnvelope::Celo(signed); + let pending = provider.send_raw_transaction(&envelope.encoded_2718()).await.unwrap(); + assert_eq!(*pending.tx_hash(), expected_hash); + let receipt = pending.get_receipt().await.unwrap(); + let fee = U256::from(receipt.gas_used()) * U256::from(receipt.effective_gas_price()); + assert_eq!(provider.get_balance(sender).await.unwrap(), balance - fee - U256::ONE); + let receipt_json = serde_json::to_value(&receipt).unwrap(); + assert_eq!(receipt_json["type"], "0x7b"); + assert_eq!(receipt_json["feeCurrency"], json!(currency)); + let mined = provider.get_transaction_by_hash(expected_hash).await.unwrap().unwrap(); + assert_eq!(serde_json::to_value(&mined).unwrap()["feeCurrency"], json!(currency)); + assert_eq!(FoundryTxEnvelope::try_from(mined).unwrap(), envelope); + let trace: serde_json::Value = provider + .raw_request( + "debug_traceTransaction".into(), + (expected_hash, json!({"tracer":"callTracer"})), + ) + .await + .unwrap(); + assert_eq!(trace["type"], "CALL"); + assert_eq!(trace["from"], json!(sender)); + assert_eq!(trace["to"], json!(recipient)); + + assert!(api.evm_revert(snapshot).await.unwrap()); + assert_eq!(provider.get_balance(sender).await.unwrap(), balance); + assert_eq!(provider.get_transaction_count(sender).await.unwrap(), 0); + + // The unlocked-account path must also preserve the currency and use the CIP-64 signer. + let request: WithOtherFields = + serde_json::from_value(json!({"from":sender,"to":recipient,"feeCurrency":currency})) + .unwrap(); + let hash = api.send_transaction(request).await.unwrap(); + let receipt = + PendingTransactionBuilder::new(provider.root().clone(), hash).get_receipt().await.unwrap(); + assert_eq!(serde_json::to_value(receipt).unwrap()["type"], "0x7b"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn cip64_requires_celo_mode() { + let (api, _handle) = spawn(NodeConfig::test()).await; + let request = serde_json::from_value(json!({"type":"0x7b","to":Address::ZERO})).unwrap(); + let error = api.fill_transaction(request).await.unwrap_err(); + assert_eq!( + error.to_string(), + "Invalid transaction request: CIP-64 transactions require Celo mode (--celo)" + ); + let envelope = FoundryTxEnvelope::Celo(TxCip64::default().into_signed(Signature::new( + U256::ONE, + U256::from(2), + false, + ))); + let error = api.send_raw_transaction(envelope.encoded_2718().into()).await.unwrap_err(); + assert_eq!( + error.to_string(), + "Invalid transaction request: CIP-64 transactions require Celo mode (--celo)" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn cip64_revert_charges_native_fees() { + let (api, handle) = spawn(NodeConfig::test().with_networks(NetworkConfigs::with_celo())).await; + let provider = handle.http_provider(); + let wallet = handle.dev_wallets().next().unwrap(); + let sender = wallet.address(); + let contract = address!("3333333333333333333333333333333333333333"); + api.anvil_set_code(contract, bytes!("60006000fd")).await.unwrap(); + let request = json!({"from":sender,"to":contract,"value":"0x1","feeCurrency":"0x2222222222222222222222222222222222222222"}); + assert!(api.fill_transaction(serde_json::from_value(request.clone()).unwrap()).await.is_err()); + let mut request = request; + request["gas"] = json!("0x186a0"); + let balance = provider.get_balance(sender).await.unwrap(); + let hash = api.send_transaction(serde_json::from_value(request).unwrap()).await.unwrap(); + let receipt = + PendingTransactionBuilder::new(provider.root().clone(), hash).get_receipt().await.unwrap(); + assert!(!receipt.status()); + assert_eq!( + provider.get_balance(sender).await.unwrap(), + balance - U256::from(receipt.gas_used()) * U256::from(receipt.effective_gas_price()) + ); + assert_eq!(provider.get_balance(contract).await.unwrap(), U256::ZERO); + assert_eq!(provider.get_transaction_count(sender).await.unwrap(), 1); +} + +#[tokio::test(flavor = "multi_thread")] +async fn cip64_impersonated_hashes() { + let (api, handle) = spawn(NodeConfig::test().with_networks(NetworkConfigs::with_celo())).await; + let provider = handle.http_provider(); + let sender = handle.dev_wallets().next().unwrap().address(); + api.anvil_impersonate_account(sender).await.unwrap(); + api.anvil_set_auto_mine(false).await.unwrap(); + let request = json!({"from":sender,"to":Address::ZERO,"feeCurrency":"0x2222222222222222222222222222222222222222"}); + let hash = api.send_transaction(serde_json::from_value(request).unwrap()).await.unwrap(); + let pending: serde_json::Value = + provider.raw_request("eth_getTransactionByHash".into(), (hash,)).await.unwrap(); + assert_eq!(pending["hash"], json!(hash)); + api.mine_one().await.unwrap(); + let mined: serde_json::Value = + provider.raw_request("eth_getTransactionByHash".into(), (hash,)).await.unwrap(); + assert_eq!(mined["hash"], json!(hash)); + let receipt = provider.get_transaction_receipt(hash).await.unwrap().unwrap(); + assert_eq!(receipt.transaction_hash(), hash); + let block: serde_json::Value = + provider.raw_request("eth_getBlockByNumber".into(), ("latest", true)).await.unwrap(); + assert_eq!(block["transactions"][0]["hash"], json!(hash)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn cip64_reorg_and_state_import_require_celo_mode() { + let (source, handle) = + spawn(NodeConfig::test().with_networks(NetworkConfigs::with_celo())).await; + let provider = handle.http_provider(); + let wallet = handle.dev_wallets().next().unwrap(); + let sender = wallet.address(); + let currency = address!("2222222222222222222222222222222222222222"); + let mut tx = TxCip64 { + inner: alloy_consensus::TxEip1559 { + chain_id: source.chain_id(), + gas_limit: 21_000, + max_fee_per_gas: 2_000_000_000, + max_priority_fee_per_gas: 1, + to: Address::ZERO.into(), + ..Default::default() + }, + fee_currency: Some(currency), + }; + let signature = wallet.sign_transaction_sync(&mut tx).unwrap(); + let raw = FoundryTxEnvelope::Celo(tx.into_signed(signature)).encoded_2718(); + let receipt = provider.send_raw_transaction(&raw).await.unwrap().get_receipt().await.unwrap(); + let hash = receipt.transaction_hash(); + let dump = source.anvil_dump_state(Some(true)).await.unwrap(); + let (restored, restored_handle) = + spawn(NodeConfig::test().with_networks(NetworkConfigs::with_celo())).await; + assert!(restored.anvil_load_state(dump.clone()).await.unwrap()); + let restored_provider = restored_handle.http_provider(); + let transaction = restored_provider.get_transaction_by_hash(hash).await.unwrap().unwrap(); + assert_eq!(serde_json::to_value(transaction).unwrap()["feeCurrency"], json!(currency)); + let receipt = restored_provider.get_transaction_receipt(hash).await.unwrap().unwrap(); + assert_eq!(serde_json::to_value(receipt).unwrap()["feeCurrency"], json!(currency)); + let trace: serde_json::Value = restored_provider + .raw_request("debug_traceTransaction".into(), (hash, json!({"tracer":"callTracer"}))) + .await + .unwrap(); + assert_eq!(trace["from"], json!(sender)); + let json_request: TransactionData = serde_json::from_value(json!({"from":sender,"to":Address::ZERO,"gas":"0x5208","maxFeePerGas":"0x77359400","maxPriorityFeePerGas":"0x1","feeCurrency":currency})).unwrap(); + source + .anvil_reorg(ReorgOptions { depth: 1, tx_block_pairs: vec![(json_request.clone(), 0)] }) + .await + .unwrap(); + let block: serde_json::Value = + provider.raw_request("eth_getBlockByNumber".into(), ("latest", true)).await.unwrap(); + assert_eq!(block["transactions"][0]["type"], "0x7b"); + assert_eq!(block["transactions"][0]["feeCurrency"], json!(currency)); + let profiles = [ + NetworkConfigs::with_ethereum(), + NetworkConfigs::with_tempo(), + #[cfg(feature = "base")] + NetworkConfigs::with_base(), + #[cfg(feature = "optimism")] + NetworkConfigs::with_optimism(), + #[cfg(feature = "monad")] + NetworkConfigs::with_monad(), + ]; + for profile in profiles { + let (api, target) = spawn(NodeConfig::test().with_networks(profile)).await; + let provider = target.http_provider(); + api.mine_one().await.unwrap(); + let before = provider + .get_block_by_number(alloy_rpc_types::BlockNumberOrTag::Latest) + .await + .unwrap() + .unwrap(); + let balance = provider.get_balance(sender).await.unwrap(); + for transaction in [TransactionData::Raw(raw.clone().into()), json_request.clone()] { + let error = api + .anvil_reorg(ReorgOptions { depth: 1, tx_block_pairs: vec![(transaction, 0)] }) + .await + .unwrap_err(); + assert_eq!( + error.to_string(), + "Invalid transaction request: CIP-64 transactions require Celo mode (--celo)" + ); + } + let error = api.anvil_load_state(dump.clone()).await.unwrap_err(); + assert_eq!( + error.to_string(), + "Invalid transaction request: CIP-64 transactions require Celo mode (--celo)" + ); + assert_eq!( + provider + .get_block_by_number(alloy_rpc_types::BlockNumberOrTag::Latest) + .await + .unwrap() + .unwrap() + .header + .hash, + before.header.hash + ); + assert_eq!(provider.get_balance(sender).await.unwrap(), balance); + assert_eq!(provider.get_transaction_count(sender).await.unwrap(), 0); + } +} diff --git a/crates/anvil/tests/it/eip2935.rs b/crates/anvil/tests/it/eip2935.rs new file mode 100644 index 0000000000000..3583fc7cec6a0 --- /dev/null +++ b/crates/anvil/tests/it/eip2935.rs @@ -0,0 +1,534 @@ +use crate::utils::http_provider; +use alloy_eips::{ + BlockId, BlockNumberOrTag, eip2935::HISTORY_STORAGE_ADDRESS, eip4788::BEACON_ROOTS_ADDRESS, + eip7002::WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, + eip7251::CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS, +}; +use alloy_evm::precompiles::{DynPrecompile, PrecompileInput}; +use alloy_network::TransactionBuilder; +use alloy_primitives::{Address, B256, Bytes, U256, address, bytes}; +use alloy_provider::Provider; +use alloy_rpc_types::{ + TransactionRequest, + simulate::{SimBlock, SimulatePayload}, + trace::{ + geth::{GethDebugBuiltInTracerType, GethDebugTracingOptions, TraceResult}, + opcode::{BlockOpcodeGas, TransactionOpcodeGas}, + parity::{Delta, TraceResults, TraceResultsWithTransactionHash, TraceType}, + }, +}; +use alloy_serde::WithOtherFields; +use anvil::{NodeConfig, PrecompileFactory, spawn}; +use foundry_evm::hardfork::{EthereumHardfork, TempoHardfork}; +use revm::precompile::{PrecompileError, PrecompileOutput, PrecompileStatus}; +use std::sync::{ + Arc, + atomic::{AtomicBool, AtomicUsize, Ordering}, +}; + +#[cfg(feature = "optimism")] +use foundry_evm::hardfork::OpHardfork; +#[cfg(feature = "optimism")] +use foundry_evm_networks::NetworkConfigs; + +const REPLAY_PRE_EXECUTION_ERROR: &str = "replay pre-execution sentinel"; + +#[derive(Debug)] +struct FailingHistoryPrecompile(Arc); + +impl PrecompileFactory for FailingHistoryPrecompile { + fn precompiles(&self) -> Vec<(Address, DynPrecompile)> { + let fail = Arc::clone(&self.0); + let precompile = DynPrecompile::from(move |input: PrecompileInput<'_>| { + if fail.load(Ordering::SeqCst) { + return Err(PrecompileError::Fatal(REPLAY_PRE_EXECUTION_ERROR.to_string())); + } + Ok(PrecompileOutput { + status: PrecompileStatus::Success, + bytes: Bytes::new(), + gas_used: 0, + gas_refunded: 0, + state_gas_used: 0, + state_gas_spilled: 0, + reservoir: input.reservoir, + }) + }); + vec![(HISTORY_STORAGE_ADDRESS, precompile)] + } +} + +#[derive(Debug)] +struct OrderedBlockStartPrecompiles(Arc); + +impl PrecompileFactory for OrderedBlockStartPrecompiles { + fn precompiles(&self) -> Vec<(Address, DynPrecompile)> { + let history_order = Arc::clone(&self.0); + let history = DynPrecompile::from(move |input: PrecompileInput<'_>| { + if history_order.compare_exchange(0, 1, Ordering::SeqCst, Ordering::SeqCst).is_err() { + return Err(PrecompileError::Fatal("EIP-2935 did not execute first".to_string())); + } + Ok(PrecompileOutput { + status: PrecompileStatus::Success, + bytes: Bytes::new(), + gas_used: 0, + gas_refunded: 0, + state_gas_used: 0, + state_gas_spilled: 0, + reservoir: input.reservoir, + }) + }); + + let beacon_order = Arc::clone(&self.0); + let beacon = DynPrecompile::from(move |input: PrecompileInput<'_>| { + if beacon_order.compare_exchange(1, 2, Ordering::SeqCst, Ordering::SeqCst).is_err() { + return Err(PrecompileError::Fatal( + "EIP-4788 did not execute after EIP-2935".to_string(), + )); + } + Ok(PrecompileOutput { + status: PrecompileStatus::Success, + bytes: Bytes::new(), + gas_used: 0, + gas_refunded: 0, + state_gas_used: 0, + state_gas_spilled: 0, + reservoir: input.reservoir, + }) + }); + + vec![(HISTORY_STORAGE_ADDRESS, history), (BEACON_ROOTS_ADDRESS, beacon)] + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn ethereum_block_start_transitions_use_consensus_order() { + let order = Arc::new(AtomicUsize::new(0)); + let node_config = NodeConfig::test() + .with_hardfork(Some(EthereumHardfork::Prague.into())) + .with_precompile_factory(OrderedBlockStartPrecompiles(Arc::clone(&order))); + let (api, _) = spawn(node_config).await; + + api.mine_one().await.unwrap(); + + assert_eq!(order.load(Ordering::SeqCst), 2); +} + +#[tokio::test(flavor = "multi_thread")] +async fn tempo_spec_id_does_not_enable_ethereum_block_transitions() { + let order = Arc::new(AtomicUsize::new(0)); + let node_config = NodeConfig::test_tempo() + .with_hardfork(Some(TempoHardfork::Genesis.into())) + .with_precompile_factory(OrderedBlockStartPrecompiles(Arc::clone(&order))); + let (api, handle) = spawn(node_config).await; + + handle.http_provider().get_block_by_number(BlockNumberOrTag::Pending).await.unwrap(); + api.simulate_v1( + SimulatePayload { block_state_calls: vec![SimBlock::default()], ..Default::default() }, + None, + ) + .await + .unwrap(); + + api.mine_one().await.unwrap(); + + assert_eq!(order.load(Ordering::SeqCst), 0); +} + +#[cfg(feature = "optimism")] +#[tokio::test(flavor = "multi_thread")] +async fn optimism_spec_id_does_not_enable_ethereum_block_transitions() { + let order = Arc::new(AtomicUsize::new(0)); + let node_config = NodeConfig::test() + .with_networks(NetworkConfigs::with_optimism()) + .with_hardfork(Some(OpHardfork::Isthmus.into())) + .with_precompile_factory(OrderedBlockStartPrecompiles(Arc::clone(&order))); + let (api, handle) = spawn(node_config).await; + + handle.http_provider().get_block_by_number(BlockNumberOrTag::Pending).await.unwrap(); + api.simulate_v1( + SimulatePayload { block_state_calls: vec![SimBlock::default()], ..Default::default() }, + None, + ) + .await + .unwrap(); + + api.mine_one().await.unwrap(); + + assert_eq!(order.load(Ordering::SeqCst), 0); +} + +#[derive(Debug)] +struct CountingPostBlockPrecompiles(Arc); + +impl PrecompileFactory for CountingPostBlockPrecompiles { + fn precompiles(&self) -> Vec<(Address, DynPrecompile)> { + [WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS] + .into_iter() + .map(|address| { + let calls = Arc::clone(&self.0); + let precompile = DynPrecompile::from(move |input: PrecompileInput<'_>| { + calls.fetch_add(1, Ordering::SeqCst); + Ok(PrecompileOutput { + status: PrecompileStatus::Success, + bytes: Bytes::new(), + gas_used: 0, + gas_refunded: 0, + state_gas_used: 0, + state_gas_spilled: 0, + reservoir: input.reservoir, + }) + }); + (address, precompile) + }) + .collect() + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn complete_block_paths_run_post_transitions_once() { + let calls = Arc::new(AtomicUsize::new(0)); + let node_config = NodeConfig::test() + .with_hardfork(Some(EthereumHardfork::Prague.into())) + .with_precompile_factory(CountingPostBlockPrecompiles(Arc::clone(&calls))); + let (api, handle) = spawn(node_config).await; + + handle.http_provider().get_block_by_number(BlockNumberOrTag::Pending).await.unwrap(); + assert_eq!(calls.load(Ordering::SeqCst), 2); + + api.simulate_v1( + SimulatePayload { block_state_calls: vec![SimBlock::default()], ..Default::default() }, + None, + ) + .await + .unwrap(); + assert_eq!(calls.load(Ordering::SeqCst), 4); + + api.mine_one().await.unwrap(); + assert_eq!(calls.load(Ordering::SeqCst), 6); +} + +#[tokio::test(flavor = "multi_thread")] +async fn transaction_prefix_replay_does_not_drain_post_block_queues() { + let calls = Arc::new(AtomicUsize::new(0)); + let node_config = NodeConfig::test() + .with_hardfork(Some(EthereumHardfork::Prague.into())) + .with_precompile_factory(CountingPostBlockPrecompiles(Arc::clone(&calls))); + let (api, handle) = spawn(node_config).await; + let provider = handle.http_provider(); + let mut wallets = handle.dev_wallets(); + let from = wallets.next().unwrap().address(); + let to = wallets.next().unwrap().address(); + let receipt = provider + .send_transaction(WithOtherFields::new( + TransactionRequest::default().from(from).to(to).value(U256::ONE), + )) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert_eq!(calls.load(Ordering::SeqCst), 2); + calls.store(0, Ordering::SeqCst); + + api.trace_replay_transaction( + receipt.transaction_hash, + [TraceType::Trace].into_iter().collect(), + ) + .await + .unwrap(); + + assert_eq!(calls.load(Ordering::SeqCst), 0); +} + +#[tokio::test(flavor = "multi_thread")] +async fn eip2935_contract_deployed_at_genesis() { + let node_config = NodeConfig::test().with_hardfork(Some(EthereumHardfork::Prague.into())); + let (_api, handle) = spawn(node_config).await; + let provider = http_provider(&handle.http_endpoint()); + + let code = provider.get_code_at(HISTORY_STORAGE_ADDRESS).await.unwrap(); + assert!(!code.is_empty(), "EIP-2935 history storage contract should be deployed at genesis"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn eip2935_stores_parent_block_hash() { + let node_config = NodeConfig::test().with_hardfork(Some(EthereumHardfork::Prague.into())); + let (api, handle) = spawn(node_config).await; + let provider = http_provider(&handle.http_endpoint()); + + // Mine a few blocks so there are parent hashes to store + api.mine_one().await.unwrap(); + api.mine_one().await.unwrap(); + api.mine_one().await.unwrap(); + + // Block 1's hash should be stored when block 2 was mined + let block1 = provider + .get_block_by_number(BlockNumberOrTag::from(1)) + .await + .unwrap() + .expect("block 1 should exist"); + let block1_hash = block1.header.hash; + + // Query the history storage contract for block 1's hash. + // The EIP-2935 contract uses raw calldata (not ABI-encoded): pass the block number + // as a 32-byte big-endian word directly. + let call_data: [u8; 32] = U256::ONE.to_be_bytes(); + let tx = TransactionRequest::default().with_to(HISTORY_STORAGE_ADDRESS).with_input(call_data); + let result = provider.call(tx.into()).await.unwrap(); + + let stored_hash = alloy_primitives::B256::from_slice(&result); + assert_eq!(stored_hash, block1_hash, "EIP-2935 contract should store parent block hash"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn eip2935_no_system_call_on_genesis() { + let node_config = NodeConfig::test().with_hardfork(Some(EthereumHardfork::Prague.into())); + let (_api, handle) = spawn(node_config).await; + let provider = http_provider(&handle.http_endpoint()); + + // At genesis (block 0), the contract should exist but no system call should have + // written any parent hash into its storage. Check raw storage slot 0 directly. + let slot = provider.get_storage_at(HISTORY_STORAGE_ADDRESS, U256::ZERO).await.unwrap(); + assert_eq!(slot, U256::ZERO, "No hash should be stored in the contract at genesis"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn eip2935_not_deployed_before_prague() { + let node_config = NodeConfig::test().with_hardfork(Some(EthereumHardfork::Cancun.into())); + let (_api, handle) = spawn(node_config).await; + let provider = http_provider(&handle.http_endpoint()); + + let code = provider.get_code_at(HISTORY_STORAGE_ADDRESS).await.unwrap(); + assert!(code.is_empty(), "EIP-2935 contract should NOT be deployed before Prague"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn eip2935_local_block_replay_applies_pre_execution_changes() { + let node_config = NodeConfig::test().with_hardfork(Some(EthereumHardfork::Prague.into())); + let (api, handle) = spawn(node_config).await; + let provider = handle.http_provider(); + let probe = address!("0000000000000000000000000000000000001000"); + + // Forward the calldata to the history contract and store the returned parent hash at slot zero + // only when it is non-zero. + api.anvil_set_code( + probe, + bytes!( + "60205f5f3760205f60205f730000f90827f1c53a10cb7a02335b1753200029355afa505f518015602d575f55005b00" + ), + ) + .await + .unwrap(); + api.mine_one().await.unwrap(); + + let parent = provider + .get_block_by_number(BlockNumberOrTag::Latest) + .await + .unwrap() + .expect("parent block should exist"); + api.anvil_set_auto_mine(false).await.unwrap(); + + let from = handle.dev_wallets().next().unwrap().address(); + let call_data: [u8; 32] = U256::from(parent.header.number).to_be_bytes(); + let pending = provider + .send_transaction(WithOtherFields::new( + TransactionRequest::default() + .from(from) + .to(probe) + .with_input(call_data) + .with_gas_limit(100_000), + )) + .await + .unwrap(); + let second_hash = api + .send_transaction(WithOtherFields::new( + TransactionRequest::default() + .from(from) + .to(probe) + .nonce(1) + .with_input(call_data) + .with_gas_limit(100_000), + )) + .await + .unwrap(); + api.mine_one().await.unwrap(); + let receipt = pending.get_receipt().await.unwrap(); + let block_number = receipt.block_number.unwrap(); + + let replay = api + .trace_replay_block_transactions( + block_number.into(), + [TraceType::StateDiff].into_iter().collect(), + ) + .await + .unwrap() + .unwrap(); + let storage = &replay[0] + .full_trace + .state_diff + .as_ref() + .expect("state diff should be present") + .get(&probe) + .expect("probe should change") + .storage; + assert_eq!(storage.get(&B256::ZERO), Some(&Delta::changed(B256::ZERO, parent.header.hash))); + + let options = GethDebugTracingOptions::default() + .with_tracer(GethDebugBuiltInTracerType::CallTracer.into()); + let result = api + .backend + .debug_trace_transaction(receipt.transaction_hash, options.clone()) + .await + .unwrap(); + let second = api.backend.debug_trace_transaction(second_hash, options.clone()).await.unwrap(); + assert_eq!( + api.backend.debug_trace_block_by_number(block_number.into(), options).await.unwrap(), + vec![ + TraceResult::Success { result, tx_hash: Some(receipt.transaction_hash) }, + TraceResult::Success { result: second, tx_hash: Some(second_hash) }, + ], + ); + + let opcode_gas: Option = provider + .raw_request("trace_blockOpcodeGas".into(), (BlockId::number(block_number),)) + .await + .unwrap(); + assert!(opcode_gas.expect("block should exist").contains("SSTORE")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn eip2935_local_block_replay_propagates_pre_execution_errors() { + let fail = Arc::new(AtomicBool::new(false)); + let node_config = NodeConfig::test() + .with_hardfork(Some(EthereumHardfork::Prague.into())) + .with_precompile_factory(FailingHistoryPrecompile(Arc::clone(&fail))); + let (api, handle) = spawn(node_config).await; + let provider = handle.http_provider(); + + let mut wallets = handle.dev_wallets(); + let from = wallets.next().unwrap().address(); + let to = wallets.next().unwrap().address(); + let receipt = provider + .send_transaction(WithOtherFields::new( + TransactionRequest::default().from(from).to(to).value(U256::ONE), + )) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + let block_number = receipt.block_number.unwrap(); + api.mine_one().await.unwrap(); + let empty_block_number = provider.get_block_number().await.unwrap(); + assert_eq!(empty_block_number, block_number + 1); + + fail.store(true, Ordering::SeqCst); + + let block_replay: Result, _> = provider + .client() + .request("trace_replayBlockTransactions", (block_number, vec![TraceType::Trace])) + .await; + let transaction_replay: Result = provider + .client() + .request("trace_replayTransaction", (receipt.transaction_hash, vec![TraceType::Trace])) + .await; + let transaction_opcode_replay: Result, _> = provider + .raw_request("trace_transactionOpcodeGas".into(), (receipt.transaction_hash,)) + .await; + let opcode_replay: Result, _> = + provider.raw_request("trace_blockOpcodeGas".into(), (BlockId::number(block_number),)).await; + let empty_opcode_replay: Result, _> = provider + .raw_request("trace_blockOpcodeGas".into(), (BlockId::number(empty_block_number),)) + .await; + + for error in [ + block_replay.unwrap_err(), + transaction_replay.unwrap_err(), + transaction_opcode_replay.unwrap_err(), + opcode_replay.unwrap_err(), + empty_opcode_replay.unwrap_err(), + ] { + let response = error.as_error_resp().expect("should return a JSON-RPC error"); + assert_eq!(response.code, -32603); + assert!(response.message.contains(REPLAY_PRE_EXECUTION_ERROR), "{response:?}"); + } + + let options = GethDebugTracingOptions::default() + .with_tracer(GethDebugBuiltInTracerType::CallTracer.into()); + let error = api + .backend + .debug_trace_transaction(receipt.transaction_hash, options.clone()) + .await + .unwrap_err(); + assert_eq!( + api.backend + .debug_trace_block_by_number(block_number.into(), options.clone()) + .await + .unwrap(), + vec![TraceResult::Error { + error: error.to_string(), + tx_hash: Some(receipt.transaction_hash) + }], + ); + assert!( + api.backend + .debug_trace_block_by_number(empty_block_number.into(), options) + .await + .unwrap() + .is_empty() + ); + + let genesis_opcode_gas: Option = + provider.raw_request("trace_blockOpcodeGas".into(), (BlockId::number(0),)).await.unwrap(); + assert!(genesis_opcode_gas.expect("genesis block should exist").transactions.is_empty()); + assert_eq!(provider.get_block_number().await.unwrap(), empty_block_number); +} + +#[tokio::test(flavor = "multi_thread")] +async fn geth_block_replay_discards_partial_results_on_execution_error() { + let fail = Arc::new(AtomicBool::new(false)); + // Cancun has no history system call, so the injected failure occurs only in the second tx. + let config = NodeConfig::test() + .with_hardfork(Some(EthereumHardfork::Cancun.into())) + .with_precompile_factory(FailingHistoryPrecompile(Arc::clone(&fail))); + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + let mut wallets = handle.dev_wallets(); + let from = wallets.next().unwrap().address(); + let to = wallets.next().unwrap().address(); + api.anvil_set_auto_mine(false).await.unwrap(); + let mut hashes = Vec::new(); + for (nonce, target) in [to, HISTORY_STORAGE_ADDRESS, to].into_iter().enumerate() { + let transaction = TransactionRequest::default() + .from(from) + .to(target) + .nonce(nonce as u64) + .value(U256::ONE) + .gas_limit(30_000); + hashes.push(api.send_transaction(WithOtherFields::new(transaction)).await.unwrap()); + } + api.mine_one().await.unwrap(); + let balance = provider.get_balance(to).await.unwrap(); + let block_number = provider.get_block_number().await.unwrap(); + fail.store(true, Ordering::SeqCst); + + let options = GethDebugTracingOptions::default() + .with_tracer(GethDebugBuiltInTracerType::CallTracer.into()); + let mut expected = Vec::new(); + for hash in hashes { + expected.push(match api.backend.debug_trace_transaction(hash, options.clone()).await { + Ok(result) => TraceResult::Success { result, tx_hash: Some(hash) }, + Err(error) => TraceResult::Error { error: error.to_string(), tx_hash: Some(hash) }, + }); + } + assert!(expected[0].is_success()); + assert!(expected[1].is_error()); + assert!(expected[2].is_error()); + assert_eq!( + api.backend.debug_trace_block_by_number(block_number.into(), options).await.unwrap(), + expected, + ); + assert_eq!(provider.get_balance(to).await.unwrap(), balance); + assert_eq!(provider.get_transaction_count(from).await.unwrap(), 3); + assert_eq!(provider.get_block_number().await.unwrap(), block_number); +} diff --git a/crates/anvil/tests/it/eip4844.rs b/crates/anvil/tests/it/eip4844.rs index 292194aae61da..d0afed03add59 100644 --- a/crates/anvil/tests/it/eip4844.rs +++ b/crates/anvil/tests/it/eip4844.rs @@ -1,21 +1,50 @@ use crate::utils::{http_provider, http_provider_with_signer}; -use alloy_consensus::{SidecarBuilder, SimpleCoder, Transaction}; +use alloy_consensus::{ + BlobTransactionSidecar, EthereumTxEnvelope, SidecarBuilder, SimpleCoder, Transaction, + TxEip4844, proofs::calculate_transaction_root, +}; use alloy_eips::{ - Typed2718, - eip4844::{BLOB_TX_MIN_BLOB_GASPRICE, DATA_GAS_PER_BLOB, MAX_DATA_GAS_PER_BLOCK_DENCUN}, + eip2718::{Decodable2718, EIP4844_TX_TYPE_ID, Typed2718}, + eip4844::{ + BLOB_TX_MIN_BLOB_GASPRICE, DATA_GAS_PER_BLOB, MAX_DATA_GAS_PER_BLOCK_DENCUN, + TARGET_DATA_GAS_PER_BLOCK_DENCUN, + }, + eip7840::BlobParams, +}; +use alloy_network::{ + AnyRpcTransaction, AnyTxEnvelope, EthereumWallet, ReceiptResponse, TransactionBuilder, + TransactionBuilder4844, }; -use alloy_hardforks::EthereumHardfork; -use alloy_network::{EthereumWallet, ReceiptResponse, TransactionBuilder, TransactionBuilder4844}; -use alloy_primitives::{Address, U256, b256}; -use alloy_provider::Provider; -use alloy_rpc_types::{BlockId, TransactionRequest}; +use alloy_primitives::{Address, B256, Bytes, U256, b256}; +use alloy_provider::{Provider, ProviderBuilder}; +use alloy_rpc_types::{Authorization, BlockId, TransactionRequest, trace::parity::TraceType}; use alloy_serde::WithOtherFields; +use alloy_signer::SignerSync; use anvil::{NodeConfig, spawn}; +use foundry_evm::hardfork::EthereumHardfork; +use foundry_test_utils::rpc; +use serde_json::{Value, json}; +use std::time::Duration; +use tokio::time::timeout; #[tokio::test(flavor = "multi_thread")] -async fn can_send_eip4844_transaction() { +async fn non_blob_receipt_omits_blob_fields() { let node_config = NodeConfig::test().with_hardfork(Some(EthereumHardfork::Cancun.into())); let (_api, handle) = spawn(node_config).await; + let provider = http_provider(&handle.http_endpoint()); + let accounts = handle.dev_accounts().collect::>(); + + let tx = TransactionRequest::default().with_from(accounts[0]).with_to(accounts[1]); + let receipt = provider.send_transaction(tx.into()).await.unwrap().get_receipt().await.unwrap(); + + assert_eq!(receipt.blob_gas_used, None); + assert_eq!(receipt.blob_gas_price, None); +} + +#[tokio::test(flavor = "multi_thread")] +async fn can_send_eip4844_transaction() { + let node_config = NodeConfig::test().with_hardfork(Some(EthereumHardfork::Cancun.into())); + let (api, handle) = spawn(node_config).await; let wallets = handle.dev_wallets().collect::>(); let from = wallets[0].address(); @@ -35,17 +64,131 @@ async fn can_send_eip4844_transaction() { .with_max_fee_per_blob_gas(gas_price + 1) .with_max_fee_per_gas(eip1559_est.max_fee_per_gas) .with_max_priority_fee_per_gas(eip1559_est.max_priority_fee_per_gas) - .with_blob_sidecar(sidecar) + .with_blob_sidecar_4844(sidecar) .value(U256::from(5)); - let mut tx = WithOtherFields::new(tx); - - tx.populate_blob_hashes(); + let tx = WithOtherFields::new(tx); let receipt = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); assert_eq!(receipt.blob_gas_used, Some(131072)); assert_eq!(receipt.blob_gas_price, Some(0x1)); // 1 wei + + let raw: Bytes = provider + .client() + .request("eth_getRawTransactionByHash", (receipt.transaction_hash,)) + .await + .unwrap(); + let canonical = EthereumTxEnvelope::::decode_2718(&mut raw.as_ref()).unwrap(); + let block = provider + .get_block_by_number(receipt.block_number.unwrap().into()) + .full() + .await + .unwrap() + .unwrap(); + + assert_eq!(block.header.transactions_root, calculate_transaction_root(&[canonical])); + let tx = serde_json::to_value(&block.transactions.as_transactions().unwrap()[0]).unwrap(); + for field in ["blobs", "commitments", "proofs", "cellProofs"] { + assert!(tx.get(field).is_none()); + } + + let raw_transactions: Vec = provider + .client() + .request("debug_getRawTransactions", (BlockId::number(block.header.number),)) + .await + .unwrap(); + assert_eq!(raw_transactions.len(), 1); + EthereumTxEnvelope::::decode_2718(&mut raw_transactions[0].as_ref()).unwrap(); + + let raw_block: Bytes = provider + .client() + .request("debug_getRawBlock", (BlockId::number(block.header.number),)) + .await + .unwrap(); + assert_eq!(block.header.size, Some(U256::from(raw_block.len()))); + let decoded: alloy_consensus::Block> = + alloy_rlp::Decodable::decode(&mut raw_block.as_ref()).unwrap(); + assert_eq!(decoded.body.transactions.len(), 1); + + assert!(api.anvil_get_blob_by_tx_hash(receipt.transaction_hash).unwrap().is_some()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn can_send_eip4844_transaction_fork() { + let node_config = NodeConfig::test() + .with_eth_rpc_url(Some(rpc::next_http_archive_rpc_url())) + .with_fork_block_number(Some(23432306u64)) + .with_hardfork(Some(EthereumHardfork::Cancun.into())); + let (api, handle) = spawn(node_config).await; + let provider = handle.http_provider(); + let accounts = provider.get_accounts().await.unwrap(); + let alice = accounts[0]; + let bob = accounts[1]; + + let sidecar: SidecarBuilder = SidecarBuilder::from_slice(b"Blobs are fun!"); + let sidecar: BlobTransactionSidecar = sidecar.build().unwrap(); + + let tx = TransactionRequest::default() + .with_from(alice) + .with_to(bob) + .with_blob_sidecar_4844(sidecar.clone()); + + let pending_tx = provider.send_transaction(tx.into()).await.unwrap(); + let receipt = pending_tx.get_receipt().await.unwrap(); + let tx_hash = receipt.transaction_hash; + + let _blobs = api.anvil_get_blob_by_tx_hash(tx_hash).unwrap().unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn can_send_eip4844_transaction_eth_send_transaction() { + let node_config = NodeConfig::test() + .with_eth_rpc_url(Some(rpc::next_http_archive_rpc_url())) + .with_fork_block_number(Some(23552208u64)) + .with_hardfork(Some(EthereumHardfork::Cancun.into())); + let (api, handle) = spawn(node_config).await; + let provider = ProviderBuilder::new().connect(handle.http_endpoint().as_str()).await.unwrap(); + let accounts = provider.get_accounts().await.unwrap(); + let alice = accounts[0]; + let bob = accounts[1]; + + let sidecar: SidecarBuilder = SidecarBuilder::from_slice(b"Blobs are fun!"); + let sidecar: BlobTransactionSidecar = sidecar.build().unwrap(); + + let tx = TransactionRequest::default() + .with_from(alice) + .with_to(bob) + .with_blob_sidecar_4844(sidecar.clone()); + + let pending_tx = provider.send_transaction(tx).await.unwrap(); + let receipt = pending_tx.get_receipt().await.unwrap(); + let tx_hash = receipt.transaction_hash; + + let _blobs = api.anvil_get_blob_by_tx_hash(tx_hash).unwrap().unwrap(); +} + +// +#[tokio::test(flavor = "multi_thread")] +async fn can_send_eip4844_transaction_with_eip7594_sidecar_format() { + let node_config = NodeConfig::test().with_hardfork(Some(EthereumHardfork::Osaka.into())); + let (api, handle) = spawn(node_config).await; + let provider = ProviderBuilder::new().connect(handle.http_endpoint().as_str()).await.unwrap(); + let accounts = provider.get_accounts().await.unwrap(); + let alice = accounts[0]; + let bob = accounts[1]; + + let sidecar: SidecarBuilder = SidecarBuilder::from_slice(b"Blobs are fun!"); + let sidecar = sidecar.build_7594().unwrap(); + + let tx = + TransactionRequest::default().with_from(alice).with_to(bob).with_blob_sidecar_7594(sidecar); + + let pending_tx = provider.send_transaction(tx).await.unwrap(); + let receipt = pending_tx.get_receipt().await.unwrap(); + let tx_hash = receipt.transaction_hash; + + let _blobs = api.anvil_get_blob_by_tx_hash(tx_hash).unwrap().unwrap(); } #[tokio::test(flavor = "multi_thread")] @@ -75,10 +218,8 @@ async fn can_send_multiple_blobs_in_one_tx() { .with_max_fee_per_blob_gas(gas_price + 1) .with_max_fee_per_gas(eip1559_est.max_fee_per_gas) .with_max_priority_fee_per_gas(eip1559_est.max_priority_fee_per_gas) - .with_blob_sidecar(sidecar); - let mut tx = WithOtherFields::new(tx); - - tx.populate_blob_hashes(); + .with_blob_sidecar_4844(sidecar); + let tx = WithOtherFields::new(tx); let receipt = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); @@ -113,10 +254,8 @@ async fn cannot_exceed_six_blobs() { .with_max_fee_per_blob_gas(gas_price + 1) .with_max_fee_per_gas(eip1559_est.max_fee_per_gas) .with_max_priority_fee_per_gas(eip1559_est.max_priority_fee_per_gas) - .with_blob_sidecar(sidecar); - let mut tx = WithOtherFields::new(tx); - - tx.populate_blob_hashes(); + .with_blob_sidecar_4844(sidecar); + let tx = WithOtherFields::new(tx); let err = provider.send_transaction(tx).await.unwrap_err(); @@ -153,11 +292,9 @@ async fn can_mine_blobs_when_exceeds_max_blobs() { .with_max_fee_per_blob_gas(gas_price + 1) .with_max_fee_per_gas(eip1559_est.max_fee_per_gas) .with_max_priority_fee_per_gas(eip1559_est.max_priority_fee_per_gas) - .with_blob_sidecar(sidecar); + .with_blob_sidecar_4844(sidecar); let mut tx = WithOtherFields::new(tx); - tx.populate_blob_hashes(); - let first_tx = provider.send_transaction(tx.clone()).await.unwrap(); let second_batch = vec![1u8; DATA_GAS_PER_BLOB as usize * 2]; @@ -167,16 +304,15 @@ async fn can_mine_blobs_when_exceeds_max_blobs() { let num_blobs_second = sidecar.clone().take().len() as u64; let sidecar = sidecar.build().unwrap(); - tx.set_blob_sidecar(sidecar); + tx.set_blob_sidecar_4844(sidecar); tx.set_nonce(1); - tx.populate_blob_hashes(); let second_tx = provider.send_transaction(tx).await.unwrap(); - api.mine_one().await; + api.mine_one().await.unwrap(); let first_receipt = first_tx.get_receipt().await.unwrap(); - api.mine_one().await; + api.mine_one().await.unwrap(); let second_receipt = second_tx.get_receipt().await.unwrap(); let (first_block, second_block) = tokio::join!( @@ -196,6 +332,51 @@ async fn can_mine_blobs_when_exceeds_max_blobs() { assert_eq!(first_receipt.block_number.unwrap() + 1, second_receipt.block_number.unwrap()); } +/// Blob txs deferred because the block ran out of blob gas must be auto mined in the next block. +#[tokio::test(flavor = "multi_thread")] +async fn auto_mine_retries_blob_tx_exceeding_max_blobs() { + let node_config = NodeConfig::test().with_hardfork(Some(EthereumHardfork::Cancun.into())); + let (api, handle) = spawn(node_config).await; + api.anvil_set_auto_mine(false).await.unwrap(); + + let wallets = handle.dev_wallets().collect::>(); + let from = wallets[0].address(); + let to = wallets[1].address(); + + let provider = http_provider(&handle.http_endpoint()); + + let eip1559_est = provider.estimate_eip1559_fees().await.unwrap(); + let gas_price = provider.get_gas_price().await.unwrap(); + + // Four and three blobs, which together exceed the six blobs a Cancun block can hold. + let mut pending = Vec::new(); + for (nonce, blob_data_len) in [3, 2].into_iter().enumerate() { + let data = vec![1u8; DATA_GAS_PER_BLOB as usize * blob_data_len]; + let sidecar = SidecarBuilder::::from_slice(&data).build().unwrap(); + let tx = TransactionRequest::default() + .with_from(from) + .with_to(to) + .with_nonce(nonce as u64) + .with_max_fee_per_blob_gas(gas_price + 1) + .with_max_fee_per_gas(eip1559_est.max_fee_per_gas) + .with_max_priority_fee_per_gas(eip1559_est.max_priority_fee_per_gas) + .with_blob_sidecar_4844(sidecar); + pending.push(provider.send_transaction(WithOtherFields::new(tx)).await.unwrap()); + } + + api.anvil_set_auto_mine(true).await.unwrap(); + + let mut blocks = Vec::new(); + for tx in pending { + let receipt = timeout(Duration::from_secs(5), tx.get_receipt()) + .await + .expect("blob tx deferred by the blob gas limit was never mined") + .unwrap(); + blocks.push(receipt.block_number.unwrap()); + } + assert_eq!(blocks[0] + 1, blocks[1]); +} + #[tokio::test(flavor = "multi_thread")] async fn can_check_blob_fields_on_genesis() { let node_config = NodeConfig::test().with_hardfork(Some(EthereumHardfork::Cancun.into())); @@ -224,7 +405,7 @@ async fn can_correctly_estimate_blob_gas_with_recommended_fillers() { let sidecar: SidecarBuilder = SidecarBuilder::from_slice(b"Blobs are fun!"); let sidecar = sidecar.build().unwrap(); - let tx = TransactionRequest::default().with_to(bob).with_blob_sidecar(sidecar); + let tx = TransactionRequest::default().with_to(bob).with_blob_sidecar_4844(sidecar); let tx = WithOtherFields::new(tx); // Send the transaction and wait for the broadcast. @@ -270,7 +451,7 @@ async fn can_correctly_estimate_blob_gas_with_recommended_fillers_with_signer() let sidecar: SidecarBuilder = SidecarBuilder::from_slice(b"Blobs are fun!"); let sidecar = sidecar.build().unwrap(); - let tx = TransactionRequest::default().with_to(bob).with_blob_sidecar(sidecar); + let tx = TransactionRequest::default().with_to(bob).with_blob_sidecar_4844(sidecar); let tx = WithOtherFields::new(tx); // Send the transaction and wait for the broadcast. @@ -298,6 +479,16 @@ async fn can_correctly_estimate_blob_gas_with_recommended_fillers_with_signer() ); } +#[tokio::test(flavor = "multi_thread")] +async fn rejects_malformed_eip4844_transaction() { + let node_config = NodeConfig::test().with_hardfork(Some(EthereumHardfork::Cancun.into())); + let (_api, handle) = spawn(node_config).await; + + let err = handle.http_provider().send_raw_transaction(&[EIP4844_TX_TYPE_ID]).await.unwrap_err(); + + assert!(err.to_string().contains("Failed to decode transaction")); +} + // #[tokio::test] async fn can_bypass_sidecar_requirement() { @@ -320,7 +511,7 @@ async fn can_bypass_sidecar_requirement() { from: Some(from), to: Some(alloy_primitives::TxKind::Call(to)), nonce: Some(0), - value: Some(U256::from(0)), + value: Some(U256::ZERO), max_fee_per_blob_gas: Some(gas_price + 1), max_fee_per_gas: Some(eip1559_est.max_fee_per_gas), max_priority_fee_per_gas: Some(eip1559_est.max_priority_fee_per_gas), @@ -362,7 +553,7 @@ async fn can_get_blobs_by_versioned_hash() { let sidecar: SidecarBuilder = SidecarBuilder::from_slice(b"Hello World"); - let sidecar = sidecar.build().unwrap(); + let sidecar: BlobTransactionSidecar = sidecar.build().unwrap(); let tx = TransactionRequest::default() .with_from(from) .with_to(to) @@ -370,12 +561,10 @@ async fn can_get_blobs_by_versioned_hash() { .with_max_fee_per_blob_gas(gas_price + 1) .with_max_fee_per_gas(eip1559_est.max_fee_per_gas) .with_max_priority_fee_per_gas(eip1559_est.max_priority_fee_per_gas) - .with_blob_sidecar(sidecar.clone()) + .with_blob_sidecar_4844(sidecar.clone()) .value(U256::from(5)); - let mut tx = WithOtherFields::new(tx); - - tx.populate_blob_hashes(); + let tx = WithOtherFields::new(tx); let _receipt = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); @@ -400,7 +589,7 @@ async fn can_get_blobs_by_tx_hash() { let sidecar: SidecarBuilder = SidecarBuilder::from_slice(b"Hello World"); - let sidecar = sidecar.build().unwrap(); + let sidecar: BlobTransactionSidecar = sidecar.build().unwrap(); let tx = TransactionRequest::default() .with_from(from) .with_to(to) @@ -408,16 +597,577 @@ async fn can_get_blobs_by_tx_hash() { .with_max_fee_per_blob_gas(gas_price + 1) .with_max_fee_per_gas(eip1559_est.max_fee_per_gas) .with_max_priority_fee_per_gas(eip1559_est.max_priority_fee_per_gas) - .with_blob_sidecar(sidecar.clone()) + .with_blob_sidecar_4844(sidecar.clone()) .value(U256::from(5)); - let mut tx = WithOtherFields::new(tx); - - tx.populate_blob_hashes(); - + let tx = WithOtherFields::new(tx); let receipt = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); let hash = receipt.transaction_hash; api.anvil_set_auto_mine(true).await.unwrap(); let blobs = api.anvil_get_blob_by_tx_hash(hash).unwrap().unwrap(); assert_eq!(blobs, sidecar.blobs); } + +#[tokio::test(flavor = "multi_thread")] +async fn simulate_v1_derives_blob_hashes_from_sidecars() { + let node_config = NodeConfig::test().with_hardfork(Some(EthereumHardfork::Cancun.into())); + let (_api, handle) = spawn(node_config).await; + let provider = http_provider(&handle.http_endpoint()); + let accounts = provider.get_accounts().await.unwrap(); + let contract = Address::with_last_byte(0x42); + let sidecar: BlobTransactionSidecar = + SidecarBuilder::::from_slice(b"Hello World").build().unwrap(); + let versioned_hash = sidecar.versioned_hash_for_blob(0).unwrap(); + let request = TransactionRequest::default() + .with_from(accounts[0]) + .with_to(contract) + .with_blob_sidecar_4844(sidecar); + let payload = json!({ + "blockStateCalls": [{ + "stateOverrides": { + contract.to_string(): { + "code": "0x5f495f5260205ff3" + } + }, + "calls": [request] + }], + "returnFullTransactions": true + }); + + let response: Value = + provider.client().request("eth_simulateV1", (payload, "latest")).await.unwrap(); + let block = &response[0]; + + assert_eq!(block["calls"][0]["returnData"], versioned_hash.to_string()); + let transaction = &block["transactions"][0]; + for field in ["blobs", "commitments", "proofs", "cellProofs"] { + assert!(transaction.get(field).is_none(), "unexpected pooled field {field}"); + } +} + +// Like geth's eth_call, a blob call without a blob fee cap runs at a zero blob base fee, while +// calls with a cap and non-blob calls see the block's blob base fee. +#[tokio::test(flavor = "multi_thread")] +async fn call_defaults_blob_fee_cap_to_zero() { + let node_config = NodeConfig::test().with_hardfork(Some(EthereumHardfork::Cancun.into())); + let (api, handle) = spawn(node_config).await; + let provider = http_provider(&handle.http_endpoint()); + let accounts = provider.get_accounts().await.unwrap(); + + // Returns BLOBBASEFEE. + let contract = Address::with_last_byte(0x42); + api.anvil_set_code(contract, Bytes::from_static(&[0x4a, 0x5f, 0x52, 0x60, 0x20, 0x5f, 0xf3])) + .await + .unwrap(); + let blob_base_fee = U256::from(provider.get_blob_base_fee().await.unwrap()); + assert!(!blob_base_fee.is_zero()); + + let sidecar: BlobTransactionSidecar = + SidecarBuilder::::from_slice(b"Hello World").build().unwrap(); + let blob_call = TransactionRequest { + from: Some(accounts[0]), + to: Some(contract.into()), + blob_versioned_hashes: Some(vec![sidecar.versioned_hash_for_blob(0).unwrap()]), + max_fee_per_gas: Some(2_000_000_000), + max_priority_fee_per_gas: Some(0), + ..Default::default() + }; + let plain_call = TransactionRequest { + blob_versioned_hashes: None, + max_fee_per_blob_gas: None, + ..blob_call.clone() + }; + + // Like geth, access lists are built at the block's blob base fee. The contract loads slot + // BLOBBASEFEE. + let sload_blob_base_fee = Address::with_last_byte(0x43); + api.anvil_set_code(sload_blob_base_fee, Bytes::from_static(&[0x4a, 0x54, 0x00])).await.unwrap(); + let request = TransactionRequest { to: Some(sload_blob_base_fee.into()), ..blob_call.clone() }; + let result = api.create_access_list(WithOtherFields::new(request), None, None).await.unwrap(); + assert_eq!(result.access_list.0[0].storage_keys, [B256::from(blob_base_fee)]); + + for (request, expected) in [ + (blob_call.clone(), U256::ZERO), + (TransactionRequest { max_fee_per_blob_gas: Some(0), ..blob_call.clone() }, U256::ZERO), + ( + TransactionRequest { + max_fee_per_gas: None, + max_priority_fee_per_gas: None, + max_fee_per_blob_gas: Some(1_000_000_000), + ..blob_call.clone() + }, + blob_base_fee, + ), + ( + TransactionRequest { max_fee_per_blob_gas: Some(1_000_000_000), ..blob_call }, + blob_base_fee, + ), + (plain_call, blob_base_fee), + ] { + let request = WithOtherFields::new(request); + let output = provider.call(request.clone()).await.unwrap(); + assert_eq!(U256::from_be_slice(&output), expected); + let traced = + api.trace_call(request, [TraceType::Trace].into_iter().collect(), None).await.unwrap(); + assert_eq!(U256::from_be_slice(&traced.output), expected); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn simulate_v1_defaults_blob_fee_cap_to_zero() { + let node_config = NodeConfig::test().with_hardfork(Some(EthereumHardfork::Cancun.into())); + let (_api, handle) = spawn(node_config).await; + let provider = http_provider(&handle.http_endpoint()); + let accounts = provider.get_accounts().await.unwrap(); + let contract = Address::with_last_byte(0x42); + let sidecar: BlobTransactionSidecar = + SidecarBuilder::::from_slice(b"Hello World").build().unwrap(); + let request = TransactionRequest { + from: Some(accounts[0]), + to: Some(contract.into()), + blob_versioned_hashes: Some(vec![sidecar.versioned_hash_for_blob(0).unwrap()]), + max_fee_per_gas: Some(2_000_000_000), + max_priority_fee_per_gas: Some(0), + ..Default::default() + }; + let block = |blob_base_fee: Option| { + json!({ + "blockOverrides": blob_base_fee.map(|blob_base_fee| { + json!({"blobBaseFee": format!("0x{blob_base_fee:x}")}) + }), + "stateOverrides": { + contract.to_string(): { + "code": "0x4a5f5260205ff3" + } + }, + "calls": [request.clone()] + }) + }; + let payload = json!({ + "blockStateCalls": [block(None)], + "validation": true, + "returnFullTransactions": true + }); + + let response: Result = + provider.client().request("eth_simulateV1", (payload, "latest")).await; + let error = response.unwrap_err(); + let error = error.as_error_resp().unwrap(); + assert_eq!(error.code, -32003); + assert_eq!( + error.message, + "Block `blob_gas_price` is greater than tx-specified `max_fee_per_blob_gas`" + ); + + let payload = json!({ + "blockStateCalls": [block(None)], + "validation": false, + "returnFullTransactions": true + }); + let response: Value = + provider.client().request("eth_simulateV1", (payload, "latest")).await.unwrap(); + assert_eq!(response[0]["calls"][0]["returnData"], format!("0x{:064x}", 1)); + assert_eq!(response[0]["transactions"][0]["maxFeePerBlobGas"], "0x0"); + + let payload = json!({ + "blockStateCalls": [block(Some(21))], + "validation": false, + "returnFullTransactions": true + }); + let response: Value = + provider.client().request("eth_simulateV1", (payload, "latest")).await.unwrap(); + assert_eq!(response[0]["calls"][0]["returnData"], format!("0x{:064x}", 21)); + assert_eq!(response[0]["transactions"][0]["maxFeePerBlobGas"], "0x0"); + + let sender = Address::with_last_byte(0x43); + let payload = |max_fee_per_blob_gas, balance| { + let mut request = request.clone(); + request.from = Some(sender); + request.gas = Some(30_000); + request.max_fee_per_gas = Some(0); + request.max_priority_fee_per_gas = Some(0); + request.max_fee_per_blob_gas = max_fee_per_blob_gas; + json!({ + "blockStateCalls": [{ + "blockOverrides": {"blobBaseFee": "0x15"}, + "stateOverrides": { + sender.to_string(): {"balance": format!("0x{balance:x}")}, + contract.to_string(): {"code": "0x4a5f5260205ff3"} + }, + "calls": [request] + }], + "validation": false, + "returnFullTransactions": true + }) + }; + + for max_fee_per_blob_gas in [None, Some(0)] { + let response: Value = provider + .client() + .request("eth_simulateV1", (payload(max_fee_per_blob_gas, 0), "latest")) + .await + .unwrap(); + assert_eq!(response[0]["calls"][0]["returnData"], format!("0x{:064x}", 21)); + assert_eq!(response[0]["transactions"][0]["maxFeePerBlobGas"], "0x0"); + } + + let response: Result = + provider.client().request("eth_simulateV1", (payload(Some(20), 4_000_000), "latest")).await; + assert_eq!(response.unwrap_err().as_error_resp().unwrap().code, -32003); + + let response: Result = + provider.client().request("eth_simulateV1", (payload(Some(30), 3_000_000), "latest")).await; + assert_eq!(response.unwrap_err().as_error_resp().unwrap().code, -38014); + + let response: Value = provider + .client() + .request("eth_simulateV1", (payload(Some(30), 4_000_000), "latest")) + .await + .unwrap(); + assert_eq!(response[0]["calls"][0]["returnData"], format!("0x{:064x}", 21)); + assert_eq!(response[0]["transactions"][0]["maxFeePerBlobGas"], "0x1e"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn simulate_v1_executes_the_canonical_transaction_type() { + let node_config = NodeConfig::test().with_hardfork(Some(EthereumHardfork::Berlin.into())); + let (_api, handle) = spawn(node_config).await; + let provider = http_provider(&handle.http_endpoint()); + let accounts = provider.get_accounts().await.unwrap(); + let request = TransactionRequest { + from: Some(accounts[0]), + to: Some(accounts[1].into()), + access_list: Some(Default::default()), + ..Default::default() + }; + let payload = json!({ + "blockStateCalls": [{"calls": [request]}], + "returnFullTransactions": true + }); + + let response: Result = + provider.client().request("eth_simulateV1", (payload, "latest")).await; + + assert!(response.is_err(), "canonical EIP-1559 transaction executed before London"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn simulate_v1_only_accounts_blobs_for_canonical_eip4844_transactions() { + let node_config = NodeConfig::test().with_hardfork(Some(EthereumHardfork::Prague.into())); + let (_api, handle) = spawn(node_config).await; + let provider = http_provider(&handle.http_endpoint()); + let accounts = provider.get_accounts().await.unwrap(); + let wallets = handle.dev_wallets().collect::>(); + let sidecar: BlobTransactionSidecar = + SidecarBuilder::::from_slice(b"Hello World").build().unwrap(); + let authorization = + Authorization { chain_id: U256::from(31337), address: accounts[2], nonce: 0 }; + let signature = wallets[1].sign_hash_sync(&authorization.signature_hash()).unwrap(); + let mut request = TransactionRequest::default() + .with_from(accounts[0]) + .with_to(accounts[1]) + .with_blob_sidecar_4844(sidecar); + request.authorization_list = Some(vec![authorization.into_signed(signature)]); + let payload = json!({ + "blockStateCalls": [{"calls": [request]}], + "returnFullTransactions": true + }); + + let response: Value = + provider.client().request("eth_simulateV1", (payload, "latest")).await.unwrap(); + + let transaction = &response[0]["transactions"][0]; + assert_eq!(transaction["type"], "0x4"); + for field in ["blobVersionedHashes", "blobs", "commitments", "proofs", "cellProofs"] { + assert!(transaction.get(field).is_none(), "unexpected blob field {field}"); + } + assert_eq!(response[0]["blobGasUsed"], "0x0"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn simulate_v1_uses_canonical_blob_transaction_root() { + let node_config = NodeConfig::test().with_hardfork(Some(EthereumHardfork::Cancun.into())); + let (_api, handle) = spawn(node_config).await; + let provider = http_provider(&handle.http_endpoint()); + let accounts = provider.get_accounts().await.unwrap(); + let sidecar: BlobTransactionSidecar = + SidecarBuilder::::from_slice(b"Hello World").build().unwrap(); + let request = TransactionRequest { + from: Some(accounts[0]), + to: Some(accounts[1].into()), + transaction_type: Some(3), + blob_versioned_hashes: Some(vec![sidecar.versioned_hash_for_blob(0).unwrap()]), + max_fee_per_blob_gas: Some(BLOB_TX_MIN_BLOB_GASPRICE), + ..Default::default() + }; + let payload = json!({ + "blockStateCalls": [{"calls": [request]}], + "returnFullTransactions": true + }); + + let response: Value = + provider.client().request("eth_simulateV1", (payload, "latest")).await.unwrap(); + let block = &response[0]; + let transaction: AnyRpcTransaction = + serde_json::from_value(block["transactions"][0].clone()).unwrap(); + let canonical = AnyTxEnvelope::from(transaction); + + assert_eq!(block["transactionsRoot"], calculate_transaction_root(&[canonical]).to_string()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn simulate_v1_rejects_cumulative_blob_gas_overflow() { + let node_config = NodeConfig::test().with_hardfork(Some(EthereumHardfork::Cancun.into())); + let (_api, handle) = spawn(node_config).await; + let provider = http_provider(&handle.http_endpoint()); + let accounts = provider.get_accounts().await.unwrap(); + let sidecar: BlobTransactionSidecar = + SidecarBuilder::::from_slice(b"Hello World").build().unwrap(); + let versioned_hash = sidecar.versioned_hash_for_blob(0).unwrap(); + let full_request = TransactionRequest { + from: Some(accounts[0]), + to: Some(accounts[1].into()), + blob_versioned_hashes: Some(vec![versioned_hash; 6]), + ..Default::default() + }; + let mut sidecar_request = TransactionRequest::default() + .with_from(accounts[0]) + .with_to(accounts[1]) + .with_blob_sidecar_4844(sidecar); + sidecar_request.blob_versioned_hashes = Some(Vec::new()); + let payload = json!({ + "blockStateCalls": [{"calls": [full_request, sidecar_request]}] + }); + + let response: Result = + provider.client().request("eth_simulateV1", (payload, "latest")).await; + let error = response.unwrap_err(); + let error = error.as_error_resp().unwrap(); + + assert_eq!(error.code, -32602); + assert_eq!( + error.message, + format!( + "blob gas usage exceeds the limit of {MAX_DATA_GAS_PER_BLOCK_DENCUN} gas per block." + ) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn simulate_v1_uses_bpo_blob_gas_limits() { + for (hardfork, blob_params) in + [(EthereumHardfork::Bpo1, BlobParams::bpo1()), (EthereumHardfork::Bpo2, BlobParams::bpo2())] + { + let mut node_config = NodeConfig::test().with_hardfork(Some(hardfork.into())); + if hardfork == EthereumHardfork::Bpo2 { + node_config = node_config + .with_chain_id(Some(1u64)) + .with_genesis_timestamp(EthereumHardfork::Bpo1.mainnet_activation_timestamp()); + } + let (_api, handle) = spawn(node_config).await; + let provider = http_provider(&handle.http_endpoint()); + let accounts = provider.get_accounts().await.unwrap(); + let sidecar: BlobTransactionSidecar = + SidecarBuilder::::from_slice(b"Hello World").build().unwrap(); + let versioned_hash = sidecar.versioned_hash_for_blob(0).unwrap(); + let request = |blob_count| TransactionRequest { + from: Some(accounts[0]), + to: Some(accounts[1].into()), + blob_versioned_hashes: Some(vec![versioned_hash; blob_count]), + ..Default::default() + }; + let max_blobs = blob_params.max_blob_count as usize; + let calls = (0..max_blobs.div_ceil(blob_params.max_blobs_per_tx as usize)) + .map(|idx| { + request( + (max_blobs - idx * blob_params.max_blobs_per_tx as usize) + .min(blob_params.max_blobs_per_tx as usize), + ) + }) + .collect::>(); + let payload = json!({"blockStateCalls": [{"calls": calls}]}); + + let response: Value = + provider.client().request("eth_simulateV1", (payload, "latest")).await.unwrap(); + assert_eq!( + response[0]["blobGasUsed"], + format!("0x{:x}", blob_params.max_blob_gas_per_block()) + ); + + let mut over_limit_calls = (0..max_blobs.div_ceil(blob_params.max_blobs_per_tx as usize)) + .map(|idx| { + request( + (max_blobs - idx * blob_params.max_blobs_per_tx as usize) + .min(blob_params.max_blobs_per_tx as usize), + ) + }) + .collect::>(); + over_limit_calls.push(request(1)); + let payload = json!({"blockStateCalls": [{"calls": over_limit_calls}]}); + let response: Result = + provider.client().request("eth_simulateV1", (payload, "latest")).await; + let error = response.unwrap_err(); + + assert_eq!(error.as_error_resp().unwrap().code, -32602); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn simulate_v1_updates_blob_schedule_at_bpo_timestamp() { + let bpo1_timestamp = EthereumHardfork::Bpo1.mainnet_activation_timestamp().unwrap(); + let node_config = NodeConfig::test() + .with_chain_id(Some(1u64)) + .with_hardfork(Some(EthereumHardfork::Osaka.into())) + .with_genesis_timestamp(Some(bpo1_timestamp - 24)); + let (_api, handle) = spawn(node_config).await; + let provider = http_provider(&handle.http_endpoint()); + let accounts = provider.get_accounts().await.unwrap(); + let sidecar: BlobTransactionSidecar = + SidecarBuilder::::from_slice(b"Hello World").build().unwrap(); + let versioned_hash = sidecar.versioned_hash_for_blob(0).unwrap(); + let request = |blob_count| TransactionRequest { + from: Some(accounts[0]), + to: Some(accounts[1].into()), + blob_versioned_hashes: Some(vec![versioned_hash; blob_count]), + ..Default::default() + }; + let payload = json!({ + "blockStateCalls": [ + {"calls": [request(6), request(3)]}, + { + "blockOverrides": {"time": format!("0x{bpo1_timestamp:x}")}, + "calls": [request(6), request(4)] + } + ] + }); + + let response: Value = + provider.client().request("eth_simulateV1", (payload, "latest")).await.unwrap(); + + assert_eq!( + response[1]["blobGasUsed"], + format!("0x{:x}", BlobParams::bpo1().target_blob_gas_per_block()) + ); + assert_eq!(response[1]["excessBlobGas"], "0x0"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn simulate_v1_advances_excess_blob_gas() { + let node_config = NodeConfig::test().with_hardfork(Some(EthereumHardfork::Cancun.into())); + let (_api, handle) = spawn(node_config).await; + let provider = http_provider(&handle.http_endpoint()); + let accounts = provider.get_accounts().await.unwrap(); + let sidecar: BlobTransactionSidecar = + SidecarBuilder::::from_slice(b"Hello World").build().unwrap(); + let versioned_hash = sidecar.versioned_hash_for_blob(0).unwrap(); + let request = |blob_count| TransactionRequest { + from: Some(accounts[0]), + to: Some(accounts[1].into()), + blob_versioned_hashes: Some(vec![versioned_hash; blob_count]), + ..Default::default() + }; + let payload = json!({ + "blockStateCalls": [ + {"calls": [request(6)]}, + {"calls": [request(1)]} + ] + }); + + let response: Value = + provider.client().request("eth_simulateV1", (payload, "latest")).await.unwrap(); + + assert_eq!(response[0]["blobGasUsed"], format!("0x{MAX_DATA_GAS_PER_BLOCK_DENCUN:x}")); + assert_eq!(response[0]["excessBlobGas"], "0x0"); + assert_eq!(response[1]["blobGasUsed"], format!("0x{DATA_GAS_PER_BLOB:x}")); + assert_eq!(response[1]["excessBlobGas"], format!("0x{TARGET_DATA_GAS_PER_BLOCK_DENCUN:x}")); +} + +// Gas estimation must leave room for the blob fee when capping gas by the sender's balance, but +// only for requests that execute as blob transactions. +#[tokio::test(flavor = "multi_thread")] +async fn estimate_gas_reserves_blob_fee() { + let node_config = NodeConfig::test().with_hardfork(Some(EthereumHardfork::Prague.into())); + let (api, handle) = spawn(node_config).await; + let provider = http_provider(&handle.http_endpoint()); + let accounts = provider.get_accounts().await.unwrap(); + let wallets = handle.dev_wallets().collect::>(); + + let from = Address::random(); + // 0.01 ETH pays for exactly 5M gas at 2 gwei, well below the block gas limit, so the balance + // caps the estimate and nothing is left for the blob fee unless it is reserved. + api.anvil_set_balance(from, U256::from(10_000_000_000_000_000u128)).await.unwrap(); + let blob_request = TransactionRequest { + from: Some(from), + to: Some(alloy_primitives::TxKind::Call(Address::random())), + max_fee_per_gas: Some(2_000_000_000), + max_priority_fee_per_gas: Some(1), + blob_versioned_hashes: Some(vec![b256!( + "0x01d5446006b21888d0267829344ab8624fdf1b425445a8ae1ca831bf1b8fbcd4" + )]), + ..Default::default() + }; + + // With an explicit blob fee cap, and with the block's blob gas price as the fallback. + for max_fee_per_blob_gas in [Some(2_000_000_000), None] { + let tx = TransactionRequest { max_fee_per_blob_gas, ..blob_request.clone() }; + let gas = provider.estimate_gas(WithOtherFields::new(tx)).await.unwrap(); + assert!(gas >= 21_000); + } + + // A sidecar-only request reserves the blob fee for the hashes its sidecar commits to. Input + // data keeps it off the plain transfer path, so the balance caps the estimate. + let sidecar: BlobTransactionSidecar = + SidecarBuilder::::from_slice(b"Hello World").build().unwrap(); + let tx = TransactionRequest { + max_fee_per_blob_gas: Some(2_000_000_000), + blob_versioned_hashes: None, + sidecar: Some(sidecar.into()), + input: Bytes::from_static(&[1]).into(), + ..blob_request.clone() + }; + let gas = provider.estimate_gas(WithOtherFields::new(tx)).await.unwrap(); + assert!(gas >= 21_000); + + // An authorization list makes the request execute as EIP-7702, which pays no blob fee. + let authorization = + Authorization { chain_id: U256::from(31337), address: accounts[2], nonce: 0 }; + let signature = wallets[1].sign_hash_sync(&authorization.signature_hash()).unwrap(); + let tx = TransactionRequest { + max_fee_per_blob_gas: Some(u128::from(u64::MAX)), + authorization_list: Some(vec![authorization.into_signed(signature)]), + ..blob_request + }; + provider.estimate_gas(WithOtherFields::new(tx)).await.unwrap(); +} + +// A legacy gas price prices a blob call, with its blob fee cap priced separately. +#[tokio::test(flavor = "multi_thread")] +async fn legacy_blob_call_runs_at_gas_price() { + let node_config = NodeConfig::test().with_hardfork(Some(EthereumHardfork::Cancun.into())); + let (api, handle) = spawn(node_config).await; + let provider = http_provider(&handle.http_endpoint()); + let accounts = provider.get_accounts().await.unwrap(); + + // Returns GASPRICE. + let contract = Address::with_last_byte(0x42); + api.anvil_set_code(contract, Bytes::from_static(&[0x3a, 0x5f, 0x52, 0x60, 0x20, 0x5f, 0xf3])) + .await + .unwrap(); + let sidecar: BlobTransactionSidecar = + SidecarBuilder::::from_slice(b"Hello World").build().unwrap(); + let gas_price = 2_000_000_000u128; + let request = WithOtherFields::new(TransactionRequest { + from: Some(accounts[0]), + to: Some(contract.into()), + gas_price: Some(gas_price), + blob_versioned_hashes: Some(vec![sidecar.versioned_hash_for_blob(0).unwrap()]), + max_fee_per_blob_gas: Some(1_000_000_000), + ..Default::default() + }); + + let output = provider.call(request.clone()).await.unwrap(); + assert_eq!(U256::from_be_slice(&output), U256::from(gas_price)); + let traced = + api.trace_call(request, [TraceType::Trace].into_iter().collect(), None).await.unwrap(); + assert_eq!(U256::from_be_slice(&traced.output), U256::from(gas_price)); +} diff --git a/crates/anvil/tests/it/eip6110.rs b/crates/anvil/tests/it/eip6110.rs new file mode 100644 index 0000000000000..f7519e211cbe4 --- /dev/null +++ b/crates/anvil/tests/it/eip6110.rs @@ -0,0 +1,39 @@ +use alloy_eips::{BlockNumberOrTag, eip6110::MAINNET_DEPOSIT_CONTRACT_ADDRESS}; +use alloy_network::TransactionBuilder; +use alloy_primitives::bytes; +use alloy_provider::Provider; +use alloy_rpc_types::TransactionRequest; +use alloy_serde::WithOtherFields; +use anvil::{NodeConfig, spawn}; +use foundry_evm::hardfork::EthereumHardfork; + +#[tokio::test(flavor = "multi_thread")] +async fn eip6110_pending_block_returns_malformed_deposit_error() { + let node_config = NodeConfig::test().with_hardfork(Some(EthereumHardfork::Prague.into())); + let (api, handle) = spawn(node_config).await; + let provider = handle.http_provider(); + + // PUSH32 DepositEvent topic, PUSH1 size=0, PUSH1 offset=0, LOG1, STOP. + api.anvil_set_code( + MAINNET_DEPOSIT_CONTRACT_ADDRESS, + bytes!("7f649bbc62d0e31342afea4e5cd82d4049e7e1ee912fc0889aa790803be39038c560006000a100"), + ) + .await + .unwrap(); + api.anvil_set_auto_mine(false).await.unwrap(); + let from = handle.dev_wallets().next().unwrap().address(); + let _pending = provider + .send_transaction(WithOtherFields::new( + TransactionRequest::default() + .from(from) + .to(MAINNET_DEPOSIT_CONTRACT_ADDRESS) + .with_gas_limit(100_000), + )) + .await + .unwrap(); + + let error = provider.get_block_by_number(BlockNumberOrTag::Pending).await.unwrap_err(); + let response = error.as_error_resp().expect("should return a JSON-RPC error"); + assert_eq!(response.code, -32603); + assert_eq!(provider.get_block_number().await.unwrap(), 0); +} diff --git a/crates/anvil/tests/it/eip7702.rs b/crates/anvil/tests/it/eip7702.rs index 2faaed75a4c8d..20ac76d19e93d 100644 --- a/crates/anvil/tests/it/eip7702.rs +++ b/crates/anvil/tests/it/eip7702.rs @@ -1,13 +1,14 @@ use crate::utils::http_provider; use alloy_consensus::{SignableTransaction, transaction::TxEip7702}; -use alloy_hardforks::EthereumHardfork; use alloy_network::{ReceiptResponse, TransactionBuilder, TxSignerSync}; -use alloy_primitives::{U256, bytes}; +use alloy_primitives::{U256, address, bytes}; use alloy_provider::{PendingTransactionConfig, Provider}; use alloy_rpc_types::{Authorization, TransactionRequest}; use alloy_serde::WithOtherFields; -use alloy_signer::SignerSync; +use alloy_signer::{Signature, SignerSync}; +use alloy_signer_local::PrivateKeySigner; use anvil::{NodeConfig, spawn}; +use foundry_evm::hardfork::EthereumHardfork; #[tokio::test(flavor = "multi_thread")] async fn can_send_eip7702_tx() { @@ -153,3 +154,113 @@ async fn can_send_eip7702_request() { assert_eq!(log.topics().len(), 0); assert_eq!(log.data().data, log_data); } + +#[tokio::test(flavor = "multi_thread")] +async fn eip7702_authorization_bypass() { + let node_config = NodeConfig::test().with_hardfork(Some(EthereumHardfork::Prague.into())); + let (api, handle) = spawn(node_config).await; + let provider = http_provider(&handle.http_endpoint()); + + let wallets = handle.dev_wallets().collect::>(); + + // deploy simple contract forwarding calldata to LOG0 + // PUSH7(CALLDATASIZE PUSH0 PUSH0 CALLDATACOPY CALLDATASIZE PUSH0 LOG0) PUSH0 MSTORE PUSH1(7) + // PUSH1(25) RETURN + let logger_bytecode = bytes!("66365f5f37365fa05f5260076019f3"); + + let eip1559_est = provider.estimate_eip1559_fees().await.unwrap(); + + let from = wallets[0].address(); + let tx = TransactionRequest::default() + .with_from(from) + .into_create() + .with_nonce(0) + .with_max_fee_per_gas(eip1559_est.max_fee_per_gas) + .with_max_priority_fee_per_gas(eip1559_est.max_priority_fee_per_gas) + .with_input(logger_bytecode); + + let receipt = provider + .send_transaction(WithOtherFields::new(tx)) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + + assert!(receipt.status()); + + let contract = receipt.contract_address.unwrap(); + let authorization = Authorization { + chain_id: U256::from(31337u64), + address: contract, + nonce: provider.get_transaction_count(from).await.unwrap(), + }; + let fake_auth_sig = Signature::new(U256::ZERO, U256::ZERO, true); + api.anvil_impersonate_signature(fake_auth_sig.as_bytes().into(), from).await.unwrap(); + let authorization = authorization.into_signed(fake_auth_sig); + + let log_data = bytes!("11112222"); + let mut tx = TxEip7702 { + max_fee_per_gas: eip1559_est.max_fee_per_gas, + max_priority_fee_per_gas: eip1559_est.max_priority_fee_per_gas, + gas_limit: 100000, + chain_id: 31337, + to: from, + input: bytes!("11112222"), + authorization_list: vec![authorization], + ..Default::default() + }; + let signature = wallets[1].sign_transaction_sync(&mut tx).unwrap(); + + let tx = tx.into_signed(signature); + let mut encoded = Vec::new(); + tx.eip2718_encode(&mut encoded); + + let receipt = + provider.send_raw_transaction(&encoded).await.unwrap().get_receipt().await.unwrap(); + let log = &receipt.inner.inner.logs()[0]; + // assert that log was from EOA which signed authorization + assert_eq!(log.address(), from); + assert_eq!(log.topics().len(), 0); + assert_eq!(log.data().data, log_data); +} + +// EIP-7702 refunds part of the 25000 charged per authorization only when the authority already +// exists, so authorizing a fresh account costs the full amount. +#[tokio::test(flavor = "multi_thread")] +async fn eip7702_refunds_only_existing_authorities() { + let node_config = NodeConfig::test().with_hardfork(Some(EthereumHardfork::Prague.into())); + let (_api, handle) = spawn(node_config).await; + let provider = http_provider(&handle.http_endpoint()); + let wallets = handle.dev_wallets().collect::>(); + + // 21000 base, 64 for the four calldata bytes and 25000 for the authorization. An existing + // authority is refunded 12500, capped at a fifth of the gas used. + for (authority, gas_used) in + [(PrivateKeySigner::random(), 46_064), (wallets[1].clone(), 46_064 - 46_064 / 5)] + { + let authorization = Authorization { + chain_id: U256::from(31337u64), + address: address!("0x000000000000000000000000000000000000dEaD"), + nonce: provider.get_transaction_count(authority.address()).await.unwrap(), + }; + let signature = authority.sign_hash_sync(&authorization.signature_hash()).unwrap(); + let tx = TransactionRequest { + authorization_list: Some(vec![authorization.into_signed(signature)]), + ..Default::default() + } + .with_from(wallets[0].address()) + .with_to(address!("0x0000000000000000000000000000000000001234")) + .with_input(bytes!("12345678")); + + let receipt = provider + .send_transaction(WithOtherFields::new(tx)) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert!(receipt.status()); + assert_eq!(receipt.gas_used(), gas_used, "authority {}", authority.address()); + } +} diff --git a/crates/anvil/tests/it/eip7928.rs b/crates/anvil/tests/it/eip7928.rs new file mode 100644 index 0000000000000..f62ecff889d1b --- /dev/null +++ b/crates/anvil/tests/it/eip7928.rs @@ -0,0 +1,346 @@ +//! EIP-7928 block access list tests. +//! +//! Anvil builds block access lists for locally mined Amsterdam blocks and forwards requests for +//! blocks that predate a fork to the upstream node. + +use crate::abi::{COUNTER_INIT_CODE, COUNTER_RUNTIME_CODE}; +use alloy_eips::{ + eip2935::HISTORY_STORAGE_ADDRESS, + eip4788::BEACON_ROOTS_ADDRESS, + eip7002::WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, + eip7928::{ + AccountChanges, BlockAccessIndex, BlockAccessList, CodeChange, NonceChange, SlotChanges, + StorageChange, compute_block_access_list_hash, validate_block_access_list, + }, +}; +use alloy_network::{Network, TransactionBuilder}; +use alloy_primitives::{Address, B256, Bytes, U256}; +use alloy_provider::Provider; +use alloy_rpc_types::{BlockId, BlockNumberOrTag, TransactionRequest}; +use alloy_serde::WithOtherFields; +use anvil::{NodeConfig, spawn}; +use foundry_evm::hardfork::EthereumHardfork; +use foundry_test_utils::rpc::spawn_rpc_proxy_canned_method; +use serde_json::{Value, json}; +use std::sync::atomic::Ordering; + +/// The four endpoints, paired with the params each takes for a block that exists. +async fn assert_all_null(provider: &impl Provider, hash: B256, number: u64) { + let by_id: Option = provider + .client() + .request("eth_getBlockAccessList", (BlockId::number(number),)) + .await + .unwrap(); + assert_eq!(by_id, None); + + let by_hash: Option = + provider.client().request("eth_getBlockAccessListByBlockHash", (hash,)).await.unwrap(); + assert_eq!(by_hash, None); + + let by_number: Option = provider + .client() + .request("eth_getBlockAccessListByBlockNumber", (BlockNumberOrTag::Number(number),)) + .await + .unwrap(); + assert_eq!(by_number, None); + + let raw: Option = provider + .client() + .request("eth_getBlockAccessListRaw", (BlockId::number(number),)) + .await + .unwrap(); + assert_eq!(raw, None); +} + +#[tokio::test(flavor = "multi_thread")] +async fn block_access_list_is_null_before_amsterdam() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + api.mine_one().await.unwrap(); + let block = provider.get_block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + + assert_all_null(&provider, block.header.hash, block.header.number).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn locally_mined_amsterdam_block_has_access_list() { + let (api, handle) = + spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Amsterdam.into()))).await; + let provider = handle.http_provider(); + + api.mine_one().await.unwrap(); + let block = provider.get_block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + let number = block.header.number; + let hash = block.header.hash; + + let by_id: BlockAccessList = provider + .client() + .request::<_, Option>("eth_getBlockAccessList", (BlockId::number(number),)) + .await + .unwrap() + .unwrap(); + assert!(!by_id.is_empty(), "Amsterdam system calls should produce BAL entries"); + + let by_hash = provider.get_block_access_list_by_hash(hash).await.unwrap().unwrap(); + let by_number = provider + .get_block_access_list_by_number(BlockNumberOrTag::Number(number)) + .await + .unwrap() + .unwrap(); + assert_eq!(by_hash, by_id); + assert_eq!(by_number, by_id); + + let raw = provider.get_block_access_list_raw(BlockId::number(number)).await.unwrap().unwrap(); + let mut expected_raw = Vec::new(); + alloy_rlp::encode_list(&by_id, &mut expected_raw); + assert_eq!(raw.as_ref(), expected_raw); + assert_eq!(block.header.block_access_list_hash, Some(compute_block_access_list_hash(&by_id))); +} + +#[tokio::test(flavor = "multi_thread")] +async fn locally_mined_transactions_use_ordered_bal_indices() { + let (api, handle) = + spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Amsterdam.into()))).await; + let provider = handle.http_provider(); + api.anvil_set_auto_mine(false).await.unwrap(); + let accounts = provider.get_accounts().await.unwrap(); + let from = accounts[0]; + let to = accounts[1]; + + for nonce in 0..2 { + let tx = TransactionRequest::default() + .with_from(from) + .with_to(to) + .with_value(U256::from(nonce + 1)) + .with_nonce(nonce); + let _ = provider.send_transaction(WithOtherFields::new(tx)).await.unwrap(); + } + api.mine_one().await.unwrap(); + + let bal = + provider.get_block_access_list_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + let sender = bal.iter().find(|account| account.address() == from).unwrap(); + let indices = + sender.nonce_changes.iter().map(|change| change.block_access_index()).collect::>(); + assert_eq!(indices, [BlockAccessIndex::new(1), BlockAccessIndex::new(2)]); +} + +fn account(bal: &BlockAccessList, address: Address) -> &AccountChanges { + bal.iter().find(|account| account.address() == address).unwrap_or_else(|| { + panic!("{address} is missing from the block access list"); + }) +} + +fn storage_indices(account: &AccountChanges) -> Vec { + account + .storage_changes + .iter() + .flat_map(|slot| slot.changes.iter().map(|change| change.block_access_index)) + .collect() +} + +#[tokio::test(flavor = "multi_thread")] +async fn locally_mined_block_access_list_records_every_phase() { + let (api, handle) = + spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Amsterdam.into()))).await; + let provider = handle.http_provider(); + api.anvil_set_auto_mine(false).await.unwrap(); + let sender = handle.dev_wallets().next().unwrap().address(); + let nonce = provider.get_transaction_count(sender).await.unwrap(); + let contract = sender.create(nonce); + + let deploy = TransactionRequest::default() + .with_from(sender) + .with_nonce(nonce) + .with_deploy_code(COUNTER_INIT_CODE) + .with_gas_limit(1_000_000); + let call = TransactionRequest::default() + .with_from(sender) + .with_nonce(nonce + 1) + .with_to(contract) + .with_gas_limit(1_000_000); + let _ = provider.send_transaction(WithOtherFields::new(deploy)).await.unwrap(); + let _ = provider.send_transaction(WithOtherFields::new(call)).await.unwrap(); + api.mine_one().await.unwrap(); + + let block = provider.get_block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert_eq!(block.transactions.len(), 2); + assert_eq!(provider.get_storage_at(contract, U256::ZERO).await.unwrap(), U256::from(2)); + let bal = provider.get_block_access_list_by_hash(block.header.hash).await.unwrap().unwrap(); + validate_block_access_list(&bal, 2).unwrap(); + assert_eq!(block.header.block_access_list_hash, Some(compute_block_access_list_hash(&bal))); + + // Transactions occupy indices 1 and 2. + let index = |index: u64| BlockAccessIndex::new(index); + let sender_changes = account(&bal, sender); + assert_eq!( + sender_changes.nonce_changes, + [NonceChange::new(index(1), nonce + 1), NonceChange::new(index(2), nonce + 2)] + ); + assert_eq!( + sender_changes + .balance_changes + .iter() + .map(|change| change.block_access_index()) + .collect::>(), + [index(1), index(2)] + ); + let beneficiary = account(&bal, block.header.beneficiary); + assert_eq!( + beneficiary + .balance_changes + .iter() + .map(|change| change.block_access_index()) + .collect::>(), + [index(1), index(2)] + ); + let contract_changes = account(&bal, contract); + assert_eq!(contract_changes.nonce_changes, [NonceChange::new(index(1), 1)]); + assert_eq!(contract_changes.code_changes, [CodeChange::new(index(1), COUNTER_RUNTIME_CODE)]); + assert_eq!( + contract_changes.storage_changes, + [SlotChanges::new( + U256::ZERO, + vec![ + StorageChange::new(index(1), U256::ONE), + StorageChange::new(index(2), U256::from(2)), + ], + )] + ); + + // Pre-block system calls write at index 0, post-block system calls after the last + // transaction. + for system_contract in [BEACON_ROOTS_ADDRESS, HISTORY_STORAGE_ADDRESS] { + let indices = storage_indices(account(&bal, system_contract)); + assert!(!indices.is_empty(), "{system_contract} should record pre-block writes"); + assert!(indices.iter().all(|i| *i == index(0)), "{system_contract}: {indices:?}"); + } + let indices = storage_indices(account(&bal, WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS)); + assert!(indices.iter().all(|i| *i == index(3)), "{indices:?}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn reverting_snapshot_removes_local_block_access_list() { + let (api, handle) = + spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Amsterdam.into()))).await; + let snapshot = api.evm_snapshot().await.unwrap(); + api.mine_one().await.unwrap(); + let block = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert!(api.block_access_list_by_hash(block.header.hash).await.unwrap().is_some()); + + assert!(api.evm_revert(snapshot).await.unwrap()); + assert_eq!(api.block_access_list_by_hash(block.header.hash).await.unwrap(), None); + assert_eq!(handle.http_provider().get_block_number().await.unwrap(), 0); +} + +#[tokio::test(flavor = "multi_thread")] +async fn block_access_list_rejects_out_of_range_blocks() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + // Block resolution happens before the fork check, so an out-of-range number is an error + // rather than a `null` access list. + for method in ["eth_getBlockAccessList", "eth_getBlockAccessListRaw"] { + let err = provider + .client() + .request::<_, Option>(method, (BlockId::number(9999),)) + .await + .unwrap_err(); + assert!(err.to_string().contains("BlockOutOfRangeError"), "{method}: unexpected {err}"); + } + + // An unknown hash has no range to check and simply reports no access list. + let by_hash: Option = provider + .client() + .request("eth_getBlockAccessListByBlockHash", (B256::random(),)) + .await + .unwrap(); + assert_eq!(by_hash, None); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_block_access_list_forwards_pre_fork_blocks() { + let (_origin_api, origin_handle) = spawn(NodeConfig::test()).await; + let canned = json!({ "blockAccessList": [] }); + let (proxy, calls) = spawn_rpc_proxy_canned_method( + origin_handle.http_endpoint(), + "eth_getBlockAccessList", + canned.clone(), + ) + .await; + + let (_api, handle) = + spawn(NodeConfig::test().with_eth_rpc_url(Some(proxy)).with_no_bal(true)).await; + let provider = handle.http_provider(); + + // The fork block itself predates the fork, so the request reaches the upstream node. + let fork_block = provider.get_block_number().await.unwrap(); + let forwarded: Option = provider + .client() + .request("eth_getBlockAccessList", (BlockId::number(fork_block),)) + .await + .unwrap(); + assert_eq!(forwarded, Some(canned)); + assert_eq!(calls.load(Ordering::Relaxed), 1); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_block_access_list_skips_locally_mined_blocks() { + let (_origin_api, origin) = spawn(NodeConfig::test()).await; + let (fork_url, upstream_calls) = spawn_rpc_proxy_canned_method( + origin.http_endpoint(), + "eth_getBlockAccessListByBlockNumber", + json!({"blockAccessList": []}), + ) + .await; + let (api, _handle) = spawn(NodeConfig::test().with_eth_rpc_url(Some(fork_url))).await; + + api.mine_one().await.unwrap(); + let number = api.block_number().unwrap().to::(); + assert!(number > 0, "expected a locally mined block"); + + let access_list = + api.block_access_list_by_number(BlockNumberOrTag::Number(number)).await.unwrap(); + + assert_eq!(access_list, None); + assert_eq!(upstream_calls.load(Ordering::Relaxed), 0); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_block_access_list_by_hash_skips_locally_mined_blocks() { + let (_origin_api, origin) = spawn(NodeConfig::test()).await; + let (fork_url, upstream_calls) = spawn_rpc_proxy_canned_method( + origin.http_endpoint(), + "eth_getBlockAccessListByBlockHash", + json!({"blockAccessList": []}), + ) + .await; + let (api, _handle) = spawn(NodeConfig::test().with_eth_rpc_url(Some(fork_url))).await; + + api.mine_one().await.unwrap(); + let block = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert!(block.header.number > 0, "expected a locally mined block"); + + let access_list = api.block_access_list_by_hash(block.header.hash).await.unwrap(); + + assert_eq!(access_list, None); + assert_eq!(upstream_calls.load(Ordering::Relaxed), 0); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_block_access_list_by_hash_forwards_unknown_blocks() { + let (_origin_api, origin) = spawn(NodeConfig::test()).await; + let (fork_url, upstream_calls) = spawn_rpc_proxy_canned_method( + origin.http_endpoint(), + "eth_getBlockAccessListByBlockHash", + json!({"blockAccessList": []}), + ) + .await; + let (api, _handle) = spawn(NodeConfig::test().with_eth_rpc_url(Some(fork_url))).await; + + let access_list = api.block_access_list_by_hash(B256::random()).await.unwrap(); + + assert_eq!(access_list, Some(json!({"blockAccessList": []}))); + assert_eq!(upstream_calls.load(Ordering::Relaxed), 1); +} diff --git a/crates/anvil/tests/it/filter.rs b/crates/anvil/tests/it/filter.rs new file mode 100644 index 0000000000000..f4c9ee3ce2010 --- /dev/null +++ b/crates/anvil/tests/it/filter.rs @@ -0,0 +1,248 @@ +//! Tests for the polling-based `eth_*Filter` RPC surface. +//! +//! Log filter *changes* are covered in [`crate::logs`]; these tests cover filter installation, +//! draining, `eth_getFilterLogs`, uninstallation, and the error paths for unknown ids. + +use crate::{abi::SimpleStorage, utils::http_provider_with_signer}; +use alloy_network::EthereumWallet; +use alloy_primitives::B256; +use alloy_provider::Provider; +use alloy_rpc_types::{BlockNumberOrTag, Filter, Log}; +use anvil::{NodeConfig, spawn}; + +/// An id that was never handed out by `eth_new*Filter`. +const UNKNOWN_FILTER_ID: &str = "0xdeadbeefdeadbeefdeadbeefdeadbeef"; + +#[tokio::test(flavor = "multi_thread")] +async fn block_filter_drains_new_block_hashes() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + let filter_id: String = provider.client().request_noparams("eth_newBlockFilter").await.unwrap(); + + // A filter installed on an idle node has nothing to report yet. + let changes: Vec = + provider.client().request("eth_getFilterChanges", (filter_id.clone(),)).await.unwrap(); + assert!(changes.is_empty(), "expected no blocks before mining, got {changes:?}"); + + api.mine_one().await.unwrap(); + api.mine_one().await.unwrap(); + + let changes: Vec = + provider.client().request("eth_getFilterChanges", (filter_id.clone(),)).await.unwrap(); + let mut expected = Vec::new(); + for number in [1u64, 2] { + let block = + provider.get_block_by_number(BlockNumberOrTag::Number(number)).await.unwrap().unwrap(); + expected.push(block.header.hash); + } + assert_eq!(changes, expected); + + // Polling drains the filter: the same blocks are not reported twice. + let changes: Vec = + provider.client().request("eth_getFilterChanges", (filter_id,)).await.unwrap(); + assert!(changes.is_empty(), "expected drained filter, got {changes:?}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn new_filter_seeds_historic_logs_only_with_from_block() { + let (_api, handle) = spawn(NodeConfig::test()).await; + + let wallet = handle.dev_wallets().next().unwrap(); + let account = wallet.address(); + let signer: EthereumWallet = wallet.into(); + let provider = http_provider_with_signer(&handle.http_endpoint(), signer); + + // The constructor emits a `ValueChanged` log before either filter is installed. + let contract = + SimpleStorage::deploy(provider.clone(), "initial value".to_string()).await.unwrap(); + + // A filter with a past `fromBlock` replays the logs that predate it. + let historic = + Filter::new().address(*contract.address()).from_block(BlockNumberOrTag::Earliest); + let historic_id: String = + provider.client().request("eth_newFilter", (historic,)).await.unwrap(); + + // A filter without a block range only reports logs emitted from now on. + let live = Filter::new().address(*contract.address()); + let live_id: String = provider.client().request("eth_newFilter", (live,)).await.unwrap(); + + let changes: Vec = + provider.client().request("eth_getFilterChanges", (historic_id,)).await.unwrap(); + assert_eq!(changes.len(), 1, "expected the constructor log to be replayed"); + + let changes: Vec = + provider.client().request("eth_getFilterChanges", (live_id,)).await.unwrap(); + assert!(changes.is_empty(), "expected no historic logs, got {changes:?}"); + + // A future range starts reporting logs only when its first block is mined. + let start = provider.get_block_number().await.unwrap() + 2; + let future = Filter::new().address(*contract.address()).from_block(start); + let future_id: String = provider.client().request("eth_newFilter", (future,)).await.unwrap(); + contract + .setValue("before range".to_string()) + .from(account) + .send() + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + let receipt = contract + .setValue("in range".to_string()) + .from(account) + .send() + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + let changes: Vec = + provider.client().request("eth_getFilterChanges", (future_id,)).await.unwrap(); + assert_eq!(changes, receipt.inner.inner.logs()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn reverting_snapshot_reports_removed_logs() { + let (api, handle) = spawn(NodeConfig::test()).await; + + let wallet = handle.dev_wallets().next().unwrap(); + let account = wallet.address(); + let signer: EthereumWallet = wallet.into(); + let provider = http_provider_with_signer(&handle.http_endpoint(), signer); + + let contract = + SimpleStorage::deploy(provider.clone(), "initial value".to_string()).await.unwrap(); + let filter = Filter::new().address(*contract.address()); + let filter_id: String = provider.client().request("eth_newFilter", (filter,)).await.unwrap(); + let snapshot = api.evm_snapshot().await.unwrap(); + + contract + .setValue("reverted value".to_string()) + .from(account) + .send() + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + + let changes: Vec = + provider.client().request("eth_getFilterChanges", (filter_id.clone(),)).await.unwrap(); + assert_eq!(changes.len(), 1); + assert!(!changes[0].removed); + + assert!(api.evm_revert(snapshot).await.unwrap()); + + let removed: Vec = + provider.client().request("eth_getFilterChanges", (filter_id,)).await.unwrap(); + let mut expected = changes[0].clone(); + expected.removed = true; + assert_eq!(removed, vec![expected]); +} + +#[tokio::test(flavor = "multi_thread")] +async fn get_filter_logs_ignores_poll_position() { + let (_api, handle) = spawn(NodeConfig::test()).await; + + let wallet = handle.dev_wallets().next().unwrap(); + let account = wallet.address(); + let signer: EthereumWallet = wallet.into(); + let provider = http_provider_with_signer(&handle.http_endpoint(), signer); + + let contract = + SimpleStorage::deploy(provider.clone(), "initial value".to_string()).await.unwrap(); + + let filter = Filter::new().address(*contract.address()).from_block(BlockNumberOrTag::Earliest); + let filter_id: String = provider.client().request("eth_newFilter", (filter,)).await.unwrap(); + + contract + .setValue("hi".to_string()) + .from(account) + .send() + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + + // Drain the filter so that a subsequent `eth_getFilterChanges` would report nothing. + let changes: Vec = + provider.client().request("eth_getFilterChanges", (filter_id.clone(),)).await.unwrap(); + assert_eq!(changes.len(), 2, "expected the constructor and `setValue` logs"); + + let changes: Vec = + provider.client().request("eth_getFilterChanges", (filter_id.clone(),)).await.unwrap(); + assert!(changes.is_empty(), "expected drained filter, got {changes:?}"); + + // `eth_getFilterLogs` re-evaluates the filter instead of continuing from the poll position. + let logs: Vec = + provider.client().request("eth_getFilterLogs", (filter_id,)).await.unwrap(); + assert_eq!(logs.len(), 2, "expected all matching logs regardless of prior polling"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn get_filter_logs_rejects_non_log_filters() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + let block_filter: String = + provider.client().request_noparams("eth_newBlockFilter").await.unwrap(); + let err = provider + .client() + .request::<_, Vec>("eth_getFilterLogs", (block_filter,)) + .await + .unwrap_err(); + assert!(err.to_string().contains("filter not found"), "unexpected error: {err}"); + + let pending_filter: String = + provider.client().request("eth_newPendingTransactionFilter", (false,)).await.unwrap(); + let err = provider + .client() + .request::<_, Vec>("eth_getFilterLogs", (pending_filter,)) + .await + .unwrap_err(); + assert!(err.to_string().contains("filter not found"), "unexpected error: {err}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn uninstall_filter_reports_whether_a_filter_was_removed() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + let filter_id: String = provider.client().request_noparams("eth_newBlockFilter").await.unwrap(); + + let removed: bool = + provider.client().request("eth_uninstallFilter", (filter_id.clone(),)).await.unwrap(); + assert!(removed); + + // Uninstalling twice is not an error, but reports that nothing was removed. + let removed: bool = + provider.client().request("eth_uninstallFilter", (filter_id.clone(),)).await.unwrap(); + assert!(!removed); + + let removed: bool = + provider.client().request("eth_uninstallFilter", (UNKNOWN_FILTER_ID,)).await.unwrap(); + assert!(!removed); + + // An uninstalled filter can no longer be polled. + let err = provider + .client() + .request::<_, Vec>("eth_getFilterChanges", (filter_id,)) + .await + .unwrap_err(); + assert!(err.to_string().contains("filter not found"), "unexpected error: {err}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn get_filter_changes_rejects_unknown_ids() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + let err = provider + .client() + .request::<_, Vec>("eth_getFilterChanges", (UNKNOWN_FILTER_ID,)) + .await + .unwrap_err(); + assert!(err.to_string().contains("filter not found"), "unexpected error: {err}"); +} diff --git a/crates/anvil/tests/it/fork.rs b/crates/anvil/tests/it/fork.rs index 8d2813e2a01a2..c4e6a7ead5572 100644 --- a/crates/anvil/tests/it/fork.rs +++ b/crates/anvil/tests/it/fork.rs @@ -5,23 +5,68 @@ use crate::{ utils::{http_provider, http_provider_with_signer}, }; use alloy_chains::NamedChain; +use alloy_eips::{ + calc_next_block_base_fee, + eip1559::BaseFeeParams, + eip2718::Decodable2718, + eip7840::BlobParams, + eip7910::{EthConfig, SystemContract}, +}; +use alloy_genesis::Genesis; use alloy_network::{EthereumWallet, ReceiptResponse, TransactionBuilder, TransactionResponse}; -use alloy_primitives::{Address, Bytes, TxHash, TxKind, U64, U256, address, b256, bytes, uint}; -use alloy_provider::Provider; +use alloy_primitives::{Address, B256, Bytes, TxKind, U64, U256, address, b256, bytes, hex, uint}; +use alloy_provider::{ + Provider, + ext::{DebugApi, TxPoolApi}, +}; use alloy_rpc_types::{ - BlockId, BlockNumberOrTag, + AccountInfo, BlockId, BlockNumberOrTag, Index, anvil::Forking, request::{TransactionInput, TransactionRequest}, state::EvmOverrides, + trace::{ + geth::{CallConfig, GethDebugTracingOptions, GethTrace, TraceResult}, + parity::{Action, TraceResultsWithTransactionHash, TraceType}, + }, }; use alloy_serde::WithOtherFields; use alloy_signer_local::PrivateKeySigner; -use anvil::{NodeConfig, NodeHandle, eth::EthApi, spawn}; +use anvil::{ + EthereumHardfork, NodeConfig, NodeHandle, PrecompileFactory, + eth::{EthApi, fees::INITIAL_BASE_FEE}, + spawn, try_spawn, +}; +use axum::{Json, Router, routing::post}; use foundry_common::provider::get_http_provider; use foundry_config::Config; -use foundry_test_utils::rpc::{self, next_http_rpc_endpoint, next_rpc_endpoint}; -use futures::StreamExt; -use std::{sync::Arc, thread::sleep, time::Duration}; +use foundry_evm_networks::{NetworkConfigs, arbitrum}; +use foundry_primitives::{FoundryNetwork, FoundryReceiptEnvelope}; +use foundry_test_utils::rpc::{ + self, next_http_rpc_endpoint, next_rpc_endpoint, spawn_rpc_proxy_canned_method, + spawn_rpc_proxy_internal_error_after, spawn_rpc_proxy_method_not_found_before, + spawn_rpc_proxy_rejecting_method_after, spawn_rpc_proxy_rejecting_method_when_enabled, + spawn_rpc_proxy_retyping_first_block_transaction, +}; +use futures::{ + StreamExt, + future::{join_all, pending}, +}; +use revm::{ + context::BlockEnv, context_interface::block::BlobExcessGasAndPrice, + precompile::PrecompileStatus, primitives::hardfork::SpecId, +}; +use serde_json::{Value, json}; +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; + +#[cfg(feature = "optimism")] +use foundry_evm::hardfork::OpHardfork; const BLOCK_NUMBER: u64 = 14_608_400u64; const DEAD_BALANCE_AT_BLOCK_NUMBER: u128 = 12_556_069_338_441_120_059_867u128; @@ -31,9 +76,9 @@ const BLOCK_TIMESTAMP: u64 = 1_650_274_250u64; /// Represents an anvil fork of an anvil node #[expect(unused)] pub struct LocalFork { - origin_api: EthApi, + origin_api: EthApi, origin_handle: NodeHandle, - fork_api: EthApi, + fork_api: EthApi, fork_handle: NodeHandle, } @@ -60,1550 +105,4982 @@ pub fn fork_config() -> NodeConfig { .with_fork_block_number(Some(BLOCK_NUMBER)) } -#[tokio::test(flavor = "multi_thread")] -async fn test_fork_gas_limit_applied_from_config() { - let (api, _handle) = spawn(fork_config().with_gas_limit(Some(10_000_000))).await; +#[derive(Clone, Copy)] +enum ForkProbeFailure { + Stall, + RateLimit, +} - assert_eq!(api.gas_limit(), uint!(10_000_000_U256)); +/// Stalls or rate-limits a probe after the given number of successful requests. +async fn spawn_failing_fork_probe( + endpoint: String, + method: &'static str, + successful_calls: usize, + failure: ForkProbeFailure, +) -> String { + let client = reqwest::Client::new(); + let calls = Arc::new(AtomicUsize::new(0)); + let router = Router::new().route( + "/", + post(move |Json(request): Json| { + let client = client.clone(); + let endpoint = endpoint.clone(); + let calls = calls.clone(); + async move { + if request.get("method").and_then(Value::as_str) == Some(method) + && calls.fetch_add(1, Ordering::Relaxed) >= successful_calls + { + return match failure { + ForkProbeFailure::Stall => pending::>().await, + ForkProbeFailure::RateLimit => Json(json!({ + "jsonrpc": "2.0", + "id": request["id"], + "error": { "code": -32005, "message": "rate limit exceeded" }, + })), + }; + } + Json( + client + .post(endpoint) + .json(&request) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(), + ) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + format!("http://{address}") } #[tokio::test(flavor = "multi_thread")] -async fn test_fork_gas_limit_disabled_from_config() { - let (api, handle) = spawn(fork_config().disable_block_gas_limit(true)).await; - - // see https://github.com/foundry-rs/foundry/pull/8933 - assert_eq!(api.gas_limit(), U256::from(U64::MAX)); - - // try to mine a couple blocks - let provider = handle.http_provider(); - let tx = TransactionRequest::default() - .to(Address::random()) - .value(U256::from(1337u64)) - .from(handle.dev_wallets().next().unwrap().address()); - let tx = WithOtherFields::new(tx); - let _ = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); +async fn test_fork_ignores_initial_anvil_node_info_timeout() { + let (_api, origin) = + spawn(NodeConfig::test().with_chain_id(Some(NamedChain::Mainnet as u64))).await; + let fork_url = spawn_failing_fork_probe( + origin.http_endpoint(), + "anvil_nodeInfo", + 0, + ForkProbeFailure::Stall, + ) + .await; + let (api, _handle) = tokio::time::timeout( + Duration::from_secs(5), + spawn( + NodeConfig::test() + .with_eth_rpc_url(Some(fork_url)) + .fork_request_timeout(Some(Duration::from_secs(60))), + ), + ) + .await + .expect("optional node-info probes must not wait for the normal RPC timeout"); - let tx = TransactionRequest::default() - .to(Address::random()) - .value(U256::from(1337u64)) - .from(handle.dev_wallets().next().unwrap().address()); - let tx = WithOtherFields::new(tx); - let _ = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + assert_eq!(api.chain_id(), NamedChain::Mainnet as u64); } #[tokio::test(flavor = "multi_thread")] -async fn test_spawn_fork() { - let (api, _handle) = spawn(fork_config()).await; - assert!(api.is_fork()); - - let head = api.block_number().unwrap(); - assert_eq!(head, U256::from(BLOCK_NUMBER)) +async fn test_fork_bounds_anvil_node_info_retry_backoff() { + let (_api, origin) = + spawn(NodeConfig::test().with_chain_id(Some(NamedChain::Mainnet as u64))).await; + let fork_url = spawn_failing_fork_probe( + origin.http_endpoint(), + "anvil_nodeInfo", + 0, + ForkProbeFailure::RateLimit, + ) + .await; + let (api, _handle) = tokio::time::timeout( + Duration::from_secs(5), + spawn( + NodeConfig::test() + .with_eth_rpc_url(Some(fork_url)) + .fork_retry_backoff(Some(Duration::from_secs(60))), + ), + ) + .await + .expect("the probe deadline must include retry backoff"); + assert_eq!(api.chain_id(), NamedChain::Mainnet as u64); } #[tokio::test(flavor = "multi_thread")] -async fn test_fork_eth_get_balance() { - let (api, handle) = spawn(fork_config()).await; - let provider = handle.http_provider(); - for _ in 0..10 { - let addr = Address::random(); - let balance = api.balance(addr, None).await.unwrap(); - let provider_balance = provider.get_balance(addr).await.unwrap(); - assert_eq!(balance, provider_balance) - } +async fn test_fork_ignores_anvil_node_info_timeout_after_identification() { + let (_api, origin) = spawn(NodeConfig::test()).await; + let fork_url = spawn_failing_fork_probe( + origin.http_endpoint(), + "anvil_nodeInfo", + 1, + ForkProbeFailure::Stall, + ) + .await; + let result = tokio::time::timeout( + Duration::from_secs(5), + try_spawn( + NodeConfig::test() + .with_eth_rpc_url(Some(fork_url)) + .fork_request_timeout(Some(Duration::from_secs(60))), + ), + ) + .await + .expect("identified node-info probes must have a bounded deadline"); + let (api, _handle) = result.expect("node-info timeout must fall back after identification"); + assert_eq!(api.chain_id(), origin.http_provider().get_chain_id().await.unwrap()); } -// #[tokio::test(flavor = "multi_thread")] -async fn test_fork_eth_get_balance_after_mine() { - let (api, handle) = spawn(fork_config()).await; - let provider = handle.http_provider(); - let info = api.anvil_node_info().await.unwrap(); - let number = info.fork_config.fork_block_number.unwrap(); - assert_eq!(number, BLOCK_NUMBER); +async fn test_fork_ignores_anvil_metadata_timeout() { + let (_api, origin) = spawn(NodeConfig::test()).await; + let fork_url = spawn_failing_fork_probe( + origin.http_endpoint(), + "anvil_metadata", + 0, + ForkProbeFailure::Stall, + ) + .await; + let result = tokio::time::timeout( + Duration::from_secs(5), + try_spawn( + NodeConfig::test() + .with_eth_rpc_url(Some(fork_url)) + .fork_request_timeout(Some(Duration::from_secs(60))), + ), + ) + .await + .expect("metadata probes must have a bounded deadline"); + let (api, _handle) = result.expect("metadata timeout must use the fallback identity"); + assert_eq!(api.chain_id(), origin.http_provider().get_chain_id().await.unwrap()); +} - let address = Address::random(); +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_ignores_initial_anvil_node_info_rpc_error() { + let (_api, origin) = + spawn(NodeConfig::test().with_chain_id(Some(NamedChain::Mainnet as u64))).await; + let fork_url = + spawn_rpc_proxy_internal_error_after(origin.http_endpoint(), "anvil_nodeInfo", 0).await; - let _balance = provider.get_balance(address).await.unwrap(); + let (api, _handle) = spawn(NodeConfig::test().with_eth_rpc_url(Some(fork_url))).await; - api.evm_mine(None).await.unwrap(); + assert_eq!(api.chain_id(), NamedChain::Mainnet as u64); +} - let _balance = provider.get_balance(address).await.unwrap(); +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_skips_anvil_node_info_when_disabled() { + let (_api, origin) = + spawn(NodeConfig::test().with_chain_id(Some(NamedChain::Mainnet as u64))).await; + let (fork_url, node_info_calls) = + spawn_rpc_proxy_canned_method(origin.http_endpoint(), "anvil_nodeInfo", json!({})).await; + + let (api, _handle) = + spawn(NodeConfig::test().with_eth_rpc_url(Some(fork_url)).with_no_fork_node_info(true)) + .await; + + assert_eq!(api.chain_id(), NamedChain::Mainnet as u64); + assert_eq!(node_info_calls.load(Ordering::Relaxed), 0); } -// #[tokio::test(flavor = "multi_thread")] -async fn test_fork_eth_get_code_after_mine() { - let (api, handle) = spawn(fork_config()).await; - let provider = handle.http_provider(); - let info = api.anvil_node_info().await.unwrap(); - let number = info.fork_config.fork_block_number.unwrap(); - assert_eq!(number, BLOCK_NUMBER); +async fn test_fork_rejects_node_info_failure_after_anvil_identification() { + let (_api, origin) = spawn(NodeConfig::test()).await; + let fork_url = + spawn_rpc_proxy_rejecting_method_after(origin.http_endpoint(), "anvil_nodeInfo", 1).await; - let address = Address::random(); + let result = try_spawn(NodeConfig::test().with_eth_rpc_url(Some(fork_url))).await; + let Err(error) = result else { panic!("expected fork startup to fail") }; - let _code = provider.get_code_at(address).block_id(BlockId::number(1)).await.unwrap(); + assert!( + error.to_string().contains("failed to determine network family from fork endpoint"), + "{error}" + ); +} - api.evm_mine(None).await.unwrap(); +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_reset_keeps_node_info_probe_strict_after_identification_during_staging() { + let (_initial_api, initial_origin) = spawn(NodeConfig::test()).await; + let (api, _handle) = + spawn(NodeConfig::test().with_eth_rpc_url(Some(initial_origin.http_endpoint()))).await; + let (_target_api, target_origin) = spawn(NodeConfig::test()).await; + let fork_url = + spawn_rpc_proxy_rejecting_method_after(target_origin.http_endpoint(), "anvil_nodeInfo", 1) + .await; + + let error = api + .anvil_reset(Some(Forking { json_rpc_url: Some(fork_url), block_number: None })) + .await + .unwrap_err(); - let _code = provider.get_code_at(address).block_id(BlockId::number(1)).await.unwrap(); + assert!( + error.to_string().contains("failed to determine network family from fork endpoint"), + "{error}" + ); } #[tokio::test(flavor = "multi_thread")] -async fn test_fork_eth_get_code() { - let (api, handle) = spawn(fork_config()).await; - let provider = handle.http_provider(); - for _ in 0..10 { - let addr = Address::random(); - let code = api.get_code(addr, None).await.unwrap(); - let provider_code = provider.get_code_at(addr).await.unwrap(); - assert_eq!(code, provider_code) - } +async fn test_fork_reset_keeps_cached_anvil_node_info_probe_strict() { + let (_origin_api, origin) = spawn(NodeConfig::test()).await; + let (fork_url, reject_node_info) = + spawn_rpc_proxy_rejecting_method_when_enabled(origin.http_endpoint(), "anvil_nodeInfo") + .await; + let (api, _handle) = spawn(NodeConfig::test().with_eth_rpc_url(Some(fork_url.clone()))).await; + api.anvil_reset(None).await.unwrap(); + reject_node_info.store(true, Ordering::SeqCst); + + let error = api + .anvil_reset(Some(Forking { json_rpc_url: Some(fork_url), block_number: None })) + .await + .unwrap_err(); - let addresses: Vec
= vec![ - "0x6b175474e89094c44da98b954eedeac495271d0f".parse().unwrap(), - "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48".parse().unwrap(), - "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2".parse().unwrap(), - "0x1F98431c8aD98523631AE4a59f267346ea31F984".parse().unwrap(), - "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45".parse().unwrap(), - ]; - for address in addresses { - let prev_code = api - .get_code(address, Some(BlockNumberOrTag::Number(BLOCK_NUMBER - 10).into())) - .await - .unwrap(); - let code = api.get_code(address, None).await.unwrap(); - let provider_code = provider.get_code_at(address).await.unwrap(); - assert_eq!(code, prev_code); - assert_eq!(code, provider_code); - assert!(!code.as_ref().is_empty()); - } + assert!( + error.to_string().contains("failed to determine network family from fork endpoint"), + "{error}" + ); } #[tokio::test(flavor = "multi_thread")] -async fn test_fork_eth_get_nonce() { - let (api, handle) = spawn(fork_config()).await; - let provider = handle.http_provider(); +async fn test_fork_reset_does_not_carry_anvil_identity_to_new_endpoint() { + let (_initial_api, initial_origin) = spawn(NodeConfig::test()).await; + let (api, handle) = + spawn(NodeConfig::test().with_eth_rpc_url(Some(initial_origin.http_endpoint()))).await; + let (_target_api, target_origin) = spawn(NodeConfig::test().with_chain_id(Some(56u64))).await; + let target_url = + spawn_rpc_proxy_rejecting_method_after(target_origin.http_endpoint(), "anvil_nodeInfo", 0) + .await; - for _ in 0..10 { - let addr = Address::random(); - let api_nonce = api.transaction_count(addr, None).await.unwrap().to::(); - let provider_nonce = provider.get_transaction_count(addr).await.unwrap(); - assert_eq!(api_nonce, provider_nonce); - } + api.anvil_reset(Some(Forking { json_rpc_url: Some(target_url), block_number: None })) + .await + .unwrap(); - let addr = Config::DEFAULT_SENDER; - let api_nonce = api.transaction_count(addr, None).await.unwrap().to::(); - let provider_nonce = provider.get_transaction_count(addr).await.unwrap(); - assert_eq!(api_nonce, provider_nonce); + assert_eq!(handle.http_provider().get_chain_id().await.unwrap(), 56); } #[tokio::test(flavor = "multi_thread")] -async fn test_fork_optimism_with_transaction_hash() { - use std::str::FromStr; - - // Fork to a block with a specific transaction - let fork_tx_hash = - TxHash::from_str("fcb864b5a50f0f0b111dbbf9e9167b2cb6179dfd6270e1ad53aac6049c0ec038") - .unwrap(); - let (api, _handle) = spawn( - NodeConfig::test() - .with_eth_rpc_url(Some(rpc::next_rpc_endpoint(NamedChain::Optimism))) - .with_fork_transaction_hash(Some(fork_tx_hash)), +async fn test_fork_reset_keeps_set_rpc_url_anvil_node_info_probe_strict() { + let (_initial_api, initial_origin) = spawn(NodeConfig::test()).await; + let (api, _handle) = + spawn(NodeConfig::test().with_eth_rpc_url(Some(initial_origin.http_endpoint()))).await; + let (replacement_url, reject_node_info) = spawn_rpc_proxy_rejecting_method_when_enabled( + initial_origin.http_endpoint(), + "anvil_nodeInfo", ) .await; + api.anvil_set_rpc_url(replacement_url).await.unwrap(); + reject_node_info.store(true, Ordering::SeqCst); - // Make sure the fork starts from previous block - let block_number = api.block_number().unwrap().to::(); - assert_eq!(block_number, 125777954 - 1); + let error = api + .anvil_reset(Some(Forking { json_rpc_url: None, block_number: None })) + .await + .unwrap_err(); + + assert!( + error.to_string().contains("failed to determine network family from fork endpoint"), + "{error}" + ); } #[tokio::test(flavor = "multi_thread")] -async fn test_fork_eth_fee_history() { - let (api, handle) = spawn(fork_config()).await; - let provider = handle.http_provider(); +async fn test_fork_reset_keeps_validated_mirror_anvil_node_info_probe_strict() { + let (_origin_api, origin) = spawn(NodeConfig::test()).await; + let primary_url = origin.http_endpoint(); + let (mirror_url, reject_node_info) = + spawn_rpc_proxy_rejecting_method_when_enabled(primary_url.clone(), "anvil_nodeInfo").await; + let (api, _handle) = + spawn(NodeConfig::test().with_fork_urls(vec![primary_url, mirror_url.clone()])).await; + reject_node_info.store(true, Ordering::SeqCst); + + let error = api + .anvil_reset(Some(Forking { json_rpc_url: Some(mirror_url), block_number: None })) + .await + .unwrap_err(); - let count = 10u64; - let _history = - api.fee_history(U256::from(count), BlockNumberOrTag::Latest, vec![]).await.unwrap(); - let _provider_history = - provider.get_fee_history(count, BlockNumberOrTag::Latest, &[]).await.unwrap(); + assert!( + error.to_string().contains("failed to determine network family from fork endpoint"), + "{error}" + ); } #[tokio::test(flavor = "multi_thread")] -async fn test_fork_reset() { - let (api, handle) = spawn(fork_config()).await; - let provider = handle.http_provider(); - - let accounts: Vec<_> = handle.dev_wallets().collect(); - let from = accounts[0].address(); - let to = accounts[1].address(); - let block_number = provider.get_block_number().await.unwrap(); - let balance_before = provider.get_balance(to).await.unwrap(); - let amount = handle.genesis_balance().checked_div(U256::from(2u64)).unwrap(); - - let initial_nonce = provider.get_transaction_count(from).await.unwrap(); +async fn test_fork_retries_when_anvil_node_info_becomes_available() { + let (_api, origin) = spawn(NodeConfig::test()).await; + let fork_url = + spawn_rpc_proxy_method_not_found_before(origin.http_endpoint(), "anvil_nodeInfo", 1).await; - let tx = TransactionRequest::default().to(to).value(amount).from(from); - let tx = WithOtherFields::new(tx); - let tx = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); - assert_eq!(tx.transaction_index, Some(0)); + try_spawn(NodeConfig::test().with_eth_rpc_url(Some(fork_url))).await.unwrap(); +} - let nonce = provider.get_transaction_count(from).await.unwrap(); +// A replayed block holds only the transactions anvil executed, so a skipped one must not leave a +// gap in the indices storage keys receipts by. The proxy stands in for Arbitrum, which opens every +// block with a system transaction anvil cannot execute. +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_transaction_hash_replay_skips_unsupported_prefix() { + let (origin_api, origin_handle) = spawn(NodeConfig::test()).await; + origin_api.anvil_set_auto_mine(false).await.unwrap(); + let origin_provider = origin_handle.http_provider(); + // Distinct senders, because Arbitrum's system transactions do not consume a user nonce. + let senders = + origin_handle.dev_wallets().take(2).map(|wallet| wallet.address()).collect::>(); - assert_eq!(nonce, initial_nonce + 1); - let to_balance = provider.get_balance(to).await.unwrap(); - assert_eq!(balance_before.saturating_add(amount), to_balance); - api.anvil_reset(Some(Forking { json_rpc_url: None, block_number: Some(block_number) })) + let skipped = origin_provider + .send_transaction(WithOtherFields::new( + TransactionRequest::default() + .from(senders[0]) + .to(Address::random()) + .value(U256::ONE) + .nonce(0), + )) .await .unwrap(); + let target = origin_provider + .send_transaction(WithOtherFields::new( + TransactionRequest::default() + .from(senders[1]) + .to(Address::random()) + .value(U256::from(2)) + .nonce(0), + )) + .await + .unwrap(); + let target_hash = *target.tx_hash(); + origin_api.mine_one().await.unwrap(); - // reset block number - assert_eq!(block_number, provider.get_block_number().await.unwrap()); - - let nonce = provider.get_transaction_count(from).await.unwrap(); - assert_eq!(nonce, initial_nonce); - let balance = provider.get_balance(from).await.unwrap(); - assert_eq!(balance, handle.genesis_balance()); - let balance = provider.get_balance(to).await.unwrap(); - assert_eq!(balance, handle.genesis_balance()); + let fork_url = spawn_rpc_proxy_retyping_first_block_transaction( + origin_handle.http_endpoint(), + // `ArbitrumInternalTx`. + "0x6a", + ) + .await; + let (fork_api, fork_handle) = spawn( + NodeConfig::test() + .with_eth_rpc_url(Some(fork_url)) + .with_fork_transaction_hash(Some(target_hash)) + .with_no_mining(true), + ) + .await; + let fork_provider = fork_handle.http_provider(); - // reset to latest - api.anvil_reset(Some(Forking::default())).await.unwrap(); + // Only the target survives the prefix, so it takes index 0 in the replayed block. + let replayed = + fork_api.block_by_number_full(BlockNumberOrTag::Number(1)).await.unwrap().unwrap(); + assert_eq!( + replayed + .transactions + .as_transactions() + .unwrap() + .iter() + .map(|tx| tx.tx_hash()) + .collect::>(), + vec![target_hash] + ); - let new_block_num = provider.get_block_number().await.unwrap(); - assert!(new_block_num > block_number); + // Receipt lookups index into that block, so a stale index panics instead of answering. + let receipt = fork_provider.get_transaction_receipt(target_hash).await.unwrap().unwrap(); + assert_eq!(receipt.transaction_index(), Some(0)); + assert_eq!( + fork_provider.get_block_receipts(BlockId::number(1)).await.unwrap().unwrap().len(), + 1 + ); + assert!(fork_api.backend.mined_transaction_by_hash(*skipped.tx_hash()).is_none()); } #[tokio::test(flavor = "multi_thread")] -async fn test_fork_reset_setup() { - let (api, handle) = spawn(NodeConfig::test()).await; - let provider = handle.http_provider(); +async fn test_fork_transaction_hash_replays_before_startup() { + let (origin_api, origin_handle) = spawn(NodeConfig::test()).await; + origin_api.anvil_set_auto_mine(false).await.unwrap(); + let origin_provider = origin_handle.http_provider(); + let sender = origin_handle.dev_wallets().next().unwrap().address(); - let dead_addr: Address = "000000000000000000000000000000000000dEaD".parse().unwrap(); + let first = origin_provider + .send_transaction(WithOtherFields::new( + TransactionRequest::default() + .from(sender) + .to(Address::random()) + .value(U256::ONE) + .nonce(0), + )) + .await + .unwrap(); + let second = origin_provider + .send_transaction(WithOtherFields::new( + TransactionRequest::default() + .from(sender) + .to(Address::random()) + .value(U256::from(2)) + .nonce(1), + )) + .await + .unwrap(); + let reverted = origin_provider + .send_transaction(WithOtherFields::new( + TransactionRequest::default() + .from(sender) + .with_input(hex!("60006000fd").to_vec()) + .gas_limit(100_000) + .nonce(2), + )) + .await + .unwrap(); + let expected_hashes = [*first.tx_hash(), *second.tx_hash(), *reverted.tx_hash()]; + origin_api.mine_one().await.unwrap(); + assert!( + !origin_provider + .get_transaction_receipt(expected_hashes[2]) + .await + .unwrap() + .unwrap() + .status() + ); - let block_number = provider.get_block_number().await.unwrap(); - assert_eq!(block_number, 0); + let (fork_api, fork_handle) = spawn( + NodeConfig::test() + .with_eth_rpc_url(Some(origin_handle.http_endpoint())) + .with_fork_transaction_hash(Some(expected_hashes[2])) + .with_no_mining(true), + ) + .await; + let fork_provider = fork_handle.http_provider(); - let local_balance = provider.get_balance(dead_addr).await.unwrap(); - assert_eq!(local_balance, U256::ZERO); + assert_eq!(fork_api.block_number().unwrap(), U256::ONE); + let replayed = + fork_api.block_by_number_full(BlockNumberOrTag::Number(1)).await.unwrap().unwrap(); + let replayed_hashes = replayed + .transactions + .as_transactions() + .unwrap() + .iter() + .map(|tx| tx.tx_hash()) + .collect::>(); + assert_eq!(replayed_hashes, expected_hashes); + assert_eq!(fork_provider.txpool_status().await.unwrap().pending, 0); + assert_eq!(fork_provider.txpool_status().await.unwrap().queued, 0); + let content = fork_provider.txpool_content().await.unwrap(); + assert!(content.pending.is_empty()); + assert!(content.queued.is_empty()); + assert_eq!(fork_provider.get_transaction_count(sender).await.unwrap(), 3); + assert!( + !fork_provider.get_transaction_receipt(expected_hashes[2]).await.unwrap().unwrap().status() + ); - api.anvil_reset(Some(Forking { - json_rpc_url: Some(rpc::next_http_archive_rpc_url()), - block_number: Some(BLOCK_NUMBER), - })) - .await - .unwrap(); + let (prefix_api, _) = spawn( + NodeConfig::test() + .with_eth_rpc_url(Some(origin_handle.http_endpoint())) + .with_fork_transaction_hash(Some(expected_hashes[1])) + .with_no_mining(true), + ) + .await; + let prefix = + prefix_api.block_by_number_full(BlockNumberOrTag::Number(1)).await.unwrap().unwrap(); + assert_eq!( + prefix + .transactions + .as_transactions() + .unwrap() + .iter() + .map(|tx| tx.tx_hash()) + .collect::>(), + expected_hashes[..2] + ); + assert!(prefix_api.backend.mined_transaction_by_hash(expected_hashes[2]).is_none()); - let block_number = provider.get_block_number().await.unwrap(); - assert_eq!(block_number, BLOCK_NUMBER); + let (pruned_api, _) = spawn( + NodeConfig::test() + .with_eth_rpc_url(Some(origin_handle.http_endpoint())) + .with_fork_transaction_hash(Some(expected_hashes[0])) + .with_transaction_block_keeper(Some(0usize)) + .with_no_mining(true), + ) + .await; + assert!(pruned_api.backend.mined_transaction_by_hash(expected_hashes[0]).is_none()); + + let _pending = fork_provider + .send_transaction(WithOtherFields::new( + TransactionRequest::default() + .from(sender) + .to(Address::random()) + .value(U256::from(3)) + .nonce(3), + )) + .await + .unwrap(); + let pool = fork_provider.txpool_status().await.unwrap(); + assert_eq!(pool.pending, 1); + assert_eq!(pool.queued, 0); + + let (auto_mining_api, _) = spawn( + NodeConfig::test() + .with_eth_rpc_url(Some(origin_handle.http_endpoint())) + .with_fork_transaction_hash(Some(expected_hashes[2])), + ) + .await; + let replayed = + auto_mining_api.block_by_number_full(BlockNumberOrTag::Number(1)).await.unwrap().unwrap(); + assert_eq!( + replayed + .transactions + .as_transactions() + .unwrap() + .iter() + .map(|tx| tx.tx_hash()) + .collect::>(), + expected_hashes + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_transaction_hash_replay_preserves_cancun_header_inputs() { + let (origin_api, origin_handle) = + spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Cancun.into()))).await; + origin_api.anvil_set_auto_mine(false).await.unwrap(); + let origin_provider = origin_handle.http_provider(); + let sender = origin_handle.dev_wallets().next().unwrap().address(); + let genesis = + origin_api.block_by_number_full(BlockNumberOrTag::Number(0)).await.unwrap().unwrap(); + let source_timestamp = genesis.header.timestamp + 100; + origin_api.evm_set_next_block_timestamp(source_timestamp).unwrap(); + + let first = origin_provider + .send_transaction(WithOtherFields::new( + TransactionRequest::default() + .from(sender) + .to(Address::random()) + .value(U256::ONE) + .nonce(0), + )) + .await + .unwrap(); + let second = origin_provider + .send_transaction(WithOtherFields::new( + TransactionRequest::default() + .from(sender) + .to(Address::random()) + .value(U256::from(2)) + .nonce(1), + )) + .await + .unwrap(); + origin_api.mine_one().await.unwrap(); + let source = + origin_api.block_by_number_full(BlockNumberOrTag::Number(1)).await.unwrap().unwrap(); + assert_eq!(source.header.timestamp, source_timestamp); + + let (fork_api, _) = spawn( + NodeConfig::test() + .with_hardfork(Some(EthereumHardfork::Cancun.into())) + .with_eth_rpc_url(Some(origin_handle.http_endpoint())) + .with_fork_transaction_hash(Some(*second.tx_hash())) + .with_no_mining(true), + ) + .await; + let replayed = + fork_api.block_by_number_full(BlockNumberOrTag::Number(1)).await.unwrap().unwrap(); + + assert_eq!( + replayed + .transactions + .as_transactions() + .unwrap() + .iter() + .map(|tx| tx.tx_hash()) + .collect::>(), + [*first.tx_hash(), *second.tx_hash()] + ); + assert_eq!(replayed.header.timestamp, source.header.timestamp); + assert_eq!(replayed.header.parent_beacon_block_root, source.header.parent_beacon_block_root); + + fork_api.mine_one().await.unwrap(); + let next = fork_api.block_by_number_full(BlockNumberOrTag::Number(2)).await.unwrap().unwrap(); + assert!(next.header.timestamp > source.header.timestamp); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_transaction_hash_replay_resolves_source_hardfork() { + // Real chain ID so the hardfork can be resolved from chain + timestamp. + const MAINNET_CHAIN_ID: u64 = 1; + // Well before Cancun's 2024-03-13 activation. + const SHANGHAI_ERA_TIMESTAMP: u64 = 1_690_000_000u64; + + let (origin_api, origin_handle) = spawn( + NodeConfig::test() + .with_chain_id(Some(MAINNET_CHAIN_ID)) + .with_hardfork(Some(EthereumHardfork::Shanghai.into())) + .with_genesis_timestamp(Some(SHANGHAI_ERA_TIMESTAMP - 100)), + ) + .await; + origin_api.anvil_set_auto_mine(false).await.unwrap(); + origin_api.evm_set_next_block_timestamp(SHANGHAI_ERA_TIMESTAMP).unwrap(); + let origin_provider = origin_handle.http_provider(); + let sender = origin_handle.dev_wallets().next().unwrap().address(); + + let target = origin_provider + .send_transaction(WithOtherFields::new( + TransactionRequest::default().from(sender).to(Address::random()).value(U256::ONE), + )) + .await + .unwrap(); + let target_hash = *target.tx_hash(); + origin_api.mine_one().await.unwrap(); + let source = + origin_api.block_by_number_full(BlockNumberOrTag::Number(1)).await.unwrap().unwrap(); + assert_eq!(source.header.timestamp, SHANGHAI_ERA_TIMESTAMP); + assert!(source.header.parent_beacon_block_root.is_none()); + + // A newer explicit `--hardfork` used to wrongly require a `parentBeaconBlockRoot` on this + // pre-Cancun source block, failing startup. + let (fork_api, _fork_handle) = spawn( + NodeConfig::test() + .with_eth_rpc_url(Some(origin_handle.http_endpoint())) + .with_fork_transaction_hash(Some(target_hash)) + .with_hardfork(Some(EthereumHardfork::Prague.into())) + .with_no_mining(true), + ) + .await; + + let replayed = + fork_api.block_by_number_full(BlockNumberOrTag::Number(1)).await.unwrap().unwrap(); + assert_eq!( + replayed + .transactions + .as_transactions() + .unwrap() + .iter() + .map(|tx| tx.tx_hash()) + .collect::>(), + [target_hash] + ); + assert!(replayed.header.parent_beacon_block_root.is_none()); +} + +#[cfg(feature = "optimism")] +#[tokio::test(flavor = "multi_thread")] +async fn test_ethereum_fork_transaction_hash_replay_preserves_optimism_source_hardfork() { + const CANYON_ERA_TIMESTAMP: u64 = 1_705_000_000; + + let (origin_api, origin) = spawn( + NodeConfig::test() + .with_optimism() + .with_chain_id(Some(NamedChain::Optimism as u64)) + .with_hardfork(Some(OpHardfork::Canyon.into())) + .with_genesis_timestamp(Some(CANYON_ERA_TIMESTAMP - 100)), + ) + .await; + origin_api.anvil_set_auto_mine(false).await.unwrap(); + origin_api.evm_set_next_block_timestamp(CANYON_ERA_TIMESTAMP).unwrap(); + let provider = origin.http_provider(); + let sender = origin.dev_wallets().next().unwrap().address(); + let target = provider + .send_transaction(WithOtherFields::new( + TransactionRequest::default().from(sender).to(Address::random()).value(U256::ONE), + )) + .await + .unwrap(); + let target_hash = *target.tx_hash(); + origin_api.mine_one().await.unwrap(); + + let (fork_api, _) = spawn( + NodeConfig::test() + .with_networks(NetworkConfigs::with_ethereum()) + .with_hardfork(Some(EthereumHardfork::Prague.into())) + .with_eth_rpc_url(Some(origin.http_endpoint())) + .with_fork_transaction_hash(Some(target_hash)) + .with_no_mining(true), + ) + .await; + + let replayed = + fork_api.block_by_number_full(BlockNumberOrTag::Number(1)).await.unwrap().unwrap(); + assert_eq!( + replayed + .transactions + .as_transactions() + .unwrap() + .iter() + .map(|tx| tx.tx_hash()) + .collect::>(), + [target_hash] + ); + assert!(replayed.header.parent_beacon_block_root.is_none()); +} + +#[cfg(all(feature = "base", feature = "optimism"))] +#[tokio::test(flavor = "multi_thread")] +async fn test_ethereum_fork_transaction_hash_replay_preserves_base_id_op_source_hardfork() { + const AZUL_ERA_TIMESTAMP: u64 = 1_779_991_200; + + let (origin_api, origin) = spawn( + NodeConfig::test() + .with_networks(NetworkConfigs::with_optimism()) + .with_chain_id(Some(NamedChain::Base as u64)) + .with_hardfork(Some(OpHardfork::Jovian.into())) + .with_genesis_timestamp(Some(AZUL_ERA_TIMESTAMP - 100)), + ) + .await; + origin_api.anvil_set_auto_mine(false).await.unwrap(); + origin_api.evm_set_next_block_timestamp(AZUL_ERA_TIMESTAMP).unwrap(); + let provider = origin.http_provider(); + let sender = origin.dev_wallets().next().unwrap().address(); + let target = Address::random(); + // CLZ is active in Base Azul/Osaka, but not OP Jovian/Prague. If replay leaks Base's schedule, + // this transaction succeeds and stores 255 instead of preserving the source failure. + origin_api.anvil_set_code(target, bytes!("60011e60005500")).await.unwrap(); + let transaction = provider + .send_transaction(WithOtherFields::new( + TransactionRequest::default().from(sender).to(target).gas_limit(100_000), + )) + .await + .unwrap(); + let transaction_hash = *transaction.tx_hash(); + origin_api.mine_one().await.unwrap(); + assert!(!provider.get_transaction_receipt(transaction_hash).await.unwrap().unwrap().status()); + + let (fork_api, fork) = spawn( + NodeConfig::test() + .with_networks(NetworkConfigs::with_ethereum()) + .with_hardfork(Some(EthereumHardfork::Prague.into())) + .with_eth_rpc_url(Some(origin.http_endpoint())) + .with_fork_transaction_hash(Some(transaction_hash)) + .with_no_mining(true), + ) + .await; + let replayed_receipt = + fork.http_provider().get_transaction_receipt(transaction_hash).await.unwrap().unwrap(); + + assert!(!fork_api.backend.is_optimism()); + assert!(!replayed_receipt.status()); + assert_eq!(fork.http_provider().get_storage_at(target, U256::ZERO).await.unwrap(), U256::ZERO); +} + +#[cfg(all(feature = "base", feature = "optimism"))] +#[tokio::test(flavor = "multi_thread")] +async fn test_optimism_fork_transaction_hash_replay_uses_base_source_schedule() { + const AZUL_ERA_TIMESTAMP: u64 = 1_779_991_200; + + let (origin_api, origin) = spawn( + NodeConfig::test_base() + .with_hardfork(Some(foundry_evm::hardforks::BaseUpgrade::Azul.into())) + .with_genesis_timestamp(Some(AZUL_ERA_TIMESTAMP - 100)), + ) + .await; + origin_api.anvil_set_auto_mine(false).await.unwrap(); + origin_api.evm_set_next_block_timestamp(AZUL_ERA_TIMESTAMP).unwrap(); + let provider = origin.http_provider(); + let sender = origin.dev_wallets().next().unwrap().address(); + let target = Address::random(); + // Base Azul enables CLZ, while the selected OP schedule remains Jovian/Prague. Replaying with + // the source's Base schedule would incorrectly preserve this successful state transition. + origin_api.anvil_set_code(target, bytes!("60011e60005500")).await.unwrap(); + let transaction = provider + .send_transaction(WithOtherFields::new( + TransactionRequest::default().from(sender).to(target).gas_limit(100_000), + )) + .await + .unwrap(); + let transaction_hash = *transaction.tx_hash(); + origin_api.mine_one().await.unwrap(); + assert!(provider.get_transaction_receipt(transaction_hash).await.unwrap().unwrap().status()); + assert_eq!(provider.get_storage_at(target, U256::ZERO).await.unwrap(), U256::from(255)); + + let (fork_api, fork) = spawn( + NodeConfig::test() + .with_networks(NetworkConfigs::with_optimism()) + .with_eth_rpc_url(Some(origin.http_endpoint())) + .with_fork_transaction_hash(Some(transaction_hash)) + .with_no_mining(true), + ) + .await; + let replayed_receipt = + fork.http_provider().get_transaction_receipt(transaction_hash).await.unwrap().unwrap(); + + assert!(fork_api.backend.is_optimism()); + assert!(!replayed_receipt.status()); + assert_eq!(fork.http_provider().get_storage_at(target, U256::ZERO).await.unwrap(), U256::ZERO); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_transaction_hash_replay_applies_source_beacon_root() { + // Real chain ID so the hardfork can be resolved from chain + timestamp. + const MAINNET_CHAIN_ID: u64 = 1; + // Past Cancun's 2024-03-13 activation. + const CANCUN_ERA_TIMESTAMP: u64 = 1_720_000_000u64; + const HISTORY_BUFFER_LENGTH: u64 = 8191; + + let (origin_api, origin_handle) = spawn( + NodeConfig::test() + .with_chain_id(Some(MAINNET_CHAIN_ID)) + .with_hardfork(Some(EthereumHardfork::Cancun.into())) + .with_genesis_timestamp(Some(CANCUN_ERA_TIMESTAMP - 100)), + ) + .await; + origin_api.anvil_set_auto_mine(false).await.unwrap(); + origin_api.evm_set_next_block_timestamp(CANCUN_ERA_TIMESTAMP).unwrap(); + let origin_provider = origin_handle.http_provider(); + let sender = origin_handle.dev_wallets().next().unwrap().address(); + + let target = origin_provider + .send_transaction(WithOtherFields::new( + TransactionRequest::default().from(sender).to(Address::random()).value(U256::ONE), + )) + .await + .unwrap(); + let target_hash = *target.tx_hash(); + origin_api.mine_one().await.unwrap(); + let source = + origin_api.block_by_number_full(BlockNumberOrTag::Number(1)).await.unwrap().unwrap(); + assert_eq!(source.header.timestamp, CANCUN_ERA_TIMESTAMP); + assert!(source.header.parent_beacon_block_root.is_some()); + + // An older explicit `--hardfork` used to silently skip the EIP-4788 beacon-root call on this + // Cancun-era source block. + let (fork_api, _fork_handle) = spawn( + NodeConfig::test() + .with_eth_rpc_url(Some(origin_handle.http_endpoint())) + .with_fork_transaction_hash(Some(target_hash)) + .with_hardfork(Some(EthereumHardfork::Shanghai.into())) + .with_no_mining(true), + ) + .await; + + let replayed = + fork_api.block_by_number_full(BlockNumberOrTag::Number(1)).await.unwrap().unwrap(); + assert_eq!(replayed.header.parent_beacon_block_root, source.header.parent_beacon_block_root); + + let slot = U256::from(CANCUN_ERA_TIMESTAMP % HISTORY_BUFFER_LENGTH); + let stored_timestamp = fork_api + .storage_at(alloy_eips::eip4788::BEACON_ROOTS_ADDRESS, slot, Some(BlockId::number(1))) + .await + .unwrap(); + assert_eq!(stored_timestamp, B256::from(U256::from(CANCUN_ERA_TIMESTAMP))); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_transaction_hash_replay_error_fails_startup() { + let (_origin_api, origin_handle) = spawn(NodeConfig::test()).await; + let origin_provider = origin_handle.http_provider(); + let sender = origin_handle.dev_wallets().next().unwrap().address(); + let pending = origin_provider + .send_transaction(WithOtherFields::new( + TransactionRequest::default().from(sender).to(Address::random()).value(U256::ONE), + )) + .await + .unwrap(); + let transaction_hash = *pending.tx_hash(); + pending.get_receipt().await.unwrap(); + + let config = NodeConfig::test() + .with_eth_rpc_url(Some(origin_handle.http_endpoint())) + .with_fork_transaction_hash(Some(transaction_hash)) + .with_gas_limit(Some(20_000)); + let cache_path = config.block_cache_path(0).unwrap(); + match std::fs::remove_file(&cache_path) { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => panic!("failed to clear stale fork cache: {err}"), + } + + let result = try_spawn(config).await; + let Err(error) = result else { panic!("expected fork transaction replay to fail") }; + let message = error.to_string(); + assert!(message.contains("failed to replay fork transaction prefix")); + assert!(format!("{error:?}").contains(&transaction_hash.to_string())); + assert!(!cache_path.exists()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_gas_limit_applied_from_config() { + let (api, _handle) = spawn(fork_config().with_gas_limit(Some(10_000_000))).await; + + assert_eq!(api.gas_limit(), uint!(10_000_000_U256)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_gas_limit_disabled_from_config() { + let (api, handle) = spawn(fork_config().disable_block_gas_limit(true)).await; + + // see https://github.com/foundry-rs/foundry/pull/8933 + assert_eq!(api.gas_limit(), U256::from(U64::MAX)); + + // try to mine a couple blocks + let provider = handle.http_provider(); + let tx = TransactionRequest::default() + .to(Address::random()) + .value(U256::from(1337u64)) + .from(handle.dev_wallets().next().unwrap().address()); + let tx = WithOtherFields::new(tx); + let _ = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + + let tx = TransactionRequest::default() + .to(Address::random()) + .value(U256::from(1337u64)) + .from(handle.dev_wallets().next().unwrap().address()); + let tx = WithOtherFields::new(tx); + let _ = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_reset_refreshes_derived_gas_settings() { + let chain_id = 7_654_321u64; + let first_gas_limit = 21_000_001u64; + let first_base_fee = 101u64; + let first_gas_price = 111u128; + let second_gas_limit = 22_000_002u64; + let second_base_fee = 202u64; + let second_gas_price = 222u128; + let (first_api, first_handle) = spawn( + NodeConfig::test() + .with_chain_id(Some(chain_id)) + .with_gas_limit(Some(first_gas_limit)) + .with_base_fee(Some(first_base_fee)) + .with_gas_price(Some(first_gas_price)), + ) + .await; + let (second_api, second_handle) = spawn( + NodeConfig::test() + .with_chain_id(Some(chain_id)) + .with_gas_limit(Some(second_gas_limit)) + .with_base_fee(Some(second_base_fee)) + .with_gas_price(Some(second_gas_price)), + ) + .await; + let (api, _handle) = spawn( + NodeConfig::test() + .with_no_storage_caching(true) + .with_eth_rpc_url(Some(first_handle.http_endpoint())), + ) + .await; + + let first_info = api.anvil_node_info().await.unwrap(); + let first_next_base_fee = + calc_next_block_base_fee(0, first_gas_limit, first_base_fee, BaseFeeParams::ethereum()); + assert_eq!(first_info.environment.gas_limit, first_gas_limit); + assert_eq!(first_info.environment.base_fee, first_next_base_fee.into()); + assert_eq!(api.backend.fees().raw_gas_price(), first_api.gas_price()); + + api.anvil_reset(Some(Forking { + json_rpc_url: Some(second_handle.http_endpoint()), + block_number: Some(0), + })) + .await + .unwrap(); + + let second_info = api.anvil_node_info().await.unwrap(); + let second_next_base_fee = + calc_next_block_base_fee(0, second_gas_limit, second_base_fee, BaseFeeParams::ethereum()); + assert_eq!(second_info.environment.gas_limit, second_gas_limit); + assert_eq!(second_info.environment.base_fee, second_next_base_fee.into()); + assert_eq!(api.backend.fees().raw_gas_price(), second_api.gas_price()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_reset_preserves_explicit_gas_settings_and_restores_memory() { + let chain_id = 7_654_322u64; + let explicit_gas_limit = 31_000_003u64; + let explicit_base_fee = 303u64; + let explicit_gas_price = 333u128; + let (_first_api, first_handle) = spawn( + NodeConfig::test() + .with_chain_id(Some(chain_id)) + .with_gas_limit(Some(21_000_001)) + .with_base_fee(Some(101)) + .with_gas_price(Some(111)), + ) + .await; + let (_second_api, second_handle) = spawn( + NodeConfig::test() + .with_chain_id(Some(chain_id)) + .with_gas_limit(Some(22_000_002)) + .with_base_fee(Some(202)) + .with_gas_price(Some(222)), + ) + .await; + let (api, _handle) = spawn( + NodeConfig::test() + .with_no_storage_caching(true) + .with_gas_limit(Some(explicit_gas_limit)) + .with_base_fee(Some(explicit_base_fee)) + .with_gas_price(Some(explicit_gas_price)) + .with_eth_rpc_url(Some(first_handle.http_endpoint())), + ) + .await; + + for url in [first_handle.http_endpoint(), second_handle.http_endpoint()] { + api.anvil_reset(Some(Forking { json_rpc_url: Some(url), block_number: Some(0) })) + .await + .unwrap(); + let info = api.anvil_node_info().await.unwrap(); + assert_eq!(info.environment.gas_limit, explicit_gas_limit); + assert_eq!(info.environment.base_fee, explicit_base_fee.into()); + assert_eq!(api.backend.fees().raw_gas_price(), explicit_gas_price); + } + + api.anvil_reset(None).await.unwrap(); + let local_info = api.anvil_node_info().await.unwrap(); + assert!(local_info.fork_config.fork_url.is_none()); + assert_eq!(local_info.environment.gas_limit, explicit_gas_limit); + assert_eq!(local_info.environment.base_fee, explicit_base_fee.into()); + assert_eq!(api.backend.fees().raw_gas_price(), explicit_gas_price); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_reset_restores_implicit_memory_base_fee() { + let (_origin_api, origin_handle) = spawn(NodeConfig::test().with_base_fee(Some(123))).await; + let (api, handle) = spawn( + NodeConfig::test() + .with_no_storage_caching(true) + .with_eth_rpc_url(Some(origin_handle.http_endpoint())) + .with_fork_block_number(Some(0u64)), + ) + .await; + + api.anvil_set_next_block_base_fee_per_gas(U256::from(999)).await.unwrap(); + api.anvil_reset(None).await.unwrap(); + + let local_info = api.anvil_node_info().await.unwrap(); + assert!(local_info.fork_config.fork_url.is_none()); + assert_eq!(local_info.environment.base_fee, INITIAL_BASE_FEE.into()); + let genesis = handle.http_provider().get_block(BlockId::number(0)).await.unwrap().unwrap(); + assert_eq!(genesis.header.base_fee_per_gas, Some(INITIAL_BASE_FEE)); + api.mine_one().await.unwrap(); + let first = handle.http_provider().get_block(BlockId::number(1)).await.unwrap().unwrap(); + assert_eq!(first.header.base_fee_per_gas, Some(INITIAL_BASE_FEE)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_reset_restores_explicit_genesis_base_fee() { + let (_origin_api, origin_handle) = spawn(NodeConfig::test().with_base_fee(Some(123))).await; + let (api, handle) = spawn( + NodeConfig::test() + .with_no_storage_caching(true) + .with_hardfork(Some(EthereumHardfork::default().into())) + .with_genesis(Some(Genesis { base_fee_per_gas: Some(0), ..Default::default() })) + .with_eth_rpc_url(Some(origin_handle.http_endpoint())) + .with_fork_block_number(Some(0u64)), + ) + .await; + + api.anvil_set_next_block_base_fee_per_gas(U256::from(999)).await.unwrap(); + assert_eq!(api.anvil_node_info().await.unwrap().environment.base_fee, 999); + + api.anvil_reset(None).await.unwrap(); + + let local_info = api.anvil_node_info().await.unwrap(); + assert!(local_info.fork_config.fork_url.is_none()); + assert_eq!(local_info.environment.base_fee, 0); + let genesis = handle.http_provider().get_block(BlockId::number(0)).await.unwrap().unwrap(); + assert_eq!(genesis.header.base_fee_per_gas, Some(0)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_reset_to_memory_rebuilds_complete_block_environment() { + let (_origin_api, origin_handle) = spawn(NodeConfig::test()).await; + let (api, _handle) = + spawn(NodeConfig::test().with_eth_rpc_url(Some(origin_handle.http_endpoint()))).await; + { + let mut env = api.backend.evm_env().write(); + env.block_env.beneficiary = Address::random(); + env.block_env.difficulty = U256::from(123u64); + env.block_env.blob_excess_gas_and_price = Some(BlobExcessGasAndPrice::new(456, 3_338_477)); + } + + api.anvil_reset(None).await.unwrap(); + + let env = api.backend.evm_env().read(); + let default_block_env = BlockEnv::default(); + assert_eq!(env.block_env.beneficiary, Address::ZERO); + assert_eq!(env.block_env.difficulty, U256::ZERO); + assert_eq!( + env.block_env.blob_excess_gas_and_price, + default_block_env.blob_excess_gas_and_price + ); +} + +// `debug_getRawReceipts` must serve pre-fork blocks from the upstream provider. +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_debug_get_raw_receipts() { + let (_api, handle) = spawn(fork_config()).await; + let provider = handle.http_provider(); + + // A pre-fork block known to contain transactions. + let block_number = BLOCK_NUMBER - 1; + let rpc_receipts = + provider.get_block_receipts(BlockId::number(block_number)).await.unwrap().unwrap(); + assert!(!rpc_receipts.is_empty()); + + let block = provider.get_block(BlockId::number(block_number)).await.unwrap().unwrap(); + let raw_by_number: Vec = provider + .client() + .request("debug_getRawReceipts", (BlockId::number(block_number),)) + .await + .unwrap(); + let raw_by_hash: Vec = provider + .client() + .request("debug_getRawReceipts", (BlockId::hash(block.header.hash),)) + .await + .unwrap(); + + assert_eq!(raw_by_number, raw_by_hash); + assert_eq!(raw_by_number.len(), rpc_receipts.len()); + + // Each entry decodes back into a receipt envelope matching the RPC receipt. + for (raw, rpc) in raw_by_number.iter().zip(rpc_receipts.iter()) { + let decoded = FoundryReceiptEnvelope::decode_2718(&mut raw.as_ref()).unwrap(); + assert_eq!(decoded.status(), rpc.status()); + } +} + +// `debug_getRawTransactions` must serve pre-fork blocks from the upstream provider. +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_debug_get_raw_transactions() { + let (_api, handle) = spawn(fork_config()).await; + let provider = handle.http_provider(); + + // A pre-fork block known to contain transactions. + let block_number = BLOCK_NUMBER - 1; + let block = provider.get_block(BlockId::number(block_number)).full().await.unwrap().unwrap(); + assert!(!block.transactions.is_empty()); + + let raw_by_number: Vec = provider + .client() + .request("debug_getRawTransactions", (BlockId::number(block_number),)) + .await + .unwrap(); + let raw_by_hash: Vec = provider + .client() + .request("debug_getRawTransactions", (BlockId::hash(block.header.hash),)) + .await + .unwrap(); + + assert_eq!(raw_by_number, raw_by_hash); + assert_eq!(raw_by_number.len(), block.transactions.len()); + + // Each entry matches the single-transaction raw encoding path for the same hash. + for (raw, hash) in raw_by_number.iter().zip(block.transactions.hashes()) { + let single: Bytes = + provider.client().request("debug_getRawTransaction", (hash,)).await.unwrap(); + assert_eq!(*raw, single); + } +} + +// `debug_accountInfoAt` must delegate pre-fork blocks to the upstream and resolve block tags +// against the fork's frozen head, not the upstream's advancing head. +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_debug_account_info_at() { + // Use a local anvil node as the upstream so we can advance it deterministically. + let (origin_api, origin_handle) = spawn(NodeConfig::test()).await; + let origin_provider = origin_handle.http_provider(); + + let account = origin_handle.dev_wallets().next().unwrap().address(); + let to = Address::random(); + let amount = U256::from(1_000u64); + + // Mine one block on the upstream containing a single transfer to `to`. + let tx = TransactionRequest::default().from(account).to(to).value(amount); + let tx = WithOtherFields::new(tx); + let receipt = origin_provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + let fork_block = receipt.block_number.unwrap(); + + // Fork from the upstream at its current head. + let (_fork_api, fork_handle) = + spawn(NodeConfig::test().with_eth_rpc_url(Some(origin_handle.http_endpoint()))).await; + let fork_provider = fork_handle.http_provider(); + + // Pre-fork block delegated by number and by hash returns the fork-point balance. + let by_number: Option = fork_provider + .raw_request( + "debug_accountInfoAt".into(), + (BlockId::number(fork_block), Index::from(0), to), + ) + .await + .unwrap(); + assert_eq!(by_number.unwrap().balance, amount); + + // Query via the `latest` tag: on the frozen fork this must resolve to `fork_block`. + let by_tag: Option = fork_provider + .raw_request("debug_accountInfoAt".into(), (BlockId::latest(), Index::from(0), to)) + .await + .unwrap(); + assert_eq!(by_tag.unwrap().balance, amount); + + // Advance the upstream with more transfers to `to` so its `latest` head drifts ahead. + for _ in 0..3 { + let tx = TransactionRequest::default().from(account).to(to).value(amount); + let tx = WithOtherFields::new(tx); + origin_provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + } + assert!(origin_api.block_number().unwrap() > U256::from(fork_block)); + + // The fork never advanced, so `latest` must still resolve to `fork_block` and return the + // fork-point balance rather than drifting with the upstream head. + let by_tag_after: Option = fork_provider + .raw_request("debug_accountInfoAt".into(), (BlockId::latest(), Index::from(0), to)) + .await + .unwrap(); + assert_eq!(by_tag_after.unwrap().balance, amount); +} + +// Pre-fork `trace_replayBlockTransactions` must be forwarded to the upstream with the trace types +// serialized as their camelCase JSON names (`trace`, `stateDiff`), not their Rust `Debug` +// representation, otherwise the upstream rejects the request. +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_trace_replay_block_transactions_forwards_trace_types() { + // Use a local anvil node as the upstream so the request path is fully exercised in-process. + let (_origin_api, origin_handle) = spawn(NodeConfig::test()).await; + let origin_provider = origin_handle.http_provider(); + + let account = origin_handle.dev_wallets().next().unwrap().address(); + let to = Address::random(); + let amount = U256::from(1_000u64); + + // Mine two blocks on the upstream; the first strictly predates the fork head. + let mut first_block = None; + for _ in 0..2 { + let tx = TransactionRequest::default().from(account).to(to).value(amount); + let tx = WithOtherFields::new(tx); + let receipt = + origin_provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + first_block.get_or_insert(receipt.block_number.unwrap()); + } + let pre_fork_block = first_block.unwrap(); + + // Fork from the upstream head so `pre_fork_block` is delegated upstream. + let (_fork_api, fork_handle) = + spawn(NodeConfig::test().with_eth_rpc_url(Some(origin_handle.http_endpoint()))).await; + let fork_provider = fork_handle.http_provider(); + + let results: Vec = fork_provider + .client() + .request( + "trace_replayBlockTransactions", + (pre_fork_block, vec![TraceType::Trace, TraceType::StateDiff]), + ) + .await + .unwrap(); + + assert_eq!(results.len(), 1); + let full_trace = &results[0].full_trace; + match &full_trace.trace[0].action { + Action::Call(call) => { + assert_eq!(call.from, account); + assert_eq!(call.to, to); + } + other => panic!("expected Call action, got {other:?}"), + } + // `StateDiff` was also requested, so it must be honored, not just `Trace`. + assert!(full_trace.state_diff.is_some()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_debug_trace_cache_includes_options() { + let (_origin_api, origin_handle) = spawn(NodeConfig::test()).await; + let origin_provider = origin_handle.http_provider(); + let from = origin_handle.dev_wallets().next().unwrap().address(); + let receipt = origin_provider + .send_transaction(WithOtherFields::new( + TransactionRequest::default().from(from).to(Address::random()).value(U256::ONE), + )) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + + let fork_url = spawn_rpc_proxy_rejecting_method_after( + origin_handle.http_endpoint(), + "debug_traceTransaction", + 2, + ) + .await; + let fork_url = + spawn_rpc_proxy_rejecting_method_after(fork_url, "debug_traceBlockByHash", 2).await; + let (_fork_api, fork_handle) = spawn(NodeConfig::test().with_eth_rpc_url(Some(fork_url))).await; + let fork_provider = fork_handle.http_provider(); + let call_tracer = GethDebugTracingOptions::call_tracer(CallConfig::default()); + + let default_trace = fork_provider + .debug_trace_transaction(receipt.transaction_hash, GethDebugTracingOptions::default()) + .await + .unwrap(); + let call_trace = fork_provider + .debug_trace_transaction(receipt.transaction_hash, call_tracer.clone()) + .await + .unwrap(); + assert!(default_trace.is_default()); + assert!(call_trace.is_call()); + assert_eq!( + fork_provider + .debug_trace_transaction(receipt.transaction_hash, GethDebugTracingOptions::default()) + .await + .unwrap(), + default_trace + ); + assert_eq!( + fork_provider + .debug_trace_transaction(receipt.transaction_hash, call_tracer.clone()) + .await + .unwrap(), + call_trace + ); + + let block_hash = receipt.block_hash.unwrap(); + let default_traces = fork_provider + .debug_trace_block_by_hash(block_hash, GethDebugTracingOptions::default()) + .await + .unwrap(); + let call_traces = + fork_provider.debug_trace_block_by_hash(block_hash, call_tracer.clone()).await.unwrap(); + assert!(matches!( + &default_traces[0], + TraceResult::Success { result: GethTrace::Default(_), .. } + )); + assert!(matches!( + &call_traces[0], + TraceResult::Success { result: GethTrace::CallTracer(_), .. } + )); + assert_eq!( + fork_provider + .debug_trace_block_by_hash(block_hash, GethDebugTracingOptions::default()) + .await + .unwrap(), + default_traces + ); + assert_eq!( + fork_provider.debug_trace_block_by_hash(block_hash, call_tracer).await.unwrap(), + call_traces + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_spawn_fork() { + let (api, _handle) = spawn(fork_config()).await; + assert!(api.is_fork()); + + let head = api.block_number().unwrap(); + assert_eq!(head, U256::from(BLOCK_NUMBER)) +} + +#[tokio::test(flavor = "multi_thread")] +async fn validates_fork_source_chain_instead_of_local_chain_id() { + for chain_id in [NamedChain::ZkSyncTestnet, NamedChain::ZkSync] { + let (_origin_api, origin_handle) = + spawn(NodeConfig::test().with_chain_id(Some(chain_id as u64))).await; + + let result = try_spawn( + NodeConfig::test() + .with_eth_rpc_url(Some(origin_handle.http_endpoint())) + .with_chain_id(Some(31_337u64)), + ) + .await; + let Err(error) = result else { panic!("expected zkSync fork startup to fail") }; + let message = format!("{error:?}"); + assert!(message.contains("cannot execute native EraVM bytecode"), "{message}"); + assert!(message.contains("anvil-zksync"), "{message}"); + } + + let (_origin_api, origin_handle) = + spawn(NodeConfig::test().with_chain_id(Some(NamedChain::Mainnet as u64))).await; + + let (_api, handle) = spawn( + NodeConfig::test() + .with_eth_rpc_url(Some(origin_handle.http_endpoint())) + .with_chain_id(Some(NamedChain::ZkSync as u64)), + ) + .await; + + assert_eq!(handle.http_provider().get_chain_id().await.unwrap(), NamedChain::ZkSync as u64); +} + +#[tokio::test(flavor = "multi_thread")] +async fn validates_every_fork_url_uses_the_same_supported_network() { + let (_mainnet_api, mainnet_handle) = + spawn(NodeConfig::test().with_chain_id(Some(NamedChain::Mainnet as u64))).await; + let (_zksync_api, zksync_handle) = + spawn(NodeConfig::test().with_chain_id(Some(NamedChain::ZkSync as u64))).await; + + let result = try_spawn( + NodeConfig::test() + .with_fork_urls(vec![mainnet_handle.http_endpoint(), zksync_handle.http_endpoint()]), + ) + .await; + let Err(error) = result else { panic!("expected zkSync fallback URL to be rejected") }; + assert!(format!("{error:?}").contains("cannot execute native EraVM bytecode")); + + let (_sepolia_api, sepolia_handle) = + spawn(NodeConfig::test().with_chain_id(Some(NamedChain::Sepolia as u64))).await; + let result = try_spawn( + NodeConfig::test() + .with_fork_urls(vec![mainnet_handle.http_endpoint(), sepolia_handle.http_endpoint()]), + ) + .await; + let Err(error) = result else { panic!("expected mixed fork networks to be rejected") }; + assert!(format!("{error:?}").contains("fork endpoints must use the same chain ID")); + + let result = try_spawn( + NodeConfig::test() + .with_fork_urls(vec![mainnet_handle.http_endpoint(), sepolia_handle.http_endpoint()]) + .with_fork_chain_id(Some(U256::from(NamedChain::Mainnet as u64))), + ) + .await; + let Err(error) = result else { panic!("expected unverifiable offline fallbacks to fail") }; + assert!(format!("{error:?}").contains("multiple fork URLs cannot be validated")); +} + +// +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_set_storage_visible_to_call() { + let (origin_api, origin_handle) = spawn(NodeConfig::test()).await; + + let target = Address::random(); + let slot = uint!(0x9f19e10bccde41c24f53ff4dbf7bb5ee2063896e54351d7230ecd1f7e361cb74_U256); + let value = B256::with_last_byte(1); + + // Return the value at `slot`, matching the storage read performed by ENS.resolver(bytes32). + origin_api + .anvil_set_code( + target, + bytes!( + "7f9f19e10bccde41c24f53ff4dbf7bb5ee2063896e54351d7230ecd1f7e361cb74545f5260205ff3" + ), + ) + .await + .unwrap(); + + let (_fork_api, fork_handle) = + spawn(NodeConfig::test().with_eth_rpc_url(Some(origin_handle.http_endpoint()))).await; + let provider = fork_handle.http_provider(); + + let updated: bool = + provider.raw_request("anvil_setStorageAt".into(), (target, slot, value)).await.unwrap(); + assert!(updated); + assert_eq!(provider.get_storage_at(target, slot - U256::ONE).await.unwrap(), U256::ZERO); + + let tx = TransactionRequest::default().to(target); + for _ in 0..10 { + assert_eq!(provider.get_storage_at(target, slot).await.unwrap(), U256::ONE); + let output = provider.call(tx.clone().into()).await.unwrap(); + assert_eq!(output.as_ref(), value.as_slice()); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_eth_get_balance() { + let (api, handle) = spawn(fork_config()).await; + let provider = handle.http_provider(); + for _ in 0..10 { + let addr = Address::random(); + let balance = api.balance(addr, None).await.unwrap(); + let provider_balance = provider.get_balance(addr).await.unwrap(); + assert_eq!(balance, provider_balance) + } +} + +// +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_eth_get_balance_after_mine() { + let (api, handle) = spawn(fork_config()).await; + let provider = handle.http_provider(); + let info = api.anvil_node_info().await.unwrap(); + let number = info.fork_config.fork_block_number.unwrap(); + assert_eq!(number, BLOCK_NUMBER); + + let address = Address::random(); + + let _balance = provider.get_balance(address).await.unwrap(); + + api.evm_mine(None).await.unwrap(); + + let _balance = provider.get_balance(address).await.unwrap(); +} + +// +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_eth_get_code_after_mine() { + let (api, handle) = spawn(fork_config()).await; + let provider = handle.http_provider(); + let info = api.anvil_node_info().await.unwrap(); + let number = info.fork_config.fork_block_number.unwrap(); + assert_eq!(number, BLOCK_NUMBER); + + let address = Address::random(); + + let _code = provider.get_code_at(address).number(1).await.unwrap(); + + api.evm_mine(None).await.unwrap(); + + let _code = provider.get_code_at(address).number(1).await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_eth_get_code() { + let (api, handle) = spawn(fork_config()).await; + let provider = handle.http_provider(); + for _ in 0..10 { + let addr = Address::random(); + let code = api.get_code(addr, None).await.unwrap(); + let provider_code = provider.get_code_at(addr).await.unwrap(); + assert_eq!(code, provider_code) + } + + let addresses: Vec
= vec![ + address!("0x6b175474e89094c44da98b954eedeac495271d0f"), + address!("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"), + address!("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"), + address!("0x1F98431c8aD98523631AE4a59f267346ea31F984"), + address!("0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45"), + ]; + for address in addresses { + let prev_code = + api.get_code(address, Some(BlockId::number(BLOCK_NUMBER - 10))).await.unwrap(); + let code = api.get_code(address, None).await.unwrap(); + let provider_code = provider.get_code_at(address).await.unwrap(); + assert_eq!(code, prev_code); + assert_eq!(code, provider_code); + assert!(!code.as_ref().is_empty()); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_eth_get_nonce() { + let (api, handle) = spawn(fork_config()).await; + let provider = handle.http_provider(); + + for _ in 0..10 { + let addr = Address::random(); + let api_nonce = api.transaction_count(addr, None).await.unwrap().to::(); + let provider_nonce = provider.get_transaction_count(addr).await.unwrap(); + assert_eq!(api_nonce, provider_nonce); + } + + let addr = Config::DEFAULT_SENDER; + let api_nonce = api.transaction_count(addr, None).await.unwrap().to::(); + let provider_nonce = provider.get_transaction_count(addr).await.unwrap(); + assert_eq!(api_nonce, provider_nonce); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_optimism_with_transaction_hash() { + // Fork to a block with a specific transaction + let fork_tx_hash = b256!("fcb864b5a50f0f0b111dbbf9e9167b2cb6179dfd6270e1ad53aac6049c0ec038"); + let (api, handle) = spawn( + NodeConfig::test() + .with_eth_rpc_url(Some(rpc::next_rpc_endpoint(NamedChain::Optimism))) + .with_fork_transaction_hash(Some(fork_tx_hash)), + ) + .await; + + // The prefix is replayed synchronously as the next local block. + let block_number = api.block_number().unwrap().to::(); + assert_eq!(block_number, 125777954); + assert!(api.backend.mined_transaction_by_hash(fork_tx_hash).is_some()); + assert!( + handle + .http_provider() + .get_transaction_receipt(fork_tx_hash) + .await + .unwrap() + .unwrap() + .status() + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_eth_fee_history() { + let (api, _handle) = spawn(fork_config()).await; + let fork = api.get_fork().unwrap(); + + let count = 10u64; + let history = + api.fee_history(U256::from(count), BlockNumberOrTag::Latest, vec![]).await.unwrap(); + let upstream_history = + fork.fee_history(count, BlockNumberOrTag::Number(BLOCK_NUMBER), &[]).await.unwrap(); + + assert_eq!(history, upstream_history); +} + +// Regression test for a fork-range bug in `eth_feeHistory`: when the requested range straddles +// the fork boundary (newest block post-fork, oldest block pre-fork), the cache fallback must not +// call `backend.get_block` on the pre-fork blocks — local storage has none, which made the call +// hard-error. The pre-fork portion is served by the fork provider and merged with the locally +// computed post-fork portion. The earlier guard only covers fully pre-fork ranges, so this needs +// local blocks mined above the fork block to be exercised. +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_fee_history_across_fork_boundary() { + let (api, _handle) = spawn(fork_config()).await; + + // Mine local blocks so `latest` sits above the fork block. + api.anvil_mine(Some(U256::from(3)), None).await.unwrap(); + + // latest = fork + 3, count = 10 => oldest = fork - 6 (pre-fork), newest = fork + 3 (post-fork). + let count = 10u64; + let history = + api.fee_history(U256::from(count), BlockNumberOrTag::Latest, vec![]).await.unwrap(); + + // The oldest block must be the true range start (latest - count + 1); a split that mistook the + // whole range for pre-fork would shift it and return more entries than requested. + let latest = api.block_number().unwrap().to::(); + assert_eq!(history.oldest_block, latest - count + 1, "wrong oldest_block"); + + // Full range covered: per-block arrays have `count` entries; base_fee_per_gas has one more. + assert_eq!(history.gas_used_ratio.len(), count as usize, "incomplete gas_used_ratio"); + assert_eq!( + history.base_fee_per_gas.len(), + count as usize + 1, + "incomplete base_fee_per_gas across the fork boundary" + ); + + // The pre-fork segment here is pre-Cancun, so the fork provider may return empty blob-fee + // arrays. They must still be padded to stay aligned with the gas arrays, otherwise the merged + // response is short and misaligned. + assert_eq!( + history.blob_gas_used_ratio.len(), + count as usize, + "incomplete blob_gas_used_ratio across the fork boundary" + ); + assert_eq!( + history.base_fee_per_blob_gas.len(), + count as usize + 1, + "incomplete base_fee_per_blob_gas across the fork boundary" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_reset() { + let (api, handle) = spawn(fork_config()).await; + let provider = handle.http_provider(); + + let accounts: Vec<_> = handle.dev_wallets().collect(); + let from = accounts[0].address(); + let to = accounts[1].address(); + let block_number = provider.get_block_number().await.unwrap(); + let balance_before = provider.get_balance(to).await.unwrap(); + let amount = handle.genesis_balance().checked_div(U256::from(2u64)).unwrap(); + + let initial_nonce = provider.get_transaction_count(from).await.unwrap(); + + let tx = TransactionRequest::default().to(to).value(amount).from(from); + let tx = WithOtherFields::new(tx); + let tx = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + assert_eq!(tx.transaction_index, Some(0)); + + let nonce = provider.get_transaction_count(from).await.unwrap(); + + assert_eq!(nonce, initial_nonce + 1); + let to_balance = provider.get_balance(to).await.unwrap(); + assert_eq!(balance_before.saturating_add(amount), to_balance); + api.anvil_reset(Some(Forking { json_rpc_url: None, block_number: Some(block_number) })) + .await + .unwrap(); + + // reset block number + assert_eq!(block_number, provider.get_block_number().await.unwrap()); + + let nonce = provider.get_transaction_count(from).await.unwrap(); + assert_eq!(nonce, initial_nonce); + let balance = provider.get_balance(from).await.unwrap(); + assert_eq!(balance, handle.genesis_balance()); + let balance = provider.get_balance(to).await.unwrap(); + assert_eq!(balance, handle.genesis_balance()); + + // reset to latest + api.anvil_reset(Some(Forking::default())).await.unwrap(); + + let new_block_num = provider.get_block_number().await.unwrap(); + assert!(new_block_num > block_number); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_reset_setup() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + let dead_addr = address!("000000000000000000000000000000000000dEaD"); + + let block_number = provider.get_block_number().await.unwrap(); + assert_eq!(block_number, 0); + + let local_balance = provider.get_balance(dead_addr).await.unwrap(); + assert_eq!(local_balance, U256::ZERO); + + api.anvil_reset(Some(Forking { + json_rpc_url: Some(rpc::next_http_archive_rpc_url()), + block_number: Some(BLOCK_NUMBER), + })) + .await + .unwrap(); + + let block_number = provider.get_block_number().await.unwrap(); + assert_eq!(block_number, BLOCK_NUMBER); + + let remote_balance = provider.get_balance(dead_addr).await.unwrap(); + assert_eq!(remote_balance, U256::from(DEAD_BALANCE_AT_BLOCK_NUMBER)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_state_snapshotting() { + let (api, handle) = spawn(fork_config()).await; + let provider = handle.http_provider(); + let state_snapshot = api.evm_snapshot().await.unwrap(); + + let accounts: Vec<_> = handle.dev_wallets().collect(); + let from = accounts[0].address(); + let to = accounts[1].address(); + let block_number = provider.get_block_number().await.unwrap(); + + let initial_nonce = provider.get_transaction_count(from).await.unwrap(); + let balance_before = provider.get_balance(to).await.unwrap(); + let amount = handle.genesis_balance().checked_div(U256::from(2u64)).unwrap(); + + let provider = handle.http_provider(); + let tx = TransactionRequest::default().to(to).value(amount).from(from); + let tx = WithOtherFields::new(tx); + + let _ = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + + let provider = handle.http_provider(); + + let nonce = provider.get_transaction_count(from).await.unwrap(); + assert_eq!(nonce, initial_nonce + 1); + let to_balance = provider.get_balance(to).await.unwrap(); + assert_eq!(balance_before.saturating_add(amount), to_balance); + + assert!(api.evm_revert(state_snapshot).await.unwrap()); + + let nonce = provider.get_transaction_count(from).await.unwrap(); + assert_eq!(nonce, initial_nonce); + let balance = provider.get_balance(from).await.unwrap(); + assert_eq!(balance, handle.genesis_balance()); + let balance = provider.get_balance(to).await.unwrap(); + assert_eq!(balance, handle.genesis_balance()); + assert_eq!(block_number, provider.get_block_number().await.unwrap()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_state_snapshotting_repeated() { + let (api, handle) = spawn(fork_config()).await; + let provider = handle.http_provider(); + + let state_snapshot = api.evm_snapshot().await.unwrap(); + + let accounts: Vec<_> = handle.dev_wallets().collect(); + let from = accounts[0].address(); + let to = accounts[1].address(); + let block_number = provider.get_block_number().await.unwrap(); + + let initial_nonce = provider.get_transaction_count(from).await.unwrap(); + let balance_before = provider.get_balance(to).await.unwrap(); + let amount = handle.genesis_balance().checked_div(U256::from(92u64)).unwrap(); + + let tx = TransactionRequest::default().to(to).value(amount).from(from); + let tx = WithOtherFields::new(tx); + let tx_provider = handle.http_provider(); + let _ = tx_provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + + let nonce = provider.get_transaction_count(from).await.unwrap(); + assert_eq!(nonce, initial_nonce + 1); + let to_balance = provider.get_balance(to).await.unwrap(); + assert_eq!(balance_before.saturating_add(amount), to_balance); + + let second_state_snapshot = api.evm_snapshot().await.unwrap(); + + assert!(api.evm_revert(state_snapshot).await.unwrap()); + + let nonce = provider.get_transaction_count(from).await.unwrap(); + assert_eq!(nonce, initial_nonce); + let balance = provider.get_balance(from).await.unwrap(); + assert_eq!(balance, handle.genesis_balance()); + let balance = provider.get_balance(to).await.unwrap(); + assert_eq!(balance, handle.genesis_balance()); + assert_eq!(block_number, provider.get_block_number().await.unwrap()); + + // The newer snapshot was invalidated by reverting to an older snapshot. + assert!(!api.evm_revert(second_state_snapshot).await.unwrap()); + + // nothing is reverted, snapshot gone + assert!(!api.evm_revert(state_snapshot).await.unwrap()); +} + +// +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_state_snapshotting_blocks() { + let (api, handle) = spawn(fork_config()).await; + let provider = handle.http_provider(); + + let state_snapshot = api.evm_snapshot().await.unwrap(); + + let accounts: Vec<_> = handle.dev_wallets().collect(); + let from = accounts[0].address(); + let to = accounts[1].address(); + let block_number = provider.get_block_number().await.unwrap(); + + let initial_nonce = provider.get_transaction_count(from).await.unwrap(); + let balance_before = provider.get_balance(to).await.unwrap(); + let amount = handle.genesis_balance().checked_div(U256::from(2u64)).unwrap(); + + // send the transaction + let tx = TransactionRequest::default().to(to).value(amount).from(from); + let tx = WithOtherFields::new(tx); + let _ = provider.send_transaction(tx.clone()).await.unwrap().get_receipt().await.unwrap(); + + let block_number_after = provider.get_block_number().await.unwrap(); + assert_eq!(block_number_after, block_number + 1); + + let nonce = provider.get_transaction_count(from).await.unwrap(); + assert_eq!(nonce, initial_nonce + 1); + let to_balance = provider.get_balance(to).await.unwrap(); + assert_eq!(balance_before.saturating_add(amount), to_balance); + + assert!(api.evm_revert(state_snapshot).await.unwrap()); + + assert_eq!(initial_nonce, provider.get_transaction_count(from).await.unwrap()); + let block_number_after = provider.get_block_number().await.unwrap(); + assert_eq!(block_number_after, block_number); + + // repeat transaction + let _ = provider.send_transaction(tx.clone()).await.unwrap().get_receipt().await.unwrap(); + let nonce = provider.get_transaction_count(from).await.unwrap(); + assert_eq!(nonce, initial_nonce + 1); + + // revert again: nothing to revert since state snapshot gone + assert!(!api.evm_revert(state_snapshot).await.unwrap()); + let nonce = provider.get_transaction_count(from).await.unwrap(); + assert_eq!(nonce, initial_nonce + 1); + let block_number_after = provider.get_block_number().await.unwrap(); + assert_eq!(block_number_after, block_number + 1); +} + +/// tests that the remote state and local state are kept separate. +/// changes don't make into the read only Database that holds the remote state, which is flushed to +/// a cache file. +#[tokio::test(flavor = "multi_thread")] +async fn test_separate_states() { + let (api, handle) = spawn(fork_config().with_fork_block_number(Some(14723772u64))).await; + let provider = handle.http_provider(); + + let addr = address!("000000000000000000000000000000000000dEaD"); + + let remote_balance = provider.get_balance(addr).await.unwrap(); + assert_eq!(remote_balance, U256::from(12556104082473169733500u128)); + + api.anvil_set_balance(addr, U256::from(1337u64)).await.unwrap(); + let balance = provider.get_balance(addr).await.unwrap(); + assert_eq!(balance, U256::from(1337u64)); + + let fork = api.get_fork().unwrap(); + let fork_db = fork.database.read().await; + let acc = fork_db + .maybe_inner() + .expect("could not get fork db inner") + .db() + .accounts + .read() + .get(&addr) + .cloned() + .unwrap(); + + assert_eq!(acc.balance, remote_balance); +} + +#[tokio::test(flavor = "multi_thread")] +async fn can_deploy_greeter_on_fork() { + let (_api, handle) = spawn(fork_config().with_fork_block_number(Some(14723772u64))).await; + + let wallet = handle.dev_wallets().next().unwrap(); + let signer: EthereumWallet = wallet.into(); + + let provider = http_provider_with_signer(&handle.http_endpoint(), signer); + + let greeter_contract = Greeter::deploy(&provider, "Hello World!".to_string()).await.unwrap(); + + let greeting = greeter_contract.greet().call().await.unwrap(); + assert_eq!("Hello World!", greeting); + + let greeter_contract = Greeter::deploy(&provider, "Hello World!".to_string()).await.unwrap(); + + let greeting = greeter_contract.greet().call().await.unwrap(); + assert_eq!("Hello World!", greeting); +} + +#[tokio::test(flavor = "multi_thread")] +async fn can_reset_properly() { + let (origin_api, origin_handle) = spawn(NodeConfig::test()).await; + let account = origin_handle.dev_accounts().next().unwrap(); + let origin_provider = origin_handle.http_provider(); + let origin_nonce = 1u64; + origin_api.anvil_set_nonce(account, U256::from(origin_nonce)).await.unwrap(); + + assert_eq!(origin_nonce, origin_provider.get_transaction_count(account).await.unwrap()); + + let (fork_api, fork_handle) = + spawn(NodeConfig::test().with_eth_rpc_url(Some(origin_handle.http_endpoint()))).await; + + let fork_provider = fork_handle.http_provider(); + let fork_tx_provider = http_provider(&fork_handle.http_endpoint()); + assert_eq!(origin_nonce, fork_provider.get_transaction_count(account).await.unwrap()); + + let to = Address::random(); + let to_balance = fork_provider.get_balance(to).await.unwrap(); + let tx = TransactionRequest::default().from(account).to(to).value(U256::from(1337u64)); + let tx = WithOtherFields::new(tx); + let tx = fork_tx_provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + + // nonce incremented by 1 + assert_eq!(origin_nonce + 1, fork_provider.get_transaction_count(account).await.unwrap()); + + // resetting to origin state + fork_api.anvil_reset(Some(Forking::default())).await.unwrap(); + + // nonce reset to origin + assert_eq!(origin_nonce, fork_provider.get_transaction_count(account).await.unwrap()); + + // balance is reset + assert_eq!(to_balance, fork_provider.get_balance(to).await.unwrap()); + + // tx does not exist anymore + assert!(fork_tx_provider.get_transaction_by_hash(tx.transaction_hash).await.unwrap().is_none()) +} + +// Ref: +#[tokio::test(flavor = "multi_thread")] +async fn can_reset_fork_to_new_fork() { + let eth_rpc_url = next_http_rpc_endpoint(); + let (api, handle) = spawn(NodeConfig::test().with_eth_rpc_url(Some(eth_rpc_url))).await; + let provider = handle.http_provider(); + + let op = address!("0xC0d3c0d3c0D3c0D3C0d3C0D3C0D3c0d3c0d30007"); // L2CrossDomainMessenger - Dead on mainnet. + + let tx = TransactionRequest::default().with_to(op).with_input("0x54fd4d50"); + + let tx = WithOtherFields::new(tx); + + let mainnet_call_output = provider.call(tx).await.unwrap(); + + assert_eq!(mainnet_call_output, Bytes::new()); // 0x + + let optimism = next_rpc_endpoint(NamedChain::Optimism); + + api.anvil_reset(Some(Forking { + json_rpc_url: Some(optimism.clone()), + block_number: Some(124659890), + })) + .await + .unwrap(); + + let code = provider.get_code_at(op).await.unwrap(); + + assert_ne!(code, Bytes::new()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_timestamp() { + let start = std::time::Instant::now(); + + let (api, handle) = spawn(fork_config()).await; + let provider = handle.http_provider(); + + let block = provider.get_block(BlockId::number(BLOCK_NUMBER)).await.unwrap().unwrap(); + assert_eq!(block.header.timestamp, BLOCK_TIMESTAMP); + + let accounts: Vec<_> = handle.dev_wallets().collect(); + let from = accounts[0].address(); + + let tx = + TransactionRequest::default().to(Address::random()).value(U256::from(1337u64)).from(from); + let tx = WithOtherFields::new(tx); + let tx = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + let status = tx.inner.inner.inner.receipt.status.coerce_status(); + assert!(status); + + let block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + + let elapsed = start.elapsed().as_secs() + 1; + + // ensure the diff between the new mined block and the original block is within the elapsed time + let diff = block.header.timestamp - BLOCK_TIMESTAMP; + assert!(diff <= elapsed, "diff={diff}, elapsed={elapsed}"); + + let start = std::time::Instant::now(); + // reset to check timestamp works after resetting + api.anvil_reset(Some(Forking { json_rpc_url: None, block_number: Some(BLOCK_NUMBER) })) + .await + .unwrap(); + let block = provider.get_block(BlockId::number(BLOCK_NUMBER)).await.unwrap().unwrap(); + assert_eq!(block.header.timestamp, BLOCK_TIMESTAMP); + + let tx = + TransactionRequest::default().to(Address::random()).value(U256::from(1337u64)).from(from); + let tx = WithOtherFields::new(tx); + let _ = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); // FIXME: Awaits endlessly here. - let remote_balance = provider.get_balance(dead_addr).await.unwrap(); - assert_eq!(remote_balance, U256::from(DEAD_BALANCE_AT_BLOCK_NUMBER)); + let block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + let elapsed = start.elapsed().as_secs() + 1; + let diff = block.header.timestamp - BLOCK_TIMESTAMP; + assert!(diff <= elapsed); + + // ensure that after setting a timestamp manually, then next block time is correct + let start = std::time::Instant::now(); + api.anvil_reset(Some(Forking { json_rpc_url: None, block_number: Some(BLOCK_NUMBER) })) + .await + .unwrap(); + api.evm_set_next_block_timestamp(BLOCK_TIMESTAMP + 1).unwrap(); + let tx = + TransactionRequest::default().to(Address::random()).value(U256::from(1337u64)).from(from); + let tx = WithOtherFields::new(tx); + let _tx = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + + let block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + assert_eq!(block.header.timestamp, BLOCK_TIMESTAMP + 1); + + let tx = + TransactionRequest::default().to(Address::random()).value(U256::from(1337u64)).from(from); + let tx = WithOtherFields::new(tx); + let _ = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + + let block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + let elapsed = start.elapsed().as_secs() + 1; + let diff = block.header.timestamp - (BLOCK_TIMESTAMP + 1); + assert!(diff <= elapsed); } #[tokio::test(flavor = "multi_thread")] -async fn test_fork_state_snapshotting() { - let (api, handle) = spawn(fork_config()).await; +async fn test_fork_set_empty_code() { + let (api, _handle) = spawn(fork_config()).await; + let addr = address!("0x1f9840a85d5af5bf1d1762f925bdaddc4201f984"); + let code = api.get_code(addr, None).await.unwrap(); + assert!(!code.as_ref().is_empty()); + api.anvil_set_code(addr, Vec::new().into()).await.unwrap(); + let code = api.get_code(addr, None).await.unwrap(); + assert!(code.as_ref().is_empty()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_can_send_tx() { + let (api, handle) = + spawn(fork_config().with_blocktime(Some(std::time::Duration::from_millis(800)))).await; + + let wallet = PrivateKeySigner::random(); + let signer = wallet.address(); let provider = handle.http_provider(); - let state_snapshot = api.evm_snapshot().await.unwrap(); + // let provider = SignerMiddleware::new(provider, wallet); - let accounts: Vec<_> = handle.dev_wallets().collect(); - let from = accounts[0].address(); - let to = accounts[1].address(); - let block_number = provider.get_block_number().await.unwrap(); + api.anvil_set_balance(signer, U256::MAX).await.unwrap(); + api.anvil_impersonate_account(signer).await.unwrap(); // Added until WalletFiller for alloy-provider is fixed. + let balance = provider.get_balance(signer).await.unwrap(); + assert_eq!(balance, U256::MAX); - let initial_nonce = provider.get_transaction_count(from).await.unwrap(); - let balance_before = provider.get_balance(to).await.unwrap(); - let amount = handle.genesis_balance().checked_div(U256::from(2u64)).unwrap(); + let addr = Address::random(); + let val = U256::from(1337u64); + let tx = TransactionRequest::default().to(addr).value(val).from(signer); + let tx = WithOtherFields::new(tx); + // broadcast it via the eth_sendTransaction API + let _ = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + + let balance = provider.get_balance(addr).await.unwrap(); + assert_eq!(balance, val); +} + +// +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_nft_set_approve_all() { + let (api, handle) = spawn( + fork_config() + .with_fork_block_number(Some(14812197u64)) + .with_blocktime(Some(Duration::from_secs(5))) + .with_chain_id(1u64.into()), + ) + .await; + + // create and fund a random wallet + let wallet = PrivateKeySigner::random(); + let signer = wallet.address(); + api.anvil_set_balance(signer, U256::from(1000e18)).await.unwrap(); let provider = handle.http_provider(); - let tx = TransactionRequest::default().to(to).value(amount).from(from); + + // pick a random nft + let nouns_addr = address!("0x9c8ff314c9bc7f6e59a9d9225fb22946427edc03"); + + let owner = address!("0x052564eb0fd8b340803df55def89c25c432f43f4"); + let token_id: U256 = U256::from(154u64); + + let nouns = ERC721::new(nouns_addr, provider.clone()); + + let real_owner = nouns.ownerOf(token_id).call().await.unwrap(); + assert_eq!(real_owner, owner); + let approval = nouns.setApprovalForAll(nouns_addr, true); + let tx = TransactionRequest::default() + .from(owner) + .to(nouns_addr) + .with_input(approval.calldata().to_owned()); let tx = WithOtherFields::new(tx); + api.anvil_impersonate_account(owner).await.unwrap(); + let tx = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + let status = tx.inner.inner.inner.receipt.status.coerce_status(); + assert!(status); - let _ = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + // transfer: impersonate real owner and transfer nft + api.anvil_impersonate_account(real_owner).await.unwrap(); - let provider = handle.http_provider(); + api.anvil_set_balance(real_owner, U256::from(10000e18 as u64)).await.unwrap(); - let nonce = provider.get_transaction_count(from).await.unwrap(); - assert_eq!(nonce, initial_nonce + 1); - let to_balance = provider.get_balance(to).await.unwrap(); - assert_eq!(balance_before.saturating_add(amount), to_balance); + let call = nouns.transferFrom(real_owner, signer, token_id); + let tx = TransactionRequest::default() + .from(real_owner) + .to(nouns_addr) + .with_input(call.calldata().to_owned()); + let tx = WithOtherFields::new(tx); + let tx = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + let status = tx.inner.inner.inner.receipt.status.coerce_status(); + assert!(status); - assert!(api.evm_revert(state_snapshot).await.unwrap()); + let real_owner = nouns.ownerOf(token_id).call().await.unwrap(); + assert_eq!(real_owner, wallet.address()); +} - let nonce = provider.get_transaction_count(from).await.unwrap(); - assert_eq!(nonce, initial_nonce); - let balance = provider.get_balance(from).await.unwrap(); - assert_eq!(balance, handle.genesis_balance()); - let balance = provider.get_balance(to).await.unwrap(); - assert_eq!(balance, handle.genesis_balance()); - assert_eq!(block_number, provider.get_block_number().await.unwrap()); +// +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_with_custom_chain_id() { + // spawn a forked node with some random chainId + let (api, handle) = spawn( + fork_config() + .with_fork_block_number(Some(14812197u64)) + .with_blocktime(Some(Duration::from_secs(5))) + .with_chain_id(3145u64.into()), + ) + .await; + + // get the eth chainId and the txn chainId + let eth_chain_id = api.eth_chain_id(); + let txn_chain_id = api.chain_id(); + + // get the chainId in the config + let config_chain_id = handle.config().chain_id; + + // check that the chainIds are the same + assert_eq!(eth_chain_id.unwrap().unwrap().to::(), 3145u64); + assert_eq!(txn_chain_id, 3145u64); + assert_eq!(config_chain_id, Some(3145u64)); } +// #[tokio::test(flavor = "multi_thread")] -async fn test_fork_state_snapshotting_repeated() { - let (api, handle) = spawn(fork_config()).await; +async fn test_fork_can_send_opensea_tx() { + let (api, handle) = spawn( + fork_config() + .with_fork_block_number(Some(14983338u64)) + .with_blocktime(Some(Duration::from_millis(5000))), + ) + .await; + + let sender = address!("0x8fdbae54b6d9f3fc2c649e3dd4602961967fd42f"); + + // transfer: impersonate real sender + api.anvil_impersonate_account(sender).await.unwrap(); + let provider = handle.http_provider(); - let state_snapshot = api.evm_snapshot().await.unwrap(); + let input = bytes!( + "0xfb0f3ee1000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003ff2e795f5000000000000000000000000000023f28ae3e9756ba982a6290f9081b6a84900b758000000000000000000000000004c00500000ad104d7dbd00e3ae0a5c00560c0000000000000000000000000003235b597a78eabcb08ffcb4d97411073211dbcb0000000000000000000000000000000000000000000000000000000000000e72000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000062ad47c20000000000000000000000000000000000000000000000000000000062d43104000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000df44e65d2a2cf40000007b02230091a7ed01230072f7006a004d60a8d4e71d599b8104250f00000000007b02230091a7ed01230072f7006a004d60a8d4e71d599b8104250f00000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000002e000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000001c6bf526340000000000000000000000000008de9c5a032463c561423387a9648c5c7bcc5bc900000000000000000000000000000000000000000000000000005543df729c0000000000000000000000000006eb234847a9e3a546539aac57a071c01dc3f398600000000000000000000000000000000000000000000000000000000000000416d39b5352353a22cf2d44faa696c2089b03137a13b5acfee0366306f2678fede043bc8c7e422f6f13a3453295a4a063dac7ee6216ab7bade299690afc77397a51c00000000000000000000000000000000000000000000000000000000000000" + ); + let to = address!("0x00000000006c3852cbef3e08e8df289169ede581"); + let tx = TransactionRequest::default() + .from(sender) + .to(to) + .value(U256::from(20000000000000000u64)) + .with_input(input) + .with_gas_price(22180711707u128) + .with_gas_limit(150_000); + let tx = WithOtherFields::new(tx); + + let tx = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + let status = tx.inner.inner.inner.receipt.status.coerce_status(); + assert!(status); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_base_fee() { + let (api, handle) = spawn(fork_config()).await; let accounts: Vec<_> = handle.dev_wallets().collect(); let from = accounts[0].address(); - let to = accounts[1].address(); - let block_number = provider.get_block_number().await.unwrap(); - let initial_nonce = provider.get_transaction_count(from).await.unwrap(); - let balance_before = provider.get_balance(to).await.unwrap(); - let amount = handle.genesis_balance().checked_div(U256::from(92u64)).unwrap(); + let provider = handle.http_provider(); - let tx = TransactionRequest::default().to(to).value(amount).from(from); + api.anvil_set_next_block_base_fee_per_gas(U256::ZERO).await.unwrap(); + + let addr = Address::random(); + let val = U256::from(1337u64); + let tx = TransactionRequest::default().from(from).to(addr).value(val); let tx = WithOtherFields::new(tx); - let tx_provider = handle.http_provider(); - let _ = tx_provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + let _res = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); +} - let nonce = provider.get_transaction_count(from).await.unwrap(); - assert_eq!(nonce, initial_nonce + 1); - let to_balance = provider.get_balance(to).await.unwrap(); - assert_eq!(balance_before.saturating_add(amount), to_balance); +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_pre_london_base_fee_is_null() { + let (_api, handle) = spawn(fork_config().with_fork_block_number(Some(12_000_000u64))).await; - let _second_state_snapshot = api.evm_snapshot().await.unwrap(); + let provider = handle.http_provider(); - assert!(api.evm_revert(state_snapshot).await.unwrap()); + let base_fee: Option = provider.client().request("eth_baseFee", ()).await.unwrap(); + assert_eq!(base_fee, None); +} - let nonce = provider.get_transaction_count(from).await.unwrap(); - assert_eq!(nonce, initial_nonce); - let balance = provider.get_balance(from).await.unwrap(); - assert_eq!(balance, handle.genesis_balance()); - let balance = provider.get_balance(to).await.unwrap(); - assert_eq!(balance, handle.genesis_balance()); - assert_eq!(block_number, provider.get_block_number().await.unwrap()); +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_init_base_fee() { + let (api, handle) = spawn(fork_config().with_fork_block_number(Some(13184859u64))).await; - // invalidated - // TODO enable after - // assert!(!api.evm_revert(second_snapshot).await.unwrap()); + let provider = handle.http_provider(); - // nothing is reverted, snapshot gone - assert!(!api.evm_revert(state_snapshot).await.unwrap()); + let block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + // + assert_eq!(block.header.number, 13184859u64); + let init_base_fee = block.header.base_fee_per_gas.unwrap(); + assert_eq!(init_base_fee, 63739886069); + + api.mine_one().await.unwrap(); + + let block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + + let next_base_fee = block.header.base_fee_per_gas.unwrap(); + assert!(next_base_fee < init_base_fee); } -// #[tokio::test(flavor = "multi_thread")] -async fn test_fork_state_snapshotting_blocks() { - let (api, handle) = spawn(fork_config()).await; - let provider = handle.http_provider(); - - let state_snapshot = api.evm_snapshot().await.unwrap(); +async fn test_fork_init_blob_base_fee_with_explicit_base_fee() { + let fork_rpc_url = rpc::next_http_archive_rpc_url(); + let fork_block_number = 24_127_158u64; + let (default_api, _) = spawn( + NodeConfig::test() + .with_eth_rpc_url(Some(fork_rpc_url.clone())) + .with_fork_block_number(Some(fork_block_number)), + ) + .await; + let explicit_base_fee = default_api + .block_by_number(BlockNumberOrTag::Latest) + .await + .unwrap() + .unwrap() + .header + .base_fee_per_gas + .unwrap(); + let (explicit_api, _) = spawn( + NodeConfig::test() + .with_eth_rpc_url(Some(fork_rpc_url)) + .with_fork_block_number(Some(fork_block_number)) + .with_base_fee(Some(explicit_base_fee)), + ) + .await; - let accounts: Vec<_> = handle.dev_wallets().collect(); - let from = accounts[0].address(); - let to = accounts[1].address(); - let block_number = provider.get_block_number().await.unwrap(); + let default_blob_base_fee = default_api.blob_base_fee().unwrap(); + let explicit_blob_base_fee = explicit_api.blob_base_fee().unwrap(); - let initial_nonce = provider.get_transaction_count(from).await.unwrap(); - let balance_before = provider.get_balance(to).await.unwrap(); - let amount = handle.genesis_balance().checked_div(U256::from(2u64)).unwrap(); + assert!(default_blob_base_fee > U256::ONE); + assert_eq!(explicit_blob_base_fee, default_blob_base_fee); +} - // send the transaction - let tx = TransactionRequest::default().to(to).value(amount).from(from); - let tx = WithOtherFields::new(tx); - let _ = provider.send_transaction(tx.clone()).await.unwrap().get_receipt().await.unwrap(); +#[tokio::test(flavor = "multi_thread")] +async fn flaky_test_reset_fork_on_new_blocks() { + let (api, handle) = + spawn(NodeConfig::test().with_eth_rpc_url(Some(rpc::next_http_archive_rpc_url()))).await; - let block_number_after = provider.get_block_number().await.unwrap(); - assert_eq!(block_number_after, block_number + 1); + let anvil_provider = handle.http_provider(); + let endpoint = next_http_rpc_endpoint(); + let provider = Arc::new(get_http_provider(&endpoint)); - let nonce = provider.get_transaction_count(from).await.unwrap(); - assert_eq!(nonce, initial_nonce + 1); - let to_balance = provider.get_balance(to).await.unwrap(); - assert_eq!(balance_before.saturating_add(amount), to_balance); + let current_block = anvil_provider.get_block_number().await.unwrap(); - assert!(api.evm_revert(state_snapshot).await.unwrap()); + handle + .task_manager() + .spawn_reset_on_new_polled_blocks::(provider.clone(), api); - assert_eq!(initial_nonce, provider.get_transaction_count(from).await.unwrap()); - let block_number_after = provider.get_block_number().await.unwrap(); - assert_eq!(block_number_after, block_number); + let mut stream = provider + .watch_blocks() + .await + .unwrap() + .with_poll_interval(Duration::from_secs(2)) + .into_stream() + .flat_map(futures::stream::iter); + // the http watcher may fetch multiple blocks at once, so we set a timeout here to offset edge + // cases where the stream immediately returns a block + tokio::time::sleep(Duration::from_secs(12)).await; + stream.next().await.unwrap(); + stream.next().await.unwrap(); - // repeat transaction - let _ = provider.send_transaction(tx.clone()).await.unwrap().get_receipt().await.unwrap(); - let nonce = provider.get_transaction_count(from).await.unwrap(); - assert_eq!(nonce, initial_nonce + 1); + let next_block = anvil_provider.get_block_number().await.unwrap(); - // revert again: nothing to revert since state snapshot gone - assert!(!api.evm_revert(state_snapshot).await.unwrap()); - let nonce = provider.get_transaction_count(from).await.unwrap(); - assert_eq!(nonce, initial_nonce + 1); - let block_number_after = provider.get_block_number().await.unwrap(); - assert_eq!(block_number_after, block_number + 1); + assert!(next_block > current_block, "nextblock={next_block} currentblock={current_block}") } -/// tests that the remote state and local state are kept separate. -/// changes don't make into the read only Database that holds the remote state, which is flushed to -/// a cache file. #[tokio::test(flavor = "multi_thread")] -async fn test_separate_states() { - let (api, handle) = spawn(fork_config().with_fork_block_number(Some(14723772u64))).await; - let provider = handle.http_provider(); - - let addr: Address = "000000000000000000000000000000000000dEaD".parse().unwrap(); +async fn test_fork_call() { + let input = bytes!("0x77c7b8fc"); + let to = address!("0x99d1Fa417f94dcD62BfE781a1213c092a47041Bc"); + let block_number = 14746300u64; - let remote_balance = provider.get_balance(addr).await.unwrap(); - assert_eq!(remote_balance, U256::from(12556104082473169733500u128)); + let provider = http_provider(rpc::next_http_archive_rpc_url().as_str()); + let tx = TransactionRequest::default().to(to).with_input(input.clone()); + let tx = WithOtherFields::new(tx); + let res0 = provider.call(tx).number(block_number).await.unwrap(); - api.anvil_set_balance(addr, U256::from(1337u64)).await.unwrap(); - let balance = provider.get_balance(addr).await.unwrap(); - assert_eq!(balance, U256::from(1337u64)); + let (api, _) = spawn(fork_config().with_fork_block_number(Some(block_number))).await; - let fork = api.get_fork().unwrap(); - let fork_db = fork.database.read().await; - let acc = fork_db - .maybe_inner() - .expect("could not get fork db inner") - .db() - .accounts - .read() - .get(&addr) - .cloned() + let res1 = api + .call( + WithOtherFields::new(TransactionRequest { + to: Some(TxKind::from(to)), + input: input.into(), + ..Default::default() + }), + None, + EvmOverrides::default(), + ) + .await .unwrap(); - assert_eq!(acc.balance, remote_balance); + assert_eq!(res0, res1); } #[tokio::test(flavor = "multi_thread")] -async fn can_deploy_greeter_on_fork() { - let (_api, handle) = spawn(fork_config().with_fork_block_number(Some(14723772u64))).await; - - let wallet = handle.dev_wallets().next().unwrap(); - let signer: EthereumWallet = wallet.into(); +async fn test_fork_block_timestamp() { + let (api, _) = spawn(fork_config()).await; - let provider = http_provider_with_signer(&handle.http_endpoint(), signer); + let initial_block = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + api.anvil_mine(Some(U256::ONE), None).await.unwrap(); + let latest_block = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); - let greeter_contract = Greeter::deploy(&provider, "Hello World!".to_string()).await.unwrap(); + assert!(initial_block.header.timestamp <= latest_block.header.timestamp); +} - let greeting = greeter_contract.greet().call().await.unwrap(); - assert_eq!("Hello World!", greeting); +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_snapshot_block_timestamp() { + let (api, _) = spawn(fork_config()).await; - let greeter_contract = Greeter::deploy(&provider, "Hello World!".to_string()).await.unwrap(); + let snapshot_id = api.evm_snapshot().await.unwrap(); + api.anvil_mine(Some(U256::ONE), None).await.unwrap(); + let initial_block = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + api.evm_revert(snapshot_id).await.unwrap(); + api.evm_set_next_block_timestamp(initial_block.header.timestamp).unwrap(); + api.anvil_mine(Some(U256::ONE), None).await.unwrap(); + let latest_block = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); - let greeting = greeter_contract.greet().call().await.unwrap(); - assert_eq!("Hello World!", greeting); + assert_eq!(initial_block.header.timestamp, latest_block.header.timestamp); } #[tokio::test(flavor = "multi_thread")] -async fn can_reset_properly() { - let (origin_api, origin_handle) = spawn(NodeConfig::test()).await; - let account = origin_handle.dev_accounts().next().unwrap(); - let origin_provider = origin_handle.http_provider(); - let origin_nonce = 1u64; - origin_api.anvil_set_nonce(account, U256::from(origin_nonce)).await.unwrap(); - - assert_eq!(origin_nonce, origin_provider.get_transaction_count(account).await.unwrap()); - - let (fork_api, fork_handle) = - spawn(NodeConfig::test().with_eth_rpc_url(Some(origin_handle.http_endpoint()))).await; +async fn test_fork_uncles_fetch() { + let (api, handle) = spawn(fork_config()).await; + let provider = handle.http_provider(); - let fork_provider = fork_handle.http_provider(); - let fork_tx_provider = http_provider(&fork_handle.http_endpoint()); - assert_eq!(origin_nonce, fork_provider.get_transaction_count(account).await.unwrap()); + // Block on ETH mainnet with 2 uncles + let block_with_uncles = 190u64; - let to = Address::random(); - let to_balance = fork_provider.get_balance(to).await.unwrap(); - let tx = TransactionRequest::default().from(account).to(to).value(U256::from(1337u64)); - let tx = WithOtherFields::new(tx); - let tx = fork_tx_provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + let block = + api.block_by_number(BlockNumberOrTag::Number(block_with_uncles)).await.unwrap().unwrap(); - // nonce incremented by 1 - assert_eq!(origin_nonce + 1, fork_provider.get_transaction_count(account).await.unwrap()); + assert_eq!(block.uncles.len(), 2); - // resetting to origin state - fork_api.anvil_reset(Some(Forking::default())).await.unwrap(); + let count = provider.get_uncle_count(block_with_uncles.into()).await.unwrap(); + assert_eq!(count as usize, block.uncles.len()); - // nonce reset to origin - assert_eq!(origin_nonce, fork_provider.get_transaction_count(account).await.unwrap()); + let hash = BlockId::hash(block.header.hash); + let count = provider.get_uncle_count(hash).await.unwrap(); + assert_eq!(count as usize, block.uncles.len()); - // balance is reset - assert_eq!(to_balance, fork_provider.get_balance(to).await.unwrap()); + for (uncle_idx, uncle_hash) in block.uncles.iter().enumerate() { + // Try with block number + let uncle = provider + .get_uncle(BlockId::number(block_with_uncles), uncle_idx as u64) + .await + .unwrap() + .unwrap(); + assert_eq!(*uncle_hash, uncle.header.hash); - // tx does not exist anymore - assert!(fork_tx_provider.get_transaction_by_hash(tx.transaction_hash).await.unwrap().is_none()) + // Try with block hash + let uncle = provider + .get_uncle(BlockId::hash(block.header.hash), uncle_idx as u64) + .await + .unwrap() + .unwrap(); + assert_eq!(*uncle_hash, uncle.header.hash); + } } -// Ref: #[tokio::test(flavor = "multi_thread")] -async fn can_reset_fork_to_new_fork() { - let eth_rpc_url = next_rpc_endpoint(NamedChain::Mainnet); - let (api, handle) = spawn(NodeConfig::test().with_eth_rpc_url(Some(eth_rpc_url))).await; +async fn test_fork_block_transaction_count() { + let (api, handle) = spawn(fork_config()).await; let provider = handle.http_provider(); - let op = address!("0xC0d3c0d3c0D3c0D3C0d3C0D3C0D3c0d3c0d30007"); // L2CrossDomainMessenger - Dead on mainnet. + let accounts: Vec<_> = handle.dev_wallets().collect(); + let sender = accounts[0].address(); - let tx = TransactionRequest::default().with_to(op).with_input("0x54fd4d50"); + // disable automine (so there are pending transactions) + api.anvil_set_auto_mine(false).await.unwrap(); + // transfer: impersonate real sender + api.anvil_impersonate_account(sender).await.unwrap(); + let tx = + TransactionRequest::default().from(sender).value(U256::from(42u64)).with_gas_limit(100_000); let tx = WithOtherFields::new(tx); + let _ = provider.send_transaction(tx).await.unwrap(); - let mainnet_call_output = provider.call(tx).await.unwrap(); - - assert_eq!(mainnet_call_output, Bytes::new()); // 0x - - let optimism = next_rpc_endpoint(NamedChain::Optimism); + let pending_txs = + api.block_transaction_count_by_number(BlockNumberOrTag::Pending).await.unwrap().unwrap(); + assert_eq!(pending_txs.to::(), 1); - api.anvil_reset(Some(Forking { - json_rpc_url: Some(optimism.to_string()), - block_number: Some(124659890), - })) - .await - .unwrap(); + // mine a new block + api.anvil_mine(None, None).await.unwrap(); - let code = provider.get_code_at(op).await.unwrap(); + let pending_txs = + api.block_transaction_count_by_number(BlockNumberOrTag::Pending).await.unwrap().unwrap(); + assert_eq!(pending_txs.to::(), 0); + let latest_txs = + api.block_transaction_count_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert_eq!(latest_txs.to::(), 1); + let latest_block = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + let latest_txs = + api.block_transaction_count_by_hash(latest_block.header.hash).await.unwrap().unwrap(); + assert_eq!(latest_txs.to::(), 1); - assert_ne!(code, Bytes::new()); + // check txs count on an older block: 420000 has 3 txs on mainnet + let count_txs = api + .block_transaction_count_by_number(BlockNumberOrTag::Number(420000)) + .await + .unwrap() + .unwrap(); + assert_eq!(count_txs.to::(), 3); + let count_txs = api + .block_transaction_count_by_hash(b256!( + "0xb3b0e3e0c64e23fb7f1ccfd29245ae423d2f6f1b269b63b70ff882a983ce317c" + )) + .await + .unwrap() + .unwrap(); + assert_eq!(count_txs.to::(), 3); } +// #[tokio::test(flavor = "multi_thread")] -async fn test_fork_timestamp() { - let start = std::time::Instant::now(); - - let (api, handle) = spawn(fork_config()).await; +async fn can_impersonate_in_fork() { + let (api, handle) = spawn(fork_config().with_fork_block_number(Some(15347924u64))).await; let provider = handle.http_provider(); - let block = provider.get_block(BlockId::Number(BLOCK_NUMBER.into())).await.unwrap().unwrap(); - assert_eq!(block.header.timestamp, BLOCK_TIMESTAMP); + let token_holder = address!("0x2f0b23f53734252bda2277357e97e1517d6b042a"); + let to = Address::random(); + let val = U256::from(1337u64); - let accounts: Vec<_> = handle.dev_wallets().collect(); - let from = accounts[0].address(); + // fund the impersonated account + api.anvil_set_balance(token_holder, U256::from(1e18)).await.unwrap(); - let tx = - TransactionRequest::default().to(Address::random()).value(U256::from(1337u64)).from(from); + let tx = TransactionRequest::default().from(token_holder).to(to).value(val); let tx = WithOtherFields::new(tx); - let tx = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); - let status = tx.inner.inner.inner.receipt.status.coerce_status(); + let res = provider.send_transaction(tx.clone()).await; + res.unwrap_err(); + + api.anvil_impersonate_account(token_holder).await.unwrap(); + + let res = provider.send_transaction(tx.clone()).await.unwrap().get_receipt().await.unwrap(); + assert_eq!(res.from, token_holder); + let status = res.inner.inner.inner.receipt.status.coerce_status(); assert!(status); - let block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + let balance = provider.get_balance(to).await.unwrap(); + assert_eq!(balance, val); - let elapsed = start.elapsed().as_secs() + 1; + api.anvil_stop_impersonating_account(token_holder).await.unwrap(); + let res = provider.send_transaction(tx).await; + res.unwrap_err(); +} - // ensure the diff between the new mined block and the original block is within the elapsed time - let diff = block.header.timestamp - BLOCK_TIMESTAMP; - assert!(diff <= elapsed, "diff={diff}, elapsed={elapsed}"); +// +#[tokio::test(flavor = "multi_thread")] +async fn test_transaction_receipt() { + let (api, _) = spawn(fork_config()).await; - let start = std::time::Instant::now(); - // reset to check timestamp works after resetting - api.anvil_reset(Some(Forking { json_rpc_url: None, block_number: Some(BLOCK_NUMBER) })) + // A transaction from the forked block (14608400) + let receipt = api + .transaction_receipt(b256!( + "0xce495d665e9091613fd962351a5cbca27a992b919d6a87d542af97e2723ec1e4" + )) .await .unwrap(); - let block = provider.get_block(BlockId::Number(BLOCK_NUMBER.into())).await.unwrap().unwrap(); - assert_eq!(block.header.timestamp, BLOCK_TIMESTAMP); - - let tx = - TransactionRequest::default().to(Address::random()).value(U256::from(1337u64)).from(from); - let tx = WithOtherFields::new(tx); - let _ = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); // FIXME: Awaits endlessly here. - - let block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); - let elapsed = start.elapsed().as_secs() + 1; - let diff = block.header.timestamp - BLOCK_TIMESTAMP; - assert!(diff <= elapsed); + assert!(receipt.is_some()); - // ensure that after setting a timestamp manually, then next block time is correct - let start = std::time::Instant::now(); - api.anvil_reset(Some(Forking { json_rpc_url: None, block_number: Some(BLOCK_NUMBER) })) + // A transaction from a block in the future (14608401) + let receipt = api + .transaction_receipt(b256!( + "0x1a15472088a4a97f29f2f9159511dbf89954b58d9816e58a32b8dc17171dc0e8" + )) .await .unwrap(); - api.evm_set_next_block_timestamp(BLOCK_TIMESTAMP + 1).unwrap(); - let tx = - TransactionRequest::default().to(Address::random()).value(U256::from(1337u64)).from(from); - let tx = WithOtherFields::new(tx); - let _tx = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + assert!(receipt.is_none()); +} - let block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); - assert_eq!(block.header.timestamp, BLOCK_TIMESTAMP + 1); +// +#[tokio::test(flavor = "multi_thread")] +async fn test_block_receipts() { + let (api, _) = spawn(fork_config()).await; - let tx = - TransactionRequest::default().to(Address::random()).value(U256::from(1337u64)).from(from); - let tx = WithOtherFields::new(tx); - let _ = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + // Receipts from the forked block (14608400) + let receipts = api.block_receipts(BlockId::number(BLOCK_NUMBER)).await.unwrap(); + assert!(receipts.is_some()); - let block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); - let elapsed = start.elapsed().as_secs() + 1; - let diff = block.header.timestamp - (BLOCK_TIMESTAMP + 1); - assert!(diff <= elapsed); -} + // Receipts from a block in the future (14608401) + let receipts = api.block_receipts(BlockId::number(BLOCK_NUMBER + 1)).await.unwrap(); + assert!(receipts.is_none()); -#[tokio::test(flavor = "multi_thread")] -async fn test_fork_set_empty_code() { - let (api, _handle) = spawn(fork_config()).await; - let addr = "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984".parse().unwrap(); - let code = api.get_code(addr, None).await.unwrap(); - assert!(!code.as_ref().is_empty()); - api.anvil_set_code(addr, Vec::new().into()).await.unwrap(); - let code = api.get_code(addr, None).await.unwrap(); - assert!(code.as_ref().is_empty()); + // Receipts from a block hash (14608400) + let hash = b256!("0x4c1c76f89cfe4eb503b09a0993346dd82865cac9d76034efc37d878c66453f0a"); + let receipts = api.block_receipts(BlockId::hash(hash)).await.unwrap(); + assert!(receipts.is_some()); } #[tokio::test(flavor = "multi_thread")] -async fn test_fork_can_send_tx() { - let (api, handle) = - spawn(fork_config().with_blocktime(Some(std::time::Duration::from_millis(800)))).await; +async fn test_pending_block_receipts_do_not_return_fork_head_receipts() { + let (origin_api, origin_handle) = spawn(NodeConfig::test()).await; + let sender = origin_handle.dev_wallets().next().unwrap().address(); + origin_api + .send_transaction(WithOtherFields::new( + TransactionRequest::default().from(sender).to(Address::random()).value(U256::ONE), + )) + .await + .unwrap(); - let wallet = PrivateKeySigner::random(); - let signer = wallet.address(); - let provider = handle.http_provider(); - // let provider = SignerMiddleware::new(provider, wallet); + let (fork_api, _) = spawn( + NodeConfig::test() + .with_eth_rpc_url(Some(origin_handle.http_endpoint())) + .with_fork_block_number(Some(1u64)), + ) + .await; - api.anvil_set_balance(signer, U256::MAX).await.unwrap(); - api.anvil_impersonate_account(signer).await.unwrap(); // Added until WalletFiller for alloy-provider is fixed. - let balance = provider.get_balance(signer).await.unwrap(); - assert_eq!(balance, U256::MAX); + let latest_receipts = fork_api.block_receipts(BlockId::latest()).await.unwrap().unwrap(); + assert_eq!(latest_receipts.len(), 1); - let addr = Address::random(); - let val = U256::from(1337u64); - let tx = TransactionRequest::default().to(addr).value(val).from(signer); - let tx = WithOtherFields::new(tx); - // broadcast it via the eth_sendTransaction API - let _ = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + let pending_block = + fork_api.block_by_number_full(BlockNumberOrTag::Pending).await.unwrap().unwrap(); + assert_eq!(pending_block.header.number, 2); + assert!(pending_block.transactions.is_empty()); - let balance = provider.get_balance(addr).await.unwrap(); - assert_eq!(balance, val); + let pending_receipts = fork_api.block_receipts(BlockId::pending()).await.unwrap().unwrap(); + assert!(pending_receipts.is_empty()); } -// #[tokio::test(flavor = "multi_thread")] -async fn test_fork_nft_set_approve_all() { - let (api, handle) = spawn( +async fn can_override_fork_chain_id() { + let chain_id_override = 5u64; + let (_api, handle) = spawn( fork_config() - .with_fork_block_number(Some(14812197u64)) - .with_blocktime(Some(Duration::from_secs(5))) - .with_chain_id(1u64.into()), + .with_fork_block_number(Some(16506610u64)) + .with_chain_id(Some(chain_id_override)), ) .await; - // create and fund a random wallet - let wallet = PrivateKeySigner::random(); - let signer = wallet.address(); - api.anvil_set_balance(signer, U256::from(1000e18)).await.unwrap(); + let wallet = handle.dev_wallets().next().unwrap(); + let signer: EthereumWallet = wallet.into(); + let provider = http_provider_with_signer(&handle.http_endpoint(), signer); - let provider = handle.http_provider(); + let greeter_contract = + Greeter::deploy(provider.clone(), "Hello World!".to_string()).await.unwrap(); + let greeting = greeter_contract.greet().call().await.unwrap(); - // pick a random nft - let nouns_addr: Address = "0x9c8ff314c9bc7f6e59a9d9225fb22946427edc03".parse().unwrap(); + assert_eq!("Hello World!", greeting); + let greeter_contract = + Greeter::deploy(provider.clone(), "Hello World!".to_string()).await.unwrap(); + let greeting = greeter_contract.greet().call().await.unwrap(); + assert_eq!("Hello World!", greeting); - let owner: Address = "0x052564eb0fd8b340803df55def89c25c432f43f4".parse().unwrap(); - let token_id: U256 = U256::from(154u64); + let provider = handle.http_provider(); + let chain_id = provider.get_chain_id().await.unwrap(); + assert_eq!(chain_id, chain_id_override); +} - let nouns = ERC721::new(nouns_addr, provider.clone()); +// +#[tokio::test(flavor = "multi_thread")] +async fn flaky_test_fork_reset_moonbeam() { + crate::init_tracing(); + let (api, handle) = spawn( + fork_config() + .with_eth_rpc_url(Some("https://moonbeam.api.onfinality.io/public".to_string())) + .with_fork_block_number(None::), + ) + .await; + let provider = handle.http_provider(); - let real_owner = nouns.ownerOf(token_id).call().await.unwrap(); - assert_eq!(real_owner, owner); - let approval = nouns.setApprovalForAll(nouns_addr, true); - let tx = TransactionRequest::default() - .from(owner) - .to(nouns_addr) - .with_input(approval.calldata().to_owned()); + let accounts: Vec<_> = handle.dev_wallets().collect(); + let from = accounts[0].address(); + + let tx = + TransactionRequest::default().to(Address::random()).value(U256::from(1337u64)).from(from); let tx = WithOtherFields::new(tx); - api.anvil_impersonate_account(owner).await.unwrap(); + api.anvil_impersonate_account(from).await.unwrap(); let tx = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); let status = tx.inner.inner.inner.receipt.status.coerce_status(); assert!(status); - // transfer: impersonate real owner and transfer nft - api.anvil_impersonate_account(real_owner).await.unwrap(); - - api.anvil_set_balance(real_owner, U256::from(10000e18 as u64)).await.unwrap(); + // reset to check timestamp works after resetting + api.anvil_reset(Some(Forking { + json_rpc_url: Some("https://moonbeam.api.onfinality.io/public".to_string()), + block_number: None, + })) + .await + .unwrap(); - let call = nouns.transferFrom(real_owner, signer, token_id); - let tx = TransactionRequest::default() - .from(real_owner) - .to(nouns_addr) - .with_input(call.calldata().to_owned()); + let tx = + TransactionRequest::default().to(Address::random()).value(U256::from(1337u64)).from(from); let tx = WithOtherFields::new(tx); let tx = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); let status = tx.inner.inner.inner.receipt.status.coerce_status(); assert!(status); - - let real_owner = nouns.ownerOf(token_id).call().await.unwrap(); - assert_eq!(real_owner, wallet.address()); } -// +// + let (api, _handle) = spawn(fork_config().with_fork_block_number(Some(18835000u64))).await; - // get the eth chainId and the txn chainId - let eth_chain_id = api.eth_chain_id(); - let txn_chain_id = api.chain_id(); + api.mine_one().await.unwrap(); + let latest = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); - // get the chainId in the config - let config_chain_id = handle.config().chain_id; + // basefee of +1 block: + assert_eq!(latest.header.base_fee_per_gas.unwrap(), 59455969592u64); - // check that the chainIds are the same - assert_eq!(eth_chain_id.unwrap().unwrap().to::(), 3145u64); - assert_eq!(txn_chain_id, 3145u64); - assert_eq!(config_chain_id, Some(3145u64)); + // now reset to block 18835000 -1 + api.anvil_reset(Some(Forking { json_rpc_url: None, block_number: Some(18835000u64 - 1) })) + .await + .unwrap(); + + api.mine_one().await.unwrap(); + let latest = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + + // basefee of the forked block: + assert_eq!(latest.header.base_fee_per_gas.unwrap(), 59017001138); } -// +// #[tokio::test(flavor = "multi_thread")] -async fn test_fork_can_send_opensea_tx() { +async fn flaky_test_arbitrum_fork_dev_balance() { let (api, handle) = spawn( fork_config() - .with_fork_block_number(Some(14983338u64)) - .with_blocktime(Some(Duration::from_millis(5000))), + .with_fork_block_number(None::) + .with_eth_rpc_url(Some(next_rpc_endpoint(NamedChain::Arbitrum))), ) .await; - let sender: Address = "0x8fdbae54b6d9f3fc2c649e3dd4602961967fd42f".parse().unwrap(); + let accounts: Vec<_> = handle.dev_wallets().collect(); + for acc in accounts { + let balance = api.balance(acc.address(), Some(Default::default())).await.unwrap(); + assert_eq!(balance, U256::from(100000000000000000000u128)); + } +} - // transfer: impersonate real sender - api.anvil_impersonate_account(sender).await.unwrap(); +// +#[tokio::test(flavor = "multi_thread")] +async fn flaky_test_arb_fork_mining() { + let fork_block_number = 394274860u64; + let fork_rpc = next_rpc_endpoint(NamedChain::Arbitrum); + let (api, _handle) = spawn( + fork_config() + .with_fork_block_number(Some(fork_block_number)) + .with_eth_rpc_url(Some(fork_rpc)), + ) + .await; - let provider = handle.http_provider(); + let init_blk_num = api.block_number().unwrap().to::(); - let input: Bytes = "0xfb0f3ee1000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003ff2e795f5000000000000000000000000000023f28ae3e9756ba982a6290f9081b6a84900b758000000000000000000000000004c00500000ad104d7dbd00e3ae0a5c00560c0000000000000000000000000003235b597a78eabcb08ffcb4d97411073211dbcb0000000000000000000000000000000000000000000000000000000000000e72000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000062ad47c20000000000000000000000000000000000000000000000000000000062d43104000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000df44e65d2a2cf40000007b02230091a7ed01230072f7006a004d60a8d4e71d599b8104250f00000000007b02230091a7ed01230072f7006a004d60a8d4e71d599b8104250f00000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000000002e000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000001c6bf526340000000000000000000000000008de9c5a032463c561423387a9648c5c7bcc5bc900000000000000000000000000000000000000000000000000005543df729c0000000000000000000000000006eb234847a9e3a546539aac57a071c01dc3f398600000000000000000000000000000000000000000000000000000000000000416d39b5352353a22cf2d44faa696c2089b03137a13b5acfee0366306f2678fede043bc8c7e422f6f13a3453295a4a063dac7ee6216ab7bade299690afc77397a51c00000000000000000000000000000000000000000000000000000000000000".parse().unwrap(); - let to: Address = "0x00000000006c3852cbef3e08e8df289169ede581".parse().unwrap(); - let tx = TransactionRequest::default() - .from(sender) - .to(to) - .value(U256::from(20000000000000000u64)) - .with_input(input) - .with_gas_price(22180711707u128) - .with_gas_limit(150_000); - let tx = WithOtherFields::new(tx); + // Mine one + api.mine_one().await.unwrap(); + let mined_blk_num = api.block_number().unwrap().to::(); - let tx = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); - let status = tx.inner.inner.inner.receipt.status.coerce_status(); - assert!(status); + assert_eq!(mined_blk_num, init_blk_num + 1); } -#[tokio::test(flavor = "multi_thread")] -async fn test_fork_base_fee() { - let (api, handle) = spawn(fork_config()).await; - - let accounts: Vec<_> = handle.dev_wallets().collect(); - let from = accounts[0].address(); +/// Serves an Arbitrum fork root with distinct L1 and L2 block numbers. +async fn spawn_arbitrum_block_number_source( + target: Address, + l2_block: u64, + l1_block: u64, +) -> (NodeHandle, String, tokio::task::JoinHandle<()>) { + let (origin_api, origin) = spawn( + NodeConfig::test() + .with_chain_id(Some(NamedChain::Arbitrum as u64)) + .with_genesis_block_number(Some(l2_block)), + ) + .await; + // Store NUMBER in slot zero and return it, exercising both calls and mined transactions. + origin_api.anvil_set_code(target, bytes!("436000554360005260206000f3")).await.unwrap(); + let endpoint = origin.http_endpoint(); + let client = reqwest::Client::new(); + let router = Router::new().route( + "/", + post(move |Json(request): Json| { + let client = client.clone(); + let endpoint = endpoint.clone(); + async move { + let mut response = client + .post(endpoint) + .json(&request) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + if matches!( + request.get("method").and_then(Value::as_str), + Some("eth_getBlockByHash" | "eth_getBlockByNumber") + ) && let Some(block) = response.get_mut("result").and_then(Value::as_object_mut) + { + block.insert( + "l1BlockNumber".to_string(), + Value::String(format!("0x{l1_block:x}")), + ); + } + Json(response) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let proxy = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); - let provider = handle.http_provider(); + (origin, format!("http://{address}"), proxy) +} - api.anvil_set_next_block_base_fee_per_gas(U256::ZERO).await.unwrap(); +// +#[tokio::test(flavor = "multi_thread")] +async fn test_arbitrum_fork_preserves_l1_block_number_after_mining() { + const L2_BLOCK: u64 = 503_433_721; + const L1_BLOCK: u64 = 25_941_231; + let target = Address::random(); + let (_origin, endpoint, proxy) = + spawn_arbitrum_block_number_source(target, L2_BLOCK, L1_BLOCK).await; + + for chain_id in [None, Some(1u64)] { + let config = NodeConfig::test() + .with_chain_id(chain_id) + .with_no_storage_caching(true) + .with_eth_rpc_url(Some(endpoint.clone())) + .with_fork_block_number(Some(L2_BLOCK)); + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + let request = WithOtherFields::new(TransactionRequest::default().with_to(target)); + assert_eq!( + U256::from_be_slice( + &provider.call(request.clone()).block(BlockId::latest()).await.unwrap() + ), + U256::from(L1_BLOCK) + ); + let arb_request = WithOtherFields::new( + TransactionRequest::default() + .with_to(arbitrum::ARB_SYS_ADDRESS) + .with_input(Bytes::from(arbitrum::ARB_BLOCK_NUMBER_SELECTOR)), + ); + let snapshot = api.evm_snapshot().await.unwrap(); + api.mine_one().await.unwrap(); + for offset in 1..=2 { + assert_eq!(provider.get_block_number().await.unwrap(), L2_BLOCK + offset); + for (block, expected_offset) in [ + (BlockNumberOrTag::Latest, offset), + (BlockNumberOrTag::Pending, offset + 1), + (BlockNumberOrTag::Number(L2_BLOCK), 0), + ] { + assert_eq!( + U256::from_be_slice( + &provider.call(request.clone()).block(block.into()).await.unwrap() + ), + U256::from(L1_BLOCK + expected_offset), + "NUMBER at {block}" + ); + assert_eq!( + U256::from_be_slice( + &provider.call(arb_request.clone()).block(block.into()).await.unwrap() + ), + U256::from(L2_BLOCK + expected_offset), + "ArbSys at {block}" + ); + } + let block = + provider.get_block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert_eq!(block.header.number, L2_BLOCK + offset); + assert_eq!( + serde_json::from_value::(block.other["l1BlockNumber"].clone()).unwrap(), + U256::from(L1_BLOCK + offset) + ); + if offset == 1 { + let sender = provider.get_accounts().await.unwrap()[0]; + let receipt = provider + .send_transaction(WithOtherFields::new( + TransactionRequest::default() + .with_from(sender) + .with_to(target) + .with_gas_limit(100_000), + )) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert!(receipt.status()); + assert_eq!(receipt.block_number, Some(L2_BLOCK + 2)); + assert_eq!( + provider.get_storage_at(target, U256::ZERO).await.unwrap(), + U256::from(L1_BLOCK + 2) + ); + } + } + assert_eq!( + U256::from_be_slice( + &provider.call(request.clone()).block((L2_BLOCK + 1).into()).await.unwrap() + ), + U256::from(L1_BLOCK + 1) + ); + let state = api.anvil_dump_state(Some(true)).await.unwrap(); + assert!(api.evm_revert(snapshot).await.unwrap()); + assert_eq!(provider.get_block_number().await.unwrap(), L2_BLOCK); + assert_eq!( + U256::from_be_slice( + &provider.call(request.clone()).block(BlockId::latest()).await.unwrap() + ), + U256::from(L1_BLOCK) + ); + api.mine_one().await.unwrap(); + assert_eq!( + U256::from_be_slice( + &provider.call(request.clone()).block(BlockId::latest()).await.unwrap() + ), + U256::from(L1_BLOCK + 1) + ); + assert!(api.anvil_load_state(state).await.unwrap()); + assert_eq!(provider.get_block_number().await.unwrap(), L2_BLOCK + 2); + assert_eq!( + U256::from_be_slice( + &provider.call(request.clone()).block(BlockId::latest()).await.unwrap() + ), + U256::from(L1_BLOCK + 2) + ); + api.mine_one().await.unwrap(); + assert_eq!( + U256::from_be_slice( + &provider.call(request.clone()).block(BlockId::latest()).await.unwrap() + ), + U256::from(L1_BLOCK + 3) + ); + assert_eq!( + U256::from_be_slice( + &provider.call(arb_request.clone()).block(BlockId::latest()).await.unwrap() + ), + U256::from(L2_BLOCK + 3) + ); + api.anvil_reset(Some(Forking { + json_rpc_url: Some(endpoint.clone()), + block_number: Some(L2_BLOCK), + })) + .await + .unwrap(); + api.mine_one().await.unwrap(); + assert_eq!( + U256::from_be_slice(&provider.call(request).block(BlockId::latest()).await.unwrap()), + U256::from(L1_BLOCK + 1) + ); + } + proxy.abort(); +} - let addr = Address::random(); - let val = U256::from(1337u64); - let tx = TransactionRequest::default().from(from).to(addr).value(val); - let tx = WithOtherFields::new(tx); - let _res = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); +#[tokio::test(flavor = "multi_thread")] +async fn test_arbitrum_fork_simulate_preserves_l1_and_l2_block_numbers() { + const L2_BLOCK: u64 = 503_433_721; + const L1_BLOCK: u64 = 25_941_231; + let target = Address::random(); + let (_origin, endpoint, proxy) = + spawn_arbitrum_block_number_source(target, L2_BLOCK, L1_BLOCK).await; + let client = reqwest::Client::new(); + + for chain_id in [None, Some(1u64)] { + let (api, handle) = spawn( + NodeConfig::test() + .with_chain_id(chain_id) + .with_no_storage_caching(true) + .with_eth_rpc_url(Some(endpoint.clone())) + .with_fork_block_number(Some(L2_BLOCK)), + ) + .await; + api.mine_one().await.unwrap(); + api.mine_one().await.unwrap(); + + for (base, offset) in [ + (serde_json::json!("latest"), 3), + (serde_json::json!("pending"), 4), + (serde_json::json!(format!("0x{:x}", L2_BLOCK + 1)), 2), + ] { + // Return and log NUMBER, while also calling ArbSys directly. + let calls = serde_json::json!([ + {"to": target}, + {"to": arbitrum::ARB_SYS_ADDRESS, "input": "0xa3b1b31d"} + ]); + let response = client.post(handle.http_endpoint()).json(&serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "method": "eth_simulateV1", + "params": [{ + "returnFullTransactions": true, + "blockStateCalls": [ + { + "stateOverrides": {target.to_string(): { + "code": "0x4360005260206000a060206000f3" + }}, + "calls": calls.clone() + }, + { + "blockOverrides": {"number": format!("0x{:x}", L2_BLOCK + offset + 2)}, + "calls": calls + } + ] + }, base] + })).send().await.unwrap().json::().await.unwrap(); + assert!(response.get("error").is_none(), "{response}"); + let blocks = response["result"].as_array().unwrap(); + assert_eq!(blocks.len(), 3); + for (index, block) in blocks.iter().enumerate() { + let l2 = L2_BLOCK + offset + index as u64; + let l1 = L1_BLOCK + offset + index as u64; + assert_eq!(block["number"], serde_json::json!(format!("0x{l2:x}"))); + assert_eq!(block["l1BlockNumber"], serde_json::json!(format!("0x{l1:x}"))); + if index == 1 { + assert_eq!(block["calls"], serde_json::json!([])); + continue; + } + for (call, expected) in block["calls"].as_array().unwrap().iter().zip([l1, l2]) { + assert_eq!(call["status"], "0x1"); + assert_eq!(call["returnData"], serde_json::json!(format!("0x{expected:064x}"))); + } + assert_eq!(block["calls"][0]["logs"][0]["blockNumber"], block["number"]); + assert_eq!(block["calls"][0]["logs"][0]["data"], block["calls"][0]["returnData"]); + for tx in block["transactions"].as_array().unwrap() { + assert_eq!(tx["blockNumber"], block["number"]); + } + } + } + assert_eq!(handle.http_provider().get_block_number().await.unwrap(), L2_BLOCK + 2); + } + proxy.abort(); } +// #[tokio::test(flavor = "multi_thread")] -async fn test_fork_init_base_fee() { - let (api, handle) = spawn(fork_config().with_fork_block_number(Some(13184859u64))).await; +async fn flaky_test_arbitrum_fork_block_number() { + // Every fork below must observe the same chain head, so reuse one endpoint: providers are a few + // blocks apart and refetching would otherwise fork at a block the next provider has not seen. + let fork_rpc = next_rpc_endpoint(NamedChain::Arbitrum); + // fork to get initial block for test + let (_, handle) = spawn( + fork_config().with_fork_block_number(None::).with_eth_rpc_url(Some(fork_rpc.clone())), + ) + .await; let provider = handle.http_provider(); + let initial_block_number = provider.get_block_number().await.unwrap(); - let block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); - // - assert_eq!(block.header.number, 13184859u64); - let init_base_fee = block.header.base_fee_per_gas.unwrap(); - assert_eq!(init_base_fee, 63739886069); - - api.mine_one().await; + // fork again at block number returned by `eth_blockNumber` + // if wrong block number returned (e.g. L1) then fork will fail with error code -32000: missing + // trie node + let (api, _) = spawn( + fork_config() + .with_fork_block_number(Some(initial_block_number)) + .with_eth_rpc_url(Some(fork_rpc.clone())), + ) + .await; + let block_number = api.block_number().unwrap().to::(); + assert_eq!(block_number, initial_block_number); - let block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + // take snapshot at initial block number + let snapshot_state = api.evm_snapshot().await.unwrap(); - let next_base_fee = block.header.base_fee_per_gas.unwrap(); - assert!(next_base_fee < init_base_fee); -} + // mine new block and check block number returned by `eth_blockNumber` + api.mine_one().await.unwrap(); + let block_number = api.block_number().unwrap().to::(); + assert_eq!(block_number, initial_block_number + 1); -#[tokio::test(flavor = "multi_thread")] -async fn test_reset_fork_on_new_blocks() { - let (api, handle) = - spawn(NodeConfig::test().with_eth_rpc_url(Some(rpc::next_http_archive_rpc_url()))).await; + // test block by number API call returns proper block number and `l1BlockNumber` is set + let block_by_number = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert_eq!(block_by_number.header.number, initial_block_number + 1); + assert!(block_by_number.other.get("l1BlockNumber").is_some()); - let anvil_provider = handle.http_provider(); - let endpoint = next_http_rpc_endpoint(); - let provider = Arc::new(get_http_provider(&endpoint)); + // revert to recorded snapshot and check block number + assert!(api.evm_revert(snapshot_state).await.unwrap()); + let block_number = api.block_number().unwrap().to::(); + assert_eq!(block_number, initial_block_number); - let current_block = anvil_provider.get_block_number().await.unwrap(); + // reset fork to different block number and compare with block returned by `eth_blockNumber` + api.anvil_reset(Some(Forking { + json_rpc_url: Some(fork_rpc), + block_number: Some(initial_block_number - 2), + })) + .await + .unwrap(); + let block_number = api.block_number().unwrap().to::(); + assert_eq!(block_number, initial_block_number - 2); +} - handle.task_manager().spawn_reset_on_new_polled_blocks(provider.clone(), api); +#[tokio::test(flavor = "multi_thread")] +async fn test_base_fork_gas_limit() { + // fork to get initial block for test + let (api, handle) = spawn( + fork_config() + .with_fork_block_number(None::) + .with_eth_rpc_url(Some(next_rpc_endpoint(NamedChain::Base))), + ) + .await; - let mut stream = provider - .watch_blocks() - .await - .unwrap() - .with_poll_interval(Duration::from_secs(2)) - .into_stream() - .flat_map(futures::stream::iter); - // the http watcher may fetch multiple blocks at once, so we set a timeout here to offset edge - // cases where the stream immediately returns a block - tokio::time::sleep(Duration::from_secs(12)).await; - stream.next().await.unwrap(); - stream.next().await.unwrap(); + // The public Base RPC occasionally returns block zero when it is unhealthy. + if api.block_number().unwrap().is_zero() { + return; + } - let next_block = anvil_provider.get_block_number().await.unwrap(); + let provider = handle.http_provider(); + let block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); - assert!(next_block > current_block, "nextblock={next_block} currentblock={current_block}") + assert!(api.gas_limit() >= uint!(96_000_000_U256)); + assert!(block.header.gas_limit >= 96_000_000_u64); } +// #[tokio::test(flavor = "multi_thread")] -async fn test_fork_call() { - let input: Bytes = "0x77c7b8fc".parse().unwrap(); - let to: Address = "0x99d1Fa417f94dcD62BfE781a1213c092a47041Bc".parse().unwrap(); - let block_number = 14746300u64; - - let provider = http_provider(rpc::next_http_archive_rpc_url().as_str()); - let tx = TransactionRequest::default().to(to).with_input(input.clone()); - let tx = WithOtherFields::new(tx); - let res0 = provider.call(tx).block(BlockId::Number(block_number.into())).await.unwrap(); - - let (api, _) = spawn(fork_config().with_fork_block_number(Some(block_number))).await; +async fn test_fork_execution_reverted() { + let target = 16681681u64; + let (api, _handle) = spawn(fork_config().with_fork_block_number(Some(target + 1))).await; - let res1 = api + let resp = api .call( WithOtherFields::new(TransactionRequest { - to: Some(TxKind::from(to)), - input: input.into(), + to: Some(TxKind::from(address!("0xFd6CC4F251eaE6d02f9F7B41D1e80464D3d2F377"))), + input: TransactionInput::new(bytes!("8f283b3c")), ..Default::default() }), - None, + Some(target.into()), EvmOverrides::default(), ) - .await - .unwrap(); + .await; - assert_eq!(res0, res1); + assert!(resp.is_err()); + let err = resp.unwrap_err(); + assert!(err.to_string().contains("execution reverted")); } #[tokio::test(flavor = "multi_thread")] -async fn test_fork_block_timestamp() { +async fn test_block_by_number_full_refetches_missing_cached_transactions() { let (api, _) = spawn(fork_config()).await; - let initial_block = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); - api.anvil_mine(Some(U256::from(1)), None).await.unwrap(); - let latest_block = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + let block = + api.block_by_number_full(BlockNumberOrTag::Number(BLOCK_NUMBER)).await.unwrap().unwrap(); + let block_txs = block.transactions.as_transactions().unwrap(); + let original_len = block_txs.len(); + let missing_hash = *block_txs[0].tx_hash(); + + let fork = api.backend.get_fork().unwrap(); + { + let mut storage = fork.storage.write(); + assert!(storage.transactions.remove(&missing_hash).is_some()); + } - assert!(initial_block.header.timestamp <= latest_block.header.timestamp); + let refreshed = + api.block_by_number_full(BlockNumberOrTag::Number(BLOCK_NUMBER)).await.unwrap().unwrap(); + let refreshed_txs = refreshed.transactions.as_transactions().unwrap(); + + assert_eq!(refreshed_txs.len(), original_len); + assert_eq!(refreshed_txs[0].tx_hash(), &missing_hash); + assert!(fork.storage.read().transactions.contains_key(&missing_hash)); } +// #[tokio::test(flavor = "multi_thread")] -async fn test_fork_snapshot_block_timestamp() { - let (api, _) = spawn(fork_config()).await; +async fn test_fork_query_at_fork_block() { + let (api, handle) = spawn(fork_config()).await; + let provider = handle.http_provider(); + let info = api.anvil_node_info().await.unwrap(); + let number = info.fork_config.fork_block_number.unwrap(); + assert_eq!(number, BLOCK_NUMBER); - let snapshot_id = api.evm_snapshot().await.unwrap(); - api.anvil_mine(Some(U256::from(1)), None).await.unwrap(); - let initial_block = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); - api.evm_revert(snapshot_id).await.unwrap(); - api.evm_set_next_block_timestamp(initial_block.header.timestamp).unwrap(); - api.anvil_mine(Some(U256::from(1)), None).await.unwrap(); - let latest_block = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + let address = Address::random(); - assert_eq!(initial_block.header.timestamp, latest_block.header.timestamp); + let balance = provider.get_balance(address).await.unwrap(); + api.evm_mine(None).await.unwrap(); + api.anvil_set_balance(address, balance + U256::ONE).await.unwrap(); + + let balance_before = provider.get_balance(address).number(number).await.unwrap(); + + assert_eq!(balance_before, balance); } +// #[tokio::test(flavor = "multi_thread")] -async fn test_fork_uncles_fetch() { - let (api, handle) = spawn(fork_config()).await; +async fn test_reset_dev_account_nonce() { + let config: NodeConfig = fork_config(); + let address = config.genesis_accounts[0].address(); + let (api, handle) = spawn(config).await; let provider = handle.http_provider(); + let info = api.anvil_node_info().await.unwrap(); + let number = info.fork_config.fork_block_number.unwrap(); + assert_eq!(number, BLOCK_NUMBER); - // Block on ETH mainnet with 2 uncles - let block_with_uncles = 190u64; + let nonce_before = provider.get_transaction_count(address).await.unwrap(); - let block = - api.block_by_number(BlockNumberOrTag::Number(block_with_uncles)).await.unwrap().unwrap(); + // Reset to older block with other nonce + api.anvil_reset(Some(Forking { + json_rpc_url: None, + block_number: Some(BLOCK_NUMBER - 1_000_000), + })) + .await + .unwrap(); - assert_eq!(block.uncles.len(), 2); + let nonce_after = provider.get_transaction_count(address).await.unwrap(); - let count = provider.get_uncle_count(block_with_uncles.into()).await.unwrap(); - assert_eq!(count as usize, block.uncles.len()); + assert!(nonce_before > nonce_after); - let hash = BlockId::hash(block.header.hash); - let count = provider.get_uncle_count(hash).await.unwrap(); - assert_eq!(count as usize, block.uncles.len()); + let receipt = provider + .send_transaction(WithOtherFields::new( + TransactionRequest::default() + .from(address) + .to(address) + .nonce(nonce_after) + .gas_limit(21000), + )) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); - for (uncle_idx, uncle_hash) in block.uncles.iter().enumerate() { - // Try with block number - let uncle = provider - .get_uncle(BlockId::number(block_with_uncles), uncle_idx as u64) - .await - .unwrap() - .unwrap(); - assert_eq!(*uncle_hash, uncle.header.hash); + assert!(receipt.status()); +} - // Try with block hash - let uncle = provider - .get_uncle(BlockId::hash(block.header.hash), uncle_idx as u64) - .await - .unwrap() - .unwrap(); - assert_eq!(*uncle_hash, uncle.header.hash); +#[tokio::test(flavor = "multi_thread")] +async fn test_set_erc20_balance() { + let config: NodeConfig = fork_config(); + let address = config.genesis_accounts[0].address(); + let (api, handle) = spawn(config).await; + + let provider = handle.http_provider(); + + alloy_sol_types::sol! { + #[sol(rpc)] + contract ERC20 { + function balanceOf(address owner) public view returns (uint256); + } } + let dai = address!("0x6B175474E89094C44Da98b954EedeAC495271d0F"); + let erc20 = ERC20::new(dai, provider); + let value = U256::from(500); + + api.anvil_deal_erc20(address, dai, value).await.unwrap(); + + let new_balance = erc20.balanceOf(address).call().await.unwrap(); + + assert_eq!(new_balance, value); } #[tokio::test(flavor = "multi_thread")] -async fn test_fork_block_transaction_count() { - let (api, handle) = spawn(fork_config()).await; +async fn test_set_erc20_allowance() { + let config: NodeConfig = fork_config(); + let owner = config.genesis_accounts[0].address(); + let spender = config.genesis_accounts[1].address(); + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); - let accounts: Vec<_> = handle.dev_wallets().collect(); - let sender = accounts[0].address(); + alloy_sol_types::sol! { + #[sol(rpc)] + contract ERC20 { + function allowance(address owner, address spender) external view returns (uint256); + } + } + let dai = address!("0x6B175474E89094C44Da98b954EedeAC495271d0F"); + let erc20 = ERC20::new(dai, provider); + let value = U256::from(500); - // disable automine (so there are pending transactions) - api.anvil_set_auto_mine(false).await.unwrap(); - // transfer: impersonate real sender - api.anvil_impersonate_account(sender).await.unwrap(); + api.anvil_set_erc20_allowance(owner, spender, dai, value).await.unwrap(); - let tx = - TransactionRequest::default().from(sender).value(U256::from(42u64)).with_gas_limit(100_000); - let tx = WithOtherFields::new(tx); - let _ = provider.send_transaction(tx).await.unwrap(); + let allowance = erc20.allowance(owner, spender).call().await.unwrap(); + assert_eq!(allowance, value); +} - let pending_txs = - api.block_transaction_count_by_number(BlockNumberOrTag::Pending).await.unwrap().unwrap(); - assert_eq!(pending_txs.to::(), 1); +#[tokio::test(flavor = "multi_thread")] +async fn test_add_balance() { + let config: NodeConfig = fork_config(); + let address = config.genesis_accounts[0].address(); + let (api, _handle) = spawn(config).await; - // mine a new block - api.anvil_mine(None, None).await.unwrap(); + let start_balance = U256::from(100_000_u64); + api.anvil_set_balance(address, start_balance).await.unwrap(); - let pending_txs = - api.block_transaction_count_by_number(BlockNumberOrTag::Pending).await.unwrap().unwrap(); - assert_eq!(pending_txs.to::(), 0); - let latest_txs = - api.block_transaction_count_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); - assert_eq!(latest_txs.to::(), 1); - let latest_block = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); - let latest_txs = - api.block_transaction_count_by_hash(latest_block.header.hash).await.unwrap().unwrap(); - assert_eq!(latest_txs.to::(), 1); + let balance_increase = U256::from(50_000_u64); + api.anvil_add_balance(address, balance_increase).await.unwrap(); - // check txs count on an older block: 420000 has 3 txs on mainnet - let count_txs = api - .block_transaction_count_by_number(BlockNumberOrTag::Number(420000)) - .await - .unwrap() - .unwrap(); - assert_eq!(count_txs.to::(), 3); - let count_txs = api - .block_transaction_count_by_hash( - "0xb3b0e3e0c64e23fb7f1ccfd29245ae423d2f6f1b269b63b70ff882a983ce317c".parse().unwrap(), - ) - .await - .unwrap() - .unwrap(); - assert_eq!(count_txs.to::(), 3); + let new_balance = api.balance(address, None).await.unwrap(); + assert_eq!(new_balance, start_balance + balance_increase); } -// #[tokio::test(flavor = "multi_thread")] -async fn can_impersonate_in_fork() { - let (api, handle) = spawn(fork_config().with_fork_block_number(Some(15347924u64))).await; - let provider = handle.http_provider(); - - let token_holder: Address = "0x2f0b23f53734252bda2277357e97e1517d6b042a".parse().unwrap(); - let to = Address::random(); - let val = U256::from(1337u64); - - // fund the impersonated account - api.anvil_set_balance(token_holder, U256::from(1e18)).await.unwrap(); +async fn test_reset_updates_cache_path_when_rpc_url_not_provided() { + let config: NodeConfig = fork_config(); - let tx = TransactionRequest::default().from(token_holder).to(to).value(val); - let tx = WithOtherFields::new(tx); - let res = provider.send_transaction(tx.clone()).await; - res.unwrap_err(); + let (mut api, _handle) = spawn(config).await; + let info = api.anvil_node_info().await.unwrap(); + let number = info.fork_config.fork_block_number.unwrap(); + assert_eq!(number, BLOCK_NUMBER); - api.anvil_impersonate_account(token_holder).await.unwrap(); + async fn get_block_from_cache_path(api: &mut EthApi) -> u64 { + let db = api.backend.get_db().read().await; + let cache_path = db.maybe_inner().unwrap().cache().cache_path().unwrap(); + cache_path + .parent() + .expect("must have filename") + .file_name() + .expect("must have block number as dir name") + .to_str() + .expect("must be valid string") + .parse::() + .expect("must be valid number") + } - let res = provider.send_transaction(tx.clone()).await.unwrap().get_receipt().await.unwrap(); - assert_eq!(res.from, token_holder); - let status = res.inner.inner.inner.receipt.status.coerce_status(); - assert!(status); + assert_eq!(BLOCK_NUMBER, get_block_from_cache_path(&mut api).await); - let balance = provider.get_balance(to).await.unwrap(); - assert_eq!(balance, val); + // Reset to older block without specifying a new rpc url + api.anvil_reset(Some(Forking { + json_rpc_url: None, + block_number: Some(BLOCK_NUMBER - 1_000_000), + })) + .await + .unwrap(); - api.anvil_stop_impersonating_account(token_holder).await.unwrap(); - let res = provider.send_transaction(tx).await; - res.unwrap_err(); + assert_eq!(BLOCK_NUMBER - 1_000_000, get_block_from_cache_path(&mut api).await); } -// #[tokio::test(flavor = "multi_thread")] -#[ignore] -async fn test_total_difficulty_fork() { - let (api, handle) = spawn(fork_config()).await; +async fn test_fork_reset_reuses_cached_remote_state() { + let address = Address::random(); + let balance = U256::from(1337u64); + let chain_id = + u64::from_be_bytes(address.as_slice()[12..].try_into().unwrap()) % 1_000_000 + 1_000_000; + let cache_dir = Config::foundry_chain_cache_dir(chain_id).unwrap(); + let _ = std::fs::remove_dir_all(&cache_dir); + + let origin_config = NodeConfig::test() + .with_chain_id(Some(chain_id)) + .with_funded_accounts([(address, balance)].into_iter().collect()); + let (_origin_api, origin_handle) = spawn(origin_config).await; + let fork_config = NodeConfig::test() + .with_chain_id(Some(chain_id)) + .with_eth_rpc_url(Some(origin_handle.http_endpoint())); + let (api, handle) = spawn(fork_config).await; + let provider = handle.http_provider(); + let fork_block_number = api.anvil_node_info().await.unwrap().fork_config.fork_block_number; - let total_difficulty = U256::from(46_673_965_560_973_856_260_636u128); - let difficulty = U256::from(13_680_435_288_526_144u128); + assert_eq!(provider.get_balance(address).await.unwrap(), balance); + api.mine_one().await.unwrap(); - let provider = handle.http_provider(); - let block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); - assert_eq!(block.header.total_difficulty, Some(total_difficulty)); - assert_eq!(block.header.difficulty, difficulty); + for _ in 0..2 { + api.anvil_reset(Some(Forking { json_rpc_url: None, block_number: fork_block_number })) + .await + .unwrap(); - api.mine_one().await; - api.mine_one().await; + let db = api.backend.get_db().read().await; + assert!(db.maybe_inner().unwrap().accounts().read().contains_key(&address)); + } - let next_total_difficulty = total_difficulty + difficulty; + api.anvil_reset(None).await.unwrap(); + let (cached_api, _) = spawn( + NodeConfig::test() + .with_chain_id(Some(chain_id)) + .with_eth_rpc_url(Some(origin_handle.http_endpoint())), + ) + .await; + let db = cached_api.backend.get_db().read().await; + assert!(db.maybe_inner().unwrap().accounts().read().contains_key(&address)); - let block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); - assert_eq!(block.header.total_difficulty, Some(next_total_difficulty)); - assert_eq!(block.header.difficulty, U256::ZERO); + let _ = std::fs::remove_dir_all(cache_dir); } -// #[tokio::test(flavor = "multi_thread")] -async fn test_transaction_receipt() { - let (api, _) = spawn(fork_config()).await; - - // A transaction from the forked block (14608400) - let receipt = api - .transaction_receipt( - "0xce495d665e9091613fd962351a5cbca27a992b919d6a87d542af97e2723ec1e4".parse().unwrap(), - ) +async fn test_fork_reset_block_zero_does_not_reuse_cache_for_new_rpc_url() { + let address = Address::random(); + let first_balance = U256::from(1337u64); + let second_balance = U256::from(42u64); + let timestamp = 1_000_000u64; + let chain_id = + u64::from_be_bytes(address.as_slice()[12..].try_into().unwrap()) % 1_000_000 + 1_000_000; + let cache_dir = Config::foundry_chain_cache_dir(chain_id).unwrap(); + let _ = std::fs::remove_dir_all(&cache_dir); + + async { + let first_origin = NodeConfig::test() + .with_chain_id(Some(chain_id)) + .with_genesis_timestamp(Some(timestamp)) + .with_funded_accounts([(address, first_balance)].into_iter().collect()); + let (_first_origin_api, first_origin_handle) = spawn(first_origin).await; + let second_origin = NodeConfig::test() + .with_chain_id(Some(chain_id)) + .with_genesis_timestamp(Some(timestamp)) + .with_funded_accounts([(address, second_balance)].into_iter().collect()); + let (_second_origin_api, second_origin_handle) = spawn(second_origin).await; + let fork_config = NodeConfig::test() + .with_chain_id(Some(chain_id)) + .with_eth_rpc_url(Some(first_origin_handle.http_endpoint())) + .with_fork_block_number(Some(0u64)); + let (api, handle) = spawn(fork_config).await; + let provider = handle.http_provider(); + + assert_eq!(provider.get_balance(address).await.unwrap(), first_balance); + + api.anvil_reset(Some(Forking { + json_rpc_url: Some(second_origin_handle.http_endpoint()), + block_number: Some(0u64), + })) .await .unwrap(); - assert!(receipt.is_some()); - // A transaction from a block in the future (14608401) - let receipt = api - .transaction_receipt( - "0x1a15472088a4a97f29f2f9159511dbf89954b58d9816e58a32b8dc17171dc0e8".parse().unwrap(), - ) - .await - .unwrap(); - assert!(receipt.is_none()); + assert_eq!(provider.get_balance(address).await.unwrap(), second_balance); + + let second_fork_config = NodeConfig::test() + .with_chain_id(Some(chain_id)) + .with_eth_rpc_url(Some(second_origin_handle.http_endpoint())) + .with_fork_block_number(Some(0u64)); + let (_second_fork_api, second_fork_handle) = spawn(second_fork_config).await; + assert_eq!( + second_fork_handle.http_provider().get_balance(address).await.unwrap(), + second_balance + ); + } + .await; + let _ = std::fs::remove_dir_all(cache_dir); } -// #[tokio::test(flavor = "multi_thread")] -async fn test_block_receipts() { - let (api, _) = spawn(fork_config()).await; +async fn test_fork_reset_does_not_reuse_cache_for_new_rpc_url() { + let address = Address::random(); + let first_balance = U256::from(1337u64); + let second_balance = U256::from(42u64); + let timestamp = 1_000_000u64; + let chain_id = + u64::from_be_bytes(address.as_slice()[12..].try_into().unwrap()) % 1_000_000 + 1_000_000; + let cache_dir = Config::foundry_chain_cache_dir(chain_id).unwrap(); + let _ = std::fs::remove_dir_all(&cache_dir); + + async { + let first_origin = NodeConfig::test() + .with_chain_id(Some(chain_id)) + .with_genesis_timestamp(Some(timestamp)) + .with_funded_accounts([(address, first_balance)].into_iter().collect()); + let (first_origin_api, first_origin_handle) = spawn(first_origin).await; + let second_origin = NodeConfig::test() + .with_chain_id(Some(chain_id)) + .with_genesis_timestamp(Some(timestamp)) + .with_funded_accounts([(address, second_balance)].into_iter().collect()); + let (second_origin_api, second_origin_handle) = spawn(second_origin).await; + first_origin_api.mine_one().await.unwrap(); + first_origin_api.mine_one().await.unwrap(); + second_origin_api.mine_one().await.unwrap(); + second_origin_api.mine_one().await.unwrap(); + let fork_config = NodeConfig::test() + .with_chain_id(Some(chain_id)) + .with_eth_rpc_url(Some(first_origin_handle.http_endpoint())) + .with_fork_block_number(Some(1u64)); + let (api, handle) = spawn(fork_config).await; + let provider = handle.http_provider(); + let fork_block_number = api.anvil_node_info().await.unwrap().fork_config.fork_block_number; + + assert_eq!(provider.get_balance(address).await.unwrap(), first_balance); + api.anvil_reset(Some(Forking { json_rpc_url: None, block_number: Some(2) })).await.unwrap(); + assert_eq!(provider.get_balance(address).await.unwrap(), first_balance); + + let local_balance = U256::from(9001u64); + api.anvil_set_balance(address, local_balance).await.unwrap(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let unavailable_url = format!("http://{}", listener.local_addr().unwrap()); + drop(listener); + assert!( + api.anvil_reset(Some(Forking { + json_rpc_url: Some(unavailable_url), + block_number: fork_block_number, + })) + .await + .is_err() + ); + assert_eq!(provider.get_balance(address).await.unwrap(), local_balance); + + api.anvil_reset(Some(Forking { + json_rpc_url: Some(second_origin_handle.http_endpoint()), + block_number: Some(2), + })) + .await + .unwrap(); - // Receipts from the forked block (14608400) - let receipts = api.block_receipts(BlockNumberOrTag::Number(BLOCK_NUMBER).into()).await.unwrap(); - assert!(receipts.is_some()); + assert_eq!(provider.get_balance(address).await.unwrap(), second_balance); - // Receipts from a block in the future (14608401) - let receipts = - api.block_receipts(BlockNumberOrTag::Number(BLOCK_NUMBER + 1).into()).await.unwrap(); - assert!(receipts.is_none()); + api.anvil_reset(Some(Forking { json_rpc_url: None, block_number: fork_block_number })) + .await + .unwrap(); - // Receipts from a block hash (14608400) - let hash = b256!("0x4c1c76f89cfe4eb503b09a0993346dd82865cac9d76034efc37d878c66453f0a"); - let receipts = api.block_receipts(BlockId::Hash(hash.into())).await.unwrap(); - assert!(receipts.is_some()); + assert_eq!(provider.get_balance(address).await.unwrap(), second_balance); + + let second_fork_config = NodeConfig::test() + .with_chain_id(Some(chain_id)) + .with_eth_rpc_url(Some(second_origin_handle.http_endpoint())); + let (_second_fork_api, second_fork_handle) = spawn(second_fork_config).await; + let second_fork_provider = second_fork_handle.http_provider(); + assert_eq!(second_fork_provider.get_balance(address).await.unwrap(), second_balance); + + api.anvil_reset(None).await.unwrap(); + api.anvil_set_rpc_url(first_origin_handle.http_endpoint()).await.unwrap(); + api.anvil_reset(Some(Forking { json_rpc_url: None, block_number: Some(2) })).await.unwrap(); + assert_eq!(provider.get_balance(address).await.unwrap(), first_balance); + } + .await; + let _ = std::fs::remove_dir_all(cache_dir); } #[tokio::test(flavor = "multi_thread")] -async fn can_override_fork_chain_id() { - let chain_id_override = 5u64; - let (_api, handle) = spawn( - fork_config() - .with_fork_block_number(Some(16506610u64)) - .with_chain_id(Some(chain_id_override)), - ) +async fn test_fork_reset_after_set_rpc_url_does_not_reuse_old_cache() { + let address = Address::random(); + let first_balance = U256::from(1337u64); + let second_balance = U256::from(42u64); + let timestamp = 1_000_000u64; + let chain_id = + u64::from_be_bytes(address.as_slice()[12..].try_into().unwrap()) % 1_000_000 + 1_000_000; + let cache_dir = Config::foundry_chain_cache_dir(chain_id).unwrap(); + let _ = std::fs::remove_dir_all(&cache_dir); + + async { + let first_origin = NodeConfig::test() + .with_chain_id(Some(chain_id)) + .with_genesis_timestamp(Some(timestamp)) + .with_funded_accounts([(address, first_balance)].into_iter().collect()); + let (_first_origin_api, first_origin_handle) = spawn(first_origin).await; + let second_origin = NodeConfig::test() + .with_chain_id(Some(chain_id)) + .with_genesis_timestamp(Some(timestamp)) + .with_funded_accounts([(address, second_balance)].into_iter().collect()); + let (_second_origin_api, second_origin_handle) = spawn(second_origin).await; + let fork_config = NodeConfig::test() + .with_chain_id(Some(chain_id)) + .with_eth_rpc_url(Some(first_origin_handle.http_endpoint())); + let (api, handle) = spawn(fork_config).await; + let provider = handle.http_provider(); + + assert_eq!(provider.get_balance(address).await.unwrap(), first_balance); + + api.anvil_set_rpc_url(second_origin_handle.http_endpoint()).await.unwrap(); + api.anvil_reset(Some(Forking::default())).await.unwrap(); + + assert_eq!(provider.get_balance(address).await.unwrap(), second_balance); + } .await; + let _ = std::fs::remove_dir_all(cache_dir); +} - let wallet = handle.dev_wallets().next().unwrap(); - let signer: EthereumWallet = wallet.into(); - let provider = http_provider_with_signer(&handle.http_endpoint(), signer); +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_reset_invalidates_cache_for_same_url_anvil_identity_change() { + let address = Address::random(); + let first_balance = U256::from(1337u64); + let second_balance = U256::from(42u64); + let third_balance = U256::from(9001u64); + let chain_id = + u64::from_be_bytes(address.as_slice()[12..].try_into().unwrap()) % 1_000_000 + 1_000_000; + let cache_dir = Config::foundry_chain_cache_dir(chain_id).unwrap(); + let _ = std::fs::remove_dir_all(&cache_dir); + + async { + let origin_config = NodeConfig::test() + .with_chain_id(Some(chain_id)) + .with_funded_accounts([(address, first_balance)].into_iter().collect()); + let (origin_api, origin_handle) = spawn(origin_config).await; + let origin_url = origin_handle.http_endpoint(); + let fork_config = NodeConfig::test() + .with_chain_id(Some(chain_id)) + .with_eth_rpc_url(Some(origin_url.clone())); + let (api, handle) = spawn(fork_config).await; + let provider = handle.http_provider(); + + assert_eq!(provider.get_balance(address).await.unwrap(), first_balance); + api.backend.get_db().read().await.maybe_flush_cache().unwrap(); + + let first_instance_id = origin_api.instance_id(); + origin_api.anvil_reset(None).await.unwrap(); + assert_ne!(origin_api.instance_id(), first_instance_id); + origin_api.anvil_set_balance(address, second_balance).await.unwrap(); + + api.anvil_reset(Some(Forking { + json_rpc_url: Some(origin_url.clone()), + block_number: Some(0), + })) + .await + .unwrap(); + assert_eq!(provider.get_balance(address).await.unwrap(), second_balance); - let greeter_contract = - Greeter::deploy(provider.clone(), "Hello World!".to_string()).await.unwrap(); - let greeting = greeter_contract.greet().call().await.unwrap(); + // The source identity must survive an intervening reset to memory as well. + api.anvil_reset(None).await.unwrap(); + let second_instance_id = origin_api.instance_id(); + origin_api.anvil_reset(None).await.unwrap(); + assert_ne!(origin_api.instance_id(), second_instance_id); + origin_api.anvil_set_balance(address, third_balance).await.unwrap(); - assert_eq!("Hello World!", greeting); - let greeter_contract = - Greeter::deploy(provider.clone(), "Hello World!".to_string()).await.unwrap(); - let greeting = greeter_contract.greet().call().await.unwrap(); - assert_eq!("Hello World!", greeting); + api.anvil_reset(Some(Forking { json_rpc_url: Some(origin_url), block_number: Some(0) })) + .await + .unwrap(); + assert_eq!(provider.get_balance(address).await.unwrap(), third_balance); + } + .await; + + let _ = std::fs::remove_dir_all(cache_dir); +} +#[tokio::test(flavor = "multi_thread")] +async fn test_failed_same_family_fork_reset_preserves_live_state() { + let (origin_api, origin_handle) = spawn(NodeConfig::test()).await; + origin_api.mine_one().await.unwrap(); + let origin_url = origin_handle.http_endpoint(); + let (api, handle) = spawn(NodeConfig::test().with_eth_rpc_url(Some(origin_url.clone()))).await; let provider = handle.http_provider(); - let chain_id = provider.get_chain_id().await.unwrap(); - assert_eq!(chain_id, chain_id_override); + let marker = Address::random(); + let marker_balance = U256::from(987_654u64); + let marker_nonce = 17u64; + api.anvil_set_balance(marker, marker_balance).await.unwrap(); + api.anvil_set_nonce(marker, U256::from(marker_nonce)).await.unwrap(); + api.mine_one().await.unwrap(); + + let info_before = api.anvil_node_info().await.unwrap(); + let metadata_before = api.anvil_metadata().await.unwrap(); + let block_before = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + let gas_price_before = api.gas_price(); + let base_fee_before = api.base_fee().unwrap(); + let gas_limit_before = api.gas_limit(); + + let err = api + .anvil_reset(Some(Forking { + json_rpc_url: Some(origin_url), + block_number: Some(1_000_000), + })) + .await + .unwrap_err(); + assert!(err.to_string().contains("Failed to get block"), "{err}"); + + let info_after = api.anvil_node_info().await.unwrap(); + let metadata_after = api.anvil_metadata().await.unwrap(); + let block_after = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + assert_eq!(metadata_after.instance_id, metadata_before.instance_id); + assert_eq!(metadata_after.forked_network, metadata_before.forked_network); + assert_eq!(info_after.current_block_number, info_before.current_block_number); + assert_eq!(info_after.current_block_hash, info_before.current_block_hash); + assert_eq!(info_after.current_block_timestamp, info_before.current_block_timestamp); + assert_eq!(info_after.hard_fork, info_before.hard_fork); + assert_eq!(info_after.environment, info_before.environment); + assert_eq!(info_after.fork_config, info_before.fork_config); + assert_eq!(block_after.header.hash, block_before.header.hash); + assert_eq!(api.gas_price(), gas_price_before); + assert_eq!(api.base_fee().unwrap(), base_fee_before); + assert_eq!(api.gas_limit(), gas_limit_before); + assert_eq!(provider.get_balance(marker).await.unwrap(), marker_balance); + assert_eq!(provider.get_transaction_count(marker).await.unwrap(), marker_nonce); + + api.mine_one().await.unwrap(); + assert_eq!(provider.get_block_number().await.unwrap(), block_before.header.number + 1); + assert_eq!(provider.get_balance(marker).await.unwrap(), marker_balance); + assert_eq!(provider.get_transaction_count(marker).await.unwrap(), marker_nonce); } -// #[tokio::test(flavor = "multi_thread")] -async fn test_fork_reset_moonbeam() { - crate::init_tracing(); +async fn test_fork_resets_invalidate_state_snapshots_without_mutating_new_context() { + let (_origin_api, origin_handle) = spawn(NodeConfig::test()).await; + let (api, handle) = + spawn(NodeConfig::test().with_eth_rpc_url(Some(origin_handle.http_endpoint()))).await; + let provider = handle.http_provider(); + let marker = Address::random(); + + for reset in [Some(Forking::default()), None] { + api.anvil_set_balance(marker, U256::from(123u64)).await.unwrap(); + api.mine_one().await.unwrap(); + let snapshot = api.evm_snapshot().await.unwrap(); + assert!(api.anvil_metadata().await.unwrap().snapshots.contains_key(&snapshot)); + + api.anvil_reset(reset).await.unwrap(); + assert!(api.anvil_metadata().await.unwrap().snapshots.is_empty()); + let info = api.anvil_node_info().await.unwrap(); + let balance = provider.get_balance(marker).await.unwrap(); + + assert!(!api.evm_revert(snapshot).await.unwrap()); + let info_after_revert = api.anvil_node_info().await.unwrap(); + assert_eq!(info_after_revert.current_block_number, info.current_block_number); + assert_eq!(info_after_revert.current_block_hash, info.current_block_hash); + assert_eq!(info_after_revert.current_block_timestamp, info.current_block_timestamp); + assert_eq!(provider.get_balance(marker).await.unwrap(), balance); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_reset_to_new_url_does_not_reuse_old_cache() { + let address = Address::random(); + let first_balance = U256::from(1337u64); + let second_balance = U256::from(42u64); + let timestamp = 1_000_000u64; + let chain_id = + u64::from_be_bytes(address.as_slice()[12..].try_into().unwrap()) % 1_000_000 + 1_000_000; + let cache_dir = Config::foundry_chain_cache_dir(chain_id).unwrap(); + let _ = std::fs::remove_dir_all(&cache_dir); + + async { + let first_origin = NodeConfig::test() + .with_chain_id(Some(chain_id)) + .with_genesis_timestamp(Some(timestamp)) + .with_funded_accounts([(address, first_balance)].into_iter().collect()); + let (_first_origin_api, first_origin_handle) = spawn(first_origin).await; + let second_origin = NodeConfig::test() + .with_chain_id(Some(chain_id)) + .with_genesis_timestamp(Some(timestamp)) + .with_funded_accounts([(address, second_balance)].into_iter().collect()); + let (_second_origin_api, second_origin_handle) = spawn(second_origin).await; + let fork_config = NodeConfig::test() + .with_chain_id(Some(chain_id)) + .with_eth_rpc_url(Some(first_origin_handle.http_endpoint())); + let (api, handle) = spawn(fork_config).await; + let provider = handle.http_provider(); + + assert_eq!(provider.get_balance(address).await.unwrap(), first_balance); + let instance_id = api.anvil_metadata().await.unwrap().instance_id; + api.anvil_reset(Some(Forking { + json_rpc_url: Some(second_origin_handle.http_endpoint()), + block_number: None, + })) + .await + .unwrap(); + + assert_ne!(api.anvil_metadata().await.unwrap().instance_id, instance_id); + assert_eq!(provider.get_balance(address).await.unwrap(), second_balance); + } + .await; + let _ = std::fs::remove_dir_all(cache_dir); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_reset_to_new_url_updates_source_chain_id() { + let (_first_origin_api, first_origin_handle) = + spawn(NodeConfig::test().with_chain_id(Some(1u64))).await; + let (_second_origin_api, second_origin_handle) = + spawn(NodeConfig::test().with_chain_id(Some(56u64))).await; let (api, handle) = spawn( - fork_config() - .with_eth_rpc_url(Some("https://rpc.api.moonbeam.network".to_string())) - .with_fork_block_number(None::), + NodeConfig::test() + .with_no_storage_caching(true) + .with_eth_rpc_url(Some(first_origin_handle.http_endpoint())), ) .await; let provider = handle.http_provider(); - let accounts: Vec<_> = handle.dev_wallets().collect(); - let from = accounts[0].address(); - - let tx = - TransactionRequest::default().to(Address::random()).value(U256::from(1337u64)).from(from); - let tx = WithOtherFields::new(tx); - api.anvil_impersonate_account(from).await.unwrap(); - let tx = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); - let status = tx.inner.inner.inner.receipt.status.coerce_status(); - assert!(status); - - // reset to check timestamp works after resetting + assert_eq!(provider.get_chain_id().await.unwrap(), 1); + assert_eq!(api.anvil_metadata().await.unwrap().forked_network.unwrap().chain_id, 1); api.anvil_reset(Some(Forking { - json_rpc_url: Some("https://rpc.api.moonbeam.network".to_string()), + json_rpc_url: Some(second_origin_handle.http_endpoint()), block_number: None, })) .await .unwrap(); - let tx = - TransactionRequest::default().to(Address::random()).value(U256::from(1337u64)).from(from); - let tx = WithOtherFields::new(tx); - let tx = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); - let status = tx.inner.inner.inner.receipt.status.coerce_status(); - assert!(status); -} - -// - let (api, _handle) = spawn(fork_config().with_fork_block_number(Some(18835000u64))).await; - - api.mine_one().await; - let latest = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert_eq!(provider.get_chain_id().await.unwrap(), 56); + assert_eq!(api.anvil_metadata().await.unwrap().forked_network.unwrap().chain_id, 56); - // basefee of +1 block: - assert_eq!(latest.header.base_fee_per_gas.unwrap(), 59455969592u64); + let from = handle.dev_accounts().next().unwrap(); + let send = || { + provider.send_transaction(WithOtherFields::new( + TransactionRequest::default().from(from).to(Address::random()).value(U256::ONE), + )) + }; + assert!(send().await.unwrap().get_receipt().await.unwrap().status()); - // now reset to block 18835000 -1 - api.anvil_reset(Some(Forking { json_rpc_url: None, block_number: Some(18835000u64 - 1) })) + api.anvil_reset(None).await.unwrap(); + assert_eq!(provider.get_chain_id().await.unwrap(), 31337); + assert!(send().await.unwrap().get_receipt().await.unwrap().status()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_pre_cancun_fork_with_post_cancun_hardfork() { + let target = Address::random(); + let (origin_api, origin_handle) = spawn( + NodeConfig::test() + .with_chain_id(Some(NamedChain::Mainnet as u64)) + .with_hardfork(Some(EthereumHardfork::Shanghai.into())) + .with_genesis_timestamp(EthereumHardfork::Cancun.mainnet_activation_timestamp()), + ) + .await; + origin_api.anvil_set_code(target, bytes!("600060005260206000f3")).await.unwrap(); + origin_api.mine_one().await.unwrap(); + let origin_url = origin_handle.http_endpoint(); + + for hardfork in [EthereumHardfork::Cancun, EthereumHardfork::Prague] { + let (api, handle) = spawn( + NodeConfig::test() + .with_eth_rpc_url(Some(origin_url.clone())) + .with_fork_block_number(Some(1u64)) + .with_hardfork(Some(hardfork.into())), + ) + .await; + let provider = handle.http_provider(); + let request = + || TransactionRequest { to: Some(TxKind::Call(target)), ..Default::default() }; + + assert_eq!(provider.call(request().into()).await.unwrap(), Bytes::from(vec![0; 32])); + assert_eq!( + api.backend + .evm_env() + .read() + .block_env + .blob_excess_gas_and_price + .as_ref() + .map(|blob| blob.excess_blob_gas), + Some(0) + ); + + api.anvil_reset(Some(Forking { + json_rpc_url: Some(origin_url.clone()), + block_number: Some(1), + })) .await .unwrap(); + assert_eq!(provider.call(request().into()).await.unwrap(), Bytes::from(vec![0; 32])); + } - api.mine_one().await; - let latest = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + let partial_header_url = + spawn_rpc_proxy_with_blob_header_fields(origin_url, Some(0), None).await; + let partial_header_url = + spawn_rpc_proxy_rejecting_method_after(partial_header_url, "anvil_nodeInfo", 0).await; + let (api, _) = spawn( + NodeConfig::test() + .with_eth_rpc_url(Some(partial_header_url)) + .with_fork_block_number(Some(1u64)) + .with_hardfork(Some(EthereumHardfork::Prague.into())), + ) + .await; + assert!(api.backend.evm_env().read().block_env.blob_excess_gas_and_price.is_none()); +} - // basefee of the forked block: - assert_eq!(latest.header.base_fee_per_gas.unwrap(), 59017001138); +#[cfg(feature = "optimism")] +#[tokio::test(flavor = "multi_thread")] +async fn test_optimism_fork_preserves_ethereum_source_blob_header_fallback() { + const SHANGHAI_ERA_TIMESTAMP: u64 = 1_705_000_000; + + let target = Address::random(); + let (origin_api, origin) = spawn( + NodeConfig::test() + .with_chain_id(Some(NamedChain::Mainnet as u64)) + .with_hardfork(Some(EthereumHardfork::Shanghai.into())) + .with_genesis_timestamp(Some(SHANGHAI_ERA_TIMESTAMP)), + ) + .await; + origin_api.anvil_set_code(target, bytes!("600060005260206000f3")).await.unwrap(); + origin_api.mine_one().await.unwrap(); + let origin_url = + spawn_rpc_proxy_rejecting_method_after(origin.http_endpoint(), "anvil_nodeInfo", 0).await; + let (api, fork) = spawn( + NodeConfig::test() + .with_optimism() + .with_eth_rpc_url(Some(origin_url)) + .with_fork_block_number(Some(1u64)), + ) + .await; + + assert_eq!( + api.backend + .evm_env() + .read() + .block_env + .blob_excess_gas_and_price + .as_ref() + .map(|blob| blob.excess_blob_gas), + Some(0) + ); + let request = TransactionRequest { to: Some(TxKind::Call(target)), ..Default::default() }; + assert_eq!(fork.http_provider().call(request.into()).await.unwrap(), Bytes::from(vec![0; 32])); } -// #[tokio::test(flavor = "multi_thread")] -async fn test_arbitrum_fork_dev_balance() { +async fn test_unknown_schedule_fork_with_post_cancun_hardfork() { + let target = Address::random(); + let (origin_api, origin_handle) = spawn( + NodeConfig::test() + .with_chain_id(Some(NamedChain::BinanceSmartChain as u64)) + .with_hardfork(Some(EthereumHardfork::Shanghai.into())) + .with_genesis_timestamp(EthereumHardfork::Cancun.mainnet_activation_timestamp()), + ) + .await; + origin_api.anvil_set_code(target, bytes!("600060005260206000f3")).await.unwrap(); + origin_api.mine_one().await.unwrap(); + let origin_url = + spawn_rpc_proxy_with_blob_header_fields(origin_handle.http_endpoint(), None, None).await; + let origin_url = spawn_rpc_proxy_rejecting_method_after(origin_url, "anvil_nodeInfo", 0).await; let (api, handle) = spawn( - fork_config() - .with_fork_block_number(None::) - .with_eth_rpc_url(Some(next_rpc_endpoint(NamedChain::Arbitrum))), + NodeConfig::test() + .with_eth_rpc_url(Some(origin_url.clone())) + .with_fork_block_number(Some(1u64)) + .with_hardfork(Some(EthereumHardfork::Prague.into())), ) .await; - let accounts: Vec<_> = handle.dev_wallets().collect(); - for acc in accounts { - let balance = api.balance(acc.address(), Some(Default::default())).await.unwrap(); - assert_eq!(balance, U256::from(100000000000000000000u128)); - } + assert_eq!( + api.backend + .evm_env() + .read() + .block_env + .blob_excess_gas_and_price + .as_ref() + .map(|blob| blob.excess_blob_gas), + Some(0) + ); + assert_eq!( + handle + .http_provider() + .call( + TransactionRequest { to: Some(TxKind::Call(target)), ..Default::default() }.into() + ) + .await + .unwrap(), + Bytes::from(vec![0; 32]) + ); + + let (api, _) = spawn( + NodeConfig::test().with_eth_rpc_url(Some(origin_url)).with_fork_block_number(Some(1u64)), + ) + .await; + assert!(api.backend.evm_env().read().block_env.blob_excess_gas_and_price.is_none()); } -// +async fn spawn_rpc_proxy_with_blob_header_fields( + endpoint: String, + blob_gas_used: Option, + excess_blob_gas: Option, +) -> String { + let client = reqwest::Client::new(); + let router = Router::new().route( + "/", + post(move |Json(request): Json| { + let client = client.clone(); + let endpoint = endpoint.clone(); + async move { + let mut response = client + .post(endpoint) + .json(&request) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + if matches!( + request.get("method").and_then(Value::as_str), + Some("eth_getBlockByHash" | "eth_getBlockByNumber") + ) && let Some(block) = response.get_mut("result").and_then(Value::as_object_mut) + { + for (field, value) in + [("blobGasUsed", blob_gas_used), ("excessBlobGas", excess_blob_gas)] + { + if let Some(value) = value { + block.insert(field.to_string(), Value::String(format!("0x{value:x}"))); + } else { + block.remove(field); + } + } + } + Json(response) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + format!("http://{address}") +} + +#[cfg(feature = "optimism")] #[tokio::test(flavor = "multi_thread")] -async fn test_arb_fork_mining() { - let fork_block_number = 266137031u64; - let fork_rpc = next_rpc_endpoint(NamedChain::Arbitrum); - let (api, _handle) = spawn( - fork_config() - .with_fork_block_number(Some(fork_block_number)) - .with_eth_rpc_url(Some(fork_rpc)), +async fn test_optimism_fork_keeps_excess_blob_gas_zero_after_mining() { + // Base Jovian stores the DA footprint in `blobGasUsed`, but OP Stack clients keep + // `excessBlobGas` at zero because the chain does not support EIP-4844 blobs. This captured + // footprint is from Base mainnet block 50_729_760. + let (_, origin) = spawn( + NodeConfig::test() + .with_chain_id(Some(NamedChain::Base as u64)) + .with_networks(NetworkConfigs::with_optimism()) + .with_hardfork(Some(OpHardfork::Jovian.into())), + ) + .await; + let fork_url = + spawn_rpc_proxy_with_blob_header_fields(origin.http_endpoint(), Some(0x2e_b434), Some(0)) + .await; + let (api, _) = spawn( + NodeConfig::test().with_eth_rpc_url(Some(fork_url)).with_fork_block_number(Some(0u64)), ) .await; - let init_blk_num = api.block_number().unwrap().to::(); + api.mine_one().await.unwrap(); + let block = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); - // Mine one - api.mine_one().await; - let mined_blk_num = api.block_number().unwrap().to::(); + assert_eq!(block.header.excess_blob_gas, Some(0)); + let next_blob_fee = api.excess_blob_gas_and_price().unwrap().unwrap(); + assert_eq!(next_blob_fee.excess_blob_gas, 0); + assert_eq!(next_blob_fee.blob_gasprice, 1); +} - assert_eq!(mined_blk_num, init_blk_num + 1); +#[tokio::test(flavor = "multi_thread")] +async fn test_arbitrum_forks_accept_nitro_headers_without_blob_fields() { + // Nitro omits Ethereum's EIP-4844 header fields, including on post-Cancun chains. Hide Anvil + // metadata so these deterministic local origins have the same observable shape as public RPCs. + for chain in [NamedChain::Arbitrum, NamedChain::Robinhood] { + let target = Address::random(); + let (origin_api, origin) = spawn( + NodeConfig::test() + .with_chain_id(Some(chain as u64)) + .with_hardfork(Some(EthereumHardfork::Prague.into())) + .with_genesis_timestamp(EthereumHardfork::Prague.arbitrum_activation_timestamp()), + ) + .await; + origin_api.anvil_set_code(target, bytes!("600060005260206000f3")).await.unwrap(); + let fork_url = + spawn_rpc_proxy_with_blob_header_fields(origin.http_endpoint(), None, None).await; + let fork_url = spawn_rpc_proxy_rejecting_method_after(fork_url, "anvil_nodeInfo", 0).await; + + let (api, handle) = spawn( + NodeConfig::test() + .with_no_storage_caching(true) + .with_eth_rpc_url(Some(fork_url)) + .with_fork_block_number(Some(0u64)), + ) + .await; + + assert!(api.backend.spec_id() >= SpecId::CANCUN); + assert_eq!( + api.backend + .evm_env() + .read() + .block_env + .blob_excess_gas_and_price + .as_ref() + .map(|blob| blob.excess_blob_gas), + Some(0), + "{chain}" + ); + let request = TransactionRequest { to: Some(TxKind::Call(target)), ..Default::default() }; + assert_eq!( + handle.http_provider().call(request.into()).await.unwrap(), + Bytes::from(vec![0; 32]), + "{chain}" + ); + } } -// #[tokio::test(flavor = "multi_thread")] -async fn test_arbitrum_fork_block_number() { - // fork to get initial block for test - let (_, handle) = spawn( - fork_config() - .with_fork_block_number(None::) - .with_eth_rpc_url(Some(next_rpc_endpoint(NamedChain::Arbitrum))), +async fn test_polygon_fork_missing_blob_fields_is_chain_scoped_across_reset() { + // Polygon's Bor headers omit the EIP-4844 fields even though Anvil executes the fork with a + // post-Cancun spec. Local origins provide deterministic state while the proxies reproduce that + // header shape and hide Anvil metadata, matching an external endpoint. + let token = address!("0d500B1d8E8eF31E21C99d1Db9A6444d3ADf1270"); + let return_zero = bytes!("600060005260206000f3"); + let (polygon_api, polygon_origin) = spawn( + NodeConfig::test() + .with_chain_id(Some(NamedChain::Polygon as u64)) + .with_hardfork(Some(EthereumHardfork::Shanghai.into())) + .with_genesis_timestamp(Some(1_750_000_000u64)), ) .await; - let provider = handle.http_provider(); - let initial_block_number = provider.get_block_number().await.unwrap(); + polygon_api.anvil_set_code(token, return_zero.clone()).await.unwrap(); + let polygon_url = + spawn_rpc_proxy_with_blob_header_fields(polygon_origin.http_endpoint(), None, None).await; + let polygon_url = + spawn_rpc_proxy_rejecting_method_after(polygon_url, "anvil_nodeInfo", 0).await; - // fork again at block number returned by `eth_blockNumber` - // if wrong block number returned (e.g. L1) then fork will fail with error code -32000: missing - // trie node - let (api, _) = spawn( - fork_config() - .with_fork_block_number(Some(initial_block_number)) - .with_eth_rpc_url(Some(next_rpc_endpoint(NamedChain::Arbitrum))), + let (pre_cancun_api, pre_cancun_origin) = spawn( + NodeConfig::test() + .with_chain_id(Some(NamedChain::Arbitrum as u64)) + .with_hardfork(Some(EthereumHardfork::Shanghai.into())) + .with_genesis_timestamp(EthereumHardfork::Shanghai.arbitrum_activation_timestamp()), ) .await; - let block_number = api.block_number().unwrap().to::(); - assert_eq!(block_number, initial_block_number); + pre_cancun_api.anvil_set_code(token, return_zero.clone()).await.unwrap(); + let pre_cancun_url = + spawn_rpc_proxy_with_blob_header_fields(pre_cancun_origin.http_endpoint(), None, None) + .await; + let pre_cancun_url = + spawn_rpc_proxy_rejecting_method_after(pre_cancun_url, "anvil_nodeInfo", 0).await; + + // A canonical Ethereum endpoint that drops required post-Cancun fields must remain invalid; + // the Polygon compatibility fallback must not hide that upstream error. + let (ethereum_api, ethereum_origin) = spawn( + NodeConfig::test() + .with_chain_id(Some(NamedChain::Mainnet as u64)) + .with_hardfork(Some(EthereumHardfork::Cancun.into())) + .with_genesis_timestamp(EthereumHardfork::Cancun.mainnet_activation_timestamp()), + ) + .await; + ethereum_api.anvil_set_code(token, return_zero).await.unwrap(); + let ethereum_url = + spawn_rpc_proxy_with_blob_header_fields(ethereum_origin.http_endpoint(), None, None).await; + let ethereum_url = + spawn_rpc_proxy_rejecting_method_after(ethereum_url, "anvil_nodeInfo", 0).await; - // take snapshot at initial block number - let snapshot_state = api.evm_snapshot().await.unwrap(); + let (api, handle) = spawn( + NodeConfig::test() + .with_no_storage_caching(true) + .with_eth_rpc_url(Some(polygon_url.clone())) + .with_fork_block_number(Some(0u64)), + ) + .await; + let provider = handle.http_provider(); + // This is the WMATIC `balanceOf` call from the Balancer failure report. + let call = || { + provider + .call( + TransactionRequest { + to: Some(TxKind::Call(token)), + input: TransactionInput::new(bytes!( + "70a08231000000000000000000000000625ac8caddc5dfb99c98176cb6e79d55c7c14e63" + )), + ..Default::default() + } + .into(), + ) + .block(BlockId::latest()) + }; + + assert!(api.backend.spec_id() >= SpecId::CANCUN); + assert_eq!(call().await.unwrap(), Bytes::from(vec![0; 32])); + assert_eq!( + api.backend + .evm_env() + .read() + .block_env + .blob_excess_gas_and_price + .as_ref() + .map(|blob| blob.excess_blob_gas), + Some(0) + ); - // mine new block and check block number returned by `eth_blockNumber` - api.mine_one().await; - let block_number = api.block_number().unwrap().to::(); - assert_eq!(block_number, initial_block_number + 1); + api.anvil_reset(Some(Forking { json_rpc_url: Some(pre_cancun_url), block_number: Some(0) })) + .await + .unwrap(); + assert!(api.backend.spec_id() < SpecId::CANCUN); + assert!(api.backend.evm_env().read().block_env.blob_excess_gas_and_price.is_none()); + assert_eq!(call().await.unwrap(), Bytes::from(vec![0; 32])); - // test block by number API call returns proper block number and `l1BlockNumber` is set - let block_by_number = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); - assert_eq!(block_by_number.header.number, initial_block_number + 1); - assert!(block_by_number.other.get("l1BlockNumber").is_some()); + api.anvil_reset(Some(Forking { json_rpc_url: Some(ethereum_url), block_number: Some(0) })) + .await + .unwrap(); + assert!(api.backend.spec_id() >= SpecId::CANCUN); + assert!(api.backend.evm_env().read().block_env.blob_excess_gas_and_price.is_none()); + let err = call().await.unwrap_err(); + assert!(err.to_string().contains("Excess blob gas not set"), "{err:?}"); - // revert to recorded snapshot and check block number - assert!(api.evm_revert(snapshot_state).await.unwrap()); - let block_number = api.block_number().unwrap().to::(); - assert_eq!(block_number, initial_block_number); + api.anvil_reset(Some(Forking { json_rpc_url: Some(polygon_url), block_number: Some(0) })) + .await + .unwrap(); + assert!(api.backend.spec_id() >= SpecId::CANCUN); + assert_eq!(call().await.unwrap(), Bytes::from(vec![0; 32])); + assert_eq!( + api.backend + .evm_env() + .read() + .block_env + .blob_excess_gas_and_price + .as_ref() + .map(|blob| blob.excess_blob_gas), + Some(0) + ); +} - // reset fork to different block number and compare with block returned by `eth_blockNumber` +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_reset_updates_bpo_blob_schedule_in_both_directions() { + let excess_blob_gas = 20_000_000u64; + let bpo1_params = BlobParams::bpo1(); + let bpo2_params = BlobParams::bpo2(); + let origin = |hardfork: EthereumHardfork, params: BlobParams| { + let mut config = NodeConfig::test() + .with_chain_id(Some(1u64)) + .with_hardfork(Some(hardfork.into())) + .with_genesis_timestamp(hardfork.mainnet_activation_timestamp()); + config.blob_excess_gas_and_price = + Some(BlobExcessGasAndPrice::new(excess_blob_gas, params.update_fraction as u64)); + config + }; + let (bpo1_api, bpo1_handle) = spawn(origin(EthereumHardfork::Bpo1, bpo1_params)).await; + let (bpo2_api, bpo2_handle) = spawn(origin(EthereumHardfork::Bpo2, bpo2_params)).await; + bpo1_api.mine_one().await.unwrap(); + bpo2_api.mine_one().await.unwrap(); + let (api, _) = spawn( + NodeConfig::test() + .with_no_storage_caching(true) + .with_eth_rpc_url(Some(bpo1_handle.http_endpoint())) + .with_fork_block_number(Some(1u64)), + ) + .await; + + let assert_blob_schedule = |expected: BlobParams| { + assert_eq!(api.backend.blob_params(), expected); + assert_eq!(api.config().unwrap().current.blob_schedule, expected); + let fees = api.excess_blob_gas_and_price().unwrap().unwrap(); + assert!(fees.excess_blob_gas > 0); + assert_eq!(fees.blob_gasprice, expected.calc_blob_fee(fees.excess_blob_gas)); + }; + + assert_blob_schedule(bpo1_params); api.anvil_reset(Some(Forking { - json_rpc_url: Some(next_rpc_endpoint(NamedChain::Arbitrum)), - block_number: Some(initial_block_number - 2), + json_rpc_url: Some(bpo2_handle.http_endpoint()), + block_number: Some(1), })) .await .unwrap(); - let block_number = api.block_number().unwrap().to::(); - assert_eq!(block_number, initial_block_number - 2); + assert_blob_schedule(bpo2_params); + api.mine_one().await.unwrap(); + assert_blob_schedule(bpo2_params); + + api.anvil_reset(Some(Forking { + json_rpc_url: Some(bpo1_handle.http_endpoint()), + block_number: Some(1), + })) + .await + .unwrap(); + assert_blob_schedule(bpo1_params); + api.mine_one().await.unwrap(); + assert_blob_schedule(bpo1_params); } #[tokio::test(flavor = "multi_thread")] -async fn test_base_fork_gas_limit() { - // fork to get initial block for test - let (api, handle) = spawn( - fork_config() - .with_fork_block_number(None::) - .with_eth_rpc_url(Some(next_rpc_endpoint(NamedChain::Base))), - ) - .await; +async fn test_memory_reset_restores_selected_blob_schedule_in_both_directions() { + let excess_blob_gas = 20_000_000u64; + let configured = |hardfork: EthereumHardfork| { + NodeConfig::test() + .with_chain_id(Some(1u64)) + .with_hardfork(Some(hardfork.into())) + .with_genesis(Some(Genesis { + timestamp: hardfork.mainnet_activation_timestamp().unwrap(), + excess_blob_gas: Some(excess_blob_gas), + ..Default::default() + })) + }; + + let (cancun_origin_api, cancun_origin) = spawn(configured(EthereumHardfork::Cancun)).await; + let (bpo2_origin_api, bpo2_origin) = spawn(configured(EthereumHardfork::Bpo2)).await; + cancun_origin_api.mine_one().await.unwrap(); + bpo2_origin_api.mine_one().await.unwrap(); + + let assert_blob_schedule = |api: &EthApi, expected: BlobParams| { + assert_eq!(api.backend.blob_params(), expected); + let fees = api.excess_blob_gas_and_price().unwrap().unwrap(); + assert!(fees.excess_blob_gas > 0); + assert_eq!(fees.blob_gasprice, expected.calc_blob_fee(fees.excess_blob_gas)); + }; + + let (cancun_api, _) = spawn(configured(EthereumHardfork::Cancun)).await; + assert_blob_schedule(&cancun_api, BlobParams::cancun()); + cancun_api + .anvil_reset(Some(Forking { + json_rpc_url: Some(bpo2_origin.http_endpoint()), + block_number: Some(1), + })) + .await + .unwrap(); + assert_blob_schedule(&cancun_api, BlobParams::bpo2()); + cancun_api.anvil_reset(None).await.unwrap(); + assert_blob_schedule(&cancun_api, BlobParams::cancun()); + + let (bpo2_api, _) = spawn(configured(EthereumHardfork::Bpo2)).await; + assert_blob_schedule(&bpo2_api, BlobParams::bpo2()); + bpo2_api + .anvil_reset(Some(Forking { + json_rpc_url: Some(cancun_origin.http_endpoint()), + block_number: Some(1), + })) + .await + .unwrap(); + assert_blob_schedule(&bpo2_api, BlobParams::cancun()); + bpo2_api.anvil_reset(None).await.unwrap(); + assert_blob_schedule(&bpo2_api, BlobParams::bpo2()); +} +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_get_account() { + let (_api, handle) = spawn(fork_config()).await; let provider = handle.http_provider(); - let block = - provider.get_block(BlockId::Number(BlockNumberOrTag::Latest)).await.unwrap().unwrap(); - assert!(api.gas_limit() >= uint!(96_000_000_U256)); - assert!(block.header.gas_limit >= 96_000_000_u64); -} + let accounts = handle.dev_accounts().collect::>(); -// -#[tokio::test(flavor = "multi_thread")] -async fn test_fork_execution_reverted() { - let target = 16681681u64; - let (api, _handle) = spawn(fork_config().with_fork_block_number(Some(target + 1))).await; + let alice = accounts[0]; + let bob = accounts[1]; - let resp = api - .call( - WithOtherFields::new(TransactionRequest { - to: Some(TxKind::from(address!("0xFd6CC4F251eaE6d02f9F7B41D1e80464D3d2F377"))), - input: TransactionInput::new(bytes!("8f283b3c")), - ..Default::default() - }), - Some(target.into()), - EvmOverrides::default(), - ) - .await; + let init_block = provider.get_block_number().await.unwrap(); + let alice_bal = provider.get_balance(alice).await.unwrap(); + let alice_nonce = provider.get_transaction_count(alice).await.unwrap(); + let alice_acc_init = provider.get_account(alice).await.unwrap(); - assert!(resp.is_err()); - let err = resp.unwrap_err(); - assert!(err.to_string().contains("execution reverted")); -} + assert_eq!(alice_acc_init.balance, alice_bal); + assert_eq!(alice_acc_init.nonce, alice_nonce); -// -#[tokio::test(flavor = "multi_thread")] -#[ignore] -async fn test_immutable_fork_transaction_hash() { - use std::str::FromStr; + let tx = TransactionRequest::default().from(alice).to(bob).value(U256::from(142)); - // Fork to a block with a specific transaction - // - let fork_tx_hash = - TxHash::from_str("2ac736ce725d628ef20569a1bb501726b42b33f9d171f60b92b69de3ce705845") - .unwrap(); - let (api, _) = spawn( - fork_config() - .with_blocktime(Some(Duration::from_millis(500))) - .with_fork_transaction_hash(Some(fork_tx_hash)) - .with_eth_rpc_url(Some("https://immutable-zkevm.drpc.org".to_string())), - ) - .await; + let tx = WithOtherFields::new(tx); + let receipt = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); - let fork_block_number = 21824325; + assert!(receipt.status()); + assert_eq!(init_block + 1, receipt.block_number.unwrap()); - // Make sure the fork starts from previous block - let mut block_number = api.block_number().unwrap().to::(); - assert_eq!(block_number, fork_block_number - 1); + let alice_acc = provider.get_account(alice).await.unwrap(); - // Wait for fork to pass the target block - while block_number < fork_block_number { - sleep(Duration::from_millis(250)); - block_number = api.block_number().unwrap().to::(); - } + assert_eq!( + alice_acc.balance, + alice_bal + - (U256::from(142) + + U256::from(receipt.gas_used as u128 * receipt.effective_gas_price)), + ); + assert_eq!(alice_acc.nonce, alice_nonce + 1); + + let alice_acc_prev_block = provider.get_account(alice).number(init_block).await.unwrap(); + + assert_eq!(alice_acc_init, alice_acc_prev_block); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_get_account_info() { + let (api, handle) = spawn(fork_config()).await; + let provider = handle.http_provider(); - let block = api - .block_by_number(BlockNumberOrTag::Number(fork_block_number - 1)) + let info = provider + .get_account_info(address!("0x19e53a7397bE5AA7908fE9eA991B03710bdC74Fd")) + // predates fork + .number(BLOCK_NUMBER - 1) .await - .unwrap() .unwrap(); - assert_eq!(block.transactions.len(), 6); - let block = api - .block_by_number_full(BlockNumberOrTag::Number(fork_block_number)) + assert_eq!( + info, + AccountInfo { + balance: U256::from(14353753764795095694u64), + nonce: 6689, + code: Default::default(), + } + ); + + // Check account info at block number, see https://github.com/foundry-rs/foundry/issues/12072 + let info = provider + .get_account_info(address!("0x19e53a7397bE5AA7908fE9eA991B03710bdC74Fd")) + // predates fork + .number(BLOCK_NUMBER) .await - .unwrap() .unwrap(); - assert!(!block.transactions.is_empty()); + assert_eq!( + info, + AccountInfo { + balance: U256::from(14352720829244098514u64), + nonce: 6690, + code: Default::default(), + } + ); + + let snapshot = api.evm_snapshot().await.unwrap(); + api.anvil_set_nonce(address!("0x19e53a7397bE5AA7908fE9eA991B03710bdC74Fd"), U256::from(123)) + .await + .unwrap(); + let info = provider + .get_account_info(address!("0x19e53a7397bE5AA7908fE9eA991B03710bdC74Fd")) + .number(BLOCK_NUMBER) + .await + .unwrap(); + assert_eq!(info.nonce, 6690); + assert!(api.evm_revert(snapshot).await.unwrap()); + + // Mine and check account info at new block number, see https://github.com/foundry-rs/foundry/issues/12148 + api.evm_mine(None).await.unwrap(); + let info = provider + .get_account_info(address!("0x19e53a7397bE5AA7908fE9eA991B03710bdC74Fd")) + // predates fork + .number(BLOCK_NUMBER + 1) + .await + .unwrap(); + assert_eq!( + info, + AccountInfo { + balance: U256::from(14352720829244098514u64), + nonce: 6690, + code: Default::default(), + } + ); +} + +fn assert_hardfork_config( + config: &EthConfig, + expected_blob_params: &BlobParams, + expected_precompiles: &[Address], + expected_system_contracts: &BTreeMap, +) { + assert!(config.next.is_none()); + assert!(config.last.is_none()); + + let current = &config.current; + + assert_eq!(current.activation_time, 0); + assert_eq!(current.chain_id, 31337); + assert_eq!(current.fork_id, Bytes::from(vec![0, 0, 0, 0])); + + assert_eq!(¤t.blob_schedule, expected_blob_params); + + assert_eq!( + current.precompiles.values().copied().collect::>(), + expected_precompiles.iter().copied().collect::>(), + ); - // Validate the transactions preceding the target transaction exist - let expected_transactions = [ - TxHash::from_str("c900784c993221ba192c53a3ff9996f6af83a951100ceb93e750f7ef86bd43d5") - .unwrap(), - TxHash::from_str("f86f001bbdf69f8f64ff8a4a5fc3e684cf3a7706f204eba8439752f6f67cd2c4") - .unwrap(), - fork_tx_hash, + assert_eq!(current.system_contracts, *expected_system_contracts); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_config_with_cancun_hardfork() { + let (api, _handle) = + spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Cancun.into()))).await; + + let config = api.config().unwrap(); + + let expected_blob_params = BlobParams { + target_blob_count: 3, + max_blob_count: 6, + update_fraction: 3338477, + min_blob_fee: 1, + max_blobs_per_tx: 6, + blob_base_cost: 0, + }; + + // <= Cancun precompiles + let expected_precompiles = [ + Address::with_last_byte(1), + Address::with_last_byte(2), + Address::with_last_byte(3), + Address::with_last_byte(4), + Address::with_last_byte(5), + Address::with_last_byte(6), + Address::with_last_byte(7), + Address::with_last_byte(8), + Address::with_last_byte(9), + Address::with_last_byte(0x0a), ]; - for expected in [ - (expected_transactions[0], address!("0x0a02a416f87a13626dda0ad386859497565222aa")), - (expected_transactions[1], address!("0x0a02a416f87a13626dda0ad386859497565222aa")), - (expected_transactions[2], address!("0x4f07d669d76ed9a17799fc4c04c4005196240940")), - ] { - let tx = api.backend.mined_transaction_by_hash(expected.0).unwrap(); - assert_eq!(tx.inner.inner.signer(), expected.1); - } - // Validate the order of transactions in the new block - for expected in [ - (expected_transactions[0], 0), - (expected_transactions[1], 1), - (expected_transactions[2], 2), - ] { - let tx = api - .backend - .mined_block_by_number(BlockNumberOrTag::Number(fork_block_number)) - .map(|b| b.header.hash) - .and_then(|hash| { - api.backend.mined_transaction_by_block_hash_and_index(hash, expected.1.into()) - }) - .unwrap(); - assert_eq!(tx.tx_hash().to_string(), expected.0.to_string()); - } + let expected_system_contracts = BTreeMap::from([( + SystemContract::BeaconRoots, + address!("000f3df6d732807ef1319fb7b8bb8522d0beac02"), + )]); + + assert_hardfork_config( + &config, + &expected_blob_params, + &expected_precompiles, + &expected_system_contracts, + ); } -// #[tokio::test(flavor = "multi_thread")] -async fn test_fork_query_at_fork_block() { - let (api, handle) = spawn(fork_config()).await; - let provider = handle.http_provider(); - let info = api.anvil_node_info().await.unwrap(); - let number = info.fork_config.fork_block_number.unwrap(); - assert_eq!(number, BLOCK_NUMBER); +async fn test_config_with_prague_hardfork_with_celo() { + let (api, _handle) = spawn( + NodeConfig::test() + .with_hardfork(Some(EthereumHardfork::Prague.into())) + .with_networks(NetworkConfigs::with_celo()), + ) + .await; - let address = Address::random(); + let config = api.config().unwrap(); + + let expected_blob_params = BlobParams { + target_blob_count: 6, + max_blob_count: 9, + update_fraction: 5007716, + min_blob_fee: 1, + max_blobs_per_tx: 9, + blob_base_cost: 0, + }; + + // <= Prague + Celo precompiles + let expected_precompiles = [ + Address::with_last_byte(1), + Address::with_last_byte(2), + Address::with_last_byte(3), + Address::with_last_byte(4), + Address::with_last_byte(5), + Address::with_last_byte(6), + Address::with_last_byte(7), + Address::with_last_byte(8), + Address::with_last_byte(9), + Address::with_last_byte(0x0a), + Address::with_last_byte(0x0b), + Address::with_last_byte(0x0c), + Address::with_last_byte(0x0d), + Address::with_last_byte(0x0e), + Address::with_last_byte(0x0f), + Address::with_last_byte(0x10), + Address::with_last_byte(0x11), + Address::with_last_byte(0xfd), // `celo transfer` + ]; - let balance = provider.get_balance(address).await.unwrap(); - api.evm_mine(None).await.unwrap(); - api.anvil_set_balance(address, balance + U256::from(1)).await.unwrap(); + let expected_system_contracts = BTreeMap::from([ + (SystemContract::BeaconRoots, address!("000f3df6d732807ef1319fb7b8bb8522d0beac02")), + ( + SystemContract::ConsolidationRequestPredeploy, + address!("0000bbddc7ce488642fb579f8b00f3a590007251"), + ), + (SystemContract::DepositContract, address!("00000000219ab540356cbb839cbe05303d7705fa")), + (SystemContract::HistoryStorage, address!("0000f90827f1c53a10cb7a02335b175320002935")), + ( + SystemContract::WithdrawalRequestPredeploy, + address!("00000961ef480eb55e80d19ad83579a64c007002"), + ), + ]); + + assert_hardfork_config( + &config, + &expected_blob_params, + &expected_precompiles, + &expected_system_contracts, + ); +} - let balance_before = - provider.get_balance(address).block_id(BlockId::number(number)).await.unwrap(); +#[tokio::test(flavor = "multi_thread")] +async fn test_config_with_osaka_hardfork() { + let (api, _handle) = + spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Osaka.into()))).await; + + let config = api.config().unwrap(); + + let expected_blob_params = BlobParams { + target_blob_count: 6, + max_blob_count: 9, + update_fraction: 5007716, + min_blob_fee: 1, + max_blobs_per_tx: 6, + blob_base_cost: 8192, + }; + + // <= Osaka precompiles + let expected_precompiles = [ + Address::with_last_byte(1), + Address::with_last_byte(2), + Address::with_last_byte(3), + Address::with_last_byte(4), + Address::with_last_byte(5), + Address::with_last_byte(6), + Address::with_last_byte(7), + Address::with_last_byte(8), + Address::with_last_byte(9), + Address::with_last_byte(0x0a), + Address::with_last_byte(0x0b), + Address::with_last_byte(0x0c), + Address::with_last_byte(0x0d), + Address::with_last_byte(0x0e), + Address::with_last_byte(0x0f), + Address::with_last_byte(0x10), + Address::with_last_byte(0x11), + address!("0000000000000000000000000000000000000100"), + ]; - assert_eq!(balance_before, balance); + let expected_system_contracts = BTreeMap::from([ + (SystemContract::BeaconRoots, address!("000f3df6d732807ef1319fb7b8bb8522d0beac02")), + ( + SystemContract::ConsolidationRequestPredeploy, + address!("0000bbddc7ce488642fb579f8b00f3a590007251"), + ), + (SystemContract::DepositContract, address!("00000000219ab540356cbb839cbe05303d7705fa")), + (SystemContract::HistoryStorage, address!("0000f90827f1c53a10cb7a02335b175320002935")), + ( + SystemContract::WithdrawalRequestPredeploy, + address!("00000961ef480eb55e80d19ad83579a64c007002"), + ), + ]); + + assert_hardfork_config( + &config, + &expected_blob_params, + &expected_precompiles, + &expected_system_contracts, + ); } -// #[tokio::test(flavor = "multi_thread")] -async fn test_reset_dev_account_nonce() { - let config: NodeConfig = fork_config(); - let address = config.genesis_accounts[0].address(); - let (api, handle) = spawn(config).await; - let provider = handle.http_provider(); - let info = api.anvil_node_info().await.unwrap(); - let number = info.fork_config.fork_block_number.unwrap(); - assert_eq!(number, BLOCK_NUMBER); +async fn test_config_with_osaka_hardfork_with_precompile_factory() { + #[derive(Debug)] + struct CustomPrecompileFactory; + + impl PrecompileFactory for CustomPrecompileFactory { + fn precompiles(&self) -> Vec<(Address, alloy_evm::precompiles::DynPrecompile)> { + vec![( + Address::with_last_byte(0x71), + alloy_evm::precompiles::DynPrecompile::from( + |input: alloy_evm::precompiles::PrecompileInput<'_>| { + Ok(revm::precompile::PrecompileOutput { + bytes: Bytes::copy_from_slice(input.data), + gas_used: 0, + gas_refunded: 0, + status: PrecompileStatus::Success, + state_gas_used: 0, + state_gas_spilled: 0, + reservoir: input.reservoir, + }) + }, + ), + )] + } + } - let nonce_before = provider.get_transaction_count(address).await.unwrap(); + let (api, _handle) = spawn( + NodeConfig::test() + .with_hardfork(Some(EthereumHardfork::Osaka.into())) + .with_precompile_factory(CustomPrecompileFactory), + ) + .await; - // Reset to older block with other nonce - api.anvil_reset(Some(Forking { - json_rpc_url: None, - block_number: Some(BLOCK_NUMBER - 1_000_000), - })) + let config = api.config().unwrap(); + + let expected_blob_params = BlobParams { + target_blob_count: 6, + max_blob_count: 9, + update_fraction: 5007716, + min_blob_fee: 1, + max_blobs_per_tx: 6, + blob_base_cost: 8192, + }; + + // <= Osaka precompiles + custom precompile + let expected_precompiles = [ + Address::with_last_byte(1), + Address::with_last_byte(2), + Address::with_last_byte(3), + Address::with_last_byte(4), + Address::with_last_byte(5), + Address::with_last_byte(6), + Address::with_last_byte(7), + Address::with_last_byte(8), + Address::with_last_byte(9), + Address::with_last_byte(0x0a), + Address::with_last_byte(0x0b), + Address::with_last_byte(0x0c), + Address::with_last_byte(0x0d), + Address::with_last_byte(0x0e), + Address::with_last_byte(0x0f), + Address::with_last_byte(0x10), + Address::with_last_byte(0x11), + Address::with_last_byte(0x71), // `custom_echo` + address!("0000000000000000000000000000000000000100"), + ]; + let expected_system_contracts = BTreeMap::from([ + (SystemContract::BeaconRoots, address!("000f3df6d732807ef1319fb7b8bb8522d0beac02")), + ( + SystemContract::ConsolidationRequestPredeploy, + address!("0000bbddc7ce488642fb579f8b00f3a590007251"), + ), + (SystemContract::DepositContract, address!("00000000219ab540356cbb839cbe05303d7705fa")), + (SystemContract::HistoryStorage, address!("0000f90827f1c53a10cb7a02335b175320002935")), + ( + SystemContract::WithdrawalRequestPredeploy, + address!("00000961ef480eb55e80d19ad83579a64c007002"), + ), + ]); + + assert_hardfork_config( + &config, + &expected_blob_params, + &expected_precompiles, + &expected_system_contracts, + ); +} + +// Regression tests: verify that `anvil_setRpcUrl` and `anvil_reset` keep +// `ClientForkConfig.fork_urls` in sync so that subsequent resets don't +// silently revert to stale URLs. + +#[tokio::test(flavor = "multi_thread")] +async fn test_anvil_set_rpc_url_syncs_fork_config() { + // Spawn an origin node and fork off it + let genesis_timestamp = 1_700_000_000u64; + let (_origin_api, origin_handle) = + spawn(NodeConfig::test().with_genesis_timestamp(Some(genesis_timestamp))).await; + let origin_url = origin_handle.http_endpoint(); + + let (api, handle) = spawn(NodeConfig::test().with_eth_rpc_url(Some(origin_url.clone()))).await; + + // Verify initial fork URL + let fork = api.backend.get_fork().unwrap(); + assert_eq!(fork.config.read().fork_urls, vec![origin_url.clone()]); + let metadata_before = api.anvil_metadata().await.unwrap(); + + // Spawn a second origin to use as the new URL + let (_origin2_api, origin2_handle) = + spawn(NodeConfig::test().with_genesis_timestamp(Some(genesis_timestamp))).await; + let new_url = origin2_handle.http_endpoint(); + + // Set RPC URL through the RPC dispatcher to cover its lifecycle-lock path. + tokio::time::timeout( + Duration::from_secs(10), + handle.http_provider().raw_request::<_, ()>("anvil_setRpcUrl".into(), (new_url.clone(),)), + ) .await + .expect("anvil_setRpcUrl deadlocked") .unwrap(); - let nonce_after = provider.get_transaction_count(address).await.unwrap(); - - assert!(nonce_before > nonce_after); + // Verify ClientForkConfig is updated + let fork = api.backend.get_fork().unwrap(); + assert_eq!( + fork.config.read().fork_urls, + vec![new_url.clone()], + "ClientForkConfig.fork_urls should be updated after anvil_setRpcUrl" + ); + assert_eq!(api.anvil_metadata().await.unwrap(), metadata_before); + api.anvil_reset(Some(Forking::default())).await.unwrap(); + assert_eq!( + api.backend.get_fork().unwrap().config.read().fork_urls, + vec![new_url], + "URL-less reset should keep the URL selected by anvil_setRpcUrl" + ); +} - let receipt = provider - .send_transaction(WithOtherFields::new( - TransactionRequest::default() - .from(address) - .to(address) - .nonce(nonce_after) - .gas_limit(21000), - )) - .await - .unwrap() - .get_receipt() - .await - .unwrap(); +#[tokio::test(flavor = "multi_thread")] +async fn test_anvil_reset_rejects_self_reference_without_mutation() { + let marker = Address::random(); + let marker_balance = U256::from(91_337u64); + let (origin_api, origin_handle) = spawn(NodeConfig::test()).await; + origin_api.anvil_set_balance(marker, marker_balance).await.unwrap(); + let origin_url = origin_handle.http_endpoint(); + let (api, handle) = spawn(NodeConfig::test().with_eth_rpc_url(Some(origin_url.clone()))).await; + let provider = handle.http_provider(); + let metadata_before = api.anvil_metadata().await.unwrap(); + let info_before = api.anvil_node_info().await.unwrap(); - assert!(receipt.status()); + for self_url in [handle.http_endpoint(), handle.ws_endpoint()] { + let err = api + .anvil_reset(Some(Forking { json_rpc_url: Some(self_url), block_number: None })) + .await + .unwrap_err(); + assert!(err.to_string().contains("own RPC endpoint"), "{err}"); + + let fork = api.backend.get_fork().unwrap(); + assert_eq!(fork.config.read().fork_urls, vec![origin_url.clone()]); + assert_eq!(api.anvil_metadata().await.unwrap(), metadata_before); + assert_eq!(api.anvil_node_info().await.unwrap(), info_before); + assert_eq!(provider.get_balance(marker).await.unwrap(), marker_balance); + } } #[tokio::test(flavor = "multi_thread")] -async fn test_set_erc20_balance() { - let config: NodeConfig = fork_config(); - let address = config.genesis_accounts[0].address(); - let (api, handle) = spawn(config).await; +async fn test_anvil_set_rpc_url_rejects_self_reference_without_mutation() { + let (_origin_api, origin_handle) = spawn(NodeConfig::test()).await; + let origin_url = origin_handle.http_endpoint(); + let (api, handle) = spawn( + NodeConfig::test() + .with_eth_rpc_url(Some(origin_url.clone())) + .with_fork_block_number(Some(0u64)), + ) + .await; + let fork = api.backend.get_fork().unwrap(); + let config_before = fork.config.read().clone(); - let provider = handle.http_provider(); + let error = api.anvil_set_rpc_url(handle.http_endpoint()).await.unwrap_err(); - alloy_sol_types::sol! { - #[sol(rpc)] - contract ERC20 { - function balanceOf(address owner) public view returns (uint256); - } + assert!(error.to_string().contains("own RPC endpoint"), "{error}"); + { + let config = fork.config.read(); + assert!(Arc::ptr_eq(&config.provider, &config_before.provider)); + assert_eq!(config.fork_urls, config_before.fork_urls); } - let dai = address!("0x6B175474E89094C44Da98b954EedeAC495271d0F"); - let erc20 = ERC20::new(dai, provider); - let value = U256::from(500); - - api.anvil_deal_erc20(address, dai, value).await.unwrap(); + api.anvil_reset(Some(Forking::default())).await.unwrap(); + assert_eq!(api.backend.get_fork().unwrap().eth_rpc_url().as_deref(), Some(origin_url.as_str())); +} - let new_balance = erc20.balanceOf(address).call().await.unwrap(); +#[tokio::test(flavor = "multi_thread")] +async fn test_anvil_set_rpc_url_rejects_zksync_source_atomically() { + let (_origin_api, origin_handle) = + spawn(NodeConfig::test().with_chain_id(Some(NamedChain::Mainnet as u64))).await; + let origin_url = origin_handle.http_endpoint(); + let (api, _handle) = spawn( + NodeConfig::test() + .with_eth_rpc_url(Some(origin_url.clone())) + .with_fork_block_number(Some(0u64)) + .with_fork_chain_id(Some(U256::from(NamedChain::Mainnet as u64))), + ) + .await; + let fork = api.backend.get_fork().unwrap(); + let config_before = fork.config.read().clone(); + + let (_zksync_api, zksync_handle) = + spawn(NodeConfig::test().with_chain_id(Some(NamedChain::ZkSync as u64))).await; + let error = api.anvil_set_rpc_url(zksync_handle.http_endpoint()).await.unwrap_err(); + + assert!(error.to_string().contains("cannot execute native EraVM bytecode")); + { + let config = fork.config.read(); + assert_eq!(config.eth_rpc_url(), Some(origin_url.as_str())); + assert!(Arc::ptr_eq(&config.provider, &config_before.provider)); + assert_eq!(config.fork_chain_id, config_before.fork_chain_id); + assert_eq!(config.block_hash, config_before.block_hash); + assert_eq!(config.chain_id, config_before.chain_id); + assert_eq!(config.execution_chain_id, config_before.execution_chain_id); + } - assert_eq!(new_balance, value); + api.anvil_reset(Some(Forking::default())).await.unwrap(); + assert_eq!(api.backend.get_fork().unwrap().eth_rpc_url().as_deref(), Some(origin_url.as_str())); } #[tokio::test(flavor = "multi_thread")] -async fn test_set_erc20_allowance() { - let config: NodeConfig = fork_config(); - let owner = config.genesis_accounts[0].address(); - let spender = config.genesis_accounts[1].address(); - let (api, handle) = spawn(config).await; +async fn test_anvil_set_rpc_url_rejects_different_chain_atomically() { + let (_origin_api, origin_handle) = + spawn(NodeConfig::test().with_chain_id(Some(NamedChain::Mainnet as u64))).await; + let origin_url = origin_handle.http_endpoint(); + let (api, _handle) = spawn( + NodeConfig::test() + .with_eth_rpc_url(Some(origin_url.clone())) + .with_fork_block_number(Some(0u64)), + ) + .await; + let fork = api.backend.get_fork().unwrap(); + let config_before = fork.config.read().clone(); + let (_target_api, target_handle) = + spawn(NodeConfig::test().with_chain_id(Some(NamedChain::Sepolia as u64))).await; - let provider = handle.http_provider(); + api.anvil_set_rpc_url(target_handle.http_endpoint()).await.unwrap_err(); - alloy_sol_types::sol! { - #[sol(rpc)] - contract ERC20 { - function allowance(address owner, address spender) external view returns (uint256); - } + { + let config = fork.config.read(); + assert!(Arc::ptr_eq(&config.provider, &config_before.provider)); + assert_eq!(config.fork_urls, config_before.fork_urls); } - let dai = address!("0x6B175474E89094C44Da98b954EedeAC495271d0F"); - let erc20 = ERC20::new(dai, provider); - let value = U256::from(500); + api.anvil_reset(Some(Forking::default())).await.unwrap(); + assert_eq!(api.backend.get_fork().unwrap().eth_rpc_url().as_deref(), Some(origin_url.as_str())); +} - api.anvil_set_erc20_allowance(owner, spender, dai, value).await.unwrap(); +#[tokio::test(flavor = "multi_thread")] +async fn test_anvil_set_rpc_url_rejects_mismatched_pinned_block_atomically() { + let (origin_api, origin_handle) = spawn(NodeConfig::test()).await; + origin_api.mine_one().await.unwrap(); + let origin_url = origin_handle.http_endpoint(); + let (api, _handle) = spawn( + NodeConfig::test() + .with_eth_rpc_url(Some(origin_url.clone())) + .with_fork_block_number(Some(1u64)), + ) + .await; + let fork = api.backend.get_fork().unwrap(); + let config_before = fork.config.read().clone(); + let (target_api, target_handle) = spawn(NodeConfig::test()).await; + target_api.anvil_set_balance(Address::random(), U256::ONE).await.unwrap(); + target_api.mine_one().await.unwrap(); + + api.anvil_set_rpc_url(target_handle.http_endpoint()).await.unwrap_err(); + + { + let config = fork.config.read(); + assert!(Arc::ptr_eq(&config.provider, &config_before.provider)); + assert_eq!(config.fork_urls, config_before.fork_urls); + } + api.anvil_reset(Some(Forking::default())).await.unwrap(); + assert_eq!(api.backend.get_fork().unwrap().eth_rpc_url().as_deref(), Some(origin_url.as_str())); +} - let allowance = erc20.allowance(owner, spender).call().await.unwrap(); - assert_eq!(allowance, value); +#[tokio::test(flavor = "multi_thread")] +async fn test_anvil_set_rpc_url_keeps_node_info_strict_without_mutation() { + let (_origin_api, origin_handle) = spawn(NodeConfig::test()).await; + let origin_url = origin_handle.http_endpoint(); + let (api, _handle) = spawn( + NodeConfig::test() + .with_eth_rpc_url(Some(origin_url.clone())) + .with_fork_block_number(Some(0u64)), + ) + .await; + let fork = api.backend.get_fork().unwrap(); + let config_before = fork.config.read().clone(); + let (_target_api, target_handle) = spawn(NodeConfig::test()).await; + let target_url = + spawn_rpc_proxy_rejecting_method_after(target_handle.http_endpoint(), "anvil_nodeInfo", 1) + .await; + + let error = api.anvil_set_rpc_url(target_url).await.unwrap_err(); + + assert!(error.to_string().contains("failed to determine network family"), "{error}"); + { + let config = fork.config.read(); + assert!(Arc::ptr_eq(&config.provider, &config_before.provider)); + assert_eq!(config.fork_urls, config_before.fork_urls); + } + api.anvil_reset(Some(Forking::default())).await.unwrap(); + assert_eq!(api.backend.get_fork().unwrap().eth_rpc_url().as_deref(), Some(origin_url.as_str())); } #[tokio::test(flavor = "multi_thread")] -async fn test_add_balance() { - let config: NodeConfig = fork_config(); - let address = config.genesis_accounts[0].address(); - let (api, _handle) = spawn(config).await; +async fn test_anvil_reset_with_url_updates_fork_urls() { + // Spawn an origin node and fork off it + let (_origin_api, origin_handle) = spawn(NodeConfig::test()).await; + let origin_url = origin_handle.http_endpoint(); - let start_balance = U256::from(100_000_u64); - api.anvil_set_balance(address, start_balance).await.unwrap(); + let (api, _handle) = spawn(NodeConfig::test().with_eth_rpc_url(Some(origin_url.clone()))).await; - let balance_increase = U256::from(50_000_u64); - api.anvil_add_balance(address, balance_increase).await.unwrap(); + // Spawn a second origin + let (_origin2_api, origin2_handle) = spawn(NodeConfig::test()).await; + let new_url = origin2_handle.http_endpoint(); - let new_balance = api.balance(address, None).await.unwrap(); - assert_eq!(new_balance, start_balance + balance_increase); + // Reset fork with a new URL + api.anvil_reset(Some(Forking { json_rpc_url: Some(new_url.clone()), block_number: None })) + .await + .unwrap(); + + // Verify the fork config uses the new URL, not the old one + let fork = api.backend.get_fork().unwrap(); + assert_eq!( + fork.config.read().fork_urls, + vec![new_url.clone()], + "ClientForkConfig.fork_urls should reflect the new URL after anvil_reset" + ); } #[tokio::test(flavor = "multi_thread")] -async fn test_reset_updates_cache_path_when_rpc_url_not_provided() { - let config: NodeConfig = fork_config(); - - let (mut api, _handle) = spawn(config).await; - let info = api.anvil_node_info().await.unwrap(); - let number = info.fork_config.fork_block_number.unwrap(); - assert_eq!(number, BLOCK_NUMBER); - - async fn get_block_from_cache_path(api: &mut EthApi) -> u64 { - let db = api.backend.get_db().read().await; - let cache_path = db.maybe_inner().unwrap().cache().cache_path().unwrap(); - cache_path - .parent() - .expect("must have filename") - .file_name() - .expect("must have block number as dir name") - .to_str() - .expect("must be valid string") - .parse::() - .expect("must be valid number") - } - - assert_eq!(BLOCK_NUMBER, get_block_from_cache_path(&mut api).await); +async fn test_anvil_reset_updates_inferred_chain_id() { + let (_mainnet_api, mainnet_handle) = + spawn(NodeConfig::test().with_chain_id(Some(NamedChain::Mainnet as u64))).await; + let (api, handle) = + spawn(NodeConfig::test().with_eth_rpc_url(Some(mainnet_handle.http_endpoint()))).await; + assert_eq!(handle.http_provider().get_chain_id().await.unwrap(), NamedChain::Mainnet as u64); - // Reset to older block without specifying a new rpc url + let (_sepolia_api, sepolia_handle) = + spawn(NodeConfig::test().with_chain_id(Some(NamedChain::Sepolia as u64))).await; api.anvil_reset(Some(Forking { - json_rpc_url: None, - block_number: Some(BLOCK_NUMBER - 1_000_000), + json_rpc_url: Some(sepolia_handle.http_endpoint()), + block_number: None, })) .await .unwrap(); - assert_eq!(BLOCK_NUMBER - 1_000_000, get_block_from_cache_path(&mut api).await); + assert_eq!(handle.http_provider().get_chain_id().await.unwrap(), NamedChain::Sepolia as u64); + let fork = api.backend.get_fork().unwrap(); + assert_eq!(fork.chain_id(), NamedChain::Sepolia as u64); + assert_eq!(fork.config.read().fork_chain_id, None); } #[tokio::test(flavor = "multi_thread")] -async fn test_fork_get_account() { - let (_api, handle) = spawn(fork_config()).await; - let provider = handle.http_provider(); - - let accounts = handle.dev_accounts().collect::>(); - - let alice = accounts[0]; - let bob = accounts[1]; +async fn test_anvil_reset_without_url_preserves_offline_fork_chain_id() { + let (_mainnet_api, mainnet_handle) = + spawn(NodeConfig::test().with_chain_id(Some(NamedChain::Mainnet as u64))).await; + let (api, _handle) = spawn( + NodeConfig::test() + .with_eth_rpc_url(Some(mainnet_handle.http_endpoint())) + .with_fork_block_number(Some(0u64)) + .with_fork_chain_id(Some(U256::from(NamedChain::Mainnet as u64))), + ) + .await; - let init_block = provider.get_block_number().await.unwrap(); - let alice_bal = provider.get_balance(alice).await.unwrap(); - let alice_nonce = provider.get_transaction_count(alice).await.unwrap(); - let alice_acc_init = provider.get_account(alice).await.unwrap(); + api.anvil_reset(Some(Forking { json_rpc_url: None, block_number: Some(0) })).await.unwrap(); - assert_eq!(alice_acc_init.balance, alice_bal); - assert_eq!(alice_acc_init.nonce, alice_nonce); + let expected = Some(NamedChain::Mainnet as u64); + let fork = api.backend.get_fork().unwrap(); + assert_eq!(fork.config.read().fork_chain_id, expected); +} - let tx = TransactionRequest::default().from(alice).to(bob).value(U256::from(142)); +#[tokio::test(flavor = "multi_thread")] +async fn test_anvil_reset_replaces_offline_fork_chain_id() { + let (_mainnet_api, mainnet_handle) = + spawn(NodeConfig::test().with_chain_id(Some(NamedChain::Mainnet as u64))).await; + let (api, handle) = spawn( + NodeConfig::test() + .with_eth_rpc_url(Some(mainnet_handle.http_endpoint())) + .with_fork_block_number(Some(0u64)) + .with_fork_chain_id(Some(U256::from(NamedChain::Mainnet as u64))), + ) + .await; - let tx = WithOtherFields::new(tx); - let receipt = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + let (_sepolia_api, sepolia_handle) = + spawn(NodeConfig::test().with_chain_id(Some(NamedChain::Sepolia as u64))).await; + api.anvil_reset(Some(Forking { + json_rpc_url: Some(sepolia_handle.http_endpoint()), + block_number: None, + })) + .await + .unwrap(); - assert!(receipt.status()); - assert_eq!(init_block + 1, receipt.block_number.unwrap()); + assert_eq!(handle.http_provider().get_chain_id().await.unwrap(), NamedChain::Sepolia as u64); +} - let alice_acc = provider.get_account(alice).await.unwrap(); +#[tokio::test(flavor = "multi_thread")] +async fn test_anvil_reset_rejects_zksync_source_atomically() { + let (_origin_api, origin_handle) = + spawn(NodeConfig::test().with_chain_id(Some(NamedChain::Mainnet as u64))).await; + let origin_url = origin_handle.http_endpoint(); + let (api, handle) = spawn( + NodeConfig::test() + .with_eth_rpc_url(Some(origin_url.clone())) + .with_fork_block_number(Some(0u64)) + .with_fork_chain_id(Some(U256::from(NamedChain::Mainnet as u64))), + ) + .await; + let original_block = handle.http_provider().get_block_number().await.unwrap(); + let original_instance_id = api.instance_id(); + + let (_zksync_api, zksync_handle) = + spawn(NodeConfig::test().with_chain_id(Some(NamedChain::ZkSync as u64))).await; + let error = api + .anvil_reset(Some(Forking { + json_rpc_url: Some(zksync_handle.http_endpoint()), + block_number: None, + })) + .await + .unwrap_err(); + + assert!(error.to_string().contains("cannot execute native EraVM bytecode")); + let fork = api.backend.get_fork().unwrap(); + assert_eq!(fork.eth_rpc_url().as_deref(), Some(origin_url.as_str())); + assert_eq!(fork.config.read().fork_chain_id, Some(NamedChain::Mainnet as u64)); + assert_eq!(handle.http_provider().get_block_number().await.unwrap(), original_block); + assert_eq!(api.instance_id(), original_instance_id); +} - assert_eq!( - alice_acc.balance, - alice_bal - - (U256::from(142) - + U256::from(receipt.gas_used as u128 * receipt.effective_gas_price)), - ); - assert_eq!(alice_acc.nonce, alice_nonce + 1); +#[tokio::test(flavor = "multi_thread")] +async fn test_anvil_reset_from_local_node_rejects_zksync_source_atomically() { + let (api, handle) = spawn(NodeConfig::test()).await; + api.mine_one().await.unwrap(); + let original_block = handle.http_provider().get_block_number().await.unwrap(); + let original_chain_id = handle.http_provider().get_chain_id().await.unwrap(); + let original_instance_id = api.instance_id(); + + let (_zksync_api, zksync_handle) = + spawn(NodeConfig::test().with_chain_id(Some(NamedChain::ZkSync as u64))).await; + let error = api + .anvil_reset(Some(Forking { + json_rpc_url: Some(zksync_handle.http_endpoint()), + block_number: None, + })) + .await + .unwrap_err(); - let alice_acc_prev_block = provider.get_account(alice).number(init_block).await.unwrap(); + assert!(error.to_string().contains("cannot execute native EraVM bytecode")); + assert!(!api.is_fork()); + assert_eq!(handle.http_provider().get_block_number().await.unwrap(), original_block); + assert_eq!(handle.http_provider().get_chain_id().await.unwrap(), original_chain_id); + assert_eq!(api.instance_id(), original_instance_id); +} - assert_eq!(alice_acc_init, alice_acc_prev_block); +// +#[test] +fn test_fork_uncached_reads_do_not_deadlock_saturated_runtime() { + // The origin is a separate node, so it gets its own runtime. + let origin_rt = tokio::runtime::Runtime::new().unwrap(); + let (_origin_api, origin_handle) = origin_rt.block_on(spawn(NodeConfig::test())); + let origin_url = origin_handle.http_endpoint(); + + // With one worker and one blocking thread, two concurrent uncached account reads occupy + // every thread of the fork's runtime. + let (done_tx, done_rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .max_blocking_threads(1) + .enable_all() + .build() + .unwrap(); + rt.block_on(async { + let (api, _handle) = spawn(NodeConfig::test().with_eth_rpc_url(Some(origin_url))).await; + let reads = (0..8).map(|_| { + let api = api.clone(); + tokio::spawn(async move { api.balance(Address::random(), None).await }) + }); + for read in join_all(reads).await { + read.unwrap().unwrap(); + } + }); + done_tx.send(()).unwrap(); + }); + + done_rx.recv_timeout(Duration::from_secs(60)).expect("fork node deadlocked"); } diff --git a/crates/anvil/tests/it/fork_bal.rs b/crates/anvil/tests/it/fork_bal.rs new file mode 100644 index 0000000000000..1e90a2409184d --- /dev/null +++ b/crates/anvil/tests/it/fork_bal.rs @@ -0,0 +1,288 @@ +//! Fork cache warming from a second Anvil instance's block access lists. + +use alloy_genesis::{Genesis, GenesisAccount}; +use alloy_network::TransactionBuilder; +use alloy_primitives::{Address, B256, U256, address, bytes}; +use alloy_provider::Provider; +use alloy_rpc_types::{BlockNumberOrTag, TransactionRequest, anvil::Forking}; +use alloy_serde::WithOtherFields; +use anvil::{ + EthereumHardfork, NodeConfig, NodeHandle, + eth::{ + EthApi, + backend::db::{SerializableAccountRecord, SerializableState}, + }, + spawn, +}; +use foundry_primitives::FoundryNetwork; +use foundry_test_utils::rpc::{ + spawn_rpc_proxy_internal_error_after, spawn_rpc_proxy_method_not_found_before, +}; + +mod accounts; + +const CONTRACT: Address = address!("000000000000000000000000000000000000ba10"); + +struct BalOrigin { + api: EthApi, + handle: NodeHandle, + endpoint: String, + sender: Address, + block_number: u64, + block_hash: B256, +} + +impl BalOrigin { + async fn new() -> Self { + Self::with_hardfork(EthereumHardfork::Amsterdam).await + } + + async fn with_hardfork(hardfork: EthereumHardfork) -> Self { + let (api, handle) = spawn( + NodeConfig::test() + .with_chain_id(Some(1u64)) + .with_hardfork(Some(hardfork.into())) + .with_genesis_timestamp(Some(1_800_000_000u64)) + .with_no_mining(true), + ) + .await; + let sender = handle.dev_wallets().next().unwrap().address(); + // Read slot one without changing it, then increment slot zero. + api.anvil_set_code(CONTRACT, bytes!("6001545060005460010160005500")).await.unwrap(); + api.anvil_set_storage_at(CONTRACT, U256::ONE, B256::with_last_byte(9)).await.unwrap(); + Self::increment(&api, sender).await; + let block = handle + .http_provider() + .get_block_by_number(BlockNumberOrTag::Latest) + .await + .unwrap() + .unwrap(); + // Model an ordinary Ethereum RPC while keeping mined source state unchanged. + let endpoint = spawn_rpc_proxy_method_not_found_before( + handle.http_endpoint(), + "anvil_nodeInfo", + usize::MAX, + ) + .await; + Self { + api, + handle, + endpoint, + sender, + block_number: block.header.number, + block_hash: block.header.hash, + } + } + + fn config(&self) -> NodeConfig { + NodeConfig::test() + .with_eth_rpc_url(Some(self.endpoint.clone())) + .with_fork_block_number(Some(self.block_number)) + .with_hardfork(Some(EthereumHardfork::Amsterdam.into())) + .with_no_storage_caching(true) + .with_genesis_accounts(vec![]) + .with_no_mining(true) + } + + async fn increment(api: &EthApi, sender: Address) { + api.send_transaction(WithOtherFields::new( + TransactionRequest::default() + .with_from(sender) + .with_to(CONTRACT) + .with_gas_limit(200_000), + )) + .await + .unwrap(); + api.mine_one().await.unwrap(); + } +} + +async fn cached_storage( + api: &EthApi, + address: Address, + slot: U256, +) -> Option { + let db = api.backend.get_db().read().await; + db.maybe_inner() + .unwrap() + .storage() + .read() + .get(&address) + .and_then(|slots| slots.get(&slot)) + .copied() +} + +async fn has_cached_account(api: &EthApi, address: Address) -> bool { + let db = api.backend.get_db().read().await; + db.maybe_inner().unwrap().accounts().read().contains_key(&address) +} + +#[tokio::test(flavor = "multi_thread")] +async fn fork_bal_real_anvil_prefill_respects_flags_on_startup_and_reset() { + let origin = BalOrigin::new().await; + BalOrigin::increment(&origin.api, origin.sender).await; + for (no_bal, no_fork_node_info) in [(false, false), (true, false), (false, true), (true, true)] + { + let (api, _handle) = + spawn(origin.config().with_no_bal(no_bal).with_no_fork_node_info(no_fork_node_info)) + .await; + for reset in [false, true] { + let value = U256::from(if reset { 2 } else { 1 }); + if reset { + api.anvil_reset(Some(Forking { + json_rpc_url: None, + block_number: Some(origin.block_number + 1), + })) + .await + .unwrap(); + } + assert_eq!( + cached_storage(&api, CONTRACT, U256::ZERO).await, + (!no_bal && !no_fork_node_info).then_some(value), + "no_bal={no_bal}, no_fork_node_info={no_fork_node_info}, reset={reset}" + ); + assert_eq!(cached_storage(&api, CONTRACT, U256::ONE).await, None); + assert!(!has_cached_account(&api, CONTRACT).await, "incomplete accounts remain lazy"); + + assert_eq!( + api.storage_at(CONTRACT, U256::ZERO, None).await.unwrap(), + B256::from(value) + ); + assert_eq!( + api.storage_at(CONTRACT, U256::ONE, None).await.unwrap(), + B256::with_last_byte(9) + ); + assert_eq!(cached_storage(&api, CONTRACT, U256::ZERO).await, Some(value)); + assert_eq!(cached_storage(&api, CONTRACT, U256::ONE).await, Some(U256::from(9))); + } + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn fork_bal_unavailable_keeps_storage_lazy() { + let origin = BalOrigin::with_hardfork(EthereumHardfork::Prague).await; + assert_eq!(origin.api.block_access_list_by_hash(origin.block_hash).await.unwrap(), None); + let (api, _handle) = spawn(origin.config()).await; + assert_eq!(cached_storage(&api, CONTRACT, U256::ZERO).await, None); + assert_eq!(api.storage_at(CONTRACT, U256::ZERO, None).await.unwrap(), B256::with_last_byte(1)); + assert_eq!(cached_storage(&api, CONTRACT, U256::ZERO).await, Some(U256::ONE)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn fork_bal_skips_mutable_and_uncertain_sources_and_reads_lazily() { + let origin = BalOrigin::new().await; + origin.api.anvil_set_storage_at(CONTRACT, U256::ZERO, B256::with_last_byte(99)).await.unwrap(); + let uncertain = + spawn_rpc_proxy_internal_error_after(origin.handle.http_endpoint(), "anvil_nodeInfo", 0) + .await; + for endpoint in [origin.handle.http_endpoint(), uncertain] { + let (api, _handle) = spawn(origin.config().with_eth_rpc_url(Some(endpoint))).await; + for reset in [false, true] { + if reset { + api.anvil_reset(Some(Forking { + json_rpc_url: None, + block_number: Some(origin.block_number), + })) + .await + .unwrap(); + } + assert_eq!(cached_storage(&api, CONTRACT, U256::ZERO).await, None); + assert_eq!( + api.storage_at(CONTRACT, U256::ZERO, None).await.unwrap(), + B256::with_last_byte(99), + ); + assert_eq!(cached_storage(&api, CONTRACT, U256::ZERO).await, Some(U256::from(99))); + } + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn fork_bal_seed_survives_local_commit_and_snapshot_revert() { + let origin = BalOrigin::new().await; + let (api, _handle) = spawn(origin.config()).await; + assert_eq!(cached_storage(&api, CONTRACT, U256::ZERO).await, Some(U256::ONE)); + let snapshot = api.evm_snapshot().await.unwrap(); + BalOrigin::increment(&api, origin.sender).await; + assert_eq!(api.storage_at(CONTRACT, U256::ZERO, None).await.unwrap(), B256::with_last_byte(2),); + assert_eq!(cached_storage(&api, CONTRACT, U256::ZERO).await, Some(U256::ONE)); + assert!(api.evm_revert(snapshot).await.unwrap()); + assert_eq!(api.storage_at(CONTRACT, U256::ZERO, None).await.unwrap(), B256::with_last_byte(1)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn fork_bal_preserves_genesis_funding_and_loaded_state_overrides() { + let origin = BalOrigin::new().await; + let genesis = Genesis { + alloc: [( + CONTRACT, + GenesisAccount { + balance: U256::from(10), + storage: Some([(B256::ZERO, B256::with_last_byte(20))].into()), + ..Default::default() + }, + )] + .into(), + ..Default::default() + }; + let config = origin + .config() + .with_genesis(Some(genesis)) + .with_funded_accounts([(CONTRACT, U256::from(30))].into_iter().collect()); + let (api, handle) = spawn(config.clone()).await; + assert_eq!(cached_storage(&api, CONTRACT, U256::ZERO).await, Some(U256::ONE)); + assert_eq!(api.storage_at(CONTRACT, U256::ZERO, None).await.unwrap(), B256::with_last_byte(20)); + assert_eq!(api.balance(CONTRACT, None).await.unwrap(), U256::from(30)); + drop(handle); + drop(api); + + let state = SerializableState { + accounts: [( + CONTRACT, + SerializableAccountRecord { + nonce: 3, + balance: U256::from(40), + code: bytes!("00"), + storage: [(B256::ZERO, B256::with_last_byte(50))].into(), + }, + )] + .into(), + ..Default::default() + }; + let (api, _handle) = spawn(config.with_init_state(Some(state))).await; + assert_eq!(api.storage_at(CONTRACT, U256::ZERO, None).await.unwrap(), B256::with_last_byte(50)); + assert_eq!(api.balance(CONTRACT, None).await.unwrap(), U256::from(40)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn fork_bal_transaction_hash_uses_parent_seed_and_replays_target_prefix() { + let origin = BalOrigin::new().await; + let nonce = origin.handle.http_provider().get_transaction_count(origin.sender).await.unwrap(); + let mut transactions = Vec::new(); + for nonce in [nonce, nonce + 1] { + transactions.push( + origin + .api + .send_transaction(WithOtherFields::new( + TransactionRequest::default() + .with_from(origin.sender) + .with_to(CONTRACT) + .with_gas_limit(200_000) + .with_nonce(nonce), + )) + .await + .unwrap(), + ); + } + origin.api.mine_one().await.unwrap(); + assert_eq!( + origin.api.storage_at(CONTRACT, U256::ZERO, None).await.unwrap(), + B256::with_last_byte(3), + ); + + let (api, _handle) = + spawn(origin.config().with_fork_transaction_hash(Some(transactions[0]))).await; + assert_eq!(cached_storage(&api, CONTRACT, U256::ZERO).await, Some(U256::ONE)); + assert_eq!(api.storage_at(CONTRACT, U256::ZERO, None).await.unwrap(), B256::with_last_byte(2)); + assert!(api.backend.mined_transaction_by_hash(transactions[0]).is_some()); + assert!(api.backend.mined_transaction_by_hash(transactions[1]).is_none()); +} diff --git a/crates/anvil/tests/it/fork_bal/accounts.rs b/crates/anvil/tests/it/fork_bal/accounts.rs new file mode 100644 index 0000000000000..c22bea2dcac7d --- /dev/null +++ b/crates/anvil/tests/it/fork_bal/accounts.rs @@ -0,0 +1,100 @@ +//! Account creation and historical deletion across a prefilled fork. + +use super::{BalOrigin, CONTRACT, cached_storage, has_cached_account}; +use crate::abi::{COUNTER_INIT_CODE, COUNTER_RUNTIME_CODE}; +use alloy_network::{ReceiptResponse, TransactionBuilder}; +use alloy_primitives::{B256, U256, bytes}; +use alloy_provider::Provider; +use alloy_rpc_types::{BlockId, BlockNumberOrTag, TransactionRequest}; +use alloy_serde::WithOtherFields; +use anvil::{EthereumHardfork, eth::EthApi, spawn}; +use foundry_primitives::FoundryNetwork; + +async fn pin_latest(origin: &mut BalOrigin) { + let block = origin + .handle + .http_provider() + .get_block_by_number(BlockNumberOrTag::Latest) + .await + .unwrap() + .unwrap(); + origin.block_number = block.header.number; + origin.block_hash = block.header.hash; +} + +async fn mine_transaction(api: &EthApi, request: TransactionRequest) { + let hash = api.send_transaction(WithOtherFields::new(request)).await.unwrap(); + api.mine_one().await.unwrap(); + assert!(api.transaction_receipt(hash).await.unwrap().unwrap().status()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn fork_bal_prefills_complete_created_accounts() { + let mut origin = BalOrigin::new().await; + let nonce = origin.handle.http_provider().get_transaction_count(origin.sender).await.unwrap(); + let contract = origin.sender.create(nonce); + mine_transaction( + &origin.api, + TransactionRequest::default() + .with_from(origin.sender) + .with_deploy_code(COUNTER_INIT_CODE) + .with_value(U256::from(42)) + .with_gas_limit(1_000_000), + ) + .await; + pin_latest(&mut origin).await; + + let (api, _handle) = spawn(origin.config()).await; + assert!(has_cached_account(&api, contract).await); + assert_eq!(api.balance(contract, None).await.unwrap(), U256::from(42)); + assert_eq!(api.transaction_count(contract, None).await.unwrap(), U256::ONE); + assert_eq!(api.get_code(contract, None).await.unwrap(), COUNTER_RUNTIME_CODE); + + mine_transaction( + &api, + TransactionRequest::default() + .with_from(origin.sender) + .with_to(contract) + .with_gas_limit(200_000), + ) + .await; + assert_eq!(api.storage_at(contract, U256::ZERO, None).await.unwrap(), B256::with_last_byte(2)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn fork_bal_historical_storage_keeps_local_deletion() { + let mut origin = BalOrigin::new().await; + // Empty calldata increments slot zero; nonempty calldata selfdestructs to the caller. + origin + .api + .anvil_set_code(CONTRACT, bytes!("361560075733ff5b60005460010160005500")) + .await + .unwrap(); + BalOrigin::increment(&origin.api, origin.sender).await; + pin_latest(&mut origin).await; + + // Source eligibility is Cancun+, but local Shanghai execution can delete existing storage. + let (api, _handle) = + spawn(origin.config().with_hardfork(Some(EthereumHardfork::Shanghai.into()))).await; + assert_eq!(cached_storage(&api, CONTRACT, U256::ZERO).await, Some(U256::from(2))); + + // Keep the old slot unread: only BAL prefill should add it to the remote snapshot. + mine_transaction( + &api, + TransactionRequest::default() + .with_from(origin.sender) + .with_to(CONTRACT) + .with_input(bytes!("01")) + .with_gas_limit(200_000), + ) + .await; + let deleted_block = BlockId::number(api.block_number().unwrap().to()); + assert!(api.get_code(CONTRACT, None).await.unwrap().is_empty()); + assert_eq!(api.storage_at(CONTRACT, U256::ZERO, None).await.unwrap(), B256::ZERO); + + api.mine_one().await.unwrap(); + assert_eq!( + api.storage_at(CONTRACT, U256::ZERO, Some(deleted_block)).await.unwrap(), + B256::ZERO, + ); +} diff --git a/crates/anvil/tests/it/fork_chains.rs b/crates/anvil/tests/it/fork_chains.rs new file mode 100644 index 0000000000000..4d86e99d387c1 --- /dev/null +++ b/crates/anvil/tests/it/fork_chains.rs @@ -0,0 +1,335 @@ +//! Fork tests for popular chains that serve RPC responses differing from Ethereum mainnet. +//! +//! Every chain runs through [`assert_can_fork`], so chain specific response shapes such as Arbitrum +//! Orbit system transactions, Celo's CIP-64 transactions, OP-stack deposits, or vendor specific +//! header fields are covered by the same assertions. Each chain round-trips both a standard +//! transaction and, when its blocks carry one, a transaction of a type anvil cannot execute. +//! +//! These tests fork from public endpoints and are therefore prefixed with `flaky_` so they only run +//! in the nightly flaky job. A request the endpoint fails to serve skips the test; a response anvil +//! cannot decode still fails it, because covering those response shapes is the point. + +use alloy_chains::NamedChain; +use alloy_eips::Typed2718; +use alloy_network::{ReceiptResponse, TransactionBuilder, TransactionResponse}; +use alloy_primitives::{Address, B256}; +use alloy_rpc_types::{BlockId, BlockNumberOrTag, TransactionRequest, state::EvmOverrides}; +use alloy_transport::{RpcError, TransportError}; +use anvil::{ + NodeConfig, + eth::{EthApi, error::BlockchainError}, + try_spawn, +}; +use foundry_primitives::FoundryNetwork; +use foundry_test_utils::rpc::next_rpc_endpoint; +use std::{ + collections::BTreeMap, + fmt::{Debug, Display}, +}; + +/// Highest EIP-2718 transaction type defined for every chain. +/// +/// Anything above it is minted by the chain itself: Arbitrum's `0x6a`, Celo's CIP-64 `0x7b`, or +/// OP-stack deposits at `0x7e`. Anvil cannot execute those, but it must still serve them from a +/// fork, so they are round-tripped alongside the standard types rather than skipped. +const MAX_STANDARD_TX_TYPE: u8 = 4; + +/// Number of blocks scanned back from the fork head when looking for a transaction to round-trip. +const TRANSACTION_LOOKBACK: u64 = 8; + +/// Fragments public endpoints use to report that they refused to serve a request. +/// +/// Every gateway spells this differently and there is no shared error code, so the rendered error +/// is matched instead. +const SERVICE_REFUSALS: &[&str] = + &["rate limit", "too many requests", "usage limit", "limit exceeded", "quota", "capacity"]; + +#[tokio::test(flavor = "multi_thread")] +async fn flaky_test_fork_arbitrum() { + assert_can_fork(NamedChain::Arbitrum).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn flaky_test_fork_base() { + assert_can_fork(NamedChain::Base).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn flaky_test_fork_celo() { + assert_can_fork(NamedChain::Celo).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn flaky_test_fork_gnosis() { + assert_can_fork(NamedChain::Gnosis).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn flaky_test_fork_hyperliquid() { + assert_can_fork(NamedChain::Hyperliquid).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn flaky_test_fork_optimism() { + assert_can_fork(NamedChain::Optimism).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn flaky_test_fork_polygon() { + assert_can_fork(NamedChain::Polygon).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn flaky_test_fork_robinhood() { + assert_can_fork(NamedChain::Robinhood).await; +} + +/// Forks `chain` at its latest block and exercises the RPC surface fork mode depends on. +async fn assert_can_fork(chain: NamedChain) { + // A `None` result means the endpoint failed to serve a request; the reason is already reported. + let _ = fork_and_assert(chain).await; +} + +/// Runs the fork assertions for `chain`, returning `None` once the endpoint stopped serving. +async fn fork_and_assert(chain: NamedChain) -> Option<()> { + // Both forks below must reach the same provider: the Arbitrum endpoints rotate and their heads + // are a few blocks apart. + let fork_rpc = next_rpc_endpoint(chain); + let config = NodeConfig::test().with_eth_rpc_url(Some(fork_rpc.clone())); + let genesis_balance = config.genesis_balance; + let (api, handle) = fork_ok(chain, "fork setup", try_spawn(config).await)?; + + assert_eq!(api.chain_id(), chain as u64, "{chain:?} fork adopted the wrong chain id"); + + fork_ok( + chain, + "eth_call", + api.call( + TransactionRequest::default().with_to(Address::ZERO).into(), + None, + EvmOverrides::default(), + ) + .await, + )?; + + let fork_block = api.block_number().unwrap().to::(); + if fork_block == 0 { + // Public endpoints occasionally report block zero while they are unhealthy. + return skip(chain, "fork head", "endpoint reported block zero"); + } + + for wallet in handle.dev_wallets() { + let balance = api.balance(wallet.address(), None).await.unwrap(); + assert_eq!(balance, genesis_balance, "{chain:?} fork did not fund the dev accounts"); + } + + let transactions = transactions_to_round_trip(chain, &api, fork_block).await?; + + // Selecting the transactions cached whole blocks on `api`, which would serve the lookups below + // without ever calling the endpoint. Use a fork that has not read those blocks instead, so + // every response is decoded as the endpoint returns it. + let lookup_config = NodeConfig::test() + .with_eth_rpc_url(Some(fork_rpc)) + .with_fork_block_number(Some(fork_block)); + let (lookup_api, _) = fork_ok(chain, "transaction fork setup", try_spawn(lookup_config).await)?; + + let mut blocks = transactions.iter().map(|(number, _, _)| *number).collect::>(); + blocks.sort_unstable(); + blocks.dedup(); + + for (block_number, tx_hash, tx_type) in transactions { + let tx = fork_ok( + chain, + "eth_getTransactionByHash", + lookup_api.transaction_by_hash(tx_hash).await, + )? + .unwrap_or_else(|| panic!("{chain:?} fork lost transaction {tx_hash}")); + assert_eq!(tx.tx_hash(), tx_hash); + assert_eq!(tx.block_number, Some(block_number)); + assert_eq!(tx.inner.ty(), tx_type, "{chain:?} fork changed the type of {tx_hash}"); + + let receipt = fork_ok( + chain, + "eth_getTransactionReceipt", + lookup_api.transaction_receipt(tx_hash).await, + )? + .unwrap_or_else(|| panic!("{chain:?} fork lost the receipt for {tx_hash}")); + assert_eq!(receipt.transaction_hash(), tx_hash); + assert_eq!(receipt.block_number(), Some(block_number)); + assert_eq!(receipt.0.inner.inner.ty(), tx_type, "{chain:?} fork changed the receipt type"); + + assert_raw_transaction(chain, &lookup_api, tx_hash, tx_type).await?; + } + + // Local mining continues from the forked head. + api.mine_one().await.unwrap(); + assert_eq!(api.block_number().unwrap().to::(), fork_block + 1); + + // Block receipts decode every transaction in a block at once, so one undecodable chain + // specific receipt takes the whole block down rather than a single lookup. Public endpoints do + // not all whitelist `eth_getBlockReceipts`, so a refusal only drops this check rather than the + // assertions above. + for number in blocks { + let Some(receipts) = fork_ok( + chain, + "eth_getBlockReceipts", + lookup_api.block_receipts(BlockId::number(number)).await, + ) else { + break; + }; + let receipts = receipts + .unwrap_or_else(|| panic!("{chain:?} fork served no receipts for block {number}")); + assert!(!receipts.is_empty(), "{chain:?} fork served an empty block {number}"); + } + + Some(()) +} + +/// The block range scanned back from the fork head when looking for transactions. +fn scanned_blocks(head: u64) -> impl DoubleEndedIterator { + head.saturating_sub(TRANSACTION_LOOKBACK)..=head +} + +/// Returns the transactions to round-trip as `(block number, hash, EIP-2718 type)`. +/// +/// Yields one standard transaction plus one for every distinct chain specific type in the scanned +/// blocks, so a chain that mints several — Celo serves both CIP-64 `0x7b` and OP-stack deposits — +/// is covered for each of them rather than whichever happened to come first. +async fn transactions_to_round_trip( + chain: NamedChain, + api: &EthApi, + head: u64, +) -> Option> { + let mut standard = None; + let mut chain_specific = BTreeMap::new(); + + for number in scanned_blocks(head).rev() { + let block = fork_ok( + chain, + "eth_getBlockByNumber", + api.block_by_number_full(BlockNumberOrTag::Number(number)).await, + )?; + let Some(block) = block else { continue }; + assert_eq!(block.header.number, number, "{chain:?} fork returned the wrong block"); + + let Some(transactions) = block.transactions.as_transactions() else { continue }; + for tx in transactions { + let ty = tx.inner.ty(); + if ty <= MAX_STANDARD_TX_TYPE { + standard.get_or_insert((number, tx.tx_hash(), ty)); + } else { + chain_specific.entry(ty).or_insert((number, tx.tx_hash(), ty)); + } + } + } + + let Some(standard) = standard else { + return skip(chain, "transaction lookup", "no standard transaction near the fork head"); + }; + Some(std::iter::once(standard).chain(chain_specific.into_values()).collect()) +} + +/// Asserts `debug_getRawTransaction` answers rather than taking the request handler down. +/// +/// A chain specific transaction reaches anvil only in its JSON-RPC form, which is not enough to +/// rebuild its consensus encoding, so anvil reports that instead. Alloy panics when asked to +/// encode one, which is the regression this guards. +async fn assert_raw_transaction( + chain: NamedChain, + api: &EthApi, + tx_hash: B256, + tx_type: u8, +) -> Option<()> { + match api.raw_transaction(tx_hash).await { + Ok(raw) => { + assert!(raw.is_some(), "{chain:?} fork lost the raw transaction for {tx_hash}"); + } + Err(BlockchainError::UnsupportedTransactionEncoding(reported)) => { + assert!( + tx_type > MAX_STANDARD_TX_TYPE, + "{chain:?} fork refused to encode standard transaction {tx_hash}" + ); + assert_eq!(reported, tx_type, "{chain:?} fork reported the wrong unsupported type"); + } + Err(err) => { + fork_ok::<(), _>(chain, "debug_getRawTransaction", Err(err))?; + } + } + Some(()) +} + +/// Unwraps a fork response, returning `None` once the endpoint failed to serve the request. +fn fork_ok( + chain: NamedChain, + request: &str, + result: Result, +) -> Option { + match result { + Ok(value) => Some(value), + Err(err) if is_endpoint_unavailable(&err) => skip(chain, request, err.render()), + Err(err) => panic!("{chain:?} fork: {request} failed: {err:?}"), + } +} + +/// Reports why a chain's fork test stopped early, so skipped runs stay visible in CI logs. +/// +/// The flaky nextest profile keeps the output of these tests on success so the report is not +/// swallowed. Chains are named by their variant rather than their canonical name, which does not +/// always match, so the line points at the test that produced it. +#[expect(clippy::disallowed_macros)] +fn skip(chain: NamedChain, request: &str, reason: impl Display) -> Option { + eprintln!("skipping {chain:?} fork test: {request}: {reason}"); + None +} + +/// An error raised while exercising a public fork endpoint. +trait ForkError { + /// Returns the transport error the fork endpoint reported, if this error still carries one. + fn transport_error(&self) -> Option<&TransportError>; + + /// Renders the error together with its causes. + fn render(&self) -> String; +} + +impl ForkError for eyre::Report { + fn transport_error(&self) -> Option<&TransportError> { + self.downcast_ref::() + } + + fn render(&self) -> String { + format!("{self:#}") + } +} + +impl ForkError for BlockchainError { + fn transport_error(&self) -> Option<&TransportError> { + match self { + Self::AlloyForkProvider(err) => Some(err), + _ => None, + } + } + + fn render(&self) -> String { + self.to_string() + } +} + +/// Returns whether the fork endpoint failed to serve the request at all. +/// +/// Connection failures and refusals mean the endpoint never answered, which public endpoints do +/// often enough that failing on them would turn these tests into noise. Everything else stays a +/// failure, in particular a response anvil cannot decode. +fn is_endpoint_unavailable(err: &impl ForkError) -> bool { + match err.transport_error() { + // The endpoint never answered. + Some(RpcError::Transport(_)) => return true, + // A response anvil could not decode is the shape mismatch these tests exist to catch. + Some(RpcError::DeserError { .. }) => return false, + // Fork state fetches render the endpoint's response into their message instead of keeping + // the transport error, so refusals are matched on the rendered error. + _ => {} + } + + let rendered = err.render().to_lowercase(); + SERVICE_REFUSALS.iter().any(|refusal| rendered.contains(refusal)) +} diff --git a/crates/anvil/tests/it/gas.rs b/crates/anvil/tests/it/gas.rs index 2becfb386d9f9..2e49fada3d6e4 100644 --- a/crates/anvil/tests/it/gas.rs +++ b/crates/anvil/tests/it/gas.rs @@ -1,12 +1,27 @@ //! Gas related tests use crate::utils::http_provider_with_signer; -use alloy_network::{EthereumWallet, TransactionBuilder}; -use alloy_primitives::{Address, U64, U256, uint}; +use alloy_chains::NamedChain; +use alloy_genesis::Genesis; +use alloy_network::{EthereumWallet, ReceiptResponse, TransactionBuilder}; +use alloy_primitives::{Address, B256, Bytes, U64, U256, bytes, uint}; use alloy_provider::Provider; -use alloy_rpc_types::{BlockId, TransactionRequest}; +use alloy_rpc_types::{ + AccessList, AccessListItem, BlockId, BlockNumberOrTag, TransactionRequest, + trace::parity::TraceType, +}; use alloy_serde::WithOtherFields; -use anvil::{NodeConfig, eth::fees::INITIAL_BASE_FEE, spawn}; +use anvil::{ + EthereumHardfork, NodeConfig, + eth::{ + error::{BlockchainError, InvalidTransactionError}, + fees::INITIAL_BASE_FEE, + }, + spawn, +}; +use foundry_evm::constants::HARDHAT_CONSOLE_ADDRESS; +use foundry_evm_networks::arbitrum; +use revm::context_interface::block::BlobExcessGasAndPrice; const GAS_TRANSFER: u64 = 21_000; @@ -129,7 +144,7 @@ async fn test_basefee_empty_block() { .unwrap(); // mine empty block - api.mine_one().await; + api.mine_one().await.unwrap(); let next_base_fee = provider .get_block(BlockId::latest()) @@ -165,6 +180,34 @@ async fn test_respect_base_fee() { provider.send_transaction(tx.clone()).await.unwrap().get_receipt().await.unwrap(); } +#[tokio::test(flavor = "multi_thread")] +async fn cap_only_calls_pay_the_base_fee() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + let from = handle.dev_wallets().next().unwrap().address(); + + // Returns GASPRICE. + let contract = Address::repeat_byte(0x3a); + api.anvil_set_code(contract, bytes!("3a5f5260205ff3")).await.unwrap(); + let block = provider.get_block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + let base_fee = block.header.base_fee_per_gas.unwrap() as u128; + + let cap_only = + TransactionRequest::default().from(from).to(contract).max_fee_per_gas(base_fee * 10); + let tipped = cap_only.clone().max_priority_fee_per_gas(1); + for (request, expected) in [(cap_only, base_fee), (tipped, base_fee + 1)] { + let request = WithOtherFields::new(request); + let output = provider.call(request.clone()).block(BlockId::latest()).await.unwrap(); + assert_eq!(U256::from_be_slice(&output), U256::from(expected)); + + let traced = api + .trace_call(request, [TraceType::Trace].into_iter().collect(), Some(BlockId::latest())) + .await + .unwrap(); + assert_eq!(U256::from_be_slice(&traced.output), U256::from(expected)); + } +} + #[tokio::test(flavor = "multi_thread")] async fn test_tip_above_fee_cap() { let base_fee = 50u128; @@ -188,6 +231,101 @@ async fn test_tip_above_fee_cap() { ); } +#[tokio::test(flavor = "multi_thread")] +async fn priced_calls_without_gas_limit_are_capped_by_allowance() { + for config in [NodeConfig::test(), NodeConfig::test().with_optimism()] { + priced_calls_without_gas_limit_are_capped_by_allowance_on(config).await; + } +} + +async fn priced_calls_without_gas_limit_are_capped_by_allowance_on(config: NodeConfig) { + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + + // The sender can pay for 100_000 gas, far below the block gas limit. + let from = Address::repeat_byte(0x11); + let gas_price = 10_000_000_000_000u128; + api.anvil_set_balance(from, U256::from(gas_price * 100_000)).await.unwrap(); + + // Returns GAS, the gas left after the transaction's 21_000 and the opcode's 2. + let contract = Address::repeat_byte(0x5a); + api.anvil_set_code(contract, bytes!("5a5f5260205ff3")).await.unwrap(); + let request = TransactionRequest::default().from(from).to(contract).gas_price(gas_price); + + // Sending half of the balance as value halves the gas the sender can pay for. + let half = request.clone().value(U256::from(gas_price * 50_000)); + for (request, allowance) in [(request.clone(), 100_000), (half, 50_000)] { + let request = WithOtherFields::new(request); + let output = provider.call(request.clone()).await.unwrap(); + assert_eq!(U256::from_be_slice(&output), U256::from(allowance - 21_002)); + + let traced = + api.trace_call(request, [TraceType::Trace].into_iter().collect(), None).await.unwrap(); + assert_eq!(traced.output, output); + } + + // An explicit gas limit is not lowered to what the sender can pay for, and a sender that + // cannot pay for any transaction still fails the funds check. + let unfunded = request.clone().from(Address::repeat_byte(0x12)); + for request in [request.gas_limit(200_000), unfunded] { + let error = provider.call(WithOtherFields::new(request)).await.unwrap_err(); + let error = error.as_error_resp().unwrap(); + assert_eq!( + (error.code, error.message.as_ref()), + (-32003, "Insufficient funds for gas * price + value") + ); + } +} + +#[cfg(feature = "optimism")] +#[tokio::test(flavor = "multi_thread")] +async fn priced_deposit_calls_without_gas_limit_keep_default_budget() { + let (api, handle) = spawn(NodeConfig::test().with_optimism()).await; + let provider = handle.http_provider(); + let from = Address::repeat_byte(0x11); + let gas_price = 10_000_000_000_000u128; + api.anvil_set_balance(from, U256::from(gas_price * 100_000)).await.unwrap(); + + let contract = Address::repeat_byte(0x5a); + api.anvil_set_code(contract, bytes!("5a5f5260205ff3")).await.unwrap(); + let gas_limit = api.gas_limit(); + // Deposits mint funds before paying for execution, so the pre-mint balance must not + // lower their default gas budget. + let request = WithOtherFields { + inner: TransactionRequest::default() + .from(from) + .to(contract) + .gas_price(gas_price) + .transaction_type(0x7e), + other: serde_json::json!({ + "sourceHash": B256::repeat_byte(0x01), + "mint": gas_limit * U256::from(gas_price), + "isSystemTx": false, + }) + .try_into() + .unwrap(), + }; + let output = provider.call(request.clone()).await.unwrap(); + assert_eq!(U256::from_be_slice(&output), gas_limit - U256::from(21_002)); + let traced = + api.trace_call(request, [TraceType::Trace].into_iter().collect(), None).await.unwrap(); + assert_eq!(traced.output, output); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_zero_block_fee_history_is_empty() { + let (api, _handle) = spawn(NodeConfig::test()).await; + + let history = api.fee_history(U256::ZERO, BlockNumberOrTag::Latest, vec![50.0]).await.unwrap(); + + assert_eq!(history.oldest_block, 0); + assert!(history.base_fee_per_gas.is_empty()); + assert!(history.gas_used_ratio.is_empty()); + assert!(history.reward.is_none()); + assert!(history.base_fee_per_blob_gas.is_empty()); + assert!(history.blob_gas_used_ratio.is_empty()); +} + #[tokio::test(flavor = "multi_thread")] async fn test_can_use_fee_history() { let base_fee = 50u128; @@ -217,6 +355,111 @@ async fn test_can_use_fee_history() { } } +// `base_fee_per_gas` includes one entry for the block after the requested range. For a +// historical range, that entry must come from the historical child rather than the current +// chain head's next-block fee. +#[tokio::test(flavor = "multi_thread")] +async fn test_fee_history_historical_next_block_fee() { + let (api, handle) = + spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Cancun.into()))).await; + let provider = handle.http_provider(); + let blob_params = api.backend.blob_params(); + let blob_update_fraction = u64::try_from(blob_params.update_fraction).unwrap(); + + for (base_fee, excess_blob_gas) in [(100, 0), (200, 10_000_000), (300, 20_000_000)] { + api.anvil_set_next_block_base_fee_per_gas(U256::from(base_fee)).await.unwrap(); + api.backend.fees().set_blob_excess_gas_and_price(BlobExcessGasAndPrice::new( + excess_blob_gas, + blob_update_fraction, + )); + api.mine_one().await.unwrap(); + } + api.anvil_set_next_block_base_fee_per_gas(U256::from(400)).await.unwrap(); + api.backend.fees().set_blob_excess_gas_and_price(BlobExcessGasAndPrice::new( + 30_000_000, + blob_update_fraction, + )); + + let history = api.fee_history(U256::ONE, BlockNumberOrTag::Number(1), vec![]).await.unwrap(); + let historical = provider.get_block(BlockId::number(1)).await.unwrap().unwrap(); + let historical_child = provider.get_block(BlockId::number(2)).await.unwrap().unwrap(); + let historical_next_fee = historical_child.header.base_fee_per_gas.unwrap() as u128; + let historical_blob_fee = historical.header.blob_fee().unwrap(); + let historical_next_blob_fee = historical_child.header.blob_fee().unwrap(); + + assert_eq!(history.base_fee_per_gas, vec![100, historical_next_fee]); + assert_ne!(historical_next_fee, api.base_fee().unwrap().unwrap().to::()); + assert_eq!(history.base_fee_per_blob_gas, vec![historical_blob_fee, historical_next_blob_fee]); + assert_ne!(historical_next_blob_fee, api.backend.fees().base_fee_per_blob_gas()); +} + +// Cache entries from the previous chain must not survive `anvil_reset` merely because the new +// chain reuses the same block number. +#[tokio::test(flavor = "multi_thread")] +async fn test_fee_history_ignores_stale_cache_after_reset() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + let old_history = + api.fee_history(U256::ONE, BlockNumberOrTag::Number(0), vec![]).await.unwrap(); + api.anvil_set_next_block_base_fee_per_gas(U256::from(123)).await.unwrap(); + api.anvil_reset(None).await.unwrap(); + + let genesis = provider.get_block(BlockId::number(0)).await.unwrap().unwrap(); + let genesis_base_fee = genesis.header.base_fee_per_gas.unwrap() as u128; + let new_history = + api.fee_history(U256::ONE, BlockNumberOrTag::Number(0), vec![]).await.unwrap(); + + assert_eq!(genesis_base_fee, 123); + assert_ne!(old_history.base_fee_per_gas[0], genesis_base_fee); + assert_eq!(new_history.base_fee_per_gas[0], genesis_base_fee); + assert_eq!(api.base_fee().unwrap(), Some(U256::from(INITIAL_BASE_FEE))); + + api.mine_one().await.unwrap(); + let first = provider.get_block(BlockId::number(1)).await.unwrap().unwrap(); + assert_eq!(first.header.base_fee_per_gas, Some(INITIAL_BASE_FEE)); +} + +// Zero gas limits must not serialize gasUsedRatio as null. +#[tokio::test(flavor = "multi_thread")] +async fn test_fee_history_zero_gas_limit_does_not_produce_null_ratio() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + assert!(api.evm_set_block_gas_limit(U256::ZERO).unwrap()); + api.mine_one().await.unwrap(); + + // Use HTTP to exercise JSON serialization and client deserialization. + let fee_history = provider + .get_fee_history(1, BlockNumberOrTag::Latest, &[]) + .await + .expect("gasUsedRatio must deserialize as a finite f64, not null"); + + let ratio = *fee_history.gas_used_ratio.last().unwrap(); + assert_eq!(ratio, 0.0, "a zero-gas-limit block used none of its (zero) capacity"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_memory_reset_restores_explicit_genesis_base_fee() { + let (api, handle) = spawn( + NodeConfig::test() + .with_hardfork(Some(EthereumHardfork::default().into())) + .with_genesis(Some(Genesis { base_fee_per_gas: Some(0), ..Default::default() })), + ) + .await; + let provider = handle.http_provider(); + + api.anvil_set_next_block_base_fee_per_gas(U256::from(999)).await.unwrap(); + api.anvil_reset(None).await.unwrap(); + + let genesis = provider.get_block(BlockId::number(0)).await.unwrap().unwrap(); + assert_eq!(genesis.header.base_fee_per_gas, Some(0)); + assert_eq!(api.base_fee().unwrap(), Some(U256::ZERO)); + api.mine_one().await.unwrap(); + let first = provider.get_block(BlockId::number(1)).await.unwrap().unwrap(); + assert_eq!(first.header.base_fee_per_gas, Some(0)); +} + #[tokio::test(flavor = "multi_thread")] async fn test_estimate_gas_empty_data() { let (api, handle) = spawn(NodeConfig::test()).await; @@ -225,7 +468,7 @@ async fn test_estimate_gas_empty_data() { let to = accounts[1]; let tx_without_data = - TransactionRequest::default().with_from(from).with_to(to).with_value(U256::from(1)); + TransactionRequest::default().with_from(from).with_to(to).with_value(U256::ONE); let gas_without_data = api .estimate_gas(WithOtherFields::new(tx_without_data), None, Default::default()) @@ -235,7 +478,7 @@ async fn test_estimate_gas_empty_data() { let tx_with_empty_data = TransactionRequest::default() .with_from(from) .with_to(to) - .with_value(U256::from(1)) + .with_value(U256::ONE) .with_input(vec![]); let gas_with_empty_data = api @@ -246,7 +489,7 @@ async fn test_estimate_gas_empty_data() { let tx_with_data = TransactionRequest::default() .with_from(from) .with_to(to) - .with_value(U256::from(1)) + .with_value(U256::ONE) .with_input(vec![0x12, 0x34]); let gas_with_data = api @@ -259,3 +502,351 @@ async fn test_estimate_gas_empty_data() { assert!(gas_with_data > U256::from(GAS_TRANSFER)); assert_eq!(gas_without_data, gas_with_empty_data); } + +#[tokio::test(flavor = "multi_thread")] +async fn test_estimate_gas_empty_precompile_data() { + let (api, _handle) = spawn(NodeConfig::test()).await; + let identity_precompile = Address::with_last_byte(4); + let tx = TransactionRequest::default() + .with_to(identity_precompile) + .with_gas_price(1_000_000_000) + .with_input(vec![]); + + let gas = api.estimate_gas(WithOtherFields::new(tx), None, Default::default()).await.unwrap(); + + assert_eq!(gas, U256::from(GAS_TRANSFER + 15)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_estimate_gas_block_precompile() { + let (api, handle) = + spawn(NodeConfig::test().with_chain_id(Some(NamedChain::Arbitrum as u64))).await; + let from = handle.dev_accounts().next().unwrap(); + let empty_input = TransactionRequest::default() + .with_from(from) + .with_to(arbitrum::ARB_SYS_ADDRESS) + .with_input(vec![]); + + let err = api + .estimate_gas(WithOtherFields::new(empty_input), None, Default::default()) + .await + .unwrap_err(); + + assert_eq!(err.to_string(), "EVM error PrecompileError"); + + let valid_input = TransactionRequest::default() + .with_from(from) + .with_to(arbitrum::ARB_SYS_ADDRESS) + .with_input(Bytes::from(arbitrum::ARB_BLOCK_NUMBER_SELECTOR)); + let gas = api + .estimate_gas(WithOtherFields::new(valid_input), None, Default::default()) + .await + .unwrap(); + + // 21000 base, 64 for the four calldata bytes and 803 inside ArbSys. + assert_eq!(gas, U256::from(21_867)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_estimate_gas_simple_transfer_checks_funds() { + let (api, handle) = spawn(NodeConfig::test()).await; + let to = handle.dev_accounts().next().unwrap(); + let from = Address::random(); + + let tx = TransactionRequest::default().with_from(from).with_to(to).with_value(U256::ONE); + let err = + api.estimate_gas(WithOtherFields::new(tx), None, Default::default()).await.unwrap_err(); + + assert!(err.to_string().contains("Insufficient funds for gas * price + value")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_estimate_gas_simple_transfer_without_from_uses_transfer_fast_path() { + let (api, handle) = spawn(NodeConfig::test()).await; + let to = handle.dev_accounts().next().unwrap(); + + let tx = TransactionRequest::default().with_to(to).with_value(U256::ONE); + let gas = api.estimate_gas(WithOtherFields::new(tx), None, Default::default()).await.unwrap(); + + assert_eq!(gas, U256::from(GAS_TRANSFER)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_estimate_gas_without_from_with_gas_price_uses_transfer_fast_path() { + let (api, handle) = spawn(NodeConfig::test()).await; + let to = handle.dev_accounts().next().unwrap(); + + let tx = TransactionRequest::default().with_to(to).with_gas_price(INITIAL_BASE_FEE as u128); + let gas = api.estimate_gas(WithOtherFields::new(tx), None, Default::default()).await.unwrap(); + + assert_eq!(gas, U256::from(GAS_TRANSFER)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_estimate_gas_fee_token_does_not_skip_funds_check_outside_tempo() { + let (api, handle) = spawn(NodeConfig::test()).await; + let to = handle.dev_accounts().next().unwrap(); + let from = Address::random(); + + let tx: WithOtherFields = WithOtherFields { + inner: TransactionRequest::default().with_from(from).with_to(to).with_value(U256::ONE), + other: [( + "feeToken".to_string(), + serde_json::json!("0x20c0000000000000000000000000000000000001"), + )] + .into_iter() + .collect(), + }; + let err = api.estimate_gas(tx, None, Default::default()).await.unwrap_err(); + + assert!(err.to_string().contains("Insufficient funds for gas * price + value")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_estimation_with_print_traces() { + let contract = Address::random(); + let mut results = Vec::new(); + for print_traces in [false, true] { + let (api, handle) = spawn(NodeConfig::test().with_print_traces(print_traces)).await; + let provider = handle.http_provider(); + let from = handle.dev_accounts().next().unwrap(); + // Return the old slot value and store calldata[0], reverting after the write if zero. + api.anvil_set_code( + contract, + bytes!("600054600052600035806000551560165760206000f35b60006000fd"), + ) + .await + .unwrap(); + api.anvil_set_storage_at(contract, U256::ZERO, B256::with_last_byte(3)).await.unwrap(); + let mut request = WithOtherFields::new( + TransactionRequest::default() + .from(from) + .to(contract) + .gas_limit(200_000) + .input(Bytes::copy_from_slice(B256::with_last_byte(5).as_slice()).into()), + ); + let estimate = api.estimate_gas(request.clone(), None, Default::default()).await.unwrap(); + let access_list = api.create_access_list(request.clone(), None, None).await.unwrap(); + assert_eq!(access_list.error, None); + let expected_access_list = AccessList::from(vec![AccessListItem { + address: contract, + storage_keys: vec![B256::ZERO], + }]); + assert_eq!(access_list.access_list, expected_access_list); + + // The estimated limit must execute successfully, while one gas less must fail. + request.gas = Some(estimate.to()); + let output = api.call(request.clone(), None, Default::default()).await.unwrap(); + assert_eq!(output.as_ref(), B256::with_last_byte(3).0); + request.gas = Some(estimate.to::() - 1); + assert!(api.call(request.clone(), None, Default::default()).await.is_err()); + + request.gas = Some(200_000); + request.input = Bytes::from(vec![0; 32]).into(); + let error = api.estimate_gas(request.clone(), None, Default::default()).await.unwrap_err(); + assert!(matches!( + error, + BlockchainError::InvalidTransaction(InvalidTransactionError::Revert(Some(data))) + if data.is_empty() + )); + let reverted = api.create_access_list(request, None, None).await.unwrap(); + assert_eq!(reverted.error.as_deref(), Some("execution reverted")); + assert_eq!(reverted.access_list, expected_access_list); + + // The console collector must remain active: malformed console calldata must revert, + // rather than succeed as an ordinary call to an address without code. + let console_request = WithOtherFields::new( + TransactionRequest::default() + .from(from) + .to(HARDHAT_CONSOLE_ADDRESS) + .gas_limit(200_000) + .input(bytes!("01").into()), + ); + let error = api.estimate_gas(console_request, None, Default::default()).await.unwrap_err(); + let BlockchainError::InvalidTransaction(InvalidTransactionError::Revert(Some(data))) = + error + else { + panic!("expected console decoding revert, got {error:?}"); + }; + assert!(!data.is_empty()); + assert_eq!(provider.get_storage_at(contract, U256::ZERO).await.unwrap(), U256::from(3)); + assert_eq!(provider.get_transaction_count(from).await.unwrap(), 0); + results.push((estimate, access_list, reverted, data)); + } + assert_eq!(results[0], results[1]); +} + +#[tokio::test(flavor = "multi_thread")] +async fn zero_fee_calls_observe_zero_base_fee() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + let from = handle.dev_wallets().next().unwrap().address(); + + // Returns BASEFEE. + let contract = Address::repeat_byte(0x48); + api.anvil_set_code(contract, bytes!("485f5260205ff3")).await.unwrap(); + let base_fee = provider.get_block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + let base_fee = U256::from(base_fee.header.base_fee_per_gas.unwrap()); + + let free = WithOtherFields::new(TransactionRequest::default().from(from).to(contract)); + let typed_free = + WithOtherFields::new(free.inner.clone().max_fee_per_gas(0).max_priority_fee_per_gas(0)); + let priced = WithOtherFields::new(free.inner.clone().gas_price(base_fee.to())); + let trace = || [TraceType::Trace].into_iter().collect(); + for (request, expected) in + [(free.clone(), U256::ZERO), (typed_free, U256::ZERO), (priced.clone(), base_fee)] + { + let output = provider.call(request.clone()).block(BlockId::latest()).await.unwrap(); + assert_eq!(U256::from_be_slice(&output), expected); + + let traced = api.trace_call(request, trace(), Some(BlockId::latest())).await.unwrap(); + assert_eq!(U256::from_be_slice(&traced.output), expected); + } + + // Like geth, access lists are built at the block's base fee. The contract loads slot BASEFEE. + let sload_base_fee = Address::repeat_byte(0x54); + api.anvil_set_code(sload_base_fee, bytes!("485400")).await.unwrap(); + for request in [free.clone(), priced.clone()] { + let request = WithOtherFields::new(request.inner.to(sload_base_fee)); + let result = api.create_access_list(request, None, None).await.unwrap(); + assert_eq!(result.access_list.0[0].storage_keys, [B256::from(base_fee)]); + } + + // Each call in a batch gets its own fee environment. + let batch = [free.clone(), priced, free].map(|request| (request, trace())); + let traced = api.trace_call_many(batch.to_vec(), Some(BlockId::latest())).await.unwrap(); + let outputs = + traced.iter().map(|result| U256::from_be_slice(&result.output)).collect::>(); + assert_eq!(outputs, [U256::ZERO, base_fee, U256::ZERO]); +} + +#[tokio::test(flavor = "multi_thread")] +async fn priced_calls_below_base_fee_are_rejected() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + let from = handle.dev_wallets().next().unwrap().address(); + let to = handle.dev_accounts().nth(1).unwrap(); + + // Returns GASPRICE. + let contract = Address::repeat_byte(0x3a); + api.anvil_set_code(contract, bytes!("3a5f5260205ff3")).await.unwrap(); + let block = provider.get_block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + let base_fee = block.header.base_fee_per_gas.unwrap() as u128; + let latest = Some(BlockId::latest()); + let trace = || [TraceType::Trace].into_iter().collect(); + let is_fee_cap_too_low = |err: BlockchainError| { + matches!(err, BlockchainError::InvalidTransaction(InvalidTransactionError::FeeCapTooLow)) + }; + + let call = TransactionRequest::default().from(from).to(contract); + let legacy = WithOtherFields::new(call.clone().gas_price(base_fee - 1)); + let capped = WithOtherFields::new(call.clone().max_fee_per_gas(base_fee - 1)); + let transfer = WithOtherFields::new( + TransactionRequest::default().from(from).to(to).gas_price(base_fee - 1), + ); + for request in [legacy, capped, transfer] { + let err = api.call(request.clone(), latest, Default::default()).await.unwrap_err(); + assert!(is_fee_cap_too_low(err)); + let err = api.trace_call(request.clone(), trace(), latest).await.unwrap_err(); + assert!(is_fee_cap_too_low(err)); + let err = api.estimate_gas(request.clone(), latest, Default::default()).await.unwrap_err(); + assert!(is_fee_cap_too_low(err)); + + // A batch with an underpriced call fails as a whole. + let free = WithOtherFields::new(call.clone()); + let batch = vec![(free, trace()), (request, trace())]; + let err = api.trace_call_many(batch, latest).await.unwrap_err(); + assert!(is_fee_cap_too_low(err)); + } + + // A fee cap at the base fee is accepted even when the tip cannot be paid in full. + let tipped = WithOtherFields::new(call.max_fee_per_gas(base_fee).max_priority_fee_per_gas(1)); + let output = api.call(tipped.clone(), latest, Default::default()).await.unwrap(); + assert_eq!(U256::from_be_slice(&output), U256::from(base_fee)); + let traced = api.trace_call(tipped, trace(), latest).await.unwrap(); + assert_eq!(U256::from_be_slice(&traced.output), U256::from(base_fee)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn priced_calls_below_base_fee_are_rejected_after_zero_base_fee() { + let (api, handle) = spawn(NodeConfig::test().with_base_fee(Some(0))).await; + let from = handle.dev_wallets().next().unwrap().address(); + let to = handle.dev_accounts().nth(1).unwrap(); + let call = WithOtherFields::new(TransactionRequest::default().from(from).to(to).gas_price(1)); + + // A zero base fee disables the check for this call only. + api.call(call.clone(), Some(BlockId::latest()), Default::default()).await.unwrap(); + + api.anvil_set_next_block_base_fee_per_gas(U256::from(INITIAL_BASE_FEE)).await.unwrap(); + api.mine_one().await.unwrap(); + let err = api.call(call, Some(BlockId::latest()), Default::default()).await.unwrap_err(); + assert!(matches!( + err, + BlockchainError::InvalidTransaction(InvalidTransactionError::FeeCapTooLow) + )); +} + +#[tokio::test(flavor = "multi_thread")] +async fn priced_calls_skip_base_fee_check_before_london() { + let (api, handle) = + spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Berlin.into()))).await; + let from = handle.dev_wallets().next().unwrap().address(); + let to = handle.dev_accounts().nth(1).unwrap(); + let transfer = + WithOtherFields::new(TransactionRequest::default().from(from).to(to).gas_price(1)); + let latest = Some(BlockId::latest()); + + api.call(transfer.clone(), latest, Default::default()).await.unwrap(); + let gas = api.estimate_gas(transfer, latest, Default::default()).await.unwrap(); + assert_eq!(gas, U256::from(21_000)); +} + +// +#[tokio::test(flavor = "multi_thread")] +async fn test_estimate_gas_transfers_across_hardforks() { + for hardfork in [EthereumHardfork::Osaka, EthereumHardfork::Amsterdam] { + let (api, handle) = spawn(NodeConfig::test().with_hardfork(Some(hardfork.into()))).await; + let provider = handle.http_provider(); + let mut accounts = handle.dev_accounts(); + let from = accounts.next().unwrap(); + let existing = accounts.next().unwrap(); + let fresh = Address::random(); + // Bytecode excludes this call from the transfer shortcut, so the binary search runs. + let contract = Address::random(); + api.anvil_set_code(contract, bytes!("00")).await.unwrap(); + let amsterdam = hardfork == EthereumHardfork::Amsterdam; + + // Under EIP-2780 only a value transfer to another account costs 21000. Under EIP-8037 a + // value transfer to an empty account also pays new-account state gas. + for (to, value, expected) in [ + (from, U256::ONE, if amsterdam { 12_000 } else { GAS_TRANSFER }), + (existing, U256::ZERO, if amsterdam { 15_000 } else { GAS_TRANSFER }), + (existing, U256::ONE, GAS_TRANSFER), + (fresh, U256::ZERO, if amsterdam { 15_000 } else { GAS_TRANSFER }), + (fresh, U256::ONE, if amsterdam { 204_600 } else { GAS_TRANSFER }), + (contract, U256::ZERO, if amsterdam { 15_000 } else { GAS_TRANSFER }), + ] { + let tx = TransactionRequest::default().with_from(from).with_to(to).with_value(value); + for gas_limit in [None, Some(expected)] { + let mut tx = tx.clone(); + tx.gas = gas_limit; + assert_eq!( + provider.estimate_gas(WithOtherFields::new(tx)).await.unwrap(), + expected + ); + } + let short = tx.clone().with_gas_limit(expected - 1); + assert!(provider.estimate_gas(WithOtherFields::new(short)).await.is_err()); + + let receipt = provider + .send_transaction(WithOtherFields::new(tx.with_gas_limit(expected))) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert!(receipt.status()); + assert_eq!(receipt.gas_used, expected); + } + } +} diff --git a/crates/anvil/tests/it/genesis.rs b/crates/anvil/tests/it/genesis.rs index d60cb089128ce..82d74ab498b1d 100644 --- a/crates/anvil/tests/it/genesis.rs +++ b/crates/anvil/tests/it/genesis.rs @@ -2,10 +2,10 @@ use crate::fork::fork_config; use alloy_genesis::Genesis; -use alloy_primitives::{Address, U256}; +use alloy_primitives::{Address, U256, address, b256}; use alloy_provider::Provider; +use alloy_rpc_types::BlockNumberOrTag; use anvil::{NodeConfig, spawn}; -use std::str::FromStr; const GENESIS: &str = r#"{ "config": { @@ -35,6 +35,41 @@ const GENESIS: &str = r#"{ } "#; +const GENESIS_HEADER: &str = r#"{ + "config": { + "chainId": 19763, + "homesteadBlock": 0, + "eip150Block": 0, + "eip155Block": 0, + "eip158Block": 0, + "byzantiumBlock": 0, + "constantinopleBlock": 0, + "petersburgBlock": 0, + "istanbulBlock": 0, + "berlinBlock": 0, + "londonBlock": 0, + "terminalTotalDifficulty": 0, + "mergeNetsplitBlock": 0, + "ethash": {} + }, + "nonce": "0x42", + "timestamp": "0x123", + "extraData": "0x1234", + "gasLimit": "0x989680", + "difficulty": "0x20000", + "mixHash": "0x1111111111111111111111111111111111111111111111111111111111111111", + "coinbase": "0x2222222222222222222222222222222222222222", + "alloc": { + "3333333333333333333333333333333333333333": { + "balance": "0x1", + "nonce": "0x2", + "code": "0x6000" + } + }, + "baseFeePerGas": "0x7" +} +"#; + #[tokio::test(flavor = "multi_thread")] async fn can_apply_genesis() { let genesis: Genesis = serde_json::from_str(GENESIS).unwrap(); @@ -44,7 +79,7 @@ async fn can_apply_genesis() { assert_eq!(provider.get_chain_id().await.unwrap(), 19763u64); - let addr: Address = Address::from_str("71562b71999873db5b286df957af199ec94617f7").unwrap(); + let addr: Address = address!("71562b71999873db5b286df957af199ec94617f7"); let balance = provider.get_balance(addr).await.unwrap(); let expected: U256 = U256::from_str_radix("ffffffffffffffffffffffffff", 16).unwrap(); @@ -54,6 +89,28 @@ async fn can_apply_genesis() { assert_eq!(block_number, 73u64); } +#[tokio::test(flavor = "multi_thread")] +async fn applies_genesis_header() { + let genesis: Genesis = serde_json::from_str(GENESIS_HEADER).unwrap(); + let (_api, handle) = spawn(NodeConfig::test().with_genesis(Some(genesis))).await; + + let block = handle + .http_provider() + .get_block_by_number(BlockNumberOrTag::Earliest) + .await + .unwrap() + .unwrap(); + + assert_eq!( + block.header.hash, + b256!("0x0a6ab47aa1672305a6d2fe01c7e4245b2e80ff8f20da2079c2a62a506410a46d") + ); + assert_eq!( + block.header.state_root, + b256!("0x5b0bc9e85c26ad3ecafbea8de25cf99fca0f65c73572b24aacb5a781fb61815a") + ); +} + // // #[tokio::test(flavor = "multi_thread")] diff --git a/crates/anvil/tests/it/ipc.rs b/crates/anvil/tests/it/ipc.rs index 2529e198c32b1..2c3e966fb09ee 100644 --- a/crates/anvil/tests/it/ipc.rs +++ b/crates/anvil/tests/it/ipc.rs @@ -1,12 +1,19 @@ //! IPC tests use crate::{init_tracing, utils::connect_pubsub}; -use alloy_primitives::U256; +use alloy_primitives::{Address, U256}; use alloy_provider::Provider; use anvil::{NodeConfig, spawn}; use futures::StreamExt; +use std::time::Duration; use tempfile::TempDir; +#[cfg(unix)] +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, + net::UnixStream, +}; + fn ipc_config() -> (Option, NodeConfig) { let path; let dir; @@ -23,7 +30,6 @@ fn ipc_config() -> (Option, NodeConfig) { } #[tokio::test(flavor = "multi_thread")] -#[cfg_attr(windows, ignore = "TODO")] async fn can_get_block_number_ipc() { init_tracing(); @@ -40,7 +46,50 @@ async fn can_get_block_number_ipc() { } #[tokio::test(flavor = "multi_thread")] -#[cfg_attr(windows, ignore = "TODO")] +async fn dropping_handle_closes_ipc_connections() { + let (_dir, config) = ipc_config(); + let (_api, handle) = spawn(config).await; + let provider = handle.ipc_provider().unwrap(); + + provider.get_balance(Address::ZERO).await.unwrap(); + drop(handle); + + tokio::time::timeout(Duration::from_secs(5), async { + while let Ok(Ok(_)) = + tokio::time::timeout(Duration::from_millis(500), provider.get_balance(Address::ZERO)) + .await + {} + }) + .await + .expect("IPC connection continued serving requests after node shutdown"); +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread")] +async fn malformed_delimiter_does_not_corrupt_ipc_connection() { + let (_dir, config) = ipc_config(); + let (_api, handle) = spawn(config).await; + let connection = UnixStream::connect(handle.ipc_path().unwrap()).await.unwrap(); + let (reader, mut writer) = connection.into_split(); + let mut reader = BufReader::new(reader); + let mut response = String::new(); + + writer.write_all(b"}").await.unwrap(); + reader.read_line(&mut response).await.unwrap(); + let malformed = serde_json::from_str::(&response).unwrap(); + assert_eq!(malformed["error"]["code"], -32700); + + response.clear(); + writer + .write_all(br#"{"jsonrpc":"2.0","method":"eth_chainId","params":[],"id":1}"#) + .await + .unwrap(); + reader.read_line(&mut response).await.unwrap(); + let valid = serde_json::from_str::(&response).unwrap(); + assert_eq!(valid["result"], "0x7a69"); +} + +#[tokio::test(flavor = "multi_thread")] async fn test_sub_new_heads_ipc() { init_tracing(); diff --git a/crates/anvil/tests/it/logs.rs b/crates/anvil/tests/it/logs.rs index 6ed631e4a2359..1e6bbc884545c 100644 --- a/crates/anvil/tests/it/logs.rs +++ b/crates/anvil/tests/it/logs.rs @@ -1,14 +1,16 @@ //! log/event related tests use crate::{ - abi::SimpleStorage::{self}, + abi::SimpleStorage, utils::{http_provider_with_signer, ws_provider_with_signer}, }; use alloy_network::EthereumWallet; -use alloy_primitives::{B256, map::B256HashSet}; +use alloy_primitives::{Address, B256, bytes, map::B256HashSet}; use alloy_provider::Provider; -use alloy_rpc_types::{BlockNumberOrTag, Filter}; +use alloy_rpc_types::{BlockId, BlockNumberOrTag, Filter, Log, TransactionRequest}; +use alloy_serde::WithOtherFields; use anvil::{NodeConfig, spawn}; +use anvil_core::types::ReorgOptions; use futures::StreamExt; #[tokio::test(flavor = "multi_thread")] @@ -102,7 +104,7 @@ async fn get_all_events() { let tx = contract.setValue("hi".to_string()).from(account); for _ in 0..num_tx { let tx = tx.send().await.unwrap(); - api.mine_one().await; + api.mine_one().await.unwrap(); tx.get_receipt().await.unwrap(); } @@ -130,7 +132,7 @@ async fn get_all_events() { .collect::, _>>() .unwrap() .into_iter() - .flat_map(|receipt| receipt.unwrap().inner.inner.inner.receipt.logs) + .flat_map(|receipt| receipt.unwrap().inner.inner.inner.receipt.logs.clone()) .collect::>(); assert_eq!(receipt_logs.len(), logs.len()); @@ -141,6 +143,49 @@ async fn get_all_events() { } } +#[tokio::test(flavor = "multi_thread")] +async fn get_block_receipts_assigns_log_indices() { + let (api, handle) = spawn(NodeConfig::test()).await; + let sender = handle.dev_wallets().next().unwrap().address(); + let one_log = Address::random(); + let two_logs = Address::random(); + api.anvil_set_code(one_log, bytes!("60006000a000")).await.unwrap(); + api.anvil_set_code(two_logs, bytes!("60006000a060006000a000")).await.unwrap(); + api.anvil_set_auto_mine(false).await.unwrap(); + + let mut hashes = Vec::new(); + for (nonce, target) in [one_log, Address::random(), two_logs].into_iter().enumerate() { + let transaction = TransactionRequest::default() + .from(sender) + .to(target) + .nonce(nonce as u64) + .gas_limit(30_000); + hashes.push(api.send_transaction(WithOtherFields::new(transaction)).await.unwrap()); + } + api.mine_one().await.unwrap(); + + let receipts = api.block_receipts(BlockId::latest()).await.unwrap().unwrap(); + assert_eq!(receipts.len(), 3); + let log_indices = receipts + .iter() + .map(|receipt| { + receipt + .0 + .inner + .inner + .logs() + .iter() + .map(|log| log.log_index.unwrap()) + .collect::>() + }) + .collect::>(); + assert_eq!(log_indices, [vec![0], vec![], vec![1, 2]]); + + for (hash, receipt) in hashes.into_iter().zip(&receipts) { + assert_eq!(api.transaction_receipt(hash).await.unwrap().as_ref(), Some(receipt)); + } +} + #[tokio::test(flavor = "multi_thread")] async fn watch_events() { let (_api, handle) = spawn(NodeConfig::test()).await; @@ -195,3 +240,103 @@ async fn watch_events() { assert_eq!(log.1.block_hash.unwrap(), hash); } } + +#[tokio::test(flavor = "multi_thread")] +async fn get_filter_changes_reorg_removed() { + let (api, handle) = spawn(NodeConfig::test()).await; + + let wallet = handle.dev_wallets().next().unwrap(); + let account = wallet.address(); + let signer: EthereumWallet = wallet.into(); + + let provider = http_provider_with_signer(&handle.http_endpoint(), signer); + + let contract = + SimpleStorage::deploy(provider.clone(), "initial value".to_string()).await.unwrap(); + + // install a log filter for the contract + let filter = Filter::new().address(*contract.address()); + let filter_id: String = provider.client().request("eth_newFilter", (filter,)).await.unwrap(); + + let _ = contract + .setValue("hi".to_string()) + .from(account) + .send() + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + + let changes: Vec = + provider.client().request("eth_getFilterChanges", (filter_id.clone(),)).await.unwrap(); + assert_eq!(changes.len(), 1); + let log = changes.into_iter().next().unwrap(); + assert!(!log.removed); + + // reorg out the block containing the `setValue` transaction + api.anvil_reorg(ReorgOptions { depth: 1, tx_block_pairs: vec![] }).await.unwrap(); + + // the filter yields the log of the reorged out block again, marked as removed + let changes: Vec = + provider.client().request("eth_getFilterChanges", (filter_id,)).await.unwrap(); + let mut expected = log.clone(); + expected.removed = true; + assert_eq!(changes, vec![expected]); + + // eth_getLogs only returns the log emitted in the constructor, which is still canonical + let logs = provider + .get_logs( + &Filter::new().address(*contract.address()).from_block(BlockNumberOrTag::Earliest), + ) + .await + .unwrap(); + assert_eq!(logs.len(), 1); + assert!(!logs[0].removed); + assert_ne!(logs[0].transaction_hash, log.transaction_hash); +} + +#[tokio::test(flavor = "multi_thread")] +async fn get_logs_unknown_block_hash_returns_error() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + let unknown_hash = B256::with_last_byte(1); + let filter = Filter::new().at_block_hash(unknown_hash); + + let err = provider.get_logs(&filter).await.unwrap_err(); + assert!( + err.to_string().contains("unknown block"), + "expected `unknown block` in error, got: {err}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn get_logs_future_to_block_returns_error() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + let best = provider.get_block_number().await.unwrap(); + let filter = Filter::new().from_block(0).to_block(best + 1); + + let err = provider.get_logs(&filter).await.unwrap_err(); + assert!( + err.to_string().contains("BlockOutOfRangeError"), + "expected `BlockOutOfRangeError` in error, got: {err}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn get_logs_reversed_block_range_returns_error() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + api.mine_one().await.unwrap(); + + let filter = Filter::new().from_block(1).to_block(0); + + let err = provider.get_logs(&filter).await.unwrap_err(); + assert!( + err.to_string().contains("invalid block range params"), + "expected `invalid block range params` in error, got: {err}" + ); +} diff --git a/crates/anvil/tests/it/main.rs b/crates/anvil/tests/it/main.rs index c8c9c437e96af..0dd4ad4693d12 100644 --- a/crates/anvil/tests/it/main.rs +++ b/crates/anvil/tests/it/main.rs @@ -2,13 +2,27 @@ mod abi; mod anvil; mod anvil_api; mod api; +#[cfg(feature = "base")] +mod base; +mod beacon_api; +mod block_index; +mod celo; +mod eip2935; mod eip4844; +mod eip6110; mod eip7702; +mod eip7928; +mod filter; mod fork; +mod fork_bal; +mod fork_chains; mod gas; mod genesis; mod ipc; mod logs; +#[cfg(feature = "monad")] +mod monad; +#[cfg(feature = "optimism")] mod optimism; mod otterscan; mod proof; @@ -16,17 +30,15 @@ mod pubsub; mod revert; mod sign; mod simulate; +#[cfg(feature = "cmd")] mod state; +mod storage_values; +mod tempo; +mod tempo_canary; mod traces; mod transaction; mod txpool; pub mod utils; mod wsapi; -pub(crate) fn init_tracing() { - let _ = tracing_subscriber::FmtSubscriber::builder() - .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) - .try_init(); -} - -fn main() {} +pub use foundry_test_utils::init_tracing; diff --git a/crates/anvil/tests/it/monad.rs b/crates/anvil/tests/it/monad.rs new file mode 100644 index 0000000000000..208dafef4432c --- /dev/null +++ b/crates/anvil/tests/it/monad.rs @@ -0,0 +1,2498 @@ +use alloy_consensus::{ + SidecarBuilder, SignableTransaction, SimpleCoder, Transaction, TxEip1559, TxLegacy, + transaction::TxEip7702, +}; +use alloy_eips::{ + calc_next_block_base_fee, + eip1559::BaseFeeParams, + eip2718::{Decodable2718, Encodable2718}, +}; +use alloy_network::{ + ReceiptResponse, TransactionBuilder, TransactionBuilder4844, TransactionResponse, TxSignerSync, +}; +use alloy_primitives::{Address, B256, Bytes, Signature, TxKind, U256, address, bytes, hex}; +use alloy_provider::{ + Provider, + ext::{DebugApi, TraceApi}, +}; +use alloy_rpc_types::{ + AccessList, AccessListItem, Authorization, BlockId, BlockNumberOrTag, Index, + TransactionRequest, + anvil::Forking, + simulate::{SimBlock, SimulatePayload}, + state::{AccountOverride, StateOverride}, + trace::{ + geth::{GethDebugTracingCallOptions, GethDebugTracingOptions, GethTrace}, + opcode::{BlockOpcodeGas, TransactionOpcodeGas}, + parity::{Action, TraceResults, TraceType}, + }, +}; +use alloy_rpc_types_eth::{AccountInfo as RpcAccountInfo, Bundle, EthCallResponse}; +use alloy_serde::WithOtherFields; +use alloy_signer::Signer; +use alloy_sol_types::{SolCall, SolEvent}; +use anvil::{ + NodeConfig, NodeHandle, + eth::{ + error::BlockchainError, + pool::transactions::{PoolTransaction, TransactionOrder}, + }, + spawn, +}; +use anvil_core::{ + eth::transaction::PendingTransaction, + types::{ReorgOptions, TransactionData}, +}; +use foundry_common::provider::redact_url; +use foundry_evm::{ + hardfork::{FoundryHardfork, MonadHardfork}, + utils::get_blob_params, +}; +use foundry_evm_networks::NetworkConfigs; +use foundry_primitives::FoundryTxEnvelope; +use foundry_test_utils::rpc::spawn_canonical_monad_system_rpc; +use monad_revm::{ + MONAD_TESTNET_CHAIN_ID, + staking::{ + constants::SYSTEM_ADDRESS, + interface::IMonadStaking::{ + EpochChanged, ValidatorRewarded, syscallOnEpochChangeCall, syscallRewardCall, + syscallSnapshotCall, + }, + storage::{ + consensus_view_key, global_slots, val_id_secp_key, validator_key, validator_offsets, + }, + }, +}; +use revm::primitives::hardfork::SpecId; +use std::sync::Arc; +const STAKING_ADDRESS: Address = address!("0x0000000000000000000000000000000000001000"); +const RESERVE_BALANCE_ADDRESS: Address = address!("0x0000000000000000000000000000000000001001"); +const RESERVE_PROBE_ADDRESS: Address = address!("0x0000000000000000000000000000000000002000"); +const BALANCE_PROBE_ADDRESS: Address = address!("0x0000000000000000000000000000000000002001"); +const CHAIN_ID_PROBE_ADDRESS: Address = address!("0x0000000000000000000000000000000000002002"); +const CLZ_PROBE_ADDRESS: Address = address!("0x0000000000000000000000000000000000002003"); +const STORAGE_GAS_PROBE_ADDRESS: Address = address!("0x0000000000000000000000000000000000002004"); +const ROLLBACK_RECIPIENT: Address = address!("0x0000000000000000000000000000000000002005"); +const REORG_RECIPIENT: Address = address!("0x0000000000000000000000000000000000002006"); +const DIPPED_INTO_RESERVE_SELECTOR: [u8; 4] = hex!("3a61584e"); +const RESERVE_RETURN_PROBE_CODE: [u8; 25] = + hex!("633a61584e5f5260205f6004601c5f6110015af15060205ff3"); +const EIP170_CODE_SIZE_LIMIT: usize = 0x6000; +const EIP3860_INITCODE_SIZE_LIMIT: usize = 0xc000; +const EIP7825_TX_GAS_LIMIT_CAP: u64 = 0x1000000; +const MONAD_TX_GAS_LIMIT_CAP: u64 = 30_000_000; + +#[tokio::test(flavor = "multi_thread")] +async fn monad_nine_exposes_reserve_balance_precompile_for_calls() { + let config = NodeConfig::test_monad().with_hardfork(Some(MonadHardfork::MonadNine.into())); + let (_api, handle) = spawn(config).await; + let provider = handle.http_provider(); + + let tx = TransactionRequest::default() + .with_to(RESERVE_BALANCE_ADDRESS) + .with_input(DIPPED_INTO_RESERVE_SELECTOR); + let result = provider.call(tx.into()).await.unwrap(); + + assert_eq!(result, Bytes::from(vec![0; 32])); +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_ten_applies_mip8_storage_gas() { + for (config, hardfork, read_delta, write_delta) in [ + ( + NodeConfig::test_monad().with_hardfork(Some(MonadHardfork::MonadNine.into())), + MonadHardfork::MonadNine, + 0, + 0, + ), + (NodeConfig::test_monad(), MonadHardfork::MonadTen, 8_000, 10_800), + ] { + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + + assert_eq!(api.anvil_node_info().await.unwrap().hard_fork, hardfork.to_string()); + + api.anvil_set_code(STORAGE_GAS_PROBE_ADDRESS, storage_read_probe_code(127)).await.unwrap(); + let same_page_read = + storage_probe_gas(provider.call(storage_gas_probe_call()).await.unwrap()); + api.anvil_set_code(STORAGE_GAS_PROBE_ADDRESS, storage_read_probe_code(128)).await.unwrap(); + let different_page_read = + storage_probe_gas(provider.call(storage_gas_probe_call()).await.unwrap()); + assert_eq!(different_page_read - same_page_read, read_delta); + + api.anvil_set_code(STORAGE_GAS_PROBE_ADDRESS, storage_write_probe_code(1)).await.unwrap(); + let same_page_write = + storage_probe_gas(provider.call(storage_gas_probe_call()).await.unwrap()); + api.anvil_set_code(STORAGE_GAS_PROBE_ADDRESS, storage_write_probe_code(128)).await.unwrap(); + let different_page_write = + storage_probe_gas(provider.call(storage_gas_probe_call()).await.unwrap()); + assert_eq!(different_page_write - same_page_write, write_delta); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_ten_deduplicates_access_list_storage_pages() { + let one = B256::with_last_byte(1); + let two = B256::with_last_byte(2); + let page_one = B256::with_last_byte(129); + + for (config, expected_same_page_keys, gas_delta) in [ + ( + NodeConfig::test_monad().with_hardfork(Some(MonadHardfork::MonadNine.into())), + vec![one, two], + 0, + ), + (NodeConfig::test_monad(), vec![one], 1_900), + ] { + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + + api.anvil_set_code(STORAGE_GAS_PROBE_ADDRESS, storage_access_list_probe_code(2, 1)) + .await + .unwrap(); + let same_page = provider.create_access_list(&storage_gas_probe_call()).await.unwrap(); + assert_eq!( + same_page.access_list, + AccessList::from(vec![AccessListItem { + address: STORAGE_GAS_PROBE_ADDRESS, + storage_keys: expected_same_page_keys, + }]) + ); + + api.anvil_set_code(STORAGE_GAS_PROBE_ADDRESS, storage_access_list_probe_code(129, 1)) + .await + .unwrap(); + let different_page = provider.create_access_list(&storage_gas_probe_call()).await.unwrap(); + assert_eq!( + different_page.access_list, + AccessList::from(vec![AccessListItem { + address: STORAGE_GAS_PROBE_ADDRESS, + storage_keys: vec![one, page_one], + }]) + ); + assert_eq!(different_page.gas_used - same_page.gas_used, U256::from(gas_delta)); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_ten_rpc_simulations_apply_mip8_storage_gas() { + for (config, read_delta, write_delta) in [ + (NodeConfig::test_monad().with_hardfork(Some(MonadHardfork::MonadNine.into())), 0, 0), + (NodeConfig::test_monad(), 8_000, 10_800), + ] { + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + + api.anvil_set_code(STORAGE_GAS_PROBE_ADDRESS, storage_read_probe_code(127)).await.unwrap(); + let same_page_read_estimate = + provider.estimate_gas(storage_gas_probe_call()).await.unwrap(); + let same_page_read_trace: TraceResults = provider + .client() + .request( + "trace_call", + (storage_gas_probe_call(), vec![TraceType::Trace], BlockId::latest()), + ) + .await + .unwrap(); + + api.anvil_set_code(STORAGE_GAS_PROBE_ADDRESS, storage_read_probe_code(128)).await.unwrap(); + let different_page_read_estimate = + provider.estimate_gas(storage_gas_probe_call()).await.unwrap(); + let different_page_read_trace: TraceResults = provider + .client() + .request( + "trace_call", + (storage_gas_probe_call(), vec![TraceType::Trace], BlockId::latest()), + ) + .await + .unwrap(); + + assert_eq!(different_page_read_estimate - same_page_read_estimate, read_delta); + assert_eq!( + root_trace_gas(&different_page_read_trace) - root_trace_gas(&same_page_read_trace), + read_delta + ); + + api.anvil_set_code(STORAGE_GAS_PROBE_ADDRESS, storage_write_probe_code(1)).await.unwrap(); + let same_page_write_estimate = + provider.estimate_gas(storage_gas_probe_call()).await.unwrap(); + let same_page_write_trace: TraceResults = provider + .client() + .request( + "trace_call", + (storage_gas_probe_call(), vec![TraceType::Trace], BlockId::latest()), + ) + .await + .unwrap(); + + api.anvil_set_code(STORAGE_GAS_PROBE_ADDRESS, storage_write_probe_code(128)).await.unwrap(); + let different_page_write_estimate = + provider.estimate_gas(storage_gas_probe_call()).await.unwrap(); + let different_page_write_trace: TraceResults = provider + .client() + .request( + "trace_call", + (storage_gas_probe_call(), vec![TraceType::Trace], BlockId::latest()), + ) + .await + .unwrap(); + + assert_eq!(different_page_write_estimate - same_page_write_estimate, write_delta); + assert_eq!( + root_trace_gas(&different_page_write_trace) - root_trace_gas(&same_page_write_trace), + write_delta + ); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_call_uses_parent_sender_context() { + let (api, handle) = spawn(monad_nine_config()).await; + let provider = handle.http_provider(); + let sender = provider.get_accounts().await.unwrap()[0]; + + api.anvil_set_code(RESERVE_PROBE_ADDRESS, RESERVE_RETURN_PROBE_CODE.into()).await.unwrap(); + api.anvil_set_balance(sender, mon(13)).await.unwrap(); + + provider + .send_transaction( + TransactionRequest::default() + .with_from(sender) + .with_to(RESERVE_PROBE_ADDRESS) + .with_value(mon(1)) + .with_gas_limit(100_000) + .into(), + ) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + + let result = provider + .call( + TransactionRequest::default() + .with_from(sender) + .with_to(RESERVE_PROBE_ADDRESS) + .with_value(mon(3)) + .with_gas_limit(100_000) + .into(), + ) + .await + .unwrap(); + + assert_eq!(result, Bytes::from(U256::ONE.to_be_bytes::<32>())); +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_simulate_tracks_current_block_senders() { + let (api, handle) = spawn(monad_nine_config()).await; + let sender = handle.http_provider().get_accounts().await.unwrap()[0]; + + api.anvil_set_code(RESERVE_PROBE_ADDRESS, RESERVE_RETURN_PROBE_CODE.into()).await.unwrap(); + api.anvil_set_balance(sender, mon(12)).await.unwrap(); + + let calls = [mon(2), mon(1)] + .into_iter() + .map(|value| { + TransactionRequest::default() + .with_from(sender) + .with_to(RESERVE_PROBE_ADDRESS) + .with_value(value) + .with_gas_limit(100_000) + }) + .collect(); + let blocks = api + .simulate_v1( + SimulatePayload { + block_state_calls: vec![SimBlock { calls, ..Default::default() }], + ..Default::default() + }, + None, + ) + .await + .unwrap(); + + assert_eq!(blocks[0].calls[0].return_data, Bytes::from(U256::ZERO.to_be_bytes::<32>())); + assert_eq!(blocks[0].calls[1].return_data, Bytes::from(U256::ONE.to_be_bytes::<32>())); +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_simulate_ages_reserve_participants_across_empty_blocks() { + let (api, handle) = spawn(monad_nine_config()).await; + let sender = handle.http_provider().get_accounts().await.unwrap()[0]; + + api.anvil_set_code(RESERVE_PROBE_ADDRESS, RESERVE_RETURN_PROBE_CODE.into()).await.unwrap(); + api.anvil_set_balance(sender, mon(12)).await.unwrap(); + + let request = |value| { + TransactionRequest::default() + .with_from(sender) + .with_to(RESERVE_PROBE_ADDRESS) + .with_value(value) + .with_gas_limit(100_000) + }; + let blocks = api + .simulate_v1( + SimulatePayload { + block_state_calls: vec![ + SimBlock { calls: vec![request(mon(2))], ..Default::default() }, + SimBlock::default(), + SimBlock::default(), + SimBlock { calls: vec![request(mon(1))], ..Default::default() }, + ], + ..Default::default() + }, + None, + ) + .await + .unwrap(); + + assert_eq!(blocks[0].calls[0].return_data, Bytes::from(U256::ZERO.to_be_bytes::<32>())); + assert_eq!(blocks[3].calls[0].return_data, Bytes::from(U256::ZERO.to_be_bytes::<32>())); +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_call_many_ages_reserve_participants_across_empty_bundles() { + let (api, handle) = spawn(monad_nine_config()).await; + let provider = handle.http_provider(); + let sender = provider.get_accounts().await.unwrap()[0]; + api.anvil_set_code(RESERVE_PROBE_ADDRESS, RESERVE_RETURN_PROBE_CODE.into()).await.unwrap(); + api.anvil_set_balance(sender, mon(12)).await.unwrap(); + + let request = |value| { + WithOtherFields::new( + TransactionRequest::default() + .with_from(sender) + .with_to(RESERVE_PROBE_ADDRESS) + .with_value(value) + .with_gas_limit(100_000), + ) + }; + let bundles = vec![ + Bundle { transactions: vec![request(mon(2))], block_override: None }, + Bundle { transactions: Vec::new(), block_override: None }, + Bundle { transactions: Vec::new(), block_override: None }, + Bundle { transactions: vec![request(mon(1))], block_override: None }, + ]; + + let response: Vec> = + provider.client().request("eth_callMany", (bundles,)).await.unwrap(); + + assert_eq!( + response[0][0].clone().ensure_ok().unwrap(), + Bytes::from(U256::ZERO.to_be_bytes::<32>()) + ); + assert_eq!( + response[3][0].clone().ensure_ok().unwrap(), + Bytes::from(U256::ZERO.to_be_bytes::<32>()) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_mining_tracks_current_and_ancestor_senders() { + let (api, handle) = spawn(monad_nine_config()).await; + let provider = handle.http_provider(); + let accounts = provider.get_accounts().await.unwrap(); + let parent_sender = accounts[0]; + let grandparent_sender = accounts[1]; + let current_sender = accounts[2]; + let initial_balance = U256::from(12_000_000_000_000_000_000u128); + let first_value = U256::from(2_000_000_000_000_000_000u128); + let second_value = U256::from(1_000_000_000_000_000_000u128); + + // Calls dippedIntoReserve(), then stores the returned bool at the calldata-provided slot. + api.anvil_set_code( + RESERVE_PROBE_ADDRESS, + bytes!("633a61584e5f5260205f6004601c5f6110015af1505f515f355500"), + ) + .await + .unwrap(); + for sender in [parent_sender, grandparent_sender, current_sender] { + api.anvil_set_balance(sender, initial_balance).await.unwrap(); + } + + provider + .send_transaction(reserve_probe_tx(parent_sender, 0, 0, first_value).into()) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert_eq!( + provider.get_storage_at(RESERVE_PROBE_ADDRESS, U256::ZERO).await.unwrap(), + U256::ZERO + ); + + provider + .send_transaction(reserve_probe_tx(parent_sender, 1, 1, second_value).into()) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert_eq!(provider.get_storage_at(RESERVE_PROBE_ADDRESS, U256::ONE).await.unwrap(), U256::ONE); + + provider + .send_transaction(reserve_probe_tx(grandparent_sender, 0, 2, first_value).into()) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert_eq!( + provider.get_storage_at(RESERVE_PROBE_ADDRESS, U256::from(2)).await.unwrap(), + U256::ZERO + ); + api.mine_one().await.unwrap(); + provider + .send_transaction(reserve_probe_tx(grandparent_sender, 1, 3, second_value).into()) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert_eq!( + provider.get_storage_at(RESERVE_PROBE_ADDRESS, U256::from(3)).await.unwrap(), + U256::ONE + ); + + api.anvil_set_auto_mine(false).await.unwrap(); + let _ = provider + .send_transaction(reserve_probe_tx(current_sender, 0, 4, first_value).into()) + .await + .unwrap(); + let second_pending = provider + .send_transaction(reserve_probe_tx(current_sender, 1, 5, second_value).into()) + .await + .unwrap(); + api.mine_one().await.unwrap(); + let second_receipt = second_pending.get_receipt().await.unwrap(); + assert_eq!( + provider.get_storage_at(RESERVE_PROBE_ADDRESS, U256::from(4)).await.unwrap(), + U256::ZERO + ); + assert_eq!( + provider.get_storage_at(RESERVE_PROBE_ADDRESS, U256::from(5)).await.unwrap(), + U256::ONE + ); + + let replay: TraceResults = provider + .client() + .request( + "trace_replayTransaction", + (second_receipt.transaction_hash, vec![TraceType::StateDiff]), + ) + .await + .unwrap(); + let slot = B256::with_last_byte(5); + let state_diff = replay.state_diff.unwrap(); + let delta = &state_diff.get(&RESERVE_PROBE_ADDRESS).unwrap().storage[&slot]; + let replayed_value = + delta.as_added().copied().or_else(|| delta.as_changed().map(|change| change.to)).unwrap(); + assert_eq!(replayed_value, B256::with_last_byte(1)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_reorg_replays_protocol_system_envelopes() { + const BLOCK_AUTHOR: Address = Address::repeat_byte(0x11); + const VALIDATOR_AUTH: Address = Address::repeat_byte(0x22); + const VALIDATOR_ID: u64 = 7; + const SYSTEM_NONCE: u64 = 11; + + let config = monad_nine_config().with_transaction_order(TransactionOrder::Fifo); + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + let accounts = provider.get_accounts().await.unwrap(); + let participant = accounts[0]; + let recipient = accounts[1]; + let transfer = U256::from(123u64); + let reward = mon(25); + let initial_system_balance = mon(100); + let initial_staking_balance = mon(3); + let initial_recipient_balance = provider.get_balance(recipient).await.unwrap(); + + api.anvil_set_nonce(SYSTEM_ADDRESS, U256::from(SYSTEM_NONCE)).await.unwrap(); + api.anvil_set_balance(SYSTEM_ADDRESS, initial_system_balance).await.unwrap(); + api.anvil_set_balance(STAKING_ADDRESS, initial_staking_balance).await.unwrap(); + api.anvil_set_storage_at( + STAKING_ADDRESS, + val_id_secp_key(&BLOCK_AUTHOR), + left_aligned_u64(VALIDATOR_ID).into(), + ) + .await + .unwrap(); + api.anvil_set_storage_at(STAKING_ADDRESS, consensus_view_key(VALIDATOR_ID, 0), mon(100).into()) + .await + .unwrap(); + api.anvil_set_storage_at( + STAKING_ADDRESS, + validator_key(VALIDATOR_ID, validator_offsets::ADDRESS_FLAGS), + address_and_flags(VALIDATOR_AUTH, 0).into(), + ) + .await + .unwrap(); + + // Keep the configured staking state below the common ancestor of the one-block reorg. + api.mine_one().await.unwrap(); + api.mine_one().await.unwrap(); + let original_height = provider.get_block_number().await.unwrap(); + + let reward_tx = monad_protocol_transaction( + SYSTEM_NONCE, + reward, + syscallRewardCall { blockAuthor: BLOCK_AUTHOR }.abi_encode().into(), + ); + let ordinary_tx = TransactionRequest::default() + .with_from(participant) + .with_to(recipient) + .with_nonce(0) + .with_value(transfer) + .with_gas_limit(21_000) + .with_gas_price(2_000_000_000); + let snapshot_tx = monad_protocol_transaction( + SYSTEM_NONCE + 1, + U256::ZERO, + syscallSnapshotCall {}.abi_encode().into(), + ); + let epoch_tx = monad_protocol_transaction( + SYSTEM_NONCE + 2, + U256::ZERO, + syscallOnEpochChangeCall { epoch: 1 }.abi_encode().into(), + ); + + api.anvil_reorg(ReorgOptions { + depth: 1, + tx_block_pairs: vec![ + (TransactionData::JSON(reward_tx.into()), 0), + (TransactionData::JSON(ordinary_tx.into()), 0), + (TransactionData::JSON(snapshot_tx.into()), 0), + (TransactionData::JSON(epoch_tx.into()), 0), + ], + }) + .await + .unwrap(); + + assert_eq!(provider.get_block_number().await.unwrap(), original_height); + let block = + provider.get_block_by_number(BlockNumberOrTag::Latest).full().await.unwrap().unwrap(); + let transactions = block.transactions.as_transactions().unwrap(); + assert_eq!(transactions.len(), 4); + assert_eq!(transactions[0].from(), SYSTEM_ADDRESS); + assert_eq!(transactions[1].from(), participant); + assert_eq!(transactions[2].from(), SYSTEM_ADDRESS); + assert_eq!(transactions[3].from(), SYSTEM_ADDRESS); + assert_eq!(transactions[0].to(), Some(STAKING_ADDRESS)); + assert_eq!(transactions[1].to(), Some(recipient)); + assert_eq!(transactions[2].to(), Some(STAKING_ADDRESS)); + assert_eq!(transactions[3].to(), Some(STAKING_ADDRESS)); + assert_eq!(&transactions[0].input()[..4], syscallRewardCall::SELECTOR); + assert_eq!(&transactions[2].input()[..4], syscallSnapshotCall::SELECTOR); + assert_eq!(&transactions[3].input()[..4], syscallOnEpochChangeCall::SELECTOR); + + let hashes = transactions.iter().map(TransactionResponse::tx_hash).collect::>(); + let mut receipts = Vec::with_capacity(hashes.len()); + for hash in hashes { + receipts.push(provider.get_transaction_receipt(hash).await.unwrap().unwrap()); + } + for receipt in &receipts { + assert!(receipt.status()); + } + for receipt in [&receipts[0], &receipts[2], &receipts[3]] { + assert_eq!(receipt.gas_used, 0); + assert_eq!(receipt.effective_gas_price, 0); + } + assert_eq!(block.header.gas_used, receipts[1].gas_used); + assert_eq!(receipts[0].inner.inner.logs().len(), 1); + assert_eq!(receipts[0].inner.inner.logs()[0].topics()[0], ValidatorRewarded::SIGNATURE_HASH); + assert!(receipts[2].inner.inner.logs().is_empty()); + assert_eq!(receipts[3].inner.inner.logs().len(), 1); + assert_eq!(receipts[3].inner.inner.logs()[0].topics()[0], EpochChanged::SIGNATURE_HASH); + + assert_eq!(provider.get_transaction_count(SYSTEM_ADDRESS).await.unwrap(), SYSTEM_NONCE + 3); + assert_eq!(provider.get_balance(SYSTEM_ADDRESS).await.unwrap(), initial_system_balance); + assert_eq!( + provider.get_balance(STAKING_ADDRESS).await.unwrap(), + initial_staking_balance + reward + ); + assert_eq!( + provider.get_balance(recipient).await.unwrap(), + initial_recipient_balance + transfer + ); + assert_eq!( + provider.get_storage_at(STAKING_ADDRESS, global_slots::PROPOSER_VAL_ID).await.unwrap(), + left_aligned_u64(VALIDATOR_ID) + ); + assert_eq!( + provider + .get_storage_at( + STAKING_ADDRESS, + validator_key(VALIDATOR_ID, validator_offsets::UNCLAIMED_REWARDS), + ) + .await + .unwrap(), + reward + ); + assert_eq!( + provider.get_storage_at(STAKING_ADDRESS, global_slots::IN_BOUNDARY).await.unwrap(), + U256::ZERO + ); + assert_eq!( + provider.get_storage_at(STAKING_ADDRESS, global_slots::EPOCH).await.unwrap(), + left_aligned_u64(1) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_reorg_replays_raw_protocol_envelope_with_non_system_signature() { + const SYSTEM_NONCE: u64 = 4; + + let (api, handle) = spawn(monad_nine_config()).await; + let provider = handle.http_provider(); + let chain_id = provider.get_chain_id().await.unwrap(); + let wallet = handle.dev_wallets().next().unwrap().with_chain_id(Some(chain_id)); + + api.anvil_set_nonce(SYSTEM_ADDRESS, U256::from(SYSTEM_NONCE)).await.unwrap(); + api.mine_one().await.unwrap(); + api.mine_one().await.unwrap(); + + let mut transaction = TxLegacy { + chain_id: Some(chain_id), + nonce: SYSTEM_NONCE, + gas_price: 0, + gas_limit: 0, + to: TxKind::Call(STAKING_ADDRESS), + value: U256::ZERO, + input: syscallSnapshotCall {}.abi_encode().into(), + }; + let signature = wallet.sign_transaction_sync(&mut transaction).unwrap(); + let transaction = transaction.into_signed(signature); + let mut encoded = Vec::new(); + transaction.eip2718_encode(&mut encoded); + + let decoded = FoundryTxEnvelope::decode_2718(&mut encoded.as_slice()).unwrap(); + let normally_recovered = PendingTransaction::new(decoded).unwrap(); + let recovered_sender = *normally_recovered.sender(); + assert_ne!(recovered_sender, SYSTEM_ADDRESS); + let transaction_hash = *normally_recovered.hash(); + + api.anvil_reorg(ReorgOptions { + depth: 1, + tx_block_pairs: vec![(TransactionData::Raw(encoded.clone().into()), 0)], + }) + .await + .unwrap(); + + let block_number = provider.get_block_number().await.unwrap(); + let block = provider + .get_block_by_number(BlockNumberOrTag::Number(block_number)) + .full() + .await + .unwrap() + .unwrap(); + let block_hash = block.header.hash; + let transactions = block.transactions.as_transactions().unwrap(); + assert_eq!(transactions.len(), 1); + assert_eq!(transactions[0].tx_hash(), transaction_hash); + assert_eq!(transactions[0].from(), SYSTEM_ADDRESS); + + let receipt = provider.get_transaction_receipt(transaction_hash).await.unwrap().unwrap(); + assert!(receipt.status()); + assert_eq!(receipt.gas_used, 0); + assert_eq!(receipt.effective_gas_price, 0); + assert_eq!(provider.get_transaction_count(SYSTEM_ADDRESS).await.unwrap(), SYSTEM_NONCE + 1); + + let local_block = api.backend.get_block(block_number).unwrap(); + let mut round_trip = Vec::new(); + local_block.body.transactions[0].encode_2718(&mut round_trip); + assert_eq!(round_trip, encoded); + + let state = api.serialized_state(false).await.unwrap(); + let participants = &state.monad_block_participants[&block_hash]; + assert!(participants.contains(&SYSTEM_ADDRESS)); + assert!(!participants.contains(&recovered_sender)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_reorg_replays_raw_protocol_envelope_with_unrecoverable_signature() { + const SYSTEM_NONCE: u64 = 4; + + let (api, handle) = spawn(monad_nine_config()).await; + let provider = handle.http_provider(); + let chain_id = provider.get_chain_id().await.unwrap(); + + api.anvil_set_nonce(SYSTEM_ADDRESS, U256::from(SYSTEM_NONCE)).await.unwrap(); + api.mine_one().await.unwrap(); + api.mine_one().await.unwrap(); + + let transaction = TxLegacy { + chain_id: Some(chain_id), + nonce: SYSTEM_NONCE, + gas_price: 0, + gas_limit: 0, + to: TxKind::Call(STAKING_ADDRESS), + value: U256::ZERO, + input: syscallSnapshotCall {}.abi_encode().into(), + } + .into_signed(Signature::new(U256::ZERO, U256::ZERO, false)); + let transaction_hash = *transaction.hash(); + let mut encoded = Vec::new(); + transaction.eip2718_encode(&mut encoded); + + let decoded = FoundryTxEnvelope::decode_2718(&mut encoded.as_slice()).unwrap(); + assert!(PendingTransaction::new(decoded).is_err()); + + api.anvil_reorg(ReorgOptions { + depth: 1, + tx_block_pairs: vec![(TransactionData::Raw(encoded.clone().into()), 0)], + }) + .await + .unwrap(); + + let block_number = provider.get_block_number().await.unwrap(); + let block = provider + .get_block_by_number(BlockNumberOrTag::Number(block_number)) + .full() + .await + .unwrap() + .unwrap(); + let block_hash = block.header.hash; + let transactions = block.transactions.as_transactions().unwrap(); + assert_eq!(transactions.len(), 1); + assert_eq!(transactions[0].tx_hash(), transaction_hash); + assert_eq!(transactions[0].from(), SYSTEM_ADDRESS); + + let receipt = provider.get_transaction_receipt(transaction_hash).await.unwrap().unwrap(); + assert!(receipt.status()); + assert_eq!(receipt.gas_used, 0); + assert_eq!(receipt.effective_gas_price, 0); + assert_eq!(provider.get_transaction_count(SYSTEM_ADDRESS).await.unwrap(), SYSTEM_NONCE + 1); + + let local_block = api.backend.get_block(block_number).unwrap(); + let mut round_trip = Vec::new(); + local_block.body.transactions[0].encode_2718(&mut round_trip); + assert_eq!(round_trip, encoded); + + let state = api.serialized_state(false).await.unwrap(); + let participants = &state.monad_block_participants[&block_hash]; + assert_eq!(participants.len(), 1); + assert!(participants.contains(&SYSTEM_ADDRESS)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_reorg_rolls_back_failed_protocol_prestate() { + const UNKNOWN_AUTHOR: Address = Address::repeat_byte(0x33); + const SYSTEM_NONCE: u64 = 7; + + let (api, handle) = spawn(monad_nine_config()).await; + let provider = handle.http_provider(); + let accounts = provider.get_accounts().await.unwrap(); + let participant = accounts[0]; + let recipient = accounts[1]; + let reward = mon(25); + let initial_staking_balance = mon(3); + let initial_recipient_balance = provider.get_balance(recipient).await.unwrap(); + + api.anvil_set_nonce(SYSTEM_ADDRESS, U256::from(SYSTEM_NONCE)).await.unwrap(); + api.anvil_set_balance(STAKING_ADDRESS, initial_staking_balance).await.unwrap(); + api.mine_one().await.unwrap(); + api.mine_one().await.unwrap(); + + let failed_reward = monad_protocol_transaction( + SYSTEM_NONCE, + reward, + syscallRewardCall { blockAuthor: UNKNOWN_AUTHOR }.abi_encode().into(), + ); + let ordinary_tx = TransactionRequest::default() + .with_from(participant) + .with_to(recipient) + .with_nonce(0) + .with_value(U256::ONE) + .with_gas_limit(21_000) + .with_gas_price(2_000_000_000); + // Reuse the failed envelope's nonce. This can only succeed if replay restores the protocol + // nonce and mint before the next candidate is validated and executed. + let snapshot_tx = monad_protocol_transaction( + SYSTEM_NONCE, + U256::ZERO, + syscallSnapshotCall {}.abi_encode().into(), + ); + let epoch_tx = monad_protocol_transaction( + SYSTEM_NONCE + 1, + U256::ZERO, + syscallOnEpochChangeCall { epoch: 1 }.abi_encode().into(), + ); + + api.anvil_reorg(ReorgOptions { + depth: 1, + tx_block_pairs: vec![ + (TransactionData::JSON(failed_reward.into()), 0), + (TransactionData::JSON(ordinary_tx.into()), 0), + (TransactionData::JSON(snapshot_tx.into()), 0), + (TransactionData::JSON(epoch_tx.into()), 0), + ], + }) + .await + .unwrap(); + + let block = + provider.get_block_by_number(BlockNumberOrTag::Latest).full().await.unwrap().unwrap(); + let transactions = block.transactions.as_transactions().unwrap(); + assert_eq!(transactions.len(), 3); + assert_eq!(transactions[0].from(), participant); + assert_eq!(transactions[1].from(), SYSTEM_ADDRESS); + assert_eq!(transactions[2].from(), SYSTEM_ADDRESS); + assert_eq!(&transactions[1].input()[..4], syscallSnapshotCall::SELECTOR); + assert_eq!(&transactions[2].input()[..4], syscallOnEpochChangeCall::SELECTOR); + + let ordinary_hash = transactions[0].tx_hash(); + let parity_traces = provider.trace_transaction(ordinary_hash).await.unwrap(); + assert_eq!(parity_traces.len(), 1); + let Action::Call(call) = &parity_traces[0].trace.action else { + panic!("expected ordinary transfer call trace") + }; + assert_eq!(call.from, participant); + assert_eq!(call.to, recipient); + assert_eq!(call.value, U256::ONE); + + let debug_trace = provider + .debug_trace_transaction(ordinary_hash, GethDebugTracingOptions::default()) + .await + .unwrap(); + let GethTrace::Default(debug_trace) = debug_trace else { + panic!("expected default transaction trace") + }; + assert!(debug_trace.struct_logs.is_empty()); + + assert_eq!(provider.get_transaction_count(SYSTEM_ADDRESS).await.unwrap(), SYSTEM_NONCE + 2); + assert_eq!(provider.get_balance(STAKING_ADDRESS).await.unwrap(), initial_staking_balance); + assert_eq!( + provider.get_balance(recipient).await.unwrap(), + initial_recipient_balance + U256::ONE + ); + assert_eq!( + provider.get_storage_at(STAKING_ADDRESS, global_slots::PROPOSER_VAL_ID).await.unwrap(), + U256::ZERO + ); + assert_eq!( + provider.get_storage_at(STAKING_ADDRESS, global_slots::EPOCH).await.unwrap(), + left_aligned_u64(1) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_reorg_rejects_malformed_and_non_replay_system_envelopes() { + const SYSTEM_NONCE: u64 = 5; + + let (api, handle) = spawn(monad_nine_config()).await; + let provider = handle.http_provider(); + let accounts = provider.get_accounts().await.unwrap(); + let participant = accounts[0]; + let recipient = accounts[1]; + let chain_id = provider.get_chain_id().await.unwrap(); + let initial_recipient_balance = provider.get_balance(recipient).await.unwrap(); + let initial_staking_balance = provider.get_balance(STAKING_ADDRESS).await.unwrap(); + + api.anvil_set_nonce(SYSTEM_ADDRESS, U256::from(SYSTEM_NONCE)).await.unwrap(); + api.mine_one().await.unwrap(); + api.mine_one().await.unwrap(); + let original_height = provider.get_block_number().await.unwrap(); + + let malformed_json = TransactionRequest::default() + .with_from(SYSTEM_ADDRESS) + .with_to(BALANCE_PROBE_ADDRESS) + .with_nonce(SYSTEM_NONCE) + .with_input(Bytes::from(syscallSnapshotCall {}.abi_encode())) + .with_gas_limit(0) + .with_gas_price(0); + let error = api + .anvil_reorg(ReorgOptions { + depth: 1, + tx_block_pairs: vec![(TransactionData::JSON(malformed_json.into()), 0)], + }) + .await + .unwrap_err(); + assert!(matches!(error, BlockchainError::NoSignerAvailable)); + assert_eq!(provider.get_block_number().await.unwrap(), original_height); + + let mut malformed_raw = TxLegacy { + chain_id: Some(chain_id), + nonce: 0, + gas_price: 0, + gas_limit: 0, + to: TxKind::Call(BALANCE_PROBE_ADDRESS), + value: U256::ZERO, + input: syscallSnapshotCall {}.abi_encode().into(), + }; + let wallet = handle.dev_wallets().next().unwrap().with_chain_id(Some(chain_id)); + let signature = wallet.sign_transaction_sync(&mut malformed_raw).unwrap(); + let mut malformed_raw_encoded = Vec::new(); + malformed_raw.into_signed(signature).eip2718_encode(&mut malformed_raw_encoded); + + let ordinary_tx = TransactionRequest::default() + .with_from(participant) + .with_to(recipient) + .with_nonce(0) + .with_value(U256::ONE) + .with_gas_limit(21_000) + .with_gas_price(2_000_000_000); + api.anvil_reorg(ReorgOptions { + depth: 1, + tx_block_pairs: vec![ + (TransactionData::Raw(malformed_raw_encoded.into()), 0), + (TransactionData::JSON(ordinary_tx.into()), 0), + ], + }) + .await + .unwrap(); + + let block = + provider.get_block_by_number(BlockNumberOrTag::Latest).full().await.unwrap().unwrap(); + let transactions = block.transactions.as_transactions().unwrap(); + assert_eq!(transactions.len(), 1); + assert_eq!(transactions[0].from(), participant); + assert_eq!(transactions[0].to(), Some(recipient)); + assert_eq!(provider.get_transaction_count(SYSTEM_ADDRESS).await.unwrap(), SYSTEM_NONCE); + assert_eq!(provider.get_balance(STAKING_ADDRESS).await.unwrap(), initial_staking_balance); + assert_eq!( + provider.get_balance(recipient).await.unwrap(), + initial_recipient_balance + U256::ONE + ); + + api.anvil_impersonate_account(SYSTEM_ADDRESS).await.unwrap(); + let error = provider + .send_transaction( + monad_protocol_transaction( + SYSTEM_NONCE, + U256::ZERO, + syscallSnapshotCall {}.abi_encode().into(), + ) + .into(), + ) + .await + .unwrap_err() + .to_string(); + assert!( + error.contains("intrinsic gas too low") || error.contains("max fee per gas less"), + "unexpected non-replay validation error: {error}" + ); + assert_eq!(provider.get_transaction_count(SYSTEM_ADDRESS).await.unwrap(), SYSTEM_NONCE); + assert_eq!( + provider.get_storage_at(STAKING_ADDRESS, global_slots::IN_BOUNDARY).await.unwrap(), + U256::ZERO + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_fork_transaction_hash_replays_protocol_system_prefix_and_target() { + const BLOCK_AUTHOR: Address = Address::repeat_byte(0x11); + const VALIDATOR_AUTH: Address = Address::repeat_byte(0x22); + const VALIDATOR_ID: u64 = 7; + + let origin_config = NodeConfig::test() + .with_chain_id(Some(MONAD_TESTNET_CHAIN_ID)) + .with_genesis_timestamp(Some( + MonadHardfork::MonadNine.testnet_activation_timestamp().unwrap(), + )) + .with_transaction_order(TransactionOrder::Fifo); + let (origin_api, origin_handle) = spawn(origin_config).await; + let origin_provider = origin_handle.http_provider(); + let participant = origin_provider.get_accounts().await.unwrap()[0]; + let reward = mon(25); + let initial_system_balance = mon(100); + let initial_staking_balance = mon(3); + + origin_api.anvil_impersonate_account(SYSTEM_ADDRESS).await.unwrap(); + origin_api.anvil_set_nonce(SYSTEM_ADDRESS, U256::from(11)).await.unwrap(); + origin_api.anvil_set_balance(SYSTEM_ADDRESS, initial_system_balance).await.unwrap(); + origin_api.anvil_set_balance(STAKING_ADDRESS, initial_staking_balance).await.unwrap(); + origin_api.anvil_set_code(RESERVE_PROBE_ADDRESS, bytes!("60015f355500")).await.unwrap(); + origin_api + .anvil_set_code( + BALANCE_PROBE_ADDRESS, + bytes!("730000000000000000000000000000000000001000315f5260205ff3"), + ) + .await + .unwrap(); + origin_api.anvil_set_code(CHAIN_ID_PROBE_ADDRESS, bytes!("465f5260205ff3")).await.unwrap(); + origin_api + .anvil_set_storage_at( + STAKING_ADDRESS, + val_id_secp_key(&BLOCK_AUTHOR), + left_aligned_u64(VALIDATOR_ID).into(), + ) + .await + .unwrap(); + origin_api + .anvil_set_storage_at(STAKING_ADDRESS, consensus_view_key(VALIDATOR_ID, 0), mon(100).into()) + .await + .unwrap(); + origin_api + .anvil_set_storage_at(STAKING_ADDRESS, consensus_view_key(VALIDATOR_ID, 1), B256::ZERO) + .await + .unwrap(); + origin_api + .anvil_set_storage_at( + STAKING_ADDRESS, + validator_key(VALIDATOR_ID, validator_offsets::ADDRESS_FLAGS), + address_and_flags(VALIDATOR_AUTH, 0).into(), + ) + .await + .unwrap(); + + origin_api.mine_one().await.unwrap(); + let parent_block = origin_provider.get_block_number().await.unwrap(); + origin_api.anvil_set_auto_mine(false).await.unwrap(); + + let reward_tx = |nonce| { + TransactionRequest::default() + .with_from(SYSTEM_ADDRESS) + .with_to(STAKING_ADDRESS) + .with_nonce(nonce) + .with_value(reward) + .with_input(Bytes::from(syscallRewardCall { blockAuthor: BLOCK_AUTHOR }.abi_encode())) + .with_gas_limit(1_000_000) + .with_gas_price(2_000_000_000) + }; + let first_reward = origin_provider.send_transaction(reward_tx(11).into()).await.unwrap(); + let first_reward_hash = *first_reward.tx_hash(); + let first_probe = origin_provider + .send_transaction( + reserve_probe_tx(participant, 0, 0, mon(2)).with_gas_price(2_000_000_000).into(), + ) + .await + .unwrap(); + let first_probe_hash = *first_probe.tx_hash(); + let second_probe = origin_provider + .send_transaction( + reserve_probe_tx(participant, 1, 1, mon(1)).with_gas_price(2_000_000_000).into(), + ) + .await + .unwrap(); + let second_probe_hash = *second_probe.tx_hash(); + let target_reward = origin_provider.send_transaction(reward_tx(12).into()).await.unwrap(); + let target_reward_hash = *target_reward.tx_hash(); + origin_api.mine_one().await.unwrap(); + + let source_block = origin_api + .block_by_number_full(BlockNumberOrTag::Number(parent_block + 1)) + .await + .unwrap() + .unwrap(); + assert_eq!( + source_block + .transactions + .as_transactions() + .unwrap() + .iter() + .map(TransactionResponse::tx_hash) + .collect::>(), + [first_reward_hash, first_probe_hash, second_probe_hash, target_reward_hash] + ); + + let endpoint = + spawn_canonical_monad_system_rpc(origin_handle.http_endpoint(), first_reward_hash).await; + let endpoint = spawn_canonical_monad_system_rpc(endpoint, target_reward_hash).await; + let canonical_provider = foundry_common::provider::get_http_provider(&endpoint); + let canonical_target = + canonical_provider.get_transaction_by_hash(target_reward_hash).await.unwrap().unwrap(); + assert_eq!(canonical_target.from(), SYSTEM_ADDRESS); + let canonical_block = canonical_provider + .get_block_by_number(BlockNumberOrTag::Number(parent_block + 1)) + .full() + .await + .unwrap() + .unwrap(); + let canonical_target = canonical_block + .transactions + .as_transactions() + .unwrap() + .iter() + .find(|transaction| transaction.tx_hash() == target_reward_hash) + .unwrap(); + assert_eq!(canonical_target.from(), SYSTEM_ADDRESS); + let config = monad_nine_config() + .with_chain_id(Some(1u64)) + .with_genesis_accounts(Vec::new()) + .with_no_storage_caching(true) + .with_eth_rpc_url(Some(endpoint.clone())) + .with_fork_transaction_hash(Some(target_reward_hash)) + .with_no_mining(true); + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + assert_eq!(provider.get_chain_id().await.unwrap(), 1); + + let replayed_target = + provider.get_transaction_by_hash(target_reward_hash).await.unwrap().unwrap(); + assert_eq!(replayed_target.from(), SYSTEM_ADDRESS); + assert_eq!(replayed_target.tx_hash(), target_reward_hash); + let first_receipt = provider.get_transaction_receipt(first_reward_hash).await.unwrap().unwrap(); + let target_receipt = + provider.get_transaction_receipt(target_reward_hash).await.unwrap().unwrap(); + let replay_block_hash = target_receipt.block_hash.unwrap(); + let replay_block_number = target_receipt.block_number.unwrap(); + for receipt in [&first_receipt, &target_receipt] { + assert!(receipt.status()); + assert_eq!(receipt.gas_used, 0); + assert_eq!(receipt.effective_gas_price, 0); + } + for probe_hash in [first_probe_hash, second_probe_hash] { + assert!(provider.get_transaction_receipt(probe_hash).await.unwrap().unwrap().status()); + } + + assert_eq!(provider.get_transaction_count(SYSTEM_ADDRESS).await.unwrap(), 13); + assert_eq!(provider.get_balance(SYSTEM_ADDRESS).await.unwrap(), initial_system_balance); + assert_eq!( + provider.get_balance(STAKING_ADDRESS).await.unwrap(), + initial_staking_balance + reward * U256::from(2) + ); + assert_eq!(provider.get_transaction_count(participant).await.unwrap(), 2); + assert_eq!( + provider.get_storage_at(RESERVE_PROBE_ADDRESS, U256::ZERO).await.unwrap(), + U256::ONE + ); + assert_eq!(provider.get_storage_at(RESERVE_PROBE_ADDRESS, U256::ONE).await.unwrap(), U256::ONE); + assert_eq!( + provider.get_storage_at(STAKING_ADDRESS, global_slots::PROPOSER_VAL_ID).await.unwrap(), + left_aligned_u64(VALIDATOR_ID) + ); + assert_eq!( + provider + .get_storage_at( + STAKING_ADDRESS, + validator_key(VALIDATOR_ID, validator_offsets::UNCLAIMED_REWARDS), + ) + .await + .unwrap(), + reward * U256::from(2) + ); + + let replay: TraceResults = provider + .client() + .request("trace_replayTransaction", (target_reward_hash, vec![TraceType::StateDiff])) + .await + .unwrap(); + assert!(replay.state_diff.unwrap().contains_key(&STAKING_ADDRESS)); + + let _: GethTrace = provider + .client() + .request("debug_traceTransaction", (target_reward_hash, GethDebugTracingOptions::default())) + .await + .unwrap(); + + let transaction_opcode_gas: Option = provider + .client() + .request("trace_transactionOpcodeGas", (target_reward_hash,)) + .await + .unwrap(); + assert_eq!(transaction_opcode_gas.unwrap().transaction_hash, target_reward_hash); + + let opcode_gas: Option = provider + .client() + .request("trace_blockOpcodeGas", (BlockId::hash(replay_block_hash),)) + .await + .unwrap(); + let opcode_gas = opcode_gas.unwrap(); + assert_eq!(opcode_gas.transactions.len(), 4); + assert_eq!( + opcode_gas + .transactions + .iter() + .map(|transaction| transaction.transaction_hash) + .collect::>(), + [first_reward_hash, first_probe_hash, second_probe_hash, target_reward_hash] + ); + + let staking_after_target: Option = provider + .client() + .request( + "debug_accountInfoAt", + (BlockId::hash(replay_block_hash), Index::from(3), STAKING_ADDRESS), + ) + .await + .unwrap(); + assert_eq!( + staking_after_target.unwrap().balance, + initial_staking_balance + reward * U256::from(2) + ); + + let balance_trace = provider + .debug_trace_call( + WithOtherFields::new(TransactionRequest::default().to(BALANCE_PROBE_ADDRESS)), + BlockId::number(replay_block_number), + GethDebugTracingCallOptions::default().with_tx_index(3), + ) + .await + .unwrap(); + let GethTrace::Default(balance_trace) = balance_trace else { + panic!("expected default balance probe trace") + }; + assert_eq!(U256::from_be_slice(&balance_trace.return_value), initial_staking_balance + reward); + + let chain_id_trace = provider + .debug_trace_call( + WithOtherFields::new(TransactionRequest::default().to(CHAIN_ID_PROBE_ADDRESS)), + BlockId::number(replay_block_number), + GethDebugTracingCallOptions::default().with_tx_index(3), + ) + .await + .unwrap(); + let GethTrace::Default(chain_id_trace) = chain_id_trace else { + panic!("expected default chain ID probe trace") + }; + assert_eq!( + U256::from_be_slice(&chain_id_trace.return_value), + U256::from(MONAD_TESTNET_CHAIN_ID) + ); + + let state = api.serialized_state(true).await.unwrap(); + for reward_hash in [first_reward_hash, target_reward_hash] { + let reward_info = state + .transactions + .iter() + .find(|transaction| transaction.info.transaction_hash == reward_hash) + .unwrap(); + assert_eq!(reward_info.info.from, SYSTEM_ADDRESS); + } + let serialized_replay_block_hash = state + .transactions + .iter() + .find(|transaction| transaction.info.transaction_hash == target_reward_hash) + .unwrap() + .block_hash; + assert_eq!(serialized_replay_block_hash, replay_block_hash); + let replay_profile = state.monad_block_replay_profiles[&replay_block_hash]; + assert_eq!(replay_profile.execution_chain_id, MONAD_TESTNET_CHAIN_ID); + assert_eq!(replay_profile.hardfork, MonadHardfork::MonadNine); + + let loaded_config = monad_eight_config() + .with_chain_id(Some(1u64)) + .with_genesis_accounts(Vec::new()) + .with_no_storage_caching(true) + .with_eth_rpc_url(Some(endpoint)) + .with_fork_block_number(Some(parent_block)) + .with_transaction_block_keeper(Some(1usize)) + .with_init_state(Some(state)) + .with_no_mining(true); + let (loaded_api, loaded_handle) = spawn(loaded_config).await; + let loaded_provider = loaded_handle.http_provider(); + let loaded_state = loaded_api.serialized_state(false).await.unwrap(); + for reward_hash in [first_reward_hash, target_reward_hash] { + let reward_info = loaded_state + .transactions + .iter() + .find(|transaction| transaction.info.transaction_hash == reward_hash) + .unwrap(); + assert_eq!(reward_info.info.from, SYSTEM_ADDRESS); + } + let loaded_replay_profile = loaded_state.monad_block_replay_profiles[&replay_block_hash]; + assert_eq!(loaded_replay_profile, replay_profile); + let mut state_overrides = StateOverride::default(); + state_overrides.insert( + CLZ_PROBE_ADDRESS, + AccountOverride { code: Some(bytes!("60011e60005260206000f3")), ..Default::default() }, + ); + let clz_trace = loaded_provider + .debug_trace_call( + WithOtherFields::new(TransactionRequest::default().to(CLZ_PROBE_ADDRESS)), + BlockId::number(replay_block_number), + GethDebugTracingCallOptions::default() + .with_tx_index(3) + .with_state_overrides(state_overrides), + ) + .await + .unwrap(); + let GethTrace::Default(clz_trace) = clz_trace else { + panic!("expected default CLZ probe trace") + }; + assert!(!clz_trace.failed); + assert_eq!(U256::from_be_slice(&clz_trace.return_value), U256::from(255)); + let loaded_opcode_gas: Option = loaded_provider + .client() + .request("trace_transactionOpcodeGas", (target_reward_hash,)) + .await + .unwrap(); + assert_eq!(loaded_opcode_gas.unwrap().transaction_hash, target_reward_hash); + + loaded_api.mine_one().await.unwrap(); + let local_block_hash = loaded_provider + .get_block_by_number(BlockNumberOrTag::Latest) + .await + .unwrap() + .unwrap() + .header + .hash; + let pruned_state = loaded_api.serialized_state(false).await.unwrap(); + assert_eq!(pruned_state.monad_block_replay_profiles[&replay_block_hash], replay_profile); + let local_profile = pruned_state.monad_block_replay_profiles[&local_block_hash]; + assert_eq!(local_profile.execution_chain_id, 1); + assert_eq!(local_profile.hardfork, MonadHardfork::MonadEight); + let participants = &pruned_state.monad_block_participants[&replay_block_hash]; + assert!(participants.contains(&SYSTEM_ADDRESS)); + assert!(participants.contains(&participant)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_fork_transaction_hash_preserves_hardfork_on_chain_id_collision() { + let (_origin_api, origin_handle) = spawn(monad_eight_config().with_chain_id(Some(1u64))).await; + let origin_provider = origin_handle.http_provider(); + let accounts = origin_provider.get_accounts().await.unwrap(); + let receipt = origin_provider + .send_transaction( + TransactionRequest::default() + .with_from(accounts[0]) + .with_to(accounts[1]) + .with_value(U256::ONE) + .into(), + ) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + + let config = NodeConfig::test() + .with_no_storage_caching(true) + .with_eth_rpc_url(Some(origin_handle.http_endpoint())) + .with_fork_transaction_hash(Some(receipt.transaction_hash)) + .with_no_mining(true); + let (api, handle) = spawn(config).await; + + assert_eq!(api.backend.hardfork(), FoundryHardfork::Monad(MonadHardfork::MonadEight)); + assert!(handle.http_provider().call(reserve_balance_call()).await.unwrap().is_empty()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_fork_transaction_hash_rollback_restores_inferred_profile() { + let (_origin, endpoint, transaction_hash) = monad_rollback_boundary_origin().await; + let config = NodeConfig::test_monad() + .with_chain_id(Some(1u64)) + .with_no_storage_caching(true) + .with_eth_rpc_url(Some(endpoint)) + .with_fork_transaction_hash(Some(transaction_hash)) + .with_no_mining(true); + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + + assert_eq!(api.anvil_node_info().await.unwrap().hard_fork, "MonadNine"); + assert_eq!(api.backend.spec_id(), SpecId::OSAKA); + assert_eq!(provider.call(reserve_balance_call()).await.unwrap(), Bytes::from(vec![0; 32])); + assert_eq!(provider.get_balance(ROLLBACK_RECIPIENT).await.unwrap(), U256::ONE); + + let replay_block_number = provider.get_block_number().await.unwrap(); + let parent = provider + .get_block_by_number(BlockNumberOrTag::Number(replay_block_number - 1)) + .await + .unwrap() + .unwrap(); + api.anvil_rollback(Some(1)).await.unwrap(); + + assert_eq!(api.anvil_node_info().await.unwrap().hard_fork, "MonadEight"); + assert_eq!(api.backend.spec_id(), SpecId::PRAGUE); + assert!(provider.call(reserve_balance_call()).await.unwrap().is_empty()); + assert_eq!(provider.get_balance(ROLLBACK_RECIPIENT).await.unwrap(), U256::ZERO); + assert_eq!(api.backend.chain_id(), U256::ONE); + assert_eq!( + api.backend.blob_params(), + get_blob_params(MONAD_TESTNET_CHAIN_ID, parent.header.timestamp) + ); + assert_eq!( + api.backend.evm_env().read().block_env.basefee, + parent.header.base_fee_per_gas.unwrap() + ); + assert_eq!( + api.backend.base_fee(), + calc_next_block_base_fee( + parent.header.gas_used, + parent.header.gas_limit, + parent.header.base_fee_per_gas.unwrap(), + BaseFeeParams::ethereum(), + ) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_fork_transaction_hash_reorg_restores_inferred_profile() { + let (_origin, endpoint, transaction_hash) = monad_rollback_boundary_origin().await; + let config = NodeConfig::test_monad() + .with_no_storage_caching(true) + .with_eth_rpc_url(Some(endpoint)) + .with_fork_transaction_hash(Some(transaction_hash)) + .with_no_mining(true); + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + + assert_eq!(api.anvil_node_info().await.unwrap().hard_fork, "MonadNine"); + assert_eq!(provider.get_balance(ROLLBACK_RECIPIENT).await.unwrap(), U256::ONE); + let from = provider.get_accounts().await.unwrap()[0]; + let replacement = + TransactionRequest::default().from(from).to(REORG_RECIPIENT).value(U256::from(2)); + api.anvil_reorg(ReorgOptions { + depth: 1, + tx_block_pairs: vec![(TransactionData::JSON(replacement.into()), 0)], + }) + .await + .unwrap(); + + assert_eq!(api.anvil_node_info().await.unwrap().hard_fork, "MonadEight"); + assert_eq!(api.backend.spec_id(), SpecId::PRAGUE); + assert!(provider.call(reserve_balance_call()).await.unwrap().is_empty()); + assert_eq!(provider.get_balance(ROLLBACK_RECIPIENT).await.unwrap(), U256::ZERO); + assert_eq!(provider.get_balance(REORG_RECIPIENT).await.unwrap(), U256::from(2)); + let block = + provider.get_block_by_number(BlockNumberOrTag::Latest).full().await.unwrap().unwrap(); + assert_eq!(block.transactions.len(), 1); + assert_eq!( + api.backend.blob_params(), + get_blob_params(MONAD_TESTNET_CHAIN_ID, block.header.timestamp) + ); + assert_eq!( + api.backend.base_fee(), + calc_next_block_base_fee( + block.header.gas_used, + block.header.gas_limit, + block.header.base_fee_per_gas.unwrap(), + BaseFeeParams::ethereum(), + ) + ); + let state = api.serialized_state(false).await.unwrap(); + assert_eq!( + state.monad_block_replay_profiles[&block.header.hash].hardfork, + MonadHardfork::MonadEight + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_fork_transaction_hash_rollback_preserves_explicit_hardfork() { + let (_origin, endpoint, transaction_hash) = monad_rollback_boundary_origin().await; + let config = NodeConfig::test_monad() + .with_hardfork(Some(MonadHardfork::MonadNine.into())) + .with_no_storage_caching(true) + .with_eth_rpc_url(Some(endpoint)) + .with_fork_transaction_hash(Some(transaction_hash)) + .with_no_mining(true); + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + + api.anvil_rollback(Some(1)).await.unwrap(); + + assert_eq!(api.anvil_node_info().await.unwrap().hard_fork, "MonadNine"); + assert_eq!(api.backend.spec_id(), SpecId::OSAKA); + assert_eq!(provider.call(reserve_balance_call()).await.unwrap(), Bytes::from(vec![0; 32])); +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_mining_tracks_eip7702_authorities() { + let (api, handle) = spawn(monad_nine_config()).await; + let provider = handle.http_provider(); + let wallets = handle.dev_wallets().collect::>(); + let authority = wallets[0].address(); + let initial_balance = U256::from(12_000_000_000_000_000_000u128); + + api.anvil_set_code( + RESERVE_PROBE_ADDRESS, + bytes!("633a61584e5f5260205f6004601c5f6110015af1505f515f355500"), + ) + .await + .unwrap(); + api.anvil_set_balance(authority, initial_balance).await.unwrap(); + + let authorization = + Authorization { chain_id: U256::from(31337), address: Address::ZERO, nonce: 0 }; + let signature = Signature::new(U256::ZERO, U256::ZERO, true); + api.anvil_impersonate_signature(signature.as_bytes().into(), authority).await.unwrap(); + let mut tx = TxEip7702 { + chain_id: 31337, + nonce: 0, + gas_limit: 100_000, + max_fee_per_gas: 2_000_000_000, + max_priority_fee_per_gas: 1_000_000_000, + to: wallets[2].address(), + authorization_list: vec![authorization.into_signed(signature)], + ..Default::default() + }; + let signature = wallets[1].sign_transaction_sync(&mut tx).unwrap(); + let mut encoded = Vec::new(); + tx.into_signed(signature).eip2718_encode(&mut encoded); + let authorization = + PendingTransaction::new(FoundryTxEnvelope::decode_2718(&mut encoded.as_slice()).unwrap()) + .unwrap(); + let authorization_hash = *authorization.hash(); + + let mut probe = TxEip1559 { + chain_id: 31337, + nonce: 1, + gas_limit: 100_000, + max_fee_per_gas: 2_000_000_000, + max_priority_fee_per_gas: 1_000_000_000, + to: TxKind::Call(RESERVE_PROBE_ADDRESS), + value: U256::from(3_000_000_000_000_000_000u128), + input: Bytes::copy_from_slice(B256::with_last_byte(6).as_slice()), + ..Default::default() + }; + let signature = wallets[0].sign_transaction_sync(&mut probe).unwrap(); + let mut encoded = Vec::new(); + probe.into_signed(signature).eip2718_encode(&mut encoded); + let probe = + PendingTransaction::new(FoundryTxEnvelope::decode_2718(&mut encoded.as_slice()).unwrap()) + .unwrap(); + let probe_hash = *probe.hash(); + + let outcome = api + .backend + .mine_block(vec![ + Arc::new(PoolTransaction::new(authorization)), + Arc::new(PoolTransaction::new(probe)), + ]) + .await + .unwrap(); + assert_eq!(outcome.included.len(), 2); + assert!(outcome.invalid.is_empty()); + + assert_eq!( + provider.get_storage_at(RESERVE_PROBE_ADDRESS, U256::from(6)).await.unwrap(), + U256::ONE + ); + + let replay: TraceResults = provider + .client() + .request("trace_replayTransaction", (probe_hash, vec![TraceType::StateDiff])) + .await + .unwrap(); + let slot = B256::with_last_byte(6); + let state_diff = replay.state_diff.unwrap(); + let delta = &state_diff.get(&RESERVE_PROBE_ADDRESS).unwrap().storage[&slot]; + let replayed_value = + delta.as_added().copied().or_else(|| delta.as_changed().map(|change| change.to)).unwrap(); + assert_eq!(replayed_value, B256::with_last_byte(1)); + + let state = api.serialized_state(false).await.unwrap(); + let authorization_block_hash = state + .transactions + .iter() + .find(|transaction| transaction.info.transaction_hash == authorization_hash) + .unwrap() + .block_hash; + assert!(state.monad_block_participants[&authorization_block_hash].contains(&authority)); + + let (loaded_api, _) = + spawn(monad_nine_config().with_genesis_accounts(Vec::new()).with_init_state(Some(state))) + .await; + let loaded_state = loaded_api.serialized_state(false).await.unwrap(); + assert!(loaded_state.monad_block_participants[&authorization_block_hash].contains(&authority)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_nine_config_reports_monad_precompiles() { + let (api, _handle) = spawn(monad_nine_config()).await; + let config = api.config().unwrap(); + + assert_eq!(config.current.precompiles.get("MonadStaking"), Some(&STAKING_ADDRESS)); + assert_eq!( + config.current.precompiles.get("MonadReserveBalance"), + Some(&RESERVE_BALANCE_ADDRESS) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_eight_config_filters_reserve_balance_precompile() { + let (api, _handle) = spawn(monad_eight_config()).await; + let config = api.config().unwrap(); + + assert_eq!(config.current.precompiles.get("MonadStaking"), Some(&STAKING_ADDRESS)); + assert!(!config.current.precompiles.contains_key("MonadReserveBalance")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_can_mine_contract_larger_than_eip170_limit() { + let config = monad_eight_config(); + let (_api, handle) = spawn(config).await; + let provider = handle.http_provider(); + let from = provider.get_accounts().await.unwrap()[0]; + let runtime_len = EIP170_CODE_SIZE_LIMIT + 1; + + let tx = TransactionRequest::default() + .with_from(from) + .with_input(large_contract_init_code(runtime_len)) + .with_gas_limit(10_000_000); + let receipt = provider.send_transaction(tx.into()).await.unwrap().get_receipt().await.unwrap(); + + assert!(receipt.status()); + let contract = receipt.contract_address.expect("deployment should create a contract"); + let code = provider.get_code_at(contract).await.unwrap(); + assert_eq!(code.len(), runtime_len); +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_can_mine_contract_larger_than_eip3860_initcode_limit() { + let config = monad_eight_config(); + let (_api, handle) = spawn(config).await; + let provider = handle.http_provider(); + let from = provider.get_accounts().await.unwrap()[0]; + let init_code = large_contract_init_code(EIP3860_INITCODE_SIZE_LIMIT); + assert!(init_code.len() > EIP3860_INITCODE_SIZE_LIMIT); + + let tx = TransactionRequest::default() + .with_from(from) + .with_input(init_code) + .with_gas_limit(25_000_000); + let receipt = provider.send_transaction(tx.into()).await.unwrap().get_receipt().await.unwrap(); + + assert!(receipt.status()); + let contract = receipt.contract_address.expect("deployment should create a contract"); + let code = provider.get_code_at(contract).await.unwrap(); + assert_eq!(code.len(), EIP3860_INITCODE_SIZE_LIMIT); +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_allows_tx_gas_limit_above_eip7825_cap() { + let config = monad_eight_config().enable_tx_gas_limit(true); + let (_api, handle) = spawn(config).await; + let provider = handle.http_provider(); + let accounts = provider.get_accounts().await.unwrap(); + let gas_limit = 20_000_000; + assert!(gas_limit > EIP7825_TX_GAS_LIMIT_CAP); + + let tx = TransactionRequest::default() + .with_from(accounts[0]) + .with_to(accounts[1]) + .with_value(U256::ONE) + .with_gas_limit(gas_limit); + let receipt = provider.send_transaction(tx.into()).await.unwrap().get_receipt().await.unwrap(); + + assert!(receipt.status()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_rejects_tx_gas_limit_above_monad_cap() { + let config = monad_eight_config().enable_tx_gas_limit(true).with_gas_limit(Some(40_000_000)); + let (_api, handle) = spawn(config).await; + let provider = handle.http_provider(); + let accounts = provider.get_accounts().await.unwrap(); + + let tx = TransactionRequest::default() + .with_from(accounts[0]) + .with_to(accounts[1]) + .with_value(U256::ONE) + .with_gas_limit(MONAD_TX_GAS_LIMIT_CAP + 1); + let err = provider.send_transaction(tx.into()).await.unwrap_err().to_string(); + + assert!(err.contains("tx.gas_limit > resolved tx gas limit cap"), "unexpected error: {err}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_omitted_gas_fallback_uses_resolved_tx_gas_cap() { + let config = monad_eight_config().enable_tx_gas_limit(true).with_gas_limit(Some(40_000_000)); + let (_api, handle) = spawn(config).await; + let provider = handle.http_provider(); + let from = provider.get_accounts().await.unwrap()[0]; + + let tx = TransactionRequest::default().with_from(from).with_input(bytes!("60006000fd")); + let pending = provider.send_transaction(tx.into()).await.unwrap(); + let sent = provider.get_transaction_by_hash(*pending.tx_hash()).await.unwrap().unwrap(); + + assert_eq!(sent.inner.gas_limit(), MONAD_TX_GAS_LIMIT_CAP); +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_pool_accepts_balance_covering_effective_fee() { + let (api, handle) = spawn(monad_nine_config()).await; + let provider = handle.http_provider(); + let accounts = provider.get_accounts().await.unwrap(); + let gas_limit = 21_000u64; + let base_fee = 1_000_000_000u128; + let priority_fee = 1_000_000_000u128; + let max_fee = 100_000_000_000u128; + let effective_fee = U256::from(gas_limit) * U256::from(base_fee + priority_fee); + let max_fee_cost = U256::from(gas_limit) * U256::from(max_fee); + assert!(effective_fee < max_fee_cost); + + api.anvil_set_next_block_base_fee_per_gas(U256::from(base_fee)).await.unwrap(); + api.anvil_set_balance(accounts[0], effective_fee).await.unwrap(); + + let tx = TransactionRequest::default() + .with_from(accounts[0]) + .with_to(accounts[1]) + .with_gas_limit(gas_limit) + .with_max_fee_per_gas(max_fee) + .with_max_priority_fee_per_gas(priority_fee); + let receipt = provider.send_transaction(tx.into()).await.unwrap().get_receipt().await.unwrap(); + + assert!(receipt.status()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_pool_admits_unaffordable_value_for_failed_receipt() { + let (api, handle) = spawn(monad_nine_config()).await; + let provider = handle.http_provider(); + let accounts = provider.get_accounts().await.unwrap(); + let gas_limit = 21_000u64; + let base_fee = 1_000_000_000u128; + let priority_fee = 1_000_000_000u128; + let max_fee = 100_000_000_000u128; + let effective_fee = U256::from(gas_limit) * U256::from(base_fee + priority_fee); + + api.anvil_set_next_block_base_fee_per_gas(U256::from(base_fee)).await.unwrap(); + api.anvil_set_balance(accounts[0], effective_fee).await.unwrap(); + let recipient_balance = provider.get_balance(accounts[1]).await.unwrap(); + + let tx = TransactionRequest::default() + .with_from(accounts[0]) + .with_to(accounts[1]) + .with_value(U256::ONE) + .with_gas_limit(gas_limit) + .with_max_fee_per_gas(max_fee) + .with_max_priority_fee_per_gas(priority_fee); + let receipt = provider.send_transaction(tx.into()).await.unwrap().get_receipt().await.unwrap(); + + assert!(!receipt.status()); + assert_eq!(provider.get_balance(accounts[1]).await.unwrap(), recipient_balance); +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_rejects_eip4844_blob_transactions() { + let config = monad_nine_config(); + let (_api, handle) = spawn(config).await; + let provider = handle.http_provider(); + let accounts = provider.get_accounts().await.unwrap(); + let eip1559_est = provider.estimate_eip1559_fees().await.unwrap(); + let gas_price = provider.get_gas_price().await.unwrap(); + let sidecar: SidecarBuilder = SidecarBuilder::from_slice(b"Hello World"); + let sidecar = sidecar.build().unwrap(); + + let tx = TransactionRequest::default() + .with_from(accounts[0]) + .with_to(accounts[1]) + .with_nonce(0) + .with_max_fee_per_blob_gas(gas_price + 1) + .with_max_fee_per_gas(eip1559_est.max_fee_per_gas) + .with_max_priority_fee_per_gas(eip1559_est.max_priority_fee_per_gas) + .with_blob_sidecar_4844(sidecar) + .with_gas_limit(100_000) + .with_value(U256::from(5)); + let err = provider.send_transaction(WithOtherFields::new(tx)).await.unwrap_err().to_string(); + + assert!( + err.contains("EIP-4844 blob transactions are not supported on Monad"), + "unexpected error: {err}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_fork_uses_monad_execution() { + let (origin_api, origin_handle) = spawn(monad_nine_config()).await; + origin_api.mine_one().await.unwrap(); + + let config = monad_nine_config().with_eth_rpc_url(Some(origin_handle.http_endpoint())); + let (fork_api, fork_handle) = spawn(config).await; + + let node_info = fork_api.anvil_node_info().await.unwrap(); + assert_eq!(node_info.network, Some("monad".to_string())); + assert_eq!(node_info.hard_fork, "MonadNine"); + assert!(node_info.fork_config.fork_block_number.is_some()); + + let tx = TransactionRequest::default() + .with_to(RESERVE_BALANCE_ADDRESS) + .with_input(DIPPED_INTO_RESERVE_SELECTOR); + let result = fork_handle.http_provider().call(tx.into()).await.unwrap(); + + assert_eq!(result, Bytes::from(vec![0; 32])); +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_fork_preserves_source_chain_with_execution_override() { + let (_origin, endpoint) = monad_boundary_origin().await; + let config = NodeConfig::test() + .with_chain_id(Some(1u64)) + .with_eth_rpc_url(Some(endpoint)) + .with_fork_block_number(Some(1u64)); + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + + let node_info = api.anvil_node_info().await.unwrap(); + assert_eq!(node_info.network, Some("monad".to_string())); + assert_eq!(node_info.hard_fork, "MonadNine"); + assert_eq!(node_info.environment.chain_id, 1); + assert_eq!( + api.anvil_metadata().await.unwrap().forked_network.unwrap().chain_id, + MONAD_TESTNET_CHAIN_ID + ); + assert_eq!(provider.call(reserve_balance_call()).await.unwrap(), Bytes::from(vec![0; 32])); + + api.anvil_reset(Some(Forking { json_rpc_url: None, block_number: Some(2) })).await.unwrap(); + + let node_info = api.anvil_node_info().await.unwrap(); + assert_eq!(node_info.network, Some("monad".to_string())); + assert_eq!(node_info.hard_fork, "MonadNine"); + assert_eq!(node_info.environment.chain_id, 1); + assert_eq!( + api.anvil_metadata().await.unwrap().forked_network.unwrap().chain_id, + MONAD_TESTNET_CHAIN_ID + ); + assert_eq!(provider.call(reserve_balance_call()).await.unwrap(), Bytes::from(vec![0; 32])); + + let nested_config = NodeConfig::test() + .with_no_storage_caching(true) + .with_eth_rpc_url(Some(handle.http_endpoint())) + .with_fork_block_number(Some(2u64)); + let (nested_api, nested_handle) = spawn(nested_config).await; + let nested_info = nested_api.anvil_node_info().await.unwrap(); + assert_eq!(nested_info.network.as_deref(), Some("monad")); + assert_eq!(nested_info.environment.chain_id, 1); + assert_eq!( + nested_api.anvil_metadata().await.unwrap().forked_network.unwrap().chain_id, + MONAD_TESTNET_CHAIN_ID + ); + assert_eq!( + nested_handle.http_provider().call(reserve_balance_call()).await.unwrap(), + Bytes::from(vec![0; 32]) + ); + + let (_ethereum_api, ethereum_handle) = + spawn(NodeConfig::test().with_chain_id(Some(1u64))).await; + let err = api + .anvil_reset(Some(Forking { + json_rpc_url: Some(ethereum_handle.http_endpoint()), + block_number: None, + })) + .await + .unwrap_err() + .to_string(); + assert!(err.contains("cannot reset Anvil across network families (monad -> ethereum)")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_fork_infers_anvil_identity_and_exact_hardfork() { + let activation = MonadHardfork::MonadNine.mainnet_activation_timestamp().unwrap(); + for (chain_id, genesis_timestamp) in [ + (1u64, Some(activation)), + (143u64, Some(activation)), + (31337u64, None), + (98_765_432u64, None), + ] { + let mut origin_config = NodeConfig::test_monad() + .with_chain_id(Some(chain_id)) + .with_hardfork(Some(MonadHardfork::MonadEight.into())); + if let Some(genesis_timestamp) = genesis_timestamp { + origin_config = origin_config.with_genesis_timestamp(Some(genesis_timestamp)); + } + let (_origin_api, origin_handle) = spawn(origin_config).await; + let fork_config = NodeConfig::test() + .with_no_storage_caching(true) + .with_eth_rpc_url(Some(origin_handle.http_endpoint())) + .with_fork_block_number(Some(0u64)); + let (fork_api, fork_handle) = spawn(fork_config).await; + + let node_info = fork_api.anvil_node_info().await.unwrap(); + assert_eq!(node_info.network, Some("monad".to_string())); + assert_eq!(node_info.hard_fork, MonadHardfork::MonadEight.to_string()); + assert_eq!(node_info.environment.chain_id, chain_id); + assert_eq!( + fork_api.anvil_metadata().await.unwrap().forked_network.unwrap().chain_id, + chain_id + ); + let result = fork_handle.http_provider().call(reserve_balance_call()).await.unwrap(); + assert!(result.is_empty()); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_fork_execution_chain_override_does_not_select_network_family() { + let (_origin_api, origin_handle) = + spawn(monad_nine_config().with_chain_id(Some(MONAD_TESTNET_CHAIN_ID))).await; + let endpoint = origin_handle.http_endpoint(); + let execution_chain_id = 4217u64; + let configs = [ + NodeConfig::test() + .with_chain_id(Some(execution_chain_id)) + .with_eth_rpc_url(Some(endpoint.clone())), + NodeConfig::test().with_eth_rpc_url(Some(endpoint)).with_chain_id(Some(execution_chain_id)), + ]; + + for config in configs { + let config = config.with_no_storage_caching(true).with_fork_block_number(Some(0u64)); + let (api, handle) = spawn(config).await; + + let node_info = api.anvil_node_info().await.unwrap(); + assert_eq!(node_info.network.as_deref(), Some("monad")); + assert_eq!(node_info.environment.chain_id, execution_chain_id); + assert_eq!( + api.anvil_metadata().await.unwrap().forked_network.unwrap().chain_id, + MONAD_TESTNET_CHAIN_ID + ); + assert_eq!( + handle.http_provider().call(reserve_balance_call()).await.unwrap(), + Bytes::from(vec![0; 32]) + ); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_fork_preserves_explicit_network_selection() { + let (_ethereum_api, ethereum_handle) = + spawn(NodeConfig::test().with_chain_id(Some(1u64))).await; + let explicit_monad = monad_nine_config() + .with_no_storage_caching(true) + .with_eth_rpc_url(Some(ethereum_handle.http_endpoint())) + .with_fork_block_number(Some(0u64)); + let (monad_api, monad_handle) = spawn(explicit_monad).await; + + assert_eq!(monad_api.anvil_node_info().await.unwrap().network.as_deref(), Some("monad")); + assert_eq!( + monad_handle.http_provider().call(reserve_balance_call()).await.unwrap(), + Bytes::from(vec![0; 32]) + ); + monad_api + .anvil_reset(Some(Forking { json_rpc_url: None, block_number: Some(0) })) + .await + .unwrap(); + monad_api + .anvil_reset(Some(Forking { + json_rpc_url: Some(ethereum_handle.http_endpoint()), + block_number: Some(0), + })) + .await + .unwrap(); + assert_eq!( + monad_handle.http_provider().call(reserve_balance_call()).await.unwrap(), + Bytes::from(vec![0; 32]) + ); + + let (_monad_api, monad_origin) = spawn(monad_nine_config().with_chain_id(Some(1u64))).await; + let config = NodeConfig::test() + .with_networks(NetworkConfigs::with_ethereum()) + .with_no_storage_caching(true) + .with_eth_rpc_url(Some(monad_origin.http_endpoint())) + .with_fork_block_number(Some(0u64)); + let (api, handle) = spawn(config).await; + + assert_eq!(api.anvil_node_info().await.unwrap().network.as_deref(), Some("ethereum")); + assert!(handle.http_provider().call(reserve_balance_call()).await.unwrap().is_empty()); + api.anvil_reset(Some(Forking { json_rpc_url: None, block_number: Some(0) })).await.unwrap(); + api.anvil_reset(Some(Forking { + json_rpc_url: Some(monad_origin.http_endpoint()), + block_number: Some(0), + })) + .await + .unwrap(); + assert!(handle.http_provider().call(reserve_balance_call()).await.unwrap().is_empty()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_fork_reset_without_url_preserves_monad_execution() { + let (origin_api, origin_handle) = spawn(monad_nine_config()).await; + origin_api.mine_one().await.unwrap(); + + let config = monad_nine_config().with_eth_rpc_url(Some(origin_handle.http_endpoint())); + let (fork_api, fork_handle) = spawn(config).await; + + fork_api + .anvil_reset(Some(Forking { json_rpc_url: None, block_number: Some(0) })) + .await + .unwrap(); + + let node_info = fork_api.anvil_node_info().await.unwrap(); + assert_eq!(node_info.network, Some("monad".to_string())); + assert_eq!(node_info.hard_fork, "MonadNine"); + assert_eq!(node_info.fork_config.fork_block_number, Some(0)); + + let tx = TransactionRequest::default() + .with_to(RESERVE_BALANCE_ADDRESS) + .with_input(DIPPED_INTO_RESERVE_SELECTOR); + let result = fork_handle.http_provider().call(tx.into()).await.unwrap(); + + assert_eq!(result, Bytes::from(vec![0; 32])); +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_reset_refreshes_ambiguous_metadata_hardfork() { + let eight_chain_id = 98_765_432u64; + let nine_chain_id = 98_765_433u64; + let (_eight_api, eight_handle) = + spawn(monad_eight_config().with_chain_id(Some(eight_chain_id)).with_genesis_timestamp( + Some(MonadHardfork::MonadNine.mainnet_activation_timestamp().unwrap()), + )) + .await; + let (_nine_api, nine_handle) = + spawn(monad_nine_config().with_chain_id(Some(nine_chain_id))).await; + + let config = NodeConfig::test() + .with_no_storage_caching(true) + .with_eth_rpc_url(Some(eight_handle.http_endpoint())) + .with_fork_block_number(Some(0u64)); + let (api, handle) = spawn(config).await; + assert_eq!(api.anvil_node_info().await.unwrap().hard_fork, "MonadEight"); + assert!(handle.http_provider().call(reserve_balance_call()).await.unwrap().is_empty()); + + api.anvil_reset(Some(Forking { + json_rpc_url: Some(nine_handle.http_endpoint()), + block_number: Some(0), + })) + .await + .unwrap(); + + let node_info = api.anvil_node_info().await.unwrap(); + assert_eq!(node_info.hard_fork, "MonadNine"); + assert_eq!(api.anvil_metadata().await.unwrap().forked_network.unwrap().chain_id, nine_chain_id); + assert_eq!( + handle.http_provider().call(reserve_balance_call()).await.unwrap(), + Bytes::from(vec![0; 32]) + ); + + let explicit_nine = NodeConfig::test_monad() + .with_hardfork(Some(MonadHardfork::MonadNine.into())) + .with_no_storage_caching(true) + .with_eth_rpc_url(Some(eight_handle.http_endpoint())) + .with_fork_block_number(Some(0u64)); + let (explicit_api, explicit_handle) = spawn(explicit_nine).await; + assert_eq!(explicit_api.anvil_node_info().await.unwrap().hard_fork, "MonadNine"); + assert_eq!( + explicit_handle.http_provider().call(reserve_balance_call()).await.unwrap(), + Bytes::from(vec![0; 32]) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_reset_to_memory_restores_local_hardfork() { + let activation = MonadHardfork::MonadNine.testnet_activation_timestamp().unwrap(); + assert_monad_reset_to_memory(activation, MonadHardfork::MonadNine, MonadHardfork::MonadEight) + .await; + assert_monad_reset_to_memory( + activation - 1, + MonadHardfork::MonadEight, + MonadHardfork::MonadNine, + ) + .await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn direct_monad_fork_reset_to_memory_publishes_local_hardfork() { + let timestamp = MonadHardfork::MonadNine.testnet_activation_timestamp().unwrap(); + assert!( + timestamp < MonadHardfork::MonadNine.mainnet_activation_timestamp().unwrap(), + "test timestamp must resolve differently on Monad mainnet and testnet" + ); + let (_origin_api, origin_handle) = spawn( + NodeConfig::test_monad() + .with_chain_id(Some(143u64)) + .with_genesis_timestamp(Some(timestamp)), + ) + .await; + let (api, handle) = spawn( + NodeConfig::test_monad() + .with_chain_id(Some(MONAD_TESTNET_CHAIN_ID)) + .with_fork_chain_id(Some(U256::from(143u64))) + .with_genesis_timestamp(Some(timestamp)) + .with_no_storage_caching(true) + .with_eth_rpc_url(Some(origin_handle.http_endpoint())) + .with_fork_block_number(Some(0u64)), + ) + .await; + let provider = handle.http_provider(); + + assert_eq!(api.anvil_node_info().await.unwrap().hard_fork, "MonadEight"); + assert!(provider.call(reserve_balance_call()).await.unwrap().is_empty()); + + api.anvil_reset(None).await.unwrap(); + + assert_eq!(api.anvil_node_info().await.unwrap().hard_fork, "MonadNine"); + assert_eq!(provider.call(reserve_balance_call()).await.unwrap(), Bytes::from(vec![0; 32])); +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_fork_resets_preserve_endpoint_hardfork() { + let (_origin, endpoint) = monad_boundary_origin().await; + + { + let config = NodeConfig::test_monad() + .with_chain_id(Some(MONAD_TESTNET_CHAIN_ID)) + .with_eth_rpc_url(Some(endpoint.clone())) + .with_fork_block_number(Some(1u64)); + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + + assert!(handle.config().hardfork.is_none()); + assert_eq!(api.anvil_node_info().await.unwrap().hard_fork, "MonadNine"); + assert_eq!(provider.call(reserve_balance_call()).await.unwrap(), Bytes::from(vec![0; 32])); + + api.anvil_reset(Some(Forking { json_rpc_url: None, block_number: Some(2) })).await.unwrap(); + + assert_eq!(api.anvil_node_info().await.unwrap().hard_fork, "MonadNine"); + assert_eq!(provider.call(reserve_balance_call()).await.unwrap(), Bytes::from(vec![0; 32])); + + api.anvil_reset(Some(Forking { json_rpc_url: None, block_number: Some(1) })).await.unwrap(); + + assert_eq!(api.anvil_node_info().await.unwrap().hard_fork, "MonadNine"); + assert_eq!(provider.call(reserve_balance_call()).await.unwrap(), Bytes::from(vec![0; 32])); + } + + let config = NodeConfig::test_monad() + .with_chain_id(Some(MONAD_TESTNET_CHAIN_ID)) + .with_hardfork(Some(MonadHardfork::MonadNine.into())) + .with_eth_rpc_url(Some(endpoint)) + .with_fork_block_number(Some(1u64)); + let (api, handle) = spawn(config).await; + + assert_eq!(api.anvil_node_info().await.unwrap().hard_fork, "MonadNine"); + assert_eq!( + handle.http_provider().call(reserve_balance_call()).await.unwrap(), + Bytes::from(vec![0; 32]) + ); + + api.anvil_reset(Some(Forking { json_rpc_url: None, block_number: Some(2) })).await.unwrap(); + + assert_eq!(api.anvil_node_info().await.unwrap().hard_fork, "MonadNine"); + assert_eq!( + handle.http_provider().call(reserve_balance_call()).await.unwrap(), + Bytes::from(vec![0; 32]) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_reset_can_start_forking_with_monad_execution() { + let (origin_api, origin_handle) = spawn(monad_nine_config()).await; + origin_api.mine_one().await.unwrap(); + + let (api, handle) = spawn(monad_nine_config()).await; + + api.anvil_reset(Some(Forking { + json_rpc_url: Some(origin_handle.http_endpoint()), + block_number: Some(0), + })) + .await + .unwrap(); + + let node_info = api.anvil_node_info().await.unwrap(); + assert_eq!(node_info.network, Some("monad".to_string())); + assert_eq!(node_info.hard_fork, "MonadNine"); + assert_eq!(node_info.fork_config.fork_block_number, Some(0)); + + let tx = TransactionRequest::default() + .with_to(RESERVE_BALANCE_ADDRESS) + .with_input(DIPPED_INTO_RESERVE_SELECTOR); + let result = handle.http_provider().call(tx.into()).await.unwrap(); + + assert_eq!(result, Bytes::from(vec![0; 32])); +} + +#[tokio::test(flavor = "multi_thread")] +async fn plain_anvil_rejects_reset_to_monad_fork() { + let (api, handle) = spawn(NodeConfig::test()).await; + + for chain_id in [1u64, 143u64, 31337u64, 98_765_432u64] { + let origin_config = monad_nine_config().with_chain_id(Some(chain_id)); + let (_origin_api, origin_handle) = spawn(origin_config).await; + let err = api + .anvil_reset(Some(Forking { + json_rpc_url: Some(origin_handle.http_endpoint()), + block_number: Some(0), + })) + .await + .unwrap_err() + .to_string(); + assert!( + err.contains("cannot reset Anvil across network families (ethereum -> monad)"), + "unexpected error for chain {chain_id}: {err}" + ); + + let node_info = api.anvil_node_info().await.unwrap(); + assert_eq!(node_info.network.as_deref(), Some("ethereum")); + assert_eq!(node_info.fork_config.fork_url, None); + } + + let tx = TransactionRequest::default() + .with_to(RESERVE_BALANCE_ADDRESS) + .with_input(DIPPED_INTO_RESERVE_SELECTOR); + let result = handle.http_provider().call(tx.into()).await.unwrap(); + assert!(result.is_empty()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn plain_anvil_rejects_monad_reset_hidden_by_fork_chain_id() { + let (_origin_api, origin_handle) = spawn(monad_nine_config()).await; + let (api, handle) = spawn(NodeConfig::test().with_fork_chain_id(Some(U256::ONE))).await; + let provider = handle.http_provider(); + let marker = Address::random(); + let balance = U256::from(123_456u64); + api.anvil_set_balance(marker, balance).await.unwrap(); + let instance_id = api.anvil_metadata().await.unwrap().instance_id; + + let err = api + .anvil_reset(Some(Forking { + json_rpc_url: Some(origin_handle.http_endpoint()), + block_number: Some(0), + })) + .await + .unwrap_err() + .to_string(); + + assert!(err.contains("cannot reset Anvil across network families (ethereum -> monad)")); + assert_eq!(provider.get_balance(marker).await.unwrap(), balance); + assert_eq!(api.anvil_metadata().await.unwrap().instance_id, instance_id); + assert_eq!(api.anvil_node_info().await.unwrap().network.as_deref(), Some("ethereum")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_anvil_rejects_reset_to_default_and_custom_ethereum_forks() { + let (_monad_origin_api, monad_origin) = spawn(monad_nine_config()).await; + let config = NodeConfig::test() + .with_no_storage_caching(true) + .with_eth_rpc_url(Some(monad_origin.http_endpoint())) + .with_fork_block_number(Some(0u64)); + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + let marker = address!("0000000000000000000000000000000000001234"); + let marker_balance = U256::from(123_456); + api.anvil_set_balance(marker, marker_balance).await.unwrap(); + let instance_id = api.anvil_metadata().await.unwrap().instance_id; + + for chain_id in [1u64, 31337u64, 98_765_432u64] { + let (_origin_api, origin_handle) = + spawn(NodeConfig::test().with_chain_id(Some(chain_id))).await; + let err = api + .anvil_reset(Some(Forking { + json_rpc_url: Some(origin_handle.http_endpoint()), + block_number: Some(0), + })) + .await + .unwrap_err() + .to_string(); + assert!( + err.contains("cannot reset Anvil across network families (monad -> ethereum)"), + "unexpected error for chain {chain_id}: {err}" + ); + + let node_info = api.anvil_node_info().await.unwrap(); + assert_eq!(node_info.network, Some("monad".to_string())); + assert_eq!(node_info.fork_config.fork_url, Some(redact_url(&monad_origin.http_endpoint()))); + assert_eq!(api.anvil_metadata().await.unwrap().instance_id, instance_id); + assert_eq!(provider.get_balance(marker).await.unwrap(), marker_balance); + } + + assert_eq!(provider.call(reserve_balance_call()).await.unwrap(), Bytes::from(vec![0; 32])); +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_safe_and_finalized_block_tags_use_configured_epoch_slots() { + let slots_in_an_epoch = 3; + let config = monad_eight_config().with_slots_in_an_epoch(slots_in_an_epoch); + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + + api.anvil_mine(Some(U256::from(8)), None).await.unwrap(); + let latest = provider.get_block_number().await.unwrap(); + assert_eq!(latest, 8); + + let safe = provider.get_block(BlockId::safe()).await.unwrap().unwrap(); + assert_eq!(safe.header.number, latest - slots_in_an_epoch); + + let finalized = provider.get_block(BlockId::finalized()).await.unwrap().unwrap(); + assert_eq!(finalized.header.number, latest - slots_in_an_epoch * 2); + + let fee_history = api.fee_history(U256::ONE, BlockNumberOrTag::Safe, vec![]).await.unwrap(); + assert_eq!(fee_history.oldest_block, latest - slots_in_an_epoch); +} + +#[tokio::test(flavor = "multi_thread")] +async fn monad_safe_and_finalized_block_tags_fall_back_to_genesis_before_epoch() { + let config = monad_eight_config().with_slots_in_an_epoch(3); + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + + api.anvil_mine(Some(U256::from(2)), None).await.unwrap(); + let genesis = provider.get_block(BlockId::number(0)).await.unwrap().unwrap(); + + let safe = provider.get_block(BlockId::safe()).await.unwrap().unwrap(); + assert_eq!(safe.header.number, genesis.header.number); + assert_eq!(safe.header.hash, genesis.header.hash); + + let finalized = provider.get_block(BlockId::finalized()).await.unwrap().unwrap(); + assert_eq!(finalized.header.number, genesis.header.number); + assert_eq!(finalized.header.hash, genesis.header.hash); + + let fee_history = + api.fee_history(U256::ONE, BlockNumberOrTag::Finalized, vec![]).await.unwrap(); + assert_eq!(fee_history.oldest_block, genesis.header.number); +} + +fn monad_nine_config() -> NodeConfig { + NodeConfig::test_monad().with_hardfork(Some(MonadHardfork::MonadNine.into())) +} + +fn monad_eight_config() -> NodeConfig { + NodeConfig::test_monad().with_hardfork(Some(MonadHardfork::MonadEight.into())) +} + +fn monad_protocol_transaction(nonce: u64, value: U256, input: Bytes) -> TransactionRequest { + TransactionRequest::default() + .with_from(SYSTEM_ADDRESS) + .with_to(STAKING_ADDRESS) + .with_nonce(nonce) + .with_value(value) + .with_input(input) + .with_gas_limit(0) + .with_gas_price(0) +} + +fn mon(value: u64) -> U256 { + U256::from(value) * U256::from(1_000_000_000_000_000_000u128) +} + +fn left_aligned_u64(value: u64) -> U256 { + let mut bytes = [0u8; 32]; + bytes[..8].copy_from_slice(&value.to_be_bytes()); + U256::from_be_bytes(bytes) +} + +fn address_and_flags(address: Address, flags: u64) -> U256 { + let mut bytes = [0u8; 32]; + bytes[..20].copy_from_slice(address.as_slice()); + bytes[20..28].copy_from_slice(&flags.to_be_bytes()); + U256::from_be_bytes(bytes) +} + +async fn assert_monad_reset_to_memory( + local_timestamp: u64, + local_hardfork: MonadHardfork, + fork_hardfork: MonadHardfork, +) { + let (_origin_api, origin_handle) = spawn( + NodeConfig::test_monad() + .with_chain_id(Some(MONAD_TESTNET_CHAIN_ID)) + .with_hardfork(Some(fork_hardfork.into())), + ) + .await; + let (api, handle) = spawn( + NodeConfig::test_monad() + .with_chain_id(Some(MONAD_TESTNET_CHAIN_ID)) + .with_genesis_timestamp(Some(local_timestamp)) + .with_no_storage_caching(true), + ) + .await; + let provider = handle.http_provider(); + + api.anvil_reset(Some(Forking { + json_rpc_url: Some(origin_handle.http_endpoint()), + block_number: Some(0), + })) + .await + .unwrap(); + assert_eq!(api.anvil_node_info().await.unwrap().hard_fork, fork_hardfork.to_string()); + let fork_result = provider.call(reserve_balance_call()).await.unwrap(); + assert_eq!(fork_result.is_empty(), fork_hardfork == MonadHardfork::MonadEight); + + api.anvil_reset(None).await.unwrap(); + assert_eq!(api.anvil_node_info().await.unwrap().hard_fork, local_hardfork.to_string()); + let local_result = provider.call(reserve_balance_call()).await.unwrap(); + assert_eq!(local_result.is_empty(), local_hardfork == MonadHardfork::MonadEight); +} + +async fn monad_rollback_boundary_origin() -> (NodeHandle, String, B256) { + let activation = MonadHardfork::MonadNine.testnet_activation_timestamp().unwrap(); + let config = monad_eight_config() + .with_chain_id(Some(MONAD_TESTNET_CHAIN_ID)) + .with_genesis_timestamp(Some(activation - 2)); + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + let accounts = provider.get_accounts().await.unwrap(); + + api.evm_set_next_block_timestamp(activation - 1).unwrap(); + api.mine_one().await.unwrap(); + api.evm_set_next_block_timestamp(activation).unwrap(); + let receipt = provider + .send_transaction( + TransactionRequest::default() + .with_from(accounts[0]) + .with_to(ROLLBACK_RECIPIENT) + .with_value(U256::ONE) + .into(), + ) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + + let endpoint = handle.http_endpoint(); + (handle, endpoint, receipt.transaction_hash) +} + +async fn monad_boundary_origin() -> (NodeHandle, String) { + let activation = MonadHardfork::MonadNine.testnet_activation_timestamp().unwrap(); + let config = monad_nine_config() + .with_chain_id(Some(MONAD_TESTNET_CHAIN_ID)) + .with_genesis_timestamp(Some(activation - 2)); + let (api, handle) = spawn(config).await; + + api.evm_set_next_block_timestamp(activation - 1).unwrap(); + api.mine_one().await.unwrap(); + api.evm_set_next_block_timestamp(activation).unwrap(); + api.mine_one().await.unwrap(); + + let endpoint = handle.http_endpoint(); + (handle, endpoint) +} + +fn reserve_balance_call() -> WithOtherFields { + TransactionRequest::default() + .with_to(RESERVE_BALANCE_ADDRESS) + .with_input(DIPPED_INTO_RESERVE_SELECTOR) + .into() +} + +fn storage_gas_probe_call() -> WithOtherFields { + TransactionRequest::default().with_to(STORAGE_GAS_PROBE_ADDRESS).into() +} + +fn storage_probe_gas(result: Bytes) -> u64 { + U256::from_be_slice(&result).to::() +} + +fn root_trace_gas(result: &TraceResults) -> u64 { + result.trace[0].result.as_ref().expect("root call trace should contain a result").gas_used() +} + +fn storage_read_probe_code(second_slot: u8) -> Bytes { + let mut code = hex!("5a5f5450600054505a90035f5260205ff3"); + code[5] = second_slot; + Bytes::from(code) +} + +fn storage_access_list_probe_code(first_slot: u8, second_slot: u8) -> Bytes { + let mut code = hex!("600054506000545000"); + code[1] = first_slot; + code[5] = second_slot; + Bytes::from(code) +} + +fn storage_write_probe_code(second_slot: u8) -> Bytes { + let mut code = hex!("5a60015f5560016000555a90035f5260205ff3"); + code[8] = second_slot; + Bytes::from(code) +} + +fn reserve_probe_tx(from: Address, nonce: u64, slot: u64, value: U256) -> TransactionRequest { + TransactionRequest::default() + .with_from(from) + .with_to(RESERVE_PROBE_ADDRESS) + .with_nonce(nonce) + .with_value(value) + .with_gas_limit(100_000) + .with_input(Bytes::from(U256::from(slot).to_be_bytes::<32>())) +} + +fn large_contract_init_code(runtime_len: usize) -> Bytes { + assert!(runtime_len <= u16::MAX as usize); + const HEADER_LEN: usize = 15; + let runtime_len = runtime_len as u16; + + let mut code = Vec::with_capacity(HEADER_LEN + runtime_len as usize); + code.extend_from_slice(&[0x61, (runtime_len >> 8) as u8, runtime_len as u8]); + code.extend_from_slice(&[0x61, 0x00, HEADER_LEN as u8]); + code.extend_from_slice(&[0x60, 0x00, 0x39]); + code.extend_from_slice(&[0x61, (runtime_len >> 8) as u8, runtime_len as u8]); + code.extend_from_slice(&[0x60, 0x00, 0xf3]); + code.resize(HEADER_LEN + runtime_len as usize, 0); + Bytes::from(code) +} diff --git a/crates/anvil/tests/it/optimism.rs b/crates/anvil/tests/it/optimism.rs index 93286030fe0bb..85c920bd14951 100644 --- a/crates/anvil/tests/it/optimism.rs +++ b/crates/anvil/tests/it/optimism.rs @@ -1,15 +1,111 @@ //! Tests for OP chain support. use crate::utils::{http_provider, http_provider_with_signer}; -use alloy_eips::eip2718::Encodable2718; -use alloy_network::{EthereumWallet, TransactionBuilder}; -use alloy_primitives::{Address, TxHash, TxKind, U256, b256}; -use alloy_provider::Provider; -use alloy_rpc_types::TransactionRequest; -use alloy_serde::WithOtherFields; -use anvil::{NodeConfig, spawn}; -use op_alloy_consensus::TxDeposit; +use alloy_consensus::{Eip658Value, Receipt, proofs::calculate_receipt_root}; +use alloy_eips::{calc_next_block_base_fee, eip1559::BaseFeeParams, eip2718::Encodable2718}; +use alloy_network::{EthereumWallet, NetworkTransactionBuilder, TransactionBuilder}; +use alloy_primitives::{Address, B256, Bloom, Bytes, TxHash, TxKind, U256, address, b256, bytes}; +use alloy_provider::{Provider, ext::TraceApi}; +use alloy_rpc_types::{ + BlockId, BlockNumberOrTag, TransactionRequest, anvil::Forking, trace::parity::Action, +}; +use alloy_serde::{OtherFields, WithOtherFields}; +use anvil::{NodeConfig, eth::fees::INITIAL_BASE_FEE, spawn}; +use axum::{Json, Router, routing::post}; +use foundry_common::provider::redact_url; +use foundry_evm::hardfork::OpHardfork; +use foundry_evm_networks::NetworkConfigs; +use foundry_primitives::FoundryReceiptEnvelope; +use op_alloy_consensus::{OpDepositReceipt, OpDepositReceiptWithBloom, TxDeposit}; use op_alloy_rpc_types::OpTransactionFields; +use serde_json::{Value, json}; + +#[cfg(feature = "base")] +use foundry_evm::hardforks::BaseUpgrade; + +#[tokio::test(flavor = "multi_thread")] +async fn inferred_optimism_forks_require_op_stack_source_resets() { + let (_optimism_api, optimism_handle) = spawn(NodeConfig::test().with_optimism()).await; + let (ethereum_api, _) = spawn(NodeConfig::test()).await; + + ethereum_api + .anvil_reset(Some(Forking { + json_rpc_url: Some(optimism_handle.http_endpoint()), + block_number: Some(0), + })) + .await + .unwrap(); + let node_info = ethereum_api.anvil_node_info().await.unwrap(); + assert_eq!(node_info.network.as_deref(), Some("ethereum")); + assert_eq!(node_info.fork_config.fork_url, Some(redact_url(&optimism_handle.http_endpoint()))); + + let (optimism_api, _) = spawn( + NodeConfig::test() + .with_no_storage_caching(true) + .with_eth_rpc_url(Some(optimism_handle.http_endpoint())) + .with_fork_block_number(Some(0u64)), + ) + .await; + let (_ethereum_origin_api, ethereum_origin) = spawn(NodeConfig::test()).await; + let original_node_info = optimism_api.anvil_node_info().await.unwrap(); + let error = optimism_api + .anvil_reset(Some(Forking { + json_rpc_url: Some(ethereum_origin.http_endpoint()), + block_number: Some(0), + })) + .await + .unwrap_err(); + assert_eq!( + error.to_string(), + "cannot reset Anvil across network families (optimism -> ethereum); start a new instance with matching network configuration" + ); + let node_info = optimism_api.anvil_node_info().await.unwrap(); + assert_eq!(node_info.network.as_deref(), Some("optimism")); + assert_eq!(node_info.fork_config.fork_url, Some(redact_url(&optimism_handle.http_endpoint()))); + assert_eq!( + node_info.fork_config.fork_block_number, + original_node_info.fork_config.fork_block_number + ); + optimism_api.mine_one().await.unwrap(); +} + +#[cfg(feature = "base")] +#[tokio::test(flavor = "multi_thread")] +async fn inferred_optimism_fork_reset_to_base_preserves_execution_hardfork() { + let timestamp = 1_710_374_401u64; + let (_, optimism_origin) = spawn( + NodeConfig::test() + .with_optimism() + .with_hardfork(Some(OpHardfork::Ecotone.into())) + .with_genesis_timestamp(Some(timestamp)), + ) + .await; + let (api, _) = spawn( + NodeConfig::test() + .with_eth_rpc_url(Some(optimism_origin.http_endpoint())) + .with_fork_block_number(Some(0u64)), + ) + .await; + assert!(api.backend.is_optimism()); + assert_eq!(api.backend.hardfork(), OpHardfork::Ecotone.into()); + + let (_, base_origin) = spawn( + NodeConfig::test_base() + .with_hardfork(Some(BaseUpgrade::Ecotone.into())) + .with_genesis_timestamp(Some(timestamp)), + ) + .await; + api.anvil_reset(Some(Forking { + json_rpc_url: Some(base_origin.http_endpoint()), + block_number: Some(0), + })) + .await + .unwrap(); + + assert!(api.backend.is_optimism()); + assert_eq!(api.backend.hardfork(), OpHardfork::Ecotone.into()); + api.mine_one().await.unwrap(); +} #[tokio::test(flavor = "multi_thread")] async fn test_deposits_not_supported_if_optimism_disabled() { @@ -27,15 +123,12 @@ async fn test_deposits_not_supported_if_optimism_disabled() { .with_gas_limit(21000); let op_fields = OpTransactionFields { - source_hash: Some(b256!( - "0x0000000000000000000000000000000000000000000000000000000000000000" - )), + source_hash: Some(B256::ZERO), mint: Some(0), is_system_tx: Some(true), deposit_receipt_version: None, }; - // TODO: Test this let other = serde_json::to_value(op_fields).unwrap().try_into().unwrap(); let tx = WithOtherFields { inner: tx, other }; @@ -48,7 +141,8 @@ async fn test_deposits_not_supported_if_optimism_disabled() { #[tokio::test(flavor = "multi_thread")] async fn test_send_value_deposit_transaction() { // enable the Optimism flag - let (api, handle) = spawn(NodeConfig::test().with_optimism(true)).await; + let (api, handle) = + spawn(NodeConfig::test().with_networks(NetworkConfigs::with_optimism())).await; let accounts: Vec<_> = handle.dev_wallets().collect(); let signer: EthereumWallet = accounts[0].clone().into(); @@ -61,9 +155,7 @@ async fn test_send_value_deposit_transaction() { let before_balance_to = provider.get_balance(to).await.unwrap(); let op_fields = OpTransactionFields { - source_hash: Some(b256!( - "0x0000000000000000000000000000000000000000000000000000000000000000" - )), + source_hash: Some(B256::ZERO), mint: Some(0), is_system_tx: Some(true), deposit_receipt_version: None, @@ -92,10 +184,223 @@ async fn test_send_value_deposit_transaction() { assert_eq!(after_balance_to, before_balance_to + send_value); } +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_fields_do_not_override_op_deposit_classification() { + let (_api, handle) = + spawn(NodeConfig::test().with_networks(NetworkConfigs::with_optimism())).await; + let provider = handle.http_provider(); + let accounts: Vec<_> = handle.dev_wallets().collect(); + + let op_fields = OpTransactionFields { + source_hash: Some(B256::ZERO), + mint: Some(0), + is_system_tx: Some(true), + deposit_receipt_version: None, + }; + let mut other = OtherFields::try_from(serde_json::to_value(op_fields).unwrap()).unwrap(); + other.insert("calls".to_string(), json!([])); + let tx = WithOtherFields { + inner: TransactionRequest::default() + .with_from(accounts[0].address()) + .with_to(accounts[1].address()) + .with_gas_limit(21_000), + other, + }; + + provider.call(tx).await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_call_does_not_charge_operator_fee() { + let (api, handle) = spawn( + NodeConfig::test() + .with_networks(NetworkConfigs::with_optimism()) + .with_hardfork(Some(OpHardfork::Isthmus.into())), + ) + .await; + let provider = handle.http_provider(); + let caller = Address::random(); + let target = Address::random(); + let l1_block = address!("0x4200000000000000000000000000000000000015"); + let mut operator_fee_params = [0u8; 32]; + operator_fee_params[24..].copy_from_slice(&1_351_351_351_351u64.to_be_bytes()); + + api.anvil_set_storage_at(l1_block, U256::from(8), B256::from(operator_fee_params)) + .await + .unwrap(); + api.anvil_set_code(target, Bytes::from_static(&[0x00])).await.unwrap(); + + let request = WithOtherFields::new( + TransactionRequest::default().with_from(caller).with_to(target).with_gas_limit(21_000), + ); + provider.call(request.clone()).await.unwrap(); + provider.call(WithOtherFields::new(request.inner.clone().with_gas_price(0))).await.unwrap(); + + let priced_request = WithOtherFields::new(request.inner.with_gas_price(1_000_000_000)); + let err = provider.call(priced_request).await.unwrap_err(); + assert!(err.to_string().contains("Insufficient funds for gas * price + value"), "{err}"); + + let value_request = WithOtherFields::new( + TransactionRequest::default() + .with_from(caller) + .with_to(target) + .with_gas_limit(21_000) + .with_value(U256::ONE), + ); + let err = provider.call(value_request).await.unwrap_err(); + assert!(err.to_string().contains("Insufficient funds for gas * price + value"), "{err}"); + + for validation in [false, true] { + let err = provider + .raw_request::<_, Value>( + "eth_simulateV1".into(), + (json!({ + "blockStateCalls": [{ + "calls": [{ + "from": caller, + "to": target, + "gas": "0x5208", + "maxFeePerGas": "0x3b9aca00", + "maxPriorityFeePerGas": "0x0" + }] + }], + "validation": validation, + }),), + ) + .await + .unwrap_err(); + assert!(err.to_string().contains("Insufficient funds for gas * price + value"), "{err}"); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulated_op_deposit_receipt_root_includes_canyon_fields() { + let (_api, handle) = spawn( + NodeConfig::test() + .with_networks(NetworkConfigs::with_optimism()) + .with_hardfork(Some(OpHardfork::Canyon.into())), + ) + .await; + let provider = handle.http_provider(); + let accounts: Vec<_> = handle.dev_wallets().collect(); + let response = provider + .raw_request::<_, Value>( + "eth_simulateV1".into(), + (json!({ + "blockStateCalls": [{ + "calls": [{ + "from": accounts[0].address(), + "to": accounts[1].address(), + "gas": "0x5208", + "sourceHash": B256::ZERO, + "mint": "0x0", + "isSystemTx": false, + "calls": [], + }] + }], + "returnFullTransactions": true, + }),), + ) + .await + .unwrap(); + + assert_eq!(response[0]["transactions"][0]["type"], "0x7e"); + let gas_used = u64::from_str_radix( + response[0]["calls"][0]["gasUsed"].as_str().unwrap().trim_start_matches("0x"), + 16, + ) + .unwrap(); + let receipt = + Receipt { status: Eip658Value::Eip658(true), cumulative_gas_used: gas_used, logs: vec![] } + .with_bloom(); + let receipt = FoundryReceiptEnvelope::Deposit(OpDepositReceiptWithBloom { + receipt: OpDepositReceipt { + inner: receipt.receipt, + deposit_nonce: Some(0), + deposit_receipt_version: Some(1), + }, + logs_bloom: receipt.logs_bloom, + }); + assert_eq!(response[0]["receiptsRoot"], json!(calculate_receipt_root(&[receipt]))); +} + +/// Mines a deposit from a sender whose nonce is already `2` and returns its receipt as JSON. +async fn mine_deposit_receipt_after_two_transfers(config: NodeConfig) -> Value { + let (api, handle) = spawn(config).await; + + let accounts: Vec<_> = handle.dev_wallets().collect(); + let from = accounts[0].address(); + let to = accounts[1].address(); + // Unsigned requests go through `eth_sendTransaction`, which is where deposits are built. + let provider = http_provider(&handle.http_endpoint()); + + // Move the sender past nonce zero so pre- and post-execution nonces differ. + for _ in 0..2 { + let tx = TransactionRequest::default().with_from(from).with_to(to).with_value(U256::ONE); + provider + .send_transaction(WithOtherFields::new(tx)) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + } + assert_eq!(provider.get_transaction_count(from).await.unwrap(), 2); + + let other: OtherFields = + json!({ "sourceHash": B256::ZERO, "mint": "0x0", "isSystemTx": false }).try_into().unwrap(); + let tx = TransactionRequest::default() + .with_from(from) + .with_to(to) + .with_value(U256::ONE) + .with_gas_limit(21000); + let pending = provider + .send_transaction(WithOtherFields { inner: tx, other }) + .await + .unwrap() + .register() + .await + .unwrap(); + api.evm_mine(None).await.unwrap(); + assert_eq!(provider.get_transaction_count(from).await.unwrap(), 3); + + provider + .raw_request::<_, Value>("eth_getTransactionReceipt".into(), (pending.tx_hash(),)) + .await + .unwrap() +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_mined_deposit_receipt_uses_pre_execution_nonce() { + let receipt = mine_deposit_receipt_after_two_transfers( + NodeConfig::test().with_networks(NetworkConfigs::with_optimism()), + ) + .await; + + assert_eq!(receipt["type"], json!("0x7E")); + assert_eq!(receipt["depositNonce"], json!("0x2")); + assert_eq!(receipt["depositReceiptVersion"], json!("0x1")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_mined_deposit_receipt_omits_metadata_before_regolith() { + let receipt = mine_deposit_receipt_after_two_transfers( + NodeConfig::test() + .with_networks(NetworkConfigs::with_optimism()) + .with_hardfork(Some(OpHardfork::Bedrock.into())), + ) + .await; + + assert_eq!(receipt["type"], json!("0x7E")); + assert_eq!(receipt["depositNonce"], Value::Null); + assert_eq!(receipt["depositReceiptVersion"], Value::Null); +} + #[tokio::test(flavor = "multi_thread")] async fn test_send_value_raw_deposit_transaction() { // enable the Optimism flag - let (api, handle) = spawn(NodeConfig::test().with_optimism(true)).await; + let (api, handle) = + spawn(NodeConfig::test().with_networks(NetworkConfigs::with_optimism())).await; let accounts: Vec<_> = handle.dev_wallets().collect(); let signer: EthereumWallet = accounts[0].clone().into(); @@ -118,16 +423,14 @@ async fn test_send_value_raw_deposit_transaction() { .with_max_priority_fee_per_gas(1_000_000_000); let op_fields = OpTransactionFields { - source_hash: Some(b256!( - "0x0000000000000000000000000000000000000000000000000000000000000000" - )), + source_hash: Some(B256::ZERO), mint: Some(0), is_system_tx: Some(true), deposit_receipt_version: None, }; let other = serde_json::to_value(op_fields).unwrap().try_into().unwrap(); let tx = WithOtherFields { inner: tx, other }; - let tx_envelope = tx.build(&signer).await.unwrap(); + let tx_envelope: alloy_network::AnyTxEnvelope = tx.build(&signer).await.unwrap(); let mut tx_buffer = Vec::with_capacity(tx_envelope.encode_2718_len()); tx_envelope.encode_2718(&mut tx_buffer); let tx_encoded = tx_buffer.as_slice(); @@ -151,14 +454,14 @@ async fn test_send_value_raw_deposit_transaction() { #[tokio::test(flavor = "multi_thread")] async fn test_deposit_transaction_hash_matches_sepolia() { // enable the Optimism flag - let (_api, handle) = spawn(NodeConfig::test().with_optimism(true)).await; + let (_api, handle) = + spawn(NodeConfig::test().with_networks(NetworkConfigs::with_optimism())).await; let accounts: Vec<_> = handle.dev_wallets().collect(); let signer: EthereumWallet = accounts[0].clone().into(); // https://sepolia-optimism.etherscan.io/tx/0xbf8b5f08c43e4b860715cd64fc0849bbce0d0ea20a76b269e7bc8886d112fca7 - let tx_hash: TxHash = "0xbf8b5f08c43e4b860715cd64fc0849bbce0d0ea20a76b269e7bc8886d112fca7" - .parse::() - .unwrap(); + let tx_hash: TxHash = + b256!("0xbf8b5f08c43e4b860715cd64fc0849bbce0d0ea20a76b269e7bc8886d112fca7"); // https://sepolia-optimism.etherscan.io/getRawTx?tx=0xbf8b5f08c43e4b860715cd64fc0849bbce0d0ea20a76b269e7bc8886d112fca7 let raw_deposit_tx = alloy_primitives::hex::decode( @@ -182,7 +485,8 @@ async fn test_deposit_transaction_hash_matches_sepolia() { #[tokio::test(flavor = "multi_thread")] async fn test_deposit_tx_checks_sufficient_funds_after_applying_deposited_value() { // enable the Optimism flag - let (_api, handle) = spawn(NodeConfig::test().with_optimism(true)).await; + let (_api, handle) = + spawn(NodeConfig::test().with_networks(NetworkConfigs::with_optimism())).await; let provider = http_provider(&handle.http_endpoint()); @@ -191,13 +495,13 @@ async fn test_deposit_tx_checks_sufficient_funds_after_applying_deposited_value( let send_value = 1_000_000_000_u128; let sender_prev_balance = provider.get_balance(sender).await.unwrap(); - assert_eq!(sender_prev_balance, U256::from(0)); + assert_eq!(sender_prev_balance, U256::ZERO); let recipient_prev_balance = provider.get_balance(recipient).await.unwrap(); - assert_eq!(recipient_prev_balance, U256::from(0)); + assert_eq!(recipient_prev_balance, U256::ZERO); let deposit_tx = TxDeposit { - source_hash: b256!("0x0000000000000000000000000000000000000000000000000000000000000000"), + source_hash: B256::ZERO, from: sender, to: TxKind::Call(recipient), mint: send_value, @@ -214,9 +518,466 @@ async fn test_deposit_tx_checks_sufficient_funds_after_applying_deposited_value( let sender_new_balance = provider.get_balance(sender).await.unwrap(); // sender should've sent the entire deposited value to recipient - assert_eq!(sender_new_balance, U256::from(0)); + assert_eq!(sender_new_balance, U256::ZERO); let recipient_new_balance = provider.get_balance(recipient).await.unwrap(); // recipient should've received the entire deposited value assert_eq!(recipient_new_balance, U256::from(send_value)); } + +#[test] +fn preserves_op_fields_in_convert_to_anvil_receipt() { + let receipt_json = json!({ + "status": "0x1", + "cumulativeGasUsed": "0x74e483", + "logs": [], + "logsBloom": Bloom::default(), + "type": "0x2", + "transactionHash": "0x91181b0dca3b29aa136eeb2f536be5ce7b0aebc949be1c44b5509093c516097d", + "transactionIndex": "0x10", + "blockHash": "0x54bafb12e8cea9bb355fbf03a4ac49e42a2a1a80fa6cf4364b342e2de6432b5d", + "blockNumber": "0x7b1ab93", + "gasUsed": "0xc222", + "effectiveGasPrice": "0x18961", + "from": "0x2d815240a61731c75fa01b2793e1d3ed09f289d0", + "to": "0x4200000000000000000000000000000000000000", + "contractAddress": Value::Null, + "l1BaseFeeScalar": "0x146b", + "l1BlobBaseFee": "0x6a83078", + "l1BlobBaseFeeScalar": "0xf79c5", + "l1Fee": "0x51a9af7fd3", + "l1GasPrice": "0x972fe4acc", + "l1GasUsed": "0x640", + }); + + let receipt: alloy_network::AnyTransactionReceipt = + serde_json::from_value(receipt_json).expect("valid receipt json"); + + let converted = + foundry_primitives::FoundryTxReceipt::try_from(receipt).expect("conversion should succeed"); + let converted_json = serde_json::to_value(&converted).expect("serialize to json"); + + for (key, expected) in [ + ("l1Fee", "0x51a9af7fd3"), + ("l1GasPrice", "0x972fe4acc"), + ("l1GasUsed", "0x640"), + ("l1BaseFeeScalar", "0x146b"), + ("l1BlobBaseFee", "0x6a83078"), + ("l1BlobBaseFeeScalar", "0xf79c5"), + ] { + let got = converted_json.get(key).and_then(Value::as_str); + assert_eq!(got, Some(expected), "field `{key}` mismatch"); + } +} + +const GAS_TRANSFER: u64 = 21_000; + +async fn spawn_rpc_proxy_with_extra_data(endpoint: String, extra_data: &'static str) -> String { + let client = reqwest::Client::new(); + let router = Router::new().route( + "/", + post(move |Json(request): Json| { + let client = client.clone(); + let endpoint = endpoint.clone(); + async move { + let mut response = client + .post(endpoint) + .json(&request) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + if matches!( + request.get("method").and_then(Value::as_str), + Some("eth_getBlockByHash" | "eth_getBlockByNumber") + ) && let Some(block) = response.get_mut("result").and_then(Value::as_object_mut) + { + block.insert("extraData".to_string(), Value::String(extra_data.to_string())); + } + Json(response) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + format!("http://{address}") +} + +#[tokio::test(flavor = "multi_thread")] +async fn jovian_mining_and_simulation_use_da_footprint() { + const DA_FOOTPRINT_SCALAR: u16 = 400; + const EXPECTED_DA_FOOTPRINT: u64 = 100 * DA_FOOTPRINT_SCALAR as u64; + const JOVIAN_EXTRA_DATA: &str = "0x01000000fa000000020000000000000000"; + const TRANSACTION_COUNT: usize = 30; + const OVERFLOW_TRANSACTION_COUNT: usize = 26; + + let (origin_api, origin) = spawn( + NodeConfig::test() + .with_chain_id(Some(8453u64)) + .with_networks(NetworkConfigs::with_optimism()) + .with_hardfork(Some(OpHardfork::Jovian.into())) + .with_gas_limit(Some(30_000_000)), + ) + .await; + let l1_block = address!("0x4200000000000000000000000000000000000015"); + let mut scalar_slot = [0u8; 32]; + scalar_slot[18..20].copy_from_slice(&DA_FOOTPRINT_SCALAR.to_be_bytes()); + origin_api + .anvil_set_storage_at(l1_block, U256::from(8), B256::from(scalar_slot)) + .await + .unwrap(); + + let fork_url = spawn_rpc_proxy_with_extra_data(origin.http_endpoint(), JOVIAN_EXTRA_DATA).await; + let (api, handle) = spawn( + NodeConfig::test() + .with_no_storage_caching(true) + .with_eth_rpc_url(Some(fork_url)) + .with_fork_block_number(Some(0u64)) + .with_hardfork(Some(OpHardfork::Jovian.into())), + ) + .await; + let wallet = handle.dev_wallets().next().unwrap(); + let to = Address::random(); + let provider = http_provider_with_signer(&handle.http_endpoint(), wallet.clone().into()); + + let simulated = provider + .raw_request::<_, Value>( + "eth_simulateV1".into(), + (json!({ + "blockStateCalls": [{ + "calls": [{ + "from": wallet.address(), + "to": to, + "gas": "0x5208", + }] + }] + }),), + ) + .await + .unwrap(); + assert_eq!(simulated[0]["blobGasUsed"], json!(format!("0x{EXPECTED_DA_FOOTPRINT:x}"))); + assert_eq!(simulated[0]["excessBlobGas"], "0x0"); + + let calls = (0..TRANSACTION_COUNT) + .map(|_| json!({ "from": wallet.address(), "to": to, "gas": "0x5208" })) + .collect::>(); + let simulated = provider + .raw_request::<_, Value>( + "eth_simulateV1".into(), + (json!({ "blockStateCalls": [{ "calls": calls }] }),), + ) + .await + .unwrap(); + assert_eq!( + simulated[0]["blobGasUsed"], + json!(format!("0x{:x}", TRANSACTION_COUNT as u64 * EXPECTED_DA_FOOTPRINT)) + ); + + let calls = (0..OVERFLOW_TRANSACTION_COUNT) + .map(|_| json!({ "from": wallet.address(), "to": to, "gas": "0x5208" })) + .collect::>(); + let overflow: Result = provider + .raw_request( + "eth_simulateV1".into(), + (json!({ + "blockStateCalls": [{ + "blockOverrides": { "gasLimit": "0xf4240" }, + "calls": calls, + }] + }),), + ) + .await; + let error = overflow.unwrap_err(); + assert!( + error.as_error_resp().unwrap().message.starts_with("blob gas usage exceeds the limit of ") + ); + + api.anvil_set_auto_mine(false).await.unwrap(); + let mut pending = Vec::new(); + for _ in 0..TRANSACTION_COUNT { + pending.push( + provider + .send_transaction(WithOtherFields::new( + TransactionRequest::default().with_to(to).with_value(U256::ONE), + )) + .await + .unwrap(), + ); + } + api.mine_one().await.unwrap(); + pending.remove(0).get_receipt().await.unwrap(); + let transaction_block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + assert_eq!(transaction_block.transactions.len(), TRANSACTION_COUNT); + assert_eq!( + transaction_block.header.blob_gas_used, + Some(TRANSACTION_COUNT as u64 * EXPECTED_DA_FOOTPRINT) + ); + assert_eq!(transaction_block.header.excess_blob_gas, Some(0)); + + api.mine_one().await.unwrap(); + let next_block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + assert_eq!( + next_block.header.base_fee_per_gas, + Some(calc_next_block_base_fee( + transaction_block.header.blob_gas_used.unwrap(), + transaction_block.header.gas_limit, + transaction_block.header.base_fee_per_gas.unwrap(), + BaseFeeParams::new(250, 2), + )), + "Jovian should price the next block from its DA footprint", + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn fork_transaction_replay_uses_jovian_da_footprint() { + const DA_FOOTPRINT_SCALAR: u16 = 400; + const EXPECTED_DA_FOOTPRINT: u64 = 100 * DA_FOOTPRINT_SCALAR as u64; + const JOVIAN_ACTIVATION_TIMESTAMP: u64 = 1_764_691_201; + + for (hardfork, timestamp, expected) in [ + (OpHardfork::Isthmus, JOVIAN_ACTIVATION_TIMESTAMP - 2, 0), + (OpHardfork::Jovian, JOVIAN_ACTIVATION_TIMESTAMP, EXPECTED_DA_FOOTPRINT), + ] { + let (origin_api, origin) = spawn( + NodeConfig::test() + .with_chain_id(Some(8453u64)) + .with_networks(NetworkConfigs::with_optimism()) + .with_hardfork(Some(hardfork.into())) + .with_genesis_timestamp(Some(timestamp)), + ) + .await; + let l1_block = address!("0x4200000000000000000000000000000000000015"); + let mut scalar_slot = [0u8; 32]; + scalar_slot[18..20].copy_from_slice(&DA_FOOTPRINT_SCALAR.to_be_bytes()); + origin_api + .anvil_set_storage_at(l1_block, U256::from(8), B256::from(scalar_slot)) + .await + .unwrap(); + + let wallet = origin.dev_wallets().next().unwrap(); + let provider = http_provider_with_signer(&origin.http_endpoint(), wallet.into()); + origin_api.evm_set_next_block_timestamp(timestamp).unwrap(); + let receipt = provider + .send_transaction(WithOtherFields::new( + TransactionRequest::default().with_to(Address::random()).with_value(U256::ONE), + )) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + + let (fork_api, _) = spawn( + NodeConfig::test() + .with_eth_rpc_url(Some(origin.http_endpoint())) + .with_fork_transaction_hash(Some(receipt.transaction_hash)) + .with_hardfork(Some(hardfork.into())) + .with_no_mining(true), + ) + .await; + let replayed = fork_api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + + assert_eq!(replayed.header.blob_gas_used, Some(expected), "hardfork: {hardfork:?}"); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn local_jovian_blocks_include_dynamic_fee_parameters() { + const DA_FOOTPRINT_SCALAR: u16 = 6_000; + + let (api, handle) = spawn( + NodeConfig::test() + .with_networks(NetworkConfigs::with_optimism()) + .with_hardfork(Some(OpHardfork::Jovian.into())) + .with_gas_limit(Some(1_000_000)), + ) + .await; + let l1_block = address!("0x4200000000000000000000000000000000000015"); + let mut scalar_slot = [0u8; 32]; + scalar_slot[18..20].copy_from_slice(&DA_FOOTPRINT_SCALAR.to_be_bytes()); + let wallet = handle.dev_wallets().next().unwrap(); + let provider = http_provider_with_signer(&handle.http_endpoint(), wallet.clone().into()); + let transaction = WithOtherFields::new( + TransactionRequest::default().with_to(Address::random()).with_value(U256::ONE), + ); + + api.anvil_set_storage_at(l1_block, U256::from(8), B256::from(scalar_slot)).await.unwrap(); + provider.send_transaction(transaction.clone()).await.unwrap().get_receipt().await.unwrap(); + let transaction_block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + api.mine_one().await.unwrap(); + let next_block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + assert!(next_block.header.base_fee_per_gas > transaction_block.header.base_fee_per_gas); + + api.anvil_reset(None).await.unwrap(); + api.anvil_set_storage_at(l1_block, U256::from(8), B256::from(scalar_slot)).await.unwrap(); + let provider = http_provider_with_signer(&handle.http_endpoint(), wallet.into()); + provider.send_transaction(transaction).await.unwrap().get_receipt().await.unwrap(); + let transaction_block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + api.mine_one().await.unwrap(); + + let block = handle.http_provider().get_block(BlockId::latest()).await.unwrap().unwrap(); + assert_eq!(block.header.extra_data.len(), 17); + assert_eq!(block.header.extra_data[0], 1); + assert!(block.header.base_fee_per_gas > transaction_block.header.base_fee_per_gas); + assert_eq!(block.header.base_fee_per_gas, next_block.header.base_fee_per_gas); +} + +/// Test that Optimism uses Canyon base fee params instead of Ethereum params. +/// +/// Optimism Canyon uses different EIP-1559 parameters: +/// - elasticity_multiplier: 6 (vs Ethereum's 2) +/// - base_fee_max_change_denominator: 250 (vs Ethereum's 8) +/// +/// This means with a full block: +/// - Ethereum: base_fee increases by base_fee * 1 / 8 = 12.5% +/// - Optimism: base_fee increases by base_fee * 5 / 250 = 2% +#[tokio::test(flavor = "multi_thread")] +async fn test_optimism_base_fee_params() { + // Spawn an Optimism node with a gas limit equal to one transfer (full block scenario) + let (_api, handle) = spawn( + NodeConfig::test() + .with_networks(NetworkConfigs::with_optimism()) + .with_base_fee(Some(INITIAL_BASE_FEE)) + .with_gas_limit(Some(GAS_TRANSFER)), + ) + .await; + + let wallet = handle.dev_wallets().next().unwrap(); + let signer: EthereumWallet = wallet.clone().into(); + + let provider = http_provider_with_signer(&handle.http_endpoint(), signer); + + let tx = TransactionRequest::default().to(Address::random()).with_value(U256::from(1337)); + let tx = WithOtherFields::new(tx); + + // Send first transaction to fill the block + provider.send_transaction(tx.clone()).await.unwrap().get_receipt().await.unwrap(); + + let base_fee = provider + .get_block(BlockId::latest()) + .await + .unwrap() + .unwrap() + .header + .base_fee_per_gas + .unwrap(); + + // Send second transaction to fill the next block + provider.send_transaction(tx.clone()).await.unwrap().get_receipt().await.unwrap(); + + let next_base_fee = provider + .get_block(BlockId::latest()) + .await + .unwrap() + .unwrap() + .header + .base_fee_per_gas + .unwrap(); + + assert!(next_base_fee > base_fee, "base fee should increase with full block"); + + // Optimism Canyon formula: base_fee * (elasticity - 1) / denominator = base_fee * 5 / 250 + // = INITIAL_BASE_FEE * 5 / 250 = 1_000_000_000 * 5 / 250 = 20_000_000 + // + // Note: Ethereum would be INITIAL_BASE_FEE + 125_000_000 (12.5% increase) + let expected_op_increase = INITIAL_BASE_FEE * 5 / 250; // 2% increase = 20_000_000 + assert_eq!( + next_base_fee, + INITIAL_BASE_FEE + expected_op_increase, + "Optimism should use Canyon base fee params (2% max increase), not Ethereum's (12.5%)" + ); + + // Explicitly verify it's NOT using Ethereum params (which would give 12.5% increase) + let ethereum_increase = INITIAL_BASE_FEE / 8; // 125_000_000 + assert_ne!( + next_base_fee, + INITIAL_BASE_FEE + ethereum_increase, + "Should not be using Ethereum base fee params" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn inferred_optimism_fork_uses_optimism_base_fee_params() { + let (_origin_api, origin_handle) = spawn( + NodeConfig::test() + .with_networks(NetworkConfigs::with_optimism()) + .with_base_fee(Some(INITIAL_BASE_FEE)) + .with_gas_limit(Some(GAS_TRANSFER)), + ) + .await; + let origin_wallet = origin_handle.dev_wallets().next().unwrap(); + let origin_provider = + http_provider_with_signer(&origin_handle.http_endpoint(), origin_wallet.into()); + let tx = TransactionRequest::default().to(Address::random()).with_value(U256::ONE); + origin_provider + .send_transaction(WithOtherFields::new(tx)) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + + let (fork_api, fork_handle) = spawn( + NodeConfig::test() + .with_no_storage_caching(true) + .with_eth_rpc_url(Some(origin_handle.http_endpoint())) + .with_fork_block_number(Some(1u64)), + ) + .await; + assert_eq!(fork_api.anvil_node_info().await.unwrap().network.as_deref(), Some("optimism")); + + let fork_wallet = fork_handle.dev_wallets().next().unwrap(); + let fork_provider = http_provider_with_signer(&fork_handle.http_endpoint(), fork_wallet.into()); + let tx = TransactionRequest::default().to(Address::random()).with_value(U256::ONE); + fork_provider + .send_transaction(WithOtherFields::new(tx)) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + + let block = fork_provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + assert_eq!(block.header.base_fee_per_gas, Some(INITIAL_BASE_FEE + INITIAL_BASE_FEE * 5 / 250)); +} + +// Mined traces omit nested calls to OP's own precompiles, such as P256VERIFY at 0x100 from Fjord, +// which Ethereum's precompiles at the mapped spec do not include. +#[tokio::test(flavor = "multi_thread")] +async fn test_trace_transaction_omits_nested_op_precompile_calls() { + let (api, handle) = spawn(NodeConfig::test().with_optimism()).await; + let provider = handle.http_provider(); + let from = handle.dev_wallets().next().unwrap().address(); + + // Zero-value STATICCALL to 0x100, then a zero-value CALL to 0xbeef. + let caller = Address::repeat_byte(0x42); + let code = bytes!("60006000600060006101005afa506000600060006000600061beef5af15000"); + api.anvil_set_code(caller, code).await.unwrap(); + + let tx = TransactionRequest::default().from(from).to(caller); + let receipt = provider + .send_transaction(WithOtherFields::new(tx)) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + let hash = receipt.transaction_hash; + + let traces = provider.trace_transaction(hash).await.unwrap(); + let traces = traces.into_iter().map(|trace| trace.trace).collect::>(); + let targets = traces + .iter() + .map(|trace| match &trace.action { + Action::Call(call) => call.to, + action => panic!("expected a call, got {action:?}"), + }) + .collect::>(); + assert_eq!(targets, [caller, Address::left_padding_from(&[0xbe, 0xef])]); + + let replay = provider.trace_replay_transaction(hash).trace().await.unwrap(); + assert_eq!(replay.trace, traces); +} diff --git a/crates/anvil/tests/it/otterscan.rs b/crates/anvil/tests/it/otterscan.rs index b5b6be4b5b920..8cd34d1960883 100644 --- a/crates/anvil/tests/it/otterscan.rs +++ b/crates/anvil/tests/it/otterscan.rs @@ -1,7 +1,6 @@ //! Tests for otterscan endpoints. use crate::abi::Multicall; -use alloy_hardforks::EthereumHardfork; use alloy_network::TransactionResponse; use alloy_primitives::{Address, Bytes, U256, address}; use alloy_provider::Provider; @@ -12,12 +11,13 @@ use alloy_rpc_types::{ use alloy_serde::WithOtherFields; use alloy_sol_types::{SolCall, SolError, SolValue, sol}; use anvil::{NodeConfig, spawn}; +use foundry_evm::hardfork::EthereumHardfork; use std::collections::VecDeque; #[tokio::test(flavor = "multi_thread")] async fn erigon_get_header_by_number() { let (api, _handle) = spawn(NodeConfig::test()).await; - api.mine_one().await; + api.mine_one().await.unwrap(); let res0 = api.erigon_get_header_by_number(0.into()).await.unwrap().unwrap(); assert_eq!(res0.header.number, 0); @@ -49,7 +49,7 @@ async fn ots_get_internal_operations_contract_deploy() { r#type: OperationType::OpCreate, from: sender, to: contract_receipt.contract_address.unwrap(), - value: U256::from(0) + value: U256::ZERO }], ); } @@ -108,7 +108,7 @@ async fn ots_get_internal_operations_contract_create2() { r#type: OperationType::OpCreate2, from: address!("0x4e59b44847b379578588920cA78FbF26c0B4956C"), to: address!("0x347bcdad821abc09b8c275881b368de36476b62c"), - value: U256::from(0), + value: U256::ZERO, }], ); } @@ -167,7 +167,7 @@ async fn ots_has_code() { let provider = handle.http_provider(); let sender = handle.dev_accounts().next().unwrap(); - api.mine_one().await; + api.mine_one().await.unwrap(); let contract_address = sender.create(0); @@ -364,7 +364,7 @@ async fn ots_get_block_transactions() { hashes.push_back(*pending_receipt.tx_hash()); } - api.mine_one().await; + api.mine_one().await.unwrap(); let page_size = 3; for page in 0..4 { @@ -373,7 +373,7 @@ async fn ots_get_block_transactions() { assert!(result.receipts.len() <= page_size); let len = result.receipts.len(); assert!(len <= page_size); - assert!(result.fullblock.transaction_count == result.receipts.len()); + assert_eq!(result.fullblock.transaction_count, 10); result.receipts.iter().enumerate().for_each(|(i, receipt)| { let expected = hashes.pop_front(); @@ -480,9 +480,9 @@ async fn ots_get_transaction_by_sender_and_nonce() { let receipt2 = provider.send_transaction(tx2).await.unwrap().get_receipt().await.unwrap(); let result1 = - api.ots_get_transaction_by_sender_and_nonce(sender, U256::from(0)).await.unwrap().unwrap(); + api.ots_get_transaction_by_sender_and_nonce(sender, U256::ZERO).await.unwrap().unwrap(); let result2 = - api.ots_get_transaction_by_sender_and_nonce(sender, U256::from(1)).await.unwrap().unwrap(); + api.ots_get_transaction_by_sender_and_nonce(sender, U256::ONE).await.unwrap().unwrap(); assert_eq!(result1, receipt1.transaction_hash); assert_eq!(result2, receipt2.transaction_hash); diff --git a/crates/anvil/tests/it/proof.rs b/crates/anvil/tests/it/proof.rs index abf0fc0d077f7..152a9e515253d 100644 --- a/crates/anvil/tests/it/proof.rs +++ b/crates/anvil/tests/it/proof.rs @@ -2,10 +2,11 @@ use alloy_primitives::{Address, B256, Bytes, U256, address, fixed_bytes}; use anvil::{NodeConfig, eth::EthApi, spawn}; +use foundry_primitives::FoundryNetwork; use std::{collections::BTreeMap, str::FromStr}; async fn verify_account_proof( - api: &EthApi, + api: &EthApi, address: Address, proof: impl IntoIterator, ) { @@ -17,7 +18,7 @@ async fn verify_account_proof( } async fn verify_storage_proof( - api: &EthApi, + api: &EthApi, address: Address, slot: B256, proof: impl IntoIterator, @@ -39,7 +40,7 @@ async fn test_account_proof() { ) .await .unwrap(); - api.anvil_set_balance(address!("0x33f0fc440b8477fcfbe9d0bf8649e7dea9baedb2"), U256::from(1)) + api.anvil_set_balance(address!("0x33f0fc440b8477fcfbe9d0bf8649e7dea9baedb2"), U256::ONE) .await .unwrap(); api.anvil_set_balance( @@ -55,14 +56,17 @@ async fn test_account_proof() { .await .unwrap(); + // Note: proof values account for canonical system contracts deployed at genesis. verify_account_proof(&api, address!("0x2031f89b3ea8014eb51a78c316e42af3e0d7695f"), [ - "0xe48200a7a040f916999be583c572cc4dd369ec53b0a99f7de95f13880cf203d98f935ed1b3", + "0xf8b1a06d582bbd1c89c4ada4f6e511557212ffae0b090ae18d026903c20b9e8b866c848080a0d4d73c7f221617e0bfb2d26079b1c2011f3670e79cf1cb67042ee1f20055c79a8080a0347892f3eeb6af4d4574ad0b89706247951bbbece83ef27efc46eb96436598b5808080a07e35bed15a14b4072a0929310da6a26e34d7017a82cca3589d7d0badb53de2e38080a0790246e443226568e8e28a7a70e77c5054bb012bdf72e505ee44786476232a47808080", + "0xe217a040f916999be583c572cc4dd369ec53b0a99f7de95f13880cf203d98f935ed1b3", "0xf87180a04fb9bab4bb88c062f32452b7c94c8f64d07b5851d44a39f1e32ba4b1829fdbfb8080808080a0b61eeb2eb82808b73c4ad14140a2836689f4ab8445d69dd40554eaf1fce34bc080808080808080a0dea230ff2026e65de419288183a340125b04b8405cc61627b3b4137e2260a1e880", - "0xf8719f31355ec1c8f7e26bb3ccbcb0b75d870d15846c0b98e5cc452db46c37faea40b84ff84d80890270801d946c940000a056e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421a0c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470" + "0xf8719f31355ec1c8f7e26bb3ccbcb0b75d870d15846c0b98e5cc452db46c37faea40b84ff84d80890270801d946c940000a056e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421a0c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470", ]).await; verify_account_proof(&api, address!("0x33f0fc440b8477fcfbe9d0bf8649e7dea9baedb2"), [ - "0xe48200a7a040f916999be583c572cc4dd369ec53b0a99f7de95f13880cf203d98f935ed1b3", + "0xf8b1a06d582bbd1c89c4ada4f6e511557212ffae0b090ae18d026903c20b9e8b866c848080a0d4d73c7f221617e0bfb2d26079b1c2011f3670e79cf1cb67042ee1f20055c79a8080a0347892f3eeb6af4d4574ad0b89706247951bbbece83ef27efc46eb96436598b5808080a07e35bed15a14b4072a0929310da6a26e34d7017a82cca3589d7d0badb53de2e38080a0790246e443226568e8e28a7a70e77c5054bb012bdf72e505ee44786476232a47808080", + "0xe217a040f916999be583c572cc4dd369ec53b0a99f7de95f13880cf203d98f935ed1b3", "0xf87180a04fb9bab4bb88c062f32452b7c94c8f64d07b5851d44a39f1e32ba4b1829fdbfb8080808080a0b61eeb2eb82808b73c4ad14140a2836689f4ab8445d69dd40554eaf1fce34bc080808080808080a0dea230ff2026e65de419288183a340125b04b8405cc61627b3b4137e2260a1e880", "0xe48200d3a0ef957210bca5b9b402d614eb8408c88cfbf4913eb6ab83ca233c8b8f0e626b54", "0xf851808080a02743a5addaf4cf9b8c0c073e1eaa555deaaf8c41cb2b41958e88624fa45c2d908080808080a0bfbf6937911dfb88113fecdaa6bde822e4e99dae62489fcf61a91cb2f36793d680808080808080", @@ -70,17 +74,19 @@ async fn test_account_proof() { ]).await; verify_account_proof(&api, address!("0x62b0dd4aab2b1a0a04e279e2b828791a10755528"), [ - "0xe48200a7a040f916999be583c572cc4dd369ec53b0a99f7de95f13880cf203d98f935ed1b3", + "0xf8b1a06d582bbd1c89c4ada4f6e511557212ffae0b090ae18d026903c20b9e8b866c848080a0d4d73c7f221617e0bfb2d26079b1c2011f3670e79cf1cb67042ee1f20055c79a8080a0347892f3eeb6af4d4574ad0b89706247951bbbece83ef27efc46eb96436598b5808080a07e35bed15a14b4072a0929310da6a26e34d7017a82cca3589d7d0badb53de2e38080a0790246e443226568e8e28a7a70e77c5054bb012bdf72e505ee44786476232a47808080", + "0xe217a040f916999be583c572cc4dd369ec53b0a99f7de95f13880cf203d98f935ed1b3", "0xf87180a04fb9bab4bb88c062f32452b7c94c8f64d07b5851d44a39f1e32ba4b1829fdbfb8080808080a0b61eeb2eb82808b73c4ad14140a2836689f4ab8445d69dd40554eaf1fce34bc080808080808080a0dea230ff2026e65de419288183a340125b04b8405cc61627b3b4137e2260a1e880", "0xf8709f3936599f93b769acf90c7178fd2ddcac1b5b4bc9949ee5a04b7e0823c2446eb84ef84c80880f43fc2c04ee0000a056e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421a0c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470", ]).await; verify_account_proof(&api, address!("0x1ed9b1dd266b607ee278726d324b855a093394a6"), [ - "0xe48200a7a040f916999be583c572cc4dd369ec53b0a99f7de95f13880cf203d98f935ed1b3", + "0xf8b1a06d582bbd1c89c4ada4f6e511557212ffae0b090ae18d026903c20b9e8b866c848080a0d4d73c7f221617e0bfb2d26079b1c2011f3670e79cf1cb67042ee1f20055c79a8080a0347892f3eeb6af4d4574ad0b89706247951bbbece83ef27efc46eb96436598b5808080a07e35bed15a14b4072a0929310da6a26e34d7017a82cca3589d7d0badb53de2e38080a0790246e443226568e8e28a7a70e77c5054bb012bdf72e505ee44786476232a47808080", + "0xe217a040f916999be583c572cc4dd369ec53b0a99f7de95f13880cf203d98f935ed1b3", "0xf87180a04fb9bab4bb88c062f32452b7c94c8f64d07b5851d44a39f1e32ba4b1829fdbfb8080808080a0b61eeb2eb82808b73c4ad14140a2836689f4ab8445d69dd40554eaf1fce34bc080808080808080a0dea230ff2026e65de419288183a340125b04b8405cc61627b3b4137e2260a1e880", "0xe48200d3a0ef957210bca5b9b402d614eb8408c88cfbf4913eb6ab83ca233c8b8f0e626b54", "0xf851808080a02743a5addaf4cf9b8c0c073e1eaa555deaaf8c41cb2b41958e88624fa45c2d908080808080a0bfbf6937911dfb88113fecdaa6bde822e4e99dae62489fcf61a91cb2f36793d680808080808080", - "0xf86f9e207a32b8ab5eb4b043c65b1f00c93f517bc8883c5cd31baf8e8a279475e3b84ef84c808801aa535d3d0c0000a056e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421a0c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470" + "0xf86f9e207a32b8ab5eb4b043c65b1f00c93f517bc8883c5cd31baf8e8a279475e3b84ef84c808801aa535d3d0c0000a056e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421a0c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470", ]).await; } diff --git a/crates/anvil/tests/it/pubsub.rs b/crates/anvil/tests/it/pubsub.rs index 5b96c7f85de7f..79b461c356b41 100644 --- a/crates/anvil/tests/it/pubsub.rs +++ b/crates/anvil/tests/it/pubsub.rs @@ -1,15 +1,20 @@ //! tests for subscriptions use crate::utils::{connect_pubsub, connect_pubsub_with_wallet}; -use alloy_network::{EthereumWallet, TransactionBuilder}; -use alloy_primitives::{Address, U256}; +use alloy_network::{EthereumWallet, ReceiptResponse, TransactionBuilder}; +use alloy_primitives::{Address, B256, U256}; use alloy_provider::Provider; use alloy_pubsub::Subscription; -use alloy_rpc_types::{Block as AlloyBlock, Filter, TransactionRequest}; +use alloy_rpc_types::{ + Block as AlloyBlock, Filter, TransactionRequest, pubsub::TransactionReceiptsParams, +}; use alloy_serde::WithOtherFields; use alloy_sol_types::sol; use anvil::{NodeConfig, spawn}; +use anvil_core::types::{ReorgOptions, TransactionData}; +use foundry_primitives::FoundryTxReceipt; use futures::StreamExt; +use std::time::Duration; #[tokio::test(flavor = "multi_thread")] async fn test_sub_new_heads() { @@ -155,6 +160,73 @@ async fn test_sub_logs_impersonated() { assert_eq!(receipt.inner.inner.logs()[0], log); } +#[tokio::test(flavor = "multi_thread")] +async fn test_sub_logs_reorg_removed() { + let (api, handle) = spawn(NodeConfig::test()).await; + let wallet = handle.dev_wallets().next().unwrap(); + let provider = + connect_pubsub_with_wallet(&handle.ws_endpoint(), EthereumWallet::from(wallet.clone())) + .await; + + let contract = EmitLogs::deploy(provider.clone(), "initial".to_string()).await.unwrap(); + + // subscribe to events from the contract + let filter = Filter::new().address(*contract.address()); + let logs_sub = provider.subscribe_logs(&filter).await.unwrap(); + let mut logs_sub = logs_sub.into_stream(); + + // emit events in two consecutive blocks + contract.setValue("first".to_string()).send().await.unwrap().get_receipt().await.unwrap(); + contract.setValue("second".to_string()).send().await.unwrap().get_receipt().await.unwrap(); + + let first_log = logs_sub.next().await.unwrap(); + let second_log = logs_sub.next().await.unwrap(); + assert!(!first_log.removed); + assert!(!second_log.removed); + + // reorg out both blocks and replace the first one with a block that emits another event + let data = contract.setValue("reorged".to_string()).calldata().clone(); + let tx = TransactionRequest::default() + .from(wallet.address()) + .to(*contract.address()) + .with_input(data); + api.anvil_reorg(ReorgOptions { + depth: 2, + tx_block_pairs: vec![(TransactionData::JSON(tx.into()), 0)], + }) + .await + .unwrap(); + + // the logs of the reorged out blocks are delivered again, marked as removed + let mut expected = first_log.clone(); + expected.removed = true; + assert_eq!(logs_sub.next().await.unwrap(), expected); + + let mut expected = second_log.clone(); + expected.removed = true; + assert_eq!(logs_sub.next().await.unwrap(), expected); + + // followed by the logs of the new chain + let new_log = logs_sub.next().await.unwrap(); + assert!(!new_log.removed); + assert_eq!(new_log.block_number, first_log.block_number); + assert_ne!(new_log.block_hash, first_log.block_hash); + let value_changed = new_log.log_decode::().unwrap(); + assert_eq!(value_changed.inner.newValue, "reorged"); + + // eth_getLogs only returns the logs of the new canonical chain + let canonical_logs = provider + .get_logs(&Filter::new().address(*contract.address()).from_block(0u64)) + .await + .unwrap(); + assert!(canonical_logs.iter().all(|log| !log.removed)); + let tx_hashes = + canonical_logs.iter().map(|log| log.transaction_hash.unwrap()).collect::>(); + assert!(!tx_hashes.contains(&first_log.transaction_hash.unwrap())); + assert!(!tx_hashes.contains(&second_log.transaction_hash.unwrap())); + assert!(tx_hashes.contains(&new_log.transaction_hash.unwrap())); +} + // FIXME: Use legacy() in tx when implemented in alloy #[tokio::test(flavor = "multi_thread")] async fn test_filters_legacy() { @@ -247,6 +319,111 @@ async fn test_subscriptions() { assert_eq!(blocks, vec![1, 2, 3]) } +#[tokio::test(flavor = "multi_thread")] +async fn test_sub_syncing_delivers_initial_status() { + let (_api, handle) = spawn(NodeConfig::test()).await; + + let provider = connect_pubsub(&handle.ws_endpoint()).await; + let sub_id = provider.raw_request("eth_subscribe".into(), ["syncing"]).await.unwrap(); + let stream: Subscription = provider.get_subscription(sub_id).await.unwrap(); + let mut stream = stream.into_stream(); + + let initial = tokio::time::timeout(Duration::from_secs(5), stream.next()) + .await + .expect("timed out waiting for initial sync status") + .expect("subscription ended unexpectedly"); + + assert_eq!(initial, serde_json::json!(false)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_sub_transaction_receipts() { + let (api, handle) = spawn(NodeConfig::test()).await; + let ws_provider = connect_pubsub(&handle.ws_endpoint()).await; + let http_provider = handle.http_provider(); + + api.anvil_set_auto_mine(false).await.unwrap(); + + let accounts = http_provider.get_accounts().await.unwrap(); + let from = accounts[0]; + let to = accounts[1]; + + let tx = TransactionRequest::default() + .with_from(from) + .with_to(to) + .with_value(U256::ONE) + .with_nonce(0); + let first = http_provider.send_transaction(WithOtherFields::new(tx)).await.unwrap(); + + let tx = TransactionRequest::default() + .with_from(from) + .with_to(to) + .with_value(U256::from(2)) + .with_nonce(1); + let second = http_provider.send_transaction(WithOtherFields::new(tx)).await.unwrap(); + + let all_id = + ws_provider.raw_request("eth_subscribe".into(), ["transactionReceipts"]).await.unwrap(); + let all_stream: Subscription> = + ws_provider.get_subscription(all_id).await.unwrap(); + let mut all_stream = all_stream.into_stream(); + + let filter = TransactionReceiptsParams { transaction_hashes: Some(vec![*second.tx_hash()]) }; + let filtered_id = ws_provider + .raw_request("eth_subscribe".into(), ("transactionReceipts", filter)) + .await + .unwrap(); + let filtered_stream: Subscription> = + ws_provider.get_subscription(filtered_id).await.unwrap(); + let mut filtered_stream = filtered_stream.into_stream(); + + api.mine_one().await.unwrap(); + + let all_receipts = tokio::time::timeout(Duration::from_secs(5), all_stream.next()) + .await + .expect("timed out waiting for transaction receipts") + .expect("subscription ended unexpectedly"); + let filtered_receipts = tokio::time::timeout(Duration::from_secs(5), filtered_stream.next()) + .await + .expect("timed out waiting for filtered transaction receipts") + .expect("subscription ended unexpectedly"); + + assert_eq!(all_receipts.len(), 2); + assert_eq!(all_receipts[0].transaction_hash(), *first.tx_hash()); + assert_eq!(all_receipts[1].transaction_hash(), *second.tx_hash()); + + assert_eq!(filtered_receipts.len(), 1); + assert_eq!(filtered_receipts[0].transaction_hash(), *second.tx_hash()); +} + +// A receipt subscription must drop its block listener once the subscriber unsubscribes, even if its +// filter never matched any transaction. +#[tokio::test(flavor = "multi_thread")] +async fn test_sub_transaction_receipts_cleanup_on_unsubscribe() { + let (api, _handle) = spawn(NodeConfig::test()).await; + api.anvil_set_auto_mine(false).await.unwrap(); + + let baseline = api.backend.new_block_listeners_count(); + + // Filter on a hash that never gets mined, so the task can only exit via the dropped receiver. + let filter = TransactionReceiptsParams { transaction_hashes: Some(vec![B256::random()]) }; + let rx = api.transaction_receipts_subscription(filter); + assert_eq!(api.backend.new_block_listeners_count(), baseline + 1); + + drop(rx); + + // Let the task observe the closed channel, then mine to trigger listener pruning. + tokio::time::sleep(Duration::from_millis(200)).await; + api.mine_one().await.unwrap(); + api.mine_one().await.unwrap(); + + assert_eq!( + api.backend.new_block_listeners_count(), + baseline, + "subscription task should terminate after unsubscribe" + ); +} + #[expect(clippy::disallowed_macros)] #[tokio::test(flavor = "multi_thread")] async fn test_sub_new_heads_fast() { @@ -261,7 +438,7 @@ async fn test_sub_new_heads_fast() { let mut block_numbers = Vec::new(); for _ in 0..num { - api.mine_one().await; + api.mine_one().await.unwrap(); let block_number = blocks.next().await.unwrap().number; block_numbers.push(block_number); } diff --git a/crates/anvil/tests/it/revert.rs b/crates/anvil/tests/it/revert.rs index ab85fc89abf80..76c321ddfbfc9 100644 --- a/crates/anvil/tests/it/revert.rs +++ b/crates/anvil/tests/it/revert.rs @@ -28,6 +28,54 @@ async fn test_deploy_reverting() { assert!(!receipt.inner.inner.status()); } +#[tokio::test(flavor = "multi_thread")] +async fn test_invalid_opcode_rpc_error_code() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + let sender = handle.dev_accounts().next().unwrap(); + + // Deploy a contract whose runtime bytecode is the invalid opcode 0xfe. + let code = bytes!("60fe60005360016000f3"); + let tx = TransactionRequest::default().from(sender).with_deploy_code(code); + let receipt = provider + .send_transaction(WithOtherFields::new(tx)) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + let contract = receipt.contract_address.unwrap(); + + for (method, params) in [ + ("eth_call", serde_json::json!([{ "from": sender, "to": contract }, "latest"])), + ("eth_estimateGas", serde_json::json!([{ "from": sender, "to": contract }])), + ] { + let error = rpc_error(&handle.http_endpoint(), method, params).await; + assert_eq!(error["code"], serde_json::json!(-32003), "{error}"); + assert!(error.get("data").is_none(), "{error}"); + + let message = error["message"].as_str().unwrap(); + assert!(message.contains("EVM error InvalidFEOpcode"), "{error}"); + assert!(!message.contains("execution reverted"), "{error}"); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_empty_revert_data_rpc_error() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let sender = handle.dev_accounts().next().unwrap(); + + for (method, params) in [ + ("eth_call", serde_json::json!([{ "from": sender, "data": "0x5f5ffd" }, "latest"])), + ("eth_estimateGas", serde_json::json!([{ "from": sender, "data": "0x5f5ffd" }])), + ] { + let error = rpc_error(&handle.http_endpoint(), method, params).await; + assert_eq!(error["code"], serde_json::json!(3), "{error}"); + assert_eq!(error["message"], serde_json::json!("execution reverted"), "{error}"); + assert_eq!(error["data"], serde_json::json!("0x"), "{error}"); + } +} + #[tokio::test(flavor = "multi_thread")] async fn test_revert_messages() { sol!( @@ -69,8 +117,7 @@ async fn test_solc_revert_example() { let contract = VendingMachine::deploy(&provider).await.unwrap(); - let err = - contract.buy(U256::from(100)).value(U256::from(1)).from(sender).call().await.unwrap_err(); + let err = contract.buy(U256::from(100)).value(U256::ONE).from(sender).call().await.unwrap_err(); let s = err.to_string(); assert!(s.contains("Not enough Ether provided."), "{s:?}"); } @@ -96,7 +143,7 @@ async fn test_another_revert_message() { let contract = Contract::deploy(&provider).await.unwrap(); - let err = contract.setNumber(U256::from(0)).call().await.unwrap_err(); + let err = contract.setNumber(U256::ZERO).call().await.unwrap_err(); let s = err.to_string(); assert!(s.contains("RevertStringFooBar"), "{s:?}"); } @@ -124,3 +171,21 @@ async fn test_solc_revert_custom_errors() { let s = err.to_string(); assert!(s.contains("execution reverted"), "{s:?}"); } + +async fn rpc_error(endpoint: &str, method: &str, params: serde_json::Value) -> serde_json::Value { + let response = reqwest::Client::new() + .post(endpoint) + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + })) + .send() + .await + .unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::OK); + + let body = response.json::().await.unwrap(); + body.get("error").cloned().unwrap_or_else(|| panic!("expected JSON-RPC error, got {body}")) +} diff --git a/crates/anvil/tests/it/sign.rs b/crates/anvil/tests/it/sign.rs index b6481115c3be0..077f75fd669be 100644 --- a/crates/anvil/tests/it/sign.rs +++ b/crates/anvil/tests/it/sign.rs @@ -1,7 +1,7 @@ use crate::utils::http_provider_with_signer; use alloy_dyn_abi::TypedData; use alloy_network::EthereumWallet; -use alloy_primitives::{Address, U256}; +use alloy_primitives::{Address, U256, address}; use alloy_provider::Provider; use alloy_rpc_types::TransactionRequest; use alloy_serde::WithOtherFields; @@ -83,10 +83,7 @@ async fn can_sign_typed_data() { // `curl -X POST -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","method": "eth_signTypedData_v4", "params": ["0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", {"types":{"EIP712Domain":[{"name":"name","type":"string"},{"name":"version","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}],"Person":[{"name":"name","type":"string"},{"name":"wallet","type":"address"}],"Mail":[{"name":"from","type":"Person"},{"name":"to","type":"Person"},{"name":"contents","type":"string"}]},"primaryType":"Mail","domain":{"name":"Ether Mail","version":"1","chainId":1,"verifyingContract":"0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC"},"message":{"from":{"name":"Cow","wallet":"0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826"},"to":{"name":"Bob","wallet":"0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB"},"contents":"Hello, Bob!"}}],"id":67}' http://localhost:8545` let signature = api - .sign_typed_data_v4( - "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266".parse().unwrap(), - &typed_data, - ) + .sign_typed_data_v4(address!("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"), &typed_data) .await .unwrap(); assert_eq!( @@ -270,10 +267,7 @@ async fn can_sign_typed_data_os() { // `curl -X POST http://localhost:8545 -d '{"jsonrpc": "2.0", "method": "eth_signTypedData_v4", "params": ["0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", {"types":{"EIP712Domain":[{"name":"name","type":"string"},{"name":"version","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}],"OrderComponents":[{"name":"offerer","type":"address"},{"name":"zone","type":"address"},{"name":"offer","type":"OfferItem[]"},{"name":"consideration","type":"ConsiderationItem[]"},{"name":"orderType","type":"uint8"},{"name":"startTime","type":"uint256"},{"name":"endTime","type":"uint256"},{"name":"zoneHash","type":"bytes32"},{"name":"salt","type":"uint256"},{"name":"conduitKey","type":"bytes32"},{"name":"counter","type":"uint256"}],"OfferItem":[{"name":"itemType","type":"uint8"},{"name":"token","type":"address"},{"name":"identifierOrCriteria","type":"uint256"},{"name":"startAmount","type":"uint256"},{"name":"endAmount","type":"uint256"}],"ConsiderationItem":[{"name":"itemType","type":"uint8"},{"name":"token","type":"address"},{"name":"identifierOrCriteria","type":"uint256"},{"name":"startAmount","type":"uint256"},{"name":"endAmount","type":"uint256"},{"name":"recipient","type":"address"}]},"primaryType":"OrderComponents","domain":{"name":"Seaport","version":"1.1","chainId":"1","verifyingContract":"0x00000000006c3852cbEf3e08E8dF289169EdE581"},"message":{"offerer":"0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266","offer":[{"itemType":"3","token":"0xA604060890923Ff400e8c6f5290461A83AEDACec","identifierOrCriteria":"110194434039389003190498847789203126033799499726478230611233094448886344768909","startAmount":"1","endAmount":"1"}],"consideration":[{"itemType":"0","token":"0x0000000000000000000000000000000000000000","identifierOrCriteria":"0","startAmount":"487500000000000000","endAmount":"487500000000000000","recipient":"0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"},{"itemType":"0","token":"0x0000000000000000000000000000000000000000","identifierOrCriteria":"0","startAmount":"12500000000000000","endAmount":"12500000000000000","recipient":"0x8De9C5A032463C561423387a9648c5C7BCC5BC90"}],"startTime":"1658645591","endTime":"1659250386","orderType":"3","zone":"0x004C00500000aD104D7DBd00e3ae0A5C00560C00","zoneHash":"0x0000000000000000000000000000000000000000000000000000000000000000","salt":"16178208897136618","conduitKey":"0x0000007b02230091a7ed01230072f7006a004d60a8d4e71d599b8104250f0000","totalOriginalConsiderationItems":"2","counter":"0"}}], "id": "1"}' -H "Content-Type: application/json"` let signature = api - .sign_typed_data_v4( - "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266".parse().unwrap(), - &typed_data, - ) + .sign_typed_data_v4(address!("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"), &typed_data) .await .unwrap(); @@ -510,10 +504,7 @@ async fn can_sign_typed_seaport_data() { // `curl -X POST http://localhost:8545 -d '{"jsonrpc": "2.0", "method": "eth_signTypedData_v4", "params": ["0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", "{\"types\":{\"EIP712Domain\":[{\"name\":\"name\",\"type\":\"string\"},{\"name\":\"version\",\"type\":\"string\"},{\"name\":\"chainId\",\"type\":\"uint256\"},{\"name\":\"verifyingContract\",\"type\":\"address\"}],\"OrderComponents\":[{\"name\":\"offerer\",\"type\":\"address\"},{\"name\":\"zone\",\"type\":\"address\"},{\"name\":\"offer\",\"type\":\"OfferItem[]\"},{\"name\":\"consideration\",\"type\":\"ConsiderationItem[]\"},{\"name\":\"orderType\",\"type\":\"uint8\"},{\"name\":\"startTime\",\"type\":\"uint256\"},{\"name\":\"endTime\",\"type\":\"uint256\"},{\"name\":\"zoneHash\",\"type\":\"bytes32\"},{\"name\":\"salt\",\"type\":\"uint256\"},{\"name\":\"conduitKey\",\"type\":\"bytes32\"},{\"name\":\"counter\",\"type\":\"uint256\"}],\"OfferItem\":[{\"name\":\"itemType\",\"type\":\"uint8\"},{\"name\":\"token\",\"type\":\"address\"},{\"name\":\"identifierOrCriteria\",\"type\":\"uint256\"},{\"name\":\"startAmount\",\"type\":\"uint256\"},{\"name\":\"endAmount\",\"type\":\"uint256\"}],\"ConsiderationItem\":[{\"name\":\"itemType\",\"type\":\"uint8\"},{\"name\":\"token\",\"type\":\"address\"},{\"name\":\"identifierOrCriteria\",\"type\":\"uint256\"},{\"name\":\"startAmount\",\"type\":\"uint256\"},{\"name\":\"endAmount\",\"type\":\"uint256\"},{\"name\":\"recipient\",\"type\":\"address\"}]},\"primaryType\":\"OrderComponents\",\"domain\":{\"name\":\"Seaport\",\"version\":\"1.1\",\"chainId\":\"137\",\"verifyingContract\":\"0x00000000006c3852cbEf3e08E8dF289169EdE581\"},\"message\":{\"offerer\":\"0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266\",\"offer\":[{\"itemType\":\"3\",\"token\":\"0xA604060890923Ff400e8c6f5290461A83AEDACec\",\"identifierOrCriteria\":\"110194434039389003190498847789203126033799499726478230611233094448886344768909\",\"startAmount\":\"1\",\"endAmount\":\"1\"}],\"consideration\":[{\"itemType\":\"0\",\"token\":\"0x0000000000000000000000000000000000000000\",\"identifierOrCriteria\":\"0\",\"startAmount\":\"487500000000000000\",\"endAmount\":\"487500000000000000\",\"recipient\":\"0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266\"},{\"itemType\":\"0\",\"token\":\"0x0000000000000000000000000000000000000000\",\"identifierOrCriteria\":\"0\",\"startAmount\":\"12500000000000000\",\"endAmount\":\"12500000000000000\",\"recipient\":\"0x8De9C5A032463C561423387a9648c5C7BCC5BC90\"}],\"startTime\":\"1658645591\",\"endTime\":\"1659250386\",\"orderType\":\"3\",\"zone\":\"0x004C00500000aD104D7DBd00e3ae0A5C00560C00\",\"zoneHash\":\"0x0000000000000000000000000000000000000000000000000000000000000000\",\"salt\":\"16178208897136618\",\"conduitKey\":\"0x0000007b02230091a7ed01230072f7006a004d60a8d4e71d599b8104250f0000\",\"totalOriginalConsiderationItems\":\"2\",\"counter\":\"0\"}}"], "id": "1"}' -H "Content-Type: application/json"` let signature = api - .sign_typed_data_v4( - "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266".parse().unwrap(), - &typed_data, - ) + .sign_typed_data_v4(address!("0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266"), &typed_data) .await .unwrap(); diff --git a/crates/anvil/tests/it/simulate.rs b/crates/anvil/tests/it/simulate.rs index 1491b53a30039..e62bc8599606f 100644 --- a/crates/anvil/tests/it/simulate.rs +++ b/crates/anvil/tests/it/simulate.rs @@ -1,44 +1,2926 @@ //! general eth api tests -use alloy_primitives::{TxKind, U256, address}; +use alloy_consensus::{Header, constants::EMPTY_WITHDRAWALS}; + +use alloy_eips::{ + eip2935::HISTORY_STORAGE_ADDRESS, + eip4788::BEACON_ROOTS_ADDRESS, + eip6110::DEPOSIT_REQUEST_TYPE, + eip7002::{WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, WITHDRAWAL_REQUEST_TYPE}, + eip7251::{CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS, CONSOLIDATION_REQUEST_TYPE}, + eip7685::{EMPTY_REQUESTS_HASH, Requests}, + eip7928::{ + AccountChanges, BlockAccessIndex, NonceChange, SlotChanges, StorageChange, + compute_block_access_list_hash, + }, +}; +use alloy_evm::{ + block::system_calls::{ + BUILDER_DEPOSIT_REQUEST_PREDEPLOY_ADDRESS, BUILDER_EXIT_REQUEST_PREDEPLOY_ADDRESS, + }, + precompiles::{DynPrecompile, PrecompileInput, PrecompilesMap}, +}; +use alloy_genesis::Genesis; +use alloy_primitives::{Address, B256, Bloom, Bytes, Log, TxKind, U256, address}; +use alloy_provider::Provider; use alloy_rpc_types::{ - BlockOverrides, + BlockNumberOrTag, BlockOverrides, request::TransactionRequest, simulate::{SimBlock, SimulatePayload}, state::{AccountOverride, StateOverridesBuilder}, }; -use anvil::{NodeConfig, spawn}; +use alloy_serde::WithOtherFields; +use alloy_sol_types::{SolEvent, sol}; +use anvil::{EthereumHardfork, NodeConfig, PrecompileFactory, spawn}; +use axum::{Json, Router, routing::post}; use foundry_test_utils::rpc; +use revm::precompile::{PrecompileError, PrecompileOutput, PrecompileStatus}; +use serde_json::{Value, json}; +use std::{ + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; + +sol! { + event DepositEvent( + bytes pubkey, + bytes withdrawal_credentials, + bytes amount, + bytes signature, + bytes index + ); +} + +fn deposit_event_runtime(event: DepositEvent) -> Bytes { + let log = DepositEvent::encode_log(&Log { address: Address::ZERO, data: event }); + let data = log.data.data.as_ref(); + let data_len = u16::try_from(data.len()).unwrap(); + const CODE_OFFSET: u16 = 47; + let mut code = Vec::with_capacity(usize::from(CODE_OFFSET) + data.len()); + code.extend([0x61]); + code.extend(data_len.to_be_bytes()); + code.extend([0x61]); + code.extend(CODE_OFFSET.to_be_bytes()); + code.extend([0x5f, 0x39, 0x7f]); + code.extend(DepositEvent::SIGNATURE_HASH); + code.extend([0x61]); + code.extend(data_len.to_be_bytes()); + code.extend([0x5f, 0xa1, 0x00]); + code.extend(data); + code.into() +} +/// Checks BAL contents and indices for pre-block, transaction, and post-block execution. #[tokio::test(flavor = "multi_thread")] -async fn test_fork_simulate_v1() { +async fn test_simulate_block_access_list_hash_rpc() { + let from = Address::with_last_byte(0x41); + let contract = Address::with_last_byte(0x42); + let beneficiary = Address::with_last_byte(0x43); + let system_contracts = [ + HISTORY_STORAGE_ADDRESS, + BEACON_ROOTS_ADDRESS, + WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, + CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS, + BUILDER_DEPOSIT_REQUEST_PREDEPLOY_ADDRESS, + BUILDER_EXIT_REQUEST_PREDEPLOY_ADDRESS, + ]; + let mut overrides = serde_json::Map::new(); + for address in system_contracts { + overrides.insert(address.to_string(), json!({"code": "0x", "state": {}})); + } + // Deterministic system writes expose the pre/post-block access indices in the BAL hash. + for address in [HISTORY_STORAGE_ADDRESS, WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS] { + overrides.insert(address.to_string(), json!({"code": "0x602a5f5500", "state": {}})); + } + overrides.insert(from.to_string(), json!({"nonce": "0x0"})); + overrides.insert(contract.to_string(), json!({"code": "0x5f5450602a60015500", "state": {}})); + let block = json!({ + "stateOverrides": overrides, + "blockOverrides": {"feeRecipient": beneficiary}, + "calls": [{"from": from, "to": contract}] + }); + let mut expected = system_contracts.map(AccountChanges::new).to_vec(); + for (address, index) in + [(HISTORY_STORAGE_ADDRESS, 0), (WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, 2)] + { + expected.iter_mut().find(|account| account.address() == address).unwrap().storage_changes = + vec![SlotChanges::new( + U256::ZERO, + vec![StorageChange::new(BlockAccessIndex::new(index), U256::from(42))], + )]; + } + expected.push(AccountChanges { + nonce_changes: vec![NonceChange::new(BlockAccessIndex::new(1), 1)], + ..AccountChanges::new(from) + }); + expected.push(AccountChanges { + storage_reads: vec![U256::ZERO], + storage_changes: vec![SlotChanges::new( + U256::ONE, + vec![StorageChange::new(BlockAccessIndex::new(1), U256::from(42))], + )], + ..AccountChanges::new(contract) + }); + expected.push(AccountChanges::new(beneficiary)); + expected.sort_by_key(|account| account.address()); + let expected_hash = compute_block_access_list_hash(&expected); + + for hardfork in [EthereumHardfork::Osaka, EthereumHardfork::Amsterdam] { + let (_api, handle) = + spawn(NodeConfig::test().with_hardfork(Some(hardfork.into())).with_base_fee(Some(0))) + .await; + for trace_transfers in [false, true] { + let response = rpc_request( + &handle.http_endpoint(), + "eth_simulateV1", + json!([{ + "blockStateCalls": [block.clone(), block.clone(), {}], + "traceTransfers": trace_transfers, + "returnFullTransactions": true + }, "latest"]), + ) + .await; + assert!(response.get("error").is_none(), "{response}"); + let blocks = response["result"].as_array().unwrap(); + assert_eq!(blocks.len(), 3); + for block in &blocks[..2] { + assert_eq!(block["calls"][0]["status"], "0x1"); + if hardfork == EthereumHardfork::Amsterdam { + assert_eq!(block["blockAccessListHash"], json!(expected_hash)); + } else { + assert!(block.get("blockAccessListHash").is_none()); + } + let header = serde_json::from_value::
(block.clone()).unwrap(); + assert_eq!(block["hash"], json!(header.hash_slow())); + assert_eq!(block["transactions"][0]["blockHash"], block["hash"]); + } + if hardfork == EthereumHardfork::Amsterdam { + assert!(blocks[2]["blockAccessListHash"].is_string()); + assert_ne!(blocks[2]["blockAccessListHash"], json!(expected_hash)); + } + for pair in blocks.windows(2) { + assert_eq!(pair[1]["parentHash"], pair[0]["hash"]); + } + } + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_includes_block_size_rpc() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let response = rpc_request( + &handle.http_endpoint(), + "eth_simulateV1", + json!([{"blockStateCalls": [{}]}, "latest"]), + ) + .await; + + assert!(response.get("error").is_none(), "{response}"); + assert!(quantity(&response["result"][0]["size"]) > 0); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_simulate_native_transfers_rpc() { crate::init_tracing(); - let (api, _) = - spawn(NodeConfig::test().with_eth_rpc_url(Some(rpc::next_http_archive_rpc_url()))).await; - let block_overrides = - Some(BlockOverrides { base_fee: Some(U256::from(9)), ..Default::default() }); - let account_override = - AccountOverride { balance: Some(U256::from(999999999999u64)), ..Default::default() }; + let (_, handle) = spawn( + NodeConfig::test() + .with_eth_rpc_url(Some(rpc::next_http_archive_rpc_url())) + .with_fork_block_number(Some(24_000_000u64)), + ) + .await; + let from = address!("0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266"); + let payload = SimulatePayload { + block_state_calls: vec![SimBlock { + calls: vec![ + TransactionRequest { + from: Some(from), + to: Some(TxKind::from(address!("0x1000000000000000000000000000000000000001"))), + value: Some(U256::ONE), + ..Default::default() + }, + TransactionRequest { + from: Some(from), + to: Some(TxKind::from(address!("0x1000000000000000000000000000000000000002"))), + value: Some(U256::ONE), + ..Default::default() + }, + ], + ..Default::default() + }], + validation: false, + ..Default::default() + }; + let response = + rpc_request(&handle.http_endpoint(), "eth_simulateV1", json!([payload, "latest"])).await; + assert!(response.get("error").is_none(), "{response}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_simulate_multiple_blocks_rpc() { + let (source_api, source_handle) = + spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Shanghai.into()))).await; + let contract = Address::with_last_byte(0x42); + source_api + .anvil_set_code( + contract, + Bytes::from_static(&[0x60, 0x01, 0x43, 0x60, 0x01, 0x03, 0x40, 0x55, 0x00]), + ) + .await + .unwrap(); + source_api.mine_one().await.unwrap(); + let accounts = source_handle.dev_accounts().take(2).collect::>(); + let payload = json!({ + "blockStateCalls": [ + { + "calls": [{ + "from": accounts[0], + "to": accounts[1], + "value": "0x1" + }] + }, + { + "calls": [{ + "from": accounts[0], + "to": contract + }] + } + ], + "validation": false + }); + + let (_, fork_handle) = spawn( + NodeConfig::test() + .with_hardfork(Some(EthereumHardfork::Shanghai.into())) + .with_eth_rpc_url(Some(source_handle.http_endpoint())) + .with_fork_block_number(Some(1u64)), + ) + .await; + let response = + rpc_request(&fork_handle.http_endpoint(), "eth_simulateV1", json!([payload, "latest"])) + .await; + assert!(response.get("error").is_none(), "{response}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_simulate_post_merge_block_context_rpc() { + let (source_api, source_handle) = + spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Prague.into()))).await; + source_api.anvil_set_next_block_prevrandao(B256::repeat_byte(0x42)).await.unwrap(); + source_api.mine_one().await.unwrap(); + + let (_, fork_handle) = spawn( + NodeConfig::test() + .with_hardfork(Some(EthereumHardfork::Prague.into())) + .with_eth_rpc_url(Some(source_handle.http_endpoint())) + .with_fork_block_number(Some(1u64)), + ) + .await; + let contract = "0xc000000000000000000000000000000000000000"; + let response = rpc_request( + &fork_handle.http_endpoint(), + "eth_simulateV1", + json!([{ + "blockStateCalls": [{ + "stateOverrides": { + (contract): {"code": "0x445f5260205ff3"} + }, + "calls": [{"to": contract}] + }] + }, "latest"]), + ) + .await; + assert!(response.get("error").is_none(), "{response}"); + + let block = &response["result"][0]; + assert_eq!(block["difficulty"], "0x0"); + assert_eq!(block["mixHash"], B256::ZERO.to_string()); + assert_eq!(block["calls"][0]["returnData"], format!("0x{}", "00".repeat(32))); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_v1_moves_precompiles_per_block_rpc() { + let (_, handle) = spawn(NodeConfig::test()).await; + let endpoint = handle.http_endpoint(); + let source = "0x0000000000000000000000000000000000000004"; + let destination = "0x0000000000000000000000000000000000123456"; + let return_42 = "0x000000000000000000000000000000000000000000000000000000000000002a"; + + let response = rpc_request( + &endpoint, + "eth_simulateV1", + json!([{ + "blockStateCalls": [ + { + "stateOverrides": { + source: { + "code": "0x602a60005260206000f3", + "movePrecompileToAddress": destination + } + }, + "calls": [ + {"to": source, "input": "0x1234"}, + {"to": destination, "input": "0x1234"} + ] + }, + { + "calls": [ + {"to": source, "input": "0x1234"}, + {"to": destination, "input": "0x1234"} + ] + } + ] + }, "latest"]), + ) + .await; + assert!(response.get("error").is_none(), "{response}"); + + let blocks = response["result"].as_array().unwrap(); + assert_eq!(blocks[0]["calls"][0]["returnData"], return_42); + assert_eq!(blocks[0]["calls"][1]["returnData"], "0x1234"); + assert_eq!(blocks[1]["calls"][0]["returnData"], "0x1234"); + assert_eq!(blocks[1]["calls"][1]["returnData"], "0x"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_v1_preserves_precompile_warming_rpc() { + let (_, handle) = spawn(NodeConfig::test()).await; + let source = "0x0000000000000000000000000000000000000004"; + let destination = "0x0000000000000000000000000000000000123456"; + let helper = "0x0000000000000000000000000000000000001000"; + let helper_code = format!( + "0x5a73{}31505a90035f525a73{}31505a90036020525a73{}31505a900360405260605ff3", + &source[2..], + &destination[2..], + &destination[2..], + ); + + // A zero-priced blob call uses the simulation handler; an ordinary call uses transact. + for call in [ + json!({"to": helper}), + json!({ + "to": helper, + "blobVersionedHashes": [format!("0x01{}", "00".repeat(31))], + "maxFeePerBlobGas": "0x0" + }), + ] { + let response = rpc_request( + &handle.http_endpoint(), + "eth_simulateV1", + json!([{"blockStateCalls": [{ + "stateOverrides": { + source: {"movePrecompileToAddress": destination}, + helper: {"code": helper_code} + }, + "calls": [call] + }]}, "latest"]), + ) + .await; + assert!(response.get("error").is_none(), "{response}"); + + let return_data = response["result"][0]["calls"][0]["returnData"].as_str().unwrap(); + let return_data = alloy_primitives::hex::decode(&return_data[2..]).unwrap(); + let access_costs = return_data + .as_chunks::<32>() + .0 + .iter() + .copied() + .map(U256::from_be_bytes) + .collect::>(); + + // The measured delta includes PUSH20, POP, and the second GAS opcode (7 gas total). + assert_eq!(access_costs, [U256::from(107), U256::from(2_607), U256::from(107)]); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_v1_validates_precompile_moves_rpc() { + let (_, handle) = spawn(NodeConfig::test()).await; + let endpoint = handle.http_endpoint(); + let precompile_one = "0x0000000000000000000000000000000000000001"; + let precompile_two = "0x0000000000000000000000000000000000000002"; + let not_precompile = "0xc100000000000000000000000000000000000000"; + let destination = "0xc200000000000000000000000000000000000000"; + + let cases = [ + ( + json!([{"blockStateCalls": [{"stateOverrides": { + not_precompile: {"movePrecompileToAddress": not_precompile} + }}]}, "latest"]), + -32000, + ), + ( + json!([{"blockStateCalls": [{"stateOverrides": { + precompile_one: {"movePrecompileToAddress": precompile_one} + }}]}, "latest"]), + -38022, + ), + ( + json!([{"blockStateCalls": [{"stateOverrides": { + not_precompile: {"movePrecompileToAddress": destination}, + "0xc300000000000000000000000000000000000000": { + "movePrecompileToAddress": destination + } + }}]}, "latest"]), + -32000, + ), + ( + json!([{"blockStateCalls": [{"stateOverrides": { + precompile_one: {"movePrecompileToAddress": destination}, + precompile_two: {"movePrecompileToAddress": destination} + }}]}, "latest"]), + -38023, + ), + ]; + + for (params, expected_code) in cases { + let response = rpc_request(&endpoint, "eth_simulateV1", params).await; + assert_eq!(response["error"]["code"], expected_code, "{response}"); + } + + let (_, handle) = + spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Homestead.into()))).await; + let response = rpc_request( + &handle.http_endpoint(), + "eth_simulateV1", + json!([{"blockStateCalls": [{"stateOverrides": { + "0x0000000000000000000000000000000000000005": { + "movePrecompileToAddress": destination + } + }}]}, "latest"]), + ) + .await; + assert_eq!(response["error"]["code"], -32000, "{response}"); +} + +#[derive(Clone, Copy, Debug)] +struct LookupOnlyPrecompileFactory(Address); + +impl PrecompileFactory for LookupOnlyPrecompileFactory { + fn precompiles(&self) -> Vec<(Address, DynPrecompile)> { + Vec::new() + } + + fn install(&self, precompiles: &mut PrecompilesMap) { + let lookup_address = self.0; + precompiles.set_precompile_lookup(move |address: &Address| { + (*address == lookup_address).then(|| { + DynPrecompile::from(|input: PrecompileInput<'_>| { + Ok(PrecompileOutput { + status: PrecompileStatus::Success, + bytes: Bytes::copy_from_slice(input.data), + gas_used: 0, + gas_refunded: 0, + state_gas_used: 0, + state_gas_spilled: 0, + reservoir: input.reservoir, + }) + }) + }) + }); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_v1_rejects_lookup_only_precompile_move_rpc() { + let source = address!("0xdead000000000000000000000000000000000071"); + let (_, handle) = + spawn(NodeConfig::test().with_precompile_factory(LookupOnlyPrecompileFactory(source))) + .await; + let response = rpc_request( + &handle.http_endpoint(), + "eth_simulateV1", + json!([{"blockStateCalls": [{"stateOverrides": { + (source.to_string()): { + "movePrecompileToAddress": "0x0000000000000000000000000000000000123456" + } + }}]}, "latest"]), + ) + .await; + + assert_eq!(response["error"]["code"], -32000, "{response}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_v1_rejects_precompile_moves_on_tempo_rpc() { + let (_, handle) = spawn(NodeConfig::test_tempo()).await; + let response = rpc_request( + &handle.http_endpoint(), + "eth_simulateV1", + json!([{"blockStateCalls": [{"stateOverrides": { + "0x0000000000000000000000000000000000000004": { + "movePrecompileToAddress": "0x0000000000000000000000000000000000123456" + } + }}]}, "latest"]), + ) + .await; + + assert_eq!(response["error"]["code"], -32000, "{response}"); +} + +#[cfg(feature = "monad")] +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_v1_rejects_precompile_moves_on_monad_rpc() { + let (_, handle) = spawn(NodeConfig::test_monad()).await; + let response = rpc_request( + &handle.http_endpoint(), + "eth_simulateV1", + json!([{"blockStateCalls": [{"stateOverrides": { + "0x0000000000000000000000000000000000000004": { + "movePrecompileToAddress": "0x0000000000000000000000000000000000123456" + } + }}]}, "latest"]), + ) + .await; + + assert_eq!(response["error"]["code"], -32000, "{response}"); +} + +#[cfg(feature = "optimism")] +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_v1_rejects_precompile_moves_on_optimism_rpc() { + let (_, handle) = spawn(NodeConfig::test().with_optimism()).await; + let response = rpc_request( + &handle.http_endpoint(), + "eth_simulateV1", + json!([{"blockStateCalls": [{"stateOverrides": { + "0x0000000000000000000000000000000000000004": { + "movePrecompileToAddress": "0x0000000000000000000000000000000000123456" + } + }}]}, "latest"]), + ) + .await; + + assert_eq!(response["error"]["code"], -32000, "{response}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_simulate_normalizes_delegated_block_sequence_rpc() { + let (origin_api, origin_handle) = + spawn(NodeConfig::test().with_genesis_timestamp(Some(1_000u64))).await; + origin_api.mine_one().await.unwrap(); + origin_api.mine_one().await.unwrap(); + + let (api, handle) = spawn( + NodeConfig::test() + .with_eth_rpc_url(Some(origin_handle.http_endpoint())) + .with_fork_block_number(Some(2u64)), + ) + .await; + api.evm_set_block_timestamp_interval(7).unwrap(); + + let endpoint = handle.http_endpoint(); + let base = rpc_request(&endpoint, "eth_getBlockByNumber", json!(["0x1", false])).await; + let base_number = quantity(&base["result"]["number"]); + let base_timestamp = quantity(&base["result"]["timestamp"]); + let response = rpc_request( + &endpoint, + "eth_simulateV1", + json!([{ + "blockStateCalls": [{ + "blockOverrides": {"number": format!("{:#x}", base_number + 3)} + }] + }, "0x1"]), + ) + .await; + assert!(response.get("error").is_none(), "{response}"); + + let blocks = response["result"].as_array().unwrap(); + assert_eq!(blocks.len(), 3); + for (index, block) in blocks.iter().enumerate() { + let offset = index as u64 + 1; + assert_eq!(quantity(&block["number"]), base_number + offset); + assert_eq!(quantity(&block["timestamp"]), base_timestamp + 7 * offset); + } + + let cases = [ + ( + json!([{"blockStateCalls": [{ + "blockOverrides": {"number": format!("{base_number:#x}")} + }]}, "0x1"]), + -38020, + ), + ( + json!([{"blockStateCalls": [{ + "blockOverrides": {"time": format!("{base_timestamp:#x}")} + }]}, "0x1"]), + -38021, + ), + ( + json!([{"blockStateCalls": [{ + "blockOverrides": {"number": format!("{:#x}", base_number + 257)} + }]}, "0x1"]), + -38026, + ), + ]; + for (params, code) in cases { + let response = rpc_request(&endpoint, "eth_simulateV1", params).await; + assert_eq!(response["error"]["code"], code, "{response}"); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_simulate_preserves_delegated_base_selector_rpc() { + let (hash_api, hash_handle) = + spawn(NodeConfig::test().with_genesis_timestamp(Some(1_000u64))).await; + hash_api.evm_set_block_timestamp_interval(1).unwrap(); + hash_api.mine_one().await.unwrap(); + hash_api.mine_one().await.unwrap(); + let hash_endpoint = hash_handle.http_endpoint(); + let hash_base = + rpc_request(&hash_endpoint, "eth_getBlockByNumber", json!(["0x1", false])).await; + let hash_selector = hash_base["result"]["hash"].clone(); + + let (canonical_api, canonical_handle) = + spawn(NodeConfig::test().with_genesis_timestamp(Some(2_000u64))).await; + canonical_api.evm_set_block_timestamp_interval(1).unwrap(); + canonical_api.mine_one().await.unwrap(); + canonical_api.mine_one().await.unwrap(); + let canonical_endpoint = canonical_handle.http_endpoint(); + let canonical_base = + rpc_request(&canonical_endpoint, "eth_getBlockByNumber", json!(["0x1", false])).await; + let canonical_hash = canonical_base["result"]["hash"].clone(); + + let proxy_endpoint = + spawn_hash_aware_rpc_proxy(hash_endpoint, canonical_endpoint, hash_selector.clone()).await; + let (api, handle) = spawn( + NodeConfig::test() + .with_eth_rpc_url(Some(proxy_endpoint.clone())) + .with_fork_block_number(Some(2u64)), + ) + .await; + api.evm_set_block_timestamp_interval(7).unwrap(); + let endpoint = handle.http_endpoint(); + + // Cache the selected block, then replace the same-height cache mapping with another block. + rpc_request(&endpoint, "eth_getBlockByHash", json!([hash_selector, false])).await; + rpc_request(&endpoint, "eth_getBlockByHash", json!([canonical_hash, false])).await; + + let response = rpc_request( + &endpoint, + "eth_simulateV1", + json!([{"blockStateCalls": [{}]}, {"blockHash": hash_selector}]), + ) + .await; + assert!(response.get("error").is_none(), "{response}"); + assert_eq!( + quantity(&response["result"][0]["timestamp"]), + quantity(&hash_base["result"]["timestamp"]) + 7 + ); + + let (api, handle) = spawn( + NodeConfig::test() + .with_eth_rpc_url(Some(proxy_endpoint)) + .with_fork_block_number(Some(2u64)), + ) + .await; + api.evm_set_block_timestamp_interval(7).unwrap(); + let endpoint = handle.http_endpoint(); + + // Cache a noncanonical block at the selected height before using a numeric selector. + rpc_request(&endpoint, "eth_getBlockByHash", json!([hash_selector, false])).await; + let response = + rpc_request(&endpoint, "eth_simulateV1", json!([{"blockStateCalls": [{}]}, "0x1"])).await; + assert!(response.get("error").is_none(), "{response}"); + assert_eq!( + quantity(&response["result"][0]["timestamp"]), + quantity(&canonical_base["result"]["timestamp"]) + 7 + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_simulate_pins_delegated_tag_selector_rpc() { + let (origin_api, origin_handle) = + spawn(NodeConfig::test().with_genesis_timestamp(Some(1_000u64)).with_slots_in_an_epoch(2)) + .await; + origin_api.evm_set_block_timestamp_interval(1).unwrap(); + origin_api.anvil_mine(Some(U256::from(4)), None).await.unwrap(); + + let (api, handle) = spawn( + NodeConfig::test() + .with_eth_rpc_url(Some(origin_handle.http_endpoint())) + .with_fork_block_number(Some(4u64)) + .with_slots_in_an_epoch(2), + ) + .await; + api.evm_set_block_timestamp_interval(1).unwrap(); + + // Advance the upstream safe tag beyond the fork while the local tag remains at block 2. + origin_api.anvil_mine(Some(U256::from(4)), None).await.unwrap(); + let response = rpc_request( + &handle.http_endpoint(), + "eth_simulateV1", + json!([{"blockStateCalls": [{}]}, "safe"]), + ) + .await; + + assert!(response.get("error").is_none(), "{response}"); + assert_eq!(quantity(&response["result"][0]["number"]), 3); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_normalizes_block_sequence_rpc() { + let (_api, handle) = spawn(NodeConfig::test().with_genesis_timestamp(Some(1_000u64))).await; + let endpoint = handle.http_endpoint(); + let latest = rpc_request(&endpoint, "eth_getBlockByNumber", json!(["latest", false])).await; + let base = &latest["result"]; + let base_number = quantity(&base["number"]); + let base_timestamp = quantity(&base["timestamp"]); + + let response = rpc_request( + &endpoint, + "eth_simulateV1", + json!([{ + "blockStateCalls": [ + { + "blockOverrides": { + "number": format!("{:#x}", base_number + 3), + "time": format!("{:#x}", base_timestamp + 100) + } + }, + {}, + { + "blockOverrides": { + "number": format!("{:#x}", base_number + 7) + } + } + ] + }, "latest"]), + ) + .await; + assert!(response.get("error").is_none(), "{response}"); + let blocks = response["result"].as_array().unwrap(); + + let expected = [ + (base_number + 1, base_timestamp + 12), + (base_number + 2, base_timestamp + 24), + (base_number + 3, base_timestamp + 100), + (base_number + 4, base_timestamp + 112), + (base_number + 5, base_timestamp + 124), + (base_number + 6, base_timestamp + 136), + (base_number + 7, base_timestamp + 148), + ]; + let mut expected_parent = base["hash"].clone(); + for (block, (number, timestamp)) in blocks.iter().zip(expected) { + assert_eq!(quantity(&block["number"]), number); + assert_eq!(quantity(&block["timestamp"]), timestamp); + assert_eq!(block["parentHash"], expected_parent); + expected_parent = block["hash"].clone(); + } + assert!(blocks[0]["calls"].as_array().unwrap().is_empty()); + assert!(blocks[1]["calls"].as_array().unwrap().is_empty()); + assert!(blocks[4]["calls"].as_array().unwrap().is_empty()); + assert!(blocks[5]["calls"].as_array().unwrap().is_empty()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_uses_configured_block_interval_rpc() { + for mixed_mining in [false, true] { + let config = NodeConfig::test() + .with_genesis_timestamp(Some(1_000u64)) + .with_mixed_mining(mixed_mining, Some(Duration::from_millis(1_500))); + let (api, handle) = spawn(config).await; + let endpoint = handle.http_endpoint(); + let base = rpc_request(&endpoint, "eth_getBlockByNumber", json!(["latest", false])).await; + let base_timestamp = quantity(&base["result"]["timestamp"]); + + let response = rpc_request( + &endpoint, + "eth_simulateV1", + json!([{"blockStateCalls": [{}, {}]}, "latest"]), + ) + .await; + let blocks = response["result"].as_array().unwrap(); + assert_eq!(quantity(&blocks[0]["timestamp"]), base_timestamp + 2); + assert_eq!(quantity(&blocks[1]["timestamp"]), base_timestamp + 4); + + api.evm_set_block_timestamp_interval(7).unwrap(); + let response = rpc_request( + &endpoint, + "eth_simulateV1", + json!([{"blockStateCalls": [{}, {}]}, "latest"]), + ) + .await; + let blocks = response["result"].as_array().unwrap(); + assert_eq!(quantity(&blocks[0]["timestamp"]), base_timestamp + 7); + assert_eq!(quantity(&blocks[1]["timestamp"]), base_timestamp + 14); + assert!(api.evm_remove_block_timestamp_interval().unwrap()); + + api.anvil_set_interval_mining(3).unwrap(); + let response = + rpc_request(&endpoint, "eth_simulateV1", json!([{"blockStateCalls": [{}]}, "latest"])) + .await; + assert_eq!(quantity(&response["result"][0]["timestamp"]), base_timestamp + 3); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_rejects_invalid_block_sequence_rpc() { + let (_api, handle) = spawn(NodeConfig::test().with_genesis_timestamp(Some(1_000u64))).await; + let endpoint = handle.http_endpoint(); + let base = rpc_request(&endpoint, "eth_getBlockByNumber", json!(["latest", false])).await; + let base_number = quantity(&base["result"]["number"]); + let base_timestamp = quantity(&base["result"]["timestamp"]); + let cases = [ + ( + json!([{"blockStateCalls": [{ + "blockOverrides": {"number": format!("{base_number:#x}")} + }]}, "latest"]), + -38020, + ), + ( + json!([{"blockStateCalls": [{ + "blockOverrides": {"time": format!("{base_timestamp:#x}")} + }]}, "latest"]), + -38021, + ), + ( + json!([{"blockStateCalls": [{ + "blockOverrides": { + "number": format!("{:#x}", base_number + 2), + "time": format!("{:#x}", base_timestamp + 12) + } + }]}, "latest"]), + -38021, + ), + ( + json!([{"blockStateCalls": [{ + "blockOverrides": {"number": format!("{:#x}", base_number + 257)} + }]}, "latest"]), + -38026, + ), + ]; + + for (params, code) in cases { + let response = rpc_request(&endpoint, "eth_simulateV1", params).await; + assert_eq!(response["error"]["code"], code, "{response}"); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_chains_block_hashes_rpc() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let endpoint = handle.http_endpoint(); + let base = rpc_request(&endpoint, "eth_getBlockByNumber", json!(["latest", false])).await; + let base_hash = base["result"]["hash"].clone(); + let response = rpc_request( + &endpoint, + "eth_simulateV1", + json!([{ + "blockStateCalls": [ + { + "stateOverrides": { + "0xc000000000000000000000000000000000000000": { + "code": "0x5f405f5260205ff3" + } + }, + "calls": [{ + "to": "0xc000000000000000000000000000000000000000" + }] + }, + { + "stateOverrides": { + "0xc100000000000000000000000000000000000000": { + "code": "0x6001405f5260205ff3" + } + }, + "calls": [{ + "to": "0xc100000000000000000000000000000000000000" + }] + } + ] + }, "latest"]), + ) + .await; + assert!(response.get("error").is_none(), "{response}"); + let blocks = response["result"].as_array().unwrap(); + + assert_eq!(blocks[0]["parentHash"], base_hash); + assert_eq!(blocks[0]["calls"][0]["returnData"], base_hash); + assert_eq!(blocks[1]["parentHash"], blocks[0]["hash"]); + assert_eq!(blocks[1]["calls"][0]["returnData"], blocks[0]["hash"]); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_includes_prevrandao_in_chained_hash_rpc() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let endpoint = handle.http_endpoint(); + let contract = "0xc000000000000000000000000000000000000000"; + let mut hashes = Vec::new(); + + for random in [42, 43] { + let random = format!("0x{random:064x}"); + let response = rpc_request( + &endpoint, + "eth_simulateV1", + json!([{ + "blockStateCalls": [ + { + "blockOverrides": {"prevRandao": random} + }, + { + "stateOverrides": { + (contract): {"code": "0x6001405f5260205ff3"} + }, + "calls": [{"to": contract}] + } + ] + }, "latest"]), + ) + .await; + assert!(response.get("error").is_none(), "{response}"); + let blocks = response["result"].as_array().unwrap(); + + assert_eq!(blocks[0]["mixHash"], random); + assert_eq!(blocks[1]["parentHash"], blocks[0]["hash"]); + assert_eq!(blocks[1]["calls"][0]["returnData"], blocks[0]["hash"]); + hashes.push(blocks[0]["hash"].clone()); + } + + assert_ne!(hashes[0], hashes[1]); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_scopes_block_hash_overrides_rpc() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let endpoint = handle.http_endpoint(); + let base = rpc_request(&endpoint, "eth_getBlockByNumber", json!(["latest", false])).await; + let base_hash = base["result"]["hash"].clone(); + let fake_hash = format!("0x{}", "42".repeat(32)); + let contract = "0xc000000000000000000000000000000000000000"; + let response = rpc_request( + &endpoint, + "eth_simulateV1", + json!([{ + "blockStateCalls": [ + { + "blockOverrides": { + "blockHash": {"0": fake_hash} + }, + "stateOverrides": { + (contract): {"code": "0x5f405f5260205ff3"} + }, + "calls": [{"to": contract}] + }, + { + "calls": [{"to": contract}] + } + ] + }, "latest"]), + ) + .await; + assert!(response.get("error").is_none(), "{response}"); + let blocks = response["result"].as_array().unwrap(); + + assert_eq!(blocks[0]["calls"][0]["returnData"], fake_hash); + assert_eq!(blocks[1]["calls"][0]["returnData"], base_hash); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_inherits_parent_block_context_rpc() { + let config = NodeConfig::test().with_hardfork(Some(EthereumHardfork::London.into())); + let (_api, handle) = spawn(config).await; + let endpoint = handle.http_endpoint(); + let contract = "0xc000000000000000000000000000000000000000"; + let fee_recipient = "0xc200000000000000000000000000000000000000"; + let gas_limit = 1_000_000; + let difficulty = 42; + let response = rpc_request( + &endpoint, + "eth_simulateV1", + json!([{ + "blockStateCalls": [ + { + "blockOverrides": { + "gasLimit": format!("{gas_limit:#x}"), + "feeRecipient": fee_recipient, + "difficulty": format!("{difficulty:#x}") + }, + "stateOverrides": { + (contract): {"code": "0x45600052416020524460405260606000f3"} + }, + "calls": [{"to": contract}] + }, + {"calls": [{"to": contract}]} + ] + }, "latest"]), + ) + .await; + assert!(response.get("error").is_none(), "{response}"); + let blocks = response["result"].as_array().unwrap(); + let return_data = format!( + "0x{gas_limit:064x}{}{}{difficulty:064x}", + "0".repeat(24), + fee_recipient.trim_start_matches("0x") + ); + + for block in blocks { + assert_eq!(quantity(&block["gasLimit"]), gas_limit); + assert_eq!(block["miner"], fee_recipient); + assert_eq!(quantity(&block["difficulty"]), difficulty); + assert_eq!(block["calls"][0]["returnData"], return_data); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_scopes_block_overrides_and_derives_base_fee_rpc() { + let config = NodeConfig::test() + .with_hardfork(Some(EthereumHardfork::Cancun.into())) + .with_base_fee(Some(0)); + let (_api, handle) = spawn(config).await; + let endpoint = handle.http_endpoint(); + let random = format!("0x{:064x}", 42); + let response = rpc_request( + &endpoint, + "eth_simulateV1", + json!([{ + "blockStateCalls": [ + { + "blockOverrides": { + "prevRandao": random, + "baseFeePerGas": "0xa", + "blobBaseFee": "0x15" + }, + "stateOverrides": { + "0xc100000000000000000000000000000000000000": { + "code": "0x445f52486020524a60405260605ff3" + } + }, + "calls": [{ + "to": "0xc100000000000000000000000000000000000000" + }] + }, + { + "calls": [{ + "to": "0xc100000000000000000000000000000000000000" + }] + } + ] + }, "latest"]), + ) + .await; + let blocks = response["result"].as_array().unwrap(); + assert_eq!(blocks[0]["calls"][0]["returnData"], format!("0x{:064x}{:064x}{:064x}", 42, 10, 21)); + assert_eq!(blocks[1]["calls"][0]["returnData"], format!("0x{:064x}{:064x}{:064x}", 0, 0, 1)); + + let response = rpc_request( + &endpoint, + "eth_simulateV1", + json!([{ + "blockStateCalls": [ + {"blockOverrides": {"baseFeePerGas": "0x3e8"}}, + {} + ], + "validation": true + }, "latest"]), + ) + .await; + assert_eq!(response["result"][0]["baseFeePerGas"], "0x3e8"); + assert_eq!(response["result"][1]["baseFeePerGas"], "0x36b"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_pre_london_blocks_keep_base_fee_disabled_rpc() { + let (_api, handle) = + spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Berlin.into()))).await; + let response = rpc_request( + &handle.http_endpoint(), + "eth_simulateV1", + json!([{ + "blockStateCalls": [{}, {}], + "validation": true + }, "latest"]), + ) + .await; + + assert!(response.get("error").is_none(), "{response}"); + let blocks = response["result"].as_array().unwrap(); + assert_eq!(blocks.len(), 2); + assert!(blocks.iter().all(|block| block.get("baseFeePerGas").is_none())); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_derives_from_historical_base_rpc() { + let (api, handle) = spawn(NodeConfig::test().with_genesis_timestamp(Some(1_000u64))).await; + api.mine_one().await.unwrap(); + let endpoint = handle.http_endpoint(); + let genesis = rpc_request(&endpoint, "eth_getBlockByNumber", json!(["0x0", false])).await; + let response = + rpc_request(&endpoint, "eth_simulateV1", json!([{"blockStateCalls": [{}]}, "0x0"])).await; + + assert!(response.get("error").is_none(), "{response}"); + assert_eq!(response["result"][0]["number"], "0x1"); + assert_eq!(response["result"][0]["parentHash"], genesis["result"]["hash"]); + assert_eq!( + quantity(&response["result"][0]["timestamp"]), + quantity(&genesis["result"]["timestamp"]) + 12 + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_returns_unchanged_state_root_rpc() { + let (api, handle) = + spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Shanghai.into()))).await; + let endpoint = handle.http_endpoint(); + let state_root = api.state_root().await.unwrap(); + let response = + rpc_request(&endpoint, "eth_simulateV1", json!([{"blockStateCalls": [{}]}])).await; + + assert!(response.get("error").is_none(), "{response}"); + assert_ne!( + response["result"][0]["stateRoot"], + "0x0000000000000000000000000000000000000000000000000000000000000000" + ); + assert_eq!(response["result"][0]["stateRoot"], serde_json::to_value(state_root).unwrap()); + assert_eq!(response["result"][0]["withdrawals"], json!([])); + assert_eq!( + response["result"][0]["withdrawalsRoot"], + serde_json::to_value(EMPTY_WITHDRAWALS).unwrap() + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulated_and_mined_ethereum_transitions_match_rpc() { + for hardfork in [EthereumHardfork::Cancun, EthereumHardfork::Prague] { + let (api, handle) = spawn(NodeConfig::test().with_hardfork(Some(hardfork.into()))).await; + let provider = handle.http_provider(); + let genesis = provider + .get_block_by_number(BlockNumberOrTag::Number(0)) + .await + .unwrap() + .expect("genesis block should exist"); + let next_timestamp = genesis.header.timestamp + 12; + + let simulated = api + .simulate_v1( + SimulatePayload { + block_state_calls: vec![SimBlock::default()], + ..Default::default() + }, + None, + ) + .await + .unwrap() + .remove(0); + + api.evm_set_next_block_timestamp(next_timestamp).unwrap(); + api.mine_one().await.unwrap(); + let mined = provider + .get_block_by_number(BlockNumberOrTag::Number(1)) + .await + .unwrap() + .expect("mined block should exist"); + + assert_eq!(simulated.inner.header.state_root, mined.header.state_root); + assert_eq!( + simulated.inner.header.parent_beacon_block_root, + mined.header.parent_beacon_block_root + ); + assert_eq!(mined.header.parent_beacon_block_root, Some(B256::ZERO)); + + if hardfork >= EthereumHardfork::Prague { + assert_eq!(simulated.inner.header.requests_hash, mined.header.requests_hash); + assert_eq!(mined.header.requests_hash, Some(EMPTY_REQUESTS_HASH)); + } + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_preserves_beacon_root_override_rpc() { + let (_, handle) = + spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Cancun.into()))).await; + let provider = handle.http_provider(); + let genesis = provider + .get_block_by_number(BlockNumberOrTag::Number(0)) + .await + .unwrap() + .expect("genesis block should exist"); + let timestamp = genesis.header.timestamp + 12; + let beacon_root = B256::repeat_byte(0x42); + let response = rpc_request( + &handle.http_endpoint(), + "eth_simulateV1", + json!([{ + "blockStateCalls": [{ + "blockOverrides": { + "time": format!("0x{timestamp:x}"), + "beaconRoot": beacon_root, + } + }] + }]), + ) + .await; + let default_root_response = rpc_request( + &handle.http_endpoint(), + "eth_simulateV1", + json!([{ + "blockStateCalls": [{ + "blockOverrides": {"time": format!("0x{timestamp:x}")} + }] + }]), + ) + .await; + + assert!(response.get("error").is_none(), "{response}"); + assert_eq!(response["result"][0]["parentBeaconBlockRoot"], beacon_root.to_string()); + assert_ne!(response["result"][0]["stateRoot"], default_root_response["result"][0]["stateRoot"]); +} + +#[derive(Clone, Copy, Debug)] +struct RequestOutputPrecompileFactory; + +impl PrecompileFactory for RequestOutputPrecompileFactory { + fn precompiles(&self) -> Vec<(Address, DynPrecompile)> { + vec![ + ( + WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, + DynPrecompile::from(|input: PrecompileInput<'_>| { + Ok(PrecompileOutput { + status: PrecompileStatus::Success, + bytes: Bytes::from_static(&[0xaa]), + gas_used: 0, + gas_refunded: 0, + state_gas_used: 0, + state_gas_spilled: 0, + reservoir: input.reservoir, + }) + }), + ), + ( + CONSOLIDATION_REQUEST_PREDEPLOY_ADDRESS, + DynPrecompile::from(|input: PrecompileInput<'_>| { + Ok(PrecompileOutput { + status: PrecompileStatus::Success, + bytes: Bytes::from_static(&[0xbb]), + gas_used: 0, + gas_refunded: 0, + state_gas_used: 0, + state_gas_spilled: 0, + reservoir: input.reservoir, + }) + }), + ), + ] + } +} + +#[derive(Clone, Debug)] +struct PerBlockRequestPrecompileFactory(Arc); + +impl PrecompileFactory for PerBlockRequestPrecompileFactory { + fn precompiles(&self) -> Vec<(Address, DynPrecompile)> { + let calls = Arc::clone(&self.0); + vec![( + WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, + DynPrecompile::from(move |input: PrecompileInput<'_>| { + let value = calls.fetch_add(1, Ordering::SeqCst) as u8 + 1; + Ok(PrecompileOutput { + status: PrecompileStatus::Success, + bytes: Bytes::from(vec![value]), + gas_used: 0, + gas_refunded: 0, + state_gas_used: 0, + state_gas_spilled: 0, + reservoir: input.reservoir, + }) + }), + )] + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_multiblock_simulation_applies_transitions_per_block_rpc() { + let calls = Arc::new(AtomicUsize::new(0)); + let (api, _) = spawn( + NodeConfig::test() + .with_hardfork(Some(EthereumHardfork::Prague.into())) + .with_precompile_factory(PerBlockRequestPrecompileFactory(Arc::clone(&calls))), + ) + .await; + let live_state_root = api.state_root().await.unwrap(); + let first_beacon_root = B256::repeat_byte(0x11); + let second_beacon_root = B256::repeat_byte(0x22); + + let blocks = api + .simulate_v1( + SimulatePayload { + block_state_calls: vec![ + SimBlock { + block_overrides: Some(BlockOverrides { + beacon_root: Some(first_beacon_root), + ..Default::default() + }), + ..Default::default() + }, + SimBlock { + block_overrides: Some(BlockOverrides { + beacon_root: Some(second_beacon_root), + ..Default::default() + }), + ..Default::default() + }, + ], + ..Default::default() + }, + None, + ) + .await + .unwrap(); + + let first_requests = + Requests::new(vec![Bytes::from(vec![WITHDRAWAL_REQUEST_TYPE, 1])]).requests_hash(); + let second_requests = + Requests::new(vec![Bytes::from(vec![WITHDRAWAL_REQUEST_TYPE, 2])]).requests_hash(); + assert_eq!(calls.load(Ordering::SeqCst), 2); + assert_eq!(blocks[0].inner.header.parent_beacon_block_root, Some(first_beacon_root)); + assert_eq!(blocks[1].inner.header.parent_beacon_block_root, Some(second_beacon_root)); + assert_eq!(blocks[0].inner.header.requests_hash, Some(first_requests)); + assert_eq!(blocks[1].inner.header.requests_hash, Some(second_requests)); + assert_eq!(blocks[1].inner.header.parent_hash, blocks[0].inner.header.hash); + assert_ne!(blocks[0].inner.header.state_root, blocks[1].inner.header.state_root); + assert_eq!(api.state_root().await.unwrap(), live_state_root); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulated_and_mined_prague_request_hashes_match_rpc() { + let (api, handle) = spawn( + NodeConfig::test() + .with_hardfork(Some(EthereumHardfork::Prague.into())) + .with_precompile_factory(RequestOutputPrecompileFactory), + ) + .await; + let provider = handle.http_provider(); + let genesis = provider + .get_block_by_number(BlockNumberOrTag::Number(0)) + .await + .unwrap() + .expect("genesis block should exist"); + + let simulated = api + .simulate_v1( + SimulatePayload { block_state_calls: vec![SimBlock::default()], ..Default::default() }, + None, + ) + .await + .unwrap() + .remove(0); + + api.evm_set_next_block_timestamp(genesis.header.timestamp + 12).unwrap(); + api.mine_one().await.unwrap(); + let mined = provider + .get_block_by_number(BlockNumberOrTag::Number(1)) + .await + .unwrap() + .expect("mined block should exist"); + let expected = Requests::new(vec![ + Bytes::from(vec![WITHDRAWAL_REQUEST_TYPE, 0xaa]), + Bytes::from(vec![CONSOLIDATION_REQUEST_TYPE, 0xbb]), + ]) + .requests_hash(); + + assert_eq!(simulated.inner.header.state_root, mined.header.state_root); + assert_eq!(simulated.inner.header.requests_hash, Some(expected)); + assert_eq!(mined.header.requests_hash, Some(expected)); + assert_ne!(expected, EMPTY_REQUESTS_HASH); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_prague_requests_use_genesis_deposit_contract_and_consensus_order_rpc() { + let deposit_contract = address!("0000000000000000000000000000000000006110"); + let mut genesis = Genesis::default(); + genesis.config.chain_id = 31_337; + genesis.config.deposit_contract_address = Some(deposit_contract); + let (api, handle) = spawn( + NodeConfig::test() + .with_genesis(Some(genesis)) + .with_hardfork(Some(EthereumHardfork::Prague.into())) + .with_precompile_factory(RequestOutputPrecompileFactory), + ) + .await; + let pubkey = Bytes::from(vec![0x11; 48]); + let withdrawal_credentials = Bytes::from(vec![0x22; 32]); + let amount = Bytes::from(vec![0x33; 8]); + let signature = Bytes::from(vec![0x44; 96]); + let index = Bytes::from(vec![0x55; 8]); + let event = DepositEvent { + pubkey: pubkey.clone(), + withdrawal_credentials: withdrawal_credentials.clone(), + amount: amount.clone(), + signature: signature.clone(), + index: index.clone(), + }; + api.anvil_set_code(deposit_contract, deposit_event_runtime(event)).await.unwrap(); + api.anvil_set_auto_mine(false).await.unwrap(); + let from = handle.dev_wallets().next().unwrap().address(); + let request = TransactionRequest::default().from(from).to(deposit_contract).gas_limit(200_000); + + let simulated = api + .simulate_v1( + SimulatePayload { + block_state_calls: vec![SimBlock { + calls: vec![request.clone()], + ..Default::default() + }], + ..Default::default() + }, + None, + ) + .await + .unwrap() + .remove(0); + + let mut deposit_request = vec![DEPOSIT_REQUEST_TYPE]; + deposit_request.extend(pubkey); + deposit_request.extend(withdrawal_credentials); + deposit_request.extend(amount); + deposit_request.extend(signature); + deposit_request.extend(index); + let expected = Requests::new(vec![ + deposit_request.into(), + Bytes::from(vec![WITHDRAWAL_REQUEST_TYPE, 0xaa]), + Bytes::from(vec![CONSOLIDATION_REQUEST_TYPE, 0xbb]), + ]) + .requests_hash(); + assert_eq!(simulated.inner.header.requests_hash, Some(expected)); + + let _pending = + handle.http_provider().send_transaction(WithOtherFields::new(request)).await.unwrap(); + api.mine_one().await.unwrap(); + let mined = handle + .http_provider() + .get_block_by_number(BlockNumberOrTag::Number(1)) + .await + .unwrap() + .expect("mined block should exist"); + assert_eq!(mined.header.requests_hash, Some(expected)); +} + +const SIMULATION_POST_BLOCK_ERROR: &str = "simulation post-block sentinel"; + +#[derive(Clone, Copy, Debug)] +struct FailingWithdrawalPrecompileFactory; + +impl PrecompileFactory for FailingWithdrawalPrecompileFactory { + fn precompiles(&self) -> Vec<(Address, DynPrecompile)> { + vec![( + WITHDRAWAL_REQUEST_PREDEPLOY_ADDRESS, + DynPrecompile::from(|_: PrecompileInput<'_>| { + Err(PrecompileError::Fatal(SIMULATION_POST_BLOCK_ERROR.to_string())) + }), + )] + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_discards_candidate_after_post_block_failure_rpc() { + let (api, handle) = spawn( + NodeConfig::test() + .with_hardfork(Some(EthereumHardfork::Prague.into())) + .with_precompile_factory(FailingWithdrawalPrecompileFactory), + ) + .await; + let state_root = api.state_root().await.unwrap(); + let response = rpc_request( + &handle.http_endpoint(), + "eth_simulateV1", + json!([{"blockStateCalls": [{ + "stateOverrides": { + "0xc000000000000000000000000000000000000000": {"balance": "0x1"} + } + }]}]), + ) + .await; + + assert!(response["error"]["message"].as_str().unwrap().contains(SIMULATION_POST_BLOCK_ERROR)); + assert_eq!(api.state_root().await.unwrap(), state_root); + assert_eq!(handle.http_provider().get_block_number().await.unwrap(), 0); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_empty_state_override_preserves_root_rpc() { + let (_, handle) = spawn(NodeConfig::test()).await; + let endpoint = handle.http_endpoint(); + let account = Address::with_last_byte(0x42).to_string(); + let without_override = + rpc_request(&endpoint, "eth_simulateV1", json!([{"blockStateCalls": [{}]}])).await; + let with_empty_override = rpc_request( + &endpoint, + "eth_simulateV1", + json!([{"blockStateCalls": [{"stateOverrides": {(account.clone()): {}}}]}]), + ) + .await; + let with_empty_state_diff = rpc_request( + &endpoint, + "eth_simulateV1", + json!([{"blockStateCalls": [{"stateOverrides": {(account): {"stateDiff": {}}}}]}]), + ) + .await; + + assert!(without_override.get("error").is_none(), "{without_override}"); + assert!(with_empty_override.get("error").is_none(), "{with_empty_override}"); + assert!(with_empty_state_diff.get("error").is_none(), "{with_empty_state_diff}"); + assert_eq!( + without_override["result"][0]["stateRoot"], + with_empty_override["result"][0]["stateRoot"] + ); + assert_eq!( + without_override["result"][0]["stateRoot"], + with_empty_state_diff["result"][0]["stateRoot"] + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_selfdestruct_state_root_matches_mined_rpc() { + let config = NodeConfig::test() + .with_hardfork(Some(EthereumHardfork::London.into())) + .with_base_fee(Some(0)); + let (api, handle) = spawn(config).await; + let endpoint = handle.http_endpoint(); + let sender = handle.dev_accounts().next().unwrap(); + let contract = address!("0xc000000000000000000000000000000000000000"); + let beneficiary = address!("0xc100000000000000000000000000000000000000"); + let mut code = vec![0x73]; + code.extend_from_slice(beneficiary.as_slice()); + code.push(0xff); + + api.anvil_set_code(contract, code.into()).await.unwrap(); + api.anvil_set_balance(contract, U256::ONE).await.unwrap(); + api.anvil_set_storage_at(contract, U256::ZERO, B256::with_last_byte(42)).await.unwrap(); + api.mine_one().await.unwrap(); + + let selfdestruct = json!({ + "from": sender, + "to": contract, + "gas": "0x186a0", + "gasPrice": "0x0" + }); + let transfer = json!({ + "from": sender, + "to": contract, + "gas": "0x5208", + "gasPrice": "0x0", + "value": "0x1" + }); + let simulated = rpc_request( + &endpoint, + "eth_simulateV1", + json!([{ + "blockStateCalls": [ + {"calls": [selfdestruct.clone()]}, + {"calls": [transfer]} + ], + "validation": true + }]), + ) + .await; + assert!(simulated.get("error").is_none(), "{simulated}"); + assert_eq!(simulated["result"][0]["calls"][0]["status"], "0x1"); + assert_eq!(simulated["result"][1]["calls"][0]["status"], "0x1"); + + handle + .http_provider() + .send_transaction(WithOtherFields::new(TransactionRequest { + from: Some(sender), + to: Some(TxKind::Call(contract)), + gas: Some(100_000), + gas_price: Some(0), + ..Default::default() + })) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + let first_mined = + rpc_request(&endpoint, "eth_getBlockByNumber", json!(["latest", false])).await; + handle + .http_provider() + .send_transaction(WithOtherFields::new(TransactionRequest { + from: Some(sender), + to: Some(TxKind::Call(contract)), + value: Some(U256::ONE), + gas: Some(21_000), + gas_price: Some(0), + ..Default::default() + })) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + let second_mined = + rpc_request(&endpoint, "eth_getBlockByNumber", json!(["latest", false])).await; + let code = rpc_request(&endpoint, "eth_getCode", json!([contract, "latest"])).await; + let storage = + rpc_request(&endpoint, "eth_getStorageAt", json!([contract, "0x0", "latest"])).await; + + assert_eq!(code["result"], "0x"); + assert_eq!(storage["result"], B256::ZERO.to_string()); + assert_eq!(simulated["result"][0]["stateRoot"], first_mined["result"]["stateRoot"]); + assert_eq!(simulated["result"][1]["stateRoot"], second_mined["result"]["stateRoot"]); + assert_eq!( + second_mined["result"]["stateRoot"], + serde_json::to_value(api.state_root().await.unwrap()).unwrap() + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_storage_written_after_selfdestruct_rpc() { + let (api, handle) = + spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Shanghai.into()))).await; + let endpoint = handle.http_endpoint(); + let sender = handle.dev_accounts().next().unwrap(); + let contract = Address::with_last_byte(0x42); + api.anvil_set_code(contract, Bytes::from_static(&[0x60, 0x00, 0xff])).await.unwrap(); + handle + .http_provider() + .send_transaction(WithOtherFields::new(TransactionRequest { + from: Some(sender), + to: Some(TxKind::Call(contract)), + gas: Some(100_000), + ..Default::default() + })) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + api.anvil_set_storage_at(contract, U256::ONE, B256::with_last_byte(5)).await.unwrap(); + + let transfer = json!({"calls": [{"from": sender, "to": contract, "value": "0x1"}]}); + let single = + rpc_request(&endpoint, "eth_simulateV1", json!([{"blockStateCalls": [transfer.clone()]}])) + .await; + let multiple = + rpc_request(&endpoint, "eth_simulateV1", json!([{"blockStateCalls": [{}, transfer]}])) + .await; + assert!(single.get("error").is_none(), "{single}"); + assert!(multiple.get("error").is_none(), "{multiple}"); + assert_eq!(single["result"][0]["calls"][0]["status"], "0x1"); + assert_eq!(multiple["result"][1]["calls"][0]["status"], "0x1"); + // Shanghai has no block-level system writes: the empty block cannot affect state. + assert_eq!(single["result"][0]["stateRoot"], multiple["result"][1]["stateRoot"]); + assert_ne!(multiple["result"][0]["stateRoot"], multiple["result"][1]["stateRoot"]); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_state_override_preserves_selfdestructed_storage_rpc() { + let config = NodeConfig::test() + .with_hardfork(Some(EthereumHardfork::London.into())) + .with_base_fee(Some(0)); + let (api, handle) = spawn(config).await; + let endpoint = handle.http_endpoint(); + let sender = handle.dev_accounts().next().unwrap(); + let contract = address!("0xc000000000000000000000000000000000000000"); + let contract_key = contract.to_string(); + let beneficiary = address!("0xc100000000000000000000000000000000000000"); + let mut selfdestruct_code = vec![0x73]; + selfdestruct_code.extend_from_slice(beneficiary.as_slice()); + selfdestruct_code.push(0xff); + + api.anvil_set_code(contract, selfdestruct_code.into()).await.unwrap(); + api.anvil_set_balance(contract, U256::ONE).await.unwrap(); + api.anvil_set_storage_at(contract, U256::ZERO, B256::with_last_byte(42)).await.unwrap(); + api.mine_one().await.unwrap(); + + let selfdestruct = json!({ + "from": sender, + "to": contract, + "gas": "0x186a0", + "gasPrice": "0x0" + }); + let read_storage = json!({ + "from": sender, + "to": contract, + "gas": "0x186a0", + "gasPrice": "0x0" + }); + let code_only = rpc_request( + &endpoint, + "eth_simulateV1", + json!([{ + "blockStateCalls": [ + {"calls": [selfdestruct.clone()]}, + { + "stateOverrides": { + (contract_key.clone()): {"code": "0x60005460005260206000f3"} + }, + "calls": [read_storage.clone()] + } + ], + "validation": true + }]), + ) + .await; + let cleared_state = rpc_request( + &endpoint, + "eth_simulateV1", + json!([{ + "blockStateCalls": [ + {"calls": [selfdestruct]}, + { + "stateOverrides": { + (contract_key): { + "code": "0x60005460005260206000f3", + "state": {} + } + }, + "calls": [read_storage] + } + ], + "validation": true + }]), + ) + .await; + + assert!(code_only.get("error").is_none(), "{code_only}"); + assert!(cleared_state.get("error").is_none(), "{cleared_state}"); + assert_eq!(code_only["result"][1]["calls"][0]["returnData"], B256::ZERO.to_string()); + assert_eq!(cleared_state["result"][1]["calls"][0]["returnData"], B256::ZERO.to_string()); + assert_eq!(code_only["result"][1]["stateRoot"], cleared_state["result"][1]["stateRoot"]); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_historical_tombstone_matches_latest_rpc() { + let config = NodeConfig::test() + .with_hardfork(Some(EthereumHardfork::Frontier.into())) + .with_base_fee(Some(0)); + let (api, handle) = spawn(config).await; + let endpoint = handle.http_endpoint(); + let sender = handle.dev_accounts().next().unwrap(); + let contract = address!("0xc000000000000000000000000000000000000000"); + let beneficiary = address!("0xc100000000000000000000000000000000000000"); + let mut code = vec![0x73]; + code.extend_from_slice(beneficiary.as_slice()); + code.push(0xff); + + api.anvil_set_code(contract, code.into()).await.unwrap(); + api.anvil_set_balance(contract, U256::ONE).await.unwrap(); + api.mine_one().await.unwrap(); + handle + .http_provider() + .send_transaction(WithOtherFields::new(TransactionRequest { + from: Some(sender), + to: Some(TxKind::Call(contract)), + gas: Some(100_000), + gas_price: Some(0), + ..Default::default() + })) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + let historical_base = + rpc_request(&endpoint, "eth_getBlockByNumber", json!(["latest", false])).await; + api.mine_one().await.unwrap(); + let latest_base = + rpc_request(&endpoint, "eth_getBlockByNumber", json!(["latest", false])).await; + assert_eq!(historical_base["result"]["stateRoot"], latest_base["result"]["stateRoot"]); + + let payload = json!([{ + "blockStateCalls": [{ + "calls": [{ + "from": sender, + "to": contract, + "gas": "0x5208", + "gasPrice": "0x0" + }] + }], + "validation": true + }]); + let historical = rpc_request( + &endpoint, + "eth_simulateV1", + json!([payload[0].clone(), historical_base["result"]["number"].clone()]), + ) + .await; + let latest = + rpc_request(&endpoint, "eth_simulateV1", json!([payload[0].clone(), "latest"])).await; + + assert!(historical.get("error").is_none(), "{historical}"); + assert!(latest.get("error").is_none(), "{latest}"); + assert_eq!(historical["result"][0]["stateRoot"], latest["result"][0]["stateRoot"]); + + handle + .http_provider() + .send_transaction(WithOtherFields::new(TransactionRequest { + from: Some(sender), + to: Some(TxKind::Call(contract)), + gas: Some(21_000), + gas_price: Some(0), + ..Default::default() + })) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + let mined = rpc_request(&endpoint, "eth_getBlockByNumber", json!(["latest", false])).await; + assert_eq!(latest["result"][0]["stateRoot"], mined["result"]["stateRoot"]); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_derives_from_pending_base_rpc() { + let (api, handle) = spawn(NodeConfig::test()).await; + api.evm_set_block_timestamp_interval(12).unwrap(); + let endpoint = handle.http_endpoint(); + let pending = rpc_request(&endpoint, "eth_getBlockByNumber", json!(["pending", false])).await; + let response = + rpc_request(&endpoint, "eth_simulateV1", json!([{"blockStateCalls": [{}]}, "pending"])) + .await; + + assert!(response.get("error").is_none(), "{response}"); + assert_eq!( + quantity(&response["result"][0]["number"]), + quantity(&pending["result"]["number"]) + 1 + ); + assert_eq!(response["result"][0]["parentHash"], pending["result"]["hash"]); + assert_eq!( + quantity(&response["result"][0]["timestamp"]), + quantity(&pending["result"]["timestamp"]) + 12 + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_executes_on_pending_state_rpc() { + let (_, handle) = spawn(NodeConfig::test().with_no_mining(true)).await; + let sender = handle.dev_accounts().next().unwrap(); + let receiver = address!("c100000000000000000000000000000000000000"); + let _pending = handle + .http_provider() + .send_transaction(WithOtherFields::new(TransactionRequest { + from: Some(sender), + to: Some(TxKind::Call(receiver)), + value: Some(U256::ONE), + ..Default::default() + })) + .await + .unwrap(); + + let mut code = vec![0x73]; + code.extend_from_slice(receiver.as_slice()); + code.extend_from_slice(&[0x31, 0x60, 0x00, 0x52, 0x60, 0x20, 0x60, 0x00, 0xf3]); + let reader = address!("c200000000000000000000000000000000000000"); + let response = rpc_request( + &handle.http_endpoint(), + "eth_simulateV1", + json!([{ + "blockStateCalls": [{ + "stateOverrides": {(reader.to_string()): {"code": Bytes::from(code)}}, + "calls": [{"to": reader}] + }] + }, "pending"]), + ) + .await; + + assert!(response.get("error").is_none(), "{response}"); + assert_eq!( + response["result"][0]["calls"][0]["returnData"], + B256::with_last_byte(1).to_string() + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_finalizes_call_and_transaction_metadata_rpc() { + let (_, handle) = spawn(NodeConfig::test()).await; + let sender = handle.dev_accounts().next().unwrap(); + let gas_price = "0x3b9aca00"; + let response = rpc_request( + &handle.http_endpoint(), + "eth_simulateV1", + json!([{ + "blockStateCalls": [{ + "calls": [ + { + "from": sender, + "to": "0xc100000000000000000000000000000000000000", + "gasPrice": gas_price + }, + { + "from": sender, + "to": "0xc200000000000000000000000000000000000000", + "gasPrice": gas_price + } + ] + }], + "returnFullTransactions": true + }, "latest"]), + ) + .await; + + assert!(response.get("error").is_none(), "{response}"); + let block = &response["result"][0]; + let calls = block["calls"].as_array().unwrap(); + let transactions = block["transactions"].as_array().unwrap(); + assert_eq!(calls.len(), 2); + assert_eq!(transactions.len(), 2); + for (index, (call, transaction)) in calls.iter().zip(transactions).enumerate() { + assert!(quantity(&call["maxUsedGas"]) >= quantity(&call["gasUsed"])); + assert_eq!(transaction["blockHash"], block["hash"]); + assert_eq!(transaction["blockNumber"], block["number"]); + assert_eq!(quantity(&transaction["transactionIndex"]), index as u64); + assert_eq!(transaction["blockTimestamp"], block["timestamp"]); + assert_eq!(transaction["gasPrice"], gas_price); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_prague_max_used_gas_includes_calldata_floor_rpc() { + let config = NodeConfig::test() + .with_hardfork(Some(EthereumHardfork::Prague.into())) + .with_base_fee(Some(0)); + let (_, handle) = spawn(config).await; + let input = format!("0x{}", "ff".repeat(1_000)); + let response = rpc_request( + &handle.http_endpoint(), + "eth_simulateV1", + json!([{"blockStateCalls": [{"calls": [{ + "to": "0xc100000000000000000000000000000000000000", + "input": input + }]}]}, "latest"]), + ) + .await; + assert!(response.get("error").is_none(), "{response}"); + let call = &response["result"][0]["calls"][0]; + + assert_eq!(call["gasUsed"], "0xee48"); + assert_eq!(call["maxUsedGas"], "0xee48"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_max_used_gas_before_refund_rpc() { + let config = NodeConfig::test() + .with_hardfork(Some(EthereumHardfork::Cancun.into())) + .with_base_fee(Some(0)); + let (_, handle) = spawn(config).await; + let response = rpc_request( + &handle.http_endpoint(), + "eth_simulateV1", + json!([{ + "blockStateCalls": [{ + "stateOverrides": { + "0xc200000000000000000000000000000000000000": { + "code": "0x5f5f5500", + "state": { + "0x0000000000000000000000000000000000000000000000000000000000000000": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "calls": [{ + "from": "0xc000000000000000000000000000000000000000", + "to": "0xc200000000000000000000000000000000000000" + }] + }] + }, "latest"]), + ) + .await; + let call = &response["result"][0]["calls"][0]; + assert_eq!(call["gasUsed"], "0x52d4"); + assert_eq!(call["maxUsedGas"], "0x6594"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_reports_non_gas_halts_rpc() { + let (_, handle) = spawn(NodeConfig::test()).await; + let response = rpc_request( + &handle.http_endpoint(), + "eth_simulateV1", + json!([{ + "blockStateCalls": [{ + "stateOverrides": { + "0xc100000000000000000000000000000000000000": {"code": "0xfe"} + }, + "calls": [{"to": "0xc100000000000000000000000000000000000000"}] + }] + }, "latest"]), + ) + .await; + + assert!(response.get("error").is_none(), "{response}"); + let error = &response["result"][0]["calls"][0]["error"]; + assert_eq!(error["code"], -32015); + assert!(error["message"].as_str().unwrap().starts_with("vm execution error:")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_enforces_block_gas_limit() { + let (api, _) = spawn(NodeConfig::test()).await; + let sender = address!("0xc000000000000000000000000000000000000000"); + let receiver = address!("0xc100000000000000000000000000000000000000"); + let gas_burner = address!("0xc200000000000000000000000000000000000000"); + let gas_limit = 100_000; let state_overrides = Some( StateOverridesBuilder::with_capacity(1) - .append(address!("0xc000000000000000000000000000000000000001"), account_override) + .append( + gas_burner, + AccountOverride { + code: Some(Bytes::from_static(&[0x5b, 0x5f, 0x56])), + ..Default::default() + }, + ) .build(), ); - let tx_request = TransactionRequest { - from: Some(address!("0xc000000000000000000000000000000000000001")), - to: Some(TxKind::from(address!("0xc000000000000000000000000000000000000001"))), - value: Some(U256::from(1)), + let calls = vec![ + TransactionRequest { + from: Some(sender), + to: Some(TxKind::Call(receiver)), + ..Default::default() + }, + TransactionRequest { + from: Some(sender), + to: Some(TxKind::Call(gas_burner)), + ..Default::default() + }, + ]; + let payload = SimulatePayload { + block_state_calls: vec![SimBlock { + block_overrides: Some(BlockOverrides { + gas_limit: Some(gas_limit), + ..Default::default() + }), + state_overrides, + calls, + }], ..Default::default() }; + + let blocks = api.simulate_v1(payload, None).await.unwrap(); + let block = &blocks[0]; + + assert!(block.calls[0].status); + assert!(!block.calls[1].status); + assert_eq!(block.inner.header.gas_used, gas_limit); + assert_eq!(block.calls[1].gas_used, gas_limit - block.calls[0].gas_used); + assert_eq!(block.calls[1].error.as_ref().unwrap().code, -32015); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_rejects_call_above_remaining_block_gas() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let sender = address!("0xc000000000000000000000000000000000000000"); + let receiver = address!("0xc100000000000000000000000000000000000000"); + let gas_limit = 100_000; let payload = SimulatePayload { block_state_calls: vec![SimBlock { - block_overrides, - state_overrides, - calls: vec![tx_request], + block_overrides: Some(BlockOverrides { + gas_limit: Some(gas_limit), + ..Default::default() + }), + calls: vec![ + TransactionRequest { + from: Some(sender), + to: Some(TxKind::Call(receiver)), + ..Default::default() + }, + TransactionRequest { + from: Some(sender), + to: Some(TxKind::Call(receiver)), + gas: Some(gas_limit), + ..Default::default() + }, + ], + ..Default::default() }], - trace_transfers: true, - validation: false, - return_full_transactions: true, + ..Default::default() + }; + + let provider = handle.http_provider(); + let response: Result = + provider.client().request("eth_simulateV1", (payload,)).await; + let error = response.unwrap_err(); + assert_eq!(error.as_error_resp().unwrap().code, -38015); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_tracks_amsterdam_gas_dimensions_separately() { + let (api, _) = + spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Amsterdam.into()))).await; + let sender = address!("0xc000000000000000000000000000000000000000"); + let receiver = address!("0xc100000000000000000000000000000000000000"); + let storage = address!("0xc200000000000000000000000000000000000000"); + let gas_limit = 130_000; + let payload = SimulatePayload { + block_state_calls: vec![SimBlock { + block_overrides: Some(BlockOverrides { + gas_limit: Some(gas_limit), + ..Default::default() + }), + state_overrides: Some( + StateOverridesBuilder::with_capacity(1) + .append( + storage, + AccountOverride { + code: Some(Bytes::from_static(&[0x60, 0x01, 0x60, 0x00, 0x55, 0x00])), + ..Default::default() + }, + ) + .build(), + ), + calls: vec![ + TransactionRequest { + from: Some(sender), + to: Some(TxKind::Call(storage)), + ..Default::default() + }, + TransactionRequest { + from: Some(sender), + to: Some(TxKind::Call(receiver)), + ..Default::default() + }, + ], + }], + ..Default::default() }; - let _res = api.simulate_v1(payload, None).await.unwrap(); + + let blocks = api.simulate_v1(payload, None).await.unwrap(); + let block = &blocks[0]; + + assert!(block.calls.iter().all(|call| call.status)); + let cumulative_gas_used = block.calls.iter().map(|call| call.gas_used).sum::(); + assert!(cumulative_gas_used > gas_limit); + assert!(block.inner.header.gas_used <= gas_limit); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_create_calls_rpc() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let response = rpc_request( + &handle.http_endpoint(), + "eth_simulateV1", + json!([{ + "blockStateCalls": [ + {}, + { + "calls": [ + {}, + {"from": "0xc000000000000000000000000000000000000000"}, + {"input": "0x602a5f526001601ff3"}, + {"input": "0x63deadbeef5f526004601cfd"} + ] + } + ], + "returnFullTransactions": true + }, "latest"]), + ) + .await; + + assert!(response.get("error").is_none(), "{response}"); + assert_eq!(response["result"][0]["calls"], json!([])); + assert_eq!(response["result"][0]["transactions"], json!([])); + + let block = &response["result"][1]; + let calls = block["calls"].as_array().unwrap(); + assert_eq!(calls.len(), 4); + assert_eq!(calls[0]["status"], "0x1"); + assert_eq!(calls[0]["returnData"], "0x"); + assert_eq!(calls[1]["status"], "0x1"); + assert_eq!(calls[1]["returnData"], "0x"); + assert_eq!(calls[2]["status"], "0x1"); + assert_eq!(calls[2]["returnData"], "0x2a"); + assert_eq!(calls[3]["status"], "0x0"); + assert_eq!(calls[3]["returnData"], "0x"); + assert_eq!(calls[3]["error"]["code"], 3); + assert_eq!(calls[3]["error"]["data"], "0xdeadbeef"); + + let transactions = block["transactions"].as_array().unwrap(); + assert_eq!(transactions.len(), 4); + assert!(transactions.iter().all(|transaction| transaction["to"].is_null())); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_trace_transfer_response_logs_rpc() { + let config = NodeConfig::test() + .with_hardfork(Some(EthereumHardfork::Cancun.into())) + .with_base_fee(Some(0)); + let (_api, handle) = spawn(config).await; + let endpoint = handle.http_endpoint(); + let payload = json!({ + "blockStateCalls": [{ + "stateOverrides": { + "0xc000000000000000000000000000000000000000": {"balance": "0x2"}, + "0xc100000000000000000000000000000000000000": { + "code": "0x5f5f5f5f600173c2000000000000000000000000000000000000005af15000" + } + }, + "calls": [{ + "from": "0xc000000000000000000000000000000000000000", + "to": "0xc100000000000000000000000000000000000000", + "value": "0x2" + }] + }], + "traceTransfers": true + }); + let response = + rpc_request(&endpoint, "eth_simulateV1", json!([payload.clone(), "latest"])).await; + assert!(response.get("error").is_none(), "{response}"); + assert_eq!(response["result"][0]["logsBloom"], format!("0x{}", "00".repeat(256))); + let logs = response["result"][0]["calls"][0]["logs"].as_array().unwrap(); + assert_eq!(logs.len(), 2); + assert_eq!( + logs.iter() + .map(|log| { + json!({ + "address": log["address"], + "from": log["topics"][1], + "to": log["topics"][2], + "value": log["data"], + "logIndex": log["logIndex"], + }) + }) + .collect::>(), + vec![ + json!({ + "address": "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "from": "0x000000000000000000000000c000000000000000000000000000000000000000", + "to": "0x000000000000000000000000c100000000000000000000000000000000000000", + "value": "0x0000000000000000000000000000000000000000000000000000000000000002", + "logIndex": "0x0", + }), + json!({ + "address": "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "from": "0x000000000000000000000000c100000000000000000000000000000000000000", + "to": "0x000000000000000000000000c200000000000000000000000000000000000000", + "value": "0x0000000000000000000000000000000000000000000000000000000000000001", + "logIndex": "0x1", + }), + ] + ); + + let mut payload = payload; + payload["traceTransfers"] = json!(false); + let response = rpc_request(&endpoint, "eth_simulateV1", json!([payload, "latest"])).await; + assert_eq!(response["result"][0]["calls"][0]["logs"], json!([])); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_preserves_amsterdam_transfer_logs_rpc() { + let config = NodeConfig::test() + .with_hardfork(Some(EthereumHardfork::Amsterdam.into())) + .with_base_fee(Some(0)); + let (_api, handle) = spawn(config).await; + let response = rpc_request( + &handle.http_endpoint(), + "eth_simulateV1", + json!([{ + "blockStateCalls": [{ + "stateOverrides": { + "0xc000000000000000000000000000000000000000": {"balance": "0x1"} + }, + "calls": [{ + "from": "0xc000000000000000000000000000000000000000", + "to": "0xc100000000000000000000000000000000000000", + "value": "0x1" + }] + }], + "traceTransfers": false + }, "latest"]), + ) + .await; + + assert!(response.get("error").is_none(), "{response}"); + assert_ne!(response["result"][0]["logsBloom"], format!("0x{}", "00".repeat(256))); + let logs = response["result"][0]["calls"][0]["logs"].as_array().unwrap(); + assert_eq!(logs.len(), 1); + assert_eq!(logs[0]["address"], "0xfffffffffffffffffffffffffffffffffffffffe"); + assert_eq!(logs[0]["logIndex"], "0x0"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_trace_transfer_excludes_callcode_rpc() { + let config = NodeConfig::test() + .with_hardfork(Some(EthereumHardfork::Cancun.into())) + .with_base_fee(Some(0)); + let (_api, handle) = spawn(config).await; + let response = rpc_request( + &handle.http_endpoint(), + "eth_simulateV1", + json!([{ + "blockStateCalls": [{ + "stateOverrides": { + "0xc100000000000000000000000000000000000000": { + "balance": "0x1", + "code": "0x5f5f5f5f600173c2000000000000000000000000000000000000005af200" + } + }, + "calls": [{"to": "0xc100000000000000000000000000000000000000"}] + }], + "traceTransfers": true + }, "latest"]), + ) + .await; + + assert!(response.get("error").is_none(), "{response}"); + assert_eq!(response["result"][0]["calls"][0]["status"], "0x1"); + assert_eq!(response["result"][0]["calls"][0]["logs"], json!([])); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_trace_transfer_create_nonce_overflow_rpc() { + let config = NodeConfig::test() + .with_hardfork(Some(EthereumHardfork::Cancun.into())) + .with_base_fee(Some(0)); + let (_api, handle) = spawn(config).await; + let response = rpc_request( + &handle.http_endpoint(), + "eth_simulateV1", + json!([{ + "blockStateCalls": [{ + "stateOverrides": { + "0xc100000000000000000000000000000000000000": { + "balance": "0x1", + "nonce": "0xffffffffffffffff", + "code": "0x600060006001f0505f5fa000" + } + }, + "calls": [{"to": "0xc100000000000000000000000000000000000000"}] + }], + "traceTransfers": true + }, "latest"]), + ) + .await; + + assert!(response.get("error").is_none(), "{response}"); + assert_eq!(response["result"][0]["calls"][0]["status"], "0x1"); + let logs = response["result"][0]["calls"][0]["logs"].as_array().unwrap(); + assert_eq!(logs.len(), 1); + assert_eq!(logs[0]["address"], "0xc100000000000000000000000000000000000000"); + assert_eq!(logs[0]["logIndex"], "0x1"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_trace_create_and_selfdestruct_transfers_rpc() { + let config = NodeConfig::test() + .with_hardfork(Some(EthereumHardfork::Cancun.into())) + .with_base_fee(Some(0)); + let (_api, handle) = spawn(config).await; + let response = rpc_request( + &handle.http_endpoint(), + "eth_simulateV1", + json!([{ + "blockStateCalls": [{ + "stateOverrides": { + "0xc100000000000000000000000000000000000000": { + "balance": "0x1", + "code": "0x600060006001f000" + }, + "0xc200000000000000000000000000000000000000": { + "balance": "0x1", + "code": "0x6000600060006001f500" + }, + "0xc300000000000000000000000000000000000000": { + "balance": "0x2", + "code": "0x73c400000000000000000000000000000000000000ff" + } + }, + "calls": [ + {"to": "0xc100000000000000000000000000000000000000"}, + {"to": "0xc200000000000000000000000000000000000000"}, + {"to": "0xc300000000000000000000000000000000000000"} + ] + }], + "traceTransfers": true + }, "latest"]), + ) + .await; + assert!(response.get("error").is_none(), "{response}"); + let calls = response["result"][0]["calls"].as_array().unwrap(); + assert!(calls.iter().all(|call| call["status"] == "0x1")); + for (index, sender) in [ + "0x000000000000000000000000c100000000000000000000000000000000000000", + "0x000000000000000000000000c200000000000000000000000000000000000000", + ] + .into_iter() + .enumerate() + { + let logs = calls[index]["logs"].as_array().unwrap(); + assert_eq!(logs.len(), 1); + assert_eq!(logs[0]["topics"][1], sender); + assert_ne!( + logs[0]["topics"][2], + "0x0000000000000000000000000000000000000000000000000000000000000000" + ); + assert_eq!( + logs[0]["data"], + "0x0000000000000000000000000000000000000000000000000000000000000001" + ); + } + let selfdestruct = &calls[2]["logs"][0]; + assert_eq!( + json!({ + "from": selfdestruct["topics"][1], + "to": selfdestruct["topics"][2], + "value": selfdestruct["data"], + }), + json!({ + "from": "0x000000000000000000000000c300000000000000000000000000000000000000", + "to": "0x000000000000000000000000c400000000000000000000000000000000000000", + "value": "0x0000000000000000000000000000000000000000000000000000000000000002", + }) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_trace_transfer_reverts_and_log_order_rpc() { + let config = NodeConfig::test() + .with_hardfork(Some(EthereumHardfork::Cancun.into())) + .with_base_fee(Some(0)); + let (_api, handle) = spawn(config).await; + let endpoint = handle.http_endpoint(); + let response = rpc_request( + &endpoint, + "eth_simulateV1", + json!([{ + "blockStateCalls": [{ + "stateOverrides": { + "0xc200000000000000000000000000000000000000": { + "balance": "0x2", + "code": "0x730000000000000000000000000000000000000000ff" + }, + "0xc400000000000000000000000000000000000000": { + "code": "0x5f5fa05f5f5f5f5f73c2000000000000000000000000000000000000005af1505f5fa000" + } + }, + "calls": [{"to": "0xc400000000000000000000000000000000000000"}] + }], + "traceTransfers": true + }, "latest"]), + ) + .await; + assert!(response.get("error").is_none(), "{response}"); + let logs = response["result"][0]["calls"][0]["logs"].as_array().unwrap(); + assert_eq!( + logs.iter() + .map(|log| json!({"address": log["address"], "logIndex": log["logIndex"]})) + .collect::>(), + vec![ + json!({ + "address": "0xc400000000000000000000000000000000000000", + "logIndex": "0x0" + }), + json!({ + "address": "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "logIndex": "0x1" + }), + json!({ + "address": "0xc400000000000000000000000000000000000000", + "logIndex": "0x2" + }), + ] + ); + + let reverted_log_payload = json!({ + "blockStateCalls": [{ + "stateOverrides": { + "0xc300000000000000000000000000000000000000": { + "code": "0x5f5fa05f5ffd" + }, + "0xc400000000000000000000000000000000000000": { + "code": "0x5f5fa05f5f5f5f5f73c3000000000000000000000000000000000000005af1505f5fa000" + } + }, + "calls": [{"to": "0xc400000000000000000000000000000000000000"}] + }], + "traceTransfers": true + }); + let response = + rpc_request(&endpoint, "eth_simulateV1", json!([reverted_log_payload.clone(), "latest"])) + .await; + assert!(response.get("error").is_none(), "{response}"); + let logs = response["result"][0]["calls"][0]["logs"].as_array().unwrap(); + assert_eq!( + logs.iter() + .map(|log| json!({"address": log["address"], "logIndex": log["logIndex"]})) + .collect::>(), + vec![ + json!({ + "address": "0xc400000000000000000000000000000000000000", + "logIndex": "0x0" + }), + json!({ + "address": "0xc400000000000000000000000000000000000000", + "logIndex": "0x2" + }), + ] + ); + + let mut reverted_log_payload = reverted_log_payload; + reverted_log_payload["traceTransfers"] = json!(false); + let response = + rpc_request(&endpoint, "eth_simulateV1", json!([reverted_log_payload, "latest"])).await; + let logs = response["result"][0]["calls"][0]["logs"].as_array().unwrap(); + assert_eq!( + logs.iter().map(|log| log["logIndex"].clone()).collect::>(), + vec![json!("0x0"), json!("0x2")] + ); + + let response = rpc_request( + &endpoint, + "eth_simulateV1", + json!([{ + "blockStateCalls": [{ + "stateOverrides": { + "0xc000000000000000000000000000000000000000": {"balance": "0x2"}, + "0xc100000000000000000000000000000000000000": {"code": "0x6000"} + }, + "calls": [ + { + "from": "0xc000000000000000000000000000000000000000", + "to": "0xc100000000000000000000000000000000000000", + "value": "0x1", + "gas": "0x5208" + }, + { + "from": "0xc000000000000000000000000000000000000000", + "to": "0xc100000000000000000000000000000000000000", + "value": "0x1" + } + ] + }], + "traceTransfers": true + }, "latest"]), + ) + .await; + let calls = response["result"][0]["calls"].as_array().unwrap(); + assert_eq!(calls[0]["status"], "0x0"); + assert_eq!(calls[0]["logs"], json!([])); + assert_eq!(calls[1]["logs"][0]["logIndex"], "0x1"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_validation_defaults_base_fee_to_zero() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let response = rpc_request( + &handle.http_endpoint(), + "eth_simulateV1", + json!([{ + "blockStateCalls": [ + {}, + {"blockOverrides": {"baseFeePerGas": "0x9"}}, + {} + ] + }, "latest"]), + ) + .await; + + assert_eq!(response["result"][0]["baseFeePerGas"], "0x0"); + assert_eq!(response["result"][1]["baseFeePerGas"], "0x9"); + assert_eq!(response["result"][2]["baseFeePerGas"], "0x0"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_transfer_traces_do_not_change_header_bloom() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let sender = handle.dev_accounts().next().unwrap(); + let response = rpc_request( + &handle.http_endpoint(), + "eth_simulateV1", + json!([{ + "blockStateCalls": [{ + "calls": [{ + "from": sender, + "to": address!("0xc100000000000000000000000000000000000000"), + "value": "0x1" + }] + }], + "traceTransfers": true + }, "latest"]), + ) + .await; + + assert!(response.get("error").is_none(), "{response}"); + assert_eq!( + response["result"][0]["calls"][0]["logs"][0]["address"], + "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + ); + assert_eq!( + serde_json::from_value::(response["result"][0]["logsBloom"].clone()).unwrap(), + Bloom::ZERO + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_preserves_canonical_transfer_emitter_log() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let endpoint = handle.http_endpoint(); + let mut payload = json!({ + "blockStateCalls": [{ + "stateOverrides": { + "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee": { + "code": concat!( + "0x7f", + "ddf252ad1be2c89b69c2b068fc378daa", + "952ba7f163c4a11628f55a4df523b3ef", + "5f5fa100" + ) + } + }, + "calls": [{ + "to": "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + }] + }], + "traceTransfers": true + }); + let traced = rpc_request(&endpoint, "eth_simulateV1", json!([payload.clone(), "latest"])).await; + assert!(traced.get("error").is_none(), "{traced}"); + assert_eq!(traced["result"][0]["calls"][0]["logs"].as_array().unwrap().len(), 1); + assert_ne!( + serde_json::from_value::(traced["result"][0]["logsBloom"].clone()).unwrap(), + Bloom::ZERO + ); + + payload["traceTransfers"] = json!(false); + let untraced = rpc_request(&endpoint, "eth_simulateV1", json!([payload, "latest"])).await; + assert_eq!(traced["result"][0]["logsBloom"], untraced["result"][0]["logsBloom"]); + assert_eq!(traced["result"][0]["receiptsRoot"], untraced["result"][0]["receiptsRoot"]); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_resolves_nonces_from_state() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let endpoint = handle.http_endpoint(); + let sender = "0xc000000000000000000000000000000000000000"; + let receiver = "0xc100000000000000000000000000000000000000"; + let response = rpc_request( + &endpoint, + "eth_simulateV1", + json!([{ + "blockStateCalls": [{ + "blockOverrides": {"baseFeePerGas": "0x0"}, + "stateOverrides": { + (sender): {"balance": "0x1", "code": "0x00", "nonce": "0x7"} + }, + "calls": [ + {"from": sender, "to": receiver}, + {"from": sender, "to": receiver} + ] + }], + "validation": true, + "returnFullTransactions": true + }, "latest"]), + ) + .await; + + assert!(response.get("error").is_none(), "{response}"); + assert_eq!(response["result"][0]["transactions"][0]["nonce"], "0x7"); + assert_eq!(response["result"][0]["transactions"][1]["nonce"], "0x8"); + + let response = rpc_request( + &endpoint, + "eth_simulateV1", + json!([{ + "blockStateCalls": [{ + "stateOverrides": {(sender): {"nonce": "0xfffffffffffffffe"}}, + "calls": [ + {"from": sender, "to": receiver}, + {"from": sender, "to": receiver}, + {"from": sender, "to": receiver} + ] + }], + "returnFullTransactions": true + }, "latest"]), + ) + .await; + + assert!(response.get("error").is_none(), "{response}"); + assert!( + response["result"][0]["calls"] + .as_array() + .unwrap() + .iter() + .all(|call| call["status"] == "0x1") + ); + assert_eq!(response["result"][0]["transactions"][0]["nonce"], "0xfffffffffffffffe"); + assert_eq!(response["result"][0]["transactions"][1]["nonce"], "0xffffffffffffffff"); + assert_eq!(response["result"][0]["transactions"][2]["nonce"], "0x0"); + + let response = rpc_request( + &endpoint, + "eth_simulateV1", + json!([{ + "blockStateCalls": [{ + "stateOverrides": {(sender): {"nonce": "0xffffffffffffffff"}}, + "calls": [ + {"from": sender}, + {"from": sender, "to": receiver} + ] + }], + "returnFullTransactions": true + }, "latest"]), + ) + .await; + + assert!(response.get("error").is_none(), "{response}"); + assert_eq!(response["result"][0]["transactions"][0]["nonce"], "0xffffffffffffffff"); + assert_eq!(response["result"][0]["transactions"][1]["nonce"], "0xffffffffffffffff"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_maps_validation_errors() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let endpoint = handle.http_endpoint(); + let sender = "0xc000000000000000000000000000000000000000"; + let receiver = "0xc100000000000000000000000000000000000000"; + let cases = [ + ( + json!([{ + "blockStateCalls": [{ + "blockOverrides": {"baseFeePerGas": "0x0"}, + "stateOverrides": {(sender): {"nonce": "0x1"}}, + "calls": [{"from": sender, "to": receiver, "nonce": "0x0"}] + }], + "validation": true + }, "latest"]), + -38010, + ), + ( + json!([{ + "blockStateCalls": [{ + "blockOverrides": {"baseFeePerGas": "0x0"}, + "stateOverrides": {(sender): {"nonce": "0x1"}}, + "calls": [{"from": sender, "to": receiver, "nonce": "0x2"}] + }], + "validation": true + }, "latest"]), + -38011, + ), + ( + json!([{ + "blockStateCalls": [{ + "blockOverrides": {"baseFeePerGas": "0x0"}, + "stateOverrides": {(sender): {"nonce": "0xffffffffffffffff"}}, + "calls": [{"from": sender, "to": receiver}] + }], + "validation": true + }, "latest"]), + -32603, + ), + ( + json!([{ + "blockStateCalls": [{ + "blockOverrides": {"baseFeePerGas": "0xa"}, + "calls": [{"from": sender, "to": receiver, "maxFeePerGas": "0x0"}] + }], + "validation": true + }, "latest"]), + -38012, + ), + ( + json!([{ + "blockStateCalls": [{"calls": [{"from": sender, "to": receiver, "gas": "0x0"}]}] + }, "latest"]), + -38013, + ), + ( + json!([{ + "blockStateCalls": [{"calls": [{"from": sender, "to": receiver, "value": "0x3e8"}]}] + }, "latest"]), + -38014, + ), + ]; + + for (params, code) in cases { + let response = rpc_request(&endpoint, "eth_simulateV1", params).await; + assert_eq!(response["error"]["code"], code, "{response}"); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_enforces_request_block_limits_rpc() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let endpoint = handle.http_endpoint(); + + let response = + rpc_request(&endpoint, "eth_simulateV1", json!([{"blockStateCalls": []}, "latest"])).await; + assert_eq!(response["error"], json!({"code": -32602, "message": "empty input"})); + + let blocks = vec![json!({}); 256]; + let response = + rpc_request(&endpoint, "eth_simulateV1", json!([{"blockStateCalls": blocks}, "latest"])) + .await; + assert_eq!(response["result"].as_array().unwrap().len(), 256); + + let blocks = vec![json!({}); 257]; + let response = + rpc_request(&endpoint, "eth_simulateV1", json!([{"blockStateCalls": blocks}, "latest"])) + .await; + assert_eq!(response["error"], json!({"code": -38026, "message": "too many blocks"})); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_future_base_block_returns_header_not_found_rpc() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let response = rpc_request( + &handle.http_endpoint(), + "eth_simulateV1", + json!([{"blockStateCalls": [{}]}, "0x1"]), + ) + .await; + + assert_eq!(response["error"], json!({"code": -32000, "message": "header not found"})); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_simulate_enforces_request_gas_budget_rpc() { + let config = NodeConfig::test().with_base_fee(Some(0)).with_gas_limit(Some(75_398_208)); + let (_api, handle) = spawn(config).await; + let sender = "0xc000000000000000000000000000000000000000"; + let receiver = "0xc100000000000000000000000000000000000000"; + let reverter = "0xc200000000000000000000000000000000000000"; + let response = rpc_request( + &handle.http_endpoint(), + "eth_simulateV1", + json!([{ + "blockStateCalls": [ + { + "stateOverrides": { + (sender): {"balance": "0x1"}, + (reverter): {"code": "0x5f5ffd"} + }, + "calls": [{"from": sender, "to": reverter}] + }, + {"calls": [{"from": sender, "to": receiver}]} + ], + "validation": true, + "returnFullTransactions": true + }, "latest"]), + ) + .await; + + assert!(response.get("error").is_none(), "{response}"); + let blocks = response["result"].as_array().unwrap(); + assert_eq!(blocks[0]["calls"][0]["status"], "0x0"); + assert_eq!(blocks[0]["transactions"][0]["gas"], "0x2faf080"); + let failed_call_gas = u64::from_str_radix( + blocks[0]["calls"][0]["gasUsed"].as_str().unwrap().trim_start_matches("0x"), + 16, + ) + .unwrap(); + assert_eq!( + blocks[1]["transactions"][0]["gas"], + format!("0x{:x}", 50_000_000 - failed_call_gas) + ); +} + +async fn rpc_request(endpoint: &str, method: &str, params: Value) -> Value { + let response = reqwest::Client::new() + .post(endpoint) + .json(&json!({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + })) + .send() + .await + .unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::OK); + response.json().await.unwrap() +} + +async fn spawn_hash_aware_rpc_proxy( + hash_endpoint: String, + canonical_endpoint: String, + hash_selector: Value, +) -> String { + let router = Router::new().route( + "/", + post(move |Json(request): Json| { + let hash_endpoint = hash_endpoint.clone(); + let canonical_endpoint = canonical_endpoint.clone(); + let hash_selector = hash_selector.clone(); + async move { + let method = request["method"].as_str().unwrap(); + let endpoint = if method == "eth_getBlockByHash" + && request["params"].get(0) == Some(&hash_selector) + || method == "eth_simulateV1" + && request["params"].get(1) == Some(&hash_selector) + { + hash_endpoint + } else { + canonical_endpoint + }; + let response = reqwest::Client::new() + .post(endpoint) + .json(&request) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + Json(response) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + format!("http://{address}") +} + +fn quantity(value: &Value) -> u64 { + u64::from_str_radix(value.as_str().unwrap().trim_start_matches("0x"), 16).unwrap() } diff --git a/crates/anvil/tests/it/state.rs b/crates/anvil/tests/it/state.rs index 75948e6578d52..9e2110c7d1c8c 100644 --- a/crates/anvil/tests/it/state.rs +++ b/crates/anvil/tests/it/state.rs @@ -2,12 +2,65 @@ use crate::abi::Greeter; use alloy_network::{ReceiptResponse, TransactionBuilder}; -use alloy_primitives::{Bytes, U256, Uint, address, utils::Unit}; +use alloy_primitives::{Address, B256, Bytes, U256, Uint, address, b256, bytes, utils::Unit}; use alloy_provider::Provider; -use alloy_rpc_types::{BlockId, TransactionRequest}; +use alloy_rpc_types::{ + BlockId, TransactionRequest, + state::{AccountOverride, EvmOverrides, StateOverride}, +}; use alloy_serde::WithOtherFields; -use anvil::{NodeConfig, spawn}; +use anvil::{NodeConfig, eth::backend::db::SerializableState, spawn}; +use foundry_evm::hardfork::EthereumHardfork; use foundry_test_utils::rpc::next_http_archive_rpc_url; +use revm::{ + context_interface::block::BlobExcessGasAndPrice, + primitives::eip4844::BLOB_BASE_FEE_UPDATE_FRACTION_PRAGUE, +}; +use serde_json::{Value, json}; +use std::{str::FromStr, time::Duration}; + +#[tokio::test(flavor = "multi_thread")] +async fn executes_rpc_notification_without_response() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let account = Address::with_last_byte(1); + let balance = U256::from(42); + + let response = reqwest::Client::new() + .post(handle.http_endpoint()) + .json(&json!({ + "jsonrpc": "2.0", + "method": "anvil_setBalance", + "params": [account, balance], + })) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT); + assert!(response.bytes().await.unwrap().is_empty()); + assert_eq!(handle.http_provider().get_balance(account).await.unwrap(), balance); +} + +async fn state_without_block_history() -> (Value, Address, U256, u64) { + let account = address!("0000000000000000000000000000000000010363"); + let balance = U256::from(10363); + let (api, _handle) = spawn(NodeConfig::test()).await; + api.anvil_set_balance(account, balance).await.unwrap(); + api.mine_one().await.unwrap(); + api.mine_one().await.unwrap(); + + let mut state = serde_json::to_value(api.serialized_state(false).await.unwrap()).unwrap(); + let state = state.as_object_mut().unwrap(); + state.remove("blocks"); + state.remove("transactions"); + state.remove("historical_states"); + + let block = state.get_mut("block").unwrap().as_object_mut().unwrap(); + let beneficiary = block.remove("beneficiary").unwrap(); + block.insert("coinbase".to_string(), beneficiary); + + (Value::Object(state.clone()), account, balance, api.backend.fees().base_fee()) +} #[tokio::test(flavor = "multi_thread")] async fn can_load_state() { @@ -16,8 +69,8 @@ async fn can_load_state() { let (api, _handle) = spawn(NodeConfig::test()).await; - api.mine_one().await; - api.mine_one().await; + api.mine_one().await.unwrap(); + api.mine_one().await.unwrap(); let num = api.block_number().unwrap(); @@ -42,6 +95,355 @@ async fn can_load_state() { assert_eq!(num, U256::from(num_from_tag)); } +// +#[tokio::test(flavor = "multi_thread")] +async fn test_load_state_continues_saved_timeline() { + let (api, _handle) = spawn(NodeConfig::test()).await; + + // Move the chain's timeline one year ahead of wall-clock time, then mine on it. + let one_year_ahead = + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + + 31_536_000; + api.evm_set_next_block_timestamp(one_year_ahead).unwrap(); + api.mine_one().await.unwrap(); + + let saved_head_timestamp = api + .block_by_number(alloy_eips::BlockNumberOrTag::Latest) + .await + .unwrap() + .unwrap() + .header + .timestamp; + assert_eq!(saved_head_timestamp, one_year_ahead); + + let state = api.serialized_state(false).await.unwrap(); + let (api, _handle) = spawn(NodeConfig::test().with_init_state(Some(state))).await; + + api.mine_one().await.unwrap(); + let new_head_timestamp = api + .block_by_number(alloy_eips::BlockNumberOrTag::Latest) + .await + .unwrap() + .unwrap() + .header + .timestamp; + + // The block mined after loading the state must continue the saved timeline instead of + // falling back to the wall-clock anchor of the fresh node. + assert!( + new_head_timestamp >= saved_head_timestamp, + "block after load_state went back in time: {new_head_timestamp} < {saved_head_timestamp}" + ); +} + +// Loading a state whose head has the same number as the fork block must keep the fork time +// anchor: the canonical head stays the fork block, so the saved timeline does not apply. +#[tokio::test(flavor = "multi_thread")] +async fn test_load_state_equal_height_fork_keeps_fork_anchor() { + // The state source: one block mined a year ahead of wall-clock time. + let (api_state, _handle_state) = spawn(NodeConfig::test()).await; + let one_year_ahead = + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + + 31_536_000; + api_state.evm_set_next_block_timestamp(one_year_ahead).unwrap(); + api_state.mine_one().await.unwrap(); + let state = api_state.serialized_state(false).await.unwrap(); + + // The fork source: one block mined on the wall-clock timeline, same height as the state. + let (api_remote, handle_remote) = spawn(NodeConfig::test()).await; + api_remote.mine_one().await.unwrap(); + let remote_head_timestamp = api_remote + .block_by_number(alloy_eips::BlockNumberOrTag::Latest) + .await + .unwrap() + .unwrap() + .header + .timestamp; + + // Fork the remote head (height 1) and load the state (best height 1 as well): the + // canonical head keeps being the fork block, so its time anchor must be preserved. + let (api, _handle) = spawn( + NodeConfig::test() + .with_eth_rpc_url(Some(handle_remote.http_endpoint())) + .with_init_state(Some(state)), + ) + .await; + + api.mine_one().await.unwrap(); + let new_head_timestamp = api + .block_by_number(alloy_eips::BlockNumberOrTag::Latest) + .await + .unwrap() + .unwrap() + .header + .timestamp; + + assert!( + new_head_timestamp < one_year_ahead, + "block after load_state jumped to the loaded state's timeline instead of keeping the \ + fork anchor: {new_head_timestamp} >= {one_year_ahead}" + ); + assert!( + new_head_timestamp >= remote_head_timestamp, + "block after load_state went back in time: {new_head_timestamp} < \ + {remote_head_timestamp}" + ); +} + +// Loading a legacy account-only state at the fork head must still reset timestamp controls to +// the canonical fork timeline, even though the state has no block environment. +#[tokio::test(flavor = "multi_thread")] +async fn test_load_blockless_state_reanchors_time_to_fork_head() { + let (api_remote, handle_remote) = spawn(NodeConfig::test()).await; + api_remote.mine_one().await.unwrap(); + let remote_head = api_remote + .block_by_number(alloy_eips::BlockNumberOrTag::Latest) + .await + .unwrap() + .unwrap() + .header + .clone(); + + let (api, _handle) = + spawn(NodeConfig::test().with_eth_rpc_url(Some(handle_remote.http_endpoint()))).await; + let one_year_ahead = remote_head.timestamp + 31_536_000; + api.evm_increase_time(U256::from(60)).await.unwrap(); + api.evm_set_next_block_timestamp(one_year_ahead).unwrap(); + + let state = SerializableState::default(); + let state = Bytes::from(serde_json::to_vec(&state).unwrap()); + assert!(api.anvil_load_state(state).await.unwrap()); + + api.mine_one().await.unwrap(); + let new_head = api + .block_by_number(alloy_eips::BlockNumberOrTag::Latest) + .await + .unwrap() + .unwrap() + .header + .clone(); + assert_eq!(new_head.parent_hash, remote_head.hash); + assert!( + new_head.timestamp < one_year_ahead, + "block after loading a blockless state reused pending timestamp controls: {} >= {}", + new_head.timestamp, + one_year_ahead + ); +} + +// When `anvil_loadState` rolls an already-advanced fork back to its fork head (state file at +// or below the fork block), the discarded local timeline must not leak into the next block: +// block time re-anchors to the fork head, exactly like `anvil_reset`. +#[tokio::test(flavor = "multi_thread")] +async fn test_load_state_fork_rollback_reanchors_time_to_fork_head() { + // The state source: a plain node dumped at genesis (height 0). + let (api_state, _handle_state) = spawn(NodeConfig::test()).await; + let state = api_state.anvil_dump_state(None).await.unwrap(); + + // The fork source: one block mined on the wall-clock timeline. + let (api_remote, handle_remote) = spawn(NodeConfig::test()).await; + api_remote.mine_one().await.unwrap(); + let remote_head = api_remote + .block_by_number(alloy_eips::BlockNumberOrTag::Latest) + .await + .unwrap() + .unwrap() + .header + .clone(); + + // Fork the remote head (height 1), then mine a local block one year ahead of wall-clock. + let (api, _handle) = + spawn(NodeConfig::test().with_eth_rpc_url(Some(handle_remote.http_endpoint()))).await; + let one_year_ahead = + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + + 31_536_000; + api.evm_set_next_block_timestamp(one_year_ahead).unwrap(); + api.mine_one().await.unwrap(); + + // Loading the height-0 state rolls the canonical head back to the exact fork head. + assert!(api.anvil_load_state(state).await.unwrap()); + let head = api + .block_by_number(alloy_eips::BlockNumberOrTag::Latest) + .await + .unwrap() + .unwrap() + .header + .clone(); + assert_eq!(head.hash, remote_head.hash, "canonical head must return to the exact fork head"); + + // The next block continues from the fork head's timeline, not from the discarded + // future-dated local block. + api.mine_one().await.unwrap(); + let new_head = api + .block_by_number(alloy_eips::BlockNumberOrTag::Latest) + .await + .unwrap() + .unwrap() + .header + .clone(); + assert_eq!(new_head.parent_hash, remote_head.hash); + assert!( + new_head.timestamp >= remote_head.timestamp, + "block after fork rollback went back in time: {} < {}", + new_head.timestamp, + remote_head.timestamp + ); + assert!( + new_head.timestamp < one_year_ahead, + "block after the fork rollback reused the discarded local timeline: {} >= {}", + new_head.timestamp, + one_year_ahead + ); +} + +// A state file can carry competing blocks at the same height: dump an older state, keep mining, +// load that older dump back, then mine a replacement block. Loading such a file must select the +// replacement as the canonical head and continue its timeline. +#[tokio::test(flavor = "multi_thread")] +async fn test_load_state_stale_blocks_preserve_canonical_head() { + let (api, _handle) = spawn(NodeConfig::test()).await; + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); + let one_year_ahead = now + 31_536_000; + let two_years_ahead = now + 63_072_000; + + // Head at height 1, one year ahead: this is the canonical timeline to preserve. + api.evm_set_next_block_timestamp(one_year_ahead).unwrap(); + api.mine_one().await.unwrap(); + let older_dump = api.anvil_dump_state(None).await.unwrap(); + + // Advance to height 2, two years ahead, then roll best back to height 1 by loading the + // older dump: the height-2 block stays in storage above the restored best block. + api.evm_set_next_block_timestamp(two_years_ahead).unwrap(); + api.mine_one().await.unwrap(); + assert!(api.anvil_load_state(older_dump).await.unwrap()); + + // Mine a replacement at height 2. The stale height-2 block remains in storage, while the + // replacement becomes canonical. + api.evm_set_next_block_timestamp(one_year_ahead + 1).unwrap(); + api.mine_one().await.unwrap(); + let canonical_head = api + .block_by_number(alloy_eips::BlockNumberOrTag::Latest) + .await + .unwrap() + .unwrap() + .header + .clone(); + let state = api.serialized_state(false).await.unwrap(); + assert_eq!(state.blocks.iter().filter(|block| block.header.number == 2).count(), 2); + assert_eq!(state.best_block_number, Some(2)); + + // A fresh node selects the replacement as its head and mines on top of it. + let (api, _handle) = spawn(NodeConfig::test().with_init_state(Some(state))).await; + assert_eq!(api.backend.best_hash(), canonical_head.hash); + api.mine_one().await.unwrap(); + let new_head = api + .block_by_number(alloy_eips::BlockNumberOrTag::Latest) + .await + .unwrap() + .unwrap() + .header + .clone(); + assert_eq!(new_head.parent_hash, canonical_head.hash); + assert!( + new_head.timestamp >= one_year_ahead && new_head.timestamp < two_years_ahead, + "block after loading a dump with stale blocks must continue the canonical timeline: \ + got {}, expected within [{}, {})", + new_head.timestamp, + one_year_ahead, + two_years_ahead + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_load_state_removes_canonical_mappings_above_restored_head() { + let (api, handle) = spawn(NodeConfig::test()).await; + api.mine_one().await.unwrap(); + let older_dump = api.anvil_dump_state(None).await.unwrap(); + + let receipt = handle + .http_provider() + .send_transaction(WithOtherFields::new( + TransactionRequest::default() + .with_from(address!("f39fd6e51aad88f6f4ce6ab8827279cfffb92266")) + .with_to(Address::ZERO), + )) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + let block_hash = receipt.block_hash.unwrap(); + let transaction_hash = receipt.transaction_hash; + + assert!(api.anvil_load_state(older_dump).await.unwrap()); + assert_eq!(api.block_number().unwrap(), U256::ONE); + assert!(api.block_by_number(2.into()).await.unwrap().is_none()); + assert!(api.block_transaction_count_by_number(2.into()).await.unwrap().is_none()); + assert!(api.transaction_by_block_number_and_index(2.into(), 0.into()).await.unwrap().is_none()); + + assert!(api.block_by_hash(block_hash).await.unwrap().is_some()); + assert!(api.transaction_by_hash(transaction_hash).await.unwrap().is_some()); +} + +// +#[tokio::test(flavor = "multi_thread")] +async fn finalized_block_hash_consistent_after_load_state() { + use alloy_eips::BlockNumberOrTag; + + let tmp = tempfile::tempdir().unwrap(); + let state_file = tmp.path().join("state.json"); + + let (api, _handle) = spawn(NodeConfig::test()).await; + + api.mine_one().await.unwrap(); + + // Get the original genesis block hash + let original_genesis = api.block_by_number(BlockNumberOrTag::Number(0)).await.unwrap().unwrap(); + let original_genesis_hash = original_genesis.header.hash; + + let state = api.serialized_state(false).await.unwrap(); + foundry_common::fs::write_json_file(&state_file, &state).unwrap(); + + // Load state with a different genesis timestamp. + // The new instance will create its own genesis block with a different timestamp, + // but then load_state should overwrite it. The bug is that genesis_hash field isn't updated. + let (api, _handle) = spawn( + NodeConfig::test() + .with_genesis_timestamp(Some(original_genesis.header.timestamp + 1000)) + .with_init_state_path(state_file), + ) + .await; + + // Query finalized block - should return genesis (block 0) since best_number is small + let finalized_block = api.block_by_number(BlockNumberOrTag::Finalized).await.unwrap().unwrap(); + let finalized_hash = finalized_block.header.hash; + let finalized_number = finalized_block.header.number; + + // Query block by the finalized block's number directly + let block_by_number = + api.block_by_number(BlockNumberOrTag::Number(finalized_number)).await.unwrap().unwrap(); + let block_by_number_hash = block_by_number.header.hash; + + // Verify the loaded genesis matches the original + assert_eq!( + block_by_number_hash, original_genesis_hash, + "Loaded genesis should match original genesis hash" + ); + + // Both finalized and block 0 should return the same hash + assert_eq!( + finalized_hash, block_by_number_hash, + "Finalized block hash should match block queried by number" + ); + + // Also verify Earliest block tag returns consistent hash + let earliest_block = api.block_by_number(BlockNumberOrTag::Earliest).await.unwrap().unwrap(); + assert_eq!( + earliest_block.header.hash, original_genesis_hash, + "Earliest block hash should match original genesis hash" + ); +} + #[tokio::test(flavor = "multi_thread")] async fn can_load_existing_state_legacy() { let state_file = "test-data/state-dump-legacy.json"; @@ -52,6 +454,157 @@ async fn can_load_existing_state_legacy() { assert_eq!(block_number, Uint::from(2)); } +// +#[tokio::test(flavor = "multi_thread")] +async fn can_load_state_without_block_history() { + let (state, account, balance, next_base_fee) = state_without_block_history().await; + let tmp = tempfile::tempdir().unwrap(); + let state_file = tmp.path().join("state.json"); + foundry_common::fs::write_json_file(&state_file, &state).unwrap(); + + let (api, handle) = spawn(NodeConfig::test().with_init_state_path(state_file)).await; + let provider = handle.http_provider(); + + assert_eq!(provider.get_balance(account).await.unwrap(), balance); + assert_eq!(api.block_number().unwrap(), U256::from(2)); + + let checkpoint = + api.block_by_number(alloy_eips::BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert_eq!(checkpoint.header.number, 2); + assert_eq!(checkpoint.header.hash, api.backend.best_hash()); + assert_eq!(api.backend.fees().base_fee(), next_base_fee); + + api.mine_one().await.unwrap(); + let latest = api.block_by_number(alloy_eips::BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert_eq!(latest.header.number, 3); + assert_eq!(latest.header.parent_hash, checkpoint.header.hash); + assert_eq!(latest.header.base_fee_per_gas, Some(next_base_fee)); + + let dumped = api.serialized_state(false).await.unwrap(); + assert!(dumped.blocks.iter().any(|block| block.header.number == 2)); + + let (reloaded, _handle) = spawn(NodeConfig::test().with_init_state(Some(dumped))).await; + let reloaded_latest = + reloaded.block_by_number(alloy_eips::BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert_eq!(reloaded_latest.header.number, 3); + assert_eq!(reloaded_latest.header.hash, latest.header.hash); +} + +// +#[tokio::test(flavor = "multi_thread")] +async fn can_load_state_without_block_history_at_runtime() { + let (mut state, _, _, _) = state_without_block_history().await; + let loaded_beneficiary = address!("0000000000000000000000000000000000010363"); + state["block"]["coinbase"] = json!(loaded_beneficiary); + + let (api, _handle) = spawn(NodeConfig::test()).await; + api.backend.set_coinbase(Address::with_last_byte(1)); + api.mine_one().await.unwrap(); + let parent = + api.block_by_number(alloy_eips::BlockNumberOrTag::Number(1)).await.unwrap().unwrap(); + api.mine_one().await.unwrap(); + let previous = + api.block_by_number(alloy_eips::BlockNumberOrTag::Latest).await.unwrap().unwrap(); + + api.anvil_load_state(Bytes::from(serde_json::to_vec(&state).unwrap())).await.unwrap(); + + let checkpoint = + api.block_by_number(alloy_eips::BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert_eq!(checkpoint.header.number, 2); + assert_eq!(checkpoint.header.parent_hash, parent.header.hash); + assert_eq!(checkpoint.header.beneficiary, loaded_beneficiary); + assert_ne!(checkpoint.header.hash, previous.header.hash); + assert_eq!(checkpoint.header.hash, api.backend.best_hash()); + + api.mine_one().await.unwrap(); + let latest = api.block_by_number(alloy_eips::BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert_eq!(latest.header.number, 3); + assert_eq!(latest.header.parent_hash, checkpoint.header.hash); +} + +#[tokio::test(flavor = "multi_thread")] +async fn state_without_block_history_restores_osaka_blob_excess_gas() { + let (mut state, _, _, _) = state_without_block_history().await; + let blob_params = alloy_eips::eip7840::BlobParams::osaka(); + let target_blob_gas = blob_params.target_blob_gas_per_block(); + state["block"]["basefee"] = json!(1); + state["block"]["blob_excess_gas_and_price"]["excess_blob_gas"] = json!(target_blob_gas); + state["block"]["blob_excess_gas_and_price"]["blob_gasprice"] = + json!(blob_params.calc_blob_fee(target_blob_gas)); + + let state = serde_json::from_value(state).unwrap(); + let (api, _handle) = spawn( + NodeConfig::test() + .with_hardfork(Some(EthereumHardfork::Osaka.into())) + .with_init_state(Some(state)), + ) + .await; + + assert_eq!(api.backend.fees().excess_blob_gas_and_price().unwrap().excess_blob_gas, 0); +} + +#[tokio::test(flavor = "multi_thread")] +async fn rejects_nonempty_block_history_without_best_block() { + let (source, _handle) = spawn(NodeConfig::test()).await; + source.backend.set_coinbase(Address::with_last_byte(2)); + source.mine_one().await.unwrap(); + source.mine_one().await.unwrap(); + source.mine_one().await.unwrap(); + let mut state = source.serialized_state(false).await.unwrap(); + state.blocks.retain(|block| block.header.number != 3); + assert!(!state.blocks.is_empty()); + let incoming_hash = + state.blocks.iter().find(|block| block.header.number == 2).unwrap().header.hash_slow(); + + let (api, _handle) = spawn(NodeConfig::test()).await; + let original_coinbase = Address::with_last_byte(1); + api.backend.set_coinbase(original_coinbase); + api.mine_one().await.unwrap(); + let original = + api.block_by_number(alloy_eips::BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert!(api.block_by_hash(incoming_hash).await.unwrap().is_none()); + + let err = + api.anvil_load_state(Bytes::from(serde_json::to_vec(&state).unwrap())).await.unwrap_err(); + assert!(err.to_string().contains("Best hash not found for best number 3")); + assert_eq!(api.backend.best_number(), original.header.number); + assert_eq!(api.backend.best_hash(), original.header.hash); + assert_eq!(api.backend.coinbase(), original_coinbase); + assert!(api.block_by_hash(incoming_hash).await.unwrap().is_none()); + + api.mine_one().await.unwrap(); + let latest = api.block_by_number(alloy_eips::BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert_eq!(latest.header.number, original.header.number + 1); + assert_eq!(latest.header.parent_hash, original.header.hash); + assert_eq!(latest.header.beneficiary, original_coinbase); +} + +#[tokio::test(flavor = "multi_thread")] +async fn loaded_state_fees_use_selected_head() { + let (source, _handle) = spawn(NodeConfig::test()).await; + source.mine_one().await.unwrap(); + let mut state = source.serialized_state(false).await.unwrap(); + let selected_hash = source.backend.best_hash(); + let selected_next_base_fee = source.backend.fees().base_fee(); + + source.mine_one().await.unwrap(); + let newer_state = source.serialized_state(false).await.unwrap(); + let newer_block = + newer_state.blocks.into_iter().find(|block| block.header.number == 2).unwrap(); + assert_ne!(source.backend.fees().base_fee(), selected_next_base_fee); + state.blocks.push(newer_block); + + let (api, _handle) = spawn(NodeConfig::test()).await; + api.anvil_load_state(Bytes::from(serde_json::to_vec(&state).unwrap())).await.unwrap(); + assert_eq!(api.backend.best_hash(), selected_hash); + assert_eq!(api.backend.fees().base_fee(), selected_next_base_fee); + + api.mine_one().await.unwrap(); + let latest = api.block_by_number(alloy_eips::BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert_eq!(latest.header.parent_hash, selected_hash); + assert_eq!(latest.header.base_fee_per_gas, Some(selected_next_base_fee)); +} + #[tokio::test(flavor = "multi_thread")] async fn can_load_existing_state_legacy_stress() { let state_file = "test-data/state-dump-legacy-stress.json"; @@ -94,7 +647,7 @@ async fn test_make_sure_historical_state_is_not_cleared_on_dump() { .await .unwrap(); - api.mine_one().await; + api.mine_one().await.unwrap(); let ser_state = api.serialized_state(true).await.unwrap(); foundry_common::fs::write_json_file(&state_file, &ser_state).unwrap(); @@ -103,7 +656,7 @@ async fn test_make_sure_historical_state_is_not_cleared_on_dump() { assert_eq!(block_number, Uint::from(3)); // Makes sure historical states of the new instance are not cleared. - let code = provider.get_code_at(*address).block_id(BlockId::number(2)).await.unwrap(); + let code = provider.get_code_at(*address).number(2).await.unwrap(); assert_ne!(code, Bytes::new()); } @@ -134,7 +687,7 @@ async fn can_preserve_historical_states_between_dump_and_load() { let change_greeting_blk_num = tx.block_number.unwrap(); - api.mine_one().await; + api.mine_one().await.unwrap(); let ser_state = api.serialized_state(true).await.unwrap(); foundry_common::fs::write_json_file(&state_file, &ser_state).unwrap(); @@ -159,6 +712,42 @@ async fn can_preserve_historical_states_between_dump_and_load() { assert_eq!(greeting_after_change, "World!"); } +#[tokio::test(flavor = "multi_thread")] +async fn revert_removes_historical_states_of_discarded_blocks() { + let (api, _handle) = spawn(NodeConfig::test()).await; + let genesis_hash = api.backend.best_hash(); + + let snapshot = api.evm_snapshot().await.unwrap(); + api.mine_one().await.unwrap(); + let discarded_hash = api.backend.best_hash(); + api.mine_one().await.unwrap(); + assert!(api.evm_revert(snapshot).await.unwrap()); + + let state = api.serialized_state(true).await.unwrap(); + let hashes: Vec<_> = + state.historical_states.unwrap().into_iter().map(|(hash, _)| hash).collect(); + assert!(hashes.contains(&genesis_hash)); + assert!(!hashes.contains(&discarded_hash)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn state_dump_is_deterministic() { + let timestamp = 1_700_000_000u64; + let (api, handle) = spawn(NodeConfig::test().with_genesis_timestamp(timestamp.into())).await; + let provider = handle.http_provider(); + let greeter = Greeter::deploy(&provider, "Hello".to_string()).await.unwrap(); + greeter.setGreeting("World!".to_string()).send().await.unwrap().watch().await.unwrap(); + api.mine_one().await.unwrap(); + + let dump = api.anvil_dump_state(Some(true)).await.unwrap(); + assert_eq!(dump, api.anvil_dump_state(Some(true)).await.unwrap()); + + let (loaded_api, _handle) = + spawn(NodeConfig::test().with_genesis_timestamp(timestamp.into())).await; + assert!(loaded_api.anvil_load_state(dump.clone()).await.unwrap()); + assert_eq!(dump, loaded_api.anvil_dump_state(Some(true)).await.unwrap()); +} + // #[tokio::test(flavor = "multi_thread")] async fn test_fork_load_state() { @@ -178,7 +767,7 @@ async fn test_fork_load_state() { let init_balance_alice = provider.get_balance(alice).await.unwrap(); - let value = Unit::ETHER.wei().saturating_mul(U256::from(1)); // 1 ether + let value = Unit::ETHER.wei().saturating_mul(U256::ONE); // 1 ether let tx = TransactionRequest::default().with_to(alice).with_value(value).with_from(bob); let tx = WithOtherFields::new(tx); @@ -246,6 +835,174 @@ async fn test_fork_load_state() { assert_eq!(balance_alice + value, latest_balance_alice); } +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_load_state_preserves_fork_blocks_below_head() { + let (_origin_api, origin) = + spawn(NodeConfig::test().with_genesis_timestamp(Some(1_700_000_000u64))).await; + let (dump_source_api, dump_source) = + spawn(NodeConfig::test().with_genesis_timestamp(Some(1_700_000_001u64))).await; + let origin_provider = origin.http_provider(); + let dump_provider = dump_source.http_provider(); + let sender = origin.dev_wallets().next().unwrap().address(); + let recipient = Address::repeat_byte(0x11); + let fork_boundary = 3; + let mut origin_transactions = Vec::new(); + let mut dump_transactions = Vec::new(); + + for _ in 0..fork_boundary { + for (provider, value, transactions) in [ + (&origin_provider, 1, &mut origin_transactions), + (&dump_provider, 2, &mut dump_transactions), + ] { + let receipt = provider + .send_transaction(WithOtherFields::new( + TransactionRequest::default() + .with_from(sender) + .with_to(recipient) + .with_value(U256::from(value)), + )) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert!(receipt.status()); + transactions.push(receipt.transaction_hash); + } + } + + let mut origin_blocks = Vec::new(); + let mut dump_blocks = Vec::new(); + for number in 0..=fork_boundary { + let origin_block = + origin_provider.get_block_by_number(number.into()).await.unwrap().unwrap(); + let dump_block = dump_provider.get_block_by_number(number.into()).await.unwrap().unwrap(); + assert_ne!(origin_block.header.hash, dump_block.header.hash); + origin_blocks.push(origin_block); + dump_blocks.push(dump_block); + } + let state = dump_source_api.serialized_state(false).await.unwrap(); + let (_forked_api, forked) = spawn( + NodeConfig::test() + .with_eth_rpc_url(Some(origin.http_endpoint())) + .with_fork_block_number(Some(fork_boundary)) + .with_init_state(Some(state)), + ) + .await; + let forked_provider = forked.http_provider(); + assert_eq!(forked_provider.get_block_number().await.unwrap(), fork_boundary); + + for (number, (origin_block, dump_block)) in origin_blocks.iter().zip(&dump_blocks).enumerate() { + let block = + forked_provider.get_block_by_number((number as u64).into()).await.unwrap().unwrap(); + assert_eq!(block.header.hash, origin_block.header.hash); + assert_eq!(block.header.parent_hash, origin_block.header.parent_hash); + if number > 0 { + assert_eq!(block.header.parent_hash, origin_blocks[number - 1].header.hash); + } + assert!(forked_provider.get_block_by_hash(dump_block.header.hash).await.unwrap().is_none()); + assert!( + forked_provider + .get_block_by_hash(dump_block.header.hash) + .full() + .await + .unwrap() + .is_none() + ); + assert!( + forked_provider + .get_transaction_by_block_hash_and_index(dump_block.header.hash, 0) + .await + .unwrap() + .is_none() + ); + if number > 0 { + let origin_tx = origin_provider + .get_transaction_by_hash(origin_transactions[number - 1]) + .await + .unwrap() + .unwrap(); + assert_eq!( + forked_provider + .get_transaction_by_hash(origin_transactions[number - 1]) + .await + .unwrap(), + Some(origin_tx.clone()) + ); + assert!( + forked_provider + .get_transaction_by_hash(dump_transactions[number - 1]) + .await + .unwrap() + .is_none() + ); + assert_eq!( + forked_provider + .get_transaction_by_block_hash_and_index(origin_block.header.hash, 0) + .await + .unwrap(), + Some(origin_tx.clone()) + ); + // Number-and-index forwarding currently excludes the fork head. + if (number as u64) < fork_boundary { + assert_eq!( + forked_provider + .get_transaction_by_block_number_and_index((number as u64).into(), 0) + .await + .unwrap(), + Some(origin_tx) + ); + } + } + } +} + +// +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_load_state_keeps_number_opcode_in_sync() { + let target = address!("0000000000000000000000000000000000010501"); + let bob = address!("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"); + + let (api, _handle) = spawn( + NodeConfig::test() + .with_eth_rpc_url(Some(next_http_archive_rpc_url())) + .with_fork_block_number(Some(21070682u64)), + ) + .await; + + // Runtime code: NUMBER, PUSH0, SSTORE, STOP. + api.anvil_set_code(target, bytes!("435f5500")).await.unwrap(); + api.mine_one().await.unwrap(); + + let serialized_state = api.serialized_state(false).await.unwrap(); + + let (api, handle) = spawn( + NodeConfig::test() + .with_eth_rpc_url(Some(next_http_archive_rpc_url())) + .with_fork_block_number(Some(21070686u64)) + .with_init_state(Some(serialized_state)), + ) + .await; + let provider = handle.http_provider(); + + let current_block = api.block_number().unwrap().to::(); + assert_eq!(current_block, 21070686u64); + + let tx = TransactionRequest::default().with_to(target).with_from(bob); + let receipt = provider + .send_transaction(WithOtherFields::new(tx)) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + let mined_block = receipt.block_number.unwrap(); + + let stored = provider.get_storage_at(target, U256::ZERO).await.unwrap(); + assert_eq!(stored, U256::from(mined_block)); + assert_eq!(stored, U256::from(current_block + 1)); +} + // #[tokio::test(flavor = "multi_thread")] async fn test_fork_load_state_with_greater_state_block() { @@ -256,7 +1013,7 @@ async fn test_fork_load_state_with_greater_state_block() { ) .await; - api.mine_one().await; + api.mine_one().await.unwrap(); let block_number = api.block_number().unwrap(); @@ -292,7 +1049,7 @@ async fn computes_next_base_fee_after_loading_state() { let base_fee_empty_chain = api.backend.fees().base_fee(); - let value = Unit::ETHER.wei().saturating_mul(U256::from(1)); // 1 ether + let value = Unit::ETHER.wei().saturating_mul(U256::ONE); // 1 ether let tx = TransactionRequest::default().with_to(alice).with_value(value).with_from(bob); let tx = WithOtherFields::new(tx); @@ -309,3 +1066,559 @@ async fn computes_next_base_fee_after_loading_state() { let base_fee_after_reload = api.backend.fees().base_fee(); assert_eq!(base_fee_after_reload, base_fee_after_one_tx); } + +// +#[tokio::test(flavor = "multi_thread")] +async fn test_backward_compatibility_deserialization_v1_2() { + let old_format = r#"{ + "block": { + "number": "0x5", + "coinbase": "0x1234567890123456789012345678901234567890", + "timestamp": "0x688c83b5", + "gas_limit": "0x1c9c380", + "basefee": "0x3b9aca00", + "difficulty": "0x0", + "prevrandao": "0xecc5f0af8ff6b65c14bfdac55ba9db870d89482eb2b87200c6d7e7cd3a3a5ad5", + "blob_excess_gas_and_price": { + "excess_blob_gas": 173990704, + "blob_gasprice": 43056053164891617135028 + } + }, + "accounts": {}, + "best_block_number": "0x5", + "blocks": [], + "transactions": [] + }"#; + + let state: SerializableState = serde_json::from_str(old_format).unwrap(); + assert!(state.block.is_some()); + let block_env = state.block.unwrap(); + assert_eq!(block_env.number, U256::from(5)); + // Verify coinbase was converted to beneficiary + assert_eq!(block_env.beneficiary, address!("0x1234567890123456789012345678901234567890")); + let blob = block_env.blob_excess_gas_and_price.unwrap(); + assert_eq!(blob.excess_blob_gas, 173990704); + assert_eq!(blob.blob_gasprice, 43056053164891617135028); + + // New format with beneficiary and numeric values + let new_format = r#"{ + "block": { + "number": 6, + "beneficiary": "0x1234567890123456789012345678901234567891", + "timestamp": 1751619509, + "gas_limit": 30000000, + "basefee": 1000000000, + "difficulty": "0x0", + "prevrandao": "0xecc5f0af8ff6b65c14bfdac55ba9db870d89482eb2b87200c6d7e7cd3a3a5ad5", + "blob_excess_gas_and_price": { + "excess_blob_gas": 0, + "blob_gasprice": 1 + } + }, + "accounts": {}, + "best_block_number": 6, + "blocks": [], + "transactions": [] + }"#; + + let state: SerializableState = serde_json::from_str(new_format).unwrap(); + assert!(state.block.is_some()); + let block_env = state.block.unwrap(); + assert_eq!(block_env.number, U256::from(6)); + assert_eq!(block_env.beneficiary, address!("0x1234567890123456789012345678901234567891")); +} + +// +#[tokio::test(flavor = "multi_thread")] +async fn test_backward_compatibility_mixed_formats_deserialization_v1_2() { + let mixed_format = json!({ + "block": { + "number": "0x3", + "coinbase": "0x1111111111111111111111111111111111111111", + "timestamp": 1751619509, + "gas_limit": "0x1c9c380", + "basefee": 1000000000, + "difficulty": "0x0", + "prevrandao": "0xecc5f0af8ff6b65c14bfdac55ba9db870d89482eb2b87200c6d7e7cd3a3a5ad5", + "blob_excess_gas_and_price": { + "excess_blob_gas": 0, + "blob_gasprice": 1 + } + }, + "accounts": {}, + "best_block_number": 3, + "blocks": [], + "transactions": [] + }); + + let state: SerializableState = serde_json::from_str(&mixed_format.to_string()).unwrap(); + let block_env = state.block.unwrap(); + + assert_eq!(block_env.number, U256::from(3)); + assert_eq!(block_env.beneficiary, Address::repeat_byte(0x11)); + assert_eq!(block_env.timestamp, U256::from(1751619509)); + assert_eq!(block_env.gas_limit, 0x1c9c380); + assert_eq!(block_env.basefee, 1_000_000_000); + assert_eq!(block_env.difficulty, U256::ZERO); + assert_eq!( + block_env.prevrandao.unwrap(), + b256!("ecc5f0af8ff6b65c14bfdac55ba9db870d89482eb2b87200c6d7e7cd3a3a5ad5") + ); + + let blob = block_env.blob_excess_gas_and_price.unwrap(); + assert_eq!(blob.excess_blob_gas, 0); + assert_eq!(blob.blob_gasprice, 1); + + assert_eq!(state.best_block_number, Some(3)); +} + +// +#[tokio::test(flavor = "multi_thread")] +async fn test_backward_compatibility_optional_fields_deserialization_v1_2() { + let partial_old_format = json!({ + "block": { + "number": "0x1", + "coinbase": "0x0000000000000000000000000000000000000000", + "timestamp": "0x688c83b5", + "gas_limit": "0x1c9c380", + "basefee": "0x3b9aca00", + "difficulty": "0x0", + "prevrandao": "0xecc5f0af8ff6b65c14bfdac55ba9db870d89482eb2b87200c6d7e7cd3a3a5ad5" + // Missing blob_excess_gas_and_price - should be None + }, + "accounts": {}, + "best_block_number": "0x1" + // Missing blocks and transactions arrays - should default to empty + }); + + let state: SerializableState = serde_json::from_str(&partial_old_format.to_string()).unwrap(); + + let block_env = state.block.unwrap(); + assert_eq!(block_env.number, U256::ONE); + assert_eq!(block_env.beneficiary, Address::ZERO); + assert_eq!(block_env.timestamp, U256::from(0x688c83b5)); + assert_eq!(block_env.gas_limit, 0x1c9c380); + assert_eq!(block_env.basefee, 0x3b9aca00); + assert_eq!(block_env.difficulty, U256::ZERO); + assert_eq!( + block_env.prevrandao.unwrap(), + b256!("ecc5f0af8ff6b65c14bfdac55ba9db870d89482eb2b87200c6d7e7cd3a3a5ad5") + ); + assert_eq!( + block_env.blob_excess_gas_and_price, + Some(BlobExcessGasAndPrice::new(0, BLOB_BASE_FEE_UPDATE_FRACTION_PRAGUE)) + ); + + assert_eq!(state.best_block_number, Some(1)); + assert!(state.blocks.is_empty()); + assert!(state.transactions.is_empty()); +} + +// +#[tokio::test(flavor = "multi_thread")] +async fn test_backward_compatibility_state_dump_deserialization_v1_2() { + let tmp = tempfile::tempdir().unwrap(); + let old_state_file = tmp.path().join("old_state.json"); + + // A simple state dump with a single block containing one transaction of a Counter contract + // deployment. + let old_state_json = json!({ + "block": { + "number": "0x1", + "coinbase": "0x0000000000000000000000000000000000000001", + "timestamp": "0x688c83b5", + "gas_limit": "0x1c9c380", + "basefee": "0x3b9aca00", + "difficulty": "0x0", + "prevrandao": "0xecc5f0af8ff6b65c14bfdac55ba9db870d89482eb2b87200c6d7e7cd3a3a5ad5", + "blob_excess_gas_and_price": { + "excess_blob_gas": 0, + "blob_gasprice": 1 + } + }, + "accounts": { + "0x0000000000000000000000000000000000000000": { + "nonce": 0, + "balance": "0x26481", + "code": "0x", + "storage": {} + }, + "0x14dc79964da2c08b23698b3d3cc7ca32193d9955": { + "nonce": 0, + "balance": "0x21e19e0c9bab2400000", + "code": "0x", + "storage": {} + }, + "0x15d34aaf54267db7d7c367839aaf71a00a2c6a65": { + "nonce": 0, + "balance": "0x21e19e0c9bab2400000", + "code": "0x", + "storage": {} + }, + "0x23618e81e3f5cdf7f54c3d65f7fbc0abf5b21e8f": { + "nonce": 0, + "balance": "0x21e19e0c9bab2400000", + "code": "0x", + "storage": {} + }, + "0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc": { + "nonce": 0, + "balance": "0x21e19e0c9bab2400000", + "code": "0x", + "storage": {} + }, + "0x4e59b44847b379578588920ca78fbf26c0b4956c": { + "nonce": 0, + "balance": "0x0", + "code": "0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe03601600081602082378035828234f58015156039578182fd5b8082525050506014600cf3", + "storage": {} + }, + "0x5fbdb2315678afecb367f032d93f642f64180aa3": { + "nonce": 1, + "balance": "0x0", + "code": "0x608060405234801561000f575f5ffd5b506004361061003f575f3560e01c80633fb5c1cb146100435780638381f58a1461005f578063d09de08a1461007d575b5f5ffd5b61005d600480360381019061005891906100e4565b610087565b005b610067610090565b604051610074919061011e565b60405180910390f35b610085610095565b005b805f8190555050565b5f5481565b5f5f8154809291906100a690610164565b9190505550565b5f5ffd5b5f819050919050565b6100c3816100b1565b81146100cd575f5ffd5b50565b5f813590506100de816100ba565b92915050565b5f602082840312156100f9576100f86100ad565b5b5f610106848285016100d0565b91505092915050565b610118816100b1565b82525050565b5f6020820190506101315f83018461010f565b92915050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b5f61016e826100b1565b91507fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff82036101a05761019f610137565b5b60018201905091905056fea264697066735822122040b6a3cd3ec8f890002f39a8719ebee029ba9bac3d7fa9d581d4712cfe9ffec264736f6c634300081e0033", + "storage": {} + }, + "0x70997970c51812dc3a010c7d01b50e0d17dc79c8": { + "nonce": 0, + "balance": "0x21e19e0c9bab2400000", + "code": "0x", + "storage": {} + }, + "0x90f79bf6eb2c4f870365e785982e1f101e93b906": { + "nonce": 0, + "balance": "0x21e19e0c9bab2400000", + "code": "0x", + "storage": {} + }, + "0x976ea74026e726554db657fa54763abd0c3a0aa9": { + "nonce": 0, + "balance": "0x21e19e0c9bab2400000", + "code": "0x", + "storage": {} + }, + "0x9965507d1a55bcc2695c58ba16fb37d819b0a4dc": { + "nonce": 0, + "balance": "0x21e19e0c9bab2400000", + "code": "0x", + "storage": {} + }, + "0xa0ee7a142d267c1f36714e4a8f75612f20a79720": { + "nonce": 0, + "balance": "0x21e19e0c9bab2400000", + "code": "0x", + "storage": {} + }, + "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266": { + "nonce": 1, + "balance": "0x21e19e03b1e9e55d17f", + "code": "0x", + "storage": {} + } + }, + "best_block_number": "0x1", + "blocks": [ + { + "header": { + "parentHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "sha3Uncles": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", + "miner": "0x0000000000000000000000000000000000000000", + "stateRoot": "0x0000000000000000000000000000000000000000000000000000000000000000", + "transactionsRoot": "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", + "receiptsRoot": "0x0000000000000000000000000000000000000000000000000000000000000000", + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "difficulty": "0x0", + "number": "0x0", + "gasLimit": "0x1c9c380", + "gasUsed": "0x0", + "timestamp": "0x688c83b0", + "extraData": "0x", + "mixHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "nonce": "0x0000000000000000", + "baseFeePerGas": "0x3b9aca00", + "withdrawalsRoot": "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", + "blobGasUsed": "0x0", + "excessBlobGas": "0x0", + "parentBeaconBlockRoot": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + "transactions": [], + "ommers": [] + }, + { + "header": { + "parentHash": "0x25097583380d90c4ac42b454ed7d2f59450ed3a16fdcf7f7bd93295aa126a901", + "sha3Uncles": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", + "miner": "0x0000000000000000000000000000000000000000", + "stateRoot": "0x6e005b459ac9acefa5f47fd2d7ff8ca81a91794fdc5f7fbc3e2faeeaefe5d516", + "transactionsRoot": "0x59f0457ec18e2181c186f49d9ac911b33b5f4f55db5c494022147346bcfc9837", + "receiptsRoot": "0x88ac48b910f796aab7407814203b3a15a04a812f387e92efeccc92a2ecf809da", + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "difficulty": "0x0", + "number": "0x1", + "gasLimit": "0x1c9c380", + "gasUsed": "0x26481", + "timestamp": "0x688c83b5", + "extraData": "0x", + "mixHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "nonce": "0x0000000000000000", + "baseFeePerGas": "0x3b9aca00", + "withdrawalsRoot": "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", + "blobGasUsed": "0x0", + "excessBlobGas": "0x0", + "parentBeaconBlockRoot": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + "transactions": [ + { + "transaction": { + "type": "0x2", + "chainId": "0x7a69", + "nonce": "0x0", + "gas": "0x31c41", + "maxFeePerGas": "0x77359401", + "maxPriorityFeePerGas": "0x1", + "to": null, + "value": "0x0", + "accessList": [], + "input": "0x6080604052348015600e575f5ffd5b506101e18061001c5f395ff3fe608060405234801561000f575f5ffd5b506004361061003f575f3560e01c80633fb5c1cb146100435780638381f58a1461005f578063d09de08a1461007d575b5f5ffd5b61005d600480360381019061005891906100e4565b610087565b005b610067610090565b604051610074919061011e565b60405180910390f35b610085610095565b005b805f8190555050565b5f5481565b5f5f8154809291906100a690610164565b9190505550565b5f5ffd5b5f819050919050565b6100c3816100b1565b81146100cd575f5ffd5b50565b5f813590506100de816100ba565b92915050565b5f602082840312156100f9576100f86100ad565b5b5f610106848285016100d0565b91505092915050565b610118816100b1565b82525050565b5f6020820190506101315f83018461010f565b92915050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b5f61016e826100b1565b91507fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff82036101a05761019f610137565b5b60018201905091905056fea264697066735822122040b6a3cd3ec8f890002f39a8719ebee029ba9bac3d7fa9d581d4712cfe9ffec264736f6c634300081e0033", + "r": "0xa7398e28ca9a56b423cab87aeb3612378bac9c5684aaf778a78943f2637fd731", + "s": "0x583511da658f564253c8c0f9ee1820ef370f23556be504b304ac1292f869d9a0", + "yParity": "0x0", + "v": "0x0", + "hash": "0x9e4846328caa09cbe8086d11b7e115adf70390e79ff203d8e5f37785c2a890be" + }, + "impersonated_sender": null + } + ], + "ommers": [] + } + ], + "transactions": [ + { + "info": { + "transaction_hash": "0x9e4846328caa09cbe8086d11b7e115adf70390e79ff203d8e5f37785c2a890be", + "transaction_index": 0, + "from": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", + "to": null, + "contract_address": "0x5fbdb2315678afecb367f032d93f642f64180aa3", + "traces": [ + { + "parent": null, + "children": [], + "idx": 0, + "trace": { + "depth": 0, + "success": true, + "caller": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", + "address": "0x5fbdb2315678afecb367f032d93f642f64180aa3", + "maybe_precompile": false, + "selfdestruct_address": null, + "selfdestruct_refund_target": null, + "selfdestruct_transferred_value": null, + "kind": "CREATE", + "value": "0x0", + "data": "0x6080604052348015600e575f5ffd5b506101e18061001c5f395ff3fe608060405234801561000f575f5ffd5b506004361061003f575f3560e01c80633fb5c1cb146100435780638381f58a1461005f578063d09de08a1461007d575b5f5ffd5b61005d600480360381019061005891906100e4565b610087565b005b610067610090565b604051610074919061011e565b60405180910390f35b610085610095565b005b805f8190555050565b5f5481565b5f5f8154809291906100a690610164565b9190505550565b5f5ffd5b5f819050919050565b6100c3816100b1565b81146100cd575f5ffd5b50565b5f813590506100de816100ba565b92915050565b5f602082840312156100f9576100f86100ad565b5b5f610106848285016100d0565b91505092915050565b610118816100b1565b82525050565b5f6020820190506101315f83018461010f565b92915050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b5f61016e826100b1565b91507fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff82036101a05761019f610137565b5b60018201905091905056fea264697066735822122040b6a3cd3ec8f890002f39a8719ebee029ba9bac3d7fa9d581d4712cfe9ffec264736f6c634300081e0033", + "output": "0x608060405234801561000f575f5ffd5b506004361061003f575f3560e01c80633fb5c1cb146100435780638381f58a1461005f578063d09de08a1461007d575b5f5ffd5b61005d600480360381019061005891906100e4565b610087565b005b610067610090565b604051610074919061011e565b60405180910390f35b610085610095565b005b805f8190555050565b5f5481565b5f5f8154809291906100a690610164565b9190505550565b5f5ffd5b5f819050919050565b6100c3816100b1565b81146100cd575f5ffd5b50565b5f813590506100de816100ba565b92915050565b5f602082840312156100f9576100f86100ad565b5b5f610106848285016100d0565b91505092915050565b610118816100b1565b82525050565b5f6020820190506101315f83018461010f565b92915050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b5f61016e826100b1565b91507fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff82036101a05761019f610137565b5b60018201905091905056fea264697066735822122040b6a3cd3ec8f890002f39a8719ebee029ba9bac3d7fa9d581d4712cfe9ffec264736f6c634300081e0033", + "gas_used": 96345, + "gas_limit": 143385, + "gas_refund_counter": 0, + "status": "Return", + "steps": [], + "decoded": null + }, + "logs": [], + "ordering": [] + } + ], + "exit": "Return", + "out": "0x608060405234801561000f575f5ffd5b506004361061003f575f3560e01c80633fb5c1cb146100435780638381f58a1461005f578063d09de08a1461007d575b5f5ffd5b61005d600480360381019061005891906100e4565b610087565b005b610067610090565b604051610074919061011e565b60405180910390f35b610085610095565b005b805f8190555050565b5f5481565b5f5f8154809291906100a690610164565b9190505550565b5f5ffd5b5f819050919050565b6100c3816100b1565b81146100cd575f5ffd5b50565b5f813590506100de816100ba565b92915050565b5f602082840312156100f9576100f86100ad565b5b5f610106848285016100d0565b91505092915050565b610118816100b1565b82525050565b5f6020820190506101315f83018461010f565b92915050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b5f61016e826100b1565b91507fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff82036101a05761019f610137565b5b60018201905091905056fea264697066735822122040b6a3cd3ec8f890002f39a8719ebee029ba9bac3d7fa9d581d4712cfe9ffec264736f6c634300081e0033", + "nonce": 0, + "gas_used": 156801 + }, + "receipt": { + "type": "0x2", + "status": "0x1", + "cumulativeGasUsed": "0x26481", + "logs": [], + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + }, + "block_hash": "0x313ea0d32d662434a55a20d7c58544e6baaea421b6eccf4b68392dec2a76d771", + "block_number": 1 + } + ], + "historical_states": null + }); + + // Write the old state to file. + foundry_common::fs::write_json_file(&old_state_file, &old_state_json).unwrap(); + + // Test deserializing the old state dump directly. + let deserialized_state: SerializableState = serde_json::from_value(old_state_json).unwrap(); + + // Verify the old state was loaded correctly with `coinbase` to `beneficiary` conversion. + let block_env = deserialized_state.block.unwrap(); + assert_eq!(block_env.number, U256::ONE); + assert_eq!(block_env.beneficiary, Address::with_last_byte(1)); + assert_eq!(block_env.gas_limit, 0x1c9c380); + assert_eq!(block_env.basefee, 0x3b9aca00); + + // Verify best_block_number hex string parsing. + assert_eq!(deserialized_state.best_block_number, Some(1)); + + // Verify account data was preserved. + assert_eq!(deserialized_state.accounts.len(), 13); + + // Test specific accounts from the old dump. + let deployer_addr = address!("0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266"); + let deployer_account = deserialized_state.accounts.get(&deployer_addr).unwrap(); + assert_eq!(deployer_account.nonce, 1); + assert_eq!(deployer_account.balance, U256::from_str("0x21e19e03b1e9e55d17f").unwrap()); + + // Test contract account. + let contract_addr = address!("0x5fbdb2315678afecb367f032d93f642f64180aa3"); + let contract_account = deserialized_state.accounts.get(&contract_addr).unwrap(); + assert_eq!(contract_account.nonce, 1); + assert_eq!(contract_account.balance, U256::ZERO); + assert!(!contract_account.code.is_empty()); + + // Verify blocks and transactions are preserved. + assert_eq!(deserialized_state.blocks.len(), 2); + assert_eq!(deserialized_state.transactions.len(), 1); + + // Test that Anvil can load this old state dump. + let (api, _handle) = spawn(NodeConfig::test().with_init_state_path(&old_state_file)).await; + + // Verify the state was loaded correctly. + let block_number = api.block_number().unwrap(); + assert_eq!(block_number, U256::ONE); + + // Verify account balances are preserved. + let provider = _handle.http_provider(); + let deployer_balance = provider.get_balance(deployer_addr).await.unwrap(); + assert_eq!(deployer_balance, U256::from_str("0x21e19e03b1e9e55d17f").unwrap()); + let contract_balance = provider.get_balance(contract_addr).await.unwrap(); + assert_eq!(contract_balance, U256::ZERO); + + // Verify contract code is preserved. + let contract_code = provider.get_code_at(contract_addr).await.unwrap(); + assert!(!contract_code.is_empty()); +} + +// Ensures the BLOCKHASH opcode sees the real hashes of loaded blocks after +// `anvil_loadState`. The EVM-level block hash cache used to stay empty after loading state, +// so BLOCKHASH silently returned EmptyDB's placeholder hash for imported blocks. +#[tokio::test(flavor = "multi_thread")] +async fn blockhash_opcode_consistent_after_load_state() { + let tmp = tempfile::tempdir().unwrap(); + let state_file = tmp.path().join("state.json"); + + let (api, _handle) = spawn(NodeConfig::test()).await; + api.mine_one().await.unwrap(); + api.mine_one().await.unwrap(); + + let block1_hash = api + .block_by_number(alloy_eips::BlockNumberOrTag::Number(1)) + .await + .unwrap() + .unwrap() + .header + .hash; + + let state = api.serialized_state(false).await.unwrap(); + foundry_common::fs::write_json_file(&state_file, &state).unwrap(); + + let (api, _handle) = spawn(NodeConfig::test().with_init_state_path(state_file)).await; + + // Runtime code returning BLOCKHASH(1): + // PUSH1 1, BLOCKHASH, PUSH1 0, MSTORE, PUSH1 32, PUSH1 0, RETURN + let code = bytes!("0x60014060005260206000f3"); + let target = address!("00000000000000000000000000000000000b10c4"); + + let mut overrides = StateOverride::default(); + overrides.insert(target, AccountOverride { code: Some(code), ..Default::default() }); + + let tx = TransactionRequest::default().with_to(target); + let res = api + .call(WithOtherFields::new(tx), None, EvmOverrides::new(Some(overrides), None)) + .await + .unwrap(); + + assert_eq!(B256::from_slice(res.as_ref()), block1_hash); +} + +#[tokio::test(flavor = "multi_thread")] +async fn blockhash_opcode_consistent_after_loading_older_state() { + let (source_api, _source_handle) = + spawn(NodeConfig::test().with_genesis_timestamp(Some(1_000_000_u64))).await; + source_api.mine_one().await.unwrap(); + source_api.mine_one().await.unwrap(); + + let block1_hash = source_api + .block_by_number(alloy_eips::BlockNumberOrTag::Number(1)) + .await + .unwrap() + .unwrap() + .header + .hash; + let state = source_api.serialized_state(false).await.unwrap(); + + let (api, _handle) = spawn(NodeConfig::test()).await; + api.anvil_mine(Some(U256::from(258)), None).await.unwrap(); + api.anvil_load_state(Bytes::from(serde_json::to_vec(&state).unwrap())).await.unwrap(); + + assert_eq!(api.block_number().unwrap(), U256::from(2)); + + // Runtime code returning BLOCKHASH(1): + // PUSH1 1, BLOCKHASH, PUSH1 0, MSTORE, PUSH1 32, PUSH1 0, RETURN + let code = bytes!("0x60014060005260206000f3"); + let target = address!("00000000000000000000000000000000000b10c4"); + + let mut overrides = StateOverride::default(); + overrides.insert(target, AccountOverride { code: Some(code), ..Default::default() }); + + let tx = TransactionRequest::default().with_to(target); + let res = api + .call(WithOtherFields::new(tx), None, EvmOverrides::new(Some(overrides), None)) + .await + .unwrap(); + + assert_eq!(B256::from_slice(res.as_ref()), block1_hash); +} + +#[tokio::test(flavor = "multi_thread")] +async fn prune_history_respects_limit_with_configured_interval() { + for seconds in [1, 2, 3] { + assert_pruned_interval_history(seconds, true).await; + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn prune_history_respects_limit_with_rpc_interval() { + for seconds in [1, 2, 3] { + assert_pruned_interval_history(seconds, false).await; + } +} + +async fn assert_pruned_interval_history(seconds: u64, configured: bool) { + let config = NodeConfig::test() + .set_pruned_history(Some(Some(8))) + .with_blocktime(configured.then(|| Duration::from_secs(seconds))); + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + if !configured { + provider.raw_request::<_, ()>("evm_setIntervalMining".into(), [seconds]).await.unwrap(); + } + // Disable the timer so the retention boundary is deterministic. + api.anvil_set_interval_mining(0).unwrap(); + api.anvil_mine(Some(U256::from(40)), None).await.unwrap(); + let best = provider.get_block_number().await.unwrap(); + let account = handle.dev_accounts().next().unwrap(); + let latest = provider.get_balance(account).await.unwrap(); + assert_eq!(provider.get_balance(account).block_id((best - 8).into()).await.unwrap(), latest); + let error = provider.get_balance(account).block_id((best - 9).into()).await.unwrap_err(); + let error = error.as_error_resp().unwrap(); + assert_eq!(error.code, -32602); + assert_eq!( + error.message, + format!("BlockOutOfRangeError: block height is {best} but requested was {}", best - 9) + ); +} diff --git a/crates/anvil/tests/it/storage_values.rs b/crates/anvil/tests/it/storage_values.rs new file mode 100644 index 0000000000000..45a1c2e0c5e4b --- /dev/null +++ b/crates/anvil/tests/it/storage_values.rs @@ -0,0 +1,172 @@ +//! Tests for the `eth_getStorageValues` batch storage endpoint. + +use alloy_primitives::{Address, B256, U256, map::HashMap}; +use alloy_provider::Provider; +use alloy_rpc_types::BlockId; +use anvil::{NodeConfig, spawn}; +use foundry_test_utils::rpc::spawn_rpc_proxy_canned_method; +use std::sync::atomic::Ordering; + +fn slot(n: u64) -> B256 { + B256::from(U256::from(n)) +} + +#[tokio::test(flavor = "multi_thread")] +async fn storage_values_batches_multiple_accounts() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + let first = Address::repeat_byte(0x01); + let second = Address::repeat_byte(0x02); + api.anvil_set_storage_at(first, U256::ZERO, B256::with_last_byte(0xaa)).await.unwrap(); + api.anvil_set_storage_at(first, U256::ONE, B256::with_last_byte(0xbb)).await.unwrap(); + api.anvil_set_storage_at(second, U256::ZERO, B256::with_last_byte(0xcc)).await.unwrap(); + + let requests = HashMap::>::from_iter([ + (first, vec![slot(0), slot(1), slot(2)]), + (second, vec![slot(0)]), + ]); + let values: HashMap> = provider + .client() + .request("eth_getStorageValues", (requests, BlockId::latest())) + .await + .unwrap(); + + assert_eq!( + values[&first], + vec![B256::with_last_byte(0xaa), B256::with_last_byte(0xbb), B256::ZERO], + "unset slots read as zero" + ); + assert_eq!(values[&second], vec![B256::with_last_byte(0xcc)]); +} + +#[tokio::test(flavor = "multi_thread")] +async fn storage_values_rejects_empty_request() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + let err = provider + .client() + .request::<_, HashMap>>( + "eth_getStorageValues", + (HashMap::>::default(), BlockId::latest()), + ) + .await + .unwrap_err(); + assert!(err.to_string().contains("empty request"), "unexpected error: {err}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn storage_values_rejects_more_than_1024_slots() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + let address = Address::repeat_byte(0x03); + + let at_limit = HashMap::>::from_iter([( + address, + (0..1024).map(slot).collect::>(), + )]); + let values: HashMap> = provider + .client() + .request("eth_getStorageValues", (at_limit, BlockId::latest())) + .await + .unwrap(); + assert_eq!(values[&address].len(), 1024); + + // The limit is on the total slot count, not per account. + let over_limit = HashMap::>::from_iter([ + (address, (0..1024).map(slot).collect::>()), + (Address::repeat_byte(0x04), vec![slot(0)]), + ]); + let err = provider + .client() + .request::<_, HashMap>>( + "eth_getStorageValues", + (over_limit, BlockId::latest()), + ) + .await + .unwrap_err(); + assert!( + err.to_string().contains("total slot count 1025 exceeds limit 1024"), + "unexpected error: {err}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn storage_values_agrees_with_get_storage_at() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + let address = Address::repeat_byte(0x05); + + api.anvil_set_storage_at(address, U256::ZERO, B256::with_last_byte(0x11)).await.unwrap(); + api.mine_one().await.unwrap(); + let first = provider.get_block_number().await.unwrap(); + + api.anvil_set_storage_at(address, U256::ONE, B256::with_last_byte(0x22)).await.unwrap(); + api.mine_one().await.unwrap(); + let second = provider.get_block_number().await.unwrap(); + + // The batch endpoint must agree with the single-slot endpoint at every block it is asked + // about, which is the property that matters for callers migrating between the two. + let requests = HashMap::>::from_iter([(address, vec![slot(0), slot(1)])]); + for block in [first, second] { + let batched: HashMap> = provider + .client() + .request("eth_getStorageValues", (requests.clone(), BlockId::number(block))) + .await + .unwrap(); + + let mut expected = Vec::new(); + for index in [U256::ZERO, U256::ONE] { + let value = provider.get_storage_at(address, index).number(block).await.unwrap(); + expected.push(B256::from(value)); + } + assert_eq!(batched[&address], expected, "divergence at block {block}"); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_storage_values_skips_upstream_for_local_state() { + let (_origin_api, origin_handle) = spawn(NodeConfig::test()).await; + let (proxy, calls) = spawn_rpc_proxy_canned_method( + origin_handle.http_endpoint(), + "eth_getStorageAt", + serde_json::json!(B256::with_last_byte(0xff)), + ) + .await; + + let (api, handle) = spawn(NodeConfig::test().with_eth_rpc_url(Some(proxy))).await; + let provider = handle.http_provider(); + let address = Address::repeat_byte(0x06); + + api.anvil_set_storage_at(address, U256::ZERO, B256::with_last_byte(0x33)).await.unwrap(); + api.mine_one().await.unwrap(); + + // State at or after the fork point is served locally, without querying the forked node. + // Only the delta matters: spawning a fork legitimately reads storage for other reasons. + let before = calls.load(Ordering::Relaxed); + let requests = HashMap::>::from_iter([(address, vec![slot(0)])]); + let values: HashMap> = provider + .client() + .request("eth_getStorageValues", (requests, BlockId::latest())) + .await + .unwrap(); + assert_eq!(values[&address], vec![B256::with_last_byte(0x33)]); + assert_eq!( + calls.load(Ordering::Relaxed), + before, + "post-fork state should not be fetched upstream" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn storage_values_defaults_to_latest_block() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + let requests = + HashMap::>::from_iter([(Address::repeat_byte(0x07), vec![slot(0)])]); + let values: HashMap> = + provider.client().request("eth_getStorageValues", (requests,)).await.unwrap(); + assert_eq!(values[&Address::repeat_byte(0x07)], vec![B256::ZERO]); +} diff --git a/crates/anvil/tests/it/tempo.rs b/crates/anvil/tests/it/tempo.rs new file mode 100644 index 0000000000000..c865163984b5e --- /dev/null +++ b/crates/anvil/tests/it/tempo.rs @@ -0,0 +1,6898 @@ +//! Tests for Tempo-specific features in Anvil. +//! +//! This module tests Tempo's payment-native protocol features including: +//! - TIP20 fee tokens (PathUSD, AlphaUSD, BetaUSD, ThetaUSD) +//! - Tempo precompiles initialization (sentinel bytecode) +//! - Native value transfer rejection +//! - Basic transaction behavior in Tempo mode + +use alloy_consensus::{ + BlockHeader, Eip658Value, Receipt, Sealable, TxEip1559, Typed2718, + proofs::{calculate_receipt_root, calculate_transaction_root}, + transaction::{SignableTransaction, SignerRecoverable}, +}; +use alloy_eips::eip2718::{Decodable2718, Encodable2718}; +use alloy_genesis::Genesis; +use alloy_network::{ReceiptResponse, TransactionBuilder, TransactionResponse}; +use alloy_primitives::{ + Address, B256, Bytes, Signature, TxKind, U256, address, aliases::U96, bytes, keccak256, +}; +use alloy_provider::{ + Provider, + ext::{DebugApi, TraceApi, TxPoolApi}, +}; +use alloy_rlp::Decodable; +use alloy_rpc_types::{ + Authorization, BlockId, BlockNumberOrTag, TransactionRequest, + anvil::Forking, + trace::{ + geth::{GethDebugTracingOptions, GethTrace}, + parity::Action, + }, +}; +use alloy_serde::WithOtherFields; +use alloy_signer::Signer; +use alloy_signer_local::PrivateKeySigner; +use alloy_sol_types::{SolCall, SolError, SolEvent, SolValue, sol}; +use anvil::{NodeConfig, eth::pool::transactions::TransactionOrder, spawn}; +use anvil_core::eth::block::Block; +use foundry_common::FoundryTransactionBuilder; +use foundry_evm::core::tempo::{ + ALPHA_USD_ADDRESS, BETA_USD_ADDRESS, ITIP20ChannelReserve, PATH_USD_ADDRESS, + TEMPO_PRECOMPILE_ADDRESSES, TEMPO_TIP20_TOKENS, THETA_USD_ADDRESS, + active_tempo_precompile_addresses, +}; +use foundry_primitives::{FoundryReceiptEnvelope, FoundryTxEnvelope, TempoTransactionRequest}; +use foundry_test_utils::rpc::spawn_rpc_proxy_canned_method; +use futures::StreamExt; +use std::{num::NonZeroU64, sync::atomic::Ordering}; +use tempo_alloy::{TempoNetwork, primitives::TempoTxEnvelope, rpc::TempoHeaderResponse}; +use tempo_hardfork::{ + TempoHardfork, + constants::gas::{TEMPO_T1_BASE_FEE, TEMPO_T7_BASE_FEE_CAP, TEMPO_T7_BASE_FEE_FLOOR}, +}; +use tempo_precompiles::{ + ACCOUNT_KEYCHAIN_ADDRESS, ADDRESS_REGISTRY_ADDRESS, DEFAULT_FEE_TOKEN, + NONCE_PRECOMPILE_ADDRESS, RECEIVE_POLICY_GUARD_ADDRESS, STABLECOIN_DEX_ADDRESS, + TIP_FEE_MANAGER_ADDRESS, TIP20_CHANNEL_RESERVE_ADDRESS, TIP20_FACTORY_ADDRESS, + TIP403_REGISTRY_ADDRESS, + account_keychain::{ + KeyRestrictions, SignatureType as KeychainSignatureType, TokenLimit as KeychainTokenLimit, + authorizeKeyCall, + }, + current_committee::{CURRENT_COMMITTEE_ADDRESS, ICurrentCommittee}, + nonce::NonceManager, + receive_policy_guard::{IReceivePolicyGuard, InboundKind}, + tip_fee_manager::IFeeManager, + tip403_registry::{ALLOW_ALL_POLICY_ID, ITIP403Registry, REJECT_ALL_POLICY_ID}, +}; +use tempo_primitives::{ + AASigned, TempoHeader, TempoSignature, TempoTransaction, + transaction::{ + Call, FEE_PAYER_SIGNATURE_MARKER, KeyAuthorization, KeychainSignature, PrimitiveSignature, + SignatureType, TEMPO_GAS_PRICE_SCALING_FACTOR, TokenLimit, calc_gas_balance_spending, + }, +}; + +#[cfg(feature = "cli")] +use crate::utils::http_provider; +#[cfg(feature = "cli")] +use std::{ + net::TcpListener, + path::PathBuf, + process::{Child, Command, Stdio}, + time::Duration, +}; + +const PATH_USD: Address = PATH_USD_ADDRESS; +const ALPHA_USD: Address = ALPHA_USD_ADDRESS; +const BETA_USD: Address = BETA_USD_ADDRESS; +const THETA_USD: Address = THETA_USD_ADDRESS; +const TEMPO_ADMIN: Address = address!("0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f"); +const DEX_MIN_ORDER_AMOUNT: u128 = 100_000_000; + +/// Gas limit for TIP20 transfer calls, including T1+ transaction accounting. +const TIP20_TRANSFER_GAS: u64 = 1_000_000; +const T5_PRECOMPILE_GAS: u64 = 10_000_000; + +fn tempo_transfer(recipient: Address, amount: U256) -> Call { + Call { + to: TxKind::Call(PATH_USD), + value: U256::ZERO, + input: IERC20::transferCall { to: recipient, amount }.abi_encode().into(), + } +} + +fn tempo_call_request(from: Address, calls: impl IntoIterator) -> serde_json::Value { + serde_json::json!({ + "from": from, + "type": "0x76", + "gas": "0x1e8480", + "calls": calls.into_iter().collect::>(), + }) +} + +async fn sponsored_tempo_request( + from: Address, + chain_id: u64, + gas_price: u128, + gas_limit: u64, + calls: Vec, +) -> serde_json::Value { + let transaction = TempoTransaction { + chain_id, + fee_token: Some(ALPHA_USD), + max_priority_fee_per_gas: gas_price / 10, + max_fee_per_gas: gas_price * 2, + gas_limit, + calls, + access_list: Default::default(), + nonce_key: U256::ZERO, + nonce: 0, + fee_payer_signature: Some(Signature::new(U256::ZERO, U256::ZERO, false)), + valid_before: None, + valid_after: None, + key_authorization: None, + tempo_authorization_list: vec![], + }; + let fee_payer_hash = transaction.fee_payer_signature_hash(from); + let fee_payer_signature = dev_key(1).sign_hash(&fee_payer_hash).await.unwrap(); + + serde_json::json!({ + "from": from, + "type": "0x76", + "chainId": transaction.chain_id, + "nonceKey": transaction.nonce_key, + "nonce": transaction.nonce, + "gas": transaction.gas_limit, + "maxFeePerGas": transaction.max_fee_per_gas, + "maxPriorityFeePerGas": transaction.max_priority_fee_per_gas, + "feeToken": transaction.fee_token, + "feePayerSignature": fee_payer_signature, + "calls": transaction.calls, + }) +} + +fn assert_tempo_header_fields(header: &TempoHeaderResponse) { + let inner: &TempoHeader = header.as_ref(); + assert_eq!(header.timestamp_millis, inner.timestamp_millis()); + assert_eq!(inner.general_gas_limit, inner.inner.gas_limit); + assert_eq!(inner.shared_gas_limit, 0); + assert_eq!(inner.timestamp_millis_part, 0); +} + +#[cfg(feature = "cli")] +struct ChildGuard(Child); + +#[cfg(feature = "cli")] +impl Drop for ChildGuard { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} + +#[cfg(feature = "cli")] +fn anvil_binary() -> PathBuf { + if let Some(path) = std::env::var_os("CARGO_BIN_EXE_anvil") { + return PathBuf::from(path); + } + + foundry_test_utils::cargo_profile_dir().join("anvil") +} + +#[tokio::test(flavor = "multi_thread")] +async fn can_get_tempo_header_by_number() { + let (api, handle) = spawn(NodeConfig::test_tempo()).await; + api.mine_one().await.unwrap(); + + let provider = handle.http_provider(); + for number in ["0x1", "pending"] { + let header: Option = + provider.client().request("eth_getHeaderByNumber", (number,)).await.unwrap(); + assert_tempo_header_fields(&header.unwrap()); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn tempo_new_heads_subscription_returns_full_header() { + let (api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = alloy_provider::ProviderBuilder::new_with_network::() + .connect(&handle.ws_endpoint()) + .await + .unwrap(); + let subscription = provider.subscribe_blocks().await.unwrap(); + let mut blocks = subscription.into_stream(); + + api.mine_one().await.unwrap(); + let header = blocks.next().await.unwrap(); + assert_tempo_header_fields(&header); + + let stored_header = api.backend.get_block(header.number()).unwrap().header; + assert_eq!(stored_header.as_tempo().unwrap(), header.as_ref()); + assert_eq!(header.hash, header.as_ref().hash_slow()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn tempo_rpc_block_hashes_match_canonical_headers() { + let (api, handle) = spawn(NodeConfig::test_tempo()).await; + api.mine_one().await.unwrap(); + api.mine_one().await.unwrap(); + + let provider = handle.http_provider(); + let mut parent_hash = None; + for number in 0..=2 { + let header: TempoHeaderResponse = provider + .client() + .request("eth_getHeaderByNumber", (format!("0x{number:x}"),)) + .await + .unwrap(); + let stored_header = api.backend.get_block(number).unwrap().header; + assert_eq!(stored_header.as_tempo().unwrap(), header.as_ref()); + let canonical_hash = header.as_ref().hash_slow(); + assert_eq!(stored_header.hash_slow(), canonical_hash); + + assert_eq!(header.hash, canonical_hash, "block {number} RPC hash"); + if let Some(parent_hash) = parent_hash { + assert_eq!(header.parent_hash(), parent_hash, "block {number} parent hash"); + } + + let header_by_hash: Option = + provider.client().request("eth_getHeaderByHash", (canonical_hash,)).await.unwrap(); + assert_eq!(header_by_hash.unwrap().as_ref(), header.as_ref()); + parent_hash = Some(canonical_hash); + } + + let pending: TempoHeaderResponse = + provider.client().request("eth_getHeaderByNumber", ("pending",)).await.unwrap(); + assert_eq!(pending.hash, pending.as_ref().hash_slow(), "pending RPC hash"); + assert_eq!(pending.parent_hash(), parent_hash.unwrap(), "pending parent hash"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn tempo_rpc_projects_legacy_ethereum_headers() { + let (source_api, _source_handle) = spawn(NodeConfig::test()).await; + source_api.mine_one().await.unwrap(); + let state = source_api.serialized_state(false).await.unwrap(); + + let (api, handle) = spawn(NodeConfig::test_tempo()).await; + api.anvil_load_state(Bytes::from(serde_json::to_vec(&state).unwrap())).await.unwrap(); + + let stored_header = api.backend.get_block(1).unwrap().header; + assert!(stored_header.as_tempo().is_none()); + let legacy_hash = stored_header.hash_slow(); + + let provider = handle.http_provider(); + let header: TempoHeaderResponse = + provider.client().request("eth_getHeaderByNumber", ("0x1",)).await.unwrap(); + assert_tempo_header_fields(&header); + assert_eq!(header.hash, legacy_hash); + assert_ne!(header.as_ref().hash_slow(), legacy_hash); + + api.mine_one().await.unwrap(); + let child: TempoHeaderResponse = + provider.client().request("eth_getHeaderByNumber", ("0x2",)).await.unwrap(); + assert_eq!(child.parent_hash(), legacy_hash); + assert_eq!(child.hash, child.as_ref().hash_slow()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn tempo_raw_header_and_block_use_tempo_rlp() { + let (api, handle) = spawn(NodeConfig::test_tempo()).await; + api.mine_one().await.unwrap(); + + let provider = handle.http_provider(); + let header: TempoHeaderResponse = + provider.client().request("eth_getHeaderByNumber", ("0x1",)).await.unwrap(); + + let raw_header: Bytes = provider + .client() + .request("debug_getRawHeader", (BlockId::hash(header.hash),)) + .await + .unwrap(); + let decoded_header = TempoHeader::decode(&mut raw_header.as_ref()).unwrap(); + assert_eq!(&decoded_header, header.as_ref()); + assert_eq!(decoded_header.hash_slow(), header.hash); + + let raw_block: Bytes = provider + .client() + .request("debug_getRawBlock", (BlockId::hash(header.hash),)) + .await + .unwrap(); + let decoded_block: Block = Block::decode(&mut raw_block.as_ref()).unwrap(); + assert_eq!(decoded_block.header.as_tempo().unwrap(), header.as_ref()); + assert_eq!(decoded_block.header.hash_slow(), header.hash); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_fork_detects_hardfork_from_fork_timestamp() { + use tempo_hardfork::TempoHardfork; + + let fork_timestamp = TempoHardfork::T3.mainnet_activation_timestamp().unwrap(); + let (_source_api, source_handle) = spawn( + NodeConfig::test() + .with_chain_id(Some(4217u64)) + .with_genesis_timestamp(Some(fork_timestamp)), + ) + .await; + + let (api, handle) = + spawn(NodeConfig::test_tempo().with_eth_rpc_url(Some(source_handle.http_endpoint()))).await; + + let node_info = api.anvil_node_info().await.unwrap(); + assert_eq!(node_info.hard_fork, "T3"); + + api.mine_one().await.unwrap(); + let latest_block = handle + .http_provider() + .get_block_by_number(BlockNumberOrTag::Latest) + .await + .unwrap() + .unwrap(); + assert_eq!(latest_block.header.beneficiary, TIP_FEE_MANAGER_ADDRESS); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_reset_to_fork_uses_fee_manager_beneficiary() { + let (_source_api, source_handle) = spawn(NodeConfig::test()).await; + + let (api, handle) = spawn(NodeConfig::test_tempo()).await; + api.anvil_reset(Some(Forking { + json_rpc_url: Some(source_handle.http_endpoint()), + block_number: None, + })) + .await + .unwrap(); + + api.mine_one().await.unwrap(); + let latest_block = handle + .http_provider() + .get_block_by_number(BlockNumberOrTag::Latest) + .await + .unwrap() + .unwrap(); + assert_eq!(latest_block.header.beneficiary, TIP_FEE_MANAGER_ADDRESS); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_fork_reset_to_memory_restores_genesis_beneficiary() { + let (_source_api, source_handle) = spawn(NodeConfig::test()).await; + let (api, handle) = + spawn(NodeConfig::test_tempo().with_eth_rpc_url(Some(source_handle.http_endpoint()))).await; + let provider = handle.http_provider(); + + api.mine_one().await.unwrap(); + let fork_block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + assert_eq!(fork_block.header.beneficiary, TIP_FEE_MANAGER_ADDRESS); + + api.anvil_reset(None).await.unwrap(); + + let genesis = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + assert_eq!(genesis.header.beneficiary, Address::ZERO); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_reset_to_fork_preserves_explicit_coinbase() { + let (_source_api, source_handle) = spawn(NodeConfig::test()).await; + let custom_coinbase = Address::repeat_byte(0x11); + + let (api, handle) = spawn(NodeConfig::test_tempo()).await; + api.anvil_set_coinbase(custom_coinbase).await.unwrap(); + api.anvil_reset(Some(Forking { + json_rpc_url: Some(source_handle.http_endpoint()), + block_number: None, + })) + .await + .unwrap(); + + api.mine_one().await.unwrap(); + let latest_block = handle + .http_provider() + .get_block_by_number(BlockNumberOrTag::Latest) + .await + .unwrap() + .unwrap(); + assert_eq!(latest_block.header.beneficiary, custom_coinbase); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_fork_with_default_genesis_uses_fee_manager_beneficiary() { + let (_source_api, source_handle) = spawn(NodeConfig::test()).await; + + let (api, handle) = spawn( + NodeConfig::test_tempo() + .with_eth_rpc_url(Some(source_handle.http_endpoint())) + .with_genesis(Some(Genesis::default())), + ) + .await; + + api.mine_one().await.unwrap(); + let latest_block = handle + .http_provider() + .get_block_by_number(BlockNumberOrTag::Latest) + .await + .unwrap() + .unwrap(); + assert_eq!(latest_block.header.beneficiary, TIP_FEE_MANAGER_ADDRESS); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_fork_with_loaded_zero_beneficiary_state_uses_fee_manager_beneficiary() { + let (source_api, source_handle) = spawn(NodeConfig::test()).await; + let mut state = source_api.serialized_state(false).await.unwrap(); + state.block.as_mut().unwrap().beneficiary = Address::ZERO; + + let (api, handle) = spawn( + NodeConfig::test_tempo() + .with_eth_rpc_url(Some(source_handle.http_endpoint())) + .with_init_state(Some(state)), + ) + .await; + + api.mine_one().await.unwrap(); + let latest_block = handle + .http_provider() + .get_block_by_number(BlockNumberOrTag::Latest) + .await + .unwrap() + .unwrap(); + assert_eq!(latest_block.header.beneficiary, TIP_FEE_MANAGER_ADDRESS); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_fork_runtime_load_state_uses_fee_manager_beneficiary() { + let (source_api, source_handle) = spawn(NodeConfig::test()).await; + let mut state = source_api.serialized_state(false).await.unwrap(); + state.block.as_mut().unwrap().beneficiary = Address::ZERO; + + let (api, handle) = + spawn(NodeConfig::test_tempo().with_eth_rpc_url(Some(source_handle.http_endpoint()))).await; + + api.anvil_load_state(Bytes::from(serde_json::to_vec(&state).unwrap())).await.unwrap(); + + api.mine_one().await.unwrap(); + let latest_block = handle + .http_provider() + .get_block_by_number(BlockNumberOrTag::Latest) + .await + .unwrap() + .unwrap(); + assert_eq!(latest_block.header.beneficiary, TIP_FEE_MANAGER_ADDRESS); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_fork_forwards_request_extensions() { + let (source_api, source_handle) = spawn(NodeConfig::test_tempo()).await; + source_api.mine_one().await.unwrap(); + let source_provider = source_handle.http_provider(); + let from = source_handle.dev_accounts().next().unwrap(); + let recipient = Address::random(); + let balance = IERC20::new(PATH_USD, &source_provider).balanceOf(from).call().await.unwrap(); + let calls = [tempo_transfer(recipient, balance), tempo_transfer(recipient, U256::ONE)]; + let request = tempo_call_request(from, calls.clone()); + + let (_fork_api, fork_handle) = spawn( + NodeConfig::test_tempo() + .with_eth_rpc_url(Some(source_handle.http_endpoint())) + .with_fork_block_number(Some(1u64)), + ) + .await; + let provider = fork_handle.http_provider(); + + for method in ["eth_call", "eth_estimateGas"] { + let error = provider + .raw_request::<_, serde_json::Value>( + method.into(), + serde_json::json!([request.clone(), "0x0"]), + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("revert"), "unexpected {method} error: {error}"); + } + + let access_list = provider + .raw_request::<_, serde_json::Value>( + "eth_createAccessList".into(), + serde_json::json!([request.clone(), "0x0"]), + ) + .await + .unwrap(); + assert!(access_list["error"].as_str().is_some_and(|error| error.contains("revert"))); + + let payload = serde_json::json!({ + "blockStateCalls": [{"calls": [request]}], + "returnFullTransactions": true, + }); + let simulated = provider + .raw_request::<_, serde_json::Value>( + "eth_simulateV1".into(), + serde_json::json!([payload, "0x0"]), + ) + .await + .unwrap(); + assert_eq!(simulated[0]["calls"][0]["status"], "0x0"); + assert_eq!(simulated[0]["transactions"][0]["calls"], serde_json::to_value(calls).unwrap()); +} + +/// Forks must use real remote balances while preserving local balance edits and snapshots. +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_fork_ignores_rpc_placeholder_balances() { + let (source, source_handle) = + spawn(NodeConfig::test_tempo().with_chain_id(Some(123456u64))).await; + let contract = Address::repeat_byte(0x42); + // Return SELFBALANCE, making the imported balance observable in EVM execution. + source.anvil_set_code(contract, alloy_primitives::bytes!("4760005260206000f3")).await.unwrap(); + source.anvil_set_balance(contract, U256::from(42)).await.unwrap(); + source.mine_one().await.unwrap(); + let (proxy, balance_requests) = spawn_rpc_proxy_canned_method( + source_handle.http_endpoint(), + "eth_getBalance", + serde_json::json!("0xffff"), + ) + .await; + let (api, handle) = spawn( + NodeConfig::test() + .with_eth_rpc_url(Some(proxy.clone())) + .with_fork_block_number(Some(1u64)) + .with_chain_id(Some(31337u64)) + .with_no_storage_caching(true), + ) + .await; + let provider = handle.http_provider(); + assert_eq!( + provider.get_account_info(contract).number(0).await.unwrap().balance, + U256::from(42), + ); + for reset in [false, true] { + if reset { + api.anvil_reset(Some(Forking { + json_rpc_url: Some(proxy.clone()), + block_number: Some(1), + })) + .await + .unwrap(); + } + let snapshot = api.evm_snapshot().await.unwrap(); + let local_code = alloy_primitives::bytes!("6001"); + api.anvil_set_balance(contract, U256::from(99)).await.unwrap(); + api.anvil_set_nonce(contract, U256::from(7)).await.unwrap(); + api.anvil_set_code(contract, local_code.clone()).await.unwrap(); + let account = provider.get_account_info(contract).await.unwrap(); + assert_eq!(account.balance, U256::from(99)); + assert_eq!(account.nonce, 7); + assert_eq!(account.code, local_code); + assert!(api.evm_revert(snapshot).await.unwrap()); + + api.mine_one().await.unwrap(); + let request = TransactionRequest::default().to(contract); + assert_eq!( + U256::from_be_slice(&provider.call(request.into()).await.unwrap()), + U256::from(42), + ); + } + assert_eq!(balance_requests.load(Ordering::Relaxed), 0); + + // A downstream fork must receive the historical balance, not this node's current state. + api.mine_one().await.unwrap(); + let historical_block = provider.get_block_number().await.unwrap() - 1; + api.anvil_set_balance(contract, U256::from(99)).await.unwrap(); + api.mine_one().await.unwrap(); + let (_downstream_api, downstream_handle) = spawn( + NodeConfig::test() + .with_eth_rpc_url(Some(handle.http_endpoint())) + .with_fork_block_number(Some(historical_block)) + .with_no_storage_caching(true), + ) + .await; + let balance = downstream_handle + .http_provider() + .call(TransactionRequest::default().to(contract).into()) + .await + .unwrap(); + assert_eq!(U256::from_be_slice(&balance), U256::from(42)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_fork_executes_request_extensions_locally() { + let (source_api, source_handle) = spawn(NodeConfig::test_tempo()).await; + source_api.mine_one().await.unwrap(); + let source_provider = source_handle.http_provider(); + let from = source_handle.dev_accounts().next().unwrap(); + let recipient = Address::random(); + let balance = IERC20::new(PATH_USD, &source_provider).balanceOf(from).call().await.unwrap(); + let calls = [tempo_transfer(recipient, balance), tempo_transfer(recipient, U256::ONE)]; + let request = tempo_call_request(from, calls.clone()); + + let (fork_api, fork_handle) = spawn( + NodeConfig::test_tempo() + .with_eth_rpc_url(Some(source_handle.http_endpoint())) + .with_fork_block_number(Some(1u64)), + ) + .await; + let provider = fork_handle.http_provider(); + + fork_api.anvil_deal_tip20(from, PATH_USD, balance + U256::ONE).await.unwrap(); + fork_api.mine_one().await.unwrap(); + assert_eq!( + IERC20::new(PATH_USD, &source_provider).balanceOf(from).call().await.unwrap(), + balance + ); + assert_eq!( + IERC20::new(PATH_USD, &provider).balanceOf(from).call().await.unwrap(), + balance + U256::ONE + ); + + for method in ["eth_call", "eth_estimateGas"] { + provider + .raw_request::<_, serde_json::Value>( + method.into(), + serde_json::json!([request.clone(), "latest"]), + ) + .await + .unwrap_or_else(|error| panic!("unexpected {method} error: {error}")); + } + + let access_list = provider + .raw_request::<_, serde_json::Value>( + "eth_createAccessList".into(), + serde_json::json!([request.clone(), "latest"]), + ) + .await + .unwrap(); + assert!(access_list["error"].is_null(), "unexpected access list error: {access_list}"); + + let payload = serde_json::json!({ + "blockStateCalls": [{"calls": [request]}], + "returnFullTransactions": true, + }); + provider + .raw_request::<_, serde_json::Value>( + "eth_simulateV1".into(), + serde_json::json!([payload, "latest"]), + ) + .await + .unwrap(); +} + +sol! { + #[sol(rpc)] + interface IFeeManagerRpc { + function userTokens(address user) external view returns (address); + function validatorTokens(address validator) external view returns (address); + function collectedFees(address validator, address token) external view returns (uint256); + + struct Pool { + uint128 reserveUserToken; + uint128 reserveValidatorToken; + } + + function getPool(address userToken, address validatorToken) external view returns (Pool memory); + function getPoolId(address userToken, address validatorToken) external pure returns (bytes32); + function totalSupply(bytes32 poolId) external view returns (uint256); + function mint(address userToken, address validatorToken, uint256 amountValidatorToken, address to) external returns (uint256 liquidity); + } +} + +sol! { + #[sol(rpc)] + interface IERC20 { + function name() external view returns (string memory); + function symbol() external view returns (string memory); + function decimals() external view returns (uint8); + function totalSupply() external view returns (uint256); + function balanceOf(address account) external view returns (uint256); + function transfer(address to, uint256 amount) external returns (bool); + function allowance(address owner, address spender) external view returns (uint256); + function approve(address spender, uint256 amount) external returns (bool); + function transferFrom(address from, address to, uint256 amount) external returns (bool); + } +} + +sol! { + #[sol(rpc)] + interface IAddressRegistryRpc { + function isImplicitlyApproved(address precompile) external view returns (bool); + } +} + +sol! { + #[sol(rpc)] + interface IAccountKeychainT5Rpc { + function burnKeyAuthorizationWitness(bytes32 witness) external; + function isKeyAuthorizationWitnessBurned(address account, bytes32 witness) external view returns (bool); + } +} + +sol! { + #[sol(rpc)] + interface IStablecoinDexT5Rpc { + struct Order { + uint128 orderId; + address maker; + bytes32 bookKey; + bool isBid; + int16 tick; + uint128 amount; + uint128 remaining; + uint128 prev; + uint128 next; + bool isFlip; + int16 flipTick; + } + + function place(address token, uint128 amount, bool isBid, int16 tick) external returns (uint128 orderId); + function placeFlip(address token, uint128 amount, bool isBid, int16 tick, int16 flipTick) external returns (uint128 orderId); + function swapExactAmountIn(address tokenIn, address tokenOut, uint128 amountIn, uint128 minAmountOut) external returns (uint128 amountOut); + function getOrder(uint128 orderId) external view returns (Order memory); + + event OrderFlipped(uint128 indexed orderId, address indexed maker, address indexed token, uint128 amount, bool isBid, int16 tick, int16 flipTick); + } +} + +sol! { + #[sol(rpc)] + interface ITIP20T5Rpc { + function balanceOf(address account) external view returns (uint256); + function transfer(address to, uint256 amount) external returns (bool); + function mint(address to, uint256 amount) external; + function grantRole(bytes32 role, address account) external; + function ISSUER_ROLE() external view returns (bytes32); + function logoURI() external view returns (string memory); + function setLogoURI(string memory logoURI) external; + } +} + +sol! { + #[sol(rpc)] + #[allow(clippy::too_many_arguments)] + interface ITIP20FactoryT5Rpc { + function createToken( + string memory name, + string memory symbol, + string memory currency, + address quoteToken, + address admin, + bytes32 salt, + string memory logoURI + ) external returns (address); + function getTokenAddress(address sender, bytes32 salt) external pure returns (address); + } +} + +sol! { + #[sol(rpc)] + #[allow(clippy::too_many_arguments)] + interface ITIP20ChannelReserveT5Rpc { + struct ChannelDescriptor { + address payer; + address payee; + address operator; + address token; + bytes32 salt; + address authorizedSigner; + bytes32 expiringNonceHash; + } + + struct ChannelState { + uint96 settled; + uint96 deposit; + uint32 closeRequestedAt; + } + + function open( + address payee, + address operator, + address token, + uint96 deposit, + bytes32 salt, + address authorizedSigner + ) external returns (bytes32 channelId); + function computeChannelId( + address payer, + address payee, + address operator, + address token, + bytes32 salt, + address authorizedSigner, + bytes32 expiringNonceHash + ) external pure returns (bytes32); + function getChannelState(bytes32 channelId) external view returns (ChannelState memory); + function getVoucherDigest(bytes32 channelId, uint96 cumulativeAmount) external view returns (bytes32); + function domainSeparator() external view returns (bytes32); + + event ChannelOpened( + bytes32 indexed channelId, + address indexed payer, + address indexed payee, + address operator, + address token, + address authorizedSigner, + bytes32 salt, + bytes32 expiringNonceHash, + uint96 deposit + ); + } +} + +// ============================================================================ +// Tempo Genesis: Precompile Initialization +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_precompiles_have_code() { + use tempo_hardfork::TempoHardfork; + + let (api, _handle) = + spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T5.into()))).await; + + assert!( + TEMPO_PRECOMPILE_ADDRESSES.contains(&TIP20_CHANNEL_RESERVE_ADDRESS), + "T5 channel reserve should be tracked as a Tempo precompile" + ); + + // Tempo precompiles should have sentinel bytecode (0xef) + for addr in active_tempo_precompile_addresses(TempoHardfork::T5) { + let code = api.get_code(addr, None).await.unwrap(); + assert!(!code.is_empty(), "Precompile {addr} should have code"); + } + + // All TIP20 token addresses should also have code + for addr in TEMPO_TIP20_TOKENS { + let code = api.get_code(*addr, None).await.unwrap(); + assert!(!code.is_empty(), "Token {addr} should have code deployed"); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_anvil_deal_tip20() { + let (_api, handle) = spawn(NodeConfig::test_tempo().with_no_mining(true)).await; + let provider = handle.http_provider(); + let recipient = Address::random(); + let token = IERC20::new(ALPHA_USD, &provider); + let supply_before = token.totalSupply().call().await.unwrap(); + + provider + .raw_request::<_, ()>("anvil_dealTIP20".into(), (recipient, ALPHA_USD, U256::from(100))) + .await + .unwrap(); + assert_eq!(token.balanceOf(recipient).call().await.unwrap(), U256::from(100)); + assert_eq!(token.totalSupply().call().await.unwrap(), supply_before); + + provider + .raw_request::<_, ()>("anvil_dealTIP20".into(), (recipient, ALPHA_USD, U256::from(40))) + .await + .unwrap(); + assert_eq!(token.balanceOf(recipient).call().await.unwrap(), U256::from(40)); + assert_eq!(token.totalSupply().call().await.unwrap(), supply_before); + assert_eq!(provider.txpool_status().await.unwrap().pending, 0); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_anvil_deal_erc20_supports_tip20() { + let (_api, handle) = spawn(NodeConfig::test_tempo().with_no_mining(true)).await; + let provider = handle.http_provider(); + let recipient = Address::random(); + let token = IERC20::new(ALPHA_USD, &provider); + let supply_before = token.totalSupply().call().await.unwrap(); + + provider + .raw_request::<_, ()>("anvil_dealERC20".into(), (recipient, ALPHA_USD, U256::from(100))) + .await + .unwrap(); + assert_eq!(token.balanceOf(recipient).call().await.unwrap(), U256::from(100)); + assert_eq!(token.totalSupply().call().await.unwrap(), supply_before); + + provider + .raw_request::<_, ()>("anvil_dealERC20".into(), (recipient, ALPHA_USD, U256::from(40))) + .await + .unwrap(); + assert_eq!(token.balanceOf(recipient).call().await.unwrap(), U256::from(40)); + assert_eq!(token.totalSupply().call().await.unwrap(), supply_before); + assert_eq!(provider.txpool_status().await.unwrap().pending, 0); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_anvil_deal_tip20_rejects_invalid_token() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + let token = Address::random(); + let result: std::result::Result<(), _> = + provider.raw_request("anvil_dealTIP20".into(), (Address::random(), token, U256::ONE)).await; + + assert_eq!( + result.unwrap_err().to_string(), + format!( + "server returned an error response: error code -32603: failed to process AnyRequest: address {token} is not a deployed TIP-20 token" + ) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_anvil_deal_tip20_rejects_non_tempo_node() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + let result: std::result::Result<(), _> = provider + .raw_request("anvil_dealTIP20".into(), (Address::random(), ALPHA_USD, U256::ONE)) + .await; + + assert_eq!( + result.unwrap_err().to_string(), + "server returned an error response: error code -32603: Not implemented" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_pre_t5_channel_reserve_has_no_sentinel_code() { + let (api, _handle) = + spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T4.into()))).await; + + let code = api.get_code(TIP20_CHANNEL_RESERVE_ADDRESS, None).await.unwrap(); + assert!(code.is_empty(), "pre-T5 channel reserve address should not have sentinel code"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_config_reports_channel_reserve_precompile() { + let (api, _handle) = + spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T5.into()))).await; + + let config = api.config().unwrap(); + + assert_eq!( + config.current.precompiles.get("TIP20ChannelReserve"), + Some(&TIP20_CHANNEL_RESERVE_ADDRESS) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_config_filters_hardfork_gated_precompiles() { + let (api_t4, _handle) = + spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T4.into()))).await; + let config_t4 = api_t4.config().unwrap(); + assert!(!config_t4.current.precompiles.contains_key("TIP20ChannelReserve")); + assert!(!config_t4.current.precompiles.contains_key("ReceivePolicyGuard")); + + let (api_t5, _handle) = + spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T5.into()))).await; + let config_t5 = api_t5.config().unwrap(); + assert_eq!( + config_t5.current.precompiles.get("TIP20ChannelReserve"), + Some(&TIP20_CHANNEL_RESERVE_ADDRESS) + ); + assert!(!config_t5.current.precompiles.contains_key("ReceivePolicyGuard")); + + let (api_t6, _handle) = + spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T6.into()))).await; + let config_t6 = api_t6.config().unwrap(); + assert_eq!( + config_t6.current.precompiles.get("ReceivePolicyGuard"), + Some(&RECEIVE_POLICY_GUARD_ADDRESS) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_t8_current_committee_activation_and_empty_genesis() { + let (api_t7, handle_t7) = + spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T7.into()))).await; + assert!(api_t7.get_code(CURRENT_COMMITTEE_ADDRESS, None).await.unwrap().is_empty()); + assert!(!api_t7.config().unwrap().current.precompiles.contains_key("CurrentCommittee")); + let committee_t7 = ICurrentCommittee::new(CURRENT_COMMITTEE_ADDRESS, handle_t7.http_provider()); + assert!(committee_t7.getCommitteeMembers().call().await.is_err()); + + let (api_t8, handle_t8) = + spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T8.into()))).await; + assert!(!api_t8.get_code(CURRENT_COMMITTEE_ADDRESS, None).await.unwrap().is_empty()); + assert_eq!( + api_t8.config().unwrap().current.precompiles.get("CurrentCommittee"), + Some(&CURRENT_COMMITTEE_ADDRESS) + ); + + let provider = handle_t8.http_provider(); + let committee = ICurrentCommittee::new(CURRENT_COMMITTEE_ADDRESS, &provider); + let members = committee.getCommitteeMembers().call().await.unwrap(); + assert_eq!(members.epoch, 0); + assert!(members.publicKeys.is_empty()); + + let caller = handle_t8.dev_accounts().next().unwrap(); + let err = committee + .setCommitteeMembers(1, vec![B256::repeat_byte(0x11)]) + .from(caller) + .call() + .await + .unwrap_err(); + let unauthorized = + alloy_primitives::hex::encode_prefixed(ICurrentCommittee::Unauthorized::SELECTOR); + assert!(err.to_string().contains(&unauthorized), "{err}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_t6_receive_policy_blocks_and_claims_transfer() { + let (_api_t5, handle_t5) = + spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T5.into()))).await; + let provider_t5 = handle_t5.http_provider(); + let registry_t5 = ITIP403Registry::new(TIP403_REGISTRY_ADDRESS, &provider_t5); + let guard_t5 = IReceivePolicyGuard::new(RECEIVE_POLICY_GUARD_ADDRESS, &provider_t5); + + assert!( + registry_t5.receivePolicy(Address::ZERO).call().await.is_err(), + "receive-policy selectors should be unavailable before T6" + ); + assert!( + guard_t5.balanceOf(Bytes::default()).call().await.is_err(), + "ReceivePolicyGuard should be unavailable before T6" + ); + + let (api, handle) = + spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T6.into()))).await; + let provider = handle.http_provider(); + let accounts: Vec
= handle.dev_accounts().collect(); + let sender = accounts[0]; + let receiver = accounts[1]; + let recovery = accounts[2]; + let claim_target = accounts[3]; + let amount = U256::from(123_456u64); + + let code = api.get_code(RECEIVE_POLICY_GUARD_ADDRESS, None).await.unwrap(); + assert!(!code.is_empty(), "ReceivePolicyGuard should have sentinel code at T6"); + + let registry = ITIP403Registry::new(TIP403_REGISTRY_ADDRESS, &provider); + let set_policy_tx = TransactionRequest::default() + .from(receiver) + .to(TIP403_REGISTRY_ADDRESS) + .with_input( + registry + .setReceivePolicy(REJECT_ALL_POLICY_ID, ALLOW_ALL_POLICY_ID, recovery) + .calldata() + .clone(), + ) + .with_gas_limit(T5_PRECOMPILE_GAS); + let receipt = provider + .send_transaction(WithOtherFields::new(set_policy_tx)) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert!(receipt.status(), "setReceivePolicy should succeed at T6"); + + let validation = + registry.validateReceivePolicy(PATH_USD, sender, receiver).call().await.unwrap(); + assert!(!validation.authorized, "REJECT_ALL sender policy should hold the transfer"); + assert_eq!(validation.blockedReason, ITIP403Registry::BlockedReason::RECEIVE_POLICY); + + let token = ITIP20T5Rpc::new(PATH_USD, &provider); + let receiver_balance_before = token.balanceOf(receiver).call().await.unwrap(); + let guard_balance_before = token.balanceOf(RECEIVE_POLICY_GUARD_ADDRESS).call().await.unwrap(); + let claim_target_balance_before = token.balanceOf(claim_target).call().await.unwrap(); + + let transfer_tx = TransactionRequest::default() + .from(sender) + .to(PATH_USD) + .with_input(token.transfer(receiver, amount).calldata().clone()) + .with_gas_limit(T5_PRECOMPILE_GAS); + let transfer_receipt = provider + .send_transaction(WithOtherFields::new(transfer_tx)) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert!(transfer_receipt.status(), "blocked transfers should still succeed"); + + let blocked = transfer_receipt + .inner + .logs() + .iter() + .find_map(|log| IReceivePolicyGuard::TransferBlocked::decode_log(&log.inner).ok()) + .expect("blocked transfer should emit TransferBlocked"); + let decoded = IReceivePolicyGuard::ClaimReceiptV1::abi_decode(&blocked.receipt).unwrap(); + + assert_eq!(blocked.token, PATH_USD); + assert_eq!(blocked.receiver, receiver); + assert_eq!(blocked.amount, amount); + assert_eq!(blocked.receiptVersion, 1); + assert_eq!(decoded.version, 1); + assert_eq!(decoded.token, PATH_USD); + assert_eq!(decoded.recoveryAuthority, recovery); + assert_eq!(decoded.originator, sender); + assert_eq!(decoded.recipient, receiver); + assert_eq!(decoded.blockedNonce, blocked.blockedNonce); + assert_eq!(decoded.blockedReason, ITIP403Registry::BlockedReason::RECEIVE_POLICY as u8); + assert_eq!(decoded.kind, InboundKind::TRANSFER); + assert_eq!(decoded.memo, B256::ZERO); + + let guard = IReceivePolicyGuard::new(RECEIVE_POLICY_GUARD_ADDRESS, &provider); + assert_eq!(guard.balanceOf(blocked.receipt.clone()).call().await.unwrap(), amount); + assert_eq!(token.balanceOf(receiver).call().await.unwrap(), receiver_balance_before); + assert_eq!( + token.balanceOf(RECEIVE_POLICY_GUARD_ADDRESS).call().await.unwrap(), + guard_balance_before + amount + ); + + let claim_tx = TransactionRequest::default() + .from(recovery) + .to(RECEIVE_POLICY_GUARD_ADDRESS) + .with_input(guard.claim(claim_target, blocked.receipt.clone()).calldata().clone()) + .with_gas_limit(T5_PRECOMPILE_GAS); + let claim_receipt = provider + .send_transaction(WithOtherFields::new(claim_tx)) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert!(claim_receipt.status(), "recovery authority should be able to claim held funds"); + + let claimed = claim_receipt + .inner + .logs() + .iter() + .find_map(|log| IReceivePolicyGuard::ReceiptClaimed::decode_log(&log.inner).ok()) + .expect("claim should emit ReceiptClaimed"); + assert_eq!(claimed.token, PATH_USD); + assert_eq!(claimed.receiver, receiver); + assert_eq!(claimed.blockedNonce, decoded.blockedNonce); + assert_eq!(claimed.caller, recovery); + assert_eq!(claimed.to, claim_target); + assert_eq!(claimed.amount, amount); + + assert_eq!(guard.balanceOf(blocked.receipt.clone()).call().await.unwrap(), U256::ZERO); + assert_eq!( + token.balanceOf(claim_target).call().await.unwrap(), + claim_target_balance_before + amount + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_channel_reserve_compute_channel_id_call() { + let (_api, handle) = + spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T5.into()))).await; + let provider = handle.http_provider(); + let reserve = ITIP20ChannelReserve::new(TIP20_CHANNEL_RESERVE_ADDRESS, &provider); + + let payer = address!("0x0000000000000000000000000000000000000101"); + let payee = address!("0x0000000000000000000000000000000000000202"); + let operator = address!("0x0000000000000000000000000000000000000303"); + let salt = B256::with_last_byte(0x42); + let authorized_signer = address!("0x0000000000000000000000000000000000000404"); + let expiring_nonce_hash = B256::with_last_byte(0x99); + + let channel_id = reserve + .computeChannelId( + payer, + payee, + operator, + PATH_USD, + salt, + authorized_signer, + expiring_nonce_hash, + ) + .call() + .await + .unwrap(); + let expected = keccak256( + ( + payer, + payee, + operator, + PATH_USD, + salt, + authorized_signer, + expiring_nonce_hash, + TIP20_CHANNEL_RESERVE_ADDRESS, + U256::from(provider.get_chain_id().await.unwrap()), + ) + .abi_encode(), + ); + + assert_eq!(channel_id, expected); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_call_uses_rpc_simulation_channel_context() { + let (_api, handle) = + spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T5.into()))).await; + let provider = handle.http_provider(); + let accounts: Vec<_> = handle.dev_accounts().collect(); + let payer = accounts[0]; + let payee = accounts[1]; + let reserve = ITIP20ChannelReserve::new(TIP20_CHANNEL_RESERVE_ADDRESS, &provider); + let salt = B256::repeat_byte(0x42); + let deposit = U96::from(1_000_000); + let open = reserve.open(payee, Address::ZERO, PATH_USD, deposit, salt, Address::ZERO); + + let output = provider + .raw_request::<_, Bytes>( + "eth_call".into(), + (serde_json::json!({ + "from": payer, + "type": "0x76", + "gas": "0x989680", + "calls": [{ + "to": TIP20_CHANNEL_RESERVE_ADDRESS, + "value": "0x0", + "input": open.calldata(), + }], + }),), + ) + .await + .unwrap(); + let channel_id = B256::abi_decode(&output).unwrap(); + let expected = reserve + .computeChannelId( + payer, + payee, + Address::ZERO, + PATH_USD, + salt, + Address::ZERO, + B256::new(*b"TEMPO_RPC_SIMULATION_MPP_CONTEXT"), + ) + .call() + .await + .unwrap(); + + assert_eq!(channel_id, expected); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_t5_implicit_approvals_are_hardfork_gated() { + let (_api, handle) = + spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T4.into()))).await; + let provider = handle.http_provider(); + let registry = IAddressRegistryRpc::new(ADDRESS_REGISTRY_ADDRESS, &provider); + + let pre_t5_result = registry.isImplicitlyApproved(TIP20_CHANNEL_RESERVE_ADDRESS).call().await; + assert!(pre_t5_result.is_err(), "T5 implicit-approval selector should be unavailable at T4"); + + let (api, handle) = + spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T5.into()))).await; + let provider = handle.http_provider(); + let registry = IAddressRegistryRpc::new(ADDRESS_REGISTRY_ADDRESS, &provider); + + let code = api.get_code(TIP20_CHANNEL_RESERVE_ADDRESS, None).await.unwrap(); + assert!(!code.is_empty(), "T5 channel reserve precompile should be registered"); + + for approved in [TIP_FEE_MANAGER_ADDRESS, STABLECOIN_DEX_ADDRESS, TIP20_CHANNEL_RESERVE_ADDRESS] + { + assert!( + registry.isImplicitlyApproved(approved).call().await.unwrap(), + "{approved} should be implicitly approved at T5" + ); + } + + let random_address = Address::random(); + assert!( + !registry.isImplicitlyApproved(random_address).call().await.unwrap(), + "arbitrary addresses should not be implicitly approved" + ); +} + +#[cfg(feature = "cli")] +#[tokio::test(flavor = "multi_thread")] +async fn test_anvil_cli_tempo_t5_hardfork_precompile_smoke() { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + let port_arg = port.to_string(); + + let mut child = ChildGuard( + Command::new(anvil_binary()) + .args([ + "--network", + "tempo", + "--hardfork", + "tempo:T5", + "--host", + "127.0.0.1", + "--port", + &port_arg, + "-q", + ]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn anvil --hardfork tempo:T5"), + ); + + let endpoint = format!("http://127.0.0.1:{port}"); + let provider = http_provider(&endpoint); + let mut ready = false; + for _ in 0..100 { + if provider.get_chain_id().await.is_ok() { + ready = true; + break; + } + if let Some(status) = child.0.try_wait().unwrap() { + panic!("anvil exited before serving RPC: {status}"); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + assert!(ready, "anvil --hardfork tempo:T5 should start serving RPC"); + + let registry = IAddressRegistryRpc::new(ADDRESS_REGISTRY_ADDRESS, &provider); + assert!(registry.isImplicitlyApproved(TIP20_CHANNEL_RESERVE_ADDRESS).call().await.unwrap()); + + let reserve = ITIP20ChannelReserveT5Rpc::new(TIP20_CHANNEL_RESERVE_ADDRESS, &provider); + assert_ne!(reserve.domainSeparator().call().await.unwrap(), B256::ZERO); +} + +#[cfg(feature = "cli")] +#[tokio::test(flavor = "multi_thread")] +async fn test_anvil_cli_tempo_t6_hardfork_receive_policy_guard_smoke() { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + let port_arg = port.to_string(); + + let mut child = ChildGuard( + Command::new(anvil_binary()) + .args([ + "--network", + "tempo", + "--hardfork", + "tempo:T6", + "--host", + "127.0.0.1", + "--port", + &port_arg, + "-q", + ]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn anvil --hardfork tempo:T6"), + ); + + let endpoint = format!("http://127.0.0.1:{port}"); + let provider = http_provider(&endpoint); + let mut ready = false; + for _ in 0..100 { + if provider.get_chain_id().await.is_ok() { + ready = true; + break; + } + if let Some(status) = child.0.try_wait().unwrap() { + panic!("anvil exited before serving RPC: {status}"); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + assert!(ready, "anvil --hardfork tempo:T6 should start serving RPC"); + + let receipt = IReceivePolicyGuard::ClaimReceiptV1::new( + PATH_USD, + Address::with_last_byte(2), + Address::with_last_byte(3), + Address::with_last_byte(4), + 1, + 1, + ITIP403Registry::BlockedReason::RECEIVE_POLICY as u8, + InboundKind::TRANSFER, + B256::ZERO, + ) + .abi_encode() + .into(); + let guard = IReceivePolicyGuard::new(RECEIVE_POLICY_GUARD_ADDRESS, &provider); + assert_eq!(guard.balanceOf(receipt).call().await.unwrap(), U256::ZERO); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_t5_tip20_logo_uri_validation_and_update() { + let (api, handle) = + spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T5.into()))).await; + let provider = handle.http_provider(); + api.anvil_set_balance(TEMPO_ADMIN, U256::MAX).await.unwrap(); + api.anvil_impersonate_account(TEMPO_ADMIN).await.unwrap(); + + let token = ITIP20T5Rpc::new(PATH_USD, &provider); + assert_eq!(token.logoURI().call().await.unwrap(), ""); + + let logo_uri = "https://example.com/pathusd.png".to_string(); + let tx = TransactionRequest::default() + .from(TEMPO_ADMIN) + .to(PATH_USD) + .with_input(token.setLogoURI(logo_uri.clone()).calldata().clone()) + .with_gas_limit(T5_PRECOMPILE_GAS); + + let receipt = provider + .send_transaction(WithOtherFields::new(tx)) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert!(receipt.status(), "setLogoURI should succeed for a valid URI"); + assert_eq!(token.logoURI().call().await.unwrap(), logo_uri); + + let logo_event_topic = alloy_primitives::keccak256(b"LogoURIUpdated(address,string)"); + assert!( + receipt + .inner + .logs() + .iter() + .any(|log| log.address() == PATH_USD && log.topics()[0] == logo_event_topic), + "setLogoURI should emit LogoURIUpdated" + ); + + let invalid_tx = TransactionRequest::default() + .from(TEMPO_ADMIN) + .to(PATH_USD) + .with_input( + token.setLogoURI("ftp://example.com/pathusd.png".to_string()).calldata().clone(), + ) + .with_gas_limit(T5_PRECOMPILE_GAS); + assert!( + provider.call(WithOtherFields::new(invalid_tx)).await.is_err(), + "unsupported logoURI schemes should revert" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_t5_stablecoin_dex_allows_same_tick_flip_order() { + let (_api, handle) = + spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T4.into()))).await; + let provider = handle.http_provider(); + let sender = handle.dev_accounts().next().unwrap(); + let dex = IStablecoinDexT5Rpc::new(STABLECOIN_DEX_ADDRESS, &provider); + + let tick = 100i16; + let t4_call = dex.placeFlip(ALPHA_USD, DEX_MIN_ORDER_AMOUNT, true, tick, tick); + let t4_tx = TransactionRequest::default() + .from(sender) + .to(STABLECOIN_DEX_ADDRESS) + .with_input(t4_call.calldata().clone()) + .with_gas_limit(T5_PRECOMPILE_GAS); + assert!( + provider.call(WithOtherFields::new(t4_tx)).await.is_err(), + "same-tick flip orders should be rejected before T5" + ); + + let (_api, handle) = + spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T5.into()))).await; + let provider = handle.http_provider(); + let accounts: Vec
= handle.dev_accounts().collect(); + let sender = accounts[0]; + let taker = accounts[1]; + let dex = IStablecoinDexT5Rpc::new(STABLECOIN_DEX_ADDRESS, &provider); + + let t5_call = dex.placeFlip(ALPHA_USD, DEX_MIN_ORDER_AMOUNT, true, tick, tick); + let t5_tx = TransactionRequest::default() + .from(sender) + .to(STABLECOIN_DEX_ADDRESS) + .with_input(t5_call.calldata().clone()) + .with_gas_limit(T5_PRECOMPILE_GAS); + provider + .call(WithOtherFields::new(t5_tx.clone())) + .await + .expect("same-tick flip order should be callable at T5"); + + let receipt = provider + .send_transaction(WithOtherFields::new(t5_tx)) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert!(receipt.status(), "same-tick flip order should execute at T5 without approval"); + + let order = dex.getOrder(1).call().await.unwrap(); + assert_eq!(order.orderId, 1); + assert_eq!(order.maker, sender); + assert!(order.isBid); + assert!(order.isFlip); + assert_eq!(order.tick, tick); + assert_eq!(order.flipTick, tick); + assert_eq!(order.remaining, DEX_MIN_ORDER_AMOUNT); + + let fill_call = dex.swapExactAmountIn(ALPHA_USD, PATH_USD, DEX_MIN_ORDER_AMOUNT, 0); + let fill_tx = TransactionRequest::default() + .from(taker) + .to(STABLECOIN_DEX_ADDRESS) + .with_input(fill_call.calldata().clone()) + .with_gas_limit(T5_PRECOMPILE_GAS); + let receipt = provider + .send_transaction(WithOtherFields::new(fill_tx)) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert!(receipt.status(), "opposite-side order should fill and flip the T5 order"); + + let order_flipped_topic = alloy_primitives::keccak256( + b"OrderFlipped(uint128,address,address,uint128,bool,int16,int16)", + ); + let flipped_log = receipt + .inner + .logs() + .iter() + .find(|log| { + log.address() == STABLECOIN_DEX_ADDRESS && log.topics()[0] == order_flipped_topic + }) + .expect("fill should emit OrderFlipped"); + assert_eq!( + flipped_log.topics()[1], + B256::with_last_byte(1), + "OrderFlipped should preserve the original order ID" + ); + + let flipped_order = dex.getOrder(1).call().await.unwrap(); + assert_eq!(flipped_order.orderId, 1); + assert_eq!(flipped_order.maker, sender); + assert!(!flipped_order.isBid, "filled bid flip order should become an ask"); + assert!(flipped_order.isFlip); + assert_eq!(flipped_order.tick, tick); + assert_eq!(flipped_order.flipTick, tick); + assert_eq!(flipped_order.remaining, DEX_MIN_ORDER_AMOUNT); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_t5_key_authorization_witness_burn_flow() { + let (_api, handle) = + spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T4.into()))).await; + let provider = handle.http_provider(); + let account = handle.dev_accounts().next().unwrap(); + let keychain = IAccountKeychainT5Rpc::new(ACCOUNT_KEYCHAIN_ADDRESS, &provider); + let witness = B256::repeat_byte(0x53); + + let pre_t5_result = keychain.isKeyAuthorizationWitnessBurned(account, witness).call().await; + assert!(pre_t5_result.is_err(), "witness check selector should be unavailable at T4"); + + let (_api, handle) = + spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T5.into()))).await; + let provider = handle.http_provider(); + let account = handle.dev_accounts().next().unwrap(); + let keychain = IAccountKeychainT5Rpc::new(ACCOUNT_KEYCHAIN_ADDRESS, &provider); + + let chain_id = provider.get_chain_id().await.unwrap(); + let signer = dev_key(0); + assert_eq!(signer.address(), account); + let access_key = PrivateKeySigner::random(); + let unsigned_without_witness = + KeyAuthorization::unrestricted(chain_id, SignatureType::Secp256k1, access_key.address()); + let authorization = unsigned_without_witness.clone().with_witness(witness); + assert_eq!(authorization.witness(), Some(witness)); + assert_ne!( + authorization.signature_hash(), + unsigned_without_witness.signature_hash(), + "witness must be part of the key authorization signing digest" + ); + assert_ne!(authorization.signature_hash(), B256::ZERO); + assert!(!keychain.isKeyAuthorizationWitnessBurned(account, witness).call().await.unwrap()); + + let calldata = keychain.burnKeyAuthorizationWitness(witness).calldata().clone(); + let base_fee = provider.get_gas_price().await.unwrap(); + let key_auth_signature = signer.sign_hash(&authorization.signature_hash()).await.unwrap(); + let signed_key_auth = + authorization.into_signed(PrimitiveSignature::Secp256k1(key_auth_signature)); + let tempo_tx = TempoTransaction { + chain_id, + fee_token: Some(ALPHA_USD), + max_priority_fee_per_gas: base_fee / 10, + max_fee_per_gas: base_fee * 2, + gas_limit: T5_PRECOMPILE_GAS, + calls: vec![Call { + to: TxKind::Call(ACCOUNT_KEYCHAIN_ADDRESS), + value: U256::ZERO, + input: calldata, + }], + access_list: Default::default(), + nonce_key: U256::ZERO, + nonce: 0, + fee_payer_signature: None, + valid_before: None, + valid_after: None, + key_authorization: Some(signed_key_auth), + tempo_authorization_list: vec![], + }; + + let sig_hash = tempo_tx.signature_hash(); + let signature = signer.sign_hash(&sig_hash).await.unwrap(); + let envelope = TempoTxEnvelope::AA(tempo_tx.into_signed(signature.into())); + + let mut encoded = Vec::new(); + envelope.encode_2718(&mut encoded); + + let receipt = + provider.send_raw_transaction(&encoded).await.unwrap().get_receipt().await.unwrap(); + assert!(receipt.status(), "burnKeyAuthorizationWitness should succeed at T5"); + + assert!(keychain.isKeyAuthorizationWitnessBurned(account, witness).call().await.unwrap()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_t5_tip20_channel_reserve_basic_views() { + let (api, handle) = + spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T5.into()))).await; + let provider = handle.http_provider(); + let accounts: Vec
= handle.dev_accounts().collect(); + let payer = accounts[0]; + let payee = accounts[1]; + let reserve = ITIP20ChannelReserveT5Rpc::new(TIP20_CHANNEL_RESERVE_ADDRESS, &provider); + + let code = api.get_code(TIP20_CHANNEL_RESERVE_ADDRESS, None).await.unwrap(); + assert!(!code.is_empty(), "channel reserve should have sentinel code"); + + let salt = B256::repeat_byte(0x34); + let expiring_nonce_hash = B256::repeat_byte(0x35); + let channel_id = reserve + .computeChannelId(payer, payee, Address::ZERO, PATH_USD, salt, payer, expiring_nonce_hash) + .call() + .await + .unwrap(); + + let state = reserve.getChannelState(channel_id).call().await.unwrap(); + assert_eq!(state.settled, 0); + assert_eq!(state.deposit, 0); + assert_eq!(state.closeRequestedAt, 0); + assert_ne!(reserve.domainSeparator().call().await.unwrap(), B256::ZERO); + assert_ne!( + reserve.getVoucherDigest(channel_id, U96::from(1)).call().await.unwrap(), + B256::ZERO + ); + + let token = ITIP20T5Rpc::new(PATH_USD, &provider); + let payer_balance_before = token.balanceOf(payer).call().await.unwrap(); + let reserve_balance_before = + token.balanceOf(TIP20_CHANNEL_RESERVE_ADDRESS).call().await.unwrap(); + let deposit_amount = U256::from(1_000_000u64); + let open_call = reserve.open( + payee, + Address::ZERO, + PATH_USD, + U96::from(1_000_000u64), + B256::repeat_byte(0x36), + Address::ZERO, + ); + + let block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + let chain_id = provider.get_chain_id().await.unwrap(); + let base_fee = provider.get_gas_price().await.unwrap(); + let signer = dev_key(0); + assert_eq!(signer.address(), payer); + let tempo_tx = TempoTransaction { + chain_id, + fee_token: Some(ALPHA_USD), + max_priority_fee_per_gas: base_fee / 10, + max_fee_per_gas: base_fee * 2, + gas_limit: T5_PRECOMPILE_GAS, + calls: vec![Call { + to: TxKind::Call(TIP20_CHANNEL_RESERVE_ADDRESS), + value: U256::ZERO, + input: open_call.calldata().clone(), + }], + access_list: Default::default(), + nonce_key: U256::MAX, + nonce: 0, + fee_payer_signature: None, + valid_before: NonZeroU64::new(block.header.timestamp + 25), + valid_after: None, + key_authorization: None, + tempo_authorization_list: vec![], + }; + + let sig_hash = tempo_tx.signature_hash(); + let signature = signer.sign_hash(&sig_hash).await.unwrap(); + let envelope = TempoTxEnvelope::AA(tempo_tx.into_signed(signature.into())); + + let mut encoded = Vec::new(); + envelope.encode_2718(&mut encoded); + let receipt = + provider.send_raw_transaction(&encoded).await.unwrap().get_receipt().await.unwrap(); + assert!(receipt.status(), "channel reserve open should succeed at T5"); + + let channel_opened_topic = alloy_primitives::keccak256( + b"ChannelOpened(bytes32,address,address,address,address,address,bytes32,bytes32,uint96)", + ); + let opened_log = receipt + .inner + .logs() + .iter() + .find(|log| { + log.address() == TIP20_CHANNEL_RESERVE_ADDRESS + && log.topics()[0] == channel_opened_topic + }) + .expect("open should emit ChannelOpened"); + let opened_channel_id = opened_log.topics()[1]; + let opened_state = reserve.getChannelState(opened_channel_id).call().await.unwrap(); + assert_eq!(opened_state.settled, 0); + assert_eq!(opened_state.deposit, U96::from(1_000_000u64)); + assert_eq!(opened_state.closeRequestedAt, 0); + + let payer_balance_after = token.balanceOf(payer).call().await.unwrap(); + let reserve_balance_after = + token.balanceOf(TIP20_CHANNEL_RESERVE_ADDRESS).call().await.unwrap(); + assert_eq!(payer_balance_before - payer_balance_after, deposit_amount); + assert_eq!(reserve_balance_after - reserve_balance_before, deposit_amount); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_t5_fee_amm_two_hop_route_for_local_fees() { + let (api, handle) = + spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T5.into()))).await; + let provider = handle.http_provider(); + let accounts: Vec
= handle.dev_accounts().collect(); + let sender = accounts[0]; + let validator = accounts[3]; + let recipient = accounts[1]; + + api.anvil_set_coinbase(validator).await.unwrap(); + api.anvil_set_validator_fee_token(validator, BETA_USD).await.unwrap(); + + let factory = ITIP20FactoryT5Rpc::new(TIP20_FACTORY_ADDRESS, &provider); + let salt = B256::repeat_byte(0xa5); + let fee_token = factory.getTokenAddress(sender, salt).call().await.unwrap(); + + let create_tx = TransactionRequest::default() + .from(sender) + .to(TIP20_FACTORY_ADDRESS) + .with_input( + factory + .createToken( + "RouteUSD".to_string(), + "RouteUSD".to_string(), + "USD".to_string(), + PATH_USD, + sender, + salt, + "https://example.com/routeusd.png".to_string(), + ) + .calldata() + .clone(), + ) + .with_gas_limit(T5_PRECOMPILE_GAS); + provider + .call(WithOtherFields::new(create_tx.clone())) + .await + .expect("T5 factory createToken with logoURI should be callable"); + let receipt = provider + .send_transaction(WithOtherFields::new(create_tx)) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert!(receipt.status(), "T5 factory createToken with logoURI should succeed"); + + let token = ITIP20T5Rpc::new(fee_token, &provider); + let issuer_role = token.ISSUER_ROLE().call().await.unwrap(); + let grant_issuer_tx = TransactionRequest::default() + .from(sender) + .to(fee_token) + .with_input(token.grantRole(issuer_role, sender).calldata().clone()) + .with_gas_limit(T5_PRECOMPILE_GAS); + let receipt = provider + .send_transaction(WithOtherFields::new(grant_issuer_tx)) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert!(receipt.status(), "token admin should be able to grant ISSUER_ROLE"); + + let mint_tx = TransactionRequest::default() + .from(sender) + .to(fee_token) + .with_input(token.mint(sender, U256::from(u64::MAX)).calldata().clone()) + .with_gas_limit(T5_PRECOMPILE_GAS); + let receipt = provider + .send_transaction(WithOtherFields::new(mint_tx)) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert!(receipt.status(), "new TIP-20 fee token mint should succeed"); + + let fee_manager = IFeeManagerRpc::new(TIP_FEE_MANAGER_ADDRESS, &provider); + let direct_pool_before = fee_manager.getPool(fee_token, BETA_USD).call().await.unwrap(); + assert_eq!(direct_pool_before.reserveUserToken, 0); + assert_eq!(direct_pool_before.reserveValidatorToken, 0); + + let liquidity_amount = U256::from(100_000_000_000_000_000u64); + for (user_token, validator_token) in [(fee_token, PATH_USD), (PATH_USD, BETA_USD)] { + let mint_liquidity_tx = TransactionRequest::default() + .from(sender) + .to(TIP_FEE_MANAGER_ADDRESS) + .with_input( + fee_manager + .mint(user_token, validator_token, liquidity_amount, sender) + .calldata() + .clone(), + ) + .with_gas_limit(T5_PRECOMPILE_GAS); + let receipt = provider + .send_transaction(WithOtherFields::new(mint_liquidity_tx)) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert!(receipt.status(), "FeeAMM liquidity mint should succeed"); + } + api.anvil_set_fee_token(sender, fee_token).await.unwrap(); + + let first_hop_pool_before = fee_manager.getPool(fee_token, PATH_USD).call().await.unwrap(); + let second_hop_pool_before = fee_manager.getPool(PATH_USD, BETA_USD).call().await.unwrap(); + let collected_before = fee_manager.collectedFees(validator, BETA_USD).call().await.unwrap(); + let transfer_tx = TransactionRequest::default() + .from(sender) + .to(fee_token) + .with_input(token.transfer(recipient, U256::from(1000)).calldata().clone()) + .with_gas_limit(T5_PRECOMPILE_GAS); + + let receipt = provider + .send_transaction(WithOtherFields::new(transfer_tx)) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert!(receipt.status(), "local fee payment should route through the T5 two-hop FeeAMM path"); + + let collected_after = fee_manager.collectedFees(validator, BETA_USD).call().await.unwrap(); + assert!( + collected_after > collected_before, + "validator should receive BETA fees through feeToken -> PATH -> BETA" + ); + + let direct_pool_after = fee_manager.getPool(fee_token, BETA_USD).call().await.unwrap(); + assert_eq!(direct_pool_after.reserveUserToken, 0); + assert_eq!(direct_pool_after.reserveValidatorToken, 0); + + let first_hop_pool_after = fee_manager.getPool(fee_token, PATH_USD).call().await.unwrap(); + assert!( + first_hop_pool_after.reserveUserToken > first_hop_pool_before.reserveUserToken, + "feeToken -> PATH pool should collect fee token input" + ); + assert!( + first_hop_pool_after.reserveValidatorToken < first_hop_pool_before.reserveValidatorToken, + "feeToken -> PATH pool should spend PATH output" + ); + + let second_hop_pool_after = fee_manager.getPool(PATH_USD, BETA_USD).call().await.unwrap(); + assert!( + second_hop_pool_after.reserveUserToken > second_hop_pool_before.reserveUserToken, + "PATH -> BETA pool should collect PATH input" + ); + assert!( + second_hop_pool_after.reserveValidatorToken < second_hop_pool_before.reserveValidatorToken, + "PATH -> BETA pool should spend BETA output" + ); +} + +// ============================================================================ +// Tempo Genesis: Fee Token Metadata +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_tip20_token_metadata() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let tokens = [ + (PATH_USD, "PathUSD", "PathUSD"), + (ALPHA_USD, "AlphaUSD", "AlphaUSD"), + (BETA_USD, "BetaUSD", "BetaUSD"), + (THETA_USD, "ThetaUSD", "ThetaUSD"), + ]; + + for (addr, expected_name, expected_symbol) in tokens { + let token = IERC20::new(addr, &provider); + let name = token.name().call().await.unwrap(); + let symbol = token.symbol().call().await.unwrap(); + let decimals = token.decimals().call().await.unwrap(); + + assert_eq!(name, expected_name, "Token at {addr} name mismatch"); + assert_eq!(symbol, expected_symbol, "Token at {addr} symbol mismatch"); + assert_eq!(decimals, 6, "All TIP20 tokens should use 6 decimals"); + } +} + +// ============================================================================ +// Tempo Genesis: Fee Token Balances +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_fee_token_balances_minted_to_dev_accounts() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let dev_accounts: Vec
= handle.dev_accounts().collect(); + assert!(!dev_accounts.is_empty()); + + for account in dev_accounts.iter().take(3) { + for token_addr in [PATH_USD, ALPHA_USD, BETA_USD, THETA_USD] { + let token = IERC20::new(token_addr, &provider); + let balance = token.balanceOf(*account).call().await.unwrap(); + assert!( + balance > U256::ZERO, + "Account {account} should have {token_addr} balance, got 0" + ); + } + } +} + +// ============================================================================ +// Tempo Genesis: Dev Account Balance +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_dev_accounts_have_balance() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let genesis_balance = handle.genesis_balance(); + + for account in handle.dev_accounts() { + let balance = provider.get_balance(account).await.unwrap(); + assert_eq!(balance, genesis_balance, "Dev account {account} should have genesis balance"); + } +} + +// ============================================================================ +// TIP20 Token Operations: Transfer +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_tip20_transfer() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let sender = accounts[0]; + let recipient = accounts[1]; + + let token = IERC20::new(PATH_USD, &provider); + + let sender_balance_before = token.balanceOf(sender).call().await.unwrap(); + let recipient_balance_before = token.balanceOf(recipient).call().await.unwrap(); + + let transfer_amount = U256::from(1_000_000); + let transfer_call = token.transfer(recipient, transfer_amount); + let calldata: Bytes = transfer_call.calldata().clone(); + + let tx = TransactionRequest::default() + .from(sender) + .to(PATH_USD) + .with_input(calldata) + .with_gas_limit(TIP20_TRANSFER_GAS); + + let tx = WithOtherFields::new(tx); + let receipt = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + assert!(receipt.status()); + + let sender_balance_after = token.balanceOf(sender).call().await.unwrap(); + let recipient_balance_after = token.balanceOf(recipient).call().await.unwrap(); + + assert_eq!( + sender_balance_before - transfer_amount, + sender_balance_after, + "Sender balance should decrease by transfer amount" + ); + assert_eq!( + recipient_balance_before + transfer_amount, + recipient_balance_after, + "Recipient balance should increase by transfer amount" + ); +} + +/// Every transaction on Tempo pays gas in a fee token, so a sender holding fee tokens but no +/// native balance, which is what every forked mainnet sender looks like, must get its EIP-1559 +/// transaction pooled and mined. +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_eip1559_sender_without_native_balance_pays_gas_in_fee_token() { + let (api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let signer = PrivateKeySigner::random(); + let sender = signer.address(); + let recipient = Address::random(); + assert!(provider.get_balance(sender).await.unwrap().is_zero()); + + let chain_id = provider.get_chain_id().await.unwrap(); + let gas_price = provider.get_gas_price().await.unwrap(); + let amount = U256::from(1_000_000); + let tx = TxEip1559 { + chain_id, + nonce: 0, + gas_limit: TIP20_TRANSFER_GAS, + max_fee_per_gas: gas_price * 2, + max_priority_fee_per_gas: gas_price / 10, + to: TxKind::Call(PATH_USD), + input: IERC20::transferCall { to: recipient, amount }.abi_encode().into(), + ..Default::default() + }; + let signature = signer.sign_hash(&tx.signature_hash()).await.unwrap(); + let raw = FoundryTxEnvelope::Eip1559(tx.into_signed(signature)).encoded_2718(); + + // Without fee tokens the pool reports the fee token shortfall, not missing native funds. + let err = provider.send_raw_transaction(&raw).await.unwrap_err(); + assert!(err.to_string().contains("insufficient fee token balance"), "unexpected error: {err}"); + + api.anvil_deal_tip20(sender, PATH_USD, U256::from(10_000_000)).await.unwrap(); + let receipt = provider.send_raw_transaction(&raw).await.unwrap().get_receipt().await.unwrap(); + assert!(receipt.status()); + assert_eq!(IERC20::new(PATH_USD, &provider).balanceOf(recipient).call().await.unwrap(), amount); +} + +/// Standard envelopes use the same Tempo fee and precompile semantics during replay and mining. +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_standard_envelope_replay() { + for legacy in [true, false] { + let (api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + let sender = handle.dev_accounts().next().unwrap(); + let recipient = Address::random(); + api.anvil_set_balance(sender, U256::ZERO).await.unwrap(); + api.mine_one().await.unwrap(); + + let amount = U256::from(1_000_000); + let block = provider.get_block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + // Raw tracing uses the current block fee, while eth_gasPrice estimates the next block. + let gas_price = provider + .get_gas_price() + .await + .unwrap() + .max(u128::from(block.header.base_fee_per_gas.unwrap())); + let request = TransactionRequest::default() + .from(sender) + .to(PATH_USD) + .with_input(IERC20::transferCall { to: recipient, amount }.abi_encode()) + .with_gas_limit(TIP20_TRANSFER_GAS); + let request = if legacy { + request.with_gas_price(gas_price) + } else { + request.max_fee_per_gas(gas_price * 2).max_priority_fee_per_gas(gas_price / 10) + }; + let signed = api.sign_transaction(WithOtherFields::new(request)).await.unwrap(); + let raw = signed.parse::().unwrap(); + let envelope = FoundryTxEnvelope::decode_2718(&mut raw.as_ref()).unwrap(); + assert_eq!(envelope.ty(), if legacy { 0 } else { 2 }); + + let raw_trace = provider.trace_raw_transaction(&raw).trace().await.unwrap(); + assert_eq!(raw_trace.output.as_ref(), true.abi_encode()); + assert!(raw_trace.trace.iter().all(|trace| trace.error.is_none())); + + let receipt = + provider.send_raw_transaction(&raw).await.unwrap().get_receipt().await.unwrap(); + assert!(receipt.status()); + let replay = + provider.trace_replay_transaction(receipt.transaction_hash).trace().await.unwrap(); + assert_eq!(replay.output, raw_trace.output); + assert!(replay.trace.iter().all(|trace| trace.error.is_none())); + + let trace = provider + .debug_trace_transaction(receipt.transaction_hash, GethDebugTracingOptions::default()) + .await + .unwrap(); + let GethTrace::Default(frame) = trace else { panic!("expected default trace") }; + assert!(!frame.failed); + assert_eq!(frame.gas, receipt.gas_used); + assert_eq!(frame.return_value, raw_trace.output); + + let block_replay = provider + .trace_replay_block_transactions(receipt.block_number.unwrap().into()) + .trace() + .await + .unwrap(); + assert_eq!(block_replay.len(), 1); + assert_eq!(block_replay[0].transaction_hash, receipt.transaction_hash); + assert_eq!(block_replay[0].full_trace.output, raw_trace.output); + assert_eq!( + IERC20::new(PATH_USD, &provider).balanceOf(recipient).call().await.unwrap(), + amount + ); + assert!(provider.get_balance(sender).await.unwrap().is_zero()); + } +} + +/// Standard EIP-7702 replay honors the same local signature overrides as mining. +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_eip7702_replay_signature_override() { + let (api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + let mut accounts = handle.dev_accounts(); + let authority = accounts.next().unwrap(); + let sender = accounts.next().unwrap(); + let implementation = Address::random(); + // Return a constant word so traces expose whether the delegation was applied. + api.anvil_set_code(implementation, bytes!("0x602a5f5260205ff3")).await.unwrap(); + api.mine_one().await.unwrap(); + + let signature = Signature::new(U256::ZERO, U256::ZERO, true); + api.anvil_impersonate_signature(signature.as_bytes().into(), authority).await.unwrap(); + let authorization = Authorization { + chain_id: U256::from(provider.get_chain_id().await.unwrap()), + address: implementation, + nonce: 0, + } + .into_signed(signature); + let block = provider.get_block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + let gas_price = provider + .get_gas_price() + .await + .unwrap() + .max(u128::from(block.header.base_fee_per_gas.unwrap())); + let request = TransactionRequest::default() + .from(sender) + .to(authority) + .with_authorization_list(vec![authorization]) + .with_gas_limit(TIP20_TRANSFER_GAS) + .max_fee_per_gas(gas_price * 2) + .max_priority_fee_per_gas(gas_price / 10); + let signed = api.sign_transaction(WithOtherFields::new(request)).await.unwrap(); + let raw = signed.parse::().unwrap(); + let envelope = FoundryTxEnvelope::decode_2718(&mut raw.as_ref()).unwrap(); + assert_eq!(envelope.ty(), 4); + + let raw_trace = provider.trace_raw_transaction(&raw).trace().await.unwrap(); + assert_eq!(raw_trace.output.as_ref(), U256::from(42).abi_encode()); + assert!(raw_trace.trace.iter().all(|trace| trace.error.is_none())); + + let receipt = provider.send_raw_transaction(&raw).await.unwrap().get_receipt().await.unwrap(); + assert!(receipt.status()); + let replay = provider.trace_replay_transaction(receipt.transaction_hash).trace().await.unwrap(); + assert_eq!(replay.output, raw_trace.output); + assert!(replay.trace.iter().all(|trace| trace.error.is_none())); + + let trace = provider + .debug_trace_transaction(receipt.transaction_hash, GethDebugTracingOptions::default()) + .await + .unwrap(); + let GethTrace::Default(frame) = trace else { panic!("expected default trace") }; + assert!(!frame.failed); + assert_eq!(frame.gas, receipt.gas_used); + assert_eq!(frame.return_value, raw_trace.output); + + let block_replay = provider + .trace_replay_block_transactions(receipt.block_number.unwrap().into()) + .trace() + .await + .unwrap(); + assert_eq!(block_replay.len(), 1); + assert_eq!(block_replay[0].transaction_hash, receipt.transaction_hash); + assert_eq!(block_replay[0].full_trace.output, raw_trace.output); +} + +/// The pool admits a Tempo transaction only once the fee payer can cover the maximum fee, +/// rounded up to a whole fee token unit like the execution precharge: one unit short is rejected, +/// the exact amount is admitted and mined. +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_pool_requires_max_fee_rounded_up_in_fee_token() { + let (api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let signer = PrivateKeySigner::random(); + let sender = signer.address(); + let chain_id = provider.get_chain_id().await.unwrap(); + let gas_price = provider.get_gas_price().await.unwrap(); + + // An odd gas limit keeps the fee from being a whole number of fee token units; a fresh + // account's first transaction also pays Tempo's new account cost. + let gas_limit = 1_000_001; + let max_fee_per_gas = gas_price * 2; + let required = calc_gas_balance_spending(gas_limit, max_fee_per_gas); + assert_ne!( + required * TEMPO_GAS_PRICE_SCALING_FACTOR, + U256::from(gas_limit) * U256::from(max_fee_per_gas), + "the fee must round up for the boundary to matter" + ); + + let tx = TxEip1559 { + chain_id, + nonce: 0, + gas_limit, + max_fee_per_gas, + max_priority_fee_per_gas: gas_price / 10, + to: TxKind::Call(Address::random()), + ..Default::default() + }; + let signature = signer.sign_hash(&tx.signature_hash()).await.unwrap(); + let raw = FoundryTxEnvelope::Eip1559(tx.into_signed(signature)).encoded_2718(); + + api.anvil_deal_tip20(sender, PATH_USD, required - U256::ONE).await.unwrap(); + let err = provider.send_raw_transaction(&raw).await.unwrap_err(); + assert!(err.to_string().contains("insufficient fee token balance"), "unexpected error: {err}"); + + api.anvil_deal_tip20(sender, PATH_USD, required).await.unwrap(); + let receipt = provider.send_raw_transaction(&raw).await.unwrap().get_receipt().await.unwrap(); + assert!(receipt.status()); +} + +/// Signs `tx` with `signer` and returns its raw EIP-2718 encoding. +async fn sign_eip1559(signer: &PrivateKeySigner, tx: TxEip1559) -> Vec { + let signature = signer.sign_hash(&tx.signature_hash()).await.unwrap(); + FoundryTxEnvelope::Eip1559(tx.into_signed(signature)).encoded_2718() +} + +/// The pool resolves the fee token like execution does: a sender holding only AlphaUSD that +/// transfers AlphaUSD pays its fee in AlphaUSD, without any pathUSD or stored preference. +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_pool_infers_fee_token_from_tip20_call() { + let (api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let signer = PrivateKeySigner::random(); + let sender = signer.address(); + let recipient = Address::random(); + let chain_id = provider.get_chain_id().await.unwrap(); + let gas_price = provider.get_gas_price().await.unwrap(); + let amount = U256::from(1_000_000); + api.anvil_deal_tip20(sender, ALPHA_USD, U256::from(10_000_000)).await.unwrap(); + + let tx = TxEip1559 { + chain_id, + nonce: 0, + gas_limit: TIP20_TRANSFER_GAS, + max_fee_per_gas: gas_price * 2, + max_priority_fee_per_gas: gas_price / 10, + to: TxKind::Call(ALPHA_USD), + input: IERC20::transferCall { to: recipient, amount }.abi_encode().into(), + ..Default::default() + }; + let raw = sign_eip1559(&signer, tx).await; + let receipt = provider.send_raw_transaction(&raw).await.unwrap().get_receipt().await.unwrap(); + assert!(receipt.status()); + + let alpha = IERC20::new(ALPHA_USD, &provider); + assert_eq!(alpha.balanceOf(recipient).call().await.unwrap(), amount); + assert!( + alpha.balanceOf(sender).call().await.unwrap() < U256::from(10_000_000) - amount, + "the fee was not paid in AlphaUSD" + ); + assert_eq!( + IERC20::new(PATH_USD, &provider).balanceOf(sender).call().await.unwrap(), + U256::ZERO + ); +} + +/// A transaction that sets the sender's fee token preference pays its own fee in the token it +/// sets, so the pool must resolve that token immediately instead of the stored preference. +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_pool_uses_fee_token_set_by_the_transaction() { + let (api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let signer = PrivateKeySigner::random(); + let sender = signer.address(); + let chain_id = provider.get_chain_id().await.unwrap(); + let gas_price = provider.get_gas_price().await.unwrap(); + api.anvil_deal_tip20(sender, ALPHA_USD, U256::from(10_000_000)).await.unwrap(); + + let tx = TxEip1559 { + chain_id, + nonce: 0, + gas_limit: TIP20_TRANSFER_GAS, + max_fee_per_gas: gas_price * 2, + max_priority_fee_per_gas: gas_price / 10, + to: TxKind::Call(TIP_FEE_MANAGER_ADDRESS), + input: IFeeManager::setUserTokenCall { token: ALPHA_USD }.abi_encode().into(), + ..Default::default() + }; + let raw = sign_eip1559(&signer, tx).await; + let receipt = provider.send_raw_transaction(&raw).await.unwrap().get_receipt().await.unwrap(); + assert!(receipt.status()); + + let fee_manager = IFeeManagerRpc::new(TIP_FEE_MANAGER_ADDRESS, &provider); + assert_eq!(fee_manager.userTokens(sender).call().await.unwrap(), ALPHA_USD); + assert!( + IERC20::new(ALPHA_USD, &provider).balanceOf(sender).call().await.unwrap() + < U256::from(10_000_000), + "the fee was not paid in AlphaUSD" + ); +} + +/// With pool balance checks disabled, a sender may spend fee tokens it only receives from an +/// earlier transaction in the same block, mirroring the Ethereum test in `txpool`. +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_accepts_spend_after_funding_when_pool_checks_disabled() { + let (api, handle) = + spawn(NodeConfig::test_tempo().with_disable_pool_balance_checks(true)).await; + let provider = handle.http_provider(); + api.anvil_set_auto_mine(false).await.unwrap(); + + let funder = provider.get_accounts().await.unwrap().remove(0); + let spender = Address::random(); + api.anvil_impersonate_account(spender).await.unwrap(); + let token = IERC20::new(PATH_USD, &provider); + let gas_price = provider.get_gas_price().await.unwrap(); + + // The funding transaction pays more, so it is mined first within the block. + let funding = U256::from(5_000_000); + let fund = TransactionRequest::default() + .with_from(funder) + .with_to(PATH_USD) + .with_input(token.transfer(spender, funding).calldata().clone()) + .with_gas_limit(TIP20_TRANSFER_GAS) + .with_gas_price(gas_price * 10); + let fund_hash = *provider.send_transaction(WithOtherFields::new(fund)).await.unwrap().tx_hash(); + + // Without fee tokens the spender would be rejected while balance checks are enabled. + let spend = TransactionRequest::default() + .with_from(spender) + .with_to(PATH_USD) + .with_input(token.transfer(funder, U256::from(1_000_000)).calldata().clone()) + .with_gas_limit(TIP20_TRANSFER_GAS) + .with_gas_price(gas_price); + let spend_hash = + *provider.send_transaction(WithOtherFields::new(spend)).await.unwrap().tx_hash(); + + let status = provider.txpool_status().await.unwrap(); + assert_eq!((status.pending, status.queued), (2, 0)); + + api.mine_one().await.unwrap(); + let block = provider.get_block(BlockId::number(1)).full().await.unwrap().unwrap(); + assert_eq!(block.transactions.hashes().collect::>(), vec![fund_hash, spend_hash]); + for hash in [fund_hash, spend_hash] { + let receipt = provider.get_transaction_receipt(hash).await.unwrap().unwrap(); + assert!(receipt.status(), "{hash} failed"); + } +} + +// ============================================================================ +// TIP20 Token Operations: Approve and TransferFrom +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_tip20_approve_and_transfer_from() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let owner = accounts[0]; + let spender = accounts[1]; + let recipient = accounts[2]; + + let token = IERC20::new(BETA_USD, &provider); + + // Owner approves spender + let approve_amount = U256::from(5_000_000); + let approve_call = token.approve(spender, approve_amount); + let calldata: Bytes = approve_call.calldata().clone(); + + let tx = TransactionRequest::default() + .from(owner) + .to(BETA_USD) + .with_input(calldata) + .with_gas_limit(TIP20_TRANSFER_GAS); + + let tx = WithOtherFields::new(tx); + provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + + let allowance = token.allowance(owner, spender).call().await.unwrap(); + assert_eq!(allowance, approve_amount); + + // Spender transfers from owner to recipient + let transfer_amount = U256::from(2_000_000); + let transfer_from_call = token.transferFrom(owner, recipient, transfer_amount); + let calldata: Bytes = transfer_from_call.calldata().clone(); + + let recipient_balance_before = token.balanceOf(recipient).call().await.unwrap(); + + let tx = TransactionRequest::default() + .from(spender) + .to(BETA_USD) + .with_input(calldata) + .with_gas_limit(TIP20_TRANSFER_GAS); + + let tx = WithOtherFields::new(tx); + let receipt = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + assert!(receipt.status()); + + let recipient_balance_after = token.balanceOf(recipient).call().await.unwrap(); + assert_eq!(recipient_balance_before + transfer_amount, recipient_balance_after); + + let allowance_after = token.allowance(owner, spender).call().await.unwrap(); + assert_eq!(allowance_after, approve_amount - transfer_amount); +} + +// ============================================================================ +// TIP20 Token Operations: Total Supply +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_tip20_total_supply() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let token = IERC20::new(PATH_USD, &provider); + let total_supply = token.totalSupply().call().await.unwrap(); + + assert!(total_supply > U256::ZERO, "Total supply should be non-zero"); +} + +// ============================================================================ +// TIP20 Token Operations: Transfer Between Different Fee Tokens +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_transfer_between_different_fee_tokens() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let sender = accounts[0]; + let recipient = accounts[1]; + + for token_addr in [PATH_USD, ALPHA_USD, BETA_USD, THETA_USD] { + let token = IERC20::new(token_addr, &provider); + let balance_before = token.balanceOf(recipient).call().await.unwrap(); + + let transfer_amount = U256::from(100_000); + let transfer_call = token.transfer(recipient, transfer_amount); + let calldata: Bytes = transfer_call.calldata().clone(); + + let tx = TransactionRequest::default() + .from(sender) + .to(token_addr) + .with_input(calldata) + .with_gas_limit(TIP20_TRANSFER_GAS); + + let tx = WithOtherFields::new(tx); + let receipt = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + assert!(receipt.status(), "Transfer for {token_addr} failed"); + + let balance_after = token.balanceOf(recipient).call().await.unwrap(); + assert_eq!(balance_after, balance_before + transfer_amount); + } +} + +// ============================================================================ +// TIP20 Token Operations: All Fee Tokens Have Correct Metadata +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_all_fee_tokens_have_correct_metadata() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let tokens = [ + (PATH_USD, "PathUSD"), + (ALPHA_USD, "AlphaUSD"), + (BETA_USD, "BetaUSD"), + (THETA_USD, "ThetaUSD"), + ]; + + for (addr, expected_name) in tokens { + let token = IERC20::new(addr, &provider); + let name = token.name().call().await.unwrap(); + let decimals = token.decimals().call().await.unwrap(); + + assert_eq!(name, expected_name, "Token at {addr} should be named {expected_name}"); + assert_eq!(decimals, 6, "All TIP20 tokens use 6 decimals"); + } +} + +// ============================================================================ +// TIP20 Token Operations: Transfer Emits Event +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_transfer_emits_event() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let from = accounts[0]; + let to = accounts[1]; + + let token = IERC20::new(ALPHA_USD, &provider); + let transfer_amount = U256::from(1_000_000); + let transfer_call = token.transfer(to, transfer_amount); + let calldata: Bytes = transfer_call.calldata().clone(); + + let tx = TransactionRequest::default() + .from(from) + .to(ALPHA_USD) + .with_input(calldata) + .with_gas_limit(TIP20_TRANSFER_GAS); + + let tx = WithOtherFields::new(tx); + let receipt = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + + assert!(!receipt.inner.logs().is_empty(), "Transfer should emit event"); + + let log = &receipt.inner.logs()[0]; + assert_eq!(log.address(), ALPHA_USD); + + let transfer_topic = + alloy_primitives::keccak256("Transfer(address,address,uint256)".as_bytes()); + assert_eq!(log.topics()[0], transfer_topic); +} + +// ============================================================================ +// Tempo Transactions: Native Value Transfer Rejected +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_native_value_transfer_rejected() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let from = accounts[0]; + let to = accounts[1]; + + let tx = TransactionRequest::default() + .from(from) + .to(to) + .value(U256::from(1_000_000_000_000_000_000u64)); // 1 ETH + + let tx = WithOtherFields::new(tx); + let result = provider.send_transaction(tx).await; + assert!(result.is_err(), "Native ETH transfers should be rejected in Tempo mode"); + + let err = result.unwrap_err().to_string(); + assert!( + err.contains("native value transfer not allowed"), + "Expected 'native value transfer not allowed' error, got: {err}" + ); +} + +// ============================================================================ +// Tempo Transactions: Zero-Value EIP-1559 Tx Succeeds +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_zero_value_tx_succeeds() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let sender = accounts[0]; + let recipient = accounts[1]; + + // TIP20 transfer (value=0, only calldata) + let token = IERC20::new(PATH_USD, &provider); + let transfer_call = token.transfer(recipient, U256::from(1_000_000)); + let calldata: Bytes = transfer_call.calldata().clone(); + + let tx = TransactionRequest::default() + .from(sender) + .to(PATH_USD) + .with_input(calldata) + .with_gas_limit(TIP20_TRANSFER_GAS); + + let tx = WithOtherFields::new(tx); + let receipt = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + assert!(receipt.status()); +} + +// ============================================================================ +// Tempo Transactions: Contract Deployment +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_contract_deployment() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let sender = accounts[0]; + + // Minimal contract: PUSH1 0x00 PUSH1 0x00 RETURN (returns empty) + let bytecode = Bytes::from(vec![0x60, 0x00, 0x60, 0x00, 0xf3]); + + let tx = + TransactionRequest::default().from(sender).with_input(bytecode).with_gas_limit(1_000_000); + + let tx = WithOtherFields::new(tx); + let receipt = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + assert!(receipt.status()); + assert!(receipt.contract_address.is_some(), "Should have deployed a contract"); +} + +// ============================================================================ +// Tempo Transactions: Nonce Increments +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_nonce_increments() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let from = accounts[0]; + let to = accounts[1]; + + let nonce_before = provider.get_transaction_count(from).await.unwrap(); + assert_eq!(nonce_before, 0); + + // Send a TIP20 transfer + let token = IERC20::new(ALPHA_USD, &provider); + let transfer_call = token.transfer(to, U256::from(1000)); + let calldata: Bytes = transfer_call.calldata().clone(); + + let tx = TransactionRequest::default() + .from(from) + .to(ALPHA_USD) + .with_input(calldata) + .with_gas_limit(TIP20_TRANSFER_GAS); + + let tx = WithOtherFields::new(tx); + provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + + let nonce_after = provider.get_transaction_count(from).await.unwrap(); + assert_eq!(nonce_after, 1); +} + +// ============================================================================ +// Tempo AA Transaction Tests (Type 0x76) +// ============================================================================ + +/// Helper to get the private key for a dev account. +fn dev_key(index: u32) -> PrivateKeySigner { + let mnemonic = "test test test test test test test test test test test junk"; + alloy_signer_local::MnemonicBuilder::::default() + .phrase(mnemonic) + .index(index) + .expect("valid mnemonic") + .build() + .expect("valid key") +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_aa_transaction_basic() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let recipient = accounts[1]; + let signer = dev_key(0); + + let token = IERC20::new(PATH_USD, &provider); + let recipient_balance_before = token.balanceOf(recipient).call().await.unwrap(); + + let transfer_amount = U256::from(100_000); + let transfer_call = token.transfer(recipient, transfer_amount); + let calldata: Bytes = transfer_call.calldata().clone(); + + let chain_id = provider.get_chain_id().await.unwrap(); + let base_fee = provider.get_gas_price().await.unwrap(); + + let tempo_tx = TempoTransaction { + chain_id, + fee_token: Some(ALPHA_USD), + max_priority_fee_per_gas: base_fee / 10, + max_fee_per_gas: base_fee * 2, + gas_limit: TIP20_TRANSFER_GAS, + calls: vec![Call { to: TxKind::Call(PATH_USD), value: U256::ZERO, input: calldata }], + access_list: Default::default(), + nonce_key: U256::ZERO, + nonce: 0, + fee_payer_signature: None, + valid_before: None, + valid_after: None, + key_authorization: None, + tempo_authorization_list: vec![], + }; + + let sig_hash = tempo_tx.signature_hash(); + let signature = signer.sign_hash(&sig_hash).await.unwrap(); + let envelope = TempoTxEnvelope::AA(tempo_tx.into_signed(signature.into())); + + let mut encoded = Vec::new(); + envelope.encode_2718(&mut encoded); + let tx_hash = provider.send_raw_transaction(&encoded).await.unwrap(); + let receipt = tx_hash.get_receipt().await.unwrap(); + + assert!(receipt.status(), "Tempo AA transaction should succeed"); + + let recipient_balance_after = token.balanceOf(recipient).call().await.unwrap(); + assert_eq!( + recipient_balance_after, + recipient_balance_before + transfer_amount, + "Recipient should receive transfer amount" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_send_transaction_preserves_calls() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + let from = handle.dev_accounts().next().unwrap(); + let calls = [ + Call { + to: TxKind::Call(Address::random()), + value: U256::ZERO, + input: Bytes::from_static(&[0xde, 0xad]), + }, + Call { + to: TxKind::Call(Address::random()), + value: U256::ZERO, + input: Bytes::from_static(&[0xbe, 0xef]), + }, + ]; + + let hash = provider + .raw_request::<_, B256>( + "eth_sendTransaction".into(), + (serde_json::json!({ + "from": from, + "type": "0x76", + "calls": calls, + }),), + ) + .await + .unwrap(); + + let transaction = provider + .raw_request::<_, serde_json::Value>("eth_getTransactionByHash".into(), (hash,)) + .await + .unwrap(); + assert_eq!(transaction["type"], "0x76"); + assert_eq!(transaction["calls"], serde_json::to_value(calls).unwrap()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_send_transaction_preserves_signed_identity() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + let accounts = handle.dev_accounts().collect::>(); + let from = accounts[0]; + let fee_payer = accounts[1]; + let calls = vec![ + Call { + to: TxKind::Call(Address::random()), + value: U256::ZERO, + input: Bytes::from_static(&[0xde, 0xad]), + }, + Call { + to: TxKind::Call(Address::random()), + value: U256::ZERO, + input: Bytes::from_static(&[0xbe, 0xef]), + }, + ]; + let nonce_key = U256::from(7); + let gas_price = provider.get_gas_price().await.unwrap(); + let mut expected_tx = TempoTransaction { + chain_id: provider.get_chain_id().await.unwrap(), + fee_token: Some(ALPHA_USD), + max_priority_fee_per_gas: gas_price / 10, + max_fee_per_gas: gas_price * 2, + gas_limit: 1_000_000, + calls: calls.clone(), + access_list: Default::default(), + nonce_key, + nonce: 0, + fee_payer_signature: Some(Signature::new(U256::ZERO, U256::ZERO, false)), + valid_before: None, + valid_after: None, + key_authorization: None, + tempo_authorization_list: vec![], + }; + let fee_payer_signature = + dev_key(1).sign_hash(&expected_tx.fee_payer_signature_hash(from)).await.unwrap(); + expected_tx.fee_payer_signature = Some(fee_payer_signature); + let sender_signature = dev_key(0).sign_hash(&expected_tx.signature_hash()).await.unwrap(); + let expected_signature = TempoSignature::from(sender_signature); + + let hash = provider + .raw_request::<_, B256>( + "eth_sendTransaction".into(), + (serde_json::json!({ + "from": from, + "type": "0x76", + "chainId": expected_tx.chain_id, + "feeToken": expected_tx.fee_token, + "maxPriorityFeePerGas": expected_tx.max_priority_fee_per_gas, + "maxFeePerGas": expected_tx.max_fee_per_gas, + "gas": expected_tx.gas_limit, + "calls": expected_tx.calls, + "nonceKey": expected_tx.nonce_key, + "nonce": expected_tx.nonce, + "feePayerSignature": fee_payer_signature, + }),), + ) + .await + .unwrap(); + + let transaction = provider + .raw_request::<_, serde_json::Value>("eth_getTransactionByHash".into(), (hash,)) + .await + .unwrap(); + let transaction = serde_json::from_value::(transaction).unwrap(); + let recomputed_hash = + *transaction.tx().clone().into_signed(transaction.signature().clone()).hash(); + + assert_eq!(*transaction.hash(), hash); + assert_eq!(recomputed_hash, hash); + assert_eq!(transaction.tx().calls, calls); + assert_eq!(transaction.tx().nonce_key, nonce_key); + assert_eq!(transaction.tx().nonce, 0); + assert_eq!(transaction.signature(), &expected_signature); + assert_eq!(transaction.recover_signer().unwrap(), from); + assert_eq!(transaction.tx().fee_payer_signature, Some(fee_payer_signature)); + assert_eq!(transaction.tx().recover_fee_payer(from).unwrap(), fee_payer); +} + +/// Builds a sender-signed Tempo AA PathUSD transfer that requests sponsorship through the fee +/// payer signature marker. +async fn sponsorship_requested_transfer( + chain_id: u64, + gas_price: u128, + sender_index: u32, + recipient: Address, + amount: U256, + fee_token: Option
, + nonce: u64, +) -> AASigned { + let tempo_tx = TempoTransaction { + chain_id, + fee_token, + max_priority_fee_per_gas: gas_price / 10, + max_fee_per_gas: gas_price * 2, + gas_limit: TIP20_TRANSFER_GAS, + calls: vec![tempo_transfer(recipient, amount)], + access_list: Default::default(), + nonce_key: U256::ZERO, + nonce, + fee_payer_signature: Some(FEE_PAYER_SIGNATURE_MARKER), + valid_before: None, + valid_after: None, + key_authorization: None, + tempo_authorization_list: vec![], + }; + let signature = dev_key(sender_index).sign_hash(&tempo_tx.signature_hash()).await.unwrap(); + tempo_tx.into_signed(signature.into()) +} + +/// Encodes a Tempo AA transaction the way fee payer service clients submit it: the fee payer +/// signature field carries the `0x00` placeholder and the fee token is omitted. +fn fee_payer_service_encoded(tx: &AASigned) -> Bytes { + let mut buf = Vec::new(); + tx.encode_for_fee_payer_service(&mut buf); + buf.into() +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_sign_raw_transaction_sponsors_transaction() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + let accounts: Vec
= handle.dev_accounts().collect(); + let sender = accounts[0]; + let sponsor = *accounts.last().unwrap(); + let recipient = Address::random(); + let amount = U256::from(100_000); + + let chain_id = provider.get_chain_id().await.unwrap(); + let gas_price = provider.get_gas_price().await.unwrap(); + let unsigned = + sponsorship_requested_transfer(chain_id, gas_price, 0, recipient, amount, None, 0).await; + + let signed_raw = provider + .raw_request::<_, Bytes>( + "eth_signRawTransaction".into(), + (fee_payer_service_encoded(&unsigned),), + ) + .await + .unwrap(); + + let TempoTxEnvelope::AA(signed) = + TempoTxEnvelope::decode_2718(&mut signed_raw.as_ref()).unwrap() + else { + panic!("expected a Tempo AA transaction"); + }; + assert_eq!(signed.signature(), unsigned.signature(), "sender signature must be preserved"); + assert_eq!(signed.tx().calls, unsigned.tx().calls); + assert_eq!(signed.tx().fee_token, Some(PATH_USD), "sponsor pays with its stored fee token"); + assert_eq!(signed.recover_signer().unwrap(), sender); + assert_eq!(signed.tx().recover_fee_payer(sender).unwrap(), sponsor); + + // The returned transaction is fully signed and broadcasts through the regular path. + let token = IERC20::new(PATH_USD, &provider); + let sender_before = token.balanceOf(sender).call().await.unwrap(); + let sponsor_before = token.balanceOf(sponsor).call().await.unwrap(); + + let receipt = + provider.send_raw_transaction(&signed_raw).await.unwrap().get_receipt().await.unwrap(); + assert!(receipt.status(), "sponsored transaction should succeed"); + + assert_eq!(token.balanceOf(recipient).call().await.unwrap(), amount); + assert_eq!( + token.balanceOf(sender).call().await.unwrap(), + sender_before - amount, + "sender must only pay the transfer amount" + ); + assert!( + token.balanceOf(sponsor).call().await.unwrap() < sponsor_before, + "sponsor must pay the transaction fee" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_send_raw_transaction_auto_sponsors_placeholder() { + let sponsor = dev_key(1).address(); + let (_api, handle) = spawn(NodeConfig::test_tempo().with_tempo_fee_payer(Some(sponsor))).await; + let provider = handle.http_provider(); + let sender = handle.dev_accounts().next().unwrap(); + let recipient = Address::random(); + let amount = U256::from(50_000); + + let chain_id = provider.get_chain_id().await.unwrap(); + let gas_price = provider.get_gas_price().await.unwrap(); + + let token = IERC20::new(PATH_USD, &provider); + let sender_before = token.balanceOf(sender).call().await.unwrap(); + let sponsor_token = IERC20::new(BETA_USD, &provider); + let sponsor_before = sponsor_token.balanceOf(sponsor).call().await.unwrap(); + + // Sign-and-relay mode: the placeholder-marked raw transaction is sponsored on submission. + let unsigned = + sponsorship_requested_transfer(chain_id, gas_price, 0, recipient, amount, None, 0).await; + let receipt = provider + .raw_request::<_, serde_json::Value>( + "eth_sendRawTransactionSync".into(), + (fee_payer_service_encoded(&unsigned),), + ) + .await + .unwrap(); + assert_eq!(receipt["status"], "0x1", "unexpected receipt: {receipt}"); + + let hash: B256 = serde_json::from_value(receipt["transactionHash"].clone()).unwrap(); + let transaction = provider + .raw_request::<_, serde_json::Value>("eth_getTransactionByHash".into(), (hash,)) + .await + .unwrap(); + let transaction = serde_json::from_value::(transaction).unwrap(); + assert_eq!(transaction.recover_signer().unwrap(), sender); + assert_eq!(transaction.tx().recover_fee_payer(sender).unwrap(), sponsor); + // The configured sponsor (dev account 1) pays with its stored fee token, BetaUSD. + assert_eq!(transaction.tx().fee_token, Some(BETA_USD)); + + // The same works for a placeholder signature in the standard encoding. + let unsigned = + sponsorship_requested_transfer(chain_id, gas_price, 0, recipient, amount, None, 1).await; + let receipt = provider + .send_raw_transaction(&TempoTxEnvelope::AA(unsigned).encoded_2718()) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert!(receipt.status(), "sponsored transaction should succeed"); + + assert_eq!(token.balanceOf(recipient).call().await.unwrap(), amount * U256::from(2)); + assert_eq!( + token.balanceOf(sender).call().await.unwrap(), + sender_before - amount * U256::from(2), + "sender must only pay the transfer amounts" + ); + assert!( + sponsor_token.balanceOf(sponsor).call().await.unwrap() < sponsor_before, + "sponsor must pay the transaction fees" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_sign_raw_transaction_preserves_fee_token() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + let sender = handle.dev_accounts().next().unwrap(); + + let chain_id = provider.get_chain_id().await.unwrap(); + let gas_price = provider.get_gas_price().await.unwrap(); + // The standard encoding keeps the sender's fee token choice, which the sponsor must honor. + let unsigned = sponsorship_requested_transfer( + chain_id, + gas_price, + 0, + Address::random(), + U256::ONE, + Some(ALPHA_USD), + 0, + ) + .await; + + let signed_raw = provider + .raw_request::<_, Bytes>( + "eth_signRawTransaction".into(), + (Bytes::from(TempoTxEnvelope::AA(unsigned).encoded_2718()),), + ) + .await + .unwrap(); + + let TempoTxEnvelope::AA(signed) = + TempoTxEnvelope::decode_2718(&mut signed_raw.as_ref()).unwrap() + else { + panic!("expected a Tempo AA transaction"); + }; + assert_eq!(signed.tx().fee_token, Some(ALPHA_USD)); + assert_eq!( + signed.tx().recover_fee_payer(sender).unwrap(), + dev_key(9).address(), + "default sponsor is the last dev account" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_sign_raw_transaction_rejects_invalid_requests() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + let chain_id = provider.get_chain_id().await.unwrap(); + let gas_price = provider.get_gas_price().await.unwrap(); + let recipient = Address::random(); + + // The sponsor must not equal the transaction sender (the default sponsor is account 9). + let from_sponsor = + sponsorship_requested_transfer(chain_id, gas_price, 9, recipient, U256::ONE, None, 0).await; + let err = provider + .raw_request::<_, Bytes>( + "eth_signRawTransaction".into(), + (fee_payer_service_encoded(&from_sponsor),), + ) + .await + .unwrap_err(); + assert!( + err.to_string().contains("must not equal the transaction sender"), + "unexpected error: {err}" + ); + + // Transactions that already carry a real fee payer signature are rejected. + let mut sponsored = + sponsorship_requested_transfer(chain_id, gas_price, 0, recipient, U256::ONE, None, 0).await; + let (mut tx, signature, _) = sponsored.into_parts(); + let sender = dev_key(0).address(); + tx.fee_payer_signature = + Some(dev_key(2).sign_hash(&tx.fee_payer_signature_hash(sender)).await.unwrap()); + sponsored = tx.into_signed(signature); + let err = provider + .raw_request::<_, Bytes>( + "eth_signRawTransaction".into(), + (Bytes::from(TempoTxEnvelope::AA(sponsored).encoded_2718()),), + ) + .await + .unwrap_err(); + assert!(err.to_string().contains("already fee-payer signed"), "unexpected error: {err}"); + + // Transactions signed without the sponsorship placeholder are rejected: the sender signature + // commits to whether a fee payer is present, so sponsoring them would invalidate it. + let unsponsored = + sponsorship_requested_transfer(chain_id, gas_price, 0, recipient, U256::ONE, None, 0).await; + let (mut tx, signature, _) = unsponsored.into_parts(); + tx.fee_payer_signature = None; + let unsponsored = tx.into_signed(signature); + let err = provider + .raw_request::<_, Bytes>( + "eth_signRawTransaction".into(), + (Bytes::from(TempoTxEnvelope::AA(unsponsored).encoded_2718()),), + ) + .await + .unwrap_err(); + assert!(err.to_string().contains("does not request sponsorship"), "unexpected error: {err}"); + + // Non-Tempo transactions cannot be fee-payer signed. + let eip1559 = TxEip1559 { + chain_id, + nonce: 0, + gas_limit: 21_000, + max_fee_per_gas: gas_price * 2, + max_priority_fee_per_gas: gas_price / 10, + to: TxKind::Call(recipient), + ..Default::default() + }; + let signature = dev_key(0).sign_hash(&eip1559.signature_hash()).await.unwrap(); + let raw = FoundryTxEnvelope::Eip1559(eip1559.into_signed(signature)).encoded_2718(); + let err = provider + .raw_request::<_, Bytes>("eth_signRawTransaction".into(), (Bytes::from(raw),)) + .await + .unwrap_err(); + assert!(err.to_string().contains("only Tempo (0x76) transactions"), "unexpected error: {err}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_sign_raw_transaction_unsupported_without_tempo() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + let err = provider + .raw_request::<_, Bytes>("eth_signRawTransaction".into(), (Bytes::from_static(&[0x76]),)) + .await + .unwrap_err(); + assert!(err.to_string().contains("Not implemented"), "unexpected error: {err}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_call_executes_all_calls() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + let from = handle.dev_accounts().next().unwrap(); + let recipient = Address::random(); + let token = IERC20::new(PATH_USD, &provider); + let balance = token.balanceOf(from).call().await.unwrap(); + + provider + .raw_request::<_, Bytes>( + "eth_call".into(), + (tempo_call_request(from, [tempo_transfer(recipient, U256::ONE)]),), + ) + .await + .unwrap(); + + let err = provider + .raw_request::<_, Bytes>( + "eth_call".into(), + (tempo_call_request( + from, + [tempo_transfer(recipient, balance), tempo_transfer(recipient, U256::ONE)], + ),), + ) + .await + .unwrap_err(); + assert!(err.to_string().contains("revert"), "unexpected error: {err}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_fields_do_not_change_ethereum_call_classification() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + let from = handle.dev_accounts().next().unwrap(); + + provider + .raw_request::<_, Bytes>( + "eth_call".into(), + (serde_json::json!({ + "from": from, + "to": Address::random(), + "calls": [], + }),), + ) + .await + .unwrap(); + + let error = provider + .raw_request::<_, Bytes>( + "eth_call".into(), + (serde_json::json!({ + "from": from, + "to": Address::random(), + "type": "0x76", + }),), + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("tempo transaction received"), "unexpected error: {error}"); + + let request = serde_json::json!({ + "from": from, + "type": "0x76", + "gas": "0x1e8480", + "calls": [{ + "to": Address::random(), + "value": "0x0", + "input": "0x", + }], + }); + for (method, params) in [ + ("eth_signTransaction", serde_json::json!([request.clone()])), + ("eth_fillTransaction", serde_json::json!([request])), + ] { + let error = + provider.raw_request::<_, serde_json::Value>(method.into(), params).await.unwrap_err(); + assert!( + error.to_string().contains("tempo transaction received"), + "unexpected {method} error: {error}" + ); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_fill_preserves_serialized_non_aa_transaction_type() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + let from = handle.dev_accounts().next().unwrap(); + let request = TempoTransactionRequest { + inner: TransactionRequest::default() + .from(from) + .to(Address::random()) + .with_gas_limit(21_000) + .with_max_fee_per_gas(1_000_000_000) + .with_max_priority_fee_per_gas(1_000_000), + ..Default::default() + }; + let request = serde_json::to_value(request).unwrap(); + assert!(request["feeToken"].is_null()); + assert_eq!(request["calls"], serde_json::json!([])); + + let filled = provider + .raw_request::<_, serde_json::Value>("eth_fillTransaction".into(), (request,)) + .await + .unwrap(); + + assert_eq!(filled["tx"]["type"], "0x2"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_rpc_rejects_malformed_extension_fields() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + let from = handle.dev_accounts().next().unwrap(); + let malformed_fields = [ + ( + "calls", + serde_json::json!("not-an-array"), + r#"invalid type: string "not-an-array", expected a sequence"#, + ), + ( + "nonceKey", + serde_json::json!("not-a-quantity"), + r#"invalid value: string "not-a-quantity", expected a 32 byte hex string"#, + ), + ( + "validBefore", + serde_json::json!("not-a-quantity"), + r#"invalid value: string "not-a-quantity", expected a 8 byte hex string"#, + ), + ( + "validAfter", + serde_json::json!(-1), + "invalid type: integer `-1`, expected a 8 byte hex string", + ), + ( + "feePayerSignature", + serde_json::json!("not-a-signature"), + r#"invalid type: string "not-a-signature", expected struct HumanReadableRepr"#, + ), + ]; + + for (field, value, expected) in malformed_fields { + let mut request = serde_json::json!({ + "from": from, + "type": "0x76", + "gas": "0x1e8480", + "calls": [{"to": Address::random(), "value": "0x0", "input": "0x"}], + }); + request[field] = value; + let error = + provider.raw_request::<_, Bytes>("eth_call".into(), (request,)).await.unwrap_err(); + assert_eq!( + error.to_string(), + format!( + "server returned an error response: error code -32602: Invalid transaction request: {expected}" + ), + "{field}" + ); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_call_many_executes_calls() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + let from = handle.dev_accounts().next().unwrap(); + let recipient = Address::random(); + let token = IERC20::new(PATH_USD, &provider); + let balance = token.balanceOf(from).call().await.unwrap(); + let request = tempo_call_request( + from, + [tempo_transfer(recipient, balance), tempo_transfer(recipient, U256::ONE)], + ); + + let response = provider + .raw_request::<_, serde_json::Value>( + "eth_callMany".into(), + (serde_json::json!([{"transactions": [request]}]),), + ) + .await + .unwrap(); + + assert!(response[0][0]["error"].as_str().is_some()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_call_many_distinguishes_expiring_nonce_transactions() { + for hardfork in [TempoHardfork::T1, TempoHardfork::T1B] { + let (_api, handle) = spawn( + NodeConfig::test_tempo() + .with_hardfork(Some(hardfork.into())) + .with_genesis_timestamp(Some(1u64)), + ) + .await; + let provider = handle.http_provider(); + let from = handle.dev_accounts().next().unwrap(); + let request = |target| { + serde_json::json!({ + "from": from, + "type": "0x76", + "nonceKey": U256::MAX, + "gas": "0x1e8480", + "validBefore": 25, + "calls": [{"to": target, "value": "0x0", "input": "0x"}], + }) + }; + + let response = provider + .raw_request::<_, serde_json::Value>( + "eth_callMany".into(), + (serde_json::json!([{ + "transactions": [request(Address::random()), request(Address::random())], + }]),), + ) + .await + .unwrap(); + + assert!(response[0][0]["error"].is_null(), "{hardfork:?}: {response}"); + assert!(response[0][1]["error"].is_null(), "{hardfork:?}: {response}"); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_trace_call_many_executes_calls() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + let from = handle.dev_accounts().next().unwrap(); + let recipient = Address::random(); + let token = IERC20::new(PATH_USD, &provider); + let balance = token.balanceOf(from).call().await.unwrap(); + let request = tempo_call_request( + from, + [tempo_transfer(recipient, balance), tempo_transfer(recipient, U256::ONE)], + ); + + let response = provider + .raw_request::<_, serde_json::Value>( + "trace_callMany".into(), + (serde_json::json!([[request, ["trace"]]]), "latest"), + ) + .await + .unwrap(); + + assert!( + response[0]["trace"] + .as_array() + .is_some_and(|traces| traces.iter().any(|trace| !trace["error"].is_null())) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_trace_call_many_distinguishes_expiring_nonce_transactions() { + for hardfork in [TempoHardfork::T1, TempoHardfork::T1B] { + let (_api, handle) = spawn( + NodeConfig::test_tempo() + .with_hardfork(Some(hardfork.into())) + .with_genesis_timestamp(Some(1u64)), + ) + .await; + let provider = handle.http_provider(); + let from = handle.dev_accounts().next().unwrap(); + let request = |target| { + serde_json::json!({ + "from": from, + "type": "0x76", + "nonceKey": U256::MAX, + "gas": "0x1e8480", + "validBefore": 25, + "calls": [{"to": target, "value": "0x0", "input": "0x"}], + }) + }; + + let response = provider + .raw_request::<_, serde_json::Value>( + "trace_callMany".into(), + ( + serde_json::json!([ + [request(Address::random()), ["trace"]], + [request(Address::random()), ["trace"]], + ]), + "latest", + ), + ) + .await + .unwrap(); + + let results = response.as_array().unwrap(); + assert_eq!(results.len(), 2, "{hardfork:?}: {response}"); + for result in results { + let traces = result["trace"].as_array().unwrap(); + assert!(!traces.is_empty(), "{hardfork:?}: {response}"); + assert!( + traces.iter().all(|trace| trace["error"].is_null()), + "{hardfork:?}: {response}" + ); + } + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_call_supports_top_level_create() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + let from = handle.dev_accounts().next().unwrap(); + + let output = provider + .raw_request::<_, Bytes>( + "eth_call".into(), + (serde_json::json!({ + "from": from, + "type": "0x76", + "gas": "0xf4240", + "data": "0x602a5f526001601ff3", + }),), + ) + .await + .unwrap(); + + assert_eq!(output, Bytes::from_static(&[0x2a])); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_simulate_executes_and_returns_the_same_batch() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + let from = handle.dev_accounts().next().unwrap(); + let recipient = Address::random(); + let calls = [tempo_transfer(recipient, U256::ONE), tempo_transfer(recipient, U256::from(2))]; + let payload = serde_json::json!({ + "blockStateCalls": [{ + "calls": [tempo_call_request(from, calls.clone())], + }], + "returnFullTransactions": true, + }); + + let response = provider + .raw_request::<_, serde_json::Value>("eth_simulateV1".into(), (payload,)) + .await + .unwrap(); + assert_eq!(response[0]["calls"][0]["status"], "0x1"); + assert_eq!(response[0]["calls"][0]["logs"].as_array().unwrap().len(), 2); + assert_eq!(response[0]["transactions"][0]["type"], "0x76"); + assert_eq!(response[0]["transactions"][0]["calls"], serde_json::to_value(calls).unwrap()); + + let transaction: AASigned = + serde_json::from_value(response[0]["transactions"][0].clone()).unwrap(); + let transaction_hash = *transaction.hash(); + assert_eq!(response[0]["transactions"][0]["hash"], serde_json::json!(transaction_hash)); + assert!( + response[0]["calls"][0]["logs"] + .as_array() + .unwrap() + .iter() + .all(|log| { log["transactionHash"] == serde_json::json!(transaction_hash) }) + ); + + let transactions_root = calculate_transaction_root(&[FoundryTxEnvelope::Tempo(transaction)]); + assert_eq!(response[0]["transactionsRoot"], serde_json::json!(transactions_root)); + + let cumulative_gas_used = u64::from_str_radix( + response[0]["calls"][0]["gasUsed"].as_str().unwrap().trim_start_matches("0x"), + 16, + ) + .unwrap(); + let logs = serde_json::from_value::>( + response[0]["calls"][0]["logs"].clone(), + ) + .unwrap() + .into_iter() + .map(|log| log.inner) + .collect(); + let receipt = FoundryReceiptEnvelope::Tempo( + Receipt { status: Eip658Value::Eip658(true), cumulative_gas_used, logs }.with_bloom(), + ); + assert_eq!(response[0]["receiptsRoot"], serde_json::json!(calculate_receipt_root(&[receipt]))); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_simulate_reverted_batch_discards_logs() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + let from = handle.dev_accounts().next().unwrap(); + let recipient = Address::random(); + let balance = IERC20::new(PATH_USD, &provider).balanceOf(from).call().await.unwrap(); + let reverted = tempo_call_request( + from, + [tempo_transfer(recipient, balance), tempo_transfer(recipient, U256::ONE)], + ); + let succeeds = tempo_call_request(from, [tempo_transfer(recipient, U256::ONE)]); + let payload = serde_json::json!({ + "blockStateCalls": [{"calls": [reverted, succeeds]}], + }); + + let response = provider + .raw_request::<_, serde_json::Value>("eth_simulateV1".into(), (payload,)) + .await + .unwrap(); + assert_eq!(response[0]["calls"][0]["status"], "0x0"); + assert_eq!(response[0]["calls"][0]["logs"], serde_json::json!([])); + assert_eq!(response[0]["calls"][1]["status"], "0x1"); + assert_eq!(response[0]["calls"][1]["logs"][0]["logIndex"], "0x1"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_simulate_uses_default_response_signature_for_key_type_hints() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + let from = handle.dev_accounts().next().unwrap(); + + for key_type in ["p256", "webAuthn"] { + let payload = serde_json::json!({ + "blockStateCalls": [{ + "calls": [{ + "from": from, + "type": "0x76", + "gas": "0x1e8480", + "keyType": key_type, + "calls": [{"to": Address::random(), "value": "0x0", "input": "0x"}], + }], + }], + "returnFullTransactions": true, + }); + let response = provider + .raw_request::<_, serde_json::Value>("eth_simulateV1".into(), (payload,)) + .await + .unwrap(); + let transaction: AASigned = + serde_json::from_value(response[0]["transactions"][0].clone()).unwrap(); + assert_eq!(transaction.signature(), &TempoSignature::default()); + assert_eq!( + response[0]["transactionsRoot"], + serde_json::json!(calculate_transaction_root(&[FoundryTxEnvelope::Tempo(transaction)])) + ); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_simulate_preserves_sponsor_when_capping_execution_gas() { + let (_api, handle) = spawn(NodeConfig::test_tempo().with_gas_limit(Some(100_000_000))).await; + let provider = handle.http_provider(); + let accounts: Vec<_> = handle.dev_accounts().collect(); + let gas_limit = 50_000_000; + let sponsored = sponsored_tempo_request( + accounts[0], + provider.get_chain_id().await.unwrap(), + provider.get_gas_price().await.unwrap(), + gas_limit, + vec![Call { to: TxKind::Call(Address::random()), value: U256::ZERO, input: Bytes::new() }], + ) + .await; + let payload = serde_json::json!({ + "blockStateCalls": [{ + "calls": [ + tempo_call_request(accounts[2], [tempo_transfer(Address::random(), U256::ONE)]), + sponsored, + ], + }], + "returnFullTransactions": true, + }); + + let response = provider + .raw_request::<_, serde_json::Value>("eth_simulateV1".into(), (payload,)) + .await + .unwrap(); + assert_eq!(response[0]["calls"][1]["status"], "0x1"); + let transaction: AASigned = + serde_json::from_value(response[0]["transactions"][1].clone()).unwrap(); + + assert_eq!(transaction.tx().gas_limit, gas_limit); + assert_eq!(transaction.tx().recover_fee_payer(accounts[0]).unwrap(), accounts[1]); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_simulate_distinguishes_expiring_nonce_transactions() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + let from = handle.dev_accounts().next().unwrap(); + let recipient = Address::random(); + let valid_before = + provider.get_block(BlockId::latest()).await.unwrap().unwrap().header.timestamp + 25; + let request = |amount| { + serde_json::json!({ + "from": from, + "type": "0x76", + "nonceKey": U256::MAX, + "gas": "0x1e8480", + "validBefore": valid_before, + "calls": [tempo_transfer(recipient, U256::from(amount))], + }) + }; + let payload = serde_json::json!({ + "blockStateCalls": [{"calls": [request(1), request(2)]}], + "returnFullTransactions": true, + }); + + let response = provider + .raw_request::<_, serde_json::Value>("eth_simulateV1".into(), (payload,)) + .await + .unwrap(); + assert_eq!(response[0]["calls"][0]["status"], "0x1"); + assert_eq!(response[0]["calls"][1]["status"], "0x1"); + assert_ne!(response[0]["transactions"][0]["hash"], response[0]["transactions"][1]["hash"]); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_simulate_rejects_expiring_nonce_replay_with_different_fee_payer_signature() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + let from = handle.dev_accounts().next().unwrap(); + let target = Address::random(); + let chain_id = provider.get_chain_id().await.unwrap(); + let gas_price = provider.get_gas_price().await.unwrap(); + let valid_before = + provider.get_block(BlockId::latest()).await.unwrap().unwrap().header.timestamp + 25; + let calls = vec![Call { to: TxKind::Call(target), value: U256::ZERO, input: Bytes::new() }]; + let tempo_tx = TempoTransaction { + chain_id, + fee_token: Some(ALPHA_USD), + max_priority_fee_per_gas: gas_price / 10, + max_fee_per_gas: gas_price * 2, + gas_limit: 2_000_000, + calls: calls.clone(), + access_list: Default::default(), + nonce_key: U256::MAX, + nonce: 0, + fee_payer_signature: Some(Signature::new(U256::ZERO, U256::ZERO, false)), + valid_before: NonZeroU64::new(valid_before), + valid_after: None, + key_authorization: None, + tempo_authorization_list: vec![], + }; + let fee_payer_hash = tempo_tx.fee_payer_signature_hash(from); + let first_signature = dev_key(1).sign_hash(&fee_payer_hash).await.unwrap(); + let second_signature = dev_key(2).sign_hash(&fee_payer_hash).await.unwrap(); + let request = |fee_payer_signature| { + serde_json::json!({ + "from": from, + "type": "0x76", + "chainId": chain_id, + "nonceKey": U256::MAX, + "nonce": "0x0", + "gas": "0x1e8480", + "maxFeePerGas": tempo_tx.max_fee_per_gas, + "maxPriorityFeePerGas": tempo_tx.max_priority_fee_per_gas, + "feeToken": ALPHA_USD, + "validBefore": valid_before, + "feePayerSignature": fee_payer_signature, + "calls": calls, + }) + }; + let payload = serde_json::json!({ + "blockStateCalls": [{ + "calls": [request(first_signature), request(second_signature)], + }], + }); + + let error = provider + .raw_request::<_, serde_json::Value>("eth_simulateV1".into(), (payload,)) + .await + .unwrap_err(); + assert!(error.to_string().contains("ExpiringNonceReplay"), "unexpected error: {error}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_simulate_applies_state_and_block_overrides() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + let from = handle.dev_accounts().next().unwrap(); + let target = Address::random(); + let nonce_key = U256::from(9); + let nonce_slot = NonceManager::new().nonces[from][nonce_key].slot(); + let timestamp = + provider.get_block(BlockId::latest()).await.unwrap().unwrap().header.timestamp + 100; + let payload = serde_json::json!({ + "blockStateCalls": [{ + "blockOverrides": {"time": format!("{timestamp:#x}")}, + "stateOverrides": { + (target.to_string()): {"code": "0x4260005260206000f3"}, + (NONCE_PRECOMPILE_ADDRESS.to_string()): { + "stateDiff": {(format!("{nonce_slot:#066x}")): format!("{:#066x}", U256::from(7))} + } + }, + "calls": [{ + "from": from, + "type": "0x76", + "nonceKey": nonce_key, + "gas": "0x1e8480", + "calls": [{"to": target, "value": "0x0", "input": "0x"}], + }], + }], + "returnFullTransactions": true, + }); + + let response = provider + .raw_request::<_, serde_json::Value>("eth_simulateV1".into(), (payload,)) + .await + .unwrap(); + assert_eq!(response[0]["transactions"][0]["nonce"], "0x7"); + assert_eq!( + U256::from_be_slice( + &response[0]["calls"][0]["returnData"].as_str().unwrap().parse::().unwrap() + ), + U256::from(timestamp) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_simulate_increments_protocol_nonce_for_call_batches() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + let from = handle.dev_accounts().next().unwrap(); + let request = tempo_call_request( + from, + [Call { to: TxKind::Call(Address::random()), value: U256::ZERO, input: Bytes::new() }], + ); + let payload = serde_json::json!({ + "blockStateCalls": [{ + "calls": [request.clone(), request], + }], + "returnFullTransactions": true, + }); + + let response = provider + .raw_request::<_, serde_json::Value>("eth_simulateV1".into(), (payload,)) + .await + .unwrap(); + let first: AASigned = serde_json::from_value(response[0]["transactions"][0].clone()).unwrap(); + let second: AASigned = serde_json::from_value(response[0]["transactions"][1].clone()).unwrap(); + assert_eq!(first.tx().nonce, 0); + assert_eq!(second.tx().nonce, 1); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_simulate_resolves_omitted_lane_nonces() { + let (api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + let from = handle.dev_accounts().next().unwrap(); + let existing_nonce_key = U256::ONE; + let existing_nonce = 3; + let slot = NonceManager::new().nonces[from][existing_nonce_key].slot(); + let valid_before = + provider.get_block(BlockId::latest()).await.unwrap().unwrap().header.timestamp + 25; + api.anvil_set_storage_at( + NONCE_PRECOMPILE_ADDRESS, + slot, + B256::from(U256::from(existing_nonce)), + ) + .await + .unwrap(); + let omitted_nonce_request = serde_json::json!({ + "from": from, + "type": "0x76", + "nonceKey": existing_nonce_key, + "gas": "0x1e8480", + "calls": [{"to": Address::random(), "value": "0x0", "input": "0x"}], + }); + let filled = provider + .raw_request::<_, serde_json::Value>( + "eth_fillTransaction".into(), + (omitted_nonce_request.clone(),), + ) + .await + .unwrap(); + assert_eq!(filled["tx"]["nonce"], "0x3"); + provider + .raw_request::<_, Bytes>("eth_signTransaction".into(), (omitted_nonce_request.clone(),)) + .await + .unwrap(); + let payload = serde_json::json!({ + "blockStateCalls": [{ + "calls": [ + { + "from": from, + "type": "0x76", + "nonceKey": "0x1", + "gas": "0x1e8480", + "calls": [{"to": Address::random(), "value": "0x0", "input": "0x"}], + }, + { + "from": from, + "type": "0x76", + "nonceKey": "0x2", + "gas": "0x1e8480", + "calls": [{"to": Address::random(), "value": "0x0", "input": "0x"}], + }, + { + "from": from, + "type": "0x76", + "nonceKey": U256::MAX, + "gas": "0x1e8480", + "validBefore": valid_before, + "calls": [{"to": Address::random(), "value": "0x0", "input": "0x"}], + } + ], + }], + "returnFullTransactions": true, + }); + + let response = provider + .raw_request::<_, serde_json::Value>("eth_simulateV1".into(), (payload,)) + .await + .unwrap(); + assert_eq!(response[0]["transactions"][0]["nonce"], "0x3"); + assert_eq!(response[0]["transactions"][1]["nonce"], "0x0"); + assert_eq!(response[0]["transactions"][2]["nonce"], "0x0"); + + let tx_hash = provider + .raw_request::<_, B256>("eth_sendTransaction".into(), (omitted_nonce_request,)) + .await + .unwrap(); + assert_ne!(tx_hash, B256::ZERO); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_estimate_and_access_list_execute_all_calls() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + let from = handle.dev_accounts().next().unwrap(); + let recipient = Address::random(); + let token = IERC20::new(PATH_USD, &provider); + let balance = token.balanceOf(from).call().await.unwrap(); + let cheap_estimate = provider + .raw_request::<_, U256>( + "eth_estimateGas".into(), + (serde_json::json!({ + "from": from, + "type": "0x76", + "nonce": "0x1", + "gas": "0x1e8480", + "calls": [{"to": Address::random(), "value": "0x0", "input": "0x"}], + }),), + ) + .await + .unwrap(); + assert!(cheap_estimate < U256::from(53_000), "unexpected estimate: {cheap_estimate}"); + + let failing = tempo_call_request( + from, + [tempo_transfer(recipient, balance), tempo_transfer(recipient, U256::ONE)], + ); + let err = provider + .raw_request::<_, U256>("eth_estimateGas".into(), (failing.clone(),)) + .await + .unwrap_err(); + assert!(err.to_string().contains("revert"), "unexpected error: {err}"); + + let access_list = provider + .raw_request::<_, serde_json::Value>("eth_createAccessList".into(), (failing,)) + .await + .unwrap(); + assert!(access_list["error"].as_str().is_some_and(|error| error.contains("revert"))); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_estimate_gas_preserves_fee_payer_recovery_across_probes() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + let accounts: Vec<_> = handle.dev_accounts().collect(); + let token = IERC20::new(PATH_USD, &provider); + let request = sponsored_tempo_request( + accounts[0], + provider.get_chain_id().await.unwrap(), + provider.get_gas_price().await.unwrap(), + 2_000_000, + vec![Call { + to: TxKind::Call(PATH_USD), + value: U256::ZERO, + input: token.transfer(accounts[2], U256::ONE).calldata().clone(), + }], + ) + .await; + + let gas = provider.raw_request::<_, U256>("eth_estimateGas".into(), (request,)).await.unwrap(); + + assert!(gas < U256::from(2_000_000)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_estimate_gas_with_key_authorization_limits() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + let account = handle.dev_accounts().next().unwrap(); + let recipient = Address::random(); + let signer = dev_key(0); + let access_key = PrivateKeySigner::random(); + let chain_id = provider.get_chain_id().await.unwrap(); + + let authorization = + KeyAuthorization::unrestricted(chain_id, SignatureType::Secp256k1, access_key.address()) + .with_limits(vec![TokenLimit { token: PATH_USD, limit: U256::ONE, period: 0 }]); + let signature = signer.sign_hash(&authorization.signature_hash()).await.unwrap(); + let authorization = authorization.into_signed(PrimitiveSignature::Secp256k1(signature)); + let request = serde_json::json!({ + "from": account, + "type": "0x76", + "feeToken": PATH_USD, + "keyId": access_key.address(), + "keyAuthorization": authorization, + "calls": [tempo_transfer(recipient, U256::ZERO)], + }); + + let gas = provider + .raw_request::<_, U256>("eth_estimateGas".into(), (request.clone(),)) + .await + .unwrap(); + assert!(gas > U256::from(21_000)); + + let mut over_limit = request; + over_limit["calls"] = serde_json::json!([tempo_transfer(recipient, U256::from(2))]); + let err = + provider.raw_request::<_, U256>("eth_estimateGas".into(), (over_limit,)).await.unwrap_err(); + assert!(err.to_string().contains("execution reverted"), "unexpected error: {err}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_estimate_gas_with_provisioned_key() { + let (api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + let account = handle.dev_accounts().next().unwrap(); + let recipient = Address::random(); + let access_key = PrivateKeySigner::random(); + let spending_limit = U256::from(1_000_000); + let authorize = authorizeKeyCall { + keyId: access_key.address(), + signatureType: KeychainSignatureType::Secp256k1, + config: KeyRestrictions { + expiry: u64::MAX, + enforceLimits: true, + limits: vec![KeychainTokenLimit { token: PATH_USD, amount: spending_limit, period: 0 }], + allowAnyCalls: true, + allowedCalls: vec![], + }, + }; + let authorization_request = serde_json::json!({ + "from": account, + "type": "0x76", + "gas": T5_PRECOMPILE_GAS, + "feeToken": PATH_USD, + "calls": [{ + "to": ACCOUNT_KEYCHAIN_ADDRESS, + "value": "0x0", + "input": Bytes::from(authorize.abi_encode()), + }], + }); + let tx_hash = provider + .raw_request::<_, B256>("eth_sendTransaction".into(), (authorization_request,)) + .await + .unwrap(); + api.mine_one().await.unwrap(); + let receipt = provider.get_transaction_receipt(tx_hash).await.unwrap().unwrap(); + assert!(receipt.status()); + + let chain_id = provider.get_chain_id().await.unwrap(); + let base_fee = provider.get_gas_price().await.unwrap(); + let nonce = provider.get_transaction_count(account).await.unwrap(); + let transfer_amount = U256::ONE; + let transfer = tempo_transfer(recipient, transfer_amount); + let request = serde_json::json!({ + "from": account, + "type": "0x76", + "chainId": chain_id, + "feeToken": PATH_USD, + "maxPriorityFeePerGas": base_fee / 10, + "maxFeePerGas": base_fee * 2, + "nonce": nonce, + "keyId": access_key.address(), + "calls": [transfer], + }); + let gas = provider + .raw_request::<_, U256>("eth_estimateGas".into(), (request.clone(),)) + .await + .unwrap(); + let gas_limit = gas.to::(); + + let token = IERC20::new(PATH_USD, &provider); + let recipient_balance = token.balanceOf(recipient).call().await.unwrap(); + let tempo_tx = TempoTransaction { + chain_id, + fee_token: Some(PATH_USD), + max_priority_fee_per_gas: base_fee / 10, + max_fee_per_gas: base_fee * 2, + gas_limit, + calls: vec![transfer], + access_list: Default::default(), + nonce_key: U256::ZERO, + nonce, + fee_payer_signature: None, + valid_before: None, + valid_after: None, + key_authorization: None, + tempo_authorization_list: vec![], + }; + let signing_hash = KeychainSignature::signing_hash(tempo_tx.signature_hash(), account); + let signature = access_key.sign_hash(&signing_hash).await.unwrap(); + let tempo_signature = TempoSignature::Keychain(KeychainSignature::new( + account, + PrimitiveSignature::Secp256k1(signature), + )); + let envelope = TempoTxEnvelope::AA(tempo_tx.into_signed(tempo_signature)); + let mut encoded = Vec::new(); + envelope.encode_2718(&mut encoded); + + let receipt = + provider.send_raw_transaction(&encoded).await.unwrap().get_receipt().await.unwrap(); + assert!(receipt.status(), "transaction should succeed with estimated gas: {gas_limit}"); + assert!( + receipt.gas_used() <= gas_limit, + "gas used ({}) should not exceed estimate ({gas_limit})", + receipt.gas_used() + ); + assert_eq!( + token.balanceOf(recipient).call().await.unwrap(), + recipient_balance + transfer_amount + ); + + let mut over_limit = request; + over_limit["nonce"] = serde_json::json!(nonce + 1); + over_limit["calls"] = + serde_json::json!([tempo_transfer(recipient, spending_limit + U256::ONE)]); + let err = + provider.raw_request::<_, U256>("eth_estimateGas".into(), (over_limit,)).await.unwrap_err(); + assert!(err.to_string().contains("execution reverted"), "unexpected error: {err}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_create_access_list_accepts_sponsored_request_on_second_execution() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + let from = handle.dev_accounts().next().unwrap(); + let target = Address::random(); + let request = sponsored_tempo_request( + from, + provider.get_chain_id().await.unwrap(), + provider.get_gas_price().await.unwrap(), + 2_000_000, + vec![Call { to: TxKind::Call(target), value: U256::ZERO, input: Bytes::new() }], + ) + .await; + let state_override = serde_json::json!({ + (target.to_string()): { + "code": "0x60005460005260206000f3", + }, + }); + + let result = provider + .raw_request::<_, serde_json::Value>( + "eth_createAccessList".into(), + (request, serde_json::Value::Null, state_override), + ) + .await + .unwrap(); + + assert!(result["error"].is_null(), "unexpected execution error: {result}"); + assert!(!result["accessList"].as_array().unwrap().is_empty()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_trace_call_executes_all_calls() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + let from = handle.dev_accounts().next().unwrap(); + let recipient = Address::random(); + let token = IERC20::new(PATH_USD, &provider); + let balance = token.balanceOf(from).call().await.unwrap(); + let request = tempo_call_request( + from, + [tempo_transfer(recipient, balance), tempo_transfer(recipient, U256::ONE)], + ); + + let debug_trace = provider + .raw_request::<_, serde_json::Value>( + "debug_traceCall".into(), + (request.clone(), "latest", serde_json::json!({})), + ) + .await + .unwrap(); + assert_eq!(debug_trace["failed"], true); + + let parity_trace = provider + .raw_request::<_, serde_json::Value>("trace_call".into(), (request, ["trace"], "latest")) + .await + .unwrap(); + assert!( + parity_trace["trace"] + .as_array() + .is_some_and(|traces| { traces.iter().any(|trace| !trace["error"].is_null()) }) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_aa_transaction_with_2d_nonce() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let recipient = accounts[1]; + let signer = dev_key(0); + + let token = IERC20::new(PATH_USD, &provider); + let chain_id = provider.get_chain_id().await.unwrap(); + let base_fee = provider.get_gas_price().await.unwrap(); + + // Send two transactions with different nonce keys (can be parallelized) + let nonce_keys = [U256::ONE, U256::from(2)]; + + for (i, nonce_key) in nonce_keys.iter().enumerate() { + let transfer_amount = U256::from(50_000 * (i + 1) as u64); + let transfer_call = token.transfer(recipient, transfer_amount); + let calldata: Bytes = transfer_call.calldata().clone(); + + let tempo_tx = TempoTransaction { + chain_id, + fee_token: Some(ALPHA_USD), + max_priority_fee_per_gas: base_fee / 10, + max_fee_per_gas: base_fee * 2, + gas_limit: TIP20_TRANSFER_GAS, + calls: vec![Call { to: TxKind::Call(PATH_USD), value: U256::ZERO, input: calldata }], + access_list: Default::default(), + nonce_key: *nonce_key, + nonce: 0, + fee_payer_signature: None, + valid_before: None, + valid_after: None, + key_authorization: None, + tempo_authorization_list: vec![], + }; + + let sig_hash = tempo_tx.signature_hash(); + let signature = signer.sign_hash(&sig_hash).await.unwrap(); + let envelope = TempoTxEnvelope::AA(tempo_tx.into_signed(signature.into())); + + let mut encoded = Vec::new(); + envelope.encode_2718(&mut encoded); + let tx_hash = provider.send_raw_transaction(&encoded).await.unwrap(); + let receipt = tx_hash.get_receipt().await.unwrap(); + + assert!(receipt.status(), "Tempo AA transaction with nonce_key {nonce_key} should succeed"); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_nonzero_lane_pending_tx_does_not_advance_scalar_nonce() { + let (api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + api.anvil_set_auto_mine(false).await.unwrap(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let recipient = accounts[1]; + let signer = dev_key(0); + let from = signer.address(); + + let pending_nonce = + provider.get_transaction_count(from).block_id(BlockId::pending()).await.unwrap(); + assert_eq!(pending_nonce, 0); + + let token = IERC20::new(PATH_USD, &provider); + let transfer_call = token.transfer(recipient, U256::from(50_000)); + let calldata: Bytes = transfer_call.calldata().clone(); + + let chain_id = provider.get_chain_id().await.unwrap(); + let base_fee = provider.get_gas_price().await.unwrap(); + + let tempo_tx = TempoTransaction { + chain_id, + fee_token: Some(ALPHA_USD), + max_priority_fee_per_gas: base_fee / 10, + max_fee_per_gas: base_fee * 2, + gas_limit: TIP20_TRANSFER_GAS, + calls: vec![Call { to: TxKind::Call(PATH_USD), value: U256::ZERO, input: calldata }], + access_list: Default::default(), + nonce_key: U256::from(42), + nonce: 7, + fee_payer_signature: None, + valid_before: None, + valid_after: None, + key_authorization: None, + tempo_authorization_list: vec![], + }; + + let sig_hash = tempo_tx.signature_hash(); + let signature = signer.sign_hash(&sig_hash).await.unwrap(); + let envelope = TempoTxEnvelope::AA(tempo_tx.into_signed(signature.into())); + + let mut encoded = Vec::new(); + envelope.encode_2718(&mut encoded); + let _ = provider.send_raw_transaction(&encoded).await.unwrap(); + + let pending_nonce = + provider.get_transaction_count(from).block_id(BlockId::pending()).await.unwrap(); + assert_eq!(pending_nonce, 0); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_txpool_orders_same_nonce_lane() { + let (api, handle) = spawn( + NodeConfig::test_tempo() + .with_no_mining(true) + .with_transaction_order(TransactionOrder::Fifo), + ) + .await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let recipient = accounts[1]; + let signer = dev_key(0); + let token = IERC20::new(PATH_USD, &provider); + let initial_balance = token.balanceOf(recipient).call().await.unwrap(); + let chain_id = provider.get_chain_id().await.unwrap(); + let base_fee = provider.get_gas_price().await.unwrap(); + let current_time = + provider.get_block(BlockId::latest()).await.unwrap().unwrap().header.timestamp; + let valid_after = current_time + 5; + let nonce_key = U256::from(42); + let mut tx_hashes = Vec::new(); + + for (nonce, amount) in [(1, 60_000), (0, 50_000)] { + let calldata: Bytes = token.transfer(recipient, U256::from(amount)).calldata().clone(); + let tempo_tx = TempoTransaction { + chain_id, + fee_token: Some(ALPHA_USD), + max_priority_fee_per_gas: base_fee / 10, + max_fee_per_gas: base_fee * 2, + gas_limit: TIP20_TRANSFER_GAS, + calls: vec![Call { to: TxKind::Call(PATH_USD), value: U256::ZERO, input: calldata }], + nonce_key, + nonce, + valid_after: if nonce == 0 { NonZeroU64::new(valid_after) } else { None }, + ..Default::default() + }; + let signature = signer.sign_hash(&tempo_tx.signature_hash()).await.unwrap(); + let signed_tx = tempo_tx.into_signed(signature.into()); + let mut encoded = Vec::new(); + TempoTxEnvelope::AA(signed_tx).encode_2718(&mut encoded); + let pending = provider.send_raw_transaction(&encoded).await.unwrap(); + tx_hashes.push(*pending.tx_hash()); + + let status = provider.txpool_status().await.unwrap(); + if nonce == 1 { + assert_eq!((status.pending, status.queued), (0, 1)); + } else { + assert_eq!((status.pending, status.queued), (2, 0)); + } + } + + api.mine_one().await.unwrap(); + + for hash in &tx_hashes { + assert!(provider.get_transaction_receipt(*hash).await.unwrap().is_none()); + } + assert_eq!(token.balanceOf(recipient).call().await.unwrap(), initial_balance); + let status = provider.txpool_status().await.unwrap(); + assert_eq!((status.pending, status.queued), (2, 0)); + + api.evm_set_next_block_timestamp(valid_after + 1).unwrap(); + api.mine_one().await.unwrap(); + + let later = provider.get_transaction_receipt(tx_hashes[0]).await.unwrap().unwrap(); + let predecessor = provider.get_transaction_receipt(tx_hashes[1]).await.unwrap().unwrap(); + assert!(later.status()); + assert!(predecessor.status()); + assert_eq!(predecessor.transaction_index(), Some(0)); + assert_eq!(later.transaction_index(), Some(1)); + assert_eq!( + token.balanceOf(recipient).call().await.unwrap(), + initial_balance + U256::from(110_000) + ); + let status = provider.txpool_status().await.unwrap(); + assert_eq!((status.pending, status.queued), (0, 0)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_txpool_mines_successor_after_lane_nonce_fast_forward() { + let (api, handle) = spawn(NodeConfig::test_tempo().with_no_mining(true)).await; + let provider = handle.http_provider(); + let sender = dev_key(0); + let recipient = handle.dev_accounts().nth(1).unwrap(); + let token = IERC20::new(PATH_USD, &provider); + let initial_balance = token.balanceOf(recipient).call().await.unwrap(); + let chain_id = provider.get_chain_id().await.unwrap(); + let base_fee = provider.get_gas_price().await.unwrap(); + let nonce_key = U256::from(42); + let mut hashes = Vec::new(); + + for nonce in 0..=1 { + let calldata = token.transfer(recipient, U256::from(50_000)).calldata().clone(); + let tx = TempoTransaction { + chain_id, + fee_token: Some(ALPHA_USD), + max_priority_fee_per_gas: base_fee / 10, + max_fee_per_gas: base_fee * 2, + gas_limit: TIP20_TRANSFER_GAS + u64::from(nonce == 0), + calls: vec![Call { to: TxKind::Call(PATH_USD), value: U256::ZERO, input: calldata }], + nonce_key, + nonce, + ..Default::default() + }; + let signature = sender.sign_hash(&tx.signature_hash()).await.unwrap(); + let signed = tx.into_signed(signature.into()); + let mut encoded = Vec::new(); + TempoTxEnvelope::AA(signed).encode_2718(&mut encoded); + hashes.push(*provider.send_raw_transaction(&encoded).await.unwrap().tx_hash()); + } + + let slot = NonceManager::new().nonces[sender.address()][nonce_key].slot(); + api.anvil_set_storage_at(NONCE_PRECOMPILE_ADDRESS, slot, B256::with_last_byte(1)) + .await + .unwrap(); + api.evm_set_block_gas_limit(U256::from(TIP20_TRANSFER_GAS)).unwrap(); + api.mine_one().await.unwrap(); + + assert!(provider.get_transaction_receipt(hashes[0]).await.unwrap().is_none()); + let receipt = provider.get_transaction_receipt(hashes[1]).await.unwrap().unwrap(); + assert!(receipt.status()); + assert_eq!( + token.balanceOf(recipient).call().await.unwrap(), + initial_balance + U256::from(50_000) + ); + let status = provider.txpool_status().await.unwrap(); + assert_eq!((status.pending, status.queued), (0, 0)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_txpool_keeps_nonzero_nonce_lanes_separate() { + let (api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + api.anvil_set_auto_mine(false).await.unwrap(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let recipient = accounts[1]; + let signer = dev_key(0); + let from = signer.address(); + + let token = IERC20::new(PATH_USD, &provider); + let transfer_call = token.transfer(recipient, U256::from(50_000)); + let calldata: Bytes = transfer_call.calldata().clone(); + + let chain_id = provider.get_chain_id().await.unwrap(); + let base_fee = provider.get_gas_price().await.unwrap(); + let nonce_keys = [U256::from(100), U256::from(101)]; + + for nonce_key in nonce_keys { + let tempo_tx = TempoTransaction { + chain_id, + fee_token: Some(ALPHA_USD), + max_priority_fee_per_gas: base_fee / 10, + max_fee_per_gas: base_fee * 2, + gas_limit: TIP20_TRANSFER_GAS, + calls: vec![Call { + to: TxKind::Call(PATH_USD), + value: U256::ZERO, + input: calldata.clone(), + }], + nonce_key, + ..Default::default() + }; + + let sig_hash = tempo_tx.signature_hash(); + let signature = signer.sign_hash(&sig_hash).await.unwrap(); + let envelope = TempoTxEnvelope::AA(tempo_tx.into_signed(signature.into())); + + let mut encoded = Vec::new(); + envelope.encode_2718(&mut encoded); + let _ = provider.send_raw_transaction(&encoded).await.unwrap(); + } + + let inspect = provider.txpool_inspect().await.unwrap(); + let pending = inspect.pending.get(&from).unwrap(); + assert_eq!(pending.len(), 2); + assert!(pending.contains_key("100:0")); + assert!(pending.contains_key("101:0")); + + let content = provider.txpool_content().await.unwrap(); + let pending = content.pending.get(&from).unwrap(); + assert_eq!(pending.len(), 2); + assert!(pending.contains_key("100:0")); + assert!(pending.contains_key("101:0")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_aa_transaction_with_valid_before() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let recipient = accounts[1]; + let signer = dev_key(0); + + let token = IERC20::new(PATH_USD, &provider); + let chain_id = provider.get_chain_id().await.unwrap(); + let base_fee = provider.get_gas_price().await.unwrap(); + + let block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + let current_time = block.header.timestamp; + let valid_before = current_time + 30; + + let transfer_call = token.transfer(recipient, U256::from(75_000)); + let calldata: Bytes = transfer_call.calldata().clone(); + + let tempo_tx = TempoTransaction { + chain_id, + fee_token: Some(ALPHA_USD), + max_priority_fee_per_gas: base_fee / 10, + max_fee_per_gas: base_fee * 2, + gas_limit: TIP20_TRANSFER_GAS, + calls: vec![Call { to: TxKind::Call(PATH_USD), value: U256::ZERO, input: calldata }], + access_list: Default::default(), + nonce_key: U256::from(3), + nonce: 0, + fee_payer_signature: None, + valid_before: NonZeroU64::new(valid_before), + valid_after: None, + key_authorization: None, + tempo_authorization_list: vec![], + }; + + let sig_hash = tempo_tx.signature_hash(); + let signature = signer.sign_hash(&sig_hash).await.unwrap(); + let envelope = TempoTxEnvelope::AA(tempo_tx.into_signed(signature.into())); + + let mut encoded = Vec::new(); + envelope.encode_2718(&mut encoded); + let tx_hash = provider.send_raw_transaction(&encoded).await.unwrap(); + let receipt = tx_hash.get_receipt().await.unwrap(); + + assert!(receipt.status(), "Tempo AA transaction with valid_before should succeed"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_aa_transaction_with_valid_after() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let recipient = accounts[1]; + let signer = dev_key(0); + + let token = IERC20::new(PATH_USD, &provider); + let chain_id = provider.get_chain_id().await.unwrap(); + let base_fee = provider.get_gas_price().await.unwrap(); + + let block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + let current_time = block.header.timestamp; + let valid_after = current_time; + let valid_before = current_time + 30; + + let transfer_call = token.transfer(recipient, U256::from(60_000)); + let calldata: Bytes = transfer_call.calldata().clone(); + + let tempo_tx = TempoTransaction { + chain_id, + fee_token: Some(ALPHA_USD), + max_priority_fee_per_gas: base_fee / 10, + max_fee_per_gas: base_fee * 2, + gas_limit: TIP20_TRANSFER_GAS, + calls: vec![Call { to: TxKind::Call(PATH_USD), value: U256::ZERO, input: calldata }], + access_list: Default::default(), + nonce_key: U256::from(4), + nonce: 0, + fee_payer_signature: None, + valid_before: NonZeroU64::new(valid_before), + valid_after: NonZeroU64::new(valid_after), + key_authorization: None, + tempo_authorization_list: vec![], + }; + + let sig_hash = tempo_tx.signature_hash(); + let signature = signer.sign_hash(&sig_hash).await.unwrap(); + let envelope = TempoTxEnvelope::AA(tempo_tx.into_signed(signature.into())); + + let mut encoded = Vec::new(); + envelope.encode_2718(&mut encoded); + let tx_hash = provider.send_raw_transaction(&encoded).await.unwrap(); + let receipt = tx_hash.get_receipt().await.unwrap(); + + assert!( + receipt.status(), + "Tempo AA transaction with valid_after (already valid) should succeed" + ); +} + +// ============================================================================ +// Tempo AA Transaction Error Cases +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_aa_expired_valid_before() { + let (api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let recipient = accounts[1]; + let signer = dev_key(0); + + let token = IERC20::new(PATH_USD, &provider); + let chain_id = provider.get_chain_id().await.unwrap(); + let base_fee = provider.get_gas_price().await.unwrap(); + + let block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + let current_time = block.header.timestamp; + // Pin the clock: the next block timestamp is otherwise `latest + 1`. + api.evm_set_next_block_timestamp(current_time).unwrap(); + let valid_before = current_time.saturating_sub(10); // 10 seconds ago + + let transfer_call = token.transfer(recipient, U256::from(50_000)); + let calldata: Bytes = transfer_call.calldata().clone(); + + let tempo_tx = TempoTransaction { + chain_id, + fee_token: Some(ALPHA_USD), + max_priority_fee_per_gas: base_fee / 10, + max_fee_per_gas: base_fee * 2, + gas_limit: TIP20_TRANSFER_GAS, + calls: vec![Call { to: TxKind::Call(PATH_USD), value: U256::ZERO, input: calldata }], + access_list: Default::default(), + nonce_key: U256::from(100), + nonce: 0, + fee_payer_signature: None, + valid_before: NonZeroU64::new(valid_before), + valid_after: None, + key_authorization: None, + tempo_authorization_list: vec![], + }; + + let sig_hash = tempo_tx.signature_hash(); + let signature = signer.sign_hash(&sig_hash).await.unwrap(); + let envelope = TempoTxEnvelope::AA(tempo_tx.into_signed(signature.into())); + + let mut encoded = Vec::new(); + envelope.encode_2718(&mut encoded); + + let result = provider.send_raw_transaction(&encoded).await; + assert_eq!( + result.unwrap_err().to_string(), + format!( + "server returned an error response: error code -32003: Tempo tx valid_before ({valid_before}) must be > current time + 3s ({})", + current_time + 3 + ) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_aa_valid_after_future() { + let (api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let recipient = accounts[1]; + let signer = dev_key(0); + + let token = IERC20::new(PATH_USD, &provider); + let chain_id = provider.get_chain_id().await.unwrap(); + let base_fee = provider.get_gas_price().await.unwrap(); + + let block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + let current_time = block.header.timestamp; + let valid_after = current_time + 5; + let valid_before = current_time + 60; + let transfer_amount = U256::from(50_000); + let recipient_balance_before = token.balanceOf(recipient).call().await.unwrap(); + + let transfer_call = token.transfer(recipient, transfer_amount); + let calldata: Bytes = transfer_call.calldata().clone(); + + let tempo_tx = TempoTransaction { + chain_id, + fee_token: Some(ALPHA_USD), + max_priority_fee_per_gas: base_fee / 10, + max_fee_per_gas: base_fee * 2, + gas_limit: TIP20_TRANSFER_GAS, + calls: vec![Call { to: TxKind::Call(PATH_USD), value: U256::ZERO, input: calldata }], + access_list: Default::default(), + nonce_key: U256::from(101), + nonce: 0, + fee_payer_signature: None, + valid_before: NonZeroU64::new(valid_before), + valid_after: NonZeroU64::new(valid_after), + key_authorization: None, + tempo_authorization_list: vec![], + }; + + let sig_hash = tempo_tx.signature_hash(); + let signature = signer.sign_hash(&sig_hash).await.unwrap(); + let envelope = TempoTxEnvelope::AA(tempo_tx.into_signed(signature.into())); + + let mut encoded = Vec::new(); + envelope.encode_2718(&mut encoded); + + // Transaction enters pool but is not yet valid + let pending = provider.send_raw_transaction(&encoded).await.unwrap(); + let tx_hash = *pending.tx_hash(); + + api.mine_one().await.unwrap(); + let receipt = provider.get_transaction_receipt(tx_hash).await.unwrap(); + assert!(receipt.is_none(), "Transaction should not be mined before valid_after"); + let recipient_balance = token.balanceOf(recipient).call().await.unwrap(); + assert_eq!(recipient_balance, recipient_balance_before); + + // Advance time past valid_after + api.evm_set_next_block_timestamp(valid_after + 1).unwrap(); + api.mine_one().await.unwrap(); + + let receipt = pending.get_receipt().await.unwrap(); + assert!(receipt.status(), "Transaction should succeed after valid_after time"); + let recipient_balance = token.balanceOf(recipient).call().await.unwrap(); + assert_eq!(recipient_balance, recipient_balance_before + transfer_amount); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_aa_valid_after_pool_limit() { + // Mirrors Tempo's `DEFAULT_AA_VALID_AFTER_MAX_SECS`. + const MAX_VALID_AFTER_SECS: u64 = 120; + + let (api, handle) = spawn(NodeConfig::test_tempo().with_no_mining(true)).await; + let provider = handle.http_provider(); + let accounts: Vec
= handle.dev_accounts().collect(); + let token = IERC20::new(PATH_USD, &provider); + let chain_id = provider.get_chain_id().await.unwrap(); + let base_fee = provider.get_gas_price().await.unwrap(); + let block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + let pool_time = block.header.timestamp + 1; + api.evm_set_next_block_timestamp(pool_time).unwrap(); + let calldata: Bytes = token.transfer(accounts[1], U256::ONE).calldata().clone(); + + for (offset, accepted) in [(MAX_VALID_AFTER_SECS, true), (MAX_VALID_AFTER_SECS + 1, false)] { + let tempo_tx = TempoTransaction { + chain_id, + fee_token: Some(ALPHA_USD), + max_priority_fee_per_gas: base_fee / 10, + max_fee_per_gas: base_fee * 2, + gas_limit: TIP20_TRANSFER_GAS, + calls: vec![Call { + to: TxKind::Call(PATH_USD), + value: U256::ZERO, + input: calldata.clone(), + }], + access_list: Default::default(), + nonce_key: U256::from(offset), + nonce: 0, + fee_payer_signature: None, + valid_before: None, + valid_after: NonZeroU64::new(pool_time + offset), + key_authorization: None, + tempo_authorization_list: vec![], + }; + let signature = dev_key(0).sign_hash(&tempo_tx.signature_hash()).await.unwrap(); + let signed_tx = tempo_tx.into_signed(signature.into()); + let mut encoded = Vec::new(); + TempoTxEnvelope::AA(signed_tx).encode_2718(&mut encoded); + + let result = provider.send_raw_transaction(&encoded).await; + assert_eq!( + result.is_ok(), + accepted, + "unexpected pool admission with valid_after offset {offset}: {result:?}" + ); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_aa_nonce_replay_same_key() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let recipient = accounts[1]; + let signer = dev_key(0); + + let token = IERC20::new(PATH_USD, &provider); + let chain_id = provider.get_chain_id().await.unwrap(); + let base_fee = provider.get_gas_price().await.unwrap(); + + let nonce_key = U256::from(200); + + // First transaction with nonce 0 + let transfer_call = token.transfer(recipient, U256::from(50_000)); + let calldata: Bytes = transfer_call.calldata().clone(); + + let tempo_tx1 = TempoTransaction { + chain_id, + fee_token: Some(ALPHA_USD), + max_priority_fee_per_gas: base_fee / 10, + max_fee_per_gas: base_fee * 2, + gas_limit: TIP20_TRANSFER_GAS, + calls: vec![Call { + to: TxKind::Call(PATH_USD), + value: U256::ZERO, + input: calldata.clone(), + }], + access_list: Default::default(), + nonce_key, + nonce: 0, + fee_payer_signature: None, + valid_before: None, + valid_after: None, + key_authorization: None, + tempo_authorization_list: vec![], + }; + + let sig_hash = tempo_tx1.signature_hash(); + let signature = signer.sign_hash(&sig_hash).await.unwrap(); + let envelope = TempoTxEnvelope::AA(tempo_tx1.into_signed(signature.into())); + + let mut encoded = Vec::new(); + envelope.encode_2718(&mut encoded); + + let tx_hash = provider.send_raw_transaction(&encoded).await.unwrap(); + let receipt = tx_hash.get_receipt().await.unwrap(); + assert!(receipt.status(), "First transaction should succeed"); + + // Second transaction with nonce=1 on the same key should succeed + let transfer_call2 = token.transfer(recipient, U256::from(60_000)); + let calldata2: Bytes = transfer_call2.calldata().clone(); + + let tempo_tx2 = TempoTransaction { + chain_id, + fee_token: Some(ALPHA_USD), + max_priority_fee_per_gas: base_fee / 10, + max_fee_per_gas: base_fee * 2, + gas_limit: TIP20_TRANSFER_GAS, + calls: vec![Call { to: TxKind::Call(PATH_USD), value: U256::ZERO, input: calldata2 }], + access_list: Default::default(), + nonce_key, + nonce: 1, + fee_payer_signature: None, + valid_before: None, + valid_after: None, + key_authorization: None, + tempo_authorization_list: vec![], + }; + + let sig_hash = tempo_tx2.signature_hash(); + let signature = signer.sign_hash(&sig_hash).await.unwrap(); + let envelope = TempoTxEnvelope::AA(tempo_tx2.into_signed(signature.into())); + + let mut encoded = Vec::new(); + envelope.encode_2718(&mut encoded); + + let tx_hash2 = provider.send_raw_transaction(&encoded).await.unwrap(); + let receipt2 = tx_hash2.get_receipt().await.unwrap(); + assert!(receipt2.status(), "Second transaction with nonce=1 should succeed"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_aa_parallel_nonces_different_keys() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let recipient = accounts[1]; + let signer = dev_key(0); + + let token = IERC20::new(PATH_USD, &provider); + let chain_id = provider.get_chain_id().await.unwrap(); + let base_fee = provider.get_gas_price().await.unwrap(); + + let recipient_balance_before = token.balanceOf(recipient).call().await.unwrap(); + + // Send two transactions with the SAME nonce (0) but DIFFERENT nonce keys + let mut tx_hashes = vec![]; + + for nonce_key_val in [300u64, 301u64] { + let transfer_call = token.transfer(recipient, U256::from(10_000)); + let calldata: Bytes = transfer_call.calldata().clone(); + + let tempo_tx = TempoTransaction { + chain_id, + fee_token: Some(ALPHA_USD), + max_priority_fee_per_gas: base_fee / 10, + max_fee_per_gas: base_fee * 2, + gas_limit: TIP20_TRANSFER_GAS, + calls: vec![Call { to: TxKind::Call(PATH_USD), value: U256::ZERO, input: calldata }], + access_list: Default::default(), + nonce_key: U256::from(nonce_key_val), + nonce: 0, + fee_payer_signature: None, + valid_before: None, + valid_after: None, + key_authorization: None, + tempo_authorization_list: vec![], + }; + + let sig_hash = tempo_tx.signature_hash(); + let signature = signer.sign_hash(&sig_hash).await.unwrap(); + let envelope = TempoTxEnvelope::AA(tempo_tx.into_signed(signature.into())); + + let mut encoded = Vec::new(); + envelope.encode_2718(&mut encoded); + + let tx_hash = provider.send_raw_transaction(&encoded).await.unwrap(); + tx_hashes.push(tx_hash); + } + + for tx_hash in tx_hashes { + let receipt = tx_hash.get_receipt().await.unwrap(); + assert!(receipt.status(), "Parallel transactions with different nonce keys should succeed"); + } + + let recipient_balance_after = token.balanceOf(recipient).call().await.unwrap(); + assert_eq!( + recipient_balance_after, + recipient_balance_before + U256::from(20_000), + "Recipient should receive both transfers" + ); +} + +// ============================================================================ +// Gas Estimation +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_gas_estimation() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + + let token = IERC20::new(ALPHA_USD, &provider); + let transfer_call = token.transfer(accounts[1], U256::from(1000)); + let calldata: Bytes = transfer_call.calldata().clone(); + + let tx = TransactionRequest::default().from(accounts[0]).to(ALPHA_USD).with_input(calldata); + + let gas_estimate = provider.estimate_gas(tx.into()).await.unwrap(); + + // TIP20 transfer should use more than 21000 gas + assert!(gas_estimate > 21000, "TIP20 transfer should use more than 21000 gas"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_gas_estimation_for_contract_call() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + + let token = IERC20::new(ALPHA_USD, &provider); + let transfer_call = token.transfer(accounts[1], U256::from(1000)); + let calldata: Bytes = transfer_call.calldata().clone(); + + let tx = TransactionRequest::default().from(accounts[0]).to(ALPHA_USD).with_input(calldata); + + let gas_estimate = provider.estimate_gas(tx.into()).await.unwrap(); + + // Contract call should use more gas than simple transfer + assert!(gas_estimate > 21000, "Contract call should use more than 21000 gas"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_gas_estimation_with_value_fails() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + + // Gas estimation with native value should fail in Tempo mode + let tx = TransactionRequest::default() + .from(accounts[0]) + .to(accounts[1]) + .value(U256::from(1_000_000_000_000_000_000u64)); + + let result = provider.estimate_gas(tx.into()).await; + assert_eq!( + result.unwrap_err().to_string(), + "server returned an error response: error code -32603: tempo transaction error: value transfer not allowed" + ); +} + +/// An Ethereum-typed request without a nonce must be estimated at the sender's current nonce, not +/// as a first transaction, which Tempo charges the account-creation cost for. +#[tokio::test(flavor = "multi_thread")] +async fn test_gas_estimation_without_nonce_uses_sender_nonce() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let sender = accounts[0]; + + let calldata = IERC20::approveCall { spender: accounts[1], amount: U256::ZERO }.abi_encode(); + let tx = TransactionRequest::default().from(sender).to(PATH_USD).with_input(calldata); + + provider + .send_transaction(WithOtherFields::new(tx.clone().with_gas_limit(TIP20_TRANSFER_GAS))) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + let nonce = provider.get_transaction_count(sender).await.unwrap(); + assert_eq!(nonce, 1); + + let without_nonce = provider.estimate_gas(WithOtherFields::new(tx.clone())).await.unwrap(); + let with_nonce = provider.estimate_gas(WithOtherFields::new(tx.nonce(nonce))).await.unwrap(); + assert_eq!(without_nonce, with_nonce); +} + +// ============================================================================ +// Gas Price & Base Fee +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_gas_price() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let gas_price = provider.get_gas_price().await.unwrap(); + + assert!(gas_price > 0, "Gas price should be non-zero"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_base_fee() { + let (api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + api.mine_one().await.unwrap(); + + let block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + + assert!(block.header.base_fee_per_gas.is_some()); +} + +// ============================================================================ +// Fee Token Deduction +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_eip1559_fee_token_deduction() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let sender = accounts[0]; + let recipient = accounts[1]; + + // Check fee token balance before (ALPHA_USD is the default fee token) + let fee_token = IERC20::new(ALPHA_USD, &provider); + let fee_balance_before = fee_token.balanceOf(sender).call().await.unwrap(); + + // Transfer PATH_USD so balance change is only from gas fees, not the transfer itself + let token = IERC20::new(PATH_USD, &provider); + let transfer_call = token.transfer(recipient, U256::from(100_000)); + let calldata: Bytes = transfer_call.calldata().clone(); + + let base_fee = provider.get_gas_price().await.unwrap(); + + let tx = TransactionRequest::default() + .from(sender) + .to(PATH_USD) + .with_input(calldata) + .with_gas_limit(TIP20_TRANSFER_GAS) + .max_fee_per_gas(base_fee * 2) + .max_priority_fee_per_gas(base_fee / 10); + + let tx = WithOtherFields::new(tx); + let receipt = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + + assert!(receipt.status(), "Transaction should succeed"); + + // Fee token balance should have decreased (gas fees paid in ALPHA_USD) + let fee_balance_after = fee_token.balanceOf(sender).call().await.unwrap(); + assert!( + fee_balance_after < fee_balance_before, + "Fee token balance should decrease after paying gas (before: {fee_balance_before}, after: {fee_balance_after})" + ); +} + +// ============================================================================ +// Anvil Control: Set Balance +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_anvil_set_balance() { + let (api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let random_address = Address::random(); + let new_balance = U256::from(1_000_000_000_000_000_000u64); + + let balance_before = provider.get_balance(random_address).await.unwrap(); + assert_eq!(balance_before, U256::ZERO); + + api.anvil_set_balance(random_address, new_balance).await.unwrap(); + + let balance_after = provider.get_balance(random_address).await.unwrap(); + assert_eq!(balance_after, new_balance); +} + +// ============================================================================ +// Anvil Control: Set Code +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_anvil_set_code() { + let (api, _handle) = spawn(NodeConfig::test_tempo()).await; + + let target = Address::random(); + + let code_before = api.get_code(target, None).await.unwrap(); + assert!(code_before.is_empty()); + + let bytecode = vec![0x60, 0x00, 0x60, 0x00, 0xf3]; // PUSH 0, PUSH 0, RETURN + api.anvil_set_code(target, bytecode.clone().into()).await.unwrap(); + + let code_after = api.get_code(target, None).await.unwrap(); + assert_eq!(code_after.as_ref(), bytecode.as_slice()); +} + +// ============================================================================ +// Anvil Control: Auto Mine Toggle +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_auto_mine_toggle() { + let (api, _handle) = spawn(NodeConfig::test_tempo()).await; + + assert!(api.anvil_get_auto_mine().unwrap()); + + api.anvil_set_auto_mine(false).await.unwrap(); + assert!(!api.anvil_get_auto_mine().unwrap()); + + api.anvil_set_auto_mine(true).await.unwrap(); + assert!(api.anvil_get_auto_mine().unwrap()); +} + +// ============================================================================ +// Anvil Control: Manual Mining +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_manual_mining() { + let (api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let block_before = provider.get_block_number().await.unwrap(); + + api.mine_one().await.unwrap(); + + let block_after = provider.get_block_number().await.unwrap(); + assert_eq!(block_after, block_before + 1); + + api.anvil_mine(Some(U256::from(5)), None).await.unwrap(); + + let block_final = provider.get_block_number().await.unwrap(); + assert_eq!(block_final, block_after + 5); +} + +// ============================================================================ +// Anvil Control: Impersonate Account +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_impersonate_account() { + let (api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let impersonated = handle.dev_accounts().next().unwrap(); + let recipient = handle.dev_accounts().nth(1).unwrap(); + + api.anvil_impersonate_account(impersonated).await.unwrap(); + + let token = IERC20::new(ALPHA_USD, &provider); + let transfer_call = token.transfer(recipient, U256::from(1000)); + let calldata: Bytes = transfer_call.calldata().clone(); + + let tx = TransactionRequest::default() + .from(impersonated) + .to(ALPHA_USD) + .with_input(calldata) + .with_gas_limit(TIP20_TRANSFER_GAS); + + let tx = WithOtherFields::new(tx); + let receipt = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + + assert!(receipt.status()); + + api.anvil_stop_impersonating_account(impersonated).await.unwrap(); +} + +// ============================================================================ +// Anvil Control: Snapshot and Revert +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_snapshot_and_revert() { + let (api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let from = accounts[0]; + let to = accounts[1]; + + let token = IERC20::new(ALPHA_USD, &provider); + let balance_before = token.balanceOf(to).call().await.unwrap(); + let block_before = provider.get_block_number().await.unwrap(); + + let snapshot_id = api.evm_snapshot().await.unwrap(); + + let transfer_call = token.transfer(to, U256::from(1_000_000)); + let calldata: Bytes = transfer_call.calldata().clone(); + + let tx = TransactionRequest::default() + .from(from) + .to(ALPHA_USD) + .with_input(calldata) + .with_gas_limit(TIP20_TRANSFER_GAS); + + let tx = WithOtherFields::new(tx); + provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + + let balance_after_tx = token.balanceOf(to).call().await.unwrap(); + assert!(balance_after_tx > balance_before); + + api.evm_revert(snapshot_id).await.unwrap(); + + let balance_reverted = token.balanceOf(to).call().await.unwrap(); + let block_reverted = provider.get_block_number().await.unwrap(); + + assert_eq!(balance_reverted, balance_before); + assert_eq!(block_reverted, block_before); +} + +// ============================================================================ +// Block & Chain: Tempo Mode Enabled +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_mode_enabled_by_default() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let block_number = provider.get_block_number().await.unwrap(); + assert_eq!(block_number, 0); +} + +// ============================================================================ +// Block & Chain: Fee Tokens Deployed +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_fee_tokens_deployed() { + let (api, _handle) = spawn(NodeConfig::test_tempo()).await; + + for token in [PATH_USD, ALPHA_USD, BETA_USD, THETA_USD] { + let code = api.get_code(token, None).await.unwrap(); + assert!(!code.is_empty(), "Token {token} should have code deployed"); + } +} + +// ============================================================================ +// Block & Chain: Block Has Timestamp +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_block_has_timestamp() { + let (api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + api.mine_one().await.unwrap(); + + let block = provider.get_block(1.into()).await.unwrap().unwrap(); + assert!(block.header.timestamp > 0, "Block should have a timestamp"); +} + +// ============================================================================ +// Block & Chain: Block Timestamp Increases +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_block_timestamp_increases() { + let (api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + api.mine_one().await.unwrap(); + let block1 = provider.get_block(1.into()).await.unwrap().unwrap(); + + let future_timestamp = block1.header.timestamp + 100; + api.evm_set_next_block_timestamp(future_timestamp).unwrap(); + + api.mine_one().await.unwrap(); + let block2 = provider.get_block(2.into()).await.unwrap().unwrap(); + + assert_eq!(block2.header.timestamp, future_timestamp); + assert!(block2.header.timestamp > block1.header.timestamp); +} + +// ============================================================================ +// Block & Chain: Block Timestamps Are Monotonic +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_block_timestamps_are_monotonic() { + let (api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + api.mine_one().await.unwrap(); + let block1 = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + let timestamp1 = block1.header.timestamp; + + let future_timestamp = timestamp1 + 10; + api.evm_set_next_block_timestamp(future_timestamp).unwrap(); + + api.mine_one().await.unwrap(); + let block2 = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + let timestamp2 = block2.header.timestamp; + + assert!( + timestamp2 > timestamp1, + "Block timestamps must be strictly increasing: {timestamp2} should be > {timestamp1}", + ); + assert_eq!(timestamp2, future_timestamp, "Block timestamp should match the set value"); +} + +// ============================================================================ +// Block & Chain: Block Gas Limit +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_block_gas_limit() { + let (api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + api.mine_one().await.unwrap(); + + let block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + + assert!(block.header.gas_limit > 0); +} + +// ============================================================================ +// Block & Chain: Transaction Respects Gas Limit +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_transaction_respects_gas_limit() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + + let token = IERC20::new(ALPHA_USD, &provider); + let transfer_call = token.transfer(accounts[1], U256::from(1000)); + let calldata: Bytes = transfer_call.calldata().clone(); + + let tx = TransactionRequest::default() + .from(accounts[0]) + .to(ALPHA_USD) + .with_input(calldata) + .with_gas_limit(TIP20_TRANSFER_GAS); + + let tx = WithOtherFields::new(tx); + let receipt = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + + assert!(receipt.status()); + assert!(receipt.gas_used <= TIP20_TRANSFER_GAS); +} + +// ============================================================================ +// Block & Chain: Multiple Transactions in Block +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_multiple_transactions_in_block() { + let (api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + api.anvil_set_auto_mine(false).await.unwrap(); + + let accounts: Vec
= handle.dev_accounts().collect(); + + let token = IERC20::new(ALPHA_USD, &provider); + + let transfer1 = token.transfer(accounts[1], U256::from(1000)); + let calldata1: Bytes = transfer1.calldata().clone(); + let tx1 = TransactionRequest::default() + .from(accounts[0]) + .to(ALPHA_USD) + .with_input(calldata1) + .with_gas_limit(TIP20_TRANSFER_GAS); + + let transfer2 = token.transfer(accounts[3], U256::from(2000)); + let calldata2: Bytes = transfer2.calldata().clone(); + let tx2 = TransactionRequest::default() + .from(accounts[2]) + .to(ALPHA_USD) + .with_input(calldata2) + .with_gas_limit(TIP20_TRANSFER_GAS); + + let tx1 = WithOtherFields::new(tx1); + let tx2 = WithOtherFields::new(tx2); + + let pending1 = provider.send_transaction(tx1).await.unwrap(); + let pending2 = provider.send_transaction(tx2).await.unwrap(); + + api.mine_one().await.unwrap(); + + let receipt1 = pending1.get_receipt().await.unwrap(); + let receipt2 = pending2.get_receipt().await.unwrap(); + + assert_eq!(receipt1.block_number, receipt2.block_number); +} + +// ============================================================================ +// Block & Chain: Chain ID +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_chain_id() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let chain_id = provider.get_chain_id().await.unwrap(); + assert_eq!(chain_id, 31337); +} + +// ============================================================================ +// Block & Chain: Custom Chain ID +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_custom_chain_id() { + let custom_chain_id = 42069u64; + let (_api, handle) = spawn(NodeConfig::test_tempo().with_chain_id(Some(custom_chain_id))).await; + let provider = handle.http_provider(); + + let chain_id = provider.get_chain_id().await.unwrap(); + assert_eq!(chain_id, custom_chain_id); +} + +// ============================================================================ +// Tempo AA: Expiring Nonce Transaction +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_aa_transaction_expiring_nonce() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let recipient = accounts[1]; + let signer = dev_key(0); + + let token = IERC20::new(PATH_USD, &provider); + let chain_id = provider.get_chain_id().await.unwrap(); + let base_fee = provider.get_gas_price().await.unwrap(); + + let block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + let current_time = block.header.timestamp; + let valid_before = current_time + 25; + + let transfer_call = token.transfer(recipient, U256::from(80_000)); + let calldata: Bytes = transfer_call.calldata().clone(); + + let tempo_tx = TempoTransaction { + chain_id, + fee_token: Some(ALPHA_USD), + max_priority_fee_per_gas: base_fee / 10, + max_fee_per_gas: base_fee * 2, + gas_limit: TIP20_TRANSFER_GAS, + calls: vec![Call { to: TxKind::Call(PATH_USD), value: U256::ZERO, input: calldata }], + access_list: Default::default(), + nonce_key: U256::MAX, + nonce: 0, + fee_payer_signature: None, + valid_before: NonZeroU64::new(valid_before), + valid_after: None, + key_authorization: None, + tempo_authorization_list: vec![], + }; + + let sig_hash = tempo_tx.signature_hash(); + let signature = signer.sign_hash(&sig_hash).await.unwrap(); + let envelope = TempoTxEnvelope::AA(tempo_tx.into_signed(signature.into())); + + let mut encoded = Vec::new(); + envelope.encode_2718(&mut encoded); + let tx_hash = provider.send_raw_transaction(&encoded).await.unwrap(); + let receipt = tx_hash.get_receipt().await.unwrap(); + + assert!(receipt.status(), "Tempo AA transaction with expiring nonce should succeed"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_expiring_nonce_valid_before_pool_limits() { + for (hardfork, max_expiry_secs, enforce_limit) in [ + (TempoHardfork::T0, 30, false), + (TempoHardfork::T1, 30, true), + (TempoHardfork::T10, 30, true), + (TempoHardfork::T11, 300, true), + ] { + let (api, handle) = spawn( + NodeConfig::test_tempo().with_hardfork(Some(hardfork.into())).with_no_mining(true), + ) + .await; + let provider = handle.http_provider(); + let accounts: Vec
= handle.dev_accounts().collect(); + let token = IERC20::new(PATH_USD, &provider); + let chain_id = provider.get_chain_id().await.unwrap(); + let base_fee = provider.get_gas_price().await.unwrap(); + let block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + let pool_time = block.header.timestamp + 1; + api.evm_set_next_block_timestamp(pool_time).unwrap(); + let calldata: Bytes = token.transfer(accounts[1], U256::ONE).calldata().clone(); + + for (offset, accepted) in [(max_expiry_secs, true), (max_expiry_secs + 1, !enforce_limit)] { + let tempo_tx = TempoTransaction { + chain_id, + fee_token: Some(ALPHA_USD), + max_priority_fee_per_gas: base_fee / 10, + max_fee_per_gas: base_fee * 2, + gas_limit: TIP20_TRANSFER_GAS, + calls: vec![Call { + to: TxKind::Call(PATH_USD), + value: U256::ZERO, + input: calldata.clone(), + }], + access_list: Default::default(), + nonce_key: U256::MAX, + nonce: 0, + fee_payer_signature: None, + valid_before: NonZeroU64::new(pool_time + offset), + valid_after: None, + key_authorization: None, + tempo_authorization_list: vec![], + }; + let signature = dev_key(0).sign_hash(&tempo_tx.signature_hash()).await.unwrap(); + let signed_tx = tempo_tx.into_signed(signature.into()); + let mut encoded = Vec::new(); + TempoTxEnvelope::AA(signed_tx).encode_2718(&mut encoded); + + let result = provider.send_raw_transaction(&encoded).await; + assert_eq!( + result.is_ok(), + accepted, + "unexpected pool admission at {hardfork} with valid_before offset {offset}: {result:?}" + ); + } + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_estimate_gas_expiring_nonce_at_genesis() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + let accounts: Vec
= handle.dev_accounts().collect(); + + let block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + assert_eq!(block.header.number, 0); + + let max_nonce_key = "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; + let tx: WithOtherFields = WithOtherFields { + inner: TransactionRequest::default().from(accounts[0]).to(accounts[1]).with_nonce(0), + other: [ + ("nonceKey".to_string(), serde_json::json!(max_nonce_key)), + ("validBefore".to_string(), serde_json::json!(block.header.timestamp + 20)), + ] + .into_iter() + .collect(), + }; + + // Before the first mined block, the expiry window must be checked against the genesis + // timestamp rather than the EVM's default block timestamp. + provider.estimate_gas(tx).block(BlockId::latest()).await.unwrap(); +} + +// ============================================================================ +// Tempo AA: Expiring Nonce Replay +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_aa_expiring_nonce_replay() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let recipient = accounts[1]; + let signer = dev_key(0); + + let token = IERC20::new(PATH_USD, &provider); + let chain_id = provider.get_chain_id().await.unwrap(); + let base_fee = provider.get_gas_price().await.unwrap(); + + let block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + let current_time = block.header.timestamp; + let valid_before = current_time + 25; + + let transfer_call = token.transfer(recipient, U256::from(50_000)); + let calldata: Bytes = transfer_call.calldata().clone(); + + let tempo_tx = TempoTransaction { + chain_id, + fee_token: Some(ALPHA_USD), + max_priority_fee_per_gas: base_fee / 10, + max_fee_per_gas: base_fee * 2, + gas_limit: TIP20_TRANSFER_GAS, + calls: vec![Call { to: TxKind::Call(PATH_USD), value: U256::ZERO, input: calldata }], + access_list: Default::default(), + nonce_key: U256::MAX, + nonce: 0, + fee_payer_signature: None, + valid_before: NonZeroU64::new(valid_before), + valid_after: None, + key_authorization: None, + tempo_authorization_list: vec![], + }; + + let sig_hash = tempo_tx.signature_hash(); + let signature = signer.sign_hash(&sig_hash).await.unwrap(); + let envelope = TempoTxEnvelope::AA(tempo_tx.into_signed(signature.into())); + + let mut encoded = Vec::new(); + envelope.encode_2718(&mut encoded); + + let tx_hash = provider.send_raw_transaction(&encoded).await.unwrap(); + let first_tx_hash = *tx_hash.tx_hash(); + let receipt = tx_hash.get_receipt().await.unwrap(); + assert!(receipt.status(), "First expiring nonce transaction should succeed"); + + // Replay the exact same transaction bytes + let result = provider.send_raw_transaction(&encoded).await; + + if let Ok(pending) = result { + let second_tx_hash = *pending.tx_hash(); + assert_eq!( + first_tx_hash, second_tx_hash, + "Replaying same transaction should return same tx hash (not execute again)" + ); + } +} + +// ============================================================================ +// Tempo AA: Multiple Calls in Single Transaction +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_aa_transaction_multiple_calls() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let recipient1 = accounts[1]; + let recipient2 = accounts[2]; + let signer = dev_key(0); + + let token = IERC20::new(PATH_USD, &provider); + let chain_id = provider.get_chain_id().await.unwrap(); + let base_fee = provider.get_gas_price().await.unwrap(); + + let recipient1_balance_before = token.balanceOf(recipient1).call().await.unwrap(); + let recipient2_balance_before = token.balanceOf(recipient2).call().await.unwrap(); + + let amount1 = U256::from(25_000); + let amount2 = U256::from(35_000); + + let call1_data: Bytes = token.transfer(recipient1, amount1).calldata().clone(); + let call2_data: Bytes = token.transfer(recipient2, amount2).calldata().clone(); + + let tempo_tx = TempoTransaction { + chain_id, + fee_token: Some(ALPHA_USD), + max_priority_fee_per_gas: base_fee / 10, + max_fee_per_gas: base_fee * 2, + gas_limit: TIP20_TRANSFER_GAS * 2, + calls: vec![ + Call { to: TxKind::Call(PATH_USD), value: U256::ZERO, input: call1_data }, + Call { to: TxKind::Call(PATH_USD), value: U256::ZERO, input: call2_data }, + ], + access_list: Default::default(), + nonce_key: U256::from(5), + nonce: 0, + fee_payer_signature: None, + valid_before: None, + valid_after: None, + key_authorization: None, + tempo_authorization_list: vec![], + }; + + let sig_hash = tempo_tx.signature_hash(); + let signature = signer.sign_hash(&sig_hash).await.unwrap(); + let envelope = TempoTxEnvelope::AA(tempo_tx.into_signed(signature.into())); + + let mut encoded = Vec::new(); + envelope.encode_2718(&mut encoded); + let tx_hash = provider.send_raw_transaction(&encoded).await.unwrap(); + let receipt = tx_hash.get_receipt().await.unwrap(); + + assert!(receipt.status(), "Tempo AA transaction with multiple calls should succeed"); + + let recipient1_balance_after = token.balanceOf(recipient1).call().await.unwrap(); + let recipient2_balance_after = token.balanceOf(recipient2).call().await.unwrap(); + + assert_eq!(recipient1_balance_after, recipient1_balance_before + amount1); + assert_eq!(recipient2_balance_after, recipient2_balance_before + amount2); +} + +// ============================================================================ +// Tempo AA: Nonce Keys Are Isolated +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_aa_nonce_keys_are_isolated() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let recipient = accounts[1]; + let signer = dev_key(0); + + let token = IERC20::new(PATH_USD, &provider); + let chain_id = provider.get_chain_id().await.unwrap(); + let base_fee = provider.get_gas_price().await.unwrap(); + + let transfer_call = token.transfer(recipient, U256::from(10_000)); + let calldata: Bytes = transfer_call.calldata().clone(); + + let tempo_tx1 = TempoTransaction { + chain_id, + fee_token: Some(ALPHA_USD), + max_priority_fee_per_gas: base_fee / 10, + max_fee_per_gas: base_fee * 2, + gas_limit: TIP20_TRANSFER_GAS, + calls: vec![Call { + to: TxKind::Call(PATH_USD), + value: U256::ZERO, + input: calldata.clone(), + }], + access_list: Default::default(), + nonce_key: U256::from(100), + nonce: 0, + fee_payer_signature: None, + valid_before: None, + valid_after: None, + key_authorization: None, + tempo_authorization_list: vec![], + }; + + let sig_hash = tempo_tx1.signature_hash(); + let signature = signer.sign_hash(&sig_hash).await.unwrap(); + let envelope = TempoTxEnvelope::AA(tempo_tx1.into_signed(signature.into())); + + let mut encoded = Vec::new(); + envelope.encode_2718(&mut encoded); + + let tx_hash = provider.send_raw_transaction(&encoded).await.unwrap(); + let receipt = tx_hash.get_receipt().await.unwrap(); + assert!(receipt.status(), "First tx with nonce_key=100 should succeed"); + + let tempo_tx2 = TempoTransaction { + chain_id, + fee_token: Some(ALPHA_USD), + max_priority_fee_per_gas: base_fee / 10, + max_fee_per_gas: base_fee * 2, + gas_limit: TIP20_TRANSFER_GAS, + calls: vec![Call { to: TxKind::Call(PATH_USD), value: U256::ZERO, input: calldata }], + access_list: Default::default(), + nonce_key: U256::from(101), + nonce: 0, + fee_payer_signature: None, + valid_before: None, + valid_after: None, + key_authorization: None, + tempo_authorization_list: vec![], + }; + + let sig_hash = tempo_tx2.signature_hash(); + let signature = signer.sign_hash(&sig_hash).await.unwrap(); + let envelope = TempoTxEnvelope::AA(tempo_tx2.into_signed(signature.into())); + + let mut encoded = Vec::new(); + envelope.encode_2718(&mut encoded); + + let tx_hash2 = provider.send_raw_transaction(&encoded).await.unwrap(); + let receipt2 = tx_hash2.get_receipt().await.unwrap(); + assert!( + receipt2.status(), + "Tx with different nonce_key should succeed even with same nonce value" + ); +} + +// ============================================================================ +// Tempo AA: Explicit Fee Token Selection +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_aa_explicit_fee_token_selection() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let sender = accounts[0]; + let recipient = accounts[1]; + let signer = dev_key(0); + + let chain_id = provider.get_chain_id().await.unwrap(); + let base_fee = provider.get_gas_price().await.unwrap(); + + let path_token = IERC20::new(PATH_USD, &provider); + let alpha_token = IERC20::new(ALPHA_USD, &provider); + let path_balance_before = path_token.balanceOf(sender).call().await.unwrap(); + let alpha_balance_before = alpha_token.balanceOf(sender).call().await.unwrap(); + + let transfer_call = path_token.transfer(recipient, U256::from(10_000)); + let calldata: Bytes = transfer_call.calldata().clone(); + + let tempo_tx = TempoTransaction { + chain_id, + fee_token: Some(PATH_USD), + max_priority_fee_per_gas: base_fee / 10, + max_fee_per_gas: base_fee * 2, + gas_limit: TIP20_TRANSFER_GAS, + calls: vec![Call { to: TxKind::Call(PATH_USD), value: U256::ZERO, input: calldata }], + access_list: Default::default(), + nonce_key: U256::from(400), + nonce: 0, + fee_payer_signature: None, + valid_before: None, + valid_after: None, + key_authorization: None, + tempo_authorization_list: vec![], + }; + + let sig_hash = tempo_tx.signature_hash(); + let signature = signer.sign_hash(&sig_hash).await.unwrap(); + let envelope = TempoTxEnvelope::AA(tempo_tx.into_signed(signature.into())); + + let mut encoded = Vec::new(); + envelope.encode_2718(&mut encoded); + + let tx_hash = provider.send_raw_transaction(&encoded).await.unwrap(); + let receipt = tx_hash.get_receipt().await.unwrap(); + assert!(receipt.status(), "Transaction with explicit fee token should succeed"); + + let path_balance_after = path_token.balanceOf(sender).call().await.unwrap(); + let alpha_balance_after = alpha_token.balanceOf(sender).call().await.unwrap(); + + assert!( + path_balance_after < path_balance_before, + "PATH_USD balance should decrease (transfer + fees)" + ); + assert_eq!( + alpha_balance_after, alpha_balance_before, + "ALPHA_USD balance should not change when using PATH_USD for fees" + ); +} + +// ============================================================================ +// Tempo AA: Fee Token Swap (Different Tokens) +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_fee_token_swap_different_tokens() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let sender = accounts[0]; + let recipient = accounts[1]; + + let alpha_token = IERC20::new(ALPHA_USD, &provider); + let alice_alpha_before = alpha_token.balanceOf(sender).call().await.unwrap(); + + let token = IERC20::new(PATH_USD, &provider); + let transfer_call = token.transfer(recipient, U256::from(100_000)); + let calldata: Bytes = transfer_call.calldata().clone(); + + let base_fee = provider.get_gas_price().await.unwrap(); + + let tx = TransactionRequest::default() + .from(sender) + .to(PATH_USD) + .with_input(calldata) + .with_gas_limit(TIP20_TRANSFER_GAS) + .max_fee_per_gas(base_fee * 2) + .max_priority_fee_per_gas(base_fee / 10); + + let tx = WithOtherFields::new(tx); + let receipt = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + + assert!(receipt.status(), "Transaction should succeed"); + + let alice_alpha_after = alpha_token.balanceOf(sender).call().await.unwrap(); + assert!( + alice_alpha_after < alice_alpha_before, + "Alice's AlphaUSD should decrease due to gas fees" + ); +} + +// ============================================================================ +// Tempo AA: Receipt Fields +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_aa_transaction_receipt_fields() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let sender = accounts[0]; + let recipient = accounts[1]; + let signer = dev_key(0); + + let token = IERC20::new(PATH_USD, &provider); + let chain_id = provider.get_chain_id().await.unwrap(); + let base_fee = provider.get_gas_price().await.unwrap(); + + let ordinary_call = token.transfer(recipient, U256::from(50_000)); + let ordinary_tx = TransactionRequest::default() + .from(sender) + .to(PATH_USD) + .with_input(ordinary_call.calldata().clone()) + .with_gas_limit(TIP20_TRANSFER_GAS) + .max_fee_per_gas(base_fee * 2) + .max_priority_fee_per_gas(base_fee / 10); + let ordinary_receipt = provider + .send_transaction(WithOtherFields::new(ordinary_tx)) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + let ordinary_receipt = serde_json::to_value(ordinary_receipt).unwrap(); + assert_eq!(ordinary_receipt["feePayer"], format!("{sender:#x}")); + assert_eq!(ordinary_receipt["feeToken"], format!("{ALPHA_USD:#x}")); + + let transfer_call = token.transfer(recipient, U256::from(50_000)); + let calldata: Bytes = transfer_call.calldata().clone(); + + let tempo_tx = TempoTransaction { + chain_id, + fee_token: Some(ALPHA_USD), + max_priority_fee_per_gas: base_fee / 10, + max_fee_per_gas: base_fee * 2, + gas_limit: TIP20_TRANSFER_GAS, + calls: vec![Call { to: TxKind::Call(PATH_USD), value: U256::ZERO, input: calldata }], + access_list: Default::default(), + nonce_key: U256::from(500), + nonce: 0, + fee_payer_signature: None, + valid_before: None, + valid_after: None, + key_authorization: None, + tempo_authorization_list: vec![], + }; + + let sig_hash = tempo_tx.signature_hash(); + let signature = signer.sign_hash(&sig_hash).await.unwrap(); + let envelope = TempoTxEnvelope::AA(tempo_tx.into_signed(signature.into())); + + let mut encoded = Vec::new(); + envelope.encode_2718(&mut encoded); + + let tx_hash = provider.send_raw_transaction(&encoded).await.unwrap(); + let receipt = tx_hash.get_receipt().await.unwrap(); + + assert!(receipt.status(), "Transaction should succeed"); + assert!(receipt.gas_used > 0, "Gas used should be non-zero"); + assert!(!receipt.inner.logs().is_empty(), "Should have Transfer event logs"); + let receipt = serde_json::to_value(receipt).unwrap(); + assert_eq!(receipt["feePayer"], format!("{sender:#x}")); + assert_eq!(receipt["feeToken"], format!("{ALPHA_USD:#x}")); +} + +// ============================================================================ +// Tempo AA: Get Transaction By Hash +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_aa_get_transaction_by_hash() { + let (api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let sender = accounts[0]; + let recipient = accounts[1]; + let signer = dev_key(0); + + let token = IERC20::new(PATH_USD, &provider); + let chain_id = provider.get_chain_id().await.unwrap(); + let base_fee = provider.get_gas_price().await.unwrap(); + + let transfer_call = token.transfer(recipient, U256::from(50_000)); + let calldata: Bytes = transfer_call.calldata().clone(); + + let tempo_tx = TempoTransaction { + chain_id, + fee_token: Some(ALPHA_USD), + max_priority_fee_per_gas: base_fee / 10, + max_fee_per_gas: base_fee * 2, + gas_limit: TIP20_TRANSFER_GAS, + calls: vec![Call { to: TxKind::Call(PATH_USD), value: U256::ZERO, input: calldata }], + access_list: Default::default(), + nonce_key: U256::from(501), + nonce: 0, + fee_payer_signature: None, + valid_before: None, + valid_after: None, + key_authorization: None, + tempo_authorization_list: vec![], + }; + + let sig_hash = tempo_tx.signature_hash(); + let signature = signer.sign_hash(&sig_hash).await.unwrap(); + let envelope = TempoTxEnvelope::AA(tempo_tx.into_signed(signature.into())); + + let mut encoded = Vec::new(); + envelope.encode_2718(&mut encoded); + + let pending = provider.send_raw_transaction(&encoded).await.unwrap(); + let tx_hash = *pending.tx_hash(); + pending.get_receipt().await.unwrap(); + + let tx = api.transaction_by_hash(tx_hash).await.unwrap(); + assert!(tx.is_some(), "Transaction should be retrievable by hash"); + + let tx = tx.unwrap(); + assert_eq!(tx.ty(), 0x76, "Transaction type should be 0x76 (Tempo)"); + assert_eq!(TransactionResponse::from(&tx), sender, "From address should match sender"); +} + +// ============================================================================ +// Tempo AA: Raw Transaction Encoding +// ============================================================================ + +// A mined Tempo transaction is rebuilt as an untyped RPC transaction, which alloy refuses to +// EIP-2718 encode. `debug_getRawTransaction` must route it back through the Foundry envelope and +// return the original encoding. +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_aa_raw_transaction() { + let (api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let recipient = accounts[1]; + let signer = dev_key(0); + + let token = IERC20::new(PATH_USD, &provider); + let chain_id = provider.get_chain_id().await.unwrap(); + let base_fee = provider.get_gas_price().await.unwrap(); + + let transfer_call = token.transfer(recipient, U256::from(50_000)); + let calldata: Bytes = transfer_call.calldata().clone(); + + let tempo_tx = TempoTransaction { + chain_id, + fee_token: Some(ALPHA_USD), + max_priority_fee_per_gas: base_fee / 10, + max_fee_per_gas: base_fee * 2, + gas_limit: TIP20_TRANSFER_GAS, + calls: vec![Call { to: TxKind::Call(PATH_USD), value: U256::ZERO, input: calldata }], + access_list: Default::default(), + nonce_key: U256::from(502), + nonce: 0, + fee_payer_signature: None, + valid_before: None, + valid_after: None, + key_authorization: None, + tempo_authorization_list: vec![], + }; + + let sig_hash = tempo_tx.signature_hash(); + let signature = signer.sign_hash(&sig_hash).await.unwrap(); + let envelope = TempoTxEnvelope::AA(tempo_tx.into_signed(signature.into())); + + let mut encoded = Vec::new(); + envelope.encode_2718(&mut encoded); + + let pending = provider.send_raw_transaction(&encoded).await.unwrap(); + let tx_hash = *pending.tx_hash(); + + // Pending, straight from the pool. + let pending_raw = api.raw_transaction(tx_hash).await.unwrap().unwrap(); + assert_eq!(pending_raw.as_ref(), encoded.as_slice()); + + pending.get_receipt().await.unwrap(); + + // Mined, rebuilt from block storage. + let mined_raw = api.raw_transaction(tx_hash).await.unwrap().unwrap(); + assert_eq!(mined_raw, pending_raw); +} + +// ============================================================================ +// Tempo AA: Wrong Chain ID Rejected +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_aa_wrong_chain_id_rejected() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let recipient = accounts[1]; + let signer = dev_key(0); + + let token = IERC20::new(PATH_USD, &provider); + let correct_chain_id = provider.get_chain_id().await.unwrap(); + let wrong_chain_id = correct_chain_id + 1; + let base_fee = provider.get_gas_price().await.unwrap(); + + let transfer_call = token.transfer(recipient, U256::from(10_000)); + let calldata: Bytes = transfer_call.calldata().clone(); + + let tempo_tx = TempoTransaction { + chain_id: wrong_chain_id, + fee_token: Some(ALPHA_USD), + max_priority_fee_per_gas: base_fee / 10, + max_fee_per_gas: base_fee * 2, + gas_limit: TIP20_TRANSFER_GAS, + calls: vec![Call { to: TxKind::Call(PATH_USD), value: U256::ZERO, input: calldata }], + access_list: Default::default(), + nonce_key: U256::ONE, + nonce: 0, + fee_payer_signature: None, + valid_before: None, + valid_after: None, + key_authorization: None, + tempo_authorization_list: vec![], + }; + + let sig_hash = tempo_tx.signature_hash(); + let signature = signer.sign_hash(&sig_hash).await.unwrap(); + let envelope = TempoTxEnvelope::AA(tempo_tx.into_signed(signature.into())); + + let mut encoded = Vec::new(); + envelope.encode_2718(&mut encoded); + + let result = provider.send_raw_transaction(&encoded).await; + assert_eq!( + result.unwrap_err().to_string(), + "server returned an error response: error code -32003: invalid chain id for signer" + ); +} + +// ============================================================================ +// Tempo AA: Gas Too Low Rejected +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_aa_gas_too_low_rejected() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let recipient = accounts[1]; + let signer = dev_key(0); + + let token = IERC20::new(PATH_USD, &provider); + let chain_id = provider.get_chain_id().await.unwrap(); + let base_fee = provider.get_gas_price().await.unwrap(); + + let transfer_call = token.transfer(recipient, U256::from(10_000)); + let calldata: Bytes = transfer_call.calldata().clone(); + + let tempo_tx = TempoTransaction { + chain_id, + fee_token: Some(ALPHA_USD), + max_priority_fee_per_gas: base_fee / 10, + max_fee_per_gas: base_fee * 2, + gas_limit: 1000, + calls: vec![Call { to: TxKind::Call(PATH_USD), value: U256::ZERO, input: calldata }], + access_list: Default::default(), + nonce_key: U256::from(2), + nonce: 0, + fee_payer_signature: None, + valid_before: None, + valid_after: None, + key_authorization: None, + tempo_authorization_list: vec![], + }; + + let sig_hash = tempo_tx.signature_hash(); + let signature = signer.sign_hash(&sig_hash).await.unwrap(); + let envelope = TempoTxEnvelope::AA(tempo_tx.into_signed(signature.into())); + + let mut encoded = Vec::new(); + envelope.encode_2718(&mut encoded); + + let result = provider.send_raw_transaction(&encoded).await; + assert_eq!( + result.unwrap_err().to_string(), + "server returned an error response: error code -32000: intrinsic gas too low" + ); +} + +// ============================================================================ +// Tempo AA: Value In Call Rejected +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_aa_value_in_call_rejected() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let recipient = accounts[1]; + let signer = dev_key(0); + + let chain_id = provider.get_chain_id().await.unwrap(); + let base_fee = provider.get_gas_price().await.unwrap(); + + let tempo_tx = TempoTransaction { + chain_id, + fee_token: Some(ALPHA_USD), + max_priority_fee_per_gas: base_fee / 10, + max_fee_per_gas: base_fee * 2, + gas_limit: TIP20_TRANSFER_GAS, + calls: vec![Call { + to: TxKind::Call(recipient), + value: U256::from(1_000_000), + input: Bytes::new(), + }], + access_list: Default::default(), + nonce_key: U256::from(3), + nonce: 0, + fee_payer_signature: None, + valid_before: None, + valid_after: None, + key_authorization: None, + tempo_authorization_list: vec![], + }; + + let sig_hash = tempo_tx.signature_hash(); + let signature = signer.sign_hash(&sig_hash).await.unwrap(); + let envelope = TempoTxEnvelope::AA(tempo_tx.into_signed(signature.into())); + + let mut encoded = Vec::new(); + envelope.encode_2718(&mut encoded); + + let result = provider.send_raw_transaction(&encoded).await; + assert_eq!( + result.unwrap_err().to_string(), + "server returned an error response: error code -32003: native value transfer not allowed in Tempo mode" + ); +} + +// ============================================================================ +// Gas Estimation: Tempo AA Transaction +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_gas_estimation_tempo_aa_transaction() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let recipient = accounts[1]; + + let token = IERC20::new(PATH_USD, &provider); + let transfer_call = token.transfer(recipient, U256::from(1000)); + let calldata: Bytes = transfer_call.calldata().clone(); + + let tx: WithOtherFields = WithOtherFields { + inner: TransactionRequest::default().from(accounts[0]).to(PATH_USD).with_input(calldata), + other: [("feeToken".to_string(), serde_json::json!(PATH_USD.to_string()))] + .into_iter() + .collect(), + }; + + let gas_estimate = provider.estimate_gas(tx).await.unwrap(); + + assert!( + gas_estimate > 21000, + "Tempo AA gas estimate should be greater than 21000, got: {gas_estimate}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_browser_webauthn_gas_estimation() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = alloy_provider::ProviderBuilder::new_with_network::() + .connect_http(handle.http_endpoint().parse().unwrap()); + let accounts: Vec
= handle.dev_accounts().collect(); + let request = tempo_alloy::rpc::TempoTransactionRequest { + inner: TransactionRequest::default().from(accounts[0]), + fee_token: Some(PATH_USD), + nonce_key: Some(U256::ZERO), + calls: vec![Call { to: TxKind::Call(accounts[1]), value: U256::ZERO, input: Bytes::new() }], + ..Default::default() + }; + + let secp256k1_estimate = provider.estimate_gas(request.clone()).await.unwrap(); + let webauthn_estimate = + provider.estimate_gas(request.browser_wallet_gas_estimation_request()).await.unwrap(); + + assert!( + webauthn_estimate > secp256k1_estimate + 7_000, + "WebAuthn estimate should cover its variable signature data: \ + secp256k1={secp256k1_estimate}, webauthn={webauthn_estimate}" + ); +} + +// ============================================================================ +// Gas Estimation: Tempo AA with 2D Nonce +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_gas_estimation_tempo_aa_with_2d_nonce() { + let config = NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T0.into())); + let (_api, handle) = spawn(config).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let recipient = accounts[1]; + + let token = IERC20::new(PATH_USD, &provider); + let transfer_call = token.transfer(recipient, U256::from(1000)); + let calldata: Bytes = transfer_call.calldata().clone(); + + // Baseline: plain AA tx (no 2D nonce) + let baseline_tx: WithOtherFields = WithOtherFields { + inner: TransactionRequest::default() + .from(accounts[0]) + .to(PATH_USD) + .with_input(calldata.clone()), + other: [("feeToken".to_string(), serde_json::json!(PATH_USD.to_string()))] + .into_iter() + .collect(), + }; + let baseline_gas = provider.estimate_gas(baseline_tx).await.unwrap(); + + // 2D nonce tx with nonce=0 (new nonce key) + let tx: WithOtherFields = WithOtherFields { + inner: TransactionRequest::default() + .from(accounts[0]) + .to(PATH_USD) + .with_input(calldata) + .with_nonce(0), + other: [ + ("feeToken".to_string(), serde_json::json!(PATH_USD.to_string())), + ("nonceKey".to_string(), serde_json::json!("0x64")), + ] + .into_iter() + .collect(), + }; + + let gas_estimate = provider.estimate_gas(tx).await.unwrap(); + + // New 2D nonce key (nonce=0) charges COLD_SLOAD + SSTORE_SET = 22100 gas + let nonce_key_delta = gas_estimate - baseline_gas; + assert!( + nonce_key_delta >= 22_100, + "2D nonce should add >= 22100 gas, got delta: {nonce_key_delta} \ + (baseline: {baseline_gas}, 2d_nonce: {gas_estimate})" + ); +} + +// ============================================================================ +// Gas Estimation: Tempo AA with Expiring Nonce +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_gas_estimation_tempo_aa_expiring_nonce() { + let config = NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T0.into())); + let (_api, handle) = spawn(config).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let recipient = accounts[1]; + + let token = IERC20::new(PATH_USD, &provider); + let transfer_call = token.transfer(recipient, U256::from(1000)); + let calldata: Bytes = transfer_call.calldata().clone(); + + // Baseline: plain AA tx (no expiring nonce) + let baseline_tx: WithOtherFields = WithOtherFields { + inner: TransactionRequest::default() + .from(accounts[0]) + .to(PATH_USD) + .with_input(calldata.clone()), + other: [("feeToken".to_string(), serde_json::json!(PATH_USD.to_string()))] + .into_iter() + .collect(), + }; + let baseline_gas = provider.estimate_gas(baseline_tx).await.unwrap(); + + let max_nonce_key = "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; + + let tx: WithOtherFields = WithOtherFields { + inner: TransactionRequest::default() + .from(accounts[0]) + .to(PATH_USD) + .with_input(calldata) + .with_nonce(0), + other: [ + ("feeToken".to_string(), serde_json::json!(PATH_USD.to_string())), + ("nonceKey".to_string(), serde_json::json!(max_nonce_key)), + ] + .into_iter() + .collect(), + }; + + let gas_estimate = provider.estimate_gas(tx).await.unwrap(); + + // At T0, expiring nonces are treated as 2D nonces (22100 gas). + // At T1+, this charges EXPIRING_NONCE_GAS = 13000 instead. + let expiring_delta = gas_estimate - baseline_gas; + assert!( + expiring_delta >= 22_100, + "Expiring nonce should add >= 22100 gas at T0, got delta: {expiring_delta} \ + (baseline: {baseline_gas}, expiring: {gas_estimate})" + ); +} + +// ============================================================================ +// Gas Estimation: T1 Hardfork Nonce Gas Costs +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_gas_estimation_t1_nonce_costs() { + use tempo_hardfork::TempoHardfork; + + let (_api, handle) = + spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T1.into()))).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let recipient = accounts[1]; + + let token = IERC20::new(PATH_USD, &provider); + let transfer_call = token.transfer(recipient, U256::from(1000)); + let calldata: Bytes = transfer_call.calldata().clone(); + + // Baseline: plain AA tx (no nonce key) + let baseline_tx: WithOtherFields = WithOtherFields { + inner: TransactionRequest::default() + .from(accounts[0]) + .to(PATH_USD) + .with_input(calldata.clone()), + other: [("feeToken".to_string(), serde_json::json!(PATH_USD.to_string()))] + .into_iter() + .collect(), + }; + let baseline_gas = provider.estimate_gas(baseline_tx).await.unwrap(); + + // TIP-1000: nonce=0 pays 250k for account creation + assert!( + baseline_gas > 250_000, + "T1 baseline should include 250k (TIP-1000), got: {baseline_gas}" + ); + + // Existing 2D nonce key (nonce=1) charges 5,000 gas + let nonce_2d_tx: WithOtherFields = WithOtherFields { + inner: TransactionRequest::default() + .from(accounts[0]) + .to(PATH_USD) + .with_input(calldata.clone()) + .with_nonce(1), + other: [ + ("feeToken".to_string(), serde_json::json!(PATH_USD.to_string())), + ("nonceKey".to_string(), serde_json::json!("0x64")), + ] + .into_iter() + .collect(), + }; + let nonce_2d_gas = provider.estimate_gas(nonce_2d_tx).await.unwrap(); + + let baseline_nonce1_tx: WithOtherFields = WithOtherFields { + inner: TransactionRequest::default() + .from(accounts[0]) + .to(PATH_USD) + .with_input(calldata.clone()) + .with_nonce(1), + other: [("feeToken".to_string(), serde_json::json!(PATH_USD.to_string()))] + .into_iter() + .collect(), + }; + let baseline_nonce1_gas = provider.estimate_gas(baseline_nonce1_tx).await.unwrap(); + let nonce_2d_delta = nonce_2d_gas - baseline_nonce1_gas; + + assert!( + nonce_2d_delta >= 5_000, + "T1: existing 2D nonce key should add >= 5000 gas, got: {nonce_2d_delta}" + ); + + // TIP-1000: nonce=0 should cost 250k more than nonce=1 + let tip1000_delta = baseline_gas - baseline_nonce1_gas; + assert!( + tip1000_delta >= 250_000, + "T1: TIP-1000 delta should be >= 250000, got: {tip1000_delta}" + ); + + // Expiring nonce (nonce_key=MAX) at T1 should charge ~13K for ring buffer ops + // (2*COLD_SLOAD + WARM_SLOAD + 3*WARM_SSTORE_RESET), NOT 22K like at T0. + let max_nonce_key = "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; + let block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + let valid_before = block.header.timestamp + 25; + + let expiring_tx: WithOtherFields = WithOtherFields { + inner: TransactionRequest::default() + .from(accounts[0]) + .to(PATH_USD) + .with_input(calldata.clone()) + .with_nonce(0), + other: [ + ("feeToken".to_string(), serde_json::json!(PATH_USD.to_string())), + ("nonceKey".to_string(), serde_json::json!(max_nonce_key)), + ("validBefore".to_string(), serde_json::json!(valid_before)), + ] + .into_iter() + .collect(), + }; + let expiring_gas = provider.estimate_gas(expiring_tx).await.unwrap(); + + // Compare against baseline_nonce1 (nonce=1, no nonce key) since both the baseline (nonce=0) + // and expiring tx (nonce=0) include the 250k account creation cost. + let expiring_delta = expiring_gas - baseline_nonce1_gas; + assert!( + expiring_delta >= 13_000, + "T1: expiring nonce should add at least ~13K gas for ring buffer ops, got delta: {expiring_delta}" + ); +} + +// ============================================================================ +// Gas Estimation: 2D Nonce Estimate Sufficient for Real Transaction +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_gas_estimation_2d_nonce_converges() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let recipient = accounts[1]; + let signer = dev_key(0); + + let token = IERC20::new(PATH_USD, &provider); + let transfer_call = token.transfer(recipient, U256::from(1000)); + let calldata: Bytes = transfer_call.calldata().clone(); + + // Estimate gas with 2D nonce + let nonce_key = U256::from(0x64); + let tx: WithOtherFields = WithOtherFields { + inner: TransactionRequest::default() + .from(accounts[0]) + .to(PATH_USD) + .with_input(calldata.clone()) + .with_nonce(0), + other: [ + ("feeToken".to_string(), serde_json::json!(PATH_USD.to_string())), + ("nonceKey".to_string(), serde_json::json!(format!("{nonce_key:#x}"))), + ] + .into_iter() + .collect(), + }; + + let gas_estimate = provider.estimate_gas(tx).await.unwrap(); + + // Send the actual transaction with the estimated gas to verify it's sufficient + let chain_id = provider.get_chain_id().await.unwrap(); + let base_fee = provider.get_gas_price().await.unwrap(); + + let tempo_tx = TempoTransaction { + chain_id, + fee_token: Some(PATH_USD), + max_priority_fee_per_gas: base_fee / 10, + max_fee_per_gas: base_fee * 2, + gas_limit: gas_estimate, + calls: vec![Call { to: TxKind::Call(PATH_USD), value: U256::ZERO, input: calldata }], + access_list: Default::default(), + nonce_key, + nonce: 0, + fee_payer_signature: None, + valid_before: None, + valid_after: None, + key_authorization: None, + tempo_authorization_list: vec![], + }; + + let sig_hash = tempo_tx.signature_hash(); + let signature = signer.sign_hash(&sig_hash).await.unwrap(); + let envelope = TempoTxEnvelope::AA(tempo_tx.into_signed(signature.into())); + + let mut encoded = Vec::new(); + envelope.encode_2718(&mut encoded); + + let tx_hash = provider.send_raw_transaction(&encoded).await.unwrap(); + let receipt = tx_hash.get_receipt().await.unwrap(); + assert!( + receipt.status(), + "2D nonce transaction should succeed with estimated gas: {gas_estimate}" + ); + assert!( + receipt.gas_used() <= gas_estimate, + "Gas used ({}) should be <= estimate ({}) for 2D nonce tx", + receipt.gas_used(), + gas_estimate + ); +} + +// ============================================================================ +// Gas Estimation: Converges for Tempo Intrinsic Gas +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_gas_estimation_converges_for_tempo_intrinsic_gas() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let recipient = accounts[1]; + + let token = IERC20::new(PATH_USD, &provider); + let transfer_call = token.transfer(recipient, U256::from(1000)); + let calldata: Bytes = transfer_call.calldata().clone(); + + let tx: WithOtherFields = WithOtherFields { + inner: TransactionRequest::default() + .from(accounts[0]) + .to(PATH_USD) + .with_input(calldata.clone()), + other: [("feeToken".to_string(), serde_json::json!(PATH_USD.to_string()))] + .into_iter() + .collect(), + }; + + let gas_estimate = provider.estimate_gas(tx).await.unwrap(); + + // Send the actual transaction with the estimated gas to verify convergence + let signer = dev_key(0); + let chain_id = provider.get_chain_id().await.unwrap(); + let base_fee = provider.get_gas_price().await.unwrap(); + + let tempo_tx = TempoTransaction { + chain_id, + fee_token: Some(PATH_USD), + max_priority_fee_per_gas: base_fee / 10, + max_fee_per_gas: base_fee * 2, + gas_limit: gas_estimate, + calls: vec![Call { to: TxKind::Call(PATH_USD), value: U256::ZERO, input: calldata }], + access_list: Default::default(), + nonce_key: U256::ZERO, + nonce: 0, + fee_payer_signature: None, + valid_before: None, + valid_after: None, + key_authorization: None, + tempo_authorization_list: vec![], + }; + + let sig_hash = tempo_tx.signature_hash(); + let signature = signer.sign_hash(&sig_hash).await.unwrap(); + let envelope = TempoTxEnvelope::AA(tempo_tx.into_signed(signature.into())); + + let mut encoded = Vec::new(); + envelope.encode_2718(&mut encoded); + + let tx_hash = provider.send_raw_transaction(&encoded).await.unwrap(); + let receipt = tx_hash.get_receipt().await.unwrap(); + assert!(receipt.status(), "Transaction should succeed with estimated gas: {gas_estimate}"); + + assert!( + receipt.gas_used() <= gas_estimate, + "Gas used ({}) should be <= estimate ({})", + receipt.gas_used(), + gas_estimate + ); +} + +// ============================================================================ +// EIP-1559 Transaction +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_eip1559_transaction() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let sender = accounts[0]; + let recipient = accounts[1]; + + let token = IERC20::new(PATH_USD, &provider); + let recipient_balance_before = token.balanceOf(recipient).call().await.unwrap(); + + let transfer_amount = U256::from(500_000); + let transfer_call = token.transfer(recipient, transfer_amount); + let calldata: Bytes = transfer_call.calldata().clone(); + + let base_fee = provider.get_gas_price().await.unwrap(); + + let tx = TransactionRequest::default() + .from(sender) + .to(PATH_USD) + .with_input(calldata) + .with_gas_limit(TIP20_TRANSFER_GAS) + .max_fee_per_gas(base_fee * 2) + .max_priority_fee_per_gas(base_fee / 10); + + let tx = WithOtherFields::new(tx); + let receipt = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + + assert!(receipt.status(), "EIP-1559 transaction should succeed"); + + let recipient_balance_after = token.balanceOf(recipient).call().await.unwrap(); + assert_eq!( + recipient_balance_after, + recipient_balance_before + transfer_amount, + "Recipient should receive transfer amount" + ); +} + +// ============================================================================ +// Legacy Transaction +// ============================================================================ + +#[tokio::test(flavor = "multi_thread")] +async fn test_legacy_transaction() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let accounts: Vec
= handle.dev_accounts().collect(); + let sender = accounts[0]; + let recipient = accounts[1]; + + let token = IERC20::new(PATH_USD, &provider); + let recipient_balance_before = token.balanceOf(recipient).call().await.unwrap(); + + let transfer_amount = U256::from(250_000); + let transfer_call = token.transfer(recipient, transfer_amount); + let calldata: Bytes = transfer_call.calldata().clone(); + + let gas_price = provider.get_gas_price().await.unwrap(); + + let tx = TransactionRequest::default() + .from(sender) + .to(PATH_USD) + .with_input(calldata) + .with_gas_limit(TIP20_TRANSFER_GAS) + .with_gas_price(gas_price); + + let tx = WithOtherFields::new(tx); + let receipt = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + + assert!(receipt.status(), "Legacy transaction should succeed"); + + let recipient_balance_after = token.balanceOf(recipient).call().await.unwrap(); + assert_eq!( + recipient_balance_after, + recipient_balance_before + transfer_amount, + "Recipient should receive transfer amount" + ); +} + +// ============================================================================ +// TipFeeManager RPC Methods +// ============================================================================ + +/// `anvil_setFeeToken` sets the fee token for a user address, readable via `userTokens`. +#[tokio::test(flavor = "multi_thread")] +async fn test_anvil_set_fee_token() { + let (api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let user = Address::random(); + let fee_manager = IFeeManagerRpc::new(TIP_FEE_MANAGER_ADDRESS, &provider); + + // Before: user has no token set (returns zero address) + let token_before = fee_manager.userTokens(user).call().await.unwrap(); + assert_eq!(token_before, Address::ZERO, "User should have no fee token initially"); + + // Set user fee token to ALPHA_USD + api.anvil_set_fee_token(user, ALPHA_USD).await.unwrap(); + + // After: userTokens returns the configured token + let token_after = fee_manager.userTokens(user).call().await.unwrap(); + assert_eq!(token_after, ALPHA_USD, "User fee token should be set to ALPHA_USD"); +} + +/// `anvil_setFeeToken` returns an error when Tempo mode is not active. +#[tokio::test(flavor = "multi_thread")] +async fn test_anvil_set_fee_token_non_tempo_fails() { + let (api, _handle) = spawn(NodeConfig::test()).await; + + let result = api.anvil_set_fee_token(Address::random(), ALPHA_USD).await; + assert_eq!(result.unwrap_err().to_string(), "Rpc Endpoint not implemented"); +} + +/// `anvil_setValidatorFeeToken` sets the fee token for a validator, readable via `validatorTokens`. +#[tokio::test(flavor = "multi_thread")] +async fn test_anvil_set_validator_fee_token() { + let (api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let validator = Address::random(); + let fee_manager = IFeeManagerRpc::new(TIP_FEE_MANAGER_ADDRESS, &provider); + + // Before: validator has no token set (returns zero address) + let token_before = fee_manager.validatorTokens(validator).call().await.unwrap(); + assert_eq!(token_before, DEFAULT_FEE_TOKEN, "Validator should have no fee token initially"); + + // Set validator fee token to BETA_USD + api.anvil_set_validator_fee_token(validator, BETA_USD).await.unwrap(); + + // After: validatorTokens returns the configured token + let token_after = fee_manager.validatorTokens(validator).call().await.unwrap(); + assert_eq!(token_after, BETA_USD, "Validator fee token should be set to BETA_USD"); +} + +/// `anvil_setValidatorFeeToken` returns an error when Tempo mode is not active. +#[tokio::test(flavor = "multi_thread")] +async fn test_anvil_set_validator_fee_token_non_tempo_fails() { + let (api, _handle) = spawn(NodeConfig::test()).await; + + let result = api.anvil_set_validator_fee_token(Address::random(), BETA_USD).await; + assert_eq!(result.unwrap_err().to_string(), "Rpc Endpoint not implemented"); +} + +/// `anvil_setFeeAmmLiquidity` mints AMM liquidity for a token pair, +/// verifiable via `getPool` (non-zero reserves) and `totalSupply` (non-zero LP supply). +#[tokio::test(flavor = "multi_thread")] +async fn test_anvil_set_fee_amm_liquidity() { + let (api, handle) = spawn(NodeConfig::test_tempo()).await; + let provider = handle.http_provider(); + + let fee_manager = IFeeManagerRpc::new(TIP_FEE_MANAGER_ADDRESS, &provider); + + // Genesis pre-seeds all pairs between fee tokens; record current state before adding more + let pool_before = fee_manager.getPool(PATH_USD, ALPHA_USD).call().await.unwrap(); + let pool_id = fee_manager.getPoolId(PATH_USD, ALPHA_USD).call().await.unwrap(); + let lp_supply_before = fee_manager.totalSupply(pool_id).call().await.unwrap(); + + let amount = U256::from(1_000_000u64); + api.anvil_set_fee_amm_liquidity(PATH_USD, ALPHA_USD, amount).await.unwrap(); + + // After minting: reserves and LP supply should have increased + let pool_after = fee_manager.getPool(PATH_USD, ALPHA_USD).call().await.unwrap(); + let lp_supply_after = fee_manager.totalSupply(pool_id).call().await.unwrap(); + + assert!( + pool_after.reserveValidatorToken > pool_before.reserveValidatorToken, + "Validator token reserve should increase after minting liquidity" + ); + assert!( + lp_supply_after > lp_supply_before, + "LP token total supply should increase after minting liquidity" + ); +} + +/// `anvil_setFeeAmmLiquidity` returns an error when Tempo mode is not active. +#[tokio::test(flavor = "multi_thread")] +async fn test_anvil_set_fee_amm_liquidity_non_tempo_fails() { + let (api, _handle) = spawn(NodeConfig::test()).await; + + let result = + api.anvil_set_fee_amm_liquidity(PATH_USD, ALPHA_USD, U256::from(1_000_000u64)).await; + assert_eq!(result.unwrap_err().to_string(), "Rpc Endpoint not implemented"); +} + +/// Pre-T7 Tempo uses a fixed base fee, so mining empty blocks must not drift it (EIP-1559 would). +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_pre_t7_base_fee_stays_fixed() { + let (api, handle) = + spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T1.into()))).await; + let provider = handle.http_provider(); + + for _ in 0..5 { + api.mine_one().await.unwrap(); + } + + let latest = provider.get_block(BlockId::latest()).await.unwrap().unwrap().header.number; + for n in 0..=latest { + let block = provider.get_block(BlockId::number(n)).await.unwrap().unwrap(); + assert_eq!( + block.header.base_fee_per_gas, + Some(TEMPO_T1_BASE_FEE), + "pre-T7 block {n} base fee should stay fixed" + ); + } +} + +/// `anvil_reset` must restore the Tempo base fee, not fall back to Anvil's Ethereum default. +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_base_fee_survives_reset() { + let (api, handle) = + spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T1.into()))).await; + let provider = handle.http_provider(); + + for _ in 0..3 { + api.mine_one().await.unwrap(); + } + + api.anvil_reset(None).await.unwrap(); + api.mine_one().await.unwrap(); + + let block = provider.get_block(BlockId::latest()).await.unwrap().unwrap(); + assert_eq!( + block.header.base_fee_per_gas, + Some(TEMPO_T1_BASE_FEE), + "base fee after reset should be the fixed Tempo value, not the Ethereum default" + ); +} + +/// T7 Tempo uses the TIP-1067 dynamic controller: empty blocks lower the base fee within the clamp. +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_t7_base_fee_is_dynamic() { + let (api, handle) = + spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T7.into()))).await; + let provider = handle.http_provider(); + + for _ in 0..8 { + api.mine_one().await.unwrap(); + } + + // The genesis block keeps the 20B seed (matching Tempo's T7 genesis). + let genesis = provider.get_block(BlockId::number(0)).await.unwrap().unwrap(); + assert_eq!(genesis.header.base_fee_per_gas, Some(TEMPO_T1_BASE_FEE)); + + let latest = provider.get_block(BlockId::latest()).await.unwrap().unwrap().header.number; + let mut fees = Vec::new(); + for n in 1..=latest { + let block = provider.get_block(BlockId::number(n)).await.unwrap().unwrap(); + fees.push(block.header.base_fee_per_gas.unwrap()); + } + + // Block 1 already clamps the 20B seed down to the TIP-1067 cap; generic EIP-1559 would give + // ~17.5B instead, so this pins the T7 path. Later empty blocks decay below the cap. + assert_eq!(fees[0], TEMPO_T7_BASE_FEE_CAP, "block 1 clamps to the T7 cap: {fees:?}"); + assert!(fees[1] < TEMPO_T7_BASE_FEE_CAP, "empty blocks decay below the cap: {fees:?}"); + // Empty blocks never raise the base fee, and it stays within the TIP-1067 clamp. + assert!(fees.windows(2).all(|w| w[1] <= w[0]), "base fee should not rise: {fees:?}"); + let last = *fees.last().unwrap(); + assert!( + (TEMPO_T7_BASE_FEE_FLOOR..=TEMPO_T7_BASE_FEE_CAP).contains(&last), + "base fee should be clamped to the TIP-1067 range: {fees:?}" + ); + + api.anvil_reset(None).await.unwrap(); + + let genesis = provider.get_block(BlockId::number(0)).await.unwrap().unwrap(); + assert_eq!(genesis.header.base_fee_per_gas, Some(TEMPO_T1_BASE_FEE)); + assert_eq!(api.base_fee().unwrap(), Some(U256::from(TEMPO_T7_BASE_FEE_CAP))); + api.mine_one().await.unwrap(); + let first = provider.get_block(BlockId::number(1)).await.unwrap().unwrap(); + assert_eq!(first.header.base_fee_per_gas, Some(TEMPO_T7_BASE_FEE_CAP)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_t7_reset_restores_explicit_base_fee() { + let explicit_base_fee = TEMPO_T1_BASE_FEE + 1; + let (api, handle) = spawn( + NodeConfig::test_tempo() + .with_hardfork(Some(TempoHardfork::T7.into())) + .with_base_fee(Some(explicit_base_fee)), + ) + .await; + let provider = handle.http_provider(); + + api.anvil_set_next_block_base_fee_per_gas(U256::from(TEMPO_T7_BASE_FEE_FLOOR)).await.unwrap(); + api.anvil_reset(None).await.unwrap(); + + let genesis = provider.get_block(BlockId::number(0)).await.unwrap().unwrap(); + assert_eq!(genesis.header.base_fee_per_gas, Some(explicit_base_fee)); + assert_eq!(api.base_fee().unwrap(), Some(U256::from(TEMPO_T7_BASE_FEE_CAP))); + api.mine_one().await.unwrap(); + let first = provider.get_block(BlockId::number(1)).await.unwrap().unwrap(); + assert_eq!(first.header.base_fee_per_gas, Some(TEMPO_T7_BASE_FEE_CAP)); +} + +/// Mined parity traces keep calls to `0x0100` before T1C, where Tempo has no P256 precompile. +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_mined_traces_use_hardfork_precompiles() { + let p256 = address!("0x0000000000000000000000000000000000000100"); + let target = address!("0x000000000000000000000000000000000000beef"); + for (hardfork, frames) in + [(TempoHardfork::T1B, vec![p256, target]), (TempoHardfork::T1C, vec![target])] + { + let config = NodeConfig::test_tempo().with_hardfork(Some(hardfork.into())); + let (api, handle) = spawn(config).await; + let provider = handle.http_provider(); + let from = handle.dev_accounts().next().unwrap(); + + // STATICCALL to 0x0100 with a P256-sized input, then a zero-value CALL to 0xbeef. + let caller = Address::repeat_byte(0x42); + let code = "0x6020600060a060006101005afa506000600060006000600061beef5af15000"; + api.anvil_set_code(caller, code.parse().unwrap()).await.unwrap(); + + let tx = TransactionRequest::default().from(from).to(caller); + let receipt = provider + .send_transaction(WithOtherFields::new(tx)) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + let hash = receipt.transaction_hash; + let traces = provider.trace_transaction(hash).await.unwrap(); + let traces = traces.into_iter().map(|trace| trace.trace).collect::>(); + let targets = traces[1..] + .iter() + .map(|trace| match &trace.action { + Action::Call(call) => call.to, + action => panic!("expected a call, got {action:?}"), + }) + .collect::>(); + assert_eq!(targets, frames, "{hardfork:?}"); + + let replay = provider.trace_replay_transaction(hash).trace().await.unwrap(); + assert_eq!(replay.trace, traces, "{hardfork:?}"); + } +} diff --git a/crates/anvil/tests/it/tempo_canary.rs b/crates/anvil/tests/it/tempo_canary.rs new file mode 100644 index 0000000000000..28a245b1e5897 --- /dev/null +++ b/crates/anvil/tests/it/tempo_canary.rs @@ -0,0 +1,567 @@ +//! Canary tests replaying Tempo mainnet and testnet transactions under the newest hardfork anvil +//! knows. +//! +//! Every test forks its source network at the parent of a pinned transaction's block, forces the +//! hardfork to [`TempoHardfork::latest`], and re-submits the transaction's original signed bytes +//! at the block's timestamp. The local receipt must reproduce the source receipt: the same status, +//! the same logs, and, unless a case documents a known change, the same gas. +//! +//! The pinned transactions come from the services that keep Tempo busy: Relay's solver and +//! router, Tempo AA payout senders, an ERC-4337 bundler, an ERC-7821 relayer for EIP-7702 +//! accounts, and end users sending through a frontend that appends an ERC-8021 attribution +//! suffix. T11 activated strict ABI decoding for precompile calls and broke the first and last of +//! those on mainnet, because both append bytes to TIP20 `transfer` and `approve` calldata. +//! Replaying the same transactions under T11 before it activated would have shown that, which is +//! what these tests do for every hardfork the pinned `tempo` revision adds; see +//! for the fix that ships in T12. +//! +//! Mainnet transactions are alone in their blocks, which is asserted. Testnet cases can pin a +//! transaction index and replay the entire prefix in source order in a single block. +//! Gas is compared exactly, so the pinned blocks must have been executed under the hardfork that is +//! active on the source network: a hardfork may change gas accounting, and T11 did for precompile +//! calldata. When the newest hardfork changes it again, record the exact change for the affected +//! case with [`GasCheck::Offset`] and the reason next to it, then re-pin the transaction past the +//! activation once it is live and restore the exact check. +//! +//! The upstream defaults to public endpoints and honours `TEMPO_MAINNET_RPC_URL` and +//! `TEMPO_TESTNET_RPC_URL`. The canaries run in a single-threaded nextest group +//! because the public endpoint rate limits concurrent replays. + +use crate::utils::http_provider; +use alloy_consensus::Transaction; +use alloy_network::{ReceiptResponse, TransactionResponse}; +use alloy_primitives::{Address, B256, Bytes, U256, address, b256}; +use alloy_provider::{Provider, ext::DebugApi}; +use alloy_rpc_types::{BlockId, BlockNumberOrTag, Log}; +use anvil::{NodeConfig, eth::pool::transactions::TransactionOrder, spawn}; +use foundry_test_utils::rpc::{next_tempo_mainnet_rpc_endpoint, next_tempo_testnet_rpc_endpoint}; +use std::fmt; +use tempo_hardfork::TempoHardfork; +use tempo_precompiles::TIP_FEE_MANAGER_ADDRESS; + +/// Topic of the TIP20 `Transfer(address,address,uint256)` event. +const TRANSFER_TOPIC: B256 = + b256!("0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"); + +/// Topic of the TIP20 `Approval(address,address,uint256)` event. +const APPROVAL_TOPIC: B256 = + b256!("0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925"); + +/// Relay's solver settling a fill with a plain USDC.e `transfer`, sent after Relay dropped the +/// request id suffix that T11 rejected. +/// +/// Block , transaction +/// . +const RELAY_USDCE_TRANSFER: PinnedTransaction = PinnedTransaction::new( + 38_917_407, + b256!("0x061690e0b4378e2415de0ed6c8106edf8ef91b59ffe0ebe03b493ba02bff9645"), +); + +/// A Relay fill executed under T10, before the incident: a USDC.e `transfer` with the 32-byte +/// request id appended to the calldata, which T10 accepted. +/// +/// Block , transaction +/// . +const RELAY_T10_TRANSFER_WITH_REQUEST_ID: PinnedTransaction = PinnedTransaction::new( + 38_890_775, + b256!("0x70f6e31600875e6202c59862f1c9e47f8471fdbb347a9034e0b92a7019bec657"), +); + +/// The Relay fill reported during the T11 incident: a USDC.e `transfer` with the 32-byte request +/// id appended to the calldata, which the strict decoding T11 introduced rejected. +/// +/// Block , transaction +/// . +const RELAY_TRANSFER_WITH_REQUEST_ID: PinnedTransaction = PinnedTransaction::new( + 38_915_456, + b256!("0x834ad50aaceced3724b3b85c871be75f8ad6946d2b9335a59690848ffad72173"), +); + +/// Relay's solver paying out in pathUSD, the fee token every account holds. +/// +/// Block , transaction +/// . +const RELAY_PATH_USD_TRANSFER: PinnedTransaction = PinnedTransaction::new( + 38_928_039, + b256!("0x716cb37db2f216eb004fe0df1e211eb33a8083caa9fff13526b866549f44f409"), +); + +/// Relay's ERC20 router pulling the user's funds through a Permit2 signature and forwarding them +/// in one `permit2TransferAndMulticall`, the entry point of a cross-chain swap into Tempo. +/// +/// Block , transaction +/// . +const RELAY_ROUTER_PERMIT2_MULTICALL: PinnedTransaction = PinnedTransaction::new( + 38_892_542, + b256!("0x35826414c38c20aee8f78b2e7a55a2631ac12843db9828c376ee533d7491c184"), +); + +/// An end user's USDC.e `approve` carrying the ERC-8021 attribution suffix a frontend appends to +/// its calldata, which T11 rejected like Relay's request id. +/// +/// Block , transaction +/// . +const ERC8021_ATTRIBUTED_APPROVE: PinnedTransaction = PinnedTransaction::new( + 38_917_602, + b256!("0xc966c5e310ff349d338ee73796fdf5d75b98b3193c639d2524752db4ac465327"), +); + +/// A payout sender's Tempo AA transaction whose single call is a TIP20 `transfer`, paid for in +/// USDC.e. +/// +/// Block , transaction +/// . +const AA_TIP20_TRANSFER: PinnedTransaction = PinnedTransaction::new( + 38_891_824, + b256!("0x58e5fd7b57d925dec145fcdc3a492d5805f2ea8defbbf76f47ea127ac3b59b8f"), +); + +/// A sponsored Tempo AA transaction carrying a fee payer signature and a validity window, whose +/// call is a TIP20 `transferWithMemo`. +/// +/// Block , transaction +/// . +const AA_SPONSORED_TRANSFER_WITH_MEMO: PinnedTransaction = PinnedTransaction::new( + 38_891_826, + b256!("0x125a827d32ff5b9416e7c29d63c43d1ce5e8464d4c26025b2246e965344ed541"), +); + +/// A pull payment: a Tempo AA transaction whose single call is a USDC.e `transferFrom` spending +/// another account's allowance, the pattern operators use to collect approved funds. +/// +/// Block , transaction +/// . +const AA_TIP20_TRANSFER_FROM: PinnedTransaction = PinnedTransaction::new( + 38_928_092, + b256!("0x2f329fd76706d8fefb3a888aa09c4ea490fe4200eef26fba687de7cf98cd7ee8"), +); + +/// An ERC-4337 bundler submitting `handleOps` to the v0.7 entry point. +/// +/// Block , transaction +/// . +const ERC4337_HANDLE_OPS: PinnedTransaction = PinnedTransaction::new( + 38_891_903, + b256!("0x82941adeb79bbf6bbdf20165e1fce55d88e4f34ea0dc476f604df82aaf043442"), +); + +/// A relayer executing an ERC-7821 batch on an EIP-7702 delegated account. +/// +/// Block , transaction +/// . +const ERC7821_EXECUTE: PinnedTransaction = PinnedTransaction::new( + 38_891_835, + b256!("0x140b39136cb50902bd6ebeed2b6485a4aca20868542ae85be83d5b86a332d4d7"), +); + +/// Batched approval and `swapExactAmountIn` whose DEX payout changed after tempo#6614. +/// The canonical payout is 76,171,603,026 raw units; the regression paid 76,701,714,247. +/// +/// . +const TESTNET_DEX_SWAP: PinnedTransaction = PinnedTransaction { + network: CanaryNetwork::Testnet, + block: 35_497_031, + hash: b256!("0x812f96c663e27b5112f2818e2cf49a12c184ac6547a07b289049f9dfa3e99591"), + transaction_index: Some(13), +}; + +/// Replays the preceding transactions before checking the testnet DEX payout regression. +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_canary_fork_testnet_dex_swap() { + let replayed = replay(TESTNET_DEX_SWAP, TempoHardfork::latest()).await; + let payout = LogRecord { + address: address!("20c0000000000000000000000000000000000000"), + topics: vec![ + TRANSFER_TOPIC, + address!("dec0000000000000000000000000000000000000").into_word(), + address!("41b982fbc063339a9b4d5827e7b51705c462402e").into_word(), + ], + data: U256::from(76_171_603_026u64).to_be_bytes::<32>().into(), + }; + assert!(replayed.source_success, "{replayed}: canonical swap must succeed"); + assert!(replayed.source_logs.contains(&payout), "{replayed}: canonical DEX payout changed"); + assert_eq!( + replayed + .logs + .iter() + .find(|log| log.address == payout.address && log.topics == payout.topics), + Some(&payout), + "{replayed}: replay changed the DEX payout" + ); + // The source transaction ran under T11. T12 changed DEX execution and reduces its gas by + // 529,300; this fixture must remain before that hardfork because it exercises corrupted state. + replayed.assert_matches_source(GasCheck::Offset(-529_300)); +} + +/// Relay's fills must keep replaying under the newest hardfork, see [`RELAY_USDCE_TRANSFER`]. +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_canary_fork_relay_usdce_transfer() { + replay(RELAY_USDCE_TRANSFER, TempoHardfork::latest()) + .await + .assert_matches_source(GasCheck::Exact); +} + +/// The T10-era fill, see [`RELAY_T10_TRANSFER_WITH_REQUEST_ID`], must reproduce mainnet exactly +/// under T10 and keep succeeding under the latest hardfork. +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_canary_fork_relay_t10_transfer_with_request_id() { + replay(RELAY_T10_TRANSFER_WITH_REQUEST_ID, TempoHardfork::T10) + .await + .assert_matches_source(GasCheck::Exact); + + // T11 raised the gas of precompile calldata, which charges this call 96 gas more than mainnet + // did under T10. The change is permanent for a T10-era transaction, so it is expected here. + replay(RELAY_T10_TRANSFER_WITH_REQUEST_ID, TempoHardfork::latest()) + .await + .assert_matches_source(GasCheck::Offset(96)); +} + +/// The T11 incident fill, see [`RELAY_TRANSFER_WITH_REQUEST_ID`], replayed under the hardforks +/// around the change: T10 accepts the trailing request id, T11 rejects it exactly as mainnet did, +/// and the latest hardfork accepts it again. +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_canary_fork_relay_transfer_trailing_bytes_across_hardforks() { + assert_trailing_bytes_regression(RELAY_TRANSFER_WITH_REQUEST_ID).await; +} + +/// Relay's pathUSD payouts must keep replaying, see [`RELAY_PATH_USD_TRANSFER`]. +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_canary_fork_relay_path_usd_transfer() { + replay(RELAY_PATH_USD_TRANSFER, TempoHardfork::latest()) + .await + .assert_matches_source(GasCheck::Exact); +} + +/// Relay's router swaps must keep replaying, see [`RELAY_ROUTER_PERMIT2_MULTICALL`]. +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_canary_fork_relay_router_permit2_multicall() { + replay(RELAY_ROUTER_PERMIT2_MULTICALL, TempoHardfork::latest()) + .await + .assert_matches_source(GasCheck::Exact); +} + +/// The ERC-8021 approval T11 rejected, see [`ERC8021_ATTRIBUTED_APPROVE`], replayed under the +/// hardforks around the change like the Relay fill. +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_canary_fork_erc8021_attribution_suffix_across_hardforks() { + assert_trailing_bytes_regression(ERC8021_ATTRIBUTED_APPROVE).await; +} + +/// AA payouts must keep replaying, see [`AA_TIP20_TRANSFER`]. +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_canary_fork_aa_tip20_transfer() { + replay(AA_TIP20_TRANSFER, TempoHardfork::latest()).await.assert_matches_source(GasCheck::Exact); +} + +/// Sponsored AA transfers must keep replaying, see [`AA_SPONSORED_TRANSFER_WITH_MEMO`]. +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_canary_fork_aa_sponsored_transfer_with_memo() { + replay(AA_SPONSORED_TRANSFER_WITH_MEMO, TempoHardfork::latest()) + .await + .assert_matches_source(GasCheck::Exact); +} + +/// Allowance pulls must keep replaying, see [`AA_TIP20_TRANSFER_FROM`]. +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_canary_fork_aa_tip20_transfer_from() { + replay(AA_TIP20_TRANSFER_FROM, TempoHardfork::latest()) + .await + .assert_matches_source(GasCheck::Exact); +} + +/// ERC-4337 bundles must keep replaying, see [`ERC4337_HANDLE_OPS`]. +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_canary_fork_erc4337_bundler_handle_ops() { + replay(ERC4337_HANDLE_OPS, TempoHardfork::latest()) + .await + .assert_matches_source(GasCheck::Exact); +} + +/// ERC-7821 batches on delegated accounts must keep replaying, see [`ERC7821_EXECUTE`]. +#[tokio::test(flavor = "multi_thread")] +async fn test_tempo_canary_fork_erc7821_execute_on_delegated_account() { + replay(ERC7821_EXECUTE, TempoHardfork::latest()).await.assert_matches_source(GasCheck::Exact); +} + +/// Replays a TIP20 `transfer` or `approve` with trailing calldata bytes that failed on mainnet +/// under T11: T10 still accepts the bytes and applies the call, T11 reproduces the mainnet +/// failure, and the latest hardfork applies the call again. +async fn assert_trailing_bytes_regression(pinned: PinnedTransaction) { + replay(pinned, TempoHardfork::T10).await.assert_applies_tip20_call(); + + let regression = replay(pinned, TempoHardfork::T11).await; + regression.assert_matches_source(GasCheck::Exact); + assert!(!regression.source_success, "{regression}: expected the mainnet call to have failed"); + + replay(pinned, TempoHardfork::latest()).await.assert_applies_tip20_call(); +} + +/// The network supplying a canary's transaction and historical state. +#[derive(Clone, Copy)] +enum CanaryNetwork { + Mainnet, + Testnet, +} + +impl CanaryNetwork { + fn rpc_endpoint(self) -> String { + match self { + Self::Mainnet => next_tempo_mainnet_rpc_endpoint(), + Self::Testnet => next_tempo_testnet_rpc_endpoint(), + } + } + + const fn explorer(self) -> &'static str { + match self { + Self::Mainnet => "https://explore.tempo.xyz", + Self::Testnet => "https://explore.testnet.tempo.xyz", + } + } +} + +/// A transaction a canary is pinned to. +#[derive(Clone, Copy)] +struct PinnedTransaction { + network: CanaryNetwork, + /// The block holding the transaction; the fork starts at its parent. + block: u64, + /// The transaction's hash. + hash: B256, + /// An explicit index enables ordered prefix replay; otherwise the block must hold only this + /// tx. + transaction_index: Option, +} + +impl PinnedTransaction { + const fn new(block: u64, hash: B256) -> Self { + Self { network: CanaryNetwork::Mainnet, block, hash, transaction_index: None } + } +} + +/// How the gas a replayed transaction used is compared against its source receipt. +#[derive(Clone, Copy)] +enum GasCheck { + /// The replay must use exactly the gas the source recorded. + Exact, + /// The replay must use exactly this much more gas than the source recorded, or less when + /// negative; the known effect of a hardfork on the pinned transaction. + Offset(i64), +} + +/// The pinned transaction replayed on a fork of its parent block under a forced hardfork, next +/// to the outcome the source recorded for it. +struct Replayed { + pinned: PinnedTransaction, + hardfork: TempoHardfork, + sender: Address, + to: Option
, + input: Bytes, + success: bool, + gas_used: u64, + logs: Vec, + source_success: bool, + source_gas_used: u64, + source_logs: Vec, +} + +/// The parts of a log that execution determines. +#[derive(Clone, Debug, PartialEq, Eq)] +struct LogRecord { + address: Address, + topics: Vec, + data: Bytes, +} + +impl LogRecord { + fn new(log: &Log) -> Self { + Self { + address: log.address(), + topics: log.topics().to_vec(), + data: log.data().data.clone(), + } + } + + /// Returns whether this is the fee payment Tempo appends to every receipt: a TIP20 transfer + /// from the fee payer to the fee manager. + fn is_fee_payment(&self) -> bool { + self.topics.first() == Some(&TRANSFER_TOPIC) + && self.topics.get(2) == Some(&TIP_FEE_MANAGER_ADDRESS.into_word()) + } +} + +impl Replayed { + /// Asserts the replay reproduced the source status, the source logs, and, per `gas`, the + /// source gas usage. + /// + /// The fee payment log depends on the gas used, so it is only compared when the gas must + /// match exactly. + fn assert_matches_source(&self, gas: GasCheck) { + assert_eq!( + self.success, self.source_success, + "{self}: status diverged from source: success {} vs {} (gas {} vs {})", + self.success, self.source_success, self.gas_used, self.source_gas_used + ); + match gas { + GasCheck::Exact => { + assert_eq!( + self.gas_used, self.source_gas_used, + "{self}: used different gas than on source" + ); + assert_eq!(self.logs, self.source_logs, "{self}: logs diverged from source"); + } + GasCheck::Offset(offset) => { + assert_eq!( + i128::from(self.gas_used) - i128::from(self.source_gas_used), + i128::from(offset), + "{self}: used {} gas, source used {}, expected offset {offset}", + self.gas_used, + self.source_gas_used + ); + assert_eq!( + self.effects(), + Self::effects_of(&self.source_logs), + "{self}: logs diverged from source" + ); + } + } + } + + /// Asserts the replay succeeded and applied the pinned TIP20 `transfer` or `approve`: its + /// only effect is the matching `Transfer` or `Approval` event, whatever the source recorded. + fn assert_applies_tip20_call(&self) { + assert!(self.success, "{self}: failed with gas {}", self.gas_used); + let (selector, args) = self.input.split_at(4); + let (topic, name) = match selector { + [0xa9, 0x05, 0x9c, 0xbb] => (TRANSFER_TOPIC, "transfer"), + [0x09, 0x5e, 0xa7, 0xb3] => (APPROVAL_TOPIC, "approve"), + _ => panic!("{self}: not a TIP20 transfer or approve"), + }; + let expected = LogRecord { + address: self.to.unwrap_or_else(|| panic!("{self}: not a call")), + topics: vec![topic, self.sender.into_word(), B256::from_slice(&args[..32])], + data: Bytes::copy_from_slice(&args[32..64]), + }; + assert_eq!(self.effects(), vec![expected], "{self}: {name} was not applied as encoded"); + } + + /// Returns the logs the replay emitted apart from the fee payment. + fn effects(&self) -> Vec { + Self::effects_of(&self.logs) + } + + fn effects_of(logs: &[LogRecord]) -> Vec { + logs.iter().filter(|log| !log.is_fee_payment()).cloned().collect() + } +} + +impl fmt::Display for Replayed { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let explorer = self.pinned.network.explorer(); + write!( + f, + "{explorer}/receipt/{} in block {} ({explorer}/block/{}) under {}", + self.pinned.hash, self.pinned.block, self.pinned.block, self.hardfork + ) + } +} + +/// Forks the source network at the parent of the pinned transaction's block, forces `hardfork`, and +/// replays the transaction's signed bytes at the block's timestamp. +async fn replay(pinned: PinnedTransaction, hardfork: TempoHardfork) -> Replayed { + let number = pinned.block; + let rpc_url = pinned.network.rpc_endpoint(); + let explorer = pinned.network.explorer(); + let source = http_provider(&rpc_url); + + let block = source + .get_block_by_number(BlockNumberOrTag::Number(number)) + .await + .unwrap() + .unwrap_or_else(|| panic!("tempo source block {number} not found")); + let receipts = source + .get_block_receipts(BlockId::number(number)) + .await + .unwrap() + .unwrap_or_else(|| panic!("tempo source block {number} has no receipts")); + let receipt = if let Some(index) = pinned.transaction_index { + receipts.get(index).unwrap_or_else(|| panic!("block {number} has no transaction {index}")) + } else { + let [receipt] = receipts.as_slice() else { + panic!( + "block {number} holds {} transactions, expected only {explorer}/receipt/{}", + receipts.len(), + pinned.hash + ); + }; + receipt + }; + assert_eq!( + receipt.tx_hash(), + pinned.hash, + "block {number} does not hold the pinned transaction {explorer}/receipt/{}", + pinned.hash + ); + let tx = source + .get_transaction_by_hash(pinned.hash) + .await + .unwrap() + .unwrap_or_else(|| panic!("tempo source transaction {} not found", pinned.hash)); + let (api, _handle) = spawn( + NodeConfig::test_tempo() + .with_eth_rpc_url(Some(rpc_url)) + .with_fork_block_number(Some(number - 1)) + .with_hardfork(Some(hardfork.into())) + .with_transaction_order(TransactionOrder::Fifo), + ) + .await; + let node_info = api.anvil_node_info().await.unwrap(); + assert_eq!(node_info.hard_fork, hardfork.to_string(), "forced hardfork was not applied"); + api.anvil_set_auto_mine(false).await.unwrap(); + // Tempo blocks may share a timestamp. Set it before submission to validate time bounds. + api.evm_set_next_block_timestamp(block.header.timestamp).unwrap(); + + // Replay signed bytes in source order, including every predecessor. Mining predecessors + // separately would change block-local execution context. + let hashes = block + .transactions + .hashes() + .take(pinned.transaction_index.unwrap_or(0) + 1) + .collect::>(); + assert_eq!(hashes.last(), Some(&pinned.hash), "pinned transaction has the wrong block index"); + for hash in &hashes { + let raw = source.debug_get_raw_transaction(*hash).await.unwrap(); + let sent = api.send_raw_transaction(raw).await.unwrap_or_else(|err| { + panic!("block {number} under {hardfork}: {explorer}/receipt/{hash} rejected: {err}") + }); + assert_eq!(sent, *hash, "block {number}: raw transaction decoded to a different hash"); + } + api.mine_one().await.unwrap(); + for (index, hash) in hashes.iter().enumerate() { + let receipt = api.transaction_receipt(*hash).await.unwrap().unwrap_or_else(|| { + panic!("block {number} under {hardfork}: {explorer}/receipt/{hash} was not mined") + }); + assert_eq!( + receipt.transaction_index(), + Some(index as u64), + "replay changed transaction order" + ); + assert_eq!(receipt.block_number(), Some(number), "replay changed transaction block"); + } + + let local = api.transaction_receipt(pinned.hash).await.unwrap().unwrap_or_else(|| { + panic!("block {number} under {hardfork}: {explorer}/receipt/{} was not mined", pinned.hash) + }); + Replayed { + pinned, + hardfork, + sender: tx.from(), + to: tx.to(), + input: tx.input().clone(), + success: local.status(), + gas_used: local.gas_used(), + logs: local.0.inner.inner.logs().iter().map(LogRecord::new).collect(), + source_success: receipt.status(), + source_gas_used: receipt.gas_used(), + source_logs: receipt.inner.inner.logs().iter().map(LogRecord::new).collect(), + } +} diff --git a/crates/anvil/tests/it/traces.rs b/crates/anvil/tests/it/traces.rs index ac79e56ee475f..4146210b7d0d8 100644 --- a/crates/anvil/tests/it/traces.rs +++ b/crates/anvil/tests/it/traces.rs @@ -1,34 +1,45 @@ +use std::collections::HashMap; + use crate::{ abi::{Multicall, SimpleStorage}, fork::fork_config, utils::http_provider_with_signer, }; use alloy_eips::BlockId; -use alloy_hardforks::EthereumHardfork; -use alloy_network::{EthereumWallet, TransactionBuilder}; +use alloy_network::{AnyNetwork, EthereumWallet, ReceiptResponse, TransactionBuilder}; use alloy_primitives::{ - Address, Bytes, U256, + Address, B256, Bytes, U256, address, hex::{self, FromHex}, }; use alloy_provider::{ Provider, ext::{DebugApi, TraceApi}, }; +use alloy_rlp::Encodable; use alloy_rpc_types::{ - TransactionRequest, + BlockNumberOrTag, Index, TransactionRequest, state::StateOverride, trace::{ filter::{TraceFilter, TraceFilterMode}, geth::{ - CallConfig, GethDebugBuiltInTracerType, GethDebugTracerType, - GethDebugTracingCallOptions, GethDebugTracingOptions, GethTrace, + AccountState, CallConfig, GethDebugBuiltInTracerType, GethDebugTracerType, + GethDebugTracingCallOptions, GethDebugTracingOptions, GethDefaultTracingOptions, + GethTrace, PreStateConfig, PreStateFrame, TraceResult, + }, + opcode::{BlockOpcodeGas, TransactionOpcodeGas}, + parity::{ + Action, ChangedType, LocalizedTransactionTrace, TraceResults, + TraceResultsWithTransactionHash, TraceType, }, - parity::{Action, LocalizedTransactionTrace}, }, }; +use alloy_rpc_types_eth::AccountInfo; use alloy_serde::WithOtherFields; -use alloy_sol_types::sol; +use alloy_sol_types::{SolCall, SolValue, sol}; use anvil::{NodeConfig, spawn}; +use foundry_evm::hardfork::EthereumHardfork; +use revm::context_interface::block::BlobExcessGasAndPrice; +use serde_json::{Value, json}; #[tokio::test(flavor = "multi_thread")] async fn test_get_transfer_parity_traces() { @@ -65,489 +76,2081 @@ async fn test_get_transfer_parity_traces() { assert_eq!(traces, block_traces); } -sol!( - #[sol(rpc, bytecode = "0x6080604052348015600f57600080fd5b50336000806101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff16021790555060a48061005e6000396000f3fe6080604052348015600f57600080fd5b506004361060285760003560e01c806375fc8e3c14602d575b600080fd5b60336035565b005b60008054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16fffea26469706673582212205006867290df97c54f2df1cb94fc081197ab670e2adf5353071d2ecce1d694b864736f6c634300080d0033")] - contract SuicideContract { - address payable private owner; - constructor() public { - owner = payable(msg.sender); - } - function goodbye() public { - selfdestruct(owner); - } - } -); - #[tokio::test(flavor = "multi_thread")] -async fn test_parity_suicide_trace() { - let (_api, handle) = - spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Shanghai.into()))).await; - let provider = handle.ws_provider(); +async fn test_trace_transaction_opcode_gas_local() { + let (_api, handle) = spawn(NodeConfig::test()).await; let wallets = handle.dev_wallets().collect::>(); - let owner = wallets[0].address(); - let destructor = wallets[1].address(); + let signer: EthereumWallet = wallets[0].clone().into(); + let provider = http_provider_with_signer(&handle.http_endpoint(), signer); - let contract_addr = - SuicideContract::deploy_builder(provider.clone()).from(owner).deploy().await.unwrap(); - let contract = SuicideContract::new(contract_addr, provider.clone()); - let call = contract.goodbye().from(destructor); - let call = call.send().await.unwrap(); - let tx = call.get_receipt().await.unwrap(); + let storage = SimpleStorage::deploy(&provider, "init value".to_string()).await.unwrap(); + let receipt = + storage.setValue("bar".to_string()).send().await.unwrap().get_receipt().await.unwrap(); + let opcode_gas: Option = handle + .http_provider() + .raw_request("trace_transactionOpcodeGas".into(), (receipt.transaction_hash,)) + .await + .unwrap(); + let opcode_gas = opcode_gas.unwrap(); - let traces = handle.http_provider().trace_transaction(tx.transaction_hash).await.unwrap(); - assert!(!traces.is_empty()); - assert!(traces[1].trace.action.is_selfdestruct()); + assert_eq!(opcode_gas.transaction_hash, receipt.transaction_hash); + assert!(opcode_gas.contains("SSTORE")); } -sol!( - #[sol(rpc, bytecode = "0x6080604052348015600f57600080fd5b50336000806101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff16021790555060a48061005e6000396000f3fe6080604052348015600f57600080fd5b506004361060285760003560e01c806375fc8e3c14602d575b600080fd5b60336035565b005b60008054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16fffea26469706673582212205006867290df97c54f2df1cb94fc081197ab670e2adf5353071d2ecce1d694b864736f6c634300080d0033")] - contract DebugTraceContract { - address payable private owner; - constructor() public { - owner = payable(msg.sender); - } - function goodbye() public { - selfdestruct(owner); - } - } -); - #[tokio::test(flavor = "multi_thread")] -async fn test_transfer_debug_trace_call() { +async fn test_trace_block_opcode_gas_local() { let (_api, handle) = spawn(NodeConfig::test()).await; let wallets = handle.dev_wallets().collect::>(); - let deployer: EthereumWallet = wallets[0].clone().into(); - let provider = http_provider_with_signer(&handle.http_endpoint(), deployer); + let signer: EthereumWallet = wallets[0].clone().into(); + let provider = http_provider_with_signer(&handle.http_endpoint(), signer); + + let genesis = provider.get_block(BlockId::number(0)).await.unwrap().unwrap(); + for block_id in [BlockId::number(0), BlockId::hash(genesis.header.hash), BlockId::earliest()] { + let opcode_gas: Option = handle + .http_provider() + .raw_request("trace_blockOpcodeGas".into(), (block_id,)) + .await + .unwrap(); + let opcode_gas = opcode_gas.unwrap(); + + assert_eq!(opcode_gas.block_hash, genesis.header.hash); + assert_eq!(opcode_gas.block_number, genesis.header.number); + assert!(opcode_gas.transactions.is_empty()); + } - let contract_addr = DebugTraceContract::deploy_builder(provider.clone()) - .from(wallets[0].clone().address()) - .deploy() + let storage = SimpleStorage::deploy(&provider, "init value".to_string()).await.unwrap(); + let receipt = + storage.setValue("bar".to_string()).send().await.unwrap().get_receipt().await.unwrap(); + let block_number = receipt.block_number.unwrap(); + let block_hash = receipt.block_hash.unwrap(); + + let by_number: Option = handle + .http_provider() + .raw_request("trace_blockOpcodeGas".into(), (BlockId::number(block_number),)) + .await + .unwrap(); + let by_hash: Option = handle + .http_provider() + .raw_request("trace_blockOpcodeGas".into(), (BlockId::hash(block_hash),)) .await .unwrap(); - let caller: EthereumWallet = wallets[1].clone().into(); - let caller_provider = http_provider_with_signer(&handle.http_endpoint(), caller); - let contract = DebugTraceContract::new(contract_addr, caller_provider); + let by_number = by_number.unwrap(); + let by_hash = by_hash.unwrap(); - let call = contract.goodbye().from(wallets[1].address()); - let calldata = call.calldata().to_owned(); + assert_eq!(by_number.block_hash, block_hash); + assert_eq!(by_number.block_number, block_number); + assert_eq!(by_number.transactions.len(), 1); + assert_eq!(by_number.transactions[0].transaction_hash, receipt.transaction_hash); + assert!(by_number.contains("SSTORE")); - let tx = TransactionRequest::default() - .from(wallets[1].address()) - .to(*contract.address()) - .with_input(calldata); + assert_eq!(by_hash.block_hash, block_hash); + assert_eq!(by_hash.block_number, block_number); + assert_eq!(by_hash.transactions.len(), 1); + assert_eq!(by_hash.transactions[0].transaction_hash, receipt.transaction_hash); + assert!(by_hash.contains("SSTORE")); +} - let traces = handle - .http_provider() - .debug_trace_call( - WithOtherFields::new(tx), - BlockId::latest(), - GethDebugTracingCallOptions::default(), +#[tokio::test(flavor = "multi_thread")] +async fn test_trace_block_opcode_gas_latest_at_fork_point() { + let (_origin_api, origin_handle) = spawn(NodeConfig::test()).await; + let origin_accounts = origin_handle.dev_wallets().collect::>(); + let origin_signer: EthereumWallet = origin_accounts[0].clone().into(); + let origin_provider = http_provider_with_signer(&origin_handle.http_endpoint(), origin_signer); + let storage = SimpleStorage::deploy(&origin_provider, "init value".to_string()).await.unwrap(); + let receipt = storage + .setValue("fork point".to_string()) + .send() + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + let fork_point_number = receipt.block_number.unwrap(); + let fork_point_hash = receipt.block_hash.unwrap(); + + let (_api, handle) = + spawn(NodeConfig::test().with_eth_rpc_url(Some(origin_handle.http_endpoint()))).await; + let provider = handle.http_provider(); + + // Advance the upstream head after the fork captures its snapshot. + let advanced = + storage.setValue("advanced".to_string()).send().await.unwrap().get_receipt().await.unwrap(); + assert!(advanced.block_number.unwrap() > fork_point_number); + assert_eq!(provider.get_block_number().await.unwrap(), fork_point_number); + + let mut by_latest = provider + .raw_request::<_, Option>( + "trace_blockOpcodeGas".into(), + (BlockId::latest(),), ) .await + .unwrap() + .unwrap(); + let mut by_number = provider + .raw_request::<_, Option>( + "trace_blockOpcodeGas".into(), + (BlockId::number(fork_point_number),), + ) + .await + .unwrap() .unwrap(); - match traces { - GethTrace::Default(default_frame) => { - assert!(!default_frame.failed); - } - _ => { - unreachable!() - } + assert_eq!(by_number.block_hash, fork_point_hash); + assert_eq!(by_number.block_number, fork_point_number); + assert_eq!(by_number.transactions.len(), 1); + assert_eq!(by_number.transactions[0].transaction_hash, receipt.transaction_hash); + assert_eq!(by_latest.block_number, by_number.block_number); + assert_eq!(by_latest.block_hash, by_number.block_hash); + // Opcode gas entries are collected from a map and have no stable order. + for transaction in by_latest.transactions.iter_mut().chain(&mut by_number.transactions) { + transaction.opcode_gas.sort_unstable_by(|a, b| a.opcode.cmp(&b.opcode)); } + assert_eq!(by_latest.transactions, by_number.transactions); } #[tokio::test(flavor = "multi_thread")] -async fn test_call_tracer_debug_trace_call() { - let (_api, handle) = spawn(NodeConfig::test()).await; - let wallets = handle.dev_wallets().collect::>(); - let deployer: EthereumWallet = wallets[0].clone().into(); - let provider = http_provider_with_signer(&handle.http_endpoint(), deployer); +async fn test_trace_raw_transaction_local() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); - let multicall_contract = Multicall::deploy(&provider).await.unwrap(); + let accounts = handle.dev_wallets().collect::>(); + let from = accounts[0].address(); + let to = accounts[1].address(); + let value = U256::from(1_000_000_000u64); + let from_balance = provider.get_balance(from).await.unwrap(); + let to_balance = provider.get_balance(to).await.unwrap(); - let simple_storage_contract = - SimpleStorage::deploy(&provider, "init value".to_string()).await.unwrap(); + let tx = TransactionRequest::default() + .from(from) + .to(to) + .value(value) + .with_gas_limit(21_000) + .max_fee_per_gas(20_000_000_000) + .max_priority_fee_per_gas(1_000_000_000); + let signed_tx = api.sign_transaction(WithOtherFields::new(tx)).await.unwrap(); + let raw_tx = hex::decode(&signed_tx[2..]).unwrap(); + + let traces = + provider.trace_raw_transaction(&raw_tx).trace().state_diff().vm_trace().await.unwrap(); + + assert!(traces.state_diff.is_some()); + assert!(traces.vm_trace.is_some()); + assert!(!traces.trace.is_empty()); + match traces.trace[0].action { + Action::Call(ref call) => { + assert_eq!(call.from, from); + assert_eq!(call.to, to); + assert_eq!(call.value, value); + } + _ => unreachable!("unexpected action"), + } - let set_value = simple_storage_contract.setValue("bar".to_string()); - let set_value_calldata = set_value.calldata(); + assert_eq!(provider.get_transaction_count(from).await.unwrap(), 0); + assert_eq!(provider.get_balance(from).await.unwrap(), from_balance); + assert_eq!(provider.get_balance(to).await.unwrap(), to_balance); +} - let internal_call_tx_builder = multicall_contract.aggregate(vec![Multicall::Call { - target: *simple_storage_contract.address(), - callData: set_value_calldata.to_owned(), - }]); +#[tokio::test(flavor = "multi_thread")] +async fn test_trace_raw_transaction_rejects_code_sender() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); - let internal_call_tx_calldata = internal_call_tx_builder.calldata().to_owned(); + let accounts = handle.dev_wallets().collect::>(); + let from = accounts[0].address(); + let tx = TransactionRequest::default() + .from(from) + .to(accounts[1].address()) + .value(U256::ONE) + .with_gas_limit(21_000) + .max_fee_per_gas(20_000_000_000) + .max_priority_fee_per_gas(1_000_000_000); + let signed_tx = api.sign_transaction(WithOtherFields::new(tx)).await.unwrap(); + let raw_tx = hex::decode(&signed_tx[2..]).unwrap(); + + api.anvil_set_code(from, Bytes::from_static(&[0x00])).await.unwrap(); + let error = provider.trace_raw_transaction(&raw_tx).trace().await.unwrap_err(); + let error = error.as_error_resp().unwrap(); + assert_eq!((error.code, error.message.as_ref()), (-32003, "sender not an eoa")); + + // An EIP-7702 delegation keeps the account an EOA. + let delegation = [&[0xef, 0x01, 0x00][..], accounts[2].address().as_slice()].concat(); + api.anvil_set_code(from, delegation.into()).await.unwrap(); + let traces = provider.trace_raw_transaction(&raw_tx).trace().await.unwrap(); + assert_eq!(traces.trace.len(), 1); + + // Mining keeps EIP-3607 disabled, so the code sender's transaction is still included. + api.anvil_set_code(from, Bytes::from_static(&[0x00])).await.unwrap(); + let receipt = + provider.send_raw_transaction(&raw_tx).await.unwrap().get_receipt().await.unwrap(); + assert!(receipt.status()); +} - // calling SimpleStorage contract through Multicall should result in an internal call - let internal_call_tx = TransactionRequest::default() - .from(wallets[1].address()) - .to(*multicall_contract.address()) - .with_input(internal_call_tx_calldata); +#[tokio::test(flavor = "multi_thread")] +async fn test_debug_account_info_at_local() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); - let internal_call_tx_traces = handle - .http_provider() - .debug_trace_call( - WithOtherFields::new(internal_call_tx.clone()), - BlockId::latest(), - GethDebugTracingCallOptions::default().with_tracing_options( - GethDebugTracingOptions::default() - .with_tracer(GethDebugTracerType::from(GethDebugBuiltInTracerType::CallTracer)) - .with_call_config(CallConfig::default().with_log()), - ), + let accounts = handle.dev_wallets().collect::>(); + let from = accounts[0].address(); + let to = accounts[1].address(); + let amount = U256::from(1000); + + let tx = TransactionRequest::default().to(to).value(amount).from(from); + let tx = WithOtherFields::new(tx); + let receipt = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + let block_number = receipt.block_number.unwrap(); + let block_hash = receipt.block_hash.unwrap(); + + let by_number: Option = provider + .raw_request( + "debug_accountInfoAt".into(), + (BlockId::number(block_number), Index::from(0), to), ) .await .unwrap(); + let by_hash: Option = provider + .raw_request("debug_accountInfoAt".into(), (BlockId::hash(block_hash), Index::from(0), to)) + .await + .unwrap(); - match internal_call_tx_traces { - GethTrace::CallTracer(call_frame) => { - assert!(call_frame.calls.len() == 1); - assert!( - call_frame.calls.first().unwrap().to.unwrap() == *simple_storage_contract.address() - ); - assert!(call_frame.calls.first().unwrap().logs.len() == 1); - } - _ => { - unreachable!() - } + let expected_balance = handle.genesis_balance().saturating_add(amount); + for account in [by_number.unwrap(), by_hash.unwrap()] { + assert_eq!(account.balance, expected_balance); + assert_eq!(account.nonce, 0); + assert!(account.code.is_empty()); } +} - // only_top_call option - should not return any internal calls - let internal_call_only_top_call_tx_traces = handle - .http_provider() - .debug_trace_call( - WithOtherFields::new(internal_call_tx.clone()), - BlockId::latest(), - GethDebugTracingCallOptions::default().with_tracing_options( - GethDebugTracingOptions::default() - .with_tracer(GethDebugTracerType::from(GethDebugBuiltInTracerType::CallTracer)) - .with_call_config(CallConfig::default().with_log().only_top_call()), - ), - ) +#[tokio::test(flavor = "multi_thread")] +async fn test_debug_account_info_at_local_block_on_fork() { + let (_origin_api, origin_handle) = spawn(NodeConfig::test()).await; + let origin_provider = origin_handle.http_provider(); + let origin_accounts = origin_handle.dev_wallets().collect::>(); + let origin_signer: EthereumWallet = origin_accounts[0].clone().into(); + let origin_provider_with_signer = + http_provider_with_signer(&origin_handle.http_endpoint(), origin_signer); + let storage = SimpleStorage::deploy(&origin_provider_with_signer, "init value".to_string()) .await .unwrap(); + let fork_account = *storage.address(); + let expected_code = origin_provider.get_code_at(fork_account).await.unwrap(); - match internal_call_only_top_call_tx_traces { - GethTrace::CallTracer(call_frame) => { - assert!(call_frame.calls.is_empty()); - } - _ => { - unreachable!() - } - } + let (_api, handle) = + spawn(NodeConfig::test().with_eth_rpc_url(Some(origin_handle.http_endpoint()))).await; + let provider = handle.http_provider(); - // directly calling the SimpleStorage contract should not result in any internal calls - let direct_call_tx = TransactionRequest::default() - .from(wallets[1].address()) - .to(*simple_storage_contract.address()) - .with_input(set_value_calldata.to_owned()); + let accounts = handle.dev_wallets().collect::>(); + let from = accounts[0].address(); + let to = accounts[1].address(); + let tx = TransactionRequest::default().to(to).value(U256::from(1000)).from(from); + let receipt = provider + .send_transaction(WithOtherFields::new(tx)) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + let block_number = receipt.block_number.unwrap(); - let direct_call_tx_traces = handle - .http_provider() - .debug_trace_call( - WithOtherFields::new(direct_call_tx), - BlockId::latest(), - GethDebugTracingCallOptions::default().with_tracing_options( - GethDebugTracingOptions::default() - .with_tracer(GethDebugTracerType::from(GethDebugBuiltInTracerType::CallTracer)) - .with_call_config(CallConfig::default().with_log()), - ), + let account: Option = provider + .raw_request( + "debug_accountInfoAt".into(), + (BlockId::number(block_number), Index::from(0), fork_account), ) .await .unwrap(); + let account = account.unwrap(); - match direct_call_tx_traces { - GethTrace::CallTracer(call_frame) => { - assert!(call_frame.calls.is_empty()); - assert!(call_frame.to.unwrap() == *simple_storage_contract.address()); - assert!(call_frame.logs.len() == 1); - } - _ => { - unreachable!() - } - } + assert_eq!(account.balance, U256::ZERO); + assert_eq!(account.nonce, 1); + assert_eq!(account.code, expected_code); } #[tokio::test(flavor = "multi_thread")] -async fn test_debug_trace_call_state_override() { - let (_api, handle) = spawn(NodeConfig::test()).await; - let wallets = handle.dev_wallets().collect::>(); - - let tx = TransactionRequest::default() - .from(wallets[1].address()) - .to("0x1234567890123456789012345678901234567890".parse().unwrap()); - - let override_json = r#"{ - "0x1234567890123456789012345678901234567890": { - "balance": "0x01", - "code": "0x30315f5260205ff3" - } - }"#; +async fn test_debug_account_info_at_delegates_pre_fork_block() { + let (_origin_api, origin_handle) = spawn(NodeConfig::test()).await; + let origin_provider = origin_handle.http_provider(); + let origin_accounts = origin_handle.dev_wallets().collect::>(); + let from = origin_accounts[0].address(); + let to = origin_accounts[1].address(); + let amount = U256::from(1000); + let tx = TransactionRequest::default().to(to).value(amount).from(from); + origin_provider + .send_transaction(WithOtherFields::new(tx)) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); - let state_override: StateOverride = serde_json::from_str(override_json).unwrap(); + let (_api, handle) = + spawn(NodeConfig::test().with_eth_rpc_url(Some(origin_handle.http_endpoint()))).await; + let provider = handle.http_provider(); - let tx_traces = handle - .http_provider() - .debug_trace_call( - WithOtherFields::new(tx.clone()), - BlockId::latest(), - GethDebugTracingCallOptions::default() - .with_tracing_options(GethDebugTracingOptions::default()) - .with_state_overrides(state_override), - ) + let account: Option = provider + .raw_request("debug_accountInfoAt".into(), (BlockId::number(1), Index::from(0), to)) .await .unwrap(); + let account = account.unwrap(); - match tx_traces { - GethTrace::Default(trace_res) => { - assert_eq!( - trace_res.return_value, - Bytes::from_hex("0000000000000000000000000000000000000000000000000000000000000001") - .unwrap() - ); - } - _ => { - unreachable!() - } - } + assert_eq!(account.balance, origin_handle.genesis_balance().saturating_add(amount)); + assert_eq!(account.nonce, 0); + assert!(account.code.is_empty()); } -// #[tokio::test(flavor = "multi_thread")] -async fn test_trace_address_fork() { - let (api, handle) = spawn(fork_config().with_fork_block_number(Some(15291050u64))).await; +async fn test_trace_call_local() { + let (_api, handle) = spawn(NodeConfig::test()).await; let provider = handle.http_provider(); - let input = hex::decode("43bcfab60000000000000000000000006b175474e89094c44da98b954eedeac495271d0f0000000000000000000000000000000000000000000000e0bd811c8769a824b00000000000000000000000000000000000000000000000e0ae9925047d8440b60000000000000000000000002e4777139254ff76db957e284b186a4507ff8c67").unwrap(); - - let from: Address = "0x2e4777139254ff76db957e284b186a4507ff8c67".parse().unwrap(); - let to: Address = "0xe2f2a5c287993345a840db3b0845fbc70f5935a5".parse().unwrap(); - let tx = TransactionRequest::default() - .to(to) - .from(from) - .with_input::(input.into()) - .with_gas_limit(300_000); - + let accounts = handle.dev_wallets().collect::>(); + let from = accounts[0].address(); + let to = accounts[1].address(); + let amount = U256::from(1000); + let before = provider.get_balance(to).await.unwrap(); + let tx = TransactionRequest::default().to(to).value(amount).from(from); let tx = WithOtherFields::new(tx); - api.anvil_impersonate_account(from).await.unwrap(); - let tx = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + let traces: TraceResults = provider + .client() + .request( + "trace_call", + ( + tx, + vec![TraceType::Trace, TraceType::VmTrace, TraceType::StateDiff], + BlockId::latest(), + ), + ) + .await + .unwrap(); - let traces = provider.trace_transaction(tx.transaction_hash).await.unwrap(); - assert!(!traces.is_empty()); - match traces[0].trace.action { - Action::Call(ref call) => { + assert!(!traces.trace.is_empty()); + assert!(traces.vm_trace.is_some()); + assert!(traces.state_diff.is_some()); + + match &traces.trace[0].action { + Action::Call(call) => { assert_eq!(call.from, from); assert_eq!(call.to, to); + assert_eq!(call.value, amount); } - _ => unreachable!("unexpected action"), + action => panic!("expected call action, got {action:?}"), } - let json = serde_json::json!([ - { - "action": { - "callType": "call", - "from": "0x2e4777139254ff76db957e284b186a4507ff8c67", - "gas": "0x262b3", - "input": "0x43bcfab60000000000000000000000006b175474e89094c44da98b954eedeac495271d0f0000000000000000000000000000000000000000000000e0bd811c8769a824b00000000000000000000000000000000000000000000000e0ae9925047d8440b60000000000000000000000002e4777139254ff76db957e284b186a4507ff8c67", - "to": "0xe2f2a5c287993345a840db3b0845fbc70f5935a5", - "value": "0x0" - }, - "blockHash": "0xa47c8f1d8c284cb614e9c8e10d260b33eae16b1957a83141191bc335838d7e29", - "blockNumber": 15291051, - "result": { - "gasUsed": "0x2131b", - "output": "0x0000000000000000000000000000000000000000000000e0e82ca52ec6e6a4d3" - }, - "subtraces": 1, - "traceAddress": [], - "transactionHash": "0x3255cce7312e9c4470e1a1883be13718e971f6faafb96199b8bd75e5b7c39e3a", - "transactionPosition": 19, - "type": "call" - }, - { - "action": { - "callType": "delegatecall", - "from": "0xe2f2a5c287993345a840db3b0845fbc70f5935a5", - "gas": "0x23d88", - "input": "0x43bcfab60000000000000000000000006b175474e89094c44da98b954eedeac495271d0f0000000000000000000000000000000000000000000000e0bd811c8769a824b00000000000000000000000000000000000000000000000e0ae9925047d8440b60000000000000000000000002e4777139254ff76db957e284b186a4507ff8c67", - "to": "0x15b2838cd28cc353afbe59385db3f366d8945aee", - "value": "0x0" - }, - "blockHash": "0xa47c8f1d8c284cb614e9c8e10d260b33eae16b1957a83141191bc335838d7e29", - "blockNumber": 15291051, - "result": { - "gasUsed": "0x1f6e1", - "output": "0x0000000000000000000000000000000000000000000000e0e82ca52ec6e6a4d3" - }, - "subtraces": 2, - "traceAddress": [0], - "transactionHash": "0x3255cce7312e9c4470e1a1883be13718e971f6faafb96199b8bd75e5b7c39e3a", - "transactionPosition": 19, - "type": "call" - }, - { - "action": { - "callType": "staticcall", - "from": "0xe2f2a5c287993345a840db3b0845fbc70f5935a5", - "gas": "0x192ed", - "input": "0x50494dc000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000e0b1ff65617f654b2f00000000000000000000000000000000000000000000000000000000000061a800000000000000000000000000000000000000000000000000b1a2bc2ec5000000000000000000000000000000000000000000000000000006f05b59d3b2000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000005f5e1000000000000000000000000000000000000000000000414ec22973db48fd3a3370000000000000000000000000000000000000000000000056bc75e2d6310000000000000000000000000000000000000000000000000000000000a7314a9ba5c0000000000000000000000000000000000000000000000000000000005f5e100000000000000000000000000000000000000000000095f783edc5a5dabcb4ba70000000000000000000000000000000000000000000000056bc75e2d6310000000000000000000000000000000000000000000000000000000000a3f42df4dab", - "to": "0xca480d596e6717c95a62a4dc1bd4fbd7b7e7d705", - "value": "0x0" - }, - "blockHash": "0xa47c8f1d8c284cb614e9c8e10d260b33eae16b1957a83141191bc335838d7e29", - "blockNumber": 15291051, - "result": { - "gasUsed": "0x661a", - "output": "0x0000000000000000000000000000000000000000000000e0e82ca52ec6e6a4d3" - }, - "subtraces": 0, - "traceAddress": [0, 0], - "transactionHash": "0x3255cce7312e9c4470e1a1883be13718e971f6faafb96199b8bd75e5b7c39e3a", - "transactionPosition": 19, - "type": "call" - }, - { - "action": { - "callType": "delegatecall", - "from": "0xe2f2a5c287993345a840db3b0845fbc70f5935a5", - "gas": "0xd2dc", - "input": "0x4e331a540000000000000000000000000000000000000000000000e0e82ca52ec6e6a4d30000000000000000000000006b175474e89094c44da98b954eedeac495271d0f000000000000000000000000a2a3cae63476891ab2d640d9a5a800755ee79d6e000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000005f5e100000000000000000000000000000000000000000000095f783edc5a5dabcb4ba70000000000000000000000002e4777139254ff76db957e284b186a4507ff8c6700000000000000000000000000000000000000000000f7be2b91f8a2e2df496e", - "to": "0x1e91f826fa8aa4fa4d3f595898af3a64dd188848", - "value": "0x0" - }, - "blockHash": "0xa47c8f1d8c284cb614e9c8e10d260b33eae16b1957a83141191bc335838d7e29", - "blockNumber": 15291051, - "result": { - "gasUsed": "0x7617", - "output": "0x" - }, - "subtraces": 2, - "traceAddress": [0, 1], - "transactionHash": "0x3255cce7312e9c4470e1a1883be13718e971f6faafb96199b8bd75e5b7c39e3a", - "transactionPosition": 19, - "type": "call" - }, - { - "action": { - "callType": "staticcall", - "from": "0xe2f2a5c287993345a840db3b0845fbc70f5935a5", - "gas": "0xbf50", - "input": "0x70a08231000000000000000000000000a2a3cae63476891ab2d640d9a5a800755ee79d6e", - "to": "0x6b175474e89094c44da98b954eedeac495271d0f", - "value": "0x0" - }, - "blockHash": "0xa47c8f1d8c284cb614e9c8e10d260b33eae16b1957a83141191bc335838d7e29", - "blockNumber": 15291051, - "result": { - "gasUsed": "0xa2a", - "output": "0x0000000000000000000000000000000000000000000020fe99f8898600d94750" - }, - "subtraces": 0, - "traceAddress": [0, 1, 0], - "transactionHash": "0x3255cce7312e9c4470e1a1883be13718e971f6faafb96199b8bd75e5b7c39e3a", - "transactionPosition": 19, - "type": "call" - }, - { - "action": { - "callType": "call", - "from": "0xe2f2a5c287993345a840db3b0845fbc70f5935a5", - "gas": "0xa92a", - "input": "0xa4e285950000000000000000000000002e4777139254ff76db957e284b186a4507ff8c670000000000000000000000006b175474e89094c44da98b954eedeac495271d0f0000000000000000000000000000000000000000000000e0e82ca52ec6e6a4d3", - "to": "0xa2a3cae63476891ab2d640d9a5a800755ee79d6e", - "value": "0x0" - }, - "blockHash": "0xa47c8f1d8c284cb614e9c8e10d260b33eae16b1957a83141191bc335838d7e29", - "blockNumber": 15291051, - "result": { - "gasUsed": "0x4ed3", - "output": "0x" - }, - "subtraces": 1, - "traceAddress": [0, 1, 1], - "transactionHash": "0x3255cce7312e9c4470e1a1883be13718e971f6faafb96199b8bd75e5b7c39e3a", - "transactionPosition": 19, - "type": "call" - }, - { - "action": { - "callType": "call", - "from": "0xa2a3cae63476891ab2d640d9a5a800755ee79d6e", - "gas": "0x8c90", - "input": "0xa9059cbb0000000000000000000000002e4777139254ff76db957e284b186a4507ff8c670000000000000000000000000000000000000000000000e0e82ca52ec6e6a4d3", - "to": "0x6b175474e89094c44da98b954eedeac495271d0f", - "value": "0x0" - }, - "blockHash": "0xa47c8f1d8c284cb614e9c8e10d260b33eae16b1957a83141191bc335838d7e29", - "blockNumber": 15291051, - "result": { - "gasUsed": "0x2b42", - "output": "0x0000000000000000000000000000000000000000000000000000000000000001" - }, - "subtraces": 0, - "traceAddress": [0, 1, 1, 0], - "transactionHash": "0x3255cce7312e9c4470e1a1883be13718e971f6faafb96199b8bd75e5b7c39e3a", - "transactionPosition": 19, - "type": "call" - } - ]); + let ChangedType { from: before_diff, to: after_diff } = + traces.state_diff.as_ref().unwrap().get(&to).unwrap().balance.as_changed().unwrap(); + assert_eq!(*before_diff, before); + assert_eq!(after_diff.saturating_sub(*before_diff), amount); - let expected_traces: Vec = serde_json::from_value(json).unwrap(); + let after = provider.get_balance(to).await.unwrap(); + assert_eq!(after, before); +} - // test matching traceAddress - traces.into_iter().zip(expected_traces).for_each(|(a, b)| { - assert_eq!(a.trace.trace_address, b.trace.trace_address); - assert_eq!(a.trace.subtraces, b.trace.subtraces); - match (a.trace.action, b.trace.action) { - (Action::Call(a), Action::Call(b)) => { - assert_eq!(a.from, b.from); - assert_eq!(a.to, b.to); - } - _ => unreachable!("unexpected action"), - } +/// Traces `tx` against the latest block and returns its Parity `trace` and `stateDiff` results. +async fn trace_call_state_diff( + provider: &impl Provider, + tx: TransactionRequest, +) -> TraceResults { + provider + .client() + .request( + "trace_call", + ( + WithOtherFields::new(tx), + vec![TraceType::Trace, TraceType::StateDiff], + BlockId::latest(), + ), + ) + .await + .unwrap() +} + +/// Returns the `stateDiff` entry for `address` as JSON, or `None` if it isn't in the diff. +fn state_diff_entry(traces: &TraceResults, address: Address) -> Option { + let diff = traces.state_diff.as_ref().unwrap().get(&address)?; + Some(serde_json::to_value(diff).unwrap()) +} + +/// Returns the `stateDiff` entry of an account born with the given fields. +fn added_account(balance: &str, nonce: &str, code: &str) -> serde_json::Value { + json!({"balance": {"+": balance}, "nonce": {"+": nonce}, "code": {"+": code}, "storage": {}}) +} + +/// Returns the `stateDiff` entry of an otherwise unchanged account whose balance changed. +fn changed_balance(from: &str, to: &str) -> serde_json::Value { + json!({ + "balance": {"*": {"from": from, "to": to}}, + "nonce": "=", + "code": "=", + "storage": {}, }) } -// -// +/// PUSH1 0x2a PUSH1 0 MSTORE8 PUSH1 1 PUSH1 0 RETURN: deploys the runtime code `0x2a`. +const INIT_RETURNING_2A: &str = "0x602a60005360016000f3"; +/// Stores 0x2a at slot zero, then deploys the runtime code `0x2a`. +const INIT_STORING_AND_RETURNING_2A: &str = "0x602a600055602a60005360016000f3"; + #[tokio::test(flavor = "multi_thread")] -async fn test_trace_address_fork2() { - let (api, handle) = spawn(fork_config().with_fork_block_number(Some(15314401u64))).await; +async fn test_trace_call_state_diff_created_account() { + let (_api, handle) = spawn(NodeConfig::test()).await; let provider = handle.http_provider(); + let from = handle.dev_wallets().next().unwrap().address(); + let nonce = provider.get_transaction_count(from).await.unwrap(); + let created = from.create(nonce); + + // PUSH1 0 PUSH1 0 RETURN deploys empty code, which is still marked as added. + for (init, code) in [("0x60006000f3", "0x"), (INIT_RETURNING_2A, "0x2a")] { + let tx = TransactionRequest::default() + .from(from) + .with_deploy_code(Bytes::from_hex(init).unwrap()); + let traces = trace_call_state_diff(&provider, tx).await; + assert_eq!(state_diff_entry(&traces, created), Some(added_account("0x0", "0x1", code))); + } - let input = hex::decode("30000003000000000000000000000000adda1059a6c6c102b0fa562b9bb2cb9a0de5b1f4000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a300000004fffffffffffffffffffffffffffffffffffffffffffff679dc91ecfe150fb980c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2f4d2888d29d722226fafa5d9b24f9164c092421e000bb8000000000000004319b52bf08b65295d49117e790000000000000000000000000000000000000000000000008b6d9e8818d6141f000000000000000000000000000000000000000000000000000000086a23af210000000000000000000000000000000000000000000000000000000000").unwrap(); - - let from: Address = "0xa009fa1ac416ec02f6f902a3a4a584b092ae6123".parse().unwrap(); - let to: Address = "0x99999999d116ffa7d76590de2f427d8e15aeb0b8".parse().unwrap(); + // A successful CREATE at a prefunded address changes the existing account, including storage. + let prefunded = from.create(nonce + 1); + let fund = TransactionRequest::default().from(from).to(prefunded).value(U256::from(7)); + provider + .send_transaction(WithOtherFields::new(fund)) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); let tx = TransactionRequest::default() - .to(to) .from(from) - .with_input::(input.into()) - .with_gas_limit(350_000); + .with_deploy_code(Bytes::from_hex(INIT_STORING_AND_RETURNING_2A).unwrap()); + let traces = trace_call_state_diff(&provider, tx).await; + assert_eq!( + state_diff_entry(&traces, prefunded), + Some(json!({ + "balance": "=", + "nonce": {"*": {"from": "0x0", "to": "0x1"}}, + "code": {"*": {"from": "0x", "to": "0x2a"}}, + "storage": { + (B256::ZERO.to_string()): {"*": { + "from": B256::ZERO.to_string(), + "to": B256::with_last_byte(42).to_string(), + }}, + }, + })) + ); +} - let tx = WithOtherFields::new(tx); - api.anvil_impersonate_account(from).await.unwrap(); +#[tokio::test(flavor = "multi_thread")] +async fn test_trace_call_state_diff_funded_account() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + let accounts = handle.dev_wallets().collect::>(); + let from = accounts[0].address(); - let tx = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); - let status = tx.inner.inner.inner.receipt.status.coerce_status(); - assert!(status); + let fresh = Address::repeat_byte(0x44); + let tx = TransactionRequest::default().from(from).to(fresh).value(U256::from(7)); + let traces = trace_call_state_diff(&provider, tx).await; + assert_eq!(state_diff_entry(&traces, fresh), Some(added_account("0x7", "0x0", "0x"))); + + // A zero-value call leaves the account absent. + let tx = TransactionRequest::default().from(from).to(fresh); + let traces = trace_call_state_diff(&provider, tx).await; + assert_eq!(state_diff_entry(&traces, fresh), None); + + // An account that already exists is changed, not added. + let funded = accounts[1].address(); + let before = provider.get_balance(funded).await.unwrap(); + let tx = TransactionRequest::default().from(from).to(funded).value(U256::from(7)); + let traces = trace_call_state_diff(&provider, tx).await; + assert_eq!( + state_diff_entry(&traces, funded), + Some(changed_balance(&format!("{before:#x}"), &format!("{:#x}", before + U256::from(7)),)) + ); +} - let traces = provider.trace_transaction(tx.transaction_hash).await.unwrap(); +#[tokio::test(flavor = "multi_thread")] +async fn test_trace_call_state_diff_funded_account_fork() { + let (_origin_api, origin_handle) = spawn(NodeConfig::test()).await; + let (_api, handle) = + spawn(NodeConfig::test().with_eth_rpc_url(Some(origin_handle.http_endpoint()))).await; + let from = handle.dev_wallets().next().unwrap().address(); + + // The fork backend also returns an empty account for an address the upstream doesn't have. + let fresh = Address::repeat_byte(0x44); + let tx = TransactionRequest::default().from(from).to(fresh).value(U256::from(7)); + let traces = trace_call_state_diff(&handle.http_provider(), tx).await; + assert_eq!(state_diff_entry(&traces, fresh), Some(added_account("0x7", "0x0", "0x"))); +} - assert!(!traces.is_empty()); - match traces[0].trace.action { - Action::Call(ref call) => { - assert_eq!(call.from, from); - assert_eq!(call.to, to); - } - _ => unreachable!("unexpected action"), - } +#[tokio::test(flavor = "multi_thread")] +async fn test_trace_call_state_diff_create_and_selfdestruct() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + let from = handle.dev_wallets().next().unwrap().address(); + let nonce = provider.get_transaction_count(from).await.unwrap(); - let json = serde_json::json!([ - { - "action": { - "from": "0xa009fa1ac416ec02f6f902a3a4a584b092ae6123", + // CALLER SELFDESTRUCT: the account is absent before and after the transaction. + let tx = TransactionRequest::default() + .from(from) + .with_deploy_code(Bytes::from_hex("0x33ff").unwrap()); + let traces = trace_call_state_diff(&provider, tx.clone()).await; + assert!(traces.trace.iter().any(|trace| trace.action.is_selfdestruct())); + assert!(traces.state_diff.as_ref().unwrap().contains_key(&from)); + assert_eq!(state_diff_entry(&traces, from.create(nonce)), None); + + // If the address was funded beforehand, the account is deleted. + let prefunded = from.create(nonce + 1); + let fund = TransactionRequest::default().from(from).to(prefunded).value(U256::from(7)); + provider + .send_transaction(WithOtherFields::new(fund)) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + let traces = trace_call_state_diff(&provider, tx).await; + assert!(traces.trace.iter().any(|trace| trace.action.is_selfdestruct())); + assert_eq!( + state_diff_entry(&traces, prefunded), + Some(json!({ + "balance": {"-": "0x7"}, + "nonce": {"-": "0x0"}, + "code": {"-": "0x"}, + "storage": {}, + })) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_trace_raw_transaction_state_diff_funded_account() { + let (api, handle) = spawn(NodeConfig::test()).await; + let from = handle.dev_wallets().next().unwrap().address(); + + let fresh = Address::repeat_byte(0x44); + let tx = TransactionRequest::default() + .from(from) + .to(fresh) + .value(U256::from(7)) + .with_gas_limit(21_000) + .max_fee_per_gas(20_000_000_000) + .max_priority_fee_per_gas(1_000_000_000); + let signed_tx = api.sign_transaction(WithOtherFields::new(tx)).await.unwrap(); + let raw_tx = hex::decode(&signed_tx[2..]).unwrap(); + + let traces = handle.http_provider().trace_raw_transaction(&raw_tx).state_diff().await.unwrap(); + assert_eq!(state_diff_entry(&traces, fresh), Some(added_account("0x7", "0x0", "0x"))); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_trace_call_many_state_diff_funded_account() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let from = handle.dev_wallets().next().unwrap().address(); + let fresh = Address::repeat_byte(0x44); + + let tx = TransactionRequest::default().from(from).to(fresh).value(U256::from(7)); + let calls = [ + (WithOtherFields::new(tx.clone()), [TraceType::StateDiff].as_slice()), + (WithOtherFields::new(tx), [TraceType::StateDiff].as_slice()), + ]; + let traces = handle.http_provider().trace_call_many(&calls).await.unwrap(); + + assert_eq!(state_diff_entry(&traces[0], fresh), Some(added_account("0x7", "0x0", "0x"))); + // The second call sees the account the first one created. + assert_eq!(state_diff_entry(&traces[1], fresh), Some(changed_balance("0x7", "0xe"))); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_trace_call_state_diff_selfdestruct_hardfork() { + let contract = Address::repeat_byte(0x55); + + for (hardfork, expected) in [ + ( + EthereumHardfork::Shanghai, + json!({ + "balance": {"-": "0x7"}, + "nonce": {"-": "0x0"}, + "code": {"-": "0x33ff"}, + "storage": {}, + }), + ), + (EthereumHardfork::Cancun, changed_balance("0x7", "0x0")), + ] { + let (api, handle) = spawn(NodeConfig::test().with_hardfork(Some(hardfork.into()))).await; + let provider = handle.http_provider(); + let from = handle.dev_wallets().next().unwrap().address(); + api.anvil_set_code(contract, Bytes::from_hex("0x33ff").unwrap()).await.unwrap(); + api.anvil_set_balance(contract, U256::from(7)).await.unwrap(); + api.anvil_set_storage_at(contract, U256::ZERO, B256::with_last_byte(42)).await.unwrap(); + + let traces = + trace_call_state_diff(&provider, TransactionRequest::default().from(from).to(contract)) + .await; + assert!(traces.trace.iter().any(|trace| trace.action.is_selfdestruct())); + assert_eq!(state_diff_entry(&traces, contract), Some(expected), "{hardfork}"); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_trace_call_safe_at_fork_point() { + let epoch = 1u64; + let (_origin_api, origin_handle) = + spawn(NodeConfig::test().with_slots_in_an_epoch(epoch)).await; + let origin_provider = origin_handle.http_provider(); + let origin_accounts = origin_handle.dev_wallets().collect::>(); + let from = origin_accounts[0].address(); + let to = origin_accounts[1].address(); + let amount1 = U256::from(1_000); + let amount2 = U256::from(2_000); + let amount3 = U256::from(3_000); + + // Block 1: `to` balance = genesis + amount1. + let tx1 = TransactionRequest::default().to(to).value(amount1).from(from); + origin_provider + .send_transaction(WithOtherFields::new(tx1)) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + + // Block 2: the fork point. `to` balance = genesis + amount1 + amount2. + let tx2 = TransactionRequest::default().to(to).value(amount2).from(from); + let receipt2 = origin_provider + .send_transaction(WithOtherFields::new(tx2)) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + let fork_point_number = receipt2.block_number.unwrap(); + assert_eq!(fork_point_number, 2); + + let (_api, handle) = spawn( + NodeConfig::test() + .with_slots_in_an_epoch(epoch) + .with_eth_rpc_url(Some(origin_handle.http_endpoint())), + ) + .await; + let provider = handle.http_provider(); + assert_eq!(provider.get_block_number().await.unwrap(), fork_point_number); + + // Advance the upstream head after the fork captures its snapshot: `to` balance becomes + // genesis + amount1 + amount2 + amount3 on the ORIGIN chain (block 3), while the forked + // node's own local chain has not advanced past the fork point. + let tx3 = TransactionRequest::default().to(to).value(amount3).from(from); + let receipt3 = origin_provider + .send_transaction(WithOtherFields::new(tx3)) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert_eq!(receipt3.block_number.unwrap(), fork_point_number + 1); + assert_eq!(provider.get_block_number().await.unwrap(), fork_point_number); + + let call_amount = U256::ONE; + let call = + WithOtherFields::new(TransactionRequest::default().to(to).value(call_amount).from(from)); + + // `safe` resolves locally (current=2, epoch=1) to block 1 - the fork's own snapshot, + // never the upstream chain's post-fork block 2. + let by_safe: TraceResults = provider + .client() + .request("trace_call", (call.clone(), vec![TraceType::StateDiff], BlockId::safe())) + .await + .unwrap(); + let by_number: TraceResults = provider + .client() + .request("trace_call", (call, vec![TraceType::StateDiff], BlockId::number(1))) + .await + .unwrap(); + + let ChangedType { from: before_safe, to: after_safe } = + by_safe.state_diff.as_ref().unwrap().get(&to).unwrap().balance.as_changed().unwrap(); + let ChangedType { from: before_number, to: after_number } = + by_number.state_diff.as_ref().unwrap().get(&to).unwrap().balance.as_changed().unwrap(); + + let expected_balance_at_block_1 = origin_handle.genesis_balance().saturating_add(amount1); + assert_eq!(*before_number, expected_balance_at_block_1); + assert_eq!( + *before_safe, expected_balance_at_block_1, + "trace_call(safe) drifted to the upstream tip's resolution instead of the fork's own \ + local resolution" + ); + assert_eq!(before_safe, before_number); + assert_eq!(after_safe, after_number); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_trace_call_many_local() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let wallets = handle.dev_wallets().collect::>(); + let deployer: EthereumWallet = wallets[0].clone().into(); + let provider = http_provider_with_signer(&handle.http_endpoint(), deployer); + + let storage = SimpleStorage::deploy(&provider, "init value".to_string()).await.unwrap(); + let set_value = storage.setValue("bar".to_string()); + let get_value = storage.getValue(); + + let set_tx = TransactionRequest::default() + .from(wallets[1].address()) + .to(*storage.address()) + .with_input(set_value.calldata().to_owned()); + let get_tx = TransactionRequest::default() + .from(wallets[1].address()) + .to(*storage.address()) + .with_input(get_value.calldata().to_owned()); + let trace_types = [TraceType::Trace]; + let calls = [ + (WithOtherFields::new(set_tx), trace_types.as_slice()), + (WithOtherFields::new(get_tx), trace_types.as_slice()), + ]; + + let traces = handle.http_provider().trace_call_many(&calls).await.unwrap(); + + assert_eq!(traces.len(), 2); + assert!(!traces[0].trace.is_empty()); + assert!(!traces[1].trace.is_empty()); + + let traced_value = SimpleStorage::getValueCall::abi_decode_returns(&traces[1].output).unwrap(); + assert_eq!(traced_value, "bar".to_string()); + assert_eq!(storage.getValue().call().await.unwrap(), "init value".to_string()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_trace_call_empty_trace_types() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + let from = handle.dev_wallets().next().unwrap().address(); + + // NUMBER PUSH0 MSTORE PUSH1 0x20 PUSH0 RETURN + let tx = TransactionRequest::default() + .from(from) + .with_deploy_code(Bytes::from_hex("0x435f5260205ff3").unwrap()); + let traces: TraceResults = provider + .client() + .request( + "trace_call", + (WithOtherFields::new(tx), Vec::::new(), BlockId::latest()), + ) + .await + .unwrap(); + + assert!(traces.trace.is_empty()); + assert!(traces.vm_trace.is_none()); + assert!(traces.state_diff.is_none()); + assert_eq!(traces.output.len(), 32); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_trace_call_many_defaults_to_latest() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + let from = handle.dev_wallets().next().unwrap().address(); + + // NUMBER PUSH0 MSTORE PUSH1 0x20 PUSH0 RETURN + let tx = TransactionRequest::default() + .from(from) + .with_deploy_code(Bytes::from_hex("0x435f5260205ff3").unwrap()); + let traces: Vec = provider + .client() + .request("trace_callMany", (vec![(WithOtherFields::new(tx), vec![TraceType::Trace])],)) + .await + .unwrap(); + + let latest = provider.get_block_number().await.unwrap(); + assert_eq!(U256::from_be_slice(&traces[0].output), U256::from(latest)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_trace_get_local() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let wallets = handle.dev_wallets().collect::>(); + let deployer: EthereumWallet = wallets[0].clone().into(); + let provider = http_provider_with_signer(&handle.http_endpoint(), deployer); + + let multicall = Multicall::deploy(&provider).await.unwrap(); + let storage = SimpleStorage::deploy(&provider, "init value".to_string()).await.unwrap(); + let get_value = Multicall::Call { + target: *storage.address(), + callData: storage.getValue().calldata().clone(), + }; + let nested = Multicall::Call { + target: *multicall.address(), + callData: multicall.aggregate(vec![get_value.clone()]).calldata().clone(), + }; + let receipt = multicall + .aggregate(vec![get_value, nested]) + .send() + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + let hash = receipt.transaction_hash; + + let traces = provider.trace_transaction(hash).await.unwrap(); + let addresses = + traces.iter().map(|trace| trace.trace.trace_address.clone()).collect::>(); + assert_eq!(addresses, vec![vec![], vec![0], vec![1], vec![1, 0]]); + + let trace_get = async |path: &[usize]| { + let path = path.iter().copied().map(Index::from).collect::>(); + provider + .client() + .request::<_, Option>("trace_get", (hash, path)) + .await + .unwrap() + }; + for trace in &traces { + assert_eq!(trace_get(&trace.trace.trace_address).await.as_ref(), Some(trace)); + } + for missing in [&[2][..], &[0, 0], &[1, 1], &[1, 0, 0]] { + assert_eq!(trace_get(missing).await, None); + } + + let unknown = provider + .client() + .request::<_, Option>( + "trace_get", + (B256::ZERO, Vec::::new()), + ) + .await + .unwrap(); + assert_eq!(unknown, None); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_block_traces_reject_pending() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + api.mine_one().await.unwrap(); + + let pending = BlockId::pending(); + let error = provider.trace_block(pending).await.unwrap_err(); + assert_eq!(error.as_error_resp().unwrap().code, -32602); + let error = provider.trace_replay_block_transactions(pending).await.unwrap_err(); + assert_eq!(error.as_error_resp().unwrap().code, -32602); + + // Mined block tags still resolve. + provider.trace_block(BlockId::latest()).await.unwrap(); + provider.trace_replay_block_transactions(BlockId::latest()).await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_calls_reject_conflicting_fields() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let client = handle.http_provider(); + let client = client.client(); + let from = handle.dev_wallets().next().unwrap().address(); + let input_error = "both \"data\" and \"input\" are set and not equal. Please use \"input\" to \ + pass transaction call data"; + let fee_error = "both gasPrice and (maxFeePerGas or maxPriorityFeePerGas) specified"; + + for (call, message) in [ + (json!({ "from": from, "to": from, "data": "0x602a", "input": "0x6001" }), input_error), + (json!({ "from": from, "to": from, "gasPrice": "0x1", "maxFeePerGas": "0x2" }), fee_error), + ( + json!({ "from": from, "to": from, "gasPrice": "0x1", "maxPriorityFeePerGas": "0x1" }), + fee_error, + ), + ] { + let simulate = json!({ "blockStateCalls": [{ "calls": [&call] }] }); + let errors = [ + client.request::<_, Value>("eth_call", (&call, "latest")).await.unwrap_err(), + client.request::<_, Value>("eth_estimateGas", (&call, "latest")).await.unwrap_err(), + client + .request::<_, Value>("trace_call", (&call, ["trace"], "latest")) + .await + .unwrap_err(), + client.request::<_, Value>("eth_simulateV1", (&simulate, "latest")).await.unwrap_err(), + client.request::<_, Value>("eth_sendTransaction", (&call,)).await.unwrap_err(), + ]; + for error in errors { + let error = error.as_error_resp().unwrap(); + assert_eq!((error.code, error.message.as_ref()), (-32602, message), "{call}"); + } + } + + let call = json!({ "from": from, "data": "0x602a", "input": "0x602a" }); + client.request::<_, Value>("eth_call", (&call, "latest")).await.unwrap(); + client.request::<_, Value>("trace_call", (&call, ["trace"], "latest")).await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_trace_transaction_omits_nested_precompile_calls() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + let from = handle.dev_wallets().next().unwrap().address(); + + // Zero-value CALL to the identity precompile, then a zero-value CALL to 0xbeef. + let caller = Address::repeat_byte(0x42); + let code = + Bytes::from_hex("0x6000600060006000600060045af1506000600060006000600061beef5af15000") + .unwrap(); + api.anvil_set_code(caller, code).await.unwrap(); + + let tx = TransactionRequest::default().from(from).to(caller); + let receipt = provider.send_transaction(tx.into()).await.unwrap().get_receipt().await.unwrap(); + let hash = receipt.transaction_hash; + + let traces = provider.trace_transaction(hash).await.unwrap(); + let traces = traces.into_iter().map(|trace| trace.trace).collect::>(); + assert_eq!(traces.len(), 2); + assert_eq!(traces[0].subtraces, 1); + assert_eq!(traces[1].trace_address, vec![0]); + let Action::Call(call) = &traces[1].action else { panic!("expected a call") }; + assert_eq!(call.to, Address::left_padding_from(&[0xbe, 0xef])); + + let replay = provider.trace_replay_transaction(hash).trace().await.unwrap(); + assert_eq!(replay.trace, traces); + + let block = provider.trace_block(receipt.block_number.unwrap().into()).await.unwrap(); + assert_eq!(block.into_iter().map(|trace| trace.trace).collect::>(), traces); + + // Parity filtering must leave both children in the stored Geth call graph. + let geth = provider + .debug_trace_transaction( + hash, + GethDebugTracingOptions::default() + .with_tracer(GethDebugTracerType::from(GethDebugBuiltInTracerType::CallTracer)), + ) + .await + .unwrap(); + let GethTrace::CallTracer(frame) = geth else { panic!("expected a call trace") }; + assert_eq!( + frame.calls.iter().map(|call| call.to.unwrap()).collect::>(), + [Address::with_last_byte(4), Address::left_padding_from(&[0xbe, 0xef])] + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_trace_transaction_keeps_root_and_valued_precompile_calls() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + let from = handle.dev_wallets().next().unwrap().address(); + let identity = Address::with_last_byte(4); + + // CALL to the identity precompile forwarding 1 wei. + let caller = Address::repeat_byte(0x42); + api.anvil_set_code(caller, Bytes::from_hex("0x6000600060006000600160045af15000").unwrap()) + .await + .unwrap(); + // DELEGATECALL to the identity precompile, which inherits the frame's value. + let delegator = Address::repeat_byte(0x43); + api.anvil_set_code(delegator, Bytes::from_hex("0x600060006000600060045af45000").unwrap()) + .await + .unwrap(); + + let tx = |to, value| TransactionRequest::default().from(from).to(to).value(U256::from(value)); + for (tx, frames) in [ + (tx(caller, 1), vec![caller, identity]), + (tx(delegator, 1), vec![delegator, identity]), + (tx(delegator, 0), vec![delegator]), + (tx(identity, 0).input(Bytes::from_static(b"echo").into()), vec![identity]), + ] { + let receipt = + provider.send_transaction(tx.into()).await.unwrap().get_receipt().await.unwrap(); + let hash = receipt.transaction_hash; + let traces = provider.trace_transaction(hash).await.unwrap(); + let traces = traces.into_iter().map(|trace| trace.trace).collect::>(); + let targets = traces + .iter() + .map(|trace| match &trace.action { + Action::Call(call) => call.to, + action => panic!("expected a call, got {action:?}"), + }) + .collect::>(); + assert_eq!(targets, frames); + + let replay = provider.trace_replay_transaction(hash).trace().await.unwrap(); + assert_eq!(replay.trace, traces); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_mined_precompile_traces_survive_chain_id_changes() { + let p256 = Address::left_padding_from(&[1, 0]); + let target = Address::left_padding_from(&[0xbe, 0xef]); + let caller = Address::repeat_byte(0x42); + for (chain_id, replacement, expected_targets) in + [(31_337u64, 56u64, vec![caller, p256, target]), (56u64, 31_337u64, vec![caller, target])] + { + let config = NodeConfig::test() + .with_hardfork(Some(EthereumHardfork::Prague.into())) + .with_chain_id(Some(chain_id)) + .with_genesis_timestamp(Some(1_718_863_501u64)); + let (api, handle) = spawn(config.clone()).await; + let provider = handle.http_provider(); + let from = handle.dev_wallets().next().unwrap().address(); + + // STATICCALL to 0x0100, then CALL to 0xbeef. BSC installs P256 after Haber. + api.anvil_set_code( + caller, + Bytes::from_hex("0x60006000600060006101005afa506000600060006000600061beef5af15000") + .unwrap(), + ) + .await + .unwrap(); + let receipt = provider + .send_transaction(WithOtherFields::new( + TransactionRequest::default().from(from).to(caller), + )) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + let hash = receipt.transaction_hash; + let block = receipt.block_number.unwrap(); + let before = provider.trace_transaction(hash).await.unwrap(); + let targets = before + .iter() + .map(|trace| match &trace.trace.action { + Action::Call(call) => call.to, + action => panic!("expected a call, got {action:?}"), + }) + .collect::>(); + assert_eq!(targets, expected_targets, "chain ID {chain_id}"); + assert_eq!(before[0].trace.subtraces, before.len() - 1); + for (index, trace) in before[1..].iter().enumerate() { + assert_eq!(trace.trace.trace_address, vec![index]); + } + + api.anvil_set_chain_id(replacement).await.unwrap(); + assert_eq!(provider.trace_transaction(hash).await.unwrap(), before); + assert_eq!(provider.trace_block(block.into()).await.unwrap(), before); + assert_eq!( + provider + .trace_filter(&TraceFilter::default().from_block(block).to_block(block)) + .await + .unwrap(), + before + ); + + // The execution marker must also survive serialization into a fresh node. + let state = api.anvil_dump_state(None).await.unwrap(); + let (restored_api, restored_handle) = spawn(config.with_chain_id(Some(replacement))).await; + assert!(restored_api.anvil_load_state(state).await.unwrap()); + assert_eq!(restored_handle.http_provider().trace_transaction(hash).await.unwrap(), before); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_trace_transaction_unknown_hash_local() { + let (_api, handle) = spawn(NodeConfig::test()).await; + + let traces = handle + .http_provider() + .client() + .request::<_, Option>>("trace_transaction", (B256::ZERO,)) + .await + .unwrap(); + assert_eq!(traces, None); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_trace_transaction_unknown_hash_fork() { + let (origin_api, origin_handle) = spawn(NodeConfig::test()).await; + let origin = origin_handle.http_provider(); + origin_api.mine_one().await.unwrap(); + origin_api.anvil_set_auto_mine(false).await.unwrap(); + + let accounts = origin_handle.dev_wallets().collect::>(); + let tx = TransactionRequest::default() + .to(accounts[1].address()) + .value(U256::from(1000)) + .from(accounts[0].address()); + let tx = WithOtherFields::new(tx); + let hash = *origin.send_transaction(tx).await.unwrap().tx_hash(); + + let config = NodeConfig::test().with_eth_rpc_url(Some(origin_handle.http_endpoint())); + let (_api, handle) = spawn(config).await; + let provider = handle.http_provider(); + + for hash in [B256::ZERO, hash] { + let traces = provider + .client() + .request::<_, Option>>("trace_transaction", (hash,)) + .await + .unwrap(); + assert_eq!(traces, None); + } + + // A missing hash is not cached, so it resolves once mined upstream. + origin_api.mine_one().await.unwrap(); + let traces = provider.trace_transaction(hash).await.unwrap(); + assert!(!traces.is_empty()); + assert_eq!(traces, origin.trace_transaction(hash).await.unwrap()); +} + +sol!( + #[sol(rpc, bytecode = "0x6080604052348015600f57600080fd5b50336000806101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff16021790555060a48061005e6000396000f3fe6080604052348015600f57600080fd5b506004361060285760003560e01c806375fc8e3c14602d575b600080fd5b60336035565b005b60008054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16fffea26469706673582212205006867290df97c54f2df1cb94fc081197ab670e2adf5353071d2ecce1d694b864736f6c634300080d0033")] + contract SuicideContract { + address payable private owner; + constructor() public { + owner = payable(msg.sender); + } + function goodbye() public { + selfdestruct(owner); + } + } +); + +#[tokio::test(flavor = "multi_thread")] +async fn test_parity_suicide_trace() { + let (_api, handle) = + spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Shanghai.into()))).await; + let provider = handle.ws_provider(); + let wallets = handle.dev_wallets().collect::>(); + let owner = wallets[0].address(); + let destructor = wallets[1].address(); + + let contract_addr = + SuicideContract::deploy_builder(provider.clone()).from(owner).deploy().await.unwrap(); + let contract = SuicideContract::new(contract_addr, provider.clone()); + let call = contract.goodbye().from(destructor); + let call = call.send().await.unwrap(); + let tx = call.get_receipt().await.unwrap(); + + let traces = handle.http_provider().trace_transaction(tx.transaction_hash).await.unwrap(); + assert!(!traces.is_empty()); + assert!(traces[1].trace.action.is_selfdestruct()); +} + +sol!( + #[sol(rpc, bytecode = "0x6080604052348015600f57600080fd5b50336000806101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff16021790555060a48061005e6000396000f3fe6080604052348015600f57600080fd5b506004361060285760003560e01c806375fc8e3c14602d575b600080fd5b60336035565b005b60008054906101000a900473ffffffffffffffffffffffffffffffffffffffff1673ffffffffffffffffffffffffffffffffffffffff16fffea26469706673582212205006867290df97c54f2df1cb94fc081197ab670e2adf5353071d2ecce1d694b864736f6c634300080d0033")] + contract DebugTraceContract { + address payable private owner; + constructor() public { + owner = payable(msg.sender); + } + function goodbye() public { + selfdestruct(owner); + } + } +); + +#[tokio::test(flavor = "multi_thread")] +async fn test_transfer_debug_trace_call() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let wallets = handle.dev_wallets().collect::>(); + let deployer: EthereumWallet = wallets[0].clone().into(); + let provider = http_provider_with_signer(&handle.http_endpoint(), deployer); + + let contract_addr = DebugTraceContract::deploy_builder(provider.clone()) + .from(wallets[0].clone().address()) + .deploy() + .await + .unwrap(); + + let caller: EthereumWallet = wallets[1].clone().into(); + let caller_provider = http_provider_with_signer(&handle.http_endpoint(), caller); + let contract = DebugTraceContract::new(contract_addr, caller_provider); + + let call = contract.goodbye().from(wallets[1].address()); + let calldata = call.calldata().to_owned(); + + let tx = TransactionRequest::default() + .from(wallets[1].address()) + .to(*contract.address()) + .with_input(calldata); + + let traces = handle + .http_provider() + .debug_trace_call( + WithOtherFields::new(tx), + BlockId::latest(), + GethDebugTracingCallOptions::default(), + ) + .await + .unwrap(); + + match traces { + GethTrace::Default(default_frame) => { + assert!(!default_frame.failed); + } + _ => { + unreachable!() + } + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_call_tracer_debug_trace_call() { + let (api, handle) = spawn(NodeConfig::test()).await; + let wallets = handle.dev_wallets().collect::>(); + let deployer: EthereumWallet = wallets[0].clone().into(); + let provider = http_provider_with_signer(&handle.http_endpoint(), deployer); + + let multicall_contract = Multicall::deploy(&provider).await.unwrap(); + + let simple_storage_contract = + SimpleStorage::deploy(&provider, "init value".to_string()).await.unwrap(); + + let set_value = simple_storage_contract.setValue("bar".to_string()); + let set_value_calldata = set_value.calldata(); + + let internal_call_tx_builder = multicall_contract.aggregate(vec![Multicall::Call { + target: *simple_storage_contract.address(), + callData: set_value_calldata.to_owned(), + }]); + + let internal_call_tx_calldata = internal_call_tx_builder.calldata().to_owned(); + + // calling SimpleStorage contract through Multicall should result in an internal call + let internal_call_tx = TransactionRequest::default() + .from(wallets[1].address()) + .to(*multicall_contract.address()) + .with_input(internal_call_tx_calldata); + + let internal_call_tx_traces = handle + .http_provider() + .debug_trace_call( + WithOtherFields::new(internal_call_tx.clone()), + BlockId::latest(), + GethDebugTracingCallOptions::default().with_tracing_options( + GethDebugTracingOptions::default() + .with_tracer(GethDebugTracerType::from(GethDebugBuiltInTracerType::CallTracer)) + .with_call_config(CallConfig::default().with_log()), + ), + ) + .await + .unwrap(); + + match internal_call_tx_traces { + GethTrace::CallTracer(call_frame) => { + assert!(call_frame.calls.len() == 1); + assert!( + call_frame.calls.first().unwrap().to.unwrap() == *simple_storage_contract.address() + ); + assert!(call_frame.calls.first().unwrap().logs.len() == 1); + } + _ => { + unreachable!() + } + } + + // only_top_call option - should not return any internal calls + let internal_call_only_top_call_tx_traces = handle + .http_provider() + .debug_trace_call( + WithOtherFields::new(internal_call_tx.clone()), + BlockId::latest(), + GethDebugTracingCallOptions::default().with_tracing_options( + GethDebugTracingOptions::default() + .with_tracer(GethDebugTracerType::from(GethDebugBuiltInTracerType::CallTracer)) + .with_call_config(CallConfig::default().with_log().only_top_call()), + ), + ) + .await + .unwrap(); + + match internal_call_only_top_call_tx_traces { + GethTrace::CallTracer(call_frame) => { + assert!(call_frame.calls.is_empty()); + } + _ => { + unreachable!() + } + } + + let receipt = internal_call_tx_builder.send().await.unwrap().get_receipt().await.unwrap(); + let internal_call_tx_hash = receipt.transaction_hash; + let trace_provider = handle.http_provider(); + + let internal_call_tx_traces: GethTrace = trace_provider + .raw_request( + "debug_traceTransaction".into(), + ( + internal_call_tx_hash, + serde_json::json!({ + "tracer": "callTracer", + "tracerConfig": { + "withLog": true + } + }), + ), + ) + .await + .unwrap(); + + match internal_call_tx_traces { + GethTrace::CallTracer(call_frame) => { + assert_eq!(call_frame.calls.len(), 1); + assert_eq!( + call_frame.calls.first().unwrap().to.unwrap(), + *simple_storage_contract.address() + ); + } + _ => { + unreachable!() + } + } + + let internal_call_only_top_level_call_tx_traces: GethTrace = trace_provider + .raw_request( + "debug_traceTransaction".into(), + ( + internal_call_tx_hash, + serde_json::json!({ + "tracer": "callTracer", + "tracerConfig": { + "onlyTopLevelCall": true, + "withLog": true + } + }), + ), + ) + .await + .unwrap(); + + match internal_call_only_top_level_call_tx_traces { + GethTrace::CallTracer(call_frame) => { + assert!(call_frame.calls.is_empty()); + } + _ => { + unreachable!() + } + } + + // directly calling the SimpleStorage contract should not result in any internal calls + let direct_call_tx = TransactionRequest::default() + .from(wallets[1].address()) + .to(*simple_storage_contract.address()) + .with_input(set_value_calldata.to_owned()); + + let direct_call_tx_traces = handle + .http_provider() + .debug_trace_call( + WithOtherFields::new(direct_call_tx), + BlockId::latest(), + GethDebugTracingCallOptions::default().with_tracing_options( + GethDebugTracingOptions::default() + .with_tracer(GethDebugTracerType::from(GethDebugBuiltInTracerType::CallTracer)) + .with_call_config(CallConfig::default().with_log()), + ), + ) + .await + .unwrap(); + + match direct_call_tx_traces { + GethTrace::CallTracer(call_frame) => { + assert!(call_frame.calls.is_empty()); + assert!(call_frame.to.unwrap() == *simple_storage_contract.address()); + assert!(call_frame.logs.len() == 1); + } + _ => { + unreachable!() + } + } + api.anvil_set_auto_mine(false).await.unwrap(); + let nonce = provider.get_transaction_count(wallets[1].address()).await.unwrap(); + let mut hashes = Vec::new(); + for nonce in nonce..nonce + 2 { + hashes.push( + api.send_transaction(WithOtherFields::new( + internal_call_tx.clone().nonce(nonce).gas_limit(500_000), + )) + .await + .unwrap(), + ); + } + api.mine_one().await.unwrap(); + let block_number = provider.get_block_number().await.unwrap(); + for config in [ + serde_json::json!({"withLog": true}), + serde_json::json!({"withLog": true, "onlyTopCall": true}), + serde_json::json!({"withLog": true, "onlyTopLevelCall": true}), + ] { + let options = serde_json::from_value::(serde_json::json!({ + "tracer": "callTracer", "tracerConfig": config, + })) + .unwrap(); + let mut expected = Vec::new(); + for hash in &hashes { + let result = api.backend.debug_trace_transaction(*hash, options.clone()).await.unwrap(); + expected.push(TraceResult::Success { result, tx_hash: Some(*hash) }); + } + assert_eq!( + api.backend.debug_trace_block_by_number(block_number.into(), options).await.unwrap(), + expected, + ); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_debug_trace_call_state_override() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let wallets = handle.dev_wallets().collect::>(); + + let tx = TransactionRequest::default() + .from(wallets[1].address()) + .to(address!("0x1234567890123456789012345678901234567890")); + + let override_json = r#"{ + "0x1234567890123456789012345678901234567890": { + "balance": "0x01", + "code": "0x30315f5260205ff3" + } + }"#; + + let state_override: StateOverride = serde_json::from_str(override_json).unwrap(); + + let tx_traces = handle + .http_provider() + .debug_trace_call( + WithOtherFields::new(tx.clone()), + BlockId::latest(), + GethDebugTracingCallOptions::default() + .with_tracing_options(GethDebugTracingOptions::default()) + .with_state_overrides(state_override), + ) + .await + .unwrap(); + + match tx_traces { + GethTrace::Default(trace_res) => { + assert_eq!( + trace_res.return_value, + Bytes::from_hex("0000000000000000000000000000000000000000000000000000000000000001") + .unwrap() + ); + } + _ => { + unreachable!() + } + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_debug_trace_call_tx_index() { + let (api, handle) = spawn(NodeConfig::test()).await; + let wallets = handle.dev_wallets().collect::>(); + let deployer: EthereumWallet = wallets[0].clone().into(); + let provider = http_provider_with_signer(&handle.http_endpoint(), deployer); + + let simple_storage_contract = + SimpleStorage::deploy(&provider, "init value".to_string()).await.unwrap(); + + api.anvil_set_auto_mine(false).await.unwrap(); + + let from = wallets[0].address(); + let nonce = provider.get_transaction_count(from).await.unwrap(); + for (offset, value) in ["first", "second", "third"].into_iter().enumerate() { + let set_value = simple_storage_contract.setValue(value.to_string()); + let tx = TransactionRequest::default() + .from(from) + .to(*simple_storage_contract.address()) + .with_input(set_value.calldata().to_owned()) + .nonce(nonce + offset as u64); + let _ = provider.send_transaction(WithOtherFields::new(tx)).await.unwrap(); + } + + api.mine_one().await.unwrap(); + let block_number = provider.get_block_number().await.unwrap(); + + let get_value = simple_storage_contract.getValue(); + let call = TransactionRequest::default() + .from(from) + .to(*simple_storage_contract.address()) + .with_input(get_value.calldata().to_owned()); + + for (tx_index, expected) in [(0, "init value"), (1, "first"), (2, "second")] { + let trace = provider + .debug_trace_call( + WithOtherFields::new(call.clone()), + BlockId::number(block_number), + GethDebugTracingCallOptions::default().with_tx_index(tx_index), + ) + .await + .unwrap(); + match trace { + GethTrace::Default(default_frame) => { + assert_eq!( + default_frame.return_value, + Bytes::from(String::from(expected).abi_encode()) + ); + } + _ => unreachable!(), + } + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_debug_trace_transaction_reports_transaction_gas() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + api.anvil_set_auto_mine(false).await.unwrap(); + + let accounts = handle.dev_wallets().collect::>(); + let from = accounts[0].address(); + let to = accounts[1].address(); + let nonce = provider.get_transaction_count(from).await.unwrap(); + + let first = provider + .send_transaction(WithOtherFields::new( + TransactionRequest::default().from(from).to(to).value(U256::ONE).nonce(nonce), + )) + .await + .unwrap(); + let second = provider + .send_transaction(WithOtherFields::new( + TransactionRequest::default().from(from).to(to).value(U256::from(2)).nonce(nonce + 1), + )) + .await + .unwrap(); + + api.mine_one().await.unwrap(); + let first_receipt = first.get_receipt().await.unwrap(); + let second_receipt = second.get_receipt().await.unwrap(); + assert_eq!(first_receipt.block_hash, second_receipt.block_hash); + + let default_trace = api + .debug_trace_transaction( + second_receipt.transaction_hash, + GethDebugTracingOptions::default(), + ) + .await + .unwrap(); + let GethTrace::Default(default_frame) = default_trace else { + unreachable!("expected default trace") + }; + assert_eq!(default_frame.gas, second_receipt.gas_used); + + let call_trace = api + .debug_trace_transaction( + second_receipt.transaction_hash, + GethDebugTracingOptions::default() + .with_tracer(GethDebugTracerType::from(GethDebugBuiltInTracerType::CallTracer)), + ) + .await + .unwrap(); + let GethTrace::CallTracer(call_frame) = call_trace else { unreachable!("expected call trace") }; + assert_eq!(call_frame.gas_used, U256::from(second_receipt.gas_used)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_debug_trace_transaction_struct_logs_with_steps_tracing() { + let (api, handle) = spawn(NodeConfig::test().with_steps_tracing(true)).await; + let provider = handle.http_provider(); + + api.anvil_set_auto_mine(false).await.unwrap(); + + let accounts = handle.dev_wallets().collect::>(); + let from = accounts[0].address(); + let nonce = provider.get_transaction_count(from).await.unwrap(); + + // Precede the traced transaction so that tracing replays the block prefix. + let transfer = TransactionRequest::default() + .from(from) + .to(accounts[1].address()) + .value(U256::ONE) + .nonce(nonce); + let _ = provider.send_transaction(WithOtherFields::new(transfer)).await.unwrap(); + + // PUSH1 0x2a PUSH1 0x00 MSTORE PUSH1 0x20 PUSH1 0x00 RETURN + let init_code = Bytes::from_hex("602a60005260206000f3").unwrap(); + let create = TransactionRequest::default() + .from(from) + .with_deploy_code(init_code) + .nonce(nonce + 1) + .gas_limit(100_000); + let pending = provider.send_transaction(WithOtherFields::new(create)).await.unwrap(); + + api.mine_one().await.unwrap(); + let receipt = pending.get_receipt().await.unwrap(); + assert_eq!(receipt.transaction_index, Some(1)); + + let word = "0x000000000000000000000000000000000000000000000000000000000000002a".to_string(); + for memory in [false, true] { + let config = GethDefaultTracingOptions::default().with_enable_memory(memory); + let opts = GethDebugTracingOptions { config, ..Default::default() }; + let trace = provider.debug_trace_transaction(receipt.transaction_hash, opts).await.unwrap(); + let GethTrace::Default(frame) = trace else { unreachable!("expected default trace") }; + assert_eq!(frame.gas, receipt.gas_used); + assert!(!frame.failed); + + let steps = frame + .struct_logs + .into_iter() + .map(|log| (log.pc, log.op.into_owned(), log.stack.unwrap(), log.memory)) + .collect::>(); + let empty = memory.then(Vec::new); + let stored = memory.then(|| vec![word.clone()]); + assert_eq!( + steps, + [ + (0, "PUSH1".to_string(), vec![], empty.clone()), + (2, "PUSH1".to_string(), vec![U256::from(0x2a)], empty.clone()), + (4, "MSTORE".to_string(), vec![U256::from(0x2a), U256::ZERO], empty), + (5, "PUSH1".to_string(), vec![], stored.clone()), + (7, "PUSH1".to_string(), vec![U256::from(0x20)], stored.clone()), + (9, "RETURN".to_string(), vec![U256::from(0x20), U256::ZERO], stored), + ] + ); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_debug_trace_transaction_replays_blob_base_fee() { + let (api, handle) = spawn(NodeConfig::test().with_steps_tracing(true)).await; + let provider = handle.http_provider(); + + let blob_update_fraction = u64::try_from(api.backend.blob_params().update_fraction).unwrap(); + api.backend.fees().set_blob_excess_gas_and_price(BlobExcessGasAndPrice::new( + 30_000_000, + blob_update_fraction, + )); + + // BLOBBASEFEE PUSH0 MSTORE PUSH1 0x20 PUSH0 RETURN + let init_code = Bytes::from_hex("4a5f5260205ff3").unwrap(); + let from = handle.dev_wallets().next().unwrap().address(); + let create = TransactionRequest::default().from(from).with_deploy_code(init_code); + let receipt = provider + .send_transaction(WithOtherFields::new(create)) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + + // The deployed code is the blob base fee observed while mining. + let code = provider.get_code_at(receipt.contract_address.unwrap()).await.unwrap(); + let mined_blob_base_fee = U256::from_be_slice(&code); + assert!(mined_blob_base_fee > U256::ONE); + + let trace = provider + .debug_trace_transaction(receipt.transaction_hash, GethDebugTracingOptions::default()) + .await + .unwrap(); + let GethTrace::Default(frame) = trace else { unreachable!("expected default trace") }; + assert_eq!(frame.struct_logs[1].op, "PUSH0"); + assert_eq!(frame.struct_logs[1].stack, Some(vec![mined_blob_base_fee])); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_trace_replays_report_unavailable_historical_state() { + let (api, handle) = spawn(NodeConfig::test().with_steps_tracing(true)).await; + let from = handle.dev_wallets().next().unwrap().address(); + let tx = TransactionRequest::default().from(from).to(from).value(U256::ONE); + let receipt = handle + .http_provider() + .send_transaction(WithOtherFields::new(tx)) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + + // Restoring without historical states keeps the transaction but not its parent state. + let state = api.serialized_state(false).await.unwrap(); + let (api, handle) = + spawn(NodeConfig::test().with_steps_tracing(true).with_init_state(Some(state))).await; + let provider = handle.http_provider(); + let message = "historical state needed to replay block 1 is not available"; + + let call_tracer = GethDebugTracingOptions::default() + .with_tracer(GethDebugTracerType::from(GethDebugBuiltInTracerType::CallTracer)); + for opts in [GethDebugTracingOptions::default(), call_tracer] { + let error = + provider.debug_trace_transaction(receipt.transaction_hash, opts).await.unwrap_err(); + let error = error.as_error_resp().unwrap(); + assert_eq!(error.code, -32000); + assert_eq!(error.message, message); + } + + let error = api + .trace_replay_block_transactions( + BlockNumberOrTag::Number(1), + [TraceType::Trace].into_iter().collect(), + ) + .await + .unwrap_err(); + assert_eq!(error.to_string(), message); + + // Genesis has no transactions to replay. + let genesis = api + .trace_replay_block_transactions( + BlockNumberOrTag::Number(0), + [TraceType::Trace].into_iter().collect(), + ) + .await + .unwrap() + .unwrap(); + assert!(genesis.is_empty()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_trace_unknown_block_and_transaction() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + let head = provider.get_block_number().await.unwrap(); + let next = BlockId::number(head + 1); + + let error = provider.trace_block(next).await.unwrap_err(); + assert_eq!(error.as_error_resp().unwrap().code, -32001); + let replays = provider + .client() + .request::<_, Option>>( + "trace_replayBlockTransactions", + (next, vec![TraceType::Trace]), + ) + .await + .unwrap(); + assert_eq!(replays, None); + + let replay = provider + .client() + .request::<_, Option>( + "trace_replayTransaction", + (B256::ZERO, vec![TraceType::Trace]), + ) + .await + .unwrap(); + assert_eq!(replay, None); + + let filter = TraceFilter::default().from_block(head).to_block(head + 1); + let error = provider.trace_filter(&filter).await.unwrap_err(); + assert_eq!(error.as_error_resp().unwrap().code, -32001); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_trace_fork_block() { + let (_origin_api, origin_handle) = spawn(NodeConfig::test()).await; + let accounts = origin_handle.dev_wallets().collect::>(); + let tx = WithOtherFields::new( + TransactionRequest::default() + .to(accounts[1].address()) + .value(U256::from(1)) + .from(accounts[0].address()), + ); + origin_handle + .http_provider() + .send_transaction(tx.clone()) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + + let (_api, handle) = + spawn(NodeConfig::test().with_eth_rpc_url(Some(origin_handle.http_endpoint()))).await; + let provider = handle.http_provider(); + let fork_block = provider.get_block_number().await.unwrap(); + + // The fork block is not stored locally, so its traces come from the fork. + assert_eq!(provider.trace_block(BlockId::latest()).await.unwrap().len(), 1); + assert_eq!(provider.trace_replay_block_transactions(BlockId::latest()).await.unwrap().len(), 1); + + provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + let filter = TraceFilter::default().from_block(fork_block).to_block(fork_block + 1); + let blocks = provider + .trace_filter(&filter) + .await + .unwrap() + .into_iter() + .map(|trace| trace.block_number.unwrap()) + .collect::>(); + assert_eq!(blocks, [fork_block, fork_block + 1]); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_debug_trace_transaction_rejects_unknown_hash() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let error = handle + .http_provider() + .debug_trace_transaction(B256::ZERO, GethDebugTracingOptions::default()) + .await + .unwrap_err(); + let error = error.as_error_resp().unwrap(); + + assert_eq!(error.code, -32001); + assert_eq!(error.message, "transaction not found"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_debug_trace_call_tx_index_fork_lazy_state() { + let (_api, handle) = spawn(fork_config()).await; + let provider = handle.http_provider(); + let from = handle.dev_wallets().next().unwrap().address(); + + let tx = TransactionRequest::default().from(from).to(Address::random()).value(U256::ONE); + let _ = provider + .send_transaction(WithOtherFields::new(tx)) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + let block_number = provider.get_block_number().await.unwrap(); + + let usdc = address!("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"); + let total_supply = TransactionRequest::default() + .from(from) + .to(usdc) + .with_input(Bytes::from_hex("18160ddd").unwrap()); + + let tx_index_trace = provider + .debug_trace_call( + WithOtherFields::new(total_supply.clone()), + BlockId::number(block_number), + GethDebugTracingCallOptions::default().with_tx_index(0), + ) + .await + .unwrap(); + let GethTrace::Default(tx_index_frame) = tx_index_trace else { unreachable!() }; + + let latest_trace = provider + .debug_trace_call( + WithOtherFields::new(total_supply), + BlockId::number(block_number), + GethDebugTracingCallOptions::default(), + ) + .await + .unwrap(); + let GethTrace::Default(latest_frame) = latest_trace else { unreachable!() }; + + assert!(!tx_index_frame.return_value.is_empty()); + assert_eq!(tx_index_frame.return_value, latest_frame.return_value); +} + +// +#[tokio::test(flavor = "multi_thread")] +async fn test_trace_address_fork() { + let (api, handle) = spawn(fork_config().with_fork_block_number(Some(15291050u64))).await; + let provider = handle.http_provider(); + + let input = hex::decode("43bcfab60000000000000000000000006b175474e89094c44da98b954eedeac495271d0f0000000000000000000000000000000000000000000000e0bd811c8769a824b00000000000000000000000000000000000000000000000e0ae9925047d8440b60000000000000000000000002e4777139254ff76db957e284b186a4507ff8c67").unwrap(); + + let from = address!("0x2e4777139254ff76db957e284b186a4507ff8c67"); + let to = address!("0xe2f2a5c287993345a840db3b0845fbc70f5935a5"); + let tx = TransactionRequest::default() + .to(to) + .from(from) + .with_input::(input.into()) + .with_gas_limit(300_000); + + let tx = WithOtherFields::new(tx); + api.anvil_impersonate_account(from).await.unwrap(); + + let tx = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + + let traces = provider.trace_transaction(tx.transaction_hash).await.unwrap(); + assert!(!traces.is_empty()); + match traces[0].trace.action { + Action::Call(ref call) => { + assert_eq!(call.from, from); + assert_eq!(call.to, to); + } + _ => unreachable!("unexpected action"), + } + + let json = serde_json::json!([ + { + "action": { + "callType": "call", + "from": "0x2e4777139254ff76db957e284b186a4507ff8c67", + "gas": "0x262b3", + "input": "0x43bcfab60000000000000000000000006b175474e89094c44da98b954eedeac495271d0f0000000000000000000000000000000000000000000000e0bd811c8769a824b00000000000000000000000000000000000000000000000e0ae9925047d8440b60000000000000000000000002e4777139254ff76db957e284b186a4507ff8c67", + "to": "0xe2f2a5c287993345a840db3b0845fbc70f5935a5", + "value": "0x0" + }, + "blockHash": "0xa47c8f1d8c284cb614e9c8e10d260b33eae16b1957a83141191bc335838d7e29", + "blockNumber": 15291051, + "result": { + "gasUsed": "0x2131b", + "output": "0x0000000000000000000000000000000000000000000000e0e82ca52ec6e6a4d3" + }, + "subtraces": 1, + "traceAddress": [], + "transactionHash": "0x3255cce7312e9c4470e1a1883be13718e971f6faafb96199b8bd75e5b7c39e3a", + "transactionPosition": 19, + "type": "call" + }, + { + "action": { + "callType": "delegatecall", + "from": "0xe2f2a5c287993345a840db3b0845fbc70f5935a5", + "gas": "0x23d88", + "input": "0x43bcfab60000000000000000000000006b175474e89094c44da98b954eedeac495271d0f0000000000000000000000000000000000000000000000e0bd811c8769a824b00000000000000000000000000000000000000000000000e0ae9925047d8440b60000000000000000000000002e4777139254ff76db957e284b186a4507ff8c67", + "to": "0x15b2838cd28cc353afbe59385db3f366d8945aee", + "value": "0x0" + }, + "blockHash": "0xa47c8f1d8c284cb614e9c8e10d260b33eae16b1957a83141191bc335838d7e29", + "blockNumber": 15291051, + "result": { + "gasUsed": "0x1f6e1", + "output": "0x0000000000000000000000000000000000000000000000e0e82ca52ec6e6a4d3" + }, + "subtraces": 2, + "traceAddress": [0], + "transactionHash": "0x3255cce7312e9c4470e1a1883be13718e971f6faafb96199b8bd75e5b7c39e3a", + "transactionPosition": 19, + "type": "call" + }, + { + "action": { + "callType": "staticcall", + "from": "0xe2f2a5c287993345a840db3b0845fbc70f5935a5", + "gas": "0x192ed", + "input": "0x50494dc000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000e0b1ff65617f654b2f00000000000000000000000000000000000000000000000000000000000061a800000000000000000000000000000000000000000000000000b1a2bc2ec5000000000000000000000000000000000000000000000000000006f05b59d3b2000000000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000005f5e1000000000000000000000000000000000000000000000414ec22973db48fd3a3370000000000000000000000000000000000000000000000056bc75e2d6310000000000000000000000000000000000000000000000000000000000a7314a9ba5c0000000000000000000000000000000000000000000000000000000005f5e100000000000000000000000000000000000000000000095f783edc5a5dabcb4ba70000000000000000000000000000000000000000000000056bc75e2d6310000000000000000000000000000000000000000000000000000000000a3f42df4dab", + "to": "0xca480d596e6717c95a62a4dc1bd4fbd7b7e7d705", + "value": "0x0" + }, + "blockHash": "0xa47c8f1d8c284cb614e9c8e10d260b33eae16b1957a83141191bc335838d7e29", + "blockNumber": 15291051, + "result": { + "gasUsed": "0x661a", + "output": "0x0000000000000000000000000000000000000000000000e0e82ca52ec6e6a4d3" + }, + "subtraces": 0, + "traceAddress": [0, 0], + "transactionHash": "0x3255cce7312e9c4470e1a1883be13718e971f6faafb96199b8bd75e5b7c39e3a", + "transactionPosition": 19, + "type": "call" + }, + { + "action": { + "callType": "delegatecall", + "from": "0xe2f2a5c287993345a840db3b0845fbc70f5935a5", + "gas": "0xd2dc", + "input": "0x4e331a540000000000000000000000000000000000000000000000e0e82ca52ec6e6a4d30000000000000000000000006b175474e89094c44da98b954eedeac495271d0f000000000000000000000000a2a3cae63476891ab2d640d9a5a800755ee79d6e000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000005f5e100000000000000000000000000000000000000000000095f783edc5a5dabcb4ba70000000000000000000000002e4777139254ff76db957e284b186a4507ff8c6700000000000000000000000000000000000000000000f7be2b91f8a2e2df496e", + "to": "0x1e91f826fa8aa4fa4d3f595898af3a64dd188848", + "value": "0x0" + }, + "blockHash": "0xa47c8f1d8c284cb614e9c8e10d260b33eae16b1957a83141191bc335838d7e29", + "blockNumber": 15291051, + "result": { + "gasUsed": "0x7617", + "output": "0x" + }, + "subtraces": 2, + "traceAddress": [0, 1], + "transactionHash": "0x3255cce7312e9c4470e1a1883be13718e971f6faafb96199b8bd75e5b7c39e3a", + "transactionPosition": 19, + "type": "call" + }, + { + "action": { + "callType": "staticcall", + "from": "0xe2f2a5c287993345a840db3b0845fbc70f5935a5", + "gas": "0xbf50", + "input": "0x70a08231000000000000000000000000a2a3cae63476891ab2d640d9a5a800755ee79d6e", + "to": "0x6b175474e89094c44da98b954eedeac495271d0f", + "value": "0x0" + }, + "blockHash": "0xa47c8f1d8c284cb614e9c8e10d260b33eae16b1957a83141191bc335838d7e29", + "blockNumber": 15291051, + "result": { + "gasUsed": "0xa2a", + "output": "0x0000000000000000000000000000000000000000000020fe99f8898600d94750" + }, + "subtraces": 0, + "traceAddress": [0, 1, 0], + "transactionHash": "0x3255cce7312e9c4470e1a1883be13718e971f6faafb96199b8bd75e5b7c39e3a", + "transactionPosition": 19, + "type": "call" + }, + { + "action": { + "callType": "call", + "from": "0xe2f2a5c287993345a840db3b0845fbc70f5935a5", + "gas": "0xa92a", + "input": "0xa4e285950000000000000000000000002e4777139254ff76db957e284b186a4507ff8c670000000000000000000000006b175474e89094c44da98b954eedeac495271d0f0000000000000000000000000000000000000000000000e0e82ca52ec6e6a4d3", + "to": "0xa2a3cae63476891ab2d640d9a5a800755ee79d6e", + "value": "0x0" + }, + "blockHash": "0xa47c8f1d8c284cb614e9c8e10d260b33eae16b1957a83141191bc335838d7e29", + "blockNumber": 15291051, + "result": { + "gasUsed": "0x4ed3", + "output": "0x" + }, + "subtraces": 1, + "traceAddress": [0, 1, 1], + "transactionHash": "0x3255cce7312e9c4470e1a1883be13718e971f6faafb96199b8bd75e5b7c39e3a", + "transactionPosition": 19, + "type": "call" + }, + { + "action": { + "callType": "call", + "from": "0xa2a3cae63476891ab2d640d9a5a800755ee79d6e", + "gas": "0x8c90", + "input": "0xa9059cbb0000000000000000000000002e4777139254ff76db957e284b186a4507ff8c670000000000000000000000000000000000000000000000e0e82ca52ec6e6a4d3", + "to": "0x6b175474e89094c44da98b954eedeac495271d0f", + "value": "0x0" + }, + "blockHash": "0xa47c8f1d8c284cb614e9c8e10d260b33eae16b1957a83141191bc335838d7e29", + "blockNumber": 15291051, + "result": { + "gasUsed": "0x2b42", + "output": "0x0000000000000000000000000000000000000000000000000000000000000001" + }, + "subtraces": 0, + "traceAddress": [0, 1, 1, 0], + "transactionHash": "0x3255cce7312e9c4470e1a1883be13718e971f6faafb96199b8bd75e5b7c39e3a", + "transactionPosition": 19, + "type": "call" + } + ]); + + let expected_traces: Vec = serde_json::from_value(json).unwrap(); + + // test matching traceAddress + traces.into_iter().zip(expected_traces).for_each(|(a, b)| { + assert_eq!(a.trace.trace_address, b.trace.trace_address); + assert_eq!(a.trace.subtraces, b.trace.subtraces); + match (a.trace.action, b.trace.action) { + (Action::Call(a), Action::Call(b)) => { + assert_eq!(a.from, b.from); + assert_eq!(a.to, b.to); + } + _ => unreachable!("unexpected action"), + } + }) +} + +// +// +#[tokio::test(flavor = "multi_thread")] +async fn test_trace_address_fork2() { + let (api, handle) = spawn(fork_config().with_fork_block_number(Some(15314401u64))).await; + let provider = handle.http_provider(); + + let input = hex::decode("30000003000000000000000000000000adda1059a6c6c102b0fa562b9bb2cb9a0de5b1f4000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a300000004fffffffffffffffffffffffffffffffffffffffffffff679dc91ecfe150fb980c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2f4d2888d29d722226fafa5d9b24f9164c092421e000bb8000000000000004319b52bf08b65295d49117e790000000000000000000000000000000000000000000000008b6d9e8818d6141f000000000000000000000000000000000000000000000000000000086a23af210000000000000000000000000000000000000000000000000000000000").unwrap(); + + let from = address!("0xa009fa1ac416ec02f6f902a3a4a584b092ae6123"); + let to = address!("0x99999999d116ffa7d76590de2f427d8e15aeb0b8"); + let tx = TransactionRequest::default() + .to(to) + .from(from) + .with_input::(input.into()) + .with_gas_limit(350_000); + + let tx = WithOtherFields::new(tx); + api.anvil_impersonate_account(from).await.unwrap(); + + let tx = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + let status = tx.inner.inner.inner.receipt.status.coerce_status(); + assert!(status); + + let traces = provider.trace_transaction(tx.transaction_hash).await.unwrap(); + + assert!(!traces.is_empty()); + match traces[0].trace.action { + Action::Call(ref call) => { + assert_eq!(call.from, from); + assert_eq!(call.to, to); + } + _ => unreachable!("unexpected action"), + } + + let json = serde_json::json!([ + { + "action": { + "from": "0xa009fa1ac416ec02f6f902a3a4a584b092ae6123", "callType": "call", "gas": "0x4fabc", "input": "0x30000003000000000000000000000000adda1059a6c6c102b0fa562b9bb2cb9a0de5b1f4000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a300000004fffffffffffffffffffffffffffffffffffffffffffff679dc91ecfe150fb980c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2f4d2888d29d722226fafa5d9b24f9164c092421e000bb8000000000000004319b52bf08b65295d49117e790000000000000000000000000000000000000000000000008b6d9e8818d6141f000000000000000000000000000000000000000000000000000000086a23af210000000000000000000000000000000000000000000000000000000000", @@ -755,176 +2358,1106 @@ async fn test_trace_address_fork2() { "transactionPosition": 289, "type": "call" } - ]); + ]); + + let expected_traces: Vec = serde_json::from_value(json).unwrap(); + + // test matching traceAddress + traces.into_iter().zip(expected_traces).for_each(|(a, b)| { + assert_eq!(a.trace.trace_address, b.trace.trace_address); + assert_eq!(a.trace.subtraces, b.trace.subtraces); + match (a.trace.action, b.trace.action) { + (Action::Call(a), Action::Call(b)) => { + assert_eq!(a.from, b.from); + assert_eq!(a.to, b.to); + } + _ => unreachable!("unexpected action"), + } + }) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_trace_get_rejects_integer_indices() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + let accounts = handle.dev_wallets().collect::>(); + let tx = TransactionRequest::default() + .to(accounts[1].address()) + .value(U256::from(1)) + .from(accounts[0].address()); + let receipt = provider + .send_transaction(WithOtherFields::new(tx)) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + let hash = receipt.transaction_hash; + + let error = provider + .client() + .request::<_, Option>("trace_get", (hash, [0])) + .await + .unwrap_err(); + assert_eq!(error.as_error_resp().unwrap().code, -32602); + + // Quantity strings are accepted. + provider + .client() + .request::<_, Option>("trace_get", (hash, ["0x0"])) + .await + .unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_trace_filter() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.ws_provider(); + + let accounts = handle.dev_wallets().collect::>(); + let from = accounts[0].address(); + let to = accounts[1].address(); + let from_two = accounts[2].address(); + let to_two = accounts[3].address(); + + // Test default block ranges: omitted bounds both default to latest. + let tracer = TraceFilter { + from_block: None, + to_block: None, + from_address: vec![], + to_address: vec![], + mode: TraceFilterMode::Intersection, + after: None, + count: None, + }; + + for i in 0..=5 { + let tx = TransactionRequest::default().to(to).value(U256::from(i)).from(from); + let tx = WithOtherFields::new(tx); + provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + } + + let latest = provider.get_block_number().await.unwrap(); + let traces = api.trace_filter(tracer.clone()).await.unwrap(); + assert_eq!(traces.len(), 1); + assert_eq!(traces[0].block_number, Some(latest)); + + let traces = + api.trace_filter(TraceFilter { from_block: Some(0), ..tracer.clone() }).await.unwrap(); + assert_eq!(traces.len(), 6); + + // An explicit end before the implicit latest start is a reversed range. + let traces = api.trace_filter(TraceFilter { to_block: Some(1), ..tracer }).await; + assert!(traces.is_err()); + + // Test filtering by address + let tracer = TraceFilter { + from_block: Some(provider.get_block_number().await.unwrap()), + to_block: None, + from_address: vec![from_two], + to_address: vec![to_two], + mode: TraceFilterMode::Intersection, + after: None, + count: None, + }; + + for i in 0..=5 { + let tx = TransactionRequest::default().to(to).value(U256::from(i)).from(from); + let tx = WithOtherFields::new(tx); + provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + + let tx = TransactionRequest::default().to(to_two).value(U256::from(i)).from(from_two); + let tx = WithOtherFields::new(tx); + provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + } + + let traces = api.trace_filter(tracer).await.unwrap(); + assert_eq!(traces.len(), 6); + + // Test for the following actions: + // Create (deploy the contract) + // Call (goodbye function) + // SelfDestruct (side-effect of goodbye) + let contract_addr = + SuicideContract::deploy_builder(provider.clone()).from(from).deploy().await.unwrap(); + let contract = SuicideContract::new(contract_addr, provider.clone()); + + // Test TraceActions + let tracer = TraceFilter { + from_block: Some(provider.get_block_number().await.unwrap()), + to_block: None, + from_address: vec![from, contract_addr], + to_address: vec![], // Leave as 0 address + mode: TraceFilterMode::Union, + after: None, + count: None, + }; + + // Execute call + let call = contract.goodbye().from(from); + let call = call.send().await.unwrap(); + call.get_receipt().await.unwrap(); + + // Mine transactions to filter against + for i in 0..=5 { + let tx = TransactionRequest::default().to(to_two).value(U256::from(i)).from(from_two); + let tx = WithOtherFields::new(tx); + provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + } + + let traces = api.trace_filter(tracer).await.unwrap(); + assert_eq!(traces.len(), 3); + + // Test Range Error + let latest = provider.get_block_number().await.unwrap(); + let tracer = TraceFilter { + from_block: Some(latest), + to_block: Some(latest + 301), + from_address: vec![], + to_address: vec![], + mode: TraceFilterMode::Union, + after: None, + count: None, + }; + + let traces = api.trace_filter(tracer).await; + assert!(traces.is_err()); + + // Test same from and to block is valid + let latest = provider.get_block_number().await.unwrap(); + let tracer = TraceFilter { + from_block: Some(latest), + to_block: Some(latest), + from_address: vec![], + to_address: vec![], + mode: TraceFilterMode::Union, + after: None, + count: None, + }; + + let traces = api.trace_filter(tracer).await; + assert!(traces.is_ok()); + + // Test invalid block range + let latest = provider.get_block_number().await.unwrap(); + let tracer = TraceFilter { + from_block: Some(latest + 10), + to_block: Some(latest), + from_address: vec![], + to_address: vec![], + mode: TraceFilterMode::Union, + after: None, + count: None, + }; + + let traces = api.trace_filter(tracer).await; + assert!(traces.is_err()); + + // Test after and count + let tracer = TraceFilter { + from_block: Some(provider.get_block_number().await.unwrap()), + to_block: None, + from_address: vec![], + to_address: vec![], + mode: TraceFilterMode::Union, + after: Some(3), + count: Some(5), + }; + + for i in 0..=10 { + let tx = TransactionRequest::default().to(to).value(U256::from(i)).from(from); + let tx = WithOtherFields::new(tx); + provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + } + + let traces = api.trace_filter(tracer).await.unwrap(); + assert_eq!(traces.len(), 5); +} + +#[cfg(feature = "js-tracer")] +#[tokio::test(flavor = "multi_thread")] +async fn test_call_tracer_debug_trace_call_js_tracer() { + let (api, handle) = spawn(NodeConfig::test()).await; + let wallets = handle.dev_wallets().collect::>(); + let deployer: EthereumWallet = wallets[0].clone().into(); + let provider = http_provider_with_signer(&handle.http_endpoint(), deployer); + + let multicall_contract = Multicall::deploy(&provider).await.unwrap(); + let simple_storage_contract = + SimpleStorage::deploy(&provider, "init value".to_string()).await.unwrap(); + + let set_value = simple_storage_contract.setValue("bar".to_string()); + let set_value_calldata = set_value.calldata(); + + let internal_call_tx_builder = multicall_contract.aggregate(vec![Multicall::Call { + target: *simple_storage_contract.address(), + callData: set_value_calldata.to_owned(), + }]); + + let internal_call_tx_calldata = internal_call_tx_builder.calldata().to_owned(); + + let internal_call_tx = TransactionRequest::default() + .from(wallets[1].address()) + .to(*multicall_contract.address()) + .with_input(internal_call_tx_calldata); + + let js_tracer_code = r#" +{ +data: [], +step: function(log) { + var op = log.op.toString(); + if (op === "SLOAD") { + this.data.push(log.getPC() + ": SLOAD " + log.contract.getAddress() + ":" + log.stack.peek(0)); + this.data.push(" Result: " + log.stack.peek(0)); + } else if (op === "SSTORE") { + this.data.push(log.getPC() + ": SSTORE " + log.contract.getAddress() + ":" + log.stack.peek(1) + " <- " + log.stack.peek(0)); + } +}, +result: function() { + return this.data; +}, +fault: function(log) {} +} +"#; + + let result = api + .debug_trace_call( + WithOtherFields::new(internal_call_tx), + Some(BlockId::latest()), + GethDebugTracingCallOptions::default() + .with_tracing_options(GethDebugTracingOptions::js_tracer(js_tracer_code)), + ) + .await + .unwrap(); + + let expected = vec![ + "547: SLOAD 231,241,114,94,119,52,206,40,143,131,103,225,187,20,62,144,187,63,5,18:0", + " Result: 0", + "1907: SLOAD 231,241,114,94,119,52,206,40,143,131,103,225,187,20,62,144,187,63,5,18:1", + " Result: 1", + "772: SLOAD 231,241,114,94,119,52,206,40,143,131,103,225,187,20,62,144,187,63,5,18:1", + " Result: 1", + "835: SSTORE 231,241,114,94,119,52,206,40,143,131,103,225,187,20,62,144,187,63,5,18:44498830125527143464827115118378702402016761369235290884359940707316142178310 <- 1", + "919: SSTORE 231,241,114,94,119,52,206,40,143,131,103,225,187,20,62,144,187,63,5,18:0 <- 80084422859880547211683076133703299733277748156566366325829078699459944778998", + "712: SLOAD 231,241,114,94,119,52,206,40,143,131,103,225,187,20,62,144,187,63,5,18:0", + " Result: 0", + "765: SSTORE 231,241,114,94,119,52,206,40,143,131,103,225,187,20,62,144,187,63,5,18:546584486846459126461364135121053344201067465379 <- 0", + ]; + + let actual: Vec = result + .try_into_json_value() + .ok() + .and_then(|val| val.as_array().cloned()) + .map(|arr| arr.into_iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect()) + .unwrap_or_default(); + + assert_eq!(actual, expected); +} + +#[cfg(feature = "js-tracer")] +#[tokio::test(flavor = "multi_thread")] +async fn test_debug_trace_transaction_js_tracer() { + let node_config = NodeConfig::test().with_hardfork(Some(EthereumHardfork::Prague.into())); + let (api, handle) = spawn(node_config).await; + let provider = crate::utils::http_provider(&handle.http_endpoint()); + + let wallets = handle.dev_wallets().collect::>(); + let from = wallets[0].address(); + api.anvil_add_balance(from, U256::MAX).await.unwrap(); + api.anvil_add_balance(wallets[1].address(), U256::MAX).await.unwrap(); + + let multicall_contract = Multicall::deploy(&provider).await.unwrap(); + let simple_storage_contract = + SimpleStorage::deploy(&provider, "init value".to_string()).await.unwrap(); + + let set_value = simple_storage_contract.setValue("bar".to_string()); + let set_value_calldata = set_value.calldata(); + + let internal_call_tx_builder = multicall_contract.aggregate(vec![Multicall::Call { + target: *simple_storage_contract.address(), + callData: set_value_calldata.to_owned(), + }]); + + let internal_call_tx_calldata = internal_call_tx_builder.calldata().to_owned(); + + let internal_call_tx = TransactionRequest::default() + .from(wallets[1].address()) + .to(*multicall_contract.address()) + .with_input(internal_call_tx_calldata) + .with_gas_limit(1_000_000) + .with_max_fee_per_gas(100_000_000_000) + .with_max_priority_fee_per_gas(100_000_000_000); + + let receipt = provider + .send_transaction(internal_call_tx.into()) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + let js_tracer_code = r#" +{ +data: [], +step: function(log) { + var op = log.op.toString(); + var pc = log.getPC(); + var addr = log.contract.getAddress(); + + if (op === "SLOAD") { + this.data.push(pc + ": SLOAD " + addr + ":" + log.stack.peek(0)); + this.data.push(" Result: " + log.stack.peek(0)); + } else if (op === "SSTORE") { + this.data.push(pc + ": SSTORE " + addr + ":" + log.stack.peek(1) + " <- " + log.stack.peek(0)); + } +}, +result: function() { + return this.data; +}, +fault: function(log) {} +} +"#; + + let expected = vec![ + "547: SLOAD 231,241,114,94,119,52,206,40,143,131,103,225,187,20,62,144,187,63,5,18:0", + " Result: 0", + "1907: SLOAD 231,241,114,94,119,52,206,40,143,131,103,225,187,20,62,144,187,63,5,18:1", + " Result: 1", + "772: SLOAD 231,241,114,94,119,52,206,40,143,131,103,225,187,20,62,144,187,63,5,18:1", + " Result: 1", + "835: SSTORE 231,241,114,94,119,52,206,40,143,131,103,225,187,20,62,144,187,63,5,18:44498830125527143464827115118378702402016761369235290884359940707316142178310 <- 1", + "919: SSTORE 231,241,114,94,119,52,206,40,143,131,103,225,187,20,62,144,187,63,5,18:0 <- 80084422859880547211683076133703299733277748156566366325829078699459944778998", + "712: SLOAD 231,241,114,94,119,52,206,40,143,131,103,225,187,20,62,144,187,63,5,18:0", + " Result: 0", + "765: SSTORE 231,241,114,94,119,52,206,40,143,131,103,225,187,20,62,144,187,63,5,18:546584486846459126461364135121053344201067465379 <- 0", + ]; + let result = api + .debug_trace_transaction( + receipt.transaction_hash, + GethDebugTracingOptions::js_tracer(js_tracer_code), + ) + .await + .unwrap(); + + let actual: Vec = result + .try_into_json_value() + .ok() + .and_then(|val| val.as_array().cloned()) + .map(|arr| arr.into_iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect()) + .unwrap_or_default(); + + assert_eq!(actual, expected); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_call_tracer_debug_trace_call_pre_state_tracer() { + let (api, handle) = spawn(NodeConfig::test()).await; + let wallets = handle.dev_wallets().collect::>(); + let deployer: EthereumWallet = wallets[0].clone().into(); + let provider = http_provider_with_signer(&handle.http_endpoint(), deployer); + + let multicall_contract = Multicall::deploy(&provider).await.unwrap(); + let simple_storage_contract = + SimpleStorage::deploy(&provider, "init value".to_string()).await.unwrap(); + + let set_value = simple_storage_contract.setValue("bar".to_string()); + let set_value_calldata = set_value.calldata(); + + let internal_call_tx_builder = multicall_contract.aggregate(vec![Multicall::Call { + target: *simple_storage_contract.address(), + callData: set_value_calldata.to_owned(), + }]); + + let internal_call_tx_calldata = internal_call_tx_builder.calldata().to_owned(); + + let internal_call_tx = TransactionRequest::default() + .from(wallets[1].address()) + .to(*multicall_contract.address()) + .with_input(internal_call_tx_calldata); + + let result = api + .debug_trace_call( + WithOtherFields::new(internal_call_tx), + Some(BlockId::latest()), + GethDebugTracingCallOptions::default().with_tracing_options( + GethDebugTracingOptions::prestate_tracer(PreStateConfig::default()), + ), + ) + .await + .unwrap(); + + let expected = r#" +{ + "0x0000000000000000000000000000000000000000": { + "balance": "0x12670f" + }, + "0x5fbdb2315678afecb367f032d93f642f64180aa3": { + "balance": "0x0", + "nonce": 1 + }, + "0x70997970c51812dc3a010c7d01b50e0d17dc79c8": { + "balance": "0x56bc75e2d63100000" + }, + "0xe7f1725e7734ce288f8367e1bb143e90bb3f0512": { + "balance": "0x0", + "nonce": 1, + "storage": { + "0x0000000000000000000000000000000000000000000000000000000000000000": "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000001": "0x696e69742076616c756500000000000000000000000000000000000000000014", + "0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6": "0x0000000000000000000000000000000000000000000000000000000000000000" + } + } +} + "#; + let expected: HashMap = serde_json::from_str(expected).unwrap(); + + match result { + GethTrace::PreStateTracer(PreStateFrame::Default(pre_state_mode)) => { + for (addr, acc) in pre_state_mode.0 { + let expected_acc = expected.get(&addr).unwrap(); + assert_eq!(acc.balance, expected_acc.balance); + assert_eq!(acc.nonce, expected_acc.nonce); + let expected_storage = &expected_acc.storage; + for (slot, value) in acc.storage { + assert_eq!(value, *expected_storage.get(&slot).unwrap()) + } + } + } + _ => unreachable!(), + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_debug_trace_transaction_pre_state_tracer() { + let node_config = NodeConfig::test().with_hardfork(Some(EthereumHardfork::Prague.into())); + let (api, handle) = spawn(node_config).await; + let provider = crate::utils::http_provider(&handle.http_endpoint()); + + let wallets = handle.dev_wallets().collect::>(); + let from = wallets[0].address(); + api.anvil_add_balance(from, U256::MAX).await.unwrap(); + api.anvil_add_balance(wallets[1].address(), U256::MAX).await.unwrap(); + + let multicall_contract = Multicall::deploy(&provider).await.unwrap(); + let simple_storage_contract = + SimpleStorage::deploy(&provider, "init value".to_string()).await.unwrap(); + + let set_value = simple_storage_contract.setValue("bar".to_string()); + let set_value_calldata = set_value.calldata(); + + let internal_call_tx_builder = multicall_contract.aggregate(vec![Multicall::Call { + target: *simple_storage_contract.address(), + callData: set_value_calldata.to_owned(), + }]); + + let internal_call_tx_calldata = internal_call_tx_builder.calldata().to_owned(); + + let internal_call_tx = TransactionRequest::default() + .from(wallets[1].address()) + .to(*multicall_contract.address()) + .with_input(internal_call_tx_calldata) + .with_gas_limit(1_000_000) + .with_max_fee_per_gas(100_000_000_000) + .with_max_priority_fee_per_gas(100_000_000_000); + + let receipt = provider + .send_transaction(internal_call_tx.into()) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); - let expected_traces: Vec = serde_json::from_value(json).unwrap(); + let result = api + .debug_trace_transaction( + receipt.transaction_hash, + GethDebugTracingOptions::prestate_tracer(PreStateConfig::default()), + ) + .await + .unwrap(); - // test matching traceAddress - traces.into_iter().zip(expected_traces).for_each(|(a, b)| { - assert_eq!(a.trace.trace_address, b.trace.trace_address); - assert_eq!(a.trace.subtraces, b.trace.subtraces); - match (a.trace.action, b.trace.action) { - (Action::Call(a), Action::Call(b)) => { - assert_eq!(a.from, b.from); - assert_eq!(a.to, b.to); + let expected = r#" +{ + "0x0000000000000000000000000000000000000000": { + "balance": "1206031000000000" + }, + "0x5fbdb2315678afecb367f032d93f642f64180aa3": { + "balance": "0x0", + "nonce": 1 + }, + "0x70997970c51812dc3a010c7d01b50e0d17dc79c8": { + "balance": "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + "0xe7f1725e7734ce288f8367e1bb143e90bb3f0512": { + "balance": "0x0", + "nonce": 1, + "storage": { + "0x0000000000000000000000000000000000000000000000000000000000000000": "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000000000000000000000000001": "0x696e69742076616c756500000000000000000000000000000000000000000014", + "0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6": "0x0000000000000000000000000000000000000000000000000000000000000000" + } + } +} + "#; + let expected: HashMap = serde_json::from_str(expected).unwrap(); + + match result { + GethTrace::PreStateTracer(PreStateFrame::Default(pre_state_mode)) => { + for (addr, acc) in pre_state_mode.0 { + let expected_acc = expected.get(&addr).unwrap(); + assert_eq!(acc.balance, expected_acc.balance); + assert_eq!(acc.nonce, expected_acc.nonce); + let expected_storage = &expected_acc.storage; + for (slot, value) in acc.storage { + assert_eq!(value, *expected_storage.get(&slot).unwrap()) + } } - _ => unreachable!("unexpected action"), } - }) + _ => unreachable!(), + } } #[tokio::test(flavor = "multi_thread")] -async fn test_trace_filter() { +async fn test_trace_replay_block_transactions_local() { let (api, handle) = spawn(NodeConfig::test()).await; - let provider = handle.ws_provider(); + let provider = handle.http_provider(); + + api.anvil_set_auto_mine(false).await.unwrap(); let accounts = handle.dev_wallets().collect::>(); let from = accounts[0].address(); let to = accounts[1].address(); - let from_two = accounts[2].address(); - let to_two = accounts[3].address(); + let amount = U256::from(1000000u64); + + // Send first transaction + let tx1 = TransactionRequest::default().to(to).value(amount).from(from); + let tx1 = WithOtherFields::new(tx1); + let pending_tx1 = provider.send_transaction(tx1).await.unwrap(); + + // Send second transaction with different value + let tx2 = TransactionRequest::default().to(to).value(amount).from(from); + let tx2 = WithOtherFields::new(tx2); + let pending_tx2 = provider.send_transaction(tx2).await.unwrap(); + + api.mine_one().await.unwrap(); + let receipt1 = pending_tx1.get_receipt().await.unwrap(); + let receipt2 = pending_tx2.get_receipt().await.unwrap(); + + let block_number = receipt2.block_number.unwrap(); + + // Replay the block transactions with call trace type + // Pass block number as hex string as per Ethereum RPC spec + let results = api + .trace_replay_block_transactions( + block_number.into(), + vec![TraceType::Trace, TraceType::VmTrace, TraceType::StateDiff].into_iter().collect(), + ) + .await + .unwrap() + .unwrap(); - // Test default block ranges. - // From will be earliest, to will be best/latest - let tracer = TraceFilter { - from_block: None, - to_block: None, - from_address: vec![], - to_address: vec![], - mode: TraceFilterMode::Intersection, - after: None, - count: None, - }; + // Verify we have traces for both transactions + assert_eq!(results.len(), 2, "Should have traces for 2 transactions"); - for i in 0..=5 { - let tx = TransactionRequest::default().to(to).value(U256::from(i)).from(from); - let tx = WithOtherFields::new(tx); - api.send_transaction(tx).await.unwrap(); + // Verify first transaction hash matches + assert_eq!(results[0].transaction_hash, receipt1.transaction_hash); + + // Verify second transaction hash matches + assert_eq!(results[1].transaction_hash, receipt2.transaction_hash); + + // Verify trace types are present and accurate + for result in results { + let full_trace = &result.full_trace; + + // Verify Trace (call trace) is present and accurate + assert!(!full_trace.trace.is_empty(), "Trace should not be empty"); + let first_trace = &full_trace.trace[0]; + match &first_trace.action { + Action::Call(call) => { + assert_eq!(call.from, from, "Call from address should match"); + assert_eq!(call.to, to, "Call to address should match"); + } + _ => panic!("Expected Call action, got {:?}", first_trace.action), + } + + // Verify VmTrace is present + assert!(full_trace.vm_trace.is_some(), "VmTrace should be present when requested"); + + // Verify StateDiff is present + assert!(full_trace.state_diff.is_some(), "StateDiff should be present when requested"); + // Verify balance change is correct in state diff + let ChangedType:: { from, to } = + full_trace.state_diff.as_ref().unwrap().get(&to).unwrap().balance.as_changed().unwrap(); + assert_eq!( + to.checked_sub(*from).unwrap(), + amount, + "Incorrect balance change in state diff" + ); } +} - let traces = api.trace_filter(tracer).await.unwrap(); - assert_eq!(traces.len(), 5); +#[tokio::test(flavor = "multi_thread")] +async fn test_trace_replay_transaction() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); - // Test filtering by address - let tracer = TraceFilter { - from_block: Some(provider.get_block_number().await.unwrap()), - to_block: None, - from_address: vec![from_two], - to_address: vec![to_two], - mode: TraceFilterMode::Intersection, - after: None, - count: None, - }; + let accounts = handle.dev_wallets().collect::>(); + let from = accounts[0].address(); + let to = accounts[1].address(); + let amount = U256::from(12345); - for i in 0..=5 { - let tx = TransactionRequest::default().to(to).value(U256::from(i)).from(from); - let tx = WithOtherFields::new(tx); - provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + let tx = TransactionRequest::default().to(to).value(amount).from(from); + let tx = WithOtherFields::new(tx); + let receipt = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); - let tx = TransactionRequest::default().to(to_two).value(U256::from(i)).from(from_two); - let tx = WithOtherFields::new(tx); - provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + let TraceResultsWithTransactionHash { full_trace: result, transaction_hash } = provider + .client() + .request( + "trace_replayTransaction", + (receipt.transaction_hash, vec![TraceType::Trace, TraceType::StateDiff]), + ) + .await + .unwrap(); + + assert_eq!(transaction_hash, receipt.transaction_hash); + assert!(!result.trace.is_empty()); + match &result.trace[0].action { + Action::Call(call) => { + assert_eq!(call.from, from); + assert_eq!(call.to, to); + } + _ => panic!("Expected Call action, got {:?}", result.trace[0].action), } - let traces = api.trace_filter(tracer).await.unwrap(); - assert_eq!(traces.len(), 6); + let ChangedType:: { from, to } = + result.state_diff.as_ref().unwrap().get(&to).unwrap().balance.as_changed().unwrap(); + assert_eq!(to.checked_sub(*from).unwrap(), amount); +} - // Test for the following actions: - // Create (deploy the contract) - // Call (goodbye function) - // SelfDestruct (side-effect of goodbye) - let contract_addr = - SuicideContract::deploy_builder(provider.clone()).from(from).deploy().await.unwrap(); - let contract = SuicideContract::new(contract_addr, provider.clone()); +#[tokio::test(flavor = "multi_thread")] +async fn test_trace_replay_transaction_fork() { + let (_origin_api, origin_handle) = spawn(NodeConfig::test()).await; + let origin = origin_handle.http_provider(); + let accounts = origin_handle.dev_wallets().collect::>(); + let tx = TransactionRequest::default() + .to(accounts[1].address()) + .value(U256::from(1000)) + .from(accounts[0].address()); + let receipt = origin + .send_transaction(WithOtherFields::new(tx)) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + let hash = receipt.transaction_hash; + + let config = NodeConfig::test().with_eth_rpc_url(Some(origin_handle.http_endpoint())); + let (_api, handle) = spawn(config).await; + + // The pre-fork transaction is replayed upstream and keeps its hash. + let mut replays = Vec::new(); + for provider in [handle.http_provider(), origin] { + replays.push( + provider + .client() + .request::<_, TraceResultsWithTransactionHash>( + "trace_replayTransaction", + (hash, vec![TraceType::Trace]), + ) + .await + .unwrap(), + ); + } + assert_eq!(replays[0].transaction_hash, hash); + assert_eq!(replays[0], replays[1]); - // Test TraceActions - let tracer = TraceFilter { - from_block: Some(provider.get_block_number().await.unwrap()), - to_block: None, - from_address: vec![from, contract_addr], - to_address: vec![], // Leave as 0 address - mode: TraceFilterMode::Union, - after: None, - count: None, - }; + // A hash unknown upstream as well is null. + let unknown = handle + .http_provider() + .client() + .request::<_, Option>( + "trace_replayTransaction", + (B256::ZERO, vec![TraceType::Trace]), + ) + .await + .unwrap(); + assert_eq!(unknown, None); +} - // Execute call - let call = contract.goodbye().from(from); - let call = call.send().await.unwrap(); - call.get_receipt().await.unwrap(); +#[tokio::test(flavor = "multi_thread")] +async fn test_trace_replay_state_diff_account_lifecycle() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + let from = handle.dev_wallets().next().unwrap().address(); + let fresh = Address::repeat_byte(0x66); + let nonce = provider.get_transaction_count(from).await.unwrap(); + api.anvil_set_auto_mine(false).await.unwrap(); - // Mine transactions to filter against - for i in 0..=5 { - let tx = TransactionRequest::default().to(to_two).value(U256::from(i)).from(from_two); - let tx = WithOtherFields::new(tx); - provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + let create = TransactionRequest::default() + .from(from) + .nonce(nonce) + .with_deploy_code(Bytes::from_hex(INIT_RETURNING_2A).unwrap()); + let create = provider.send_transaction(WithOtherFields::new(create)).await.unwrap(); + let fund = + TransactionRequest::default().from(from).to(fresh).nonce(nonce + 1).value(U256::from(7)); + let fund = provider.send_transaction(WithOtherFields::new(fund)).await.unwrap(); + let update = + TransactionRequest::default().from(from).to(fresh).nonce(nonce + 2).value(U256::from(7)); + let update = provider.send_transaction(WithOtherFields::new(update)).await.unwrap(); + api.mine_one().await.unwrap(); + + let create = create.get_receipt().await.unwrap(); + let fund = fund.get_receipt().await.unwrap(); + let update = update.get_receipt().await.unwrap(); + let created = create.contract_address.unwrap(); + let expected = [ + (create.transaction_hash, created, added_account("0x0", "0x1", "0x2a")), + (fund.transaction_hash, fresh, added_account("0x7", "0x0", "0x")), + (update.transaction_hash, fresh, changed_balance("0x7", "0xe")), + ]; + + let block = api + .trace_replay_block_transactions( + create.block_number.unwrap().into(), + [TraceType::StateDiff].into_iter().collect(), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(block.len(), expected.len()); + + for (index, (hash, address, diff)) in expected.into_iter().enumerate() { + let traces: TraceResults = provider + .client() + .request("trace_replayTransaction", (hash, vec![TraceType::StateDiff])) + .await + .unwrap(); + assert_eq!(state_diff_entry(&traces, address), Some(diff.clone())); + assert_eq!(block[index].transaction_hash, hash); + assert_eq!(state_diff_entry(&block[index].full_trace, address), Some(diff)); } +} - let traces = api.trace_filter(tracer).await.unwrap(); - assert_eq!(traces.len(), 3); +#[tokio::test(flavor = "multi_thread")] +async fn test_trace_replay_transaction_preserves_prefix_state() { + let (api, handle) = spawn(NodeConfig::test().with_steps_tracing(true)).await; + let provider = handle.http_provider(); + let from = handle.dev_wallets().next().unwrap().address(); + let contract = Address::random(); + // Return the old slot value and store calldata[0], reverting after the write if it is zero. + api.anvil_set_code( + contract, + Bytes::from_hex("600054600052600035806000551560165760206000f35b60006000fd").unwrap(), + ) + .await + .unwrap(); + api.anvil_set_storage_at(contract, U256::ZERO, B256::with_last_byte(3)).await.unwrap(); + api.anvil_set_auto_mine(false).await.unwrap(); + + let values = [5u64, 0, 13, 8]; + let mut hashes = Vec::new(); + for (nonce, value) in values.into_iter().enumerate() { + let tx = TransactionRequest::default() + .from(from) + .to(contract) + .nonce(nonce as u64) + .gas_limit(100_000) + .input(Bytes::from(U256::from(value).to_be_bytes::<32>()).into()); + hashes.push(api.send_transaction(WithOtherFields::new(tx)).await.unwrap()); + } + api.mine_one().await.unwrap(); + let block_number = provider.get_block_number().await.unwrap(); + let old_values = [3u64, 5, 5, 13]; + + for trace_types in [ + vec![TraceType::Trace, TraceType::VmTrace, TraceType::StateDiff], + vec![TraceType::Trace], + vec![TraceType::VmTrace], + vec![TraceType::StateDiff], + vec![], + ] { + let block_results = api + .trace_replay_block_transactions( + block_number.into(), + trace_types.iter().copied().collect(), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(block_results.len(), hashes.len()); + for (index, hash) in hashes.iter().copied().enumerate() { + let result: TraceResults = provider + .client() + .request("trace_replayTransaction", (hash, &trace_types)) + .await + .unwrap(); + assert_eq!(block_results[index].transaction_hash, hash); + assert_eq!(result, block_results[index].full_trace); + if values[index] == 0 { + assert!(result.output.is_empty()); + if trace_types.contains(&TraceType::Trace) { + assert!(result.trace[0].error.is_some()); + } + } else { + assert_eq!( + result.output.as_ref(), + U256::from(old_values[index]).to_be_bytes::<32>() + ); + if let Some(state_diff) = &result.state_diff { + let change = state_diff[&contract].storage[&B256::ZERO].as_changed().unwrap(); + assert_eq!(change.from, B256::from(U256::from(old_values[index]))); + assert_eq!(change.to, B256::from(U256::from(values[index]))); + } + } + } + } + let block = api.backend.get_block(BlockId::number(block_number)).unwrap(); + let mut rlp_block = Vec::new(); + block.encode(&mut rlp_block); + for options in [ + serde_json::json!({"tracer": "callTracer"}), + serde_json::json!({"tracer": "callTracer", "tracerConfig": {"withLog": true}}), + serde_json::json!({"tracer": "callTracer", "tracerConfig": {"onlyTopCall": true}}), + serde_json::json!({"tracer": "callTracer", "tracerConfig": {"onlyTopLevelCall": true}}), + serde_json::json!({"tracer": "callTracer", "tracerConfig": {"withLog": "invalid"}}), + serde_json::json!({"tracer": "noopTracer"}), + serde_json::json!({}), + serde_json::json!({"enableMemory": true, "enableReturnData": true}), + ] { + let options = serde_json::from_value::(options).unwrap(); + let mut expected = Vec::new(); + for hash in &hashes { + expected.push( + match api.backend.debug_trace_transaction(*hash, options.clone()).await { + Ok(result) => TraceResult::Success { result, tx_hash: Some(*hash) }, + Err(error) => { + TraceResult::Error { error: error.to_string(), tx_hash: Some(*hash) } + } + }, + ); + } + assert_eq!( + api.backend + .debug_trace_block_by_number(block_number.into(), options.clone()) + .await + .unwrap(), + expected, + ); + assert_eq!( + api.backend + .debug_trace_block_by_hash(block.header.hash_slow(), options.clone()) + .await + .unwrap(), + expected, + ); + assert_eq!( + api.backend.debug_trace_block(rlp_block.clone().into(), options).await.unwrap(), + expected, + ); + } + // Replays must not mutate the live chain. + assert_eq!(provider.get_storage_at(contract, U256::ZERO).await.unwrap(), U256::from(8)); + assert_eq!(provider.get_transaction_count(from).await.unwrap(), hashes.len() as u64); + assert_eq!(provider.get_block_number().await.unwrap(), block_number); +} - // Test Range Error - let latest = provider.get_block_number().await.unwrap(); - let tracer = TraceFilter { - from_block: Some(latest), - to_block: Some(latest + 301), - from_address: vec![], - to_address: vec![], - mode: TraceFilterMode::Union, - after: None, - count: None, - }; +#[tokio::test(flavor = "multi_thread")] +async fn test_debug_trace_block_without_history() { + let (api, handle) = spawn(NodeConfig::test().set_pruned_history(Some(None))).await; + let from = handle.dev_wallets().next().unwrap().address(); + let invalid = serde_json::from_value::(serde_json::json!({ + "tracer": "callTracer", "tracerConfig": {"withLog": "invalid"}, + })) + .unwrap(); + assert!( + api.backend + .debug_trace_block_by_number(0.into(), invalid.clone()) + .await + .unwrap() + .is_empty() + ); + + api.anvil_set_auto_mine(false).await.unwrap(); + let mut hashes = Vec::new(); + for nonce in 0..2 { + hashes.push( + api.send_transaction(WithOtherFields::new( + TransactionRequest::default().from(from).to(from).nonce(nonce).gas_limit(21_000), + )) + .await + .unwrap(), + ); + } + api.mine_one().await.unwrap(); + let receipt = handle.http_provider().get_transaction_receipt(hashes[0]).await.unwrap().unwrap(); + api.mine_one().await.unwrap(); + for options in [ + GethDebugTracingOptions::default() + .with_tracer(GethDebugBuiltInTracerType::CallTracer.into()), + invalid, + GethDebugTracingOptions::default(), + GethDebugTracingOptions::default() + .with_tracer(GethDebugBuiltInTracerType::NoopTracer.into()), + ] { + let mut expected = Vec::new(); + for hash in &hashes { + let trace = match api.backend.debug_trace_transaction(*hash, options.clone()).await { + Ok(result) => TraceResult::Success { result, tx_hash: Some(*hash) }, + Err(error) => TraceResult::Error { error: error.to_string(), tx_hash: Some(*hash) }, + }; + assert_eq!( + trace.is_error(), + matches!( + options.tracer, + Some(GethDebugTracerType::BuiltInTracer( + GethDebugBuiltInTracerType::CallTracer + )) + ), + ); + expected.push(trace); + } + assert_eq!( + api.backend + .debug_trace_block_by_hash(receipt.block_hash.unwrap(), options.clone()) + .await + .unwrap(), + expected.clone(), + ); + assert_eq!( + api.backend + .debug_trace_block_by_number(receipt.block_number.unwrap().into(), options) + .await + .unwrap(), + expected, + ); + } +} - let traces = api.trace_filter(tracer).await; - assert!(traces.is_err()); +#[tokio::test(flavor = "multi_thread")] +async fn test_debug_trace_block_by_number() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); - // Test same from and to block is valid - let latest = provider.get_block_number().await.unwrap(); - let tracer = TraceFilter { - from_block: Some(latest), - to_block: Some(latest), - from_address: vec![], - to_address: vec![], - mode: TraceFilterMode::Union, - after: None, - count: None, - }; + let accounts = handle.dev_wallets().collect::>(); + let from = accounts[0].address(); + let to = accounts[1].address(); + let amount = U256::from(1000); - let traces = api.trace_filter(tracer).await; - assert!(traces.is_ok()); + let tx = TransactionRequest::default().to(to).value(amount).from(from); + let tx = WithOtherFields::new(tx); + let receipt = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + let block_number = receipt.block_number.unwrap(); + + let traces = api + .backend + .debug_trace_block_by_number( + BlockNumberOrTag::Number(block_number), + GethDebugTracingOptions::default() + .with_tracer(GethDebugTracerType::from(GethDebugBuiltInTracerType::CallTracer)), + ) + .await + .unwrap(); - // Test invalid block range - let latest = provider.get_block_number().await.unwrap(); - let tracer = TraceFilter { - from_block: Some(latest + 10), - to_block: Some(latest), - from_address: vec![], - to_address: vec![], - mode: TraceFilterMode::Union, - after: None, - count: None, - }; + assert_eq!(traces.len(), 1); - let traces = api.trace_filter(tracer).await; - assert!(traces.is_err()); + match &traces[0] { + alloy_rpc_types::trace::geth::TraceResult::Success { result, .. } => match result { + GethTrace::CallTracer(call_frame) => { + assert_eq!(call_frame.from, from); + assert_eq!(call_frame.to.unwrap(), to); + assert_eq!(call_frame.value, Some(amount)); + } + _ => unreachable!("expected CallTracer"), + }, + alloy_rpc_types::trace::geth::TraceResult::Error { error, .. } => { + panic!("trace failed: {error}"); + } + } +} - // Test after and count - let tracer = TraceFilter { - from_block: Some(provider.get_block_number().await.unwrap()), - to_block: None, - from_address: vec![], - to_address: vec![], - mode: TraceFilterMode::Union, - after: Some(3), - count: Some(5), - }; +#[tokio::test(flavor = "multi_thread")] +async fn test_debug_trace_block_by_hash() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); - for i in 0..=10 { - let tx = TransactionRequest::default().to(to).value(U256::from(i)).from(from); - let tx = WithOtherFields::new(tx); - provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + let accounts = handle.dev_wallets().collect::>(); + let from = accounts[0].address(); + let to = accounts[1].address(); + let amount = U256::from(2000); + + let tx = TransactionRequest::default().to(to).value(amount).from(from); + let tx = WithOtherFields::new(tx); + let receipt = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + let block_hash = receipt.block_hash.unwrap(); + + let traces = api + .backend + .debug_trace_block_by_hash( + block_hash, + GethDebugTracingOptions::default() + .with_tracer(GethDebugTracerType::from(GethDebugBuiltInTracerType::CallTracer)), + ) + .await + .unwrap(); + + assert_eq!(traces.len(), 1); + + match &traces[0] { + alloy_rpc_types::trace::geth::TraceResult::Success { result, .. } => match result { + GethTrace::CallTracer(call_frame) => { + assert_eq!(call_frame.from, from); + assert_eq!(call_frame.to.unwrap(), to); + assert_eq!(call_frame.value, Some(amount)); + } + _ => unreachable!("expected CallTracer"), + }, + alloy_rpc_types::trace::geth::TraceResult::Error { error, .. } => { + panic!("trace failed: {error}"); + } } +} - let traces = api.trace_filter(tracer).await.unwrap(); - assert_eq!(traces.len(), 5); +#[tokio::test(flavor = "multi_thread")] +async fn test_debug_trace_block() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + let accounts = handle.dev_wallets().collect::>(); + let from = accounts[0].address(); + let to = accounts[1].address(); + let amount = U256::from(3000); + + let tx = TransactionRequest::default().to(to).value(amount).from(from); + let tx = WithOtherFields::new(tx); + let receipt = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + let block_hash = receipt.block_hash.unwrap(); + let block = api.backend.get_block(BlockId::hash(block_hash)).unwrap(); + + let mut rlp_block = Vec::new(); + block.encode(&mut rlp_block); + + let traces = provider + .debug_trace_block( + &rlp_block, + GethDebugTracingOptions::default() + .with_tracer(GethDebugTracerType::from(GethDebugBuiltInTracerType::CallTracer)), + ) + .await + .unwrap(); + + assert_eq!(traces.len(), 1); + + match &traces[0] { + alloy_rpc_types::trace::geth::TraceResult::Success { result, .. } => match result { + GethTrace::CallTracer(call_frame) => { + assert_eq!(call_frame.from, from); + assert_eq!(call_frame.to.unwrap(), to); + assert_eq!(call_frame.value, Some(amount)); + } + _ => unreachable!("expected CallTracer"), + }, + alloy_rpc_types::trace::geth::TraceResult::Error { error, .. } => { + panic!("trace failed: {error}"); + } + } } diff --git a/crates/anvil/tests/it/transaction.rs b/crates/anvil/tests/it/transaction.rs index b5d9e1a78e68d..d0f9a35b62ee8 100644 --- a/crates/anvil/tests/it/transaction.rs +++ b/crates/anvil/tests/it/transaction.rs @@ -2,21 +2,32 @@ use crate::{ abi::{Greeter, Multicall, SimpleStorage}, utils::{connect_pubsub, http_provider_with_signer}, }; -use alloy_hardforks::EthereumHardfork; -use alloy_network::{EthereumWallet, TransactionBuilder, TransactionResponse}; -use alloy_primitives::{Address, Bytes, FixedBytes, U256, address, hex, map::B256HashSet}; -use alloy_provider::{Provider, WsConnect}; +use alloy_consensus::{Header, Transaction}; +use alloy_network::{ + AnyRpcTransaction, EthereumWallet, Network, ReceiptResponse, TransactionBuilder, + TransactionResponse, eip2718::Decodable2718, +}; +use alloy_primitives::{ + Address, B256, Bytes, FixedBytes, TxHash, U64, U256, address, b256, bytes, hex, + map::B256HashSet, +}; +use alloy_provider::{PendingTransactionBuilder, Provider, WsConnect}; +use alloy_rlp::Decodable; use alloy_rpc_types::{ - AccessList, AccessListItem, BlockId, BlockNumberOrTag, BlockOverrides, BlockTransactions, - TransactionRequest, + AccessList, AccessListItem, AccessListResult, BlockId, BlockNumberOrTag, BlockOverrides, + BlockTransactions, TransactionRequest, state::{AccountOverride, EvmOverrides, StateOverride, StateOverridesBuilder}, }; use alloy_serde::WithOtherFields; use alloy_sol_types::SolValue; use anvil::{NodeConfig, spawn}; +use anvil_core::eth::block::Block; use eyre::Ok; +use foundry_evm::hardfork::EthereumHardfork; +use foundry_primitives::FoundryReceiptEnvelope; use futures::{FutureExt, StreamExt, future::join_all}; -use std::{str::FromStr, time::Duration}; +use revm::primitives::eip7825::TX_GAS_LIMIT_CAP; +use std::time::{Duration, Instant}; use tokio::time::timeout; #[tokio::test(flavor = "multi_thread")] @@ -56,6 +67,45 @@ async fn can_transfer_eth() { assert_eq!(balance_before.saturating_add(amount), to_balance); } +#[tokio::test(flavor = "multi_thread")] +async fn can_get_pending_transactions() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + api.anvil_set_auto_mine(false).await.unwrap(); + + let accounts = handle.dev_wallets().collect::>(); + let sender = accounts[0].address(); + let other_sender = accounts[1].address(); + let recipient = accounts[2].address(); + + let sender_tx = + TransactionRequest::default().from(sender).to(recipient).value(U256::ONE).nonce(0); + let sender_tx = provider.send_transaction(WithOtherFields::new(sender_tx)).await.unwrap(); + + let queued_tx = + TransactionRequest::default().from(sender).to(recipient).value(U256::from(2)).nonce(2); + let queued_tx = provider.send_transaction(WithOtherFields::new(queued_tx)).await.unwrap(); + + let other_tx = TransactionRequest::default() + .from(other_sender) + .to(recipient) + .value(U256::from(3)) + .nonce(0); + let other_tx = provider.send_transaction(WithOtherFields::new(other_tx)).await.unwrap(); + + let pending: Vec = + provider.client().request("eth_pendingTransactions", ()).await.unwrap(); + let hashes = pending.iter().map(|tx| B256::from(*tx.inner.tx_hash())).collect::(); + + assert_eq!(pending.len(), 2); + assert!(hashes.contains(sender_tx.tx_hash())); + assert!(hashes.contains(other_tx.tx_hash())); + assert!(!hashes.contains(queued_tx.tx_hash())); + assert!(pending.iter().any(|tx| tx.from() == sender)); + assert!(pending.iter().any(|tx| tx.from() == other_sender)); +} + #[tokio::test(flavor = "multi_thread")] async fn can_order_transactions() { let (api, handle) = spawn(NodeConfig::test()).await; @@ -87,7 +137,7 @@ async fn can_order_transactions() { let tx_higher = provider.send_transaction(tx).await.unwrap(); // manually mine the block with the transactions - api.mine_one().await; + api.mine_one().await.unwrap(); let higher_price = tx_higher.get_receipt().await.unwrap().transaction_hash; let lower_price = tx_lower.get_receipt().await.unwrap().transaction_hash; @@ -171,24 +221,127 @@ async fn can_replace_transaction() { let higher_tx_hash = *higher_priced_pending_tx.tx_hash(); // mine exactly one block - api.mine_one().await; + api.mine_one().await.unwrap(); let block = provider.get_block(1.into()).await.unwrap().unwrap(); assert_eq!(block.transactions.len(), 1); assert_eq!(BlockTransactions::Hashes(vec![higher_tx_hash]), block.transactions); - // FIXME: Unable to get receipt despite hotfix in https://github.com/alloy-rs/alloy/pull/614 + // verify the higher priced transaction was included + let higher_priced_receipt = higher_priced_pending_tx.get_receipt().await.unwrap(); + assert_eq!(higher_priced_receipt.transaction_hash, higher_tx_hash); + + // verify only one transaction was included in the block (lower priced was replaced) + assert_eq!(1, block.transactions.len()); + assert_eq!( + BlockTransactions::Hashes(vec![higher_priced_receipt.transaction_hash]), + block.transactions + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn can_resend_transaction() { + let (api, handle) = spawn(NodeConfig::test()).await; + + api.anvil_set_auto_mine(false).await.unwrap(); + + let provider = handle.http_provider(); + + let accounts = handle.dev_wallets().collect::>(); + let from = accounts[0].address(); + let to = accounts[1].address(); + + let nonce = provider.get_transaction_count(from).await.unwrap(); + let gas_price = provider.get_gas_price().await.unwrap(); + let amount = handle.genesis_balance().checked_div(U256::from(3u64)).unwrap(); + + let tx = TransactionRequest::default() + .to(to) + .value(amount) + .from(from) + .nonce(nonce) + .gas_price(gas_price); + + let tx = WithOtherFields::new(tx); + let original_pending_tx = provider.send_transaction(tx.clone()).await.unwrap(); + + let replacement_gas_price = gas_price + 1; + let replacement_gas_limit = 22_000; + let replacement_hash: TxHash = provider + .client() + .request( + "eth_resend", + (tx, Some(U256::from(replacement_gas_price)), Some(U64::from(replacement_gas_limit))), + ) + .await + .unwrap(); + + assert_ne!(*original_pending_tx.tx_hash(), replacement_hash); + + api.mine_one().await.unwrap(); + + let block = provider.get_block(1.into()).await.unwrap().unwrap(); + assert_eq!(BlockTransactions::Hashes(vec![replacement_hash]), block.transactions); + + let replacement_tx: AnyRpcTransaction = + provider.get_transaction_by_hash(replacement_hash).await.unwrap().unwrap(); + assert_eq!(replacement_tx.inner.tx_hash(), replacement_hash); + assert_eq!(replacement_tx.inner.gas_limit(), replacement_gas_limit); + assert_eq!(Transaction::max_fee_per_gas(replacement_tx.inner()), replacement_gas_price); +} + +#[tokio::test(flavor = "multi_thread")] +async fn can_reject_invalid_resend_transaction() { + let (api, handle) = spawn(NodeConfig::test()).await; + + api.anvil_set_auto_mine(false).await.unwrap(); + + let provider = handle.http_provider(); + + let accounts = handle.dev_wallets().collect::>(); + let from = accounts[0].address(); + let to = accounts[1].address(); - // lower priced transaction was replaced - // let _lower_priced_receipt = lower_priced_pending_tx.get_receipt().await.unwrap(); - // let higher_priced_receipt = higher_priced_pending_tx.get_receipt().await.unwrap(); + let nonce = provider.get_transaction_count(from).await.unwrap(); + let gas_price = provider.get_gas_price().await.unwrap(); + let amount = handle.genesis_balance().checked_div(U256::from(3u64)).unwrap(); + + let missing_nonce_tx = + TransactionRequest::default().to(to).value(amount).from(from).gas_price(gas_price); + let missing_nonce_resend: Result = provider + .client() + .request("eth_resend", (WithOtherFields::new(missing_nonce_tx), None::, None::)) + .await; + assert!(missing_nonce_resend.unwrap_err().to_string().contains("missing transaction nonce")); + + let tx = TransactionRequest::default() + .to(to) + .value(amount) + .from(from) + .nonce(nonce) + .gas_price(gas_price); + + let not_in_pool_resend: Result = provider + .client() + .request("eth_resend", (WithOtherFields::new(tx.clone()), None::, None::)) + .await; + assert!(not_in_pool_resend.unwrap_err().to_string().contains("transaction not found")); + + let mut tx = WithOtherFields::new(tx); + tx.set_gas_price(gas_price + 1); + let pending_tx = provider.send_transaction(tx.clone()).await.unwrap(); + + let underpriced_resend: Result = provider + .client() + .request("eth_resend", (tx, Some(U256::from(gas_price)), None::)) + .await; + assert!( + underpriced_resend.unwrap_err().to_string().contains("replacement transaction underpriced") + ); - // assert_eq!(1, block.transactions.len()); - // assert_eq!( - // BlockTransactions::Hashes(vec![higher_priced_receipt.transaction_hash]), - // block.transactions - // ); + api.mine_one().await.unwrap(); + pending_tx.get_receipt().await.unwrap(); } #[tokio::test(flavor = "multi_thread")] @@ -285,7 +438,7 @@ async fn can_reject_underpriced_replacement() { assert!(replacement_err.to_string().contains("replacement transaction underpriced")); // mine exactly one block - api.mine_one().await; + api.mine_one().await.unwrap(); let higher_priced_receipt = higher_priced_pending_tx.get_receipt().await.unwrap(); // ensure that only the higher priced tx was mined @@ -430,11 +583,11 @@ async fn can_call_greeter_historic() { assert_eq!("Another Message", greeting); // min - api.mine_one().await; + api.mine_one().await.unwrap(); // returns previous state let greeting = - greeter_contract.greet().block(BlockId::Number(block_number.into())).call().await.unwrap(); + greeter_contract.greet().block(BlockId::number(block_number)).call().await.unwrap(); assert_eq!("Hello World!", greeting); } @@ -484,7 +637,7 @@ async fn get_blocktimestamp_works() { let timestamp = contract.getCurrentBlockTimestamp().call().await.unwrap(); - assert!(timestamp > U256::from(1)); + assert!(timestamp > U256::ONE); let latest_block = api.block_by_number(alloy_rpc_types::BlockNumberOrTag::Latest).await.unwrap().unwrap(); @@ -540,12 +693,11 @@ async fn call_past_state() { assert_eq!(value, "hi"); // assert previous value - let value = - contract.getValue().block(BlockId::Number(deployed_block.into())).call().await.unwrap(); + let value = contract.getValue().block(BlockId::number(deployed_block)).call().await.unwrap(); assert_eq!(value, "initial value"); - let hash = provider.get_block(BlockId::Number(1.into())).await.unwrap().unwrap().header.hash; - let value = contract.getValue().block(BlockId::Hash(hash.into())).call().await.unwrap(); + let hash = provider.get_block(BlockId::number(1)).await.unwrap().unwrap().header.hash; + let value = contract.getValue().block(BlockId::hash(hash)).call().await.unwrap(); assert_eq!(value, "initial value"); } @@ -702,7 +854,7 @@ async fn can_get_pending_transaction() { let pending = provider.get_transaction_by_hash(*tx.tx_hash()).await; assert!(pending.is_ok()); - api.mine_one().await; + api.mine_one().await.unwrap(); let mined = provider.get_transaction_by_hash(*tx.tx_hash()).await.unwrap().unwrap(); assert_eq!(mined.tx_hash(), pending.unwrap().unwrap().tx_hash()); @@ -753,12 +905,187 @@ async fn can_get_raw_transaction() { let res1 = api.raw_transaction(*tx.tx_hash()).await; assert!(res1.is_ok()); - api.mine_one().await; + api.mine_one().await.unwrap(); let res2 = api.raw_transaction(*tx.tx_hash()).await; assert_eq!(res1.unwrap(), res2.unwrap()); } +#[tokio::test(flavor = "multi_thread")] +async fn can_get_raw_receipts() { + let (api, handle) = spawn(NodeConfig::test()).await; + api.anvil_set_auto_mine(false).await.unwrap(); + + let provider = handle.http_provider(); + let accounts = handle.dev_wallets().collect::>(); + let first = TransactionRequest::default() + .from(accounts[0].address()) + .value(U256::ONE) + .to(Address::random()); + let second = TransactionRequest::default() + .from(accounts[1].address()) + .value(U256::from(2)) + .to(Address::random()); + + let first = provider.send_transaction(WithOtherFields::new(first)).await.unwrap(); + let second = provider.send_transaction(WithOtherFields::new(second)).await.unwrap(); + + api.mine_one().await.unwrap(); + let first_receipt = first.get_receipt().await.unwrap(); + let second_receipt = second.get_receipt().await.unwrap(); + assert_eq!(first_receipt.block_number, Some(1)); + assert_eq!(second_receipt.block_number, Some(1)); + + let block = provider.get_block(BlockId::number(1)).await.unwrap().unwrap(); + let raw_by_number: Vec = + provider.client().request("debug_getRawReceipts", (BlockId::number(1),)).await.unwrap(); + let raw_by_hash: Vec = provider + .client() + .request("debug_getRawReceipts", (BlockId::hash(block.header.hash),)) + .await + .unwrap(); + let missing: Result, _> = + provider.client().request("debug_getRawReceipts", (BlockId::number(999),)).await; + + assert_eq!(raw_by_number, raw_by_hash); + assert_eq!(raw_by_number.len(), 2); + assert!(missing.is_err()); + + let mut first_raw = raw_by_number[0].as_ref(); + let first_decoded = FoundryReceiptEnvelope::decode_2718(&mut first_raw).unwrap(); + let mut second_raw = raw_by_number[1].as_ref(); + let second_decoded = FoundryReceiptEnvelope::decode_2718(&mut second_raw).unwrap(); + + assert!(first_decoded.status()); + assert!(second_decoded.status()); + assert_eq!(first_decoded.cumulative_gas_used(), 21_000); + assert_eq!(second_decoded.cumulative_gas_used(), 42_000); + + // Loaded state can contain transaction metadata that is inconsistent with the block body. + // Receipt lookup must continue to use transaction hashes in block-body order. + let mut state = api.serialized_state(false).await.unwrap(); + state.transactions.reverse(); + for transaction in &mut state.transactions { + transaction.info.transaction_index = 99; + transaction.block_hash = B256::repeat_byte(0xff); + transaction.block_number = 999; + } + let (loaded_api, _loaded_handle) = + spawn(NodeConfig::test().with_init_state(Some(state.clone()))).await; + let loaded_raw = loaded_api.raw_receipts(BlockId::number(1)).await.unwrap(); + assert_eq!(loaded_raw, raw_by_number); + + // A block with any missing stored transaction must not return a partial receipt list. + state.transactions.pop(); + let (incomplete_api, _incomplete_handle) = + spawn(NodeConfig::test().with_init_state(Some(state))).await; + assert!(incomplete_api.raw_receipts(BlockId::number(1)).await.is_err()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn can_get_raw_transactions() { + let (api, handle) = spawn(NodeConfig::test()).await; + api.anvil_set_auto_mine(false).await.unwrap(); + + let provider = handle.http_provider(); + let accounts = handle.dev_wallets().collect::>(); + let first = TransactionRequest::default() + .from(accounts[0].address()) + .value(U256::ONE) + .to(Address::random()); + let second = TransactionRequest::default() + .from(accounts[1].address()) + .value(U256::from(2)) + .to(Address::random()); + + let first = provider.send_transaction(WithOtherFields::new(first)).await.unwrap(); + let second = provider.send_transaction(WithOtherFields::new(second)).await.unwrap(); + let first_hash = *first.tx_hash(); + let second_hash = *second.tx_hash(); + + api.mine_one().await.unwrap(); + let first_receipt = first.get_receipt().await.unwrap(); + let second_receipt = second.get_receipt().await.unwrap(); + assert_eq!(first_receipt.block_number, Some(1)); + assert_eq!(second_receipt.block_number, Some(1)); + + let block = provider.get_block(BlockId::number(1)).await.unwrap().unwrap(); + let raw_by_number: Vec = + provider.client().request("debug_getRawTransactions", (BlockId::number(1),)).await.unwrap(); + let raw_by_hash: Vec = provider + .client() + .request("debug_getRawTransactions", (BlockId::hash(block.header.hash),)) + .await + .unwrap(); + let missing: Vec = provider + .client() + .request("debug_getRawTransactions", (BlockId::number(999),)) + .await + .unwrap(); + + assert_eq!(raw_by_number, raw_by_hash); + assert_eq!(raw_by_number.len(), 2); + assert!(missing.is_empty()); + + let first_raw = api.raw_transaction(first_hash).await.unwrap().unwrap(); + let second_raw = api.raw_transaction(second_hash).await.unwrap().unwrap(); + assert!(raw_by_number.contains(&first_raw)); + assert!(raw_by_number.contains(&second_raw)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn can_get_raw_header() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + let from = handle.dev_wallets().next().unwrap().address(); + let tx = TransactionRequest::default().from(from).value(U256::ONE).to(Address::random()); + provider.send_transaction(WithOtherFields::new(tx)).await.unwrap().get_receipt().await.unwrap(); + + let block = provider.get_block(BlockId::number(1)).await.unwrap().unwrap(); + let raw_by_number: Bytes = + provider.client().request("debug_getRawHeader", (BlockId::number(1),)).await.unwrap(); + let raw_by_hash: Bytes = provider + .client() + .request("debug_getRawHeader", (BlockId::hash(block.header.hash),)) + .await + .unwrap(); + + assert_eq!(raw_by_number, raw_by_hash); + assert!(!raw_by_number.is_empty()); + + let decoded = Header::decode(&mut raw_by_number.as_ref()).unwrap(); + assert_eq!(decoded.number, 1); + assert_eq!(decoded.hash_slow(), block.header.hash); +} + +#[tokio::test(flavor = "multi_thread")] +async fn can_get_raw_block() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + let from = handle.dev_wallets().next().unwrap().address(); + let tx = TransactionRequest::default().from(from).value(U256::ONE).to(Address::random()); + provider.send_transaction(WithOtherFields::new(tx)).await.unwrap().get_receipt().await.unwrap(); + + let block = provider.get_block(BlockId::number(1)).await.unwrap().unwrap(); + let raw_by_number: Bytes = + provider.client().request("debug_getRawBlock", (BlockId::number(1),)).await.unwrap(); + let raw_by_hash: Bytes = provider + .client() + .request("debug_getRawBlock", (BlockId::hash(block.header.hash),)) + .await + .unwrap(); + + assert_eq!(raw_by_number, raw_by_hash); + assert!(!raw_by_number.is_empty()); + + let decoded: Block = Block::decode(&mut raw_by_number.as_ref()).unwrap(); + assert_eq!(decoded.header.hash_slow(), block.header.hash); + assert_eq!(decoded.header.number, 1); + assert_eq!(decoded.body.transactions.len(), 1); +} + #[tokio::test(flavor = "multi_thread")] async fn test_first_nonce_is_zero() { let (api, handle) = spawn(NodeConfig::test()).await; @@ -832,11 +1159,73 @@ async fn includes_pending_tx_for_transaction_count() { assert_eq!(nonce, idx); } - api.mine_one().await; + api.mine_one().await.unwrap(); let nonce = provider.get_transaction_count(from).block_id(BlockId::pending()).await.unwrap(); assert_eq!(nonce, tx_count); } +// +#[tokio::test(flavor = "multi_thread")] +async fn concurrent_unlocked_transactions_assign_unique_pending_nonces() { + assert_concurrent_unlocked_transactions(false).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn concurrent_unlocked_transactions_assign_unique_nonces_with_automining() { + assert_concurrent_unlocked_transactions(true).await; +} + +async fn assert_concurrent_unlocked_transactions(automine: bool) { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + let from = handle.dev_wallets().next().unwrap().address(); + let to = Address::random(); + + // Start from a mined nonce to exercise both chain state and the pending pool. + let initial = WithOtherFields::new(TransactionRequest::default().from(from).to(to)); + provider.send_transaction(initial).await.unwrap().get_receipt().await.unwrap(); + api.anvil_set_auto_mine(automine).await.unwrap(); + + let tx_count = 32u64; + let requests = (1..=tx_count).map(|value| { + let request = TransactionRequest::default().from(from).to(to).value(U256::from(value)); + // Send raw RPC requests so client nonce fillers cannot hide server-side races. + provider.raw_request::<_, TxHash>("eth_sendTransaction".into(), (request,)) + }); + let hashes = timeout(Duration::from_secs(30), join_all(requests)).await.unwrap(); + let hashes = hashes.into_iter().collect::, _>>().unwrap(); + assert_eq!(hashes.iter().copied().collect::().len(), tx_count as usize); + + let mut nonces = Vec::new(); + for hash in &hashes { + let tx = provider.get_transaction_by_hash(*hash).await.unwrap().unwrap(); + assert_eq!(tx.from(), from); + nonces.push(tx.nonce()); + } + nonces.sort_unstable(); + assert_eq!(nonces, (1..=tx_count).collect::>()); + + if !automine { + assert_eq!(provider.get_transaction_count(from).await.unwrap(), 1); + assert_eq!( + provider.get_transaction_count(from).block_id(BlockId::pending()).await.unwrap(), + tx_count + 1 + ); + api.mine_one().await.unwrap(); + } + + for hash in hashes { + let receipt = PendingTransactionBuilder::new(provider.root().clone(), hash) + .with_timeout(Some(Duration::from_secs(30))) + .get_receipt() + .await + .unwrap(); + assert!(receipt.status()); + } + assert_eq!(provider.get_transaction_count(from).await.unwrap(), tx_count + 1); + assert_eq!(provider.get_balance(to).await.unwrap(), U256::from(tx_count * (tx_count + 1) / 2)); +} + #[tokio::test(flavor = "multi_thread")] async fn can_get_historic_info() { let (_api, handle) = spawn(NodeConfig::test()).await; @@ -852,14 +1241,13 @@ async fn can_get_historic_info() { let tx = provider.send_transaction(tx).await.unwrap(); let _ = tx.get_receipt().await.unwrap(); - let nonce_pre = - provider.get_transaction_count(from).block_id(BlockId::number(0)).await.unwrap(); + let nonce_pre = provider.get_transaction_count(from).number(0).await.unwrap(); let nonce_post = provider.get_transaction_count(from).await.unwrap(); assert!(nonce_pre < nonce_post); - let balance_pre = provider.get_balance(from).block_id(BlockId::number(0)).await.unwrap(); + let balance_pre = provider.get_balance(from).number(0).await.unwrap(); let balance_post = provider.get_balance(from).await.unwrap(); @@ -898,6 +1286,42 @@ async fn test_tx_receipt() { assert!(tx.contract_address.is_some()); } +// +#[tokio::test(flavor = "multi_thread")] +async fn test_reverted_contract_creation_has_contract_address() { + let (_api, handle) = spawn(NodeConfig::test()).await; + + let provider = handle.http_provider(); + let wallet = handle.dev_wallets().next().unwrap(); + + // Init code that immediately reverts: PUSH1 0x00 PUSH1 0x00 REVERT (0x60006000fd) + let reverting_init_code = hex!("60006000fd"); + + let tx = TransactionRequest::default() + .from(wallet.address()) + .with_input(reverting_init_code.to_vec()); + + let tx = WithOtherFields::new(tx); + let receipt = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + + // Transaction should have reverted + assert!(!receipt.status()); + + // `to` field should be none (contract creation) + assert!(receipt.to.is_none()); + + // `contractAddress` should still be set even though the transaction reverted + // This matches geth's behavior: https://github.com/ethereum/go-ethereum/issues/27937 + assert!( + receipt.contract_address.is_some(), + "contractAddress should be set for reverted contract creation" + ); + + // Verify the computed address is correct (sender.create(nonce)) + let expected_addr = wallet.address().create(0); + assert_eq!(receipt.contract_address, Some(expected_addr)); +} + #[tokio::test(flavor = "multi_thread")] async fn can_stream_pending_transactions() { let (_api, handle) = @@ -1013,6 +1437,8 @@ async fn test_tx_access_list() { let multicall = Multicall::deploy(provider.clone()).await.unwrap(); let simple_storage = SimpleStorage::deploy(provider.clone(), "foo".to_string()).await.unwrap(); + // Mirrors execution-apis `create-al-contract.io`: + // https://github.com/ethereum/execution-apis/blob/8d6b784cc57047ef10e4044ec2efc1c60950dd7f/tests/eth_createAccessList/create-al-contract.io // when calling `setValue` on SimpleStorage, both the `lastSender` and `_value` storages are // modified The `_value` is a `string`, so the storage slots here (small string) are `0x1` // and `keccak(0x1)` @@ -1024,8 +1450,6 @@ async fn test_tx_access_list() { .with_input(set_value_calldata.to_owned()); let set_value_tx = WithOtherFields::new(set_value_tx); let access_list = provider.create_access_list(&set_value_tx).await.unwrap(); - // let set_value_tx = simple_storage.set_value("bar".to_string()).from(sender).tx; - // let access_list = client.create_access_list(&set_value_tx, None).await.unwrap(); assert_access_list_eq( access_list.access_list, AccessList::from(vec![AccessListItem { @@ -1033,10 +1457,7 @@ async fn test_tx_access_list() { storage_keys: vec![ FixedBytes::ZERO, FixedBytes::with_last_byte(1), - FixedBytes::from_str( - "0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6", - ) - .unwrap(), + b256!("0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6"), ], }]), ); @@ -1073,19 +1494,83 @@ async fn test_tx_access_list() { let access_list = provider.create_access_list(&subcall_tx).await.unwrap(); assert_access_list_eq( access_list.access_list, - // H256::from_uint(&(1u64.into())), AccessList::from(vec![AccessListItem { address: *simple_storage.address(), storage_keys: vec![ FixedBytes::ZERO, FixedBytes::with_last_byte(1), - FixedBytes::from_str( - "0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6", - ) - .unwrap(), + b256!("0xb10e2d527612073b26eecdfd717e6a320cf44b4afac2b0732d9fcbe2b7fa0cf6"), ], }]), ); + + // Mirrors execution-apis `create-al-abi-revert.io`: + // https://github.com/ethereum/execution-apis/blob/8d6b784cc57047ef10e4044ec2efc1c60950dd7f/tests/eth_createAccessList/create-al-abi-revert.io + // eth_createAccessList should return a successful RPC result for reverted execution, + // preserving the generated access list and reporting the revert in the result object. + // + // Runtime: + // 60 00 PUSH1 0x00 ; calldata offset + // 35 CALLDATALOAD ; read the 32-byte storage slot from calldata + // 54 SLOAD ; access that slot so it appears in the access list + // 60 00 PUSH1 0x00 ; revert data offset + // 60 00 PUSH1 0x00 ; revert data length + // fd REVERT ; fail after the storage read + let reverter_initcode = bytes!("6009600c60003960096000f36000355460006000fd"); + let funded_sender = handle.dev_accounts().next().unwrap(); + let deploy_reverter_tx = + TransactionRequest::default().from(funded_sender).with_deploy_code(reverter_initcode); + let reverter_receipt = provider + .send_transaction(WithOtherFields::new(deploy_reverter_tx)) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + let reverter = reverter_receipt.contract_address.unwrap(); + + let slot = b256!("0x00000000000000000000000000000000000000000000000000000000000042ff"); + let reverter_call_tx = TransactionRequest::default() + .from(funded_sender) + .to(reverter) + .input(Bytes::from(slot).into()); + let reverter_call_tx = WithOtherFields::new(reverter_call_tx); + let access_list = provider.create_access_list(&reverter_call_tx).await.unwrap(); + + assert_eq!(access_list.error.as_deref(), Some("execution reverted")); + assert!(access_list.gas_used > U256::ZERO); + assert_access_list_eq( + access_list.access_list, + AccessList::from(vec![AccessListItem { address: reverter, storage_keys: vec![slot] }]), + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn can_create_access_list_with_state_override() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + let sender = Address::random(); + let target = Address::random(); + let tx = TransactionRequest::default().from(sender).to(target); + let tx = WithOtherFields::new(tx); + + // PUSH1 0; SLOAD; STOP. + let code = bytes!("60005400"); + let state_override = StateOverridesBuilder::default() + .append(target, AccountOverride::default().with_code(code.to_vec())) + .build(); + + let access_list: AccessListResult = provider + .client() + .request("eth_createAccessList", (tx, None::, state_override)) + .await + .unwrap(); + + assert_eq!(access_list.access_list.0.len(), 1); + let item = access_list.access_list.0.first().unwrap(); + assert_eq!(item.address, target); + assert_eq!(item.storage_keys, vec![FixedBytes::ZERO]); } // ensures that the gas estimate is running on pending block by default @@ -1164,10 +1649,8 @@ async fn test_block_override() { let sender = wallet.address(); let recipient = Address::random(); - let tx = TransactionRequest::default() - .from(sender) - .to(recipient) - .input(Bytes::from(hex!("42cbb15c").to_vec()).into()); + let tx = + TransactionRequest::default().from(sender).to(recipient).input(bytes!("42cbb15c").into()); // function getBlockNumber() external view returns (uint256) { // return block.number; @@ -1278,7 +1761,7 @@ async fn can_mine_multiple_in_block() { api.anvil_set_auto_mine(false).await.unwrap(); let tx = TransactionRequest { - from: Some("0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266".parse().unwrap()), + from: Some(address!("0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266")), ..Default::default() }; @@ -1286,7 +1769,7 @@ async fn can_mine_multiple_in_block() { let first = api.send_transaction(WithOtherFields::new(tx.clone())).await.unwrap(); let second = api.send_transaction(WithOtherFields::new(tx.clone())).await.unwrap(); - api.anvil_mine(Some(U256::from(1)), Some(U256::ZERO)).await.unwrap(); + api.anvil_mine(Some(U256::ONE), Some(U256::ZERO)).await.unwrap(); let block = api.block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); @@ -1296,7 +1779,7 @@ async fn can_mine_multiple_in_block() { // ensures that the gas estimate is running on pending block by default #[tokio::test(flavor = "multi_thread")] -async fn estimates_gas_prague() { +async fn can_estimate_gas_prague() { let (api, _handle) = spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Prague.into()))).await; @@ -1308,3 +1791,687 @@ async fn estimates_gas_prague() { .with_to(address!("0x70997970c51812dc3a010c7d01b50e0d17dc79c8")); api.estimate_gas(WithOtherFields::new(req), None, EvmOverrides::default()).await.unwrap(); } + +#[tokio::test(flavor = "multi_thread")] +async fn can_send_tx_osaka_valid_with_limit_enabled() { + let (_api, handle) = spawn( + NodeConfig::test() + .enable_tx_gas_limit(true) + .with_hardfork(Some(EthereumHardfork::Osaka.into())), + ) + .await; + let provider = handle.http_provider(); + let wallet = handle.dev_wallets().next().unwrap(); + let sender = wallet.address(); + let recipient = Address::random(); + + let base_tx = TransactionRequest::default().from(sender).to(recipient).value(U256::from(1e18)); + + // gas limit below the cap is accepted + let tx = base_tx.clone().gas_limit(TX_GAS_LIMIT_CAP - 1); + let tx = WithOtherFields::new(tx); + let pending_tx = provider.send_transaction(tx).await.unwrap(); + let tx_receipt = pending_tx.get_receipt().await.unwrap(); + assert!(tx_receipt.inner.inner.is_success()); + + // gas limit at the cap is accepted + let tx = base_tx.clone().gas_limit(TX_GAS_LIMIT_CAP); + let tx = WithOtherFields::new(tx); + let pending_tx = provider.send_transaction(tx).await.unwrap(); + let tx_receipt = pending_tx.get_receipt().await.unwrap(); + assert!(tx_receipt.inner.inner.is_success()); + + // gas limit above the cap is rejected + let tx = base_tx.clone().gas_limit(TX_GAS_LIMIT_CAP + 1); + let tx = WithOtherFields::new(tx); + let err = provider.send_transaction(tx).await.unwrap_err().to_string(); + assert!( + err.contains("intrinsic gas too high -- tx.gas_limit > resolved tx gas limit cap"), + "{err}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn can_send_tx_osaka_valid_with_limit_disabled() { + let (_api, handle) = + spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Osaka.into()))).await; + let provider = handle.http_provider(); + let wallet = handle.dev_wallets().next().unwrap(); + let sender = wallet.address(); + let recipient = Address::random(); + + let base_tx = TransactionRequest::default().from(sender).to(recipient).value(U256::from(1e18)); + + // gas limit below the cap is accepted + let tx = base_tx.clone().gas_limit(TX_GAS_LIMIT_CAP - 1); + let tx = WithOtherFields::new(tx); + let pending_tx = provider.send_transaction(tx).await.unwrap(); + let tx_receipt = pending_tx.get_receipt().await.unwrap(); + assert!(tx_receipt.inner.inner.is_success()); + + // gas limit at the cap is accepted + let tx = base_tx.clone().gas_limit(TX_GAS_LIMIT_CAP); + let tx = WithOtherFields::new(tx); + let pending_tx = provider.send_transaction(tx).await.unwrap(); + let tx_receipt = pending_tx.get_receipt().await.unwrap(); + assert!(tx_receipt.inner.inner.is_success()); + + // gas limit above the cap is accepted when the limit is disabled + let tx = base_tx.clone().gas_limit(TX_GAS_LIMIT_CAP + 1); + let tx = WithOtherFields::new(tx); + let pending_tx = provider.send_transaction(tx).await.unwrap(); + let tx_receipt = pending_tx.get_receipt().await.unwrap(); + assert!(tx_receipt.inner.inner.is_success()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn can_get_tx_by_sender_and_nonce() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + let accounts = handle.dev_wallets().collect::>(); + let sender = accounts[0].address(); + let recipient = accounts[1].address(); + + api.anvil_set_auto_mine(false).await.unwrap(); + + let mut tx_hashes = std::collections::BTreeMap::new(); + + // send 4 transactions from the same sender with consecutive nonces + for i in 0..4 { + let tx_request = TransactionRequest::default() + .to(recipient) + .value(U256::from(1000 + i)) + .from(sender) + .nonce(i as u64); + + let tx = WithOtherFields::new(tx_request); + let pending_tx = provider.send_transaction(tx).await.unwrap(); + tx_hashes.insert(i as u64, *pending_tx.tx_hash()); + } + + // mine all transactions + api.mine_one().await.unwrap(); + + for nonce in 0..4 { + let result: Option = provider + .client() + .request("eth_getTransactionBySenderAndNonce", (sender, U256::from(nonce))) + .await + .unwrap(); + + assert!(result.is_some()); + let found_tx = result.unwrap(); + + assert_eq!(found_tx.inner.nonce(), nonce); + assert_eq!(found_tx.from(), sender); + assert_eq!(found_tx.inner.to(), Some(recipient)); + assert_eq!(found_tx.inner.value(), U256::from(1000 + nonce)); + assert_eq!(found_tx.inner.tx_hash(), tx_hashes[&nonce]); + } + + let result: Option = provider + .client() + .request("eth_getTransactionBySenderAndNonce", (sender, U256::from(999))) + .await + .unwrap(); + assert!(result.is_none()); + + let different_sender = accounts[2].address(); + let result: Option = provider + .client() + .request("eth_getTransactionBySenderAndNonce", (different_sender, U256::ZERO)) + .await + .unwrap(); + assert!(result.is_none()); + + // send a pending transaction with explicit nonce 4 + let pending_tx_request = + TransactionRequest::default().to(recipient).value(U256::from(5000)).from(sender).nonce(4); + + let tx = WithOtherFields::new(pending_tx_request); + let pending_tx = provider.send_transaction(tx).await.unwrap(); + + // find the pending transaction with nonce 4 + let result: Option = provider + .client() + .request("eth_getTransactionBySenderAndNonce", (sender, U256::from(4))) + .await + .unwrap(); + + assert!(result.is_some()); + let found_tx = result.unwrap(); + assert_eq!(found_tx.inner.nonce(), 4); + assert_eq!(found_tx.inner.tx_hash(), *pending_tx.tx_hash()); + + api.mine_one().await.unwrap(); + + let result: Option = provider + .client() + .request("eth_getTransactionBySenderAndNonce", (sender, U256::from(4))) + .await + .unwrap(); + + assert!(result.is_some()); + let found_tx = result.unwrap(); + assert_eq!(found_tx.inner.nonce(), 4); +} + +// Instant-mine coalescing regression tests. + +/// Polls `eth_getTransactionReceipt` until a block number is available. +async fn poll_receipt_block(client: &reqwest::Client, endpoint: &str, tx_hash: &str) -> u64 { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let payload = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "eth_getTransactionReceipt", + "params": [tx_hash], + }); + let resp: serde_json::Value = + client.post(endpoint).json(&payload).send().await.unwrap().json().await.unwrap(); + if let Some(receipt) = resp.get("result").and_then(|r| r.as_object()) + && let Some(block_hex) = receipt.get("blockNumber").and_then(|b| b.as_str()) + { + return u64::from_str_radix(block_hex.trim_start_matches("0x"), 16).unwrap(); + } + assert!(Instant::now() < deadline, "timed out waiting for receipt of {tx_hash}"); + tokio::time::sleep(Duration::from_millis(25)).await; + } +} + +/// Txs in one JSON-RPC batch POST must share a block. +#[tokio::test(flavor = "multi_thread")] +async fn instant_mine_groups_json_rpc_batch_in_one_block() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let endpoint = handle.http_endpoint(); + let accounts = handle.dev_wallets().collect::>(); + let from1 = accounts[1].address(); + let from2 = accounts[2].address(); + let to = accounts[0].address(); + + let batch = serde_json::json!([ + { + "jsonrpc": "2.0", + "id": 1, + "method": "eth_sendTransaction", + "params": [{"from": from1, "to": to, "value": "0x1"}] + }, + { + "jsonrpc": "2.0", + "id": 2, + "method": "eth_sendTransaction", + "params": [{"from": from2, "to": to, "value": "0x1"}] + } + ]); + + let client = reqwest::Client::new(); + let resp: serde_json::Value = + client.post(&endpoint).json(&batch).send().await.unwrap().json().await.unwrap(); + + let arr = resp.as_array().expect("expected JSON-RPC batch response"); + assert_eq!(arr.len(), 2); + let hashes: Vec = arr + .iter() + .map(|r| r.get("result").expect("expected result").as_str().unwrap().to_owned()) + .collect(); + + let mut blocks = Vec::new(); + for hash in &hashes { + let block = poll_receipt_block(&client, &endpoint, hash).await; + blocks.push(block); + } + + assert_eq!( + blocks[0], blocks[1], + "batched eth_sendTransaction txs landed in different blocks ({blocks:?})" + ); +} + +/// Txs from in-process parallel HTTP requests must share a block. +#[tokio::test(flavor = "multi_thread")] +async fn instant_mine_groups_in_process_parallel_http_in_one_block() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let endpoint = handle.http_endpoint(); + let accounts = handle.dev_wallets().collect::>(); + let from1 = accounts[1].address(); + let from2 = accounts[2].address(); + let to = accounts[0].address(); + + let client = reqwest::Client::new(); + + let send = |from: Address, id: u64| { + let client = client.clone(); + let endpoint = endpoint.clone(); + async move { + let payload = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "method": "eth_sendTransaction", + "params": [{"from": from, "to": to, "value": "0x1"}], + }); + let resp: serde_json::Value = + client.post(&endpoint).json(&payload).send().await.unwrap().json().await.unwrap(); + resp.get("result").unwrap().as_str().unwrap().to_owned() + } + }; + + let (h1, h2) = tokio::join!(send(from1, 1), send(from2, 2)); + + let b1 = poll_receipt_block(&client, &endpoint, &h1).await; + let b2 = poll_receipt_block(&client, &endpoint, &h2).await; + + assert_eq!(b1, b2, "parallel-HTTP txs landed in different blocks (b1={b1}, b2={b2})"); +} + +/// Sends spaced beyond the coalescing window must still mine in separate blocks. +#[tokio::test(flavor = "multi_thread")] +async fn instant_mine_does_not_group_sequential_sends_beyond_window() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let endpoint = handle.http_endpoint(); + let accounts = handle.dev_wallets().collect::>(); + let from1 = accounts[1].address(); + let from2 = accounts[2].address(); + let to = accounts[0].address(); + let client = reqwest::Client::new(); + + let send = |from: Address, id: u64| { + let client = client.clone(); + let endpoint = endpoint.clone(); + async move { + let payload = serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "method": "eth_sendTransaction", + "params": [{"from": from, "to": to, "value": "0x1"}], + }); + let resp: serde_json::Value = + client.post(&endpoint).json(&payload).send().await.unwrap().json().await.unwrap(); + resp.get("result").unwrap().as_str().unwrap().to_owned() + } + }; + + let h1 = send(from1, 1).await; + let b1 = poll_receipt_block(&client, &endpoint, &h1).await; + tokio::time::sleep(Duration::from_millis(50)).await; + let h2 = send(from2, 2).await; + let b2 = poll_receipt_block(&client, &endpoint, &h2).await; + + assert_ne!(b1, b2, "sequential sends with delay coalesced into the same block ({b1})"); +} + +/// Ready txs skipped because the block ran out of gas must be mined in follow-up blocks. +#[tokio::test(flavor = "multi_thread")] +async fn instant_mine_retries_txs_skipped_by_block_gas_limit() { + // Room for exactly three transfers per block. + let (api, handle) = spawn(NodeConfig::test().with_gas_limit(Some(63_000))).await; + let provider = handle.http_provider(); + let accounts = handle.dev_wallets().collect::>(); + let from = accounts[0].address(); + let to = accounts[1].address(); + + api.anvil_set_auto_mine(false).await.unwrap(); + + let mut pending = Vec::new(); + for nonce in 0..5 { + let tx = TransactionRequest::default() + .from(from) + .to(to) + .value(U256::ONE) + .nonce(nonce) + .gas_limit(21_000); + pending.push(provider.send_transaction(WithOtherFields::new(tx)).await.unwrap()); + } + + // The new instant miner selects all five ready txs, but only three fit in the first block. + api.anvil_set_auto_mine(true).await.unwrap(); + + for tx in pending { + timeout(Duration::from_secs(5), tx.get_receipt()) + .await + .expect("tx skipped by the block gas limit was never mined") + .unwrap(); + } + assert_eq!(provider.get_transaction_count(from).await.unwrap(), 5); + assert_eq!(api.txpool_status().await.unwrap().pending, 0); +} + +/// Builds a plain value transfer with an explicit nonce and gas limit. +fn transfer( + from: Address, + to: Address, + nonce: u64, + gas_limit: u64, +) -> WithOtherFields { + let tx = TransactionRequest::default() + .from(from) + .to(to) + .value(U256::ONE) + .nonce(nonce) + .gas_limit(gas_limit); + WithOtherFields::new(tx) +} + +/// Waits for every transaction to be mined and returns the block number of each receipt. +async fn mined_blocks(pending: Vec>) -> Vec { + let mut blocks = Vec::with_capacity(pending.len()); + for tx in pending { + let receipt = timeout(Duration::from_secs(5), tx.get_receipt()) + .await + .expect("ready tx left behind by a full block was never mined") + .unwrap(); + blocks.push(receipt.block_number().unwrap()); + } + blocks +} + +/// Returns the largest number of the given transactions that were mined in the same block. +fn max_txs_per_block(blocks: &[u64]) -> usize { + let mut blocks = blocks.to_vec(); + blocks.sort_unstable(); + blocks.chunk_by(|a, b| a == b).map(<[u64]>::len).max().unwrap_or_default() +} + +/// A concurrent burst spanning several blocks must be drained by chained retries, both in auto +/// and in mixed mining mode. +#[tokio::test(flavor = "multi_thread")] +async fn instant_mine_drains_concurrent_burst_across_blocks() { + // The mixed mode interval never fires within the test, so only the instant miner can mine. + for config in [ + NodeConfig::test(), + NodeConfig::test().with_mixed_mining(true, Some(Duration::from_secs(60))), + ] { + // Room for exactly three transfers per block. + let (api, handle) = spawn(config.with_gas_limit(Some(63_000))).await; + let provider = handle.http_provider(); + let accounts = handle.dev_wallets().map(|wallet| wallet.address()).collect::>(); + let senders = &accounts[..3]; + let to = accounts[3]; + + let mut txs = Vec::new(); + for from in senders { + for nonce in 0..4 { + txs.push(transfer(*from, to, nonce, 21_000)); + } + } + let pending = join_all(txs.into_iter().map(|tx| provider.send_transaction(tx))) + .await + .into_iter() + .map(Result::unwrap) + .collect(); + let blocks = mined_blocks(pending).await; + + assert!(max_txs_per_block(&blocks) <= 3, "block exceeded the gas limit: {blocks:?}"); + for sender_blocks in blocks.chunks(4) { + assert!(sender_blocks.is_sorted(), "nonces mined out of order: {sender_blocks:?}"); + } + for from in senders { + assert_eq!(provider.get_transaction_count(*from).await.unwrap(), 4); + } + assert_eq!(api.txpool_status().await.unwrap().pending, 0); + } +} + +/// Successors of a tx skipped for gas must stay in the pool and be mined right after it, even +/// though they would have fit into the block that skipped their predecessor. +#[tokio::test(flavor = "multi_thread")] +async fn instant_mine_keeps_successors_of_tx_skipped_by_block_gas_limit() { + let (api, handle) = spawn(NodeConfig::test().with_gas_limit(Some(100_000))).await; + let provider = handle.http_provider(); + let accounts = handle.dev_wallets().collect::>(); + let from = accounts[0].address(); + let to = accounts[1].address(); + + api.anvil_set_auto_mine(false).await.unwrap(); + + // Nonce 1 does not fit after nonce 0, its smaller successors would. + let mut pending = Vec::new(); + for (nonce, gas_limit) in [21_000, 90_000, 21_000, 21_000].into_iter().enumerate() { + let tx = transfer(from, to, nonce as u64, gas_limit); + pending.push(provider.send_transaction(tx).await.unwrap()); + } + + api.anvil_set_auto_mine(true).await.unwrap(); + + let blocks = mined_blocks(pending).await; + let first = blocks[0]; + assert_eq!(blocks, [first, first + 1, first + 1, first + 1]); + assert_eq!(api.txpool_status().await.unwrap().pending, 0); +} + +/// A leftover tx that can never fit must not keep the instant miner producing blocks. +#[tokio::test(flavor = "multi_thread")] +async fn instant_mine_stops_retrying_tx_exceeding_block_gas_limit() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + let accounts = handle.dev_wallets().map(|wallet| wallet.address()).collect::>(); + let to = accounts[2]; + + api.anvil_set_auto_mine(false).await.unwrap(); + let stuck = provider.send_transaction(transfer(accounts[0], to, 0, 100_000)).await.unwrap(); + // Lowering the limit below the pooled tx keeps it ready but impossible to include. + api.evm_set_block_gas_limit(U256::from(50_000)).unwrap(); + api.anvil_set_auto_mine(true).await.unwrap(); + + // Every block that includes another tx makes progress and leaves the stuck tx behind. + for nonce in 0..3 { + let tx = provider.send_transaction(transfer(accounts[1], to, nonce, 21_000)).await.unwrap(); + mined_blocks(vec![tx]).await; + } + + let settled = timeout(Duration::from_secs(5), async { + let mut last = provider.get_block_number().await.unwrap(); + loop { + tokio::time::sleep(Duration::from_millis(250)).await; + let current = provider.get_block_number().await.unwrap(); + if current == last { + break current; + } + last = current; + } + }) + .await + .expect("miner keeps producing blocks for a tx that cannot fit"); + + // The initial selection plus at most one retry per block that made progress. + assert!(settled <= 7, "mined {settled} blocks for three includable txs"); + assert!(provider.get_transaction_receipt(*stuck.tx_hash()).await.unwrap().is_none()); + assert_eq!(api.txpool_status().await.unwrap().pending, 1); +} + +/// Manually mining a block that leaves ready txs behind must hand them back to the instant miner. +#[tokio::test(flavor = "multi_thread")] +async fn manual_mine_resumes_instant_mining_of_leftover_txs() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + let accounts = handle.dev_wallets().collect::>(); + let from = accounts[0].address(); + let to = accounts[1].address(); + + api.anvil_set_auto_mine(false).await.unwrap(); + + let mut pending = Vec::new(); + for nonce in 0..5 { + pending.push(provider.send_transaction(transfer(from, to, nonce, 21_000)).await.unwrap()); + } + + // Nothing fits, so the new instant miner selects the txs once, makes no progress and idles. + api.evm_set_block_gas_limit(U256::from(20_000)).unwrap(); + api.anvil_set_auto_mine(true).await.unwrap(); + tokio::time::sleep(Duration::from_millis(300)).await; + assert_eq!(api.txpool_status().await.unwrap().pending, 5); + + // Room for three transfers: the manual block takes three, the instant miner the rest. + api.evm_set_block_gas_limit(U256::from(63_000)).unwrap(); + api.evm_mine(None).await.unwrap(); + + mined_blocks(pending).await; + assert_eq!(provider.get_transaction_count(from).await.unwrap(), 5); + assert_eq!(api.txpool_status().await.unwrap().pending, 0); +} + +/// A backlog larger than `max_transactions` must be drained in consecutive capped blocks. +#[tokio::test(flavor = "multi_thread")] +async fn instant_mine_drains_backlog_larger_than_max_transactions() { + let (api, handle) = spawn(NodeConfig::test().with_max_transactions(Some(2))).await; + let provider = handle.http_provider(); + let accounts = handle.dev_wallets().collect::>(); + let from = accounts[0].address(); + let to = accounts[1].address(); + + let sends = (0..7).map(|nonce| provider.send_transaction(transfer(from, to, nonce, 21_000))); + let pending = join_all(sends).await.into_iter().map(Result::unwrap).collect(); + let blocks = mined_blocks(pending).await; + + assert!(max_txs_per_block(&blocks) <= 2, "block exceeded max_transactions: {blocks:?}"); + assert_eq!(provider.get_transaction_count(from).await.unwrap(), 7); + assert_eq!(api.txpool_status().await.unwrap().pending, 0); +} + +/// A sync send whose tx is deferred to a follow-up block must still return its receipt. +#[tokio::test(flavor = "multi_thread")] +async fn tx_sync_returns_receipt_for_tx_skipped_by_block_gas_limit() { + // Room for exactly three transfers per block. + let (api, handle) = spawn(NodeConfig::test().with_gas_limit(Some(63_000))).await; + let accounts = handle.dev_wallets().collect::>(); + let from = accounts[0].address(); + let to = accounts[1].address(); + + api.anvil_set_auto_mine(false).await.unwrap(); + + let sends = (0..5).map(|nonce| api.send_transaction_sync(transfer(from, to, nonce, 21_000))); + let enable_mining = async { + // All five sync sends must be waiting before the instant miner selects them. + while api.txpool_status().await.unwrap().pending < 5 { + tokio::time::sleep(Duration::from_millis(10)).await; + } + api.anvil_set_auto_mine(true).await.unwrap(); + }; + let (receipts, ()) = + timeout(Duration::from_secs(5), async { tokio::join!(join_all(sends), enable_mining) }) + .await + .expect("sync send of a tx skipped by the block gas limit never returned"); + + let blocks = receipts + .into_iter() + .map(|receipt| receipt.unwrap().block_number().unwrap()) + .collect::>(); + let first = blocks[0]; + assert_eq!(blocks, [first, first, first, first + 1, first + 1]); +} + +/// A backlog larger than the default per-block transaction cap must be drained in capped blocks. +#[tokio::test(flavor = "multi_thread")] +async fn instant_mine_drains_backlog_larger_than_default_block_cap() { + const TXS_PER_SENDER: u64 = 250; + + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + let senders = handle.dev_wallets().map(|wallet| wallet.address()).collect::>(); + let to = Address::random(); + + api.anvil_set_auto_mine(false).await.unwrap(); + + // The fee cap leaves room for the base fee to rise while the backlog fills several blocks. + let sends = senders.iter().map(|from| { + let api = &api; + async move { + for nonce in 0..TXS_PER_SENDER { + let tx = transfer(*from, to, nonce, 21_000) + .with_max_fee_per_gas(100_000_000_000) + .with_max_priority_fee_per_gas(1); + api.send_transaction(tx).await.unwrap(); + } + } + }); + join_all(sends).await; + + api.anvil_set_auto_mine(true).await.unwrap(); + + timeout(Duration::from_secs(30), async { + for from in &senders { + while provider.get_transaction_count(*from).await.unwrap() < TXS_PER_SENDER { + tokio::time::sleep(Duration::from_millis(50)).await; + } + } + }) + .await + .expect("backlog was not mined completely"); + + let mut txs_per_block = Vec::new(); + for number in 1..=provider.get_block_number().await.unwrap() { + let block = provider.get_block_by_number(number.into()).await.unwrap().unwrap(); + txs_per_block.push(block.transactions.len()); + } + assert_eq!(txs_per_block, [1_000, 1_000, 500]); + assert_eq!(api.txpool_status().await.unwrap().pending, 0); +} + +/// Txs left behind by a full block must also be retried when state is fetched from a fork. +#[tokio::test(flavor = "multi_thread")] +async fn instant_mine_drains_concurrent_burst_on_local_fork() { + let (_origin_api, origin_handle) = spawn(NodeConfig::test()).await; + // Room for exactly three transfers per block. + let fork_config = NodeConfig::test() + .with_eth_rpc_url(Some(origin_handle.http_endpoint())) + .with_gas_limit(Some(63_000)); + let (api, handle) = spawn(fork_config).await; + let provider = handle.http_provider(); + let accounts = handle.dev_wallets().map(|wallet| wallet.address()).collect::>(); + let senders = &accounts[..3]; + let to = Address::random(); + + let mut txs = Vec::new(); + for from in senders { + for nonce in 0..4 { + txs.push(transfer(*from, to, nonce, 21_000)); + } + } + let pending = join_all(txs.into_iter().map(|tx| provider.send_transaction(tx))) + .await + .into_iter() + .map(Result::unwrap) + .collect(); + let blocks = mined_blocks(pending).await; + + assert!(max_txs_per_block(&blocks) <= 3, "block exceeded the gas limit: {blocks:?}"); + for from in senders { + assert_eq!(provider.get_transaction_count(*from).await.unwrap(), 4); + } + assert_eq!(api.txpool_status().await.unwrap().pending, 0); +} + +/// A configured window applies both at startup and after automining is re-enabled. +#[tokio::test(flavor = "multi_thread")] +async fn instant_mine_configured_coalescing_window_survives_toggle() { + for window in [Duration::ZERO, Duration::from_secs(60)] { + let (api, handle) = + spawn(NodeConfig::test().with_transaction_coalescing_window(window)).await; + let provider = handle.http_provider(); + let accounts = handle.dev_wallets().collect::>(); + for toggle in [false, true] { + if toggle { + api.anvil_set_auto_mine(false).await.unwrap(); + api.anvil_set_auto_mine(true).await.unwrap(); + } + let tx = TransactionRequest::default() + .from(accounts[0].address()) + .to(accounts[1].address()) + .value(U256::ONE); + let pending = provider.send_transaction(WithOtherFields::new(tx)).await.unwrap(); + if window.is_zero() { + timeout(Duration::from_secs(5), pending.get_receipt()).await.unwrap().unwrap(); + } else { + tokio::time::sleep(Duration::from_millis(20)).await; + assert!( + provider.get_transaction_receipt(*pending.tx_hash()).await.unwrap().is_none() + ); + api.mine_one().await.unwrap(); + assert!( + provider.get_transaction_receipt(*pending.tx_hash()).await.unwrap().is_some() + ); + } + } + } +} diff --git a/crates/anvil/tests/it/txpool.rs b/crates/anvil/tests/it/txpool.rs index 28f89451ed4b4..c6f5c9f129a59 100644 --- a/crates/anvil/tests/it/txpool.rs +++ b/crates/anvil/tests/it/txpool.rs @@ -1,11 +1,14 @@ //! txpool related tests -use alloy_network::TransactionBuilder; -use alloy_primitives::U256; +use alloy_consensus::Transaction; +use alloy_network::{AnyRpcTransaction, ReceiptResponse, TransactionBuilder, TransactionResponse}; +use alloy_primitives::{TxHash, U256}; use alloy_provider::{Provider, ext::TxPoolApi}; use alloy_rpc_types::TransactionRequest; use alloy_serde::WithOtherFields; use anvil::{NodeConfig, spawn}; +use std::time::Duration; +use tokio::time::sleep; #[tokio::test(flavor = "multi_thread")] async fn geth_txpool() { @@ -54,3 +57,601 @@ async fn geth_txpool() { assert!(content.contains_key(&nonce.to_string())); } } + +#[tokio::test(flavor = "multi_thread")] +async fn geth_txpool_separates_queued_transactions() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + api.anvil_set_auto_mine(false).await.unwrap(); + + let accounts = handle.dev_wallets().collect::>(); + let account = accounts[0].address(); + let recipient = accounts[1].address(); + let gas_price = 221435145689u128; + + let pending_value = U256::from(42); + let pending_tx = TransactionRequest::default() + .with_to(recipient) + .with_from(account) + .with_value(pending_value) + .with_gas_price(gas_price) + .with_nonce(0); + let pending_tx = WithOtherFields::new(pending_tx); + + let queued_value = U256::from(84); + let queued_tx = TransactionRequest::default() + .with_to(recipient) + .with_from(account) + .with_value(queued_value) + .with_gas_price(gas_price) + .with_nonce(2); + let queued_tx = WithOtherFields::new(queued_tx); + + let _ = provider.send_transaction(pending_tx).await.unwrap(); + let _ = provider.send_transaction(queued_tx).await.unwrap(); + + let status = provider.txpool_status().await.unwrap(); + assert_eq!(status.pending, 1); + assert_eq!(status.queued, 1); + + let inspect = provider.txpool_inspect().await.unwrap(); + let pending = inspect.pending.get(&account).unwrap(); + assert_eq!(pending.len(), 1); + assert!(!pending.contains_key("2")); + let pending_summary = pending.get("0").unwrap(); + assert_eq!(pending_summary.gas_price, gas_price); + assert_eq!(pending_summary.value, pending_value); + assert_eq!(pending_summary.gas, 21000); + assert_eq!(pending_summary.to.unwrap(), recipient); + + let queued = inspect.queued.get(&account).unwrap(); + assert_eq!(queued.len(), 1); + assert!(!queued.contains_key("0")); + let queued_summary = queued.get("2").unwrap(); + assert_eq!(queued_summary.gas_price, gas_price); + assert_eq!(queued_summary.value, queued_value); + assert_eq!(queued_summary.gas, 21000); + assert_eq!(queued_summary.to.unwrap(), recipient); + + let content = provider.txpool_content().await.unwrap(); + let pending = content.pending.get(&account).unwrap(); + assert_eq!(pending.len(), 1); + assert!(pending.contains_key("0")); + assert!(!pending.contains_key("2")); + + let queued = content.queued.get(&account).unwrap(); + assert_eq!(queued.len(), 1); + assert!(queued.contains_key("2")); + assert!(!queued.contains_key("0")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn mines_successor_after_nonce_fast_forward() { + for (stale_gas_limit, successor_gas_limit) in + [(21_000, 21_000), (30_000, 21_000), (30_000, 30_000)] + { + let (api, handle) = spawn(NodeConfig::test().with_no_mining(true)).await; + let provider = handle.http_provider(); + let accounts = handle.dev_wallets().collect::>(); + let sender = accounts[0].address(); + let recipient = accounts[1].address(); + let initial_balance = provider.get_balance(recipient).await.unwrap(); + let mut hashes = Vec::new(); + + for nonce in 0..=1 { + let tx = TransactionRequest::default() + .with_to(recipient) + .with_from(sender) + .with_value(U256::from(nonce + 1)) + .with_nonce(nonce) + .with_gas_limit(if nonce == 0 { stale_gas_limit } else { successor_gas_limit }); + let pending = provider.send_transaction(WithOtherFields::new(tx)).await.unwrap(); + hashes.push(*pending.tx_hash()); + } + + api.anvil_set_nonce(sender, U256::ONE).await.unwrap(); + if stale_gas_limit > 21_000 { + api.evm_set_block_gas_limit(U256::from(21_000)).unwrap(); + } + api.mine_one().await.unwrap(); + + assert!(provider.get_transaction_receipt(hashes[0]).await.unwrap().is_none()); + if successor_gas_limit > 21_000 { + assert!(provider.get_transaction_receipt(hashes[1]).await.unwrap().is_none()); + let status = provider.txpool_status().await.unwrap(); + assert_eq!((status.pending, status.queued), (1, 0)); + api.evm_set_block_gas_limit(U256::from(successor_gas_limit)).unwrap(); + api.mine_one().await.unwrap(); + } + let receipt = provider.get_transaction_receipt(hashes[1]).await.unwrap().unwrap(); + assert!(receipt.status()); + assert_eq!(provider.get_balance(recipient).await.unwrap(), initial_balance + U256::from(2)); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn can_debug_clear_txpool() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + api.anvil_set_auto_mine(false).await.unwrap(); + + let accounts = handle.dev_wallets().collect::>(); + let account = accounts[0].address(); + let recipient = accounts[1].address(); + let gas_price = 221435145689u128; + + let pending_tx = TransactionRequest::default() + .with_to(recipient) + .with_from(account) + .with_value(U256::from(42)) + .with_gas_price(gas_price) + .with_nonce(0); + let queued_tx = TransactionRequest::default() + .with_to(recipient) + .with_from(account) + .with_value(U256::from(84)) + .with_gas_price(gas_price) + .with_nonce(2); + + let _ = provider.send_transaction(WithOtherFields::new(pending_tx)).await.unwrap(); + let _ = provider.send_transaction(WithOtherFields::new(queued_tx)).await.unwrap(); + + let status = provider.txpool_status().await.unwrap(); + assert_eq!(status.pending, 1); + assert_eq!(status.queued, 1); + + let _: () = provider.client().request("debug_clearTxpool", ()).await.unwrap(); + + let status = provider.txpool_status().await.unwrap(); + assert_eq!(status.pending, 0); + assert_eq!(status.queued, 0); +} + +#[tokio::test(flavor = "multi_thread")] +async fn geth_txpool_content_from_filters_sender() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + api.anvil_set_auto_mine(false).await.unwrap(); + + let accounts = handle.dev_wallets().collect::>(); + let sender = accounts[0].address(); + let other_sender = accounts[1].address(); + let recipient = accounts[2].address(); + let empty_sender = accounts[3].address(); + let gas_price = 221435145689u128; + + let sender_pending_tx = TransactionRequest::default() + .with_to(recipient) + .with_from(sender) + .with_value(U256::from(42)) + .with_gas_price(gas_price) + .with_nonce(0); + let sender_pending_tx = WithOtherFields::new(sender_pending_tx); + + let sender_queued_tx = TransactionRequest::default() + .with_to(recipient) + .with_from(sender) + .with_value(U256::from(84)) + .with_gas_price(gas_price) + .with_nonce(2); + let sender_queued_tx = WithOtherFields::new(sender_queued_tx); + + let other_pending_tx = TransactionRequest::default() + .with_to(recipient) + .with_from(other_sender) + .with_value(U256::from(126)) + .with_gas_price(gas_price) + .with_nonce(0); + let other_pending_tx = WithOtherFields::new(other_pending_tx); + + let _ = provider.send_transaction(sender_pending_tx).await.unwrap(); + let _ = provider.send_transaction(sender_queued_tx).await.unwrap(); + let _ = provider.send_transaction(other_pending_tx).await.unwrap(); + + let status = provider.txpool_status().await.unwrap(); + assert_eq!(status.pending, 2); + assert_eq!(status.queued, 1); + + let content = provider.txpool_content_from(sender).await.unwrap(); + assert_eq!(content.pending.len(), 1); + assert_eq!(content.queued.len(), 1); + + let pending = content.pending.get("0").unwrap(); + assert_eq!(pending.from(), sender); + assert!(!content.pending.contains_key("2")); + + let queued = content.queued.get("2").unwrap(); + assert_eq!(queued.from(), sender); + assert!(!content.queued.contains_key("0")); + + let other_content = provider.txpool_content_from(other_sender).await.unwrap(); + assert_eq!(other_content.pending.len(), 1); + assert_eq!(other_content.pending.get("0").unwrap().from(), other_sender); + assert!(other_content.queued.is_empty()); + + let empty_content = provider.txpool_content_from(empty_sender).await.unwrap(); + assert!(empty_content.pending.is_empty()); + assert!(empty_content.queued.is_empty()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn can_filter_full_pending_transactions() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + api.anvil_set_auto_mine(false).await.unwrap(); + + let account = provider.get_accounts().await.unwrap().remove(0); + let value = U256::from(42); + let tx = TransactionRequest::default().with_to(account).with_from(account).with_value(value); + let tx = WithOtherFields::new(tx); + + let hash_filter: String = + provider.client().request("eth_newPendingTransactionFilter", (false,)).await.unwrap(); + let full_filter: String = + provider.client().request("eth_newPendingTransactionFilter", (true,)).await.unwrap(); + + let pending = provider.send_transaction(tx).await.unwrap(); + let tx_hash = *pending.tx_hash(); + + let mut hash_changes = Vec::new(); + for _ in 0..100 { + let changes: Vec = provider + .client() + .request("eth_getFilterChanges", (hash_filter.clone(),)) + .await + .unwrap(); + if !changes.is_empty() { + hash_changes = changes; + break; + } + sleep(Duration::from_millis(10)).await; + } + + let mut full_changes = Vec::new(); + for _ in 0..100 { + let changes: Vec = provider + .client() + .request("eth_getFilterChanges", (full_filter.clone(),)) + .await + .unwrap(); + if !changes.is_empty() { + full_changes = changes; + break; + } + sleep(Duration::from_millis(10)).await; + } + + assert_eq!(hash_changes, vec![tx_hash]); + assert_eq!(full_changes.len(), 1); + + let full_tx = &full_changes[0]; + assert_eq!(full_tx.inner.tx_hash(), tx_hash); + assert_eq!(full_tx.inner.value(), value); +} + +// Cf. https://github.com/foundry-rs/foundry/issues/11239 +#[tokio::test(flavor = "multi_thread")] +async fn accepts_spend_after_funding_when_pool_checks_disabled() { + // Spawn with pool balance checks disabled + let (api, handle) = spawn(NodeConfig::test().with_disable_pool_balance_checks(true)).await; + let provider = handle.http_provider(); + + // Work with pending pool (no automine) + api.anvil_set_auto_mine(false).await.unwrap(); + + // Funder is a dev account controlled by the node + let funder = provider.get_accounts().await.unwrap().remove(0); + + // Recipient/spender is a random address with zero balance that we'll impersonate + let spender = alloy_primitives::Address::random(); + api.anvil_set_balance(spender, U256::ZERO).await.unwrap(); + api.anvil_impersonate_account(spender).await.unwrap(); + + // Ensure tx1 (funding) has higher gas price so it's mined before tx2 within the same block + let gas_price_fund = 2_000_000_000_000u128; // 2_000 gwei + let gas_price_spend = 1_000_000_000u128; // 1 gwei + + let fund_value = U256::from(1_000_000_000_000_000_000u128); // 1 ether + + // tx1: fund spender from funder + let tx1 = TransactionRequest::default() + .with_from(funder) + .with_to(spender) + .with_value(fund_value) + .with_gas_price(gas_price_fund); + let tx1 = WithOtherFields::new(tx1); + + // tx2: spender attempts to send value greater than their pre-funding balance (0), + // which would normally be rejected by pool balance checks, but should be accepted when disabled + let spend_value = fund_value - U256::from(21_000u64) * U256::from(gas_price_spend); + let tx2 = TransactionRequest::default() + .with_from(spender) + .with_to(funder) + .with_value(spend_value) + .with_gas_limit(21_000) + .with_gas_price(gas_price_spend); + let tx2 = WithOtherFields::new(tx2); + + // Publish both transactions (funding first, then spend-before-funding-is-mined) + let sent1 = provider.send_transaction(tx1).await.unwrap(); + let sent2 = provider.send_transaction(tx2).await.unwrap(); + + // Both should be accepted into the pool (pending) + let status = provider.txpool_status().await.unwrap(); + assert_eq!(status.pending, 2); + assert_eq!(status.queued, 0); + + // Mine a block and ensure both succeed + api.evm_mine(None).await.unwrap(); + + let receipt1 = sent1.get_receipt().await.unwrap(); + let receipt2 = sent2.get_receipt().await.unwrap(); + assert!(receipt1.status()); + assert!(receipt2.status()); +} + +/// Queued replacements must remove the previous transaction. +#[tokio::test(flavor = "multi_thread")] +async fn queued_tx_replacement_removes_old_tx() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + _api.anvil_set_auto_mine(false).await.unwrap(); + + let accounts = handle.dev_wallets().collect::>(); + let account = accounts[0].address(); + let recipient = accounts[1].address(); + let gas_price_base = 221435145689u128; + + // Nonce 5 remains queued until nonces 0 through 4 arrive. + let make_tx = |gas_price: u128| { + WithOtherFields::new( + TransactionRequest::default() + .with_to(recipient) + .with_from(account) + .with_value(U256::ONE) + .with_gas_price(gas_price) + .with_nonce(5), + ) + }; + + let first = provider.send_transaction(make_tx(gas_price_base)).await.unwrap(); + let first_hash = *first.tx_hash(); + let status = provider.txpool_status().await.unwrap(); + assert_eq!(status.queued, 1, "first tx should be queued"); + + let second = provider.send_transaction(make_tx(gas_price_base * 2)).await.unwrap(); + let second_hash = *second.tx_hash(); + + let status = provider.txpool_status().await.unwrap(); + assert_eq!(status.queued, 1, "replacement must remove the old queued tx, not stack it"); + assert!(provider.get_transaction_by_hash(first_hash).await.unwrap().is_none()); + assert!(provider.get_transaction_by_hash(second_hash).await.unwrap().is_some()); + + // A second replacement checks that marker cleanup preserves replacement tracking. + let third = provider.send_transaction(make_tx(gas_price_base * 3)).await.unwrap(); + let third_hash = *third.tx_hash(); + let status = provider.txpool_status().await.unwrap(); + assert_eq!(status.queued, 1); + assert!(provider.get_transaction_by_hash(second_hash).await.unwrap().is_none()); + assert!(provider.get_transaction_by_hash(third_hash).await.unwrap().is_some()); + + // Underpriced replacements must still be rejected. + let underpriced_err = + provider.send_transaction(make_tx(gas_price_base * 2 + 1)).await.unwrap_err(); + let msg = format!("{underpriced_err:?}").to_lowercase(); + assert!(msg.contains("underpriced"), "expected underpriced rejection, got: {msg}"); + + let status = provider.txpool_status().await.unwrap(); + assert_eq!(status.queued, 1); + assert!(provider.get_transaction_by_hash(third_hash).await.unwrap().is_some()); + + // Fill the nonce gap to check marker cleanup also preserves promotion and mining. + for nonce in 0..5u64 { + let filler = WithOtherFields::new( + TransactionRequest::default() + .with_to(recipient) + .with_from(account) + .with_value(U256::ONE) + .with_gas_price(gas_price_base) + .with_nonce(nonce), + ); + let _ = provider.send_transaction(filler).await.unwrap(); + } + _api.evm_mine(None).await.unwrap(); + _api.evm_mine(None).await.unwrap(); + + let receipt = provider.get_transaction_receipt(third_hash).await.unwrap(); + assert!( + receipt.is_some_and(|r| r.status()), + "the surviving replacement tx must mine successfully once its nonce gap is filled" + ); +} + +/// Dropping a transaction must also search the queued pool. +#[tokio::test(flavor = "multi_thread")] +async fn anvil_drop_transaction_removes_queued_tx() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + api.anvil_set_auto_mine(false).await.unwrap(); + + let accounts = handle.dev_wallets().collect::>(); + let account = accounts[0].address(); + let recipient = accounts[1].address(); + + let tx = WithOtherFields::new( + TransactionRequest::default() + .with_to(recipient) + .with_from(account) + .with_value(U256::ONE) + .with_gas_price(221435145689u128) + .with_nonce(5), + ); + let sent = provider.send_transaction(tx).await.unwrap(); + let hash = *sent.tx_hash(); + + let status = provider.txpool_status().await.unwrap(); + assert_eq!(status.queued, 1); + + let dropped = api.anvil_drop_transaction(hash).await.unwrap(); + assert_eq!(dropped, Some(hash), "anvil_dropTransaction should report the queued tx as dropped"); + + let status = provider.txpool_status().await.unwrap(); + assert_eq!(status.queued, 0, "the queued tx must actually be removed from the pool"); + assert!(provider.get_transaction_by_hash(hash).await.unwrap().is_none()); +} + +/// Dropping a transaction must also remove transactions that depend on its nonce. +#[tokio::test(flavor = "multi_thread")] +async fn anvil_drop_transaction_removes_nonce_dependents() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + api.anvil_set_auto_mine(false).await.unwrap(); + + let accounts = handle.dev_wallets().collect::>(); + let account = accounts[0].address(); + let recipient = accounts[1].address(); + let transaction = |nonce| { + WithOtherFields::new( + TransactionRequest::default() + .with_to(recipient) + .with_from(account) + .with_value(U256::ONE) + .with_nonce(nonce), + ) + }; + + let first = provider.send_transaction(transaction(0)).await.unwrap(); + let first_hash = *first.tx_hash(); + let dependent = provider.send_transaction(transaction(1)).await.unwrap(); + let dependent_hash = *dependent.tx_hash(); + let transitive_dependent = provider.send_transaction(transaction(2)).await.unwrap(); + let transitive_dependent_hash = *transitive_dependent.tx_hash(); + + let status = provider.txpool_status().await.unwrap(); + assert_eq!(status.pending, 3); + + let dropped = api.anvil_drop_transaction(first_hash).await.unwrap(); + assert_eq!(dropped, Some(first_hash)); + + let status = provider.txpool_status().await.unwrap(); + assert_eq!(status.pending, 0); + assert!(provider.get_transaction_by_hash(dependent_hash).await.unwrap().is_none()); + assert!(provider.get_transaction_by_hash(transitive_dependent_hash).await.unwrap().is_none()); +} + +/// Dropping a queued transaction must also remove its queued nonce dependents. +#[tokio::test(flavor = "multi_thread")] +async fn anvil_drop_transaction_removes_queued_nonce_dependents() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + + api.anvil_set_auto_mine(false).await.unwrap(); + + let accounts = handle.dev_wallets().collect::>(); + let account = accounts[0].address(); + let recipient = accounts[1].address(); + let transaction = |nonce| { + WithOtherFields::new( + TransactionRequest::default() + .with_to(recipient) + .with_from(account) + .with_value(U256::ONE) + .with_nonce(nonce), + ) + }; + + let first_hash = *provider.send_transaction(transaction(5)).await.unwrap().tx_hash(); + let dependent_hash = *provider.send_transaction(transaction(6)).await.unwrap().tx_hash(); + let transitive_hash = *provider.send_transaction(transaction(7)).await.unwrap().tx_hash(); + + let status = provider.txpool_status().await.unwrap(); + assert_eq!(status.queued, 3); + + assert_eq!(api.anvil_drop_transaction(first_hash).await.unwrap(), Some(first_hash)); + + let status = provider.txpool_status().await.unwrap(); + assert_eq!(status.queued, 0); + assert!(provider.get_transaction_by_hash(dependent_hash).await.unwrap().is_none()); + assert!(provider.get_transaction_by_hash(transitive_hash).await.unwrap().is_none()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn evm_revert_restores_transaction_pool() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + api.anvil_set_auto_mine(false).await.unwrap(); + + let accounts = handle.dev_wallets().collect::>(); + let sender = accounts[0].address(); + let recipient = accounts[1].address(); + let transaction = |nonce| { + WithOtherFields::new( + TransactionRequest::default() + .with_to(recipient) + .with_from(sender) + .with_value(U256::ONE) + .with_nonce(nonce), + ) + }; + + let pending_hash = *provider.send_transaction(transaction(0)).await.unwrap().tx_hash(); + let queued_hash = *provider.send_transaction(transaction(2)).await.unwrap().tx_hash(); + let snapshot = api.evm_snapshot().await.unwrap(); + let later_hash = *provider.send_transaction(transaction(1)).await.unwrap().tx_hash(); + let later_snapshot = api.evm_snapshot().await.unwrap(); + + api.mine_one().await.unwrap(); + assert_eq!(provider.txpool_status().await.unwrap().pending, 0); + + assert!(api.evm_revert(snapshot).await.unwrap()); + assert!(!api.evm_revert(later_snapshot).await.unwrap()); + + let status = provider.txpool_status().await.unwrap(); + assert_eq!(status.pending, 1); + assert_eq!(status.queued, 1); + assert!(provider.get_transaction_by_hash(pending_hash).await.unwrap().is_some()); + assert!(provider.get_transaction_by_hash(queued_hash).await.unwrap().is_some()); + assert!(provider.get_transaction_by_hash(later_hash).await.unwrap().is_none()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn evm_revert_wakes_autominer_for_restored_transactions() { + let (api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + api.anvil_set_auto_mine(false).await.unwrap(); + + let accounts = handle.dev_wallets().collect::>(); + let hash = *provider + .send_transaction(WithOtherFields::new( + TransactionRequest::default() + .with_from(accounts[0].address()) + .with_to(accounts[1].address()) + .with_value(U256::ONE), + )) + .await + .unwrap() + .tx_hash(); + let snapshot = api.evm_snapshot().await.unwrap(); + api.mine_one().await.unwrap(); + + api.anvil_set_auto_mine(true).await.unwrap(); + assert!(api.evm_revert(snapshot).await.unwrap()); + + tokio::time::timeout(Duration::from_secs(1), async { + loop { + if provider.get_transaction_receipt(hash).await.unwrap().is_some() { + break; + } + sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); +} diff --git a/crates/anvil/tests/it/utils.rs b/crates/anvil/tests/it/utils.rs index 4a82c27745edd..044a86531f287 100644 --- a/crates/anvil/tests/it/utils.rs +++ b/crates/anvil/tests/it/utils.rs @@ -1,6 +1,6 @@ use alloy_network::{Ethereum, EthereumWallet}; use alloy_provider::{ - Identity, RootProvider, + Identity, RootProvider, builder, fillers::{ChainIdFiller, FillProvider, GasFiller, JoinFill, NonceFiller, WalletFiller}, }; use foundry_common::provider::{ @@ -31,7 +31,7 @@ pub fn ws_provider_with_signer( /// Currently required to get around pub async fn connect_pubsub(conn_str: &str) -> RootProvider { - alloy_provider::ProviderBuilder::default().connect(conn_str).await.unwrap() + builder().connect(conn_str).await.unwrap() } type PubsubSigner = FillProvider< diff --git a/crates/cast/Cargo.toml b/crates/cast/Cargo.toml index 6e9e55c4ae0db..cd906d7e0bf11 100644 --- a/crates/cast/Cargo.toml +++ b/crates/cast/Cargo.toml @@ -26,9 +26,12 @@ foundry-block-explorers.workspace = true foundry-common.workspace = true foundry-compilers.workspace = true foundry-config.workspace = true -foundry-evm-core.workspace = true +foundry-debugger.workspace = true foundry-evm.workspace = true -foundry-wallets.workspace = true +foundry-evm-networks.workspace = true +foundry-primitives.workspace = true +foundry-wallets = { workspace = true, features = ["browser", "tempo"] } +forge-fmt.workspace = true alloy-chains.workspace = true alloy-consensus = { workspace = true, features = ["serde", "kzg"] } @@ -44,19 +47,33 @@ alloy-provider = { workspace = true, features = [ "ipc", "trace-api", "txpool-api", + "debug-api", ] } alloy-rlp.workspace = true +alloy-rpc-client.workspace = true alloy-rpc-types = { workspace = true, features = ["eth", "trace"] } -alloy-serde.workspace = true +alloy-rpc-types-beacon.workspace = true alloy-signer-local = { workspace = true, features = ["mnemonic", "keystore"] } alloy-signer.workspace = true alloy-sol-types.workspace = true alloy-transport.workspace = true alloy-ens = { workspace = true, features = ["provider"] } alloy-eips.workspace = true +tempo-alloy.workspace = true +tempo-contracts.workspace = true +tempo-primitives.workspace = true + +# Base +base-common-network = { workspace = true, optional = true, features = ["serde", "std"] } + +op-alloy-consensus = { workspace = true, features = ["k256"], optional = true } +op-alloy-flz = { workspace = true, optional = true } +op-alloy-network = { workspace = true, optional = true } -op-alloy-flz.workspace = true -op-alloy-consensus = { workspace = true, features = ["alloy-compat"] } +aes-gcm = "0.10" +hkdf = "0.12" +k256.workspace = true +sha2 = "0.10" chrono.workspace = true eyre.workspace = true @@ -65,6 +82,7 @@ revm = { workspace = true, features = ["optional_balance_check"] } rand.workspace = true rand_08.workspace = true rayon.workspace = true +reqwest.workspace = true serde_json.workspace = true serde.workspace = true @@ -72,31 +90,79 @@ serde.workspace = true foundry-cli.workspace = true clap = { version = "4", features = ["derive", "env", "unicode", "wrap_help"] } -clap_complete = "4" -clap_complete_fig = "4" +clap_complete.workspace = true comfy-table.workspace = true -dunce.workspace = true itertools.workspace = true regex = { workspace = true, default-features = false } rpassword = "7" semver.workspace = true tempfile.workspace = true -tokio = { workspace = true, features = ["macros", "signal"] } +tokio = { workspace = true, features = ["macros", "process", "signal", "time"] } tracing.workspace = true yansi.workspace = true evmole.workspace = true +url.workspace = true + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_System_JobObjects", + "Win32_System_Threading", +] } + +[target.'cfg(unix)'.dev-dependencies] +rexpect.workspace = true [dev-dependencies] +alloy-hardforks.workspace = true +alloy-serde.workspace = true +axum.workspace = true +dirs.workspace = true anvil.workspace = true foundry-test-utils.workspace = true -alloy-hardforks.workspace = true +tempo-hardfork.workspace = true +path-slash.workspace = true +reqwest.workspace = true +tower.workspace = true [features] -default = ["jemalloc"] -asm-keccak = ["alloy-primitives/asm-keccak"] +default = ["jemalloc", "asm-keccak", "optimism"] +asm-keccak = ["alloy-primitives/asm-keccak", "revm/asm-keccak"] jemalloc = ["foundry-cli/jemalloc"] mimalloc = ["foundry-cli/mimalloc"] tracy-allocator = ["foundry-cli/tracy-allocator"] aws-kms = ["foundry-wallets/aws-kms"] gcp-kms = ["foundry-wallets/gcp-kms"] -isolate-by-default = ["foundry-config/isolate-by-default"] +turnkey = ["foundry-wallets/turnkey"] +touch-id = ["foundry-wallets/touch-id"] +monad = [ + "anvil/monad", + "foundry-common/monad", + "foundry-config/monad", + "foundry-evm-networks/monad", + "foundry-evm/monad", + "foundry-cli/monad", +] +base = [ + "anvil/base", + "dep:base-common-network", + "dep:op-alloy-flz", + "foundry-common/base", + "foundry-debugger/base", + "foundry-evm/base", + "foundry-evm-networks/base", + "foundry-cli/base", +] +optimism = [ + "dep:op-alloy-flz", + "dep:op-alloy-consensus", + "dep:op-alloy-network", + "foundry-common/optimism", + "foundry-evm-networks/optimism", + "foundry-evm/optimism", + "foundry-cli/optimism", +] diff --git a/crates/cast/bin/main.rs b/crates/cast/bin/main.rs index a554d01d7be44..2bdf022c73fc7 100644 --- a/crates/cast/bin/main.rs +++ b/crates/cast/bin/main.rs @@ -2,13 +2,32 @@ //! transactions and getting chain data. use cast::args::run; +use foundry_cli::json::{JsonEnvelope, JsonError, JsonMessage, print_json}; #[global_allocator] static ALLOC: foundry_cli::utils::Allocator = foundry_cli::utils::new_allocator(); fn main() { if let Err(err) = run() { - let _ = foundry_common::sh_err!("{err:?}"); + if foundry_common::shell::is_json() { + if let Some(err) = err.downcast_ref::() { + let envelope = JsonEnvelope::failure_with_data(&err.data, err.errors.clone()); + let _ = print_json(&envelope); + std::process::exit(1); + } + // Collect the full error chain into structured error entries. + let errors = err + .chain() + .enumerate() + .map(|(i, e)| { + let code = if i == 0 { "cast.error" } else { "cast.error.context" }; + JsonMessage::error(code, e.to_string()) + }) + .collect(); + let _ = print_json(&JsonEnvelope::<()>::failure(errors)); + } else { + let _ = foundry_common::sh_err!("{err:?}"); + } std::process::exit(1); } } diff --git a/crates/cast/src/args.rs b/crates/cast/src/args.rs index ad32b2330b220..909307ae382fb 100644 --- a/crates/cast/src/args.rs +++ b/crates/cast/src/args.rs @@ -1,336 +1,656 @@ use crate::{ - Cast, SimpleCast, - opts::{Cast as CastArgs, CastSubcommand, ToBaseArgs}, + base::{Base, NumberWithBase}, + cmd::{erc20::IERC20, rpc_provider}, + opts::{ + AbiSubcommand, Cast as CastArgs, CastSubcommand, ConversionSubcommand, MiscSubcommand, + QuerySubcommand, ToBaseArgs, TransactionSubcommand, + }, traces::identifier::SignaturesIdentifier, + tx::CastTxSender, +}; +use alloy_consensus::{ + Typed2718, + transaction::{Recovered, SignerRecoverable}, +}; +use alloy_dyn_abi::{DynSolType, DynSolValue, ErrorExt, EventExt, Specifier}; +use alloy_eips::{Encodable2718, eip7702::SignedAuthorization}; +use alloy_ens::{NameOrAddress, ProviderEnsExt, namehash}; +use alloy_network::{BlockResponse, Ethereum, Network, eip2718::Decodable2718}; +use alloy_primitives::{ + Address, B256, Bytes, I256, Keccak256, LogData, TxHash, U64, U256, b256, eip191_hash_message, + hex, keccak256, + utils::{ParseUnits, Unit}, }; -use alloy_consensus::transaction::{Recovered, SignerRecoverable}; -use alloy_dyn_abi::{DynSolValue, ErrorExt, EventExt}; -use alloy_eips::eip7702::SignedAuthorization; -use alloy_ens::{ProviderEnsExt, namehash}; -use alloy_primitives::{Address, B256, eip191_hash_message, hex, keccak256}; use alloy_provider::Provider; -use alloy_rpc_types::{BlockId, BlockNumberOrTag::Latest}; +use alloy_rlp::{Decodable, encode}; +use alloy_rpc_types::BlockId; use clap::{CommandFactory, Parser}; use clap_complete::generate; -use eyre::Result; -use foundry_cli::{handler, utils, utils::LoadConfig}; +use eyre::{ContextCompat, OptionExt, Result, WrapErr}; +use foundry_block_explorers::Client; +use foundry_cli::{ + json::{print_json_object, print_json_value_or_scalar, print_list, print_scalar, print_tokens}, + opts::RpcOpts, + utils::{self, LoadConfig}, +}; use foundry_common::{ - abi::{get_error, get_event}, - fmt::{format_tokens, format_tokens_raw, format_uint_exp}, + abi::{ + abi_decode_calldata, abi_decode_event_data, encode_function_args, + encode_function_args_packed, get_error, get_event, get_func, + }, + fmt::{UIfmt, UIfmtSignatureExt, format_uint_exp, get_pretty_block_attr, get_pretty_tx_attr}, fs, + provider::{ProviderBuilder, RetryProvider}, selectors::{ ParsedSignatures, SelectorImportData, SelectorKind, decode_calldata, decode_event_topic, decode_function_selector, decode_selectors, import_selectors, parse_signatures, pretty_calldata, }, shell, stdin, + tempo::classify_payment_lane, }; -use std::time::Instant; +use foundry_config::Chain; +use foundry_evm_networks::NetworkVariant; +use foundry_primitives::{FoundryNetwork, FoundryTxEnvelope}; +use rayon::prelude::*; +use serde::Serialize; +use std::{ + str::FromStr, + sync::atomic::{AtomicBool, Ordering}, + time::Instant, +}; +use tempo_alloy::TempoNetwork; +use tempo_contracts::precompiles::{ITIP20ChannelReserve, TIP20_CHANNEL_RESERVE_ADDRESS}; + +#[cfg(feature = "base")] +use base_common_network::Base as BaseNetwork; + +#[cfg(feature = "optimism")] +use op_alloy_network::Optimism; + +/// Runs `$body` with `$provider` bound to a provider for the selected `--network`. +/// Optionally binds `$network_type` to the selected network type. +/// +/// The fallback arm is used for Ethereum and when no network is selected: either `$default` is a +/// provider expression that `$body` runs against, or a full `_ => $default` arm. +macro_rules! with_network_provider { + ($network:expr, $config:expr, $default:expr, |$provider:ident $(, $network_type:ident)?| $body:expr) => { + with_network_provider!($network, $config, |$provider $(, $network_type)?| $body, _ => { + $(type $network_type = Ethereum;)? + let $provider = $default; + $body + }) + }; + ($network:expr, $config:expr, |$provider:ident $(, $network_type:ident)?| $body:expr, _ => $default:expr) => { + match $network { + #[cfg(feature = "base")] + Some(NetworkVariant::Base) => { + $(type $network_type = BaseNetwork;)? + let $provider = ProviderBuilder::::from_config($config)?.build()?; + $body + } + #[cfg(feature = "optimism")] + Some(NetworkVariant::Optimism) => { + $(type $network_type = Optimism;)? + let $provider = ProviderBuilder::::from_config($config)?.build()?; + $body + } + Some(NetworkVariant::Tempo) => { + $(type $network_type = TempoNetwork;)? + let $provider = ProviderBuilder::::from_config($config)?.build()?; + $body + } + _ => $default, + } + }; +} /// Run the `cast` command-line interface. pub fn run() -> Result<()> { - setup()?; + foundry_cli::opts::GlobalArgs::check_markdown_help::(); + + let warnings = setup()?; let args = CastArgs::parse(); args.global.init()?; + for warning in warnings { + let _ = foundry_common::sh_warn!("{warning}"); + } args.global.tokio_runtime().block_on(run_command(args)) } /// Setup the global logger and other utilities. -pub fn setup() -> Result<()> { - utils::install_crypto_provider(); - handler::install(); - utils::load_dotenv(); +pub fn setup() -> Result> { + let warnings = utils::common_setup(); utils::subscriber(); - utils::enable_paint(); - Ok(()) + Ok(warnings) } /// Run the subcommand. +#[allow(clippy::large_stack_frames)] pub async fn run_command(args: CastArgs) -> Result<()> { match args.cmd { // Constants - CastSubcommand::MaxInt { r#type } => { - sh_println!("{}", SimpleCast::max_int(&r#type)?)?; - } - CastSubcommand::MinInt { r#type } => { - sh_println!("{}", SimpleCast::min_int(&r#type)?)?; + CastSubcommand::Conversion( + ConversionSubcommand::MaxInt { r#type } | ConversionSubcommand::MaxUint { r#type }, + ) => { + print_scalar(int_bound(&r#type, true)?)?; } - CastSubcommand::MaxUint { r#type } => { - sh_println!("{}", SimpleCast::max_int(&r#type)?)?; + CastSubcommand::Conversion(ConversionSubcommand::MinInt { r#type }) => { + print_scalar(int_bound(&r#type, false)?)? } - CastSubcommand::AddressZero => { - sh_println!("{:?}", Address::ZERO)?; + CastSubcommand::Conversion(ConversionSubcommand::AddressZero) => { + print_scalar(format!("{:?}", Address::ZERO))? } - CastSubcommand::HashZero => { - sh_println!("{:?}", B256::ZERO)?; + CastSubcommand::Conversion(ConversionSubcommand::HashZero) => { + print_scalar(format!("{:?}", B256::ZERO))? } // Conversions & transformations - CastSubcommand::FromUtf8 { text } => { - let value = stdin::unwrap(text, false)?; - sh_println!("{}", SimpleCast::from_utf8(&value))? + CastSubcommand::Conversion(ConversionSubcommand::FromUtf8 { text }) => { + print_scalar(hex::encode_prefixed(stdin::unwrap(text, false)?))?; } - CastSubcommand::ToAscii { hexdata } => { - let value = stdin::unwrap(hexdata, false)?; - sh_println!("{}", SimpleCast::to_ascii(value.trim())?)? + CastSubcommand::Conversion(ConversionSubcommand::ToAscii { hexdata }) => { + let bytes = hex::decode(stdin::unwrap(hexdata, false)?.trim())?; + eyre::ensure!(bytes.iter().all(u8::is_ascii), "Invalid ASCII bytes"); + print_scalar(String::from_utf8(bytes).unwrap())?; } - CastSubcommand::ToUtf8 { hexdata } => { - let value = stdin::unwrap(hexdata, false)?; - sh_println!("{}", SimpleCast::to_utf8(&value)?)? + CastSubcommand::Conversion(ConversionSubcommand::ToUtf8 { hexdata }) => { + let bytes = hex::decode(stdin::unwrap(hexdata, false)?)?; + print_scalar(String::from_utf8_lossy(&bytes).into_owned())?; } - CastSubcommand::FromFixedPoint { value, decimals } => { + CastSubcommand::Conversion(ConversionSubcommand::FromFixedPoint { value, decimals }) => { let (value, decimals) = stdin::unwrap2(value, decimals)?; - sh_println!("{}", SimpleCast::from_fixed_point(&value, &decimals)?)? + print_scalar(ParseUnits::parse_units(&value, Unit::from_str(&decimals)?)?.to_string())?; } - CastSubcommand::ToFixedPoint { value, decimals } => { + CastSubcommand::Conversion(ConversionSubcommand::ToFixedPoint { value, decimals }) => { let (value, decimals) = stdin::unwrap2(value, decimals)?; - sh_println!("{}", SimpleCast::to_fixed_point(&value, &decimals)?)? - } - CastSubcommand::ConcatHex { data } => { - if data.is_empty() { - let s = stdin::read(true)?; - sh_println!("{}", SimpleCast::concat_hex(s.split_whitespace()))? + + let number = NumberWithBase::parse_int(&value, None)?; + let sign = if number.is_nonnegative() { "" } else { "-" }; + let mut value = number.to_string().trim_start_matches('-').to_string(); + let value_len = value.len(); + let decimals_num = NumberWithBase::parse_uint(&decimals, None)?.number(); + let decimals: usize = decimals_num + .try_into() + .ok() + .filter(|&d: &usize| d <= u16::MAX as usize) + .ok_or_else(|| eyre::eyre!("decimals out of range: {decimals_num}"))?; + + if decimals >= value_len { + value = format!("0.{value:0>decimals$}"); } else { - sh_println!("{}", SimpleCast::concat_hex(data))? + value.insert(value_len - decimals, '.'); } + print_scalar(format!("{sign}{value}"))?; } - CastSubcommand::FromBin => { - let hex = stdin::read_bytes(false)?; - sh_println!("{}", hex::encode_prefixed(hex))? + CastSubcommand::Conversion(ConversionSubcommand::ConcatHex { data }) => { + let input; + let values = if data.is_empty() { + input = stdin::read(true)?; + itertools::Either::Left(input.split_whitespace()) + } else { + itertools::Either::Right(data.iter().map(String::as_str)) + }; + let out = values.map(strip_0x).collect::(); + print_scalar(format!("0x{out}"))?; } - CastSubcommand::ToHexdata { input } => { + CastSubcommand::Conversion(ConversionSubcommand::FromBin) => { + print_scalar(hex::encode_prefixed(stdin::read_bytes(false)?))?; + } + CastSubcommand::Conversion(ConversionSubcommand::ToHexdata { input }) => { let value = stdin::unwrap_line(input)?; let output = match value { s if s.starts_with('@') => hex::encode(std::env::var(&s[1..])?), s if s.starts_with('/') => hex::encode(fs::read(s)?), s => s.split(':').map(|s| s.trim_start_matches("0x").to_lowercase()).collect(), }; - sh_println!("0x{output}")? + print_scalar(format!("0x{output}"))?; } - CastSubcommand::ToCheckSumAddress { address, chain_id } => { - let value = stdin::unwrap_line(address)?; - sh_println!("{}", value.to_checksum(chain_id))? + CastSubcommand::Conversion(ConversionSubcommand::ToCheckSumAddress { + address, + chain_id, + }) => { + print_scalar(stdin::unwrap_line(address)?.to_checksum(chain_id))?; } - CastSubcommand::ToUint256 { value } => { - let value = stdin::unwrap_line(value)?; - sh_println!("{}", SimpleCast::to_uint256(&value)?)? + CastSubcommand::Conversion(ConversionSubcommand::ToUint256 { value }) => { + let n = NumberWithBase::parse_uint(&stdin::unwrap_line(value)?, None)?; + print_scalar(format!("{n:#066x}"))?; } - CastSubcommand::ToInt256 { value } => { - let value = stdin::unwrap_line(value)?; - sh_println!("{}", SimpleCast::to_int256(&value)?)? + CastSubcommand::Conversion(ConversionSubcommand::ToInt256 { value }) => { + let n = NumberWithBase::parse_int(&stdin::unwrap_line(value)?, None)?; + print_scalar(format!("{n:#066x}"))?; } - CastSubcommand::ToUnit { value, unit } => { + CastSubcommand::Conversion(ConversionSubcommand::ToUnit { value, unit }) => { let value = stdin::unwrap_line(value)?; - sh_println!("{}", SimpleCast::to_unit(&value, &unit)?)? - } - CastSubcommand::ParseUnits { value, unit } => { + let value = DynSolType::coerce_str(&DynSolType::Uint(256), &value)? + .as_uint() + .wrap_err("Could not convert to uint")? + .0; + let unit = unit.parse().wrap_err("could not parse units")?; + print_scalar(format_unit_as_string(ParseUnits::U256(value), unit))?; + } + CastSubcommand::Conversion(ConversionSubcommand::ParseUnits { value, unit }) => { let value = stdin::unwrap_line(value)?; - sh_println!("{}", SimpleCast::parse_units(&value, unit)?)?; + let unit = Unit::new(unit).ok_or_else(|| eyre::eyre!("invalid unit"))?; + + print_scalar(ParseUnits::parse_units(&value, unit)?.to_string())?; } - CastSubcommand::FormatUnits { value, unit } => { - let value = stdin::unwrap_line(value)?; - sh_println!("{}", SimpleCast::format_units(&value, unit)?)?; + CastSubcommand::Conversion(ConversionSubcommand::FormatUnits { value, unit }) => { + print_scalar(format_units(&stdin::unwrap_line(value)?, unit)?)?; } - CastSubcommand::FromWei { value, unit } => { - let value = stdin::unwrap_line(value)?; - sh_println!("{}", SimpleCast::from_wei(&value, &unit)?)? + CastSubcommand::Conversion(ConversionSubcommand::FromWei { value, unit }) => { + print_scalar( + signed_parse_units(&NumberWithBase::parse_int(&stdin::unwrap_line(value)?, None)?)? + .format_units(unit.parse()?), + )?; } - CastSubcommand::ToWei { value, unit } => { + CastSubcommand::Conversion(ConversionSubcommand::ToWei { value, unit }) => { let value = stdin::unwrap_line(value)?; - sh_println!("{}", SimpleCast::to_wei(&value, &unit)?)? + let unit = unit.parse().wrap_err("could not parse units")?; + print_scalar(ParseUnits::parse_units(&value, unit)?.to_string())?; } - CastSubcommand::FromRlp { value, as_int } => { - let value = stdin::unwrap_line(value)?; - sh_println!("{}", SimpleCast::from_rlp(value, as_int)?)? + CastSubcommand::Conversion(ConversionSubcommand::FromRlp { value, as_int }) => { + let bytes = hex::decode(stdin::unwrap_line(value)?).wrap_err("Could not decode hex")?; + let value = if as_int { + U256::decode(&mut &bytes[..])?.to_string() + } else { + crate::rlp_converter::Item::decode(&mut &bytes[..]) + .wrap_err("Could not decode rlp")? + .to_string() + }; + print_scalar(value)?; } - CastSubcommand::ToRlp { value } => { + CastSubcommand::Conversion(ConversionSubcommand::ToRlp { value }) => { let value = stdin::unwrap_line(value)?; - sh_println!("{}", SimpleCast::to_rlp(&value)?)? + let val = + serde_json::from_str(&value).unwrap_or_else(|_| serde_json::Value::String(value)); + let item = crate::rlp_converter::Item::value_to_item(&val)?; + print_scalar(hex::encode_prefixed(encode(item)))?; } - CastSubcommand::ToHex(ToBaseArgs { value, base_in }) => { + CastSubcommand::Conversion(ConversionSubcommand::ToHex(ToBaseArgs { value, base_in })) => { let value = stdin::unwrap_line(value)?; - sh_println!("{}", SimpleCast::to_base(&value, base_in.as_deref(), "hex")?)? + print_scalar(to_base(&value, base_in.as_deref(), "hex")?)?; } - CastSubcommand::ToDec(ToBaseArgs { value, base_in }) => { + CastSubcommand::Conversion(ConversionSubcommand::ToDec(ToBaseArgs { value, base_in })) => { let value = stdin::unwrap_line(value)?; - sh_println!("{}", SimpleCast::to_base(&value, base_in.as_deref(), "dec")?)? + print_scalar(to_base(&value, base_in.as_deref(), "dec")?)?; } - CastSubcommand::ToBase { base: ToBaseArgs { value, base_in }, base_out } => { + CastSubcommand::Conversion(ConversionSubcommand::ToBase { + base: ToBaseArgs { value, base_in }, + base_out, + }) => { let (value, base_out) = stdin::unwrap2(value, base_out)?; - sh_println!("{}", SimpleCast::to_base(&value, base_in.as_deref(), &base_out)?)? - } - CastSubcommand::ToBytes32 { bytes } => { - let value = stdin::unwrap_line(bytes)?; - sh_println!("{}", SimpleCast::to_bytes32(&value)?)? - } - CastSubcommand::FormatBytes32String { string } => { - let value = stdin::unwrap_line(string)?; - sh_println!("{}", SimpleCast::format_bytes32_string(&value)?)? + print_scalar(to_base(&value, base_in.as_deref(), &base_out)?)?; } - CastSubcommand::ParseBytes32String { bytes } => { - let value = stdin::unwrap_line(bytes)?; - sh_println!("{}", SimpleCast::parse_bytes32_string(&value)?)? + CastSubcommand::Conversion(ConversionSubcommand::ToBytes32 { bytes }) => { + let s = stdin::unwrap_line(bytes)?; + let s = strip_0x(&s); + if s.len() > 64 { + eyre::bail!("string >32 bytes"); + } + + let padded = format!("{s:0<64}"); + print_scalar(padded.parse::()?.to_string())?; } - CastSubcommand::ParseBytes32Address { bytes } => { - let value = stdin::unwrap_line(bytes)?; - sh_println!("{}", SimpleCast::parse_bytes32_address(&value)?)? + CastSubcommand::Conversion(ConversionSubcommand::ToBytesMemory { data }) => { + let data = stdin::unwrap_line(data)?; + const WORD: usize = 32; + + let data = hex::decode(data).wrap_err("Could not decode hex")?; + let padded_len = data.len().next_multiple_of(WORD); + let mut out = Vec::with_capacity(WORD + padded_len); + out.extend_from_slice(&U256::from(data.len()).to_be_bytes::()); + out.extend_from_slice(&data); + out.resize(WORD + padded_len, 0); + print_scalar(hex::encode_prefixed(out))?; + } + CastSubcommand::Conversion(ConversionSubcommand::Pad { data, right, left: _, len }) => { + let s = stdin::unwrap_line(data)?; + let s = strip_0x(&s); + let hex_len = len + .checked_mul(2) + .filter(|&h| h <= u16::MAX as usize) + .ok_or_else(|| eyre::eyre!("len out of range: {len}"))?; + + // Validate input + if s.len() > hex_len { + eyre::bail!("input length exceeds target length"); + } + if !s.chars().all(|c| c.is_ascii_hexdigit()) { + eyre::bail!("input is not a valid hex"); + } + + print_scalar(if right { + format!("0x{s:0hex_len$}") + })?; + } + CastSubcommand::Conversion(ConversionSubcommand::FormatBytes32String { string }) => { + let s = stdin::unwrap_line(string)?; + let str_bytes: &[u8] = s.as_bytes(); + eyre::ensure!( + str_bytes.len() <= 32, + "bytes32 strings must not exceed 32 bytes in length" + ); + + let mut bytes32: [u8; 32] = [0u8; 32]; + bytes32[..str_bytes.len()].copy_from_slice(str_bytes); + print_scalar(hex::encode_prefixed(bytes32))?; + } + CastSubcommand::Conversion(ConversionSubcommand::ParseBytes32String { bytes }) => { + let s = stdin::unwrap_line(bytes)?; + let bytes = hex::decode(s)?; + eyre::ensure!(bytes.len() == 32, "expected 32 byte hex-string"); + let len = bytes.iter().take_while(|x| **x != 0).count(); + print_scalar(std::str::from_utf8(&bytes[..len])?)?; + } + CastSubcommand::Conversion(ConversionSubcommand::ParseBytes32Address { bytes }) => { + let s = stdin::unwrap_line(bytes)?; + let s = strip_0x(&s); + if s.len() != 64 { + eyre::bail!("expected 64 byte hex-string, got {s}"); + } + let Some(s) = s.strip_prefix("000000000000000000000000") else { + eyre::bail!("Not convertible to address, there are non-zero bytes"); + }; + print_scalar(Address::from_str(s)?.to_checksum(None))?; } // ABI encoding & decoding - CastSubcommand::DecodeAbi { sig, calldata, input } => { - let tokens = SimpleCast::abi_decode(&sig, &calldata, input)?; - print_tokens(&tokens); + CastSubcommand::Abi(AbiSubcommand::DecodeAbi { sig, calldata, input }) => { + print_tokens(&abi_decode_calldata(&sig, &calldata, input, false)?)?; } - CastSubcommand::AbiEncode { sig, packed, args } => { - if !packed { - sh_println!("{}", SimpleCast::abi_encode(&sig, &args)?)? + CastSubcommand::Abi(AbiSubcommand::AbiEncode { sig, packed, args }) => { + let out = if packed { + // If the signature is a tuple, we need to prefix it to make it a function + let sig = if sig.trim_start().starts_with('(') { format!("foo{sig}") } else { sig }; + + let func = get_func(&sig)?; + let encoded = encode_function_args_packed(&func, &args).map_err(|e| { + eyre::eyre!("Could not ABI encode the function and arguments: {e}") + })?; + hex::encode_prefixed(encoded) } else { - sh_println!("{}", SimpleCast::abi_encode_packed(&sig, &args)?)? + let func = get_func(&sig)?; + let encoded = encode_function_args(&func, &args).map_err(|e| { + eyre::eyre!("Could not ABI encode the function and arguments: {e}") + })?; + hex::encode_prefixed(&encoded[4..]) + }; + print_scalar(out)?; + } + // TODO(json): multi-line output (one line per topic + data field), needs structured object + // envelope + CastSubcommand::Abi(AbiSubcommand::AbiEncodeEvent { sig, args }) => { + let event = get_event(&sig)?; + if event.inputs.len() != args.len() { + eyre::bail!( + "encode length mismatch: expected {} types, got {}", + event.inputs.len(), + args.len(), + ); + } + + let types = event + .inputs + .iter() + .map(Specifier::::resolve) + .collect::, _>>()?; + let tokens = std::iter::zip(&types, &args) + .map(|(ty, arg)| Ok(DynSolType::coerce_str(ty, arg.as_ref())?)) + .collect::>>()?; + + let mut topics = if event.anonymous { vec![] } else { vec![event.selector()] }; + // Non-indexed parameters are encoded together as the event body. + let mut data_tokens = Vec::new(); + for (input, token) in event.inputs.iter().zip(tokens) { + if input.indexed { + topics.push(encode_event_topic(&token)); + } else { + data_tokens.push(token); + } + } + + let data = DynSolValue::Tuple(data_tokens).abi_encode_params(); + let log_data = LogData::new_unchecked(topics, data.into()); + if shell::is_json() { + #[derive(serde::Serialize)] + struct EncodedEvent { + topics: Vec, + data: String, + } + print_json_object(EncodedEvent { + topics: log_data.topics().iter().map(|t| t.to_string()).collect(), + data: hex::encode_prefixed(&log_data.data), + })?; + } else { + for (i, topic) in log_data.topics().iter().enumerate() { + sh_println!("[topic{i}]: {topic}")?; + } + if !log_data.data.is_empty() { + sh_println!("[data]: {}", hex::encode_prefixed(log_data.data))?; + } } } - CastSubcommand::DecodeCalldata { sig, calldata } => { - let tokens = SimpleCast::calldata_decode(&sig, &calldata, true)?; - print_tokens(&tokens); + CastSubcommand::Abi(AbiSubcommand::DecodeCalldata { sig, calldata, file }) => { + let raw_hex = match file { + Some(file_path) => fs::read_to_string(&file_path)?.trim().to_string(), + None => calldata.unwrap(), + }; + print_tokens(&abi_decode_calldata(&sig, &raw_hex, true, true)?)?; } - CastSubcommand::CalldataEncode { sig, args, file } => { - let final_args = if let Some(file_path) = file { - let contents = fs::read_to_string(file_path)?; - contents + CastSubcommand::Abi(AbiSubcommand::CalldataEncode { sig, args, file }) => { + let args = match file { + Some(file_path) => fs::read_to_string(file_path)? .lines() .map(str::trim) .filter(|line| !line.is_empty()) .map(String::from) - .collect() - } else { - args + .collect(), + None => args, }; - sh_println!("{}", SimpleCast::calldata_encode(sig, &final_args)?)?; + print_scalar(hex::encode_prefixed(encode_function_args(&get_func(&sig)?, &args)?))?; } - CastSubcommand::DecodeString { data } => { - let tokens = SimpleCast::calldata_decode("Any(string)", &data, true)?; - print_tokens(&tokens); + CastSubcommand::Abi(AbiSubcommand::DecodeString { data }) => { + print_tokens(&abi_decode_calldata("Any(string)", &data, true, true)?)?; } - CastSubcommand::DecodeEvent { sig, data } => { - let decoded_event = if let Some(event_sig) = sig { - let event = get_event(event_sig.as_str())?; - event.decode_log_parts(core::iter::once(event.selector()), &hex::decode(data)?)? + CastSubcommand::Abi(AbiSubcommand::DecodeEvent { sig, data }) => { + let decoded_values = if let Some(event_sig) = sig { + let event = get_event(&event_sig)?; + abi_decode_event_data(&event, &hex::decode(data)?)? } else { - let data = data.strip_prefix("0x").unwrap_or(data.as_str()); - let selector = data.get(..64).unwrap_or_default(); - let selector = selector.parse()?; - let identified_event = - SignaturesIdentifier::new(false)?.identify_event(selector).await; - if let Some(event) = identified_event { - let _ = sh_println!("{}", event.signature()); - let data = data.get(64..).unwrap_or_default(); - get_event(event.signature().as_str())? - .decode_log_parts(core::iter::once(selector), &hex::decode(data)?)? - } else { - eyre::bail!("No matching event signature found for selector `{selector}`") - } + let data = strip_0x(&data); + let selector: B256 = data.get(..64).unwrap_or_default().parse()?; + let Some(event) = SignaturesIdentifier::new(false)?.identify_event(selector).await + else { + eyre::bail!("No matching event signature found for selector `{selector}`"); + }; + let _ = sh_println!("{}", event.signature()); + let data = data.get(64..).unwrap_or_default(); + get_event(&event.signature())? + .decode_log_parts(core::iter::once(selector), &hex::decode(data)?)? + .body }; - print_tokens(&decoded_event.body); + print_tokens(&decoded_values)?; } - CastSubcommand::DecodeError { sig, data } => { + CastSubcommand::Abi(AbiSubcommand::DecodeError { sig, data }) => { let error = if let Some(err_sig) = sig { - get_error(err_sig.as_str())? + get_error(&err_sig)? } else { - let data = data.strip_prefix("0x").unwrap_or(data.as_str()); + let data = strip_0x(&data); let selector = data.get(..8).unwrap_or_default(); - let identified_error = - SignaturesIdentifier::new(false)?.identify_error(selector.parse()?).await; - if let Some(error) = identified_error { - let _ = sh_println!("{}", error.signature()); - error - } else { - eyre::bail!("No matching error signature found for selector `{selector}`") - } + let Some(error) = + SignaturesIdentifier::new(false)?.identify_error(selector.parse()?).await + else { + eyre::bail!("No matching error signature found for selector `{selector}`"); + }; + let _ = sh_println!("{}", error.signature()); + error }; - let decoded_error = error.decode_error(&hex::decode(data)?)?; - print_tokens(&decoded_error.body); - } - CastSubcommand::Interface(cmd) => cmd.run().await?, - CastSubcommand::CreationCode(cmd) => cmd.run().await?, - CastSubcommand::ConstructorArgs(cmd) => cmd.run().await?, - CastSubcommand::Artifact(cmd) => cmd.run().await?, - CastSubcommand::Bind(cmd) => cmd.run().await?, - CastSubcommand::PrettyCalldata { calldata, offline } => { + print_tokens(&error.decode_error(&hex::decode(data)?)?.body)?; + } + CastSubcommand::Abi(AbiSubcommand::Interface(cmd)) => cmd.run().await?, + CastSubcommand::Abi(AbiSubcommand::CreationCode(cmd)) => cmd.run().await?, + CastSubcommand::Abi(AbiSubcommand::ConstructorArgs(cmd)) => cmd.run().await?, + CastSubcommand::Abi(AbiSubcommand::Artifact(cmd)) => cmd.run().await?, + CastSubcommand::Abi(AbiSubcommand::Bind(cmd)) => cmd.run().await?, + CastSubcommand::Abi(AbiSubcommand::B2EPayload(cmd)) => cmd.run().await?, + CastSubcommand::Abi(AbiSubcommand::PrettyCalldata { calldata, offline }) => { let calldata = stdin::unwrap_line(calldata)?; - sh_println!("{}", pretty_calldata(&calldata, offline).await?)?; + print_scalar(pretty_calldata(&calldata, offline).await?.to_string())?; } - CastSubcommand::Sig { sig, optimize } => { + // JSON: --optimize conflicts with --json at the clap level; optimize=None uses print_scalar + CastSubcommand::Abi(AbiSubcommand::Sig { sig, optimize }) => { let sig = stdin::unwrap_line(sig)?; match optimize { Some(opt) => { - sh_println!("Starting to optimize signature...")?; + sh_status!("Starting to optimize signature...")?; let start_time = Instant::now(); - let (selector, signature) = SimpleCast::get_selector(&sig, opt)?; - sh_println!("Successfully generated in {:?}", start_time.elapsed())?; + let (selector, signature) = get_selector(&sig, opt)?; + sh_status!("Successfully generated in {:?}", start_time.elapsed())?; sh_println!("Selector: {selector}")?; sh_println!("Optimized signature: {signature}")?; } - None => sh_println!("{}", SimpleCast::get_selector(&sig, 0)?.0)?, + None => print_scalar(get_selector(&sig, 0)?.0)?, } } // Blockchain & RPC queries - CastSubcommand::AccessList(cmd) => cmd.run().await?, - CastSubcommand::Age { block, rpc } => { - let config = rpc.load_config()?; - let provider = utils::get_provider(&config)?; - sh_println!( - "{} UTC", - Cast::new(provider).age(block.unwrap_or(BlockId::Number(Latest))).await? - )? - } - CastSubcommand::Balance { block, who, ether, rpc, erc20 } => { - let config = rpc.load_config()?; - let provider = utils::get_provider(&config)?; - let account_addr = who.resolve(&provider).await?; + CastSubcommand::Query(QuerySubcommand::AccessList(cmd)) => cmd.run().await?, + CastSubcommand::Query(QuerySubcommand::Age { block, rpc }) => { + let timestamp = rpc_provider(&rpc)? + .get_block(block.unwrap_or_default()) + .await? + .ok_or_eyre("block not found")? + .header + .timestamp; + let age = i64::try_from(timestamp) + .ok() + .and_then(|timestamp| chrono::DateTime::from_timestamp(timestamp, 0)) + .ok_or_eyre("invalid timestamp")? + .format("%a %b %e %H:%M:%S %Y"); + print_scalar(format!("{age} UTC"))?; + } + CastSubcommand::Query(QuerySubcommand::Balance { + block, + who, + ether, + rpc, + erc20, + overrides, + }) => { + if erc20.is_none() && !overrides.is_empty() { + eyre::bail!("call overrides require `--erc20` when using `cast balance`"); + } + let (provider, account_addr) = rpc_provider_and_address(&rpc, who).await?; match erc20 { Some(token) => { - let balance = - Cast::new(&provider).erc20_balance(token, account_addr, block).await?; - sh_println!("{}", format_uint_exp(balance))? + let token = IERC20::new(token, &provider); + let balance_call = + token.balanceOf(account_addr).block(block.unwrap_or_default()); + let balance = overrides.apply(balance_call.call())?.await?; + + sh_warn!("--erc20 flag is deprecated, use `cast erc20 balance` instead")?; + print_scalar(format_uint_exp(balance))?; } None => { - let value = Cast::new(&provider).balance(account_addr, block).await?; - if ether { - sh_println!("{}", SimpleCast::from_wei(&value.to_string(), "eth")?)? + let value = provider + .get_balance(account_addr) + .block_id(block.unwrap_or_default()) + .await?; + let out = if ether { + ParseUnits::U256(value).format_units(Unit::ETHER) } else { - sh_println!("{value}")? - } + value.to_string() + }; + print_scalar(out)?; } } } - CastSubcommand::BaseFee { block, rpc } => { - let config = rpc.load_config()?; - let provider = utils::get_provider(&config)?; - sh_println!( - "{}", - Cast::new(provider).base_fee(block.unwrap_or(BlockId::Number(Latest))).await? - )? - } - CastSubcommand::Block { block, full, field, raw, rpc } => { + CastSubcommand::Query(QuerySubcommand::BaseFee { block, rpc }) => { + let fee = rpc_provider(&rpc)? + .get_block(block.unwrap_or_default()) + .await? + .ok_or_eyre("block not found")? + .header + .base_fee_per_gas + .ok_or_eyre("base fee not found")?; + print_scalar(fee.to_string())?; + } + CastSubcommand::Query(QuerySubcommand::Block { + block, + full, + fields, + raw, + rpc, + network, + }) => { let config = rpc.load_config()?; - let provider = utils::get_provider(&config)?; - + #[cfg(feature = "base")] + let network = if network.is_none() && (raw || fields.iter().any(|f| f == "raw")) { + crate::cmd::resolve_transaction_network(&config, false) + .await? + .is_base() + .then_some(NetworkVariant::Base) + } else { + network + }; + let block = block.unwrap_or_default(); // Can use either --raw or specify raw as a field - let raw = raw || field.as_ref().is_some_and(|f| f == "raw"); + let output = if raw || fields.contains(&"raw".into()) { + with_network_provider!( + network, + &config, + ProviderBuilder::::from_config(&config)?.build()?, + |provider, N| { + let block_id = block; + let block = provider + .get_block(block_id) + .kind(full.into()) + .await? + .ok_or_else(|| eyre::eyre!("block {:?} not found", block_id))?; + hex::encode_prefixed(alloy_rlp::encode( + AsRef::<::Header>::as_ref(block.header()), + )) + } + ) + } else { + let provider = utils::get_provider(&config)?; + if fields.contains(&"transactions".into()) && !full { + eyre::bail!("use --full to view transactions"); + } - sh_println!( - "{}", - Cast::new(provider) - .block(block.unwrap_or(BlockId::Number(Latest)), full, field, raw) + let block = provider + .get_block(block) + .kind(full.into()) .await? - )? + .ok_or_else(|| eyre::eyre!("block {:?} not found", block))?; + + if !fields.is_empty() { + let mut result = String::new(); + for field in fields { + result.push_str( + &get_pretty_block_attr::(&block, &field) + .unwrap_or_else(|| format!("{field} is not a valid block field")), + ); + + result.push('\n'); + } + result.trim_end().to_string() + } else if shell::is_json() { + serde_json::to_value(&block).unwrap().to_string() + } else { + block.pretty() + } + }; + print_json_value_or_scalar(output)?; } - CastSubcommand::BlockNumber { rpc, block } => { - let config = rpc.load_config()?; - let provider = utils::get_provider(&config)?; + CastSubcommand::Query(QuerySubcommand::BlockNumber { rpc, block }) => { + let provider = rpc_provider(&rpc)?; let number = match block { Some(id) => { provider @@ -340,65 +660,204 @@ pub async fn run_command(args: CastArgs) -> Result<()> { .header .number } - None => Cast::new(provider).block_number().await?, + None => provider.get_block_number().await?, }; - sh_println!("{number}")? - } - CastSubcommand::Chain { rpc } => { - let config = rpc.load_config()?; - let provider = utils::get_provider(&config)?; - sh_println!("{}", Cast::new(provider).chain().await?)? - } - CastSubcommand::ChainId { rpc } => { - let config = rpc.load_config()?; - let provider = utils::get_provider(&config)?; - sh_println!("{}", Cast::new(provider).chain_id().await?)? + print_scalar(number)?; + } + CastSubcommand::Query(QuerySubcommand::Bal(cmd)) => cmd.run().await?, + CastSubcommand::Query(QuerySubcommand::Chain { rpc }) => { + let provider = rpc_provider(&rpc)?; + const GENESIS_CHAINS: &[(&str, &str)] = &[ + ("0xa3c565fc15c7478862d50ccd6561e3c06b24cc509bf388941c25ea985ce32cb9", "kovan"), + ("0x41941023680923e0fe4d74a34bdac8141f2540e3ae90623718e47d66d1ca4a2d", "ropsten"), + ( + "0x7ca38a1916c42007829c55e69d3e9a73265554b586a499015373241b8a3fa48b", + "optimism-mainnet", + ), + ( + "0xc1fc15cd51159b1f1e5cbc4b82e85c1447ddfa33c52cf1d98d14fba0d6354be1", + "optimism-goerli", + ), + ( + "0x02adc9b449ff5f2467b8c674ece7ff9b21319d76c4ad62a67a70d552655927e5", + "optimism-kovan", + ), + ("0x521982bd54239dc71269eefb58601762cc15cfb2978e0becb46af7962ed6bfaa", "fraxtal"), + ( + "0x910f5c4084b63fd860d0c2f9a04615115a5a991254700b39ba072290dbd77489", + "fraxtal-testnet", + ), + ( + "0x7ee576b35482195fc49205cec9af72ce14f003b9ae69f6ba0faef4514be8b442", + "arbitrum-mainnet", + ), + ("0x0cd786a2425d16f152c658316c423e6ce1181e15c3295826d7c9904cba9ce303", "morden"), + ("0x6341fd3daf94b748c72ced5a5b26028f2474f5f00d824504e4fa37a75767e177", "rinkeby"), + ("0xbf7e331f7f7c1dd2e05159666b3bf8bc7a8a3a9eb1d518969eab529dd9b88c1a", "goerli"), + ("0x14c2283285a88fe5fce9bf5c573ab03d6616695d717b12a127188bcacfc743c4", "kotti"), + ( + "0xa9c28ce2141b56c474f1dc504bee9b01eb1bd7d1a507580d5519d4437a97de1b", + "polygon-pos", + ), + ( + "0x7202b2b53c5a0836e773e319d18922cc756dd67432f9a1f65352b61f4406c697", + "polygon-pos-amoy-testnet", + ), + ( + "0x81005434635456a16f74ff7023fbe0bf423abbc8a8deb093ffff455c0ad3b741", + "polygon-zkevm", + ), + ( + "0x676c1a76a6c5855a32bdf7c61977a0d1510088a4eeac1330466453b3d08b60b9", + "polygon-zkevm-cardona-testnet", + ), + ("0x4f1dd23188aab3a76b463e4af801b52b1248ef073c648cbdc4c9333d3da79756", "gnosis"), + ("0xada44fd8d2ecab8b08f256af07ad3e777f17fb434f8f8e678b312f576212ba9a", "chiado"), + ("0x6d3c66c5357ec91d5c43af47e234a939b22557cbb552dc45bebbceeed90fbe34", "bsctest"), + ("0x0d21840abff46b96c84b2ac9e10e4f5cdaeb5693cb665db62a2f3b02d2d57b5b", "bsc"), + ("0x23a2658170ba70d014ba0d0d2709f8fbfe2fa660cd868c5f282f991eecbe38ee", "ink"), + ( + "0xe5fd5cf0be56af58ad5751b401410d6b7a09d830fa459789746a3d0dd1c79834", + "ink-sepolia", + ), + ]; + + let genesis_hash = provider + .get_block_by_number(0.into()) + .await? + .ok_or_eyre("block not found")? + .header + .hash + .to_string(); + let chain = match genesis_hash.as_str() { + // Ethereum and Ethereum Classic share the genesis block and split at the DAO fork. + "0xd4e56740f876aef8c010b86a40d5f56745a118d0906a34e69aec8c0db1cb8fa3" => { + match provider + .get_block_by_number(1920000.into()) + .await? + .ok_or_eyre("block not found")? + .header + .hash + .to_string() + .as_str() + { + "0x94365e3a8c0b35089c1d1195081fe7489b528a84b22199c916180db8b28ade7f" => { + "etclive" + } + _ => "ethlive", + } + } + // Avalanche and Fuji share the genesis block. + "0x31ced5b9beb7f8782b014660da0cb18cc409f121f408186886e1ca3e8eeca96b" => { + match provider + .get_block_by_number(1.into()) + .await? + .ok_or_eyre("block not found")? + .header + .hash + .to_string() + .as_str() + { + "0x738639479dc82d199365626f90caa82f7eafcfe9ed354b456fb3d294597ceb53" => { + "avalanche-fuji" + } + _ => "avalanche", + } + } + hash => GENESIS_CHAINS + .iter() + .find(|(genesis, _)| *genesis == hash) + .map_or("unknown", |(_, chain)| chain), + }; + print_scalar(chain)?; } - CastSubcommand::Client { rpc } => { - let config = rpc.load_config()?; - let provider = utils::get_provider(&config)?; - sh_println!("{}", provider.get_client_version().await?)? + CastSubcommand::Query(QuerySubcommand::ChainId { rpc }) => { + print_scalar(rpc_provider(&rpc)?.get_chain_id().await?.to_string())?; } - CastSubcommand::Code { block, who, disassemble, rpc } => { - let config = rpc.load_config()?; - let provider = utils::get_provider(&config)?; - let who = who.resolve(&provider).await?; - sh_println!("{}", Cast::new(provider).code(who, block, disassemble).await?)? + CastSubcommand::Query(QuerySubcommand::Client { rpc }) => { + print_scalar(rpc_provider(&rpc)?.get_client_version().await?)?; } - CastSubcommand::Codesize { block, who, rpc } => { - let config = rpc.load_config()?; - let provider = utils::get_provider(&config)?; - let who = who.resolve(&provider).await?; - sh_println!("{}", Cast::new(provider).codesize(who, block).await?)? + CastSubcommand::Query(QuerySubcommand::Code { block, who, disassemble, rpc }) => { + let (provider, who) = rpc_provider_and_address(&rpc, who).await?; + let code = provider.get_code_at(who).block_id(block.unwrap_or_default()).await?; + print_scalar(if disassemble { + crate::cmd::disassemble(&code)? + } else { + code.to_string() + })?; + } + CastSubcommand::Query(QuerySubcommand::Codesize { block, who, rpc }) => { + let (provider, who) = rpc_provider_and_address(&rpc, who).await?; + print_scalar( + provider + .get_code_at(who) + .block_id(block.unwrap_or_default()) + .await? + .len() + .to_string(), + )?; } - CastSubcommand::ComputeAddress { address, nonce, salt, init_code, init_code_hash, rpc } => { + CastSubcommand::Query(QuerySubcommand::ComputeAddress { + address, + nonce, + salt, + init_code, + init_code_hash, + rpc, + }) => { let address = stdin::unwrap_line(address)?; - let computed = { - // For CREATE2, init_code_hash is needed to compute the address - if let Some(init_code_hash) = init_code_hash { - address.create2(salt.unwrap_or(B256::ZERO), init_code_hash) - } else if let Some(init_code) = init_code { - address.create2(salt.unwrap_or(B256::ZERO), keccak256(hex::decode(init_code)?)) - } else { - // For CREATE, rpc is needed to compute the address - let config = rpc.load_config()?; - let provider = utils::get_provider(&config)?; - Cast::new(provider).compute_address(address, nonce).await? - } + let salt = salt.unwrap_or(B256::ZERO); + let computed = if let Some(init_code_hash) = init_code_hash { + address.create2(salt, init_code_hash) + } else if let Some(init_code) = init_code { + address.create2(salt, keccak256(hex::decode(init_code)?)) + } else { + // CREATE addresses depend on the deployer nonce, which is fetched over RPC. + let nonce = match nonce { + Some(nonce) => nonce, + None => rpc_provider(&rpc)?.get_transaction_count(address).await?, + }; + address.create(nonce) }; - sh_println!("Computed Address: {}", computed.to_checksum(None))? + print_scalar(computed.to_checksum(None))?; } - CastSubcommand::Disassemble { bytecode } => { + CastSubcommand::Query(QuerySubcommand::Disassemble { bytecode }) => { let bytecode = stdin::unwrap_line(bytecode)?; - sh_println!("{}", SimpleCast::disassemble(&hex::decode(bytecode)?)?)? + print_scalar(crate::cmd::disassemble(&hex::decode(bytecode)?)?)?; } - CastSubcommand::Selectors { bytecode, resolve } => { + CastSubcommand::Query(QuerySubcommand::Selectors { bytecode, resolve }) => { let bytecode = stdin::unwrap_line(bytecode)?; - let functions = SimpleCast::extract_functions(&bytecode)?; - let max_args_len = functions.iter().map(|r| r.1.len()).max().unwrap_or(0); - let max_mutability_len = functions.iter().map(|r| r.2.len()).max().unwrap_or(0); + let code = hex::decode(&bytecode)?; + let info = evmole::contract_info( + evmole::ContractInfoArgs::new(&code) + .with_selectors() + .with_arguments() + .with_state_mutability(), + ); + let functions = info + .functions + .expect("functions extraction was requested") + .into_iter() + .filter(|f| f.dispatch == evmole::SelectorDispatch::Abi) + .map(|f| { + let arguments = f + .arguments + .expect("arguments extraction was requested") + .iter() + .map(|t| t.sol_type_name()) + .collect::>() + .join(","); + let mutability = + f.state_mutability.expect("state_mutability extraction was requested"); + ( + alloy_primitives::Selector::from(f.selector), + arguments, + mutability.as_json_str(), + ) + }) + .collect::>(); - let resolve_results = if resolve { + let resolve_results: Vec = if resolve { let selectors = functions .iter() .map(|&(selector, ..)| SelectorKind::Function(selector)) @@ -408,131 +867,296 @@ pub async fn run_command(args: CastArgs) -> Result<()> { } else { vec![] }; - for (pos, (selector, arguments, state_mutability)) in functions.into_iter().enumerate() - { - if resolve { - let resolved = &resolve_results[pos]; - sh_println!( - "{selector}\t{arguments:max_args_len$}\t{state_mutability:max_mutability_len$}\t{resolved}" - )? - } else { - sh_println!("{selector}\t{arguments:max_args_len$}\t{state_mutability}")? + + if shell::is_json() { + #[derive(serde::Serialize)] + struct SelectorInfo { + selector: String, + arguments: String, + state_mutability: String, + #[serde(skip_serializing_if = "Option::is_none")] + resolved: Option, + } + let infos = functions + .into_iter() + .enumerate() + .map(|(pos, (selector, arguments, state_mutability))| SelectorInfo { + selector: selector.to_string(), + arguments, + state_mutability: state_mutability.to_string(), + resolved: resolve_results.get(pos).cloned(), + }) + .collect::>(); + print_json_object(infos)?; + } else { + let max_args_len = functions.iter().map(|r| r.1.len()).max().unwrap_or(0); + let max_mutability_len = functions.iter().map(|r| r.2.len()).max().unwrap_or(0); + for (pos, (selector, arguments, state_mutability)) in + functions.into_iter().enumerate() + { + if resolve { + let resolved = &resolve_results[pos]; + sh_println!( + "{selector}\t{arguments:max_args_len$}\t{state_mutability:max_mutability_len$}\t{resolved}" + )? + } else { + sh_println!("{selector}\t{arguments:max_args_len$}\t{state_mutability}")? + } } } } - CastSubcommand::FindBlock(cmd) => cmd.run().await?, - CastSubcommand::GasPrice { rpc } => { - let config = rpc.load_config()?; - let provider = utils::get_provider(&config)?; - sh_println!("{}", Cast::new(provider).gas_price().await?)?; + CastSubcommand::Query(QuerySubcommand::FindBlock(cmd)) => cmd.run().await?, + CastSubcommand::Query(QuerySubcommand::GasPrice { rpc }) => { + print_scalar(rpc_provider(&rpc)?.get_gas_price().await?.to_string())?; } - CastSubcommand::Index { key_type, key, slot_number } => { - sh_println!("{}", SimpleCast::index(&key_type, &key, &slot_number)?)?; + CastSubcommand::Query(QuerySubcommand::Index { key_type, key, slot_number }) => { + let mut hasher = Keccak256::new(); + + let k_ty = DynSolType::parse(&key_type).wrap_err("Could not parse type")?; + let k = k_ty.coerce_str(&key).wrap_err("Could not parse value")?; + match k_ty { + // For value types, `h` pads the value to 32 bytes in the same way as when storing + // the value in memory. + DynSolType::Bool + | DynSolType::Int(_) + | DynSolType::Uint(_) + | DynSolType::FixedBytes(_) + | DynSolType::Address + | DynSolType::Function => hasher.update(k.as_word().unwrap()), + + // For strings and byte arrays, `h(k)` is just the unpadded data. + DynSolType::String | DynSolType::Bytes => hasher.update(k.as_packed_seq().unwrap()), + + DynSolType::Array(..) + | DynSolType::FixedArray(..) + | DynSolType::Tuple(..) + | DynSolType::CustomStruct { .. } => { + eyre::bail!("Type `{k_ty}` is not supported as a mapping key"); + } + } + + let p = DynSolType::Uint(256) + .coerce_str(&slot_number) + .wrap_err("Could not parse slot number")?; + let p = p.as_word().unwrap(); + hasher.update(p); + + let location = hasher.finalize(); + print_scalar(location.to_string())?; } - CastSubcommand::IndexErc7201 { id, formula_id } => { + CastSubcommand::Query(QuerySubcommand::IndexErc7201 { id, formula_id }) => { eyre::ensure!(formula_id == "erc7201", "unsupported formula ID: {formula_id}"); let id = stdin::unwrap_line(id)?; - sh_println!("{}", foundry_common::erc7201(&id))?; - } - CastSubcommand::Implementation { block, beacon, who, rpc } => { - let config = rpc.load_config()?; - let provider = utils::get_provider(&config)?; - let who = who.resolve(&provider).await?; - sh_println!("{}", Cast::new(provider).implementation(who, beacon, block).await?)?; - } - CastSubcommand::Admin { block, who, rpc } => { - let config = rpc.load_config()?; - let provider = utils::get_provider(&config)?; - let who = who.resolve(&provider).await?; - sh_println!("{}", Cast::new(provider).admin(who, block).await?)?; - } - CastSubcommand::Nonce { block, who, rpc } => { - let config = rpc.load_config()?; - let provider = utils::get_provider(&config)?; - let who = who.resolve(&provider).await?; - sh_println!("{}", Cast::new(provider).nonce(who, block).await?)?; - } - CastSubcommand::Codehash { block, who, slots, rpc } => { - let config = rpc.load_config()?; - let provider = utils::get_provider(&config)?; - let who = who.resolve(&provider).await?; - sh_println!("{}", Cast::new(provider).codehash(who, slots, block).await?)?; - } - CastSubcommand::StorageRoot { block, who, slots, rpc } => { - let config = rpc.load_config()?; - let provider = utils::get_provider(&config)?; - let who = who.resolve(&provider).await?; - sh_println!("{}", Cast::new(provider).storage_root(who, slots, block).await?)?; - } - CastSubcommand::Proof { address, slots, rpc, block } => { - let config = rpc.load_config()?; - let provider = utils::get_provider(&config)?; - let address = address.resolve(&provider).await?; - let value = provider - .get_proof(address, slots.into_iter().collect()) - .block_id(block.unwrap_or_default()) + print_scalar(foundry_common::erc7201(&id).to_string())?; + } + CastSubcommand::Query(QuerySubcommand::Implementation { block, beacon, who, rpc }) => { + let (provider, who) = rpc_provider_and_address(&rpc, who).await?; + // bytes32(uint256(keccak256('eip1967.proxy.beacon')) - 1) + const BEACON_SLOT: B256 = + b256!("0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50"); + // bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1) + const IMPLEMENTATION_SLOT: B256 = + b256!("0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc"); + + let slot = if beacon { BEACON_SLOT } else { IMPLEMENTATION_SLOT }; + print_scalar(address_at_slot(&provider, who, slot, block).await?)?; + } + CastSubcommand::Query(QuerySubcommand::Admin { block, who, rpc }) => { + let (provider, who) = rpc_provider_and_address(&rpc, who).await?; + // bytes32(uint256(keccak256('eip1967.proxy.admin')) - 1) + const ADMIN_SLOT: B256 = + b256!("0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103"); + print_scalar(address_at_slot(&provider, who, ADMIN_SLOT, block).await?)?; + } + CastSubcommand::Query(QuerySubcommand::Nonce { block, who, rpc }) => { + let (provider, who) = rpc_provider_and_address(&rpc, who).await?; + print_scalar( + provider.get_transaction_count(who).block_id(block.unwrap_or_default()).await?, + )?; + } + CastSubcommand::Query(QuerySubcommand::Codehash { block, who, slots, rpc }) => { + let (provider, who) = rpc_provider_and_address(&rpc, who).await?; + print_scalar( + provider + .get_proof(who, slots) + .block_id(block.unwrap_or_default()) + .await? + .code_hash + .to_string(), + )?; + } + CastSubcommand::Query(QuerySubcommand::StorageRoot { block, who, slots, rpc }) => { + let (provider, who) = rpc_provider_and_address(&rpc, who).await?; + print_scalar( + provider + .get_proof(who, slots) + .block_id(block.unwrap_or_default()) + .await? + .storage_hash + .to_string(), + )?; + } + CastSubcommand::Query(QuerySubcommand::ChannelId { + payer, + payee, + token, + salt, + operator, + authorized_signer, + expiring_nonce_hash, + reserve, + block, + rpc, + }) => { + let provider = rpc_provider(&rpc)?; + let payer = payer.resolve(&provider).await?; + let payee = payee.resolve(&provider).await?; + let token = token.resolve(&provider).await?; + let operator = resolve_or(operator, Address::ZERO, &provider).await?; + let authorized_signer = resolve_or(authorized_signer, Address::ZERO, &provider).await?; + let reserve = resolve_or(reserve, TIP20_CHANNEL_RESERVE_ADDRESS, &provider).await?; + + let channel_id = ITIP20ChannelReserve::new(reserve, &provider) + .computeChannelId( + payer, + payee, + operator, + token, + salt, + authorized_signer, + expiring_nonce_hash, + ) + .block(block.unwrap_or_default()) + .call() .await?; - sh_println!("{}", serde_json::to_string(&value)?)?; + print_scalar(format!("{channel_id:#x}"))?; + } + CastSubcommand::Query(QuerySubcommand::Proof { address, slots, rpc, block }) => { + let (provider, address) = rpc_provider_and_address(&rpc, address).await?; + let value = + provider.get_proof(address, slots).block_id(block.unwrap_or_default()).await?; + print_json_object(value)?; } - CastSubcommand::Rpc(cmd) => cmd.run().await?, - CastSubcommand::Storage(cmd) => cmd.run().await?, + CastSubcommand::Query(QuerySubcommand::Rpc(cmd)) => cmd.run().await?, + CastSubcommand::Query(QuerySubcommand::Storage(cmd)) => cmd.run().await?, // Calls & transactions - CastSubcommand::Call(cmd) => cmd.run().await?, - CastSubcommand::Estimate(cmd) => cmd.run().await?, - CastSubcommand::MakeTx(cmd) => cmd.run().await?, - CastSubcommand::PublishTx { raw_tx, cast_async, rpc } => { - let config = rpc.load_config()?; - let provider = utils::get_provider(&config)?; - let cast = Cast::new(&provider); - let pending_tx = cast.publish(raw_tx).await?; - let tx_hash = pending_tx.inner().tx_hash(); - + CastSubcommand::Transaction(TransactionSubcommand::Call(cmd)) => cmd.run().await?, + CastSubcommand::Transaction(TransactionSubcommand::Estimate(cmd)) => cmd.run().await?, + CastSubcommand::Transaction(TransactionSubcommand::MakeTx(cmd)) => cmd.run().await?, + CastSubcommand::Transaction(TransactionSubcommand::PublishTx { + raw_tx, + cast_async, + rpc, + }) => { + let provider = rpc_provider(&rpc)?; + let raw_tx = hex::decode(strip_0x(&raw_tx))?; + let pending_tx = provider.send_raw_transaction(&raw_tx).await?; if cast_async { - sh_println!("{tx_hash:#x}")?; + print_scalar(format!("{:#x}", pending_tx.inner().tx_hash()))?; } else { - let receipt = pending_tx.get_receipt().await?; - sh_println!("{}", serde_json::json!(receipt))?; + print_json_object(pending_tx.get_receipt().await?)?; } } - CastSubcommand::Receipt { tx_hash, field, cast_async, confirmations, rpc } => { - let config = rpc.load_config()?; - let provider = utils::get_provider(&config)?; + CastSubcommand::Transaction(TransactionSubcommand::Receipt { + tx_hash, + field, + cast_async, + confirmations, + rpc, + }) => { + // JSON: The receipt helper already formats the output. sh_println!( "{}", - Cast::new(provider) + CastTxSender::new(rpc_provider(&rpc)?) .receipt(tx_hash, field, confirmations, None, cast_async) .await? )? } - CastSubcommand::Run(cmd) => cmd.run().await?, - CastSubcommand::SendTx(cmd) => cmd.run().await?, - CastSubcommand::Tx { tx_hash, from, nonce, field, raw, rpc } => { + CastSubcommand::Transaction(TransactionSubcommand::Run(cmd)) => cmd.run().await?, + CastSubcommand::Transaction(TransactionSubcommand::SendTx(cmd)) => cmd.run().await?, + CastSubcommand::Transaction(TransactionSubcommand::BatchMakeTx(cmd)) => cmd.run().await?, + CastSubcommand::Transaction(TransactionSubcommand::BatchSend(cmd)) => cmd.run().await?, + CastSubcommand::Transaction(TransactionSubcommand::Classify { raw_tx }) => { + let raw_tx = hex::decode(stdin::unwrap_line(raw_tx)?)?; + let out = format_lane_classification(&raw_tx, "failed to decode raw transaction")?; + print_json_value_or_scalar(out)? + } + CastSubcommand::Transaction(TransactionSubcommand::Tx { + tx_hash, + from, + nonce, + field, + raw, + lane, + rpc, + to_request, + network, + }) => { let config = rpc.load_config()?; - let provider = utils::get_provider(&config)?; - + #[cfg(feature = "base")] + let network = match network { + Some(network) => Some(network), + None => crate::cmd::resolve_transaction_network(&config, false) + .await? + .is_base() + .then_some(NetworkVariant::Base), + }; // Can use either --raw or specify raw as a field - let raw = raw || field.as_ref().is_some_and(|f| f == "raw"); - - sh_println!( - "{}", - Cast::new(&provider).transaction(tx_hash, from, nonce, field, raw).await? - )? + let is_raw = raw || field.as_deref() == Some("raw"); + let output = if is_raw || lane { + let encoded: Bytes = with_network_provider!( + network, + &config, + |provider| { + let tx = + transaction_response(&provider, tx_hash, from, nonce).await?; + tx.as_ref().encoded_2718().into() + }, + _ => { + let provider = utils::get_provider(&config)?; + let tx = + transaction_response(&provider, tx_hash, from, nonce).await?; + FoundryTxEnvelope::encode_rpc_2718(&tx).wrap_err_with(|| { + format!("Cannot EIP-2718 encode transaction type 0x{:x}", tx.ty()) + })? + } + ); + if lane { + format_lane_classification( + &encoded, + "failed to decode transaction for lane classification", + )? + } else { + hex::encode_prefixed(encoded) + } + } else { + with_network_provider!( + network, + &config, + utils::get_provider(&config)?, + |provider| { + let tx = transaction_response(&provider, tx_hash, from, nonce).await?; + format_transaction(&provider, tx, field, to_request)? + } + ) + }; + print_json_value_or_scalar(output)?; } // 4Byte - CastSubcommand::FourByte { selector } => { + CastSubcommand::Abi(AbiSubcommand::FourByte { selector }) => { let selector = stdin::unwrap_line(selector)?; let sigs = decode_function_selector(selector).await?; if sigs.is_empty() { eyre::bail!("No matching function signatures found for selector `{selector}`"); } - for sig in sigs { - sh_println!("{sig}")? - } + print_list(&sigs)?; } - CastSubcommand::FourByteCalldata { calldata } => { + // JSON envelope intentionally unsupported: output combines an interactive selector + // disambiguation step with decoded token output; no single stable shape exists. + CastSubcommand::Abi(AbiSubcommand::FourByteCalldata { calldata }) => { let calldata = stdin::unwrap_line(calldata)?; if calldata.len() == 10 { @@ -547,34 +1171,33 @@ pub async fn run_command(args: CastArgs) -> Result<()> { } let sigs = decode_calldata(&calldata).await?; - sigs.iter().enumerate().for_each(|(i, sig)| { + for (i, sig) in sigs.iter().enumerate() { let _ = sh_println!("{}) \"{sig}\"", i + 1); - }); + } let sig = match sigs.len() { 0 => eyre::bail!("No signatures found"), - 1 => sigs.first().unwrap(), + 1 => &sigs[0], _ => { let i: usize = prompt!("Select a function signature by number: ")?; sigs.get(i - 1).ok_or_else(|| eyre::eyre!("Invalid signature index"))? } }; - let tokens = SimpleCast::calldata_decode(sig, &calldata, true)?; - print_tokens(&tokens); + print_tokens(&abi_decode_calldata(sig, &calldata, true, true)?)?; } - CastSubcommand::FourByteEvent { topic } => { + CastSubcommand::Abi(AbiSubcommand::FourByteEvent { topic }) => { let topic = stdin::unwrap_line(topic)?; let sigs = decode_event_topic(topic).await?; if sigs.is_empty() { eyre::bail!("No matching event signatures found for topic `{topic}`"); } - for sig in sigs { - sh_println!("{sig}")? - } + print_list(&sigs)?; } - CastSubcommand::UploadSignature { signatures } => { + // JSON envelope intentionally unsupported: output is a human-readable summary from an + // external selector registry API with no stable machine-readable schema. + CastSubcommand::Abi(AbiSubcommand::UploadSignature { signatures }) => { let signatures = stdin::unwrap_vec(signatures)?; let ParsedSignatures { signatures, abis } = parse_signatures(signatures); if !abis.is_empty() { @@ -586,14 +1209,11 @@ pub async fn run_command(args: CastArgs) -> Result<()> { } // ENS - CastSubcommand::Namehash { name } => { - let name = stdin::unwrap_line(name)?; - sh_println!("{}", namehash(&name))? + CastSubcommand::Query(QuerySubcommand::Namehash { name }) => { + print_scalar(namehash(&stdin::unwrap_line(name)?).to_string())?; } - CastSubcommand::LookupAddress { who, rpc, verify } => { - let config = rpc.load_config()?; - let provider = utils::get_provider(&config)?; - + CastSubcommand::Query(QuerySubcommand::LookupAddress { who, rpc, verify }) => { + let provider = rpc_provider(&rpc)?; let who = stdin::unwrap_line(who)?; let name = provider.lookup_address(&who).await?; if verify { @@ -603,14 +1223,15 @@ pub async fn run_command(args: CastArgs) -> Result<()> { "Reverse lookup verification failed: got `{address}`, expected `{who}`" ); } - sh_println!("{name}")? + print_scalar(name)?; } - CastSubcommand::ResolveName { who, rpc, verify } => { - let config = rpc.load_config()?; - let provider = utils::get_provider(&config)?; - + CastSubcommand::Query(QuerySubcommand::ResolveName { who, rpc, verify }) => { + let provider = rpc_provider(&rpc)?; let who = stdin::unwrap_line(who)?; - let address = provider.resolve_name(&who).await?; + let address = provider + .resolve_name(&who) + .await + .wrap_err(format!("Failed to resolve ENS name: {who}"))?; if verify { let name = provider.lookup_address(&address).await?; eyre::ensure!( @@ -618,141 +1239,519 @@ pub async fn run_command(args: CastArgs) -> Result<()> { "Forward lookup verification failed: got `{name}`, expected `{who}`" ); } - sh_println!("{address}")? + print_scalar(address.to_string())?; } // Misc - CastSubcommand::Keccak { data } => { + CastSubcommand::Misc(MiscSubcommand::Keccak { data }) => { let bytes = match data { Some(data) => data.into_bytes(), None => stdin::read_bytes(false)?, }; - match String::from_utf8(bytes) { + let out = match String::from_utf8(bytes) { Ok(s) => { - let s = SimpleCast::keccak(&s)?; - sh_println!("{s}")? - } - Err(e) => { - let hash = keccak256(e.as_bytes()); - let s = hex::encode(hash); - sh_println!("0x{s}")? + // Hex-decode if data starts with 0x. + if s.starts_with("0x") { + keccak256(hex::decode(s.trim_end())?) + } else { + keccak256(s) + } + .to_string() } + Err(e) => hex::encode_prefixed(keccak256(e.as_bytes())), }; + print_scalar(out)?; + } + CastSubcommand::Misc(MiscSubcommand::HashMessage { message }) => { + print_scalar(eip191_hash_message(stdin::unwrap(message, false)?).to_string())?; + } + CastSubcommand::Misc(MiscSubcommand::SigEvent { event_string }) => { + let event = get_event(&stdin::unwrap_line(event_string)?)?; + print_scalar(format!("{:?}", event.selector()))?; + } + CastSubcommand::Misc(MiscSubcommand::LeftShift { value, bits, base_in, base_out }) => { + print_scalar(shift(&value, &bits, base_in.as_deref(), &base_out, |value, bits| { + value << bits + })?)?; } - CastSubcommand::HashMessage { message } => { - let message = stdin::unwrap(message, false)?; - sh_println!("{}", eip191_hash_message(message))? - } - CastSubcommand::SigEvent { event_string } => { - let event_string = stdin::unwrap_line(event_string)?; - let parsed_event = get_event(&event_string)?; - sh_println!("{:?}", parsed_event.selector())? - } - CastSubcommand::LeftShift { value, bits, base_in, base_out } => sh_println!( - "{}", - SimpleCast::left_shift(&value, &bits, base_in.as_deref(), &base_out)? - )?, - CastSubcommand::RightShift { value, bits, base_in, base_out } => sh_println!( - "{}", - SimpleCast::right_shift(&value, &bits, base_in.as_deref(), &base_out)? - )?, - CastSubcommand::Source { + CastSubcommand::Misc(MiscSubcommand::RightShift { value, bits, base_in, base_out }) => { + print_scalar(shift(&value, &bits, base_in.as_deref(), &base_out, |value, bits| { + value.wrapping_shr(bits.saturating_to()) + })?)?; + } + // TODO(json): multi-line source code or directory expansion, needs structured envelope + CastSubcommand::Misc(MiscSubcommand::Source { address, directory, explorer_api_url, explorer_url, etherscan, flatten, - } => { + }) => { let config = etherscan.load_config()?; let chain = config.chain.unwrap_or_default(); let api_key = config.get_etherscan_api_key(Some(chain)); + let client = explorer_client(chain, api_key, explorer_api_url, explorer_url)?; + let metadata = client.contract_source_code(address.parse()?).await?; match (directory, flatten) { (Some(dir), false) => { - SimpleCast::expand_etherscan_source_to_directory( - chain, - address, - api_key, - dir, - explorer_api_url, - explorer_url, - ) - .await? + metadata.source_tree().write_to(&dir)?; } - (None, false) => sh_println!( - "{}", - SimpleCast::etherscan_source( - chain, - address, - api_key, - explorer_api_url, - explorer_url - ) - .await? - )?, + (None, false) => sh_println!("{}", metadata.source_code())?, (dir, true) => { - SimpleCast::etherscan_source_flatten( - chain, - address, - api_key, - dir, - explorer_api_url, - explorer_url, - ) - .await?; + let Some(metadata) = metadata.items.first() else { + eyre::bail!("Empty contract source code"); + }; + + let tmp = tempfile::tempdir()?; + let project = foundry_common::compile::etherscan_project(metadata, tmp.path())?; + let target_path = project.find_contract_path(&metadata.contract_name)?; + + let flattened = foundry_common::flatten(project, &target_path)?; + + if let Some(path) = dir { + fs::create_dir_all(path.parent().unwrap())?; + fs::write(&path, flattened)?; + sh_status!("Flattened file written at {}", path.display())? + } else { + sh_println!("{flattened}")? + } } } } - CastSubcommand::Create2(cmd) => { - cmd.run()?; - } - CastSubcommand::Wallet { command } => command.run().await?, - CastSubcommand::Completions { shell } => { + CastSubcommand::Misc(MiscSubcommand::Create2(cmd)) => cmd.execute()?, + CastSubcommand::Misc(MiscSubcommand::Wallet { command }) => command.run().await?, + CastSubcommand::Misc(MiscSubcommand::Safe { command }) => command.run().await?, + CastSubcommand::Misc(MiscSubcommand::Completions { shell }) => { generate(shell, &mut CastArgs::command(), "cast", &mut std::io::stdout()) } - CastSubcommand::GenerateFigSpec => clap_complete::generate( - clap_complete_fig::Fig, - &mut CastArgs::command(), - "cast", - &mut std::io::stdout(), - ), - CastSubcommand::Logs(cmd) => cmd.run().await?, - CastSubcommand::DecodeTransaction { tx } => { + CastSubcommand::Misc(MiscSubcommand::Logs(cmd)) => cmd.run().await?, + CastSubcommand::Misc(MiscSubcommand::Events(cmd)) => cmd.run().await?, + CastSubcommand::Misc(MiscSubcommand::DecodeTransaction { tx, network }) => { let tx = stdin::unwrap_line(tx)?; - let tx = SimpleCast::decode_raw_transaction(&tx)?; + let decoded_tx = match network { + #[cfg(feature = "optimism")] + Some(NetworkVariant::Optimism) => decode_raw_transaction::(&tx)?, + Some(NetworkVariant::Tempo) => decode_raw_transaction::(&tx)?, + #[cfg(feature = "base")] + Some(NetworkVariant::Base) => decode_raw_transaction::(&tx)?, + Some(NetworkVariant::Ethereum) => decode_raw_transaction::(&tx)?, + #[cfg(feature = "monad")] + Some(NetworkVariant::Monad) => decode_raw_transaction::(&tx)?, + // Without an explicit `--network` override, decode with the Foundry envelope, + // which dispatches on the EIP-2718 type byte for the transaction types compiled + // into `FoundryNetwork`, including Tempo txs (`0x76`). + None => decode_raw_transaction::(&tx)?, + }; + print_json_object(decoded_tx)?; + } + CastSubcommand::Misc(MiscSubcommand::RecoverAuthority { auth }) => { + let auth: SignedAuthorization = serde_json::from_str(&auth)?; + print_scalar(auth.recover_authority()?.to_string())?; + } + CastSubcommand::Misc(MiscSubcommand::TxPool { command }) => command.run().await?, + CastSubcommand::Misc(MiscSubcommand::Erc20Token { command }) => command.run().await?, + CastSubcommand::Misc(MiscSubcommand::Erc4626 { command }) => command.run().await?, + CastSubcommand::Misc(MiscSubcommand::Tip20Token { command }) => command.run().await?, + CastSubcommand::Misc(MiscSubcommand::ReceivePolicy { command }) => command.run().await?, + CastSubcommand::Misc(MiscSubcommand::Tip403 { command }) => command.run().await?, + CastSubcommand::Misc(MiscSubcommand::StorageCredits { command }) => command.run().await?, + CastSubcommand::Misc(MiscSubcommand::Keychain { command }) => command.run().await?, + CastSubcommand::Misc(MiscSubcommand::KeyAuthorization { command }) => command.run().await?, + CastSubcommand::Misc(MiscSubcommand::Tempo(args)) => args.run().await?, + CastSubcommand::Misc(MiscSubcommand::VirtualAddress { command }) => command.run().await?, + #[cfg(any(feature = "base", feature = "optimism"))] + CastSubcommand::Misc(MiscSubcommand::DAEstimate(cmd)) => cmd.run().await?, + CastSubcommand::Misc(MiscSubcommand::Trace(cmd)) => cmd.run().await?, + }; + + Ok(()) +} + +/// Builds the default provider for `rpc` and resolves `who` against it. +async fn rpc_provider_and_address( + rpc: &RpcOpts, + who: NameOrAddress, +) -> Result<(RetryProvider, Address)> { + let provider = rpc_provider(rpc)?; + let who = who.resolve(&provider).await?; + Ok((provider, who)) +} + +/// Resolves `who` when given, otherwise returns `default`. +async fn resolve_or( + who: Option, + default: Address, + provider: &RetryProvider, +) -> Result
{ + Ok(match who { + Some(who) => who.resolve(provider).await?, + None => default, + }) +} - if let Ok(signer) = tx.recover_signer() { - let recovered = Recovered::new_unchecked(tx, signer); - sh_println!("{}", serde_json::to_string_pretty(&recovered)?)?; +/// Validates that `encoded` is a supported EIP-2718 transaction and renders its Tempo T5 payment +/// lane classification. +fn format_lane_classification(encoded: &[u8], decode_context: &'static str) -> Result { + FoundryTxEnvelope::decode_2718(&mut &encoded[..]).wrap_err(decode_context)?; + let classification = classify_payment_lane(encoded); + if shell::is_json() { + Ok(serde_json::to_string_pretty(&classification)?) + } else { + Ok(serde_json::to_string(&classification)?) + } +} + +async fn address_at_slot( + provider: &impl Provider, + who: Address, + slot: B256, + block: Option, +) -> Result { + let value = + provider.get_storage_at(who, slot.into()).block_id(block.unwrap_or_default()).await?; + Ok(format!("{:?}", Address::from_word(value.into()))) +} + +async fn transaction_response( + provider: &impl Provider, + tx_hash: Option, + from: Option, + nonce: Option, +) -> Result { + if let Some(tx_hash) = tx_hash { + let tx_hash = TxHash::from_str(&tx_hash).wrap_err("invalid tx hash")?; + provider + .get_transaction_by_hash(tx_hash) + .await? + .ok_or_else(|| eyre::eyre!("tx not found: {:?}", tx_hash)) + } else if let Some(from) = from { + let nonce = U64::from(nonce.unwrap_or_default()); + let from = from.resolve(provider.root()).await?; + provider + .raw_request::<_, Option>( + "eth_getTransactionBySenderAndNonce".into(), + (from, nonce), + ) + .await? + .ok_or_else(|| { + eyre::eyre!("tx not found for sender {from} and nonce {:?}", nonce.to::()) + }) + } else { + eyre::bail!("tx hash or from address is required") + } +} + +fn format_transaction( + _provider: &impl Provider, + tx: N::TransactionResponse, + field: Option, + to_request: bool, +) -> Result +where + N::TransactionResponse: UIfmt, + N::TxEnvelope: UIfmtSignatureExt, +{ + Ok(if let Some(field) = field { + if let Some(value) = get_pretty_tx_attr::(&tx, &field) { + value + } else { + let tx_json = serde_json::to_value(&tx)?; + let value = + tx_json.get(&field).ok_or_else(|| eyre::eyre!("invalid tx field: {field}"))?; + match value { + serde_json::Value::String(value) => value.clone(), + value => value.to_string(), + } + } + } else if shell::is_json() { + // to_value first to sort json object keys + serde_json::to_value(&tx)?.to_string() + } else if to_request { + serde_json::to_string_pretty(&Into::::into(tx))? + } else { + tx.pretty() + }) +} + +fn int_bound(s: &str, max: bool) -> Result { + let ty = DynSolType::parse(s).wrap_err("Invalid type, expected `(u)int`")?; + match ty { + DynSolType::Int(n) => { + let max_value = (U256::MAX & U256::ONE.wrapping_shl(n - 1)) - U256::ONE; + if max { + Ok(max_value.to_string()) } else { - sh_println!("{}", serde_json::to_string_pretty(&tx)?)?; + Ok((I256::from_raw(max_value).wrapping_neg() + I256::MINUS_ONE).to_string()) } } - CastSubcommand::RecoverAuthority { auth } => { - let auth: SignedAuthorization = serde_json::from_str(&auth).unwrap(); - sh_println!("{}", auth.recover_authority()?)?; + DynSolType::Uint(n) if max => { + let mut max_value = U256::MAX; + if n < 256 { + max_value &= U256::ONE.wrapping_shl(n).wrapping_sub(U256::ONE); + } + Ok(max_value.to_string()) } - CastSubcommand::TxPool { command } => command.run().await?, - CastSubcommand::DAEstimate(cmd) => { - cmd.run().await?; + DynSolType::Uint(_) => Ok("0".to_string()), + _ => Err(eyre::eyre!("Type is not int/uint: {s}")), + } +} + +/// Converts a parsed, possibly-negative [`NumberWithBase`] into a [`ParseUnits`], preserving +/// its sign. +/// +/// `NumberWithBase::number()` returns the two's-complement bits of a negative value modulo +/// 2^256, which is a wider range than [`I256`] can represent (magnitudes up to 2^255 only). +/// A magnitude beyond that range would silently reinterpret as a small *positive* [`I256`] +/// if constructed unconditionally via [`I256::from_raw`] -- reject it instead. +fn signed_parse_units(value: &NumberWithBase) -> Result { + if value.is_nonnegative() { + return Ok(ParseUnits::U256(value.number())); + } + let signed = I256::from_raw(value.number()); + if !signed.is_negative() { + eyre::bail!("value out of range for a signed 256-bit integer"); + } + Ok(ParseUnits::I256(signed)) +} + +fn format_unit_as_string(value: ParseUnits, unit: Unit) -> String { + let mut formatted = value.format_units(unit); + // Trim empty fractional part. + if let Some(dot) = formatted.find('.') { + let fractional = &formatted[dot + 1..]; + if fractional.chars().all(|c: char| c == '0') { + formatted = formatted[..dot].to_string(); } + } + formatted +} + +pub(super) fn format_units(value: &str, unit: u8) -> Result { + let value = NumberWithBase::parse_int(value, None)?; + let unit = Unit::new(unit).ok_or_else(|| eyre::eyre!("invalid unit"))?; + let parsed = signed_parse_units(&value)?; + Ok(format_unit_as_string(parsed, unit)) +} + +fn to_base(value: &str, base_in: Option<&str>, base_out: &str) -> Result { + let base_in = Base::unwrap_or_detect(base_in, value)?; + let base_out = base_out.parse()?; + let n = NumberWithBase::parse_int_in(value, base_in)?.with_base(base_out); + if base_in == base_out { + return Ok(value.to_string()); + } + Ok(format!("{n:#?}")) +} + +/// Parses `value` and `bits`, applies `shift` and formats the result with the `base_out` +/// prefix. +fn shift( + value: &str, + bits: &str, + base_in: Option<&str>, + base_out: &str, + shift: impl FnOnce(U256, U256) -> U256, +) -> Result { + let base_out = base_out.parse()?; + let value = NumberWithBase::parse_uint(value, base_in)?.number(); + let bits = NumberWithBase::parse_uint(bits, None)?.number(); + Ok(format!("{:#?}", NumberWithBase::from(shift(value, bits)).with_base(base_out))) +} + +fn explorer_client( + chain: Chain, + api_key: Option, + api_url: Option, + explorer_url: Option, +) -> Result { + let mut builder = Client::builder(); + + let deduced = chain.etherscan_urls(); + + let explorer_url = explorer_url + .or(deduced.map(|d| d.1.to_string())) + .ok_or_eyre("Please provide the explorer browser URL using `--explorer-url`")?; + builder = builder.with_url(explorer_url)?; + + let api_url = api_url + .or(deduced.map(|d| d.0.to_string())) + .ok_or_eyre("Please provide the explorer API URL using `--explorer-api-url`")?; + builder = builder.with_api_url(api_url)?; + + if let Some(api_key) = api_key { + builder = builder.with_api_key(api_key); + } + + builder.build().map_err(Into::into) +} + +fn decode_raw_transaction>( + tx: &str, +) -> Result { + let tx_hex = hex::decode(tx)?; + let tx: N::TxEnvelope = Decodable2718::decode_2718(&mut tx_hex.as_slice())?; + if let Ok(signer) = tx.recover_signer() { + Ok(serde_json::to_value(Recovered::new_unchecked(tx, signer))?) + } else { + Ok(serde_json::to_value(tx)?) + } +} + +fn get_selector(signature: &str, optimize: usize) -> Result<(String, String)> { + if optimize > 4 { + eyre::bail!("number of leading zeroes must not be greater than 4"); + } + if optimize == 0 { + let selector = get_func(signature)?.selector(); + return Ok((selector.to_string(), String::from(signature))); + } + let Some((name, params)) = signature.split_once('(') else { + eyre::bail!("invalid function signature"); }; - /// Prints slice of tokens using [`format_tokens`] or [`format_tokens_raw`] depending whether - /// the shell is in JSON mode. - /// - /// This is included here to avoid a cyclic dependency between `fmt` and `common`. - fn print_tokens(tokens: &[DynSolValue]) { - if shell::is_json() { - let tokens: Vec = format_tokens_raw(tokens).collect(); - let _ = sh_println!("{}", serde_json::to_string_pretty(&tokens).unwrap()); - } else { - let tokens = format_tokens(tokens); - tokens.for_each(|t| { - let _ = sh_println!("{t}"); - }); + let num_threads = rayon::current_num_threads(); + let found = AtomicBool::new(false); + + // Each thread walks its own residue class of nonces until one of them finds a match. + (0..num_threads as u32) + .into_par_iter() + .find_map_any(|mut nonce| { + while nonce < u32::MAX && !found.load(Ordering::Relaxed) { + let input = format!("{name}{nonce}({params}"); + let selector = &keccak256(input.as_bytes())[..4]; + if selector.iter().take_while(|&&byte| byte == 0).count() == optimize { + found.store(true, Ordering::Relaxed); + return Some((hex::encode_prefixed(selector), input)); + } + nonce += num_threads as u32; + } + None + }) + .ok_or_eyre("No selector found") +} + +fn strip_0x(s: &str) -> &str { + s.strip_prefix("0x").unwrap_or(s) +} + +/// Encodes the topic of an indexed event parameter. +/// +/// Value types are encoded as their 32-byte word. Reference types are hashed over the special +/// in-place encoding defined for indexed event parameters, which differs from regular ABI +/// encoding: `string` and `bytes` contribute their raw contents, and array or struct members are +/// concatenated recursively without any offsets or length prefixes. +/// +/// See +pub(super) fn encode_event_topic(value: &DynSolValue) -> B256 { + if let Some(word) = value.as_word() { + return word; + } + // Top-level `string` and `bytes` hash their raw contents without padding. + if let Some(bytes) = value.as_packed_seq() { + return keccak256(bytes); + } + let mut preimage = Vec::new(); + encode_event_topic_preimage(value, &mut preimage); + keccak256(preimage) +} + +/// Encodes a value into the in-place preimage of an indexed event parameter: words as-is, +/// `string`/`bytes` right-padded to a multiple of 32 bytes, and sequences as the concatenation of +/// their encoded members. +fn encode_event_topic_preimage(value: &DynSolValue, out: &mut Vec) { + if let Some(word) = value.as_word() { + out.extend_from_slice(word.as_slice()); + } else if let Some(bytes) = value.as_packed_seq() { + let pad = bytes.len().next_multiple_of(32) - bytes.len(); + out.extend_from_slice(bytes); + out.resize(out.len() + pad, 0); + } else if let Some(values) = value.as_fixed_seq().or_else(|| value.as_array()) { + for value in values { + encode_event_topic_preimage(value, out); } } +} - Ok(()) +#[cfg(test)] +mod tests { + use super::*; + use alloy_sol_types::{EventTopic, sol_data}; + + /// Compares [`super::encode_event_topic`] against alloy's static [`EventTopic`] + /// implementation, which `sol!`-generated events use to compute indexed topics. + #[test] + fn encode_event_topic_matches_static_encoding() { + let uint = |n: u64| DynSolValue::Uint(U256::from(n), 256); + let string = |s: &str| DynSolValue::String(s.into()); + let topic = |v: &DynSolValue| super::encode_event_topic(v); + + let long = "abcdefghijklmnopqrstuvwxyz0123456789abcd"; + for s in ["", "hello", long] { + assert_eq!( + topic(&string(s)), + ::encode_topic(&s.to_string()).0, + "string {s:?}" + ); + } + + let bytes = hex::decode("deadbeef").unwrap(); + assert_eq!( + topic(&DynSolValue::Bytes(bytes.clone())), + ::encode_topic(&Bytes::from(bytes)).0, + ); + + let addr = Address::repeat_byte(0x42); + assert_eq!( + topic(&DynSolValue::Address(addr)), + ::encode_topic(&addr).0, + ); + + assert_eq!( + topic(&DynSolValue::Array(vec![uint(1), uint(2)])), + > as EventTopic>::encode_topic(&vec![ + U256::ONE, + U256::from(2) + ]) + .0, + ); + + assert_eq!( + topic(&DynSolValue::FixedArray(vec![uint(7), uint(9)])), + , 2> as EventTopic>::encode_topic(&[ + U256::from(7), + U256::from(9) + ]) + .0, + ); + + assert_eq!( + topic(&DynSolValue::Array(vec![string("alpha"), string(long)])), + as EventTopic>::encode_topic(&vec![ + "alpha".to_string(), + long.to_string() + ]) + .0, + ); + + assert_eq!( + topic(&DynSolValue::Tuple(vec![uint(7), string("hello")])), + <(sol_data::Uint<256>, sol_data::String) as EventTopic>::encode_topic(&( + U256::from(7), + "hello".to_string() + )) + .0, + ); + + assert_eq!( + topic(&DynSolValue::Array(vec![ + DynSolValue::Array(vec![uint(1)]), + DynSolValue::Array(vec![uint(2), uint(3)]), + ])), + >> as EventTopic>::encode_topic( + &vec![vec![U256::ONE], vec![U256::from(2), U256::from(3)]] + ) + .0, + ); + } } diff --git a/crates/cast/src/base.rs b/crates/cast/src/base.rs index 478aa3980c570..00f0615d5eb5a 100644 --- a/crates/cast/src/base.rs +++ b/crates/cast/src/base.rs @@ -1,20 +1,18 @@ -use alloy_primitives::{I256, Sign, U256, utils::ParseUnits}; +//! Number base parsing and formatting for the `cast` conversion commands. + +use alloy_primitives::{I256, U256}; use eyre::Result; use std::{ - convert::Infallible, - fmt::{Binary, Debug, Display, Formatter, LowerHex, Octal, Result as FmtResult, UpperHex}, + fmt::{Debug, Display, Formatter, LowerHex, Result as FmtResult}, num::IntErrorKind, str::FromStr, }; -/* -------------------------------------------- Base -------------------------------------------- */ - -/// Represents a number's [radix] or base. Currently it supports the same bases that [std::fmt] -/// supports. +/// Represents a number's [radix] or base. Supports the same bases that [`std::fmt`] supports. /// /// [radix]: https://en.wikipedia.org/wiki/Radix #[repr(u32)] -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum Base { Binary = 2, Octal = 8, @@ -23,12 +21,6 @@ pub enum Base { Hexadecimal = 16, } -impl Display for Base { - fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { - Display::fmt(&(*self as u32), f) - } -} - impl FromStr for Base { type Err = eyre::Report; @@ -50,113 +42,44 @@ Invalid base \"{s}\". Possible values: } } -impl TryFrom for Base { - type Error = eyre::Report; - - fn try_from(s: String) -> Result { - Self::from_str(&s) - } -} - -impl TryFrom for Base { - type Error = eyre::Report; - - fn try_from(n: u32) -> Result { - match n { - 2 => Ok(Self::Binary), - 8 => Ok(Self::Octal), - 10 => Ok(Self::Decimal), - 16 => Ok(Self::Hexadecimal), - n => Err(eyre::eyre!("Invalid base \"{}\". Possible values: 2, 8, 10, 16", n)), - } - } -} - -impl TryFrom for Base { - type Error = eyre::Report; - - fn try_from(n: I256) -> Result { - Self::try_from(n.low_u32()) - } -} - -impl TryFrom for Base { - type Error = eyre::Report; - - fn try_from(n: U256) -> Result { - Self::try_from(n.saturating_to::()) - } -} - -impl From for u32 { - fn from(b: Base) -> Self { - b as Self - } -} - -impl From for String { - fn from(b: Base) -> Self { - b.to_string() - } -} - impl Base { - pub fn unwrap_or_detect(base: Option<&str>, s: impl AsRef) -> Result { + /// Parses `base` when given, otherwise detects the base of `s` from its prefix. + pub fn unwrap_or_detect(base: Option<&str>, s: &str) -> Result { match base { Some(base) => base.parse(), None => Self::detect(s), } } - /// Try parsing a number's base from a string. - pub fn detect(s: impl AsRef) -> Result { - let s = s.as_ref(); - match s { - // Ignore sign - _ if s.starts_with(['+', '-']) => Self::detect(&s[1..]), - // Verify binary and octal values with u128::from_str_radix as U256 does not support - // them; - // assume overflows are within u128::MAX and U256::MAX, we're not using the parsed value - // anyway; - // strip prefix when using u128::from_str_radix because it does not recognize it as - // valid. - _ if s.starts_with("0b") => match u64::from_str_radix(&s[2..], 2) { - Ok(_) => Ok(Self::Binary), - Err(e) => match e.kind() { - IntErrorKind::PosOverflow => Ok(Self::Binary), - _ => Err(eyre::eyre!("could not parse binary value: {}", e)), - }, - }, - _ if s.starts_with("0o") => match u64::from_str_radix(&s[2..], 8) { - Ok(_) => Ok(Self::Octal), - Err(e) => match e.kind() { - IntErrorKind::PosOverflow => Ok(Self::Octal), - _ => Err(eyre::eyre!("could not parse octal value: {e}")), - }, - }, - _ if s.starts_with("0x") => match u64::from_str_radix(&s[2..], 16) { - Ok(_) => Ok(Self::Hexadecimal), - Err(e) => match e.kind() { - IntErrorKind::PosOverflow => Ok(Self::Hexadecimal), - _ => Err(eyre::eyre!("could not parse hexadecimal value: {e}")), - }, - }, - // No prefix => first try parsing as decimal - _ => match U256::from_str_radix(s, 10) { - // Can be both, ambiguous but default to Decimal - Ok(_) => Ok(Self::Decimal), - Err(_) => match U256::from_str_radix(s, 16) { - Ok(_) => Ok(Self::Hexadecimal), - Err(e) => Err(eyre::eyre!( - "could not autodetect base as neither decimal or hexadecimal: {e}" - )), - }, - }, + /// Detects a number's base from its prefix, defaulting to decimal and then hexadecimal for + /// unprefixed values. + pub fn detect(s: &str) -> Result { + let s = s.strip_prefix(['+', '-']).unwrap_or(s); + let prefix = s.get(..2).map(str::to_ascii_lowercase); + match prefix.as_deref() { + Some("0b") => Self::detect_prefixed(s, Self::Binary, "binary"), + Some("0o") => Self::detect_prefixed(s, Self::Octal, "octal"), + Some("0x") => Self::detect_prefixed(s, Self::Hexadecimal, "hexadecimal"), + // Unprefixed digits are ambiguous; prefer decimal. + _ if U256::from_str_radix(s, 10).is_ok() => Ok(Self::Decimal), + _ => U256::from_str_radix(s, 16).map(|_| Self::Hexadecimal).map_err(|e| { + eyre::eyre!("could not autodetect base as neither decimal or hexadecimal: {e}") + }), + } + } + + /// Validates the digits after a 2-char prefix. `PosOverflow` is accepted since the digits are + /// correct for the base; only the base is being detected here. + fn detect_prefixed(s: &str, base: Self, label: &str) -> Result { + match u64::from_str_radix(&s[2..], base as u32) { + Ok(_) => Ok(base), + Err(e) if *e.kind() == IntErrorKind::PosOverflow => Ok(base), + Err(e) => Err(eyre::eyre!("could not parse {label} value: {e}")), } } - /// Returns the Rust standard prefix for a base - pub const fn prefix(&self) -> &str { + /// Returns the Rust standard prefix for a base. + pub const fn prefix(self) -> &'static str { match self { Self::Binary => "0b", Self::Octal => "0o", @@ -166,38 +89,27 @@ impl Base { } } -/* --------------------------------------- NumberWithBase --------------------------------------- */ - -/// Utility struct for parsing numbers and formatting them into different [bases][Base]. +/// A parsed number together with the [`Base`] it is formatted in. +/// +/// [`Debug`] formats the number in its base, [`Display`] in decimal and [`LowerHex`] in +/// hexadecimal; the alternate flag (`#`) prepends the base prefix. /// /// # Example /// /// ``` -/// use cast::base::NumberWithBase; /// use alloy_primitives::U256; +/// use cast::base::{Base, NumberWithBase}; /// -/// let number: NumberWithBase = U256::from(12345).into(); -/// assert_eq!(number.format(), "12345"); -/// -/// // Debug uses number.base() to determine which base to format to, which defaults to Base::Decimal -/// assert_eq!(format!("{:?}", number), "12345"); -/// -/// // Display uses Base::Decimal -/// assert_eq!(format!("{}", number), "12345"); -/// -/// // The alternate formatter ("#") prepends the base's prefix -/// assert_eq!(format!("{:x}", number), "3039"); -/// assert_eq!(format!("{:#x}", number), "0x3039"); -/// -/// assert_eq!(format!("{:b}", number), "11000000111001"); -/// assert_eq!(format!("{:#b}", number), "0b11000000111001"); -/// -/// assert_eq!(format!("{:o}", number), "30071"); -/// assert_eq!(format!("{:#o}", number), "0o30071"); +/// let number = NumberWithBase::from(U256::from(12345)); +/// assert_eq!(format!("{number}"), "12345"); +/// assert_eq!(format!("{number:x}"), "3039"); +/// assert_eq!(format!("{number:#x}"), "0x3039"); +/// assert_eq!(format!("{:#?}", number.with_base(Base::Binary)), "0b11000000111001"); +/// assert_eq!(format!("{:#?}", number.with_base(Base::Octal)), "0o30071"); /// ``` #[derive(Clone, Copy)] pub struct NumberWithBase { - /// The number. + /// The number, as two's complement when negative. number: U256, /// Whether the number is positive or zero. is_nonnegative: bool, @@ -205,40 +117,15 @@ pub struct NumberWithBase { base: Base, } -impl std::ops::Deref for NumberWithBase { - type Target = U256; - - fn deref(&self) -> &Self::Target { - &self.number - } -} - -// Format using self.base impl Debug for NumberWithBase { fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { let prefix = self.base.prefix(); if self.number.is_zero() { - f.pad_integral(true, prefix, "0") - } else { - // Add sign only for decimal - let is_nonnegative = match self.base { - Base::Decimal => self.is_nonnegative, - _ => true, - }; - f.pad_integral(is_nonnegative, prefix, &self.format()) + return f.pad_integral(true, prefix, "0"); } - } -} - -impl Binary for NumberWithBase { - fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { - Debug::fmt(&self.with_base(Base::Binary), f) - } -} - -impl Octal for NumberWithBase { - fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { - Debug::fmt(&self.with_base(Base::Octal), f) + // Only decimal output carries a sign; the other bases show the two's complement. + let is_nonnegative = self.base != Base::Decimal || self.is_nonnegative; + f.pad_integral(is_nonnegative, prefix, &self.format()) } } @@ -254,237 +141,85 @@ impl LowerHex for NumberWithBase { } } -impl UpperHex for NumberWithBase { - fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { - let n = format!("{self:x}").to_uppercase(); - f.pad_integral(true, Base::Hexadecimal.prefix(), &n) - } -} - -impl FromStr for NumberWithBase { - type Err = eyre::Report; - - fn from_str(s: &str) -> Result { - Self::parse_int(s, None) - } -} - impl From for NumberWithBase { fn from(number: I256) -> Self { - // both is_positive and is_negative return false for 0 - Self::new(number.into_raw(), !number.is_negative(), Base::default()) - } -} - -impl From for NumberWithBase { - fn from(value: ParseUnits) -> Self { - match value { - ParseUnits::U256(val) => val.into(), - ParseUnits::I256(val) => val.into(), + Self { + number: number.into_raw(), + is_nonnegative: !number.is_negative(), + base: Base::default(), } } } impl From for NumberWithBase { fn from(number: U256) -> Self { - Self::new(number, true, Base::default()) - } -} - -impl From for I256 { - fn from(n: NumberWithBase) -> Self { - Self::from_raw(n.number) - } -} - -impl From for U256 { - fn from(n: NumberWithBase) -> Self { - n.number - } -} - -impl From for String { - /// Formats the number into the specified base. See [NumberWithBase::format]. - /// - /// [NumberWithBase::format]: NumberWithBase - fn from(n: NumberWithBase) -> Self { - n.format() + Self { number, is_nonnegative: true, base: Base::default() } } } impl NumberWithBase { - pub fn new(number: impl Into, is_nonnegative: bool, base: Base) -> Self { - Self { number: number.into(), is_nonnegative, base } - } - - /// Creates a copy of the number with the provided base. - pub fn with_base(&self, base: Base) -> Self { - Self { number: self.number, is_nonnegative: self.is_nonnegative, base } + /// Parses a signed integer, detecting the base from the prefix when `base` is `None`. + pub fn parse_int(s: &str, base: Option<&str>) -> Result { + Self::parse_int_in(s, Base::unwrap_or_detect(base, s)?) } - /// Parses a string slice into a signed integer. If base is None then it tries to determine base - /// from the prefix, otherwise defaults to Decimal. - pub fn parse_int(s: &str, base: Option<&str>) -> Result { - let base = Base::unwrap_or_detect(base, s)?; - let (number, is_nonnegative) = Self::_parse_int(s, base)?; + /// Parses a signed integer in `base`. + pub fn parse_int_in(s: &str, base: Base) -> Result { + let (s, is_nonnegative) = match s.strip_prefix('-') { + Some(s) => (s, false), + None => (s.strip_prefix('+').unwrap_or(s), true), + }; + let mut number = Self::parse_digits(s, base)?; + if !is_nonnegative { + eyre::ensure!( + number <= I256::MIN.unsigned_abs(), + "value out of range for a signed 256-bit integer" + ); + number = number.wrapping_neg(); + } Ok(Self { number, is_nonnegative, base }) } - /// Parses a string slice into an unsigned integer. If base is None then it tries to determine - /// base from the prefix, otherwise defaults to Decimal. + /// Parses an unsigned integer, detecting the base from the prefix when `base` is `None`. pub fn parse_uint(s: &str, base: Option<&str>) -> Result { let base = Base::unwrap_or_detect(base, s)?; - let number = Self::_parse_uint(s, base)?; - Ok(Self { number, is_nonnegative: true, base }) + Ok(Self { number: Self::parse_digits(s, base)?, is_nonnegative: true, base }) } - /// Returns a copy of the underlying number as an unsigned integer. If the value is negative - /// then the two's complement of its absolute value will be returned. - pub fn number(&self) -> U256 { - self.number - } - - /// Returns whether the underlying number is positive or zero. - pub fn is_nonnegative(&self) -> bool { - self.is_nonnegative + /// Parses the digits of `s` in `base`, stripping only that base's prefix: a leading `0b` is a + /// prefix when parsing binary and a pair of hexadecimal digits otherwise. + fn parse_digits(s: &str, base: Base) -> Result { + let s = match s.get(..2) { + Some(p) if p.eq_ignore_ascii_case(base.prefix()) => &s[2..], + _ => s, + }; + U256::from_str_radix(s, base as u64).map_err(Into::into) } - /// Returns the underlying base. Defaults to [Decimal][Base]. - pub fn base(&self) -> Base { - self.base + /// Returns the number as an unsigned integer (two's complement when negative). + pub const fn number(&self) -> U256 { + self.number } - /// Returns the Rust standard prefix for the base. - pub const fn prefix(&self) -> &str { - self.base.prefix() + /// Returns whether the number is positive or zero. + pub const fn is_nonnegative(&self) -> bool { + self.is_nonnegative } - /// Sets the number's base to format to. - pub fn set_base(&mut self, base: Base) -> &mut Self { - self.base = base; - self + /// Returns a copy of the number formatted in `base`. + pub const fn with_base(self, base: Base) -> Self { + Self { base, ..self } } - /// Formats the number into the specified base. - /// - /// **Note**: this method only formats the number into the base, without adding any prefixes, - /// signs or padding. Refer to the [std::fmt] module documentation on how to format this - /// number with the aforementioned properties. - pub fn format(&self) -> String { - let s = match self.base { + /// Formats the number's digits in its base, without any prefix, sign or padding. + fn format(&self) -> String { + match self.base { Base::Binary => format!("{:b}", self.number), Base::Octal => format!("{:o}", self.number), - Base::Decimal => { - if self.is_nonnegative { - self.number.to_string() - } else { - let s = I256::from_raw(self.number).to_string(); - s.strip_prefix('-').unwrap_or(&s).to_string() - } - } + Base::Decimal if self.is_nonnegative => self.number.to_string(), + Base::Decimal => I256::from_raw(self.number).to_string().trim_start_matches('-').into(), Base::Hexadecimal => format!("{:x}", self.number), - }; - if s.starts_with('0') { s.trim_start_matches('0').to_string() } else { s } - } - - fn _parse_int(s: &str, base: Base) -> Result<(U256, bool)> { - let (s, sign) = get_sign(s); - let mut n = Self::_parse_uint(s, base)?; - - let is_neg = matches!(sign, Sign::Negative); - if is_neg { - n = (!n).overflowing_add(U256::from(1)).0; } - - Ok((n, !is_neg)) - } - - fn _parse_uint(s: &str, base: Base) -> Result { - let s = match s.get(0..2) { - Some("0x" | "0X" | "0o" | "0O" | "0b" | "0B") => &s[2..], - _ => s, - }; - U256::from_str_radix(s, base as u64).map_err(Into::into) - } -} - -/* ------------------------------------------- ToBase ------------------------------------------- */ - -/// Facilitates formatting an integer into a [Base]. -pub trait ToBase { - type Err; - - /// Formats self into a base, specifying whether to add the base prefix or not. - /// - /// Tries converting `self` into a [NumberWithBase] and then formats into the provided base by - /// using the [Debug] implementation. - /// - /// # Example - /// - /// ``` - /// use alloy_primitives::U256; - /// use cast::base::{Base, ToBase}; - /// - /// // Any type that implements ToBase - /// let number = U256::from(12345); - /// assert_eq!(number.to_base(Base::Decimal, false).unwrap(), "12345"); - /// assert_eq!(number.to_base(Base::Hexadecimal, false).unwrap(), "3039"); - /// assert_eq!(number.to_base(Base::Hexadecimal, true).unwrap(), "0x3039"); - /// assert_eq!(number.to_base(Base::Binary, true).unwrap(), "0b11000000111001"); - /// assert_eq!(number.to_base(Base::Octal, true).unwrap(), "0o30071"); - /// ``` - fn to_base(&self, base: Base, add_prefix: bool) -> Result; -} - -impl ToBase for NumberWithBase { - type Err = Infallible; - - fn to_base(&self, base: Base, add_prefix: bool) -> Result { - let n = self.with_base(base); - if add_prefix { Ok(format!("{n:#?}")) } else { Ok(format!("{n:?}")) } - } -} - -impl ToBase for I256 { - type Err = Infallible; - - fn to_base(&self, base: Base, add_prefix: bool) -> Result { - let n = NumberWithBase::from(*self).with_base(base); - if add_prefix { Ok(format!("{n:#?}")) } else { Ok(format!("{n:?}")) } - } -} - -impl ToBase for U256 { - type Err = Infallible; - - fn to_base(&self, base: Base, add_prefix: bool) -> Result { - let n = NumberWithBase::from(*self).with_base(base); - if add_prefix { Ok(format!("{n:#?}")) } else { Ok(format!("{n:?}")) } - } -} - -impl ToBase for String { - type Err = eyre::Report; - - fn to_base(&self, base: Base, add_prefix: bool) -> Result { - str::to_base(self, base, add_prefix) - } -} - -impl ToBase for str { - type Err = eyre::Report; - - fn to_base(&self, base: Base, add_prefix: bool) -> Result { - let n = NumberWithBase::from_str(self)?.with_base(base); - if add_prefix { Ok(format!("{n:#?}")) } else { Ok(format!("{n:?}")) } - } -} - -fn get_sign(s: &str) -> (&str, Sign) { - match s.as_bytes().first() { - Some(b'+') => (&s[1..], Sign::Positive), - Some(b'-') => (&s[1..], Sign::Negative), - _ => (s, Sign::Positive), } } @@ -493,7 +228,7 @@ mod tests { use super::*; use Base::*; - const POS_NUM: [i128; 44] = [ + const NUMS: [i128; 44] = [ 1, 2, 3, @@ -540,85 +275,24 @@ mod tests { i128::MAX, ]; - const NEG_NUM: [i128; 44] = [ - -1, - -2, - -3, - -5, - -7, - -8, - -10, - -11, - -13, - -16, - -17, - -19, - -23, - -29, - -31, - -32, - -37, - -41, - -43, - -47, - -53, - -59, - -61, - -64, - -67, - -71, - -73, - -79, - -83, - -89, - -97, - -100, - -128, - -200, - -333, - -500, - -666, - -1000, - -6666, - -10000, - i16::MIN as i128, - i32::MIN as i128, - i64::MIN as i128, - i128::MIN, - ]; - - #[test] - fn test_defaults() { - let def: Base = Default::default(); - assert!(matches!(def, Decimal)); - - let n: NumberWithBase = U256::ZERO.into(); - assert!(matches!(n.base, Decimal)); - let n: NumberWithBase = I256::ZERO.into(); - assert!(matches!(n.base, Decimal)); + fn number(n: i128) -> NumberWithBase { + NumberWithBase::from(I256::try_from(n).unwrap()) } #[test] fn can_parse_base() { - assert_eq!("2".parse::().unwrap(), Binary); - assert_eq!("b".parse::().unwrap(), Binary); - assert_eq!("bin".parse::().unwrap(), Binary); - assert_eq!("binary".parse::().unwrap(), Binary); - - assert_eq!("8".parse::().unwrap(), Octal); - assert_eq!("o".parse::().unwrap(), Octal); - assert_eq!("oct".parse::().unwrap(), Octal); - assert_eq!("octal".parse::().unwrap(), Octal); - - assert_eq!("10".parse::().unwrap(), Decimal); - assert_eq!("d".parse::().unwrap(), Decimal); - assert_eq!("dec".parse::().unwrap(), Decimal); - assert_eq!("decimal".parse::().unwrap(), Decimal); - - assert_eq!("16".parse::().unwrap(), Hexadecimal); - assert_eq!("h".parse::().unwrap(), Hexadecimal); - assert_eq!("hex".parse::().unwrap(), Hexadecimal); - assert_eq!("hexadecimal".parse::().unwrap(), Hexadecimal); + for (aliases, base) in [ + (["2", "b", "bin", "binary"], Binary), + (["8", "o", "oct", "octal"], Octal), + (["10", "d", "dec", "decimal"], Decimal), + (["16", "h", "hex", "hexadecimal"], Hexadecimal), + ] { + for alias in aliases { + assert_eq!(alias.parse::().unwrap(), base, "{alias}"); + assert_eq!(alias.to_uppercase().parse::().unwrap(), base, "{alias}"); + } + } + assert!("3".parse::().is_err()); } #[test] @@ -628,6 +302,14 @@ mod tests { assert_eq!(Base::detect("100").unwrap(), Decimal); assert_eq!(Base::detect("0x100").unwrap(), Hexadecimal); + assert_eq!(Base::detect("0B100").unwrap(), Binary); + assert_eq!(Base::detect("0O100").unwrap(), Octal); + assert_eq!(Base::detect("0X100").unwrap(), Hexadecimal); + + assert_eq!(Base::detect("-0B100").unwrap(), Binary); + assert_eq!(Base::detect("-0O100").unwrap(), Octal); + assert_eq!(Base::detect("-0X100").unwrap(), Hexadecimal); + assert_eq!(Base::detect("0123456789abcdef").unwrap(), Hexadecimal); let _ = Base::detect("0b234abc").unwrap_err(); @@ -638,97 +320,67 @@ mod tests { } #[test] - fn test_format_pos() { - let expected_2: Vec<_> = POS_NUM.iter().map(|n| format!("{n:b}")).collect(); - let expected_8: Vec<_> = POS_NUM.iter().map(|n| format!("{n:o}")).collect(); - let expected_10: Vec<_> = POS_NUM.iter().map(|n| format!("{n:}")).collect(); - let expected_l16: Vec<_> = POS_NUM.iter().map(|n| format!("{n:x}")).collect(); - let expected_u16: Vec<_> = POS_NUM.iter().map(|n| format!("{n:X}")).collect(); - - for (i, n) in POS_NUM.into_iter().enumerate() { - let mut num: NumberWithBase = I256::try_from(n).unwrap().into(); - - assert_eq!(num.set_base(Binary).format(), expected_2[i]); - assert_eq!(num.set_base(Octal).format(), expected_8[i]); - assert_eq!(num.set_base(Decimal).format(), expected_10[i]); - assert_eq!(num.set_base(Hexadecimal).format(), expected_l16[i]); - assert_eq!(num.set_base(Hexadecimal).format().to_uppercase(), expected_u16[i]); + fn strips_only_the_prefix_of_the_requested_base() { + // `0b`/`0B` are hexadecimal digits when the base is explicitly hexadecimal. + for s in ["0b1010", "0B1010"] { + let number = NumberWithBase::parse_uint(s, Some("16")).unwrap(); + assert_eq!(number.number(), U256::from(0xb1010)); + assert_eq!(number.base, Hexadecimal); } + let number = NumberWithBase::parse_int("-0b11", Some("hex")).unwrap(); + assert_eq!(number.number(), U256::from(0xb11).wrapping_neg()); + assert!(!number.is_nonnegative()); + + // Prefixes of the requested base are still accepted, in either case. + assert_eq!( + NumberWithBase::parse_uint("0x1f", Some("16")).unwrap().number(), + U256::from(31) + ); + assert_eq!( + NumberWithBase::parse_uint("0X1F", Some("16")).unwrap().number(), + U256::from(31) + ); + assert_eq!(NumberWithBase::parse_uint("0b11", Some("2")).unwrap().number(), U256::from(3)); + assert_eq!(NumberWithBase::parse_uint("0o17", Some("8")).unwrap().number(), U256::from(15)); + assert_eq!(NumberWithBase::parse_uint("42", Some("10")).unwrap().number(), U256::from(42)); + + // A prefix of another base is not a valid digit sequence in the requested base. + assert!(NumberWithBase::parse_uint("0x10", Some("2")).is_err()); + assert!(NumberWithBase::parse_uint("0b10", Some("10")).is_err()); + + // Detection from the prefix is unaffected. + let number = NumberWithBase::parse_uint("0b1010", None).unwrap(); + assert_eq!(number.number(), U256::from(10)); + assert_eq!(number.base, Binary); } #[test] - fn test_format_neg() { - // underlying is 256 bits so we have to pad left manually - - let expected_2: Vec<_> = NEG_NUM.iter().map(|n| format!("{n:1>256b}")).collect(); - let expected_8: Vec<_> = NEG_NUM - .iter() - .map(|n| { - let i = I256::try_from(*n).unwrap(); - let mut u = NumberWithBase::from(i); - u.set_base(Octal); - u.format() - }) - .collect(); - // Sign not included, see NumberWithBase::format - let expected_10: Vec<_> = - NEG_NUM.iter().map(|n| format!("{n:}").trim_matches('-').to_string()).collect(); - let expected_l16: Vec<_> = NEG_NUM.iter().map(|n| format!("{n:f>64x}")).collect(); - let expected_u16: Vec<_> = NEG_NUM.iter().map(|n| format!("{n:F>64X}")).collect(); - - for (i, n) in NEG_NUM.into_iter().enumerate() { - let mut num: NumberWithBase = I256::try_from(n).unwrap().into(); - - assert_eq!(num.set_base(Binary).format(), expected_2[i]); - assert_eq!(num.set_base(Octal).format(), expected_8[i]); - assert_eq!(num.set_base(Decimal).format(), expected_10[i]); - assert_eq!(num.set_base(Hexadecimal).format(), expected_l16[i]); - assert_eq!(num.set_base(Hexadecimal).format().to_uppercase(), expected_u16[i]); + fn formats_positive_numbers() { + for n in NUMS { + let num = number(n); + assert_eq!(num.with_base(Binary).format(), format!("{n:b}")); + assert_eq!(num.with_base(Octal).format(), format!("{n:o}")); + assert_eq!(num.with_base(Decimal).format(), n.to_string()); + assert_eq!(num.with_base(Hexadecimal).format(), format!("{n:x}")); + + assert_eq!(format!("{num}"), n.to_string()); + assert_eq!(format!("{num:x}"), format!("{n:x}")); + assert_eq!(format!("{num:#x}"), format!("{n:#x}")); + assert_eq!(format!("{:#?}", num.with_base(Binary)), format!("{n:#b}")); + assert_eq!(format!("{:#?}", num.with_base(Octal)), format!("{n:#o}")); } } #[test] - fn test_fmt_macro() { - let nums: Vec<_> = - POS_NUM.into_iter().map(|n| NumberWithBase::from(I256::try_from(n).unwrap())).collect(); - - let actual_2: Vec<_> = nums.iter().map(|n| format!("{n:b}")).collect(); - let actual_2_alt: Vec<_> = nums.iter().map(|n| format!("{n:#b}")).collect(); - let actual_8: Vec<_> = nums.iter().map(|n| format!("{n:o}")).collect(); - let actual_8_alt: Vec<_> = nums.iter().map(|n| format!("{n:#o}")).collect(); - let actual_10: Vec<_> = nums.iter().map(|n| format!("{n:}")).collect(); - let actual_10_alt: Vec<_> = nums.iter().map(|n| format!("{n:#}")).collect(); - let actual_l16: Vec<_> = nums.iter().map(|n| format!("{n:x}")).collect(); - let actual_l16_alt: Vec<_> = nums.iter().map(|n| format!("{n:#x}")).collect(); - let actual_u16: Vec<_> = nums.iter().map(|n| format!("{n:X}")).collect(); - let actual_u16_alt: Vec<_> = nums.iter().map(|n| format!("{n:#X}")).collect(); - - let expected_2: Vec<_> = POS_NUM.iter().map(|n| format!("{n:b}")).collect(); - let expected_2_alt: Vec<_> = POS_NUM.iter().map(|n| format!("{n:#b}")).collect(); - let expected_8: Vec<_> = POS_NUM.iter().map(|n| format!("{n:o}")).collect(); - let expected_8_alt: Vec<_> = POS_NUM.iter().map(|n| format!("{n:#o}")).collect(); - let expected_10: Vec<_> = POS_NUM.iter().map(|n| format!("{n:}")).collect(); - let expected_10_alt: Vec<_> = POS_NUM.iter().map(|n| format!("{n:#}")).collect(); - let expected_l16: Vec<_> = POS_NUM.iter().map(|n| format!("{n:x}")).collect(); - let expected_l16_alt: Vec<_> = POS_NUM.iter().map(|n| format!("{n:#x}")).collect(); - let expected_u16: Vec<_> = POS_NUM.iter().map(|n| format!("{n:X}")).collect(); - let expected_u16_alt: Vec<_> = POS_NUM.iter().map(|n| format!("{n:#X}")).collect(); - - for (i, _) in POS_NUM.iter().enumerate() { - assert_eq!(actual_2[i], expected_2[i]); - assert_eq!(actual_2_alt[i], expected_2_alt[i]); - - assert_eq!(actual_8[i], expected_8[i]); - assert_eq!(actual_8_alt[i], expected_8_alt[i]); - - assert_eq!(actual_10[i], expected_10[i]); - assert_eq!(actual_10_alt[i], expected_10_alt[i]); - - assert_eq!(actual_l16[i], expected_l16[i]); - assert_eq!(actual_l16_alt[i], expected_l16_alt[i]); - - assert_eq!(actual_u16[i], expected_u16[i]); - assert_eq!(actual_u16_alt[i], expected_u16_alt[i]); + fn formats_negative_numbers() { + for n in NUMS.into_iter().map(|n| -n).chain([i128::MIN]) { + let num = number(n); + // The underlying number is 256 bits wide, so the two's complement is sign-extended. + assert_eq!(num.with_base(Binary).format(), format!("{n:1>256b}")); + assert_eq!(num.with_base(Hexadecimal).format(), format!("{n:f>64x}")); + // Decimal digits never carry the sign; `Display` adds it back. + assert_eq!(num.with_base(Decimal).format(), n.to_string().trim_start_matches('-')); + assert_eq!(format!("{num}"), n.to_string()); } } } diff --git a/crates/cast/src/call_spec.rs b/crates/cast/src/call_spec.rs new file mode 100644 index 0000000000000..1dfafd3f347e5 --- /dev/null +++ b/crates/cast/src/call_spec.rs @@ -0,0 +1,417 @@ +//! Call specification parsing for batch transactions. +//! +//! Parses call specs in the format: `to[:][:[:]]` or `to[:][:<0xrawdata>]` +//! +//! Examples: +//! - `0x1234567890123456789012345678901234567890` - Just an address (empty call) +//! - `0x1234567890123456789012345678901234567890:0.1ether` - ETH transfer +//! - `0x1234567890123456789012345678901234567890::transfer(address,uint256): +//! 0x0987654321098765432109876543210987654321,1000` - Contract call with signature +//! - `0x1234567890123456789012345678901234567890::batch(uint256[],(uint256,uint256)): [1,2],(3,4)` +//! - Array and tuple arguments. +//! - `0x1234567890123456789012345678901234567890::0x123def` - Contract call with raw calldata +//! - `0x1234567890123456789012345678901234567890:1ether:deposit()` - Value + function call + +use alloy_network::Network; +use alloy_primitives::{Address, Bytes, U256, hex}; +use alloy_provider::Provider; +use eyre::{Result, WrapErr, eyre}; +use foundry_cli::utils::{parse_ether_value, parse_function_args}; +use foundry_config::Chain; +use std::str::FromStr; +use tempo_primitives::transaction::Call; + +/// A parsed call specification for batch transactions. +#[derive(Debug, Clone)] +pub struct CallSpec { + /// Target address (required) + pub to: Address, + /// ETH value to send (optional, defaults to 0) + pub value: U256, + /// Function signature, e.g., "transfer(address,uint256)" (optional) + pub sig: Option, + /// Function arguments (optional) + pub args: Vec, + /// Arguments split using the legacy comma-separated grammar, when that differs from `args`. + legacy_args: Option>, + /// Raw calldata if provided instead of sig+args (optional) + pub data: Option, +} + +impl CallSpec { + /// Parse a call spec string. + /// + /// Format: `to[:][:[:]]` or `to[:][:<0xrawdata>]`. A double colon + /// (`::`) separates the address from the sig/data when the value is omitted. + pub fn parse(s: &str) -> Result { + let s = s.trim(); + if s.is_empty() { + return Err(eyre!("Empty call specification")); + } + + let parts: Vec<&str> = s.split(':').collect(); + let to = Address::from_str(parts[0]) + .map_err(|e| eyre!("Invalid address '{}': {}", parts[0], e))?; + let mut spec = Self { + to, + value: U256::ZERO, + sig: None, + args: Vec::new(), + legacy_args: None, + data: None, + }; + + // The first field is the value unless it is empty, a signature, or a terminal lowercase + // hex field, which is raw calldata. + let mut rest = &parts[1..]; + if let Some((part, tail)) = rest.split_first() { + if part.is_empty() { + rest = tail; + } else if (!part.starts_with("0x") || !tail.is_empty()) && !part.contains('(') { + spec.value = + parse_ether_value(part).wrap_err_with(|| format!("Invalid value '{part}'"))?; + rest = tail; + } + } + + match rest.split_first() { + Some((part, tail)) if part.starts_with("0x") => { + let decoded = + hex::decode(part).map_err(|e| eyre!("Invalid hex data '{}': {}", part, e))?; + eyre::ensure!(tail.is_empty(), "Unexpected trailing field(s) after raw calldata"); + spec.data = Some(Bytes::from(decoded)); + } + Some((part, tail)) if !part.is_empty() => { + spec.sig = Some(part.to_string()); + if !tail.is_empty() { + // Args are comma-separated; rejoin any colons that were split off. + let args_str = tail.join(":"); + spec.args = split_call_args(&args_str); + let legacy_args = + args_str.split(',').map(|arg| arg.trim().to_string()).collect::>(); + if legacy_args != spec.args { + spec.legacy_args = Some(legacy_args); + } + } + } + _ => {} + } + + Ok(spec) + } + + /// Resolves this spec into a [`Call`], encoding function arguments if needed. + /// `i` is the 0-based index of this call; displayed as `i + 1` in error messages. + pub async fn resolve>( + &self, + i: usize, + chain: Chain, + provider: &P, + etherscan_api_key: Option<&str>, + etherscan_api_url: Option<&str>, + ) -> Result { + let input = if let Some(data) = &self.data { + data.clone() + } else if let Some(sig) = &self.sig { + let mut result = None; + for args in self.legacy_args.iter().chain(std::iter::once(&self.args)) { + result = Some( + parse_function_args( + sig, + args.clone(), + Some(self.to), + chain, + provider, + etherscan_api_key, + etherscan_api_url, + ) + .await, + ); + if result.as_ref().is_some_and(Result::is_ok) { + break; + } + } + let (encoded, _) = result + .expect("argument candidates are never empty") + .map_err(|e| eyre!("Failed to encode call {}: {e}", i + 1))?; + Bytes::from(encoded) + } else { + Bytes::new() + }; + Ok(Call { to: self.to.into(), value: self.value, input }) + } +} + +/// Split call arguments on top-level commas, respecting nested parentheses, brackets, and quoted +/// strings. +/// +/// This ensures that array and tuple arguments containing internal commas are not incorrectly +/// split. For example: +/// - `[1,2]` stays as one argument +/// - `(7,hello),9` splits into `(7,hello)` and `9` +fn split_call_args(s: &str) -> Vec { + let mut args = Vec::new(); + let mut depth = 0usize; + let mut start = 0usize; + let mut quote = None; + let mut at_value_start = true; + + for (idx, ch) in s.char_indices() { + if let Some(active_quote) = quote { + if ch == active_quote { + quote = None; + } + continue; + } + + match ch { + '\'' | '"' if at_value_start => { + quote = Some(ch); + at_value_start = false; + } + '(' | '[' => { + depth += 1; + at_value_start = true; + } + ')' | ']' => { + depth = depth.saturating_sub(1); + at_value_start = false; + } + ',' if depth == 0 => { + args.push(s[start..idx].trim().to_string()); + start = idx + ch.len_utf8(); + at_value_start = true; + } + ',' => at_value_start = true, + ch if ch.is_whitespace() && at_value_start => {} + _ => { + at_value_start = false; + } + } + } + + args.push(s[start..].trim().to_string()); + args +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_provider::{ProviderBuilder, mock::Asserter}; + use foundry_common::abi::{encode_function_args, get_func}; + + const ADDRESS: &str = "0x1234567890123456789012345678901234567890"; + + async fn assert_resolves(cases: &[(&str, &str, &[&str])]) { + let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + for &(suffix, sig, args) in cases { + let spec = format!("{ADDRESS}{suffix}"); + let call = CallSpec::parse(&spec) + .unwrap() + .resolve(0, Chain::from_id(1), &provider, None, None) + .await + .unwrap(); + let expected = encode_function_args(&get_func(sig).unwrap(), args).unwrap(); + assert_eq!(call.input.as_ref(), expected, "call spec: {spec}"); + } + } + + #[test] + fn test_parse_address_and_value() { + let address = "0x1234567890123456789012345678901234567890"; + + let spec = CallSpec::parse(address).unwrap(); + assert_eq!(spec.to, address.parse::
().unwrap()); + assert_eq!(spec.value, U256::ZERO); + assert!(spec.sig.is_none() && spec.args.is_empty() && spec.data.is_none()); + + let spec = CallSpec::parse(&format!("{address}:1ether")).unwrap(); + assert_eq!(spec.value, parse_ether_value("1ether").unwrap()); + assert!(spec.sig.is_none()); + } + + #[test] + fn test_parse_lowercase_hex_value() { + let address = "0x1234567890123456789012345678901234567890"; + + let spec = CallSpec::parse(&format!("{address}:0x10:deposit()")).unwrap(); + assert_eq!(spec.value, U256::from(16)); + assert_eq!(spec.sig.as_deref(), Some("deposit()")); + + let spec = CallSpec::parse(&format!("{address}:0x10")).unwrap(); + assert_eq!(spec.value, U256::ZERO); + assert_eq!(spec.data, Some(Bytes::from([0x10]))); + } + + #[test] + fn test_parse_with_sig() { + let spec = CallSpec::parse( + "0x1234567890123456789012345678901234567890::transfer(address,uint256):0xabc,1000", + ) + .unwrap(); + assert_eq!(spec.value, U256::ZERO); + assert_eq!(spec.sig, Some("transfer(address,uint256)".to_string())); + assert_eq!(spec.args, vec!["0xabc", "1000"]); + } + + #[test] + fn test_parse_with_value_and_sig() { + let spec = CallSpec::parse( + "0x1234567890123456789012345678901234567890:0.5ether:transfer(address,uint256):0xabc,1000", + ) + .unwrap(); + assert_eq!(spec.value, parse_ether_value("0.5ether").unwrap()); + assert_eq!(spec.sig, Some("transfer(address,uint256)".to_string())); + } + + #[test] + fn test_parse_with_raw_data() { + let spec = CallSpec::parse("0x1234567890123456789012345678901234567890::0xabcdef").unwrap(); + assert_eq!(spec.value, U256::ZERO); + assert!(spec.sig.is_none()); + assert_eq!(spec.data, Some(Bytes::from(hex::decode("abcdef").unwrap()))); + } + + #[test] + fn test_parse_raw_data_rejects_trailing_fields() { + for spec in [ + "0x1234567890123456789012345678901234567890::0xabcdef:typo", + "0x1234567890123456789012345678901234567890:1wei:0xabcdef:unexpected", + ] { + assert_eq!( + CallSpec::parse(spec).unwrap_err().to_string(), + "Unexpected trailing field(s) after raw calldata" + ); + } + } + + #[test] + fn test_parse_array_args() { + let spec = + CallSpec::parse("0x1234567890123456789012345678901234567890::foo(uint256[]):[1,2]") + .unwrap(); + assert_eq!(spec.sig, Some("foo(uint256[])".to_string())); + assert_eq!(spec.args, vec!["[1,2]"]); + + let spec = CallSpec::parse( + "0x1234567890123456789012345678901234567890::foo(uint256[][]):[[1,2],[3,4]]", + ) + .unwrap(); + assert_eq!(spec.sig, Some("foo(uint256[][])".to_string())); + assert_eq!(spec.args, vec!["[[1,2],[3,4]]"]); + } + + #[test] + fn test_parse_tuple_args() { + let spec = CallSpec::parse( + "0x1234567890123456789012345678901234567890::foo((uint256,string)):(7,hello)", + ) + .unwrap(); + assert_eq!(spec.sig, Some("foo((uint256,string))".to_string())); + assert_eq!(spec.args, vec!["(7,hello)"]); + + let spec = CallSpec::parse( + "0x1234567890123456789012345678901234567890::foo((uint256,string),uint256):(7,hello),9", + ) + .unwrap(); + assert_eq!(spec.sig, Some("foo((uint256,string),uint256)".to_string())); + assert_eq!(spec.args, vec!["(7,hello)", "9"]); + } + + #[test] + fn test_parse_nested_structures() { + let spec = CallSpec::parse( + "0x1234567890123456789012345678901234567890::foo((uint256[],string)):([1,2],hello)", + ) + .unwrap(); + assert_eq!(spec.sig, Some("foo((uint256[],string))".to_string())); + assert_eq!(spec.args, vec!["([1,2],hello)"]); + } + + #[test] + fn test_split_call_args() { + assert_eq!(split_call_args("[1,2]"), vec!["[1,2]"]); + assert_eq!(split_call_args("[[1,2],[3,4]]"), vec!["[[1,2],[3,4]]"]); + assert_eq!(split_call_args("(7,hello)"), vec!["(7,hello)"]); + assert_eq!(split_call_args("(7,hello),9"), vec!["(7,hello)", "9"]); + assert_eq!(split_call_args("1,2,3"), vec!["1", "2", "3"]); + assert_eq!(split_call_args("(1,2),(3,4)"), vec!["(1,2)", "(3,4)"]); + assert_eq!(split_call_args("[1,2],[3,4]"), vec!["[1,2]", "[3,4]"]); + assert_eq!(split_call_args("\"a,b\",9"), vec!["\"a,b\"", "9"]); + assert_eq!(split_call_args("(7,\"a],b\"),9"), vec!["(7,\"a],b\")", "9"]); + assert_eq!(split_call_args("can't,9"), vec!["can't", "9"]); + } + + #[tokio::test] + async fn test_resolve_nested_args() { + const MIXED_ARGS: &str = "[(0x1111111111111111111111111111111111111111,[1,2]),(0x2222222222222222222222222222222222222222,[3])]"; + let mixed = + format!("{ADDRESS}:1ether:airdrop((address,uint256[])[],bytes):{MIXED_ARGS},0x00"); + assert_eq!(CallSpec::parse(&mixed).unwrap().value, parse_ether_value("1ether").unwrap()); + assert_resolves(&[ + ("::foo(uint256[][]):[[1,2],[3,4]]", "foo(uint256[][])", &["[[1,2],[3,4]]"]), + ( + ":1ether:airdrop((address,uint256[])[],bytes):[(0x1111111111111111111111111111111111111111,[1,2]),(0x2222222222222222222222222222222222222222,[3])],0x00", + "airdrop((address,uint256[])[],bytes)", + &[MIXED_ARGS, "0x00"], + ), + ( + "::foo((uint256,string),uint256):(7,hello),9", + "foo((uint256,string),uint256)", + &["(7,hello)", "9"], + ), + ( + "::foo((uint256,string),uint256):(7,\"a],b\"),9", + "foo((uint256,string),uint256)", + &["(7,\"a],b\")", "9"], + ), + ]) + .await; + } + + #[tokio::test] + async fn test_resolve_preserves_legacy_string_args() { + assert_resolves(&[ + ("::foo(string,string):(a,b)", "foo(string,string)", &["(a", "b)"]), + ("::foo(string,uint256):hello[,9", "foo(string,uint256)", &["hello[", "9"]), + ("::foo(string[],uint256):[\"[\"],9", "foo(string[],uint256)", &["[\"[\"]", "9"]), + ("::foo(string,uint256):can't,9", "foo(string,uint256)", &["can't", "9"]), + ]) + .await; + } + + #[tokio::test] + async fn test_resolve_quoted_commas_and_colons() { + assert_resolves(&[ + ("::foo(string,uint256):\"a,b\",9", "foo(string,uint256)", &["\"a,b\"", "9"]), + ("::foo(string,uint256):'a,b',9", "foo(string,uint256)", &["'a,b'", "9"]), + ("::foo(string,uint256): urn:a:b , 9", "foo(string,uint256)", &["urn:a:b", "9"]), + ]) + .await; + } + + #[tokio::test] + async fn test_resolve_empty_args() { + assert_resolves(&[ + ("::foo()", "foo()", &[]), + ("::foo(string):", "foo(string)", &[""]), + ("::foo(string,string):,value", "foo(string,string)", &["", "value"]), + ("::foo(string,string):value,", "foo(string,string)", &["value", ""]), + ("::foo(uint256[]):[]", "foo(uint256[])", &["[]"]), + ]) + .await; + } + + #[tokio::test] + async fn test_resolve_rejects_malformed_nested_args() { + let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + for args in ["[1,2)", "[1,2", "[1,2]]", "\"a,b,9"] { + let sig = if args.starts_with('[') { "foo(uint256[])" } else { "foo(string,uint256)" }; + let spec = CallSpec::parse(&format!("{ADDRESS}::{sig}:{args}")).unwrap(); + assert!( + spec.resolve(0, Chain::from_id(1), &provider, None, None).await.is_err(), + "malformed arguments unexpectedly encoded: {args}" + ); + } + } +} diff --git a/crates/cast/src/cmd/access_list.rs b/crates/cast/src/cmd/access_list.rs index d455c6ac6e760..a8d5b42f91ae8 100644 --- a/crates/cast/src/cmd/access_list.rs +++ b/crates/cast/src/cmd/access_list.rs @@ -1,16 +1,23 @@ -use crate::{ - Cast, - tx::{CastTxBuilder, SenderKind}, -}; +use super::auth::confirm_and_build; +use crate::tx::{CastTxBuilder, read_only_sender}; use alloy_ens::NameOrAddress; +use alloy_network::{Ethereum, Network}; +use alloy_provider::Provider; use alloy_rpc_types::BlockId; use clap::Parser; use eyre::Result; use foundry_cli::{ - opts::{EthereumOpts, TransactionOpts}, - utils::{self, LoadConfig}, + opts::{RpcOpts, TransactionOpts}, + utils::LoadConfig, }; +use foundry_common::{FoundryTransactionBuilder, provider::ProviderBuilder, shell}; +use foundry_config::Config; +use foundry_wallets::{BrowserWalletOpts, WalletOpts}; use std::str::FromStr; +use tempo_alloy::TempoNetwork; + +#[cfg(feature = "base")] +use base_common_network::Base; /// CLI arguments for `cast access-list`. #[derive(Debug, Parser)] @@ -27,9 +34,16 @@ pub struct AccessListArgs { sig: Option, /// The arguments of the function to call. - #[arg(value_name = "ARGS")] + #[arg(value_name = "ARGS", allow_negative_numbers = true)] args: Vec, + /// Raw hex-encoded data for the transaction. Used instead of `SIG` and `ARGS`. + #[arg( + long, + conflicts_with_all = &["sig", "args"] + )] + data: Option, + /// The block height to query at. /// /// Can also be the tags earliest, finalized, safe, latest, or pending. @@ -39,33 +53,104 @@ pub struct AccessListArgs { #[command(flatten)] tx: TransactionOpts, + /// Skip the EIP-7702 authorization disclosure confirmation. + #[arg(long)] + force: bool, + + #[command(flatten)] + rpc: RpcOpts, + + #[command(flatten)] + wallet: WalletOpts, + #[command(flatten)] - eth: EthereumOpts, + browser: BrowserWalletOpts, } impl AccessListArgs { pub async fn run(self) -> Result<()> { - let Self { to, sig, args, tx, eth, block } = self; + let config = self.rpc.load_config()?; + let requires_tempo = self.tx.tempo.is_tempo() || self.tx.tempo.session_id()?.is_some(); + let network = super::resolve_transaction_network(&config, requires_tempo).await?; + if network.is_tempo() { + return self.run_with_network::(config).await; + } + #[cfg(feature = "base")] + if network.is_base() { + super::validate_base_transaction_options(&self.tx)?; + return self.run_with_network::(config).await; + } + self.run_with_network::(config).await + } + + async fn run_with_network(self, config: Config) -> Result<()> + where + N::TransactionRequest: FoundryTransactionBuilder, + { + let Self { to, sig, args, data, tx, force, rpc: _, wallet, browser, block } = self; - let config = eth.load_config()?; - let provider = utils::get_provider(&config)?; - let sender = SenderKind::from_wallet_opts(eth.wallet).await?; + let provider = ProviderBuilder::::from_config(&config)?.build()?; + let chain_id = match config.chain { + Some(chain) => chain.id(), + None => provider.get_chain_id().await?, + }; + let (sender, _) = read_only_sender::(&browser, wallet, &tx, chain_id).await?; - let (tx, _) = CastTxBuilder::new(&provider, tx, &config) + let builder = CastTxBuilder::new(&provider, tx, &config) .await? .with_to(to) .await? - .with_code_sig_and_args(None, sig, args) + .with_code_sig_and_args(None, data.or(sig), args) .await? - .build_raw(sender) - .await?; + .raw(); + let Some(tx) = confirm_and_build(builder, sender, force, None, true).await? else { + return Ok(()); + }; - let cast = Cast::new(&provider); + let access_list = + provider.create_access_list(&tx).block_id(block.unwrap_or_default()).await?; + let access_list = if shell::is_json() { + serde_json::to_string(&access_list)? + } else { + if let Some(error) = &access_list.error { + sh_warn!("access list generated from a failed execution: {error}")?; + } + let mut s = + vec![format!("gas used: {}", access_list.gas_used), "access list:".to_string()]; + for al in access_list.access_list.0 { + s.push(format!("- address: {}", al.address.to_checksum(None))); + if !al.storage_keys.is_empty() { + s.push(" keys:".to_string()); + for key in al.storage_keys { + s.push(format!(" {key:?}")); + } + } + } + s.join("\n") + }; + sh_println!("{access_list}")?; + Ok(()) + } +} - let access_list: String = cast.access_list(&tx, block).await?; +#[cfg(test)] +mod tests { + use super::*; + use clap::error::ErrorKind; - sh_println!("{access_list}")?; + #[test] + fn data_conflicts_with_sig_and_args() { + let err = AccessListArgs::try_parse_from([ + "foundry-cli", + "0x0000000000000000000000000000000000000001", + "transfer(address,uint256)", + "0x0000000000000000000000000000000000000002", + "1", + "--data", + "0x1234", + ]) + .unwrap_err(); - Ok(()) + assert_eq!(err.kind(), ErrorKind::ArgumentConflict); } } diff --git a/crates/cast/src/cmd/artifact.rs b/crates/cast/src/cmd/artifact.rs index 42a0609262e2e..9aae38ce3abaf 100644 --- a/crates/cast/src/cmd/artifact.rs +++ b/crates/cast/src/cmd/artifact.rs @@ -1,19 +1,17 @@ -use super::{ - creation_code::{fetch_creation_code_from_etherscan, parse_code_output}, - interface::{fetch_abi_from_etherscan, load_abi_from_file}, -}; +use super::creation_code::{fetch_creation_code, load_abi, parse_code_output}; use alloy_primitives::Address; -use alloy_provider::Provider; -use clap::{Parser, command}; +use clap::Parser; use eyre::Result; use foundry_cli::{ opts::{EtherscanOpts, RpcOpts}, - utils::{self, LoadConfig}, + utils::LoadConfig, }; use foundry_common::fs; use serde_json::json; use std::path::PathBuf; +foundry_config::impl_figment_convert!(ArtifactArgs, etherscan, rpc); + /// CLI arguments for `cast artifact`. #[derive(Parser)] pub struct ArtifactArgs { @@ -45,45 +43,27 @@ pub struct ArtifactArgs { impl ArtifactArgs { pub async fn run(self) -> Result<()> { - let Self { contract, mut etherscan, rpc, output: output_location, abi_path } = self; - - let config = rpc.load_config()?; - let provider = utils::get_provider(&config)?; - let chain = provider.get_chain_id().await?; - etherscan.chain = Some(chain.into()); - - let abi = if let Some(ref abi_path) = abi_path { - load_abi_from_file(abi_path, None)? - } else { - fetch_abi_from_etherscan(contract, ðerscan).await? - }; + let mut config = self.load_config()?; + let Self { contract, output, abi_path, .. } = self; - let (abi, _) = abi.first().ok_or_else(|| eyre::eyre!("No ABI found"))?; - - let bytecode = fetch_creation_code_from_etherscan(contract, ðerscan, provider).await?; + let bytecode = fetch_creation_code(&mut config, contract).await?; + let abi_path = abi_path.as_deref(); + let abi = load_abi(contract, &config, abi_path).await?; let bytecode = - parse_code_output(bytecode, contract, ðerscan, abi_path.as_deref(), true, false) - .await?; - - let artifact = json!({ - "abi": abi, - "bytecode": { - "object": bytecode - } - }); + parse_code_output(bytecode, contract, &config, abi_path, true, false).await?; + let artifact = json!({ "abi": abi, "bytecode": { "object": bytecode } }); let artifact = serde_json::to_string_pretty(&artifact)?; - if let Some(loc) = output_location { + if let Some(loc) = output { if let Some(parent) = loc.parent() { fs::create_dir_all(parent)?; } fs::write(&loc, artifact)?; - sh_println!("Saved artifact at {}", loc.display())?; + sh_status!("Saved artifact at {}", loc.display())?; } else { sh_println!("{artifact}")?; } - Ok(()) } } diff --git a/crates/cast/src/cmd/auth.rs b/crates/cast/src/cmd/auth.rs new file mode 100644 index 0000000000000..53662edbd280b --- /dev/null +++ b/crates/cast/src/cmd/auth.rs @@ -0,0 +1,98 @@ +use super::confirm_continue; +use crate::tx::{CastTxBuilder, InputState, SenderKind, validate_authorizations}; +use alloy_network::{Network, TransactionBuilder}; +use alloy_provider::Provider; +use eyre::Result; +use foundry_cli::{ + opts::CliAuthorizationList, + utils::{ResolvedLane, maybe_print_resolved_lane}, +}; +use foundry_common::{FoundryTransactionBuilder, shell}; +use foundry_wallets::TempoAccountsWallet; + +/// Validates the authorization sender and confirms that the user intends to disclose an EIP-7702 +/// authorization to an RPC endpoint. +/// +/// Returns `false` when the user declines and the command should exit without sending the +/// authorization. +pub(super) fn confirm_auth_rpc_disclosure( + builder: &CastTxBuilder, + sender: &SenderKind<'_>, + force: bool, +) -> Result { + builder.validate_auth(sender)?; + confirm_auth_rpc_disclosure_after_validation(force) +} + +/// Validates and confirms disclosure before an execution network is resolved from the RPC. +pub(super) fn confirm_auth_rpc_disclosure_before_network_resolution( + authorizations: &[CliAuthorizationList], + sender: &SenderKind<'_>, + force: bool, +) -> Result { + validate_authorizations(authorizations, sender)?; + confirm_auth_rpc_disclosure_after_validation(force) +} + +fn confirm_auth_rpc_disclosure_after_validation(force: bool) -> Result { + if force { + return Ok(true); + } + if shell::is_quiet() { + eyre::bail!( + "EIP-7702 authorization disclosure requires confirmation; pass `--force` to continue with `--quiet`" + ); + } + + sh_warn!( + "This command will send a signed EIP-7702 authorization to the RPC endpoint. The authorization can be submitted on-chain by anyone once its nonce is valid." + )?; + confirm_continue() +} + +/// Confirms the authorization disclosure, builds the transaction for `sender` and prints the +/// resolved lane. `rpc_signs` marks modes where the RPC signs the transaction, which discloses +/// every authorization regardless of how the request is filled. +/// +/// Returns `None` when the user declined. +pub(super) async fn confirm_and_build<'a, N: Network, P: Provider>( + builder: CastTxBuilder, + sender: impl Into>, + force: bool, + lane: Option<&ResolvedLane>, + rpc_signs: bool, +) -> Result> +where + N::TransactionRequest: FoundryTransactionBuilder, +{ + let sender = sender.into(); + let discloses = + if rpc_signs { builder.has_auth() } else { builder.will_disclose_auth_during_build() }; + if discloses && !confirm_auth_rpc_disclosure(&builder, &sender, force)? { + return Ok(None); + } + let (tx, _) = builder.build(sender).await?; + maybe_print_resolved_lane(lane, tx.nonce().unwrap_or_default())?; + Ok(Some(tx)) +} + +/// [`confirm_and_build`] for a transaction signed by a Tempo access key; also returns the +/// prepared wallet. +pub(super) async fn confirm_and_build_with_tempo_wallet>( + builder: CastTxBuilder, + wallet: &TempoAccountsWallet, + force: bool, + lane: Option<&ResolvedLane>, +) -> Result> +where + N::TransactionRequest: FoundryTransactionBuilder, +{ + if builder.will_disclose_auth_during_build() + && !confirm_auth_rpc_disclosure(&builder, &wallet.account().into(), force)? + { + return Ok(None); + } + let (tx, _, prepared) = builder.build_with_tempo_wallet(wallet).await?; + maybe_print_resolved_lane(lane, tx.nonce().unwrap_or_default())?; + Ok(Some((tx, prepared))) +} diff --git a/crates/cast/src/cmd/b2e_payload.rs b/crates/cast/src/cmd/b2e_payload.rs new file mode 100644 index 0000000000000..d4f9dd88690cd --- /dev/null +++ b/crates/cast/src/cmd/b2e_payload.rs @@ -0,0 +1,59 @@ +//! Command Line handler to convert Beacon block's execution payload to Execution format. + +use alloy_rpc_types_beacon::payload::BeaconBlockData; +use clap::Parser; +use eyre::{Result, eyre}; +use foundry_common::{fs, sh_print}; + +/// CLI arguments for `cast b2e-payload`, convert Beacon block's execution payload to Execution +/// format. +#[derive(Parser)] +pub struct B2EPayloadArgs { + /// Input data, it can be either a file path to JSON file or raw JSON string containing the + /// beacon block + #[arg( + value_name = "INPUT", + help = "File path to JSON file or raw JSON string containing the beacon block" + )] + pub input: String, +} + +impl B2EPayloadArgs { + pub async fn run(self) -> Result<()> { + let json = read_input(self.input)?; + let beacon_block_data: BeaconBlockData = serde_json::from_str(&json) + .map_err(|e| eyre!("Failed to parse beacon block JSON: {}", e))?; + let output = serde_json::to_string(&beacon_block_data.execution_payload()) + .map_err(|e| eyre!("Failed to serialize execution payload: {}", e))?; + sh_print!("{}", output)?; + Ok(()) + } +} + +/// Returns `input` if it is raw JSON, otherwise reads it as a file path. +fn read_input(input: String) -> Result { + if serde_json::from_str::(&input).is_ok() { + return Ok(input); + } + fs::read_to_string(&input).map_err(|e| eyre!("Failed to read JSON file '{input}': {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn read_input_prefers_raw_json_over_paths() { + let json = r#"{"execution_payload": {"block_hash": "0x123"}}"#; + assert_eq!(read_input(json.to_string()).unwrap(), json); + let json = r#"[{"block": "data"}]"#; + assert_eq!(read_input(json.to_string()).unwrap(), json); + + let file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(file.path(), json).unwrap(); + assert_eq!(read_input(file.path().to_string_lossy().into_owned()).unwrap(), json); + + let err = read_input("not-json-{".to_string()).unwrap_err().to_string(); + assert!(err.starts_with("Failed to read JSON file 'not-json-{'"), "{err}"); + } +} diff --git a/crates/cast/src/cmd/bal.rs b/crates/cast/src/cmd/bal.rs new file mode 100644 index 0000000000000..b62853dfeb6a2 --- /dev/null +++ b/crates/cast/src/cmd/bal.rs @@ -0,0 +1,47 @@ +use crate::cmd::rpc_provider; +use alloy_primitives::Bytes; +use alloy_provider::Provider; +use alloy_rpc_types::BlockId; +use clap::Parser; +use eyre::Result; +use foundry_cli::{ + json::{print_json_object, print_scalar}, + opts::RpcOpts, +}; + +/// CLI arguments for `cast bal`. +#[derive(Debug, Parser)] +pub struct BalArgs { + /// The block height or hash to query at. + /// + /// Can also be the tags earliest, finalized, safe, latest, or pending. + block: Option, + + /// Print the RLP encoded block access list. + #[arg(long)] + raw: bool, + + #[command(flatten)] + rpc: RpcOpts, +} + +impl BalArgs { + pub async fn run(self) -> Result<()> { + let provider = rpc_provider(&self.rpc)?; + let block = self.block.unwrap_or_default(); + let bal = provider.get_block_access_list(block).await?.ok_or_else(|| missing_bal(block))?; + + // `eth_getBlockAccessListRaw` is not a specified method, so the encoding is done here + // rather than asked of the node. + if self.raw { + print_scalar(Bytes::from(alloy_rlp::encode(&bal))) + } else { + print_json_object(bal) + } + } +} + +/// The error for a block whose access list the node did not return. +fn missing_bal(block: BlockId) -> eyre::Report { + eyre::eyre!("block access list for {block} not found") +} diff --git a/crates/cast/src/cmd/batch_mktx.rs b/crates/cast/src/cmd/batch_mktx.rs new file mode 100644 index 0000000000000..477166131b893 --- /dev/null +++ b/crates/cast/src/cmd/batch_mktx.rs @@ -0,0 +1,155 @@ +//! `cast batch-mktx` command implementation. +//! +//! Creates a signed or unsigned batch transaction using Tempo's native call batching. +//! Outputs the RLP-encoded transaction hex. + +use crate::{ + cmd::{ + auth::{confirm_and_build, confirm_and_build_with_tempo_wallet}, + batch_send::with_batch_calls, + }, + tempo, + tx::{self, CastTxBuilder}, +}; +use alloy_consensus::SignableTransaction; +use alloy_eips::eip2718::Encodable2718; +use alloy_network::{EthereumWallet, NetworkTransactionBuilder}; +use alloy_primitives::{Address, hex}; +use alloy_provider::Provider; +use clap::Parser; +use eyre::Result; +use foundry_cli::{ + opts::{EthereumOpts, TransactionOpts}, + utils::{self, resolve_lane}, +}; +use foundry_common::FoundryTransactionBuilder; +use tempo_alloy::TempoNetwork; + +/// CLI arguments for `cast batch-mktx`. +/// +/// Creates a signed (or unsigned) batch transaction. +#[derive(Debug, Parser)] +pub struct BatchMakeTxArgs { + /// Call specifications in format: `to[:][:[:]]` or `to[:][:<0xdata>]` + /// + /// Examples: + /// --call "0x1234567890123456789012345678901234567890:0.1ether" (ETH transfer) + /// --call "0x1234567890123456789012345678901234567890::transfer(address,uint256): + /// 0x0987654321098765432109876543210987654321,1000" (ERC20 transfer) + /// --call "0x1234567890123456789012345678901234567890::0x123def" (raw calldata) + /// --call "0x1234567890123456789012345678901234567890:1ether:deposit()" (value + function + /// call) + #[arg(long = "call", value_name = "SPEC", required = true)] + pub calls: Vec, + + #[command(flatten)] + pub tx: TransactionOpts, + + /// Skip the EIP-7702 authorization disclosure confirmation. + #[arg(long)] + pub force: bool, + + #[command(flatten)] + pub eth: EthereumOpts, + + /// Generate a raw RLP-encoded unsigned transaction. + #[arg(long)] + pub raw_unsigned: bool, + + /// Call `eth_signTransaction` using the `--from` argument or $ETH_FROM as sender + #[arg(long, requires = "from", conflicts_with = "raw_unsigned")] + pub ethsign: bool, +} + +impl BatchMakeTxArgs { + pub async fn run(self) -> Result<()> { + let Self { calls, mut tx, force, eth, raw_unsigned, ethsign } = self; + let has_nonce = tx.nonce.is_some(); + let has_session = tx.tempo.session_id()?.is_some(); + let expires_at = tx.tempo.resolve_expires(); + + if has_session && raw_unsigned { + eyre::bail!("--tempo.session/TEMPO_SESSION_ID cannot be combined with --raw-unsigned"); + } + if has_session && ethsign { + eyre::bail!("--tempo.session/TEMPO_SESSION_ID cannot be combined with --ethsign"); + } + + let (config, provider) = tempo::tempo_provider(ð)?; + // The provider is not consulted for fee tokens in `--curl` mode. + let fee_provider = (!config.eth_rpc_curl).then_some(&provider); + let resolved_lane = resolve_lane(&mut tx.tempo, &config.root)?; + let lane = resolved_lane.as_ref(); + + let chain = utils::get_chain(config.chain, &provider).await?; + // A raw unsigned transaction needs no signer, but the access-key metadata still shapes + // the request. + let (signer, tempo_access_key) = if raw_unsigned { + (None, eth.wallet.maybe_signer_for_chain(chain.id()).await?.1) + } else { + tempo::resolve_session_or_wallet_signer(&tx.tempo, ð.wallet, chain.id()).await? + }; + + // Preserve key_id for modes that do not call build_with_tempo_wallet, such as raw unsigned. + if let Some(access_key) = &tempo_access_key { + tx.tempo.key_id = Some(access_key.key_id()?); + } + + let builder = CastTxBuilder::::new(&provider, tx, &config).await?; + let tx_builder = with_batch_calls(&calls, builder, &provider).await?; + tempo::print_expires(expires_at)?; + + if raw_unsigned { + if eth.wallet.from.is_none() && !has_nonce { + eyre::bail!( + "Missing required parameters for raw unsigned transaction. When --from is not provided, you must specify: --nonce" + ); + } + + let from = eth.wallet.from.unwrap_or(Address::ZERO); + let Some(mut tx) = confirm_and_build(tx_builder, from, force, lane, false).await? + else { + return Ok(()); + }; + tempo::resolve_and_print_fee_token(fee_provider, &mut tx, Some(from)).await?; + let raw_tx = hex::encode_prefixed(tx.build_unsigned()?.encoded_for_signing()); + sh_println!("{raw_tx}")?; + return Ok(()); + } + + if ethsign { + let Some(mut tx) = + confirm_and_build(tx_builder, config.sender, force, lane, true).await? + else { + return Ok(()); + }; + tempo::resolve_and_print_fee_token(fee_provider, &mut tx, Some(config.sender)).await?; + let signed_tx = provider.sign_transaction(tx).await?; + sh_println!("{signed_tx}")?; + return Ok(()); + } + + let signed_tx = if let Some(access_key) = &tempo_access_key { + let Some((mut tx, prepared)) = + confirm_and_build_with_tempo_wallet(tx_builder, access_key, force, lane).await? + else { + return Ok(()); + }; + tempo::resolve_and_print_fee_token(fee_provider, &mut tx, Some(prepared.account())) + .await?; + tx.sign_with_tempo_wallet(&prepared).await? + } else { + let (signer, from) = tx::resolve_send_signer(signer, ð).await?; + let Some(mut tx) = confirm_and_build(tx_builder, &signer, force, lane, false).await? + else { + return Ok(()); + }; + tempo::resolve_and_print_fee_token(fee_provider, &mut tx, Some(from)).await?; + tx.build(&EthereumWallet::new(signer)).await?.encoded_2718() + }; + + sh_println!("{}", hex::encode_prefixed(signed_tx))?; + + Ok(()) + } +} diff --git a/crates/cast/src/cmd/batch_send.rs b/crates/cast/src/cmd/batch_send.rs new file mode 100644 index 0000000000000..7a288571ce2dc --- /dev/null +++ b/crates/cast/src/cmd/batch_send.rs @@ -0,0 +1,146 @@ +//! `cast batch-send` command implementation. +//! +//! Sends a batch of calls as a single Tempo transaction using native call batching. +//! Uses a single type 0x76 transaction with multiple calls executed atomically. + +use crate::{ + call_spec::CallSpec, + cmd::{ + auth::{confirm_and_build, confirm_and_build_with_tempo_wallet}, + send::{SendOptions, cast_send, cast_send_with_tempo_wallet}, + }, + tempo, + tx::{self, CastTxBuilder, InitState, InputState, SendTxOpts, apply_poll_interval}, +}; +use alloy_network::EthereumWallet; +use alloy_provider::{Provider, ProviderBuilder as AlloyProviderBuilder}; +use clap::Parser; +use eyre::Result; +use foundry_cli::{ + opts::TransactionOpts, + utils::{self, resolve_lane}, +}; +use tempo_alloy::TempoNetwork; + +/// CLI arguments for `cast batch-send`. +/// +/// Sends multiple calls as a single atomic Tempo transaction. +#[derive(Debug, Parser)] +pub struct BatchSendArgs { + /// Call specifications in format: `to[:][:[:]]` or `to[:][:<0xdata>]` + /// + /// Examples: + /// --call "0x1234567890123456789012345678901234567890:0.1ether" (ETH transfer) + /// --call "0x1234567890123456789012345678901234567890::transfer(address,uint256): + /// 0x0987654321098765432109876543210987654321,1000" (ERC20 transfer) + /// --call "0x1234567890123456789012345678901234567890::0x123def" (raw calldata) + /// --call "0x1234567890123456789012345678901234567890:1ether:deposit()" (value + function + /// call) + #[arg(long = "call", value_name = "SPEC", required = true)] + pub calls: Vec, + + #[command(flatten)] + pub send_tx: SendTxOpts, + + #[command(flatten)] + pub tx: TransactionOpts, + + /// Skip the EIP-7702 authorization disclosure confirmation. + #[arg(long)] + pub force: bool, + + /// Send via `eth_sendTransaction` using the `--from` argument or $ETH_FROM as sender + #[arg(long, requires = "from")] + pub unlocked: bool, +} + +impl BatchSendArgs { + pub async fn run(self) -> Result<()> { + let Self { calls, send_tx, mut tx, force, unlocked } = self; + // Tempo sessions must sign with the session key; these modes route signing through a + // node-managed account or browser wallet instead. + if tx.tempo.session_id()?.is_some() && unlocked { + eyre::bail!("--tempo.session/TEMPO_SESSION_ID cannot be combined with --unlocked"); + } + tempo::ensure_session_not_browser(&tx.tempo, send_tx.browser.browser)?; + + let expires_at = tx.tempo.resolve_expires(); + + let (config, provider) = tempo::tempo_provider(&send_tx.eth)?; + let resolved_lane = resolve_lane(&mut tx.tempo, &config.root)?; + let lane = resolved_lane.as_ref(); + + apply_poll_interval(&provider, send_tx.poll_interval); + + let chain = utils::get_chain(config.chain, &provider).await?; + let (signer, tempo_access_key) = + tempo::resolve_session_or_wallet_signer(&tx.tempo, &send_tx.eth.wallet, chain.id()) + .await?; + + // Preserve key_id for modes that do not call build_with_tempo_wallet, such as unlocked. + if let Some(access_key) = &tempo_access_key { + tx.tempo.key_id = Some(access_key.key_id()?); + } + + let builder = CastTxBuilder::::new(&provider, tx, &config).await?; + let builder = with_batch_calls(&calls, builder, &provider).await?; + tempo::print_expires(expires_at)?; + + let send_opts = SendOptions::new(&send_tx, &config).resolving_fee_token(true, &config); + + if unlocked { + let Some(tx) = confirm_and_build(builder, config.sender, force, lane, false).await? + else { + return Ok(()); + }; + cast_send(provider, tx, &send_opts).await?; + } else if let Some(access_key) = &tempo_access_key { + let Some((tx_request, prepared)) = + confirm_and_build_with_tempo_wallet(builder, access_key, force, lane).await? + else { + return Ok(()); + }; + cast_send_with_tempo_wallet(&provider, tx_request, &prepared, &send_opts).await?; + } else { + let (signer, _) = tx::resolve_send_signer(signer, &send_tx.eth).await?; + let Some(tx_request) = confirm_and_build(builder, &signer, force, lane, false).await? + else { + return Ok(()); + }; + let provider = AlloyProviderBuilder::<_, _, TempoNetwork>::default() + .wallet(EthereumWallet::from(signer)) + .connect_provider(&provider); + cast_send(provider, tx_request, &send_opts).await?; + } + + Ok(()) + } +} + +/// Parses the `--call` specs, resolves them against the builder's chain and sets them as the +/// batch calls of the transaction. +pub(super) async fn with_batch_calls>( + calls: &[String], + mut builder: CastTxBuilder, + provider: &impl Provider, +) -> Result> { + let specs = calls.iter().map(|s| CallSpec::parse(s)).collect::>>()?; + let (etherscan_api_key, etherscan_api_url) = builder.etherscan_api(); + let mut tempo_calls = Vec::with_capacity(specs.len()); + for (i, spec) in specs.iter().enumerate() { + tempo_calls.push( + spec.resolve(i, builder.chain(), provider, etherscan_api_key, etherscan_api_url) + .await?, + ); + } + sh_status!("Building batch transaction with {} call(s)...", tempo_calls.len())?; + builder.tx_mut().calls = tempo_calls; + + // The builder requires a `to`; `build_aa` uses the calls instead, so point it at the first + // call's target. + builder + .with_to(specs.first().map(|spec| spec.to.into())) + .await? + .with_code_sig_and_args(None, None, vec![]) + .await +} diff --git a/crates/cast/src/cmd/bind.rs b/crates/cast/src/cmd/bind.rs index 159302344e528..61fdf1a9e4c6d 100644 --- a/crates/cast/src/cmd/bind.rs +++ b/crates/cast/src/cmd/bind.rs @@ -1,64 +1,20 @@ -use clap::{Parser, ValueHint}; +use clap::Parser; use eyre::Result; -use foundry_cli::opts::EtherscanOpts; -use std::path::PathBuf; - -const DEFAULT_CRATE_NAME: &str = "foundry-contracts"; -const DEFAULT_CRATE_VERSION: &str = "0.0.1"; /// CLI arguments for `cast bind`. #[derive(Clone, Debug, Parser)] pub struct BindArgs { - /// The contract address, or the path to an ABI Directory - /// - /// If an address is specified, then the ABI is fetched from Etherscan. - path_or_address: String, - - /// Path to where bindings will be stored - #[arg( - short, - long, - value_hint = ValueHint::DirPath, - value_name = "PATH" - )] - pub output_dir: Option, - - /// The name of the Rust crate to generate. - /// - /// This should be a valid crates.io crate name. However, this is currently not validated by - /// this command. - #[arg( - long, - default_value = DEFAULT_CRATE_NAME, - value_name = "NAME" - )] - crate_name: String, - - /// The version of the Rust crate to generate. - /// - /// This should be a standard semver version string. However, it is not currently validated by - /// this command. - #[arg( - long, - default_value = DEFAULT_CRATE_VERSION, - value_name = "VERSION" - )] - crate_version: String, - - /// Generate bindings as separate files. - #[arg(long)] - separate_files: bool, - - #[command(flatten)] - etherscan: EtherscanOpts, + /// Legacy `cast bind` arguments; the command has been removed. + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, } impl BindArgs { pub async fn run(self) -> Result<()> { - Err(eyre::eyre!( + eyre::bail!( "`cast bind` has been removed.\n\ Please use `cast source` to create a Forge project from a block explorer source\n\ and `forge bind` to generate the bindings to it instead." - )) + ) } } diff --git a/crates/cast/src/cmd/call.rs b/crates/cast/src/cmd/call.rs index 7948d22b9eb83..3f5c5190cc91d 100644 --- a/crates/cast/src/cmd/call.rs +++ b/crates/cast/src/cmd/call.rs @@ -1,45 +1,78 @@ +use super::{ + auth::{confirm_auth_rpc_disclosure, confirm_auth_rpc_disclosure_before_network_resolution}, + call_overrides::CallOverrideOpts, + fetch_code_via_rpc, print_raw_line, + run::{ + block_num_hash, call_tracer_frame, fetch_contracts_bytecode_from_trace, trace_addresses, + }, +}; use crate::{ - Cast, + debug::{ensure_remote_trace_context_unchanged, handle_traces, resolve_remote_trace_hardfork}, + rpc_trace::{call_frame_to_arena, call_tracer_config}, traces::TraceKind, - tx::{CastTxBuilder, SenderKind}, + tx::{CastTxBuilder, SenderKind, read_only_sender}, }; +use alloy_consensus::BlockHeader; +use alloy_dyn_abi::FunctionExt; +use alloy_eips::BlockNumHash; use alloy_ens::NameOrAddress; -use alloy_primitives::{Address, Bytes, TxKind, U256}; -use alloy_provider::Provider; +use alloy_network::{ + BlockResponse, Ethereum, NetworkTransactionBuilder, TransactionBuilder, + primitives::HeaderResponse, +}; +use alloy_primitives::{B256, Bytes, TxKind, U256, hex, map::AddressHashMap}; +use alloy_provider::{Provider, ext::DebugApi}; use alloy_rpc_types::{ - BlockId, BlockNumberOrTag, BlockOverrides, - state::{StateOverride, StateOverridesBuilder}, + BlockId, BlockNumberOrTag, TransactionInput, TransactionRequest, + trace::geth::{ + GethDebugBuiltInTracerType, GethDebugTracerType, GethDebugTracingCallOptions, + GethDebugTracingOptions, + }, }; use clap::Parser; -use eyre::Result; +use eyre::{Result, WrapErr}; use foundry_cli::{ - opts::{EthereumOpts, TransactionOpts}, - utils::{self, TraceResult, handle_traces, parse_ether_value}, + opts::{ChainValueParser, RpcOpts, TracingArgs, TransactionOpts}, + utils::{TraceResult, load_config_from_provider, parse_ether_value}, +}; +use foundry_common::{ + FoundryTransactionBuilder, + abi::{encode_function_args, get_func}, + fmt::{format_token, serialize_value_as_json}, + provider::{ProviderBuilder, curl_transport::generate_curl_command}, + sh_println, shell, }; -use foundry_common::shell; use foundry_compilers::artifacts::EvmVersion; use foundry_config::{ - Config, + Chain, Config, TracingConfig, figment::{ - self, Figment, Metadata, Profile, + self, Metadata, Profile, value::{Dict, Map}, }, }; use foundry_evm::{ - executors::TracingExecutor, + core::{ + FoundryBlock, FoundryTransaction, + decode::RevertDecoder, + evm::{EthEvmNetwork, FoundryEvmNetwork, TempoEvmNetwork}, + }, + executors::{ExecutorBuilder, TracingExecutor}, opts::EvmOpts, - traces::{InternalTraceMode, TraceMode}, + traces::{InternalTraceMode, SparsedTraceArena, TraceContext, TraceRequirements}, }; -use regex::Regex; -use revm::context::TransactionType; -use std::{str::FromStr, sync::LazyLock}; +use foundry_evm_networks::{NetworkConfigs, NetworkVariant}; +use foundry_wallets::{BrowserWalletOpts, WalletOpts}; +use revm::context::Block; +use std::str::FromStr; + +#[cfg(feature = "base")] +use foundry_evm::core::evm::BaseEvmNetwork; -use super::run::fetch_contracts_bytecode_from_trace; +#[cfg(feature = "monad")] +use foundry_evm::core::evm::MonadEvmNetwork; -// matches override pattern
:: -// e.g. 0x123:0x1:0x1234 -static OVERRIDE_PATTERN: LazyLock = - LazyLock::new(|| Regex::new(r"^([^:]+):([^:]+):([^:]+)$").unwrap()); +#[cfg(feature = "optimism")] +use foundry_evm::core::evm::OpEvmNetwork; /// CLI arguments for `cast call`. /// @@ -56,12 +89,15 @@ static OVERRIDE_PATTERN: LazyLock = /// /// ```bash /// cast call 0x... "transfer(address,uint256)" 0x... 100 \ -/// --override-balance 0x123:0x1234 \ -/// --override-nonce 0x123:1 \ -/// --override-code 0x123:0x1234 \ -/// --override-state 0x123:0x1:0x1234 -/// --override-state-diff 0x123:0x1:0x1234 +/// --override-balance 0x0000000000000000000000000000000000000001:0x1234 \ +/// --override-nonce 0x0000000000000000000000000000000000000001:1 \ +/// --override-code 0x0000000000000000000000000000000000000001:0x1234 \ +/// --override-state 0x0000000000000000000000000000000000000001:0x1:0x1234 \ +/// --override-state-diff 0x0000000000000000000000000000000000000001:0x1:0x1234 /// ``` +/// +/// `--delegate` builds on the same mechanism: it overrides the code of the `--from` address with +/// the destination's code so the call runs as a `delegatecall`. #[derive(Debug, Parser)] pub struct CallArgs { /// The destination of the transaction. @@ -72,9 +108,10 @@ pub struct CallArgs { sig: Option, /// The arguments of the function to call. + #[arg(allow_negative_numbers = true)] args: Vec, - /// Raw hex-encoded data for the transaction. Used instead of \[SIG\] and \[ARGS\]. + /// Raw hex-encoded data for the transaction. Used instead of `SIG` and `ARGS`. #[arg( long, conflicts_with_all = &["sig", "args"] @@ -85,23 +122,40 @@ pub struct CallArgs { #[arg(long, default_value_t = false)] trace: bool, - /// Disables the labels in the traces. - /// Can only be set with `--trace`. - #[arg(long, default_value_t = false, requires = "trace")] - disable_labels: bool, + /// Simulate the call as a `delegatecall` from the `--from` address. + /// + /// The destination's runtime code is applied as a code override on the `--from` address and + /// the call is then made to that address, so the destination's code runs in the caller's + /// storage context, like an on-chain `delegatecall`. + /// + /// Note that the executed code observes `msg.sender` (and `tx.origin`) equal to the `--from` + /// address itself, whereas in an on-chain `delegatecall` `msg.sender` is preserved from the + /// delegating contract's own caller. + #[arg(long, requires = "from", conflicts_with = "browser")] + delegate: bool, + + /// Fetch the call trace from the node via `debug_traceCall` (callTracer) and render it, + /// instead of re-executing the call locally like `--trace`. + /// + /// This is a call-tree view: nested calls, value, gas, emitted logs and revert data. It does + /// not provide the opcode / struct-log level detail of a local `--trace` / `--debug` run. + /// + /// The local-execution-only trace flags (`--debug`, `--decode-internal`, `--evm-version`) do + /// not apply, since the trace comes from the node rather than a local run. + #[arg( + long = "debug-trace-call", + default_value_t = false, + conflicts_with_all = ["trace", "debug", "decode_internal", "evm_version"] + )] + debug_trace_call: bool, /// Opens an interactive debugger. /// Can only be used with `--trace`. #[arg(long, requires = "trace")] debug: bool, - #[arg(long, requires = "trace")] - decode_internal: bool, - - /// Labels to apply to the traces; format: `address:label`. - /// Can only be used with `--trace`. - #[arg(long, requires = "trace")] - labels: Vec, + #[command(flatten)] + tracing: TracingArgs, /// The EVM Version to use. /// Can only be used with `--trace`. @@ -114,55 +168,40 @@ pub struct CallArgs { #[arg(long, short)] block: Option, - /// Enable Odyssey features. - #[arg(long, alias = "alphanet")] - pub odyssey: bool, - #[command(subcommand)] command: Option, #[command(flatten)] tx: TransactionOpts, + /// Skip the EIP-7702 authorization disclosure confirmation. + #[arg(long)] + force: bool, + + #[command(flatten)] + rpc: RpcOpts, + #[command(flatten)] - eth: EthereumOpts, + wallet: WalletOpts, + + #[command(flatten)] + browser: BrowserWalletOpts, + + #[arg( + short, + long, + alias = "chain-id", + env = "CHAIN", + value_parser = ChainValueParser::default(), + )] + pub chain: Option, /// Use current project artifacts for trace decoding. #[arg(long, visible_alias = "la")] pub with_local_artifacts: bool, - /// Override the balance of an account. - /// Format: address:balance - #[arg(long = "override-balance", value_name = "ADDRESS:BALANCE")] - pub balance_overrides: Option>, - - /// Override the nonce of an account. - /// Format: address:nonce - #[arg(long = "override-nonce", value_name = "ADDRESS:NONCE")] - pub nonce_overrides: Option>, - - /// Override the code of an account. - /// Format: address:code - #[arg(long = "override-code", value_name = "ADDRESS:CODE")] - pub code_overrides: Option>, - - /// Override the state of an account. - /// Format: address:slot:value - #[arg(long = "override-state", value_name = "ADDRESS:SLOT:VALUE")] - pub state_overrides: Option>, - - /// Override the state diff of an account. - /// Format: address:slot:value - #[arg(long = "override-state-diff", value_name = "ADDRESS:SLOT:VALUE")] - pub state_diff_overrides: Option>, - - /// Override the block timestamp. - #[arg(long = "block.time", value_name = "TIME")] - pub block_time: Option, - - /// Override the block number. - #[arg(long = "block.number", value_name = "NUMBER")] - pub block_number: Option, + #[command(flatten)] + pub overrides: CallOverrideOpts, } #[derive(Debug, Parser)] @@ -177,6 +216,7 @@ pub enum CallSubcommands { sig: Option, /// The arguments of the constructor. + #[arg(allow_negative_numbers = true)] args: Vec, /// Ether to send in the transaction. @@ -189,30 +229,213 @@ pub enum CallSubcommands { }, } +/// The `callTracer` options shared by `--debug-trace-call` and its `--curl` rendering. +fn call_tracer_options() -> GethDebugTracingCallOptions { + GethDebugTracingCallOptions::default().with_tracing_options( + GethDebugTracingOptions::default() + .with_tracer(GethDebugTracerType::from(GethDebugBuiltInTracerType::CallTracer)) + .with_call_config(call_tracer_config()), + ) +} + impl CallArgs { - pub async fn run(self) -> Result<()> { - let figment = Into::::into(&self.eth).merge(&self); - let evm_opts = figment.extract::()?; - let mut config = Config::from_provider(figment)?.sanitized(); - let state_overrides = self.get_state_overrides()?; - let block_overrides = self.get_block_overrides()?; + fn resolve_tracing(&self, config: &TracingConfig, verbosity: u8) -> TracingConfig { + if self.debug_trace_call { + self.tracing.resolve_call_tracer(config, verbosity) + } else { + self.tracing.resolve(config, verbosity) + } + } + + pub async fn run(mut self) -> Result<()> { + self.validate_trace_args()?; + + // Handle --curl mode early, before any provider interaction + if self.rpc.curl { + if self.trace { + eyre::bail!( + "--trace cannot be combined with --curl; use --debug-trace-call --curl instead" + ); + } + if self.browser.browser { + eyre::bail!("--browser cannot be combined with --curl; use --from
"); + } + if self.delegate { + // The code override that makes the call a `delegatecall` is read from the node, + // which `--curl` deliberately never contacts. + eyre::bail!("--delegate cannot be combined with --curl"); + } + if !self.tx.auth.is_empty() { + eyre::bail!("--auth cannot be combined with --curl"); + } + return self.run_curl().await; + } + + let figment = self.rpc.clone().into_figment(self.with_local_artifacts).merge(&self); + let (mut config, mut evm_opts) = super::load_cast_config_and_evm_opts(figment)?; + evm_opts.fork_url = Some(config.get_rpc_url_or_localhost_http()?.into_owned()); + let has_tempo_session = self.tx.tempo.session_id()?.is_some(); + let requires_tempo = self.tx.tempo.is_tempo() || has_tempo_session; + super::validate_tempo_network(&config, requires_tempo)?; + if requires_tempo { + evm_opts.networks = NetworkConfigs::with_tempo(); + } else if !evm_opts.networks.has_network_selection() + && let Some(chain) = config.chain + { + evm_opts.networks = + evm_opts.networks.try_with_chain_id(chain.id()).map_err(eyre::Report::msg)?; + } + if has_tempo_session && self.will_disclose_auth() { + eyre::bail!("Tempo sessions cannot be combined with EIP-7702 authorizations"); + } + let Some(auth_preflight) = self.preflight_auth_disclosure().await? else { + return Ok(()); + }; + evm_opts.infer_network_from_fork().await?; + if self.chain.is_none() + && let Some(chain_id) = evm_opts.env.chain_id + { + let chain = Chain::from_id(chain_id); + self.chain = Some(chain); + config.chain = Some(chain); + } + + match evm_opts.networks.execution_network() { + NetworkVariant::Tempo => { + self.run_with_network_and_opts::( + config, + evm_opts, + auth_preflight, + ExecutorBuilder::::new(), + ) + .await + } + #[cfg(feature = "base")] + NetworkVariant::Base => { + super::validate_base_transaction_options(&self.tx)?; + self.run_with_network_and_opts::( + config, + evm_opts, + auth_preflight, + ExecutorBuilder::::new(), + ) + .await + } + #[cfg(feature = "monad")] + NetworkVariant::Monad => { + self.run_with_network_and_opts::( + config, + evm_opts, + auth_preflight, + ExecutorBuilder::::new(), + ) + .await + } + #[cfg(feature = "optimism")] + NetworkVariant::Optimism => { + self.run_with_network_and_opts::( + config, + evm_opts, + auth_preflight, + ExecutorBuilder::::new(), + ) + .await + } + NetworkVariant::Ethereum => { + self.run_with_network_and_opts::( + config, + evm_opts, + auth_preflight, + ExecutorBuilder::::new(), + ) + .await + } + } + } + + /// Returns whether resolving this call can disclose an authorization before the transaction + /// builder exists. This mirrors the builder's disclosure check after applying `.raw()`. + const fn will_disclose_auth(&self) -> bool { + !self.tx.auth.is_empty() && (!self.trace || matches!(self.tx.access_list, Some(None))) + } + + /// Confirms the authorization disclosure before the network is resolved. + /// + /// Returns `None` when the user declined, otherwise whether the disclosure was confirmed + /// along with the sender resolved for it (absent for browser wallets). + async fn preflight_auth_disclosure( + &self, + ) -> Result>)>> { + if !self.will_disclose_auth() { + return Ok(Some((false, None))); + } + + let sender = if self.browser.browser { + None + } else { + Some(SenderKind::from_wallet_opts(self.wallet.clone(), &self.tx.auth).await?) + }; + let browser_sender = SenderKind::from(self.wallet.from.unwrap_or_default()); + let validation_sender = sender.as_ref().unwrap_or(&browser_sender); + if !confirm_auth_rpc_disclosure_before_network_resolution( + &self.tx.auth, + validation_sender, + self.force, + )? { + return Ok(None); + } + + Ok(Some((true, sender))) + } + + fn validate_trace_args(&self) -> Result<()> { + if !self.trace + && !self.debug_trace_call + && (self.tracing.disable_labels + || self.tracing.compact_labels + || !self.tracing.labels.is_empty() + || self.tracing.trace_depth.is_some()) + { + eyre::bail!("trace rendering options require `--trace` or `--debug-trace-call`"); + } + + if self.tracing.decode_internal && !self.trace { + eyre::bail!("`--decode-internal` requires `--trace`"); + } + + Ok(()) + } + + async fn run_with_network_and_opts( + self, + mut config: Box, + evm_opts: EvmOpts, + (auth_confirmed, auth_sender): (bool, Option>), + executor_builder: ExecutorBuilder, + ) -> Result<()> { + config.networks = evm_opts.networks; + let mut state_overrides = self.overrides.get_state_overrides()?; + let block_overrides = self.overrides.get_block_overrides()?; + config.tracing = self.resolve_tracing(&config.tracing, shell::verbosity()); + let tracing = config.tracing.clone(); let Self { - to, + mut to, mut sig, mut args, mut tx, - eth, command, block, trace, + debug_trace_call, evm_version, debug, - decode_internal, - labels, data, with_local_artifacts, - disable_labels, + wallet, + browser, + force, + delegate, .. } = self; @@ -220,10 +443,51 @@ impl CallArgs { sig = Some(data); } - let provider = utils::get_provider(&config)?; - let sender = SenderKind::from_wallet_opts(eth.wallet).await?; + let provider = ProviderBuilder::::from_config(&config)?.build()?; + let endpoint_identity = + if debug_trace_call { Some(evm_opts.fork_endpoint_identity().await?) } else { None }; + let sender = match auth_sender { + Some(sender) => sender, + None => { + let chain_id = match config.chain { + Some(chain) => chain.id(), + None => provider.get_chain_id().await?, + }; + read_only_sender::(&browser, wallet, &tx, chain_id).await?.0 + } + }; let from = sender.address(); + // A `delegatecall` runs the destination's code against the caller's storage, which + // `eth_call` cannot express. Overriding the caller's code with the destination's and + // then calling the caller reproduces that context. The retarget to the caller happens + // after the transaction is built, so calldata encoding and function resolution still + // see the destination. + if delegate { + if command.is_some() { + eyre::bail!("`--delegate` cannot be combined with `--create`"); + } + let Some(target) = to else { + eyre::bail!("`--delegate` requires a destination address"); + }; + let target = target.resolve(&provider).await?; + let overrides = state_overrides.get_or_insert_with(Default::default); + if overrides.get(&from).is_some_and(|account| account.code.is_some()) { + eyre::bail!("`--delegate` conflicts with `--override-code` for the sender {from}"); + } + // A code override for the destination is the state this call runs against, so it + // takes precedence over the deployed code. + let code = match overrides.get(&target).and_then(|account| account.code.clone()) { + Some(code) => code, + None => provider.get_code_at(target).block_id(block.unwrap_or_default()).await?, + }; + if code.is_empty() { + eyre::bail!("`--delegate` destination {target} has no code to delegate to"); + } + overrides.entry(from).or_default().code = Some(code); + to = Some(NameOrAddress::Address(target)); + } + let code = if let Some(CallSubcommands::Create { code, sig: create_sig, @@ -241,186 +505,444 @@ impl CallArgs { None }; - let (tx, func) = CastTxBuilder::new(&provider, tx, &config) + let builder = CastTxBuilder::new(&provider, tx, &config) .await? .with_to(to) .await? .with_code_sig_and_args(code, sig, args) .await? - .build_raw(sender) - .await?; + .raw(); + let will_disclose = + (!trace && builder.has_auth()) || builder.will_disclose_auth_during_build(); + if will_disclose + && !auth_confirmed + && !confirm_auth_rpc_disclosure(&builder, &sender, force)? + { + return Ok(()); + } + let (mut tx, func) = builder.build(sender).await?; + + // The delegate override put the destination's code on the sender, so the built call is + // aimed at the sender; the calldata above was still encoded against the destination. + if delegate { + tx.set_to(from); + } + + if debug_trace_call { + let endpoint_identity = endpoint_identity + .ok_or_else(|| eyre::eyre!("remote trace endpoint identity was not captured"))?; + let requested_block = block.unwrap_or(BlockId::latest()); + let fetched_block = provider.get_block(requested_block).await?; + let resolved_canonical_block = + if matches!(requested_block, BlockId::Number(_)) && !requested_block.is_pending() { + fetched_block.as_ref().map(block_num_hash) + } else { + None + }; + let block = pin_remote_trace_block( + requested_block, + fetched_block.as_ref().map(|block| block.header().hash()), + )?; + let block_time_override = block_overrides.as_ref().and_then(|overrides| overrides.time); + let mut call_options = call_tracer_options(); + // A contract that only exists through a `--override-code` entry has no on-chain + // code to fetch for local-artifact matching, so remember the override code before + // handing the overrides to `debug_traceCall`. + let mut override_bytecode = AddressHashMap::::default(); + if with_local_artifacts && let Some(overrides) = &state_overrides { + for (address, account) in overrides { + if let Some(code) = &account.code { + override_bytecode.insert(*address, code.clone()); + } + } + } + + // Honour the same state / block overrides as the local `--trace` path. + if let Some(state_overrides) = state_overrides { + call_options = call_options.with_state_overrides(state_overrides); + } + if let Some(block_overrides) = block_overrides { + call_options = call_options.with_block_overrides(block_overrides); + } + + let frame = call_tracer_frame( + provider.debug_trace_call(tx, block, call_options).await, + "debug_traceCall", + "drop `--debug-trace-call` to run the call locally with `--trace`", + "the requested block; use an archive endpoint, or target a more recent block with `--block`", + )?; + + let arena = SparsedTraceArena { + arena: call_frame_to_arena(&frame, None), + ignored: Default::default(), + diagnostics: Default::default(), + }; + let result = TraceResult { + success: frame.error.is_none() && frame.revert_reason.is_none(), + traces: Some(vec![(TraceKind::Execution, arena)]), + gas_used: frame.gas_used.saturating_to(), + }; + + // Local-artifact labeling matches deployed runtime bytecode against the + // project artifacts. There is no local executor on this path, so fetch the code + // over RPC for the addresses in the trace. Skip the extra round-trips unless + // local artifacts were requested. + let contracts_bytecode = if with_local_artifacts { + let mut contracts_bytecode = + fetch_code_via_rpc(&provider, trace_addresses(&result), block).await; + // The trace ran the override code, not the on-chain code, so the override + // wins for artifact matching. + contracts_bytecode.extend(override_bytecode); + contracts_bytecode + } else { + Default::default() + }; + let final_endpoint_identity = evm_opts.fork_endpoint_identity().await?; + ensure_remote_trace_context_unchanged(&endpoint_identity, &final_endpoint_identity)?; + + // The remote node executed this trace, so its reported family is authoritative for + // decoding even when the caller selected a compatible local EVM implementation. + let chain = alloy_chains::Chain::from_id(endpoint_identity.source_chain_id); + let block_timestamp = block_time_override + .or_else(|| fetched_block.as_ref().map(|block| block.header().timestamp())); + let resolved_hardfork = + resolve_remote_trace_hardfork(config.hardfork, &endpoint_identity, block_timestamp); + if let Some(resolved_block) = resolved_canonical_block { + let canonical_block = + provider.get_block_by_number(resolved_block.number.into()).await?; + ensure_remote_trace_block_is_canonical( + resolved_block, + canonical_block.as_ref().map(block_num_hash), + )?; + } + return handle_traces( + result, + &config, + { + let context = TraceContext::new( + chain, + endpoint_identity.network_profile, + resolved_hardfork, + ); + context.with_hardfork(context.decoding_hardfork(&config)) + }, + &contracts_bytecode, + &tracing, + with_local_artifacts, + false, + ) + .await; + } if trace { - if let Some(BlockId::Number(BlockNumberOrTag::Number(block_number))) = self.block { + if let Some(BlockId::Number(BlockNumberOrTag::Number(block_number))) = block { // Override Config `fork_block_number` (if set) with CLI value. config.fork_block_number = Some(block_number); } let create2_deployer = evm_opts.create2_deployer; - let (mut env, fork, chain, odyssey) = - TracingExecutor::get_fork_material(&config, evm_opts).await?; - - // modify settings that usually set in eth_call - env.evm_env.cfg_env.disable_block_gas_limit = true; - env.evm_env.block_env.gas_limit = u64::MAX; + let mut fork = TracingExecutor::::get_fork(&mut config, evm_opts).await?; + // Modify settings usually set in eth_call while keeping execution gas bounded. + fork.evm_env.cfg_env.disable_block_gas_limit = true; + fork.evm_env.cfg_env.tx_gas_limit_cap = Some(u64::MAX); - // Apply the block overrides. if let Some(block_overrides) = block_overrides { if let Some(number) = block_overrides.number { - env.evm_env.block_env.number = number.to(); + fork.evm_env.block_env.set_number(number.to()); } if let Some(time) = block_overrides.time { - env.evm_env.block_env.timestamp = U256::from(time); + fork.evm_env.block_env.set_timestamp(U256::from(time)); } } + fork.resolve_spec(&config, evm_version); + fork.extend_precompile_labels(&mut config); + let context = fork.context(); - let trace_mode = TraceMode::Call + let trace_requirements = TraceRequirements::none() + .with_calls(true) .with_debug(debug) - .with_decode_internal(if decode_internal { + .with_decode_internal(if tracing.decode_internal { InternalTraceMode::Full } else { InternalTraceMode::None }) - .with_state_changes(shell::verbosity() > 4); - let mut executor = TracingExecutor::new( - env, - fork, - evm_version, - trace_mode, - odyssey, + .with_state_changes(tracing.verbosity > 4); + let mut executor = fork.into_executor( + executor_builder, + trace_requirements, create2_deployer, state_overrides, )?; - let value = tx.value.unwrap_or_default(); - let input = tx.inner.input.into_input().unwrap_or_default(); - let tx_kind = tx.inner.to.expect("set by builder"); - let env_tx = &mut executor.env_mut().tx; + let value = tx.value().unwrap_or_default(); + let input = tx.input().cloned().unwrap_or_default(); + let tx_kind = tx.kind().expect("set by builder"); - if let Some(tx_type) = tx.inner.transaction_type { - env_tx.tx_type = tx_type; + // Apply a user-provided `--gas-limit` to the executor. `prepare_call_env` propagates + // the executor's gas limit to the executed call/deploy, so setting it here + // is what takes effect; writing it onto the tx env directly would be + // overwritten. + if let Some(gas_limit) = tx.gas_limit() { + executor.set_gas_limit(gas_limit); } - if let Some(access_list) = tx.inner.access_list { - env_tx.access_list = access_list; - - if env_tx.tx_type == TransactionType::Legacy as u8 { - env_tx.tx_type = TransactionType::Eip2930 as u8; - } + // `prepare_call_env` zeroes fees for tests, so apply the transaction options to the + // prepared env. Like `eth_call`, honour the requested fees without requiring the caller + // to afford them. Monad ignores `disable_fee_charge`, so reject priced traces there. + // Zero fees keep the zeroed base fee, skipping base fee validation like `eth_call`. + let (mut evm_env, mut env_tx) = executor.prepare_call_env(from, tx_kind, input, value); + let priority_fee = tx.max_priority_fee_per_gas().unwrap_or_default(); + let gas_price = tx.max_fee_per_gas().or(tx.gas_price()).unwrap_or_default(); + if gas_price != 0 || priority_fee != 0 { + eyre::ensure!( + !config.networks.execution_network().is_monad(), + "Nonzero fees are not supported for local Monad traces; use --debug-trace-call instead" + ); + evm_env.cfg_env.disable_fee_charge = true; + evm_env.block_env.set_basefee(executor.evm_env().block_env.basefee()); + env_tx.set_gas_price(gas_price); + env_tx.set_gas_priority_fee(Some(priority_fee)); + } + if let Some(max_fee_per_blob_gas) = tx.max_fee_per_blob_gas() { + env_tx.set_max_fee_per_blob_gas(max_fee_per_blob_gas); + } + if let Some(nonce) = tx.nonce() { + env_tx.set_nonce(nonce); + } + env_tx.set_tx_type(tx.output_tx_type().into()); + if let Some(access_list) = tx.access_list().cloned() { + env_tx.set_access_list(access_list); + } + if let Some(auth) = tx.authorization_list().cloned() { + env_tx.set_signed_authorization(auth); } let trace = match tx_kind { TxKind::Create => { - let deploy_result = executor.deploy(from, input, value, None); + let deploy_result = executor.deploy_with_env(evm_env, env_tx, None); TraceResult::try_from(deploy_result)? } - TxKind::Call(to) => TraceResult::from_raw( - executor.transact_raw(from, to, input, value)?, + TxKind::Call(_) => TraceResult::from_raw( + executor.transact_with_env(evm_env, env_tx)?, TraceKind::Execution, ), }; - let contracts_bytecode = fetch_contracts_bytecode_from_trace(&provider, &trace).await?; - handle_traces( + let contracts_bytecode = fetch_contracts_bytecode_from_trace(&executor, &trace)?; + return handle_traces( trace, &config, - chain, + context, &contracts_bytecode, - labels, + &tracing, with_local_artifacts, debug, - decode_internal, - disable_labels, ) - .await?; + .await; + } - return Ok(()); + let mut call = provider + .call(tx.clone()) + .block(block.unwrap_or_default()) + .with_block_overrides_opt(block_overrides); + if let Some(state_override) = state_overrides { + call = call.overrides(state_override) } - let response = Cast::new(&provider) - .call(&tx, func.as_ref(), block, state_overrides, block_overrides) - .await?; + let res = match call.await { + Ok(res) => res, + Err(err) => { + let data = err.as_error_resp().and_then(|payload| payload.as_revert_data()); + if let Some(data) = data { + let decoded = match RevertDecoder::new().maybe_decode_known(&data) { + Some(decoded) => Some(decoded), + None => crate::tx::decode_custom_error(&data).await.ok().flatten(), + }; + if let Some(decoded) = decoded { + return Err(err).wrap_err(format!("execution reverted: {decoded}")); + } + } + return Err(err.into()); + } + }; + let decoded = match func.as_ref() { + Some(func) => match func.abi_decode_output(res.as_ref()) { + Ok(decoded) => decoded, + Err(err) => { + // An empty response usually means the recipient is not a contract. + if res.is_empty() { + let Some(addr) = tx.to() else { + eyre::bail!("tx req is a contract deployment"); + }; + if let Ok(code) = + provider.get_code_at(addr).block_id(block.unwrap_or_default()).await + && code.is_empty() + { + eyre::bail!("contract {addr:?} does not have any code"); + } + } + return Err(err).wrap_err( + "could not decode output; did you specify the wrong function return data type?" + ); + } + }, + None => vec![], + }; + // handle case when return type is not specified + let response = if decoded.is_empty() { + res.to_string() + } else if shell::is_json() { + let tokens = decoded + .into_iter() + .map(|value| serialize_value_as_json(value, None, true)) + .collect::>>()?; + serde_json::to_string_pretty(&tokens).unwrap() + } else { + // seth compatible user-friendly return type conversions + decoded.iter().map(format_token).collect::>().join("\n") + }; + + // With `--delegate` the call targets the sender, whose code comes from the override and + // was already checked to be non-empty, so the on-chain code lookup would be misleading. if response == "0x" - && let Some(contract_address) = tx.to.and_then(|tx_kind| tx_kind.into_to()) + && !delegate + && let Some(contract_address) = tx.to() + && provider.get_code_at(contract_address).await?.is_empty() { - let code = provider.get_code_at(contract_address).await?; - if code.is_empty() { - sh_warn!("Contract code is empty")?; - } + sh_warn!("Contract code is empty")?; } - sh_println!("{}", response)?; - Ok(()) + print_raw_line(response) } - /// Parse state overrides from command line arguments. - pub fn get_state_overrides(&self) -> eyre::Result> { - // Early return if no override set - - if [ - self.balance_overrides.as_ref(), - self.nonce_overrides.as_ref(), - self.code_overrides.as_ref(), - self.state_overrides.as_ref(), - self.state_diff_overrides.as_ref(), - ] - .iter() - .all(Option::is_none) - { - return Ok(None); + /// Handle --curl mode by generating curl command without any RPC interaction. + async fn run_curl(self) -> Result<()> { + let figment = self.rpc.clone().into_figment(self.with_local_artifacts).merge(&self); + let config = load_config_from_provider(figment)?; + let has_tempo_session = self.tx.tempo.session_id()?.is_some(); + super::validate_tempo_network(&config, self.tx.tempo.is_tempo() || has_tempo_session)?; + if has_tempo_session { + eyre::bail!("--tempo.session/TEMPO_SESSION_ID cannot be combined with --curl"); } + let url = config.get_rpc_url_or_localhost_http()?; + let jwt = config.get_rpc_jwt_secret()?; + + // Get call data - either from --data or from sig + args + let data = if let Some(data) = &self.data { + hex::decode(data)? + } else if let Some(sig) = &self.sig { + // If sig is already hex data, use it directly + match hex::decode(sig) { + Ok(data) => data, + Err(_) => encode_function_args(&get_func(sig)?, &self.args)?, + } + } else { + Vec::new() + }; - let mut state_overrides_builder = StateOverridesBuilder::default(); + // Resolve the destination address (must be a raw address for curl mode) + let to = self.to.as_ref().map(|n| match n { + NameOrAddress::Address(addr) => Ok(*addr), + NameOrAddress::Name(name) => { + eyre::bail!("ENS names are not supported with --curl. Please use a raw address instead of '{}'", name); + } + }).transpose()?; + + // Apply explicit transaction options offline, using the configured chain's fee style + // when available. Blob sidecars and authorization lists still require the RPC builder. + let legacy = self.tx.legacy + || (config.chain.is_some_and(|chain| chain.is_legacy()) && self.tx.auth.is_empty()); + let mut call_request = TransactionRequest { + to: Some(to.map_or(TxKind::Create, TxKind::Call)), + from: self.wallet.from, + input: TransactionInput::new(data.into()).normalized_data(), + ..Default::default() + }; + // Curl currently emits Ethereum-compatible fields only. Keep Tempo options from changing + // the nonce without their accompanying network-specific fields. + let tx_opts = TransactionOpts { tempo: Default::default(), ..self.tx }; + tx_opts.apply::(&mut call_request, legacy); + + let block_param = self + .block + .map(|b| serde_json::to_value(b).unwrap_or(serde_json::json!("latest"))) + .unwrap_or(serde_json::json!("latest")); + + // `--debug-trace-call` fetches a callTracer trace of the call instead of executing it, + // so the curl payload must target `debug_traceCall` with the same third param as the + // non-curl path: the tracer options plus any state / block overrides, so the printed + // request traces the same state as the command it represents. + let (method, params) = if self.debug_trace_call { + let mut call_options = call_tracer_options(); + if let Some(state_overrides) = self.overrides.get_state_overrides()? { + call_options = call_options.with_state_overrides(state_overrides); + } + if let Some(block_overrides) = self.overrides.get_block_overrides()? { + call_options = call_options.with_block_overrides(block_overrides); + } + ("debug_traceCall", serde_json::json!([call_request, block_param, call_options])) + } else { + ("eth_call", serde_json::json!([call_request, block_param])) + }; - // Parse balance overrides - for override_str in self.balance_overrides.iter().flatten() { - let (addr, balance) = address_value_override(override_str)?; - state_overrides_builder = - state_overrides_builder.with_balance(addr.parse()?, balance.parse()?); - } + let curl_cmd = generate_curl_command( + url.as_ref(), + method, + params, + config.eth_rpc_headers.as_deref(), + jwt.as_deref(), + )?; - // Parse nonce overrides - for override_str in self.nonce_overrides.iter().flatten() { - let (addr, nonce) = address_value_override(override_str)?; - state_overrides_builder = - state_overrides_builder.with_nonce(addr.parse()?, nonce.parse()?); - } + sh_println!("{}", curl_cmd)?; + Ok(()) + } +} - // Parse code overrides - for override_str in self.code_overrides.iter().flatten() { - let (addr, code_str) = address_value_override(override_str)?; - state_overrides_builder = - state_overrides_builder.with_code(addr.parse()?, Bytes::from_str(code_str)?); - } +fn pin_remote_trace_block(requested: BlockId, fetched_hash: Option) -> Result { + if requested.is_pending() { + return Ok(requested); + } - // Parse state overrides - for override_str in self.state_overrides.iter().flatten() { - let (addr, slot, value) = address_slot_value_override(override_str)?; - state_overrides_builder = - state_overrides_builder.with_state(addr, [(slot.into(), value.into())]); + let fetched_hash = fetched_hash.ok_or_else(|| { + eyre::eyre!("block {requested:?} was not found while preparing the remote trace") + })?; + if let BlockId::Hash(requested_hash) = requested { + if requested_hash.block_hash != fetched_hash { + eyre::bail!( + "the RPC endpoint returned block {fetched_hash} for requested block {}; retry the command", + requested_hash.block_hash + ); } + // Preserve `requireCanonical` exactly as supplied by the caller. + return Ok(requested); + } - // Parse state diff overrides - for override_str in self.state_diff_overrides.iter().flatten() { - let (addr, slot, value) = address_slot_value_override(override_str)?; - state_overrides_builder = - state_overrides_builder.with_state_diff(addr, [(slot.into(), value.into())]); - } + Ok(BlockId::hash(fetched_hash)) +} - Ok(Some(state_overrides_builder.build())) +fn ensure_remote_trace_block_is_canonical( + expected: BlockNumHash, + actual: Option, +) -> Result<()> { + let Some(actual) = actual else { + eyre::bail!( + "block {} at {} changed canonicality while collecting its remote trace: the canonical block lookup no longer reports that height; retry the command", + expected.hash, + expected.number, + ); + }; + if actual != expected { + eyre::bail!( + "block {} at {} changed canonicality while collecting its remote trace: the canonical block lookup reported block {} at {}; retry the command", + expected.hash, + expected.number, + actual.hash, + actual.number, + ); } - /// Parse block overrides from command line arguments. - pub fn get_block_overrides(&self) -> eyre::Result> { - let mut overrides = BlockOverrides::default(); - if let Some(number) = self.block_number { - overrides = overrides.with_number(U256::from(number)); - } - if let Some(time) = self.block_time { - overrides = overrides.with_time(time); - } - if overrides.is_empty() { Ok(None) } else { Ok(Some(overrides)) } - } + Ok(()) } impl figment::Provider for CallArgs { @@ -431,216 +953,92 @@ impl figment::Provider for CallArgs { fn data(&self) -> Result, figment::Error> { let mut map = Map::new(); - if self.odyssey { - map.insert("odyssey".into(), self.odyssey.into()); - } - if let Some(evm_version) = self.evm_version { map.insert("evm_version".into(), figment::value::Value::serialize(evm_version)?); } + if let Some(chain) = self.chain { + map.insert("chain_id".into(), chain.id().into()); + } Ok(Map::from([(Config::selected_profile(), map)])) } } -/// Parse an override string in the format address:value. -fn address_value_override(address_override: &str) -> Result<(&str, &str)> { - address_override.split_once(':').ok_or_else(|| { - eyre::eyre!("Invalid override {address_override}. Expected
:") - }) -} - -/// Parse an override string in the format address:slot:value. -fn address_slot_value_override(address_override: &str) -> Result<(Address, U256, U256)> { - let captures = OVERRIDE_PATTERN.captures(address_override).ok_or_else(|| { - eyre::eyre!("Invalid override {address_override}. Expected
::") - })?; - - Ok(( - captures[1].parse()?, // Address - captures[2].parse()?, // Slot (U256) - captures[3].parse()?, // Value (U256) - )) -} - #[cfg(test)] mod tests { use super::*; - use alloy_primitives::{address, b256, fixed_bytes, hex}; + use alloy_eips::RpcBlockHash; #[test] - fn test_get_state_overrides() { - let call_args = CallArgs::parse_from([ - "foundry-cli", - "--override-balance", - "0x0000000000000000000000000000000000000001:2", - "--override-nonce", - "0x0000000000000000000000000000000000000001:3", - "--override-code", - "0x0000000000000000000000000000000000000001:0x04", - "--override-state", - "0x0000000000000000000000000000000000000001:5:6", - "--override-state-diff", - "0x0000000000000000000000000000000000000001:7:8", - ]); - let overrides = call_args.get_state_overrides().unwrap().unwrap(); - let address = address!("0x0000000000000000000000000000000000000001"); - if let Some(account_override) = overrides.get(&address) { - if let Some(balance) = account_override.balance { - assert_eq!(balance, U256::from(2)); - } - if let Some(nonce) = account_override.nonce { - assert_eq!(nonce, 3); - } - if let Some(code) = &account_override.code { - assert_eq!(*code, Bytes::from([0x04])); - } - if let Some(state) = &account_override.state - && let Some(value) = state.get(&b256!( - "0x0000000000000000000000000000000000000000000000000000000000000005" - )) - { - assert_eq!( - *value, - b256!("0x0000000000000000000000000000000000000000000000000000000000000006") - ); - } - if let Some(state_diff) = &account_override.state_diff - && let Some(value) = state_diff.get(&b256!( - "0x0000000000000000000000000000000000000000000000000000000000000007" - )) - { - assert_eq!( - *value, - b256!("0x0000000000000000000000000000000000000000000000000000000000000008") - ); - } + fn remote_trace_block_pinning() { + let hash = B256::repeat_byte(0x11); + + // Pending stays unpinned; every other block is pinned to the fetched hash. + assert_eq!(pin_remote_trace_block(BlockId::pending(), None).unwrap(), BlockId::pending()); + for requested in [ + BlockId::number(42), + BlockId::earliest(), + BlockId::latest(), + BlockId::safe(), + BlockId::finalized(), + ] { + assert_eq!(pin_remote_trace_block(requested, Some(hash)).unwrap(), BlockId::hash(hash)); } - } + let err = pin_remote_trace_block(BlockId::number(42), None).unwrap_err(); + assert!(err.to_string().contains("was not found while preparing the remote trace")); - #[test] - fn test_get_state_overrides_empty() { - let call_args = CallArgs::parse_from([""]); - let overrides = call_args.get_state_overrides().unwrap(); - assert_eq!(overrides, None); + // Hash requests keep `requireCanonical` and must match the response. + for require_canonical in [None, Some(false), Some(true)] { + let requested = BlockId::Hash(RpcBlockHash { block_hash: hash, require_canonical }); + assert_eq!(pin_remote_trace_block(requested, Some(hash)).unwrap(), requested); + } + let err = pin_remote_trace_block( + BlockId::hash_canonical(B256::repeat_byte(0x33)), + Some(B256::repeat_byte(0x44)), + ) + .unwrap_err(); + assert!(err.to_string().contains("returned block")); } #[test] - fn test_get_block_overrides() { - let mut call_args = CallArgs::parse_from([""]); - call_args.block_number = Some(1); - call_args.block_time = Some(2); - let overrides = call_args.get_block_overrides().unwrap().unwrap(); - assert_eq!(overrides.number, Some(U256::from(1))); - assert_eq!(overrides.time, Some(2)); - } + fn remote_trace_block_must_remain_canonical() { + let expected = BlockNumHash::new(42, B256::repeat_byte(0x55)); + ensure_remote_trace_block_is_canonical(expected, Some(expected)).unwrap(); - #[test] - fn test_get_block_overrides_empty() { - let call_args = CallArgs::parse_from([""]); - let overrides = call_args.get_block_overrides().unwrap(); - assert_eq!(overrides, None); - } + let err = ensure_remote_trace_block_is_canonical(expected, None).unwrap_err(); + assert!(err.to_string().contains("no longer reports that height"), "{err}"); - #[test] - fn test_address_value_override_success() { - let text = "0x0000000000000000000000000000000000000001:2"; - let (address, value) = address_value_override(text).unwrap(); - assert_eq!(address, "0x0000000000000000000000000000000000000001"); - assert_eq!(value, "2"); + let reorged = BlockNumHash::new(42, B256::repeat_byte(0x66)); + let err = ensure_remote_trace_block_is_canonical(expected, Some(reorged)).unwrap_err(); + assert!(err.to_string().contains("changed canonicality"), "{err}"); } #[test] - fn test_address_value_override_error() { - let text = "invalid_value"; - let error = address_value_override(text).unwrap_err(); - assert_eq!(error.to_string(), "Invalid override invalid_value. Expected
:"); - } - - #[test] - fn test_address_slot_value_override_success() { - let text = "0x0000000000000000000000000000000000000001:2:3"; - let (address, slot, value) = address_slot_value_override(text).unwrap(); - assert_eq!(*address, fixed_bytes!("0x0000000000000000000000000000000000000001")); - assert_eq!(slot, U256::from(2)); - assert_eq!(value, U256::from(3)); - } + fn chain_is_merged_into_config() { + let args = CallArgs::parse_from(["foundry-cli", "--chain", "1"]); + let config = Config::from_provider(Config::figment().merge(&args)).unwrap(); - #[test] - fn test_address_slot_value_override_error() { - let text = "invalid_value"; - let error = address_slot_value_override(text).unwrap_err(); - assert_eq!( - error.to_string(), - "Invalid override invalid_value. Expected
::" - ); + assert_eq!(config.chain, Some(Chain::mainnet())); } + /// Base chain IDs resolved to Optimism before Base support existed, so a build without the + /// `base` feature — which is what release binaries ship — must keep resolving them that way. #[test] - fn can_parse_call_data() { - let data = hex::encode("hello"); - let args = CallArgs::parse_from(["foundry-cli", "--data", data.as_str()]); - assert_eq!(args.data, Some(data)); - - let data = hex::encode_prefixed("hello"); - let args = CallArgs::parse_from(["foundry-cli", "--data", data.as_str()]); - assert_eq!(args.data, Some(data)); + #[cfg(all(not(feature = "base"), feature = "optimism"))] + fn chain_id_without_base_still_resolves_to_optimism() { + for chain_id in [8453, 84532] { + let networks = NetworkConfigs::default() + .try_with_chain_id(chain_id) + .unwrap_or_else(|error| panic!("chain ID {chain_id} must still resolve: {error}")); + assert!(networks.is_optimism(), "chain ID {chain_id} must resolve to Optimism"); + } } #[test] - fn can_parse_state_overrides() { - let args = CallArgs::parse_from([ - "foundry-cli", - "--override-balance", - "0x123:0x1234", - "--override-nonce", - "0x123:1", - "--override-code", - "0x123:0x1234", - "--override-state", - "0x123:0x1:0x1234", - ]); - - assert_eq!(args.balance_overrides, Some(vec!["0x123:0x1234".to_string()])); - assert_eq!(args.nonce_overrides, Some(vec!["0x123:1".to_string()])); - assert_eq!(args.code_overrides, Some(vec!["0x123:0x1234".to_string()])); - assert_eq!(args.state_overrides, Some(vec!["0x123:0x1:0x1234".to_string()])); - } + fn debug_trace_call_ignores_configured_internal_decoding() { + let args = CallArgs::parse_from(["foundry-cli", "--debug-trace-call"]); + let config = TracingConfig { decode_internal: true, ..Default::default() }; - #[test] - fn can_parse_multiple_state_overrides() { - let args = CallArgs::parse_from([ - "foundry-cli", - "--override-balance", - "0x123:0x1234", - "--override-balance", - "0x456:0x5678", - "--override-nonce", - "0x123:1", - "--override-nonce", - "0x456:2", - "--override-code", - "0x123:0x1234", - "--override-code", - "0x456:0x5678", - "--override-state", - "0x123:0x1:0x1234", - "--override-state", - "0x456:0x2:0x5678", - ]); - - assert_eq!( - args.balance_overrides, - Some(vec!["0x123:0x1234".to_string(), "0x456:0x5678".to_string()]) - ); - assert_eq!(args.nonce_overrides, Some(vec!["0x123:1".to_string(), "0x456:2".to_string()])); - assert_eq!( - args.code_overrides, - Some(vec!["0x123:0x1234".to_string(), "0x456:0x5678".to_string()]) - ); - assert_eq!( - args.state_overrides, - Some(vec!["0x123:0x1:0x1234".to_string(), "0x456:0x2:0x5678".to_string()]) - ); + assert!(!args.resolve_tracing(&config, 0).decode_internal); } } diff --git a/crates/cast/src/cmd/call_overrides.rs b/crates/cast/src/cmd/call_overrides.rs new file mode 100644 index 0000000000000..a83aee11cb0c8 --- /dev/null +++ b/crates/cast/src/cmd/call_overrides.rs @@ -0,0 +1,227 @@ +//! Shared `eth_call` state and block override options. + +use alloy_contract::{CallDecoder, EthCall}; +use alloy_network::Network; +use alloy_primitives::{Address, B256, Bytes, U256, map::HashMap}; +use alloy_rpc_types::{ + BlockOverrides, + state::{StateOverride, StateOverridesBuilder}, +}; +use clap::Args; +use eyre::Result; +use regex::Regex; +use std::{str::FromStr, sync::LazyLock}; + +// Matches override pattern
::. +// e.g. 0x123:0x1:0x1234. +static OVERRIDE_PATTERN: LazyLock = + LazyLock::new(|| Regex::new(r"^([^:]+):([^:]+):([^:]+)$").unwrap()); + +/// State and block overrides for an `eth_call`. +#[derive(Args, Clone, Debug, Default)] +pub struct CallOverrideOpts { + /// Override account balances. + /// Format: "address:balance,address:balance". + #[arg(long = "override-balance", value_name = "ADDRESS:BALANCE", value_delimiter = ',')] + pub balance_overrides: Option>, + + /// Override account nonces. + /// Format: "address:nonce,address:nonce". + #[arg(long = "override-nonce", value_name = "ADDRESS:NONCE", value_delimiter = ',')] + pub nonce_overrides: Option>, + + /// Override account code. + /// Format: "address:code,address:code". + #[arg(long = "override-code", value_name = "ADDRESS:CODE", value_delimiter = ',')] + pub code_overrides: Option>, + + /// Override account state and replace the current state entirely with the new one. + /// Format: "address:slot:value,address:slot:value". + #[arg(long = "override-state", value_name = "ADDRESS:SLOT:VALUE", value_delimiter = ',')] + pub state_overrides: Option>, + + /// Override specific account storage slots and preserve the rest of the state. + /// Format: "address:slot:value,address:slot:value". + #[arg(long = "override-state-diff", value_name = "ADDRESS:SLOT:VALUE", value_delimiter = ',')] + pub state_diff_overrides: Option>, + + /// Override the block timestamp. + #[arg(long = "block.time", value_name = "TIME")] + pub block_time: Option, + + /// Override the block number. + #[arg(long = "block.number", value_name = "NUMBER")] + pub block_number: Option, +} + +impl CallOverrideOpts { + /// Returns true when no state or block override was provided. + pub const fn is_empty(&self) -> bool { + !self.has_state_overrides() && self.block_time.is_none() && self.block_number.is_none() + } + + const fn has_state_overrides(&self) -> bool { + self.balance_overrides.is_some() + || self.nonce_overrides.is_some() + || self.code_overrides.is_some() + || self.state_overrides.is_some() + || self.state_diff_overrides.is_some() + } + + /// Applies the configured overrides to an `eth_call`. + pub fn apply<'a, D, N>(&self, mut call: EthCall<'a, D, N>) -> Result> + where + D: CallDecoder, + N: Network, + { + if let Some(state_overrides) = self.get_state_overrides()? { + call = call.overrides(state_overrides); + } + if let Some(block_overrides) = self.get_block_overrides()? { + call = call.with_block_overrides(block_overrides); + } + Ok(call) + } + + /// Parses state overrides from command line arguments. + pub fn get_state_overrides(&self) -> Result> { + // Early return if no override set - . + if !self.has_state_overrides() { + return Ok(None); + } + + let mut builder = StateOverridesBuilder::default(); + for (addr, balance) in address_value_overrides(&self.balance_overrides)? { + builder = builder.with_balance(addr.parse()?, balance.parse()?); + } + for (addr, nonce) in address_value_overrides(&self.nonce_overrides)? { + builder = builder.with_nonce(addr.parse()?, nonce.parse()?); + } + for (addr, code) in address_value_overrides(&self.code_overrides)? { + builder = builder.with_code(addr.parse()?, Bytes::from_str(code)?); + } + for (addr, entries) in address_slot_value_overrides(&self.state_overrides)? { + builder = builder.with_state(addr, entries); + } + for (addr, entries) in address_slot_value_overrides(&self.state_diff_overrides)? { + builder = builder.with_state_diff(addr, entries) + } + Ok(Some(builder.build())) + } + + /// Parses block overrides from command line arguments. + pub fn get_block_overrides(&self) -> Result> { + let mut overrides = BlockOverrides::default(); + if let Some(number) = self.block_number { + overrides = overrides.with_number(U256::from(number)); + } + if let Some(time) = self.block_time { + overrides = overrides.with_time(time); + } + Ok((!overrides.is_empty()).then_some(overrides)) + } +} + +/// Parses override strings in the format address:value. +fn address_value_overrides(overrides: &Option>) -> Result> { + overrides + .iter() + .flatten() + .map(|s| { + s.split_once(':') + .ok_or_else(|| eyre::eyre!("Invalid override {s}. Expected
:")) + }) + .collect() +} + +/// Parses override strings in the format address:slot:value, grouped by address. +fn address_slot_value_overrides( + overrides: &Option>, +) -> Result>> { + let mut parsed = HashMap::>::default(); + for s in overrides.iter().flatten() { + let captures = OVERRIDE_PATTERN.captures(s).ok_or_else(|| { + eyre::eyre!("Invalid override {s}. Expected
::") + })?; + let (slot, value): (U256, U256) = (captures[2].parse()?, captures[3].parse()?); + parsed.entry(captures[1].parse()?).or_default().insert(slot.into(), value.into()); + } + Ok(parsed) +} + +#[cfg(test)] +mod tests { + + use super::*; + use clap::Parser; + + #[derive(Debug, Parser)] + struct TestArgs { + #[command(flatten)] + overrides: CallOverrideOpts, + } + + #[test] + fn test_get_state_overrides() { + let args = TestArgs::parse_from([ + "foundry-cli", + "--override-balance", + "0x0000000000000000000000000000000000000001:2", + "--override-nonce", + "0x0000000000000000000000000000000000000001:3", + "--override-code", + "0x0000000000000000000000000000000000000001:0x04", + "--override-state", + "0x0000000000000000000000000000000000000001:5:6", + "--override-state-diff", + "0x0000000000000000000000000000000000000001:7:8", + ]); + let overrides = args.overrides.get_state_overrides().unwrap().unwrap(); + let address = Address::with_last_byte(1); + let account = overrides.get(&address).unwrap(); + + assert_eq!(account.balance, Some(U256::from(2))); + assert_eq!(account.nonce, Some(3)); + assert_eq!(account.code, Some(Bytes::from([0x04]))); + assert_eq!( + account.state.as_ref().unwrap().get(&B256::with_last_byte(5)), + Some(&B256::with_last_byte(6)) + ); + assert_eq!( + account.state_diff.as_ref().unwrap().get(&B256::with_last_byte(7)), + Some(&B256::with_last_byte(8)) + ); + } + + #[test] + fn test_get_state_overrides_empty() { + let args = TestArgs::parse_from([""]); + assert_eq!(args.overrides.get_state_overrides().unwrap(), None); + } + + #[test] + fn test_invalid_overrides() { + let args = TestArgs::parse_from(["foundry-cli", "--override-balance", "invalid_value"]); + assert_eq!( + args.overrides.get_state_overrides().unwrap_err().to_string(), + "Invalid override invalid_value. Expected
:" + ); + let args = TestArgs::parse_from(["foundry-cli", "--override-state", "invalid_value"]); + assert_eq!( + args.overrides.get_state_overrides().unwrap_err().to_string(), + "Invalid override invalid_value. Expected
::" + ); + } + + #[test] + fn test_get_block_overrides() { + let args = + TestArgs::parse_from(["foundry-cli", "--block.number", "1", "--block.time", "2"]); + let overrides = args.overrides.get_block_overrides().unwrap().unwrap(); + assert_eq!(overrides.number, Some(U256::ONE)); + assert_eq!(overrides.time, Some(2)); + + let args = TestArgs::parse_from([""]); + assert_eq!(args.overrides.get_block_overrides().unwrap(), None); + } +} diff --git a/crates/cast/src/cmd/constructor_args.rs b/crates/cast/src/cmd/constructor_args.rs index 38f324de5270d..da2ed4b69c7df 100644 --- a/crates/cast/src/cmd/constructor_args.rs +++ b/crates/cast/src/cmd/constructor_args.rs @@ -1,17 +1,17 @@ -use super::{ - creation_code::fetch_creation_code_from_etherscan, - interface::{fetch_abi_from_etherscan, load_abi_from_file}, +use super::creation_code::{ + constructor_args_offset, constructor_with_args, fetch_creation_code, load_abi, }; -use alloy_dyn_abi::DynSolType; +use alloy_dyn_abi::JsonAbiExt; use alloy_primitives::{Address, Bytes}; -use alloy_provider::Provider; -use clap::{Parser, command}; -use eyre::{OptionExt, Result, eyre}; +use clap::Parser; +use eyre::Result; use foundry_cli::{ opts::{EtherscanOpts, RpcOpts}, - utils::{self, LoadConfig}, + utils::LoadConfig, }; +foundry_config::impl_figment_convert!(ConstructorArgsArgs, etherscan, rpc); + /// CLI arguments for `cast creation-args`. #[derive(Parser)] pub struct ConstructorArgsArgs { @@ -32,64 +32,17 @@ pub struct ConstructorArgsArgs { impl ConstructorArgsArgs { pub async fn run(self) -> Result<()> { - let Self { contract, mut etherscan, rpc, abi_path } = self; - - let config = rpc.load_config()?; - let provider = utils::get_provider(&config)?; - let chain = provider.get_chain_id().await?; - etherscan.chain = Some(chain.into()); + let mut config = self.load_config()?; + let Self { contract, abi_path, .. } = self; - let bytecode = fetch_creation_code_from_etherscan(contract, ðerscan, provider).await?; + let bytecode = fetch_creation_code(&mut config, contract).await?; + let abi = load_abi(contract, &config, abi_path.as_deref()).await?; + let constructor = constructor_with_args(&abi)?; + let split = constructor_args_offset(constructor, &bytecode)?; - let args_arr = parse_constructor_args(bytecode, contract, ðerscan, abi_path).await?; - for arg in args_arr { - let _ = sh_println!("{arg}"); + for decoded in constructor.abi_decode_input(&bytecode[split..])? { + sh_println!("{} → {decoded:?}", Bytes::from(decoded.abi_encode()))?; } - Ok(()) } } - -/// Fetches the constructor arguments values and types from the creation bytecode and ABI. -async fn parse_constructor_args( - bytecode: Bytes, - contract: Address, - etherscan: &EtherscanOpts, - abi_path: Option, -) -> Result> { - let abi = if let Some(abi_path) = abi_path { - load_abi_from_file(&abi_path, None)? - } else { - fetch_abi_from_etherscan(contract, etherscan).await? - }; - - let abi = abi.into_iter().next().ok_or_eyre("No ABI found.")?; - let (abi, _) = abi; - - let constructor = abi.constructor.ok_or_else(|| eyre!("No constructor found."))?; - - if constructor.inputs.is_empty() { - return Err(eyre!("No constructor arguments found.")); - } - - let args_size = constructor.inputs.len() * 32; - let args_bytes = Bytes::from(bytecode[bytecode.len() - args_size..].to_vec()); - - let display_args: Vec = args_bytes - .chunks(32) - .enumerate() - .map(|(i, arg)| { - format_arg(&constructor.inputs[i].ty, arg).expect("Failed to format argument.") - }) - .collect(); - - Ok(display_args) -} - -fn format_arg(ty: &str, arg: &[u8]) -> Result { - let arg_type: DynSolType = ty.parse().expect("Invalid ABI type."); - let decoded = arg_type.abi_decode(arg)?; - let bytes = Bytes::from(arg.to_vec()); - - Ok(format!("{bytes} → {decoded:?}")) -} diff --git a/crates/cast/src/cmd/create2.rs b/crates/cast/src/cmd/create2.rs index 8852ac8a88b95..97210c7ea33c7 100644 --- a/crates/cast/src/cmd/create2.rs +++ b/crates/cast/src/cmd/create2.rs @@ -1,22 +1,82 @@ -use alloy_primitives::{Address, B256, U256, hex, keccak256}; -use clap::Parser; +use alloy_dyn_abi::JsonAbiExt; +use alloy_primitives::{Address, B256, U256, hex, hex::FromHex, keccak256}; +use clap::{Args, Parser, Subcommand}; use eyre::{Result, WrapErr}; +use foundry_cli::{ + json::print_scalar, + opts::BuildOpts, + utils::{LoadConfig, find_contract_artifacts, parse_constructor_args}, +}; +use foundry_common::{compile, shell}; +use foundry_compilers::{info::ContractInfo, utils::canonicalize}; use rand::{RngCore, SeedableRng, rngs::StdRng}; use regex::RegexSetBuilder; -use std::{ - sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }, - time::Instant, -}; +use std::time::Instant; // https://etherscan.io/address/0x4e59b44847b379578588920ca78fbf26c0b4956c#code const DEPLOYER: &str = "0x4e59b44847b379578588920ca78fbf26c0b4956c"; +#[derive(Clone, Debug, Subcommand)] +enum Create2Subcommand { + /// Compute a contract's CREATE2 init code hash. + #[command(visible_alias = "initcodehash")] + InitCodeHash(InitCodeHashArgs), +} + +foundry_config::impl_figment_convert!(InitCodeHashArgs, build); + +#[derive(Clone, Debug, Args)] +struct InitCodeHashArgs { + /// The contract identifier in the form `:`. + contract: ContractInfo, + + /// The constructor arguments. + #[arg(value_name = "ARGS", allow_negative_numbers = true)] + constructor_args: Vec, + + #[command(flatten)] + build: BuildOpts, +} + +impl InitCodeHashArgs { + fn run(&self) -> Result<()> { + let config = self.load_config()?; + let project = config.project()?; + let target_path = if let Some(path) = &self.contract.path { + canonicalize(project.root().join(path))? + } else { + project.find_contract_path(&self.contract.name)? + }; + + let output = compile::compile_target(&target_path, &project, true)?; + let (abi, bin, _) = find_contract_artifacts(output, &target_path, &self.contract.name)?; + let Some(bytecode) = bin.object.into_bytes() else { + eyre::bail!("contract contains unlinked libraries"); + }; + if bytecode.is_empty() { + eyre::bail!("no bytecode found in bin object for {}", self.contract.name); + } + + let mut init_code = bytecode.to_vec(); + if let Some(constructor) = &abi.constructor { + let params = parse_constructor_args(constructor, &self.constructor_args)?; + init_code.extend(constructor.abi_encode_input(¶ms)?); + } else if !self.constructor_args.is_empty() { + eyre::bail!("contract does not have a constructor"); + } + + print_scalar(keccak256(init_code))?; + Ok(()) + } +} + /// CLI arguments for `cast create2`. #[derive(Clone, Debug, Parser)] +#[command(subcommand_negates_reqs = true, args_conflicts_with_subcommands = true)] pub struct Create2Args { + #[command(subcommand)] + command: Option, + /// Prefix for the contract address. #[arg( long, @@ -89,14 +149,18 @@ pub struct Create2Args { no_random: bool, } -pub struct Create2Output { - pub address: Address, - pub salt: B256, -} - impl Create2Args { - pub fn run(self) -> Result { + pub fn execute(self) -> Result<()> { + if let Some(Create2Subcommand::InitCodeHash(args)) = &self.command { + return args.run(); + } + self.run().map(drop) + } + + /// Mines (or derives) the salt and returns the resulting address and salt. + fn run(self) -> Result<(Address, B256)> { let Self { + command: _, starts_with, ends_with, matching, @@ -111,19 +175,17 @@ impl Create2Args { no_random, } = self; - let init_code_hash = if let Some(init_code_hash) = init_code_hash { - hex::FromHex::from_hex(init_code_hash) - } else if let Some(init_code) = init_code { - hex::decode(init_code).map(keccak256) - } else { - unreachable!(); - }?; + let init_code_hash = match (init_code_hash, init_code) { + (Some(init_code_hash), _) => B256::from_hex(init_code_hash)?, + // Clap requires one of the two. + (None, init_code) => keccak256(hex::decode(init_code.unwrap_or_default())?), + }; if let Some(salt) = salt { - let salt = hex::FromHex::from_hex(salt)?; + let salt = B256::from_hex(salt)?; let address = deployer.create2(salt, init_code_hash); - sh_println!("{address}")?; - return Ok(Create2Output { address, salt }); + sh_println!("{address}\t{salt}")?; + return Ok((address, salt)); } let mut regexs = vec![]; @@ -154,7 +216,7 @@ impl Create2Args { if let Some(suffix) = ends_with { regexs.push(format!( r"{}$", - get_regex_hex_string(suffix).wrap_err("invalid prefix hex provided")? + get_regex_hex_string(suffix).wrap_err("invalid suffix hex provided")? )) } @@ -165,10 +227,10 @@ impl Create2Args { let regex = RegexSetBuilder::new(regexs).case_insensitive(!case_sensitive).build()?; - let mut n_threads = std::thread::available_parallelism().map_or(1, |n| n.get()); - if let Some(threads) = threads { - n_threads = n_threads.min(threads); - } + let mut n_threads = match threads { + Some(n) if n != 0 => n, + _ => std::thread::available_parallelism().map_or(1, |n| n.get()), + }; if cfg!(test) { n_threads = n_threads.min(2); } @@ -189,70 +251,43 @@ impl Create2Args { rng.fill_bytes(remaining); } - sh_println!("Configuration:")?; - sh_println!("Init code hash: {init_code_hash}")?; - sh_println!("Regex patterns: {:?}\n", regex.patterns())?; - sh_println!( + sh_status!("Configuration:")?; + sh_status!("Init code hash: {init_code_hash}")?; + sh_status!("Regex patterns: {:?}\n", regex.patterns())?; + sh_status!( "Starting to generate deterministic contract address with {n_threads} threads..." )?; - let mut handles = Vec::with_capacity(n_threads); - let found = Arc::new(AtomicBool::new(false)); let timer = Instant::now(); - - // Loops through all possible salts in parallel until a result is found. - // Each thread iterates over `(i..).step_by(n_threads)`. - for i in 0..n_threads { - // Create local copies for the thread. - let increment = n_threads; - let regex = regex.clone(); - let regex_len = regex.patterns().len(); - let found = Arc::clone(&found); - handles.push(std::thread::spawn(move || { - // Read the first bytes of the salt as a usize to be able to increment it. - struct B256Aligned(B256, [usize; 0]); - let mut salt = B256Aligned(salt, []); - // SAFETY: B256 is aligned to `usize`. - let salt_word = unsafe { - &mut *salt.0.as_mut_ptr().add(32 - usize::BITS as usize / 8).cast::() - }; - // Important: add the thread index to the salt to avoid duplicate results. - *salt_word = salt_word.wrapping_add(i); - - let mut checksum = [0; 42]; - loop { - // Stop if a result was found in another thread. - if found.load(Ordering::Relaxed) { - break None; - } - - // Calculate the `CREATE2` address. - #[expect(clippy::needless_borrows_for_generic_args)] - let addr = deployer.create2(&salt.0, &init_code_hash); - - // Check if the regex matches the calculated address' checksum. - let _ = addr.to_checksum_raw(&mut checksum, None); - // SAFETY: stripping 2 ASCII bytes ("0x") off of an already valid UTF-8 string - // is safe. - let s = unsafe { std::str::from_utf8_unchecked(checksum.get_unchecked(2..)) }; - if regex.matches(s).into_iter().count() == regex_len { - // Notify other threads that we found a result. - found.store(true, Ordering::Relaxed); - break Some((addr, salt.0)); - } - - // Increment the salt for the next iteration. - *salt_word = salt_word.wrapping_add(increment); - } - })); + let regex_len = regex.patterns().len(); + let mut checksum_buf = [0u8; 42]; + let mut hex_buf = [0u8; 40]; + let (address, salt) = super::miner::mine_salt(salt, n_threads, move |salt| { + #[expect(clippy::needless_borrows_for_generic_args)] + let addr = deployer.create2(&salt, &init_code_hash); + // Use checksum format only when case_sensitive is enabled — it requires an extra + // keccak256 call, so we fall back to plain hex when case sensitivity is off. + let s = if case_sensitive { + let _ = addr.to_checksum_raw(&mut checksum_buf, None); + // SAFETY: stripping 2 ASCII bytes ("0x") off of an already valid UTF-8 string. + unsafe { std::str::from_utf8_unchecked(checksum_buf.get_unchecked(2..)) } + } else { + // SAFETY: hex::encode_to_slice always produces valid UTF-8 (hex digits). + let _ = hex::encode_to_slice(addr.as_slice(), &mut hex_buf); + unsafe { std::str::from_utf8_unchecked(&hex_buf) } + }; + (regex.matches(s).into_iter().count() == regex_len).then_some((addr, salt)) + }) + .ok_or_else(|| eyre::eyre!("create2 salt mining failed: all threads panicked"))?; + sh_status!("Successfully found contract address in {:?}", timer.elapsed())?; + sh_status!("Address: {address}")?; + sh_status!("Salt: {salt} ({})", Into::::into(salt))?; + // The machine-readable stdout record duplicates the prose above when stdout is an + // interactive terminal. + if !shell::is_out_tty() { + sh_println!("{address}\t{salt}")?; } - let results = handles.into_iter().filter_map(|h| h.join().unwrap()).collect::>(); - let (address, salt) = results.into_iter().next().unwrap(); - sh_println!("Successfully found contract address in {:?}", timer.elapsed())?; - sh_println!("Address: {address}")?; - sh_println!("Salt: {salt} ({})", U256::from_be_bytes(salt.0))?; - - Ok(Create2Output { address, salt }) + Ok((address, salt)) } } @@ -269,85 +304,64 @@ mod tests { use alloy_primitives::{address, b256}; use std::str::FromStr; + const ZERO_HASH: &str = + "--init-code-hash=0x0000000000000000000000000000000000000000000000000000000000000000"; + + fn run(args: &[&str]) -> Result<(Address, B256)> { + Create2Args::parse_from(["foundry-cli"].iter().chain(args)).run() + } + #[test] fn basic_create2() { - let mk_args = |args: &[&str]| { - Create2Args::parse_from(["foundry-cli", "--init-code-hash=0x0000000000000000000000000000000000000000000000000000000000000000"].iter().chain(args)) - }; + for (flag, pattern) in [ + ("--starts-with", "aa"), + ("--ends-with", "bb"), + ("--starts-with", "aaa"), + ("--ends-with", "bbb"), + ("--starts-with", "0xaa"), + ("--starts-with", "0xaaa"), + ] { + let (address, _) = run(&[ZERO_HASH, flag, pattern]).unwrap(); + let address = format!("{address:x}"); + let pattern = pattern.trim_start_matches("0x"); + assert!( + if flag == "--starts-with" { + address.starts_with(pattern) + } else { + address.ends_with(pattern) + }, + "{flag} {pattern}: {address}" + ); + } - // even hex chars - let args = mk_args(&["--starts-with", "aa"]); - let create2_out = args.run().unwrap(); - assert!(format!("{:x}", create2_out.address).starts_with("aa")); - - let args = mk_args(&["--ends-with", "bb"]); - let create2_out = args.run().unwrap(); - assert!(format!("{:x}", create2_out.address).ends_with("bb")); - - // odd hex chars - let args = mk_args(&["--starts-with", "aaa"]); - let create2_out = args.run().unwrap(); - assert!(format!("{:x}", create2_out.address).starts_with("aaa")); - - let args = mk_args(&["--ends-with", "bbb"]); - let create2_out = args.run().unwrap(); - assert!(format!("{:x}", create2_out.address).ends_with("bbb")); - - // even hex chars with 0x prefix - let args = mk_args(&["--starts-with", "0xaa"]); - let create2_out = args.run().unwrap(); - assert!(format!("{:x}", create2_out.address).starts_with("aa")); - - // odd hex chars with 0x prefix - let args = mk_args(&["--starts-with", "0xaaa"]); - let create2_out = args.run().unwrap(); - assert!(format!("{:x}", create2_out.address).starts_with("aaa")); - - // check fails on wrong chars - let args = mk_args(&["--starts-with", "0xerr"]); - let create2_out = args.run(); - assert!(create2_out.is_err()); - - // check fails on wrong x prefixed string provided - let args = mk_args(&["--starts-with", "x00"]); - let create2_out = args.run(); - assert!(create2_out.is_err()); + // Non-hex and misplaced prefixes are rejected. + assert!(run(&[ZERO_HASH, "--starts-with", "0xerr"]).is_err()); + assert!(run(&[ZERO_HASH, "--starts-with", "x00"]).is_err()); } #[test] fn matches_pattern() { - let args = Create2Args::parse_from([ - "foundry-cli", - "--init-code-hash=0x0000000000000000000000000000000000000000000000000000000000000000", - "--matching=0xbbXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", - ]); - let create2_out = args.run().unwrap(); - let address = create2_out.address; + let (address, _) = + run(&[ZERO_HASH, "--matching=0xbbXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"]).unwrap(); assert!(format!("{address:x}").starts_with("bb")); } #[test] fn create2_salt() { - let args = Create2Args::parse_from([ - "foundry-cli", + let (address, _) = run(&[ "--deployer=0x8ba1f109551bD432803012645Ac136ddd64DBA72", "--salt=0x7c5ea36004851c764c44143b1dcb59679b11c9a68e5f41497f6cf3d480715331", "--init-code=0x6394198df16000526103ff60206004601c335afa6040516060f3", - ]); - let create2_out = args.run().unwrap(); - let address = create2_out.address; + ]) + .unwrap(); assert_eq!(address, address!("0x533AE9D683B10C02EBDB05471642F85230071FC3")); } #[test] fn create2_init_code() { let init_code = "00"; - let args = - Create2Args::parse_from(["foundry-cli", "--starts-with=cc", "--init-code", init_code]); - let create2_out = args.run().unwrap(); - let address = create2_out.address; + let (address, salt) = run(&["--starts-with=cc", "--init-code", init_code]).unwrap(); assert!(format!("{address:x}").starts_with("cc")); - let salt = create2_out.salt; let deployer = Address::from_str(DEPLOYER).unwrap(); assert_eq!(address, deployer.create2_from_code(salt, hex::decode(init_code).unwrap())); } @@ -355,87 +369,54 @@ mod tests { #[test] fn create2_init_code_hash() { let init_code_hash = "bc36789e7a1e281436464229828f817d6612f7b477d66591ff96a9e064bcc98a"; - let args = Create2Args::parse_from([ - "foundry-cli", - "--starts-with=dd", - "--init-code-hash", - init_code_hash, - ]); - let create2_out = args.run().unwrap(); - let address = create2_out.address; + let (address, salt) = + run(&["--starts-with=dd", "--init-code-hash", init_code_hash]).unwrap(); assert!(format!("{address:x}").starts_with("dd")); - - let salt = create2_out.salt; let deployer = Address::from_str(DEPLOYER).unwrap(); - - assert_eq!( - address, - deployer - .create2(salt, B256::from_slice(hex::decode(init_code_hash).unwrap().as_slice())) - ); + assert_eq!(address, deployer.create2(salt, B256::from_str(init_code_hash).unwrap())); } #[test] fn create2_caller() { - let init_code_hash = "bc36789e7a1e281436464229828f817d6612f7b477d66591ff96a9e064bcc98a"; - let args = Create2Args::parse_from([ - "foundry-cli", + let (address, salt) = run(&[ "--starts-with=dd", - "--init-code-hash", - init_code_hash, + "--init-code-hash=bc36789e7a1e281436464229828f817d6612f7b477d66591ff96a9e064bcc98a", "--caller=0x66f9664f97F2b50F62D13eA064982f936dE76657", - ]); - let create2_out = args.run().unwrap(); - let address = create2_out.address; - let salt = create2_out.salt; + ]) + .unwrap(); assert!(format!("{address:x}").starts_with("dd")); assert!(format!("{salt:x}").starts_with("66f9664f97f2b50f62d13ea064982f936de76657")); } #[test] fn deterministic_seed() { - let args = Create2Args::parse_from([ - "foundry-cli", + let (address, salt) = run(&[ "--starts-with=0x00", "--init-code-hash=0x479d7e8f31234e208d704ba1a123c76385cea8a6981fd675b784fbd9cffb918d", "--seed=0x479d7e8f31234e208d704ba1a123c76385cea8a6981fd675b784fbd9cffb918d", "-j1", - ]); - let out = args.run().unwrap(); - assert_eq!(out.address, address!("0x00614b3D65ac4a09A376a264fE1aE5E5E12A6C43")); + ]) + .unwrap(); + assert_eq!(address, address!("0x00614b3D65ac4a09A376a264fE1aE5E5E12A6C43")); assert_eq!( - out.salt, - b256!("0x322113f523203e2c0eb00bbc8e69208b0eb0c8dad0eaac7b01d64ff016edb40d"), + salt, + b256!("0x322113f523203e2c0eb00bbc8e69208b0eb0c8dad0eaac7b01d64ff016edb40d") ); } #[test] fn deterministic_output() { - let args = Create2Args::parse_from([ - "foundry-cli", + let (address, salt) = run(&[ "--starts-with=0x00", "--init-code-hash=0x479d7e8f31234e208d704ba1a123c76385cea8a6981fd675b784fbd9cffb918d", "--no-random", "-j1", - ]); - let out = args.run().unwrap(); - assert_eq!(out.address, address!("0x00bF495b8b42fdFeb91c8bCEB42CA4eE7186AEd2")); - assert_eq!( - out.salt, - b256!("0x000000000000000000000000000000000000000000000000df00000000000000"), - ); - } - - #[test] - fn j0() { - let args = Create2Args::try_parse_from([ - "foundry-cli", - "--starts-with=00", - "--init-code-hash", - &B256::ZERO.to_string(), - "-j0", ]) .unwrap(); - assert_eq!(args.threads, Some(0)); + assert_eq!(address, address!("0x00bF495b8b42fdFeb91c8bCEB42CA4eE7186AEd2")); + assert_eq!( + salt, + b256!("0x000000000000000000000000000000000000000000000000df00000000000000") + ); } } diff --git a/crates/cast/src/cmd/creation_code.rs b/crates/cast/src/cmd/creation_code.rs index cf0847eaa69eb..dacfe355e4527 100644 --- a/crates/cast/src/cmd/creation_code.rs +++ b/crates/cast/src/cmd/creation_code.rs @@ -1,17 +1,19 @@ -use super::interface::{fetch_abi_from_etherscan, load_abi_from_file}; -use crate::SimpleCast; +use super::interface::load_abi_from_file; use alloy_consensus::Transaction; +use alloy_dyn_abi::{DynSolType, Specifier}; +use alloy_json_abi::{Constructor, JsonAbi}; use alloy_primitives::{Address, Bytes}; use alloy_provider::{Provider, ext::TraceApi}; use alloy_rpc_types::trace::parity::{Action, CreateAction, CreateOutput, TraceOutput}; -use clap::{Parser, command}; +use clap::Parser; use eyre::{OptionExt, Result, eyre}; -use foundry_block_explorers::Client; use foundry_cli::{ opts::{EtherscanOpts, RpcOpts}, - utils::{self, LoadConfig}, + utils::{self, LoadConfig, fetch_abi_from_etherscan}, }; -use foundry_common::provider::RetryProvider; +use foundry_config::Config; + +foundry_config::impl_figment_convert!(CreationCodeArgs, etherscan, rpc); /// CLI arguments for `cast creation-code`. #[derive(Parser)] @@ -45,20 +47,14 @@ pub struct CreationCodeArgs { impl CreationCodeArgs { pub async fn run(self) -> Result<()> { - let Self { contract, mut etherscan, rpc, disassemble, without_args, only_args, abi_path } = - self; - - let config = rpc.load_config()?; - let provider = utils::get_provider(&config)?; - let chain = provider.get_chain_id().await?; - etherscan.chain = Some(chain.into()); - - let bytecode = fetch_creation_code_from_etherscan(contract, ðerscan, provider).await?; + let mut config = self.load_config()?; + let Self { contract, disassemble, without_args, only_args, abi_path, .. } = self; + let bytecode = fetch_creation_code(&mut config, contract).await?; let bytecode = parse_code_output( bytecode, contract, - ðerscan, + &config, abi_path.as_deref(), without_args, only_args, @@ -66,11 +62,10 @@ impl CreationCodeArgs { .await?; if disassemble { - let _ = sh_println!("{}", SimpleCast::disassemble(&bytecode)?); + sh_println!("{}", super::disassemble(&bytecode)?)?; } else { - let _ = sh_println!("{bytecode}"); + sh_println!("{bytecode}")?; } - Ok(()) } } @@ -79,10 +74,10 @@ impl CreationCodeArgs { /// - The complete bytecode /// - The bytecode without constructor arguments /// - Only the constructor arguments -pub async fn parse_code_output( +pub(crate) async fn parse_code_output( bytecode: Bytes, contract: Address, - etherscan: &EtherscanOpts, + config: &Config, abi_path: Option<&str>, without_args: bool, only_args: bool, @@ -91,84 +86,226 @@ pub async fn parse_code_output( return Ok(bytecode); } - let abi = if let Some(abi_path) = abi_path { - load_abi_from_file(abi_path, None)? - } else { - fetch_abi_from_etherscan(contract, etherscan).await? + let abi = load_abi(contract, config, abi_path).await?; + let constructor = match constructor_with_args(&abi) { + Ok(constructor) => constructor, + Err(e) if only_args => return Err(e), + Err(_) => return Ok(bytecode), }; + let split = constructor_args_offset(constructor, &bytecode)?; + Ok(if without_args { bytecode.slice(..split) } else { bytecode.slice(split..) }) +} - let abi = abi.into_iter().next().ok_or_eyre("No ABI found.")?; - let (abi, _) = abi; - - if abi.constructor.is_none() { - if only_args { - return Err(eyre!("No constructor found.")); - } - return Ok(bytecode); +/// Loads the ABI of `contract` from `abi_path`, or from Etherscan when no path is given. +pub(crate) async fn load_abi( + contract: Address, + config: &Config, + abi_path: Option<&str>, +) -> Result { + if let Some(path) = abi_path { + return load_abi_from_file(path); } + let abis = fetch_abi_from_etherscan(contract, config, false).await?; + abis.into_iter().next().map(|(abi, _)| abi).ok_or_eyre("No ABI found.") +} - let constructor = abi.constructor.unwrap(); +/// Returns the constructor of `abi`, failing if there is none or it takes no arguments. +pub(crate) fn constructor_with_args(abi: &JsonAbi) -> Result<&Constructor> { + let constructor = abi.constructor().ok_or_else(|| eyre!("No constructor found."))?; if constructor.inputs.is_empty() { - if only_args { - return Err(eyre!("No constructor arguments found.")); - } - return Ok(bytecode); + eyre::bail!("No constructor arguments found."); } + Ok(constructor) +} - let args_size = constructor.inputs.len() * 32; +/// Returns the offset in `bytecode` at which the ABI-encoded constructor arguments start. +/// +/// The arguments are appended to the init code, so their encoding is a word-aligned suffix of the +/// creation bytecode. Static arguments occupy a fixed number of words, but dynamic ones carry +/// their own lengths, so the suffix is searched for a unique candidate that decodes as the +/// constructor inputs and encodes back to the same bytes. Nested ABI data can make this ambiguous. +pub(crate) fn constructor_args_offset(constructor: &Constructor, bytecode: &[u8]) -> Result { + let types = + constructor.inputs.iter().map(|input| input.resolve()).collect::, _>>()?; + let min_words = types.iter().map(DynSolType::minimum_words).sum::(); + let max_offset = bytecode.len().checked_sub(min_words * 32).ok_or_else(|| { + eyre!( + "Invalid creation bytecode length: have {} bytes, need at least {} for {} constructor inputs", + bytecode.len(), + min_words * 32, + constructor.inputs.len() + ) + })?; + if !types.iter().any(DynSolType::is_dynamic) { + return Ok(max_offset); + } - let bytecode = if without_args { - Bytes::from(bytecode[..bytecode.len() - args_size].to_vec()) - } else if only_args { - Bytes::from(bytecode[bytecode.len() - args_size..].to_vec()) - } else { - unreachable!(); - }; + let tuple = DynSolType::Tuple(types); + let mut candidates = (min_words..=bytecode.len() / 32) + .map(|words| bytecode.len() - words * 32) + .filter(|&offset| { + let args = &bytecode[offset..]; + tuple.abi_decode_params(args).is_ok_and(|value| value.abi_encode_params() == args) + }); + let offset = candidates.next().ok_or_else(|| { + eyre!("Could not find constructor arguments matching the ABI in the creation bytecode") + })?; + if candidates.next().is_some() { + eyre::bail!( + "Multiple constructor argument suffixes match the ABI in the creation bytecode" + ); + } + Ok(offset) +} + +/// Connects to the configured RPC, pins `config.chain` to it, and fetches the creation code of +/// `contract` using its Etherscan creation transaction. +pub(crate) async fn fetch_creation_code(config: &mut Config, contract: Address) -> Result { + let provider = utils::get_provider(config)?; + let chain = provider.get_chain_id().await?.into(); + config.chain = Some(chain); - Ok(bytecode) + let client = config + .get_etherscan_config_with_chain(Some(chain))? + .ok_or_else(|| eyre!("No Etherscan API key configured for chain {chain}"))? + .into_client_with_no_proxy(config.eth_rpc_no_proxy)?; + let creation_tx_hash = client.contract_creation_data(contract).await?.transaction_hash; + let tx_data = provider + .get_transaction_by_hash(creation_tx_hash) + .await? + .ok_or_eyre("Could not find creation tx data.")?; + + if tx_data.to().is_none() { + // Contract was created using a standard transaction. + return Ok(tx_data.input().clone()); + } + + // Contract was created using a factory pattern or create2: extract the init code from the + // creation trace. + let traces = provider + .trace_transaction(creation_tx_hash) + .await + .map_err(|e| eyre!("Could not fetch traces for transaction {}: {}", creation_tx_hash, e))?; + traces + .into_iter() + .filter(|trace| { + matches!(&trace.trace.result, Some(TraceOutput::Create(CreateOutput { address, .. })) if *address == contract) + }) + .filter_map(|trace| match trace.trace.action { + Action::Create(CreateAction { init, .. }) => Some(init), + _ => None, + }) + .last() + .ok_or_else(|| eyre!("Could not find contract creation trace.")) } -/// Fetches the creation code of a contract from Etherscan and RPC. -pub async fn fetch_creation_code_from_etherscan( - contract: Address, - etherscan: &EtherscanOpts, - provider: RetryProvider, -) -> Result { - let config = etherscan.load_config()?; - let chain = config.chain.unwrap_or_default(); - let api_version = config.get_etherscan_api_version(Some(chain)); - let api_key = config.get_etherscan_api_key(Some(chain)).unwrap_or_default(); - let client = Client::new_with_api_version(chain, api_key, api_version)?; - let creation_data = client.contract_creation_data(contract).await?; - let creation_tx_hash = creation_data.transaction_hash; - let tx_data = provider.get_transaction_by_hash(creation_tx_hash).await?; - let tx_data = tx_data.ok_or_eyre("Could not find creation tx data.")?; - - let bytecode = if tx_data.to().is_none() { - // Contract was created using a standard transaction - tx_data.input().clone() - } else { - // Contract was created using a factory pattern or create2 - // Extract creation code from tx traces - let mut creation_bytecode = None; - - let traces = provider.trace_transaction(creation_tx_hash).await.map_err(|e| { - eyre!("Could not fetch traces for transaction {}: {}", creation_tx_hash, e) - })?; - - for trace in traces { - if let Some(TraceOutput::Create(CreateOutput { address, .. })) = trace.trace.result - && address == contract - { - creation_bytecode = match trace.trace.action { - Action::Create(CreateAction { init, .. }) => Some(init), - _ => None, - }; - } +#[cfg(test)] +mod tests { + use super::*; + use alloy_dyn_abi::DynSolValue; + use alloy_primitives::U256; + use std::io::Write; + + fn constructor(inputs: &str) -> Constructor { + let abi: JsonAbi = + serde_json::from_str(&format!(r#"[{{"type":"constructor","inputs":[{inputs}]}}]"#)) + .unwrap(); + abi.constructor().unwrap().clone() + } + + #[test] + fn splits_static_constructor_args() { + let constructor = constructor( + r#"{"name":"owner","type":"address"},{"name":"limits","type":"uint256[2]"}"#, + ); + let args = DynSolValue::Tuple(vec![ + DynSolValue::Address(Address::repeat_byte(0x11)), + DynSolValue::FixedArray(vec![ + DynSolValue::Uint(U256::ONE, 256), + DynSolValue::Uint(U256::from(2), 256), + ]), + ]) + .abi_encode_params(); + let init_code = vec![0xfe; 77]; + let bytecode = [init_code.as_slice(), args.as_slice()].concat(); + + assert_eq!(constructor_args_offset(&constructor, &bytecode).unwrap(), init_code.len()); + } + + #[test] + fn splits_dynamic_constructor_args() { + let constructor = constructor( + r#"{"name":"name","type":"string"},{"name":"supply","type":"uint256"},{"name":"admins","type":"address[]"}"#, + ); + let args = DynSolValue::Tuple(vec![ + DynSolValue::String("Creation code with a name longer than one word".into()), + DynSolValue::Uint(U256::from(42), 256), + DynSolValue::Array(vec![ + DynSolValue::Address(Address::repeat_byte(0x11)), + DynSolValue::Address(Address::repeat_byte(0x22)), + ]), + ]) + .abi_encode_params(); + let init_code = vec![0xfe; 77]; + let bytecode = [init_code.as_slice(), args.as_slice()].concat(); + + let offset = constructor_args_offset(&constructor, &bytecode).unwrap(); + assert_eq!(offset, init_code.len()); + assert_eq!(&bytecode[offset..], args.as_slice()); + } + + #[test] + fn rejects_ambiguous_dynamic_constructor_args() { + let constructor = constructor(r#"{"name":"data","type":"bytes"}"#); + let nested = + DynSolValue::Tuple(vec![DynSolValue::Bytes(vec![0x42; 32])]).abi_encode_params(); + let args = DynSolValue::Tuple(vec![DynSolValue::Bytes(nested)]).abi_encode_params(); + for init_len in [64, 77] { + let bytecode = [vec![0xfe; init_len], args.clone()].concat(); + assert_eq!( + constructor_args_offset(&constructor, &bytecode).unwrap_err().to_string(), + "Multiple constructor argument suffixes match the ABI in the creation bytecode" + ); } + } - creation_bytecode.ok_or_else(|| eyre!("Could not find contract creation trace."))? - }; + #[test] + fn rejects_creation_code_without_matching_args() { + let constructor = constructor(r#"{"name":"name","type":"string"}"#); + let bytecode = vec![0xfe; 100]; + + let err = constructor_args_offset(&constructor, &bytecode).unwrap_err(); + assert_eq!( + err.to_string(), + "Could not find constructor arguments matching the ABI in the creation bytecode" + ); + } - Ok(bytecode) + #[tokio::test] + async fn rejects_creation_code_shorter_than_constructor_head() { + let mut abi = tempfile::NamedTempFile::new().unwrap(); + write!( + abi, + r#"{{"abi":[{{"type":"constructor","inputs":[{{"name":"value","type":"uint256"}}]}}]}}"# + ) + .unwrap(); + + for (without_args, only_args) in [(true, false), (false, true)] { + let err = parse_code_output( + Bytes::from(vec![0; 31]), + Address::ZERO, + &Config::default(), + abi.path().to_str(), + without_args, + only_args, + ) + .await + .unwrap_err(); + + assert_eq!( + err.to_string(), + "Invalid creation bytecode length: have 31 bytes, need at least 32 for 1 constructor inputs" + ); + } + } } diff --git a/crates/cast/src/cmd/da_estimate.rs b/crates/cast/src/cmd/da_estimate.rs index db9e75a577f3e..0be0f7471947f 100644 --- a/crates/cast/src/cmd/da_estimate.rs +++ b/crates/cast/src/cmd/da_estimate.rs @@ -1,15 +1,21 @@ //! Estimates the data availability size of a block for opstack. use alloy_consensus::BlockHeader; -use alloy_network::eip2718::Encodable2718; +use alloy_network::{BlockResponse, Ethereum, Network, eip2718::Encodable2718}; use alloy_provider::Provider; use alloy_rpc_types::BlockId; use clap::Parser; -use foundry_cli::{ - opts::RpcOpts, - utils::{self, LoadConfig}, -}; -use op_alloy_consensus::OpTxEnvelope; +use eyre::Result; +use foundry_cli::{opts::RpcOpts, utils::LoadConfig}; +use foundry_common::provider::ProviderBuilder; +use foundry_config::Config; +use foundry_evm_networks::NetworkVariant; + +#[cfg(feature = "base")] +use base_common_network::Base; + +#[cfg(feature = "optimism")] +use op_alloy_network::Optimism; /// CLI arguments for `cast da-estimate`. #[derive(Debug, Parser)] @@ -18,33 +24,64 @@ pub struct DAEstimateArgs { pub block: BlockId, #[command(flatten)] pub rpc: RpcOpts, + /// Specify the Network for correct encoding. + #[arg(long, short, num_args = 1, value_name = "NETWORK")] + network: Option, } impl DAEstimateArgs { - /// Load the RPC URL from the config file. - pub async fn run(self) -> eyre::Result<()> { - let Self { block, rpc } = self; + pub async fn run(self) -> Result<()> { + let Self { block, rpc, network } = self; let config = rpc.load_config()?; - let provider = utils::get_provider(&config)?; - let block = provider - .get_block(block) - .full() - .await? - .ok_or_else(|| eyre::eyre!("Block not found"))?; - - let block_number = block.header.number(); - let tx_count = block.transactions.len(); - let mut da_estimate = 0; - for tx in block.into_transactions_iter() { - // try to convert into opstack transaction - let tx = OpTxEnvelope::try_from(tx)?; - da_estimate += op_alloy_flz::tx_estimated_size_fjord(&tx.encoded_2718()); + let network = match network { + Some(n) => n, + None => super::resolve_transaction_network(&config, false).await?, + }; + match network { + #[cfg(feature = "base")] + NetworkVariant::Base => da_estimate::(&config, block).await, + #[cfg(feature = "optimism")] + NetworkVariant::Optimism => da_estimate::(&config, block).await, + NetworkVariant::Ethereum => da_estimate::(&config, block).await, + other => eyre::bail!( + "DA estimation is not supported for {other:?}: EIP-4844 blob transactions are not available on this network" + ), } + } +} + +async fn da_estimate(config: &Config, block_id: BlockId) -> Result<()> { + let provider = ProviderBuilder::::from_config(config)?.build()?; + let block = + provider.get_block(block_id).full().await?.ok_or_else(|| eyre::eyre!("Block not found"))?; + + let block_number = block.header().number(); + let tx_count = block.transactions().len(); + let da_estimate = block + .transactions() + .txns() + .map(|tx| op_alloy_flz::tx_estimated_size_fjord(&tx.as_ref().encoded_2718())) + .sum::(); + sh_status!( + "Estimated data availability size for block {block_number} with {tx_count} transactions:" + )?; + sh_println!("{da_estimate}")?; + Ok(()) +} - sh_println!( - "Estimated data availability size for block {block_number} with {tx_count} transactions: {da_estimate}" - )?; +#[cfg(test)] +mod tests { + use super::*; - Ok(()) + #[tokio::test] + async fn rejects_networks_without_blob_transactions() { + let args = DAEstimateArgs { + block: BlockId::latest(), + rpc: RpcOpts::default(), + network: Some(NetworkVariant::Tempo), + }; + let err = args.run().await.unwrap_err().to_string(); + assert!(err.contains("Tempo"), "{err}"); + assert!(err.contains("EIP-4844"), "{err}"); } } diff --git a/crates/cast/src/cmd/erc20.rs b/crates/cast/src/cmd/erc20.rs new file mode 100644 index 0000000000000..baa8e52ba6636 --- /dev/null +++ b/crates/cast/src/cmd/erc20.rs @@ -0,0 +1,346 @@ +use foundry_common::fmt::format_uint_exp; +use std::str::FromStr; + +use crate::{ + cmd::{call_overrides::CallOverrideOpts, rpc_provider, send::SendTxArgs}, + tx::{SendTxOpts, TxParams}, +}; +use alloy_eips::BlockId; +use alloy_ens::NameOrAddress; +use alloy_network::AnyNetwork; +use alloy_primitives::{Address, U256}; +use alloy_sol_types::{SolCall, sol}; +use clap::Parser; +use eyre::Result; +use foundry_cli::{ + json::{print_json_success, print_scalar}, + opts::RpcOpts, +}; +use foundry_common::{provider::RetryProvider, shell}; + +mod permit; + +sol! { + #[sol(rpc)] + interface IERC20 { + event Transfer(address indexed from, address indexed to, uint256 value); + + function name() external view returns (string); + function symbol() external view returns (string); + function decimals() external view returns (uint8); + function totalSupply() external view returns (uint256); + function balanceOf(address owner) external view returns (uint256); + function transfer(address to, uint256 amount) external returns (bool); + function approve(address spender, uint256 amount) external returns (bool); + function allowance(address owner, address spender) external view returns (uint256); + function mint(address to, uint256 amount) external; + function burn(uint256 amount) external; + } +} + +/// Interact with ERC20 tokens. +#[derive(Debug, Parser, Clone)] +pub enum Erc20Subcommand { + /// Sign an ERC-2612 approval without sending a transaction, or submit it with --broadcast. + /// + /// The owner is the signing wallet. Amounts are in raw token units and the deadline is an + /// absolute Unix timestamp in seconds. This does not transfer tokens or deposit into a vault. + /// For deposits, the permit must target the underlying asset, not the vault's share token. + /// + /// By default stdout contains the 65-byte signature (r, s, v). With --json, it contains the + /// signature, permit calldata, owner, spender, value, nonce, deadline, token, and typed data. + /// With --broadcast, output follows cast send: a receipt, or a transaction hash with --async. + /// Anyone can submit the generated calldata to the token before the deadline. + /// Transaction options require --broadcast; --nonce selects the transaction nonce, while the + /// permit nonce is always read from the token. + /// + /// Uses EIP-5267 domain discovery when available. Otherwise uses name(), version "1", the + /// RPC chain ID, and the token address. --domain-name and --domain-version override the name + /// and version. The resulting domain must match DOMAIN_SEPARATOR() before signing. + /// DAI-style permits and Permit2 are not supported. + /// + /// Example: + /// ```text + /// cast erc20 permit $TOKEN $SPENDER 1000000 --deadline $DEADLINE \ + /// --account owner --rpc-url $RPC_URL --json + /// cast erc20 permit $TOKEN $SPENDER 1000000 --deadline $DEADLINE \ + /// --account owner --rpc-url $RPC_URL --broadcast --async + /// ``` + #[command(verbatim_doc_comment)] + Permit(permit::PermitArgs), + + /// Query ERC20 token balance. + #[command(visible_alias = "b")] + Balance { + /// The ERC20 token contract address. + #[arg(value_parser = NameOrAddress::from_str)] + token: NameOrAddress, + + /// The owner to query balance for. + #[arg(value_parser = NameOrAddress::from_str)] + owner: NameOrAddress, + + /// The block height to query at. + #[arg(long, short = 'B')] + block: Option, + + #[command(flatten)] + rpc: RpcOpts, + + #[command(flatten)] + overrides: CallOverrideOpts, + }, + + /// Transfer ERC20 tokens. + #[command(visible_aliases = ["t", "send"])] + Transfer { + /// The ERC20 token contract address. + #[arg(value_parser = NameOrAddress::from_str)] + token: NameOrAddress, + + /// The recipient address. + #[arg(value_parser = NameOrAddress::from_str)] + to: NameOrAddress, + + /// The amount to transfer. + amount: String, + + #[command(flatten)] + send_tx: SendTxOpts, + + #[command(flatten)] + tx: TxParams, + }, + + /// Approve ERC20 token spending. + #[command(visible_alias = "a")] + Approve { + /// The ERC20 token contract address. + #[arg(value_parser = NameOrAddress::from_str)] + token: NameOrAddress, + + /// The spender address. + #[arg(value_parser = NameOrAddress::from_str)] + spender: NameOrAddress, + + /// The amount to approve. + amount: String, + + #[command(flatten)] + send_tx: SendTxOpts, + + #[command(flatten)] + tx: TxParams, + }, + + /// Query ERC20 token allowance. + #[command(visible_alias = "al")] + Allowance { + /// The ERC20 token contract address. + #[arg(value_parser = NameOrAddress::from_str)] + token: NameOrAddress, + + /// The owner address. + #[arg(value_parser = NameOrAddress::from_str)] + owner: NameOrAddress, + + /// The spender address. + #[arg(value_parser = NameOrAddress::from_str)] + spender: NameOrAddress, + + /// The block height to query at. + #[arg(long, short = 'B')] + block: Option, + + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Query ERC20 token name. + #[command(visible_alias = "n")] + Name { + /// The ERC20 token contract address. + #[arg(value_parser = NameOrAddress::from_str)] + token: NameOrAddress, + + /// The block height to query at. + #[arg(long, short = 'B')] + block: Option, + + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Query ERC20 token symbol. + #[command(visible_alias = "s")] + Symbol { + /// The ERC20 token contract address. + #[arg(value_parser = NameOrAddress::from_str)] + token: NameOrAddress, + + /// The block height to query at. + #[arg(long, short = 'B')] + block: Option, + + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Query ERC20 token decimals. + #[command(visible_alias = "d")] + Decimals { + /// The ERC20 token contract address. + #[arg(value_parser = NameOrAddress::from_str)] + token: NameOrAddress, + + /// The block height to query at. + #[arg(long, short = 'B')] + block: Option, + + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Query ERC20 token total supply. + #[command(visible_alias = "ts")] + TotalSupply { + /// The ERC20 token contract address. + #[arg(value_parser = NameOrAddress::from_str)] + token: NameOrAddress, + + /// The block height to query at. + #[arg(long, short = 'B')] + block: Option, + + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Mint ERC20 tokens (if the token supports minting). + #[command(visible_alias = "m")] + Mint { + /// The ERC20 token contract address. + #[arg(value_parser = NameOrAddress::from_str)] + token: NameOrAddress, + + /// The recipient address. + #[arg(value_parser = NameOrAddress::from_str)] + to: NameOrAddress, + + /// The amount to mint. + amount: String, + + #[command(flatten)] + send_tx: SendTxOpts, + + #[command(flatten)] + tx: TxParams, + }, + + /// Burn ERC20 tokens. + #[command(visible_alias = "bu")] + Burn { + /// The ERC20 token contract address. + #[arg(value_parser = NameOrAddress::from_str)] + token: NameOrAddress, + + /// The amount to burn. + amount: String, + + #[command(flatten)] + send_tx: SendTxOpts, + + #[command(flatten)] + tx: TxParams, + }, +} + +impl Erc20Subcommand { + pub async fn run(self) -> Result<()> { + match self { + Self::Permit(args) => args.run().await, + Self::Allowance { token, owner, spender, block, rpc } => { + let (provider, erc20) = token_at(&rpc, token).await?; + let owner = owner.resolve(&provider).await?; + let spender = spender.resolve(&provider).await?; + let allowance = + erc20.allowance(owner, spender).block(block.unwrap_or_default()).call().await?; + print_amount(allowance) + } + Self::Balance { token, owner, block, rpc, overrides } => { + let (provider, erc20) = token_at(&rpc, token).await?; + let owner = owner.resolve(&provider).await?; + let call = erc20.balanceOf(owner).block(block.unwrap_or_default()); + let balance = overrides.apply(call.call())?.await?; + print_scalar(balance.to_string()) + } + Self::Name { token, block, rpc } => { + let (_, erc20) = token_at(&rpc, token).await?; + print_scalar(erc20.name().block(block.unwrap_or_default()).call().await?) + } + Self::Symbol { token, block, rpc } => { + let (_, erc20) = token_at(&rpc, token).await?; + print_scalar(erc20.symbol().block(block.unwrap_or_default()).call().await?) + } + Self::Decimals { token, block, rpc } => { + let (_, erc20) = token_at(&rpc, token).await?; + print_scalar(erc20.decimals().block(block.unwrap_or_default()).call().await?) + } + Self::TotalSupply { token, block, rpc } => { + let (_, erc20) = token_at(&rpc, token).await?; + print_amount(erc20.totalSupply().block(block.unwrap_or_default()).call().await?) + } + Self::Transfer { token, to, amount, send_tx, tx } => { + let to = resolve(&send_tx.eth.rpc, to).await?; + let call = IERC20::transferCall { to, amount: U256::from_str(&amount)? }; + send(token, call, send_tx, tx).await + } + Self::Approve { token, spender, amount, send_tx, tx } => { + let spender = resolve(&send_tx.eth.rpc, spender).await?; + let call = IERC20::approveCall { spender, amount: U256::from_str(&amount)? }; + send(token, call, send_tx, tx).await + } + Self::Mint { token, to, amount, send_tx, tx } => { + let to = resolve(&send_tx.eth.rpc, to).await?; + let call = IERC20::mintCall { to, amount: U256::from_str(&amount)? }; + send(token, call, send_tx, tx).await + } + Self::Burn { token, amount, send_tx, tx } => { + send(token, IERC20::burnCall { amount: U256::from_str(&amount)? }, send_tx, tx) + .await + } + } + } +} + +async fn token_at( + rpc: &RpcOpts, + token: NameOrAddress, +) -> Result<(RetryProvider, IERC20::IERC20Instance)> { + let provider = rpc_provider(rpc)?; + let token = token.resolve(&provider).await?; + Ok((provider.clone(), IERC20::new(token, provider))) +} + +async fn resolve(rpc: &RpcOpts, account: NameOrAddress) -> Result
{ + Ok(account.resolve(&rpc_provider(rpc)?).await?) +} + +async fn send( + token: NameOrAddress, + call: impl SolCall, + send_tx: SendTxOpts, + tx: TxParams, +) -> Result<()> { + // Boxed to keep the large `cast send` future off this command's stack frame. + Box::pin(SendTxArgs::contract_call(token, call.abi_encode(), send_tx, tx).run()).await +} + +/// Prints a token amount: the raw decimal string in JSON mode, exponent-annotated otherwise. +pub(crate) fn print_amount(amount: U256) -> Result<()> { + if shell::is_json() { + print_json_success(amount.to_string()) + } else { + sh_println!("{}", format_uint_exp(amount)) + } +} diff --git a/crates/cast/src/cmd/erc20/permit.rs b/crates/cast/src/cmd/erc20/permit.rs new file mode 100644 index 0000000000000..f4be3ddd18723 --- /dev/null +++ b/crates/cast/src/cmd/erc20/permit.rs @@ -0,0 +1,408 @@ +//! ERC-2612 signed approvals. + +use crate::{ + cmd::send::SendTxArgs, + tempo, + tx::{SendTxOpts, TxParams}, +}; +use alloy_consensus::{SignableTransaction, Signed}; +use alloy_dyn_abi::TypedData; +use alloy_ens::NameOrAddress; +use alloy_network::{Ethereum, Network}; +use alloy_primitives::{Address, B256, U256, hex}; +use alloy_provider::Provider; +use alloy_signer::{Signature, Signer}; +use alloy_sol_types::{Eip712Domain, SolCall, sol}; +use clap::Args; +use eyre::{Result, WrapErr, ensure}; +use foundry_cli::{ + json::{print_json_success, print_scalar}, + utils::{LoadConfig, get_provider}, +}; +use foundry_common::{ + FoundryTransactionBuilder, + fmt::{UIfmt, UIfmtReceiptExt}, + provider::ProviderBuilder, + shell, +}; +use foundry_config::Config; +use foundry_wallets::WalletSigner; +use serde::Serialize; +use serde_json::json; +use std::str::FromStr; +use tempo_alloy::TempoNetwork; + +sol! { + #[sol(rpc)] + interface IERC2612 { + function name() external view returns (string); + function nonces(address owner) external view returns (uint256); + function DOMAIN_SEPARATOR() external view returns (bytes32); + function eip712Domain() external view returns ( + bytes1 fields, string name, string version, uint256 chainId, + address verifyingContract, bytes32 salt, uint256[] extensions + ); + } + + function permit(address owner, address spender, uint256 value, uint256 deadline, + uint8 v, bytes32 r, bytes32 s) external; + + #[derive(Serialize)] + struct Permit { + address owner; + address spender; + uint256 value; + uint256 nonce; + uint256 deadline; + } +} + +/// Arguments for signing and submitting an ERC-2612 permit. +#[derive(Debug, Clone, Args)] +pub struct PermitArgs { + /// The ERC-2612 token contract address. + #[arg(value_parser = NameOrAddress::from_str)] + token: NameOrAddress, + /// The spender authorized by the permit. + #[arg(value_parser = NameOrAddress::from_str)] + spender: NameOrAddress, + /// The allowance to set, in raw token units. + amount: U256, + /// Absolute Unix timestamp in seconds after which the permit cannot be submitted. + #[arg(long)] + deadline: U256, + /// Override the EIP-712 domain name. + #[arg(long)] + domain_name: Option, + /// Override the EIP-712 domain version (fallback: "1"). + #[arg(long)] + domain_version: Option, + /// Submit the permit transaction using the signing wallet. + #[arg(long)] + broadcast: bool, + #[command(flatten)] + pub(super) send_tx: SendTxOpts, + #[command(flatten)] + tx: TxParams, +} + +impl PermitArgs { + pub(super) async fn run(self) -> Result<()> { + self.ensure_broadcast_options()?; + ensure!( + self.tx.tempo.session_id()?.is_none(), + "Tempo sessions cannot sign ERC-2612 permits" + ); + let config = self.send_tx.eth.load_config()?; + let provider = get_provider(&config)?; + let rpc_chain_id = provider.get_chain_id().await?; + ensure!( + config.chain.is_none_or(|chain| chain.id() == rpc_chain_id), + "Configured chain does not match the RPC chain" + ); + let (network, signer, access_key) = + tempo::resolve_transaction_network_and_signer(&self.tx.tempo, &self.send_tx.eth) + .await?; + ensure!( + access_key.is_none(), + "Tempo access keys cannot sign ERC-2612 permits; use a root account signer" + ); + if network.is_tempo() { + self.run_generic::(signer, config, rpc_chain_id).await + } else { + self.run_generic::(signer, config, rpc_chain_id).await + } + } + + async fn run_generic( + self, + pre_resolved_signer: Option, + config: Config, + rpc_chain_id: u64, + ) -> Result<()> + where + N::TxEnvelope: From>, + N::UnsignedTx: SignableTransaction, + N::TransactionRequest: FoundryTransactionBuilder, + N::ReceiptResponse: UIfmt + UIfmtReceiptExt, + { + let provider = ProviderBuilder::::from_config(&config)?.build()?; + let token = self.token.resolve(&provider).await?; + let spender = self.spender.resolve(&provider).await?; + let chain_id = U256::from(rpc_chain_id); + let contract = IERC2612::new(token, &provider); + let separator = contract + .DOMAIN_SEPARATOR() + .call() + .await + .wrap_err("Could not read ERC-2612 DOMAIN_SEPARATOR()")?; + let mut domain = match contract.eip712Domain().call().await { + Ok(domain) => discovered_domain(domain)?, + Err(_) => Eip712Domain { + name: Some( + match &self.domain_name { + Some(name) => name.clone(), + None => contract + .name() + .call() + .await + .wrap_err("Could not read name(); supply --domain-name")?, + } + .into(), + ), + version: Some("1".into()), + chain_id: Some(chain_id), + verifying_contract: Some(token), + ..Default::default() + }, + }; + if let Some(name) = self.domain_name { + domain.name = Some(name.into()); + } + if let Some(version) = self.domain_version { + domain.version = Some(version.into()); + } + validate_domain(&domain, separator, chain_id, token)?; + + let browser = self.send_tx.browser.run::().await?; + if let Some(browser) = &browser + && domain.chain_id.is_some() + && browser.chain_id() != rpc_chain_id + { + browser.switch_chain(rpc_chain_id).await?; + } + let wallet = if browser.is_none() { + Some(match pre_resolved_signer { + Some(signer) => signer, + None => self.send_tx.eth.wallet.signer().await?, + }) + } else { + None + }; + let owner = browser + .as_ref() + .map(|wallet| wallet.address()) + .unwrap_or_else(|| wallet.as_ref().expect("signer resolved").address()); + ensure!( + self.send_tx.eth.wallet.from.is_none_or(|from| from == owner), + "--from must match the permit signing wallet" + ); + let nonce = contract + .nonces(owner) + .call() + .await + .wrap_err("Could not read ERC-2612 nonces(owner)")?; + let permit = Permit { owner, spender, value: self.amount, nonce, deadline: self.deadline }; + let typed_data = TypedData::from_struct(&permit, Some(domain)); + let signature = if let Some(browser) = &browser { + browser.sign_dynamic_typed_data(&typed_data).await? + } else { + wallet.as_ref().expect("signer resolved").sign_dynamic_typed_data(&typed_data).await? + }; + let calldata = permitCall { + owner, + spender, + value: self.amount, + deadline: self.deadline, + v: signature.v_byte(), + r: signature.r().into(), + s: signature.s().into(), + } + .abi_encode(); + if self.broadcast { + let send = SendTxArgs::contract_call(token.into(), calldata, self.send_tx, self.tx); + return if let Some(browser) = browser { + send.run_generic_with_browser::(browser).await + } else { + send.run_generic::(wallet, None).await + }; + } + if shell::is_json() { + print_json_success(json!({ + "token": token, "owner": owner, "spender": spender, + "value": self.amount.to_string(), "nonce": nonce.to_string(), + "deadline": self.deadline.to_string(), + "signature": hex::encode_prefixed(signature.as_bytes()), + "calldata": hex::encode_prefixed(calldata), "typed_data": typed_data, + }))?; + } else { + print_scalar(hex::encode_prefixed(signature.as_bytes()))?; + } + Ok(()) + } + + fn ensure_broadcast_options(&self) -> Result<()> { + let send_options = self.send_tx.cast_async + || self.send_tx.sync + || self.send_tx.confirmations != 1 + || self.send_tx.timeout.is_some() + || self.send_tx.poll_interval.is_some(); + let transaction_options = self.tx.gas_limit.is_some() + || self.tx.gas_price.is_some() + || self.tx.priority_gas_price.is_some() + || self.tx.nonce.is_some() + || self.tx.tempo.is_tempo() + || self.tx.tempo.session_id()?.is_some() + || self.tx.tempo.lanes_file.is_some(); + ensure!( + self.broadcast || !(send_options || transaction_options), + "Transaction options require --broadcast" + ); + Ok(()) + } +} + +fn discovered_domain(domain: IERC2612::eip712DomainReturn) -> Result { + let fields = domain.fields[0]; + ensure!( + fields & !0x1f == 0 && domain.extensions.is_empty(), + "Unsupported EIP-712 domain fields or extensions" + ); + Ok(Eip712Domain { + name: (fields & 1 != 0).then(|| domain.name.into()), + version: (fields & 2 != 0).then(|| domain.version.into()), + chain_id: (fields & 4 != 0).then_some(domain.chainId), + verifying_contract: (fields & 8 != 0).then_some(domain.verifyingContract), + salt: (fields & 16 != 0).then_some(domain.salt), + }) +} + +fn validate_domain( + domain: &Eip712Domain, + separator: B256, + chain_id: U256, + token: Address, +) -> Result<()> { + ensure!( + domain.chain_id.is_none_or(|id| id == chain_id), + "EIP-712 domain chain ID does not match the RPC chain" + ); + ensure!( + domain.verifying_contract.is_none_or(|address| address == token), + "EIP-712 domain verifying contract does not match the token" + ); + ensure!( + domain.separator() == separator, + "EIP-712 domain does not match DOMAIN_SEPARATOR(); check --domain-name and --domain-version" + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cmd::erc20::Erc20Subcommand; + use clap::Parser; + + #[test] + fn permit_requires_explicit_deadline_and_valid_amount() { + let args = [ + "erc20", + "permit", + "0x0000000000000000000000000000000000000001", + "0x0000000000000000000000000000000000000002", + "123", + ]; + assert!(Erc20Subcommand::try_parse_from(args).is_err()); + assert!( + Erc20Subcommand::try_parse_from(args.into_iter().chain(["--deadline", "4000000000"])) + .is_ok() + ); + let mut invalid = args; + invalid[4] = "-1"; + assert!( + Erc20Subcommand::try_parse_from( + invalid.into_iter().chain(["--deadline", "4000000000"]) + ) + .is_err() + ); + } + + #[test] + fn permit_requires_broadcast_for_transaction_options() { + let base = [ + "erc20", + "permit", + "0x0000000000000000000000000000000000000001", + "0x0000000000000000000000000000000000000002", + "123", + "--deadline", + "4000000000", + ]; + for option in [["--async", ""], ["--nonce", "1"], ["--gas-limit", "21000"]] { + let args = base.into_iter().chain(option.into_iter().filter(|value| !value.is_empty())); + let Erc20Subcommand::Permit(args) = Erc20Subcommand::try_parse_from(args).unwrap() + else { + unreachable!() + }; + assert_eq!( + args.ensure_broadcast_options().unwrap_err().to_string(), + "Transaction options require --broadcast" + ); + } + + let args = base.into_iter().chain(["--async", "--broadcast"]); + let Erc20Subcommand::Permit(args) = Erc20Subcommand::try_parse_from(args).unwrap() else { + unreachable!() + }; + assert!(args.ensure_broadcast_options().is_ok()); + } + + #[test] + fn domain_discovery_respects_fields_and_salt() { + let domain = discovered_domain(IERC2612::eip712DomainReturn { + fields: [0x14].into(), + name: "ignored".into(), + version: "ignored".into(), + chainId: U256::ONE, + verifyingContract: Address::ZERO, + salt: B256::repeat_byte(42), + extensions: vec![], + }) + .unwrap(); + assert_eq!( + domain, + Eip712Domain { + chain_id: Some(U256::ONE), + salt: Some(B256::repeat_byte(42)), + ..Default::default() + } + ); + } + + #[test] + fn domain_discovery_rejects_extensions_and_unknown_fields() { + for (fields, extensions) in [(0x20, vec![]), (0x0f, vec![U256::ONE])] { + assert!( + discovered_domain(IERC2612::eip712DomainReturn { + fields: [fields].into(), + name: String::new(), + version: String::new(), + chainId: U256::ZERO, + verifyingContract: Address::ZERO, + salt: B256::ZERO, + extensions, + }) + .is_err() + ); + } + } + + #[test] + fn domain_validation_rejects_mismatched_separator_chain_and_contract() { + let chain = U256::ONE; + let token = Address::repeat_byte(1); + let domain = Eip712Domain { + name: Some("Token".into()), + version: Some("2".into()), + chain_id: Some(chain), + verifying_contract: Some(token), + ..Default::default() + }; + let separator = domain.separator(); + assert!(validate_domain(&domain, separator, chain, token).is_ok()); + assert!(validate_domain(&domain, B256::ZERO, chain, token).is_err()); + assert!(validate_domain(&domain, separator, U256::from(2), token).is_err()); + assert!(validate_domain(&domain, separator, chain, Address::ZERO).is_err()); + } +} diff --git a/crates/cast/src/cmd/erc4626.rs b/crates/cast/src/cmd/erc4626.rs new file mode 100644 index 0000000000000..c8024d976c662 --- /dev/null +++ b/crates/cast/src/cmd/erc4626.rs @@ -0,0 +1,1634 @@ +use std::str::FromStr; + +use crate::{ + cmd::{erc20::print_amount, rpc_provider, send::SendTxArgs}, + tx::{SendTxOpts, TxParams}, +}; +use alloy_eips::BlockId; +use alloy_ens::NameOrAddress; +use alloy_network::AnyNetwork; +use alloy_primitives::{Address, FixedBytes, U256}; +use alloy_provider::Provider; +use alloy_sol_types::{SolCall, sol}; +use clap::Parser; +use eyre::{Result, WrapErr}; +use foundry_cli::{ + json::{ + JsonError, JsonMessage, print_json_success, print_json_success_with_warnings, print_scalar, + }, + opts::RpcOpts, +}; +use foundry_common::{provider::RetryProvider, shell}; +use serde::Serialize; + +const NATIVE_ASSET: Address = Address::repeat_byte(0xee); +/// ERC-7535 asset quantities are denominated in wei. +const NATIVE_ASSET_DECIMALS: u8 = 18; +const ERC7540_ASYNC_DEPOSIT_INTERFACE: FixedBytes<4> = FixedBytes::new([0xce, 0x3b, 0xbe, 0x50]); +const ERC7540_ASYNC_REDEEM_INTERFACE: FixedBytes<4> = FixedBytes::new([0x62, 0x0e, 0xe8, 0xe4]); + +sol! { + #[sol(rpc)] + interface IERC4626 { + function asset() external view returns (address assetTokenAddress); + function totalAssets() external view returns (uint256 totalManagedAssets); + function convertToShares(uint256 assets) external view returns (uint256 shares); + function convertToAssets(uint256 shares) external view returns (uint256 assets); + function maxDeposit(address receiver) external view returns (uint256 maxAssets); + function previewDeposit(uint256 assets) external view returns (uint256 shares); + function deposit(uint256 assets, address receiver) external returns (uint256 shares); + function maxMint(address receiver) external view returns (uint256 maxShares); + function previewMint(uint256 shares) external view returns (uint256 assets); + function mint(uint256 shares, address receiver) external returns (uint256 assets); + function maxWithdraw(address owner) external view returns (uint256 maxAssets); + function previewWithdraw(uint256 assets) external view returns (uint256 shares); + function withdraw(uint256 assets, address receiver, address owner) + external + returns (uint256 shares); + function maxRedeem(address owner) external view returns (uint256 maxShares); + function previewRedeem(uint256 shares) external view returns (uint256 assets); + function redeem(uint256 shares, address receiver, address owner) + external + returns (uint256 assets); + + function name() external view returns (string); + function symbol() external view returns (string); + function decimals() external view returns (uint8); + function totalSupply() external view returns (uint256 shares); + function balanceOf(address owner) external view returns (uint256 shares); + function allowance(address owner, address spender) external view returns (uint256 shares); + } + + #[sol(rpc)] + interface IERC20Metadata { + function name() external view returns (string); + function symbol() external view returns (string); + function decimals() external view returns (uint8); + function balanceOf(address owner) external view returns (uint256 amount); + } + + #[sol(rpc)] + interface IERC165 { + function supportsInterface(bytes4 interfaceId) external view returns (bool); + } +} + +/// Interact with synchronous ERC-4626 tokenized vaults. +#[derive(Debug, Parser, Clone)] +pub enum Erc4626Subcommand { + /// Show vault, asset, and exchange-rate information + /// + /// Example: + /// + /// ```text + /// $ cast erc4626 info 0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB --human \ + /// --block 25519075 --rpc-url https://ethereum.reth.rs/rpc + /// ``` + /// + /// Output: + /// + /// ```text + /// Vault 0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB + /// Name Steakhouse USDC + /// Symbol steakUSDC + /// Decimals 18 + /// Asset 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 + /// Asset name USD Coin + /// Asset symbol USDC + /// Asset decimals 6 + /// Total assets 95183395.377893 USDC + /// Total supply 84037200.060143388288943211 steakUSDC + /// Assets per share 1.132634 USDC + /// Shares per asset 0.882897731163608580 steakUSDC + /// ``` + #[command(verbatim_doc_comment)] + Info { + /// The ERC-4626 vault contract address. + #[arg(value_parser = NameOrAddress::from_str)] + vault: NameOrAddress, + + /// Use formatted token amounts in text output. + #[arg(long)] + human: bool, + + /// The block height to query at. + #[arg(long, short = 'B')] + block: Option, + + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Show an account's shares, asset value, and withdrawal limits + /// + /// Example: + /// + /// ```text + /// $ cast erc4626 position 0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB \ + /// 0x255c7705E8bb334dfCaE438197f7c4297988085A --human --block 25519075 \ + /// --rpc-url https://ethereum.reth.rs/rpc + /// ``` + /// + /// Output: + /// + /// ```text + /// Vault 0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB + /// Owner 0x255c7705e8BB334DfCae438197f7C4297988085a + /// Asset 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 + /// Share symbol steakUSDC + /// Share decimals 18 + /// Asset symbol USDC + /// Asset decimals 6 + /// Share balance 35733.949295544029939485 steakUSDC + /// Assets equivalent 40473.486378 USDC + /// Max withdraw 40473.486378 USDC + /// Max redeem 35733.949295417417957447 steakUSDC + /// ``` + #[command(verbatim_doc_comment)] + Position { + /// The ERC-4626 vault contract address. + #[arg(value_parser = NameOrAddress::from_str)] + vault: NameOrAddress, + + /// The owner of the vault shares. + #[arg(value_parser = NameOrAddress::from_str)] + owner: NameOrAddress, + + /// Use formatted token amounts in text output. + #[arg(long)] + human: bool, + + /// The block height to query at. + #[arg(long, short = 'B')] + block: Option, + + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Probe synchronous ERC-4626 interface compatibility + /// + /// Example: + /// + /// ```text + /// $ cast erc4626 check 0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB \ + /// --account 0x255c7705E8bb334dfCaE438197f7c4297988085A --block 25519075 \ + /// --rpc-url https://ethereum.reth.rs/rpc + /// ``` + /// + /// Output: + /// + /// ```text + /// Vault 0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB + /// Account 0x255c7705e8BB334DfCae438197f7C4297988085a + /// Note: This probes read-call behavior only; it does not prove state-changing selector coverage or semantic ERC-4626 compliance. + /// PASS contract code contract bytecode is present + /// PASS asset() returned 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 + /// PASS asset contract underlying asset bytecode is present + /// PASS asset balanceOf(address) call succeeded + /// PASS totalAssets() call succeeded + /// PASS totalSupply() call succeeded + /// PASS balanceOf(address) call succeeded + /// PASS allowance(address,address) call succeeded + /// PASS convertToShares(0) returned zero + /// PASS convertToAssets(0) returned zero + /// PASS maxDeposit(address) call succeeded + /// PASS previewDeposit(0) returned zero + /// PASS maxMint(address) call succeeded + /// PASS previewMint(0) returned zero + /// PASS maxWithdraw(address) call succeeded + /// PASS previewWithdraw(0) returned zero + /// PASS maxRedeem(address) call succeeded + /// PASS previewRedeem(0) returned zero + /// PASS name() call succeeded + /// PASS symbol() call succeeded + /// PASS decimals() call succeeded + /// Summary: 21 passed, 0 warnings, 0 failed + /// ``` + #[command(verbatim_doc_comment)] + Check { + /// The ERC-4626 vault contract address. + #[arg(value_parser = NameOrAddress::from_str)] + vault: NameOrAddress, + + /// The account used for limit and balance probes. + #[arg(long, value_parser = NameOrAddress::from_str)] + account: Option, + + /// The block height to query at. + #[arg(long, short = 'B')] + block: Option, + + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Query the vault's underlying asset token + /// + /// Example: + /// + /// ```text + /// $ cast erc4626 asset 0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB \ + /// --block 25519075 --rpc-url https://ethereum.reth.rs/rpc + /// ``` + /// + /// Output: + /// + /// ```text + /// 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 + /// ``` + #[command(verbatim_doc_comment)] + Asset { + /// The ERC-4626 vault contract address. + #[arg(value_parser = NameOrAddress::from_str)] + vault: NameOrAddress, + + /// The block height to query at. + #[arg(long, short = 'B')] + block: Option, + + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Query the total amount of underlying assets managed by the vault + /// + /// Example: + /// + /// ```text + /// $ cast erc4626 total-assets 0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB \ + /// --block 25519075 --rpc-url https://ethereum.reth.rs/rpc + /// ``` + /// + /// Output: + /// + /// ```text + /// 95183395377893 [9.518e13] + /// ``` + #[command(verbatim_doc_comment)] + TotalAssets { + /// The ERC-4626 vault contract address. + #[arg(value_parser = NameOrAddress::from_str)] + vault: NameOrAddress, + + /// The block height to query at. + #[arg(long, short = 'B')] + block: Option, + + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Convert an asset amount to the corresponding share amount + /// + /// Example: + /// + /// ```text + /// $ cast erc4626 convert-to-shares 0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB \ + /// 1000000 --block 25519075 --rpc-url https://ethereum.reth.rs/rpc + /// ``` + /// + /// Output: + /// + /// ```text + /// 882897731163608580 [8.828e17] + /// ``` + #[command(verbatim_doc_comment)] + ConvertToShares { + /// The ERC-4626 vault contract address. + #[arg(value_parser = NameOrAddress::from_str)] + vault: NameOrAddress, + + /// The amount of underlying assets. + assets: U256, + + /// The block height to query at. + #[arg(long, short = 'B')] + block: Option, + + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Convert a share amount to the corresponding asset amount + /// + /// Example: + /// + /// ```text + /// $ cast erc4626 convert-to-assets 0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB \ + /// 1000000000000000000 --block 25519075 \ + /// --rpc-url https://ethereum.reth.rs/rpc + /// ``` + /// + /// Output: + /// + /// ```text + /// 1132634 [1.132e6] + /// ``` + #[command(verbatim_doc_comment)] + ConvertToAssets { + /// The ERC-4626 vault contract address. + #[arg(value_parser = NameOrAddress::from_str)] + vault: NameOrAddress, + + /// The amount of vault shares. + shares: U256, + + /// The block height to query at. + #[arg(long, short = 'B')] + block: Option, + + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Query the maximum assets that may be deposited for a receiver + /// + /// Example: + /// + /// ```text + /// $ cast erc4626 max-deposit 0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB \ + /// 0x255c7705E8bb334dfCaE438197f7c4297988085A --block 25519075 \ + /// --rpc-url https://ethereum.reth.rs/rpc + /// ``` + /// + /// Output: + /// + /// ```text + /// 1002934816604622098 [1.002e18] + /// ``` + #[command(verbatim_doc_comment)] + MaxDeposit { + /// The ERC-4626 vault contract address. + #[arg(value_parser = NameOrAddress::from_str)] + vault: NameOrAddress, + + /// The receiver of the resulting vault shares. + #[arg(value_parser = NameOrAddress::from_str)] + receiver: NameOrAddress, + + /// The block height to query at. + #[arg(long, short = 'B')] + block: Option, + + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Preview the shares received by depositing an asset amount + /// + /// Example: + /// + /// ```text + /// $ cast erc4626 preview-deposit 0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB \ + /// 1000000 --block 25519075 --rpc-url https://ethereum.reth.rs/rpc + /// ``` + /// + /// Output: + /// + /// ```text + /// 882897731163608580 [8.828e17] + /// ``` + #[command(verbatim_doc_comment)] + PreviewDeposit { + /// The ERC-4626 vault contract address. + #[arg(value_parser = NameOrAddress::from_str)] + vault: NameOrAddress, + + /// The amount of underlying assets. + assets: U256, + + /// The block height to query at. + #[arg(long, short = 'B')] + block: Option, + + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Deposit assets into the vault + /// + /// The vault must have sufficient allowance to spend the underlying asset. + /// + /// Example: + /// + /// ```text + /// $ cast erc4626 deposit 0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB \ + /// 1000000 $ACCOUNT --private-key $PRIVATE_KEY --async --rpc-url $ETH_RPC_URL + /// ``` + /// + /// Output: + /// + /// ```text + /// 0x6f2a7e10f148a0ee81208cd8d7dee10cc33b5bdb739bfeef805dc68467e6db4e + /// ``` + #[command(verbatim_doc_comment)] + Deposit { + /// The ERC-4626 vault contract address. + #[arg(value_parser = NameOrAddress::from_str)] + vault: NameOrAddress, + + /// The amount of underlying assets. + assets: U256, + + /// The receiver of the resulting vault shares. + #[arg(value_parser = NameOrAddress::from_str)] + receiver: NameOrAddress, + + #[command(flatten)] + send_tx: SendTxOpts, + + #[command(flatten)] + tx: TxParams, + }, + + /// Query the maximum shares that may be minted for a receiver + /// + /// Example: + /// + /// ```text + /// $ cast erc4626 max-mint 0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB \ + /// 0x255c7705E8bb334dfCaE438197f7c4297988085A --block 25519075 \ + /// --rpc-url https://ethereum.reth.rs/rpc + /// ``` + /// + /// Output: + /// + /// ```text + /// 885488874085210715750045480687 [8.854e29] + /// ``` + #[command(verbatim_doc_comment)] + MaxMint { + /// The ERC-4626 vault contract address. + #[arg(value_parser = NameOrAddress::from_str)] + vault: NameOrAddress, + + /// The receiver of the resulting vault shares. + #[arg(value_parser = NameOrAddress::from_str)] + receiver: NameOrAddress, + + /// The block height to query at. + #[arg(long, short = 'B')] + block: Option, + + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Preview the assets required to mint a share amount + /// + /// Example: + /// + /// ```text + /// $ cast erc4626 preview-mint 0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB \ + /// 1000000000000000000 --block 25519075 \ + /// --rpc-url https://ethereum.reth.rs/rpc + /// ``` + /// + /// Output: + /// + /// ```text + /// 1132635 [1.132e6] + /// ``` + #[command(verbatim_doc_comment)] + PreviewMint { + /// The ERC-4626 vault contract address. + #[arg(value_parser = NameOrAddress::from_str)] + vault: NameOrAddress, + + /// The amount of vault shares. + shares: U256, + + /// The block height to query at. + #[arg(long, short = 'B')] + block: Option, + + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Mint vault shares + /// + /// The vault must have sufficient allowance to spend the underlying asset. + /// + /// Example: + /// + /// ```text + /// $ cast erc4626 mint 0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB \ + /// 1000000000000000000 $ACCOUNT --private-key $PRIVATE_KEY --async \ + /// --rpc-url $ETH_RPC_URL + /// ``` + /// + /// Output: + /// + /// ```text + /// 0xa7c3b4e26a99e2cb422e2b36cffeae642a39f1d09f8f3454c9c2d4657ebb0491 + /// ``` + #[command(verbatim_doc_comment)] + Mint { + /// The ERC-4626 vault contract address. + #[arg(value_parser = NameOrAddress::from_str)] + vault: NameOrAddress, + + /// The amount of vault shares. + shares: U256, + + /// The receiver of the resulting vault shares. + #[arg(value_parser = NameOrAddress::from_str)] + receiver: NameOrAddress, + + #[command(flatten)] + send_tx: SendTxOpts, + + #[command(flatten)] + tx: TxParams, + }, + + /// Query the maximum assets that an owner may withdraw + /// + /// Example: + /// + /// ```text + /// $ cast erc4626 max-withdraw 0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB \ + /// 0x255c7705E8bb334dfCaE438197f7c4297988085A --block 25519075 \ + /// --rpc-url https://ethereum.reth.rs/rpc + /// ``` + /// + /// Output: + /// + /// ```text + /// 40473486378 [4.047e10] + /// ``` + #[command(verbatim_doc_comment)] + MaxWithdraw { + /// The ERC-4626 vault contract address. + #[arg(value_parser = NameOrAddress::from_str)] + vault: NameOrAddress, + + /// The owner of the vault shares. + #[arg(value_parser = NameOrAddress::from_str)] + owner: NameOrAddress, + + /// The block height to query at. + #[arg(long, short = 'B')] + block: Option, + + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Preview the shares burned to withdraw an asset amount + /// + /// Example: + /// + /// ```text + /// $ cast erc4626 preview-withdraw 0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB \ + /// 1000000 --block 25519075 --rpc-url https://ethereum.reth.rs/rpc + /// ``` + /// + /// Output: + /// + /// ```text + /// 882897731163608581 [8.828e17] + /// ``` + #[command(verbatim_doc_comment)] + PreviewWithdraw { + /// The ERC-4626 vault contract address. + #[arg(value_parser = NameOrAddress::from_str)] + vault: NameOrAddress, + + /// The amount of underlying assets. + assets: U256, + + /// The block height to query at. + #[arg(long, short = 'B')] + block: Option, + + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Withdraw assets from the vault + /// + /// Example: + /// + /// ```text + /// $ cast erc4626 withdraw 0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB \ + /// 1000000 $ACCOUNT $ACCOUNT --private-key $PRIVATE_KEY --async \ + /// --rpc-url $ETH_RPC_URL + /// ``` + /// + /// Output: + /// + /// ```text + /// 0x6d6d70151151f30aa13bdb4082ea7fd3e531193eebdfbb356586284cd0e6a8a2 + /// ``` + #[command(verbatim_doc_comment)] + Withdraw { + /// The ERC-4626 vault contract address. + #[arg(value_parser = NameOrAddress::from_str)] + vault: NameOrAddress, + + /// The amount of underlying assets. + assets: U256, + + /// The receiver of the withdrawn assets. + #[arg(value_parser = NameOrAddress::from_str)] + receiver: NameOrAddress, + + /// The owner of the vault shares. + #[arg(value_parser = NameOrAddress::from_str)] + owner: NameOrAddress, + + #[command(flatten)] + send_tx: SendTxOpts, + + #[command(flatten)] + tx: TxParams, + }, + + /// Query the maximum shares that an owner may redeem + /// + /// Example: + /// + /// ```text + /// $ cast erc4626 max-redeem 0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB \ + /// 0x255c7705E8bb334dfCaE438197f7c4297988085A --block 25519075 \ + /// --rpc-url https://ethereum.reth.rs/rpc + /// ``` + /// + /// Output: + /// + /// ```text + /// 35733949295417417957447 [3.573e22] + /// ``` + #[command(verbatim_doc_comment)] + MaxRedeem { + /// The ERC-4626 vault contract address. + #[arg(value_parser = NameOrAddress::from_str)] + vault: NameOrAddress, + + /// The owner of the vault shares. + #[arg(value_parser = NameOrAddress::from_str)] + owner: NameOrAddress, + + /// The block height to query at. + #[arg(long, short = 'B')] + block: Option, + + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Preview the assets received by redeeming a share amount + /// + /// Example: + /// + /// ```text + /// $ cast erc4626 preview-redeem 0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB \ + /// 1000000000000000000 --block 25519075 \ + /// --rpc-url https://ethereum.reth.rs/rpc + /// ``` + /// + /// Output: + /// + /// ```text + /// 1132634 [1.132e6] + /// ``` + #[command(verbatim_doc_comment)] + PreviewRedeem { + /// The ERC-4626 vault contract address. + #[arg(value_parser = NameOrAddress::from_str)] + vault: NameOrAddress, + + /// The amount of vault shares. + shares: U256, + + /// The block height to query at. + #[arg(long, short = 'B')] + block: Option, + + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Redeem vault shares for assets + /// + /// Example: + /// + /// ```text + /// $ cast erc4626 redeem 0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB \ + /// 1000000000000000000 $ACCOUNT $ACCOUNT --private-key $PRIVATE_KEY --async \ + /// --rpc-url $ETH_RPC_URL + /// ``` + /// + /// Output: + /// + /// ```text + /// 0x304d463e4d33b462778a974d008cd1b9c4c109730ae6617d2815477b8aa7c03e + /// ``` + #[command(verbatim_doc_comment)] + Redeem { + /// The ERC-4626 vault contract address. + #[arg(value_parser = NameOrAddress::from_str)] + vault: NameOrAddress, + + /// The amount of vault shares. + shares: U256, + + /// The receiver of the redeemed assets. + #[arg(value_parser = NameOrAddress::from_str)] + receiver: NameOrAddress, + + /// The owner of the vault shares. + #[arg(value_parser = NameOrAddress::from_str)] + owner: NameOrAddress, + + #[command(flatten)] + send_tx: SendTxOpts, + + #[command(flatten)] + tx: TxParams, + }, +} + +#[derive(Debug, Serialize)] +struct TokenAmount { + raw: String, + #[serde(skip_serializing_if = "Option::is_none")] + formatted: Option, +} + +impl TokenAmount { + fn new(value: U256, decimals: Option) -> Self { + Self { + raw: value.to_string(), + formatted: decimals + .and_then(|decimals| crate::args::format_units(&value.to_string(), decimals).ok()), + } + } +} + +#[derive(Debug, Serialize)] +struct VaultInfo { + vault: String, + name: Option, + symbol: Option, + decimals: Option, + asset: String, + asset_name: Option, + asset_symbol: Option, + asset_decimals: Option, + total_assets: TokenAmount, + total_supply: TokenAmount, + assets_per_share: Option, + shares_per_asset: Option, +} + +#[derive(Debug, Serialize)] +struct VaultPosition { + vault: String, + owner: String, + asset: String, + share_symbol: Option, + share_decimals: Option, + asset_symbol: Option, + asset_decimals: Option, + share_balance: TokenAmount, + assets_equivalent: TokenAmount, + max_withdraw: TokenAmount, + max_redeem: TokenAmount, +} + +#[derive(Debug)] +struct VaultWarning { + code: &'static str, + message: String, +} + +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "lowercase")] +enum CheckStatus { + Pass, + Warn, + Fail, +} + +#[derive(Debug, Serialize)] +struct CompatibilityCheck { + name: String, + status: CheckStatus, + detail: String, +} + +#[derive(Debug, Serialize)] +struct CompatibilityReport { + vault: String, + account: String, + read_compatible: bool, + disclaimer: &'static str, + passed: usize, + warnings: usize, + failed: usize, + checks: Vec, +} + +const CHECK_DISCLAIMER: &str = "This probes read-call behavior only; it does not prove state-changing selector coverage or \ + semantic ERC-4626 compliance."; + +impl Erc4626Subcommand { + pub async fn run(self) -> Result<()> { + match self { + Self::Info { vault, human, block, rpc } => show_info(vault, human, block, rpc).await, + Self::Position { vault, owner, human, block, rpc } => { + show_position(vault, owner, human, block, rpc).await + } + Self::Check { vault, account, block, rpc } => { + check_compatibility(vault, account, block, rpc).await + } + Self::Asset { vault, block, rpc } => { + let (_, vault) = vault_at(&rpc, vault).await?; + let asset = vault.asset().block(block.unwrap_or_default()).call().await?; + warn_if_native_asset(asset)?; + print_scalar(asset.to_string()) + } + Self::TotalAssets { vault, block, rpc } => { + let (_, vault) = vault_at(&rpc, vault).await?; + print_amount(vault.totalAssets().block(block.unwrap_or_default()).call().await?) + } + Self::ConvertToShares { vault, assets, block, rpc } => { + let (_, vault) = vault_at(&rpc, vault).await?; + let call = vault.convertToShares(assets).block(block.unwrap_or_default()); + print_amount(call.call().await?) + } + Self::ConvertToAssets { vault, shares, block, rpc } => { + let (_, vault) = vault_at(&rpc, vault).await?; + let call = vault.convertToAssets(shares).block(block.unwrap_or_default()); + print_amount(call.call().await?) + } + Self::MaxDeposit { vault, receiver, block, rpc } => { + let (provider, vault) = vault_at(&rpc, vault).await?; + let receiver = receiver.resolve(&provider).await?; + let assets = + vault.maxDeposit(receiver).block(block.unwrap_or_default()).call().await?; + warn_if_zero_entry_max("maxDeposit", assets)?; + print_amount(assets) + } + Self::PreviewDeposit { vault, assets, block, rpc } => { + let (_, vault) = vault_at(&rpc, vault).await?; + let call = vault.previewDeposit(assets).block(block.unwrap_or_default()); + print_amount(call.call().await.wrap_err_with(|| preview_error("Deposit"))?) + } + Self::Deposit { vault, assets, receiver, send_tx, tx } => { + let [receiver] = prepare_write(&vault, [receiver], &send_tx).await?; + send_call(vault, IERC4626::depositCall { assets, receiver }, send_tx, tx).await + } + Self::MaxMint { vault, receiver, block, rpc } => { + let (provider, vault) = vault_at(&rpc, vault).await?; + let receiver = receiver.resolve(&provider).await?; + let shares = + vault.maxMint(receiver).block(block.unwrap_or_default()).call().await?; + warn_if_zero_entry_max("maxMint", shares)?; + print_amount(shares) + } + Self::PreviewMint { vault, shares, block, rpc } => { + let (_, vault) = vault_at(&rpc, vault).await?; + let call = vault.previewMint(shares).block(block.unwrap_or_default()); + print_amount(call.call().await.wrap_err_with(|| preview_error("Mint"))?) + } + Self::Mint { vault, shares, receiver, send_tx, tx } => { + let [receiver] = prepare_write(&vault, [receiver], &send_tx).await?; + send_call(vault, IERC4626::mintCall { shares, receiver }, send_tx, tx).await + } + Self::MaxWithdraw { vault, owner, block, rpc } => { + let (provider, vault) = vault_at(&rpc, vault).await?; + let owner = owner.resolve(&provider).await?; + let block = block.unwrap_or_default(); + let assets = vault.maxWithdraw(owner).block(block).call().await?; + if assets.is_zero() && has_shares(&vault, owner, block).await { + warn_if_zero_exit_max("maxWithdraw")?; + } + print_amount(assets) + } + Self::PreviewWithdraw { vault, assets, block, rpc } => { + let (_, vault) = vault_at(&rpc, vault).await?; + let call = vault.previewWithdraw(assets).block(block.unwrap_or_default()); + print_amount(call.call().await.wrap_err_with(|| preview_error("Withdraw"))?) + } + Self::Withdraw { vault, assets, receiver, owner, send_tx, tx } => { + let [receiver, owner] = prepare_write(&vault, [receiver, owner], &send_tx).await?; + let call = IERC4626::withdrawCall { assets, receiver, owner }; + send_call(vault, call, send_tx, tx).await + } + Self::MaxRedeem { vault, owner, block, rpc } => { + let (provider, vault) = vault_at(&rpc, vault).await?; + let owner = owner.resolve(&provider).await?; + let block = block.unwrap_or_default(); + let shares = vault.maxRedeem(owner).block(block).call().await?; + if shares.is_zero() && has_shares(&vault, owner, block).await { + warn_if_zero_exit_max("maxRedeem")?; + } + print_amount(shares) + } + Self::PreviewRedeem { vault, shares, block, rpc } => { + let (_, vault) = vault_at(&rpc, vault).await?; + let call = vault.previewRedeem(shares).block(block.unwrap_or_default()); + print_amount(call.call().await.wrap_err_with(|| preview_error("Redeem"))?) + } + Self::Redeem { vault, shares, receiver, owner, send_tx, tx } => { + let [receiver, owner] = prepare_write(&vault, [receiver, owner], &send_tx).await?; + let call = IERC4626::redeemCall { shares, receiver, owner }; + send_call(vault, call, send_tx, tx).await + } + } + } +} + +type Vault = IERC4626::IERC4626Instance; + +async fn vault_at(rpc: &RpcOpts, vault: NameOrAddress) -> Result<(RetryProvider, Vault)> { + let provider = rpc_provider(rpc)?; + let vault = vault.resolve(&provider).await?; + Ok((provider.clone(), IERC4626::new(vault, provider))) +} + +async fn has_shares(vault: &Vault, owner: Address, block: BlockId) -> bool { + vault.balanceOf(owner).block(block).call().await.is_ok_and(|shares| !shares.is_zero()) +} + +/// Error context for a failed `preview*` call; asynchronous ERC-7540 vaults revert these. +fn preview_error(method: &str) -> String { + let kind = if matches!(method, "Deposit" | "Mint") { "deposit" } else { "redeem" }; + format!( + "preview{method} failed; asynchronous ERC-7540 {kind} vaults intentionally revert this \ + preview" + ) +} + +async fn show_info( + vault: NameOrAddress, + human: bool, + block: Option, + rpc: RpcOpts, +) -> Result<()> { + let (provider, contract) = vault_at(&rpc, vault).await?; + let vault = *contract.address(); + let block = block.unwrap_or_default(); + + let name_call = contract.name().block(block); + let symbol_call = contract.symbol().block(block); + let decimals_call = contract.decimals().block(block); + let asset_call = contract.asset().block(block); + let total_assets_call = contract.totalAssets().block(block); + let total_supply_call = contract.totalSupply().block(block); + let (name, symbol, decimals, asset, total_assets, total_supply) = tokio::join!( + name_call.call(), + symbol_call.call(), + decimals_call.call(), + asset_call.call(), + total_assets_call.call(), + total_supply_call.call(), + ); + + let asset = asset.wrap_err("asset() call failed")?; + let total_assets = total_assets.wrap_err("totalAssets() call failed")?; + let total_supply = total_supply.wrap_err("totalSupply() call failed")?; + let name = name.ok(); + let symbol = symbol.ok(); + let decimals = decimals.ok(); + + let mut warnings = Vec::new(); + let (asset_name, asset_symbol, asset_decimals) = if asset == NATIVE_ASSET { + warnings.push(native_asset_warning()); + (None, None, Some(NATIVE_ASSET_DECIMALS)) + } else { + let asset_contract = IERC20Metadata::new(asset, &provider); + let name_call = asset_contract.name().block(block); + let symbol_call = asset_contract.symbol().block(block); + let decimals_call = asset_contract.decimals().block(block); + let (name, symbol, decimals) = + tokio::join!(name_call.call(), symbol_call.call(), decimals_call.call()); + (name.ok(), symbol.ok(), decimals.ok()) + }; + + let assets_per_share = match decimals.and_then(decimal_unit) { + Some(unit) => contract.convertToAssets(unit).block(block).call().await.ok(), + None => None, + } + .map(|value| TokenAmount::new(value, asset_decimals)); + let shares_per_asset = match asset_decimals.and_then(decimal_unit) { + Some(unit) => contract.convertToShares(unit).block(block).call().await.ok(), + None => None, + } + .map(|value| TokenAmount::new(value, decimals)); + + print_info( + VaultInfo { + vault: vault.to_string(), + name, + symbol, + decimals, + asset: asset.to_string(), + asset_name, + asset_symbol, + asset_decimals, + total_assets: TokenAmount::new(total_assets, asset_decimals), + total_supply: TokenAmount::new(total_supply, decimals), + assets_per_share, + shares_per_asset, + }, + human, + warnings, + ) +} + +async fn show_position( + vault: NameOrAddress, + owner: NameOrAddress, + human: bool, + block: Option, + rpc: RpcOpts, +) -> Result<()> { + let (provider, contract) = vault_at(&rpc, vault).await?; + let vault = *contract.address(); + let owner = owner.resolve(&provider).await?; + let block = block.unwrap_or_default(); + + let asset_call = contract.asset().block(block); + let symbol_call = contract.symbol().block(block); + let decimals_call = contract.decimals().block(block); + let balance_call = contract.balanceOf(owner).block(block); + let max_withdraw_call = contract.maxWithdraw(owner).block(block); + let max_redeem_call = contract.maxRedeem(owner).block(block); + let (asset, share_symbol, share_decimals, share_balance, max_withdraw, max_redeem) = tokio::join!( + asset_call.call(), + symbol_call.call(), + decimals_call.call(), + balance_call.call(), + max_withdraw_call.call(), + max_redeem_call.call(), + ); + + let asset = asset.wrap_err("asset() call failed")?; + let share_balance = share_balance.wrap_err("balanceOf() call failed")?; + let max_withdraw = max_withdraw.wrap_err("maxWithdraw() call failed")?; + let max_redeem = max_redeem.wrap_err("maxRedeem() call failed")?; + let share_symbol = share_symbol.ok(); + let share_decimals = share_decimals.ok(); + let assets_equivalent = contract + .convertToAssets(share_balance) + .block(block) + .call() + .await + .wrap_err("convertToAssets() call failed")?; + + let mut warnings = Vec::new(); + let (asset_symbol, asset_decimals) = if asset == NATIVE_ASSET { + warnings.push(native_asset_warning()); + (None, Some(NATIVE_ASSET_DECIMALS)) + } else { + let asset_contract = IERC20Metadata::new(asset, &provider); + let symbol_call = asset_contract.symbol().block(block); + let decimals_call = asset_contract.decimals().block(block); + let (symbol, decimals) = tokio::join!(symbol_call.call(), decimals_call.call()); + (symbol.ok(), decimals.ok()) + }; + + if !share_balance.is_zero() { + if max_withdraw.is_zero() { + warnings.push(zero_exit_warning("maxWithdraw")); + } + if max_redeem.is_zero() { + warnings.push(zero_exit_warning("maxRedeem")); + } + } + + print_position( + VaultPosition { + vault: vault.to_string(), + owner: owner.to_string(), + asset: asset.to_string(), + share_symbol, + share_decimals, + asset_symbol, + asset_decimals, + share_balance: TokenAmount::new(share_balance, share_decimals), + assets_equivalent: TokenAmount::new(assets_equivalent, asset_decimals), + max_withdraw: TokenAmount::new(max_withdraw, asset_decimals), + max_redeem: TokenAmount::new(max_redeem, share_decimals), + }, + human, + warnings, + ) +} + +async fn check_compatibility( + vault: NameOrAddress, + account: Option, + block: Option, + rpc: RpcOpts, +) -> Result<()> { + let (provider, contract) = vault_at(&rpc, vault).await?; + let vault = *contract.address(); + let account = match account { + Some(account) => account.resolve(&provider).await?, + None => Address::ZERO, + }; + let block = block.unwrap_or_default(); + + let code_call = provider.get_code_at(vault).block_id(block); + let asset_call = contract.asset().block(block); + let total_assets_call = contract.totalAssets().block(block); + let convert_to_shares_call = contract.convertToShares(U256::ZERO).block(block); + let convert_to_assets_call = contract.convertToAssets(U256::ZERO).block(block); + let max_deposit_call = contract.maxDeposit(account).block(block); + let preview_deposit_call = contract.previewDeposit(U256::ZERO).block(block); + let max_mint_call = contract.maxMint(account).block(block); + let preview_mint_call = contract.previewMint(U256::ZERO).block(block); + let max_withdraw_call = contract.maxWithdraw(account).block(block); + let preview_withdraw_call = contract.previewWithdraw(U256::ZERO).block(block); + let max_redeem_call = contract.maxRedeem(account).block(block); + let preview_redeem_call = contract.previewRedeem(U256::ZERO).block(block); + let name_call = contract.name().block(block); + let symbol_call = contract.symbol().block(block); + let decimals_call = contract.decimals().block(block); + let total_supply_call = contract.totalSupply().block(block); + let balance_call = contract.balanceOf(account).block(block); + let allowance_call = contract.allowance(account, vault).block(block); + let erc165 = IERC165::new(vault, &provider); + let async_deposit_call = erc165.supportsInterface(ERC7540_ASYNC_DEPOSIT_INTERFACE).block(block); + let async_redeem_call = erc165.supportsInterface(ERC7540_ASYNC_REDEEM_INTERFACE).block(block); + let ( + code, + asset, + total_assets, + convert_to_shares, + convert_to_assets, + max_deposit, + preview_deposit, + max_mint, + preview_mint, + max_withdraw, + preview_withdraw, + max_redeem, + preview_redeem, + name, + symbol, + decimals, + total_supply, + balance, + allowance, + async_deposit, + async_redeem, + ) = tokio::join!( + code_call, + asset_call.call(), + total_assets_call.call(), + convert_to_shares_call.call(), + convert_to_assets_call.call(), + max_deposit_call.call(), + preview_deposit_call.call(), + max_mint_call.call(), + preview_mint_call.call(), + max_withdraw_call.call(), + preview_withdraw_call.call(), + max_redeem_call.call(), + preview_redeem_call.call(), + name_call.call(), + symbol_call.call(), + decimals_call.call(), + total_supply_call.call(), + balance_call.call(), + allowance_call.call(), + async_deposit_call.call(), + async_redeem_call.call(), + ); + let async_deposit = async_deposit.unwrap_or(false); + let async_redeem = async_redeem.unwrap_or(false); + + let mut checks = Vec::new(); + match code { + Ok(code) if !code.is_empty() => push_check( + &mut checks, + "contract code", + CheckStatus::Pass, + "contract bytecode is present", + ), + Ok(_) => push_check( + &mut checks, + "contract code", + CheckStatus::Fail, + "no contract bytecode was found", + ), + Err(_) => push_check( + &mut checks, + "contract code", + CheckStatus::Fail, + "contract bytecode could not be read", + ), + } + + if let Ok(asset) = asset { + if asset.is_zero() { + push_check(&mut checks, "asset()", CheckStatus::Fail, "returned the zero address"); + } else if asset == NATIVE_ASSET { + push_check( + &mut checks, + "asset()", + CheckStatus::Warn, + "returned the ERC-7535 native-asset sentinel", + ); + } else { + push_check(&mut checks, "asset()", CheckStatus::Pass, format!("returned {asset}")); + match provider.get_code_at(asset).block_id(block).await { + Ok(code) if !code.is_empty() => push_check( + &mut checks, + "asset contract", + CheckStatus::Pass, + "underlying asset bytecode is present", + ), + Ok(_) => push_check( + &mut checks, + "asset contract", + CheckStatus::Warn, + "underlying asset has no bytecode and may be a system contract or precompile", + ), + Err(_) => push_check( + &mut checks, + "asset contract", + CheckStatus::Warn, + "underlying asset bytecode could not be read", + ), + } + record_required( + &mut checks, + "asset balanceOf(address)", + IERC20Metadata::new(asset, &provider).balanceOf(vault).block(block).call().await, + ); + } + } else { + push_check( + &mut checks, + "asset()", + CheckStatus::Fail, + "call failed or returned incompatible data", + ); + } + + record_required(&mut checks, "totalAssets()", total_assets); + record_required(&mut checks, "totalSupply()", total_supply); + record_required(&mut checks, "balanceOf(address)", balance); + record_required(&mut checks, "allowance(address,address)", allowance); + record_zero_conversion(&mut checks, "convertToShares(0)", convert_to_shares); + record_zero_conversion(&mut checks, "convertToAssets(0)", convert_to_assets); + record_required(&mut checks, "maxDeposit(address)", max_deposit); + record_preview(&mut checks, "previewDeposit(0)", "deposit", async_deposit, preview_deposit); + record_required(&mut checks, "maxMint(address)", max_mint); + record_preview(&mut checks, "previewMint(0)", "deposit", async_deposit, preview_mint); + record_required(&mut checks, "maxWithdraw(address)", max_withdraw); + record_preview(&mut checks, "previewWithdraw(0)", "redeem", async_redeem, preview_withdraw); + record_required(&mut checks, "maxRedeem(address)", max_redeem); + record_preview(&mut checks, "previewRedeem(0)", "redeem", async_redeem, preview_redeem); + record_required(&mut checks, "name()", name); + record_required(&mut checks, "symbol()", symbol); + record_required(&mut checks, "decimals()", decimals); + + let passed = checks.iter().filter(|check| matches!(check.status, CheckStatus::Pass)).count(); + let warnings = checks.iter().filter(|check| matches!(check.status, CheckStatus::Warn)).count(); + let failed = checks.iter().filter(|check| matches!(check.status, CheckStatus::Fail)).count(); + let report = CompatibilityReport { + vault: vault.to_string(), + account: account.to_string(), + read_compatible: failed == 0, + disclaimer: CHECK_DISCLAIMER, + passed, + warnings, + failed, + checks, + }; + if failed > 0 { + let message = format!("vault failed {failed} ERC-4626 compatibility probe(s)"); + if shell::is_json() { + return Err(JsonError::new( + report, + JsonMessage::error("erc4626.compatibility_failed", message), + )? + .into()); + } + print_compatibility_report(&report)?; + eyre::bail!(message) + } + print_compatibility_report(&report) +} + +fn print_info(info: VaultInfo, human: bool, warnings: Vec) -> Result<()> { + if shell::is_json() { + return print_json_with_warnings(info, warnings); + } + + print_warnings(&warnings)?; + let asset_symbol = info.asset_symbol.as_deref(); + let symbol = info.symbol.as_deref(); + print_field("Vault", &info.vault)?; + print_field("Name", or_unavailable(info.name.as_ref()))?; + print_field("Symbol", or_unavailable(symbol))?; + print_field("Decimals", or_unavailable(info.decimals))?; + print_field("Asset", &info.asset)?; + print_field("Asset name", or_unavailable(info.asset_name.as_ref()))?; + print_field("Asset symbol", or_unavailable(asset_symbol))?; + print_field("Asset decimals", or_unavailable(info.asset_decimals))?; + print_field("Total assets", display_amount(&info.total_assets, human, asset_symbol))?; + print_field("Total supply", display_amount(&info.total_supply, human, symbol))?; + print_field( + "Assets per share", + or_unavailable( + info.assets_per_share.as_ref().map(|a| display_amount(a, human, asset_symbol)), + ), + )?; + print_field( + "Shares per asset", + or_unavailable(info.shares_per_asset.as_ref().map(|a| display_amount(a, human, symbol))), + ) +} + +fn print_position(position: VaultPosition, human: bool, warnings: Vec) -> Result<()> { + if shell::is_json() { + return print_json_with_warnings(position, warnings); + } + + print_warnings(&warnings)?; + let share_symbol = position.share_symbol.as_deref(); + let asset_symbol = position.asset_symbol.as_deref(); + print_field("Vault", &position.vault)?; + print_field("Owner", &position.owner)?; + print_field("Asset", &position.asset)?; + print_field("Share symbol", or_unavailable(share_symbol))?; + print_field("Share decimals", or_unavailable(position.share_decimals))?; + print_field("Asset symbol", or_unavailable(asset_symbol))?; + print_field("Asset decimals", or_unavailable(position.asset_decimals))?; + print_field("Share balance", display_amount(&position.share_balance, human, share_symbol))?; + print_field( + "Assets equivalent", + display_amount(&position.assets_equivalent, human, asset_symbol), + )?; + print_field("Max withdraw", display_amount(&position.max_withdraw, human, asset_symbol))?; + print_field("Max redeem", display_amount(&position.max_redeem, human, share_symbol)) +} + +fn print_compatibility_report(report: &CompatibilityReport) -> Result<()> { + if shell::is_json() { + return print_json_success(report); + } + + print_field("Vault", &report.vault)?; + print_field("Account", &report.account)?; + sh_println!("Note: {}", report.disclaimer)?; + for check in &report.checks { + let status = match check.status { + CheckStatus::Pass => "PASS", + CheckStatus::Warn => "WARN", + CheckStatus::Fail => "FAIL", + }; + sh_println!("{status:<4} {:<24} {}", check.name, check.detail)?; + } + sh_println!( + "Summary: {} passed, {} warnings, {} failed", + report.passed, + report.warnings, + report.failed + ) +} + +fn print_field(label: &str, value: impl std::fmt::Display) -> Result<()> { + sh_println!("{label:<20} {value}") +} + +fn print_json_with_warnings(value: T, warnings: Vec) -> Result<()> { + if warnings.is_empty() { + print_json_success(value) + } else { + print_json_success_with_warnings( + value, + warnings + .into_iter() + .map(|warning| JsonMessage::warning(warning.code, warning.message)) + .collect(), + ) + } +} + +fn print_warnings(warnings: &[VaultWarning]) -> Result<()> { + for warning in warnings { + sh_warn!("{}", warning.message)?; + } + Ok(()) +} + +fn or_unavailable(value: Option) -> String { + value.map_or_else(|| "".to_string(), |value| value.to_string()) +} + +fn display_amount(amount: &TokenAmount, human: bool, symbol: Option<&str>) -> String { + match (&amount.formatted, symbol.filter(|symbol| !symbol.is_empty())) { + (Some(formatted), Some(symbol)) if human => format!("{formatted} {symbol}"), + (Some(formatted), None) if human => formatted.clone(), + _ => amount.raw.clone(), + } +} + +/// `10^decimals`, or `None` when it overflows. +fn decimal_unit(decimals: u8) -> Option { + U256::from(10).checked_pow(U256::from(decimals)) +} + +fn push_check( + checks: &mut Vec, + name: impl Into, + status: CheckStatus, + detail: impl Into, +) { + checks.push(CompatibilityCheck { name: name.into(), status, detail: detail.into() }); +} + +fn record_required( + checks: &mut Vec, + name: &str, + result: std::result::Result, +) { + let (status, detail) = if result.is_ok() { + (CheckStatus::Pass, "call succeeded") + } else { + (CheckStatus::Fail, "call failed or returned incompatible data") + }; + push_check(checks, name, status, detail); +} + +fn record_zero_conversion( + checks: &mut Vec, + name: &str, + result: std::result::Result, +) { + match result { + Ok(value) if value.is_zero() => { + push_check(checks, name, CheckStatus::Pass, "returned zero") + } + Ok(value) => push_check( + checks, + name, + CheckStatus::Warn, + format!("returned {value}; zero input normally converts to zero"), + ), + Err(_) => { + push_check(checks, name, CheckStatus::Fail, "call failed or returned incompatible data") + } + } +} + +fn record_preview( + checks: &mut Vec, + name: &str, + request_kind: &str, + async_supported: bool, + result: std::result::Result, +) { + match result { + Ok(_) if async_supported => push_check( + checks, + name, + CheckStatus::Warn, + format!( + "vault advertises asynchronous ERC-7540 {request_kind} support, which requires \ + this preview to revert" + ), + ), + Ok(value) if value.is_zero() => { + push_check(checks, name, CheckStatus::Pass, "returned zero") + } + Ok(value) => push_check( + checks, + name, + CheckStatus::Warn, + format!("returned {value}; a zero-amount preview normally returns zero"), + ), + Err(_) if async_supported => push_check( + checks, + name, + CheckStatus::Warn, + format!( + "reverted as required by advertised asynchronous ERC-7540 {request_kind} support" + ), + ), + Err(_) => push_check( + checks, + name, + CheckStatus::Fail, + "call failed or returned incompatible data without advertised ERC-7540 support", + ), + } +} + +fn warn_if_zero_entry_max(method: &str, amount: U256) -> Result<()> { + if amount.is_zero() { + sh_warn!( + "Vault reported zero from {method}; some ERC-4626 vaults intentionally return \ + conservative maxima or gate deposits, so this may not mean deposits are impossible." + )?; + } + Ok(()) +} + +fn warn_if_zero_exit_max(method: &str) -> Result<()> { + sh_warn!("{}", zero_exit_warning(method).message) +} + +fn warn_if_native_asset(asset: Address) -> Result<()> { + if asset == NATIVE_ASSET { + sh_warn!("{}", native_asset_warning().message)?; + } + Ok(()) +} + +fn zero_exit_warning(method: &str) -> VaultWarning { + VaultWarning { + code: match method { + "maxWithdraw" => "erc4626_zero_max_withdraw", + "maxRedeem" => "erc4626_zero_max_redeem", + _ => "erc4626_zero_exit_max", + }, + message: format!( + "Vault reported zero from {method} even though the owner has shares; liquidity, \ + gates, withdrawal queues, or a conservative implementation may prevent the base \ + ERC-4626 exit." + ), + } +} + +fn native_asset_warning() -> VaultWarning { + VaultWarning { + code: "erc4626_native_asset", + message: "Vault uses the ERC-7535 native-asset sentinel; base ERC-4626 write commands do \ + not attach native value, so use `cast send --value` when the vault requires it." + .to_string(), + } +} + +/// Warns when the vault holds the native asset and resolves the write's account arguments. +async fn prepare_write( + vault: &NameOrAddress, + accounts: [NameOrAddress; N], + send_tx: &SendTxOpts, +) -> Result<[Address; N]> { + let (provider, vault) = vault_at(&send_tx.eth.rpc, vault.clone()).await?; + if let Ok(asset) = vault.asset().call().await { + warn_if_native_asset(asset)?; + } + + let mut resolved = [Address::ZERO; N]; + for (slot, account) in resolved.iter_mut().zip(accounts) { + *slot = account.resolve(&provider).await?; + } + Ok(resolved) +} + +async fn send_call( + vault: NameOrAddress, + call: C, + send_tx: SendTxOpts, + tx: TxParams, +) -> Result<()> { + // Boxed to keep the large `cast send` future off this command's stack frame. + Box::pin(SendTxArgs::contract_call(vault, call.abi_encode(), send_tx, tx).run()).await +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::CommandFactory; + + #[test] + fn all_subcommands_document_example_output() { + let command = Erc4626Subcommand::command(); + let subcommands = command.get_subcommands().collect::>(); + assert_eq!(subcommands.len(), 19); + + for subcommand in subcommands { + let help = subcommand + .get_long_about() + .unwrap_or_else(|| panic!("{} is missing long help", subcommand.get_name())) + .to_string(); + assert!( + help.contains("Example:\n\n```text\n$ cast erc4626"), + "{} is missing a fenced example command", + subcommand.get_name() + ); + assert!( + help.contains("\n\nOutput:\n\n```text\n"), + "{} is missing fenced example output", + subcommand.get_name() + ); + } + + let info = command.find_subcommand("info").unwrap().get_long_about().unwrap().to_string(); + assert!(info.contains("--human")); + } +} diff --git a/crates/cast/src/cmd/estimate.rs b/crates/cast/src/cmd/estimate.rs index 031376e2747e3..6506574529c23 100644 --- a/crates/cast/src/cmd/estimate.rs +++ b/crates/cast/src/cmd/estimate.rs @@ -1,15 +1,24 @@ -use crate::tx::{CastTxBuilder, SenderKind}; +use super::{auth::confirm_and_build, print_result_line}; +use crate::tx::{CastTxBuilder, read_only_sender}; use alloy_ens::NameOrAddress; +use alloy_network::{Ethereum, Network}; use alloy_primitives::U256; use alloy_provider::Provider; use alloy_rpc_types::BlockId; use clap::Parser; use eyre::Result; use foundry_cli::{ - opts::{EthereumOpts, TransactionOpts}, - utils::{self, LoadConfig, parse_ether_value}, + opts::{RpcOpts, TransactionOpts}, + utils::{LoadConfig, parse_ether_value}, }; +use foundry_common::{FoundryTransactionBuilder, provider::ProviderBuilder}; +use foundry_config::Config; +use foundry_wallets::{BrowserWalletOpts, WalletOpts}; use std::str::FromStr; +use tempo_alloy::TempoNetwork; + +#[cfg(feature = "base")] +use base_common_network::Base; /// CLI arguments for `cast estimate`. #[derive(Debug, Parser)] @@ -22,6 +31,7 @@ pub struct EstimateArgs { sig: Option, /// The arguments of the function to call. + #[arg(allow_negative_numbers = true)] args: Vec, /// The block height to query at. @@ -36,14 +46,24 @@ pub struct EstimateArgs { #[arg(long)] cost: bool, + #[command(flatten)] + wallet: WalletOpts, + + #[command(flatten)] + browser: BrowserWalletOpts, + #[command(subcommand)] command: Option, #[command(flatten)] tx: TransactionOpts, + /// Skip the EIP-7702 authorization disclosure confirmation. + #[arg(long)] + force: bool, + #[command(flatten)] - eth: EthereumOpts, + rpc: RpcOpts, } #[derive(Debug, Parser)] @@ -58,6 +78,7 @@ pub enum EstimateSubcommands { sig: Option, /// Constructor arguments + #[arg(allow_negative_numbers = true)] args: Vec, /// Ether to send in the transaction @@ -72,11 +93,44 @@ pub enum EstimateSubcommands { impl EstimateArgs { pub async fn run(self) -> Result<()> { - let Self { to, mut sig, mut args, mut tx, block, cost, eth, command } = self; + let config = self.rpc.load_config()?; + let requires_tempo = self.tx.tempo.is_tempo() || self.tx.tempo.session_id()?.is_some(); + let network = super::resolve_transaction_network(&config, requires_tempo).await?; + if network.is_tempo() { + return self.run_with_network::(config).await; + } + #[cfg(feature = "base")] + if network.is_base() { + super::validate_base_transaction_options(&self.tx)?; + return self.run_with_network::(config).await; + } + self.run_with_network::(config).await + } - let config = eth.load_config()?; - let provider = utils::get_provider(&config)?; - let sender = SenderKind::from_wallet_opts(eth.wallet).await?; + async fn run_with_network(self, config: Config) -> Result<()> + where + N::TransactionRequest: FoundryTransactionBuilder, + { + let Self { + to, + mut sig, + mut args, + mut tx, + block, + cost, + wallet, + browser, + force, + rpc: _, + command, + } = self; + + let provider = ProviderBuilder::::from_config(&config)?.build()?; + let chain_id = match config.chain { + Some(chain) => chain.id(), + None => provider.get_chain_id().await?, + }; + let (sender, is_browser) = read_only_sender::(&browser, wallet, &tx, chain_id).await?; let code = if let Some(EstimateSubcommands::Create { code, @@ -95,35 +149,24 @@ impl EstimateArgs { None }; - let (tx, _) = CastTxBuilder::new(&provider, tx, &config) + let builder = CastTxBuilder::new(&provider, tx, &config) .await? .with_to(to) .await? .with_code_sig_and_args(code, sig, args) .await? - .build_raw(sender) - .await?; + .raw(); + let Some(tx) = confirm_and_build(builder, sender, force, None, true).await? else { + return Ok(()); + }; + let tx = if is_browser { tx.browser_wallet_gas_estimation_request() } else { tx }; let gas = provider.estimate_gas(tx).block(block.unwrap_or_default()).await?; if cost { - let gas_price_wei = provider.get_gas_price().await?; - let cost = gas_price_wei * gas as u128; - let cost_eth = cost as f64 / 1e18; - sh_println!("{cost_eth}")?; + let cost = provider.get_gas_price().await? * gas as u128; + print_result_line(cost as f64 / 1e18) } else { - sh_println!("{gas}")?; + print_result_line(gas) } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_estimate_value() { - let args: EstimateArgs = EstimateArgs::parse_from(["foundry-cli", "--value", "100"]); - assert!(args.tx.value.is_some()); } } diff --git a/crates/cast/src/cmd/events.rs b/crates/cast/src/cmd/events.rs new file mode 100644 index 0000000000000..e62c1bbe27fc3 --- /dev/null +++ b/crates/cast/src/cmd/events.rs @@ -0,0 +1,453 @@ +use super::{MAX_CONCURRENT_RPC_REQUESTS, fetch_code_via_rpc, logs::LogQueryArgs}; +use crate::traces::{ + CallTraceDecoderBuilder, DecodedEvent, + identifier::{ExternalIdentifier, SignaturesIdentifier}, +}; +use alloy_dyn_abi::DynSolValue; +use alloy_json_abi::JsonAbi; +use alloy_network::Network; +use alloy_primitives::{Address, B256, Bytes, TxHash}; +use alloy_provider::Provider; +use alloy_rpc_types::{BlockId, Log}; +use clap::{ArgGroup, Parser}; +use eyre::Result; +use foundry_cli::{ + json::print_json_object, + opts::{EtherscanOpts, RpcOpts}, + utils::{self, load_config_from_provider}, +}; +use foundry_common::{ + ContractsByArtifact, compile::ProjectCompiler, fmt::serialize_value_as_json, shell, +}; +use foundry_config::{Chain, Config}; +use futures::StreamExt; +use itertools::Itertools; +use serde::{Serialize, Serializer}; +use std::{ + collections::{BTreeMap, BTreeSet}, + fmt::Write as _, +}; + +foundry_config::impl_figment_convert!(EventsArgs, etherscan, rpc); + +/// CLI arguments for `cast events`. +#[derive(Debug, Parser)] +#[command(group( + ArgGroup::new("event_source") + .required(true) + .multiple(true) + .args(["tx_hash", "address", "from_block", "to_block", "sig_or_topic"]) +))] +pub struct EventsArgs { + /// Get events emitted by this transaction. + #[arg( + long, + alias = "txhash", + value_name = "TX_HASH", + conflicts_with_all = [ + "from_block", + "to_block", + "address", + "sig_or_topic", + "topics_or_args", + "query_size" + ] + )] + tx_hash: Option, + + #[command(flatten)] + query: LogQueryArgs, + + #[command(flatten)] + etherscan: EtherscanOpts, + + #[command(flatten)] + rpc: RpcOpts, + + /// Use current project artifacts for event decoding. + /// + /// Only supported when querying by transaction hash. + #[arg(long, visible_alias = "la")] + with_local_artifacts: bool, +} + +impl EventsArgs { + pub async fn run(self) -> Result<()> { + let figment = + self.rpc.clone().into_figment(self.with_local_artifacts).merge(&self.etherscan); + let mut config = load_config_from_provider(figment)?; + let Self { tx_hash, mut query, with_local_artifacts, .. } = self; + let tx_hash = tx_hash.or_else(|| query.take_transaction_hash()); + if with_local_artifacts && tx_hash.is_none() { + eyre::bail!("--with-local-artifacts is only supported with a transaction hash"); + } + let provider = utils::get_provider(&config)?; + let rpc_chain = Chain::from(provider.get_chain_id().await?); + let (decoder_chain, explorer_chain) = resolve_chains(config.chain, rpc_chain); + config.chain = Some(decoder_chain); + + let logs = if let Some(tx_hash) = tx_hash { + provider + .get_transaction_receipt(tx_hash) + .await? + .ok_or_else(|| eyre::eyre!("tx receipt not found: {tx_hash}"))? + .inner + .logs() + .to_vec() + } else { + let (filter, query_size) = query.resolve(&provider).await?; + match query_size { + Some(chunk_size) => { + crate::cmd::logs::get_logs_chunked(&provider, &filter, chunk_size).await? + } + None => provider.get_logs(&filter).await?, + } + }; + + let local_abis = if with_local_artifacts { + local_event_abis(&provider, &logs, &config).await? + } else { + BTreeMap::new() + }; + let events = decode_logs(logs, &config, explorer_chain, local_abis).await?; + if shell::is_json() { + print_json_object(events)?; + } else { + // Bypass the shell verbosity layer so `--quiet` does not suppress the primary result. + let mut shell = shell::Shell::get(); + let out = shell.out(); + write!(out, "{}", format_events(&events))?; + out.flush()?; + } + Ok(()) + } +} + +async fn local_event_abis>( + provider: &P, + logs: &[Log], + config: &Config, +) -> Result> { + let addresses = logs.iter().map(Log::address).collect::>(); + let Some(block_number) = logs.first().and_then(|log| log.block_number) else { + return Ok(BTreeMap::new()); + }; + + let bytecodes = fetch_code_via_rpc(&provider, addresses, BlockId::number(block_number)).await; + + let output = ProjectCompiler::new().quiet(true).compile(&config.project()?)?; + let contracts = ContractsByArtifact::from(output); + Ok(bytecodes + .iter() + .filter_map(|(&address, code)| { + let (_, contract) = contracts.find_by_deployed_code_exact_unique(code)?; + Some((address, contract.abi.clone())) + }) + .collect()) +} + +async fn decode_logs( + logs: Vec, + config: &Config, + explorer_chain: Chain, + local_abis: BTreeMap, +) -> Result> { + let signature_identifier = SignaturesIdentifier::from_config(config)?; + let mut builder = CallTraceDecoderBuilder::new() + .with_signature_identifier(signature_identifier) + .with_networks(config.networks) + .with_chain_id(config.chain.map(|chain| chain.id())); + + let mut externally_resolved = BTreeSet::new(); + if let Some(mut identifier) = ExternalIdentifier::new(config, Some(explorer_chain))? { + let addresses = + logs.iter().map(Log::address).collect::>().into_iter().collect::>(); + for (address, result) in identifier.get_abis(&addresses).await { + match result { + Ok((abis, complete)) => { + externally_resolved.insert(address); + if !complete { + sh_warn!("Only partially resolved proxy ABI chain for {address}")?; + } + for abi in abis { + builder = builder.with_address_events(address, &abi); + } + } + Err(err) => sh_warn!("Failed to fetch ABI for {address}: {err}")?, + } + } + } + + for (address, abi) in local_abis { + if !externally_resolved.contains(&address) { + builder = builder.with_address_events(address, &abi); + } + } + + let decoder = builder.build(); + let events = futures::stream::iter(logs) + .map(|log| async { + let decoded = + decoder.decode_event_with_address_signature(log.address(), log.data()).await; + EventOutput::new(log, decoded) + }) + .buffered(MAX_CONCURRENT_RPC_REQUESTS) + .collect() + .await; + Ok(events) +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct EventOutput { + address: Address, + block_hash: Option, + block_number: Option, + block_timestamp: Option, + transaction_hash: Option, + transaction_index: Option, + log_index: Option, + removed: bool, + event: Option, + params: Option>, + topics: Vec, + data: Bytes, +} + +impl EventOutput { + fn new(log: Log, decoded: DecodedEvent) -> Self { + let params = decoded.params.map(|params| { + params + .into_iter() + .enumerate() + .map(|(index, (name, display_value, value))| EventParam { + name: if name.is_empty() { format!("param{index}") } else { name }, + value, + display_value, + }) + .collect() + }); + Self { + address: log.address(), + block_hash: log.block_hash, + block_number: log.block_number, + block_timestamp: log.block_timestamp, + transaction_hash: log.transaction_hash, + transaction_index: log.transaction_index, + log_index: log.log_index, + removed: log.removed, + event: decoded.name, + params, + topics: log.topics().to_vec(), + data: log.data().data.clone(), + } + } +} + +#[derive(Debug, Serialize)] +struct EventParam { + name: String, + #[serde(serialize_with = "serialize_abi_value")] + value: DynSolValue, + #[serde(skip)] + display_value: String, +} + +fn serialize_abi_value(value: &DynSolValue, serializer: S) -> Result +where + S: Serializer, +{ + serialize_value_as_json(value.clone(), None, true) + .map_err(serde::ser::Error::custom)? + .serialize(serializer) +} + +/// Formats decoded and raw events for human-readable output. +/// +/// # Example +/// +/// ```text +/// [block 1, tx 0xabc..., log 0] 0x123...::Transfer(address,uint256) { from: 0x456..., value: 1 } +/// 0x789... +/// topic 0: 0xdef... +/// data: 0x +/// ``` +fn format_events(events: &[EventOutput]) -> String { + let mut output = String::new(); + for event in events { + let location = [ + event.block_number.map(|n| format!("block {n}")), + event.transaction_hash.map(|hash| format!("tx {hash}")), + event.log_index.map(|i| format!("log {i}")), + ]; + let mut location = location.iter().flatten().peekable(); + if location.peek().is_some() { + let _ = write!(output, "[{}] ", location.format(", ")); + } + if let Some(name) = &event.event { + let _ = write!(output, "{}::{name}", event.address); + if let Some(params) = &event.params { + let params = + params.iter().map(|param| format!("{}: {}", param.name, param.display_value)); + let _ = write!(output, " {{ {} }}", params.format(", ")); + } + output.push('\n'); + } else { + let _ = writeln!(output, "{}", event.address); + for (index, topic) in event.topics.iter().enumerate() { + let _ = writeln!(output, " topic {index}: {topic}"); + } + let _ = writeln!(output, " data: {}", event.data); + } + } + output +} + +/// Decodes against the RPC chain, but looks ABIs up on the configured explorer chain. +fn resolve_chains(configured: Option, rpc_chain: Chain) -> (Chain, Chain) { + (rpc_chain, configured.unwrap_or(rpc_chain)) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::{Function, U256}; + + #[test] + fn configured_chain_controls_explorer_lookup() { + let rpc_chain = Chain::from(31337); + assert_eq!( + resolve_chains(Some(Chain::mainnet()), rpc_chain), + (rpc_chain, Chain::mainnet()) + ); + assert_eq!(resolve_chains(None, rpc_chain), (rpc_chain, rpc_chain)); + } + + #[test] + fn validates_event_sources() { + assert!(EventsArgs::try_parse_from(["events"]).is_err()); + assert!( + EventsArgs::try_parse_from(["events", "--tx-hash", &TxHash::ZERO.to_string()]).is_ok() + ); + let EventsArgs { tx_hash, mut query, .. } = + EventsArgs::try_parse_from(["events", &TxHash::ZERO.to_string()]).unwrap(); + assert_eq!(tx_hash.or_else(|| query.take_transaction_hash()), Some(TxHash::ZERO)); + let EventsArgs { mut query, .. } = EventsArgs::try_parse_from([ + "events", + &TxHash::ZERO.to_string(), + "--address", + &Address::ZERO.to_string(), + ]) + .unwrap(); + assert!(query.take_transaction_hash().is_none()); + assert!( + EventsArgs::try_parse_from([ + "events", + "--tx-hash", + &TxHash::ZERO.to_string(), + "--address", + &Address::ZERO.to_string(), + ]) + .is_err() + ); + assert!( + EventsArgs::try_parse_from([ + "events", + "--address", + &Address::ZERO.to_string(), + "--from-block", + "1", + "--to-block", + "2", + ]) + .is_ok() + ); + } + + #[test] + fn serializes_abi_values_and_formats_display_values() { + let output = EventOutput::new( + Log::default(), + DecodedEvent { + name: Some("Value(uint256)".to_string()), + params: Some(vec![( + "value".to_string(), + "19705728070 [1.97e10]".to_string(), + DynSolValue::Uint(U256::from(19_705_728_070u64), 256), + )]), + }, + ); + let json = serde_json::to_value(&output).unwrap(); + assert_eq!(json["params"][0]["value"], "19705728070"); + assert!(format_events(&[output]).contains("19705728070 [1.97e10]")); + + let output = EventOutput::new( + Log::default(), + DecodedEvent { + name: Some("Callback(function)".to_string()), + params: Some(vec![( + "callback".to_string(), + "0x111111111111111111111111111111111111111111111111".to_string(), + DynSolValue::Function(Function::new([0x11; 24])), + )]), + }, + ); + assert!(format_events(&[output]).contains( + "Callback(function) { callback: 0x111111111111111111111111111111111111111111111111 }" + )); + } + + #[test] + fn formats_decoded_and_raw_events() { + let decoded = EventOutput { + address: Address::repeat_byte(0xaa), + block_hash: Some(B256::repeat_byte(0x33)), + block_number: Some(7), + block_timestamp: Some(123), + transaction_hash: Some(TxHash::repeat_byte(0xbb)), + transaction_index: Some(2), + log_index: Some(3), + removed: false, + event: Some("Transfer(address,address,uint256)".to_string()), + params: Some(vec![EventParam { + name: "value".to_string(), + value: DynSolValue::Uint(U256::from(42), 256), + display_value: "42".to_string(), + }]), + topics: vec![B256::repeat_byte(0x11)], + data: Bytes::from_static(&[0x22]), + }; + let raw = EventOutput { + address: Address::repeat_byte(0xbb), + block_hash: None, + block_number: None, + block_timestamp: None, + transaction_hash: None, + transaction_index: None, + log_index: None, + removed: false, + event: None, + params: None, + topics: vec![B256::repeat_byte(0x11)], + data: Bytes::from_static(&[0x22]), + }; + + let value = serde_json::to_value(&decoded).unwrap(); + assert_eq!(value["blockNumber"], 7); + assert_eq!(value["event"], "Transfer(address,address,uint256)"); + assert_eq!(value["params"][0]["name"], "value"); + assert_eq!(value["params"][0]["value"], "42"); + assert_eq!(value["data"], "0x22"); + + assert_eq!( + format_events(&[decoded, raw]), + concat!( + "[block 7, tx 0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb, log 3] ", + "0xaAaAaAaaAaAaAaaAaAAAAAAAAaaaAaAaAaaAaaAa::Transfer(address,address,uint256) { value: 42 }\n", + "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB\n", + " topic 0: 0x1111111111111111111111111111111111111111111111111111111111111111\n", + " data: 0x22\n", + ) + ); + } +} diff --git a/crates/cast/src/cmd/find_block.rs b/crates/cast/src/cmd/find_block.rs index 19aa9e3ff6abb..142134e0d7138 100644 --- a/crates/cast/src/cmd/find_block.rs +++ b/crates/cast/src/cmd/find_block.rs @@ -1,11 +1,8 @@ -use crate::Cast; +use crate::cmd::rpc_provider; use alloy_provider::Provider; use clap::Parser; use eyre::Result; -use foundry_cli::{ - opts::RpcOpts, - utils::{self, LoadConfig}, -}; +use foundry_cli::{json::print_scalar, opts::RpcOpts}; use futures::join; /// CLI arguments for `cast find-block`. @@ -18,20 +15,36 @@ pub struct FindBlockArgs { rpc: RpcOpts, } +fn interpolate_block( + low_block: u64, + low_timestamp: u64, + high_block: u64, + high_timestamp: u64, + target_timestamp: u64, +) -> u64 { + let block_range = high_block - low_block; + let midpoint = high_block - block_range / 2; + if high_timestamp <= low_timestamp { + return midpoint; + } + + let timestamp_offset = target_timestamp - low_timestamp; + let timestamp_range = high_timestamp - low_timestamp; + let block_offset = + u128::from(timestamp_offset) * u128::from(block_range) / u128::from(timestamp_range); + (low_block + block_offset as u64).clamp(low_block + 1, high_block - 1) +} + impl FindBlockArgs { pub async fn run(self) -> Result<()> { - let Self { timestamp, rpc } = self; - - let ts_target = timestamp; - let config = rpc.load_config()?; - let provider = utils::get_provider(&config)?; + let Self { timestamp: ts_target, rpc } = self; + let provider = rpc_provider(&rpc)?; let last_block_num = provider.get_block_number().await?; - let cast_provider = Cast::new(provider); - - let res = join!(cast_provider.timestamp(last_block_num), cast_provider.timestamp(1)); - let ts_block_latest: u64 = res.0?.to(); - let ts_block_1: u64 = res.1?.to(); + let (ts_block_latest, ts_block_1) = + join!(timestamp(&provider, last_block_num), timestamp(&provider, 1)); + let ts_block_latest = ts_block_latest?; + let ts_block_1 = ts_block_1?; let block_num = if ts_block_latest < ts_target { // If the most recent block's timestamp is below the target, return it @@ -42,45 +55,83 @@ impl FindBlockArgs { } else { // Otherwise, find the block that is closest to the timestamp let mut low_block = 1_u64; // block 0 has a timestamp of 0: https://github.com/ethereum/go-ethereum/issues/17042#issuecomment-559414137 + let mut low_timestamp = ts_block_1; let mut high_block = last_block_num; - let mut matching_block = None; - while high_block > low_block && matching_block.is_none() { - // Get timestamp of middle block (this approach to avoids overflow) - let high_minus_low_over_2 = high_block - .checked_sub(low_block) - .ok_or_else(|| eyre::eyre!("unexpected underflow")) - .unwrap() - .checked_div(2_u64) - .unwrap(); - let mid_block = high_block.checked_sub(high_minus_low_over_2).unwrap(); - let ts_mid_block = cast_provider.timestamp(mid_block).await?.to::(); + let mut high_timestamp = ts_block_latest; + // Limit interpolation to the range's binary search depth so irregular chains retain + // logarithmic worst-case behavior. + let mut interpolation_budget = + u64::BITS - last_block_num.saturating_sub(1).leading_zeros(); + loop { + let block_range = high_block - low_block; + if block_range == 0 { + break low_block; + } + if block_range == 1 { + // Round to the higher block when the timestamp is equidistant. + let high_diff = high_timestamp - ts_target; + let low_diff = ts_target - low_timestamp; + break if low_diff < high_diff { low_block } else { high_block }; + } - // Check if we've found a match or should keep searching - if ts_mid_block == ts_target { - matching_block = Some(mid_block) - } else if high_block.checked_sub(low_block).unwrap() == 1_u64 { - // The target timestamp is in between these blocks. This rounds to the - // highest block if timestamp is equidistant between blocks - let res = join!( - cast_provider.timestamp(high_block), - cast_provider.timestamp(low_block) - ); - let ts_high: u64 = res.0.unwrap().to(); - let ts_low: u64 = res.1.unwrap().to(); - let high_diff = ts_high.checked_sub(ts_target).unwrap(); - let low_diff = ts_target.checked_sub(ts_low).unwrap(); - let is_low = low_diff < high_diff; - matching_block = if is_low { Some(low_block) } else { Some(high_block) } - } else if ts_mid_block < ts_target { - low_block = mid_block; + let midpoint = high_block - block_range / 2; + let next_block = if interpolation_budget == 0 { + midpoint } else { - high_block = mid_block; + interpolation_budget -= 1; + interpolate_block( + low_block, + low_timestamp, + high_block, + high_timestamp, + ts_target, + ) + }; + let next_timestamp = timestamp(&provider, next_block).await?; + + if next_timestamp == ts_target { + break next_block; + } + if next_timestamp < ts_target { + low_block = next_block; + low_timestamp = next_timestamp; + } else { + high_block = next_block; + high_timestamp = next_timestamp; } } - matching_block.unwrap_or(low_block) }; - sh_println!("{block_num}")?; + print_scalar(block_num)?; Ok(()) } } + +async fn timestamp( + provider: &impl Provider, + number: u64, +) -> Result { + Ok(provider + .get_block_by_number(number.into()) + .await? + .ok_or_else(|| eyre::eyre!("block {number} not found"))? + .header + .timestamp) +} + +#[cfg(test)] +mod tests { + use super::interpolate_block; + + #[test] + fn interpolates_block_from_timestamps() { + assert_eq!(interpolate_block(1, 100, 11, 200, 150), 6); + // Stays inside the search bounds. + assert_eq!(interpolate_block(1, 100, 11, 200, 100), 2); + assert_eq!(interpolate_block(1, 100, 11, 200, 200), 10); + // Does not overflow. + assert_eq!(interpolate_block(0, 0, u64::MAX, u64::MAX, u64::MAX / 2), u64::MAX / 2); + // Falls back to the midpoint for equal timestamps. + assert_eq!(interpolate_block(1, 100, 10, 100, 100), 6); + } +} diff --git a/crates/cast/src/cmd/interface.rs b/crates/cast/src/cmd/interface.rs index 3c2295dd9e294..6c681c6c62450 100644 --- a/crates/cast/src/cmd/interface.rs +++ b/crates/cast/src/cmd/interface.rs @@ -1,21 +1,25 @@ -use alloy_json_abi::{ContractObject, JsonAbi}; -use alloy_primitives::Address; +use alloy_json_abi::{ContractObject, InternalType, JsonAbi, Param, ToSolConfig}; +use alloy_primitives::{ + Address, + map::{HashMap, HashSet}, +}; use clap::Parser; use eyre::{Context, Result}; -use foundry_block_explorers::Client; -use foundry_cli::{opts::EtherscanOpts, utils::LoadConfig}; +use forge_fmt::FormatterConfig; +use foundry_cli::{ + json::print_json_object, + opts::EtherscanOpts, + utils::{LoadConfig, fetch_abi_from_etherscan}, +}; use foundry_common::{ ContractsByArtifact, - compile::{PathOrContractInfo, ProjectCompiler}, + compile::{PathOrContractInfo, ProjectCompiler, compile_abi_project}, find_target_path, fs, shell, }; use foundry_config::load_config; use itertools::Itertools; use serde_json::Value; -use std::{ - path::{Path, PathBuf}, - str::FromStr, -}; +use std::{path::PathBuf, str::FromStr}; /// CLI arguments for `cast interface`. #[derive(Clone, Debug, Parser)] @@ -23,7 +27,8 @@ pub struct InterfaceArgs { /// The target contract, which can be one of: /// - A file path to an ABI JSON file. /// - A contract identifier in the form `:` or just ``. - /// - An Ethereum address, for which the ABI will be fetched from Etherscan. + /// - An Ethereum address, for which the ABI will be fetched from Etherscan. If Etherscan + /// reports the contract as a proxy, the ABI of its implementation is included as well. contract: String, /// The name to use for the generated interface. @@ -47,123 +52,170 @@ pub struct InterfaceArgs { )] output: Option, + /// If set, generate all types in a single interface, inlining any inherited or library types. + /// + /// This can fail if there are structs with the same name in different interfaces. + #[arg(long)] + flatten: bool, + #[command(flatten)] etherscan: EtherscanOpts, } impl InterfaceArgs { pub async fn run(self) -> Result<()> { - let Self { contract, name, pragma, output: output_location, etherscan } = self; - - // Determine if the target contract is an ABI file, a local contract or an Ethereum address. - let abis = if Path::new(&contract).is_file() - && fs::read_to_string(&contract) - .ok() - .and_then(|content| serde_json::from_str::(&content).ok()) - .is_some() - { - load_abi_from_file(&contract, name)? - } else { - match Address::from_str(&contract) { - Ok(address) => fetch_abi_from_etherscan(address, ðerscan).await?, - Err(_) => load_abi_from_artifact(&contract)?, - } - }; - - // Retrieve interfaces from the array of ABIs. - let interfaces = get_interfaces(abis)?; - - // Print result or write to file. - let res = if shell::is_json() { - // Format as JSON. - interfaces.iter().map(|iface| &iface.json_abi).format("\n").to_string() + let Self { contract, name, pragma, output, flatten, etherscan } = self; + + // The target is an ABI file, an Ethereum address, or a local contract. + let is_json_file = fs::read_to_string(&contract) + .is_ok_and(|content| serde_json::from_str::(&content).is_ok()); + let abis = if is_json_file { + vec![(load_abi_from_file(&contract)?, name.unwrap_or_else(|| "Interface".to_owned()))] + } else if let Ok(address) = Address::from_str(&contract) { + fetch_abi_from_etherscan(address, ðerscan.load_config()?, true).await? } else { - // Format as Solidity. - format!( - "// SPDX-License-Identifier: UNLICENSED\n\ - pragma solidity {pragma};\n\n\ - {}", - interfaces.iter().map(|iface| &iface.source).format("\n") - ) + vec![load_abi_from_artifact(&contract)?] }; - if let Some(loc) = output_location { + let config = flatten.then(|| ToSolConfig::new().one_contract(true)); + let mut json_abis = Vec::with_capacity(abis.len()); + let mut sources = Vec::with_capacity(abis.len()); + let multiple = abis.len() > 1; + let mut declarations = HashSet::default(); + for (mut abi, mut name) in abis { + json_abis.push(serde_json::to_value(&abi)?); + abi.dedup(); + if multiple { + let mut names = HashMap::<_, _>::default(); + let unique = unique_declaration_name(&name, &mut declarations, Some(&abi)); + names.insert(name, unique.clone()); + name = unique; + visit_abi_types(&mut abi, &mut |ty| { + if let InternalType::Struct { contract: Some(contract), .. } + | InternalType::Enum { contract: Some(contract), .. } + | InternalType::Other { contract: Some(contract), .. } = ty + { + *contract = names + .entry(contract.clone()) + .or_insert_with(|| { + if flatten { + contract.clone() + } else { + unique_declaration_name(contract, &mut declarations, None) + } + }) + .clone(); + } + }); + } + let source = abi.to_sol(&name, config.clone()); + sources.push( + match forge_fmt::format(&source, FormatterConfig::default()).into_result() { + Ok(formatted) => formatted, + Err(e) => { + sh_warn!("Failed to format interface for {name}: {e}")?; + source + } + }, + ); + } + let source = format!( + "// SPDX-License-Identifier: UNLICENSED\n\ + pragma solidity {pragma};\n\n\ + {}", + sources.iter().format("\n") + ); + + if let Some(loc) = output { + let res = + if shell::is_json() { serde_json::to_string_pretty(&json_abis)? } else { source }; if let Some(parent) = loc.parent() { fs::create_dir_all(parent)?; } fs::write(&loc, res)?; - sh_println!("Saved interface at {}", loc.display())?; + sh_status!("Saved interface at {}", loc.display())?; + } else if shell::is_json() { + print_json_object(json_abis)?; } else { - sh_print!("{res}")?; + sh_print!("{source}")?; } - Ok(()) } } -struct InterfaceSource { - json_abi: String, - source: String, +/// Reserves a declaration name across all generated interfaces and libraries. +fn unique_declaration_name( + name: &str, + declarations: &mut HashSet, + abi: Option<&JsonAbi>, +) -> String { + let mut available = |candidate: &str| { + !abi.is_some_and(|abi| abi.functions.contains_key(candidate)) + && declarations.insert(candidate.to_owned()) + }; + if available(name) { + return name.to_owned(); + } + for suffix in 1.. { + let candidate = format!("{name}_{suffix}"); + if available(&candidate) { + return candidate; + } + } + unreachable!() +} + +/// Visits internal types, including nested tuples, in every ABI parameter. +fn visit_abi_types(abi: &mut JsonAbi, visit: &mut impl FnMut(&mut InternalType)) { + if let Some(constructor) = abi.constructor_mut() { + visit_param_types(&mut constructor.inputs, visit); + } + for function in abi.functions_mut() { + visit_param_types(&mut function.inputs, visit); + visit_param_types(&mut function.outputs, visit); + } + for error in abi.errors_mut() { + visit_param_types(&mut error.inputs, visit); + } + for event in abi.events_mut() { + for param in &mut event.inputs { + if let Some(ty) = &mut param.internal_type { + visit(ty); + } + visit_param_types(&mut param.components, visit); + } + } +} + +fn visit_param_types(params: &mut [Param], visit: &mut impl FnMut(&mut InternalType)) { + for param in params { + if let Some(ty) = &mut param.internal_type { + visit(ty); + } + visit_param_types(&mut param.components, visit); + } } /// Load the ABI from a file. -pub fn load_abi_from_file(path: &str, name: Option) -> Result> { +pub(crate) fn load_abi_from_file(path: &str) -> Result { let file = std::fs::read_to_string(path).wrap_err("unable to read abi file")?; let obj: ContractObject = serde_json::from_str(&file)?; - let abi = obj.abi.ok_or_else(|| eyre::eyre!("could not find ABI in file {path}"))?; - let name = name.unwrap_or_else(|| "Interface".to_owned()); - Ok(vec![(abi, name)]) + obj.abi.ok_or_else(|| eyre::eyre!("could not find ABI in file {path}")) } -/// Load the ABI from the artifact of a locally compiled contract. -fn load_abi_from_artifact(path_or_contract: &str) -> Result> { +/// Load the ABI and name from the artifact of a locally compiled contract. +fn load_abi_from_artifact(path_or_contract: &str) -> Result<(JsonAbi, String)> { let config = load_config()?; - let project = config.project()?; + let mut project = config.project()?; + project.no_artifacts = true; let compiler = ProjectCompiler::new().quiet(true); let contract = PathOrContractInfo::from_str(path_or_contract)?; - let target_path = find_target_path(&project, &contract)?; - let output = compiler.files([target_path.clone()]).compile(&project)?; - - let contracts_by_artifact = ContractsByArtifact::from(output); - - let maybe_abi = contracts_by_artifact - .find_abi_by_name_or_src_path(contract.name().unwrap_or(&target_path.to_string_lossy())); - - let (abi, name) = - maybe_abi.as_ref().ok_or_else(|| eyre::eyre!("Failed to fetch lossless ABI"))?; - - Ok(vec![(abi.clone(), contract.name().unwrap_or(name).to_string())]) -} - -/// Fetches the ABI of a contract from Etherscan. -pub async fn fetch_abi_from_etherscan( - address: Address, - etherscan: &EtherscanOpts, -) -> Result> { - let config = etherscan.load_config()?; - let chain = config.chain.unwrap_or_default(); - let api_version = config.get_etherscan_api_version(Some(chain)); - let api_key = config.get_etherscan_api_key(Some(chain)).unwrap_or_default(); - let client = Client::new_with_api_version(chain, api_key, api_version)?; - let source = client.contract_source_code(address).await?; - source.items.into_iter().map(|item| Ok((item.abi()?, item.contract_name))).collect() -} + let output = compile_abi_project(&mut project, compiler.files([target_path.clone()]))?; -/// Converts a vector of tuples containing the ABI and contract name into a vector of -/// `InterfaceSource` objects. -fn get_interfaces(abis: Vec<(JsonAbi, String)>) -> Result> { - abis.into_iter() - .map(|(contract_abi, name)| { - let source = match foundry_cli::utils::abi_to_solidity(&contract_abi, &name) { - Ok(generated_source) => generated_source, - Err(e) => { - sh_warn!("Failed to format interface for {name}: {e}")?; - contract_abi.to_sol(&name, None) - } - }; - Ok(InterfaceSource { json_abi: serde_json::to_string_pretty(&contract_abi)?, source }) - }) - .collect() + let (abi, name) = ContractsByArtifact::from(output) + .find_abi_by_name_or_src_path(contract.name().unwrap_or(&target_path.to_string_lossy())) + .ok_or_else(|| eyre::eyre!("Failed to fetch lossless ABI"))?; + Ok((abi, contract.name().unwrap_or(&name).to_string())) } diff --git a/crates/cast/src/cmd/keychain.rs b/crates/cast/src/cmd/keychain.rs new file mode 100644 index 0000000000000..b626bf1e70091 --- /dev/null +++ b/crates/cast/src/cmd/keychain.rs @@ -0,0 +1,3924 @@ +use crate::{ + cmd::{ + auth::confirm_and_build, + print_json_or, + send::{SendOptions, cast_send}, + tempo_policy_args::{parse_period, parse_scope, parse_selector_bytes}, + }, + tempo::{apply_fee_payment, print_expires, require_hardfork, sponsor_hash, tempo_provider}, + tx::{CastTxBuilder, SendTxOpts, SenderKind, apply_poll_interval}, +}; +use alloy_consensus::BlockHeader; +use alloy_ens::NameOrAddress; +use alloy_network::{EthereumWallet, NetworkTransactionBuilder}; +use alloy_primitives::{Address, B256, Bytes, U256, hex}; +use alloy_provider::{Provider, ProviderBuilder as AlloyProviderBuilder}; +use alloy_rpc_types::BlockId; +use alloy_signer::Signer; +use alloy_sol_types::SolCall; +use chrono::DateTime; +use clap::Parser; +use eyre::Result; +use foundry_cli::{ + json::{print_json_object, print_json_success}, + opts::{RpcOpts, TempoOpts, TransactionOpts}, + utils::{LoadConfig, now, parse_fee_token_address, parse_json, resolve_lane}, +}; +use foundry_common::{ + provider::ProviderBuilder, + sh_warn, shell, + tempo::{ + self, AccountsStoreView, KeyType, active_tempo_hardfork, is_tempo_hardfork_active, + read_tempo_accounts_store, tempo_accounts_store_path, + }, +}; +use foundry_evm::hardfork::TempoHardfork; +use foundry_wallets::{ + BrowserWalletOpts, WalletOpts, WalletSigner, wallet_browser::signer::BrowserSigner, +}; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::fmt::Display; +use tempo_alloy::{TempoNetwork, provider::TempoProviderExt}; +use tempo_contracts::precompiles::{ + ACCOUNT_KEYCHAIN_ADDRESS, DEFAULT_FEE_TOKEN, + IAccountKeychain::{ + self, CallScope, KeyInfo, KeyRestrictions, LegacyTokenLimit, SelectorRule, SignatureType, + TokenLimit, + }, + ISignatureVerifier, ITIP20, PATH_USD_ADDRESS, SIGNATURE_VERIFIER_ADDRESS, + account_keychain::{ + authorizeAdminKeyCall, authorizeKeyCall, authorizeKeyWithWitnessCall, + legacyAuthorizeKeyCall, + }, +}; +use tempo_primitives::transaction::{ + CallScope as AuthCallScope, KeyAuthorization, PrimitiveSignature, + SignatureType as AuthSignatureType, SignedKeyAuthorization, TokenLimit as AuthTokenLimit, +}; +use yansi::Paint; + +/// Tempo keychain management commands. +/// +/// Manage access keys stored in `~/.tempo/wallet/store.json` and query or modify +/// on-chain key state via the AccountKeychain precompile. +#[derive(Debug, Parser)] +pub enum KeychainSubcommand { + /// List all keys from the local Tempo Accounts store. + #[command(visible_alias = "ls")] + List, + + /// Show all keys for a specific wallet address from the local Tempo Accounts store. + Show { + /// The wallet address to look up. + wallet_address: Address, + }, + + /// Check on-chain provisioning status of a key via the AccountKeychain precompile. + #[command(visible_alias = "info")] + Check { + /// The wallet (account) address. + wallet_address: Address, + + /// The key address to check. + key_address: Address, + + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Inspect an access key policy using the Tempo Accounts store and on-chain state. + Inspect { + /// The key address to inspect. + key_address: Address, + + /// Root account address. Required when the key is not present in the local Accounts store. + #[arg(long, visible_alias = "wallet-address", value_name = "ADDRESS")] + root_account: Option
, + + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Diagnose access-key signing issues end-to-end. + /// + /// Walks the Tempo Accounts store, RPC, and on-chain key state and prints a green + /// checklist. The first failing step turns red and includes a one-line hint. + Doctor { + /// The key address to diagnose. Optional when `--root-account` is provided. + #[arg(required_unless_present = "root_account")] + key_address: Option
, + + /// Root account address. Required if the key cannot be resolved from the Accounts store, + /// or to diagnose the default key for a sender. + #[arg(long, visible_alias = "wallet-address", value_name = "ADDRESS")] + root_account: Option
, + + /// Hypothetical call target for the TIP-1011 scope check. + #[arg(long, value_name = "ADDRESS")] + to: Option
, + + /// Function selector for the TIP-1011 scope check (hex `0x12345678`, + /// known shorthand like `transfer`, or full signature like `foo(uint256)`). + #[arg(long, value_parser = parse_selector_bytes, requires = "to")] + selector: Option<[u8; 4]>, + + /// Recipient address for the TIP-1011 scope check (per-selector recipient list). + #[arg(long, value_name = "ADDRESS", requires = "selector")] + recipient: Option
, + + /// Fee token to check the root account balance for. Defaults to PathUSD. + #[arg( + id = "doctor_fee_token", + long = "fee-token", + value_name = "TOKEN", + value_parser = parse_fee_token_address + )] + fee_token: Option
, + + #[command(flatten)] + tempo: TempoOpts, + + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Authorize a new key on-chain via the AccountKeychain precompile. + #[command(visible_alias = "auth")] + Authorize { + /// The key address to authorize. + key_address: Address, + + /// Signature type: secp256k1, p256, or webauthn. + #[arg(default_value = "secp256k1", value_parser = parse_signature_type)] + key_type: SignatureType, + + /// Expiry timestamp (unix seconds). Defaults to u64::MAX (never expires). + #[arg(default_value_t = u64::MAX)] + expiry: u64, + + /// Enforce spending limits for this key. + #[arg(long)] + enforce_limits: bool, + + /// Spending limit in TOKEN:AMOUNT format. Can be specified multiple times. + #[arg(long = "limit", value_parser = parse_limit)] + limits: Vec, + + /// Call scope restriction in `TARGET[:SELECTORS[@RECIPIENTS]]` format. + /// TARGET alone allows all calls. `TARGET:transfer,approve` restricts to those selectors. + /// `TARGET:transfer@0x123` restricts selector to specific recipients. + #[arg(long = "scope", value_parser = parse_scope)] + scope: Vec, + + /// Call scope restrictions as a JSON array. An empty array denies all calls. + /// Requires a Tempo T3-capable chain. + /// Format: `[{"target":"0x...","selectors":["transfer"]}]` or + /// `[{"target":"0x...","selectors":[{"selector":"transfer","recipients":["0x..."]}]}]` + #[arg(long = "scopes", value_parser = parse_scopes_json_wrapped, conflicts_with = "scope")] + scopes_json: Option, + + /// Optional TIP-1053 witness to bind to this on-chain authorization. + /// + /// `0x000...000` is a valid present witness and is distinct from omitting the flag. + /// + /// For `--admin`, the `authorizeAdminKey` precompile always takes a witness; omitting + /// this flag submits `bytes32(0)` (which fails if that witness is already burned). + #[arg(long)] + witness: Option, + + /// Authorize a T6 admin access key via `authorizeAdminKey` (key-management only). + /// + /// Admin keys may authorize/revoke other keys but cannot carry an expiry, spending limits, + /// or call scopes. The account is the signing (precompile caller) account. + #[arg(long)] + admin: bool, + + /// Skip the EIP-7702 authorization disclosure confirmation. + #[arg(long)] + force: bool, + + #[command(flatten)] + tx: TransactionOpts, + + #[command(flatten)] + send_tx: SendTxOpts, + }, + + /// Revoke an authorized key on-chain via the AccountKeychain precompile. + #[command(visible_alias = "rev")] + Revoke { + /// The key address to revoke. + key_address: Address, + + /// Skip the EIP-7702 authorization disclosure confirmation. + #[arg(long)] + force: bool, + + #[command(flatten)] + tx: TransactionOpts, + + #[command(flatten)] + send_tx: SendTxOpts, + }, + + /// Burn a TIP-1053 key-authorization witness for the signing account. + #[command(name = "burn-witness")] + BurnWitness { + /// Witness to burn. `bytes32(0)` is valid. + witness: B256, + + /// Skip the EIP-7702 authorization disclosure confirmation. + #[arg(long)] + force: bool, + + #[command(flatten)] + tx: TransactionOpts, + + #[command(flatten)] + send_tx: SendTxOpts, + }, + + /// Check whether a TIP-1053 key-authorization witness has been burned. + #[command(name = "is-witness-burned")] + IsWitnessBurned { + /// Account whose witness burn set should be checked. + account: Address, + + /// Witness to check. `bytes32(0)` is valid. + witness: B256, + + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Check whether a key is the root key or an active admin key for an account (T6). + #[command(name = "is-admin")] + IsAdmin { + /// The account (root) address. + account: Address, + + /// The key address to check. + key_address: Address, + + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Verify a Tempo keychain signature against an account's active access key (T6). + /// + /// `signature` must be an encoded Tempo keychain signature (not a raw 65-byte secp256k1 + /// signature). Returns true only for an active access key, never the root key. The supplied + /// `hash` should already be domain-separated by the caller. + Verify { + /// The expected (root) account that embeds the key. + account: Address, + + /// The 32-byte message hash that was signed. + hash: B256, + + /// The encoded Tempo keychain signature. + signature: Bytes, + + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Verify a Tempo keychain signature against an account's root or admin key (T6). + /// + /// `signature` must be an encoded Tempo keychain signature (not a raw 65-byte secp256k1 + /// signature). Returns true for the root key or an active admin key. `hash` should already be + /// domain-separated by the caller. + #[command(name = "verify-admin")] + VerifyAdmin { + /// The expected (root) account that embeds the key. + account: Address, + + /// The 32-byte message hash that was signed. + hash: B256, + + /// The encoded Tempo keychain signature. + signature: Bytes, + + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Query the remaining spending limit for a key on a specific token. + #[command(name = "rl", visible_alias = "remaining-limit")] + RemainingLimit { + /// The wallet (account) address. + wallet_address: Address, + + /// The key address. + key_address: Address, + + /// The token address. + token: Address, + + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Update the spending limit for a key on a specific token. + #[command(name = "ul", visible_alias = "update-limit")] + UpdateLimit { + /// The key address. + key_address: Address, + + /// The token address. + token: Address, + + /// The new spending limit. + new_limit: U256, + + /// Skip the EIP-7702 authorization disclosure confirmation. + #[arg(long)] + force: bool, + + #[command(flatten)] + tx: TransactionOpts, + + #[command(flatten)] + send_tx: SendTxOpts, + }, + + /// Set allowed call scopes for a key. + #[command(name = "ss", visible_alias = "set-scope")] + SetScope { + /// The key address. + key_address: Address, + + /// Call scope restriction in `TARGET[:SELECTORS[@RECIPIENTS]]` format. + #[arg(long = "scope", required = true, value_parser = parse_scope)] + scope: Vec, + + /// Skip the EIP-7702 authorization disclosure confirmation. + #[arg(long)] + force: bool, + + #[command(flatten)] + tx: TransactionOpts, + + #[command(flatten)] + send_tx: SendTxOpts, + }, + + /// Remove call scope for a key on a target. + #[command(name = "rs", visible_alias = "remove-scope")] + RemoveScope { + /// The key address. + key_address: Address, + + /// The target address to remove scope for. + target: Address, + + /// Skip the EIP-7702 authorization disclosure confirmation. + #[arg(long)] + force: bool, + + #[command(flatten)] + tx: TransactionOpts, + + #[command(flatten)] + send_tx: SendTxOpts, + }, + + /// Read or edit TIP-1011 access-key permissions. + Policy { + /// Skip the EIP-7702 authorization disclosure confirmation. + #[arg(long, global = true)] + force: bool, + + #[command(subcommand)] + command: KeychainPolicySubcommand, + }, +} + +/// Tempo key-authorization artifact helpers. +#[derive(Debug, Parser)] +pub enum KeyAuthorizationSubcommand { + /// RLP-encode an unsigned Tempo key authorization. + Encode { + #[command(flatten)] + authorization: KeyAuthorizationArgs, + + /// Bind this authorization to a target account (T6). + /// + /// Required for `--admin` so the authorization cannot be replayed across accounts sharing + /// the same admin key. May also bind a plain authorization without `--admin`. + #[arg(long, value_name = "ADDRESS")] + account: Option
, + }, + + /// Sign and RLP-encode a Tempo key authorization. + /// + /// With an admin access-key signer the bound account is the key's root and is derived + /// automatically; with a direct signer, `--bind-account` binds to another root. A root-signed + /// `--admin` authorization defaults the account to the signer. + Sign { + #[command(flatten)] + authorization: KeyAuthorizationArgs, + + /// Bind this authorization to a target (root) account (T6). + /// + /// Named `--bind-account` to avoid clashing with the wallet keystore `--account` selector. + #[arg(long = "bind-account", value_name = "ADDRESS")] + account: Option
, + + #[command(flatten)] + wallet: Box, + + #[command(flatten)] + browser: BrowserWalletOpts, + }, + + /// Decode and inspect a Tempo key authorization (signed or unsigned). + /// + /// Accepts the hex RLP from `encode` (unsigned) or `sign` (signed) and prints its fields, + /// including the T6 `is_admin`/`account` fields and the recovered signer for signed input. + Inspect { + /// Hex-encoded RLP key authorization (signed or unsigned). + authorization: String, + + /// Expected bound account; rejects a mismatched or replayed account-bound authorization. + #[arg(long, value_name = "ADDRESS")] + account: Option
, + }, +} + +/// Common fields for `cast key-authorization encode` and `cast key-authorization sign`. +#[derive(Debug, Parser)] +pub struct KeyAuthorizationArgs { + /// Chain ID for replay protection. + #[arg(long)] + chain_id: u64, + + /// Key address to authorize. + key_address: Address, + + /// Type of access key being authorized: secp256k1, p256, or webauthn. + /// The root signature type is determined by the configured signer. + #[arg(long, default_value = "secp256k1", value_parser = parse_auth_signature_type)] + key_type: AuthSignatureType, + + /// Expiry timestamp (unix seconds). Omit for no expiry. + #[arg(long)] + expiry: Option, + + /// Enforce spending limits for this key. With no --limit entries, this means no spending. + #[arg(long)] + enforce_limits: bool, + + /// Spending limit in `TOKEN:AMOUNT[:PERIOD]` format. Can be specified multiple times. + #[arg(long = "limit", value_parser = parse_auth_limit)] + limits: Vec, + + /// Call scope restriction in `TARGET[:SELECTORS[@RECIPIENTS]]` format. + /// TARGET alone allows all calls to that target. + #[arg(long = "scope", value_parser = parse_auth_scope)] + scope: Vec, + + /// Call scope restrictions as a JSON array. + #[arg(long = "scopes", value_parser = parse_auth_scopes_json_wrapped, conflicts_with = "scope")] + scopes_json: Option, + + /// Optional TIP-1053 witness to include in the authorization signing hash. + /// + /// `0x000...000` is a valid present witness and is distinct from omitting the flag. + #[arg(long)] + witness: Option, + + /// Authorize a T6 admin access key (key-management only). + /// + /// Admin keys may authorize/revoke other keys but cannot carry an expiry, spending limits, or + /// call scopes, and require a bound account (`--account` for `encode`, signer-derived for + /// `sign`). + #[arg(long)] + admin: bool, +} + +/// Higher-level access-key policy editing commands. +#[derive(Debug, Parser)] +pub enum KeychainPolicySubcommand { + /// Add or widen an allowed call rule for a target contract. + AddCall { + /// The key address to update. + key_address: Address, + + /// Root account address. Required when the key is not present in the local Accounts store. + #[arg(long, visible_alias = "wallet-address", value_name = "ADDRESS")] + root_account: Option
, + + /// Target contract address. + #[arg(long)] + target: Address, + + /// Function selector, full signature, or known TIP-20 shorthand. + #[arg(long, value_parser = parse_selector_bytes)] + selector: [u8; 4], + + /// Optional recipient/spender restrictions for selector calls. + #[arg(long, value_delimiter = ',')] + recipients: Vec
, + + #[command(flatten)] + tx: TransactionOpts, + + #[command(flatten)] + send_tx: SendTxOpts, + }, + + /// Update a token spending limit amount for a key. + SetLimit { + /// The key address to update. + key_address: Address, + + /// Token address, numeric TIP-20 token id, or known Tempo fee-token symbol. + #[arg(long, value_parser = parse_fee_token_address)] + token: Address, + + /// New raw token-denominated limit. + #[arg(long)] + amount: U256, + + /// Limit period such as 7d, 24h, or 3600s. + /// + /// The current AccountKeychain update entrypoint cannot change periods, so non-zero + /// values are rejected. + #[arg(long, value_parser = parse_period)] + period: Option, + + #[command(flatten)] + tx: TransactionOpts, + + #[command(flatten)] + send_tx: SendTxOpts, + }, + + /// Remove all allowed-call rules for a target contract. + RemoveTarget { + /// The key address to update. + key_address: Address, + + /// Target contract address to remove. + #[arg(long)] + target: Address, + + #[command(flatten)] + tx: TransactionOpts, + + #[command(flatten)] + send_tx: SendTxOpts, + }, +} + +fn parse_auth_signature_type(s: &str) -> Result { + match s.to_lowercase().as_str() { + "secp256k1" => Ok(AuthSignatureType::Secp256k1), + "p256" => Ok(AuthSignatureType::P256), + "webauthn" => Ok(AuthSignatureType::WebAuthn), + _ => Err(format!("unknown signature type: {s} (expected secp256k1, p256, or webauthn)")), + } +} + +fn parse_signature_type(s: &str) -> Result { + parse_auth_signature_type(s).map(Into::into) +} + +/// The key type of an ABI signature type; `None` for values outside the known variants. +fn abi_key_type(t: SignatureType) -> Option { + AuthSignatureType::try_from(t).ok().map(KeyType::from) +} + +const fn key_type_name(t: KeyType) -> &'static str { + match t { + KeyType::Secp256k1 => "secp256k1", + KeyType::P256 => "p256", + KeyType::WebAuthn => "webauthn", + } +} + +const fn key_type_label(t: KeyType) -> &'static str { + match t { + KeyType::Secp256k1 => "Secp256k1", + KeyType::P256 => "P256", + KeyType::WebAuthn => "WebAuthn", + } +} + +/// Parse a `--limit TOKEN:AMOUNT[:PERIOD]` flag value. +fn parse_auth_limit(s: &str) -> Result { + let (token, amount, period) = match s.split(':').collect::>()[..] { + [token, amount] => (token, amount, None), + [token, amount, period] => (token, amount, Some(period)), + _ => return Err(format!("invalid limit format: {s} (expected TOKEN:AMOUNT[:PERIOD])")), + }; + Ok(AuthTokenLimit { + token: token.parse().map_err(|e| format!("invalid token address '{token}': {e}"))?, + limit: amount.parse().map_err(|e| format!("invalid amount '{amount}': {e}"))?, + period: period.map_or(Ok(0), parse_period)?, + }) +} + +fn parse_limit(s: &str) -> Result { + parse_auth_limit(s).map(|limit| TokenLimit { + token: limit.token, + amount: limit.limit, + period: limit.period, + }) +} + +fn parse_auth_scope(s: &str) -> Result { + parse_scope(s).map(Into::into) +} + +/// Represents a single scope entry in JSON format for `--scopes`. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct JsonCallScope { + target: Address, + #[serde(default)] + selectors: Option>, +} + +/// A selector entry can be either a plain string or an object with recipients. +#[derive(Deserialize)] +#[serde(untagged)] +enum JsonSelectorEntry { + Name(String), + WithRecipients(JsonSelectorWithRecipients), +} + +// `deny_unknown_fields` is not honoured on untagged enum variants, so this needs its own struct. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct JsonSelectorWithRecipients { + selector: String, + #[serde(default)] + recipients: Vec
, +} + +/// Parse `--scopes` JSON flag value. +fn parse_scopes_json(s: &str) -> Result, String> { + let entries: Vec = + parse_json(s).map_err(|e| format!("invalid --scopes JSON: {e}"))?; + entries + .into_iter() + .map(|entry| { + let selector_rules = entry + .selectors + .unwrap_or_default() + .into_iter() + .map(|sel| { + let (selector, recipients) = match sel { + JsonSelectorEntry::Name(name) => (name, vec![]), + JsonSelectorEntry::WithRecipients(JsonSelectorWithRecipients { + selector, + recipients, + }) => (selector, recipients), + }; + let selector = parse_selector_bytes(&selector) + .map_err(|e| format!("in --scopes JSON: {e}"))?; + Ok(SelectorRule { selector: selector.into(), recipients }) + }) + .collect::>()?; + Ok(CallScope { target: entry.target, selectorRules: selector_rules }) + }) + .collect() +} + +/// Newtype wrapper for parsed `--scopes` JSON so clap can treat it as a single value. +#[derive(Debug, Clone)] +pub struct ScopesJson(Vec); + +fn parse_scopes_json_wrapped(s: &str) -> Result { + parse_scopes_json(s).map(ScopesJson) +} + +/// Newtype wrapper for parsed key-authorization `--scopes` JSON. +#[derive(Debug, Clone)] +pub struct AuthScopesJson(Vec); + +fn parse_auth_scopes_json_wrapped(s: &str) -> Result { + parse_scopes_json(s).map(|scopes| AuthScopesJson(scopes.into_iter().map(Into::into).collect())) +} + +impl KeychainSubcommand { + #[allow(clippy::large_stack_frames)] + pub async fn run(self) -> Result<()> { + match self { + Self::List => list_keys(None), + Self::Show { wallet_address } => list_keys(Some(wallet_address)), + Self::Check { wallet_address, key_address, rpc } => { + run_check(wallet_address, key_address, rpc).await + } + Self::Inspect { key_address, root_account, rpc } => { + run_inspect(key_address, root_account, rpc).await + } + Self::Doctor { + key_address, + root_account, + to, + selector, + recipient, + fee_token, + mut tempo, + rpc, + } => { + let fee_token = fee_token.or(tempo.fee_token).unwrap_or(DEFAULT_FEE_TOKEN); + let mut doctor = Doctor::new(root_account, key_address, fee_token); + doctor + .run(key_address, root_account, to, selector, recipient, &mut tempo, rpc) + .await; + doctor.finish() + } + Self::Authorize { + key_address, + key_type, + expiry, + enforce_limits, + limits, + scope, + scopes_json, + witness, + admin, + force, + tx, + send_tx, + } => { + let scopes_present = scopes_json.is_some() || !scope.is_empty(); + let scopes = scopes_json.map_or(scope, |ScopesJson(scopes)| scopes); + run_authorize( + key_address, + key_type, + expiry, + enforce_limits, + limits, + scopes, + scopes_present, + witness, + admin, + tx, + send_tx, + force, + ) + .await + } + Self::Revoke { key_address, force, tx, send_tx } => { + send_keychain_call( + &IAccountKeychain::revokeKeyCall { keyId: key_address }, + tx, + &send_tx, + force, + ) + .await + } + Self::BurnWitness { witness, force, tx, send_tx } => { + let (_, provider) = tempo_provider(&send_tx.eth.rpc)?; + require_hardfork( + &provider, + TempoHardfork::T5, + "burn-witness requires a Tempo T5-capable AccountKeychain RPC", + ) + .await?; + send_keychain_call( + &IAccountKeychain::burnKeyAuthorizationWitnessCall { witness }, + tx, + &send_tx, + force, + ) + .await + } + Self::IsWitnessBurned { account, witness, rpc } => { + let (_, provider) = tempo_provider(&rpc)?; + require_hardfork( + &provider, + TempoHardfork::T5, + "is-witness-burned requires a Tempo T5-capable AccountKeychain RPC", + ) + .await?; + let burned = provider + .account_keychain() + .isKeyAuthorizationWitnessBurned(account, witness) + .call() + .await?; + print_json_or( + json!({ "account": account, "witness": witness, "burned": burned }), + burned, + ) + } + Self::IsAdmin { account, key_address, rpc } => { + let (_, provider) = tempo_provider(&rpc)?; + require_hardfork( + &provider, + TempoHardfork::T6, + "is-admin requires a Tempo T6-capable AccountKeychain RPC", + ) + .await?; + let is_admin = + provider.account_keychain().isAdminKey(account, key_address).call().await?; + print_json_or( + json!({ "account": account, "key_address": key_address, "is_admin": is_admin }), + is_admin, + ) + } + Self::Verify { account, hash, signature, rpc } => { + run_verify_keychain(account, hash, signature, rpc, false).await + } + Self::VerifyAdmin { account, hash, signature, rpc } => { + run_verify_keychain(account, hash, signature, rpc, true).await + } + Self::RemainingLimit { wallet_address, key_address, token, rpc } => { + let (_, provider) = tempo_provider(&rpc)?; + let is_t3 = is_tempo_hardfork_active(&provider, TempoHardfork::T3).await?; + let (remaining, _) = + remaining_limit(&provider, wallet_address, key_address, token, is_t3).await?; + if shell::is_json() { + sh_println!("{}", json!({ "remaining": remaining.to_string() }))?; + } else { + sh_println!("{remaining}")?; + } + Ok(()) + } + Self::UpdateLimit { key_address, token, new_limit, force, tx, send_tx } => { + send_keychain_call( + &IAccountKeychain::updateSpendingLimitCall { + keyId: key_address, + token, + newLimit: new_limit, + }, + tx, + &send_tx, + force, + ) + .await + } + Self::SetScope { key_address, scope, force, tx, send_tx } => { + send_keychain_call( + &IAccountKeychain::setAllowedCallsCall { keyId: key_address, scopes: scope }, + tx, + &send_tx, + force, + ) + .await + } + Self::RemoveScope { key_address, target, force, tx, send_tx } => { + send_keychain_call( + &IAccountKeychain::removeAllowedCallsCall { keyId: key_address, target }, + tx, + &send_tx, + force, + ) + .await + } + Self::Policy { force, command } => command.run(force).await, + } + } +} + +impl KeyAuthorizationSubcommand { + pub async fn run(self) -> Result<()> { + match self { + Self::Encode { authorization, account } => { + let authorization = authorization.into_authorization(account)?; + let encoded = alloy_rlp::encode(&authorization); + print_json_or( + json!({ + "key_authorization": hex::encode_prefixed(&encoded), + "signature_hash": authorization.signature_hash(), + "rlp_length": encoded.len(), + "is_admin": authorization.is_admin(), + "account": authorization.account, + "witness": authorization.witness(), + }), + hex::encode_prefixed(&encoded), + ) + } + Self::Sign { authorization, account, wallet, browser } => { + run_key_auth_sign(authorization, account, *wallet, browser).await + } + Self::Inspect { authorization, account } => { + run_key_auth_inspect(&authorization, account) + } + } + } +} + +impl KeychainPolicySubcommand { + pub async fn run(self, force: bool) -> Result<()> { + match self { + Self::AddCall { + key_address, + root_account, + target, + selector, + recipients, + tx, + send_tx, + } => { + run_policy_add_call( + key_address, + root_account, + target, + selector, + recipients, + tx, + send_tx, + force, + ) + .await + } + Self::SetLimit { key_address, token, amount, period, tx, send_tx } => { + if period.is_some_and(|period| period != 0) { + eyre::bail!( + "--period is not supported by the current AccountKeychain updateSpendingLimit \ + precompile; periods can only be set when authorizing a key" + ); + } + // updateSpendingLimit authorizes against msg.sender; the root account is not part + // of calldata. + send_keychain_call( + &IAccountKeychain::updateSpendingLimitCall { + keyId: key_address, + token, + newLimit: amount, + }, + tx, + &send_tx, + force, + ) + .await + } + Self::RemoveTarget { key_address, target, tx, send_tx } => { + send_keychain_call( + &IAccountKeychain::removeAllowedCallsCall { keyId: key_address, target }, + tx, + &send_tx, + force, + ) + .await + } + } + } +} + +/// `cast keychain list` / `cast keychain show ` — display Tempo Accounts store +/// entries, optionally filtered to one wallet. +fn list_keys(wallet_address: Option
) -> Result<()> { + let store = load_accounts_store()?; + let entries: Vec<_> = store + .keys + .iter() + .filter(|e| wallet_address.is_none_or(|wallet| e.wallet_address == wallet)) + .collect(); + + if shell::is_json() { + return print_json_object(entries.iter().map(|e| key_entry_to_json(e)).collect::>()); + } + if entries.is_empty() { + return match wallet_address { + Some(wallet) => sh_println!("No keys found for wallet {wallet}."), + None => sh_println!("No keys found in store.json."), + }; + } + for (i, entry) in entries.iter().enumerate() { + if i > 0 { + sh_println!()?; + } + print_key_entry(entry)?; + } + Ok(()) +} + +struct InspectedLimit { + token: Address, + configured_amount: String, + remaining: U256, + period_end: Option, +} + +enum AllowedCallsView { + Unsupported, + Unrestricted, + Scoped(Vec), +} + +/// `cast keychain inspect ` — inspect on-chain key policy. +async fn run_inspect( + key_address: Address, + root_account: Option
, + rpc: RpcOpts, +) -> Result<()> { + let (root_account, entry) = resolve_key_metadata(key_address, root_account)?; + let (_, provider) = tempo_provider(&rpc)?; + + let info = provider.get_keychain_key(root_account, key_address).await?; + let provisioned = !info.keyId.is_zero(); + let is_t3 = is_tempo_hardfork_active(&provider, TempoHardfork::T3).await?; + // On T6, `isAdminKey` is authoritative for the root/admin distinction. + let is_admin = is_tempo_hardfork_active(&provider, TempoHardfork::T6).await? + && provider.account_keychain().isAdminKey(root_account, key_address).call().await?; + let role = key_role(key_address == root_account, is_admin); + + let mut limits = Vec::new(); + if info.enforceLimits { + for local in entry.iter().flat_map(|entry| &entry.limits) { + let (remaining, period_end) = + remaining_limit(&provider, root_account, key_address, local.currency, is_t3) + .await?; + limits.push(InspectedLimit { + token: local.currency, + configured_amount: local.limit.clone(), + remaining, + period_end, + }); + } + } + + let allowed_calls = if is_t3 { + let allowed = + provider.account_keychain().getAllowedCalls(root_account, key_address).call().await?; + if allowed.isScoped { + AllowedCallsView::Scoped(allowed.scopes) + } else { + AllowedCallsView::Unrestricted + } + } else { + AllowedCallsView::Unsupported + }; + + let key_type = + if provisioned { abi_key_type(info.signatureType) } else { entry.map(|e| e.key_type) }; + + if shell::is_json() { + return print_json_object(json!({ + "root_account": root_account, + "key_id": key_address, + "provisioned": provisioned, + "type": key_type.map_or("unknown", key_type_name), + "role": role, + "is_admin": is_admin, + "expiry": provisioned.then_some(info.expiry), + "expiry_human": provisioned.then(|| format_expiry_for_inspect(info.expiry)), + "enforce_limits": info.enforceLimits, + "is_revoked": info.isRevoked, + "limits": limits.iter().map(inspected_limit_to_json).collect::>(), + "allowed_calls": allowed_calls_to_json(&allowed_calls), + })); + } + + sh_println!("Root account: {root_account}")?; + sh_println!("Key id: {key_address}")?; + sh_println!("Type: {}", key_type.map_or("unknown", key_type_label))?; + sh_println!("Role: {role}")?; + if info.isRevoked { + sh_println!("Status: revoked")?; + } else if !provisioned { + sh_println!("Status: not provisioned")?; + } else { + sh_println!("Status: active")?; + sh_println!("Expiry: {}", format_expiry_for_inspect(info.expiry))?; + } + print_inspected_limits(info.enforceLimits, &limits)?; + print_allowed_calls(&allowed_calls) +} + +/// `cast keychain check` / `cast keychain info` — query on-chain key status. +async fn run_check(wallet_address: Address, key_address: Address, rpc: RpcOpts) -> Result<()> { + let (_, provider) = tempo_provider(&rpc)?; + let info = provider.get_keychain_key(wallet_address, key_address).await?; + let provisioned = !info.keyId.is_zero(); + let signature_type = abi_key_type(info.signatureType).map_or("unknown", key_type_name); + + if shell::is_json() { + return print_json_object(json!({ + "wallet_address": wallet_address, + "key_address": key_address, + "provisioned": provisioned, + "signatureType": signature_type, + "key_id": info.keyId, + "expiry": info.expiry, + "expiry_human": format_expiry(info.expiry), + "enforce_limits": info.enforceLimits, + "is_revoked": info.isRevoked, + })); + } + + sh_println!("Wallet: {wallet_address}")?; + sh_println!("Key: {key_address}")?; + if info.isRevoked { + return sh_println!("Status: {} revoked", "✗".red()); + } + if !provisioned { + return sh_println!("Status: {} not provisioned", "✗".red()); + } + sh_println!("Status: {} active", "✓".green())?; + sh_println!("Signature Type: {signature_type}")?; + sh_println!("Key ID: {}", info.keyId)?; + let expiry = format_expiry(info.expiry); + if info.expiry != u64::MAX && info.expiry <= now().as_secs() { + sh_println!("Expiry: {expiry} ({})", "expired".red())?; + } else { + sh_println!("Expiry: {expiry}")?; + } + sh_println!("Spending Limits: {}", if info.enforceLimits { "enforced" } else { "none" }) +} + +/// `cast keychain verify` / `verify-admin` — verify a Tempo keychain signature (T6). +async fn run_verify_keychain( + account: Address, + hash: B256, + signature: Bytes, + rpc: RpcOpts, + admin: bool, +) -> Result<()> { + let (_, provider) = tempo_provider(&rpc)?; + let command = if admin { "verify-admin" } else { "verify" }; + require_hardfork( + &provider, + TempoHardfork::T6, + &format!("{command} requires a Tempo T6-capable SignatureVerifier RPC"), + ) + .await?; + + let verifier = ISignatureVerifier::new(SIGNATURE_VERIFIER_ADDRESS, &provider); + let valid = if admin { + verifier.verifyKeychainAdmin(account, hash, signature.clone()).call().await? + } else { + verifier.verifyKeychain(account, hash, signature.clone()).call().await? + }; + print_json_or( + json!({ + "account": account, + "hash": hash, + "signature": signature, + "admin": admin, + "valid": valid, + }), + valid, + ) +} + +/// Remaining spending limit for `token` and, on T3+, the current period end. +async fn remaining_limit>( + provider: &P, + root_account: Address, + key_address: Address, + token: Address, + is_t3: bool, +) -> Result<(U256, Option)> { + if is_t3 { + let limit = provider + .get_keychain_remaining_limit_with_period(root_account, key_address, token) + .await?; + Ok((limit.remaining, Some(limit.periodEnd))) + } else { + let remaining = provider + .account_keychain() + .getRemainingLimit(root_account, key_address, token) + .call() + .await?; + Ok((remaining, None)) + } +} + +// --------------------------------------------------------------------------- +// `cast keychain doctor` +// --------------------------------------------------------------------------- +// +// TODO(OSS-160 follow-up): browser-wallet KeyAuthorization signing still needs a +// wallet-facing probe once the upstream browser-wallet surface lands. TIP-1009 +// and sponsorship have config-level diagnostics below, but full fee-payer digest +// validation needs a concrete transaction payload. +// +// * Browser-wallet `KeyAuthorization` signing — wallet capability is being added in +// foundry-rs/foundry#14743 + foundry-rs/foundry-core#67 + foundry-rs/foundry-browser-wallet#67. +// Once merged, doctor can probe whether the connected browser/passkey wallet can sign the +// digest. + +/// A doctor check as `(name, label)`. +type Check = (&'static str, &'static str); + +const ACCOUNTS_STORE: Check = ("accounts_store", "Accounts store"); +const RPC: Check = ("rpc_reachability", "RPC reachable"); +const CHAIN_ID: Check = ("chain_id_match", "Chain ID match"); +const LOCAL_SIGNING: Check = ("local_signing", "Local signing"); +const KEY_REGISTRATION: Check = ("key_registration", "Key registration"); +const REVOCATION: Check = ("revocation", "Revocation"); +const EXPIRY: Check = ("expiry", "Expiry"); +const HARDFORK: Check = ("hardfork", "Hardfork"); +const SPENDING_LIMITS: Check = ("spending_limits", "Spending limits"); +const ALLOWED_CALLS: Check = ("allowed_calls", "Allowed calls"); +const FEE_TOKEN_BALANCE: Check = ("fee_token_balance", "Fee-token balance"); +const EXPIRING_NONCE: Check = ("expiring_nonce", "Expiring nonce"); +const SPONSORSHIP: Check = ("sponsorship", "Sponsorship"); + +const HARDFORK_UNKNOWN_HINT: &str = "retry against an RPC that reports Tempo hardfork activation"; +const WIDEN_POLICY_HINT: &str = "widen the policy with `cast keychain policy add-call ...`"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "lowercase")] +enum DoctorStatus { + Pass, + Warn, + Fail, +} + +#[derive(Debug, Clone, serde::Serialize)] +struct DoctorStep { + name: &'static str, + label: &'static str, + status: DoctorStatus, + detail: String, + #[serde(skip_serializing_if = "Option::is_none")] + hint: Option, +} + +impl DoctorStep { + fn new( + (name, label): Check, + status: DoctorStatus, + detail: impl Into, + hint: Option, + ) -> Self { + Self { name, label, status, detail: detail.into(), hint } + } + + fn pass(check: Check, detail: impl Into) -> Self { + Self::new(check, DoctorStatus::Pass, detail, None) + } + + fn warn(check: Check, detail: impl Into, hint: impl Into) -> Self { + Self::new(check, DoctorStatus::Warn, detail, Some(hint.into())) + } + + fn fail(check: Check, detail: impl Into, hint: impl Into) -> Self { + Self::new(check, DoctorStatus::Fail, detail, Some(hint.into())) + } +} + +#[derive(Debug, Clone, Copy, Default, serde::Serialize)] +struct DoctorContext { + #[serde(skip_serializing_if = "Option::is_none")] + root_account: Option
, + #[serde(skip_serializing_if = "Option::is_none")] + key_address: Option
, + #[serde(skip_serializing_if = "Option::is_none")] + chain_id: Option, + fee_token: Address, +} + +/// Result of resolving a Tempo Accounts store entry for the doctor. +#[derive(Debug)] +struct DoctorSubject { + root_account: Address, + key_address: Address, + entry: Option, + explicit: bool, +} + +/// Candidate subject collected before the RPC chain is known. +#[derive(Debug)] +struct DoctorCandidate { + root_account: Address, + key_address: Address, + chain_id: Option, + entry: Option, + explicit: bool, +} + +impl DoctorCandidate { + const fn from_entry(entry: tempo::KeyEntry) -> Self { + Self { + root_account: entry.wallet_address, + key_address: entry.key_address, + chain_id: Some(entry.chain_id), + entry: Some(entry), + explicit: false, + } + } + + const fn explicit(root_account: Address, key_address: Address) -> Self { + Self { root_account, key_address, chain_id: None, entry: None, explicit: true } + } + + fn has_inline_key(&self) -> bool { + self.entry.as_ref().is_some_and(|entry| entry.has_inline_key()) + } +} + +enum KeyRegistration { + OnChain(KeyInfo), + Pending(Box), +} + +#[derive(Debug, Clone)] +enum ChainTimestamp { + Known(u64), + Unknown { detail: String, hint: &'static str }, +} + +impl ChainTimestamp { + const fn timestamp(&self) -> Option { + match self { + Self::Known(timestamp) => Some(*timestamp), + Self::Unknown { .. } => None, + } + } + + /// The chain timestamp, or a warning step that `detail` could not be checked without it. + fn get(&self, check: Check, detail: impl Display) -> Result { + match self { + Self::Known(timestamp) => Ok(*timestamp), + Self::Unknown { detail: reason, hint } => { + Err(DoctorStep::warn(check, format!("{detail}: {reason}"), *hint)) + } + } + } +} + +/// Outcome of TIP-1011 allowed-call matching. +#[derive(Debug, PartialEq, Eq)] +enum AllowedCallMatch { + /// The call is allowed. + Allowed(String), + /// The call is denied. + Denied(String), + /// The selector is allowed but recipients are restricted; user did not pass `--recipient`. + RecipientRestricted(Vec
), +} + +/// Accumulated `cast keychain doctor` report. +struct Doctor { + steps: Vec, + context: DoctorContext, +} + +impl Doctor { + const fn new( + root_account: Option
, + key_address: Option
, + fee_token: Address, + ) -> Self { + let context = DoctorContext { root_account, key_address, chain_id: None, fee_token }; + Self { steps: Vec::new(), context } + } + + /// Records `step`; `None` when it failed so `?` stops the diagnosis. + fn check(&mut self, step: DoctorStep) -> Option<()> { + let failed = step.status == DoctorStatus::Fail; + self.steps.push(step); + (!failed).then_some(()) + } + + /// Unwraps `result`, recording the failing step and stopping the diagnosis on `Err`. + fn attempt(&mut self, result: Result) -> Option { + match result { + Ok(value) => Some(value), + Err(step) => { + self.steps.push(step); + None + } + } + } + + /// Diagnoses access-key signing failures, stopping at the first failing step. + #[allow(clippy::too_many_arguments)] + async fn run( + &mut self, + key_address: Option
, + root_account: Option
, + to: Option
, + selector: Option<[u8; 4]>, + recipient: Option
, + tempo: &mut TempoOpts, + rpc: RpcOpts, + ) -> Option<()> { + let resolved_expires_at = tempo.resolve_expires(); + + // Step 1: Tempo Accounts store lookup. + let (step, candidates) = + self.attempt(collect_local_candidates(key_address, root_account))?; + self.steps.push(step); + + // Step 2: RPC reachability. + let config = self.attempt(rpc.load_config().map_err(|err| { + DoctorStep::fail( + RPC, + format!("could not load RPC config: {err}"), + "check --rpc-url and your foundry.toml", + ) + }))?; + let provider = self.attempt( + ProviderBuilder::::from_config(&config) + .and_then(|builder| builder.build()) + .map_err(|err| { + DoctorStep::fail( + RPC, + format!("could not build provider: {err}"), + "verify --rpc-url is set and reachable", + ) + }), + )?; + let rpc_chain_id = self.attempt(provider.get_chain_id().await.map_err(|err| { + DoctorStep::fail( + RPC, + format!("eth_chainId failed: {err}"), + "confirm the node is reachable and not rate-limited", + ) + }))?; + self.context.chain_id = Some(rpc_chain_id); + self.steps.push(DoctorStep::pass(RPC, format!("chain id {rpc_chain_id}"))); + let chain_timestamp = fetch_chain_timestamp(&provider).await; + + // Step 3: chain-id match + final entry selection. + let subject = self.attempt( + select_subject_for_chain(candidates, rpc_chain_id, root_account).map_err(|detail| { + DoctorStep::fail( + CHAIN_ID, + detail, + "use the RPC for the chain the local entry was created on, or pass --root-account", + ) + }), + )?; + let DoctorSubject { root_account, key_address, .. } = subject; + let detail = if subject.entry.is_some() { + format!( + "local entry on chain {rpc_chain_id} matches RPC (root {root_account}, key {key_address})" + ) + } else { + format!( + "using explicit root {root_account} and key {key_address} on RPC chain {rpc_chain_id}" + ) + }; + self.steps.push(DoctorStep::pass(CHAIN_ID, detail)); + self.context.root_account = Some(root_account); + self.context.key_address = Some(key_address); + + // Step 4: local signing readiness. + self.check(check_local_signing_readiness(&subject))?; + + // Step 5: on-chain key state. + let registration = match provider.get_keychain_key(root_account, key_address).await { + Ok(info) if !info.keyId.is_zero() => { + let key_type = abi_key_type(info.signatureType).map_or("unknown", key_type_label); + self.steps.push(DoctorStep::pass( + KEY_REGISTRATION, + format!("provisioned, type {key_type}"), + )); + KeyRegistration::OnChain(info) + } + Ok(_) => { + let (signed, detail) = self.attempt(validate_pending_key_authorization( + &subject, + rpc_chain_id, + &chain_timestamp, + ))?; + self.steps.push(DoctorStep::pass(KEY_REGISTRATION, detail)); + KeyRegistration::Pending(Box::new(signed)) + } + Err(err) => { + return self.check(DoctorStep::fail( + KEY_REGISTRATION, + format!("AccountKeychain.getKey failed: {err}"), + "verify the RPC supports the AccountKeychain precompile", + )); + } + }; + + // Steps 6-7: revocation and expiry. + let expiry = match ®istration { + KeyRegistration::OnChain(info) => { + if info.isRevoked { + return self.check(DoctorStep::fail( + REVOCATION, + "key is revoked on-chain", + "authorize a new key or re-authorize this one", + )); + } + self.steps.push(DoctorStep::pass(REVOCATION, "active")); + check_expiry( + (info.expiry != u64::MAX).then_some(info.expiry), + &chain_timestamp, + "", + "authorize a new key with a later expiry", + ) + } + KeyRegistration::Pending(signed) => { + self.steps.push(DoctorStep::pass( + REVOCATION, + "not on-chain yet; key_authorization will provision a fresh key", + )); + check_expiry( + signed.authorization.expiry.map(|expiry| expiry.get()), + &chain_timestamp, + "key_authorization ", + "refresh the access key to get a later key_authorization expiry", + ) + } + }; + self.check(expiry)?; + + // Steps 8-10: hardfork detection, spending limits, allowed calls (TIP-1011, T3+ only). + let (step, hardfork) = check_hardfork(&provider).await; + self.steps.push(step); + let is_t3 = hardfork.map(|hardfork| hardfork.is_t3()); + let fee_token = self.context.fee_token; + let (limits, pending) = match ®istration { + KeyRegistration::OnChain(info) => { + (check_spending_limits(&provider, &subject, info, fee_token, is_t3).await, None) + } + KeyRegistration::Pending(signed) => ( + check_authorization_spending_limits(signed, fee_token, is_t3), + Some(&signed.authorization), + ), + }; + self.steps.push(limits); + self.steps.push( + check_allowed_calls(&provider, &subject, pending, is_t3, to, selector, recipient).await, + ); + + // Transaction-option diagnostics that affect access-key sends. + self.steps.push(check_expiring_nonce( + tempo, + resolved_expires_at, + &chain_timestamp, + hardfork, + )); + + let (sponsorship, fee_payer) = check_sponsorship(tempo, root_account).await; + let sponsor_failed = sponsorship.status == DoctorStatus::Fail; + self.steps.push(sponsorship); + let balance = if sponsor_failed && tempo.has_sponsor_submission() { + DoctorStep::warn( + FEE_TOKEN_BALANCE, + "skipped; sponsorship config is invalid", + "fix the sponsorship configuration before checking the fee payer balance", + ) + } else { + let (account, owner) = match fee_payer { + Some(sponsor) => (sponsor, "sponsor"), + None => (root_account, "root account"), + }; + check_fee_token_balance(&provider, account, fee_token, owner).await + }; + self.steps.push(balance); + Some(()) + } + + /// Renders the doctor report. + fn finish(self) -> Result<()> { + let Self { steps, context } = self; + let count = |status| steps.iter().filter(|s| s.status == status).count(); + let failure_count = count(DoctorStatus::Fail); + let warning_count = count(DoctorStatus::Warn); + let no_failures = failure_count == 0; + let healthy = no_failures && warning_count == 0; + + if shell::is_json() { + let status = if !no_failures { + "fail" + } else if !healthy { + "warn" + } else { + "pass" + }; + return print_json_success(json!({ + "context": context, + "steps": steps, + "status": status, + "no_failures": no_failures, + "healthy": healthy, + "warning_count": warning_count, + "failure_count": failure_count, + })); + } + + for step in &steps { + let marker = match step.status { + DoctorStatus::Pass => "✓".green().to_string(), + DoctorStatus::Warn => "!".yellow().to_string(), + DoctorStatus::Fail => "✗".red().to_string(), + }; + sh_println!("{marker} {:<22} {}", step.label, step.detail)?; + if let Some(hint) = &step.hint { + sh_println!(" {} {hint}", "hint:".dim())?; + } + } + sh_println!()?; + if healthy { + sh_println!("{} access-key signing path looks healthy", "✓".green()) + } else if no_failures { + sh_println!("{} access-key signing path has warnings (see above)", "!".yellow()) + } else { + sh_println!("{} access-key signing path has issues (see above)", "✗".red()) + } + } +} + +/// Step 1 helper: collect Tempo Accounts store candidates. +fn collect_local_candidates( + key_address: Option
, + root_account: Option
, +) -> Result<(DoctorStep, Vec), DoctorStep> { + let explicit = key_address + .zip(root_account) + .map(|(key_address, root_account)| DoctorCandidate::explicit(root_account, key_address)); + let store_path = tempo_accounts_store_path_display(); + + let Some(store) = read_tempo_accounts_store() else { + return match explicit { + Some(candidate) => Ok(( + DoctorStep::pass( + ACCOUNTS_STORE, + format!("could not read {store_path}; using explicit root/key"), + ), + vec![candidate], + )), + None => Err(DoctorStep::fail( + ACCOUNTS_STORE, + format!("could not read Tempo Accounts store at {store_path}"), + "run `cast tempo login` or pass both KEY_ADDRESS and --root-account", + )), + }; + }; + + let matches: Vec<_> = store + .keys + .into_iter() + .filter(|entry| { + (key_address.is_some() || root_account.is_some()) + && key_address.is_none_or(|k| entry.key_address == k) + && root_account.is_none_or(|r| entry.wallet_address == r) + }) + .collect(); + + if matches.is_empty() { + if let Some(candidate) = explicit { + let detail = format!( + "no local entry for key {} and root {}; using explicit root/key", + candidate.key_address, candidate.root_account + ); + return Ok((DoctorStep::pass(ACCOUNTS_STORE, detail), vec![candidate])); + } + let (descriptor, hint) = match (key_address, root_account) { + (Some(k), None) => { + (format!("key {k}"), "pass --root-account to diagnose an explicit key/root pair") + } + (None, Some(r)) => ( + format!("root account {r}"), + "pass KEY_ADDRESS to diagnose a key absent from the Accounts store", + ), + _ => ( + "the requested key".to_string(), + "run `cast tempo login` to add a key to ~/.tempo/wallet/store.json", + ), + }; + return Err(DoctorStep::fail( + ACCOUNTS_STORE, + format!("no entry for {descriptor} in {store_path}"), + hint, + )); + } + + let count = matches.len(); + let candidates = matches.into_iter().map(DoctorCandidate::from_entry).chain(explicit).collect(); + Ok(( + DoctorStep::pass(ACCOUNTS_STORE, format!("{count} candidate(s) in {store_path}")), + candidates, + )) +} + +/// Step 3 helper: filter candidates to the RPC chain id and pick a single entry. +fn select_subject_for_chain( + candidates: Vec, + rpc_chain_id: u64, + explicit_root: Option
, +) -> Result { + let local_chain_ids: Vec = candidates.iter().filter_map(|e| e.chain_id).collect(); + let chain_matched: Vec<_> = candidates + .into_iter() + .filter(|entry| entry.chain_id.is_none_or(|chain_id| chain_id == rpc_chain_id)) + .collect(); + + let Some(first) = chain_matched.first() else { + return Err(format!( + "no local entry matches RPC chain id {rpc_chain_id} (local entries on {local_chain_ids:?})" + )); + }; + + // If multiple entries belong to different roots and the user did not pin one, refuse to guess. + if explicit_root.is_none() + && chain_matched.iter().any(|entry| entry.root_account != first.root_account) + { + return Err( + "multiple local entries match this chain across different root accounts; pass --root-account" + .to_string(), + ); + } + + let explicit = chain_matched.iter().any(|entry| entry.explicit); + // Prefer a locally signable store entry over metadata-only records. + let preferred = chain_matched.iter().position(DoctorCandidate::has_inline_key).unwrap_or(0); + let entry = chain_matched.into_iter().nth(preferred).expect("non-empty"); + Ok(DoctorSubject { + root_account: entry.root_account, + key_address: entry.key_address, + entry: entry.entry, + explicit, + }) +} + +/// Step 4 helper: verify whether the local side can actually sign as the key. +fn check_local_signing_readiness(subject: &DoctorSubject) -> DoctorStep { + let Some(entry) = &subject.entry else { + return DoctorStep::warn( + LOCAL_SIGNING, + "not verified; using explicit root/key absent from the Accounts store", + "pass --tempo.access-key in the send command or run `cast tempo login`", + ); + }; + if entry.has_inline_key() { + DoctorStep::pass( + LOCAL_SIGNING, + format!("inline {} key available", key_type_name(entry.key_type)), + ) + } else if subject.explicit { + DoctorStep::warn( + LOCAL_SIGNING, + "local entry has no inline access-key private key; explicit root/key can still use --tempo.access-key", + "pass --tempo.access-key in the send command or refresh the local key material", + ) + } else { + DoctorStep::fail( + LOCAL_SIGNING, + "local entry has no inline access-key private key", + "run `cast tempo login` again, restore the key material, or pass --tempo.access-key when sending", + ) + } +} + +/// Validates the local pending `key_authorization`, returning it with its pass detail. +fn validate_pending_key_authorization( + subject: &DoctorSubject, + rpc_chain_id: u64, + chain_timestamp: &ChainTimestamp, +) -> Result<(SignedKeyAuthorization, String), DoctorStep> { + let fail = |detail: String, hint: &str| DoctorStep::fail(KEY_REGISTRATION, detail, hint); + let not_registered = || { + format!( + "key {} is not registered for root account {}", + subject.key_address, subject.root_account + ) + }; + + let Some(entry) = &subject.entry else { + return Err(fail( + not_registered(), + "authorize the key with `cast keychain authorize ` or add a local key_authorization", + )); + }; + let Some(signed) = entry.key_authorization.clone() else { + return Err(fail( + not_registered(), + "authorize the key with `cast keychain authorize ` or refresh the local key_authorization", + )); + }; + let auth = &signed.authorization; + + if auth.key_id != subject.key_address { + return Err(fail( + format!( + "local key_authorization is for key {}, expected {}", + auth.key_id, subject.key_address + ), + "refresh the access key for this root/key pair", + )); + } + if auth.chain_id != rpc_chain_id { + return Err(fail( + format!( + "local key_authorization is for chain {}, RPC is chain {rpc_chain_id}", + auth.chain_id + ), + "use the RPC for the chain the authorization was created on", + )); + } + if entry.key_type != KeyType::from(auth.key_type) { + return Err(fail( + format!( + "local key type {} does not match key_authorization type {}", + key_type_label(entry.key_type), + key_type_label(auth.key_type.into()) + ), + "refresh the local key entry so its key material and authorization agree", + )); + } + if let Some(expiry) = auth.expiry + && let Some(now) = chain_timestamp.timestamp() + && expiry.get() <= now + { + return Err(fail( + format!( + "local key_authorization expired {}", + format_relative_timestamp_from(expiry.get(), now) + ), + "refresh the access key to get a later key_authorization expiry", + )); + } + match signed.recover_signer() { + Ok(recovered) if recovered == subject.root_account => {} + Ok(recovered) => { + return Err(fail( + format!( + "local key_authorization recovers signer {recovered}, expected root {}", + subject.root_account + ), + "refresh the authorization with the correct root account", + )); + } + Err(err) => { + return Err(fail( + format!("local key_authorization signature could not be verified: {err}"), + "refresh the access key with `cast tempo login`", + )); + } + } + + let expiry = auth.expiry.map_or_else( + || "never expires".to_string(), + |expiry| { + let expiry = expiry.get(); + let relative = match chain_timestamp.timestamp() { + Some(now) => format_relative_timestamp_from(expiry, now), + None => format_relative_timestamp(expiry), + }; + format!("{relative} ({})", format_timestamp_iso(expiry)) + }, + ); + let witness = auth.witness().map(|witness| format!(", witness {witness}")).unwrap_or_default(); + let detail = format!( + "not on-chain; local key_authorization can provision atomically, type {}, expiry {expiry}{witness}", + key_type_label(auth.key_type.into()), + ); + Ok((signed, detail)) +} + +async fn fetch_chain_timestamp>(provider: &P) -> ChainTimestamp { + match provider.get_block(BlockId::latest()).await { + Ok(Some(block)) => ChainTimestamp::Known(block.header.timestamp()), + Ok(None) => ChainTimestamp::Unknown { + detail: "latest block not found; chain timestamp unavailable".to_string(), + hint: "verify the RPC can serve latest block data", + }, + Err(err) => ChainTimestamp::Unknown { + detail: format!("latest block query failed: {err}"), + hint: "validity windows and expiries could not be checked against chain time", + }, + } +} + +/// Expiry check shared by on-chain keys (`prefix` empty) and pending authorizations +/// (`prefix` = `"key_authorization "`). `None` means the key never expires. +fn check_expiry( + expiry: Option, + chain_timestamp: &ChainTimestamp, + prefix: &str, + hint: &str, +) -> DoctorStep { + let Some(expiry) = expiry else { + return DoctorStep::pass(EXPIRY, format!("{prefix}never expires")); + }; + let subject = if prefix.is_empty() { "key " } else { prefix }; + let now = match chain_timestamp.get(EXPIRY, format!("{subject}expiry not checked")) { + Ok(now) => now, + Err(step) => return step, + }; + let relative = format_relative_timestamp_from(expiry, now); + if expiry <= now { + DoctorStep::fail(EXPIRY, format!("{prefix}expired {relative}"), hint) + } else { + DoctorStep::pass(EXPIRY, format!("{prefix}{relative} ({})", format_timestamp_iso(expiry))) + } +} + +async fn check_hardfork>( + provider: &P, +) -> (DoctorStep, Option) { + match active_tempo_hardfork(provider).await { + Ok(hardfork) if hardfork.is_t3() => { + (DoctorStep::pass(HARDFORK, "Tempo T3 active"), Some(hardfork)) + } + Ok(hardfork) => { + (DoctorStep::pass(HARDFORK, "pre-T3; TIP-1011 scopes not enforced"), Some(hardfork)) + } + Err(err) => ( + DoctorStep::warn( + HARDFORK, + format!("could not determine Tempo T3 activation: {err}"), + "TIP-1011 allowed-call and T3 spending-period checks will be skipped", + ), + None, + ), + } +} + +/// Step 9 helper: spending limits of an on-chain key. +async fn check_spending_limits>( + provider: &P, + subject: &DoctorSubject, + info: &KeyInfo, + fee_token: Address, + is_t3: Option, +) -> DoctorStep { + let Some(is_t3) = is_t3 else { + return DoctorStep::warn( + SPENDING_LIMITS, + "skipped; hardfork unknown", + HARDFORK_UNKNOWN_HINT, + ); + }; + if !info.enforceLimits { + return DoctorStep::pass(SPENDING_LIMITS, "limits not enforced for this key"); + } + + let local_limits = subject.entry.as_ref().map_or(&[][..], |entry| entry.limits.as_slice()); + // Token universe: local-entry limits ∪ {fee_token}. + let mut tokens: Vec
= local_limits.iter().map(|l| l.currency).collect(); + if !tokens.contains(&fee_token) { + tokens.push(fee_token); + } + + let mut lines = Vec::new(); + let mut any_zero = false; + for token in tokens { + let (remaining, period_end) = match remaining_limit( + provider, + subject.root_account, + subject.key_address, + token, + is_t3, + ) + .await + { + Ok(limit) => limit, + Err(err) => { + return DoctorStep::warn( + SPENDING_LIMITS, + format!("{} query failed: {err}", address_label(token)), + "verify the AccountKeychain precompile is reachable", + ); + } + }; + any_zero |= remaining.is_zero(); + let configured = + local_limits.iter().find(|l| l.currency == token).map_or("?", |l| l.limit.as_str()); + lines.push(format!( + "{} remaining {remaining} / {configured}{}", + address_label(token), + format_period_suffix(period_end) + )); + } + + let detail = lines.join("; "); + if any_zero { + DoctorStep::warn( + SPENDING_LIMITS, + detail, + "raise the limit (e.g. `cast keychain ul ...`) or wait for the window reset", + ) + } else { + DoctorStep::pass(SPENDING_LIMITS, detail) + } +} + +/// Step 9 helper: spending limits of a pending `key_authorization`. +fn check_authorization_spending_limits( + signed: &SignedKeyAuthorization, + fee_token: Address, + is_t3: Option, +) -> DoctorStep { + let auth = &signed.authorization; + if is_t3.is_none() && auth.has_periodic_limits() { + return DoctorStep::warn( + SPENDING_LIMITS, + "skipped; hardfork unknown and key_authorization uses periodic limits", + HARDFORK_UNKNOWN_HINT, + ); + } + if is_t3 == Some(false) && !auth.is_legacy_compatible() { + return DoctorStep::fail( + SPENDING_LIMITS, + "key_authorization uses T3-only limits or call scopes on a pre-T3 chain", + "use a T3 RPC or refresh the authorization with legacy-compatible restrictions", + ); + } + + match auth.limits.as_deref() { + None => DoctorStep::pass(SPENDING_LIMITS, "limits not enforced by key_authorization"), + Some([]) => DoctorStep::warn( + SPENDING_LIMITS, + "key_authorization allows no token spending", + "refresh the access key with spending limits if the transaction spends TIP-20 tokens", + ), + Some(limits) => { + let mut lines: Vec = limits + .iter() + .map(|limit| { + let period = if limit.period == 0 { + String::new() + } else { + format!(" per {}s", limit.period) + }; + format!("{} limit {}{period}", address_label(limit.token), limit.limit) + }) + .collect(); + let fee_limit = limits.iter().find(|limit| limit.token == fee_token); + if fee_limit.is_none() { + lines.push(format!( + "{} not listed in key_authorization limits", + address_label(fee_token) + )); + } + let detail = lines.join("; "); + match fee_limit { + None => DoctorStep::warn( + SPENDING_LIMITS, + detail, + "refresh the access key with a limit for the selected fee token", + ), + Some(limit) if limit.limit.is_zero() => DoctorStep::warn( + SPENDING_LIMITS, + detail, + "raise the fee-token limit before sending with this authorization", + ), + Some(_) => DoctorStep::pass(SPENDING_LIMITS, detail), + } + } + } +} + +/// Step 10 helper: allowed calls (TIP-1011) of the on-chain key, or of `pending` when the key is +/// not registered yet. +async fn check_allowed_calls>( + provider: &P, + subject: &DoctorSubject, + pending: Option<&KeyAuthorization>, + is_t3: Option, + to: Option
, + selector: Option<[u8; 4]>, + recipient: Option
, +) -> DoctorStep { + let Some(is_t3) = is_t3 else { + return DoctorStep::warn(ALLOWED_CALLS, "skipped; hardfork unknown", HARDFORK_UNKNOWN_HINT); + }; + if !is_t3 { + return DoctorStep::pass(ALLOWED_CALLS, "TIP-1011 not enforced before T3"); + } + + let scopes = match pending { + Some(auth) => { + let Some(scopes) = auth.allowed_calls.as_deref() else { + return DoctorStep::pass(ALLOWED_CALLS, "any call permitted by key_authorization"); + }; + scopes.iter().cloned().map(Into::into).collect() + } + None => match provider + .account_keychain() + .getAllowedCalls(subject.root_account, subject.key_address) + .call() + .await + { + Ok(allowed) if !allowed.isScoped => { + return DoctorStep::pass(ALLOWED_CALLS, "any call permitted"); + } + Ok(allowed) => allowed.scopes, + Err(err) => { + return DoctorStep::warn( + ALLOWED_CALLS, + format!("getAllowedCalls failed: {err}"), + "verify the AccountKeychain precompile is reachable", + ); + } + }, + }; + diagnose_allowed_scopes(&scopes, to, selector, recipient) +} + +fn diagnose_allowed_scopes( + scopes: &[CallScope], + to: Option
, + selector: Option<[u8; 4]>, + recipient: Option
, +) -> DoctorStep { + if scopes.is_empty() { + let detail = "scoped, but no targets permitted"; + return if to.is_some() && selector.is_some() { + DoctorStep::fail(ALLOWED_CALLS, detail, WIDEN_POLICY_HINT) + } else { + DoctorStep::warn(ALLOWED_CALLS, detail, WIDEN_POLICY_HINT) + }; + } + let Some(to) = to else { + return DoctorStep::pass( + ALLOWED_CALLS, + format!( + "scoped to {} target(s); pass --to/--selector to test a specific call", + scopes.len() + ), + ); + }; + let Some(selector) = selector else { + // --to without --selector: report whether the target is in scope at all. + return if scopes.iter().any(|s| s.target == to) { + DoctorStep::pass( + ALLOWED_CALLS, + format!("target {to} is in scope; pass --selector to test the function"), + ) + } else { + DoctorStep::warn( + ALLOWED_CALLS, + format!("target {to} not in any allowed scope"), + WIDEN_POLICY_HINT, + ) + }; + }; + + match match_allowed_call(scopes, to, selector, recipient) { + AllowedCallMatch::Allowed(detail) => DoctorStep::pass(ALLOWED_CALLS, detail), + AllowedCallMatch::Denied(reason) => { + DoctorStep::fail(ALLOWED_CALLS, reason, WIDEN_POLICY_HINT) + } + AllowedCallMatch::RecipientRestricted(recipients) => DoctorStep::pass( + ALLOWED_CALLS, + format!( + "selector {} on {} allowed only for {}; pass --recipient to verify exact match", + format_selector(&selector), + address_label_with_address(to), + format_recipients(&recipients) + ), + ), + } +} + +/// Pure TIP-1011 matching logic. +fn match_allowed_call( + scopes: &[CallScope], + to: Address, + selector: [u8; 4], + recipient: Option
, +) -> AllowedCallMatch { + let target = address_label_with_address(to); + let matching_scopes: Vec<_> = scopes.iter().filter(|scope| scope.target == to).collect(); + if matching_scopes.is_empty() { + return AllowedCallMatch::Denied(format!("target {to} not in any allowed scope")); + } + if matching_scopes.iter().any(|scope| scope.selectorRules.is_empty()) { + return AllowedCallMatch::Allowed(format!("any selector on {target} permitted")); + } + + let selector_str = format_selector(&selector); + let matching_rules: Vec<_> = matching_scopes + .iter() + .flat_map(|scope| &scope.selectorRules) + .filter(|rule| rule.selector.0 == selector) + .collect(); + if matching_rules.is_empty() { + return AllowedCallMatch::Denied(format!( + "selector {selector_str} on {target} not in allowed list" + )); + } + if matching_rules.iter().any(|rule| rule.recipients.is_empty()) { + return AllowedCallMatch::Allowed(format!( + "{selector_str} on {target} permitted (any recipient)" + )); + } + + match recipient { + Some(r) if matching_rules.iter().any(|rule| rule.recipients.contains(&r)) => { + AllowedCallMatch::Allowed(format!( + "{selector_str} on {target} to recipient {r} permitted" + )) + } + Some(r) => AllowedCallMatch::Denied(format!( + "recipient {r} not in allowed list for {selector_str} on {target}" + )), + None => { + let mut recipients = Vec::new(); + for recipient in matching_rules.iter().flat_map(|rule| &rule.recipients) { + if !recipients.contains(recipient) { + recipients.push(*recipient); + } + } + AllowedCallMatch::RecipientRestricted(recipients) + } + } +} + +/// Fee-token balance of the account paying for the transaction. +async fn check_fee_token_balance>( + provider: &P, + account: Address, + fee_token: Address, + owner_label: &str, +) -> DoctorStep { + let token = address_label(fee_token); + match ITIP20::new(fee_token, provider).balanceOf(account).call().await { + Ok(balance) if balance.is_zero() => DoctorStep::warn( + FEE_TOKEN_BALANCE, + format!("0 {token} on {owner_label} {account}"), + format!("fund {owner_label} {account} with {token}"), + ), + Ok(balance) => DoctorStep::pass( + FEE_TOKEN_BALANCE, + format!("{balance} {token} on {owner_label} {account}"), + ), + Err(err) => DoctorStep::warn( + FEE_TOKEN_BALANCE, + format!("balanceOf failed: {err}"), + "verify --fee-token points to a TIP-20 token", + ), + } +} + +/// Validate TIP-1009 expiring-nonce options, if supplied. +fn check_expiring_nonce( + tempo: &TempoOpts, + resolved_expires_at: Option, + chain_timestamp: &ChainTimestamp, + hardfork: Option, +) -> DoctorStep { + if !tempo.expiring_nonce && tempo.valid_before.is_none() && tempo.valid_after.is_none() { + return DoctorStep::pass(EXPIRING_NONCE, "not requested"); + } + match chain_timestamp.get(EXPIRING_NONCE, "validity window not checked") { + Ok(now) => check_expiring_nonce_window(tempo, resolved_expires_at, now, hardfork), + Err(step) => step, + } +} + +/// Checks the validity window against the chain timestamp. The upper bound depends on the active +/// hardfork and is skipped when it is unknown. +fn check_expiring_nonce_window( + tempo: &TempoOpts, + resolved_expires_at: Option, + chain_timestamp: u64, + hardfork: Option, +) -> DoctorStep { + let valid_before = tempo.valid_before; + let valid_after = tempo.valid_after; + + if let (Some(after), Some(before)) = (valid_after, valid_before) + && after >= before + { + return DoctorStep::fail( + EXPIRING_NONCE, + format!("valid-after {after} is not before valid-before {before}"), + "choose a valid window where valid-after < valid-before", + ); + } + + if let Some(before) = valid_before { + if before <= chain_timestamp { + return DoctorStep::fail( + EXPIRING_NONCE, + format!( + "valid-before {} is expired at chain timestamp {chain_timestamp}", + format_timestamp_iso(before) + ), + "use a later --tempo.valid-before or rerun with --tempo.expires", + ); + } + let ttl = before - chain_timestamp; + if ttl <= 3 { + return DoctorStep::fail( + EXPIRING_NONCE, + format!( + "valid-before must be more than 3s after chain timestamp {chain_timestamp}; current ttl is {ttl}s" + ), + "use a later --tempo.valid-before or rerun with --tempo.expires", + ); + } + if ttl <= 5 { + return DoctorStep::warn( + EXPIRING_NONCE, + format!("valid for only {ttl}s at chain timestamp {chain_timestamp}"), + "use a larger validity window before signing", + ); + } + if let Some(max_expiry_secs) = + hardfork.map(|hardfork| hardfork.expiring_nonce_max_expiry_secs()) + && ttl > max_expiry_secs + { + return if resolved_expires_at.is_some() { + DoctorStep::warn( + EXPIRING_NONCE, + format!( + "--tempo.expires resolved to a deadline {ttl}s ahead of chain timestamp {chain_timestamp}; the active hardfork allows at most {max_expiry_secs}s" + ), + "use a shorter --tempo.expires or check local clock/RPC timestamp skew", + ) + } else { + DoctorStep::warn( + EXPIRING_NONCE, + format!( + "valid-before is {ttl}s ahead of chain timestamp {chain_timestamp}; expiring nonce transactions must expire within {max_expiry_secs}s on the active hardfork" + ), + "prefer --tempo.expires for bounded retry-safe sends", + ) + }; + } + } + + if let Some(after) = valid_after + && after > chain_timestamp + { + return DoctorStep::warn( + EXPIRING_NONCE, + format!("transaction is not valid until {}", format_timestamp_iso(after)), + "wait until valid-after or choose an earlier lower bound", + ); + } + + if (valid_before.is_some() || valid_after.is_some()) && !tempo.expiring_nonce { + return DoctorStep::warn( + EXPIRING_NONCE, + "validity window set without --tempo.expiring-nonce", + "use --tempo.expiring-nonce or --tempo.expires so nonce_key is set to the expiring lane", + ); + } + + let mut detail = format!("enabled at chain timestamp {chain_timestamp}"); + if let Some(before) = valid_before { + detail.push_str(&format!(", valid-before {}", format_timestamp_iso(before))); + } + if let Some(after) = valid_after { + detail.push_str(&format!(", valid-after {}", format_timestamp_iso(after))); + } + if let Some(expires_at) = resolved_expires_at { + detail.push_str(&format!( + ", --tempo.expires resolved to {}", + format_timestamp_iso(expires_at) + )); + } + DoctorStep::pass(EXPIRING_NONCE, detail) +} + +/// Validate sponsorship configuration, if supplied; returns the step and the fee payer. +async fn check_sponsorship(tempo: &TempoOpts, sender: Address) -> (DoctorStep, Option
) { + if tempo.print_sponsor_hash { + return ( + DoctorStep::pass( + SPONSORSHIP, + "--tempo.print-sponsor-hash requested, but doctor has no concrete tx payload", + ), + None, + ); + } + let not_requested = || (DoctorStep::pass(SPONSORSHIP, "not requested"), None); + if !tempo.has_sponsor_submission() { + return not_requested(); + } + let sponsor = match tempo.sponsor_config().await { + Ok(Some(sponsor)) => sponsor.sponsor(), + Ok(None) => return not_requested(), + Err(err) => { + return ( + DoctorStep::fail( + SPONSORSHIP, + format!( + "invalid sponsor config: {}", + sanitize_sponsor_config_error(&err.to_string(), tempo) + ), + "pass --tempo.sponsor with either --tempo.sponsor-signer or --tempo.sponsor-sig", + ), + None, + ); + } + }; + + let step = if sponsor == sender { + DoctorStep::fail( + SPONSORSHIP, + format!("sponsor {sponsor} equals transaction sender {sender}"), + "use a different fee payer for sponsored transactions", + ) + } else if tempo.sponsor_sig.is_some() { + DoctorStep::warn( + SPONSORSHIP, + format!("signature syntax parsed for sponsor {sponsor}"), + "doctor cannot recover fee_payer_signature without the exact transaction digest", + ) + } else { + DoctorStep::pass(SPONSORSHIP, format!("sponsor signer configured for {sponsor}")) + }; + (step, Some(sponsor)) +} + +fn sanitize_sponsor_config_error(message: &str, tempo: &TempoOpts) -> String { + let mut sanitized = message.to_string(); + if let Some(spec) = tempo.sponsor_signer.as_deref() + && spec.starts_with("private-key://") + { + sanitized = sanitized.replace(spec, "private-key://"); + } + redact_private_key_uri_tokens(&sanitized) +} + +fn redact_private_key_uri_tokens(message: &str) -> String { + const PREFIX: &str = "private-key://"; + let mut redacted = String::with_capacity(message.len()); + let mut rest = message; + while let Some(idx) = rest.find(PREFIX) { + redacted.push_str(&rest[..idx + PREFIX.len()]); + redacted.push_str(""); + let after_prefix = &rest[idx + PREFIX.len()..]; + let end = after_prefix + .find(|c: char| c.is_whitespace() || matches!(c, '`' | '\'' | '"' | ',' | ';' | ')')) + .unwrap_or(after_prefix.len()); + rest = &after_prefix[end..]; + } + redacted.push_str(rest); + redacted +} + +/// `cast keychain authorize` / `cast keychain auth` — authorize a key on-chain. +#[allow(clippy::too_many_arguments)] +async fn run_authorize( + key_address: Address, + key_type: SignatureType, + expiry: u64, + enforce_limits: bool, + limits: Vec, + allowed_calls: Vec, + scopes_present: bool, + witness: Option, + admin: bool, + tx_opts: TransactionOpts, + send_tx: SendTxOpts, + force: bool, +) -> Result<()> { + let enforce = enforce_limits || !limits.is_empty(); + let (_, provider) = tempo_provider(&send_tx.eth.rpc)?; + + // T6 admin keys are key-management only and use a dedicated precompile entrypoint. + if admin { + require_hardfork( + &provider, + TempoHardfork::T6, + "--admin requires a Tempo T6-capable AccountKeychain RPC", + ) + .await?; + // u64::MAX is the no-expiry default; anything else is an explicit expiry admin keys reject. + eyre::ensure!(expiry == u64::MAX, "--admin cannot be combined with an explicit --expiry"); + eyre::ensure!( + !enforce, + "--admin cannot be combined with spending limits (--enforce-limits / --limit)" + ); + eyre::ensure!( + !scopes_present, + "--admin cannot be combined with call scopes (--scope / --scopes)" + ); + + // `authorizeAdminKey` requires a witness argument; omitting `--witness` submits bytes32(0). + let call = authorizeAdminKeyCall { + keyId: key_address, + signatureType: key_type, + witness: witness.unwrap_or(B256::ZERO), + }; + return send_keychain_call(&call, tx_opts, &send_tx, force).await; + } + + let is_t3 = is_tempo_hardfork_active(&provider, TempoHardfork::T3).await?; + if witness.is_some() { + require_hardfork( + &provider, + TempoHardfork::T5, + "--witness requires a Tempo T5-capable AccountKeychain RPC", + ) + .await?; + } + + let calldata = if is_t3 { + let config = KeyRestrictions { + expiry, + enforceLimits: enforce, + limits, + allowAnyCalls: !scopes_present, + allowedCalls: allowed_calls, + }; + match witness { + Some(witness) => authorizeKeyWithWitnessCall { + keyId: key_address, + signatureType: key_type, + config, + witness, + } + .abi_encode(), + None => authorizeKeyCall { keyId: key_address, signatureType: key_type, config } + .abi_encode(), + } + } else { + // Legacy (pre-T3) authorizeKey(address,SignatureType,uint64,bool,LegacyTokenLimit[]) + eyre::ensure!( + !scopes_present, + "call scopes (--scope / --scopes) require a Tempo T3-capable chain" + ); + if let Some(limit) = limits.iter().find(|limit| limit.period != 0) { + eyre::bail!( + "legacy AccountKeychain authorization does not support periodic limits; remove \ + the period from --limit {}:{}:{} or use a Tempo T3-capable chain", + limit.token, + limit.amount, + limit.period + ); + } + legacyAuthorizeKeyCall { + keyId: key_address, + signatureType: key_type, + expiry, + enforceLimits: enforce, + limits: limits + .into_iter() + .map(|l| LegacyTokenLimit { token: l.token, amount: l.amount }) + .collect(), + } + .abi_encode() + }; + + send_keychain_tx(calldata, tx_opts, &send_tx, None, force).await?; + Ok(()) +} + +async fn run_key_auth_sign( + args: KeyAuthorizationArgs, + account: Option
, + wallet: WalletOpts, + browser: BrowserWalletOpts, +) -> Result<()> { + let is_admin = args.admin; + let chain_id = args.chain_id; + + // TODO: remove this check once browser supports T5/T6 KeyAuthorization fields. Guard before + // `browser.run()` so the browser flow never starts for unsupported authorizations. + if browser.browser && (args.witness.is_some() || is_admin || account.is_some()) { + eyre::bail!( + "browser key authorization signing does not support T5/T6 fields yet: witness, admin, account" + ); + } + + if let Some(browser) = browser.run::().await? { + let signer_address = browser.address(); + ensure_root_sender(signer_address, wallet.from, "key authorization")?; + // The browser path rejects admin/witness/account above, so there is nothing to bind. + let authorization = args.into_authorization(None)?; + let key_type = authorization.key_type; + let signature_hash = authorization.signature_hash(); + let signed = browser.sign_key_authorization(authorization).await?; + return print_signed_key_authorization(&signed, signature_hash, signer_address, key_type); + } + + let (signer, tempo_access_key) = wallet.maybe_signer_for_chain(chain_id).await?; + let signer_address = match (&signer, &tempo_access_key) { + (Some(signer), None) => signer.address(), + (None, Some(wallet)) => wallet.key_id()?, + _ => eyre::bail!( + "a signer is required to sign key authorizations; pass a signer with \ + --browser, --private-key, --keystore, Ledger, Trezor, AWS, GCP, or Turnkey" + ), + }; + + // Resolve the account this authorization is bound to (T6 replay protection). + let bound_account = if let Some(access_key) = &tempo_access_key { + // The access key (an admin key) signs for its root, so bind to the root, not the signer. + if let Some(explicit) = account { + eyre::ensure!( + explicit == access_key.account(), + "--bind-account {explicit} does not match the selected Tempo access key's root account {}", + access_key.account(), + ); + } + Some(access_key.account()) + } else { + ensure_root_sender(signer_address, wallet.from, "key authorization")?; + account.or(is_admin.then_some(signer_address)) + }; + + let authorization = args.into_authorization(bound_account)?; + let key_type = authorization.key_type; + let signature_hash = authorization.signature_hash(); + let signature = match (&signer, &tempo_access_key) { + (Some(signer), None) => { + PrimitiveSignature::Secp256k1(signer.sign_hash(&signature_hash).await?) + } + (None, Some(wallet)) => wallet.sign_hash(&signature_hash).await?, + _ => eyre::bail!("exactly one signer is required to sign a key authorization"), + }; + let signed = authorization.into_signed(signature); + print_signed_key_authorization(&signed, signature_hash, signer_address, key_type) +} + +fn print_signed_key_authorization( + signed: &SignedKeyAuthorization, + signature_hash: B256, + signer_address: Address, + authorized_key_type: AuthSignatureType, +) -> Result<()> { + let encoded = alloy_rlp::encode(signed); + print_json_or( + json!({ + "signed_key_authorization": hex::encode_prefixed(&encoded), + "signature_hash": signature_hash, + "rlp_length": encoded.len(), + "signer": signer_address, + "authorized_key_type": key_type_name(authorized_key_type.into()), + "signature_type": key_type_name(signed.signature.signature_type().into()), + "witness": signed.authorization.witness(), + "is_admin": signed.authorization.is_admin(), + "account": signed.authorization.account, + }), + hex::encode_prefixed(&encoded), + ) +} + +/// Decode a hex RLP key authorization (signed or unsigned) and validate its account binding. +/// +/// Tries the signed shape first, then the unsigned one. Returns the authorization, whether the +/// input was signed, and the best-effort recovered signer. When `expected_account` is set the +/// decoded authorization must be bound to exactly that account. +fn decode_and_validate_key_authorization( + authorization: &str, + expected_account: Option
, +) -> Result<(KeyAuthorization, bool, Option
)> { + let raw = authorization.trim(); + let (auth, signed, signer) = + match tempo::decode_key_authorization::(raw) { + // Signer recovery is best-effort so `inspect` still surfaces fields for a corrupt sig. + Ok(signed) => { + let signer = signed.recover_signer().ok(); + (signed.authorization, true, signer) + } + Err(signed_err) => match tempo::decode_key_authorization::(raw) { + Ok(unsigned) => (unsigned, false, None), + Err(unsigned_err) => eyre::bail!( + "could not decode key authorization as signed ({signed_err}) or unsigned \ + ({unsigned_err})" + ), + }, + }; + + // Mirror the chain's T6 admin invariants so `inspect` rejects a malformed admin authorization. + eyre::ensure!( + auth.account != Some(Address::ZERO), + "key authorization account cannot be the zero address" + ); + if auth.is_admin() { + // A root-signed admin authorization may omit `account` (it is only required when the signer + // is not the target root), so `inspect` does not require it here. Binding is still enforced + // below when `--account` is supplied. + eyre::ensure!(auth.expiry.is_none(), "admin key authorization cannot carry an expiry"); + eyre::ensure!( + auth.limits.is_none(), + "admin key authorization cannot carry spending limits" + ); + eyre::ensure!( + auth.allowed_calls.is_none(), + "admin key authorization cannot carry call scopes" + ); + } + + // `--account` rejects a replayed or mismatched account-bound authorization. + if let Some(expected) = expected_account { + match auth.account { + Some(account) if account == expected => {} + Some(account) => eyre::bail!( + "key authorization is bound to account {account} but {expected} was expected" + ), + None => eyre::bail!( + "expected key authorization bound to account {expected} but it has no account field" + ), + } + } + + Ok((auth, signed, signer)) +} + +/// `cast key-authorization inspect` — decode a signed or unsigned key authorization and print its +/// fields, including the T6 `is_admin` / `account` fields. +fn run_key_auth_inspect(authorization: &str, expected_account: Option
) -> Result<()> { + let (auth, signed, signer) = + decode_and_validate_key_authorization(authorization, expected_account)?; + let key_type = key_type_name(auth.key_type.into()); + + if shell::is_json() { + let json = json!({ + "signed": signed, + "signer": signer, + "chain_id": auth.chain_id, + "key_address": auth.key_id, + "key_type": key_type, + "is_admin": auth.is_admin(), + "account": auth.account, + "expiry": auth.expiry, + "witness": auth.witness(), + "enforce_limits": auth.limits.is_some(), + "scoped_calls": auth.allowed_calls.is_some(), + }); + return sh_println!("{}", serde_json::to_string_pretty(&json)?); + } + + sh_println!("Signed: {signed}")?; + if let Some(signer) = signer { + sh_println!("Signer: {signer}")?; + } + sh_println!("Chain ID: {}", auth.chain_id)?; + sh_println!("Key Address: {}", auth.key_id)?; + sh_println!("Key Type: {key_type}")?; + sh_println!("Admin: {}", auth.is_admin())?; + if let Some(account) = auth.account { + sh_println!("Account: {account}")?; + } + match auth.expiry { + Some(expiry) => sh_println!("Expiry: {expiry}")?, + None => sh_println!("Expiry: none")?, + } + match auth.witness() { + Some(witness) => sh_println!("Witness: {witness}")?, + None => sh_println!("Witness: none")?, + } + sh_println!("Enforce Lim: {}", auth.limits.is_some())?; + sh_println!("Scoped Calls: {}", auth.allowed_calls.is_some()) +} + +impl KeyAuthorizationArgs { + /// Build a [`KeyAuthorization`] from these args, binding it to `account` when present. + /// + /// Admin keys are key-management only: no expiry, spending limits, or call scopes, and they + /// must be bound to a target account (`--account` for `encode`, signer-derived for `sign`) to + /// prevent cross-account replay. A TIP-1053 witness is still allowed. + fn into_authorization(self, account: Option
) -> Result { + let (scopes, scopes_present) = match self.scopes_json { + Some(AuthScopesJson(scopes)) => (scopes, true), + None => { + let present = !self.scope.is_empty(); + (self.scope, present) + } + }; + let has_limits = self.enforce_limits || !self.limits.is_empty(); + + eyre::ensure!(account != Some(Address::ZERO), "--account cannot be the zero address"); + if self.admin { + eyre::ensure!(account.is_some(), "--admin requires --account"); + eyre::ensure!(self.expiry.is_none(), "--admin cannot be combined with --expiry"); + eyre::ensure!( + !has_limits, + "--admin cannot be combined with spending limits (--enforce-limits / --limit)" + ); + eyre::ensure!( + !scopes_present, + "--admin cannot be combined with call scopes (--scope / --scopes)" + ); + } + + let mut authorization = + KeyAuthorization::unrestricted(self.chain_id, self.key_type, self.key_address); + if let Some(expiry) = self.expiry { + eyre::ensure!(expiry != 0, "--expiry must be greater than zero"); + authorization = authorization.with_expiry(expiry); + } + if has_limits { + authorization = authorization.with_limits(self.limits); + } + if scopes_present { + authorization = authorization.with_allowed_calls(scopes); + } + if let Some(witness) = self.witness { + authorization = authorization.with_witness(witness); + } + // Apply T6 admin / account binding last, after the restriction fields are validated above. + Ok(match account { + Some(account) if self.admin => authorization.into_admin(account), + Some(account) => authorization.with_account(account), + None => authorization, + }) + } +} + +/// `cast keychain policy add-call` — merge a selector rule into a target scope. +#[allow(clippy::too_many_arguments)] +async fn run_policy_add_call( + key_address: Address, + root_account: Option
, + target: Address, + selector: [u8; 4], + recipients: Vec
, + tx_opts: TransactionOpts, + send_tx: SendTxOpts, + force: bool, +) -> Result<()> { + let (root_account, _) = resolve_key_metadata(key_address, root_account)?; + let (_, provider) = tempo_provider(&send_tx.eth.rpc)?; + require_hardfork( + &provider, + TempoHardfork::T3, + "allowed-call policy editing requires the Tempo T3 hardfork", + ) + .await?; + + let allowed = + provider.account_keychain().getAllowedCalls(root_account, key_address).call().await?; + let new_rule = SelectorRule { selector: selector.into(), recipients }; + let existing = allowed + .isScoped + .then(|| allowed.scopes.into_iter().find(|scope| scope.target == target)) + .flatten(); + let (scope, changed) = match existing { + Some(mut scope) => { + if scope.selectorRules.is_empty() { + sh_warn!( + "Allowed calls for {} already allow any selector; leaving wildcard scope unchanged", + address_label_with_address(target) + )?; + } + let changed = add_selector_rule_to_scope(&mut scope, new_rule); + (scope, changed) + } + None => (CallScope { target, selectorRules: vec![new_rule] }, true), + }; + + if !changed { + return if shell::is_json() { + sh_println!("{}", json!({ "status": "already_present", "target": target })) + } else { + sh_status!("Allowed call already present for {}", address_label_with_address(target)) + }; + } + + send_keychain_call( + &IAccountKeychain::setAllowedCallsCall { keyId: key_address, scopes: vec![scope] }, + tx_opts, + &send_tx, + force, + ) + .await +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum KeychainTxOutcome { + Aborted, + Submitted, + PrintedSponsorHash, +} + +pub(crate) enum KeychainRootSigner { + Browser(BrowserSigner), + Wallet(Box), +} + +impl KeychainRootSigner { + fn address(&self) -> Address { + match self { + Self::Browser(browser) => browser.address(), + Self::Wallet(signer) => signer.address(), + } + } + + fn sender(&self) -> SenderKind<'_> { + match self { + Self::Browser(browser) => browser.address().into(), + Self::Wallet(signer) => signer.as_ref().into(), + } + } +} + +/// Resolve the root-authorized signer used for AccountKeychain policy changes. +pub(crate) async fn resolve_keychain_root_signer( + send_tx: &SendTxOpts, + expected_from: Option
, + print_sponsor_hash: bool, +) -> Result { + const WHAT: &str = "AccountKeychain transaction"; + let (signer, tempo_access_key) = send_tx.eth.wallet.maybe_signer().await?; + if let Some(browser) = send_tx.browser.run::().await? { + ensure_root_sender(browser.address(), expected_from, WHAT)?; + return Ok(KeychainRootSigner::Browser(browser)); + } + + // The T6 spec allows an active admin access key to authorize/revoke other keys, but submitting + // these AccountKeychain mutators as access-key-signed precompile calldata reverts on-chain with + // `UnauthorizedCaller()` on the pinned Tempo build (gas estimation succeeds because it injects + // an override key id, while real execution recovers the signer). Reject before broadcasting + // rather than emitting a guaranteed-revert transaction; use a root signer for direct mutations. + if tempo_access_key.is_some() { + eyre::bail!( + "submitting AccountKeychain admin mutators (authorize / revoke / policy) signed by a \ + Tempo access key currently reverts on-chain with UnauthorizedCaller() on the pinned \ + Tempo build, even for an active admin key. Use a root account signer (--browser for \ + passkey roots, or --private-key / --keystore / Ledger / Trezor / AWS / GCP / Turnkey) \ + for direct mutations." + ); + } + + let signer = match signer { + Some(signer) => signer, + None if print_sponsor_hash => eyre::bail!( + "--tempo.print-sponsor-hash requires a root account signer, such as \ + --browser, --private-key, or --keystore" + ), + None => send_tx.eth.wallet.signer().await?, + }; + ensure_root_sender(signer.address(), expected_from, WHAT)?; + Ok(KeychainRootSigner::Wallet(Box::new(signer))) +} + +/// Send an AccountKeychain precompile call as a root-authorized transaction. +async fn send_keychain_call( + call: &impl SolCall, + tx_opts: TransactionOpts, + send_tx: &SendTxOpts, + force: bool, +) -> Result<()> { + send_keychain_tx(call.abi_encode(), tx_opts, send_tx, None, force).await?; + Ok(()) +} + +/// Send calldata to the Tempo AccountKeychain precompile as a root-authorized transaction. +pub(crate) async fn send_keychain_tx( + calldata: Vec, + tx_opts: TransactionOpts, + send_tx: &SendTxOpts, + expected_from: Option
, + force: bool, +) -> Result { + let root_signer = + resolve_keychain_root_signer(send_tx, expected_from, tx_opts.tempo.print_sponsor_hash) + .await?; + send_keychain_tx_with_root_signer(calldata, tx_opts, send_tx, root_signer, force, || Ok(())) + .await +} + +/// Send AccountKeychain calldata with an already-resolved root signer. +pub(crate) async fn send_keychain_tx_with_root_signer( + calldata: Vec, + mut tx_opts: TransactionOpts, + send_tx: &SendTxOpts, + root_signer: KeychainRootSigner, + force: bool, + before_submit: impl FnOnce() -> Result<()>, +) -> Result { + if tx_opts.tempo.sponsor_url.is_some() { + eyre::bail!( + "--sponsor-url is not supported by cast keychain; use --tempo.sponsor with \ + --tempo.sponsor-signer or --tempo.sponsor-sig" + ); + } + + let print_sponsor_hash = tx_opts.tempo.print_sponsor_hash; + let sponsor_fee_payer = tx_opts.tempo.sponsor; + let expires_at = tx_opts.tempo.resolve_expires(); + let tempo_sponsor = + if print_sponsor_hash { None } else { tx_opts.tempo.sponsor_config().await? }; + + let (config, provider) = tempo_provider(&send_tx.eth)?; + apply_poll_interval(&provider, send_tx.poll_interval); + // `--curl` must preserve the first RPC request for the user's intended action. + let fee_provider = (!config.eth_rpc_curl).then_some(&provider); + + // Resolve `--tempo.lane ` against the lanes file (default + // `/tempo.lanes.toml`) and populate `tx_opts.tempo.nonce_key` from the lane. + let resolved_lane = resolve_lane(&mut tx_opts.tempo, &config.root)?; + + let builder = CastTxBuilder::new(&provider, tx_opts, &config) + .await? + .with_to(Some(NameOrAddress::Address(ACCOUNT_KEYCHAIN_ADDRESS))) + .await? + .with_code_sig_and_args(None, Some(hex::encode_prefixed(&calldata)), vec![]) + .await?; + + let from = root_signer.address(); + if print_sponsor_hash { + let Some(mut tx) = + confirm_and_build(builder, root_signer.sender(), force, None, false).await? + else { + return Ok(KeychainTxOutcome::Aborted); + }; + let hash = sponsor_hash(fee_provider, &mut tx, from, sponsor_fee_payer).await?; + if shell::is_json() { + sh_println!("{}", json!({ "sponsor_hash": format!("{hash:?}") }))?; + } else { + sh_println!("{hash:?}")?; + } + return Ok(KeychainTxOutcome::PrintedSponsorHash); + } + + print_expires(expires_at)?; + + let send_opts = + SendOptions::new(send_tx, &config).resolving_fee_token(tempo_sponsor.is_none(), &config); + let is_browser = matches!(root_signer, KeychainRootSigner::Browser(_)); + let (builder, lane) = if is_browser { + (builder.with_browser_wallet(), None) + } else { + (builder, resolved_lane.as_ref()) + }; + let Some(mut tx) = confirm_and_build(builder, root_signer.sender(), force, lane, false).await? + else { + return Ok(KeychainTxOutcome::Aborted); + }; + apply_fee_payment::(tempo_sponsor.as_ref(), fee_provider, &mut tx, from) + .await?; + before_submit()?; + + match root_signer { + KeychainRootSigner::Browser(browser) => { + tx.prep_for_submission(); + let tx_hash = browser.send_transaction_via_browser(tx).await?; + send_opts.print_tx_result(&provider, tx_hash).await?; + } + KeychainRootSigner::Wallet(signer) => { + let provider = AlloyProviderBuilder::<_, _, TempoNetwork>::default() + .wallet(EthereumWallet::from(*signer)) + .connect_provider(&provider); + cast_send(provider, tx, &send_opts).await?; + } + } + + Ok(KeychainTxOutcome::Submitted) +} + +/// Ensures `what` is signed by the expected root account when one is known. +fn ensure_root_sender(actual: Address, expected: Option
, what: &str) -> Result<()> { + if let Some(expected) = expected + && actual != expected + { + eyre::bail!( + "{what} must be signed by root account {expected}; resolved signer is {actual}" + ); + } + Ok(()) +} + +/// Resolves the root account of `key_address` and its local Accounts store entry, if any. +fn resolve_key_metadata( + key_address: Address, + root_account: Option
, +) -> Result<(Address, Option)> { + let store = read_tempo_accounts_store(); + if let Some(root_account) = root_account { + let entry = store.and_then(|store| { + store.keys.into_iter().find(|entry| { + entry.wallet_address == root_account && entry.key_address == key_address + }) + }); + return Ok((root_account, entry)); + } + + let path = tempo_accounts_store_path_display(); + let Some(store) = store else { + eyre::bail!( + "key {key_address} was not found because the Tempo Accounts store could not be read at {path}; pass --root-account" + ); + }; + let mut matches = + store.keys.into_iter().filter(|entry| entry.key_address == key_address).peekable(); + let Some(root_account) = matches.peek().map(|entry| entry.wallet_address) else { + eyre::bail!("key {key_address} was not found in {path}; pass --root-account"); + }; + let matches: Vec<_> = matches.collect(); + if matches.iter().any(|entry| entry.wallet_address != root_account) { + eyre::bail!( + "key {key_address} matches multiple root accounts in {path}; pass --root-account" + ); + } + let preferred = matches.iter().position(|entry| !entry.limits.is_empty()).unwrap_or(0); + Ok((root_account, matches.into_iter().nth(preferred))) +} + +fn tempo_accounts_store_path_display() -> String { + let Some(path) = tempo_accounts_store_path() else { + return "(unknown)".to_string(); + }; + if let Some(home) = + std::env::var_os("HOME").filter(|home| !home.is_empty()).map(std::path::PathBuf::from) + && let Ok(relative) = path.strip_prefix(&home) + && relative == std::path::Path::new(".tempo/wallet/store.json") + { + return "~/.tempo/wallet/store.json".to_string(); + } + path.display().to_string() +} + +/// Merges `rule` into `scope`; returns whether the scope changed. +fn add_selector_rule_to_scope(scope: &mut CallScope, rule: SelectorRule) -> bool { + if scope.selectorRules.is_empty() { + return false; + } + let Some(existing) = + scope.selectorRules.iter_mut().find(|existing| existing.selector == rule.selector) + else { + scope.selectorRules.push(rule); + return true; + }; + if existing.recipients.is_empty() { + return false; + } + if rule.recipients.is_empty() { + existing.recipients = Vec::new(); + return true; + } + let mut changed = false; + for recipient in rule.recipients { + if !existing.recipients.contains(&recipient) { + existing.recipients.push(recipient); + changed = true; + } + } + changed +} + +fn inspected_limit_to_json(limit: &InspectedLimit) -> Value { + json!({ + "token": limit.token, + "token_label": address_label(limit.token), + "configured_amount": limit.configured_amount, + "remaining": limit.remaining.to_string(), + "period_end": limit.period_end, + "period_end_human": limit.period_end.filter(|&end| end != 0).map(format_period_end), + }) +} + +fn allowed_calls_to_json(allowed_calls: &AllowedCallsView) -> Value { + let (mode, scopes) = match allowed_calls { + AllowedCallsView::Unsupported => ("unsupported", &[][..]), + AllowedCallsView::Unrestricted => ("any", &[][..]), + AllowedCallsView::Scoped(scopes) => { + (if scopes.is_empty() { "none" } else { "scoped" }, scopes.as_slice()) + } + }; + let scopes: Vec<_> = scopes + .iter() + .map(|scope| { + json!({ + "target": scope.target, + "target_label": address_label(scope.target), + "selectors": scope.selectorRules.iter().map(|rule| json!({ + "selector": hex::encode_prefixed(rule.selector), + "signature": selector_signature(&rule.selector.0), + "recipients": rule.recipients, + })).collect::>(), + }) + }) + .collect(); + json!({ "mode": mode, "scopes": scopes }) +} + +fn print_inspected_limits(enforce_limits: bool, limits: &[InspectedLimit]) -> Result<()> { + if !enforce_limits { + return sh_println!("Limits: none"); + } + sh_println!("Limits:")?; + if limits.is_empty() { + return sh_println!(" enforced, but no local limit metadata was found"); + } + for limit in limits { + sh_println!( + " {}: {} / {} remaining{}", + address_label(limit.token), + limit.remaining, + limit.configured_amount, + format_period_suffix(limit.period_end) + )?; + } + Ok(()) +} + +fn print_allowed_calls(allowed_calls: &AllowedCallsView) -> Result<()> { + let scopes = match allowed_calls { + AllowedCallsView::Unsupported => { + return sh_println!("Allowed calls: unsupported before T3"); + } + AllowedCallsView::Unrestricted => return sh_println!("Allowed calls: any"), + AllowedCallsView::Scoped(scopes) if scopes.is_empty() => { + return sh_println!("Allowed calls: none"); + } + AllowedCallsView::Scoped(scopes) => scopes, + }; + sh_println!("Allowed calls:")?; + for scope in scopes { + sh_println!(" {}:", address_label_with_address(scope.target))?; + if scope.selectorRules.is_empty() { + sh_println!(" any selector")?; + } + for rule in &scope.selectorRules { + sh_println!( + " {} -> {}", + format_selector(&rule.selector.0), + format_recipients(&rule.recipients) + )?; + } + } + Ok(()) +} + +fn address_label(address: Address) -> String { + if address == PATH_USD_ADDRESS { "PathUSD".to_string() } else { address.to_string() } +} + +fn address_label_with_address(address: Address) -> String { + if address == PATH_USD_ADDRESS { format!("PathUSD ({address})") } else { address.to_string() } +} + +fn format_selector(selector: &[u8; 4]) -> String { + selector_signature(selector).map_or_else(|| hex::encode_prefixed(selector), str::to_string) +} + +fn selector_signature(selector: &[u8; 4]) -> Option<&'static str> { + const KNOWN: [([u8; 4], &str); 7] = [ + (ITIP20::transferCall::SELECTOR, "transfer(address,uint256)"), + (ITIP20::approveCall::SELECTOR, "approve(address,uint256)"), + (ITIP20::transferFromCall::SELECTOR, "transferFrom(address,address,uint256)"), + (ITIP20::transferWithMemoCall::SELECTOR, "transferWithMemo(address,uint256,bytes32)"), + ( + ITIP20::transferFromWithMemoCall::SELECTOR, + "transferFromWithMemo(address,address,uint256,bytes32)", + ), + (ITIP20::mintCall::SELECTOR, "mint(address,uint256)"), + (ITIP20::burnCall::SELECTOR, "burn(uint256)"), + ]; + KNOWN.iter().find(|(known, _)| known == selector).map(|(_, signature)| *signature) +} + +fn format_recipients(recipients: &[Address]) -> String { + if recipients.is_empty() { + return "any recipient".to_string(); + } + let recipients = recipients.iter().map(ToString::to_string).collect::>().join(", "); + format!("recipients [{recipients}]") +} + +fn format_expiry_for_inspect(expiry: u64) -> String { + if expiry == u64::MAX { + return "never".to_string(); + } + format!("{} ({})", format_timestamp_iso(expiry), format_relative_timestamp(expiry)) +} + +fn format_period_end(period_end: u64) -> String { + format!("period resets {}", format_relative_timestamp(period_end)) +} + +/// ` (period resets ...)` for a non-zero period end, empty otherwise. +fn format_period_suffix(period_end: Option) -> String { + period_end + .filter(|&end| end != 0) + .map(|end| format!(" ({})", format_period_end(end))) + .unwrap_or_default() +} + +fn format_utc(timestamp: u64, format: &str) -> String { + DateTime::from_timestamp(timestamp as i64, 0) + .map_or_else(|| timestamp.to_string(), |dt| dt.format(format).to_string()) +} + +fn format_timestamp_iso(timestamp: u64) -> String { + format_utc(timestamp, "%Y-%m-%dT%H:%M:%SZ") +} + +fn format_relative_timestamp(timestamp: u64) -> String { + format_relative_timestamp_from(timestamp, now().as_secs()) +} + +fn format_relative_timestamp_from(timestamp: u64, now: u64) -> String { + if timestamp == now { + "now".to_string() + } else if timestamp > now { + format!("in {}", format_duration_words(timestamp - now)) + } else { + format!("{} ago", format_duration_words(now - timestamp)) + } +} + +fn format_duration_words(seconds: u64) -> String { + const MINUTE: u64 = 60; + const HOUR: u64 = 60 * MINUTE; + const DAY: u64 = 24 * HOUR; + match seconds { + DAY.. => { + let days = seconds / DAY; + if days == 1 { "1 day".to_string() } else { format!("{days} days") } + } + HOUR.. => format!("{}h", seconds / HOUR), + MINUTE.. => format!("{}m", seconds / MINUTE), + _ => format!("{seconds}s"), + } +} + +fn format_expiry(expiry: u64) -> String { + if expiry == u64::MAX { + return "never".to_string(); + } + format_utc(expiry, "%Y-%m-%d %H:%M:%S UTC") +} + +fn load_accounts_store() -> Result { + read_tempo_accounts_store().ok_or_else(|| { + let path = tempo_accounts_store_path() + .map_or_else(|| "(unknown)".to_string(), |p| p.display().to_string()); + eyre::eyre!("could not read Tempo Accounts store at {path}") + }) +} + +/// `root` when the key is the account EOA itself, `admin` when it is a T6 admin key, otherwise +/// `limited`. +const fn key_role(is_root: bool, is_admin: bool) -> &'static str { + if is_root { + "root" + } else if is_admin { + "admin" + } else { + "limited" + } +} + +fn print_key_entry(entry: &tempo::KeyEntry) -> Result<()> { + let is_direct = entry.key_address == entry.wallet_address; + let auth = entry.key_authorization.as_ref().map(|signed| &signed.authorization); + let is_admin = auth.is_some_and(KeyAuthorization::is_admin); + + sh_println!("Wallet: {}", entry.wallet_address)?; + sh_println!("Chain ID: {}", entry.chain_id)?; + sh_println!("Key Type: {}", key_type_name(entry.key_type))?; + sh_println!("Key Address: {}", entry.key_address)?; + sh_println!( + "Mode: {}", + if is_direct { "direct (EOA)" } else { "keychain (access key)" } + )?; + if let Some(expiry) = entry.expiry { + sh_println!("Expiry: {}", format_expiry(expiry))?; + } + sh_println!("Role: {}", key_role(is_direct, is_admin))?; + sh_println!("Has Key: {}", entry.has_inline_key())?; + sh_println!("Has Auth: {}", auth.is_some())?; + if let Some(auth) = auth { + let witness = auth.witness().map_or_else(|| "(none)".to_string(), |w| w.to_string()); + sh_println!("Auth Witness: {witness}")?; + sh_println!("Auth Admin: {is_admin}")?; + if let Some(account) = auth.account { + sh_println!("Auth Account: {account}")?; + } + } + if !entry.limits.is_empty() { + sh_println!("Limits:")?; + for limit in &entry.limits { + sh_println!(" {} → {}", limit.currency, limit.limit)?; + } + } + Ok(()) +} + +fn key_entry_to_json(entry: &tempo::KeyEntry) -> Value { + let is_direct = entry.key_address == entry.wallet_address; + let auth = entry.key_authorization.as_ref().map(|signed| &signed.authorization); + let is_admin = auth.is_some_and(KeyAuthorization::is_admin); + let limits: Vec<_> = + entry.limits.iter().map(|l| json!({ "currency": l.currency, "limit": l.limit })).collect(); + json!({ + "wallet_address": entry.wallet_address, + "chain_id": entry.chain_id, + "key_type": key_type_name(entry.key_type), + "key_address": entry.key_address, + "mode": if is_direct { "direct" } else { "keychain" }, + "expiry": entry.expiry, + "expiry_human": entry.expiry.map(format_expiry), + "has_key": entry.has_inline_key(), + "has_authorization": auth.is_some(), + "role": key_role(is_direct, is_admin), + "authorization_witness": auth.and_then(KeyAuthorization::witness), + "authorization_is_admin": is_admin, + "authorization_account": auth.and_then(|auth| auth.account), + "limits": limits, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_rlp::Decodable; + + fn addr(byte: u8) -> Address { + Address::repeat_byte(byte) + } + + fn rule(selector: [u8; 4], recipients: Vec
) -> SelectorRule { + SelectorRule { selector: selector.into(), recipients } + } + + fn scope(target: Address, rules: Vec) -> CallScope { + CallScope { target, selectorRules: rules } + } + + fn stored_entry(wallet: Address, chain_id: u64, key: Address) -> tempo::KeyEntry { + tempo::KeyEntry::new(wallet, chain_id, KeyType::Secp256k1, key) + } + + fn signed_authorization_with_limits( + limits: Option>, + ) -> SignedKeyAuthorization { + let mut authorization = + KeyAuthorization::unrestricted(31337, AuthSignatureType::Secp256k1, addr(0x42)); + authorization.limits = limits; + authorization.into_signed(PrimitiveSignature::default()) + } + + fn key_auth_args() -> KeyAuthorizationArgs { + KeyAuthorizationArgs { + chain_id: 31337, + key_address: addr(0x42), + key_type: AuthSignatureType::Secp256k1, + expiry: None, + enforce_limits: false, + limits: vec![], + scope: vec![], + scopes_json: None, + witness: None, + admin: false, + } + } + + fn admin_args() -> KeyAuthorizationArgs { + KeyAuthorizationArgs { admin: true, ..key_auth_args() } + } + + fn signed_hex(authorization: KeyAuthorization) -> String { + let signed = authorization.into_signed(PrimitiveSignature::from_bytes(&[0u8; 65]).unwrap()); + hex::encode_prefixed(alloy_rlp::encode(&signed)) + } + + #[test] + fn parse_scopes_json_shapes() { + let plain = r#"[{"target":"0x20c0000000000000000000000000000000000001","selectors":["transfer","approve"]},{"target":"0x86A2EE8FAf9A840F7a2c64CA3d51209F9A02081D"}]"#; + let result = parse_scopes_json(plain).unwrap(); + assert_eq!(result.len(), 2); + assert_eq!(result[0].selectorRules.len(), 2); + assert!(result[1].selectorRules.is_empty()); + + let with_recipients = r#"[{"target":"0x20c0000000000000000000000000000000000001","selectors":[{"selector":"transfer","recipients":["0x1111111111111111111111111111111111111111"]}]}]"#; + let result = parse_scopes_json(with_recipients).unwrap(); + assert_eq!(result[0].selectorRules[0].recipients.len(), 1); + + let unknown_scope_field = + r#"[{"target":"0x20c0000000000000000000000000000000000001","selector":["transfer"]}]"#; + assert!(parse_scopes_json(unknown_scope_field).is_err()); + let unknown_selector_field = r#"[{"target":"0x20c0000000000000000000000000000000000001","selectors":[{"selector":"transfer","recipients":[],"bogus":true}]}]"#; + assert!(parse_scopes_json(unknown_selector_field).is_err()); + } + + #[test] + fn parse_limit_grammar() { + let token = "0x20c0000000000000000000000000000000000000"; + let limit = parse_auth_limit(&format!("{token}:10000000")).unwrap(); + assert_eq!(limit.token, token.parse::
().unwrap()); + assert_eq!(limit.limit, U256::from(10_000_000)); + assert_eq!(limit.period, 0); + assert_eq!(parse_auth_limit(&format!("{token}:5:1d")).unwrap().period, 86_400); + assert_eq!(parse_limit(&format!("{token}:5:1d")).unwrap().period, 86_400); + assert!(parse_auth_limit(token).unwrap_err().contains("invalid limit format")); + assert!(parse_auth_limit(&format!("{token}:x")).unwrap_err().contains("invalid amount")); + } + + #[test] + fn add_selector_rule_merging() { + let transfer = parse_selector_bytes("transfer").unwrap(); + let (first, second) = (addr(0x11), addr(0x22)); + + let mut merged = scope(PATH_USD_ADDRESS, vec![rule(transfer, vec![first])]); + assert!(add_selector_rule_to_scope(&mut merged, rule(transfer, vec![second]))); + assert_eq!(merged.selectorRules.len(), 1); + assert_eq!(merged.selectorRules[0].recipients, vec![first, second]); + + let mut widened = scope(PATH_USD_ADDRESS, vec![rule(transfer, vec![first])]); + assert!(add_selector_rule_to_scope(&mut widened, rule(transfer, vec![]))); + assert!(widened.selectorRules[0].recipients.is_empty()); + + let mut wildcard = scope(PATH_USD_ADDRESS, vec![]); + assert!(!add_selector_rule_to_scope(&mut wildcard, rule(transfer, vec![]))); + assert!(wildcard.selectorRules.is_empty()); + } + + #[test] + fn into_authorization_builds_fields() { + let plain = key_auth_args().into_authorization(None).unwrap(); + assert!(!plain.is_admin()); + assert_eq!(plain.account, None); + assert_eq!(plain.witness(), None); + assert_eq!(plain.allowed_calls, None); + assert!(plain.is_legacy_compatible()); + + // `bytes32(0)` is a present witness, distinct from omitting the flag. + let zero_witness = KeyAuthorizationArgs { witness: Some(B256::ZERO), ..key_auth_args() } + .into_authorization(None) + .unwrap(); + assert_eq!(zero_witness.witness(), Some(B256::ZERO)); + assert_ne!(plain.signature_hash(), zero_witness.signature_hash()); + assert_ne!(alloy_rlp::encode(&plain), alloy_rlp::encode(&zero_witness)); + + // An explicit empty `--scopes []` denies all calls rather than allowing any. + let deny_all = + KeyAuthorizationArgs { scopes_json: Some(AuthScopesJson(vec![])), ..key_auth_args() } + .into_authorization(None) + .unwrap(); + assert_eq!(deny_all.allowed_calls, Some(vec![])); + assert_ne!(plain.signature_hash(), deny_all.signature_hash()); + + // Account binding round-trips and feeds the signing hash. + let bound = key_auth_args().into_authorization(Some(addr(0xCD))).unwrap(); + assert!(!bound.is_admin()); + assert_eq!(bound.account, Some(addr(0xCD))); + let admin_a = admin_args().into_authorization(Some(addr(0x01))).unwrap(); + let admin_b = admin_args().into_authorization(Some(addr(0x02))).unwrap(); + assert!(admin_a.is_admin()); + assert_ne!(admin_a.signature_hash(), admin_b.signature_hash()); + + let signed = + admin_a.clone().into_signed(PrimitiveSignature::from_bytes(&[0u8; 65]).unwrap()); + let encoded = alloy_rlp::encode(&signed); + let decoded = SignedKeyAuthorization::decode(&mut encoded.as_slice()).unwrap(); + assert_eq!(decoded.authorization, admin_a); + + // Local store entries expose the decoded authorization witness. + let witness = B256::repeat_byte(0x53); + let signed = + KeyAuthorization::unrestricted(31337, AuthSignatureType::Secp256k1, addr(0x42)) + .with_witness(witness) + .into_signed(PrimitiveSignature::from_bytes(&[0u8; 65]).unwrap()); + let json = key_entry_to_json(&tempo::KeyEntry::default().with_key_authorization(signed)); + assert_eq!(json["authorization_witness"], witness.to_string()); + } + + #[test] + fn into_authorization_rejects_invalid_args() { + let account = Some(addr(0xAB)); + let cases: [(KeyAuthorizationArgs, Option
, &str); 6] = [ + (admin_args(), None, "--admin requires --account"), + ( + KeyAuthorizationArgs { expiry: Some(1_782_647_677), ..admin_args() }, + account, + "--expiry", + ), + ( + KeyAuthorizationArgs { enforce_limits: true, ..admin_args() }, + account, + "spending limits", + ), + ( + KeyAuthorizationArgs { scopes_json: Some(AuthScopesJson(vec![])), ..admin_args() }, + account, + "call scopes", + ), + (key_auth_args(), Some(Address::ZERO), "--account cannot be the zero address"), + ( + KeyAuthorizationArgs { expiry: Some(0), ..key_auth_args() }, + None, + "--expiry must be greater than zero", + ), + ]; + for (args, account, expected) in cases { + let err = args.into_authorization(account).unwrap_err().to_string(); + assert!(err.contains(expected), "expected {expected:?}, got: {err}"); + } + } + + #[test] + fn inspect_decodes_signed_and_unsigned_shapes() { + let account = addr(0xAB); + let hex = signed_hex(admin_args().into_authorization(Some(account)).unwrap()); + let (auth, signed, _) = decode_and_validate_key_authorization(&hex, None).unwrap(); + assert!(signed, "signed input must be reported as signed"); + assert!(auth.is_admin()); + assert_eq!(auth.account, Some(account)); + + let unsigned = key_auth_args().into_authorization(None).unwrap(); + let hex = hex::encode_prefixed(alloy_rlp::encode(&unsigned)); + let (auth, signed, signer) = decode_and_validate_key_authorization(&hex, None).unwrap(); + assert!(!signed, "unsigned input must be reported as unsigned"); + assert_eq!(auth, unsigned); + assert!(signer.is_none(), "unsigned input must not recover a signer"); + } + + #[test] + fn inspect_enforces_admin_invariants_and_account_binding() { + let unrestricted = + || KeyAuthorization::unrestricted(31337, AuthSignatureType::Secp256k1, addr(0x42)); + // Built directly (bypassing the CLI constructor's guard) to prove `inspect` mirrors the + // chain's admin invariants. + let admin_with_expiry = unrestricted().with_expiry(1_782_647_677).into_admin(addr(0xAB)); + let mut admin_without_account = unrestricted(); + admin_without_account.is_admin = true; + + // T6 allows a root-signed admin authorization to omit `account`; `inspect` is a decoder and + // must not reject it unless `--account` asks for a binding it cannot verify. + let hex = hex::encode_prefixed(alloy_rlp::encode(&admin_without_account)); + let (auth, _, _) = decode_and_validate_key_authorization(&hex, None).unwrap(); + assert!(auth.is_admin()); + assert_eq!(auth.account, None); + + let cases = [ + ( + signed_hex(admin_args().into_authorization(Some(addr(0xAB))).unwrap()), + Some(addr(0xCD)), + "was expected", + ), + ( + hex::encode_prefixed(alloy_rlp::encode(&admin_with_expiry)), + None, + "cannot carry an expiry", + ), + (hex, Some(addr(0xAB)), "no account field"), + ]; + for (hex, expected_account, expected) in cases { + let err = decode_and_validate_key_authorization(&hex, expected_account) + .unwrap_err() + .to_string(); + assert!(err.contains(expected), "expected {expected:?}, got: {err}"); + } + } + + #[test] + fn root_sender_mismatch_message_names_the_artifact() { + let (expected, actual) = (addr(0x11), addr(0x22)); + let err = ensure_root_sender(actual, Some(expected), "key authorization").unwrap_err(); + assert_eq!( + err.to_string(), + format!( + "key authorization must be signed by root account {expected}; resolved signer is {actual}" + ) + ); + assert!(ensure_root_sender(actual, None, "key authorization").is_ok()); + } + + #[test] + fn match_allowed_call_cases() { + use AllowedCallMatch::{Allowed, Denied, RecipientRestricted}; + let transfer = ITIP20::transferCall::SELECTOR; + let approve = ITIP20::approveCall::SELECTOR; + let (target, other, bob, carol) = (addr(0xAA), addr(0xCC), addr(0xBB), addr(0xDD)); + let wildcard = vec![scope(target, vec![])]; + let any_recipient = vec![scope(target, vec![rule(transfer, vec![])])]; + let restricted = vec![scope(target, vec![rule(transfer, vec![bob])])]; + let duplicated = vec![ + scope(target, vec![rule(transfer, vec![bob])]), + scope(target, vec![rule(approve, vec![]), rule(transfer, vec![carol])]), + ]; + let kind = |m: &AllowedCallMatch| match m { + Allowed(_) => "allowed", + Denied(_) => "denied", + RecipientRestricted(_) => "restricted", + }; + + let cases = [ + (&wildcard, target, transfer, None, "allowed"), + (&wildcard, other, transfer, None, "denied"), + (&any_recipient, target, transfer, Some(bob), "allowed"), + (&any_recipient, target, approve, None, "denied"), + (&restricted, target, transfer, None, "restricted"), + (&restricted, target, transfer, Some(bob), "allowed"), + (&restricted, target, transfer, Some(carol), "denied"), + (&duplicated, target, approve, None, "allowed"), + (&duplicated, target, transfer, Some(carol), "allowed"), + ]; + for (scopes, to, selector, recipient, expected) in cases { + let result = match_allowed_call(scopes, to, selector, recipient); + assert_eq!(kind(&result), expected, "{result:?}"); + } + + // Recipient lists are aggregated across duplicate target scopes. + assert_eq!( + match_allowed_call(&duplicated, target, transfer, None), + RecipientRestricted(vec![bob, carol]) + ); + } + + #[test] + fn doctor_args_parse() { + let root = "0x1111111111111111111111111111111111111111"; + let key = "0x2222222222222222222222222222222222222222"; + let KeychainSubcommand::Doctor { key_address, root_account, .. } = + KeychainSubcommand::try_parse_from(["keychain", "doctor", "--root-account", root]) + .unwrap() + else { + panic!("expected doctor"); + }; + assert!(key_address.is_none()); + assert!(root_account.is_some()); + + assert!( + KeychainSubcommand::try_parse_from([ + "keychain", + "doctor", + key, + "--selector", + "transfer" + ]) + .is_err(), + "--selector without --to should error" + ); + + let KeychainSubcommand::Doctor { fee_token, tempo, .. } = + KeychainSubcommand::try_parse_from([ + "keychain", + "doctor", + key, + "--root-account", + root, + "--fee-token", + "PathUSD", + "--tempo.expiring-nonce", + "--tempo.valid-before", + "9999999999", + ]) + .unwrap() + else { + panic!("expected doctor"); + }; + assert_eq!(fee_token, Some(PATH_USD_ADDRESS)); + assert!(tempo.expiring_nonce); + assert_eq!(tempo.valid_before, Some(9_999_999_999)); + } + + #[test] + fn select_subject_for_chain_preferences() { + let (root, key, other_key) = (addr(0x11), addr(0x22), addr(0x33)); + + // Explicit root/key without a local entry is accepted and warns about local signing. + let subject = + select_subject_for_chain(vec![DoctorCandidate::explicit(root, key)], 31337, Some(root)) + .unwrap(); + assert_eq!((subject.root_account, subject.key_address), (root, key)); + assert!(subject.entry.is_none()); + assert_eq!(check_local_signing_readiness(&subject).status, DoctorStatus::Warn); + + // A local entry on another chain is skipped in favour of the explicit pair. + let wrong_chain = stored_entry(root, 1, key).with_locally_signable(true); + let subject = select_subject_for_chain( + vec![DoctorCandidate::from_entry(wrong_chain), DoctorCandidate::explicit(root, key)], + 31337, + Some(root), + ) + .unwrap(); + assert_eq!(subject.key_address, key); + assert!(subject.entry.is_none()); + + // Locally signable entries win over metadata-only records. + let subject = select_subject_for_chain( + vec![ + DoctorCandidate::from_entry(stored_entry(root, 31337, key)), + DoctorCandidate::from_entry( + stored_entry(root, 31337, other_key).with_locally_signable(true), + ), + ], + 31337, + Some(root), + ) + .unwrap(); + assert_eq!(subject.key_address, other_key); + + // A stale entry is kept for its authorization metadata when the pair is also explicit. + let stale = stored_entry(root, 31337, key) + .with_key_authorization(signed_authorization_with_limits(None)); + let subject = select_subject_for_chain( + vec![DoctorCandidate::from_entry(stale), DoctorCandidate::explicit(root, key)], + 31337, + Some(root), + ) + .unwrap(); + assert!(subject.explicit); + assert!(subject.entry.as_ref().is_some_and(|entry| entry.key_authorization.is_some())); + assert_eq!(check_local_signing_readiness(&subject).status, DoctorStatus::Warn); + + // Without an inline key a non-explicit local entry fails; with one it passes. + let mut subject = DoctorSubject { + root_account: root, + key_address: key, + entry: Some(stored_entry(root, 31337, key)), + explicit: false, + }; + assert_eq!(check_local_signing_readiness(&subject).status, DoctorStatus::Fail); + subject.entry = Some(stored_entry(root, 31337, key).with_locally_signable(true)); + assert_eq!(check_local_signing_readiness(&subject).status, DoctorStatus::Pass); + } + + #[test] + fn authorization_spending_limits_warnings() { + let fee_token = addr(0xAA); + let limit = |token, limit, period| AuthTokenLimit { token, limit, period }; + let cases = [ + (limit(addr(0xBB), U256::ONE, 0), Some(true), "not listed"), + (limit(fee_token, U256::ZERO, 0), Some(true), ""), + (limit(fee_token, U256::ONE, 60), None, "hardfork unknown"), + ]; + for (limit, is_t3, detail) in cases { + let signed = signed_authorization_with_limits(Some(vec![limit])); + let step = check_authorization_spending_limits(&signed, fee_token, is_t3); + assert_eq!(step.status, DoctorStatus::Warn, "{step:?}"); + assert!(step.detail.contains(detail), "{step:?}"); + } + } + + #[test] + fn key_role_precedence() { + assert_eq!(key_role(true, false), "root"); + assert_eq!(key_role(true, true), "root"); + assert_eq!(key_role(false, true), "admin"); + assert_eq!(key_role(false, false), "limited"); + } + + #[tokio::test] + async fn allowed_calls_hardfork_gates() { + let provider = alloy_provider::ProviderBuilder::new_with_network::() + .connect_mocked_client(alloy_provider::mock::Asserter::new()); + let subject = DoctorSubject { + root_account: addr(0x11), + key_address: addr(0x22), + entry: None, + explicit: true, + }; + let step = check_allowed_calls(&provider, &subject, None, None, None, None, None).await; + assert_eq!(step.status, DoctorStatus::Warn); + assert_eq!(step.detail, "skipped; hardfork unknown"); + let step = + check_allowed_calls(&provider, &subject, None, Some(false), None, None, None).await; + assert_eq!(step.status, DoctorStatus::Pass); + assert_eq!(step.detail, "TIP-1011 not enforced before T3"); + } + + #[test] + fn expiry_uses_chain_timestamp() { + let known = ChainTimestamp::Known(100); + assert_eq!(check_expiry(Some(100), &known, "", "hint").status, DoctorStatus::Fail); + assert_eq!(check_expiry(Some(101), &known, "", "hint").status, DoctorStatus::Pass); + assert_eq!(check_expiry(None, &known, "", "hint").detail, "never expires"); + + let unknown = + ChainTimestamp::Unknown { detail: "latest block not found".to_string(), hint: "h" }; + let step = check_expiry(Some(100), &unknown, "key_authorization ", "hint"); + assert_eq!(step.status, DoctorStatus::Warn); + assert_eq!(step.detail, "key_authorization expiry not checked: latest block not found"); + } + + #[test] + fn expiring_nonce_window_thresholds() { + let opts = |expiring_nonce, valid_after, valid_before| TempoOpts { + expiring_nonce, + valid_after, + valid_before, + ..Default::default() + }; + let t10 = Some(TempoHardfork::T10); + let t11 = Some(TempoHardfork::T11); + let cases = [ + // Validated even without --tempo.expiring-nonce. + (opts(false, Some(20), Some(20)), 10, t10, DoctorStatus::Fail), + (opts(false, None, Some(10)), 10, t10, DoctorStatus::Fail), + (opts(true, None, Some(103)), 100, t10, DoctorStatus::Fail), + (opts(true, None, Some(104)), 100, t10, DoctorStatus::Warn), + (opts(true, None, Some(105)), 100, t10, DoctorStatus::Warn), + (opts(true, None, Some(120)), 100, t10, DoctorStatus::Pass), + // The upper bound follows the active hardfork and is skipped when it is unknown. + (opts(true, None, Some(131)), 100, t10, DoctorStatus::Warn), + (opts(true, None, Some(131)), 100, t11, DoctorStatus::Pass), + (opts(true, None, Some(400)), 100, t11, DoctorStatus::Pass), + (opts(true, None, Some(401)), 100, t11, DoctorStatus::Warn), + (opts(true, None, Some(401)), 100, None, DoctorStatus::Pass), + ]; + for (tempo, now, hardfork, expected) in cases { + let step = check_expiring_nonce_window(&tempo, None, now, hardfork); + assert_eq!(step.status, expected, "{step:?}"); + } + } + + #[test] + fn diagnose_allowed_scopes_denials() { + let exact = + diagnose_allowed_scopes(&[], Some(addr(0x11)), Some([0xaa, 0xbb, 0xcc, 0xdd]), None); + assert_eq!(exact.status, DoctorStatus::Fail); + + let scopes = [scope(addr(0x11), vec![rule([0xaa, 0xbb, 0xcc, 0xdd], vec![])])]; + let target_only = diagnose_allowed_scopes(&scopes, Some(addr(0x22)), None, None); + assert_eq!(target_only.status, DoctorStatus::Warn); + } + + #[test] + fn sponsor_config_error_redacts_private_key_uri() { + let tempo = TempoOpts { + sponsor_signer: Some("private-key://super-secret".to_string()), + ..Default::default() + }; + let sanitized = sanitize_sponsor_config_error( + "unsupported Tempo sponsor signer `private-key://super-secret`", + &tempo, + ); + assert!(sanitized.contains("private-key://")); + assert!(!sanitized.contains("super-secret")); + } +} diff --git a/crates/cast/src/cmd/logs.rs b/crates/cast/src/cmd/logs.rs index da06c5eddbacf..757705595bc22 100644 --- a/crates/cast/src/cmd/logs.rs +++ b/crates/cast/src/cmd/logs.rs @@ -1,19 +1,47 @@ -use crate::Cast; -use alloy_dyn_abi::{DynSolType, DynSolValue, Specifier}; +use super::MAX_CONCURRENT_RPC_REQUESTS; +use crate::args::encode_event_topic; +use alloy_consensus::BlockHeader; +use alloy_dyn_abi::{EventExt, Specifier}; use alloy_ens::NameOrAddress; use alloy_json_abi::Event; -use alloy_network::AnyNetwork; -use alloy_primitives::{Address, B256, hex::FromHex}; -use alloy_rpc_types::{BlockId, BlockNumberOrTag, Filter, FilterBlockOption, FilterSet, Topic}; +use alloy_json_rpc::RpcError; +use alloy_network::{AnyNetwork, BlockResponse, Network}; +use alloy_primitives::{Address, B256, TxHash}; +use alloy_provider::Provider; +use alloy_rpc_types::{BlockId, BlockNumberOrTag, Filter, FilterBlockOption, Log, Topic}; +use alloy_transport::TransportErrorKind; use clap::Parser; use eyre::Result; -use foundry_cli::{opts::EthereumOpts, utils, utils::LoadConfig}; -use itertools::Itertools; -use std::{io, str::FromStr}; +use foundry_cli::{ + opts::RpcOpts, + utils::{self, LoadConfig}, +}; +use foundry_common::{ + fmt::{UIfmt, format_token}, + shell, +}; +use futures::{FutureExt, StreamExt, TryStreamExt, future::Either}; +use std::{fmt::Write as _, io::Write as _, str::FromStr}; +use tokio::signal::ctrl_c; /// CLI arguments for `cast logs`. #[derive(Debug, Parser)] pub struct LogsArgs { + #[command(flatten)] + query: LogQueryArgs, + + /// If the RPC type and endpoints supports `eth_subscribe` stream logs instead of printing and + /// exiting. Will continue until interrupted or TO_BLOCK is reached. + #[arg(long)] + subscribe: bool, + + #[command(flatten)] + rpc: RpcOpts, +} + +/// Arguments shared by commands that query logs with `eth_getLogs`. +#[derive(Debug, Parser)] +pub struct LogQueryArgs { /// The block height to start query at. /// /// Can also be the tags earliest, finalized, safe, latest, or pending. @@ -27,11 +55,8 @@ pub struct LogsArgs { to_block: Option, /// The contract address to filter on. - #[arg( - long, - value_parser = NameOrAddress::from_str - )] - address: Option, + #[arg(long, value_parser = NameOrAddress::from_str)] + address: Option>, /// The signature of the event to filter logs by which will be converted to the first topic or /// a topic to filter on. @@ -43,43 +68,37 @@ pub struct LogsArgs { #[arg(value_name = "TOPICS_OR_ARGS")] topics_or_args: Vec, - /// If the RPC type and endpoints supports `eth_subscribe` stream logs instead of printing and - /// exiting. Will continue until interrupted or TO_BLOCK is reached. - #[arg(long)] - subscribe: bool, - - #[command(flatten)] - eth: EthereumOpts, + /// Split the query into chunks of this many blocks to work around provider range/result + /// limits. + /// + /// When omitted, the range is queried in a single request. Pass a value (e.g. `10000`) to + /// fetch the logs in `query-size`-block chunks instead. + #[arg(long, value_name = "BLOCKS")] + query_size: Option, } impl LogsArgs { pub async fn run(self) -> Result<()> { - let Self { from_block, to_block, address, sig_or_topic, topics_or_args, subscribe, eth } = - self; + let Self { query, subscribe, rpc } = self; - let config = eth.load_config()?; + let config = rpc.load_config()?; let provider = utils::get_provider(&config)?; - - let cast = Cast::new(&provider); - - let address = match address { - Some(address) => Some(address.resolve(&provider).await?), - None => None, - }; - - let from_block = - cast.convert_block_number(Some(from_block.unwrap_or_else(BlockId::earliest))).await?; - let to_block = - cast.convert_block_number(Some(to_block.unwrap_or_else(BlockId::latest))).await?; - - let filter = build_filter(from_block, to_block, address, sig_or_topic, topics_or_args)?; + let (filter, query_size, event) = query.resolve_with_event(&provider).await?; if !subscribe { - let logs = cast.filter_logs(filter).await?; + let logs = match query_size { + Some(chunk_size) => format_logs( + get_logs_chunked(&provider, &filter, chunk_size).await?, + event.as_ref(), + )?, + None => format_logs(provider.get_logs(&filter).await?, event.as_ref())?, + }; sh_println!("{logs}")?; return Ok(()); } + // JSON envelope intentionally unsupported for streaming: --subscribe emits NDJSON events + // continuously; a terminal JsonEnvelope is pointless. // FIXME: this is a hotfix for // currently the alloy `eth_subscribe` impl does not work with all transports, so we use // the builtin transport here for now @@ -87,337 +106,768 @@ impl LogsArgs { let provider = alloy_provider::ProviderBuilder::<_, _, AnyNetwork>::default() .connect(url.as_ref()) .await?; - let cast = Cast::new(&provider); - let mut stdout = io::stdout(); - cast.subscribe(filter, &mut stdout).await?; + let output = &mut std::io::stdout(); + let mut subscription = provider.subscribe_logs(&filter).await?.into_stream(); + + // Subscribe to blocks when a `to_block` is set so the stream ends once it is passed. + let to_block_number = filter.get_to_block(); + let mut block_subscription = match to_block_number { + Some(_) => Some(provider.subscribe_blocks().await?.into_stream()), + None => None, + }; + + let format_json = shell::is_json(); + if format_json { + write!(output, "[")?; + } + + let mut first = true; + let mut warned_decode_failure = false; + loop { + tokio::select! { + block = match &mut block_subscription { + Some(bs) => Either::Left(bs.next().fuse()), + None => Either::Right(futures::future::pending()), + } => { + if let (Some(block), Some(to_block)) = (block, to_block_number) + && block.number() > to_block + { + break; + } + }, + log = subscription.next() => { + let Some(log) = log else { break }; + if format_json { + if !first { + write!(output, ",")?; + } + first = false; + write!(output, "{}", serde_json::to_string(&log).unwrap())?; + } else { + let (formatted, decode_failed) = format_log(&log, event.as_ref()); + if decode_failed && !warned_decode_failure { + warned_decode_failure = true; + sh_warn!( + "failed to decode a log with the provided event signature; \ + make sure its indexed parameters match the log topics" + )?; + } + writeln!(output, "{formatted}")?; + } + }, + // Break on the cancel signal so the JSON array is still closed. + _ = ctrl_c() => break, + else => break, + } + } + if format_json { + write!(output, "]")?; + } Ok(()) } } +impl LogQueryArgs { + /// Takes a lone positional transaction hash, if present. + pub(super) fn take_transaction_hash(&mut self) -> Option { + if self.from_block.is_none() + && self.to_block.is_none() + && self.address.is_none() + && self.topics_or_args.is_empty() + && self.query_size.is_none() + && let Some(tx_hash) = self.sig_or_topic.as_deref().and_then(|value| value.parse().ok()) + { + self.sig_or_topic = None; + return Some(tx_hash); + } + None + } + + /// Resolves names and block tags and builds the RPC filter. + pub async fn resolve, N: Network>( + self, + provider: &P, + ) -> Result<(Filter, Option)> { + let (filter, query_size, _) = self.resolve_with_event(provider).await?; + Ok((filter, query_size)) + } + + async fn resolve_with_event, N: Network>( + self, + provider: &P, + ) -> Result<(Filter, Option, Option)> { + let Self { from_block, to_block, address, sig_or_topic, topics_or_args, query_size } = self; + + let addresses = match address { + Some(addresses) => Some( + futures::future::try_join_all( + addresses.iter().map(|address| address.resolve(provider)), + ) + .await?, + ), + None => None, + }; + + let from_block = + convert_block_number(&provider, Some(from_block.unwrap_or_else(BlockId::earliest))) + .await?; + let to_block = + convert_block_number(&provider, Some(to_block.unwrap_or_else(BlockId::latest))).await?; + let (filter, event) = + build_filter(from_block, to_block, addresses, sig_or_topic, topics_or_args)?; + + Ok((filter, query_size, event)) + } +} + /// Builds a Filter by first trying to parse the `sig_or_topic` as an event signature. If /// successful, `topics_or_args` is parsed as indexed inputs and converted to topics. Otherwise, /// `sig_or_topic` is prepended to `topics_or_args` and used as raw topics. fn build_filter( from_block: Option, to_block: Option, - address: Option
, + address: Option>, sig_or_topic: Option, topics_or_args: Vec, -) -> Result { - let block_option = FilterBlockOption::Range { from_block, to_block }; - let filter = match sig_or_topic { - // Try and parse the signature as an event signature - Some(sig_or_topic) => match foundry_common::abi::get_event(sig_or_topic.as_str()) { - Ok(event) => build_filter_event_sig(event, topics_or_args)?, - Err(_) => { - let topics = [vec![sig_or_topic], topics_or_args].concat(); - build_filter_topics(topics)? +) -> Result<(Filter, Option)> { + let (topics, event) = match sig_or_topic { + Some(sig_or_topic) => match foundry_common::abi::get_event(&sig_or_topic) { + Ok(parsed) => { + let topics = event_topics(&parsed, &topics_or_args)?; + (topics, Some(parsed)) } + Err(_) => (raw_topics([vec![sig_or_topic], topics_or_args].concat())?, None), }, - None => Filter::default(), + None => (Default::default(), None), }; - let mut filter = filter.select(block_option); - + let mut filter = Filter { + block_option: FilterBlockOption::Range { from_block, to_block }, + topics, + ..Default::default() + }; if let Some(address) = address { - filter = filter.address(address) + filter = filter.address(address); } + Ok((filter, event)) +} - Ok(filter) +/// Encodes `args` as the indexed topics of `event`; empty arguments match any value. Anonymous +/// events have no selector topic, so their indexed inputs start at the first topic. +fn event_topics(event: &Event, args: &[String]) -> Result<[Topic; 4]> { + let mut topics = if event.anonymous { vec![] } else { vec![Topic::from(event.selector())] }; + let indexed = event.inputs.iter().filter(|input| input.indexed); + eyre::ensure!( + topics.len() + indexed.clone().count() <= 4, + "event `{}` has too many indexed inputs: a log has at most 4 topics", + event.name + ); + for (input, arg) in indexed.zip(args) { + let kind = input.resolve()?; + topics.push(if arg.is_empty() { + Topic::default() + } else { + Topic::from(encode_event_topic(&kind.coerce_str(arg)?)) + }); + } + topics.resize(4, Topic::default()); + Ok(topics.try_into().unwrap()) } -/// Creates a [Filter] from the given event signature and arguments. -fn build_filter_event_sig(event: Event, args: Vec) -> Result { - let args = args.iter().map(|arg| arg.as_str()).collect::>(); - - // Match the args to indexed inputs. Enumerate so that the ordering can be restored - // when merging the inputs with arguments and without arguments - let (with_args, without_args): (Vec<_>, Vec<_>) = event - .inputs - .iter() - .zip(args) - .filter(|(input, _)| input.indexed) - .map(|(input, arg)| { - let kind = input.resolve()?; - Ok((kind, arg)) - }) - .collect::>>()? +/// Parses raw topic hashes; empty topics match any value. +fn raw_topics(topics: Vec) -> Result<[Topic; 4]> { + let mut topics = topics .into_iter() - .enumerate() - .partition(|(_, (_, arg))| !arg.is_empty()); + .map(|topic| { + Ok(if topic.is_empty() { + Topic::default() + } else { + Topic::from(B256::from_str(&topic)?) + }) + }) + .collect::>>()?; + topics.resize(4, Topic::default()); + Ok(topics.try_into().unwrap()) +} - // Only parse the inputs with arguments - let indexed_tokens = with_args - .iter() - .map(|(_, (kind, arg))| kind.coerce_str(arg)) - .collect::, _>>()?; +/// Fetches logs for the inclusive `[from, to]` range, recursively bisecting on failure. +fn get_logs_bisecting<'a, P: Provider, N: Network>( + provider: &'a P, + filter: &'a Filter, + from: u64, + to: u64, +) -> futures::future::BoxFuture<'a, Result>> { + Box::pin(async move { + let range_filter = filter.clone().from_block(from).to_block(to); + match provider.get_logs(&range_filter).await { + Ok(logs) => Ok(logs), + Err(e) => { + // Only bisect range-limit errors with room left to split; surface anything + // else immediately. + if from >= to || !is_range_limit_error(&e) { + return Err(e.into()); + } + + // Bisect sequentially: this path is only reached after a provider failure, so + // fanning out concurrently here would risk amplifying rate-limit errors and + // would defeat the top-level concurrency cap. + let mid = from + (to - from) / 2; + let mut left = get_logs_bisecting(provider, filter, from, mid).await?; + let right = get_logs_bisecting(provider, filter, mid + 1, to).await?; + left.extend(right); + Ok(left) + } + } + }) +} - // Merge the inputs restoring the original ordering - let mut topics = with_args - .into_iter() - .zip(indexed_tokens) - .map(|((i, _), t)| (i, Some(t))) - .chain(without_args.into_iter().map(|(i, _)| (i, None))) - .sorted_by(|(i1, _), (i2, _)| i1.cmp(i2)) - .map(|(_, token)| { - token - .map(|token| Topic::from(B256::from_slice(token.abi_encode().as_slice()))) - .unwrap_or(Topic::default()) - }) - .collect::>(); +/// Retrieves logs for the inclusive `[from, to]` range using concurrent chunked requests. +async fn get_logs_chunked_concurrent, N: Network>( + provider: &P, + filter: &Filter, + from: u64, + to: u64, + chunk_size: u64, +) -> Result> { + let chunk_ranges = (from..=to) + .step_by(chunk_size as usize) + .map(|start| (start, start.saturating_add(chunk_size - 1).min(to))); + + // `buffered` preserves input order, so results stay ordered by block. `try_collect` stops + // early and surfaces the error if any chunk ultimately fails. + let chunks: Vec> = futures::stream::iter(chunk_ranges) + .map(|(start, end)| get_logs_bisecting(provider, filter, start, end)) + .buffered(MAX_CONCURRENT_RPC_REQUESTS) + .try_collect() + .await?; + + Ok(chunks.into_iter().flatten().collect()) +} + +/// Resolves a [`BlockNumberOrTag`] to a concrete block number, querying the provider for tags. +async fn resolve_block_tag, N: Network>( + provider: &P, + tag: BlockNumberOrTag, +) -> Result { + match tag { + BlockNumberOrTag::Number(number) => Ok(number), + BlockNumberOrTag::Earliest => Ok(0), + tag => { + let block = provider + .get_block(BlockId::Number(tag)) + .await? + .ok_or_else(|| eyre::eyre!("could not resolve block tag `{tag}`"))?; + Ok(block.header().number()) + } + } +} + +/// Resolves the filter's block range to concrete block numbers. +/// +/// Returns `None` when the filter does not target a block-number range (e.g. it filters by +/// block hash), in which case chunking is not possible. Tags such as `latest` and `earliest` +/// are resolved against the provider so that the common case (`--to-block` defaulting to +/// `latest`) can still be chunked. +async fn resolve_block_range, N: Network>( + provider: &P, + filter: &Filter, +) -> Result> { + let FilterBlockOption::Range { from_block, to_block } = &filter.block_option else { + return Ok(None); + }; - topics.resize(3, Topic::default()); + let from_tag = from_block.unwrap_or(BlockNumberOrTag::Earliest); + let to_tag = to_block.unwrap_or(BlockNumberOrTag::Latest); - let filter = Filter::new() - .event_signature(event.selector()) - .topic1(topics[0].clone()) - .topic2(topics[1].clone()) - .topic3(topics[2].clone()); + // `pending` is not a concrete canonical range boundary; don't chunk it, so the single + // request preserves the provider's native `pending` semantics. + if from_tag.is_pending() || to_tag.is_pending() { + return Ok(None); + } - Ok(filter) + let from = resolve_block_tag(provider, from_tag).await?; + // Resolve identical tags only once so a moving head (e.g. `latest`..`latest`) can't yield + // an inconsistent range. + let to = if from_tag == to_tag { from } else { resolve_block_tag(provider, to_tag).await? }; + Ok(Some((from, to))) } -/// Creates a [Filter] from raw topic hashes. -fn build_filter_topics(topics: Vec) -> Result { - let mut topics = topics - .into_iter() - .map(|topic| { - if topic.is_empty() { - Ok(Topic::default()) - } else { - Ok(Topic::from(B256::from_hex(topic.as_str())?)) - } - }) - .collect::>>>()?; +/// Retrieves logs, splitting the request into fixed-size block chunks when needed. +pub(super) async fn get_logs_chunked, N: Network>( + provider: &P, + filter: &Filter, + chunk_size: u64, +) -> Result> { + // Only chunk a finite block-number range larger than one chunk; `chunk_size == 0` + // disables chunking and falls back to a single request. + let Some((from, to)) = resolve_block_range(provider, filter).await? else { + return provider.get_logs(filter).await.map_err(Into::into); + }; + // Inverted range yields no logs; warn instead of returning empty silently. + if from > to { + sh_warn!( + "requested block range is inverted (from-block {from} > to-block {to}); no logs to return" + )?; + return Ok(vec![]); + } + if chunk_size == 0 || to - from < chunk_size { + return provider.get_logs(filter).await.map_err(Into::into); + } - topics.resize(4, Topic::default()); + get_logs_chunked_concurrent(provider, filter, from, to, chunk_size).await +} + +async fn convert_block_number, N: Network>( + provider: &P, + block: Option, +) -> Result> { + match block { + Some(BlockId::Number(number)) => Ok(Some(number)), + Some(BlockId::Hash(hash)) => { + let block = provider + .get_block_by_hash(hash.block_hash) + .await? + .ok_or_else(|| eyre::eyre!("block {} not found", hash.block_hash))?; + Ok(Some(block.header().number().into())) + } + None => Ok(None), + } +} - let filter = Filter::new() - .event_signature(topics[0].clone()) - .topic1(topics[1].clone()) - .topic2(topics[2].clone()) - .topic3(topics[3].clone()); +fn format_logs(logs: Vec, event: Option<&Event>) -> Result { + if shell::is_json() { + Ok(serde_json::to_string(&logs)?) + } else { + let total = logs.len(); + let mut failed = 0; + let formatted = logs + .iter() + .map(|log| { + let (text, decode_failed) = format_log(log, event); + failed += usize::from(decode_failed); + text + }) + .collect::>() + .join("\n"); + if failed > 0 { + sh_warn!( + "failed to decode {failed} of {total} logs with the provided event signature; \ + make sure its indexed parameters match the log topics" + )?; + } + Ok(formatted) + } +} - Ok(filter) +fn format_log(log: &Log, event: Option<&Event>) -> (String, bool) { + let mut pretty = log.pretty(); + let mut decode_failed = false; + if let Some(event) = event { + match format_log_params(event, log) { + Some(decoded) => pretty.push_str(&decoded), + None => decode_failed = true, + } + } + (pretty.replacen('\n', "- ", 1).replace('\n', "\n "), decode_failed) +} + +/// Formats decoded event parameters in declaration order. +fn format_log_params(event: &Event, log: &Log) -> Option { + let decoded = event.decode_log(log.data()).ok()?; + let mut indexed = decoded.indexed.iter(); + let mut body = decoded.body.iter(); + let mut result = String::from("\ndecoded:"); + for (i, input) in event.inputs.iter().enumerate() { + let value = if input.indexed { indexed.next() } else { body.next() }?; + let name = if input.name.is_empty() { format!("param{i}") } else { input.name.clone() }; + write!(result, "\n\t{name}: {}", format_token(value)).ok()?; + } + Some(result) +} + +/// Returns `true` if `err` is a provider range/result-size limit that retrying over a smaller +/// range can fix. Network, auth, rate-limit, and malformed-response errors return `false`. +fn is_range_limit_error(err: &RpcError) -> bool { + // Only HTTP 413 (payload too large) is fixable by a smaller range; other transport errors + // (network, auth 401/403, rate-limit 429) are not. + if let RpcError::Transport(kind) = err { + return kind.as_http_error().is_some_and(|http| http.status == 413); + } + + // Range/result-size limits are reported as JSON-RPC server error responses; every other + // variant falls through to `false`. + let RpcError::ErrorResp(payload) = err else { return false }; + let message = payload.message.to_ascii_lowercase(); + + // Phrases providers use for range/result-size limits, kept specific so rate-limit/quota + // wording (e.g. "no more than 10 requests per second") doesn't match. + const RANGE_LIMIT_HINTS: &[&str] = &[ + "block range", + "blocks range", + "range is too", + "range too", + "returned more than", + "response size", + "result set", + "too many results", + "too many blocks", + "maximum block range", + "max block range", + ]; + RANGE_LIMIT_HINTS.iter().any(|hint| message.contains(hint)) } #[cfg(test)] mod tests { use super::*; - use alloy_primitives::{U160, U256}; - use alloy_rpc_types::ValueOrArray; + use alloy_dyn_abi::DynSolValue; + use alloy_primitives::{Log as PrimitiveLog, U256, keccak256}; - const ADDRESS: &str = "0x4D1A2e2bB4F88F0250f26Ffff098B0b30B26BF38"; - const TRANSFER_SIG: &str = "Transfer(address indexed,address indexed,uint256)"; - const TRANSFER_TOPIC: &str = - "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; + fn rpc_log(topics: Vec, data: Vec) -> Log { + Log { + inner: PrimitiveLog::new_unchecked(Address::ZERO, topics, data.into()), + ..Default::default() + } + } #[test] - fn test_build_filter_basic() { - let from_block = Some(BlockNumberOrTag::from(1337)); - let to_block = Some(BlockNumberOrTag::Latest); - let address = Address::from_str(ADDRESS).ok(); - let expected = Filter { - block_option: FilterBlockOption::Range { from_block, to_block }, - address: ValueOrArray::Value(address.unwrap()).into(), - topics: [vec![].into(), vec![].into(), vec![].into(), vec![].into()], - }; - let filter = build_filter(from_block, to_block, address, None, vec![]).unwrap(); - assert_eq!(filter, expected) + fn format_log_params_respects_signature_indexed_flags() { + let event = Event::parse("event Ev(bytes32 indexed a, address b)").unwrap(); + let a = B256::repeat_byte(0x11); + let b = Address::repeat_byte(0x22); + let log = rpc_log(vec![event.selector(), a], DynSolValue::Address(b).abi_encode()); + + let params = format_log_params(&event, &log).unwrap(); + assert_eq!(params, format!("\ndecoded:\n\ta: {a}\n\tb: {b}")); } #[test] - fn test_build_filter_sig() { - let expected = Filter { - block_option: FilterBlockOption::Range { from_block: None, to_block: None }, - address: vec![].into(), - topics: [ - B256::from_str(TRANSFER_TOPIC).unwrap().into(), - vec![].into(), - vec![].into(), - vec![].into(), - ], - }; - let filter = - build_filter(None, None, None, Some(TRANSFER_SIG.to_string()), vec![]).unwrap(); - assert_eq!(filter, expected) + fn format_log_params_requires_explicit_indexed_flags() { + let event = Event::parse("event Ev(uint256 id, address owner)").unwrap(); + let owner = Address::repeat_byte(0x22); + let log = rpc_log( + vec![event.selector(), B256::with_last_byte(7)], + DynSolValue::Address(owner).abi_encode(), + ); + + assert_eq!(format_log_params(&event, &log), None); } #[test] - fn test_build_filter_mismatch() { - let expected = Filter { - block_option: FilterBlockOption::Range { from_block: None, to_block: None }, - address: vec![].into(), - topics: [ - B256::from_str(TRANSFER_TOPIC).unwrap().into(), - vec![].into(), - vec![].into(), - vec![].into(), - ], - }; - let filter = build_filter( - None, - None, - None, - Some("Swap(address indexed from, address indexed to, uint256 value)".to_string()), // Change signature, should result in error - vec![], - ) - .unwrap(); - assert_ne!(filter, expected) + fn format_log_params_preserves_declaration_order() { + let event = + Event::parse("event Ev(uint256 a, bytes32 indexed b, address c, bytes32 indexed d)") + .unwrap(); + let b = B256::repeat_byte(0x11); + let c = Address::repeat_byte(0x22); + let d = B256::repeat_byte(0x33); + let data = DynSolValue::Tuple(vec![ + DynSolValue::Uint(U256::from(7), 256), + DynSolValue::Address(c), + ]) + .abi_encode_params(); + let log = rpc_log(vec![event.selector(), b, d], data); + + let params = format_log_params(&event, &log).unwrap(); + assert_eq!(params, format!("\ndecoded:\n\ta: 7\n\tb: {b}\n\tc: {c}\n\td: {d}")); } #[test] - fn test_build_filter_sig_with_arguments() { - let addr = Address::from_str(ADDRESS).unwrap(); - let addr = U256::from(U160::from_be_bytes(addr.0.0)); - let expected = Filter { - block_option: FilterBlockOption::Range { from_block: None, to_block: None }, - address: vec![].into(), - topics: [ - B256::from_str(TRANSFER_TOPIC).unwrap().into(), - addr.into(), - vec![].into(), - vec![].into(), - ], - }; - let filter = build_filter( - None, - None, - None, - Some(TRANSFER_SIG.to_string()), - vec![ADDRESS.to_string()], - ) - .unwrap(); - assert_eq!(filter, expected) + fn format_log_params_decodes_anonymous_event() { + let event = Event::parse("event Ev(bytes32 indexed key, uint256 value) anonymous").unwrap(); + let key = B256::repeat_byte(0x44); + let log = rpc_log(vec![key], DynSolValue::Uint(U256::from(9), 256).abi_encode()); + + let params = format_log_params(&event, &log).unwrap(); + assert_eq!(params, format!("\ndecoded:\n\tkey: {key}\n\tvalue: 9")); } #[test] - fn test_build_filter_sig_with_skipped_arguments() { - let addr = Address::from_str(ADDRESS).unwrap(); - let addr = U256::from(U160::from_be_bytes(addr.0.0)); - let expected = Filter { - block_option: FilterBlockOption::Range { from_block: None, to_block: None }, - address: vec![].into(), - topics: [ - vec![B256::from_str(TRANSFER_TOPIC).unwrap()].into(), - vec![].into(), - addr.into(), - vec![].into(), - ], - }; - let filter = build_filter( - None, - None, - None, - Some(TRANSFER_SIG.to_string()), - vec![String::new(), ADDRESS.to_string()], - ) - .unwrap(); - assert_eq!(filter, expected) + fn format_log_params_displays_dynamic_indexed_hash() { + let event = Event::parse("event Ev(string indexed key, uint256 value)").unwrap(); + let key = keccak256("hello"); + let log = rpc_log( + vec![event.selector(), key], + DynSolValue::Uint(U256::from(9), 256).abi_encode(), + ); + + let params = format_log_params(&event, &log).unwrap(); + assert_eq!(params, format!("\ndecoded:\n\tkey: {key}\n\tvalue: 9")); } #[test] - fn test_build_filter_with_topics() { - let expected = Filter { - block_option: FilterBlockOption::Range { from_block: None, to_block: None }, - address: vec![].into(), - topics: [ - vec![B256::from_str(TRANSFER_TOPIC).unwrap()].into(), - vec![B256::from_str(TRANSFER_TOPIC).unwrap()].into(), - vec![].into(), - vec![].into(), - ], - }; - let filter = build_filter( + fn format_log_params_mismatching_log() { + let event = Event::parse("event Ev(uint256 indexed a, uint256 b)").unwrap(); + let log = rpc_log(vec![event.selector()], DynSolValue::Uint(U256::ONE, 256).abi_encode()); + assert_eq!(format_log_params(&event, &log), None); + } + + const ADDRESS: &str = "0x4D1A2e2bB4F88F0250f26Ffff098B0b30B26BF38"; + const OTHER_ADDRESS: &str = "0x000000000000000000000000000000000000dead"; + const TRANSFER_SIG: &str = "Transfer(address indexed,address indexed,uint256)"; + const TRANSFER_TOPIC: &str = + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; + + fn filter(sig_or_topic: &str, args: &[&str]) -> Result { + build_filter( None, None, None, - Some(TRANSFER_TOPIC.to_string()), - vec![TRANSFER_TOPIC.to_string()], + Some(sig_or_topic.to_string()), + args.iter().map(|s| s.to_string()).collect(), ) - .unwrap(); + .map(|(filter, _)| filter) + } - assert_eq!(filter, expected) + fn topics(topics: [Topic; 4]) -> Filter { + Filter { topics, ..Default::default() } } #[test] - fn test_build_filter_with_skipped_topic() { - let expected = Filter { - block_option: FilterBlockOption::Range { from_block: None, to_block: None }, - address: vec![].into(), - topics: [ - vec![B256::from_str(TRANSFER_TOPIC).unwrap()].into(), - vec![].into(), - vec![B256::from_str(TRANSFER_TOPIC).unwrap()].into(), - vec![].into(), - ], - }; - let filter = build_filter( - None, + fn builds_filters() { + let transfer_topic = B256::from_str(TRANSFER_TOPIC).unwrap(); + let addr: Address = ADDRESS.parse().unwrap(); + let other_addr: Address = OTHER_ADDRESS.parse().unwrap(); + let addr_topic = Topic::from(addr); + let any = Topic::default; + + let from_block = Some(BlockNumberOrTag::from(1337)); + let to_block = Some(BlockNumberOrTag::Latest); + let (basic, _) = + build_filter(from_block, to_block, Some(vec![addr]), None, vec![]).unwrap(); + assert_eq!( + basic, + Filter { + block_option: FilterBlockOption::Range { from_block, to_block }, + address: addr.into(), + topics: Default::default(), + } + ); + + let cases: [(&str, &[&str], [Topic; 4]); 10] = [ + (TRANSFER_SIG, &[], [transfer_topic.into(), any(), any(), any()]), + (TRANSFER_SIG, &[ADDRESS], [transfer_topic.into(), addr_topic.clone(), any(), any()]), + (TRANSFER_SIG, &["", ADDRESS], [transfer_topic.into(), any(), addr_topic, any()]), + ( + TRANSFER_TOPIC, + &[TRANSFER_TOPIC], + [transfer_topic.into(), transfer_topic.into(), any(), any()], + ), + ( + TRANSFER_TOPIC, + &["", TRANSFER_TOPIC], + [transfer_topic.into(), any(), transfer_topic.into(), any()], + ), + ( + "event Owned(uint256 value, address indexed owner)", + &[ADDRESS], + [ + Event::parse("event Owned(uint256 value, address indexed owner)") + .unwrap() + .selector() + .into(), + addr.into(), + any(), + any(), + ], + ), + ( + "event Message(string indexed value)", + &["hello"], + [ + Event::parse("event Message(string indexed value)").unwrap().selector().into(), + keccak256("hello").into(), + any(), + any(), + ], + ), + ( + "Swap(address indexed from, address indexed to, uint256 value)", + &[], + [ + Event::parse( + "event Swap(address indexed from, address indexed to, uint256 value)", + ) + .unwrap() + .selector() + .into(), + any(), + any(), + any(), + ], + ), + ( + "event Anon(address indexed a, uint256 indexed b, uint256 c, address indexed d) anonymous", + &[ADDRESS, "7", ""], + [addr.into(), B256::with_last_byte(7).into(), any(), any()], + ), + ( + "event Anon(address indexed a, uint256 indexed b, uint256 indexed c, address indexed d) anonymous", + &[ADDRESS, "7", "", OTHER_ADDRESS], + [addr.into(), B256::with_last_byte(7).into(), any(), other_addr.into()], + ), + ]; + for (sig_or_topic, args, expected) in cases { + assert_eq!(filter(sig_or_topic, args).unwrap(), topics(expected), "{sig_or_topic}"); + } + + let too_many = filter( + "event Wide(address indexed a, uint256 indexed b, uint256 indexed c, uint256 indexed d)", + &[], + ) + .unwrap_err(); + assert!(too_many.to_string().contains("too many indexed inputs"), "{too_many}"); + + let (multiple, _) = build_filter( None, None, + Some(vec![Address::ZERO, addr]), Some(TRANSFER_TOPIC.to_string()), - vec![String::new(), TRANSFER_TOPIC.to_string()], + vec![], ) .unwrap(); - - assert_eq!(filter, expected) + assert_eq!( + multiple, + Filter { + address: vec![Address::ZERO, addr].into(), + topics: [transfer_topic.into(), any(), any(), any()], + ..Default::default() + } + ); } #[test] - fn test_build_filter_sig_with_mismatched_argument() { - let err = build_filter( - None, - None, - None, - Some(TRANSFER_SIG.to_string()), - vec!["1234".to_string()], - ) - .err() - .unwrap() - .to_string() - .to_lowercase(); + fn rejects_invalid_arguments_and_topics() { + let cases = [ + (TRANSFER_SIG, &["1234"][..], "parser error:\n1234\n^\ninvalid string length"), + ("asdasdasd", &[], "odd number of digits"), + (ADDRESS, &[], "invalid string length"), + (TRANSFER_TOPIC, &["1234"], "invalid string length"), + ]; + for (sig_or_topic, args, expected) in cases { + let err = filter(sig_or_topic, args).unwrap_err().to_string().to_lowercase(); + assert_eq!(err, expected, "{sig_or_topic}"); + } + } +} - assert_eq!(err, "parser error:\n1234\n^\ninvalid string length"); +#[cfg(test)] +mod logs_bisecting { + use super::*; + use alloy_json_rpc::{RequestPacket, ResponsePacket, SerializedRequest}; + use alloy_provider::ProviderBuilder; + use alloy_rpc_client::RpcClient; + use alloy_transport::{ + TransportError, TransportFut, + mock::{Asserter, MockTransport}, + }; + use std::{ + sync::{Arc, Mutex}, + task::{Context, Poll}, + }; + use tower::Service; + + fn log_at(block: u64) -> Log { + Log { block_number: Some(block), ..Default::default() } } - #[test] - fn test_build_filter_with_invalid_sig_or_topic() { - let err = build_filter(None, None, None, Some("asdasdasd".to_string()), vec![]) - .err() - .unwrap() - .to_string() - .to_lowercase(); + /// Mock transport that records the `eth_getLogs` `[fromBlock, toBlock]` ranges it is asked for + /// while delegating the actual responses to a FIFO [`Asserter`]. + #[derive(Clone)] + struct RecordingTransport { + inner: MockTransport, + ranges: Arc>>, + } + + impl RecordingTransport { + fn new(asserter: Asserter) -> Self { + Self { inner: MockTransport::new(asserter), ranges: Arc::new(Mutex::new(Vec::new())) } + } - assert_eq!(err, "odd number of digits"); + fn record(&self, req: &SerializedRequest) { + if req.method() != "eth_getLogs" { + return; + } + let Some(params) = req.params() else { return }; + let Ok(value) = serde_json::from_str::(params.get()) else { return }; + let Some(filter) = value.get(0) else { return }; + let field = + |name| filter.get(name).and_then(|v| v.as_str()).unwrap_or_default().to_string(); + self.ranges.lock().unwrap().push((field("fromBlock"), field("toBlock"))); + } } - #[test] - fn test_build_filter_with_invalid_sig_or_topic_hex() { - let err = build_filter(None, None, None, Some(ADDRESS.to_string()), vec![]) - .err() - .unwrap() - .to_string() - .to_lowercase(); + impl Service for RecordingTransport { + type Response = ResponsePacket; + type Error = TransportError; + type Future = TransportFut<'static>; - assert_eq!(err, "invalid string length"); + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, req: RequestPacket) -> Self::Future { + match &req { + RequestPacket::Single(req) => self.record(req), + RequestPacket::Batch(reqs) => reqs.iter().for_each(|req| self.record(req)), + } + self.inner.call(req) + } } - #[test] - fn test_build_filter_with_invalid_topic() { - let err = build_filter( - None, - None, - None, - Some(TRANSFER_TOPIC.to_string()), - vec!["1234".to_string()], - ) - .err() - .unwrap() - .to_string() - .to_lowercase(); + // A range-limit failure splits depth-first into [0,1]/[2,3] and aggregates in range order. + #[tokio::test] + async fn bisects_failed_range_and_aggregates_in_order() { + let asserter = Asserter::new(); + asserter.push_failure_msg("query returned more than 10000 results"); + asserter.push_success(&vec![log_at(0)]); + asserter.push_success(&vec![log_at(2)]); + + let transport = RecordingTransport::new(asserter); + let ranges = transport.ranges.clone(); + let provider = ProviderBuilder::<_, _, AnyNetwork>::default() + .connect_client(RpcClient::new(transport, true)); + + let logs = get_logs_bisecting(&provider, &Filter::new(), 0, 3).await.unwrap(); + let blocks: Vec<_> = logs.iter().map(|l| l.block_number).collect(); + assert_eq!(blocks, vec![Some(0), Some(2)]); + + // The original range fails, then bisection requests exactly the two halves in order. + let ranges = ranges.lock().unwrap(); + assert_eq!( + *ranges, + vec![ + ("0x0".to_string(), "0x3".to_string()), + ("0x0".to_string(), "0x1".to_string()), + ("0x2".to_string(), "0x3".to_string()), + ] + ); + } + + // A single-block failure can't be split, so the error is surfaced. + #[tokio::test] + async fn surfaces_single_block_failure() { + let asserter = Asserter::new(); + asserter.push_failure_msg("query returned more than 10000 results"); + + let provider = + ProviderBuilder::<_, _, AnyNetwork>::default().connect_mocked_client(asserter); + + let err = get_logs_bisecting(&provider, &Filter::new(), 5, 5).await.unwrap_err(); + assert!(err.to_string().contains("more than 10000 results"), "got: {err}"); + } + + // A non-range error fails after one request instead of bisecting. + #[tokio::test] + async fn does_not_bisect_non_range_errors() { + let asserter = Asserter::new(); + asserter.push_failure_msg("unauthorized: invalid api key"); + + let provider = + ProviderBuilder::<_, _, AnyNetwork>::default().connect_mocked_client(asserter); - assert_eq!(err, "invalid string length"); + let err = get_logs_bisecting(&provider, &Filter::new(), 0, 3).await.unwrap_err(); + assert!(err.to_string().contains("unauthorized"), "got: {err}"); } } diff --git a/crates/cast/src/cmd/miner.rs b/crates/cast/src/cmd/miner.rs new file mode 100644 index 0000000000000..346efb9a503a0 --- /dev/null +++ b/crates/cast/src/cmd/miner.rs @@ -0,0 +1,52 @@ +use alloy_primitives::B256; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; + +/// Mines a salt by iterating B256 values in parallel until `check` returns `Some`. +/// +/// Each of the `n_threads` threads starts at `salt + thread_index` and steps by `n_threads`, +/// ensuring non-overlapping coverage. Returns `None` only if all threads panicked. +pub(crate) fn mine_salt(salt: B256, n_threads: usize, check: F) -> Option +where + T: Send + 'static, + F: FnMut(B256) -> Option + Clone + Send + 'static, +{ + let found = Arc::new(AtomicBool::new(false)); + let mut handles = Vec::with_capacity(n_threads); + + for i in 0..n_threads { + let increment = n_threads; + let found = Arc::clone(&found); + let mut check = check.clone(); + + handles.push(std::thread::spawn(move || { + #[repr(C)] + struct B256Aligned(B256, [usize; 0]); + + let mut salt = B256Aligned(salt, []); + // SAFETY: `B256` is aligned to `usize`. + let salt_word = unsafe { + &mut *salt.0.as_mut_ptr().add(32 - usize::BITS as usize / 8).cast::() + }; + // Important: offset by thread index to avoid duplicate work across threads. + *salt_word = salt_word.wrapping_add(i); + + loop { + if found.load(Ordering::Relaxed) { + break None; + } + + if let Some(result) = check(salt.0) { + found.store(true, Ordering::Relaxed); + break Some(result); + } + + *salt_word = salt_word.wrapping_add(increment); + } + })); + } + + handles.into_iter().find_map(|h| h.join().ok().flatten()) +} diff --git a/crates/cast/src/cmd/mktx.rs b/crates/cast/src/cmd/mktx.rs index df71528ddecdd..926374d66fad4 100644 --- a/crates/cast/src/cmd/mktx.rs +++ b/crates/cast/src/cmd/mktx.rs @@ -1,16 +1,29 @@ -use crate::tx::{self, CastTxBuilder}; +use super::auth::{confirm_and_build, confirm_and_build_with_tempo_wallet}; +use crate::{ + tempo, + tx::{self, CastTxBuilder}, +}; +use alloy_consensus::{SignableTransaction, Signed}; +use alloy_eips::Encodable2718; use alloy_ens::NameOrAddress; -use alloy_network::{EthereumWallet, TransactionBuilder, eip2718::Encodable2718}; -use alloy_primitives::hex; +use alloy_network::{Ethereum, EthereumWallet, Network, NetworkTransactionBuilder}; +use alloy_primitives::{Address, hex}; use alloy_provider::Provider; -use alloy_signer::Signer; +use alloy_signer::{Signature, Signer}; use clap::Parser; -use eyre::{OptionExt, Result}; +use eyre::Result; use foundry_cli::{ + json::print_scalar, opts::{EthereumOpts, TransactionOpts}, - utils::{LoadConfig, get_provider}, + utils::{LoadConfig, resolve_lane}, }; +use foundry_common::{FoundryTransactionBuilder, provider::ProviderBuilder}; +use foundry_wallets::{TempoAccountsWallet, WalletSigner}; use std::{path::PathBuf, str::FromStr}; +use tempo_alloy::TempoNetwork; + +#[cfg(feature = "base")] +use base_common_network::Base; /// CLI arguments for `cast mktx`. #[derive(Debug, Parser)] @@ -25,6 +38,7 @@ pub struct MakeTxArgs { sig: Option, /// The arguments of the function to call. + #[arg(allow_negative_numbers = true)] args: Vec, #[command(subcommand)] @@ -33,6 +47,10 @@ pub struct MakeTxArgs { #[command(flatten)] tx: TransactionOpts, + /// Skip the EIP-7702 authorization disclosure confirmation. + #[arg(long)] + force: bool, + /// The path of blob data to be sent. #[arg( long, @@ -49,12 +67,21 @@ pub struct MakeTxArgs { /// Generate a raw RLP-encoded unsigned transaction. /// /// Relaxes the wallet requirement. - #[arg(long, requires = "from")] + #[arg(long)] raw_unsigned: bool, /// Call `eth_signTransaction` using the `--from` argument or $ETH_FROM as sender #[arg(long, requires = "from", conflicts_with = "raw_unsigned")] ethsign: bool, + + /// Generate a raw signed transaction using the provided 65-byte signature. + #[arg( + long, + value_name = "SIGNATURE", + requires = "from", + conflicts_with_all = ["raw_unsigned", "ethsign"] + )] + signature: Option, } #[derive(Debug, Parser)] @@ -69,15 +96,67 @@ pub enum MakeTxSubcommands { sig: Option, /// The constructor arguments. + #[arg(allow_negative_numbers = true)] args: Vec, }, } impl MakeTxArgs { pub async fn run(self) -> Result<()> { - let Self { to, mut sig, mut args, command, tx, path, eth, raw_unsigned, ethsign } = self; + if self.tx.tempo.sponsor_url.is_some() { + eyre::bail!( + "--sponsor-url is not supported by cast mktx; use --tempo.sponsor with \ + --tempo.sponsor-signer or --tempo.sponsor-sig" + ); + } + + let (network, signer, access_key) = + tempo::resolve_transaction_network_and_signer(&self.tx.tempo, &self.eth).await?; + if network.is_tempo() { + return self.run_generic::(signer, access_key).await; + } + + #[cfg(feature = "base")] + if network.is_base() { + super::validate_base_transaction_options(&self.tx)?; + return self.run_generic::(signer, None).await; + } + + self.run_generic::(signer, None).await + } + + async fn run_generic( + self, + pre_resolved_signer: Option, + pre_resolved_access_key: Option, + ) -> Result<()> + where + N::TxEnvelope: From>, + N::UnsignedTx: SignableTransaction, + N::TransactionRequest: FoundryTransactionBuilder, + { + let Self { + to, + mut sig, + mut args, + command, + mut tx, + force, + path, + eth, + raw_unsigned, + ethsign, + signature, + } = self; + let has_session = tx.tempo.session_id()?.is_some(); + + let print_sponsor_hash = tx.tempo.print_sponsor_hash; + let sponsor_fee_payer = tx.tempo.sponsor; + let expires_at = tx.tempo.resolve_expires(); + let tempo_sponsor = + if print_sponsor_hash { None } else { tx.tempo.sponsor_config().await? }; - let blob_data = if let Some(path) = path { Some(std::fs::read(path)?) } else { None }; + let blob_data = path.map(std::fs::read).transpose()?; let code = if let Some(MakeTxSubcommands::Create { code, @@ -93,50 +172,150 @@ impl MakeTxArgs { }; let config = eth.load_config()?; + let provider = ProviderBuilder::::from_config(&config)?.build()?; + // The provider is not consulted for fee tokens in `--curl` mode. + let fee_provider = (!config.eth_rpc_curl).then_some(&provider); - let provider = get_provider(&config)?; + // Populate `tx.tempo.nonce_key` from `--tempo.lane` before the options are cloned into + // the builder. + let resolved_lane = resolve_lane(&mut tx.tempo, &config.root)?; + let lane = resolved_lane.as_ref(); - let tx_builder = CastTxBuilder::new(&provider, tx, &config) + let tx_builder = CastTxBuilder::new(&provider, tx.clone(), &config) .await? .with_to(to) .await? .with_code_sig_and_args(code, sig, args) .await? .with_blob_data(blob_data)?; + let chain = tx_builder.chain(); + let (signer, access_key) = if has_session || pre_resolved_access_key.is_some() { + tempo::resolve_session_or_wallet_signer(&tx.tempo, ð.wallet, chain.id()).await? + } else { + (pre_resolved_signer, None) + }; + + // If --tempo.print-sponsor-hash was passed, build the tx, print the hash, and exit. + if print_sponsor_hash { + // Resolve the actual sender because the sponsor hash commits to it. Tempo access-key + // transactions must also be prepared before hashing so a pending authorization is + // included in the digest. + let (mut tx, from) = if let Some(access_key) = access_key { + let Some((tx, prepared)) = + confirm_and_build_with_tempo_wallet(tx_builder, &access_key, force, None) + .await? + else { + return Ok(()); + }; + (tx, prepared.account()) + } else { + let signer = match signer { + Some(signer) => signer, + None => eth.wallet.signer().await?, + }; + let Some(tx) = confirm_and_build(tx_builder, &signer, force, None, false).await? + else { + return Ok(()); + }; + (tx, signer.address()) + }; + let hash = tempo::sponsor_hash(fee_provider, &mut tx, from, sponsor_fee_payer).await?; + return print_scalar(format!("{hash:?}")); + } + + tempo::print_expires(expires_at)?; if raw_unsigned { - // Build unsigned raw tx - let from = eth.wallet.from.ok_or_eyre("missing `--from` address")?; - let raw_tx = tx_builder.build_unsigned_raw(from).await?; + // Without a sender the nonce cannot be fetched. + // See: + if eth.wallet.from.is_none() && tx.nonce.is_none() { + eyre::bail!( + "Missing required parameters for raw unsigned transaction. When --from is not provided, you must specify: --nonce" + ); + } + if tempo_sponsor.is_some() && eth.wallet.from.is_none() { + eyre::bail!( + "--tempo.sponsor requires --from for --raw-unsigned because the sponsor digest commits to the sender" + ); + } + + // Use zero address as placeholder for unsigned transactions + let from = eth.wallet.from.unwrap_or(Address::ZERO); + let Some(mut tx) = confirm_and_build(tx_builder, from, force, lane, false).await? + else { + return Ok(()); + }; + tempo::apply_fee_payment::(tempo_sponsor.as_ref(), fee_provider, &mut tx, from) + .await?; + return print_scalar(hex::encode_prefixed(tx.build_unsigned()?.encoded_for_signing())); + } + + if let Some(signature) = signature { + let signature = signature.normalized_s(); + let from = eth.wallet.from.expect("required by clap"); + let Some(mut tx) = confirm_and_build(tx_builder, from, force, lane, false).await? + else { + return Ok(()); + }; + tempo::apply_fee_payment::(tempo_sponsor.as_ref(), fee_provider, &mut tx, from) + .await?; - sh_println!("{raw_tx}")?; - return Ok(()); + let tx = tx.build_unsigned()?; + let recovered = signature.recover_address_from_prehash(&tx.signature_hash())?; + if recovered != from { + eyre::bail!( + "The provided signature recovers to {recovered}, which does not match the specified sender {from}" + ); + } + + let tx = N::TxEnvelope::from(tx.into_signed(signature)); + return print_scalar(hex::encode_prefixed(tx.encoded_2718())); } if ethsign { // Use "eth_signTransaction" to sign the transaction only works if the node/RPC has // unlocked accounts. - let (tx, _) = tx_builder.build(config.sender).await?; - let signed_tx = provider.sign_transaction(tx).await?; - - sh_println!("{signed_tx}")?; - return Ok(()); + let Some(mut tx) = + confirm_and_build(tx_builder, config.sender, force, lane, true).await? + else { + return Ok(()); + }; + tempo::apply_fee_payment::( + tempo_sponsor.as_ref(), + fee_provider, + &mut tx, + config.sender, + ) + .await?; + return print_scalar(provider.sign_transaction(tx).await?); } // Default to using the local signer. - // Get the signer from the wallet, and fail if it can't be constructed. - let signer = eth.wallet.signer().await?; - let from = signer.address(); - - tx::validate_from_address(eth.wallet.from, from)?; - - let (tx, _) = tx_builder.build(&signer).await?; - - let tx = tx.build(&EthereumWallet::new(signer)).await?; - - let signed_tx = hex::encode(tx.encoded_2718()); - sh_println!("0x{signed_tx}")?; + let signed_tx = if let Some(access_key) = access_key { + let Some((mut tx, prepared)) = + confirm_and_build_with_tempo_wallet(tx_builder, &access_key, force, lane).await? + else { + return Ok(()); + }; + tempo::apply_fee_payment::( + tempo_sponsor.as_ref(), + fee_provider, + &mut tx, + prepared.account(), + ) + .await?; + tx.sign_with_tempo_wallet(&prepared).await? + } else { + let (signer, from) = tx::resolve_send_signer(signer, ð).await?; + let Some(mut tx) = confirm_and_build(tx_builder, &signer, force, lane, false).await? + else { + return Ok(()); + }; + tempo::apply_fee_payment::(tempo_sponsor.as_ref(), fee_provider, &mut tx, from) + .await?; + tx.build(&EthereumWallet::new(signer)).await?.encoded_2718() + }; - Ok(()) + print_scalar(hex::encode_prefixed(signed_tx)) } } diff --git a/crates/cast/src/cmd/mod.rs b/crates/cast/src/cmd/mod.rs index 482cb77b4e343..938e934e372f6 100644 --- a/crates/cast/src/cmd/mod.rs +++ b/crates/cast/src/cmd/mod.rs @@ -5,22 +5,339 @@ //! implement `figment::Provider` which allows the subcommand to override the config's defaults, see //! [`foundry_config::Config`]. +use alloy_network::{AnyNetwork, Network}; +use alloy_primitives::{Address, Bytes, map::AddressHashMap}; +use alloy_provider::Provider; +use alloy_rpc_types::BlockId; +use eyre::Result; +use foundry_cli::{ + json::print_scalar, + opts::RpcOpts, + utils::{LoadConfig, get_provider, load_config_from_provider}, +}; +use foundry_common::{ + provider::{ProviderBuilder, RetryProvider}, + shell, +}; +use foundry_config::{Config, figment::Figment}; +use foundry_evm::{core::bytecode::InstIter, opts::EvmOpts}; +use foundry_evm_networks::NetworkVariant; +use futures::StreamExt; +use serde::Serialize; +use serde_json::Value; +use std::fmt::{Display, Write}; + +const MAX_CONCURRENT_RPC_REQUESTS: usize = 5; + +/// Loads Cast's config and applies its normalized network to the EVM options. +pub(crate) fn load_cast_config_and_evm_opts(figment: Figment) -> Result<(Box, EvmOpts)> { + let config = Box::new(load_config_from_provider(figment.clone())?); + let mut evm_opts = figment.extract::()?; + evm_opts.networks = config.networks; + Ok((config, evm_opts)) +} + +/// Loads the config for `rpc` and builds the default provider from it. +pub(crate) fn rpc_provider(rpc: &RpcOpts) -> Result { + get_provider(&rpc.load_config()?) +} + +/// Asks whether to continue after a warning, printing `Aborted.` when the user declines. +pub(crate) fn confirm_continue() -> Result { + let response: String = foundry_common::prompt!("\nContinue anyway? [y/N] ")?; + if matches!(response.trim(), "y" | "Y") { + return Ok(true); + } + sh_status!("Aborted.")?; + Ok(false) +} + +/// Prints `json` pretty-printed (un-enveloped) in JSON mode, `plain` otherwise. +pub(crate) fn print_json_or(json: Value, plain: impl Display) -> Result<()> { + if shell::is_json() { + sh_println!("{}", serde_json::to_string_pretty(&json)?)?; + } else { + sh_println!("{plain}")?; + } + Ok(()) +} + +/// Prints the primary result of a command: a JSON envelope in `--json` mode, otherwise a raw line +/// that bypasses the shell verbosity layer so `--quiet` does not suppress it. +pub(crate) fn print_result_line(value: impl Serialize + Display) -> Result<()> { + if shell::is_json() { + return print_scalar(value); + } + print_raw_line(value) +} + +/// Prints a raw line to stdout, bypassing the shell verbosity layer so `--quiet` does not +/// suppress it. +pub(crate) fn print_raw_line(value: impl Display) -> Result<()> { + let mut shell = shell::Shell::get(); + let out = shell.out(); + writeln!(out, "{value}")?; + out.flush()?; + Ok(()) +} + +/// Fetches the non-empty code of `addresses` at `block` with bounded concurrency, warning about +/// addresses whose code cannot be fetched. +pub(crate) async fn fetch_code_via_rpc>( + provider: &P, + addresses: impl IntoIterator, + block: BlockId, +) -> AddressHashMap { + let mut code_by_address = AddressHashMap::default(); + let mut requests = futures::stream::iter(addresses) + .map( + |address| async move { (address, provider.get_code_at(address).block_id(block).await) }, + ) + .buffer_unordered(MAX_CONCURRENT_RPC_REQUESTS); + while let Some((address, code)) = requests.next().await { + match code { + Ok(code) if !code.is_empty() => { + code_by_address.insert(address, code); + } + Ok(_) => {} + Err(err) => { + let _ = sh_warn!("Failed to fetch code for {address}: {err}"); + } + } + } + code_by_address +} + pub mod access_list; pub mod artifact; +mod auth; +pub mod b2e_payload; +pub mod bal; +pub mod batch_mktx; +pub mod batch_send; pub mod bind; pub mod call; +pub mod call_overrides; pub mod constructor_args; pub mod create2; pub mod creation_code; +#[cfg(any(feature = "base", feature = "optimism"))] pub mod da_estimate; +pub mod erc20; +pub mod erc4626; pub mod estimate; +pub mod events; pub mod find_block; pub mod interface; +pub mod keychain; pub mod logs; +pub(crate) mod miner; pub mod mktx; +pub mod receive_policy; pub mod rpc; pub mod run; +pub mod safe; pub mod send; pub mod storage; +pub mod storage_credits; +pub mod tempo; +pub(crate) mod tempo_policy_args; +pub mod tip20; +pub mod tip403; +pub mod trace; pub mod txpool; +pub mod vaddr; pub mod wallet; + +/// Validates a Tempo transaction requirement against an explicit execution family. +pub(crate) fn validate_tempo_network(config: &Config, requires_tempo: bool) -> Result<()> { + if requires_tempo && config.networks.has_network_selection() { + let network = config.networks.execution_network(); + eyre::ensure!( + network.is_tempo(), + "Tempo transaction options conflict with configured network `{}`", + config.networks.execution_profile_name() + ); + } + Ok(()) +} + +/// Resolves the RPC request family before typed transaction construction. +pub(crate) async fn resolve_transaction_network( + config: &Config, + requires_tempo: bool, +) -> Result { + validate_tempo_network(config, requires_tempo)?; + if requires_tempo { + return Ok(NetworkVariant::Tempo); + } + if config.networks.has_network_selection() { + return Ok(config.networks.execution_network()); + } + if let Some(chain) = config.chain { + return Ok(chain.id().into()); + } + if config.eth_rpc_curl { + return Ok(NetworkVariant::Ethereum); + } + + let provider = ProviderBuilder::::from_config(config)?.build()?; + Ok(provider.get_chain_id().await?.into()) +} + +pub(crate) fn disassemble(code: &[u8]) -> Result { + let mut output = String::new(); + for (pc, inst) in InstIter::new(code).with_pc() { + writeln!(output, "{pc:08x}: {inst}")?; + } + Ok(output) +} + +/// Rejects blob options before Base's transaction builder can discard them. +#[cfg(feature = "base")] +pub(crate) fn validate_base_transaction_options( + tx: &foundry_cli::opts::TransactionOpts, +) -> eyre::Result<()> { + eyre::ensure!( + !tx.blob && !tx.eip4844 && tx.blob_gas_price.is_none(), + "Base does not support blob transactions; remove --blob, --eip4844, and --blob-gas-price" + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(feature = "base")] + use foundry_config::Chain; + + #[tokio::test] + async fn transaction_network_respects_explicit_selection() { + for network in [NetworkVariant::Ethereum, NetworkVariant::Tempo] { + let config = + Config { networks: network.into(), eth_rpc_curl: true, ..Default::default() }; + assert_eq!(resolve_transaction_network(&config, false).await.unwrap(), network); + assert_eq!( + resolve_transaction_network(&config, true).await.is_ok(), + network.is_tempo() + ); + } + let config = Config { eth_rpc_curl: true, ..Default::default() }; + assert_eq!( + resolve_transaction_network(&config, true).await.unwrap(), + NetworkVariant::Tempo + ); + assert_eq!( + resolve_transaction_network(&config, false).await.unwrap(), + NetworkVariant::Ethereum + ); + + let config = Config { + networks: foundry_evm_networks::NetworkConfigs::with_celo(), + eth_rpc_curl: true, + ..Default::default() + }; + assert_eq!( + resolve_transaction_network(&config, false).await.unwrap(), + NetworkVariant::Ethereum + ); + assert_eq!( + resolve_transaction_network(&config, true).await.unwrap_err().to_string(), + "Tempo transaction options conflict with configured network `celo`" + ); + } + + #[cfg(feature = "monad")] + #[test] + fn normalized_hardfork_network_is_applied_to_evm_opts() { + let figment = Config::figment().merge(("hardfork", "monad:MonadNine")); + let (config, evm_opts) = load_cast_config_and_evm_opts(figment).unwrap(); + + assert!(config.networks.is_monad()); + assert!(evm_opts.networks.is_monad()); + } + + #[cfg(feature = "base")] + #[tokio::test] + async fn resolve_network_preserves_explicit_base() { + let config = Config { networks: NetworkVariant::Base.into(), ..Default::default() }; + assert_eq!( + resolve_transaction_network(&config, false).await.unwrap(), + NetworkVariant::Base + ); + assert_eq!( + resolve_transaction_network(&config, true).await.unwrap_err().to_string(), + "Tempo transaction options conflict with configured network `base`" + ); + } + + #[cfg(feature = "base")] + #[tokio::test] + async fn resolve_network_infers_base_from_chain_id() { + let config = Config { chain: Some(Chain::base_mainnet()), ..Default::default() }; + assert_eq!( + resolve_transaction_network(&config, false).await.unwrap(), + NetworkVariant::Base + ); + } + + #[cfg(all(any(feature = "base", feature = "optimism"), not(feature = "monad")))] + #[tokio::test] + async fn resolve_network_allows_rpc_without_local_evm() { + let config = Config { + chain: Some(foundry_config::Chain::from_named(alloy_chains::NamedChain::Monad)), + ..Default::default() + }; + assert_eq!( + resolve_transaction_network(&config, false).await.unwrap(), + NetworkVariant::Ethereum + ); + } + + #[cfg(feature = "base")] + #[tokio::test] + async fn resolve_network_preserves_config_over_chain_in_curl_mode() { + let config = Config { + networks: NetworkVariant::Base.into(), + chain: Some(foundry_config::Chain::from_id(31337)), + eth_rpc_curl: true, + ..Default::default() + }; + assert_eq!( + resolve_transaction_network(&config, false).await.unwrap(), + NetworkVariant::Base + ); + let config = Config { + networks: NetworkVariant::Ethereum.into(), + chain: Some(foundry_config::Chain::from_id(8453)), + ..config + }; + assert_eq!( + resolve_transaction_network(&config, false).await.unwrap(), + NetworkVariant::Ethereum + ); + } + + #[cfg(all(feature = "base", not(feature = "optimism")))] + #[tokio::test] + async fn resolve_network_allows_rpc_without_optimism() { + let config = + Config { chain: Some(foundry_config::Chain::from_id(10)), ..Default::default() }; + assert_eq!( + resolve_transaction_network(&config, false).await.unwrap(), + NetworkVariant::Ethereum + ); + } + + #[cfg(any(feature = "base", feature = "optimism"))] + #[tokio::test] + async fn resolve_network_still_defaults_unknown_chain_ids_to_ethereum() { + let config = + Config { chain: Some(foundry_config::Chain::from_id(u64::MAX)), ..Default::default() }; + assert_eq!( + resolve_transaction_network(&config, false).await.unwrap(), + NetworkVariant::Ethereum + ); + } +} diff --git a/crates/cast/src/cmd/receive_policy.rs b/crates/cast/src/cmd/receive_policy.rs new file mode 100644 index 0000000000000..999fb7d9b0acb --- /dev/null +++ b/crates/cast/src/cmd/receive_policy.rs @@ -0,0 +1,781 @@ +use crate::{ + cmd::{rpc_provider, tip20::send_tip20_transaction, tip403::policy_type_label}, + tempo::{ensure_tempo_precompile_active, print_payload, tempo_provider}, + tx::{SendTxOpts, TxParams}, +}; +use alloy_ens::NameOrAddress; +use alloy_primitives::{Address, Bytes, U256, keccak256}; +use alloy_sol_types::{SolCall, SolValue}; +use clap::{Parser, Subcommand}; +use eyre::{Result, WrapErr, ensure}; +use foundry_cli::opts::RpcOpts; +use foundry_common::provider::RetryProvider; +use foundry_evm::hardfork::TempoHardfork; +use foundry_evm_networks::TEMPO_PRECOMPILE_ADDRESSES; +use serde_json::{Value, json}; +use std::str::FromStr; +use tempo_alloy::TempoNetwork; +use tempo_contracts::precompiles::{ + ADDRESS_REGISTRY_ADDRESS, IAddressRegistry, IReceivePolicyGuard, ITIP403Registry, + RECEIVE_POLICY_GUARD_ADDRESS, TIP403_REGISTRY_ADDRESS, +}; +use tempo_primitives::TempoAddressExt; + +/// Account-level receive policy operations (Tempo). +#[derive(Debug, Parser, Clone)] +pub enum ReceivePolicySubcommand { + /// Set the caller's TIP-403 receive policy. + /// + /// Create the sender and token-filter policies referenced here with `cast tip403 create`. + Set { + /// Sender policy ID to evaluate for inbound transfer originators. + sender_policy_id: u64, + + /// Token filter policy ID to evaluate for inbound TIP-20 tokens. + token_filter_id: u64, + + /// Address authorized to recover held receipts. Defaults to originator recovery. + #[arg(long, value_name = "ADDRESS", default_value_t = Address::ZERO)] + recovery_authority: Address, + + /// Print the calldata and receive-policy warning without sending a transaction. + #[arg(long, visible_alias = "dry-run")] + preview: bool, + + /// Suppress the originator-recovery/system-sender warning. + #[arg(long)] + force: bool, + + #[command(flatten)] + send_tx: SendTxOpts, + + #[command(flatten)] + tx: TxParams, + }, + + /// Get an account's configured receive policy. + Get { + /// Account whose receive policy should be queried. + #[arg(value_parser = NameOrAddress::from_str)] + account: NameOrAddress, + + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Validate whether an inbound TIP-20 transfer or mint would be credited or held. + Validate { + /// TIP-20 token address. + #[arg(value_parser = NameOrAddress::from_str)] + token: NameOrAddress, + + /// Inbound transfer sender or mint originator. + #[arg(value_parser = NameOrAddress::from_str)] + sender: NameOrAddress, + + /// Intended recipient. + #[arg(value_parser = NameOrAddress::from_str)] + receiver: NameOrAddress, + + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Blocked receive-policy receipt utilities. + Receipt { + #[command(subcommand)] + command: ReceivePolicyReceiptSubcommand, + }, + + /// Claim held TIP-20 funds using a blocked receive-policy receipt. + Claim { + /// Desired release target. The guard decides onchain whether to resume or reroute. + #[arg(value_parser = NameOrAddress::from_str)] + to: NameOrAddress, + + /// ABI-encoded ReceivePolicyGuard claim receipt. + receipt: Bytes, + + #[command(flatten)] + send_tx: SendTxOpts, + + #[command(flatten)] + tx: TxParams, + }, +} + +#[derive(Debug, Subcommand, Clone)] +pub enum ReceivePolicyReceiptSubcommand { + /// Decode an ABI-encoded ReceivePolicyGuard claim receipt. + Decode { + /// ABI-encoded ReceivePolicyGuard claim receipt. + receipt: Bytes, + }, + + /// Query the held TIP-20 balance for a claim receipt. + Balance { + /// ABI-encoded ReceivePolicyGuard claim receipt. + receipt: Bytes, + + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Burn held funds for a blocked receipt when authorized by the token. + Burn { + /// ABI-encoded ReceivePolicyGuard claim receipt. + receipt: Bytes, + + #[command(flatten)] + send_tx: Box, + + #[command(flatten)] + tx: Box, + }, +} + +impl ReceivePolicySubcommand { + pub async fn run(self) -> Result<()> { + match self { + Self::Set { + sender_policy_id, + token_filter_id, + recovery_authority, + preview, + force, + send_tx, + tx, + } => { + set( + sender_policy_id, + token_filter_id, + recovery_authority, + preview, + force, + send_tx, + tx, + ) + .await? + } + Self::Get { account, rpc } => get(account, rpc).await?, + Self::Validate { token, sender, receiver, rpc } => { + validate(token, sender, receiver, rpc).await? + } + Self::Receipt { command } => match command { + ReceivePolicyReceiptSubcommand::Decode { receipt } => decode_receipt(receipt)?, + ReceivePolicyReceiptSubcommand::Balance { receipt, rpc } => { + receipt_balance(receipt, rpc).await? + } + ReceivePolicyReceiptSubcommand::Burn { receipt, send_tx, tx } => { + burn_receipt(receipt, *send_tx, *tx).await? + } + }, + Self::Claim { to, receipt, send_tx, tx } => claim(to, receipt, send_tx, tx).await?, + } + + Ok(()) + } +} + +async fn set( + sender_policy_id: u64, + token_filter_id: u64, + recovery_authority: Address, + preview: bool, + force: bool, + send_tx: SendTxOpts, + tx: TxParams, +) -> Result<()> { + // Reject authorities that can never satisfy `ReceivePolicyGuard.claim()` before doing + // anything else; `--force` only suppresses the softer originator-recovery warning below. + if let Some(message) = invalid_recovery_authority_message(recovery_authority) { + eyre::bail!("{message}"); + } + + let warning = if force { + None + } else { + recovery_warning(sender_policy_id, recovery_authority, &send_tx.eth.rpc).await? + }; + + let calldata = ITIP403Registry::setReceivePolicyCall { + senderPolicyId: sender_policy_id, + tokenFilterId: token_filter_id, + recoveryAuthority: recovery_authority, + } + .abi_encode(); + + if preview { + let payload = json!({ + "action": "set_receive_policy", + "registry": format!("{TIP403_REGISTRY_ADDRESS}"), + "sender_policy_id": sender_policy_id, + "token_filter_id": token_filter_id, + "recovery_authority": format!("{recovery_authority}"), + "recovery_mode": recovery_mode(recovery_authority), + "calldata": Bytes::from(calldata), + "warning": warning, + }); + return print_payload(payload, |payload| { + sh_println!( + "Registry: {TIP403_REGISTRY_ADDRESS}\n\ + Sender policy ID: {sender_policy_id}\n\ + Token filter ID: {token_filter_id}\n\ + Recovery authority: {recovery_authority}\n\ + Recovery mode: {}\n\ + Calldata: {}", + recovery_mode(recovery_authority), + payload["calldata"].as_str().unwrap_or_default(), + )?; + if let Some(warning) = payload["warning"].as_str() { + sh_warn!("{warning}")?; + } + Ok(()) + }); + } + + if let Some(warning) = warning.as_deref() { + sh_warn!("{warning}")?; + } + + send_tip20_transaction(TIP403_REGISTRY_ADDRESS, calldata, send_tx, tx).await +} + +async fn get(account: NameOrAddress, rpc: RpcOpts) -> Result<()> { + let provider = rpc_provider(&rpc)?; + let account = account.resolve(&provider).await?; + let registry = ITIP403Registry::new(TIP403_REGISTRY_ADDRESS, provider); + let policy = registry.receivePolicy(account).call().await?; + + let payload = json!({ + "account": format!("{account}"), + "has_receive_policy": policy.hasReceivePolicy, + "sender_policy_id": policy.senderPolicyId, + "sender_policy_type": policy_type_label(policy.senderPolicyType), + "token_filter_id": policy.tokenFilterId, + "token_filter_type": policy_type_label(policy.tokenFilterType), + "recovery_authority": format!("{}", policy.recoveryAuthority), + "recovery_mode": recovery_mode(policy.recoveryAuthority), + }); + print_payload(payload, |payload| { + sh_println!( + "Account: {}\n\ + Has receive policy: {}\n\ + Sender policy ID: {}\n\ + Sender policy type: {}\n\ + Token filter ID: {}\n\ + Token filter type: {}\n\ + Recovery authority: {}\n\ + Recovery mode: {}", + payload["account"].as_str().unwrap_or_default(), + payload["has_receive_policy"].as_bool().unwrap_or_default(), + payload["sender_policy_id"], + payload["sender_policy_type"].as_str().unwrap_or_default(), + payload["token_filter_id"], + payload["token_filter_type"].as_str().unwrap_or_default(), + payload["recovery_authority"].as_str().unwrap_or_default(), + payload["recovery_mode"].as_str().unwrap_or_default(), + ) + }) +} + +async fn validate( + token: NameOrAddress, + sender: NameOrAddress, + receiver: NameOrAddress, + rpc: RpcOpts, +) -> Result<()> { + let provider = rpc_provider(&rpc)?; + let token = token.resolve(&provider).await?; + let sender = sender.resolve(&provider).await?; + let receiver = receiver.resolve(&provider).await?; + let effective_receiver = IAddressRegistry::new(ADDRESS_REGISTRY_ADDRESS, &provider) + .resolveRecipient(receiver) + .call() + .await?; + let registry = ITIP403Registry::new(TIP403_REGISTRY_ADDRESS, provider); + let result = registry.validateReceivePolicy(token, sender, effective_receiver).call().await?; + let delivery_state = if result.authorized { "credited" } else { "held" }; + + let payload = validate_payload( + token, + sender, + receiver, + effective_receiver, + result.authorized, + result.blockedReason, + delivery_state, + ); + print_payload(payload, |payload| { + sh_println!( + "Token: {}\n\ + Sender: {}\n\ + Receiver: {}\n\ + Effective recv: {}\n\ + Authorized: {}\n\ + Blocked reason: {}\n\ + Delivery state: {}", + payload["token"].as_str().unwrap_or_default(), + payload["sender"].as_str().unwrap_or_default(), + payload["receiver"].as_str().unwrap_or_default(), + payload["effective_receiver"].as_str().unwrap_or_default(), + payload["authorized"].as_bool().unwrap_or_default(), + payload["blocked_reason"].as_str().unwrap_or_default(), + payload["delivery_state"].as_str().unwrap_or_default(), + ) + }) +} + +fn validate_payload( + token: Address, + sender: Address, + receiver: Address, + effective_receiver: Address, + authorized: bool, + blocked_reason_value: ITIP403Registry::BlockedReason, + delivery_state: &str, +) -> Value { + json!({ + "token": format!("{token}"), + "sender": format!("{sender}"), + "receiver": format!("{receiver}"), + "effective_receiver": format!("{effective_receiver}"), + "receiver_was_resolved": receiver != effective_receiver, + "authorized": authorized, + "blocked_reason": blocked_reason(blocked_reason_value as u8), + "delivery_state": delivery_state, + }) +} + +fn decode_receipt(receipt: Bytes) -> Result<()> { + let decoded = decode_claim_receipt(&receipt)?; + let payload = receipt_payload(&receipt, &decoded, None); + print_payload(payload, |payload| { + print_decoded_receipt(payload)?; + print_claim_hint(payload) + }) +} + +async fn receipt_balance(receipt: Bytes, rpc: RpcOpts) -> Result<()> { + let provider = rpc_provider(&rpc)?; + let guard = IReceivePolicyGuard::new(RECEIVE_POLICY_GUARD_ADDRESS, provider); + let amount = guard.balanceOf(receipt.clone()).call().await?; + let decoded = decode_claim_receipt(&receipt)?; + let payload = receipt_payload(&receipt, &decoded, Some(amount)); + print_payload(payload, |payload| { + print_decoded_receipt(payload)?; + sh_println!("Held balance: {}", payload["held_balance"].as_str().unwrap_or_default()) + }) +} + +/// Checks the receipt and that the RPC is T6+: the ReceivePolicyGuard precompile only has code from +/// T6 onwards, so a pre-T6 call would succeed as a silent no-op instead of reverting. +async fn guard_provider( + receipt: &Bytes, + send_tx: &SendTxOpts, + command: &str, +) -> Result> { + decode_claim_receipt(receipt)?; + let (_, provider) = tempo_provider(&send_tx.eth.rpc)?; + ensure_tempo_precompile_active( + &provider, + TempoHardfork::T6, + RECEIVE_POLICY_GUARD_ADDRESS, + &format!("{command} requires a Tempo T6-capable ReceivePolicy RPC"), + ) + .await?; + Ok(provider) +} + +async fn burn_receipt(receipt: Bytes, send_tx: SendTxOpts, tx: TxParams) -> Result<()> { + guard_provider(&receipt, &send_tx, "cast receive-policy receipt burn").await?; + let data = IReceivePolicyGuard::burnBlockedReceiptCall { receipt }.abi_encode(); + send_tip20_transaction(RECEIVE_POLICY_GUARD_ADDRESS, data, send_tx, tx).await +} + +async fn claim(to: NameOrAddress, receipt: Bytes, send_tx: SendTxOpts, tx: TxParams) -> Result<()> { + let provider = guard_provider(&receipt, &send_tx, "cast receive-policy claim").await?; + let to = to.resolve(&provider).await?; + let data = IReceivePolicyGuard::claimCall { to, receipt }.abi_encode(); + send_tip20_transaction(RECEIVE_POLICY_GUARD_ADDRESS, data, send_tx, tx).await +} + +async fn recovery_warning( + sender_policy_id: u64, + recovery_authority: Address, + rpc: &RpcOpts, +) -> Result> { + if !recovery_authority.is_zero() { + return Ok(None); + } + + let provider = rpc_provider(rpc)?; + let registry = ITIP403Registry::new(TIP403_REGISTRY_ADDRESS, provider); + let mut blocked = Vec::new(); + for address in TEMPO_PRECOMPILE_ADDRESSES { + if !registry.isAuthorizedSender(sender_policy_id, *address).call().await.unwrap_or(true) { + blocked.push(*address); + } + } + + if blocked.is_empty() { + return Ok(None); + } + + Ok(Some(format!( + "originator recovery is enabled because recovery authority is 0x0, but sender policy \ + {sender_policy_id} blocks {} Tempo system/precompile sender(s): {}. Receipts created \ + for those senders may not be claimable by a user. Choose receiver or third-party \ + recovery authority when blocking system senders, or pass --force if this is intentional.", + blocked.len(), + blocked.iter().map(Address::to_string).collect::>().join(", ") + ))) +} + +/// Returns an error message when `recovery_authority` could never pass +/// `ReceivePolicyGuard.claim()` and would leave held receipts unclaimable. +/// +/// `address(0)` is the originator-recovery sentinel and a receiver's own address is valid +/// (receiver recovery), so those pass. Virtual and TIP-20 addresses are always rejected. Fixed +/// system precompiles are rejected conservatively against the full known list (a superset of the +/// registry's spec-aware check), since a not-yet-active precompile is never a sound authority and +/// becomes unclaimable once it activates. +fn invalid_recovery_authority_message(recovery_authority: Address) -> Option { + if recovery_authority.is_zero() { + return None; + } + if recovery_authority.is_virtual() { + return Some("recovery authority cannot be a TIP-1022 virtual address".to_string()); + } + if recovery_authority.is_tip20() { + return Some("recovery authority cannot be a TIP-20 token address".to_string()); + } + if TEMPO_PRECOMPILE_ADDRESSES.contains(&recovery_authority) { + return Some(format!( + "recovery authority cannot be a fixed Tempo system precompile: {recovery_authority}" + )); + } + None +} + +fn decode_claim_receipt(receipt: &Bytes) -> Result { + let decoded = IReceivePolicyGuard::ClaimReceiptV1::abi_decode(receipt) + .wrap_err("invalid ReceivePolicyGuard claim receipt")?; + + ensure!( + decoded.version == 1, + "unsupported ReceivePolicyGuard claim receipt version {}", + decoded.version + ); + ensure!(!decoded.token.is_zero(), "ReceivePolicyGuard claim receipt token is zero"); + ensure!( + decoded.recipient != RECEIVE_POLICY_GUARD_ADDRESS, + "ReceivePolicyGuard claim receipt recipient cannot be the guard precompile" + ); + ensure!( + decoded.blockedReason == ITIP403Registry::BlockedReason::TOKEN_FILTER as u8 + || decoded.blockedReason == ITIP403Registry::BlockedReason::RECEIVE_POLICY as u8, + "ReceivePolicyGuard claim receipt blocked reason is not claimable" + ); + ensure!( + matches!( + decoded.kind, + IReceivePolicyGuard::InboundKind::TRANSFER | IReceivePolicyGuard::InboundKind::MINT + ), + "ReceivePolicyGuard claim receipt inbound kind is unknown" + ); + + Ok(decoded) +} + +fn receipt_payload( + receipt: &Bytes, + decoded: &IReceivePolicyGuard::ClaimReceiptV1, + amount: Option, +) -> Value { + let receipt_key = keccak256(receipt); + let delivery_state = match amount { + Some(amount) if amount > U256::ZERO => "held", + Some(_) => "not_held", + None => "unknown", + }; + let mut payload = json!({ + "receipt": format!("{receipt}"), + "receipt_key": format!("{receipt_key}"), + "version": decoded.version, + "token": format!("{}", decoded.token), + "recovery_authority": format!("{}", decoded.recoveryAuthority), + "recovery_mode": recovery_mode(decoded.recoveryAuthority), + "originator": format!("{}", decoded.originator), + "recipient": format!("{}", decoded.recipient), + "recipient_is_virtual": decoded.recipient.is_virtual(), + "claim_target": if decoded.recipient.is_virtual() || decoded.recoveryAuthority.is_zero() { + Value::Null + } else { + json!(format!("{}", decoded.recipient)) + }, + "blocked_at": decoded.blockedAt, + "blocked_nonce": decoded.blockedNonce, + "blocked_reason": blocked_reason(decoded.blockedReason), + "kind": inbound_kind(decoded.kind), + "memo": format!("{}", decoded.memo), + "delivery_state": delivery_state, + }); + if let Some(amount) = amount { + payload["held_balance"] = json!(amount.to_string()); + } + payload +} + +fn print_decoded_receipt(payload: &Value) -> Result<()> { + sh_println!( + "Receipt key: {}\n\ + Token: {}\n\ + Recovery authority: {}\n\ + Recovery mode: {}\n\ + Originator: {}\n\ + Recipient: {}\n\ + Blocked at: {}\n\ + Blocked nonce: {}\n\ + Blocked reason: {}\n\ + Kind: {}\n\ + Memo: {}\n\ + Delivery state: {}", + payload["receipt_key"].as_str().unwrap_or_default(), + payload["token"].as_str().unwrap_or_default(), + payload["recovery_authority"].as_str().unwrap_or_default(), + payload["recovery_mode"].as_str().unwrap_or_default(), + payload["originator"].as_str().unwrap_or_default(), + payload["recipient"].as_str().unwrap_or_default(), + payload["blocked_at"], + payload["blocked_nonce"], + payload["blocked_reason"].as_str().unwrap_or_default(), + payload["kind"].as_str().unwrap_or_default(), + payload["memo"].as_str().unwrap_or_default(), + payload["delivery_state"].as_str().unwrap_or_default(), + ) +} + +fn print_claim_hint(payload: &Value) -> Result<()> { + let recipient = payload["recipient"].as_str().unwrap_or_default(); + let receipt = payload["receipt"].as_str().unwrap_or_default(); + if payload["recovery_mode"].as_str() == Some("originator") { + sh_println!( + "\nClaim target: originator recovery reroutes funds, so do not default to the blocked recipient. Claim to an address that can receive the token:\n cast receive-policy claim {receipt}" + ) + } else if payload["recipient_is_virtual"].as_bool().unwrap_or_default() { + sh_println!( + "\nClaim target: recipient is a virtual address; resolve it first with:\n cast vaddr resolve {recipient}\nThen claim to the registered master address:\n cast receive-policy claim {receipt}" + ) + } else { + sh_println!("\nClaim path: cast receive-policy claim {recipient} {receipt}") + } +} + +fn recovery_mode(recovery_authority: Address) -> &'static str { + if recovery_authority.is_zero() { "originator" } else { "authority" } +} + +/// Labels a `BlockedReason` discriminant; receipts carry it as a raw `u8`. +const fn blocked_reason(reason: u8) -> &'static str { + match reason { + 0 => "none", + 1 => "token_filter", + 2 => "receive_policy", + _ => "unknown", + } +} + +const fn inbound_kind(kind: IReceivePolicyGuard::InboundKind) -> &'static str { + match kind { + IReceivePolicyGuard::InboundKind::TRANSFER => "transfer", + IReceivePolicyGuard::InboundKind::MINT => "mint", + _ => "unknown", + } +} + +#[cfg(test)] +mod tests { + + use super::*; + use alloy_primitives::{B256, address}; + use tempo_primitives::{MasterId, UserTag}; + + fn sample_receipt() -> Bytes { + IReceivePolicyGuard::ClaimReceiptV1::new( + Address::with_last_byte(0x10), + Address::with_last_byte(0x20), + Address::with_last_byte(0x30), + Address::with_last_byte(0x40), + 1_780_000_000, + 7, + ITIP403Registry::BlockedReason::RECEIVE_POLICY as u8, + IReceivePolicyGuard::InboundKind::TRANSFER, + B256::with_last_byte(0x42), + ) + .abi_encode() + .into() + } + + #[test] + fn decodes_guard_claim_receipt() { + let receipt = sample_receipt(); + let decoded = decode_claim_receipt(&receipt).unwrap(); + assert_eq!(decoded.version, 1); + assert_eq!(decoded.token, Address::with_last_byte(0x10)); + assert_eq!(decoded.recoveryAuthority, Address::with_last_byte(0x20)); + assert_eq!(decoded.originator, Address::with_last_byte(0x30)); + assert_eq!(decoded.recipient, Address::with_last_byte(0x40)); + assert_eq!(decoded.blockedNonce, 7); + assert_eq!(decoded.kind, IReceivePolicyGuard::InboundKind::TRANSFER); + } + + #[test] + fn rejects_invalid_guard_claim_receipt() { + let err = decode_claim_receipt(&Bytes::from_static(&[0xde, 0xad])).unwrap_err(); + assert!(err.to_string().contains("invalid ReceivePolicyGuard claim receipt")); + } + + #[test] + fn rejects_semantically_invalid_guard_claim_receipts() { + let receipt = sample_receipt(); + let decoded = IReceivePolicyGuard::ClaimReceiptV1::abi_decode(&receipt).unwrap(); + + let mut bad_version = decoded.clone(); + bad_version.version = 2; + let err = decode_claim_receipt(&bad_version.abi_encode().into()).unwrap_err(); + assert!(err.to_string().contains("unsupported ReceivePolicyGuard claim receipt version")); + + let mut bad_token = decoded.clone(); + bad_token.token = Address::ZERO; + let err = decode_claim_receipt(&bad_token.abi_encode().into()).unwrap_err(); + assert!(err.to_string().contains("token is zero")); + + let mut bad_recipient = decoded.clone(); + bad_recipient.recipient = RECEIVE_POLICY_GUARD_ADDRESS; + let err = decode_claim_receipt(&bad_recipient.abi_encode().into()).unwrap_err(); + assert!(err.to_string().contains("recipient cannot be the guard precompile")); + + let mut bad_reason = decoded; + bad_reason.blockedReason = ITIP403Registry::BlockedReason::NONE as u8; + let err = decode_claim_receipt(&bad_reason.abi_encode().into()).unwrap_err(); + assert!(err.to_string().contains("blocked reason is not claimable")); + } + + #[test] + fn validate_payload_records_effective_receiver() { + let receiver = Address::new_virtual( + MasterId::from([0x12, 0x34, 0x56, 0x78]), + UserTag::from([0xab, 0xcd, 0xef, 0x01, 0x23, 0x45]), + ); + let effective_receiver = Address::with_last_byte(0x40); + + let payload = validate_payload( + Address::with_last_byte(0x10), + Address::with_last_byte(0x30), + receiver, + effective_receiver, + false, + ITIP403Registry::BlockedReason::RECEIVE_POLICY, + "held", + ); + + assert_eq!(payload["receiver"], format!("{receiver}")); + assert_eq!(payload["effective_receiver"], format!("{effective_receiver}")); + assert_eq!(payload["receiver_was_resolved"], true); + assert_eq!(payload["authorized"], false); + assert_eq!(payload["blocked_reason"], "receive_policy"); + assert_eq!(payload["delivery_state"], "held"); + } + + #[test] + fn receipt_payload_preserves_delivery_state_confidence() { + let receipt = sample_receipt(); + let decoded = decode_claim_receipt(&receipt).unwrap(); + + let unknown = receipt_payload(&receipt, &decoded, None); + assert_eq!(unknown["delivery_state"], "unknown"); + + let held = receipt_payload(&receipt, &decoded, Some(U256::ONE)); + assert_eq!(held["delivery_state"], "held"); + assert_eq!(held["blocked_reason"], "receive_policy"); + assert_eq!(held["kind"], "transfer"); + assert_eq!(held["held_balance"], "1"); + assert_eq!(held["recipient_is_virtual"], false); + assert_eq!(held["claim_target"], format!("{}", decoded.recipient)); + + let not_held = receipt_payload(&receipt, &decoded, Some(U256::ZERO)); + assert_eq!(not_held["delivery_state"], "not_held"); + assert_eq!(not_held["held_balance"], "0"); + } + + #[test] + fn virtual_receipt_recipient_requires_resolved_claim_target() { + let receipt = sample_receipt(); + let mut decoded = decode_claim_receipt(&receipt).unwrap(); + decoded.recipient = Address::new_virtual( + MasterId::from([0x12, 0x34, 0x56, 0x78]), + UserTag::from([0xab, 0xcd, 0xef, 0x01, 0x23, 0x45]), + ); + + let payload = receipt_payload(&receipt, &decoded, None); + assert_eq!(payload["recipient"], format!("{}", decoded.recipient)); + assert_eq!(payload["recipient_is_virtual"], true); + assert_eq!(payload["claim_target"], Value::Null); + } + + #[test] + fn originator_recovery_receipt_requires_explicit_claim_target() { + let receipt = sample_receipt(); + let mut decoded = decode_claim_receipt(&receipt).unwrap(); + decoded.recoveryAuthority = Address::ZERO; + + let payload = receipt_payload(&receipt, &decoded, None); + assert_eq!(payload["recovery_mode"], "originator"); + assert_eq!(payload["recipient_is_virtual"], false); + assert_eq!(payload["claim_target"], Value::Null); + } + + #[test] + fn originator_recovery_takes_precedence_over_virtual_recipient() { + let receipt = sample_receipt(); + let mut decoded = decode_claim_receipt(&receipt).unwrap(); + decoded.recoveryAuthority = Address::ZERO; + decoded.recipient = Address::new_virtual( + MasterId::from([0x12, 0x34, 0x56, 0x78]), + UserTag::from([0xab, 0xcd, 0xef, 0x01, 0x23, 0x45]), + ); + + let payload = receipt_payload(&receipt, &decoded, None); + assert_eq!(payload["recovery_mode"], "originator"); + assert_eq!(payload["recipient_is_virtual"], true); + assert_eq!(payload["claim_target"], Value::Null); + } + + #[test] + fn rejects_unclaimable_recovery_authorities() { + // Originator recovery and a plain EOA authority are valid. + assert_eq!(invalid_recovery_authority_message(Address::ZERO), None); + assert_eq!(invalid_recovery_authority_message(Address::repeat_byte(0x11)), None); + + // Every fixed Tempo system precompile is unclaimable. + for authority in TEMPO_PRECOMPILE_ADDRESSES { + let err = invalid_recovery_authority_message(*authority).unwrap(); + assert!(err.contains("fixed Tempo system precompile")); + } + + // TIP-20 token and TIP-1022 virtual addresses are unclaimable too. + let err = invalid_recovery_authority_message(address!( + "20c0000000000000000000000000000000000001" + )) + .unwrap(); + assert!(err.contains("TIP-20 token address")); + + let virtual_address = Address::new_virtual( + MasterId::from([0x12, 0x34, 0x56, 0x78]), + UserTag::from([0xab, 0xcd, 0xef, 0x01, 0x23, 0x45]), + ); + let err = invalid_recovery_authority_message(virtual_address).unwrap(); + assert!(err.contains("TIP-1022 virtual address")); + } +} diff --git a/crates/cast/src/cmd/rpc.rs b/crates/cast/src/cmd/rpc.rs index 2bb309c2b2209..439cede48f246 100644 --- a/crates/cast/src/cmd/rpc.rs +++ b/crates/cast/src/cmd/rpc.rs @@ -1,8 +1,7 @@ -use crate::Cast; +use alloy_provider::Provider; use clap::Parser; use eyre::Result; -use foundry_cli::{opts::RpcOpts, utils, utils::LoadConfig}; -use foundry_common::shell; +use foundry_cli::{json::print_json_value_or_scalar, opts::RpcOpts, utils, utils::LoadConfig}; use itertools::Itertools; /// CLI arguments for `cast rpc`. @@ -36,31 +35,25 @@ pub struct RpcArgs { impl RpcArgs { pub async fn run(self) -> Result<()> { let Self { raw, method, params, rpc } = self; - let config = rpc.load_config()?; - let provider = utils::get_provider(&config)?; - let params = if raw { - if params.is_empty() { - serde_json::Deserializer::from_reader(std::io::stdin()) - .into_iter() - .next() - .transpose()? - .ok_or_else(|| eyre::format_err!("Empty JSON parameters"))? - } else { - value_or_string(params.into_iter().join(" ")) - } - } else { + let params = if !raw { serde_json::Value::Array(params.into_iter().map(value_or_string).collect()) - }; - let result = Cast::new(provider).rpc(&method, params).await?; - if shell::is_json() { - let result: serde_json::Value = serde_json::from_str(&result)?; - sh_println!("{}", serde_json::to_string_pretty(&result)?)?; + } else if params.is_empty() { + serde_json::Deserializer::from_reader(std::io::stdin()) + .into_iter() + .next() + .transpose()? + .ok_or_else(|| eyre::format_err!("Empty JSON parameters"))? } else { - sh_println!("{}", result)?; - } - Ok(()) + value_or_string(params.into_iter().join(" ")) + }; + + let result = utils::get_provider(&config)? + .raw_request::<_, serde_json::Value>(method.into(), params) + .await?; + let result = serde_json::to_string(&result)?; + print_json_value_or_scalar(result) } } diff --git a/crates/cast/src/cmd/run.rs b/crates/cast/src/cmd/run.rs index 412dd970a89f1..06f5c1d20bf5b 100644 --- a/crates/cast/src/cmd/run.rs +++ b/crates/cast/src/cmd/run.rs @@ -1,36 +1,86 @@ -use alloy_consensus::Transaction; -use alloy_network::{AnyNetwork, TransactionResponse}; +use super::{MAX_CONCURRENT_RPC_REQUESTS, fetch_code_via_rpc}; +use crate::{ + debug::{ensure_remote_trace_context_unchanged, handle_traces, resolve_remote_trace_hardfork}, + evm_version::probe_evm_version, + rpc_trace::{ + call_frame_to_arena, call_tracer_config, is_method_not_found_error, is_missing_state_error, + is_missing_state_message, + }, + traces::TraceKind, + utils::{ + apply_chain_and_block_specific_env_changes_for_chain, + apply_chain_specific_tx_replay_env_changes_for_chain, block_env_from_header, + }, +}; +use alloy_consensus::{BlockHeader, Transaction, transaction::SignerRecoverable}; +use alloy_eips::{BlockNumHash, eip7928::compute_block_access_list_hash}; +use alloy_network::{ + AnyNetwork, AnyRpcBlock, AnyRpcTransaction, AnyTransactionReceipt, AnyTxEnvelope, + BlockResponse, Network, ReceiptResponse, TransactionResponse, primitives::HeaderResponse, +}; use alloy_primitives::{ - Address, Bytes, U256, - map::{HashMap, HashSet}, + Address, B256, Bytes, U256, + map::{AddressHashMap, AddressSet, B256HashMap}, +}; +use alloy_provider::{Provider, ext::DebugApi}; +use alloy_rpc_types::{ + BlockId, BlockTransactions, + trace::geth::{CallFrame, GethDebugTracingOptions, GethTrace, PreStateConfig}, }; -use alloy_provider::{Provider, RootProvider}; -use alloy_rpc_types::BlockTransactions; +use alloy_transport::TransportError; use clap::Parser; use eyre::{Result, WrapErr}; use foundry_cli::{ - opts::{EtherscanOpts, RpcOpts}, - utils::{TraceResult, handle_traces, init_progress}, + opts::{EtherscanOpts, RpcOpts, TracingArgs}, + utils::{TraceResult, init_progress}, +}; +use foundry_common::{ + SYSTEM_TRANSACTION_TYPE, is_known_system_sender, + provider::{ProviderBuilder, RetryProvider}, + shell, }; -use foundry_common::{SYSTEM_TRANSACTION_TYPE, is_impersonated_tx, is_known_system_sender, shell}; use foundry_compilers::artifacts::EvmVersion; use foundry_config::{ - Config, + Config, TracingConfig, figment::{ - self, Figment, Metadata, Profile, + self, Metadata, Profile, value::{Dict, Map}, }, }; use foundry_evm::{ - Env, - executors::{EvmError, TracingExecutor}, + core::{ + FoundryBlock as _, FoundryTransaction as _, + env::FromAnyRpcTransaction as _, + evm::{EthEvmNetwork, EvmEnvFor, FoundryEvmNetwork, TempoEvmNetwork, TxEnvFor}, + }, + executors::{Executor, ExecutorBuilder, TracingExecutor}, + hardforks::FoundryHardfork, opts::EvmOpts, - traces::{InternalTraceMode, TraceMode, Traces}, - utils::configure_tx_env, + traces::{InternalTraceMode, SparsedTraceArena, TraceContext, TraceRequirements}, }; -use foundry_evm_core::env::AsEnvMut; +use foundry_evm_networks::{NetworkConfigs, NetworkVariant}; +use futures::{StreamExt, TryFutureExt}; +use revm::{ + DatabaseRef, + context::Block, + primitives::hardfork::SpecId, + state::bal::{Bal, BlockAccessIndex}, +}; +use std::sync::Arc; + +#[cfg(feature = "base")] +use foundry_evm::core::evm::BaseEvmNetwork; -use crate::utils::apply_chain_and_block_specific_env_changes; +#[cfg(feature = "monad")] +use foundry_evm::core::evm::{BlockContext, MonadEvmNetwork}; +#[cfg(feature = "monad")] +use revm::context::TxEnv; + +#[cfg(feature = "optimism")] +use foundry_evm::core::evm::OpEvmNetwork; + +/// Points at the remote trace when a transaction cannot be replayed locally. +const REMOTE_TRACE_HINT: &str = "`--debug-trace-transaction` renders the node's own trace instead"; /// CLI arguments for `cast run`. #[derive(Clone, Debug, Parser)] @@ -42,10 +92,6 @@ pub struct RunArgs { #[arg(long, short)] debug: bool, - /// Whether to identify internal functions in traces. - #[arg(long)] - decode_internal: bool, - /// Print out opcode traces. #[arg(long, short)] trace_printer: bool, @@ -56,15 +102,50 @@ pub struct RunArgs { #[arg(long)] quick: bool, - /// Disables the labels in the traces. + /// Do not use the block access list (BAL) of the transaction's block. + /// + /// If the node serves an EIP-7928 block access list, cast reads the transaction's prestate + /// from it instead of replaying the earlier transactions of the block. With this flag, the + /// block is always replayed. + #[arg(long)] + no_bal: bool, + + /// Whether to replay system transactions. + #[arg(long, alias = "sys")] + replay_system_txes: bool, + + /// Use debug_traceTransaction to fetch the prestate instead of replaying the block. + /// + /// This is significantly faster than replaying all previous transactions in the block, but + /// requires the node to expose the `debug_` namespace (most public RPCs don't). If the call + /// or response can't be used, cast silently falls back to the block access list, then to + /// replaying the block. #[arg(long, default_value_t = false)] - disable_labels: bool, + prestate_tracer: bool, - /// Label addresses in the trace. + /// Fetch the transaction's trace from the node via `debug_traceTransaction` (callTracer) and + /// render it, instead of re-executing the transaction locally. /// - /// Example: 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045:vitalik.eth - #[arg(long, short)] - label: Vec, + /// This skips the block replay entirely, so it is fast and reflects exactly what happened + /// on-chain, including chain-specific EVM behavior a local replay may not reproduce, but it + /// requires the node to expose the `debug_` namespace. The result is a call-tree view: + /// nested calls, value, gas, emitted logs and revert data. It does not provide the + /// opcode-level detail of a local run, so the local-execution-only flags (`--debug`, + /// `--decode-internal`, `--trace-printer`, `--quick`, `--no-bal`, `--prestate-tracer`, + /// `--evm-version`) do not apply. + #[arg( + long, + default_value_t = false, + conflicts_with_all = ["debug", "decode_internal", "trace_printer", "quick", "no_bal", "prestate_tracer", "evm_version"] + )] + debug_trace_transaction: bool, + + #[command(flatten)] + tracing: TracingArgs, + + /// Deprecated short alias for `--labels`. + #[arg(short = 'l', value_name = "ADDRESS:LABEL", hide = true)] + legacy_labels: Vec, #[command(flatten)] etherscan: EtherscanOpts, @@ -78,270 +159,1027 @@ pub struct RunArgs { #[arg(long)] evm_version: Option, - /// Sets the number of assumed available compute units per second for this provider - /// - /// default value: 330 - /// - /// See also, - #[arg(long, alias = "cups", value_name = "CUPS")] - pub compute_units_per_second: Option, - - /// Disables rate limiting for this node's provider. - /// - /// default value: false - /// - /// See also, - #[arg(long, value_name = "NO_RATE_LIMITS", visible_alias = "no-rpc-rate-limit")] - pub no_rate_limit: bool, - - /// Enables Odyssey features. - #[arg(long, alias = "alphanet")] - pub odyssey: bool, - /// Use current project artifacts for trace decoding. #[arg(long, visible_alias = "la")] pub with_local_artifacts: bool, /// Disable block gas limit check. + /// + /// Always implied: a mined transaction already passed its chain's own check. #[arg(long)] pub disable_block_gas_limit: bool, + + /// Enable the tx gas limit checks as imposed by Osaka (EIP-7825). + #[arg(long)] + pub enable_tx_gas_limit: bool, +} + +/// Target transaction resolved up front by [`RunArgs::fetch_target`], before any per-network +/// preparation. +struct TargetFetch { + tx: AnyRpcTransaction, + provider: RetryProvider, + compute_units_per_second: Option, +} + +/// Fields only needed by the Monad-specific `execute_monad` path. +#[cfg(feature = "monad")] +struct MonadPrepared { + tx_block_number: u64, + compute_units_per_second: Option, +} + +/// Chain-specific gas limits for transactions replayed by `cast run`. +enum ReplayGasLimits { + Unchanged, + Nitro(B256HashMap), +} + +/// State assembled by [`RunArgs::prepare`] and consumed by the network-specific `execute_*` +/// methods below. +struct PreparedRun { + args: RunArgs, + config: Box, + tracing: TracingConfig, + tx: AnyRpcTransaction, + block: Option, + evm_env: EvmEnvFor, + executor: TracingExecutor, + trace_context: TraceContext, + prestate_applied: bool, + /// The block access list of the target's block, when the node serves one. + block_access_list: Option>, + replay_gas_limits: ReplayGasLimits, + /// The target's receipt, which the replay is checked against. + receipt: Option, + #[cfg(feature = "monad")] + monad: MonadPrepared, } impl RunArgs { + fn resolve_tracing(&self, config: &TracingConfig, verbosity: u8) -> TracingConfig { + if self.debug_trace_transaction { + self.tracing.resolve_call_tracer(config, verbosity) + } else { + self.tracing.resolve(config, verbosity) + } + } + + /// Applies the network and tracing options to `config` and returns the resolved tracing + /// config. + fn configure_tracing(&mut self, config: &mut Config, evm_opts: &EvmOpts) -> TracingConfig { + config.networks = evm_opts.networks; + self.tracing.labels.append(&mut self.legacy_labels); + config.tracing = self.resolve_tracing(&config.tracing, shell::verbosity()); + config.tracing.clone() + } + /// Executes the transaction by replaying it /// /// This replays the entire block the transaction was mined in unless `quick` is set to true + /// or the node serves a block access list for the block. /// /// Note: This executes the transaction(s) as is: Cheatcodes are disabled pub async fn run(self) -> Result<()> { - let figment = Into::::into(&self.rpc).merge(&self); - let evm_opts = figment.extract::()?; - let mut config = Config::from_provider(figment)?.sanitized(); + let figment = self.rpc.clone().into_figment(self.with_local_artifacts).merge(&self); + let (mut config, mut evm_opts) = super::load_cast_config_and_evm_opts(figment)?; + if config.eth_rpc_url.is_none() + && let Some(chain) = self.etherscan.chain + { + let alias = chain.to_string(); + if config.rpc_endpoints.contains_key(&alias) { + config.eth_rpc_url = Some(alias); + } + } + evm_opts.fork_url = Some(config.get_rpc_url_or_localhost_http()?.into_owned()); + + // Auto-detect network from fork chain ID when not explicitly configured. + evm_opts.infer_network_from_fork().await?; + + if self.debug_trace_transaction { + return self.remote_trace(config, evm_opts).await; + } - let compute_units_per_second = - if self.no_rate_limit { Some(u64::MAX) } else { self.compute_units_per_second }; + self.replay(config, evm_opts).await.map_err(|err| { + if err.chain().any(|cause| is_missing_state_message(&cause.to_string())) { + err.wrap_err( + "the RPC endpoint does not have the historical state for the transaction's \ + block; use an archive endpoint", + ) + } else { + err + } + }) + } + + /// Replays the transaction locally with the EVM of its network. + async fn replay(self, config: Box, evm_opts: EvmOpts) -> Result<()> { + match evm_opts.networks.execution_network() { + NetworkVariant::Tempo => { + self.run_with_evm(config, evm_opts, ExecutorBuilder::::new()).await + } + #[cfg(feature = "base")] + NetworkVariant::Base => { + self.run_with_evm(config, evm_opts, ExecutorBuilder::::new()).await + } + #[cfg(feature = "monad")] + NetworkVariant::Monad => { + let target = self.fetch_target(&config).await?; + let mut run = self + .prepare::( + config, + evm_opts, + target, + ExecutorBuilder::::new(), + ) + .await?; + let result = run.execute_monad().await?; + run.finish(result).await + } + #[cfg(feature = "optimism")] + NetworkVariant::Optimism => { + self.run_with_evm(config, evm_opts, ExecutorBuilder::::new()).await + } + NetworkVariant::Ethereum => { + self.run_with_evm(config, evm_opts, ExecutorBuilder::::new()).await + } + } + } + + async fn run_with_evm( + self, + config: Box, + evm_opts: EvmOpts, + executor_builder: ExecutorBuilder, + ) -> Result<()> { + let target = self.fetch_target(&config).await?; + let endpoint_is_anvil = evm_opts + .fork_endpoint + .as_ref() + .is_some_and(|identity| identity.reported_hardfork.is_some()); + if let Some(chain) = target.tx.chain_id().map(alloy_chains::Chain::from_id) + && chain.is_elastic() + && !endpoint_is_anvil + { + eyre::bail!( + "{chain} executes EraVM bytecode, which cannot be replayed locally; \ + {REMOTE_TRACE_HINT}" + ); + } + if is_system_transaction(&target.tx) && !self.replay_system_txes { + eyre::bail!( + "{:?} is a system transaction.\nReplaying system transactions is currently not supported.", + target.tx.tx_hash() + ); + } + let mut run = self.prepare::(config, evm_opts, target, executor_builder).await?; + let result = run.execute_ordinary()?; + run.finish(result).await + } - let provider = foundry_cli::utils::get_provider_builder(&config)? + /// `AnyNetwork` rather than `FEN::Network`: chains such as Arbitrum, Celo and the OP-stack + /// forks Foundry does not route to a dedicated network put transaction types the strict + /// Ethereum envelope cannot decode into every block, which would fail the full block fetch in + /// `prepare` for the whole chain. Execution still uses `FEN`. + async fn fetch_target(&self, config: &Config) -> Result { + let compute_units_per_second = if self.rpc.common.no_rpc_rate_limit { + Some(u64::MAX) + } else { + self.rpc.common.compute_units_per_second + }; + let provider = ProviderBuilder::::from_config(config)? .compute_units_per_second_opt(compute_units_per_second) .build()?; - let tx_hash = self.tx_hash.parse().wrap_err("invalid tx hash")?; let tx = provider .get_transaction_by_hash(tx_hash) .await .wrap_err_with(|| format!("tx not found: {tx_hash:?}"))? - .ok_or_else(|| eyre::eyre!("tx not found: {:?}", tx_hash))?; + .ok_or_else(|| eyre::eyre!("tx not found: {tx_hash:?}"))?; + ensure_requested_transaction(tx_hash, &tx)?; + Ok(TargetFetch { tx, provider, compute_units_per_second }) + } - // check if the tx is a system transaction - if is_known_system_sender(tx.from()) - || tx.transaction_type() == Some(SYSTEM_TRANSACTION_TYPE) - { - return Err(eyre::eyre!( - "{:?} is a system transaction.\nReplaying system transactions is currently not supported.", - tx.tx_hash() - )); + /// Fetches the trace from the node via `debug_traceTransaction` (callTracer) instead of + /// re-executing the transaction locally. The node already holds the transaction's exact + /// pre-state and EVM rules, so this needs no block replay and no local executor; it also + /// handles system transactions. + async fn remote_trace(mut self, mut config: Box, evm_opts: EvmOpts) -> Result<()> { + let TargetFetch { tx, provider, .. } = self.fetch_target(&config).await?; + let tx_hash = tx.tx_hash(); + let tracing = self.configure_tracing(&mut config, &evm_opts); + let with_local_artifacts = self.with_local_artifacts; + + let endpoint_identity = evm_opts.fork_endpoint_identity().await?; + let tx_inclusion = tx + .block_hash_num() + .ok_or_else(|| eyre::eyre!("tx may still be pending: {:?}", tx_hash))?; + + let frame = call_tracer_frame( + provider + .debug_trace_transaction( + tx_hash, + GethDebugTracingOptions::call_tracer(call_tracer_config()), + ) + .await, + "debug_traceTransaction", + "drop `--debug-trace-transaction` to re-execute the transaction locally", + "the transaction's block; use an archive endpoint", + )?; + + let receipt = provider + .get_transaction_receipt(tx_hash) + .await? + .ok_or_else(|| eyre::eyre!("tx receipt not found: {:?}", tx_hash))?; + ensure_remote_transaction_inclusion( + tx_hash, + tx_inclusion, + receipt.block_hash_num(), + "transaction receipt", + )?; + + let transaction_block = provider.get_block_by_hash(tx_inclusion.hash).await?; + ensure_remote_transaction_inclusion( + tx_hash, + tx_inclusion, + transaction_block.as_ref().map(block_num_hash), + "block fetched by hash", + )?; + let Some(transaction_block) = transaction_block else { + unreachable!("ensure_remote_transaction_inclusion errors when the block is missing") + }; + + let root_create_address = Transaction::to(&tx) + .is_none() + .then(|| receipt.contract_address().unwrap_or_else(|| tx.from().create(tx.nonce()))); + let arena = SparsedTraceArena { + arena: call_frame_to_arena(&frame, root_create_address), + ignored: Default::default(), + diagnostics: Default::default(), + }; + let result = TraceResult { + success: receipt.status(), + traces: Some(vec![(TraceKind::Execution, arena)]), + gas_used: receipt.gas_used(), + }; + + // Local-artifact labeling matches deployed runtime bytecode against the project + // artifacts. There is no local executor on this path, so fetch the code over RPC for the + // addresses in the trace, at the transaction's block. Skip the extra round-trips unless + // local artifacts were requested. + let contracts_bytecode = if with_local_artifacts { + fetch_transaction_contracts_bytecode_via_rpc( + &provider, + &result, + tx_hash, + BlockId::hash(tx_inclusion.hash), + ) + .await + } else { + Default::default() + }; + + // The remote node executed this trace, so its reported family is authoritative for + // decoding even when the caller selected a compatible local EVM implementation. + let chain = alloy_chains::Chain::from_id(endpoint_identity.source_chain_id); + let resolved_hardfork = resolve_remote_trace_hardfork( + config.hardfork, + &endpoint_identity, + Some(transaction_block.header().timestamp()), + ); + let final_endpoint_identity = evm_opts.fork_endpoint_identity().await?; + ensure_remote_trace_context_unchanged(&endpoint_identity, &final_endpoint_identity)?; + + let current_tx = provider.get_transaction_by_hash(tx_hash).await?; + if let Some(current_tx) = ¤t_tx { + ensure_requested_transaction(tx_hash, current_tx)?; } + ensure_remote_transaction_inclusion( + tx_hash, + tx_inclusion, + current_tx.and_then(|tx| tx.block_hash_num()), + "transaction lookup", + )?; + let canonical_block = provider.get_block_by_number(tx_inclusion.number.into()).await?; + ensure_remote_transaction_inclusion( + tx_hash, + tx_inclusion, + canonical_block.as_ref().map(block_num_hash), + "canonical block lookup", + )?; + handle_traces( + result, + &config, + { + let context = + TraceContext::new(chain, endpoint_identity.network_profile, resolved_hardfork); + context.with_hardfork(context.decoding_hardfork(&config)) + }, + &contracts_bytecode, + &tracing, + with_local_artifacts, + false, + ) + .await + } - let tx_block_number = - tx.block_number.ok_or_else(|| eyre::eyre!("tx may still be pending: {:?}", tx_hash))?; + async fn prepare( + mut self, + mut config: Box, + evm_opts: EvmOpts, + target: TargetFetch, + executor_builder: ExecutorBuilder, + ) -> Result> { + #[cfg_attr(not(feature = "monad"), allow(unused_variables))] + let TargetFetch { tx, provider, compute_units_per_second } = target; + let tx_hash = tx.tx_hash(); + let tracing = self.configure_tracing(&mut config, &evm_opts); - // fetch the block the transaction was mined in - let block = provider.get_block(tx_block_number.into()).full().await?; + let tx_block_number = tx + .block_number() + .ok_or_else(|| eyre::eyre!("tx may still be pending: {:?}", tx_hash))?; // we need to fork off the parent block config.fork_block_number = Some(tx_block_number - 1); let create2_deployer = evm_opts.create2_deployer; - let (mut env, fork, chain, odyssey) = - TracingExecutor::get_fork_material(&config, evm_opts).await?; + let (block, receipt, mut fork) = tokio::try_join!( + // fetch the block the transaction was mined in + provider.get_block(tx_block_number.into()).full().into_future().map_err(Into::into), + // The receipt only backs the check after the replay, so a failed lookup skips that + // check instead of failing the run. + async { Ok(provider.get_transaction_receipt(tx_hash).await.ok().flatten()) }, + TracingExecutor::::get_fork(&mut config, evm_opts) + )?; + let chain = fork.context().chain(); + let networks = fork.context().networks(); + let mut evm_version = self.evm_version; + // Mined transactions already passed the block gas limit check their chain applies, and + // some chains admit transactions whose gas limit exceeds it: BSC validator transactions + // carry a gas limit of `i64::MAX`. Re-applying the check can only reject a transaction + // the chain accepted. + fork.evm_env.cfg_env.disable_block_gas_limit = true; - env.evm_env.cfg_env.disable_block_gas_limit = self.disable_block_gas_limit; - env.evm_env.block_env.number = U256::from(tx_block_number); + // By default do not enforce transaction gas limits imposed by Osaka (EIP-7825). + // Users can opt-in to enable these limits by setting `enable_tx_gas_limit` to true. + if !self.enable_tx_gas_limit { + fork.evm_env.cfg_env.tx_gas_limit_cap = Some(u64::MAX); + } + + fork.evm_env.cfg_env.limit_contract_code_size = None; + fork.evm_env.block_env.set_number(U256::from(tx_block_number)); + let mut parent_beacon_block_root = None; if let Some(block) = &block { - env.evm_env.block_env.timestamp = U256::from(block.header.timestamp); - env.evm_env.block_env.beneficiary = block.header.beneficiary; - env.evm_env.block_env.difficulty = block.header.difficulty; - env.evm_env.block_env.prevrandao = Some(block.header.mix_hash.unwrap_or_default()); - env.evm_env.block_env.basefee = block.header.base_fee_per_gas.unwrap_or_default(); - env.evm_env.block_env.gas_limit = block.header.gas_limit; - - // TODO: we need a smarter way to map the block to the corresponding evm_version for - // commonly used chains - if evm_version.is_none() { - // if the block has the excess_blob_gas field, we assume it's a Cancun block - if block.header.excess_blob_gas.is_some() { - evm_version = Some(EvmVersion::Prague); - } + fork.evm_env.block_env = block_env_from_header(block.header()); + parent_beacon_block_root = block.header().parent_beacon_block_root(); + + // Unless explicitly configured, resolve the correct spec for the block using the same + // approach as reth: walk known chain activation conditions to find the latest active + // fork. For unknown chains, probe the node for the features it executes at the block, + // falling back to a blob-gas heuristic if the node rejects the probe. + if evm_version.is_none() + && config.hardfork.is_none() + && FoundryHardfork::from_chain_and_timestamp(chain.id(), block.header().timestamp()) + .is_none() + { + let probed = probe_evm_version(&provider, BlockId::number(tx_block_number)).await; + evm_version = probed.or_else(|| { + block.header().excess_blob_gas().is_some().then_some(EvmVersion::Cancun) + }); } - apply_chain_and_block_specific_env_changes::(env.as_env_mut(), block); + apply_chain_and_block_specific_env_changes_for_chain::( + &mut fork.evm_env, + block, + chain.id(), + config.networks, + ); } + fork.resolve_spec(&config, evm_version); + fork.extend_precompile_labels(&mut config); - let trace_mode = TraceMode::Call - .with_debug(self.debug) - .with_decode_internal(if self.decode_internal { - InternalTraceMode::Full - } else { - InternalTraceMode::None - }) - .with_state_changes(shell::verbosity() > 4); - let mut executor = TracingExecutor::new( - env.clone(), - fork, - evm_version, - trace_mode, - odyssey, + apply_chain_specific_tx_replay_env_changes_for_chain(&mut fork.evm_env, chain.id()); + + let trace_context = fork.context(); + let mut evm_env = fork.evm_env.clone(); + + let mut executor = fork.into_executor( + executor_builder, + TraceRequirements::none(), create2_deployer, None, )?; - let mut env = Env::new_with_spec_id( - env.evm_env.cfg_env.clone(), - env.evm_env.block_env.clone(), - env.tx.clone(), - executor.spec_id(), - ); + // The fork is pinned to the parent block, but the replayed transactions execute in the + // target's block, which block queries such as Arbitrum's `ArbSys.arbBlockNumber()` must + // report. + executor.backend_mut().set_fork_block_number_override(tx_block_number); - // Set the state to the moment right before the transaction - if !self.quick { - if !shell::is_json() { - sh_println!("Executing previous transactions from the block.")?; - } + evm_env.cfg_env.set_spec_and_mainnet_gas_params(executor.spec_id()); - if let Some(block) = block { - let pb = init_progress(block.transactions.len() as u64, "tx"); - pb.set_position(0); - - let BlockTransactions::Full(ref txs) = block.transactions else { - return Err(eyre::eyre!("Could not get block txs")); - }; - - for (index, tx) in txs.iter().enumerate() { - // System transactions such as on L2s don't contain any pricing info so - // we skip them otherwise this would cause - // reverts - if is_known_system_sender(tx.from()) - || tx.transaction_type() == Some(SYSTEM_TRANSACTION_TYPE) - { - pb.set_position((index + 1) as u64); - continue; - } - if tx.tx_hash() == tx_hash { - break; - } + let spec_id = (*evm_env.cfg_env.spec()).into(); - configure_tx_env(&mut env.as_env_mut(), &tx.inner); - - env.evm_env.cfg_env.disable_balance_check = true; - - if let Some(to) = Transaction::to(tx) { - trace!(tx=?tx.tx_hash(),?to, "executing previous call transaction"); - executor.transact_with_env(env.clone()).wrap_err_with(|| { - format!( - "Failed to execute transaction: {:?} in block {}", - tx.tx_hash(), - env.evm_env.block_env.number - ) - })?; - } else { - trace!(tx=?tx.tx_hash(), "executing previous create transaction"); - if let Err(error) = executor.deploy_with_env(env.clone(), None) { - match error { - // Reverted transactions should be skipped - EvmError::Execution(_) => (), - error => { - return Err(error).wrap_err_with(|| { - format!( - "Failed to deploy transaction: {:?} in block {}", - tx.tx_hash(), - env.evm_env.block_env.number - ) - }); - } - } - } - } + if let Some(parent_beacon_block_root) = + parent_beacon_block_root_for_network(networks, spec_id, parent_beacon_block_root) + { + executor.apply_beacon_root(parent_beacon_block_root)?; + } - pb.set_position((index + 1) as u64); + // Set the state to the moment right before the transaction. + // + // When `--prestate-tracer` is set, opportunistically try to fetch the prestate directly + // via `debug_traceTransaction` (much faster than replaying the block). This requires the + // `debug_` namespace, which most nodes don't expose, so it is opt-in and silently falls + // back to the block access list, then to replaying previous transactions in the block, + // if the call or parsing fails. + let mut prestate_applied = false; + if !self.quick && self.prestate_tracer { + trace!(?tx_hash, "attempting to fetch prestate via debug_traceTransaction"); + match provider + .debug_trace_transaction( + tx_hash, + GethDebugTracingOptions::prestate_tracer(PreStateConfig::default()), + ) + .await + { + Ok(trace) => match trace.try_into_pre_state_frame() { + Ok(pre_state_frame) => { + executor.apply_prestate_trace(pre_state_frame.into_pre_state())?; + prestate_applied = true; + trace!("prestate trace applied successfully, skipping block replay"); + } + Err(err) => { + trace!(%err, "failed to parse prestate trace response"); + } + }, + Err(err) => { + trace!(?err, "debug_traceTransaction failed, falling back to block replay"); } } } - // Execute our transaction - let result = { - executor.set_trace_printer(self.trace_printer); + // A block access list (BAL) records every state write of the block by transaction index, + // so the target's prestate can be read from it instead of replaying the earlier + // transactions. Before Cancun, SELFDESTRUCT wipes storage the list does not enumerate. + // Explicit execution rules must replay the prefix: canonical writes may differ under + // the requested EVM version or hardfork. + let block_access_list = if !self.quick + && !self.no_bal + && !prestate_applied + && self.evm_version.is_none() + && config.hardfork.is_none() + && spec_id.is_enabled_in(SpecId::CANCUN) + && let Some(block) = &block + { + fetch_block_access_list(&provider, block).await? + } else { + None + }; + + let mut replay_tx_hashes = vec![tx_hash]; + if !self.quick + && !prestate_applied + && block_access_list.is_none() + && let Some(block) = &block + { + replay_tx_hashes.extend( + full_transactions(block)? + .iter() + .take_while(|tx| tx.tx_hash() != tx_hash) + .filter(|tx| !is_system_transaction(tx) || self.replay_system_txes) + .map(TransactionResponse::tx_hash), + ); + } + let replay_gas_limits = + ReplayGasLimits::fetch(chain.id(), &provider, replay_tx_hashes).await?; - configure_tx_env(&mut env.as_env_mut(), &tx.inner); - if is_impersonated_tx(tx.inner.inner.inner()) { - env.evm_env.cfg_env.disable_balance_check = true; - } + Ok(PreparedRun { + args: self, + config, + tracing, + tx, + block, + evm_env, + executor, + trace_context, + prestate_applied, + block_access_list, + replay_gas_limits, + receipt, + #[cfg(feature = "monad")] + monad: MonadPrepared { tx_block_number, compute_units_per_second }, + }) + } +} + +/// Fetches the block access list of `block`, if the node serves one that matches the header. +async fn fetch_block_access_list( + provider: &RetryProvider, + block: &AnyRpcBlock, +) -> Result>> { + let header = block.header(); + let bal = match provider.get_block_access_list(BlockId::hash(header.hash)).await { + Ok(Some(bal)) => bal, + Ok(None) => return Ok(None), + Err(err) => { + trace!(%err, "block access list unavailable, falling back to block replay"); + return Ok(None); + } + }; + if let Some(hash) = header.block_access_list_hash() + && compute_block_access_list_hash(&bal) != hash + { + sh_warn!( + "block access list of block {} does not match its header, replaying the block instead", + header.number() + )?; + return Ok(None); + } + Ok(Some(Arc::new(Bal::try_from_alloy(bal).wrap_err("invalid block access list")?))) +} - if let Some(to) = Transaction::to(&tx) { - trace!(tx=?tx.tx_hash(), to=?to, "executing call transaction"); - TraceResult::try_from(executor.transact_with_env(env))? +impl PreparedRun { + fn transaction_env(&self, tx: &AnyRpcTransaction) -> Result> { + let mut tx_env = TxEnvFor::::from_any_rpc_transaction(tx)?; + self.replay_gas_limits.apply::(tx, &mut tx_env)?; + Ok(tx_env) + } + + /// Prepares the executor for the target transaction: enables tracing and, when the sender + /// was forged, disables the balance check. + fn prepare_target(&mut self) { + let requirements = TraceRequirements::none() + .with_calls(true) + .with_debug(self.args.debug) + .with_decode_internal(if self.tracing.decode_internal { + InternalTraceMode::Full } else { - trace!(tx=?tx.tx_hash(), "executing create transaction"); - TraceResult::try_from(executor.deploy_with_env(env, None))? + InternalTraceMode::None + }) + .with_state_changes(self.tracing.verbosity > 4); + self.executor.set_trace_requirements(requirements); + self.executor.set_trace_printer(self.args.trace_printer); + + let sender_is_forged = match &*self.tx.inner.inner { + AnyTxEnvelope::Ethereum(inner) => { + inner.recover_signer().is_ok_and(|signer| signer != self.tx.from()) } + AnyTxEnvelope::Unknown(_) => true, }; + if sender_is_forged { + self.evm_env.cfg_env.disable_balance_check = true; + } + } + + /// Returns the index of the target transaction in its block. + fn target_index(&self) -> Result { + let Some(block) = &self.block else { return Ok(0) }; + full_transactions(block)? + .iter() + .position(|candidate| candidate.tx_hash() == self.tx.tx_hash()) + .ok_or_else(|| { + eyre::eyre!("transaction {:?} is missing from its block", self.tx.tx_hash()) + }) + } + + /// Calls `f` for every transaction mined before the target, unless the block replay was + /// skipped (`--quick` or an applied prestate). + fn for_each_prefix_transaction( + &self, + target_index: usize, + mut f: impl FnMut(usize, &AnyRpcTransaction) -> Result<()>, + ) -> Result<()> { + if self.args.quick || self.prestate_applied { + return Ok(()); + } + sh_status!("Executing previous transactions from the block.")?; + let Some(block) = &self.block else { return Ok(()) }; + let txs = full_transactions(block)?; + let pb = init_progress(txs.len() as u64, "tx"); + for (index, tx) in txs.iter().take(target_index).enumerate() { + if let Some(to) = Transaction::to(tx) { + trace!(tx=?tx.tx_hash(), ?to, "preparing previous call transaction"); + } else { + trace!(tx=?tx.tx_hash(), "preparing previous create transaction"); + } + f(index, tx)?; + pb.set_position((index + 1) as u64); + } + Ok(()) + } - let contracts_bytecode = fetch_contracts_bytecode_from_trace(&provider, &result).await?; + fn trace_kind(&self) -> TraceKind { + if let Some(to) = Transaction::to(&self.tx) { + trace!(tx=?self.tx.tx_hash(), ?to, "executing call transaction"); + TraceKind::Execution + } else { + trace!(tx=?self.tx.tx_hash(), "executing create transaction"); + TraceKind::Deployment + } + } + + fn execute_ordinary(&mut self) -> Result { + // Decode the target transaction before replaying the block: an envelope this build + // can't decode should fail fast. + let target_tx_env = self.transaction_env(&self.tx).wrap_err_with(|| { + format!( + "cannot replay transaction {:?} locally; {REMOTE_TRACE_HINT}", + self.tx.tx_hash() + ) + })?; + let target_index = self.target_index()?; + self.prepare_target(); + + let mut replay = Vec::new(); + if let Some(bal) = self.block_access_list.take() { + // Index 0 holds the pre-block system writes and transaction `i` is index `i + 1`, so + // reads positioned at the target's own index see exactly the earlier writes. + trace!("reading prestate from block access list, skipping block replay"); + self.executor + .backend_mut() + .set_bal(bal, BlockAccessIndex::new(target_index as u64 + 1)); + } else { + let block_number = self.evm_env.block_env.number(); + let replay_system_txes = self.args.replay_system_txes; + self.for_each_prefix_transaction(target_index, |_, tx| { + if !is_system_transaction(tx) || replay_system_txes { + let tx_env = self.transaction_env(tx).wrap_err_with(|| { + format!( + "Failed to prepare transaction: {:?} in block {}; `--quick` skips the \ + transactions before the target, and {REMOTE_TRACE_HINT}", + tx.tx_hash(), + block_number + ) + })?; + replay.push((tx.tx_hash(), tx_env)); + } + Ok(()) + })?; + } + let result = self.executor.transact_with_ordinary_block_replay( + self.evm_env.clone(), + target_tx_env, + replay, + )?; + let trace_kind = self.trace_kind(); + trace!(tx_hash=?self.tx.tx_hash(), "completed execution"); + Ok(TraceResult::from_raw(result, trace_kind)) + } + + async fn finish(self, result: TraceResult) -> Result<()> { + let contracts_bytecode = fetch_contracts_bytecode_from_trace(&self.executor, &result)?; + let (success, gas_used) = (result.success, result.gas_used); handle_traces( result, - &config, - chain, + &self.config, + self.trace_context, &contracts_bytecode, - self.label, - self.with_local_artifacts, - self.debug, - self.decode_internal, - self.disable_labels, + &self.tracing, + self.args.with_local_artifacts, + self.args.debug, ) .await?; + self.warn_on_receipt_mismatch(success, gas_used) + } + /// Warns when the replay does not reproduce the target's receipt. + /// + /// A replay diverges when the chain applies rules the local EVM does not model or the replay + /// starts from different state, and the trace alone gives no sign of it. + fn warn_on_receipt_mismatch(&self, success: bool, gas_used: u64) -> Result<()> { + let Some(receipt) = &self.receipt else { return Ok(()) }; + let outcome = |success: bool| if success { "succeeded" } else { "reverted" }; + let mut differences = Vec::new(); + if receipt.status() != success { + differences.push(format!( + "it {} on-chain but {} in the replay", + outcome(receipt.status()), + outcome(success) + )); + } + if let Some(expected) = self.expected_replay_gas(receipt) + && expected != gas_used + { + differences + .push(format!("it used {expected} gas on-chain but {gas_used} in the replay")); + } + if differences.is_empty() { + return Ok(()); + } + let hint = if self.args.quick { + "`--quick` skips the transactions before it in the block, which can change the result; \ + run without it to replay them first" + } else { + "The chain may apply rules the replay does not model; `--debug-trace-transaction` \ + shows the node's own trace if it exposes the `debug` namespace" + }; + sh_warn!( + "the replay does not match the transaction's receipt: {}. {hint}.", + differences.join(", and ") + ) + } + + /// Returns the gas the replay should report for `receipt`, or `None` if the receipt does not + /// record the gas the transaction executed. + fn expected_replay_gas(&self, receipt: &AnyTransactionReceipt) -> Option { + // Monad charges the full gas limit and reports it as the gas used. + if self.trace_context.networks().is_monad() { + return None; + } + let gas_used = receipt.gas_used(); + if foundry_evm_networks::arbitrum::is_arbitrum_chain(self.trace_context.chain().id()) { + // Nitro folds the L1 posting cost into the receipt's gas used. + let l1_gas_used = + parse_nitro_l1_gas_used(receipt.other_fields().get("gasUsedForL1")).ok()?; + return gas_used.checked_sub(l1_gas_used); + } + Some(gas_used) + } +} + +#[cfg(feature = "monad")] +impl PreparedRun { + async fn execute_monad(&mut self) -> Result { + // `BlockContext` is typed to `MonadEvmNetwork::Network` (`Ethereum`). Monad blocks only + // carry standard envelopes, so a typed provider can serve this path while the rest of the + // command stays on `AnyNetwork`. + let provider = ProviderBuilder::::from_config(&self.config)? + .compute_units_per_second_opt(self.monad.compute_units_per_second) + .build()?; + let block = + provider.get_block(self.monad.tx_block_number.into()).full().await?.ok_or_else( + || { + eyre::eyre!( + "block {} is required to reconstruct transaction context", + self.monad.tx_block_number + ) + }, + )?; + let block_context = BlockContext::::fetch(&provider, &block).await?; + // Decode the target transaction before replaying the block: an envelope this build + // can't decode should fail fast, not after paying for the entire prior-transaction + // replay. + let target_tx_env = TxEnv::from_any_rpc_transaction(&self.tx)?; + let target_index = self.target_index()?; + self.prepare_target(); + + let mut replay = Vec::new(); + self.for_each_prefix_transaction(target_index, |index, tx| { + let tx_env = TxEnv::from_any_rpc_transaction(tx)?; + let chain_context = block_context.transaction(index); + replay.push((tx.tx_hash(), tx_env, chain_context)); + Ok(()) + })?; + let result = self.executor.transact_with_monad_block_replay( + self.evm_env.clone(), + target_tx_env, + block_context.transaction(target_index), + replay, + self.args.replay_system_txes, + )?; + let Some((result, used_system_replay)) = result else { + eyre::bail!( + "{:?} is a system transaction.\nReplaying system transactions is currently not supported.", + self.tx.tx_hash() + ); + }; + if used_system_replay { + trace!(tx=?self.tx.tx_hash(), "executed canonical system transaction"); + } + Ok(TraceResult::from_raw(result, self.trace_kind())) + } +} + +fn is_system_transaction(tx: &AnyRpcTransaction) -> bool { + is_known_system_sender(tx.from()) || tx.transaction_type() == Some(SYSTEM_TRANSACTION_TYPE) +} + +fn full_transactions(block: &AnyRpcBlock) -> Result<&[AnyRpcTransaction]> { + let BlockTransactions::Full(txs) = block.transactions() else { + eyre::bail!("Could not get block txs"); + }; + Ok(txs) +} + +impl ReplayGasLimits { + async fn fetch(chain_id: u64, provider: &RetryProvider, tx_hashes: Vec) -> Result { + if !foundry_evm_networks::arbitrum::is_arbitrum_chain(chain_id) { + return Ok(Self::Unchanged); + } + + let mut requests = futures::stream::iter(tx_hashes) + .map(|tx_hash| fetch_nitro_l1_gas_used(provider, tx_hash)) + .buffer_unordered(MAX_CONCURRENT_RPC_REQUESTS); + let mut gas_used = B256HashMap::default(); + while let Some(result) = requests.next().await { + let (tx_hash, l1_gas_used) = result?; + gas_used.insert(tx_hash, l1_gas_used); + } + Ok(Self::Nitro(gas_used)) + } + + fn apply( + &self, + tx: &AnyRpcTransaction, + tx_env: &mut TxEnvFor, + ) -> Result<()> { + let Self::Nitro(l1_gas_used) = self else { return Ok(()) }; + let tx_hash = tx.tx_hash(); + let l1_gas_used = l1_gas_used + .get(&tx_hash) + .ok_or_else(|| eyre::eyre!("receipt not found for Nitro transaction {tx_hash:?}"))?; + tx_env.set_gas_limit(nitro_execution_gas_limit(tx_hash, tx.gas_limit(), *l1_gas_used)?); Ok(()) } } -pub async fn fetch_contracts_bytecode_from_trace( - provider: &RootProvider, - result: &TraceResult, -) -> Result> { - let mut contracts_bytecode = HashMap::default(); - if let Some(ref traces) = result.traces { - let addresses = gather_trace_addresses(traces); - let results = futures::future::join_all(addresses.into_iter().map(async |a| { - ( - a, - provider.get_code_at(a).await.unwrap_or_else(|e| { - sh_warn!("Failed to fetch code for {a:?}: {e:?}").ok(); - Bytes::new() - }), +async fn fetch_nitro_l1_gas_used(provider: &RetryProvider, tx_hash: B256) -> Result<(B256, u64)> { + let receipt = provider + .get_transaction_receipt(tx_hash) + .await? + .ok_or_else(|| eyre::eyre!("receipt not found for Nitro transaction {tx_hash:?}"))?; + let l1_gas_used = parse_nitro_l1_gas_used(receipt.other_fields().get("gasUsedForL1")) + .wrap_err_with(|| format!("invalid Nitro poster gas for transaction {tx_hash:?}"))?; + Ok((tx_hash, l1_gas_used)) +} + +fn parse_nitro_l1_gas_used(field: Option<&serde_json::Value>) -> Result { + let field = field.ok_or_else(|| eyre::eyre!("missing `gasUsedForL1` receipt field"))?; + let value = serde_json::from_value::(field.clone()) + .wrap_err("malformed `gasUsedForL1` receipt field")?; + value.try_into().map_err(|_| eyre::eyre!("`gasUsedForL1` value {value} exceeds u64::MAX")) +} + +fn nitro_execution_gas_limit(tx_hash: B256, gas_limit: u64, l1_gas_used: u64) -> Result { + gas_limit.checked_sub(l1_gas_used).ok_or_else(|| { + eyre::eyre!( + "Nitro poster gas {l1_gas_used} exceeds gas limit {gas_limit} for transaction {tx_hash:?}" + ) + }) +} + +/// Returns the number and hash of a fetched block. +pub(super) fn block_num_hash(block: &B) -> BlockNumHash +where + B::Header: HeaderResponse, +{ + BlockNumHash::new(block.header().number(), block.header().hash()) +} + +/// Extracts the `callTracer` frame from a `debug_trace*` response. +/// +/// Two RPC rejections deserve an actionable hint instead of the raw transport error, and they +/// need different fixes: a disabled `debug` namespace, and missing historical state, hit +/// whenever a full node has pruned the traced block. +pub(super) fn call_tracer_frame( + response: Result, + method: &str, + local_hint: &str, + missing_state_hint: &str, +) -> Result { + let trace = response.map_err(|err| -> eyre::Report { + if is_method_not_found_error(&err) { + eyre::eyre!( + "the RPC endpoint does not support `{method}` (method not found); use a node with the `debug` namespace enabled (e.g. a local anvil/reth or an archive endpoint), or {local_hint}" ) - })) - .await; - for (address, code) in results { - if !code.is_empty() { - contracts_bytecode.insert(address, code); - } + } else if is_missing_state_error(&err) { + eyre::eyre!( + "the RPC endpoint does not have the historical state for {missing_state_hint}" + ) + } else { + err.into() } + })?; + let GethTrace::CallTracer(frame) = trace else { + eyre::bail!( + "`{method}` did not return a callTracer frame; the RPC endpoint may not support the `callTracer`" + ); + }; + Ok(frame) +} + +fn ensure_remote_transaction_inclusion( + tx_hash: B256, + expected: BlockNumHash, + actual: Option, + source: &str, +) -> Result<()> { + let Some(actual) = actual else { + eyre::bail!( + "transaction {tx_hash} changed inclusion while collecting its remote trace: {source} no longer reports it as mined; retry the command" + ); + }; + if actual != expected { + eyre::bail!( + "transaction {tx_hash} changed inclusion while collecting its remote trace: expected block {} at {}, but {source} reported block {} at {}; retry the command", + expected.hash, + expected.number, + actual.hash, + actual.number, + ); + } + + Ok(()) +} + +/// Ensures the RPC answered `eth_getTransactionByHash` with the requested transaction. +fn ensure_requested_transaction(requested: B256, tx: &AnyRpcTransaction) -> Result<()> { + let returned = tx.tx_hash(); + eyre::ensure!( + returned == requested, + "RPC returned transaction {returned:?} for requested {requested:?}" + ); + Ok(()) +} + +const fn parent_beacon_block_root_for_network( + networks: NetworkConfigs, + spec_id: SpecId, + parent_beacon_block_root: Option, +) -> Option { + if networks.is_monad() || !spec_id.is_enabled_in(SpecId::CANCUN) { + return None; } + + // Chains that run a Cancun or later EVM without Ethereum's beacon chain, such as Polygon and + // Scroll, never populate this header field and never deploy the EIP-4788 contract, so there + // is no root to apply. Requiring one makes their blocks unreplayable. + parent_beacon_block_root +} + +pub fn fetch_contracts_bytecode_from_trace( + executor: &Executor, + result: &TraceResult, +) -> Result> { + let contracts_bytecode = trace_addresses(result) + .filter_map(|addr| { + // All relevant bytecodes should already be cached in the executor. + let code = executor + .backend() + .basic_ref(addr) + .inspect_err(|e| _ = sh_warn!("Failed to fetch code for {addr}: {e}")) + .ok()?? + .code? + .bytes(); + (!code.is_empty()).then_some((addr, code)) + }) + .collect(); Ok(contracts_bytecode) } -fn gather_trace_addresses(traces: &Traces) -> HashSet
{ - let mut addresses = HashSet::default(); - for (_, trace) in traces { - for node in trace.arena.nodes() { - if !node.trace.address.is_zero() { - addresses.insert(node.trace.address); +/// Fetches bytecode for a mined transaction at its exact transaction index. +/// +/// The prestate tracer provides the code that existed immediately before the transaction, which +/// avoids reading end-of-block state for contracts changed or removed by later transactions. Any +/// address absent from the prestate (for example, a contract created by this transaction) falls +/// back to `eth_getCode` at the transaction's block. +async fn fetch_transaction_contracts_bytecode_via_rpc>( + provider: &P, + result: &TraceResult, + tx_hash: B256, + block: BlockId, +) -> AddressHashMap { + let mut contracts_bytecode = AddressHashMap::default(); + let prestate_config = PreStateConfig { disable_storage: Some(true), ..Default::default() }; + match provider + .debug_trace_transaction(tx_hash, GethDebugTracingOptions::prestate_tracer(prestate_config)) + .await + { + Ok(trace) => match trace.try_into_pre_state_frame() { + Ok(prestate) => { + for (&address, account) in prestate.pre_state() { + if let Some(code) = account.code.clone().filter(|code| !code.is_empty()) { + contracts_bytecode.insert(address, code); + } + } } - if !node.trace.caller.is_zero() { - addresses.insert(node.trace.caller); + Err(err) => { + let _ = sh_warn!("Failed to parse transaction prestate for local artifacts: {err}"); } + }, + Err(err) => { + let _ = sh_warn!("Failed to fetch transaction prestate for local artifacts: {err}"); } } - addresses + + let missing_addresses = trace_addresses(result) + .filter(|address| !contracts_bytecode.contains_key(address)) + .collect::>(); + contracts_bytecode.extend(fetch_code_via_rpc(provider, missing_addresses, block).await); + contracts_bytecode +} + +/// Returns the distinct non-zero addresses and callers seen in the traces of `result`. +pub(super) fn trace_addresses(result: &TraceResult) -> impl Iterator { + let mut addresses = AddressSet::default(); + for (_, trace) in result.traces.iter().flatten() { + for node in trace.arena.nodes() { + addresses.extend( + [node.trace.address, node.trace.caller].into_iter().filter(|a| !a.is_zero()), + ); + } + } + addresses.into_iter() } impl figment::Provider for RunArgs { @@ -352,18 +1190,10 @@ impl figment::Provider for RunArgs { fn data(&self) -> Result, figment::Error> { let mut map = Map::new(); - if self.odyssey { - map.insert("odyssey".into(), self.odyssey.into()); - } - if let Some(api_key) = &self.etherscan.key { map.insert("etherscan_api_key".into(), api_key.as_str().into()); } - if let Some(api_version) = &self.etherscan.api_version { - map.insert("etherscan_api_version".into(), api_version.to_string().into()); - } - if let Some(evm_version) = self.evm_version { map.insert("evm_version".into(), figment::value::Value::serialize(evm_version)?); } @@ -371,3 +1201,118 @@ impl figment::Provider for RunArgs { Ok(Map::from([(Config::selected_profile(), map)])) } } + +#[cfg(test)] +mod tests { + + use super::*; + + #[test] + fn http_wrapped_method_not_found_has_trace_guidance() { + let error = alloy_transport::TransportErrorKind::http_error( + 403, + r#"{"jsonrpc":"2.0","error":{"code":-32601,"message":"method disabled"}}"#.into(), + ); + let error = call_tracer_frame( + Err(error), + "debug_traceTransaction", + "replay locally", + "this transaction", + ) + .unwrap_err(); + assert_eq!( + error.to_string(), + "the RPC endpoint does not support `debug_traceTransaction` (method not found); use a node with the `debug` namespace enabled (e.g. a local anvil/reth or an archive endpoint), or replay locally" + ); + } + + #[test] + fn parses_legacy_short_label_alias() { + let address = Address::with_last_byte(1); + let label = format!("{address}:alice"); + let args = RunArgs::parse_from(["cast run", "0x00", "-l", &label]); + + assert_eq!(args.legacy_labels, vec![label]); + } + + #[test] + fn debug_trace_transaction_accepts_label_and_render_flags() { + let args = RunArgs::try_parse_from([ + "foundry-cli", + "--debug-trace-transaction", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "--label", + "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045:vitalik.eth", + "--disable-labels", + "--trace-depth", + "2", + "--with-local-artifacts", + ]); + assert!(args.is_ok(), "--debug-trace-transaction must accept label/rendering flags"); + } + + #[test] + fn parent_beacon_block_root_is_applied_only_when_the_header_has_one() { + let networks = NetworkConfigs::default(); + let root = Some(B256::repeat_byte(0x42)); + // Polygon and Scroll run a Cancun or later EVM without populating the header field. + for (networks, spec_id, root, expected) in [ + (networks, SpecId::CANCUN, None, None), + (networks, SpecId::CANCUN, root, root), + (networks, SpecId::SHANGHAI, root, None), + (networks, SpecId::SHANGHAI, None, None), + #[cfg(feature = "monad")] + (NetworkConfigs::with_monad(), SpecId::PRAGUE, root, None), + #[cfg(feature = "monad")] + (NetworkConfigs::with_monad(), SpecId::OSAKA, root, None), + #[cfg(feature = "monad")] + (NetworkConfigs::with_monad(), SpecId::PRAGUE, None, None), + #[cfg(feature = "monad")] + (NetworkConfigs::with_monad(), SpecId::OSAKA, None, None), + ] { + assert_eq!(parent_beacon_block_root_for_network(networks, spec_id, root), expected); + } + } + + #[test] + fn debug_trace_transaction_ignores_configured_internal_decoding() { + let args = RunArgs::parse_from(["cast run", "0x00", "--debug-trace-transaction"]); + let config = TracingConfig { decode_internal: true, ..Default::default() }; + + assert!(!args.resolve_tracing(&config, 0).decode_internal); + } + + #[test] + fn parses_nitro_l1_gas_used() { + let field = serde_json::json!("0x26ed52"); + + assert_eq!(parse_nitro_l1_gas_used(Some(&field)).unwrap(), 2_551_122); + assert_eq!(nitro_execution_gas_limit(B256::ZERO, 2_733_748, 2_551_122).unwrap(), 182_626); + } + + #[test] + fn rejects_invalid_nitro_gas() { + assert_eq!( + parse_nitro_l1_gas_used(None).unwrap_err().to_string(), + "missing `gasUsedForL1` receipt field" + ); + + let field = serde_json::json!("invalid"); + assert_eq!( + parse_nitro_l1_gas_used(Some(&field)).unwrap_err().to_string(), + "malformed `gasUsedForL1` receipt field" + ); + + let field = serde_json::json!("0x10000000000000000"); + assert_eq!( + parse_nitro_l1_gas_used(Some(&field)).unwrap_err().to_string(), + "`gasUsedForL1` value 18446744073709551616 exceeds u64::MAX" + ); + + let tx_hash = B256::repeat_byte(0x42); + assert_eq!( + nitro_execution_gas_limit(tx_hash, 100, 101).unwrap_err().to_string(), + format!("Nitro poster gas 101 exceeds gas limit 100 for transaction {tx_hash:?}") + ); + } +} diff --git a/crates/cast/src/cmd/safe/contracts.rs b/crates/cast/src/cmd/safe/contracts.rs new file mode 100644 index 0000000000000..1875cd188f16d --- /dev/null +++ b/crates/cast/src/cmd/safe/contracts.rs @@ -0,0 +1,86 @@ +// The Safe ABI fixes the number of transaction hash fields. +#![allow(clippy::too_many_arguments)] + +use alloy_primitives::{Address, address}; +use alloy_sol_types::sol; + +pub(super) const SAFE_V1_4_1: Address = address!("41675C099F32341bf84BFc5382aF534df5C7461a"); +pub(super) const SAFE_L2_V1_4_1: Address = address!("29fcB43b46531BcA003ddC8FCB67FFE91900C762"); +pub(super) const SAFE_PROXY_FACTORY_V1_4_1: Address = + address!("4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67"); +pub(super) const COMPATIBILITY_FALLBACK_HANDLER_V1_4_1: Address = + address!("fd0732Dc9E303f09fCEf3a7388Ad10A83459Ec99"); +pub(super) const SIMULATE_TX_ACCESSOR_V1_4_1: Address = + address!("3d4BA2E0884aa488718476ca2FB8Efc291A46199"); +pub(super) const SENTINEL_OWNER: Address = Address::with_last_byte(1); +pub(super) const PREDETERMINED_SALT_NONCE: &str = + "0xb1073742015cbcf5a3a4d9d1ae33ecf619439710b89475f92e2abd2117e90f90"; + +sol! { + #[sol(rpc)] + interface ISafe { + function nonce() external view returns (uint256); + + function setup( + address[] calldata owners, + uint256 threshold, + address to, + bytes calldata data, + address fallbackHandler, + address paymentToken, + uint256 payment, + address payable paymentReceiver + ) external; + + function getTransactionHash( + address to, + uint256 value, + bytes calldata data, + uint8 operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address refundReceiver, + uint256 nonce + ) external view returns (bytes32); + + function simulateAndRevert(address targetContract, bytes calldata calldataPayload) external; + + function execTransaction( + address to, + uint256 value, + bytes calldata data, + uint8 operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address payable refundReceiver, + bytes calldata signatures + ) external payable returns (bool success); + + event ExecutionSuccess(bytes32 indexed txHash, uint256 payment); + event ExecutionFailure(bytes32 indexed txHash, uint256 payment); + } + + interface ISimulateTxAccessor { + function simulate( + address to, + uint256 value, + bytes calldata data, + uint8 operation + ) external returns (uint256 estimate, bool success, bytes memory returnData); + } + + #[sol(rpc)] + interface ISafeProxyFactory { + event ProxyCreation(address indexed proxy, address singleton); + + function createProxyWithNonce( + address singleton, + bytes memory initializer, + uint256 saltNonce + ) public returns (address proxy); + } +} diff --git a/crates/cast/src/cmd/safe/delegates.rs b/crates/cast/src/cmd/safe/delegates.rs new file mode 100644 index 0000000000000..696f471e40704 --- /dev/null +++ b/crates/cast/src/cmd/safe/delegates.rs @@ -0,0 +1,151 @@ +use super::{ + rpc_provider, + service::{SafeDelegatesResponse, SafeServiceOpts}, + signing::sign_delegate, +}; +use alloy_primitives::Address; +use alloy_signer::Signer; +use clap::Args; +use eyre::{Context, Result, ensure}; +use foundry_cli::{ + json::{print_json_object, print_scalar}, + opts::RpcOpts, +}; +use foundry_wallets::WalletOpts; +use reqwest::Method; +use serde_json::{Value, json}; +use std::collections::HashSet; + +/// CLI arguments for `cast safe add-delegate`. +#[derive(Args, Debug)] +pub struct AddDelegateArgs { + /// Safe account address. + safe: Address, + + /// Address allowed to propose transactions. + delegate: Address, + + /// Human-readable delegate label. + #[arg(long)] + label: String, + + #[command(flatten)] + service: Box, + + #[command(flatten)] + rpc: Box, + + #[command(flatten)] + wallet: Box, +} + +impl AddDelegateArgs { + pub(super) async fn run(self) -> Result<()> { + let Self { safe, delegate, label, service, rpc, wallet } = self; + ensure!(!label.trim().is_empty(), "delegate label cannot be empty"); + let body = json!({ "delegate": delegate.to_checksum(None), "label": label }); + submit_delegate( + &service, + &rpc, + &wallet, + safe, + delegate, + Method::POST, + "v2/delegates/", + body, + ) + .await + } +} + +/// CLI arguments for `cast safe list-delegates`. +#[derive(Args, Debug)] +pub struct ListDelegatesArgs { + /// Safe account address. + safe: Address, + + #[command(flatten)] + service: Box, + + #[command(flatten)] + rpc: Box, +} + +impl ListDelegatesArgs { + pub(super) async fn run(self) -> Result<()> { + let Self { safe, service, rpc } = self; + let chain_id = if service.service_url.is_some() { 0 } else { rpc_provider(&rpc).await?.1 }; + let mut url = service.endpoint(chain_id, "v2/delegates/")?; + url.query_pairs_mut().append_pair("safe", &safe.to_checksum(None)); + let origin = url.origin(); + let path = url.path().to_string(); + let mut visited = HashSet::new(); + let mut delegates = Vec::new(); + loop { + ensure!(visited.insert(url.clone()), "delegate pagination contains a cycle at {url}"); + let mut response: SafeDelegatesResponse = + service.response(service.request(Method::GET, url.clone())).await?; + delegates.append(&mut response.results); + let Some(next) = response.next else { break }; + let next = url.join(&next).wrap_err("invalid delegate pagination URL")?; + ensure!( + next.origin() == origin && next.path() == path, + "delegate pagination URL points outside the Transaction Service endpoint: {next}" + ); + url = next; + } + print_json_object(delegates) + } +} + +/// CLI arguments for `cast safe remove-delegate`. +#[derive(Args, Debug)] +pub struct RemoveDelegateArgs { + /// Safe account address. + safe: Address, + + /// Delegate address to remove. + delegate: Address, + + #[command(flatten)] + service: Box, + + #[command(flatten)] + rpc: Box, + + #[command(flatten)] + wallet: Box, +} + +impl RemoveDelegateArgs { + pub(super) async fn run(self) -> Result<()> { + let Self { safe, delegate, service, rpc, wallet } = self; + let path = format!("v2/delegates/{}/", delegate.to_checksum(None)); + submit_delegate(&service, &rpc, &wallet, safe, delegate, Method::DELETE, &path, json!({})) + .await + } +} + +/// Signs the delegate TOTP message with the wallet and submits `body` (extended with the Safe, +/// delegator and signature) to the Transaction Service, printing the delegate on success. +#[allow(clippy::too_many_arguments)] +async fn submit_delegate( + service: &SafeServiceOpts, + rpc: &RpcOpts, + wallet: &WalletOpts, + safe: Address, + delegate: Address, + method: Method, + path: &str, + mut body: Value, +) -> Result<()> { + let (_, chain_id) = rpc_provider(rpc).await?; + let signer = wallet.signer().await?; + let signature = sign_delegate(&signer, delegate, chain_id).await?; + body["safe"] = safe.to_checksum(None).into(); + body["delegator"] = signer.address().to_checksum(None).into(); + body["signature"] = signature.into(); + let url = service.endpoint(chain_id, path)?; + service.empty_response(service.request(method, url).json(&body)).await?; + print_scalar(delegate.to_checksum(None)) +} diff --git a/crates/cast/src/cmd/safe/deploy.rs b/crates/cast/src/cmd/safe/deploy.rs new file mode 100644 index 0000000000000..9497468497b7f --- /dev/null +++ b/crates/cast/src/cmd/safe/deploy.rs @@ -0,0 +1,190 @@ +use super::{ + contracts::{ + COMPATIBILITY_FALLBACK_HANDLER_V1_4_1, ISafe, ISafeProxyFactory, PREDETERMINED_SALT_NONCE, + SAFE_L2_V1_4_1, SAFE_PROXY_FACTORY_V1_4_1, SAFE_V1_4_1, SENTINEL_OWNER, + }, + rpc_provider, + transaction::SafeSendOpts, +}; +use alloy_network::Ethereum; +use alloy_primitives::{Address, Bytes, U256, keccak256, map::AddressHashSet}; +use alloy_provider::Provider; +use alloy_sol_types::{SolCall, SolEvent}; +use clap::Args; +use eyre::{Context, Result, ensure}; +use foundry_cli::json::print_scalar; +use foundry_common::sh_status; + +/// CLI arguments for `cast safe create`. +#[derive(Args, Debug)] +pub struct CreateArgs { + /// Addresses that own the Safe. + #[arg(required = true, num_args = 1..)] + owners: Vec
, + + /// Number of owner signatures required. Defaults to all owners. + #[arg(long)] + threshold: Option, + + /// CREATE2 salt nonce. Defaults to Safe Protocol Kit's chain-specific nonce. + #[arg(long)] + salt_nonce: Option, + + /// Safe singleton address. Defaults to the canonical v1.4.1 deployment. + #[arg(long, conflicts_with = "l1")] + singleton: Option
, + + /// Use the L1 Safe singleton instead of SafeL2. + #[arg(long)] + l1: bool, + + /// SafeProxyFactory address. + #[arg(long, default_value_t = SAFE_PROXY_FACTORY_V1_4_1)] + factory: Address, + + /// CompatibilityFallbackHandler address. Pass the zero address to disable it. + #[arg(long, default_value_t = COMPATIBILITY_FALLBACK_HANDLER_V1_4_1)] + fallback_handler: Address, + + /// Number of confirmations to wait for. + #[arg(long, default_value = "1")] + confirmations: u64, + + /// Timeout for deployment confirmation, in seconds. + #[arg(long, env = "ETH_TIMEOUT")] + timeout: Option, + + /// Polling interval for the deployment receipt, in seconds. + #[arg(long, env = "ETH_POLL_INTERVAL")] + poll_interval: Option, + + #[command(flatten)] + send: SafeSendOpts, +} + +impl CreateArgs { + pub(super) async fn run(self) -> Result<()> { + let Self { + owners, + threshold, + salt_nonce, + singleton, + l1, + factory, + fallback_handler, + confirmations, + timeout, + poll_interval, + send, + } = self; + let threshold = validate_owners(&owners, threshold)?; + let (provider, chain_id) = rpc_provider(&send.rpc).await?; + let singleton = + singleton.unwrap_or(if l1 || chain_id == 1 { SAFE_V1_4_1 } else { SAFE_L2_V1_4_1 }); + ensure_contract(&provider, singleton, "Safe singleton", "--singleton").await?; + ensure_contract(&provider, factory, "SafeProxyFactory", "--factory").await?; + if !fallback_handler.is_zero() { + ensure_contract( + &provider, + fallback_handler, + "CompatibilityFallbackHandler", + "--fallback-handler", + ) + .await?; + } + + let initializer = ISafe::setupCall { + owners, + threshold: U256::from(threshold), + to: Address::ZERO, + data: Bytes::new(), + fallbackHandler: fallback_handler, + paymentToken: Address::ZERO, + payment: U256::ZERO, + paymentReceiver: Address::ZERO, + } + .abi_encode() + .into(); + + sh_status!("Deploying Safe with singleton {singleton}")?; + let calldata = ISafeProxyFactory::createProxyWithNonceCall { + singleton, + initializer, + saltNonce: salt_nonce.unwrap_or_else(|| default_salt_nonce(chain_id)), + } + .abi_encode() + .into(); + let result = send + .send(factory, calldata, confirmations, timeout, poll_interval) + .await + .wrap_err("failed to submit Safe deployment")?; + let deployed = result + .logs + .iter() + .filter(|log| log.address() == factory) + .find_map(|log| ISafeProxyFactory::ProxyCreation::decode_log(&log.inner).ok()) + .ok_or_else(|| eyre::eyre!("Safe deployment receipt did not emit ProxyCreation"))?; + sh_status!("Transaction hash: {}", result.tx_hash)?; + print_scalar(deployed.proxy.to_checksum(None)) + } +} + +fn validate_owners(owners: &[Address], threshold: Option) -> Result { + ensure!(!owners.is_empty(), "at least one Safe owner is required"); + let threshold = threshold.unwrap_or(owners.len()); + ensure!(threshold > 0, "Safe threshold must be greater than zero"); + ensure!( + threshold <= owners.len(), + "Safe threshold ({threshold}) exceeds owner count ({})", + owners.len() + ); + let mut unique = AddressHashSet::default(); + for owner in owners { + ensure!(!owner.is_zero(), "Safe owner cannot be the zero address"); + ensure!(*owner != SENTINEL_OWNER, "Safe owner cannot be the sentinel address"); + ensure!(unique.insert(*owner), "duplicate Safe owner: {owner}"); + } + Ok(threshold) +} + +fn default_salt_nonce(chain_id: u64) -> U256 { + U256::from_be_slice(keccak256(format!("{PREDETERMINED_SALT_NONCE}{chain_id}")).as_slice()) +} + +pub(super) async fn ensure_contract>( + provider: &P, + address: Address, + name: &str, + flag: &str, +) -> Result<()> { + ensure!( + !provider.get_code_at(address).await?.is_empty(), + "{name} is not deployed at {address}; provide the network deployment with {flag}" + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + #[test] + fn validates_safe_owner_configuration() { + let owners = [Address::repeat_byte(1), Address::repeat_byte(2)]; + assert_eq!(validate_owners(&owners, None).unwrap(), 2); + assert_eq!(validate_owners(&owners, Some(1)).unwrap(), 1); + assert!(validate_owners(&owners, Some(3)).is_err()); + assert!(validate_owners(&[owners[0], owners[0]], None).is_err()); + assert!(validate_owners(&[Address::ZERO], None).is_err()); + } + + #[test] + fn matches_protocol_kit_default_salt_nonce() { + assert_eq!( + default_salt_nonce(1), + U256::from_str("0x69b348339eea4ed93f9d11931c3b894c8f9d8c7663a053024b11cb7eb4e5a1f6") + .unwrap() + ); + } +} diff --git a/crates/cast/src/cmd/safe/execute.rs b/crates/cast/src/cmd/safe/execute.rs new file mode 100644 index 0000000000000..05ca9d50e1023 --- /dev/null +++ b/crates/cast/src/cmd/safe/execute.rs @@ -0,0 +1,149 @@ +use super::{ + contracts::ISafe, + rpc_provider, + service::{SafeServiceOpts, SafeTransaction}, + transaction::SafeSendOpts, +}; +use alloy_primitives::{Address, B256}; +use alloy_rpc_types::Log; +use alloy_sol_types::{SolCall, SolEvent}; +use clap::Args; +use eyre::{Context, Result, ensure}; +use foundry_cli::json::print_scalar; +use foundry_common::sh_status; + +/// CLI arguments for `cast safe execute`. +#[derive(Args, Debug)] +pub struct ExecuteArgs { + /// Safe account address. + safe: Address, + + /// Safe transaction hash from the Transaction Service. + safe_tx_hash: B256, + + /// Number of confirmations to wait for. + #[arg(long, default_value = "1")] + confirmations: u64, + + /// Timeout for execution confirmation, in seconds. + #[arg(long, env = "ETH_TIMEOUT")] + timeout: Option, + + /// Polling interval for the execution receipt, in seconds. + #[arg(long, env = "ETH_POLL_INTERVAL")] + poll_interval: Option, + + #[command(flatten)] + service: Box, + + #[command(flatten)] + send: SafeSendOpts, +} + +impl ExecuteArgs { + pub(super) async fn run(self) -> Result<()> { + let Self { safe, safe_tx_hash, confirmations, timeout, poll_interval, service, send } = + self; + let (provider, chain_id) = rpc_provider(&send.rpc).await?; + let transaction = service.get_transaction(chain_id, "v2", safe_tx_hash).await?; + ensure!( + !transaction.is_executed && transaction.transaction_hash.is_none(), + "Safe transaction has already been executed{}", + transaction + .transaction_hash + .map(|hash| format!(" onchain as {hash}")) + .unwrap_or_default() + ); + + transaction.verify_hash(safe, &provider).await?; + let transaction_nonce = SafeTransaction::number(&transaction.nonce, "nonce")?; + let current_nonce = ISafe::new(safe, &provider) + .nonce() + .call() + .await + .wrap_err("failed to read Safe nonce")?; + ensure!( + transaction_nonce == current_nonce, + "Safe transaction nonce {transaction_nonce} does not match current Safe nonce {current_nonce}" + ); + transaction.show_transaction_summary()?; + let signatures = transaction.packed_signatures()?; + + sh_status!("Executing Safe transaction {safe_tx_hash}")?; + let calldata = ISafe::execTransactionCall { + to: transaction.to, + value: SafeTransaction::number(&transaction.value, "value")?, + data: transaction.data.clone(), + operation: transaction.operation, + safeTxGas: SafeTransaction::number(&transaction.safe_tx_gas, "safeTxGas")?, + baseGas: SafeTransaction::number(&transaction.base_gas, "baseGas")?, + gasPrice: SafeTransaction::number(&transaction.gas_price, "gasPrice")?, + gasToken: transaction.gas_token, + refundReceiver: transaction.refund_receiver, + signatures, + } + .abi_encode() + .into(); + let result = send + .send(safe, calldata, confirmations, timeout, poll_interval) + .await + .wrap_err("failed to submit Safe transaction")?; + ensure!( + execution_succeeded(&result.logs, safe, safe_tx_hash)?, + "Safe inner transaction failed" + ); + print_scalar(result.tx_hash) + } +} + +/// Returns the outcome of the last `ExecutionSuccess`/`ExecutionFailure` event for `safe_tx_hash`. +fn execution_succeeded(logs: &[Log], safe: Address, safe_tx_hash: B256) -> Result { + logs.iter() + .rev() + .filter(|log| log.address() == safe) + .find_map(|log| { + if let Ok(event) = ISafe::ExecutionSuccess::decode_log(&log.inner) + && event.txHash == safe_tx_hash + { + return Some(true); + } + if let Ok(event) = ISafe::ExecutionFailure::decode_log(&log.inner) + && event.txHash == safe_tx_hash + { + return Some(false); + } + None + }) + .ok_or_else(|| eyre::eyre!("Safe execution receipt did not emit a matching result event")) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::{LogData, U256}; + + fn log(safe: Address, data: LogData) -> Log { + Log { inner: alloy_primitives::Log { address: safe, data }, ..Default::default() } + } + + #[test] + fn uses_last_matching_safe_execution_event() { + let safe = Address::repeat_byte(1); + let hash = B256::repeat_byte(2); + let success = || { + log( + safe, + ISafe::ExecutionSuccess { txHash: hash, payment: U256::ZERO }.encode_log_data(), + ) + }; + let failure = || { + log( + safe, + ISafe::ExecutionFailure { txHash: hash, payment: U256::ZERO }.encode_log_data(), + ) + }; + + assert!(execution_succeeded(&[failure(), success()], safe, hash).unwrap()); + assert!(!execution_succeeded(&[success(), failure()], safe, hash).unwrap()); + } +} diff --git a/crates/cast/src/cmd/safe/mod.rs b/crates/cast/src/cmd/safe/mod.rs new file mode 100644 index 0000000000000..7b93aaf448bbd --- /dev/null +++ b/crates/cast/src/cmd/safe/mod.rs @@ -0,0 +1,85 @@ +use alloy_network::Ethereum; +use alloy_provider::Provider; +use clap::{Parser, ValueEnum}; +use eyre::Result; +use foundry_cli::{opts::RpcOpts, utils::LoadConfig}; +use foundry_common::provider::{ProviderBuilder, RetryProvider}; + +mod contracts; +mod delegates; +mod deploy; +mod execute; +mod proposal; +mod service; +mod signing; +mod simulate; +mod transaction; + +/// Safe transaction operations. +#[derive(Debug, Parser)] +pub enum SafeSubcommand { + /// Deploy a Safe account. + /// + /// Examples: + /// - cast safe create $OWNER --threshold 1 --rpc-url $RPC --ledger + /// - cast safe create $OWNER_1 $OWNER_2 $OWNER_3 --threshold 2 --rpc-url $RPC --account + /// deployer + #[command(verbatim_doc_comment)] + Create(deploy::CreateArgs), + + /// Register a transaction-service delegate for a Safe owner. + AddDelegate(delegates::AddDelegateArgs), + + /// List transaction-service delegates registered for a Safe. + ListDelegates(delegates::ListDelegatesArgs), + + /// Remove a transaction-service delegate for a Safe owner. + RemoveDelegate(delegates::RemoveDelegateArgs), + + /// Create, sign, and submit a Safe transaction proposal. + Propose(proposal::ProposeArgs), + + /// Sign and submit a confirmation for a proposed Safe transaction. + Sign(proposal::SignArgs), + + /// Simulate a proposed Safe transaction without requiring owner signatures. + /// + /// This simulates the inner CALL or DELEGATECALL in the Safe's context. It does not validate + /// the Safe nonce, owner signatures, threshold, or guard hooks. Reimbursed transactions + /// (`gasPrice > 0`) are rejected because SimulateTxAccessor does not enforce `safeTxGas`. + #[command(verbatim_doc_comment)] + Simulate(simulate::SimulateArgs), + + /// Execute a confirmed Safe transaction onchain. + Execute(execute::ExecuteArgs), +} + +#[derive(Clone, Copy, Debug, Default, ValueEnum)] +#[repr(u8)] +pub enum SafeOperation { + #[default] + Call = 0, + DelegateCall = 1, +} + +impl SafeSubcommand { + pub async fn run(self) -> Result<()> { + match self { + Self::Create(args) => args.run().await, + Self::AddDelegate(args) => args.run().await, + Self::ListDelegates(args) => args.run().await, + Self::RemoveDelegate(args) => args.run().await, + Self::Propose(args) => args.run().await, + Self::Sign(args) => args.run().await, + Self::Simulate(args) => args.run().await, + Self::Execute(args) => args.run().await, + } + } +} + +/// Builds the read-only provider for `rpc` and returns it together with its chain ID. +async fn rpc_provider(rpc: &RpcOpts) -> Result<(RetryProvider, u64)> { + let provider = ProviderBuilder::::from_config(&rpc.load_config()?)?.build()?; + let chain_id = provider.get_chain_id().await?; + Ok((provider, chain_id)) +} diff --git a/crates/cast/src/cmd/safe/proposal.rs b/crates/cast/src/cmd/safe/proposal.rs new file mode 100644 index 0000000000000..249925449b64f --- /dev/null +++ b/crates/cast/src/cmd/safe/proposal.rs @@ -0,0 +1,185 @@ +use super::{ + SafeOperation, + contracts::ISafe, + rpc_provider, + service::{SafeServiceOpts, SafeTransaction}, + signing::sign_safe_hash, +}; +use alloy_primitives::{Address, B256, Bytes, U256}; +use alloy_signer::Signer; +use clap::Args; +use eyre::Result; +use foundry_cli::{json::print_scalar, opts::RpcOpts, utils::parse_ether_value}; +use foundry_common::abi::{encode_function_args, get_func}; +use foundry_wallets::WalletOpts; +use reqwest::Method; +use serde_json::json; + +/// CLI arguments for `cast safe propose`. +#[derive(Args, Debug)] +pub struct ProposeArgs { + /// Safe account address. + safe: Address, + + /// Transaction target. + to: Address, + + /// Function signature to call. + sig: Option, + + /// Function arguments. + #[arg(allow_negative_numbers = true)] + args: Vec, + + /// Raw calldata. Cannot be combined with a function signature or arguments. + #[arg(long, conflicts_with_all = ["sig", "args"])] + data: Option, + + /// Native token value sent by the Safe. + #[arg(long, default_value = "0", value_parser = parse_ether_value)] + value: U256, + + /// Safe operation type. + #[arg(long, value_enum, default_value_t = SafeOperation::Call)] + operation: SafeOperation, + + /// Safe transaction gas. + #[arg(long, default_value = "0")] + safe_tx_gas: U256, + + /// Base gas reimbursed by the Safe. + #[arg(long, default_value = "0")] + base_gas: U256, + + /// Gas price reimbursed by the Safe. + #[arg(long, default_value = "0")] + gas_price: U256, + + /// Token used for gas reimbursement. + #[arg(long, default_value_t = Address::ZERO)] + gas_token: Address, + + /// Gas reimbursement receiver. + #[arg(long, default_value_t = Address::ZERO)] + refund_receiver: Address, + + /// Safe nonce. Defaults to the next queued Transaction Service nonce. + #[arg(long)] + nonce: Option, + + /// Optional origin shown by Safe clients. + #[arg(long)] + origin: Option, + + #[command(flatten)] + service: Box, + + #[command(flatten)] + rpc: Box, + + #[command(flatten)] + wallet: Box, +} + +impl ProposeArgs { + pub(super) async fn run(self) -> Result<()> { + let Self { + safe, + to, + sig, + args, + data, + value, + operation, + safe_tx_gas, + base_gas, + gas_price, + gas_token, + refund_receiver, + nonce, + origin, + service, + rpc, + wallet, + } = self; + let data = match (data, sig) { + (Some(data), None) => data, + (None, Some(sig)) => encode_function_args(&get_func(&sig)?, &args)?.into(), + (None, None) => Bytes::new(), + (Some(_), Some(_)) => unreachable!("enforced by clap"), + }; + let (provider, chain_id) = rpc_provider(&rpc).await?; + let nonce = match nonce { + Some(nonce) => nonce, + None => { + let onchain_nonce = ISafe::new(safe, &provider).nonce().call().await?; + service.next_nonce(chain_id, safe, onchain_nonce).await? + } + }; + let mut transaction = SafeTransaction { + safe, + to, + value: value.to_string(), + data, + operation: operation as u8, + safe_tx_gas: safe_tx_gas.to_string(), + base_gas: base_gas.to_string(), + gas_price: gas_price.to_string(), + gas_token, + refund_receiver, + nonce: nonce.to_string(), + safe_tx_hash: B256::ZERO, + confirmations: Vec::new(), + is_executed: false, + transaction_hash: None, + }; + transaction.safe_tx_hash = transaction.calculate_hash(&provider).await?; + transaction.show_transaction_summary()?; + let signer = wallet.signer().await?; + let signature = sign_safe_hash(&signer, transaction.safe_tx_hash).await?; + let url = service.endpoint( + chain_id, + &format!("v2/safes/{}/multisig-transactions/", transaction.safe.to_checksum(None)), + )?; + let body = transaction.proposal_body(signer.address(), signature, origin); + service.empty_response(service.request(Method::POST, url).json(&body)).await?; + print_scalar(transaction.safe_tx_hash) + } +} + +/// CLI arguments for `cast safe sign`. +#[derive(Args, Debug)] +pub struct SignArgs { + /// Safe account address. + safe: Address, + + /// Safe transaction hash from the Transaction Service. + safe_tx_hash: B256, + + #[command(flatten)] + service: Box, + + #[command(flatten)] + rpc: Box, + + #[command(flatten)] + wallet: Box, +} + +impl SignArgs { + pub(super) async fn run(self) -> Result<()> { + let Self { safe, safe_tx_hash, service, rpc, wallet } = self; + let (provider, chain_id) = rpc_provider(&rpc).await?; + let transaction = service.get_transaction(chain_id, "v1", safe_tx_hash).await?; + transaction.verify_hash(safe, &provider).await?; + transaction.show_transaction_summary()?; + let signature = sign_safe_hash(&wallet.signer().await?, safe_tx_hash).await?; + let url = service.endpoint( + chain_id, + &format!("v1/multisig-transactions/{safe_tx_hash}/confirmations/"), + )?; + let body = json!({ "signature": signature }); + service.empty_response(service.request(Method::POST, url).json(&body)).await?; + print_scalar(signature) + } +} diff --git a/crates/cast/src/cmd/safe/service.rs b/crates/cast/src/cmd/safe/service.rs new file mode 100644 index 0000000000000..df4641c78dc44 --- /dev/null +++ b/crates/cast/src/cmd/safe/service.rs @@ -0,0 +1,653 @@ +use super::contracts::ISafe; +use alloy_network::Ethereum; +use alloy_primitives::{Address, B256, Bytes, U256}; +use clap::Args; +use eyre::{Context, Result, ensure}; +use foundry_common::sh_status; +use reqwest::{Client, Method, Url, header}; +use serde::{Deserialize, Deserializer, Serialize, de::DeserializeOwned}; +use serde_json::{Value, json}; +use std::str::FromStr; + +const SAFE_SIGNATURE_LENGTH: usize = 65; +const CONTRACT_SIGNATURE_HEADER_LENGTH: usize = SAFE_SIGNATURE_LENGTH + U256::BYTES; +const P256_SIGNATURE_DATA_LENGTH: usize = 128; +const P256_SIGNATURE_LENGTH: usize = SAFE_SIGNATURE_LENGTH + P256_SIGNATURE_DATA_LENGTH; + +#[derive(Args, Clone, Debug)] +pub struct SafeServiceOpts { + /// Safe Transaction Service URL. Inferred from the RPC chain ID when omitted. + /// The `/api` suffix is optional. + #[arg(long, env = "SAFE_TRANSACTION_SERVICE_URL")] + pub(super) service_url: Option, + + /// Safe Transaction Service API key. + #[arg(long, env = "SAFE_API_KEY")] + api_key: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct SafeTransaction { + pub(super) safe: Address, + pub(super) to: Address, + #[serde(deserialize_with = "deserialize_number_string")] + pub(super) value: String, + #[serde(default, deserialize_with = "deserialize_null_default")] + pub(super) data: Bytes, + pub(super) operation: u8, + #[serde(deserialize_with = "deserialize_number_string")] + pub(super) safe_tx_gas: String, + #[serde(deserialize_with = "deserialize_number_string")] + pub(super) base_gas: String, + #[serde(deserialize_with = "deserialize_number_string")] + pub(super) gas_price: String, + #[serde(default, deserialize_with = "deserialize_null_default")] + pub(super) gas_token: Address, + #[serde(default, deserialize_with = "deserialize_null_default")] + pub(super) refund_receiver: Address, + #[serde(deserialize_with = "deserialize_number_string")] + pub(super) nonce: String, + #[serde(alias = "contractTransactionHash")] + pub(super) safe_tx_hash: B256, + #[serde(default, deserialize_with = "deserialize_null_default")] + pub(super) confirmations: Vec, + #[serde(default)] + pub(super) is_executed: bool, + #[serde(default)] + pub(super) transaction_hash: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct SafeConfirmation { + owner: Address, + #[serde(default)] + signature: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct SafeDelegate { + safe: Option
, + delegate: Address, + delegator: Address, + label: String, +} + +#[derive(Debug, Deserialize)] +pub(super) struct SafeDelegatesResponse { + #[serde(default)] + pub(super) next: Option, + pub(super) results: Vec, +} + +impl SafeTransaction { + pub(super) fn number(value: &str, field: &str) -> Result { + U256::from_str(value).wrap_err_with(|| format!("invalid {field} in transaction")) + } + + pub(super) async fn calculate_hash

(&self, provider: &P) -> Result + where + P: alloy_provider::Provider, + { + ISafe::new(self.safe, provider) + .getTransactionHash( + self.to, + Self::number(&self.value, "value")?, + self.data.clone(), + self.operation, + Self::number(&self.safe_tx_gas, "safeTxGas")?, + Self::number(&self.base_gas, "baseGas")?, + Self::number(&self.gas_price, "gasPrice")?, + self.gas_token, + self.refund_receiver, + Self::number(&self.nonce, "nonce")?, + ) + .call() + .await + .wrap_err("failed to calculate Safe transaction hash") + } + + pub(super) async fn verify_hash

(&self, expected_safe: Address, provider: &P) -> Result<()> + where + P: alloy_provider::Provider, + { + ensure!( + self.safe == expected_safe, + "Transaction Service returned Safe {}, expected {expected_safe}", + self.safe + ); + ensure!(self.operation <= 1, "invalid Safe operation: {}", self.operation); + let calculated = self.calculate_hash(provider).await?; + ensure!( + calculated == self.safe_tx_hash, + "Safe transaction hash mismatch: service/file returned {}, calculated {calculated}", + self.safe_tx_hash + ); + Ok(()) + } + + pub(super) fn proposal_body( + &self, + sender: Address, + signature: String, + origin: Option, + ) -> Value { + let mut body = json!({ + "to": self.to.to_checksum(None), + "value": self.value, + "data": self.data, + "operation": self.operation, + "safeTxGas": self.safe_tx_gas, + "baseGas": self.base_gas, + "gasPrice": self.gas_price, + "gasToken": self.gas_token.to_checksum(None), + "refundReceiver": self.refund_receiver.to_checksum(None), + "nonce": self.nonce, + "contractTransactionHash": self.safe_tx_hash, + "sender": sender.to_checksum(None), + "signature": signature, + }); + if let Some(origin) = origin { + body["origin"] = Value::String(origin); + } + body + } + + pub(super) fn packed_signatures(&self) -> Result { + let mut confirmations = self.confirmations.iter().collect::>(); + confirmations.sort_unstable_by_key(|confirmation| confirmation.owner); + let static_len = confirmations.len() * SAFE_SIGNATURE_LENGTH; + let mut signatures = Vec::with_capacity(static_len); + let mut dynamic = Vec::new(); + for confirmation in confirmations { + let signature = confirmation.signature.as_ref().ok_or_else(|| { + eyre::eyre!("confirmation from {} does not contain a signature", confirmation.owner) + })?; + ensure!( + signature.len() >= SAFE_SIGNATURE_LENGTH, + "invalid signature from {}: expected at least {SAFE_SIGNATURE_LENGTH} bytes, got {}", + confirmation.owner, + signature.len() + ); + let v = signature[SAFE_SIGNATURE_LENGTH - 1]; + match v { + // Contract (v = 0) and P-256 (v = 2) signatures carry dynamic data after the + // static part, which points at the dynamic offset. + 0 | 2 => { + let kind = if v == 0 { "contract" } else { "P-256" }; + if v == 0 { + ensure!( + signature.len() >= CONTRACT_SIGNATURE_HEADER_LENGTH, + "contract signature from {} does not contain a length", + confirmation.owner + ); + let data_len = U256::from_be_slice( + &signature[SAFE_SIGNATURE_LENGTH..CONTRACT_SIGNATURE_HEADER_LENGTH], + ); + ensure!( + data_len + == U256::from(signature.len() - CONTRACT_SIGNATURE_HEADER_LENGTH), + "invalid contract signature length from {}: expected {}, got {data_len}", + confirmation.owner, + signature.len() - CONTRACT_SIGNATURE_HEADER_LENGTH + ); + } else { + ensure!( + signature.len() == P256_SIGNATURE_LENGTH, + "invalid P-256 signature from {}: expected {P256_SIGNATURE_LENGTH} bytes, got {}", + confirmation.owner, + signature.len() + ); + } + let offset = + U256::from_be_slice(&signature[U256::BYTES..SAFE_SIGNATURE_LENGTH - 1]); + ensure!( + offset == U256::from(SAFE_SIGNATURE_LENGTH), + "invalid {kind} signature offset from {}: expected {SAFE_SIGNATURE_LENGTH}, got {offset}", + confirmation.owner + ); + + signatures.extend_from_slice(&signature[..U256::BYTES]); + signatures.extend_from_slice( + &U256::from(static_len + dynamic.len()).to_be_bytes::<{ U256::BYTES }>(), + ); + signatures.push(v); + dynamic.extend_from_slice(&signature[SAFE_SIGNATURE_LENGTH..]); + } + 1 => { + eyre::bail!( + "approved-hash signatures (v = 1) are not supported by `cast safe execute`" + ); + } + _ => { + ensure!( + signature.len() == SAFE_SIGNATURE_LENGTH, + "invalid signature from {}: expected {SAFE_SIGNATURE_LENGTH} bytes, got {}", + confirmation.owner, + signature.len() + ); + signatures.extend_from_slice(signature); + } + } + } + ensure!(!signatures.is_empty(), "Safe transaction has no confirmations"); + signatures.extend_from_slice(&dynamic); + Ok(signatures.into()) + } + + pub(super) fn show_transaction_summary(&self) -> Result<()> { + let operation = if self.operation == 0 { "CALL" } else { "DELEGATECALL" }; + sh_status!("Safe transaction: {}", self.safe_tx_hash)?; + sh_status!(" Safe: {}", self.safe)?; + sh_status!(" To: {}", self.to)?; + sh_status!(" Value: {}", self.value)?; + sh_status!(" Operation: {} ({operation})", self.operation)?; + sh_status!(" Safe tx gas: {}", self.safe_tx_gas)?; + sh_status!(" Base gas: {}", self.base_gas)?; + sh_status!(" Gas price: {}", self.gas_price)?; + sh_status!(" Gas token: {}", self.gas_token)?; + sh_status!(" Refund receiver: {}", self.refund_receiver)?; + sh_status!(" Nonce: {}", self.nonce)?; + sh_status!(" Data: {}", self.data)?; + Ok(()) + } +} + +impl SafeServiceOpts { + pub(super) fn endpoint(&self, chain_id: u64, path: &str) -> Result { + let mut url = match &self.service_url { + Some(url) => url.clone(), + None => default_service_url(chain_id)?, + }; + let mut endpoint = url.path().trim_end_matches('/').to_string(); + if !endpoint.ends_with("/api") { + endpoint.push_str("/api"); + } + endpoint.push('/'); + endpoint.push_str(path.trim_start_matches('/')); + url.set_path(&endpoint); + url.set_query(None); + url.set_fragment(None); + Ok(url) + } + + pub(super) fn request(&self, method: Method, url: Url) -> reqwest::RequestBuilder { + let request = Client::new() + .request(method, url) + .header(header::ACCEPT, "application/json") + .header(header::CONTENT_TYPE, "application/json"); + if let Some(api_key) = &self.api_key { request.bearer_auth(api_key) } else { request } + } + + /// Sends `request` and returns the successful response body. + async fn send(&self, request: reqwest::RequestBuilder) -> Result { + let response = request.send().await.wrap_err("Safe Transaction Service request failed")?; + let status = response.status(); + let text = response.text().await.wrap_err("failed to read Transaction Service response")?; + ensure!(status.is_success(), "Safe Transaction Service returned {status}: {}", text.trim()); + Ok(text) + } + + pub(super) async fn response( + &self, + request: reqwest::RequestBuilder, + ) -> Result { + serde_json::from_str(&self.send(request).await?) + .wrap_err("invalid Transaction Service response") + } + + pub(super) async fn empty_response(&self, request: reqwest::RequestBuilder) -> Result<()> { + self.send(request).await.map(drop) + } + + pub(super) async fn get_transaction( + &self, + chain_id: u64, + api_version: &str, + safe_tx_hash: B256, + ) -> Result { + let url = self + .endpoint(chain_id, &format!("{api_version}/multisig-transactions/{safe_tx_hash}/"))?; + let transaction: SafeTransaction = self.response(self.request(Method::GET, url)).await?; + ensure!( + transaction.safe_tx_hash == safe_tx_hash, + "Transaction Service returned a different Safe transaction hash" + ); + Ok(transaction) + } + + pub(super) async fn next_nonce( + &self, + chain_id: u64, + safe: Address, + onchain_nonce: U256, + ) -> Result { + let response: Value = self + .response(self.request(Method::GET, self.next_nonce_endpoint(chain_id, safe)?)) + .await?; + let Some(nonce) = response + .get("results") + .and_then(Value::as_array) + .and_then(|results| results.first()) + .and_then(|tx| tx.get("nonce")) + else { + return Ok(onchain_nonce); + }; + let nonce = match nonce { + Value::String(nonce) => U256::from_str(nonce), + Value::Number(nonce) => U256::from_str(&nonce.to_string()), + _ => eyre::bail!("invalid nonce returned by Safe Transaction Service"), + } + .wrap_err("invalid nonce returned by Safe Transaction Service")?; + Ok(std::cmp::max(onchain_nonce, nonce.saturating_add(U256::ONE))) + } + + fn next_nonce_endpoint(&self, chain_id: u64, safe: Address) -> Result { + let mut url = self.endpoint( + chain_id, + &format!("v1/safes/{}/multisig-transactions/", safe.to_checksum(None)), + )?; + url.query_pairs_mut() + .append_pair("executed", "false") + .append_pair("ordering", "-nonce") + .append_pair("limit", "1"); + Ok(url) + } +} + +fn deserialize_number_string<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + match Value::deserialize(deserializer)? { + Value::String(value) => Ok(value), + Value::Number(value) => Ok(value.to_string()), + value => Err(serde::de::Error::custom(format!("expected number or string, got {value}"))), + } +} + +fn deserialize_null_default<'de, D, T>(deserializer: D) -> Result +where + D: Deserializer<'de>, + T: Deserialize<'de> + Default, +{ + Ok(Option::::deserialize(deserializer)?.unwrap_or_default()) +} + +fn default_service_url(chain_id: u64) -> Result { + let short_name = match chain_id { + 1 => "eth", + 10 => "oeth", + 56 => "bnb", + 100 => "gno", + 130 => "unichain", + 137 => "pol", + 146 => "sonic", + 196 => "okb", + 232 => "lens", + 324 => "zksync", + 480 => "wc", + 4217 => "tempo", + 5000 => "mantle", + 8453 => "base", + 9745 => "plasma", + 10200 => "chi", + 42161 => "arb1", + 42220 => "celo", + 42431 => "tempo-moderato", + 43114 => "avax", + 43111 => "hemi", + 57073 => "ink", + 59144 => "linea", + 747474 => "katana", + 80094 => "berachain", + 84532 => "basesep", + 534352 => "scr", + 11155111 => "sep", + 1313161554 => "aurora", + _ => eyre::bail!( + "no known Safe Transaction Service for chain ID {chain_id}; pass --service-url" + ), + }; + format!("https://api.safe.global/tx-service/{short_name}") + .parse() + .wrap_err("invalid built-in Safe Transaction Service URL") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn transaction() -> SafeTransaction { + SafeTransaction { + safe: Address::ZERO, + to: Address::ZERO, + value: "1".to_string(), + data: Bytes::new(), + operation: 0, + safe_tx_gas: "0".to_string(), + base_gas: "0".to_string(), + gas_price: "0".to_string(), + gas_token: Address::ZERO, + refund_receiver: Address::ZERO, + nonce: "7".to_string(), + safe_tx_hash: B256::ZERO, + confirmations: Vec::new(), + is_executed: false, + transaction_hash: None, + } + } + + fn eoa_signature(byte: u8) -> Bytes { + let mut signature = vec![byte; SAFE_SIGNATURE_LENGTH]; + signature[SAFE_SIGNATURE_LENGTH - 1] = 27; + signature.into() + } + + fn contract_signature(owner: Address, payload: &[u8]) -> Bytes { + let mut signature = Vec::with_capacity(CONTRACT_SIGNATURE_HEADER_LENGTH + payload.len()); + signature.extend_from_slice(owner.into_word().as_slice()); + signature.extend_from_slice(&U256::from(SAFE_SIGNATURE_LENGTH).to_be_bytes::<32>()); + signature.push(0); + signature.extend_from_slice(&U256::from(payload.len()).to_be_bytes::<32>()); + signature.extend_from_slice(payload); + signature.into() + } + + fn p256_signature(owner: Address, payload: &[u8; 128]) -> Bytes { + let mut signature = Vec::with_capacity(SAFE_SIGNATURE_LENGTH + payload.len()); + signature.extend_from_slice(owner.into_word().as_slice()); + signature.extend_from_slice(&U256::from(SAFE_SIGNATURE_LENGTH).to_be_bytes::<32>()); + signature.push(2); + signature.extend_from_slice(payload); + signature.into() + } + + #[test] + fn normalizes_transaction_service_urls() { + for base in [ + "https://api.safe.global/tx-service/tempo-moderato/", + "https://api.safe.global/tx-service/tempo-moderato/api", + ] { + let service = + SafeServiceOpts { service_url: Some(base.parse().unwrap()), api_key: None }; + assert_eq!( + service.endpoint(42431, "v2/delegates/").unwrap().as_str(), + "https://api.safe.global/tx-service/tempo-moderato/api/v2/delegates/" + ); + } + } + + #[test] + fn infers_tempo_transaction_service_urls() { + assert_eq!( + default_service_url(4217).unwrap().as_str(), + "https://api.safe.global/tx-service/tempo" + ); + assert_eq!( + default_service_url(42431).unwrap().as_str(), + "https://api.safe.global/tx-service/tempo-moderato" + ); + for chain_id in [1101, 81457, 31337] { + assert!(default_service_url(chain_id).is_err()); + } + } + + #[test] + fn builds_pending_nonce_query_as_url_parameters() { + let service = SafeServiceOpts { service_url: None, api_key: None }; + assert_eq!( + service.next_nonce_endpoint(42431, Address::ZERO).unwrap().as_str(), + "https://api.safe.global/tx-service/tempo-moderato/api/v1/safes/0x0000000000000000000000000000000000000000/multisig-transactions/?executed=false&ordering=-nonce&limit=1" + ); + } + + #[test] + fn parses_transaction_service_response() { + let transaction: SafeTransaction = serde_json::from_value(json!({ + "safe": Address::ZERO, + "to": Address::ZERO, + "value": "1", + "data": null, + "operation": 0, + "safeTxGas": 0, + "baseGas": "0", + "gasPrice": 0, + "gasToken": null, + "refundReceiver": null, + "nonce": 7, + "safeTxHash": B256::ZERO, + })) + .unwrap(); + + assert_eq!(transaction.data, Bytes::new()); + assert_eq!(transaction.nonce, "7"); + assert_eq!(transaction.gas_token, Address::ZERO); + assert_eq!(transaction.refund_receiver, Address::ZERO); + } + + #[test] + fn packs_confirmations_in_owner_order() { + let mut transaction = transaction(); + transaction.confirmations = vec![ + SafeConfirmation { owner: Address::repeat_byte(2), signature: Some(eoa_signature(2)) }, + SafeConfirmation { owner: Address::repeat_byte(1), signature: Some(eoa_signature(1)) }, + ]; + + let signatures = transaction.packed_signatures().unwrap(); + assert_eq!(&signatures[..64], &[1; 64]); + assert_eq!(signatures[64], 27); + assert_eq!(&signatures[65..129], &[2; 64]); + assert_eq!(signatures[129], 27); + } + + #[test] + fn packs_contract_signatures_after_static_signatures() { + let first_owner = Address::repeat_byte(1); + let third_owner = Address::repeat_byte(3); + let first_payload = [4, 5, 6]; + let third_payload = [7, 8, 9, 10]; + + let mut transaction = transaction(); + transaction.confirmations = vec![ + SafeConfirmation { + owner: third_owner, + signature: Some(contract_signature(third_owner, &third_payload)), + }, + SafeConfirmation { owner: Address::repeat_byte(2), signature: Some(eoa_signature(2)) }, + SafeConfirmation { + owner: first_owner, + signature: Some(contract_signature(first_owner, &first_payload)), + }, + ]; + + let signatures = transaction.packed_signatures().unwrap(); + assert_eq!(&signatures[..32], first_owner.into_word().as_slice()); + assert_eq!(U256::from_be_slice(&signatures[32..64]), U256::from(195)); + assert_eq!(signatures[64], 0); + assert_eq!(&signatures[65..130], &eoa_signature(2)); + assert_eq!(&signatures[130..162], third_owner.into_word().as_slice()); + assert_eq!(U256::from_be_slice(&signatures[162..194]), U256::from(230)); + assert_eq!(signatures[194], 0); + assert_eq!(U256::from_be_slice(&signatures[195..227]), U256::from(3)); + assert_eq!(&signatures[227..230], &first_payload); + assert_eq!(U256::from_be_slice(&signatures[230..262]), U256::from(4)); + assert_eq!(&signatures[262..], &third_payload); + } + + #[test] + fn packs_p256_and_contract_signatures_after_static_signatures() { + let p256_owner = Address::repeat_byte(1); + let contract_owner = Address::repeat_byte(3); + let p256_payload = [4; 128]; + let contract_payload = [5, 6, 7]; + let mut transaction = transaction(); + transaction.confirmations = vec![ + SafeConfirmation { + owner: contract_owner, + signature: Some(contract_signature(contract_owner, &contract_payload)), + }, + SafeConfirmation { owner: Address::repeat_byte(2), signature: Some(eoa_signature(2)) }, + SafeConfirmation { + owner: p256_owner, + signature: Some(p256_signature(p256_owner, &p256_payload)), + }, + ]; + + let signatures = transaction.packed_signatures().unwrap(); + assert_eq!(&signatures[..32], p256_owner.into_word().as_slice()); + assert_eq!(U256::from_be_slice(&signatures[32..64]), U256::from(195)); + assert_eq!(signatures[64], 2); + assert_eq!(&signatures[65..130], &eoa_signature(2)); + assert_eq!(&signatures[130..162], contract_owner.into_word().as_slice()); + assert_eq!(U256::from_be_slice(&signatures[162..194]), U256::from(323)); + assert_eq!(signatures[194], 0); + assert_eq!(&signatures[195..323], &p256_payload); + assert_eq!(U256::from_be_slice(&signatures[323..355]), U256::from(3)); + assert_eq!(&signatures[355..], &contract_payload); + } + + #[test] + fn rejects_malformed_confirmation_signatures() { + let owner = Address::repeat_byte(1); + let mut wrong_offset = vec![0; 97]; + wrong_offset[32..64].copy_from_slice(B256::with_last_byte(66).as_slice()); + let mut wrong_length = vec![0; 97]; + wrong_length[32..64].copy_from_slice(B256::with_last_byte(65).as_slice()); + wrong_length[65..97].copy_from_slice(B256::with_last_byte(1).as_slice()); + + for signature in [vec![1; 66], vec![0; 65], wrong_offset, wrong_length] { + let mut transaction = transaction(); + transaction.confirmations = + vec![SafeConfirmation { owner, signature: Some(signature.into()) }]; + assert!(transaction.packed_signatures().is_err()); + } + } + + #[test] + fn rejects_malformed_p256_signatures() { + let owner = Address::repeat_byte(1); + let signature = |len: usize, offset: usize| { + let mut signature = vec![0; len]; + signature[..U256::BYTES].copy_from_slice(owner.into_word().as_slice()); + signature[U256::BYTES..SAFE_SIGNATURE_LENGTH - 1] + .copy_from_slice(&U256::from(offset).to_be_bytes::<32>()); + signature[SAFE_SIGNATURE_LENGTH - 1] = 2; + signature + }; + + for signature in [ + signature(SAFE_SIGNATURE_LENGTH, SAFE_SIGNATURE_LENGTH), + signature(P256_SIGNATURE_LENGTH - 1, SAFE_SIGNATURE_LENGTH), + signature(P256_SIGNATURE_LENGTH + 1, SAFE_SIGNATURE_LENGTH), + signature(P256_SIGNATURE_LENGTH, SAFE_SIGNATURE_LENGTH + 1), + ] { + let mut transaction = transaction(); + transaction.confirmations = + vec![SafeConfirmation { owner, signature: Some(signature.into()) }]; + assert!(transaction.packed_signatures().is_err()); + } + } +} diff --git a/crates/cast/src/cmd/safe/signing.rs b/crates/cast/src/cmd/safe/signing.rs new file mode 100644 index 0000000000000..f23b523091bc4 --- /dev/null +++ b/crates/cast/src/cmd/safe/signing.rs @@ -0,0 +1,118 @@ +use alloy_dyn_abi::TypedData; +use alloy_primitives::{Address, B256, hex}; +use alloy_signer::Signer; +use eyre::Result; +use foundry_cli::utils::now; +use foundry_wallets::WalletSigner; +use serde_json::json; + +const DELEGATE_TOTP_PERIOD_SECS: u64 = 60 * 60; + +pub(super) async fn sign_delegate( + signer: &WalletSigner, + delegate: Address, + chain_id: u64, +) -> Result { + let totp = now().as_secs() / DELEGATE_TOTP_PERIOD_SECS; + let typed_data = delegate_typed_data(delegate, chain_id, totp)?; + sign_delegate_typed_data(signer, &typed_data, matches!(signer, WalletSigner::Trezor(_))).await +} + +pub(super) async fn sign_safe_hash(signer: &WalletSigner, safe_tx_hash: B256) -> Result { + let signature = signer.sign_message(safe_tx_hash.as_slice()).await?; + Ok(normalize_signature(&signature.as_bytes(), true)) +} + +fn delegate_typed_data(delegate: Address, chain_id: u64, totp: u64) -> Result { + Ok(serde_json::from_value(json!({ + "types": { + "EIP712Domain": [ + { "name": "name", "type": "string" }, + { "name": "version", "type": "string" }, + { "name": "chainId", "type": "uint256" } + ], + "Delegate": [ + { "name": "delegateAddress", "type": "address" }, + { "name": "totp", "type": "uint256" } + ] + }, + "primaryType": "Delegate", + "domain": { + "name": "Safe Transaction Service", + "version": "1.0", + "chainId": chain_id + }, + "message": { + "delegateAddress": delegate.to_checksum(None), + "totp": totp + } + }))?) +} + +async fn sign_delegate_typed_data( + signer: &WalletSigner, + typed_data: &TypedData, + safe_eth_sign: bool, +) -> Result { + let signature = if safe_eth_sign { + signer.sign_message(typed_data.eip712_signing_hash()?.as_slice()).await? + } else { + signer.sign_dynamic_typed_data(typed_data).await? + }; + Ok(normalize_signature(&signature.as_bytes(), safe_eth_sign)) +} + +fn normalize_signature(signature: &[u8], safe_eth_sign: bool) -> String { + let mut signature = signature.to_vec(); + let v = &mut signature[64]; + if *v < 27 { + *v += 27; + } + if safe_eth_sign { + *v += 4; + } + hex::encode_prefixed(signature) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::Signature; + + #[test] + fn normalizes_signature_v() { + let mut signature = [0u8; 65]; + signature[64] = 1; + assert!(normalize_signature(&signature, true).ends_with("20")); + signature[64] = 27; + assert!(normalize_signature(&signature, true).ends_with("1f")); + signature[64] = 0; + assert!(normalize_signature(&signature, false).ends_with("1b")); + } + + #[tokio::test] + async fn signs_delegate_typed_data_with_supported_safe_signatures() -> Result<()> { + let typed_data = delegate_typed_data(Address::repeat_byte(0x11), 1, 1)?; + let signer = WalletSigner::from_private_key(&B256::repeat_byte(1))?; + let signing_hash = typed_data.eip712_signing_hash()?; + + for safe_eth_sign in [false, true] { + let signature = sign_delegate_typed_data(&signer, &typed_data, safe_eth_sign).await?; + let mut bytes = hex::decode(signature.strip_prefix("0x").unwrap())?; + if safe_eth_sign { + assert!(matches!(bytes[64], 31 | 32)); + bytes[64] -= 4; + } else { + assert!(matches!(bytes[64], 27 | 28)); + } + let signature = Signature::from_raw(&bytes)?; + let recovered = if safe_eth_sign { + signature.recover_address_from_msg(signing_hash.as_slice())? + } else { + signature.recover_address_from_prehash(&signing_hash)? + }; + assert_eq!(recovered, signer.address()); + } + Ok(()) + } +} diff --git a/crates/cast/src/cmd/safe/simulate.rs b/crates/cast/src/cmd/safe/simulate.rs new file mode 100644 index 0000000000000..26d768fb7d2ad --- /dev/null +++ b/crates/cast/src/cmd/safe/simulate.rs @@ -0,0 +1,127 @@ +use super::{ + contracts::{ISafe, ISimulateTxAccessor, SIMULATE_TX_ACCESSOR_V1_4_1}, + deploy::ensure_contract, + rpc_provider, + service::{SafeServiceOpts, SafeTransaction}, +}; +use alloy_network::TransactionBuilder; +use alloy_primitives::{Address, B256, Bytes, U256, hex}; +use alloy_provider::Provider; +use alloy_rpc_types::TransactionRequest; +use alloy_sol_types::{SolCall, SolValue}; +use clap::Args; +use eyre::{Context, Result, ensure}; +use foundry_cli::{json::print_json_object, opts::RpcOpts}; +use serde_json::json; + +/// CLI arguments for `cast safe simulate`. +#[derive(Args, Debug)] +pub struct SimulateArgs { + /// Safe account address. + safe: Address, + + /// Safe transaction hash from the Transaction Service. + safe_tx_hash: B256, + + /// Address that will execute the Safe transaction. Used as the simulation's tx.origin. + #[arg(long, env = "ETH_FROM", value_name = "ADDRESS")] + from: Address, + + /// SimulateTxAccessor address. + #[arg(long, default_value_t = SIMULATE_TX_ACCESSOR_V1_4_1)] + accessor: Address, + + #[command(flatten)] + service: Box, + + #[command(flatten)] + rpc: Box, +} + +impl SimulateArgs { + pub(super) async fn run(self) -> Result<()> { + let Self { safe, safe_tx_hash, from, accessor, service, rpc } = self; + let (provider, chain_id) = rpc_provider(&rpc).await?; + let transaction = service.get_transaction(chain_id, "v2", safe_tx_hash).await?; + transaction.verify_hash(safe, &provider).await?; + transaction.show_transaction_summary()?; + ensure!( + SafeTransaction::number(&transaction.gas_price, "gasPrice")?.is_zero(), + "cannot simulate reimbursed Safe transactions (gasPrice > 0): SimulateTxAccessor does not enforce safeTxGas" + ); + ensure_contract(&provider, accessor, "SimulateTxAccessor", "--accessor").await?; + + let accessor_call = ISimulateTxAccessor::simulateCall { + to: transaction.to, + value: SafeTransaction::number(&transaction.value, "value")?, + data: transaction.data.clone(), + operation: transaction.operation, + } + .abi_encode(); + let simulation_call = ISafe::simulateAndRevertCall { + targetContract: accessor, + calldataPayload: accessor_call.into(), + } + .abi_encode(); + let request = + TransactionRequest::default().with_from(from).with_to(safe).with_input(simulation_call); + let Err(error) = provider.call(request).await else { + eyre::bail!("Safe simulateAndRevert unexpectedly returned successfully"); + }; + let revert_data = error + .as_error_resp() + .and_then(|payload| payload.as_revert_data()) + .ok_or_else(|| eyre::eyre!("Safe simulation failed without revert data: {error}"))?; + let (gas_used, success, return_data) = decode_result(&revert_data)?; + ensure!( + success, + "Safe transaction simulation failed after {gas_used} gas; return data: {return_data}" + ); + print_json_object(json!({ + "safeTxHash": safe_tx_hash, + "success": true, + "gasUsed": gas_used.to_string(), + "returnData": return_data, + })) + } +} + +fn decode_result(revert_data: &[u8]) -> Result<(U256, bool, Bytes)> { + let header_length = 2 * U256::BYTES; + ensure!(revert_data.len() >= header_length, "invalid Safe simulateAndRevert response"); + let accessor_success = !U256::from_be_slice(&revert_data[..U256::BYTES]).is_zero(); + let response_len = U256::from_be_slice(&revert_data[U256::BYTES..header_length]); + let available = revert_data.len() - header_length; + ensure!( + response_len <= U256::from(available), + "invalid Safe simulateAndRevert response length" + ); + let response = &revert_data[header_length..header_length + response_len.to::()]; + ensure!( + accessor_success, + "SimulateTxAccessor delegatecall failed; return data: {}", + hex::encode_prefixed(response) + ); + <(U256, bool, Bytes)>::abi_decode_params(response) + .wrap_err("invalid SimulateTxAccessor response") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decodes_signature_independent_simulation_result() { + let return_data = Bytes::from_static(b"result"); + let accessor_response = (U256::from(42), true, return_data.clone()).abi_encode_params(); + let mut revert_data = Vec::with_capacity(2 * U256::BYTES + accessor_response.len()); + revert_data.extend_from_slice(&U256::ONE.to_be_bytes::<{ U256::BYTES }>()); + revert_data.extend_from_slice( + &U256::from(accessor_response.len()).to_be_bytes::<{ U256::BYTES }>(), + ); + revert_data.extend_from_slice(&accessor_response); + + let decoded = decode_result(&revert_data).unwrap(); + assert_eq!(decoded, (U256::from(42), true, return_data)); + } +} diff --git a/crates/cast/src/cmd/safe/transaction.rs b/crates/cast/src/cmd/safe/transaction.rs new file mode 100644 index 0000000000000..4f6ed53a82541 --- /dev/null +++ b/crates/cast/src/cmd/safe/transaction.rs @@ -0,0 +1,147 @@ +use crate::{ + tempo, + tx::{CastTxBuilder, apply_poll_interval}, +}; +use alloy_consensus::{SignableTransaction, Signed}; +use alloy_network::{Ethereum, EthereumWallet, Network, ReceiptResponse}; +use alloy_primitives::{Address, B256, Bytes, hex}; +use alloy_provider::{Provider, fillers::RecommendedFillers}; +use alloy_rpc_types::Log; +use alloy_signer::{Signature, Signer}; +use clap::Args; +use eyre::{Context, Result, ensure}; +use foundry_cli::{ + opts::{EthereumOpts, RpcOpts, TransactionOpts}, + utils::{LoadConfig, resolve_lane}, +}; +use foundry_common::{FoundryTransactionBuilder, provider::ProviderBuilder}; +use foundry_wallets::{WalletOpts, WalletSigner}; +use serde::Serialize; +use std::time::Duration; +use tempo_alloy::TempoNetwork; + +/// Options for sending the onchain transaction of `cast safe create` and `cast safe execute`. +#[derive(Args, Debug)] +pub(super) struct SafeSendOpts { + #[command(flatten)] + pub(super) rpc: Box, + + #[command(flatten)] + wallet: Box, + + #[command(flatten)] + tx: Box, +} + +pub(super) struct SafeSendResult { + pub(super) tx_hash: B256, + pub(super) logs: Vec, +} + +impl SafeSendOpts { + /// Sends a zero-value call of `data` to `to` and waits for its receipt. + pub(super) async fn send( + self, + to: Address, + data: Bytes, + confirmations: u64, + timeout: Option, + poll_interval: Option, + ) -> Result { + let Self { rpc, wallet, tx } = self; + let eth = EthereumOpts { rpc: *rpc, wallet: *wallet, ..Default::default() }; + let (network, signer, access_key) = + tempo::resolve_transaction_network_and_signer(&tx.tempo, ð).await?; + ensure!( + access_key.is_none(), + "Tempo Accounts sessions are not yet supported by `cast safe create` or `cast safe execute`" + ); + let call = SafeCall { to, data, confirmations, timeout, poll_interval }; + if network.is_tempo() { + call.send::(eth, *tx, signer).await + } else { + call.send::(eth, *tx, signer).await + } + } +} + +struct SafeCall { + to: Address, + data: Bytes, + confirmations: u64, + timeout: Option, + poll_interval: Option, +} + +impl SafeCall { + async fn send( + self, + eth: EthereumOpts, + mut tx_opts: TransactionOpts, + signer: Option, + ) -> Result + where + N: Network + RecommendedFillers, + N::TxEnvelope: From>, + N::UnsignedTx: SignableTransaction, + N::TransactionRequest: Default + FoundryTransactionBuilder, + N::ReceiptResponse: Serialize, + { + ensure!( + tx_opts.value.is_none_or(|value| value.is_zero()), + "Safe outer transaction value must be zero" + ); + ensure!(!tx_opts.blob, "blob transactions are not supported by `cast safe`"); + ensure!( + tx_opts.auth.is_empty(), + "EIP-7702 authorizations are not supported by `cast safe`" + ); + ensure!( + !tx_opts.tempo.has_sponsor_submission() + && tx_opts.tempo.sponsor_url.is_none() + && !tx_opts.tempo.print_sponsor_hash, + "Tempo sponsorship is not yet supported by `cast safe create` or `cast safe execute`" + ); + ensure!( + tx_opts.tempo.session_id()?.is_none(), + "Tempo Accounts sessions are not yet supported by `cast safe create` or `cast safe execute`" + ); + + let config = eth.rpc.load_config()?; + let timeout = self.timeout.unwrap_or(config.transaction_timeout); + resolve_lane(&mut tx_opts.tempo, &config.root)?; + tempo::print_expires(tx_opts.tempo.resolve_expires())?; + let signer = match signer { + Some(signer) => signer, + None => eth.wallet.signer().await?, + }; + crate::tx::validate_from_address(eth.wallet.from, signer.address())?; + let from = signer.address(); + let provider = ProviderBuilder::::from_config(&config)? + .build_with_wallet(EthereumWallet::from(signer))?; + apply_poll_interval(&provider, self.poll_interval); + let builder = CastTxBuilder::new(&provider, tx_opts, &config) + .await? + .with_to(Some(self.to.into())) + .await? + .with_code_sig_and_args(None, Some(hex::encode_prefixed(self.data)), Vec::new()) + .await?; + let (mut request, _) = builder.build(from).await?; + let fee_provider = (!config.eth_rpc_curl).then_some(&provider); + tempo::resolve_and_print_fee_token(fee_provider, &mut request, Some(from)).await?; + + let receipt = provider + .send_transaction(request) + .await? + .with_required_confirmations(self.confirmations) + .with_timeout(Some(Duration::from_secs(timeout))) + .get_receipt() + .await?; + ensure!(receipt.status(), "Safe transaction reverted"); + let tx_hash = receipt.transaction_hash(); + let receipt = serde_json::to_value(receipt)?; + let logs = serde_json::from_value(receipt["logs"].clone()) + .wrap_err("invalid logs in transaction receipt")?; + Ok(SafeSendResult { tx_hash, logs }) + } +} diff --git a/crates/cast/src/cmd/send.rs b/crates/cast/src/cmd/send.rs index e6b119b110f7c..e0ebe090c3542 100644 --- a/crates/cast/src/cmd/send.rs +++ b/crates/cast/src/cmd/send.rs @@ -1,21 +1,40 @@ use crate::{ - Cast, - tx::{self, CastTxBuilder}, + cmd::{ + auth::{confirm_and_build, confirm_and_build_with_tempo_wallet}, + confirm_continue, + tip20::iso4217_warning_message, + }, + tempo, + tx::{self, CastTxBuilder, CastTxSender, SendTxOpts, TxParams, apply_poll_interval}, }; +use alloy_consensus::{SignableTransaction, Signed}; use alloy_ens::NameOrAddress; -use alloy_network::{AnyNetwork, EthereumWallet}; -use alloy_provider::{Provider, ProviderBuilder}; -use alloy_rpc_types::TransactionRequest; -use alloy_serde::WithOtherFields; -use alloy_signer::Signer; +use alloy_network::{Ethereum, EthereumWallet, Network, NetworkTransactionBuilder}; +use alloy_primitives::{Address, B256, hex}; +use alloy_provider::{Provider, ProviderBuilder as AlloyProviderBuilder}; +use alloy_signer::{Signature, Signer}; use clap::Parser; use eyre::{Result, eyre}; use foundry_cli::{ - opts::{EthereumOpts, TransactionOpts}, - utils, - utils::LoadConfig, + opts::TransactionOpts, + utils::{LoadConfig, get_chain, resolve_lane}, +}; +use foundry_common::{ + FoundryTransactionBuilder, + fmt::{UIfmt, UIfmtReceiptExt}, + provider::ProviderBuilder, + tempo::maybe_print_fee_token, }; +use foundry_config::Config; +use foundry_wallets::{TempoAccountsWallet, WalletSigner, wallet_browser::signer::BrowserSigner}; use std::{path::PathBuf, str::FromStr}; +use tempo_alloy::TempoNetwork; +use tempo_contracts::precompiles::{TIP20_FACTORY_ADDRESS, is_iso4217_currency}; +use tempo_primitives::transaction::FEE_PAYER_SIGNATURE_MARKER; +use url::Url; + +#[cfg(feature = "base")] +use base_common_network::Base; /// CLI arguments for `cast send`. #[derive(Debug, Parser)] @@ -30,15 +49,18 @@ pub struct SendTxArgs { sig: Option, /// The arguments of the function to call. + #[arg(allow_negative_numbers = true)] args: Vec, - /// Only print the transaction hash and exit immediately. - #[arg(id = "async", long = "async", alias = "cast-async", env = "CAST_ASYNC")] - cast_async: bool, + /// Raw hex-encoded data for the transaction. Used instead of `SIG` and `ARGS`. + #[arg( + long, + conflicts_with_all = &["sig", "args"] + )] + data: Option, - /// The number of confirmations until the receipt is fetched. - #[arg(long, default_value = "1")] - confirmations: u64, + #[command(flatten)] + send_tx: SendTxOpts, #[command(subcommand)] command: Option, @@ -47,15 +69,16 @@ pub struct SendTxArgs { #[arg(long, requires = "from")] unlocked: bool, - /// Timeout for sending the transaction. - #[arg(long, env = "ETH_TIMEOUT")] - pub timeout: Option, + /// Skip confirmation prompts (e.g. non-ISO 4217 currency warnings). + #[arg(long)] + force: bool, - #[command(flatten)] - tx: TransactionOpts, + /// Relative percentage to multiply the gas estimate by. + #[arg(long, value_name = "PERCENT", help_heading = "Transaction options")] + gas_estimate_multiplier: Option, #[command(flatten)] - eth: EthereumOpts, + tx: TransactionOpts, /// The path of blob data to be sent. #[arg( @@ -80,27 +103,140 @@ pub enum SendTxSubcommands { sig: Option, /// The arguments of the function to call. + #[arg(allow_negative_numbers = true)] args: Vec, }, } impl SendTxArgs { - pub async fn run(self) -> eyre::Result<()> { + /// Creates a `cast send` invocation for pre-encoded contract calldata. + pub(crate) fn contract_call( + to: NameOrAddress, + data: Vec, + send_tx: SendTxOpts, + tx: TxParams, + ) -> Self { + Self { + to: Some(to), + sig: None, + args: Vec::new(), + data: Some(hex::encode_prefixed(data)), + send_tx, + command: None, + unlocked: false, + force: false, + gas_estimate_multiplier: None, + tx: tx.into_transaction_opts(), + path: None, + } + } + + pub async fn run(self) -> Result<()> { + if self.to.is_none() && matches!(self.command, Some(SendTxSubcommands::Create { .. })) { + if !self.tx.auth.is_empty() { + eyre::bail!( + "EIP-7702 transactions can't be CREATE transactions and require a destination address" + ); + } + if self.path.is_some() { + eyre::bail!( + "EIP-4844 transactions can't be CREATE transactions and require a destination address" + ); + } + } + + let (network, signer, tempo_access_key) = + tempo::resolve_transaction_network_and_signer(&self.tx.tempo, &self.send_tx.eth) + .await?; + + if network.is_tempo() { + return self.run_generic::(signer, tempo_access_key).await; + } + + #[cfg(feature = "base")] + if network.is_base() { + super::validate_base_transaction_options(&self.tx)?; + return self.run_generic::(signer, None).await; + } + + self.run_generic::(signer, None).await + } + + /// Runs a contract call with an already resolved browser signer. + pub(crate) async fn run_generic_with_browser( + self, + browser: BrowserSigner, + ) -> Result<()> + where + N::TxEnvelope: From>, + N::UnsignedTx: SignableTransaction, + N::TransactionRequest: FoundryTransactionBuilder, + N::ReceiptResponse: UIfmt + UIfmtReceiptExt, + { + self.run_generic_inner::(None, None, Some(browser)).await + } + + pub(crate) async fn run_generic( + self, + pre_resolved_signer: Option, + access_key: Option, + ) -> Result<()> + where + N::TxEnvelope: From>, + N::UnsignedTx: SignableTransaction, + N::TransactionRequest: FoundryTransactionBuilder, + N::ReceiptResponse: UIfmt + UIfmtReceiptExt, + { + self.run_generic_inner::(pre_resolved_signer, access_key, None).await + } + + async fn run_generic_inner( + self, + pre_resolved_signer: Option, + mut access_key: Option, + pre_resolved_browser: Option>, + ) -> Result<()> + where + N::TxEnvelope: From>, + N::UnsignedTx: SignableTransaction, + N::TransactionRequest: FoundryTransactionBuilder, + N::ReceiptResponse: UIfmt + UIfmtReceiptExt, + { let Self { - eth, to, mut sig, - cast_async, mut args, - tx, - confirmations, + data, + send_tx, + mut tx, command, unlocked, + force, + gas_estimate_multiplier, path, - timeout, } = self; - let blob_data = if let Some(path) = path { Some(std::fs::read(path)?) } else { None }; + let has_session = tx.tempo.session_id()?.is_some(); + if has_session && unlocked { + eyre::bail!("--tempo.session/TEMPO_SESSION_ID cannot be combined with --unlocked"); + } + tempo::ensure_session_not_browser(&tx.tempo, send_tx.browser.browser)?; + + let print_sponsor_hash = tx.tempo.print_sponsor_hash; + let sponsor_url = tx.tempo.sponsor_url.clone(); + let sponsor_fee_payer = tx.tempo.sponsor; + let expires_at = tx.tempo.resolve_expires(); + let tempo_sponsor = if print_sponsor_hash || sponsor_url.is_some() { + None + } else { + tx.tempo.sponsor_config().await? + }; + + let blob_data = path.map(std::fs::read).transpose()?; + + if let Some(data) = data { + sig = Some(data); + } let code = if let Some(SendTxSubcommands::Create { code, @@ -108,21 +244,6 @@ impl SendTxArgs { args: constructor_args, }) = command { - // ensure we don't violate settings for transactions that can't be CREATE: 7702 and 4844 - // which require mandatory target - if to.is_none() && tx.auth.is_some() { - return Err(eyre!( - "EIP-7702 transactions can't be CREATE transactions and require a destination address" - )); - } - // ensure we don't violate settings for transactions that can't be CREATE: 7702 and 4844 - // which require mandatory target - if to.is_none() && blob_data.is_some() { - return Err(eyre!( - "EIP-4844 transactions can't be CREATE transactions and require a destination address" - )); - } - sig = constructor_sig; args = constructor_args; Some(code) @@ -130,31 +251,120 @@ impl SendTxArgs { None }; - let config = eth.load_config()?; - let provider = utils::get_provider(&config)?; + // Validate ISO 4217 currency code for TIP20Factory createToken calls. + if let Some(to_addr) = &to { + let is_factory = match to_addr { + NameOrAddress::Address(addr) => *addr == TIP20_FACTORY_ADDRESS, + NameOrAddress::Name(name) => { + Address::from_str(name).ok() == Some(TIP20_FACTORY_ADDRESS) + } + }; + + if !force + && is_factory + && let Some(sig_str) = &sig + && sig_str.starts_with("createToken") + && let Some(currency) = args.get(2) + && !is_iso4217_currency(currency) + { + sh_warn!("{}", iso4217_warning_message(currency))?; + if !confirm_continue()? { + return Ok(()); + } + } + } + + let config = send_tx.eth.load_config()?; + let provider = ProviderBuilder::::from_config(&config)?.build()?; + // The provider is not consulted for fee tokens in `--curl` mode. + let fee_provider = (!config.eth_rpc_curl).then_some(&provider); + + let resolved_lane = resolve_lane(&mut tx.tempo, &config.root)?; + let lane = resolved_lane.as_ref(); + + apply_poll_interval(&provider, send_tx.poll_interval); + + if has_session || access_key.is_some() { + let chain = get_chain(config.chain, &provider).await?; + access_key = + tempo::resolve_session_or_wallet_signer(&tx.tempo, &send_tx.eth.wallet, chain.id()) + .await? + .1; + } + + // Inject access key ID into TempoOpts so it's set before gas estimation. + if let Some(ak) = &access_key { + tx.tempo.key_id = Some(ak.key_id()?); + } let builder = CastTxBuilder::new(&provider, tx, &config) .await? + .with_gas_estimate_multiplier(gas_estimate_multiplier) .with_to(to) .await? .with_code_sig_and_args(code, sig, args) .await? .with_blob_data(blob_data)?; + let chain = builder.chain(); + + // If --tempo.print-sponsor-hash was passed, build the tx, print the hash, and exit. + if print_sponsor_hash { + // The sponsor hash commits to the sender, so resolve the actual sender first. + let (mut tx, from) = if let Some(ak) = &access_key { + let Some((tx, prepared)) = + confirm_and_build_with_tempo_wallet(builder, ak, force, None).await? + else { + return Ok(()); + }; + (tx, prepared.account()) + } else { + let signer = pre_resolved_signer.as_ref().ok_or_else(|| { + eyre!("--tempo.print-sponsor-hash requires a signer (e.g. --private-key)") + })?; + let Some(tx) = confirm_and_build(builder, signer, force, None, false).await? else { + return Ok(()); + }; + (tx, signer.address()) + }; + let hash = tempo::sponsor_hash(fee_provider, &mut tx, from, sponsor_fee_payer).await?; + sh_println!("{hash:?}")?; + return Ok(()); + } + + tempo::print_expires(expires_at)?; - let timeout = timeout.unwrap_or(config.transaction_timeout); + // Without a sponsor the fee token is resolved for the sender while sending. + let send_opts = SendOptions::new(&send_tx, &config); + let fee_send_opts = send_opts.resolving_fee_token(tempo_sponsor.is_none(), &config); + + // --sponsor-url is valid with local signers and Tempo access keys. Bail early rather than + // silently ignoring it in signing paths that cannot produce a raw transaction locally. + if let Some(url) = &sponsor_url { + validate_sponsor_url(url)?; + if unlocked { + eyre::bail!("--sponsor-url cannot be combined with --unlocked"); + } + if send_tx.browser.browser { + eyre::bail!("--sponsor-url cannot be combined with --browser"); + } + } + + // Launch a browser signer if one was not already resolved by the caller. + let browser = if let Some(browser) = pre_resolved_browser { + Some(browser) + } else { + send_tx.browser.run::().await? + }; // Case 1: // Default to sending via eth_sendTransaction if the --unlocked flag is passed. // This should be the only way this RPC method is used as it requires a local node // or remote RPC with unlocked accounts. - if unlocked { - // only check current chain id if it was specified in the config + if unlocked && browser.is_none() { + // Switch chain if the current chain id is not the one specified in the config. if let Some(config_chain) = config.chain { - let current_chain_id = provider.get_chain_id().await?; let config_chain_id = config_chain.id(); - // switch chain if current chain id is not the same as the one specified in the - // config - if config_chain_id != current_chain_id { + if config_chain_id != provider.get_chain_id().await? { sh_warn!("Switching to chain {}", config_chain)?; provider .raw_request::<_, ()>( @@ -167,51 +377,411 @@ impl SendTxArgs { } } - let (tx, _) = builder.build(config.sender).await?; - - cast_send(provider, tx, cast_async, confirmations, timeout).await + let Some(mut tx_request) = + confirm_and_build(builder, config.sender, force, lane, false).await? + else { + return Ok(()); + }; + tempo::maybe_attach_sponsor( + tempo_sponsor.as_ref(), + fee_provider, + &mut tx_request, + config.sender, + ) + .await?; + + cast_send(provider, tx_request, &fee_send_opts).await?; // Case 2: - // An option to use a local signer was provided. + // Browser wallet signs and sends the transaction in one step. + } else if let Some(browser) = browser { + let from = browser.address(); + let Some(mut tx_request) = + confirm_and_build(builder.with_browser_wallet(), from, force, lane, false).await? + else { + return Ok(()); + }; + tempo::apply_fee_payment::( + tempo_sponsor.as_ref(), + fee_provider, + &mut tx_request, + from, + ) + .await?; + + if chain.id() != browser.chain_id() { + sh_warn!("Switching browser wallet to chain {}", chain)?; + browser.switch_chain(chain.id()).await?; + } + + tx_request.prep_for_submission(); + let tx_hash = browser.send_transaction_via_browser(tx_request).await?; + send_opts.print_tx_result(&provider, tx_hash).await?; + // Case 3: Tempo access-key wallet. + } else if let Some(ak) = access_key { + let Some((mut tx_request, prepared)) = + confirm_and_build_with_tempo_wallet(builder, &ak, force, lane).await? + else { + return Ok(()); + }; + tempo::maybe_attach_sponsor( + tempo_sponsor.as_ref(), + fee_provider, + &mut tx_request, + prepared.account(), + ) + .await?; + if let Some(sponsor_url) = sponsor_url.as_deref() { + cast_send_with_tempo_wallet_via_sponsor( + &provider, + tx_request, + &prepared, + sponsor_url, + &send_opts, + ) + .await?; + } else { + cast_send_with_tempo_wallet(&provider, tx_request, &prepared, &fee_send_opts) + .await?; + } + // Case 4: a local signer. // If we cannot successfully instantiate a local signer, then we will assume we don't have // enough information to sign and we must bail. } else { - // Retrieve the signer, and bail if it can't be constructed. - let signer = eth.wallet.signer().await?; - let from = signer.address(); + let (signer, from) = tx::resolve_send_signer(pre_resolved_signer, &send_tx.eth).await?; + let Some(mut tx_request) = + confirm_and_build(builder, &signer, force, lane, false).await? + else { + return Ok(()); + }; + let wallet_provider = AlloyProviderBuilder::<_, _, N>::default(); + + if let Some(sponsor_url) = sponsor_url { + // Sign locally, ask the sponsor service for a fee-payer signature, then submit the + // fully-sponsored tx to the regular RPC. + tx_request.set_fee_payer_signature(FEE_PAYER_SIGNATURE_MARKER); + let connector = tempo::sponsor_relay_connector(&provider, &sponsor_url)?; + let provider = wallet_provider + .wallet(EthereumWallet::from(signer)) + .connect_with(&connector) + .await?; + cast_send(provider, tx_request, &send_opts).await?; + } else { + tempo::maybe_attach_sponsor( + tempo_sponsor.as_ref(), + fee_provider, + &mut tx_request, + from, + ) + .await?; + let provider = wallet_provider + .wallet(EthereumWallet::from(signer)) + .connect_provider(&provider); + cast_send(provider, tx_request, &fee_send_opts).await?; + } + } - tx::validate_from_address(eth.wallet.from, from)?; + Ok(()) + } +} - let (tx, _) = builder.build(&signer).await?; +/// How a transaction is submitted and its result reported. +#[derive(Clone, Copy, Debug)] +pub(crate) struct SendOptions { + /// Only print the transaction hash instead of waiting for the receipt. + cast_async: bool, + /// Submit with the synchronous RPC methods and print the returned receipt. + sync: bool, + confirmations: u64, + timeout: u64, + /// Whether to resolve a missing Tempo fee token for the sender before sending. Otherwise the + /// fee token is left as built, e.g. when a sponsor already selected it. + resolve_fee_token: bool, + /// Whether the provider may be queried for the stored fee token and its symbol. + query_fee_token: bool, +} + +impl SendOptions { + /// Submission options from the CLI flags and config, without fee token resolution. + pub(crate) fn new(send_tx: &SendTxOpts, config: &Config) -> Self { + Self { + cast_async: send_tx.cast_async, + sync: send_tx.sync, + confirmations: send_tx.confirmations, + timeout: send_tx.timeout.unwrap_or(config.transaction_timeout), + resolve_fee_token: false, + query_fee_token: false, + } + } + + /// Resolves the sender's fee token before sending when `resolve` is set, querying the RPC + /// unless the request is only rendered as `curl`. + pub(crate) const fn resolving_fee_token(self, resolve: bool, config: &Config) -> Self { + Self { + resolve_fee_token: resolve, + query_fee_token: resolve && !config.eth_rpc_curl, + ..self + } + } - let wallet = EthereumWallet::from(signer); - let provider = ProviderBuilder::<_, _, AnyNetwork>::default() - .wallet(wallet) - .connect_provider(&provider); + /// Prints the hash of a submitted transaction, or its receipt unless `--async` was passed. + pub(crate) async fn print_tx_result>( + &self, + provider: P, + tx_hash: B256, + ) -> Result<()> + where + N::TransactionRequest: FoundryTransactionBuilder, + N::ReceiptResponse: UIfmt + UIfmtReceiptExt, + { + CastTxSender::new(provider) + .print_tx_result(tx_hash, self.cast_async, self.confirmations, self.timeout) + .await + } - cast_send(provider, tx, cast_async, confirmations, timeout).await + /// Prints a sync receipt, or the hash / polled receipt of a pending transaction. + async fn print_send_result>( + &self, + provider: P, + tx_hash: B256, + receipt: Option, + ) -> Result + where + N::TransactionRequest: FoundryTransactionBuilder, + N::ReceiptResponse: UIfmt + UIfmtReceiptExt, + { + match receipt { + Some(receipt) => sh_println!("{receipt}")?, + None => self.print_tx_result(provider, tx_hash).await?, + } + Ok(tx_hash) + } + + async fn resolve_and_print_fee_token>( + &self, + provider: &P, + tx: &mut N::TransactionRequest, + ) -> Result<()> + where + N::TransactionRequest: Default + FoundryTransactionBuilder, + { + let provider = self.query_fee_token.then_some(provider); + if self.resolve_fee_token { + tempo::resolve_and_print_fee_token(provider, tx, None).await + } else { + maybe_print_fee_token(provider, tx.fee_token()).await } } } -async fn cast_send>( +/// Sends a transaction through `provider`, which signs it (a wallet-filled provider or an +/// unlocked RPC account). +pub(crate) async fn cast_send>( provider: P, - tx: WithOtherFields, - cast_async: bool, - confs: u64, - timeout: u64, -) -> Result<()> { - let cast = Cast::new(provider); - let pending_tx = cast.send(tx).await?; - - let tx_hash = pending_tx.inner().tx_hash(); + mut tx: N::TransactionRequest, + opts: &SendOptions, +) -> Result +where + N::TransactionRequest: Default + FoundryTransactionBuilder, + N::ReceiptResponse: UIfmt + UIfmtReceiptExt, +{ + opts.resolve_and_print_fee_token(&provider, &mut tx).await?; + let (tx_hash, receipt) = if opts.sync { + // JSON envelope not supported: N::ReceiptResponse is generic over Display but not + // Serialize; adding Serialize would ripple across all network-generic callers. + let (tx_hash, receipt) = CastTxSender::new(&provider).send_sync(tx).await?; + (tx_hash, Some(receipt)) + } else { + (*CastTxSender::new(&provider).send(tx).await?.tx_hash(), None) + }; + opts.print_send_result(provider, tx_hash, receipt).await +} - if cast_async { - sh_println!("{tx_hash:#x}")?; +/// Sends a raw transaction using the RPC method selected by `sync`. +pub(crate) async fn cast_send_raw>( + provider: &P, + raw_tx: &[u8], + sync: bool, +) -> Result<(B256, Option)> +where + N::TransactionRequest: FoundryTransactionBuilder, + N::ReceiptResponse: UIfmt + UIfmtReceiptExt, +{ + if sync { + let (tx_hash, receipt) = CastTxSender::new(provider).send_raw_sync(raw_tx).await?; + Ok((tx_hash, Some(receipt))) } else { - let receipt = - cast.receipt(format!("{tx_hash:#x}"), None, confs, Some(timeout), false).await?; - sh_println!("{receipt}")?; + Ok((*provider.send_raw_transaction(raw_tx).await?.tx_hash(), None)) + } +} + +/// Signs a prepared transaction with a Tempo wallet and sends it as a raw transaction. +pub(crate) async fn cast_send_with_tempo_wallet>( + provider: &P, + mut tx: N::TransactionRequest, + wallet: &TempoAccountsWallet, + opts: &SendOptions, +) -> Result +where + N::TransactionRequest: Default + FoundryTransactionBuilder, + N::ReceiptResponse: UIfmt + UIfmtReceiptExt, +{ + opts.resolve_and_print_fee_token(provider, &mut tx).await?; + let raw_tx = tx.sign_with_tempo_wallet(wallet).await?; + let (tx_hash, receipt) = cast_send_raw(provider, &raw_tx, opts.sync).await?; + opts.print_send_result(provider, tx_hash, receipt).await +} + +/// Signs a prepared transaction with a Tempo wallet, obtains a remote fee-payer signature, and +/// broadcasts the sponsored transaction through the original provider transport. The relay +/// selects the fee token, so `opts` must not resolve one. +pub(crate) async fn cast_send_with_tempo_wallet_via_sponsor>( + provider: &P, + mut tx: N::TransactionRequest, + wallet: &TempoAccountsWallet, + sponsor_url: &str, + opts: &SendOptions, +) -> Result +where + N::TransactionRequest: Default + FoundryTransactionBuilder, + N::ReceiptResponse: UIfmt + UIfmtReceiptExt, +{ + tx.set_fee_payer_signature(FEE_PAYER_SIGNATURE_MARKER); + let connector = tempo::sponsor_relay_connector(provider, sponsor_url)?; + let sponsor_provider = + AlloyProviderBuilder::<_, _, N>::default().connect_with(&connector).await?; + cast_send_with_tempo_wallet(&sponsor_provider, tx, wallet, opts).await +} + +/// Validates that a sponsor URL uses https:// (localhost/127.0.0.1 may use http://). +pub(crate) fn validate_sponsor_url(raw: &str) -> Result<()> { + let url = Url::parse(raw) + .map_err(|e| eyre::eyre!("--sponsor-url is not a valid URL ({raw}): {e}"))?; + match url.scheme() { + "https" => Ok(()), + "http" if matches!(url.host_str(), Some("localhost" | "127.0.0.1")) => Ok(()), + "http" => eyre::bail!( + "--sponsor-url must use https:// for non-local endpoints (got {raw}). \ + The sponsor relay is a trusted third party; use an encrypted channel." + ), + _ => eyre::bail!( + "--sponsor-url must start with https:// (got {raw}). \ + The sponsor relay is a trusted third party; use an encrypted channel." + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_json_rpc::{RequestPacket, ResponsePacket}; + use alloy_provider::mock::Asserter; + use alloy_rpc_client::RpcClient; + use alloy_rpc_types::TransactionRequest; + use alloy_transport::{TransportError, TransportFut, mock::MockTransport}; + use foundry_wallets::utils::create_local_signer; + use std::{ + sync::{Arc, Mutex}, + task::{Context, Poll}, + }; + use tempo_alloy::rpc::TempoTransactionRequest; + use tower::Service; + + #[derive(Clone)] + struct RecordingTransport { + inner: MockTransport, + methods: Arc>>, } - Ok(()) + impl Service for RecordingTransport { + type Response = ResponsePacket; + type Error = TransportError; + type Future = TransportFut<'static>; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, req: RequestPacket) -> Self::Future { + if let RequestPacket::Single(req) = &req { + self.methods.lock().unwrap().push(req.method().to_string()); + } + self.inner.call(req) + } + } + + #[test] + fn test_validate_sponsor_url() { + // accepted + assert!(validate_sponsor_url("https://sponsor.tempo.xyz/tp_abc").is_ok()); + assert!(validate_sponsor_url("http://localhost:8545").is_ok()); + assert!(validate_sponsor_url("http://127.0.0.1:8545").is_ok()); + + // rejected + assert!(validate_sponsor_url("http://sponsor.tempo.xyz").is_err()); + assert!(validate_sponsor_url("not-a-url").is_err()); + // bypass attempts that fooled the old starts_with check + assert!(validate_sponsor_url("http://localhost.evil.com").is_err()); + assert!(validate_sponsor_url("http://127.0.0.1.evil.com").is_err()); + } + + #[tokio::test] + async fn tempo_wallet_sync_send_uses_sync_rpc_method() { + let asserter = Asserter::new(); + let tx_hash = B256::repeat_byte(0x11); + asserter.push_success(&serde_json::json!({ + "type": "0x76", + "status": "0x1", + "cumulativeGasUsed": "0x5208", + "logs": [], + "logsBloom": format!("0x{}", "00".repeat(256)), + "transactionHash": tx_hash, + "transactionIndex": "0x0", + "blockHash": B256::repeat_byte(0x22), + "blockNumber": "0x1", + "gasUsed": "0x5208", + "effectiveGasPrice": "0x1", + "from": Address::ZERO, + "to": Address::ZERO, + "contractAddress": null, + "feeToken": Address::repeat_byte(0x55), + "feePayer": Address::ZERO, + })); + let methods = Arc::new(Mutex::new(Vec::new())); + let transport = + RecordingTransport { inner: MockTransport::new(asserter), methods: methods.clone() }; + let provider = AlloyProviderBuilder::new_with_network::() + .connect_client(RpcClient::new(transport, true)); + let root = Address::repeat_byte(0x33); + let access_key = create_local_signer( + "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", + ) + .unwrap(); + let wallet = + TempoAccountsWallet::from_secp256k1(root, access_key, None).with_chain_id(4217); + let tx = TempoTransactionRequest { + inner: TransactionRequest { + to: Some(Address::repeat_byte(0x44).into()), + nonce: Some(0), + gas: Some(100_000), + max_fee_per_gas: Some(1), + max_priority_fee_per_gas: Some(1), + chain_id: Some(4217), + ..Default::default() + }, + ..Default::default() + }; + + let opts = SendOptions { + cast_async: false, + sync: true, + confirmations: 1, + timeout: 1, + resolve_fee_token: false, + query_fee_token: false, + }; + let actual_hash = cast_send_with_tempo_wallet(&provider, tx, &wallet, &opts).await.unwrap(); + + assert_eq!(actual_hash, tx_hash); + assert_eq!(methods.lock().unwrap().as_slice(), ["eth_sendRawTransactionSync"]); + } } diff --git a/crates/cast/src/cmd/storage.rs b/crates/cast/src/cmd/storage.rs index ec8a41a616e4c..125a3fa97fe8d 100644 --- a/crates/cast/src/cmd/storage.rs +++ b/crates/cast/src/cmd/storage.rs @@ -1,13 +1,15 @@ -use crate::{Cast, opts::parse_slot}; +use crate::opts::parse_slot; use alloy_ens::NameOrAddress; use alloy_network::AnyNetwork; -use alloy_primitives::{Address, B256, U256}; +use alloy_primitives::{Address, B256, Bytes, U256}; use alloy_provider::Provider; use alloy_rpc_types::BlockId; use clap::Parser; -use comfy_table::{Cell, Table, modifiers::UTF8_ROUND_CORNERS}; +use comfy_table::{ + Table, + presets::{ASCII_FULL, ASCII_MARKDOWN}, +}; use eyre::Result; -use foundry_block_explorers::Client; use foundry_cli::{ opts::{BuildOpts, EtherscanOpts, RpcOpts}, utils, @@ -15,16 +17,13 @@ use foundry_cli::{ }; use foundry_common::{ abi::find_source, - compile::{ProjectCompiler, etherscan_project}, - shell, + compile::{ProjectCompiler, add_storage_layout_output, etherscan_project}, + has_metadata_hash, shell, }; use foundry_compilers::{ - Artifact, Project, - artifacts::{ConfigurableContractArtifact, Contract, StorageLayout}, - compilers::{ - Compiler, - solc::{Solc, SolcCompiler}, - }, + Artifact, ArtifactId, Project, ProjectCompileOutput, + artifacts::{ConfigurableContractArtifact, StorageLayout}, + compilers::solc::{Solc, SolcCompiler}, }; use foundry_config::{ Config, @@ -33,7 +32,7 @@ use foundry_config::{ }; use semver::Version; use serde::{Deserialize, Serialize}; -use std::str::FromStr; +use std::{collections::BTreeSet, str::FromStr}; /// The minimum Solc version for outputting storage layouts. /// @@ -49,7 +48,11 @@ pub struct StorageArgs { /// The storage slot number. If not provided, it gets the full storage layout. #[arg(value_parser = parse_slot)] - slot: Option, + base_slot: Option, + + /// The storage offset from the base slot. If not provided, it is assumed to be zero. + #[arg(value_parser = str::parse::, default_value_t = U256::ZERO)] + offset: U256, /// The known proxy address. If provided, the storage layout is retrieved from this address. #[arg(long,value_parser = NameOrAddress::from_str)] @@ -69,6 +72,10 @@ pub struct StorageArgs { #[command(flatten)] build: BuildOpts, + + /// Specify the solc version to compile with. Overrides detected version. + #[arg(long, value_parser = Version::parse)] + solc_version: Option, } impl_figment_convert_cast!(StorageArgs); @@ -91,115 +98,260 @@ impl StorageArgs { pub async fn run(self) -> Result<()> { let config = self.load_config()?; - let Self { address, slot, block, build, .. } = self; + let Self { address, base_slot, offset, block, build, .. } = self; let provider = utils::get_provider(&config)?; let address = address.resolve(&provider).await?; // Slot was provided, perform a simple RPC call - if let Some(slot) = slot { - let cast = Cast::new(provider); - sh_println!("{}", cast.storage(address, slot, block).await?)?; + if let Some(slot) = base_slot { + let slot = Into::::into(slot).saturating_add(offset); + sh_println!( + "{}", + B256::from( + provider + .get_storage_at(address, slot) + .block_id(block.unwrap_or_default()) + .await? + ) + )?; return Ok(()); } - // No slot was provided - // Get deployed bytecode at given address + // No slot was provided: get deployed bytecode at given address let address_code = provider.get_code_at(address).block_id(block.unwrap_or_default()).await?; if address_code.is_empty() { eyre::bail!("Provided address has no deployed code and thus no storage"); } - // Check if we're in a forge project and if we can find the address' code - let mut project = build.project()?; - if project.paths.has_input_files() { - // Find in artifacts and pretty print - add_storage_layout_output(&mut project); - let out = ProjectCompiler::new().quiet(shell::is_json()).compile(&project)?; - let artifact = out.artifacts().find(|(_, artifact)| { - artifact.get_deployed_bytecode_bytes().is_some_and(|b| *b == address_code) - }); - if let Some((_, artifact)) = artifact { - return fetch_and_print_storage( - provider, - address, - block, - artifact, - !shell::is_json(), - ) - .await; - } + // The layout comes from `--proxy` when provided, the values always from `address`. + let source_address = match self.proxy { + Some(proxy) => proxy.resolve(&provider).await?, + None => address, + }; + let source_code = if source_address == address { + address_code + } else { + provider.get_code_at(source_address).block_id(block.unwrap_or_default()).await? + }; + if source_code.is_empty() { + eyre::bail!("Provided proxy address has no deployed code and thus no storage layout"); } - if !self.etherscan.has_key() { - eyre::bail!( - "You must provide an Etherscan API key if you're fetching a remote contract's storage." - ); + // Check if we're in a forge project and if we can find the source's code. + let project = build.project()?; + if project.paths.has_input_files() + && let Some(artifact) = + compile_local_storage_layout(&project, &source_code, shell::is_json())? + { + return fetch_and_print_storage(provider, address, block, &artifact).await; } let chain = utils::get_chain(config.chain, &provider).await?; - let api_version = config.get_etherscan_api_version(Some(chain)); - let api_key = config.get_etherscan_api_key(Some(chain)).unwrap_or_default(); - let client = Client::new_with_api_version(chain, api_key, api_version)?; - let source = if let Some(proxy) = self.proxy { - find_source(client, proxy.resolve(&provider).await?).await? - } else { - find_source(client, address).await? + let client = match config.get_etherscan_config_with_chain(Some(chain))? { + Some(etherscan_config) => { + etherscan_config.into_client_with_no_proxy(config.eth_rpc_no_proxy)? + } + None => { + let api_key = self.etherscan.key().ok_or_else(|| { + eyre::eyre!("You must provide an Etherscan API key if you're fetching a remote contract's storage.") + })?; + foundry_block_explorers::Client::new(chain, api_key)? + } }; + let source = find_source(client, source_address).await?; let metadata = source.items.first().unwrap(); if metadata.is_vyper() { - eyre::bail!("Contract at provided address is not a valid Solidity contract") + eyre::bail!("Contract at provided address is not a valid Solidity contract"); } - let version = metadata.compiler_version()?; - let auto_detect = version < MIN_SOLC; - - // Create a new temp project - // TODO: Cache instead of using a temp directory: metadata from Etherscan won't change - let root = tempfile::tempdir()?; - let root_path = root.path(); - let mut project = etherscan_project(metadata, root_path)?; + // Create or reuse a persistent cache for Etherscan sources; fall back to a temp dir. + let mut root_path = Config::foundry_etherscan_chain_cache_dir(chain) + .map(|cache_root| cache_root.join("sources").join(address.to_string())); + if let Some(path) = &root_path + && let Err(err) = std::fs::create_dir_all(path) + { + sh_warn!("Could not create etherscan cache dir, falling back to temp: {err}")?; + root_path = None; + } + let _temp_dir; + let root_path = match root_path { + Some(path) => path, + None => { + _temp_dir = tempfile::tempdir()?; + _temp_dir.path().to_path_buf() + } + }; + let mut project = etherscan_project(metadata, &root_path)?; add_storage_layout_output(&mut project); - project.compiler = if auto_detect { - SolcCompiler::AutoDetect - } else { - SolcCompiler::Specific(Solc::find_or_install(&version)?) - }; + // Decide on compiler to use (user override -> metadata -> autodetect). + let meta_version = metadata.compiler_version()?; + let auto_detect = self.solc_version.is_none() && meta_version < MIN_SOLC; + project.compiler.solc = Some(match self.solc_version { + Some(user_version) => { + if user_version < MIN_SOLC { + sh_warn!( + "The provided --solc-version is {user_version} while the minimum version for \ + storage layouts is {MIN_SOLC} and as a result the output may be empty." + )?; + } + SolcCompiler::Specific(Solc::find_or_install(&user_version)?) + } + None if auto_detect => SolcCompiler::AutoDetect, + None => SolcCompiler::Specific(Solc::find_or_install(&meta_version)?), + }); - // Compile - let mut out = ProjectCompiler::new().quiet(true).compile(&project)?; - let artifact = { - let (_, mut artifact) = out - .artifacts() + let find_artifact = |out: &ProjectCompileOutput| { + out.artifacts() .find(|(name, _)| name == &metadata.contract_name) - .ok_or_else(|| eyre::eyre!("Could not find artifact"))?; - - if is_storage_layout_empty(&artifact.storage_layout) && auto_detect { - // try recompiling with the minimum version - sh_warn!( - "The requested contract was compiled with {version} while the minimum version for storage layouts is {MIN_SOLC} and as a result the output may be empty." - )?; - let solc = Solc::find_or_install(&MIN_SOLC)?; - project.compiler = SolcCompiler::Specific(solc); - if let Ok(output) = ProjectCompiler::new().quiet(true).compile(&project) { - out = output; - let (_, new_artifact) = out - .artifacts() - .find(|(name, _)| name == &metadata.contract_name) - .ok_or_else(|| eyre::eyre!("Could not find artifact"))?; - artifact = new_artifact; - } + .map(|(_, artifact)| artifact.clone()) + .ok_or_else(|| eyre::eyre!("Could not find artifact")) + }; + let out = ProjectCompiler::new().quiet(true).compile(&project)?; + let mut artifact = find_artifact(&out)?; + if auto_detect && artifact.storage_layout.as_ref().is_none_or(|l| l.storage.is_empty()) { + // Try recompiling with the minimum version. + sh_warn!( + "The requested contract was compiled with {meta_version} while the minimum version \ + for storage layouts is {MIN_SOLC} and as a result the output may be empty.", + )?; + project.compiler.solc = Some(SolcCompiler::Specific(Solc::find_or_install(&MIN_SOLC)?)); + if let Ok(out) = ProjectCompiler::new().quiet(true).compile(&project) { + artifact = find_artifact(&out)?; } + } - artifact + fetch_and_print_storage(provider, address, block, &artifact).await + } +} + +/// Finds the local artifact matching `address_code` and produces its storage layout. +/// +/// Human-readable output compiles only the target's source and imports when safe. JSON and unsafe +/// cases retain the full-project compile to preserve existing behavior. +fn compile_local_storage_layout( + project: &Project, + address_code: &Bytes, + json: bool, +) -> Result> { + // The JSON output exposes compiler-assigned AST IDs, which change when the compilation unit is + // reduced to the target's dependency graph. Preserve those IDs by retaining the full compile. + let full_compile = json + || project.build_info + || !project.cache_path().is_file() + || !project.paths.artifacts.is_dir(); + if !full_compile { + let output = ProjectCompiler::new().quiet(false).compile(project)?; + let Some((target, artifact)) = find_unique_artifact(project, output, address_code)? else { + return Ok(None); }; + if artifact.storage_layout.is_some() { + return Ok(Some(artifact)); + } + if let Ok(output) = compile_target_storage_layout(project, &target) + && let Some(artifact) = find_target_artifact(output, &target, address_code) + { + return Ok(Some(artifact)); + } + } - // Clear temp directory - root.close()?; + let output = compile_full_storage_layout(project, json)?; + Ok(find_unique_artifact(project, output, address_code)?.map(|(_, artifact)| artifact)) +} - fetch_and_print_storage(provider, address, block, artifact, !shell::is_json()).await +/// Returns whether `code` is the artifact's deployed bytecode. +/// +/// Immutables are zero in the artifact and only filled in at deployment, so their values are +/// ignored, but only when the artifact ends in a metadata hash. The hash commits to the sources, +/// so the rest of the code still has to come from the same contract. +fn has_deployed_code(artifact: &ConfigurableContractArtifact, code: &Bytes) -> bool { + let Some(deployed_code) = artifact.get_deployed_bytecode_bytes() else { return false }; + if deployed_code[..] == code[..] { + return true; + } + if deployed_code.len() != code.len() || !has_metadata_hash(&deployed_code) { + return false; } + + let mut code = code.to_vec(); + let immutables = + artifact.deployed_bytecode.iter().flat_map(|b| b.immutable_references.values()); + for offsets in immutables.flatten() { + let range = offsets.start as usize..offsets.start as usize + offsets.length as usize; + let (Some(value), Some(placeholder)) = + (code.get_mut(range.clone()), deployed_code.get(range)) + else { + return false; + }; + value.copy_from_slice(placeholder); + } + deployed_code[..] == code[..] +} + +fn find_target_artifact( + output: ProjectCompileOutput, + target: &ArtifactId, + address_code: &Bytes, +) -> Option { + output.into_artifacts().find_map(|(id, artifact)| { + (same_artifact(&id, target) + && artifact.storage_layout.is_some() + && has_deployed_code(&artifact, address_code)) + .then_some(artifact) + }) +} + +/// Returns the artifact whose deployed bytecode is `code`. +/// +/// Fails if the code matches more than one contract, since each can have a different storage +/// layout. +fn find_unique_artifact( + project: &Project, + output: ProjectCompileOutput, + code: &Bytes, +) -> Result> { + let mut found = None; + let mut contracts = BTreeSet::new(); + for (id, artifact) in output.into_artifacts() { + if has_deployed_code(&artifact, code) { + contracts.insert(id.clone().with_stripped_file_prefixes(project.root()).identifier()); + found.get_or_insert((id, artifact)); + } + } + if contracts.len() > 1 { + let contracts = contracts.into_iter().collect::>().join(", "); + eyre::bail!("Deployed code matches multiple local contracts: {contracts}"); + } + Ok(found) +} + +fn compile_target_storage_layout( + project: &Project, + target: &ArtifactId, +) -> Result { + let mut project = project.clone(); + project.no_artifacts = true; + add_storage_layout_output(&mut project); + ProjectCompiler::new().quiet(true).files([target.source.clone()]).compile(&project) +} + +fn compile_full_storage_layout(project: &Project, quiet: bool) -> Result { + let mut project = project.clone(); + add_storage_layout_output(&mut project); + ProjectCompiler::new().quiet(quiet).compile(&project) +} + +/// Returns whether two artifact IDs identify the same contract across compiler runs. +/// +/// Changing the output selection changes the build ID, and compiling fewer files can change an +/// artifact path that was disambiguated due to a name collision. Neither can be used to match the +/// normal compile against the targeted storage-layout compile. +fn same_artifact(left: &ArtifactId, right: &ArtifactId) -> bool { + left.name == right.name + && left.source == right.source + && left.version == right.version + && left.profile == right.profile } /// Represents the value of a storage slot `eth_getStorageAt` call. @@ -215,21 +367,9 @@ impl StorageValue { /// Returns the value of the storage slot, applying the offset if necessary. fn value(&self, offset: i64, number_of_bytes: Option) -> B256 { let offset = offset as usize; - let mut end = 32; - if let Some(number_of_bytes) = number_of_bytes { - end = offset + number_of_bytes; - if end > 32 { - end = 32; - } - } - - // reverse range, because the value is stored in big endian - let raw_sliced_value = &self.raw_slot_value.as_slice()[32 - end..32 - offset]; - - // copy the raw sliced value as tail - let mut value = [0u8; 32]; - value[32 - raw_sliced_value.len()..32].copy_from_slice(raw_sliced_value); - B256::from(value) + let end = number_of_bytes.map_or(32, |n| (offset + n).min(32)); + // Reverse range, because the value is stored in big endian. + B256::left_padding_from(&self.raw_slot_value[32 - end..32 - offset]) } } @@ -246,115 +386,380 @@ async fn fetch_and_print_storage>( address: Address, block: Option, artifact: &ConfigurableContractArtifact, - pretty: bool, ) -> Result<()> { - if is_storage_layout_empty(&artifact.storage_layout) { + let Some(layout) = artifact.storage_layout.as_ref().filter(|l| !l.storage.is_empty()) else { sh_warn!("Storage layout is empty.")?; - Ok(()) - } else { - let layout = artifact.storage_layout.as_ref().unwrap().clone(); - let values = fetch_storage_slots(provider, address, block, &layout).await?; - print_storage(layout, values, pretty) - } -} - -async fn fetch_storage_slots>( - provider: P, - address: Address, - block: Option, - layout: &StorageLayout, -) -> Result> { - let requests = layout.storage.iter().map(|storage_slot| async { + return Ok(()); + }; + let values = futures::future::try_join_all(layout.storage.iter().map(|storage_slot| async { let slot = B256::from(U256::from_str(&storage_slot.slot)?); let raw_slot_value = provider .get_storage_at(address, slot.into()) .block_id(block.unwrap_or_default()) .await?; - - let value = StorageValue { slot, raw_slot_value: raw_slot_value.into() }; - - Ok(value) - }); - - futures::future::try_join_all(requests).await -} - -fn print_storage(layout: StorageLayout, values: Vec, pretty: bool) -> Result<()> { - if !pretty { - let values: Vec<_> = layout - .storage - .iter() - .zip(&values) - .map(|(slot, storage_value)| { - let storage_type = layout.types.get(&slot.storage_type); - storage_value.value( - slot.offset, - storage_type.and_then(|t| t.number_of_bytes.parse::().ok()), - ) - }) - .collect(); - sh_println!( - "{}", - serde_json::to_string_pretty(&serde_json::to_value(StorageReport { layout, values })?)? - )?; + let storage_type = layout.types.get(&storage_slot.storage_type); + let value = StorageValue { slot, raw_slot_value: raw_slot_value.into() }.value( + storage_slot.offset, + storage_type.and_then(|t| t.number_of_bytes.parse::().ok()), + ); + Ok::<_, eyre::Report>(value) + })) + .await?; + + if shell::is_json() { + let report = StorageReport { layout: layout.clone(), values }; + sh_println!("{}", serde_json::to_string_pretty(&serde_json::to_value(report)?)?)?; return Ok(()); } let mut table = Table::new(); - table.apply_modifier(UTF8_ROUND_CORNERS); - - table.set_header(vec![ - Cell::new("Name"), - Cell::new("Type"), - Cell::new("Slot"), - Cell::new("Offset"), - Cell::new("Bytes"), - Cell::new("Value"), - Cell::new("Hex Value"), - Cell::new("Contract"), - ]); - - for (slot, storage_value) in layout.storage.into_iter().zip(values) { + table.load_style(if shell::is_markdown() { + ASCII_MARKDOWN + } else { + ASCII_FULL.with_rounded_corners() + }); + table.set_header(["Name", "Type", "Slot", "Offset", "Bytes", "Value", "Hex Value", "Contract"]); + for (slot, value) in layout.storage.iter().zip(values) { let storage_type = layout.types.get(&slot.storage_type); - let value = storage_value - .value(slot.offset, storage_type.and_then(|t| t.number_of_bytes.parse::().ok())); - let converted_value = U256::from_be_bytes(value.0); - table.add_row([ slot.label.as_str(), storage_type.map_or("?", |t| &t.label), &slot.slot, &slot.offset.to_string(), storage_type.map_or("?", |t| &t.number_of_bytes), - &converted_value.to_string(), + &Into::::into(value).to_string(), &value.to_string(), &slot.contract, ]); } - sh_println!("\n{table}\n")?; - Ok(()) } -fn add_storage_layout_output>(project: &mut Project) { - project.artifacts.additional_values.storage_layout = true; - project.update_output_selection(|selection| { - selection.0.values_mut().for_each(|contract_selection| { - contract_selection - .values_mut() - .for_each(|selection| selection.push("storageLayout".to_string())) - }); - }) +#[cfg(test)] +mod tests { + use super::*; + use foundry_compilers::{PathStyle, artifacts::BytecodeHash}; + use foundry_config::{CompilationRestrictions, SettingsOverrides, filter::GlobMatcher}; + use foundry_test_utils::{ + TestProject, + util::{OTHER_SOLC_VERSION, SOLC_VERSION}, + }; + use std::path::Path; + + fn load_project(project: &TestProject) -> Project { + load_project_with_config(project, Config::with_root(project.root())) + } + + fn load_project_with_config(project: &TestProject, config: Config) -> Project { + let project = config.canonic_at(project.root()).project().unwrap(); + assert!(project.paths.has_input_files(), "{:?}", project.paths); + project + } + + /// Compiles `project` and returns the artifact id and deployed code of `name` in `source`. + fn compile_target(project: &Project, source: &Path, name: &str) -> (ArtifactId, Bytes) { + let output = ProjectCompiler::new().quiet(true).compile(project).unwrap(); + let (target, artifact) = + output.artifact_ids().find(|(id, _)| id.source == source && id.name == name).unwrap(); + (target, artifact.get_deployed_bytecode_bytes().unwrap().into_owned()) + } + + #[test] + fn local_storage_layout_targets_exact_artifact_and_imports() { + let prj = TestProject::new("cast-storage-target", PathStyle::Dapptools); + let base_path = prj.add_source("Base", "contract Base { uint256 baseValue; }"); + let unrelated_path = prj.add_source("Target", "contract Target { uint256 unrelated; }"); + let target_path = prj.add_source( + "nested/Target", + r#" +import "src/Base.sol"; + +contract Target is Base { + uint256 value; + + function marker() external pure returns (bool) { + return true; + } } +"#, + ); + let project = load_project(&prj); + let (target, address_code) = compile_target(&project, &target_path, "Target"); + let artifact_before = std::fs::read(&target.path).unwrap(); + let cache_before = std::fs::read(project.cache_path()).unwrap(); + + let output = compile_target_storage_layout(&project, &target).unwrap(); + assert!(output.artifact_ids().any(|(id, _)| id.source == base_path)); + assert!(!output.artifact_ids().any(|(id, _)| id.source == unrelated_path)); + let artifact = find_target_artifact(output, &target, &address_code).unwrap(); + let labels = artifact + .storage_layout + .as_ref() + .unwrap() + .storage + .iter() + .map(|slot| slot.label.as_str()) + .collect::>(); + assert_eq!(labels, ["baseValue", "value"]); + + let artifact = + compile_local_storage_layout(&project, &address_code, false).unwrap().unwrap(); + assert!(artifact.storage_layout.is_some()); + assert_eq!(std::fs::read(&target.path).unwrap(), artifact_before); + assert_eq!(std::fs::read(project.cache_path()).unwrap(), cache_before); + } + + #[test] + fn local_storage_layout_rechecks_bytecode_after_source_change() { + let prj = TestProject::new("cast-storage-source-change", PathStyle::Dapptools); + let target_path = prj.add_source("Target", "contract Target { uint256 originalValue; }"); + let project = load_project(&prj); + let (target, address_code) = compile_target(&project, &target_path, "Target"); + + std::fs::write( + &target_path, + format!( + "// SPDX-License-Identifier: MIT\npragma solidity ={SOLC_VERSION};\ncontract Target {{ uint256 changedValue; }}\n" + ), + ) + .unwrap(); + + let output = compile_target_storage_layout(&project, &target).unwrap(); + assert!(find_target_artifact(output, &target, &address_code).is_none()); + assert!(compile_local_storage_layout(&project, &address_code, false).unwrap().is_none()); + } + + #[test] + fn local_storage_layout_preserves_compiler_profile() { + let prj = TestProject::new("cast-storage-profile", PathStyle::Dapptools); + let target_path = prj.add_source("Profiled", "contract Profiled { uint256 value; }"); + let mut config = Config::with_root(prj.root()); + config.additional_compiler_profiles = vec![SettingsOverrides { + name: "optimized".to_string(), + via_ir: Some(true), + evm_version: None, + optimizer: Some(true), + optimizer_runs: Some(1), + bytecode_hash: None, + }]; + config.compilation_restrictions = vec![CompilationRestrictions { + paths: GlobMatcher::from_str("src/Profiled.sol").unwrap(), + version: None, + via_ir: Some(true), + bytecode_hash: None, + min_optimizer_runs: None, + optimizer_runs: Some(1), + max_optimizer_runs: None, + min_evm_version: None, + evm_version: None, + max_evm_version: None, + }]; + let project = load_project_with_config(&prj, config); + let (target, address_code) = compile_target(&project, &target_path, "Profiled"); + assert_eq!(target.profile, "optimized"); + + let output = compile_target_storage_layout(&project, &target).unwrap(); + let (compiled, _) = output + .artifact_ids() + .find(|(id, artifact)| { + same_artifact(id, &target) && has_deployed_code(artifact, &address_code) + }) + .unwrap(); + assert_eq!(compiled.version, target.version); + assert_eq!(compiled.profile, target.profile); + assert!(find_target_artifact(output, &target, &address_code).is_some()); + } -fn is_storage_layout_empty(storage_layout: &Option) -> bool { - if let Some(s) = storage_layout { s.storage.is_empty() } else { true } + #[test] + fn local_storage_layout_preserves_compiler_version_in_multi_version_project() { + let prj = TestProject::new("cast-storage-multi-version", PathStyle::Dapptools); + let old_path = prj.add_raw_source( + "Old", + &format!( + "// SPDX-License-Identifier: MIT\npragma solidity ={OTHER_SOLC_VERSION};\ncontract Old {{ uint256 oldValue; }}\n" + ), + ); + let new_path = prj.add_raw_source( + "New", + &format!( + "// SPDX-License-Identifier: MIT\npragma solidity ={SOLC_VERSION};\ncontract New {{ uint256 newValue; }}\n" + ), + ); + let mut config = Config::with_root(prj.root()); + config.solc = None; + let project = load_project_with_config(&prj, config); + let (target, address_code) = compile_target(&project, &old_path, "Old"); + assert_eq!(target.version, Version::parse(OTHER_SOLC_VERSION).unwrap()); + + let output = compile_target_storage_layout(&project, &target).unwrap(); + assert!(!output.artifact_ids().any(|(id, _)| id.source == new_path)); + assert!(find_target_artifact(output, &target, &address_code).is_some()); + let artifact = + compile_local_storage_layout(&project, &address_code, false).unwrap().unwrap(); + assert_eq!(artifact.storage_layout.unwrap().storage[0].label, "oldValue"); + } + + #[test] + fn local_storage_layout_preserves_full_json_ast_ids() { + let prj = TestProject::new("cast-storage-json-ast-ids", PathStyle::Dapptools); + prj.add_source("First", "contract First { uint256 first; }"); + let target_path = prj.add_source("Target", "contract Target { uint256 value; }"); + let project = load_project(&prj); + let (target, address_code) = compile_target(&project, &target_path, "Target"); + + let targeted = find_target_artifact( + compile_target_storage_layout(&project, &target).unwrap(), + &target, + &address_code, + ) + .unwrap(); + let full = compile_local_storage_layout(&project, &address_code, true).unwrap().unwrap(); + + assert_ne!(full.storage_layout, targeted.storage_layout); + assert_eq!(full.storage_layout.unwrap().storage[0].label, "value"); + } + + #[test] + fn local_storage_layout_uses_full_compile_with_build_info() { + let prj = TestProject::new("cast-storage-build-info", PathStyle::Dapptools); + let target_path = prj.add_source("Target", "contract Target { uint256 value; }"); + let mut config = Config::with_root(prj.root()); + config.build_info = true; + let project = load_project_with_config(&prj, config); + let (_, address_code) = compile_target(&project, &target_path, "Target"); + + let artifact = + compile_local_storage_layout(&project, &address_code, true).unwrap().unwrap(); + assert!(artifact.storage_layout.is_some()); + } + + #[test] + fn local_storage_layout_uses_full_compile_without_cache() { + let prj = TestProject::new("cast-storage-no-cache", PathStyle::Dapptools); + let target_path = prj.add_source("Target", "contract Target { uint256 value; }"); + let project = load_project(&prj); + let mut code_project = project.clone(); + code_project.no_artifacts = true; + let (_, address_code) = compile_target(&code_project, &target_path, "Target"); + assert!(!project.cache_path().exists()); + + let artifact = + compile_local_storage_layout(&project, &address_code, true).unwrap().unwrap(); + assert!(artifact.storage_layout.is_some()); + } + + #[test] + fn local_storage_layout_ignores_immutable_values() { + let prj = TestProject::new("cast-storage-immutable", PathStyle::Dapptools); + let target_path = prj.add_source( + "Pinned", + r#" +contract Pinned { + address public immutable token; + uint256 count; + + constructor(address token_) { + token = token_; + } } +"#, + ); + let project = load_project(&prj); + let output = ProjectCompiler::new().quiet(true).compile(&project).unwrap(); + let (_, artifact) = output.artifact_ids().find(|(id, _)| id.source == target_path).unwrap(); + + // Deploy-time code with `token` set to `address(1)`. + let mut code = artifact.get_deployed_bytecode_bytes().unwrap().to_vec(); + let immutables = &artifact.deployed_bytecode.as_ref().unwrap().immutable_references; + assert!(!immutables.is_empty()); + for offsets in immutables.values().flatten() { + code[(offsets.start + offsets.length - 1) as usize] = 1; + } + let address_code = Bytes::from(code.clone()); + assert!(has_deployed_code(artifact, &address_code)); + + for json in [false, true] { + let artifact = + compile_local_storage_layout(&project, &address_code, json).unwrap().unwrap(); + let labels = artifact + .storage_layout + .as_ref() + .unwrap() + .storage + .iter() + .map(|slot| slot.label.as_str()) + .collect::>(); + assert_eq!(labels, ["count"]); + } -#[cfg(test)] -mod tests { - use super::*; + code[0] ^= 1; + assert!(!has_deployed_code(artifact, &Bytes::from(code))); + } + + #[test] + fn local_storage_layout_requires_metadata_hash_for_immutables() { + let prj = TestProject::new("cast-storage-immutable-no-hash", PathStyle::Dapptools); + let target_path = prj.add_source( + "Pinned", + r#" +contract Pinned { + address public immutable token; + uint256 count; + + constructor(address token_) { + token = token_; + } +} +"#, + ); + + for cbor_metadata in [true, false] { + let mut config = Config::with_root(prj.root()); + config.bytecode_hash = BytecodeHash::None; + config.cbor_metadata = cbor_metadata; + let project = load_project_with_config(&prj, config); + let output = ProjectCompiler::new().quiet(true).compile(&project).unwrap(); + let (_, artifact) = + output.artifact_ids().find(|(id, _)| id.source == target_path).unwrap(); + let mut code = artifact.get_deployed_bytecode_bytes().unwrap().to_vec(); + assert!(has_deployed_code(artifact, &Bytes::from(code.clone()))); + + // Without a metadata hash, a different contract could share every other byte. + let immutables = &artifact.deployed_bytecode.as_ref().unwrap().immutable_references; + for offsets in immutables.values().flatten() { + code[(offsets.start + offsets.length - 1) as usize] = 1; + } + let address_code = Bytes::from(code); + assert!(!has_deployed_code(artifact, &address_code)); + for json in [false, true] { + assert!( + compile_local_storage_layout(&project, &address_code, json).unwrap().is_none() + ); + } + } + } + + #[test] + fn local_storage_layout_rejects_ambiguous_artifacts() { + let prj = TestProject::new("cast-storage-ambiguous", PathStyle::Dapptools); + let first_path = prj.add_source("First", "contract First { uint256 first; }"); + let second_path = prj.add_source("Second", "contract Second { uint256 second; }"); + let mut config = Config::with_root(prj.root()); + config.bytecode_hash = BytecodeHash::None; + config.cbor_metadata = false; + let project = load_project_with_config(&prj, config); + let (_, address_code) = compile_target(&project, &first_path, "First"); + assert_eq!(compile_target(&project, &second_path, "Second").1, address_code); + + for json in [false, true] { + let err = compile_local_storage_layout(&project, &address_code, json).unwrap_err(); + assert_eq!( + err.to_string(), + "Deployed code matches multiple local contracts: src/First.sol:First, src/Second.sol:Second" + ); + } + } #[test] fn parse_storage_etherscan_api_key() { @@ -370,8 +775,6 @@ mod tests { std::env::remove_var("ETHERSCAN_API_KEY"); } assert_eq!(config.etherscan_api_key, Some("dummykey".to_string())); - - let key = config.get_etherscan_api_key(None).unwrap(); - assert_eq!(key, "dummykey".to_string()); + assert_eq!(config.get_etherscan_api_key(None).unwrap(), "dummykey".to_string()); } } diff --git a/crates/cast/src/cmd/storage_credits.rs b/crates/cast/src/cmd/storage_credits.rs new file mode 100644 index 0000000000000..f35130accd50c --- /dev/null +++ b/crates/cast/src/cmd/storage_credits.rs @@ -0,0 +1,184 @@ +use crate::{ + cmd::tip20::send_tip20_transaction, + tempo::{ensure_tempo_precompile_active, print_payload, tempo_provider}, + tx::{SendTxOpts, TxParams}, +}; +use alloy_ens::NameOrAddress; +use alloy_primitives::Address; +use alloy_sol_types::SolCall; +use clap::{Parser, ValueEnum}; +use eyre::Result; +use foundry_cli::opts::RpcOpts; +use foundry_common::provider::RetryProvider; +use foundry_evm::hardfork::TempoHardfork; +use serde_json::{Value, json}; +use std::str::FromStr; +use tempo_alloy::TempoNetwork; +use tempo_contracts::precompiles::{IStorageCredits, STORAGE_CREDITS_ADDRESS}; + +/// T7 storage credits operations (Tempo). +/// +/// Storage credits are a per-account, non-transferable balance minted when an account frees its own +/// storage and later spent to discount the creation cost of new storage. This wraps the T7 +/// StorageCredits precompile at `0x1060000000000000000000000000000000000000`. +#[derive(Debug, Parser, Clone)] +pub enum StorageCreditsSubcommand { + /// Show an account's storage credit balance. + Balance { + /// Account to query. + #[arg(value_parser = NameOrAddress::from_str)] + account: NameOrAddress, + + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Show an account's storage credit consumption mode. + /// + /// Mode is transaction-local transient state, so a standalone read reflects the default rather + /// than a value set by an earlier `set-mode` transaction. + Mode { + /// Account to query. + #[arg(value_parser = NameOrAddress::from_str)] + account: NameOrAddress, + + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Show an account's storage credit spend budget. + /// + /// Budget is transaction-local transient state, so a standalone read reflects the default + /// rather than a value set by an earlier `set-budget` transaction. + Budget { + /// Account to query. + #[arg(value_parser = NameOrAddress::from_str)] + account: NameOrAddress, + + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Set the caller's storage credit consumption mode. + /// + /// The mode only applies within the transaction that sets it; batch it with the storage + /// operations it should govern. + SetMode { + /// Mode to switch to. + #[arg(value_enum)] + mode: CreditMode, + + #[command(flatten)] + send_tx: SendTxOpts, + + #[command(flatten)] + tx: TxParams, + }, + + /// Set the caller's storage credit spend budget, which also selects `direct` mode. + /// + /// The budget only applies within the transaction that sets it; batch it with the storage + /// operations it should govern. + SetBudget { + /// Maximum number of credits the caller may spend in `direct` mode this transaction. + credits: u64, + + #[command(flatten)] + send_tx: SendTxOpts, + + #[command(flatten)] + tx: TxParams, + }, +} + +/// CLI-facing spelling of `IStorageCredits::Mode`. +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum CreditMode { + /// Pay creation cost upfront, then settle credits as a refund at end of transaction. + Refund, + /// Pay creation cost upfront and keep freed credits instead of spending them. + Preserve, + /// Spend existing credits synchronously; selecting this sets an effectively unlimited budget. + Direct, +} + +impl StorageCreditsSubcommand { + pub async fn run(self) -> Result<()> { + match self { + Self::Balance { account, rpc } => { + read(account, rpc, "balance", "Balance:", |credits, account| async move { + Ok(json!(credits.balanceOf(account).call().await?)) + }) + .await + } + Self::Mode { account, rpc } => { + read(account, rpc, "mode", "Mode: ", |credits, account| async move { + Ok(json!(credits.modeOf(account).call().await?.as_str())) + }) + .await + } + Self::Budget { account, rpc } => { + read(account, rpc, "budget", "Budget: ", |credits, account| async move { + Ok(json!(credits.budgetOf(account).call().await?)) + }) + .await + } + Self::SetMode { mode, send_tx, tx } => { + ensure_t7(&send_tx.eth.rpc, "cast storage-credits set-mode").await?; + let new_mode = match mode { + CreditMode::Refund => IStorageCredits::Mode::Refund, + CreditMode::Preserve => IStorageCredits::Mode::Preserve, + CreditMode::Direct => IStorageCredits::Mode::Direct, + }; + let data = IStorageCredits::setModeCall { newMode: new_mode }.abi_encode(); + send_tip20_transaction(STORAGE_CREDITS_ADDRESS, data, send_tx, tx).await + } + Self::SetBudget { credits, send_tx, tx } => { + ensure_t7(&send_tx.eth.rpc, "cast storage-credits set-budget").await?; + let data = IStorageCredits::setBudgetCall { credits }.abi_encode(); + send_tip20_transaction(STORAGE_CREDITS_ADDRESS, data, send_tx, tx).await + } + } + } +} + +type Credits = IStorageCredits::IStorageCreditsInstance, TempoNetwork>; + +/// Reads one account field from the precompile and prints it as `key` in JSON mode and after +/// `label` otherwise. +async fn read( + account: NameOrAddress, + rpc: RpcOpts, + key: &str, + label: &str, + query: F, +) -> Result<()> +where + F: FnOnce(Credits, Address) -> Fut, + Fut: Future>, +{ + let provider = ensure_t7(&rpc, &format!("cast storage-credits {key}")).await?; + let account = account.resolve(&provider).await?; + let value = query(IStorageCredits::new(STORAGE_CREDITS_ADDRESS, provider), account).await?; + let payload = json!({ "account": format!("{account}"), key: value }); + print_payload(payload, |payload| { + let value = &payload[key]; + let value = value.as_str().map_or_else(|| value.to_string(), str::to_string); + sh_println!("Account: {}\n{label} {value}", payload["account"].as_str().unwrap_or_default()) + }) +} + +/// The StorageCredits precompile only exists on T7+; fail early with a clear message instead of +/// surfacing a raw revert (or, for writes, a silently successful no-op transaction to an empty +/// account). +async fn ensure_t7(rpc: &RpcOpts, command: &str) -> Result> { + let (_, provider) = tempo_provider(rpc)?; + ensure_tempo_precompile_active( + &provider, + TempoHardfork::T7, + STORAGE_CREDITS_ADDRESS, + &format!("{command} requires a Tempo T7-capable StorageCredits RPC"), + ) + .await?; + Ok(provider) +} diff --git a/crates/cast/src/cmd/tempo.rs b/crates/cast/src/cmd/tempo.rs new file mode 100644 index 0000000000000..fd9ebaf848683 --- /dev/null +++ b/crates/cast/src/cmd/tempo.rs @@ -0,0 +1,105 @@ +use alloy_primitives::Address; +use alloy_signer_local::PrivateKeySigner; +use clap::Parser; +use eyre::Result; +use foundry_common::tempo::{EnsureAccessKeyConfig, decode_key_authorization, ensure_access_key}; +use tempo_alloy::accounts::TempoAccountsStore; +use tempo_primitives::transaction::SignedKeyAuthorization; + +mod zone; + +/// Tempo-specific commands. +#[derive(Debug, Parser)] +pub struct TempoArgs { + #[command(subcommand)] + command: TempoSubcommand, +} + +impl TempoArgs { + pub async fn run(self) -> Result<()> { + self.command.run().await + } +} + +/// Tempo wallet and zone integration commands. +#[derive(Debug, Parser)] +#[allow( + clippy::large_enum_variant, + reason = "parsed once; retaining PrivateKeySigner keeps access-key input typed and redacted" +)] +pub enum TempoSubcommand { + /// Deposit into and withdraw from Tempo zones. + Zone(zone::ZoneArgs), + + /// Authorize a new access key against your Tempo wallet via wallet.tempo. + /// + /// Persists the key to `$TEMPO_HOME/wallet/store.json` (default + /// `~/.tempo/wallet/store.json`). Also runs automatically on a 402 from a + /// Tempo RPC when no local key is configured. + /// + /// Env: `TEMPO_HOME`, `TEMPO_CLI_AUTH_URL` (override auth service). + Login { + /// Chain ID to authorize the key for. Defaults to Tempo mainnet (4217). + #[arg(long, default_value_t = 4217)] + chain_id: u64, + + /// Print the authorization URL to stderr instead of opening a browser. + #[arg(long)] + no_browser: bool, + }, + + /// Import a signed secp256k1 access key into the Tempo Accounts store. + /// + /// The signed authorization may be pending or already provisioned. The + /// access-key private key is persisted to `store.json`; the authorizing + /// root key is never stored. + ImportAccessKey { + /// Root Tempo account controlled by the access key. + #[arg(long)] + account: Address, + + /// Access-key private key to persist. + #[arg(long, env = "TEMPO_ACCESS_KEY", hide_env_values = true)] + access_key: PrivateKeySigner, + + /// Signed key authorization encoded as RLP hex. + #[arg(long)] + authorization: String, + }, +} + +impl TempoSubcommand { + pub async fn run(self) -> Result<()> { + match self { + Self::Zone(args) => args.run().await, + Self::Login { chain_id, no_browser } => { + let mut cfg = EnsureAccessKeyConfig::from_env(chain_id); + cfg.no_browser |= no_browser; + let outcome = ensure_access_key(cfg).await?; + let _ = foundry_common::sh_status!( + "Authorized key {} for wallet {} on chain {}", + outcome.key_address, + outcome.wallet_address, + outcome.chain_id, + ); + Ok(()) + } + Self::ImportAccessKey { account, access_key, authorization } => { + let authorization = + decode_key_authorization::(&authorization)?; + let chain_id = authorization.chain_id; + let key_address = access_key.address(); + let store = TempoAccountsStore::default_path()?; + store.upsert_secp256k1_access_key(account, &access_key, &authorization)?; + let _ = foundry_common::sh_status!( + "Imported access key {} for wallet {} on chain {} into {}", + key_address, + account, + chain_id, + store.path().display(), + ); + Ok(()) + } + } + } +} diff --git a/crates/cast/src/cmd/tempo/zone/abi.rs b/crates/cast/src/cmd/tempo/zone/abi.rs new file mode 100644 index 0000000000000..2cd8f0442d5b1 --- /dev/null +++ b/crates/cast/src/cmd/tempo/zone/abi.rs @@ -0,0 +1,58 @@ +//! Minimal zone client ABI, mirrored from tempoxyz/zones at a1c15e9f. +//! +//! Source: + +use alloy_primitives::{Address, address}; +use alloy_sol_types::sol; + +/// Zone outbox precompile address. +pub(super) const OUTBOX: Address = address!("1c00000000000000000000000000000000000002"); + +sol! { + /// Encrypted recipient and memo submitted to the L1 portal. + struct DepositPayload { + bytes32 ephemeralPubkeyX; + uint8 ephemeralPubkeyYParity; + bytes ciphertext; + bytes12 nonce; + bytes16 tag; + } + + #[sol(rpc)] + interface IZonePortal { + function zoneId() external view returns (uint32); + event WithdrawalProcessed(address indexed to, bytes32 indexed senderTag, + address token, uint128 amount, bool callbackSuccess); + function encryptionKeyAtBlock(uint64 tempoBlockNumber) + external view returns (bytes32 x, uint8 yParity, uint256 keyIndex); + function deposit(address token, uint128 amount, uint256 keyIndex, + DepositPayload encrypted, address tempoRefundRecipient) + external returns (bytes32 newCurrentDepositQueueHash); + } + + #[sol(rpc)] + #[allow(clippy::too_many_arguments, reason = "matches the zone protocol ABI")] + interface IZoneOutbox { + event WithdrawalRequested(uint64 indexed withdrawalIndex, address indexed sender, + address token, address to, uint128 amount, uint128 fee, bytes32 memo, + uint64 gasLimit, uint64 fallbackNonce, bytes data, bytes revealTo); + function calculateWithdrawalFee(uint64 gasLimit) external view returns (uint128 fee); + function requestWithdrawal(address token, address to, uint128 amount, bytes32 memo, + uint64 gasLimit, address fallbackRecipient, bytes callbackData, bytes revealTo) + external; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_sol_types::SolCall; + + #[test] + fn zone_abi_selectors() { + assert_eq!(IZonePortal::depositCall::SELECTOR, [0x03, 0xdd, 0x6f, 0x34]); + assert_eq!(IZonePortal::encryptionKeyAtBlockCall::SELECTOR, [0x39, 0xdc, 0x01, 0x5d]); + assert_eq!(IZoneOutbox::requestWithdrawalCall::SELECTOR, [0xb3, 0xb2, 0x00, 0xaa]); + assert_eq!(IZoneOutbox::calculateWithdrawalFeeCall::SELECTOR, [0x7b, 0x9c, 0x9a, 0xa4]); + } +} diff --git a/crates/cast/src/cmd/tempo/zone/auth.rs b/crates/cast/src/cmd/tempo/zone/auth.rs new file mode 100644 index 0000000000000..b7df8e3b88b93 --- /dev/null +++ b/crates/cast/src/cmd/tempo/zone/auth.rs @@ -0,0 +1,74 @@ +//! Zone RPC auth wire format, mirrored from tempoxyz/zones at a1c15e9f. +//! +//! Source: + +use alloy_primitives::{B256, hex, keccak256}; +use alloy_signer::Signer; +use eyre::Result; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub(super) const HEADER: &str = "x-authorization-token"; + +/// Sign a short-lived token with the same raw-digest signature as `cast wallet sign --no-hash`. +pub(super) async fn sign_token( + signer: &impl Signer, + zone_id: u32, + chain_id: u64, +) -> Result { + let issued = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + let (fields, digest) = token_fields(zone_id, chain_id, issued, issued + 600); + let signature = signer.sign_hash(&digest).await?; + let mut token = signature.as_bytes().to_vec(); + token.extend_from_slice(&fields); + Ok(hex::encode(token)) +} + +fn token_fields(zone_id: u32, chain_id: u64, issued: u64, expires: u64) -> ([u8; 29], B256) { + let mut message = [0u8; 61]; + message[..12].copy_from_slice(b"TempoZoneRPC"); + message[33..37].copy_from_slice(&zone_id.to_be_bytes()); + message[37..45].copy_from_slice(&chain_id.to_be_bytes()); + message[45..53].copy_from_slice(&issued.to_be_bytes()); + message[53..61].copy_from_slice(&expires.to_be_bytes()); + (message[32..].try_into().unwrap(), keccak256(message)) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::{Signature, b256}; + use alloy_signer_local::PrivateKeySigner; + + #[tokio::test] + async fn token_recovers_wallet_from_raw_digest() { + let signer = PrivateKeySigner::random(); + let token = hex::decode(sign_token(&signer, 42, 1337).await.unwrap()).unwrap(); + assert_eq!(token.len(), 94); + let fields = &token[65..]; + assert_eq!(&fields[..13], &hex::decode("000000002a0000000000000539").unwrap()); + let issued = u64::from_be_bytes(fields[13..21].try_into().unwrap()); + let expires = u64::from_be_bytes(fields[21..29].try_into().unwrap()); + assert_eq!(expires - issued, 600); + let (_, digest) = token_fields(42, 1337, issued, expires); + let signature = Signature::try_from(&token[..65]).unwrap(); + assert_eq!(signature.recover_address_from_prehash(&digest).unwrap(), signer.address()); + } + + #[test] + fn matches_zones_wire_vector() { + let (fields, digest) = token_fields( + 0x0102_0304, + 0x0506_0708_090a_0b0c, + 0x0d0e_0f10_1112_1314, + 0x1516_1718_191a_1b1c, + ); + assert_eq!( + hex::encode(fields), + "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c" + ); + assert_eq!( + digest, + b256!("0xf827387a933f40dfedece81ba4933feaef89e98a269f52f4f54dda2f1dac4171") + ); + } +} diff --git a/crates/cast/src/cmd/tempo/zone/earn.rs b/crates/cast/src/cmd/tempo/zone/earn.rs new file mode 100644 index 0000000000000..7a7000d62ae54 --- /dev/null +++ b/crates/cast/src/cmd/tempo/zone/earn.rs @@ -0,0 +1,283 @@ +//! Callback builders for Earn's scoped SingleZoneEarnRouter. +//! +//! ABI mirrored from tempoxyz/earn at 0c0f141f. +//! +//! Source: + +use super::{ + abi::{DepositPayload, IZonePortal}, + encryption, + l1::L1Args, +}; +use alloy_primitives::{Address, B256, Bytes, keccak256}; +use alloy_provider::Provider; +use alloy_rpc_types::BlockId; +use alloy_sol_types::{SolValue, sol}; +use clap::Parser; +use eyre::{Result, ensure}; +use foundry_cli::json::print_scalar; +use foundry_common::sh_status; + +sol! { + struct ZoneReturn { + uint256 keyIndex; + DepositPayload encrypted; + address refundRecipient; + } + + struct CallbackData { + uint8 flow; + uint128 minVaultAssets; + uint128 minEarnShares; + uint128 minOutputAmount; + bytes32 actionId; + ZoneReturn zoneReturn; + } + + #[sol(rpc)] + interface IEarnRouter { + function allowedZoneId() external view returns (uint32); + } +} + +/// Build callback bytes to pass to `zone withdraw --callback-data` with nonzero callback gas. +#[derive(Debug, Parser)] +pub(super) struct EarnArgs { + #[command(subcommand)] + command: EarnSubcommand, +} + +#[derive(Debug, Parser)] +enum EarnSubcommand { + /// Encode an Earn deposit. Withdraw the router's private asset to --router. + EncodeDeposit { + /// Minimum vault assets after conversion, in smallest units. + #[arg(long)] + min_vault_assets: u128, + /// Minimum Earn shares minted, in smallest units. + #[arg(long)] + min_earn_shares: u128, + #[command(flatten)] + args: BuilderArgs, + }, + /// Encode an Earn redemption. Withdraw Earn shares to --router. + EncodeRedeem { + /// Minimum assets redeemed from the vault, in smallest units. + #[arg(long)] + min_vault_assets: u128, + /// Minimum private assets returned to the zone, in smallest units. + #[arg(long)] + min_output_amount: u128, + #[command(flatten)] + args: BuilderArgs, + }, +} + +#[derive(Debug, Parser)] +struct BuilderArgs { + /// Scoped SingleZoneEarnRouter address on L1. Also use it as withdrawal --to. + #[arg(long)] + router: Address, + /// Recipient of the encrypted return deposit inside the zone. + #[arg(long)] + recipient: Address, + /// Public L1 refund address if the return deposit fails. This is not encrypted and should not + /// identify --recipient when recipient privacy matters. + #[arg(long)] + refund_recipient: Address, + /// Memo encrypted with the return recipient. + #[arg(long, default_value_t = B256::ZERO)] + memo: B256, + /// Operation identifier. Defaults to the hash of the randomized return ciphertext. + #[arg(long)] + action_id: Option, + /// Zone ID for the return deposit. + #[arg(long, env = "ZONE_ID")] + zone_id: u32, + /// Zone chain ID used to select the parent RPC. + #[arg(long, env = "ZONE_CHAIN_ID")] + zone_chain_id: u64, + #[command(flatten)] + l1: L1Args, +} + +impl EarnArgs { + pub(super) async fn run(self) -> Result<()> { + let (args, flow, min_vault_assets, min_earn_shares, min_output_amount) = match self.command + { + EarnSubcommand::EncodeDeposit { args, min_vault_assets, min_earn_shares } => { + ensure!( + min_vault_assets > 0 && min_earn_shares > 0, + "Earn deposit minimum outputs must be greater than zero" + ); + (args, 0, min_vault_assets, min_earn_shares, 0) + } + EarnSubcommand::EncodeRedeem { args, min_vault_assets, min_output_amount } => { + ensure!( + min_vault_assets > 0 && min_output_amount > 0, + "Earn redemption minimum outputs must be greater than zero" + ); + (args, 1, min_vault_assets, 0, min_output_amount) + } + }; + ensure!( + !args.router.is_zero() && !args.recipient.is_zero() && !args.refund_recipient.is_zero(), + "router, recipient, and refund recipient must be nonzero" + ); + let provider = args.l1.provider(args.zone_chain_id, args.zone_id).await?; + ensure!( + IEarnRouter::new(args.router, &provider).allowedZoneId().call().await? == args.zone_id, + "Earn router belongs to a different zone" + ); + let portal_address = args.l1.portal()?; + let portal = IZonePortal::new(portal_address, &provider); + let block = provider.get_block_number().await?; + let key = portal.encryptionKeyAtBlock(block).block(BlockId::number(block)).call().await?; + // The router submits the return deposit, so ECIES must bind to its address. + let encrypted = encryption::encrypt_deposit( + key.x, + key.yParity, + args.recipient, + args.memo, + args.router, + portal_address, + key.keyIndex, + )?; + let action_id = args.action_id.unwrap_or_else(|| keccak256(&encrypted.ciphertext)); + let data = CallbackData { + flow, + minVaultAssets: min_vault_assets, + minEarnShares: min_earn_shares, + minOutputAmount: min_output_amount, + actionId: action_id, + zoneReturn: ZoneReturn { + keyIndex: key.keyIndex, + encrypted, + refundRecipient: args.refund_recipient, + }, + }; + sh_status!( + "Earn action ID: {action_id}. Use withdrawal --to {} with \ + --callback-gas-limit 10000000.", + args.router + )?; + // abi.encode(struct), including its dynamic tuple offset, without a function selector. + print_scalar(Bytes::from(data.abi_encode())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::{U256, hex}; + + const ROUTER: &str = "0x1111111111111111111111111111111111111111"; + const RECIPIENT: &str = "0x2222222222222222222222222222222222222222"; + const REFUND_RECIPIENT: &str = "0x3333333333333333333333333333333333333333"; + + #[test] + fn requires_public_refund_recipient() { + let args = [ + "earn", + "encode-deposit", + "--min-vault-assets", + "1", + "--min-earn-shares", + "1", + "--router", + ROUTER, + "--recipient", + RECIPIENT, + "--zone-id", + "1", + "--zone-chain-id", + "421700001", + "--portal", + ROUTER, + ]; + assert!(EarnArgs::try_parse_from(args).is_err()); + + let mut args = args.to_vec(); + args.extend(["--refund-recipient", REFUND_RECIPIENT]); + assert!(EarnArgs::try_parse_from(args).is_ok()); + } + + #[tokio::test] + async fn rejects_zero_minimum_outputs() { + for (subcommand, command, expected) in [ + ( + "encode-deposit", + ["--min-vault-assets", "0", "--min-earn-shares", "1"], + "Earn deposit minimum outputs must be greater than zero", + ), + ( + "encode-deposit", + ["--min-vault-assets", "1", "--min-earn-shares", "0"], + "Earn deposit minimum outputs must be greater than zero", + ), + ( + "encode-redeem", + ["--min-vault-assets", "0", "--min-output-amount", "1"], + "Earn redemption minimum outputs must be greater than zero", + ), + ( + "encode-redeem", + ["--min-vault-assets", "1", "--min-output-amount", "0"], + "Earn redemption minimum outputs must be greater than zero", + ), + ] { + let args = ["earn", subcommand].into_iter().chain(command).chain([ + "--router", + ROUTER, + "--recipient", + RECIPIENT, + "--refund-recipient", + REFUND_RECIPIENT, + "--zone-id", + "1", + "--zone-chain-id", + "421700001", + "--portal", + ROUTER, + ]); + assert_eq!( + EarnArgs::try_parse_from(args).unwrap().run().await.unwrap_err().to_string(), + expected + ); + } + } + + #[test] + fn callback_matches_solidity_struct_encoding() { + let encrypted = DepositPayload { + ephemeralPubkeyX: B256::repeat_byte(1), + ephemeralPubkeyYParity: 1, + ciphertext: Bytes::from(vec![2; 64]), + nonce: [3; 12].into(), + tag: [4; 16].into(), + }; + let recipient = Address::repeat_byte(5); + let action = B256::repeat_byte(6); + // Generated by solc 0.8.35 from the EarnCallbackData definition pinned in the module docs. + // Keeping the independently generated bytes catches tuple, enum, and dynamic-offset drift. + let expected = + hex::decode(include_str!("../../../../tests/fixtures/tempo_earn_callback.hex").trim()) + .unwrap(); + let encoded = CallbackData { + flow: 1, + minVaultAssets: 7, + minEarnShares: 0, + minOutputAmount: 9, + actionId: action, + zoneReturn: ZoneReturn { + keyIndex: U256::from(10), + encrypted, + refundRecipient: recipient, + }, + } + .abi_encode(); + assert_eq!(encoded, expected); + assert_eq!(U256::from_be_slice(&encoded[..32]), U256::from(32)); + assert_eq!(::abi_decode(&encoded).unwrap().flow, 1); + } +} diff --git a/crates/cast/src/cmd/tempo/zone/encryption.rs b/crates/cast/src/cmd/tempo/zone/encryption.rs new file mode 100644 index 0000000000000..db8423f4e7f05 --- /dev/null +++ b/crates/cast/src/cmd/tempo/zone/encryption.rs @@ -0,0 +1,149 @@ +//! Client-side deposit ECIES, mirrored from tempoxyz/zones at a1c15e9f. +//! +//! Source: + +use super::abi::DepositPayload; +use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::Aead}; +use alloy_primitives::{Address, B256, U256}; +use eyre::{Result, eyre}; +use hkdf::Hkdf; +use k256::{ + AffinePoint, ProjectivePoint, PublicKey, SecretKey, elliptic_curve::sec1::ToEncodedPoint, +}; +use sha2::Sha256; + +/// Encrypt `[recipient(20) | memo(32) | padding(12)]` for the sequencer. +pub(super) fn encrypt_deposit( + x: B256, + parity: u8, + to: Address, + memo: B256, + sender: Address, + portal: Address, + key_index: U256, +) -> Result { + let mut compressed = [0u8; 33]; + compressed[0] = match parity { + 0 | 1 => parity + 2, + 2 | 3 => parity, + _ => return Err(eyre!("invalid sequencer encryption key parity")), + }; + compressed[1..].copy_from_slice(x.as_slice()); + let public = PublicKey::from_sec1_bytes(&compressed) + .map_err(|_| eyre!("invalid sequencer encryption public key"))?; + let ephemeral = SecretKey::random(&mut rand_08::thread_rng()); + let encoded = ephemeral.public_key().to_encoded_point(true); + let ephemeral_x = B256::from_slice(encoded.x().unwrap()); + let shared = AffinePoint::from( + ProjectivePoint::from(*public.as_affine()) * *ephemeral.to_nonzero_scalar(), + ); + let shared = shared.to_encoded_point(true); + let mut info = [0u8; 104]; + info[..20].copy_from_slice(portal.as_slice()); + info[20..52].copy_from_slice(&key_index.to_be_bytes::<32>()); + info[52..84].copy_from_slice(ephemeral_x.as_slice()); + info[84..].copy_from_slice(sender.as_slice()); + let mut key = [0u8; 32]; + Hkdf::::new(Some(b"ecies-aes-key"), shared.x().unwrap()) + .expand(&info, &mut key) + .map_err(|_| eyre!("deposit key derivation failed"))?; + let mut plaintext = [0u8; 64]; + plaintext[..20].copy_from_slice(to.as_slice()); + plaintext[20..52].copy_from_slice(memo.as_slice()); + let nonce: [u8; 12] = rand_08::random(); + let mut encrypted = Aes256Gcm::new((&key).into()) + .encrypt(Nonce::from_slice(&nonce), plaintext.as_ref()) + .map_err(|_| eyre!("deposit encryption failed"))?; + let tag = encrypted.split_off(64); + Ok(DepositPayload { + ephemeralPubkeyX: ephemeral_x, + ephemeralPubkeyYParity: encoded.as_bytes()[0], + ciphertext: encrypted.into(), + nonce: nonce.into(), + tag: tag.as_slice().try_into()?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deposit_roundtrip_and_context_binding() { + let sequencer = SecretKey::from_slice(&[7; 32]).unwrap(); + let public = sequencer.public_key().to_encoded_point(true); + let x = B256::from_slice(public.x().unwrap()); + let to = Address::repeat_byte(0xbb); + let memo = B256::repeat_byte(0xcc); + let sender = Address::repeat_byte(0xdd); + let portal = Address::repeat_byte(0xaa); + let index = U256::from(42); + let payload = + encrypt_deposit(x, public.as_bytes()[0], to, memo, sender, portal, index).unwrap(); + assert_eq!(payload.ciphertext.len(), 64); + let mut ephemeral = [0u8; 33]; + ephemeral[0] = payload.ephemeralPubkeyYParity; + ephemeral[1..].copy_from_slice(payload.ephemeralPubkeyX.as_slice()); + let ephemeral = PublicKey::from_sec1_bytes(&ephemeral).unwrap(); + let shared = AffinePoint::from( + ProjectivePoint::from(*ephemeral.as_affine()) * *sequencer.to_nonzero_scalar(), + ); + let shared = shared.to_encoded_point(true); + // Zones' decryption context is packed, not ABI-padded. + let mut info = [ + portal.as_slice(), + &index.to_be_bytes::<32>(), + payload.ephemeralPubkeyX.as_slice(), + sender.as_slice(), + ] + .concat(); + let mut ciphertext = payload.ciphertext.to_vec(); + ciphertext.extend_from_slice(payload.tag.as_slice()); + let mut key = [0u8; 32]; + Hkdf::::new(Some(b"ecies-aes-key"), shared.x().unwrap()) + .expand(&info, &mut key) + .unwrap(); + let cipher = Aes256Gcm::new((&key).into()); + let plaintext = cipher + .decrypt(Nonce::from_slice(payload.nonce.as_slice()), ciphertext.as_slice()) + .unwrap(); + assert_eq!(&plaintext[..20], to.as_slice()); + assert_eq!(&plaintext[20..52], memo.as_slice()); + assert_eq!(&plaintext[52..], &[0; 12]); + // Changing any bound field must fail authentication. + for offset in [0, 20, 52, 84] { + info[offset] ^= 1; + Hkdf::::new(Some(b"ecies-aes-key"), shared.x().unwrap()) + .expand(&info, &mut key) + .unwrap(); + assert!( + Aes256Gcm::new((&key).into()) + .decrypt(Nonce::from_slice(payload.nonce.as_slice()), ciphertext.as_slice()) + .is_err() + ); + info[offset] ^= 1; + } + let second = + encrypt_deposit(x, public.as_bytes()[0], to, memo, sender, portal, index).unwrap(); + assert_ne!(payload.ephemeralPubkeyX, second.ephemeralPubkeyX); + assert_ne!(payload.nonce, second.nonce); + } + + #[test] + fn rejects_invalid_encryption_keys() { + for parity in [0, 1, 2, 3, 4, 255] { + assert!( + encrypt_deposit( + B256::repeat_byte(0xff), + parity, + Address::ZERO, + B256::ZERO, + Address::ZERO, + Address::ZERO, + U256::ZERO + ) + .is_err() + ); + } + } +} diff --git a/crates/cast/src/cmd/tempo/zone/l1.rs b/crates/cast/src/cmd/tempo/zone/l1.rs new file mode 100644 index 0000000000000..58a91bec793b5 --- /dev/null +++ b/crates/cast/src/cmd/tempo/zone/l1.rs @@ -0,0 +1,264 @@ +//! Parent-chain selection and correlated withdrawal settlement. + +use super::abi::{IZoneOutbox, IZonePortal, OUTBOX}; +use alloy_primitives::{Address, B256, keccak256}; +use alloy_provider::{Provider, RootProvider}; +use alloy_rpc_types::Filter; +use alloy_sol_types::SolEvent; +use clap::Parser; +use eyre::{Result, ensure, eyre}; +use std::time::Duration; +use tempo_alloy::TempoNetwork; + +/// L1 connection options, deliberately separate from the authenticated zone RPC. +#[derive(Debug, Parser)] +pub(super) struct L1Args { + /// Override the public mainnet or Moderato RPC inferred from the zone chain ID. + #[arg(long, env = "L1_RPC_URL")] + l1_rpc_url: Option, + /// Zone portal on Tempo L1. Required for --wait-l1 and Earn callback builders. + #[arg(long, env = "L1_PORTAL_ADDRESS")] + portal: Option

, +} + +impl L1Args { + pub(super) fn portal(&self) -> Result
{ + self.portal.ok_or_else(|| eyre!("--portal is required for L1 operations")) + } + + pub(super) async fn provider( + &self, + zone_chain_id: u64, + zone_id: u32, + ) -> Result> { + let portal = self.portal()?; + let parent = parent_chain(zone_chain_id, zone_id)?; + let url = match self.l1_rpc_url.as_deref() { + Some(url) => url, + None => public_rpc(parent)?, + }; + let provider = RootProvider::::new_http(url.parse()?); + ensure!( + provider.get_chain_id().await? == parent, + "L1 RPC chain ID does not match the zone's parent chain" + ); + ensure!( + IZonePortal::new(portal, &provider).zoneId().call().await? == zone_id, + "--portal does not belong to --zone-id" + ); + Ok(provider) + } +} + +// Chain ID allocation mirrored from zones/crates/primitives/src/constants.rs at a1c15e9f. +fn parent_chain(chain: u64, zone: u32) -> Result { + ensure!(zone != 0, "--zone-id must be nonzero"); + let (parent, decoded_zone) = match chain { + 421_700_000..1_424_310_000 => (4217, chain - 421_700_000), + 1_424_310_000..2_147_483_648 => (42431, chain - 1_424_310_000), + _ if chain >= 1 << 32 => (chain >> 32, chain & 0xffff_ffff), + _ => return Err(eyre!("invalid zone chain ID: {chain}")), + }; + ensure!(decoded_zone == u64::from(zone), "--zone-id does not match --zone-chain-id"); + ensure!(parent <= (1 << 20) - 2, "invalid parent chain ID"); + ensure!(chain < 1 << 32 || !matches!(parent, 4217 | 42431), "noncanonical zone chain ID"); + Ok(parent) +} + +fn public_rpc(parent: u64) -> Result<&'static str> { + match parent { + 4217 => Ok("https://rpc.tempo.xyz"), + 42431 => Ok("https://rpc.moderato.tempo.xyz"), + _ => Err(eyre!("no public RPC for parent chain {parent}; supply --l1-rpc-url")), + } +} + +fn sender_tag(sender: Address, tx_hash: B256, nonce: u64) -> B256 { + let mut bytes = [0u8; 60]; + bytes[..20].copy_from_slice(sender.as_slice()); + bytes[20..52].copy_from_slice(tx_hash.as_slice()); + bytes[52..].copy_from_slice(&nonce.to_be_bytes()); + keccak256(bytes) +} + +pub(super) async fn wait_for_withdrawal( + zone: &impl Provider, + l1: &impl Provider, + portal: Address, + mut from_block: u64, + zone_block: u64, + zone_hash: B256, +) -> Result { + let logs = zone + .get_logs( + &Filter::new() + .address(OUTBOX) + .event_signature(IZoneOutbox::WithdrawalRequested::SIGNATURE_HASH) + .from_block(zone_block) + .to_block(zone_block), + ) + .await?; + let requested = logs + .iter() + .filter(|log| log.transaction_hash == Some(zone_hash)) + .find_map(|log| IZoneOutbox::WithdrawalRequested::decode_log(&log.inner).ok()) + .ok_or_else(|| eyre!("withdrawal request event missing from {zone_hash}"))?; + let tag = sender_tag(requested.sender, zone_hash, requested.fallbackNonce); + loop { + let head = l1.get_block_number().await?; + if head >= from_block { + // Bound each query for public RPC providers and advance through historical ranges. + let end = head.min(from_block.saturating_add(999)); + let logs = l1 + .get_logs( + &Filter::new() + .address(portal) + .event_signature(IZonePortal::WithdrawalProcessed::SIGNATURE_HASH) + .topic1(requested.to) + .topic2(tag) + .from_block(from_block) + .to_block(end), + ) + .await?; + for log in logs { + let event = IZonePortal::WithdrawalProcessed::decode_log(&log.inner)?; + if !log.removed + && event.senderTag == tag + && event.to == requested.to + && event.token == requested.token + && event.amount == requested.amount + { + let hash = log + .transaction_hash + .ok_or_else(|| eyre!("L1 event missing transaction hash"))?; + ensure!( + event.callbackSuccess, + "L1 delivery failed in {hash}; a refund was queued for the zone fallback recipient" + ); + return Ok(hash); + } + } + from_block = end + 1; + if end < head { + continue; + } + } + tokio::time::sleep(Duration::from_secs(2)).await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::Bytes; + use alloy_provider::{ProviderBuilder, mock::Asserter}; + use alloy_rpc_types::Log; + + #[test] + fn parent_chain_and_public_defaults() { + assert_eq!( + public_rpc(parent_chain(421_700_007, 7).unwrap()).unwrap(), + "https://rpc.tempo.xyz" + ); + assert_eq!( + public_rpc(parent_chain(1_424_310_003, 3).unwrap()).unwrap(), + "https://rpc.moderato.tempo.xyz" + ); + assert_eq!(parent_chain((31337 << 32) | 2, 2).unwrap(), 31337); + assert!(public_rpc(31337).is_err()); + for (chain, zone) in + [(421_700_007, 8), (1_424_310_000, 0), (1 << 31, 1), ((4217 << 32) | 1, 1)] + { + assert!(parent_chain(chain, zone).is_err()); + } + } + + #[test] + fn sender_tag_binds_transaction_and_nonce() { + let sender = Address::repeat_byte(0x11); + let hash = B256::repeat_byte(0x22); + let expected = + keccak256([sender.as_slice(), hash.as_slice(), &3u64.to_be_bytes()].concat()); + assert_eq!(sender_tag(sender, hash, 3), expected); + assert_ne!(sender_tag(sender, hash, 3), sender_tag(sender, hash, 4)); + assert_ne!(sender_tag(sender, hash, 3), sender_tag(sender, B256::ZERO, 3)); + } + #[tokio::test] + async fn wait_checks_delivery_result_and_ignores_other_transactions() { + for success in [Some(true), Some(false), None] { + let zone_hash = B256::repeat_byte(1); + let l1_hash = B256::repeat_byte(2); + let portal = Address::repeat_byte(3); + let request = IZoneOutbox::WithdrawalRequested { + withdrawalIndex: 0, + sender: Address::repeat_byte(4), + token: Address::repeat_byte(5), + to: Address::repeat_byte(6), + amount: 7, + fee: 0, + memo: B256::ZERO, + gasLimit: 100_000, + fallbackNonce: 8, + data: Bytes::new(), + revealTo: Bytes::new(), + }; + let request_log = Log { + inner: alloy_primitives::Log { address: OUTBOX, data: request.encode_log_data() }, + transaction_hash: Some(zone_hash), + ..Default::default() + }; + let mut unrelated = request_log.clone(); + unrelated.transaction_hash = Some(B256::ZERO); + let zone_responses = Asserter::new(); + zone_responses.push_success(&vec![unrelated, request_log]); + let zone = ProviderBuilder::new() + .network::() + .connect_mocked_client(zone_responses); + let delivered = IZonePortal::WithdrawalProcessed { + to: request.to, + senderTag: sender_tag(request.sender, zone_hash, 8), + token: request.token, + amount: request.amount, + callbackSuccess: success.unwrap_or(true), + }; + let delivered_log = Log { + inner: alloy_primitives::Log { address: portal, data: delivered.encode_log_data() }, + transaction_hash: Some(l1_hash), + ..Default::default() + }; + let l1_responses = Asserter::new(); + l1_responses.push_success(&"0xa"); + let mut removed = delivered_log.clone(); + removed.removed = true; + let unrelated = IZonePortal::WithdrawalProcessed { senderTag: B256::ZERO, ..delivered }; + let mut unrelated_log = delivered_log.clone(); + unrelated_log.inner.data = unrelated.encode_log_data(); + let mut logs = vec![removed, unrelated_log]; + if success.is_some() { + logs.push(delivered_log); + } + l1_responses.push_success(&logs); + let l1 = ProviderBuilder::new() + .network::() + .connect_mocked_client(l1_responses); + let result = tokio::time::timeout( + Duration::from_millis(100), + wait_for_withdrawal(&zone, &l1, portal, 10, 20, zone_hash), + ) + .await; + match success { + Some(true) => assert_eq!(result.unwrap().unwrap(), l1_hash), + Some(false) => assert_eq!( + result.unwrap().unwrap_err().to_string(), + format!( + "L1 delivery failed in {l1_hash}; a refund was queued for the zone fallback recipient" + ) + ), + None => assert!( + result.is_err(), + "unrelated or removed events must not complete the wait" + ), + } + } + } +} diff --git a/crates/cast/src/cmd/tempo/zone/mod.rs b/crates/cast/src/cmd/tempo/zone/mod.rs new file mode 100644 index 0000000000000..234d71b476016 --- /dev/null +++ b/crates/cast/src/cmd/tempo/zone/mod.rs @@ -0,0 +1,425 @@ +//! Deposits to Tempo L1 portals and authenticated zone withdrawals. + +use crate::tempo::tempo_provider; +use alloy_network::{EthereumWallet, primitives::ReceiptResponse}; +use alloy_primitives::{Address, B256, Bytes, U256}; +use alloy_provider::{PendingTransactionBuilder, Provider, ProviderBuilder}; +use alloy_rpc_types::BlockId; +use alloy_signer::Signer; +use clap::Parser; +use eyre::{Result, WrapErr, ensure}; +use foundry_cli::{json::print_scalar, opts::RpcOpts, utils::LoadConfig}; +use foundry_common::{sh_status, shell}; +use foundry_wallets::{WalletOpts, WalletSigner}; +use std::time::Duration; +use tempo_alloy::{TempoNetwork, rpc::TempoTransactionReceipt}; +use tempo_contracts::precompiles::{ITIP20, PATH_USD_ADDRESS}; + +mod abi; +mod auth; +mod earn; +mod encryption; +mod l1; + +// Mirrored from zones/crates/primitives and zones/crates/precompiles at a1c15e9f. +const MAX_CALLBACK_GAS_LIMIT: u64 = 10_000_000; +const MAX_CALLBACK_DATA_SIZE: usize = 1024; + +/// Tempo zone operations. +#[derive(Debug, Parser)] +pub struct ZoneArgs { + #[command(subcommand)] + command: ZoneSubcommand, +} + +#[derive(Debug, Parser)] +enum ZoneSubcommand { + /// Encrypt a deposit and submit it to a portal on Tempo L1. + /// + /// --rpc-url selects the L1 RPC. The receipt confirms L1 submission, not zone completion. + Deposit(DepositArgs), + /// Request a withdrawal using the authenticated zone RPC. + /// + /// --rpc-url selects the zone RPC. The receipt confirms the request, not L1 settlement. + Withdraw(WithdrawArgs), + /// Build encrypted callbacks for the scoped Earn router. + Earn(earn::EarnArgs), +} + +#[derive(Debug, Parser)] +struct TransferArgs { + /// TIP-20 token address on the source chain. + #[arg(long, default_value_t = PATH_USD_ADDRESS)] + token: Address, + /// Amount in the token's smallest units. + #[arg(long)] + amount: u128, + /// Destination address. Defaults to the signing wallet. + #[arg(long)] + to: Option
, + /// Transfer memo. + #[arg(long, default_value_t = B256::ZERO)] + memo: B256, + /// Approve the required amount (including withdrawal fees) if allowance is insufficient. + #[arg(long)] + approve: bool, + #[command(flatten)] + rpc: RpcOpts, + #[command(flatten)] + wallet: WalletOpts, +} + +#[derive(Debug, Parser)] +struct DepositArgs { + /// Zone portal address on Tempo L1. + #[arg(long, env = "L1_PORTAL_ADDRESS")] + portal: Address, + /// Tempo L1 refund recipient if the deposit fails. Defaults to the signing wallet. + #[arg(long)] + refund_recipient: Option
, + #[command(flatten)] + transfer: TransferArgs, +} + +#[derive(Debug, Parser)] +struct WithdrawArgs { + /// Zone ID used to scope the RPC authorization token. + #[arg(long, env = "ZONE_ID")] + zone_id: u32, + /// Zone chain ID used to sign the RPC authorization token before connecting. + #[arg(long, env = "ZONE_CHAIN_ID")] + zone_chain_id: u64, + /// L1 callback gas limit. Zero disables the callback. + #[arg(long, default_value_t = 0)] + callback_gas_limit: u64, + /// Zone recipient for bounced withdrawals if L1 execution fails. Defaults to the signing + /// wallet. + #[arg(long)] + fallback_recipient: Option
, + /// Data passed to the L1 receiver's withdrawal callback, without a function selector. + #[arg(long, default_value = "0x")] + callback_data: Bytes, + /// Compressed secp256k1 key for revealing the withdrawal sender. + #[arg(long, default_value = "0x")] + reveal_to: Bytes, + /// Wait for successful L1 delivery, including callback execution. Does not wait for a return + /// deposit. + #[arg(long)] + wait_l1: bool, + /// Maximum seconds to wait for L1 settlement after zone inclusion. + #[arg(long, default_value_t = 180, value_parser = clap::value_parser!(u64).range(1..))] + wait_timeout: u64, + #[command(flatten)] + l1: l1::L1Args, + #[command(flatten)] + transfer: TransferArgs, +} + +impl ZoneArgs { + pub async fn run(self) -> Result<()> { + match self.command { + ZoneSubcommand::Deposit(args) => args.run().await, + ZoneSubcommand::Withdraw(args) => args.run().await, + ZoneSubcommand::Earn(args) => args.run().await, + } + } +} + +impl TransferArgs { + async fn signer(&self) -> Result { + ensure!(!self.rpc.curl, "zone operations do not support --curl"); + ensure!(self.amount > 0, "amount must be greater than zero"); + let signer = self.wallet.signer().await?; + if let Some(from) = self.wallet.from { + ensure!(from == signer.address(), "--from does not match the signing wallet"); + } + Ok(signer) + } + + async fn ensure_allowance( + &self, + provider: &impl Provider, + sender: Address, + spender: Address, + amount: u128, + gas_price: Option, + ) -> Result<()> { + let token = ITIP20::new(self.token, provider); + let amount = U256::from(amount); + if token.allowance(sender, spender).from(sender).call().await? < amount { + ensure!( + self.approve, + "insufficient token allowance for {spender}; use --approve to approve the deposit or withdrawal amount" + ); + sh_status!("Approving {} for {}", amount, spender)?; + let mut approval = token.approve(spender, amount).from(sender); + if let Some(gas_price) = gas_price { + approval = approval.max_fee_per_gas(gas_price).max_priority_fee_per_gas(0); + } + let receipt = submitted_receipt("token approval", approval.send().await?).await?; + ensure!(receipt.status(), "token approval reverted: {}", receipt.transaction_hash()); + } + Ok(()) + } +} + +impl DepositArgs { + async fn run(self) -> Result<()> { + let signer = self.transfer.signer().await?; + let sender = signer.address(); + let (_, provider) = tempo_provider(&self.transfer.rpc)?; + let provider = ProviderBuilder::new_with_network::() + .wallet(EthereumWallet::from(signer)) + .connect_provider(provider); + let portal = abi::IZonePortal::new(self.portal, &provider); + let block = provider.get_block_number().await?; + let key = portal + .encryptionKeyAtBlock(block) + .block(BlockId::number(block)) + .call() + .await + .wrap_err("failed to fetch portal encryption key")?; + let payload = encryption::encrypt_deposit( + key.x, + key.yParity, + self.transfer.to.unwrap_or(sender), + self.transfer.memo, + sender, + self.portal, + key.keyIndex, + )?; + self.transfer + .ensure_allowance(&provider, sender, self.portal, self.transfer.amount, None) + .await?; + let pending = portal + .deposit( + self.transfer.token, + self.transfer.amount, + key.keyIndex, + payload, + self.refund_recipient.unwrap_or(sender), + ) + .from(sender) + .send() + .await?; + let receipt = submitted_receipt("deposit", pending).await?; + ensure!(receipt.status(), "deposit reverted: {}", receipt.transaction_hash()); + let _ = sh_status!("Deposit submitted on L1; zone processing is asynchronous."); + print_receipt(&receipt) + } +} + +impl WithdrawArgs { + async fn run(self) -> Result<()> { + validate_callback(&self.callback_data, self.callback_gas_limit)?; + ensure!(self.zone_id != 0, "--zone-id must be nonzero"); + ensure!(self.zone_chain_id != 0, "--zone-chain-id must be nonzero"); + if !self.reveal_to.is_empty() { + ensure!( + self.reveal_to.len() == 33 + && k256::PublicKey::from_sec1_bytes(&self.reveal_to).is_ok(), + "--reveal-to must be a compressed secp256k1 public key" + ); + } + let signer = self.transfer.signer().await?; + let sender = signer.address(); + let token = auth::sign_token(&signer, self.zone_id, self.zone_chain_id) + .await + .wrap_err("wallet could not sign zone RPC authorization")?; + let mut config = self.transfer.rpc.load_config()?; + let headers = config.eth_rpc_headers.get_or_insert_default(); + headers.retain(|header| { + !header + .split_once(':') + .is_some_and(|(name, _)| name.trim().eq_ignore_ascii_case(auth::HEADER)) + }); + headers.push(format!("{}: {token}", auth::HEADER)); + let provider = + foundry_common::provider::ProviderBuilder::::from_config(&config)? + .build()?; + let provider = ProviderBuilder::new_with_network::() + .wallet(EthereumWallet::from(signer)) + .connect_provider(provider); + ensure!( + provider.get_chain_id().await? == self.zone_chain_id, + "zone RPC chain ID differs from --zone-chain-id" + ); + // Snapshot L1 before submitting, so fast settlement cannot be missed. + let wait = if self.wait_l1 { + let l1_provider = self.l1.provider(self.zone_chain_id, self.zone_id).await?; + let from_block = l1_provider.get_block_number().await?; + Some((l1_provider, from_block)) + } else { + None + }; + let outbox = abi::IZoneOutbox::new(abi::OUTBOX, &provider); + let fee = + outbox.calculateWithdrawalFee(self.callback_gas_limit).from(sender).call().await?; + let total = self + .transfer + .amount + .checked_add(fee) + .ok_or_else(|| eyre::eyre!("withdrawal amount plus fee exceeds uint128"))?; + // Use the private RPC's gas-price quote without requiring fee-history support. + let gas_price = provider.get_gas_price().await?; + self.transfer + .ensure_allowance(&provider, sender, abi::OUTBOX, total, Some(gas_price)) + .await?; + let to = self.transfer.to.unwrap_or(sender); + let pending = outbox + .requestWithdrawal( + self.transfer.token, + to, + self.transfer.amount, + self.transfer.memo, + self.callback_gas_limit, + self.fallback_recipient.unwrap_or(sender), + self.callback_data, + self.reveal_to, + ) + .max_fee_per_gas(gas_price) + .max_priority_fee_per_gas(0) + .from(sender) + .send() + .await?; + let receipt = submitted_receipt("withdrawal", pending).await?; + ensure!(receipt.status(), "withdrawal reverted: {}", receipt.transaction_hash()); + let hash = receipt.transaction_hash(); + if let Some((l1_provider, from_block)) = wait { + let _ = sh_status!("Withdrawal requested: {hash}; waiting for L1 delivery."); + let result = l1::wait_for_withdrawal( + &provider, + &l1_provider, + self.l1.portal()?, + from_block, + receipt.block_number().ok_or_else(|| eyre::eyre!("missing zone receipt block"))?, + hash, + ); + let l1_hash = tokio::time::timeout(Duration::from_secs(self.wait_timeout), result) + .await + .wrap_err_with(|| { + format!( + "L1 wait timed out; withdrawal {hash} is already submitted; do not resubmit" + ) + })? + .wrap_err_with(|| format!("L1 wait failed for submitted withdrawal {hash}"))?; + let _ = sh_status!("Withdrawal delivered on L1: {l1_hash}"); + } else { + let _ = sh_status!("Withdrawal requested on the zone; L1 settlement is asynchronous."); + } + print_receipt(&receipt) + } +} + +async fn submitted_receipt( + action: &str, + pending: PendingTransactionBuilder, +) -> Result { + let hash = *pending.tx_hash(); + let receipt = pending.with_timeout(Some(Duration::from_secs(120))).get_receipt(); + tokio::time::timeout(Duration::from_secs(120), receipt) + .await + .wrap_err_with(|| { + format!("{action} {hash} was submitted; receipt polling timed out; do not resubmit") + })? + .wrap_err_with(|| { + format!("{action} {hash} was submitted; receipt polling failed; do not resubmit") + }) +} + +fn validate_callback(data: &Bytes, gas_limit: u64) -> Result<()> { + ensure!( + data.is_empty() || gas_limit > 0, + "--callback-data requires a nonzero --callback-gas-limit" + ); + ensure!( + data.len() <= MAX_CALLBACK_DATA_SIZE, + "--callback-data exceeds the protocol limit of {MAX_CALLBACK_DATA_SIZE} bytes" + ); + ensure!( + gas_limit <= MAX_CALLBACK_GAS_LIMIT, + "--callback-gas-limit exceeds the protocol limit of {MAX_CALLBACK_GAS_LIMIT}" + ); + Ok(()) +} + +fn print_receipt(receipt: &(impl ReceiptResponse + serde::Serialize)) -> Result<()> { + if shell::is_json() { + foundry_common::sh_println!("{}", serde_json::to_string(receipt)?)?; + Ok(()) + } else { + print_scalar(receipt.transaction_hash()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cmd::tempo::{TempoArgs, TempoSubcommand}; + + #[test] + fn zone_commands_accept_cast_keystore_wallets() { + for args in [ + vec![ + "tempo", + "zone", + "deposit", + "--portal", + "0x1111111111111111111111111111111111111111", + "--amount", + "1000000", + "--account", + "zone-test", + "--password-file", + "/tmp/password", + "--approve", + ], + vec![ + "tempo", + "zone", + "withdraw", + "--zone-id", + "42", + "--zone-chain-id", + "1337", + "--amount", + "1000000", + "--account", + "zone-test", + "--password-file", + "/tmp/password", + "--approve", + ], + ] { + let tempo = TempoArgs::try_parse_from(args).unwrap(); + assert!(matches!(tempo.command, TempoSubcommand::Zone(_))); + } + } + + #[test] + fn callback_limits_match_zone_protocol() { + assert!(validate_callback(&Bytes::from(vec![0; 1024]), 10_000_000).is_ok()); + assert!(validate_callback(&Bytes::from(vec![0; 1025]), 10_000_000).is_err()); + assert!(validate_callback(&Bytes::new(), 10_000_001).is_err()); + assert!(validate_callback(&Bytes::from_static(&[1]), 0).is_err()); + } + + #[test] + fn parses_settlement_wait() { + assert!( + TempoArgs::try_parse_from([ + "tempo", + "zone", + "withdraw", + "--zone-id", + "42", + "--zone-chain-id", + "1337", + "--amount", + "1", + "--wait-l1", + ]) + .is_ok() + ); + } +} diff --git a/crates/cast/src/cmd/tempo_policy_args.rs b/crates/cast/src/cmd/tempo_policy_args.rs new file mode 100644 index 0000000000000..9a20bca3206c4 --- /dev/null +++ b/crates/cast/src/cmd/tempo_policy_args.rs @@ -0,0 +1,281 @@ +use alloy_primitives::{Address, hex}; +use foundry_common::abi::get_func; +use tempo_contracts::precompiles::IAccountKeychain::{CallScope, SelectorRule}; + +// Shared Tempo policy flag grammar used by both `cast keychain` and +// `cast wallet session`. Keeping it here avoids duplicating parsing behavior +// or making wallet-session commands depend on the larger keychain command module. + +/// Parse a selector string into 4-byte selector bytes. +/// +/// Accepts 4-byte hex (`0xd09de08a`), a full signature +/// (`transfer(address,uint256)`), or a well-known TIP-20 shorthand. +pub(crate) fn parse_selector_bytes(s: &str) -> Result<[u8; 4], String> { + let s = s.trim(); + if s.starts_with("0x") || s.starts_with("0X") { + let hex_str = &s[2..]; + if hex_str.len() != 8 { + return Err(format!("hex selector must be 4 bytes (8 hex chars), got: {s}")); + } + let bytes = hex::decode(hex_str).map_err(|e| format!("invalid hex selector '{s}': {e}"))?; + let mut arr = [0u8; 4]; + arr.copy_from_slice(&bytes); + Ok(arr) + } else { + let sig = if s.contains('(') || s.contains(')') { + s.to_string() + } else { + match s { + "transfer" => "transfer(address,uint256)".to_string(), + "approve" => "approve(address,uint256)".to_string(), + "transferFrom" => "transferFrom(address,address,uint256)".to_string(), + "transferWithMemo" => "transferWithMemo(address,uint256,bytes32)".to_string(), + "transferFromWithMemo" => { + "transferFromWithMemo(address,address,uint256,bytes32)".to_string() + } + _ => format!("{s}()"), + } + }; + get_func(&sig) + .map(|func| func.selector().into()) + .map_err(|e| format!("invalid function signature '{sig}': {e}")) + } +} + +/// Parse a `TARGET[:SELECTORS[@RECIPIENTS]]` scope string. +pub(crate) fn parse_scope(s: &str) -> Result { + let (target_str, selectors_str) = + s.split_once(':').map_or((s, None), |(target, selectors)| (target, Some(selectors))); + + let target: Address = + target_str.parse().map_err(|e| format!("invalid target address '{target_str}': {e}"))?; + let selector_rules = selectors_str.map_or(Ok(vec![]), parse_selector_rules)?; + + Ok(CallScope { target, selectorRules: selector_rules }) +} + +fn parse_selector_rules(s: &str) -> Result, String> { + let mut rules = Vec::::new(); + + for part in split_selector_rule_parts(s) { + let part = part.trim(); + if part.is_empty() { + continue; + } + + // Require a hex prefix to distinguish recipients from hex-like function names. + if let Some(rule) = rules.last_mut() + && !rule.recipients.is_empty() + && (part.starts_with("0x") || part.starts_with("0X")) + && let Ok(addr) = part.parse::
() + { + rule.recipients.push(addr); + continue; + } + + let (selector_str, recipients_str) = part.split_once('@').unwrap_or((part, "")); + let selector = parse_selector_bytes(selector_str)?; + let recipients = recipients_str + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|addr_str| { + addr_str + .parse::
() + .map_err(|e| format!("invalid recipient address '{addr_str}': {e}")) + }) + .collect::, _>>()?; + + rules.push(SelectorRule { selector: selector.into(), recipients }); + } + + Ok(rules) +} + +fn split_selector_rule_parts(s: &str) -> Vec<&str> { + let mut parts = Vec::new(); + let mut depth = 0usize; + let mut start = 0usize; + + for (idx, ch) in s.char_indices() { + match ch { + '(' => depth += 1, + ')' => depth = depth.saturating_sub(1), + ',' if depth == 0 => { + parts.push(&s[start..idx]); + start = idx + ch.len_utf8(); + } + _ => {} + } + } + + parts.push(&s[start..]); + parts +} + +/// Parse a period string like `10m`, `7d`, or `3600s`. +pub(crate) fn parse_period(s: &str) -> Result { + let s = s.trim(); + if s.is_empty() { + return Err("period cannot be empty".to_string()); + } + + let split = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len()); + if split == 0 { + return Err(format!( + "invalid period '{s}': expected a number followed by s, m, h, d, or w" + )); + } + + let value: u64 = + s[..split].parse().map_err(|e| format!("invalid period value '{}': {e}", &s[..split]))?; + let multiplier = match &s[split..].to_ascii_lowercase()[..] { + "" | "s" => 1, + "m" => 60, + "h" => 60 * 60, + "d" => 24 * 60 * 60, + "w" => 7 * 24 * 60 * 60, + unit => { + return Err(format!( + "invalid period unit '{unit}' in '{s}' (expected s, m, h, d, or w)" + )); + } + }; + + value.checked_mul(multiplier).ok_or_else(|| format!("period '{s}' is too large")) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::{address, keccak256}; + + fn selector(sig: &str) -> [u8; 4] { + keccak256(sig.as_bytes())[..4].try_into().unwrap() + } + + #[test] + fn parse_selector_bytes_accepts_names_hex_and_signatures() { + for (input, expected) in [ + ("transfer", selector("transfer(address,uint256)")), + ("approve", selector("approve(address,uint256)")), + ("transferWithMemo", selector("transferWithMemo(address,uint256,bytes32)")), + ("increment()", selector("increment()")), + ("transfer(address,uint256)", selector("transfer(address,uint256)")), + ("0xaabbccdd", [0xaa, 0xbb, 0xcc, 0xdd]), + ("0xd09de08a", [0xd0, 0x9d, 0xe0, 0x8a]), + ] { + assert_eq!(parse_selector_bytes(input).unwrap(), expected, "{input}"); + } + for input in + ["0xaabb", "0xaabbccddee", "0xzzzzzzzz", "", "transfer(address,uint256", "transfer)"] + { + assert!(parse_selector_bytes(input).is_err(), "{input}"); + } + } + + #[test] + fn parse_scope_variants() { + let target = address!("0x86A2EE8FAf9A840F7a2c64CA3d51209F9A02081D"); + let recipient = Address::repeat_byte(0x11); + let recipient2 = Address::repeat_byte(0x22); + // (input, expected selectors, expected recipients per rule) + let cases = [ + ("0x86A2EE8FAf9A840F7a2c64CA3d51209F9A02081D", vec![]), + ( + "0x86A2EE8FAf9A840F7a2c64CA3d51209F9A02081D:0xaabbccdd", + vec![([0xaa, 0xbb, 0xcc, 0xdd], vec![])], + ), + ( + "0x86A2EE8FAf9A840F7a2c64CA3d51209F9A02081D:transfer,approve", + vec![ + (selector("transfer(address,uint256)"), vec![]), + (selector("approve(address,uint256)"), vec![]), + ], + ), + ( + "0x86A2EE8FAf9A840F7a2c64CA3d51209F9A02081D:transfer@0x1111111111111111111111111111111111111111", + vec![(selector("transfer(address,uint256)"), vec![recipient])], + ), + ( + "0x86A2EE8FAf9A840F7a2c64CA3d51209F9A02081D:0xaabbccdd@0x1111111111111111111111111111111111111111", + vec![([0xaa, 0xbb, 0xcc, 0xdd], vec![recipient])], + ), + ( + // Multiple recipients for one rule. + "0x86A2EE8FAf9A840F7a2c64CA3d51209F9A02081D:transfer@0x1111111111111111111111111111111111111111,0x2222222222222222222222222222222222222222", + vec![(selector("transfer(address,uint256)"), vec![recipient, recipient2])], + ), + ( + // An unrestricted rule ends the previous recipient list. + "0x86A2EE8FAf9A840F7a2c64CA3d51209F9A02081D:transfer@0x1111111111111111111111111111111111111111,0x2222222222222222222222222222222222222222,approve", + vec![ + (selector("transfer(address,uint256)"), vec![recipient, recipient2]), + (selector("approve(address,uint256)"), vec![]), + ], + ), + ( + // Recipient lists stay local to their rules. + "0x86A2EE8FAf9A840F7a2c64CA3d51209F9A02081D:transfer@0x1111111111111111111111111111111111111111,approve@0x2222222222222222222222222222222222222222", + vec![ + (selector("transfer(address,uint256)"), vec![recipient]), + (selector("approve(address,uint256)"), vec![recipient2]), + ], + ), + ]; + for (input, expected) in cases { + let scope = parse_scope(input).unwrap(); + assert_eq!(scope.target, target, "{input}"); + let rules: Vec<_> = + scope.selectorRules.iter().map(|r| (r.selector.0, r.recipients.clone())).collect(); + assert_eq!(rules, expected, "{input}"); + } + } + + #[test] + fn parse_scope_hex_like_function_name_starts_a_new_rule() { + let scope = parse_scope( + "0x20c0000000000000000000000000000000000001:transfer@0x1111111111111111111111111111111111111111,aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ) + .unwrap(); + assert_eq!(scope.selectorRules.len(), 2); + assert_eq!(scope.selectorRules[0].recipients.len(), 1); + assert_eq!( + scope.selectorRules[1].selector.0, + selector("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa()") + ); + assert!(scope.selectorRules[1].recipients.is_empty()); + } + + #[test] + fn parse_scope_bogus_selector_after_unrestricted_rule_still_errors() { + // A bare address cannot continue a rule without an @ clause. + let err = parse_scope( + "0x20c0000000000000000000000000000000000001:transfer,0x2222222222222222222222222222222222222222", + ) + .unwrap_err(); + assert!(err.contains("hex selector must be 4 bytes"), "{err}"); + } + + #[test] + fn parse_scope_full_signatures_split_outside_parentheses() { + let scope = parse_scope( + "0x20c0000000000000000000000000000000000001:transfer(address,uint256),approve(address,uint256)", + ) + .unwrap(); + assert_eq!(scope.selectorRules.len(), 2); + assert_eq!(scope.selectorRules[0].selector.0, keccak256(b"transfer(address,uint256)")[..4]); + assert_eq!(scope.selectorRules[1].selector.0, keccak256(b"approve(address,uint256)")[..4]); + } + + #[test] + fn parse_period_units() { + assert_eq!(parse_period("0").unwrap(), 0); + assert_eq!(parse_period("30s").unwrap(), 30); + assert_eq!(parse_period("5m").unwrap(), 300); + assert_eq!(parse_period("2h").unwrap(), 7200); + assert_eq!(parse_period("7d").unwrap(), 604800); + assert_eq!(parse_period("2w").unwrap(), 1209600); + assert!(parse_period("1mo").is_err()); + } +} diff --git a/crates/cast/src/cmd/tip20/create.rs b/crates/cast/src/cmd/tip20/create.rs new file mode 100644 index 0000000000000..d84f3ae7ff0a0 --- /dev/null +++ b/crates/cast/src/cmd/tip20/create.rs @@ -0,0 +1,111 @@ +use crate::{ + cmd::confirm_continue, + tempo::tempo_provider, + tx::{SendTxOpts, TxParams}, +}; +use alloy_ens::NameOrAddress; +use alloy_network::TransactionBuilder; +use alloy_primitives::{B256, Bytes}; +use alloy_provider::Provider; +use alloy_rpc_types::TransactionInputKind; +use alloy_sol_types::{SolCall, SolError}; +use eyre::Result; +use tempo_alloy::{TempoNetwork, rpc::TempoTransactionRequest}; +use tempo_contracts::precompiles::{ + TIP20_FACTORY_ADDRESS, UnknownFunctionSelector, createTokenCall, createTokenWithLogoCall, + is_iso4217_currency, +}; + +/// Returns a warning message for non-ISO 4217 currency codes used in TIP-20 token creation. +pub(crate) fn iso4217_warning_message(currency: &str) -> String { + let hyperlink = |url: &str| format!("\x1b]8;;{url}\x1b\\{url}\x1b]8;;\x1b\\"); + let tip20_docs = hyperlink("https://docs.tempo.xyz/protocol/tip20/overview"); + let iso_docs = hyperlink("https://www.iso.org/iso-4217-currency-codes.html"); + + format!( + "\"{currency}\" is not a recognized ISO 4217 currency code.\n\ + \n\ + If the token you are trying to deploy is a fiat-backed stablecoin, Tempo strongly\n\ + recommends that the currency code field be the ISO-4217 currency code of the fiat\n\ + currency your token tracks (e.g. \"USD\", \"EUR\", \"GBP\").\n\ + \n\ + The currency field is IMMUTABLE after token creation and affects fee payment\n\ + eligibility, DEX routing, and quote token pairing. Only \"USD\"-denominated tokens\n\ + can be used to pay transaction fees on Tempo.\n\ + \n\ + Learn more:\n \ + - Tempo TIP-20 docs: {tip20_docs}\n \ + - ISO 4217 standard: {iso_docs}" + ) +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn run( + name: String, + symbol: String, + currency: String, + quote_token: NameOrAddress, + admin: NameOrAddress, + salt: B256, + logo_uri: Option, + force: bool, + send_tx: SendTxOpts, + tx_opts: TxParams, +) -> Result<()> { + if let Some(logo_uri) = logo_uri.as_deref() { + super::logo::validate_logo_uri(logo_uri)?; + } + + if !is_iso4217_currency(¤cy) && !force { + sh_warn!("{}", iso4217_warning_message(¤cy))?; + if !confirm_continue()? { + return Ok(()); + } + } + + let (_, provider) = tempo_provider(&send_tx.eth.rpc)?; + let quote_token = quote_token.resolve(&provider).await?; + let admin = admin.resolve(&provider).await?; + + let data = match logo_uri { + Some(logo_uri) => { + let call = createTokenWithLogoCall { + name, + symbol, + currency, + quoteToken: quote_token, + admin, + salt, + logoURI: logo_uri, + }; + ensure_t5_create_logo_supported(&provider, &call).await?; + call.abi_encode() + } + None => createTokenCall { name, symbol, currency, quoteToken: quote_token, admin, salt } + .abi_encode(), + }; + super::send_tip20_transaction(TIP20_FACTORY_ADDRESS, data, send_tx, tx_opts).await +} + +/// Fails early when the factory rejects the 7-arg `createToken` selector, which only T5+ +/// factories implement. +async fn ensure_t5_create_logo_supported>( + provider: &P, + call: &createTokenWithLogoCall, +) -> Result<()> { + let mut tx = TempoTransactionRequest::default(); + tx.set_kind(TIP20_FACTORY_ADDRESS.into()); + tx.set_input_kind(call.abi_encode(), TransactionInputKind::Both); + + let unknown_selector = + UnknownFunctionSelector { selector: createTokenWithLogoCall::SELECTOR.into() }.abi_encode(); + if let Err(err) = provider.call(tx).await + && let Some(data) = err.as_error_resp().and_then(|resp| resp.data.as_ref()) + && serde_json::from_str::(data.get()).is_ok_and(|data| data == unknown_selector) + { + eyre::bail!( + "--logo-uri requires a T5-compatible TIP20Factory; the configured RPC rejected the 7-arg createToken selector 0x5323d222" + ); + } + Ok(()) +} diff --git a/crates/cast/src/cmd/tip20/logo.rs b/crates/cast/src/cmd/tip20/logo.rs new file mode 100644 index 0000000000000..c9d85f181dcd5 --- /dev/null +++ b/crates/cast/src/cmd/tip20/logo.rs @@ -0,0 +1,40 @@ +use crate::{ + tempo::tempo_provider, + tx::{SendTxOpts, TxParams}, +}; +use alloy_ens::NameOrAddress; +use alloy_sol_types::SolCall; +use eyre::Result; +use foundry_common::tempo::{Tip20LogoUriValidationError, validate_tip20_logo_uri}; +use tempo_contracts::precompiles::ITIP20; + +pub(super) fn check(logo_uri: &str) -> Result<()> { + validate_logo_uri(logo_uri)?; + sh_println!("Valid TIP-20 logo URI") +} + +pub(super) async fn set( + token: NameOrAddress, + logo_uri: String, + send_tx: SendTxOpts, + tx_opts: TxParams, +) -> Result<()> { + validate_logo_uri(&logo_uri)?; + let (_, provider) = tempo_provider(&send_tx.eth.rpc)?; + let token = token.resolve(&provider).await?; + let data = ITIP20::setLogoURICall { newLogoURI: logo_uri }.abi_encode(); + super::send_tip20_transaction(token, data, send_tx, tx_opts).await +} + +pub(super) fn validate_logo_uri(logo_uri: &str) -> Result<()> { + validate_tip20_logo_uri(logo_uri).map_err(|err| match err { + Tip20LogoUriValidationError::LogoURITooLong => { + eyre::eyre!("client-side validation failed: LogoURITooLong: logo URI exceeds 256 bytes") + } + Tip20LogoUriValidationError::InvalidLogoURI => { + eyre::eyre!( + "client-side validation failed: InvalidLogoURI: logo URI must use one of: https, http, ipfs, data" + ) + } + }) +} diff --git a/crates/cast/src/cmd/tip20/mine.rs b/crates/cast/src/cmd/tip20/mine.rs new file mode 100644 index 0000000000000..b2cef0b2ac5bf --- /dev/null +++ b/crates/cast/src/cmd/tip20/mine.rs @@ -0,0 +1,294 @@ +use crate::{ + cmd::send::cast_send_raw, + tempo, + tx::{CastTxSender, SendTxOpts, TxParams, apply_poll_interval, fill_transaction_gas_fees}, +}; +use alloy_network::EthereumWallet; +use alloy_primitives::{Address, B256, keccak256}; +use alloy_signer::Signer; +use eyre::Result; +use foundry_cli::utils::get_chain; +use foundry_common::{FoundryTransactionBuilder, provider::ProviderBuilder}; +use foundry_evm::hardfork::TempoHardfork; +use rand::{RngCore, SeedableRng, rngs::StdRng}; +use std::time::{Duration, Instant}; +use tempo_alloy::{ + TempoNetwork, + contracts::precompiles::{ADDRESS_REGISTRY_ADDRESS, IAddressRegistry}, +}; +use tempo_primitives::{MasterId, TempoAddressExt, UserTag}; + +/// Number of leading zero bytes a TIP-1022 registration hash must have. +pub(crate) const POW_BYTES: usize = 4; + +pub(crate) struct Output { + pub(crate) salt: B256, + pub(crate) registration_hash: B256, + pub(crate) master_id: MasterId, + pub(crate) zero_tag_virtual_address: Address, +} + +impl Output { + fn new(salt: B256, registration_hash: B256) -> Self { + let master_id = MasterId::from_slice(®istration_hash[4..8]); + let zero_tag_virtual_address = Address::new_virtual(master_id, UserTag::ZERO); + Self { salt, registration_hash, master_id, zero_tag_virtual_address } + } +} + +/// Command-specific wording for [`register_virtual_master`]. +pub(crate) struct RegisterMessages { + pub(crate) no_signer: &'static str, + /// Prefix of the sender-mismatch error, completed with the salt owner and actual sender. + pub(crate) mismatch: &'static str, + /// Suffix of the `Submitting registerVirtualMaster(..)` status line. + pub(crate) submitting: &'static str, +} + +pub(super) fn run( + master: Address, + salt: Option, + threads: Option, + seed: Option, + no_random: bool, +) -> Result { + let (output, elapsed) = find_salt(master, salt, threads, seed, no_random, false, "master")?; + let header = elapsed.map(|elapsed| format!("Found salt in {elapsed:?}\n")).unwrap_or_default(); + sh_println!( + r#"{header}Salt: {} +Registration hash: {} +Master ID: {} +Zero-tag address: {}"#, + output.salt, + output.registration_hash, + output.master_id, + output.zero_tag_virtual_address, + )?; + Ok(output) +} + +/// Validates `master` and returns the registration to submit: `salt` after checking its proof of +/// work, or a freshly mined salt together with the time it took to find it. +pub(crate) fn find_salt( + master: Address, + salt: Option, + threads: Option, + seed: Option, + no_random: bool, + quiet: bool, + label: &str, +) -> Result<(Output, Option)> { + if !master.is_valid_master() { + eyre::bail!( + "invalid {label} address {master}; see https://docs.tempo.xyz/protocol/tips/tip-1022" + ); + } + + if let Some(salt) = salt { + let output = derive(master, salt); + if !has_pow(&output.registration_hash, POW_BYTES) { + eyre::bail!( + "provided salt does not satisfy TIP-1022 proof of work: {}", + output.registration_hash + ); + } + return Ok((output, None)); + } + + let n_threads = match threads { + Some(n) if n > 0 => n, + _ => std::thread::available_parallelism().map_or(1, |n| n.get()), + }; + let mut salt = B256::ZERO; + if !no_random { + let mut rng = match seed { + Some(seed) => StdRng::from_seed(seed.0), + None => StdRng::from_os_rng(), + }; + rng.fill_bytes(&mut salt[..]); + } + + if !quiet { + sh_status!("Mining TIP-1022 salt for {master} with {n_threads} threads...")?; + } + let timer = Instant::now(); + let output = mine(master, salt, n_threads, POW_BYTES)?; + Ok((output, Some(timer.elapsed()))) +} + +/// Submits `registerVirtualMaster(salt)` from `master` and returns the transaction hash. +/// +/// With `quiet`, neither the transaction hash nor the receipt is printed, but the receipt is still +/// awaited unless `--async` was passed. +pub(crate) async fn register_virtual_master( + master: Address, + salt: B256, + send_tx: SendTxOpts, + mut tx_opts: TxParams, + quiet: bool, + msgs: &RegisterMessages, +) -> Result { + let (config, provider) = tempo::tempo_provider(&send_tx.eth.rpc)?; + tempo::ensure_tempo_precompile_active( + &provider, + TempoHardfork::T3, + ADDRESS_REGISTRY_ADDRESS, + "virtual master registration requires a Tempo T3-capable AddressRegistry RPC", + ) + .await?; + apply_poll_interval(&provider, send_tx.poll_interval); + let chain = get_chain(config.chain, &provider).await?; + tempo::ensure_session_not_browser(&tx_opts.tempo, send_tx.browser.browser)?; + let (signer, access_key) = + tempo::resolve_session_or_wallet_signer(&tx_opts.tempo, &send_tx.eth.wallet, chain.id()) + .await?; + let sender = match (&access_key, &signer) { + (Some(wallet), _) => wallet.account(), + (None, Some(signer)) => signer.address(), + (None, None) => eyre::bail!("{}", msgs.no_signer), + }; + if sender != master { + eyre::bail!( + "{} {master}, but the configured signer would register as {sender}", + msgs.mismatch + ); + } + + let mut tx = IAddressRegistry::new(ADDRESS_REGISTRY_ADDRESS, &provider) + .registerVirtualMaster(salt) + .into_transaction_request(); + tempo::print_expires(tx_opts.tempo.resolve_expires())?; + tx_opts.apply::(&mut tx, chain.is_legacy()); + sh_status!("Submitting registerVirtualMaster({salt}){}", msgs.submitting)?; + + let timeout = send_tx.timeout.unwrap_or(config.transaction_timeout); + let fee_provider = (!config.eth_rpc_curl).then_some(&provider); + let (tx_hash, receipt) = if let Some(access_key) = &access_key { + let prepared = tempo::fill_access_key_transaction( + &provider, + &mut tx, + access_key, + chain, + config.eip1559_fee_estimate, + ) + .await?; + tempo::resolve_and_print_fee_token(fee_provider, &mut tx, Some(sender)).await?; + let raw_tx = tx.sign_with_tempo_wallet(&prepared).await?; + cast_send_raw(&provider, &raw_tx, send_tx.sync).await? + } else { + // The wallet provider fills the nonce and gas limit; only the fees are filled here. + let wallet = EthereumWallet::from(signer.expect("sender was derived from the signer")); + let signer_provider = + ProviderBuilder::::from_config(&config)?.build_with_wallet(wallet)?; + fill_transaction_gas_fees( + &signer_provider, + &mut tx, + chain.is_legacy(), + false, + config.eip1559_fee_estimate, + ) + .await?; + tempo::resolve_and_print_fee_token(fee_provider, &mut tx, Some(sender)).await?; + let cast = CastTxSender::new(&signer_provider); + if send_tx.sync { + let (tx_hash, receipt) = cast.send_sync(tx).await?; + (tx_hash, Some(receipt)) + } else { + (*cast.send(tx).await?.inner().tx_hash(), None) + } + }; + + let cast = CastTxSender::new(&provider); + match receipt { + Some(receipt) if !quiet => sh_println!("{receipt}")?, + Some(_) => {} + None if quiet => { + if !send_tx.cast_async { + let hash = format!("{tx_hash:#x}"); + cast.receipt(hash, None, send_tx.confirmations, Some(timeout), false).await?; + } + } + None => { + cast.print_tx_result(tx_hash, send_tx.cast_async, send_tx.confirmations, timeout) + .await? + } + } + Ok(tx_hash) +} + +pub(crate) fn mine( + master: Address, + salt: B256, + n_threads: usize, + pow_bytes: usize, +) -> Result { + let mut packed = [0u8; 52]; + packed[..20].copy_from_slice(master.as_slice()); + + crate::cmd::miner::mine_salt(salt, n_threads, move |salt| { + packed[20..].copy_from_slice(salt.as_slice()); + let registration_hash = keccak256(packed); + has_pow(®istration_hash, pow_bytes).then(|| Output::new(salt, registration_hash)) + }) + .ok_or_else(|| eyre::eyre!("virtual master mining failed: all threads panicked")) +} + +pub(crate) fn derive(master: Address, salt: B256) -> Output { + let mut packed = [0u8; 52]; + packed[..20].copy_from_slice(master.as_slice()); + packed[20..].copy_from_slice(salt.as_slice()); + Output::new(salt, keccak256(packed)) +} + +pub(crate) fn has_pow(registration_hash: &B256, pow_bytes: usize) -> bool { + registration_hash[..pow_bytes].iter().all(|byte| *byte == 0) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::{address, b256}; + + #[test] + fn derives_master_id_and_zero_tag_address() { + let master = address!("0x1234567890123456789012345678901234567890"); + let salt = B256::with_last_byte(1); + let output = derive(master, salt); + + assert_eq!( + output.registration_hash, + b256!("0x661db5481211842e0330ea3e4cf0b4e7e5abd2314161ce16e9a99e7460480f21"), + ); + assert_eq!(output.master_id, MasterId::from([0x12, 0x11, 0x84, 0x2e])); + assert_eq!( + output.zero_tag_virtual_address, + address!("0x1211842efdfdfdfdfdfdfdfdfdfd000000000000"), + ); + } + + #[test] + fn mines_pow_with_reduced_difficulty() -> Result<()> { + let master = address!("0x1234567890123456789012345678901234567890"); + let output = mine(master, B256::ZERO, 1, 1)?; + + assert_eq!( + output.salt, + b256!("0x000000000000000000000000000000000000000000000000f301000000000000"), + ); + assert_eq!(output.registration_hash[0], 0); + assert_eq!(output.master_id, MasterId::from_slice(&output.registration_hash[4..8])); + Ok(()) + } + + #[test] + fn has_pow_checks_leading_zero_bytes() { + let mut hash = B256::ZERO; + assert!(has_pow(&hash, 4)); + assert!(has_pow(&hash, 0)); + + hash[3] = 1; + assert!(!has_pow(&hash, 4)); + assert!(has_pow(&hash, 3)); + assert!(has_pow(&hash, 0)); + } +} diff --git a/crates/cast/src/cmd/tip20/mod.rs b/crates/cast/src/cmd/tip20/mod.rs new file mode 100644 index 0000000000000..014ff30662332 --- /dev/null +++ b/crates/cast/src/cmd/tip20/mod.rs @@ -0,0 +1,275 @@ +use crate::{ + cmd::send::SendTxArgs, + tempo, + tx::{SendTxOpts, TxParams}, +}; +use alloy_ens::NameOrAddress; +use alloy_primitives::{Address, B256}; +use clap::Parser; +use eyre::Result; +use foundry_cli::{opts::RpcOpts, utils::get_chain}; +use foundry_wallets::{TempoAccountsWallet, WalletSigner}; +use std::str::FromStr; +use tempo_alloy::TempoNetwork; + +mod create; +pub(crate) use create::iso4217_warning_message; +pub(crate) mod logo; +pub(crate) mod mine; +mod role; +pub use role::Tip20Role; + +const MINE_REGISTER_MESSAGES: mine::RegisterMessages = mine::RegisterMessages { + no_signer: "--register requires a signer or Tempo keychain identity (for example --private-key or --from)", + mismatch: "registration sender mismatch: mined salt is for", + submitting: " on Tempo...", +}; + +/// TIP-20 token operations (Tempo). +#[derive(Debug, Parser, Clone)] +pub enum Tip20Subcommand { + /// Create a new TIP-20 token via the TIP20Factory. + #[command(visible_alias = "c")] + Create { + /// The token name (e.g. "US Dollar Coin"). + name: String, + + /// The token symbol (e.g. "USDC"). + symbol: String, + + /// The ISO 4217 currency code (e.g. "USD", "EUR", "GBP"). + /// This field is IMMUTABLE after creation and affects fee payment + /// eligibility, DEX routing, and quote token pairing. + currency: String, + + /// The TIP-20 quote token address used for exchange pricing. + #[arg(value_parser = NameOrAddress::from_str)] + quote_token: NameOrAddress, + + /// The admin address to receive DEFAULT_ADMIN_ROLE on the new token. + #[arg(value_parser = NameOrAddress::from_str)] + admin: NameOrAddress, + + /// A unique salt for deterministic address derivation (hex-encoded bytes32). + salt: B256, + + /// Optional T5 logo URI for the token. + #[arg(long, value_name = "URI")] + logo_uri: Option, + + /// Skip the ISO 4217 currency code validation warning. + #[arg(long)] + force: bool, + + #[command(flatten)] + send_tx: SendTxOpts, + + #[command(flatten)] + tx: TxParams, + }, + + /// Validate a TIP-20 logo URI offline against Tempo T5 constraints. + LogoCheck { + /// The logo URI to validate. Empty string is valid. + #[arg(value_name = "URI")] + logo_uri: String, + }, + + /// Update a TIP-20 token logo URI. + LogoSet { + /// The TIP-20 token contract address. + #[arg(value_parser = NameOrAddress::from_str)] + token: NameOrAddress, + + /// The new logo URI. Empty string clears the on-chain value. + #[arg(value_name = "URI")] + logo_uri: String, + + #[command(flatten)] + send_tx: SendTxOpts, + + #[command(flatten)] + tx: TxParams, + }, + + /// Mine a TIP-1022 salt for virtual address' master registration on Tempo. + #[command(visible_alias = "m")] + Mine { + /// Address that will call `registerVirtualMaster(bytes32)`. + #[arg(value_name = "ADDRESS")] + master: Address, + + /// Salt to validate directly instead of mining one. + #[arg(long, conflicts_with_all = ["seed", "no_random"], value_name = "HEX")] + salt: Option, + + /// Number of threads to use. Specifying 0 defaults to the number of logical cores. + #[arg(global = true, long, short = 'j', visible_alias = "jobs")] + threads: Option, + + /// The random number generator's seed, used to initialize the salt search. + #[arg(long, value_name = "HEX")] + seed: Option, + + /// Don't initialize the salt with a random value, and instead use the default value of 0. + #[arg(long, conflicts_with = "seed")] + no_random: bool, + + /// Submit `registerVirtualMaster(bytes32)` on Tempo after finding or validating the salt. + #[arg(long, conflicts_with_all = ["seed", "no_random"])] + register: bool, + + #[command(flatten)] + send_tx: SendTxOpts, + + #[command(flatten)] + tx: TxParams, + }, + + /// Grant a TIP-20 role to an account. + /// + /// The sender must hold the role's admin role, which is `DEFAULT_ADMIN_ROLE` unless it was + /// reconfigured. A newly created token only assigns `DEFAULT_ADMIN_ROLE` to its admin, so + /// minting requires granting `issuer` first. + GrantRole { + /// The TIP-20 token contract address. + #[arg(value_parser = NameOrAddress::from_str)] + token: NameOrAddress, + + /// The role to grant: `admin`, `issuer`, `pause`, `unpause`, `burn-blocked`, `burn-at` + /// (T12+), or a 32-byte role hash. + role: Tip20Role, + + /// The account that receives the role. + #[arg(value_parser = NameOrAddress::from_str)] + account: NameOrAddress, + + #[command(flatten)] + send_tx: SendTxOpts, + + #[command(flatten)] + tx: TxParams, + }, + + /// Revoke a TIP-20 role from an account. + /// + /// The sender must hold the role's admin role, which is `DEFAULT_ADMIN_ROLE` unless it was + /// reconfigured. + RevokeRole { + /// The TIP-20 token contract address. + #[arg(value_parser = NameOrAddress::from_str)] + token: NameOrAddress, + + /// The role to revoke: `admin`, `issuer`, `pause`, `unpause`, `burn-blocked`, `burn-at` + /// (T12+), or a 32-byte role hash. + role: Tip20Role, + + /// The account that loses the role. + #[arg(value_parser = NameOrAddress::from_str)] + account: NameOrAddress, + + #[command(flatten)] + send_tx: SendTxOpts, + + #[command(flatten)] + tx: TxParams, + }, + + /// Check whether an account holds a TIP-20 role. + HasRole { + /// The TIP-20 token contract address. + #[arg(value_parser = NameOrAddress::from_str)] + token: NameOrAddress, + + /// The role to check: `admin`, `issuer`, `pause`, `unpause`, `burn-blocked`, `burn-at` + /// (T12+), or a 32-byte role hash. + role: Tip20Role, + + /// The account to check. + #[arg(value_parser = NameOrAddress::from_str)] + account: NameOrAddress, + + #[command(flatten)] + rpc: RpcOpts, + }, +} + +impl Tip20Subcommand { + pub async fn run(self) -> Result<()> { + match self { + Self::Create { + name, + symbol, + currency, + quote_token, + admin, + salt, + logo_uri, + force, + send_tx, + tx, + } => { + create::run( + name, + symbol, + currency, + quote_token, + admin, + salt, + logo_uri, + force, + send_tx, + tx, + ) + .await + } + Self::LogoCheck { logo_uri } => logo::check(&logo_uri), + Self::LogoSet { token, logo_uri, send_tx, tx } => { + logo::set(token, logo_uri, send_tx, tx).await + } + Self::Mine { master, salt, threads, seed, no_random, register, send_tx, tx } => { + let output = mine::run(master, salt, threads, seed, no_random)?; + if register { + let msgs = &MINE_REGISTER_MESSAGES; + mine::register_virtual_master(master, output.salt, send_tx, tx, false, msgs) + .await?; + } + Ok(()) + } + Self::GrantRole { token, role, account, send_tx, tx } => { + role::update(role::RoleUpdate::Grant, token, role, account, send_tx, tx).await + } + Self::RevokeRole { token, role, account, send_tx, tx } => { + role::update(role::RoleUpdate::Revoke, token, role, account, send_tx, tx).await + } + Self::HasRole { token, role, account, rpc } => { + role::has_role(token, role, account, rpc).await + } + } + } +} + +/// Sends pre-encoded `data` to the Tempo contract at `to` through the `cast send` flow, signing +/// with the selected Tempo session, access key, or wallet. +pub(crate) async fn send_tip20_transaction( + to: Address, + data: Vec, + send_tx: SendTxOpts, + tx: TxParams, +) -> Result<()> { + let (signer, access_key) = resolve_tip20_signer(&send_tx, &tx).await?; + SendTxArgs::contract_call(NameOrAddress::Address(to), data, send_tx, tx) + .run_generic::(signer, access_key) + .await +} + +/// Resolves the Tempo session, access key, or wallet that signs a `cast send` style transaction. +pub(crate) async fn resolve_tip20_signer( + send_tx: &SendTxOpts, + tx: &TxParams, +) -> Result<(Option, Option)> { + tempo::ensure_session_not_browser(&tx.tempo, send_tx.browser.browser)?; + let (config, provider) = tempo::tempo_provider(&send_tx.eth.rpc)?; + let chain = get_chain(config.chain, &provider).await?; + tempo::resolve_session_or_wallet_signer(&tx.tempo, &send_tx.eth.wallet, chain.id()).await +} diff --git a/crates/cast/src/cmd/tip20/role.rs b/crates/cast/src/cmd/tip20/role.rs new file mode 100644 index 0000000000000..cd2dafa97f7aa --- /dev/null +++ b/crates/cast/src/cmd/tip20/role.rs @@ -0,0 +1,202 @@ +use crate::{ + cmd::send::SendTxArgs, + tempo::{print_payload, tempo_provider}, + tx::{SendTxOpts, TxParams}, +}; +use alloy_ens::NameOrAddress; +use alloy_primitives::{Address, B256, keccak256}; +use alloy_provider::Provider; +use alloy_signer::Signer; +use alloy_sol_types::SolCall; +use eyre::{Result, WrapErr}; +use foundry_cli::opts::RpcOpts; +use serde_json::json; +use std::{fmt, str::FromStr}; +use tempo_alloy::TempoNetwork; +use tempo_contracts::precompiles::IRolesAuth; + +/// Name of the root admin role, whose identifier is the zero hash. +const DEFAULT_ADMIN_ROLE: &str = "DEFAULT_ADMIN_ROLE"; + +/// Names of the TIP-20 roles whose identifier is the keccak256 hash of the name. +const HASHED_ROLES: [&str; 5] = + ["ISSUER_ROLE", "PAUSE_ROLE", "UNPAUSE_ROLE", "BURN_BLOCKED_ROLE", "BURN_AT_ROLE"]; + +/// A TIP-20 role identifier, parsed from a role name or a raw 32-byte hash. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Tip20Role(B256); + +impl Tip20Role { + /// Returns the canonical name of a role defined by the TIP-20 precompile. + fn name(&self) -> Option<&'static str> { + if self.0.is_zero() { + return Some(DEFAULT_ADMIN_ROLE); + } + HASHED_ROLES.into_iter().find(|name| keccak256(name) == self.0) + } + + /// Returns the role name, or its hash for roles the TIP-20 precompile does not define. + fn label(&self) -> String { + self.name().map_or_else(|| self.0.to_string(), str::to_string) + } +} + +impl FromStr for Tip20Role { + type Err = String; + + fn from_str(s: &str) -> Result { + if let Ok(hash) = s.parse() { + return Ok(Self(hash)); + } + + // Accept `issuer`, `burn-at`, `BURN_AT_ROLE`, and similar spellings. + let mut name = s.to_ascii_uppercase().replace('-', "_"); + if !name.ends_with("_ROLE") { + name.push_str("_ROLE"); + } + if name == DEFAULT_ADMIN_ROLE || name == "ADMIN_ROLE" { + return Ok(Self(B256::ZERO)); + } + if HASHED_ROLES.contains(&name.as_str()) { + return Ok(Self(keccak256(&name))); + } + Err(format!( + "unknown TIP-20 role `{s}`; expected one of admin, issuer, pause, unpause, \ + burn-blocked, burn-at, or a 32-byte role hash" + )) + } +} + +impl fmt::Display for Tip20Role { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.name() { + Some(name) => write!(f, "{name} ({})", self.0), + None => write!(f, "{}", self.0), + } + } +} + +/// Whether a role membership update grants or revokes the role. +#[derive(Clone, Copy, Debug)] +pub(super) enum RoleUpdate { + Grant, + Revoke, +} + +/// Grants or revokes `role` for `account` on `token`. +pub(super) async fn update( + update: RoleUpdate, + token: NameOrAddress, + role: Tip20Role, + account: NameOrAddress, + send_tx: SendTxOpts, + tx: TxParams, +) -> Result<()> { + let (_, provider) = tempo_provider(&send_tx.eth.rpc)?; + let token = token.resolve(&provider).await?; + let account = account.resolve(&provider).await?; + + let (signer, access_key) = super::resolve_tip20_signer(&send_tx, &tx).await?; + // The sender is only known up front for local signers and access keys. Browser wallets skip + // the check, which the precompile still enforces on-chain. + let sender = match (&access_key, &signer) { + (Some(wallet), _) => Some(wallet.account()), + (None, Some(signer)) => Some(signer.address()), + (None, None) => None, + }; + if let Some(sender) = sender { + ensure_role_admin(&provider, update, token, role, sender).await?; + } + + let data = match update { + RoleUpdate::Grant => IRolesAuth::grantRoleCall { role: role.0, account }.abi_encode(), + RoleUpdate::Revoke => IRolesAuth::revokeRoleCall { role: role.0, account }.abi_encode(), + }; + SendTxArgs::contract_call(NameOrAddress::Address(token), data, send_tx, tx) + .run_generic::(signer, access_key) + .await +} + +/// Prints whether `account` holds `role` on `token`. +pub(super) async fn has_role( + token: NameOrAddress, + role: Tip20Role, + account: NameOrAddress, + rpc: RpcOpts, +) -> Result<()> { + let (_, provider) = tempo_provider(&rpc)?; + let token = token.resolve(&provider).await?; + let account = account.resolve(&provider).await?; + let has_role = IRolesAuth::new(token, &provider) + .hasRole(account, role.0) + .call() + .await + .wrap_err_with(|| format!("failed to read roles of TIP-20 token {token}"))?; + + let payload = json!({ + "token": format!("{token}"), + "role": format!("{}", role.0), + "role_name": role.name(), + "account": format!("{account}"), + "has_role": has_role, + }); + print_payload(payload, |_| { + sh_println!( + "Token: {token}\n\ + Role: {role}\n\ + Account: {account}\n\ + Has role: {has_role}" + ) + }) +} + +/// Fails early when `sender` does not hold the admin role of `role`, instead of submitting a +/// transaction the precompile rejects with a bare `Unauthorized()`. +async fn ensure_role_admin>( + provider: &P, + update: RoleUpdate, + token: Address, + role: Tip20Role, + sender: Address, +) -> Result<()> { + let roles = IRolesAuth::new(token, provider); + let read_err = || format!("failed to read roles of TIP-20 token {token}"); + let admin_role = Tip20Role(roles.getRoleAdmin(role.0).call().await.wrap_err_with(read_err)?); + if !roles.hasRole(sender, admin_role.0).call().await.wrap_err_with(read_err)? { + let action = match update { + RoleUpdate::Grant => "grant", + RoleUpdate::Revoke => "revoke", + }; + eyre::bail!( + "{sender} cannot {action} {} on TIP-20 token {token}: it does not hold {}, the role's \ + admin role", + role.label(), + admin_role.label() + ); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + + use super::*; + + #[test] + fn parses_role_names_and_hashes() { + let burn_at = Tip20Role(keccak256("BURN_AT_ROLE")); + for spelling in ["burn-at", "burn_at", "BURN_AT", "BURN_AT_ROLE", "burn-at-role"] { + assert_eq!(spelling.parse(), Ok(burn_at), "{spelling}"); + } + for spelling in ["admin", "default-admin", "DEFAULT_ADMIN_ROLE"] { + assert_eq!(spelling.parse(), Ok(Tip20Role(B256::ZERO)), "{spelling}"); + } + + let custom = B256::with_last_byte(0xab); + assert_eq!(custom.to_string().parse(), Ok(Tip20Role(custom))); + assert_eq!(Tip20Role(custom).name(), None); + assert_eq!(burn_at.name(), Some("BURN_AT_ROLE")); + + assert!("minter".parse::().is_err()); + } +} diff --git a/crates/cast/src/cmd/tip403.rs b/crates/cast/src/cmd/tip403.rs new file mode 100644 index 0000000000000..394a0f580cb60 --- /dev/null +++ b/crates/cast/src/cmd/tip403.rs @@ -0,0 +1,303 @@ +use crate::{ + cmd::{rpc_provider, tip20::send_tip20_transaction}, + tempo::print_payload, + tx::{SendTxOpts, TxParams}, +}; +use alloy_ens::NameOrAddress; +use alloy_primitives::Address; +use alloy_sol_types::SolCall; +use clap::{Parser, ValueEnum}; +use eyre::Result; +use foundry_cli::opts::RpcOpts; +use serde_json::json; +use std::str::FromStr; +use tempo_contracts::precompiles::{ITIP403Registry, TIP403_REGISTRY_ADDRESS}; +use tempo_primitives::TempoAddressExt; + +/// TIP-403 policy registry operations (Tempo). +/// +/// Policies created here are referenced by ID from `cast receive-policy set` (sender policy and +/// token filter) and by TIP-20 token compliance configuration. +#[derive(Debug, Parser, Clone)] +pub enum Tip403Subcommand { + /// Create a new simple (whitelist or blacklist) policy. + Create { + /// Policy type to create. + #[arg(value_enum)] + policy_type: PolicyKind, + + /// Address authorized to modify the policy. + #[arg(long, value_parser = NameOrAddress::from_str)] + admin: NameOrAddress, + + /// Initial member(s) to seed the policy with. Can be specified multiple times. + #[arg(long = "member", value_name = "ADDRESS", value_parser = NameOrAddress::from_str)] + accounts: Vec, + + #[command(flatten)] + send_tx: SendTxOpts, + + #[command(flatten)] + tx: TxParams, + }, + + /// Add or remove an account from a whitelist policy. + Whitelist { + #[command(flatten)] + args: MembershipArgs, + }, + + /// Add or remove an account from a blacklist policy. + Blacklist { + #[command(flatten)] + args: MembershipArgs, + }, + + /// Show a policy's type and admin. + Info { + /// Policy ID to inspect. + policy_id: u64, + + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Check whether an address is authorized by a policy. + Check { + /// Policy ID to evaluate. + policy_id: u64, + + /// Address to check. + #[arg(value_parser = NameOrAddress::from_str)] + address: NameOrAddress, + + /// Role to evaluate (defaults to the transfer check). Role variants require T2+. + #[arg(long, value_enum)] + role: Option, + + #[command(flatten)] + rpc: RpcOpts, + }, +} + +#[derive(Debug, Clone, clap::Args)] +pub struct MembershipArgs { + /// Whether to add or remove the account. + #[arg(value_enum)] + pub action: MembershipAction, + + /// Policy ID to modify. + pub policy_id: u64, + + /// Account to add or remove. + #[arg(value_parser = NameOrAddress::from_str)] + pub account: NameOrAddress, + + #[command(flatten)] + pub send_tx: SendTxOpts, + + #[command(flatten)] + pub tx: TxParams, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum PolicyKind { + Whitelist, + Blacklist, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum MembershipAction { + Add, + Remove, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +pub enum PolicyRole { + Sender, + Recipient, + MintRecipient, +} + +impl Tip403Subcommand { + pub async fn run(self) -> Result<()> { + match self { + Self::Create { policy_type, admin, accounts, send_tx, tx } => { + create(policy_type, admin, accounts, send_tx, tx).await + } + Self::Whitelist { args } => modify(PolicyKind::Whitelist, args).await, + Self::Blacklist { args } => modify(PolicyKind::Blacklist, args).await, + Self::Info { policy_id, rpc } => info(policy_id, rpc).await, + Self::Check { policy_id, address, role, rpc } => { + check(policy_id, address, role, rpc).await + } + } + } +} + +async fn create( + policy_type: PolicyKind, + admin: NameOrAddress, + accounts: Vec, + send_tx: SendTxOpts, + tx: TxParams, +) -> Result<()> { + let provider = rpc_provider(&send_tx.eth.rpc)?; + let admin = admin.resolve(&provider).await?; + + let mut members = Vec::with_capacity(accounts.len()); + for account in accounts { + let account = account.resolve(&provider).await?; + warn_if_virtual(account)?; + members.push(account); + } + + // Preview the policy ID the registry would assign. This is the next counter value, so it is + // only accurate if no other policy is created before this transaction lands. + let registry = ITIP403Registry::new(TIP403_REGISTRY_ADDRESS, &provider); + let policy_type = policy_type.to_sol(); + let (expected_id, data) = if members.is_empty() { + let call = registry.createPolicy(admin, policy_type); + (call.call().await?, call.calldata().to_vec()) + } else { + let call = registry.createPolicyWithAccounts(admin, policy_type, members); + (call.call().await?, call.calldata().to_vec()) + }; + sh_status!( + "Expected policy ID: {expected_id} (only if this tx is mined before any other policy \ + creation; read the PolicyCreated event for the authoritative ID)" + )?; + + send_tip20_transaction(TIP403_REGISTRY_ADDRESS, data, send_tx, tx).await +} + +async fn modify(kind: PolicyKind, args: MembershipArgs) -> Result<()> { + let MembershipArgs { action, policy_id, account, send_tx, tx } = args; + let provider = rpc_provider(&send_tx.eth.rpc)?; + let account = account.resolve(&provider).await?; + warn_if_virtual(account)?; + + let flag = matches!(action, MembershipAction::Add); + let data = match kind { + PolicyKind::Whitelist => ITIP403Registry::modifyPolicyWhitelistCall { + policyId: policy_id, + account, + allowed: flag, + } + .abi_encode(), + PolicyKind::Blacklist => ITIP403Registry::modifyPolicyBlacklistCall { + policyId: policy_id, + account, + restricted: flag, + } + .abi_encode(), + }; + send_tip20_transaction(TIP403_REGISTRY_ADDRESS, data, send_tx, tx).await +} + +async fn info(policy_id: u64, rpc: RpcOpts) -> Result<()> { + let provider = rpc_provider(&rpc)?; + let registry = ITIP403Registry::new(TIP403_REGISTRY_ADDRESS, provider); + let builtin = match policy_id { + 0 => Some("reject-all"), + 1 => Some("allow-all"), + _ => None, + }; + + if !registry.policyExists(policy_id).call().await? { + let payload = json!({ "policy_id": policy_id, "exists": false, "builtin": builtin }); + return print_payload(payload, |_| sh_println!("Policy {policy_id} does not exist")); + } + + let data = registry.policyData(policy_id).call().await?; + let payload = json!({ + "policy_id": policy_id, + "exists": true, + "builtin": builtin, + "policy_type": policy_type_label(data.policyType), + "admin": format!("{}", data.admin), + }); + print_payload(payload, |payload| { + sh_println!( + "Policy ID: {}\n\ + Built-in: {}\n\ + Type: {}\n\ + Admin: {}", + payload["policy_id"], + payload["builtin"].as_str().unwrap_or("no"), + payload["policy_type"].as_str().unwrap_or_default(), + payload["admin"].as_str().unwrap_or_default(), + ) + }) +} + +async fn check( + policy_id: u64, + address: NameOrAddress, + role: Option, + rpc: RpcOpts, +) -> Result<()> { + let provider = rpc_provider(&rpc)?; + let address = address.resolve(&provider).await?; + let registry = ITIP403Registry::new(TIP403_REGISTRY_ADDRESS, provider); + let (role_label, authorized) = match role { + None => ("transfer", registry.isAuthorized(policy_id, address).call().await?), + Some(PolicyRole::Sender) => { + ("sender", registry.isAuthorizedSender(policy_id, address).call().await?) + } + Some(PolicyRole::Recipient) => { + ("recipient", registry.isAuthorizedRecipient(policy_id, address).call().await?) + } + Some(PolicyRole::MintRecipient) => { + ("mint-recipient", registry.isAuthorizedMintRecipient(policy_id, address).call().await?) + } + }; + + let payload = json!({ + "policy_id": policy_id, + "address": format!("{address}"), + "role": role_label, + "authorized": authorized, + }); + print_payload(payload, |payload| { + sh_println!( + "Policy ID: {}\n\ + Address: {}\n\ + Role: {}\n\ + Authorized: {}", + payload["policy_id"], + payload["address"].as_str().unwrap_or_default(), + payload["role"].as_str().unwrap_or_default(), + payload["authorized"].as_bool().unwrap_or_default(), + ) + }) +} + +/// Warn (but don't fail) on virtual members; only T3+ chains reject them on-chain. +fn warn_if_virtual(account: Address) -> Result<()> { + if account.is_virtual() { + sh_warn!( + "{account} looks like a TIP-1022 virtual address; on T3+ chains it is rejected as a \ + literal policy member. Resolve it to its master with `cast vaddr resolve {account}`." + )?; + } + Ok(()) +} + +impl PolicyKind { + const fn to_sol(self) -> ITIP403Registry::PolicyType { + match self { + Self::Whitelist => ITIP403Registry::PolicyType::WHITELIST, + Self::Blacklist => ITIP403Registry::PolicyType::BLACKLIST, + } + } +} + +pub(super) const fn policy_type_label(policy_type: ITIP403Registry::PolicyType) -> &'static str { + match policy_type { + ITIP403Registry::PolicyType::WHITELIST => "whitelist", + ITIP403Registry::PolicyType::BLACKLIST => "blacklist", + ITIP403Registry::PolicyType::COMPOUND => "compound", + _ => "unknown", + } +} diff --git a/crates/cast/src/cmd/trace.rs b/crates/cast/src/cmd/trace.rs new file mode 100644 index 0000000000000..2c7bc8d49b636 --- /dev/null +++ b/crates/cast/src/cmd/trace.rs @@ -0,0 +1,82 @@ +use crate::cmd::rpc_provider; +use alloy_consensus::Typed2718; +use alloy_network::AnyRpcTransaction; +use alloy_primitives::hex; +use alloy_provider::ext::TraceApi; +use clap::Parser; +use eyre::{Result, WrapErr}; +use foundry_cli::{opts::RpcOpts, utils::parse_json}; +use foundry_common::stdin; +use foundry_primitives::FoundryTxEnvelope; + +/// CLI arguments for `cast trace`. +#[derive(Debug, Parser)] +pub struct TraceArgs { + /// Transaction hash (for trace_transaction) or raw tx hex/JSON (for trace_rawTransaction + /// with --raw) + tx: Option, + + /// Use trace_rawTransaction instead of trace_transaction. + /// Required when passing raw transaction hex or JSON instead of a tx hash. + #[arg(long)] + raw: bool, + + /// Include the basic trace of the transaction. + #[arg(long, requires = "raw")] + trace: bool, + + /// Include the full trace of the virtual machine's state during transaction execution + #[arg(long, requires = "raw")] + vm_trace: bool, + + /// Include state changes caused by the transaction (requires --raw). + #[arg(long, requires = "raw")] + state_diff: bool, + + #[command(flatten)] + rpc: RpcOpts, +} + +impl TraceArgs { + pub async fn run(self) -> Result<()> { + let provider = rpc_provider(&self.rpc)?; + let input = stdin::unwrap_line(self.tx)?; + + let result = if self.raw { + // trace_rawTransaction: accepts raw hex OR JSON tx + let trimmed = input.trim(); + let raw_bytes = if trimmed.starts_with('{') { + let tx: AnyRpcTransaction = parse_json(trimmed)?; + FoundryTxEnvelope::encode_rpc_2718(&tx) + .wrap_err_with(|| { + format!("Cannot EIP-2718 encode transaction type 0x{:x}", tx.ty()) + })? + .to_vec() + } else { + hex::decode(trimmed)? + }; + + let mut trace_builder = provider.trace_raw_transaction(&raw_bytes); + if self.trace { + trace_builder = trace_builder.trace(); + } + if self.vm_trace { + trace_builder = trace_builder.vm_trace(); + } + if self.state_diff { + trace_builder = trace_builder.state_diff(); + } + if trace_builder.get_trace_types().is_none_or(|t| t.is_empty()) { + eyre::bail!("No trace type specified. Use --trace, --vm-trace, or --state-diff"); + } + + serde_json::to_string_pretty(&trace_builder.await?)? + } else { + // trace_transaction: use tx hash directly + serde_json::to_string_pretty(&provider.trace_transaction(input.parse()?).await?)? + }; + + sh_println!("{}", result)?; + Ok(()) + } +} diff --git a/crates/cast/src/cmd/txpool.rs b/crates/cast/src/cmd/txpool.rs index eb43e2306a791..b3e4f3621a708 100644 --- a/crates/cast/src/cmd/txpool.rs +++ b/crates/cast/src/cmd/txpool.rs @@ -1,10 +1,8 @@ +use crate::cmd::rpc_provider; use alloy_primitives::Address; use alloy_provider::ext::TxPoolApi; use clap::Parser; -use foundry_cli::{ - opts::RpcOpts, - utils::{self, LoadConfig}, -}; +use foundry_cli::{json::print_json_object, opts::RpcOpts}; /// CLI arguments for `cast tx-pool`. #[derive(Debug, Parser, Clone)] @@ -36,29 +34,20 @@ pub enum TxPoolSubcommands { impl TxPoolSubcommands { pub async fn run(self) -> eyre::Result<()> { + let args = match &self { + Self::Content { args } + | Self::ContentFrom { args, .. } + | Self::Inspect { args } + | Self::Status { args } => args, + }; + let provider = rpc_provider(args)?; match self { - Self::Content { args } => { - let config = args.load_config()?; - let provider = utils::get_provider(&config)?; - sh_println!("{:#?}", provider.txpool_content().await?)?; - } - Self::ContentFrom { from, args } => { - let config = args.load_config()?; - let provider = utils::get_provider(&config)?; - sh_println!("{:#?}", provider.txpool_content_from(from).await?)?; - } - Self::Inspect { args } => { - let config = args.load_config()?; - let provider = utils::get_provider(&config)?; - sh_println!("{:#?}", provider.txpool_inspect().await?)?; + Self::Content { .. } => print_json_object(provider.txpool_content().await?), + Self::ContentFrom { from, .. } => { + print_json_object(provider.txpool_content_from(from).await?) } - Self::Status { args } => { - let config = args.load_config()?; - let provider = utils::get_provider(&config)?; - sh_println!("{:#?}", provider.txpool_status().await?)?; - } - }; - - Ok(()) + Self::Inspect { .. } => print_json_object(provider.txpool_inspect().await?), + Self::Status { .. } => print_json_object(provider.txpool_status().await?), + } } } diff --git a/crates/cast/src/cmd/vaddr/create.rs b/crates/cast/src/cmd/vaddr/create.rs new file mode 100644 index 0000000000000..78df8a624a400 --- /dev/null +++ b/crates/cast/src/cmd/vaddr/create.rs @@ -0,0 +1,89 @@ +use crate::{ + cmd::tip20::mine::{self, RegisterMessages}, + tx::{SendTxOpts, TxParams}, +}; +use alloy_primitives::{Address, B256}; +use eyre::Result; +use foundry_cli::json::print_json_success; +use foundry_common::shell; +use serde_json::json; +use tempo_primitives::{TempoAddressExt, UserTag}; + +const MESSAGES: RegisterMessages = RegisterMessages { + no_signer: "cast vaddr create requires a signer (for example --private-key or --from)", + mismatch: "signer mismatch: salt is for", + submitting: "...", +}; + +/// Largest 6-byte user tag. +const MAX_USER_TAG: u64 = 0x0000_FFFF_FFFF_FFFF; + +#[allow(clippy::too_many_arguments)] +pub(super) async fn run( + owner: Address, + salt: Option, + tag: u64, + count: u32, + threads: Option, + seed: Option, + no_random: bool, + no_register: bool, + send_tx: SendTxOpts, + tx_opts: TxParams, +) -> Result<()> { + if count == 0 { + return Ok(()); + } + + let json = shell::is_json(); + let (output, elapsed) = mine::find_salt(owner, salt, threads, seed, no_random, json, "owner")?; + if let Some(elapsed) = elapsed + && !json + { + sh_status!("Found salt in {elapsed:?}")?; + } + + let mut virtual_addresses = Vec::with_capacity(count as usize); + for i in 0..count { + let tag_value = tag + .checked_add(i as u64) + .filter(|&t| t <= MAX_USER_TAG) + .ok_or_else(|| eyre::eyre!("tag overflow: tag + count exceeds the 6-byte user tag range (max {MAX_USER_TAG:#x})"))?; + let user_tag = UserTag::new(tag_value.to_be_bytes()[2..].try_into().unwrap()); + virtual_addresses.push((user_tag, Address::new_virtual(output.master_id, user_tag))); + } + + let mut payload = json!({ + "salt": format!("{}", output.salt), + "registration_hash": format!("{}", output.registration_hash), + "master_id": format!("{}", output.master_id), + "virtual_addresses": virtual_addresses.iter().map(|(tag, addr)| json!({ + "tag": format!("{tag}"), + "address": format!("{addr}"), + })).collect::>(), + }); + + if !json { + sh_println!( + "Salt: {}\nRegistration hash: {}\nMaster ID: {}", + output.salt, + output.registration_hash, + output.master_id, + )?; + sh_println!("\nVirtual addresses:")?; + for (tag, vaddr) in &virtual_addresses { + sh_println!(" tag={tag} {vaddr}")?; + } + } + + if !no_register { + let tx_hash = + mine::register_virtual_master(owner, output.salt, send_tx, tx_opts, json, &MESSAGES) + .await?; + payload["registration_tx_hash"] = json!(format!("{tx_hash:#x}")); + } + if json { + print_json_success(payload)?; + } + Ok(()) +} diff --git a/crates/cast/src/cmd/vaddr/mod.rs b/crates/cast/src/cmd/vaddr/mod.rs new file mode 100644 index 0000000000000..446d1e7ece5e2 --- /dev/null +++ b/crates/cast/src/cmd/vaddr/mod.rs @@ -0,0 +1,131 @@ +use crate::tx::{SendTxOpts, TxParams}; +use alloy_primitives::{Address, B256}; +use clap::Parser; +use foundry_cli::opts::RpcOpts; + +mod create; +mod resolve; +mod watch; + +/// TIP-1022 virtual address registry operations (Tempo). +/// +/// Virtual addresses are deterministic 20-byte aliases (masterId || VIRTUAL_MAGIC || userTag) +/// that auto-forward TIP-20 deposits to a registered master wallet at the protocol level, +/// with no on-chain sweep transaction required. +/// +/// See: +#[derive(Debug, Parser, Clone)] +pub enum VaddrSubcommand { + /// Mine a TIP-1022 proof-of-work salt, register as a virtual address master, and print + /// derived virtual addresses for the given owner. + #[command(visible_alias = "c")] + Create { + /// The master (owner) address that will control all virtual addresses under this + /// registration. Must not be the zero address, a virtual address, or a TIP-20 token. + #[arg(long, value_name = "ADDRESS")] + owner: Address, + + /// Use this salt directly instead of mining one. Must satisfy the 32-bit PoW requirement. + #[arg(long, conflicts_with_all = ["seed", "no_random"], value_name = "HEX")] + salt: Option, + + /// Starting user tag for the derived virtual address output (hex-encoded 6 bytes). + #[arg(long, default_value = "0", value_name = "U64")] + tag: u64, + + /// Number of virtual addresses to derive and print. + #[arg(long, default_value = "1", value_name = "N")] + count: u32, + + /// Number of threads to use for mining. Defaults to number of logical cores. + #[arg(long, short = 'j', visible_alias = "jobs")] + threads: Option, + + /// Seed for the random number generator used to initialize the salt search. + #[arg(long, value_name = "HEX")] + seed: Option, + + /// Start salt search from zero instead of a random value. + #[arg(long, conflicts_with = "seed")] + no_random: bool, + + /// Mine and print the salt and derived virtual addresses without submitting the + /// registerVirtualMaster transaction. + #[arg(long)] + no_register: bool, + + #[command(flatten)] + send_tx: Box, + + #[command(flatten)] + tx: Box, + }, + + /// Resolve a virtual address to its registered master and decode its components. + #[command(visible_alias = "r")] + Resolve { + /// The virtual address to resolve. + #[arg(value_name = "ADDRESS")] + addr: Address, + + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Watch (tail) incoming TIP-20 transfers to a virtual address. + #[command(visible_alias = "w")] + Watch { + /// The virtual address to monitor. + #[arg(value_name = "ADDRESS")] + addr: Address, + + /// Filter on a specific TIP-20 token address. Watches all tokens if omitted. + #[arg(long, value_name = "ADDRESS")] + token: Option
, + + /// Block number to start from. Defaults to the current latest block. + #[arg(long, value_name = "BLOCK")] + from_block: Option, + + #[command(flatten)] + rpc: RpcOpts, + }, +} + +impl VaddrSubcommand { + pub async fn run(self) -> eyre::Result<()> { + match self { + Self::Create { + owner, + salt, + tag, + count, + threads, + seed, + no_random, + no_register, + send_tx, + tx, + } => { + create::run( + owner, + salt, + tag, + count, + threads, + seed, + no_random, + no_register, + *send_tx, + *tx, + ) + .await? + } + Self::Resolve { addr, rpc } => resolve::run(addr, rpc).await?, + Self::Watch { addr, token, from_block, rpc } => { + watch::run(addr, token, from_block, rpc).await? + } + } + Ok(()) + } +} diff --git a/crates/cast/src/cmd/vaddr/resolve.rs b/crates/cast/src/cmd/vaddr/resolve.rs new file mode 100644 index 0000000000000..f0603fe1a1cef --- /dev/null +++ b/crates/cast/src/cmd/vaddr/resolve.rs @@ -0,0 +1,36 @@ +use crate::{cmd::print_json_or, tempo::tempo_provider}; +use alloy_primitives::Address; +use eyre::Result; +use foundry_cli::opts::RpcOpts; +use serde_json::json; +use tempo_alloy::contracts::precompiles::{ADDRESS_REGISTRY_ADDRESS, IAddressRegistry}; + +pub(super) async fn run(addr: Address, rpc: RpcOpts) -> Result<()> { + let (_, provider) = tempo_provider(&rpc)?; + let registry = IAddressRegistry::new(ADDRESS_REGISTRY_ADDRESS, &provider); + + let decode = registry.decodeVirtualAddress(addr); + let resolve = registry.resolveVirtualAddress(addr); + let (decoded, master) = tokio::try_join!(decode.call(), resolve.call())?; + + if !decoded.isVirtual { + return sh_println!("{addr} is not a virtual address"); + } + + let master_address = (!master.is_zero()).then(|| format!("{master}")); + let payload = json!({ + "address": format!("{addr}"), + "master_id": format!("{}", decoded.masterId), + "user_tag": format!("{}", decoded.userTag), + "master_address": master_address, + }); + print_json_or( + payload, + format!( + "Virtual address: {addr}\nMaster ID: {}\nUser tag: {}\nMaster address: {}", + decoded.masterId, + decoded.userTag, + master_address.as_deref().unwrap_or("(unregistered)"), + ), + ) +} diff --git a/crates/cast/src/cmd/vaddr/watch.rs b/crates/cast/src/cmd/vaddr/watch.rs new file mode 100644 index 0000000000000..8e7fe759f18ab --- /dev/null +++ b/crates/cast/src/cmd/vaddr/watch.rs @@ -0,0 +1,105 @@ +use crate::{cmd::erc20::IERC20, tempo::tempo_provider}; +use alloy_primitives::{Address, U256}; +use alloy_provider::Provider; +use alloy_rpc_types::{BlockNumberOrTag, Filter, Log}; +use alloy_sol_types::SolEvent; +use eyre::Result; +use foundry_cli::opts::RpcOpts; +use foundry_common::shell; +use serde_json::json; +use std::time::Duration; +use tempo_primitives::TempoAddressExt; + +fn historical_filter(filter: &Filter, anchor: u64) -> Filter { + filter.clone().from_block(filter.get_from_block().unwrap_or(anchor)).to_block(anchor) +} + +fn poll_filter(filter: &Filter, last_block: u64, current: u64) -> Option { + (current > last_block).then(|| filter.clone().from_block(last_block + 1).to_block(current)) +} + +pub(super) async fn run( + addr: Address, + token: Option
, + from_block: Option, + rpc: RpcOpts, +) -> Result<()> { + if !addr.is_virtual() { + eyre::bail!("{addr} is not a virtual address"); + } + + let (_, provider) = tempo_provider(&rpc)?; + + // Transfer(address indexed from, address indexed to, uint256 value): topic2 is the recipient. + let start = from_block.map_or(BlockNumberOrTag::Latest, BlockNumberOrTag::Number); + let mut filter = Filter::new() + .event_signature(IERC20::Transfer::SIGNATURE_HASH) + .topic2(addr) + .from_block(start); + if let Some(token) = token { + filter = filter.address(token); + } + + if !shell::is_json() { + sh_status!("Watching transfers to {addr}... (Ctrl-C to stop)")?; + } + + // Historical logs from the requested start block, then poll for new ones. + let mut last_block = provider.get_block_number().await?; + for log in provider.get_logs(&historical_filter(&filter, last_block)).await? { + print_transfer_log(&log)?; + } + loop { + tokio::time::sleep(Duration::from_secs(2)).await; + let current = provider.get_block_number().await?; + if let Some(filter) = poll_filter(&filter, last_block, current) { + for log in provider.get_logs(&filter).await? { + print_transfer_log(&log)?; + } + last_block = current; + } + } +} + +fn print_transfer_log(log: &Log) -> Result<()> { + let block = log.block_number.unwrap_or(0); + let tx = log.transaction_hash.unwrap_or_default(); + let token = log.address(); + let from = log.topics().get(1).map(|t| Address::from_word(*t).to_string()).unwrap_or_default(); + let data = &log.data().data; + let amount = if data.len() >= 32 { U256::from_be_slice(&data[..32]) } else { U256::ZERO }; + + if shell::is_json() { + let payload = json!({ + "block": block, + "tx": format!("{tx}"), + "token": format!("{token}"), + "from": from, + "amount": amount.to_string(), + }); + sh_println!("{payload}") + } else { + sh_println!("block={block} tx={tx} token={token} from={from} amount={amount}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn history_and_poll_ranges_are_contiguous() { + for start in [BlockNumberOrTag::Number(0), BlockNumberOrTag::Latest] { + let filter = Filter::new().from_block(start); + let historical = historical_filter(&filter, 100); + let poll = poll_filter(&filter, 100, 106).unwrap(); + + assert_eq!(historical.get_from_block(), start.as_number().or(Some(100))); + assert_eq!(historical.get_to_block(), Some(100)); + assert_eq!(poll.get_from_block(), Some(101)); + assert_eq!(poll.get_to_block(), Some(106)); + } + + assert!(poll_filter(&Filter::new(), 100, 100).is_none()); + } +} diff --git a/crates/cast/src/cmd/wallet/list.rs b/crates/cast/src/cmd/wallet/list.rs index 033c9b278a518..019cdebcaaaec 100644 --- a/crates/cast/src/cmd/wallet/list.rs +++ b/crates/cast/src/cmd/wallet/list.rs @@ -1,10 +1,11 @@ use clap::Parser; use eyre::Result; -use std::env; - -use foundry_common::{fs, sh_err, sh_println}; +use foundry_cli::json::print_json_success; +use foundry_common::{fs, fs::canonicalize_path, sh_err, sh_println, shell}; use foundry_config::Config; -use foundry_wallets::multi_wallet::MultiWalletOptsBuilder; +use foundry_wallets::wallet_multi::MultiWalletOptsBuilder; +use serde::Serialize; +use std::{borrow::Cow, env}; /// CLI arguments for `cast wallet list`. #[derive(Clone, Debug, Parser)] @@ -23,30 +24,51 @@ pub struct ListArgs { trezor: bool, /// List accounts from AWS KMS. + /// + /// Ensure either one of AWS_KMS_KEY_IDS (comma-separated) or AWS_KMS_KEY_ID environment + /// variables are set. #[arg(long, hide = !cfg!(feature = "aws-kms"))] aws: bool, /// List accounts from Google Cloud KMS. + /// + /// Ensure the following environment variables are set: GCP_PROJECT_ID, GCP_LOCATION, + /// GCP_KEY_RING, GCP_KEY_NAME, GCP_KEY_VERSION. + /// + /// See: #[arg(long, hide = !cfg!(feature = "gcp-kms"))] gcp: bool, + /// List accounts from Turnkey. + #[arg(long, hide = !cfg!(feature = "turnkey"))] + turnkey: bool, + /// List all configured accounts. #[arg(long, group = "hw-wallets")] all: bool, /// Max number of addresses to display from hardware wallets. #[arg(long, short, default_value = "3", requires = "hw-wallets")] - max_senders: Option, + max_senders: usize, } impl ListArgs { pub async fn run(self) -> Result<()> { + let format_json = shell::is_json(); + let mut accounts: Vec = Vec::new(); + // list local accounts as files in keystore dir, no need to unlock / provide password if self.dir.is_some() || self.all - || (!self.ledger && !self.trezor && !self.aws && !self.gcp) + || (!self.ledger && !self.trezor && !self.aws && !self.gcp && !self.turnkey) { - let _ = self.list_local_senders(); + match self.list_local_senders() { + Ok(local) => accounts.extend(local), + Err(e) if !self.all => { + sh_err!("{}", e)?; + } + _ => {} + } } // Create options for multi wallet - ledger, trezor and AWS @@ -56,30 +78,35 @@ impl ListArgs { .trezor(self.trezor || self.all) .aws(self.aws || self.all) .gcp(self.gcp || (self.all && gcp_env_vars_set())) + .turnkey(self.turnkey || self.all) .interactives(0) + .interactive(false) + .browser(Default::default()) .build() .expect("build multi wallet"); - // macro to print senders for a list of signers + // macro to collect or print senders for a list of signers macro_rules! list_senders { ($signers:expr, $label:literal) => { match $signers.await { Ok(signers) => { for signer in signers.unwrap_or_default().iter() { - signer - .available_senders(self.max_senders.unwrap()) - .await? - .iter() - .for_each(|sender| { + for sender in signer.available_senders(self.max_senders).await? { + if format_json { + accounts.push(WalletAccount { + address: sender.to_string(), + source: $label, + }); + } else { let _ = sh_println!("{} ({})", sender, $label); - }) + } + } } } - Err(e) => { - if !self.all { - sh_err!("{}", e)?; - } + Err(e) if !self.all => { + sh_err!("{}", e)?; } + _ => {} } }; } @@ -87,36 +114,74 @@ impl ListArgs { list_senders!(list_opts.ledgers(), "Ledger"); list_senders!(list_opts.trezors(), "Trezor"); list_senders!(list_opts.aws_signers(), "AWS"); + list_senders!(list_opts.gcp_signers(), "GCP"); + list_senders!(async { list_opts.turnkey_signers() }, "Turnkey"); + + if format_json { + print_json_success(accounts)?; + } Ok(()) } - fn list_local_senders(&self) -> Result<()> { - let keystore_path = self.dir.clone().unwrap_or_default(); + fn list_local_senders(&self) -> Result> { + let keystore_path = self.dir.as_deref().unwrap_or_default(); let keystore_dir = if keystore_path.is_empty() { // Create the keystore default directory if it doesn't exist let default_dir = Config::foundry_keystores_dir().unwrap(); fs::create_dir_all(&default_dir)?; default_dir } else { - dunce::canonicalize(keystore_path)? + canonicalize_path(keystore_path)? }; + let mut accounts = Vec::new(); + // List all files within the keystore directory. - for entry in std::fs::read_dir(keystore_dir)? { - let path = entry?.path(); + let mut paths = std::fs::read_dir(keystore_dir)? + .map(|entry| entry.map(|entry| entry.path())) + .collect::, _>>()?; + paths.sort(); + for path in paths { if path.is_file() && let Some(file_name) = path.file_name() && let Some(name) = file_name.to_str() + // Skip recognized Touch ID sidecars while retaining ambiguous files. + && !matches!(super::is_touch_id_sidecar(&path), Ok(true)) { - sh_println!("{name} (Local)")?; + let account = local_account_name(name); + if shell::is_json() { + accounts.push(WalletAccount { address: account.into_owned(), source: "Local" }); + } else { + sh_println!("{} (Local)", account)?; + } } } - Ok(()) + Ok(accounts) + } +} + +/// Extracts the address from a Geth-style keystore filename, preserving custom names. +fn local_account_name(name: &str) -> Cow<'_, str> { + if let Some((timestamp, address)) = + name.strip_prefix("UTC--").and_then(|suffix| suffix.rsplit_once("--")) + && !timestamp.is_empty() + && address.len() == 40 + && address.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + Cow::Owned(format!("0x{address}")) + } else { + Cow::Borrowed(name) } } +#[derive(Serialize)] +struct WalletAccount { + address: String, + source: &'static str, +} + fn gcp_env_vars_set() -> bool { let required_vars = ["GCP_PROJECT_ID", "GCP_LOCATION", "GCP_KEY_RING", "GCP_KEY_NAME", "GCP_KEY_VERSION"]; diff --git a/crates/cast/src/cmd/wallet/mod.rs b/crates/cast/src/cmd/wallet/mod.rs index 4591836c45ece..b9f5cf4edd0f0 100644 --- a/crates/cast/src/cmd/wallet/mod.rs +++ b/crates/cast/src/cmd/wallet/mod.rs @@ -1,3 +1,4 @@ +use crate::cmd::{confirm_continue, rpc_provider}; use alloy_chains::Chain; use alloy_dyn_abi::TypedData; use alloy_primitives::{Address, B256, Signature, U256, hex}; @@ -10,13 +11,20 @@ use alloy_signer_local::{ }; use clap::Parser; use eyre::{Context, Result}; -use foundry_cli::{opts::RpcOpts, utils, utils::LoadConfig}; -use foundry_common::{fs, sh_println, shell}; +use foundry_cli::{ + json::{print_json_success, print_scalar}, + opts::RpcOpts, + utils::parse_json, +}; +use foundry_common::{errors::FsPathError, fs, fs::canonicalize_path, sh_println, shell}; use foundry_config::Config; -use foundry_wallets::{RawWalletOpts, WalletOpts, WalletSigner}; +use foundry_wallets::{BrowserWalletOpts, RawWalletOpts, WalletOpts, WalletSigner}; use rand_08::thread_rng; -use serde_json::json; -use std::path::Path; +use serde_json::{Value, json}; +use std::{ + ffi::OsString, + path::{Path, PathBuf}, +}; use yansi::Paint; pub mod vanity; @@ -25,13 +33,30 @@ use vanity::VanityArgs; pub mod list; use list::ListArgs; +mod process_tree; + +pub mod session; +use session::SessionArgs; + +mod touch_id; +use touch_id::TouchIdArgs; + /// CLI arguments for `cast wallet`. #[derive(Debug, Parser)] pub enum WalletSubcommands { - /// Create a new random keypair. - #[command(visible_alias = "n")] + /// Create a new random keypair + /// + /// Examples: + /// - cast wallet new (print a new private key and address) + /// - cast wallet new my-wallet (save to the default keystore directory) + /// - cast wallet new ~/.foundry/keystores dev (save to an encrypted keystore) + #[command(verbatim_doc_comment, visible_alias = "n")] New { /// If provided, then keypair will be written to an encrypted JSON keystore. + /// + /// A single bare argument that is not an existing directory is treated as ACCOUNT_NAME and + /// saved under the default keystore directory (`~/.foundry/keystores`), matching + /// `cast wallet import `. path: Option, /// Account name for the keystore file. If provided, the keystore file @@ -42,18 +67,28 @@ pub enum WalletSubcommands { /// Triggers a hidden password prompt for the JSON keystore. /// /// Deprecated: prompting for a hidden password is now the default. - #[arg(long, short, requires = "path", conflicts_with = "unsafe_password")] + #[arg(long, short, conflicts_with = "unsafe_password")] password: bool, /// Password for the JSON keystore in cleartext. /// /// This is UNSAFE to use and we recommend using the --password. - #[arg(long, requires = "path", env = "CAST_PASSWORD", value_name = "PASSWORD")] + #[arg(long, env = "CAST_PASSWORD", value_name = "PASSWORD")] unsafe_password: Option, /// Number of wallets to generate. #[arg(long, short, default_value = "1")] number: u32, + + /// Overwrite existing keystore files without prompting. + #[arg(long)] + force: bool, + + /// Enroll the keystore for Touch ID-assisted authentication on macOS. + /// + /// The macOS login password and explicit keystore passwords remain available. + #[arg(long, hide = !cfg!(all(target_os = "macos", feature = "touch-id")))] + touch_id: bool, }, /// Generates a random BIP39 mnemonic phrase @@ -85,10 +120,38 @@ pub enum WalletSubcommands { #[command(flatten)] wallet: WalletOpts, + + #[command(flatten)] + browser: BrowserWalletOpts, }, - /// Sign a message or typed data. - #[command(visible_alias = "s")] + /// Derive accounts from a mnemonic + /// + /// Examples: + /// - cast wallet derive "test test test test test test test test test test test junk" + /// - cast wallet derive "$MNEMONIC" --accounts 5 + #[command(verbatim_doc_comment, visible_alias = "d")] + Derive { + /// The accounts will be derived from the specified mnemonic phrase. + #[arg(value_name = "MNEMONIC")] + mnemonic: String, + + /// Number of accounts to display. + #[arg(long, short, default_value = "1")] + accounts: Option, + + /// Insecure mode: display private keys in the terminal. + #[arg(long, default_value = "false")] + insecure: bool, + }, + + /// Sign a message or typed data + /// + /// Examples: + /// - cast wallet sign "hello" --account dev + /// - cast wallet sign "hello" --private-key $PK + /// - cast wallet sign --data --from-file typed_data.json --ledger + #[command(verbatim_doc_comment, visible_alias = "s")] Sign { /// The message, typed data, or hash to sign. /// @@ -119,6 +182,9 @@ pub enum WalletSubcommands { #[command(flatten)] wallet: WalletOpts, + + #[command(flatten)] + browser: BrowserWalletOpts, }, /// EIP-7702 sign authorization. @@ -136,17 +202,40 @@ pub enum WalletSubcommands { #[arg(long)] chain: Option, + /// Skip the confirmation prompt for wildcard chain authorizations. + #[arg(long)] + force: bool, + + /// If set, indicates the authorization will be broadcast by the signing account itself. + /// This means the nonce used will be the current nonce + 1 (to account for the + /// transaction that will include this authorization). + #[arg(long, conflicts_with = "nonce")] + self_broadcast: bool, + #[command(flatten)] wallet: WalletOpts, }, - /// Verify the signature of a message. - #[command(visible_alias = "v")] + /// Verify the signature of a message + /// + /// Examples: + /// - cast wallet verify --address $ADDRESS "hello" $SIGNATURE + /// - cast wallet verify --address $ADDRESS --no-hash $HASH $SIGNATURE + #[command(verbatim_doc_comment, visible_alias = "v")] Verify { /// The original message. /// /// Treats 0x-prefixed strings as hex encoded bytes. /// Non 0x-prefixed strings are treated as raw input message. + /// + /// The message will be prefixed with the Ethereum Signed Message header and hashed before + /// signing, unless `--no-hash` is provided. + /// + /// Typed data can be provided as a json string or a file name. + /// Use --data flag to denote the message is a string of typed data. + /// Use --data --from-file to denote the message is a file name containing typed data. + /// The data will be combined and hashed using the EIP712 specification before signing. + /// The data should be formatted as JSON. message: String, /// The signature to verify. @@ -155,10 +244,27 @@ pub enum WalletSubcommands { /// The address of the message signer. #[arg(long, short)] address: Address, + + /// Treat the message as JSON typed data. + #[arg(long)] + data: bool, + + /// Treat the message as a file containing JSON typed data. Requires `--data`. + #[arg(long, requires = "data")] + from_file: bool, + + /// Treat the message as a raw 32-byte hash and sign it directly without hashing it again. + #[arg(long, conflicts_with = "data")] + no_hash: bool, }, - /// Import a private key into an encrypted keystore. - #[command(visible_alias = "i")] + /// Import a private key into an encrypted keystore + /// + /// Examples: + /// - cast wallet import dev --interactive (prompt for the private key) + /// - cast wallet import dev --private-key $PK + /// - cast wallet import dev --mnemonic "$MNEMONIC" --mnemonic-index 1 + #[command(verbatim_doc_comment, visible_alias = "i")] Import { /// The name for the account in the keystore. #[arg(value_name = "ACCOUNT_NAME")] @@ -171,6 +277,11 @@ pub enum WalletSubcommands { /// This is unsafe, we recommend using the default hidden password prompt #[arg(long, env = "CAST_UNSAFE_PASSWORD", value_name = "PASSWORD")] unsafe_password: Option, + /// Enroll the keystore for Touch ID-assisted authentication on macOS. + /// + /// The macOS login password and explicit keystore passwords remain available. + #[arg(long, hide = !cfg!(all(target_os = "macos", feature = "touch-id")))] + touch_id: bool, #[command(flatten)] raw_wallet_options: RawWalletOpts, }, @@ -179,6 +290,12 @@ pub enum WalletSubcommands { #[command(visible_alias = "ls")] List(ListArgs), + /// Manage temporary Tempo wallet sessions. + Session(SessionArgs), + + /// Manage Touch ID enrollment for encrypted keystores. + TouchId(TouchIdArgs), + /// Remove a wallet from the keystore. /// /// This command requires the wallet alias and will prompt for a password to ensure that only @@ -199,9 +316,14 @@ pub enum WalletSubcommands { }, /// Derives private key from mnemonic - #[command(name = "private-key", visible_alias = "pk", aliases = &["derive-private-key", "--derive-private-key"])] + /// + /// Examples: + /// - cast wallet private-key "test test test test test test test test test test test junk" + /// - cast wallet private-key "$MNEMONIC" 1 (derive the key at index 1) + /// - cast wallet private-key "$MNEMONIC" "m/44'/60'/0'/0/1" (use a custom path) + #[command(verbatim_doc_comment, name = "private-key", visible_alias = "pk", aliases = &["derive-private-key", "--derive-private-key"])] PrivateKey { - /// If provided, the private key will be derived from the specified menomonic phrase. + /// If provided, the private key will be derived from the specified mnemonic phrase. #[arg(value_name = "MNEMONIC")] mnemonic_override: Option, @@ -263,104 +385,44 @@ pub enum WalletSubcommands { impl WalletSubcommands { pub async fn run(self) -> Result<()> { match self { - Self::New { path, account_name, unsafe_password, number, .. } => { - let mut rng = thread_rng(); - - let mut json_values = if shell::is_json() { Some(vec![]) } else { None }; - if let Some(path) = path { - let path = match dunce::canonicalize(path.clone()) { - Ok(path) => path, - // If the path doesn't exist, it will fail to be canonicalized, - // so we attach more context to the error message. - Err(e) => { - eyre::bail!( - "If you specified a directory, please make sure it exists, or create it before running `cast wallet new `.\n{path} is not a directory.\nError: {}", - e - ); - } - }; - if !path.is_dir() { - // we require path to be an existing directory - eyre::bail!("`{}` is not a directory", path.display()); + Self::New { + path, + mut account_name, + unsafe_password, + number, + password, + force, + touch_id, + } => { + ensure_touch_id_available(touch_id)?; + + let path = match path { + Some(path) => Some(resolve_new_dir(path, &mut account_name)?), + None if unsafe_password.is_some() || password || touch_id => { + let path = resolve_keystore_dir(None)?; + fs::create_dir_all(&path)?; + Some(path) } + None => None, + }; - let password = if let Some(password) = unsafe_password { - password - } else { - // if no --unsafe-password was provided read via stdin - rpassword::prompt_password("Enter secret: ")? - }; - - for i in 0..number { - let account_name_ref = account_name.as_deref().map(|name| match number { - 1 => name.to_string(), - _ => format!("{}_{}", name, i + 1), - }); - - let (wallet, uuid) = PrivateKeySigner::new_keystore( - &path, - &mut rng, - password.clone(), - account_name_ref.as_deref(), - )?; - let identifier = account_name_ref.as_deref().unwrap_or(&uuid); - - if let Some(json) = json_values.as_mut() { - json.push(if shell::verbosity() > 0 { - json!({ - "address": wallet.address().to_checksum(None), - "public_key": format!("0x{}", hex::encode(wallet.public_key())), - "path": format!("{}", path.join(identifier).display()), - }) - } else { - json!({ - "address": wallet.address().to_checksum(None), - "path": format!("{}", path.join(identifier).display()), - }) - }); - } else { - sh_println!( - "Created new encrypted keystore file: {}", - path.join(identifier).display() - )?; - sh_println!("Address: {}", wallet.address().to_checksum(None))?; - if shell::verbosity() > 0 { - sh_println!("Public key: 0x{}", hex::encode(wallet.public_key()))?; - } - } - } - } else { - for _ in 0..number { - let wallet = PrivateKeySigner::random_with(&mut rng); - - if let Some(json) = json_values.as_mut() { - json.push(if shell::verbosity() > 0 { - json!({ - "address": wallet.address().to_checksum(None), - "public_key": format!("0x{}", hex::encode(wallet.public_key())), - "private_key": format!("0x{}", hex::encode(wallet.credential().to_bytes())), - }) - } else { - json!({ - "address": wallet.address().to_checksum(None), - "private_key": format!("0x{}", hex::encode(wallet.credential().to_bytes())), - }) - }); - } else { - sh_println!("Successfully created new keypair.")?; - sh_println!("Address: {}", wallet.address().to_checksum(None))?; - if shell::verbosity() > 0 { - sh_println!("Public key: 0x{}", hex::encode(wallet.public_key()))?; - } - sh_println!( - "Private key: 0x{}", - hex::encode(wallet.credential().to_bytes()) - )?; - } - } + if let Some(name) = &account_name { + ensure_account_name_available(name)?; } - if let Some(json) = json_values.as_ref() { - sh_println!("{}", serde_json::to_string_pretty(json)?)?; + + let json = match path { + Some(path) => new_keystores( + &path, + account_name.as_deref(), + unsafe_password, + number, + force, + touch_id, + )?, + None => new_keypairs(number)?, + }; + if shell::is_json() { + print_json_success(json)?; } } Self::NewMnemonic { words, accounts, entropy } => { @@ -368,23 +430,23 @@ impl WalletSubcommands { let entropy = Entropy::from_slice(hex::decode(entropy)?)?; Mnemonic::::new_from_entropy(entropy).to_phrase() } else { - let mut rng = thread_rng(); - Mnemonic::::new_with_count(&mut rng, words)?.to_phrase() + Mnemonic::::new_with_count(&mut thread_rng(), words)?.to_phrase() }; let format_json = shell::is_json(); - if !format_json { sh_println!("{}", "Generating mnemonic from provided entropy...".yellow())?; } let builder = MnemonicBuilder::::default().phrase(phrase.as_str()); - let derivation_path = "m/44'/60'/0'/0/"; let wallets = (0..accounts) - .map(|i| builder.clone().derivation_path(format!("{derivation_path}{i}"))) - .collect::, _>>()?; - let wallets = - wallets.into_iter().map(|b| b.build()).collect::, _>>()?; + .map(|i| -> Result<_> { + Ok(builder + .clone() + .derivation_path(format!("m/44'/60'/0'/0/{i}"))? + .build()?) + }) + .collect::>>()?; if !format_json { sh_println!("{}", "Successfully generated a new mnemonic.".green())?; @@ -392,254 +454,294 @@ impl WalletSubcommands { sh_println!("\nAccounts:")?; } - let mut accounts = json!([]); + let mut accounts = Vec::new(); for (i, wallet) in wallets.iter().enumerate() { - let public_key = hex::encode(wallet.public_key()); - let private_key = hex::encode(wallet.credential().to_bytes()); + let public_key = hex::encode_prefixed(wallet.public_key()); + let private_key = hex::encode_prefixed(wallet.credential().to_bytes()); if format_json { - accounts.as_array_mut().unwrap().push(if shell::verbosity() > 0 { - json!({ - "address": format!("{}", wallet.address()), - "public_key": format!("0x{}", public_key), - "private_key": format!("0x{}", private_key), - }) - } else { - json!({ - "address": format!("{}", wallet.address()), - "private_key": format!("0x{}", private_key), - }) - }); + let mut account = serde_json::Map::new(); + account.insert("address".into(), json!(wallet.address().to_string())); + if shell::verbosity() > 0 { + account.insert("public_key".into(), json!(public_key)); + } + account.insert("private_key".into(), json!(private_key)); + accounts.push(Value::Object(account)); } else { sh_println!("- Account {i}:")?; sh_println!("Address: {}", wallet.address())?; if shell::verbosity() > 0 { - sh_println!("Public key: 0x{}", public_key)?; + sh_println!("Public key: {public_key}")?; } - sh_println!("Private key: 0x{}\n", private_key)?; + sh_println!("Private key: {private_key}\n")?; } } if format_json { - let obj = json!({ - "mnemonic": phrase, - "accounts": accounts, - }); - sh_println!("{}", serde_json::to_string_pretty(&obj)?)?; + print_json_success(json!({ "mnemonic": phrase, "accounts": accounts }))?; } } Self::Vanity(cmd) => { cmd.run()?; } - Self::Address { wallet, private_key_override } => { - let wallet = private_key_override - .map(|pk| WalletOpts { - raw: RawWalletOpts { private_key: Some(pk), ..Default::default() }, + Self::Address { wallet, browser, private_key_override } => { + let addr = if let Some(pk) = private_key_override { + raw_wallet(RawWalletOpts { private_key: Some(pk), ..Default::default() }) + .signer() + .await? + .address() + } else if let Some(browser) = browser.run::().await? { + browser.address() + } else { + wallet.signer().await?.address() + }; + print_scalar(addr.to_checksum(None))?; + } + Self::Derive { mnemonic, accounts, insecure } => { + let format_json = shell::is_json(); + let mut accounts_json = Vec::new(); + for i in 0..accounts.unwrap_or(1) { + let wallet = raw_wallet(RawWalletOpts { + mnemonic: Some(mnemonic.clone()), + mnemonic_index: i as u32, ..Default::default() }) - .unwrap_or(wallet) .signer() .await?; - let addr = wallet.address(); - sh_println!("{}", addr.to_checksum(None))?; + let WalletSigner::Local(wallet) = wallet else { + eyre::bail!("Only local wallets are supported by this command"); + }; + + let address = wallet.address().to_checksum(None); + let private_key = hex::encode_prefixed(wallet.credential().to_bytes()); + if format_json { + accounts_json.push(if insecure { + json!({ "address": address, "private_key": private_key }) + } else { + json!({ "address": address }) + }); + } else { + sh_println!("- Account {i}:")?; + if insecure { + sh_println!("Address: {address}")?; + sh_println!("Private key: {private_key}\n")?; + } else { + sh_println!("Address: {address}\n")?; + } + } + } + + if format_json { + print_json_success(accounts_json)?; + } } Self::PublicKey { wallet, private_key_override } => { let wallet = private_key_override - .map(|pk| WalletOpts { - raw: RawWalletOpts { private_key: Some(pk), ..Default::default() }, - ..Default::default() + .map(|pk| { + raw_wallet(RawWalletOpts { private_key: Some(pk), ..Default::default() }) }) .unwrap_or(wallet) .signer() .await?; - - let public_key = match wallet { - WalletSigner::Local(wallet) => wallet.public_key(), - _ => eyre::bail!("Only local wallets are supported by this command"), + let WalletSigner::Local(wallet) = wallet else { + eyre::bail!("Only local wallets are supported by this command"); }; - - sh_println!("0x{}", hex::encode(public_key))?; + print_scalar(hex::encode_prefixed(wallet.public_key()))?; } - Self::Sign { message, data, from_file, no_hash, wallet } => { - let wallet = wallet.signer().await?; - let sig = if data { - let typed_data: TypedData = if from_file { - // data is a file name, read json from file - foundry_common::fs::read_json_file(message.as_ref())? + Self::Sign { message, data, from_file, no_hash, wallet, browser } => { + if browser.browser && no_hash { + eyre::bail!("Raw hash signing is not supported with a browser wallet"); + } + + let typed_data = data.then(|| parse_typed_data(&message, from_file)).transpose()?; + + let (sig, address) = + if let Some(browser) = browser.run::().await? { + let sig = if let Some(typed_data) = &typed_data { + browser.sign_dynamic_typed_data(typed_data).await? + } else { + browser.sign_message(&hex_str_to_bytes(&message)?).await? + }; + (sig, browser.address()) } else { - // data is a json string - serde_json::from_str(&message)? + let wallet = wallet.signer().await?; + let sig = if let Some(typed_data) = &typed_data { + wallet.sign_dynamic_typed_data(typed_data).await? + } else if no_hash { + wallet.sign_hash(&hex::decode(&message)?[..].try_into()?).await? + } else { + wallet.sign_message(&hex_str_to_bytes(&message)?).await? + }; + (sig, wallet.address()) }; - wallet.sign_dynamic_typed_data(&typed_data).await? - } else if no_hash { - wallet.sign_hash(&hex::decode(&message)?[..].try_into()?).await? - } else { - wallet.sign_message(&Self::hex_str_to_bytes(&message)?).await? - }; - if shell::verbosity() > 0 { - if shell::is_json() { - sh_println!( - "{}", - serde_json::to_string_pretty(&json!({ - "message": message, - "address": wallet.address(), - "signature": hex::encode(sig.as_bytes()), - }))? - )?; - } else { - sh_println!( - "Successfully signed!\n Message: {}\n Address: {}\n Signature: 0x{}", - message, - wallet.address(), - hex::encode(sig.as_bytes()), - )?; - } + let signature = hex::encode(sig.as_bytes()); + if shell::verbosity() == 0 { + print_scalar(format!("0x{signature}"))?; + } else if shell::is_json() { + print_json_success(json!({ + "message": message, + "address": address, + "signature": signature, + }))?; } else { - // Pipe friendly output - sh_println!("0x{}", hex::encode(sig.as_bytes()))?; + sh_status!("Successfully signed!")?; + sh_status!(" Message: {message}")?; + sh_status!(" Address: {address}")?; + sh_println!("0x{signature}")?; } } - Self::SignAuth { rpc, nonce, chain, wallet, address } => { - let wallet = wallet.signer().await?; - let provider = utils::get_provider(&rpc.load_config()?)?; - let nonce = if let Some(nonce) = nonce { - nonce - } else { - provider.get_transaction_count(wallet.address()).await? + Self::SignAuth { rpc, nonce, chain, force, wallet, address, self_broadcast } => { + let provider = rpc_provider(&rpc)?; + let chain_id = match chain { + Some(chain) => chain.id(), + None => provider.get_chain_id().await?, }; - let chain_id = if let Some(chain) = chain { - chain.id() - } else { - provider.get_chain_id().await? + if chain_id == 0 && !force { + sh_warn!( + "Chain ID 0 creates an EIP-7702 authorization that is valid on every chain." + )?; + if !confirm_continue()? { + return Ok(()); + } + } + + let wallet = wallet.signer().await?; + let nonce = match nonce { + Some(nonce) => nonce, + // When self-broadcasting, the authorization nonce needs to be +1 because the + // transaction itself will consume the current nonce. + None => { + provider.get_transaction_count(wallet.address()).await? + + u64::from(self_broadcast) + } }; let auth = Authorization { chain_id: U256::from(chain_id), address, nonce }; let signature = wallet.sign_hash(&auth.signature_hash()).await?; - let auth = auth.into_signed(signature); - - if shell::verbosity() > 0 { - if shell::is_json() { - sh_println!( - "{}", - serde_json::to_string_pretty(&json!({ - "nonce": nonce, - "chain_id": chain_id, - "address": wallet.address(), - "signature": hex::encode_prefixed(alloy_rlp::encode(&auth)), - }))? - )?; - } else { - sh_println!( - "Successfully signed!\n Nonce: {}\n Chain ID: {}\n Address: {}\n Signature: 0x{}", - nonce, - chain_id, - wallet.address(), - hex::encode_prefixed(alloy_rlp::encode(&auth)), - )?; - } + let signed = hex::encode_prefixed(alloy_rlp::encode(auth.into_signed(signature))); + + if shell::verbosity() == 0 { + print_scalar(signed)?; + } else if shell::is_json() { + print_json_success(json!({ + "nonce": nonce, + "chain_id": chain_id, + "address": wallet.address(), + "signature": signed, + }))?; } else { - // Pipe friendly output - sh_println!("{}", hex::encode_prefixed(alloy_rlp::encode(&auth)))?; + sh_status!("Successfully signed!")?; + sh_status!(" Nonce: {nonce}")?; + sh_status!(" Chain ID: {chain_id}")?; + sh_status!(" Address: {}", wallet.address())?; + sh_println!("{signed}")?; } } - Self::Verify { message, signature, address } => { - let recovered_address = Self::recover_address_from_message(&message, &signature)?; - if address == recovered_address { - sh_println!("Validation succeeded. Address {address} signed this message.")?; - } else { + Self::Verify { message, signature, address, data, from_file, no_hash } => { + let recovered_address = + recover_signer(&message, &signature, data, from_file, no_hash)?; + + if address != recovered_address { eyre::bail!("Validation failed. Address {address} did not sign this message."); } - } - Self::Import { account_name, keystore_dir, unsafe_password, raw_wallet_options } => { - // Set up keystore directory - let dir = if let Some(path) = keystore_dir { - Path::new(&path).to_path_buf() + if shell::is_json() { + print_json_success(json!({"address": address, "result": true}))?; } else { - Config::foundry_keystores_dir().ok_or_else(|| { - eyre::eyre!("Could not find the default keystore directory.") - })? - }; - + sh_println!("Validation succeeded. Address {address} signed this message.")?; + } + } + Self::Import { + account_name, + keystore_dir, + unsafe_password, + touch_id, + raw_wallet_options, + } => { + ensure_touch_id_available(touch_id)?; + ensure_account_name_available(&account_name)?; + let dir = resolve_keystore_dir(keystore_dir)?; fs::create_dir_all(&dir)?; - // check if account exists already - let keystore_path = Path::new(&dir).join(&account_name); + let keystore_path = dir.join(&account_name); if keystore_path.exists() { eyre::bail!("Keystore file already exists at {}", keystore_path.display()); } + if touch_id { + ensure_touch_id_sidecar_available(&keystore_path)?; + } - // get wallet - let wallet = raw_wallet_options - .signer()? - .and_then(|s| match s { - WalletSigner::Local(s) => Some(s), - _ => None, - }) - .ok_or_else(|| { - eyre::eyre!( - "\ + let Some(WalletSigner::Local(wallet)) = raw_wallet_options.signer()? else { + eyre::bail!( + "\ Did you set a private key or mnemonic? Run `cast wallet import --help` and use the corresponding CLI flag to set your key via: --private-key, --mnemonic-path or --interactive." - ) - })?; - - let private_key = wallet.credential().to_bytes(); - let password = if let Some(password) = unsafe_password { - password - } else { - // if no --unsafe-password was provided read via stdin - rpassword::prompt_password("Enter password: ")? + ); }; - let mut rng = thread_rng(); + let password = password_or_prompt(unsafe_password, "Enter password: ")?; let (wallet, _) = PrivateKeySigner::encrypt_keystore( dir, - &mut rng, - private_key, - password, + &mut thread_rng(), + wallet.credential().to_bytes(), + &password, Some(&account_name), )?; let address = wallet.address(); - let success_message = format!( - "`{}` keystore was saved successfully. Address: {:?}", - &account_name, address, - ); - sh_println!("{}", success_message.green())?; + + if touch_id { + let action = format!("keystore was imported at {}", keystore_path.display()); + enroll_new_keystore(&keystore_path, &password, &action, 0)?; + } + + if shell::is_json() { + let mut result = json!({"account": account_name, "address": address}); + if touch_id { + result["touch_id"] = json!(true); + } + print_json_success(result)?; + } else { + sh_println!( + "{}", + format!( + "`{account_name}` keystore was saved successfully. Address: {address:?}" + ) + .green() + )?; + if touch_id { + sh_status!("{TOUCH_ID_ENROLLED_STATUS}")?; + } + } } Self::List(cmd) => { cmd.run().await?; } + Self::Session(args) => { + args.run().await?; + } + Self::TouchId(args) => { + args.run()?; + } Self::Remove { name, dir, unsafe_password } => { - let dir = if let Some(path) = dir { - Path::new(&path).to_path_buf() - } else { - Config::foundry_keystores_dir().ok_or_else(|| { - eyre::eyre!("Could not find the default keystore directory.") - })? - }; - - let keystore_path = Path::new(&dir).join(&name); - if !keystore_path.exists() { - eyre::bail!("Keystore file does not exist at {}", keystore_path.display()); - } - - let password = if let Some(pwd) = unsafe_password { - pwd - } else { - rpassword::prompt_password("Enter password: ")? - }; - + let keystore_path = existing_keystore_path(&name, dir)?; + let password = password_or_prompt(unsafe_password, "Enter password: ")?; if PrivateKeySigner::decrypt_keystore(&keystore_path, password).is_err() { eyre::bail!("Invalid password - wallet removal cancelled"); } + remove_touch_id_sidecar(&keystore_path)?; std::fs::remove_file(&keystore_path).wrap_err_with(|| { format!("Failed to remove keystore file at {}", keystore_path.display()) })?; - let success_message = format!("`{}` keystore was removed successfully.", &name); - sh_println!("{}", success_message.green())?; + if shell::is_json() { + print_json_success(json!({"account": name, "removed": true}))?; + } else { + sh_println!( + "{}", + format!("`{name}` keystore was removed successfully.").green() + )?; + } } Self::PrivateKey { wallet, @@ -665,54 +767,39 @@ flag to set your key via: } .signer() .await?; - match wallet { - WalletSigner::Local(wallet) => { - if shell::verbosity() > 0 { - sh_println!("Address: {}", wallet.address())?; - sh_println!( - "Private key: 0x{}", - hex::encode(wallet.credential().to_bytes()) - )?; - } else { - sh_println!("0x{}", hex::encode(wallet.credential().to_bytes()))?; - } - } - _ => { - eyre::bail!("Only local wallets are supported by this command."); - } - } - } - Self::DecryptKeystore { account_name, keystore_dir, unsafe_password } => { - // Set up keystore directory - let dir = if let Some(path) = keystore_dir { - Path::new(&path).to_path_buf() - } else { - Config::foundry_keystores_dir().ok_or_else(|| { - eyre::eyre!("Could not find the default keystore directory.") - })? + let WalletSigner::Local(wallet) = wallet else { + eyre::bail!("Only local wallets are supported by this command."); }; - let keypath = dir.join(&account_name); - - if !keypath.exists() { - eyre::bail!("Keystore file does not exist at {}", keypath.display()); - } - - let password = if let Some(password) = unsafe_password { - password + let private_key = hex::encode_prefixed(wallet.credential().to_bytes()); + if shell::verbosity() == 0 { + print_scalar(private_key)?; + } else if shell::is_json() { + print_json_success(json!({ + "address": wallet.address(), + "private_key": private_key, + }))?; } else { - // if no --unsafe-password was provided read via stdin - rpassword::prompt_password("Enter password: ")? - }; - + sh_println!("Address: {}", wallet.address())?; + sh_println!("Private key: {private_key}")?; + } + } + Self::DecryptKeystore { account_name, keystore_dir, unsafe_password } => { + let keypath = existing_keystore_path(&account_name, keystore_dir)?; + let password = password_or_prompt(unsafe_password, "Enter password: ")?; let wallet = PrivateKeySigner::decrypt_keystore(keypath, password)?; let private_key = B256::from_slice(&wallet.credential().to_bytes()); - - let success_message = - format!("{}'s private key is: {}", &account_name, private_key); - - sh_println!("{}", success_message.green())?; + if shell::is_json() { + print_json_success( + json!({"account": account_name, "private_key": private_key}), + )?; + } else { + sh_println!( + "{}", + format!("{account_name}'s private key is: {private_key}").green() + )?; + } } Self::ChangePassword { account_name, @@ -720,82 +807,582 @@ flag to set your key via: unsafe_password, unsafe_new_password, } => { - // Set up keystore directory - let dir = if let Some(path) = keystore_dir { - Path::new(&path).to_path_buf() - } else { - Config::foundry_keystores_dir().ok_or_else(|| { - eyre::eyre!("Could not find the default keystore directory.") - })? + let keypath = existing_keystore_path(&account_name, keystore_dir)?; + let sidecar = touch_id_sidecar_path(&keypath); + + let touch_id_enrolled = match touch_id_sidecar_state(&sidecar)? { + TouchIdSidecarState::Missing => false, + TouchIdSidecarState::Recognized => true, + TouchIdSidecarState::Keystore => { + eyre::bail!( + "refusing to change the password because {} is an existing keystore", + sidecar.display() + ); + } + TouchIdSidecarState::Unknown => { + // Preserve useful structured errors such as UnsupportedVersion. + #[cfg(all(target_os = "macos", feature = "touch-id"))] + foundry_wallets::touch_id::policy(&keypath)?; + + // Never continue after an Unknown classification, even if another + // parser happens to accept the file. + eyre::bail!( + "refusing to change the password because {} exists and is not a recognized Touch ID sidecar", + sidecar.display() + ); + } }; - let keypath = dir.join(&account_name); - - if !keypath.exists() { - eyre::bail!("Keystore file does not exist at {}", keypath.display()); - } - - let current_password = if let Some(password) = unsafe_password { - password - } else { - // if no --unsafe-password was provided read via stdin - rpassword::prompt_password("Enter current password: ")? - }; + #[cfg(all(target_os = "macos", feature = "touch-id"))] + let touch_id_policy = touch_id_enrolled + .then(|| foundry_wallets::touch_id::policy(&keypath)) + .transpose()?; + let current_password = + password_or_prompt(unsafe_password, "Enter current password: ")?; // decrypt the keystore to verify the current password and get the private key let wallet = PrivateKeySigner::decrypt_keystore(&keypath, current_password.clone()) .map_err(|_| eyre::eyre!("Invalid password - password change cancelled"))?; - let new_password = if let Some(password) = unsafe_new_password { - password - } else { - // if no --unsafe-new-password was provided read via stdin - rpassword::prompt_password("Enter new password: ")? - }; - + let new_password = password_or_prompt(unsafe_new_password, "Enter new password: ")?; if current_password == new_password { eyre::bail!("New password cannot be the same as the current password"); } - // Create a new keystore with the new password - let private_key = wallet.credential().to_bytes(); - let mut rng = thread_rng(); let (wallet, _) = PrivateKeySigner::encrypt_keystore( - dir, - &mut rng, - private_key, - new_password, + keypath.parent().unwrap_or(Path::new("")), + &mut thread_rng(), + wallet.credential().to_bytes(), + &new_password, Some(&account_name), )?; - let success_message = format!( - "Password for keystore `{}` was changed successfully. Address: {:?}", - &account_name, - wallet.address(), - ); - sh_println!("{}", success_message.green())?; + #[cfg(all(target_os = "macos", feature = "touch-id"))] + if let Some(policy) = touch_id_policy { + foundry_wallets::touch_id::enroll(&keypath, &new_password, policy).map_err( + |error| { + touch_id_enrollment_failure( + &keypath, + &format!( + "password for keystore `{account_name}` was changed at {}", + keypath.display() + ), + error, + ) + }, + )?; + } + + #[cfg(not(all(target_os = "macos", feature = "touch-id")))] + if touch_id_enrolled { + match remove_touch_id_sidecar(&keypath) { + Ok(true) => { + sh_warn!( + "Removed the stale Touch ID enrollment after changing the password" + )?; + } + Ok(false) => {} + Err(cleanup_error) => { + eyre::bail!( + "password changed, but Touch ID sidecar cleanup failed: {cleanup_error}. The new password is valid; remove {} manually", + sidecar.display() + ); + } + } + } + + let address = wallet.address(); + if shell::is_json() { + print_json_success(json!({"account": account_name, "address": address}))?; + } else { + sh_println!( + "{}", + format!( + "Password for keystore `{account_name}` was changed successfully. Address: {address:?}" + ) + .green() + )?; + } } }; Ok(()) } +} - /// Recovers an address from the specified message and signature. - /// - /// Note: This attempts to decode the message as hex if it starts with 0x. - fn recover_address_from_message(message: &str, signature: &Signature) -> Result
{ - let message = Self::hex_str_to_bytes(message)?; - Ok(signature.recover_address_from_msg(message)?) +const TOUCH_ID_ENROLLED_STATUS: &str = + "Touch ID-assisted unlock enrolled; password-based unlock remains available."; + +/// Creates `number` encrypted keystores in `dir`, returning their JSON records in JSON mode. +fn new_keystores( + dir: &Path, + account_name: Option<&str>, + unsafe_password: Option, + number: u32, + force: bool, + touch_id: bool, +) -> Result> { + let password = password_or_prompt(unsafe_password, "Enter secret: ")?; + let names = (0..number) + .map(|i| account_name.map(|name| indexed_account_name(name, number, i))) + .collect::>(); + + if touch_id { + for name in names.iter().flatten() { + ensure_touch_id_sidecar_available(&dir.join(name))?; + } } - /// Strips the 0x prefix from a hex string and decodes it to bytes. - /// - /// Treats the string as raw bytes if it doesn't start with 0x. - fn hex_str_to_bytes(s: &str) -> Result> { - Ok(match s.strip_prefix("0x") { - Some(data) => hex::decode(data).wrap_err("Could not decode 0x-prefixed string.")?, - None => s.as_bytes().to_vec(), - }) + // Prevent accidental overwriting: check all target files upfront. + if !force { + let existing = + names.iter().flatten().filter(|name| dir.join(name).exists()).collect::>(); + if !existing.is_empty() { + sh_eprintln!("The following keystore file(s) already exist:")?; + for file in &existing { + sh_eprintln!(" - {file}")?; + } + let input: String = foundry_common::prompt!( + "\nDo you want to overwrite all {} file(s)? [y/N]: ", + existing.len() + )?; + if !input.trim().eq_ignore_ascii_case("y") { + eyre::bail!("Operation cancelled. No keystores were modified."); + } + } + } + + let mut rng = thread_rng(); + let mut json_values = Vec::new(); + for (i, name) in names.iter().enumerate() { + let (wallet, uuid) = + PrivateKeySigner::new_keystore(dir, &mut rng, &password, name.as_deref())?; + let keystore_path = dir.join(name.as_deref().unwrap_or(&uuid)); + + if touch_id { + let action = format!("keystore was created at {}", keystore_path.display()); + enroll_new_keystore(&keystore_path, &password, &action, i)?; + } + + let address = wallet.address().to_checksum(None); + if shell::is_json() { + let mut result = json!({ + "address": address, + "public_key": hex::encode_prefixed(wallet.public_key()), + "path": format!("{}", keystore_path.display()), + }); + if touch_id { + result["touch_id"] = json!(true); + } + json_values.push(result); + } else { + sh_status!("Created new encrypted keystore file: {}", keystore_path.display())?; + if touch_id { + sh_status!("{TOUCH_ID_ENROLLED_STATUS}")?; + } + sh_status!("Address: {address}")?; + if shell::verbosity() > 0 { + sh_status!("Public key: {}", hex::encode_prefixed(wallet.public_key()))?; + } + // The machine-readable stdout record duplicates the prose above when stdout is an + // interactive terminal. + if !shell::is_out_tty() { + sh_println!("{address}")?; + } + } + } + Ok(json_values) +} + +/// Generates `number` random keypairs, returning their JSON records in JSON mode. +fn new_keypairs(number: u32) -> Result> { + let mut rng = thread_rng(); + let mut json_values = Vec::new(); + for _ in 0..number { + let wallet = PrivateKeySigner::random_with(&mut rng); + let address = wallet.address().to_checksum(None); + let private_key = hex::encode_prefixed(wallet.credential().to_bytes()); + if shell::is_json() { + json_values.push(json!({ + "address": address, + "public_key": hex::encode_prefixed(wallet.public_key()), + "private_key": private_key, + })); + } else { + sh_status!("Successfully created new keypair.")?; + sh_status!("Address: {address}")?; + if shell::verbosity() > 0 { + sh_status!("Public key: {}", hex::encode_prefixed(wallet.public_key()))?; + } + sh_status!("Private key: {private_key}")?; + // The machine-readable stdout record duplicates the prose above when stdout is an + // interactive terminal. + if !shell::is_out_tty() { + sh_println!("{address}\t{private_key}")?; + } + } + } + Ok(json_values) +} + +fn raw_wallet(raw: RawWalletOpts) -> WalletOpts { + WalletOpts { raw, ..Default::default() } +} + +/// Parses EIP-712 typed data from a JSON string, or from the file it names when `from_file`. +fn parse_typed_data(message: &str, from_file: bool) -> Result { + if from_file { Ok(fs::read_json_file(Path::new(message))?) } else { Ok(parse_json(message)?) } +} + +/// Strips the 0x prefix from a hex string and decodes it to bytes. +/// +/// Treats the string as raw bytes if it doesn't start with 0x. +fn hex_str_to_bytes(s: &str) -> Result> { + Ok(match s.strip_prefix("0x") { + Some(data) => hex::decode(data).wrap_err("Could not decode 0x-prefixed string.")?, + None => s.as_bytes().to_vec(), + }) +} + +/// Returns `password` when given, otherwise prompts for it on the terminal. +fn password_or_prompt(password: Option, prompt: &str) -> Result { + match password { + Some(password) => Ok(password), + None => Ok(rpassword::prompt_password(prompt)?), + } +} + +/// Resolves the directory for `cast wallet new`. +/// +/// A missing bare name is rewritten into `account_name` and stored in the default keystore +/// directory, matching `cast wallet import `. Path-like values and resolution failures +/// other than `NotFound` still error. +fn resolve_new_dir(path: String, account_name: &mut Option) -> Result { + match canonicalize_path(&path) { + Ok(dir) if dir.is_dir() => Ok(dir), + Ok(dir) => eyre::bail!("`{}` is not a directory", dir.display()), + Err(e) + if e.kind() == std::io::ErrorKind::NotFound + && account_name.is_none() + && is_bare_account_name(&path) => + { + *account_name = Some(path); + let dir = resolve_keystore_dir(None)?; + fs::create_dir_all(&dir)?; + Ok(dir) + } + Err(e) => eyre::bail!( + "If you specified a directory, please make sure it exists, or create it before running `cast wallet new `.\n{path} is not a directory.\nError: {e}" + ), + } +} + +/// Returns true when `value` is a bare keystore account name rather than a filesystem path. +/// +/// Path-like values (`.`, `..`, anything containing a separator or `:`, including Windows +/// prefixes such as `C:foo` and ADS names such as `foo:bar`) stay on the existing +/// directory-resolution path for `cast wallet new`. +fn is_bare_account_name(value: &str) -> bool { + !value.is_empty() + && value != "." + && value != ".." + && !value.contains('/') + && !value.contains('\\') + // `C:foo` is a drive-relative path; `foo:bar` is an alternate data stream on + // Windows. Joining either can write outside the default keystore directory + // or hide the keystore in a stream so listing/loading miss it. + && !value.contains(':') +} + +/// Resolves the keystore directory, defaulting to `~/.foundry/keystores`. +fn resolve_keystore_dir(dir: Option) -> Result { + match dir { + Some(dir) => Ok(PathBuf::from(dir)), + None => Config::foundry_keystores_dir() + .ok_or_else(|| eyre::eyre!("Could not find the default keystore directory.")), + } +} + +/// Validates `account_name` and resolves its existing keystore file in `dir`. +fn existing_keystore_path(account_name: &str, dir: Option) -> Result { + ensure_account_name_available(account_name)?; + let keystore_path = resolve_keystore_dir(dir)?.join(account_name); + if !keystore_path.exists() { + eyre::bail!("Keystore file does not exist at {}", keystore_path.display()); + } + Ok(keystore_path) +} + +fn ensure_touch_id_available(touch_id: bool) -> Result<()> { + if !touch_id { + return Ok(()); + } + + #[cfg(all(target_os = "macos", feature = "touch-id"))] + { + if !foundry_wallets::touch_id::is_available() { + eyre::bail!("Touch ID is unavailable on this Mac"); + } + Ok(()) + } + + #[cfg(not(all(target_os = "macos", feature = "touch-id")))] + eyre::bail!("`--touch-id` requires macOS and a cast build with the `touch-id` feature"); +} + +const TOUCH_ID_SIDECAR_SUFFIX: &str = ".touchid"; + +fn ensure_account_name_available(name: &str) -> Result<()> { + let file_name = Path::new(name).file_name().and_then(|s| s.to_str()); + if name.is_empty() || name.contains('\\') || file_name != Some(name) { + eyre::bail!("account name must be a single path segment"); + } + if name.ends_with(TOUCH_ID_SIDECAR_SUFFIX) { + eyre::bail!("account names ending in `{TOUCH_ID_SIDECAR_SUFFIX}` are reserved"); + } + Ok(()) +} + +fn touch_id_sidecar_path(keystore_path: &Path) -> PathBuf { + let mut path = OsString::from(keystore_path.as_os_str()); + path.push(TOUCH_ID_SIDECAR_SUFFIX); + path.into() +} + +/// Classification of a file at a `.touchid` path. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TouchIdSidecarState { + /// No filesystem entry exists at the path. + Missing, + /// The file strictly matches the currently supported Touch ID sidecar schema. + Recognized, + /// The file is an Ethereum keystore. + Keystore, + /// Anything else: malformed JSON, empty object, array, unrelated object, + /// unsupported sidecar version, unknown policy, invalid hex, empty payload, + /// truncated sealed payload, invalid X9.63 prefix, or future sidecar format. + Unknown, +} + +/// The only sidecar version this Cast release understands. +const TOUCH_ID_SIDECAR_VERSION: u32 = 1; + +/// Minimum encoded ciphertext payload: +/// 65-byte P-256 X9.63 public key + 12-byte ChaChaPoly nonce + 16-byte tag. +/// +/// The encrypted password itself may be empty, so 93 bytes is the true minimum. +const TOUCH_ID_SEALED_PASSWORD_MIN_LEN: usize = 65 + 12 + 16; + +/// X9.63 prefix for an uncompressed P-256 public key. +const TOUCH_ID_X963_UNCOMPRESSED_PREFIX: u8 = 0x04; + +/// Strict deserialization-only representation of the persisted sidecar format. +/// +/// Uses `deny_unknown_fields` so that any unrecognized field (e.g. from a +/// future sidecar version) causes a parse failure, which maps to `Unknown`. +/// +/// This duplicates `foundry_wallets::touch_id` on purpose: that module only exists on macOS +/// builds with the `touch-id` feature, while sidecar files must be classified everywhere. +#[derive(Debug, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct TouchIdSidecarWire { + version: u32, + policy: TouchIdPolicyWire, + se_key: String, + sealed_password: String, +} + +impl TouchIdSidecarWire { + /// Whether this is a supported sidecar with plausible payload bytes: `se_key` is non-empty + /// hex and `sealed_password` is hex of at least 93 bytes starting with 0x04. + fn is_recognized(&self) -> bool { + self.version == TOUCH_ID_SIDECAR_VERSION + && hex::decode(&self.se_key).is_ok_and(|se_key| !se_key.is_empty()) + && hex::decode(&self.sealed_password).is_ok_and(|sealed| { + sealed.len() >= TOUCH_ID_SEALED_PASSWORD_MIN_LEN + && sealed.first() == Some(&TOUCH_ID_X963_UNCOMPRESSED_PREFIX) + }) + } +} + +/// The policy values currently recognised by this Cast release. +#[derive(Clone, Copy, Debug, serde::Deserialize)] +#[serde(rename_all = "kebab-case")] +enum TouchIdPolicyWire { + UserPresence, + CurrentBiometry, +} + +impl TouchIdPolicyWire { + const fn as_str(self) -> &'static str { + match self { + Self::UserPresence => "user-presence", + Self::CurrentBiometry => "current-biometry", + } + } +} + +/// Returns the state of the file at `path` with respect to the Touch ID sidecar schema. +/// +/// Classification order: +/// 1. Not found → `Missing` +/// 2. Has both `version` and `crypto`/`Crypto` fields → `Keystore` +/// 3. Parses strictly as a v1 Touch ID sidecar + plausible payload → `Recognized` +/// 4. Everything else → `Unknown` +fn touch_id_sidecar_state(path: &Path) -> Result { + let value = match fs::read_json_file::(path) { + Ok(v) => v, + Err(FsPathError::Read { source, .. }) if source.kind() == std::io::ErrorKind::NotFound => { + return Ok(TouchIdSidecarState::Missing); + } + Err(e) => return Err(e.into()), + }; + + if value.get("version").is_some() + && (value.get("crypto").is_some() || value.get("Crypto").is_some()) + { + return Ok(TouchIdSidecarState::Keystore); + } + + Ok(match serde_json::from_value::(value) { + Ok(wire) if wire.is_recognized() => TouchIdSidecarState::Recognized, + _ => TouchIdSidecarState::Unknown, + }) +} + +fn touch_id_sidecar_policy(path: &Path) -> Result { + let value = fs::read_json_file::(path)?; + let wire = serde_json::from_value::(value) + .wrap_err_with(|| format!("failed to parse Touch ID sidecar at {}", path.display()))?; + if !wire.is_recognized() { + eyre::bail!("{} is not a recognized Touch ID sidecar", path.display()); + } + Ok(wire.policy) +} + +fn is_touch_id_sidecar(path: &Path) -> Result { + let is_sidecar_name = path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(TOUCH_ID_SIDECAR_SUFFIX)); + Ok(is_sidecar_name && touch_id_sidecar_state(path)? == TouchIdSidecarState::Recognized) +} + +fn ensure_touch_id_sidecar_available(keystore_path: &Path) -> Result<()> { + let sidecar = touch_id_sidecar_path(keystore_path); + match touch_id_sidecar_state(&sidecar)? { + TouchIdSidecarState::Missing | TouchIdSidecarState::Recognized => Ok(()), + TouchIdSidecarState::Keystore => { + eyre::bail!( + "refusing Touch ID enrollment because {} is an existing keystore", + sidecar.display() + ); + } + TouchIdSidecarState::Unknown => { + eyre::bail!( + "refusing Touch ID enrollment because {} already exists and is not a recognized Touch ID sidecar", + sidecar.display() + ); + } + } +} + +/// Recovers the signer of `message`, interpreted as EIP-712 typed data (`data`), a prehashed +/// digest (`no_hash`) or a plain message. +fn recover_signer( + message: &str, + signature: &Signature, + data: bool, + from_file: bool, + no_hash: bool, +) -> Result
{ + Ok(if data { + let typed_data = parse_typed_data(message, from_file)?; + signature.recover_address_from_prehash(&typed_data.eip712_signing_hash()?)? + } else if no_hash { + signature.recover_address_from_prehash(&hex::decode(message)?[..].try_into()?)? + } else { + signature.recover_address_from_msg(hex_str_to_bytes(message)?)? + }) +} + +fn indexed_account_name(base: &str, number: u32, index: u32) -> String { + if number == 1 { base.to_string() } else { format!("{base}_{}", index + 1) } +} + +fn remove_touch_id_sidecar(keystore_path: &Path) -> Result { + let sidecar = touch_id_sidecar_path(keystore_path); + match touch_id_sidecar_state(&sidecar)? { + TouchIdSidecarState::Missing => Ok(false), + TouchIdSidecarState::Recognized => match std::fs::remove_file(&sidecar) { + Ok(()) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error).wrap_err_with(|| { + format!("Failed to remove Touch ID sidecar at {}", sidecar.display()) + }), + }, + TouchIdSidecarState::Keystore => { + eyre::bail!("refusing to remove existing keystore at {}", sidecar.display()); + } + TouchIdSidecarState::Unknown => { + eyre::bail!( + "refusing to remove {} because it is not a recognized Touch ID sidecar", + sidecar.display() + ); + } + } +} + +/// Enrolls a freshly written keystore for Touch ID with the default policy. +/// +/// `completed_action` describes the keystore write that already succeeded; `index` is the +/// keystore's position in a `cast wallet new --number` batch. +#[cfg(all(target_os = "macos", feature = "touch-id"))] +fn enroll_new_keystore( + keystore_path: &Path, + password: &str, + completed_action: &str, + index: usize, +) -> Result<()> { + ensure_touch_id_sidecar_available(keystore_path).map_err(|e| { + eyre::eyre!( + "{completed_action}, but Touch ID enrollment preflight failed: {e}. The sidecar was left untouched and must be resolved manually before password-prompt fallback is reliable" + ) + })?; + foundry_wallets::touch_id::enroll( + keystore_path, + password, + foundry_wallets::touch_id::Policy::default(), + ) + .map_err(|error| { + let note = if index == 0 { "" } else { " (earlier batch keystores were not rolled back)" }; + touch_id_enrollment_failure(keystore_path, &format!("{completed_action}{note}"), error) + }) +} + +/// Unreachable in practice: [`ensure_touch_id_available`] rejects `--touch-id` on this platform. +#[cfg(not(all(target_os = "macos", feature = "touch-id")))] +const fn enroll_new_keystore(_: &Path, _: &str, _: &str, _: usize) -> Result<()> { + Ok(()) +} + +#[cfg(all(target_os = "macos", feature = "touch-id"))] +fn touch_id_enrollment_failure( + keystore_path: &Path, + completed_action: &str, + enrollment_error: impl std::fmt::Display, +) -> eyre::Report { + match remove_touch_id_sidecar(keystore_path) { + Ok(true) => eyre::eyre!( + "{completed_action}, but Touch ID enrollment failed: {enrollment_error}. The stale Touch ID sidecar was removed; password-prompt fallback remains available" + ), + Ok(false) => eyre::eyre!( + "{completed_action}, but Touch ID enrollment failed: {enrollment_error}. No stale Touch ID sidecar remained; password-prompt fallback remains available" + ), + Err(cleanup_error) => eyre::eyre!( + "{completed_action}, but Touch ID enrollment failed: {enrollment_error}. The stale sidecar could not be removed: {cleanup_error}. Remove {} manually before password-prompt fallback is possible", + touch_id_sidecar_path(keystore_path).display() + ), } } @@ -805,99 +1392,216 @@ mod tests { use alloy_primitives::address; use std::str::FromStr; - #[test] - fn can_parse_wallet_sign_message() { - let args = WalletSubcommands::parse_from(["foundry-cli", "sign", "deadbeef"]); - match args { - WalletSubcommands::Sign { message, data, from_file, .. } => { - assert_eq!(message, "deadbeef".to_string()); - assert!(!data); - assert!(!from_file); - } - _ => panic!("expected WalletSubcommands::Sign"), - } + fn sidecar_json(version: u32, policy: &str, se_key: &str, sealed_password: &str) -> String { + json!({ + "version": version, + "policy": policy, + "se_key": se_key, + "sealed_password": sealed_password, + }) + .to_string() + } + + fn sealed_password(prefix: &str, len: usize) -> String { + format!("{prefix}{}", "00".repeat(len - 1)) } #[test] - fn can_parse_wallet_sign_hex_message() { - let args = WalletSubcommands::parse_from(["foundry-cli", "sign", "0xdeadbeef"]); - match args { - WalletSubcommands::Sign { message, data, from_file, .. } => { - assert_eq!(message, "0xdeadbeef".to_string()); - assert!(!data); - assert!(!from_file); - } - _ => panic!("expected WalletSubcommands::Sign"), - } + fn recovers_signer_for_each_message_kind() { + let address = address!("0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf"); // private key = 1 + + let prehash = alloy_primitives::keccak256("hello"); + let signature = Signature::from_str("433ec3d37e4f1253df15e2dea412fed8e915737730f74b3dfb1353268f932ef5557c9158e0b34bce39de28d11797b42e9b1acb2749230885fe075aedc3e491a41b").unwrap(); + assert_eq!( + recover_signer(&hex::encode(prehash), &signature, false, false, true).unwrap(), + address + ); + + let typed_data = r#"{"domain":{"name":"Test","version":"1","chainId":1,"verifyingContract":"0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF"},"message":{"value":123},"primaryType":"Data","types":{"Data":[{"name":"value","type":"uint256"}]}}"#; + let signature = Signature::from_str("0285ff83b93bd01c14e201943af7454fe2bc6c98be707a73888c397d6ae3b0b92f73ca559f81cbb19fe4e0f1dc4105bd7b647c6a84b033057977cf2ec982daf71b").unwrap(); + assert_eq!(recover_signer(typed_data, &signature, true, false, false).unwrap(), address); } #[test] - fn can_verify_signed_hex_message() { - let message = "hello"; - let signature = Signature::from_str("f2dd00eac33840c04b6fc8a5ec8c4a47eff63575c2bc7312ecb269383de0c668045309c423484c8d097df306e690c653f8e1ec92f7f6f45d1f517027771c3e801c").unwrap(); - let address = address!("0x28A4F420a619974a2393365BCe5a7b560078Cc13"); - let recovered_address = - WalletSubcommands::recover_address_from_message(message, &signature); - assert!(recovered_address.is_ok()); - assert_eq!(address, recovered_address.unwrap()); + fn new_keystores_preflight_every_touch_id_sidecar() { + let dir = tempfile::tempdir().unwrap(); + let sidecar = dir.path().join("batch_2.touchid"); + std::fs::write(&sidecar, r#"{"version":3,"crypto":{}}"#).unwrap(); + + let error = new_keystores(dir.path(), Some("batch"), Some("pw".into()), 2, false, true) + .unwrap_err(); + assert_eq!( + error.to_string(), + format!( + "refusing Touch ID enrollment because {} is an existing keystore", + sidecar.display() + ) + ); + assert!(!dir.path().join("batch_1").exists()); } #[test] - fn can_parse_wallet_sign_data() { - let args = WalletSubcommands::parse_from(["foundry-cli", "sign", "--data", "{ ... }"]); - match args { - WalletSubcommands::Sign { message, data, from_file, .. } => { - assert_eq!(message, "{ ... }".to_string()); - assert!(data); - assert!(!from_file); + fn classifies_touch_id_sidecars() { + use TouchIdSidecarState::*; + + let valid_sealed = sealed_password("04", TOUCH_ID_SEALED_PASSWORD_MIN_LEN); + let cases = [ + (None, Missing), + (Some(sidecar_json(1, "user-presence", "aa", &valid_sealed)), Recognized), + (Some(sidecar_json(1, "current-biometry", "aa", &valid_sealed)), Recognized), + (Some(r#"{"version":3,"crypto":{}}"#.to_string()), Keystore), + (Some(r#"{"version":3,"Crypto":{}}"#.to_string()), Keystore), + (Some("{}".to_string()), Unknown), + (Some("[]".to_string()), Unknown), + (Some(r#"{"application":"unrelated"}"#.to_string()), Unknown), + // unsupported version + (Some(sidecar_json(2, "user-presence", "aa", &valid_sealed)), Unknown), + // unknown field is rejected by `deny_unknown_fields` + ( + Some( + json!({ + "version": 1, + "policy": "user-presence", + "se_key": "aa", + "sealed_password": valid_sealed, + "future_field": true + }) + .to_string(), + ), + Unknown, + ), + (Some(sidecar_json(1, "future-policy", "aa", &valid_sealed)), Unknown), + // invalid payloads + (Some(sidecar_json(1, "user-presence", "", &valid_sealed)), Unknown), + (Some(sidecar_json(1, "user-presence", "zz", &valid_sealed)), Unknown), + (Some(sidecar_json(1, "user-presence", "aa", "")), Unknown), + (Some(sidecar_json(1, "user-presence", "aa", "zz")), Unknown), + ( + Some(sidecar_json( + 1, + "user-presence", + "aa", + &sealed_password("04", TOUCH_ID_SEALED_PASSWORD_MIN_LEN - 1), + )), + Unknown, + ), + ( + Some(sidecar_json( + 1, + "user-presence", + "aa", + &sealed_password("03", TOUCH_ID_SEALED_PASSWORD_MIN_LEN), + )), + Unknown, + ), + ]; + + for (content, expected) in cases { + let dir = tempfile::tempdir().unwrap(); + let keystore = dir.path().join("account"); + let sidecar = touch_id_sidecar_path(&keystore); + if let Some(content) = &content { + std::fs::write(&sidecar, content).unwrap(); + } + + assert_eq!(touch_id_sidecar_state(&sidecar).unwrap(), expected, "{content:?}"); + assert_eq!(is_touch_id_sidecar(&sidecar).unwrap(), expected == Recognized); + + // Enrollment preflight and removal only ever touch recognized sidecars. + let preflight = ensure_touch_id_sidecar_available(&keystore); + let removal = remove_touch_id_sidecar(&keystore); + match expected { + Missing => { + preflight.unwrap(); + assert!(!removal.unwrap()); + } + Recognized => { + preflight.unwrap(); + assert!(removal.unwrap()); + assert!(!sidecar.exists()); + } + Keystore => { + let err = preflight.unwrap_err().to_string(); + assert!(err.contains("is an existing keystore"), "{err}"); + let err = removal.unwrap_err().to_string(); + assert!(err.contains("refusing to remove existing keystore"), "{err}"); + } + Unknown => { + for err in [preflight.unwrap_err(), removal.unwrap_err()] { + let err = err.to_string(); + assert!(err.contains("is not a recognized Touch ID sidecar"), "{err}"); + } + } + } + if expected != Recognized { + assert_eq!( + std::fs::read_to_string(&sidecar).ok(), + content, + "file must be untouched" + ); } - _ => panic!("expected WalletSubcommands::Sign"), } } #[test] - fn can_parse_wallet_sign_data_file() { - let args = WalletSubcommands::parse_from([ + fn malformed_json_propagates_error() { + let dir = tempfile::tempdir().unwrap(); + let sidecar = dir.path().join("account.touchid"); + std::fs::write(&sidecar, "not json").unwrap(); + // Malformed JSON is an I/O/parse error, not Unknown. + assert!(is_touch_id_sidecar(&sidecar).is_err()); + assert!(touch_id_sidecar_state(&sidecar).is_err()); + } + + #[test] + fn wallet_sign_auth_nonce_and_self_broadcast_conflict() { + let result = WalletSubcommands::try_parse_from([ "foundry-cli", - "sign", - "--data", - "--from-file", - "tests/data/typed_data.json", + "sign-auth", + "0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF", + "--nonce", + "42", + "--self-broadcast", ]); - match args { - WalletSubcommands::Sign { message, data, from_file, .. } => { - assert_eq!(message, "tests/data/typed_data.json".to_string()); - assert!(data); - assert!(from_file); - } - _ => panic!("expected WalletSubcommands::Sign"), + assert!( + result.is_err(), + "expected error when both --nonce and --self-broadcast are provided" + ); + } + + #[test] + fn rejects_path_keystore_account_name() { + assert!(ensure_account_name_available("dev").is_ok()); + assert!(ensure_account_name_available("testAccount").is_ok()); + for invalid in ["../pwned", "nested/alias", "foo/../bar", "..", ".", "", "foo\\bar"] { + assert!(ensure_account_name_available(invalid).is_err(), "{invalid:?}"); } } #[test] - fn can_parse_wallet_change_password() { - let args = WalletSubcommands::parse_from([ - "foundry-cli", - "change-password", - "my_account", - "--unsafe-password", - "old_password", - "--unsafe-new-password", - "new_password", - ]); + fn can_parse_wallet_new_bare_account_name() { + let args = WalletSubcommands::parse_from(["foundry-cli", "new", "my-wallet"]); match args { - WalletSubcommands::ChangePassword { - account_name, - keystore_dir, - unsafe_password, - unsafe_new_password, - } => { - assert_eq!(account_name, "my_account".to_string()); - assert_eq!(unsafe_password, Some("old_password".to_string())); - assert_eq!(unsafe_new_password, Some("new_password".to_string())); - assert!(keystore_dir.is_none()); + WalletSubcommands::New { path, account_name, .. } => { + assert_eq!(path.as_deref(), Some("my-wallet")); + assert_eq!(account_name, None); } - _ => panic!("expected WalletSubcommands::ChangePassword"), + _ => panic!("expected WalletSubcommands::New"), } } + + #[test] + fn bare_account_name_heuristic() { + assert!(is_bare_account_name("my-wallet")); + assert!(is_bare_account_name("dev")); + assert!(!is_bare_account_name("")); + assert!(!is_bare_account_name(".")); + assert!(!is_bare_account_name("..")); + assert!(!is_bare_account_name("./missing-dir")); + assert!(!is_bare_account_name("missing-dir/")); + assert!(!is_bare_account_name("/tmp/keystores")); + assert!(!is_bare_account_name(r"C:\keystores")); + assert!(!is_bare_account_name("C:foo")); + assert!(!is_bare_account_name("foo:bar")); + } } diff --git a/crates/cast/src/cmd/wallet/process_tree.rs b/crates/cast/src/cmd/wallet/process_tree.rs new file mode 100644 index 0000000000000..8a9355846bed6 --- /dev/null +++ b/crates/cast/src/cmd/wallet/process_tree.rs @@ -0,0 +1,255 @@ +use std::{ + io, + process::{Command, ExitStatus}, + time::Duration, +}; + +use tokio::process::{Child, Command as TokioCommand}; + +const TERMINATION_GRACE: Duration = Duration::from_millis(250); + +pub(super) struct ManagedChild { + child: Child, + group: PlatformProcessGroup, + waited: bool, +} + +impl ManagedChild { + pub(super) fn spawn(command: Command) -> io::Result { + let mut command = TokioCommand::from(command); + let group = PlatformProcessGroup::configure(&mut command)?; + let child = command.kill_on_drop(true).spawn()?; + let group = group.attach(&child)?; + Ok(Self { child, group, waited: false }) + } + + pub(super) async fn wait(&mut self) -> io::Result { + let status = self.child.wait().await?; + self.waited = true; + Ok(status) + } + + pub(super) async fn terminate_tree(&mut self) -> io::Result<()> { + if self.group.terminate()? { + tokio::time::sleep(TERMINATION_GRACE).await; + self.group.kill()?; + } + + if self.waited { + return Ok(()); + } + + match tokio::time::timeout(TERMINATION_GRACE, self.child.wait()).await { + Ok(result) => { + self.waited = true; + result.map(|_| ()) + } + Err(_) => { + self.child.start_kill()?; + self.child.wait().await?; + self.waited = true; + Ok(()) + } + } + } +} + +#[cfg(unix)] +struct PlatformProcessGroup { + pgid: Option, +} + +#[cfg(unix)] +impl PlatformProcessGroup { + fn configure(command: &mut TokioCommand) -> io::Result { + command.process_group(0); + Ok(Self { pgid: None }) + } + + fn attach(mut self, child: &Child) -> io::Result { + self.pgid = child.id().map(|id| id as libc::pid_t); + Ok(self) + } + + fn terminate(&mut self) -> io::Result { + let Some(pgid) = self.pgid else { + return Ok(false); + }; + signal_process_group(pgid, libc::SIGTERM) + } + + fn kill(&mut self) -> io::Result<()> { + let Some(pgid) = self.pgid.take() else { + return Ok(()); + }; + signal_process_group(pgid, libc::SIGKILL).map(|_| ()) + } +} + +#[cfg(unix)] +fn signal_process_group(pgid: libc::pid_t, signal: libc::c_int) -> io::Result { + // SAFETY: negative pid targets the process group created for the child process. + let rc = unsafe { libc::kill(-pgid, signal) }; + if rc == 0 { + Ok(true) + } else { + let err = io::Error::last_os_error(); + if err.raw_os_error() == Some(libc::ESRCH) { Ok(false) } else { Err(err) } + } +} + +#[cfg(windows)] +struct PlatformProcessGroup { + job: Option, +} + +#[cfg(windows)] +impl PlatformProcessGroup { + fn configure(_command: &mut TokioCommand) -> io::Result { + Ok(Self { job: Some(WindowsJob::new()?) }) + } + + fn attach(self, child: &Child) -> io::Result { + if let Some(job) = &self.job { + let handle = child.raw_handle().ok_or_else(|| { + io::Error::new(io::ErrorKind::Other, "session child exited before job assignment") + })?; + job.assign_process(handle)?; + } + Ok(self) + } + + fn terminate(&mut self) -> io::Result { + if let Some(job) = self.job.take() { + job.terminate()?; + return Ok(true); + } + Ok(false) + } + + fn kill(&mut self) -> io::Result<()> { + Ok(()) + } +} + +#[cfg(windows)] +struct WindowsJob { + handle: windows_sys::Win32::Foundation::HANDLE, +} + +#[cfg(windows)] +impl WindowsJob { + fn new() -> io::Result { + use windows_sys::Win32::{ + Foundation::INVALID_HANDLE_VALUE, + System::JobObjects::{ + CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, + SetInformationJobObject, + }, + }; + + let handle = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) }; + if handle.is_null() || handle == INVALID_HANDLE_VALUE { + return Err(io::Error::last_os_error()); + } + + let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + let ok = unsafe { + SetInformationJobObject( + handle, + JobObjectExtendedLimitInformation, + std::ptr::addr_of!(limits).cast(), + std::mem::size_of_val(&limits) as u32, + ) + }; + if ok == 0 { + let err = io::Error::last_os_error(); + unsafe { + windows_sys::Win32::Foundation::CloseHandle(handle); + } + return Err(err); + } + + Ok(Self { handle }) + } + + fn assign_process(&self, process: std::os::windows::io::RawHandle) -> io::Result<()> { + use windows_sys::Win32::System::JobObjects::AssignProcessToJobObject; + + let ok = unsafe { AssignProcessToJobObject(self.handle, process.cast()) }; + if ok == 0 { Err(io::Error::last_os_error()) } else { Ok(()) } + } + + fn terminate(self) -> io::Result<()> { + use windows_sys::Win32::System::JobObjects::TerminateJobObject; + + let ok = unsafe { TerminateJobObject(self.handle, 1) }; + let err = if ok == 0 { Some(io::Error::last_os_error()) } else { None }; + drop(self); + match err { + Some(err) => Err(err), + None => Ok(()), + } + } +} + +#[cfg(windows)] +impl Drop for WindowsJob { + fn drop(&mut self) { + unsafe { + windows_sys::Win32::Foundation::CloseHandle(self.handle); + } + } +} + +#[cfg(not(any(unix, windows)))] +struct PlatformProcessGroup; + +#[cfg(not(any(unix, windows)))] +impl PlatformProcessGroup { + fn configure(_command: &mut TokioCommand) -> io::Result { + Ok(Self) + } + + fn attach(self, _child: &Child) -> io::Result { + Ok(self) + } + + fn terminate(&mut self) -> io::Result { + Ok(false) + } + + fn kill(&mut self) -> io::Result<()> { + Ok(()) + } +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + + #[test] + fn cleanup_terminates_background_grandchild() { + let runtime = tokio::runtime::Runtime::new().unwrap(); + runtime.block_on(async { + let tmp = tempfile::tempdir().unwrap(); + let marker = tmp.path().join("session-child-leaked"); + let mut command = Command::new("sh"); + command.args([ + "-c", + "(sleep 1; touch \"$1\") &", + "session-child", + &marker.to_string_lossy(), + ]); + + let mut child = ManagedChild::spawn(command).unwrap(); + child.wait().await.unwrap(); + child.terminate_tree().await.unwrap(); + tokio::time::sleep(Duration::from_millis(1200)).await; + + assert!(!marker.exists(), "background grandchild escaped session cleanup"); + }); + } +} diff --git a/crates/cast/src/cmd/wallet/session.rs b/crates/cast/src/cmd/wallet/session.rs new file mode 100644 index 0000000000000..0c4c3a9f2b8d0 --- /dev/null +++ b/crates/cast/src/cmd/wallet/session.rs @@ -0,0 +1,912 @@ +use alloy_primitives::{Address, B256, U256}; +use alloy_provider::Provider; +use alloy_signer::Signer; +use alloy_sol_types::SolCall; +use clap::{Args, Parser}; +use eyre::{Context, Result}; +use foundry_cli::{ + opts::{TEMPO_SESSION_ID_ENV, TransactionOpts}, + utils::{LoadConfig, now, parse_fee_token_address}, +}; +use foundry_common::{ + provider::ProviderBuilder, + sh_println, shell, + tempo::{ + GeneratedSessionKey, SessionAuthorizationRequest, SessionEntry, SessionSpendLimit, + read_session_entry, retire_session_entry, upsert_session_entry, + }, +}; +use foundry_wallets::{WalletOpts, WalletSigner}; +use serde_json::json; +use std::{ + num::NonZeroU64, + process::{Command, ExitStatus}, +}; +use tempo_alloy::{TempoNetwork, provider::TempoProviderExt}; +use tempo_contracts::precompiles::IAccountKeychain; +use tempo_primitives::transaction::{CallScope, PrimitiveSignature, SelectorRule}; +use tokio::signal; + +use crate::{ + cmd::{ + keychain::{ + KeychainTxOutcome, resolve_keychain_root_signer, send_keychain_tx_with_root_signer, + }, + print_json_or, + tempo_policy_args::{ + parse_period, parse_scope as parse_policy_scope, parse_selector_bytes, + }, + }, + tempo, + tx::SendTxOpts, +}; + +use super::process_tree::ManagedChild; + +const PRINT_SPONSOR_HASH_REVOKE_ERROR: &str = "--tempo.print-sponsor-hash only prints a sponsor hash and does not revoke the session on-chain"; +/// Signer-related environment variables that `foundry_wallets` resolves signers from. They are +/// removed from the `--for` child so it can only sign with the injected session key. +const SESSION_CHILD_SIGNER_ENV: &[&str] = &[ + "ETH_KEYSTORE", + "ETH_KEYSTORE_ACCOUNT", + "ETH_PASSWORD", + "TEMPO_ACCESS_KEY", + "TEMPO_ROOT_ACCOUNT", + "AWS_KMS_KEY_ID", + "AWS_KMS_KEY_IDS", + "GCP_PROJECT_ID", + "GCP_LOCATION", + "GCP_KEY_RING", + "GCP_KEY_NAME", + "GCP_KEY_VERSION", + "TURNKEY_API_PRIVATE_KEY", + "TURNKEY_ORGANIZATION_ID", + "TURNKEY_ADDRESS", +]; + +/// Arguments for `cast wallet session`. +/// +/// Without a subcommand, this runs an issue-style temporary session around `--for `. +/// The existing `create` and `revoke` subcommands remain explicit lifecycle operations. +#[derive(Debug, Args)] +#[command(args_conflicts_with_subcommands = true)] +pub struct SessionArgs { + #[command(subcommand)] + pub command: Option, + + /// Skip the EIP-7702 authorization disclosure confirmation. + #[arg(long)] + pub force: bool, + + /// Root account that will authorize the temporary session. + #[arg(long = "root", value_name = "ADDRESS")] + pub root_account: Option
, + + /// Session lifetime, expressed as a duration like `10m`, `2h`, or `7d`. + #[arg(long = "expires", id = "session_expires", value_name = "DURATION", value_parser = parse_period)] + pub expires: Option, + + /// Allowed call scope, in `TARGET[:SELECTORS[@RECIPIENTS]]` format. + #[arg(long = "scope", value_parser = parse_scope)] + pub scope: Vec, + + /// Allowed call target for issue-style `--target ... --selector ...` input. + #[arg(long = "target", value_name = "ADDRESS")] + pub target: Option
, + + /// Function selector allowed for `--target`, such as `register(address)`. + #[arg(long = "selector", value_name = "SELECTOR")] + pub selectors: Vec, + + /// Token spend limit, in `TOKEN:AMOUNT` or `TOKEN=AMOUNT` format. + #[arg(long = "spend-limit", value_parser = parse_spend_limit)] + pub spend_limits: Vec, + + /// Command to run with the temporary Tempo session. + #[arg(long = "for", value_name = "COMMAND")] + pub for_command: Option, + + #[command(flatten)] + pub tx: Box, + + #[command(flatten)] + pub send_tx: Box, +} + +impl SessionArgs { + pub async fn run(self) -> Result<()> { + let Self { + command, + force, + root_account, + expires, + scope, + target, + selectors, + spend_limits, + for_command, + tx, + send_tx, + } = self; + + if let Some(command) = command { + return command.run().await; + } + + let root_account = + root_account.ok_or_else(|| eyre::eyre!("cast wallet session requires --root"))?; + let expires = + expires.ok_or_else(|| eyre::eyre!("cast wallet session requires --expires"))?; + let command = + for_command.ok_or_else(|| eyre::eyre!("cast wallet session requires --for"))?; + let command = InnerCommand::parse(command)?; + let scope = session_scope(scope, target, selectors)?; + let send_tx = *send_tx; + let chain_id = resolve_session_chain_id(&send_tx).await?; + + let tx = *tx; + if tx.tempo.print_sponsor_hash { + eyre::bail!(PRINT_SPONSOR_HASH_REVOKE_ERROR); + } + + let entry = build_session_entry( + root_account, + chain_id, + expires, + scope, + spend_limits, + send_tx.eth.wallet.clone(), + ) + .await?; + let session_id = entry.session_id; + upsert_session_entry(entry)?; + + let child_result = command.run(session_id).await; + + // Always retire the local key material, then revoke on-chain; the on-chain error takes + // precedence when both fail. + let retire_result = retire_session_entry(session_id) + .map(drop) + .wrap_err_with(|| format!("failed to retire local Tempo session {session_id:?}")); + let revoke_result = + revoke(session_id, false, tx, send_tx, UnprovisionedKeyPolicy::Fail, force).await; + let cleanup_result = match (retire_result, revoke_result) { + (Ok(()), result) | (result, Ok(())) => result, + (Err(retire_err), Err(revoke_err)) => Err(revoke_err + .wrap_err(format!("also failed to retire local Tempo session: {retire_err}"))), + }; + + // The inner command's error takes precedence over cleanup failures. + match (child_result, cleanup_result) { + (Ok(()), Err(cleanup_err)) => { + Err(cleanup_err.wrap_err("failed to clean up Tempo session after inner command")) + } + (Err(child_err), Err(cleanup_err)) => Err(child_err.wrap_err(format!( + "also failed to clean up Tempo session {session_id:?}: {cleanup_err}" + ))), + (child_result, Ok(())) => child_result, + } + } +} + +/// Tempo wallet session lifecycle commands. +#[derive(Debug, Parser)] +pub enum SessionSubcommands { + /// Create a temporary Tempo session and persist it locally. + Create { + /// Root account that will authorize the session. + #[arg(long = "root", value_name = "ADDRESS")] + root_account: Address, + + /// Chain ID the session is valid on. + #[arg(long = "chain-id", value_name = "CHAIN_ID")] + chain_id: u64, + + /// Session lifetime, expressed as a duration like `10m`, `2h`, or `7d`. + #[arg(long = "expires", value_name = "DURATION", value_parser = parse_period)] + expires: u64, + + /// Allowed call scope, in `TARGET[:SELECTORS[@RECIPIENTS]]` format. + #[arg(long = "scope", value_parser = parse_scope, required = true)] + scope: Vec, + + /// Token spend limit, in `TOKEN:AMOUNT` or `TOKEN=AMOUNT` format. + #[arg(long = "spend-limit", value_parser = parse_spend_limit)] + spend_limits: Vec, + + #[command(flatten)] + wallet: Box, + }, + + /// Revoke a Tempo session key on-chain when provisioned, then clear local key material. + Revoke { + /// Session identifier to revoke. + #[arg(value_name = "SESSION_ID")] + session_id: B256, + + /// Only clear local session key material; do not query or submit an on-chain revoke. + #[arg(long)] + local: bool, + + /// Skip the EIP-7702 authorization disclosure confirmation. + #[arg(long)] + force: bool, + + #[command(flatten)] + tx: Box, + + #[command(flatten)] + send_tx: Box, + }, +} + +impl SessionSubcommands { + pub async fn run(self) -> Result<()> { + match self { + Self::Create { root_account, chain_id, expires, scope, spend_limits, wallet } => { + create(root_account, chain_id, expires, scope, spend_limits, *wallet).await + } + Self::Revoke { session_id, local, force, tx, send_tx } => { + revoke( + session_id, + local, + *tx, + *send_tx, + UnprovisionedKeyPolicy::RevokeLocally, + force, + ) + .await + } + } + } +} + +#[derive(Debug)] +struct InnerCommand { + raw: String, + program: String, + args: Vec, +} + +impl InnerCommand { + fn parse(raw: String) -> Result { + let mut argv = split_for_command(&raw)?.into_iter(); + let program = argv.next().ok_or_else(|| eyre::eyre!("--for command cannot be empty"))?; + let args = argv.collect(); + Ok(Self { raw, program, args }) + } + + async fn run(&self, session_id: B256) -> Result<()> { + let mut interrupt = SessionInterrupt::new()?; + self.run_with_interrupt(session_id, interrupt.recv()).await + } + + async fn run_with_interrupt(&self, session_id: B256, interrupt: I) -> Result<()> + where + I: std::future::Future>, + { + let mut child = ManagedChild::spawn(self.command(session_id)) + .wrap_err_with(|| format!("failed to run inner command `{}`", self.raw))?; + + let status = tokio::select! { + status = child.wait() => status.wrap_err_with(|| { + format!("failed to wait for inner command `{}`", self.raw) + })?, + interrupt = interrupt => { + let _ = child.terminate_tree().await; + let interrupt = interrupt?; + eyre::bail!("inner command `{}` interrupted by {interrupt}", self.raw); + } + }; + + let _ = child.terminate_tree().await; + + self.check_status(status) + } + + fn command(&self, session_id: B256) -> Command { + let mut command = Command::new(&self.program); + command.args(&self.args); + for key in SESSION_CHILD_SIGNER_ENV { + command.env_remove(key); + } + command.env(TEMPO_SESSION_ID_ENV, format!("{session_id:?}")); + command + } + + fn check_status(&self, status: ExitStatus) -> Result<()> { + if status.success() { + return Ok(()); + } + match status.code() { + Some(code) => eyre::bail!("inner command `{}` exited with code {code}", self.raw), + None => eyre::bail!("inner command `{}` terminated by a signal", self.raw), + } + } +} + +#[cfg(unix)] +struct SessionInterrupt { + sigint: signal::unix::Signal, + sigterm: signal::unix::Signal, +} + +#[cfg(unix)] +impl SessionInterrupt { + fn new() -> Result { + Ok(Self { + sigint: signal::unix::signal(signal::unix::SignalKind::interrupt()) + .wrap_err("failed to listen for SIGINT")?, + sigterm: signal::unix::signal(signal::unix::SignalKind::terminate()) + .wrap_err("failed to listen for SIGTERM")?, + }) + } + + async fn recv(&mut self) -> Result<&'static str> { + tokio::select! { + _ = self.sigint.recv() => Ok("SIGINT"), + _ = self.sigterm.recv() => Ok("SIGTERM"), + } + } +} + +#[cfg(not(unix))] +struct SessionInterrupt; + +#[cfg(not(unix))] +impl SessionInterrupt { + fn new() -> Result { + Ok(Self) + } + + async fn recv(&mut self) -> Result<&'static str> { + signal::ctrl_c().await.wrap_err("failed to listen for Ctrl-C")?; + Ok("Ctrl-C") + } +} + +async fn resolve_session_chain_id(send_tx: &SendTxOpts) -> Result { + let config = send_tx.eth.load_config()?; + if let Some(chain) = config.chain { + return Ok(chain.id()); + } + + let provider = ProviderBuilder::::from_config(&config)?.build()?; + provider.get_chain_id().await.wrap_err( + "failed to resolve session chain id from RPC; pass --chain/--chain-id or --rpc-url", + ) +} + +fn session_scope( + mut scope: Vec, + target: Option
, + selectors: Vec, +) -> Result> { + match target { + None if !selectors.is_empty() => eyre::bail!("--selector requires --target"), + Some(_) if selectors.is_empty() => eyre::bail!( + "--target requires at least one --selector; use --scope TARGET for target-wide access" + ), + Some(target) => { + let selector_rules = selectors + .iter() + .map(|selector| { + parse_selector_bytes(selector) + .map(|selector| SelectorRule { selector, recipients: vec![] }) + .map_err(|err| eyre::eyre!("{err}")) + }) + .collect::>>()?; + scope.push(CallScope { target, selector_rules }); + } + None => {} + } + + if scope.is_empty() { + eyre::bail!("cast wallet session requires --scope or --target"); + } + + Ok(scope) +} + +fn split_for_command(command: &str) -> Result> { + let mut args = Vec::new(); + let mut current = String::new(); + let mut quote = None; + let mut escaped = false; + let mut in_token = false; + + for ch in command.chars() { + if escaped { + current.push(ch); + escaped = false; + in_token = true; + continue; + } + + match quote { + Some('\'') => { + if ch == '\'' { + quote = None; + } else { + current.push(ch); + } + } + Some('"') => { + if ch == '"' { + quote = None; + } else if ch == '\\' { + escaped = true; + } else { + current.push(ch); + } + } + Some(_) => unreachable!(), + None if ch.is_whitespace() => { + if in_token { + args.push(std::mem::take(&mut current)); + in_token = false; + } + } + None if ch == '\'' || ch == '"' => { + quote = Some(ch); + in_token = true; + } + None if ch == '\\' => { + escaped = true; + in_token = true; + } + None => { + current.push(ch); + in_token = true; + } + } + } + + if escaped { + eyre::bail!("unterminated escape in --for command"); + } + if let Some(quote) = quote { + eyre::bail!("unterminated {quote} quote in --for command"); + } + if in_token { + args.push(current); + } + Ok(args) +} + +/// Creates a signed temporary access key in the Tempo Accounts store. +async fn create( + root_account: Address, + chain_id: u64, + expires: u64, + scope: Vec, + spend_limits: Vec, + wallet: WalletOpts, +) -> Result<()> { + let entry = + build_session_entry(root_account, chain_id, expires, scope, spend_limits, wallet).await?; + let json = json!({ + "session_id": entry.session_id.to_string(), + "root_account": entry.root_account.to_string(), + "chain_id": entry.chain_id, + "key_address": entry.key_address.to_string(), + "expiry": entry.expiry, + "status": "active", + "scope_count": entry.scope.as_ref().map_or(0, Vec::len), + "spend_limit_count": entry.limits.as_ref().map_or(0, Vec::len), + }); + let prose = format!( + "Created Tempo session {}\nRoot: {}\nChain: {}\nKey: {}\nExpiry: {}", + entry.session_id, entry.root_account, entry.chain_id, entry.key_address, entry.expiry + ); + upsert_session_entry(entry)?; + + print_json_or(json, prose) +} + +/// How to treat a session key that was never provisioned on-chain when revoking it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum UnprovisionedKeyPolicy { + /// Explicit `revoke`: mark the key revoked locally. + RevokeLocally, + /// Automatic `--for` cleanup: fail, since pending transactions may still provision it. + Fail, +} + +/// Revokes a session entry locally and on-chain when the key has been provisioned. +async fn revoke( + session_id: B256, + local: bool, + tx: TransactionOpts, + send_tx: SendTxOpts, + unprovisioned_policy: UnprovisionedKeyPolicy, + force: bool, +) -> Result<()> { + let Some(entry) = read_session_entry(session_id)? else { + return print_revoke_status(session_id, None, SessionRevokeStatus::NotFound); + }; + + if local { + retire_session_entry(session_id)?; + return print_revoke_status(session_id, Some(&entry), SessionRevokeStatus::Local); + } + + if tx.tempo.print_sponsor_hash { + eyre::bail!(PRINT_SPONSOR_HASH_REVOKE_ERROR); + } + + let (_, provider) = tempo::tempo_provider(&send_tx.eth)?; + let rpc_chain_id = provider.get_chain_id().await?; + if rpc_chain_id != entry.chain_id { + eyre::bail!( + "session {} was created for chain {}, but the RPC is connected to chain {}", + entry.session_id, + entry.chain_id, + rpc_chain_id + ); + } + + let info = provider.get_keychain_key(entry.root_account, entry.key_address).await?; + if info.isRevoked { + retire_session_entry(session_id)?; + return print_revoke_status(session_id, Some(&entry), SessionRevokeStatus::AlreadyRevoked); + } + if info.keyId.is_zero() { + return match unprovisioned_policy { + UnprovisionedKeyPolicy::RevokeLocally => { + retire_session_entry(session_id)?; + print_revoke_status(session_id, Some(&entry), SessionRevokeStatus::NotProvisioned) + } + UnprovisionedKeyPolicy::Fail => eyre::bail!( + "session key is not provisioned on-chain yet; pending transactions from the \ + wrapped command may still provision it. Wait for pending transactions to settle, \ + then run `cast wallet session revoke {session_id}`." + ), + }; + } + + let root_signer = + resolve_keychain_root_signer(&send_tx, Some(entry.root_account), false).await?; + let calldata = IAccountKeychain::revokeKeyCall { keyId: entry.key_address }.abi_encode(); + let outcome = + send_keychain_tx_with_root_signer(calldata, tx, &send_tx, root_signer, force, || { + retire_session_entry(session_id).map(drop) + }) + .await + .and_then(|outcome| { + if outcome == KeychainTxOutcome::PrintedSponsorHash { + eyre::bail!(PRINT_SPONSOR_HASH_REVOKE_ERROR); + } + Ok(outcome) + }); + let outcome = match outcome { + Ok(outcome) => outcome, + Err(err) => { + // The key may have been revoked despite the error; retire the local copy if so. + if provider + .get_keychain_key(entry.root_account, entry.key_address) + .await + .is_ok_and(|info| info.isRevoked) + { + let _ = retire_session_entry(session_id); + } + return Err(err.wrap_err("failed to revoke Tempo session key on-chain")); + } + }; + + if outcome == KeychainTxOutcome::Aborted { + // Automatic cleanup uses `Fail` and must report an aborted on-chain revoke. + if unprovisioned_policy == UnprovisionedKeyPolicy::Fail { + eyre::bail!("EIP-7702 authorization disclosure was declined"); + } + return Ok(()); + } + + retire_session_entry(session_id)?; + Ok(()) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SessionRevokeStatus { + NotFound, + Local, + NotProvisioned, + AlreadyRevoked, +} + +impl SessionRevokeStatus { + const fn reason(self) -> &'static str { + match self { + Self::NotFound => "not_found", + Self::Local => "local", + Self::NotProvisioned => "not_provisioned", + Self::AlreadyRevoked => "already_revoked", + } + } +} + +fn print_revoke_status( + session_id: B256, + entry: Option<&SessionEntry>, + status: SessionRevokeStatus, +) -> Result<()> { + if shell::is_json() { + return sh_println!( + "{}", + serde_json::to_string_pretty(&json!({ + "session_id": session_id.to_string(), + "status": if status == SessionRevokeStatus::NotFound { "not_found" } else { "revoked" }, + "reason": status.reason(), + "root_account": entry.map(|entry| entry.root_account.to_string()), + "chain_id": entry.map(|entry| entry.chain_id), + "key_address": entry.map(|entry| entry.key_address.to_string()), + }))? + ); + } + + match status { + SessionRevokeStatus::NotFound => sh_status!("Tempo session {session_id} was not found."), + SessionRevokeStatus::Local => sh_status!("Revoked local Tempo session {session_id}"), + SessionRevokeStatus::NotProvisioned => sh_status!( + "Revoked Tempo session {session_id} locally; key was not provisioned on-chain" + ), + SessionRevokeStatus::AlreadyRevoked => sh_status!( + "Revoked Tempo session {session_id} locally; key was already revoked on-chain" + ), + } +} + +/// Builds an active session entry from CLI policy inputs and a root signature. +async fn build_session_entry( + root_account: Address, + chain_id: u64, + expires: u64, + scope: Vec, + spend_limits: Vec, + wallet: WalletOpts, +) -> Result { + if expires == 0 { + eyre::bail!("--expires must be greater than 0"); + } + if chain_id == 0 { + eyre::bail!("--chain-id must be greater than 0"); + } + if wallet.from.is_some_and(|from| from != root_account) { + eyre::bail!("--from must match --root for cast wallet session create"); + } + + let signer = resolve_root_signer(wallet, root_account, chain_id).await?; + let session_key = GeneratedSessionKey::random(); + let session_id = B256::random(); + let now_secs = now().as_secs(); + let expiry = now_secs + .checked_add(expires) + .ok_or_else(|| eyre::eyre!("session expiry overflows the unix timestamp range"))?; + let expiry = + NonZeroU64::new(expiry).ok_or_else(|| eyre::eyre!("session expiry cannot be zero"))?; + + let request = SessionAuthorizationRequest { + session_id, + root_account, + chain_id, + key_address: session_key.address(), + expiry, + scope, + spend_limits, + }; + let prepared = request.prepare(now_secs)?; + let signature = signer.sign_hash(&prepared.authorization.signature_hash()).await?; + let signed_authorization = + prepared.authorization.clone().into_signed(PrimitiveSignature::Secp256k1(signature)); + prepared.into_active_entry(session_key, &signed_authorization) +} + +async fn resolve_root_signer( + wallet: WalletOpts, + root_account: Address, + chain_id: u64, +) -> Result { + let (signer, tempo_access_key) = wallet.maybe_signer_for_chain(chain_id).await?; + if tempo_access_key.is_some() { + eyre::bail!( + "Tempo access keys cannot authorize Tempo sessions; use a persistent root signer" + ); + } + + let signer = signer.ok_or_else(|| eyre::eyre!("a root wallet signer is required"))?; + let signer_address = signer.address(); + if signer_address != root_account { + eyre::bail!("resolved signer {} does not match --root {}", signer_address, root_account); + } + + Ok(signer) +} + +/// Adapts shared keychain scope parsing into the session authorization type. +fn parse_scope(s: &str) -> Result { + parse_policy_scope(s).map(CallScope::from) +} + +/// Parses a session spend limit into the session policy model. +fn parse_spend_limit(s: &str) -> Result { + let Some((token_str, amount_str)) = s.split_once(':').or_else(|| s.split_once('=')) else { + return Err(format!("invalid limit format: {s} (expected TOKEN:AMOUNT or TOKEN=AMOUNT)")); + }; + + let token = parse_fee_token_address(token_str.trim()).map_err(|e| e.to_string())?; + let amount: U256 = + amount_str.trim().parse().map_err(|e| format!("invalid amount '{amount_str}': {e}"))?; + Ok(SessionSpendLimit { token, amount }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cmd::wallet::raw_wallet; + use alloy_primitives::address; + use foundry_common::tempo::SessionStatus; + use foundry_wallets::RawWalletOpts; + use std::{ffi::OsStr, sync::Mutex}; + use tempo_contracts::precompiles::PATH_USD_ADDRESS; + + const ROOT_PRIVATE_KEY: &str = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + + static ENV_MUTEX: Mutex<()> = Mutex::new(()); + + fn with_tempo_home(test: impl FnOnce()) { + let _guard = ENV_MUTEX.lock().unwrap(); + let tmp = tempfile::tempdir().unwrap(); + // SAFETY: tests serialize all Tempo environment mutation through the mutex. + unsafe { std::env::set_var("TEMPO_HOME", tmp.path()) }; + test(); + // SAFETY: restore the process environment after the critical section. + unsafe { std::env::remove_var("TEMPO_HOME") }; + } + + #[test] + fn parse_spend_limit_accepts_fee_token_symbol() { + let limit = parse_spend_limit("PathUSD=0").unwrap(); + assert_eq!(limit.token, PATH_USD_ADDRESS); + assert_eq!(limit.amount, U256::ZERO); + } + + #[test] + fn inner_command_parse_preserves_literal_argv() { + let raw = + r#"forge script "Deploy Script" --sig 'run(uint256)' value\ with\ spaces #literal"#; + let command = InnerCommand::parse(raw.to_string()).unwrap(); + + assert_eq!(command.raw, raw); + assert_eq!(command.program, "forge"); + assert_eq!( + command.args, + ["script", "Deploy Script", "--sig", "run(uint256)", "value with spaces", "#literal",] + ); + } + + #[test] + fn inner_command_parse_rejects_invalid_input() { + let err = InnerCommand::parse(" ".to_string()).unwrap_err(); + assert!(err.to_string().contains("--for command cannot be empty"), "{err}"); + + let err = InnerCommand::parse("forge 'script".to_string()).unwrap_err(); + assert!(err.to_string().contains("unterminated"), "{err}"); + } + + #[test] + fn session_scope_target_shortcut() { + let target = Address::with_last_byte(0xaa); + let err = session_scope(vec![], Some(target), vec![]).unwrap_err(); + assert!(err.to_string().contains("--target requires at least one --selector"), "{err}"); + + // an explicit `--scope TARGET` keeps its target-wide wildcard + let scope = vec![CallScope { target, selector_rules: vec![] }]; + assert_eq!(session_scope(scope.clone(), None, vec![]).unwrap(), scope); + } + + #[test] + fn inner_command_clears_inherited_signer_env_for_session_child() { + let session_id = B256::repeat_byte(0x7a); + let command = InnerCommand::parse("forge script Deploy".to_string()).unwrap(); + let child = command.command(session_id); + + for key in SESSION_CHILD_SIGNER_ENV { + assert_eq!( + command_env(&child, key), + Some(None), + "expected {key} to be removed from session child environment" + ); + } + + // Remote signers resolve their credentials from the environment as well. + for key in ["AWS_KMS_KEY_ID", "AWS_KMS_KEY_IDS", "GCP_KEY_NAME", "TURNKEY_API_PRIVATE_KEY"] + { + assert_eq!( + command_env(&child, key), + Some(None), + "expected {key} to be removed from session child environment" + ); + } + + let expected_session_id = format!("{session_id:?}"); + assert_eq!( + command_env(&child, TEMPO_SESSION_ID_ENV), + Some(Some(OsStr::new(&expected_session_id))) + ); + assert_eq!( + command_env(&child, "ETH_FROM"), + None, + "ETH_FROM is a sender hint and should not be stripped by session --for" + ); + } + + #[cfg(unix)] + #[test] + fn inner_command_interrupt_terminates_child() { + let runtime = tokio::runtime::Runtime::new().unwrap(); + runtime.block_on(async { + let session_id = B256::repeat_byte(0x7b); + let command = InnerCommand::parse("sh -c 'sleep 30'".to_string()).unwrap(); + let err = command + .run_with_interrupt(session_id, std::future::ready(Ok("test interrupt"))) + .await + .unwrap_err(); + + assert!(err.to_string().contains("interrupted by test interrupt"), "{err}"); + }); + } + + fn command_env<'a>(command: &'a Command, key: &str) -> Option> { + command.get_envs().find_map(|(name, value)| (name == key).then_some(value)) + } + + #[test] + fn local_revoke_is_idempotent_when_missing() { + with_tempo_home(|| { + assert!(!retire_session_entry(B256::repeat_byte(0x42)).unwrap()); + }); + } + + #[test] + fn create_and_local_revoke_session_entry_round_trips() { + with_tempo_home(|| { + let runtime = tokio::runtime::Runtime::new().unwrap(); + runtime.block_on(async { + let root = address!("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"); + let wallet = raw_wallet(RawWalletOpts { + private_key: Some(ROOT_PRIVATE_KEY.to_string()), + ..Default::default() + }); + + let entry = build_session_entry( + root, + 4217, + 600, + vec![CallScope { + target: Address::with_last_byte(0xaa), + selector_rules: vec![], + }], + vec![], + wallet, + ) + .await + .unwrap(); + assert_eq!(entry.status, SessionStatus::Active); + assert!(entry.key.is_some()); + + let session_id = entry.session_id; + let expiry = entry.expiry; + upsert_session_entry(entry).unwrap(); + let stored = read_session_entry(session_id).unwrap().unwrap(); + assert_eq!(stored.session_id, session_id); + assert!(stored.has_live_key_at(expiry - 1)); + + assert!(retire_session_entry(session_id).unwrap()); + let session = read_session_entry(session_id).unwrap().unwrap(); + assert_eq!(session.status, SessionStatus::Revoked); + assert!(session.key.is_none()); + }); + }); + } +} diff --git a/crates/cast/src/cmd/wallet/touch_id.rs b/crates/cast/src/cmd/wallet/touch_id.rs new file mode 100644 index 0000000000000..a66de98f7376f --- /dev/null +++ b/crates/cast/src/cmd/wallet/touch_id.rs @@ -0,0 +1,183 @@ +use super::{ + TouchIdSidecarState, ensure_touch_id_available, existing_keystore_path, + remove_touch_id_sidecar, touch_id_sidecar_path, touch_id_sidecar_policy, + touch_id_sidecar_state, +}; +use clap::{Args, Parser}; +use eyre::Result; +use foundry_cli::json::print_json_success; +use foundry_common::{sh_println, shell}; +use serde_json::json; + +#[cfg(all(target_os = "macos", feature = "touch-id"))] +use super::{ensure_touch_id_sidecar_available, password_or_prompt}; +#[cfg(all(target_os = "macos", feature = "touch-id"))] +use alloy_signer_local::PrivateKeySigner; + +/// Arguments for `cast wallet touch-id`. +#[derive(Debug, Args)] +pub struct TouchIdArgs { + #[command(subcommand)] + command: TouchIdSubcommands, +} + +impl TouchIdArgs { + pub fn run(self) -> Result<()> { + self.command.run() + } +} + +/// Touch ID lifecycle commands for encrypted keystores. +#[derive(Debug, Parser)] +enum TouchIdSubcommands { + /// Enroll an existing keystore for Touch ID-assisted authentication. + Enroll { + /// The name of the keystore account. + #[arg(value_name = "ACCOUNT_NAME")] + account_name: String, + + /// The directory containing the keystore. + #[arg(long, short)] + keystore_dir: Option, + + /// The keystore password in cleartext. + #[arg(long, env = "CAST_UNSAFE_PASSWORD", value_name = "PASSWORD")] + unsafe_password: Option, + }, + + /// Report the persisted Touch ID enrollment state for a keystore. + Status { + /// The name of the keystore account. + #[arg(value_name = "ACCOUNT_NAME")] + account_name: String, + + /// The directory containing the keystore. + #[arg(long, short)] + keystore_dir: Option, + }, + + /// Remove the persisted Touch ID enrollment for a keystore. + Remove { + /// The name of the keystore account. + #[arg(value_name = "ACCOUNT_NAME")] + account_name: String, + + /// The directory containing the keystore. + #[arg(long, short)] + keystore_dir: Option, + }, +} + +impl TouchIdSubcommands { + fn run(self) -> Result<()> { + match self { + Self::Enroll { account_name, keystore_dir, unsafe_password } => { + enroll(&account_name, keystore_dir, unsafe_password) + } + Self::Status { account_name, keystore_dir } => status(&account_name, keystore_dir), + Self::Remove { account_name, keystore_dir } => remove(&account_name, keystore_dir), + } + } +} + +fn status(account_name: &str, keystore_dir: Option) -> Result<()> { + let keystore_path = existing_keystore_path(account_name, keystore_dir)?; + let sidecar = touch_id_sidecar_path(&keystore_path); + + match touch_id_sidecar_state(&sidecar)? { + TouchIdSidecarState::Missing => print_status( + json!({"account": account_name, "status": "not-enrolled"}), + format!("Touch ID is not enrolled for keystore `{account_name}`."), + ), + TouchIdSidecarState::Recognized => { + let policy = touch_id_sidecar_policy(&sidecar)?.as_str(); + print_status( + json!({"account": account_name, "status": "enrolled", "policy": policy}), + format!( + "Touch ID is enrolled for keystore `{account_name}` with `{policy}` policy." + ), + ) + } + TouchIdSidecarState::Keystore => print_status( + json!({"account": account_name, "status": "conflict"}), + format!( + "Touch ID status for keystore `{account_name}` is conflicted: {} is an existing keystore.", + sidecar.display() + ), + ), + TouchIdSidecarState::Unknown => print_status( + json!({"account": account_name, "status": "unknown"}), + format!( + "Touch ID status for keystore `{account_name}` is unknown: {} is not a recognized Touch ID sidecar.", + sidecar.display() + ), + ), + } +} + +fn remove(account_name: &str, keystore_dir: Option) -> Result<()> { + let keystore_path = existing_keystore_path(account_name, keystore_dir)?; + let removed = remove_touch_id_sidecar(&keystore_path)?; + let message = if removed { + format!("Touch ID enrollment removed for keystore `{account_name}`.") + } else { + format!("Touch ID is not enrolled for keystore `{account_name}`.") + }; + print_status(json!({"account": account_name, "removed": removed}), message) +} + +#[cfg(all(target_os = "macos", feature = "touch-id"))] +fn enroll( + account_name: &str, + keystore_dir: Option, + unsafe_password: Option, +) -> Result<()> { + let keystore_path = existing_keystore_path(account_name, keystore_dir)?; + ensure_touch_id_available(true)?; + + let sidecar = touch_id_sidecar_path(&keystore_path); + let state = touch_id_sidecar_state(&sidecar)?; + ensure_touch_id_sidecar_available(&keystore_path)?; + let (reenrolled, policy) = match state { + TouchIdSidecarState::Missing => (false, foundry_wallets::touch_id::Policy::default()), + TouchIdSidecarState::Recognized => { + (true, foundry_wallets::touch_id::policy(&keystore_path)?) + } + TouchIdSidecarState::Keystore | TouchIdSidecarState::Unknown => { + eyre::bail!("Touch ID sidecar state changed during enrollment preflight"); + } + }; + + let password = password_or_prompt(unsafe_password, "Enter password: ")?; + PrivateKeySigner::decrypt_keystore(&keystore_path, &password) + .map_err(|_| eyre::eyre!("Invalid password - Touch ID enrollment cancelled"))?; + + foundry_wallets::touch_id::enroll(&keystore_path, &password, policy).map_err(|error| { + let action = if reenrolled { "re-enrollment" } else { "enrollment" }; + eyre::eyre!("Touch ID {action} failed for keystore `{account_name}`: {error}") + })?; + + let message = if reenrolled { + format!("Touch ID re-enrolled for keystore `{account_name}`.") + } else { + format!("Touch ID enrolled for keystore `{account_name}`.") + }; + print_status( + json!({"account": account_name, "touch_id": true, "reenrolled": reenrolled}), + message, + ) +} + +#[cfg(not(all(target_os = "macos", feature = "touch-id")))] +fn enroll( + account_name: &str, + keystore_dir: Option, + _unsafe_password: Option, +) -> Result<()> { + existing_keystore_path(account_name, keystore_dir)?; + ensure_touch_id_available(true) +} + +fn print_status(value: serde_json::Value, message: String) -> Result<()> { + if shell::is_json() { print_json_success(value) } else { sh_println!("{message}") } +} diff --git a/crates/cast/src/cmd/wallet/vanity.rs b/crates/cast/src/cmd/wallet/vanity.rs index 0424b28531c79..6694c4f91a43d 100644 --- a/crates/cast/src/cmd/wallet/vanity.rs +++ b/crates/cast/src/cmd/wallet/vanity.rs @@ -1,22 +1,19 @@ use alloy_primitives::{Address, hex}; -use alloy_signer::{k256::ecdsa::SigningKey, utils::secret_key_to_address}; use alloy_signer_local::PrivateKeySigner; use clap::Parser; -use eyre::Result; -use foundry_common::sh_println; -use itertools::Either; +use eyre::{Result, WrapErr}; +use foundry_cli::json::print_json_success; +use foundry_common::{fs::write_sensitive_json_file, sh_println, shell}; use rayon::iter::{self, ParallelIterator}; use regex::Regex; use serde::{Deserialize, Serialize}; +use serde_json::json; use std::{ fs, path::{Path, PathBuf}, time::Instant, }; -/// Type alias for the result of [generate_wallet]. -pub type GeneratedWallet = (SigningKey, Address); - /// CLI arguments for `cast wallet vanity`. #[derive(Clone, Debug, Parser)] pub struct VanityArgs { @@ -60,10 +57,10 @@ struct Wallets { } impl WalletData { - pub fn new(wallet: &PrivateKeySigner) -> Self { + fn new(wallet: &PrivateKeySigner) -> Self { Self { address: wallet.address().to_checksum(None), - private_key: format!("0x{}", hex::encode(wallet.credential().to_bytes())), + private_key: hex::encode_prefixed(wallet.credential().to_bytes()), } } } @@ -72,91 +69,38 @@ impl VanityArgs { pub fn run(self) -> Result { let Self { starts_with, ends_with, nonce, save_path } = self; - let mut left_exact_hex = None; - let mut left_regex = None; - if let Some(prefix) = starts_with { - match parse_pattern(&prefix, true)? { - Either::Left(left) => left_exact_hex = Some(left), - Either::Right(re) => left_regex = Some(re), - } - } - - let mut right_exact_hex = None; - let mut right_regex = None; - if let Some(suffix) = ends_with { - match parse_pattern(&suffix, false)? { - Either::Left(right) => right_exact_hex = Some(right), - Either::Right(re) => right_regex = Some(re), - } - } + let matcher = Matcher { + left: starts_with.as_deref().map(|p| parse_pattern(p, true)).transpose()?, + right: ends_with.as_deref().map(|p| parse_pattern(p, false)).transpose()?, + }; - macro_rules! find_vanity { - ($m:ident, $nonce:ident) => { - if let Some(nonce) = $nonce { - find_vanity_address_with_nonce($m, nonce) - } else { - find_vanity_address($m) - } - }; - } - - sh_println!("Starting to generate vanity address...")?; + sh_status!("Starting to generate vanity address...")?; let timer = Instant::now(); + let wallet = find_vanity(&matcher, nonce); - let wallet = match (left_exact_hex, left_regex, right_exact_hex, right_regex) { - (Some(left), _, Some(right), _) => { - let matcher = HexMatcher { left, right }; - find_vanity!(matcher, nonce) - } - (Some(left), _, _, Some(right)) => { - let matcher = LeftExactRightRegexMatcher { left, right }; - find_vanity!(matcher, nonce) - } - (_, Some(left), _, Some(right)) => { - let matcher = RegexMatcher { left, right }; - find_vanity!(matcher, nonce) - } - (_, Some(left), Some(right), _) => { - let matcher = LeftRegexRightExactMatcher { left, right }; - find_vanity!(matcher, nonce) - } - (Some(left), None, None, None) => { - let matcher = LeftHexMatcher { left }; - find_vanity!(matcher, nonce) - } - (None, None, Some(right), None) => { - let matcher = RightHexMatcher { right }; - find_vanity!(matcher, nonce) - } - (None, Some(re), None, None) => { - let matcher = SingleRegexMatcher { re }; - find_vanity!(matcher, nonce) - } - (None, None, None, Some(re)) => { - let matcher = SingleRegexMatcher { re }; - find_vanity!(matcher, nonce) - } - _ => unreachable!(), - } - .expect("failed to generate vanity wallet"); - - // If a save path is provided, save the generated vanity wallet to the specified path. if let Some(save_path) = save_path { save_wallet_to_file(&wallet, &save_path)?; } - sh_println!( - "Successfully found vanity address in {:.3} seconds.{}{}\nAddress: {}\nPrivate Key: 0x{}", - timer.elapsed().as_secs_f64(), - if nonce.is_some() { "\nContract address: " } else { "" }, - if nonce.is_some() { - wallet.address().create(nonce.unwrap()).to_checksum(None) - } else { - String::new() - }, - wallet.address().to_checksum(None), - hex::encode(wallet.credential().to_bytes()), - )?; + let contract_address = nonce.map(|nonce| wallet.address().create(nonce).to_checksum(None)); + let WalletData { address, private_key } = WalletData::new(&wallet); + + if shell::is_json() { + print_json_success(json!({ + "address": address, + "private_key": private_key, + "contract_address": contract_address, + }))?; + } else { + sh_println!( + "Successfully found vanity address in {:.3} seconds.{}{}\nAddress: {}\nPrivate Key: {}", + timer.elapsed().as_secs_f64(), + if contract_address.is_some() { "\nContract address: " } else { "" }, + contract_address.unwrap_or_default(), + address, + private_key, + )?; + } Ok(wallet) } @@ -168,205 +112,104 @@ impl VanityArgs { fn save_wallet_to_file(wallet: &PrivateKeySigner, path: &Path) -> Result<()> { let mut wallets = if path.exists() { let data = fs::read_to_string(path)?; - serde_json::from_str::(&data).unwrap_or_default() + if data.trim().is_empty() { + Wallets::default() + } else { + serde_json::from_str::(&data) + .wrap_err_with(|| format!("failed to parse wallet file {}", path.display()))? + } } else { Wallets::default() }; wallets.wallets.push(WalletData::new(wallet)); - fs::write(path, serde_json::to_string_pretty(&wallets)?)?; + write_sensitive_json_file(path, &wallets)?; Ok(()) } -/// Generates random wallets until `matcher` matches the wallet address, returning the wallet. -pub fn find_vanity_address(matcher: T) -> Option { - wallet_generator().find_any(create_matcher(matcher)).map(|(key, _)| key.into()) -} - -/// Generates random wallets until `matcher` matches the contract address created at `nonce`, -/// returning the wallet. -pub fn find_vanity_address_with_nonce( - matcher: T, - nonce: u64, -) -> Option { - wallet_generator().find_any(create_nonce_matcher(matcher, nonce)).map(|(key, _)| key.into()) -} - -/// Creates a nonce matcher function, which takes a reference to a [GeneratedWallet] and returns -/// whether it found a match or not by using `matcher`. -#[inline] -pub fn create_matcher(matcher: T) -> impl Fn(&GeneratedWallet) -> bool { - move |(_, addr)| matcher.is_match(addr) -} - -/// Creates a nonce matcher function, which takes a reference to a [GeneratedWallet] and a nonce and -/// returns whether it found a match or not by using `matcher`. -#[inline] -pub fn create_nonce_matcher( - matcher: T, - nonce: u64, -) -> impl Fn(&GeneratedWallet) -> bool { - move |(_, addr)| { - let contract_addr = addr.create(nonce); - matcher.is_match(&contract_addr) - } -} - -/// Returns an infinite parallel iterator which yields a [GeneratedWallet]. -#[inline] -pub fn wallet_generator() -> iter::Map, impl Fn(()) -> GeneratedWallet> { - iter::repeat(()).map(|()| generate_wallet()) -} - -/// Generates a random K-256 signing key and derives its Ethereum address. -pub fn generate_wallet() -> GeneratedWallet { - let key = SigningKey::random(&mut rand_08::thread_rng()); - let address = secret_key_to_address(&key); - (key, address) -} - -/// A trait to match vanity addresses. -pub trait VanityMatcher: Send + Sync { - fn is_match(&self, addr: &Address) -> bool; -} - -/// Matches start and end hex. -pub struct HexMatcher { - pub left: Vec, - pub right: Vec, -} - -impl VanityMatcher for HexMatcher { - #[inline] - fn is_match(&self, addr: &Address) -> bool { - let bytes = addr.as_slice(); - bytes.starts_with(&self.left) && bytes.ends_with(&self.right) - } -} - -/// Matches only start hex. -pub struct LeftHexMatcher { - pub left: Vec, -} - -impl VanityMatcher for LeftHexMatcher { - #[inline] - fn is_match(&self, addr: &Address) -> bool { - let bytes = addr.as_slice(); - bytes.starts_with(&self.left) - } -} - -/// Matches only end hex. -pub struct RightHexMatcher { - pub right: Vec, -} - -impl VanityMatcher for RightHexMatcher { - #[inline] - fn is_match(&self, addr: &Address) -> bool { - let bytes = addr.as_slice(); - bytes.ends_with(&self.right) - } -} - -/// Matches start hex and end regex. -pub struct LeftExactRightRegexMatcher { - pub left: Vec, - pub right: Regex, +/// Generates random wallets in parallel until `matcher` matches the wallet address, or the +/// contract address it creates at `nonce` when one is given. +fn find_vanity(matcher: &Matcher, nonce: Option) -> PrivateKeySigner { + iter::repeat(()) + .map(|()| PrivateKeySigner::random()) + .find_any(|wallet| { + let address = wallet.address(); + matcher.is_match(&nonce.map_or(address, |nonce| address.create(nonce))) + }) + .expect("infinite iterator") } -impl VanityMatcher for LeftExactRightRegexMatcher { - #[inline] - fn is_match(&self, addr: &Address) -> bool { - let bytes = addr.as_slice(); - bytes.starts_with(&self.left) && self.right.is_match(&hex::encode(bytes)) - } +/// A vanity pattern: an exact byte prefix/suffix, or a regex over the lowercase hex address. +#[derive(Debug)] +enum Pattern { + Exact(Vec), + Re(Regex), } -/// Matches start regex and end hex. -pub struct LeftRegexRightExactMatcher { - pub left: Regex, - pub right: Vec, +/// Optional start and end patterns an address must satisfy. +struct Matcher { + left: Option, + right: Option, } -impl VanityMatcher for LeftRegexRightExactMatcher { - #[inline] +impl Matcher { fn is_match(&self, addr: &Address) -> bool { let bytes = addr.as_slice(); - bytes.ends_with(&self.right) && self.left.is_match(&hex::encode(bytes)) + let mut encoded = None; + let mut matches = |pattern: &Pattern, exact: fn(&[u8], &[u8]) -> bool| match pattern { + Pattern::Exact(hex) => exact(bytes, hex), + Pattern::Re(re) => re.is_match(encoded.get_or_insert_with(|| hex::encode(bytes))), + }; + self.left.as_ref().is_none_or(|p| matches(p, <[u8]>::starts_with)) + && self.right.as_ref().is_none_or(|p| matches(p, <[u8]>::ends_with)) } } -/// Matches a single regex. -pub struct SingleRegexMatcher { - pub re: Regex, -} - -impl VanityMatcher for SingleRegexMatcher { - #[inline] - fn is_match(&self, addr: &Address) -> bool { - let addr = hex::encode(addr); - self.re.is_match(&addr) +fn parse_pattern(pattern: &str, is_start: bool) -> Result { + let pattern = + pattern.strip_prefix("0x").or_else(|| pattern.strip_prefix("0X")).unwrap_or(pattern); + if pattern.is_empty() { + return Err(eyre::eyre!("Vanity pattern cannot be empty")); } -} - -/// Matches start and end regex. -pub struct RegexMatcher { - pub left: Regex, - pub right: Regex, -} -impl VanityMatcher for RegexMatcher { - #[inline] - fn is_match(&self, addr: &Address) -> bool { - let addr = hex::encode(addr); - self.left.is_match(&addr) && self.right.is_match(&addr) + let is_hex = pattern.bytes().all(|byte| byte.is_ascii_hexdigit()); + if is_hex && pattern.len() > 40 { + return Err(eyre::eyre!("Hex pattern must be less than 20 bytes")); } -} -fn parse_pattern(pattern: &str, is_start: bool) -> Result, Regex>> { if let Ok(decoded) = hex::decode(pattern) { - if decoded.len() > 20 { - return Err(eyre::eyre!("Hex pattern must be less than 20 bytes")); - } - Ok(Either::Left(decoded)) - } else { - let (prefix, suffix) = if is_start { ("^", "") } else { ("", "$") }; - Ok(Either::Right(Regex::new(&format!("{prefix}{pattern}{suffix}"))?)) + return Ok(Pattern::Exact(decoded)); + } + // a non regex literal containing non-hex characters can never match + if !is_hex && pattern.bytes().all(|byte| byte.is_ascii_alphanumeric()) { + return Err(eyre::eyre!("Pattern contains non-hex characters and can never match")); } + let (prefix, suffix) = if is_start { ("^", "") } else { ("", "$") }; + let pattern = if is_hex { pattern.to_ascii_lowercase() } else { pattern.to_string() }; + Ok(Pattern::Re(Regex::new(&format!("{prefix}{pattern}{suffix}"))?)) } #[cfg(test)] mod tests { use super::*; - #[test] - fn find_simple_vanity_start() { - let args: VanityArgs = VanityArgs::parse_from(["foundry-cli", "--starts-with", "00"]); - let wallet = args.run().unwrap(); - let addr = wallet.address(); - let addr = format!("{addr:x}"); - assert!(addr.starts_with("00")); - } + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; - #[test] - fn find_simple_vanity_start2() { - let args: VanityArgs = VanityArgs::parse_from(["foundry-cli", "--starts-with", "9"]); - let wallet = args.run().unwrap(); - let addr = wallet.address(); - let addr = format!("{addr:x}"); - assert!(addr.starts_with('9')); + fn single(pattern: &str, is_start: bool) -> Matcher { + let pattern = parse_pattern(pattern, is_start).unwrap(); + if is_start { + Matcher { left: Some(pattern), right: None } + } else { + Matcher { left: None, right: Some(pattern) } + } } - #[test] - fn find_simple_vanity_end() { - let args: VanityArgs = VanityArgs::parse_from(["foundry-cli", "--ends-with", "00"]); - let wallet = args.run().unwrap(); - let addr = wallet.address(); - let addr = format!("{addr:x}"); - assert!(addr.ends_with("00")); + fn address(index: usize, byte: u8) -> Address { + let mut bytes = [0; 20]; + bytes[index] = byte; + Address::from(bytes) } #[test] @@ -379,10 +222,77 @@ mod tests { "--save-path", tmp.path().to_str().unwrap(), ]); - args.run().unwrap(); - assert!(tmp.path().exists()); + let wallet = args.run().unwrap(); + assert!(wallet.address().as_slice().starts_with(&[0])); let s = fs::read_to_string(tmp.path()).unwrap(); let wallets: Wallets = serde_json::from_str(&s).unwrap(); assert!(!wallets.wallets.is_empty()); } + + #[test] + fn malformed_wallet_file_is_not_overwritten() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + let original = "{\"wallets\":["; + fs::write(tmp.path(), original).unwrap(); + + let err = save_wallet_to_file(&PrivateKeySigner::random(), tmp.path()).unwrap_err(); + + assert!(err.to_string().contains("failed to parse wallet file")); + assert_eq!(fs::read_to_string(tmp.path()).unwrap(), original); + } + + #[cfg(unix)] + #[test] + fn wallet_file_is_owner_only() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("wallets.json"); + save_wallet_to_file(&PrivateKeySigner::random(), &path).unwrap(); + assert_eq!(fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); + + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); + save_wallet_to_file(&PrivateKeySigner::random(), &path).unwrap(); + assert_eq!(fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); + } + + #[test] + fn parse_patterns() { + // odd-length hex is matched case-insensitively as a regex + assert!(matches!(parse_pattern("A", true).unwrap(), Pattern::Re(_))); + assert!(single("A", true).is_match(&address(0, 0xa0))); + assert!(single("A", false).is_match(&address(19, 0x0a))); + assert!(single("0x9", true).is_match(&address(0, 0x90))); + + // 0x/0X prefixes are stripped from exact hex patterns + for prefixed in ["0xdead", "0Xdead"] { + let Pattern::Exact(bytes) = parse_pattern(prefixed, true).unwrap() else { + panic!("expected an exact hex pattern"); + }; + assert_eq!(bytes, hex::decode("dead").unwrap()); + } + let mut matching = [0; 20]; + matching[..2].copy_from_slice(&[0xde, 0xad]); + assert!(single("0xdead", true).is_match(&Address::from(matching))); + + // regex patterns stay supported + assert!(parse_pattern("a.c", true).is_ok()); + + // exact suffixes match the end of the address only + assert!(matches!(parse_pattern("00", false).unwrap(), Pattern::Exact(_))); + assert!(single("00", false).is_match(&address(0, 0xff))); + assert!(!single("00", false).is_match(&address(19, 0x01))); + assert!(!single("dead", true).is_match(&address(19, 0xde))); + } + + #[test] + fn reject_invalid_patterns() { + for (pattern, err) in [ + (&*"1".repeat(41), "Hex pattern must be less than 20 bytes"), + ("0x", "Vanity pattern cannot be empty"), + // non-hex chars can never appear in a hex address + ("zzz", "Pattern contains non-hex characters and can never match"), + ("foobar", "Pattern contains non-hex characters and can never match"), + ] { + assert_eq!(parse_pattern(pattern, true).unwrap_err().to_string(), err); + } + } } diff --git a/crates/cast/src/debug.rs b/crates/cast/src/debug.rs new file mode 100644 index 0000000000000..67bc0df927265 --- /dev/null +++ b/crates/cast/src/debug.rs @@ -0,0 +1,185 @@ +use alloy_primitives::{Bytes, map::AddressHashMap}; +use foundry_cli::utils::{TraceResult, print_traces}; +use foundry_common::{ContractsByArtifactBuilder, compile::ProjectCompiler, shell}; +use foundry_compilers::artifacts::output_selection::ContractOutputSelection; +use foundry_config::{Config, FoundryHardfork, TracingConfig}; +use foundry_debugger::Debugger; +use foundry_evm::{ + opts::ForkEndpointIdentity, + traces::{ + CallTraceDecoderBuilder, DebugTraceIdentifier, TraceContext, + debug::ContractSources, + erc8021::Attribution, + identifier::{SignaturesIdentifier, TraceIdentifiers}, + }, +}; +use foundry_evm_networks::NetworkVariant; +use itertools::Itertools; + +pub(crate) fn select_remote_trace_hardfork( + configured: Option, + endpoint: Option, + network: NetworkVariant, +) -> Option { + let namespace = network.hardfork_namespace(); + configured + .filter(|hardfork| hardfork.namespace() == namespace) + .or_else(|| endpoint.filter(|hardfork| hardfork.namespace() == namespace)) +} + +/// Resolves the hardfork used to decode a trace executed by the remote node. A configured +/// hardfork is an explicit override; otherwise an Anvil endpoint's exact execution hardfork is +/// honored before consulting the source chain's schedule at `block_timestamp`. +pub(crate) fn resolve_remote_trace_hardfork( + configured: Option, + endpoint: &ForkEndpointIdentity, + block_timestamp: Option, +) -> Option { + select_remote_trace_hardfork(configured, endpoint.hardfork, endpoint.network).or_else(|| { + block_timestamp.and_then(|timestamp| { + FoundryHardfork::from_chain_and_timestamp(endpoint.source_chain_id, timestamp) + }) + }) +} + +pub(crate) fn ensure_remote_trace_context_unchanged( + before: &ForkEndpointIdentity, + after: &ForkEndpointIdentity, +) -> eyre::Result<()> { + if before != after { + eyre::bail!( + "the RPC endpoint changed execution context while the remote trace was being \ + collected; retry the command" + ); + } + Ok(()) +} + +/// labels the traces, conditionally prints them or opens the debugger +pub(crate) async fn handle_traces( + mut result: TraceResult, + config: &Config, + context: TraceContext, + contracts_bytecode: &AddressHashMap, + tracing: &TracingConfig, + with_local_artifacts: bool, + debug: bool, +) -> eyre::Result<()> { + let (known_contracts, mut sources) = if with_local_artifacts { + // Status prose goes to stderr so `--json` output on stdout stays machine-readable. + let _ = sh_status!("Compiling project to generate artifacts"); + let mut config = config.clone(); + if debug && !config.extra_output.contains(&ContractOutputSelection::StorageLayout) { + config.extra_output.push(ContractOutputSelection::StorageLayout); + } + let project = config.project()?; + let compiler = ProjectCompiler::new(); + let output = compiler.compile(&project)?; + ( + Some( + ContractsByArtifactBuilder::new( + output.artifact_ids().map(|(id, artifact)| (id, artifact.into())), + ) + .with_storage_layouts(output.artifact_ids().filter_map(|(id, artifact)| { + artifact.storage_layout.as_ref().map(|layout| (id, layout.clone())) + })) + .build(), + ), + ContractSources::from_project_output(&output, project.root(), None)?, + ) + } else { + (None, ContractSources::default()) + }; + + let mut builder = CallTraceDecoderBuilder::new() + .with_tracing_config(tracing) + .with_signature_identifier(SignaturesIdentifier::from_config(config)?) + .with_networks(context.networks()) + .with_chain_id(Some(context.chain().id())) + .with_hardfork(context.hardfork()); + let mut identifier = TraceIdentifiers::new().with_external(config, Some(context.chain()))?; + if let Some(contracts) = &known_contracts { + builder = builder.with_known_contracts(contracts); + identifier = identifier.with_local_and_bytecodes(contracts, contracts_bytecode); + } + + let mut decoder = builder.build(); + + for (_, trace) in result.traces.as_deref_mut().unwrap_or_default() { + decoder.identify(trace, &mut identifier); + } + + if tracing.decode_internal || debug { + if let Some(ref etherscan_identifier) = identifier.external { + sources.merge(etherscan_identifier.get_compiled_contracts().await?); + } + + if debug { + let mut builder = Debugger::builder() + .traces(result.traces.expect("missing traces")) + .decoder(&decoder) + .sources(sources); + if let Some(known_contracts) = &known_contracts { + builder = builder.known_contracts(known_contracts); + } + let mut debugger = builder.build(); + debugger.try_run_tui()?; + return Ok(()); + } + + decoder.debug_identifier = Some(DebugTraceIdentifier::new(sources)); + } + + // Collected before printing, which may prune the calls that carry them, e.g. ERC-4337 user + // operations. + let attributions = result + .traces + .iter() + .flatten() + .flat_map(|(_, arena)| arena.nodes()) + .filter_map(|node| Attribution::decode(&node.trace.data)) + .unique() + .collect::>(); + + print_traces( + &mut result, + &decoder, + tracing.verbosity > 0, + tracing.verbosity > 4, + tracing.trace_depth, + ) + .await?; + + if !shell::is_json() { + for attribution in attributions { + sh_println!("ERC-8021 attribution: {attribution}")?; + } + } + + Ok(()) +} + +#[cfg(all(test, feature = "monad"))] +mod tests { + use super::*; + + #[test] + fn remote_trace_hardfork_ignores_cross_network_override() { + let ethereum = FoundryHardfork::Ethereum(foundry_evm::hardforks::EthereumHardfork::Cancun); + let monad_eight = FoundryHardfork::Monad(foundry_evm::hardforks::MonadHardfork::MonadEight); + let monad_nine = FoundryHardfork::Monad(foundry_evm::hardforks::MonadHardfork::MonadNine); + + assert_eq!( + select_remote_trace_hardfork(Some(ethereum), Some(monad_nine), NetworkVariant::Monad), + Some(monad_nine) + ); + assert_eq!( + select_remote_trace_hardfork( + Some(monad_eight), + Some(monad_nine), + NetworkVariant::Monad + ), + Some(monad_eight) + ); + } +} diff --git a/crates/cast/src/errors.rs b/crates/cast/src/errors.rs deleted file mode 100644 index 03ec7c3178a6f..0000000000000 --- a/crates/cast/src/errors.rs +++ /dev/null @@ -1,37 +0,0 @@ -//! Errors for this crate - -use foundry_config::Chain; -use std::fmt; - -/// An error thrown when resolving a function via signature failed -#[derive(Clone, Debug)] -pub enum FunctionSignatureError { - MissingSignature, - MissingEtherscan { sig: String }, - UnknownChain(Chain), - MissingToAddress, -} - -impl fmt::Display for FunctionSignatureError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::MissingSignature => { - writeln!(f, "Function signature must be set") - } - Self::MissingEtherscan { sig } => { - writeln!(f, "Failed to determine function signature for `{sig}`")?; - writeln!( - f, - "To lookup a function signature of a deployed contract by name, a valid ETHERSCAN_API_KEY must be set." - )?; - write!(f, "\tOr did you mean:\t {sig}()") - } - Self::UnknownChain(chain) => { - write!(f, "Resolving via etherscan requires a known chain. Unknown chain: {chain}") - } - Self::MissingToAddress => f.write_str("Target address must be set"), - } - } -} - -impl std::error::Error for FunctionSignatureError {} diff --git a/crates/cast/src/evm_version.rs b/crates/cast/src/evm_version.rs new file mode 100644 index 0000000000000..1b1cd1abc77cd --- /dev/null +++ b/crates/cast/src/evm_version.rs @@ -0,0 +1,185 @@ +//! Detects the EVM version a node executes at a given block. +//! +//! Replaying a transaction needs the spec its chain applied at that block. For chains without a +//! hardfork schedule known to Foundry, header fields only hint at it, and replaying an old block +//! under a newer spec can make a transaction succeed locally that failed on-chain because an +//! opcode was not active yet. [`probe_evm_version`] instead asks the node which features it +//! executes. + +use alloy_network::{Network, TransactionBuilder}; +use alloy_primitives::{Bytes, U256}; +use alloy_provider::Provider; +use alloy_rpc_types::BlockId; +use foundry_compilers::artifacts::EvmVersion; +use revm::bytecode::opcode::{ + AND, CALL, CLZ, CODECOPY, CREATE, DUP6, EQ, GAS, INVALID, ISZERO, JUMPDEST, JUMPI, MCOPY, + MSTORE, MUL, OR, POP, PUSH0, PUSH1, PUSH2, PUSH3, RETURN, RETURNDATASIZE, SLOTNUM, STATICCALL, + STOP, SWAP1, +}; + +/// Probed features in activation order, each paired with the EVM version that introduced it and +/// runtime code that halts exceptionally unless the feature is active. +#[rustfmt::skip] +const PROBES: [(EvmVersion, &[u8]); 5] = [ + (EvmVersion::Shanghai, &[PUSH0, POP, STOP]), + (EvmVersion::Cancun, &[PUSH1, 0, PUSH1, 0, PUSH1, 0, MCOPY, STOP]), + // Calls the BLS12-381 G1ADD precompile (0x0b) with two points at infinity and requires its + // 128-byte result. Before Prague the call reaches an empty account and returns no data. + (EvmVersion::Prague, &[ + // STATICCALL(GAS, 0x0b, 0, 256, 0, 0) + PUSH1, 0, PUSH1, 0, PUSH2, 0x01, 0x00, PUSH1, 0, PUSH1, 0x0b, GAS, STATICCALL, + // Halt unless the call succeeded and returned 128 bytes. + RETURNDATASIZE, PUSH1, 0x80, EQ, AND, PUSH1, 22, JUMPI, INVALID, JUMPDEST, STOP, + ]), + (EvmVersion::Osaka, &[PUSH1, 0, CLZ, POP, STOP]), + (EvmVersion::Amsterdam, &[SLOTNUM, POP, STOP]), +]; + +/// Gas forwarded to each probe. A failing probe consumes all of it. +const PROBE_GAS: u32 = 20_000; + +/// Gas limit of the probe call, enough for the worst case in which every probe fails. Under +/// EIP-8037 the six accounts the probe creates cost 1,101,600 state gas alone. +const PROBE_CALL_GAS: u64 = 3_000_000; + +/// Returns the newest EVM version whose features, and those of every earlier probed version, the +/// node executes at `block`. +/// +/// Returns `None` if the node rejects the probe call or returns an unexpected result. +pub async fn probe_evm_version>( + provider: &P, + block: BlockId, +) -> Option { + let request = N::TransactionRequest::default() + .with_deploy_code(probe_code()) + .with_gas_limit(PROBE_CALL_GAS); + let output = match provider.call(request).block(block).await { + Ok(output) => output, + Err(err) => { + trace!(%err, "EVM version probe failed"); + return None; + } + }; + let Ok(word) = <[u8; 32]>::try_from(output.as_ref()) else { + trace!(%output, "unexpected EVM version probe output"); + return None; + }; + let mask = U256::from_be_bytes(word); + if !(mask >> PROBES.len()).is_zero() { + trace!(%mask, "unexpected EVM version probe mask"); + return None; + } + let version = evm_version_from_mask(mask); + trace!(?version, %mask, "probed EVM version"); + Some(version) +} + +/// Maps the probe result to the newest version whose probe and all earlier probes succeeded. +/// +/// A node that fails every probe predates Shanghai. Paris is returned then, the newest version +/// without `PUSH0`. +fn evm_version_from_mask(mask: U256) -> EvmVersion { + PROBES + .iter() + .enumerate() + .take_while(|(bit, _)| mask.bit(*bit)) + .last() + .map_or(EvmVersion::Paris, |(_, (version, _))| *version) +} + +/// Builds the creation code of the probe call. +/// +/// For every probe it deploys the probe's runtime code, calls it with a bounded gas stipend so a +/// failure cannot exhaust the call, and sets bit `i` of the returned word if probe `i` succeeded. +/// The code itself only uses opcodes that predate every probed feature. +fn probe_code() -> Bytes { + const PER_PROBE: usize = 39; + const HEADER_AND_FOOTER: usize = 10; + + let [_, gas @ ..] = PROBE_GAS.to_be_bytes(); + // The accumulator for the result stays at the bottom of the stack. + let mut code = vec![PUSH1, 0]; + let mut data = Vec::new(); + let mut offset = HEADER_AND_FOOTER + PER_PROBE * PROBES.len(); + for (bit, (_, runtime)) in PROBES.iter().enumerate() { + let initcode = deploy_code(runtime); + let len = u8::try_from(initcode.len()).expect("probe initcode length fits in PUSH1"); + let [start_hi, start_lo] = + u16::try_from(offset).expect("probe initcode offset fits in PUSH2").to_be_bytes(); + // CODECOPY(0, start, len) + code.extend([PUSH1, len, PUSH2, start_hi, start_lo, PUSH1, 0, CODECOPY]); + // CREATE(0, 0, len) + code.extend([PUSH1, len, PUSH1, 0, PUSH1, 0, CREATE]); + // CALL(PROBE_GAS, address, 0, 0, 0, 0, 0), duplicating the created address from below the + // five zero arguments. + code.extend([PUSH1, 0].repeat(5)); + code.extend([DUP6, PUSH3, gas[0], gas[1], gas[2], CALL]); + // success && address != 0 + code.extend([SWAP1, ISZERO, ISZERO, AND]); + // accumulator |= result << bit + code.extend([PUSH1, 1 << bit, MUL, OR]); + offset += initcode.len(); + data.extend(initcode); + } + // MSTORE(0, accumulator) RETURN(0, 32) + code.extend([PUSH1, 0, MSTORE, PUSH1, 32, PUSH1, 0, RETURN]); + debug_assert_eq!(code.len(), HEADER_AND_FOOTER + PER_PROBE * PROBES.len()); + code.extend(data); + code.into() +} + +/// Returns initcode that deploys `runtime`. +fn deploy_code(runtime: &[u8]) -> Vec { + const PREFIX_LEN: u8 = 12; + let len = u8::try_from(runtime.len()).expect("probe runtime length fits in PUSH1"); + // CODECOPY(0, PREFIX_LEN, len) RETURN(0, len) + let mut code = + vec![PUSH1, len, PUSH1, PREFIX_LEN, PUSH1, 0, CODECOPY, PUSH1, len, PUSH1, 0, RETURN]; + code.extend_from_slice(runtime); + code +} + +#[cfg(test)] +mod tests { + + use super::*; + use alloy_primitives::TxKind; + use revm::{ + Context, ExecuteEvm, MainBuilder, MainContext, context::TxEnv, database::InMemoryDB, + primitives::hardfork::SpecId, + }; + + #[test] + fn detects_executed_spec() { + for (spec, expected) in [ + (SpecId::LONDON, EvmVersion::Paris), + (SpecId::MERGE, EvmVersion::Paris), + (SpecId::SHANGHAI, EvmVersion::Shanghai), + (SpecId::CANCUN, EvmVersion::Cancun), + (SpecId::PRAGUE, EvmVersion::Prague), + (SpecId::OSAKA, EvmVersion::Osaka), + (SpecId::AMSTERDAM, EvmVersion::Amsterdam), + ] { + let result = Context::mainnet() + .modify_cfg_chained(|cfg| cfg.set_spec_and_mainnet_gas_params(spec)) + .with_db(InMemoryDB::default()) + .build_mainnet() + .transact( + TxEnv::builder() + .kind(TxKind::Create) + .data(probe_code()) + .gas_limit(PROBE_CALL_GAS) + .build() + .unwrap(), + ) + .unwrap(); + let output = result.result.into_output().unwrap(); + assert_eq!(evm_version_from_mask(U256::from_be_slice(&output)), expected, "{spec:?}"); + } + } + + #[test] + fn ignores_probes_after_a_failed_one() { + assert_eq!(evm_version_from_mask(U256::from(0b1011)), EvmVersion::Cancun); + } +} diff --git a/crates/cast/src/lib.rs b/crates/cast/src/lib.rs index 412e9bd83ee9b..5f6da5de3e575 100644 --- a/crates/cast/src/lib.rs +++ b/crates/cast/src/lib.rs @@ -1,2426 +1,28 @@ //! Cast is a Swiss Army knife for interacting with Ethereum applications from the command line. -#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] #![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![cfg_attr(docsrs, feature(doc_cfg))] +#![recursion_limit = "256"] -use alloy_consensus::{Header, TxEnvelope}; -use alloy_dyn_abi::{DynSolType, DynSolValue, FunctionExt}; -use alloy_ens::NameOrAddress; -use alloy_json_abi::Function; -use alloy_network::{AnyNetwork, AnyRpcTransaction}; -use alloy_primitives::{ - Address, B256, I256, Keccak256, Selector, TxHash, TxKind, U64, U256, hex, - utils::{ParseUnits, Unit, keccak256}, -}; -use alloy_provider::{ - PendingTransactionBuilder, Provider, - network::eip2718::{Decodable2718, Encodable2718}, -}; -use alloy_rlp::Decodable; -use alloy_rpc_types::{ - BlockId, BlockNumberOrTag, BlockOverrides, Filter, TransactionRequest, state::StateOverride, -}; -use alloy_serde::WithOtherFields; -use alloy_sol_types::sol; -use base::{Base, NumberWithBase, ToBase}; -use chrono::DateTime; -use eyre::{Context, ContextCompat, OptionExt, Result}; -use foundry_block_explorers::Client; -use foundry_common::{ - TransactionReceiptWithRevertReason, - abi::{encode_function_args, get_func}, - compile::etherscan_project, - fmt::*, - fs, get_pretty_tx_receipt_attr, shell, -}; -use foundry_compilers::flatten::Flattener; -use foundry_config::Chain; -use foundry_evm_core::ic::decode_instructions; -use futures::{FutureExt, StreamExt, future::Either}; -use op_alloy_consensus::OpTxEnvelope; -use rayon::prelude::*; -use std::{ - borrow::Cow, - fmt::Write, - io, - path::PathBuf, - str::FromStr, - sync::atomic::{AtomicBool, Ordering}, - time::Duration, -}; -use tokio::signal::ctrl_c; +#[macro_use] +extern crate foundry_common; +#[macro_use] +extern crate tracing; + +#[cfg(feature = "optimism")] +use op_alloy_consensus as _; -use foundry_common::abi::encode_function_args_packed; pub use foundry_evm::*; pub mod args; pub mod cmd; pub mod opts; +pub mod tempo; pub mod base; -pub mod errors; +pub mod call_spec; +pub(crate) mod debug; +mod evm_version; mod rlp_converter; +pub mod rpc_trace; pub mod tx; - -use rlp_converter::Item; - -#[macro_use] -extern crate tracing; - -#[macro_use] -extern crate foundry_common; - -// TODO: CastContract with common contract initializers? Same for CastProviders? - -sol! { - #[sol(rpc)] - interface IERC20 { - #[derive(Debug)] - function balanceOf(address owner) external view returns (uint256); - } -} - -pub struct Cast

{ - provider: P, -} - -impl> Cast

{ - /// Creates a new Cast instance from the provided client - /// - /// # Example - /// - /// ``` - /// use alloy_provider::{ProviderBuilder, RootProvider, network::AnyNetwork}; - /// use cast::Cast; - /// - /// # async fn foo() -> eyre::Result<()> { - /// let provider = - /// ProviderBuilder::<_, _, AnyNetwork>::default().connect("http://localhost:8545").await?; - /// let cast = Cast::new(provider); - /// # Ok(()) - /// # } - /// ``` - pub fn new(provider: P) -> Self { - Self { provider } - } - - /// Makes a read-only call to the specified address - /// - /// # Example - /// - /// ``` - /// use alloy_primitives::{Address, U256, Bytes}; - /// use alloy_rpc_types::{TransactionRequest, BlockOverrides, state::{StateOverride, AccountOverride}}; - /// use alloy_serde::WithOtherFields; - /// use cast::Cast; - /// use alloy_provider::{RootProvider, ProviderBuilder, network::AnyNetwork}; - /// use std::{str::FromStr, collections::HashMap}; - /// use alloy_rpc_types::state::StateOverridesBuilder; - /// use alloy_sol_types::{sol, SolCall}; - /// - /// sol!( - /// function greeting(uint256 i) public returns (string); - /// ); - /// - /// # async fn foo() -> eyre::Result<()> { - /// let alloy_provider = ProviderBuilder::<_,_, AnyNetwork>::default().connect("http://localhost:8545").await?;; - /// let to = Address::from_str("0xB3C95ff08316fb2F2e3E52Ee82F8e7b605Aa1304")?; - /// let greeting = greetingCall { i: U256::from(5) }.abi_encode(); - /// let bytes = Bytes::from_iter(greeting.iter()); - /// let tx = TransactionRequest::default().to(to).input(bytes.into()); - /// let tx = WithOtherFields::new(tx); - /// - /// // Create state overrides - /// let mut state_override = StateOverride::default(); - /// let mut account_override = AccountOverride::default(); - /// account_override.balance = Some(U256::from(1000)); - /// state_override.insert(to, account_override); - /// let state_override_object = StateOverridesBuilder::default().build(); - /// let block_override_object = BlockOverrides::default(); - /// - /// let cast = Cast::new(alloy_provider); - /// let data = cast.call(&tx, None, None, Some(state_override_object), Some(block_override_object)).await?; - /// println!("{}", data); - /// # Ok(()) - /// # } - /// ``` - pub async fn call( - &self, - req: &WithOtherFields, - func: Option<&Function>, - block: Option, - state_override: Option, - block_override: Option, - ) -> Result { - let mut call = self - .provider - .call(req.clone()) - .block(block.unwrap_or_default()) - .with_block_overrides_opt(block_override); - if let Some(state_override) = state_override { - call = call.overrides(state_override) - } - - let res = call.await?; - let mut decoded = vec![]; - - if let Some(func) = func { - // decode args into tokens - decoded = match func.abi_decode_output(res.as_ref()) { - Ok(decoded) => decoded, - Err(err) => { - // ensure the address is a contract - if res.is_empty() { - // check that the recipient is a contract that can be called - if let Some(TxKind::Call(addr)) = req.to { - if let Ok(code) = self - .provider - .get_code_at(addr) - .block_id(block.unwrap_or_default()) - .await - && code.is_empty() - { - eyre::bail!("contract {addr:?} does not have any code") - } - } else if Some(TxKind::Create) == req.to { - eyre::bail!("tx req is a contract deployment"); - } else { - eyre::bail!("recipient is None"); - } - } - return Err(err).wrap_err( - "could not decode output; did you specify the wrong function return data type?" - ); - } - }; - } - - // handle case when return type is not specified - Ok(if decoded.is_empty() { - res.to_string() - } else if shell::is_json() { - let tokens = decoded.iter().map(format_token_raw).collect::>(); - serde_json::to_string_pretty(&tokens).unwrap() - } else { - // seth compatible user-friendly return type conversions - decoded.iter().map(format_token).collect::>().join("\n") - }) - } - - /// Generates an access list for the specified transaction - /// - /// # Example - /// - /// ``` - /// use cast::{Cast}; - /// use alloy_primitives::{Address, U256, Bytes}; - /// use alloy_rpc_types::{TransactionRequest}; - /// use alloy_serde::WithOtherFields; - /// use alloy_provider::{RootProvider, ProviderBuilder, network::AnyNetwork}; - /// use std::str::FromStr; - /// use alloy_sol_types::{sol, SolCall}; - /// - /// sol!( - /// function greeting(uint256 i) public returns (string); - /// ); - /// - /// # async fn foo() -> eyre::Result<()> { - /// let provider = ProviderBuilder::<_,_, AnyNetwork>::default().connect("http://localhost:8545").await?;; - /// let to = Address::from_str("0xB3C95ff08316fb2F2e3E52Ee82F8e7b605Aa1304")?; - /// let greeting = greetingCall { i: U256::from(5) }.abi_encode(); - /// let bytes = Bytes::from_iter(greeting.iter()); - /// let tx = TransactionRequest::default().to(to).input(bytes.into()); - /// let tx = WithOtherFields::new(tx); - /// let cast = Cast::new(&provider); - /// let access_list = cast.access_list(&tx, None).await?; - /// println!("{}", access_list); - /// # Ok(()) - /// # } - /// ``` - pub async fn access_list( - &self, - req: &WithOtherFields, - block: Option, - ) -> Result { - let access_list = - self.provider.create_access_list(req).block_id(block.unwrap_or_default()).await?; - let res = if shell::is_json() { - serde_json::to_string(&access_list)? - } else { - let mut s = - vec![format!("gas used: {}", access_list.gas_used), "access list:".to_string()]; - for al in access_list.access_list.0 { - s.push(format!("- address: {}", &al.address.to_checksum(None))); - if !al.storage_keys.is_empty() { - s.push(" keys:".to_string()); - for key in al.storage_keys { - s.push(format!(" {key:?}")); - } - } - } - s.join("\n") - }; - - Ok(res) - } - - pub async fn balance(&self, who: Address, block: Option) -> Result { - Ok(self.provider.get_balance(who).block_id(block.unwrap_or_default()).await?) - } - - /// Sends a transaction to the specified address - /// - /// # Example - /// - /// ``` - /// use cast::{Cast}; - /// use alloy_primitives::{Address, U256, Bytes}; - /// use alloy_serde::WithOtherFields; - /// use alloy_rpc_types::{TransactionRequest}; - /// use alloy_provider::{RootProvider, ProviderBuilder, network::AnyNetwork}; - /// use std::str::FromStr; - /// use alloy_sol_types::{sol, SolCall}; - /// - /// sol!( - /// function greet(string greeting) public; - /// ); - /// - /// # async fn foo() -> eyre::Result<()> { - /// let provider = ProviderBuilder::<_,_, AnyNetwork>::default().connect("http://localhost:8545").await?;; - /// let from = Address::from_str("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045")?; - /// let to = Address::from_str("0xB3C95ff08316fb2F2e3E52Ee82F8e7b605Aa1304")?; - /// let greeting = greetCall { greeting: "hello".to_string() }.abi_encode(); - /// let bytes = Bytes::from_iter(greeting.iter()); - /// let gas = U256::from_str("200000").unwrap(); - /// let value = U256::from_str("1").unwrap(); - /// let nonce = U256::from_str("1").unwrap(); - /// let tx = TransactionRequest::default().to(to).input(bytes.into()).from(from); - /// let tx = WithOtherFields::new(tx); - /// let cast = Cast::new(provider); - /// let data = cast.send(tx).await?; - /// println!("{:#?}", data); - /// # Ok(()) - /// # } - /// ``` - pub async fn send( - &self, - tx: WithOtherFields, - ) -> Result> { - let res = self.provider.send_transaction(tx).await?; - - Ok(res) - } - - /// Publishes a raw transaction to the network - /// - /// # Example - /// - /// ``` - /// use alloy_provider::{ProviderBuilder, RootProvider, network::AnyNetwork}; - /// use cast::Cast; - /// - /// # async fn foo() -> eyre::Result<()> { - /// let provider = - /// ProviderBuilder::<_, _, AnyNetwork>::default().connect("http://localhost:8545").await?; - /// let cast = Cast::new(provider); - /// let res = cast.publish("0x1234".to_string()).await?; - /// println!("{:?}", res); - /// # Ok(()) - /// # } - /// ``` - pub async fn publish( - &self, - mut raw_tx: String, - ) -> Result> { - raw_tx = match raw_tx.strip_prefix("0x") { - Some(s) => s.to_string(), - None => raw_tx, - }; - let tx = hex::decode(raw_tx)?; - let res = self.provider.send_raw_transaction(&tx).await?; - - Ok(res) - } - - /// # Example - /// - /// ``` - /// use alloy_provider::{ProviderBuilder, RootProvider, network::AnyNetwork}; - /// use cast::Cast; - /// - /// # async fn foo() -> eyre::Result<()> { - /// let provider = - /// ProviderBuilder::<_, _, AnyNetwork>::default().connect("http://localhost:8545").await?; - /// let cast = Cast::new(provider); - /// let block = cast.block(5, true, None, false).await?; - /// println!("{}", block); - /// # Ok(()) - /// # } - /// ``` - pub async fn block>( - &self, - block: B, - full: bool, - field: Option, - raw: bool, - ) -> Result { - let block = block.into(); - if let Some(ref field) = field - && field == "transactions" - && !full - { - eyre::bail!("use --full to view transactions") - } - - let block = self - .provider - .get_block(block) - .kind(full.into()) - .await? - .ok_or_else(|| eyre::eyre!("block {:?} not found", block))?; - - Ok(if raw { - let header: Header = block.into_inner().header.inner.try_into_header()?; - format!("0x{}", hex::encode(alloy_rlp::encode(&header))) - } else if let Some(ref field) = field { - get_pretty_block_attr(&block, field) - .unwrap_or_else(|| format!("{field} is not a valid block field")) - } else if shell::is_json() { - serde_json::to_value(&block).unwrap().to_string() - } else { - block.pretty() - }) - } - - async fn block_field_as_num>(&self, block: B, field: String) -> Result { - Self::block( - self, - block.into(), - false, - // Select only select field - Some(field), - false, - ) - .await? - .parse() - .map_err(Into::into) - } - - pub async fn base_fee>(&self, block: B) -> Result { - Self::block_field_as_num(self, block, String::from("baseFeePerGas")).await - } - - pub async fn age>(&self, block: B) -> Result { - let timestamp_str = - Self::block_field_as_num(self, block, String::from("timestamp")).await?.to_string(); - let datetime = DateTime::from_timestamp(timestamp_str.parse::().unwrap(), 0).unwrap(); - Ok(datetime.format("%a %b %e %H:%M:%S %Y").to_string()) - } - - pub async fn timestamp>(&self, block: B) -> Result { - Self::block_field_as_num(self, block, "timestamp".to_string()).await - } - - pub async fn chain(&self) -> Result<&str> { - let genesis_hash = Self::block( - self, - 0, - false, - // Select only block hash - Some(String::from("hash")), - false, - ) - .await?; - - Ok(match &genesis_hash[..] { - "0xd4e56740f876aef8c010b86a40d5f56745a118d0906a34e69aec8c0db1cb8fa3" => { - match &(Self::block(self, 1920000, false, Some("hash".to_string()), false).await?)[..] - { - "0x94365e3a8c0b35089c1d1195081fe7489b528a84b22199c916180db8b28ade7f" => { - "etclive" - } - _ => "ethlive", - } - } - "0xa3c565fc15c7478862d50ccd6561e3c06b24cc509bf388941c25ea985ce32cb9" => "kovan", - "0x41941023680923e0fe4d74a34bdac8141f2540e3ae90623718e47d66d1ca4a2d" => "ropsten", - "0x7ca38a1916c42007829c55e69d3e9a73265554b586a499015373241b8a3fa48b" => { - "optimism-mainnet" - } - "0xc1fc15cd51159b1f1e5cbc4b82e85c1447ddfa33c52cf1d98d14fba0d6354be1" => { - "optimism-goerli" - } - "0x02adc9b449ff5f2467b8c674ece7ff9b21319d76c4ad62a67a70d552655927e5" => { - "optimism-kovan" - } - "0x521982bd54239dc71269eefb58601762cc15cfb2978e0becb46af7962ed6bfaa" => "fraxtal", - "0x910f5c4084b63fd860d0c2f9a04615115a5a991254700b39ba072290dbd77489" => { - "fraxtal-testnet" - } - "0x7ee576b35482195fc49205cec9af72ce14f003b9ae69f6ba0faef4514be8b442" => { - "arbitrum-mainnet" - } - "0x0cd786a2425d16f152c658316c423e6ce1181e15c3295826d7c9904cba9ce303" => "morden", - "0x6341fd3daf94b748c72ced5a5b26028f2474f5f00d824504e4fa37a75767e177" => "rinkeby", - "0xbf7e331f7f7c1dd2e05159666b3bf8bc7a8a3a9eb1d518969eab529dd9b88c1a" => "goerli", - "0x14c2283285a88fe5fce9bf5c573ab03d6616695d717b12a127188bcacfc743c4" => "kotti", - "0xa9c28ce2141b56c474f1dc504bee9b01eb1bd7d1a507580d5519d4437a97de1b" => "polygon-pos", - "0x7202b2b53c5a0836e773e319d18922cc756dd67432f9a1f65352b61f4406c697" => { - "polygon-pos-amoy-testnet" - } - "0x81005434635456a16f74ff7023fbe0bf423abbc8a8deb093ffff455c0ad3b741" => "polygon-zkevm", - "0x676c1a76a6c5855a32bdf7c61977a0d1510088a4eeac1330466453b3d08b60b9" => { - "polygon-zkevm-cardona-testnet" - } - "0x4f1dd23188aab3a76b463e4af801b52b1248ef073c648cbdc4c9333d3da79756" => "gnosis", - "0xada44fd8d2ecab8b08f256af07ad3e777f17fb434f8f8e678b312f576212ba9a" => "chiado", - "0x6d3c66c5357ec91d5c43af47e234a939b22557cbb552dc45bebbceeed90fbe34" => "bsctest", - "0x0d21840abff46b96c84b2ac9e10e4f5cdaeb5693cb665db62a2f3b02d2d57b5b" => "bsc", - "0x31ced5b9beb7f8782b014660da0cb18cc409f121f408186886e1ca3e8eeca96b" => { - match &(Self::block(self, 1, false, Some(String::from("hash")), false).await?)[..] { - "0x738639479dc82d199365626f90caa82f7eafcfe9ed354b456fb3d294597ceb53" => { - "avalanche-fuji" - } - _ => "avalanche", - } - } - "0x23a2658170ba70d014ba0d0d2709f8fbfe2fa660cd868c5f282f991eecbe38ee" => "ink", - "0xe5fd5cf0be56af58ad5751b401410d6b7a09d830fa459789746a3d0dd1c79834" => "ink-sepolia", - _ => "unknown", - }) - } - - pub async fn chain_id(&self) -> Result { - Ok(self.provider.get_chain_id().await?) - } - - pub async fn block_number(&self) -> Result { - Ok(self.provider.get_block_number().await?) - } - - pub async fn gas_price(&self) -> Result { - Ok(self.provider.get_gas_price().await?) - } - - /// # Example - /// - /// ``` - /// use alloy_primitives::Address; - /// use alloy_provider::{ProviderBuilder, RootProvider, network::AnyNetwork}; - /// use cast::Cast; - /// use std::str::FromStr; - /// - /// # async fn foo() -> eyre::Result<()> { - /// let provider = - /// ProviderBuilder::<_, _, AnyNetwork>::default().connect("http://localhost:8545").await?; - /// let cast = Cast::new(provider); - /// let addr = Address::from_str("0x7eD52863829AB99354F3a0503A622e82AcD5F7d3")?; - /// let nonce = cast.nonce(addr, None).await?; - /// println!("{}", nonce); - /// # Ok(()) - /// # } - /// ``` - pub async fn nonce(&self, who: Address, block: Option) -> Result { - Ok(self.provider.get_transaction_count(who).block_id(block.unwrap_or_default()).await?) - } - - /// #Example - /// - /// ``` - /// use alloy_primitives::{Address, FixedBytes}; - /// use alloy_provider::{network::AnyNetwork, ProviderBuilder, RootProvider}; - /// use cast::Cast; - /// use std::str::FromStr; - /// - /// # async fn foo() -> eyre::Result<()> { - /// let provider = - /// ProviderBuilder::<_, _, AnyNetwork>::default().connect("http://localhost:8545").await?; - /// let cast = Cast::new(provider); - /// let addr = Address::from_str("0x7eD52863829AB99354F3a0503A622e82AcD5F7d3")?; - /// let slots = vec![FixedBytes::from_str("0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421")?]; - /// let codehash = cast.codehash(addr, slots, None).await?; - /// println!("{}", codehash); - /// # Ok(()) - /// # } - pub async fn codehash( - &self, - who: Address, - slots: Vec, - block: Option, - ) -> Result { - Ok(self - .provider - .get_proof(who, slots) - .block_id(block.unwrap_or_default()) - .await? - .code_hash - .to_string()) - } - - /// #Example - /// - /// ``` - /// use alloy_primitives::{Address, FixedBytes}; - /// use alloy_provider::{network::AnyNetwork, ProviderBuilder, RootProvider}; - /// use cast::Cast; - /// use std::str::FromStr; - /// - /// # async fn foo() -> eyre::Result<()> { - /// let provider = - /// ProviderBuilder::<_, _, AnyNetwork>::default().connect("http://localhost:8545").await?; - /// let cast = Cast::new(provider); - /// let addr = Address::from_str("0x7eD52863829AB99354F3a0503A622e82AcD5F7d3")?; - /// let slots = vec![FixedBytes::from_str("0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421")?]; - /// let storage_root = cast.storage_root(addr, slots, None).await?; - /// println!("{}", storage_root); - /// # Ok(()) - /// # } - pub async fn storage_root( - &self, - who: Address, - slots: Vec, - block: Option, - ) -> Result { - Ok(self - .provider - .get_proof(who, slots) - .block_id(block.unwrap_or_default()) - .await? - .storage_hash - .to_string()) - } - - /// # Example - /// - /// ``` - /// use alloy_primitives::Address; - /// use alloy_provider::{ProviderBuilder, RootProvider, network::AnyNetwork}; - /// use cast::Cast; - /// use std::str::FromStr; - /// - /// # async fn foo() -> eyre::Result<()> { - /// let provider = - /// ProviderBuilder::<_, _, AnyNetwork>::default().connect("http://localhost:8545").await?; - /// let cast = Cast::new(provider); - /// let addr = Address::from_str("0x7eD52863829AB99354F3a0503A622e82AcD5F7d3")?; - /// let implementation = cast.implementation(addr, false, None).await?; - /// println!("{}", implementation); - /// # Ok(()) - /// # } - /// ``` - pub async fn implementation( - &self, - who: Address, - is_beacon: bool, - block: Option, - ) -> Result { - let slot = match is_beacon { - true => { - // Use the beacon slot : bytes32(uint256(keccak256('eip1967.proxy.beacon')) - 1) - B256::from_str( - "0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50", - )? - } - false => { - // Use the implementation slot : - // bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1) - B256::from_str( - "0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc", - )? - } - }; - - let value = self - .provider - .get_storage_at(who, slot.into()) - .block_id(block.unwrap_or_default()) - .await?; - let addr = Address::from_word(value.into()); - Ok(format!("{addr:?}")) - } - - /// # Example - /// - /// ``` - /// use alloy_primitives::Address; - /// use alloy_provider::{ProviderBuilder, RootProvider, network::AnyNetwork}; - /// use cast::Cast; - /// use std::str::FromStr; - /// - /// # async fn foo() -> eyre::Result<()> { - /// let provider = - /// ProviderBuilder::<_, _, AnyNetwork>::default().connect("http://localhost:8545").await?; - /// let cast = Cast::new(provider); - /// let addr = Address::from_str("0x7eD52863829AB99354F3a0503A622e82AcD5F7d3")?; - /// let admin = cast.admin(addr, None).await?; - /// println!("{}", admin); - /// # Ok(()) - /// # } - /// ``` - pub async fn admin(&self, who: Address, block: Option) -> Result { - let slot = - B256::from_str("0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103")?; - let value = self - .provider - .get_storage_at(who, slot.into()) - .block_id(block.unwrap_or_default()) - .await?; - let addr = Address::from_word(value.into()); - Ok(format!("{addr:?}")) - } - - /// # Example - /// - /// ``` - /// use alloy_primitives::{Address, U256}; - /// use alloy_provider::{ProviderBuilder, RootProvider, network::AnyNetwork}; - /// use cast::Cast; - /// use std::str::FromStr; - /// - /// # async fn foo() -> eyre::Result<()> { - /// let provider = - /// ProviderBuilder::<_, _, AnyNetwork>::default().connect("http://localhost:8545").await?; - /// let cast = Cast::new(provider); - /// let addr = Address::from_str("7eD52863829AB99354F3a0503A622e82AcD5F7d3")?; - /// let computed_address = cast.compute_address(addr, None).await?; - /// println!("Computed address for address {addr}: {computed_address}"); - /// # Ok(()) - /// # } - /// ``` - pub async fn compute_address(&self, address: Address, nonce: Option) -> Result

{ - let unpacked = if let Some(n) = nonce { n } else { self.nonce(address, None).await? }; - Ok(address.create(unpacked)) - } - - /// # Example - /// - /// ``` - /// use alloy_primitives::Address; - /// use alloy_provider::{ProviderBuilder, RootProvider, network::AnyNetwork}; - /// use cast::Cast; - /// use std::str::FromStr; - /// - /// # async fn foo() -> eyre::Result<()> { - /// let provider = - /// ProviderBuilder::<_, _, AnyNetwork>::default().connect("http://localhost:8545").await?; - /// let cast = Cast::new(provider); - /// let addr = Address::from_str("0x00000000219ab540356cbb839cbe05303d7705fa")?; - /// let code = cast.code(addr, None, false).await?; - /// println!("{}", code); - /// # Ok(()) - /// # } - /// ``` - pub async fn code( - &self, - who: Address, - block: Option, - disassemble: bool, - ) -> Result { - if disassemble { - let code = - self.provider.get_code_at(who).block_id(block.unwrap_or_default()).await?.to_vec(); - SimpleCast::disassemble(&code) - } else { - Ok(format!( - "{}", - self.provider.get_code_at(who).block_id(block.unwrap_or_default()).await? - )) - } - } - - /// Example - /// - /// ``` - /// use alloy_primitives::Address; - /// use alloy_provider::{ProviderBuilder, RootProvider, network::AnyNetwork}; - /// use cast::Cast; - /// use std::str::FromStr; - /// - /// # async fn foo() -> eyre::Result<()> { - /// let provider = - /// ProviderBuilder::<_, _, AnyNetwork>::default().connect("http://localhost:8545").await?; - /// let cast = Cast::new(provider); - /// let addr = Address::from_str("0x00000000219ab540356cbb839cbe05303d7705fa")?; - /// let codesize = cast.codesize(addr, None).await?; - /// println!("{}", codesize); - /// # Ok(()) - /// # } - /// ``` - pub async fn codesize(&self, who: Address, block: Option) -> Result { - let code = - self.provider.get_code_at(who).block_id(block.unwrap_or_default()).await?.to_vec(); - Ok(format!("{}", code.len())) - } - - /// # Example - /// - /// ``` - /// use alloy_provider::{ProviderBuilder, RootProvider, network::AnyNetwork}; - /// use cast::Cast; - /// - /// # async fn foo() -> eyre::Result<()> { - /// let provider = - /// ProviderBuilder::<_, _, AnyNetwork>::default().connect("http://localhost:8545").await?; - /// let cast = Cast::new(provider); - /// let tx_hash = "0xf8d1713ea15a81482958fb7ddf884baee8d3bcc478c5f2f604e008dc788ee4fc"; - /// let tx = cast.transaction(Some(tx_hash.to_string()), None, None, None, false).await?; - /// println!("{}", tx); - /// # Ok(()) - /// # } - /// ``` - pub async fn transaction( - &self, - tx_hash: Option, - from: Option, - nonce: Option, - field: Option, - raw: bool, - ) -> Result { - let tx = if let Some(tx_hash) = tx_hash { - let tx_hash = TxHash::from_str(&tx_hash).wrap_err("invalid tx hash")?; - self.provider - .get_transaction_by_hash(tx_hash) - .await? - .ok_or_else(|| eyre::eyre!("tx not found: {:?}", tx_hash))? - } else if let Some(from) = from { - // If nonce is not provided, uses 0. - let nonce = U64::from(nonce.unwrap_or_default()); - let from = from.resolve(self.provider.root()).await?; - - self.provider - .raw_request::<_, Option>( - "eth_getTransactionBySenderAndNonce".into(), - (from, nonce), - ) - .await? - .ok_or_else(|| { - eyre::eyre!("tx not found for sender {from} and nonce {:?}", nonce.to::()) - })? - } else { - eyre::bail!("tx hash or from address is required") - }; - - Ok(if raw { - // also consider opstack deposit transactions - let either_tx = tx.try_into_either::()?; - let encoded = either_tx.encoded_2718(); - format!("0x{}", hex::encode(encoded)) - } else if let Some(field) = field { - get_pretty_tx_attr(&tx.inner, field.as_str()) - .ok_or_else(|| eyre::eyre!("invalid tx field: {}", field.to_string()))? - } else if shell::is_json() { - // to_value first to sort json object keys - serde_json::to_value(&tx)?.to_string() - } else { - tx.pretty() - }) - } - - /// # Example - /// - /// ``` - /// use alloy_provider::{ProviderBuilder, RootProvider, network::AnyNetwork}; - /// use cast::Cast; - /// - /// # async fn foo() -> eyre::Result<()> { - /// let provider = - /// ProviderBuilder::<_, _, AnyNetwork>::default().connect("http://localhost:8545").await?; - /// let cast = Cast::new(provider); - /// let tx_hash = "0xf8d1713ea15a81482958fb7ddf884baee8d3bcc478c5f2f604e008dc788ee4fc"; - /// let receipt = cast.receipt(tx_hash.to_string(), None, 1, None, false).await?; - /// println!("{}", receipt); - /// # Ok(()) - /// # } - /// ``` - pub async fn receipt( - &self, - tx_hash: String, - field: Option, - confs: u64, - timeout: Option, - cast_async: bool, - ) -> Result { - let tx_hash = TxHash::from_str(&tx_hash).wrap_err("invalid tx hash")?; - - let mut receipt: TransactionReceiptWithRevertReason = - match self.provider.get_transaction_receipt(tx_hash).await? { - Some(r) => r, - None => { - // if the async flag is provided, immediately exit if no tx is found, otherwise - // try to poll for it - if cast_async { - eyre::bail!("tx not found: {:?}", tx_hash) - } else { - PendingTransactionBuilder::new(self.provider.root().clone(), tx_hash) - .with_required_confirmations(confs) - .with_timeout(timeout.map(Duration::from_secs)) - .get_receipt() - .await? - } - } - } - .into(); - - // Allow to fail silently - let _ = receipt.update_revert_reason(&self.provider).await; - - Ok(if let Some(ref field) = field { - get_pretty_tx_receipt_attr(&receipt, field) - .ok_or_else(|| eyre::eyre!("invalid receipt field: {}", field))? - } else if shell::is_json() { - // to_value first to sort json object keys - serde_json::to_value(&receipt)?.to_string() - } else { - receipt.pretty() - }) - } - - /// Perform a raw JSON-RPC request - /// - /// # Example - /// - /// ``` - /// use alloy_provider::{ProviderBuilder, RootProvider, network::AnyNetwork}; - /// use cast::Cast; - /// - /// # async fn foo() -> eyre::Result<()> { - /// let provider = - /// ProviderBuilder::<_, _, AnyNetwork>::default().connect("http://localhost:8545").await?; - /// let cast = Cast::new(provider); - /// let result = cast - /// .rpc("eth_getBalance", &["0xc94770007dda54cF92009BFF0dE90c06F603a09f", "latest"]) - /// .await?; - /// println!("{}", result); - /// # Ok(()) - /// # } - /// ``` - pub async fn rpc(&self, method: &str, params: V) -> Result - where - V: alloy_json_rpc::RpcSend, - { - let res = self - .provider - .raw_request::(Cow::Owned(method.to_string()), params) - .await?; - Ok(serde_json::to_string(&res)?) - } - - /// Returns the slot - /// - /// # Example - /// - /// ``` - /// use alloy_primitives::{Address, B256}; - /// use alloy_provider::{ProviderBuilder, RootProvider, network::AnyNetwork}; - /// use cast::Cast; - /// use std::str::FromStr; - /// - /// # async fn foo() -> eyre::Result<()> { - /// let provider = - /// ProviderBuilder::<_, _, AnyNetwork>::default().connect("http://localhost:8545").await?; - /// let cast = Cast::new(provider); - /// let addr = Address::from_str("0x00000000006c3852cbEf3e08E8dF289169EdE581")?; - /// let slot = B256::ZERO; - /// let storage = cast.storage(addr, slot, None).await?; - /// println!("{}", storage); - /// # Ok(()) - /// # } - /// ``` - pub async fn storage( - &self, - from: Address, - slot: B256, - block: Option, - ) -> Result { - Ok(format!( - "{:?}", - B256::from( - self.provider - .get_storage_at(from, slot.into()) - .block_id(block.unwrap_or_default()) - .await? - ) - )) - } - - pub async fn filter_logs(&self, filter: Filter) -> Result { - let logs = self.provider.get_logs(&filter).await?; - - let res = if shell::is_json() { - serde_json::to_string(&logs)? - } else { - let mut s = vec![]; - for log in logs { - let pretty = log - .pretty() - .replacen('\n', "- ", 1) // Remove empty first line - .replace('\n', "\n "); // Indent - s.push(pretty); - } - s.join("\n") - }; - Ok(res) - } - - /// Converts a block identifier into a block number. - /// - /// If the block identifier is a block number, then this function returns the block number. If - /// the block identifier is a block hash, then this function returns the block number of - /// that block hash. If the block identifier is `None`, then this function returns `None`. - /// - /// # Example - /// - /// ``` - /// use alloy_primitives::fixed_bytes; - /// use alloy_provider::{ProviderBuilder, RootProvider, network::AnyNetwork}; - /// use alloy_rpc_types::{BlockId, BlockNumberOrTag}; - /// use cast::Cast; - /// use std::{convert::TryFrom, str::FromStr}; - /// - /// # async fn foo() -> eyre::Result<()> { - /// let provider = - /// ProviderBuilder::<_, _, AnyNetwork>::default().connect("http://localhost:8545").await?; - /// let cast = Cast::new(provider); - /// - /// let block_number = cast.convert_block_number(Some(BlockId::number(5))).await?; - /// assert_eq!(block_number, Some(BlockNumberOrTag::Number(5))); - /// - /// let block_number = cast - /// .convert_block_number(Some(BlockId::hash(fixed_bytes!( - /// "0000000000000000000000000000000000000000000000000000000000001234" - /// )))) - /// .await?; - /// assert_eq!(block_number, Some(BlockNumberOrTag::Number(4660))); - /// - /// let block_number = cast.convert_block_number(None).await?; - /// assert_eq!(block_number, None); - /// # Ok(()) - /// # } - /// ``` - pub async fn convert_block_number( - &self, - block: Option, - ) -> Result, eyre::Error> { - match block { - Some(block) => match block { - BlockId::Number(block_number) => Ok(Some(block_number)), - BlockId::Hash(hash) => { - let block = self.provider.get_block_by_hash(hash.block_hash).await?; - Ok(block.map(|block| block.header.number).map(BlockNumberOrTag::from)) - } - }, - None => Ok(None), - } - } - - /// Sets up a subscription to the given filter and writes the logs to the given output. - /// - /// # Example - /// - /// ``` - /// use alloy_primitives::Address; - /// use alloy_provider::{ProviderBuilder, RootProvider, network::AnyNetwork}; - /// use alloy_rpc_types::Filter; - /// use alloy_transport::BoxTransport; - /// use cast::Cast; - /// use std::{io, str::FromStr}; - /// - /// # async fn foo() -> eyre::Result<()> { - /// let provider = - /// ProviderBuilder::<_, _, AnyNetwork>::default().connect("wss://localhost:8545").await?; - /// let cast = Cast::new(provider); - /// - /// let filter = - /// Filter::new().address(Address::from_str("0x00000000006c3852cbEf3e08E8dF289169EdE581")?); - /// let mut output = io::stdout(); - /// cast.subscribe(filter, &mut output).await?; - /// # Ok(()) - /// # } - /// ``` - pub async fn subscribe(&self, filter: Filter, output: &mut dyn io::Write) -> Result<()> { - // Initialize the subscription stream for logs - let mut subscription = self.provider.subscribe_logs(&filter).await?.into_stream(); - - // Check if a to_block is specified, if so, subscribe to blocks - let mut block_subscription = if filter.get_to_block().is_some() { - Some(self.provider.subscribe_blocks().await?.into_stream()) - } else { - None - }; - - let format_json = shell::is_json(); - let to_block_number = filter.get_to_block(); - - // If output should be JSON, start with an opening bracket - if format_json { - write!(output, "[")?; - } - - let mut first = true; - - loop { - tokio::select! { - // If block subscription is present, listen to it to avoid blocking indefinitely past the desired to_block - block = if let Some(bs) = &mut block_subscription { - Either::Left(bs.next().fuse()) - } else { - Either::Right(futures::future::pending()) - } => { - if let (Some(block), Some(to_block)) = (block, to_block_number) - && block.number > to_block { - break; - } - }, - // Process incoming log - log = subscription.next() => { - if format_json { - if !first { - write!(output, ",")?; - } - first = false; - let log_str = serde_json::to_string(&log).unwrap(); - write!(output, "{log_str}")?; - } else { - let log_str = log.pretty() - .replacen('\n', "- ", 1) // Remove empty first line - .replace('\n', "\n "); // Indent - writeln!(output, "{log_str}")?; - } - }, - // Break on cancel signal, to allow for closing JSON bracket - _ = ctrl_c() => { - break; - }, - else => break, - } - } - - // If output was JSON, end with a closing bracket - if format_json { - write!(output, "]")?; - } - - Ok(()) - } - - pub async fn erc20_balance( - &self, - token: Address, - owner: Address, - block: Option, - ) -> Result { - Ok(IERC20::new(token, &self.provider) - .balanceOf(owner) - .block(block.unwrap_or_default()) - .call() - .await?) - } -} - -pub struct SimpleCast; - -impl SimpleCast { - /// Returns the maximum value of the given integer type - /// - /// # Example - /// - /// ``` - /// use alloy_primitives::{I256, U256}; - /// use cast::SimpleCast; - /// - /// assert_eq!(SimpleCast::max_int("uint256")?, U256::MAX.to_string()); - /// assert_eq!(SimpleCast::max_int("int256")?, I256::MAX.to_string()); - /// assert_eq!(SimpleCast::max_int("int32")?, i32::MAX.to_string()); - /// # Ok::<(), eyre::Report>(()) - /// ``` - pub fn max_int(s: &str) -> Result { - Self::max_min_int::(s) - } - - /// Returns the maximum value of the given integer type - /// - /// # Example - /// - /// ``` - /// use alloy_primitives::{I256, U256}; - /// use cast::SimpleCast; - /// - /// assert_eq!(SimpleCast::min_int("uint256")?, "0"); - /// assert_eq!(SimpleCast::min_int("int256")?, I256::MIN.to_string()); - /// assert_eq!(SimpleCast::min_int("int32")?, i32::MIN.to_string()); - /// # Ok::<(), eyre::Report>(()) - /// ``` - pub fn min_int(s: &str) -> Result { - Self::max_min_int::(s) - } - - fn max_min_int(s: &str) -> Result { - let ty = DynSolType::parse(s).wrap_err("Invalid type, expected `(u)int`")?; - match ty { - DynSolType::Int(n) => { - let mask = U256::from(1).wrapping_shl(n - 1); - let max = (U256::MAX & mask).saturating_sub(U256::from(1)); - if MAX { - Ok(max.to_string()) - } else { - let min = I256::from_raw(max).wrapping_neg() + I256::MINUS_ONE; - Ok(min.to_string()) - } - } - DynSolType::Uint(n) => { - if MAX { - let mut max = U256::MAX; - if n < 255 { - max &= U256::from(1).wrapping_shl(n).wrapping_sub(U256::from(1)); - } - Ok(max.to_string()) - } else { - Ok("0".to_string()) - } - } - _ => Err(eyre::eyre!("Type is not int/uint: {s}")), - } - } - - /// Converts UTF-8 text input to hex - /// - /// # Example - /// - /// ``` - /// use cast::SimpleCast as Cast; - /// - /// assert_eq!(Cast::from_utf8("yo"), "0x796f"); - /// assert_eq!(Cast::from_utf8("Hello, World!"), "0x48656c6c6f2c20576f726c6421"); - /// assert_eq!(Cast::from_utf8("TurboDappTools"), "0x547572626f44617070546f6f6c73"); - /// # Ok::<_, eyre::Report>(()) - /// ``` - pub fn from_utf8(s: &str) -> String { - hex::encode_prefixed(s) - } - - /// Converts hex input to UTF-8 text - /// - /// # Example - /// - /// ``` - /// use cast::SimpleCast as Cast; - /// - /// assert_eq!(Cast::to_utf8("0x796f")?, "yo"); - /// assert_eq!(Cast::to_utf8("0x48656c6c6f2c20576f726c6421")?, "Hello, World!"); - /// assert_eq!(Cast::to_utf8("0x547572626f44617070546f6f6c73")?, "TurboDappTools"); - /// assert_eq!(Cast::to_utf8("0xe4bda0e5a5bd")?, "你好"); - /// # Ok::<_, eyre::Report>(()) - /// ``` - pub fn to_utf8(s: &str) -> Result { - let bytes = hex::decode(s)?; - Ok(String::from_utf8_lossy(bytes.as_ref()).to_string()) - } - - /// Converts hex data into text data - /// - /// # Example - /// - /// ``` - /// use cast::SimpleCast as Cast; - /// - /// assert_eq!(Cast::to_ascii("0x796f")?, "yo"); - /// assert_eq!(Cast::to_ascii("48656c6c6f2c20576f726c6421")?, "Hello, World!"); - /// assert_eq!(Cast::to_ascii("0x547572626f44617070546f6f6c73")?, "TurboDappTools"); - /// # Ok::<_, eyre::Report>(()) - /// ``` - pub fn to_ascii(hex: &str) -> Result { - let bytes = hex::decode(hex)?; - if !bytes.iter().all(u8::is_ascii) { - return Err(eyre::eyre!("Invalid ASCII bytes")); - } - Ok(String::from_utf8(bytes).unwrap()) - } - - /// Converts fixed point number into specified number of decimals - /// ``` - /// use alloy_primitives::U256; - /// use cast::SimpleCast as Cast; - /// - /// assert_eq!(Cast::from_fixed_point("10", "0")?, "10"); - /// assert_eq!(Cast::from_fixed_point("1.0", "1")?, "10"); - /// assert_eq!(Cast::from_fixed_point("0.10", "2")?, "10"); - /// assert_eq!(Cast::from_fixed_point("0.010", "3")?, "10"); - /// # Ok::<_, eyre::Report>(()) - /// ``` - pub fn from_fixed_point(value: &str, decimals: &str) -> Result { - let units: Unit = Unit::from_str(decimals)?; - let n = ParseUnits::parse_units(value, units)?; - Ok(n.to_string()) - } - - /// Converts integers with specified decimals into fixed point numbers - /// - /// # Example - /// - /// ``` - /// use alloy_primitives::U256; - /// use cast::SimpleCast as Cast; - /// - /// assert_eq!(Cast::to_fixed_point("10", "0")?, "10."); - /// assert_eq!(Cast::to_fixed_point("10", "1")?, "1.0"); - /// assert_eq!(Cast::to_fixed_point("10", "2")?, "0.10"); - /// assert_eq!(Cast::to_fixed_point("10", "3")?, "0.010"); - /// - /// assert_eq!(Cast::to_fixed_point("-10", "0")?, "-10."); - /// assert_eq!(Cast::to_fixed_point("-10", "1")?, "-1.0"); - /// assert_eq!(Cast::to_fixed_point("-10", "2")?, "-0.10"); - /// assert_eq!(Cast::to_fixed_point("-10", "3")?, "-0.010"); - /// # Ok::<_, eyre::Report>(()) - /// ``` - pub fn to_fixed_point(value: &str, decimals: &str) -> Result { - let (sign, mut value, value_len) = { - let number = NumberWithBase::parse_int(value, None)?; - let sign = if number.is_nonnegative() { "" } else { "-" }; - let value = format!("{number:#}"); - let value_stripped = value.strip_prefix('-').unwrap_or(&value).to_string(); - let value_len = value_stripped.len(); - (sign, value_stripped, value_len) - }; - let decimals = NumberWithBase::parse_uint(decimals, None)?.number().to::(); - - let value = if decimals >= value_len { - // Add "0." and pad with 0s - format!("0.{value:0>decimals$}") - } else { - // Insert decimal at -idx (i.e 1 => decimal idx = -1) - value.insert(value_len - decimals, '.'); - value - }; - - Ok(format!("{sign}{value}")) - } - - /// Concatencates hex strings - /// - /// # Example - /// - /// ``` - /// use cast::SimpleCast as Cast; - /// - /// assert_eq!(Cast::concat_hex(["0x00", "0x01"]), "0x0001"); - /// assert_eq!(Cast::concat_hex(["1", "2"]), "0x12"); - /// # Ok::<_, eyre::Report>(()) - /// ``` - pub fn concat_hex>(values: impl IntoIterator) -> String { - let mut out = String::new(); - for s in values { - let s = s.as_ref(); - out.push_str(s.strip_prefix("0x").unwrap_or(s)) - } - format!("0x{out}") - } - - /// Converts a number into uint256 hex string with 0x prefix - /// - /// # Example - /// - /// ``` - /// use cast::SimpleCast as Cast; - /// - /// assert_eq!( - /// Cast::to_uint256("100")?, - /// "0x0000000000000000000000000000000000000000000000000000000000000064" - /// ); - /// assert_eq!( - /// Cast::to_uint256("192038293923")?, - /// "0x0000000000000000000000000000000000000000000000000000002cb65fd1a3" - /// ); - /// assert_eq!( - /// Cast::to_uint256( - /// "115792089237316195423570985008687907853269984665640564039457584007913129639935" - /// )?, - /// "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" - /// ); - /// # Ok::<_, eyre::Report>(()) - /// ``` - pub fn to_uint256(value: &str) -> Result { - let n = NumberWithBase::parse_uint(value, None)?; - Ok(format!("{n:#066x}")) - } - - /// Converts a number into int256 hex string with 0x prefix - /// - /// # Example - /// - /// ``` - /// use cast::SimpleCast as Cast; - /// - /// assert_eq!( - /// Cast::to_int256("0")?, - /// "0x0000000000000000000000000000000000000000000000000000000000000000" - /// ); - /// assert_eq!( - /// Cast::to_int256("100")?, - /// "0x0000000000000000000000000000000000000000000000000000000000000064" - /// ); - /// assert_eq!( - /// Cast::to_int256("-100")?, - /// "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff9c" - /// ); - /// assert_eq!( - /// Cast::to_int256("192038293923")?, - /// "0x0000000000000000000000000000000000000000000000000000002cb65fd1a3" - /// ); - /// assert_eq!( - /// Cast::to_int256("-192038293923")?, - /// "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffd349a02e5d" - /// ); - /// assert_eq!( - /// Cast::to_int256( - /// "57896044618658097711785492504343953926634992332820282019728792003956564819967" - /// )?, - /// "0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" - /// ); - /// assert_eq!( - /// Cast::to_int256( - /// "-57896044618658097711785492504343953926634992332820282019728792003956564819968" - /// )?, - /// "0x8000000000000000000000000000000000000000000000000000000000000000" - /// ); - /// # Ok::<_, eyre::Report>(()) - /// ``` - pub fn to_int256(value: &str) -> Result { - let n = NumberWithBase::parse_int(value, None)?; - Ok(format!("{n:#066x}")) - } - - /// Converts an eth amount into a specified unit - /// - /// # Example - /// - /// ``` - /// use cast::SimpleCast as Cast; - /// - /// assert_eq!(Cast::to_unit("1 wei", "wei")?, "1"); - /// assert_eq!(Cast::to_unit("1", "wei")?, "1"); - /// assert_eq!(Cast::to_unit("1ether", "wei")?, "1000000000000000000"); - /// # Ok::<_, eyre::Report>(()) - /// ``` - pub fn to_unit(value: &str, unit: &str) -> Result { - let value = DynSolType::coerce_str(&DynSolType::Uint(256), value)? - .as_uint() - .wrap_err("Could not convert to uint")? - .0; - let unit = unit.parse().wrap_err("could not parse units")?; - Ok(Self::format_unit_as_string(value, unit)) - } - - /// Convert a number into a uint with arbitrary decimals. - /// - /// # Example - /// - /// ``` - /// use cast::SimpleCast as Cast; - /// - /// # fn main() -> eyre::Result<()> { - /// assert_eq!(Cast::parse_units("1.0", 6)?, "1000000"); // USDC (6 decimals) - /// assert_eq!(Cast::parse_units("2.5", 6)?, "2500000"); - /// assert_eq!(Cast::parse_units("1.0", 12)?, "1000000000000"); // 12 decimals - /// assert_eq!(Cast::parse_units("1.23", 3)?, "1230"); // 3 decimals - /// - /// # Ok(()) - /// # } - /// ``` - pub fn parse_units(value: &str, unit: u8) -> Result { - let unit = Unit::new(unit).ok_or_else(|| eyre::eyre!("invalid unit"))?; - - Ok(ParseUnits::parse_units(value, unit)?.to_string()) - } - - /// Format a number from smallest unit to decimal with arbitrary decimals. - /// - /// # Example - /// - /// ``` - /// use cast::SimpleCast as Cast; - /// - /// # fn main() -> eyre::Result<()> { - /// assert_eq!(Cast::format_units("1000000", 6)?, "1"); // USDC (6 decimals) - /// assert_eq!(Cast::format_units("2500000", 6)?, "2.500000"); - /// assert_eq!(Cast::format_units("1000000000000", 12)?, "1"); // 12 decimals - /// assert_eq!(Cast::format_units("1230", 3)?, "1.230"); // 3 decimals - /// - /// # Ok(()) - /// # } - /// ``` - pub fn format_units(value: &str, unit: u8) -> Result { - let value = NumberWithBase::parse_int(value, None)?.number(); - let unit = Unit::new(unit).ok_or_else(|| eyre::eyre!("invalid unit"))?; - Ok(Self::format_unit_as_string(value, unit)) - } - - // Helper function to format units as a string - fn format_unit_as_string(value: U256, unit: Unit) -> String { - let mut formatted = ParseUnits::U256(value).format_units(unit); - // Trim empty fractional part. - if let Some(dot) = formatted.find('.') { - let fractional = &formatted[dot + 1..]; - if fractional.chars().all(|c: char| c == '0') { - formatted = formatted[..dot].to_string(); - } - } - formatted - } - - /// Converts wei into an eth amount - /// - /// # Example - /// - /// ``` - /// use cast::SimpleCast as Cast; - /// - /// assert_eq!(Cast::from_wei("1", "gwei")?, "0.000000001"); - /// assert_eq!(Cast::from_wei("12340000005", "gwei")?, "12.340000005"); - /// assert_eq!(Cast::from_wei("10", "ether")?, "0.000000000000000010"); - /// assert_eq!(Cast::from_wei("100", "eth")?, "0.000000000000000100"); - /// assert_eq!(Cast::from_wei("17", "ether")?, "0.000000000000000017"); - /// # Ok::<_, eyre::Report>(()) - /// ``` - pub fn from_wei(value: &str, unit: &str) -> Result { - let value = NumberWithBase::parse_int(value, None)?.number(); - Ok(ParseUnits::U256(value).format_units(unit.parse()?)) - } - - /// Converts an eth amount into wei - /// - /// # Example - /// - /// ``` - /// use cast::SimpleCast as Cast; - /// - /// assert_eq!(Cast::to_wei("100", "gwei")?, "100000000000"); - /// assert_eq!(Cast::to_wei("100", "eth")?, "100000000000000000000"); - /// assert_eq!(Cast::to_wei("1000", "ether")?, "1000000000000000000000"); - /// # Ok::<_, eyre::Report>(()) - /// ``` - pub fn to_wei(value: &str, unit: &str) -> Result { - let unit = unit.parse().wrap_err("could not parse units")?; - Ok(ParseUnits::parse_units(value, unit)?.to_string()) - } - - // Decodes RLP encoded data with validation for canonical integer representation - /// - /// # Examples - /// ``` - /// use cast::SimpleCast as Cast; - /// - /// assert_eq!(Cast::from_rlp("0xc0", false).unwrap(), "[]"); - /// assert_eq!(Cast::from_rlp("0x0f", false).unwrap(), "\"0x0f\""); - /// assert_eq!(Cast::from_rlp("0x33", false).unwrap(), "\"0x33\""); - /// assert_eq!(Cast::from_rlp("0xc161", false).unwrap(), "[\"0x61\"]"); - /// assert_eq!(Cast::from_rlp("820002", true).is_err(), true); - /// assert_eq!(Cast::from_rlp("820002", false).unwrap(), "\"0x0002\""); - /// assert_eq!(Cast::from_rlp("00", true).is_err(), true); - /// assert_eq!(Cast::from_rlp("00", false).unwrap(), "\"0x00\""); - /// # Ok::<_, eyre::Report>(()) - /// ``` - pub fn from_rlp(value: impl AsRef, as_int: bool) -> Result { - let bytes = hex::decode(value.as_ref()).wrap_err("Could not decode hex")?; - - if as_int { - return Ok(U256::decode(&mut &bytes[..])?.to_string()); - } - - let item = Item::decode(&mut &bytes[..]).wrap_err("Could not decode rlp")?; - - Ok(item.to_string()) - } - - /// Encodes hex data or list of hex data to hexadecimal rlp - /// - /// # Example - /// - /// ``` - /// use cast::SimpleCast as Cast; - /// - /// assert_eq!(Cast::to_rlp("[]").unwrap(), "0xc0".to_string()); - /// assert_eq!(Cast::to_rlp("0x22").unwrap(), "0x22".to_string()); - /// assert_eq!(Cast::to_rlp("[\"0x61\"]",).unwrap(), "0xc161".to_string()); - /// assert_eq!(Cast::to_rlp("[\"0xf1\", \"f2\"]").unwrap(), "0xc481f181f2".to_string()); - /// # Ok::<_, eyre::Report>(()) - /// ``` - pub fn to_rlp(value: &str) -> Result { - let val = serde_json::from_str(value) - .unwrap_or_else(|_| serde_json::Value::String(value.to_string())); - let item = Item::value_to_item(&val)?; - Ok(format!("0x{}", hex::encode(alloy_rlp::encode(item)))) - } - - /// Converts a number of one base to another - /// - /// # Example - /// - /// ``` - /// use alloy_primitives::I256; - /// use cast::SimpleCast as Cast; - /// - /// assert_eq!(Cast::to_base("100", Some("10"), "16")?, "0x64"); - /// assert_eq!(Cast::to_base("100", Some("10"), "oct")?, "0o144"); - /// assert_eq!(Cast::to_base("100", Some("10"), "binary")?, "0b1100100"); - /// - /// assert_eq!(Cast::to_base("0xffffffffffffffff", None, "10")?, u64::MAX.to_string()); - /// assert_eq!( - /// Cast::to_base("0xffffffffffffffffffffffffffffffff", None, "dec")?, - /// u128::MAX.to_string() - /// ); - /// // U256::MAX overflows as internally it is being parsed as I256 - /// assert_eq!( - /// Cast::to_base( - /// "0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", - /// None, - /// "decimal" - /// )?, - /// I256::MAX.to_string() - /// ); - /// # Ok::<_, eyre::Report>(()) - /// ``` - pub fn to_base(value: &str, base_in: Option<&str>, base_out: &str) -> Result { - let base_in = Base::unwrap_or_detect(base_in, value)?; - let base_out: Base = base_out.parse()?; - if base_in == base_out { - return Ok(value.to_string()); - } - - let mut n = NumberWithBase::parse_int(value, Some(&base_in.to_string()))?; - n.set_base(base_out); - - // Use Debug fmt - Ok(format!("{n:#?}")) - } - - /// Converts hexdata into bytes32 value - /// - /// # Example - /// - /// ``` - /// use cast::SimpleCast as Cast; - /// - /// let bytes = Cast::to_bytes32("1234")?; - /// assert_eq!(bytes, "0x1234000000000000000000000000000000000000000000000000000000000000"); - /// - /// let bytes = Cast::to_bytes32("0x1234")?; - /// assert_eq!(bytes, "0x1234000000000000000000000000000000000000000000000000000000000000"); - /// - /// let err = Cast::to_bytes32("0x123400000000000000000000000000000000000000000000000000000000000011").unwrap_err(); - /// assert_eq!(err.to_string(), "string >32 bytes"); - /// # Ok::<_, eyre::Report>(()) - pub fn to_bytes32(s: &str) -> Result { - let s = strip_0x(s); - if s.len() > 64 { - eyre::bail!("string >32 bytes"); - } - - let padded = format!("{s:0<64}"); - Ok(padded.parse::()?.to_string()) - } - - /// Encodes string into bytes32 value - pub fn format_bytes32_string(s: &str) -> Result { - let str_bytes: &[u8] = s.as_bytes(); - eyre::ensure!(str_bytes.len() <= 32, "bytes32 strings must not exceed 32 bytes in length"); - - let mut bytes32: [u8; 32] = [0u8; 32]; - bytes32[..str_bytes.len()].copy_from_slice(str_bytes); - Ok(hex::encode_prefixed(bytes32)) - } - - /// Decodes string from bytes32 value - pub fn parse_bytes32_string(s: &str) -> Result { - let bytes = hex::decode(s)?; - eyre::ensure!(bytes.len() == 32, "expected 32 byte hex-string"); - let len = bytes.iter().take_while(|x| **x != 0).count(); - Ok(std::str::from_utf8(&bytes[..len])?.into()) - } - - /// Decodes checksummed address from bytes32 value - pub fn parse_bytes32_address(s: &str) -> Result { - let s = strip_0x(s); - if s.len() != 64 { - eyre::bail!("expected 64 byte hex-string, got {s}"); - } - - let s = if let Some(stripped) = s.strip_prefix("000000000000000000000000") { - stripped - } else { - return Err(eyre::eyre!("Not convertible to address, there are non-zero bytes")); - }; - - let lowercase_address_string = format!("0x{s}"); - let lowercase_address = Address::from_str(&lowercase_address_string)?; - - Ok(lowercase_address.to_checksum(None)) - } - - /// Decodes abi-encoded hex input or output - /// - /// When `input=true`, `calldata` string MUST not be prefixed with function selector - /// - /// # Example - /// - /// ``` - /// use cast::SimpleCast as Cast; - /// use alloy_primitives::hex; - /// - /// // Passing `input = false` will decode the data as the output type. - /// // The input data types and the full function sig are ignored, i.e. - /// // you could also pass `balanceOf()(uint256)` and it'd still work. - /// let data = "0x0000000000000000000000000000000000000000000000000000000000000001"; - /// let sig = "balanceOf(address, uint256)(uint256)"; - /// let decoded = Cast::abi_decode(sig, data, false)?[0].as_uint().unwrap().0.to_string(); - /// assert_eq!(decoded, "1"); - /// - /// // Passing `input = true` will decode the data with the input function signature. - /// // We exclude the "prefixed" function selector from the data field (the first 4 bytes). - /// let data = "0x0000000000000000000000008dbd1b711dc621e1404633da156fcc779e1c6f3e000000000000000000000000d9f3c9cc99548bf3b44a43e0a2d07399eb918adc000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000"; - /// let sig = "safeTransferFrom(address, address, uint256, uint256, bytes)"; - /// let decoded = Cast::abi_decode(sig, data, true)?; - /// let decoded = [ - /// decoded[0].as_address().unwrap().to_string().to_lowercase(), - /// decoded[1].as_address().unwrap().to_string().to_lowercase(), - /// decoded[2].as_uint().unwrap().0.to_string(), - /// decoded[3].as_uint().unwrap().0.to_string(), - /// hex::encode(decoded[4].as_bytes().unwrap()) - /// ] - /// .into_iter() - /// .collect::>(); - /// - /// assert_eq!( - /// decoded, - /// vec!["0x8dbd1b711dc621e1404633da156fcc779e1c6f3e", "0xd9f3c9cc99548bf3b44a43e0a2d07399eb918adc", "42", "1", ""] - /// ); - /// # Ok::<_, eyre::Report>(()) - /// ``` - pub fn abi_decode(sig: &str, calldata: &str, input: bool) -> Result> { - foundry_common::abi::abi_decode_calldata(sig, calldata, input, false) - } - - /// Decodes calldata-encoded hex input or output - /// - /// Similar to `abi_decode`, but `calldata` string MUST be prefixed with function selector - /// - /// # Example - /// - /// ``` - /// use cast::SimpleCast as Cast; - /// use alloy_primitives::hex; - /// - /// // Passing `input = false` will decode the data as the output type. - /// // The input data types and the full function sig are ignored, i.e. - /// // you could also pass `balanceOf()(uint256)` and it'd still work. - /// let data = "0x0000000000000000000000000000000000000000000000000000000000000001"; - /// let sig = "balanceOf(address, uint256)(uint256)"; - /// let decoded = Cast::calldata_decode(sig, data, false)?[0].as_uint().unwrap().0.to_string(); - /// assert_eq!(decoded, "1"); - /// - /// // Passing `input = true` will decode the data with the input function signature. - /// let data = "0xf242432a0000000000000000000000008dbd1b711dc621e1404633da156fcc779e1c6f3e000000000000000000000000d9f3c9cc99548bf3b44a43e0a2d07399eb918adc000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000"; - /// let sig = "safeTransferFrom(address, address, uint256, uint256, bytes)"; - /// let decoded = Cast::calldata_decode(sig, data, true)?; - /// let decoded = [ - /// decoded[0].as_address().unwrap().to_string().to_lowercase(), - /// decoded[1].as_address().unwrap().to_string().to_lowercase(), - /// decoded[2].as_uint().unwrap().0.to_string(), - /// decoded[3].as_uint().unwrap().0.to_string(), - /// hex::encode(decoded[4].as_bytes().unwrap()), - /// ] - /// .into_iter() - /// .collect::>(); - /// assert_eq!( - /// decoded, - /// vec!["0x8dbd1b711dc621e1404633da156fcc779e1c6f3e", "0xd9f3c9cc99548bf3b44a43e0a2d07399eb918adc", "42", "1", ""] - /// ); - /// # Ok::<_, eyre::Report>(()) - /// ``` - pub fn calldata_decode(sig: &str, calldata: &str, input: bool) -> Result> { - foundry_common::abi::abi_decode_calldata(sig, calldata, input, true) - } - - /// Performs ABI encoding based off of the function signature. Does not include - /// the function selector in the result. - /// - /// # Example - /// - /// ``` - /// use cast::SimpleCast as Cast; - /// - /// assert_eq!( - /// "0x0000000000000000000000000000000000000000000000000000000000000001", - /// Cast::abi_encode("f(uint a)", &["1"]).unwrap().as_str() - /// ); - /// assert_eq!( - /// "0x0000000000000000000000000000000000000000000000000000000000000001", - /// Cast::abi_encode("constructor(uint a)", &["1"]).unwrap().as_str() - /// ); - /// # Ok::<_, eyre::Report>(()) - /// ``` - pub fn abi_encode(sig: &str, args: &[impl AsRef]) -> Result { - let func = get_func(sig)?; - match encode_function_args(&func, args) { - Ok(res) => Ok(hex::encode_prefixed(&res[4..])), - Err(e) => eyre::bail!( - "Could not ABI encode the function and arguments. Did you pass in the right types?\nError\n{}", - e - ), - } - } - - /// Performs packed ABI encoding based off of the function signature or tuple. - /// - /// # Examplez - /// - /// ``` - /// use cast::SimpleCast as Cast; - /// - /// assert_eq!( - /// "0x0000000000000000000000000000000000000000000000000000000000000064000000000000000000000000000000000000000000000000000000000000012c00000000000000c8", - /// Cast::abi_encode_packed("(uint128[] a, uint64 b)", &["[100, 300]", "200"]).unwrap().as_str() - /// ); - /// - /// assert_eq!( - /// "0x8dbd1b711dc621e1404633da156fcc779e1c6f3e68656c6c6f20776f726c64", - /// Cast::abi_encode_packed("foo(address a, string b)", &["0x8dbd1b711dc621e1404633da156fcc779e1c6f3e", "hello world"]).unwrap().as_str() - /// ); - /// # Ok::<_, eyre::Report>(()) - /// ``` - pub fn abi_encode_packed(sig: &str, args: &[impl AsRef]) -> Result { - // If the signature is a tuple, we need to prefix it to make it a function - let sig = - if sig.trim_start().starts_with('(') { format!("foo{sig}") } else { sig.to_string() }; - - let func = get_func(sig.as_str())?; - let encoded = match encode_function_args_packed(&func, args) { - Ok(res) => hex::encode(res), - Err(e) => eyre::bail!( - "Could not ABI encode the function and arguments. Did you pass in the right types?\nError\n{}", - e - ), - }; - Ok(format!("0x{encoded}")) - } - - /// Performs ABI encoding to produce the hexadecimal calldata with the given arguments. - /// - /// # Example - /// - /// ``` - /// use cast::SimpleCast as Cast; - /// - /// assert_eq!( - /// "0xb3de648b0000000000000000000000000000000000000000000000000000000000000001", - /// Cast::calldata_encode("f(uint256 a)", &["1"]).unwrap().as_str() - /// ); - /// # Ok::<_, eyre::Report>(()) - /// ``` - pub fn calldata_encode(sig: impl AsRef, args: &[impl AsRef]) -> Result { - let func = get_func(sig.as_ref())?; - let calldata = encode_function_args(&func, args)?; - Ok(hex::encode_prefixed(calldata)) - } - - /// Returns the slot number for a given mapping key and slot. - /// - /// Given `mapping(k => v) m`, for a key `k` the slot number of its associated `v` is - /// `keccak256(concat(h(k), p))`, where `h` is the padding function for `k`'s type, and `p` - /// is slot number of the mapping `m`. - /// - /// See [the Solidity documentation](https://docs.soliditylang.org/en/latest/internals/layout_in_storage.html#mappings-and-dynamic-arrays) - /// for more details. - /// - /// # Example - /// - /// ``` - /// # use cast::SimpleCast as Cast; - /// - /// // Value types. - /// assert_eq!( - /// Cast::index("address", "0xD0074F4E6490ae3f888d1d4f7E3E43326bD3f0f5", "2").unwrap().as_str(), - /// "0x9525a448a9000053a4d151336329d6563b7e80b24f8e628e95527f218e8ab5fb" - /// ); - /// assert_eq!( - /// Cast::index("uint256", "42", "6").unwrap().as_str(), - /// "0xfc808b0f31a1e6b9cf25ff6289feae9b51017b392cc8e25620a94a38dcdafcc1" - /// ); - /// - /// // Strings and byte arrays. - /// assert_eq!( - /// Cast::index("string", "hello", "1").unwrap().as_str(), - /// "0x8404bb4d805e9ca2bd5dd5c43a107e935c8ec393caa7851b353b3192cd5379ae" - /// ); - /// # Ok::<_, eyre::Report>(()) - /// ``` - pub fn index(key_type: &str, key: &str, slot_number: &str) -> Result { - let mut hasher = Keccak256::new(); - - let k_ty = DynSolType::parse(key_type).wrap_err("Could not parse type")?; - let k = k_ty.coerce_str(key).wrap_err("Could not parse value")?; - match k_ty { - // For value types, `h` pads the value to 32 bytes in the same way as when storing the - // value in memory. - DynSolType::Bool - | DynSolType::Int(_) - | DynSolType::Uint(_) - | DynSolType::FixedBytes(_) - | DynSolType::Address - | DynSolType::Function => hasher.update(k.as_word().unwrap()), - - // For strings and byte arrays, `h(k)` is just the unpadded data. - DynSolType::String | DynSolType::Bytes => hasher.update(k.as_packed_seq().unwrap()), - - DynSolType::Array(..) - | DynSolType::FixedArray(..) - | DynSolType::Tuple(..) - | DynSolType::CustomStruct { .. } => { - eyre::bail!("Type `{k_ty}` is not supported as a mapping key") - } - } - - let p = DynSolType::Uint(256) - .coerce_str(slot_number) - .wrap_err("Could not parse slot number")?; - let p = p.as_word().unwrap(); - hasher.update(p); - - let location = hasher.finalize(); - Ok(location.to_string()) - } - - /// Keccak-256 hashes arbitrary data - /// - /// # Example - /// - /// ``` - /// use cast::SimpleCast as Cast; - /// - /// assert_eq!( - /// Cast::keccak("foo")?, - /// "0x41b1a0649752af1b28b3dc29a1556eee781e4a4c3a1f7f53f90fa834de098c4d" - /// ); - /// assert_eq!( - /// Cast::keccak("123abc")?, - /// "0xb1f1c74a1ba56f07a892ea1110a39349d40f66ca01d245e704621033cb7046a4" - /// ); - /// assert_eq!( - /// Cast::keccak("0x12")?, - /// "0x5fa2358263196dbbf23d1ca7a509451f7a2f64c15837bfbb81298b1e3e24e4fa" - /// ); - /// assert_eq!( - /// Cast::keccak("12")?, - /// "0x7f8b6b088b6d74c2852fc86c796dca07b44eed6fb3daf5e6b59f7c364db14528" - /// ); - /// # Ok::<_, eyre::Report>(()) - /// ``` - pub fn keccak(data: &str) -> Result { - // Hex-decode if data starts with 0x. - let hash = - if data.starts_with("0x") { keccak256(hex::decode(data)?) } else { keccak256(data) }; - Ok(hash.to_string()) - } - - /// Performs the left shift operation (<<) on a number - /// - /// # Example - /// - /// ``` - /// use cast::SimpleCast as Cast; - /// - /// assert_eq!(Cast::left_shift("16", "10", Some("10"), "hex")?, "0x4000"); - /// assert_eq!(Cast::left_shift("255", "16", Some("dec"), "hex")?, "0xff0000"); - /// assert_eq!(Cast::left_shift("0xff", "16", None, "hex")?, "0xff0000"); - /// # Ok::<_, eyre::Report>(()) - /// ``` - pub fn left_shift( - value: &str, - bits: &str, - base_in: Option<&str>, - base_out: &str, - ) -> Result { - let base_out: Base = base_out.parse()?; - let value = NumberWithBase::parse_uint(value, base_in)?; - let bits = NumberWithBase::parse_uint(bits, None)?; - - let res = value.number() << bits.number(); - - Ok(res.to_base(base_out, true)?) - } - - /// Performs the right shift operation (>>) on a number - /// - /// # Example - /// - /// ``` - /// use cast::SimpleCast as Cast; - /// - /// assert_eq!(Cast::right_shift("0x4000", "10", None, "dec")?, "16"); - /// assert_eq!(Cast::right_shift("16711680", "16", Some("10"), "hex")?, "0xff"); - /// assert_eq!(Cast::right_shift("0xff0000", "16", None, "hex")?, "0xff"); - /// # Ok::<(), eyre::Report>(()) - /// ``` - pub fn right_shift( - value: &str, - bits: &str, - base_in: Option<&str>, - base_out: &str, - ) -> Result { - let base_out: Base = base_out.parse()?; - let value = NumberWithBase::parse_uint(value, base_in)?; - let bits = NumberWithBase::parse_uint(bits, None)?; - - let res = value.number().wrapping_shr(bits.number().saturating_to()); - - Ok(res.to_base(base_out, true)?) - } - - /// Fetches source code of verified contracts from etherscan. - /// - /// # Example - /// - /// ``` - /// # use cast::SimpleCast as Cast; - /// # use foundry_config::NamedChain; - /// # async fn foo() -> eyre::Result<()> { - /// assert_eq!( - /// "/* - /// - Bytecode Verification performed was compared on second iteration - - /// This file is part of the DAO.....", - /// Cast::etherscan_source( - /// NamedChain::Mainnet.into(), - /// "0xBB9bc244D798123fDe783fCc1C72d3Bb8C189413".to_string(), - /// Some("".to_string()), - /// None, - /// None - /// ) - /// .await - /// .unwrap() - /// .as_str() - /// ); - /// # Ok(()) - /// # } - /// ``` - pub async fn etherscan_source( - chain: Chain, - contract_address: String, - etherscan_api_key: Option, - explorer_api_url: Option, - explorer_url: Option, - ) -> Result { - let client = explorer_client(chain, etherscan_api_key, explorer_api_url, explorer_url)?; - let metadata = client.contract_source_code(contract_address.parse()?).await?; - Ok(metadata.source_code()) - } - - /// Fetches the source code of verified contracts from etherscan and expands the resulting - /// files to a directory for easy perusal. - /// - /// # Example - /// - /// ``` - /// # use cast::SimpleCast as Cast; - /// # use foundry_config::NamedChain; - /// # use std::path::PathBuf; - /// # async fn expand() -> eyre::Result<()> { - /// Cast::expand_etherscan_source_to_directory( - /// NamedChain::Mainnet.into(), - /// "0xBB9bc244D798123fDe783fCc1C72d3Bb8C189413".to_string(), - /// Some("".to_string()), - /// PathBuf::from("output_dir"), - /// None, - /// None, - /// ) - /// .await?; - /// # Ok(()) - /// # } - /// ``` - pub async fn expand_etherscan_source_to_directory( - chain: Chain, - contract_address: String, - etherscan_api_key: Option, - output_directory: PathBuf, - explorer_api_url: Option, - explorer_url: Option, - ) -> eyre::Result<()> { - let client = explorer_client(chain, etherscan_api_key, explorer_api_url, explorer_url)?; - let meta = client.contract_source_code(contract_address.parse()?).await?; - let source_tree = meta.source_tree(); - source_tree.write_to(&output_directory)?; - Ok(()) - } - - /// Fetches the source code of verified contracts from etherscan, flattens it and writes it to - /// the given path or stdout. - pub async fn etherscan_source_flatten( - chain: Chain, - contract_address: String, - etherscan_api_key: Option, - output_path: Option, - explorer_api_url: Option, - explorer_url: Option, - ) -> Result<()> { - let client = explorer_client(chain, etherscan_api_key, explorer_api_url, explorer_url)?; - let metadata = client.contract_source_code(contract_address.parse()?).await?; - let Some(metadata) = metadata.items.first() else { - eyre::bail!("Empty contract source code") - }; - - let tmp = tempfile::tempdir()?; - let project = etherscan_project(metadata, tmp.path())?; - let target_path = project.find_contract_path(&metadata.contract_name)?; - - let flattened = Flattener::new(project, &target_path)?.flatten(); - - if let Some(path) = output_path { - fs::create_dir_all(path.parent().unwrap())?; - fs::write(&path, flattened)?; - sh_println!("Flattened file written at {}", path.display())? - } else { - sh_println!("{flattened}")? - } - - Ok(()) - } - - /// Disassembles hex encoded bytecode into individual / human readable opcodes - /// - /// # Example - /// - /// ``` - /// use alloy_primitives::hex; - /// use cast::SimpleCast as Cast; - /// - /// # async fn foo() -> eyre::Result<()> { - /// let bytecode = "0x608060405260043610603f57600035"; - /// let opcodes = Cast::disassemble(&hex::decode(bytecode)?)?; - /// println!("{}", opcodes); - /// # Ok(()) - /// # } - /// ``` - pub fn disassemble(code: &[u8]) -> Result { - let mut output = String::new(); - - for step in decode_instructions(code)? { - write!(output, "{:08x}: ", step.pc)?; - - if let Some(op) = step.op { - write!(output, "{op}")?; - } else { - write!(output, "INVALID")?; - } - - if !step.immediate.is_empty() { - write!(output, " {}", hex::encode_prefixed(step.immediate))?; - } - - writeln!(output)?; - } - - Ok(output) - } - - /// Gets the selector for a given function signature - /// Optimizes if the `optimize` parameter is set to a number of leading zeroes - /// - /// # Example - /// - /// ``` - /// use cast::SimpleCast as Cast; - /// - /// assert_eq!(Cast::get_selector("foo(address,uint256)", 0)?.0, String::from("0xbd0d639f")); - /// # Ok::<(), eyre::Error>(()) - /// ``` - pub fn get_selector(signature: &str, optimize: usize) -> Result<(String, String)> { - if optimize > 4 { - eyre::bail!("number of leading zeroes must not be greater than 4"); - } - if optimize == 0 { - let selector = get_func(signature)?.selector(); - return Ok((selector.to_string(), String::from(signature))); - } - let Some((name, params)) = signature.split_once('(') else { - eyre::bail!("invalid function signature"); - }; - - let num_threads = std::thread::available_parallelism().map_or(1, |n| n.get()); - let found = AtomicBool::new(false); - - let result: Option<(u32, String, String)> = - (0..num_threads).into_par_iter().find_map_any(|i| { - let nonce_start = i as u32; - let nonce_step = num_threads as u32; - - let mut nonce = nonce_start; - while nonce < u32::MAX && !found.load(Ordering::Relaxed) { - let input = format!("{name}{nonce}({params}"); - let hash = keccak256(input.as_bytes()); - let selector = &hash[..4]; - - if selector.iter().take_while(|&&byte| byte == 0).count() == optimize { - found.store(true, Ordering::Relaxed); - return Some((nonce, hex::encode_prefixed(selector), input)); - } - - nonce += nonce_step; - } - None - }); - - match result { - Some((_nonce, selector, signature)) => Ok((selector, signature)), - None => eyre::bail!("No selector found"), - } - } - - /// Extracts function selectors, arguments and state mutability from bytecode - /// - /// # Example - /// - /// ``` - /// use alloy_primitives::fixed_bytes; - /// use cast::SimpleCast as Cast; - /// - /// let bytecode = "6080604052348015600e575f80fd5b50600436106026575f3560e01c80632125b65b14602a575b5f80fd5b603a6035366004603c565b505050565b005b5f805f60608486031215604d575f80fd5b833563ffffffff81168114605f575f80fd5b925060208401356001600160a01b03811681146079575f80fd5b915060408401356001600160e01b03811681146093575f80fd5b80915050925092509256"; - /// let functions = Cast::extract_functions(bytecode)?; - /// assert_eq!(functions, vec![(fixed_bytes!("0x2125b65b"), "uint32,address,uint224".to_string(), "pure")]); - /// # Ok::<(), eyre::Report>(()) - /// ``` - pub fn extract_functions(bytecode: &str) -> Result> { - let code = hex::decode(bytecode)?; - let info = evmole::contract_info( - evmole::ContractInfoArgs::new(&code) - .with_selectors() - .with_arguments() - .with_state_mutability(), - ); - Ok(info - .functions - .expect("functions extraction was requested") - .into_iter() - .map(|f| { - ( - f.selector.into(), - f.arguments - .expect("arguments extraction was requested") - .into_iter() - .map(|t| t.sol_type_name().to_string()) - .collect::>() - .join(","), - f.state_mutability - .expect("state_mutability extraction was requested") - .as_json_str(), - ) - }) - .collect()) - } - - /// Decodes a raw EIP2718 transaction payload - /// Returns details about the typed transaction and ECSDA signature components - /// - /// # Example - /// - /// ``` - /// use cast::SimpleCast as Cast; - /// - /// let tx = "0x02f8f582a86a82058d8459682f008508351050808303fd84948e42f2f4101563bf679975178e880fd87d3efd4e80b884659ac74b00000000000000000000000080f0c1c49891dcfdd40b6e0f960f84e6042bcb6f000000000000000000000000b97ef9ef8734c71904d8002f8b6bc66dd9c48a6e00000000000000000000000000000000000000000000000000000000007ff4e20000000000000000000000000000000000000000000000000000000000000064c001a05d429597befe2835396206781b199122f2e8297327ed4a05483339e7a8b2022aa04c23a7f70fb29dda1b4ee342fb10a625e9b8ddc6a603fb4e170d4f6f37700cb8"; - /// let tx_envelope = Cast::decode_raw_transaction(&tx)?; - /// # Ok::<(), eyre::Report>(()) - pub fn decode_raw_transaction(tx: &str) -> Result { - let tx_hex = hex::decode(tx)?; - let tx = TxEnvelope::decode_2718(&mut tx_hex.as_slice())?; - Ok(tx) - } -} - -fn strip_0x(s: &str) -> &str { - s.strip_prefix("0x").unwrap_or(s) -} - -fn explorer_client( - chain: Chain, - api_key: Option, - api_url: Option, - explorer_url: Option, -) -> Result { - let mut builder = Client::builder().with_chain_id(chain); - - let deduced = chain.etherscan_urls(); - - let explorer_url = explorer_url - .or(deduced.map(|d| d.1.to_string())) - .ok_or_eyre("Please provide the explorer browser URL using `--explorer-url`")?; - builder = builder.with_url(explorer_url)?; - - let api_url = api_url - .or(deduced.map(|d| d.0.to_string())) - .ok_or_eyre("Please provide the explorer API URL using `--explorer-api-url`")?; - builder = builder.with_api_url(api_url)?; - - if let Some(api_key) = api_key { - builder = builder.with_api_key(api_key); - } - - builder.build().map_err(Into::into) -} - -#[cfg(test)] -mod tests { - use super::SimpleCast as Cast; - use alloy_primitives::hex; - - #[test] - fn simple_selector() { - assert_eq!("0xc2985578", Cast::get_selector("foo()", 0).unwrap().0.as_str()) - } - - #[test] - fn selector_with_arg() { - assert_eq!("0xbd0d639f", Cast::get_selector("foo(address,uint256)", 0).unwrap().0.as_str()) - } - - #[test] - fn calldata_uint() { - assert_eq!( - "0xb3de648b0000000000000000000000000000000000000000000000000000000000000001", - Cast::calldata_encode("f(uint256 a)", &["1"]).unwrap().as_str() - ); - } - - // - #[test] - fn calldata_array() { - assert_eq!( - "0xcde2baba0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000", - Cast::calldata_encode("propose(string[])", &["[\"\"]"]).unwrap().as_str() - ); - } - - #[test] - fn calldata_bool() { - assert_eq!( - "0x6fae94120000000000000000000000000000000000000000000000000000000000000000", - Cast::calldata_encode("bar(bool)", &["false"]).unwrap().as_str() - ); - } - - #[test] - fn abi_decode() { - let data = "0x0000000000000000000000000000000000000000000000000000000000000001"; - let sig = "balanceOf(address, uint256)(uint256)"; - assert_eq!( - "1", - Cast::abi_decode(sig, data, false).unwrap()[0].as_uint().unwrap().0.to_string() - ); - - let data = "0x0000000000000000000000008dbd1b711dc621e1404633da156fcc779e1c6f3e000000000000000000000000d9f3c9cc99548bf3b44a43e0a2d07399eb918adc000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000"; - let sig = "safeTransferFrom(address,address,uint256,uint256,bytes)"; - let decoded = Cast::abi_decode(sig, data, true).unwrap(); - let decoded = [ - decoded[0] - .as_address() - .unwrap() - .to_string() - .strip_prefix("0x") - .unwrap() - .to_owned() - .to_lowercase(), - decoded[1] - .as_address() - .unwrap() - .to_string() - .strip_prefix("0x") - .unwrap() - .to_owned() - .to_lowercase(), - decoded[2].as_uint().unwrap().0.to_string(), - decoded[3].as_uint().unwrap().0.to_string(), - hex::encode(decoded[4].as_bytes().unwrap()), - ] - .to_vec(); - assert_eq!( - decoded, - vec![ - "8dbd1b711dc621e1404633da156fcc779e1c6f3e", - "d9f3c9cc99548bf3b44a43e0a2d07399eb918adc", - "42", - "1", - "" - ] - ); - } - - #[test] - fn calldata_decode() { - let data = "0x0000000000000000000000000000000000000000000000000000000000000001"; - let sig = "balanceOf(address, uint256)(uint256)"; - let decoded = - Cast::calldata_decode(sig, data, false).unwrap()[0].as_uint().unwrap().0.to_string(); - assert_eq!(decoded, "1"); - - // Passing `input = true` will decode the data with the input function signature. - // We exclude the "prefixed" function selector from the data field (the first 4 bytes). - let data = "0xf242432a0000000000000000000000008dbd1b711dc621e1404633da156fcc779e1c6f3e000000000000000000000000d9f3c9cc99548bf3b44a43e0a2d07399eb918adc000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000"; - let sig = "safeTransferFrom(address, address, uint256, uint256, bytes)"; - let decoded = Cast::calldata_decode(sig, data, true).unwrap(); - let decoded = [ - decoded[0].as_address().unwrap().to_string().to_lowercase(), - decoded[1].as_address().unwrap().to_string().to_lowercase(), - decoded[2].as_uint().unwrap().0.to_string(), - decoded[3].as_uint().unwrap().0.to_string(), - hex::encode(decoded[4].as_bytes().unwrap()), - ] - .into_iter() - .collect::>(); - assert_eq!( - decoded, - vec![ - "0x8dbd1b711dc621e1404633da156fcc779e1c6f3e", - "0xd9f3c9cc99548bf3b44a43e0a2d07399eb918adc", - "42", - "1", - "" - ] - ); - } - - #[test] - fn concat_hex() { - assert_eq!(Cast::concat_hex(["0x00", "0x01"]), "0x0001"); - assert_eq!(Cast::concat_hex(["1", "2"]), "0x12"); - } - - #[test] - fn from_rlp() { - let rlp = "0xf8b1a02b5df5f0757397573e8ff34a8b987b21680357de1f6c8d10273aa528a851eaca8080a02838ac1d2d2721ba883169179b48480b2ba4f43d70fcf806956746bd9e83f90380a0e46fff283b0ab96a32a7cc375cecc3ed7b6303a43d64e0a12eceb0bc6bd8754980a01d818c1c414c665a9c9a0e0c0ef1ef87cacb380b8c1f6223cb2a68a4b2d023f5808080a0236e8f61ecde6abfebc6c529441f782f62469d8a2cc47b7aace2c136bd3b1ff08080808080"; - let item = Cast::from_rlp(rlp, false).unwrap(); - assert_eq!( - item, - r#"["0x2b5df5f0757397573e8ff34a8b987b21680357de1f6c8d10273aa528a851eaca","0x","0x","0x2838ac1d2d2721ba883169179b48480b2ba4f43d70fcf806956746bd9e83f903","0x","0xe46fff283b0ab96a32a7cc375cecc3ed7b6303a43d64e0a12eceb0bc6bd87549","0x","0x1d818c1c414c665a9c9a0e0c0ef1ef87cacb380b8c1f6223cb2a68a4b2d023f5","0x","0x","0x","0x236e8f61ecde6abfebc6c529441f782f62469d8a2cc47b7aace2c136bd3b1ff0","0x","0x","0x","0x","0x"]"# - ) - } - - #[test] - fn disassemble_incomplete_sequence() { - let incomplete = &hex!("60"); // PUSH1 - let disassembled = Cast::disassemble(incomplete).unwrap(); - assert_eq!(disassembled, "00000000: PUSH1 0x00\n"); - - let complete = &hex!("6000"); // PUSH1 0x00 - let disassembled = Cast::disassemble(complete); - assert!(disassembled.is_ok()); - - let incomplete = &hex!("7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); // PUSH32 with 31 bytes - - let disassembled = Cast::disassemble(incomplete).unwrap(); - - assert_eq!( - disassembled, - "00000000: PUSH32 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00\n" - ); - - let complete = &hex!("7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); // PUSH32 with 32 bytes - let disassembled = Cast::disassemble(complete); - assert!(disassembled.is_ok()); - } -} diff --git a/crates/cast/src/opts.rs b/crates/cast/src/opts.rs index 1bb93ce59d449..d203f8304f1d2 100644 --- a/crates/cast/src/opts.rs +++ b/crates/cast/src/opts.rs @@ -1,19 +1,53 @@ use crate::cmd::{ - access_list::AccessListArgs, artifact::ArtifactArgs, bind::BindArgs, call::CallArgs, - constructor_args::ConstructorArgsArgs, create2::Create2Args, creation_code::CreationCodeArgs, - da_estimate::DAEstimateArgs, estimate::EstimateArgs, find_block::FindBlockArgs, - interface::InterfaceArgs, logs::LogsArgs, mktx::MakeTxArgs, rpc::RpcArgs, run::RunArgs, - send::SendTxArgs, storage::StorageArgs, txpool::TxPoolSubcommands, wallet::WalletSubcommands, + access_list::AccessListArgs, + artifact::ArtifactArgs, + b2e_payload::B2EPayloadArgs, + bal::BalArgs, + batch_mktx::BatchMakeTxArgs, + batch_send::BatchSendArgs, + bind::BindArgs, + call::CallArgs, + call_overrides::CallOverrideOpts, + constructor_args::ConstructorArgsArgs, + create2::Create2Args, + creation_code::CreationCodeArgs, + erc20::Erc20Subcommand, + erc4626::Erc4626Subcommand, + estimate::EstimateArgs, + events::EventsArgs, + find_block::FindBlockArgs, + interface::InterfaceArgs, + keychain::{KeyAuthorizationSubcommand, KeychainSubcommand}, + logs::LogsArgs, + mktx::MakeTxArgs, + receive_policy::ReceivePolicySubcommand, + rpc::RpcArgs, + run::RunArgs, + safe::SafeSubcommand, + send::SendTxArgs, + storage::StorageArgs, + storage_credits::StorageCreditsSubcommand, + tempo::TempoArgs, + tip20::Tip20Subcommand, + tip403::Tip403Subcommand, + trace::TraceArgs, + txpool::TxPoolSubcommands, + vaddr::VaddrSubcommand, + wallet::WalletSubcommands, }; use alloy_ens::NameOrAddress; use alloy_primitives::{Address, B256, Selector, U256}; use alloy_rpc_types::BlockId; -use clap::{Parser, Subcommand, ValueHint}; +use clap::{ArgAction, Parser, Subcommand, ValueHint}; use eyre::Result; use foundry_cli::opts::{EtherscanOpts, GlobalArgs, RpcOpts}; use foundry_common::version::{LONG_VERSION, SHORT_VERSION}; +use foundry_evm_networks::NetworkVariant; use std::{path::PathBuf, str::FromStr}; +#[cfg(any(feature = "base", feature = "optimism"))] +use crate::cmd::da_estimate::DAEstimateArgs; + /// A Swiss Army knife for interacting with Ethereum applications from the command line. #[derive(Parser)] #[command( @@ -32,8 +66,33 @@ pub struct Cast { pub cmd: CastSubcommand, } +/// The `cast` subcommands. +/// +/// Subcommands are declared across several flattened enums because clap's derive generates one +/// `augment_subcommands` function per enum, and in debug builds a single enum holding every +/// subcommand produced a stack frame that overflowed the 2 MiB stack of test threads. #[derive(Subcommand)] +#[allow(clippy::large_enum_variant, reason = "parsed once per process")] pub enum CastSubcommand { + #[command(flatten)] + Conversion(ConversionSubcommand), + + #[command(flatten)] + Transaction(TransactionSubcommand), + + #[command(flatten)] + Abi(AbiSubcommand), + + #[command(flatten)] + Query(QuerySubcommand), + + #[command(flatten)] + Misc(MiscSubcommand), +} + +/// Constant and conversion subcommands. +#[derive(Subcommand)] +pub enum ConversionSubcommand { /// Prints the maximum value of the given integer type. #[command(visible_aliases = &["--max-int", "maxi"])] MaxInt { @@ -151,6 +210,35 @@ pub enum CastSubcommand { bytes: Option, }, + /// Convert hex data to the word-aligned layout of a Solidity `bytes memory` value. + /// + /// The output contains a 32-byte length prefix followed by the data, right-padded with zeros + /// to a whole number of 32-byte words. + #[command(visible_alias = "tbm")] + ToBytesMemory { + /// The hex data to convert. + data: Option, + }, + + /// Pads hex data to a specified length. + #[command(visible_aliases = &["pd"])] + Pad { + /// The hex data to pad. + data: Option, + + /// Right-pad the data (instead of left-pad). + #[arg(long)] + right: bool, + + /// Left-pad the data (default). + #[arg(long, conflicts_with = "right")] + left: bool, + + /// Target length in bytes (default: 32). + #[arg(long, default_value = "32")] + len: usize, + }, + /// Convert an integer into a fixed point number. #[command(visible_aliases = &["--to-fix", "tf", "2f"])] ToFixedPoint { @@ -176,43 +264,7 @@ pub enum CastSubcommand { value: Option, }, - /// Perform a left shifting operation - #[command(name = "shl")] - LeftShift { - /// The value to shift. - value: String, - - /// The number of bits to shift. - bits: String, - - /// The input base. - #[arg(long)] - base_in: Option, - - /// The output base. - #[arg(long, default_value = "16")] - base_out: String, - }, - - /// Perform a right shifting operation - #[command(name = "shr")] - RightShift { - /// The value to shift. - value: String, - - /// The number of bits to shift. - bits: String, - - /// The input base, - #[arg(long)] - base_in: Option, - - /// The output base, - #[arg(long, default_value = "16")] - base_out: String, - }, - - /// Convert an ETH amount into another unit (ether, gwei or wei). + /// Convert an ETH amount into another unit (ether, gwei or wei) /// /// Examples: /// - 1ether wei @@ -220,7 +272,7 @@ pub enum CastSubcommand { /// - 1ether /// - 1 gwei /// - 1gwei ether - #[command(visible_aliases = &["--to-unit", "tun", "2un"])] + #[command(verbatim_doc_comment, visible_aliases = &["--to-unit", "tun", "2un"])] ToUnit { /// The value to convert. value: Option, @@ -230,13 +282,13 @@ pub enum CastSubcommand { unit: String, }, - /// Convert a number from decimal to smallest unit with arbitrary decimals. + /// Convert a number from decimal to smallest unit with arbitrary decimals /// /// Examples: /// - 1.0 6 (for USDC, result: 1000000) /// - 2.5 12 (for 12 decimals token, result: 2500000000000) /// - 1.23 3 (for 3 decimals token, result: 1230) - #[command(visible_aliases = &["--parse-units", "pun"])] + #[command(verbatim_doc_comment, visible_aliases = &["--parse-units", "pun"])] ParseUnits { /// The value to convert. value: Option, @@ -246,13 +298,13 @@ pub enum CastSubcommand { unit: u8, }, - /// Format a number from smallest unit to decimal with arbitrary decimals. + /// Format a number from smallest unit to decimal with arbitrary decimals /// /// Examples: /// - 1000000 6 (for USDC, result: 1.0) /// - 2500000000000 12 (for 12 decimals, result: 2.5) /// - 1230 3 (for 3 decimals, result: 1.23) - #[command(visible_aliases = &["--format-units", "fun"])] + #[command(verbatim_doc_comment, visible_aliases = &["--format-units", "fun"])] FormatUnits { /// The value to format. value: Option, @@ -290,7 +342,7 @@ pub enum CastSubcommand { unit: String, }, - /// RLP encodes hex data, or an array of hex data. + /// RLP encodes hex data, or an array of hex data /// /// Accepts a hex-encoded string, or an array of hex-encoded strings. /// Can be arbitrarily recursive. @@ -300,7 +352,7 @@ pub enum CastSubcommand { /// - `cast to-rlp "0x22"` -> `0x22` /// - `cast to-rlp "[\"0x61\"]"` -> `0xc161` /// - `cast to-rlp "[\"0xf1\", \"f2\"]"` -> `0xc481f181f2` - #[command(visible_aliases = &["--to-rlp"])] + #[command(verbatim_doc_comment, visible_aliases = &["--to-rlp"])] ToRlp { /// The value to convert. /// @@ -344,136 +396,61 @@ pub enum CastSubcommand { #[arg(value_name = "BASE")] base_out: Option, }, - /// Create an access list for a transaction. - #[command(visible_aliases = &["ac", "acl"])] - AccessList(AccessListArgs), - /// Get logs by signature or topic. - #[command(visible_alias = "l")] - Logs(LogsArgs), - /// Get information about a block. - #[command(visible_alias = "bl")] - Block { - /// The block height to query at. - /// - /// Can also be the tags earliest, finalized, safe, latest, or pending. - block: Option, - - /// If specified, only get the given field of the block. - #[arg(long, short)] - field: Option, - - /// Print the raw RLP encoded block header. - #[arg(long, conflicts_with = "field")] - raw: bool, - - #[arg(long, env = "CAST_FULL_BLOCK")] - full: bool, - - #[command(flatten)] - rpc: RpcOpts, - }, - - /// Get the latest block number. - #[command(visible_alias = "bn")] - BlockNumber { - /// The hash or tag to query. If not specified, the latest number is returned. - block: Option, - #[command(flatten)] - rpc: RpcOpts, - }, - - /// Perform a call on an account without publishing a transaction. - #[command(visible_alias = "c")] - Call(CallArgs), - - /// ABI-encode a function with arguments. - #[command(name = "calldata", visible_alias = "cd")] - CalldataEncode { - /// The function signature in the format `()()` - sig: String, - - /// The arguments to encode. - #[arg(allow_hyphen_values = true)] - args: Vec, - - // Path to file containing arguments to encode. - #[arg(long, value_name = "PATH")] - file: Option, - }, - - /// Get the symbolic name of the current chain. - Chain { - #[command(flatten)] - rpc: RpcOpts, - }, - /// Get the Ethereum chain ID. - #[command(visible_aliases = &["ci", "cid"])] - ChainId { - #[command(flatten)] - rpc: RpcOpts, + /// Formats a string into bytes32 encoding. + #[command(name = "format-bytes32-string", visible_aliases = &["--format-bytes32-string"])] + FormatBytes32String { + /// The string to format. + string: Option, }, - /// Get the current client version. - #[command(visible_alias = "cl")] - Client { - #[command(flatten)] - rpc: RpcOpts, + /// Parses a string from bytes32 encoding. + #[command(name = "parse-bytes32-string", visible_aliases = &["--parse-bytes32-string"])] + ParseBytes32String { + /// The string to parse. + bytes: Option, }, - /// Compute the contract address from a given nonce and deployer address. - #[command(visible_alias = "ca")] - ComputeAddress { - /// The deployer address. - address: Option
, - - /// The nonce of the deployer address. - #[arg( - long, - conflicts_with = "salt", - conflicts_with = "init_code", - conflicts_with = "init_code_hash" - )] - nonce: Option, - - /// The salt for CREATE2 address computation. - #[arg(long, conflicts_with = "nonce")] - salt: Option, - - /// The init code for CREATE2 address computation. - #[arg( - long, - requires = "salt", - conflicts_with = "init_code_hash", - conflicts_with = "nonce" - )] - init_code: Option, - - /// The init code hash for CREATE2 address computation. - #[arg(long, requires = "salt", conflicts_with = "init_code", conflicts_with = "nonce")] - init_code_hash: Option, - - #[command(flatten)] - rpc: RpcOpts, + #[command(name = "parse-bytes32-address", visible_aliases = &["--parse-bytes32-address"])] + #[command(about = "Parses a checksummed address from bytes32 encoding.")] + ParseBytes32Address { + #[arg(value_name = "BYTES")] + bytes: Option, }, +} - /// Disassembles a hex-encoded bytecode into a human-readable representation. - #[command(visible_alias = "da")] - Disassemble { - /// The hex-encoded bytecode. - bytecode: Option, - }, +/// Call and transaction subcommands. +#[derive(Subcommand)] +pub enum TransactionSubcommand { + /// Perform a call on an account without publishing a transaction + /// + /// Examples: + /// - cast call $TOKEN "balanceOf(address)(uint256)" vitalik.eth + /// - cast call $TOKEN "transfer(address,uint256)" vitalik.eth 100 --trace + #[command(verbatim_doc_comment, visible_alias = "c")] + Call(CallArgs), - /// Build and sign a transaction. - #[command(name = "mktx", visible_alias = "m")] + /// Build and sign a transaction + /// + /// Examples: + /// - cast mktx vitalik.eth --value 0.1ether --private-key $PK + /// - cast mktx $TOKEN "transfer(address,uint256)" vitalik.eth 100 --account dev + #[command(verbatim_doc_comment, name = "mktx", visible_alias = "m")] MakeTx(MakeTxArgs), - /// Calculate the ENS namehash of a name. - #[command(visible_aliases = &["na", "nh"])] - Namehash { name: Option }, + /// Classify a raw transaction as Tempo T5 payment/general lane. + Classify { + /// The raw signed transaction. + raw_tx: Option, + }, - /// Get information about a transaction. - #[command(visible_alias = "t")] + /// Get information about a transaction + /// + /// Examples: + /// - cast tx $TX_HASH + /// - cast tx $TX_HASH blockNumber (only print the blockNumber field) + /// - cast tx $TX_HASH --raw + #[command(verbatim_doc_comment, visible_alias = "t")] Tx { /// The transaction hash. tx_hash: Option, @@ -494,12 +471,28 @@ pub enum CastSubcommand { #[arg(long, conflicts_with = "field")] raw: bool, + /// Classify the transaction as Tempo T5 payment/general lane. + #[arg(long, conflicts_with_all = ["field", "raw", "to_request"])] + lane: bool, + #[command(flatten)] rpc: RpcOpts, + + /// If specified, the transaction will be converted to a TransactionRequest JSON format. + #[arg(long)] + to_request: bool, + + /// Specify the Network for correct encoding. + #[arg(long, short, num_args = 1, value_name = "NETWORK")] + network: Option, }, - /// Get the transaction receipt for a transaction. - #[command(visible_alias = "re")] + /// Get the transaction receipt for a transaction + /// + /// Examples: + /// - cast receipt $TX_HASH + /// - cast receipt $TX_HASH status (only print the status field) + #[command(verbatim_doc_comment, visible_alias = "re")] Receipt { /// The transaction hash. tx_hash: String, @@ -519,10 +512,23 @@ pub enum CastSubcommand { rpc: RpcOpts, }, - /// Sign and publish a transaction. - #[command(name = "send", visible_alias = "s")] + /// Sign and publish a transaction + /// + /// Examples: + /// - cast send vitalik.eth --value 0.1ether --private-key $PK (transfer ETH) + /// - cast send $TOKEN "transfer(address,uint256)" vitalik.eth 100 --account dev + /// - cast send --private-key $PK --create $BYTECODE (deploy a contract) + #[command(verbatim_doc_comment, name = "send", visible_alias = "s")] SendTx(SendTxArgs), + /// Build and sign a batch transaction (Tempo). + #[command(name = "batch-mktx", visible_alias = "bm")] + BatchMakeTx(BatchMakeTxArgs), + + /// Sign and publish a batch transaction (Tempo). + #[command(name = "batch-send", visible_alias = "bs")] + BatchSend(BatchSendArgs), + /// Publish a raw transaction to the network. #[command(name = "publish", visible_alias = "p")] PublishTx { @@ -537,21 +543,65 @@ pub enum CastSubcommand { rpc: RpcOpts, }, - /// Estimate the gas cost of a transaction. - #[command(visible_alias = "e")] + /// Estimate the gas cost of a transaction + /// + /// Examples: + /// - cast estimate vitalik.eth --value 0.1ether + /// - cast estimate $CONTRACT "deposit()" --value 1ether + #[command(verbatim_doc_comment, visible_alias = "e")] Estimate(EstimateArgs), - /// Decode ABI-encoded input data. + /// Runs a published transaction in a local environment and prints the trace + /// + /// If the node serves an EIP-7928 block access list (BAL) for the transaction's block, the + /// transaction's prestate is read from it instead of replaying the earlier transactions of + /// the block. Pass `--no-bal` to always replay the block. + /// + /// Examples: + /// - cast run $TX_HASH + /// - cast run $TX_HASH --quick (only use the state from the previous block) + /// - cast run $TX_HASH --debug (open the transaction in the debugger) + #[command(verbatim_doc_comment, visible_alias = "r")] + Run(RunArgs), +} + +/// ABI encoding and decoding, selector and contract artifact subcommands. +#[derive(Subcommand)] +pub enum AbiSubcommand { + /// ABI-encode a function with arguments. + #[command(name = "calldata", visible_alias = "cd")] + CalldataEncode { + /// The function signature in the format `()()` + sig: String, + + /// The arguments to encode. + #[arg(allow_hyphen_values = true)] + args: Vec, + + // Path to file containing arguments to encode. + #[arg(long, value_name = "PATH")] + file: Option, + }, + + /// Decode ABI-encoded input data + /// + /// Similar to `abi-decode --input`, but function selector MUST be prefixed in `calldata`. /// - /// Similar to `abi-decode --input`, but function selector MUST be prefixed in `calldata` - /// string - #[command(visible_aliases = &["calldata-decode", "--calldata-decode", "cdd"])] + /// Examples: + /// - cast decode-calldata "transfer(address,uint256)" $CALLDATA + /// - cast decode-calldata --json "transfer(address,uint256)" $CALLDATA + #[command(verbatim_doc_comment, visible_aliases = &["calldata-decode", "--calldata-decode", "cdd"])] DecodeCalldata { /// The function signature in the format `()()`. sig: String, /// The ABI-encoded calldata. - calldata: String, + #[arg(required_unless_present = "file", index = 2)] + calldata: Option, + + /// Load ABI-encoded calldata from a file instead. + #[arg(long = "file", short = 'f', conflicts_with = "calldata")] + file: Option, }, /// Decode ABI-encoded string. @@ -563,10 +613,19 @@ pub enum CastSubcommand { data: String, }, - /// Decode event data. - #[command(visible_aliases = &["event-decode", "--event-decode", "ed"])] + /// Decode event data + /// + /// Parameters marked `indexed` in the supplied signature are emitted as log topics rather than + /// data, so they are ignored when decoding. With `--sig`, DATA must contain only the encoded + /// non-indexed event data, without topics. + /// + /// Examples: + /// - cast decode-event --sig "Transfer(address indexed from, address indexed to, uint256 + /// value)" $DATA + /// - cast decode-event $DATA (topic0-prefixed data; looks up the signature) + #[command(verbatim_doc_comment, visible_aliases = &["event-decode", "--event-decode", "ed"])] DecodeEvent { - /// The event signature. If none provided then tries to decode from local cache or . + /// The event signature. If none provided then tries to decode from local cache or . #[arg(long, visible_alias = "event-sig")] sig: Option, /// The event data to decode. @@ -576,19 +635,23 @@ pub enum CastSubcommand { /// Decode custom error data. #[command(visible_aliases = &["error-decode", "--error-decode", "erd"])] DecodeError { - /// The error signature. If none provided then tries to decode from local cache or . + /// The error signature. If none provided then tries to decode from local cache or . #[arg(long, visible_alias = "error-sig")] sig: Option, /// The error data to decode. data: String, }, - /// Decode ABI-encoded input or output data. + /// Decode ABI-encoded input or output data /// /// Defaults to decoding output data. To decode input data pass --input. /// - /// When passing `--input`, function selector must NOT be prefixed in `calldata` string - #[command(name = "decode-abi", visible_aliases = &["abi-decode", "--abi-decode", "ad"])] + /// When passing `--input`, function selector must NOT be prefixed in `calldata` string. + /// + /// Examples: + /// - cast decode-abi "balanceOf(address)(uint256)" $DATA + /// - cast decode-abi --input "transfer(address,uint256)" $CALLDATA + #[command(verbatim_doc_comment, name = "decode-abi", visible_aliases = &["abi-decode", "--abi-decode", "ad"])] DecodeAbi { /// The function signature in the format `()()`. sig: String, @@ -601,8 +664,12 @@ pub enum CastSubcommand { input: bool, }, - /// ABI encode the given function argument, excluding the selector. - #[command(visible_alias = "ae")] + /// ABI encode the given function argument, excluding the selector + /// + /// Examples: + /// - cast abi-encode "transfer(address,uint256)" $ADDRESS 100 + /// - cast abi-encode --packed "f(string,uint64)" hello 100 + #[command(verbatim_doc_comment, visible_alias = "ae")] AbiEncode { /// The function signature. sig: String, @@ -616,22 +683,258 @@ pub enum CastSubcommand { args: Vec, }, - /// Compute the storage slot for an entry in a mapping. - #[command(visible_alias = "in")] - Index { - /// The mapping key type. - key_type: String, - - /// The mapping key. - key: String, + /// ABI encode an event and its arguments to generate topics and data. + #[command(visible_alias = "aee")] + AbiEncodeEvent { + /// The event signature. + sig: String, - /// The storage slot of the mapping. - slot_number: String, + /// The arguments of the event. + #[arg(allow_hyphen_values = true)] + args: Vec, }, - /// Compute storage slots as specified by `ERC-7201: Namespaced Storage Layout`. - #[command(name = "index-erc7201", alias = "index-erc-7201", visible_aliases = &["index7201", "in7201"])] - IndexErc7201 { + /// Get the function signatures for the given selector from . + #[command(name = "4byte", visible_aliases = &["4", "4b"])] + FourByte { + /// The function selector. + selector: Option, + }, + + /// Decode ABI-encoded calldata using . + #[command(name = "4byte-calldata", aliases = &["4byte-decode", "4d", "4bd"], visible_aliases = &["4c", "4bc"])] + FourByteCalldata { + /// The ABI-encoded calldata. + calldata: Option, + }, + + /// Get the event signature for a given topic 0 from . + #[command(name = "4byte-event", visible_aliases = &["4e", "4be", "topic0-event", "t0e"])] + FourByteEvent { + /// Topic 0 + #[arg(value_name = "TOPIC_0")] + topic: Option, + }, + + /// Upload the given signatures to . + /// + /// Example inputs: + /// - "transfer(address,uint256)" + /// - "function transfer(address,uint256)" + /// - "function transfer(address,uint256)" "event Transfer(address,address,uint256)" + /// - "./out/Contract.sol/Contract.json" + #[command(visible_aliases = &["ups"])] + UploadSignature { + /// The signatures to upload. + /// + /// Prefix with 'function', 'event', or 'error'. Defaults to function if no prefix given. + /// Can also take paths to contract artifact JSON. + signatures: Vec, + }, + + /// Pretty print calldata. + /// + /// Tries to decode the calldata using unless --offline is passed. + #[command(visible_alias = "pc")] + PrettyCalldata { + /// The calldata. + calldata: Option, + + /// Skip the lookup. + #[arg(long, short)] + offline: bool, + }, + + /// Download a contract creation code from Etherscan and RPC. + #[command(visible_alias = "cc")] + CreationCode(CreationCodeArgs), + + /// Generate an artifact file, that can be used to deploy a contract locally. + #[command(visible_alias = "ar")] + Artifact(ArtifactArgs), + + /// Display constructor arguments used for the contract initialization. + #[command(visible_alias = "cra")] + ConstructorArgs(ConstructorArgsArgs), + + /// Generate a Solidity interface from a given ABI + /// + /// Currently does not support ABI encoder v2. + /// + /// Examples: + /// - cast interface $TOKEN --etherscan-api-key $KEY (fetch the ABI from Etherscan) + /// - cast interface ./out/Counter.sol/Counter.json (load a local ABI file) + #[command(verbatim_doc_comment, visible_alias = "i")] + Interface(InterfaceArgs), + + /// Generate a rust binding from a given ABI. + #[command(visible_alias = "bi")] + Bind(BindArgs), + + /// Convert Beacon payload to execution payload. + #[command(visible_alias = "b2e")] + B2EPayload(B2EPayloadArgs), + + /// Get the selector for a function + /// + /// Examples: + /// - cast sig "transfer(address,uint256)" + /// - cast sig "deposit(uint256)" 2 (optimize for 2 leading zero bytes) + #[command(verbatim_doc_comment, visible_alias = "si")] + Sig { + /// The function signature, e.g. transfer(address,uint256). + sig: Option, + + /// Optimize signature to contain provided amount of leading zeroes in selector. + #[arg(conflicts_with = "json")] + optimize: Option, + }, +} + +/// Blockchain, RPC and ENS query subcommands. +#[derive(Subcommand)] +#[allow(clippy::large_enum_variant, reason = "parsed once per process")] +pub enum QuerySubcommand { + /// Create an access list for a transaction + /// + /// Examples: + /// - cast access-list vitalik.eth --value 0.1ether + /// - cast access-list $TOKEN "transfer(address,uint256)" vitalik.eth 100 + #[command(verbatim_doc_comment, visible_aliases = &["ac", "acl"])] + AccessList(AccessListArgs), + + /// Get information about a block + /// + /// Examples: + /// - cast block latest + /// - cast block 21000000 --field timestamp + /// - cast block latest --json + #[command(verbatim_doc_comment, visible_alias = "bl")] + Block { + /// The block height to query at. + /// + /// Can also be the tags earliest, finalized, safe, latest, or pending. + block: Option, + + /// If specified, only get the given field of the block. + #[arg(short, long = "field", aliases = ["fields"], num_args = 0.., action = ArgAction::Append, value_delimiter = ',')] + fields: Vec, + + /// Print the raw RLP encoded block header. + #[arg(long, conflicts_with = "fields")] + raw: bool, + + #[arg(long, env = "CAST_FULL_BLOCK")] + full: bool, + + #[command(flatten)] + rpc: RpcOpts, + + /// Specify the Network for correct encoding. + #[arg(long, short, num_args = 1, value_name = "NETWORK")] + network: Option, + }, + + /// Get the latest block number. + #[command(visible_alias = "bn")] + BlockNumber { + /// The hash or tag to query. If not specified, the latest number is returned. + block: Option, + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Get the EIP-7928 block access list of a block + /// + /// Examples: + /// - cast bal latest + /// - cast bal 21000000 --raw + #[command(verbatim_doc_comment, visible_alias = "block-access-list")] + Bal(BalArgs), + + /// Get the symbolic name of the current chain. + Chain { + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Get the Ethereum chain ID. + #[command(visible_aliases = &["ci", "cid"])] + ChainId { + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Get the current client version. + #[command(visible_alias = "cl")] + Client { + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Compute the contract address from a given nonce and deployer address. + #[command(visible_alias = "ca")] + ComputeAddress { + /// The deployer address. + address: Option
, + + /// The nonce of the deployer address. + #[arg( + long, + conflicts_with = "salt", + conflicts_with = "init_code", + conflicts_with = "init_code_hash" + )] + nonce: Option, + + /// The salt for CREATE2 address computation. + #[arg(long, conflicts_with = "nonce")] + salt: Option, + + /// The init code for CREATE2 address computation. + #[arg( + long, + requires = "salt", + conflicts_with = "init_code_hash", + conflicts_with = "nonce" + )] + init_code: Option, + + /// The init code hash for CREATE2 address computation. + #[arg(long, requires = "salt", conflicts_with = "init_code", conflicts_with = "nonce")] + init_code_hash: Option, + + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Disassembles a hex-encoded bytecode into a human-readable representation. + #[command(visible_alias = "da")] + Disassemble { + /// The hex-encoded bytecode. + bytecode: Option, + }, + + /// Calculate the ENS namehash of a name. + #[command(visible_aliases = &["na", "nh"])] + Namehash { name: Option }, + + /// Compute the storage slot for an entry in a mapping. + #[command(visible_alias = "in")] + Index { + /// The mapping key type. + key_type: String, + + /// The mapping key. + key: String, + + /// The storage slot of the mapping. + slot_number: String, + }, + + /// Compute storage slots as specified by `ERC-7201: Namespaced Storage Layout`. + #[command(name = "index-erc7201", alias = "index-erc-7201", visible_aliases = &["index7201", "in7201"])] + IndexErc7201 { /// The arbitrary identifier. id: Option, /// The formula ID. Currently the only supported formula is `erc7201`. @@ -680,57 +983,6 @@ pub enum CastSubcommand { rpc: RpcOpts, }, - /// Get the function signatures for the given selector from . - #[command(name = "4byte", visible_aliases = &["4", "4b"])] - FourByte { - /// The function selector. - selector: Option, - }, - - /// Decode ABI-encoded calldata using . - #[command(name = "4byte-calldata", aliases = &["4byte-decode", "4d", "4bd"], visible_aliases = &["4c", "4bc"])] - FourByteCalldata { - /// The ABI-encoded calldata. - calldata: Option, - }, - - /// Get the event signature for a given topic 0 from . - #[command(name = "4byte-event", visible_aliases = &["4e", "4be", "topic0-event", "t0e"])] - FourByteEvent { - /// Topic 0 - #[arg(value_name = "TOPIC_0")] - topic: Option, - }, - - /// Upload the given signatures to . - /// - /// Example inputs: - /// - "transfer(address,uint256)" - /// - "function transfer(address,uint256)" - /// - "function transfer(address,uint256)" "event Transfer(address,address,uint256)" - /// - "./out/Contract.sol/Contract.json" - #[command(visible_aliases = &["ups"])] - UploadSignature { - /// The signatures to upload. - /// - /// Prefix with 'function', 'event', or 'error'. Defaults to function if no prefix given. - /// Can also take paths to contract artifact JSON. - signatures: Vec, - }, - - /// Pretty print calldata. - /// - /// Tries to decode the calldata using unless --offline is passed. - #[command(visible_alias = "pc")] - PrettyCalldata { - /// The calldata. - calldata: Option, - - /// Skip the lookup. - #[arg(long, short)] - offline: bool, - }, - /// Get the timestamp of a block. #[command(visible_alias = "a")] Age { @@ -743,8 +995,12 @@ pub enum CastSubcommand { rpc: RpcOpts, }, - /// Get the balance of an account in wei. - #[command(visible_alias = "b")] + /// Get the balance of an account in wei + /// + /// Examples: + /// - cast balance vitalik.eth --ether + /// - cast balance vitalik.eth --erc20 0x6B175474E89094C44Da98b954EedeAC495271d0F + #[command(verbatim_doc_comment, visible_alias = "b")] Balance { /// The block height to query at. /// @@ -767,6 +1023,9 @@ pub enum CastSubcommand { /// with '--erc721' #[arg(long, alias = "erc721")] erc20: Option
, + + #[command(flatten)] + overrides: CallOverrideOpts, }, /// Get the basefee of a block. @@ -781,8 +1040,12 @@ pub enum CastSubcommand { rpc: RpcOpts, }, - /// Get the runtime bytecode of a contract. - #[command(visible_alias = "co")] + /// Get the runtime bytecode of a contract + /// + /// Examples: + /// - cast code 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 + /// - cast code 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 --disassemble + #[command(verbatim_doc_comment, visible_alias = "co")] Code { /// The block height to query at. /// @@ -826,27 +1089,6 @@ pub enum CastSubcommand { rpc: RpcOpts, }, - /// Generate event signatures from event string. - #[command(visible_alias = "se")] - SigEvent { - /// The event string. - event_string: Option, - }, - - /// Hash arbitrary data using Keccak-256. - #[command(visible_aliases = &["k", "keccak256"])] - Keccak { - /// The data to hash. - data: Option, - }, - - /// Hash a message according to EIP-191. - #[command(visible_aliases = &["--hash-message", "hm"])] - HashMessage { - /// The message to hash. - message: Option, - }, - /// Perform an ENS lookup. #[command(visible_alias = "rn")] ResolveName { @@ -875,8 +1117,12 @@ pub enum CastSubcommand { rpc: RpcOpts, }, - /// Get the raw value of a contract's storage slot. - #[command(visible_alias = "st")] + /// Get the raw value of a contract's storage slot + /// + /// Examples: + /// - cast storage 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 0 + /// - cast storage $TOKEN --etherscan-api-key $KEY (decode the full storage layout) + #[command(verbatim_doc_comment, visible_alias = "st")] Storage(StorageArgs), /// Generate a storage proof for a given storage slot. @@ -959,6 +1205,164 @@ pub enum CastSubcommand { rpc: RpcOpts, }, + /// Compute a Tempo TIP-20 channel reserve channel ID. + #[command(name = "channel-id")] + ChannelId { + /// Channel payer address. + #[arg(value_parser = NameOrAddress::from_str)] + payer: NameOrAddress, + + /// Channel payee address. + #[arg(value_parser = NameOrAddress::from_str)] + payee: NameOrAddress, + + /// TIP-20 token address locked by the channel. + #[arg(value_parser = NameOrAddress::from_str)] + token: NameOrAddress, + + /// User-supplied channel salt. + salt: B256, + + /// Optional relayer allowed to submit settlements for the payee. + #[arg(long, value_parser = NameOrAddress::from_str)] + operator: Option, + + /// Optional voucher signer. Defaults to the zero address, meaning the payer signs. + #[arg(long, value_parser = NameOrAddress::from_str)] + authorized_signer: Option, + + /// Transaction-derived expiring nonce hash from ChannelOpened. + #[arg(long, default_value_t = B256::ZERO)] + expiring_nonce_hash: B256, + + /// Channel reserve precompile address. + #[arg(long, value_parser = NameOrAddress::from_str)] + reserve: Option, + + /// The block height to query at. + /// + /// Can also be the tags earliest, finalized, safe, latest, or pending. + #[arg(long, short = 'B')] + block: Option, + + #[command(flatten)] + rpc: RpcOpts, + }, + + /// Get the block number closest to the provided timestamp. + #[command(visible_alias = "f")] + FindBlock(FindBlockArgs), + + /// Perform a raw JSON-RPC request + /// + /// Examples: + /// - cast rpc eth_blockNumber + /// - cast rpc eth_getBlockByNumber 0x123 false + /// - cast rpc eth_getBlockByNumber '["0x123", false]' --raw + #[command(verbatim_doc_comment, visible_alias = "rp")] + Rpc(RpcArgs), + + /// Extracts function selectors and arguments from bytecode + #[command(visible_alias = "sel")] + Selectors { + /// The hex-encoded bytecode. + bytecode: Option, + + /// Resolve the function signatures for the extracted selectors using + #[arg(long, short)] + resolve: bool, + }, +} + +/// Miscellaneous subcommands. +#[derive(Subcommand)] +pub enum MiscSubcommand { + /// Perform a left shifting operation + #[command(name = "shl")] + LeftShift { + /// The value to shift. + value: String, + + /// The number of bits to shift. + bits: String, + + /// The input base. + #[arg(long)] + base_in: Option, + + /// The output base. + #[arg(long, default_value = "16")] + base_out: String, + }, + + /// Perform a right shifting operation + #[command(name = "shr")] + RightShift { + /// The value to shift. + value: String, + + /// The number of bits to shift. + bits: String, + + /// The input base, + #[arg(long)] + base_in: Option, + + /// The output base, + #[arg(long, default_value = "16")] + base_out: String, + }, + + /// Get logs by signature or topic + /// + /// Event declarations decode matching logs in text output. Indexed parameters must be marked + /// `indexed` in the declaration. JSON output remains raw. + /// + /// Examples: + /// - cast logs "Transfer(address indexed from, address indexed to, uint256 value)" + /// - cast logs --address $TOKEN --from-block 21000000 --to-block latest $TOPIC_0 + #[command(verbatim_doc_comment, visible_alias = "l")] + Logs(LogsArgs), + + /// Fetch and decode events from a transaction receipt or log filter. + /// + /// Examples: + /// - cast events $TX_HASH + /// - cast events --tx-hash $TX_HASH + /// - cast events --address $TOKEN --from-block 21000000 --to-block latest + /// - cast events --address $TOKEN "Transfer(address indexed,address indexed,uint256)" + /// + /// A lone 32-byte positional value is treated as a transaction hash. Qualify a raw topic with + /// an address, block range, additional topic, or query size. + #[command(verbatim_doc_comment, visible_alias = "ev")] + Events(EventsArgs), + + /// Generate event signatures from event string. + #[command(visible_alias = "se")] + SigEvent { + /// The event string. + event_string: Option, + }, + + /// Hash arbitrary data using Keccak-256 + /// + /// Examples: + /// - cast keccak "hello world" + /// - cast keccak 0xdeadbeef + /// - echo -n "some data" | cast keccak (hash data from stdin) + #[command(verbatim_doc_comment, visible_aliases = &["k", "keccak256"])] + Keccak { + /// The data to hash. + data: Option, + }, + + /// Hash a message according to EIP-191. + #[command(visible_aliases = &["--hash-message", "hm"])] + HashMessage { + /// The message to hash. + message: Option, + }, + /// Get the source code of a contract from a block explorer. #[command(visible_aliases = &["et", "src"])] Source { @@ -993,113 +1397,121 @@ pub enum CastSubcommand { command: WalletSubcommands, }, - /// Download a contract creation code from Etherscan and RPC. - #[command(visible_alias = "cc")] - CreationCode(CreationCodeArgs), - - /// Generate an artifact file, that can be used to deploy a contract locally. - #[command(visible_alias = "ar")] - Artifact(ArtifactArgs), - - /// Display constructor arguments used for the contract initialization. - #[command(visible_alias = "cra")] - ConstructorArgs(ConstructorArgsArgs), - - /// Generate a Solidity interface from a given ABI. - /// - /// Currently does not support ABI encoder v2. - #[command(visible_alias = "i")] - Interface(InterfaceArgs), - - /// Generate a rust binding from a given ABI. - #[command(visible_alias = "bi")] - Bind(BindArgs), - - /// Get the selector for a function. - #[command(visible_alias = "si")] - Sig { - /// The function signature, e.g. transfer(address,uint256). - sig: Option, - - /// Optimize signature to contain provided amount of leading zeroes in selector. - optimize: Option, + /// Create, propose, and sign Safe transactions. + Safe { + #[command(subcommand)] + command: SafeSubcommand, }, /// Generate a deterministic contract address using CREATE2. #[command(visible_alias = "c2")] Create2(Create2Args), - /// Get the block number closest to the provided timestamp. - #[command(visible_alias = "f")] - FindBlock(FindBlockArgs), - /// Generate shell completions script. #[command(visible_alias = "com")] Completions { #[arg(value_enum)] - shell: clap_complete::Shell, - }, - - /// Generate Fig autocompletion spec. - #[command(visible_alias = "fig")] - GenerateFigSpec, - - /// Runs a published transaction in a local environment and prints the trace. - #[command(visible_alias = "r")] - Run(RunArgs), - - /// Perform a raw JSON-RPC request. - #[command(visible_alias = "rp")] - Rpc(RpcArgs), - - /// Formats a string into bytes32 encoding. - #[command(name = "format-bytes32-string", visible_aliases = &["--format-bytes32-string"])] - FormatBytes32String { - /// The string to format. - string: Option, - }, - - /// Parses a string from bytes32 encoding. - #[command(name = "parse-bytes32-string", visible_aliases = &["--parse-bytes32-string"])] - ParseBytes32String { - /// The string to parse. - bytes: Option, - }, - #[command(name = "parse-bytes32-address", visible_aliases = &["--parse-bytes32-address"])] - #[command(about = "Parses a checksummed address from bytes32 encoding.")] - ParseBytes32Address { - #[arg(value_name = "BYTES")] - bytes: Option, + shell: foundry_cli::clap::Shell, }, /// Decodes a raw signed EIP 2718 typed transaction #[command(visible_aliases = &["dt", "decode-tx"])] - DecodeTransaction { tx: Option }, + DecodeTransaction { + /// Encoded transaction + tx: Option, + + /// Override the network used to decode the transaction. + /// + /// By default, cast decodes with Foundry's transaction envelope, which recognizes + /// standard Ethereum txs and Foundry-supported network-specific tx types such as Tempo. + #[arg(long, short, num_args = 1, value_name = "NETWORK")] + network: Option, + }, /// Recovery an EIP-7702 authority from a Authorization JSON string. #[command(visible_aliases = &["decode-auth"])] RecoverAuthority { auth: String }, - /// Extracts function selectors and arguments from bytecode - #[command(visible_alias = "sel")] - Selectors { - /// The hex-encoded bytecode. - bytecode: Option, - - /// Resolve the function signatures for the extracted selectors using - #[arg(long, short)] - resolve: bool, - }, - /// Inspect the TxPool of a node. #[command(visible_alias = "tp")] TxPool { #[command(subcommand)] command: TxPoolSubcommands, }, + /// Estimates the data availability size of a given opstack block. + #[cfg(any(feature = "base", feature = "optimism"))] #[command(name = "da-estimate")] DAEstimate(DAEstimateArgs), + + /// ERC20 token operations. + #[command(visible_alias = "erc20")] + Erc20Token { + #[command(subcommand)] + command: Erc20Subcommand, + }, + + /// ERC-4626 tokenized vault operations. + #[command(name = "erc4626", visible_alias = "vault")] + Erc4626 { + #[command(subcommand)] + command: Erc4626Subcommand, + }, + + /// TIP-20 token operations (Tempo). + #[command(visible_alias = "tip20")] + Tip20Token { + #[command(subcommand)] + command: Tip20Subcommand, + }, + + /// Account-level receive policy operations (Tempo). + #[command(name = "receive-policy")] + ReceivePolicy { + #[command(subcommand)] + command: ReceivePolicySubcommand, + }, + + /// TIP-403 policy registry operations (Tempo). + #[command(name = "tip403")] + Tip403 { + #[command(subcommand)] + command: Tip403Subcommand, + }, + + /// T7 storage credits operations (Tempo). + #[command(name = "storage-credits", visible_alias = "sc")] + StorageCredits { + #[command(subcommand)] + command: StorageCreditsSubcommand, + }, + + /// Tempo keychain (access key) management. + #[command(visible_alias = "kc")] + Keychain { + #[command(subcommand)] + command: KeychainSubcommand, + }, + + /// Tempo key authorization RLP helpers. + #[command(name = "key-authorization", visible_alias = "key-auth")] + KeyAuthorization { + #[command(subcommand)] + command: KeyAuthorizationSubcommand, + }, + + /// Tempo wallet and zone operations. + Tempo(TempoArgs), + + /// TIP-1022 virtual address registry operations (Tempo). + #[command(visible_alias = "vaddr")] + VirtualAddress { + #[command(subcommand)] + command: VaddrSubcommand, + }, + + #[command(name = "trace")] + Trace(TraceArgs), } /// CLI arguments for `cast --to-base`. @@ -1122,8 +1534,6 @@ pub fn parse_slot(s: &str) -> Result { #[cfg(test)] mod tests { use super::*; - use crate::SimpleCast; - use alloy_rpc_types::{BlockNumberOrTag, RpcBlockHash}; use clap::CommandFactory; #[test] @@ -1144,15 +1554,15 @@ mod tests { "0x01", ]); match args.cmd { - CastSubcommand::Proof { slots, .. } => { + CastSubcommand::Query(QuerySubcommand::Proof { slots, .. }) => { assert_eq!( slots, vec![ B256::ZERO, - U256::from(1).into(), + U256::ONE.into(), B256::ZERO, - U256::from(1).into(), - U256::from(1).into() + U256::ONE.into(), + U256::ONE.into() ] ); } @@ -1170,7 +1580,7 @@ mod tests { "2", ]); match args.cmd { - CastSubcommand::CalldataEncode { args, .. } => { + CastSubcommand::Abi(AbiSubcommand::CalldataEncode { args, .. }) => { assert_eq!( args, vec!["5c9d55b78febcc2061715ba4f57ecf8ea2711f2c".to_string(), "2".to_string()] @@ -1184,7 +1594,7 @@ mod tests { fn parse_call_data_with_file() { let args: Cast = Cast::parse_from(["foundry-cli", "calldata", "f()", "--file", "test.txt"]); match args.cmd { - CastSubcommand::CalldataEncode { sig, file, args } => { + CastSubcommand::Abi(AbiSubcommand::CalldataEncode { sig, file, args }) => { assert_eq!(sig, "f()".to_string()); assert_eq!(file, Some(PathBuf::from("test.txt"))); assert!(args.is_empty()); @@ -1202,64 +1612,17 @@ mod tests { "__$_$__$$$$$__$$_$$$_$$__$$___$$(address,address,uint256)", ]); match args.cmd { - CastSubcommand::Sig { sig, .. } => { + CastSubcommand::Abi(AbiSubcommand::Sig { sig, .. }) => { let sig = sig.unwrap(); assert_eq!( sig, "__$_$__$$$$$__$$_$$$_$$__$$___$$(address,address,uint256)".to_string() ); - let selector = SimpleCast::get_selector(&sig, 0).unwrap(); - assert_eq!(selector.0, "0x23b872dd".to_string()); + let selector = foundry_common::abi::get_func(&sig).unwrap().selector(); + assert_eq!(selector.to_string(), "0x23b872dd"); } _ => unreachable!(), }; } - - #[test] - fn parse_block_ids() { - struct TestCase { - input: String, - expect: BlockId, - } - - let test_cases = [ - TestCase { - input: "0".to_string(), - expect: BlockId::Number(BlockNumberOrTag::Number(0u64)), - }, - TestCase { - input: "0x56462c47c03df160f66819f0a79ea07def1569f8aac0fe91bb3a081159b61b4a" - .to_string(), - expect: BlockId::Hash(RpcBlockHash::from_hash( - "0x56462c47c03df160f66819f0a79ea07def1569f8aac0fe91bb3a081159b61b4a" - .parse() - .unwrap(), - None, - )), - }, - TestCase { - input: "latest".to_string(), - expect: BlockId::Number(BlockNumberOrTag::Latest), - }, - TestCase { - input: "earliest".to_string(), - expect: BlockId::Number(BlockNumberOrTag::Earliest), - }, - TestCase { - input: "pending".to_string(), - expect: BlockId::Number(BlockNumberOrTag::Pending), - }, - TestCase { input: "safe".to_string(), expect: BlockId::Number(BlockNumberOrTag::Safe) }, - TestCase { - input: "finalized".to_string(), - expect: BlockId::Number(BlockNumberOrTag::Finalized), - }, - ]; - - for test in test_cases { - let result: BlockId = test.input.parse().unwrap(); - assert_eq!(result, test.expect); - } - } } diff --git a/crates/cast/src/rlp_converter.rs b/crates/cast/src/rlp_converter.rs index 36b974fb2e976..4f4f8a45c5840 100644 --- a/crates/cast/src/rlp_converter.rs +++ b/crates/cast/src/rlp_converter.rs @@ -1,5 +1,5 @@ use alloy_primitives::{U256, hex}; -use alloy_rlp::{Buf, Decodable, Encodable, Header}; +use alloy_rlp::{Decodable, Encodable, Header, PayloadView}; use eyre::Context; use serde_json::Value; use std::fmt; @@ -11,7 +11,7 @@ use std::fmt; #[derive(Clone, Debug, PartialEq, Eq)] pub enum Item { Data(Vec), - Array(Vec), + Array(Vec), } impl Encodable for Item { @@ -25,23 +25,50 @@ impl Encodable for Item { impl Decodable for Item { fn decode(buf: &mut &[u8]) -> alloy_rlp::Result { - let h = Header::decode(buf)?; - if buf.len() < h.payload_length { - return Err(alloy_rlp::Error::InputTooShort); + struct ListFrame<'a> { + remaining: std::vec::IntoIter<&'a [u8]>, + items: Vec, } - let mut d = &buf[..h.payload_length]; - let r = if h.list { - let view = &mut d; - let mut v = Vec::new(); - while !view.is_empty() { - v.push(Self::decode(view)?); - } - Ok(Self::Array(v)) - } else { - Ok(Self::Data(d.to_vec())) + + let items = match Header::decode_raw(buf)? { + PayloadView::String(data) => return Ok(Self::Data(data.to_vec())), + PayloadView::List(items) => items, }; - buf.advance(h.payload_length); - r + + let mut frames = vec![ListFrame { remaining: items.into_iter(), items: Vec::new() }]; + loop { + let Some(encoded) = frames.last_mut().unwrap().remaining.next() else { + let frame = frames.pop().unwrap(); + let item = Self::Array(frame.items); + if let Some(parent) = frames.last_mut() { + parent.items.push(item); + continue; + } + return Ok(item); + }; + + match Header::decode_raw(&mut &encoded[..])? { + PayloadView::String(data) => { + frames.last_mut().unwrap().items.push(Self::Data(data.to_vec())); + } + PayloadView::List(items) => { + frames.push(ListFrame { remaining: items.into_iter(), items: Vec::new() }); + } + } + } + } +} + +impl Drop for Item { + fn drop(&mut self) { + // The default recursive drop can overflow after successfully decoding deeply nested RLP. + let Self::Array(items) = self else { return }; + let mut pending = std::mem::take(items); + while let Some(mut item) = pending.pop() { + if let Self::Array(children) = &mut item { + pending.append(children); + } + } } } @@ -49,45 +76,46 @@ impl Item { pub(crate) fn value_to_item(value: &Value) -> eyre::Result { match value { Value::Null => Ok(Self::Data(vec![])), - Value::Bool(_) => { - eyre::bail!("RLP input can not contain booleans") - } + Value::Bool(_) => eyre::bail!("RLP input can not contain booleans"), Value::Number(n) => { Ok(Self::Data(n.to_string().parse::()?.to_be_bytes_trimmed_vec())) } Value::String(s) => Ok(Self::Data(hex::decode(s).wrap_err("Could not decode hex")?)), - Value::Array(values) => values.iter().map(Self::value_to_item).collect(), - Value::Object(_) => { - eyre::bail!("RLP input can not contain objects") + Value::Array(values) => { + values.iter().map(Self::value_to_item).collect::>().map(Self::Array) } + Value::Object(_) => eyre::bail!("RLP input can not contain objects"), } } } -impl FromIterator for Item { - fn from_iter>(iter: T) -> Self { - Self::Array(Vec::from_iter(iter)) - } -} - -// Display as hex values +/// Displays the items as nested JSON arrays of hex strings. impl fmt::Display for Item { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Data(dat) => { - write!(f, "\"0x{}\"", hex::encode(dat))?; - } - Self::Array(items) => { - f.write_str("[")?; - for (i, item) in items.iter().enumerate() { - if i > 0 { - f.write_str(",")?; + enum Task<'a> { + Item(&'a Item), + Comma, + Close, + } + + let mut tasks = vec![Task::Item(self)]; + while let Some(task) = tasks.pop() { + match task { + Task::Item(Self::Data(data)) => write!(f, "\"{}\"", hex::encode_prefixed(data))?, + Task::Item(Self::Array(items)) => { + f.write_str("[")?; + tasks.push(Task::Close); + for (i, item) in items.iter().enumerate().rev() { + tasks.push(Task::Item(item)); + if i > 0 { + tasks.push(Task::Comma); + } } - fmt::Display::fmt(item, f)?; } - f.write_str("]")?; + Task::Comma => f.write_str(",")?, + Task::Close => f.write_str("]")?, } - }; + } Ok(()) } } @@ -97,6 +125,7 @@ mod test { use crate::rlp_converter::Item; use alloy_primitives::hex; use alloy_rlp::{Bytes, Decodable}; + use foundry_cli::utils::parse_json; use serde_json::Result as JsonResult; // https://en.wikipedia.org/wiki/Set-theoretic_definition_of_natural_numbers @@ -109,7 +138,6 @@ mod test { } #[test] - #[expect(clippy::disallowed_macros)] fn encode_decode_test() -> alloy_rlp::Result<()> { let parameters = vec![ (1, b"\xc0".to_vec(), Item::Array(vec![])), @@ -144,14 +172,12 @@ mod test { assert_eq!(Item::decode(&mut &encoded[..])?, params.2); let decoded = Item::decode(&mut ¶ms.1[..])?; assert_eq!(alloy_rlp::encode(&decoded), params.1); - println!("case {} validated", params.0) } Ok(()) } #[test] - #[expect(clippy::disallowed_macros)] fn deserialize_from_str_test_hex() -> JsonResult<()> { let parameters = vec![ (1, "[\"\"]", Item::Array(vec![Item::Data(vec![])])), @@ -172,10 +198,9 @@ mod test { (6, "[[],[[]],[[],[[]]]]", array_von_neuman()), ]; for params in parameters { - let val = serde_json::from_str(params.1)?; + let val = parse_json(params.1)?; let item = Item::value_to_item(&val).unwrap(); assert_eq!(item, params.2); - println!("case {} validated", params.0); } Ok(()) diff --git a/crates/cast/src/rpc_trace.rs b/crates/cast/src/rpc_trace.rs new file mode 100644 index 0000000000000..ae827236cb082 --- /dev/null +++ b/crates/cast/src/rpc_trace.rs @@ -0,0 +1,495 @@ +//! Conversion from geth `callTracer` output into a [`CallTraceArena`]. +//! +//! This lets traces fetched over RPC (via `debug_traceCall` / `debug_traceTransaction` with the +//! `callTracer`) be decoded and rendered with the same machinery used for locally executed traces. +//! `callTracer` does not record opcode-level steps, so [`CallTrace::steps`] is left empty; +//! everything the call-tree view needs (calls, value, gas, logs, revert reasons) is preserved. +//! +//! Also hosts the shared classification of the RPC rejections a `debug_trace*` request can hit, +//! so `cast call --debug-trace-call` and `cast run --debug-trace-transaction` surface the same +//! actionable hints. + +use alloy_primitives::{Address, Bytes, LogData, U256}; +use alloy_rpc_types::trace::geth::{CallConfig, CallFrame, CallLogFrame}; +use alloy_transport::TransportError; +use foundry_evm::traces::{ + CallKind, CallLog, CallTrace, CallTraceArena, CallTraceNode, TraceMemberOrder, +}; +use revm::interpreter::InstructionResult; + +pub use foundry_common::provider::is_rpc_method_not_found as is_method_not_found_error; + +/// Returns the `callTracer` config for remote traces: every nested call, with its logs. +/// +/// `onlyTopCall` is sent explicitly although `false` is its default, because ZKsync nodes reject a +/// config that omits it. +pub const fn call_tracer_config() -> CallConfig { + CallConfig { only_top_call: Some(false), with_log: Some(true) } +} + +/// Builds a [`CallTraceArena`] from a geth `callTracer` [`CallFrame`] tree, overriding the root +/// frame's address with `root_address` when the tracer omitted it. +pub fn call_frame_to_arena(root: &CallFrame, root_address: Option
) -> CallTraceArena { + let mut arena = CallTraceArena::default(); + let nodes = arena.nodes_mut(); + nodes.clear(); + push_frame(nodes, root, None, 0); + if let Some(root_address) = root_address + && let Some(root) = nodes.first_mut() + && root.trace.address.is_zero() + { + root.trace.address = root_address; + } + arena +} + +/// Returns `true` if `err` looks like a missing-historical-state rejection, hit whenever a +/// `debug_trace*` request targets a block whose state a full node has pruned. +pub fn is_missing_state_error(err: &TransportError) -> bool { + match err.as_error_resp() { + Some(resp) => is_missing_state_message(&resp.message), + None => is_missing_state_message(&err.to_string()), + } +} + +/// Returns `true` if `message` reads like a node rejecting a request for state it has pruned. +/// +/// These rejections usually carry a generic code (-32000), so only the message tells them apart. +pub fn is_missing_state_message(message: &str) -> bool { + let message = message.to_ascii_lowercase(); + [ + "missing trie node", + "required historical state", + "historical state", + "header not found", + "missing state", + // Cosmos SDK based nodes, e.g. "height 1 is not available, lowest height is 2". + "lowest height is", + // Providers that gate history behind a plan, e.g. "Archive, Debug and Trace requests + // are not available on your current plan". + "archive, debug and trace requests are not available on your current plan", + ] + .iter() + .any(|needle| message.contains(needle)) +} + +/// Pushes `frame` and all of its children into `nodes`, returning the index of the pushed node. +fn push_frame( + nodes: &mut Vec, + frame: &CallFrame, + parent: Option, + depth: usize, +) -> usize { + let idx = nodes.len(); + + let success = frame.error.is_none() && frame.revert_reason.is_none(); + + // A `SELFDESTRUCT` frame is not an ordinary call: geth encodes `from` as the destructed + // contract, `to` as the refund target and `value` as the transferred balance (the inverse of + // `CallTraceNode::geth_selfdestruct_call_trace`). Mirror the local trace representation, which + // records the selfdestruct through the dedicated fields and an + // `InstructionResult::SelfDestruct` status, so the destructed contract (not the + // beneficiary) is identified and `is_selfdestruct` holds. The transferred balance lives in + // `selfdestruct_transferred_value`, so the call `value` stays zero. + let is_selfdestruct = frame.typ == "SELFDESTRUCT"; + let status = if is_selfdestruct { + Some(InstructionResult::SelfDestruct) + } else { + Some(status_from_frame(frame)) + }; + + // `callTracer` reports an unclassified halt (invalid opcode, a provider-specific quirk) only in + // the `error` string. When the frame failed but returned no data, surface that string (or the + // decoded `revert_reason`, preferred) as the output so the renderer shows it instead of a + // coarse `EvmError: Revert`. + let mut output = frame.output.clone().unwrap_or_default(); + if output.is_empty() + && !success + && let Some(text) = frame.revert_reason.as_deref().or(frame.error.as_deref()) + { + output = Bytes::copy_from_slice(text.as_bytes()); + } + + let trace = CallTrace { + depth, + success, + caller: frame.from, + address: if is_selfdestruct { frame.from } else { frame.to.unwrap_or_default() }, + maybe_precompile: None, + selfdestruct_address: is_selfdestruct.then_some(frame.from), + selfdestruct_refund_target: if is_selfdestruct { frame.to } else { None }, + selfdestruct_transferred_value: if is_selfdestruct { frame.value } else { None }, + kind: call_kind(&frame.typ), + value: if is_selfdestruct { U256::ZERO } else { frame.value.unwrap_or_default() }, + data: frame.input.clone(), + output, + bytecode: None, + gas_used: frame.gas_used.saturating_to(), + gas_limit: frame.gas.saturating_to(), + gas_refund_counter: 0, + status, + steps: Vec::new(), + step_deltas: Vec::new(), + decoded: None, + }; + + let logs = frame.logs.iter().map(call_log).collect::>(); + + nodes.push(CallTraceNode { + parent, + children: Vec::new(), + idx, + trace, + logs, + ordering: Vec::new(), + }); + + let mut children = Vec::with_capacity(frame.calls.len()); + for child in &frame.calls { + children.push(push_frame(nodes, child, Some(idx), depth + 1)); + } + + // Reconstruct the interleaving of logs and child calls in linear time. A log's `position` is + // the number of child calls emitted before it, so bucketing the logs by position places each + // one after that many calls. `TraceMemberOrder::Call`/`Log` index into the node's local + // `children`/`logs` vectors. A position past the last call is clamped to the end so the log is + // never dropped. + let num_calls = children.len(); + let mut logs_by_position: Vec> = vec![Vec::new(); num_calls + 1]; + for (li, log) in frame.logs.iter().enumerate() { + let position = (log.position.unwrap_or(0) as usize).min(num_calls); + logs_by_position[position].push(li); + } + let mut ordering = Vec::with_capacity(num_calls + frame.logs.len()); + for (i, logs_at_position) in logs_by_position.iter().enumerate() { + for &li in logs_at_position { + ordering.push(TraceMemberOrder::Log(li)); + } + if i < num_calls { + ordering.push(TraceMemberOrder::Call(i)); + } + } + + nodes[idx].children = children; + nodes[idx].ordering = ordering; + idx +} + +/// Maps a `callTracer` frame to the [`InstructionResult`] used for the rendered `[status]` label. +/// +/// `callTracer` only exposes a coarse, human-readable `error` string (plus an optional +/// `revert_reason`), not a machine status code, so we recognise the two halts geth and reth report +/// reliably (an explicit revert and running out of gas) and fall back to +/// [`InstructionResult::Revert`] for anything else. `push_frame` preserves the frame's `error` / +/// `revert_reason` text in the trace output, and the call is coloured by [`CallTrace::success`], so +/// an imperfect status never hides a failure or the original error message. +fn status_from_frame(frame: &CallFrame) -> InstructionResult { + if frame.error.is_none() && frame.revert_reason.is_none() { + return InstructionResult::Return; + } + if frame.revert_reason.is_some() { + return InstructionResult::Revert; + } + match frame.error.as_deref() { + Some(err) if err.contains("out of gas") => InstructionResult::OutOfGas, + // "execution reverted" and any other unclassified halt render as a revert. + _ => InstructionResult::Revert, + } +} + +/// Maps a `callTracer` call type string to a [`CallKind`], ignoring case: geth reports +/// `DELEGATECALL`, ZKsync `delegateCall`. +fn call_kind(typ: &str) -> CallKind { + match typ.to_ascii_uppercase().as_str() { + "STATICCALL" => CallKind::StaticCall, + "DELEGATECALL" => CallKind::DelegateCall, + "CALLCODE" => CallKind::CallCode, + "AUTHCALL" => CallKind::AuthCall, + "CREATE" => CallKind::Create, + "CREATE2" => CallKind::Create2, + // "CALL", "SELFDESTRUCT" and anything unknown render as a plain call. + _ => CallKind::Call, + } +} + +/// Maps a geth `callTracer` log frame to a [`CallLog`]. +fn call_log(log: &CallLogFrame) -> CallLog { + CallLog { + address: log.address.unwrap_or_default(), + raw_log: LogData::new_unchecked( + log.topics.clone().unwrap_or_default(), + log.data.clone().unwrap_or_default(), + ), + decoded: None, + position: log.position.unwrap_or_default(), + index: log.index.unwrap_or_default(), + } +} + +#[cfg(test)] +mod tests { + + use super::*; + use alloy_primitives::{B256, bytes}; + + /// A geth `callTracer` `SELFDESTRUCT` frame encodes `from` as the destructed contract, `to` as + /// the refund target and `value` as the transferred balance (the inverse of + /// `CallTraceNode::geth_selfdestruct_call_trace`). It must convert into a node that identifies + /// the destructed contract (not the beneficiary) and carries the selfdestruct fields, so + /// `is_selfdestruct()` holds and the status renders as `[SelfDestruct]`. + #[test] + fn converts_selfdestruct_frame() { + let destructed = Address::repeat_byte(0x11); + let beneficiary = Address::repeat_byte(0x22); + let frame = CallFrame { + from: destructed, + to: Some(beneficiary), + value: Some(U256::from(9u64)), + typ: "SELFDESTRUCT".to_string(), + ..Default::default() + }; + + let arena = call_frame_to_arena(&frame, None); + let trace = &arena.nodes()[0].trace; + + // The destructed contract is the identified address, not the refund target. + assert_eq!(trace.address, destructed); + assert_eq!(trace.selfdestruct_address, Some(destructed)); + assert_eq!(trace.selfdestruct_refund_target, Some(beneficiary)); + assert_eq!(trace.selfdestruct_transferred_value, Some(U256::from(9u64))); + assert_eq!(trace.status, Some(InstructionResult::SelfDestruct)); + assert!(trace.is_selfdestruct()); + } + + #[test] + fn fills_missing_root_create_address() { + let created = Address::repeat_byte(0x33); + let frame = CallFrame { typ: "CREATE".to_string(), ..Default::default() }; + + let arena = call_frame_to_arena(&frame, Some(created)); + + assert_eq!(arena.nodes()[0].trace.address, created); + assert_eq!(arena.nodes()[0].trace.kind, CallKind::Create); + } + + /// A nested `callTracer` frame (root CALL -> child STATICCALL) with a log on the root, + /// mirroring a real `debug_traceCall` response, must convert into a well-formed two-node + /// arena. + #[test] + fn converts_nested_call_frame() { + let frame = CallFrame { + from: Address::repeat_byte(0x11), + to: Some(Address::repeat_byte(0x22)), + gas: U256::from(100_000u64), + gas_used: U256::from(21_000u64), + input: bytes!("dead"), + output: Some(bytes!("beef")), + value: Some(U256::from(7u64)), + typ: "CALL".to_string(), + logs: vec![CallLogFrame { + address: Some(Address::repeat_byte(0x22)), + topics: Some(vec![]), + data: Some(bytes!("00")), + position: Some(1), + index: Some(0), + }], + calls: vec![CallFrame { + from: Address::repeat_byte(0x22), + to: Some(Address::repeat_byte(0x33)), + gas: U256::from(50_000u64), + gas_used: U256::from(5_000u64), + input: bytes!("cafe"), + typ: "STATICCALL".to_string(), + ..Default::default() + }], + ..Default::default() + }; + + let arena = call_frame_to_arena(&frame, None); + let nodes = arena.nodes(); + assert_eq!(nodes.len(), 2, "root + one child"); + + let root = &nodes[0]; + assert_eq!(root.parent, None); + assert_eq!(root.children, vec![1]); + assert_eq!(root.trace.kind, CallKind::Call); + assert_eq!(root.trace.caller, frame.from); + assert_eq!(root.trace.value, U256::from(7u64)); + assert_eq!(root.trace.gas_used, 21_000); + assert!(root.trace.success); + assert_eq!(root.logs.len(), 1); + + // The log has position 1, so it must be ordered after the single child call. + assert_eq!(root.ordering, vec![TraceMemberOrder::Call(0), TraceMemberOrder::Log(0)]); + + let child = &nodes[1]; + assert_eq!(child.parent, Some(0)); + assert_eq!(child.trace.depth, 1); + assert_eq!(child.trace.kind, CallKind::StaticCall); + } + + /// `callTracer` error strings must map onto the status used for the rendered `[status]` label: + /// a clean call returns, an explicit revert and a `revert_reason` map to `Revert`, an + /// out-of-gas halt maps to `OutOfGas`, and any other halt falls back to `Revert`. + #[test] + fn maps_frame_status() { + let ok = CallFrame { typ: "CALL".to_string(), ..Default::default() }; + assert_eq!(status_from_frame(&ok), InstructionResult::Return); + + let reverted = CallFrame { + typ: "CALL".to_string(), + error: Some("execution reverted".to_string()), + revert_reason: Some("boom".to_string()), + ..Default::default() + }; + assert_eq!(status_from_frame(&reverted), InstructionResult::Revert); + + let oog = CallFrame { + typ: "CALL".to_string(), + error: Some("out of gas".to_string()), + ..Default::default() + }; + assert_eq!(status_from_frame(&oog), InstructionResult::OutOfGas); + + let other = CallFrame { + typ: "CALL".to_string(), + error: Some("invalid opcode: opcode 0xfe not defined".to_string()), + ..Default::default() + }; + assert_eq!(status_from_frame(&other), InstructionResult::Revert); + } + + /// An unclassified halt with no return data (e.g. an invalid opcode) must keep its original + /// `error` string as the trace output, so the renderer surfaces it instead of a coarse + /// `EvmError: Revert`. + #[test] + fn surfaces_error_string_in_output() { + let frame = CallFrame { + from: Address::repeat_byte(0x11), + to: Some(Address::repeat_byte(0x22)), + typ: "CALL".to_string(), + error: Some("invalid opcode: opcode 0xfe not defined".to_string()), + ..Default::default() + }; + + let arena = call_frame_to_arena(&frame, None); + let root = &arena.nodes()[0]; + + assert!(!root.trace.success); + assert_eq!( + core::str::from_utf8(&root.trace.output[..]).unwrap(), + "invalid opcode: opcode 0xfe not defined" + ); + } + + /// A log whose `position` points past the last child call must be clamped to the end rather + /// than dropped, and a `position` of zero must order the log before the first call. + #[test] + fn clamps_out_of_range_log_position() { + let frame = CallFrame { + from: Address::repeat_byte(0x11), + to: Some(Address::repeat_byte(0x22)), + typ: "CALL".to_string(), + logs: vec![ + CallLogFrame { position: Some(0), index: Some(0), ..Default::default() }, + CallLogFrame { position: Some(5), index: Some(1), ..Default::default() }, + ], + calls: vec![CallFrame { typ: "CALL".to_string(), ..Default::default() }], + ..Default::default() + }; + + let arena = call_frame_to_arena(&frame, None); + let root = &arena.nodes()[0]; + + assert_eq!(root.logs.len(), 2, "no log dropped"); + // position 0 -> before the only call; position 5 -> clamped to after it. + assert_eq!( + root.ordering, + vec![TraceMemberOrder::Log(0), TraceMemberOrder::Call(0), TraceMemberOrder::Log(1),] + ); + } + + /// A log whose `position` falls strictly between two child calls must be ordered between them. + /// The single-child cases above only exercise before-first and after-last, so an off-by-one in + /// the `Call(i)` / `Log(li)` indexing would otherwise go unnoticed. + #[test] + fn orders_log_between_two_children() { + let frame = CallFrame { + from: Address::repeat_byte(0x11), + to: Some(Address::repeat_byte(0x22)), + typ: "CALL".to_string(), + // position 1 -> one child emitted before the log, so it lands between the two children. + logs: vec![CallLogFrame { position: Some(1), index: Some(0), ..Default::default() }], + calls: vec![ + CallFrame { typ: "CALL".to_string(), ..Default::default() }, + CallFrame { typ: "CALL".to_string(), ..Default::default() }, + ], + ..Default::default() + }; + + let arena = call_frame_to_arena(&frame, None); + let root = &arena.nodes()[0]; + + assert_eq!(arena.nodes().len(), 3, "root + two children"); + assert_eq!(root.children, vec![1, 2]); + assert_eq!( + root.ordering, + vec![TraceMemberOrder::Call(0), TraceMemberOrder::Log(0), TraceMemberOrder::Call(1),] + ); + } + + /// Pruned-state rejections as geth, Cosmos SDK nodes and plan-gated providers word them. + #[test] + fn recognizes_missing_state_messages() { + for message in [ + "missing trie node 5d1c4b4a2f7a1c1d8f0b6f3e0a5b8c9d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b (path ) ", + "header not found", + "height 20170443 is not available, lowest height is 90850001", + "Archive, Debug and Trace requests are not available on your current plan.", + ] { + assert!(is_missing_state_message(message), "{message}"); + } + for message in ["execution reverted", "failed to connect to archive RPC endpoint"] { + assert!(!is_missing_state_message(message), "{message}"); + } + } + + #[test] + fn maps_call_kind() { + for (typ, kind) in [ + ("CALL", CallKind::Call), + ("STATICCALL", CallKind::StaticCall), + ("DELEGATECALL", CallKind::DelegateCall), + ("CALLCODE", CallKind::CallCode), + ("AUTHCALL", CallKind::AuthCall), + ("CREATE", CallKind::Create), + ("CREATE2", CallKind::Create2), + // ZKsync reports call types in camelCase. + ("call", CallKind::Call), + ("delegateCall", CallKind::DelegateCall), + // `SELFDESTRUCT` and unknown types render as a plain call. + ("SELFDESTRUCT", CallKind::Call), + ("NOT_A_REAL_TYPE", CallKind::Call), + ] { + assert_eq!(call_kind(typ), kind, "{typ}"); + } + } + + /// Distinct topics, data, position and index catch a swapped or dropped field. + #[test] + fn maps_call_log_fields() { + let topics = vec![B256::with_last_byte(0xaa), B256::with_last_byte(0xbb)]; + let log = call_log(&CallLogFrame { + address: Some(Address::repeat_byte(0x33)), + topics: Some(topics.clone()), + data: Some(bytes!("dead")), + position: Some(2), + index: Some(5), + }); + assert_eq!(log.address, Address::repeat_byte(0x33)); + assert_eq!(log.raw_log.topics(), &topics[..]); + assert_eq!(log.raw_log.data, bytes!("dead")); + assert_eq!(log.position, 2); + assert_eq!(log.index, 5); + } +} diff --git a/crates/cast/src/tempo.rs b/crates/cast/src/tempo.rs new file mode 100644 index 0000000000000..02d174a82ac3f --- /dev/null +++ b/crates/cast/src/tempo.rs @@ -0,0 +1,357 @@ +//! Tempo transaction helpers used by Cast-facing commands. + +use crate::tx::fill_transaction_gas_fees; +use alloy_network::{Ethereum, Network, TransactionBuilder}; +use alloy_primitives::{Address, B256}; +use alloy_provider::Provider; +use alloy_rpc_client::BuiltInConnectionString; +use alloy_transport::{BoxTransport, TransportConnect, TransportError}; +use eyre::Result; +use foundry_cli::{ + json::print_json_success, + opts::{EthereumOpts, TempoOpts}, + utils::{LoadConfig, get_chain}, +}; +use foundry_common::{ + FoundryTransactionBuilder, + provider::{ProviderBuilder, RetryProvider}, + shell, + tempo::{is_tempo_hardfork_active, maybe_print_fee_token, resolve_and_set_fee_token}, +}; +use foundry_config::{Chain, Config, Eip1559FeeEstimatePreset}; +use foundry_evm::hardfork::TempoHardfork; +use foundry_evm_networks::NetworkVariant; +use foundry_wallets::{TempoAccountsWallet, WalletOpts, WalletSigner}; +use serde_json::Value; +use std::str::FromStr; +use tempo_alloy::{ + TempoNetwork, + rpc::TempoTransactionRequest, + transport::{RelayConnector, SponsorshipMode}, +}; + +pub use foundry_common::tempo::TempoSponsor; + +/// Loads the config for `opts` and builds a Tempo provider from it. +pub(crate) fn tempo_provider( + opts: &impl LoadConfig, +) -> Result<(Config, RetryProvider)> { + let config = opts.load_config()?; + let provider = ProviderBuilder::::from_config(&config)?.build()?; + Ok((config, provider)) +} + +/// Attaches the fee payment to a built transaction: the sponsor signature when a sponsor is +/// configured, otherwise the resolved fee token for `payer` (printing it when it was resolved). +pub(crate) async fn apply_fee_payment( + sponsor: Option<&TempoSponsor>, + provider: Option<&P>, + tx: &mut N::TransactionRequest, + payer: Address, +) -> Result<()> +where + N: Network, + N::TransactionRequest: Default + FoundryTransactionBuilder, + P: Provider, +{ + if tx.gas_price().is_some() { + eyre::ensure!( + sponsor.is_none() && !tx.is_tempo_aa(), + "Tempo transaction options cannot be combined with a legacy transaction" + ); + return Ok(()); + } + + if sponsor.is_some() { + maybe_attach_sponsor(sponsor, provider, tx, payer).await + } else { + resolve_and_print_fee_token(provider, tx, Some(payer)).await + } +} + +/// Resolves the sponsored fee token and attaches the sponsor signature preview for `payer` when a +/// sponsor is configured. +pub(crate) async fn maybe_attach_sponsor( + sponsor: Option<&TempoSponsor>, + provider: Option<&P>, + tx: &mut N::TransactionRequest, + payer: Address, +) -> Result<()> +where + N: Network, + N::TransactionRequest: Default + FoundryTransactionBuilder, + P: Provider, +{ + if let Some(sponsor) = sponsor { + let provider = provider.map(|p| p as &dyn Provider); + sponsor.resolve_and_set_fee_token(provider, tx).await?; + sponsor.attach_and_print::(tx, payer).await?; + } + Ok(()) +} + +/// Resolves and sets the fee token paid by `fee_payer`, printing it when it was resolved. +pub(crate) async fn resolve_and_print_fee_token( + provider: Option<&P>, + tx: &mut N::TransactionRequest, + fee_payer: Option
, +) -> Result<()> +where + N: Network, + N::TransactionRequest: Default + FoundryTransactionBuilder, + P: Provider, +{ + let dyn_provider = provider.map(|p| p as &dyn Provider); + let fee_token = resolve_and_set_fee_token(dyn_provider, tx, fee_payer).await?; + maybe_print_fee_token(provider, fee_token).await +} + +/// Computes the sponsor hash of a built transaction, resolving the fee token for `fee_payer` first +/// when one is configured. Used by `--tempo.print-sponsor-hash`. +pub(crate) async fn sponsor_hash( + provider: Option<&P>, + tx: &mut N::TransactionRequest, + from: Address, + fee_payer: Option
, +) -> Result +where + N: Network, + N::TransactionRequest: Default + FoundryTransactionBuilder, + P: Provider, +{ + if fee_payer.is_some() { + let provider = provider.map(|p| p as &dyn Provider); + resolve_and_set_fee_token(provider, tx, fee_payer).await?; + } + tx.compute_sponsor_hash(from) + .ok_or_else(|| eyre::eyre!("This network does not support sponsored transactions")) +} + +/// Prints a command result: the raw payload in JSON mode, the human rendering otherwise. +pub(crate) fn print_payload(payload: Value, human: F) -> Result<()> +where + F: FnOnce(&Value) -> Result<()>, +{ + if shell::is_json() { + print_json_success(payload)?; + } else { + human(&payload)?; + } + Ok(()) +} + +pub(crate) fn print_expires(expires_at: Option) -> Result<()> { + if let Some(ts) = expires_at { + sh_status!("Transaction expires at unix timestamp {ts}")?; + } + Ok(()) +} + +/// Resolves a command signer, preferring an explicitly selected Tempo session. +/// +/// Session resolution is fail-closed: when `--tempo.session` or `TEMPO_SESSION_ID` is set, wallet +/// signer options are rejected by [`TempoOpts::session_signer_for_wallet`] instead of falling back +/// to a long-lived signer. +pub(crate) async fn resolve_session_or_wallet_signer( + tempo: &TempoOpts, + wallet: &WalletOpts, + chain_id: u64, +) -> Result<(Option, Option)> { + let (signer, access_key) = match tempo.session_signer_for_wallet(wallet, chain_id)? { + Some(session) => (None, Some(session.access_key)), + None => wallet.maybe_signer_for_chain(chain_id).await?, + }; + if let (Some(from), Some(access_key)) = (wallet.from, &access_key) { + eyre::ensure!( + access_key.account() == from, + "sender {from} does not match Tempo account {}", + access_key.account() + ); + } + Ok((signer, access_key)) +} + +pub(crate) fn ensure_session_not_browser(tempo: &TempoOpts, browser: bool) -> Result<()> { + if browser && tempo.session_id()?.is_some() { + eyre::bail!("--tempo.session/TEMPO_SESSION_ID cannot be combined with --browser"); + } + Ok(()) +} + +/// Fails with `message` unless `hardfork` is active on the RPC. +pub(crate) async fn require_hardfork>( + provider: &P, + hardfork: TempoHardfork, + message: &str, +) -> Result<()> { + if !is_tempo_hardfork_active(provider, hardfork).await? { + eyre::bail!("{message}"); + } + Ok(()) +} + +/// Fails early with `requirement` when a Tempo precompile is not active yet: a pre-fork call +/// would succeed as a silent no-op instead of reverting. Prefers the hardfork query and falls +/// back to checking the precompile's code when the RPC lacks the method. +pub(crate) async fn ensure_tempo_precompile_active>( + provider: &P, + hardfork: TempoHardfork, + precompile: Address, + requirement: &str, +) -> Result<()> { + let active = match is_tempo_hardfork_active(provider, hardfork).await { + Ok(active) => active, + Err(_) => !provider.get_code_at(precompile).await?.is_empty(), + }; + eyre::ensure!(active, "{requirement}"); + Ok(()) +} + +/// Connector for reusing an already-configured RPC transport. +/// +/// This preserves Foundry transport behavior such as MPP payment handling when a sponsor relay is +/// layered over the default RPC. +#[derive(Clone, Debug)] +pub(crate) struct ExistingTransportConnector { + transport: BoxTransport, + is_local: bool, +} + +impl TransportConnect for ExistingTransportConnector { + fn is_local(&self) -> bool { + self.is_local + } + + async fn get_transport(&self) -> Result { + Ok(self.transport.clone()) + } +} + +pub(crate) fn sponsor_relay_connector( + provider: &impl Provider, + sponsor_url: &str, +) -> Result> { + let default = ExistingTransportConnector { + transport: provider.client().transport().clone(), + is_local: provider.client().is_local(), + }; + let relay = BuiltInConnectionString::from_str(sponsor_url)?; + Ok(RelayConnector::with_config(default, relay, SponsorshipMode::SignOnly, false)) +} + +/// Resolves the transaction network and any configured signer without letting an unrelated Tempo +/// Accounts store change ordinary Ethereum commands. +/// +/// Explicit signer options are resolved with `from` cleared so the store fallback is not consulted. +/// The fallback is only enabled after selecting Tempo from transaction options, configuration, +/// or the RPC chain. +pub(crate) async fn resolve_transaction_network_and_signer( + tempo: &TempoOpts, + eth: &EthereumOpts, +) -> Result<(NetworkVariant, Option, Option)> { + let config = eth.load_config()?; + let has_session = tempo.session_id()?.is_some(); + let requires_tempo = tempo.is_tempo() || has_session; + crate::cmd::validate_tempo_network(&config, requires_tempo)?; + if has_session { + return Ok((NetworkVariant::Tempo, None, None)); + } + + let mut explicit_wallet = eth.wallet.clone(); + explicit_wallet.from = None; + let (signer, access_key) = explicit_wallet.maybe_signer().await?; + let network = + crate::cmd::resolve_transaction_network(&config, requires_tempo || access_key.is_some()) + .await?; + if let (Some(from), Some(access_key)) = (eth.wallet.from, &access_key) { + eyre::ensure!( + access_key.account() == from, + "sender {from} does not match Tempo account {}", + access_key.account() + ); + } + if !network.is_tempo() || signer.is_some() || access_key.is_some() || eth.wallet.from.is_none() + { + return Ok((network, signer, access_key)); + } + + // Only consult the Accounts store after selecting Tempo. + let provider = ProviderBuilder::::from_config(&config)?.build()?; + let chain = get_chain(config.chain, &provider).await?; + let (signer, access_key) = eth.wallet.maybe_signer_for_chain(chain.id()).await?; + if let Some(access_key) = &access_key { + let from = eth.wallet.from.expect("checked above"); + eyre::ensure!( + access_key.account() == from, + "sender {from} does not match active Tempo account {}", + access_key.account() + ); + } + Ok((network, signer, access_key)) +} + +/// Fills a Tempo transaction request that was built outside [`crate::tx::CastTxBuilder`] before +/// access-key signing. +pub(crate) async fn fill_access_key_transaction

( + provider: &P, + tx: &mut TempoTransactionRequest, + access_key: &TempoAccountsWallet, + chain: Chain, + eip1559_fee_estimate: Eip1559FeeEstimatePreset, +) -> Result +where + P: Provider, +{ + tx.set_chain_id(chain.id()); + let prepared = access_key.prepare_request(provider, tx).await?; + + if tx.nonce().is_none() { + tx.set_nonce(provider.get_transaction_count(prepared.account()).await?); + } + fill_transaction_gas_fees(provider, tx, chain.is_legacy(), false, eip1559_fee_estimate).await?; + if tx.gas_limit().is_none() { + tx.set_gas_limit(provider.estimate_gas(tx.clone()).await?); + } + + Ok(prepared) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_provider::{ProviderBuilder as AlloyProviderBuilder, mock::Asserter}; + use alloy_rpc_client::RpcClient; + + #[tokio::test] + async fn legacy_fee_payment_skips_stored_token_and_rejects_tempo_fields() { + let asserter = Asserter::new(); + let provider = + AlloyProviderBuilder::new().network::().connect_mocked_client(asserter); + let payer = Address::repeat_byte(0x11); + let mut tx = TempoTransactionRequest::default(); + tx.set_gas_price(1); + + apply_fee_payment(None, Some(&provider), &mut tx, payer).await.unwrap(); + assert!(tx.fee_token().is_none()); + + tx.set_fee_token(Address::repeat_byte(0x22)); + assert_eq!( + apply_fee_payment(None, Some(&provider), &mut tx, payer).await.unwrap_err().to_string(), + "Tempo transaction options cannot be combined with a legacy transaction" + ); + } + + #[tokio::test] + async fn sponsor_relay_reuses_existing_default_transport() { + let asserter = Asserter::new(); + asserter.push_success(&alloy_primitives::U64::from(42)); + let provider = AlloyProviderBuilder::new().connect_mocked_client(asserter); + let connector = + sponsor_relay_connector(&provider, "http://127.0.0.1:1").expect("valid relay"); + let transport = connector.get_transport().await.expect("relay transport"); + let client = RpcClient::builder().transport(transport, true); + let relayed = AlloyProviderBuilder::new().connect_client(client); + + assert_eq!(relayed.get_block_number().await.unwrap(), 42); + } +} diff --git a/crates/cast/src/tx.rs b/crates/cast/src/tx.rs index 17f4dd4e62170..7ce611d3dedfe 100644 --- a/crates/cast/src/tx.rs +++ b/crates/cast/src/tx.rs @@ -1,33 +1,138 @@ use crate::traces::identifier::SignaturesIdentifier; -use alloy_consensus::{SidecarBuilder, SignableTransaction, SimpleCoder}; +use alloy_consensus::{SidecarBuilder, SimpleCoder}; use alloy_dyn_abi::ErrorExt; use alloy_ens::NameOrAddress; use alloy_json_abi::Function; -use alloy_network::{ - AnyNetwork, AnyTypedTransaction, TransactionBuilder, TransactionBuilder4844, - TransactionBuilder7702, -}; -use alloy_primitives::{Address, Bytes, TxKind, U256, hex}; -use alloy_provider::Provider; -use alloy_rpc_types::{AccessList, Authorization, TransactionInput, TransactionRequest}; -use alloy_serde::WithOtherFields; +use alloy_network::{Network, ReceiptResponse, TransactionBuilder}; +use alloy_primitives::{Address, B256, Bytes, TxHash, TxKind, U64, U256, hex}; +use alloy_provider::{PendingTransactionBuilder, Provider}; +use alloy_rpc_types::{AccessList, Authorization, TransactionInputKind}; use alloy_signer::Signer; use alloy_transport::TransportError; -use eyre::Result; -use foundry_block_explorers::EtherscanApiVersion; +use clap::Args; +use eyre::{Result, WrapErr}; use foundry_cli::{ - opts::{CliAuthorizationList, TransactionOpts}, - utils::{self, parse_function_args}, + opts::{CliAuthorizationList, EthereumOpts, TempoOpts, TransactionOpts}, + utils::{self, apply_gas_estimate_multiplier, parse_ether_value, parse_function_args}, +}; +use foundry_common::{ + FoundryTransactionBuilder, TransactionReceiptWithRevertReason, + fmt::*, + get_pretty_receipt_w_reason_attr, + provider::fee::{estimate_eip1559_fees, resolve_broadcast_eip1559_fees}, + shell, + tempo::ensure_expiring_nonce_discriminator_active, }; -use foundry_common::fmt::format_tokens; -use foundry_config::{Chain, Config}; -use foundry_wallets::{WalletOpts, WalletSigner}; +use foundry_config::{Chain, Config, Eip1559FeeEstimatePreset}; +use foundry_wallets::{BrowserWalletOpts, TempoAccountsWallet, WalletOpts, WalletSigner}; use itertools::Itertools; -use serde_json::value::RawValue; -use std::fmt::Write; +use std::{fmt::Write, marker::PhantomData, str::FromStr, time::Duration}; + +#[derive(Debug, Clone, Args)] +pub struct SendTxOpts { + /// Only print the transaction hash and exit immediately. + #[arg(id = "async", long = "async", alias = "cast-async", env = "CAST_ASYNC")] + pub cast_async: bool, + + /// Wait for transaction receipt synchronously instead of polling. + /// Note: uses `eth_sendTransactionSync` or `eth_sendRawTransactionSync`, which may not be + /// supported by all clients. + #[arg(long, conflicts_with = "async")] + pub sync: bool, + + /// The number of confirmations until the receipt is fetched. + #[arg(long, default_value = "1")] + pub confirmations: u64, + + /// Timeout for sending the transaction. + #[arg(long, env = "ETH_TIMEOUT")] + pub timeout: Option, + + /// Polling interval for transaction receipts (in seconds). + #[arg(long, alias = "poll-interval", env = "ETH_POLL_INTERVAL")] + pub poll_interval: Option, + + /// Ethereum options + #[command(flatten)] + pub eth: EthereumOpts, + + /// Browser wallet options + #[command(flatten)] + pub browser: BrowserWalletOpts, +} + +/// Applies the `--poll-interval` (in seconds) to a provider's receipt polling when one was given. +pub(crate) fn apply_poll_interval(provider: &impl Provider, interval: Option) { + if let Some(interval) = interval { + provider.client().set_poll_interval(Duration::from_secs(interval)); + } +} + +/// Transaction options shared across cast commands that submit on-chain transactions. +#[derive(Debug, Clone, Args)] +#[command(next_help_heading = "Transaction options")] +pub struct TxParams { + /// Gas limit for the transaction. + #[arg(long, env = "ETH_GAS_LIMIT")] + pub gas_limit: Option, + + /// Gas price for legacy transactions, or max fee per gas for EIP1559 transactions, either + /// specified in wei, or as a string with a unit type. + /// + /// Examples: 1ether, 10gwei, 0.01ether + #[arg( + long, + env = "ETH_GAS_PRICE", + value_parser = parse_ether_value, + value_name = "PRICE" + )] + pub gas_price: Option, + + /// Max priority fee per gas for EIP1559 transactions. + #[arg( + long, + env = "ETH_PRIORITY_GAS_PRICE", + value_parser = parse_ether_value, + value_name = "PRICE" + )] + pub priority_gas_price: Option, + + /// Nonce for the transaction. + #[arg(long)] + pub nonce: Option, + + #[command(flatten)] + pub tempo: TempoOpts, +} + +impl TxParams { + pub(crate) fn apply(&self, tx: &mut N::TransactionRequest, legacy: bool) + where + N::TransactionRequest: FoundryTransactionBuilder, + { + self.clone().into_transaction_opts().apply::(tx, legacy); + } + + /// Converts the shared compact transaction options into the full `cast send` options. + pub(crate) fn into_transaction_opts(self) -> TransactionOpts { + TransactionOpts { + gas_limit: self.gas_limit, + gas_price: self.gas_price, + priority_gas_price: self.priority_gas_price, + value: None, + nonce: self.nonce, + legacy: false, + blob: false, + eip4844: false, + blob_gas_price: None, + auth: Vec::new(), + access_list: None, + tempo: self.tempo, + } + } +} /// Different sender kinds used by [`CastTxBuilder`]. -#[expect(clippy::large_enum_variant)] pub enum SenderKind<'a> { /// An address without signer. Used for read-only calls and transactions sent through unlocked /// accounts. @@ -35,7 +140,7 @@ pub enum SenderKind<'a> { /// A reference to a signer. Signer(&'a WalletSigner), /// An owned signer. - OwnedSigner(WalletSigner), + OwnedSigner(Box), } impl SenderKind<'_> { @@ -50,27 +155,36 @@ impl SenderKind<'_> { /// Resolves the sender from the wallet options. /// - /// This function prefers the `from` field and may return a different address from the - /// configured signer - /// If from is specified, returns it - /// If from is not specified, but there is a signer configured, returns the signer's address - /// If from is not specified and there is no signer configured, returns zero address - pub async fn from_wallet_opts(opts: WalletOpts) -> Result { - if let Some(from) = opts.from { - Ok(from.into()) - } else if let Ok(signer) = opts.signer().await { - Ok(Self::OwnedSigner(signer)) - } else { - Ok(Address::ZERO.into()) + /// Returns `from` when set, unless a Tempo access key is set or an address-based authorization + /// needs a signer. Otherwise returns the configured signer (or Tempo wallet account), falling + /// back to the zero address. + pub async fn from_wallet_opts( + mut opts: WalletOpts, + authorizations: &[CliAuthorizationList], + ) -> Result { + let from = opts.from.take(); + if let Some(from) = from + && opts.tempo_access_key.is_none() + && !authorizations.iter().any(|auth| matches!(auth, CliAuthorizationList::Address(_))) + { + return Ok(from.into()); } + let (signer, tempo_wallet) = opts.maybe_signer().await?; + Ok(if let Some(signer) = signer { + signer.into() + } else if let Some(tempo_wallet) = tempo_wallet { + tempo_wallet.account().into() + } else { + from.unwrap_or_default().into() + }) } /// Returns the signer if available. pub fn as_signer(&self) -> Option<&WalletSigner> { match self { Self::Signer(signer) => Some(signer), - Self::OwnedSigner(signer) => Some(signer), - _ => None, + Self::OwnedSigner(signer) => Some(signer.as_ref()), + Self::Address(_) => None, } } } @@ -89,10 +203,69 @@ impl<'a> From<&'a WalletSigner> for SenderKind<'a> { impl From for SenderKind<'_> { fn from(signer: WalletSigner) -> Self { - Self::OwnedSigner(signer) + Self::OwnedSigner(Box::new(signer)) } } +/// Resolves the sender of a read-only request: the browser wallet address when `--browser` is +/// set, otherwise the wallet options. Also returns whether the browser wallet was used. +pub(crate) async fn read_only_sender( + browser: &BrowserWalletOpts, + wallet: WalletOpts, + tx: &TransactionOpts, + chain_id: u64, +) -> Result<(SenderKind<'static>, bool)> { + crate::tempo::ensure_session_not_browser(&tx.tempo, browser.browser)?; + if let Some(session) = tx.tempo.session_signer_for_wallet(&wallet, chain_id)? { + return Ok((session.access_key.account().into(), false)); + } + + Ok(match browser.run::().await? { + Some(browser) => (browser.address().into(), true), + None => (SenderKind::from_wallet_opts(wallet, &tx.auth).await?, false), + }) +} + +/// Validates that `sender` can resolve every EIP-7702 authorization. +pub(crate) fn validate_authorizations( + authorizations: &[CliAuthorizationList], + sender: &SenderKind<'_>, +) -> Result<()> { + let address_auth_count = authorizations + .iter() + .filter(|auth| matches!(auth, CliAuthorizationList::Address(_))) + .count(); + if address_auth_count > 1 { + eyre::bail!( + "Multiple address-based authorizations provided. Only one address can be specified; \ + use pre-signed authorizations (hex-encoded) for multiple authorizations." + ); + } + if address_auth_count == 1 && sender.as_signer().is_none() { + eyre::bail!( + "No signer available to sign authorization. \ + Provide a pre-signed authorization (hex-encoded) instead." + ); + } + + Ok(()) +} + +/// Resolves the sending signer, falling back to the wallet options, and validates it against an +/// explicit `--from`. +pub(crate) async fn resolve_send_signer( + pre_resolved: Option, + eth: &EthereumOpts, +) -> Result<(WalletSigner, Address)> { + let signer = match pre_resolved { + Some(signer) => signer, + None => eth.wallet.signer().await?, + }; + let from = signer.address(); + validate_from_address(eth.wallet.from, from)?; + Ok((signer, from)) +} + /// Prevents a misconfigured hwlib from sending a transaction that defies user-specified --from pub fn validate_from_address( specified_from: Option

, @@ -107,7 +280,7 @@ The specified sender via CLI/env vars does not match the sender configured via the hardware wallet's HD Path. Please use the `--hd-path ` parameter to specify the BIP32 Path which corresponds to the sender, or let foundry automatically detect it by not specifying any sender address." - ) + ); } Ok(()) } @@ -130,98 +303,253 @@ pub struct InputState { func: Option, } -/// Builder type constructing [TransactionRequest] from cast send/mktx inputs. +pub struct CastTxSender { + provider: P, + _phantom: PhantomData, +} + +impl> CastTxSender +where + N::TransactionRequest: FoundryTransactionBuilder, + N::ReceiptResponse: UIfmt + UIfmtReceiptExt, +{ + /// Creates a new Cast instance responsible for sending transactions. + pub const fn new(provider: P) -> Self { + Self { provider, _phantom: PhantomData } + } + + /// Sends a transaction and waits for receipt synchronously + pub async fn send_sync(&self, tx: N::TransactionRequest) -> Result<(B256, String)> { + let receipt = self.provider.send_transaction_sync(tx).await?; + self.finish(receipt, None).await + } + + /// Sends a transaction and returns the pending transaction handle. + pub async fn send(&self, tx: N::TransactionRequest) -> Result> { + Ok(self.provider.send_transaction(tx).await?) + } + + /// Sends a raw RLP-encoded transaction and waits for its receipt synchronously. + pub async fn send_raw_sync(&self, raw_tx: &[u8]) -> Result<(B256, String)> { + let receipt = self.provider.send_raw_transaction_sync(raw_tx).await?; + self.finish(receipt, None).await + } + + /// Prints the transaction hash (if async) or waits for the receipt and prints it. + /// + /// This is the shared "output" path used by both the normal send flow and the browser wallet + /// flow (which sends the transaction out-of-band and only has a tx hash). + pub async fn print_tx_result( + &self, + tx_hash: B256, + cast_async: bool, + confs: u64, + timeout: u64, + ) -> Result<()> { + if cast_async { + sh_println!("{tx_hash:#x}")?; + } else { + let receipt = + self.receipt(format!("{tx_hash:#x}"), None, confs, Some(timeout), false).await?; + sh_println!("{receipt}")?; + } + Ok(()) + } + + /// Fetches the receipt of `tx_hash`, polling for it unless `cast_async` is set, and formats + /// it (or the requested `field`). + pub async fn receipt( + &self, + tx_hash: String, + field: Option, + confs: u64, + timeout: Option, + cast_async: bool, + ) -> Result { + let tx_hash = TxHash::from_str(&tx_hash).wrap_err("invalid tx hash")?; + let receipt = match self.provider.get_transaction_receipt(tx_hash).await? { + Some(receipt) => receipt, + None if cast_async => eyre::bail!("tx not found: {tx_hash:?}"), + None => { + PendingTransactionBuilder::::new(self.provider.root().clone(), tx_hash) + .with_required_confirmations(confs) + .with_timeout(timeout.map(Duration::from_secs)) + .get_receipt() + .await? + } + }; + Ok(self.finish(receipt, field).await?.1) + } + + /// Attaches the revert reason (best effort) and formats the receipt. + async fn finish( + &self, + receipt: N::ReceiptResponse, + field: Option, + ) -> Result<(B256, String)> { + let mut receipt = TransactionReceiptWithRevertReason:: { receipt, revert_reason: None }; + let tx_hash = receipt.receipt.transaction_hash(); + let _ = receipt.update_revert_reason(&self.provider).await; + let formatted = if let Some(field) = field { + get_pretty_receipt_w_reason_attr(&receipt, &field) + .ok_or_else(|| eyre::eyre!("invalid receipt field: {field}"))? + } else if shell::is_json() { + // to_value first to sort json object keys + serde_json::to_value(&receipt)?.to_string() + } else { + receipt.pretty() + }; + Ok((tx_hash, formatted)) + } +} + +/// Builder type constructing generic TransactionRequest from cast send/mktx inputs. /// /// It is implemented as a stateful builder with expected state transition of [InitState] -> /// [ToState] -> [InputState]. #[derive(Debug)] -pub struct CastTxBuilder { +pub struct CastTxBuilder { + inner: TxBuilderInner, + state: S, +} + +/// The state-independent part of [`CastTxBuilder`]. +#[derive(Debug)] +struct TxBuilderInner { provider: P, - tx: WithOtherFields, + tx: N::TransactionRequest, /// Whether the transaction should be sent as a legacy transaction. legacy: bool, blob: bool, - auth: Option, + /// Whether the blob transaction should use EIP-4844 (legacy) format instead of EIP-7594. + eip4844: bool, + /// Whether to fill gas, fees and nonce. Set to `false` for read-only calls + /// (eth_call, eth_estimateGas, eth_createAccessList). + fill: bool, + /// Whether the filled transaction will be submitted through a browser wallet. + browser: bool, + /// The preset used when estimating EIP-1559 fees. + eip1559_fee_estimate: Eip1559FeeEstimatePreset, + /// Optional percentage applied to provider gas estimates. + gas_estimate_multiplier: Option, + auth: Vec, chain: Chain, etherscan_api_key: Option, - etherscan_api_version: EtherscanApiVersion, + etherscan_api_url: Option, access_list: Option>, - state: S, } -impl> CastTxBuilder { - /// Creates a new instance of [CastTxBuilder] filling transaction with fields present in - /// provided [TransactionOpts]. - pub async fn new(provider: P, tx_opts: TransactionOpts, config: &Config) -> Result { - let mut tx = WithOtherFields::::default(); +impl CastTxBuilder { + /// Returns the resolved chain for this builder. + pub const fn chain(&self) -> Chain { + self.inner.chain + } - let chain = utils::get_chain(config.chain, &provider).await?; - let etherscan_api_version = config.get_etherscan_api_version(Some(chain)); - let etherscan_api_key = config.get_etherscan_api_key(Some(chain)); - // mark it as legacy if requested or the chain is legacy and no 7702 is provided. - let legacy = tx_opts.legacy || (chain.is_legacy() && tx_opts.auth.is_none()); + /// Returns the Etherscan API key and URL resolved for this builder's chain. + pub(crate) fn etherscan_api(&self) -> (Option<&str>, Option<&str>) { + (self.inner.etherscan_api_key.as_deref(), self.inner.etherscan_api_url.as_deref()) + } - if let Some(gas_limit) = tx_opts.gas_limit { - tx.set_gas_limit(gas_limit.to()); - } + /// Returns the transaction request being built. + pub(crate) const fn tx_mut(&mut self) -> &mut N::TransactionRequest { + &mut self.inner.tx + } - if let Some(value) = tx_opts.value { - tx.set_value(value); - } + /// Marks this transaction as destined for browser wallet submission. + pub const fn with_browser_wallet(mut self) -> Self { + self.inner.browser = true; + self + } - if let Some(gas_price) = tx_opts.gas_price { - if legacy { - tx.set_gas_price(gas_price.to()); - } else { - tx.set_max_fee_per_gas(gas_price.to()); - } - } + /// Applies a percentage multiplier to provider gas estimates. + pub const fn with_gas_estimate_multiplier(mut self, multiplier: Option) -> Self { + self.inner.gas_estimate_multiplier = multiplier; + self + } - if !legacy && let Some(priority_fee) = tx_opts.priority_gas_price { - tx.set_max_priority_fee_per_gas(priority_fee.to()); - } + /// Returns whether this builder contains any EIP-7702 authorizations. + pub(crate) const fn has_auth(&self) -> bool { + !self.inner.auth.is_empty() + } - if let Some(max_blob_fee) = tx_opts.blob_gas_price { - tx.set_max_fee_per_blob_gas(max_blob_fee.to()) - } + /// Validates that the configured sender can resolve all EIP-7702 authorizations. + pub(crate) fn validate_auth(&self, sender: &SenderKind<'_>) -> Result<()> { + validate_authorizations(&self.inner.auth, sender) + } + + /// Returns whether building this request will disclose an EIP-7702 authorization to an RPC + /// endpoint. + pub(crate) fn will_disclose_auth_during_build(&self) -> bool { + // Generating an access list or estimating gas sends the authorization-bearing request + // to the RPC. + self.has_auth() + && (matches!(self.inner.access_list, Some(None)) + || (self.inner.fill && self.inner.tx.gas_limit().is_none())) + } + + fn with_state(self, state: S2) -> CastTxBuilder { + CastTxBuilder { inner: self.inner, state } + } +} + +impl> CastTxBuilder +where + N::TransactionRequest: FoundryTransactionBuilder, +{ + /// Creates a new instance of [CastTxBuilder] filling transaction with fields present in + /// provided [TransactionOpts]. + pub async fn new(provider: P, tx_opts: TransactionOpts, config: &Config) -> Result { + let mut tx = N::TransactionRequest::default(); - if let Some(nonce) = tx_opts.nonce { - tx.set_nonce(nonce.to()); + let chain = utils::get_chain(config.chain, &provider).await?; + let etherscan_config = config.get_etherscan_config_with_chain(Some(chain)).ok().flatten(); + let etherscan_api_key = etherscan_config.as_ref().map(|c| c.key.clone()); + let etherscan_api_url = etherscan_config.map(|c| c.api_url); + // mark it as legacy if requested or the chain is legacy and no 7702 is provided. + let legacy = tx_opts.legacy || (chain.is_legacy() && tx_opts.auth.is_empty()); + + tx_opts.apply::(&mut tx, legacy); + // The curl transport answers every request with a placeholder, so there is nothing to + // check against. + if !config.eth_rpc_curl { + ensure_expiring_nonce_discriminator_active(&provider, &tx).await?; } Ok(Self { - provider, - tx, - legacy, - blob: tx_opts.blob, - chain, - etherscan_api_key, - etherscan_api_version, - auth: tx_opts.auth, - access_list: tx_opts.access_list, + inner: TxBuilderInner { + provider, + tx, + legacy, + blob: tx_opts.blob, + eip4844: tx_opts.eip4844, + fill: true, + browser: false, + eip1559_fee_estimate: config.eip1559_fee_estimate, + gas_estimate_multiplier: None, + chain, + etherscan_api_key, + etherscan_api_url, + auth: tx_opts.auth, + access_list: tx_opts.access_list, + }, state: InitState, }) } /// Sets [TxKind] for this builder and changes state to [ToState]. - pub async fn with_to(self, to: Option) -> Result> { - let to = if let Some(to) = to { Some(to.resolve(&self.provider).await?) } else { None }; - Ok(CastTxBuilder { - provider: self.provider, - tx: self.tx, - legacy: self.legacy, - blob: self.blob, - chain: self.chain, - etherscan_api_key: self.etherscan_api_key, - etherscan_api_version: self.etherscan_api_version, - auth: self.auth, - access_list: self.access_list, - state: ToState { to }, - }) + pub async fn with_to(self, to: Option) -> Result> { + let to = match to { + Some(to) => Some(to.resolve(&self.inner.provider).await?), + None => None, + }; + Ok(self.with_state(ToState { to })) } } -impl> CastTxBuilder { +impl> CastTxBuilder +where + N::TransactionRequest: FoundryTransactionBuilder, +{ /// Accepts user-provided code, sig and args params and constructs calldata for the transaction. /// If code is present, input will be set to code + encoded constructor arguments. If no code is /// present, input is set to just provided arguments. @@ -230,18 +558,12 @@ impl> CastTxBuilder { code: Option, sig: Option, args: Vec, - ) -> Result> { + ) -> Result> { + let to = self.state.to; let (mut args, func) = if let Some(sig) = sig { - parse_function_args( - &sig, - args, - self.state.to, - self.chain, - &self.provider, - self.etherscan_api_key.as_deref(), - self.etherscan_api_version, - ) - .await? + let (key, url) = self.etherscan_api(); + parse_function_args(&sig, args, to, self.chain(), &self.inner.provider, key, url) + .await? } else { (Vec::new(), None) }; @@ -254,230 +576,552 @@ impl> CastTxBuilder { args }; - if self.state.to.is_none() && code.is_none() { - let has_value = self.tx.value.is_some_and(|v| !v.is_zero()); - let has_auth = self.auth.is_some(); - // We only allow user to omit the recipient address if transaction is an EIP-7702 tx - // without a value. - if !has_auth || has_value { - eyre::bail!("Must specify a recipient address or contract code to deploy"); - } + // We only allow user to omit the recipient address if transaction is an EIP-7702 tx + // without a value. The sender is used as the destination in that case, see `build`. + if to.is_none() + && code.is_none() + && (!self.has_auth() || self.inner.tx.value().is_some_and(|v| !v.is_zero())) + { + eyre::bail!("Must specify a recipient address or contract code to deploy"); + } + if to.is_none() && code.is_some() && self.has_auth() { + eyre::bail!( + "EIP-7702 transactions can't be CREATE transactions and require a destination address" + ); } - Ok(CastTxBuilder { - provider: self.provider, - tx: self.tx, - legacy: self.legacy, - blob: self.blob, - chain: self.chain, - etherscan_api_key: self.etherscan_api_key, - etherscan_api_version: self.etherscan_api_version, - auth: self.auth, - access_list: self.access_list, - state: InputState { kind: self.state.to.into(), input, func }, - }) + Ok(self.with_state(InputState { kind: to.into(), input, func })) } } -impl> CastTxBuilder { - /// Builds [TransactionRequest] and fiils missing fields. Returns a transaction which is ready - /// to be broadcasted. +impl> CastTxBuilder +where + N::TransactionRequest: FoundryTransactionBuilder, +{ + /// Builds the TransactionRequest. Fills gas, fees and nonce unless [`raw`](Self::raw) was + /// called. pub async fn build( self, sender: impl Into>, - ) -> Result<(WithOtherFields, Option)> { - self._build(sender, true, false).await + ) -> Result<(N::TransactionRequest, Option)> { + self.inner.build(self.state, &sender.into(), None).await } - /// Builds [TransactionRequest] without filling missing fields. Used for read-only calls such as - /// eth_call, eth_estimateGas, etc - pub async fn build_raw( + /// Builds a transaction that will be signed by a Tempo access key. + /// + /// The access-key id is set before gas estimation. If the access key needs on-chain + /// provisioning, its authorization is embedded before access-list/gas estimation and before + /// any sponsor digest can be computed. + pub async fn build_with_tempo_wallet( self, - sender: impl Into>, - ) -> Result<(WithOtherFields, Option)> { - self._build(sender, false, false).await + wallet: &TempoAccountsWallet, + ) -> Result<(N::TransactionRequest, Option, TempoAccountsWallet)> { + let mut prepared = wallet.clone(); + let (tx, func) = + self.inner.build(self.state, &wallet.account().into(), Some(&mut prepared)).await?; + Ok((tx, func, prepared)) } - /// Builds an unsigned RLP-encoded raw transaction. - /// - /// Returns the hex encoded string representation of the transaction. - pub async fn build_unsigned_raw(self, from: Address) -> Result { - let (tx, _) = self._build(SenderKind::Address(from), true, true).await?; - let tx = tx.build_unsigned()?; - match tx { - AnyTypedTransaction::Ethereum(t) => Ok(hex::encode_prefixed(t.encoded_for_signing())), - _ => eyre::bail!("Cannot generate unsigned transaction for non-Ethereum transactions"), + /// Populates the blob sidecar for the transaction if any blob data was provided. + pub fn with_blob_data(mut self, blob_data: Option>) -> Result { + let Some(blob_data) = blob_data else { return Ok(self) }; + + let mut coder = SidecarBuilder::::default(); + coder.ingest(&blob_data); + if self.inner.eip4844 { + self.inner.tx.set_blob_sidecar_4844(coder.build_4844()?); + } else { + self.inner.tx.set_blob_sidecar_7594(coder.build_7594()?); } + Ok(self) } - async fn _build( + /// Skips gas, fee and nonce filling. Use for read-only calls + /// (eth_call, eth_estimateGas, eth_createAccessList). + pub const fn raw(mut self) -> Self { + self.inner.fill = false; + self + } +} + +impl> TxBuilderInner +where + N::TransactionRequest: FoundryTransactionBuilder, +{ + async fn build( mut self, - sender: impl Into>, - fill: bool, - unsigned: bool, - ) -> Result<(WithOtherFields, Option)> { - let sender = sender.into(); + state: InputState, + sender: &SenderKind<'_>, + tempo_wallet: Option<&mut TempoAccountsWallet>, + ) -> Result<(N::TransactionRequest, Option)> { + let fill = self.fill; let from = sender.address(); - self.tx.set_kind(self.state.kind); - - // we set both fields to the same value because some nodes only accept the legacy `data` field: - let input = Bytes::copy_from_slice(&self.state.input); - self.tx.input = TransactionInput { input: Some(input.clone()), data: Some(input) }; - - self.tx.set_from(from); + // An EIP-7702 transaction can't be a CREATE. If the recipient was omitted, the sender + // itself is the destination, which is the common case for self-delegation. + let kind = match state.kind { + TxKind::Create if !self.auth.is_empty() => TxKind::Call(from), + kind => kind, + }; + self.tx.set_kind(kind); + // We set both fields to the same value because some nodes only accept the legacy + // `data` field: https://github.com/foundry-rs/foundry/issues/7764#issuecomment-2210453249 + self.tx.set_input_kind(state.input, TransactionInputKind::Both); + if !from.is_zero() { + self.tx.set_from(from); + } self.tx.set_chain_id(self.chain.id()); + // For batch transactions with calls, clear `to` and `value` so the node correctly + // identifies this as an AA batch transaction. The `calls` field determines the actual + // targets. If `to` is set, `build_aa()` would add a spurious extra call. + self.tx.clear_batch_to(); - let tx_nonce = if let Some(nonce) = self.tx.nonce { - nonce + // Read-only calls do not need a nonce unless it is required to sign an authorization. + // Avoid an otherwise unused `eth_getTransactionCount` request for raw transactions. + let resolve_in_parallel = + fill && self.auth.is_empty() && tempo_wallet.is_none() && !self.chain.is_tempo(); + let tx_nonce = if resolve_in_parallel { + let fees_are_complete = if self.legacy { + self.tx.gas_price().is_some() + } else { + matches!( + (self.tx.max_fee_per_gas(), self.tx.max_priority_fee_per_gas()), + (Some(max_fee), Some(priority_fee)) if priority_fee <= max_fee + ) + } && (!self.blob || self.tx.max_fee_per_blob_gas().is_some()); + let gas_request = + (fees_are_complete && self.access_list.is_none() && self.tx.gas_limit().is_none()) + .then(|| self.tx.clone()); + let (blob, legacy, browser, eip1559_fee_estimate, gas_estimate_multiplier) = ( + self.blob, + self.legacy, + self.browser, + self.eip1559_fee_estimate, + self.gas_estimate_multiplier, + ); + let Self { provider, tx, .. } = &mut self; + let provider = &*provider; + let (tx_nonce, (), gas_limit) = tokio::try_join!( + Self::resolve_nonce(provider, from, tx.nonce()), + Self::fill_fees(provider, tx, blob, legacy, browser, eip1559_fee_estimate), + async { + match gas_request { + Some(request) => { + Self::estimate_gas(provider, request, gas_estimate_multiplier) + .await + .map(Some) + } + None => Ok(None), + } + }, + )?; + if let Some(gas_limit) = gas_limit { + self.tx.set_gas_limit(gas_limit); + } + Some(tx_nonce) + } else if fill || !self.auth.is_empty() { + Some(Self::resolve_nonce(&self.provider, from, self.tx.nonce()).await?) } else { - let nonce = self.provider.get_transaction_count(from).await?; + None + }; + if let Some(tx_nonce) = tx_nonce { if fill { - self.tx.nonce = Some(nonce); + self.tx.set_nonce(tx_nonce); } - nonce - }; - - if !unsigned { self.resolve_auth(sender, tx_nonce).await?; - } else if self.auth.is_some() { - let Some(CliAuthorizationList::Signed(signed_auth)) = self.auth.take() else { - eyre::bail!( - "SignedAuthorization needs to be provided for generating unsigned 7702 txs" - ) - }; - - self.tx.set_authorization_list(vec![signed_auth]); } - - if let Some(access_list) = match self.access_list.take() { + if let Some(wallet) = tempo_wallet { + *wallet = self.tx.prepare_with_tempo_wallet(&self.provider, wallet).await?; + } + if fill && !resolve_in_parallel { + Self::fill_fees( + &self.provider, + &mut self.tx, + self.blob, + self.legacy, + self.browser, + self.eip1559_fee_estimate, + ) + .await?; + } + // Fetch the access list from the provider if `--access-list` was passed without a value. + let access_list = match self.access_list.take() { None => None, - // --access-list provided with no value, call the provider to create it Some(None) => Some(self.provider.create_access_list(&self.tx).await?.access_list), - // Access list provided as a string, attempt to parse it Some(Some(access_list)) => Some(access_list), - } { + }; + if let Some(access_list) = access_list { self.tx.set_access_list(access_list); } + if fill && self.tx.gas_limit().is_none() { + let request = if self.browser { + self.tx.browser_wallet_gas_estimation_request() + } else { + self.tx.clone() + }; + let estimated = + Self::estimate_gas(&self.provider, request, self.gas_estimate_multiplier).await?; + self.tx.set_gas_limit(estimated); + } + + Ok((self.tx, state.func)) + } + + /// Resolves the transaction nonce. Returns the existing nonce or fetches one from the provider. + async fn resolve_nonce(provider: &P, from: Address, nonce: Option) -> Result { + match nonce { + Some(nonce) => Ok(nonce), + None => Ok(provider.get_transaction_count(from).await?), + } + } - if !fill { - return Ok((self.tx, self.state.func)); + /// Parses the passed --auth values and sets the authorization list on the transaction. + /// + /// If a signer is available in `sender`, address-based auths will be signed. + /// If no signer is available, all auths must be pre-signed. + async fn resolve_auth(&mut self, sender: &SenderKind<'_>, tx_nonce: u64) -> Result<()> { + if self.auth.is_empty() { + return Ok(()); } + validate_authorizations(&self.auth, sender)?; - if self.legacy && self.tx.gas_price.is_none() { - self.tx.gas_price = Some(self.provider.get_gas_price().await?); + let mut signed_auths = Vec::with_capacity(self.auth.len()); + for auth in std::mem::take(&mut self.auth) { + signed_auths.push(match auth { + CliAuthorizationList::Address(address) => { + let auth = Authorization { + chain_id: U256::from(self.chain.id()), + nonce: tx_nonce + 1, + address, + }; + let signer = + sender.as_signer().expect("address-based authorization requires a signer"); + let signature = signer.sign_hash(&auth.signature_hash()).await?; + auth.into_signed(signature) + } + CliAuthorizationList::Signed(auth) => auth, + }); } + self.tx.set_authorization_list(signed_auths); + Ok(()) + } - if self.blob && self.tx.max_fee_per_blob_gas.is_none() { - self.tx.max_fee_per_blob_gas = Some(self.provider.get_blob_base_fee().await?) + /// Fills gas price, EIP-1559 fees, and blob fees from the provider. + /// + /// Only fills values that haven't been explicitly set by the user. + async fn fill_fees( + provider: &P, + tx: &mut N::TransactionRequest, + blob: bool, + legacy: bool, + browser: bool, + eip1559_fee_estimate: Eip1559FeeEstimatePreset, + ) -> Result<()> { + if blob && tx.max_fee_per_blob_gas().is_none() { + tx.set_max_fee_per_blob_gas(provider.get_blob_base_fee().await?) } + fill_transaction_gas_fees(provider, tx, legacy, browser, eip1559_fee_estimate).await + } - if !self.legacy - && (self.tx.max_fee_per_gas.is_none() || self.tx.max_priority_fee_per_gas.is_none()) + /// Estimate tx gas from provider call. Tries to decode custom error if execution reverted. + async fn estimate_gas( + provider: &P, + request: N::TransactionRequest, + multiplier: Option, + ) -> Result { + let err = match provider.estimate_gas(request).await { + Ok(estimated) => return apply_gas_estimate_multiplier(estimated, multiplier), + Err(err) => err, + }; + // If execution reverted with code 3 during provider gas estimation then try to decode + // custom errors and append it to the error message. + if let TransportError::ErrorResp(payload) = &err + && payload.code == 3 + && let Some(data) = &payload.data + && let Ok(data) = serde_json::from_str::(data.get()) + && let Ok(Some(decoded_error)) = decode_custom_error(&data).await { - let estimate = self.provider.estimate_eip1559_fees().await?; + eyre::bail!("Failed to estimate gas: {err}: {decoded_error}"); + } + eyre::bail!("Failed to estimate gas: {err}"); + } +} - if !self.legacy { - if self.tx.max_fee_per_gas.is_none() { - self.tx.max_fee_per_gas = Some(estimate.max_fee_per_gas); - } +/// Fills gas price or EIP-1559 fee fields from the provider and validates the final pair. +pub(crate) async fn fill_transaction_gas_fees>( + provider: &P, + tx: &mut N::TransactionRequest, + legacy: bool, + browser: bool, + eip1559_fee_estimate: Eip1559FeeEstimatePreset, +) -> Result<()> +where + N::TransactionRequest: FoundryTransactionBuilder, +{ + if legacy { + if tx.gas_price().is_none() { + tx.set_gas_price(provider.get_gas_price().await?); + } + return Ok(()); + } - if self.tx.max_priority_fee_per_gas.is_none() { - self.tx.max_priority_fee_per_gas = Some(estimate.max_priority_fee_per_gas); - } - } + if tx.max_fee_per_gas().is_none() || tx.max_priority_fee_per_gas().is_none() { + let estimate = estimate_eip1559_fees(provider, eip1559_fee_estimate).await?; + + // Only honor the browser-suggested tip when the user has not pinned a + // priority fee; `resolve_broadcast_eip1559_fees` ignores a lower tip. + let browser_suggested_tip = if browser && tx.max_priority_fee_per_gas().is_none() { + provider.get_max_priority_fee_per_gas().await.ok() + } else { + None + }; + + // User `--gas-price`/`--priority-gas-price` overrides are already applied + // to `tx`; pass `None` so they are not double-applied here. + let estimate = resolve_broadcast_eip1559_fees(estimate, None, None, browser_suggested_tip)?; + + if tx.max_fee_per_gas().is_none() { + tx.set_max_fee_per_gas(estimate.max_fee_per_gas); } - if self.tx.gas.is_none() { - self.estimate_gas().await?; + if tx.max_priority_fee_per_gas().is_none() { + tx.set_max_priority_fee_per_gas(estimate.max_priority_fee_per_gas); } + } - Ok((self.tx, self.state.func)) + if let (Some(max_fee), Some(priority)) = (tx.max_fee_per_gas(), tx.max_priority_fee_per_gas()) { + eyre::ensure!( + priority <= max_fee, + "max priority fee per gas ({priority}) cannot exceed max fee per gas ({max_fee})" + ); } - /// Estimate tx gas from provider call. Tries to decode custom error if execution reverted. - async fn estimate_gas(&mut self) -> Result<()> { - match self.provider.estimate_gas(self.tx.clone()).await { - Ok(estimated) => { - self.tx.gas = Some(estimated); - Ok(()) - } - Err(err) => { - if let TransportError::ErrorResp(payload) = &err { - // If execution reverted with code 3 during provider gas estimation then try - // to decode custom errors and append it to the error message. - if payload.code == 3 - && let Some(data) = &payload.data - && let Ok(Some(decoded_error)) = decode_execution_revert(data).await - { - eyre::bail!("Failed to estimate gas: {}: {}", err, decoded_error) - } + Ok(()) +} + +/// Tries to decode a custom error name and inputs from revert data. +pub(crate) async fn decode_custom_error(data: &[u8]) -> Result> { + let Some(selector) = data.get(..4) else { return Ok(None) }; + let Some(known_error) = + SignaturesIdentifier::new(false)?.identify_error(selector.try_into().unwrap()).await + else { + return Ok(None); + }; + let mut decoded_error = known_error.name.clone(); + if !known_error.inputs.is_empty() + && let Ok(error) = known_error.decode_error(data) + { + write!(decoded_error, "({})", format_tokens(&error.body).format(", "))?; + } + Ok(Some(decoded_error)) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_json_rpc::{RequestPacket, ResponsePacket}; + use alloy_network::{Ethereum, NetworkTransactionBuilder}; + use alloy_provider::{ProviderBuilder, mock::Asserter}; + use alloy_rpc_client::RpcClient; + use alloy_sol_types::SolValue; + use alloy_transport::{TransportFut, mock::MockTransport}; + use clap::Parser; + use foundry_common::tempo::resolve_and_set_fee_token; + use std::{ + sync::{Arc, Mutex}, + task::{Context, Poll}, + }; + use tempo_alloy::TempoNetwork; + use tokio::{sync::Barrier, time::timeout}; + use tower::Service; + + #[derive(Clone)] + struct BarrierTransport { + inner: MockTransport, + barrier: Arc, + fill_methods: Arc>>, + } + + impl Service for BarrierTransport { + type Response = ResponsePacket; + type Error = TransportError; + type Future = TransportFut<'static>; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, req: RequestPacket) -> Self::Future { + let fill_method = match &req { + RequestPacket::Single(req) + if matches!( + req.method(), + "eth_getTransactionCount" | "eth_gasPrice" | "eth_estimateGas" + ) => + { + Some(req.method().to_string()) } - eyre::bail!("Failed to estimate gas: {}", err) - } + _ => None, + }; + let Some(fill_method) = fill_method else { + return self.inner.call(req); + }; + self.fill_methods.lock().unwrap().push(fill_method); + + let barrier = self.barrier.clone(); + let mut inner = self.inner.clone(); + Box::pin(async move { + barrier.wait().await; + inner.call(req).await + }) } } - /// Parses the passed --auth value and sets the authorization list on the transaction. - async fn resolve_auth(&mut self, sender: SenderKind<'_>, tx_nonce: u64) -> Result<()> { - let Some(auth) = self.auth.take() else { return Ok(()) }; + const TO: Address = Address::repeat_byte(0x11); - let auth = match auth { - CliAuthorizationList::Address(address) => { - let auth = Authorization { - chain_id: U256::from(self.chain.id()), - nonce: tx_nonce + 1, - address, - }; - - let Some(signer) = sender.as_signer() else { - eyre::bail!("No signer available to sign authorization"); - }; - let signature = signer.sign_hash(&auth.signature_hash()).await?; + /// Builds a transaction to [`TO`] from the given `cast send` style arguments. + async fn builder>( + provider: P, + args: &[&str], + ) -> CastTxBuilder { + let config = Config { chain: Some(Chain::mainnet()), ..Default::default() }; + let opts = TransactionOpts::parse_from([&["test"], args].concat()); + CastTxBuilder::new(provider, opts, &config) + .await + .unwrap() + .with_to(Some(TO.into())) + .await + .unwrap() + .with_code_sig_and_args(None, None, Vec::new()) + .await + .unwrap() + } - auth.into_signed(signature) - } - CliAuthorizationList::Signed(auth) => auth, + #[tokio::test] + async fn filled_build_applies_multiplier_to_concurrent_gas_estimate() { + let asserter = Asserter::new(); + for _ in 0..2 { + asserter.push_success(&U64::from(100)); + } + let fill_methods = Arc::new(Mutex::new(Vec::new())); + let transport = BarrierTransport { + inner: MockTransport::new(asserter), + barrier: Arc::new(Barrier::new(2)), + fill_methods: fill_methods.clone(), }; + let provider = ProviderBuilder::new_with_network::() + .connect_client(RpcClient::new(transport, true)); - self.tx.set_authorization_list(vec![auth]); + let builder = builder(&provider, &["--legacy", "--gas-price", "1"]) + .await + .with_gas_estimate_multiplier(Some(150)); + let (tx, _) = timeout(Duration::from_secs(1), builder.build(Address::repeat_byte(0x22))) + .await + .expect("nonce and gas requests were not in flight together") + .unwrap(); - Ok(()) + assert_eq!(tx.nonce, Some(100)); + assert_eq!(tx.gas_price, Some(1)); + assert_eq!(tx.gas, Some(150)); + let mut fill_methods = fill_methods.lock().unwrap().clone(); + fill_methods.sort(); + assert_eq!(fill_methods, ["eth_estimateGas", "eth_getTransactionCount"]); } -} -impl CastTxBuilder -where - P: Provider, -{ - pub fn with_blob_data(mut self, blob_data: Option>) -> Result { - let Some(blob_data) = blob_data else { return Ok(self) }; + #[tokio::test] + async fn browser_submission_resolves_type_after_tempo_fee_token() { + let asserter = Asserter::new(); + let token = Address::repeat_byte(0x42); + asserter.push_success(&token.abi_encode()); + let provider = + ProviderBuilder::new_with_network::().connect_mocked_client(asserter); + let chain = Chain::from_id(4217); + let config = Config { chain: Some(chain), ..Default::default() }; + let opts = TransactionOpts::parse_from([ + "test", + "--gas-limit", + "21000", + "--nonce", + "0", + "--gas-price", + "1", + "--priority-gas-price", + "1", + ]); + let (mut tx, _) = CastTxBuilder::new(&provider, opts, &config) + .await + .unwrap() + .with_browser_wallet() + .with_to(Some(TO.into())) + .await + .unwrap() + .with_code_sig_and_args(None, None, Vec::new()) + .await + .unwrap() + .build(Address::repeat_byte(0x22)) + .await + .unwrap(); + assert_eq!(tx.inner.transaction_type, None); + assert_eq!(tx.fee_token, None); - let mut coder = SidecarBuilder::::default(); - coder.ingest(&blob_data); - let sidecar = coder.build()?; + // Fee resolution happens after building and can change the submission type to Tempo AA. + resolve_and_set_fee_token(Some(&provider), &mut tx, None).await.unwrap(); + tx.prep_for_submission(); + assert_eq!(tx.inner.transaction_type, Some(0x76)); + assert_eq!(tx.fee_token, Some(token)); + } - self.tx.set_blob_sidecar(sidecar); - self.tx.populate_blob_hashes(); + #[tokio::test] + async fn raw_build_skips_nonce_request() { + // No responses are queued, so any RPC request would fail this test. In particular, this + // guards against restoring the unused `eth_getTransactionCount` request. + let provider = + ProviderBuilder::new_with_network::().connect_mocked_client(Asserter::new()); + builder(&provider, &[]).await.raw().build(Address::repeat_byte(0x22)).await.unwrap(); + } - Ok(self) + #[tokio::test] + async fn detects_auth_rpc_disclosure() { + let provider = + ProviderBuilder::new_with_network::().connect_mocked_client(Asserter::new()); + let auth = TO.to_string(); + + let no_auth = builder(&provider, &["--gas-limit", "21000"]).await.raw(); + assert!(!no_auth.has_auth()); + assert!(!no_auth.will_disclose_auth_during_build()); + + let rpc_call = builder(&provider, &["--auth", &auth, "--gas-limit", "21000"]).await.raw(); + assert!(rpc_call.has_auth()); + assert!(!rpc_call.will_disclose_auth_during_build()); + + let generated_access_list = builder(&provider, &["--auth", &auth, "--access-list"]).await; + assert!(generated_access_list.raw().will_disclose_auth_during_build()); + + let explicit_access_list = + builder(&provider, &["--auth", &auth, "--access-list", "[]"]).await; + assert!(!explicit_access_list.raw().will_disclose_auth_during_build()); + + let estimated_gas = builder(&provider, &["--auth", &auth]).await; + assert!(estimated_gas.will_disclose_auth_during_build()); } -} -/// Helper function that tries to decode custom error name and inputs from error payload data. -async fn decode_execution_revert(data: &RawValue) -> Result> { - let err_data = serde_json::from_str::(data.get())?; - let Some(selector) = err_data.get(..4) else { return Ok(None) }; - if let Some(known_error) = - SignaturesIdentifier::new(false)?.identify_error(selector.try_into().unwrap()).await - { - let mut decoded_error = known_error.name.clone(); - if !known_error.inputs.is_empty() - && let Ok(error) = known_error.decode_error(&err_data) - { - write!(decoded_error, "({})", format_tokens(&error.body).format(", "))?; + #[test] + fn tx_params_parses_gas_price_units() { + #[derive(Parser)] + struct Cli { + #[command(flatten)] + tx: TxParams, } - return Ok(Some(decoded_error)); + + let cli = + Cli::try_parse_from(["test", "--gas-price", "10gwei", "--priority-gas-price", "2gwei"]) + .unwrap(); + assert_eq!(cli.tx.gas_price, Some(U256::from(10_000_000_000u64))); + assert_eq!(cli.tx.priority_gas_price, Some(U256::from(2_000_000_000u64))); + + let cli = Cli::try_parse_from(["test", "--gas-price", "100", "--priority-gas-price", "2"]) + .unwrap(); + assert_eq!(cli.tx.gas_price, Some(U256::from(100))); + assert_eq!(cli.tx.priority_gas_price, Some(U256::from(2))); } - Ok(None) } diff --git a/crates/cast/tests/cli/abi.rs b/crates/cast/tests/cli/abi.rs new file mode 100644 index 0000000000000..382bc061eb47a --- /dev/null +++ b/crates/cast/tests/cli/abi.rs @@ -0,0 +1,373 @@ +//! CLI tests for abi commands. + +use super::*; + +// checks `cast calldata` can handle arrays +#[casttest] +fn calldata_array(cmd: _) { + cmd.args(["calldata", "propose(string[])", "[\"\"]"]).assert_success().stdout_eq(str![[r#" +0xcde2baba0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000 + +"#]]); +} + +#[casttest] +fn string_decode(cmd: _) { + cmd.args(["string-decode", "0x88c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000054753303235000000000000000000000000000000000000000000000000000000"]).assert_success().stdout_eq(str![[r#" +"GS025" + +"#]]); +} + +// Test cast abi-encode-event with indexed parameters +#[casttest] +fn abi_encode_event_indexed(cmd: _) { + cmd.args([ + "abi-encode-event", + "Transfer(address indexed from, address indexed to, uint256 value)", + "0x1234567890123456789012345678901234567890", + "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd", + "1000", + ]) + .assert_success() + .stdout_eq(str![[r#" +[topic0]: 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef +[topic1]: 0x0000000000000000000000001234567890123456789012345678901234567890 +[topic2]: 0x000000000000000000000000abcdefabcdefabcdefabcdefabcdefabcdefabcd +[data]: 0x00000000000000000000000000000000000000000000000000000000000003e8 + +"#]]); +} + +// Test cast abi-encode-event with no indexed parameters +#[casttest] +fn abi_encode_event_no_indexed(cmd: _) { + cmd.args([ + "abi-encode-event", + "Approval(address owner, address spender, uint256 value)", + "0x1234567890123456789012345678901234567890", + "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd", + "2000" + ]) + .assert_success() + .stdout_eq(str![[r#" +[topic0]: 0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925 +[data]: 0x0000000000000000000000001234567890123456789012345678901234567890000000000000000000000000abcdefabcdefabcdefabcdefabcdefabcdefabcd00000000000000000000000000000000000000000000000000000000000007d0 + +"#]]); +} + +// Test cast abi-encode-event with dynamic indexed parameter (string) +#[casttest] +fn abi_encode_event_dynamic_indexed(cmd: _) { + // topic1 is keccak256("hello"), matching Solidity's hashing of indexed strings. + cmd.args(["abi-encode-event", "Log(string indexed message, uint256 data)", "hello", "42"]) + .assert_success() + .stdout_eq(str![[r#" +[topic0]: 0xdd970dd9b5bfe707922155b058a407655cb18288b807e2216442bca8ad83d6b5 +[topic1]: 0x1c8aff950685c2ed4bc3174f3472287b56d9517b9c948127319a09a7a36deac8 +[data]: 0x000000000000000000000000000000000000000000000000000000000000002a + +"#]]); + + // topic1 is keccak256(0xdeadbeef): raw contents, no padding or length prefix. + cmd.cast_fuse() + .args(["abi-encode-event", "Raw(bytes indexed payload)", "0xdeadbeef"]) + .assert_success() + .stdout_eq(str![[r#" +[topic0]: 0xada02cd4e7d8ed80ea02ba8f2b0c44295aecd704d4d25ffb82af584a09f59997 +[topic1]: 0xd4fd4e189132273036449fc9e11198c739161b4c0116a9a2dccdfa1c492006f1 + +"#]]); +} + +#[casttest] +fn abi_encode_event_indexed_arrays(cmd: _) { + // Array topics hash the concatenated padded elements without any length prefix: + // topic1 is keccak256(word(1) ++ word(2)). + cmd.args(["abi-encode-event", "Numbers(uint256[] indexed values)", "[1,2]"]) + .assert_success() + .stdout_eq(str![[r#" +[topic0]: 0x998e4b4864cb035323945d614c5aecbe49a6045c844d1e046fee480db062cc97 +[topic1]: 0xe90b7bceb6e7df5418fb78d8ee546e97c83a08bbccc01a0644d599ccd2a7c2e0 + +"#]]); + + cmd.cast_fuse() + .args(["abi-encode-event", "Fixed(uint256[2] indexed values)", "[7,9]"]) + .assert_success() + .stdout_eq(str![[r#" +[topic0]: 0x369112dff39fc5861d470b610421e77e5cd444efdbe9948df8de38824dffc6be +[topic1]: 0xae6299332bcd708cd60e3a8defa55de28078a50a4cf2b3de3a546253240ff9e1 + +"#]]); + + // Nested arrays flatten into one preimage: `[[1],[2,3]]` hashes like `[1,2,3]`. + cmd.cast_fuse() + .args(["abi-encode-event", "Matrix(uint256[][] indexed rows)", "[[1],[2,3]]"]) + .assert_success() + .stdout_eq(str![[r#" +[topic0]: 0x8b54144c0baafc3c0a1b743082b3378dc1f6c787fe1b6ed5067469704b5b47b9 +[topic1]: 0x6e0c627900b24bd432fe7b1f713f1b0744091a646a9fe4a65a18dfed21f2949c + +"#]]); + + // Strings nested in arrays are right-padded to 32 bytes each. + cmd.cast_fuse() + .args(["abi-encode-event", "Names(string[] indexed names)", "[alpha,beta]"]) + .assert_success() + .stdout_eq(str![[r#" +[topic0]: 0x54612034f490f8c9efbbf618b99e0dd23834387135bf603e7f77f36ab5a0dc59 +[topic1]: 0xec503acd2f5b8395d778ead6068e4dff0b21beb8f55fc559e660857d57f0c112 + +"#]]); +} + +#[casttest] +fn abi_encode_event_indexed_tuples(cmd: _) { + // Tuple topics hash member preimages without offsets: keccak256(word(7) ++ pad32("hello")). + cmd.args(["abi-encode-event", "Pair((uint256,string) indexed pair)", "(7,hello)"]) + .assert_success() + .stdout_eq(str![[r#" +[topic0]: 0x9238dd7c0dba6500736bb8e584ccce3ba50e1d827893b0a66469369afa1b1ac8 +[topic1]: 0x2919104b27111a00427dc5719d616be7497d87822aa864c58817a818e17032b4 + +"#]]); + + // Nested strings longer than one word are padded to a multiple of 32 bytes: the 40-byte + // string occupies 64 bytes of the preimage. + cmd.cast_fuse() + .args([ + "abi-encode-event", + "Entries((string,uint256) indexed entry)", + "(abcdefghijklmnopqrstuvwxyz0123456789abcd,1)", + ]) + .assert_success() + .stdout_eq(str![[r#" +[topic0]: 0x55493f7db049561b9d0b263da2632368fcd86567dae4e8b4d8767f6789647dee +[topic1]: 0x5e20f5ecd52f29dc801132b4fba26d512378bf6c068eff0f4c6e797d69ac1e2a + +"#]]); +} + +#[casttest] +fn abi_encode_event_indexed_function(cmd: _) { + cmd.args([ + "abi-encode-event", + "Callback(function indexed callback)", + "0x29088eeb3082c897bebd16bbafc162322cbb1bf47cfdab90", + ]) + .assert_success() + .stdout_eq(str![[r#" +[topic0]: 0xc5656f42bc03a54463abf4aa177d76f9e12a2b4c0307bd71f23e713c47e8ed2d +[topic1]: 0x29088eeb3082c897bebd16bbafc162322cbb1bf47cfdab900000000000000000 + +"#]]); +} + +#[casttest] +fn abi_encode_event_dynamic_tuple(cmd: _) { + cmd.args([ + "abi-encode-event", + "Details((uint256,string) details,uint256 nonce)", + "(7,hello)", + "9", + ]) + .assert_success() + .stdout_eq(str![[r#" +[topic0]: 0xaabc576555b53190a80e9ddc865e2c1a772416783d18d8e5dc1d3f80ccbbbf3e +[data]: 0x0000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000900000000000000000000000000000000000000000000000000000000000000070000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000568656c6c6f000000000000000000000000000000000000000000000000000000 + +"#]]); +} + +#[casttest] +fn abi_encode_event_dynamic_strings(cmd: _) { + let signature = "Strings(string,string)"; + let data = "0x000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000005616c70686100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000046265746100000000000000000000000000000000000000000000000000000000"; + + cmd.args(["abi-encode-event", signature, "alpha", "beta"]) + .assert_success() + .stdout_eq(str![[r#" +[topic0]: 0xb032a34ae8575c904b71dc599fde31122ca66ed21144188304825ec5ab48652a +[data]: 0x000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000005616c70686100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000046265746100000000000000000000000000000000000000000000000000000000 + +"#]]); + + cmd.cast_fuse().args(["decode-event", "--sig", signature, data]).assert_success().stdout_eq( + str![[r#" +"alpha" +"beta" + +"#]], + ); +} + +#[casttest] +fn decode_event_with_indexed_params(cmd: _) { + cmd.args([ + "decode-event", + "--sig", + "Transfer(address indexed from, address indexed to, uint256 value)", + "0x000000000000000000000000000000000000000000000000000000000000002a", + ]) + .assert_success() + .stdout_eq(str![[r#" +42 + +"#]]); +} + +#[casttest] +fn abi_encode_event_argument_count_mismatch(cmd: _) { + cmd.args(["abi-encode-event", "Pair(uint256,uint256)", "1"]).assert_failure().stderr_eq(str![ + [r#" +Error: encode length mismatch: expected 2 types, got 1 + +"#] + ]); + + cmd.cast_fuse() + .args(["abi-encode-event", "Pair(uint256,uint256)", "1", "2", "3"]) + .assert_failure() + .stderr_eq(str![[r#" +Error: encode length mismatch: expected 2 types, got 3 + +"#]]); +} + +#[casttest] +fn abi_encode_event_anonymous(cmd: _) { + cmd.args([ + "abi-encode-event", + "Log(uint256 indexed id,string message) anonymous", + "7", + "hello", + ]) + .assert_success() + .stdout_eq(str![[r#" +[topic0]: 0x0000000000000000000000000000000000000000000000000000000000000007 +[data]: 0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000568656c6c6f000000000000000000000000000000000000000000000000000000 + +"#]]); +} + +#[casttest] +fn abi_decode_output(cmd: _) { + cmd.cast_fuse() + .args([ + "abi-decode", + "f()(uint256)", + "0x0000000000000000000000000000000000000000000000000000000000000001", + ]) + .assert_success() + .stdout_eq("1\n"); + cmd.cast_fuse() + .args([ + "abi-decode", + "balanceOf(address, uint256)(uint256)", + "0x0000000000000000000000000000000000000000000000000000000000000001", + ]) + .assert_success() + .stdout_eq("1\n"); +} + +#[casttest] +fn abi_and_calldata_decode_mixed_values(cmd: _) { + let data = "0000000000000000000000008dbd1b711dc621e1404633da156fcc779e1c6f3e000000000000000000000000d9f3c9cc99548bf3b44a43e0a2d07399eb918adc000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000000"; + let sig = "safeTransferFrom(address, address, uint256, uint256, bytes)"; + let expected = "0x8DbD1b711DC621e1404633da156FcC779e1c6f3E\n0xD9f3c9CC99548bF3b44a43E0A2D07399EB918ADc\n42\n1\n0x\n"; + cmd.cast_fuse() + .args(["abi-decode", "--input", sig, &format!("0x{data}")]) + .assert_success() + .stdout_eq(expected); + cmd.cast_fuse() + .args(["calldata-decode", sig, &format!("0xf242432a{data}")]) + .assert_success() + .stdout_eq(expected); +} + +#[casttest] +fn calldata_decode_nested_json(cmd: _) { + let calldata = "0xdb5b0ed700000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000006772bf190000000000000000000000000000000000000000000000000000000000020716000000000000000000000000af9d27ffe4d51ed54ac8eec78f2785d7e11e5ab100000000000000000000000000000000000000000000000000000000000002c0000000000000000000000000000000000000000000000000000000000000000404366a6dc4b2f348a85e0066e46f0cc206fca6512e0ed7f17ca7afb88e9a4c27000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000093922dee6e380c28a50c008ab167b7800bb24c2026cd1b22f1c6fb884ceed7400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060f85e59ecad6c1a6be343a945abedb7d5b5bfad7817c4d8cc668da7d391faf700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000093dfbf04395fbec1f1aed4ad0f9d3ba880ff58a60485df5d33f8f5e0fb73188600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000aa334a426ea9e21d5f84eb2d4723ca56b92382b9260ab2b6769b7c23d437b6b512322a25cecc954127e60cf91ef056ac1da25f90b73be81c3ff1872fa48d10c7ef1ccb4087bbeedb54b1417a24abbb76f6cd57010a65bb03c7b6602b1eaf0e32c67c54168232d4edc0bfa1b815b2af2a2d0a5c109d675a4f2de684e51df9abb324ab1b19a81bac80f9ce3a45095f3df3a7cf69ef18fc08e94ac3cbc1c7effeacca68e3bfe5d81e26a659b5"; + let sig = + "sequenceBatchesValidium((bytes32,bytes32,uint64,bytes32)[],uint64,uint64,address,bytes)"; + let expected = serde_json::json!([ + [ + [ + "0x04366a6dc4b2f348a85e0066e46f0cc206fca6512e0ed7f17ca7afb88e9a4c27", + "0x0000000000000000000000000000000000000000000000000000000000000000", + 0, + "0x0000000000000000000000000000000000000000000000000000000000000000" + ], + [ + "0x093922dee6e380c28a50c008ab167b7800bb24c2026cd1b22f1c6fb884ceed74", + "0x0000000000000000000000000000000000000000000000000000000000000000", + 0, + "0x0000000000000000000000000000000000000000000000000000000000000000" + ], + [ + "0x60f85e59ecad6c1a6be343a945abedb7d5b5bfad7817c4d8cc668da7d391faf7", + "0x0000000000000000000000000000000000000000000000000000000000000000", + 0, + "0x0000000000000000000000000000000000000000000000000000000000000000" + ], + [ + "0x93dfbf04395fbec1f1aed4ad0f9d3ba880ff58a60485df5d33f8f5e0fb731886", + "0x0000000000000000000000000000000000000000000000000000000000000000", + 0, + "0x0000000000000000000000000000000000000000000000000000000000000000" + ] + ], + 1735573273, + 132886, + "0xAF9d27ffe4d51eD54AC8eEc78f2785D7E11E5ab1", + "0x334a426ea9e21d5f84eb2d4723ca56b92382b9260ab2b6769b7c23d437b6b512322a25cecc954127e60cf91ef056ac1da25f90b73be81c3ff1872fa48d10c7ef1ccb4087bbeedb54b1417a24abbb76f6cd57010a65bb03c7b6602b1eaf0e32c67c54168232d4edc0bfa1b815b2af2a2d0a5c109d675a4f2de684e51df9abb324ab1b19a81bac80f9ce3a45095f3df3a7cf69ef18fc08e94ac3cbc1c7effeacca68e3bfe5d81e26a659b5" + ]); + cmd.args(["calldata-decode", sig, calldata, "--json"]).assert_json_stdout(json!({"schema_version": 1, "success": true, "data": expected, "errors": [], "warnings": []}).to_string()); +} + +#[casttest] +fn abi_encode(cmd: _) { + cmd.cast_fuse() + .args(["abi-encode", "f(uint256)", "1"]) + .assert_success() + .stdout_eq("0x0000000000000000000000000000000000000000000000000000000000000001\n"); +} + +#[casttest] +fn abi_encode_constructor(cmd: _) { + cmd.args(["abi-encode", "constructor(uint a)", "1"]) + .assert_success() + .stdout_eq("0x0000000000000000000000000000000000000000000000000000000000000001\n"); +} + +#[casttest] +fn abi_encode_packed(cmd: _) { + cmd.cast_fuse() + .args(["abi-encode", "--packed", "(uint128[] a, uint64 b)", "[100, 300]", "200"]) + .assert_success() + .stdout_eq("0x0000000000000000000000000000000000000000000000000000000000000064000000000000000000000000000000000000000000000000000000000000012c00000000000000c8\n"); + cmd.cast_fuse() + .args([ + "abi-encode", + "--packed", + "foo(address a, string b)", + "0x8dbd1b711dc621e1404633da156fcc779e1c6f3e", + "hello world", + ]) + .assert_success() + .stdout_eq("0x8dbd1b711dc621e1404633da156fcc779e1c6f3e68656c6c6f20776f726c64\n"); + cmd.cast_fuse() + .args(["abi-encode", "--packed", "f(uint256)", "1"]) + .assert_success() + .stdout_eq("0x0000000000000000000000000000000000000000000000000000000000000001\n"); +} + +#[casttest] +fn calldata_bool(cmd: _) { + cmd.cast_fuse() + .args(["calldata", "bar(bool)", "false"]) + .assert_success() + .stdout_eq("0x6fae94120000000000000000000000000000000000000000000000000000000000000000\n"); +} diff --git a/crates/cast/tests/cli/access_list.rs b/crates/cast/tests/cli/access_list.rs new file mode 100644 index 0000000000000..7ba00f9a54a6e --- /dev/null +++ b/crates/cast/tests/cli/access_list.rs @@ -0,0 +1,140 @@ +//! CLI tests for access list commands. + +use super::*; + +#[casttest] +fn access_list(cmd: _) { + let rpc = next_http_rpc_endpoint(); + cmd.args([ + "access-list", + "0xbb2b8038a1640196fbe3e38816f3e67cba72d940", + "skim(address)", + "0xbb2b8038a1640196fbe3e38816f3e67cba72d940", + "--rpc-url", + rpc.as_str(), + "--gas-limit", // need to set this for alchemy.io to avoid "intrinsic gas too low" error + "100000", + ]) + .assert_success() + .stdout_eq(str![[r#" +[GAS] +access list: +- address: [..] + keys: +... +- address: [..] + keys: +... +- address: [..] + keys: +... + +"#]]); +} + +#[casttest] +fn access_list_eip7702_auth_disclosure_declined(prj: _, cmd: _) { + prj.update_config(|config| config.chain = Some(31337.into())); + + cmd.args([ + "access-list", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "--auth", + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--rpc-url", + "http://127.0.0.1:1", + ]) + .stdin("n\n") + .assert_success() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Warning: This command will send a signed EIP-7702 authorization to the RPC endpoint. The authorization can be submitted on-chain by anyone once its nonce is valid. + +Continue anyway? [y/N] Aborted. + +"#]]); +} + +#[casttest] +fn access_list_eip7702_auth_disclosure_requires_signer(prj: _, cmd: _) { + prj.update_config(|config| config.chain = Some(31337.into())); + + cmd.args([ + "access-list", + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "--auth", + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "--from", + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "--nonce", + "0", + "--rpc-url", + "http://127.0.0.1:1", + ]) + .assert_failure() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Error: No signer available to sign authorization. Provide a pre-signed authorization (hex-encoded) instead. + +"#]]); +} + +#[casttest] +async fn access_list_eip7702_auth_disclosure_accepted_and_forced(cmd: _) { + let (_api, handle) = + anvil::spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Prague.into()))).await; + let endpoint = handle.http_endpoint(); + let args = [ + "access-list", + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "--auth", + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--rpc-url", + &endpoint, + ]; + + cmd.args(args) + .stdin("y\n") + .assert_success() + .stdout_eq(str![[r#" +[GAS] +access list: + +"#]]) + .stderr_eq(str![[r#" +Warning: This command will send a signed EIP-7702 authorization to the RPC endpoint. The authorization can be submitted on-chain by anyone once its nonce is valid. + +Continue anyway? [y/N] "#]]); + + cmd.cast_fuse() + .args(args) + .arg("--force") + .assert_success() + .stdout_eq(str![[r#" +[GAS] +access list: + +"#]]) + .stderr_eq(str![""]); +} + +// Test cast access-list with negative numbers +#[casttest] +fn cast_access_list_negative_numbers(cmd: _) { + let rpc = next_rpc_endpoint(NamedChain::Sepolia); + cmd.args([ + "access-list", + "0x9999999999999999999999999999999999999999", + "adjustPosition(int128)", + "-33333", + "--gas-limit", + "1000000", + "--rpc-url", + rpc.as_str(), + ]) + .assert_success(); +} diff --git a/crates/cast/tests/cli/address.rs b/crates/cast/tests/cli/address.rs new file mode 100644 index 0000000000000..b815aa07b9900 --- /dev/null +++ b/crates/cast/tests/cli/address.rs @@ -0,0 +1,183 @@ +//! CLI tests for address commands. + +use super::*; + +// tests that `cast create2` writes `address\tsalt` to stdout and prose to stderr +#[casttest] +fn create2_output_channels(cmd: _) { + cmd.args([ + "create2", + "--starts-with", + "cc", + "--init-code-hash", + "0x0000000000000000000000000000000000000000000000000000000000000000", + ]) + .assert_success() + .stdout_eq(str![[r#" +0x[..] 0x[..] + +"#]]); +} + +// tests that the machine-readable stdout record is omitted on an interactive terminal, where it +// would duplicate the stderr prose +#[test] +#[cfg(unix)] +fn create2_tty_omits_stdout_record() { + let mut command = Command::new(env!("CARGO_BIN_EXE_cast")); + command.env("NO_COLOR", "1").env("TERM", "dumb").args([ + "create2", + "--starts-with", + "cc", + "--init-code-hash", + "0x0000000000000000000000000000000000000000000000000000000000000000", + ]); + + let mut session = spawn_with_options( + command, + Options { + timeout_ms: Some(30_000), + strip_ansi_escape_codes: true, + encoding: Encoding::UTF8, + }, + ) + .unwrap(); + + session.exp_string("Successfully found contract address").unwrap(); + session.exp_string("Address: 0x").unwrap(); + session.exp_string("Salt: 0x").unwrap(); + // Only the salt value and its decimal representation may follow; the `address\tsalt` + // record must not be printed to a tty. + let rest = session.exp_eof().unwrap(); + assert!( + !rest.contains("0x") && !rest.contains('\t'), + "unexpected stdout record on tty: {rest:?}" + ); +} + +// tests that `cast create2 --salt` writes `address\tsalt` to stdout +#[casttest] +fn create2_fixed_salt_output_channels(cmd: _) { + cmd.args([ + "create2", + "--salt", + "0x0000000000000000000000000000000000000000000000000000000000000001", + "--init-code-hash", + "0x0000000000000000000000000000000000000000000000000000000000000000", + ]) + .assert_success() + .stdout_eq(str![[r#" +0x[..] 0x0000000000000000000000000000000000000000000000000000000000000001 + +"#]]); +} + +#[casttest] +fn create2_init_code_hash(prj: _, cmd: _) { + prj.add_source( + "InitCodeHash", + r#" +contract InitCodeHash { + int256 public immutable value; + address public immutable owner; + + constructor(int256 value_, address owner_) { + value = value_; + owner = owner_; + } +} +"#, + ); + + let owner = Address::with_last_byte(1); + let bytecode = cmd + .forge_fuse() + .args(["inspect", "InitCodeHash", "bytecode"]) + .assert_success() + .get_output() + .stdout_lossy(); + let mut expected_init_code = hex::decode(bytecode.trim()).unwrap(); + expected_init_code.extend((I256::unchecked_from(42), owner).abi_encode()); + let expected = keccak256(expected_init_code); + + cmd.cast_fuse() + .current_dir(prj.root()) + .args([ + "create2", + "init-code-hash", + "src/InitCodeHash.sol:InitCodeHash", + "42", + &owner.to_string(), + ]) + .assert_success() + .stdout_eq(format!("{expected}\n")); + + let mut expected_init_code = hex::decode(bytecode.trim()).unwrap(); + expected_init_code.extend((I256::unchecked_from(-5), owner).abi_encode()); + let expected = keccak256(expected_init_code); + let root = prj.root().to_str().unwrap(); + + cmd.cast_fuse() + .current_dir(prj.root()) + .args([ + "create2", + "init-code-hash", + "src/InitCodeHash.sol:InitCodeHash", + "-5", + &owner.to_string(), + "--root", + root, + ]) + .assert_success() + .stdout_eq(format!("{expected}\n")); + + cmd.cast_fuse() + .current_dir(prj.root()) + .args([ + "--json", + "create2", + "init-code-hash", + "src/InitCodeHash.sol:InitCodeHash", + "-5", + &owner.to_string(), + ]) + .assert_json_stdout(format!( + r#"{{"schema_version":1,"success":true,"data":"{expected}","errors":[],"warnings":[]}}"# + )); +} + +#[casttest] +fn create2_init_code_hash_rejects_abstract_contract(prj: _, cmd: _) { + prj.add_source( + "AbstractInitCodeHash", + r#" +abstract contract AbstractInitCodeHash { + function value() public pure virtual returns (uint256); +} +"#, + ); + + cmd.cast_fuse() + .current_dir(prj.root()) + .args(["create2", "init-code-hash", "src/AbstractInitCodeHash.sol:AbstractInitCodeHash"]) + .assert_failure() + .stderr_eq(str![[r#" +Error: no bytecode found in bin object for AbstractInitCodeHash + +"#]]); +} + +#[casttest] +async fn compute_address(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + cmd.args([ + "compute-address", + "0x0000000000000000000000000000000000000000", + "--nonce", + "0", + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success() + .stdout_eq(format!("{}\n", Address::ZERO.create(0).to_checksum(None))); +} diff --git a/crates/cast/tests/cli/bal.rs b/crates/cast/tests/cli/bal.rs new file mode 100644 index 0000000000000..538e62d3765dd --- /dev/null +++ b/crates/cast/tests/cli/bal.rs @@ -0,0 +1,110 @@ +//! CLI tests for `cast bal`. + +use super::*; +use foundry_test_utils::rpc::spawn_rpc_proxy_canned_method; +use serde_json::{Value, json}; + +/// A block access list covering every change kind, as `eth_getBlockAccessList` returns it. +fn block_access_list() -> Value { + json!([ + { + "address": "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b", + "storageChanges": [ + { + "key": "0x0", + "changes": [{ "index": "0x0", "value": "0x100" }], + }, + ], + "storageReads": ["0x1"], + "balanceChanges": [{ "index": "0x0", "value": "0x56bc75e2d63100000" }], + "nonceChanges": [{ "index": "0x0", "value": "0x1" }], + "codeChanges": [{ "index": "0x0", "code": "0x6001" }], + }, + ]) +} + +/// Serves `result` for both the officially specified `eth_getBlockAccessList` and the +/// `eth_getBlockAccessListByBlockNumber` extension a block tag currently resolves through. +async fn canned_endpoint(upstream: String, result: Value) -> String { + let (endpoint, _) = spawn_rpc_proxy_canned_method( + upstream, + "eth_getBlockAccessListByBlockNumber", + result.clone(), + ) + .await; + let (endpoint, _) = + spawn_rpc_proxy_canned_method(endpoint, "eth_getBlockAccessList", result).await; + endpoint +} + +#[casttest] +async fn bal(cmd: _) { + let (_api, handle) = anvil::spawn(NodeConfig::test()).await; + let endpoint = canned_endpoint(handle.http_endpoint(), block_access_list()).await; + + cmd.args(["bal", "latest", "--rpc-url", &endpoint]).assert_success().stdout_eq(str![[r#" +[ + { + "address": "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b", + "storageChanges": [ + { + "key": "0x0", + "changes": [ + { + "index": "0x0", + "value": "0x100" + } + ] + } + ], + "storageReads": [ + "0x1" + ], + "balanceChanges": [ + { + "index": "0x0", + "value": "0x56bc75e2d63100000" + } + ], + "nonceChanges": [ + { + "index": "0x0", + "value": "0x1" + } + ], + "codeChanges": [ + { + "index": "0x0", + "code": "0x6001" + } + ] + } +] + +"#]]); +} + +#[casttest] +async fn bal_raw(cmd: _) { + let (_api, handle) = anvil::spawn(NodeConfig::test()).await; + let endpoint = canned_endpoint(handle.http_endpoint(), block_access_list()).await; + + cmd.args(["bal", "latest", "--raw", "--rpc-url", &endpoint]).assert_success().stdout_eq(str![ + [r#" +0xf838f794a94f5374fce5edbc8e2a8697c15331677e6ebf0bc8c780c5c480820100c101cccb8089056bc75e2d63100000c3c28001c5c480826001 + +"#] + ]); +} + +#[casttest] +async fn bal_not_found(cmd: _) { + let (_api, handle) = anvil::spawn(NodeConfig::test()).await; + + cmd.args(["bal", "latest", "--rpc-url", &handle.http_endpoint()]).assert_failure().stderr_eq( + str![[r#" +Error: block access list for latest not found + +"#]], + ); +} diff --git a/crates/cast/tests/cli/base.rs b/crates/cast/tests/cli/base.rs new file mode 100644 index 0000000000000..0e5e5a5c039af --- /dev/null +++ b/crates/cast/tests/cli/base.rs @@ -0,0 +1,175 @@ +//! CLI tests for Base execution. + +use super::*; + +#[cfg(feature = "base")] +use alloy_consensus::{TxEip1559, TypedTransaction}; + +#[cfg(feature = "base")] +#[casttest] +async fn cast_call_trace_selects_base_network(prj: _, cmd: _) { + prj.update_config(|config| { + config.networks = foundry_evm_networks::NetworkConfigs::with_base(); + config.hardfork = Some("base:Beryl".parse().unwrap()); + config.chain = Some(foundry_config::Chain::from_id(8453)); + }); + let (_api, handle) = + anvil::spawn(NodeConfig::test_base().with_hardfork(Some("base:Beryl".parse().unwrap()))) + .await; + let rpc = handle.http_endpoint(); + let from = handle.dev_accounts().next().unwrap().to_string(); + + let output = cmd + .args([ + "call", + "0x8453000000000000000000000000000000000001", + "admin()(address)", + "--rpc-url", + &rpc, + "--trace", + "--from", + &from, + "--chain", + "8453", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + // The registered precompile ABI names the call and decodes the return as a typed address, + // which renders checksummed rather than as raw hex. + assert!(output.contains("ActivationRegistry::admin()"), "{output}"); + assert!(output.contains("0xcE3a3bEE7E72E2A24079f3c0Cb3b97740ED425A9"), "{output}"); +} + +#[cfg(feature = "base")] +#[casttest] +fn cast_decode_tx_network_base_short_and_long_equivalent(cmd: _) { + let tx = "0x7ef90207a0cbde10ec697aff886f95d2514bab434e455620627b9bb8ba33baaaa4d537d62794d45955f4de64f1840e5686e64278da901e263031944200000000000000000000000000000000000007872386f26fc10000872386f26fc1000083096c4980b901a4d764ad0b0001000000000000000000000000000000000000000000000000000000065132000000000000000000000000fd0bf71f60660e2f608ed56e1659c450eb1131200000000000000000000000004200000000000000000000000000000000000010000000000000000000000000000000000000000000000000002386f26fc1000000000000000000000000000000000000000000000000000000000000000493e000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000a41635f5fd000000000000000000000000ca11bde05977b3631167028862be2a173976ca110000000000000000000000005703b26fe5a7be820db1bf34c901a79da1a46ba4000000000000000000000000000000000000000000000000002386f26fc100000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"; + + let via_long = cmd + .args(["decode-tx", "--network", "base", tx]) + .assert_success() + .get_output() + .stdout + .clone(); + let via_short = cmd + .cast_fuse() + .args(["decode-tx", "-n", "base", tx]) + .assert_success() + .get_output() + .stdout + .clone(); + + assert_eq!(via_long, via_short, "--network base and -n base should produce same output"); +} + +#[casttest] +fn cast_base_rejects_blob_options(prj: _, cmd: _) { + prj.update_config(|config| { + config.networks = foundry_evm_networks::NetworkConfigs::with_base(); + config.chain = Some(foundry_config::Chain::from_id(8453)); + }); + // The nonexistent path proves rejection precedes reading and discarding the blob data. + for command in ["mktx", "send", "estimate", "access-list"] { + for options in [vec!["--blob"], vec!["--blob", "--eip4844"], vec!["--blob-gas-price", "1"]] + { + cmd.cast_fuse() + .current_dir(prj.root()) + .args([command, "0x0000000000000000000000000000000000000001"]) + .args(options) + .args(["--rpc-url", "http://127.0.0.1:1"]) + .assert_failure() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Error: Base does not support blob transactions; remove --blob, --eip4844, and --blob-gas-price + +"#]]); + } + } + for command in ["mktx", "send"] { + cmd.cast_fuse() + .current_dir(prj.root()) + .args([ + command, + "0x0000000000000000000000000000000000000001", + "--blob", + "--path", + "missing-blob.bin", + "--rpc-url", + "http://127.0.0.1:1", + ]) + .assert_failure() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Error: Base does not support blob transactions; remove --blob, --eip4844, and --blob-gas-price + +"#]]); + } +} + +#[casttest] +async fn cast_base_transaction_roundtrip(prj: _, cmd: _) { + let (_api, handle) = + anvil::spawn(NodeConfig::test_base().with_hardfork(Some("base:Beryl".parse().unwrap()))) + .await; + let provider = handle.http_provider(); + let mut accounts = handle.dev_accounts(); + let from = accounts.next().unwrap(); + let to = accounts.next().unwrap(); + let receipt = provider + .send_transaction(TransactionRequest::default().from(from).to(to).value(U256::ONE).into()) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + let hash = receipt.transaction_hash; + let raw = provider.get_raw_transaction_by_hash(hash).await.unwrap().unwrap(); + cmd.args(["tx", &hash.to_string(), "--raw", "--rpc-url", &handle.http_endpoint()]) + .assert_success() + .stdout_eq(format!("{raw}\n")) + .stderr_eq(str![""]); + + // Base construction must preserve ordinary transaction fields too. + let encoded = cmd + .cast_fuse() + .current_dir(prj.root()) + .args([ + "mktx", + &to.to_string(), + "--chain", + "8453", + "--nonce", + "7", + "--gas-limit", + "21000", + "--gas-price", + "1000000000", + "--priority-gas-price", + "1", + "--value", + "123", + "--raw-unsigned", + "--rpc-url", + "http://127.0.0.1:1", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + let bytes = hex::decode(encoded.trim()).unwrap(); + let tx = TypedTransaction::decode_unsigned(&mut bytes.as_slice()).unwrap(); + assert_eq!( + tx, + TypedTransaction::Eip1559(TxEip1559 { + chain_id: 8453, + nonce: 7, + gas_limit: 21000, + max_fee_per_gas: 1000000000, + max_priority_fee_per_gas: 1, + to: to.into(), + value: U256::from(123), + ..Default::default() + }) + ); +} diff --git a/crates/cast/tests/cli/bytecode.rs b/crates/cast/tests/cli/bytecode.rs new file mode 100644 index 0000000000000..3583251f8bd3d --- /dev/null +++ b/crates/cast/tests/cli/bytecode.rs @@ -0,0 +1,85 @@ +//! CLI tests for bytecode commands. + +use super::*; +use alloy_primitives::KECCAK256_EMPTY; + +// +// tests `cast code --disassemble` +#[casttest] +fn can_disassemble_contract_code(cmd: _) { + let rpc = next_http_rpc_endpoint(); + cmd.args([ + "code", + "--disassemble", + "--rpc-url", + rpc.as_str(), + "0x1F573D6Fb3F13d689FF844B4cE37794d79a7FF1C", + ]) + .assert_success() + .stdout_eq(str![[r#" +00000000: PUSH1 0x60 +00000002: PUSH1 0x40 +00000004: MSTORE +00000005: CALLDATASIZE +00000006: ISZERO +00000007: PUSH2 0x010f +0000000a: JUMPI +0000000b: PUSH4 0xffffffff +00000010: PUSH29 0x0100000000000000000000000000000000000000000000000000000000 +0000002e: PUSH1 0x00 +... +"#]]); +} + +#[casttest] +async fn code_empty(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + cmd.args([ + "code", + "0x0000000000000000000000000000000000000000", + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success() + .stdout_eq("0x\n"); +} + +#[casttest] +async fn codesize_empty(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + cmd.args([ + "codesize", + "0x0000000000000000000000000000000000000000", + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success() + .stdout_eq("0\n"); +} + +#[casttest] +async fn codehash_empty(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + cmd.args([ + "codehash", + "0x0000000000000000000000000000000000000000", + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success() + .stdout_eq(format!("{KECCAK256_EMPTY}\n")); +} + +#[casttest] +fn disassemble_incomplete_sequence(cmd: _) { + cmd.cast_fuse().args(["disassemble", "60"]).assert_success().stdout_eq("00000000: PUSH1\n\n"); + cmd.cast_fuse() + .args(["disassemble", "6000"]) + .assert_success() + .stdout_eq("00000000: PUSH1 0x00\n\n"); + cmd.cast_fuse() + .args(["disassemble", "7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"]) + .assert_success() + .stdout_eq("00000000: PUSH32\n\n"); + cmd.cast_fuse().args(["disassemble", "7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"]).assert_success().stdout_eq("00000000: PUSH32 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\n\n"); +} diff --git a/crates/cast/tests/cli/call.rs b/crates/cast/tests/cli/call.rs new file mode 100644 index 0000000000000..aa081fd1b3b6b --- /dev/null +++ b/crates/cast/tests/cli/call.rs @@ -0,0 +1,1310 @@ +//! CLI tests for call commands. + +use super::*; +use alloy_primitives::bytes; + +#[forgetest_init] +async fn cast_call_custom_override(prj: _, cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + + prj.initialize_default_contracts(); + prj.add_source( + "Counter", + r#" +contract Counter { + uint256 public number; + + function getBalance(address target) public returns (uint256) { + return target.balance; + } +} + "#, + ); + + // Deploy counter contract. + cmd.args([ + "script", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--rpc-url", + &handle.http_endpoint(), + "--broadcast", + "CounterScript", + ]) + .assert_success(); + + // Override state, `number()` should return overridden value. + cmd.cast_fuse() + .args([ + "call", + "0x5FbDB2315678afecb367f032d93F642f64180aa3", + "--rpc-url", + &handle.http_endpoint(), + "--override-state", + "0x5FbDB2315678afecb367f032d93F642f64180aa3:0x0:0x1234", + "number()(uint256)", + ]) + .assert_success() + .stdout_eq(str![[r#" +4660 + +"#]]); + + // Override state, `number()` should return overridden value. + cmd.cast_fuse() + .args([ + "call", + "0x5FbDB2315678afecb367f032d93F642f64180aa3", + "--rpc-url", + &handle.http_endpoint(), + "--override-state", + "0x5FbDB2315678afecb367f032d93F642f64180aa3:0x0:0x1234", + "number()(uint256)", + "--trace", + ]) + .assert_success() + .stdout_eq(str![[r#" +Traces: + [2402] 0x5FbDB2315678afecb367f032d93F642f64180aa3::number() + └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000001234 + + +Transaction successfully executed. +[GAS] + +"#]]); + + // Override balance, `getBalance()` should return overridden value. + cmd.cast_fuse() + .args([ + "call", + "0x5FbDB2315678afecb367f032d93F642f64180aa3", + "--rpc-url", + &handle.http_endpoint(), + "--override-balance", + "0x5FbDB2315678afecb367f032d93F642f64180aa3:0x1111", + "getBalance(address)(uint256)", + "0x5FbDB2315678afecb367f032d93F642f64180aa3", + ]) + .assert_success() + .stdout_eq(str![[r#" +4369 + +"#]]); + + // Override balance, `getBalance()` should return overridden value. + cmd.cast_fuse() + .args([ + "call", + "0x5FbDB2315678afecb367f032d93F642f64180aa3", + "--rpc-url", + &handle.http_endpoint(), + "--override-balance", + "0x5FbDB2315678afecb367f032d93F642f64180aa3:0x1111", + "getBalance(address)(uint256)", + "0x5FbDB2315678afecb367f032d93F642f64180aa3", + "--trace", + ]) + .assert_success() + .stdout_eq(str![[r#" +Traces: + [747] 0x5FbDB2315678afecb367f032d93F642f64180aa3::getBalance(0x5FbDB2315678afecb367f032d93F642f64180aa3) + └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000001111 + + +Transaction successfully executed. +[GAS] + +"#]]); + + // Override code with + // contract Counter { + // uint256 public number1; + // } + // Calling `number()` should fail. + cmd.cast_fuse() + .args([ + "call", + "0x5FbDB2315678afecb367f032d93F642f64180aa3", + "--rpc-url", + &handle.http_endpoint(), + "--override-code", + "0x5FbDB2315678afecb367f032d93F642f64180aa3:0x6080604052348015600e575f5ffd5b50600436106026575f3560e01c8063c223a39e14602a575b5f5ffd5b60306044565b604051603b9190605f565b60405180910390f35b5f5481565b5f819050919050565b6059816049565b82525050565b5f60208201905060705f8301846052565b9291505056fea26469706673582212202a0acfb9083efed3e0e9f27177b090731d4392cf196d58e27e05088f59008d0964736f6c634300081d0033", + "number()(uint256)", + ]) + .assert_failure() + .stderr_eq(str![[r#" +Error: server returned an error response: error code 3: execution reverted, data: "0x" + +"#]]); + + // Override code with + // contract Counter { + // uint256 public number1; + // } + // Calling `number()` should revert. + cmd.cast_fuse() + .args([ + "call", + "0x5FbDB2315678afecb367f032d93F642f64180aa3", + "--rpc-url", + &handle.http_endpoint(), + "--override-code", + "0x5FbDB2315678afecb367f032d93F642f64180aa3:0x6080604052348015600e575f5ffd5b50600436106026575f3560e01c8063c223a39e14602a575b5f5ffd5b60306044565b604051603b9190605f565b60405180910390f35b5f5481565b5f819050919050565b6059816049565b82525050565b5f60208201905060705f8301846052565b9291505056fea26469706673582212202a0acfb9083efed3e0e9f27177b090731d4392cf196d58e27e05088f59008d0964736f6c634300081d0033", + "number()(uint256)", + "--trace" + ]) + .assert_success() + .stderr_eq(str![[r#" +Error: Transaction failed. + +"#]]); + + // Calling `number1()` with overridden state should return new value. + cmd.cast_fuse() + .args([ + "call", + "0x5FbDB2315678afecb367f032d93F642f64180aa3", + "--rpc-url", + &handle.http_endpoint(), + "--override-code", + "0x5FbDB2315678afecb367f032d93F642f64180aa3:0x6080604052348015600e575f5ffd5b50600436106026575f3560e01c8063c223a39e14602a575b5f5ffd5b60306044565b604051603b9190605f565b60405180910390f35b5f5481565b5f819050919050565b6059816049565b82525050565b5f60208201905060705f8301846052565b9291505056fea26469706673582212202a0acfb9083efed3e0e9f27177b090731d4392cf196d58e27e05088f59008d0964736f6c634300081d0033", + "--override-state", + "0x5FbDB2315678afecb367f032d93F642f64180aa3:0x0:0x2222", + "number1()(uint256)", + ]) + .assert_success() + .stdout_eq(str![[r#" +8738 + +"#]]); + + // Calling `number1()` with overridden state should return new value. + cmd.cast_fuse() + .args([ + "call", + "0x5FbDB2315678afecb367f032d93F642f64180aa3", + "--rpc-url", + &handle.http_endpoint(), + "--override-code", + "0x5FbDB2315678afecb367f032d93F642f64180aa3:0x6080604052348015600e575f5ffd5b50600436106026575f3560e01c8063c223a39e14602a575b5f5ffd5b60306044565b604051603b9190605f565b60405180910390f35b5f5481565b5f819050919050565b6059816049565b82525050565b5f60208201905060705f8301846052565b9291505056fea26469706673582212202a0acfb9083efed3e0e9f27177b090731d4392cf196d58e27e05088f59008d0964736f6c634300081d0033", + "--override-state", + "0x5FbDB2315678afecb367f032d93F642f64180aa3:0x0:0x2222", + "number1()(uint256)", + "--trace" + ]) + .assert_success() + .stdout_eq(str![[r#" +Traces: + [2402] 0x5FbDB2315678afecb367f032d93F642f64180aa3::number1() + └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000002222 + + +Transaction successfully executed. +[GAS] + +"#]]); + + // Calling `number1()` with overridden state should return new value. + cmd.cast_fuse() + .args([ + "call", + "0x5FbDB2315678afecb367f032d93F642f64180aa3", + "--rpc-url", + &handle.http_endpoint(), + "--override-code", + "0x5FbDB2315678afecb367f032d93F642f64180aa3:0x6080604052348015600e575f5ffd5b50600436106026575f3560e01c8063c223a39e14602a575b5f5ffd5b60306044565b604051603b9190605f565b60405180910390f35b5f5481565b5f819050919050565b6059816049565b82525050565b5f60208201905060705f8301846052565b9291505056fea26469706673582212202a0acfb9083efed3e0e9f27177b090731d4392cf196d58e27e05088f59008d0964736f6c634300081d0033", + "--override-state-diff", + "0x5FbDB2315678afecb367f032d93F642f64180aa3:0x0:0x2222", + "number1()(uint256)", + ]) + .assert_success() + .stdout_eq(str![[r#" +8738 + +"#]]); + + // Calling `number1()` with overridden state should return new value. + cmd.cast_fuse() + .args([ + "call", + "0x5FbDB2315678afecb367f032d93F642f64180aa3", + "--rpc-url", + &handle.http_endpoint(), + "--override-code", + "0x5FbDB2315678afecb367f032d93F642f64180aa3:0x6080604052348015600e575f5ffd5b50600436106026575f3560e01c8063c223a39e14602a575b5f5ffd5b60306044565b604051603b9190605f565b60405180910390f35b5f5481565b5f819050919050565b6059816049565b82525050565b5f60208201905060705f8301846052565b9291505056fea26469706673582212202a0acfb9083efed3e0e9f27177b090731d4392cf196d58e27e05088f59008d0964736f6c634300081d0033", + "--override-state-diff", + "0x5FbDB2315678afecb367f032d93F642f64180aa3:0x0:0x2222", + "number1()(uint256)", + "--trace", + ]) + .assert_success() + .stdout_eq(str![[r#" +Traces: + [2402] 0x5FbDB2315678afecb367f032d93F642f64180aa3::number1() + └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000002222 + + +Transaction successfully executed. +[GAS] + +"#]]); +} + +#[casttest] +fn correct_json_serialization(cmd: _) { + let rpc = next_http_archive_rpc_url(); + // cast calldata "decimals()" + let calldata = "0x313ce567"; + let tokens = [ + "0xdac17f958d2ee523a2206206994597c13d831ec7", // USDT + "0x6b175474e89094c44da98b954eedeac495271d0f", // DAI + "0x6b175474e89094c44da98b954eedeac495271d0f", // WETH + ]; + let calldata_args = format!( + "[{}]", + tokens + .iter() + .map(|token| format!("({token},false,{calldata})")) + .collect::>() + .join(",") + ); + let args = vec![ + "call", + "--json", + "--rpc-url", + rpc.as_str(), + "0xcA11bde05977b3631167028862bE2a173976CA11", + "aggregate3((address,bool,bytes)[])((bool,bytes)[])", + &calldata_args, + ]; + let expected_output = json!([[ + [true, "0x0000000000000000000000000000000000000000000000000000000000000006"], + [true, "0x0000000000000000000000000000000000000000000000000000000000000012"], + [true, "0x0000000000000000000000000000000000000000000000000000000000000012"] + ]]); + let output: serde_json::Value = + serde_json::from_slice(&cmd.args(args).assert_success().get_output().stdout) + .expect("not valid json"); + assert_eq!(output, expected_output); +} + +#[casttest] +fn call_eip7702_auth_disclosure_declined(cmd: _) { + cmd.args([ + "call", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "--auth", + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--chain", + "31337", + "--rpc-url", + "http://127.0.0.1:1", + ]) + .stdin("n\n") + .assert_success() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Warning: This command will send a signed EIP-7702 authorization to the RPC endpoint. The authorization can be submitted on-chain by anyone once its nonce is valid. + +Continue anyway? [y/N] Aborted. + +"#]]); +} + +// Address-based authorizations are signed by the configured signer even when `--from` is set. +#[casttest] +fn call_eip7702_address_auth_with_from_uses_signer(cmd: _) { + cmd.args([ + "call", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "--auth", + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--from", + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "--chain", + "31337", + "--rpc-url", + "http://127.0.0.1:1", + ]) + .stdin("n\n") + .assert_success() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Warning: This command will send a signed EIP-7702 authorization to the RPC endpoint. The authorization can be submitted on-chain by anyone once its nonce is valid. + +Continue anyway? [y/N] Aborted. + +"#]]); +} + +#[casttest] +fn call_eip7702_auth_disclosure_requires_signer(cmd: _) { + cmd.args([ + "call", + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "--auth", + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "--from", + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "--nonce", + "0", + "--chain", + "31337", + "--rpc-url", + "http://127.0.0.1:1", + ]) + .assert_failure() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Error: No signer available to sign authorization. Provide a pre-signed authorization (hex-encoded) instead. + +"#]]); +} + +#[casttest] +async fn call_eip7702_auth_disclosure_accepted_and_forced(cmd: _) { + let (api, handle) = + anvil::spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Prague.into()))).await; + let endpoint = handle.http_endpoint(); + let delegate_code = bytes!("0x602a5f5260205ff3"); + api.anvil_set_code(address!("0x70997970C51812dc3A010C7d01b50e0d17dc79C8"), delegate_code) + .await + .unwrap(); + api.anvil_set_code( + address!("0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"), + bytes!("0x602a5f5260205ff3"), + ) + .await + .unwrap(); + let args = [ + "call", + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "--auth", + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--rpc-url", + &endpoint, + ]; + + cmd.args(args) + .arg("--json") + .stdin("y\n") + .assert_success() + .stdout_eq(str![[r#" +0x000000000000000000000000000000000000000000000000000000000000002a + +"#]]) + .stderr_eq(str![[r#" +Warning: This command will send a signed EIP-7702 authorization to the RPC endpoint. The authorization can be submitted on-chain by anyone once its nonce is valid. + +Continue anyway? [y/N] "#]]); + + cmd.cast_fuse() + .args(args) + .arg("--force") + .assert_success() + .stdout_eq(str![[r#" +0x000000000000000000000000000000000000000000000000000000000000002a + +"#]]) + .stderr_eq(str![""]); + + cmd.cast_fuse() + .args(args) + .args(["--quiet", "--force"]) + .assert_success() + .stdout_eq(str![[r#" +0x000000000000000000000000000000000000000000000000000000000000002a + +"#]]) + .stderr_eq(str![""]); + + let signer: PrivateKeySigner = + "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d".parse().unwrap(); + let auth = Authorization { + chain_id: U256::from(31337), + address: address!("0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"), + nonce: 0, + }; + let signature = signer.sign_hash(&auth.signature_hash()).await.unwrap(); + let encoded_auth = hex::encode_prefixed(alloy_rlp::encode(auth.into_signed(signature))); + + cmd.cast_fuse() + .args([ + "call", + &signer.address().to_string(), + "--auth", + &encoded_auth, + "--from", + &signer.address().to_string(), + "--rpc-url", + &endpoint, + ]) + .stdin("y\n") + .assert_success() + .stdout_eq(str![[r#" +0x000000000000000000000000000000000000000000000000000000000000002a + +"#]]) + .stderr_eq(str![[r#" +Warning: This command will send a signed EIP-7702 authorization to the RPC endpoint. The authorization can be submitted on-chain by anyone once its nonce is valid. + +Continue anyway? [y/N] "#]]); + + cmd.cast_fuse().args(args).arg("--trace").assert_success().stderr_eq(str![""]); + cmd.cast_fuse() + .args(args) + .args(["--trace", "--access-list", "[]"]) + .assert_success() + .stderr_eq(str![""]); + + // With a pre-signed authorization, `ETH_FROM` is the sender and `ETH_KEYSTORE` is not used. + let keystore = format!( + "{}/tests/fixtures/keystore/UTC--2022-10-30T06-51-20.130356000Z--560d246fcddc9ea98a8b032c9a2f474efb493c28", + env!("CARGO_MANIFEST_DIR") + ); + cmd.cast_fuse().env("ETH_KEYSTORE", keystore); + cmd.env("ETH_FROM", signer.address().to_string()); + cmd.args([ + "call", + &signer.address().to_string(), + "--auth", + &encoded_auth, + "--force", + "--rpc-url", + &endpoint, + ]) + .assert_success() + .stdout_eq(str![[r#" +0x000000000000000000000000000000000000000000000000000000000000002a + +"#]]) + .stderr_eq(str![""]); +} + +#[casttest] +fn call_eip7702_auth_disclosure_routing(cmd: _) { + let base_args = [ + "call", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "--auth", + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--chain", + "31337", + "--rpc-url", + "http://127.0.0.1:1", + ]; + + cmd.args(base_args) + .arg("--debug-trace-call") + .stdin("n\n") + .assert_success() + .stderr_eq(str![[r#" +Warning: This command will send a signed EIP-7702 authorization to the RPC endpoint. The authorization can be submitted on-chain by anyone once its nonce is valid. + +Continue anyway? [y/N] Aborted. + +"#]]); + + cmd.cast_fuse() + .args(base_args) + .args(["--trace", "--access-list"]) + .stdin("n\n") + .assert_success() + .stderr_eq(str![[r#" +Warning: This command will send a signed EIP-7702 authorization to the RPC endpoint. The authorization can be submitted on-chain by anyone once its nonce is valid. + +Continue anyway? [y/N] Aborted. + +"#]]); + + cmd.cast_fuse() + .args(base_args) + .arg("--quiet") + .assert_failure() + .stderr_eq(str![[r#" +Error: EIP-7702 authorization disclosure requires confirmation; pass `--force` to continue with `--quiet` + +"#]]); + + cmd.cast_fuse().args(base_args).arg("--curl").assert_failure().stdout_eq(str![""]).stderr_eq( + str![[r#" +Error: --auth cannot be combined with --curl + +"#]], + ); +} + +// https://github.com/foundry-rs/foundry/issues/11521 +// `--delegate` must run the destination's code against the sender's storage. The destination's +// runtime returns slot 0, and only the sender holds a value there, so a plain call returns zero +// and the delegated call returns the sender's value. +#[casttest] +async fn cast_call_delegate_uses_sender_storage(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + let from = "0x00000000000000000000000000000000000000d0"; + let to = "0x00000000000000000000000000000000000000d1"; + + cmd.cast_fuse() + .args([ + "call", + to, + "number()(uint256)", + "--from", + from, + "--delegate", + // runtime: PUSH1 0 SLOAD PUSH1 0 MSTORE PUSH1 0x20 PUSH1 0 RETURN + "--override-code", + &format!("{to}:0x60005460005260206000f3"), + "--override-state", + &format!("{from}:0x0:0x1234"), + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success() + .stdout_eq(str![[r#" +4660 + +"#]]); + + // Without `--delegate` the same call runs against the destination's own empty storage. + cmd.cast_fuse() + .args([ + "call", + to, + "number()(uint256)", + "--from", + from, + "--override-code", + &format!("{to}:0x60005460005260206000f3"), + "--override-state", + &format!("{from}:0x0:0x1234"), + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success() + .stdout_eq(str![[r#" +0 + +"#]]); +} + +// A code override on the sender is what `--delegate` installs, so the two cannot both win. +#[casttest] +async fn cast_call_delegate_rejects_sender_code_override(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + let from = "0x00000000000000000000000000000000000000d2"; + let to = "0x00000000000000000000000000000000000000d3"; + + cmd.cast_fuse() + .args([ + "call", + to, + "--from", + from, + "--delegate", + "--override-code", + &format!("{to}:0x60005460005260206000f3,{from}:0x00"), + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_failure() + .stderr_eq(str![[r#" +Error: `--delegate` conflicts with `--override-code` for the sender 0x00000000000000000000000000000000000000D2 + +"#]]); +} + +// The primary `--delegate` path reads the destination's runtime code from the node: no override +// flags are involved, the delegated code and the sender's storage both come from chain state. +#[casttest] +async fn cast_call_delegate_fetches_code_from_node(cmd: _) { + let (api, handle) = anvil::spawn(NodeConfig::test()).await; + let from = "0x00000000000000000000000000000000000000d4"; + let to = "0x00000000000000000000000000000000000000d5"; + + // runtime: PUSH1 0 SLOAD PUSH1 0 MSTORE PUSH1 0x20 PUSH1 0 RETURN + api.anvil_set_code(to.parse().unwrap(), bytes!("0x60005460005260206000f3")).await.unwrap(); + api.anvil_set_storage_at(from.parse().unwrap(), U256::ZERO, B256::from(U256::from(0x1234))) + .await + .unwrap(); + + cmd.cast_fuse() + .args([ + "call", + to, + "number()(uint256)", + "--from", + from, + "--delegate", + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success() + .stdout_eq(str![[r#" +4660 + +"#]]); + + // A delegated call that returns no data must not trip the empty-code warning: the sender has + // no on-chain code, but the delegate override guarantees executable code. + let void = "0x00000000000000000000000000000000000000d6"; + // runtime: STOP + api.anvil_set_code(void.parse().unwrap(), bytes!("0x00")).await.unwrap(); + cmd.cast_fuse() + .args(["call", void, "--from", from, "--delegate", "--rpc-url", &handle.http_endpoint()]) + .assert_success() + .stdout_eq(str![[r#" +0x + +"#]]) + .stderr_eq(str![""]); +} + +// Documents the identity semantics: the delegated code observes the sender itself as +// `msg.sender`, not the delegating contract's caller as an on-chain `delegatecall` would. +#[casttest] +async fn cast_call_delegate_msg_sender_is_from(cmd: _) { + let (api, handle) = anvil::spawn(NodeConfig::test()).await; + let from = "0x00000000000000000000000000000000000000d9"; + let to = "0x00000000000000000000000000000000000000da"; + + // runtime: CALLER PUSH1 0 MSTORE PUSH1 0x20 PUSH1 0 RETURN + api.anvil_set_code(to.parse().unwrap(), bytes!("0x3360005260206000f3")).await.unwrap(); + + cmd.cast_fuse() + .args([ + "call", + to, + "sender()(address)", + "--from", + from, + "--delegate", + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success() + .stdout_eq(str![[r#" +0x00000000000000000000000000000000000000D9 + +"#]]); +} + +// https://github.com/foundry-rs/foundry/issues/17388 +// `--from` is the sender even when a keystore is configured. +#[casttest] +async fn cast_call_from_skips_keystore_unlock(cmd: _) { + let (api, handle) = anvil::spawn(NodeConfig::test()).await; + let from = "0x00000000000000000000000000000000000000d9"; + let to = "0x00000000000000000000000000000000000000da"; + let keystore = format!( + "{}/tests/fixtures/keystore/UTC--2022-10-30T06-51-20.130356000Z--560d246fcddc9ea98a8b032c9a2f474efb493c28", + env!("CARGO_MANIFEST_DIR") + ); + + // runtime: CALLER PUSH1 0 MSTORE PUSH1 0x20 PUSH1 0 RETURN + api.anvil_set_code(to.parse().unwrap(), bytes!("0x3360005260206000f3")).await.unwrap(); + + cmd.cast_fuse().env("ETH_KEYSTORE", keystore); + cmd.args([ + "call", + to, + "sender()(address)", + "--from", + from, + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success() + .stdout_eq(str![[r#" +0x00000000000000000000000000000000000000D9 + +"#]]); +} + +// A Tempo access key keeps its root account as the sender even when `--from` is set. +#[casttest] +async fn cast_call_tempo_access_key_root_account_overrides_from(cmd: _) { + let (api, handle) = anvil::spawn(NodeConfig::test()).await; + let to = "0x00000000000000000000000000000000000000da"; + + // runtime: CALLER PUSH1 0 MSTORE PUSH1 0x20 PUSH1 0 RETURN + api.anvil_set_code(to.parse().unwrap(), bytes!("0x3360005260206000f3")).await.unwrap(); + + cmd.cast_fuse() + .args([ + "call", + to, + "sender()(address)", + "--tempo.access-key", + "0x59c6995e998f97a5a004497e5da3b5d2b2b66a87f064d39c44da0b6d6e4f8ff0", + "--tempo.root-account", + "0x00000000000000000000000000000000000000d8", + "--from", + "0x00000000000000000000000000000000000000d9", + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success() + .stdout_eq(str![[r#" +0x00000000000000000000000000000000000000d8 + +"#]]); +} + +// `--delegate` needs runtime code at the destination; a codeless address is an explicit error. +#[casttest] +async fn cast_call_delegate_no_code_destination(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + + cmd.cast_fuse() + .args([ + "call", + "0x00000000000000000000000000000000000000db", + "number()(uint256)", + "--from", + "0x00000000000000000000000000000000000000dc", + "--delegate", + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_failure() + .stderr_eq(str![[r#" +Error: `--delegate` destination 0x00000000000000000000000000000000000000db has no code to delegate to + +"#]]); +} + +// `--curl` builds the request offline, but the delegate override needs the destination's code +// from the node. +#[casttest] +fn cast_call_delegate_rejects_curl(cmd: _) { + cmd.args([ + "call", + "0x00000000000000000000000000000000000000dd", + "--from", + "0x00000000000000000000000000000000000000de", + "--delegate", + "--curl", + ]) + .assert_failure() + .stderr_eq(str![[r#" +Error: --delegate cannot be combined with --curl + +"#]]); +} + +// `dirs::home_dir()` ignores `HOME` on Windows, so the signature cache cannot be isolated there. +#[cfg(not(windows))] +#[casttest] +async fn cast_call_decodes_custom_error(prj: _, cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + + let signature = "RequestLimitExceeded(uint256,uint256)"; + let selector = keccak256(signature); + let mut revert_data = selector[..4].to_vec(); + revert_data.extend((U256::from(5), U256::from(3)).abi_encode()); + + // Runtime bytecode that copies the appended revert payload into memory and reverts with it. + let payload_len = u8::try_from(revert_data.len()).unwrap(); + let mut runtime = + vec![0x60, payload_len, 0x60, 0x0a, 0x5f, 0x39, 0x60, payload_len, 0x5f, 0xfd]; + runtime.extend(revert_data); + + // Isolate and seed the signature cache so decoding is deterministic and offline. + let home = prj.root().join("home"); + let cache_dir = home.join(".foundry/cache"); + fs::create_dir_all(&cache_dir).unwrap(); + let selector = hex::encode_prefixed(&selector[..4]); + let mut errors = serde_json::Map::new(); + errors.insert(selector, json!(signature)); + fs::write( + cache_dir.join("signatures"), + serde_json::to_vec(&json!({ + "functions": {}, + "errors": errors, + "events": {}, + })) + .unwrap(), + ) + .unwrap(); + + let target = "0x000000000000000000000000000000000000dead"; + let code_override = format!("{target}:{}", hex::encode_prefixed(runtime)); + let endpoint = handle.http_endpoint(); + + cmd.env("HOME", &home); + cmd.env("FOUNDRY_OFFLINE", "true"); + cmd.args([ + "call", + target, + "--data", + "0x", + "--override-code", + &code_override, + "--rpc-url", + &endpoint, + ]) + .assert_failure() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Error: execution reverted: RequestLimitExceeded(5, 3) + +Context: +- server returned an error response:[..] + +"#]]); + + cmd.cast_fuse(); + cmd.env("HOME", &home); + cmd.env("FOUNDRY_OFFLINE", "true"); + cmd.args([ + "call", + target, + "--data", + "0x", + "--override-code", + &code_override, + "--rpc-url", + &endpoint, + "--json", + ]) + .assert_failure() + .stdout_eq(str![[r#" +{"schema_version":1,"success":false,"data":null,"errors":[{"level":"error","code":"cast.error","message":"execution reverted: RequestLimitExceeded(5, 3)"},{"level":"error","code":"cast.error.context","message":"server returned an error response:[..]"}],"warnings":[]} + +"#]]) + .stderr_eq(str![""]); +} + +// +#[casttest] +fn cast_call_return_array_of_tuples(cmd: _) { + cmd.args([ + "call", + "0x198FC70Dfe05E755C81e54bd67Bff3F729344B9b", + "facets() returns ((address,bytes4[])[])", + "--rpc-url", + "https://rpc.viction.xyz", + ]) + .assert_success() + .stdout_eq(str![[r#" +[[..]] + +"#]]); +} + +// +#[casttest] +fn cast_call_on_contract_with_no_code_prints_warning(cmd: _) { + let eth_rpc_url = next_http_rpc_endpoint(); + cmd.args([ + "call", + "0x0000000000000000000000000000000000000000", + "--rpc-url", + eth_rpc_url.as_str(), + ]) + .assert_success() + .stderr_eq(str![[r#" +Warning: Contract code is empty + +"#]]) + .stdout_eq(str![[r#" +0x + +"#]]); +} + +// tests that cast call properly applies state diff override +// +#[casttest] +fn cast_call_can_override_state_diff(cmd: _) { + let rpc = next_rpc_endpoint(NamedChain::Sepolia); + cmd.args([ + "call", + "--rpc-url", + rpc.as_str(), + "--data", + "0x", + "0x1EA77b250eF79e917A5A637D5BB82D0980653F1B", + "--override-state-diff", + "0x1EA77b250eF79e917A5A637D5BB82D0980653F1B:1:1", + ]) + .assert_success() + .stdout_eq(str![[r#" +0x1337 + +"#]]); + cmd.args(["--trace"]).assert_success().stdout_eq(str![[r#" +Traces: + [7681] 0x1EA77b250eF79e917A5A637D5BB82D0980653F1B::fallback() + ├─ [2275] 0xe537cb8a46Bd179c0C36aB7E3Fdecd759C8B80fc::fallback() [delegatecall] + │ └─ ← [Return] 0x1337 + └─ ← [Return] 0x1337 + + +Transaction successfully executed. +[GAS] + +"#]]); +} + +// Test that cast call accepts negative numbers as function arguments +#[casttest] +fn cast_call_negative_numbers(cmd: _) { + let rpc = next_rpc_endpoint(NamedChain::Sepolia); + // Test with negative int parameter - should not treat -456789 as a flag + cmd.args([ + "call", + "0xAbCdEf1234567890aBcDeF1234567890aBcDeF12", + "processValue(int128)", + "-456789", + "--rpc-url", + rpc.as_str(), + ]) + .assert_success(); +} + +// Test negative numbers with multiple parameters +#[casttest] +fn cast_call_multiple_negative_numbers(cmd: _) { + let rpc = next_rpc_endpoint(NamedChain::Sepolia); + cmd.args([ + "call", + "--rpc-url", + rpc.as_str(), + "0xDeaDBeeFcAfEbAbEfAcEfEeDcBaDbEeFcAfEbAbE", + "calculateDelta(int64,int32,uint16)", + "-987654321", + "-42", + "65535", + ]) + .assert_success(); +} + +// Test negative numbers mixed with flags +#[casttest] +fn cast_call_negative_with_flags(cmd: _) { + let rpc = next_rpc_endpoint(NamedChain::Sepolia); + cmd.args([ + "call", + "--trace", // flag before + "0x9876543210FeDcBa9876543210FeDcBa98765432", + "updateBalance(int256)", + "-777888", + "--rpc-url", + rpc.as_str(), // flag after + ]) + .assert_success(); +} + +// Test that actual invalid flags are still caught +#[casttest] +fn cast_call_invalid_flag_still_caught(cmd: _) { + cmd.args([ + "call", + "--invalid-flag", // This should be caught as invalid + "0x5555555555555555555555555555555555555555", + ]) + .assert_failure() + .stderr_eq(str![[r#" +error: unexpected argument '--invalid-flag' found + + tip: to pass '--invalid-flag' as a value, use '-- --invalid-flag' + +Usage: cast[..] call [OPTIONS] [TO] [SIG] [ARGS]... [COMMAND] + +For more information, try '--help'. + +"#]]); +} + +// tests that cast call properly applies multiple state diff overrides +// +#[casttest] +fn cast_call_can_override_several_state_diff(cmd: _) { + let rpc = next_http_archive_rpc_url(); + cmd.args([ + "call", + "--trace", + "--from", + "0xf6F444fD3B0088c1375671c05A7513661beFa4e6", + "0x5EA1d9A6dDC3A0329378a327746D71A2019eC332", + "--rpc-url", + rpc.as_str(), + "--block", + "23290753", + "--data", + "0xe75235b8", + "--override-state-diff", + "0x5EA1d9A6dDC3A0329378a327746D71A2019eC332:0xf0af0268363540b847b4c07f2f9a0401c607c1b11ebca511724a71755dfd4137:1,0x5EA1d9A6dDC3A0329378a327746D71A2019eC332:4:1,0x5EA1d9A6dDC3A0329378a327746D71A2019eC332:0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8:0,0x5EA1d9A6dDC3A0329378a327746D71A2019eC332:0xb104e0b93118902c651344349b610029d694cfdec91c589c91ebafbcd0289947:0", + ]) + .assert_success() + .stdout_eq(str![[r#" +... + [..] 0x5EA1d9A6dDC3A0329378a327746D71A2019eC332::getThreshold() +... + +"#]]); + + cmd.cast_fuse().args([ + "call", + "--trace", + "--from", + "0x2066901073a33ba2500274704aB04763875cF210", + "0x5EA1d9A6dDC3A0329378a327746D71A2019eC332", + "--rpc-url", + rpc.as_str(), + "--block", + "23290753", + "--data", + "0x2f54bf6e0000000000000000000000002066901073a33ba2500274704ab04763875cf210", + "--override-state-diff", + "0x5EA1d9A6dDC3A0329378a327746D71A2019eC332:0xf0af0268363540b847b4c07f2f9a0401c607c1b11ebca511724a71755dfd4137:1,0x5EA1d9A6dDC3A0329378a327746D71A2019eC332:4:1,0x5EA1d9A6dDC3A0329378a327746D71A2019eC332:0x4a204f620c8c5ccdca3fd54d003badd85ba500436a431f0cbda4f558c93c34c8:0,0x5EA1d9A6dDC3A0329378a327746D71A2019eC332:0xb104e0b93118902c651344349b610029d694cfdec91c589c91ebafbcd0289947:0", + ]) + .assert_success() + .stdout_eq(str![[r#" +... + [..] 0x5EA1d9A6dDC3A0329378a327746D71A2019eC332::isOwner(0x2066901073a33ba2500274704aB04763875cF210) +... +"#]]); +} + +// tests that the --jwt-secret flag outputs a valid curl command with Authorization header +#[casttest] +fn curl_call_with_jwt(cmd: _) { + let rpc = "https://eth.example.com"; + let jwt_secret = "cabee703106087906e50f3e75a6ddbab60809f980511d1d1548d449d52220795"; + let to = "0xdead000000000000000000000000000000000000"; + + let output = cmd + .args([ + "call", + to, + "balanceOf(address)(uint256)", + to, + "--rpc-url", + rpc, + "--jwt-secret", + jwt_secret, + "--curl", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + // Verify curl command structure + assert!(output.contains("curl -X POST")); + assert!(output.contains("-H 'Content-Type: application/json'")); + assert!(output.contains("eth_call")); + assert!(output.contains("jsonrpc")); + assert!(output.contains(rpc)); + + let jwt = output + .split("Authorization: Bearer ") + .nth(1) + .expect("missing Authorization header") + .split('\'') + .next() + .expect("malformed Authorization header"); + let secret = JwtSecret::from_hex(jwt_secret).unwrap(); + secret.validate(jwt).unwrap(); +} + +// tests that the --curl flag outputs a valid curl command for cast call +#[casttest] +fn curl_call(cmd: _) { + let rpc = "https://eth.example.com"; + let to = "0xdead000000000000000000000000000000000000"; + + let output = cmd + .args(["call", to, "balanceOf(address)(uint256)", to, "--rpc-url", rpc, "--curl"]) + .assert_success() + .get_output() + .stdout_lossy(); + + // Verify curl command structure + assert!(output.contains("curl -X POST")); + assert!(output.contains("eth_call")); + assert!(output.contains(rpc)); +} + +#[casttest] +fn curl_call_eip1559_fees(cmd: _) { + cmd.args([ + "call", + "0xdead000000000000000000000000000000000000", + "--rpc-url", + "http://127.0.0.1:1", + "--curl", + "--gas-price", + "2gwei", + "--priority-gas-price", + "1gwei", + "--from", + "0xbeef000000000000000000000000000000000000", + "--value", + "1", + "--gas-limit", + "100000", + "--nonce", + "7", + "--data", + "0x1234", + ]) + .assert_success() + .stderr_eq(str![""]) + .stdout_eq(str![[r#" +curl -X POST -H 'Content-Type: application/json' --data-raw '{"jsonrpc":"2.0","method":"eth_call","params":[{"from":"0xbeef000000000000000000000000000000000000","to":"0xdead000000000000000000000000000000000000","maxFeePerGas":"0x77359400","maxPriorityFeePerGas":"0x3b9aca00","gas":"0x186a0","value":"0x1","data":"0x1234","nonce":"0x7"},"latest"],"id":1}' 'http://127.0.0.1:1' + +"#]]); +} + +#[casttest] +fn curl_call_legacy_fees(prj: _, cmd: _) { + for configured in [false, true] { + if configured { + prj.create_file("foundry.toml", "[profile.default]\nchain_id = \"fantom\"\n"); + } + cmd.cast_fuse().current_dir(prj.root()).args([ + "call", + "0xdead000000000000000000000000000000000000", + "--rpc-url", + "http://127.0.0.1:1", + "--curl", + "--gas-price", + "2gwei", + "--priority-gas-price", + "1gwei", + ]); + if !configured { + cmd.arg("--legacy"); + } + cmd.assert_success().stderr_eq(str![""]).stdout_eq(str![[r#" +curl -X POST -H 'Content-Type: application/json' --data-raw '{"jsonrpc":"2.0","method":"eth_call","params":[{"to":"0xdead000000000000000000000000000000000000","gasPrice":"0x77359400","data":"0x"},"latest"],"id":1}' 'http://127.0.0.1:1' + +"#]]); + } +} + +#[casttest] +fn curl_call_accepts_named_chain_config(prj: _, cmd: _) { + let rpc = "https://eth.example.com"; + prj.create_file("foundry.toml", "[profile.default]\nchain_id = \"sepolia\"\n"); + + cmd.current_dir(prj.root()) + .args(["call", "0xdead000000000000000000000000000000000000", "--rpc-url", rpc, "--curl"]) + .assert_success() + .stderr_eq(str![""]); +} + +// https://github.com/foundry-rs/foundry/issues/11584 +// Tests that invalid hex with uppercase 0X prefix also produces clear error +#[casttest] +fn cast_call_invalid_hex_uppercase_prefix(cmd: _) { + let rpc = next_http_rpc_endpoint(); + cmd.args([ + "call", + "0xdead000000000000000000000000000000000000", + "--data", + "0X1", // Invalid: odd length hex with uppercase prefix + "--rpc-url", + rpc.as_str(), + ]) + .assert_failure() + .stderr_eq(str![[r#" +Error: Invalid hex calldata '0X1': odd number of digits + +"#]]); +} + +// https://github.com/foundry-rs/foundry/issues/11584 +// Tests that invalid hex calldata (odd length) produces a clear error message +#[casttest] +fn cast_call_invalid_hex_calldata_error(cmd: _) { + let rpc = next_http_rpc_endpoint(); + cmd.args([ + "call", + "0xdead000000000000000000000000000000000000", + "--data", + "0x0", // Invalid: odd length hex + "--rpc-url", + rpc.as_str(), + ]) + .assert_failure() + .stderr_eq(str![[r#" +Error: Invalid hex calldata '0x0': odd number of digits + +"#]]); +} + +// https://github.com/foundry-rs/foundry/issues/11584 +// Tests that valid hex calldata works correctly +#[casttest] +fn cast_call_valid_hex_calldata(cmd: _) { + let rpc = next_http_rpc_endpoint(); + cmd.args([ + "call", + "0xdead000000000000000000000000000000000000", + "--data", + "0x00", // Valid: even length hex + "--rpc-url", + rpc.as_str(), + ]) + .assert_success(); +} + +#[casttest] +fn curl_call_rejects_browser_wallet(cmd: _) { + let stderr = cmd + .args(["call", "0xdead000000000000000000000000000000000000", "--browser", "--curl"]) + .assert_failure() + .get_output() + .stderr_lossy(); + + assert!( + stderr.contains("--browser cannot be combined with --curl; use --from
"), + "unexpected stderr:\n{stderr}" + ); +} + +#[casttest] +async fn curl_call_rejects_signed_authorization(cmd: _) { + let signer: PrivateKeySigner = + "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d".parse().unwrap(); + let auth = Authorization { + chain_id: U256::from(31337), + address: address!("0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"), + nonce: 0, + }; + let signature = signer.sign_hash(&auth.signature_hash()).await.unwrap(); + let encoded_auth = hex::encode_prefixed(alloy_rlp::encode(auth.into_signed(signature))); + + for trace_args in [&[][..], &["--debug-trace-call"][..]] { + cmd.cast_fuse() + .args([ + "call", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "--auth", + &encoded_auth, + "--rpc-url", + "http://127.0.0.1:1", + "--curl", + ]) + .args(trace_args) + .assert_failure() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Error: --auth cannot be combined with --curl + +"#]]); + } +} diff --git a/crates/cast/tests/cli/call_trace.rs b/crates/cast/tests/cli/call_trace.rs new file mode 100644 index 0000000000000..7807dbdd43228 --- /dev/null +++ b/crates/cast/tests/cli/call_trace.rs @@ -0,0 +1,1032 @@ +//! CLI tests for call trace commands. + +use super::*; +use alloy_primitives::bytes; + +// https://github.com/foundry-rs/foundry/issues/9476 +#[forgetest] +async fn cast_call_custom_chain_id(cmd: _) { + let chain_id = 55555u64; + let (_api, handle) = anvil::spawn(NodeConfig::test().with_chain_id(Some(chain_id))).await; + + let http_endpoint = handle.http_endpoint(); + + cmd.cast_fuse() + .args([ + "call", + "5FbDB2315678afecb367f032d93F642f64180aa3", + "--rpc-url", + &http_endpoint, + "--chain", + &chain_id.to_string(), + ]) + .assert_success(); +} + +// https://github.com/foundry-rs/foundry/issues/10848 +#[forgetest_init] +async fn cast_call_disable_labels(prj: _, cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + + prj.initialize_default_contracts(); + prj.add_source( + "Counter", + r#" +contract Counter { + uint256 public number; + + function getBalance(address target) public returns (uint256) { + return target.balance; + } +} + "#, + ); + + // Deploy counter contract. + cmd.args([ + "script", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--rpc-url", + &handle.http_endpoint(), + "--broadcast", + "CounterScript", + ]) + .assert_success(); + + // Override state, `number()` should return overridden value. + cmd.cast_fuse() + .args([ + "call", + "0x5FbDB2315678afecb367f032d93F642f64180aa3", + "--rpc-url", + &handle.http_endpoint(), + "--override-state", + "0x5FbDB2315678afecb367f032d93F642f64180aa3:0x0:0x1234", + "number()(uint256)", + ]) + .assert_success() + .stdout_eq(str![[r#" +4660 + +"#]]); + + // Override state, `number()` should return overridden value. + cmd.cast_fuse() + .args([ + "call", + "0x5FbDB2315678afecb367f032d93F642f64180aa3", + "--labels", + "0x5FbDB2315678afecb367f032d93F642f64180aa3:WETH", + "--rpc-url", + &handle.http_endpoint(), + "--override-state", + "0x5FbDB2315678afecb367f032d93F642f64180aa3:0x0:0x1234", + "number()(uint256)", + "--trace", + ]) + .assert_success() + .stdout_eq(str![[r#" +Traces: + [2402] WETH::number() + └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000001234 + + +Transaction successfully executed. +[GAS] + +"#]]); + + // Override state, `number()` with `disable_labels`. + cmd.cast_fuse() + .args([ + "call", + "0x5FbDB2315678afecb367f032d93F642f64180aa3", + "--labels", + "0x5FbDB2315678afecb367f032d93F642f64180aa3:WETH", + "--rpc-url", + &handle.http_endpoint(), + "--override-state", + "0x5FbDB2315678afecb367f032d93F642f64180aa3:0x0:0x1234", + "number()(uint256)", + "--trace", + "--disable-labels", + ]) + .assert_success() + .stdout_eq(str![[r#" +Traces: + [2402] 0x5FbDB2315678afecb367f032d93F642f64180aa3::number() + └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000001234 + + +Transaction successfully executed. +[GAS] + +"#]]); +} + +// --debug-trace-call with --with-local-artifacts labels the called contract by its local +// artifact name (Counter::) instead of the raw address. Without the RPC bytecode-map fetch the +// trace falls back to the bare address, so this test can fail. +#[forgetest_init] +async fn cast_call_debug_trace_call_with_local_artifacts(prj: _, cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + + prj.initialize_default_contracts(); + cmd.args([ + "script", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--rpc-url", + &handle.http_endpoint(), + "--broadcast", + "CounterScript", + ]) + .assert_success(); + + cmd.cast_fuse(); + cmd.set_current_dir(prj.root()); + cmd.args([ + "call", + "0x5FbDB2315678afecb367f032d93F642f64180aa3", + "number()(uint256)", + "--debug-trace-call", + "--with-local-artifacts", + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success() + .stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! +Traces: + [23488] Counter::number() + └─ ← [Return] 0 + + +Transaction successfully executed. +[GAS] + +"#]]); +} + +// `--debug-trace-call --with-local-artifacts` must label a contract that only exists through a +// `--override-code` state override: the trace runs the override code, so artifact matching must +// see that code instead of the (empty) on-chain code. +#[forgetest_init] +async fn cast_call_debug_trace_call_override_code_local_artifacts(prj: _, cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + + prj.initialize_default_contracts(); + + // Deploy counter contract, only to read its runtime bytecode back. + cmd.args([ + "script", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--rpc-url", + &handle.http_endpoint(), + "--broadcast", + "CounterScript", + ]) + .assert_success(); + + let runtime_code = cmd + .cast_fuse() + .args([ + "code", + "0x5FbDB2315678afecb367f032d93F642f64180aa3", + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success() + .get_output() + .stdout_lossy() + .trim() + .to_string(); + + // Call an address that has no code on chain, overriding it with Counter's runtime code. + cmd.cast_fuse(); + cmd.set_current_dir(prj.root()); + let output = cmd + .args([ + "call", + "0x00000000000000000000000000000000000000aa", + "number()(uint256)", + "--debug-trace-call", + "--with-local-artifacts", + "--override-code", + &format!("0x00000000000000000000000000000000000000aa:{runtime_code}"), + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + assert!( + output.contains("Counter::number()"), + "expected the override-code contract to be labeled from local artifacts:\n{output}" + ); +} + +// `--json --debug-trace-call --with-local-artifacts` must keep stdout machine-readable: the +// compile banner/progress goes to stderr, so stdout is exactly one JSON document. +#[forgetest_init] +async fn cast_call_debug_trace_call_local_artifacts_json_stdout(prj: _, cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + + prj.initialize_default_contracts(); + + // Deploy counter contract. + cmd.args([ + "script", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--rpc-url", + &handle.http_endpoint(), + "--broadcast", + "CounterScript", + ]) + .assert_success(); + + cmd.cast_fuse(); + cmd.set_current_dir(prj.root()); + let output = cmd + .args([ + "call", + "0x5FbDB2315678afecb367f032d93F642f64180aa3", + "number()(uint256)", + "--debug-trace-call", + "--with-local-artifacts", + "--json", + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + serde_json::from_str::(output.trim()).unwrap_or_else(|err| { + panic!("expected stdout to be a single JSON document ({err}):\n{output}") + }); +} + +// `cast call --trace` decodes custom errors through the local signatures cache that `forge build` +// populates, without requiring `--with-local-artifacts`. +// +#[forgetest_init] +async fn flaky_cast_call_trace_decodes_error_from_signatures_cache(prj: _, cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + + prj.add_source( + "CustomErrorContract", + r#" +contract CustomErrorContract { + error WTF273987(uint256 a, uint256 b); + error PrintableError18(); + + function wtf2890230(uint256 a, uint128 b) external pure { + revert WTF273987(a, b); + } + + function printableError() external pure { + revert PrintableError18(); + } +} +"#, + ); + + // `forge build` caches the project's signatures, including custom errors. + cmd.args(["build"]).assert_success(); + + // Deploy the contract. + cmd.forge_fuse() + .args([ + "create", + "./src/CustomErrorContract.sol:CustomErrorContract", + "--broadcast", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success(); + + // The revert reason is decoded from the cached signatures, even offline and without + // `--with-local-artifacts`. + cmd.cast_fuse().env("FOUNDRY_OFFLINE", "true"); + cmd.args([ + "call", + "0x5FbDB2315678afecb367f032d93F642f64180aa3", + "wtf2890230(uint256,uint128)", + "42", + "69", + "--trace", + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success() + .stdout_eq(str![[r#" +Traces: + [..] 0x5FbDB2315678afecb367f032d93F642f64180aa3::wtf2890230(42, 69) + └─ ← [Revert] WTF273987(42, 69) + + +[GAS] + +"#]]) + .stderr_eq(str![[r#" +Error: Transaction failed. + +"#]]); + + // A custom error whose selector is valid ASCII must also be decoded from the cache rather than + // rendered as a raw string. `PrintableError18()` has selector `0x2e2c426f` (`.,Bo`). + cmd.cast_fuse().env("FOUNDRY_OFFLINE", "true"); + cmd.args([ + "call", + "0x5FbDB2315678afecb367f032d93F642f64180aa3", + "printableError()", + "--trace", + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success() + .stdout_eq(str![[r#" +Traces: + [..] 0x5FbDB2315678afecb367f032d93F642f64180aa3::printableError() + └─ ← [Revert] PrintableError18() + + +[GAS] + +"#]]) + .stderr_eq(str![[r#" +Error: Transaction failed. + +"#]]); +} + +// tests that cast call --trace selects TempoEvmNetwork when Tempo is inferred from +// the fork RPC, or when a Tempo chain ID is provided explicitly via --chain. +#[casttest] +async fn cast_call_trace_selects_tempo_network(cmd: _) { + let (_, tempo_handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let (_, eth_handle) = anvil::spawn(NodeConfig::test()).await; + + let token = PATH_USD_ADDRESS.to_string(); + for (name, rpc, extra_args) in [ + ("inferred Tempo RPC", tempo_handle.http_endpoint(), Vec::<&str>::new()), + ("explicit Tempo --chain", eth_handle.http_endpoint(), vec!["--chain", "4217"]), + ] { + cmd.cast_fuse(); + let mut args = vec!["call", &token, "decimals()(uint8)", "--rpc-url", &rpc, "--trace"]; + args.extend(extra_args); + + let output = cmd.args(args).assert_success().get_output().stdout_lossy(); + + assert!( + output.contains("PathUSD::decimals()") && output.contains("← [Return] 6"), + "expected traced Tempo TIP20 call to execute successfully for {name}, got:\n{output}" + ); + } +} + +// tests that `cast call --trace` executes the call with the configured gas limit or the limit given +// via `--gas-limit` rather than running with an unbounded gas limit. +// +#[forgetest] +async fn cast_call_trace_respects_gas_limits(prj: _, cmd: _) { + let (_api, handle) = anvil::spawn(NodeConfig::test()).await; + let endpoint = handle.http_endpoint(); + prj.update_config(|config| config.gas_limit = 1_000_000.into()); + + // Contract that reverts when the call is given an unrealistically large gas limit. + prj.add_source( + "GasDependent", + r#" +contract GasDependent { + function run(uint256 maxGas) external view returns (uint256) { + require(gasleft() < maxGas, "unrealistic gas limit"); + return gasleft(); + } +} +"#, + ); + cmd.forge_fuse().args(["build"]).assert_success(); + + let bytecode_path = prj.root().join("out/GasDependent.sol/GasDependent.json"); + let contract_json = std::fs::read_to_string(bytecode_path).unwrap(); + let contract_data: serde_json::Value = serde_json::from_str(&contract_json).unwrap(); + let bytecode = contract_data["bytecode"]["object"].as_str().unwrap(); + + let deploy_output = cmd + .cast_fuse() + .args([ + "send", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--rpc-url", + &endpoint, + "--json", + "--create", + bytecode, + ]) + .assert_success() + .get_output() + .stdout_lossy(); + let receipt: serde_json::Value = serde_json::from_str(&deploy_output).unwrap(); + let address = receipt["contractAddress"].as_str().unwrap().to_string(); + + // A plain `cast call` (eth_call) uses a realistic gas limit, so it succeeds. + cmd.cast_fuse() + .args(["call", &address, "run(uint256)(uint256)", "30000000", "--rpc-url", &endpoint]) + .assert_success(); + + // `--trace` uses the configured gas limit when no CLI limit is provided. + cmd.cast_fuse() + .args([ + "call", + &address, + "run(uint256)(uint256)", + "2000000", + "--trace", + "--rpc-url", + &endpoint, + ]) + .assert_success(); + + // `--trace` with an explicit `--gas-limit` executes the call with that limit, so it succeeds + // too instead of reverting. + let trace_output = cmd + .cast_fuse() + .args([ + "call", + &address, + "run(uint256)(uint256)", + "750000", + "--trace", + "--gas-limit", + "500000", + "--rpc-url", + &endpoint, + ]) + .assert_success() + .get_output() + .stdout_lossy(); + assert!( + trace_output.contains("[Return]") && !trace_output.contains("unrealistic gas limit"), + "expected traced call to respect --gas-limit and succeed, got:\n{trace_output}" + ); +} + +// tests that `cast call --trace` executes the call with the requested fees, like `eth_call`. +// +#[casttest] +async fn cast_call_trace_respects_gas_price(cmd: _) { + let (_api, handle) = anvil::spawn(NodeConfig::test()).await; + let endpoint = handle.http_endpoint(); + let target = "0x0000000000000000000000000000000000001234"; + // Returns `GASPRICE`. + let override_code = format!("{target}:0x3a60005260206000f3"); + + // The block base fee is 1 gwei. + for (fee_args, gas_price) in [ + (&[][..], 0), + // Zero fees skip base fee validation, like `eth_call`. + (&["--legacy", "--gas-price", "0"][..], 0), + (&["--gas-price", "0", "--priority-gas-price", "0"][..], 0), + (&["--legacy", "--gas-price", "3gwei"][..], 3_000_000_000u64), + (&["--gas-price", "50gwei", "--priority-gas-price", "2gwei"][..], 3_000_000_000), + (&["--gas-price", "50gwei"][..], 1_000_000_000), + (&["--gas-price", "2gwei", "--priority-gas-price", "2gwei"][..], 2_000_000_000), + ] { + cmd.cast_fuse() + .args(["call", target, "--trace", "--rpc-url", &endpoint, "--override-code"]) + .arg(&override_code) + .args(fee_args) + .assert_success() + .stdout_eq(format!( + "\ +Traces: + [17] 0x0000000000000000000000000000000000001234::fallback() + └─ ← [Return] 0x{gas_price:064x} + + +Transaction successfully executed. +[GAS] +" + )); + } + + for fee_args in [ + &["--priority-gas-price", "2gwei"][..], + &["--gas-price", "0", "--priority-gas-price", "2gwei"][..], + ] { + cmd.cast_fuse() + .args(["call", target, "--trace", "--rpc-url", &endpoint, "--override-code"]) + .arg(&override_code) + .args(fee_args) + .assert_failure() + .stderr_eq(str![[r#" +Error: EVM error + +Context: +- transaction validation error: priority fee is greater than max fee + +"#]]); + } +} + +#[cfg(feature = "monad")] +#[casttest] +async fn cast_call_trace_monad_fees(cmd: _) { + let (_api, handle) = anvil::spawn(NodeConfig::test_monad()).await; + let endpoint = handle.http_endpoint(); + let target = "0x0000000000000000000000000000000000001234"; + let override_code = format!("{target}:0x3a60005260206000f3"); + + for fee_args in [ + &[][..], + &["--legacy", "--gas-price", "0"][..], + &["--gas-price", "0", "--priority-gas-price", "0"][..], + ] { + cmd.cast_fuse() + .args(["call", target, "--trace", "--chain", "143", "--rpc-url", &endpoint]) + .args(["--override-code", &override_code]) + .args(fee_args) + .assert_success() + .stdout_eq(str![[r#" +Traces: + [14] 0x0000000000000000000000000000000000001234::fallback() + └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000000000 + + +Transaction successfully executed. +[GAS] + +"#]]); + } + + for fee_args in [ + &["--legacy", "--gas-price", "3gwei"][..], + &["--gas-price", "50gwei", "--priority-gas-price", "2gwei"][..], + &["--priority-gas-price", "2gwei"][..], + ] { + cmd.cast_fuse() + .args(["call", target, "--trace", "--chain", "143", "--rpc-url", &endpoint]) + .args(["--override-code", &override_code]) + .args(fee_args) + .assert_failure() + .stderr_eq(str![[r#" +Error: Nonzero fees are not supported for local Monad traces; use --debug-trace-call instead + +"#]]); + } +} + +#[casttest] +async fn cast_call_disables_external_identification(prj: _, cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + // Leave the listener unserved: correct flag propagation prevents a connection, while an + // enabled identifier connects before exhausting the configured timeout. + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let etherscan_url = format!("http://{}", listener.local_addr().unwrap()); + let target = Address::random().to_string(); + let override_code = format!("{target}:0x60006000f3"); + fs::write( + prj.root().join("foundry.toml"), + format!( + r#"[profile.default] +etherscan_api_key = "local" +eth_rpc_no_proxy = true +offline = false + +[tracing] +external_identification_timeout = 1 + +[etherscan] +local = {{ key = "test", url = "{etherscan_url}" }} +"#, + ), + ) + .unwrap(); + + for var in [ + "ETHERSCAN_API_KEY", + "FOUNDRY_CONFIG", + "FOUNDRY_ETHERSCAN_API_KEY", + "FOUNDRY_OFFLINE", + "FOUNDRY_TRACING_EXTERNAL_IDENTIFICATION_TIMEOUT", + ] { + cmd.unset_env(var); + } + let assert = cmd + .args([ + "call", + &target, + "--rpc-url", + &handle.http_endpoint(), + "--override-code", + &override_code, + "--trace", + "--disable-external-identification", + ]) + .assert_success(); + let stdout = assert.get_output().stdout_lossy().to_lowercase(); + assert!( + stdout.contains("traces:") && stdout.contains(&target.to_lowercase()), + "expected trace for {target}, got:\n{stdout}" + ); + + match listener.accept() { + Err(err) if err.kind() == ErrorKind::WouldBlock => {} + Ok(_) => panic!("external identification made an Etherscan request"), + Err(err) => panic!("failed to inspect mock Etherscan listener: {err}"), + } +} + +// https://github.com/foundry-rs/foundry/issues/10189 +// `cast call --debug-trace-call` fetches the call trace from the node via `debug_traceCall` +// (callTracer) and renders it with the same decoding/rendering machinery as `--trace`. The call +// targets the identity precompile so the test needs no deployed contract. +#[casttest] +async fn cast_call_debug_trace_call(prj: _, cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + + fs::write( + prj.root().join("foundry.toml"), + r#"[labels] + 0x0000000000000000000000000000000000000001 = "unused" + + [tracing] + decode_internal = true + "#, + ) + .unwrap(); + + cmd.cast_fuse(); + cmd.set_current_dir(prj.root()); + cmd + .args([ + "call", + "0x0000000000000000000000000000000000000004", + "--data", + "0xdeadbeef", + "--debug-trace-call", + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success() + .stdout_eq(str![[r#" +Traces: + [21160] PRECOMPILES::identity(0xdeadbeef) + └─ ← [Return] 0xdeadbeef + + +Transaction successfully executed. +[GAS] + +"#]]) + .stderr_eq(str![[r#" +Warning: Key `[labels]` is being deprecated in favor of `[tracing.labels]`. It will be removed in future versions. + +"#]]); +} + +// `--debug-trace-call` must honour state overrides: here we override the code of an address with a +// tiny runtime that returns storage slot 0, and override slot 0 itself, then check the traced call +// returns the overridden value. If the overrides were not forwarded to `debug_traceCall`, the +// address would have no code and the return would not be the overridden value, so this test can +// fail. +#[casttest] +async fn cast_call_debug_trace_call_applies_overrides(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + + cmd.cast_fuse() + .args([ + "call", + "0x00000000000000000000000000000000000000aa", + "number()(uint256)", + "--debug-trace-call", + // runtime: PUSH1 0 SLOAD PUSH1 0 MSTORE PUSH1 0x20 PUSH1 0 RETURN + "--override-code", + "0x00000000000000000000000000000000000000aa:0x60005460005260206000f3", + "--override-state", + "0x00000000000000000000000000000000000000aa:0x0:0x1234", + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success() + .stdout_eq(str![[r#" +Traces: + [23182] 0x00000000000000000000000000000000000000AA::number() + └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000001234 + + +Transaction successfully executed. +[GAS] + +"#]]); +} + +// `--debug-trace-call` must also forward block overrides: override an address with a runtime that +// returns `block.number` and pass `--block.number`, then check the traced call returns that number. +// If `with_block_overrides` were not forwarded to `debug_traceCall`, the call would run at anvil's +// real block number and the return would differ, so this test can fail. +#[casttest] +async fn cast_call_debug_trace_call_applies_block_overrides(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + + cmd.cast_fuse() + .args([ + "call", + "0x00000000000000000000000000000000000000bb", + "number()(uint256)", + "--debug-trace-call", + // runtime: NUMBER PUSH1 0 MSTORE PUSH1 0x20 PUSH1 0 RETURN + "--override-code", + "0x00000000000000000000000000000000000000bb:0x4360005260206000f3", + "--block.number", + "1234", + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success() + .stdout_eq(str![[r#" +Traces: + [21160] 0x00000000000000000000000000000000000000bb::number() + └─ ← [Return] 0x00000000000000000000000000000000000000000000000000000000000004d2 + + +Transaction successfully executed. +[GAS] + +"#]]); +} + +// The motivating invocation in issue #11521 is `cast call --trace --from `, so the +// delegate override must reach the local tracing executor as well. The trace shows the call +// executing at the sender address, which is where the delegated code runs. +#[casttest] +async fn cast_call_delegate_trace_uses_sender_storage(cmd: _) { + let (api, handle) = anvil::spawn(NodeConfig::test()).await; + let from = "0x00000000000000000000000000000000000000d7"; + let to = "0x00000000000000000000000000000000000000d8"; + + // runtime: PUSH1 0 SLOAD PUSH1 0 MSTORE PUSH1 0x20 PUSH1 0 RETURN + api.anvil_set_code(to.parse().unwrap(), bytes!("0x60005460005260206000f3")).await.unwrap(); + api.anvil_set_storage_at(from.parse().unwrap(), U256::ZERO, B256::from(U256::from(0x1234))) + .await + .unwrap(); + + cmd.cast_fuse() + .args([ + "call", + to, + "number()(uint256)", + "--from", + from, + "--delegate", + "--trace", + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success() + .stdout_eq(str![[r#" +Traces: + [..] 0x00000000000000000000000000000000000000d7::number() + └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000001234 + + +Transaction successfully executed. +[GAS] + +"#]]); +} + +// `--debug-trace-call` must render a reverting call as `[Revert]` (success = false), exercising +// `status_from_frame` and the failure rendering end-to-end. The overridden runtime just reverts. +#[casttest] +async fn cast_call_debug_trace_call_renders_revert(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + + cmd.cast_fuse() + .args([ + "call", + "0x00000000000000000000000000000000000000dd", + "run()", + "--debug-trace-call", + // runtime: PUSH1 0 PUSH1 0 REVERT + "--override-code", + "0x00000000000000000000000000000000000000dd:0x60006000fd", + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success() + .stdout_eq(str![[r#" +Traces: + [21160] 0x00000000000000000000000000000000000000dd::run() + └─ ← [Revert] execution reverted + + +[GAS] + +"#]]); +} + +// tests that `--debug-trace-call --curl` emits a `debug_traceCall` request with the +// callTracer, not a plain `eth_call` +#[casttest] +fn curl_call_debug_trace_call(cmd: _) { + let rpc = "https://eth.example.com"; + let to = "0xdead000000000000000000000000000000000000"; + + let output = cmd + .args(["call", to, "number()(uint256)", "--rpc-url", rpc, "--debug-trace-call", "--curl"]) + .assert_success() + .get_output() + .stdout_lossy(); + + // Verify curl command structure + assert!(output.contains("curl -X POST")); + assert!(output.contains(rpc)); + assert!(output.contains("debug_traceCall"), "expected debug_traceCall method:\n{output}"); + assert!(output.contains("callTracer"), "expected callTracer tracer param:\n{output}"); + assert!(!output.contains("eth_call"), "unexpected eth_call request:\n{output}"); +} + +// tests that `--debug-trace-call --curl` forwards state and block overrides in the request, +// like the non-curl path does, so the printed request traces the same state +#[casttest] +fn curl_call_debug_trace_call_forwards_overrides(cmd: _) { + let rpc = "https://eth.example.com"; + let to = "0xdead000000000000000000000000000000000000"; + + let output = cmd + .args([ + "call", + to, + "number()(uint256)", + "--rpc-url", + rpc, + "--debug-trace-call", + "--override-code", + "0x00000000000000000000000000000000000000aa:0x60005460005260206000f3", + "--block.number", + "1234", + "--curl", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + assert!(output.contains("debug_traceCall"), "expected debug_traceCall method:\n{output}"); + assert!(output.contains("stateOverrides"), "expected state overrides in params:\n{output}"); + assert!( + output.contains("0x60005460005260206000f3"), + "expected the override code in params:\n{output}" + ); + assert!(output.contains("blockOverrides"), "expected block overrides in params:\n{output}"); +} + +// tests that `--curl` forwards the scalar transaction fields into the call object, so the +// printed request runs the same call as the non-curl command +#[casttest] +fn curl_call_debug_trace_call_forwards_tx_fields(cmd: _) { + let rpc = "https://eth.example.com"; + let to = "0xdead000000000000000000000000000000000000"; + + let output = cmd + .args([ + "call", + to, + "number()(uint256)", + "--rpc-url", + rpc, + "--debug-trace-call", + "--from", + "0x000000000000000000000000000000000000beef", + "--value", + "1ether", + "--gas-limit", + "12345", + "--nonce", + "7", + "--curl", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + assert!(output.contains("debug_traceCall"), "expected debug_traceCall method:\n{output}"); + assert!( + output.contains("0x000000000000000000000000000000000000beef"), + "expected the from address in params:\n{output}" + ); + assert!( + output.contains("0xde0b6b3a7640000"), + "expected the value (1 ether) in params:\n{output}" + ); + assert!(output.contains("0x3039"), "expected the gas limit (12345) in params:\n{output}"); + assert!(output.contains("nonce"), "expected the nonce in params:\n{output}"); +} + +// tests that `--labels` / `--disable-labels` are accepted with `--debug-trace-call`, which +// forwards them to the trace renderer like `--trace` does +#[casttest] +fn call_labels_accepted_with_debug_trace_call(cmd: _) { + let rpc = "https://eth.example.com"; + let to = "0xdead000000000000000000000000000000000000"; + + cmd.args([ + "call", + to, + "number()(uint256)", + "--rpc-url", + rpc, + "--debug-trace-call", + "--labels", + "0xdead000000000000000000000000000000000000:Counter", + "--disable-labels", + "--curl", + ]) + .assert_success(); +} + +// tests that `--labels` still requires one of the trace modes +#[casttest] +fn call_labels_rejected_without_trace_mode(cmd: _) { + let rpc = "https://eth.example.com"; + let to = "0xdead000000000000000000000000000000000000"; + + cmd.args([ + "call", + to, + "number()(uint256)", + "--rpc-url", + rpc, + "--labels", + "0xdead000000000000000000000000000000000000:Counter", + "--curl", + ]) + .assert_failure(); +} + +// `--debug-trace-call` must render a multi-node trace (a call that emits a log AND makes a +// sub-call), exercising the log/sub-call interleaving and nesting through the real pipeline, not +// just in the unit tests. The overridden runtime emits a LOG0 then STATICCALLs the identity +// precompile, so the trace has a child call ordered after the log. +#[casttest] +async fn cast_call_debug_trace_call_renders_nested_call_and_log(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + + cmd.cast_fuse() + .args([ + "call", + "0x00000000000000000000000000000000000000cc", + "run()", + "--debug-trace-call", + // runtime: LOG0(0,0); STATICCALL(gas, 0x4, 0,0,0,0); POP; STOP + "--override-code", + "0x00000000000000000000000000000000000000cc:0x60006000a060006000600060007300000000000000000000000000000000000000045afa5000", + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success() + .stdout_eq(str![[r#" +Traces: + [21579] 0x00000000000000000000000000000000000000cc::run() + ├─ data: 0x + ├─ [15] PRECOMPILES::identity(0x) [staticcall] + │ └─ ← [Return] 0x + └─ ← [Return] + + +Transaction successfully executed. +[GAS] + +"#]]); +} + +#[casttest] +fn curl_call_rejects_local_trace(cmd: _) { + cmd.args([ + "call", + "0xdead000000000000000000000000000000000000", + "number()(uint256)", + "--rpc-url", + "http://127.0.0.1:1", + "--trace", + "--curl", + ]) + .assert_failure() + .stdout_eq("") + .stderr_eq(str![[r#" +Error: --trace cannot be combined with --curl; use --debug-trace-call --curl instead + +"#]]); +} diff --git a/crates/cast/tests/cli/chain.rs b/crates/cast/tests/cli/chain.rs new file mode 100644 index 0000000000000..56626be4af4d6 --- /dev/null +++ b/crates/cast/tests/cli/chain.rs @@ -0,0 +1,347 @@ +//! CLI tests for chain commands. + +use super::*; + +// tests that the `cast block` command works correctly +#[casttest] +fn latest_block(cmd: _) { + let eth_rpc_url = next_http_rpc_endpoint(); + + // Call `cast find-block` + cmd.args(["block", "latest", "--rpc-url", eth_rpc_url.as_str()]); + cmd.assert_success().stdout_eq(str![[r#" + + +baseFeePerGas [..] +difficulty [..] +extraData [..] +gasLimit [..] +gasUsed [..] +hash [..] +logsBloom [..] +miner [..] +mixHash [..] +nonce [..] +number [..] +parentHash [..] +parentBeaconRoot [..] +transactionsRoot [..] +receiptsRoot [..] +sha3Uncles [..] +size [..] +stateRoot [..] +timestamp [..] +withdrawalsRoot [..] +totalDifficulty [..] +blobGasUsed [..] +excessBlobGas [..] +requestsHash [..] +transactions: [ +... +] + +"#]]); + + // + cmd.cast_fuse().args([ + "block", + "15007840", + "-f", + "hash,timestamp", + "--rpc-url", + eth_rpc_url.as_str(), + ]); + cmd.assert_success().stdout_eq(str![[r#" +0x950091817a57e22b6c1f3b951a15f52d41ac89b299cc8f9c89bb6d185f80c415 +1655904485 + +"#]]); +} + +#[casttest] +fn block_raw(cmd: _) { + let eth_rpc_url = next_http_rpc_endpoint(); + + let output = cmd + .args(["block", "22934900", "--rpc-url", eth_rpc_url.as_str(), "--raw"]) + .assert_success() + .get_output() + .stdout_lossy() + .trim() + .to_string(); + + // Hash the output with keccak256 + let hash = alloy_primitives::keccak256(hex::decode(output).unwrap()); + + // Verify the Mainnet's block #22934900 header hash equals the expected value + // obtained with go-ethereum's `block.Header().Hash()` method + assert_eq!( + hash.to_string(), + "0x49fd7f3b9ba5d67fa60197027f09454d4cac945e8f271edcc84c3fd5872446d3" + ); +} + +#[casttest] +fn block_json_wraps_raw_and_scalar_field_outputs(cmd: _) { + let eth_rpc_url = next_http_rpc_endpoint(); + + let raw_output = cmd + .args(["block", "22934900", "--rpc-url", eth_rpc_url.as_str(), "--raw", "--json"]) + .assert_success() + .get_output() + .stdout_lossy(); + let raw_envelope: serde_json::Value = serde_json::from_str(raw_output.trim()).unwrap(); + assert_eq!(raw_envelope["schema_version"], 1); + assert!(raw_envelope["success"].as_bool().unwrap()); + assert!(raw_envelope["data"].as_str().unwrap().starts_with("0x")); + + let field_output = cmd + .cast_fuse() + .args(["block", "0x123", "--field", "number", "--rpc-url", eth_rpc_url.as_str(), "--json"]) + .assert_success() + .get_output() + .stdout_lossy(); + let field_envelope: serde_json::Value = serde_json::from_str(field_output.trim()).unwrap(); + assert_eq!(field_envelope["schema_version"], 1); + assert!(field_envelope["success"].as_bool().unwrap()); + assert_eq!(field_envelope["data"], 291); +} + +#[casttest] +fn block_raw_tempo(cmd: _) { + // https://explore.tempo.xyz/block/8386710 + let output = cmd + .args([ + "block", + "8386710", + "--rpc-url", + "https://rpc.moderato.tempo.xyz", + "--raw", + "-n", + "tempo", + ]) + .assert_success() + .get_output() + .stdout_lossy() + .trim() + .to_string(); + + let hash = alloy_primitives::keccak256(hex::decode(output).unwrap()); + + assert_eq!( + hash.to_string(), + "0xcd6170dc28b888bcb93ed1ad76a6bea4ad9977b678db5d462df83d35ec9b8d15" + ); +} + +// tests that the `cast find-block` command works correctly +#[casttest] +fn finds_block(cmd: _) { + // Construct args + let timestamp = "1647843609".to_string(); + let eth_rpc_url = next_http_rpc_endpoint(); + + // Call `cast find-block` + // + cmd.args(["find-block", "--rpc-url", eth_rpc_url.as_str(), ×tamp]) + .assert_success() + .stdout_eq(str![[r#" +14428082 + +"#]]); +} + +#[casttest] +fn balance(cmd: _) { + let rpc = next_http_rpc_endpoint(); + let dai = "0x6B175474E89094C44Da98b954EedeAC495271d0F"; + + let dai_result = cmd + .args([ + "balance", + "0x0000000000000000000000000000000000000000", + "--erc20", + dai, + "--rpc-url", + &rpc, + ]) + .assert_success() + .get_output() + .stdout_lossy() + .trim() + .to_string(); + + let alias_result = cmd + .cast_fuse() + .args([ + "balance", + "0x0000000000000000000000000000000000000000", + "--erc721", + dai, + "--rpc-url", + &rpc, + ]) + .assert_success() + .get_output() + .stdout_lossy() + .trim() + .to_string(); + + assert_ne!(dai_result, "0"); + assert_eq!(alias_result, dai_result); +} + +#[casttest] +fn block_number(cmd: _) { + let eth_rpc_url = next_http_rpc_endpoint(); + let s = cmd + .args(["block-number", "--rpc-url", eth_rpc_url.as_str()]) + .assert_success() + .get_output() + .stdout_lossy(); + assert!(s.trim().parse::().unwrap() > 0, "{s}") +} + +#[casttest] +fn block_number_latest(cmd: _) { + let eth_rpc_url = next_http_rpc_endpoint(); + let s = cmd + .args(["block-number", "--rpc-url", eth_rpc_url.as_str(), "latest"]) + .assert_success() + .get_output() + .stdout_lossy(); + assert!(s.trim().parse::().unwrap() > 0, "{s}") +} + +#[casttest] +fn block_number_hash(cmd: _) { + let eth_rpc_url = next_http_rpc_endpoint(); + let s = cmd + .args([ + "block-number", + "--rpc-url", + eth_rpc_url.as_str(), + "0x88e96d4537bea4d9c05d12549907b32561d3bf31f45aae734cdc119f13406cb6", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + assert_eq!(s.trim().parse::().unwrap(), 1, "{s}") +} + +// tests that the --curl flag outputs a valid curl command for cast block-number +#[casttest] +fn curl_block_number(cmd: _) { + let rpc = "https://eth.example.com"; + + let output = cmd + .args(["block-number", "--rpc-url", rpc, "--curl"]) + .assert_success() + .get_output() + .stdout_lossy(); + + // Verify curl command structure + assert!(output.contains("curl -X POST")); + assert!(output.contains("eth_blockNumber")); + assert!(output.contains(rpc)); +} + +// tests that the --curl flag outputs a valid curl command for cast chain-id +#[casttest] +fn curl_chain_id(cmd: _) { + let rpc = "https://eth.example.com"; + + let output = cmd + .args(["chain-id", "--rpc-url", rpc, "--curl"]) + .assert_success() + .get_output() + .stdout_lossy(); + + // Verify curl command structure + assert!(output.contains("curl -X POST")); + assert!(output.contains("eth_chainId")); + assert!(output.contains(rpc)); +} + +// tests that the --curl flag outputs a valid curl command for cast gas-price +#[casttest] +fn curl_gas_price(cmd: _) { + let rpc = "https://eth.example.com"; + + let output = cmd + .args(["gas-price", "--rpc-url", rpc, "--curl"]) + .assert_success() + .get_output() + .stdout_lossy(); + + // Verify curl command structure + assert!(output.contains("curl -X POST")); + assert!(output.contains("eth_gasPrice")); + assert!(output.contains(rpc)); +} + +#[casttest] +async fn chain_unknown(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + cmd.args(["chain", "--rpc-url", &handle.http_endpoint()]) + .assert_success() + .stdout_eq("unknown\n"); +} + +#[casttest] +async fn age(cmd: _) { + let (_, handle) = + anvil::spawn(NodeConfig::test().with_genesis_timestamp(Some(1_645_099_200u64))).await; + cmd.args(["age", "0", "--rpc-url", &handle.http_endpoint()]) + .assert_success() + .stdout_eq("Thu Feb 17 12:00:00 2022 UTC\n"); +} + +#[casttest] +async fn age_rejects_timestamp_overflow(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test().with_genesis_timestamp(Some(u64::MAX))).await; + cmd.args(["age", "0", "--rpc-url", &handle.http_endpoint()]) + .assert_failure() + .stderr_eq("Error: invalid timestamp\n"); +} + +#[casttest] +async fn base_fee(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test().with_base_fee(Some(123_456_789))).await; + cmd.args(["base-fee", "0", "--rpc-url", &handle.http_endpoint()]) + .assert_success() + .stdout_eq("123456789\n"); +} + +#[casttest] +fn cast_tx_curl_skips_network_probe(cmd: _) { + cmd.args([ + "tx", + "0x0000000000000000000000000000000000000000000000000000000000000001", + "--rpc-url", + "http://127.0.0.1:1", + "--curl", + ]) + .assert_success() + .stdout_eq(str![[r#" +curl -X POST -H 'Content-Type: application/json' --data-raw '{"method":"eth_getTransactionByHash","params":["0x0000000000000000000000000000000000000000000000000000000000000001"],"id":0,"jsonrpc":"2.0"}' 'http://127.0.0.1:1/' + +"#]]) + .stderr_eq(str![""]); +} + +#[casttest] +fn cast_raw_block_curl_skips_network_probe(prj: _, cmd: _) { + for raw in ["--raw", "--field=raw"] { + cmd.cast_fuse().current_dir(prj.root()) + .args(["block", "latest"]) + .arg(raw) + .args(["--rpc-url", "http://127.0.0.1:1", "--curl"]) + .assert_success() + .stdout_eq(str![[r#" +curl -X POST -H 'Content-Type: application/json' --data-raw '{"method":"eth_getBlockByNumber","params":["latest",false],"id":0,"jsonrpc":"2.0"}' 'http://127.0.0.1:1/' + +"#]]) + .stderr_eq(str![""]); + } +} diff --git a/crates/cast/tests/cli/conversions.rs b/crates/cast/tests/cli/conversions.rs new file mode 100644 index 0000000000000..8f8557c47557b --- /dev/null +++ b/crates/cast/tests/cli/conversions.rs @@ -0,0 +1,681 @@ +//! CLI tests for conversions commands. + +use super::*; + +// tests that the `cast to-rlp` and `cast from-rlp` commands work correctly +#[casttest] +fn rlp(cmd: _) { + cmd.args(["--to-rlp", "[\"0xaa\", [[\"bb\"]], \"0xcc\"]"]).assert_success().stdout_eq(str![[ + r#" +0xc881aac3c281bb81cc + +"# + ]]); + + cmd.cast_fuse(); + cmd.args(["--from-rlp", "0xcbc58455556666c0c0c2c1c0"]).assert_success().stdout_eq(str![[r#" +[["0x55556666"],[],[],[[[]]]] + +"#]]); + + // Build the RLP encoding of 10,000 nested single-item lists without recursively encoding it. + const NESTING_DEPTH: usize = 10_000; + let mut encoded_len = 1; + let mut headers = Vec::with_capacity(NESTING_DEPTH); + for _ in 0..NESTING_DEPTH { + let mut header = Vec::new(); + Header { list: true, payload_length: encoded_len }.encode(&mut header); + encoded_len += header.len(); + headers.push(header); + } + let mut deeply_nested = Vec::with_capacity(encoded_len); + for header in headers.iter().rev() { + deeply_nested.extend_from_slice(header); + } + deeply_nested.push(0x80); + + cmd.cast_fuse().arg("--from-rlp").stdin(hex::encode_prefixed(deeply_nested)).assert_success(); + cmd.cast_fuse().args(["from-rlp", "0xc0"]).assert_success().stdout_eq("[]\n"); + cmd.cast_fuse().args(["from-rlp", "0x0f"]).assert_success().stdout_eq("\"0x0f\"\n"); + cmd.cast_fuse().args(["from-rlp", "0x33"]).assert_success().stdout_eq("\"0x33\"\n"); + cmd.cast_fuse().args(["from-rlp", "0xc161"]).assert_success().stdout_eq("[\"0x61\"]\n"); + cmd.cast_fuse().args(["from-rlp", "820002"]).assert_success().stdout_eq("\"0x0002\"\n"); + cmd.cast_fuse().args(["from-rlp", "00"]).assert_success().stdout_eq("\"0x00\"\n"); + cmd.cast_fuse().args(["to-rlp", "[]"]).assert_success().stdout_eq("0xc0\n"); + cmd.cast_fuse().args(["to-rlp", "0x22"]).assert_success().stdout_eq("0x22\n"); + cmd.cast_fuse().args(["to-rlp", "[\"0x61\"]"]).assert_success().stdout_eq("0xc161\n"); + cmd.cast_fuse() + .args(["to-rlp", "[\"0xf1\", \"f2\"]"]) + .assert_success() + .stdout_eq("0xc481f181f2\n"); +} + +#[casttest] +fn to_bytes_memory(cmd: _) { + cmd.args(["to-bytes-memory", "0x1234"]).assert_success().stdout_eq(str![[r#" +0x00000000000000000000000000000000000000000000000000000000000000021234000000000000000000000000000000000000000000000000000000000000 + +"#]]); +} + +#[casttest] +fn to_bytes_memory_alias_from_stdin(cmd: _) { + cmd.arg("tbm").stdin("0x1234\n").assert_success().stdout_eq(str![[r#" +0x00000000000000000000000000000000000000000000000000000000000000021234000000000000000000000000000000000000000000000000000000000000 + +"#]]); +} + +// tests that `cast --to-base` commands are working correctly. +#[casttest] +fn to_base(cmd: _) { + // One value per distinct code path (small positive, u256 max in decimal and + // hex form, small negative, i256 min) to keep the number of spawned `cast` + // processes low and avoid timing out on slow Windows/macOS/ARM CI runners. + let values = [ + "1", + "115792089237316195423570985008687907853269984665640564039457584007913129639935", + "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "-1", + "-57896044618658097711785492504343953926634992332820282019728792003956564819968", + ]; + for value in values { + for subcmd in ["--to-base", "--to-hex", "--to-dec"] { + if subcmd == "--to-base" { + for base in ["bin", "oct", "dec", "hex"] { + cmd.cast_fuse().args([subcmd, value, base]); + assert!(!cmd.assert_success().get_output().stdout_lossy().trim().is_empty()); + } + } else { + cmd.cast_fuse().args([subcmd, value]); + assert!(!cmd.assert_success().get_output().stdout_lossy().trim().is_empty()); + } + } + } + cmd.cast_fuse() + .args(["to-base", "100", "16", "--base-in", "10"]) + .assert_success() + .stdout_eq("0x64\n"); + cmd.cast_fuse() + .args(["to-base", "100", "oct", "--base-in", "10"]) + .assert_success() + .stdout_eq("0o144\n"); + cmd.cast_fuse() + .args(["to-base", "100", "binary", "--base-in", "10"]) + .assert_success() + .stdout_eq("0b1100100\n"); + cmd.cast_fuse() + .args(["to-base", "0xffffffffffffffff", "10"]) + .assert_success() + .stdout_eq("18446744073709551615\n"); + cmd.cast_fuse() + .args(["to-base", "0xffffffffffffffffffffffffffffffff", "dec"]) + .assert_success() + .stdout_eq("340282366920938463463374607431768211455\n"); + cmd.cast_fuse() + .args([ + "to-base", + "0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "decimal", + ]) + .assert_success() + .stdout_eq( + "57896044618658097711785492504343953926634992332820282019728792003956564819967\n", + ); + cmd.cast_fuse() + .args(["to-dec", "--base-in", "10", "garbage"]) + .assert_failure() + .stderr_eq("Error: invalid digit: g\n"); + cmd.cast_fuse() + .args(["to-hex", "0x10000000000000000000000000000000000000000000000000000000000000000"]) + .assert_failure() + .stderr_eq("Error: the value is too large to fit the target type\n"); +} + +// tests that `cast --parse-bytes32-address` command is working correctly. +#[casttest] +fn parse_bytes32_address(cmd: _) { + cmd.args([ + "--parse-bytes32-address", + "0x000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa96045", + ]) + .assert_success() + .stdout_eq(str![[r#" +0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 + +"#]]); +} + +#[casttest] +fn index7201(cmd: _) { + cmd.args(["index-erc7201", "example.main"]).assert_success().stdout_eq(str![[r#" +0x183a6125c38840424c4a85fa12bab2ab606c4b6d0e7cc73c0c06ba5300eab500 + +"#]]); +} + +#[casttest] +fn index7201_unknown_formula_id(cmd: _) { + cmd.args(["index-erc7201", "test", "--formula-id", "unknown"]).assert_failure().stderr_eq( + str![[r#" +Error: unsupported formula ID: unknown + +"#]], + ); +} + +#[casttest] +fn hash_message(cmd: _) { + cmd.args(["hash-message", "hello"]).assert_success().stdout_eq(str![[r#" +0x50b2c43fd39106bafbba0da34fc430e1f91e3c96ea2acee2bc34119f92b37750 + +"#]]); + + cmd.cast_fuse().args(["hash-message", "0x68656c6c6f"]).assert_success().stdout_eq(str![[r#" +0x83a0870b6c63a71efdd3b2749ef700653d97454152c4b53fa9b102dc430c7c32 + +"#]]); +} + +#[casttest] +fn parse_units(cmd: _) { + cmd.args(["parse-units", "1.5", "6"]).assert_success().stdout_eq(str![[r#" +1500000 + +"#]]); + + cmd.cast_fuse().args(["pun", "1.23", "18"]).assert_success().stdout_eq(str![[r#" +1230000000000000000 + +"#]]); + + cmd.cast_fuse().args(["--parse-units", "1.23", "3"]).assert_success().stdout_eq(str![[r#" +1230 + +"#]]); + cmd.cast_fuse().args(["parse-units", "1.0", "6"]).assert_success().stdout_eq("1000000\n"); + cmd.cast_fuse().args(["parse-units", "2.5", "6"]).assert_success().stdout_eq("2500000\n"); + cmd.cast_fuse() + .args(["parse-units", "1.0", "12"]) + .assert_success() + .stdout_eq("1000000000000\n"); +} + +#[casttest] +fn format_units(cmd: _) { + cmd.args(["format-units", "1000000", "6"]).assert_success().stdout_eq(str![[r#" +1 + +"#]]); + + cmd.cast_fuse().args(["--format-units", "2500000", "6"]).assert_success().stdout_eq(str![[ + r#" +2.500000 + +"# + ]]); + + cmd.cast_fuse().args(["fun", "1230", "3"]).assert_success().stdout_eq(str![[r#" +1.230 + +"#]]); + + // Negative values must round-trip correctly instead of wrapping to a huge unsigned garbage + // value (regression test). + cmd.cast_fuse().args(["format-units", "--", "-1000000", "6"]).assert_success().stdout_eq(str![ + [r#" +-1 + +"#] + ]); + cmd.cast_fuse().args(["format-units", "1000000000000", "12"]).assert_success().stdout_eq("1\n"); +} + +// negative wei/unit values must round-trip +// through from-wei/format-units instead of silently wrapping to U256::MAX-derived garbage. +#[casttest] +fn from_wei_negative(cmd: _) { + cmd.args(["from-wei", "--", "-1000000000000000000"]).assert_success().stdout_eq(str![[r#" +-1.000000000000000000 + +"#]]); + + // Round-trip against the documented inverse command. + cmd.cast_fuse().args(["to-wei", "-1", "ether"]).assert_success().stdout_eq(str![[r#" +-1000000000000000000 + +"#]]); + + cmd.cast_fuse().args(["from-wei", "--", "-1000000000000000000"]).assert_success().stdout_eq( + str![[r#" +-1.000000000000000000 + +"#]], + ); + + // In-binary oracle: to-fixed-point performs the same underlying arithmetic and must agree. + cmd.cast_fuse() + .args(["to-fixed-point", "18", "--", "-1000000000000000000"]) + .assert_success() + .stdout_eq(str![[r#" +-1.000000000000000000 + +"#]]); + cmd.cast_fuse().args(["from-wei", "1", "gwei"]).assert_success().stdout_eq("0.000000001\n"); + cmd.cast_fuse() + .args(["from-wei", "12340000005", "gwei"]) + .assert_success() + .stdout_eq("12.340000005\n"); + cmd.cast_fuse() + .args(["from-wei", "10", "ether"]) + .assert_success() + .stdout_eq("0.000000000000000010\n"); + cmd.cast_fuse() + .args(["from-wei", "100", "eth"]) + .assert_success() + .stdout_eq("0.000000000000000100\n"); + cmd.cast_fuse() + .args(["from-wei", "17", "ether"]) + .assert_success() + .stdout_eq("0.000000000000000017\n"); +} + +// A negative magnitude whose absolute value exceeds I256::MIN (2^255) cannot be represented as a +// signed 256-bit integer -- must error cleanly, not silently reinterpret as a small positive +// value (regression test for a review finding on the negative-value fix above). +#[casttest] +fn from_wei_rejects_magnitude_beyond_i256_range(cmd: _) { + cmd.args([ + "from-wei", + "--", + "-57896044618658097711785492504343953926634992332820282019728792003956564819969", + "wei", + ]) + .assert_failure() + .stderr_eq(str![[r#" +Error: value out of range for a signed 256-bit integer + +"#]]); +} + +#[casttest] +fn keccak_stdin_bytes(cmd: _) { + cmd.args(["keccak"]).stdin("0x12").assert_success().stdout_eq(str![[r#" +0x5fa2358263196dbbf23d1ca7a509451f7a2f64c15837bfbb81298b1e3e24e4fa + +"#]]); +} + +#[casttest] +fn keccak_stdin_bytes_with_newline(cmd: _) { + cmd.args(["keccak"]).stdin("0x12\n").assert_success().stdout_eq(str![[r#" +0x5fa2358263196dbbf23d1ca7a509451f7a2f64c15837bfbb81298b1e3e24e4fa + +"#]]); +} + +#[casttest] +fn max_int(cmd: _) { + cmd.cast_fuse().args(["max-int", "int32"]).assert_success().stdout_eq("2147483647\n"); + cmd.cast_fuse().args(["max-uint", "uint256"]).assert_success().stdout_eq( + "115792089237316195423570985008687907853269984665640564039457584007913129639935\n", + ); + cmd.cast_fuse().args(["max-int", "int256"]).assert_success().stdout_eq( + "57896044618658097711785492504343953926634992332820282019728792003956564819967\n", + ); +} + +#[casttest] +fn min_int(cmd: _) { + cmd.cast_fuse().args(["min-int", "int32"]).assert_success().stdout_eq("-2147483648\n"); + cmd.cast_fuse().args(["min-int", "uint256"]).assert_success().stdout_eq("0\n"); + cmd.cast_fuse().args(["min-int", "int256"]).assert_success().stdout_eq( + "-57896044618658097711785492504343953926634992332820282019728792003956564819968\n", + ); +} + +#[casttest] +fn from_utf8(cmd: _) { + cmd.cast_fuse().args(["from-utf8", "你好"]).assert_success().stdout_eq("0xe4bda0e5a5bd\n"); + cmd.cast_fuse().args(["from-utf8", "yo"]).assert_success().stdout_eq("0x796f\n"); + cmd.cast_fuse() + .args(["from-utf8", "Hello, World!"]) + .assert_success() + .stdout_eq("0x48656c6c6f2c20576f726c6421\n"); + cmd.cast_fuse() + .args(["from-utf8", "TurboDappTools"]) + .assert_success() + .stdout_eq("0x547572626f44617070546f6f6c73\n"); +} + +#[casttest] +fn to_utf8(cmd: _) { + cmd.cast_fuse().args(["to-utf8", "0xe4bda0e5a5bd"]).assert_success().stdout_eq("你好\n"); + cmd.cast_fuse().args(["to-utf8", "0xff"]).assert_success().stdout_eq("�\n"); + cmd.cast_fuse().args(["to-utf8", "0x796f"]).assert_success().stdout_eq("yo\n"); + cmd.cast_fuse() + .args(["to-utf8", "0x48656c6c6f2c20576f726c6421"]) + .assert_success() + .stdout_eq("Hello, World!\n"); + cmd.cast_fuse() + .args(["to-utf8", "0x547572626f44617070546f6f6c73"]) + .assert_success() + .stdout_eq("TurboDappTools\n"); +} + +#[casttest] +fn to_ascii(cmd: _) { + cmd.cast_fuse().args(["to-ascii", "0x796f"]).assert_success().stdout_eq("yo\n"); + cmd.cast_fuse() + .args(["to-ascii", "48656c6c6f2c20576f726c6421"]) + .assert_success() + .stdout_eq("Hello, World!\n"); + cmd.cast_fuse() + .args(["to-ascii", "0x547572626f44617070546f6f6c73"]) + .assert_success() + .stdout_eq("TurboDappTools\n"); +} + +#[casttest] +fn from_fixed_point(cmd: _) { + cmd.cast_fuse().args(["from-fixed-point", "3", "0.010"]).assert_success().stdout_eq("10\n"); + cmd.cast_fuse().args(["from-fixed-point", "0", "10"]).assert_success().stdout_eq("10\n"); + cmd.cast_fuse().args(["from-fixed-point", "1", "1.0"]).assert_success().stdout_eq("10\n"); + cmd.cast_fuse().args(["from-fixed-point", "2", "0.10"]).assert_success().stdout_eq("10\n"); +} + +#[casttest] +fn concat_hex(cmd: _) { + cmd.cast_fuse().args(["concat-hex", "0x00", "0x01"]).assert_success().stdout_eq("0x0001\n"); + cmd.cast_fuse().args(["concat-hex", "1", "2"]).assert_success().stdout_eq("0x12\n"); +} + +#[casttest] +fn to_uint256(cmd: _) { + cmd.cast_fuse() + .args(["to-uint256", "100"]) + .assert_success() + .stdout_eq("0x0000000000000000000000000000000000000000000000000000000000000064\n"); + cmd.cast_fuse() + .args(["to-uint256", "192038293923"]) + .assert_success() + .stdout_eq("0x0000000000000000000000000000000000000000000000000000002cb65fd1a3\n"); + cmd.cast_fuse() + .args([ + "to-uint256", + "115792089237316195423570985008687907853269984665640564039457584007913129639935", + ]) + .assert_success() + .stdout_eq("0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\n"); +} + +#[casttest] +fn to_int256(cmd: _) { + cmd.cast_fuse() + .args(["to-int256", "100"]) + .assert_success() + .stdout_eq("0x0000000000000000000000000000000000000000000000000000000000000064\n"); + cmd.cast_fuse() + .args(["to-int256", "0"]) + .assert_success() + .stdout_eq("0x0000000000000000000000000000000000000000000000000000000000000000\n"); + cmd.cast_fuse() + .args(["to-int256", "--", "-100"]) + .assert_success() + .stdout_eq("0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff9c\n"); + cmd.cast_fuse() + .args(["to-int256", "192038293923"]) + .assert_success() + .stdout_eq("0x0000000000000000000000000000000000000000000000000000002cb65fd1a3\n"); + cmd.cast_fuse() + .args(["to-int256", "--", "-192038293923"]) + .assert_success() + .stdout_eq("0xffffffffffffffffffffffffffffffffffffffffffffffffffffffd349a02e5d\n"); + cmd.cast_fuse() + .args([ + "to-int256", + "57896044618658097711785492504343953926634992332820282019728792003956564819967", + ]) + .assert_success() + .stdout_eq("0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff\n"); + cmd.cast_fuse() + .args([ + "to-int256", + "--", + "-57896044618658097711785492504343953926634992332820282019728792003956564819968", + ]) + .assert_success() + .stdout_eq("0x8000000000000000000000000000000000000000000000000000000000000000\n"); +} + +#[casttest] +fn to_fixed_point(cmd: _) { + cmd.cast_fuse().args(["to-fixed-point", "2", "10"]).assert_success().stdout_eq("0.10\n"); + cmd.cast_fuse().args(["to-fixed-point", "3", "-10"]).assert_success().stdout_eq("-0.010\n"); + cmd.cast_fuse().args(["to-fixed-point", "0", "10"]).assert_success().stdout_eq("10.\n"); + cmd.cast_fuse().args(["to-fixed-point", "1", "10"]).assert_success().stdout_eq("1.0\n"); + cmd.cast_fuse().args(["to-fixed-point", "3", "10"]).assert_success().stdout_eq("0.010\n"); + cmd.cast_fuse().args(["to-fixed-point", "0", "-10"]).assert_success().stdout_eq("-10.\n"); + cmd.cast_fuse().args(["to-fixed-point", "1", "-10"]).assert_success().stdout_eq("-1.0\n"); + cmd.cast_fuse().args(["to-fixed-point", "2", "-10"]).assert_success().stdout_eq("-0.10\n"); +} + +#[casttest] +fn to_fixed_point_overflow_18446744073709551616(cmd: _) { + cmd.args(["to-fixed-point", "18446744073709551616", "10"]) + .assert_failure() + .stderr_eq("Error: decimals out of range: 18446744073709551616\n"); +} + +#[casttest] +fn to_fixed_point_overflow_70000(cmd: _) { + cmd.args(["to-fixed-point", "70000", "10"]) + .assert_failure() + .stderr_eq("Error: decimals out of range: 70000\n"); +} + +#[casttest] +fn to_fixed_point_overflow_65536(cmd: _) { + cmd.args(["to-fixed-point", "65536", "10"]) + .assert_failure() + .stderr_eq("Error: decimals out of range: 65536\n"); +} + +#[casttest] +fn to_unit(cmd: _) { + cmd.cast_fuse() + .args(["to-unit", "1ether", "wei"]) + .assert_success() + .stdout_eq("1000000000000000000\n"); + cmd.cast_fuse().args(["to-unit", "1 wei", "wei"]).assert_success().stdout_eq("1\n"); + cmd.cast_fuse().args(["to-unit", "1", "wei"]).assert_success().stdout_eq("1\n"); +} + +#[casttest] +fn to_wei(cmd: _) { + cmd.cast_fuse().args(["to-wei", "100", "gwei"]).assert_success().stdout_eq("100000000000\n"); + cmd.cast_fuse() + .args(["to-wei", "100", "eth"]) + .assert_success() + .stdout_eq("100000000000000000000\n"); + cmd.cast_fuse() + .args(["to-wei", "1000", "ether"]) + .assert_success() + .stdout_eq("1000000000000000000000\n"); +} + +#[casttest] +fn from_rlp_long(cmd: _) { + cmd.cast_fuse().args(["from-rlp", "0xf8b1a02b5df5f0757397573e8ff34a8b987b21680357de1f6c8d10273aa528a851eaca8080a02838ac1d2d2721ba883169179b48480b2ba4f43d70fcf806956746bd9e83f90380a0e46fff283b0ab96a32a7cc375cecc3ed7b6303a43d64e0a12eceb0bc6bd8754980a01d818c1c414c665a9c9a0e0c0ef1ef87cacb380b8c1f6223cb2a68a4b2d023f5808080a0236e8f61ecde6abfebc6c529441f782f62469d8a2cc47b7aace2c136bd3b1ff08080808080"]).assert_success().stdout_eq("[\"0x2b5df5f0757397573e8ff34a8b987b21680357de1f6c8d10273aa528a851eaca\",\"0x\",\"0x\",\"0x2838ac1d2d2721ba883169179b48480b2ba4f43d70fcf806956746bd9e83f903\",\"0x\",\"0xe46fff283b0ab96a32a7cc375cecc3ed7b6303a43d64e0a12eceb0bc6bd87549\",\"0x\",\"0x1d818c1c414c665a9c9a0e0c0ef1ef87cacb380b8c1f6223cb2a68a4b2d023f5\",\"0x\",\"0x\",\"0x\",\"0x236e8f61ecde6abfebc6c529441f782f62469d8a2cc47b7aace2c136bd3b1ff0\",\"0x\",\"0x\",\"0x\",\"0x\",\"0x\"]\n"); +} + +#[casttest] +fn to_base_uppercase(cmd: _) { + cmd.cast_fuse().args(["to-dec", "0B10"]).assert_success().stdout_eq("2\n"); + cmd.cast_fuse().args(["to-dec", "0O10"]).assert_success().stdout_eq("8\n"); + cmd.cast_fuse().args(["to-dec", "0X10"]).assert_success().stdout_eq("16\n"); + cmd.cast_fuse().args(["to-dec", "-0X10"]).assert_success().stdout_eq("-16\n"); +} + +#[casttest] +fn to_bytes32(cmd: _) { + cmd.cast_fuse() + .args(["to-bytes32", "0x1234"]) + .assert_success() + .stdout_eq("0x1234000000000000000000000000000000000000000000000000000000000000\n"); + cmd.cast_fuse() + .args(["to-bytes32", "1234"]) + .assert_success() + .stdout_eq("0x1234000000000000000000000000000000000000000000000000000000000000\n"); +} + +#[casttest] +fn to_bytes32_too_long(cmd: _) { + cmd.args(["to-bytes32", "000000000000000000000000000000000000000000000000000000000000000000"]) + .assert_failure() + .stderr_eq("Error: string >32 bytes\n"); +} + +#[casttest] +fn to_bytes_memory_boundaries(cmd: _) { + cmd.cast_fuse() + .args(["to-bytes-memory", "0x"]) + .assert_success() + .stdout_eq("0x0000000000000000000000000000000000000000000000000000000000000000\n"); + cmd.cast_fuse().args(["to-bytes-memory", "0xababababababababababababababababababababababababababababababab"]).assert_success().stdout_eq("0x000000000000000000000000000000000000000000000000000000000000001fababababababababababababababababababababababababababababababab00\n"); + cmd.cast_fuse().args(["to-bytes-memory", "0xabababababababababababababababababababababababababababababababab"]).assert_success().stdout_eq("0x0000000000000000000000000000000000000000000000000000000000000020abababababababababababababababababababababababababababababababab\n"); + cmd.cast_fuse().args(["to-bytes-memory", "0xababababababababababababababababababababababababababababababababab"]).assert_success().stdout_eq("0x0000000000000000000000000000000000000000000000000000000000000021ababababababababababababababababababababababababababababababababab00000000000000000000000000000000000000000000000000000000000000\n"); +} + +#[casttest] +fn format_bytes32_string(cmd: _) { + cmd.cast_fuse() + .args(["format-bytes32-string", "hello"]) + .assert_success() + .stdout_eq("0x68656c6c6f000000000000000000000000000000000000000000000000000000\n"); +} + +#[casttest] +fn pad(cmd: _) { + cmd.cast_fuse() + .args(["pad", "abcd", "--len", "20"]) + .assert_success() + .stdout_eq("0x000000000000000000000000000000000000abcd\n"); + cmd.cast_fuse() + .args(["pad", "abcd", "--len", "20", "--right"]) + .assert_success() + .stdout_eq("0xabcd000000000000000000000000000000000000\n"); + cmd.cast_fuse() + .args(["pad", "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", "--len", "32"]) + .assert_success() + .stdout_eq("0x000000000000000000000000C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2\n"); + cmd.cast_fuse() + .args(["pad", "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", "--len", "32", "--right"]) + .assert_success() + .stdout_eq("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2000000000000000000000000\n"); +} + +#[casttest] +fn pad_overflow(cmd: _) { + cmd.args(["pad", "abcd", "--len", "32768"]) + .assert_failure() + .stderr_eq("Error: len out of range: 32768\n"); +} + +#[casttest] +fn parse_bytes32_string(cmd: _) { + cmd.cast_fuse() + .args([ + "parse-bytes32-string", + "0x68656c6c6f000000000000000000000000000000000000000000000000000000", + ]) + .assert_success() + .stdout_eq("hello\n"); +} + +#[casttest] +fn left_shift(cmd: _) { + cmd.cast_fuse().args(["shl", "16", "1"]).assert_success().stdout_eq("0x20\n"); + cmd.cast_fuse() + .args(["shl", "16", "10", "--base-in", "10", "--base-out", "hex"]) + .assert_success() + .stdout_eq("0x4000\n"); + cmd.cast_fuse() + .args(["shl", "255", "16", "--base-in", "dec", "--base-out", "hex"]) + .assert_success() + .stdout_eq("0xff0000\n"); + cmd.cast_fuse() + .args(["shl", "0xff", "16", "--base-out", "hex"]) + .assert_success() + .stdout_eq("0xff0000\n"); +} + +#[casttest] +fn right_shift(cmd: _) { + cmd.cast_fuse().args(["shr", "16", "1"]).assert_success().stdout_eq("0x8\n"); + cmd.cast_fuse() + .args(["shr", "0x4000", "10", "--base-out", "dec"]) + .assert_success() + .stdout_eq("16\n"); + cmd.cast_fuse() + .args(["shr", "16711680", "16", "--base-in", "10", "--base-out", "hex"]) + .assert_success() + .stdout_eq("0xff\n"); + cmd.cast_fuse() + .args(["shr", "0xff0000", "16", "--base-out", "hex"]) + .assert_success() + .stdout_eq("0xff\n"); +} + +#[casttest] +fn pad_rejects_length_overflow(cmd: _) { + let len = usize::MAX.to_string(); + cmd.args(["pad", "abcd", "--len", &len]) + .assert_failure() + .stderr_eq(format!("Error: len out of range: {len}\n")); +} + +#[casttest] +fn to_bytes_memory_rejects_odd_hex(cmd: _) { + cmd.args(["to-bytes-memory", "0x1"]) + .assert_failure() + .stderr_eq("Error: Could not decode hex\n\nContext:\n- odd number of digits\n"); +} + +#[casttest] +fn from_rlp_rejects_noncanonical_integers(cmd: _) { + for value in ["820002", "00"] { + cmd.cast_fuse() + .args(["from-rlp", value, "--as-int"]) + .assert_failure() + .stderr_eq("Error: leading zero\n"); + } +} + +#[casttest] +fn pad_rejects_invalid_input(cmd: _) { + cmd.cast_fuse() + .args(["pad", "1234", "--len", "1"]) + .assert_failure() + .stderr_eq("Error: input length exceeds target length\n"); + cmd.cast_fuse() + .args(["pad", "foobar", "--len", "32"]) + .assert_failure() + .stderr_eq("Error: input is not a valid hex\n"); +} + +#[casttest] +fn keccak_text(cmd: _) { + cmd.cast_fuse() + .args(["keccak", "foo"]) + .assert_success() + .stdout_eq("0x41b1a0649752af1b28b3dc29a1556eee781e4a4c3a1f7f53f90fa834de098c4d\n"); + cmd.cast_fuse() + .args(["keccak", "123abc"]) + .assert_success() + .stdout_eq("0xb1f1c74a1ba56f07a892ea1110a39349d40f66ca01d245e704621033cb7046a4\n"); + cmd.cast_fuse() + .args(["keccak", "12"]) + .assert_success() + .stdout_eq("0x7f8b6b088b6d74c2852fc86c796dca07b44eed6fb3daf5e6b59f7c364db14528\n"); +} diff --git a/crates/cast/tests/cli/ens.rs b/crates/cast/tests/cli/ens.rs new file mode 100644 index 0000000000000..09506f95c7399 --- /dev/null +++ b/crates/cast/tests/cli/ens.rs @@ -0,0 +1,52 @@ +//! CLI tests for ens commands. + +use super::*; + +#[casttest] +fn ens_namehash(cmd: _) { + cmd.args(["namehash", "emo.eth"]).assert_success().stdout_eq(str![[r#" +0x0a21aaf2f6414aa664deb341d1114351fdb023cad07bf53b28e57c26db681910 + +"#]]); +} + +#[casttest] +fn ens_lookup(cmd: _) { + let eth_rpc_url = next_http_rpc_endpoint(); + cmd.args([ + "lookup-address", + "0x28679A1a632125fbBf7A68d850E50623194A709E", + "--rpc-url", + ð_rpc_url, + "--verify", + ]) + .assert_success() + .stdout_eq(str![[r#" +emo.eth + +"#]]); +} + +#[casttest] +fn ens_resolve(cmd: _) { + let eth_rpc_url = next_http_rpc_endpoint(); + cmd.args(["resolve-name", "emo.eth", "--rpc-url", ð_rpc_url, "--verify"]) + .assert_success() + .stdout_eq(str![[r#" +0x28679A1a632125fbBf7A68d850E50623194A709E + +"#]]); +} + +#[casttest] +fn ens_resolve_no_dot_eth(cmd: _) { + let eth_rpc_url = next_http_rpc_endpoint(); + cmd.args(["resolve-name", "emo", "--rpc-url", ð_rpc_url, "--verify"]) + .assert_failure() + .stderr_eq(str![[r#" +Error: Failed to resolve ENS name: emo + +Context: +- [..] +"#]]); +} diff --git a/crates/cast/tests/cli/erc20.rs b/crates/cast/tests/cli/erc20.rs new file mode 100644 index 0000000000000..91a8cea0cf161 --- /dev/null +++ b/crates/cast/tests/cli/erc20.rs @@ -0,0 +1,998 @@ +//! Contains various tests for checking cast erc20 subcommands + +use alloy_primitives::U256; +use anvil::{NodeConfig, NodeHandle}; +use foundry_test_utils::{str, util::OutputExt}; + +mod anvil_const { + /// First Anvil account + pub const PK1: &str = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + pub const ADDR1: &str = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; + + /// Second Anvil account + pub const _PK2: &str = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"; + pub const ADDR2: &str = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; + + /// Contract address deploying from ADDR1 with nonce 0 + pub const TOKEN: &str = "0x5FbDB2315678afecb367f032d93F642f64180aa3"; +} + +fn get_u256_from_cmd(cmd: &mut foundry_test_utils::TestCommand, args: &[&str]) -> U256 { + let output = cmd.cast_fuse().args(args).assert_success().get_output().stdout_lossy(); + + // Parse balance from output (format: "100000000000000000000 [1e20]") + output.split_whitespace().next().unwrap().parse().unwrap() +} + +fn get_balance( + cmd: &mut foundry_test_utils::TestCommand, + token: &str, + address: &str, + rpc: &str, +) -> U256 { + get_u256_from_cmd(cmd, &["erc20", "balance", token, address, "--rpc-url", rpc]) +} + +fn get_allowance( + cmd: &mut foundry_test_utils::TestCommand, + token: &str, + owner: &str, + spender: &str, + rpc: &str, +) -> U256 { + get_u256_from_cmd(cmd, &["erc20", "allowance", token, owner, spender, "--rpc-url", rpc]) +} + +/// Helper function to deploy TestToken contract +fn deploy_test_token( + cmd: &mut foundry_test_utils::TestCommand, + rpc: &str, + private_key: &str, +) -> String { + cmd.args([ + "create", + "--private-key", + private_key, + "--rpc-url", + rpc, + "--broadcast", + "src/TestToken.sol:TestToken", + ]) + .assert_success(); + + // Return the standard deployment address (nonce 0 from first account) + anvil_const::TOKEN.to_string() +} + +/// Helper to setup anvil node and deploy test token +async fn setup_token_test( + prj: &foundry_test_utils::TestProject, + cmd: &mut foundry_test_utils::TestCommand, +) -> (String, String, NodeHandle) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + let rpc = handle.http_endpoint(); + + // Deploy TestToken contract + foundry_test_utils::util::initialize(prj.root()); + prj.add_source("TestToken.sol", include_str!("../fixtures/TestToken.sol")); + let token = deploy_test_token(cmd, &rpc, anvil_const::PK1); + + (rpc, token, handle) +} + +#[casttest] +async fn erc20_balance_applies_call_overrides(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + let rpc = handle.http_endpoint(); + let token = "0x00000000000000000000000000000000000000aa"; + + cmd.cast_fuse() + .args([ + "erc20-token", + "balance", + token, + anvil_const::ADDR1, + "--block", + "latest", + "--override-code", + // Runtime that returns the current block number. + &format!("{token}:0x4360005260206000f3"), + "--block.number", + "1234", + "--rpc-url", + &rpc, + ]) + .assert_success() + .stdout_eq(str![[r#" +1234 + +"#]]); +} + +#[casttest] +async fn deprecated_erc20_balance_applies_call_overrides(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + let rpc = handle.http_endpoint(); + let token = "0x00000000000000000000000000000000000000aa"; + + cmd.cast_fuse() + .args([ + "balance", + anvil_const::ADDR1, + "--erc20", + token, + "--block", + "latest", + "--override-code", + // Runtime that returns the current block number. + &format!("{token}:0x4360005260206000f3"), + "--block.number", + "1234", + "--rpc-url", + &rpc, + ]) + .assert_success() + .stdout_eq(str![[r#" +1234 + +"#]]); +} + +#[casttest] +fn native_balance_rejects_call_overrides(cmd: _) { + cmd.cast_fuse() + .args([ + "balance", + anvil_const::ADDR1, + "--block.number", + "1234", + "--rpc-url", + "http://127.0.0.1:1", + ]) + .assert_failure() + .stderr_eq(str![[r#" +Error: call overrides require `--erc20` when using `cast balance` + +"#]]); +} + +// tests that `balance` and `transfer` commands works correctly +#[forgetest] +async fn erc20_transfer_approve_success(prj: _, cmd: _) { + let (rpc, token, _handle) = setup_token_test(&prj, &mut cmd).await; + + // Test constants + let transfer_amount = U256::from(100_000_000_000_000_000_000u128); // 100 tokens (18 decimals) + let initial_supply = U256::from(1_000_000_000_000_000_000_000u128); // 1000 tokens total supply + + // Verify initial balances + let addr1_balance_before = get_balance(&mut cmd, &token, anvil_const::ADDR1, &rpc); + let addr2_balance_before = get_balance(&mut cmd, &token, anvil_const::ADDR2, &rpc); + assert_eq!(addr1_balance_before, initial_supply); + assert_eq!(addr2_balance_before, U256::ZERO); + + // Test ERC20 transfer from ADDR1 to ADDR2 + cmd.cast_fuse() + .args([ + "erc20", + "transfer", + &token, + anvil_const::ADDR2, + &transfer_amount.to_string(), + "--rpc-url", + &rpc, + "--private-key", + anvil_const::PK1, + ]) + .assert_success(); + + // Verify balance changes after transfer + let addr1_balance_after = get_balance(&mut cmd, &token, anvil_const::ADDR1, &rpc); + let addr2_balance_after = get_balance(&mut cmd, &token, anvil_const::ADDR2, &rpc); + assert_eq!(addr1_balance_after, addr1_balance_before - transfer_amount); + assert_eq!(addr2_balance_after, addr2_balance_before + transfer_amount); +} + +// tests that `approve` and `allowance` commands works correctly +#[forgetest] +async fn erc20_approval_allowance(prj: _, cmd: _) { + let (rpc, token, _handle) = setup_token_test(&prj, &mut cmd).await; + + // ADDR1 approves ADDR2 to spend their tokens + let approve_amount = U256::from(50_000_000_000_000_000_000u128); // 50 tokens + cmd.cast_fuse() + .args([ + "erc20", + "approve", + &token, + anvil_const::ADDR2, + &approve_amount.to_string(), + "--rpc-url", + &rpc, + "--private-key", + anvil_const::PK1, + ]) + .assert_success(); + + // Verify allowance was set + let allowance = get_allowance(&mut cmd, &token, anvil_const::ADDR1, anvil_const::ADDR2, &rpc); + assert_eq!(allowance, approve_amount); +} + +// tests that `name`, `symbol`, `decimals`, and `totalSupply` commands work correctly +#[forgetest] +async fn erc20_metadata_success(prj: _, cmd: _) { + let (rpc, token, _handle) = setup_token_test(&prj, &mut cmd).await; + + // Test name + let output = cmd + .cast_fuse() + .args(["erc20", "name", &token, "--rpc-url", &rpc]) + .assert_success() + .get_output() + .stdout_lossy(); + assert_eq!(output.trim(), "Test Token"); + + // Test symbol + let output = cmd + .cast_fuse() + .args(["erc20", "symbol", &token, "--rpc-url", &rpc]) + .assert_success() + .get_output() + .stdout_lossy(); + assert_eq!(output.trim(), "TEST"); + + // Test decimals + let output = cmd + .cast_fuse() + .args(["erc20", "decimals", &token, "--rpc-url", &rpc]) + .assert_success() + .get_output() + .stdout_lossy(); + assert_eq!(output.trim(), "18"); + + // Test totalSupply + let output = cmd + .cast_fuse() + .args(["erc20", "total-supply", &token, "--rpc-url", &rpc]) + .assert_success() + .get_output() + .stdout_lossy(); + let total_supply: U256 = output.split_whitespace().next().unwrap().parse().unwrap(); + assert_eq!(total_supply, U256::from(1_000_000_000_000_000_000_000u128)); +} + +// tests that `mint` command works correctly +#[forgetest] +async fn erc20_mint_success(prj: _, cmd: _) { + let (rpc, token, _handle) = setup_token_test(&prj, &mut cmd).await; + + let mint_amount = U256::from(500_000_000_000_000_000_000u128); // 500 tokens + let initial_supply = U256::from(1_000_000_000_000_000_000_000u128); // 1000 tokens + + // Get initial balance and supply + let addr2_balance_before = get_balance(&mut cmd, &token, anvil_const::ADDR2, &rpc); + assert_eq!(addr2_balance_before, U256::ZERO); + + // Mint tokens to ADDR2 (only owner can mint) + cmd.cast_fuse() + .args([ + "erc20", + "mint", + &token, + anvil_const::ADDR2, + &mint_amount.to_string(), + "--rpc-url", + &rpc, + "--private-key", + anvil_const::PK1, // PK1 is the owner/deployer + ]) + .assert_success(); + + // Verify balance increased + let addr2_balance_after = get_balance(&mut cmd, &token, anvil_const::ADDR2, &rpc); + assert_eq!(addr2_balance_after, mint_amount); + + // Verify totalSupply increased + let output = cmd + .cast_fuse() + .args(["erc20", "total-supply", &token, "--rpc-url", &rpc]) + .assert_success() + .get_output() + .stdout_lossy(); + let total_supply: U256 = output.split_whitespace().next().unwrap().parse().unwrap(); + assert_eq!(total_supply, initial_supply + mint_amount); +} + +// tests that `burn` command works correctly +#[forgetest] +async fn erc20_burn_success(prj: _, cmd: _) { + let (rpc, token, _handle) = setup_token_test(&prj, &mut cmd).await; + + let burn_amount = U256::from(200_000_000_000_000_000_000u128); // 200 tokens + let initial_supply = U256::from(1_000_000_000_000_000_000_000u128); // 1000 tokens + + // Get initial balance + let addr1_balance_before = get_balance(&mut cmd, &token, anvil_const::ADDR1, &rpc); + assert_eq!(addr1_balance_before, initial_supply); + + // Burn tokens from ADDR1 + cmd.cast_fuse() + .args([ + "erc20", + "burn", + &token, + &burn_amount.to_string(), + "--rpc-url", + &rpc, + "--private-key", + anvil_const::PK1, + ]) + .assert_success(); + + // Verify balance decreased + let addr1_balance_after = get_balance(&mut cmd, &token, anvil_const::ADDR1, &rpc); + assert_eq!(addr1_balance_after, addr1_balance_before - burn_amount); + + // Verify totalSupply decreased + let output = cmd + .cast_fuse() + .args(["erc20", "total-supply", &token, "--rpc-url", &rpc]) + .assert_success() + .get_output() + .stdout_lossy(); + let total_supply: U256 = output.split_whitespace().next().unwrap().parse().unwrap(); + assert_eq!(total_supply, initial_supply - burn_amount); +} + +// tests that `transfer` command works with gas options +#[forgetest] +async fn erc20_transfer_with_gas_opts(prj: _, cmd: _) { + let (rpc, token, _handle) = setup_token_test(&prj, &mut cmd).await; + + let transfer_amount = U256::from(100_000_000_000_000_000_000u128); // 100 tokens + + // Transfer with explicit gas limit and gas price + cmd.cast_fuse() + .args([ + "erc20", + "transfer", + &token, + anvil_const::ADDR2, + &transfer_amount.to_string(), + "--rpc-url", + &rpc, + "--private-key", + anvil_const::PK1, + "--gas-limit", + "100000", + "--gas-price", + "2000000000", + ]) + .assert_success(); + + // Verify transfer succeeded + let balance = get_balance(&mut cmd, &token, anvil_const::ADDR2, &rpc); + assert_eq!(balance, transfer_amount); +} + +// tests that `transfer` command fails with insufficient gas limit +#[forgetest] +async fn erc20_transfer_insufficient_gas(prj: _, cmd: _) { + let (rpc, token, _handle) = setup_token_test(&prj, &mut cmd).await; + + let transfer_amount = U256::from(50_000_000_000_000_000_000u128); // 50 tokens + + // Transfer with insufficient gas limit (ERC20 transfer needs ~50k gas) + cmd.cast_fuse() + .args([ + "erc20", + "transfer", + &token, + anvil_const::ADDR2, + &transfer_amount.to_string(), + "--rpc-url", + &rpc, + "--private-key", + anvil_const::PK1, + "--gas-limit", + "1000", // Way too low for ERC20 transfer + ]) + .assert_failure(); + + // Verify transfer did NOT occur + let balance = get_balance(&mut cmd, &token, anvil_const::ADDR2, &rpc); + assert_eq!(balance, U256::ZERO); +} + +// tests that `transfer` command fails with incorrect nonce +#[forgetest] +async fn erc20_transfer_incorrect_nonce(prj: _, cmd: _) { + let (rpc, token, _handle) = setup_token_test(&prj, &mut cmd).await; + + let transfer_amount = U256::from(50_000_000_000_000_000_000u128); // 50 tokens + + cmd.cast_fuse() + .args([ + "erc20", + "transfer", + &token, + anvil_const::ADDR2, + &transfer_amount.to_string(), + "--rpc-url", + &rpc, + "--private-key", + anvil_const::PK1, + ]) + .assert_success(); + + // Transfer with nonce too low + cmd.cast_fuse() + .args([ + "erc20", + "transfer", + &token, + anvil_const::ADDR2, + &transfer_amount.to_string(), + "--rpc-url", + &rpc, + "--private-key", + anvil_const::PK1, + "--nonce", + "0", // Too low nonce + ]) + .assert_failure(); + + // Verify transfer did NOT occur + let balance = get_balance(&mut cmd, &token, anvil_const::ADDR2, &rpc); + assert_eq!(balance, transfer_amount); // 2nd transfer failed +} + +// tests that the --curl flag outputs a valid curl command for cast erc20 balance +#[casttest] +fn curl_erc20_balance(cmd: _) { + let rpc = "https://eth.example.com"; + let token = "0xdead000000000000000000000000000000000000"; + let owner = "0xbeef000000000000000000000000000000000000"; + + let output = cmd + .args(["erc20", "balance", token, owner, "--rpc-url", rpc, "--curl"]) + .assert_success() + .get_output() + .stdout_lossy(); + + // Verify curl command structure + assert!(output.contains("curl -X POST")); + assert!(output.contains("eth_call")); + assert!(output.contains(rpc)); +} + +// tests that the --curl flag outputs a valid curl command for cast erc20 name +#[casttest] +fn curl_erc20_name(cmd: _) { + let rpc = "https://eth.example.com"; + let token = "0xdead000000000000000000000000000000000000"; + + let output = cmd + .args(["erc20", "name", token, "--rpc-url", rpc, "--curl"]) + .assert_success() + .get_output() + .stdout_lossy(); + + // Verify curl command structure + assert!(output.contains("curl -X POST")); + assert!(output.contains("eth_call")); + assert!(output.contains(rpc)); +} + +// tests that the --curl flag outputs a valid curl command for cast erc20 decimals +#[casttest] +fn curl_erc20_decimals(cmd: _) { + let rpc = "https://eth.example.com"; + let token = "0xdead000000000000000000000000000000000000"; + + let output = cmd + .args(["erc20", "decimals", token, "--rpc-url", rpc, "--curl"]) + .assert_success() + .get_output() + .stdout_lossy(); + + // Verify curl command structure + assert!(output.contains("curl -X POST")); + assert!(output.contains("eth_call")); + assert!(output.contains(rpc)); +} + +// tests that the --curl flag outputs a valid curl command for cast erc20 total-supply +#[casttest] +fn curl_erc20_total_supply(cmd: _) { + let rpc = "https://eth.example.com"; + let token = "0xdead000000000000000000000000000000000000"; + + let output = cmd + .args(["erc20", "total-supply", token, "--rpc-url", rpc, "--curl"]) + .assert_success() + .get_output() + .stdout_lossy(); + + // Verify curl command structure + assert!(output.contains("curl -X POST")); + assert!(output.contains("eth_call")); + assert!(output.contains(rpc)); +} + +// tests that the --curl flag outputs a valid curl command for erc20 balance +#[casttest] +fn erc20_curl_balance(cmd: _) { + let rpc = "https://eth.example.com"; + let token = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; // USDC + let owner = "0xdead000000000000000000000000000000000000"; + + let output = cmd + .args(["erc20", "balance", token, owner, "--rpc-url", rpc, "--curl"]) + .assert_success() + .get_output() + .stdout_lossy(); + + // Verify curl command structure + assert!(output.contains("curl -X POST")); + assert!(output.contains("eth_call")); + assert!(output.contains(rpc)); +} + +// tests that the --curl flag outputs a valid curl command for erc20 name +#[casttest] +fn erc20_curl_name(cmd: _) { + let rpc = "https://eth.example.com"; + let token = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; // USDC + + let output = cmd + .args(["erc20", "name", token, "--rpc-url", rpc, "--curl"]) + .assert_success() + .get_output() + .stdout_lossy(); + + // Verify curl command structure + assert!(output.contains("curl -X POST")); + assert!(output.contains("eth_call")); + assert!(output.contains(rpc)); +} + +// tests that the --curl flag outputs a valid curl command for erc20 decimals +#[casttest] +fn erc20_curl_decimals(cmd: _) { + let rpc = "https://eth.example.com"; + let token = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; // USDC + + let output = cmd + .args(["erc20", "decimals", token, "--rpc-url", rpc, "--curl"]) + .assert_success() + .get_output() + .stdout_lossy(); + + // Verify curl command structure + assert!(output.contains("curl -X POST")); + assert!(output.contains("eth_call")); + assert!(output.contains(rpc)); +} + +// tests that the --curl flag outputs a valid curl command for erc20 total-supply +#[casttest] +fn erc20_curl_total_supply(cmd: _) { + let rpc = "https://eth.example.com"; + let token = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; // USDC + + let output = cmd + .args(["erc20", "total-supply", token, "--rpc-url", rpc, "--curl"]) + .assert_success() + .get_output() + .stdout_lossy(); + + // Verify curl command structure + assert!(output.contains("curl -X POST")); + assert!(output.contains("eth_call")); + assert!(output.contains(rpc)); +} + +#[casttest] +fn erc20_transfer_help_includes_tempo_expires(cmd: _) { + let output = + cmd.args(["erc20", "transfer", "--help"]).assert_success().get_output().stdout_lossy(); + + assert!( + output.contains("--tempo.expires "), + "expected erc20 transfer help to expose --tempo.expires, got:\n{output}", + ); +} + +#[forgetest] +async fn erc20_transfer_prints_tempo_sponsor_hash(cmd: _) { + let (_, _handle) = anvil::spawn(NodeConfig::test()).await; + let rpc = _handle.http_endpoint(); + + let output = cmd + .cast_fuse() + .args([ + "erc20", + "transfer", + anvil_const::TOKEN, + anvil_const::ADDR2, + "1", + "--rpc-url", + &rpc, + "--private-key", + anvil_const::PK1, + "--tempo.print-sponsor-hash", + "--nonce", + "0", + "--gas-limit", + "100000", + "--gas-price", + "1", + "--priority-gas-price", + "1", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + let hash = output.trim(); + assert!(hash.starts_with("0x") && hash.len() == 66, "expected sponsor hash, got:\n{output}",); +} + +// tests that `balance` command works correctly with --json flag +#[forgetest] +async fn erc20_balance_json(prj: _, cmd: _) { + let (rpc, token, _handle) = setup_token_test(&prj, &mut cmd).await; + + let output = cmd + .cast_fuse() + .args(["--json", "erc20", "balance", &token, anvil_const::ADDR1, "--rpc-url", &rpc]) + .assert_success() + .get_output() + .stdout_lossy(); + + let v: serde_json::Value = serde_json::from_str(&output).expect("valid json"); + let balance_str = v["data"].as_str().expect("string data"); + let balance: U256 = balance_str.parse().unwrap(); + assert_eq!(balance, U256::from(1_000_000_000_000_000_000_000u128)); +} + +// tests that `allowance` command works correctly with --json flag +#[forgetest] +async fn erc20_allowance_json(prj: _, cmd: _) { + let (rpc, token, _handle) = setup_token_test(&prj, &mut cmd).await; + + // First approve some tokens + let approve_amount = U256::from(50_000_000_000_000_000_000u128); + cmd.cast_fuse() + .args([ + "erc20", + "approve", + &token, + anvil_const::ADDR2, + &approve_amount.to_string(), + "--rpc-url", + &rpc, + "--private-key", + anvil_const::PK1, + ]) + .assert_success(); + + // Check allowance with JSON flag + let output = cmd + .cast_fuse() + .args([ + "--json", + "erc20", + "allowance", + &token, + anvil_const::ADDR1, + anvil_const::ADDR2, + "--rpc-url", + &rpc, + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + let v: serde_json::Value = serde_json::from_str(&output).expect("valid json"); + let allowance_str = v["data"].as_str().expect("string data"); + let allowance: U256 = allowance_str.parse().unwrap(); + assert_eq!(allowance, approve_amount); +} + +// tests that `name`, `symbol`, `decimals`, and `totalSupply` commands work correctly with --json +// flag +#[forgetest] +async fn erc20_metadata_json(prj: _, cmd: _) { + let (rpc, token, _handle) = setup_token_test(&prj, &mut cmd).await; + + // Test name with --json + let output = cmd + .cast_fuse() + .args(["--json", "erc20", "name", &token, "--rpc-url", &rpc]) + .assert_success() + .get_output() + .stdout_lossy(); + let v: serde_json::Value = serde_json::from_str(&output).expect("valid json"); + assert_eq!(v["data"].as_str().expect("string data"), "Test Token"); + + // Test symbol with --json + let output = cmd + .cast_fuse() + .args(["--json", "erc20", "symbol", &token, "--rpc-url", &rpc]) + .assert_success() + .get_output() + .stdout_lossy(); + let v: serde_json::Value = serde_json::from_str(&output).expect("valid json"); + assert_eq!(v["data"].as_str().expect("string data"), "TEST"); + + // Test decimals with --json + let output = cmd + .cast_fuse() + .args(["--json", "erc20", "decimals", &token, "--rpc-url", &rpc]) + .assert_success() + .get_output() + .stdout_lossy(); + let v: serde_json::Value = serde_json::from_str(&output).expect("valid json"); + assert_eq!(v["data"].as_u64().expect("numeric data"), 18); + + // Test totalSupply with --json + let output = cmd + .cast_fuse() + .args(["--json", "erc20", "total-supply", &token, "--rpc-url", &rpc]) + .assert_success() + .get_output() + .stdout_lossy(); + let v: serde_json::Value = serde_json::from_str(&output).expect("valid json"); + let total_supply: U256 = v["data"].as_str().expect("string data").parse().unwrap(); + assert_eq!(total_supply, U256::from(1_000_000_000_000_000_000_000u128)); +} + +async fn setup_permit_test( + prj: &foundry_test_utils::TestProject, + cmd: &mut foundry_test_utils::TestCommand, +) -> (String, String, NodeHandle) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + let rpc = handle.http_endpoint(); + foundry_test_utils::util::initialize(prj.root()); + prj.add_source("TestToken.sol", include_str!("../fixtures/PermitToken.sol")); + cmd.forge_fuse(); + let token = deploy_test_token(cmd, &rpc, anvil_const::PK1); + (rpc, token, handle) +} + +#[casttest] +async fn erc20_permit_sign_and_relay(prj: _, cmd: _) { + let (rpc, token, _handle) = setup_permit_test(&prj, &mut cmd).await; + let output = cmd + .cast_fuse() + .args([ + "erc20", + "permit", + &token, + anvil_const::ADDR2, + "123", + "--deadline", + "4000000000", + "--private-key", + anvil_const::PK1, + "--rpc-url", + &rpc, + "--json", + ]) + .assert_json_stdout_with_status( + true, + str![[r#" +{ + "schema_version": 1, + "success": true, + "data": { + "token": "0x5fbdb2315678afecb367f032d93f642f64180aa3", + "owner": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", + "spender": "0x70997970c51812dc3a010c7d01b50e0d17dc79c8", + "value": "123", + "nonce": "0", + "deadline": "4000000000", + "signature": "[..]", + "calldata": "[..]", + "typed_data": "{...}" + }, + "errors": [], + "warnings": [] +} +"#]], + ); + // Extract calldata for a real submission by another account, not just a snapshot assertion. + let output = serde_json::from_slice::(&output.get_output().stdout).unwrap(); + let calldata = output["data"]["calldata"].as_str().unwrap(); + let signature = output["data"]["signature"].as_str().unwrap(); + cmd.cast_fuse() + .args([ + "erc20", + "permit", + &token, + anvil_const::ADDR2, + "123", + "--deadline", + "4000000000", + "--private-key", + anvil_const::PK1, + "--rpc-url", + &rpc, + ]) + .assert_success() + .stdout_eq(format!("{signature}\n")); + assert_eq!( + get_allowance(&mut cmd, &token, anvil_const::ADDR1, anvil_const::ADDR2, &rpc), + U256::ZERO + ); + cmd.cast_fuse() + .args([ + "send", + &token, + "--data", + calldata, + "--private-key", + anvil_const::_PK2, + "--rpc-url", + &rpc, + ]) + .assert_success(); + assert_eq!( + get_allowance(&mut cmd, &token, anvil_const::ADDR1, anvil_const::ADDR2, &rpc), + U256::from(123) + ); + cmd.cast_fuse() + .args(["call", &token, "nonces(address)(uint256)", anvil_const::ADDR1, "--rpc-url", &rpc]) + .assert_success() + .stdout_eq(str![[r#" +1 + +"#]]); + // A consumed permit cannot be replayed. + cmd.cast_fuse() + .args([ + "send", + &token, + "--data", + calldata, + "--private-key", + anvil_const::_PK2, + "--rpc-url", + &rpc, + ]) + .assert_failure(); +} + +#[casttest] +async fn erc20_permit_broadcast_and_domain_fallback(prj: _, cmd: _) { + let (rpc, token, _handle) = setup_permit_test(&prj, &mut cmd).await; + for (version, discovery, extra) in [ + ("2", "true", vec![]), + ("1", "false", vec![]), + ("2", "false", vec!["--domain-version", "2"]), + ] { + cmd.cast_fuse() + .args([ + "send", + &token, + "setDomain(string,bool)", + version, + discovery, + "--private-key", + anvil_const::PK1, + "--rpc-url", + &rpc, + ]) + .assert_success(); + cmd.cast_fuse() + .args([ + "erc20", + "permit", + &token, + anvil_const::ADDR2, + "456", + "--deadline", + "4000000000", + "--private-key", + anvil_const::PK1, + "--rpc-url", + &rpc, + "--broadcast", + ]) + .args(extra) + .assert_success(); + assert_eq!( + get_allowance(&mut cmd, &token, anvil_const::ADDR1, anvil_const::ADDR2, &rpc), + U256::from(456) + ); + } + cmd.cast_fuse() + .args([ + "erc20", + "permit", + &token, + anvil_const::ADDR2, + "456", + "--deadline", + "4000000000", + "--private-key", + anvil_const::PK1, + "--rpc-url", + &rpc, + ]) + .assert_failure() + .stderr_eq(str![[r#" +Error: EIP-712 domain does not match DOMAIN_SEPARATOR(); check --domain-name and --domain-version + +"#]]); +} + +#[casttest] +async fn erc20_permit_rejects_wrong_owner_and_expired_submission(prj: _, cmd: _) { + let (rpc, token, _handle) = setup_permit_test(&prj, &mut cmd).await; + cmd.cast_fuse() + .args([ + "erc20", + "permit", + &token, + anvil_const::ADDR2, + "123", + "--deadline", + "4000000000", + "--private-key", + anvil_const::PK1, + "--chain", + "mainnet", + "--rpc-url", + &rpc, + ]) + .assert_failure() + .stderr_eq(str![[r#" +Error: Configured chain does not match the RPC chain + +"#]]); + cmd.cast_fuse() + .args([ + "erc20", + "permit", + &token, + anvil_const::ADDR2, + "123", + "--deadline", + "4000000000", + "--private-key", + anvil_const::PK1, + "--from", + anvil_const::ADDR2, + "--rpc-url", + &rpc, + ]) + .assert_failure() + .stderr_eq(str![[r#" +Error: --from must match the permit signing wallet + +"#]]); + cmd.cast_fuse() + .args([ + "erc20", + "permit", + &token, + anvil_const::ADDR2, + "123", + "--deadline", + "0", + "--private-key", + anvil_const::PK1, + "--rpc-url", + &rpc, + "--broadcast", + ]) + .assert_failure(); + assert_eq!( + get_allowance(&mut cmd, &token, anvil_const::ADDR1, anvil_const::ADDR2, &rpc), + U256::ZERO + ); +} diff --git a/crates/cast/tests/cli/erc4626.rs b/crates/cast/tests/cli/erc4626.rs new file mode 100644 index 0000000000000..6ce14d6f4725f --- /dev/null +++ b/crates/cast/tests/cli/erc4626.rs @@ -0,0 +1,602 @@ +//! End-to-end tests for `cast erc4626`. + +use alloy_primitives::U256; +use anvil::{NodeConfig, NodeHandle}; +use foundry_test_utils::{rpc::next_http_archive_rpc_url, str, util::OutputExt}; + +mod anvil_const { + pub const PK1: &str = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + pub const ADDR1: &str = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; + pub const ADDR2: &str = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; + + /// Contract address deployed by `ADDR1` at nonce zero. + pub const VAULT: &str = "0x5FbDB2315678afecb367f032d93F642f64180aa3"; +} + +const ETHEREUM_FORK_BLOCK: u64 = 25_519_075; +const TEMPO_FORK_BLOCK: u64 = 37_847_799; +const TEMPO_RPC_URL: &str = "https://rpc.tempo.xyz"; + +struct ProductionVault { + project: &'static str, + address: &'static str, +} + +const PRODUCTION_VAULTS: &[ProductionVault] = &[ + ProductionVault { + project: "Morpho MetaMorpho", + address: "0xBEEF01735c132Ada46AA9aA4c54623cAA92A64CB", + }, + ProductionVault { project: "Yearn V3", address: "0x028eC7330ff87667b6dfb0D94b954c820195336c" }, + ProductionVault { + project: "Maple syrupUSDC", + address: "0x80ac24aA929eaF5013f6436cdA2a7ba190f5Cc0b", + }, +]; + +const TEMPO_VAULT: ProductionVault = ProductionVault { + project: "Morpho on Tempo", + address: "0x83a1491f3e7f8dAAB8F787a631334b9ca7a87023", +}; + +const READ_CALLS: &[(&str, &[&str])] = &[ + ("asset", &[]), + ("total-assets", &[]), + ("convert-to-shares", &["1"]), + ("convert-to-assets", &["1"]), + ("max-deposit", &[anvil_const::ADDR1]), + ("preview-deposit", &["1"]), + ("max-mint", &[anvil_const::ADDR1]), + ("preview-mint", &["1"]), + ("max-withdraw", &[anvil_const::ADDR1]), + ("preview-withdraw", &["1"]), + ("max-redeem", &[anvil_const::ADDR1]), + ("preview-redeem", &["1"]), +]; + +fn assert_read_surface( + cmd: &mut foundry_test_utils::TestCommand, + vault: &ProductionVault, + rpc: &str, +) { + for (command, args) in READ_CALLS { + let output = cmd + .cast_fuse() + .args(["erc4626", command, vault.address]) + .args(*args) + .args(["--rpc-url", rpc]) + .assert_success() + .get_output() + .stdout_lossy(); + assert!(!output.trim().is_empty(), "{} {command} returned no output", vault.project); + } +} + +fn assert_inspection_surface( + cmd: &mut foundry_test_utils::TestCommand, + vault: &ProductionVault, + rpc: &str, +) { + for (command, args) in + [("info", Vec::new()), ("position", vec![anvil_const::ADDR1]), ("check", Vec::new())] + { + let output = cmd + .cast_fuse() + .args(["erc4626", command, vault.address]) + .args(args) + .args(["--rpc-url", rpc]) + .assert_success() + .get_output() + .stdout_lossy(); + assert!(!output.trim().is_empty(), "{} {command} returned no output", vault.project); + } +} + +fn read_amount( + cmd: &mut foundry_test_utils::TestCommand, + command: &str, + vault: &str, + args: &[&str], + rpc: &str, +) -> U256 { + let output = cmd + .cast_fuse() + .args(["erc4626", command, vault]) + .args(args) + .args(["--rpc-url", rpc]) + .assert_success() + .get_output() + .stdout_lossy(); + output.split_whitespace().next().unwrap().parse().unwrap() +} + +fn read_erc20_balance( + cmd: &mut foundry_test_utils::TestCommand, + token: &str, + owner: &str, + rpc: &str, +) -> U256 { + let output = cmd + .cast_fuse() + .args(["erc20", "balance", token, owner, "--rpc-url", rpc]) + .assert_success() + .get_output() + .stdout_lossy(); + output.split_whitespace().next().unwrap().parse().unwrap() +} + +fn deploy_test_vault(cmd: &mut foundry_test_utils::TestCommand, rpc: &str, private_key: &str) { + deploy_test_contract(cmd, rpc, private_key, "TestVault"); +} + +fn deploy_test_contract( + cmd: &mut foundry_test_utils::TestCommand, + rpc: &str, + private_key: &str, + contract: &str, +) { + cmd.args([ + "create", + "--private-key", + private_key, + "--rpc-url", + rpc, + "--broadcast", + &format!("src/TestVault.sol:{contract}"), + ]) + .assert_success(); +} + +async fn setup_test_vault( + prj: &foundry_test_utils::TestProject, + cmd: &mut foundry_test_utils::TestCommand, +) -> (String, NodeHandle) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + let rpc = handle.http_endpoint(); + + foundry_test_utils::util::initialize(prj.root()); + prj.add_source("TestVault.sol", include_str!("../fixtures/TestVault.sol")); + deploy_test_vault(cmd, &rpc, anvil_const::PK1); + + (rpc, handle) +} + +#[forgetest] +async fn erc4626_complete_synchronous_interface(prj: _, cmd: _) { + let (rpc, _handle) = setup_test_vault(&prj, &mut cmd).await; + + let asset = cmd + .cast_fuse() + .args(["vault", "asset", anvil_const::VAULT, "--rpc-url", &rpc]) + .assert_success() + .get_output() + .stdout_lossy() + .trim() + .to_string(); + + assert_eq!(read_amount(&mut cmd, "total-assets", anvil_const::VAULT, &[], &rpc), U256::ZERO); + assert_eq!( + read_amount(&mut cmd, "convert-to-shares", anvil_const::VAULT, &["100"], &rpc), + U256::from(100) + ); + assert_eq!( + read_amount(&mut cmd, "convert-to-assets", anvil_const::VAULT, &["100"], &rpc), + U256::from(100) + ); + + let max_deposit = cmd + .cast_fuse() + .args(["erc4626", "max-deposit", anvil_const::VAULT, anvil_const::ADDR1, "--rpc-url", &rpc]) + .assert_success(); + assert!(max_deposit.get_output().stderr_lossy().contains("conservative maxima")); + assert_eq!( + read_amount(&mut cmd, "preview-deposit", anvil_const::VAULT, &["100"], &rpc), + U256::from(100) + ); + + let max_mint = cmd + .cast_fuse() + .args(["erc4626", "max-mint", anvil_const::VAULT, anvil_const::ADDR1, "--rpc-url", &rpc]) + .assert_success(); + assert!(max_mint.get_output().stderr_lossy().contains("conservative maxima")); + assert_eq!( + read_amount(&mut cmd, "preview-mint", anvil_const::VAULT, &["50"], &rpc), + U256::from(50) + ); + + cmd.cast_fuse() + .args([ + "erc20", + "approve", + &asset, + anvil_const::VAULT, + "1000", + "--rpc-url", + &rpc, + "--private-key", + anvil_const::PK1, + ]) + .assert_success(); + + cmd.cast_fuse() + .args([ + "erc4626", + "deposit", + anvil_const::VAULT, + "100", + anvil_const::ADDR1, + "--rpc-url", + &rpc, + "--private-key", + anvil_const::PK1, + ]) + .assert_success(); + cmd.cast_fuse() + .args([ + "erc4626", + "mint", + anvil_const::VAULT, + "50", + anvil_const::ADDR1, + "--rpc-url", + &rpc, + "--private-key", + anvil_const::PK1, + ]) + .assert_success(); + + let max_withdraw = cmd + .cast_fuse() + .args([ + "erc4626", + "max-withdraw", + anvil_const::VAULT, + anvil_const::ADDR1, + "--rpc-url", + &rpc, + ]) + .assert_success(); + assert!(max_withdraw.get_output().stderr_lossy().contains("even though the owner has shares")); + assert_eq!( + read_amount(&mut cmd, "preview-withdraw", anvil_const::VAULT, &["25"], &rpc), + U256::from(25) + ); + + let max_redeem = cmd + .cast_fuse() + .args(["erc4626", "max-redeem", anvil_const::VAULT, anvil_const::ADDR1, "--rpc-url", &rpc]) + .assert_success(); + assert!(max_redeem.get_output().stderr_lossy().contains("even though the owner has shares")); + assert_eq!( + read_amount(&mut cmd, "preview-redeem", anvil_const::VAULT, &["25"], &rpc), + U256::from(25) + ); + + cmd.cast_fuse() + .args([ + "erc4626", + "withdraw", + anvil_const::VAULT, + "25", + anvil_const::ADDR2, + anvil_const::ADDR1, + "--rpc-url", + &rpc, + "--private-key", + anvil_const::PK1, + ]) + .assert_success(); + cmd.cast_fuse() + .args([ + "erc4626", + "redeem", + anvil_const::VAULT, + "25", + anvil_const::ADDR2, + anvil_const::ADDR1, + "--rpc-url", + &rpc, + "--private-key", + anvil_const::PK1, + ]) + .assert_success(); + + assert_eq!( + read_amount(&mut cmd, "total-assets", anvil_const::VAULT, &[], &rpc), + U256::from(100) + ); + assert_eq!( + read_erc20_balance(&mut cmd, anvil_const::VAULT, anvil_const::ADDR1, &rpc), + U256::from(100) + ); + assert_eq!(read_erc20_balance(&mut cmd, &asset, anvil_const::ADDR2, &rpc), U256::from(50)); + + cmd.cast_fuse() + .args(["erc4626", "info", anvil_const::VAULT, "--human", "--rpc-url", &rpc]) + .assert_success() + .stdout_eq(format!( + "Vault {}\n\ + Name Test Vault\n\ + Symbol TV\n\ + Decimals 18\n\ + Asset {asset}\n\ + Asset name Test Vault Asset\n\ + Asset symbol TVA\n\ + Asset decimals 18\n\ + Total assets 0.000000000000000100 TVA\n\ + Total supply 0.000000000000000100 TV\n\ + Assets per share 1 TVA\n\ + Shares per asset 1 TV\n", + anvil_const::VAULT + )); + + cmd.cast_fuse() + .args(["erc4626", "info", anvil_const::VAULT, "--json", "--rpc-url", &rpc]) + .assert_json_stdout(format!( + r#"{{ + "schema_version": 1, + "success": true, + "data": {{ + "vault": "{}", + "name": "Test Vault", + "symbol": "TV", + "decimals": 18, + "asset": "{asset}", + "asset_name": "Test Vault Asset", + "asset_symbol": "TVA", + "asset_decimals": 18, + "total_assets": {{ + "raw": "100", + "formatted": "0.000000000000000100" + }}, + "total_supply": {{ + "raw": "100", + "formatted": "0.000000000000000100" + }}, + "assets_per_share": {{ "raw": "1000000000000000000", "formatted": "1" }}, + "shares_per_asset": {{ "raw": "1000000000000000000", "formatted": "1" }} + }}, + "errors": [], + "warnings": [] + }}"#, + anvil_const::VAULT + )); + + cmd.cast_fuse() + .args([ + "erc4626", + "position", + anvil_const::VAULT, + anvil_const::ADDR1, + "--json", + "--rpc-url", + &rpc, + ]) + .assert_json_stdout(format!( + r#"{{ + "schema_version": 1, + "success": true, + "data": {{ + "vault": "{}", + "owner": "{}", + "asset": "{asset}", + "share_symbol": "TV", + "share_decimals": 18, + "asset_symbol": "TVA", + "asset_decimals": 18, + "share_balance": {{ + "raw": "100", + "formatted": "0.000000000000000100" + }}, + "assets_equivalent": {{ + "raw": "100", + "formatted": "0.000000000000000100" + }}, + "max_withdraw": {{ "raw": "0", "formatted": "0" }}, + "max_redeem": {{ "raw": "0", "formatted": "0" }} + }}, + "errors": [], + "warnings": [ + {{ + "level": "warning", + "code": "erc4626_zero_max_withdraw", + "message": "Vault reported zero from maxWithdraw even though the owner has shares; liquidity, gates, withdrawal queues, or a conservative implementation may prevent the base ERC-4626 exit." + }}, + {{ + "level": "warning", + "code": "erc4626_zero_max_redeem", + "message": "Vault reported zero from maxRedeem even though the owner has shares; liquidity, gates, withdrawal queues, or a conservative implementation may prevent the base ERC-4626 exit." + }} + ] + }}"#, + anvil_const::VAULT, + anvil_const::ADDR1 + )); + + cmd.cast_fuse() + .args(["erc4626", "check", anvil_const::VAULT, "--rpc-url", &rpc]) + .assert_success() + .stdout_eq(format!( + "Vault {}\n\ + Account 0x0000000000000000000000000000000000000000\n\ + Note: This probes read-call behavior only; it does not prove state-changing selector coverage or semantic ERC-4626 compliance.\n\ + PASS contract code contract bytecode is present\n\ + PASS asset() returned {asset}\n\ + PASS asset contract underlying asset bytecode is present\n\ + PASS asset balanceOf(address) call succeeded\n\ + PASS totalAssets() call succeeded\n\ + PASS totalSupply() call succeeded\n\ + PASS balanceOf(address) call succeeded\n\ + PASS allowance(address,address) call succeeded\n\ + PASS convertToShares(0) returned zero\n\ + PASS convertToAssets(0) returned zero\n\ + PASS maxDeposit(address) call succeeded\n\ + PASS previewDeposit(0) returned zero\n\ + PASS maxMint(address) call succeeded\n\ + PASS previewMint(0) returned zero\n\ + PASS maxWithdraw(address) call succeeded\n\ + PASS previewWithdraw(0) returned zero\n\ + PASS maxRedeem(address) call succeeded\n\ + PASS previewRedeem(0) returned zero\n\ + PASS name() call succeeded\n\ + PASS symbol() call succeeded\n\ + PASS decimals() call succeeded\n\ + Summary: 21 passed, 0 warnings, 0 failed\n", + anvil_const::VAULT + )); +} + +#[forgetest_init] +async fn erc4626_check_warns_for_known_extensions(prj: _, cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + let rpc = handle.http_endpoint(); + + prj.add_source("TestVault.sol", include_str!("../fixtures/TestVault.sol")); + deploy_test_contract(&mut cmd, &rpc, anvil_const::PK1, "TestAsyncVault"); + + cmd.cast_fuse() + .args(["erc4626", "info", anvil_const::VAULT, "--human", "--rpc-url", &rpc]) + .assert_success() + .stdout_eq(str![[r#" +Vault 0x5FbDB2315678afecb367f032d93F642f64180aa3 +Name Test Async Vault +Symbol TAV +Decimals 18 +Asset 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE +Asset name +Asset symbol +Asset decimals 18 +Total assets 1 +Total supply 0 TAV +Assets per share 1 +Shares per asset 1 TAV + +"#]]); + + let output = cmd + .cast_fuse() + .args([ + "erc4626", + "position", + anvil_const::VAULT, + anvil_const::ADDR1, + "--json", + "--rpc-url", + &rpc, + ]) + .assert_success() + .get_output() + .stdout + .clone(); + let output: serde_json::Value = serde_json::from_slice(&output).unwrap(); + assert_eq!(output["data"]["asset_decimals"], 18); + assert_eq!(output["data"]["assets_equivalent"]["formatted"], "0"); + + cmd.cast_fuse() + .args(["erc4626", "check", anvil_const::VAULT, "--rpc-url", &rpc]) + .assert_success() + .stdout_eq(str![[r#" +Vault 0x5FbDB2315678afecb367f032d93F642f64180aa3 +Account 0x0000000000000000000000000000000000000000 +Note: This probes read-call behavior only; it does not prove state-changing selector coverage or semantic ERC-4626 compliance. +PASS contract code contract bytecode is present +WARN asset() returned the ERC-7535 native-asset sentinel +PASS totalAssets() call succeeded +PASS totalSupply() call succeeded +PASS balanceOf(address) call succeeded +PASS allowance(address,address) call succeeded +PASS convertToShares(0) returned zero +PASS convertToAssets(0) returned zero +PASS maxDeposit(address) call succeeded +WARN previewDeposit(0) reverted as required by advertised asynchronous ERC-7540 deposit support +PASS maxMint(address) call succeeded +WARN previewMint(0) reverted as required by advertised asynchronous ERC-7540 deposit support +PASS maxWithdraw(address) call succeeded +WARN previewWithdraw(0) reverted as required by advertised asynchronous ERC-7540 redeem support +PASS maxRedeem(address) call succeeded +WARN previewRedeem(0) reverted as required by advertised asynchronous ERC-7540 redeem support +PASS name() call succeeded +PASS symbol() call succeeded +PASS decimals() call succeeded +Summary: 14 passed, 5 warnings, 0 failed + +"#]]); +} + +#[forgetest_init] +async fn erc4626_check_fails_for_missing_metadata(prj: _, cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + let rpc = handle.http_endpoint(); + + prj.add_source("TestVault.sol", include_str!("../fixtures/TestVault.sol")); + deploy_test_contract(&mut cmd, &rpc, anvil_const::PK1, "TestMissingMetadataVault"); + + cmd.cast_fuse() + .args(["erc4626", "check", anvil_const::VAULT, "--rpc-url", &rpc]) + .assert_failure() + .stdout_eq(str![[r#" +Vault 0x5FbDB2315678afecb367f032d93F642f64180aa3 +Account 0x0000000000000000000000000000000000000000 +Note: This probes read-call behavior only; it does not prove state-changing selector coverage or semantic ERC-4626 compliance. +PASS contract code contract bytecode is present +WARN asset() returned the ERC-7535 native-asset sentinel +PASS totalAssets() call succeeded +PASS totalSupply() call succeeded +PASS balanceOf(address) call succeeded +PASS allowance(address,address) call succeeded +PASS convertToShares(0) returned zero +PASS convertToAssets(0) returned zero +PASS maxDeposit(address) call succeeded +WARN previewDeposit(0) reverted as required by advertised asynchronous ERC-7540 deposit support +PASS maxMint(address) call succeeded +WARN previewMint(0) reverted as required by advertised asynchronous ERC-7540 deposit support +PASS maxWithdraw(address) call succeeded +WARN previewWithdraw(0) reverted as required by advertised asynchronous ERC-7540 redeem support +PASS maxRedeem(address) call succeeded +WARN previewRedeem(0) reverted as required by advertised asynchronous ERC-7540 redeem support +FAIL name() call failed or returned incompatible data +FAIL symbol() call failed or returned incompatible data +FAIL decimals() call failed or returned incompatible data +Summary: 11 passed, 5 warnings, 3 failed + +"#]]); + + let output = cmd + .cast_fuse() + .args(["erc4626", "check", anvil_const::VAULT, "--rpc-url", &rpc, "--json"]) + .assert_failure() + .get_output() + .stdout + .clone(); + let output: serde_json::Value = serde_json::from_slice(&output).unwrap(); + assert_eq!(output["success"], false); + assert_eq!(output["data"]["read_compatible"], false); + assert_eq!(output["data"]["failed"], 3); + assert_eq!(output["errors"][0]["code"], "erc4626.compatibility_failed"); +} + +#[casttest] +async fn erc4626_fork_reads_multiple_production_vaults(cmd: _) { + let fork = NodeConfig::test() + .with_eth_rpc_url(Some(next_http_archive_rpc_url())) + .with_fork_block_number(Some(ETHEREUM_FORK_BLOCK)); + let (_, handle) = anvil::spawn(fork).await; + let rpc = handle.http_endpoint(); + + for vault in PRODUCTION_VAULTS { + assert_read_surface(&mut cmd, vault, &rpc); + assert_inspection_surface(&mut cmd, vault, &rpc); + } +} + +#[casttest] +async fn flaky_erc4626_fork_reads_tempo_vault(cmd: _) { + let fork = NodeConfig::test_tempo() + .with_eth_rpc_url(Some(TEMPO_RPC_URL.to_string())) + .with_fork_block_number(Some(TEMPO_FORK_BLOCK)); + let (_, handle) = anvil::spawn(fork).await; + let rpc = handle.http_endpoint(); + + assert_read_surface(&mut cmd, &TEMPO_VAULT, &rpc); + assert_inspection_surface(&mut cmd, &TEMPO_VAULT, &rpc); +} diff --git a/crates/cast/tests/cli/estimate.rs b/crates/cast/tests/cli/estimate.rs new file mode 100644 index 0000000000000..57dba44fd2855 --- /dev/null +++ b/crates/cast/tests/cli/estimate.rs @@ -0,0 +1,300 @@ +//! CLI tests for estimate commands. + +use super::*; +use alloy_primitives::bytes; + +// tests that `cast estimate` is working correctly. +#[casttest] +fn estimate_function_gas(cmd: _) { + let eth_rpc_url = next_http_rpc_endpoint(); + + // ensure we get a positive non-error value for gas estimate + let output: u32 = cmd + .args([ + "estimate", + "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", // vitalik.eth + "--value", + "100", + "deposit()", + "--rpc-url", + eth_rpc_url.as_str(), + ]) + .assert_success() + .get_output() + .stdout_lossy() + .trim() + .parse() + .unwrap(); + assert!(output.ge(&0)); +} + +// tests that `cast estimate --cost` is working correctly. +#[casttest] +fn estimate_function_cost(cmd: _) { + let eth_rpc_url = next_http_rpc_endpoint(); + + // ensure we get a positive non-error value for cost estimate + let output: f64 = cmd + .args([ + "estimate", + "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", // vitalik.eth + "--value", + "100", + "deposit()", + "--rpc-url", + eth_rpc_url.as_str(), + "--cost", + ]) + .assert_success() + .get_output() + .stdout_lossy() + .trim() + .parse() + .unwrap(); + assert!(output > 0f64); +} + +// tests that `cast estimate --create` is working correctly. +#[casttest] +fn estimate_contract_deploy_gas(cmd: _) { + let eth_rpc_url = next_http_rpc_endpoint(); + // sample contract code bytecode. Wouldn't run but is valid bytecode that the estimate method + // accepts and could be deployed. + let output = cmd + .args([ + "estimate", + "--rpc-url", + eth_rpc_url.as_str(), + "--create", + "0000", + "ERC20(uint256,string,string)", + "100", + "Test", + "TST", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + // ensure we get a positive non-error value for gas estimate + let output: u32 = output.trim().parse().unwrap(); + assert!(output > 0); +} + +#[casttest] +fn estimate_eip7702_auth_disclosure_declined(prj: _, cmd: _) { + prj.update_config(|config| config.chain = Some(31337.into())); + + cmd.args([ + "estimate", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "--auth", + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--rpc-url", + "http://127.0.0.1:1", + ]) + .stdin("n\n") + .assert_success() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Warning: This command will send a signed EIP-7702 authorization to the RPC endpoint. The authorization can be submitted on-chain by anyone once its nonce is valid. + +Continue anyway? [y/N] Aborted. + +"#]]); +} + +#[casttest] +fn estimate_eip7702_auth_disclosure_requires_signer(prj: _, cmd: _) { + prj.update_config(|config| config.chain = Some(31337.into())); + + cmd.args([ + "estimate", + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "--auth", + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "--from", + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "--nonce", + "0", + "--rpc-url", + "http://127.0.0.1:1", + ]) + .assert_failure() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Error: No signer available to sign authorization. Provide a pre-signed authorization (hex-encoded) instead. + +"#]]); +} + +#[casttest] +async fn estimate_eip7702_auth_disclosure_accepted_and_forced(cmd: _) { + let (api, handle) = + anvil::spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Prague.into()))).await; + let endpoint = handle.http_endpoint(); + api.anvil_set_code( + address!("0x70997970C51812dc3A010C7d01b50e0d17dc79C8"), + bytes!("0x602a5f5260205ff3"), + ) + .await + .unwrap(); + let args = [ + "estimate", + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "--auth", + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--rpc-url", + &endpoint, + ]; + + let output = cmd + .args(args) + .arg("--json") + .stdin("y\n") + .assert_success() + .stderr_eq(str![[r#" +Warning: This command will send a signed EIP-7702 authorization to the RPC endpoint. The authorization can be submitted on-chain by anyone once its nonce is valid. + +Continue anyway? [y/N] "#]]) + .get_output() + .stdout_lossy(); + let output: serde_json::Value = serde_json::from_str(&output).unwrap(); + assert!(output["data"].as_u64().unwrap() > 21_000); + + let output = cmd + .cast_fuse() + .args(args) + .arg("--force") + .assert_success() + .stderr_eq(str![""]) + .get_output() + .stdout_lossy(); + assert!(output.trim().parse::().unwrap() > 21_000); + + let output = cmd + .cast_fuse() + .args(args) + .args(["--quiet", "--force"]) + .assert_success() + .stderr_eq(str![""]) + .get_output() + .stdout_lossy(); + assert!(output.trim().parse::().unwrap() > 21_000); +} + +// +#[casttest] +fn flaky_estimate_base_da(cmd: _) { + cmd.args(["da-estimate", "30558838", "-r", next_rpc_endpoint(NamedChain::Base).as_str()]) + .assert_success() + .stdout_eq(str![[r#" +52916546100 + +"#]]) + .stderr_eq(str![[r#" +Estimated data availability size for block 30558838 with 225 transactions: + +"#]]); +} + +// Test that cast estimate --create works correctly with constructor arguments +// +#[casttest] +fn cast_estimate_create_with_constructor_args(prj: _, cmd: _) { + let eth_rpc_url = next_http_rpc_endpoint(); + + // Add a simple contract with constructor arguments + prj.add_source( + "EstimateContract", + r#" +contract EstimateContract { + uint256 public value; + string public name; + + constructor(uint256 _value, string memory _name) { + value = _value; + name = _name; + } +} +"#, + ); + + // Compile to get bytecode + cmd.forge_fuse().args(["build"]).assert_success(); + + // Get the compiled bytecode + let bytecode_path = prj.root().join("out/EstimateContract.sol/EstimateContract.json"); + let contract_json = std::fs::read_to_string(bytecode_path).unwrap(); + let contract_data: serde_json::Value = serde_json::from_str(&contract_json).unwrap(); + let bytecode = contract_data["bytecode"]["object"].as_str().unwrap(); + + let output = cmd + .cast_fuse() + .args([ + "estimate", + "--rpc-url", + eth_rpc_url.as_str(), + "--create", + bytecode, + "constructor(uint256,string)", + "100", + "TestContract", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + // Parse the gas estimate + let gas_estimate = output.trim().parse::().expect("Failed to parse gas estimate"); + + // Gas estimate should be positive and reasonable for contract deployment + assert!(gas_estimate > 50000, "Gas estimate too low for contract deployment"); + assert!(gas_estimate < 5000000, "Gas estimate unreasonably high"); +} + +// Tests for negative number argument parsing +// Ensures that negative numbers in function arguments are properly parsed +// instead of being treated as command flags + +// Test cast estimate with negative numbers +#[casttest] +fn cast_estimate_negative_numbers(cmd: _) { + let rpc = next_rpc_endpoint(NamedChain::Sepolia); + cmd.args([ + "estimate", + "0xBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBb", + "rebalance(int64)", + "-8888", + "--rpc-url", + rpc.as_str(), + ]) + .assert_success(); +} + +#[cfg(any(feature = "base", feature = "optimism"))] +#[casttest] +fn cast_da_estimate_honors_config_and_cli_override(prj: _, cmd: _) { + prj.update_config(|config| { + config.networks = foundry_evm_networks::NetworkConfigs::with_tempo(); + }); + cmd.args(["da-estimate", "latest", "--rpc-url", "http://127.0.0.1:1"]) + .assert_failure() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Error: DA estimation is not supported for Tempo: EIP-4844 blob transactions are not available on this network + +"#]]); + cmd.cast_fuse().current_dir(prj.root()) + .args(["da-estimate", "latest", "--network", "ethereum", "--rpc-url", "http://127.0.0.1:1", "--curl"]) + .assert_success() + .stdout_eq(str![[r#" +curl -X POST -H 'Content-Type: application/json' --data-raw '{"method":"eth_getBlockByNumber","params":["latest",true],"id":0,"jsonrpc":"2.0"}' 'http://127.0.0.1:1/' + +"#]]) + .stderr_eq(str![""]); +} diff --git a/crates/cast/tests/cli/help.rs b/crates/cast/tests/cli/help.rs new file mode 100644 index 0000000000000..e61f027ef5f04 --- /dev/null +++ b/crates/cast/tests/cli/help.rs @@ -0,0 +1,111 @@ +//! CLI tests for help commands. + +use super::*; + +#[casttest] +fn print_short_version(cmd: _) { + cmd.arg("-V").assert_success().stdout_eq(str![[r#" +cast [..]-[..] ([..] [..]) + +"#]]); +} + +#[casttest] +fn print_long_version(cmd: _) { + cmd.arg("--version").assert_success().stdout_eq(str![[r#" +cast Version: [..] +Commit SHA: [..] +Build Timestamp: [..] +Build Profile: [..] + +"#]]); +} + +// tests that a non-UTF-8 command-line argument produces a clean error instead of an unrecovered +// panic in `GlobalArgs::check_markdown_help` (which used to call `std::env::args()`, documented to +// panic on invalid Unicode, as the very first statement of every binary's entry point) +#[cfg(unix)] +#[casttest] +fn non_utf8_argument_does_not_panic(prj: _) { + use std::os::unix::ffi::OsStrExt; + + let bad_arg = std::ffi::OsStr::from_bytes(&[0xff]); + let output = prj.cast_bin().arg(bad_arg).output().unwrap(); + + assert_ne!( + output.status.code(), + Some(101), + "a non-UTF-8 argument must not cause an unrecovered panic (exit code 101); got status {:?}, stderr: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !stderr.contains("panicked at"), + "a non-UTF-8 argument must not panic; stderr: {stderr}" + ); +} + +// tests `--help` is printed to std out +#[casttest] +fn print_help(cmd: _) { + cmd.arg("--help").assert_success().stdout_eq(str![[r#" +A Swiss Army knife for interacting with Ethereum applications from the command line + +Usage: cast[..] + +Commands: +... + +Options: + -h, --help + Print help (see a summary with '-h') + + -j, --threads + Number of threads to use. Specifying 0 defaults to the number of logical cores +... + [alias: --jobs] + + --profile + The configuration profile to use + + -V, --version + Print version + +Display options: + --color + The color of the log messages + + Possible values: + - auto: Intelligently guess whether to use color output (default) + - always: Force color output + - never: Force disable color output + + --json + Format log messages as JSON + + --md + Format log messages as Markdown + + -q, --quiet + Do not print log messages + + -v, --verbosity... + Verbosity level of the log messages. +... + Pass multiple times to increase the verbosity (e.g. -v, -vv, -vvv). +... + Depending on the context the verbosity levels have different meanings. +... + For example, the verbosity levels of the EVM are: + - 2 (-vv): Print logs for all tests. + - 3 (-vvv): Print execution traces for failing tests. + - 4 (-vvvv): Print execution traces for all tests, and setup traces for failing tests. + - 5 (-vvvvv): Print execution and setup traces for all tests, including storage changes + and + backtraces with line numbers. + +Find more information in the book: https://getfoundry.sh/cast/overview + +"#]]); +} diff --git a/crates/cast/tests/cli/keychain.rs b/crates/cast/tests/cli/keychain.rs new file mode 100644 index 0000000000000..2a709522e0ed2 --- /dev/null +++ b/crates/cast/tests/cli/keychain.rs @@ -0,0 +1,3000 @@ +//! CLI tests for `cast keychain` subcommands. + +use crate::PRECOMPUTED_VADDR_SALT_FOR_ADDR1; +use alloy_consensus::{TxEnvelope, transaction::SignerRecoverable}; +use alloy_eips::{Decodable2718, Encodable2718}; +use alloy_primitives::{Address, U256, hex}; +use alloy_rlp::{Header, PayloadView}; +use alloy_rpc_types::Authorization; +use alloy_signer::SignerSync; +use alloy_signer_local::PrivateKeySigner; +use anvil::NodeConfig; +use foundry_cli::utils::parse_json; +use foundry_evm::core::tempo::PATH_USD_ADDRESS; +use foundry_test_utils::{TestCommand, str, util::OutputExt}; +use path_slash::PathExt; +use serde_json::json; +use std::{ + fs, + io::{Read, Write}, + net::TcpListener, + path::Path, + thread, + time::Duration, +}; +use tempo_alloy::accounts::TempoAccountsStore; +use tempo_contracts::precompiles::TIP20_FACTORY_ADDRESS; +use tempo_hardfork::TempoHardfork; +use tempo_primitives::TempoTxEnvelope; + +/// Anvil test accounts (standard mnemonic). +mod accounts { + pub const PK1: &str = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + pub const PK2: &str = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"; + pub const PK3: &str = "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a"; + pub const ADDR1: &str = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; + pub const ADDR2: &str = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; + pub const ADDR3: &str = "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"; +} + +fn path_usd() -> String { + PATH_USD_ADDRESS.to_string() +} + +fn signed_eip7702_authorization() -> String { + let signer: PrivateKeySigner = accounts::PK2.parse().unwrap(); + let authorization = Authorization { + chain_id: U256::from(31337), + address: accounts::ADDR3.parse().unwrap(), + nonce: 0, + }; + let signature = signer.sign_hash_sync(&authorization.signature_hash()).unwrap(); + hex::encode_prefixed(alloy_rlp::encode(authorization.into_signed(signature))) +} + +fn write_accounts_store(tempo_home: &Path, chain_id: u64) { + let wallet = tempo_home.join("wallet"); + fs::create_dir_all(&wallet).expect("create Tempo wallet dir"); + let store = serde_json::json!({ + "tempo-cli.store": { + "state": { + "activeAccount": 0, + "chainId": chain_id, + "accounts": [{"address": accounts::ADDR1}], + "accessKeys": [{ + "access": accounts::ADDR1, + "address": accounts::ADDR2, + "chainId": chain_id, + "keyType": "secp256k1", + "privateKey": accounts::PK2, + }], + }, + }, + }); + fs::write( + wallet.join("store.json"), + serde_json::to_vec(&store).expect("serialize Tempo Accounts store"), + ) + .expect("write Tempo Accounts store"); +} + +fn spawn_fee_payer_service( + fee_payer: PrivateKeySigner, + fee_token: Address, +) -> (String, thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind fee payer service"); + let address = listener.local_addr().expect("fee payer service address"); + let handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept fee payer request"); + stream + .set_read_timeout(Some(Duration::from_secs(10))) + .expect("set fee payer service timeout"); + + let mut request = Vec::new(); + let (body_offset, content_length) = loop { + let mut chunk = [0u8; 4096]; + let read = stream.read(&mut chunk).expect("read fee payer request"); + assert_ne!(read, 0, "fee payer request ended before its body"); + request.extend_from_slice(&chunk[..read]); + + let Some(header_end) = request.windows(4).position(|window| window == b"\r\n\r\n") + else { + continue; + }; + let headers = + std::str::from_utf8(&request[..header_end]).expect("HTTP headers are UTF-8"); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().expect("valid content length")) + }) + .expect("fee payer request has content length"); + let body_offset = header_end + 4; + if request.len() >= body_offset + content_length { + break (body_offset, content_length); + } + }; + + let rpc: serde_json::Value = + serde_json::from_slice(&request[body_offset..body_offset + content_length]) + .expect("valid fee payer JSON-RPC request"); + assert_eq!(rpc["method"], "eth_signRawTransaction"); + let raw = rpc["params"][0].as_str().expect("raw transaction parameter"); + let bytes = hex::decode(raw).expect("decode fee payer transaction"); + assert_eq!(bytes.first(), Some(&0x76), "fee payer request is a Tempo AA transaction"); + let mut encoded_fields = &bytes[1..]; + let PayloadView::List(fields) = + Header::decode_raw(&mut encoded_fields).expect("decode fee payer transaction fields") + else { + panic!("fee payer request must contain an RLP list") + }; + assert!(encoded_fields.is_empty(), "fee payer request has trailing bytes"); + assert_eq!(fields[11], [0x00], "fee payer request has the signature placeholder"); + + // The fee-payer service encoding uses 0x00 as a signature marker. Normalize that + // sponsor-owned field to an empty value so the standard envelope decoder can recover the + // user signature before the mock sponsor fills it. + let mut payload = Vec::new(); + for (index, field) in fields.into_iter().enumerate() { + if index == 11 { + payload.push(0x80); + } else { + payload.extend_from_slice(field); + } + } + let mut normalized = vec![0x76]; + Header { list: true, payload_length: payload.len() }.encode(&mut normalized); + normalized.extend_from_slice(&payload); + let TempoTxEnvelope::AA(unsigned) = + TempoTxEnvelope::decode_2718(&mut normalized.as_slice()) + .expect("decode Tempo transaction") + else { + panic!("fee payer request must contain a Tempo AA transaction") + }; + let user = unsigned.recover_signer().expect("recover transaction signer"); + let (mut tx, user_signature, _) = unsigned.into_parts(); + tx.fee_token = Some(fee_token); + tx.fee_payer_signature = Some( + fee_payer + .sign_hash_sync(&tx.fee_payer_signature_hash(user)) + .expect("sign fee payer authorization"), + ); + + let signed = TempoTxEnvelope::AA(tx.into_signed(user_signature)); + let mut encoded = Vec::new(); + signed.encode_2718(&mut encoded); + let response = serde_json::json!({ + "jsonrpc": "2.0", + "id": rpc["id"], + "result": hex::encode_prefixed(encoded), + }) + .to_string(); + write!( + stream, + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + response.len(), + response, + ) + .expect("write fee payer response"); + }); + (format!("http://{address}"), handle) +} + +const ADDRESS_REGISTRY: &str = "0xFDC0000000000000000000000000000000000000"; + +fn tip20_factory() -> String { + TIP20_FACTORY_ADDRESS.to_string() +} + +const MISSING_SESSION_ID: &str = + "0x5555555555555555555555555555555555555555555555555555555555555555"; + +fn batch_send_transfer_call(path_usd: &str) -> String { + format!("{path_usd}::transfer(address,uint256):{},0", accounts::ADDR3) +} + +fn assert_wrong_chain_error(stderr: &str) { + assert!(stderr.contains("is for chain 31338"), "unexpected stderr:\n{stderr}"); + assert!(stderr.contains("command is using chain 31337"), "unexpected stderr:\n{stderr}"); +} + +fn cast_send_session_script(path_usd: &str) -> String { + format!( + r#"#!/bin/sh +set -eu +test -n "${{TEMPO_SESSION_ID:-}}" +tx_hash=$("${{CAST_BIN}}" send "{path_usd}" 'transfer(address,uint256)' "{recipient}" 0 --rpc-url "${{RPC_URL}}" --tempo.fee-token "{path_usd}" --async) +"${{CAST_BIN}}" receipt "$tx_hash" --rpc-url "${{RPC_URL}}" >/dev/null +printf '%s\n' "$tx_hash" +"#, + recipient = accounts::ADDR3, + ) +} + +fn create_session(cmd: &mut TestCommand, tempo_home: &Path, chain_id: &str) -> (String, String) { + create_session_with_scope(cmd, tempo_home, chain_id, &path_usd()) +} + +fn create_session_with_scope( + cmd: &mut TestCommand, + tempo_home: &Path, + chain_id: &str, + scope: &str, +) -> (String, String) { + cmd.cast_fuse(); + cmd.env("TEMPO_HOME", tempo_home); + let create_output = cmd + .args([ + "--json", + "wallet", + "session", + "create", + "--root", + accounts::ADDR1, + "--chain-id", + chain_id, + "--expires", + "10m", + "--scope", + scope, + "--spend-limit", + "PathUSD=1000000", + "--spend-limit", + "AlphaUSD=1000000", + "--private-key", + accounts::PK1, + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + let created: serde_json::Value = + serde_json::from_str(create_output.trim()).expect("session create emits JSON"); + let session_id = created["session_id"].as_str().expect("session_id").to_string(); + let key_address = created["key_address"].as_str().expect("key_address").to_string(); + (session_id, key_address) +} + +fn assert_accounts_store_key_retired(tempo_home: &Path, context: &str) { + let store_file = tempo_home.join("wallet/store.json"); + let contents = fs::read_to_string(&store_file).expect("store.json exists"); + let store: serde_json::Value = serde_json::from_str(&contents).expect("valid store.json"); + let keys = + store["tempo-cli.store"]["state"]["accessKeys"].as_array().expect("accessKeys array"); + assert!( + keys.iter().all(|key| key.get("privateKey").is_none()), + "{context} managed key must not retain private key material:\n{contents}" + ); + assert!(!tempo_home.join("wallet/sessions.toml").exists()); +} + +fn assert_accounts_store_key_signable(tempo_home: &Path, context: &str) { + let store_file = tempo_home.join("wallet/store.json"); + let contents = fs::read_to_string(&store_file).expect("store.json exists"); + let store: serde_json::Value = serde_json::from_str(&contents).expect("valid store.json"); + let keys = + store["tempo-cli.store"]["state"]["accessKeys"].as_array().expect("accessKeys array"); + assert!( + keys.iter().any(|key| key.get("privateKey").is_some()), + "{context} managed key should retain private key material:\n{contents}" + ); + assert!(!tempo_home.join("wallet/sessions.toml").exists()); +} + +fn assert_async_tx_hash(stdout: &str, command: &str) { + assert!( + stdout.trim().starts_with("0x"), + "expected {command} --async to print a tx hash, got:\n{stdout}" + ); +} + +fn assert_contains_tx_hash(stdout: &str, command: &str) { + assert!( + stdout.lines().any(|line| { + let line = line.trim(); + line.len() == 66 + && line.starts_with("0x") + && line[2..].chars().all(|c| c.is_ascii_hexdigit()) + }), + "expected {command} to print a tx hash, got:\n{stdout}" + ); +} + +fn assert_session_cleanup_failure(stderr: &str) { + assert!( + stderr.contains("failed to clean up Tempo session after inner command"), + "unexpected stderr:\n{stderr}" + ); + assert!( + stderr.contains("session key is not provisioned on-chain yet"), + "unexpected stderr:\n{stderr}" + ); +} + +// `cast keychain rl --json` must emit `{"remaining":""}`, not a bare string. +#[casttest] +async fn keychain_rl_json_is_object(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let path_usd = path_usd(); + + let output = cmd + .args([ + "keychain", + "rl", + accounts::ADDR1, + accounts::ADDR2, + &path_usd, + "--rpc-url", + &rpc, + "--rpc-timeout", + "5", + "--json", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + let parsed: serde_json::Value = + parse_json(output.trim()).expect("cast keychain rl --json should emit valid JSON"); + assert!(parsed.is_object(), "expected JSON object, got: {output}"); + assert!( + parsed.get("remaining").is_some(), + "expected 'remaining' key in JSON output, got: {output}" + ); + // Must not be a bare string (old bug: `"0"`) + assert!(!parsed.is_string(), "JSON output must not be a bare string, got: {output}"); +} + +// `cast keychain authorize --tempo.print-sponsor-hash --json` must emit +// `{"sponsor_hash":"0x..."}`, not a raw hex string. +#[casttest] +async fn keychain_authorize_sponsor_hash_json_is_object(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + + let output = cmd + .args([ + "keychain", + "authorize", + accounts::ADDR2, // key to authorize + "--private-key", + accounts::PK1, + "--rpc-url", + &rpc, + "--tempo.print-sponsor-hash", + "--json", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + let parsed: serde_json::Value = parse_json(output.trim()) + .expect("cast keychain authorize --tempo.print-sponsor-hash --json should emit valid JSON"); + assert!(parsed.is_object(), "expected JSON object, got: {output}"); + let hash = parsed + .get("sponsor_hash") + .and_then(|v| v.as_str()) + .unwrap_or_else(|| panic!("expected 'sponsor_hash' key in JSON output, got: {output}")); + assert!(hash.starts_with("0x"), "sponsor_hash should be 0x-prefixed, got: {hash}"); + assert_eq!(hash.len(), 66, "sponsor_hash should be 32-byte hex (66 chars), got: {hash}"); +} + +#[casttest] +async fn keychain_rejects_remote_sponsor_instead_of_ignoring_it(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + + let stderr = cmd + .cast_fuse() + .args([ + "keychain", + "authorize", + accounts::ADDR2, + "--private-key", + accounts::PK1, + "--rpc-url", + &rpc, + "--sponsor-url", + "http://localhost:1", + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + + assert!(stderr.contains("--sponsor-url is not supported by cast keychain"), "{stderr}"); +} + +// TODO: remove this check once browser supports T5/T6 KeyAuthorization fields +#[casttest] +fn key_authorization_sign_rejects_browser_witness_before_browser_run(cmd: _) { + let stderr = cmd + .args([ + "key-authorization", + "sign", + accounts::ADDR2, + "--chain-id", + "31337", + "--witness", + "0x5353535353535353535353535353535353535353535353535353535353535353", + "--browser", + "--browser-disable-open", + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + + assert!( + stderr.contains( + "browser key authorization signing does not support T5/T6 fields yet: witness, admin, account" + ), + "unexpected stderr:\n{stderr}" + ); + assert!( + !stderr.contains("Waiting for browser wallet connection"), + "browser flow should not start before rejecting --browser --witness:\n{stderr}" + ); +} + +// Offline: signing an admin key authorization round-trips `is_admin`/`account` into the JSON. +#[casttest] +fn key_authorization_sign_admin_emits_admin_json(cmd: _) { + // `sign --admin` derives the bound account from the signer (it has no auth-level --account), + // so signing with PK1 binds the authorization to ADDR1. + let output = cmd + .args([ + "key-authorization", + "sign", + accounts::ADDR2, + "--chain-id", + "31337", + "--admin", + "--private-key", + accounts::PK1, + "--json", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + let parsed: serde_json::Value = parse_json(output.trim()) + .expect("cast key-authorization sign --admin --json should emit valid JSON"); + assert_eq!(parsed["is_admin"], serde_json::Value::Bool(true), "got: {output}"); + // The bound account equals the signer (ADDR1), not the authorized key (ADDR2). + assert_eq!( + parsed["account"].as_str().map(str::to_lowercase), + Some(accounts::ADDR1.to_lowercase()), + "got: {output}" + ); + assert!( + parsed["signed_key_authorization"].as_str().is_some_and(|s| s.starts_with("0x")), + "got: {output}" + ); +} + +// Offline: `--admin` without `--account` is rejected before any signing happens. +#[casttest] +fn key_authorization_encode_admin_requires_account(cmd: _) { + let stderr = cmd + .args(["key-authorization", "encode", accounts::ADDR2, "--chain-id", "31337", "--admin"]) + .assert_failure() + .get_output() + .stderr_lossy(); + + assert!(stderr.contains("--admin requires --account"), "unexpected stderr:\n{stderr}"); +} + +// Offline: an admin key authorization cannot carry an expiry. +#[casttest] +fn key_authorization_encode_admin_rejects_expiry(cmd: _) { + let stderr = cmd + .args([ + "key-authorization", + "encode", + accounts::ADDR2, + "--chain-id", + "31337", + "--admin", + "--account", + accounts::ADDR1, + "--expiry", + "1782647677", + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + + assert!(stderr.contains("--expiry"), "unexpected stderr:\n{stderr}"); +} + +// Offline: `key-authorization inspect` decodes a signed admin authorization and exposes T6 fields. +#[casttest] +fn key_authorization_inspect_signed_admin_json(cmd: _) { + let signed = cmd + .args([ + "key-authorization", + "sign", + accounts::ADDR2, + "--chain-id", + "31337", + "--admin", + "--private-key", + accounts::PK1, // root account ADDR1; the bound account is derived from the signer + ]) + .assert_success() + .get_output() + .stdout_lossy(); + let signed = signed.trim().to_string(); + + let output = cmd + .cast_fuse() + .args(["key-authorization", "inspect", &signed, "--json"]) + .assert_success() + .get_output() + .stdout_lossy(); + + let parsed: serde_json::Value = parse_json(output.trim()) + .expect("cast key-authorization inspect --json should emit valid JSON"); + assert_eq!(parsed["signed"], serde_json::Value::Bool(true), "got: {output}"); + assert_eq!(parsed["is_admin"], serde_json::Value::Bool(true), "got: {output}"); + assert_eq!( + parsed["account"].as_str().map(str::to_lowercase), + Some(accounts::ADDR1.to_lowercase()), + "got: {output}" + ); + assert_eq!( + parsed["signer"].as_str().map(str::to_lowercase), + Some(accounts::ADDR1.to_lowercase()), + "got: {output}" + ); +} + +// Offline: inspecting with a mismatched `--account` rejects a replayed admin authorization. +#[casttest] +fn key_authorization_inspect_account_mismatch_rejected(cmd: _) { + let signed = cmd + .args([ + "key-authorization", + "sign", + accounts::ADDR2, + "--chain-id", + "31337", + "--admin", + "--private-key", + accounts::PK1, // bound to ADDR1 + ]) + .assert_success() + .get_output() + .stdout_lossy(); + let signed = signed.trim().to_string(); + + let stderr = cmd + .cast_fuse() + .args(["key-authorization", "inspect", &signed, "--account", accounts::ADDR2]) + .assert_failure() + .get_output() + .stderr_lossy(); + + assert!(stderr.contains("is bound to account"), "unexpected stderr:\n{stderr}"); +} + +// On-chain (T6): authorize an admin key, then confirm it via `keychain is-admin --json`. +#[casttest] +async fn keychain_authorize_admin_then_is_admin(cmd: _) { + use tempo_hardfork::TempoHardfork; + let (_, handle) = + anvil::spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T6.into()))).await; + let rpc = handle.http_endpoint(); + + cmd.cast_fuse() + .args([ + "keychain", + "authorize", + accounts::ADDR2, // admin key to authorize + "--admin", + "--private-key", + accounts::PK1, // root account ADDR1 + "--rpc-url", + &rpc, + ]) + .assert_success(); + + let output = cmd + .cast_fuse() + .args([ + "keychain", + "is-admin", + accounts::ADDR1, // root account + accounts::ADDR2, // admin key + "--rpc-url", + &rpc, + "--json", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + let parsed: serde_json::Value = + parse_json(output.trim()).expect("cast keychain is-admin --json should emit valid JSON"); + assert!(parsed.is_object(), "expected JSON object, got: {output}"); + assert_eq!(parsed["is_admin"], serde_json::Value::Bool(true), "got: {output}"); +} + +// On-chain: authorize a regular access key, then use the real `cast send` binary to sign, +// broadcast, and mine an account transaction with it. +#[casttest] +async fn send_with_authorized_access_key_succeeds(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let path_usd = path_usd(); + + cmd.cast_fuse() + .args([ + "keychain", + "authorize", + accounts::ADDR2, + "--private-key", + accounts::PK1, + "--rpc-url", + &rpc, + ]) + .assert_success(); + + let output = cmd + .cast_fuse() + .args([ + "send", + &path_usd, + "approve(address,uint256)", + accounts::ADDR3, + "0", + "--tempo.access-key", + accounts::PK2, + "--tempo.root-account", + accounts::ADDR1, + "--tempo.fee-token", + &path_usd, + "--rpc-url", + &rpc, + "--json", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + let receipt: serde_json::Value = + parse_json(output.trim()).expect("cast send emits a JSON receipt"); + assert!(receipt["transactionHash"].is_string(), "unexpected receipt: {output}"); + assert_eq!(receipt["status"], "0x1", "unexpected receipt: {output}"); +} + +// On-chain: key authorization and `keychain set-scope` keep the tuple ABI through T14. +#[casttest] +async fn keychain_set_scope_succeeds_through_t14(cmd: _) { + for hardfork in [ + TempoHardfork::T10, + TempoHardfork::T11, + TempoHardfork::T12, + TempoHardfork::T13, + TempoHardfork::T14, + ] { + let (_, handle) = + anvil::spawn(NodeConfig::test_tempo().with_hardfork(Some(hardfork.into()))).await; + let rpc = handle.http_endpoint(); + + cmd.cast_fuse() + .args([ + "keychain", + "authorize", + accounts::ADDR2, + "--private-key", + accounts::PK1, + "--rpc-url", + &rpc, + ]) + .assert_success(); + + cmd.cast_fuse() + .args([ + "keychain", + "set-scope", + accounts::ADDR2, + "--scope", + accounts::ADDR3, + "--private-key", + accounts::PK1, + "--rpc-url", + &rpc, + ]) + .assert_success(); + + let output = cmd + .cast_fuse() + .args([ + "keychain", + "inspect", + accounts::ADDR2, + "--root-account", + accounts::ADDR1, + "--rpc-url", + &rpc, + "--json", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + let parsed: serde_json::Value = parse_json(output.trim()).unwrap(); + assert_eq!( + parsed["data"]["allowed_calls"]["scopes"][0]["target"].as_str().map(str::to_lowercase), + Some(accounts::ADDR3.to_lowercase()), + "unexpected {hardfork:?} key info: {output}" + ); + } +} + +// Scope presence is preserved by both ordinary and witness-based authorization. +#[casttest] +async fn keychain_authorize_preserves_empty_scopes(cmd: _) { + for (hardfork, witness) in [(TempoHardfork::T3, false), (TempoHardfork::T5, true)] { + for (scope_args, mode) in [(&[][..], "any"), (&["--scopes", "[]"][..], "none")] { + let (_, handle) = + anvil::spawn(NodeConfig::test_tempo().with_hardfork(Some(hardfork.into()))).await; + let rpc = handle.http_endpoint(); + let authorize = cmd.cast_fuse(); + authorize + .args([ + "keychain", + "authorize", + accounts::ADDR2, + "--private-key", + accounts::PK1, + "--rpc-url", + &rpc, + ]) + .args(scope_args); + if witness { + authorize.args([ + "--witness", + "0x0000000000000000000000000000000000000000000000000000000000000000", + ]); + } + authorize.assert_success(); + + cmd.cast_fuse() + .args([ + "keychain", + "inspect", + accounts::ADDR2, + "--root-account", + accounts::ADDR1, + "--rpc-url", + &rpc, + "--json", + ]) + .assert_json_stdout( + json!({ + "schema_version": 1, + "success": true, + "data": { + "root_account": accounts::ADDR1.to_lowercase(), + "key_id": accounts::ADDR2.to_lowercase(), + "provisioned": true, + "type": "secp256k1", + "role": "limited", + "is_admin": false, + "expiry": u64::MAX, + "expiry_human": "never", + "enforce_limits": false, + "is_revoked": false, + "limits": [], + "allowed_calls": { "mode": mode, "scopes": [] } + }, + "errors": [], + "warnings": [] + }) + .to_string(), + ); + } + } +} + +#[casttest] +async fn keychain_authorize_rejects_scopes_before_t3(cmd: _) { + let (_, handle) = + anvil::spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T2.into()))).await; + let rpc = handle.http_endpoint(); + for scope_args in [["--scopes", "[]"], ["--scope", accounts::ADDR3]] { + cmd.cast_fuse() + .args(["keychain", "authorize", accounts::ADDR2, "--rpc-url", &rpc]) + .args(scope_args) + .assert_failure() + .stderr_eq(str![[r#" +Error: call scopes (--scope / --scopes) require a Tempo T3-capable chain + +"#]]) + .stdout_eq(str![""]); + } +} + +#[casttest] +async fn send_with_local_sponsor_reports_sponsor_as_fee_payer(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let path_usd = path_usd(); + + let output = cmd + .cast_fuse() + .args([ + "send", + &path_usd, + "approve(address,uint256)", + accounts::ADDR3, + "0", + "--private-key", + accounts::PK1, + "--tempo.fee-token", + &path_usd, + "--tempo.sponsor", + accounts::ADDR2, + "--tempo.sponsor-signer", + &format!("private-key://{}", accounts::PK2), + "--rpc-url", + &rpc, + "--json", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + let receipt: serde_json::Value = + parse_json(output.trim()).expect("cast send emits a JSON receipt"); + let fee_payer: alloy_primitives::Address = + receipt["feePayer"].as_str().expect("feePayer").parse().expect("valid feePayer"); + assert_eq!( + fee_payer, + accounts::ADDR2.parse::().unwrap(), + "unexpected receipt: {output}" + ); + let fee_token: alloy_primitives::Address = + receipt["feeToken"].as_str().expect("feeToken").parse().expect("valid feeToken"); + assert_eq!( + fee_token, + path_usd.parse::().unwrap(), + "unexpected receipt: {output}" + ); +} + +#[casttest] +async fn send_uses_access_key_from_accounts_store(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let path_usd = path_usd(); + + cmd.cast_fuse() + .args([ + "keychain", + "authorize", + accounts::ADDR2, + "--private-key", + accounts::PK1, + "--rpc-url", + &rpc, + ]) + .assert_success(); + + let tempo_home = tempfile::tempdir().unwrap(); + write_accounts_store(tempo_home.path(), 31337); + cmd.cast_fuse(); + cmd.env("TEMPO_HOME", tempo_home.path()); + let output = cmd + .args([ + "send", + &path_usd, + "approve(address,uint256)", + accounts::ADDR3, + "0", + "--from", + accounts::ADDR1, + "--tempo.fee-token", + &path_usd, + "--rpc-url", + &rpc, + "--json", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + let receipt: serde_json::Value = + parse_json(output.trim()).expect("cast send emits a JSON receipt"); + assert_eq!(receipt["status"], "0x1", "unexpected receipt: {output}"); +} + +#[casttest] +async fn send_with_accounts_store_and_remote_sponsor_sync_succeeds(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let path_usd = path_usd(); + + cmd.cast_fuse() + .args([ + "keychain", + "authorize", + accounts::ADDR2, + "--private-key", + accounts::PK1, + "--rpc-url", + &rpc, + ]) + .assert_success(); + + let fee_payer: PrivateKeySigner = accounts::PK3.parse().unwrap(); + let (sponsor_url, sponsor) = spawn_fee_payer_service(fee_payer.clone(), PATH_USD_ADDRESS); + let tempo_home = tempfile::tempdir().unwrap(); + write_accounts_store(tempo_home.path(), 31337); + cmd.cast_fuse(); + cmd.env("TEMPO_HOME", tempo_home.path()); + let output = cmd + .args([ + "send", + &path_usd, + "approve(address,uint256)", + accounts::ADDR3, + "0", + "--from", + accounts::ADDR1, + "--rpc-url", + &rpc, + "--sponsor-url", + &sponsor_url, + "--sync", + "--json", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + sponsor.join().expect("fee payer service completed"); + + let receipt: serde_json::Value = + parse_json(output.trim()).expect("cast send emits a JSON receipt"); + assert_eq!(receipt["status"], "0x1", "unexpected receipt: {output}"); + assert_eq!( + receipt["feePayer"].as_str().map(str::to_lowercase), + Some(fee_payer.address().to_string().to_lowercase()), + "unexpected receipt: {output}" + ); + assert_eq!( + receipt["feeToken"].as_str().map(str::to_lowercase), + Some(path_usd.to_lowercase()), + "unexpected receipt: {output}" + ); +} + +#[casttest] +async fn tempo_unlocked_send_does_not_require_accounts_store_entry(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let path_usd = path_usd(); + let tempo_home = tempfile::tempdir().unwrap(); + write_accounts_store(tempo_home.path(), 31337); + + cmd.cast_fuse(); + cmd.env("TEMPO_HOME", tempo_home.path()); + let output = cmd + .args([ + "send", + &path_usd, + "approve(address,uint256)", + accounts::ADDR3, + "0", + "--from", + accounts::ADDR2, + "--unlocked", + "--tempo.fee-token", + &path_usd, + "--rpc-url", + &rpc, + "--json", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + let receipt: serde_json::Value = + parse_json(output.trim()).expect("cast send emits a JSON receipt"); + assert_eq!(receipt["status"], "0x1", "unexpected receipt: {output}"); +} + +#[casttest] +async fn tempo_accounts_store_does_not_change_ethereum_send_or_mktx(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + let rpc = handle.http_endpoint(); + let tempo_home = tempfile::tempdir().unwrap(); + write_accounts_store(tempo_home.path(), 31337); + + cmd.cast_fuse(); + cmd.env("TEMPO_HOME", tempo_home.path()); + let output = cmd + .args([ + "send", + accounts::ADDR3, + "--value", + "1", + "--from", + accounts::ADDR1, + "--unlocked", + "--rpc-url", + &rpc, + "--json", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + let receipt: serde_json::Value = + parse_json(output.trim()).expect("cast send emits a JSON receipt"); + assert_eq!(receipt["status"], "0x1", "unexpected receipt: {output}"); + + cmd.cast_fuse(); + cmd.env("TEMPO_HOME", tempo_home.path()); + let output = cmd + .args([ + "mktx", + accounts::ADDR3, + "--value", + "1", + "--from", + accounts::ADDR1, + "--ethsign", + "--rpc-url", + &rpc, + ]) + .assert_success() + .get_output() + .stdout_lossy(); + let raw = alloy_primitives::hex::decode(output.trim()).expect("decode raw transaction"); + TxEnvelope::decode_2718(&mut raw.as_slice()).expect("decode Ethereum transaction"); +} + +#[casttest] +async fn corrupt_tempo_store_does_not_break_ethereum_unlocked_send(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + let rpc = handle.http_endpoint(); + let tempo_home = tempfile::tempdir().unwrap(); + let wallet_dir = tempo_home.path().join("wallet"); + fs::create_dir_all(&wallet_dir).unwrap(); + fs::write(wallet_dir.join("store.json"), b"not json").unwrap(); + + cmd.cast_fuse(); + cmd.env("TEMPO_HOME", tempo_home.path()); + cmd.args([ + "send", + accounts::ADDR3, + "--value", + "1", + "--from", + accounts::ADDR1, + "--unlocked", + "--rpc-url", + &rpc, + ]) + .assert_success(); + + cmd.cast_fuse(); + cmd.env("TEMPO_HOME", tempo_home.path()); + cmd.args(["call", accounts::ADDR3, "--from", accounts::ADDR1, "--rpc-url", &rpc]) + .assert_success() + .stdout_eq("0x\n"); +} + +// On-chain (T6): a keychain signature from an authorized admin key passes `verify-admin`. +#[casttest] +async fn keychain_verify_admin_accepts_admin_signature(cmd: _) { + use alloy_primitives::{Address, B256, hex}; + use alloy_signer::SignerSync; + use alloy_signer_local::PrivateKeySigner; + use tempo_hardfork::TempoHardfork; + use tempo_primitives::transaction::{KeychainSignature, PrimitiveSignature, TempoSignature}; + + let (_, handle) = + anvil::spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T6.into()))).await; + let rpc = handle.http_endpoint(); + + // Authorize ADDR2 as an admin key for ADDR1 (PK1). + cmd.cast_fuse() + .args([ + "keychain", + "authorize", + accounts::ADDR2, + "--admin", + "--private-key", + accounts::PK1, + "--rpc-url", + &rpc, + ]) + .assert_success(); + + // Build a keychain signature over an arbitrary hash, signed by the admin key (PK2) for ADDR1. + let account: Address = accounts::ADDR1.parse().unwrap(); + let hash = B256::repeat_byte(0x42); + let key: PrivateKeySigner = accounts::PK2.parse().unwrap(); + let signing_hash = KeychainSignature::signing_hash(hash, account); + let inner = key.sign_hash_sync(&signing_hash).unwrap(); + let signature = TempoSignature::Keychain(KeychainSignature::new( + account, + PrimitiveSignature::Secp256k1(inner), + )) + .to_bytes(); + let signature_hex = hex::encode_prefixed(signature); + + let output = cmd + .cast_fuse() + .args([ + "keychain", + "verify-admin", + accounts::ADDR1, + &hash.to_string(), + &signature_hex, + "--rpc-url", + &rpc, + "--json", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + let parsed: serde_json::Value = parse_json(output.trim()) + .expect("cast keychain verify-admin --json should emit valid JSON"); + assert_eq!(parsed["valid"], serde_json::Value::Bool(true), "got: {output}"); +} + +// On-chain (T6): `keychain authorize --admin` rejects spending limits before submitting. +#[casttest] +async fn keychain_authorize_admin_rejects_limits(cmd: _) { + use tempo_hardfork::TempoHardfork; + let (_, handle) = + anvil::spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T6.into()))).await; + let rpc = handle.http_endpoint(); + + let stderr = cmd + .args([ + "keychain", + "authorize", + accounts::ADDR2, + "--admin", + "--enforce-limits", + "--private-key", + accounts::PK1, + "--rpc-url", + &rpc, + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + + assert!(stderr.contains("spending limits"), "unexpected stderr:\n{stderr}"); +} + +// An access-key signer is rejected for admin-gated keychain mutators even when it is an active +// admin key, because submitting the mutator as access-key-signed calldata reverts on-chain with +// `UnauthorizedCaller()` on the pinned Tempo build. +#[casttest] +async fn keychain_access_key_cannot_submit_admin_mutator(cmd: _) { + use tempo_hardfork::TempoHardfork; + let (_, handle) = + anvil::spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T6.into()))).await; + let rpc = handle.http_endpoint(); + + // Root ADDR1 (PK1) authorizes ADDR2 (PK2) as an admin key. + cmd.cast_fuse() + .args([ + "keychain", + "authorize", + accounts::ADDR2, + "--admin", + "--private-key", + accounts::PK1, + "--rpc-url", + &rpc, + ]) + .assert_success(); + + // ADDR2 is an active admin key, but the access-key mutator is still rejected locally. + let stderr = cmd + .cast_fuse() + .args([ + "keychain", + "authorize", + accounts::ADDR3, + "--tempo.access-key", + accounts::PK2, + "--tempo.root-account", + accounts::ADDR1, + "--rpc-url", + &rpc, + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + + assert!( + stderr.contains("currently reverts on-chain with UnauthorizedCaller()"), + "unexpected stderr:\n{stderr}" + ); +} + +// Offline (T6): an admin access key signing a child authorization binds the authorization to the +// root account it manages, not to the signing admin key. This covers the delegated admin-signing +// path in `run_key_auth_sign`. +#[casttest] +fn key_authorization_sign_admin_access_key_binds_root_account(cmd: _) { + let output = cmd + .args([ + "key-authorization", + "sign", + accounts::ADDR3, // the child key being authorized + "--chain-id", + "31337", + "--admin", + "--tempo.access-key", + accounts::PK2, // admin key ADDR2 signs + "--tempo.root-account", + accounts::ADDR1, // on behalf of root ADDR1 + "--json", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + let parsed: serde_json::Value = parse_json(output.trim()) + .expect("cast key-authorization sign --json should emit valid JSON"); + assert_eq!(parsed["is_admin"], serde_json::Value::Bool(true), "got: {output}"); + // The signer is the admin key (ADDR2)... + assert_eq!( + parsed["signer"].as_str().map(str::to_lowercase), + Some(accounts::ADDR2.to_lowercase()), + "got: {output}" + ); + // ...but the bound account is the root account being modified (ADDR1), not the signer. + assert_eq!( + parsed["account"].as_str().map(str::to_lowercase), + Some(accounts::ADDR1.to_lowercase()), + "got: {output}" + ); +} + +#[casttest] +fn tempo_import_access_key_writes_accounts_store(prj: _, cmd: _) { + let tempo_home = prj.root().join("tempo-home"); + let authorization = cmd + .args([ + "key-authorization", + "sign", + accounts::ADDR2, + "--chain-id", + "42431", + "--private-key", + accounts::PK1, + "--bind-account", + accounts::ADDR1, + ]) + .assert_success() + .get_output() + .stdout_lossy() + .trim() + .to_owned(); + + cmd.cast_fuse(); + cmd.env("TEMPO_HOME", &tempo_home); + cmd.args([ + "tempo", + "import-access-key", + "--account", + accounts::ADDR1, + "--access-key", + accounts::PK2, + "--authorization", + &authorization, + ]) + .assert_success(); + + let store = TempoAccountsStore::open(tempo_home.join("wallet/store.json")) + .expect("open Accounts store"); + assert_eq!(store.active_account().unwrap().to_string(), accounts::ADDR1); + let keys = store.access_keys().expect("read imported access key"); + assert_eq!(keys.len(), 1); + assert_eq!(keys[0].address().to_string(), accounts::ADDR2); + assert_eq!(keys[0].chain_id(), 42431); + assert!(keys[0].key_authorization().is_some()); + assert!(keys[0].is_locally_signable()); +} + +#[casttest] +async fn keychain_doctor_json_keeps_report_schema_version(cmd: _) { + let output = cmd + .args([ + "keychain", + "doctor", + accounts::ADDR2, + "--root-account", + accounts::ADDR1, + "--rpc-url", + "http://127.0.0.1:1", + "--json", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + let parsed: serde_json::Value = serde_json::from_str(output.trim()) + .expect("cast keychain doctor --json should emit valid JSON"); + assert_eq!(parsed["schema_version"], 1); +} + +#[casttest] +fn keychain_show_json_no_match_returns_empty_array(prj: _, cmd: _) { + let tempo_home = prj.root().join("tempo-home"); + fs::create_dir_all(tempo_home.join("wallet")).expect("create Tempo wallet dir"); + let store = serde_json::json!({ + "tempo-cli.store": { + "state": { + "activeAccount": 0, + "chainId": 4217, + "accounts": [{"address": accounts::ADDR2}], + "accessKeys": [], + }, + }, + }); + fs::write( + tempo_home.join("wallet/store.json"), + serde_json::to_vec(&store).expect("serialize store"), + ) + .expect("write store.json"); + + cmd.env("TEMPO_HOME", &tempo_home); + + let output = cmd + .args(["keychain", "show", accounts::ADDR1, "--json"]) + .assert_success() + .get_output() + .stdout_lossy(); + + let parsed: serde_json::Value = serde_json::from_str(output.trim()) + .expect("cast keychain show --json should emit valid JSON"); + assert_eq!(parsed["schema_version"], 1); + assert_eq!( + parsed["data"].as_array().expect("keychain show --json data should be an array").len(), + 0 + ); +} + +#[casttest] +fn keychain_eip7702_auth_disclosure(cmd: _) { + let authorization = signed_eip7702_authorization(); + let args = [ + "keychain", + "revoke", + accounts::ADDR2, + "--auth", + &authorization, + "--private-key", + accounts::PK1, + "--chain", + "31337", + "--rpc-url", + "http://127.0.0.1:1", + ]; + + cmd.args(args) + .stdin("n\n") + .assert_success() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Warning: This command will send a signed EIP-7702 authorization to the RPC endpoint. The authorization can be submitted on-chain by anyone once its nonce is valid. + +Continue anyway? [y/N] Aborted. + +"#]]); + + let stderr = + cmd.cast_fuse().args(args).arg("--force").assert_failure().get_output().stderr_lossy(); + assert!(!stderr.contains("Continue anyway?"), "unexpected stderr:\n{stderr}"); + assert!(stderr.contains("error sending request"), "unexpected stderr:\n{stderr}"); +} + +#[casttest] +async fn keychain_eip7702_address_auth_uses_wallet_signer(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + + cmd.cast_fuse() + .args([ + "keychain", + "authorize", + accounts::ADDR2, + "--auth", + accounts::ADDR3, + "--private-key", + accounts::PK1, + "--rpc-url", + &rpc, + "--force", + ]) + .assert_success(); + + let output = cmd + .cast_fuse() + .args(["keychain", "check", accounts::ADDR1, accounts::ADDR2, "--rpc-url", &rpc, "--json"]) + .assert_success() + .get_output() + .stdout_lossy(); + let checked: serde_json::Value = + serde_json::from_str(output.trim()).expect("keychain check emits JSON"); + assert_eq!(checked["data"]["provisioned"], true); +} + +#[casttest] +async fn wallet_session_revoke_revokes_provisioned_key_on_chain(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let tempo_home = tempfile::tempdir().unwrap(); + let (session_id, key_address) = create_session(&mut cmd, tempo_home.path(), "31337"); + + cmd.cast_fuse() + .args([ + "keychain", + "authorize", + &key_address, + "--private-key", + accounts::PK1, + "--rpc-url", + &rpc, + ]) + .assert_success(); + + cmd.cast_fuse(); + cmd.env("TEMPO_HOME", tempo_home.path()); + let authorization = signed_eip7702_authorization(); + cmd.args([ + "wallet", + "session", + "revoke", + &session_id, + "--auth", + &authorization, + "--private-key", + accounts::PK1, + "--rpc-url", + &rpc, + ]) + .stdin("n\n") + .assert_success() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Warning: This command will send a signed EIP-7702 authorization to the RPC endpoint. The authorization can be submitted on-chain by anyone once its nonce is valid. + +Continue anyway? [y/N] Aborted. + +"#]]); + assert_accounts_store_key_signable(tempo_home.path(), "aborted revoke"); + + cmd.cast_fuse(); + cmd.env("TEMPO_HOME", tempo_home.path()); + cmd.args([ + "wallet", + "session", + "revoke", + &session_id, + "--private-key", + accounts::PK1, + "--rpc-url", + &rpc, + ]) + .assert_success(); + + let check_output = cmd + .cast_fuse() + .args(["keychain", "check", accounts::ADDR1, &key_address, "--rpc-url", &rpc, "--json"]) + .assert_success() + .get_output() + .stdout_lossy(); + let checked: serde_json::Value = + serde_json::from_str(check_output.trim()).expect("keychain check emits JSON"); + let checked = &checked["data"]; + assert_eq!(checked["provisioned"], false); + assert_eq!(checked["is_revoked"], true); + + assert_accounts_store_key_retired(tempo_home.path(), "revoked"); +} + +#[casttest] +async fn wallet_session_revoke_sponsor_hash_does_not_mark_revoked(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let tempo_home = tempfile::tempdir().unwrap(); + let (session_id, key_address) = create_session(&mut cmd, tempo_home.path(), "31337"); + + cmd.cast_fuse() + .args([ + "keychain", + "authorize", + &key_address, + "--private-key", + accounts::PK1, + "--rpc-url", + &rpc, + ]) + .assert_success(); + + cmd.cast_fuse(); + cmd.env("TEMPO_HOME", tempo_home.path()); + let output = cmd + .args([ + "wallet", + "session", + "revoke", + &session_id, + "--private-key", + accounts::PK1, + "--rpc-url", + &rpc, + "--tempo.print-sponsor-hash", + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + assert!( + output.contains("--tempo.print-sponsor-hash only prints a sponsor hash"), + "unexpected stderr:\n{output}" + ); + + let check_output = cmd + .cast_fuse() + .args(["keychain", "check", accounts::ADDR1, &key_address, "--rpc-url", &rpc, "--json"]) + .assert_success() + .get_output() + .stdout_lossy(); + let checked: serde_json::Value = + serde_json::from_str(check_output.trim()).expect("keychain check emits JSON"); + let checked = &checked["data"]; + assert_eq!(checked["provisioned"], true); + assert_eq!(checked["is_revoked"], false); + + assert_accounts_store_key_signable(tempo_home.path(), "active"); +} + +#[casttest] +async fn wallet_session_revoke_marks_unprovisioned_key_revoked_locally(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let tempo_home = tempfile::tempdir().unwrap(); + let (session_id, key_address) = create_session(&mut cmd, tempo_home.path(), "31337"); + + cmd.cast_fuse(); + cmd.env("TEMPO_HOME", tempo_home.path()); + let output = cmd + .args([ + "--json", + "wallet", + "session", + "revoke", + &session_id, + "--private-key", + accounts::PK1, + "--rpc-url", + &rpc, + ]) + .assert_success() + .get_output() + .stdout_lossy(); + let revoked: serde_json::Value = + serde_json::from_str(output.trim()).expect("session revoke emits JSON"); + assert_eq!(revoked["status"], "revoked"); + assert_eq!(revoked["reason"], "not_provisioned"); + + let check_output = cmd + .cast_fuse() + .args(["keychain", "check", accounts::ADDR1, &key_address, "--rpc-url", &rpc, "--json"]) + .assert_success() + .get_output() + .stdout_lossy(); + let checked: serde_json::Value = + serde_json::from_str(check_output.trim()).expect("keychain check emits JSON"); + let checked = &checked["data"]; + assert_eq!(checked["provisioned"], false); + assert_eq!(checked["is_revoked"], false); + + assert_accounts_store_key_retired(tempo_home.path(), "revoked"); +} + +#[casttest] +async fn wallet_session_revoke_local_cleans_key_without_rpc(cmd: _) { + let tempo_home = tempfile::tempdir().unwrap(); + let (session_id, _) = create_session(&mut cmd, tempo_home.path(), "31337"); + + cmd.cast_fuse(); + cmd.env("TEMPO_HOME", tempo_home.path()); + let output = cmd + .args(["--json", "wallet", "session", "revoke", &session_id, "--local"]) + .assert_success() + .get_output() + .stdout_lossy(); + let revoked: serde_json::Value = + serde_json::from_str(output.trim()).expect("session revoke --local emits JSON"); + assert_eq!(revoked["status"], "revoked"); + assert_eq!(revoked["reason"], "local"); + + assert_accounts_store_key_retired(tempo_home.path(), "revoked"); +} + +#[casttest] +async fn wallet_session_store_preserves_existing_access_keys(prj: _, cmd: _) { + let tempo_home = prj.root().join("tempo-home"); + write_accounts_store(&tempo_home, 31337); + let before = fs::read_to_string(tempo_home.join("wallet/store.json")).unwrap(); + let before: serde_json::Value = serde_json::from_str(&before).unwrap(); + let existing = before["tempo-cli.store"]["state"]["accessKeys"][0].clone(); + + let (session_id, key_address) = create_session(&mut cmd, &tempo_home, "31337"); + cmd.cast_fuse(); + cmd.env("TEMPO_HOME", &tempo_home); + cmd.args(["--json", "wallet", "session", "revoke", &session_id, "--local"]).assert_success(); + + let after = fs::read_to_string(tempo_home.join("wallet/store.json")).unwrap(); + let after: serde_json::Value = serde_json::from_str(&after).unwrap(); + let keys = after["tempo-cli.store"]["state"]["accessKeys"].as_array().unwrap(); + assert_eq!(keys.len(), 2); + assert_eq!( + keys.iter() + .find(|key| { + key["address"] + .as_str() + .is_some_and(|address| address.eq_ignore_ascii_case(accounts::ADDR2)) + }) + .unwrap(), + &existing + ); + let retired = keys + .iter() + .find(|key| { + key["address"] + .as_str() + .is_some_and(|address| address.eq_ignore_ascii_case(&key_address)) + }) + .unwrap(); + assert!(retired.get("privateKey").is_none()); + assert!(retired.get("keyAuthorization").is_some()); + assert!(!tempo_home.join("wallet/sessions.toml").exists()); +} + +#[casttest] +async fn wallet_session_revoke_wrong_chain_preserves_local_key(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let tempo_home = tempfile::tempdir().unwrap(); + let (session_id, _) = create_session(&mut cmd, tempo_home.path(), "31338"); + + cmd.cast_fuse(); + cmd.env("TEMPO_HOME", tempo_home.path()); + cmd.args([ + "wallet", + "session", + "revoke", + &session_id, + "--private-key", + accounts::PK1, + "--rpc-url", + &rpc, + ]) + .assert_failure(); + + assert_accounts_store_key_signable(tempo_home.path(), "active"); +} + +#[casttest] +async fn wallet_session_run_for_without_key_use_fails_closed_and_cleans_key_material(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let tempo_home = tempfile::tempdir().unwrap(); + let path_usd = path_usd(); + let child_dir = tempfile::tempdir().unwrap(); + let child_script = child_dir.path().join("session-child.sh"); + let child_session_out = child_dir.path().join("child-session-id.txt"); + fs::write( + &child_script, + r#"#!/bin/sh +set -eu +test -n "${TEMPO_SESSION_ID:-}" +store_file="${TEMPO_HOME}/wallet/store.json" +test ! -e "${TEMPO_HOME}/wallet/sessions.toml" +grep -q '"privateKey":' "${store_file}" +grep -q '"keyAuthorization":' "${store_file}" +printf '%s\n' "${TEMPO_SESSION_ID}" > "$1" +"#, + ) + .expect("write child script"); + + // Git Bash (the `sh` on Windows) treats backslashes as escapes, so embed the script + // paths with forward slashes; `to_slash_lossy` is a no-op on Unix. + let for_command = + format!("sh {} {}", child_script.to_slash_lossy(), child_session_out.to_slash_lossy()); + + cmd.cast_fuse(); + cmd.env("TEMPO_HOME", tempo_home.path()); + let stderr = cmd + .args([ + "wallet", + "session", + "--root", + accounts::ADDR1, + "--expires", + "10m", + "--scope", + &path_usd, + "--spend-limit", + "PathUSD=0", + "--for", + &for_command, + "--private-key", + accounts::PK1, + "--rpc-url", + &rpc, + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + assert_session_cleanup_failure(&stderr); + + let child_session_id = + fs::read_to_string(&child_session_out).expect("child wrote TEMPO_SESSION_ID"); + assert!( + child_session_id.trim().starts_with("0x"), + "unexpected child session id: {child_session_id}" + ); + assert_accounts_store_key_retired(tempo_home.path(), "revoking"); +} + +#[casttest] +async fn wallet_session_run_for_cast_send_submits_with_session_key(prj: _, cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let tempo_home = tempfile::tempdir().unwrap(); + let child_dir = tempfile::tempdir().unwrap(); + let child_script = child_dir.path().join("session-cast-send.sh"); + let path_usd = path_usd(); + fs::write(&child_script, cast_send_session_script(&path_usd)).expect("write child script"); + + let for_command = format!("sh {}", child_script.to_slash_lossy()); + + cmd.cast_fuse(); + cmd.env("TEMPO_HOME", tempo_home.path()); + cmd.env("CAST_BIN", prj.foundry_bin_path("cast")); + cmd.env("RPC_URL", &rpc); + let assertion = cmd + .args([ + "wallet", + "session", + "--root", + accounts::ADDR1, + "--expires", + "10m", + "--scope", + &path_usd, + "--spend-limit", + "PathUSD=1000000", + "--for", + &for_command, + "--private-key", + accounts::PK1, + "--rpc-url", + &rpc, + ]) + .assert_success(); + let stdout = assertion.get_output().stdout_lossy(); + + assert_contains_tx_hash(&stdout, "child cast send"); + assert_accounts_store_key_retired(tempo_home.path(), "revoked"); +} + +#[casttest] +async fn wallet_session_run_for_batch_send_submits_with_session_key(prj: _, cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let tempo_home = tempfile::tempdir().unwrap(); + let child_dir = tempfile::tempdir().unwrap(); + let child_script = child_dir.path().join("session-batch-send.sh"); + let path_usd = path_usd(); + let call = batch_send_transfer_call(&path_usd); + fs::write( + &child_script, + format!( + r#"#!/bin/sh +set -eu +test -n "${{TEMPO_SESSION_ID:-}}" +tx_hash=$("${{CAST_BIN}}" batch-send --call "{call}" --rpc-url "${{RPC_URL}}" --tempo.fee-token "{path_usd}" --async) +"${{CAST_BIN}}" receipt "$tx_hash" --rpc-url "${{RPC_URL}}" >/dev/null +printf '%s\n' "$tx_hash" +"#, + ), + ) + .expect("write child script"); + + let for_command = format!("sh {}", child_script.to_slash_lossy()); + + cmd.cast_fuse(); + cmd.env("TEMPO_HOME", tempo_home.path()); + cmd.env("CAST_BIN", prj.foundry_bin_path("cast")); + cmd.env("RPC_URL", &rpc); + let assertion = cmd + .args([ + "wallet", + "session", + "--root", + accounts::ADDR1, + "--expires", + "10m", + "--scope", + &path_usd, + "--spend-limit", + "PathUSD=1000000", + "--for", + &for_command, + "--private-key", + accounts::PK1, + "--rpc-url", + &rpc, + ]) + .assert_success(); + let stdout = assertion.get_output().stdout_lossy(); + + assert_contains_tx_hash(&stdout, "child cast batch-send"); + assert_accounts_store_key_retired(tempo_home.path(), "revoked"); +} + +#[casttest] +async fn wallet_session_run_for_forge_script_submits_with_session_key(prj: _, cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let tempo_home = tempfile::tempdir().unwrap(); + let path_usd = path_usd(); + + foundry_test_utils::util::initialize(prj.root()); + let script = prj.add_script( + "SessionForgeScript.s.sol", + &format!( + r#" +import "forge-std/Script.sol"; + +interface PathUsdLike {{ + function transfer(address to, uint256 amount) external returns (bool); +}} + +contract SessionForgeScript is Script {{ + function run() external {{ + vm.startBroadcast(); + PathUsdLike({path_usd}).transfer({recipient}, 0); + vm.stopBroadcast(); + }} +}} +"#, + recipient = accounts::ADDR3, + ), + ); + + let for_command = format!( + "{} script {} --tc SessionForgeScript --broadcast --timeout 1 --rpc-url {} --root {}", + prj.ensure_foundry_bin("forge").to_slash_lossy(), + script.to_slash_lossy(), + rpc, + prj.root().to_slash_lossy(), + ); + + cmd.cast_fuse(); + cmd.env("TEMPO_HOME", tempo_home.path()); + let assertion = cmd + .args([ + "wallet", + "session", + "--root", + accounts::ADDR1, + "--expires", + "10m", + "--target", + &path_usd, + "--selector", + "transfer(address,uint256)", + "--spend-limit", + "PathUSD=1000000", + "--spend-limit", + "AlphaUSD=1000000", + "--for", + &for_command, + "--private-key", + accounts::PK1, + "--rpc-url", + &rpc, + ]) + .assert_success(); + let stdout = assertion.get_output().stdout_lossy(); + let stderr = assertion.get_output().stderr_lossy(); + + assert!( + stdout.contains("ONCHAIN EXECUTION COMPLETE & SUCCESSFUL."), + "expected forge script broadcast completion\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + + let run_latest = foundry_common::fs::json_files(&prj.root().join("broadcast")) + .find(|path| path.ends_with("run-latest.json")) + .unwrap_or_else(|| { + panic!("expected forge broadcast artifact\nstdout:\n{stdout}\nstderr:\n{stderr}") + }); + let broadcast = fs::read_to_string(&run_latest).expect("read forge broadcast artifact"); + let broadcast: serde_json::Value = + serde_json::from_str(&broadcast).expect("forge broadcast artifact is valid JSON"); + let tx = &broadcast["transactions"][0]; + + assert_eq!(tx["transactionType"], "CALL", "unexpected forge broadcast tx: {tx}"); + assert_eq!(tx["function"], "transfer(address,uint256)", "unexpected forge broadcast tx: {tx}"); + assert_eq!( + tx["functionAbi"], "function transfer(address to, uint256 amount) returns (bool)", + "unexpected forge broadcast tx: {tx}" + ); + assert_eq!( + tx["contractAddress"].as_str().map(str::to_ascii_lowercase), + Some(path_usd.to_ascii_lowercase()), + "unexpected forge broadcast tx: {tx}" + ); + assert!( + tx["hash"].as_str().is_some_and(|hash| hash.starts_with("0x")), + "forge broadcast tx should have a submitted hash: {tx}" + ); + assert_accounts_store_key_retired(tempo_home.path(), "revoked"); +} + +#[casttest] +async fn batch_commands_validate_tempo_sender(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let call = batch_send_transfer_call(&path_usd()); + cmd.args([ + "keychain", + "authorize", + accounts::ADDR2, + "--private-key", + accounts::PK1, + "--rpc-url", + &rpc, + ]) + .assert_success(); + + let tempo_home = tempfile::tempdir().unwrap(); + write_accounts_store(tempo_home.path(), 31337); + let store_path = tempo_home.path().join("wallet/store.json"); + let mut store = + serde_json::from_slice::(&fs::read(&store_path).unwrap()).unwrap(); + store["tempo-cli.store"]["state"]["accounts"] + .as_array_mut() + .unwrap() + .push(serde_json::json!({ "address": accounts::ADDR3 })); + fs::write(&store_path, serde_json::to_vec(&store).unwrap()).unwrap(); + + for wallet_args in [ + &[][..], + &["--tempo.access-key", accounts::PK2, "--tempo.root-account", accounts::ADDR1][..], + ] { + for command in ["batch-send", "batch-mktx"] { + for from in [accounts::ADDR3, accounts::ADDR1] { + let batch = cmd.cast_fuse(); + batch.env("TEMPO_HOME", tempo_home.path()); + batch + .args([command, "--call", &call, "--from", from, "--rpc-url", &rpc]) + .args(wallet_args); + if from == accounts::ADDR3 { + batch.assert_failure().stdout_eq(str![""]).stderr_eq(str![[r#" +Error: sender 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC does not match Tempo account 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 + +"#]]); + } else { + batch.assert_success(); + } + } + } + } +} + +#[casttest] +async fn batch_send_uses_tempo_session_id_env(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let tempo_home = tempfile::tempdir().unwrap(); + let path_usd = path_usd(); + let call = batch_send_transfer_call(&path_usd); + let (session_id, _) = create_session(&mut cmd, tempo_home.path(), "31337"); + + cmd.cast_fuse(); + cmd.env("TEMPO_HOME", tempo_home.path()); + cmd.env("TEMPO_SESSION_ID", &session_id); + let stdout = cmd + .args([ + "batch-send", + "--call", + &call, + "--rpc-url", + &rpc, + "--tempo.fee-token", + &path_usd, + "--async", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + assert_async_tx_hash(&stdout, "cast batch-send"); +} + +#[casttest] +async fn batch_mktx_uses_tempo_session_id_env(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let tempo_home = tempfile::tempdir().unwrap(); + let path_usd = path_usd(); + let call = batch_send_transfer_call(&path_usd); + let (session_id, _) = create_session(&mut cmd, tempo_home.path(), "31337"); + + cmd.cast_fuse(); + cmd.env("TEMPO_HOME", tempo_home.path()); + cmd.env("TEMPO_SESSION_ID", &session_id); + let stdout = cmd + .args(["batch-mktx", "--call", &call, "--rpc-url", &rpc, "--tempo.fee-token", &path_usd]) + .assert_success() + .get_output() + .stdout_lossy(); + + assert!( + stdout.trim().starts_with("0x"), + "expected cast batch-mktx to print raw tx hex, got:\n{stdout}" + ); +} + +#[casttest] +async fn batch_mktx_raw_unsigned_resolves_tempo_access_key_metadata(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let path_usd = path_usd(); + let call = batch_send_transfer_call(&path_usd); + + cmd.cast_fuse(); + let stderr = cmd + .args([ + "batch-mktx", + "--call", + &call, + "--raw-unsigned", + "--from", + accounts::ADDR1, + "--nonce", + "0", + "--gas-price", + "1", + "--rpc-url", + &rpc, + "--tempo.fee-token", + &path_usd, + "--tempo.access-key", + accounts::PK2, + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + + assert!( + stderr.contains("--tempo.root-account is required when --tempo.access-key is set"), + "raw unsigned must still resolve Tempo access-key metadata, got:\n{stderr}" + ); +} + +#[casttest] +async fn virtual_master_registration_requires_t3(cmd: _) { + for hardfork in [TempoHardfork::T2, TempoHardfork::T3] { + for command in [ + ["vaddr", "create", "--owner", accounts::ADDR1], + ["tip20", "mine", accounts::ADDR1, "--register"], + ] { + let (_, handle) = + anvil::spawn(NodeConfig::test_tempo().with_hardfork(Some(hardfork.into()))).await; + let rpc = handle.http_endpoint(); + let register = cmd.cast_fuse(); + register.args(command).args([ + "--salt", + PRECOMPUTED_VADDR_SALT_FOR_ADDR1, + "--private-key", + accounts::PK1, + "--rpc-url", + &rpc, + ]); + if hardfork == TempoHardfork::T2 { + register.assert_failure().stderr_eq(str![[r#" +Error: virtual master registration requires a Tempo T3-capable AddressRegistry RPC + +"#]]); + } else { + register.assert_success(); + } + cmd.cast_fuse() + .args(["nonce", accounts::ADDR1, "--rpc-url", &rpc]) + .assert_success() + .stdout_eq(if hardfork == TempoHardfork::T2 { "0\n" } else { "1\n" }); + } + } +} + +#[casttest] +async fn vaddr_create_sync_json_uses_tempo_session_id_env(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let tempo_home = tempfile::tempdir().unwrap(); + let (session_id, _) = + create_session_with_scope(&mut cmd, tempo_home.path(), "31337", ADDRESS_REGISTRY); + + cmd.cast_fuse(); + cmd.env("TEMPO_HOME", tempo_home.path()); + cmd.env("TEMPO_SESSION_ID", &session_id); + let stdout = cmd + .args([ + "--json", + "vaddr", + "create", + "--owner", + accounts::ADDR1, + "--salt", + PRECOMPUTED_VADDR_SALT_FOR_ADDR1, + "--rpc-url", + &rpc, + "--sync", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + let envelope: serde_json::Value = parse_json(stdout.trim()).expect("vaddr create emits JSON"); + assert!( + envelope["data"]["registration_tx_hash"] + .as_str() + .is_some_and(|hash| hash.starts_with("0x")), + "expected vaddr create --json to include registration tx hash, got:\n{stdout}" + ); +} + +#[casttest] +async fn tip20_create_uses_tempo_session_id_env(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let tempo_home = tempfile::tempdir().unwrap(); + let path_usd = path_usd(); + let (session_id, _) = + create_session_with_scope(&mut cmd, tempo_home.path(), "31337", &tip20_factory()); + + cmd.cast_fuse(); + cmd.env("TEMPO_HOME", tempo_home.path()); + cmd.env("TEMPO_SESSION_ID", &session_id); + let stdout = cmd + .args([ + "tip20", + "create", + "Session Dollar", + "SESS", + "USD", + &path_usd, + accounts::ADDR1, + "0x0000000000000000000000000000000000000000000000000000000000005151", + "--rpc-url", + &rpc, + "--tempo.fee-token", + &path_usd, + "--async", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + assert_async_tx_hash(&stdout, "cast tip20 create"); +} + +#[casttest] +async fn tip20_mine_register_uses_tempo_session_id_env(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let tempo_home = tempfile::tempdir().unwrap(); + let (session_id, _) = + create_session_with_scope(&mut cmd, tempo_home.path(), "31337", ADDRESS_REGISTRY); + + cmd.cast_fuse(); + cmd.env("TEMPO_HOME", tempo_home.path()); + cmd.env("TEMPO_SESSION_ID", &session_id); + let stdout = cmd + .args([ + "tip20", + "mine", + accounts::ADDR1, + "--salt", + PRECOMPUTED_VADDR_SALT_FOR_ADDR1, + "--register", + "--rpc-url", + &rpc, + "--async", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + assert_contains_tx_hash(&stdout, "cast tip20 mine --register"); +} + +#[casttest] +async fn erc20_transfer_uses_tempo_session_id_env(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let tempo_home = tempfile::tempdir().unwrap(); + let path_usd = path_usd(); + let (session_id, _) = create_session(&mut cmd, tempo_home.path(), "31337"); + + cmd.cast_fuse(); + cmd.env("TEMPO_HOME", tempo_home.path()); + cmd.env("TEMPO_SESSION_ID", &session_id); + let stdout = cmd + .args([ + "erc20", + "transfer", + &path_usd, + accounts::ADDR3, + "0", + "--rpc-url", + &rpc, + "--tempo.fee-token", + &path_usd, + "--async", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + assert_async_tx_hash(&stdout, "cast erc20 transfer"); +} + +#[casttest] +async fn batch_mktx_rejects_session_with_explicit_signer(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let path_usd = path_usd(); + let call = batch_send_transfer_call(&path_usd); + + cmd.cast_fuse(); + let stderr = cmd + .args([ + "batch-mktx", + "--call", + &call, + "--tempo.session", + MISSING_SESSION_ID, + "--private-key", + accounts::PK1, + "--rpc-url", + &rpc, + "--tempo.fee-token", + &path_usd, + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + + assert!(stderr.contains("explicit wallet signer"), "unexpected stderr:\n{stderr}"); +} + +#[casttest] +async fn erc20_rejects_session_with_explicit_signer(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let path_usd = path_usd(); + + cmd.cast_fuse(); + let stderr = cmd + .args([ + "erc20", + "transfer", + &path_usd, + accounts::ADDR3, + "0", + "--tempo.session", + MISSING_SESSION_ID, + "--private-key", + accounts::PK1, + "--rpc-url", + &rpc, + "--tempo.fee-token", + &path_usd, + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + + assert!(stderr.contains("explicit wallet signer"), "unexpected stderr:\n{stderr}"); +} + +#[casttest] +fn batch_mktx_rejects_session_with_ethsign(cmd: _) { + let path_usd = path_usd(); + let call = batch_send_transfer_call(&path_usd); + + cmd.cast_fuse(); + let stderr = cmd + .args([ + "batch-mktx", + "--call", + &call, + "--tempo.session", + MISSING_SESSION_ID, + "--ethsign", + "--from", + accounts::ADDR1, + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + + assert!(stderr.contains("cannot be combined with --ethsign"), "unexpected stderr:\n{stderr}"); +} + +#[casttest] +fn batch_mktx_rejects_session_with_raw_unsigned(cmd: _) { + let path_usd = path_usd(); + let call = batch_send_transfer_call(&path_usd); + + cmd.cast_fuse(); + let stderr = cmd + .args([ + "batch-mktx", + "--call", + &call, + "--tempo.session", + MISSING_SESSION_ID, + "--raw-unsigned", + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + + assert!( + stderr.contains("cannot be combined with --raw-unsigned"), + "unexpected stderr:\n{stderr}" + ); +} + +#[casttest] +async fn batch_mktx_rejects_session_on_wrong_chain(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let tempo_home = tempfile::tempdir().unwrap(); + let path_usd = path_usd(); + let call = batch_send_transfer_call(&path_usd); + let (session_id, _) = create_session(&mut cmd, tempo_home.path(), "31338"); + + cmd.cast_fuse(); + cmd.env("TEMPO_HOME", tempo_home.path()); + let stderr = cmd + .args([ + "batch-mktx", + "--call", + &call, + "--tempo.session", + &session_id, + "--rpc-url", + &rpc, + "--tempo.fee-token", + &path_usd, + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + + assert_wrong_chain_error(&stderr); +} + +#[casttest] +async fn vaddr_create_rejects_session_with_explicit_signer(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + + cmd.cast_fuse(); + let stderr = cmd + .args([ + "vaddr", + "create", + "--owner", + accounts::ADDR1, + "--salt", + PRECOMPUTED_VADDR_SALT_FOR_ADDR1, + "--tempo.session", + MISSING_SESSION_ID, + "--private-key", + accounts::PK1, + "--rpc-url", + &rpc, + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + + assert!(stderr.contains("explicit wallet signer"), "unexpected stderr:\n{stderr}"); +} + +#[casttest] +async fn vaddr_create_rejects_session_with_browser(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + + cmd.cast_fuse(); + let stderr = cmd + .args([ + "vaddr", + "create", + "--owner", + accounts::ADDR1, + "--salt", + PRECOMPUTED_VADDR_SALT_FOR_ADDR1, + "--tempo.session", + MISSING_SESSION_ID, + "--browser", + "--rpc-url", + &rpc, + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + + assert!(stderr.contains("cannot be combined with --browser"), "unexpected stderr:\n{stderr}"); +} + +#[casttest] +async fn vaddr_create_rejects_session_on_wrong_chain(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let tempo_home = tempfile::tempdir().unwrap(); + let (session_id, _) = + create_session_with_scope(&mut cmd, tempo_home.path(), "31338", ADDRESS_REGISTRY); + + cmd.cast_fuse(); + cmd.env("TEMPO_HOME", tempo_home.path()); + let stderr = cmd + .args([ + "vaddr", + "create", + "--owner", + accounts::ADDR1, + "--salt", + PRECOMPUTED_VADDR_SALT_FOR_ADDR1, + "--tempo.session", + &session_id, + "--rpc-url", + &rpc, + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + + assert_wrong_chain_error(&stderr); +} + +#[casttest] +async fn tip20_create_rejects_session_with_explicit_signer(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let path_usd = path_usd(); + + cmd.cast_fuse(); + let stderr = cmd + .args([ + "tip20", + "create", + "Session Dollar", + "SESS", + "USD", + &path_usd, + accounts::ADDR1, + "0x0000000000000000000000000000000000000000000000000000000000005152", + "--tempo.session", + MISSING_SESSION_ID, + "--private-key", + accounts::PK1, + "--rpc-url", + &rpc, + "--tempo.fee-token", + &path_usd, + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + + assert!(stderr.contains("explicit wallet signer"), "unexpected stderr:\n{stderr}"); +} + +#[casttest] +async fn tip20_create_rejects_session_with_browser(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let path_usd = path_usd(); + + cmd.cast_fuse(); + let stderr = cmd + .args([ + "tip20", + "create", + "Session Dollar", + "SESS", + "USD", + &path_usd, + accounts::ADDR1, + "0x0000000000000000000000000000000000000000000000000000000000005153", + "--tempo.session", + MISSING_SESSION_ID, + "--browser", + "--rpc-url", + &rpc, + "--tempo.fee-token", + &path_usd, + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + + assert!(stderr.contains("cannot be combined with --browser"), "unexpected stderr:\n{stderr}"); +} + +#[casttest] +async fn tip20_create_rejects_session_on_wrong_chain(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let tempo_home = tempfile::tempdir().unwrap(); + let path_usd = path_usd(); + let (session_id, _) = + create_session_with_scope(&mut cmd, tempo_home.path(), "31338", &tip20_factory()); + + cmd.cast_fuse(); + cmd.env("TEMPO_HOME", tempo_home.path()); + let stderr = cmd + .args([ + "tip20", + "create", + "Session Dollar", + "SESS", + "USD", + &path_usd, + accounts::ADDR1, + "0x0000000000000000000000000000000000000000000000000000000000005154", + "--tempo.session", + &session_id, + "--rpc-url", + &rpc, + "--tempo.fee-token", + &path_usd, + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + + assert_wrong_chain_error(&stderr); +} + +#[casttest] +async fn tip20_mine_register_rejects_session_with_explicit_signer(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + + cmd.cast_fuse(); + let stderr = cmd + .args([ + "tip20", + "mine", + accounts::ADDR1, + "--salt", + PRECOMPUTED_VADDR_SALT_FOR_ADDR1, + "--register", + "--tempo.session", + MISSING_SESSION_ID, + "--private-key", + accounts::PK1, + "--rpc-url", + &rpc, + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + + assert!(stderr.contains("explicit wallet signer"), "unexpected stderr:\n{stderr}"); +} + +#[casttest] +async fn tip20_mine_register_rejects_session_with_browser(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + + cmd.cast_fuse(); + let stderr = cmd + .args([ + "tip20", + "mine", + accounts::ADDR1, + "--salt", + PRECOMPUTED_VADDR_SALT_FOR_ADDR1, + "--register", + "--tempo.session", + MISSING_SESSION_ID, + "--browser", + "--rpc-url", + &rpc, + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + + assert!(stderr.contains("cannot be combined with --browser"), "unexpected stderr:\n{stderr}"); +} + +#[casttest] +async fn tip20_mine_register_rejects_session_on_wrong_chain(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let tempo_home = tempfile::tempdir().unwrap(); + let (session_id, _) = + create_session_with_scope(&mut cmd, tempo_home.path(), "31338", ADDRESS_REGISTRY); + + cmd.cast_fuse(); + cmd.env("TEMPO_HOME", tempo_home.path()); + let stderr = cmd + .args([ + "tip20", + "mine", + accounts::ADDR1, + "--salt", + PRECOMPUTED_VADDR_SALT_FOR_ADDR1, + "--register", + "--tempo.session", + &session_id, + "--rpc-url", + &rpc, + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + + assert_wrong_chain_error(&stderr); +} + +#[casttest] +async fn erc20_rejects_session_with_browser(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let path_usd = path_usd(); + + cmd.cast_fuse(); + let stderr = cmd + .args([ + "erc20", + "transfer", + &path_usd, + accounts::ADDR3, + "0", + "--tempo.session", + MISSING_SESSION_ID, + "--browser", + "--rpc-url", + &rpc, + "--tempo.fee-token", + &path_usd, + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + + assert!(stderr.contains("cannot be combined with --browser"), "unexpected stderr:\n{stderr}"); +} + +#[casttest] +async fn erc20_rejects_session_on_wrong_chain(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let tempo_home = tempfile::tempdir().unwrap(); + let path_usd = path_usd(); + let (session_id, _) = create_session(&mut cmd, tempo_home.path(), "31338"); + + cmd.cast_fuse(); + cmd.env("TEMPO_HOME", tempo_home.path()); + let stderr = cmd + .args([ + "erc20", + "transfer", + &path_usd, + accounts::ADDR3, + "0", + "--tempo.session", + &session_id, + "--rpc-url", + &rpc, + "--tempo.fee-token", + &path_usd, + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + + assert_wrong_chain_error(&stderr); +} + +#[casttest] +async fn wallet_session_run_for_grandchild_cast_send_inherits_session_key(prj: _, cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let tempo_home = tempfile::tempdir().unwrap(); + let child_dir = tempfile::tempdir().unwrap(); + let child_script = child_dir.path().join("session-child.sh"); + let grandchild_script = child_dir.path().join("session-grandchild-cast-send.sh"); + let path_usd = path_usd(); + + fs::write( + &child_script, + r#"#!/bin/sh +set -eu +test -n "${TEMPO_SESSION_ID:-}" +sh "$1" +"#, + ) + .expect("write child script"); + + fs::write(&grandchild_script, cast_send_session_script(&path_usd)) + .expect("write grandchild script"); + + let for_command = + format!("sh {} {}", child_script.to_slash_lossy(), grandchild_script.to_slash_lossy()); + + cmd.cast_fuse(); + cmd.env("TEMPO_HOME", tempo_home.path()); + cmd.env("CAST_BIN", prj.foundry_bin_path("cast")); + cmd.env("RPC_URL", &rpc); + let assertion = cmd + .args([ + "wallet", + "session", + "--root", + accounts::ADDR1, + "--expires", + "10m", + "--scope", + &path_usd, + "--spend-limit", + "PathUSD=1000000", + "--for", + &for_command, + "--private-key", + accounts::PK1, + "--rpc-url", + &rpc, + ]) + .assert_success(); + let stdout = assertion.get_output().stdout_lossy(); + + assert_contains_tx_hash(&stdout, "grandchild cast send"); + assert_accounts_store_key_retired(tempo_home.path(), "revoked"); +} + +#[casttest] +async fn cast_send_rejects_session_with_explicit_signer(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + + cmd.cast_fuse(); + let stderr = cmd + .args([ + "send", + accounts::ADDR3, + "--value", + "1", + "--tempo.session", + MISSING_SESSION_ID, + "--private-key", + accounts::PK1, + "--rpc-url", + &rpc, + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + + assert!(stderr.contains("explicit wallet signer"), "unexpected stderr:\n{stderr}"); +} + +#[casttest] +async fn batch_send_rejects_session_with_explicit_signer(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let path_usd = path_usd(); + let call = batch_send_transfer_call(&path_usd); + + cmd.cast_fuse(); + let stderr = cmd + .args([ + "batch-send", + "--call", + &call, + "--tempo.session", + MISSING_SESSION_ID, + "--private-key", + accounts::PK1, + "--rpc-url", + &rpc, + "--tempo.fee-token", + &path_usd, + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + + assert!(stderr.contains("explicit wallet signer"), "unexpected stderr:\n{stderr}"); +} + +#[casttest] +async fn batch_send_rejects_session_with_unlocked(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let path_usd = path_usd(); + let call = batch_send_transfer_call(&path_usd); + + cmd.cast_fuse(); + let stderr = cmd + .args([ + "batch-send", + "--call", + &call, + "--tempo.session", + MISSING_SESSION_ID, + "--unlocked", + "--from", + accounts::ADDR1, + "--rpc-url", + &rpc, + "--tempo.fee-token", + &path_usd, + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + + assert!(stderr.contains("cannot be combined with --unlocked"), "unexpected stderr:\n{stderr}"); +} + +#[casttest] +async fn batch_send_rejects_session_on_wrong_chain(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let tempo_home = tempfile::tempdir().unwrap(); + let path_usd = path_usd(); + let call = batch_send_transfer_call(&path_usd); + let (session_id, _) = create_session(&mut cmd, tempo_home.path(), "31338"); + + cmd.cast_fuse(); + cmd.env("TEMPO_HOME", tempo_home.path()); + let stderr = cmd + .args([ + "batch-send", + "--call", + &call, + "--tempo.session", + &session_id, + "--rpc-url", + &rpc, + "--tempo.fee-token", + &path_usd, + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + + assert!(stderr.contains("is for chain 31338"), "unexpected stderr:\n{stderr}"); + assert!(stderr.contains("command is using chain 31337"), "unexpected stderr:\n{stderr}"); +} + +#[casttest] +async fn wallet_session_run_for_cleans_key_material_when_child_fails(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let tempo_home = tempfile::tempdir().unwrap(); + let path_usd = path_usd(); + let child_dir = tempfile::tempdir().unwrap(); + let child_script = child_dir.path().join("failing-session-child.sh"); + fs::write( + &child_script, + r#"#!/bin/sh +set -eu +test -n "${TEMPO_SESSION_ID:-}" +store_file="${TEMPO_HOME}/wallet/store.json" +test ! -e "${TEMPO_HOME}/wallet/sessions.toml" +grep -q '"privateKey":' "${store_file}" +exit 7 +"#, + ) + .expect("write child script"); + + let for_command = format!("sh {}", child_script.to_slash_lossy()); + + cmd.cast_fuse(); + cmd.env("TEMPO_HOME", tempo_home.path()); + let stderr = cmd + .args([ + "wallet", + "session", + "--root", + accounts::ADDR1, + "--expires", + "10m", + "--scope", + &path_usd, + "--for", + &for_command, + "--private-key", + accounts::PK1, + "--rpc-url", + &rpc, + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + assert!(stderr.contains("exited with code 7"), "unexpected stderr:\n{stderr}"); + assert_accounts_store_key_retired(tempo_home.path(), "failed"); +} + +#[casttest] +async fn wallet_session_run_for_retires_local_key_when_revoke_preflight_fails(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let tempo_home = tempfile::tempdir().unwrap(); + let path_usd = path_usd(); + let child_dir = tempfile::tempdir().unwrap(); + let child_script = child_dir.path().join("session-child.sh"); + fs::write( + &child_script, + r#"#!/bin/sh +set -eu +test -n "${TEMPO_SESSION_ID:-}" +"#, + ) + .expect("write child script"); + + let for_command = format!("sh {}", child_script.to_slash_lossy()); + + cmd.cast_fuse(); + cmd.env("TEMPO_HOME", tempo_home.path()); + let stderr = cmd + .args([ + "wallet", + "session", + "--root", + accounts::ADDR1, + "--chain-id", + "31338", + "--expires", + "10m", + "--scope", + &path_usd, + "--for", + &for_command, + "--private-key", + accounts::PK1, + "--rpc-url", + &rpc, + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + + assert!(stderr.contains("created for chain 31338"), "unexpected stderr:\n{stderr}"); + assert_accounts_store_key_retired(tempo_home.path(), "revoking"); +} diff --git a/crates/cast/tests/cli/logs.rs b/crates/cast/tests/cli/logs.rs new file mode 100644 index 0000000000000..53a1f69a51fe8 --- /dev/null +++ b/crates/cast/tests/cli/logs.rs @@ -0,0 +1,321 @@ +//! CLI tests for logs commands. + +use super::*; + +#[casttest] +fn logs_topics(cmd: _) { + let rpc = next_http_archive_rpc_url(); + cmd.args([ + "logs", + "--rpc-url", + rpc.as_str(), + "--from-block", + "12421181", + "--to-block", + "12421182", + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000ab5801a7d398351b8be11c439e05c5b3259aec9b", + ]) + .assert_success() + .stdout_eq(file!["../fixtures/cast_logs.stdout"]); +} + +#[casttest] +fn logs_topic_2(cmd: _) { + let rpc = next_http_archive_rpc_url(); + cmd.args([ + "logs", + "--rpc-url", + rpc.as_str(), + "--from-block", + "12421181", + "--to-block", + "12421182", + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "", + "0x00000000000000000000000068a99f89e475a078645f4bac491360afe255dff1", /* Filter on the + * `to` address */ + ]) + .assert_success() + .stdout_eq(file!["../fixtures/cast_logs.stdout"]); +} + +#[casttest] +fn logs_sig(cmd: _) { + let rpc = next_http_archive_rpc_url(); + cmd.args([ + "logs", + "--rpc-url", + rpc.as_str(), + "--from-block", + "12421181", + "--to-block", + "12421182", + "Transfer(address indexed from, address indexed to, uint256 value)", + "0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B", + ]) + .assert_success() + .stdout_eq(file!["../fixtures/cast_logs_decoded.stdout"]); +} + +#[casttest] +fn logs_sig_2(cmd: _) { + let rpc = next_http_archive_rpc_url(); + cmd.args([ + "logs", + "--rpc-url", + rpc.as_str(), + "--from-block", + "12421181", + "--to-block", + "12421182", + "Transfer(address indexed from, address indexed to, uint256 value)", + "", + "0x68A99f89E475a078645f4BAC491360aFe255Dff1", + ]) + .assert_success() + .stdout_eq(file!["../fixtures/cast_logs_decoded.stdout"]); +} + +#[casttest] +fn logs_json_remains_raw(cmd: _) { + let rpc = next_http_archive_rpc_url(); + let expected = json!([{ + "address": "0x95ad61b0a150d79219dcf64e1e6cc01f0b64c4ce", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000ab5801a7d398351b8be11c439e05c5b3259aec9b", + "0x00000000000000000000000068a99f89e475a078645f4bac491360afe255dff1", + ], + "data": "0x000000000000000000000000000000000000027fd7b375dda5ef932dac18d302", + "blockHash": "0x439b61565dacbc09a6d54378dff60d9b0400496d7a5a060cfdfdd899262f466c", + "blockNumber": "0xbd883e", + "blockTimestamp": "0x609c2019", + "transactionHash": "0xb65bcbb85c1633b0ab4e4886c3cd8eeaeb63edbb39cacdb9223fdcf4454fd2c7", + "transactionIndex": "0x8", + "logIndex": "0xf", + "removed": false, + }]) + .to_string(); + + for signature in [ + "Transfer(address indexed from, address indexed to, uint256 value)", + "Transfer(address indexed from, address to, uint256 value)", + ] { + cmd.cast_fuse() + .args([ + "--json", + "logs", + "--rpc-url", + rpc.as_str(), + "--from-block", + "12421181", + "--to-block", + "12421182", + signature, + "0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B", + ]) + .assert_json_stdout_with_status(true, expected.clone()) + .stderr_eq(str![""]); + } +} + +#[casttest] +fn logs_sig_mismatched_indexed(cmd: _) { + let rpc = next_http_archive_rpc_url(); + cmd.args([ + "logs", "--rpc-url", rpc.as_str(), "--from-block", "12421181", "--to-block", + "12421182", "Transfer(address indexed from, address to, uint256 value)", + "0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B", + ]) + .assert_success() + .stdout_eq(file!["../fixtures/cast_logs.stdout"]) + .stderr_eq(str![[r#" +Warning: failed to decode 1 of 1 logs with the provided event signature; make sure its indexed parameters match the log topics + +"#]]); +} + +#[casttest] +fn logs_decode_warning_shown_once(cmd: _) { + let rpc = next_http_archive_rpc_url(); + cmd.args([ + "logs", "--rpc-url", rpc.as_str(), "--address", + "0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE", "--from-block", "12421181", + "--to-block", "12421182", "Transfer(address indexed from, address to, uint256 value)", + ]) + .assert_success() + .stderr_eq(str![[r#" +Warning: failed to decode 31 of 31 logs with the provided event signature; make sure its indexed parameters match the log topics + +"#]]); +} + +// Queries a 60k-block range (which `--query-size` splits into multiple chunks) and asserts the +// chunked result is byte-for-byte identical to a single unchunked request. This proves chunking +// collects logs from every chunk without gaps, duplicates, or reordering, and that the inclusive +// `to` block is covered. +#[casttest] +fn logs_chunked(cmd: _) { + let rpc = next_http_archive_rpc_url(); + let args = [ + "logs", + "--rpc-url", + rpc.as_str(), + "--from-block", + "12400000", + "--to-block", + "12460000", + "Transfer(address indexed from, address indexed to, uint256 value)", + "0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B", + ]; + + // Baseline: single request over the whole range. + let unchunked = cmd.args(args).assert_success().get_output().stdout_lossy(); + + // Same query split into 10k-block chunks (6 chunks). + cmd.cast_fuse(); + let chunked = + cmd.args(args).args(["--query-size", "10000"]).assert_success().get_output().stdout_lossy(); + + assert_eq!(chunked, unchunked, "chunked logs must match the unchunked result"); + // Sanity check: results actually span the first and last chunk of the range. + assert!(chunked.contains("12400314"), "missing log from the first chunk"); + assert!(chunked.contains("12454418"), "missing log from the last chunk"); +} + +#[forgetest] +async fn events_quiet_preserves_output(prj: _, cmd: _) { + let (_api, handle) = anvil::spawn(NodeConfig::test()).await; + let endpoint = handle.http_endpoint(); + let private_key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + cmd.set_current_dir(prj.root()); + prj.update_config(|config| { + config.cbor_metadata = false; + config.bytecode_hash = "none".parse().unwrap(); + }); + + prj.add_source( + "EventEmitter", + r#" +contract EventEmitter { + event Constructed(address indexed owner, uint256 value); + event Transfer(address indexed from, address indexed to, uint256 value); + + constructor() { + emit Constructed(msg.sender, 42); + } + + function emitTransfer() external { + emit Transfer(msg.sender, address(this), 42); + } +} +"#, + ); + cmd.forge_fuse().args(["build"]).assert_success(); + + let artifact = prj.root().join("out/EventEmitter.sol/EventEmitter.json"); + let contract: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(artifact).unwrap()).unwrap(); + let bytecode = contract["bytecode"]["object"].as_str().unwrap(); + let deployment = cmd + .cast_fuse() + .args([ + "send", + "--json", + "--private-key", + private_key, + "--rpc-url", + &endpoint, + "--create", + bytecode, + ]) + .assert_success() + .get_output() + .stdout_lossy(); + let deployment: serde_json::Value = serde_json::from_str(&deployment).unwrap(); + let address = deployment["contractAddress"].as_str().unwrap(); + let deployment_tx_hash = deployment["transactionHash"].as_str().unwrap(); + + cmd.cast_fuse() + .args([ + "--quiet", + "events", + deployment_tx_hash, + "--with-local-artifacts", + "--rpc-url", + &endpoint, + ]) + .assert_success() + .stdout_eq(str![[r#" +[block 1, tx 0x[..], log 0] 0x5FbDB2315678afecb367f032d93F642f64180aa3::Constructed(address,uint256) { owner: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, value: 42 } + +"#]]); + + let receipt = cmd + .cast_fuse() + .args([ + "send", + "--json", + "--private-key", + private_key, + "--rpc-url", + &endpoint, + address, + "emitTransfer()", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + let receipt: serde_json::Value = serde_json::from_str(&receipt).unwrap(); + let tx_hash = receipt["transactionHash"].as_str().unwrap(); + + cmd.cast_fuse() + .args(["--quiet", "events", tx_hash, "--rpc-url", &endpoint]) + .assert_success() + .stdout_eq(str![[r#" +[block 2, tx 0x[..], log 0] 0x5FbDB2315678afecb367f032d93F642f64180aa3 + topic 0: 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef + topic 1: 0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266 + topic 2: 0x0000000000000000000000005fbdb2315678afecb367f032d93f642f64180aa3 + data: 0x000000000000000000000000000000000000000000000000000000000000002a + +"#]]); + + cmd.cast_fuse() + .args([ + "--quiet", + "events", + tx_hash, + "--with-local-artifacts", + "--rpc-url", + &endpoint, + ]) + .assert_success() + .stdout_eq(str![[r#" +[block 2, tx 0x[..], log 0] 0x5FbDB2315678afecb367f032d93F642f64180aa3::Transfer(address,address,uint256) { from: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, to: 0x5FbDB2315678afecb367f032d93F642f64180aa3, value: 42 } + +"#]]); + + prj.add_source( + "AmbiguousEventEmitter", + r#" +import {EventEmitter} from "./EventEmitter.sol"; + +contract AmbiguousEventEmitter is EventEmitter { + event Ambiguous(uint256 value); +} +"#, + ); + cmd.cast_fuse() + .args(["--quiet", "events", tx_hash, "--with-local-artifacts", "--rpc-url", &endpoint]) + .assert_success() + .stdout_eq(str![[r#" +[block 2, tx 0x[..], log 0] 0x5FbDB2315678afecb367f032d93F642f64180aa3 + topic 0: 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef + topic 1: 0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266 + topic 2: 0x0000000000000000000000005fbdb2315678afecb367f032d93f642f64180aa3 + data: 0x000000000000000000000000000000000000000000000000000000000000002a + +"#]]); +} diff --git a/crates/cast/tests/cli/main.rs b/crates/cast/tests/cli/main.rs index 82c6bfa614d0f..51b05c9859d75 100644 --- a/crates/cast/tests/cli/main.rs +++ b/crates/cast/tests/cli/main.rs @@ -1,3648 +1,150 @@ //! Contains various tests for checking cast commands use alloy_chains::NamedChain; +use alloy_eips::Decodable2718; use alloy_hardforks::EthereumHardfork; -use alloy_network::{TransactionBuilder, TransactionResponse}; -use alloy_primitives::{B256, Bytes, address, b256, hex}; +use alloy_network::{ReceiptResponse, TransactionBuilder, TransactionResponse}; +use alloy_primitives::{Address, B256, Bytes, I256, U256, address, b256, hex, keccak256}; use alloy_provider::{Provider, ProviderBuilder}; -use alloy_rpc_types::{BlockNumberOrTag, Index, TransactionRequest}; +use alloy_rlp::Header; +use alloy_rpc_types::{ + Authorization, BlockNumberOrTag, Index, TransactionRequest, engine::JwtSecret, +}; +use alloy_signer::Signer; +use alloy_signer_local::PrivateKeySigner; +use alloy_sol_types::SolValue; use anvil::NodeConfig; +use foundry_evm::core::tempo::PATH_USD_ADDRESS; use foundry_test_utils::{ rpc::{ next_etherscan_api_key, next_http_archive_rpc_url, next_http_rpc_endpoint, - next_rpc_endpoint, next_ws_rpc_endpoint, + next_rpc_endpoint, next_ws_rpc_endpoint, spawn_rpc_proxy_method_not_found_before, + spawn_rpc_proxy_recording_method, }, snapbox::IntoData as _, str, util::OutputExt, }; -use std::{fs, io::Write, path::Path, str::FromStr}; +use serde_json::json; +use std::{fs, io::ErrorKind, net::TcpListener, path::Path, process::Command, str::FromStr}; +use tempo_primitives::{ + TempoTxEnvelope, + transaction::{KeychainVersion, TempoSignature}, +}; + +#[cfg(unix)] +use rexpect::{Encoding, process::wait::WaitStatus, reader::Options, spawn_with_options}; #[macro_use] extern crate foundry_test_utils; +mod erc20; +mod erc4626; +mod keychain; +mod read_networks; +mod remote_trace; +mod run_bal; +mod run_networks; +mod safe; mod selectors; +mod tempo; + +const PRESIGNED_EIP7702_AUTH: &str = "0xf85c827a6994f39fd6e51aad88f6f4ce6ab8827279cfffb922668001a03e1a66234e71242afcc7bc46c8950c3b2997b102db257774865f1232d2e7bf48a045e252dad189b27b2306792047745eba86bff0dd18aca813dbf3fba8c4e94576"; + +/// A TIP-1022 salt that satisfies the 4-byte proof of work for anvil dev account 0 +/// (`0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266`), so tests can register a virtual master without +/// mining one. +const PRECOMPUTED_VADDR_SALT_FOR_ADDR1: &str = + "0x00000000000000000000000000000000000000000000000000000000abf52baf"; + +fn valid_touch_id_sidecar_fixture(version: u32, policy: &str) -> String { + let sealed_password = format!("04{}", "00".repeat(92)); + + serde_json::json!({ + "version": version, + "policy": policy, + "se_key": "aa", + "sealed_password": sealed_password, + }) + .to_string() +} + +// Deploys the default Counter and sends a `setNumber(111)` tx, returning its hash. +// Used by the `--prestate-tracer` tests below. +async fn deploy_counter_and_set_number( + prj: &foundry_test_utils::TestProject, + cmd: &mut foundry_test_utils::TestCommand, + api: &anvil::eth::EthApi, + endpoint: &str, +) -> alloy_primitives::TxHash { + foundry_test_utils::util::initialize(prj.root()); + prj.initialize_default_contracts(); -casttest!(print_short_version, |_prj, cmd| { - cmd.arg("-V").assert_success().stdout_eq(str![[r#" -cast [..]-[..] ([..] [..]) - -"#]]); -}); - -casttest!(print_long_version, |_prj, cmd| { - cmd.arg("--version").assert_success().stdout_eq(str![[r#" -cast Version: [..] -Commit SHA: [..] -Build Timestamp: [..] -Build Profile: [..] - -"#]]); -}); - -// tests `--help` is printed to std out -casttest!(print_help, |_prj, cmd| { - cmd.arg("--help").assert_success().stdout_eq(str![[r#" -A Swiss Army knife for interacting with Ethereum applications from the command line - -Usage: cast[..] - -Commands: -... - -Options: - -h, --help - Print help (see a summary with '-h') - - -j, --threads - Number of threads to use. Specifying 0 defaults to the number of logical cores - - [aliases: --jobs] - - -V, --version - Print version - -Display options: - --color - The color of the log messages - - Possible values: - - auto: Intelligently guess whether to use color output (default) - - always: Force color output - - never: Force disable color output - - --json - Format log messages as JSON - - -q, --quiet - Do not print log messages - - -v, --verbosity... - Verbosity level of the log messages. - - Pass multiple times to increase the verbosity (e.g. -v, -vv, -vvv). - - Depending on the context the verbosity levels have different meanings. - - For example, the verbosity levels of the EVM are: - - 2 (-vv): Print logs for all tests. - - 3 (-vvv): Print execution traces for failing tests. - - 4 (-vvvv): Print execution traces for all tests, and setup traces for failing tests. - - 5 (-vvvvv): Print execution and setup traces for all tests, including storage changes. - -Find more information in the book: https://getfoundry.sh/cast/overview - -"#]]); -}); - -// tests that the `cast block` command works correctly -casttest!(latest_block, |_prj, cmd| { - let eth_rpc_url = next_http_rpc_endpoint(); - - // Call `cast find-block` - cmd.args(["block", "latest", "--rpc-url", eth_rpc_url.as_str()]); - cmd.assert_success().stdout_eq(str![[r#" - - -baseFeePerGas [..] -difficulty [..] -extraData [..] -gasLimit [..] -gasUsed [..] -hash [..] -logsBloom [..] -miner [..] -mixHash [..] -nonce [..] -number [..] -parentHash [..] -parentBeaconRoot [..] -transactionsRoot [..] -receiptsRoot [..] -sha3Uncles [..] -size [..] -stateRoot [..] -timestamp [..] -withdrawalsRoot [..] -totalDifficulty [..] -blobGasUsed [..] -excessBlobGas [..] -requestsHash [..] -transactions: [ -... -] - -"#]]); - - // - cmd.cast_fuse().args(["block", "15007840", "-f", "hash", "--rpc-url", eth_rpc_url.as_str()]); - cmd.assert_success().stdout_eq(str![[r#" -0x950091817a57e22b6c1f3b951a15f52d41ac89b299cc8f9c89bb6d185f80c415 - -"#]]); -}); - -casttest!(block_raw, |_prj, cmd| { - let eth_rpc_url = next_http_rpc_endpoint(); - - let output = cmd - .args(["block", "22934900", "--rpc-url", eth_rpc_url.as_str(), "--raw"]) - .assert_success() - .get_output() - .stdout_lossy() - .trim() - .to_string(); - - // Hash the output with keccak256 - let hash = alloy_primitives::keccak256(hex::decode(output).unwrap()); - - // Verify the Mainnet's block #22934900 header hash equals the expected value - // obtained with go-ethereum's `block.Header().Hash()` method - assert_eq!( - hash.to_string(), - "0x49fd7f3b9ba5d67fa60197027f09454d4cac945e8f271edcc84c3fd5872446d3" - ); -}); - -// tests that the `cast find-block` command works correctly -casttest!(finds_block, |_prj, cmd| { - // Construct args - let timestamp = "1647843609".to_string(); - let eth_rpc_url = next_http_rpc_endpoint(); - - // Call `cast find-block` - // - cmd.args(["find-block", "--rpc-url", eth_rpc_url.as_str(), ×tamp]) - .assert_success() - .stdout_eq(str![[r#" -14428082 - -"#]]); -}); - -// tests that we can create a new wallet -casttest!(new_wallet, |_prj, cmd| { - cmd.args(["wallet", "new"]).assert_success().stdout_eq(str![[r#" -Successfully created new keypair. -[ADDRESS] -[PRIVATE_KEY] - -"#]]); -}); - -// tests that we can create a new wallet (verbose variant) -casttest!(new_wallet_verbose, |_prj, cmd| { - cmd.args(["wallet", "new", "-v"]).assert_success().stdout_eq(str![[r#" -Successfully created new keypair. -[ADDRESS] -[PUBLIC_KEY] -[PRIVATE_KEY] - -"#]]); -}); - -// tests that we can create a new wallet with json output -casttest!(new_wallet_json, |_prj, cmd| { - cmd.args(["wallet", "new", "--json"]).assert_success().stdout_eq( - str![[r#" -[ - { - "address": "{...}", - "private_key": "{...}" - } -] - -"#]] - .is_json(), - ); -}); - -// tests that we can create a new wallet with json output (verbose variant) -casttest!(new_wallet_json_verbose, |_prj, cmd| { - cmd.args(["wallet", "new", "--json", "-v"]).assert_success().stdout_eq( - str![[r#" -[ - { - "address": "{...}", - "public_key": "{...}", - "private_key": "{...}" - } -] - -"#]] - .is_json(), - ); -}); - -// tests that we can create a new wallet with keystore -casttest!(new_wallet_keystore_with_password, |_prj, cmd| { - cmd.args(["wallet", "new", ".", "test-account", "--unsafe-password", "test"]) - .assert_success() - .stdout_eq(str![[r#" -Created new encrypted keystore file: [..] -[ADDRESS] - -"#]]); -}); - -// tests that we can create a new wallet with keystore (verbose variant) -casttest!(new_wallet_keystore_with_password_verbose, |_prj, cmd| { - cmd.args(["wallet", "new", ".", "test-account", "--unsafe-password", "test", "-v"]) - .assert_success() - .stdout_eq(str![[r#" -Created new encrypted keystore file: [..] -[ADDRESS] -[PUBLIC_KEY] - -"#]]); -}); - -// tests that we can get the address of a keystore file -casttest!(wallet_address_keystore_with_password_file, |_prj, cmd| { - let keystore_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/keystore"); - - cmd.args([ - "wallet", - "address", - "--keystore", - keystore_dir - .join("UTC--2022-12-20T10-30-43.591916000Z--ec554aeafe75601aaab43bd4621a22284db566c2") - .to_str() - .unwrap(), - "--password-file", - keystore_dir.join("password-ec554").to_str().unwrap(), - ]) - .assert_success() - .stdout_eq(str![[r#" -0xeC554aeAFE75601AaAb43Bd4621A22284dB566C2 - -"#]]); -}); - -// tests that `cast wallet remove` can successfully remove a keystore file and validates password -casttest!(wallet_remove_keystore_with_unsafe_password, |prj, cmd| { - let keystore_path = prj.root().join("keystore"); - - cmd.set_current_dir(prj.root()); - - let account_name = "testAccount"; - - // Default Anvil private key - let test_private_key = - b256!("0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"); - - // import private key - cmd.cast_fuse() - .args([ - "wallet", - "import", - account_name, - "--private-key", - &test_private_key.to_string(), - "-k", - "keystore", - "--unsafe-password", - "test", - ]) - .assert_success() - .stdout_eq(str![[r#" -`testAccount` keystore was saved successfully. [ADDRESS] - -"#]]); - - // check that the keystore file was created - let keystore_file = keystore_path.join(account_name); - - assert!(keystore_file.exists()); - // Remove the wallet - cmd.cast_fuse() - .args([ - "wallet", - "remove", - "--name", - account_name, - "--dir", - keystore_path.to_str().unwrap(), - "--unsafe-password", - "test", - ]) - .assert_success() - .stdout_eq(str![[r#" -`testAccount` keystore was removed successfully. - -"#]]); - - assert!(!keystore_file.exists()); -}); - -// tests that `cast wallet sign message` outputs the expected signature -casttest!(wallet_sign_message_utf8_data, |_prj, cmd| { - let pk = "0x0000000000000000000000000000000000000000000000000000000000000001"; - let address = "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf"; - let msg = "test"; - let expected = "0xfe28833983d6faa0715c7e8c3873c725ddab6fa5bf84d40e780676e463e6bea20fc6aea97dc273a98eb26b0914e224c8dd5c615ceaab69ddddcf9b0ae3de0e371c"; - - cmd.args(["wallet", "sign", "--private-key", pk, msg]).assert_success().stdout_eq(str![[r#" -0xfe28833983d6faa0715c7e8c3873c725ddab6fa5bf84d40e780676e463e6bea20fc6aea97dc273a98eb26b0914e224c8dd5c615ceaab69ddddcf9b0ae3de0e371c - -"#]]); - - // Success. - cmd.cast_fuse() - .args(["wallet", "verify", "-a", address, msg, expected]) - .assert_success() - .stdout_eq(str![[r#" -Validation succeeded. Address 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf signed this message. - -"#]]); - - // Fail. - cmd.cast_fuse() - .args(["wallet", "verify", "-a", address, "other msg", expected]) - .assert_failure() - .stderr_eq(str![[r#" -Error: Validation failed. Address 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf did not sign this message. - -"#]]); -}); - -// tests that `cast wallet sign message` outputs the expected signature, given a 0x-prefixed data -casttest!(wallet_sign_message_hex_data, |_prj, cmd| { - cmd.args([ - "wallet", - "sign", - "--private-key", - "0x0000000000000000000000000000000000000000000000000000000000000001", - "0x0000000000000000000000000000000000000000000000000000000000000000", - ]).assert_success().stdout_eq(str![[r#" -0x23a42ca5616ee730ff3735890c32fc7b9491a9f633faca9434797f2c845f5abf4d9ba23bd7edb8577acebaa3644dc5a4995296db420522bb40060f1693c33c9b1c - -"#]]); -}); - -// -// tests that `cast wallet sign` and `cast wallet verify` work with the same message as input -casttest!(wallet_sign_and_verify_message_hex_data, |_prj, cmd| { - // message="$1" - // mnemonic="test test test test test test test test test test test junk" - // key=$(cast wallet private-key --mnemonic "$mnemonic") - // address=$(cast wallet address --mnemonic "$mnemonic") - // signature=$(cast wallet sign --private-key "$key" "$message") - // cast wallet verify --address "$address" "$message" "$signature" - let mnemonic = "test test test test test test test test test test test junk"; - let key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; - let address = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; - cmd.args(["wallet", "private-key", "--mnemonic", mnemonic]).assert_success().stdout_eq(str![[ - r#" -0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 - -"# - ]]); - cmd.cast_fuse().args(["wallet", "address", "--mnemonic", mnemonic]).assert_success().stdout_eq( - str![[r#" -0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 - -"#]], - ); - - let msg_hex = "0x0000000000000000000000000000000000000000000000000000000000000001"; - let signature_hex = "0xed769da87f78d0166b30aebf2767ceed5a3867da21b2fba8c6527af256bbcebe24a1e758ec8ad1ffc29cfefa540ea7ba7966c0edf6907af82348f894ba4f40fa1b"; - cmd.cast_fuse().args([ - "wallet", "sign", "--private-key",key, msg_hex - ]).assert_success().stdout_eq(str![[r#" -0xed769da87f78d0166b30aebf2767ceed5a3867da21b2fba8c6527af256bbcebe24a1e758ec8ad1ffc29cfefa540ea7ba7966c0edf6907af82348f894ba4f40fa1b - -"#]]); - - cmd.cast_fuse() - .args(["wallet", "verify", "--address", address, msg_hex, signature_hex]) - .assert_success() - .stdout_eq(str![[r#" -Validation succeeded. Address 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 signed this message. - -"#]]); - - let msg_raw = "0000000000000000000000000000000000000000000000000000000000000001"; - let signature_raw = "0x27a97b378477d9d004bd19cbd838d59bbb9847074ae4cc5b5975cc5566065eea76ee5b752fcdd483073e1baba548d82d9accc8603b3781bcc9abf195614cd3411c"; - cmd.cast_fuse().args([ - "wallet", "sign", "--private-key",key, msg_raw - ]).assert_success().stdout_eq(str![[r#" -0x27a97b378477d9d004bd19cbd838d59bbb9847074ae4cc5b5975cc5566065eea76ee5b752fcdd483073e1baba548d82d9accc8603b3781bcc9abf195614cd3411c - -"#]]); - - cmd.cast_fuse() - .args(["wallet", "verify", "--address", address, msg_raw, signature_raw]) - .assert_success() - .stdout_eq(str![[r#" -Validation succeeded. Address 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 signed this message. - -"#]]); -}); - -// tests that `cast wallet sign typed-data` outputs the expected signature, given a JSON string -casttest!(wallet_sign_typed_data_string, |_prj, cmd| { - cmd.args([ - "wallet", - "sign", - "--private-key", - "0x0000000000000000000000000000000000000000000000000000000000000001", - "--data", - "{\"types\": {\"EIP712Domain\": [{\"name\": \"name\",\"type\": \"string\"},{\"name\": \"version\",\"type\": \"string\"},{\"name\": \"chainId\",\"type\": \"uint256\"},{\"name\": \"verifyingContract\",\"type\": \"address\"}],\"Message\": [{\"name\": \"data\",\"type\": \"string\"}]},\"primaryType\": \"Message\",\"domain\": {\"name\": \"example.metamask.io\",\"version\": \"1\",\"chainId\": \"1\",\"verifyingContract\": \"0x0000000000000000000000000000000000000000\"},\"message\": {\"data\": \"Hello!\"}}", - ]).assert_success().stdout_eq(str![[r#" -0x06c18bdc8163219fddc9afaf5a0550e381326474bb757c86dc32317040cf384e07a2c72ce66c1a0626b6750ca9b6c035bf6f03e7ed67ae2d1134171e9085c0b51b - -"#]]); -}); - -// tests that `cast wallet sign typed-data` outputs the expected signature, given a JSON file -casttest!(wallet_sign_typed_data_file, |_prj, cmd| { - cmd.args([ - "wallet", - "sign", - "--private-key", - "0x0000000000000000000000000000000000000000000000000000000000000001", - "--data", - "--from-file", - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("tests/fixtures/sign_typed_data.json") - .into_os_string() - .into_string() - .unwrap() - .as_str(), - ]).assert_success().stdout_eq(str![[r#" -0x06c18bdc8163219fddc9afaf5a0550e381326474bb757c86dc32317040cf384e07a2c72ce66c1a0626b6750ca9b6c035bf6f03e7ed67ae2d1134171e9085c0b51b - -"#]]); -}); - -// tests that `cast wallet sign typed-data` passes with type names containing colons -// -casttest!(wallet_sign_typed_data_with_colon_succeeds, |_prj, cmd| { - let typed_data_with_colon = r#"{ - "types": { - "EIP712Domain": [ - {"name": "name", "type": "string"}, - {"name": "version", "type": "string"}, - {"name": "chainId", "type": "uint256"}, - {"name": "verifyingContract", "type": "address"} - ], - "Test:Message": [ - {"name": "content", "type": "string"} - ] - }, - "primaryType": "Test:Message", - "domain": { - "name": "TestDomain", - "version": "1", - "chainId": 1, - "verifyingContract": "0x0000000000000000000000000000000000000000" - }, - "message": { - "content": "Hello" - } - }"#; - - cmd.args([ - "wallet", - "sign", - "--private-key", - "0x0000000000000000000000000000000000000000000000000000000000000001", - "--data", - typed_data_with_colon, - ]).assert_success().stdout_eq(str![[r#" -0xf91c67e845a4d468d1f876f457ffa01e65468641fc121453705242d21de39b266c278592b085814ab1e9adc938cc26b1d64bb61f80b437df077777c4283612291b - -"#]]); -}); - -// tests that the same data without colon works correctly -// -casttest!(wallet_sign_typed_data_without_colon_works, |_prj, cmd| { - let typed_data_without_colon = r#"{ - "types": { - "EIP712Domain": [ - {"name": "name", "type": "string"}, - {"name": "version", "type": "string"}, - {"name": "chainId", "type": "uint256"}, - {"name": "verifyingContract", "type": "address"} - ], - "TestMessage": [ - {"name": "content", "type": "string"} - ] - }, - "primaryType": "TestMessage", - "domain": { - "name": "TestDomain", - "version": "1", - "chainId": 1, - "verifyingContract": "0x0000000000000000000000000000000000000000" - }, - "message": { - "content": "Hello" - } - }"#; - - cmd.args([ - "wallet", - "sign", - "--private-key", - "0x0000000000000000000000000000000000000000000000000000000000000001", - "--data", - typed_data_without_colon, - ]) - .assert_success(); -}); - -// tests that `cast wallet sign-auth message` outputs the expected signature -casttest!(wallet_sign_auth, |_prj, cmd| { - cmd.args([ - "wallet", - "sign-auth", - "--private-key", - "0x0000000000000000000000000000000000000000000000000000000000000001", - "--nonce", - "100", - "--chain", - "1", - "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf"]).assert_success().stdout_eq(str![[r#" -0xf85a01947e5f4552091a69125d5dfcb7b8c2659029395bdf6401a0ad489ee0314497c3f06567f3080a46a63908edc1c7cdf2ac2d609ca911212086a065a6ba951c8748dd8634740fe498efb61770097d99ff5fdcb9a863b62ea899f6 - -"#]]); -}); - -// tests that `cast wallet list` outputs the local accounts -casttest!(wallet_list_local_accounts, |prj, cmd| { - let keystore_path = prj.root().join("keystore"); - fs::create_dir_all(keystore_path).unwrap(); - cmd.set_current_dir(prj.root()); - - // empty results - cmd.cast_fuse() - .args(["wallet", "list", "--dir", "keystore"]) - .assert_success() - .stdout_eq(str![""]); - - // create 10 wallets - cmd.cast_fuse() - .args(["wallet", "new", "keystore", "-n", "10", "--unsafe-password", "test"]) - .assert_success() - .stdout_eq(str![[r#" -Created new encrypted keystore file: [..] -[ADDRESS] -Created new encrypted keystore file: [..] -[ADDRESS] -Created new encrypted keystore file: [..] -[ADDRESS] -Created new encrypted keystore file: [..] -[ADDRESS] -Created new encrypted keystore file: [..] -[ADDRESS] -Created new encrypted keystore file: [..] -[ADDRESS] -Created new encrypted keystore file: [..] -[ADDRESS] -Created new encrypted keystore file: [..] -[ADDRESS] -Created new encrypted keystore file: [..] -[ADDRESS] -Created new encrypted keystore file: [..] -[ADDRESS] - -"#]]); - - // test list new wallet - cmd.cast_fuse().args(["wallet", "list", "--dir", "keystore"]).assert_success().stdout_eq(str![ - [r#" -[..] (Local) -[..] (Local) -[..] (Local) -[..] (Local) -[..] (Local) -[..] (Local) -[..] (Local) -[..] (Local) -[..] (Local) -[..] (Local) - -"#] - ]); -}); - -// tests that `cast wallet new-mnemonic --entropy` outputs the expected mnemonic -casttest!(wallet_mnemonic_from_entropy, |_prj, cmd| { - cmd.args([ - "wallet", - "new-mnemonic", - "--accounts", - "3", - "--entropy", - "0xdf9bf37e6fcdf9bf37e6fcdf9bf37e3c", - ]) - .assert_success() - .stdout_eq( - str![[r#" -Generating mnemonic from provided entropy... -Successfully generated a new mnemonic. -Phrase: -test test test test test test test test test test test junk - -Accounts: -- Account 0: -Address: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 -Private key: 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 - -- Account 1: -Address: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 -Private key: 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d - -- Account 2: -Address: 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC -Private key: 0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a - - -"#]] - .raw(), - ); -}); - -// tests that `cast wallet new-mnemonic --entropy` outputs the expected mnemonic (verbose variant) -casttest!(wallet_mnemonic_from_entropy_verbose, |_prj, cmd| { - cmd.args([ - "wallet", - "new-mnemonic", - "--accounts", - "3", - "--entropy", - "0xdf9bf37e6fcdf9bf37e6fcdf9bf37e3c", - "-v", - ]) - .assert_success() - .stdout_eq( - str![[r#" -Generating mnemonic from provided entropy... -Successfully generated a new mnemonic. -Phrase: -test test test test test test test test test test test junk - -Accounts: -- Account 0: -Address: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 -Public key: 0x8318535b54105d4a7aae60c08fc45f9687181b4fdfc625bd1a753fa7397fed753547f11ca8696646f2f3acb08e31016afac23e630c5d11f59f61fef57b0d2aa5 -Private key: 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 - -- Account 1: -Address: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 -Public key: 0xba5734d8f7091719471e7f7ed6b9df170dc70cc661ca05e688601ad984f068b0d67351e5f06073092499336ab0839ef8a521afd334e53807205fa2f08eec74f4 -Private key: 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d - -- Account 2: -Address: 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC -Public key: 0x9d9031e97dd78ff8c15aa86939de9b1e791066a0224e331bc962a2099a7b1f0464b8bbafe1535f2301c72c2cb3535b172da30b02686ab0393d348614f157fbdb -Private key: 0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a - - -"#]] - .raw(), - ); -}); - -// tests that `cast wallet new-mnemonic --json` outputs the expected mnemonic -casttest!(wallet_mnemonic_from_entropy_json, |_prj, cmd| { - cmd.args([ - "wallet", - "new-mnemonic", - "--accounts", - "3", - "--entropy", - "0xdf9bf37e6fcdf9bf37e6fcdf9bf37e3c", - "--json", - ]) - .assert_success() - .stdout_eq(str![[r#" -{ - "mnemonic": "test test test test test test test test test test test junk", - "accounts": [ - { - "address": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", - "private_key": "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" - }, - { - "address": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", - "private_key": "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" - }, - { - "address": "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", - "private_key": "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a" - } - ] -} - -"#]]); -}); - -// tests that `cast wallet new-mnemonic --json` outputs the expected mnemonic (verbose variant) -casttest!(wallet_mnemonic_from_entropy_json_verbose, |_prj, cmd| { - cmd.args([ - "wallet", - "new-mnemonic", - "--accounts", - "3", - "--entropy", - "0xdf9bf37e6fcdf9bf37e6fcdf9bf37e3c", - "--json", - "-v", - ]) - .assert_success() - .stdout_eq(str![[r#" -{ - "mnemonic": "test test test test test test test test test test test junk", - "accounts": [ - { - "address": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", - "public_key": "0x8318535b54105d4a7aae60c08fc45f9687181b4fdfc625bd1a753fa7397fed753547f11ca8696646f2f3acb08e31016afac23e630c5d11f59f61fef57b0d2aa5", - "private_key": "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" - }, - { - "address": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", - "public_key": "0xba5734d8f7091719471e7f7ed6b9df170dc70cc661ca05e688601ad984f068b0d67351e5f06073092499336ab0839ef8a521afd334e53807205fa2f08eec74f4", - "private_key": "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" - }, - { - "address": "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", - "public_key": "0x9d9031e97dd78ff8c15aa86939de9b1e791066a0224e331bc962a2099a7b1f0464b8bbafe1535f2301c72c2cb3535b172da30b02686ab0393d348614f157fbdb", - "private_key": "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a" - } - ] -} - -"#]]); -}); - -// tests that `cast wallet private-key` with arguments outputs the private key -casttest!(wallet_private_key_from_mnemonic_arg, |_prj, cmd| { - cmd.args([ - "wallet", - "private-key", - "test test test test test test test test test test test junk", - "1", - ]) - .assert_success() - .stdout_eq(str![[r#" -0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d - -"#]]); -}); - -// tests that `cast wallet private-key` with options outputs the private key -casttest!(wallet_private_key_from_mnemonic_option, |_prj, cmd| { - cmd.args([ - "wallet", - "private-key", - "--mnemonic", - "test test test test test test test test test test test junk", - "--mnemonic-index", - "1", - ]) - .assert_success() - .stdout_eq(str![[r#" -0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d - -"#]]); -}); -// tests that `cast wallet public-key` correctly derives and outputs the public key -casttest!(wallet_public_key_with_private_key, |_prj, cmd| { - cmd.args([ - "wallet", - "public-key", - "--raw-private-key", - "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" - ]) - .assert_success() - .stdout_eq(str![[r#" -0xba5734d8f7091719471e7f7ed6b9df170dc70cc661ca05e688601ad984f068b0d67351e5f06073092499336ab0839ef8a521afd334e53807205fa2f08eec74f4 - -"#]]); -}); -// tests that `cast wallet private-key` with derivation path outputs the private key -casttest!(wallet_private_key_with_derivation_path, |_prj, cmd| { - cmd.args([ - "wallet", - "private-key", - "--mnemonic", - "test test test test test test test test test test test junk", - "--mnemonic-derivation-path", - "m/44'/60'/0'/0/1", - ]) - .assert_success() - .stdout_eq(str![[r#" -0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d - -"#]]); -}); - -// tests that `cast wallet import` creates a keystore for a private key and that `cast wallet -// decrypt-keystore` can access it -casttest!(wallet_import_and_decrypt, |prj, cmd| { - let keystore_path = prj.root().join("keystore"); - - cmd.set_current_dir(prj.root()); - - let account_name = "testAccount"; - - // Default Anvil private key - let test_private_key = - b256!("0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"); - - // import private key - cmd.cast_fuse() - .args([ - "wallet", - "import", - account_name, - "--private-key", - &test_private_key.to_string(), - "-k", - "keystore", - "--unsafe-password", - "test", - ]) - .assert_success() - .stdout_eq(str![[r#" -`testAccount` keystore was saved successfully. [ADDRESS] - -"#]]); - - // check that the keystore file was created - let keystore_file = keystore_path.join(account_name); - - assert!(keystore_file.exists()); - - // decrypt the keystore file - let decrypt_output = cmd.cast_fuse().args([ - "wallet", - "decrypt-keystore", - account_name, - "-k", - "keystore", - "--unsafe-password", - "test", - ]); - - // get the PK out of the output (last word in the output) - let decrypt_output = decrypt_output.assert_success().get_output().stdout_lossy(); - let private_key_string = decrypt_output.split_whitespace().last().unwrap(); - // check that the decrypted private key matches the imported private key - let decrypted_private_key = B256::from_str(private_key_string).unwrap(); - // the form - assert_eq!(decrypted_private_key, test_private_key); -}); - -// tests that `cast wallet change-password` can successfully change the password of a keystore file -casttest!(wallet_change_password, |prj, cmd| { - let keystore_path = prj.root().join("keystore"); - - cmd.set_current_dir(prj.root()); - - let account_name = "testAccount"; - - // Default Anvil private key - let test_private_key = - b256!("0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"); - - // import private key with initial password - cmd.cast_fuse() + // Deploy counter contract. + let mut forge = Command::new(prj.ensure_foundry_bin("forge")); + forge.current_dir(prj.root()); + forge.env("NO_COLOR", "1"); + cmd.set_cmd(forge) .args([ - "wallet", - "import", - account_name, + "script", "--private-key", - &test_private_key.to_string(), - "-k", - "keystore", - "--unsafe-password", - "old_password", - ]) - .assert_success() - .stdout_eq(str![[r#" -`testAccount` keystore was saved successfully. [ADDRESS] - -"#]]); - - // check that the keystore file was created - let keystore_file = keystore_path.join(account_name); - assert!(keystore_file.exists()); - - // change the password - cmd.cast_fuse() - .args([ - "wallet", - "change-password", - account_name, - "--keystore-dir", - "keystore", - "--unsafe-password", - "old_password", - "--unsafe-new-password", - "new_password", - ]) - .assert_success() - .stdout_eq(str![[r#" -Password for keystore `testAccount` was changed successfully. [ADDRESS] - -"#]]); - - // verify the old password no longer works - cmd.cast_fuse() - .args([ - "wallet", - "decrypt-keystore", - account_name, - "-k", - "keystore", - "--unsafe-password", - "old_password", - ]) - .assert_failure(); - - // verify the new password works - let decrypt_output = cmd.cast_fuse().args([ - "wallet", - "decrypt-keystore", - account_name, - "-k", - "keystore", - "--unsafe-password", - "new_password", - ]); - - // get the PK out of the output (last word in the output) - let decrypt_output = decrypt_output.assert_success().get_output().stdout_lossy(); - let private_key_string = decrypt_output.split_whitespace().last().unwrap(); - - // check that the decrypted private key matches the imported private key - let decrypted_private_key = B256::from_str(private_key_string).unwrap(); - assert_eq!(decrypted_private_key, test_private_key); -}); - -// tests that `cast estimate` is working correctly. -casttest!(estimate_function_gas, |_prj, cmd| { - let eth_rpc_url = next_http_rpc_endpoint(); - - // ensure we get a positive non-error value for gas estimate - let output: u32 = cmd - .args([ - "estimate", - "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", // vitalik.eth - "--value", - "100", - "deposit()", - "--rpc-url", - eth_rpc_url.as_str(), - ]) - .assert_success() - .get_output() - .stdout_lossy() - .trim() - .parse() - .unwrap(); - assert!(output.ge(&0)); -}); - -// tests that `cast estimate --cost` is working correctly. -casttest!(estimate_function_cost, |_prj, cmd| { - let eth_rpc_url = next_http_rpc_endpoint(); - - // ensure we get a positive non-error value for cost estimate - let output: f64 = cmd - .args([ - "estimate", - "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", // vitalik.eth - "--value", - "100", - "deposit()", - "--rpc-url", - eth_rpc_url.as_str(), - "--cost", - ]) - .assert_success() - .get_output() - .stdout_lossy() - .trim() - .parse() - .unwrap(); - assert!(output > 0f64); -}); - -// tests that `cast estimate --create` is working correctly. -casttest!(estimate_contract_deploy_gas, |_prj, cmd| { - let eth_rpc_url = next_http_rpc_endpoint(); - // sample contract code bytecode. Wouldn't run but is valid bytecode that the estimate method - // accepts and could be deployed. - let output = cmd - .args([ - "estimate", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", "--rpc-url", - eth_rpc_url.as_str(), - "--create", - "0000", - "ERC20(uint256,string,string)", - "100", - "Test", - "TST", + endpoint, + "--broadcast", + "CounterScript", ]) - .assert_success() - .get_output() - .stdout_lossy(); - - // ensure we get a positive non-error value for gas estimate - let output: u32 = output.trim().parse().unwrap(); - assert!(output > 0); -}); - -// tests that the `cast to-rlp` and `cast from-rlp` commands work correctly -casttest!(rlp, |_prj, cmd| { - cmd.args(["--to-rlp", "[\"0xaa\", [[\"bb\"]], \"0xcc\"]"]).assert_success().stdout_eq(str![[ - r#" -0xc881aac3c281bb81cc - -"# - ]]); - - cmd.cast_fuse(); - cmd.args(["--from-rlp", "0xcbc58455556666c0c0c2c1c0"]).assert_success().stdout_eq(str![[r#" -[["0x55556666"],[],[],[[[]]]] - -"#]]); -}); - -// test that `cast impl` works correctly for both the implementation slot and the beacon slot -casttest!(impl_slot, |_prj, cmd| { - let eth_rpc_url = next_http_archive_rpc_url(); - - // Call `cast impl` for the implementation slot (AAVE Proxy) - cmd.args([ - "impl", - "0x4965f6FA20fE9728deCf5165016fc338a5a85aBF", - "--rpc-url", - eth_rpc_url.as_str(), - "--block", - "21422087", - ]) - .assert_success() - .stdout_eq(str![[r#" -0xb61306c8eb34a2104d9eb8d84f1bb1001067fa4b - -"#]]); -}); - -casttest!(impl_slot_beacon, |_prj, cmd| { - let eth_rpc_url = next_http_archive_rpc_url(); - - // Call `cast impl` for the beacon slot - cmd.args([ - "impl", - "0xc63d9f0040d35f328274312fc8771a986fc4ba86", - "--beacon", - "--rpc-url", - eth_rpc_url.as_str(), - "--block", - "21422087", - ]) - .assert_success() - .stdout_eq(str![[r#" -0xa748ae65ba11606492a9c57effa0d4b7be551ec2 - -"#]]); -}); - -// test for cast_rpc without arguments -casttest!(rpc_no_args, |_prj, cmd| { - let eth_rpc_url = next_http_rpc_endpoint(); - - // Call `cast rpc eth_chainId` - cmd.args(["rpc", "--rpc-url", eth_rpc_url.as_str(), "eth_chainId"]).assert_success().stdout_eq( - str![[r#" -"0x1" - -"#]], - ); -}); - -// test for cast_rpc without arguments using websocket -casttest!(ws_rpc_no_args, |_prj, cmd| { - let eth_rpc_url = next_ws_rpc_endpoint(); - - // Call `cast rpc eth_chainId` - cmd.args(["rpc", "--rpc-url", eth_rpc_url.as_str(), "eth_chainId"]).assert_success().stdout_eq( - str![[r#" -"0x1" - -"#]], - ); -}); - -// test for cast_rpc with arguments -casttest!(rpc_with_args, |_prj, cmd| { - let eth_rpc_url = next_http_rpc_endpoint(); - - // Call `cast rpc eth_getBlockByNumber 0x123 false` - cmd.args(["rpc", "--rpc-url", eth_rpc_url.as_str(), "eth_getBlockByNumber", "0x123", "false"]) - .assert_json_stdout(str![[r#" -{"number":"0x123","hash":"0xc5dab4e189004a1312e9db43a40abb2de91ad7dd25e75880bf36016d8e9df524","transactions":[],"logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","receiptsRoot":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421","extraData":"0x476574682f4c5649562f76312e302e302f6c696e75782f676f312e342e32","nonce":"0x29d6547c196e00e0","miner":"0xbb7b8287f3f0a933474a79eae42cbca977791171","difficulty":"0x494433b31","gasLimit":"0x1388","gasUsed":"0x0","uncles":[],"sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347","size":"0x220","transactionsRoot":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421","stateRoot":"0x3fe6bd17aa85376c7d566df97d9f2e536f37f7a87abb3a6f9e2891cf9442f2e4","mixHash":"0x943056aa305aa6d22a3c06110942980342d1f4d4b11c17711961436a0f963ea0","parentHash":"0x7abfd11e862ccde76d6ea8ee20978aac26f4bcb55de1188cc0335be13e817017","timestamp":"0x55ba4564"} - -"#]]); -}); - -// test for cast_rpc with arguments -casttest!(rpc_format_as_json, |_prj, cmd| { - let eth_rpc_url = next_http_rpc_endpoint(); - - // Call `cast rpc eth_getBlockByNumber 0x123 false` - cmd.args(["rpc", "--rpc-url", eth_rpc_url.as_str(), "eth_getBlockByNumber", "0x123", "false", "--json"]) - .assert_json_stdout(str![[r#" -{ - "hash": "0xc5dab4e189004a1312e9db43a40abb2de91ad7dd25e75880bf36016d8e9df524", - "parentHash": "0x7abfd11e862ccde76d6ea8ee20978aac26f4bcb55de1188cc0335be13e817017", - "sha3Uncles": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", - "miner": "0xbb7b8287f3f0a933474a79eae42cbca977791171", - "stateRoot": "0x3fe6bd17aa85376c7d566df97d9f2e536f37f7a87abb3a6f9e2891cf9442f2e4", - "transactionsRoot": "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", - "receiptsRoot": "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", - "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", - "difficulty": "0x494433b31", - "number": "0x123", - "gasLimit": "0x1388", - "gasUsed": "0x0", - "timestamp": "0x55ba4564", - "extraData": "0x476574682f4c5649562f76312e302e302f6c696e75782f676f312e342e32", - "mixHash": "0x943056aa305aa6d22a3c06110942980342d1f4d4b11c17711961436a0f963ea0", - "nonce": "0x29d6547c196e00e0", - "size": "0x220", - "uncles": [], - "transactions": [] -} - -"#]]); -}); - -// test for cast_rpc with raw params -casttest!(rpc_raw_params, |_prj, cmd| { - let eth_rpc_url = next_http_rpc_endpoint(); - - // Call `cast rpc eth_getBlockByNumber --raw '["0x123", false]'` - cmd.args([ - "rpc", - "--rpc-url", - eth_rpc_url.as_str(), - "eth_getBlockByNumber", - "--raw", - r#"["0x123", false]"#, - ]) - .assert_json_stdout(str![[r#" -{"number":"0x123","hash":"0xc5dab4e189004a1312e9db43a40abb2de91ad7dd25e75880bf36016d8e9df524","transactions":[],"logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","receiptsRoot":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421","extraData":"0x476574682f4c5649562f76312e302e302f6c696e75782f676f312e342e32","nonce":"0x29d6547c196e00e0","miner":"0xbb7b8287f3f0a933474a79eae42cbca977791171","difficulty":"0x494433b31","gasLimit":"0x1388","gasUsed":"0x0","uncles":[],"sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347","size":"0x220","transactionsRoot":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421","stateRoot":"0x3fe6bd17aa85376c7d566df97d9f2e536f37f7a87abb3a6f9e2891cf9442f2e4","mixHash":"0x943056aa305aa6d22a3c06110942980342d1f4d4b11c17711961436a0f963ea0","parentHash":"0x7abfd11e862ccde76d6ea8ee20978aac26f4bcb55de1188cc0335be13e817017","timestamp":"0x55ba4564"} - -"#]]); -}); - -// test for cast_rpc with direct params -casttest!(rpc_raw_params_stdin, |_prj, cmd| { - let eth_rpc_url = next_http_rpc_endpoint(); - - // Call `echo "\n[\n\"0x123\",\nfalse\n]\n" | cast rpc eth_getBlockByNumber --raw - cmd.args(["rpc", "--rpc-url", eth_rpc_url.as_str(), "eth_getBlockByNumber", "--raw"]).stdin( - |mut stdin| { - stdin.write_all(b"\n[\n\"0x123\",\nfalse\n]\n").unwrap(); - }, - ) - .assert_json_stdout(str![[r#" -{"number":"0x123","hash":"0xc5dab4e189004a1312e9db43a40abb2de91ad7dd25e75880bf36016d8e9df524","transactions":[],"logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","receiptsRoot":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421","extraData":"0x476574682f4c5649562f76312e302e302f6c696e75782f676f312e342e32","nonce":"0x29d6547c196e00e0","miner":"0xbb7b8287f3f0a933474a79eae42cbca977791171","difficulty":"0x494433b31","gasLimit":"0x1388","gasUsed":"0x0","uncles":[],"sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347","size":"0x220","transactionsRoot":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421","stateRoot":"0x3fe6bd17aa85376c7d566df97d9f2e536f37f7a87abb3a6f9e2891cf9442f2e4","mixHash":"0x943056aa305aa6d22a3c06110942980342d1f4d4b11c17711961436a0f963ea0","parentHash":"0x7abfd11e862ccde76d6ea8ee20978aac26f4bcb55de1188cc0335be13e817017","timestamp":"0x55ba4564"} - -"#]]); -}); - -// checks `cast calldata` can handle arrays -casttest!(calldata_array, |_prj, cmd| { - cmd.args(["calldata", "propose(string[])", "[\"\"]"]).assert_success().stdout_eq(str![[r#" -0xcde2baba0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000 - -"#]]); -}); - -// -casttest!(run_succeeds, |_prj, cmd| { - let rpc = next_http_archive_rpc_url(); - cmd.args([ - "run", - "-v", - "0x2d951c5c95d374263ca99ad9c20c9797fc714330a8037429a3aa4c83d456f845", - "--quick", - "--rpc-url", - rpc.as_str(), - ]) - .assert_success() - .stdout_eq(str![[r#" -... -Transaction successfully executed. -[GAS] - -"#]]); -}); - -// tests that `cast --to-base` commands are working correctly. -casttest!(to_base, |_prj, cmd| { - let values = [ - "1", - "100", - "100000", - "115792089237316195423570985008687907853269984665640564039457584007913129639935", - "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", - "-1", - "-100", - "-100000", - "-57896044618658097711785492504343953926634992332820282019728792003956564819968", - ]; - for value in values { - for subcmd in ["--to-base", "--to-hex", "--to-dec"] { - if subcmd == "--to-base" { - for base in ["bin", "oct", "dec", "hex"] { - cmd.cast_fuse().args([subcmd, value, base]); - assert!(!cmd.assert_success().get_output().stdout_lossy().trim().is_empty()); - } - } else { - cmd.cast_fuse().args([subcmd, value]); - assert!(!cmd.assert_success().get_output().stdout_lossy().trim().is_empty()); - } - } - } -}); - -// tests that revert reason is only present if transaction has reverted. -casttest!(receipt_revert_reason, |_prj, cmd| { - let rpc = next_http_archive_rpc_url(); - - // - cmd.args([ - "receipt", - "0x44f2aaa351460c074f2cb1e5a9e28cbc7d83f33e425101d2de14331c7b7ec31e", - "--rpc-url", - rpc.as_str(), - ]) - .assert_success() - .stdout_eq(str![[r#" - -blockHash 0x2cfe65be49863676b6dbc04d58176a14f39b123f1e2f4fea0383a2d82c2c50d0 -blockNumber 16239315 -contractAddress -cumulativeGasUsed 10743428 -effectiveGasPrice 10539984136 -from 0x199D5ED7F45F4eE35960cF22EAde2076e95B253F -gasUsed 21000 -logs [] -logsBloom 0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -root -status 1 (success) -transactionHash 0x44f2aaa351460c074f2cb1e5a9e28cbc7d83f33e425101d2de14331c7b7ec31e -transactionIndex 116 -type 0 -blobGasPrice -blobGasUsed -to 0x91da5bf3F8Eb72724E6f50Ec6C3D199C6355c59c - -"#]]); - - let rpc = next_http_archive_rpc_url(); + .assert_success(); - // + // Send tx to change counter storage value. cmd.cast_fuse() .args([ - "receipt", - "0x0e07d8b53ed3d91314c80e53cf25bcde02084939395845cbb625b029d568135c", + "send", + "0x5FbDB2315678afecb367f032d93F642f64180aa3", + "setNumber(uint256)", + "111", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", "--rpc-url", - rpc.as_str(), + endpoint, ]) - .assert_success() - .stdout_eq(str![[r#" - -blockHash 0x883f974b17ca7b28cb970798d1c80f4d4bb427473dc6d39b2a7fe24edc02902d -blockNumber 14839405 -contractAddress -cumulativeGasUsed 20273649 -effectiveGasPrice 21491736378 -from 0x3cF412d970474804623bb4e3a42dE13F9bCa5436 -gasUsed 24952 -logs [] -logsBloom 0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -root -status 0 (failed) -transactionHash 0x0e07d8b53ed3d91314c80e53cf25bcde02084939395845cbb625b029d568135c -transactionIndex 173 -type 2 -blobGasPrice -blobGasUsed -to 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45 -revertReason [..]Transaction too old, data: "0x08c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000135472616e73616374696f6e20746f6f206f6c6400000000000000000000000000" - -"#]]); -}); - -// tests that the revert reason is loaded using the correct `from` address. -casttest!(revert_reason_from, |_prj, cmd| { - let rpc = next_rpc_endpoint(NamedChain::Sepolia); - // https://sepolia.etherscan.io/tx/0x10ee70cf9f5ced5c515e8d53bfab5ea9f5c72cd61b25fba455c8355ee286c4e4 - cmd.args([ - "receipt", - "0x10ee70cf9f5ced5c515e8d53bfab5ea9f5c72cd61b25fba455c8355ee286c4e4", - "--rpc-url", - rpc.as_str(), - ]) - .assert_success() - .stdout_eq(str![[r#" - -blockHash 0x32663d7730c9ea8e1de6d99854483e25fcc05bb56c91c0cc82f9f04944fbffc1 -blockNumber 7823353 -contractAddress -cumulativeGasUsed 7500797 -effectiveGasPrice 14296851013 -from 0x3583fF95f96b356d716881C871aF7Eb55ea34a93 -gasUsed 25815 -logs [] -logsBloom 0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -root -status 0 (failed) -transactionHash 0x10ee70cf9f5ced5c515e8d53bfab5ea9f5c72cd61b25fba455c8355ee286c4e4 -transactionIndex 96 -type 0 -blobGasPrice -blobGasUsed -to 0x91b5d4111a4C038153b24e31F75ccdC47123595d -revertReason Counter is too large, data: "0x08c379a000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000014436f756e74657220697320746f6f206c61726765000000000000000000000000" - -"#]]); -}); - -// tests that `cast --parse-bytes32-address` command is working correctly. -casttest!(parse_bytes32_address, |_prj, cmd| { - cmd.args([ - "--parse-bytes32-address", - "0x000000000000000000000000d8da6bf26964af9d7eed9e03e53415d37aa96045", - ]) - .assert_success() - .stdout_eq(str![[r#" -0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 - -"#]]); -}); - -casttest!(access_list, |_prj, cmd| { - let rpc = next_http_rpc_endpoint(); - cmd.args([ - "access-list", - "0xbb2b8038a1640196fbe3e38816f3e67cba72d940", - "skim(address)", - "0xbb2b8038a1640196fbe3e38816f3e67cba72d940", - "--rpc-url", - rpc.as_str(), - "--gas-limit", // need to set this for alchemy.io to avoid "intrinsic gas too low" error - "100000", - ]) - .assert_success() - .stdout_eq(str![[r#" -[GAS] -access list: -- address: [..] - keys: -... -- address: [..] - keys: -... -- address: [..] - keys: -... - -"#]]); -}); - -casttest!(logs_topics, |_prj, cmd| { - let rpc = next_http_archive_rpc_url(); - cmd.args([ - "logs", - "--rpc-url", - rpc.as_str(), - "--from-block", - "12421181", - "--to-block", - "12421182", - "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", - "0x000000000000000000000000ab5801a7d398351b8be11c439e05c5b3259aec9b", - ]) - .assert_success() - .stdout_eq(file!["../fixtures/cast_logs.stdout"]); -}); - -casttest!(logs_topic_2, |_prj, cmd| { - let rpc = next_http_archive_rpc_url(); - cmd.args([ - "logs", - "--rpc-url", - rpc.as_str(), - "--from-block", - "12421181", - "--to-block", - "12421182", - "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", - "", - "0x00000000000000000000000068a99f89e475a078645f4bac491360afe255dff1", /* Filter on the - * `to` address */ - ]) - .assert_success() - .stdout_eq(file!["../fixtures/cast_logs.stdout"]); -}); - -casttest!(logs_sig, |_prj, cmd| { - let rpc = next_http_archive_rpc_url(); - cmd.args([ - "logs", - "--rpc-url", - rpc.as_str(), - "--from-block", - "12421181", - "--to-block", - "12421182", - "Transfer(address indexed from, address indexed to, uint256 value)", - "0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B", - ]) - .assert_success() - .stdout_eq(file!["../fixtures/cast_logs.stdout"]); -}); - -casttest!(logs_sig_2, |_prj, cmd| { - let rpc = next_http_archive_rpc_url(); - cmd.args([ - "logs", - "--rpc-url", - rpc.as_str(), - "--from-block", - "12421181", - "--to-block", - "12421182", - "Transfer(address indexed from, address indexed to, uint256 value)", - "", - "0x68A99f89E475a078645f4BAC491360aFe255Dff1", - ]) - .assert_success() - .stdout_eq(file!["../fixtures/cast_logs.stdout"]); -}); - -casttest!(mktx, |_prj, cmd| { - cmd.args([ - "mktx", - "--private-key", - "0x0000000000000000000000000000000000000000000000000000000000000001", - "--chain", - "1", - "--nonce", - "0", - "--value", - "100", - "--gas-limit", - "21000", - "--gas-price", - "10000000000", - "--priority-gas-price", - "1000000000", - "0x0000000000000000000000000000000000000001", - ]).assert_success().stdout_eq(str![[r#" -0x02f86b0180843b9aca008502540be4008252089400000000000000000000000000000000000000016480c001a070d55e79ed3ac9fc8f51e78eb91fd054720d943d66633f2eb1bc960f0126b0eca052eda05a792680de3181e49bab4093541f75b49d1ecbe443077b3660c836016a - -"#]]); -}); - -// ensure recipient or code is required -casttest!(mktx_requires_to, |_prj, cmd| { - cmd.args([ - "mktx", - "--private-key", - "0x0000000000000000000000000000000000000000000000000000000000000001", - "--chain", - "1", - ]); - cmd.assert_failure().stderr_eq(str![[r#" -Error: Must specify a recipient address or contract code to deploy - -"#]]); -}); - -casttest!(mktx_signer_from_mismatch, |_prj, cmd| { - cmd.args([ - "mktx", - "--private-key", - "0x0000000000000000000000000000000000000000000000000000000000000001", - "--from", - "0x0000000000000000000000000000000000000001", - "--chain", - "1", - "0x0000000000000000000000000000000000000001", - ]); - cmd.assert_failure().stderr_eq(str![[r#" -Error: The specified sender via CLI/env vars does not match the sender configured via -the hardware wallet's HD Path. -Please use the `--hd-path ` parameter to specify the BIP32 Path which -corresponds to the sender, or let foundry automatically detect it by not specifying any sender address. - -"#]]); -}); - -casttest!(mktx_signer_from_match, |_prj, cmd| { - cmd.args([ - "mktx", - "--private-key", - "0x0000000000000000000000000000000000000000000000000000000000000001", - "--from", - "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf", - "--chain", - "1", - "--nonce", - "0", - "--gas-limit", - "21000", - "--gas-price", - "10000000000", - "--priority-gas-price", - "1000000000", - "0x0000000000000000000000000000000000000001", - ]).assert_success().stdout_eq(str![[r#" -0x02f86b0180843b9aca008502540be4008252089400000000000000000000000000000000000000018080c001a0cce9a61187b5d18a89ecd27ec675e3b3f10d37f165627ef89a15a7fe76395ce8a07537f5bffb358ffbef22cda84b1c92f7211723f9e09ae037e81686805d3e5505 - -"#]]); -}); + .assert_success(); -casttest!(mktx_raw_unsigned, |_prj, cmd| { - cmd.args([ - "mktx", - "--from", - "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf", - "--chain", - "1", - "--nonce", - "0", - "--gas-limit", - "21000", - "--gas-price", - "10000000000", - "--priority-gas-price", - "1000000000", - "0x0000000000000000000000000000000000000001", - "--raw-unsigned", - ]) - .assert_success() - .stdout_eq(str![[ - r#"0x02e80180843b9aca008502540be4008252089400000000000000000000000000000000000000018080c0 - -"# - ]]); -}); - -casttest!(mktx_ethsign, async |_prj, cmd| { - let (_api, handle) = anvil::spawn(NodeConfig::test()).await; - let rpc = handle.http_endpoint(); - cmd.args([ - "mktx", - "--from", - "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", - "--chain", - "31337", - "--nonce", - "0", - "--gas-limit", - "21000", - "--gas-price", - "10000000000", - "--priority-gas-price", - "1000000000", - "0x0000000000000000000000000000000000000001", - "--ethsign", - "--rpc-url", - rpc.as_str(), - ]) - .assert_success() - .stdout_eq(str![[ - r#" -0x02f86d827a6980843b9aca008502540be4008252089400000000000000000000000000000000000000018080c001a0b8eeb1ded87b085859c510c5692bed231e3ee8b068ccf71142bbf28da0e95987a07813b676a248ae8055f28495021d78dee6695479d339a6ad9d260d9eaf20674c - -"# - ]]); -}); - -// tests that the raw encoded transaction is returned -casttest!(tx_raw, |_prj, cmd| { - let rpc = next_http_rpc_endpoint(); - - // - cmd.args([ - "tx", - "0x44f2aaa351460c074f2cb1e5a9e28cbc7d83f33e425101d2de14331c7b7ec31e", - "raw", - "--rpc-url", - rpc.as_str(), - ]).assert_success().stdout_eq(str![[r#" -0xf86d824c548502743b65088275309491da5bf3f8eb72724e6f50ec6c3d199c6355c59c87a0a73f33e9e4cc8025a0428518b1748a08bbeb2392ea055b418538944d30adfc2accbbfa8362a401d3a4a07d6093ab2580efd17c11b277de7664fce56e6953cae8e925bec3313399860470 - -"#]]); - - // - cmd.cast_fuse().args([ - "tx", - "0x44f2aaa351460c074f2cb1e5a9e28cbc7d83f33e425101d2de14331c7b7ec31e", - "--raw", - "--rpc-url", - rpc.as_str(), - ]).assert_success().stdout_eq(str![[r#" -0xf86d824c548502743b65088275309491da5bf3f8eb72724e6f50ec6c3d199c6355c59c87a0a73f33e9e4cc8025a0428518b1748a08bbeb2392ea055b418538944d30adfc2accbbfa8362a401d3a4a07d6093ab2580efd17c11b277de7664fce56e6953cae8e925bec3313399860470 - -"#]]); -}); - -casttest!(tx_using_sender_and_nonce, |_prj, cmd| { - let rpc = "https://reth-ethereum.ithaca.xyz/rpc"; - // - let args = vec![ - "tx", - "--from", - "0x4648451b5F87FF8F0F7D622bD40574bb97E25980", - "--nonce", - "113642", - "--rpc-url", - rpc, - ]; - cmd.args(args).assert_success().stdout_eq(str![[r#" - -blockHash 0x29518c1cea251b1bda5949a9b039722604ec1fb99bf9d8124cfe001c95a50bdc -blockNumber 22287055 -from 0x4648451b5F87FF8F0F7D622bD40574bb97E25980 -transactionIndex 230 -effectiveGasPrice 363392048 - -accessList [] -chainId 1 -gasLimit 350000 -hash 0x5bcd22734cca2385dc25b2d38a3d33a640c5961bd46d390dff184c894204b594 -input 0xa9059cbb000000000000000000000000568766d218d82333dd4dae933ddfcda5da26625000000000000000000000000000000000000000000000000000000000cc3ed109 -maxFeePerGas 675979146 -maxPriorityFeePerGas 1337 -nonce 113642 -r 0x1e92d3e1ca69109a1743fc4b3cf9dff58630bc9f429cea3c3fe311506264e36c -s 0x793947d4bbdce56a1a5b2b3525c46f01569414a22355f4883b5429668ab0f51a -to 0xdAC17F958D2ee523a2206206994597C13D831ec7 -type 2 -value 0 -yParity 1 -... -"#]]); -}); - -// ensure receipt or code is required -casttest!(send_requires_to, |_prj, cmd| { - cmd.args([ - "send", - "--private-key", - "0x0000000000000000000000000000000000000000000000000000000000000001", - "--chain", - "1", - ]); - cmd.assert_failure().stderr_eq(str![[r#" -Error: Must specify a recipient address or contract code to deploy - -"#]]); -}); - -// -casttest!(send_7702_conflicts_with_create, |_prj, cmd| { - cmd.args([ - "send", "--private-key", "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" ,"--auth", "0xf85c827a6994f39fd6e51aad88f6f4ce6ab8827279cfffb922668001a03e1a66234e71242afcc7bc46c8950c3b2997b102db257774865f1232d2e7bf48a045e252dad189b27b2306792047745eba86bff0dd18aca813dbf3fba8c4e94576", "--create", "0x60806040523373ffffffffffffffffffffffffffffffffffffffff163273ffffffffffffffffffffffffffffffffffffffff1614610072576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401610069906100e5565b60405180910390fd5b3373ffffffffffffffffffffffffffffffffffffffff16ff5b5f82825260208201905092915050565b7f74782e6f726967696e203d3d206d73672e73656e6465720000000000000000005f82015250565b5f6100cf60178361008b565b91506100da8261009b565b602082019050919050565b5f6020820190508181035f8301526100fc816100c3565b905091905056fe" - ]); - cmd.assert_failure().stderr_eq(str![[r#" -Error: EIP-7702 transactions can't be CREATE transactions and require a destination address - -"#]]); -}); - -casttest!(storage, |_prj, cmd| { - let rpc = next_http_archive_rpc_url(); - cmd.args(["storage", "vitalik.eth", "1", "--rpc-url", &rpc]).assert_success().stdout_eq(str![ - [r#" -0x0000000000000000000000000000000000000000000000000000000000000000 - -"#] - ]); - - let rpc = next_http_archive_rpc_url(); - cmd.cast_fuse() - .args(["storage", "vitalik.eth", "0x01", "--rpc-url", &rpc]) - .assert_success() - .stdout_eq(str![[r#" -0x0000000000000000000000000000000000000000000000000000000000000000 - -"#]]); - - let rpc = next_http_archive_rpc_url(); - let usdt = "0xdac17f958d2ee523a2206206994597c13d831ec7"; - let decimals_slot = "0x09"; - cmd.cast_fuse() - .args(["storage", usdt, decimals_slot, "--rpc-url", &rpc]) - .assert_success() - .stdout_eq(str![[r#" -0x0000000000000000000000000000000000000000000000000000000000000006 - -"#]]); - - let rpc = next_http_archive_rpc_url(); - let total_supply_slot = "0x01"; - let block_before = "4634747"; - let block_after = "4634748"; - cmd.cast_fuse() - .args(["storage", usdt, total_supply_slot, "--rpc-url", &rpc, "--block", block_before]) - .assert_success() - .stdout_eq(str![[r#" -0x0000000000000000000000000000000000000000000000000000000000000000 - -"#]]); - - let rpc = next_http_archive_rpc_url(); - cmd.cast_fuse() - .args(["storage", usdt, total_supply_slot, "--rpc-url", &rpc, "--block", block_after]) - .assert_success() - .stdout_eq(str![[r#" -0x000000000000000000000000000000000000000000000000000000174876e800 - -"#]]); -}); - -// -casttest!(storage_layout_simple, |_prj, cmd| { - cmd.args([ - "storage", - "--rpc-url", - next_http_archive_rpc_url().as_str(), - "--block", - "21034138", - "--etherscan-api-key", - next_etherscan_api_key().as_str(), - "0x13b0D85CcB8bf860b6b79AF3029fCA081AE9beF2", - ]) - .assert_success() - .stdout_eq(str![[r#" - -╭---------+---------+------+--------+-------+-------+--------------------------------------------------------------------+-----------------------------------------------╮ -| Name | Type | Slot | Offset | Bytes | Value | Hex Value | Contract | -+========================================================================================================================================================================+ -| _owner | address | 0 | 0 | 20 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/Create2Deployer.sol:Create2Deployer | -|---------+---------+------+--------+-------+-------+--------------------------------------------------------------------+-----------------------------------------------| -| _paused | bool | 0 | 20 | 1 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/Create2Deployer.sol:Create2Deployer | -╰---------+---------+------+--------+-------+-------+--------------------------------------------------------------------+-----------------------------------------------╯ - - -"#]]); -}); - -// -casttest!(storage_layout_simple_json, |_prj, cmd| { - cmd.args([ - "storage", - "--rpc-url", - next_http_archive_rpc_url().as_str(), - "--block", - "21034138", - "--etherscan-api-key", - next_etherscan_api_key().as_str(), - "0x13b0D85CcB8bf860b6b79AF3029fCA081AE9beF2", - "--json", - ]) - .assert_success() - .stdout_eq(file!["../fixtures/storage_layout_simple.json": Json]); -}); - -// -casttest!(storage_layout_complex, |_prj, cmd| { - cmd.args([ - "storage", - "--rpc-url", - next_http_archive_rpc_url().as_str(), - "--block", - "21034138", - "--etherscan-api-key", - next_etherscan_api_key().as_str(), - "0xBA12222222228d8Ba445958a75a0704d566BF2C8", - ]) - .assert_success() - .stdout_eq(str![[r#" - -╭-------------------------------+--------------------------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+---------------------------------╮ -| Name | Type | Slot | Offset | Bytes | Value | Hex Value | Contract | -+======================================================================================================================================================================================================================================================================================+ -| _status | uint256 | 0 | 0 | 32 | 1 | 0x0000000000000000000000000000000000000000000000000000000000000001 | contracts/vault/Vault.sol:Vault | -|-------------------------------+--------------------------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+---------------------------------| -| _generalPoolsBalances | mapping(bytes32 => struct EnumerableMap.IERC20ToBytes32Map) | 1 | 0 | 32 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/vault/Vault.sol:Vault | -|-------------------------------+--------------------------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+---------------------------------| -| _nextNonce | mapping(address => uint256) | 2 | 0 | 32 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/vault/Vault.sol:Vault | -|-------------------------------+--------------------------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+---------------------------------| -| _paused | bool | 3 | 0 | 1 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/vault/Vault.sol:Vault | -|-------------------------------+--------------------------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+---------------------------------| -| _authorizer | contract IAuthorizer | 3 | 1 | 20 | 549683469959765988649777481110995959958745616871 | 0x0000000000000000000000006048a8c631fb7e77eca533cf9c29784e482391e7 | contracts/vault/Vault.sol:Vault | -|-------------------------------+--------------------------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+---------------------------------| -| _approvedRelayers | mapping(address => mapping(address => bool)) | 4 | 0 | 32 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/vault/Vault.sol:Vault | -|-------------------------------+--------------------------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+---------------------------------| -| _isPoolRegistered | mapping(bytes32 => bool) | 5 | 0 | 32 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/vault/Vault.sol:Vault | -|-------------------------------+--------------------------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+---------------------------------| -| _nextPoolNonce | uint256 | 6 | 0 | 32 | 1760 | 0x00000000000000000000000000000000000000000000000000000000000006e0 | contracts/vault/Vault.sol:Vault | -|-------------------------------+--------------------------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+---------------------------------| -| _minimalSwapInfoPoolsBalances | mapping(bytes32 => mapping(contract IERC20 => bytes32)) | 7 | 0 | 32 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/vault/Vault.sol:Vault | -|-------------------------------+--------------------------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+---------------------------------| -| _minimalSwapInfoPoolsTokens | mapping(bytes32 => struct EnumerableSet.AddressSet) | 8 | 0 | 32 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/vault/Vault.sol:Vault | -|-------------------------------+--------------------------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+---------------------------------| -| _twoTokenPoolTokens | mapping(bytes32 => struct TwoTokenPoolsBalance.TwoTokenPoolTokens) | 9 | 0 | 32 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/vault/Vault.sol:Vault | -|-------------------------------+--------------------------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+---------------------------------| -| _poolAssetManagers | mapping(bytes32 => mapping(contract IERC20 => address)) | 10 | 0 | 32 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/vault/Vault.sol:Vault | -|-------------------------------+--------------------------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+---------------------------------| -| _internalTokenBalance | mapping(address => mapping(contract IERC20 => uint256)) | 11 | 0 | 32 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/vault/Vault.sol:Vault | -╰-------------------------------+--------------------------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+---------------------------------╯ - - -"#]]); -}); - -casttest!(storage_layout_complex_proxy, |_prj, cmd| { - cmd.args([ - "storage", - "--rpc-url", - next_rpc_endpoint(NamedChain::Sepolia).as_str(), - "--block", - "7857852", - "--etherscan-api-key", - next_etherscan_api_key().as_str(), - "0xE2588A9CAb7Ea877206E35f615a39f84a64A7A3b", - "--proxy", - "0x29fcb43b46531bca003ddc8fcb67ffe91900c762" - ]) - .assert_success() - .stdout_eq(str![[r#" - -╭----------------------------+-------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+-----------------------------╮ -| Name | Type | Slot | Offset | Bytes | Value | Hex Value | Contract | -+============================================================================================================================================================================================================================================================+ -| singleton | address | 0 | 0 | 20 | 239704109775411986678417050956533140837380441954 | 0x00000000000000000000000029fcb43b46531bca003ddc8fcb67ffe91900c762 | contracts/SafeL2.sol:SafeL2 | -|----------------------------+-------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+-----------------------------| -| modules | mapping(address => address) | 1 | 0 | 32 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/SafeL2.sol:SafeL2 | -|----------------------------+-------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+-----------------------------| -| owners | mapping(address => address) | 2 | 0 | 32 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/SafeL2.sol:SafeL2 | -|----------------------------+-------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+-----------------------------| -| ownerCount | uint256 | 3 | 0 | 32 | 1 | 0x0000000000000000000000000000000000000000000000000000000000000001 | contracts/SafeL2.sol:SafeL2 | -|----------------------------+-------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+-----------------------------| -| threshold | uint256 | 4 | 0 | 32 | 1 | 0x0000000000000000000000000000000000000000000000000000000000000001 | contracts/SafeL2.sol:SafeL2 | -|----------------------------+-------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+-----------------------------| -| nonce | uint256 | 5 | 0 | 32 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/SafeL2.sol:SafeL2 | -|----------------------------+-------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+-----------------------------| -| _deprecatedDomainSeparator | bytes32 | 6 | 0 | 32 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/SafeL2.sol:SafeL2 | -|----------------------------+-------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+-----------------------------| -| signedMessages | mapping(bytes32 => uint256) | 7 | 0 | 32 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/SafeL2.sol:SafeL2 | -|----------------------------+-------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+-----------------------------| -| approvedHashes | mapping(address => mapping(bytes32 => uint256)) | 8 | 0 | 32 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/SafeL2.sol:SafeL2 | -╰----------------------------+-------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+-----------------------------╯ - - -"#]]); -}); - -casttest!(storage_layout_complex_json, |_prj, cmd| { - cmd.args([ - "storage", - "--rpc-url", - next_http_archive_rpc_url().as_str(), - "--block", - "21034138", - "--etherscan-api-key", - next_etherscan_api_key().as_str(), - "0xBA12222222228d8Ba445958a75a0704d566BF2C8", - "--json", - ]) - .assert_success() - .stdout_eq(file!["../fixtures/storage_layout_complex.json": Json]); -}); - -casttest!(balance, |_prj, cmd| { - let rpc = next_http_rpc_endpoint(); - let usdt = "0xdac17f958d2ee523a2206206994597c13d831ec7"; - - let usdt_result = cmd - .args([ - "balance", - "0x0000000000000000000000000000000000000000", - "--erc20", - usdt, - "--rpc-url", - &rpc, - ]) - .assert_success() - .get_output() - .stdout_lossy() - .trim() - .to_string(); - - let alias_result = cmd - .cast_fuse() - .args([ - "balance", - "0x0000000000000000000000000000000000000000", - "--erc721", - usdt, - "--rpc-url", - &rpc, - ]) - .assert_success() - .get_output() - .stdout_lossy() - .trim() - .to_string(); - - assert_ne!(usdt_result, "0"); - assert_eq!(alias_result, usdt_result); -}); - -// tests that `cast interface` excludes the constructor -// -casttest!(interface_no_constructor, |prj, cmd| { - let interface = include_str!("../fixtures/interface.json"); - - let path = prj.root().join("interface.json"); - fs::write(&path, interface).unwrap(); - // Call `cast find-block` - cmd.arg("interface").arg(&path).assert_success().stdout_eq(str![[ - r#"// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.4; - -library IIntegrationManager { - type SpendAssetsHandleType is uint8; -} - -interface Interface { - function getIntegrationManager() external view returns (address integrationManager_); - function lend(address _vaultProxy, bytes memory, bytes memory _assetData) external; - function parseAssetsForAction(address, bytes4 _selector, bytes memory _actionData) - external - view - returns ( - IIntegrationManager.SpendAssetsHandleType spendAssetsHandleType_, - address[] memory spendAssets_, - uint256[] memory spendAssetAmounts_, - address[] memory incomingAssets_, - uint256[] memory minIncomingAssetAmounts_ - ); - function redeem(address _vaultProxy, bytes memory, bytes memory _assetData) external; -} - -"# - ]]); -}); - -// tests that fetches WETH interface from etherscan -// -casttest!(fetch_weth_interface_from_etherscan, |_prj, cmd| { - cmd.args([ - "interface", - "--etherscan-api-key", - &next_etherscan_api_key(), - "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", - ]) - .assert_success() - .stdout_eq(str![[r#" -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.4; - -interface WETH9 { - event Approval(address indexed src, address indexed guy, uint256 wad); - event Deposit(address indexed dst, uint256 wad); - event Transfer(address indexed src, address indexed dst, uint256 wad); - event Withdrawal(address indexed src, uint256 wad); - - fallback() external payable; - - function allowance(address, address) external view returns (uint256); - function approve(address guy, uint256 wad) external returns (bool); - function balanceOf(address) external view returns (uint256); - function decimals() external view returns (uint8); - function deposit() external payable; - function name() external view returns (string memory); - function symbol() external view returns (string memory); - function totalSupply() external view returns (uint256); - function transfer(address dst, uint256 wad) external returns (bool); - function transferFrom(address src, address dst, uint256 wad) external returns (bool); - function withdraw(uint256 wad) external; -} - -"#]]); -}); - -casttest!(ens_namehash, |_prj, cmd| { - cmd.args(["namehash", "emo.eth"]).assert_success().stdout_eq(str![[r#" -0x0a21aaf2f6414aa664deb341d1114351fdb023cad07bf53b28e57c26db681910 - -"#]]); -}); - -casttest!(ens_lookup, |_prj, cmd| { - let eth_rpc_url = next_http_rpc_endpoint(); - cmd.args([ - "lookup-address", - "0x28679A1a632125fbBf7A68d850E50623194A709E", - "--rpc-url", - ð_rpc_url, - "--verify", - ]) - .assert_success() - .stdout_eq(str![[r#" -emo.eth - -"#]]); -}); - -casttest!(ens_resolve, |_prj, cmd| { - let eth_rpc_url = next_http_rpc_endpoint(); - cmd.args(["resolve-name", "emo.eth", "--rpc-url", ð_rpc_url, "--verify"]) - .assert_success() - .stdout_eq(str![[r#" -0x28679A1a632125fbBf7A68d850E50623194A709E - -"#]]); -}); - -casttest!(ens_resolve_no_dot_eth, |_prj, cmd| { - let eth_rpc_url = next_http_rpc_endpoint(); - cmd.args(["resolve-name", "emo", "--rpc-url", ð_rpc_url, "--verify"]) - .assert_failure() - .stderr_eq(str![[r#" -Error: ENS resolver not found for name "emo" - -"#]]); -}); - -casttest!(index7201, |_prj, cmd| { - cmd.args(["index-erc7201", "example.main"]).assert_success().stdout_eq(str![[r#" -0x183a6125c38840424c4a85fa12bab2ab606c4b6d0e7cc73c0c06ba5300eab500 - -"#]]); -}); - -casttest!(index7201_unknown_formula_id, |_prj, cmd| { - cmd.args(["index-erc7201", "test", "--formula-id", "unknown"]).assert_failure().stderr_eq( - str![[r#" -Error: unsupported formula ID: unknown - -"#]], - ); -}); - -casttest!(block_number, |_prj, cmd| { - let eth_rpc_url = next_http_rpc_endpoint(); - let s = cmd - .args(["block-number", "--rpc-url", eth_rpc_url.as_str()]) - .assert_success() - .get_output() - .stdout_lossy(); - assert!(s.trim().parse::().unwrap() > 0, "{s}") -}); - -casttest!(block_number_latest, |_prj, cmd| { - let eth_rpc_url = next_http_rpc_endpoint(); - let s = cmd - .args(["block-number", "--rpc-url", eth_rpc_url.as_str(), "latest"]) - .assert_success() - .get_output() - .stdout_lossy(); - assert!(s.trim().parse::().unwrap() > 0, "{s}") -}); - -casttest!(block_number_hash, |_prj, cmd| { - let eth_rpc_url = next_http_rpc_endpoint(); - let s = cmd - .args([ - "block-number", - "--rpc-url", - eth_rpc_url.as_str(), - "0x88e96d4537bea4d9c05d12549907b32561d3bf31f45aae734cdc119f13406cb6", - ]) - .assert_success() - .get_output() - .stdout_lossy(); - assert_eq!(s.trim().parse::().unwrap(), 1, "{s}") -}); - -// Tests that `cast --disable-block-gas-limit` commands are working correctly for BSC -// -// Equivalent transaction on Binance Smart Chain Testnet: -// -casttest!(run_disable_block_gas_limit_check, |_prj, cmd| { - let bsc_testnet_rpc_url = next_rpc_endpoint(NamedChain::BinanceSmartChainTestnet); - - let latest_block_json: serde_json::Value = serde_json::from_str( - &cmd.args(["block", "--rpc-url", bsc_testnet_rpc_url.as_str(), "--json"]) - .assert_success() - .get_output() - .stdout_lossy(), - ) - .expect("Failed to parse latest block"); - - let latest_excessive_gas_limit_tx = - latest_block_json["transactions"].as_array().and_then(|txs| { - txs.iter() - .find(|tx| tx.get("gas").and_then(|gas| gas.as_str()) == Some("0x7fffffffffffffff")) - }); - - match latest_excessive_gas_limit_tx { - Some(tx) => { - let tx_hash = - tx.get("hash").and_then(|h| h.as_str()).expect("Transaction missing hash"); - - // If --disable-block-gas-limit is not provided, the transaction should fail as the gas - // limit exceeds the block gas limit. - cmd.cast_fuse() - .args(["run", "-v", tx_hash, "--quick", "--rpc-url", bsc_testnet_rpc_url.as_str()]) - .assert_failure() - .stderr_eq(str![[r#" -Error: EVM error; transaction validation error: caller gas limit exceeds the block gas limit - -"#]]); - - // If --disable-block-gas-limit is provided, the transaction should succeed - // despite the gas limit exceeding the block gas limit. - cmd.cast_fuse() - .args([ - "run", - "-v", - tx_hash, - "--quick", - "--rpc-url", - bsc_testnet_rpc_url.as_str(), - "--disable-block-gas-limit", - ]) - .assert_success() - .stdout_eq(str![[r#" -... -Transaction successfully executed. -[GAS] - -"#]]); - } - None => { - eprintln!( - "Skipping test: No transaction with gas = 0x7fffffffffffffff found in the latest block." - ); - } - } -}); - -casttest!(send_eip7702, async |_prj, cmd| { - let (_api, handle) = - anvil::spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Prague.into()))).await; - let endpoint = handle.http_endpoint(); - - cmd.args([ - "send", - "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", - "--auth", - "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", - "--private-key", - "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", - "--rpc-url", - &endpoint, - ]) - .assert_success(); - - cmd.cast_fuse() - .args(["code", "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", "--rpc-url", &endpoint]) - .assert_success() - .stdout_eq(str![[r#" -0xef010070997970c51812dc3a010c7d01b50e0d17dc79c8 - -"#]]); -}); - -casttest!(hash_message, |_prj, cmd| { - cmd.args(["hash-message", "hello"]).assert_success().stdout_eq(str![[r#" -0x50b2c43fd39106bafbba0da34fc430e1f91e3c96ea2acee2bc34119f92b37750 - -"#]]); - - cmd.cast_fuse().args(["hash-message", "0x68656c6c6f"]).assert_success().stdout_eq(str![[r#" -0x83a0870b6c63a71efdd3b2749ef700653d97454152c4b53fa9b102dc430c7c32 - -"#]]); -}); - -casttest!(parse_units, |_prj, cmd| { - cmd.args(["parse-units", "1.5", "6"]).assert_success().stdout_eq(str![[r#" -1500000 - -"#]]); - - cmd.cast_fuse().args(["pun", "1.23", "18"]).assert_success().stdout_eq(str![[r#" -1230000000000000000 - -"#]]); - - cmd.cast_fuse().args(["--parse-units", "1.23", "3"]).assert_success().stdout_eq(str![[r#" -1230 - -"#]]); -}); - -casttest!(string_decode, |_prj, cmd| { - cmd.args(["string-decode", "0x88c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000054753303235000000000000000000000000000000000000000000000000000000"]).assert_success().stdout_eq(str![[r#" -"GS025" - -"#]]); -}); - -casttest!(format_units, |_prj, cmd| { - cmd.args(["format-units", "1000000", "6"]).assert_success().stdout_eq(str![[r#" -1 - -"#]]); - - cmd.cast_fuse().args(["--format-units", "2500000", "6"]).assert_success().stdout_eq(str![[ - r#" -2.500000 - -"# - ]]); - - cmd.cast_fuse().args(["fun", "1230", "3"]).assert_success().stdout_eq(str![[r#" -1.230 - -"#]]); -}); - -// tests that fetches a sample contract creation code -// -casttest!(fetch_creation_code_from_etherscan, |_prj, cmd| { - let eth_rpc_url = next_http_rpc_endpoint(); - cmd.args([ - "creation-code", - "--etherscan-api-key", - &next_etherscan_api_key(), - "0x0923cad07f06b2d0e5e49e63b8b35738d4156b95", - "--rpc-url", - eth_rpc_url.as_str(), - ]) - .assert_success() - .stdout_eq(str![[r#" -0x60566050600b82828239805160001a6073146043577f4e487b7100000000000000000000000000000000000000000000000000000000600052600060045260246000fd5b30600052607381538281f3fe73000000000000000000000000000000000000000030146080604052600080fdfea264697066735822122074c61e8e4eefd410ca92eec26e8112ec6e831d0a4bf35718fdd78b45d68220d064736f6c63430008070033 - -"#]]); -}); - -// tests that fetches a sample contract creation args bytes -// -casttest!(fetch_creation_code_only_args_from_etherscan, |_prj, cmd| { - let eth_rpc_url = next_http_rpc_endpoint(); - cmd.args([ - "creation-code", - "--etherscan-api-key", - &next_etherscan_api_key(), - "0x6982508145454ce325ddbe47a25d4ec3d2311933", - "--rpc-url", - eth_rpc_url.as_str(), - "--only-args", - ]) - .assert_success() - .stdout_eq(str![[r#" -0x00000000000000000000000000000000000014bddab3e51a57cff87a50000000 - -"#]]); -}); - -// tests that displays a sample contract creation args -// -casttest!(fetch_constructor_args_from_etherscan, |_prj, cmd| { - let eth_rpc_url = next_http_rpc_endpoint(); - cmd.args([ - "constructor-args", - "--etherscan-api-key", - &next_etherscan_api_key(), - "0x6982508145454ce325ddbe47a25d4ec3d2311933", - "--rpc-url", - eth_rpc_url.as_str(), - ]) - .assert_success() - .stdout_eq(str![[r#" -0x00000000000000000000000000000000000014bddab3e51a57cff87a50000000 → Uint(420690000000000000000000000000000, 256) - -"#]]); -}); - -// -casttest!(test_non_mainnet_traces, |prj, cmd| { - prj.clear(); - cmd.args([ - "run", - "0xa003e419e2d7502269eb5eda56947b580120e00abfd5b5460d08f8af44a0c24f", - "--rpc-url", - next_rpc_endpoint(NamedChain::Optimism).as_str(), - "--etherscan-api-key", - next_etherscan_api_key().as_str(), - ]) - .assert_success() - .stdout_eq(str![[r#" -Executing previous transactions from the block. -Traces: - [33841] FiatTokenProxy::fallback(0x111111125421cA6dc452d289314280a0f8842A65, 164054805 [1.64e8]) - ├─ [26673] FiatTokenV2_2::approve(0x111111125421cA6dc452d289314280a0f8842A65, 164054805 [1.64e8]) [delegatecall] - │ ├─ emit Approval(owner: 0x9a95Af47C51562acfb2107F44d7967DF253197df, spender: 0x111111125421cA6dc452d289314280a0f8842A65, value: 164054805 [1.64e8]) - │ └─ ← [Return] true - └─ ← [Return] true -... - -"#]]); -}); - -// tests that displays a sample contract artifact -// -casttest!(fetch_artifact_from_etherscan, |_prj, cmd| { - let eth_rpc_url = next_http_rpc_endpoint(); - cmd.args([ - "artifact", - "--etherscan-api-key", - &next_etherscan_api_key(), - "0x0923cad07f06b2d0e5e49e63b8b35738d4156b95", - "--rpc-url", - eth_rpc_url.as_str(), - ]) - .assert_success() - .stdout_eq(str![[r#"{ - "abi": [], - "bytecode": { - "object": "0x60566050600b82828239805160001a6073146043577f4e487b7100000000000000000000000000000000000000000000000000000000600052600060045260246000fd5b30600052607381538281f3fe73000000000000000000000000000000000000000030146080604052600080fdfea264697066735822122074c61e8e4eefd410ca92eec26e8112ec6e831d0a4bf35718fdd78b45d68220d064736f6c63430008070033" - } -} - -"#]]); -}); - -// tests cast can decode traces when using project artifacts -forgetest_async!(decode_traces_with_project_artifacts, |prj, cmd| { - let (api, handle) = - anvil::spawn(NodeConfig::test().with_disable_default_create2_deployer(true)).await; - - foundry_test_utils::util::initialize(prj.root()); - prj.add_source( - "LocalProjectContract", - r#" -contract LocalProjectContract { - event LocalProjectContractCreated(address owner); - - constructor() { - emit LocalProjectContractCreated(msg.sender); - } -} - "#, - ) - .unwrap(); - prj.add_script( - "LocalProjectScript", - r#" -import "forge-std/Script.sol"; -import {LocalProjectContract} from "../src/LocalProjectContract.sol"; - -contract LocalProjectScript is Script { - function run() public { - vm.startBroadcast(); - new LocalProjectContract(); - vm.stopBroadcast(); - } -} - "#, - ) - .unwrap(); - - cmd.args([ - "script", - "--private-key", - "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", - "--rpc-url", - &handle.http_endpoint(), - "--broadcast", - "LocalProjectScript", - ]); - - cmd.assert_success(); - - let tx_hash = api - .transaction_by_block_number_and_index(BlockNumberOrTag::Latest, Index::from(0)) + api.transaction_by_block_number_and_index(BlockNumberOrTag::Latest, Index::from(0)) .await .unwrap() .unwrap() - .tx_hash(); - - // Assert cast with local artifacts from outside the project. - cmd.cast_fuse() - .args(["run", "--la", format!("{tx_hash}").as_str(), "--rpc-url", &handle.http_endpoint()]) - .assert_success() - .stdout_eq(str![[r#" -Executing previous transactions from the block. -Compiling project to generate artifacts -Nothing to compile - -"#]]); - - // Run cast from project dir. - cmd.cast_fuse().set_current_dir(prj.root()); - - // Assert cast without local artifacts cannot decode traces. - cmd.cast_fuse() - .args(["run", format!("{tx_hash}").as_str(), "--rpc-url", &handle.http_endpoint()]) - .assert_success() - .stdout_eq(str![[r#" -Executing previous transactions from the block. -Traces: - [..] → new @0x5FbDB2315678afecb367f032d93F642f64180aa3 - ├─ emit topic 0: 0xa7263295d3a687d750d1fd377b5df47de69d7db8decc745aaa4bbee44dc1688d - │ data: 0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266 - └─ ← [Return] 62 bytes of code - - -Transaction successfully executed. -[GAS] - -"#]]); - - // Assert cast with local artifacts can decode traces. - cmd.cast_fuse() - .args(["run", "--la", format!("{tx_hash}").as_str(), "--rpc-url", &handle.http_endpoint()]) - .assert_success() - .stdout_eq(str![[r#" -Executing previous transactions from the block. -Compiling project to generate artifacts -No files changed, compilation skipped -Traces: - [..] → new LocalProjectContract@0x5FbDB2315678afecb367f032d93F642f64180aa3 - ├─ emit LocalProjectContractCreated(owner: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266) - └─ ← [Return] 62 bytes of code - - -Transaction successfully executed. -[GAS] - -"#]]); -}); - -// tests cast can decode traces when running with verbosity level > 4 -forgetest_async!(show_state_changes_in_traces, |prj, cmd| { - let (api, handle) = anvil::spawn(NodeConfig::test()).await; - - foundry_test_utils::util::initialize(prj.root()); - // Deploy counter contract. - cmd.args([ - "script", - "--private-key", - "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", - "--rpc-url", - &handle.http_endpoint(), - "--broadcast", - "CounterScript", - ]) - .assert_success(); - - // Send tx to change counter storage value. - cmd.cast_fuse() - .args([ - "send", - "0x5FbDB2315678afecb367f032d93F642f64180aa3", - "setNumber(uint256)", - "111", - "--private-key", - "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", - "--rpc-url", - &handle.http_endpoint(), - ]) - .assert_success(); - - let tx_hash = api - .transaction_by_block_number_and_index(BlockNumberOrTag::Latest, Index::from(0)) - .await - .unwrap() - .unwrap() - .tx_hash(); - - // Assert cast with verbosity displays storage changes. - cmd.cast_fuse() - .args([ - "run", - format!("{tx_hash}").as_str(), - "-vvvvv", - "--rpc-url", - &handle.http_endpoint(), - ]) - .assert_success() - .stdout_eq(str![[r#" -Executing previous transactions from the block. -Traces: - [..] 0x5FbDB2315678afecb367f032d93F642f64180aa3::setNumber(111) - ├─ storage changes: - │ @ 0: 0 → 111 - └─ ← [Stop] - - -Transaction successfully executed. -[GAS] - -"#]]); -}); - -// tests cast can decode external libraries traces with project cached selectors -forgetest_async!(decode_external_libraries_with_cached_selectors, |prj, cmd| { - let (api, handle) = anvil::spawn(NodeConfig::test()).await; - - foundry_test_utils::util::initialize(prj.root()); - prj.add_source( - "ExternalLib", - r#" -import "./CounterInExternalLib.sol"; -library ExternalLib { - function updateCounterInExternalLib(CounterInExternalLib.Info storage counterInfo, uint256 counter) public { - counterInfo.counter = counter + 1; - } -} - "#, - ) - .unwrap(); - prj.add_source( - "CounterInExternalLib", - r#" -import "./ExternalLib.sol"; -contract CounterInExternalLib { - struct Info { - uint256 counter; - } - Info info; - constructor() { - ExternalLib.updateCounterInExternalLib(info, 100); - } -} - "#, - ) - .unwrap(); - prj.add_script( - "CounterInExternalLibScript", - r#" -import "forge-std/Script.sol"; -import {CounterInExternalLib} from "../src/CounterInExternalLib.sol"; -contract CounterInExternalLibScript is Script { - function run() public { - vm.startBroadcast(); - new CounterInExternalLib(); - vm.stopBroadcast(); - } -} - "#, - ) - .unwrap(); - - cmd.args([ - "script", - "--private-key", - "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", - "--rpc-url", - &handle.http_endpoint(), - "--broadcast", - "CounterInExternalLibScript", - ]) - .assert_success(); - - let tx_hash = api - .transaction_by_block_number_and_index(BlockNumberOrTag::Latest, Index::from(0)) - .await - .unwrap() - .unwrap() - .tx_hash(); - - // Cache project selectors. - cmd.forge_fuse().args(["selectors", "cache"]).assert_success(); - - // Assert cast with local artifacts can decode external lib signature. - cmd.cast_fuse() - .args(["run", format!("{tx_hash}").as_str(), "--rpc-url", &handle.http_endpoint()]) - .assert_success() - .stdout_eq(str![[r#" -... -Traces: - [..] → new @0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 - ├─ [..] 0x6fD8bf6770F4bEe578348D24028000cE9c4D2bB9::updateCounterInExternalLib(0, 100) [delegatecall] - │ └─ ← [Stop] - └─ ← [Return] 62 bytes of code - - -Transaction successfully executed. -[GAS] - -"#]]); -}); - -// https://github.com/foundry-rs/foundry/issues/9476 -forgetest_async!(cast_call_custom_chain_id, |_prj, cmd| { - let chain_id = 55555u64; - let (_api, handle) = anvil::spawn(NodeConfig::test().with_chain_id(Some(chain_id))).await; - - let http_endpoint = handle.http_endpoint(); - - cmd.cast_fuse() - .args([ - "call", - "5FbDB2315678afecb367f032d93F642f64180aa3", - "--rpc-url", - &http_endpoint, - "--chain", - &chain_id.to_string(), - ]) - .assert_success(); -}); - -// https://github.com/foundry-rs/foundry/issues/10848 -forgetest_async!(cast_call_disable_labels, |prj, cmd| { - let (_, handle) = anvil::spawn(NodeConfig::test()).await; - - foundry_test_utils::util::initialize(prj.root()); - prj.add_source( - "Counter", - r#" -contract Counter { - uint256 public number; - - function getBalance(address target) public returns (uint256) { - return target.balance; - } -} - "#, - ) - .unwrap(); - - // Deploy counter contract. - cmd.args([ - "script", - "--private-key", - "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", - "--rpc-url", - &handle.http_endpoint(), - "--broadcast", - "CounterScript", - ]) - .assert_success(); - - // Override state, `number()` should return overridden value. - cmd.cast_fuse() - .args([ - "call", - "0x5FbDB2315678afecb367f032d93F642f64180aa3", - "--rpc-url", - &handle.http_endpoint(), - "--override-state", - "0x5FbDB2315678afecb367f032d93F642f64180aa3:0x0:0x1234", - "number()(uint256)", - ]) - .assert_success() - .stdout_eq(str![[r#" -4660 - -"#]]); - - // Override state, `number()` should return overridden value. - cmd.cast_fuse() - .args([ - "call", - "0x5FbDB2315678afecb367f032d93F642f64180aa3", - "--labels", - "0x5FbDB2315678afecb367f032d93F642f64180aa3:WETH", - "--rpc-url", - &handle.http_endpoint(), - "--override-state", - "0x5FbDB2315678afecb367f032d93F642f64180aa3:0x0:0x1234", - "number()(uint256)", - "--trace", - ]) - .assert_success() - .stdout_eq(str![[r#" -Traces: - [2402] WETH::number() - └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000001234 - - -Transaction successfully executed. -[GAS] - -"#]]); - - // Override state, `number()` with `disable_labels`. - cmd.cast_fuse() - .args([ - "call", - "0x5FbDB2315678afecb367f032d93F642f64180aa3", - "--labels", - "0x5FbDB2315678afecb367f032d93F642f64180aa3:WETH", - "--rpc-url", - &handle.http_endpoint(), - "--override-state", - "0x5FbDB2315678afecb367f032d93F642f64180aa3:0x0:0x1234", - "number()(uint256)", - "--trace", - "--disable-labels", - ]) - .assert_success() - .stdout_eq(str![[r#" -Traces: - [2402] 0x5FbDB2315678afecb367f032d93F642f64180aa3::number() - └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000001234 - - -Transaction successfully executed. -[GAS] - -"#]]); -}); - -// https://github.com/foundry-rs/foundry/issues/10189 -forgetest_async!(cast_call_custom_override, |prj, cmd| { - let (_, handle) = anvil::spawn(NodeConfig::test()).await; - - foundry_test_utils::util::initialize(prj.root()); - prj.add_source( - "Counter", - r#" -contract Counter { - uint256 public number; - - function getBalance(address target) public returns (uint256) { - return target.balance; - } -} - "#, - ) - .unwrap(); - - // Deploy counter contract. - cmd.args([ - "script", - "--private-key", - "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", - "--rpc-url", - &handle.http_endpoint(), - "--broadcast", - "CounterScript", - ]) - .assert_success(); - - // Override state, `number()` should return overridden value. - cmd.cast_fuse() - .args([ - "call", - "0x5FbDB2315678afecb367f032d93F642f64180aa3", - "--rpc-url", - &handle.http_endpoint(), - "--override-state", - "0x5FbDB2315678afecb367f032d93F642f64180aa3:0x0:0x1234", - "number()(uint256)", - ]) - .assert_success() - .stdout_eq(str![[r#" -4660 - -"#]]); - - // Override state, `number()` should return overridden value. - cmd.cast_fuse() - .args([ - "call", - "0x5FbDB2315678afecb367f032d93F642f64180aa3", - "--rpc-url", - &handle.http_endpoint(), - "--override-state", - "0x5FbDB2315678afecb367f032d93F642f64180aa3:0x0:0x1234", - "number()(uint256)", - "--trace", - ]) - .assert_success() - .stdout_eq(str![[r#" -Traces: - [2402] 0x5FbDB2315678afecb367f032d93F642f64180aa3::number() - └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000001234 - - -Transaction successfully executed. -[GAS] - -"#]]); - - // Override balance, `getBalance()` should return overridden value. - cmd.cast_fuse() - .args([ - "call", - "0x5FbDB2315678afecb367f032d93F642f64180aa3", - "--rpc-url", - &handle.http_endpoint(), - "--override-balance", - "0x5FbDB2315678afecb367f032d93F642f64180aa3:0x1111", - "getBalance(address)(uint256)", - "0x5FbDB2315678afecb367f032d93F642f64180aa3", - ]) - .assert_success() - .stdout_eq(str![[r#" -4369 - -"#]]); - - // Override balance, `getBalance()` should return overridden value. - cmd.cast_fuse() - .args([ - "call", - "0x5FbDB2315678afecb367f032d93F642f64180aa3", - "--rpc-url", - &handle.http_endpoint(), - "--override-balance", - "0x5FbDB2315678afecb367f032d93F642f64180aa3:0x1111", - "getBalance(address)(uint256)", - "0x5FbDB2315678afecb367f032d93F642f64180aa3", - "--trace", - ]) - .assert_success() - .stdout_eq(str![[r#" -Traces: - [747] 0x5FbDB2315678afecb367f032d93F642f64180aa3::getBalance(0x5FbDB2315678afecb367f032d93F642f64180aa3) - └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000001111 - - -Transaction successfully executed. -[GAS] - -"#]]); - - // Override code with - // contract Counter { - // uint256 public number1; - // } - // Calling `number()` should fail. - cmd.cast_fuse() - .args([ - "call", - "0x5FbDB2315678afecb367f032d93F642f64180aa3", - "--rpc-url", - &handle.http_endpoint(), - "--override-code", - "0x5FbDB2315678afecb367f032d93F642f64180aa3:0x6080604052348015600e575f5ffd5b50600436106026575f3560e01c8063c223a39e14602a575b5f5ffd5b60306044565b604051603b9190605f565b60405180910390f35b5f5481565b5f819050919050565b6059816049565b82525050565b5f60208201905060705f8301846052565b9291505056fea26469706673582212202a0acfb9083efed3e0e9f27177b090731d4392cf196d58e27e05088f59008d0964736f6c634300081d0033", - "number()(uint256)", - ]) - .assert_failure() - .stderr_eq(str![[r#" -Error: server returned an error response: error code 3: execution reverted, data: "0x" - -"#]]); - - // Override code with - // contract Counter { - // uint256 public number1; - // } - // Calling `number()` should revert. - cmd.cast_fuse() - .args([ - "call", - "0x5FbDB2315678afecb367f032d93F642f64180aa3", - "--rpc-url", - &handle.http_endpoint(), - "--override-code", - "0x5FbDB2315678afecb367f032d93F642f64180aa3:0x6080604052348015600e575f5ffd5b50600436106026575f3560e01c8063c223a39e14602a575b5f5ffd5b60306044565b604051603b9190605f565b60405180910390f35b5f5481565b5f819050919050565b6059816049565b82525050565b5f60208201905060705f8301846052565b9291505056fea26469706673582212202a0acfb9083efed3e0e9f27177b090731d4392cf196d58e27e05088f59008d0964736f6c634300081d0033", - "number()(uint256)", - "--trace" - ]) - .assert_success() - .stderr_eq(str![[r#" -Error: Transaction failed. - -"#]]); - - // Calling `number1()` with overridden state should return new value. - cmd.cast_fuse() - .args([ - "call", - "0x5FbDB2315678afecb367f032d93F642f64180aa3", - "--rpc-url", - &handle.http_endpoint(), - "--override-code", - "0x5FbDB2315678afecb367f032d93F642f64180aa3:0x6080604052348015600e575f5ffd5b50600436106026575f3560e01c8063c223a39e14602a575b5f5ffd5b60306044565b604051603b9190605f565b60405180910390f35b5f5481565b5f819050919050565b6059816049565b82525050565b5f60208201905060705f8301846052565b9291505056fea26469706673582212202a0acfb9083efed3e0e9f27177b090731d4392cf196d58e27e05088f59008d0964736f6c634300081d0033", - "--override-state", - "0x5FbDB2315678afecb367f032d93F642f64180aa3:0x0:0x2222", - "number1()(uint256)", - ]) - .assert_success() - .stdout_eq(str![[r#" -8738 - -"#]]); - - // Calling `number1()` with overridden state should return new value. - cmd.cast_fuse() - .args([ - "call", - "0x5FbDB2315678afecb367f032d93F642f64180aa3", - "--rpc-url", - &handle.http_endpoint(), - "--override-code", - "0x5FbDB2315678afecb367f032d93F642f64180aa3:0x6080604052348015600e575f5ffd5b50600436106026575f3560e01c8063c223a39e14602a575b5f5ffd5b60306044565b604051603b9190605f565b60405180910390f35b5f5481565b5f819050919050565b6059816049565b82525050565b5f60208201905060705f8301846052565b9291505056fea26469706673582212202a0acfb9083efed3e0e9f27177b090731d4392cf196d58e27e05088f59008d0964736f6c634300081d0033", - "--override-state", - "0x5FbDB2315678afecb367f032d93F642f64180aa3:0x0:0x2222", - "number1()(uint256)", - "--trace" - ]) - .assert_success() - .stdout_eq(str![[r#" -Traces: - [2402] 0x5FbDB2315678afecb367f032d93F642f64180aa3::number1() - └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000002222 - - -Transaction successfully executed. -[GAS] - -"#]]); - - // Calling `number1()` with overridden state should return new value. - cmd.cast_fuse() - .args([ - "call", - "0x5FbDB2315678afecb367f032d93F642f64180aa3", - "--rpc-url", - &handle.http_endpoint(), - "--override-code", - "0x5FbDB2315678afecb367f032d93F642f64180aa3:0x6080604052348015600e575f5ffd5b50600436106026575f3560e01c8063c223a39e14602a575b5f5ffd5b60306044565b604051603b9190605f565b60405180910390f35b5f5481565b5f819050919050565b6059816049565b82525050565b5f60208201905060705f8301846052565b9291505056fea26469706673582212202a0acfb9083efed3e0e9f27177b090731d4392cf196d58e27e05088f59008d0964736f6c634300081d0033", - "--override-state-diff", - "0x5FbDB2315678afecb367f032d93F642f64180aa3:0x0:0x2222", - "number1()(uint256)", - ]) - .assert_success() - .stdout_eq(str![[r#" -8738 - -"#]]); - - // Calling `number1()` with overridden state should return new value. - cmd.cast_fuse() - .args([ - "call", - "0x5FbDB2315678afecb367f032d93F642f64180aa3", - "--rpc-url", - &handle.http_endpoint(), - "--override-code", - "0x5FbDB2315678afecb367f032d93F642f64180aa3:0x6080604052348015600e575f5ffd5b50600436106026575f3560e01c8063c223a39e14602a575b5f5ffd5b60306044565b604051603b9190605f565b60405180910390f35b5f5481565b5f819050919050565b6059816049565b82525050565b5f60208201905060705f8301846052565b9291505056fea26469706673582212202a0acfb9083efed3e0e9f27177b090731d4392cf196d58e27e05088f59008d0964736f6c634300081d0033", - "--override-state-diff", - "0x5FbDB2315678afecb367f032d93F642f64180aa3:0x0:0x2222", - "number1()(uint256)", - "--trace", - ]) - .assert_success() - .stdout_eq(str![[r#" -Traces: - [2402] 0x5FbDB2315678afecb367f032d93F642f64180aa3::number1() - └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000002222 - - -Transaction successfully executed. -[GAS] - -"#]]); -}); - -// https://github.com/foundry-rs/foundry/issues/9541 -forgetest_async!(cast_run_impersonated_tx, |_prj, cmd| { - let (_api, handle) = anvil::spawn( - NodeConfig::test() - .with_auto_impersonate(true) - .with_eth_rpc_url(Some("https://sepolia.base.org")), - ) - .await; - - let http_endpoint = handle.http_endpoint(); - - let provider = ProviderBuilder::new().connect_http(http_endpoint.parse().unwrap()); - - // send impersonated tx - let tx = TransactionRequest::default() - .with_from(address!("0x041563c07028Fc89106788185763Fc73028e8511")) - .with_to(address!("0xF38aA5909D89F5d98fCeA857e708F6a6033f6CF8")) - .with_input( - Bytes::from_str( - "0x60fe47b1000000000000000000000000000000000000000000000000000000000000000c", - ) - .unwrap(), - ); - - let receipt = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); - - assert!(receipt.status()); - - // run impersonated tx - cmd.cast_fuse() - .args(["run", &receipt.transaction_hash.to_string(), "--rpc-url", &http_endpoint]) - .assert_success(); -}); - -// -casttest!(fetch_src_blockscout, |_prj, cmd| { - let url = "https://eth.blockscout.com/api"; - - let weth = address!("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"); - - cmd.args([ - "source", - &weth.to_string(), - "--chain-id", - "1", - "--explorer-api-url", - url, - "--flatten", - ]) - .assert_success() - .stdout_eq(str![[r#" -... -contract WETH9 { - string public name = "Wrapped Ether"; - string public symbol = "WETH"; - uint8 public decimals = 18; -..."#]]); -}); - -casttest!(fetch_src_default, |_prj, cmd| { - let weth = address!("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"); - let etherscan_api_key = next_etherscan_api_key(); - - cmd.args(["source", &weth.to_string(), "--flatten", "--etherscan-api-key", ðerscan_api_key]) - .assert_success() - .stdout_eq(str![[r#" -... -contract WETH9 { - string public name = "Wrapped Ether"; - string public symbol = "WETH"; - uint8 public decimals = 18; -..."#]]); -}); - -// -// -casttest!(odyssey_can_run_p256_precompile, |_prj, cmd| { - cmd.args([ - "run", - "0x17b2de59ebd7dfd2452a3638a16737b6b65ae816c1c5571631dc0d80b63c41de", - "--rpc-url", - next_rpc_endpoint(NamedChain::Base).as_str(), - "--quick", - "--odyssey", - ]) - .assert_success() - .stdout_eq(str![[r#" -Traces: - [88087] 0xc2FF493F28e894742b968A7DB5D3F21F0aD80C6c::execute(0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000a12384c5e52fd646e7bc7f6b3b33a605651f566e000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000170000000000000000000000000000000000000000000000000000000000000000000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda02913000000000000000000000000000000000000000000000000000000000000060f000000000000000000000000000000000000000000000000000000000000060f0000000000000000000000000000000000000000000000000000000000036cd000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000000320000000000000000000000000000000000000000000000000000000000000060f000000000000000000000000000000000000000000000000000000000000060f000000000000000000000000327a25ad5cfe5c4d4339c1a4267d4a83e8c93312000000000000000000000000000000000000000000000000000000000000034000000000000000000000000000000000000000000000000000000000000005a00000000000000000000000000b55b053230e4effb6609de652fca73fd1c2980400000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000221000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006cdd519280ec730727f07aa36550bde31a1d5f3097818f3425c2f083ed33a91f080fa2afac0071f6e1af9a0e9c09b851bf01e68bc8a1c1f89f686c48205762f92500000000000000000000000000000000000000000000000000000000000000244242424242424242424242424242424242424242424242424242424242424242010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000827b226368616c6c656e6765223a224b51704d51446e7841757a726f68522d483878472d5a536b625249702d76515f5f5f4a714259357a655038222c2263726f73734f726967696e223a66616c73652c226f726967696e223a2268747470732f2f6974686163612e78797a222c2274797065223a22776562617574686e2e676574227d0000000000000000000000000000000000000000000000000000000000001bde17b8de18819c9eb86cefc3920ddb5d3d4254de276e3d6e18dd2b399f732b00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000) - ├─ [2241] 0xA12384c5E52fD646E7BC7F6B3b33A605651F566E::fallback(00) [staticcall] - │ └─ ← [Return] 0x0000000000000000000000000b55b053230e4effb6609de652fca73fd1c29804 - ├─ [9750] 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913::balanceOf(0xA12384c5E52fD646E7BC7F6B3b33A605651F566E) [staticcall] - │ ├─ [2553] 0x2Ce6311ddAE708829bc0784C967b7d77D19FD779::balanceOf(0xA12384c5E52fD646E7BC7F6B3b33A605651F566E) [delegatecall] - │ │ └─ ← [Return] 0x000000000000000000000000000000000000000000000000000000000000f3b9 - │ └─ ← [Return] 0x000000000000000000000000000000000000000000000000000000000000f3b9 - ├─ [61992] 0xc2FF493F28e894742b968A7DB5D3F21F0aD80C6c::00000000(00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000a12384c5e52fd646e7bc7f6b3b33a605651f566e000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000170000000000000000000000000000000000000000000000000000000000000000000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda02913000000000000000000000000000000000000000000000000000000000000060f000000000000000000000000000000000000000000000000000000000000060f0000000000000000000000000000000000000000000000000000000000036cd000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000000320000000000000000000000000000000000000000000000000000000000000060f000000000000000000000000000000000000000000000000000000000000060f000000000000000000000000327a25ad5cfe5c4d4339c1a4267d4a83e8c93312000000000000000000000000000000000000000000000000000000000000034000000000000000000000000000000000000000000000000000000000000005a00000000000000000000000000b55b053230e4effb6609de652fca73fd1c2980400000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000221000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006cdd519280ec730727f07aa36550bde31a1d5f3097818f3425c2f083ed33a91f080fa2afac0071f6e1af9a0e9c09b851bf01e68bc8a1c1f89f686c48205762f92500000000000000000000000000000000000000000000000000000000000000244242424242424242424242424242424242424242424242424242424242424242010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000827b226368616c6c656e6765223a224b51704d51446e7841757a726f68522d483878472d5a536b625249702d76515f5f5f4a714259357a655038222c2263726f73734f726967696e223a66616c73652c226f726967696e223a2268747470732f2f6974686163612e78797a222c2274797065223a22776562617574686e2e676574227d0000000000000000000000000000000000000000000000000000000000001bde17b8de18819c9eb86cefc3920ddb5d3d4254de276e3d6e18dd2b399f732b000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000) - │ ├─ [21620] 0xA12384c5E52fD646E7BC7F6B3b33A605651F566E::unwrapAndValidateSignature(0x290a4c4039f102eceba2147e1fcc46f994a46d1229faf43ffff26a058e7378ff, 0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006cdd519280ec730727f07aa36550bde31a1d5f3097818f3425c2f083ed33a91f080fa2afac0071f6e1af9a0e9c09b851bf01e68bc8a1c1f89f686c48205762f92500000000000000000000000000000000000000000000000000000000000000244242424242424242424242424242424242424242424242424242424242424242010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000827b226368616c6c656e6765223a224b51704d51446e7841757a726f68522d483878472d5a536b625249702d76515f5f5f4a714259357a655038222c2263726f73734f726967696e223a66616c73652c226f726967696e223a2268747470732f2f6974686163612e78797a222c2274797065223a22776562617574686e2e676574227d0000000000000000000000000000000000000000000000000000000000001bde17b8de18819c9eb86cefc3920ddb5d3d4254de276e3d6e18dd2b399f732b00) [staticcall] - │ │ ├─ [18617] 0x0B55b053230E4EFFb6609de652fCa73Fd1C29804::unwrapAndValidateSignature(0x290a4c4039f102eceba2147e1fcc46f994a46d1229faf43ffff26a058e7378ff, 0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006cdd519280ec730727f07aa36550bde31a1d5f3097818f3425c2f083ed33a91f080fa2afac0071f6e1af9a0e9c09b851bf01e68bc8a1c1f89f686c48205762f92500000000000000000000000000000000000000000000000000000000000000244242424242424242424242424242424242424242424242424242424242424242010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000827b226368616c6c656e6765223a224b51704d51446e7841757a726f68522d483878472d5a536b625249702d76515f5f5f4a714259357a655038222c2263726f73734f726967696e223a66616c73652c226f726967696e223a2268747470732f2f6974686163612e78797a222c2274797065223a22776562617574686e2e676574227d0000000000000000000000000000000000000000000000000000000000001bde17b8de18819c9eb86cefc3920ddb5d3d4254de276e3d6e18dd2b399f732b00) [delegatecall] - │ │ │ ├─ [2369] 0xc2FF493F28e894742b968A7DB5D3F21F0aD80C6c::pauseFlag() [staticcall] - │ │ │ │ └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000000000 - │ │ │ ├─ [120] PRECOMPILES::sha256(0x7b226368616c6c656e6765223a224b51704d51446e7841757a726f68522d483878472d5a536b625249702d76515f5f5f4a714259357a655038222c2263726f73734f726967696e223a66616c73652c226f726967696e223a2268747470732f2f6974686163612e78797a222c2274797065223a22776562617574686e2e676574227d) [staticcall] - │ │ │ │ └─ ← [Return] 0xc13089327d3c20c0ce35f2f058c423de29977e6950e406c095e366a8fabd463f - │ │ │ ├─ [96] PRECOMPILES::sha256(0x424242424242424242424242424242424242424242424242424242424242424201000000c13089327d3c20c0ce35f2f058c423de29977e6950e406c095e366a8fabd463f) [staticcall] - │ │ │ │ └─ ← [Return] 0xc544bd9a4ea526dda3a008f43c21b6f0be3031b1ff71832b9876915dc91deea0 - │ │ │ ├─ [3450] 0x0000000000000000000000000000000000000100::c544bd9a(4ea526dda3a008f43c21b6f0be3031b1ff71832b9876915dc91deea0dd519280ec730727f07aa36550bde31a1d5f3097818f3425c2f083ed33a91f080fa2afac0071f6e1af9a0e9c09b851bf01e68bc8a1c1f89f686c48205762f925bf54fa13f88658092efa36c51b1e3c4db31d3afb92812fb852dac7cf9614bc479bf5da7241d9c4ab1b431b57ec3369587b4c831d7a564438990da053708c3289) [staticcall] - │ │ │ │ └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000000001 - │ │ │ └─ ← [Return] 0x00000000000000000000000000000000000000000000000000000000000000011bde17b8de18819c9eb86cefc3920ddb5d3d4254de276e3d6e18dd2b399f732b - │ │ └─ ← [Return] 0x00000000000000000000000000000000000000000000000000000000000000011bde17b8de18819c9eb86cefc3920ddb5d3d4254de276e3d6e18dd2b399f732b - │ ├─ [5994] 0xA12384c5E52fD646E7BC7F6B3b33A605651F566E::checkAndIncrementNonce(23) - │ │ ├─ [5608] 0x0B55b053230E4EFFb6609de652fCa73Fd1C29804::checkAndIncrementNonce(23) [delegatecall] - │ │ │ └─ ← [Stop] - │ │ └─ ← [Return] - │ ├─ [3250] 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913::balanceOf(0x327a25aD5Cfe5c4D4339C1A4267D4a83E8c93312) [staticcall] - │ │ ├─ [2553] 0x2Ce6311ddAE708829bc0784C967b7d77D19FD779::balanceOf(0x327a25aD5Cfe5c4D4339C1A4267D4a83E8c93312) [delegatecall] - │ │ │ └─ ← [Return] 0x000000000000000000000000000000000000000000000000000000000000968b - │ │ └─ ← [Return] 0x000000000000000000000000000000000000000000000000000000000000968b - │ ├─ [16411] 0xA12384c5E52fD646E7BC7F6B3b33A605651F566E::pay(1551, 0x1bde17b8de18819c9eb86cefc3920ddb5d3d4254de276e3d6e18dd2b399f732b, 0x290a4c4039f102eceba2147e1fcc46f994a46d1229faf43ffff26a058e7378ff, 0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000a12384c5e52fd646e7bc7f6b3b33a605651f566e000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000170000000000000000000000000000000000000000000000000000000000000000000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda02913000000000000000000000000000000000000000000000000000000000000060f000000000000000000000000000000000000000000000000000000000000060f0000000000000000000000000000000000000000000000000000000000036cd000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000000320000000000000000000000000000000000000000000000000000000000000060f000000000000000000000000000000000000000000000000000000000000060f000000000000000000000000327a25ad5cfe5c4d4339c1a4267d4a83e8c93312000000000000000000000000000000000000000000000000000000000000034000000000000000000000000000000000000000000000000000000000000005a00000000000000000000000000b55b053230e4effb6609de652fca73fd1c2980400000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000221000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006cdd519280ec730727f07aa36550bde31a1d5f3097818f3425c2f083ed33a91f080fa2afac0071f6e1af9a0e9c09b851bf01e68bc8a1c1f89f686c48205762f92500000000000000000000000000000000000000000000000000000000000000244242424242424242424242424242424242424242424242424242424242424242010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000827b226368616c6c656e6765223a224b51704d51446e7841757a726f68522d483878472d5a536b625249702d76515f5f5f4a714259357a655038222c2263726f73734f726967696e223a66616c73652c226f726967696e223a2268747470732f2f6974686163612e78797a222c2274797065223a22776562617574686e2e676574227d0000000000000000000000000000000000000000000000000000000000001bde17b8de18819c9eb86cefc3920ddb5d3d4254de276e3d6e18dd2b399f732b000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000) - │ │ ├─ [15711] 0x0B55b053230E4EFFb6609de652fCa73Fd1C29804::pay(1551, 0x1bde17b8de18819c9eb86cefc3920ddb5d3d4254de276e3d6e18dd2b399f732b, 0x290a4c4039f102eceba2147e1fcc46f994a46d1229faf43ffff26a058e7378ff, 0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000a12384c5e52fd646e7bc7f6b3b33a605651f566e000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000170000000000000000000000000000000000000000000000000000000000000000000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda02913000000000000000000000000000000000000000000000000000000000000060f000000000000000000000000000000000000000000000000000000000000060f0000000000000000000000000000000000000000000000000000000000036cd000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000000320000000000000000000000000000000000000000000000000000000000000060f000000000000000000000000000000000000000000000000000000000000060f000000000000000000000000327a25ad5cfe5c4d4339c1a4267d4a83e8c93312000000000000000000000000000000000000000000000000000000000000034000000000000000000000000000000000000000000000000000000000000005a00000000000000000000000000b55b053230e4effb6609de652fca73fd1c2980400000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000221000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006cdd519280ec730727f07aa36550bde31a1d5f3097818f3425c2f083ed33a91f080fa2afac0071f6e1af9a0e9c09b851bf01e68bc8a1c1f89f686c48205762f92500000000000000000000000000000000000000000000000000000000000000244242424242424242424242424242424242424242424242424242424242424242010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000827b226368616c6c656e6765223a224b51704d51446e7841757a726f68522d483878472d5a536b625249702d76515f5f5f4a714259357a655038222c2263726f73734f726967696e223a66616c73652c226f726967696e223a2268747470732f2f6974686163612e78797a222c2274797065223a22776562617574686e2e676574227d0000000000000000000000000000000000000000000000000000000000001bde17b8de18819c9eb86cefc3920ddb5d3d4254de276e3d6e18dd2b399f732b000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000) [delegatecall] - │ │ │ ├─ [12963] 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913::transfer(0x327a25aD5Cfe5c4D4339C1A4267D4a83E8c93312, 1551) - │ │ │ │ ├─ [12263] 0x2Ce6311ddAE708829bc0784C967b7d77D19FD779::transfer(0x327a25aD5Cfe5c4D4339C1A4267D4a83E8c93312, 1551) [delegatecall] - │ │ │ │ │ ├─ emit Transfer(param0: 0xA12384c5E52fD646E7BC7F6B3b33A605651F566E, param1: 0x327a25aD5Cfe5c4D4339C1A4267D4a83E8c93312, param2: 1551) - │ │ │ │ │ └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000000001 - │ │ │ │ └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000000001 - │ │ │ └─ ← [Stop] - │ │ └─ ← [Return] - │ ├─ [1250] 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913::balanceOf(0x327a25aD5Cfe5c4D4339C1A4267D4a83E8c93312) [staticcall] - │ │ ├─ [553] 0x2Ce6311ddAE708829bc0784C967b7d77D19FD779::balanceOf(0x327a25aD5Cfe5c4D4339C1A4267D4a83E8c93312) [delegatecall] - │ │ │ └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000009c9a - │ │ └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000009c9a - │ ├─ [5675] 0xc2FF493F28e894742b968A7DB5D3F21F0aD80C6c::00000001(00000000000000000000000000000000000000000000000000000000000000001bde17b8de18819c9eb86cefc3920ddb5d3d4254de276e3d6e18dd2b399f732b290a4c4039f102eceba2147e1fcc46f994a46d1229faf43ffff26a058e7378ff0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000a12384c5e52fd646e7bc7f6b3b33a605651f566e000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000170000000000000000000000000000000000000000000000000000000000000000000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda02913000000000000000000000000000000000000000000000000000000000000060f000000000000000000000000000000000000000000000000000000000000060f0000000000000000000000000000000000000000000000000000000000036cd000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000000320000000000000000000000000000000000000000000000000000000000000060f000000000000000000000000000000000000000000000000000000000000060f000000000000000000000000327a25ad5cfe5c4d4339c1a4267d4a83e8c93312000000000000000000000000000000000000000000000000000000000000034000000000000000000000000000000000000000000000000000000000000005a00000000000000000000000000b55b053230e4effb6609de652fca73fd1c2980400000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000221000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006cdd519280ec730727f07aa36550bde31a1d5f3097818f3425c2f083ed33a91f080fa2afac0071f6e1af9a0e9c09b851bf01e68bc8a1c1f89f686c48205762f92500000000000000000000000000000000000000000000000000000000000000244242424242424242424242424242424242424242424242424242424242424242010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000827b226368616c6c656e6765223a224b51704d51446e7841757a726f68522d483878472d5a536b625249702d76515f5f5f4a714259357a655038222c2263726f73734f726967696e223a66616c73652c226f726967696e223a2268747470732f2f6974686163612e78797a222c2274797065223a22776562617574686e2e676574227d0000000000000000000000000000000000000000000000000000000000001bde17b8de18819c9eb86cefc3920ddb5d3d4254de276e3d6e18dd2b399f732b00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000) - │ │ ├─ [4148] 0xA12384c5E52fD646E7BC7F6B3b33A605651F566E::execute(0x0100000000007821000100000000000000000000000000000000000000000000, 0x0000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000201bde17b8de18819c9eb86cefc3920ddb5d3d4254de276e3d6e18dd2b399f732b) - │ │ │ ├─ [3693] 0x0B55b053230E4EFFb6609de652fCa73Fd1C29804::execute(0x0100000000007821000100000000000000000000000000000000000000000000, 0x0000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000201bde17b8de18819c9eb86cefc3920ddb5d3d4254de276e3d6e18dd2b399f732b) [delegatecall] - │ │ │ │ ├─ [435] 0xA12384c5E52fD646E7BC7F6B3b33A605651F566E::fallback() - │ │ │ │ │ ├─ [55] 0x0B55b053230E4EFFb6609de652fCa73Fd1C29804::fallback() [delegatecall] - │ │ │ │ │ │ └─ ← [Stop] - │ │ │ │ │ └─ ← [Return] - │ │ │ │ └─ ← [Stop] - │ │ │ └─ ← [Return] - │ │ └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000000000 - │ └─ ← [Stop] - ├─ emit topic 0: 0x31e2fdd22f7eeca688d70008a7bee8e41aa5640885c2bc592419ae8d09d889f1 - │ topic 1: 0x000000000000000000000000a12384c5e52fd646e7bc7f6b3b33a605651f566e - │ topic 2: 0x0000000000000000000000000000000000000000000000000000000000000017 - │ data: 0x00000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000 - └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000000000 - - -Transaction successfully executed. -[GAS] - -"#]]); -}); - -// tests cast send gas estimate execution failure message contains decoded custom error -// -forgetest_async!(cast_send_estimate_gas_error, |prj, cmd| { - let (_, handle) = anvil::spawn(NodeConfig::test()).await; - - foundry_test_utils::util::initialize(prj.root()); - prj.add_source( - "SimpleStorage", - r#" -contract SimpleStorage { - uint256 private storedValue; - error AddressInsufficientBalance(address account, uint256 newValue); - function setValue(uint256 _newValue) public { - if (_newValue > 100) { - revert AddressInsufficientBalance(msg.sender, _newValue); - } - storedValue = _newValue; - } -} - "#, - ) - .unwrap(); - prj.add_script( - "SimpleStorageScript", - r#" -import "forge-std/Script.sol"; -import {SimpleStorage} from "../src/SimpleStorage.sol"; -contract SimpleStorageScript is Script { - function run() public { - vm.startBroadcast(); - new SimpleStorage(); - vm.stopBroadcast(); - } -} - "#, - ) - .unwrap(); - - cmd.args([ - "script", - "--private-key", - "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", - "--rpc-url", - &handle.http_endpoint(), - "--broadcast", - "SimpleStorageScript", - ]) - .assert_success(); - - // Cache project selectors. - cmd.forge_fuse().set_current_dir(prj.root()); - cmd.forge_fuse().args(["selectors", "cache"]).assert_success(); - - // Assert cast send can decode custom error on estimate gas execution failure. - cmd.cast_fuse() - .args([ - "send", - "0x5FbDB2315678afecb367f032d93F642f64180aa3", - "setValue(uint256)", - "1000", - "--private-key", - "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", - "--rpc-url", - &handle.http_endpoint(), - ]) - .assert_failure().stderr_eq(str![[r#" -Error: Failed to estimate gas: server returned an error response: error code 3: execution reverted: custom error 0x6786ad34: 000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb9226600000000000000000000000000000000000000000000000000000000000003e8, data: "0x6786ad34000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb9226600000000000000000000000000000000000000000000000000000000000003e8": AddressInsufficientBalance(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, 1000) - -"#]]); -}); - -// -casttest!(estimate_base_da, |_prj, cmd| { - cmd.args(["da-estimate", "30558838", "-r", "https://mainnet.base.org/"]) - .assert_success() - .stdout_eq(str![[r#" -Estimated data availability size for block 30558838 with 225 transactions: 52916546100 - -"#]]); -}); - -// -casttest!(cast_call_return_array_of_tuples, |_prj, cmd| { - cmd.args([ - "call", - "0x198FC70Dfe05E755C81e54bd67Bff3F729344B9b", - "facets() returns ((address,bytes4[])[])", - "--rpc-url", - "https://rpc.viction.xyz", - ]) - .assert_success() - .stdout_eq(str![[r#" -[[..]] - -"#]]); -}); - -// -casttest!(cast_call_on_contract_with_no_code_prints_warning, |_prj, cmd| { - let eth_rpc_url = next_http_rpc_endpoint(); - cmd.args([ - "call", - "0x0000000000000000000000000000000000000000", - "--rpc-url", - eth_rpc_url.as_str(), - ]) - .assert_success() - .stderr_eq(str![[r#" -Warning: Contract code is empty - -"#]]) - .stdout_eq(str![[r#" -0x - -"#]]); -}); - -// -casttest!(tx_raw_opstack_deposit, |_prj, cmd| { - cmd.args([ - "tx", - "0xf403cba612d1c01c027455c0d97427ccd5f7f99aac30017e065f81d1e30244ea", - "--raw", - "--rpc-url", - "https://sepolia.base.org", - ]).assert_success() - .stdout_eq(str![[r#" -0x7ef90207a0cbde10ec697aff886f95d2514bab434e455620627b9bb8ba33baaaa4d537d62794d45955f4de64f1840e5686e64278da901e263031944200000000000000000000000000000000000007872386f26fc10000872386f26fc1000083096c4980b901a4d764ad0b0001000000000000000000000000000000000000000000000000000000065132000000000000000000000000fd0bf71f60660e2f608ed56e1659c450eb1131200000000000000000000000004200000000000000000000000000000000000010000000000000000000000000000000000000000000000000002386f26fc1000000000000000000000000000000000000000000000000000000000000000493e000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000a41635f5fd000000000000000000000000ca11bde05977b3631167028862be2a173976ca110000000000000000000000005703b26fe5a7be820db1bf34c901a79da1a46ba4000000000000000000000000000000000000000000000000002386f26fc100000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 - -"#]]); -}); - -// Test that cast send --create works correctly with constructor arguments -// -forgetest_async!(cast_send_create_with_constructor_args, |prj, cmd| { - let (_api, handle) = anvil::spawn(NodeConfig::test()).await; - let endpoint = handle.http_endpoint(); - - // Deploy a simple contract with constructor arguments - // Contract source that takes constructor args - prj.add_source( - "ConstructorContract", - r#" -contract ConstructorContract { - uint256 public value; - string public name; - - constructor(uint256 _value, string memory _name) { - value = _value; - name = _name; - } - - function getValue() public view returns (uint256) { - return value; - } -} -"#, - ) - .unwrap(); - - // Compile to get bytecode - cmd.forge_fuse().args(["build"]).assert_success(); - - // Get the compiled bytecode - let bytecode_path = prj.root().join("out/ConstructorContract.sol/ConstructorContract.json"); - let contract_json = std::fs::read_to_string(bytecode_path).unwrap(); - let contract_data: serde_json::Value = serde_json::from_str(&contract_json).unwrap(); - let bytecode = contract_data["bytecode"]["object"].as_str().unwrap(); - - // Use cast send --create with constructor arguments - let output = cmd - .cast_fuse() - .args([ - "send", - "--private-key", - "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", - "--rpc-url", - &endpoint, - "--create", - bytecode, - "constructor(uint256,string)", - "42", - "TestContract", - ]) - .assert_success() - .get_output() - .stdout_lossy(); - - // Extract the deployed contract address from output - let lines: Vec<&str> = output.lines().collect(); - let mut address = None; - for line in lines { - if line.contains("contractAddress") { - let parts: Vec<&str> = line.split_whitespace().collect(); - address = Some(parts[1]); - break; - } - } - let address = address.expect("Contract address not found in output"); - - // Verify the contract was deployed correctly by calling getValue() - let value_output = cmd - .cast_fuse() - .args(["call", address, "getValue()", "--rpc-url", &endpoint]) - .assert_success() - .get_output() - .stdout_lossy(); - - // The value should be 42 (0x2a in hex) - assert!( - value_output.contains("0x000000000000000000000000000000000000000000000000000000000000002a") - ); -}); - -// Test that cast estimate --create works correctly with constructor arguments -// -casttest!(cast_estimate_create_with_constructor_args, |prj, cmd| { - let eth_rpc_url = next_http_rpc_endpoint(); - - // Add a simple contract with constructor arguments - prj.add_source( - "EstimateContract", - r#" -contract EstimateContract { - uint256 public value; - string public name; - - constructor(uint256 _value, string memory _name) { - value = _value; - name = _name; - } -} -"#, - ) - .unwrap(); - - // Compile to get bytecode - cmd.forge_fuse().args(["build"]).assert_success(); - - // Get the compiled bytecode - let bytecode_path = prj.root().join("out/EstimateContract.sol/EstimateContract.json"); - let contract_json = std::fs::read_to_string(bytecode_path).unwrap(); - let contract_data: serde_json::Value = serde_json::from_str(&contract_json).unwrap(); - let bytecode = contract_data["bytecode"]["object"].as_str().unwrap(); - - let output = cmd - .cast_fuse() - .args([ - "estimate", - "--rpc-url", - eth_rpc_url.as_str(), - "--create", - bytecode, - "constructor(uint256,string)", - "100", - "TestContract", - ]) - .assert_success() - .get_output() - .stdout_lossy(); - - // Parse the gas estimate - let gas_estimate = output.trim().parse::().expect("Failed to parse gas estimate"); - - // Gas estimate should be positive and reasonable for contract deployment - assert!(gas_estimate > 50000, "Gas estimate too low for contract deployment"); - assert!(gas_estimate < 5000000, "Gas estimate unreasonably high"); -}); - -// Test edge case: empty constructor arguments -// -forgetest_async!(cast_send_create_empty_constructor, |prj, cmd| { - let (_api, handle) = anvil::spawn(NodeConfig::test()).await; - let endpoint = handle.http_endpoint(); - - // Simple contract with no constructor arguments - prj.add_source( - "SimpleContract", - r#" -contract SimpleContract { - uint256 public constant VALUE = 42; -} -"#, - ) - .unwrap(); - - // Compile - cmd.forge_fuse().args(["build"]).assert_success(); - - // Get bytecode - let bytecode_path = prj.root().join("out/SimpleContract.sol/SimpleContract.json"); - let contract_json = std::fs::read_to_string(bytecode_path).unwrap(); - let contract_data: serde_json::Value = serde_json::from_str(&contract_json).unwrap(); - let bytecode = contract_data["bytecode"]["object"].as_str().unwrap(); - - // Deploy with empty constructor - let output = cmd - .cast_fuse() - .args([ - "send", - "--private-key", - "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", - "--rpc-url", - &endpoint, - "--create", - bytecode, - "constructor()", - ]) - .assert_success() - .get_output() - .stdout_lossy(); - - // Verify deployment succeeded - assert!(output.contains("contractAddress")); -}); - -// Test complex constructor arguments (multiple types) -// -forgetest_async!(cast_send_create_complex_constructor, |prj, cmd| { - let (_api, handle) = anvil::spawn(NodeConfig::test()).await; - let endpoint = handle.http_endpoint(); - - // Contract with complex constructor - prj.add_source( - "ComplexContract", - r#" -contract ComplexContract { - address public owner; - uint256[] public values; - bool public active; - - constructor(address _owner, uint256[] memory _values, bool _active) { - owner = _owner; - values = _values; - active = _active; - } - - function getValuesLength() public view returns (uint256) { - return values.length; - } -} -"#, - ) - .unwrap(); - - // Compile - cmd.forge_fuse().args(["build"]).assert_success(); - - // Get bytecode - let bytecode_path = prj.root().join("out/ComplexContract.sol/ComplexContract.json"); - let contract_json = std::fs::read_to_string(bytecode_path).unwrap(); - let contract_data: serde_json::Value = serde_json::from_str(&contract_json).unwrap(); - let bytecode = contract_data["bytecode"]["object"].as_str().unwrap(); - - // Deploy with complex arguments - let output = cmd - .cast_fuse() - .args([ - "send", - "--private-key", - "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", - "--rpc-url", - &endpoint, - "--create", - bytecode, - "constructor(address,uint256[],bool)", - "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", - "[1,2,3,4,5]", - "true", - ]) - .assert_success() - .get_output() - .stdout_lossy(); - - // Extract deployed address - let lines: Vec<&str> = output.lines().collect(); - let mut address = None; - for line in lines { - if line.contains("contractAddress") { - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() >= 2 { - address = Some(parts[1]); - break; - } - } - } - let address = address.expect("Contract address not found in output"); - - // Verify the array length was set correctly - let length_output = cmd - .cast_fuse() - .args(["call", address, "getValuesLength()", "--rpc-url", &endpoint]) - .assert_success() - .get_output() - .stdout_lossy(); - - // Should return 5 (0x5 in hex) - assert!( - length_output - .contains("0x0000000000000000000000000000000000000000000000000000000000000005") - ); -}); - -casttest!(recover_authority, |_prj, cmd| { - let auth = r#"{ - "chainId": "0x1", - "address": "0xb684710e6d5914ad6e64493de2a3c424cc43e970", - "nonce": "0x3dc1", - "yParity": "0x1", - "r": "0x2f15ba55009fcd3682cd0f9c9645dd94e616f9a969ba3f1a5a2d871f9fe0f2b4", - "s": "0x53c332a83312d0b17dd4c16eeb15b1ff5223398b14e0a55c70762e8f3972b7a5" - }"#; - cmd.args(["recover-authority", auth]).assert_success().stdout_eq(str![[r#" -0x17816E9A858b161c3E37016D139cf618056CaCD4 - -"#]]); -}); - -// -// tests `cast code --disassemble` -casttest!(can_disassemble_contract_code, |_prj, cmd| { - let rpc = next_rpc_endpoint(NamedChain::Mainnet); - cmd.args([ - "code", - "--disassemble", - "--rpc-url", - rpc.as_str(), - "0x1F573D6Fb3F13d689FF844B4cE37794d79a7FF1C", - ]) - .assert_success() - .stdout_eq(str![[r#" -00000000: PUSH1 0x60 -00000002: PUSH1 0x40 -00000004: MSTORE -00000005: CALLDATASIZE -00000006: ISZERO -00000007: PUSH2 0x010f -0000000a: JUMPI -0000000b: PUSH4 0xffffffff -00000010: PUSH29 0x0100000000000000000000000000000000000000000000000000000000 -0000002e: PUSH1 0x00 -... -"#]]); -}); - -// tests that cast call properly applies state diff override -// -casttest!(cast_call_can_override_state_diff, |_prj, cmd| { - let rpc = next_rpc_endpoint(NamedChain::Sepolia); - cmd.args([ - "call", - "--rpc-url", - rpc.as_str(), - "--data", - "0x", - "0x1EA77b250eF79e917A5A637D5BB82D0980653F1B", - "--override-state-diff", - "0x1EA77b250eF79e917A5A637D5BB82D0980653F1B:1:1", - ]) - .assert_success() - .stdout_eq(str![[r#" -0x1337 - -"#]]); - cmd.args(["--trace"]).assert_success().stdout_eq(str![[r#" -Traces: - [7281] 0x1EA77b250eF79e917A5A637D5BB82D0980653F1B::fallback() - ├─ [2275] 0xe537cb8a46Bd179c0C36aB7E3Fdecd759C8B80fc::fallback() [delegatecall] - │ └─ ← [Return] 0x1337 - └─ ← [Return] 0x1337 - - -Transaction successfully executed. -[GAS] - -"#]]); -}); + .tx_hash() +} + +mod abi; +mod access_list; +mod address; +mod bal; +#[cfg(feature = "base")] +mod base; +mod bytecode; +mod call; +mod call_trace; +mod chain; +mod conversions; +mod ens; +mod estimate; +mod help; +mod logs; +mod mktx; +#[cfg(feature = "monad")] +mod monad; +mod receipt; +mod rpc; +mod run; +mod run_trace; +mod send; +mod source; +mod storage; +mod transaction; +mod vaddr; +mod wallet_keys; +mod wallet_keystore; +mod wallet_signing; +mod wallet_touch_id; diff --git a/crates/cast/tests/cli/mktx.rs b/crates/cast/tests/cli/mktx.rs new file mode 100644 index 0000000000000..f64220e1f804c --- /dev/null +++ b/crates/cast/tests/cli/mktx.rs @@ -0,0 +1,792 @@ +//! CLI tests for mktx commands. + +use super::*; + +#[casttest] +fn mktx(cmd: _) { + cmd.args([ + "mktx", + "--private-key", + "0x0000000000000000000000000000000000000000000000000000000000000001", + "--chain", + "1", + "--nonce", + "0", + "--value", + "100", + "--gas-limit", + "21000", + "--gas-price", + "10000000000", + "--priority-gas-price", + "1000000000", + "0x0000000000000000000000000000000000000001", + ]).assert_success().stdout_eq(str![[r#" +0x02f86b0180843b9aca008502540be4008252089400000000000000000000000000000000000000016480c001a070d55e79ed3ac9fc8f51e78eb91fd054720d943d66633f2eb1bc960f0126b0eca052eda05a792680de3181e49bab4093541f75b49d1ecbe443077b3660c836016a + +"#]]); +} + +#[casttest] +fn mktx_eip7702_auth_disclosure_declined(cmd: _) { + cmd.args([ + "mktx", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "--auth", + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--chain", + "31337", + "--rpc-url", + "http://127.0.0.1:1", + ]) + .stdin("n\n") + .assert_success() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Warning: This command will send a signed EIP-7702 authorization to the RPC endpoint. The authorization can be submitted on-chain by anyone once its nonce is valid. + +Continue anyway? [y/N] Aborted. + +"#]]); +} + +#[casttest] +fn mktx_ethsign_eip7702_auth_disclosure_declined(cmd: _) { + cmd.args([ + "mktx", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "--auth", + PRESIGNED_EIP7702_AUTH, + "--ethsign", + "--from", + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "--chain", + "31337", + "--nonce", + "0", + "--gas-limit", + "21000", + "--gas-price", + "10000000000", + "--priority-gas-price", + "1000000000", + "--rpc-url", + "http://127.0.0.1:1", + ]) + .stdin("n\n") + .assert_success() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Warning: This command will send a signed EIP-7702 authorization to the RPC endpoint. The authorization can be submitted on-chain by anyone once its nonce is valid. + +Continue anyway? [y/N] Aborted. + +"#]]); +} + +#[casttest] +fn mktx_eip7702_auth_no_disclosure(cmd: _) { + cmd.args([ + "mktx", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "--auth", + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--chain", + "31337", + "--nonce", + "0", + "--gas-limit", + "21000", + "--gas-price", + "10000000000", + "--priority-gas-price", + "1000000000", + "--rpc-url", + "http://127.0.0.1:1", + ]) + .assert_success() + .stdout_eq(str![[r#" +0x[..] + +"#]]) + .stderr_eq(str![""]); +} + +#[casttest] +fn mktx_eip7702_create_requires_recipient(cmd: _) { + cmd.args([ + "mktx", + "--auth", + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--chain", + "31337", + "--nonce", + "0", + "--gas-limit", + "100000", + "--gas-price", + "10000000000", + "--priority-gas-price", + "1000000000", + "--rpc-url", + "http://127.0.0.1:1", + "--create", + "0x6001", + ]) + .assert_failure() + .stderr_eq(str![[r#" +Error: EIP-7702 transactions can't be CREATE transactions and require a destination address + +"#]]); +} + +#[casttest] +async fn mktx_eip7702_auth_disclosure_forced(cmd: _) { + let (_api, handle) = + anvil::spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Prague.into()))).await; + + cmd.args([ + "mktx", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "--auth", + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--force", + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success() + .stdout_eq(str![[r#" +0x[..] + +"#]]) + .stderr_eq(str![""]); +} + +#[casttest] +async fn mktx_sponsor_hash_supports_address_auth(cmd: _) { + let (_api, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + + cmd.args([ + "mktx", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "--auth", + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--force", + "--tempo.print-sponsor-hash", + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success() + .stdout_eq(str![[r#" +0x[..] + +"#]]) + .stderr_eq(str![""]); +} + +#[casttest] +fn mktx_signature(cmd: _) { + cmd.args([ + "mktx", + "--signature", + "0x70d55e79ed3ac9fc8f51e78eb91fd054720d943d66633f2eb1bc960f0126b0ec52eda05a792680de3181e49bab4093541f75b49d1ecbe443077b3660c836016a01", + "--from", + "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf", + "--chain", + "1", + "--nonce", + "0", + "--value", + "100", + "--gas-limit", + "21000", + "--gas-price", + "10000000000", + "--priority-gas-price", + "1000000000", + "0x0000000000000000000000000000000000000001", + ]) + .assert_success() + .stdout_eq(str![[r#" +0x02f86b0180843b9aca008502540be4008252089400000000000000000000000000000000000000016480c001a070d55e79ed3ac9fc8f51e78eb91fd054720d943d66633f2eb1bc960f0126b0eca052eda05a792680de3181e49bab4093541f75b49d1ecbe443077b3660c836016a + +"#]]); +} + +#[casttest] +fn mktx_signature_requires_from(cmd: _) { + cmd.args([ + "mktx", + "--signature", + "0x70d55e79ed3ac9fc8f51e78eb91fd054720d943d66633f2eb1bc960f0126b0ec52eda05a792680de3181e49bab4093541f75b49d1ecbe443077b3660c836016a01", + "0x0000000000000000000000000000000000000001", + ]) + .assert_failure() + .stderr_eq(str![[r#" +error: the following required arguments were not provided: + --from
+ +Usage: cast[..] mktx --from
--signature [SIG] [ARGS]... + +For more information, try '--help'. + +"#]]); +} + +#[casttest] +fn mktx_signature_normalizes_high_s(cmd: _) { + cmd.args([ + "mktx", + "--signature", + "0x70d55e79ed3ac9fc8f51e78eb91fd054720d943d66633f2eb1bc960f0126b0ecad125fa586d97f21ce7e1b6454bf6caa9b392849907cbbf8b857282c08003fd700", + "--from", + "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf", + "--chain", + "1", + "--nonce", + "0", + "--value", + "100", + "--gas-limit", + "21000", + "--gas-price", + "10000000000", + "--priority-gas-price", + "1000000000", + "0x0000000000000000000000000000000000000001", + ]) + .assert_success() + .stdout_eq(str![[r#" +0x02f86b0180843b9aca008502540be4008252089400000000000000000000000000000000000000016480c001a070d55e79ed3ac9fc8f51e78eb91fd054720d943d66633f2eb1bc960f0126b0eca052eda05a792680de3181e49bab4093541f75b49d1ecbe443077b3660c836016a + +"#]]); +} + +#[casttest] +fn mktx_signature_from_mismatch(cmd: _) { + cmd.args([ + "mktx", + "--signature", + "0x70d55e79ed3ac9fc8f51e78eb91fd054720d943d66633f2eb1bc960f0126b0ec52eda05a792680de3181e49bab4093541f75b49d1ecbe443077b3660c836016a01", + "--from", + "0x0000000000000000000000000000000000000001", + "--chain", + "1", + "--nonce", + "0", + "--value", + "100", + "--gas-limit", + "21000", + "--gas-price", + "10000000000", + "--priority-gas-price", + "1000000000", + "0x0000000000000000000000000000000000000001", + ]) + .assert_failure() + .stderr_eq(str![[r#" +Error: The provided signature recovers to 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf, which does not match the specified sender 0x0000000000000000000000000000000000000001 + +"#]]); +} + +// ensure recipient or code is required +#[casttest] +fn mktx_requires_to(cmd: _) { + cmd.args([ + "mktx", + "--private-key", + "0x0000000000000000000000000000000000000000000000000000000000000001", + "--chain", + "1", + ]); + cmd.assert_failure().stderr_eq(str![[r#" +Error: Must specify a recipient address or contract code to deploy + +"#]]); +} + +#[casttest] +fn mktx_signer_from_mismatch(cmd: _) { + cmd.args([ + "mktx", + "--private-key", + "0x0000000000000000000000000000000000000000000000000000000000000001", + "--from", + "0x0000000000000000000000000000000000000001", + "--chain", + "1", + "0x0000000000000000000000000000000000000001", + ]); + cmd.assert_failure().stderr_eq(str![[r#" +Error: The specified sender via CLI/env vars does not match the sender configured via +the hardware wallet's HD Path. +Please use the `--hd-path ` parameter to specify the BIP32 Path which +corresponds to the sender, or let foundry automatically detect it by not specifying any sender address. + +"#]]); +} + +#[casttest] +fn mktx_signer_from_match(cmd: _) { + cmd.args([ + "mktx", + "--private-key", + "0x0000000000000000000000000000000000000000000000000000000000000001", + "--from", + "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf", + "--chain", + "1", + "--nonce", + "0", + "--gas-limit", + "21000", + "--gas-price", + "10000000000", + "--priority-gas-price", + "1000000000", + "0x0000000000000000000000000000000000000001", + ]).assert_success().stdout_eq(str![[r#" +0x02f86b0180843b9aca008502540be4008252089400000000000000000000000000000000000000018080c001a0cce9a61187b5d18a89ecd27ec675e3b3f10d37f165627ef89a15a7fe76395ce8a07537f5bffb358ffbef22cda84b1c92f7211723f9e09ae037e81686805d3e5505 + +"#]]); +} + +#[casttest] +fn mktx_raw_unsigned(cmd: _) { + cmd.args([ + "mktx", + "--from", + "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf", + "--chain", + "1", + "--nonce", + "0", + "--gas-limit", + "21000", + "--gas-price", + "10000000000", + "--priority-gas-price", + "1000000000", + "0x0000000000000000000000000000000000000001", + "--raw-unsigned", + ]) + .assert_success() + .stdout_eq(str![[ + r#"0x02e80180843b9aca008502540be4008252089400000000000000000000000000000000000000018080c0 + +"# + ]]); +} + +#[casttest] +fn mktx_raw_unsigned_curl_skips_unknown_fee_token_symbol_lookup(cmd: _) { + let output = cmd + .args([ + "mktx", + "0x0000000000000000000000000000000000001234", + "--chain", + "tempo", + "--rpc-url", + "https://example.invalid", + "--curl", + "--tempo.fee-token", + "0x20C00000000000000000000014f22CA97301EB73", + "--from", + "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf", + "--nonce", + "0", + "--gas-limit", + "100000", + "--gas-price", + "1000000000", + "--priority-gas-price", + "1000000000", + "--value", + "0", + "--raw-unsigned", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + assert!(output.starts_with("0x"), "expected raw transaction hex, got:\n{output}"); + assert!(!output.contains("eth_call"), "unexpected fee-token symbol lookup curl:\n{output}"); + assert!(!output.contains("0x95d89b41"), "unexpected symbol() calldata:\n{output}"); +} + +#[casttest] +async fn mktx_raw_unsigned_no_from_missing_chain(cmd: _) { + // As chain is not provided, a query is made to the provider to get the chain id, before the + // tx is built. Anvil is configured to use chain id 1 so that the produced tx will + // be the same as in the `mktx_raw_unsigned` test. + let (_, handle) = anvil::spawn(NodeConfig::test().with_chain_id(Some(1u64))).await; + cmd.args([ + "mktx", + "--nonce", + "0", + "--gas-limit", + "21000", + "--gas-price", + "10000000000", + "--priority-gas-price", + "1000000000", + "0x0000000000000000000000000000000000000001", + "--raw-unsigned", + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success() + .stdout_eq(str![[ + r#"0x02e80180843b9aca008502540be4008252089400000000000000000000000000000000000000018080c0 + +"# + ]]); +} + +#[casttest] +async fn mktx_raw_unsigned_no_from_missing_gas_pricing(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + cmd.args([ + "mktx", + "--nonce", + "0", + "0x0000000000000000000000000000000000000001", + "--raw-unsigned", + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success() + .stdout_eq(str![[ + r#"0x02e5827a6980018477359401825dc09400000000000000000000000000000000000000018080c0 + +"# + ]]); +} + +#[casttest] +fn mktx_raw_unsigned_no_from_missing_nonce(cmd: _) { + cmd.args([ + "mktx", + "--chain", + "1", + "--gas-limit", + "21000", + "--gas-price", + "20000000000", + "0x742d35Cc6634C0532925a3b8D6Ac6F67C9c2b7FD", + "--raw-unsigned", + ]) + .assert_failure() + .stderr_eq(str![[ + r#"Error: Missing required parameters for raw unsigned transaction. When --from is not provided, you must specify: --nonce + +"# + ]]); +} + +#[casttest] +async fn mktx_ethsign(cmd: _) { + let (_api, handle) = anvil::spawn(NodeConfig::test()).await; + let rpc = handle.http_endpoint(); + cmd.args([ + "mktx", + "--from", + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "--chain", + "31337", + "--nonce", + "0", + "--gas-limit", + "21000", + "--gas-price", + "10000000000", + "--priority-gas-price", + "1000000000", + "0x0000000000000000000000000000000000000001", + "--ethsign", + "--rpc-url", + rpc.as_str(), + ]) + .assert_success() + .stdout_eq(str![[ + r#" +0x02f86d827a6980843b9aca008502540be4008252089400000000000000000000000000000000000000018080c001a0b8eeb1ded87b085859c510c5692bed231e3ee8b068ccf71142bbf28da0e95987a07813b676a248ae8055f28495021d78dee6695479d339a6ad9d260d9eaf20674c + +"# + ]]); +} + +// tests that `cast mktx --tempo.lane ` resolves the lane against a `tempo.lanes.toml` file at +// the project root, sets the corresponding `nonce_key` on the produced Tempo AA transaction. +#[casttest] +fn mktx_tempo_lane_resolves_nonce_key(prj: _, cmd: _) { + // Write a shared lanes file at the project root. + let lanes_path = prj.root().join("tempo.lanes.toml"); + fs::write(&lanes_path, "deploy = 1\nops = 2\npayments = 42\n").unwrap(); + + let output = cmd + .current_dir(prj.root()) + .args([ + "mktx", + "--tempo.lane", + "payments", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--chain", + "1", + "--nonce", + "0", + "--gas-limit", + "21000", + "--gas-price", + "10000000000", + "--priority-gas-price", + "1000000000", + "--tempo.fee-token", + "PathUSD", + "0x0000000000000000000000000000000000000001", + ]) + .assert_success() + .get_output() + .clone(); + + // The resolved-lane breadcrumb is printed to stderr so it doesn't pollute stdout + // (which carries the raw signed transaction). + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("lane: payments (nonce_key=42, nonce=0)"), + "expected lane breadcrumb on stderr, got: {stderr}", + ); + + // Decode the produced signed Tempo AA transaction and verify it carries the + // resolved 2D nonce key. + let stdout = String::from_utf8_lossy(&output.stdout); + let raw_hex = stdout.trim().trim_start_matches("0x"); + let raw = hex::decode(raw_hex).expect("decode hex output"); + let envelope = TempoTxEnvelope::decode_2718(&mut raw.as_slice()).expect("decode tempo tx"); + assert!(envelope.is_aa(), "expected Tempo AA transaction, got: {envelope:?}"); + assert_eq!(envelope.nonce_key(), Some(U256::from(42_u64))); +} + +#[casttest] +async fn batch_mktx_eip7702_auth_disclosure(cmd: _) { + let args = [ + "batch-mktx", + "--call", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "--auth", + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + ]; + + cmd.args(args) + .args(["--chain", "31337", "--rpc-url", "http://127.0.0.1:1"]) + .stdin("n\n") + .assert_success() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Building batch transaction with 1 call(s)... +Warning: This command will send a signed EIP-7702 authorization to the RPC endpoint. The authorization can be submitted on-chain by anyone once its nonce is valid. + +Continue anyway? [y/N] Aborted. + +"#]]); + + let (_api, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + cmd.cast_fuse() + .args(args) + .args(["--force", "--rpc-url", &handle.http_endpoint()]) + .assert_success() + .stdout_eq(str![[r#" +0x[..] + +"#]]) + .stderr_eq(str![[r#" +Building batch transaction with 1 call(s)... +Paying gas in AlphaUSD (0x20C0000000000000000000000000000000000001) + +"#]]); +} + +#[casttest] +fn batch_mktx_ethsign_eip7702_auth_disclosure_declined(cmd: _) { + cmd.args([ + "batch-mktx", + "--call", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "--auth", + PRESIGNED_EIP7702_AUTH, + "--ethsign", + "--from", + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--chain", + "31337", + "--nonce", + "0", + "--gas-limit", + "21000", + "--gas-price", + "10000000000", + "--priority-gas-price", + "1000000000", + "--rpc-url", + "http://127.0.0.1:1", + ]) + .stdin("n\n") + .assert_success() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Building batch transaction with 1 call(s)... +Warning: This command will send a signed EIP-7702 authorization to the RPC endpoint. The authorization can be submitted on-chain by anyone once its nonce is valid. + +Continue anyway? [y/N] Aborted. + +"#]]); +} + +// Test cast mktx with negative numbers +#[casttest] +fn cast_mktx_negative_numbers(cmd: _) { + let rpc = next_rpc_endpoint(NamedChain::Sepolia); + cmd.args([ + "mktx", + "0x1111111111111111111111111111111111111111", + "settleDebt(int256)", + "-15000", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", // anvil wallet #0 + "--rpc-url", + rpc.as_str(), + "--gas-limit", + "100000", + ]) + .assert_success(); +} + +// Test cast mktx with EIP-4844 blob transaction (legacy format) +#[casttest] +fn cast_mktx_eip4844_blob(prj: _, cmd: _) { + // Create a temporary blob data file + let blob_data = b"dummy blob data for testing"; + let blob_path = prj.root().join("blob_data.bin"); + fs::write(&blob_path, blob_data).unwrap(); + + cmd.args([ + "mktx", + "--private-key", + "0x0000000000000000000000000000000000000000000000000000000000000001", + "--chain", + "1", + "--nonce", + "0", + "--gas-limit", + "100000", + "--gas-price", + "10000000000", + "--priority-gas-price", + "1000000000", + "--blob", + "--eip4844", + "--blob-gas-price", + "1000000", + "--path", + blob_path.to_str().unwrap(), + "0x0000000000000000000000000000000000000001", + ]) + .assert_success(); +} + +// Test cast mktx with EIP-7594 blob transaction (default format) +#[casttest] +fn cast_mktx_eip7594_blob(prj: _, cmd: _) { + // Create a temporary blob data file + let blob_data = b"dummy peerdas blob data for testing"; + let blob_path = prj.root().join("peerdas_blob_data.bin"); + fs::write(&blob_path, blob_data).unwrap(); + + cmd.args([ + "mktx", + "--private-key", + "0x0000000000000000000000000000000000000000000000000000000000000001", + "--chain", + "1", + "--nonce", + "0", + "--gas-limit", + "100000", + "--gas-price", + "10000000000", + "--priority-gas-price", + "1000000000", + "--blob", + "--blob-gas-price", + "1000000", + "--path", + blob_path.to_str().unwrap(), + "0x0000000000000000000000000000000000000001", + ]) + .assert_success(); +} + +#[casttest] +async fn mktx_tempo_access_key_uses_alloy_wallet(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let output = cmd + .args([ + "mktx", + "0x0000000000000000000000000000000000000001", + "--rpc-url", + &handle.http_endpoint(), + "--chain", + "31337", + "--nonce", + "0", + "--gas-limit", + "100000", + "--gas-price", + "20000000000", + "--priority-gas-price", + "1000000000", + "--tempo.fee-token", + "0x20C0000000000000000000000000000000000000", + "--tempo.access-key", + "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", + "--tempo.root-account", + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + ]) + .assert_success() + .get_output() + .clone(); + + let stdout = String::from_utf8_lossy(&output.stdout); + let raw = hex::decode(stdout.trim().trim_start_matches("0x")).expect("decode raw transaction"); + let TempoTxEnvelope::AA(signed) = + TempoTxEnvelope::decode_2718(&mut raw.as_slice()).expect("decode Tempo AA transaction") + else { + panic!("expected a Tempo AA transaction"); + }; + let TempoSignature::Keychain(signature) = signed.signature() else { + panic!("expected an account access-key signature"); + }; + assert_eq!(signature.user_address, address!("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266")); + assert_eq!(signature.version, KeychainVersion::V2); + assert_eq!( + signature.key_id(&signed.tx().signature_hash()).unwrap(), + address!("0x70997970C51812dc3A010C7d01b50e0d17dc79C8") + ); +} diff --git a/crates/cast/tests/cli/monad.rs b/crates/cast/tests/cli/monad.rs new file mode 100644 index 0000000000000..8d423d155d7dd --- /dev/null +++ b/crates/cast/tests/cli/monad.rs @@ -0,0 +1,596 @@ +//! CLI tests for monad commands. + +use super::*; + +const MONAD_RESERVE_BALANCE_ADDRESS: Address = + address!("0x0000000000000000000000000000000000001001"); +const MONAD_STAKING_ADDRESS: Address = address!("0x0000000000000000000000000000000000001000"); +const MONAD_SYSTEM_ADDRESS: Address = address!("0x6f49a8f621353f12378d0046e7d7e4b9b249dc9e"); +const MONAD_TESTNET_CHAIN_ID: u64 = 10_143; +const MONAD_NINE_TESTNET_ACTIVATION_TIMESTAMP: u64 = 1_773_153_000; +const MONAD_DIPPED_INTO_RESERVE_SELECTOR: [u8; 4] = hex!("3a61584e"); +const MONAD_RESERVE_PROBE_ADDRESS: Address = address!("0x0000000000000000000000000000000000002000"); +const MONAD_RESERVE_RETURN_PROBE_CODE: [u8; 25] = + hex!("633a61584e5f5260205f6004601c5f6110015af15060205ff3"); + +fn mon(value: u64) -> U256 { + U256::from(value) * U256::from(1_000_000_000_000_000_000u128) +} + +#[cfg(feature = "monad")] +#[casttest] +async fn monad_call_trace_uses_monad_evm_network(cmd: _) { + let config = NodeConfig::test_monad() + .with_hardfork(Some(foundry_evm::hardfork::MonadHardfork::MonadNine.into())); + let (_api, handle) = anvil::spawn(config).await; + let endpoint = handle.http_endpoint(); + let reserve_balance_address = MONAD_RESERVE_BALANCE_ADDRESS.to_string(); + let input = hex::encode_prefixed(MONAD_DIPPED_INTO_RESERVE_SELECTOR); + let output = cmd + .args([ + "call", + &reserve_balance_address, + "--data", + &input, + "--rpc-url", + &endpoint, + "--trace", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + assert!(output.contains("Traces:"), "{output}"); + assert!(output.contains("ReserveBalance::dippedIntoReserve()"), "{output}"); + assert!(output.contains("[Return] false"), "{output}"); +} + +#[cfg(feature = "monad")] +#[casttest] +async fn monad_call_trace_resolves_effective_hardfork(cmd: _) { + let config = NodeConfig::test_monad() + .with_hardfork(Some(foundry_evm::hardfork::MonadHardfork::MonadEight.into())) + .with_chain_id(Some(MONAD_TESTNET_CHAIN_ID)) + .with_genesis_timestamp(Some(MONAD_NINE_TESTNET_ACTIVATION_TIMESTAMP - 1)); + let (_api, monad_eight_handle) = anvil::spawn(config).await; + let monad_eight_endpoint = monad_eight_handle.http_endpoint(); + let reserve_balance_address = MONAD_RESERVE_BALANCE_ADDRESS.to_string(); + let input = hex::encode_prefixed(MONAD_DIPPED_INTO_RESERVE_SELECTOR); + + let monad_eight = cmd + .args([ + "call", + &reserve_balance_address, + "--data", + &input, + "--rpc-url", + &monad_eight_endpoint, + "--trace", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + assert!(monad_eight.contains(&reserve_balance_address), "{monad_eight}"); + assert!(!monad_eight.contains("ReserveBalance"), "{monad_eight}"); + assert!(monad_eight.contains("[Stop]"), "{monad_eight}"); + assert!(!monad_eight.contains("[Return] false"), "{monad_eight}"); + + let monad_eight_rpc = cmd + .cast_fuse() + .args([ + "call", + &reserve_balance_address, + "--data", + &input, + "--rpc-url", + &monad_eight_endpoint, + "--debug-trace-call", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + assert!(monad_eight_rpc.contains(&reserve_balance_address), "{monad_eight_rpc}"); + assert!(!monad_eight_rpc.contains("ReserveBalance"), "{monad_eight_rpc}"); + + let activation = MONAD_NINE_TESTNET_ACTIVATION_TIMESTAMP.to_string(); + let monad_eight_with_later_timestamp = cmd + .cast_fuse() + .args([ + "call", + &reserve_balance_address, + "--data", + &input, + "--rpc-url", + &monad_eight_endpoint, + "--trace", + "--block.time", + &activation, + ]) + .assert_success() + .get_output() + .stdout_lossy(); + assert!( + monad_eight_with_later_timestamp.contains(&reserve_balance_address), + "{monad_eight_with_later_timestamp}" + ); + assert!( + !monad_eight_with_later_timestamp.contains("ReserveBalance"), + "{monad_eight_with_later_timestamp}" + ); + assert!( + monad_eight_with_later_timestamp.contains("[Stop]"), + "{monad_eight_with_later_timestamp}" + ); + assert!( + !monad_eight_with_later_timestamp.contains("[Return] false"), + "{monad_eight_with_later_timestamp}" + ); + + let config = NodeConfig::test_monad() + .with_hardfork(Some(foundry_evm::hardfork::MonadHardfork::MonadNine.into())) + .with_chain_id(Some(MONAD_TESTNET_CHAIN_ID)) + .with_genesis_timestamp(Some(MONAD_NINE_TESTNET_ACTIVATION_TIMESTAMP)); + let (_origin_api, monad_nine_origin) = anvil::spawn(config).await; + let config = NodeConfig::test_monad() + .with_chain_id(Some(1u64)) + .with_no_storage_caching(true) + .with_eth_rpc_url(Some(monad_nine_origin.http_endpoint())) + .with_fork_block_number(Some(0u64)); + let (_api, monad_nine_handle) = anvil::spawn(config).await; + let monad_nine_endpoint = monad_nine_handle.http_endpoint(); + let monad_nine = cmd + .cast_fuse() + .args([ + "call", + &reserve_balance_address, + "--data", + &input, + "--rpc-url", + &monad_nine_endpoint, + "--trace", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + assert!(monad_nine.contains("ReserveBalance::dippedIntoReserve()"), "{monad_nine}"); + assert!(monad_nine.contains("[Return] false"), "{monad_nine}"); + + let monad_nine_rpc = cmd + .cast_fuse() + .args([ + "call", + &reserve_balance_address, + "--data", + &input, + "--rpc-url", + &monad_nine_endpoint, + "--debug-trace-call", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + assert!(monad_nine_rpc.contains("ReserveBalance::dippedIntoReserve()"), "{monad_nine_rpc}"); + + cmd.cast_fuse().env("FOUNDRY_HARDFORK", "monad:MonadEight"); + let explicit_monad_eight = cmd + .args([ + "call", + &reserve_balance_address, + "--data", + &input, + "--rpc-url", + &monad_nine_endpoint, + "--trace", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + assert!(explicit_monad_eight.contains(&reserve_balance_address), "{explicit_monad_eight}"); + assert!(!explicit_monad_eight.contains("ReserveBalance"), "{explicit_monad_eight}"); + assert!(explicit_monad_eight.contains("[Stop]"), "{explicit_monad_eight}"); + assert!(!explicit_monad_eight.contains("[Return] false"), "{explicit_monad_eight}"); +} + +#[cfg(feature = "monad")] +#[casttest] +async fn monad_call_trace_uses_parent_sender_context(cmd: _) { + let config = NodeConfig::test_monad() + .with_hardfork(Some(foundry_evm::hardfork::MonadHardfork::MonadNine.into())); + let (api, handle) = anvil::spawn(config).await; + let provider = handle.http_provider(); + let sender = provider.get_accounts().await.unwrap()[0]; + api.anvil_set_code(MONAD_RESERVE_PROBE_ADDRESS, MONAD_RESERVE_RETURN_PROBE_CODE.into()) + .await + .unwrap(); + api.anvil_set_balance(sender, mon(13)).await.unwrap(); + + let _ = provider + .send_transaction( + TransactionRequest::default() + .with_from(sender) + .with_to(MONAD_RESERVE_PROBE_ADDRESS) + .with_value(mon(1)) + .with_gas_limit(100_000) + .into(), + ) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + + let endpoint = handle.http_endpoint(); + let probe = MONAD_RESERVE_PROBE_ADDRESS.to_string(); + let sender = sender.to_string(); + let value = mon(3).to_string(); + let output = cmd + .args([ + "call", + &probe, + "--data", + "0x", + "--from", + &sender, + "--value", + &value, + "--gas-limit", + "100000", + "--rpc-url", + &endpoint, + "--trace", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + assert!(output.contains("ReserveBalance::dippedIntoReserve()"), "{output}"); + assert!(output.contains("[Return] true"), "{output}"); +} + +#[cfg(feature = "monad")] +#[casttest] +async fn monad_run_replays_reserve_balance_precompile_tx(cmd: _) { + let config = NodeConfig::test_monad() + .with_hardfork(Some(foundry_evm::hardfork::MonadHardfork::MonadNine.into())); + let (_api, handle) = anvil::spawn(config).await; + let provider = handle.http_provider(); + let from = provider.get_accounts().await.unwrap()[0]; + let tx = TransactionRequest::default() + .with_from(from) + .with_to(MONAD_RESERVE_BALANCE_ADDRESS) + .with_input(MONAD_DIPPED_INTO_RESERVE_SELECTOR); + let receipt = provider.send_transaction(tx.into()).await.unwrap().get_receipt().await.unwrap(); + + assert!(receipt.status()); + + let endpoint = handle.http_endpoint(); + let tx_hash = receipt.transaction_hash.to_string(); + let output = cmd + .args(["run", &tx_hash, "--rpc-url", &endpoint, "--quick"]) + .assert_success() + .get_output() + .stdout_lossy(); + + assert!(output.contains("Transaction successfully executed."), "{output}"); + assert!(output.contains("ReserveBalance::dippedIntoReserve()"), "{output}"); + assert!(output.contains("[Return] false"), "{output}"); +} + +#[cfg(feature = "monad")] +#[casttest] +async fn monad_run_preserves_endpoint_hardfork(cmd: _) { + let origin_config = NodeConfig::test_monad() + .with_hardfork(Some(foundry_evm::hardfork::MonadHardfork::MonadNine.into())) + .with_chain_id(Some(MONAD_TESTNET_CHAIN_ID)) + .with_genesis_timestamp(Some(MONAD_NINE_TESTNET_ACTIVATION_TIMESTAMP - 2)); + let (_origin_api, origin_handle) = anvil::spawn(origin_config).await; + let config = NodeConfig::test_monad() + .with_chain_id(Some(1u64)) + .with_no_storage_caching(true) + .with_eth_rpc_url(Some(origin_handle.http_endpoint())) + .with_fork_block_number(Some(0u64)); + let (api, handle) = anvil::spawn(config).await; + let provider = handle.http_provider(); + let from = provider.get_accounts().await.unwrap()[0]; + + api.evm_set_next_block_timestamp(MONAD_NINE_TESTNET_ACTIVATION_TIMESTAMP - 1).unwrap(); + let pre_activation_receipt = provider + .send_transaction( + TransactionRequest::default() + .with_from(from) + .with_to(MONAD_RESERVE_BALANCE_ADDRESS) + .with_input(MONAD_DIPPED_INTO_RESERVE_SELECTOR) + .into(), + ) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + + api.evm_set_next_block_timestamp(MONAD_NINE_TESTNET_ACTIVATION_TIMESTAMP).unwrap(); + let post_activation_receipt = provider + .send_transaction( + TransactionRequest::default() + .with_from(from) + .with_to(MONAD_RESERVE_BALANCE_ADDRESS) + .with_input(MONAD_DIPPED_INTO_RESERVE_SELECTOR) + .into(), + ) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert!(pre_activation_receipt.status()); + assert!(post_activation_receipt.status()); + + let endpoint = handle.http_endpoint(); + let pre_activation_hash = pre_activation_receipt.transaction_hash.to_string(); + let pre_activation = cmd + .args(["run", &pre_activation_hash, "--rpc-url", &endpoint, "--quick"]) + .assert_success() + .get_output() + .stdout_lossy(); + assert!(pre_activation.contains("ReserveBalance::dippedIntoReserve()"), "{pre_activation}"); + assert!(pre_activation.contains("[Return] false"), "{pre_activation}"); + + let post_activation_hash = post_activation_receipt.transaction_hash.to_string(); + let post_activation = cmd + .cast_fuse() + .args(["run", &post_activation_hash, "--rpc-url", &endpoint, "--quick"]) + .assert_success() + .get_output() + .stdout_lossy(); + assert!(post_activation.contains("ReserveBalance::dippedIntoReserve()"), "{post_activation}"); + assert!(post_activation.contains("[Return] false"), "{post_activation}"); + + let pre_activation_rpc_trace = cmd + .cast_fuse() + .args(["run", &pre_activation_hash, "--rpc-url", &endpoint, "--debug-trace-transaction"]) + .assert_success() + .get_output() + .stdout_lossy(); + assert!( + pre_activation_rpc_trace.contains("ReserveBalance::dippedIntoReserve()"), + "{pre_activation_rpc_trace}" + ); + + let post_activation_rpc_trace = cmd + .cast_fuse() + .args(["run", &post_activation_hash, "--rpc-url", &endpoint, "--debug-trace-transaction"]) + .assert_success() + .get_output() + .stdout_lossy(); + assert!( + post_activation_rpc_trace.contains("ReserveBalance::dippedIntoReserve()"), + "{post_activation_rpc_trace}" + ); +} + +#[cfg(feature = "monad")] +#[casttest] +async fn monad_run_traces_protocol_system_call(cmd: _) { + let config = NodeConfig::test_monad() + .with_hardfork(Some(foundry_evm::hardfork::MonadHardfork::MonadNine.into())); + let (api, handle) = anvil::spawn(config).await; + let provider = handle.http_provider(); + api.anvil_impersonate_account(MONAD_SYSTEM_ADDRESS).await.unwrap(); + api.anvil_set_balance(MONAD_SYSTEM_ADDRESS, mon(1)).await.unwrap(); + + let snapshot_selector = &keccak256("syscallSnapshot()")[..4]; + let receipt = provider + .send_transaction( + TransactionRequest::default() + .with_from(MONAD_SYSTEM_ADDRESS) + .with_to(MONAD_STAKING_ADDRESS) + .with_input(snapshot_selector.to_vec()) + .with_gas_limit(1_000_000) + .into(), + ) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert!(receipt.status()); + + let endpoint = handle.http_endpoint(); + let tx_hash = receipt.transaction_hash; + let tx_hash_string = tx_hash.to_string(); + let original = + cmd.args(["run", &tx_hash_string, "--rpc-url", &endpoint, "--quick"]).assert_failure(); + let original = original.get_output(); + assert!( + original.stderr_lossy().contains("invalid Monad protocol system transaction"), + "{}", + original.stderr_lossy() + ); + assert!( + !original.stdout_lossy().contains("Staking::syscallSnapshot()"), + "{}", + original.stdout_lossy() + ); + + let canonical_endpoint = + foundry_test_utils::rpc::spawn_canonical_monad_system_rpc(endpoint.clone(), tx_hash).await; + let output = cmd + .cast_fuse() + .args(["run", &tx_hash_string, "--rpc-url", &canonical_endpoint, "--quick"]) + .assert_success() + .get_output() + .stdout_lossy(); + + assert!(output.contains("Staking::syscallSnapshot()"), "{output}"); + assert!(output.contains("Transaction successfully executed."), "{output}"); + assert!(!output.contains("0x0000000000000000000000000000000000000000::fallback()"), "{output}"); + + let unrelated_system = address!("deaddeaddeaddeaddeaddeaddeaddeaddead0001"); + api.anvil_impersonate_account(unrelated_system).await.unwrap(); + api.anvil_set_balance(unrelated_system, mon(1)).await.unwrap(); + let unrelated_receipt = provider + .send_transaction( + TransactionRequest::default() + .with_from(unrelated_system) + .with_to(Address::with_last_byte(1)) + .with_gas_limit(100_000) + .into(), + ) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert!(unrelated_receipt.status()); + let unrelated_hash = unrelated_receipt.transaction_hash.to_string(); + + cmd.cast_fuse() + .args(["run", &unrelated_hash, "--rpc-url", &endpoint, "--quick"]) + .assert_failure() + .stderr_eq(str![[r#" +Error: 0x[..] is a system transaction. +Replaying system transactions is currently not supported. + +"#]]); + cmd.cast_fuse() + .args(["run", &unrelated_hash, "--rpc-url", &endpoint, "--quick", "--replay-system-txes"]) + .assert_success(); +} + +#[cfg(feature = "monad")] +#[casttest] +async fn monad_run_replays_current_sender_context(cmd: _) { + let config = NodeConfig::test_monad() + .with_hardfork(Some(foundry_evm::hardfork::MonadHardfork::MonadNine.into())); + let (api, handle) = anvil::spawn(config).await; + let provider = handle.http_provider(); + let sender = provider.get_accounts().await.unwrap()[0]; + api.anvil_set_code(MONAD_RESERVE_PROBE_ADDRESS, MONAD_RESERVE_RETURN_PROBE_CODE.into()) + .await + .unwrap(); + api.anvil_set_balance(sender, mon(12)).await.unwrap(); + api.mine_one().await.unwrap(); + api.anvil_set_auto_mine(false).await.unwrap(); + + let _ = provider + .send_transaction( + TransactionRequest::default() + .with_from(sender) + .with_to(MONAD_RESERVE_PROBE_ADDRESS) + .with_nonce(0) + .with_value(mon(2)) + .with_gas_limit(100_000) + .into(), + ) + .await + .unwrap(); + let second = provider + .send_transaction( + TransactionRequest::default() + .with_from(sender) + .with_to(MONAD_RESERVE_PROBE_ADDRESS) + .with_nonce(1) + .with_value(mon(1)) + .with_gas_limit(100_000) + .into(), + ) + .await + .unwrap(); + api.mine_one().await.unwrap(); + let receipt = second.get_receipt().await.unwrap(); + + let endpoint = handle.http_endpoint(); + let tx_hash = receipt.transaction_hash.to_string(); + let output = cmd + .args(["run", &tx_hash, "--rpc-url", &endpoint]) + .assert_success() + .get_output() + .stdout_lossy(); + + assert!(output.contains("ReserveBalance::dippedIntoReserve()"), "{output}"); + assert!(output.contains("[Return] true"), "{output}"); +} + +// An explicit EVM version selects the concrete Monad spec even when configuration and +// endpoint metadata request MonadEight. Execution and labels must both use the override. +#[casttest] +async fn monad_fork_trace_evm_version_override(prj: _, cmd: _) { + prj.update_config(|config| { + config.networks = foundry_evm_networks::NetworkConfigs::with_monad(); + config.hardfork = Some("monad:MonadEight".parse().unwrap()); + }); + let (_api, handle) = anvil::spawn( + NodeConfig::test_monad() + .with_hardfork(Some("monad:MonadEight".parse().unwrap())) + .with_chain_id(Some(MONAD_TESTNET_CHAIN_ID)), + ) + .await; + let endpoint = handle.http_endpoint(); + let from = handle.dev_accounts().next().unwrap(); + let target = MONAD_RESERVE_BALANCE_ADDRESS.to_string(); + let input = hex::encode_prefixed(MONAD_DIPPED_INTO_RESERVE_SELECTOR); + cmd.args([ + "call", + &target, + "--data", + &input, + "--rpc-url", + &endpoint, + "--trace", + "--from", + &from.to_string(), + "--evm-version", + "cancun", + ]) + .assert_success() + .stdout_eq(str![[r#" +Traces: + [..] ReserveBalance::dippedIntoReserve() + └─ ← [Return] false + + +Transaction successfully executed. +[GAS] + +"#]]) + .stderr_eq(str![""]); + + let receipt = handle + .http_provider() + .send_transaction( + TransactionRequest::default() + .with_from(from) + .with_to(MONAD_RESERVE_BALANCE_ADDRESS) + .with_gas_limit(100_000) + .with_input(MONAD_DIPPED_INTO_RESERVE_SELECTOR) + .into(), + ) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert!(receipt.status()); + cmd.cast_fuse() + .args([ + "run", + &receipt.transaction_hash.to_string(), + "--rpc-url", + &endpoint, + "--quick", + "--evm-version", + "cancun", + ]) + .assert_success() + .stdout_eq(str![[r#" +Traces: + [..] ReserveBalance::dippedIntoReserve() + └─ ← [Return] false + + +Transaction successfully executed. +[GAS] + +"#]]) + .stderr_eq(str![""]); +} diff --git a/crates/cast/tests/cli/read_networks.rs b/crates/cast/tests/cli/read_networks.rs new file mode 100644 index 0000000000000..61f75c8ee2c46 --- /dev/null +++ b/crates/cast/tests/cli/read_networks.rs @@ -0,0 +1,322 @@ +//! Read-path `cast` coverage across public networks. +//! +//! The read commands are the ones every other workflow is built on, and each of them decodes a +//! chain's responses through Foundry's network types. A chain that serves a shape those types do +//! not model breaks the command for that whole chain rather than for one transaction, which is +//! the class of breakage this module is here to catch. +//! +//! Every test is named `flaky_` so the default nextest profile skips it and the nightly `flaky` +//! profile runs it with retries. These endpoints are free, unauthenticated and rate limited, so +//! an outage must not fail a normal CI run. + +use alloy_primitives::{hex, keccak256}; +use foundry_test_utils::TestCommand; + +/// How far behind the tip to start looking. +/// +/// The newest block is not always fully served yet, and a chain that reorgs its tip would leave +/// the transaction unfetchable. +const TIP_CONFIRMATIONS: u64 = 3; + +/// How many blocks back to look for one holding a usable transaction. +/// +/// Chains with sub-second blocks routinely produce empty blocks, and Tempo produces long runs of +/// them, so a single block is not enough to test against. +const BLOCK_SCAN_DEPTH: u64 = 24; + +/// The highest EIP-2718 type byte every chain here is expected to encode. +/// +/// `cast tx --raw` re-encodes the transaction rather than reformatting the response, so unlike +/// the other read commands it needs a consensus encoding for the type. Foundry's envelope covers +/// the standard Ethereum types everywhere, and a few chain-specific ones where the RPC form +/// carries enough to rebuild them. Above this sit the types a chain can serve that Foundry does +/// not model at all, such as Arbitrum's `ArbitrumInternalTx` (`0x6a`). +const MAX_STANDARD_TX_TYPE: u64 = 0x04; + +/// A public endpoint to exercise the read commands against. +struct Network { + name: &'static str, + rpc_url: &'static str, + /// Pinned so that an endpoint quietly repointed at another chain fails instead of passing. + chain_id: u64, +} + +/// Runs the read commands against `network` and cross-checks what they return. +/// +/// Returns without asserting when the endpoint is unreachable, so an endpoint having a bad day +/// reports a skip rather than a failure. Once a response has been served, failing to decode it is +/// a failure: that is the case being guarded. +#[expect(clippy::disallowed_macros, reason = "skips have to be visible in the nightly test log")] +fn assert_read_commands_work(cmd: &mut TestCommand, network: &Network) { + let Some(chain_id) = decimal_output(cmd, &["chain-id", "--rpc-url", network.rpc_url]) else { + eprintln!("skipping {}: endpoint unreachable", network.name); + return; + }; + assert_eq!( + chain_id, network.chain_id, + "{}: endpoint served chain id {chain_id}, expected {}", + network.name, network.chain_id + ); + + let block_number = decimal_output(cmd, &["block-number", "--rpc-url", network.rpc_url]) + .unwrap_or_else(|| panic!("{}: `cast block-number` returned no number", network.name)); + + // Both of these are chain-wide decode canaries: `--full` decodes every transaction body in + // the block, which is where an unmodelled envelope takes out the command for the whole chain. + let scan_from = block_number.saturating_sub(TIP_CONFIRMATIONS); + let header = block(cmd, network, scan_from, false) + .unwrap_or_else(|| panic!("{}: could not decode block {scan_from}", network.name)); + assert_eq!( + hex_field(&header, "number"), + Some(scan_from), + "{}: block {scan_from} reports a different number", + network.name + ); + block(cmd, network, scan_from, true).unwrap_or_else(|| { + panic!("{}: could not decode block {scan_from} with full transactions", network.name) + }); + + for command in [["gas-price"], ["base-fee"]] { + assert!( + decimal_output(cmd, &[command[0], "--rpc-url", network.rpc_url]).is_some(), + "{}: `cast {}` returned no number", + network.name, + command[0] + ); + } + + let found = find_transaction(cmd, network, scan_from).unwrap_or_else(|err| panic!("{err}")); + let Some((tx_block, tx_hash)) = found else { + eprintln!( + "skipping {} transaction checks: no transaction in the {BLOCK_SCAN_DEPTH} blocks \ + before {scan_from}", + network.name + ); + return; + }; + + let tx = json_output(cmd, &["tx", &tx_hash, "--rpc-url", network.rpc_url]) + .unwrap_or_else(|| panic!("{}: could not decode transaction {tx_hash}", network.name)); + assert_eq!( + tx.get("hash").and_then(|hash| hash.as_str()), + Some(tx_hash.as_str()), + "{}: `cast tx` returned a different transaction", + network.name + ); + + assert_raw_encoding(cmd, network, &tx, &tx_hash); + + // The receipt decodes through a separate path from the transaction, so agreeing on the block + // is a real cross-check rather than a restatement. + let receipt = json_output(cmd, &["receipt", &tx_hash, "--rpc-url", network.rpc_url]) + .unwrap_or_else(|| panic!("{}: could not decode the receipt for {tx_hash}", network.name)); + assert_eq!( + hex_field(&receipt, "blockNumber"), + Some(tx_block), + "{}: receipt for {tx_hash} disagrees with the block it was found in", + network.name + ); + + // The sender is present for calls and contract creations alike, so the account reads below + // stay unconditional; `to` is null for a creation and would skip them. + let account = tx + .get("from") + .and_then(|from| from.as_str()) + .unwrap_or_else(|| panic!("{}: transaction {tx_hash} has no sender", network.name)); + + // State reads at the block the transaction landed in, which is also an archive-depth probe: + // an endpoint that ignores the block tag still answers, so this asserts shape, not history. + let at_block = tx_block.to_string(); + for command in ["balance", "nonce"] { + // Not parsed as a `u64`: balances routinely exceed it, and Monad's system account holds + // more than 1e28 wei. + let output = cmd + .cast_fuse() + .args([command, account, "--block", &at_block, "--rpc-url", network.rpc_url]) + .execute(); + let value = String::from_utf8_lossy(&output.stdout); + let value = value.trim(); + assert!( + output.status.success() + && !value.is_empty() + && value.bytes().all(|b| b.is_ascii_digit()), + "{}: `cast {command}` returned no number for {account}: {value:?}", + network.name + ); + } + for args in [ + vec!["code", account, "--block", &at_block, "--rpc-url", network.rpc_url], + vec!["storage", account, "0", "--block", &at_block, "--rpc-url", network.rpc_url], + ] { + let output = cmd.cast_fuse().args(&args).execute(); + assert!( + output.status.success(), + "{}: `cast {}` failed for {account}", + network.name, + args[0] + ); + assert!( + String::from_utf8_lossy(&output.stdout).trim().starts_with("0x"), + "{}: `cast {}` returned no hex for {account}", + network.name, + args[0] + ); + } +} + +/// Asserts `cast tx --raw` either encodes the transaction or reports why it cannot. +/// +/// Alloy panics rather than invent an encoding for a type it does not model, so the type that +/// cannot be encoded has to be reported rather than reached. +fn assert_raw_encoding( + cmd: &mut TestCommand, + network: &Network, + tx: &serde_json::Value, + tx_hash: &str, +) { + let ty = hex_field(tx, "type") + .unwrap_or_else(|| panic!("{}: {tx_hash} reports no transaction type", network.name)); + let output = + cmd.cast_fuse().args(["tx", tx_hash, "--raw", "--rpc-url", network.rpc_url]).execute(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + // Asserted on both outcomes, because the failure being guarded against is a crash rather than + // an exit code. + assert!( + !stderr.contains("panicked"), + "{}: `cast tx --raw` panicked on {tx_hash} (type 0x{ty:x}): {stderr}", + network.name + ); + + if !output.status.success() { + assert!( + ty > MAX_STANDARD_TX_TYPE, + "{}: `cast tx --raw` failed for {tx_hash} (type 0x{ty:x}): {stderr}", + network.name + ); + assert!( + stderr.contains(&format!("Cannot EIP-2718 encode transaction type 0x{ty:x}")), + "{}: `cast tx --raw` on type 0x{ty:x} failed without naming the type: {stderr}", + network.name + ); + return; + } + + // Hashing the encoding checks the bytes rather than just that something hex-shaped was + // printed, since a transaction hash is the keccak of its EIP-2718 encoding. + let raw = hex::decode(stdout.trim()).unwrap_or_else(|err| { + panic!("{}: `cast tx --raw` printed no hex for {tx_hash}: {err}", network.name) + }); + assert_eq!( + keccak256(&raw).to_string(), + tx_hash, + "{}: re-encoding {tx_hash} did not reproduce it", + network.name + ); +} + +/// Fetches a block, optionally with full transaction bodies. +fn block( + cmd: &mut TestCommand, + network: &Network, + number: u64, + full: bool, +) -> Option { + let number = number.to_string(); + let mut args = vec!["block", &number]; + if full { + args.push("--full"); + } + args.extend(["--rpc-url", network.rpc_url]); + json_output(cmd, &args) +} + +/// Walks back from `latest` looking for a block holding a transaction, returning its block number +/// and hash. +/// +/// Reachability is already established by the time this runs, so a block that will not decode is +/// the breakage this module guards and is reported as an error. `Ok(None)` is reserved for blocks +/// that decoded and genuinely held no transactions. +fn find_transaction( + cmd: &mut TestCommand, + network: &Network, + latest: u64, +) -> Result, String> { + for number in (latest.saturating_sub(BLOCK_SCAN_DEPTH)..=latest).rev() { + let Some(block) = block(cmd, network, number, false) else { + return Err(format!("{}: could not decode block {number}", network.name)); + }; + let transactions = block + .get("transactions") + .and_then(|txs| txs.as_array()) + .ok_or_else(|| format!("{}: block {number} has no transaction array", network.name))?; + let Some(first) = transactions.first() else { continue }; + let hash = first.as_str().ok_or_else(|| { + format!("{}: block {number} lists a transaction that is not a hash", network.name) + })?; + return Ok(Some((number, hash.to_string()))); + } + Ok(None) +} + +/// Runs a `cast` subcommand with `--json` and parses its output, returning `None` on any failure. +/// +/// `--json` wraps some results in the shell's envelope, so the payload is unwrapped from `data`. +fn json_output(cmd: &mut TestCommand, args: &[&str]) -> Option { + let output = cmd.cast_fuse().args(args).arg("--json").execute(); + if !output.status.success() { + return None; + } + let mut value: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?; + if let Some(data) = value.get_mut("data") { + value = data.take(); + } + Some(value) +} + +/// Runs a `cast` subcommand that prints a single decimal number small enough to be a `u64`. +fn decimal_output(cmd: &mut TestCommand, args: &[&str]) -> Option { + let output = cmd.cast_fuse().args(args).execute(); + if !output.status.success() { + return None; + } + String::from_utf8_lossy(&output.stdout).trim().parse().ok() +} + +/// Reads a hex-quantity field out of a JSON-RPC response. +fn hex_field(value: &serde_json::Value, field: &str) -> Option { + let raw = value.get(field)?.as_str()?; + u64::from_str_radix(raw.strip_prefix("0x").unwrap_or(raw), 16).ok() +} + +macro_rules! network_read_tests { + ($($test:ident => ($name:literal, $rpc_url:literal, $chain_id:literal),)*) => { + $( + #[casttest] + fn $test(cmd: _) { + assert_read_commands_work( + &mut cmd, + &Network { name: $name, rpc_url: $rpc_url, chain_id: $chain_id }, + ); + } + )* + }; +} + +network_read_tests! { + flaky_read_ethereum => ("ethereum", "https://ethereum-rpc.publicnode.com", 1), + flaky_read_base => ("base", "https://mainnet.base.org", 8453), + flaky_read_polygon => ("polygon", "https://polygon-bor-rpc.publicnode.com", 137), + flaky_read_bsc => ("bsc", "https://bsc-dataseed.bnbchain.org", 56), + + // Every block opens with an internal transaction (`0x6a`) that the strict Ethereum envelope + // cannot decode, so `block --full` is the assertion that matters on the Nitro chains. + flaky_read_arbitrum => ("arbitrum", "https://arb1.arbitrum.io/rpc", 42161), + flaky_read_robinhood => ("robinhood", "https://rpc.mainnet.chain.robinhood.com", 4663), + + flaky_read_monad => ("monad", "https://rpc.monad.xyz", 143), + + // Produces long runs of empty blocks, which is what the block scan is sized for. + flaky_read_tempo => ("tempo", "https://rpc.mpp.tempo.xyz", 4217), +} diff --git a/crates/cast/tests/cli/receipt.rs b/crates/cast/tests/cli/receipt.rs new file mode 100644 index 0000000000000..791cd325e41ef --- /dev/null +++ b/crates/cast/tests/cli/receipt.rs @@ -0,0 +1,106 @@ +//! CLI tests for receipt commands. + +use super::*; + +// tests that revert reason is only present if transaction has reverted. + +#[casttest] +fn receipt_revert_reason(cmd: _) { + let rpc = next_http_archive_rpc_url(); + + // + cmd.args([ + "receipt", + "0x44f2aaa351460c074f2cb1e5a9e28cbc7d83f33e425101d2de14331c7b7ec31e", + "--rpc-url", + rpc.as_str(), + ]) + .assert_success() + .stdout_eq(format!(r#" +blockHash 0x2cfe65be49863676b6dbc04d58176a14f39b123f1e2f4fea0383a2d82c2c50d0 +blockNumber 16239315 +contractAddress {} +cumulativeGasUsed 10743428 +effectiveGasPrice 10539984136 +from 0x199D5ED7F45F4eE35960cF22EAde2076e95B253F +gasUsed 21000 +logs [] +logsBloom 0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +root {} +status 1 (success) +transactionHash 0x44f2aaa351460c074f2cb1e5a9e28cbc7d83f33e425101d2de14331c7b7ec31e +transactionIndex 116 +type 0 +blobGasPrice {} +blobGasUsed {} +to 0x91da5bf3F8Eb72724E6f50Ec6C3D199C6355c59c +"#,"", "", "", "")); + + let rpc = next_http_archive_rpc_url(); + + // + cmd.cast_fuse() + .args([ + "receipt", + "0x0e07d8b53ed3d91314c80e53cf25bcde02084939395845cbb625b029d568135c", + "--rpc-url", + rpc.as_str(), + ]) + .assert_success() + .stdout_eq(format!(r#" +blockHash 0x883f974b17ca7b28cb970798d1c80f4d4bb427473dc6d39b2a7fe24edc02902d +blockNumber 14839405 +contractAddress {} +cumulativeGasUsed 20273649 +effectiveGasPrice 21491736378 +from 0x3cF412d970474804623bb4e3a42dE13F9bCa5436 +gasUsed 24952 +logs [] +logsBloom 0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +root {} +status 0 (failed) +transactionHash 0x0e07d8b53ed3d91314c80e53cf25bcde02084939395845cbb625b029d568135c +transactionIndex 173 +type 2 +blobGasPrice {} +blobGasUsed {} +to 0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45 +revertReason [..]Transaction too old, data: "0x08c379a0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000135472616e73616374696f6e20746f6f206f6c6400000000000000000000000000" +"#,"","","","")); +} + +// tests that the revert reason is loaded using the correct `from` address. +// Flaky: Sepolia RPC may not return the revertReason field depending on provider +// support for debug/trace APIs. +#[casttest] +fn flaky_revert_reason_from(cmd: _) { + let rpc = next_rpc_endpoint(NamedChain::Sepolia); + // https://sepolia.etherscan.io/tx/0x10ee70cf9f5ced5c515e8d53bfab5ea9f5c72cd61b25fba455c8355ee286c4e4 + cmd.args([ + "receipt", + "0x10ee70cf9f5ced5c515e8d53bfab5ea9f5c72cd61b25fba455c8355ee286c4e4", + "--rpc-url", + rpc.as_str(), + ]) + .assert_success() + .stdout_eq(format!(r#" +blockHash 0x32663d7730c9ea8e1de6d99854483e25fcc05bb56c91c0cc82f9f04944fbffc1 +blockNumber 7823353 +contractAddress {} +cumulativeGasUsed 7500797 +effectiveGasPrice 14296851013 +from 0x3583fF95f96b356d716881C871aF7Eb55ea34a93 +gasUsed 25815 +logs [] +logsBloom 0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +root {} +status 0 (failed) +transactionHash 0x10ee70cf9f5ced5c515e8d53bfab5ea9f5c72cd61b25fba455c8355ee286c4e4 +transactionIndex 96 +type 0 +blobGasPrice {} +blobGasUsed {} +to 0x91b5d4111a4C038153b24e31F75ccdC47123595d +... +"#, "", "", "", "")); +} diff --git a/crates/cast/tests/cli/remote_trace.rs b/crates/cast/tests/cli/remote_trace.rs new file mode 100644 index 0000000000000..5c8c04bda4ae5 --- /dev/null +++ b/crates/cast/tests/cli/remote_trace.rs @@ -0,0 +1,645 @@ +//! Tests for pinning remote traces to one canonical block context. + +use alloy_network::{BlockResponse, TransactionBuilder, primitives::HeaderResponse}; +use alloy_primitives::{Address, B256, bytes, hex}; +use alloy_provider::Provider; +use alloy_rpc_types::{BlockNumberOrTag, TransactionRequest}; +use anvil::{NodeConfig, NodeHandle}; +use axum::{Json, Router, routing::post}; +use foundry_test_utils::util::OutputExt; +use serde_json::{Value, json}; +use std::{ + slice, + sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + }, +}; + +#[derive(Clone)] +enum ResponseMutation { + None, + ReceiptBlockHash { + tx_hash: String, + replacement: String, + }, + RefetchedTransactionBlockHash { + tx_hash: String, + replacement: String, + lookups: Arc, + }, + /// Reports a different transaction hash on the second lookup of `tx_hash`. + RefetchedTransactionHash { + tx_hash: String, + replacement: String, + lookups: Arc, + }, + MissingTransactionBlock { + block_hash: String, + }, + MissingTransactionFromFullBlock { + tx_hash: String, + }, + CanonicalBlockHash { + block_number: String, + replacement: String, + }, + RefetchedCanonicalBlockHash { + block_number: String, + replacement: String, + lookups: Arc, + }, + /// Rejects account and storage reads the way a node that pruned the state does. + MissingState, + /// Reports the transaction with an envelope type Foundry cannot execute. + UnknownTransactionType { + tx_hash: String, + }, + /// Answers `debug_trace*` requests the way ZKsync nodes do: a `callTracer` config without + /// `onlyTopCall` is rejected, and call types are reported in camelCase. + ZksyncCallTracer, +} + +async fn spawn_recording_rpc_proxy( + endpoint: String, + mutation: ResponseMutation, +) -> (String, Arc>>) { + let requests = Arc::new(Mutex::new(Vec::new())); + let recorded_requests = Arc::clone(&requests); + let client = reqwest::Client::new(); + let router = Router::new().route( + "/", + post(move |Json(request): Json| { + let client = client.clone(); + let endpoint = endpoint.clone(); + let mutation = mutation.clone(); + let recorded_requests = Arc::clone(&recorded_requests); + async move { + recorded_requests.lock().unwrap().push(request.clone()); + let mut response = client + .post(endpoint) + .json(&request) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + mutate_rpc_response(&request, &mut response, &mutation); + Json(response) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + (format!("http://{address}"), requests) +} + +fn mutate_rpc_response(request: &Value, response: &mut Value, mutation: &ResponseMutation) { + if let Some(requests) = request.as_array() { + let Some(responses) = response.as_array_mut() else { return }; + for response in responses { + let Some(response_id) = response.get("id") else { continue }; + if let Some(request) = + requests.iter().find(|request| request.get("id") == Some(response_id)) + { + mutate_rpc_result(request, response, mutation); + } + } + } else { + mutate_rpc_result(request, response, mutation); + } +} + +fn mutate_rpc_result(request: &Value, response: &mut Value, mutation: &ResponseMutation) { + let Some(method) = request.get("method").and_then(Value::as_str) else { return }; + let requested_target = request.pointer("/params/0").and_then(Value::as_str); + + if matches!(mutation, ResponseMutation::MissingState) + && matches!( + method, + "eth_getAccountInfo" + | "eth_getBalance" + | "eth_getCode" + | "eth_getProof" + | "eth_getStorageAt" + | "eth_getTransactionCount" + ) + { + *response = json!({ + "jsonrpc": "2.0", + "id": request["id"], + "error": { "code": -32000, "message": "missing trie node" }, + }); + return; + } + + if let ResponseMutation::UnknownTransactionType { tx_hash } = mutation + && method == "eth_getTransactionByHash" + && requested_target.is_some_and(|target| target.eq_ignore_ascii_case(tx_hash)) + && let Some(result) = response.get_mut("result").and_then(Value::as_object_mut) + { + result.insert("type".to_string(), json!("0x71")); + return; + } + + if matches!(mutation, ResponseMutation::ZksyncCallTracer) + && let Some(options_index) = match method { + "debug_traceTransaction" => Some(1), + "debug_traceCall" => Some(2), + _ => None, + } + { + let options = &request["params"][options_index]; + if options["tracerConfig"].get("onlyTopCall").is_none() { + *response = json!({ + "jsonrpc": "2.0", + "id": request["id"], + "error": { + "code": -32602, + "message": "Invalid params", + "data": "missing field `onlyTopCall`", + }, + }); + } else if let Some(frame) = response.get_mut("result") { + camel_case_call_types(frame); + } + return; + } + + if let ResponseMutation::RefetchedTransactionHash { tx_hash, replacement, lookups } = mutation + && method == "eth_getTransactionByHash" + && requested_target.is_some_and(|target| target.eq_ignore_ascii_case(tx_hash)) + && lookups.fetch_add(1, Ordering::Relaxed) == 1 + { + response["result"]["hash"] = json!(replacement); + return; + } + + if let ResponseMutation::MissingTransactionBlock { block_hash } = mutation + && method == "eth_getBlockByHash" + && requested_target.is_some_and(|target| target.eq_ignore_ascii_case(block_hash)) + { + response["result"] = Value::Null; + return; + } + + if let ResponseMutation::MissingTransactionFromFullBlock { tx_hash } = mutation + && method == "eth_getBlockByNumber" + && request.pointer("/params/1").and_then(Value::as_bool) == Some(true) + && let Some(transactions) = + response.pointer_mut("/result/transactions").and_then(Value::as_array_mut) + { + transactions.retain(|transaction| { + transaction + .get("hash") + .and_then(Value::as_str) + .is_none_or(|hash| !hash.eq_ignore_ascii_case(tx_hash)) + }); + return; + } + + let replacement = match mutation { + ResponseMutation::None => return, + ResponseMutation::ReceiptBlockHash { tx_hash, replacement } + if method == "eth_getTransactionReceipt" + && requested_target.is_some_and(|target| target.eq_ignore_ascii_case(tx_hash)) => + { + replacement + } + ResponseMutation::RefetchedTransactionBlockHash { tx_hash, replacement, lookups } + if method == "eth_getTransactionByHash" + && requested_target.is_some_and(|target| target.eq_ignore_ascii_case(tx_hash)) + && lookups.fetch_add(1, Ordering::Relaxed) == 1 => + { + replacement + } + ResponseMutation::CanonicalBlockHash { block_number, replacement } + if method == "eth_getBlockByNumber" + && requested_target + .is_some_and(|target| target.eq_ignore_ascii_case(block_number)) + && response + .pointer("/result/number") + .and_then(Value::as_str) + .is_some_and(|number| number.eq_ignore_ascii_case(block_number)) => + { + replacement + } + ResponseMutation::RefetchedCanonicalBlockHash { block_number, replacement, lookups } + if method == "eth_getBlockByNumber" + && requested_target + .is_some_and(|target| target.eq_ignore_ascii_case(block_number)) + && response + .pointer("/result/number") + .and_then(Value::as_str) + .is_some_and(|number| number.eq_ignore_ascii_case(block_number)) + && lookups.fetch_add(1, Ordering::Relaxed) == 1 => + { + replacement + } + _ => return, + }; + + if let Some(result) = response.get_mut("result").and_then(Value::as_object_mut) { + let field = if method == "eth_getBlockByNumber" { "hash" } else { "blockHash" }; + result.insert(field.to_string(), json!(replacement)); + } +} + +/// Rewrites the call types of a `callTracer` frame tree to ZKsync's spelling. +fn camel_case_call_types(frame: &mut Value) { + if let Some(typ) = frame.get_mut("type") { + match typ.as_str() { + Some("CALL") => *typ = json!("call"), + Some("DELEGATECALL") => *typ = json!("delegateCall"), + _ => {} + } + } + if let Some(calls) = frame.get_mut("calls").and_then(Value::as_array_mut) { + calls.iter_mut().for_each(camel_case_call_types); + } +} + +fn flatten_requests(requests: &[Value]) -> impl Iterator { + requests.iter().flat_map(|request| match request.as_array() { + Some(requests) => requests.as_slice(), + None => slice::from_ref(request), + }) +} + +fn assert_block_hash_param(param: &Value, expected: B256) { + let actual = param.as_str().or_else(|| param.get("blockHash").and_then(Value::as_str)); + let expected = expected.to_string(); + assert_eq!(actual, Some(expected.as_str()), "unexpected block parameter: {param}"); +} + +async fn send_identity_transaction(handle: &NodeHandle) -> (B256, u64, B256) { + let provider = handle.http_provider(); + let from = provider.get_accounts().await.unwrap()[0]; + let receipt = provider + .send_transaction( + TransactionRequest::default() + .with_from(from) + .with_to(Address::with_last_byte(4)) + .with_input(hex!("deadbeef")) + .into(), + ) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + (receipt.transaction_hash(), receipt.block_number.unwrap(), receipt.block_hash.unwrap()) +} + +#[casttest] +async fn cast_call_remote_trace_pins_rpc_requests_to_block_hash(prj: _, cmd: _) { + let (api, handle) = anvil::spawn(NodeConfig::test()).await; + api.mine_one().await.unwrap(); + let block = handle + .http_provider() + .get_block_by_number(BlockNumberOrTag::Number(1)) + .await + .unwrap() + .unwrap(); + let block_hash = block.header().hash(); + let (endpoint, requests) = + spawn_recording_rpc_proxy(handle.http_endpoint(), ResponseMutation::None).await; + + cmd.set_current_dir(prj.root()); + cmd.args([ + "call", + "0x0000000000000000000000000000000000000004", + "--data", + "0xdeadbeef", + "--debug-trace-call", + "--block", + "1", + "--with-local-artifacts", + "--rpc-url", + &endpoint, + ]) + .assert_success(); + + let requests = requests.lock().unwrap(); + let trace_request = flatten_requests(&requests) + .find(|request| request["method"] == "debug_traceCall") + .expect("debug_traceCall request was recorded"); + assert_block_hash_param(&trace_request["params"][1], block_hash); + + let code_requests = flatten_requests(&requests) + .filter(|request| request["method"] == "eth_getCode") + .collect::>(); + assert!(!code_requests.is_empty(), "expected local-artifact code lookups"); + for request in code_requests { + assert_block_hash_param(&request["params"][1], block_hash); + } +} + +#[casttest] +async fn cast_call_remote_trace_rejects_canonical_block_mismatch(cmd: _) { + let (api, handle) = anvil::spawn(NodeConfig::test()).await; + api.mine_one().await.unwrap(); + let (endpoint, _) = spawn_recording_rpc_proxy( + handle.http_endpoint(), + ResponseMutation::RefetchedCanonicalBlockHash { + block_number: "0x1".to_string(), + replacement: B256::repeat_byte(0x88).to_string(), + lookups: Arc::new(AtomicUsize::new(0)), + }, + ) + .await; + + let output = cmd + .args([ + "call", + "0x0000000000000000000000000000000000000004", + "--data", + "0xdeadbeef", + "--debug-trace-call", + "--block", + "1", + "--rpc-url", + &endpoint, + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + assert!(output.contains("changed canonicality"), "{output}"); + assert!(output.contains("canonical block lookup reported block"), "{output}"); +} + +#[casttest] +async fn cast_run_remote_trace_pins_artifact_code_to_transaction_block(prj: _, cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + let (tx_hash, _, block_hash) = send_identity_transaction(&handle).await; + let (endpoint, requests) = + spawn_recording_rpc_proxy(handle.http_endpoint(), ResponseMutation::None).await; + + cmd.set_current_dir(prj.root()); + let tx_hash = tx_hash.to_string(); + cmd.args([ + "run", + "--debug-trace-transaction", + &tx_hash, + "--with-local-artifacts", + "--rpc-url", + &endpoint, + ]) + .assert_success(); + + let requests = requests.lock().unwrap(); + let code_requests = flatten_requests(&requests) + .filter(|request| request["method"] == "eth_getCode") + .collect::>(); + assert!(!code_requests.is_empty(), "expected local-artifact code lookups"); + for request in code_requests { + assert_block_hash_param(&request["params"][1], block_hash); + } +} + +#[casttest] +async fn cast_run_remote_trace_rejects_receipt_inclusion_mismatch(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + let (tx_hash, _, _) = send_identity_transaction(&handle).await; + let (endpoint, _) = spawn_recording_rpc_proxy( + handle.http_endpoint(), + ResponseMutation::ReceiptBlockHash { + tx_hash: tx_hash.to_string(), + replacement: B256::repeat_byte(0x99).to_string(), + }, + ) + .await; + + let tx_hash = tx_hash.to_string(); + let output = cmd + .args(["run", "--debug-trace-transaction", &tx_hash, "--rpc-url", &endpoint]) + .assert_failure() + .get_output() + .stderr_lossy(); + assert!(output.contains("transaction receipt reported block"), "{output}"); + assert!(output.contains("changed inclusion"), "{output}"); +} + +#[casttest] +async fn cast_run_remote_trace_rejects_missing_transaction_block(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + let (tx_hash, _, block_hash) = send_identity_transaction(&handle).await; + let (endpoint, _) = spawn_recording_rpc_proxy( + handle.http_endpoint(), + ResponseMutation::MissingTransactionBlock { block_hash: block_hash.to_string() }, + ) + .await; + + let tx_hash = tx_hash.to_string(); + let output = cmd + .args(["run", "--debug-trace-transaction", &tx_hash, "--rpc-url", &endpoint]) + .assert_failure() + .get_output() + .stderr_lossy(); + assert!(output.contains("block fetched by hash no longer reports it as mined"), "{output}"); + assert!(output.contains("retry the command"), "{output}"); +} + +#[casttest] +async fn cast_run_remote_trace_rejects_refetched_transaction_mismatch(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + let (tx_hash, _, _) = send_identity_transaction(&handle).await; + let (endpoint, _) = spawn_recording_rpc_proxy( + handle.http_endpoint(), + ResponseMutation::RefetchedTransactionBlockHash { + tx_hash: tx_hash.to_string(), + replacement: B256::repeat_byte(0xaa).to_string(), + lookups: Arc::new(AtomicUsize::new(0)), + }, + ) + .await; + + let tx_hash = tx_hash.to_string(); + let output = cmd + .args(["run", "--debug-trace-transaction", &tx_hash, "--rpc-url", &endpoint]) + .assert_failure() + .get_output() + .stderr_lossy(); + assert!(output.contains("transaction lookup reported block"), "{output}"); + assert!(output.contains("changed inclusion"), "{output}"); +} + +#[casttest] +async fn cast_run_remote_trace_rejects_canonical_block_mismatch(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + let (tx_hash, block_number, _) = send_identity_transaction(&handle).await; + let (endpoint, _) = spawn_recording_rpc_proxy( + handle.http_endpoint(), + ResponseMutation::CanonicalBlockHash { + block_number: format!("0x{block_number:x}"), + replacement: B256::repeat_byte(0xbb).to_string(), + }, + ) + .await; + + let tx_hash = tx_hash.to_string(); + let output = cmd + .args(["run", "--debug-trace-transaction", &tx_hash, "--rpc-url", &endpoint]) + .assert_failure() + .get_output() + .stderr_lossy(); + assert!(output.contains("canonical block lookup reported block"), "{output}"); + assert!(output.contains("changed inclusion"), "{output}"); +} + +#[casttest] +async fn cast_run_rejects_target_missing_from_replay_block(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + let (tx_hash, _, _) = send_identity_transaction(&handle).await; + let (endpoint, _) = spawn_recording_rpc_proxy( + handle.http_endpoint(), + ResponseMutation::MissingTransactionFromFullBlock { tx_hash: tx_hash.to_string() }, + ) + .await; + + let tx_hash = tx_hash.to_string(); + let output = cmd + .args(["run", &tx_hash, "--rpc-url", &endpoint]) + .assert_failure() + .get_output() + .stderr_lossy(); + assert!( + output.contains(&format!("transaction {tx_hash} is missing from its block")), + "{output}" + ); +} + +#[casttest] +async fn cast_run_hints_archive_endpoint_for_missing_state(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + let (tx_hash, _, _) = send_identity_transaction(&handle).await; + let (endpoint, _) = + spawn_recording_rpc_proxy(handle.http_endpoint(), ResponseMutation::MissingState).await; + + cmd.args(["run", &tx_hash.to_string(), "--rpc-url", &endpoint]) + .assert_failure() + .stderr_eq(str![[r#" +... +Error: the RPC endpoint does not have the historical state for the transaction's block; use an archive endpoint + +Context: +- database error: failed to get account for [..]: server returned an error response: error code -32000: missing trie node + +"#]]); +} + +#[casttest] +async fn cast_run_hints_remote_trace_for_unknown_transaction_type(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + let (tx_hash, _, _) = send_identity_transaction(&handle).await; + let (endpoint, _) = spawn_recording_rpc_proxy( + handle.http_endpoint(), + ResponseMutation::UnknownTransactionType { tx_hash: tx_hash.to_string() }, + ) + .await; + + cmd.args(["run", &tx_hash.to_string(), "--rpc-url", &endpoint]) + .assert_failure() + .stderr_eq(str![[r#" +Error: cannot replay transaction [..] locally; `--debug-trace-transaction` renders the node's own trace instead + +Context: +- cannot convert unknown transaction type 0x71 to TxEnv + +"#]]); +} + +// ZKsync nodes reject a `callTracer` config that omits `onlyTopCall` and report call types in +// camelCase. Both remote trace commands must still render the full tree with its call kinds. +#[casttest] +async fn cast_remote_trace_supports_zksync_call_tracer(cmd: _) { + let (api, handle) = anvil::spawn(NodeConfig::test()).await; + // DELEGATECALL(gas, 0x..bb, 0, 0, 0, 0) POP STOP + api.anvil_set_code( + Address::with_last_byte(0xaa), + bytes!("0x60006000600060007300000000000000000000000000000000000000bb5af45000"), + ) + .await + .unwrap(); + // REVERT(0, 0) + api.anvil_set_code(Address::with_last_byte(0xbb), bytes!("0x60006000fd")).await.unwrap(); + let provider = handle.http_provider(); + let from = provider.get_accounts().await.unwrap()[0]; + let tx_hash = provider + .send_transaction( + TransactionRequest::default() + .with_from(from) + .with_to(Address::with_last_byte(0xaa)) + .into(), + ) + .await + .unwrap() + .get_receipt() + .await + .unwrap() + .transaction_hash + .to_string(); + let (endpoint, _) = + spawn_recording_rpc_proxy(handle.http_endpoint(), ResponseMutation::ZksyncCallTracer).await; + + cmd.args(["run", "--debug-trace-transaction", &tx_hash, "--rpc-url", &endpoint]) + .assert_success() + .stdout_eq(str![[r#" +Traces: + [..] 0x00000000000000000000000000000000000000AA::fallback() + ├─ [..] 0x00000000000000000000000000000000000000bb::fallback() [delegatecall] + │ └─ ← [Revert] execution reverted + └─ ← [Return] + + +Transaction successfully executed. +[GAS] + +"#]]); + + cmd.cast_fuse() + .args([ + "call", + "0x00000000000000000000000000000000000000aa", + "--debug-trace-call", + "--rpc-url", + &endpoint, + ]) + .assert_success() + .stdout_eq(str![[r#" +Traces: + [..] 0x00000000000000000000000000000000000000AA::fallback() + ├─ [..] 0x00000000000000000000000000000000000000bb::fallback() [delegatecall] + │ └─ ← [Revert] execution reverted + └─ ← [Return] + + +Transaction successfully executed. +[GAS] + +"#]]); +} + +#[casttest] +async fn cast_run_remote_trace_rejects_refetched_transaction_hash_mismatch(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + let (tx_hash, _, _) = send_identity_transaction(&handle).await; + let replacement = B256::repeat_byte(0xcc); + let (endpoint, _) = spawn_recording_rpc_proxy( + handle.http_endpoint(), + ResponseMutation::RefetchedTransactionHash { + tx_hash: tx_hash.to_string(), + replacement: replacement.to_string(), + lookups: Arc::new(AtomicUsize::new(0)), + }, + ) + .await; + + cmd.args(["run", "--debug-trace-transaction", &tx_hash.to_string(), "--rpc-url", &endpoint]) + .assert_failure() + .stderr_eq(format!( + "Error: RPC returned transaction {replacement} for requested {tx_hash}\n" + )); +} diff --git a/crates/cast/tests/cli/rpc.rs b/crates/cast/tests/cli/rpc.rs new file mode 100644 index 0000000000000..92b63f56365ac --- /dev/null +++ b/crates/cast/tests/cli/rpc.rs @@ -0,0 +1,167 @@ +//! CLI tests for rpc commands. + +use super::*; + +// test for cast_rpc without arguments +#[casttest] +fn rpc_no_args(cmd: _) { + let eth_rpc_url = next_http_rpc_endpoint(); + + // Call `cast rpc eth_chainId` + cmd.args(["rpc", "--rpc-url", eth_rpc_url.as_str(), "eth_chainId"]).assert_success().stdout_eq( + str![[r#" +"0x1" + +"#]], + ); +} + +// test for cast_rpc without arguments using websocket +#[casttest] +fn ws_rpc_no_args(cmd: _) { + let eth_rpc_url = next_ws_rpc_endpoint(); + + // Call `cast rpc eth_chainId` + cmd.args(["rpc", "--rpc-url", eth_rpc_url.as_str(), "eth_chainId"]).assert_success().stdout_eq( + str![[r#" +"0x1" + +"#]], + ); +} + +// test for cast_rpc with arguments +#[casttest] +fn rpc_with_args(cmd: _) { + let eth_rpc_url = next_http_rpc_endpoint(); + + // Call `cast rpc eth_getBlockByNumber 0x123 false` + cmd.args(["rpc", "--rpc-url", eth_rpc_url.as_str(), "eth_getBlockByNumber", "0x123", "false"]) + .assert_json_stdout(str![[r#" +{"number":"0x123","hash":"0xc5dab4e189004a1312e9db43a40abb2de91ad7dd25e75880bf36016d8e9df524","transactions":[],"logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","receiptsRoot":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421","extraData":"0x476574682f4c5649562f76312e302e302f6c696e75782f676f312e342e32","nonce":"0x29d6547c196e00e0","miner":"0xbb7b8287f3f0a933474a79eae42cbca977791171","difficulty":"0x494433b31","gasLimit":"0x1388","gasUsed":"0x0","uncles":[],"sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347","size":"0x220","transactionsRoot":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421","stateRoot":"0x3fe6bd17aa85376c7d566df97d9f2e536f37f7a87abb3a6f9e2891cf9442f2e4","mixHash":"0x943056aa305aa6d22a3c06110942980342d1f4d4b11c17711961436a0f963ea0","parentHash":"0x7abfd11e862ccde76d6ea8ee20978aac26f4bcb55de1188cc0335be13e817017","timestamp":"0x55ba4564"} + +"#]]); +} + +// test for cast_rpc with arguments +#[casttest] +fn rpc_format_as_json(cmd: _) { + let eth_rpc_url = next_http_rpc_endpoint(); + + // Call `cast rpc eth_getBlockByNumber 0x123 false` + cmd.args(["rpc", "--rpc-url", eth_rpc_url.as_str(), "eth_getBlockByNumber", "0x123", "false", "--json"]) + .assert_json_stdout(str![[r#" +{ + "schema_version": 1, + "success": true, + "data": { + "hash": "0xc5dab4e189004a1312e9db43a40abb2de91ad7dd25e75880bf36016d8e9df524", + "parentHash": "0x7abfd11e862ccde76d6ea8ee20978aac26f4bcb55de1188cc0335be13e817017", + "sha3Uncles": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", + "miner": "0xbb7b8287f3f0a933474a79eae42cbca977791171", + "stateRoot": "0x3fe6bd17aa85376c7d566df97d9f2e536f37f7a87abb3a6f9e2891cf9442f2e4", + "transactionsRoot": "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", + "receiptsRoot": "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "difficulty": "0x494433b31", + "number": "0x123", + "gasLimit": "0x1388", + "gasUsed": "0x0", + "timestamp": "0x55ba4564", + "extraData": "0x476574682f4c5649562f76312e302e302f6c696e75782f676f312e342e32", + "mixHash": "0x943056aa305aa6d22a3c06110942980342d1f4d4b11c17711961436a0f963ea0", + "nonce": "0x29d6547c196e00e0", + "size": "0x220", + "uncles": [], + "transactions": [] + }, + "errors": [], + "warnings": [] +} + +"#]]); +} + +// test for cast_rpc with raw params +#[casttest] +fn rpc_raw_params(cmd: _) { + let eth_rpc_url = next_http_rpc_endpoint(); + + // Call `cast rpc eth_getBlockByNumber --raw '["0x123", false]'` + cmd.args([ + "rpc", + "--rpc-url", + eth_rpc_url.as_str(), + "eth_getBlockByNumber", + "--raw", + r#"["0x123", false]"#, + ]) + .assert_json_stdout(str![[r#" +{"number":"0x123","hash":"0xc5dab4e189004a1312e9db43a40abb2de91ad7dd25e75880bf36016d8e9df524","transactions":[],"logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","receiptsRoot":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421","extraData":"0x476574682f4c5649562f76312e302e302f6c696e75782f676f312e342e32","nonce":"0x29d6547c196e00e0","miner":"0xbb7b8287f3f0a933474a79eae42cbca977791171","difficulty":"0x494433b31","gasLimit":"0x1388","gasUsed":"0x0","uncles":[],"sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347","size":"0x220","transactionsRoot":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421","stateRoot":"0x3fe6bd17aa85376c7d566df97d9f2e536f37f7a87abb3a6f9e2891cf9442f2e4","mixHash":"0x943056aa305aa6d22a3c06110942980342d1f4d4b11c17711961436a0f963ea0","parentHash":"0x7abfd11e862ccde76d6ea8ee20978aac26f4bcb55de1188cc0335be13e817017","timestamp":"0x55ba4564"} + +"#]]); +} + +// test for cast_rpc with direct params +#[casttest] +fn rpc_raw_params_stdin(cmd: _) { + let eth_rpc_url = next_http_rpc_endpoint(); + + // Call `echo "\n[\n\"0x123\",\nfalse\n]\n" | cast rpc eth_getBlockByNumber --raw + cmd.args(["rpc", "--rpc-url", eth_rpc_url.as_str(), "eth_getBlockByNumber", "--raw"]).stdin( + b"\n[\n\"0x123\",\nfalse\n]\n" + ) + .assert_json_stdout(str![[r#" +{"number":"0x123","hash":"0xc5dab4e189004a1312e9db43a40abb2de91ad7dd25e75880bf36016d8e9df524","transactions":[],"logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","receiptsRoot":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421","extraData":"0x476574682f4c5649562f76312e302e302f6c696e75782f676f312e342e32","nonce":"0x29d6547c196e00e0","miner":"0xbb7b8287f3f0a933474a79eae42cbca977791171","difficulty":"0x494433b31","gasLimit":"0x1388","gasUsed":"0x0","uncles":[],"sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347","size":"0x220","transactionsRoot":"0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421","stateRoot":"0x3fe6bd17aa85376c7d566df97d9f2e536f37f7a87abb3a6f9e2891cf9442f2e4","mixHash":"0x943056aa305aa6d22a3c06110942980342d1f4d4b11c17711961436a0f963ea0","parentHash":"0x7abfd11e862ccde76d6ea8ee20978aac26f4bcb55de1188cc0335be13e817017","timestamp":"0x55ba4564"} + +"#]]); +} + +// tests that the --curl flag outputs a valid curl command for cast rpc +#[casttest] +fn curl_rpc(cmd: _) { + let rpc = "https://eth.example.com"; + + let output = cmd + .args(["rpc", "eth_blockNumber", "--rpc-url", rpc, "--curl"]) + .assert_success() + .get_output() + .stdout_lossy(); + + // Verify curl command structure + assert!(output.contains("curl -X POST")); + assert!(output.contains("-H 'Content-Type: application/json'")); + assert!(output.contains("eth_blockNumber")); + assert!(output.contains("jsonrpc")); + assert!(output.contains(rpc)); +} + +// tests that the --jwt-secret flag outputs a valid curl command with Authorization header +#[casttest] +fn curl_rpc_with_jwt(cmd: _) { + let rpc = "https://eth.example.com"; + let jwt_secret = "cabee703106087906e50f3e75a6ddbab60809f980511d1d1548d449d52220795"; + + let output = cmd + .args(["rpc", "eth_blockNumber", "--rpc-url", rpc, "--jwt-secret", jwt_secret, "--curl"]) + .assert_success() + .get_output() + .stdout_lossy(); + + // Verify curl command structure + assert!(output.contains("curl -X POST")); + assert!(output.contains("-H 'Content-Type: application/json'")); + assert!(output.contains("eth_blockNumber")); + assert!(output.contains("jsonrpc")); + assert!(output.contains(rpc)); + + let jwt = output + .split("Authorization: Bearer ") + .nth(1) + .expect("missing Authorization header") + .split('\'') + .next() + .expect("malformed Authorization header"); + let secret = JwtSecret::from_hex(jwt_secret).unwrap(); + secret.validate(jwt).unwrap(); +} diff --git a/crates/cast/tests/cli/run.rs b/crates/cast/tests/cli/run.rs new file mode 100644 index 0000000000000..55a4037b8ccb9 --- /dev/null +++ b/crates/cast/tests/cli/run.rs @@ -0,0 +1,784 @@ +//! CLI tests for run commands. + +use super::*; +use alloy_primitives::bytes; +use alloy_signer::SignerSync; +use foundry_test_utils::rpc::spawn_rpc_proxy_canned_method; + +// +#[casttest] +fn run_succeeds(cmd: _) { + let rpc = next_http_archive_rpc_url(); + cmd.args([ + "run", + "-v", + "0x2d951c5c95d374263ca99ad9c20c9797fc714330a8037429a3aa4c83d456f845", + "--quick", + "--rpc-url", + rpc.as_str(), + ]) + .assert_success() + .stdout_eq(str![[r#" +... +Transaction successfully executed. +[GAS] + +"#]]); +} + +// Regression test: pre-Berlin tx (block 12243999) must use Istanbul gas costs. +// Without correct hardfork resolution, this tx OOGs under Prague/Berlin cold SLOAD costs. +#[casttest] +fn run_pre_berlin_tx_uses_correct_spec(cmd: _) { + let rpc = next_http_archive_rpc_url(); + cmd.args([ + "run", + "-v", + "0xbb4dece05b8d41a2f79475f76daccf7abdd816f6813897cd02ef8509205ebecb", + "--quick", + "--rpc-url", + rpc.as_str(), + ]) + .assert_success() + .stdout_eq(str![[r#" +... +Transaction successfully executed. +[GAS] + +"#]]); +} + +// Tests that `cast --disable-block-gas-limit` commands are working correctly for BSC +// +// Equivalent transaction on Binance Smart Chain Testnet: +// +#[casttest] +#[expect(clippy::disallowed_macros, reason = "skips have to be visible in the test log")] +fn run_replays_transaction_over_block_gas_limit(cmd: _) { + let bsc_testnet_rpc_url = next_rpc_endpoint(NamedChain::BinanceSmartChainTestnet); + + let latest_block_json: serde_json::Value = serde_json::from_str( + &cmd.args(["block", "--rpc-url", bsc_testnet_rpc_url.as_str(), "--json"]) + .assert_success() + .get_output() + .stdout_lossy(), + ) + .expect("Failed to parse latest block"); + + let latest_excessive_gas_limit_tx = + latest_block_json["transactions"].as_array().and_then(|txs| { + txs.iter() + .find(|tx| tx.get("gas").and_then(|gas| gas.as_str()) == Some("0x7fffffffffffffff")) + }); + + match latest_excessive_gas_limit_tx { + Some(tx) => { + let tx_hash = + tx.get("hash").and_then(|h| h.as_str()).expect("Transaction missing hash"); + + // The chain accepted this transaction even though its gas limit exceeds the block + // gas limit, so replay must not re-apply the check. + cmd.cast_fuse() + .args(["run", "-v", tx_hash, "--quick", "--rpc-url", bsc_testnet_rpc_url.as_str()]) + .assert_success() + .stdout_eq(str![[r#" +... +Transaction successfully executed. +[GAS] + +"#]]); + + // `--disable-block-gas-limit` is now implied and must not change the outcome. + cmd.cast_fuse() + .args([ + "run", + "-v", + tx_hash, + "--quick", + "--rpc-url", + bsc_testnet_rpc_url.as_str(), + "--disable-block-gas-limit", + ]) + .assert_success() + .stdout_eq(str![[r#" +... +Transaction successfully executed. +[GAS] + +"#]]); + } + None => { + eprintln!( + "Skipping test: No transaction with gas = 0x7fffffffffffffff found in the latest block." + ); + } + } +} + +// +#[forgetest] +async fn cast_run_uses_chain_rpc_endpoint(prj: _, cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test().with_chain_id(Some(1u64))).await; + let endpoint = handle.http_endpoint(); + let provider = handle.http_provider(); + let sender = handle.dev_wallets().next().unwrap().address(); + let receipt = provider + .send_transaction( + TransactionRequest::default() + .from(sender) + .to(address!("000000000000000000000000000000000000dEaD")) + .value(U256::ONE) + .into(), + ) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + + fs::write( + prj.root().join("foundry.toml"), + r#"[rpc_endpoints] +mainnet = "${CAST_RUN_MAINNET_RPC_URL}" +"#, + ) + .unwrap(); + + cmd.cast_fuse(); + cmd.set_current_dir(prj.root()); + cmd.env("CAST_RUN_MAINNET_RPC_URL", endpoint); + cmd.unset_env("ETH_RPC_URL"); + cmd.args(["run", &receipt.transaction_hash.to_string(), "--chain", "mainnet", "--quick"]) + .assert_success(); +} + +// Without BAL support, `cast run` replays the block even on a node that supports the debug API. +// The prestate tracer must be explicitly opted into via `--prestate-tracer`. +#[forgetest] +async fn cast_run_default_uses_block_replay(prj: _, cmd: _) { + let (api, handle) = anvil::spawn(NodeConfig::test()).await; + let endpoint = handle.http_endpoint(); + let tx_hash = deploy_counter_and_set_number(&prj, &mut cmd, &api, &endpoint).await; + + cmd.cast_fuse() + .args(["run", format!("{tx_hash}").as_str(), "--rpc-url", &endpoint]) + .assert_success() + .stdout_eq(str![[r#" +Traces: + [..] 0x5FbDB2315678afecb367f032d93F642f64180aa3::setNumber(111) + └─ ← [Stop] + + +Transaction successfully executed. +[GAS] + +"#]]) + .stderr_eq(str![[r#" +... +Executing previous transactions from the block. +... + +"#]]); +} + +// tests cast can decode external libraries traces with project cached selectors +#[forgetest_init] +async fn flaky_decode_external_libraries_with_cached_selectors(prj: _, cmd: _) { + let (api, handle) = anvil::spawn(NodeConfig::test()).await; + + prj.add_source( + "ExternalLib", + r#" +import "./CounterInExternalLib.sol"; +library ExternalLib { + function updateCounterInExternalLib(CounterInExternalLib.Info storage counterInfo, uint256 counter) public { + counterInfo.counter = counter + 1; + } +} + "#, + ); + prj.add_source( + "CounterInExternalLib", + r#" +import "./ExternalLib.sol"; +contract CounterInExternalLib { + struct Info { + uint256 counter; + } + Info info; + constructor() { + ExternalLib.updateCounterInExternalLib(info, 100); + } +} + "#, + ); + prj.add_script( + "CounterInExternalLibScript", + r#" +import "forge-std/Script.sol"; +import {CounterInExternalLib} from "../src/CounterInExternalLib.sol"; +contract CounterInExternalLibScript is Script { + function run() public { + vm.startBroadcast(); + new CounterInExternalLib(); + vm.stopBroadcast(); + } +} + "#, + ); + + cmd.args([ + "script", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--rpc-url", + &handle.http_endpoint(), + "--broadcast", + "CounterInExternalLibScript", + ]) + .assert_success(); + + let tx_hash = api + .transaction_by_block_number_and_index(BlockNumberOrTag::Latest, Index::from(1)) + .await + .unwrap() + .unwrap() + .tx_hash(); + + // Build and cache project selectors. + cmd.forge_fuse().args(["build"]).assert_success(); + cmd.forge_fuse().args(["selectors", "cache"]).assert_success(); + // Assert cast with local artifacts can decode external lib signature. + cmd.cast_fuse() + .args(["run", format!("{tx_hash}").as_str(), "--rpc-url", &handle.http_endpoint()]) + .assert_success() + .stdout_eq(str![[r#" +... +Traces: + [..] → new @0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 + ├─ [..] [..]::updateCounterInExternalLib(0, 100) [delegatecall] + │ └─ ← [Stop] + └─ ← [Return] [..] bytes of code + + +Transaction successfully executed. +[GAS] + +"#]]); +} + +// https://github.com/foundry-rs/foundry/issues/9541 +#[forgetest] +async fn flaky_cast_run_impersonated_tx(cmd: _) { + let (_api, handle) = anvil::spawn( + NodeConfig::test() + .with_auto_impersonate(true) + .with_eth_rpc_url(Some("https://sepolia.base.org")), + ) + .await; + + let http_endpoint = handle.http_endpoint(); + + let provider = ProviderBuilder::new().connect_http(http_endpoint.parse().unwrap()); + + // send impersonated tx + let tx = TransactionRequest::default() + .with_from(address!("0x041563c07028Fc89106788185763Fc73028e8511")) + .with_to(address!("0xF38aA5909D89F5d98fCeA857e708F6a6033f6CF8")) + .with_input(bytes!( + "0x60fe47b1000000000000000000000000000000000000000000000000000000000000000c" + )); + + let receipt = provider.send_transaction(tx).await.unwrap().get_receipt().await.unwrap(); + + assert!(receipt.status()); + + // run impersonated tx + cmd.cast_fuse() + .args(["run", &receipt.transaction_hash.to_string(), "--rpc-url", &http_endpoint]) + .assert_success(); +} + +// +// +#[casttest] +fn flaky_osaka_can_run_p256_precompile(cmd: _) { + cmd.args([ + "run", + "0x17b2de59ebd7dfd2452a3638a16737b6b65ae816c1c5571631dc0d80b63c41de", + "--rpc-url", + next_rpc_endpoint(NamedChain::Base).as_str(), + "--quick", + "--evm-version", + "osaka", +]) +.assert_success() +.stdout_eq(str![[r#" +Traces: + [..] 0xc2FF493F28e894742b968A7DB5D3F21F0aD80C6c::execute(0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000a12384c5e52fd646e7bc7f6b3b33a605651f566e000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000170000000000000000000000000000000000000000000000000000000000000000000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda02913000000000000000000000000000000000000000000000000000000000000060f000000000000000000000000000000000000000000000000000000000000060f0000000000000000000000000000000000000000000000000000000000036cd000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000000320000000000000000000000000000000000000000000000000000000000000060f000000000000000000000000000000000000000000000000000000000000060f000000000000000000000000327a25ad5cfe5c4d4339c1a4267d4a83e8c93312000000000000000000000000000000000000000000000000000000000000034000000000000000000000000000000000000000000000000000000000000005a00000000000000000000000000b55b053230e4effb6609de652fca73fd1c2980400000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000221000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006cdd519280ec730727f07aa36550bde31a1d5f3097818f3425c2f083ed33a91f080fa2afac0071f6e1af9a0e9c09b851bf01e68bc8a1c1f89f686c48205762f92500000000000000000000000000000000000000000000000000000000000000244242424242424242424242424242424242424242424242424242424242424242010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000827b226368616c6c656e6765223a224b51704d51446e7841757a726f68522d483878472d5a536b625249702d76515f5f5f4a714259357a655038222c2263726f73734f726967696e223a66616c73652c226f726967696e223a2268747470732f2f6974686163612e78797a222c2274797065223a22776562617574686e2e676574227d0000000000000000000000000000000000000000000000000000000000001bde17b8de18819c9eb86cefc3920ddb5d3d4254de276e3d6e18dd2b399f732b00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000) + ├─ [2241] 0xA12384c5E52fD646E7BC7F6B3b33A605651F566E::fallback(00) [staticcall] + │ └─ ← [Return] 0x0000000000000000000000000b55b053230e4effb6609de652fca73fd1c29804 + ├─ [9750] 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913::balanceOf(0xA12384c5E52fD646E7BC7F6B3b33A605651F566E) [staticcall] + │ ├─ [2553] 0x2Ce6311ddAE708829bc0784C967b7d77D19FD779::balanceOf(0xA12384c5E52fD646E7BC7F6B3b33A605651F566E) [delegatecall] + │ │ └─ ← [Return] 62393 [6.239e4] + │ └─ ← [Return] 62393 [6.239e4] + ├─ [..] 0xc2FF493F28e894742b968A7DB5D3F21F0aD80C6c::[..]() + │ ├─ [..] 0xA12384c5E52fD646E7BC7F6B3b33A605651F566E::unwrapAndValidateSignature(0x290a4c4039f102eceba2147e1fcc46f994a46d1229faf43ffff26a058e7378ff, 0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006cdd519280ec730727f07aa36550bde31a1d5f3097818f3425c2f083ed33a91f080fa2afac0071f6e1af9a0e9c09b851bf01e68bc8a1c1f89f686c48205762f92500000000000000000000000000000000000000000000000000000000000000244242424242424242424242424242424242424242424242424242424242424242010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000827b226368616c6c656e6765223a224b51704d51446e7841757a726f68522d483878472d5a536b625249702d76515f5f5f4a714259357a655038222c2263726f73734f726967696e223a66616c73652c226f726967696e223a2268747470732f2f6974686163612e78797a222c2274797065223a22776562617574686e2e676574227d0000000000000000000000000000000000000000000000000000000000001bde17b8de18819c9eb86cefc3920ddb5d3d4254de276e3d6e18dd2b399f732b00) [staticcall] + │ │ ├─ [..] 0x0B55b053230E4EFFb6609de652fCa73Fd1C29804::unwrapAndValidateSignature(0x290a4c4039f102eceba2147e1fcc46f994a46d1229faf43ffff26a058e7378ff, 0x000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006cdd519280ec730727f07aa36550bde31a1d5f3097818f3425c2f083ed33a91f080fa2afac0071f6e1af9a0e9c09b851bf01e68bc8a1c1f89f686c48205762f92500000000000000000000000000000000000000000000000000000000000000244242424242424242424242424242424242424242424242424242424242424242010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000827b226368616c6c656e6765223a224b51704d51446e7841757a726f68522d483878472d5a536b625249702d76515f5f5f4a714259357a655038222c2263726f73734f726967696e223a66616c73652c226f726967696e223a2268747470732f2f6974686163612e78797a222c2274797065223a22776562617574686e2e676574227d0000000000000000000000000000000000000000000000000000000000001bde17b8de18819c9eb86cefc3920ddb5d3d4254de276e3d6e18dd2b399f732b00) [delegatecall] + │ │ │ ├─ [2369] 0xc2FF493F28e894742b968A7DB5D3F21F0aD80C6c::pauseFlag() [staticcall] + │ │ │ │ └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000000000 + │ │ │ ├─ [120] PRECOMPILES::sha256(0x7b226368616c6c656e6765223a224b51704d51446e7841757a726f68522d483878472d5a536b625249702d76515f5f5f4a714259357a655038222c2263726f73734f726967696e223a66616c73652c226f726967696e223a2268747470732f2f6974686163612e78797a222c2274797065223a22776562617574686e2e676574227d) [staticcall] + │ │ │ │ └─ ← [Return] 0xc13089327d3c20c0ce35f2f058c423de29977e6950e406c095e366a8fabd463f + │ │ │ ├─ [96] PRECOMPILES::sha256(0x424242424242424242424242424242424242424242424242424242424242424201000000c13089327d3c20c0ce35f2f058c423de29977e6950e406c095e366a8fabd463f) [staticcall] + │ │ │ │ └─ ← [Return] 0xc544bd9a4ea526dda3a008f43c21b6f0be3031b1ff71832b9876915dc91deea0 + │ │ │ ├─ [..] PRECOMPILES::p256Verify(0xc544bd9a4ea526dda3a008f43c21b6f0be3031b1ff71832b9876915dc91deea0, 100105265279889746367868033207795503835004638867404470555471132548343465058056, 7072134396011491412722047857354424388694374548663828422239323654972225288485, 86541895207843662984465061939919839471417016180185784541173973900783506472007, 70542876722349398371292179791476581686494250953390952520841573290937254949513) [staticcall] + │ │ │ │ └─ ← [Return] true + │ │ │ └─ ← [Return] 0x00000000000000000000000000000000000000000000000000000000000000011bde17b8de18819c9eb86cefc3920ddb5d3d4254de276e3d6e18dd2b399f732b + │ │ └─ ← [Return] 0x00000000000000000000000000000000000000000000000000000000000000011bde17b8de18819c9eb86cefc3920ddb5d3d4254de276e3d6e18dd2b399f732b + │ ├─ [..] 0xA12384c5E52fD646E7BC7F6B3b33A605651F566E::checkAndIncrementNonce(23) + │ │ ├─ [..] 0x0B55b053230E4EFFb6609de652fCa73Fd1C29804::checkAndIncrementNonce(23) [delegatecall] + │ │ │ └─ ← [Stop] + │ │ └─ ← [Return] + │ ├─ [3250] 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913::balanceOf(0x327a25aD5Cfe5c4D4339C1A4267D4a83E8c93312) [staticcall] + │ │ ├─ [2553] 0x2Ce6311ddAE708829bc0784C967b7d77D19FD779::balanceOf(0x327a25aD5Cfe5c4D4339C1A4267D4a83E8c93312) [delegatecall] + │ │ │ └─ ← [Return] 38539 [3.853e4] + │ │ └─ ← [Return] 38539 [3.853e4] + │ ├─ [16411] 0xA12384c5E52fD646E7BC7F6B3b33A605651F566E::pay(1551, 0x1bde17b8de18819c9eb86cefc3920ddb5d3d4254de276e3d6e18dd2b399f732b, 0x290a4c4039f102eceba2147e1fcc46f994a46d1229faf43ffff26a058e7378ff, 0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000a12384c5e52fd646e7bc7f6b3b33a605651f566e000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000170000000000000000000000000000000000000000000000000000000000000000000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda02913000000000000000000000000000000000000000000000000000000000000060f000000000000000000000000000000000000000000000000000000000000060f0000000000000000000000000000000000000000000000000000000000036cd000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000000320000000000000000000000000000000000000000000000000000000000000060f000000000000000000000000000000000000000000000000000000000000060f000000000000000000000000327a25ad5cfe5c4d4339c1a4267d4a83e8c93312000000000000000000000000000000000000000000000000000000000000034000000000000000000000000000000000000000000000000000000000000005a00000000000000000000000000b55b053230e4effb6609de652fca73fd1c2980400000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000221000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006cdd519280ec730727f07aa36550bde31a1d5f3097818f3425c2f083ed33a91f080fa2afac0071f6e1af9a0e9c09b851bf01e68bc8a1c1f89f686c48205762f92500000000000000000000000000000000000000000000000000000000000000244242424242424242424242424242424242424242424242424242424242424242010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000827b226368616c6c656e6765223a224b51704d51446e7841757a726f68522d483878472d5a536b625249702d76515f5f5f4a714259357a655038222c2263726f73734f726967696e223a66616c73652c226f726967696e223a2268747470732f2f6974686163612e78797a222c2274797065223a22776562617574686e2e676574227d0000000000000000000000000000000000000000000000000000000000001bde17b8de18819c9eb86cefc3920ddb5d3d4254de276e3d6e18dd2b399f732b000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000) + │ │ ├─ [15711] 0x0B55b053230E4EFFb6609de652fCa73Fd1C29804::pay(1551, 0x1bde17b8de18819c9eb86cefc3920ddb5d3d4254de276e3d6e18dd2b399f732b, 0x290a4c4039f102eceba2147e1fcc46f994a46d1229faf43ffff26a058e7378ff, 0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000a12384c5e52fd646e7bc7f6b3b33a605651f566e000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000170000000000000000000000000000000000000000000000000000000000000000000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda02913000000000000000000000000000000000000000000000000000000000000060f000000000000000000000000000000000000000000000000000000000000060f0000000000000000000000000000000000000000000000000000000000036cd000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000000320000000000000000000000000000000000000000000000000000000000000060f000000000000000000000000000000000000000000000000000000000000060f000000000000000000000000327a25ad5cfe5c4d4339c1a4267d4a83e8c93312000000000000000000000000000000000000000000000000000000000000034000000000000000000000000000000000000000000000000000000000000005a00000000000000000000000000b55b053230e4effb6609de652fca73fd1c2980400000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000221000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000001200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006cdd519280ec730727f07aa36550bde31a1d5f3097818f3425c2f083ed33a91f080fa2afac0071f6e1af9a0e9c09b851bf01e68bc8a1c1f89f686c48205762f92500000000000000000000000000000000000000000000000000000000000000244242424242424242424242424242424242424242424242424242424242424242010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000827b226368616c6c656e6765223a224b51704d51446e7841757a726f68522d483878472d5a536b625249702d76515f5f5f4a714259357a655038222c2263726f73734f726967696e223a66616c73652c226f726967696e223a2268747470732f2f6974686163612e78797a222c2274797065223a22776562617574686e2e676574227d0000000000000000000000000000000000000000000000000000000000001bde17b8de18819c9eb86cefc3920ddb5d3d4254de276e3d6e18dd2b399f732b000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000) [delegatecall] + │ │ │ ├─ [12963] 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913::transfer(0x327a25aD5Cfe5c4D4339C1A4267D4a83E8c93312, 1551) + │ │ │ │ ├─ [12263] 0x2Ce6311ddAE708829bc0784C967b7d77D19FD779::transfer(0x327a25aD5Cfe5c4D4339C1A4267D4a83E8c93312, 1551) [delegatecall] + │ │ │ │ │ ├─ emit Transfer(from: 0xA12384c5E52fD646E7BC7F6B3b33A605651F566E, to: 0x327a25aD5Cfe5c4D4339C1A4267D4a83E8c93312, amount: 1551) + │ │ │ │ │ └─ ← [Return] true + │ │ │ │ └─ ← [Return] true + │ │ │ └─ ← [Stop] + │ │ └─ ← [Return] + │ ├─ [1250] 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913::balanceOf(0x327a25aD5Cfe5c4D4339C1A4267D4a83E8c93312) [staticcall] + │ │ ├─ [553] 0x2Ce6311ddAE708829bc0784C967b7d77D19FD779::balanceOf(0x327a25aD5Cfe5c4D4339C1A4267D4a83E8c93312) [delegatecall] + │ │ │ └─ ← [Return] 40090 [4.009e4] + │ │ └─ ← [Return] 40090 [4.009e4] + │ ├─ [..] 0xc2FF493F28e894742b968A7DB5D3F21F0aD80C6c::[..]() + │ │ ├─ [..] 0xA12384c5E52fD646E7BC7F6B3b33A605651F566E::execute(0x0100000000007821000100000000000000000000000000000000000000000000, 0x0000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000201bde17b8de18819c9eb86cefc3920ddb5d3d4254de276e3d6e18dd2b399f732b) + │ │ │ ├─ [..] 0x0B55b053230E4EFFb6609de652fCa73Fd1C29804::execute(0x0100000000007821000100000000000000000000000000000000000000000000, 0x0000000000000000000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000201bde17b8de18819c9eb86cefc3920ddb5d3d4254de276e3d6e18dd2b399f732b) [delegatecall] + │ │ │ │ ├─ [435] 0xA12384c5E52fD646E7BC7F6B3b33A605651F566E::fallback() + │ │ │ │ │ ├─ [55] 0x0B55b053230E4EFFb6609de652fCa73Fd1C29804::fallback() [delegatecall] + │ │ │ │ │ │ └─ ← [Stop] + │ │ │ │ │ └─ ← [Return] + │ │ │ │ └─ ← [Stop] + │ │ │ └─ ← [Return] + │ │ └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000000000 + │ └─ ← [Stop] + ├─ emit topic 0: 0x31e2fdd22f7eeca688d70008a7bee8e41aa5640885c2bc592419ae8d09d889f1 + │ topic 1: 0x000000000000000000000000a12384c5e52fd646e7bc7f6b3b33a605651f566e + │ topic 2: 0x0000000000000000000000000000000000000000000000000000000000000017 + │ data: 0x00000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000 + └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000000000 + + +Transaction successfully executed. +[GAS] + +"#]]); +} + +// Test cast run Celo transfer with precompiles. +#[casttest] +fn flaky_run_celo_with_precompiles(cmd: _) { + let rpc = next_rpc_endpoint(NamedChain::Celo); + cmd.args([ + "run", + "0xa652b9f41bb1a617ea6b2835b3316e79f0f21b8264e7bcd20e57c4092a70a0f6", + "--quick", + "--rpc-url", + rpc.as_str(), + ]) + .assert_success() + .stdout_eq(str![[r#" +Traces: + [17776] 0x471EcE3750Da237f93B8E339c536989b8978a438::transfer(0xD2eB2d37d238Caeff39CFA36A013299C6DbAC56A, 138000000000000000 [1.38e17]) + ├─ [12370] 0xFeA1B35f1D5f2A58532a70e7A32e6F2D3Bc4F7B1::transfer(0xD2eB2d37d238Caeff39CFA36A013299C6DbAC56A, 138000000000000000 [1.38e17]) [delegatecall] + │ ├─ [9000] CELO_TRANSFER_PRECOMPILE::00000000(00000000000000008106680ba7095cfd8f4351a8b7041da3060afb83000000000000000000000000d2eb2d37d238caeff39cfa36a013299c6dbac56a00000000000000000000000000000000000000000000000001ea4644d3010000) + │ │ └─ ← [Return] + │ ├─ emit Transfer(from: 0x8106680Ba7095CfD8F4351a8B7041da3060Afb83, to: 0xD2eB2d37d238Caeff39CFA36A013299C6DbAC56A, amount: 138000000000000000 [1.38e17]) + │ └─ ← [Return] true + └─ ← [Return] true + + +Transaction successfully executed. +[GAS] + +"#]]); +} + +// Test that `cast run --evm-version` correctly updates gas parameters for historical blocks. +// Mainnet tx 0xb856d9...d05d9647 is a Homestead-era tx (block 1,625,693). +// EXP gas pricing differs between Homestead (10 gas/byte) and Spurious Dragon+ (50 gas/byte). +// Without the fix, `set_spec()` only updated the spec discriminant but not the gas_params table, +// so the executor would use stale (latest) gas pricing even when `--evm-version homestead` is set. +#[casttest] +fn run_evm_version_updates_gas_params(cmd: _) { + let rpc = next_http_archive_rpc_url(); + let tx = "0xb856d9c8dffeaa317d89ed6abba861d007a708c54971da91233abcd2d05d9647"; + + // Run with --evm-version homestead: gas must match on-chain gasUsed (166651). + let homestead_output = cmd + .args(["run", tx, "--quick", "--rpc-url", rpc.as_str(), "--evm-version", "homestead"]) + .assert_success() + .get_output() + .stdout_lossy(); + assert!( + homestead_output.contains("Gas used: 166651"), + "expected Homestead gas (166651), got: {homestead_output}" + ); + + // Run with --evm-version spuriousDragon: higher gas due to EXP repricing (50 vs 10 gas/byte). + let sd_output = cmd + .cast_fuse() + .args(["run", tx, "--quick", "--rpc-url", rpc.as_str(), "--evm-version", "spuriousDragon"]) + .assert_success() + .get_output() + .stdout_lossy(); + assert!( + sd_output.contains("Gas used: 177241"), + "expected Spurious Dragon gas (177241), got: {sd_output}" + ); +} + +// Anvil can use an Elastic chain ID while still executing EVM bytecode. +#[casttest] +async fn cast_run_replays_elastic_chain_id_on_anvil(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test().with_chain_id(Some(324u64))).await; + let provider = handle.http_provider(); + let from = provider.get_accounts().await.unwrap()[0]; + let tx_hash = provider + .send_transaction(TransactionRequest::default().with_from(from).with_to(from).into()) + .await + .unwrap() + .get_receipt() + .await + .unwrap() + .tx_hash() + .to_string(); + + cmd.args(["run", &tx_hash, "--rpc-url", &handle.http_endpoint()]).assert_success().stdout_eq( + str![[r#" +... +Transaction successfully executed. +[GAS] + +"#]], + ); +} + +// Without Anvil metadata, retain the chain-ID-based rejection for Elastic chains. +#[casttest] +async fn cast_run_rejects_elastic_chains(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test().with_chain_id(Some(324u64))).await; + let provider = handle.http_provider(); + let from = provider.get_accounts().await.unwrap()[0]; + let tx_hash = provider + .send_transaction(TransactionRequest::default().with_from(from).with_to(from).into()) + .await + .unwrap() + .get_receipt() + .await + .unwrap() + .tx_hash() + .to_string(); + let endpoint = spawn_rpc_proxy_method_not_found_before( + handle.http_endpoint(), + "anvil_nodeInfo", + usize::MAX, + ) + .await; + + cmd.args(["run", &tx_hash, "--rpc-url", &endpoint]) + .assert_failure() + .stderr_eq(str![[r#" +Error: zksync executes EraVM bytecode, which cannot be replayed locally; `--debug-trace-transaction` renders the node's own trace instead + +"#]]); +} + +// Without Anvil metadata the endpoint identity is discovered once and reused for the environment, +// the fork, and the executor. +#[casttest] +async fn cast_run_discovers_fork_endpoint_once(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + let from = provider.get_accounts().await.unwrap()[0]; + let tx_hash = provider + .send_transaction(TransactionRequest::default().with_from(from).with_to(from).into()) + .await + .unwrap() + .get_receipt() + .await + .unwrap() + .tx_hash() + .to_string(); + let endpoint = spawn_rpc_proxy_method_not_found_before( + handle.http_endpoint(), + "anvil_nodeInfo", + usize::MAX, + ) + .await; + let (endpoint, chain_ids) = spawn_rpc_proxy_recording_method(endpoint, "eth_chainId").await; + let (endpoint, node_infos) = spawn_rpc_proxy_recording_method(endpoint, "anvil_nodeInfo").await; + + for args in [&[][..], &["--debug-trace-transaction"]] { + chain_ids.lock().unwrap().clear(); + node_infos.lock().unwrap().clear(); + + cmd.cast_fuse().args(["run", &tx_hash, "--rpc-url", &endpoint]).args(args).assert_success(); + + assert_eq!(chain_ids.lock().unwrap().len(), 1, "{args:?}"); + assert_eq!(node_infos.lock().unwrap().len(), 1, "{args:?}"); + } +} + +// Tracing replays exact chain history, so it ignores the number-based state opt-in. +#[casttest] +async fn cast_run_keeps_hash_addressed_state(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + let from = provider.get_accounts().await.unwrap()[0]; + let tx_hash = provider + .send_transaction(TransactionRequest::default().with_from(from).with_to(from).into()) + .await + .unwrap() + .get_receipt() + .await + .unwrap() + .transaction_hash() + .to_string(); + let mut endpoint = handle.http_endpoint(); + for method in ["anvil_nodeInfo", "anvil_metadata"] { + endpoint = spawn_rpc_proxy_method_not_found_before(endpoint, method, usize::MAX).await; + } + let mut recorded = Vec::new(); + for method in ["eth_getBalance", "eth_getTransactionCount", "eth_getCode", "eth_getStorageAt"] { + let (next_endpoint, requests) = spawn_rpc_proxy_recording_method(endpoint, method).await; + endpoint = next_endpoint; + recorded.push(requests); + } + + cmd.env("FOUNDRY_FORK_STATE_BY_NUMBER", "true"); + cmd.args(["run", &tx_hash, "--rpc-url", &endpoint]).assert_success(); + + let mut reads = 0; + for requests in recorded { + let requests = requests.lock().unwrap(); + reads += requests.len(); + assert!( + requests.iter().all(|params| params.as_array().unwrap().last().unwrap().is_object()), + "{requests:?}" + ); + } + assert!(reads > 0); +} + +// A replay that does not reproduce the transaction's receipt must say so. The `--evm-version` +// overrides stand in for rules the replay does not model: Shanghai predates the `MCOPY` the first +// transaction executes, and Cancun predates the EIP-7623 calldata floor that prices the second. +#[casttest] +async fn cast_run_warns_on_receipt_mismatch(cmd: _) { + let (api, handle) = anvil::spawn(NodeConfig::test()).await; + let endpoint = handle.http_endpoint(); + // MCOPY(0, 0, 0) STOP + api.anvil_set_code(Address::with_last_byte(0xaa), bytes!("0x6000600060005e00")).await.unwrap(); + let provider = handle.http_provider(); + let from = provider.get_accounts().await.unwrap()[0]; + let mut tx_hashes = Vec::new(); + for (to, input) in [ + (Address::with_last_byte(0xaa), Bytes::new()), + (Address::with_last_byte(0xcc), vec![1u8; 1000].into()), + ] { + let receipt = provider + .send_transaction( + TransactionRequest::default().with_from(from).with_to(to).with_input(input).into(), + ) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + tx_hashes.push(receipt.transaction_hash().to_string()); + } + let [mcopy_tx, floor_tx] = &tx_hashes[..] else { unreachable!() }; + + for tx_hash in [mcopy_tx, floor_tx] { + cmd.cast_fuse() + .args(["run", tx_hash, "--rpc-url", &endpoint, "--evm-version", "prague"]) + .assert_success() + .stderr_eq(str![[r#" +Executing previous transactions from the block. + +"#]]); + } + + cmd.cast_fuse() + .args(["run", mcopy_tx, "--rpc-url", &endpoint, "--evm-version", "shanghai"]) + .assert_success() + .stderr_eq(str![[r#" +Executing previous transactions from the block. +Error: Transaction failed. +Warning: the replay does not match the transaction's receipt: it succeeded on-chain but reverted in the replay. The chain may apply rules the replay does not model; `--debug-trace-transaction` shows the node's own trace if it exposes the `debug` namespace. + +"#]]); + + cmd.cast_fuse() + .args(["run", floor_tx, "--rpc-url", &endpoint, "--evm-version", "cancun"]) + .assert_success() + .stderr_eq(str![[r#" +Executing previous transactions from the block. +Warning: the replay does not match the transaction's receipt: it used 61000 gas on-chain but 37000 in the replay. The chain may apply rules the replay does not model; `--debug-trace-transaction` shows the node's own trace if it exposes the `debug` namespace. + +"#]]); +} + +// Forked state reports an account that does not exist as empty, but replay must not refund the +// EIP-7702 authorization of an authority that did not exist before the transaction. +#[casttest] +async fn cast_run_charges_fresh_eip7702_authority(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + let from = provider.get_accounts().await.unwrap()[0]; + let authority = PrivateKeySigner::random(); + let authorization = Authorization { + chain_id: U256::from(31337u64), + address: address!("0x000000000000000000000000000000000000dEaD"), + nonce: 0, + }; + let signature = authority.sign_hash_sync(&authorization.signature_hash()).unwrap(); + let tx = TransactionRequest { + authorization_list: Some(vec![authorization.into_signed(signature)]), + ..Default::default() + } + .with_from(from) + .with_to(address!("0x0000000000000000000000000000000000001234")) + .with_input(hex!("12345678")); + let receipt = provider.send_transaction(tx.into()).await.unwrap().get_receipt().await.unwrap(); + assert_eq!(receipt.gas_used(), 46_064); + + let output = cmd + .args([ + "run", + &receipt.tx_hash().to_string(), + "--rpc-url", + &handle.http_endpoint(), + "--evm-version", + "prague", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + assert!(output.contains("Gas used: 46064"), "{output}"); +} + +// Prints the ERC-8021 attribution codes appended to the transaction calldata. +#[casttest] +async fn cast_run_prints_erc8021_attribution(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + let from = provider.get_accounts().await.unwrap()[0]; + // ERC-8021 schema 2 test vector with app, wallet and service codes. + let tx = TransactionRequest::default().with_from(from).with_to(from).with_input(hex!( + "a361616762617365617070617765707269767961738269666c617368626f747365746974616e00260280218021802180218021802180218021" + )); + let receipt = provider.send_transaction(tx.into()).await.unwrap().get_receipt().await.unwrap(); + + cmd.args([ + "run", + &receipt.transaction_hash().to_string(), + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success() + .stdout_eq(str![[r#" +... +Transaction successfully executed. +[GAS] +ERC-8021 attribution: baseapp (app), privy (wallet), flashbots (service), titan (service) + +"#]]); +} + +// The transaction returned by the RPC must be the one that was requested. +#[casttest] +async fn cast_run_rejects_mismatched_transaction(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + let from = provider.get_accounts().await.unwrap()[0]; + let mut tx_hashes = Vec::new(); + for to in [Address::with_last_byte(0xaa), Address::with_last_byte(0xbb)] { + let receipt = provider + .send_transaction(TransactionRequest::default().with_from(from).with_to(to).into()) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + tx_hashes.push(receipt.transaction_hash()); + } + let [requested, returned] = tx_hashes[..] else { unreachable!() }; + let returned_tx = provider.get_transaction_by_hash(returned).await.unwrap().unwrap(); + let (endpoint, _) = spawn_rpc_proxy_canned_method( + handle.http_endpoint(), + "eth_getTransactionByHash", + serde_json::to_value(returned_tx).unwrap(), + ) + .await; + + for args in [&[][..], &["--debug-trace-transaction"]] { + cmd.cast_fuse() + .args(["run", &requested.to_string(), "--rpc-url", &endpoint]) + .args(args) + .assert_failure() + .stderr_eq(format!( + "Error: RPC returned transaction {returned} for requested {requested}\n" + )); + } +} + +// A forked Anvil replays upstream transactions with the hardfork of the upstream chain: Cancun +// charges 21000 where EIP-2780 charges 15000. +#[casttest] +async fn cast_run_upstream_tx_through_amsterdam_fork(cmd: _) { + for (hardfork, gas) in + [(EthereumHardfork::Cancun, 21_000), (EthereumHardfork::Amsterdam, 15_000)] + { + let (upstream_api, upstream) = + anvil::spawn(NodeConfig::test().with_hardfork(Some(hardfork.into()))).await; + let mut accounts = upstream.dev_accounts(); + let tx = TransactionRequest::default() + .with_from(accounts.next().unwrap()) + .with_to(accounts.next().unwrap()); + let receipt = upstream + .http_provider() + .send_transaction(tx.into()) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert_eq!(receipt.gas_used, gas); + // Fork after the transaction's block, so that the fork serves it as upstream history. + upstream_api.evm_mine(None).await.unwrap(); + + let (_, fork) = anvil::spawn( + NodeConfig::test() + .with_hardfork(Some(EthereumHardfork::Amsterdam.into())) + .with_eth_rpc_url(Some(upstream.http_endpoint())), + ) + .await; + cmd.cast_fuse() + .args([ + "run", + &receipt.transaction_hash.to_string(), + "--rpc-url", + &fork.http_endpoint(), + ]) + .with_no_redact() + .assert_success() + .stdout_eq(format!("...\nTransaction successfully executed.\nGas used: {gas}\n")); + } +} diff --git a/crates/cast/tests/cli/run_bal.rs b/crates/cast/tests/cli/run_bal.rs new file mode 100644 index 0000000000000..ed51c6543c0bc --- /dev/null +++ b/crates/cast/tests/cli/run_bal.rs @@ -0,0 +1,433 @@ +//! `cast run` block access list (BAL) coverage using locally mined transactions. Anvil serves the +//! BALs of the Amsterdam blocks it mines; earlier hardforks use canned BAL responses instead. + +use alloy_consensus::BlockHeader; +use alloy_eips::{ + BlockId, + eip4788::BEACON_ROOTS_ADDRESS, + eip7928::{ + AccountChanges, BalanceChange, BlockAccessIndex, BlockAccessList, NonceChange, SlotChanges, + StorageChange, compute_block_access_list_hash, + }, +}; +use alloy_hardforks::EthereumHardfork; +use alloy_network::{ + BlockResponse, ReceiptResponse, TransactionBuilder, primitives::HeaderResponse, +}; +use alloy_primitives::{Address, B256, Bytes, U256, bytes, hex}; +use alloy_provider::Provider; +use alloy_rpc_types::{BlockNumberOrTag, TransactionRequest}; +use anvil::{NodeConfig, NodeHandle}; +use foundry_test_utils::{ + TestCommand, + rpc::{ + spawn_rpc_proxy_canned_method, spawn_rpc_proxy_mapping_method, + spawn_rpc_proxy_method_not_found_before, + }, + snapbox::cmd::OutputAssert, + str, +}; +use serde_json::{Value, json}; +use std::{collections::BTreeMap, process::Output, sync::atomic::Ordering}; + +const BAL_METHOD: &str = "eth_getBlockAccessList"; + +/// Three counter transactions in one block, plus the BAL the block would have produced. +struct Fixture { + handle: NodeHandle, + block_number: u64, + transactions: [B256; 3], + gas: [u64; 3], + bal: BlockAccessList, +} + +impl Fixture { + async fn new(hardfork: EthereumHardfork) -> Self { + let (api, handle) = + anvil::spawn(NodeConfig::test().with_hardfork(Some(hardfork.into()))).await; + let provider = handle.http_provider(); + let sender = handle.dev_wallets().next().unwrap().address(); + let mut nonce = provider.get_transaction_count(sender).await.unwrap(); + let target = sender.create(nonce); + + // System calls increment slot zero; ordinary calls return it. Unlike the standard + // contract, this makes accidentally executing the system operation twice observable. + api.anvil_set_code( + BEACON_ROOTS_ADDRESS, + bytes!("3373fffffffffffffffffffffffffffffffffffffffe1460255760005460005260206000f35b60005460010160005500"), + ) + .await + .unwrap(); + // Increment slot zero, then log and return both its value and the beacon counter. + let runtime = hex::decode(format!( + "60005460010180600055600052602060206000600073{BEACON_ROOTS_ADDRESS:x}5afa5060406000a060406000f3" + )) + .unwrap(); + let init = hex::decode(format!( + "600160005560{:02x}601160003960{:02x}6000f3{}", + runtime.len(), + runtime.len(), + hex::encode(&runtime), + )) + .unwrap(); + let receipt = provider + .send_transaction( + TransactionRequest::default() + .from(sender) + .with_deploy_code(Bytes::copy_from_slice(&init)) + .into(), + ) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert!(receipt.status()); + nonce += 1; + api.mine_one().await.unwrap(); + let parent = provider.get_block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + let parent_hash = parent.header().hash(); + let system_value = + provider.get_storage_at(BEACON_ROOTS_ADDRESS, U256::ZERO).await.unwrap() + U256::ONE; + + api.anvil_set_auto_mine(false).await.unwrap(); + let mut transactions = [B256::ZERO; 3]; + for (index, hash) in transactions.iter_mut().enumerate() { + let tx = TransactionRequest::default() + .from(sender) + .to(target) + .nonce(nonce + index as u64) + .gas_limit(1_000_000); + *hash = *provider.send_transaction(tx.into()).await.unwrap().tx_hash(); + } + api.mine_one().await.unwrap(); + let block = provider.get_block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert_eq!(block.transactions().hashes().collect::>(), transactions); + assert_eq!(provider.get_storage_at(target, U256::ZERO).await.unwrap(), U256::from(4)); + + let beneficiary = block.header().beneficiary(); + let mut sender_balance = + provider.get_balance(sender).block_id(BlockId::hash(parent_hash)).await.unwrap(); + let mut beneficiary_balance = + provider.get_balance(beneficiary).block_id(BlockId::hash(parent_hash)).await.unwrap(); + let mut accounts = BTreeMap::from([ + (sender, AccountChanges::new(sender)), + (beneficiary, AccountChanges::new(beneficiary)), + ]); + let mut gas = [0; 3]; + for (index, hash) in transactions.iter().enumerate() { + let receipt = provider.get_transaction_receipt(*hash).await.unwrap().unwrap(); + assert!(receipt.status()); + gas[index] = receipt.gas_used(); + let bal_index = BlockAccessIndex::new(index as u64 + 1); + sender_balance -= + U256::from(receipt.gas_used()) * U256::from(receipt.effective_gas_price()); + let changes = accounts.get_mut(&sender).unwrap(); + changes.balance_changes.push(BalanceChange::new(bal_index, sender_balance)); + changes.nonce_changes.push(NonceChange::new(bal_index, nonce + index as u64 + 1)); + beneficiary_balance += U256::from(receipt.gas_used()) + * U256::from( + receipt.effective_gas_price() + - block.header().base_fee_per_gas().unwrap() as u128, + ); + accounts + .get_mut(&beneficiary) + .unwrap() + .balance_changes + .push(BalanceChange::new(bal_index, beneficiary_balance)); + } + accounts.insert( + target, + AccountChanges { + storage_changes: vec![SlotChanges::new( + U256::ZERO, + (1..=3) + .map(|index| { + StorageChange::new(BlockAccessIndex::new(index), U256::from(index + 1)) + }) + .collect(), + )], + ..AccountChanges::new(target) + }, + ); + accounts.insert( + BEACON_ROOTS_ADDRESS, + AccountChanges { + storage_changes: vec![SlotChanges::new( + U256::ZERO, + vec![StorageChange::new(BlockAccessIndex::new(0), system_value)], + )], + ..AccountChanges::new(BEACON_ROOTS_ADDRESS) + }, + ); + Self { + handle, + block_number: block.header().number(), + transactions, + gas, + bal: accounts.into_values().collect(), + } + } +} + +fn run_command<'a>( + cmd: &'a mut TestCommand, + hash: B256, + endpoint: &str, + flags: &[&str], +) -> &'a mut TestCommand { + let project_dir = cmd.cmd().get_current_dir().unwrap().to_path_buf(); + cmd.cast_fuse().current_dir(project_dir); + cmd.env("FOUNDRY_NO_STORAGE_CACHING", "true"); + cmd.env("FOUNDRY_DISABLE_NIGHTLY_WARNING", "true"); + cmd.env("RUST_LOG", "off"); + cmd.args([ + "run", + &hash.to_string(), + "--rpc-url", + endpoint, + "--disable-external-identification", + "-vvvvv", + ]) + .args(flags) +} + +fn run(cmd: &mut TestCommand, hash: B256, endpoint: &str, flags: &[&str]) -> Output { + run_command(cmd, hash, endpoint, flags).assert_success().get_output().clone() +} + +#[casttest] +async fn cast_run_fork_bal_matches_replay_at_every_position(cmd: _) { + let fixture = Fixture::new(EthereumHardfork::Cancun).await; + let (endpoint, calls) = spawn_rpc_proxy_canned_method( + fixture.handle.http_endpoint(), + BAL_METHOD, + json!(fixture.bal), + ) + .await; + for (index, hash) in fixture.transactions.iter().enumerate() { + let replay = run(&mut cmd, *hash, &fixture.handle.http_endpoint(), &[]); + OutputAssert::new(replay.clone()).stdout_eq(format!( + "Traces:\n...\nTransaction successfully executed.\nGas used: {}\n", + fixture.gas[index], + )); + run_command(&mut cmd, *hash, &endpoint, &[]); + cmd.env("RUST_LOG", "cast::cmd::run=trace"); + // Same trace, return data, logs, storage changes and gas, without the prefix replay. + cmd.with_no_redact().assert_success().stdout_eq(replay.stdout).stderr_eq(str![[r#" +[..] TRACE cast::cmd::run: reading prestate from block access list, skipping block replay +[..] TRACE cast::cmd::run: executing call transaction tx=[..] +[..] TRACE cast::cmd::run: completed execution tx_hash=[..] + +"#]]); + } + assert_eq!(calls.load(Ordering::Relaxed), 3); +} + +#[casttest] +async fn cast_run_fork_bal_respects_no_bal_quick_prestate_and_remote_modes(cmd: _) { + let fixture = Fixture::new(EthereumHardfork::Cancun).await; + let hash = fixture.transactions[2]; + let replay = run(&mut cmd, hash, &fixture.handle.http_endpoint(), &[]); + let (endpoint, calls) = spawn_rpc_proxy_canned_method( + fixture.handle.http_endpoint(), + BAL_METHOD, + json!(fixture.bal), + ) + .await; + for flags in + [&["--no-bal"][..], &["--quick"], &["--prestate-tracer"], &["--debug-trace-transaction"]] + { + let expected = run(&mut cmd, hash, &fixture.handle.http_endpoint(), flags); + let actual = run(&mut cmd, hash, &endpoint, flags); + OutputAssert::new(actual).stdout_eq(expected.stdout).stderr_eq(expected.stderr); + assert_eq!(calls.load(Ordering::Relaxed), 0, "flags: {flags:?}"); + } + + // A failed explicitly requested prestate tracer tries the BAL next, before ordinary replay. + let (endpoint, prestate_calls) = + spawn_rpc_proxy_canned_method(endpoint, "debug_traceTransaction", Value::Null).await; + let output = run(&mut cmd, hash, &endpoint, &["--prestate-tracer"]); + OutputAssert::new(output).stdout_eq(replay.stdout).stderr_eq(""); + assert_eq!(prestate_calls.load(Ordering::Relaxed), 1); + assert_eq!(calls.load(Ordering::Relaxed), 1); +} + +#[casttest] +async fn cast_run_fork_bal_unavailable_falls_back_to_replay(cmd: _) { + let fixture = Fixture::new(EthereumHardfork::Cancun).await; + let hash = fixture.transactions[2]; + let replay = run(&mut cmd, hash, &fixture.handle.http_endpoint(), &[]); + + let (endpoint, calls) = + spawn_rpc_proxy_canned_method(fixture.handle.http_endpoint(), BAL_METHOD, Value::Null) + .await; + let output = run(&mut cmd, hash, &endpoint, &[]); + OutputAssert::new(output) + .stdout_eq(replay.stdout.clone()) + .stderr_eq("Executing previous transactions from the block.\n"); + assert_eq!(calls.load(Ordering::Relaxed), 1); + + let endpoint = spawn_rpc_proxy_method_not_found_before( + fixture.handle.http_endpoint(), + BAL_METHOD, + usize::MAX, + ) + .await; + let output = run(&mut cmd, hash, &endpoint, &[]); + OutputAssert::new(output) + .stdout_eq(replay.stdout) + .stderr_eq("Executing previous transactions from the block.\n"); +} + +#[casttest] +async fn cast_run_fork_bal_is_checked_against_the_header_hash(cmd: _) { + let fixture = Fixture::new(EthereumHardfork::Cancun).await; + let hash = fixture.transactions[2]; + let replay = run(&mut cmd, hash, &fixture.handle.http_endpoint(), &[]); + let block_number = json!(format!("{:#x}", fixture.block_number)); + // Anvil headers do not commit to a BAL yet, so present the block the way an Amsterdam node + // would: once with the hash of the served list and once with a foreign one. + for (header_hash, accepted) in + [(compute_block_access_list_hash(&fixture.bal), true), (B256::ZERO, false)] + { + let block_number = block_number.clone(); + let endpoint = spawn_rpc_proxy_mapping_method( + fixture.handle.http_endpoint(), + "eth_getBlockByNumber", + move |_, mut block| { + if block.get("number") == Some(&block_number) { + block["blockAccessListHash"] = json!(header_hash); + } + block + }, + ) + .await; + let (endpoint, calls) = + spawn_rpc_proxy_canned_method(endpoint, BAL_METHOD, json!(fixture.bal)).await; + let output = run(&mut cmd, hash, &endpoint, &[]); + let output = OutputAssert::new(output).stdout_eq(replay.stdout.clone()); + if accepted { + output.stderr_eq(""); + } else { + output.stderr_eq(str![[r#" +Warning: block access list of block [..] does not match its header, replaying the block instead +Executing previous transactions from the block. + +"#]]); + } + assert_eq!(calls.load(Ordering::Relaxed), 1); + } +} + +#[casttest] +async fn cast_run_fork_bal_uses_anvil_block_access_list(cmd: _) { + // Amsterdam anvil serves the BAL of its own blocks, so no canned response is needed. + let fixture = Fixture::new(EthereumHardfork::Amsterdam).await; + let endpoint = fixture.handle.http_endpoint(); + for hash in fixture.transactions { + let replay = run(&mut cmd, hash, &endpoint, &["--no-bal"]); + OutputAssert::new(replay.clone()) + .stderr_eq("Executing previous transactions from the block.\n"); + run_command(&mut cmd, hash, &endpoint, &[]); + cmd.env("RUST_LOG", "cast::cmd::run=trace"); + cmd.with_no_redact().assert_success().stdout_eq(replay.stdout).stderr_eq(str![[r#" +[..] TRACE cast::cmd::run: reading prestate from block access list, skipping block replay +[..] TRACE cast::cmd::run: executing call transaction tx=[..] +[..] TRACE cast::cmd::run: completed execution tx_hash=[..] + +"#]]); + } +} + +#[casttest] +async fn cast_run_fork_bal_replays_prefix_for_execution_overrides(prj: _, cmd: _) { + let (api, handle) = + anvil::spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Amsterdam.into()))) + .await; + let provider = handle.http_provider(); + let sender = handle.dev_wallets().next().unwrap().address(); + let target = Address::repeat_byte(0x11); + + // Empty input stores CLZ(0), while nonempty input returns slot zero. CLZ is invalid under + // Cancun, so replaying the first transaction there must leave the slot untouched. + api.anvil_set_code(target, bytes!("361560105760005460005260206000f35b60001e60005500")) + .await + .unwrap(); + api.mine_one().await.unwrap(); + api.anvil_set_auto_mine(false).await.unwrap(); + let nonce = provider.get_transaction_count(sender).await.unwrap(); + let mut transactions = Vec::new(); + for (index, input) in [Bytes::new(), Bytes::from_static(&[1])].into_iter().enumerate() { + transactions.push( + *provider + .send_transaction( + TransactionRequest::default() + .from(sender) + .to(target) + .nonce(nonce + index as u64) + // Enough for the EIP-8037 state gas of creating slot zero. + .gas_limit(1_000_000) + .input(input.into()) + .into(), + ) + .await + .unwrap() + .tx_hash(), + ); + } + api.mine_one().await.unwrap(); + let block = provider.get_block_by_number(BlockNumberOrTag::Latest).await.unwrap().unwrap(); + assert_eq!(block.transactions().hashes().collect::>(), transactions); + let hash = transactions[1]; + let bal = provider.get_block_access_list(BlockId::hash(block.header().hash())).await.unwrap(); + assert!(bal.is_some()); + let (endpoint, calls) = + spawn_rpc_proxy_canned_method(handle.http_endpoint(), BAL_METHOD, json!(bal)).await; + + let canonical = run(&mut cmd, hash, &endpoint, &[]); + OutputAssert::new(canonical) + .stdout_eq(str![[r#" +Traces: + [..] 0x1111111111111111111111111111111111111111::fallback(01) + └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000000100 + + +Transaction successfully executed. +Gas used: 17152 + +"#]]) + .stderr_eq(""); + assert_eq!(calls.load(Ordering::Relaxed), 1); + + // Both the CLI override and the configured hardfork must bypass the canonical BAL. + for flags in [&["--evm-version", "cancun"][..], &[]] { + if flags.is_empty() { + prj.update_config(|config| config.hardfork = Some(EthereumHardfork::Cancun.into())); + } + let mut replay_flags = flags.to_vec(); + replay_flags.push("--no-bal"); + let replay = run(&mut cmd, hash, &endpoint, &replay_flags); + OutputAssert::new(replay.clone()) + .stdout_eq(str![[r#" +Traces: + [..] 0x1111111111111111111111111111111111111111::fallback(01) + └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000000000 + + +Transaction successfully executed. +Gas used: 23152 + +"#]]) + // Cancun prices the call above what the Amsterdam chain charged. + .stderr_eq(str![[r#" +Executing previous transactions from the block. +Warning: the replay does not match the transaction's receipt: it used 17152 gas on-chain but 23152 in the replay. The chain may apply rules the replay does not model; `--debug-trace-transaction` shows the node's own trace if it exposes the `debug` namespace. + +"#]]); + OutputAssert::new(run(&mut cmd, hash, &endpoint, flags)) + .stdout_eq(replay.stdout) + .stderr_eq(replay.stderr); + assert_eq!(calls.load(Ordering::Relaxed), 1); + } +} diff --git a/crates/cast/tests/cli/run_networks.rs b/crates/cast/tests/cli/run_networks.rs new file mode 100644 index 0000000000000..1b90428c5c9c3 --- /dev/null +++ b/crates/cast/tests/cli/run_networks.rs @@ -0,0 +1,377 @@ +//! End-to-end `cast run` coverage across public networks. +//! +//! Each test picks a transaction out of a recent block on a public endpoint, replays it, and +//! compares the result against the receipt the chain issued. This is the only coverage that +//! catches whole-chain replay breakage: block bodies that fail to decode, header fields Foundry +//! assumes are present, and validation the chain itself does not apply. +//! +//! Every test here is named `flaky_` so the default nextest profile skips it and the nightly +//! `flaky` profile runs it with retries. These endpoints are free, unauthenticated, and rate +//! limited, so an outage must not fail a normal CI run. + +use foundry_evm_networks::celo::CELO_DYNAMIC_FEE_TX_TYPE; +use foundry_test_utils::{TestCommand, util::OutputExt}; + +/// How closely a replay is expected to reproduce the receipt's `gasUsed`. +#[derive(Clone, Copy, Debug)] +enum GasExpectation { + /// Replay reproduces `gasUsed` exactly. + Exact, + /// Replay reproduces `gasUsed` minus the L1 data availability component the receipt reports + /// in `gasUsedForL1`, which is posting cost rather than EVM execution. + ExactMinusL1Gas, + /// The chain prices execution differently from stock revm, so only assert that the + /// transaction replays at all. Polygon, for example, charges an extra 840 gas for every + /// storage slot a transaction creates. + ReplaysOnly, +} + +/// How many blocks back to look for a transaction to replay. +const BLOCK_SCAN_DEPTH: u64 = 16; + +/// How many transactions before the target one replay is allowed to walk. +const MAX_REPLAYED_PREDECESSORS: usize = 2; + +/// How far behind the tip to start looking. +/// +/// Receipts for the newest block are not always being served yet, and a chain that reorgs its tip +/// would leave the transaction unreplayable. +const TIP_CONFIRMATIONS: u64 = 3; + +/// A public endpoint to exercise `cast run` against. +struct Network { + name: &'static str, + rpc_url: &'static str, + gas: GasExpectation, + transaction_type: Option, +} + +/// Replays a recent transaction from `network` and checks it against its receipt. +/// +/// Returns without asserting when the endpoint is unreachable or the recent blocks hold nothing +/// replayable, so an endpoint having a bad day reports a skip rather than a failure. +#[expect(clippy::disallowed_macros, reason = "skips have to be visible in the nightly test log")] +fn assert_replays_recent_transaction(cmd: &mut TestCommand, network: &Network) { + // Fetch the block twice, by hashes and then in full. The first call is the liveness probe: if + // it fails the endpoint is simply unavailable and there is nothing to test. If it succeeds and + // the second fails, the endpoint is fine and Foundry could not decode the block body, which is + // the failure mode that takes out replay for an entire chain. + let Some(block_number) = latest_block_number(cmd, network) else { + eprintln!("skipping {}: endpoint unreachable", network.name); + return; + }; + + // The latest block must decode; later ones are best effort so a single flaky response does + // not fail the run. + full_block(cmd, network, block_number).unwrap_or_else(|| { + panic!("{}: could not decode block {block_number} with full transactions", network.name) + }); + + let scan_from = block_number.saturating_sub(TIP_CONFIRMATIONS); + let Some(tx_hash) = find_replayable_transaction(cmd, network, scan_from) else { + eprintln!( + "skipping {}: no replayable transaction in the {BLOCK_SCAN_DEPTH} blocks before \ + {scan_from}", + network.name + ); + return; + }; + + let Some(receipt) = json_output(cmd, &["receipt", &tx_hash, "--rpc-url", network.rpc_url]) + else { + eprintln!("skipping {}: could not fetch the receipt for {tx_hash}", network.name); + return; + }; + + let output = cmd + .cast_fuse() + .args(["run", &tx_hash, "--rpc-url", network.rpc_url]) + .assert_success() + .get_output() + .stdout_lossy(); + + let replayed_gas = gas_used(&output).unwrap_or_else(|| { + panic!("{}: `cast run` reported no gas for {tx_hash}:\n{output}", network.name) + }); + + let receipt_gas = hex_field(&receipt, "gasUsed") + .unwrap_or_else(|| panic!("{}: receipt for {tx_hash} has no gasUsed", network.name)); + + let expected = match network.gas { + GasExpectation::Exact => receipt_gas, + GasExpectation::ExactMinusL1Gas => { + receipt_gas - hex_field(&receipt, "gasUsedForL1").unwrap_or_default() + } + GasExpectation::ReplaysOnly => return, + }; + + assert_eq!( + replayed_gas, expected, + "{}: replayed {tx_hash} with {replayed_gas} gas, chain reported {expected}", + network.name + ); +} + +/// Fetches the latest block by transaction hashes, which every endpoint can serve. +fn latest_block_number(cmd: &mut TestCommand, network: &Network) -> Option { + let block = json_output(cmd, &["block", "latest", "--rpc-url", network.rpc_url])?; + hex_field(&block, "number") +} + +/// Fetches a block with full transaction bodies. +fn full_block(cmd: &mut TestCommand, network: &Network, number: u64) -> Option { + json_output(cmd, &["block", &number.to_string(), "--full", "--rpc-url", network.rpc_url]) +} + +/// Walks back from `latest` looking for a transaction worth replaying. +/// +/// Chains with sub-second blocks routinely produce blocks holding nothing but their own system +/// transaction, so a single block is not enough to test against. A block with more than one +/// candidate is preferred, because replaying anything but the first transaction forces its +/// predecessors through the executor, and that is where per-chain replay breaks: a validation rule +/// the chain itself does not apply, or one envelope mid-block that cannot be decoded. +/// +/// Only a few predecessors are taken on. Replaying the tail of a busy block means hundreds of +/// state reads at the parent, which the endpoints here answer slowly if at all. +fn find_replayable_transaction( + cmd: &mut TestCommand, + network: &Network, + latest: u64, +) -> Option { + let mut single_candidate = None; + + for number in (latest.saturating_sub(BLOCK_SCAN_DEPTH)..=latest).rev() { + let Some(block) = full_block(cmd, network, number) else { continue }; + let mut candidates = replayable_transactions(&block, network.transaction_type); + match candidates.len() { + 1 => single_candidate = single_candidate.or_else(|| candidates.pop()), + len if len > 1 => { + candidates.truncate(MAX_REPLAYED_PREDECESSORS.min(len - 1) + 1); + return candidates.pop(); + } + _ => {} + } + } + + single_candidate +} + +/// Returns the hashes of the transactions in `block` that are worth replaying. +/// +/// System transactions are excluded: chains inject them outside normal execution, and `cast run` +/// deliberately refuses to replay them. +fn replayable_transactions(block: &serde_json::Value, transaction_type: Option) -> Vec { + const SYSTEM_TX_TYPES: [u8; 2] = [ + // OP-stack deposit. + 0x7e, // Arbitrum internal. + 0x6a, + ]; + + let Some(transactions) = block.get("transactions").and_then(|txs| txs.as_array()) else { + return Vec::new(); + }; + + transactions + .iter() + .filter(|tx| rpc_transaction_type(tx).is_none_or(|ty| !SYSTEM_TX_TYPES.contains(&ty))) + .filter(|tx| { + transaction_type.is_none_or(|expected| rpc_transaction_type(tx) == Some(expected)) + }) + .filter_map(|tx| Some(tx.get("hash")?.as_str()?.to_string())) + .collect() +} + +/// Reads a transaction type quantity from a JSON-RPC transaction. +fn rpc_transaction_type(tx: &serde_json::Value) -> Option { + let raw = tx.get("type")?.as_str()?; + u8::from_str_radix(raw.strip_prefix("0x").unwrap_or(raw), 16).ok() +} + +/// Runs a `cast` subcommand with `--json` and parses its output, returning `None` on any failure. +/// +/// `--json` wraps the result in the shell's envelope, so the payload is unwrapped from `data`. +fn json_output(cmd: &mut TestCommand, args: &[&str]) -> Option { + let output = cmd.cast_fuse().args(args).arg("--json").execute(); + if !output.status.success() { + return None; + } + let mut value: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?; + if let Some(data) = value.get_mut("data") { + value = data.take(); + } + Some(value) +} + +/// Reads a hex-quantity field out of a JSON-RPC response. +fn hex_field(value: &serde_json::Value, field: &str) -> Option { + let raw = value.get(field)?.as_str()?; + u64::from_str_radix(raw.strip_prefix("0x").unwrap_or(raw), 16).ok() +} + +/// Extracts the gas total `cast run` prints. +fn gas_used(output: &str) -> Option { + output.lines().find_map(|line| line.strip_prefix("Gas used: ")?.trim().parse().ok()) +} + +macro_rules! network_replay_tests { + ($($test:ident => ($name:literal, $rpc_url:literal, $gas:ident),)*) => { + $( + #[casttest] + fn $test(cmd: _) { + assert_replays_recent_transaction( + &mut cmd, + &Network { + name: $name, + rpc_url: $rpc_url, + gas: GasExpectation::$gas, + transaction_type: None, + }, + ); + } + )* + }; +} + +network_replay_tests! { + flaky_run_mainnet => ("ethereum", "https://ethereum-rpc.publicnode.com", Exact), + flaky_run_optimism => ("optimism", "https://mainnet.optimism.io", Exact), + flaky_run_base => ("base", "https://mainnet.base.org", Exact), + flaky_run_linea => ("linea", "https://linea-rpc.publicnode.com", Exact), + + // Disables gas refunds and, since Helicon, charges at least ceil(gas_limit / 2), neither of + // which stock revm models. + // TODO: Restore exact gas assertions once native Avalanche accounting is implemented. + flaky_run_avalanche => ("avalanche", "https://avalanche-c-chain-rpc.publicnode.com", ReplaysOnly), + + // Blocks carry no `parentBeaconBlockRoot` even though the EVM is Cancun or later. + flaky_run_scroll => ("scroll", "https://rpc.scroll.io", Exact), + + // Validator transactions carry a gas limit of `i64::MAX`, past the block gas limit. + flaky_run_bsc => ("bsc", "https://bsc-dataseed.bnbchain.org", Exact), + + // Every block opens with an internal transaction the strict Ethereum envelope cannot decode, + // and receipts fold the L1 posting cost into `gasUsed`. + flaky_run_arbitrum => ("arbitrum", "https://arb1.arbitrum.io/rpc", ExactMinusL1Gas), + + // Charges 840 gas for each storage slot a transaction creates, which revm does not model. + flaky_run_polygon => ("polygon", "https://polygon-bor-rpc.publicnode.com", ReplaysOnly), + + flaky_run_gnosis => ("gnosis", "https://gnosis-rpc.publicnode.com", Exact), + + // OP-stack forks that Foundry does not route to the Optimism network. + flaky_run_berachain => ("berachain", "https://rpc.berachain.com", Exact), + flaky_run_mantle => ("mantle", "https://rpc.mantle.xyz", ReplaysOnly), + + // HyperCore credits are injected by the chain and read precompiles have no local + // implementation, so only some transactions reproduce exactly. Needs an archive endpoint: + // the official one ignores the block tag and answers every state read at latest. + flaky_run_hyperevm => ("hyperevm", "https://rpc.purroofgroup.com", ReplaysOnly), +} + +/// Replays a transaction that failed on-chain from an archive endpoint and checks that it fails +/// with the gas the chain charged. +#[expect(clippy::disallowed_macros, reason = "skips have to be visible in the nightly test log")] +fn assert_replays_failed_transaction( + cmd: &mut TestCommand, + name: &str, + rpc_url: &str, + tx_hash: &str, + gas: u64, +) { + if json_output(cmd, &["receipt", tx_hash, "--rpc-url", rpc_url]).is_none() { + eprintln!("skipping {name}: archive endpoint unreachable"); + return; + } + + let output = cmd + .cast_fuse() + .args(["run", tx_hash, "--rpc-url", rpc_url]) + .assert_success() + .stderr_eq(str![[r#" +Executing previous transactions from the block. +Error: Transaction failed. + +"#]]) + .get_output() + .stdout_lossy(); + + assert_eq!(gas_used(&output), Some(gas), "{name}: replayed {tx_hash}"); +} + +#[casttest] +fn flaky_run_arbitrum_out_of_gas(cmd: _) { + assert_replays_failed_transaction( + &mut cmd, + "arbitrum", + "https://arbitrum-one.public.blastapi.io", + "0x1d66909d9039e5937406357449fad064edbb6d9c81944a8001ebfc4f0ba6d0b9", + 177_864, + ); +} + +// Cronos had not activated Cancun at this block, so an `MCOPY` in the called contract halted and +// the transaction reverted. Foundry has no Cronos hardfork schedule, so replay has to detect the +// spec the node executed rather than default to the newest one, under which it succeeds. +#[casttest] +fn flaky_run_cronos_pre_cancun(cmd: _) { + assert_replays_failed_transaction( + &mut cmd, + "cronos", + "https://evm.cronos.org", + "0x6f23146056354c0b2a3b982e2f03bbadf8eece0364c5b8be4db1a0ec987f7af4", + 562_421, + ); +} + +#[casttest] +fn flaky_run_celo_cip64(cmd: _) { + assert_replays_recent_transaction( + &mut cmd, + &Network { + name: "celo", + rpc_url: "https://forno.celo.org", + gas: GasExpectation::ReplaysOnly, + transaction_type: Some(CELO_DYNAMIC_FEE_TX_TYPE), + }, + ); +} + +// The bot behind this transaction reverts unless `ArbSys.arbBlockNumber()` returns the block it +// targeted, the one before the block the transaction landed in. Replay executes on the parent +// block's state but must report the transaction's own block, or the transaction succeeds. +#[expect(clippy::disallowed_macros, reason = "skips have to be visible in the nightly test log")] +fn assert_arbitrum_arb_block_number(cmd: &mut TestCommand) { + const RPC_URL: &str = "https://arbitrum-one.public.blastapi.io"; + const TX_HASH: &str = "0x3823e04a8def5dc41ffdd2528997a2c70f2337caabfd8039d1e28db4bc71b8b1"; + + if json_output(cmd, &["receipt", TX_HASH, "--rpc-url", RPC_URL]).is_none() { + eprintln!("skipping arbitrum: archive endpoint unreachable"); + return; + } + + cmd.cast_fuse() + .args(["run", TX_HASH, "--rpc-url", RPC_URL]) + .assert_success() + .stdout_eq(str![[r#" +Traces: + [..] 0xeAB71344cc3D1BF0803BbFCb36bAB6ee07650B74::01000000([..]1e5c14c0) + ├─ [803] 0x0000000000000000000000000000000000000064::[..] [staticcall] + │ └─ ← [Return] 0x000000000000000000000000000000000000000000000000000000001e5c14c1 + └─ ← [Revert] EvmError: Revert + + +[GAS] + +"#]]) + .stderr_eq(str![[r#" +Executing previous transactions from the block. +Error: Transaction failed. +... + +"#]]); +} + +#[casttest] +fn flaky_run_arbitrum_arb_block_number(cmd: _) { + assert_arbitrum_arb_block_number(&mut cmd); +} diff --git a/crates/cast/tests/cli/run_trace.rs b/crates/cast/tests/cli/run_trace.rs new file mode 100644 index 0000000000000..f6272990d9ee9 --- /dev/null +++ b/crates/cast/tests/cli/run_trace.rs @@ -0,0 +1,636 @@ +//! CLI tests for run trace commands. + +use super::*; +use alloy_primitives::bytes; + +// +#[casttest] +fn flaky_test_non_mainnet_traces(prj: _, cmd: _) { + prj.clear(); + cmd.args([ + "run", + "0xa003e419e2d7502269eb5eda56947b580120e00abfd5b5460d08f8af44a0c24f", + "--rpc-url", + next_rpc_endpoint(NamedChain::Optimism).as_str(), + "--etherscan-api-key", + next_etherscan_api_key().as_str(), + ]) + .assert_success() + .stdout_eq(str![[r#" +Traces: + [33841] FiatTokenProxy::fallback(0x111111125421cA6dc452d289314280a0f8842A65, 164054805 [1.64e8]) + ├─ [26673] FiatTokenV2_2::approve(0x111111125421cA6dc452d289314280a0f8842A65, 164054805 [1.64e8]) [delegatecall] + │ ├─ emit Approval(owner: 0x9a95Af47C51562acfb2107F44d7967DF253197df, spender: 0x111111125421cA6dc452d289314280a0f8842A65, amount: 164054805 [1.64e8]) + │ └─ ← [Return] true + └─ ← [Return] true +... + +"#]]) + .stderr_eq(str![[r#" +... +Executing previous transactions from the block. +... + +"#]]); +} + +// tests cast can decode traces when using project artifacts +#[forgetest_init] +async fn decode_traces_with_project_artifacts(prj: _, cmd: _) { + let (api, handle) = + anvil::spawn(NodeConfig::test().with_disable_default_create2_deployer(true)).await; + + prj.add_source( + "LocalProjectContract", + r#" +contract LocalProjectContract { + event LocalProjectContractCreated(address owner); + + constructor() { + emit LocalProjectContractCreated(msg.sender); + } +} + "#, + ); + prj.add_script( + "LocalProjectScript", + r#" +import "forge-std/Script.sol"; +import {LocalProjectContract} from "../src/LocalProjectContract.sol"; + +contract LocalProjectScript is Script { + function run() public { + vm.startBroadcast(); + new LocalProjectContract(); + vm.stopBroadcast(); + } +} + "#, + ); + + cmd.args([ + "script", + "--no-dynamic-test-linking", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--rpc-url", + &handle.http_endpoint(), + "--broadcast", + "LocalProjectScript", + ]); + + cmd.assert_success(); + + let tx_hash = api + .transaction_by_block_number_and_index(BlockNumberOrTag::Latest, Index::from(0)) + .await + .unwrap() + .unwrap() + .tx_hash(); + + // Assert cast with local artifacts from outside the project. + cmd.cast_fuse() + .args(["run", "--la", format!("{tx_hash}").as_str(), "--rpc-url", &handle.http_endpoint()]) + .assert_success() + .stdout_eq(str![[r#" +Nothing to compile + +"#]]) + .stderr_eq(str![[r#" +... +Executing previous transactions from the block. +... + +"#]]); + + // Run cast from project dir. + cmd.cast_fuse().set_current_dir(prj.root()); + + // Assert cast without local artifacts cannot decode traces. + cmd.cast_fuse() + .args(["run", format!("{tx_hash}").as_str(), "--rpc-url", &handle.http_endpoint()]) + .assert_success() + .stdout_eq(str![[r#" +Traces: + [..] → new @0x5FbDB2315678afecb367f032d93F642f64180aa3 + ├─ emit topic 0: 0xa7263295d3a687d750d1fd377b5df47de69d7db8decc745aaa4bbee44dc1688d + │ data: 0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266 + └─ ← [Return] 62 bytes of code + + +Transaction successfully executed. +[GAS] + +"#]]) + .stderr_eq(str![[r#" +... +Executing previous transactions from the block. +... + +"#]]); + + // Assert cast with local artifacts can decode traces. + cmd.cast_fuse() + .args(["run", "--la", format!("{tx_hash}").as_str(), "--rpc-url", &handle.http_endpoint()]) + .assert_success() + .stdout_eq(str![[r#" +No files changed, compilation skipped +Traces: + [..] → new LocalProjectContract@0x5FbDB2315678afecb367f032d93F642f64180aa3 + ├─ emit LocalProjectContractCreated(owner: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266) + └─ ← [Return] 62 bytes of code + + +Transaction successfully executed. +[GAS] + +"#]]) + .stderr_eq(str![[r#" +... +Executing previous transactions from the block. +... + +"#]]); +} + +// `cast run` must replay a transaction's block prefix without changing the trace requested for the +// selected transaction. A single block covers a deployment in the first position, a revert in the +// middle, and an internally traced state change in the last position. +#[forgetest_init] +async fn cast_run_fork_traces_only_target_transaction(prj: _, cmd: _) { + let (api, handle) = anvil::spawn(NodeConfig::test()).await; + let endpoint = handle.http_endpoint(); + let provider = handle.http_provider(); + let sender = handle.dev_wallets().next().unwrap().address(); + + prj.add_source( + "ReplayTarget", + r#" +contract ReplayTarget { + uint256 public number; + + constructor() { + number = 1; + } + + function revertingIncrement() external { + number++; + revert("expected revert"); + } + + function increment() external { + _increment(); + } + + function _increment() internal { + number++; + } +} +"#, + ); + let bytecode = cmd + .forge_fuse() + .args(["inspect", "ReplayTarget", "bytecode"]) + .assert_success() + .get_output() + .stdout_lossy(); + let bytecode = Bytes::from_str(bytecode.trim()).unwrap(); + + api.anvil_set_auto_mine(false).await.unwrap(); + let nonce = provider.get_transaction_count(sender).await.unwrap(); + let target = sender.create(nonce); + let deployment = provider + .send_transaction( + TransactionRequest::default() + .from(sender) + .with_deploy_code(bytecode) + .nonce(nonce) + .gas_limit(1_000_000) + .into(), + ) + .await + .unwrap(); + let revert = provider + .send_transaction( + TransactionRequest::default() + .from(sender) + .to(target) + .with_input(Bytes::copy_from_slice(&keccak256(b"revertingIncrement()")[..4])) + .nonce(nonce + 1) + .gas_limit(1_000_000) + .into(), + ) + .await + .unwrap(); + let increment = provider + .send_transaction( + TransactionRequest::default() + .from(sender) + .to(target) + .with_input(Bytes::copy_from_slice(&keccak256(b"increment()")[..4])) + .nonce(nonce + 2) + .gas_limit(1_000_000) + .into(), + ) + .await + .unwrap(); + let tx_hashes = [*deployment.tx_hash(), *revert.tx_hash(), *increment.tx_hash()]; + api.mine_one().await.unwrap(); + + for (index, tx_hash) in tx_hashes.iter().enumerate() { + let block_tx = api + .transaction_by_block_number_and_index(BlockNumberOrTag::Latest, Index::from(index)) + .await + .unwrap() + .unwrap(); + assert_eq!(block_tx.tx_hash(), *tx_hash); + } + assert!(!api.transaction_receipt(tx_hashes[1]).await.unwrap().unwrap().status()); + + cmd.set_current_dir(prj.root()); + let deployment_output = cmd + .cast_fuse() + .args(["run", &tx_hashes[0].to_string(), "--rpc-url", &endpoint, "--with-local-artifacts"]) + .assert_success() + .get_output() + .stdout_lossy(); + assert!(deployment_output.contains("new ReplayTarget")); + assert!(deployment_output.contains("Transaction successfully executed.")); + + let revert_output = cmd + .cast_fuse() + .args(["run", &tx_hashes[1].to_string(), "--rpc-url", &endpoint, "--with-local-artifacts"]) + .assert_success() + .get_output() + .stdout_lossy(); + assert!(revert_output.contains("ReplayTarget::revertingIncrement()")); + assert!(revert_output.contains("expected revert")); + + let increment_output = cmd + .cast_fuse() + .args(["run", &tx_hashes[2].to_string(), "--rpc-url", &endpoint, "--with-local-artifacts"]) + .assert_success() + .get_output() + .stdout_lossy(); + assert!(increment_output.contains("ReplayTarget::increment()")); + assert!(increment_output.contains("Transaction successfully executed.")); + + let decoded_output = cmd + .cast_fuse() + .args([ + "run", + &tx_hashes[2].to_string(), + "--rpc-url", + &endpoint, + "--with-local-artifacts", + "--decode-internal", + "-vvvvv", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + assert!(decoded_output.contains("ReplayTarget::_increment()")); + assert!(decoded_output.contains("@ 0: 1 → 2")); +} + +// `cast run --prestate-tracer` uses the prestate tracer when the node exposes the debug API +// (Anvil does), skipping the block replay while still producing correct traces. The block replay +// message must be absent from stderr. +#[forgetest] +async fn cast_run_prestate_tracer(prj: _, cmd: _) { + let (api, handle) = anvil::spawn(NodeConfig::test()).await; + let endpoint = handle.http_endpoint(); + let tx_hash = deploy_counter_and_set_number(&prj, &mut cmd, &api, &endpoint).await; + + let assert = cmd + .cast_fuse() + .args(["run", "--prestate-tracer", format!("{tx_hash}").as_str(), "--rpc-url", &endpoint]) + .assert_success() + .stdout_eq(str![[r#" +Traces: + [..] 0x5FbDB2315678afecb367f032d93F642f64180aa3::setNumber(111) + └─ ← [Stop] + + +Transaction successfully executed. +[GAS] + +"#]]); + assert!( + !assert + .get_output() + .stderr_lossy() + .contains("Executing previous transactions from the block."), + "prestate tracer path should not replay previous block transactions" + ); +} + +// The prestate tracer path produces the same traces as the block replay path, proving the prestate +// is applied correctly before execution. +#[forgetest] +async fn cast_run_prestate_tracer_matches_block_replay(prj: _, cmd: _) { + let (api, handle) = anvil::spawn(NodeConfig::test()).await; + let endpoint = handle.http_endpoint(); + let tx_hash = deploy_counter_and_set_number(&prj, &mut cmd, &api, &endpoint).await; + + let replay = cmd + .cast_fuse() + .args(["run", format!("{tx_hash}").as_str(), "--rpc-url", &endpoint]) + .assert_success() + .get_output() + .stdout_lossy(); + + let prestate = cmd + .cast_fuse() + .args(["run", "--prestate-tracer", format!("{tx_hash}").as_str(), "--rpc-url", &endpoint]) + .assert_success() + .get_output() + .stdout_lossy(); + + assert_eq!(replay, prestate, "prestate tracer traces must match block replay traces"); +} + +// https://github.com/foundry-rs/foundry/issues/12336 +// `cast run --debug-trace-transaction` fetches the transaction's trace from the node via +// `debug_traceTransaction` (callTracer) and renders it with the same decoding/rendering machinery +// as the local replay, skipping local execution entirely: the block replay message must be absent +// from stderr. +#[forgetest] +async fn cast_run_debug_trace_transaction(prj: _, cmd: _) { + let (api, handle) = anvil::spawn(NodeConfig::test()).await; + let endpoint = handle.http_endpoint(); + let tx_hash = deploy_counter_and_set_number(&prj, &mut cmd, &api, &endpoint).await; + + fs::write( + prj.root().join("foundry.toml"), + r#"[labels] + 0x0000000000000000000000000000000000000001 = "unused" + + [tracing] + decode_internal = true + "#, + ) + .unwrap(); + + cmd.cast_fuse(); + cmd.set_current_dir(prj.root()); + let assert = cmd + .args([ + "run", + "--debug-trace-transaction", + format!("{tx_hash}").as_str(), + "--rpc-url", + &endpoint, + ]) + .assert_success() + .stdout_eq(str![[r#" +Traces: + [..] 0x5FbDB2315678afecb367f032d93F642f64180aa3::setNumber(111) + └─ ← [Return] + + +Transaction successfully executed. +[GAS] + +"#]]) + .stderr_eq(str![[r#" +Warning: Key `[labels]` is being deprecated in favor of `[tracing.labels]`. It will be removed in future versions. + +"#]]); + assert!( + !assert + .get_output() + .stderr_lossy() + .contains("Executing previous transactions from the block."), + "debug_traceTransaction path should not replay previous block transactions" + ); + let receipt = api.transaction_receipt(tx_hash).await.unwrap().unwrap(); + assert!( + assert.get_output().stdout_lossy().contains(&format!("Gas used: {}", receipt.gas_used())), + "debug_traceTransaction summary should report the receipt gas used" + ); +} + +// `cast run --debug-trace-transaction` must render a multi-node trace: the parent runtime emits a +// LOG0 and then CALLs a child whose runtime reverts (the parent ignores the failure), exercising +// the log/sub-call interleaving, nesting and revert rendering through the real pipeline. +#[casttest] +async fn cast_run_debug_trace_transaction_renders_nested_call_and_revert(cmd: _) { + let (api, handle) = anvil::spawn(NodeConfig::test()).await; + + // Parent runtime: LOG0(0,0); CALL(gas, 0x..bb, 0,0,0,0,0); POP; STOP. + api.anvil_set_code( + Address::with_last_byte(0xaa), + bytes!( + "0x60006000a0600060006000600060007300000000000000000000000000000000000000bb5af15000" + ), + ) + .await + .unwrap(); + // Child runtime: PUSH1 0 PUSH1 0 REVERT. + api.anvil_set_code(Address::with_last_byte(0xbb), bytes!("0x60006000fd")).await.unwrap(); + + cmd.cast_fuse() + .args([ + "send", + "0x00000000000000000000000000000000000000aa", + "run()", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success(); + + let tx_hash = api + .transaction_by_block_number_and_index(BlockNumberOrTag::Latest, Index::from(0)) + .await + .unwrap() + .unwrap() + .tx_hash(); + + cmd.cast_fuse() + .args([ + "run", + "--debug-trace-transaction", + format!("{tx_hash}").as_str(), + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success() + .stdout_eq(str![[r#" +Traces: + [..] 0x00000000000000000000000000000000000000AA::run() + ├─ data: 0x + ├─ [..] 0x00000000000000000000000000000000000000bb::fallback() + │ └─ ← [Revert] execution reverted + └─ ← [Return] + + +Transaction successfully executed. +[GAS] + +"#]]); +} + +// `cast run --debug-trace-transaction --with-local-artifacts` labels the target contract by its +// local artifact name (Counter::) instead of the raw address: the RPC path has no local executor, +// so the bytecode for artifact matching must be fetched over RPC at the transaction's block. +#[forgetest] +async fn cast_run_debug_trace_transaction_with_local_artifacts(prj: _, cmd: _) { + let (api, handle) = anvil::spawn(NodeConfig::test()).await; + let endpoint = handle.http_endpoint(); + let tx_hash = deploy_counter_and_set_number(&prj, &mut cmd, &api, &endpoint).await; + + cmd.cast_fuse(); + cmd.set_current_dir(prj.root()); + cmd.args([ + "run", + "--debug-trace-transaction", + "--la", + format!("{tx_hash}").as_str(), + "--rpc-url", + &endpoint, + ]) + .assert_success() + .stdout_eq(str![[r#" +... +Traces: + [..] Counter::setNumber(111) + └─ ← [Return] + + +Transaction successfully executed. +[GAS] + +"#]]); +} + +// `--debug-trace-transaction` fetches the trace from the node, so the local-execution-only flags +// must be rejected by clap. +#[casttest] +fn cast_run_debug_trace_transaction_conflicts_with_debug(cmd: _) { + cmd.args([ + "run", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "--debug-trace-transaction", + "--debug", + ]) + .assert_failure() + .stderr_eq(str![[r#" +error: the argument '--debug-trace-transaction' cannot be used with '--debug' +... +"#]]); +} + +// tests cast can decode traces when running with verbosity level > 4 +#[forgetest_init] +async fn show_state_changes_in_traces(prj: _, cmd: _) { + let (api, handle) = anvil::spawn(NodeConfig::test()).await; + + prj.initialize_default_contracts(); + // Deploy counter contract. + cmd.args([ + "script", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--rpc-url", + &handle.http_endpoint(), + "--broadcast", + "CounterScript", + ]) + .assert_success(); + + // Send tx to change counter storage value. + cmd.cast_fuse() + .args([ + "send", + "0x5FbDB2315678afecb367f032d93F642f64180aa3", + "setNumber(uint256)", + "111", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success(); + + let tx_hash = api + .transaction_by_block_number_and_index(BlockNumberOrTag::Latest, Index::from(0)) + .await + .unwrap() + .unwrap() + .tx_hash(); + + // Assert cast with verbosity displays storage changes. + cmd.cast_fuse() + .args([ + "run", + format!("{tx_hash}").as_str(), + "-vvvvv", + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success() + .stdout_eq(str![[r#" +Traces: + [..] 0x5FbDB2315678afecb367f032d93F642f64180aa3::setNumber(111) + ├─ storage changes: + │ @ 0: 0 → 111 + └─ ← [Stop] + + +Transaction successfully executed. +[GAS] + +"#]]) + .stderr_eq(str![[r#" +... +Executing previous transactions from the block. +... + +"#]]); +} + +// Tests that `cast trace --raw` reports transaction types Foundry cannot encode instead of +// panicking, e.g. Arbitrum's `ArbitrumInternalTx`. +#[casttest] +fn trace_raw_json_unsupported_tx_type(cmd: _) { + let tx = r#"{"type":"0x6a","chainId":"0xa4b1","nonce":"0x0","gasPrice":"0x0","gas":"0x0","to":"0x00000000000000000000000000000000000a4b05","value":"0x0","input":"0x6bf6a42d","r":"0x0","s":"0x0","v":"0x0","hash":"0xe5ad4cc44e5cd67a464c038af87169fde2bd475f2c00306bd2d55ca2c5e4452e","blockHash":"0x0ce1511da42af573bac6870ef058d63bc4c8552440e97c149d4d539c482b5f7a","blockNumber":"0x1dc83ddc","transactionIndex":"0x0","from":"0x00000000000000000000000000000000000a4b05"}"#; + + cmd.args(["trace", "--raw", tx, "--trace"]).assert_failure().stderr_eq(str![[r#" +Error: Cannot EIP-2718 encode transaction type 0x6a + +Context: +- conversion error: Unknown transaction type: 0x6A + +"#]]); +} + +// tests that displays a sample beacon block traces in Cancun +// https://github.com/foundry-rs/foundry/issues/12435 +#[casttest] +fn test_beacon_block_root_in_cancun(prj: _, cmd: _) { + prj.clear(); + let eth_rpc_url = next_http_rpc_endpoint(); + cmd.args([ + "run", + "0xae290fe8c89c3e83dff20eeb2b8e3261bcdce0d66441c7056918dfb5fafe6d96", + "--rpc-url", + eth_rpc_url.as_str(), + ]) + .assert_success() + .stdout_eq(str![[r#" +Traces: + [45054] 0xB731392c0EB5BF2092f9f7B520DA551f70Ea9131::Claim{value: 46698476594582387}() + ├─ [4320] 0x000F3df6D732807Ef1319fB7B8bB8522d0Beac02::00000000(00000000000000000000000000000000000000000000000069091d4b) [staticcall] + │ └─ ← [Return] 0x70c7855161ec07af782df915fb3e81702df40f34972da3d740cdfc132ac926f6 + ├─ emit NvStuck(param0: 0x6e6C36B970f8862bA3F148DEdAB8F98f5ed8b426, param1: 46698476594582387 [4.669e16], param2: 1762205003 [1.762e9]) + └─ ← [Stop] + + +Transaction successfully executed. +[GAS] + +"#]]); +} diff --git a/crates/cast/tests/cli/safe.rs b/crates/cast/tests/cli/safe.rs new file mode 100644 index 0000000000000..7f635bfa8b7e9 --- /dev/null +++ b/crates/cast/tests/cli/safe.rs @@ -0,0 +1,1896 @@ +use alloy_consensus::Transaction; +use alloy_dyn_abi::TypedData; +use alloy_eips::Typed2718; +use alloy_network::ReceiptResponse; +use alloy_primitives::{ + Address, B256, Bytes, Signature, U256, address, b256, bytes, hex, keccak256, +}; +use alloy_provider::Provider; +use alloy_rpc_types::BlockNumberOrTag; +use alloy_signer::Signer; +use alloy_signer_local::PrivateKeySigner; +use alloy_sol_types::{SolCall, SolEvent}; +use anvil::NodeConfig; +use axum::{ + Json, Router, + body::{Body, to_bytes}, + extract::{RawQuery, State}, + http::{Method, Request, StatusCode, Uri}, + response::{IntoResponse, Response}, +}; +use foundry_cli::json::JsonEnvelope; +use foundry_test_utils::util::OutputExt; +use serde_json::{Value, json}; +use std::{ + sync::{Arc, Mutex}, + time::{SystemTime, UNIX_EPOCH}, +}; +use test_safe_contract::TestSafe; +use tokio::task::JoinHandle; + +const ANVIL_KEY: &str = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; +const ANVIL_KEY_2: &str = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d"; +const ANVIL_KEY_3: &str = "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a"; +const ANVIL_OWNER: Address = address!("f39Fd6e51aad88F6F4ce6aB8827279cffFb92266"); +const ANVIL_OWNER_2: Address = address!("70997970C51812dc3A010C7d01b50e0d17dc79C8"); +const ANVIL_OWNER_3: Address = address!("3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"); +const SAFE_L2_V1_4_1: Address = address!("29fcB43b46531BcA003ddC8FCB67FFE91900C762"); +const SAFE_PROXY_FACTORY_V1_4_1: Address = address!("4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67"); +const SIMULATE_TX_ACCESSOR_V1_4_1: Address = address!("3d4BA2E0884aa488718476ca2FB8Efc291A46199"); +const SAFE_L2_V1_4_1_RUNTIME_LEN: usize = 24_421; +const SAFE_PROXY_FACTORY_V1_4_1_RUNTIME_LEN: usize = 3_054; +const SIMULATE_TX_ACCESSOR_V1_4_1_RUNTIME_LEN: usize = 850; +const SAFE_L2_V1_4_1_RUNTIME_HASH: B256 = + b256!("b1f926978a0f44a2c0ec8fe822418ae969bd8c3f18d61e5103100339894f81ff"); +const SAFE_PROXY_FACTORY_V1_4_1_RUNTIME_HASH: B256 = + b256!("50c3cdc4074750a7a974204a716c999edd37482f907608d960b2b025ee0b3317"); +const SIMULATE_TX_ACCESSOR_V1_4_1_RUNTIME_HASH: B256 = + b256!("91f82615581fc73b190b83d72e883608b25e392f72322035df1b13d51766cf8d"); +const SAFE_SERVICE_API_KEY: &str = "cast-safe-test-key"; +const SIMULATION_SAFE_CODE: &str = "0x63d8d11f785f3560e01c146051577333333333333333333333333333333333333333333214602b575f80fd5b60015f5260a0602052602a60405260016060526060608052602060a0523260c05260e05ffd5b5f805260205ff3"; + +#[allow(clippy::too_many_arguments)] +mod test_safe_contract { + alloy_sol_types::sol! { + #[sol(rpc)] + interface TestSafe { + function nonce() external view returns (uint256); + + function getOwners() external view returns (address[] memory); + + function getThreshold() external view returns (uint256); + + function getTransactionHash( + address to, + uint256 value, + bytes calldata data, + uint8 operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address refundReceiver, + uint256 nonce + ) external view returns (bytes32); + + function execTransaction( + address to, + uint256 value, + bytes calldata data, + uint8 operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address payable refundReceiver, + bytes calldata signatures + ) external payable returns (bool success); + + event ExecutionSuccess(bytes32 indexed txHash, uint256 payment); + } + } +} + +fn safe_transaction(safe: Address, operation: u8) -> Value { + json!({ + "safe": safe, + "to": Address::ZERO, + "value": "0", + "data": "0x", + "operation": operation, + "safeTxGas": "0", + "baseGas": "0", + "gasPrice": "0", + "gasToken": Address::ZERO, + "refundReceiver": Address::ZERO, + "nonce": "0", + "safeTxHash": B256::ZERO, + }) +} + +async fn spawn_safe_service(response: Value) -> TestServerHandle { + let router = Router::new().fallback(move || { + let response = response.clone(); + async move { Json(response) } + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + let task = tokio::spawn(async move { + let _ = axum::serve(listener, router).await; + }); + TestServerHandle { endpoint, task } +} + +fn json_envelope(data: Value) -> String { + serde_json::to_string(&JsonEnvelope::success(data)).unwrap() +} + +fn fixture_runtime(bytes: &'static [u8], expected_len: usize, expected_hash: B256) -> Bytes { + assert_eq!(bytes.len(), expected_len, "Safe runtime fixture length changed"); + assert_eq!(keccak256(bytes), expected_hash, "Safe runtime fixture hash changed"); + Bytes::from_static(bytes) +} + +#[derive(Debug, Clone)] +struct StrictConfirmation { + owner: Address, + signature: String, +} + +#[derive(Debug, Clone)] +struct StrictTransaction { + safe: Address, + to: Address, + value: String, + data: String, + operation: u8, + safe_tx_gas: String, + base_gas: String, + gas_price: String, + gas_token: Address, + refund_receiver: Address, + nonce: u64, + hash: B256, + proposal_sender: Address, + proposal_signature: String, + confirmations: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ExpectedTransaction { + safe: Address, + target: Address, + nonce: u64, + hash: B256, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum RequestEvent { + AddDelegate { safe: Address, delegate: Address, delegator: Address }, + ListDelegates { safe: Address }, + NonceLookup { safe: Address }, + Propose { safe: Address, sender: Address, hash: B256 }, + GetTransaction { version: u8, hash: B256 }, + Confirm { hash: B256, owner: Address }, + RemoveDelegate { safe: Address, delegate: Address, delegator: Address }, +} + +#[derive(Debug)] +struct StrictServiceState { + chain_id: u64, + api_key: String, + owners: Vec
, + expected: ExpectedTransaction, + delegates: Vec<(Address, Address, String)>, + transaction: Option, + requests: Vec, +} + +struct TestServerHandle { + endpoint: String, + task: JoinHandle<()>, +} + +impl TestServerHandle { + fn endpoint(&self) -> &str { + &self.endpoint + } +} + +impl Drop for TestServerHandle { + fn drop(&mut self) { + self.task.abort(); + } +} + +/// A small, stateful Transaction Service double used by the lifecycle test. +/// +/// It deliberately accepts only the routes used by Cast and checks the wire-level +/// representation, so a test cannot pass by talking to a permissive catch-all. +struct SafeServiceHandle { + state: Arc>, + server: TestServerHandle, +} + +impl SafeServiceHandle { + fn endpoint(&self) -> &str { + self.server.endpoint() + } + + fn assert_delegate_proposal(&self, delegate: Address, hash: B256) { + let state = self.state.lock().unwrap(); + assert!( + state.delegates.iter().any(|(current, _, _)| *current == delegate), + "delegate proposal was accepted without a registered delegate" + ); + let transaction = state.transaction.as_ref().expect("transaction was not proposed"); + assert_eq!(transaction.hash, hash); + assert_eq!(transaction.proposal_sender, delegate); + assert!(!transaction.proposal_signature.is_empty()); + ensure_safe_signature(&transaction.proposal_signature, hash, delegate) + .expect("delegate proposal signature did not recover the delegate"); + assert!( + transaction.confirmations.is_empty(), + "delegate proposal must not count as an owner confirmation" + ); + } + + fn assert_lifecycle_complete(&self, expected_requests: &[RequestEvent], delegate: Address) { + let state = self.state.lock().unwrap(); + assert!(state.delegates.is_empty(), "delegate was not removed: {:?}", state.delegates); + let transaction = state.transaction.as_ref().expect("transaction was not proposed"); + let expected = state.expected; + assert_eq!(transaction.safe, expected.safe); + assert_eq!(transaction.to, expected.target); + assert_eq!(transaction.nonce, expected.nonce); + assert_eq!(transaction.hash, expected.hash); + assert_eq!(transaction.proposal_sender, delegate); + assert_eq!(transaction.confirmations.len(), state.owners.len()); + assert!( + !transaction.confirmations.iter().any(|confirmation| confirmation.owner == delegate) + ); + let mut actual_owners = transaction + .confirmations + .iter() + .map(|confirmation| confirmation.owner) + .collect::>(); + actual_owners.sort_unstable(); + let mut expected_owners = state.owners.clone(); + expected_owners.sort_unstable(); + assert_eq!(actual_owners, expected_owners); + assert_eq!(state.requests, expected_requests); + } +} + +async fn spawn_strict_safe_service( + owners: Vec
, + chain_id: u64, + expected: ExpectedTransaction, +) -> SafeServiceHandle { + let state = Arc::new(Mutex::new(StrictServiceState { + chain_id, + api_key: SAFE_SERVICE_API_KEY.to_string(), + owners, + expected, + delegates: Vec::new(), + transaction: None, + requests: Vec::new(), + })); + let router = Router::new().fallback(strict_service_handler).with_state(state.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + let task = tokio::spawn(async move { + let _ = axum::serve(listener, router).await; + }); + let server = TestServerHandle { endpoint, task }; + SafeServiceHandle { state, server } +} + +async fn strict_service_handler( + State(state): State>>, + request: Request, +) -> Response { + let method = request.method().clone(); + let uri = request.uri().clone(); + let authorization = request + .headers() + .get("authorization") + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let body = match to_bytes(request.into_body(), 1024 * 1024).await { + Ok(body) => body, + Err(error) => return service_error(format!("failed to read request body: {error}")), + }; + let body = if body.is_empty() { + Value::Null + } else { + match serde_json::from_slice(&body) { + Ok(body) => body, + Err(error) => return service_error(format!("invalid JSON request body: {error}")), + } + }; + + let mut state = state.lock().unwrap(); + if authorization.as_deref() != Some(&format!("Bearer {}", state.api_key)) { + return service_error_with_status(StatusCode::UNAUTHORIZED, "invalid API authorization"); + } + match strict_service_request(&mut state, method, &uri, body) { + Ok(ServiceResponse::Json(value)) => Json(value).into_response(), + Ok(ServiceResponse::Empty) => StatusCode::NO_CONTENT.into_response(), + Err(error) => service_error(error), + } +} + +enum ServiceResponse { + Json(Value), + Empty, +} + +fn strict_service_request( + state: &mut StrictServiceState, + method: Method, + uri: &Uri, + body: Value, +) -> Result { + let path = uri.path(); + let method_name = method.as_str(); + if path == "/api/v2/delegates/" { + match method { + Method::GET => { + handle_list_delegates(state, uri)?; + let safe = state.expected.safe; + state.requests.push(RequestEvent::ListDelegates { safe }); + return Ok(ServiceResponse::Json(json!({ + "count": state.delegates.len(), + "next": null, + "previous": null, + "results": state.delegates.iter().map(|(delegate, delegator, label)| json!({ + "safe": checksum(safe), + "delegate": checksum(*delegate), + "delegator": checksum(*delegator), + "label": label, + })).collect::>(), + }))); + } + Method::POST => { + ensure_no_query(uri)?; + let (delegate, delegator) = handle_add_delegate(state, body)?; + let safe = state.expected.safe; + state.requests.push(RequestEvent::AddDelegate { safe, delegate, delegator }); + return Ok(ServiceResponse::Empty); + } + _ => return Err(format!("unexpected method {method_name} for {path}")), + } + } + if path == "/api/v2/safes/" { + return Err("proposal path is missing its Safe address".to_string()); + } + if let Some(safe_path) = path.strip_prefix("/api/v2/safes/") + && let Some(suffix) = safe_path.strip_suffix("/multisig-transactions/") + { + ensure_method(&method, Method::POST, path)?; + ensure_no_query(uri)?; + let (sender, hash) = handle_proposal(state, suffix, body)?; + let safe = state.expected.safe; + state.requests.push(RequestEvent::Propose { safe, sender, hash }); + return Ok(ServiceResponse::Empty); + } + if let Some(safe_path) = path.strip_prefix("/api/v1/safes/") + && let Some(suffix) = safe_path.strip_suffix("/multisig-transactions/") + { + ensure_method(&method, Method::GET, path)?; + handle_nonce_lookup(state, suffix, uri)?; + let safe = state.expected.safe; + state.requests.push(RequestEvent::NonceLookup { safe }); + return Ok(ServiceResponse::Json(json!({ + "count": 0, + "next": null, + "previous": null, + "results": [], + }))); + } + if let Some(tx_path) = path.strip_prefix("/api/v1/multisig-transactions/") { + if let Some(hash) = tx_path.strip_suffix("/confirmations/") { + ensure_method(&method, Method::POST, path)?; + ensure_no_query(uri)?; + let (hash, owner) = handle_confirmation(state, hash, body)?; + state.requests.push(RequestEvent::Confirm { hash, owner }); + return Ok(ServiceResponse::Empty); + } + if let Some(hash) = tx_path.strip_suffix('/') { + ensure_method(&method, Method::GET, path)?; + ensure_no_query(uri)?; + let transaction_ref = handle_transaction_get(state, hash)?; + let transaction_hash = transaction_ref.hash; + let transaction = transaction_json(transaction_ref); + state + .requests + .push(RequestEvent::GetTransaction { version: 1, hash: transaction_hash }); + return Ok(ServiceResponse::Json(transaction)); + } + } + if let Some(tx_path) = path.strip_prefix("/api/v2/multisig-transactions/") + && let Some(hash) = tx_path.strip_suffix('/') + { + ensure_method(&method, Method::GET, path)?; + ensure_no_query(uri)?; + let transaction_ref = handle_transaction_get(state, hash)?; + let transaction_hash = transaction_ref.hash; + let transaction = transaction_json(transaction_ref); + state.requests.push(RequestEvent::GetTransaction { version: 2, hash: transaction_hash }); + return Ok(ServiceResponse::Json(transaction)); + } + if let Some(delegate_path) = path.strip_prefix("/api/v2/delegates/") + && let Some(delegate) = delegate_path.strip_suffix('/') + { + if method != Method::DELETE { + return Err(format!("unexpected method {method_name} for {path}")); + } + ensure_no_query(uri)?; + let (safe, delegate, delegator) = handle_remove_delegate(state, delegate, body)?; + state.requests.push(RequestEvent::RemoveDelegate { safe, delegate, delegator }); + return Ok(ServiceResponse::Empty); + } + Err(format!("unexpected Transaction Service route: {method_name} {uri}")) +} + +fn service_error(error: impl Into) -> Response { + service_error_with_status(StatusCode::BAD_REQUEST, error) +} + +fn service_error_with_status(status: StatusCode, error: impl Into) -> Response { + (status, Json(json!({ "detail": error.into() }))).into_response() +} + +fn ensure_method(method: &Method, expected: Method, path: &str) -> Result<(), String> { + if *method != expected { + return Err(format!( + "unexpected method {} for {path}; expected {}", + method.as_str(), + expected.as_str() + )); + } + Ok(()) +} + +fn ensure_no_query(uri: &Uri) -> Result<(), String> { + if let Some(query) = uri.query() { + return Err(format!("unexpected query `{query}` for {}", uri.path())); + } + Ok(()) +} + +fn checksum(address: Address) -> String { + address.to_checksum(None) +} + +fn handle_list_delegates(state: &StrictServiceState, uri: &Uri) -> Result<(), String> { + let safe = state.expected.safe; + let expected = format!("safe={}", checksum(safe)); + if uri.query() != Some(expected.as_str()) { + return Err(format!("delegate list query must be `{expected}`, got {:?}", uri.query())); + } + Ok(()) +} + +fn handle_add_delegate( + state: &mut StrictServiceState, + body: Value, +) -> Result<(Address, Address), String> { + let safe = state.expected.safe; + ensure_address_field(&body, "safe", safe)?; + let delegate = parse_checksum_field(&body, "delegate")?; + let delegator = parse_checksum_field(&body, "delegator")?; + if !state.owners.contains(&delegator) { + return Err(format!("delegator {delegator} is not a Safe owner")); + } + let label = + body.get("label").and_then(Value::as_str).ok_or("delegate label is missing")?.to_owned(); + if label.trim().is_empty() { + return Err("delegate label cannot be empty".to_string()); + } + let signature = + body.get("signature").and_then(Value::as_str).ok_or("delegate signature is missing")?; + ensure_delegate_signature(signature, delegate, delegator, state.chain_id)?; + if state.delegates.iter().any(|(current, _, _)| *current == delegate) { + return Err(format!("delegate {delegate} was already registered")); + } + state.delegates.push((delegate, delegator, label)); + Ok((delegate, delegator)) +} + +fn handle_remove_delegate( + state: &mut StrictServiceState, + path: &str, + body: Value, +) -> Result<(Address, Address, Address), String> { + let safe = state.expected.safe; + let delegate = parse_checksum_path(path, "delegate")?; + ensure_address_field(&body, "safe", safe)?; + let delegator = parse_checksum_field(&body, "delegator")?; + if !state.owners.contains(&delegator) { + return Err(format!("delegator {delegator} is not a Safe owner")); + } + let signature = + body.get("signature").and_then(Value::as_str).ok_or("delegate signature is missing")?; + ensure_delegate_signature(signature, delegate, delegator, state.chain_id)?; + let before = state.delegates.len(); + state.delegates.retain(|(current, _, _)| *current != delegate); + if state.delegates.len() == before { + return Err(format!("delegate {delegate} was not registered")); + } + Ok((safe, delegate, delegator)) +} + +fn handle_nonce_lookup(state: &StrictServiceState, path: &str, uri: &Uri) -> Result<(), String> { + let safe = state.expected.safe; + ensure_checksum_path(path, safe, "Safe")?; + let expected = "executed=false&ordering=-nonce&limit=1"; + if uri.query() != Some(expected) { + return Err(format!("nonce lookup query must be `{expected}`, got {:?}", uri.query())); + } + Ok(()) +} + +fn handle_proposal( + state: &mut StrictServiceState, + path: &str, + body: Value, +) -> Result<(Address, B256), String> { + let expected = state.expected; + let safe = expected.safe; + ensure_checksum_path(path, safe, "Safe")?; + if state.transaction.is_some() { + return Err("only one Safe transaction is supported by this test service".to_string()); + } + let target = parse_checksum_field(&body, "to")?; + if target != expected.target { + return Err(format!("proposal target {target} does not match {}", expected.target)); + } + let operation = + body.get("operation").and_then(Value::as_u64).ok_or("proposal operation is missing")?; + if operation > 1 { + return Err(format!("invalid proposal operation {operation}")); + } + let value = decimal_field(&body, "value")?; + let safe_tx_gas = decimal_field(&body, "safeTxGas")?; + let base_gas = decimal_field(&body, "baseGas")?; + let gas_price = decimal_field(&body, "gasPrice")?; + let nonce = decimal_field(&body, "nonce")?.parse::().map_err(|_| "invalid nonce")?; + if nonce != expected.nonce { + return Err(format!("proposal nonce {nonce} does not match {}", expected.nonce)); + } + let data = + body.get("data").and_then(Value::as_str).ok_or("proposal data is missing")?.to_owned(); + let gas_token = parse_checksum_field(&body, "gasToken")?; + let refund_receiver = parse_checksum_field(&body, "refundReceiver")?; + let hash = body + .get("contractTransactionHash") + .and_then(Value::as_str) + .ok_or("proposal transaction hash is missing")? + .parse::() + .map_err(|error| format!("invalid proposal transaction hash: {error}"))?; + if hash != expected.hash { + return Err(format!("proposal hash {hash} does not match {}", expected.hash)); + } + let sender = parse_checksum_field(&body, "sender")?; + let is_owner = state.owners.contains(&sender); + let is_delegate = state.delegates.iter().any(|(delegate, _, _)| *delegate == sender); + if !is_owner && !is_delegate { + return Err(format!("proposal sender {sender} is not an owner or registered delegate")); + } + let signature = body + .get("signature") + .and_then(Value::as_str) + .ok_or("proposal signature is missing")? + .to_owned(); + ensure_safe_signature(&signature, hash, sender)?; + let confirmations = if is_owner { + vec![StrictConfirmation { owner: sender, signature: signature.clone() }] + } else { + Vec::new() + }; + state.transaction = Some(StrictTransaction { + safe, + to: target, + value, + data, + operation: operation as u8, + safe_tx_gas, + base_gas, + gas_price, + gas_token, + refund_receiver, + nonce, + hash, + proposal_sender: sender, + proposal_signature: signature, + confirmations, + }); + Ok((sender, hash)) +} + +fn handle_transaction_get<'a>( + state: &'a StrictServiceState, + path: &str, +) -> Result<&'a StrictTransaction, String> { + let hash = + path.parse::().map_err(|error| format!("invalid transaction hash path: {error}"))?; + let transaction = state.transaction.as_ref().ok_or("transaction was not proposed")?; + if transaction.hash != hash { + return Err(format!("requested transaction {hash}, expected {}", transaction.hash)); + } + Ok(transaction) +} + +fn handle_confirmation( + state: &mut StrictServiceState, + path: &str, + body: Value, +) -> Result<(B256, Address), String> { + let hash = handle_transaction_get(state, path)?.hash; + let signature = body + .get("signature") + .and_then(Value::as_str) + .ok_or("confirmation signature is missing")? + .to_owned(); + let owner = recover_safe_signature(&signature, hash)?; + if !state.owners.contains(&owner) { + return Err(format!("confirmation signer {owner} is not a Safe owner")); + } + let transaction = state.transaction.as_mut().expect("transaction was checked above"); + if transaction.confirmations.iter().any(|confirmation| confirmation.owner == owner) { + return Err(format!("duplicate confirmation from {owner}")); + } + transaction.confirmations.push(StrictConfirmation { owner, signature }); + Ok((hash, owner)) +} + +fn transaction_json(transaction: &StrictTransaction) -> Value { + json!({ + "safe": checksum(transaction.safe), + "to": checksum(transaction.to), + "value": transaction.value, + "data": transaction.data, + "operation": transaction.operation, + "safeTxGas": transaction.safe_tx_gas, + "baseGas": transaction.base_gas, + "gasPrice": transaction.gas_price, + "gasToken": checksum(transaction.gas_token), + "refundReceiver": checksum(transaction.refund_receiver), + "nonce": transaction.nonce.to_string(), + "safeTxHash": transaction.hash, + "confirmations": transaction.confirmations.iter().map(|confirmation| json!({ + "owner": checksum(confirmation.owner), + "signature": confirmation.signature, + })).collect::>(), + "isExecuted": false, + "transactionHash": Value::Null, + }) +} + +fn ensure_checksum_path(path: &str, expected: Address, name: &str) -> Result<(), String> { + if path != checksum(expected) { + return Err(format!("{name} path must use checksum {}, got {path}", checksum(expected))); + } + Ok(()) +} + +fn parse_checksum_path(path: &str, name: &str) -> Result { + let address = + path.parse::
().map_err(|error| format!("invalid {name} path: {error}"))?; + if path != checksum(address) { + return Err(format!("{name} path is not checksummed: {path}")); + } + Ok(address) +} + +fn parse_checksum_field(body: &Value, field: &str) -> Result { + let value = + body.get(field).and_then(Value::as_str).ok_or_else(|| format!("{field} is missing"))?; + let address = value.parse::
().map_err(|error| format!("invalid {field}: {error}"))?; + if value != checksum(address) { + return Err(format!("{field} is not checksummed: {value}")); + } + Ok(address) +} + +fn ensure_address_field(body: &Value, field: &str, expected: Address) -> Result<(), String> { + let actual = parse_checksum_field(body, field)?; + if actual != expected { + return Err(format!("{field} {actual} does not match {}", checksum(expected))); + } + Ok(()) +} + +fn decimal_field(body: &Value, field: &str) -> Result { + let value = body + .get(field) + .and_then(Value::as_str) + .ok_or_else(|| format!("{field} must be a decimal string"))?; + if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(format!("{field} must be a decimal string")); + } + value.parse::().map_err(|error| format!("invalid {field}: {error}"))?; + Ok(value.to_owned()) +} + +fn decode_signature(value: &str) -> Result, String> { + let bytes = + value.parse::().map_err(|error| format!("invalid signature encoding: {error}"))?; + if bytes.len() != 65 { + return Err(format!("expected 65-byte signature, got {}", bytes.len())); + } + Ok(bytes.to_vec()) +} + +fn recover_safe_signature(value: &str, hash: B256) -> Result { + let mut bytes = decode_signature(value)?; + if !matches!(bytes[64], 31 | 32) { + return Err(format!("Safe signature has invalid v = {}", bytes[64])); + } + bytes[64] -= 4; + let signature = + Signature::from_raw(&bytes).map_err(|error| format!("invalid Safe signature: {error}"))?; + signature + .recover_address_from_msg(hash.as_slice()) + .map_err(|error| format!("failed to recover Safe signature: {error}")) +} + +fn ensure_safe_signature(value: &str, hash: B256, expected: Address) -> Result<(), String> { + let recovered = recover_safe_signature(value, hash)?; + if recovered != expected { + return Err(format!("signature recovered {recovered}, expected {}", checksum(expected))); + } + Ok(()) +} + +fn delegate_typed_hash(delegate: Address, chain_id: u64, totp: u64) -> Result { + let typed_data: TypedData = serde_json::from_value(json!({ + "types": { + "EIP712Domain": [ + { "name": "name", "type": "string" }, + { "name": "version", "type": "string" }, + { "name": "chainId", "type": "uint256" } + ], + "Delegate": [ + { "name": "delegateAddress", "type": "address" }, + { "name": "totp", "type": "uint256" } + ] + }, + "primaryType": "Delegate", + "domain": { + "name": "Safe Transaction Service", + "version": "1.0", + "chainId": chain_id + }, + "message": { + "delegateAddress": checksum(delegate), + "totp": totp + } + })) + .map_err(|error| format!("failed to build delegate typed data: {error}"))?; + typed_data + .eip712_signing_hash() + .map_err(|error| format!("failed to hash delegate typed data: {error}")) +} + +fn ensure_delegate_signature( + value: &str, + delegate: Address, + expected: Address, + chain_id: u64, +) -> Result<(), String> { + let bytes = decode_signature(value)?; + if !matches!(bytes[64], 27 | 28) { + return Err(format!("delegate signature has invalid v = {}", bytes[64])); + } + let signature = Signature::from_raw(&bytes) + .map_err(|error| format!("invalid delegate signature: {error}"))?; + let current = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| format!("system clock is before Unix epoch: {error}"))? + .as_secs() + / (60 * 60); + for totp in [current.saturating_sub(1), current, current.saturating_add(1)] { + let hash = delegate_typed_hash(delegate, chain_id, totp)?; + if signature.recover_address_from_prehash(&hash).ok() == Some(expected) { + return Ok(()); + } + } + Err(format!("delegate signature does not recover {expected}")) +} + +#[tokio::test(flavor = "multi_thread")] +async fn safe_service_rejects_non_checksum_proposal_addresses() { + // Cast serializes parsed Address values as checksums, so send malformed wire payloads + // directly to the strict service to cover the validation boundary a remote client crosses. + let safe = Address::repeat_byte(0x11); + let target = address!("5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed"); + let gas_token = address!("fB6916095ca1df60bB79Ce92cE3Ea74c37c5d359"); + let refund_receiver = address!("52908400098527886E0F7030069857D2E4169EE7"); + let client = reqwest::Client::new(); + + for (field, address, malformed) in [ + ("to", target, checksum(target).to_ascii_lowercase()), + ("gasToken", gas_token, checksum(gas_token).to_ascii_lowercase()), + ("refundReceiver", refund_receiver, checksum(refund_receiver).to_ascii_lowercase()), + ] { + assert_ne!(malformed, checksum(address), "test address must contain checksum casing"); + let expected = ExpectedTransaction { safe, target, nonce: 0, hash: B256::ZERO }; + let service = spawn_strict_safe_service(vec![ANVIL_OWNER], 31337, expected).await; + let mut body = json!({ + "to": checksum(target), + "value": "0", + "data": "0x", + "operation": 0, + "safeTxGas": "0", + "baseGas": "0", + "gasPrice": "0", + "gasToken": checksum(gas_token), + "refundReceiver": checksum(refund_receiver), + "nonce": "0", + "contractTransactionHash": B256::ZERO, + "sender": checksum(ANVIL_OWNER), + "signature": "0x", + }); + body[field] = Value::String(malformed.clone()); + + let url = format!( + "{}/api/v2/safes/{}/multisig-transactions/", + service.endpoint(), + checksum(safe) + ); + let response = + client.post(url).bearer_auth(SAFE_SERVICE_API_KEY).json(&body).send().await.unwrap(); + assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST); + assert_eq!( + response.json::().await.unwrap(), + json!({"detail": format!("{field} is not checksummed: {malformed}")}) + ); + + let state = service.state.lock().unwrap(); + assert!(state.transaction.is_none(), "rejected proposal mutated transaction state"); + assert!(state.requests.is_empty(), "rejected proposal recorded a request"); + } +} + +#[casttest] +fn safe_commands_are_exposed(cmd: _) { + let output = + cmd.cast_fuse().args(["safe", "--help"]).assert_success().get_output().stdout_lossy(); + for command in [ + "create", + "add-delegate", + "list-delegates", + "remove-delegate", + "propose", + "sign", + "simulate", + "execute", + ] { + assert!(output.contains(command), "expected `cast safe {command}` in help:\n{output}"); + } +} + +#[casttest] +fn safe_signing_commands_support_hardware_wallets(cmd: _) { + for command in ["create", "add-delegate", "remove-delegate", "propose", "sign", "execute"] { + let output = cmd + .cast_fuse() + .args(["safe", command, "--help"]) + .assert_success() + .get_output() + .stdout_lossy(); + assert!(output.contains("--ledger"), "expected Ledger support in help:\n{output}"); + assert!(output.contains("--trezor"), "expected Trezor support in help:\n{output}"); + } +} + +#[casttest] +fn safe_onchain_commands_support_tempo_transaction_options(cmd: _) { + for command in ["create", "execute"] { + let output = cmd + .cast_fuse() + .args(["safe", command, "--help"]) + .assert_success() + .get_output() + .stdout_lossy(); + assert!( + output.contains("--tempo.fee-token"), + "expected Tempo fee-token support in help:\n{output}" + ); + assert!( + output.contains("--tempo.nonce-key"), + "expected Tempo nonce-key support in help:\n{output}" + ); + } +} + +#[casttest] +async fn safe_create_honors_transaction_options(cmd: _) { + let (api, handle) = anvil::spawn(NodeConfig::test()).await; + let rpc = handle.http_endpoint(); + let provider = handle.http_provider(); + let singleton = Address::repeat_byte(0x11); + let factory = Address::repeat_byte(0x22); + + api.anvil_set_code(singleton, bytes!("0x00")).await.unwrap(); + // mstore(singleton); emit ProxyCreation(proxy, singleton); return proxy. + api.anvil_set_code( + factory, + bytes!( + "0x7311111111111111111111111111111111111111115f527333333333333333333333333333333333333333337f4f51faf6c4561ff95f067657e43439f0f856d97c04d9ec9070a6199ad418e23560205fa27333333333333333333333333333333333333333335f5260205ff3" + ), + ) + .await + .unwrap(); + + let owner = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; + let singleton = singleton.to_string(); + let factory = factory.to_string(); + let common_args = [ + "safe", + "create", + owner, + "--singleton", + &singleton, + "--factory", + &factory, + "--fallback-handler", + "0x0000000000000000000000000000000000000000", + "--private-key", + ANVIL_KEY, + "--rpc-url", + &rpc, + ]; + + cmd.cast_fuse().args(common_args).arg("--legacy").assert_success(); + let block = + provider.get_block_by_number(BlockNumberOrTag::Latest).full().await.unwrap().unwrap(); + let transaction = block.transactions.as_transactions().unwrap().last().unwrap(); + assert_eq!(transaction.ty(), 0); + + cmd.cast_fuse() + .args(common_args) + .args([ + "--access-list", + r#"[{"address":"0x4444444444444444444444444444444444444444","storageKeys":["0x5555555555555555555555555555555555555555555555555555555555555555"]}]"#, + ]) + .assert_success(); + let block = + provider.get_block_by_number(BlockNumberOrTag::Latest).full().await.unwrap().unwrap(); + let transaction = block.transactions.as_transactions().unwrap().last().unwrap(); + let access_list = transaction.access_list().expect("explicit access list"); + assert_eq!(access_list.len(), 1); + assert_eq!(access_list[0].address, Address::repeat_byte(0x44)); + assert_eq!(access_list[0].storage_keys, [B256::repeat_byte(0x55)]); +} + +#[casttest] +async fn safe_v1_4_1_lifecycle_uses_stateful_service(cmd: _) { + let (api, handle) = anvil::spawn(NodeConfig::test()).await; + let rpc = handle.http_endpoint(); + let provider = handle.http_provider(); + api.anvil_set_code( + SAFE_L2_V1_4_1, + fixture_runtime( + include_bytes!("../fixtures/safe/v1.4.1/SafeL2.runtime.bin"), + SAFE_L2_V1_4_1_RUNTIME_LEN, + SAFE_L2_V1_4_1_RUNTIME_HASH, + ), + ) + .await + .unwrap(); + api.anvil_set_code( + SAFE_PROXY_FACTORY_V1_4_1, + fixture_runtime( + include_bytes!("../fixtures/safe/v1.4.1/SafeProxyFactory.runtime.bin"), + SAFE_PROXY_FACTORY_V1_4_1_RUNTIME_LEN, + SAFE_PROXY_FACTORY_V1_4_1_RUNTIME_HASH, + ), + ) + .await + .unwrap(); + api.anvil_set_code( + SIMULATE_TX_ACCESSOR_V1_4_1, + fixture_runtime( + include_bytes!("../fixtures/safe/v1.4.1/SimulateTxAccessor.runtime.bin"), + SIMULATE_TX_ACCESSOR_V1_4_1_RUNTIME_LEN, + SIMULATE_TX_ACCESSOR_V1_4_1_RUNTIME_HASH, + ), + ) + .await + .unwrap(); + + let target = address!("5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed"); + // Increment slot zero and return msg.sender. The counter distinguishes one execution from + // repeated calls while the return value keeps the simulation assertion observable. + api.anvil_set_code(target, bytes!("0x6000546001016000553360005260206000f3")).await.unwrap(); + + let owners = vec![ANVIL_OWNER, ANVIL_OWNER_2]; + let owner_1 = ANVIL_OWNER.to_string(); + let owner_2 = ANVIL_OWNER_2.to_string(); + let target_arg = target.to_string(); + let create_output = cmd + .cast_fuse() + .args([ + "safe", + "create", + &owner_1, + &owner_2, + "--threshold", + "2", + "--singleton", + &SAFE_L2_V1_4_1.to_string(), + "--factory", + &SAFE_PROXY_FACTORY_V1_4_1.to_string(), + "--fallback-handler", + "0x0000000000000000000000000000000000000000", + "--private-key", + ANVIL_KEY, + "--rpc-url", + &rpc, + ]) + .assert_success() + .get_output() + .stdout_lossy(); + let safe: Address = create_output + .lines() + .rev() + .find_map(|line| line.trim().parse().ok()) + .expect("cast safe create did not print a Safe address"); + let safe_contract = TestSafe::new(safe, &provider); + assert!(!provider.get_code_at(safe).await.unwrap().is_empty()); + assert_eq!(safe_contract.getOwners().call().await.unwrap(), owners); + assert_eq!(safe_contract.getThreshold().call().await.unwrap(), U256::from(2)); + assert_eq!(safe_contract.nonce().call().await.unwrap(), U256::ZERO); + + let calculated_hash = safe_contract + .getTransactionHash( + target, + U256::ZERO, + Bytes::new(), + 0, + U256::ZERO, + U256::ZERO, + U256::ZERO, + Address::ZERO, + Address::ZERO, + U256::ZERO, + ) + .call() + .await + .unwrap(); + let service = spawn_strict_safe_service( + owners, + 31337, + ExpectedTransaction { safe, target, nonce: 0, hash: calculated_hash }, + ) + .await; + + let safe_arg = safe.to_string(); + let delegate = ANVIL_OWNER_3; + let delegate_arg = delegate.to_string(); + let service_args = [ + "--service-url", + service.endpoint(), + "--api-key", + SAFE_SERVICE_API_KEY, + "--rpc-url", + rpc.as_str(), + ]; + + cmd.cast_fuse() + .args([ + "--json", + "safe", + "add-delegate", + &safe_arg, + &delegate_arg, + "--label", + "integration delegate", + "--private-key", + ANVIL_KEY, + ]) + .args(service_args) + .assert_json_stdout(json_envelope(json!(checksum(delegate)))); + cmd.cast_fuse() + .args(["--json", "safe", "list-delegates", &safe_arg]) + .args(service_args) + .assert_json_stdout(json_envelope(json!([{ + "safe": safe, + "delegate": delegate, + "delegator": ANVIL_OWNER, + "label": "integration delegate", + }]))); + + let proposal_output = cmd + .cast_fuse() + .args(["safe", "propose", &safe_arg, &target_arg, "--private-key", ANVIL_KEY_3]) + .args(service_args) + .assert_success() + .get_output() + .stdout_lossy(); + let safe_tx_hash: B256 = proposal_output + .lines() + .rev() + .find_map(|line| line.trim().parse().ok()) + .expect("cast safe propose did not print a transaction hash"); + assert_eq!(safe_tx_hash, calculated_hash); + service.assert_delegate_proposal(delegate, safe_tx_hash); + + let safe_tx_hash_arg = safe_tx_hash.to_string(); + cmd.cast_fuse() + .args(["safe", "sign", &safe_arg, &safe_tx_hash_arg, "--private-key", ANVIL_KEY]) + .args(service_args) + .assert_success(); + cmd.cast_fuse() + .args(["safe", "sign", &safe_arg, &safe_tx_hash_arg, "--private-key", ANVIL_KEY_2]) + .args(service_args) + .assert_success(); + + let expected_return_data = hex::encode_prefixed(safe.into_word()); + let accessor_arg = SIMULATE_TX_ACCESSOR_V1_4_1.to_string(); + cmd.cast_fuse() + .args([ + "--json", + "safe", + "simulate", + &safe_arg, + &safe_tx_hash_arg, + "--from", + &delegate_arg, + "--accessor", + &accessor_arg, + ]) + .args(service_args) + .assert_json_stdout(json_envelope(json!({ + "safeTxHash": safe_tx_hash, + "success": true, + "gasUsed": "[..]", + "returnData": expected_return_data, + }))); + assert_eq!(provider.get_storage_at(target, U256::ZERO).await.unwrap(), U256::ZERO); + + let execution_output = cmd + .cast_fuse() + .args(["safe", "execute", &safe_arg, &safe_tx_hash_arg, "--private-key", ANVIL_KEY]) + .args(service_args) + .assert_success() + .get_output() + .stdout_lossy(); + let execution_hash: B256 = + execution_output.trim().parse().expect("missing outer transaction hash"); + let receipt = provider + .get_transaction_receipt(execution_hash) + .await + .unwrap() + .expect("Safe execution receipt was not mined"); + assert!(receipt.status()); + assert_eq!(receipt.transaction_hash, execution_hash); + let matching_events = receipt + .logs() + .iter() + .filter(|log| log.address() == safe) + .filter_map(|log| TestSafe::ExecutionSuccess::decode_log(&log.inner).ok()) + .filter(|event| event.txHash == safe_tx_hash) + .collect::>(); + assert_eq!(matching_events.len(), 1, "expected one matching ExecutionSuccess event"); + assert_eq!(matching_events[0].payment, U256::ZERO); + assert_eq!(provider.get_storage_at(target, U256::ZERO).await.unwrap(), U256::ONE); + assert_eq!(safe_contract.nonce().call().await.unwrap(), U256::ONE); + + cmd.cast_fuse() + .args([ + "--json", + "safe", + "remove-delegate", + &safe_arg, + &delegate_arg, + "--private-key", + ANVIL_KEY, + ]) + .args(service_args) + .assert_json_stdout(json_envelope(json!(checksum(delegate)))); + cmd.cast_fuse() + .args(["--json", "safe", "list-delegates", &safe_arg]) + .args(service_args) + .assert_json_stdout(json_envelope(json!([]))); + service.assert_lifecycle_complete( + &[ + RequestEvent::AddDelegate { safe, delegate, delegator: ANVIL_OWNER }, + RequestEvent::ListDelegates { safe }, + RequestEvent::NonceLookup { safe }, + RequestEvent::Propose { safe, sender: delegate, hash: safe_tx_hash }, + RequestEvent::GetTransaction { version: 1, hash: safe_tx_hash }, + RequestEvent::Confirm { hash: safe_tx_hash, owner: ANVIL_OWNER }, + RequestEvent::GetTransaction { version: 1, hash: safe_tx_hash }, + RequestEvent::Confirm { hash: safe_tx_hash, owner: ANVIL_OWNER_2 }, + RequestEvent::GetTransaction { version: 2, hash: safe_tx_hash }, + RequestEvent::GetTransaction { version: 2, hash: safe_tx_hash }, + RequestEvent::RemoveDelegate { safe, delegate, delegator: ANVIL_OWNER }, + RequestEvent::ListDelegates { safe }, + ], + delegate, + ); +} + +#[casttest] +async fn safe_execute_rejects_approved_hash_confirmation(cmd: _) { + let (api, handle) = anvil::spawn(NodeConfig::test()).await; + let rpc = handle.http_endpoint(); + let safe = Address::repeat_byte(0x11); + // Return a zero bytes32 word for both getTransactionHash() and nonce(). + api.anvil_set_code(safe, bytes!("0x600060005260206000f3")).await.unwrap(); + let mut transaction = safe_transaction(safe, 0); + let mut approved_hash_signature = vec![0u8; 65]; + approved_hash_signature[..32].copy_from_slice(ANVIL_OWNER.into_word().as_slice()); + approved_hash_signature[64] = 1; + transaction["confirmations"] = json!([{ + "owner": ANVIL_OWNER, + "signature": hex::encode_prefixed(approved_hash_signature), + }]); + let service = spawn_safe_service(transaction).await; + cmd.cast_fuse() + .args([ + "safe", + "execute", + &safe.to_string(), + &B256::ZERO.to_string(), + "--service-url", + service.endpoint(), + "--rpc-url", + &rpc, + "--private-key", + ANVIL_KEY, + ]) + .assert_failure() + .stderr_eq(str![[r#" +Safe transaction: 0x0000000000000000000000000000000000000000000000000000000000000000 + Safe: 0x1111111111111111111111111111111111111111 + To: 0x0000000000000000000000000000000000000000 + Value: 0 + Operation: 0 (CALL) + Safe tx gas: 0 + Base gas: 0 + Gas price: 0 + Gas token: 0x0000000000000000000000000000000000000000 + Refund receiver: 0x0000000000000000000000000000000000000000 + Nonce: 0 + Data: 0x +Error: approved-hash signatures (v = 1) are not supported by `cast safe execute` + +"#]]); +} + +#[casttest] +async fn safe_execute_rejects_future_nonce(cmd: _) { + let (api, handle) = anvil::spawn(NodeConfig::test()).await; + let rpc = handle.http_endpoint(); + let safe = Address::repeat_byte(0x11); + api.anvil_set_code(safe, bytes!("0x600060005260206000f3")).await.unwrap(); + let mut transaction = safe_transaction(safe, 0); + transaction["nonce"] = json!("1"); + let service = spawn_safe_service(transaction).await; + cmd.cast_fuse() + .args([ + "safe", + "execute", + &safe.to_string(), + &B256::ZERO.to_string(), + "--service-url", + service.endpoint(), + "--rpc-url", + &rpc, + "--private-key", + ANVIL_KEY, + ]) + .assert_failure() + .stderr_eq(str![[r#" +Error: Safe transaction nonce 1 does not match current Safe nonce 0 + +"#]]); +} + +#[casttest] +async fn safe_execute_rejects_stale_nonce(cmd: _) { + let (api, handle) = anvil::spawn(NodeConfig::test()).await; + let rpc = handle.http_endpoint(); + let safe = Address::repeat_byte(0x11); + // Return one for nonce() and zero for getTransactionHash(). + api.anvil_set_code( + safe, + bytes!("0x5f3560e01c63affed0e0146015575f5f5260205ff35b60015f5260205ff3"), + ) + .await + .unwrap(); + let transaction = safe_transaction(safe, 0); + let service = spawn_safe_service(transaction).await; + cmd.cast_fuse() + .args([ + "safe", + "execute", + &safe.to_string(), + &B256::ZERO.to_string(), + "--service-url", + service.endpoint(), + "--rpc-url", + &rpc, + "--private-key", + ANVIL_KEY, + ]) + .assert_failure() + .stderr_eq(str![[r#" +Error: Safe transaction nonce 0 does not match current Safe nonce 1 + +"#]]); +} + +#[casttest] +async fn safe_execute_rejects_approved_hash_with_stale_or_future_nonce(cmd: _) { + // A v = 1 confirmation must not make a transaction with a stale or future nonce + // executable. Use a fresh node for each case so the no-broadcast assertions are + // independent and a regression cannot hide behind state from the other case. + for (nonce, current_nonce, safe_code, expected_error) in [ + ( + 1u64, + 0u64, + "0x600060005260206000f3", + "Error: Safe transaction nonce 1 does not match current Safe nonce 0\n", + ), + ( + 0u64, + 1u64, + "0x5f3560e01c63affed0e0146015575f5f5260205ff35b60015f5260205ff3", + "Error: Safe transaction nonce 0 does not match current Safe nonce 1\n", + ), + ] { + let (api, handle) = anvil::spawn(NodeConfig::test()).await; + let rpc = handle.http_endpoint(); + let provider = handle.http_provider(); + let safe = Address::repeat_byte(0x11); + api.anvil_set_code(safe, safe_code.parse().unwrap()).await.unwrap(); + + let initial_block = provider.get_block_number().await.unwrap(); + let initial_sender_nonce = provider.get_transaction_count(ANVIL_OWNER).await.unwrap(); + let mut transaction = safe_transaction(safe, 0); + transaction["nonce"] = json!(nonce.to_string()); + let mut approved_hash_signature = vec![0u8; 65]; + approved_hash_signature[..32].copy_from_slice(ANVIL_OWNER.into_word().as_slice()); + approved_hash_signature[64] = 1; + transaction["confirmations"] = json!([{ + "owner": ANVIL_OWNER, + "signature": hex::encode_prefixed(approved_hash_signature), + }]); + let service = spawn_safe_service(transaction).await; + + let safe_arg = safe.to_string(); + let hash_arg = B256::ZERO.to_string(); + cmd.cast_fuse() + .args([ + "safe", + "execute", + &safe_arg, + &hash_arg, + "--service-url", + service.endpoint(), + "--rpc-url", + &rpc, + "--private-key", + ANVIL_KEY, + ]) + .assert_failure() + .stdout_eq("") + .stderr_eq(expected_error); + + // Nonce validation happens before eth_sendTransaction; prove that no outer transaction + // was submitted even though the service supplied an approved-hash confirmation. + assert_eq!(provider.get_block_number().await.unwrap(), initial_block); + assert_eq!( + provider.get_transaction_count(ANVIL_OWNER).await.unwrap(), + initial_sender_nonce + ); + assert_eq!( + TestSafe::new(safe, &provider).nonce().call().await.unwrap(), + U256::from(current_nonce) + ); + let latest = + provider.get_block_by_number(BlockNumberOrTag::Latest).full().await.unwrap().unwrap(); + assert!(latest.transactions.as_transactions().unwrap().is_empty()); + } +} + +#[casttest] +async fn safe_execute_packs_mixed_p256_confirmations(cmd: _) { + let (api, handle) = anvil::spawn(NodeConfig::test()).await; + let rpc = handle.http_endpoint(); + let provider = handle.http_provider(); + let safe = Address::repeat_byte(0x11); + // Return zero for hash/nonce calls and emit ExecutionSuccess(bytes32(0), 0). + api.anvil_set_code( + safe, + bytes!( + "0x5f7f442e715f626346e8c54381002da614f62bee8d27386535b2521ec8540898556e60205fa260205ff3" + ), + ) + .await + .unwrap(); + + let p256_owner = Address::repeat_byte(1); + let eoa_owner = Address::repeat_byte(2); + let contract_owner = Address::repeat_byte(3); + let p256_payload = [4; 128]; + let contract_payload = [5, 6, 7]; + + let mut p256_signature = Vec::with_capacity(193); + p256_signature.extend_from_slice(p256_owner.into_word().as_slice()); + p256_signature.extend_from_slice(B256::with_last_byte(65).as_slice()); + p256_signature.push(2); + p256_signature.extend_from_slice(&p256_payload); + + let mut eoa_signature = vec![2; 65]; + eoa_signature[64] = 27; + + let mut contract_signature = Vec::with_capacity(100); + contract_signature.extend_from_slice(contract_owner.into_word().as_slice()); + contract_signature.extend_from_slice(B256::with_last_byte(65).as_slice()); + contract_signature.push(0); + contract_signature.extend_from_slice(&U256::from(contract_payload.len()).to_be_bytes::<32>()); + contract_signature.extend_from_slice(&contract_payload); + + let mut service_transaction = safe_transaction(safe, 0); + service_transaction["confirmations"] = json!([ + { + "owner": contract_owner, + "signature": hex::encode_prefixed(&contract_signature), + }, + { + "owner": eoa_owner, + "signature": hex::encode_prefixed(&eoa_signature), + }, + { + "owner": p256_owner, + "signature": hex::encode_prefixed(&p256_signature), + }, + ]); + let service = spawn_safe_service(service_transaction).await; + + cmd.cast_fuse() + .args([ + "safe", + "execute", + &safe.to_string(), + &B256::ZERO.to_string(), + "--service-url", + service.endpoint(), + "--rpc-url", + &rpc, + "--private-key", + ANVIL_KEY, + ]) + .assert_success(); + + let mut expected_signatures = Vec::with_capacity(358); + expected_signatures.extend_from_slice(p256_owner.into_word().as_slice()); + expected_signatures.extend_from_slice(B256::with_last_byte(195).as_slice()); + expected_signatures.push(2); + expected_signatures.extend_from_slice(&eoa_signature); + expected_signatures.extend_from_slice(contract_owner.into_word().as_slice()); + expected_signatures.extend_from_slice(&U256::from(323).to_be_bytes::<32>()); + expected_signatures.push(0); + expected_signatures.extend_from_slice(&p256_payload); + expected_signatures.extend_from_slice(&U256::from(contract_payload.len()).to_be_bytes::<32>()); + expected_signatures.extend_from_slice(&contract_payload); + + let expected_calldata = TestSafe::execTransactionCall { + to: Address::ZERO, + value: U256::ZERO, + data: Bytes::new(), + operation: 0, + safeTxGas: U256::ZERO, + baseGas: U256::ZERO, + gasPrice: U256::ZERO, + gasToken: Address::ZERO, + refundReceiver: Address::ZERO, + signatures: expected_signatures.into(), + } + .abi_encode(); + let block = + provider.get_block_by_number(BlockNumberOrTag::Latest).full().await.unwrap().unwrap(); + let submitted = block.transactions.as_transactions().unwrap().last().unwrap(); + assert_eq!(submitted.input(), expected_calldata.as_slice()); +} + +#[casttest] +async fn safe_service_mutations_emit_json_envelopes(cmd: _) { + let (api, handle) = anvil::spawn(NodeConfig::test()).await; + let rpc = handle.http_endpoint(); + let safe = Address::repeat_byte(0x11); + let delegate = Address::repeat_byte(0x22); + let target = Address::repeat_byte(0x33); + api.anvil_set_code(safe, bytes!("0x5f5f5260205ff3")).await.unwrap(); + let service = spawn_safe_service(safe_transaction(safe, 0)).await; + let safe = safe.to_string(); + let delegate = delegate.to_string(); + let target = target.to_string(); + let safe_tx_hash = B256::ZERO.to_string(); + let signer_args = [ + "--service-url", + service.endpoint(), + "--rpc-url", + rpc.as_str(), + "--private-key", + ANVIL_KEY, + ]; + + cmd.cast_fuse() + .args(["--json", "safe", "add-delegate", &safe, &delegate, "--label", "test"]) + .args(signer_args) + .assert_json_stdout(json_envelope(json!(delegate))); + + cmd.cast_fuse() + .args(["--json", "safe", "remove-delegate", &safe, &delegate]) + .args(signer_args) + .assert_json_stdout(json_envelope(json!(delegate))); + + cmd.cast_fuse() + .args(["--json", "safe", "propose", &safe, &target, "--nonce", "0"]) + .args(signer_args) + .assert_json_stdout(json_envelope(json!(B256::ZERO))); + + let signer: PrivateKeySigner = ANVIL_KEY.parse().unwrap(); + let mut signature = signer.sign_message(B256::ZERO.as_slice()).await.unwrap().as_bytes(); + signature[64] += 4; + let signature = hex::encode_prefixed(signature); + cmd.cast_fuse() + .args(["--json", "safe", "sign", &safe, &safe_tx_hash]) + .args(signer_args) + .assert_json_stdout(json_envelope(json!(signature))); +} + +#[casttest] +async fn safe_list_delegates_follows_pagination(cmd: _) { + let safe = Address::repeat_byte(0x11); + let first = json!({ + "safe": safe, + "delegate": Address::repeat_byte(0x22), + "delegator": Address::repeat_byte(0x33), + "label": "first", + }); + let second = json!({ + "safe": safe, + "delegate": Address::repeat_byte(0x44), + "delegator": Address::repeat_byte(0x55), + "label": "second", + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + let next = format!("{endpoint}/api/v2/delegates/?page=2"); + let first_response = first.clone(); + let second_response = second.clone(); + let router = Router::new().fallback(move |RawQuery(query): RawQuery| { + let first = first_response.clone(); + let second = second_response.clone(); + let next = next.clone(); + async move { + let is_second_page = + query.as_deref().is_some_and(|query| query.split('&').any(|pair| pair == "page=2")); + Json(if is_second_page { + json!({ "count": 2, "next": null, "previous": null, "results": [second] }) + } else { + json!({ "count": 2, "next": next, "previous": null, "results": [first] }) + }) + } + }); + let task = tokio::spawn(async move { + let _ = axum::serve(listener, router).await; + }); + let server = TestServerHandle { endpoint, task }; + + cmd.cast_fuse() + .args([ + "--json", + "safe", + "list-delegates", + &safe.to_string(), + "--service-url", + server.endpoint(), + ]) + .assert_json_stdout(json_envelope(json!([first, second]))); +} + +#[casttest] +async fn safe_list_delegates_rejects_external_next_page(cmd: _) { + let service = spawn_safe_service(json!({ + "count": 1, + "next": "http://127.0.0.1:1/collect-api-key", + "previous": null, + "results": [], + })) + .await; + cmd.cast_fuse() + .args([ + "safe", + "list-delegates", + "0x1111111111111111111111111111111111111111", + "--service-url", + service.endpoint(), + ]) + .assert_failure() + .stdout_eq("") + .stderr_eq(str![[r#" +Error: delegate pagination URL points outside the Transaction Service endpoint: http://127.0.0.1:1/collect-api-key + +"#]]); +} + +#[casttest] +async fn safe_sign_rejects_service_selected_safe(cmd: _) { + let (api, handle) = anvil::spawn(NodeConfig::test()).await; + let rpc = handle.http_endpoint(); + let expected = Address::repeat_byte(0x11); + let malicious = Address::repeat_byte(0x22); + api.anvil_set_code(malicious, bytes!("0x5f5f5260205ff3")).await.unwrap(); + let service = spawn_safe_service(safe_transaction(malicious, 0)).await; + let expected_arg = expected.to_string(); + let safe_tx_hash = B256::ZERO.to_string(); + + cmd.cast_fuse() + .args([ + "safe", + "sign", + &expected_arg, + &safe_tx_hash, + "--service-url", + service.endpoint(), + "--rpc-url", + &rpc, + "--private-key", + ANVIL_KEY, + ]) + .assert_failure() + .stdout_eq("") + .stderr_eq(format!( + "Error: Transaction Service returned Safe {malicious}, expected {expected}\n" + )); +} + +#[casttest] +fn safe_simulation_requires_executor(cmd: _) { + let safe = Address::repeat_byte(0x11).to_string(); + let safe_tx_hash = B256::ZERO.to_string(); + + cmd.cast_fuse(); + cmd.unset_env("ETH_FROM"); + cmd.args(["safe", "simulate", &safe, &safe_tx_hash, "--rpc-url", "http://127.0.0.1:1"]) + .assert_failure() + .stdout_eq("") + .stderr_eq(str![[r#" +error: the following required arguments were not provided: + --from
+ +Usage: cast[..] safe simulate --from
--rpc-url + +For more information, try '--help'. + +"#]]); +} + +#[casttest] +async fn safe_simulation_uses_executor_context(cmd: _) { + let (api, handle) = anvil::spawn(NodeConfig::test()).await; + let rpc = handle.http_endpoint(); + let safe = Address::repeat_byte(0x11); + let executor = Address::repeat_byte(0x33); + let accessor = Address::repeat_byte(0x44); + // Return a zero transaction hash, then require `tx.origin` to be `executor` and return it as + // the simulated call result. + api.anvil_set_code(safe, SIMULATION_SAFE_CODE.parse().unwrap()).await.unwrap(); + api.anvil_set_code(accessor, bytes!("0x00")).await.unwrap(); + let safe_arg = safe.to_string(); + let executor_arg = executor.to_string(); + let accessor_arg = accessor.to_string(); + let safe_tx_hash = B256::ZERO.to_string(); + + for operation in [0, 1] { + let service = spawn_safe_service(safe_transaction(safe, operation)).await; + cmd.cast_fuse() + .args([ + "--json", + "safe", + "simulate", + &safe_arg, + &safe_tx_hash, + "--from", + &executor_arg, + "--accessor", + &accessor_arg, + "--service-url", + service.endpoint(), + "--rpc-url", + &rpc, + ]) + .assert_json_stdout(json_envelope(json!({ + "safeTxHash": B256::ZERO, + "success": true, + "gasUsed": "42", + "returnData": "0x0000000000000000000000003333333333333333333333333333333333333333", + }))); + } +} + +#[casttest] +async fn safe_simulation_rejects_reimbursed_transaction(cmd: _) { + let (api, handle) = anvil::spawn(NodeConfig::test()).await; + let rpc = handle.http_endpoint(); + let safe = Address::repeat_byte(0x11); + let executor = Address::repeat_byte(0x33); + let accessor = Address::repeat_byte(0x44); + api.anvil_set_code(safe, SIMULATION_SAFE_CODE.parse().unwrap()).await.unwrap(); + api.anvil_set_code(accessor, bytes!("0x00")).await.unwrap(); + let mut transaction = safe_transaction(safe, 0); + transaction["gasPrice"] = json!("1"); + let service = spawn_safe_service(transaction).await; + + cmd.cast_fuse() + .args([ + "safe", + "simulate", + &safe.to_string(), + &B256::ZERO.to_string(), + "--from", + &executor.to_string(), + "--accessor", + &accessor.to_string(), + "--service-url", + service.endpoint(), + "--rpc-url", + &rpc, + ]) + .assert_failure() + .stdout_eq("") + .stderr_eq(str![[r#" +Safe transaction: 0x0000000000000000000000000000000000000000000000000000000000000000 + Safe: 0x1111111111111111111111111111111111111111 + To: 0x0000000000000000000000000000000000000000 + Value: 0 + Operation: 0 (CALL) + Safe tx gas: 0 + Base gas: 0 + Gas price: 1 + Gas token: 0x0000000000000000000000000000000000000000 + Refund receiver: 0x0000000000000000000000000000000000000000 + Nonce: 0 + Data: 0x +Error: cannot simulate reimbursed Safe transactions (gasPrice > 0): SimulateTxAccessor does not enforce safeTxGas + +"#]]); +} + +#[casttest] +async fn safe_simulation_call_target_checks_origin_and_sender(cmd: _) { + let (api, handle) = anvil::spawn(NodeConfig::test()).await; + let rpc = handle.http_endpoint(); + let provider = handle.http_provider(); + api.anvil_set_code( + SAFE_L2_V1_4_1, + fixture_runtime( + include_bytes!("../fixtures/safe/v1.4.1/SafeL2.runtime.bin"), + SAFE_L2_V1_4_1_RUNTIME_LEN, + SAFE_L2_V1_4_1_RUNTIME_HASH, + ), + ) + .await + .unwrap(); + api.anvil_set_code( + SAFE_PROXY_FACTORY_V1_4_1, + fixture_runtime( + include_bytes!("../fixtures/safe/v1.4.1/SafeProxyFactory.runtime.bin"), + SAFE_PROXY_FACTORY_V1_4_1_RUNTIME_LEN, + SAFE_PROXY_FACTORY_V1_4_1_RUNTIME_HASH, + ), + ) + .await + .unwrap(); + api.anvil_set_code( + SIMULATE_TX_ACCESSOR_V1_4_1, + fixture_runtime( + include_bytes!("../fixtures/safe/v1.4.1/SimulateTxAccessor.runtime.bin"), + SIMULATE_TX_ACCESSOR_V1_4_1_RUNTIME_LEN, + SIMULATE_TX_ACCESSOR_V1_4_1_RUNTIME_HASH, + ), + ) + .await + .unwrap(); + + let owner_1 = ANVIL_OWNER.to_string(); + let owner_2 = ANVIL_OWNER_2.to_string(); + let create_output = cmd + .cast_fuse() + .args([ + "safe", + "create", + &owner_1, + &owner_2, + "--threshold", + "2", + "--singleton", + &SAFE_L2_V1_4_1.to_string(), + "--factory", + &SAFE_PROXY_FACTORY_V1_4_1.to_string(), + "--fallback-handler", + "0x0000000000000000000000000000000000000000", + "--private-key", + ANVIL_KEY, + "--rpc-url", + &rpc, + ]) + .assert_success() + .get_output() + .stdout_lossy(); + let safe: Address = create_output + .lines() + .rev() + .find_map(|line| line.trim().parse().ok()) + .expect("cast safe create did not print a Safe address"); + + // The target rejects either context mismatch, then returns both context values for the + // simulation assertion. The Safe address is patched into the CALLER comparison after create. + let target = address!("5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed"); + let mut target_code = hex!( + "733c44cdddb6a900fa2b585dd299e03d12fa4293bc321415604157730000000000000000000000000000000000000000331415604157325f523360205260405ff35b5f5ffd" + ); + target_code[28..48].copy_from_slice(safe.as_slice()); + api.anvil_set_code(target, target_code.into()).await.unwrap(); + + let safe_contract = TestSafe::new(safe, &provider); + let safe_tx_hash = safe_contract + .getTransactionHash( + target, + U256::ZERO, + Bytes::new(), + 0, + U256::ZERO, + U256::ZERO, + U256::ZERO, + Address::ZERO, + Address::ZERO, + U256::ZERO, + ) + .call() + .await + .unwrap(); + let mut transaction = safe_transaction(safe, 0); + transaction["to"] = json!(target); + transaction["safeTxHash"] = json!(safe_tx_hash); + let service = spawn_safe_service(transaction).await; + + let executor = ANVIL_OWNER_3; + let mut expected_return_data = Vec::with_capacity(2 * Address::len_bytes() * 2); + expected_return_data.extend_from_slice(executor.into_word().as_slice()); + expected_return_data.extend_from_slice(safe.into_word().as_slice()); + let expected_return_data = hex::encode_prefixed(expected_return_data); + cmd.cast_fuse() + .args([ + "--json", + "safe", + "simulate", + &safe.to_string(), + &safe_tx_hash.to_string(), + "--from", + &executor.to_string(), + "--accessor", + &SIMULATE_TX_ACCESSOR_V1_4_1.to_string(), + "--service-url", + service.endpoint(), + "--rpc-url", + &rpc, + ]) + .assert_json_stdout(json_envelope(json!({ + "safeTxHash": safe_tx_hash, + "success": true, + "gasUsed": "[..]", + "returnData": expected_return_data, + }))); +} diff --git a/crates/cast/tests/cli/selectors.rs b/crates/cast/tests/cli/selectors.rs index d3a7e84b88079..f2efa3ffaeec6 100644 --- a/crates/cast/tests/cli/selectors.rs +++ b/crates/cast/tests/cli/selectors.rs @@ -1,7 +1,8 @@ use foundry_test_utils::util::OutputExt; use std::path::Path; -casttest!(error_decode_with_openchain, |prj, cmd| { +#[casttest] +fn flaky_error_decode_with_openchain(prj: _, cmd: _) { prj.clear_cache(); cmd.args(["decode-error", "0x7a0e198500000000000000000000000000000000000000000000000000000000000000650000000000000000000000000000000000000000000000000000000000000064"]).assert_success().stdout_eq(str![[r#" ValueTooHigh(uint256,uint256) @@ -9,68 +10,76 @@ ValueTooHigh(uint256,uint256) 100 "#]]); -}); +} -casttest!(fourbyte, |_prj, cmd| { +#[casttest] +fn flaky_fourbyte(cmd: _) { cmd.args(["4byte", "0xa9059cbb"]).assert_success().stdout_eq(str![[r#" transfer(address,uint256) "#]]); -}); +} -casttest!(fourbyte_invalid, |_prj, cmd| { +#[casttest] +fn fourbyte_invalid(cmd: _) { cmd.args(["4byte", "0xa9059c"]).assert_failure().stderr_eq(str![[r#" error: invalid value '0xa9059c' for '[SELECTOR]': invalid string length For more information, try '--help'. "#]]); -}); +} -casttest!(fourbyte_calldata, |_prj, cmd| { +#[casttest] +fn flaky_fourbyte_calldata(cmd: _) { cmd.args(["4byte-calldata", "0xa9059cbb0000000000000000000000000a2ac0c368dc8ec680a0c98c907656bd970675950000000000000000000000000000000000000000000000000000000767954a79"]).assert_success().stdout_eq(str![[r#" 1) "transfer(address,uint256)" 0x0A2AC0c368Dc8eC680a0c98C907656BD97067595 31802608249 [3.18e10] "#]]); -}); +} -casttest!(fourbyte_calldata_only_selector, |_prj, cmd| { +#[casttest] +fn flaky_fourbyte_calldata_only_selector(cmd: _) { cmd.args(["4byte-calldata", "0xa9059cbb"]).assert_success().stdout_eq(str![[r#" transfer(address,uint256) "#]]); -}); +} -casttest!(fourbyte_calldata_alias, |_prj, cmd| { +#[casttest] +fn flaky_fourbyte_calldata_alias(cmd: _) { cmd.args(["4byte-decode", "0xa9059cbb0000000000000000000000000a2ac0c368dc8ec680a0c98c907656bd970675950000000000000000000000000000000000000000000000000000000767954a79"]).assert_success().stdout_eq(str![[r#" 1) "transfer(address,uint256)" 0x0A2AC0c368Dc8eC680a0c98C907656BD97067595 31802608249 [3.18e10] "#]]); -}); +} -casttest!(fourbyte_event, |_prj, cmd| { +#[casttest] +fn flaky_fourbyte_event(cmd: _) { cmd.args(["4byte-event", "0x7e1db2a1cd12f0506ecd806dba508035b290666b84b096a87af2fd2a1516ede6"]) .assert_success() .stdout_eq(str![[r#" updateAuthority(address,uint8) "#]]); -}); +} -casttest!(fourbyte_event_2, |_prj, cmd| { +#[casttest] +fn flaky_fourbyte_event_2(cmd: _) { cmd.args(["4byte-event", "0xb7009613e63fb13fd59a2fa4c206a992c1f090a44e5d530be255aa17fed0b3dd"]) .assert_success() .stdout_eq(str![[r#" canCall(address,address,bytes4) "#]]); -}); +} -casttest!(upload_signatures, |_prj, cmd| { +#[casttest] +fn flaky_upload_signatures(cmd: _) { // test no prefix is accepted as function let output = cmd .args(["upload-signature", "transfer(address,uint256)"]) @@ -128,10 +137,66 @@ casttest!(upload_signatures, |_prj, cmd| { "{}", output ); -}); +} + +#[casttest] +fn selectors_json_envelope(cmd: _) { + // bytecode with one function: 0x2125b65b / uint32,address,uint224 / pure + let bytecode = "6080604052348015600e575f80fd5b50600436106026575f3560e01c80632125b65b14602a575b5f80fd5b603a6035366004603c565b505050565b005b5f805f60608486031215604d575f80fd5b833563ffffffff81168114605f575f80fd5b925060208401356001600160a01b03811681146079575f80fd5b915060408401356001600160e01b03811681146093575f80fd5b80915050925092509256"; + + cmd.args(["selectors", bytecode]).assert_success().stdout_eq(str![[r#" +0x2125b65b uint32,address,uint224 pure + +"#]]); + + cmd.args(["--json"]).assert_success().stdout_eq(str![[r#" +{"schema_version":1,"success":true,"data":[{"selector":"0x2125b65b","arguments":"uint32,address,uint224","state_mutability":"pure"}],"errors":[],"warnings":[]} + +"#]]); +} + +#[casttest] +fn selectors_exclude_fallback_dispatch(cmd: _) { + // Bytecode with ABI-dispatched selector 0x11111111 and fallback-dispatched selector 0x22222222. + let bytecode = "5f3560e01c806322222222146025576004361060215780631111111114602357005b005b005b00"; + + cmd.args(["selectors", bytecode]).assert_success().stdout_eq(str![[r#" +0x11111111 payable + +"#]]); +} + +#[casttest] +fn abi_encode_event_json_envelope(cmd: _) { + cmd.args([ + "abi-encode-event", + "Transfer(address indexed,address indexed,uint256)", + "0x0000000000000000000000000000000000000001", + "0x0000000000000000000000000000000000000002", + "1000", + ]) + .assert_success() + .stdout_eq(str![[r#" +[topic0]: 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef +[topic1]: 0x0000000000000000000000000000000000000000000000000000000000000001 +[topic2]: 0x0000000000000000000000000000000000000000000000000000000000000002 +[data]: 0x00000000000000000000000000000000000000000000000000000000000003e8 + +"#]]); + + // --json must precede the subcommand because `args` uses allow_hyphen_values + cmd.cast_fuse() + .args(["--json", "abi-encode-event", "Transfer(address indexed,address indexed,uint256)", "0x0000000000000000000000000000000000000001", "0x0000000000000000000000000000000000000002", "1000"]) + .assert_success() + .stdout_eq(str![[r#" +{"schema_version":1,"success":true,"data":{"topics":["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef","0x0000000000000000000000000000000000000000000000000000000000000001","0x0000000000000000000000000000000000000000000000000000000000000002"],"data":"0x00000000000000000000000000000000000000000000000000000000000003e8"},"errors":[],"warnings":[]} + +"#]]); +} // tests cast can decode event with provided signature -casttest!(event_decode_with_sig, |_prj, cmd| { +#[casttest] +fn event_decode_with_sig(cmd: _) { cmd.args(["decode-event", "--sig", "MyEvent(uint256,address)", "0x000000000000000000000000000000000000000000000000000000000000004e0000000000000000000000000000000000000000000000000000000000d0004f"]).assert_success().stdout_eq(str![[r#" 78 0x0000000000000000000000000000000000D0004F @@ -139,16 +204,37 @@ casttest!(event_decode_with_sig, |_prj, cmd| { "#]]); cmd.args(["--json"]).assert_success().stdout_eq(str![[r#" -[ - "78", - "0x0000000000000000000000000000000000D0004F" -] +{"schema_version":1,"success":true,"data":["78","0x0000000000000000000000000000000000D0004F"],"errors":[],"warnings":[]} "#]]); -}); +} + +#[casttest] +fn function_pointer_in_event_tuple(cmd: _) { + let signature = "ActionLogged((uint256,function))"; + let function = "0x29088eeb3082c897bebd16bbafc162322cbb1bf47cfdab90"; + let action = format!("(1337,{function})"); + let data = "0x000000000000000000000000000000000000000000000000000000000000053929088eeb3082c897bebd16bbafc162322cbb1bf47cfdab900000000000000000"; + + cmd.args(["abi-encode-event", signature, &action]) + .assert_success() + .stdout_eq(str![[r#" +[topic0]: 0x413ec73c547fcf364943e3f9182965c6662c9bb75c94568d39ebb9f66d2cff4b +[data]: 0x000000000000000000000000000000000000000000000000000000000000053929088eeb3082c897bebd16bbafc162322cbb1bf47cfdab900000000000000000 + +"#]]); + + cmd.cast_fuse().args(["decode-event", "--sig", signature, data]).assert_success().stdout_eq( + str![[r#" +(1337, 0x29088eeb3082c897bebd16bbafc162322cbb1bf47cfdab90) + +"#]], + ); +} // tests cast can decode event with Openchain API -casttest!(event_decode_with_openchain, |prj, cmd| { +#[casttest] +fn flaky_event_decode_with_openchain(prj: _, cmd: _) { prj.clear_cache(); cmd.args(["decode-event", "0xe27c4c1372396a3d15a9922f74f9dfc7c72b1ad6d63868470787249c356454c1000000000000000000000000000000000000000000000000000000000000004e00000000000000000000000000000000000000000000000000000dd00000004e"]).assert_success().stdout_eq(str![[r#" BaseCurrencySet(address,uint256) @@ -156,10 +242,11 @@ BaseCurrencySet(address,uint256) 15187004358734 [1.518e13] "#]]); -}); +} // tests cast can decode error with provided signature -casttest!(error_decode_with_sig, |_prj, cmd| { +#[casttest] +fn error_decode_with_sig(cmd: _) { cmd.args(["decode-error", "--sig", "AnotherValueTooHigh(uint256,address)", "0x7191bc6200000000000000000000000000000000000000000000000000000000000000650000000000000000000000000000000000000000000000000000000000D0004F"]).assert_success().stdout_eq(str![[r#" 101 0x0000000000000000000000000000000000D0004F @@ -167,16 +254,14 @@ casttest!(error_decode_with_sig, |_prj, cmd| { "#]]); cmd.args(["--json"]).assert_success().stdout_eq(str![[r#" -[ - "101", - "0x0000000000000000000000000000000000D0004F" -] +{"schema_version":1,"success":true,"data":["101","0x0000000000000000000000000000000000D0004F"],"errors":[],"warnings":[]} "#]]); -}); +} // tests cast can decode error and event when using local sig identifiers cache -forgetest_init!(error_event_decode_with_cache, |prj, cmd| { +#[forgetest_init] +fn flaky_error_event_decode_with_cache(prj: _, cmd: _) { prj.add_source( "LocalProjectContract", r#" @@ -185,9 +270,9 @@ contract ContractWithCustomError { event MyUniqueEventWithinLocalProject(uint256 a, address b); } "#, - ) - .unwrap(); - // Store selectors in local cache. + ); + // Build and cache project selectors. + cmd.forge_fuse().args(["build"]).assert_success(); cmd.forge_fuse().args(["selectors", "cache"]).assert_success(); // Assert cast can decode custom error with local cache. @@ -210,4 +295,49 @@ MyUniqueEventWithinLocalProject(uint256,address) 0x00000000000000000000000000000DD00000004e "#]]); -}); +} + +#[forgetest] +fn flaky_cache_selectors_from_extra_abis(prj: _, cmd: _) { + // Create folder with ABI JSON files containing a unique error + let abis_dir = prj.root().join("external_abis"); + std::fs::create_dir(&abis_dir).unwrap(); + std::fs::write( + abis_dir.join("test.json"), + r#"[{ + "type": "error", + "name": "MyUniqueExtraAbiError", + "inputs": [ + {"name": "value", "type": "uint256"}, + {"name": "flag", "type": "bool"} + ] + }]"#, + ) + .unwrap(); + + cmd.forge_fuse() + .args(["selectors", "cache", "--extra-abis-path", abis_dir.to_str().unwrap()]) + .assert_success(); + + // Verify with cast decode-error (uses local cache via SignaturesIdentifier) + // Selector for MyUniqueExtraAbiError(uint256,bool) is 0x7819b107 + // Encoded: selector + uint256(42) + bool(true) + cmd.cast_fuse() + .args(["decode-error", "0x7819b107000000000000000000000000000000000000000000000000000000000000002a0000000000000000000000000000000000000000000000000000000000000001"]) + .assert_success() + .stdout_eq(str![[r#" +MyUniqueExtraAbiError(uint256,bool) +42 +true + +"#]]); +} + +#[casttest] +fn signature_selector(cmd: _) { + cmd.cast_fuse().args(["sig", "foo()"]).assert_success().stdout_eq("0xc2985578\n"); + cmd.cast_fuse() + .args(["sig", "foo(address,uint256)"]) + .assert_success() + .stdout_eq("0xbd0d639f\n"); +} diff --git a/crates/cast/tests/cli/send.rs b/crates/cast/tests/cli/send.rs new file mode 100644 index 0000000000000..1fb1517f7c178 --- /dev/null +++ b/crates/cast/tests/cli/send.rs @@ -0,0 +1,818 @@ +//! CLI tests for send commands. + +use super::*; +use std::{process::Stdio, time::Duration}; + +#[casttest] +async fn send_rejects_invalid_eip1559_fees_before_access_list(cmd: _) { + let (_api, handle) = anvil::spawn(NodeConfig::test()).await; + let rpc = handle.http_endpoint(); + let wallet = handle.dev_wallets().next().unwrap(); + let pk = hex::encode(wallet.credential().to_bytes()); + + let stderr = cmd + .cast_fuse() + .args([ + "send", + "0x0000000000000000000000000000000000000001", + "--rpc-url", + rpc.as_str(), + "--private-key", + pk.as_str(), + "--access-list", + "--gas-price", + "1", + "--priority-gas-price", + "2", + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + + assert!( + stderr.contains("Error: max priority fee per gas (2) cannot exceed max fee per gas (1)"), + "{stderr}" + ); +} + +// ensure receipt or code is required +#[casttest] +fn send_requires_to(cmd: _) { + cmd.args([ + "send", + "--private-key", + "0x0000000000000000000000000000000000000000000000000000000000000001", + "--chain", + "1", + ]); + cmd.assert_failure().stderr_eq(str![[r#" +Error: Must specify a recipient address or contract code to deploy + +"#]]); +} + +// +#[casttest] +fn send_7702_conflicts_with_create(cmd: _) { + cmd.args([ + "send", "--private-key", "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" ,"--auth", "0xf85c827a6994f39fd6e51aad88f6f4ce6ab8827279cfffb922668001a03e1a66234e71242afcc7bc46c8950c3b2997b102db257774865f1232d2e7bf48a045e252dad189b27b2306792047745eba86bff0dd18aca813dbf3fba8c4e94576", "--create", "0x60806040523373ffffffffffffffffffffffffffffffffffffffff163273ffffffffffffffffffffffffffffffffffffffff1614610072576040517f08c379a0000000000000000000000000000000000000000000000000000000008152600401610069906100e5565b60405180910390fd5b3373ffffffffffffffffffffffffffffffffffffffff16ff5b5f82825260208201905092915050565b7f74782e6f726967696e203d3d206d73672e73656e6465720000000000000000005f82015250565b5f6100cf60178361008b565b91506100da8261009b565b602082019050919050565b5f6020820190508181035f8301526100fc816100c3565b905091905056fe" + ]); + cmd.assert_failure().stderr_eq(str![[r#" +Error: EIP-7702 transactions can't be CREATE transactions and require a destination address + +"#]]); +} + +#[casttest] +async fn send_eip7702(cmd: _) { + let (_api, handle) = + anvil::spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Prague.into()))).await; + let endpoint = handle.http_endpoint(); + + cmd.args([ + "send", + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "--auth", + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--gas-limit", + "100000", + "--rpc-url", + &endpoint, + ]) + .assert_success() + .stderr_eq(str![""]); + + cmd.cast_fuse() + .args(["code", "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", "--rpc-url", &endpoint]) + .assert_success() + .stdout_eq(str![[r#" +0xef010070997970c51812dc3a010c7d01b50e0d17dc79c8 + +"#]]); +} + +#[casttest] +async fn send_eip7702_without_recipient_targets_sender(cmd: _) { + let (_api, handle) = + anvil::spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Prague.into()))).await; + let endpoint = handle.http_endpoint(); + + cmd.args([ + "send", + "--auth", + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--gas-limit", + "100000", + "--rpc-url", + &endpoint, + ]) + .assert_success() + .stdout_eq(str![[r#" +... +from 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 +... +status 1 (success) +... +type 4 +... +to 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 +... +"#]]) + .stderr_eq(str![""]); + + cmd.cast_fuse() + .args(["code", "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", "--rpc-url", &endpoint]) + .assert_success() + .stdout_eq(str![[r#" +0xef010070997970c51812dc3a010c7d01b50e0d17dc79c8 + +"#]]); +} + +#[casttest] +fn send_eip7702_auth_disclosure_declined(cmd: _) { + cmd.args([ + "send", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "--auth", + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--chain", + "31337", + "--rpc-url", + "http://127.0.0.1:1", + ]) + .stdin("n\n") + .assert_success() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Warning: This command will send a signed EIP-7702 authorization to the RPC endpoint. The authorization can be submitted on-chain by anyone once its nonce is valid. + +Continue anyway? [y/N] Aborted. + +"#]]); +} + +#[casttest] +async fn send_eip7702_auth_disclosure_forced(cmd: _) { + let (_api, handle) = + anvil::spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Prague.into()))).await; + + cmd.args([ + "send", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "--auth", + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--force", + "--async", + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success() + .stdout_eq(str![[r#" +0x[..] + +"#]]) + .stderr_eq(str![""]); +} + +#[casttest] +async fn send_sponsor_hash_supports_address_auth(cmd: _) { + let (_api, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + + cmd.args([ + "send", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "--auth", + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--force", + "--tempo.print-sponsor-hash", + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success() + .stdout_eq(str![[r#" +0x[..] + +"#]]) + .stderr_eq(str![""]); +} + +#[casttest] +async fn batch_send_eip7702_auth_disclosure(cmd: _) { + let args = [ + "batch-send", + "--call", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "--auth", + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + ]; + + cmd.args(args) + .args(["--chain", "31337", "--rpc-url", "http://127.0.0.1:1"]) + .stdin("n\n") + .assert_success() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Building batch transaction with 1 call(s)... +Warning: This command will send a signed EIP-7702 authorization to the RPC endpoint. The authorization can be submitted on-chain by anyone once its nonce is valid. + +Continue anyway? [y/N] Aborted. + +"#]]); + + let (_api, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + cmd.cast_fuse() + .args(args) + .args(["--force", "--async", "--rpc-url", &handle.http_endpoint()]) + .assert_success() + .stdout_eq(str![[r#" +0x[..] + +"#]]) + .stderr_eq(str![[r#" +Building batch transaction with 1 call(s)... +Paying gas in AlphaUSD (0x20C0000000000000000000000000000000000001) + +"#]]); +} + +#[casttest] +async fn send_eip7702_multiple_auth(cmd: _) { + let (_api, handle) = + anvil::spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Prague.into()))).await; + let endpoint = handle.http_endpoint(); + + // Create a pre-signed authorization using a different signer (account index 1) + let signer: PrivateKeySigner = + "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d".parse().unwrap(); + // Anvil default chain_id is 31337 + let auth = Authorization { + chain_id: U256::from(31337), + // Delegate to account index 2 + address: address!("0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"), + nonce: 0, + }; + let signature = signer.sign_hash(&auth.signature_hash()).await.unwrap(); + let signed_auth = auth.into_signed(signature); + let encoded_auth = hex::encode_prefixed(alloy_rlp::encode(&signed_auth)); + + // Send transaction with multiple --auth flags: one address and one pre-signed authorization + let output = cmd + .args([ + "send", + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "--auth", + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "--auth", + &encoded_auth, + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--rpc-url", + &endpoint, + "--gas-limit", + "100000", + "--json", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + // Extract transaction hash from JSON output + let json: serde_json::Value = serde_json::from_str(&output).unwrap(); + let tx_hash = json["transactionHash"].as_str().unwrap(); + + // Use cast tx to verify multiple authorizations were included + let tx_output = cmd + .cast_fuse() + .args(["tx", tx_hash, "--rpc-url", &endpoint, "--json"]) + .assert_success() + .get_output() + .stdout_lossy(); + + let tx_envelope: serde_json::Value = serde_json::from_str(&tx_output).unwrap(); + let auth_list = tx_envelope["data"]["authorizationList"].as_array().unwrap(); + + // Verify we have 2 authorizations + assert_eq!(auth_list.len(), 2, "Expected 2 authorizations in the transaction"); + + let field_output = cmd + .cast_fuse() + .args(["tx", tx_hash, "authorizationList", "--rpc-url", &endpoint, "--json"]) + .assert_success() + .get_output() + .stdout_lossy(); + + let field_envelope: serde_json::Value = serde_json::from_str(field_output.trim()).unwrap(); + let field_auth_list = field_envelope["data"].as_array().unwrap(); + assert_eq!(field_auth_list.len(), 2, "Expected authorizationList field data to be an array"); +} + +// Test that multiple address-based authorizations are rejected +#[casttest] +async fn send_eip7702_multiple_address_auth_rejected(cmd: _) { + let (_api, handle) = + anvil::spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Prague.into()))).await; + let endpoint = handle.http_endpoint(); + + cmd.args([ + "send", + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "--auth", + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "--auth", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--rpc-url", + &endpoint, + ]); + cmd.assert_failure().stderr_eq(str![[r#" +Error: Multiple address-based authorizations provided. Only one address can be specified; use pre-signed authorizations (hex-encoded) for multiple authorizations. + +"#]]); +} + +#[casttest] +async fn send_sync(cmd: _) { + let (_api, handle) = anvil::spawn(NodeConfig::test()).await; + let endpoint = handle.http_endpoint(); + + let output = cmd + .args([ + "send", + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "--value", + "1", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--rpc-url", + &endpoint, + "--sync", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + assert!(output.contains("transactionHash")); + assert!(output.contains("blockNumber")); + assert!(output.contains("gasUsed")); +} + +// Concurrent `cast send --async` processes whose txs do not fit into one block must all be mined. +#[casttest] +async fn send_async_burst_is_mined_across_full_blocks(prj: _) { + // Room for exactly three transfers per block. + let (api, handle) = anvil::spawn(NodeConfig::test().with_gas_limit(Some(63_000))).await; + let endpoint = handle.http_endpoint(); + let wallets = handle.dev_wallets().take(3).collect::>(); + + let mut children = Vec::new(); + for wallet in &wallets { + let private_key = hex::encode(wallet.credential().to_bytes()); + for nonce in 0..3 { + let child = prj + .cast_bin() + .args([ + "send", + "0x000000000000000000000000000000000000dEaD", + "--value", + "1", + "--nonce", + &nonce.to_string(), + "--gas-limit", + "21000", + "--private-key", + &private_key, + "--rpc-url", + &endpoint, + "--async", + ]) + .stdout(Stdio::null()) + .spawn() + .unwrap(); + children.push(child); + } + } + for mut child in children { + assert!(child.wait().unwrap().success()); + } + + let provider = ProviderBuilder::new().connect_http(endpoint.parse().unwrap()); + tokio::time::timeout(Duration::from_secs(10), async { + for wallet in &wallets { + while provider.get_transaction_count(wallet.address()).await.unwrap() < 3 { + tokio::time::sleep(Duration::from_millis(50)).await; + } + } + }) + .await + .expect("txs left behind by a full block were never mined"); + + assert!(provider.get_block_number().await.unwrap() >= 3); + assert_eq!(api.txpool_status().await.unwrap().pending, 0); +} + +// tests cast send gas estimate execution failure message contains decoded custom error +// +#[forgetest_init] +async fn cast_send_estimate_gas_error(prj: _, cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + + prj.add_source( + "SimpleStorage", + r#" +contract SimpleStorage { + uint256 private storedValue; + error AddressInsufficientBalance(address account, uint256 newValue); + function setValue(uint256 _newValue) public { + if (_newValue > 100) { + revert AddressInsufficientBalance(msg.sender, _newValue); + } + storedValue = _newValue; + } +} + "#, + ); + prj.add_script( + "SimpleStorageScript", + r#" +import "forge-std/Script.sol"; +import {SimpleStorage} from "../src/SimpleStorage.sol"; +contract SimpleStorageScript is Script { + function run() public { + vm.startBroadcast(); + new SimpleStorage(); + vm.stopBroadcast(); + } +} + "#, + ); + + cmd.args([ + "script", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--rpc-url", + &handle.http_endpoint(), + "--broadcast", + "SimpleStorageScript", + ]) + .assert_success(); + + // Cache project selectors. + cmd.forge_fuse().set_current_dir(prj.root()); + cmd.forge_fuse().args(["selectors", "cache"]).assert_success(); + + // Assert cast send can decode custom error on estimate gas execution failure. + cmd.cast_fuse() + .args([ + "send", + "0x5FbDB2315678afecb367f032d93F642f64180aa3", + "setValue(uint256)", + "1000", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_failure().stderr_eq(str![[r#" +Error: Failed to estimate gas: server returned an error response: error code 3: execution reverted: custom error 0x6786ad34: 000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb9226600000000000000000000000000000000000000000000000000000000000003e8, data: "0x6786ad34000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb9226600000000000000000000000000000000000000000000000000000000000003e8"[..] + +"#]]); +} + +// Test that cast send --create works correctly with constructor arguments +// +#[forgetest] +async fn cast_send_create_with_constructor_args(prj: _, cmd: _) { + let (_api, handle) = anvil::spawn(NodeConfig::test()).await; + let endpoint = handle.http_endpoint(); + + // Deploy a simple contract with constructor arguments + // Contract source that takes constructor args + prj.add_source( + "ConstructorContract", + r#" +contract ConstructorContract { + uint256 public value; + string public name; + + constructor(uint256 _value, string memory _name) { + value = _value; + name = _name; + } + + function getValue() public view returns (uint256) { + return value; + } +} +"#, + ); + + // Compile to get bytecode + cmd.forge_fuse().args(["build"]).assert_success(); + + // Get the compiled bytecode + let bytecode_path = prj.root().join("out/ConstructorContract.sol/ConstructorContract.json"); + let contract_json = std::fs::read_to_string(bytecode_path).unwrap(); + let contract_data: serde_json::Value = serde_json::from_str(&contract_json).unwrap(); + let bytecode = contract_data["bytecode"]["object"].as_str().unwrap(); + + // Use cast send --create with constructor arguments + let output = cmd + .cast_fuse() + .args([ + "send", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--rpc-url", + &endpoint, + "--create", + bytecode, + "constructor(uint256,string)", + "42", + "TestContract", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + // Extract the deployed contract address from output + let lines: Vec<&str> = output.lines().collect(); + let mut address = None; + for line in lines { + if line.contains("contractAddress") { + let parts: Vec<&str> = line.split_whitespace().collect(); + address = Some(parts[1]); + break; + } + } + let address = address.expect("Contract address not found in output"); + + // Verify the contract was deployed correctly by calling getValue() + let value_output = cmd + .cast_fuse() + .args(["call", address, "getValue()", "--rpc-url", &endpoint]) + .assert_success() + .get_output() + .stdout_lossy(); + + // The value should be 42 (0x2a in hex) + assert!( + value_output.contains("0x000000000000000000000000000000000000000000000000000000000000002a") + ); + + cmd.cast_fuse() + .args(["--json", "call", address, "getValue()(uint256)", "--rpc-url", &endpoint]) + .assert_success() + .stdout_eq(str![[r#" +[ + "42" +] + +"#]]); +} + +// Test edge case: empty constructor arguments +// +#[forgetest] +async fn cast_send_create_empty_constructor(prj: _, cmd: _) { + let (_api, handle) = anvil::spawn(NodeConfig::test()).await; + let endpoint = handle.http_endpoint(); + + // Simple contract with no constructor arguments + prj.add_source( + "SimpleContract", + r#" +contract SimpleContract { + uint256 public constant VALUE = 42; +} +"#, + ); + + // Compile + cmd.forge_fuse().args(["build"]).assert_success(); + + // Get bytecode + let bytecode_path = prj.root().join("out/SimpleContract.sol/SimpleContract.json"); + let contract_json = std::fs::read_to_string(bytecode_path).unwrap(); + let contract_data: serde_json::Value = serde_json::from_str(&contract_json).unwrap(); + let bytecode = contract_data["bytecode"]["object"].as_str().unwrap(); + + // Deploy with empty constructor + let output = cmd + .cast_fuse() + .args([ + "send", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--rpc-url", + &endpoint, + "--create", + bytecode, + "constructor()", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + // Verify deployment succeeded + assert!(output.contains("contractAddress")); +} + +// Test complex constructor arguments (multiple types) +// +#[forgetest] +async fn cast_send_create_complex_constructor(prj: _, cmd: _) { + let (_api, handle) = anvil::spawn(NodeConfig::test()).await; + let endpoint = handle.http_endpoint(); + + // Contract with complex constructor + prj.add_source( + "ComplexContract", + r#" +contract ComplexContract { + address public owner; + uint256[] public values; + bool public active; + + constructor(address _owner, uint256[] memory _values, bool _active) { + owner = _owner; + values = _values; + active = _active; + } + + function getValuesLength() public view returns (uint256) { + return values.length; + } +} +"#, + ); + + // Compile + cmd.forge_fuse().args(["build"]).assert_success(); + + // Get bytecode + let bytecode_path = prj.root().join("out/ComplexContract.sol/ComplexContract.json"); + let contract_json = std::fs::read_to_string(bytecode_path).unwrap(); + let contract_data: serde_json::Value = serde_json::from_str(&contract_json).unwrap(); + let bytecode = contract_data["bytecode"]["object"].as_str().unwrap(); + + // Deploy with complex arguments + let output = cmd + .cast_fuse() + .args([ + "send", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--rpc-url", + &endpoint, + "--create", + bytecode, + "constructor(address,uint256[],bool)", + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "[1,2,3,4,5]", + "true", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + // Extract deployed address + let lines: Vec<&str> = output.lines().collect(); + let mut address = None; + for line in lines { + if line.contains("contractAddress") { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 2 { + address = Some(parts[1]); + break; + } + } + } + let address = address.expect("Contract address not found in output"); + + // Verify the array length was set correctly + let length_output = cmd + .cast_fuse() + .args(["call", address, "getValuesLength()", "--rpc-url", &endpoint]) + .assert_success() + .get_output() + .stdout_lossy(); + + // Should return 5 (0x5 in hex) + assert!( + length_output + .contains("0x0000000000000000000000000000000000000000000000000000000000000005") + ); +} + +// Test cast send with raw --data flag using encoded calldata +#[forgetest_init] +async fn cast_send_with_data(prj: _, cmd: _) { + let (api, handle) = anvil::spawn(NodeConfig::test()).await; + + prj.initialize_default_contracts(); + + // Deploy counter contract + cmd.args([ + "script", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--rpc-url", + &handle.http_endpoint(), + "--broadcast", + "CounterScript", + ]) + .assert_success(); + + // setNumber(111) encoded: selector 0x3fb5c1cb + uint256(111) + let calldata = "0x3fb5c1cb000000000000000000000000000000000000000000000000000000000000006f"; + + // Send tx using --data instead of sig+args + cmd.cast_fuse() + .args([ + "send", + "0x5FbDB2315678afecb367f032d93F642f64180aa3", + "--data", + calldata, + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success(); + + // Verify via trace that setNumber(111) was called + let tx_hash = api + .transaction_by_block_number_and_index(BlockNumberOrTag::Latest, Index::from(0)) + .await + .unwrap() + .unwrap() + .tx_hash(); + + cmd.cast_fuse() + .args([ + "run", + format!("{tx_hash}").as_str(), + "-vvvvv", + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success() + .stdout_eq(str![[r#" +Traces: + [..] 0x5FbDB2315678afecb367f032d93F642f64180aa3::setNumber(111) + ├─ storage changes: + │ @ 0: 0 → 111 + └─ ← [Stop] + + +Transaction successfully executed. +[GAS] + +"#]]) + .stderr_eq(str![[r#" +... +Executing previous transactions from the block. +... + +"#]]); +} + +#[casttest] +async fn publish_raw_transaction(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + let signer = handle.dev_wallets().next().unwrap(); + let raw = cmd + .args([ + "mktx", + "0x0000000000000000000000000000000000000001", + "--private-key", + &hex::encode(signer.to_bytes()), + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success() + .get_output() + .stdout_lossy() + .trim() + .to_string(); + let hash = keccak256(hex::decode(&raw).unwrap()); + cmd.cast_fuse() + .args(["publish", &raw, "--async", "--rpc-url", &handle.http_endpoint()]) + .assert_success() + .stdout_eq(format!("{hash}\n")); +} diff --git a/crates/cast/tests/cli/source.rs b/crates/cast/tests/cli/source.rs new file mode 100644 index 0000000000000..08071040b8d77 --- /dev/null +++ b/crates/cast/tests/cli/source.rs @@ -0,0 +1,634 @@ +//! CLI tests for source commands. + +use super::*; +use alloy_primitives::map::HashMap; +use axum::{Json, Router, extract::Query, routing::get}; +use foundry_config::Config; +use foundry_test_utils::TestCommand; + +// tests that `cast interface` excludes the constructor +// +#[casttest] +fn interface_no_constructor(prj: _, cmd: _) { + let interface = include_str!("../fixtures/interface.json"); + + let path = prj.root().join("interface.json"); + fs::write(&path, interface).unwrap(); + // Call `cast find-block` + cmd.arg("interface").arg(&path).assert_success().stdout_eq(str![[ + r#"// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.4; + +library IIntegrationManager { + type SpendAssetsHandleType is uint8; +} + +interface Interface { + function getIntegrationManager() external view returns (address integrationManager_); + function lend(address _vaultProxy, bytes memory, bytes memory _assetData) external; + function parseAssetsForAction(address, bytes4 _selector, bytes memory _actionData) + external + view + returns ( + IIntegrationManager.SpendAssetsHandleType spendAssetsHandleType_, + address[] memory spendAssets_, + uint256[] memory spendAssetAmounts_, + address[] memory incomingAssets_, + uint256[] memory minIncomingAssetAmounts_ + ); + function redeem(address _vaultProxy, bytes memory, bytes memory _assetData) external; +} + +"# + ]]); +} + +// tests that `cast interface --flatten` inlines inherited struct types into the interface +// +#[casttest] +fn interface_flatten(prj: _, cmd: _) { + let interface = include_str!("../fixtures/interface_inherited_struct.json"); + + let path = prj.root().join("interface_inherited_struct.json"); + fs::write(&path, interface).unwrap(); + + // Without --flatten, a separate library is generated for the struct + cmd.arg("interface").arg(&path).assert_success().stdout_eq(str![[ + r#"// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.4; + +library IBase { + struct TestStruct { + address asset; + } +} + +interface Interface { + function test(IBase.TestStruct memory param) external; +} + +"# + ]]); + + // With --flatten, the struct is inlined into the interface + cmd.cast_fuse().arg("interface").arg("--flatten").arg(&path).assert_success().stdout_eq(str![ + [r#"// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.4; + +interface Interface { + // Types from `IBase` + struct TestStruct { + address asset; + } + + function test(TestStruct memory param) external; +} + +"#] + ]); +} + +#[casttest] +fn interface_with_function_pointer_in_struct(prj: _, cmd: _) { + let abi = r#"[ + { + "anonymous": false, + "inputs": [ + { + "components": [ + {"internalType": "uint256", "name": "id", "type": "uint256"}, + { + "internalType": "function (uint256) external", + "name": "callback", + "type": "function" + } + ], + "indexed": false, + "internalType": "struct StructWithFunctionEvent.Action", + "name": "action", + "type": "tuple" + } + ], + "name": "ActionLogged", + "type": "event" + } + ]"#; + let path = prj.root().join("function_event_abi.json"); + fs::write(&path, abi).unwrap(); + + cmd.arg("interface") + .arg(&path) + .arg("--name") + .arg("StructWithFunctionEvent") + .assert_success() + .stdout_eq(str![[r#" +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.4; + +interface StructWithFunctionEvent { + struct Action { + uint256 id; + function(uint256) external callback; + } + + event ActionLogged(Action action); +} + +"#]]); +} + +#[casttest] +fn interface_local_contract_does_not_write_artifacts(prj: _, cmd: _) { + foundry_test_utils::util::initialize(prj.root()); + prj.add_source( + "InterfaceTarget", + r#" +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +contract InterfaceTarget { + event ValueSet(uint256 value); + + function setValue(uint256 value) external { + emit ValueSet(value); + } +} + "#, + ); + + let source = prj.root().join("src/InterfaceTarget.sol"); + let artifact = prj.root().join("out/InterfaceTarget.sol/InterfaceTarget.json"); + let output = + cmd.cast_fuse().arg("interface").arg(&source).assert_success().get_output().stdout_lossy(); + assert!(output.contains("interface InterfaceTarget"), "{output}"); + assert!(output.contains("event ValueSet(uint256 value);"), "{output}"); + assert!(!artifact.exists()); + + fs::create_dir_all(artifact.parent().unwrap()).unwrap(); + fs::write(&artifact, b"sentinel").unwrap(); + + cmd.cast_fuse().arg("interface").arg(&source).assert_success(); + let after = fs::read(&artifact).unwrap(); + assert_eq!(after, b"sentinel"); +} + +// tests that fetches WETH interface from etherscan +// +#[casttest] +fn flaky_fetch_weth_interface_from_etherscan(cmd: _) { + cmd.args([ + "interface", + "--etherscan-api-key", + &next_etherscan_api_key(), + "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", + ]) + .assert_success() + .stdout_eq(str![[r#" +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.4; + +interface WETH9 { + event Approval(address indexed src, address indexed guy, uint256 wad); + event Deposit(address indexed dst, uint256 wad); + event Transfer(address indexed src, address indexed dst, uint256 wad); + event Withdrawal(address indexed src, uint256 wad); + + fallback() external payable; + + function allowance(address, address) external view returns (uint256); + function approve(address guy, uint256 wad) external returns (bool); + function balanceOf(address) external view returns (uint256); + function decimals() external view returns (uint8); + function deposit() external payable; + function name() external view returns (string memory); + function symbol() external view returns (string memory); + function totalSupply() external view returns (uint256); + function transfer(address dst, uint256 wad) external returns (bool); + function transferFrom(address src, address dst, uint256 wad) external returns (bool); + function withdraw(uint256 wad) external; +} + +"#]]); +} + +// tests that fetches a sample contract creation code +// +#[casttest] +fn flaky_fetch_creation_code_from_etherscan(cmd: _) { + let eth_rpc_url = next_http_rpc_endpoint(); + cmd.args([ + "creation-code", + "--etherscan-api-key", + &next_etherscan_api_key(), + "0x0923cad07f06b2d0e5e49e63b8b35738d4156b95", + "--rpc-url", + eth_rpc_url.as_str(), + ]) + .assert_success() + .stdout_eq(str![[r#" +0x60566050600b82828239805160001a6073146043577f4e487b7100000000000000000000000000000000000000000000000000000000600052600060045260246000fd5b30600052607381538281f3fe73000000000000000000000000000000000000000030146080604052600080fdfea264697066735822122074c61e8e4eefd410ca92eec26e8112ec6e831d0a4bf35718fdd78b45d68220d064736f6c63430008070033 + +"#]]); +} + +// tests that fetches a sample contract creation args bytes +// +#[casttest] +fn flaky_fetch_creation_code_only_args_from_etherscan(cmd: _) { + let eth_rpc_url = next_http_rpc_endpoint(); + cmd.args([ + "creation-code", + "--etherscan-api-key", + &next_etherscan_api_key(), + "0x6982508145454ce325ddbe47a25d4ec3d2311933", + "--rpc-url", + eth_rpc_url.as_str(), + "--only-args", + ]) + .assert_success() + .stdout_eq(str![[r#" +0x00000000000000000000000000000000000014bddab3e51a57cff87a50000000 + +"#]]); +} + +// tests that displays a sample contract creation args +// +#[casttest] +fn flaky_fetch_constructor_args_from_etherscan(cmd: _) { + let eth_rpc_url = next_http_rpc_endpoint(); + cmd.args([ + "constructor-args", + "--etherscan-api-key", + &next_etherscan_api_key(), + "0x6982508145454ce325ddbe47a25d4ec3d2311933", + "--rpc-url", + eth_rpc_url.as_str(), + ]) + .assert_success() + .stdout_eq(str![[r#" +0x00000000000000000000000000000000000014bddab3e51a57cff87a50000000 → Uint(420690000000000000000000000000000, 256) + +"#]]); +} + +// tests that displays a sample contract artifact +// +#[casttest] +fn flaky_fetch_artifact_from_etherscan(cmd: _) { + let eth_rpc_url = next_http_rpc_endpoint(); + cmd.args([ + "artifact", + "--etherscan-api-key", + &next_etherscan_api_key(), + "0x0923cad07f06b2d0e5e49e63b8b35738d4156b95", + "--rpc-url", + eth_rpc_url.as_str(), + ]) + .assert_success() + .stdout_eq(str![[r#"{ + "abi": [], + "bytecode": { + "object": "0x60566050600b82828239805160001a6073146043577f4e487b7100000000000000000000000000000000000000000000000000000000600052600060045260246000fd5b30600052607381538281f3fe73000000000000000000000000000000000000000030146080604052600080fdfea264697066735822122074c61e8e4eefd410ca92eec26e8112ec6e831d0a4bf35718fdd78b45d68220d064736f6c63430008070033" + } +} + +"#]]); +} + +// +#[casttest] +fn flaky_fetch_src_blockscout(cmd: _) { + let url = "https://eth.blockscout.com/api"; + + let weth = address!("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"); + + cmd.args([ + "source", + &weth.to_string(), + "--chain-id", + "1", + "--explorer-api-url", + url, + "--flatten", + ]) + .assert_success() + .stdout_eq(str![[r#" +... +contract WETH9 { + string public name = "Wrapped Ether"; + string public symbol = "WETH"; + uint8 public decimals = 18; +..."#]]); +} + +#[casttest] +fn flaky_fetch_src_default(cmd: _) { + let weth = address!("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"); + let etherscan_api_key = next_etherscan_api_key(); + + cmd.args(["source", &weth.to_string(), "--flatten", "--etherscan-api-key", ðerscan_api_key]) + .assert_success() + .stdout_eq(str![[r#" +... +contract WETH9 { + string public name = "Wrapped Ether"; + string public symbol = "WETH"; + uint8 public decimals = 18; +..."#]]); +} + +#[casttest] +async fn source_plain_and_directory(prj: _, cmd: _) { + let source = "pragma solidity ^0.8.0; contract Example {}"; + let response = json!({ + "status": "1", "message": "OK", "result": [{ + "SourceCode": source, "ABI": "[]", "ContractName": "Example", + "CompilerVersion": "v0.8.30+commit.73712a01", "OptimizationUsed": "0", + "Runs": "200", "EVMVersion": "Default", "Proxy": "0" + }] + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let app = Router::new().route( + "/api", + get(move || { + let response = response.clone(); + async move { Json(response) } + }), + ); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let args = [ + "source", + "0x0000000000000000000000000000000000000001", + "--explorer-api-url", + &format!("{url}/api"), + "--explorer-url", + &url, + ]; + cmd.args(args).assert_success().stdout_eq(format!("{source}\n")); + let directory = prj.root().join("sources"); + cmd.cast_fuse().args(args).arg("-d").arg(&directory).assert_empty_stdout(); + assert_eq!(fs::read_to_string(directory.join("Example/Contract.sol")).unwrap(), source); + server.abort(); +} + +// Tests that proxy interfaces include the implementation and remain valid Solidity. +#[casttest] +async fn interface_follows_etherscan_proxy_implementation(prj: _, cmd: _) { + let orphan_proxy = Address::random(); + let unverified = Address::random(); + let malformed_proxy = Address::random(); + let malformed = Address::random(); + let collision_proxy = Address::random(); + let collision_impl = Address::random(); + + let metadata = |name: &str, abi: &serde_json::Value, implementation: Option
| { + json!({ + "status": "1", "message": "OK", "result": [{ + "SourceCode": "", "ABI": abi.to_string(), "ContractName": name, + "CompilerVersion": "v0.8.30+commit.73712a01", "OptimizationUsed": "0", + "Runs": "200", "EVMVersion": "Default", + "Proxy": if implementation.is_some() { "1" } else { "0" }, + "Implementation": implementation.map(|address| address.to_string()).unwrap_or_default(), + }] + }) + }; + let proxy_abi = json!([ + {"type": "function", "name": "implementation", "inputs": [], "outputs": [{"name": "", "type": "address"}], "stateMutability": "view"}, + {"type": "event", "name": "Upgraded", "inputs": [{"name": "implementation", "type": "address", "indexed": true}], "anonymous": false}, + {"type": "fallback", "stateMutability": "payable"} + ]); + let mut malformed_metadata = metadata("Malformed", &json!([]), None); + malformed_metadata["result"][0]["ABI"] = json!("invalid JSON"); + let shared = json!({ + "name": "value", "type": "tuple", "internalType": "struct Shared.Data", + "components": [{"name": "amount", "type": "uint256", "internalType": "uint256"}] + }); + let nested = json!({ + "name": "result", "type": "tuple", "internalType": "struct Token.State", + "components": [shared.clone()] + }); + let mut event_param = shared.clone(); + event_param["indexed"] = json!(false); + let collision_abi = json!([ + {"type": "function", "name": "consume", "inputs": [shared.clone()], "outputs": [nested], "stateMutability": "view"}, + {"type": "function", "name": "Token_1", "inputs": [], "outputs": [], "stateMutability": "nonpayable"}, + {"type": "event", "name": "Changed", "inputs": [event_param], "anonymous": false}, + {"type": "error", "name": "Invalid", "inputs": [shared.clone()]}, + {"type": "error", "name": "Invalid", "inputs": [shared]} + ]); + let mut implementation_abi = collision_abi.clone(); + implementation_abi.as_array_mut().unwrap().push(json!({ + "type": "function", "name": "totalSupply", "inputs": [], + "outputs": [{"name": "", "type": "uint256"}], "stateMutability": "view" + })); + let responses = HashMap::from([ + (orphan_proxy, metadata("OrphanProxy", &proxy_abi, Some(unverified))), + (malformed_proxy, metadata("MalformedProxy", &proxy_abi, Some(malformed))), + (malformed, malformed_metadata), + (collision_proxy, metadata("Token", &collision_abi, Some(collision_impl))), + (collision_impl, metadata("Token", &implementation_abi, None)), + ]); + let not_verified = + json!({"status": "0", "message": "NOTOK", "result": "Contract source code not verified"}); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let app = Router::new().route( + "/api", + get(move |Query(query): Query>| { + let address = query["address"].parse::
().unwrap(); + let response = responses.get(&address).cloned().unwrap_or_else(|| not_verified.clone()); + async move { Json(response) } + }), + ); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + fs::write( + prj.root().join("foundry.toml"), + format!( + r#"[profile.default] +etherscan_api_key = "local" +eth_rpc_no_proxy = true + +[etherscan] +local = {{ key = "test", url = "{url}/api" }} +"# + ), + ) + .unwrap(); + + let proxy_interface = r#"// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.4; + +interface {name} { + event Upgraded(address indexed implementation); + + fallback() external payable; + + function implementation() external view returns (address); +} +"#; + let following = |proxy: Address, implementation: Address| { + format!("Contract at {proxy} is a proxy, fetching implementation at {implementation}...\n") + }; + let scenarios = [ + // An invalid implementation ABI only costs its own interface. + ( + malformed_proxy, + proxy_interface.replace("{name}", "MalformedProxy"), + format!( + "{}Warning: Could not fetch implementation ABI: Failed to deserialize content: expected value at line 1 column 1\ninvalid JSON\n", + following(malformed_proxy, malformed) + ), + ), + // An unverified implementation only costs its own interface. + ( + orphan_proxy, + proxy_interface.replace("{name}", "OrphanProxy"), + format!( + "{}Warning: Could not fetch implementation ABI: Contract source code not verified: {unverified}\n", + following(orphan_proxy, unverified) + ), + ), + ]; + let compile = |source: &str| { + prj.add_source("Generated.sol", source); + let compiled = Config::load_with_root(prj.root()) + .unwrap() + .sanitized() + .project() + .unwrap() + .compile() + .unwrap(); + assert!(!compiled.has_compiler_errors(), "{compiled}"); + }; + let prepare = |cmd: &mut TestCommand| { + cmd.cast_fuse().current_dir(prj.root()); + for var in ["CHAIN", "ETHERSCAN_API_KEY", "FOUNDRY_CONFIG", "FOUNDRY_ETHERSCAN_API_KEY"] { + cmd.unset_env(var); + } + }; + for (target, stdout, stderr) in scenarios { + prepare(&mut cmd); + cmd.args(["interface", &target.to_string()]) + .assert_success() + .stdout_eq(stdout) + .stderr_eq(stderr); + } + prepare(&mut cmd); + let output = cmd + .args(["interface", &collision_proxy.to_string()]) + .assert_success() + .stderr_eq(following(collision_proxy, collision_impl)) + .stdout_eq(str![[r#" +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.4; + +library Shared { + struct Data { + uint256 amount; + } +} + +interface Token { + struct State { + Shared.Data value; + } + + error Invalid(Shared.Data value); + + event Changed(Shared.Data value); + + function Token_1() external; + function consume(Shared.Data memory value) external view returns (State memory result); +} + +library Shared_1 { + struct Data { + uint256 amount; + } +} + +interface Token_2 { + struct State { + Shared_1.Data value; + } + + error Invalid(Shared_1.Data value); + + event Changed(Shared_1.Data value); + + function Token_1() external; + function consume(Shared_1.Data memory value) external view returns (State memory result); + function totalSupply() external view returns (uint256); +} + +"#]]) + .get_output() + .stdout_lossy(); + compile(&output); + + prepare(&mut cmd); + let output = cmd + .args(["interface", &collision_proxy.to_string(), "--flatten"]) + .assert_success() + .stderr_eq(following(collision_proxy, collision_impl)) + .stdout_eq(str![[r#" +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.4; + +interface Token { + // Types from `Shared` + struct Data { + uint256 amount; + } + + struct State { + Data value; + } + + error Invalid(Data value); + + event Changed(Data value); + + function Token_1() external; + function consume(Data memory value) external view returns (State memory result); +} + +interface Token_2 { + // Types from `Shared` + struct Data { + uint256 amount; + } + + struct State { + Data value; + } + + error Invalid(Data value); + + event Changed(Data value); + + function Token_1() external; + function consume(Data memory value) external view returns (State memory result); + function totalSupply() external view returns (uint256); +} + +"#]]) + .get_output() + .stdout_lossy(); + compile(&output); + + // Solidity-only renaming must preserve the explorer's original JSON ABI. + prepare(&mut cmd); + cmd.args(["interface", &collision_proxy.to_string(), "--json"]).assert_json_stdout( + json!({ + "schema_version": 1, "success": true, + "data": [collision_abi, implementation_abi], "errors": [], "warnings": [] + }) + .to_string(), + ); + server.abort(); +} diff --git a/crates/cast/tests/cli/storage.rs b/crates/cast/tests/cli/storage.rs new file mode 100644 index 0000000000000..47592b673a57a --- /dev/null +++ b/crates/cast/tests/cli/storage.rs @@ -0,0 +1,744 @@ +//! CLI tests for storage commands. + +use super::*; + +// test that `cast impl` works correctly for both the implementation slot and the beacon slot +#[casttest] +fn impl_slot(cmd: _) { + let eth_rpc_url = next_http_archive_rpc_url(); + + // Call `cast impl` for the implementation slot (AAVE Proxy) + cmd.args([ + "impl", + "0x4965f6FA20fE9728deCf5165016fc338a5a85aBF", + "--rpc-url", + eth_rpc_url.as_str(), + "--block", + "21422087", + ]) + .assert_success() + .stdout_eq(str![[r#" +0xb61306c8eb34a2104d9eb8d84f1bb1001067fa4b + +"#]]); +} + +#[casttest] +fn impl_slot_beacon(cmd: _) { + let eth_rpc_url = next_http_archive_rpc_url(); + + // Call `cast impl` for the beacon slot + cmd.args([ + "impl", + "0xc63d9f0040d35f328274312fc8771a986fc4ba86", + "--beacon", + "--rpc-url", + eth_rpc_url.as_str(), + "--block", + "21422087", + ]) + .assert_success() + .stdout_eq(str![[r#" +0xa748ae65ba11606492a9c57effa0d4b7be551ec2 + +"#]]); +} + +#[casttest] +fn storage(cmd: _) { + let rpc = next_http_archive_rpc_url(); + cmd.args(["storage", "vitalik.eth", "1", "--rpc-url", &rpc]).assert_success().stdout_eq(str![ + [r#" +0x0000000000000000000000000000000000000000000000000000000000000000 + +"#] + ]); + + let rpc = next_http_archive_rpc_url(); + cmd.cast_fuse() + .args(["storage", "vitalik.eth", "0x01", "--rpc-url", &rpc]) + .assert_success() + .stdout_eq(str![[r#" +0x0000000000000000000000000000000000000000000000000000000000000000 + +"#]]); + + let rpc = next_http_archive_rpc_url(); + let usdt = "0xdac17f958d2ee523a2206206994597c13d831ec7"; + let decimals_slot = "0x09"; + cmd.cast_fuse() + .args(["storage", usdt, decimals_slot, "--rpc-url", &rpc]) + .assert_success() + .stdout_eq(str![[r#" +0x0000000000000000000000000000000000000000000000000000000000000006 + +"#]]); + + let rpc = next_http_archive_rpc_url(); + let total_supply_slot = "0x01"; + let block_before = "4634747"; + let block_after = "4634748"; + cmd.cast_fuse() + .args(["storage", usdt, total_supply_slot, "--rpc-url", &rpc, "--block", block_before]) + .assert_success() + .stdout_eq(str![[r#" +0x0000000000000000000000000000000000000000000000000000000000000000 + +"#]]); + + let rpc = next_http_archive_rpc_url(); + cmd.cast_fuse() + .args(["storage", usdt, total_supply_slot, "--rpc-url", &rpc, "--block", block_after]) + .assert_success() + .stdout_eq(str![[r#" +0x000000000000000000000000000000000000000000000000000000174876e800 + +"#]]); + + let decimal_slot_offset_from_total_supply_slot = "0x08"; + let decimal_slot_offset_from_total_supply_slot_uint = "8"; + let rpc = next_http_archive_rpc_url(); + cmd.cast_fuse() + .args([ + "storage", + usdt, + total_supply_slot, + decimal_slot_offset_from_total_supply_slot, + "--rpc-url", + &rpc, + ]) + .assert_success() + .stdout_eq(str![[r#" +0x0000000000000000000000000000000000000000000000000000000000000006 + +"#]]); + + let rpc = next_http_archive_rpc_url(); + cmd.cast_fuse() + .args([ + "storage", + usdt, + total_supply_slot, + decimal_slot_offset_from_total_supply_slot_uint, + "--rpc-url", + &rpc, + ]) + .assert_success() + .stdout_eq(str![[r#" +0x0000000000000000000000000000000000000000000000000000000000000006 + +"#]]); +} + +#[casttest] +fn flaky_storage_with_valid_solc_version_1(cmd: _) { + cmd.args([ + "storage", + "0x13b0D85CcB8bf860b6b79AF3029fCA081AE9beF2", + "--solc-version", + "0.8.10", + "--rpc-url", + next_http_archive_rpc_url().as_str(), + "--etherscan-api-key", + next_etherscan_api_key().as_str(), + ]) + .assert_success(); +} + +#[casttest] +fn flaky_storage_with_valid_solc_version_2(cmd: _) { + cmd.args([ + "storage", + "0x13b0D85CcB8bf860b6b79AF3029fCA081AE9beF2", + "--solc-version", + "0.8.23", + "--rpc-url", + next_http_archive_rpc_url().as_str(), + "--etherscan-api-key", + next_etherscan_api_key().as_str(), + ]) + .assert_success(); +} + +#[casttest] +fn flaky_storage_with_invalid_solc_version_1(cmd: _) { + let output = cmd + .args([ + "storage", + "0x13b0D85CcB8bf860b6b79AF3029fCA081AE9beF2", + "--solc-version", + "0.4.0", + "--rpc-url", + next_http_archive_rpc_url().as_str(), + "--etherscan-api-key", + next_etherscan_api_key().as_str(), + ]) + .assert_failure() + .get_output() + .stderr + .clone(); + let stderr = String::from_utf8_lossy(&output); + assert!( + stderr.contains( + "Warning: The provided --solc-version is 0.4.0 while the minimum version for storage layouts is 0.6.5" + ), + "stderr did not contain expected warning. Full stderr:\n{stderr}" + ); +} + +#[casttest] +fn flaky_storage_with_invalid_solc_version_2(cmd: _) { + cmd.args([ + "storage", + "0x13b0D85CcB8bf860b6b79AF3029fCA081AE9beF2", + "--solc-version", + "0.8.2", + "--rpc-url", + next_http_archive_rpc_url().as_str(), + "--etherscan-api-key", + next_etherscan_api_key().as_str(), + ]) + .assert_failure() + .stderr_eq(str![[r#" +Error: Encountered invalid compiler version in contracts/Create2Deployer.sol: No compiler version exists that matches the version requirement: ^0.8.9 + +"#]]); +} + +// +#[casttest] +fn flaky_storage_layout_simple(cmd: _) { + cmd.args([ + "storage", + "--rpc-url", + next_http_archive_rpc_url().as_str(), + "--block", + "21034138", + "--etherscan-api-key", + next_etherscan_api_key().as_str(), + "0x13b0D85CcB8bf860b6b79AF3029fCA081AE9beF2", + ]) + .assert_success() + .stdout_eq(str![[r#" + +╭---------+---------+------+--------+-------+-------+--------------------------------------------------------------------+-----------------------------------------------╮ +| Name | Type | Slot | Offset | Bytes | Value | Hex Value | Contract | ++========================================================================================================================================================================+ +| _owner | address | 0 | 0 | 20 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/Create2Deployer.sol:Create2Deployer | +|---------+---------+------+--------+-------+-------+--------------------------------------------------------------------+-----------------------------------------------| +| _paused | bool | 0 | 20 | 1 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/Create2Deployer.sol:Create2Deployer | +╰---------+---------+------+--------+-------+-------+--------------------------------------------------------------------+-----------------------------------------------╯ + + +"#]]); +} + +// +#[casttest] +fn flaky_storage_layout_simple_json(cmd: _) { + cmd.args([ + "storage", + "--rpc-url", + next_http_archive_rpc_url().as_str(), + "--block", + "21034138", + "--etherscan-api-key", + next_etherscan_api_key().as_str(), + "0x13b0D85CcB8bf860b6b79AF3029fCA081AE9beF2", + "--json", + ]) + .assert_success() + .stdout_eq(file!["../fixtures/storage_layout_simple.json": Json]); +} + +// +#[casttest] +fn flaky_storage_layout_complex(cmd: _) { + cmd.args([ + "storage", + "--rpc-url", + next_http_archive_rpc_url().as_str(), + "--block", + "21034138", + "--etherscan-api-key", + next_etherscan_api_key().as_str(), + "0xBA12222222228d8Ba445958a75a0704d566BF2C8", + ]) + .assert_success() + .stdout_eq(str![[r#" + +╭-------------------------------+--------------------------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+---------------------------------╮ +| Name | Type | Slot | Offset | Bytes | Value | Hex Value | Contract | ++======================================================================================================================================================================================================================================================================================+ +| _status | uint256 | 0 | 0 | 32 | 1 | 0x0000000000000000000000000000000000000000000000000000000000000001 | contracts/vault/Vault.sol:Vault | +|-------------------------------+--------------------------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+---------------------------------| +| _generalPoolsBalances | mapping(bytes32 => struct EnumerableMap.IERC20ToBytes32Map) | 1 | 0 | 32 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/vault/Vault.sol:Vault | +|-------------------------------+--------------------------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+---------------------------------| +| _nextNonce | mapping(address => uint256) | 2 | 0 | 32 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/vault/Vault.sol:Vault | +|-------------------------------+--------------------------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+---------------------------------| +| _paused | bool | 3 | 0 | 1 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/vault/Vault.sol:Vault | +|-------------------------------+--------------------------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+---------------------------------| +| _authorizer | contract IAuthorizer | 3 | 1 | 20 | 549683469959765988649777481110995959958745616871 | 0x0000000000000000000000006048a8c631fb7e77eca533cf9c29784e482391e7 | contracts/vault/Vault.sol:Vault | +|-------------------------------+--------------------------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+---------------------------------| +| _approvedRelayers | mapping(address => mapping(address => bool)) | 4 | 0 | 32 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/vault/Vault.sol:Vault | +|-------------------------------+--------------------------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+---------------------------------| +| _isPoolRegistered | mapping(bytes32 => bool) | 5 | 0 | 32 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/vault/Vault.sol:Vault | +|-------------------------------+--------------------------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+---------------------------------| +| _nextPoolNonce | uint256 | 6 | 0 | 32 | 1760 | 0x00000000000000000000000000000000000000000000000000000000000006e0 | contracts/vault/Vault.sol:Vault | +|-------------------------------+--------------------------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+---------------------------------| +| _minimalSwapInfoPoolsBalances | mapping(bytes32 => mapping(contract IERC20 => bytes32)) | 7 | 0 | 32 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/vault/Vault.sol:Vault | +|-------------------------------+--------------------------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+---------------------------------| +| _minimalSwapInfoPoolsTokens | mapping(bytes32 => struct EnumerableSet.AddressSet) | 8 | 0 | 32 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/vault/Vault.sol:Vault | +|-------------------------------+--------------------------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+---------------------------------| +| _twoTokenPoolTokens | mapping(bytes32 => struct TwoTokenPoolsBalance.TwoTokenPoolTokens) | 9 | 0 | 32 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/vault/Vault.sol:Vault | +|-------------------------------+--------------------------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+---------------------------------| +| _poolAssetManagers | mapping(bytes32 => mapping(contract IERC20 => address)) | 10 | 0 | 32 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/vault/Vault.sol:Vault | +|-------------------------------+--------------------------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+---------------------------------| +| _internalTokenBalance | mapping(address => mapping(contract IERC20 => uint256)) | 11 | 0 | 32 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/vault/Vault.sol:Vault | +╰-------------------------------+--------------------------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+---------------------------------╯ + + +"#]]); +} + +#[casttest] +fn flaky_storage_layout_complex_md(cmd: _) { + cmd.args([ + "storage", + "--rpc-url", + next_http_archive_rpc_url().as_str(), + "--block", + "21034138", + "--etherscan-api-key", + next_etherscan_api_key().as_str(), + "0xBA12222222228d8Ba445958a75a0704d566BF2C8", + "--md", + ]) + .assert_success() + .stdout_eq(str![[r#" + +| Name | Type | Slot | Offset | Bytes | Value | Hex Value | Contract | +|-------------------------------|--------------------------------------------------------------------|------|--------|-------|--------------------------------------------------|--------------------------------------------------------------------|---------------------------------| +| _status | uint256 | 0 | 0 | 32 | 1 | 0x0000000000000000000000000000000000000000000000000000000000000001 | contracts/vault/Vault.sol:Vault | +| _generalPoolsBalances | mapping(bytes32 => struct EnumerableMap.IERC20ToBytes32Map) | 1 | 0 | 32 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/vault/Vault.sol:Vault | +| _nextNonce | mapping(address => uint256) | 2 | 0 | 32 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/vault/Vault.sol:Vault | +| _paused | bool | 3 | 0 | 1 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/vault/Vault.sol:Vault | +| _authorizer | contract IAuthorizer | 3 | 1 | 20 | 549683469959765988649777481110995959958745616871 | 0x0000000000000000000000006048a8c631fb7e77eca533cf9c29784e482391e7 | contracts/vault/Vault.sol:Vault | +| _approvedRelayers | mapping(address => mapping(address => bool)) | 4 | 0 | 32 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/vault/Vault.sol:Vault | +| _isPoolRegistered | mapping(bytes32 => bool) | 5 | 0 | 32 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/vault/Vault.sol:Vault | +| _nextPoolNonce | uint256 | 6 | 0 | 32 | 1760 | 0x00000000000000000000000000000000000000000000000000000000000006e0 | contracts/vault/Vault.sol:Vault | +| _minimalSwapInfoPoolsBalances | mapping(bytes32 => mapping(contract IERC20 => bytes32)) | 7 | 0 | 32 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/vault/Vault.sol:Vault | +| _minimalSwapInfoPoolsTokens | mapping(bytes32 => struct EnumerableSet.AddressSet) | 8 | 0 | 32 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/vault/Vault.sol:Vault | +| _twoTokenPoolTokens | mapping(bytes32 => struct TwoTokenPoolsBalance.TwoTokenPoolTokens) | 9 | 0 | 32 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/vault/Vault.sol:Vault | +| _poolAssetManagers | mapping(bytes32 => mapping(contract IERC20 => address)) | 10 | 0 | 32 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/vault/Vault.sol:Vault | +| _internalTokenBalance | mapping(address => mapping(contract IERC20 => uint256)) | 11 | 0 | 32 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/vault/Vault.sol:Vault | + + +"#]]); +} + +#[casttest] +fn flaky_storage_layout_complex_proxy(cmd: _) { + cmd.args([ + "storage", + "--rpc-url", + next_rpc_endpoint(NamedChain::Sepolia).as_str(), + "--block", + "7857852", + "--etherscan-api-key", + next_etherscan_api_key().as_str(), + "0xE2588A9CAb7Ea877206E35f615a39f84a64A7A3b", + "--proxy", + "0x29fcb43b46531bca003ddc8fcb67ffe91900c762" + ]) + .assert_success() + .stdout_eq(str![[r#" + +╭----------------------------+-------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+-----------------------------╮ +| Name | Type | Slot | Offset | Bytes | Value | Hex Value | Contract | ++============================================================================================================================================================================================================================================================+ +| singleton | address | 0 | 0 | 20 | 239704109775411986678417050956533140837380441954 | 0x00000000000000000000000029fcb43b46531bca003ddc8fcb67ffe91900c762 | contracts/SafeL2.sol:SafeL2 | +|----------------------------+-------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+-----------------------------| +| modules | mapping(address => address) | 1 | 0 | 32 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/SafeL2.sol:SafeL2 | +|----------------------------+-------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+-----------------------------| +| owners | mapping(address => address) | 2 | 0 | 32 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/SafeL2.sol:SafeL2 | +|----------------------------+-------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+-----------------------------| +| ownerCount | uint256 | 3 | 0 | 32 | 1 | 0x0000000000000000000000000000000000000000000000000000000000000001 | contracts/SafeL2.sol:SafeL2 | +|----------------------------+-------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+-----------------------------| +| threshold | uint256 | 4 | 0 | 32 | 1 | 0x0000000000000000000000000000000000000000000000000000000000000001 | contracts/SafeL2.sol:SafeL2 | +|----------------------------+-------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+-----------------------------| +| nonce | uint256 | 5 | 0 | 32 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/SafeL2.sol:SafeL2 | +|----------------------------+-------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+-----------------------------| +| _deprecatedDomainSeparator | bytes32 | 6 | 0 | 32 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/SafeL2.sol:SafeL2 | +|----------------------------+-------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+-----------------------------| +| signedMessages | mapping(bytes32 => uint256) | 7 | 0 | 32 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/SafeL2.sol:SafeL2 | +|----------------------------+-------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+-----------------------------| +| approvedHashes | mapping(address => mapping(bytes32 => uint256)) | 8 | 0 | 32 | 0 | 0x0000000000000000000000000000000000000000000000000000000000000000 | contracts/SafeL2.sol:SafeL2 | +╰----------------------------+-------------------------------------------------+------+--------+-------+--------------------------------------------------+--------------------------------------------------------------------+-----------------------------╯ + + +"#]]); +} + +#[casttest] +fn flaky_storage_layout_complex_json(cmd: _) { + cmd.args([ + "storage", + "--rpc-url", + next_http_archive_rpc_url().as_str(), + "--block", + "21034138", + "--etherscan-api-key", + next_etherscan_api_key().as_str(), + "0xBA12222222228d8Ba445958a75a0704d566BF2C8", + "--json", + ]) + .assert_success() + .stdout_eq(file!["../fixtures/storage_layout_complex.json": Json]); +} + +#[casttest] +async fn storage_layout_local_proxy(prj: _, cmd: _) { + prj.add_source( + "Proxy", + r#" +contract Proxy { + fallback() external payable { + assembly { + let implementation := sload(0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc) + calldatacopy(0, 0, calldatasize()) + let success := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0) + returndatacopy(0, 0, returndatasize()) + if iszero(success) { revert(0, returndatasize()) } + return(0, returndatasize()) + } + } +} +"#, + ); + prj.add_source( + "Vault", + r#" +contract Vault { + address public owner; + uint256 public totalDeposits; +} +"#, + ); + + let (api, handle) = anvil::spawn(NodeConfig::test()).await; + let rpc = handle.http_endpoint(); + let proxy = Address::with_last_byte(0xaa); + let implementation = Address::with_last_byte(0xbb); + // An EIP-1167 clone of the implementation, which matches no local artifact. + let clone = Address::with_last_byte(0xcc); + + for (address, name) in [(proxy, "Proxy"), (implementation, "Vault")] { + let code = cmd + .forge_fuse() + .args(["inspect", name, "deployedBytecode"]) + .assert_success() + .get_output() + .stdout_lossy(); + api.anvil_set_code(address, Bytes::from_str(code.trim()).unwrap()).await.unwrap(); + } + let clone_code = format!( + "0x363d3d373d3d3d363d73{}5af43d82803e903d91602b57fd5bf3", + hex::encode(implementation) + ); + api.anvil_set_code(clone, Bytes::from_str(&clone_code).unwrap()).await.unwrap(); + for address in [proxy, clone] { + api.anvil_set_storage_at(address, U256::ZERO, Address::with_last_byte(1).into_word()) + .await + .unwrap(); + api.anvil_set_storage_at(address, U256::from(1), B256::with_last_byte(42)).await.unwrap(); + } + + for address in [proxy, clone] { + cmd.cast_fuse() + .current_dir(prj.root()) + .args([ + "storage", + &address.to_string(), + "--proxy", + &implementation.to_string(), + "--rpc-url", + &rpc, + ]) + .assert_success() + .stdout_eq(str![[r#" +No files changed, compilation skipped + +╭---------------+---------+------+--------+-------+-------+--------------------------------------------------------------------+---------------------╮ +| Name | Type | Slot | Offset | Bytes | Value | Hex Value | Contract | ++====================================================================================================================================================+ +| owner | address | 0 | 0 | 20 | 1 | 0x0000000000000000000000000000000000000000000000000000000000000001 | src/Vault.sol:Vault | +|---------------+---------+------+--------+-------+-------+--------------------------------------------------------------------+---------------------| +| totalDeposits | uint256 | 1 | 0 | 32 | 42 | 0x000000000000000000000000000000000000000000000000000000000000002a | src/Vault.sol:Vault | +╰---------------+---------+------+--------+-------+-------+--------------------------------------------------------------------+---------------------╯ + + +"#]]); + } + + cmd.cast_fuse() + .current_dir(prj.root()) + .args([ + "storage", + &proxy.to_string(), + "--proxy", + &implementation.to_string(), + "--rpc-url", + &rpc, + "--json", + ]) + .assert_json_stdout(str![[r#" +{ + "storage": [ + { + "astId": 11, + "contract": "src/Vault.sol:Vault", + "label": "owner", + "offset": 0, + "slot": "0", + "type": "t_address" + }, + { + "astId": 13, + "contract": "src/Vault.sol:Vault", + "label": "totalDeposits", + "offset": 0, + "slot": "1", + "type": "t_uint256" + } + ], + "types": { + "t_address": { + "encoding": "inplace", + "label": "address", + "numberOfBytes": "20" + }, + "t_uint256": { + "encoding": "inplace", + "label": "uint256", + "numberOfBytes": "32" + } + }, + "values": [ + "0x0000000000000000000000000000000000000000000000000000000000000001", + "0x000000000000000000000000000000000000000000000000000000000000002a" + ] +} +"#]]); +} + +#[casttest] +async fn storage_layout_local_immutable(prj: _, cmd: _) { + prj.add_source( + "Pinned", + r#" +contract Pinned { + address public immutable token; + uint256 public count; + + constructor(address token_, uint256 count_) { + token = token_; + count = count_; + } +} +"#, + ); + + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + let rpc = handle.http_endpoint(); + let private_key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + let bytecode = cmd + .forge_fuse() + .args(["inspect", "Pinned", "bytecode"]) + .assert_success() + .get_output() + .stdout_lossy(); + let deployment = cmd + .cast_fuse() + .args([ + "send", + "--json", + "--private-key", + private_key, + "--rpc-url", + &rpc, + "--create", + bytecode.trim(), + "constructor(address,uint256)", + "0x000000000000000000000000000000000000dEaD", + "7", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + let deployment: serde_json::Value = serde_json::from_str(&deployment).unwrap(); + let address = deployment["contractAddress"].as_str().unwrap(); + + cmd.cast_fuse() + .current_dir(prj.root()) + .args(["storage", address, "--rpc-url", &rpc]) + .assert_success() + .stdout_eq(str![[r#" +No files changed, compilation skipped + +╭-------+---------+------+--------+-------+-------+--------------------------------------------------------------------+-----------------------╮ +| Name | Type | Slot | Offset | Bytes | Value | Hex Value | Contract | ++==============================================================================================================================================+ +| count | uint256 | 0 | 0 | 32 | 7 | 0x0000000000000000000000000000000000000000000000000000000000000007 | src/Pinned.sol:Pinned | +╰-------+---------+------+--------+-------+-------+--------------------------------------------------------------------+-----------------------╯ + + +"#]]); +} + +#[casttest] +async fn storage_layout_local_proxy_at_block(prj: _, cmd: _) { + prj.add_source( + "Vault", + r#" +contract Vault { + address public owner; + uint256 public totalDeposits; + + function deposit(uint256 amount) external { + owner = msg.sender; + totalDeposits += amount; + } +} +"#, + ); + + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + let rpc = handle.http_endpoint(); + let private_key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + let sender = address!("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"); + let clone = sender.create(0).to_string(); + let implementation = sender.create(1).to_string(); + let vault = cmd + .forge_fuse() + .args(["inspect", "Vault", "bytecode"]) + .assert_success() + .get_output() + .stdout_lossy(); + let clone_code = format!( + "0x3d602d80600a3d3981f3363d3d373d3d3d363d73{}5af43d82803e903d91602b57fd5bf3", + hex::encode(sender.create(1)) + ); + + // Block 1 deploys the clone, block 2 its implementation, and blocks 3 and 4 deposit. + for args in [ + &["--create", clone_code.as_str()][..], + &["--create", vault.trim()], + &[&clone, "deposit(uint256)", "42"], + &[&clone, "deposit(uint256)", "1"], + ] { + cmd.cast_fuse() + .args(["send", "--private-key", private_key, "--rpc-url", &rpc]) + .args(args) + .assert_success(); + } + + cmd.cast_fuse() + .current_dir(prj.root()) + .args(["storage", &clone, "--proxy", &implementation, "--rpc-url", &rpc, "--block", "1"]) + .assert_failure() + .stderr_eq(str![[r#" +Error: Provided proxy address has no deployed code and thus no storage layout + +"#]]); + + cmd.cast_fuse() + .current_dir(prj.root()) + .args(["storage", &clone, "--proxy", &implementation, "--rpc-url", &rpc, "--block", "3"]) + .assert_success() + .stdout_eq(str![[r#" +No files changed, compilation skipped + +╭---------------+---------+------+--------+-------+---------------------------------------------------+--------------------------------------------------------------------+---------------------╮ +| Name | Type | Slot | Offset | Bytes | Value | Hex Value | Contract | ++================================================================================================================================================================================================+ +| owner | address | 0 | 0 | 20 | 1390849295786071768276380950238675083608645509734 | 0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266 | src/Vault.sol:Vault | +|---------------+---------+------+--------+-------+---------------------------------------------------+--------------------------------------------------------------------+---------------------| +| totalDeposits | uint256 | 1 | 0 | 32 | 42 | 0x000000000000000000000000000000000000000000000000000000000000002a | src/Vault.sol:Vault | +╰---------------+---------+------+--------+-------+---------------------------------------------------+--------------------------------------------------------------------+---------------------╯ + + +"#]]); + + cmd.cast_fuse() + .current_dir(prj.root()) + .args(["storage", &clone, "--proxy", &implementation, "--rpc-url", &rpc]) + .assert_success() + .stdout_eq(str![[r#" +No files changed, compilation skipped + +╭---------------+---------+------+--------+-------+---------------------------------------------------+--------------------------------------------------------------------+---------------------╮ +| Name | Type | Slot | Offset | Bytes | Value | Hex Value | Contract | ++================================================================================================================================================================================================+ +| owner | address | 0 | 0 | 20 | 1390849295786071768276380950238675083608645509734 | 0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266 | src/Vault.sol:Vault | +|---------------+---------+------+--------+-------+---------------------------------------------------+--------------------------------------------------------------------+---------------------| +| totalDeposits | uint256 | 1 | 0 | 32 | 43 | 0x000000000000000000000000000000000000000000000000000000000000002b | src/Vault.sol:Vault | +╰---------------+---------+------+--------+-------+---------------------------------------------------+--------------------------------------------------------------------+---------------------╯ + + +"#]]); +} + +#[casttest] +async fn storage_root_empty(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + cmd.args([ + "storage-root", + "0x0000000000000000000000000000000000000000", + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success() + .stdout_eq("0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421\n"); +} + +#[casttest] +async fn implementation_empty(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + cmd.args([ + "implementation", + "0x0000000000000000000000000000000000000000", + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success() + .stdout_eq("0x0000000000000000000000000000000000000000\n"); +} + +#[casttest] +async fn admin_empty(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test()).await; + cmd.args([ + "admin", + "0x0000000000000000000000000000000000000000", + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success() + .stdout_eq("0x0000000000000000000000000000000000000000\n"); +} + +#[casttest] +fn index_mapping(cmd: _) { + cmd.args(["index", "uint256", "42", "6"]) + .assert_success() + .stdout_eq("0xfc808b0f31a1e6b9cf25ff6289feae9b51017b392cc8e25620a94a38dcdafcc1\n"); + cmd.cast_fuse() + .args(["index", "string", "hello", "1"]) + .assert_success() + .stdout_eq("0x8404bb4d805e9ca2bd5dd5c43a107e935c8ec393caa7851b353b3192cd5379ae\n"); + cmd.cast_fuse() + .args(["index", "address", "0xD0074F4E6490ae3f888d1d4f7E3E43326bD3f0f5", "2"]) + .assert_success() + .stdout_eq("0x9525a448a9000053a4d151336329d6563b7e80b24f8e628e95527f218e8ab5fb\n"); +} diff --git a/crates/cast/tests/cli/tempo.rs b/crates/cast/tests/cli/tempo.rs new file mode 100644 index 0000000000000..a5e26d4b53645 --- /dev/null +++ b/crates/cast/tests/cli/tempo.rs @@ -0,0 +1,1812 @@ +//! CLI tests for shared Tempo transaction options. + +use alloy_eips::eip2718::Decodable2718; +use alloy_network::{ReceiptResponse, TransactionBuilder}; +use alloy_primitives::{Address, B256, U256, address, hex, keccak256}; +use alloy_provider::Provider; +use alloy_rpc_types::TransactionRequest; +use alloy_serde::WithOtherFields; +use alloy_sol_types::{SolEvent, SolValue}; +use anvil::NodeConfig; +use foundry_cli::utils::parse_json; +use foundry_evm::core::tempo::PATH_USD_ADDRESS; +use foundry_test_utils::util::OutputExt; +use std::time::{SystemTime, UNIX_EPOCH}; +use tempo_contracts::precompiles::{ + CURRENT_COMMITTEE_ADDRESS, ICurrentCommittee, IReceivePolicyGuard, IRolesAuth, ITIP20, + ITIP20Factory, ITIP403Registry, TIP20_CHANNEL_RESERVE_ADDRESS, TIP20_FACTORY_ADDRESS, + TIP403_REGISTRY_ADDRESS, +}; +use tempo_hardfork::TempoHardfork; +use tempo_primitives::TempoTxEnvelope; + +fn json_success_data(output: &str) -> serde_json::Value { + let envelope: serde_json::Value = parse_json(output.trim()).expect("command emits JSON"); + assert_eq!(envelope["success"], true, "unexpected JSON envelope: {envelope}"); + envelope["data"].clone() +} + +#[casttest] +fn tempo_state_changing_help_includes_expires(cmd: _) { + let cases: &[(&str, &[&str])] = &[ + ("batch-mktx", &["batch-mktx", "--help"]), + ("batch-send", &["batch-send", "--help"]), + ("keychain authorize", &["keychain", "authorize", "--help"]), + ("tip20 create", &["tip20", "create", "--help"]), + ("tip20 logo-set", &["tip20", "logo-set", "--help"]), + ("tip20 mine", &["tip20", "mine", "--help"]), + ("tip20 grant-role", &["tip20", "grant-role", "--help"]), + ("tip20 revoke-role", &["tip20", "revoke-role", "--help"]), + ("storage-credits set-mode", &["storage-credits", "set-mode", "--help"]), + ("storage-credits set-budget", &["storage-credits", "set-budget", "--help"]), + ("vaddr create", &["vaddr", "create", "--help"]), + ]; + + for (name, args) in cases { + let output = cmd.cast_fuse().args(*args).assert_success().get_output().stdout_lossy(); + assert!( + output.contains("--tempo.expires "), + "expected {name} help to expose --tempo.expires, got:\n{output}", + ); + } +} + +#[casttest] +async fn receive_policy_receipt_json_and_claim_flow(cmd: _) { + let (_, handle) = + anvil::spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T6.into()))).await; + let rpc = handle.http_endpoint(); + let provider = handle.http_provider(); + let accounts: Vec
= handle.dev_accounts().collect(); + let sender = accounts[0]; + let receiver = accounts[1]; + let recovery = accounts[2]; + let claim_target = accounts[3]; + let recovery_wallet = handle.dev_wallets().nth(2).unwrap(); + assert_eq!(recovery_wallet.address(), recovery); + let recovery_pk = hex::encode_prefixed(recovery_wallet.credential().to_bytes()); + let amount = U256::from(77_000u64); + let path_usd = PATH_USD_ADDRESS.to_string(); + let sender_arg = sender.to_string(); + let receiver_arg = receiver.to_string(); + let recovery_arg = recovery.to_string(); + let claim_target_arg = claim_target.to_string(); + + let warning_preview = cmd + .cast_fuse() + .args(["--json", "receive-policy", "set", "0", "1", "--preview", "--rpc-url", &rpc]) + .assert_success() + .get_output() + .stdout_lossy(); + let warning_preview = json_success_data(&warning_preview); + let warning = warning_preview["warning"].as_str().expect("warning should be present"); + assert!(warning.contains("originator recovery is enabled"), "{warning}"); + assert!(warning.contains("system/precompile sender"), "{warning}"); + + let safe_preview = cmd + .cast_fuse() + .args([ + "--json", + "receive-policy", + "set", + "0", + "1", + "--recovery-authority", + &recovery_arg, + "--preview", + "--rpc-url", + &rpc, + ]) + .assert_success() + .get_output() + .stdout_lossy(); + let safe_preview = json_success_data(&safe_preview); + assert_eq!(safe_preview["action"], "set_receive_policy"); + assert_eq!(safe_preview["recovery_mode"], "authority"); + assert!(safe_preview["calldata"].as_str().is_some_and(|s| s.starts_with("0x"))); + assert!(safe_preview["warning"].is_null()); + + let registry = ITIP403Registry::new(TIP403_REGISTRY_ADDRESS, &provider); + let set_policy_tx = TransactionRequest::default() + .from(receiver) + .to(TIP403_REGISTRY_ADDRESS) + .with_input(registry.setReceivePolicy(0, 1, recovery).calldata().clone()) + .with_gas_limit(10_000_000); + let set_policy = provider + .send_transaction(WithOtherFields::new(set_policy_tx)) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert!(set_policy.status(), "setReceivePolicy should succeed"); + + let validate = cmd + .cast_fuse() + .args([ + "--json", + "receive-policy", + "validate", + &path_usd, + &sender_arg, + &receiver_arg, + "--rpc-url", + &rpc, + ]) + .assert_success() + .get_output() + .stdout_lossy(); + let validate = json_success_data(&validate); + assert_eq!(validate["authorized"], false); + assert_eq!(validate["blocked_reason"], "receive_policy"); + assert_eq!(validate["delivery_state"], "held"); + + let token = ITIP20::new(PATH_USD_ADDRESS, &provider); + let claim_target_before = token.balanceOf(claim_target).call().await.unwrap(); + let transfer_tx = TransactionRequest::default() + .from(sender) + .to(PATH_USD_ADDRESS) + .with_input(token.transfer(receiver, amount).calldata().clone()) + .with_gas_limit(10_000_000); + let transfer = provider + .send_transaction(WithOtherFields::new(transfer_tx)) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert!(transfer.status(), "blocked transfer should still succeed"); + + let blocked = transfer + .inner + .logs() + .iter() + .find_map(|log| IReceivePolicyGuard::TransferBlocked::decode_log(&log.inner).ok()) + .expect("transfer should emit TransferBlocked"); + assert_eq!(blocked.token, PATH_USD_ADDRESS); + assert_eq!(blocked.receiver, receiver); + assert_eq!(blocked.amount, amount); + let decoded = IReceivePolicyGuard::ClaimReceiptV1::abi_decode(&blocked.receipt).unwrap(); + assert_eq!(decoded.version, 1); + assert_eq!(decoded.recoveryAuthority, recovery); + assert_eq!(decoded.originator, sender); + assert_eq!(decoded.recipient, receiver); + assert_eq!(decoded.blockedReason, ITIP403Registry::BlockedReason::RECEIVE_POLICY as u8); + assert_eq!(decoded.memo, B256::ZERO); + + let receipt_arg = blocked.receipt.to_string(); + let decoded_output = cmd + .cast_fuse() + .args(["--json", "receive-policy", "receipt", "decode", &receipt_arg]) + .assert_success() + .get_output() + .stdout_lossy(); + let decoded_output = json_success_data(&decoded_output); + assert_eq!(decoded_output["receipt"], receipt_arg); + assert_eq!(decoded_output["token"], path_usd); + assert_eq!(decoded_output["recovery_mode"], "authority"); + assert_eq!(decoded_output["originator"], sender_arg); + assert_eq!(decoded_output["recipient"], receiver_arg); + assert_eq!(decoded_output["blocked_reason"], "receive_policy"); + assert_eq!(decoded_output["kind"], "transfer"); + assert_eq!(decoded_output["delivery_state"], "unknown"); + assert_eq!(decoded_output["claim_target"], receiver_arg); + + let human_decode = cmd + .cast_fuse() + .args(["receive-policy", "receipt", "decode", &receipt_arg]) + .assert_success() + .get_output() + .stdout_lossy(); + assert!(human_decode.contains("cast receive-policy claim"), "{human_decode}"); + assert!(human_decode.contains(&receipt_arg), "{human_decode}"); + + let balance_output = cmd + .cast_fuse() + .args(["--json", "receive-policy", "receipt", "balance", &receipt_arg, "--rpc-url", &rpc]) + .assert_success() + .get_output() + .stdout_lossy(); + let balance_output = json_success_data(&balance_output); + assert_eq!(balance_output["held_balance"], amount.to_string()); + assert_eq!(balance_output["delivery_state"], "held"); + + cmd.cast_fuse() + .args([ + "receive-policy", + "claim", + &claim_target_arg, + &receipt_arg, + "--private-key", + &recovery_pk, + "--rpc-url", + &rpc, + ]) + .assert_success(); + + assert_eq!(token.balanceOf(claim_target).call().await.unwrap(), claim_target_before + amount); + + let claimed_balance_output = cmd + .cast_fuse() + .args(["--json", "receive-policy", "receipt", "balance", &receipt_arg, "--rpc-url", &rpc]) + .assert_success() + .get_output() + .stdout_lossy(); + let claimed_balance_output = json_success_data(&claimed_balance_output); + assert_eq!(claimed_balance_output["held_balance"], "0"); + assert_eq!(claimed_balance_output["delivery_state"], "not_held"); +} + +// The ReceivePolicyGuard precompile is only active from T6, so claim/burn must fail early on a +// pre-T6 RPC instead of submitting a transaction that would silently succeed as a no-op. +#[casttest] +async fn receive_policy_claim_and_burn_require_t6(cmd: _) { + let (_, handle) = + anvil::spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T5.into()))).await; + let rpc = handle.http_endpoint(); + let wallet = handle.dev_wallets().next().unwrap(); + let pk = hex::encode_prefixed(wallet.credential().to_bytes()); + + let receipt = IReceivePolicyGuard::ClaimReceiptV1::new( + PATH_USD_ADDRESS, + Address::ZERO, + wallet.address(), + Address::with_last_byte(0xbe), + 1_780_000_000, + 1, + ITIP403Registry::BlockedReason::RECEIVE_POLICY as u8, + IReceivePolicyGuard::InboundKind::TRANSFER, + B256::ZERO, + ) + .abi_encode(); + let receipt_arg = hex::encode_prefixed(&receipt); + + let claim_err = cmd + .cast_fuse() + .args([ + "receive-policy", + "claim", + &wallet.address().to_string(), + &receipt_arg, + "--private-key", + &pk, + "--rpc-url", + &rpc, + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + assert!( + claim_err + .contains("cast receive-policy claim requires a Tempo T6-capable ReceivePolicy RPC"), + "{claim_err}" + ); + + let burn_err = cmd + .cast_fuse() + .args([ + "receive-policy", + "receipt", + "burn", + &receipt_arg, + "--private-key", + &pk, + "--rpc-url", + &rpc, + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + assert!( + burn_err.contains( + "cast receive-policy receipt burn requires a Tempo T6-capable ReceivePolicy RPC" + ), + "{burn_err}" + ); +} + +// Exercises the full TIP-403 policy lifecycle: create, inspect, check, and modify membership. +#[casttest] +async fn tip403_policy_lifecycle(cmd: _) { + let (_, handle) = + anvil::spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T6.into()))).await; + let rpc = handle.http_endpoint(); + let wallet = handle.dev_wallets().next().unwrap(); + let pk = hex::encode_prefixed(wallet.credential().to_bytes()); + let admin = wallet.address(); + let member = handle.dev_wallets().nth(1).unwrap().address(); + + // IDs 0 and 1 are reserved, so the first user policy on a fresh node is ID 2. + let create_err = cmd + .cast_fuse() + .args([ + "tip403", + "create", + "whitelist", + "--admin", + &admin.to_string(), + "--private-key", + &pk, + "--rpc-url", + &rpc, + ]) + .assert_success() + .get_output() + .stderr_lossy(); + assert!(create_err.contains("Expected policy ID: 2"), "{create_err}"); + + let info = cmd + .cast_fuse() + .args(["--json", "tip403", "info", "2", "--rpc-url", &rpc]) + .assert_success() + .get_output() + .stdout_lossy(); + let info = json_success_data(&info); + assert_eq!(info["exists"], true); + assert_eq!(info["policy_type"], "whitelist"); + assert_eq!(info["admin"], admin.to_string()); + + // Non-member is not authorized by a whitelist policy until added. + let before = cmd + .cast_fuse() + .args(["--json", "tip403", "check", "2", &member.to_string(), "--rpc-url", &rpc]) + .assert_success() + .get_output() + .stdout_lossy(); + assert_eq!(json_success_data(&before)["authorized"], false); + + cmd.cast_fuse() + .args([ + "tip403", + "whitelist", + "add", + "2", + &member.to_string(), + "--private-key", + &pk, + "--rpc-url", + &rpc, + ]) + .assert_success(); + + let after = cmd + .cast_fuse() + .args(["--json", "tip403", "check", "2", &member.to_string(), "--rpc-url", &rpc]) + .assert_success() + .get_output() + .stdout_lossy(); + assert_eq!(json_success_data(&after)["authorized"], true); + + // Built-in policies are labeled. + let allow_all = cmd + .cast_fuse() + .args(["--json", "tip403", "info", "1", "--rpc-url", &rpc]) + .assert_success() + .get_output() + .stdout_lossy(); + assert_eq!(json_success_data(&allow_all)["builtin"], "allow-all"); +} + +#[casttest] +async fn tip403_create_warns_on_virtual_member(cmd: _) { + let (_, handle) = + anvil::spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T6.into()))).await; + let rpc = handle.http_endpoint(); + let pk = hex::encode_prefixed(handle.dev_wallets().next().unwrap().credential().to_bytes()); + + // A TIP-1022 virtual address (bytes [4:14] == 0xFD) is rejected on-chain on T3+; cast warns + // and lets the chain enforce rather than hard-failing client-side. + let virtual_addr = "0x12345678fdfdfdfdfdfdfdfdfdfdaabbccdd0011"; + let err = cmd + .cast_fuse() + .args([ + "tip403", + "create", + "whitelist", + "--admin", + "0x0000000000000000000000000000000000000001", + "--member", + virtual_addr, + "--private-key", + &pk, + "--rpc-url", + &rpc, + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + assert!(err.contains("looks like a TIP-1022 virtual address"), "{err}"); +} + +#[casttest] +async fn tip403_blacklist_semantics(cmd: _) { + let (_, handle) = + anvil::spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T6.into()))).await; + let rpc = handle.http_endpoint(); + let wallet = handle.dev_wallets().next().unwrap(); + let pk = hex::encode_prefixed(wallet.credential().to_bytes()); + let admin = wallet.address(); + let member = handle.dev_wallets().nth(1).unwrap().address(); + + cmd.cast_fuse() + .args([ + "tip403", + "create", + "blacklist", + "--admin", + &admin.to_string(), + "--private-key", + &pk, + "--rpc-url", + &rpc, + ]) + .assert_success(); + + // A blacklist authorizes everyone until they are explicitly added. + let before = cmd + .cast_fuse() + .args(["--json", "tip403", "check", "2", &member.to_string(), "--rpc-url", &rpc]) + .assert_success() + .get_output() + .stdout_lossy(); + assert_eq!(json_success_data(&before)["authorized"], true); + + cmd.cast_fuse() + .args([ + "tip403", + "blacklist", + "add", + "2", + &member.to_string(), + "--private-key", + &pk, + "--rpc-url", + &rpc, + ]) + .assert_success(); + let blocked = cmd + .cast_fuse() + .args(["--json", "tip403", "check", "2", &member.to_string(), "--rpc-url", &rpc]) + .assert_success() + .get_output() + .stdout_lossy(); + assert_eq!(json_success_data(&blocked)["authorized"], false); + + cmd.cast_fuse() + .args([ + "tip403", + "blacklist", + "remove", + "2", + &member.to_string(), + "--private-key", + &pk, + "--rpc-url", + &rpc, + ]) + .assert_success(); + let restored = cmd + .cast_fuse() + .args(["--json", "tip403", "check", "2", &member.to_string(), "--rpc-url", &rpc]) + .assert_success() + .get_output() + .stdout_lossy(); + assert_eq!(json_success_data(&restored)["authorized"], true); +} + +#[casttest] +async fn tip403_create_with_members(cmd: _) { + let (_, handle) = + anvil::spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T6.into()))).await; + let rpc = handle.http_endpoint(); + let wallet = handle.dev_wallets().next().unwrap(); + let pk = hex::encode_prefixed(wallet.credential().to_bytes()); + let admin = wallet.address(); + let member = handle.dev_wallets().nth(1).unwrap().address(); + + // `--member` seeds the whitelist via createPolicyWithAccounts, so the member is authorized + // immediately without a follow-up modify. + cmd.cast_fuse() + .args([ + "tip403", + "create", + "whitelist", + "--admin", + &admin.to_string(), + "--member", + &member.to_string(), + "--private-key", + &pk, + "--rpc-url", + &rpc, + ]) + .assert_success(); + let check = cmd + .cast_fuse() + .args(["--json", "tip403", "check", "2", &member.to_string(), "--rpc-url", &rpc]) + .assert_success() + .get_output() + .stdout_lossy(); + assert_eq!(json_success_data(&check)["authorized"], true); +} + +#[casttest] +async fn tip403_works_pre_t6(cmd: _) { + // TIP-403 is a Genesis precompile, so the base policy commands work before T6 activates. + let (_, handle) = + anvil::spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T5.into()))).await; + let rpc = handle.http_endpoint(); + let wallet = handle.dev_wallets().next().unwrap(); + let pk = hex::encode_prefixed(wallet.credential().to_bytes()); + let admin = wallet.address(); + let member = handle.dev_wallets().nth(1).unwrap().address(); + + cmd.cast_fuse() + .args([ + "tip403", + "create", + "whitelist", + "--admin", + &admin.to_string(), + "--private-key", + &pk, + "--rpc-url", + &rpc, + ]) + .assert_success(); + let info = cmd + .cast_fuse() + .args(["--json", "tip403", "info", "2", "--rpc-url", &rpc]) + .assert_success() + .get_output() + .stdout_lossy(); + assert_eq!(json_success_data(&info)["policy_type"], "whitelist"); + + cmd.cast_fuse() + .args([ + "tip403", + "whitelist", + "add", + "2", + &member.to_string(), + "--private-key", + &pk, + "--rpc-url", + &rpc, + ]) + .assert_success(); + let check = cmd + .cast_fuse() + .args(["--json", "tip403", "check", "2", &member.to_string(), "--rpc-url", &rpc]) + .assert_success() + .get_output() + .stdout_lossy(); + assert_eq!(json_success_data(&check)["authorized"], true); +} + +#[casttest] +async fn storage_credits_reads_and_writes(cmd: _) { + let (_, handle) = + anvil::spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T7.into()))).await; + let rpc = handle.http_endpoint(); + let wallet = handle.dev_wallets().next().unwrap(); + let pk = hex::encode_prefixed(wallet.credential().to_bytes()); + let account = wallet.address(); + + // A fresh account starts with no credits, the default `refund` mode, and a zero budget. + let balance = cmd + .cast_fuse() + .args(["--json", "storage-credits", "balance", &account.to_string(), "--rpc-url", &rpc]) + .assert_success() + .get_output() + .stdout_lossy(); + assert_eq!(json_success_data(&balance)["balance"], 0); + + let mode = cmd + .cast_fuse() + .args(["--json", "storage-credits", "mode", &account.to_string(), "--rpc-url", &rpc]) + .assert_success() + .get_output() + .stdout_lossy(); + assert_eq!(json_success_data(&mode)["mode"], "refund"); + + let budget = cmd + .cast_fuse() + .args(["--json", "storage-credits", "budget", &account.to_string(), "--rpc-url", &rpc]) + .assert_success() + .get_output() + .stdout_lossy(); + assert_eq!(json_success_data(&budget)["budget"], 0); + + // set-mode / set-budget send valid transactions that the precompile accepts. + cmd.cast_fuse() + .args(["storage-credits", "set-mode", "direct", "--private-key", &pk, "--rpc-url", &rpc]) + .assert_success(); + cmd.cast_fuse() + .args(["storage-credits", "set-budget", "42", "--private-key", &pk, "--rpc-url", &rpc]) + .assert_success(); + + // Mode and budget are transaction-local transient state (TIP-1060), so they reset to defaults + // after the setter transaction ends; a standalone read never observes the previous write. + let mode = cmd + .cast_fuse() + .args(["--json", "storage-credits", "mode", &account.to_string(), "--rpc-url", &rpc]) + .assert_success() + .get_output() + .stdout_lossy(); + assert_eq!(json_success_data(&mode)["mode"], "refund"); + + let budget = cmd + .cast_fuse() + .args(["--json", "storage-credits", "budget", &account.to_string(), "--rpc-url", &rpc]) + .assert_success() + .get_output() + .stdout_lossy(); + assert_eq!(json_success_data(&budget)["budget"], 0); +} + +#[casttest] +async fn storage_credits_require_t7(cmd: _) { + // The StorageCredits precompile only activates at T7, so reads must fail cleanly before then. + let (_, handle) = + anvil::spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T6.into()))).await; + let rpc = handle.http_endpoint(); + let account = handle.dev_wallets().next().unwrap().address(); + + let pk = hex::encode_prefixed(handle.dev_wallets().next().unwrap().credential().to_bytes()); + let expected = "requires a Tempo T7-capable StorageCredits RPC"; + + let read_err = cmd + .cast_fuse() + .args(["storage-credits", "balance", &account.to_string(), "--rpc-url", &rpc]) + .assert_failure() + .get_output() + .stderr_lossy(); + assert!(read_err.contains(expected), "{read_err}"); + + // Writes must fail closed too; otherwise a pre-T7 send would look like a successful no-op. + let set_mode_err = cmd + .cast_fuse() + .args(["storage-credits", "set-mode", "direct", "--private-key", &pk, "--rpc-url", &rpc]) + .assert_failure() + .get_output() + .stderr_lossy(); + assert!(set_mode_err.contains(expected), "{set_mode_err}"); + + let set_budget_err = cmd + .cast_fuse() + .args(["storage-credits", "set-budget", "1", "--private-key", &pk, "--rpc-url", &rpc]) + .assert_failure() + .get_output() + .stderr_lossy(); + assert!(set_budget_err.contains(expected), "{set_budget_err}"); +} + +#[casttest] +async fn current_committee_cast_run_decoding(cmd: _) { + let (_, handle) = + anvil::spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T8.into()))).await; + let provider = handle.http_provider(); + let caller = handle.dev_accounts().next().unwrap(); + let committee = ICurrentCommittee::new(CURRENT_COMMITTEE_ADDRESS, &provider); + let public_key = B256::repeat_byte(0x11); + + let getter = TransactionRequest::default() + .from(caller) + .to(CURRENT_COMMITTEE_ADDRESS) + .with_input(committee.getCommitteeMembers().calldata().clone()) + .with_gas_limit(1_000_000); + let getter_receipt = provider + .send_transaction(WithOtherFields::new(getter)) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert!(getter_receipt.status()); + + cmd.cast_fuse(); + cmd.env("FOUNDRY_HARDFORK", "tempo:T8"); + let getter_stdout = cmd + .args([ + "run", + &getter_receipt.transaction_hash.to_string(), + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success() + .get_output() + .stdout_lossy(); + assert!(getter_stdout.contains("CurrentCommittee::getCommitteeMembers()"), "{getter_stdout}"); + assert!(getter_stdout.contains("← [Return] 0, []"), "{getter_stdout}"); + + let setter = TransactionRequest::default() + .from(caller) + .to(CURRENT_COMMITTEE_ADDRESS) + .with_input(committee.setCommitteeMembers(1, vec![public_key]).calldata().clone()) + .with_gas_limit(1_000_000); + let setter_receipt = provider + .send_transaction(WithOtherFields::new(setter)) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert!(!setter_receipt.status()); + + cmd.cast_fuse(); + cmd.env("FOUNDRY_HARDFORK", "tempo:T8"); + let setter_stdout = cmd + .args([ + "run", + &setter_receipt.transaction_hash.to_string(), + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success() + .get_output() + .stdout_lossy(); + assert!(setter_stdout.contains("CurrentCommittee::setCommitteeMembers(1"), "{setter_stdout}"); + assert!(setter_stdout.contains("← [Revert] Unauthorized()"), "{setter_stdout}"); +} + +#[casttest] +fn tip20_logo_create_help_includes_logo_uri(cmd: _) { + let output = cmd + .cast_fuse() + .args(["tip20", "create", "--help"]) + .assert_success() + .get_output() + .stdout_lossy(); + + assert!( + output.contains("--logo-uri "), + "expected tip20 create help to expose --logo-uri, got:\n{output}", + ); +} + +#[casttest] +fn tip20_logo_commands_expose_browser_and_remote_sponsor_options(cmd: _) { + for args in [["tip20", "create", "--help"], ["tip20", "logo-set", "--help"]] { + let output = cmd.cast_fuse().args(args).assert_success().get_output().stdout_lossy(); + assert!(output.contains("--browser"), "expected --browser in help, got:\n{output}"); + assert!( + output.contains("--sponsor-url "), + "expected --sponsor-url in help, got:\n{output}" + ); + } +} + +#[casttest] +fn mktx_rejects_remote_sponsor_instead_of_ignoring_it(cmd: _) { + let stderr = cmd + .cast_fuse() + .args([ + "mktx", + "0x0000000000000000000000000000000000000001", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--sponsor-url", + "http://localhost:1", + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + + assert!(stderr.contains("--sponsor-url is not supported by cast mktx"), "{stderr}"); +} + +#[casttest] +async fn send_with_presigned_sponsor_signature_keeps_digest_stable(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let provider = handle.http_provider(); + let accounts: Vec
= handle.dev_accounts().collect(); + let sender = accounts[0]; + let sponsor = accounts[1]; + let filler = accounts[2]; + let recipient = accounts[3]; + let sender_wallet = handle.dev_wallets().next().unwrap(); + assert_eq!(sender_wallet.address(), sender); + let sender_pk = hex::encode_prefixed(sender_wallet.credential().to_bytes()); + let sponsor_wallet = handle.dev_wallets().nth(1).unwrap(); + assert_eq!(sponsor_wallet.address(), sponsor); + let sponsor_pk = hex::encode_prefixed(sponsor_wallet.credential().to_bytes()); + let token = PATH_USD_ADDRESS.to_string(); + let recipient_arg = recipient.to_string(); + let sponsor_arg = sponsor.to_string(); + + // The sponsor digest commits to the full transaction, so nonce, gas limit and fees must be + // pinned for `cast mktx` and `cast send` to build the identical transaction. The gas limit + // must cover actual usage: a Tempo transaction that runs out of gas during AA validation is + // dropped without a receipt. + let nonce = provider.get_transaction_count(sender).await.unwrap().to_string(); + let pinned = [ + "--nonce", + nonce.as_str(), + "--gas-limit", + "1000000", + "--gas-price", + "40gwei", + "--priority-gas-price", + "1gwei", + ]; + + let mut mktx_args = vec![ + "mktx", + &token, + "transfer(address,uint256)", + &recipient_arg, + "1", + "--private-key", + &sender_pk, + "--rpc-url", + &rpc, + "--tempo.print-sponsor-hash", + "--tempo.sponsor", + &sponsor_arg, + ]; + mktx_args.extend(pinned); + let hash = cmd + .cast_fuse() + .args(&mktx_args) + .assert_success() + .get_output() + .stdout_lossy() + .trim() + .to_string(); + assert!(hash.starts_with("0x") && hash.len() == 66, "unexpected sponsor hash: {hash}"); + + let sig = cmd + .cast_fuse() + .args(["wallet", "sign", "--private-key", &sponsor_pk, "--no-hash", &hash]) + .assert_success() + .get_output() + .stdout_lossy() + .trim() + .to_string(); + + // Mine an unrelated transaction between hash generation and send; the pinned fields must + // keep the digest stable across the chain-state change. + let tip20 = ITIP20::new(PATH_USD_ADDRESS, &provider); + let filler_tx = TransactionRequest::default() + .from(filler) + .to(PATH_USD_ADDRESS) + .with_input(tip20.transfer(recipient, U256::from(5u64)).calldata().clone()) + .with_gas_limit(10_000_000); + let filler_receipt = provider + .send_transaction(WithOtherFields::new(filler_tx)) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert!(filler_receipt.status(), "filler transfer should succeed"); + + let mut send_args = vec![ + "send", + &token, + "transfer(address,uint256)", + &recipient_arg, + "1", + "--private-key", + &sender_pk, + "--rpc-url", + &rpc, + "--tempo.sponsor", + &sponsor_arg, + "--tempo.sponsor-signature", + &sig, + "--json", + ]; + send_args.extend(pinned); + let send_assert = cmd.cast_fuse().args(&send_args).assert_success(); + let output = send_assert.get_output(); + + let stderr = output.stderr_lossy(); + assert!( + stderr.to_lowercase().contains(&format!("tempo sponsor digest: {}", hash.to_lowercase())), + "sponsor digest drifted from the pre-signed hash {hash}:\n{stderr}" + ); + + let receipt: serde_json::Value = + parse_json(output.stdout_lossy().trim()).expect("receipt should be JSON"); + assert_eq!(receipt["status"], "0x1", "unexpected receipt: {receipt}"); + let fee_payer: Address = + receipt["feePayer"].as_str().expect("receipt has feePayer").parse().unwrap(); + assert_eq!(fee_payer, sponsor, "receipt fee payer should be the sponsor"); +} + +#[casttest] +async fn send_with_presigned_sponsor_signature_rejects_stale_digest(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let provider = handle.http_provider(); + let accounts: Vec
= handle.dev_accounts().collect(); + let sender = accounts[0]; + let sponsor = accounts[1]; + let recipient = accounts[2]; + let sender_wallet = handle.dev_wallets().next().unwrap(); + assert_eq!(sender_wallet.address(), sender); + let sender_pk = hex::encode_prefixed(sender_wallet.credential().to_bytes()); + let sponsor_wallet = handle.dev_wallets().nth(1).unwrap(); + assert_eq!(sponsor_wallet.address(), sponsor); + let sponsor_pk = hex::encode_prefixed(sponsor_wallet.credential().to_bytes()); + let token = PATH_USD_ADDRESS.to_string(); + let recipient_arg = recipient.to_string(); + let sponsor_arg = sponsor.to_string(); + + // Without pinned fields, both commands fill nonce and fees from live chain state. + let hash = cmd + .cast_fuse() + .args([ + "mktx", + &token, + "transfer(address,uint256)", + &recipient_arg, + "1", + "--private-key", + &sender_pk, + "--rpc-url", + &rpc, + "--tempo.print-sponsor-hash", + ]) + .assert_success() + .get_output() + .stdout_lossy() + .trim() + .to_string(); + + let sig = cmd + .cast_fuse() + .args(["wallet", "sign", "--private-key", &sponsor_pk, "--no-hash", &hash]) + .assert_success() + .get_output() + .stdout_lossy() + .trim() + .to_string(); + + // Consume the sender nonce so the refilled transaction no longer matches the signed digest. + let tip20 = ITIP20::new(PATH_USD_ADDRESS, &provider); + let bump_tx = TransactionRequest::default() + .from(sender) + .to(PATH_USD_ADDRESS) + .with_input(tip20.transfer(recipient, U256::ONE).calldata().clone()) + .with_gas_limit(10_000_000); + let bump_receipt = provider + .send_transaction(WithOtherFields::new(bump_tx)) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert!(bump_receipt.status(), "nonce bump transfer should succeed"); + + let stderr = cmd + .cast_fuse() + .args([ + "send", + &token, + "transfer(address,uint256)", + &recipient_arg, + "1", + "--private-key", + &sender_pk, + "--rpc-url", + &rpc, + "--tempo.sponsor", + &sponsor_arg, + "--tempo.sponsor-signature", + &sig, + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + + assert!(stderr.contains("Tempo sponsor signature recovered"), "{stderr}"); + assert!(stderr.contains("--tempo.print-sponsor-hash"), "{stderr}"); +} + +#[casttest] +async fn send_with_sponsor_url_uses_anvil_builtin_fee_payer(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let provider = handle.http_provider(); + let accounts: Vec
= handle.dev_accounts().collect(); + let sender = accounts[0]; + let sponsor = *accounts.last().unwrap(); + let recipient = accounts[3]; + let sender_wallet = handle.dev_wallets().next().unwrap(); + assert_eq!(sender_wallet.address(), sender); + let sender_pk = hex::encode_prefixed(sender_wallet.credential().to_bytes()); + let token = PATH_USD_ADDRESS.to_string(); + let recipient_arg = recipient.to_string(); + let amount = U256::from(1000u64); + + let tip20 = ITIP20::new(PATH_USD_ADDRESS, &provider); + let sender_before = tip20.balanceOf(sender).call().await.unwrap(); + let sponsor_before = tip20.balanceOf(sponsor).call().await.unwrap(); + + // No local sponsor key or address: anvil's built-in fee payer signs the sponsorship request + // via `eth_signRawTransaction`, so the sponsor URL is simply the node itself. + let stdout = cmd + .cast_fuse() + .args([ + "send", + &token, + "transfer(address,uint256)", + &recipient_arg, + "1000", + "--private-key", + &sender_pk, + "--rpc-url", + &rpc, + "--sponsor-url", + &rpc, + "--json", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + let receipt: serde_json::Value = + serde_json::from_str(stdout.trim()).expect("receipt should be JSON"); + assert_eq!(receipt["status"], "0x1", "unexpected receipt: {receipt}"); + assert_eq!( + receipt["feePayer"], + serde_json::to_value(sponsor).unwrap(), + "fee payer should be anvil's default sponsor (last dev account): {receipt}" + ); + assert_eq!( + receipt["feeToken"], + serde_json::to_value(PATH_USD_ADDRESS).unwrap(), + "sponsor pays with its stored fee token: {receipt}" + ); + + let sender_after = tip20.balanceOf(sender).call().await.unwrap(); + let sponsor_after = tip20.balanceOf(sponsor).call().await.unwrap(); + assert_eq!( + sender_after, + sender_before - amount, + "sender must only pay the transfer amount, fees are sponsored" + ); + assert!(sponsor_after < sponsor_before, "sponsor must pay the transaction fee"); +} + +#[casttest] +fn tip20_logo_check_accepts_valid_values(cmd: _) { + for uri in ["", "https://example.com/logo.png", "HTTP://example.com/logo.png", "ipfs://token"] { + cmd.cast_fuse().args(["tip20", "logo-check", uri]).assert_success(); + } +} + +#[casttest] +fn tip20_logo_check_rejects_invalid_values(cmd: _) { + let invalid = cmd + .cast_fuse() + .args(["tip20", "logo-check", "ftp://example.com/logo.png"]) + .assert_failure() + .get_output() + .stderr_lossy(); + assert!(invalid.contains("InvalidLogoURI"), "got:\n{invalid}"); + + let too_long = format!("https://{}", "a".repeat(249)); + let output = cmd + .cast_fuse() + .args(["tip20", "logo-check", &too_long]) + .assert_failure() + .get_output() + .stderr_lossy(); + assert!(output.contains("LogoURITooLong"), "got:\n{output}"); +} + +#[casttest] +fn tip20_create_validates_logo_uri_before_network_setup(cmd: _) { + let output = cmd + .cast_fuse() + .args([ + "tip20", + "create", + "Logo Token", + "LOGO", + "USD", + "0x0000000000000000000000000000000000000001", + "0x0000000000000000000000000000000000000002", + "0x0000000000000000000000000000000000000000000000000000000000000003", + "--logo-uri", + "ftp://example.com/logo.png", + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + + assert!(output.contains("client-side validation failed: InvalidLogoURI"), "got:\n{output}"); +} + +#[casttest] +fn tip20_logo_set_validates_logo_uri_before_network_setup(cmd: _) { + let output = cmd + .cast_fuse() + .args([ + "tip20", + "logo-set", + "0x0000000000000000000000000000000000000001", + "ftp://example.com/logo.png", + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + + assert!(output.contains("client-side validation failed: InvalidLogoURI"), "got:\n{output}"); +} + +#[casttest] +async fn channel_id_defaults(cmd: _) { + let (_api, handle) = + anvil::spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T5.into()))).await; + let provider = handle.http_provider(); + let chain_id = provider.get_chain_id().await.unwrap(); + + let payer = address!("0000000000000000000000000000000000000101"); + let payee = address!("0000000000000000000000000000000000000202"); + let salt = B256::with_last_byte(0x42); + let expected = keccak256( + ( + payer, + payee, + Address::ZERO, + PATH_USD_ADDRESS, + salt, + Address::ZERO, + B256::ZERO, + TIP20_CHANNEL_RESERVE_ADDRESS, + U256::from(chain_id), + ) + .abi_encode(), + ); + + cmd.args([ + "channel-id", + &payer.to_string(), + &payee.to_string(), + &PATH_USD_ADDRESS.to_string(), + &salt.to_string(), + "--rpc-url", + handle.http_endpoint().as_str(), + ]) + .assert_success() + .stdout_eq(format!("{expected:#x}\n")); +} + +#[casttest] +fn tempo_options_reject_conflicting_network(prj: _, cmd: _) { + prj.update_config(|config| { + config.networks = foundry_evm_networks::NetworkVariant::Ethereum.into(); + }); + for command in ["access-list", "estimate", "send", "mktx", "call"] { + cmd.cast_fuse() + .current_dir(prj.root()) + .args([ + command, + "0x0000000000000000000000000000000000000001", + "--tempo.fee-token", + "0x20c0000000000000000000000000000000000000", + "--rpc-url", + "http://127.0.0.1:1", + ]) + .assert_failure() + .stderr_eq(str![[r#" +Error: Tempo transaction options conflict with configured network `ethereum` + +"#]]); + } +} + +#[casttest] +fn tempo_sessions_reject_conflicting_network(prj: _, cmd: _) { + prj.update_config(|config| { + config.networks = foundry_evm_networks::NetworkVariant::Ethereum.into(); + }); + for command in ["access-list", "estimate", "send", "mktx", "call"] { + cmd.cast_fuse() + .current_dir(prj.root()) + .args([ + command, + "0x0000000000000000000000000000000000000001", + "--tempo.session", + "0x4444444444444444444444444444444444444444444444444444444444444444", + "--rpc-url", + "http://127.0.0.1:1", + ]) + .assert_failure() + .stderr_eq(str![[r#" +Error: Tempo transaction options conflict with configured network `ethereum` + +"#]]); + } +} + +#[casttest] +async fn tempo_mktx_selects_network_without_tempo_options(prj: _, cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo().with_chain_id(Some(4217u64))).await; + let rpc = handle.http_endpoint(); + for network in [ + None, + Some(foundry_evm_networks::NetworkVariant::Tempo), + Some(foundry_evm_networks::NetworkVariant::Ethereum), + ] { + prj.update_config(|config| { + config.networks = network.map(Into::into).unwrap_or_default(); + }); + let expected = if network == Some(foundry_evm_networks::NetworkVariant::Ethereum) { + str![[r#" +0x02[..] + +"#]] + } else { + str![[r#" +0x76[..] + +"#]] + }; + cmd.cast_fuse() + .current_dir(prj.root()) + .args([ + "mktx", + "0x0000000000000000000000000000000000000001", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--rpc-url", + &rpc, + ]) + .assert_success() + .stdout_eq(expected); + } +} + +// A local Tempo node runs on chain 31337; once Tempo is selected, fee tokens must resolve as they +// do on a canonical Tempo chain ID. +#[casttest] +async fn tempo_selected_network_ignores_local_chain_id(prj: _, cmd: _) { + prj.update_config(|config| { + config.networks = foundry_evm_networks::NetworkVariant::Tempo.into(); + }); + let private_key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + let sponsor_key = + "private-key://0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6"; + for chain_id in [Some(4217u64), None] { + let (_, handle) = anvil::spawn(NodeConfig::test_tempo().with_chain_id(chain_id)).await; + let rpc = handle.http_endpoint(); + let mktx = ["mktx", "0x0000000000000000000000000000000000000001", "--private-key"]; + + cmd.cast_fuse() + .current_dir(prj.root()) + .args(mktx) + .args([private_key, "--rpc-url", &rpc]) + .assert_success() + .stdout_eq(str![[r#" +0x76[..] + +"#]]) + .stderr_eq(str![[r#" +Paying gas in AlphaUSD (0x20C0000000000000000000000000000000000001) + +"#]]); + + cmd.cast_fuse() + .current_dir(prj.root()) + .args(mktx) + .args([private_key, "--rpc-url", &rpc]) + .args(["--tempo.sponsor", "0xa0Ee7A142d267C1f36714E4a8F75612F20a79720"]) + .args(["--tempo.sponsor-signer", sponsor_key]) + .assert_success() + .stdout_eq(str![[r#" +0x76[..] + +"#]]) + .stderr_eq(str![[r#" +Tempo sponsor: 0xa0Ee7A142d267C1f36714E4a8F75612F20a79720 +Tempo fee token: 0x20C0000000000000000000000000000000000000 +Tempo validity: after none, before none +Tempo sponsor digest: 0x[..] + +"#]]); + } +} + +#[casttest] +fn tempo_zone_rejects_zero_amount(cmd: _) { + for args in [ + vec!["tempo", "zone", "deposit", "--portal", "0x1111111111111111111111111111111111111111"], + vec!["tempo", "zone", "withdraw", "--zone-id", "42", "--zone-chain-id", "1337"], + ] { + cmd.cast_fuse() + .args(args) + .args(["--amount", "0"]) + .assert_failure() + .stdout_eq("") + .stderr_eq(str![[r#" +Error: amount must be greater than zero + +"#]]); + } +} + +#[casttest] +fn tempo_zone_rejects_callback_without_gas(cmd: _) { + cmd.args([ + "tempo", + "zone", + "withdraw", + "--zone-id", + "7", + "--zone-chain-id", + "421700007", + "--amount", + "1", + "--callback-data", + "0x1234", + ]) + .assert_failure() + .stdout_eq("") + .stderr_eq(str![[r#" +Error: --callback-data requires a nonzero --callback-gas-limit + +"#]]); +} + +// TIP-1106: from T12 the nonce of an expiring nonce transaction is an opaque discriminator. +#[casttest] +async fn tempo_expiring_nonce_keeps_explicit_discriminator(cmd: _) { + let (_, handle) = + anvil::spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T12.into()))).await; + let rpc = handle.http_endpoint(); + let sender = handle.dev_accounts().next().unwrap().to_string(); + let valid_before = + (SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + 25).to_string(); + + // Every field but the nonce is pinned, so the two transactions are otherwise identical. + let mut transactions = Vec::new(); + for nonce in ["7", "8"] { + let raw = cmd + .cast_fuse() + .args([ + "mktx", + "0x0000000000000000000000000000000000000001", + "--nonce", + nonce, + "--tempo.expiring-nonce", + "--tempo.valid-before", + &valid_before, + "--gas-limit", + "100000", + "--gas-price", + "20000000000", + "--priority-gas-price", + "1", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--rpc-url", + &rpc, + ]) + .assert_success() + .get_output() + .stdout_lossy(); + let raw = raw.trim(); + let TempoTxEnvelope::AA(signed) = + TempoTxEnvelope::decode_2718_exact(&hex::decode(raw).unwrap()).unwrap() + else { + panic!("expected a Tempo AA transaction"); + }; + transactions.push(signed.tx().clone()); + + let hash = cmd + .cast_fuse() + .args(["publish", "--async", raw, "--rpc-url", &rpc]) + .assert_success() + .get_output() + .stdout_lossy(); + cmd.cast_fuse() + .args(["receipt", hash.trim(), "status", "--rpc-url", &rpc]) + .assert_success() + .stdout_eq(str![[r#" +true + +"#]]); + } + + let [mut first, second] = <[_; 2]>::try_from(transactions).unwrap(); + assert_eq!((first.nonce, second.nonce), (7, 8)); + assert_eq!(first.nonce_key, U256::MAX); + first.nonce = second.nonce; + assert_eq!(first, second); + + // The discriminator never touches nonce state. + cmd.cast_fuse().args(["nonce", &sender, "--rpc-url", &rpc]).assert_success().stdout_eq(str![[ + r#" +0 + +"# + ]]); +} + +#[casttest] +async fn tempo_expiring_nonce_discriminator_requires_t12(cmd: _) { + let (_, t11) = + anvil::spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T11.into()))).await; + let (_, t12) = + anvil::spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T12.into()))).await; + let mktx = [ + "mktx", + "0x0000000000000000000000000000000000000001", + "--nonce", + "7", + "--tempo.expires", + "20", + "--gas-limit", + "100000", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--rpc-url", + ]; + + // Cast does not build a discriminator for a chain that is known to reject it. + cmd.cast_fuse() + .args(mktx) + .arg(t11.http_endpoint()) + .assert_failure() + .stdout_eq("") + .stderr_eq(str![[r#" +Error: expiring nonce transactions must use nonce 0 before the Tempo T12 hardfork, got nonce 7; non-zero expiring nonce discriminators (TIP-1106) are not active on this chain + +"#]]); + + // The node enforces the same rule for a transaction that was built for T12. + let raw = cmd + .cast_fuse() + .args(mktx) + .arg(t12.http_endpoint()) + .assert_success() + .get_output() + .stdout_lossy(); + cmd.cast_fuse() + .args(["publish", raw.trim(), "--rpc-url", &t11.http_endpoint()]) + .assert_failure() + .stdout_eq("") + .stderr_eq(str![[r#" +Error: server returned an error response: error code -32603: tempo transaction error: expiring nonce transaction must have nonce == 0 before T12 + +"#]]); +} + +/// Returns the address and private key of the dev account at `index`. +fn dev_account(handle: &anvil::NodeHandle, index: usize) -> (Address, String) { + let wallet = handle.dev_wallets().nth(index).unwrap(); + (wallet.address(), hex::encode_prefixed(wallet.credential().to_bytes())) +} + +/// Creates a TIP-20 token administered by the first dev account and returns its address. +async fn create_role_test_token( + cmd: &mut foundry_test_utils::TestCommand, + handle: &anvil::NodeHandle, +) -> Address { + let (admin, admin_pk) = dev_account(handle, 0); + let salt = B256::with_last_byte(1); + cmd.cast_fuse() + .args([ + "tip20", + "create", + "Role Test", + "ROLE", + "USD", + &PATH_USD_ADDRESS.to_string(), + &admin.to_string(), + &salt.to_string(), + "--private-key", + &admin_pk, + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success(); + ITIP20Factory::new(TIP20_FACTORY_ADDRESS, handle.http_provider()) + .getTokenAddress(admin, salt) + .call() + .await + .unwrap() +} + +/// Expected `cast --json tip20 has-role` output. +fn has_role_json( + token: Address, + role: B256, + role_name: Option<&str>, + account: Address, + has_role: bool, +) -> String { + serde_json::json!({ + "schema_version": 1, + "success": true, + "data": { + "token": token.to_string(), + "role": role.to_string(), + "role_name": role_name, + "account": account.to_string(), + "has_role": has_role, + }, + "errors": [], + "warnings": [], + }) + .to_string() +} + +// A freshly created token only assigns `DEFAULT_ADMIN_ROLE`, so minting is gated on granting +// `ISSUER_ROLE` and stops working again once the role is revoked. +#[casttest] +async fn tip20_issuer_role_grant_and_revoke_gate_minting(cmd: _) { + let (_, handle) = + anvil::spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T12.into()))).await; + let rpc = handle.http_endpoint(); + let provider = handle.http_provider(); + let (admin, admin_pk) = dev_account(&handle, 0); + let token = create_role_test_token(&mut cmd, &handle).await; + let token_arg = token.to_string(); + let admin_arg = admin.to_string(); + let tip20 = ITIP20::new(token, &provider); + let issuer_role = tip20.ISSUER_ROLE().call().await.unwrap(); + + cmd.cast_fuse() + .args(["tip20", "has-role", &token_arg, "admin", &admin_arg, "--rpc-url", &rpc]) + .assert_success() + .stdout_eq(str![[r#" +Token: 0x20c000000000000000000000A3C1274aaDd82e4D +Role: DEFAULT_ADMIN_ROLE (0x0000000000000000000000000000000000000000000000000000000000000000) +Account: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 +Has role: true + +"#]]); + cmd.cast_fuse() + .args(["--json", "tip20", "has-role", &token_arg, "issuer", &admin_arg, "--rpc-url", &rpc]) + .assert_json_stdout(has_role_json(token, issuer_role, Some("ISSUER_ROLE"), admin, false)); + + let mint = ["erc20", "mint", &token_arg, &admin_arg, "1000", "--private-key", &admin_pk]; + cmd.cast_fuse().args(mint).args(["--rpc-url", &rpc]).assert_failure().stderr_eq(str![[r#" +Error: Failed to estimate gas: server returned an error response: error code 3: execution reverted: custom error 0x82b42900, data: "0x82b42900": Unauthorized + +"#]]); + + cmd.cast_fuse() + .args(["tip20", "grant-role", &token_arg, "issuer", &admin_arg, "--private-key", &admin_pk]) + .args(["--rpc-url", &rpc]) + .assert_success(); + cmd.cast_fuse() + .args(["--json", "tip20", "has-role", &token_arg, "ISSUER_ROLE", &admin_arg]) + .args(["--rpc-url", &rpc]) + .assert_json_stdout(has_role_json(token, issuer_role, Some("ISSUER_ROLE"), admin, true)); + + cmd.cast_fuse().args(mint).args(["--rpc-url", &rpc]).assert_success(); + assert_eq!(tip20.balanceOf(admin).call().await.unwrap(), U256::from(1000)); + + cmd.cast_fuse() + .args([ + "tip20", + "revoke-role", + &token_arg, + "issuer", + &admin_arg, + "--private-key", + &admin_pk, + ]) + .args(["--rpc-url", &rpc]) + .assert_success(); + cmd.cast_fuse() + .args(["--json", "tip20", "has-role", &token_arg, "issuer", &admin_arg, "--rpc-url", &rpc]) + .assert_json_stdout(has_role_json(token, issuer_role, Some("ISSUER_ROLE"), admin, false)); + cmd.cast_fuse().args(mint).args(["--rpc-url", &rpc]).assert_failure(); + assert_eq!(tip20.balanceOf(admin).call().await.unwrap(), U256::from(1000)); +} + +// T12 activates TIP-1006 `burnAt`, which only accounts holding `BURN_AT_ROLE` may call. +#[casttest] +async fn tip20_burn_at_role_enables_burn_at_on_t12(cmd: _) { + let (_, handle) = + anvil::spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T12.into()))).await; + let rpc = handle.http_endpoint(); + let provider = handle.http_provider(); + let (admin, admin_pk) = dev_account(&handle, 0); + let (burner, burner_pk) = dev_account(&handle, 1); + let holder = handle.dev_accounts().nth(2).unwrap(); + let token = create_role_test_token(&mut cmd, &handle).await; + let token_arg = token.to_string(); + let burner_arg = burner.to_string(); + let holder_arg = holder.to_string(); + let tip20 = ITIP20::new(token, &provider); + let burn_at_role = tip20.BURN_AT_ROLE().call().await.unwrap(); + + cmd.cast_fuse() + .args(["tip20", "grant-role", &token_arg, "issuer", &admin.to_string()]) + .args(["--private-key", &admin_pk, "--rpc-url", &rpc]) + .assert_success(); + cmd.cast_fuse() + .args(["erc20", "mint", &token_arg, &holder_arg, "1000"]) + .args(["--private-key", &admin_pk, "--rpc-url", &rpc]) + .assert_success(); + + let burn_at = ["send", &token_arg, "burnAt(address,uint256)", &holder_arg, "400"]; + cmd.cast_fuse() + .args(burn_at) + .args(["--private-key", &burner_pk, "--rpc-url", &rpc]) + .assert_failure() + .stderr_eq(str![[r#" +Error: Failed to estimate gas: server returned an error response: error code 3: execution reverted: custom error 0x82b42900, data: "0x82b42900": Unauthorized + +"#]]); + + cmd.cast_fuse() + .args(["tip20", "grant-role", &token_arg, "burn-at", &burner_arg]) + .args(["--private-key", &admin_pk, "--rpc-url", &rpc]) + .assert_success(); + cmd.cast_fuse() + .args([ + "--json", + "tip20", + "has-role", + &token_arg, + "burn-at", + &burner_arg, + "--rpc-url", + &rpc, + ]) + .assert_json_stdout(has_role_json(token, burn_at_role, Some("BURN_AT_ROLE"), burner, true)); + + cmd.cast_fuse() + .args(burn_at) + .args(["--private-key", &burner_pk, "--rpc-url", &rpc]) + .assert_success(); + assert_eq!(tip20.balanceOf(holder).call().await.unwrap(), U256::from(600)); + assert_eq!(tip20.totalSupply().call().await.unwrap(), U256::from(600)); +} + +// Role updates are rejected before a transaction is sent when the sender does not hold the +// role's admin role, including an admin role reconfigured to a role the precompile does not name. +#[casttest] +async fn tip20_role_updates_require_role_admin(cmd: _) { + let (_, handle) = + anvil::spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T12.into()))).await; + let rpc = handle.http_endpoint(); + let provider = handle.http_provider(); + let (admin, admin_pk) = dev_account(&handle, 0); + let (delegate, delegate_pk) = dev_account(&handle, 1); + let token = create_role_test_token(&mut cmd, &handle).await; + let token_arg = token.to_string(); + let admin_arg = admin.to_string(); + let delegate_arg = delegate.to_string(); + let issuer_role = keccak256("ISSUER_ROLE"); + let issuer_admin_role = B256::with_last_byte(0xab); + let issuer_admin_role_arg = issuer_admin_role.to_string(); + + cmd.cast_fuse() + .args(["tip20", "grant-role", &token_arg, "issuer", &delegate_arg]) + .args(["--private-key", &delegate_pk, "--rpc-url", &rpc]) + .assert_failure() + .stderr_eq(str![[r#" +Error: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 cannot grant ISSUER_ROLE on TIP-20 token 0x20c000000000000000000000A3C1274aaDd82e4D: it does not hold DEFAULT_ADMIN_ROLE, the role's admin role + +"#]]); + cmd.cast_fuse() + .args(["tip20", "revoke-role", &token_arg, "admin", &admin_arg]) + .args(["--private-key", &delegate_pk, "--rpc-url", &rpc]) + .assert_failure() + .stderr_eq(str![[r#" +Error: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 cannot revoke DEFAULT_ADMIN_ROLE on TIP-20 token 0x20c000000000000000000000A3C1274aaDd82e4D: it does not hold DEFAULT_ADMIN_ROLE, the role's admin role + +"#]]); + assert_eq!(provider.get_transaction_count(delegate).await.unwrap(), 0); + + let set_role_admin = TransactionRequest::default() + .from(admin) + .to(token) + .with_input( + IRolesAuth::new(token, &provider) + .setRoleAdmin(issuer_role, issuer_admin_role) + .calldata() + .clone(), + ) + .with_gas_limit(10_000_000); + let receipt = provider + .send_transaction(WithOtherFields::new(set_role_admin)) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert!(receipt.status(), "setRoleAdmin should succeed"); + + cmd.cast_fuse() + .args(["tip20", "grant-role", &token_arg, "issuer", &admin_arg]) + .args(["--private-key", &admin_pk, "--rpc-url", &rpc]) + .assert_failure() + .stderr_eq(str![[r#" +Error: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 cannot grant ISSUER_ROLE on TIP-20 token 0x20c000000000000000000000A3C1274aaDd82e4D: it does not hold 0x00000000000000000000000000000000000000000000000000000000000000ab, the role's admin role + +"#]]); + + cmd.cast_fuse() + .args(["tip20", "grant-role", &token_arg, &issuer_admin_role_arg, &delegate_arg]) + .args(["--private-key", &admin_pk, "--rpc-url", &rpc]) + .assert_success(); + cmd.cast_fuse() + .args(["tip20", "has-role", &token_arg, &issuer_admin_role_arg, &delegate_arg]) + .args(["--rpc-url", &rpc]) + .assert_success() + .stdout_eq(str![[r#" +Token: 0x20c000000000000000000000A3C1274aaDd82e4D +Role: 0x00000000000000000000000000000000000000000000000000000000000000ab +Account: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 +Has role: true + +"#]]); + + cmd.cast_fuse() + .args(["tip20", "grant-role", &token_arg, "issuer", &admin_arg]) + .args(["--private-key", &delegate_pk, "--rpc-url", &rpc]) + .assert_success(); + cmd.cast_fuse() + .args(["--json", "tip20", "has-role", &token_arg, "issuer", &admin_arg, "--rpc-url", &rpc]) + .assert_json_stdout(has_role_json(token, issuer_role, Some("ISSUER_ROLE"), admin, true)); +} + +// Roles are plain hashes, so `BURN_AT_ROLE` can be granted ahead of T12 even though the `burnAt` +// selector it guards is not active yet. +#[casttest] +async fn tip20_burn_at_role_can_be_granted_before_t12(cmd: _) { + let (_, handle) = + anvil::spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T11.into()))).await; + let rpc = handle.http_endpoint(); + let (_, admin_pk) = dev_account(&handle, 0); + let (burner, burner_pk) = dev_account(&handle, 1); + let holder = handle.dev_accounts().nth(2).unwrap(); + let token = create_role_test_token(&mut cmd, &handle).await; + let token_arg = token.to_string(); + let burner_arg = burner.to_string(); + let burn_at_role = keccak256("BURN_AT_ROLE"); + + cmd.cast_fuse() + .args(["tip20", "grant-role", &token_arg, "burn-at", &burner_arg]) + .args(["--private-key", &admin_pk, "--rpc-url", &rpc]) + .assert_success(); + cmd.cast_fuse() + .args([ + "--json", + "tip20", + "has-role", + &token_arg, + "burn-at", + &burner_arg, + "--rpc-url", + &rpc, + ]) + .assert_json_stdout(has_role_json(token, burn_at_role, Some("BURN_AT_ROLE"), burner, true)); + + cmd.cast_fuse() + .args(["send", &token_arg, "burnAt(address,uint256)", &holder.to_string(), "1"]) + .args(["--private-key", &burner_pk, "--rpc-url", &rpc]) + .assert_failure() + .stderr_eq(str![[r#" +Error: Failed to estimate gas: server returned an error response: error code 3: execution reverted: custom error 0xaa4bc69a: 9803f21600000000000000000000000000000000000000000000000000000000, data: "0xaa4bc69a9803f21600000000000000000000000000000000000000000000000000000000": UnknownFunctionSelector(0x9803f216) + +"#]]); +} + +#[casttest] +fn tip20_role_commands_reject_unknown_role_names(cmd: _) { + cmd.cast_fuse() + .args(["tip20", "has-role", &PATH_USD_ADDRESS.to_string(), "minter"]) + .arg("0x0000000000000000000000000000000000000001") + .assert_failure() + .stderr_eq(str![[r#" +error: invalid value 'minter' for '': unknown TIP-20 role `minter`; expected one of admin, issuer, pause, unpause, burn-blocked, burn-at, or a 32-byte role hash + +For more information, try '--help'. + +"#]]); +} diff --git a/crates/cast/tests/cli/transaction.rs b/crates/cast/tests/cli/transaction.rs new file mode 100644 index 0000000000000..18a1acfdf98de --- /dev/null +++ b/crates/cast/tests/cli/transaction.rs @@ -0,0 +1,312 @@ +//! CLI tests for transaction commands. + +use super::*; + +// tests that the raw encoded transaction is returned +#[casttest] +fn tx_raw(cmd: _) { + let rpc = next_http_rpc_endpoint(); + + // + cmd.args([ + "tx", + "0x44f2aaa351460c074f2cb1e5a9e28cbc7d83f33e425101d2de14331c7b7ec31e", + "raw", + "--rpc-url", + rpc.as_str(), + ]).assert_success().stdout_eq(str![[r#" +0xf86d824c548502743b65088275309491da5bf3f8eb72724e6f50ec6c3d199c6355c59c87a0a73f33e9e4cc8025a0428518b1748a08bbeb2392ea055b418538944d30adfc2accbbfa8362a401d3a4a07d6093ab2580efd17c11b277de7664fce56e6953cae8e925bec3313399860470 + +"#]]); + + // + cmd.cast_fuse().args([ + "tx", + "0x44f2aaa351460c074f2cb1e5a9e28cbc7d83f33e425101d2de14331c7b7ec31e", + "--raw", + "--rpc-url", + rpc.as_str(), + ]).assert_success().stdout_eq(str![[r#" +0xf86d824c548502743b65088275309491da5bf3f8eb72724e6f50ec6c3d199c6355c59c87a0a73f33e9e4cc8025a0428518b1748a08bbeb2392ea055b418538944d30adfc2accbbfa8362a401d3a4a07d6093ab2580efd17c11b277de7664fce56e6953cae8e925bec3313399860470 + +"#]]); +} + +#[casttest] +fn tx_to_request_json(cmd: _) { + let rpc = next_http_rpc_endpoint(); + + // + cmd.args([ + "tx", + "0x44f2aaa351460c074f2cb1e5a9e28cbc7d83f33e425101d2de14331c7b7ec31e", + "--to-request", + "--rpc-url", + rpc.as_str(), + ]) + .assert_success() + .stdout_eq(str![[r#" +{ + "from": "0x199d5ed7f45f4ee35960cf22eade2076e95b253f", + "to": "0x91da5bf3f8eb72724e6f50ec6c3d199c6355c59c", + "gasPrice": "0x2743b6508", + "gas": "0x7530", + "value": "0xa0a73f33e9e4cc", + "input": "0x", + "nonce": "0x4c54", + "chainId": "0x1", + "type": "0x0" +} + +"#]]); +} + +// +#[cfg(feature = "optimism")] +#[casttest] +fn tx_raw_opstack_deposit(cmd: _) { + cmd.args([ + "tx", + "0xf403cba612d1c01c027455c0d97427ccd5f7f99aac30017e065f81d1e30244ea", + "--raw", + "-n", + "optimism", + "--rpc-url", + "https://sepolia.base.org", + ]).assert_success() + .stdout_eq(str![[r#" +0x7ef90207a0cbde10ec697aff886f95d2514bab434e455620627b9bb8ba33baaaa4d537d62794d45955f4de64f1840e5686e64278da901e263031944200000000000000000000000000000000000007872386f26fc10000872386f26fc1000083096c4980b901a4d764ad0b0001000000000000000000000000000000000000000000000000000000065132000000000000000000000000fd0bf71f60660e2f608ed56e1659c450eb1131200000000000000000000000004200000000000000000000000000000000000010000000000000000000000000000000000000000000000000002386f26fc1000000000000000000000000000000000000000000000000000000000000000493e000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000a41635f5fd000000000000000000000000ca11bde05977b3631167028862be2a173976ca110000000000000000000000005703b26fe5a7be820db1bf34c901a79da1a46ba4000000000000000000000000000000000000000000000000002386f26fc100000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 + +"#]]); +} + +#[casttest] +fn tx_raw_tempo(cmd: _) { + cmd.args([ + "tx", + "0xa24c6bbeea629a80be79e970a9749d0cbc6ee31625a0b75f585c173ab15a18ec", + "--raw", + "-n", + "tempo", + "--rpc-url", + "https://rpc.moderato.tempo.xyz", + ]).assert_success() + .stdout_eq(str![[r#" +0x76f8cf82a5bf1485059682f018830494e5f85ef85c9420c0000000000000000000007d9cc57068833ea780b84440c10f190000000000000000000000008a871f4189067637cfc4cc1500abd6244bf1df740000000000000000000000000000000000000000000000000000000005f5e100c08082057e80809420c000000000000000000000000000000000000080c0b841eb100c4cbd96903bf9e97968c0982670bb90fc191ee4544c7ff32d44e901dbea3f6fbdd58255051135c2fe1aa81583a270d96009cbe375f4605ef15971273a4f1b + +"#]]); +} + +// Test decode-tx with a valid EIP-1559 Ethereum transaction +#[casttest] +fn cast_decode_tx_ethereum(cmd: _) { + // Ethereum mainnet 0x02d2ae7454273bcc02405276b208c03b83ea979ec06aa6f9bc48f81ca343dc1d + let tx = "0x02f8b1018223e48374667184147d0df48301388094dac17f958d2ee523a2206206994597c13d831ec780b844a9059cbb000000000000000000000000594bd0e0c83d619e375459f0f9b85a17cb8391b400000000000000000000000000000000000000000000000000000000295d9980c080a0704a930876b48fc99cbee17597dc6660c82cd4de5d6f4ace58fe0fcf3bbcb942a07c94560ed0850c9b8e9ab5f3c50902113decea95db3a6bac7c76edd11b7138aa"; + cmd.args(["decode-tx", tx]).assert_success().stdout_eq(str![[r#" +{ + "signer": "0x6e8d7d12c3f0a9285981b8998ce7cf0cf0bce78e", + "type": "0x2", + "chainId": "0x1", + "nonce": "0x23e4", + "gas": "0x13880", + "maxFeePerGas": "0x147d0df4", + "maxPriorityFeePerGas": "0x746671", + "to": "0xdac17f958d2ee523a2206206994597c13d831ec7", + "value": "0x0", + "accessList": [], + "input": "0xa9059cbb000000000000000000000000594bd0e0c83d619e375459f0f9b85a17cb8391b400000000000000000000000000000000000000000000000000000000295d9980", + "r": "0x704a930876b48fc99cbee17597dc6660c82cd4de5d6f4ace58fe0fcf3bbcb942", + "s": "0x7c94560ed0850c9b8e9ab5f3c50902113decea95db3a6bac7c76edd11b7138aa", + "yParity": "0x0", + "v": "0x0", + "hash": "0x02d2ae7454273bcc02405276b208c03b83ea979ec06aa6f9bc48f81ca343dc1d" +} + +"#]]); + + cmd.cast_fuse().args(["decode-tx", "--json", tx]).assert_json_stdout(str![[r#" +{ + "schema_version": 1, + "success": true, + "data": { + "signer": "0x6e8d7d12c3f0a9285981b8998ce7cf0cf0bce78e", + "type": "0x2", + "chainId": "0x1", + "nonce": "0x23e4", + "gas": "0x13880", + "maxFeePerGas": "0x147d0df4", + "maxPriorityFeePerGas": "0x746671", + "to": "0xdac17f958d2ee523a2206206994597c13d831ec7", + "value": "0x0", + "accessList": [], + "input": "0xa9059cbb000000000000000000000000594bd0e0c83d619e375459f0f9b85a17cb8391b400000000000000000000000000000000000000000000000000000000295d9980", + "r": "0x704a930876b48fc99cbee17597dc6660c82cd4de5d6f4ace58fe0fcf3bbcb942", + "s": "0x7c94560ed0850c9b8e9ab5f3c50902113decea95db3a6bac7c76edd11b7138aa", + "yParity": "0x0", + "v": "0x0", + "hash": "0x02d2ae7454273bcc02405276b208c03b83ea979ec06aa6f9bc48f81ca343dc1d" + }, + "errors": [], + "warnings": [] +} +"#]]); +} + +// Test decode-tx with --network tempo accepts the flag and decodes correctly +#[casttest] +fn cast_decode_tx_tempo(cmd: _) { + // Tempo mainnet 0xa26f2dc8ed22d65ad5e5b3acc40295d89c331fd1e79d34b13baa3f6f47b136dc + let tx = "0x76f9033a821079843b9aca0085098bca5a0083241bc4f9011cf85c9420c000000000000000000000b9537d11c60e8b5080b844095ea7b30000000000000000000000000901aed692c755b870f9605e56baa66c35beff6900000000000000000000000000000000000000000000000000000000000f4240f8bc940901aed692c755b870f9605e56baa66c35beff6980b8a4c79ea485000000000000000000000000b48141c3da5030def992bdc686f0e9a8729206b600000000000000000000000020c000000000000000000000b9537d11c60e8b5000000000000000000000000000000000000000000000000000000000000f424055d3e824159a36fa0d16bbe5c91f497568124441cc6731b8638263d82bfeea6f0000000000000000000000007cfdf901fba309a4a9189a56bede35701aea96dac0a0ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff808469b2c5ef809420c000000000000000000000b9537d11c60e8b5080c0f9016ef83a82107980947cfdf901fba309a4a9189a56bede35701aea96da8469da52c0dbda9420c000000000000000000000b9537d11c60e8b508405f5e100b9012f02a7cb28053c8ee4e5394fc67a0018dc1c622dad5ce3591b8ca13094ae86d11ba61d000000007b2274797065223a22776562617574686e2e676574222c226368616c6c656e6765223a22355068657374624a6a6d4c4c514331622d696f6b5334755f496748307a4d4a6b5a3677385f4e667532596b222c226f726967696e223a2268747470733a2f2f77616c6c65742e74656d706f2e78797a222c2263726f73734f726967696e223a66616c73657d763ed1d6d008091ef06390b2d3150e326795daeba580f0bebc84242d503f13e71328ee2af9426777d4fa5ee148753262ea41b5503522967a6b877c04e5c0c2a7e1af1c624e48eba171e5f521d8f4c89f80b04ecb3f5ba6060109ccb56d344ed129f2a97fb8757cb3cbdcbc636b949fedad4b74490af444a49f5b83d6e0bb0750b8560339e87712af0f3c9c3c1f7c9c57190bb8c8db125d721b2cbf2ba3ac52332b11e0f5d397406da076668a40840135e950e5967bc5f032e8502e33ea91899b90cd8f6106d27efb934a4dac5dcfd0ca5c491733faf91c1c"; + let output = cmd + .args(["decode-tx", "--network", "tempo", tx]) + .assert_success() + .get_output() + .stdout + .clone(); + let decoded: serde_json::Value = serde_json::from_slice(&output).unwrap(); + assert_eq!(decoded["type"], "0x76"); + assert_eq!(decoded["feeToken"], "0x20c000000000000000000000b9537d11c60e8b50"); +} + +// Test decode-tx with invalid hex input +#[casttest] +fn cast_decode_tx_invalid(cmd: _) { + cmd.args(["decode-tx", "0xinvalid"]).assert_failure(); +} + +// Test decode-tx auto-detects the Tempo network from the `0x76` type byte without `--network`, +// producing the same output as passing `--network tempo` explicitly. +#[casttest] +fn cast_decode_tx_tempo_autodetect(cmd: _) { + let tx = "0x76f8cf82a5bf1485059682f018830494e5f85ef85c9420c0000000000000000000007d9cc57068833ea780b84440c10f190000000000000000000000008a871f4189067637cfc4cc1500abd6244bf1df740000000000000000000000000000000000000000000000000000000005f5e100c08082057e80809420c000000000000000000000000000000000000080c0b841eb100c4cbd96903bf9e97968c0982670bb90fc191ee4544c7ff32d44e901dbea3f6fbdd58255051135c2fe1aa81583a270d96009cbe375f4605ef15971273a4f1b"; + + let auto = cmd.args(["decode-tx", tx]).assert_success().get_output().stdout.clone(); + + let with_flag = cmd + .cast_fuse() + .args(["decode-tx", "--network", "tempo", tx]) + .assert_success() + .get_output() + .stdout + .clone(); + + assert_eq!(auto, with_flag, "auto-detected and --network tempo output should match"); + + let decoded: serde_json::Value = serde_json::from_slice(&auto).unwrap(); + assert_eq!(decoded["type"], "0x76"); +} + +// Test that `--network ethereum` forces Ethereum decoding and rejects a Tempo tx (type `0x76`), +// so the flag remains a meaningful override rather than falling back to auto-detection. +#[casttest] +fn cast_decode_tx_network_ethereum_rejects_tempo(cmd: _) { + let tx = "0x76f8cf82a5bf1485059682f018830494e5f85ef85c9420c0000000000000000000007d9cc57068833ea780b84440c10f190000000000000000000000008a871f4189067637cfc4cc1500abd6244bf1df740000000000000000000000000000000000000000000000000000000005f5e100c08082057e80809420c000000000000000000000000000000000000080c0b841eb100c4cbd96903bf9e97968c0982670bb90fc191ee4544c7ff32d44e901dbea3f6fbdd58255051135c2fe1aa81583a270d96009cbe375f4605ef15971273a4f1b"; + + cmd.args(["decode-tx", "--network", "ethereum", tx]).assert_failure(); +} + +// Test that `--network tempo` and `-n tempo` (short form) produce identical output for decode-tx. +// Uses a known Tempo mainnet transaction. +#[casttest] +fn cast_decode_tx_network_flag_short_and_long_equivalent(cmd: _) { + let tx = "0x76f8cf82a5bf1485059682f018830494e5f85ef85c9420c0000000000000000000007d9cc57068833ea780b84440c10f190000000000000000000000008a871f4189067637cfc4cc1500abd6244bf1df740000000000000000000000000000000000000000000000000000000005f5e100c08082057e80809420c000000000000000000000000000000000000080c0b841eb100c4cbd96903bf9e97968c0982670bb90fc191ee4544c7ff32d44e901dbea3f6fbdd58255051135c2fe1aa81583a270d96009cbe375f4605ef15971273a4f1b"; + + let via_long = cmd + .args(["decode-tx", "--network", "tempo", tx]) + .assert_success() + .get_output() + .stdout + .clone(); + + let via_short = cmd + .cast_fuse() + .args(["decode-tx", "-n", "tempo", tx]) + .assert_success() + .get_output() + .stdout + .clone(); + + assert_eq!(via_long, via_short, "--network tempo and -n tempo should produce same output"); +} + +// Test that `--network optimism` and `-n optimism` produce identical output for decode-tx. +// Uses a known OP-stack deposit transaction (same tx as tx_raw_opstack_deposit test). +#[cfg(feature = "optimism")] +#[casttest] +fn cast_decode_tx_network_optimism_short_and_long_equivalent(cmd: _) { + let tx = "0x7ef90207a0cbde10ec697aff886f95d2514bab434e455620627b9bb8ba33baaaa4d537d62794d45955f4de64f1840e5686e64278da901e263031944200000000000000000000000000000000000007872386f26fc10000872386f26fc1000083096c4980b901a4d764ad0b0001000000000000000000000000000000000000000000000000000000065132000000000000000000000000fd0bf71f60660e2f608ed56e1659c450eb1131200000000000000000000000004200000000000000000000000000000000000010000000000000000000000000000000000000000000000000002386f26fc1000000000000000000000000000000000000000000000000000000000000000493e000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000a41635f5fd000000000000000000000000ca11bde05977b3631167028862be2a173976ca110000000000000000000000005703b26fe5a7be820db1bf34c901a79da1a46ba4000000000000000000000000000000000000000000000000002386f26fc100000000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"; + + let via_long = cmd + .args(["decode-tx", "--network", "optimism", tx]) + .assert_success() + .get_output() + .stdout + .clone(); + + let via_short = cmd + .cast_fuse() + .args(["decode-tx", "-n", "optimism", tx]) + .assert_success() + .get_output() + .stdout + .clone(); + + assert_eq!( + via_long, via_short, + "--network optimism and -n optimism should produce same output" + ); +} + +#[casttest] +fn tx_using_sender_and_nonce(cmd: _) { + let rpc = next_http_archive_rpc_url(); + // + let args = vec![ + "tx", + "--from", + "0x4648451b5F87FF8F0F7D622bD40574bb97E25980", + "--nonce", + "113642", + "--rpc-url", + rpc.as_str(), + ]; + cmd.args(args).assert_success().stdout_eq(str![[r#" + +blockHash 0x29518c1cea251b1bda5949a9b039722604ec1fb99bf9d8124cfe001c95a50bdc +blockNumber 22287055 +from 0x4648451b5F87FF8F0F7D622bD40574bb97E25980 +transactionIndex 230 +effectiveGasPrice 363392048 +hash 0x5bcd22734cca2385dc25b2d38a3d33a640c5961bd46d390dff184c894204b594 +type 2 +chainId 1 +nonce 113642 +gasLimit 350000 +maxFeePerGas 675979146 +maxPriorityFeePerGas 1337 +to 0xdAC17F958D2ee523a2206206994597C13D831ec7 +value 0 +accessList [] +input 0xa9059cbb000000000000000000000000568766d218d82333dd4dae933ddfcda5da26625000000000000000000000000000000000000000000000000000000000cc3ed109 +r 0x1e92d3e1ca69109a1743fc4b3cf9dff58630bc9f429cea3c3fe311506264e36c +s 0x793947d4bbdce56a1a5b2b3525c46f01569414a22355f4883b5429668ab0f51a +yParity 1 +... +"#]]); +} + +#[casttest] +fn recover_authority(cmd: _) { + let auth = r#"{ + "chainId": "0x1", + "address": "0xb684710e6d5914ad6e64493de2a3c424cc43e970", + "nonce": "0x3dc1", + "yParity": "0x1", + "r": "0x2f15ba55009fcd3682cd0f9c9645dd94e616f9a969ba3f1a5a2d871f9fe0f2b4", + "s": "0x53c332a83312d0b17dd4c16eeb15b1ff5223398b14e0a55c70762e8f3972b7a5" + }"#; + cmd.args(["recover-authority", auth]).assert_success().stdout_eq(str![[r#" +0x17816E9A858b161c3E37016D139cf618056CaCD4 + +"#]]); +} diff --git a/crates/cast/tests/cli/vaddr.rs b/crates/cast/tests/cli/vaddr.rs new file mode 100644 index 0000000000000..3954d6064715f --- /dev/null +++ b/crates/cast/tests/cli/vaddr.rs @@ -0,0 +1,344 @@ +//! CLI tests for vaddr commands. + +use super::*; + +// Tests for `cast vaddr` JSON output +#[casttest] +fn vaddr_create_json_output(cmd: _) { + // Use a pre-computed salt that satisfies the 4-byte PoW requirement for this owner. + // Salt: 0x0000000000000000000000000000000000000000000000003ee0a78d00000000 + // Owner: 0x1234567890123456789012345678901234567890 + let out = cmd + .args([ + "--json", + "vaddr", + "create", + "--owner", + "0x1234567890123456789012345678901234567890", + "--salt", + "0x0000000000000000000000000000000000000000000000003ee0a78d00000000", + "--no-register", + "--count", + "2", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + let envelope: serde_json::Value = serde_json::from_str(out.trim()).expect("valid JSON"); + let v = &envelope["data"]; + assert_eq!(v["salt"], "0x0000000000000000000000000000000000000000000000003ee0a78d00000000"); + assert_eq!( + v["registration_hash"], + "0x000000002f51c0c4f66f3910f799c6b98e2123ef43a401a062eb8ee07498c396" + ); + assert_eq!(v["master_id"], "0x2f51c0c4"); + let addrs = v["virtual_addresses"].as_array().expect("array"); + assert_eq!(addrs.len(), 2); + assert_eq!(addrs[0]["tag"], "0x000000000000"); + assert_eq!( + addrs[0]["address"].as_str().unwrap().to_lowercase(), + "0x2f51c0c4fdfdfdfdfdfdfdfdfdfd000000000000" + ); + assert_eq!(addrs[1]["tag"], "0x000000000001"); + assert_eq!( + addrs[1]["address"].as_str().unwrap().to_lowercase(), + "0x2f51c0c4fdfdfdfdfdfdfdfdfdfd000000000001" + ); +} + +#[casttest] +fn vaddr_create_plain_output(cmd: _) { + cmd.args([ + "vaddr", + "create", + "--owner", + "0x1234567890123456789012345678901234567890", + "--salt", + "0x0000000000000000000000000000000000000000000000003ee0a78d00000000", + "--no-register", + ]) + .assert_success() + .stdout_eq(str![[r#" +Salt: 0x0000000000000000000000000000000000000000000000003ee0a78d00000000 +Registration hash: 0x000000002f51c0c4f66f3910f799c6b98e2123ef43a401a062eb8ee07498c396 +Master ID: 0x2f51c0c4 + +Virtual addresses: + tag=0x000000000000 [..] + +"#]]); +} + +// End-to-end `cast vaddr` tests against a local Anvil Tempo node. +// +// These exercise the TIP-1022 register, resolve, forward and watch flow. They pass a precomputed +// salt instead of mining one; the CPU-bound miner has its own unit tests in `tip20::mine`. + +mod vaddr_e2e { + use super::*; + use foundry_cli::utils::parse_json; + use std::{ + io::{BufRead, BufReader}, + process::Stdio, + sync::mpsc, + thread, + time::{Duration, Instant}, + }; + use tempo_contracts::precompiles::DEFAULT_FEE_TOKEN; + use tempo_hardfork::TempoHardfork; + + /// `cast vaddr` exercises TIP-1022, which is enabled after `TempoHardfork::T3`. + fn tempo_t3_config() -> anvil::NodeConfig { + anvil::NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T3.into())) + } + + /// Run `cast vaddr create` (register with the precomputed salt) and parse the user-tag-zero + /// virtual address from the plain-text output. + fn create_and_register_vaddr( + cmd: &mut foundry_test_utils::TestCommand, + rpc: &str, + owner: &PrivateKeySigner, + ) -> String { + let owner_pk = hex::encode_prefixed(owner.credential().to_bytes()); + let owner_addr = format!("{:#x}", owner.address()); + let out = cmd + .cast_fuse() + .args([ + "vaddr", + "create", + "--owner", + &owner_addr, + "--salt", + PRECOMPUTED_VADDR_SALT_FOR_ADDR1, + "--private-key", + &owner_pk, + "--rpc-url", + rpc, + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + out.lines() + .find_map(|line| { + let rest = line.trim_start().strip_prefix("tag=0x000000000000")?; + rest.split_whitespace().next().map(str::to_string) + }) + .unwrap_or_else(|| panic!("could not parse vaddr from create output:\n{out}")) + } + + #[casttest] + async fn vaddr_create_register_json_includes_tx_hash(cmd: _) { + let (_api, handle) = anvil::spawn(tempo_t3_config()).await; + let rpc = handle.http_endpoint(); + let owner = handle.dev_wallets().next().unwrap(); + let owner_pk = hex::encode_prefixed(owner.credential().to_bytes()); + let owner_addr = format!("{:#x}", owner.address()); + + let out = cmd + .cast_fuse() + .args([ + "--json", + "vaddr", + "create", + "--owner", + &owner_addr, + "--salt", + PRECOMPUTED_VADDR_SALT_FOR_ADDR1, + "--private-key", + &owner_pk, + "--rpc-url", + &rpc, + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + let envelope: serde_json::Value = + serde_json::from_str(out.trim()).expect("create --json output is valid JSON"); + let tx_hash = envelope["data"]["registration_tx_hash"] + .as_str() + .expect("registration_tx_hash is a string"); + B256::from_str(tx_hash).expect("registration_tx_hash is a valid tx hash"); + } + + // `cast vaddr create` mines a PoW salt, registers a virtual master on-chain, + // and `cast vaddr resolve` returns the registered owner. + #[casttest] + async fn vaddr_create_register_and_resolve(cmd: _) { + let (_api, handle) = anvil::spawn(tempo_t3_config()).await; + let rpc = handle.http_endpoint(); + let owner = handle.dev_wallets().next().unwrap(); + + let vaddr = create_and_register_vaddr(&mut cmd, &rpc, &owner); + + let resolve_out = cmd + .cast_fuse() + .args(["--json", "vaddr", "resolve", &vaddr, "--rpc-url", &rpc]) + .assert_success() + .get_output() + .stdout_lossy(); + + let v: serde_json::Value = + parse_json(resolve_out.trim()).expect("resolve --json output is valid JSON"); + assert_eq!( + v["address"].as_str().unwrap().to_lowercase(), + vaddr.to_lowercase(), + "resolve.address mismatch: {resolve_out}" + ); + assert_eq!( + v["master_address"].as_str().unwrap().to_lowercase(), + format!("{:#x}", owner.address()), + "resolve.master_address should match the registered owner: {resolve_out}" + ); + } + + // Transferring a TIP-20 fee token to a registered virtual address must + // auto-forward the deposit to the master wallet at the protocol level. + #[casttest] + async fn vaddr_auto_forward_to_master(cmd: _) { + let (_api, handle) = anvil::spawn(tempo_t3_config()).await; + let rpc = handle.http_endpoint(); + let owner = handle.dev_wallets().next().unwrap(); + let sender = handle.dev_wallets().nth(1).unwrap(); + let sender_pk = hex::encode_prefixed(sender.credential().to_bytes()); + let owner_addr = format!("{:#x}", owner.address()); + + let vaddr = create_and_register_vaddr(&mut cmd, &rpc, &owner); + + let balance = |cmd: &mut foundry_test_utils::TestCommand| -> u128 { + let out = cmd + .cast_fuse() + .args([ + "call", + &DEFAULT_FEE_TOKEN.to_string(), + "balanceOf(address)(uint256)", + &owner_addr, + "--rpc-url", + &rpc, + ]) + .assert_success() + .get_output() + .stdout_lossy(); + // `cast call` with a typed signature prints e.g. `1000000000000 [1e12]`. + out.split_whitespace().next().unwrap().parse().unwrap() + }; + + let before = balance(&mut cmd); + + let amount: u128 = 1_000_000; + cmd.cast_fuse() + .args([ + "send", + &DEFAULT_FEE_TOKEN.to_string(), + "transfer(address,uint256)", + &vaddr, + &amount.to_string(), + "--rpc-url", + &rpc, + "--private-key", + &sender_pk, + ]) + .assert_success(); + + let after = balance(&mut cmd); + assert_eq!( + after - before, + amount, + "transfer to virtual address should auto-forward to master (before={before}, after={after})" + ); + } + + // `cast vaddr watch --from-block` must replay historical TIP-20 Transfer + // logs targeted at the virtual address. The command then polls forever, so + // we spawn it as a child process and kill it once we observe the expected + // historical line (or the deadline elapses). + #[casttest] + async fn vaddr_watch_historical(cmd: _) { + let (_api, handle) = anvil::spawn(tempo_t3_config()).await; + let rpc = handle.http_endpoint(); + let owner = handle.dev_wallets().next().unwrap(); + let sender = handle.dev_wallets().nth(1).unwrap(); + let sender_pk = hex::encode_prefixed(sender.credential().to_bytes()); + let sender_addr = format!("{:#x}", sender.address()); + + let vaddr = create_and_register_vaddr(&mut cmd, &rpc, &owner); + + // Capture the block before the transfer so `--from-block` replays it. + let block_before_out = cmd + .cast_fuse() + .args(["block-number", "--rpc-url", &rpc]) + .assert_success() + .get_output() + .stdout_lossy(); + let block_before: u64 = block_before_out.trim().parse().unwrap(); + + let amount: u128 = 1_000_000; + cmd.cast_fuse() + .args([ + "send", + &DEFAULT_FEE_TOKEN.to_string(), + "transfer(address,uint256)", + &vaddr, + &amount.to_string(), + "--rpc-url", + &rpc, + "--private-key", + &sender_pk, + ]) + .assert_success(); + + // Spawn `cast vaddr watch` as a child process (it loops indefinitely). + cmd.cast_fuse().args([ + "vaddr", + "watch", + &vaddr, + "--token", + &DEFAULT_FEE_TOKEN.to_string(), + "--from-block", + &block_before.to_string(), + "--rpc-url", + &rpc, + ]); + let mut child = cmd.cmd().stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); + + let expected = format!( + "token={} from={} amount={}", + DEFAULT_FEE_TOKEN.to_string().to_lowercase(), + sender_addr, + amount + ); + + // `watch` never exits, so read on a separate thread: a blocking `read_line` would + // otherwise outlive the deadline whenever the expected line is never printed. + let stdout = BufReader::new(child.stdout.take().unwrap()); + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + for line in stdout.lines().map_while(Result::ok) { + if tx.send(line).is_err() { + break; + } + } + }); + + let deadline = Instant::now() + Duration::from_secs(15); + let mut captured = String::new(); + let mut found = false; + while let Ok(line) = rx.recv_timeout(deadline.saturating_duration_since(Instant::now())) { + captured.push_str(&line); + captured.push('\n'); + if line.to_lowercase().contains(&expected) { + found = true; + break; + } + } + let _ = child.kill(); + let _ = child.wait(); + + assert!( + found, + "cast vaddr watch did not emit historical transfer; expected substring `{expected}` in:\n{captured}" + ); + } +} diff --git a/crates/cast/tests/cli/wallet_keys.rs b/crates/cast/tests/cli/wallet_keys.rs new file mode 100644 index 0000000000000..f23725b2fde41 --- /dev/null +++ b/crates/cast/tests/cli/wallet_keys.rs @@ -0,0 +1,376 @@ +//! CLI tests for wallet keys commands. + +use super::*; + +// tests that `cast wallet new-mnemonic --entropy` outputs the expected mnemonic +#[casttest] +fn wallet_mnemonic_from_entropy(cmd: _) { + cmd.args([ + "wallet", + "new-mnemonic", + "--accounts", + "3", + "--entropy", + "0xdf9bf37e6fcdf9bf37e6fcdf9bf37e3c", + ]) + .assert_success() + .stdout_eq( + str![[r#" +Generating mnemonic from provided entropy... +Successfully generated a new mnemonic. +Phrase: +test test test test test test test test test test test junk + +Accounts: +- Account 0: +Address: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 +Private key: 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 + +- Account 1: +Address: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 +Private key: 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d + +- Account 2: +Address: 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC +Private key: 0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a + + +"#]] + .raw(), + ); +} + +// tests that `cast wallet new-mnemonic --entropy` outputs the expected mnemonic (verbose variant) +#[casttest] +fn wallet_mnemonic_from_entropy_verbose(cmd: _) { + cmd.args([ + "wallet", + "new-mnemonic", + "--accounts", + "3", + "--entropy", + "0xdf9bf37e6fcdf9bf37e6fcdf9bf37e3c", + "-v", + ]) + .assert_success() + .stdout_eq( + str![[r#" +Generating mnemonic from provided entropy... +Successfully generated a new mnemonic. +Phrase: +test test test test test test test test test test test junk + +Accounts: +- Account 0: +Address: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 +Public key: 0x8318535b54105d4a7aae60c08fc45f9687181b4fdfc625bd1a753fa7397fed753547f11ca8696646f2f3acb08e31016afac23e630c5d11f59f61fef57b0d2aa5 +Private key: 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 + +- Account 1: +Address: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 +Public key: 0xba5734d8f7091719471e7f7ed6b9df170dc70cc661ca05e688601ad984f068b0d67351e5f06073092499336ab0839ef8a521afd334e53807205fa2f08eec74f4 +Private key: 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d + +- Account 2: +Address: 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC +Public key: 0x9d9031e97dd78ff8c15aa86939de9b1e791066a0224e331bc962a2099a7b1f0464b8bbafe1535f2301c72c2cb3535b172da30b02686ab0393d348614f157fbdb +Private key: 0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a + + +"#]] + .raw(), + ); +} + +// tests that `cast wallet new-mnemonic --json` outputs the expected mnemonic +#[casttest] +fn wallet_mnemonic_from_entropy_json(cmd: _) { + cmd.args([ + "wallet", + "new-mnemonic", + "--accounts", + "3", + "--entropy", + "0xdf9bf37e6fcdf9bf37e6fcdf9bf37e3c", + "--json", + ]) + .assert_success() + .stdout_eq( + str![[r#" +{ + "schema_version": 1, + "success": true, + "data": { + "mnemonic": "test test test test test test test test test test test junk", + "accounts": [ + { + "address": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "private_key": "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + }, + { + "address": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "private_key": "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" + }, + { + "address": "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "private_key": "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a" + } + ] + }, + "errors": [], + "warnings": [] +} + +"#]] + .is_json(), + ); +} + +// tests that `cast wallet new-mnemonic --json` outputs the expected mnemonic (verbose variant) +#[casttest] +fn wallet_mnemonic_from_entropy_json_verbose(cmd: _) { + cmd.args([ + "wallet", + "new-mnemonic", + "--accounts", + "3", + "--entropy", + "0xdf9bf37e6fcdf9bf37e6fcdf9bf37e3c", + "--json", + "-v", + ]) +.assert_success() +.stdout_eq(str![[r#" +{ + "schema_version": 1, + "success": true, + "data": { + "mnemonic": "test test test test test test test test test test test junk", + "accounts": [ + { + "address": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "public_key": "0x8318535b54105d4a7aae60c08fc45f9687181b4fdfc625bd1a753fa7397fed753547f11ca8696646f2f3acb08e31016afac23e630c5d11f59f61fef57b0d2aa5", + "private_key": "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + }, + { + "address": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "public_key": "0xba5734d8f7091719471e7f7ed6b9df170dc70cc661ca05e688601ad984f068b0d67351e5f06073092499336ab0839ef8a521afd334e53807205fa2f08eec74f4", + "private_key": "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" + }, + { + "address": "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "public_key": "0x9d9031e97dd78ff8c15aa86939de9b1e791066a0224e331bc962a2099a7b1f0464b8bbafe1535f2301c72c2cb3535b172da30b02686ab0393d348614f157fbdb", + "private_key": "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a" + } + ] + }, + "errors": [], + "warnings": [] +} + +"#]] +.is_json()); +} + +// tests that `cast wallet derive` outputs the addresses of the accounts derived from the mnemonic +#[casttest] +fn wallet_derive_mnemonic(cmd: _) { + cmd.args([ + "wallet", + "derive", + "--accounts", + "3", + "test test test test test test test test test test test junk", + ]) + .assert_success() + .stdout_eq(str![[r#" +- Account 0: +[ADDRESS] + +- Account 1: +[ADDRESS] + +- Account 2: +[ADDRESS] + + +"#]]); +} + +// tests that `cast wallet derive` with insecure flag outputs the addresses and private keys of the +// accounts derived from the mnemonic +#[casttest] +fn wallet_derive_mnemonic_insecure(cmd: _) { + cmd.args([ + "wallet", + "derive", + "--accounts", + "3", + "--insecure", + "test test test test test test test test test test test junk", + ]) + .assert_success() + .stdout_eq(str![[r#" +- Account 0: +[ADDRESS] +[PRIVATE_KEY] + +- Account 1: +[ADDRESS] +[PRIVATE_KEY] + +- Account 2: +[ADDRESS] +[PRIVATE_KEY] + + +"#]]); +} + +// tests that `cast wallet derive` with json flag outputs the addresses of the accounts derived from +// the mnemonic in JSON format +#[casttest] +fn wallet_derive_mnemonic_json(cmd: _) { + cmd.args([ + "wallet", + "derive", + "--accounts", + "3", + "--json", + "test test test test test test test test test test test junk", + ]) + .assert_success() + .stdout_eq( + str![[r#" +{ + "schema_version": 1, + "success": true, + "data": [ + { + "address": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" + }, + { + "address": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" + }, + { + "address": "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC" + } + ], + "errors": [], + "warnings": [] +} + +"#]] + .is_json(), + ); +} + +// tests that `cast wallet derive` with insecure and json flag outputs the addresses and private +// keys of the accounts derived from the mnemonic in JSON format +#[casttest] +fn wallet_derive_mnemonic_insecure_json(cmd: _) { + cmd.args([ + "wallet", + "derive", + "--accounts", + "3", + "--insecure", + "--json", + "test test test test test test test test test test test junk", + ]) + .assert_success() + .stdout_eq( + str![[r#" +{ + "schema_version": 1, + "success": true, + "data": [ + { + "address": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "private_key": "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + }, + { + "address": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "private_key": "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" + }, + { + "address": "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "private_key": "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a" + } + ], + "errors": [], + "warnings": [] +} + +"#]] + .is_json(), + ); +} + +// tests that `cast wallet private-key` with arguments outputs the private key +#[casttest] +fn wallet_private_key_from_mnemonic_arg(cmd: _) { + cmd.args([ + "wallet", + "private-key", + "test test test test test test test test test test test junk", + "1", + ]) + .assert_success() + .stdout_eq(str![[r#" +0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d + +"#]]); +} + +// tests that `cast wallet private-key` with options outputs the private key +#[casttest] +fn wallet_private_key_from_mnemonic_option(cmd: _) { + cmd.args([ + "wallet", + "private-key", + "--mnemonic", + "test test test test test test test test test test test junk", + "--mnemonic-index", + "1", + ]) + .assert_success() + .stdout_eq(str![[r#" +0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d + +"#]]); +} + +// tests that `cast wallet public-key` correctly derives and outputs the public key +#[casttest] +fn wallet_public_key_with_private_key(cmd: _) { + cmd.args([ + "wallet", + "public-key", + "--raw-private-key", + "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" + ]) + .assert_success() + .stdout_eq(str![[r#" +0xba5734d8f7091719471e7f7ed6b9df170dc70cc661ca05e688601ad984f068b0d67351e5f06073092499336ab0839ef8a521afd334e53807205fa2f08eec74f4 + +"#]]); +} + +// tests that `cast wallet private-key` with derivation path outputs the private key +#[casttest] +fn wallet_private_key_with_derivation_path(cmd: _) { + cmd.args([ + "wallet", + "private-key", + "--mnemonic", + "test test test test test test test test test test test junk", + "--mnemonic-derivation-path", + "m/44'/60'/0'/0/1", + ]) + .assert_success() + .stdout_eq(str![[r#" +0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d + +"#]]); +} diff --git a/crates/cast/tests/cli/wallet_keystore.rs b/crates/cast/tests/cli/wallet_keystore.rs new file mode 100644 index 0000000000000..6777ba670f5a8 --- /dev/null +++ b/crates/cast/tests/cli/wallet_keystore.rs @@ -0,0 +1,998 @@ +//! CLI tests for wallet keystore commands. + +use super::*; +#[cfg(unix)] +use std::os::unix::fs::{PermissionsExt, symlink}; + +#[casttest] +fn browser_wallet_commands_expose_browser_option(cmd: _) { + for (name, args) in [ + ("call", &["call", "--help"][..]), + ("estimate", &["estimate", "--help"]), + ("access-list", &["access-list", "--help"]), + ("wallet address", &["wallet", "address", "--help"]), + ("wallet sign", &["wallet", "sign", "--help"]), + ] { + let output = cmd.cast_fuse().args(args).assert_success().get_output().stdout_lossy(); + assert!( + output.contains("--browser"), + "expected {name} help to expose --browser:\n{output}" + ); + } +} + +// tests that we can create a new wallet +#[casttest] +fn new_wallet(cmd: _) { + cmd.args(["wallet", "new"]) + .assert_success() + .stdout_eq(str![[r#" +0x[..] 0x[..] + +"#]]) + .stderr_eq(str![[r#" +Successfully created new keypair. +[ADDRESS] +[PRIVATE_KEY] + +"#]]); +} + +// tests that we can create a new wallet (verbose variant) +#[casttest] +fn new_wallet_verbose(cmd: _) { + cmd.args(["wallet", "new", "-v"]) + .assert_success() + .stdout_eq(str![[r#" +0x[..] 0x[..] + +"#]]) + .stderr_eq(str![[r#" +Successfully created new keypair. +[ADDRESS] +[PUBLIC_KEY] +[PRIVATE_KEY] + +"#]]); +} + +// tests that the machine-readable stdout record is omitted on an interactive terminal, where it +// would duplicate the stderr prose +#[test] +#[cfg(unix)] +fn new_wallet_tty_omits_stdout_record() { + let mut command = Command::new(env!("CARGO_BIN_EXE_cast")); + command.env("NO_COLOR", "1").env("TERM", "dumb").args(["wallet", "new"]); + + let mut session = spawn_with_options( + command, + Options { + timeout_ms: Some(30_000), + strip_ansi_escape_codes: true, + encoding: Encoding::UTF8, + }, + ) + .unwrap(); + + session.exp_string("Successfully created new keypair.").unwrap(); + session.exp_string("Address:").unwrap(); + session.exp_string("Private key: 0x").unwrap(); + // Only the private key value may follow; the `address\tprivate_key` record must not be + // printed to a tty. + let rest = session.exp_eof().unwrap(); + assert!( + !rest.contains("0x") && !rest.contains('\t'), + "unexpected stdout record on tty: {rest:?}" + ); +} + +// tests that we can create a new wallet with json output +#[casttest] +fn new_wallet_json(cmd: _) { + cmd.args(["wallet", "new", "--json"]).assert_success().stdout_eq( + str![[r#" +{ + "schema_version": 1, + "success": true, + "data": [ + { + "address": "{...}", + "public_key": "{...}", + "private_key": "{...}" + } + ], + "errors": [], + "warnings": [] +} + +"#]] + .is_json(), + ); +} + +// tests that `--json -v` does not alter stdout (verbosity is stderr-only) +#[casttest] +fn new_wallet_json_verbose(cmd: _) { + cmd.args(["wallet", "new", "--json", "-v"]).assert_success().stdout_eq( + str![[r#" +{ + "schema_version": 1, + "success": true, + "data": [ + { + "address": "{...}", + "public_key": "{...}", + "private_key": "{...}" + } + ], + "errors": [], + "warnings": [] +} + +"#]] + .is_json(), + ); +} + +// tests that `cast wallet address --json` wraps output in envelope +#[casttest] +fn wallet_address_json(cmd: _) { + cmd.args([ + "wallet", + "address", + "--json", + "--private-key", + "0x0000000000000000000000000000000000000000000000000000000000000001", + ]) + .assert_success() + .stdout_eq(str![[r#" +{"schema_version":1,"success":true,"data":"0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf","errors":[],"warnings":[]} + +"#]]); +} + +// tests that keystore `--json` output includes address, public_key, path +#[casttest] +fn new_wallet_keystore_json(cmd: _) { + cmd.args(["wallet", "new", ".", "test-account", "--unsafe-password", "test", "--json"]) + .assert_success() + .stdout_eq( + str![[r#" +{ + "schema_version": 1, + "success": true, + "data": [ + { + "address": "{...}", + "public_key": "{...}", + "path": "{...}" + } + ], + "errors": [], + "warnings": [] +} + +"#]] + .is_json(), + ); +} + +// tests that keystore `--json -v` does not alter stdout (verbosity is stderr-only) +#[casttest] +fn new_wallet_keystore_json_verbose(cmd: _) { + cmd.args(["wallet", "new", ".", "test-account", "--unsafe-password", "test", "--json", "-v"]) + .assert_success() + .stdout_eq( + str![[r#" +{ + "schema_version": 1, + "success": true, + "data": [ + { + "address": "{...}", + "public_key": "{...}", + "path": "{...}" + } + ], + "errors": [], + "warnings": [] +} + +"#]] + .is_json(), + ); +} + +// tests that we can create a new wallet with keystore +#[casttest] +fn new_wallet_keystore_with_password(cmd: _) { + cmd.args(["wallet", "new", ".", "test-account", "--unsafe-password", "test"]) + .assert_success() + .stdout_eq(str![[r#" +0x[..] + +"#]]) + .stderr_eq(str![[r#" +Created new encrypted keystore file: [..] +[ADDRESS] + +"#]]); +} + +// tests that we can create a new wallet with keystore (verbose variant) +#[casttest] +fn new_wallet_keystore_with_password_verbose(cmd: _) { + cmd.args(["wallet", "new", ".", "test-account", "--unsafe-password", "test", "-v"]) + .assert_success() + .stdout_eq(str![[r#" +0x[..] + +"#]]) + .stderr_eq(str![[r#" +Created new encrypted keystore file: [..] +[ADDRESS] +[PUBLIC_KEY] + +"#]]); +} + +// tests that `cast wallet new` prompts before overwriting an existing keystore file +#[casttest] +fn new_wallet_keystore_overwrite_protection(prj: _, cmd: _) { + // Create the initial keystore + cmd.args(["wallet", "new", ".", "test-account", "--unsafe-password", "test"]).assert_success(); + + // Attempt to overwrite with stdin "n" — should be cancelled + cmd.cast_fuse() + .current_dir(prj.root()) + .args(["wallet", "new", ".", "test-account", "--unsafe-password", "test"]) + .stdin("n\n") + .assert_failure() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +The following keystore file(s) already exist: + - test-account + +Do you want to overwrite all 1 file(s)? [y/N]: Error: Operation cancelled. No keystores were modified. + +"#]]); +} + +// tests that `cast wallet new --force` overwrites existing keystore files without prompting +#[casttest] +fn new_wallet_keystore_overwrite_force(prj: _, cmd: _) { + // Create the initial keystore + cmd.args(["wallet", "new", ".", "test-account", "--unsafe-password", "test"]).assert_success(); + + // Overwrite with --force — should succeed without prompting + cmd.cast_fuse() + .current_dir(prj.root()) + .args(["wallet", "new", ".", "test-account", "--unsafe-password", "test", "--force"]) + .assert_success() + .stdout_eq(str![[r#" +0x[..] + +"#]]) + .stderr_eq(str![[r#" +Created new encrypted keystore file: [..] +[ADDRESS] + +"#]]); +} + +// tests that `cast wallet new -n 2` prompts before overwriting existing keystore files +#[casttest] +fn new_wallet_keystore_overwrite_protection_multiple(prj: _, cmd: _) { + // Create 2 keystores: test-account_1 and test-account_2 + cmd.args(["wallet", "new", ".", "test-account", "--unsafe-password", "test", "-n", "2"]) + .assert_success(); + + // Attempt to overwrite with stdin "n" — should list both and cancel + cmd.cast_fuse() + .current_dir(prj.root()) + .args(["wallet", "new", ".", "test-account", "--unsafe-password", "test", "-n", "2"]) + .stdin("n\n") + .assert_failure() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +The following keystore file(s) already exist: + - test-account_1 + - test-account_2 + +Do you want to overwrite all 2 file(s)? [y/N]: Error: Operation cancelled. No keystores were modified. + +"#]]); +} + +// tests that we can create a new wallet with default keystore location +#[casttest] +fn new_wallet_default_keystore(cmd: _) { + cmd.args(["wallet", "new", "--unsafe-password", "test"]) + .assert_success() + .stdout_eq(str![[r#" +0x[..] + +"#]]) + .stderr_eq(str![[r#" +Created new encrypted keystore file: [..] +[ADDRESS] + +"#]]); + + // Verify the default keystore directory was created + let keystore_path = dirs::home_dir().unwrap().join(".foundry").join("keystores"); + assert!(keystore_path.exists()); + assert!(keystore_path.is_dir()); +} + +// tests that `cast wallet new ` treats a bare account name like `cast wallet import ` +#[casttest] +fn new_wallet_bare_account_name_uses_default_keystore(prj: _, cmd: _) { + // Windows resolves the home directory through the shell API, ignoring HOME. + // Use a unique account in the real default keystore directory on every platform. + let account = prj.root().file_name().unwrap().to_str().unwrap(); + cmd.args(["wallet", "new", account, "--unsafe-password", "test"]) + .assert_success() + .stdout_eq(str![[r#" +0x[..] + +"#]]) + .stderr_eq(str![[r#" +Created new encrypted keystore file: [..] +[ADDRESS] + +"#]]); + + let keystore_path = dirs::home_dir().unwrap().join(".foundry").join("keystores").join(account); + assert!(keystore_path.is_file(), "expected keystore at {}", keystore_path.display()); + fs::remove_file(keystore_path).unwrap(); +} + +// tests that a missing path-like argument is still treated as a directory, not an account name +#[casttest] +fn new_wallet_missing_dir_still_errors(cmd: _) { + cmd.args(["wallet", "new", "./missing-keystore-dir", "--unsafe-password", "test"]) + .assert_failure() + .stderr_eq(str![[r#" +Error: If you specified a directory, please make sure it exists, or create it before running `cast wallet new `. +./missing-keystore-dir is not a directory. +Error: [..] + +"#]]); +} + +// tests that a bare argument whose resolution fails for a reason other than the path being +// missing is reported, rather than silently falling back to the default keystore directory +#[cfg(unix)] +#[casttest] +fn new_wallet_bare_name_unresolvable_symlink_errors(prj: _, cmd: _) { + /// Restores search permission so the temporary project can be torn down, even on panic. + struct RestorePermissions<'a>(&'a Path); + + impl Drop for RestorePermissions<'_> { + fn drop(&mut self) { + let _ = fs::set_permissions(self.0, fs::Permissions::from_mode(0o755)); + } + } + + let account = "inaccessible-wallet"; + // Resolving a symlink through a directory without search permission fails with + // `PermissionDenied` instead of `NotFound`. + let locked = prj.root().join("locked"); + fs::create_dir_all(locked.join("keystores")).unwrap(); + symlink(locked.join("keystores"), prj.root().join(account)).unwrap(); + fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).unwrap(); + let _restore = RestorePermissions(&locked); + + cmd.env("HOME", prj.root()); + cmd.args(["wallet", "new", account, "--unsafe-password", "test"]) + .assert_failure() + .stderr_eq(str![[r#" +Error: If you specified a directory, please make sure it exists, or create it before running `cast wallet new `. +inaccessible-wallet is not a directory. +Error: [..] + +"#]]); + + let keystore_path = prj.root().join(".foundry").join("keystores").join(account); + assert!(!keystore_path.exists(), "unexpected keystore at {}", keystore_path.display()); +} + +// tests that the two-positional `[PATH] [ACCOUNT_NAME]` form still errors for a missing +// separator-free directory, rather than treating the first argument as an account name +#[casttest] +fn new_wallet_missing_dir_with_account_name_still_errors(cmd: _) { + cmd.args(["wallet", "new", "missing-dir", "account-name", "--unsafe-password", "test"]) + .assert_failure() + .stderr_eq(str![[r#" +Error: If you specified a directory, please make sure it exists, or create it before running `cast wallet new `. +missing-dir is not a directory. +Error: [..] + +"#]]); +} + +#[casttest] +fn new_wallet_multiple_keys(cmd: _) { + cmd.args(["wallet", "new", "-n", "2"]) + .assert_success() + .stdout_eq(str![[r#" +0x[..] 0x[..] +0x[..] 0x[..] + +"#]]) + .stderr_eq(str![[r#" +Successfully created new keypair. +[ADDRESS] +[PRIVATE_KEY] +Successfully created new keypair. +[ADDRESS] +[PRIVATE_KEY] + +"#]]); +} + +// tests that we can get the address of a keystore file +#[casttest] +fn wallet_address_keystore_with_password_file(cmd: _) { + let keystore_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/keystore"); + + cmd.args([ + "wallet", + "address", + "--keystore", + keystore_dir + .join("UTC--2022-12-20T10-30-43.591916000Z--ec554aeafe75601aaab43bd4621a22284db566c2") + .to_str() + .unwrap(), + "--password-file", + keystore_dir.join("password-ec554").to_str().unwrap(), + ]) + .assert_success() + .stdout_eq(str![[r#" +0xeC554aeAFE75601AaAb43Bd4621A22284dB566C2 + +"#]]); +} + +// https://github.com/foundry-rs/foundry/issues/16523 +#[test] +#[cfg(unix)] +fn wallet_address_keystore_from_stdin() { + let keystore = + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/keystore/UTC--2022-12-20T10-30-43.591916000Z--ec554aeafe75601aaab43bd4621a22284db566c2"); + let mut command = Command::new("sh"); + command + .env("CAST_BIN", env!("CARGO_BIN_EXE_cast")) + .env("KEYSTORE", keystore) + .env("NO_COLOR", "1") + .env("TERM", "dumb") + .args(["-c", r#"cat "$KEYSTORE" | "$CAST_BIN" wallet address --keystore /dev/stdin"#]); + + let mut session = spawn_with_options( + command, + Options { + timeout_ms: Some(30_000), + strip_ansi_escape_codes: true, + encoding: Encoding::UTF8, + }, + ) + .unwrap(); + + session.exp_string("Enter keystore password:").unwrap(); + session.send_line("keystorepassword").unwrap(); + let output = session.exp_eof().unwrap(); + assert!( + matches!(session.process.wait().unwrap(), WaitStatus::Exited(_, 0)), + "cast command failed: {output}" + ); + assert!( + output.contains("0xeC554aeAFE75601AaAb43Bd4621A22284dB566C2"), + "missing keystore address: {output}" + ); +} + +// Tests that `cast wallet remove` can successfully remove a keystore file and validates password. +#[casttest] +fn wallet_remove_keystore_with_unsafe_password(prj: _, cmd: _) { + let keystore_path = prj.root().join("keystore"); + + cmd.set_current_dir(prj.root()); + + let account_name = "testAccount"; + + // Default Anvil private key + let test_private_key = + b256!("0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"); + + // import private key + cmd.cast_fuse() + .args([ + "wallet", + "import", + account_name, + "--private-key", + &test_private_key.to_string(), + "-k", + "keystore", + "--unsafe-password", + "test", + ]) + .assert_success() + .stdout_eq(str![[r#" +`testAccount` keystore was saved successfully. [ADDRESS] + +"#]]); + + // check that the keystore file was created + let keystore_file = keystore_path.join(account_name); + + assert!(keystore_file.exists()); + // Remove the wallet + cmd.cast_fuse() + .args([ + "wallet", + "remove", + "--name", + account_name, + "--dir", + keystore_path.to_str().unwrap(), + "--unsafe-password", + "test", + ]) + .assert_success() + .stdout_eq(str![[r#" +`testAccount` keystore was removed successfully. + +"#]]); + + assert!(!keystore_file.exists()); +} + +// `cast wallet import` treats ACCOUNT_NAME as a file name under the keystore dir. +// A path segment would write the encrypted keystore outside that directory. +#[casttest] +fn wallet_import_rejects_path_account_name(prj: _, cmd: _) { + let keystore_dir = prj.root().join("keystore"); + fs::create_dir_all(&keystore_dir).unwrap(); + let escaped = prj.root().join("pwned_foundry_alias"); + + cmd.set_current_dir(prj.root()); + cmd.args([ + "wallet", + "import", + "../pwned_foundry_alias", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--keystore-dir", + keystore_dir.to_str().unwrap(), + "--unsafe-password", + "test", + ]) + .assert_failure() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Error: account name must be a single path segment + +"#]]); + + assert!(!escaped.exists()); + assert!(!keystore_dir.join("../pwned_foundry_alias").exists()); +} + +// Tests that `cast wallet list` outputs the local accounts. +#[casttest] +fn wallet_list_local_accounts(prj: _, cmd: _) { + let keystore_path = prj.root().join("keystore"); + fs::create_dir_all(&keystore_path).unwrap(); + cmd.set_current_dir(prj.root()); + + // empty results + cmd.cast_fuse() + .args(["wallet", "list", "--dir", "keystore"]) + .assert_success() + .stdout_eq(str![""]); + + // create 10 wallets + cmd.cast_fuse() + .args(["wallet", "new", "keystore", "-n", "10", "--unsafe-password", "test"]) + .assert_success() + .stdout_eq(str![[r#" +0x[..] +0x[..] +0x[..] +0x[..] +0x[..] +0x[..] +0x[..] +0x[..] +0x[..] +0x[..] + +"#]]) + .stderr_eq(str![[r#" +Created new encrypted keystore file: [..] +[ADDRESS] +Created new encrypted keystore file: [..] +[ADDRESS] +Created new encrypted keystore file: [..] +[ADDRESS] +Created new encrypted keystore file: [..] +[ADDRESS] +Created new encrypted keystore file: [..] +[ADDRESS] +Created new encrypted keystore file: [..] +[ADDRESS] +Created new encrypted keystore file: [..] +[ADDRESS] +Created new encrypted keystore file: [..] +[ADDRESS] +Created new encrypted keystore file: [..] +[ADDRESS] +Created new encrypted keystore file: [..] +[ADDRESS] + +"#]]); + + fs::write( + keystore_path.join("ignored.touchid"), + valid_touch_id_sidecar_fixture(1, "user-presence"), + ) + .unwrap(); + + // Test listing new wallets while omitting the Touch ID sidecar. + cmd.cast_fuse().args(["wallet", "list", "--dir", "keystore"]).assert_success().stdout_eq(str![ + [r#" +[..] (Local) +[..] (Local) +[..] (Local) +[..] (Local) +[..] (Local) +[..] (Local) +[..] (Local) +[..] (Local) +[..] (Local) +[..] (Local) + +"#] + ]); +} + +// Tests that `cast wallet list --json --dir` wraps local accounts in the shared envelope. +#[casttest] +fn wallet_list_local_accounts_json(prj: _, cmd: _) { + let keystore_path = prj.root().join("keystore"); + fs::create_dir_all(&keystore_path).unwrap(); + cmd.set_current_dir(prj.root()); + + cmd.args(["wallet", "new", "keystore", "--unsafe-password", "test"]).assert_success(); + fs::write( + keystore_path.join("ignored.touchid"), + valid_touch_id_sidecar_fixture(1, "user-presence"), + ) + .unwrap(); + + cmd.cast_fuse() + .args(["wallet", "list", "--json", "--dir", "keystore"]) + .assert_success() + .stdout_eq( + str![[r#" +{ + "schema_version": 1, + "success": true, + "data": [ + { + "address": "{...}", + "source": "Local" + } + ], + "errors": [], + "warnings": [] +} + +"#]] + .is_json(), + ); +} + +// tests that `cast wallet list` preserves custom keystore names +#[casttest] +fn wallet_list_named_local_account(prj: _, cmd: _) { + let keystore_path = prj.root().join("keystore"); + fs::create_dir_all(&keystore_path).unwrap(); + fs::write(keystore_path.join("my_account"), "{}").unwrap(); + cmd.set_current_dir(prj.root()); + + cmd.cast_fuse().args(["wallet", "list", "--dir", "keystore"]).assert_success().stdout_eq(str![ + [r#" +my_account (Local) + +"#] + ]); + + cmd.cast_fuse() + .args(["wallet", "list", "--json", "--dir", "keystore"]) + .assert_success() + .stdout_eq( + str![[r#" +{ + "schema_version": 1, + "success": true, + "data": [ + { + "address": "my_account", + "source": "Local" + } + ], + "errors": [], + "warnings": [] +} + +"#]] + .is_json(), + ); +} + +// tests that `cast wallet vanity --json --nonce` wraps wallet and contract address output +#[casttest] +fn wallet_vanity_json_nonce_contract_address(cmd: _) { + cmd.args(["wallet", "vanity", "--starts-with", ".", "--nonce", "1", "--json"]) + .assert_success() + .stdout_eq( + str![[r#" +{ + "schema_version": 1, + "success": true, + "data": { + "address": "{...}", + "private_key": "{...}", + "contract_address": "{...}" + }, + "errors": [], + "warnings": [] +} + +"#]] + .is_json(), + ); +} + +// tests that `cast wallet import` creates a keystore for a private key and that `cast wallet +// decrypt-keystore` can access it +#[casttest] +fn wallet_import_and_decrypt(prj: _, cmd: _) { + let keystore_path = prj.root().join("keystore"); + + cmd.set_current_dir(prj.root()); + + let account_name = "testAccount"; + + // Default Anvil private key + let test_private_key = + b256!("0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"); + + // import private key + cmd.cast_fuse() + .args([ + "wallet", + "import", + account_name, + "--private-key", + &test_private_key.to_string(), + "-k", + "keystore", + "--unsafe-password", + "test", + ]) + .assert_success() + .stdout_eq(str![[r#" +`testAccount` keystore was saved successfully. [ADDRESS] + +"#]]); + + // check that the keystore file was created + let keystore_file = keystore_path.join(account_name); + + assert!(keystore_file.exists()); + + // decrypt the keystore file + let decrypt_output = cmd.cast_fuse().args([ + "wallet", + "decrypt-keystore", + account_name, + "-k", + "keystore", + "--unsafe-password", + "test", + ]); + + // get the PK out of the output (last word in the output) + let decrypt_output = decrypt_output.assert_success().get_output().stdout_lossy(); + let private_key_string = decrypt_output.split_whitespace().last().unwrap(); + // check that the decrypted private key matches the imported private key + let decrypted_private_key = B256::from_str(private_key_string).unwrap(); + // the form + assert_eq!(decrypted_private_key, test_private_key); +} + +// tests that `cast wallet change-password` can successfully change the password of a keystore file +#[casttest] +fn wallet_change_password(prj: _, cmd: _) { + let keystore_path = prj.root().join("keystore"); + + cmd.set_current_dir(prj.root()); + + let account_name = "testAccount"; + + // Default Anvil private key + let test_private_key = + b256!("0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"); + + // import private key with initial password + cmd.cast_fuse() + .args([ + "wallet", + "import", + account_name, + "--private-key", + &test_private_key.to_string(), + "-k", + "keystore", + "--unsafe-password", + "old_password", + ]) + .assert_success() + .stdout_eq(str![[r#" +`testAccount` keystore was saved successfully. [ADDRESS] + +"#]]); + + // check that the keystore file was created + let keystore_file = keystore_path.join(account_name); + assert!(keystore_file.exists()); + + // change the password + cmd.cast_fuse() + .args([ + "wallet", + "change-password", + account_name, + "--keystore-dir", + "keystore", + "--unsafe-password", + "old_password", + "--unsafe-new-password", + "new_password", + ]) + .assert_success() + .stdout_eq(str![[r#" +Password for keystore `testAccount` was changed successfully. [ADDRESS] + +"#]]) + .stderr_eq(str![""]); + + // verify the old password no longer works + cmd.cast_fuse() + .args([ + "wallet", + "decrypt-keystore", + account_name, + "-k", + "keystore", + "--unsafe-password", + "old_password", + ]) + .assert_failure(); + + // verify the new password works + let decrypt_output = cmd.cast_fuse().args([ + "wallet", + "decrypt-keystore", + account_name, + "-k", + "keystore", + "--unsafe-password", + "new_password", + ]); + + // get the PK out of the output (last word in the output) + let decrypt_output = decrypt_output.assert_success().get_output().stdout_lossy(); + let private_key_string = decrypt_output.split_whitespace().last().unwrap(); + + // check that the decrypted private key matches the imported private key + let decrypted_private_key = B256::from_str(private_key_string).unwrap(); + assert_eq!(decrypted_private_key, test_private_key); +} + +// HOME does not override the default keystore directory on Windows. +#[cfg(not(windows))] +#[casttest] +fn wallet_list_turnkey_skips_local_accounts(prj: _, cmd: _) { + let keystores = prj.root().join(".foundry/keystores"); + fs::create_dir_all(&keystores).unwrap(); + fs::write(keystores.join("local-account"), "").unwrap(); + + cmd.env("HOME", prj.root()); + cmd.args(["wallet", "list"]).assert_success().stdout_eq(str![[r#" +local-account (Local) + +"#]]); + + cmd.cast_fuse(); + cmd.env("HOME", prj.root()); + cmd.unset_env("TURNKEY_API_PRIVATE_KEY"); + let assert = cmd.args(["wallet", "list", "--turnkey"]).assert_success().stdout_eq(str![""]); + if cfg!(feature = "turnkey") { + assert.stderr_eq(str![[r#" +Error: environment variable not found + +"#]]); + } else { + assert.stderr_eq(str![""]); + } +} + +#[cfg(feature = "turnkey")] +#[casttest] +fn wallet_list_turnkey(cmd: _) { + for json in [false, true] { + cmd.cast_fuse(); + cmd.envs([ + ( + "TURNKEY_API_PRIVATE_KEY", + "0000000000000000000000000000000000000000000000000000000000000001", + ), + ("TURNKEY_ORGANIZATION_ID", "test-organization"), + ("TURNKEY_ADDRESS", "0x000000000000000000000000000000000000dEaD"), + ]); + cmd.args(["wallet", "list", "--turnkey"]); + if json { + cmd.arg("--json").assert_json_stdout(str![[r#" +{ + "schema_version": 1, + "success": true, + "data": [{"address": "0x000000000000000000000000000000000000dEaD", "source": "Turnkey"}], + "errors": [], + "warnings": [] +} +"#]]); + } else { + cmd.assert_success().stdout_eq(str![[r#" +0x000000000000000000000000000000000000dEaD (Turnkey) + +"#]]); + } + } +} + +#[casttest] +fn malformed_dotenv_warns_without_exposing_values(prj: _, cmd: _) { + fs::write(prj.root().join(".env"), "FIRST=one\nSECRET=\"sensitive-value\nAFTER=two\n").unwrap(); + cmd.args([ + "wallet", + "address", + "--private-key", + "0000000000000000000000000000000000000000000000000000000000000001", + ]) + .assert_success() + .stdout_eq("0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf\n") + .stderr_eq(str![[r#" +Warning: Failed to load [..]/.env: invalid syntax; remaining variables were not loaded + +"#]]); +} + +#[casttest] +fn malformed_dotenv_quiet_suppresses_warning(prj: _, cmd: _) { + fs::write(prj.root().join(".env"), "FIRST=one\nSECRET=\"sensitive-value\nAFTER=two\n").unwrap(); + cmd.args([ + "--quiet", + "wallet", + "address", + "--private-key", + "0000000000000000000000000000000000000000000000000000000000000001", + ]) + .assert_success() + .stdout_eq("") + .stderr_eq(""); +} diff --git a/crates/cast/tests/cli/wallet_signing.rs b/crates/cast/tests/cli/wallet_signing.rs new file mode 100644 index 0000000000000..d18012127b45a --- /dev/null +++ b/crates/cast/tests/cli/wallet_signing.rs @@ -0,0 +1,469 @@ +//! CLI tests for wallet signing commands. + +use super::*; + +// tests that `cast wallet sign --json` wraps signature in envelope +#[casttest] +fn wallet_sign_json(cmd: _) { + cmd.args([ + "wallet", + "sign", + "--json", + "--private-key", + "0x0000000000000000000000000000000000000000000000000000000000000001", + "test", + ]) + .assert_success() + .stdout_eq(str![[r#" +{"schema_version":1,"success":true,"data":"0xfe28833983d6faa0715c7e8c3873c725ddab6fa5bf84d40e780676e463e6bea20fc6aea97dc273a98eb26b0914e224c8dd5c615ceaab69ddddcf9b0ae3de0e371c","errors":[],"warnings":[]} + +"#]]); +} + +// tests that `cast wallet sign -v --json` wraps verbose output in envelope +#[casttest] +fn wallet_sign_json_verbose(cmd: _) { + cmd.args([ + "wallet", + "sign", + "--json", + "-v", + "--private-key", + "0x0000000000000000000000000000000000000000000000000000000000000001", + "test", + ]) + .assert_success() + .stdout_eq(str![[r#" +{"schema_version":1,"success":true,"data":{"message":"test","address":"0x7e5f4552091a69125d5dfcb7b8c2659029395bdf","signature":"fe28833983d6faa0715c7e8c3873c725ddab6fa5bf84d40e780676e463e6bea20fc6aea97dc273a98eb26b0914e224c8dd5c615ceaab69ddddcf9b0ae3de0e371c"},"errors":[],"warnings":[]} + +"#]]); +} + +// tests that `cast wallet sign message` outputs the expected signature +#[casttest] +fn wallet_sign_message_utf8_data(cmd: _) { + let pk = "0x0000000000000000000000000000000000000000000000000000000000000001"; + let address = "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf"; + let msg = "test"; + let expected = "0xfe28833983d6faa0715c7e8c3873c725ddab6fa5bf84d40e780676e463e6bea20fc6aea97dc273a98eb26b0914e224c8dd5c615ceaab69ddddcf9b0ae3de0e371c"; + + cmd.args(["wallet", "sign", "--private-key", pk, msg]).assert_success().stdout_eq(str![[r#" +0xfe28833983d6faa0715c7e8c3873c725ddab6fa5bf84d40e780676e463e6bea20fc6aea97dc273a98eb26b0914e224c8dd5c615ceaab69ddddcf9b0ae3de0e371c + +"#]]); + + // Success. + cmd.cast_fuse() + .args(["wallet", "verify", "-a", address, msg, expected]) + .assert_success() + .stdout_eq(str![[r#" +Validation succeeded. Address 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf signed this message. + +"#]]); + + // Fail. + cmd.cast_fuse() + .args(["wallet", "verify", "-a", address, "other msg", expected]) + .assert_failure() + .stderr_eq(str![[r#" +Error: Validation failed. Address 0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf did not sign this message. + +"#]]); +} + +// tests that `cast wallet sign --json` outputs JSON on stdout regardless of verbosity +#[casttest] +fn wallet_sign_message_json(cmd: _) { + cmd.args([ + "wallet", + "sign", + "--json", + "--private-key", + "0x0000000000000000000000000000000000000000000000000000000000000001", + "test", + ]) +.assert_success() +.stdout_eq(str![[r#" +{"schema_version":1,"success":true,"data":"0xfe28833983d6faa0715c7e8c3873c725ddab6fa5bf84d40e780676e463e6bea20fc6aea97dc273a98eb26b0914e224c8dd5c615ceaab69ddddcf9b0ae3de0e371c","errors":[],"warnings":[]} + +"#]]); +} + +// tests that `cast wallet sign message` outputs the expected signature, given a 0x-prefixed data +#[casttest] +fn wallet_sign_message_hex_data(cmd: _) { + cmd.args([ + "wallet", + "sign", + "--private-key", + "0x0000000000000000000000000000000000000000000000000000000000000001", + "0x0000000000000000000000000000000000000000000000000000000000000000", + ]).assert_success().stdout_eq(str![[r#" +0x23a42ca5616ee730ff3735890c32fc7b9491a9f633faca9434797f2c845f5abf4d9ba23bd7edb8577acebaa3644dc5a4995296db420522bb40060f1693c33c9b1c + +"#]]); +} + +// +// tests that `cast wallet sign` and `cast wallet verify` work with the same message as input +#[casttest] +fn wallet_sign_and_verify_message_hex_data(cmd: _) { + // message="$1" + // mnemonic="test test test test test test test test test test test junk" + // key=$(cast wallet private-key --mnemonic "$mnemonic") + // address=$(cast wallet address --mnemonic "$mnemonic") + // signature=$(cast wallet sign --private-key "$key" "$message") + // cast wallet verify --address "$address" "$message" "$signature" + let mnemonic = "test test test test test test test test test test test junk"; + let key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + let address = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; + cmd.args(["wallet", "private-key", "--mnemonic", mnemonic]).assert_success().stdout_eq(str![[ + r#" +0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 + +"# + ]]); + cmd.cast_fuse().args(["wallet", "address", "--mnemonic", mnemonic]).assert_success().stdout_eq( + str![[r#" +0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 + +"#]], + ); + + let msg_hex = "0x0000000000000000000000000000000000000000000000000000000000000001"; + let signature_hex = "0xed769da87f78d0166b30aebf2767ceed5a3867da21b2fba8c6527af256bbcebe24a1e758ec8ad1ffc29cfefa540ea7ba7966c0edf6907af82348f894ba4f40fa1b"; + cmd.cast_fuse().args([ + "wallet", "sign", "--private-key",key, msg_hex + ]).assert_success().stdout_eq(str![[r#" +0xed769da87f78d0166b30aebf2767ceed5a3867da21b2fba8c6527af256bbcebe24a1e758ec8ad1ffc29cfefa540ea7ba7966c0edf6907af82348f894ba4f40fa1b + +"#]]); + + cmd.cast_fuse() + .args(["wallet", "verify", "--address", address, msg_hex, signature_hex]) + .assert_success() + .stdout_eq(str![[r#" +Validation succeeded. Address 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 signed this message. + +"#]]); + + let msg_raw = "0000000000000000000000000000000000000000000000000000000000000001"; + let signature_raw = "0x27a97b378477d9d004bd19cbd838d59bbb9847074ae4cc5b5975cc5566065eea76ee5b752fcdd483073e1baba548d82d9accc8603b3781bcc9abf195614cd3411c"; + cmd.cast_fuse().args([ + "wallet", "sign", "--private-key",key, msg_raw + ]).assert_success().stdout_eq(str![[r#" +0x27a97b378477d9d004bd19cbd838d59bbb9847074ae4cc5b5975cc5566065eea76ee5b752fcdd483073e1baba548d82d9accc8603b3781bcc9abf195614cd3411c + +"#]]); + + cmd.cast_fuse() + .args(["wallet", "verify", "--address", address, msg_raw, signature_raw]) + .assert_success() + .stdout_eq(str![[r#" +Validation succeeded. Address 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 signed this message. + +"#]]); +} + +// tests that `cast wallet sign typed-data` outputs the expected signature, given a JSON string +#[casttest] +fn wallet_sign_typed_data_string(cmd: _) { + cmd.args([ + "wallet", + "sign", + "--private-key", + "0x0000000000000000000000000000000000000000000000000000000000000001", + "--data", + "{\"types\": {\"EIP712Domain\": [{\"name\": \"name\",\"type\": \"string\"},{\"name\": \"version\",\"type\": \"string\"},{\"name\": \"chainId\",\"type\": \"uint256\"},{\"name\": \"verifyingContract\",\"type\": \"address\"}],\"Message\": [{\"name\": \"data\",\"type\": \"string\"}]},\"primaryType\": \"Message\",\"domain\": {\"name\": \"example.metamask.io\",\"version\": \"1\",\"chainId\": \"1\",\"verifyingContract\": \"0x0000000000000000000000000000000000000000\"},\"message\": {\"data\": \"Hello!\"}}", + ]).assert_success().stdout_eq(str![[r#" +0x06c18bdc8163219fddc9afaf5a0550e381326474bb757c86dc32317040cf384e07a2c72ce66c1a0626b6750ca9b6c035bf6f03e7ed67ae2d1134171e9085c0b51b + +"#]]); +} + +// tests that `cast wallet sign typed-data` outputs the expected signature, given a JSON file +#[casttest] +fn wallet_sign_typed_data_file(cmd: _) { + cmd.args([ + "wallet", + "sign", + "--private-key", + "0x0000000000000000000000000000000000000000000000000000000000000001", + "--data", + "--from-file", + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/sign_typed_data.json") + .into_os_string() + .into_string() + .unwrap() + .as_str(), + ]).assert_success().stdout_eq(str![[r#" +0x06c18bdc8163219fddc9afaf5a0550e381326474bb757c86dc32317040cf384e07a2c72ce66c1a0626b6750ca9b6c035bf6f03e7ed67ae2d1134171e9085c0b51b + +"#]]); +} + +// tests that `cast wallet sign typed-data` passes with type names containing colons +// +#[casttest] +fn wallet_sign_typed_data_with_colon_succeeds(cmd: _) { + let typed_data_with_colon = r#"{ + "types": { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "version", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"} + ], + "Test:Message": [ + {"name": "content", "type": "string"} + ] + }, + "primaryType": "Test:Message", + "domain": { + "name": "TestDomain", + "version": "1", + "chainId": 1, + "verifyingContract": "0x0000000000000000000000000000000000000000" + }, + "message": { + "content": "Hello" + } + }"#; + + cmd.args([ + "wallet", + "sign", + "--private-key", + "0x0000000000000000000000000000000000000000000000000000000000000001", + "--data", + typed_data_with_colon, + ]).assert_success().stdout_eq(str![[r#" +0xf91c67e845a4d468d1f876f457ffa01e65468641fc121453705242d21de39b266c278592b085814ab1e9adc938cc26b1d64bb61f80b437df077777c4283612291b + +"#]]); +} + +// tests that the same data without colon works correctly +// +#[casttest] +fn wallet_sign_typed_data_without_colon_works(cmd: _) { + let typed_data_without_colon = r#"{ + "types": { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "version", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"} + ], + "TestMessage": [ + {"name": "content", "type": "string"} + ] + }, + "primaryType": "TestMessage", + "domain": { + "name": "TestDomain", + "version": "1", + "chainId": 1, + "verifyingContract": "0x0000000000000000000000000000000000000000" + }, + "message": { + "content": "Hello" + } + }"#; + + cmd.args([ + "wallet", + "sign", + "--private-key", + "0x0000000000000000000000000000000000000000000000000000000000000001", + "--data", + typed_data_without_colon, + ]) + .assert_success(); +} + +// tests that `cast wallet sign-auth message` outputs the expected signature +#[casttest] +fn wallet_sign_auth(cmd: _) { + cmd.args([ + "wallet", + "sign-auth", + "--private-key", + "0x0000000000000000000000000000000000000000000000000000000000000001", + "--nonce", + "100", + "--chain", + "1", + "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf"]).assert_success().stdout_eq(str![[r#" +0xf85a01947e5f4552091a69125d5dfcb7b8c2659029395bdf6401a0ad489ee0314497c3f06567f3080a46a63908edc1c7cdf2ac2d609ca911212086a065a6ba951c8748dd8634740fe498efb61770097d99ff5fdcb9a863b62ea899f6 + +"#]]); +} + +#[casttest] +fn wallet_sign_auth_zero_chain_requires_confirmation(cmd: _) { + use alloy_rlp::Decodable; + + let delegate = "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf"; + let private_key = "0x0000000000000000000000000000000000000000000000000000000000000001"; + + cmd.args(["wallet", "sign-auth", "--nonce", "100", "--chain", "0", delegate]) + .stdin("n\n") + .assert_success() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Warning: Chain ID 0 creates an EIP-7702 authorization that is valid on every chain. + +Continue anyway? [y/N] Aborted. + +"#]]); + + let confirmed = cmd + .cast_fuse() + .args([ + "wallet", + "sign-auth", + "--private-key", + private_key, + "--nonce", + "100", + "--chain", + "0", + delegate, + ]) + .stdin("y\n") + .assert_success() + .stderr_eq(str![[r#" +Warning: Chain ID 0 creates an EIP-7702 authorization that is valid on every chain. + +Continue anyway? [y/N] "#]]) + .get_output() + .stdout_lossy() + .trim() + .to_string(); + + let bytes = hex::decode(confirmed.strip_prefix("0x").unwrap()).unwrap(); + let auth = alloy_eips::eip7702::SignedAuthorization::decode(&mut bytes.as_slice()).unwrap(); + assert_eq!(*auth.chain_id(), U256::ZERO); + assert_eq!(auth.nonce(), 100); + + cmd.cast_fuse() + .args([ + "wallet", + "sign-auth", + "--private-key", + private_key, + "--nonce", + "100", + "--chain", + "0", + "--force", + delegate, + ]) + .assert_success() + .stdout_eq(format!("{confirmed}\n")) + .stderr_eq(str![""]); +} + +#[casttest] +async fn wallet_sign_auth_rpc_zero_chain_requires_confirmation(cmd: _) { + let (_, handle) = anvil::spawn(NodeConfig::test().with_chain_id(Some(0u64))).await; + + cmd.args([ + "wallet", + "sign-auth", + "--rpc-url", + &handle.http_endpoint(), + "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf", + ]) + .stdin("n\n") + .assert_success() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Warning: Chain ID 0 creates an EIP-7702 authorization that is valid on every chain. + +Continue anyway? [y/N] Aborted. + +"#]]); +} + +// tests that `cast wallet sign-auth --self-broadcast` uses nonce + 1 +#[casttest] +async fn wallet_sign_auth_self_broadcast(cmd: _) { + use alloy_rlp::Decodable; + use alloy_signer_local::PrivateKeySigner; + + let (_, handle) = + anvil::spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Prague.into()))).await; + let endpoint = handle.http_endpoint(); + + let private_key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + let signer: PrivateKeySigner = private_key.parse().unwrap(); + let signer_address = signer.address(); + let delegate_address = address!("0x70997970C51812dc3A010C7d01b50e0d17dc79C8"); + + // Get the current nonce from the RPC + let provider = ProviderBuilder::new().connect_http(endpoint.parse().unwrap()); + let current_nonce = provider.get_transaction_count(signer_address).await.unwrap(); + + // First, get the auth without --self-broadcast (should use current nonce) + let output_normal = cmd + .args([ + "wallet", + "sign-auth", + "--private-key", + private_key, + "--rpc-url", + &endpoint, + &delegate_address.to_string(), + ]) + .assert_success() + .get_output() + .stdout_lossy() + .trim() + .to_string(); + + // Then, get the auth with --self-broadcast (should use current nonce + 1) + let output_self_broadcast = cmd + .cast_fuse() + .args([ + "wallet", + "sign-auth", + "--private-key", + private_key, + "--rpc-url", + &endpoint, + "--self-broadcast", + &delegate_address.to_string(), + ]) + .assert_success() + .get_output() + .stdout_lossy() + .trim() + .to_string(); + + // The outputs should be different due to different nonces + assert_ne!( + output_normal, output_self_broadcast, + "self-broadcast should produce different signature due to nonce + 1" + ); + + // Decode the RLP to verify the nonces + let normal_bytes = hex::decode(output_normal.strip_prefix("0x").unwrap()).unwrap(); + let self_broadcast_bytes = + hex::decode(output_self_broadcast.strip_prefix("0x").unwrap()).unwrap(); + + let normal_auth = + alloy_eips::eip7702::SignedAuthorization::decode(&mut normal_bytes.as_slice()).unwrap(); + let self_broadcast_auth = + alloy_eips::eip7702::SignedAuthorization::decode(&mut self_broadcast_bytes.as_slice()) + .unwrap(); + + assert_eq!(normal_auth.nonce(), current_nonce, "normal auth should have current nonce"); + assert_eq!( + self_broadcast_auth.nonce(), + current_nonce + 1, + "self-broadcast auth should have current nonce + 1" + ); +} diff --git a/crates/cast/tests/cli/wallet_touch_id.rs b/crates/cast/tests/cli/wallet_touch_id.rs new file mode 100644 index 0000000000000..fc4c2d1dbd694 --- /dev/null +++ b/crates/cast/tests/cli/wallet_touch_id.rs @@ -0,0 +1,791 @@ +//! CLI tests for wallet touch id commands. + +use super::*; + +// tests that we can outputting multiple keys without a keystore path + +#[cfg(all(target_os = "macos", feature = "touch-id"))] +#[casttest] +fn wallet_new_help_describes_touch_id_fallback(cmd: _) { + let assert = cmd.args(["wallet", "new", "--help"]).assert_success(); + let output = String::from_utf8_lossy(&assert.get_output().stdout); + + assert!(output.contains("Touch ID-assisted authentication")); + assert!(output.contains("explicit keystore passwords remain available")); +} + +#[cfg(all(target_os = "macos", feature = "touch-id"))] +#[casttest] +fn wallet_import_help_describes_touch_id_fallback(cmd: _) { + let assert = cmd.args(["wallet", "import", "--help"]).assert_success(); + let output = String::from_utf8_lossy(&assert.get_output().stdout); + + assert!(output.contains("Touch ID-assisted authentication")); + assert!(output.contains("explicit keystore passwords remain available")); +} + +#[casttest] +fn wallet_touch_id_help_lists_lifecycle_commands(cmd: _) { + let assert = cmd.args(["wallet", "touch-id", "--help"]).assert_success(); + let output = String::from_utf8_lossy(&assert.get_output().stdout); + + assert!(output.contains("enroll")); + assert!(output.contains("status")); + assert!(output.contains("remove")); +} + +#[casttest] +fn wallet_touch_id_status_missing_and_json(prj: _, cmd: _) { + let keystore_dir = prj.root().join("keystore"); + cmd.args([ + "wallet", + "import", + "testAccount", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--keystore-dir", + keystore_dir.to_str().unwrap(), + "--unsafe-password", + "test", + ]) + .assert_success(); + + cmd.cast_fuse() + .args([ + "wallet", + "touch-id", + "status", + "testAccount", + "--keystore-dir", + keystore_dir.to_str().unwrap(), + ]) + .assert_success() + .stdout_eq(str![[r#" +Touch ID is not enrolled for keystore `testAccount`. + +"#]]); + + cmd.cast_fuse() + .args([ + "wallet", + "touch-id", + "status", + "testAccount", + "--keystore-dir", + keystore_dir.to_str().unwrap(), + "--json", + ]) + .assert_success() + .stdout_eq(str![[r#" +{"schema_version":1,"success":true,"data":{"account":"testAccount","status":"not-enrolled"},"errors":[],"warnings":[]} + +"#]]); +} + +#[casttest] +fn wallet_touch_id_status_recognized_is_non_mutating(prj: _, cmd: _) { + let keystore_dir = prj.root().join("keystore"); + let sidecar = keystore_dir.join("testAccount.touchid"); + cmd.args([ + "wallet", + "import", + "testAccount", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--keystore-dir", + keystore_dir.to_str().unwrap(), + "--unsafe-password", + "test", + ]) + .assert_success(); + fs::write(&sidecar, valid_touch_id_sidecar_fixture(1, "current-biometry")).unwrap(); + let original_sidecar = fs::read(&sidecar).unwrap(); + + cmd.cast_fuse() + .args([ + "wallet", + "touch-id", + "status", + "testAccount", + "--keystore-dir", + keystore_dir.to_str().unwrap(), + ]) + .assert_success() + .stdout_eq(str![[r#" +Touch ID is enrolled for keystore `testAccount` with `current-biometry` policy. + +"#]]); + + assert_eq!(fs::read(sidecar).unwrap(), original_sidecar); +} + +#[casttest] +fn wallet_touch_id_remove_is_idempotent(prj: _, cmd: _) { + let keystore_dir = prj.root().join("keystore"); + let keystore = keystore_dir.join("testAccount"); + let sidecar = keystore_dir.join("testAccount.touchid"); + cmd.args([ + "wallet", + "import", + "testAccount", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--keystore-dir", + keystore_dir.to_str().unwrap(), + "--unsafe-password", + "test", + ]) + .assert_success(); + fs::write(&sidecar, valid_touch_id_sidecar_fixture(1, "user-presence")).unwrap(); + + cmd.cast_fuse() + .args([ + "wallet", + "touch-id", + "remove", + "testAccount", + "--keystore-dir", + keystore_dir.to_str().unwrap(), + ]) + .assert_success() + .stdout_eq(str![[r#" +Touch ID enrollment removed for keystore `testAccount`. + +"#]]); + assert!(keystore.exists()); + assert!(!sidecar.exists()); + + cmd.cast_fuse() + .args([ + "wallet", + "touch-id", + "remove", + "testAccount", + "--keystore-dir", + keystore_dir.to_str().unwrap(), + ]) + .assert_success() + .stdout_eq(str![[r#" +Touch ID is not enrolled for keystore `testAccount`. + +"#]]); +} + +#[casttest] +fn wallet_touch_id_remove_refuses_unknown_sidecar(prj: _, cmd: _) { + let keystore_dir = prj.root().join("keystore"); + let sidecar = keystore_dir.join("testAccount.touchid"); + cmd.args([ + "wallet", + "import", + "testAccount", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--keystore-dir", + keystore_dir.to_str().unwrap(), + "--unsafe-password", + "test", + ]) + .assert_success(); + fs::write(&sidecar, valid_touch_id_sidecar_fixture(2, "user-presence")).unwrap(); + + cmd.cast_fuse() + .args([ + "wallet", + "touch-id", + "status", + "testAccount", + "--keystore-dir", + keystore_dir.to_str().unwrap(), + ]) + .assert_success() + .stdout_eq(str![[r#" +Touch ID status for keystore `testAccount` is unknown: [..]/testAccount.touchid is not a recognized Touch ID sidecar. + +"#]]); + + cmd.cast_fuse() + .args([ + "wallet", + "touch-id", + "remove", + "testAccount", + "--keystore-dir", + keystore_dir.to_str().unwrap(), + ]) + .assert_failure() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Error: refusing to remove [..]/testAccount.touchid because it is not a recognized Touch ID sidecar + +"#]]); + assert!(sidecar.exists()); +} + +#[casttest] +fn wallet_touch_id_remove_refuses_keystore_collision(prj: _, cmd: _) { + let keystore_dir = prj.root().join("keystore"); + let keystore = keystore_dir.join("testAccount"); + let sidecar = keystore_dir.join("testAccount.touchid"); + cmd.args([ + "wallet", + "import", + "testAccount", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--keystore-dir", + keystore_dir.to_str().unwrap(), + "--unsafe-password", + "test", + ]) + .assert_success(); + fs::copy(&keystore, &sidecar).unwrap(); + + cmd.cast_fuse() + .args([ + "wallet", + "touch-id", + "remove", + "testAccount", + "--keystore-dir", + keystore_dir.to_str().unwrap(), + ]) + .assert_failure() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Error: refusing to remove existing keystore at [..]/testAccount.touchid + +"#]]); + assert!(keystore.exists()); + assert!(sidecar.exists()); +} + +#[casttest] +fn wallet_remove_touch_id_sidecar(prj: _, cmd: _) { + let keystore_dir = prj.root().join("keystore"); + + let account_name = "testAccount"; + let keystore_file = keystore_dir.join(account_name); + + cmd.args([ + "wallet", + "import", + account_name, + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--keystore-dir", + keystore_dir.to_str().unwrap(), + "--unsafe-password", + "test", + ]) + .assert_success(); + + let sidecar = keystore_dir.join(format!("{account_name}.touchid")); + fs::write(&sidecar, valid_touch_id_sidecar_fixture(1, "user-presence")).unwrap(); + + cmd.cast_fuse() + .args([ + "wallet", + "remove", + "--name", + account_name, + "--dir", + keystore_dir.to_str().unwrap(), + "--unsafe-password", + "wrong", + ]) + .assert_failure() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Error: Invalid password - wallet removal cancelled + +"#]]); + assert!(keystore_file.exists()); + assert!(sidecar.exists()); + + cmd.cast_fuse() + .args([ + "wallet", + "remove", + "--name", + account_name, + "--dir", + keystore_dir.to_str().unwrap(), + "--unsafe-password", + "test", + ]) + .assert_success() + .stdout_eq(str![[r#" +`testAccount` keystore was removed successfully. + +"#]]); + assert!(!keystore_file.exists()); + assert!(!sidecar.exists()); +} + +#[casttest] +fn wallet_remove_preserves_invalid_touch_id_payload(prj: _, cmd: _) { + let keystore_dir = prj.root().join("keystore"); + let keystore_path = keystore_dir.join("testAccount"); + let sidecar_path = keystore_dir.join("testAccount.touchid"); + + cmd.args([ + "wallet", + "import", + "testAccount", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--keystore-dir", + keystore_dir.to_str().unwrap(), + "--unsafe-password", + "test", + ]) + .assert_success(); + + let invalid_sidecar_content = serde_json::json!({ + "version": 1, + "policy": "user-presence", + "se_key": "aa", + "sealed_password": format!("04{}", "00".repeat(90)), + }) + .to_string(); + + fs::write(&sidecar_path, &invalid_sidecar_content).unwrap(); + + let original_keystore_bytes = fs::read(&keystore_path).unwrap(); + let original_sidecar_bytes = fs::read(&sidecar_path).unwrap(); + + cmd.cast_fuse() + .args([ + "wallet", + "remove", + "--name", + "testAccount", + "--dir", + keystore_dir.to_str().unwrap(), + "--unsafe-password", + "test", + ]) + .assert_failure(); + + assert!(keystore_path.exists()); + assert!(sidecar_path.exists()); + assert_eq!(fs::read(&keystore_path).unwrap(), original_keystore_bytes); + assert_eq!(fs::read(&sidecar_path).unwrap(), original_sidecar_bytes); +} + +#[casttest] +fn wallet_remove_preserves_touch_id_named_keystore(prj: _, cmd: _) { + let keystore_dir = prj.root().join("keystore"); + let keystore_file = keystore_dir.join("testAccount"); + let legacy_keystore = keystore_dir.join("testAccount.touchid"); + + cmd.args([ + "wallet", + "import", + "testAccount", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--keystore-dir", + keystore_dir.to_str().unwrap(), + "--unsafe-password", + "test", + ]) + .assert_success(); + fs::copy(&keystore_file, &legacy_keystore).unwrap(); + + cmd.cast_fuse() + .args([ + "wallet", + "remove", + "--name", + "testAccount", + "--dir", + keystore_dir.to_str().unwrap(), + "--unsafe-password", + "test", + ]) + .assert_failure() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Error: refusing to remove existing keystore at [..]/testAccount.touchid + +"#]]); + assert!(keystore_file.exists()); + assert!(legacy_keystore.exists()); +} + +#[casttest] +fn wallet_remove_preserves_ambiguous_touch_id_file(prj: _, cmd: _) { + let keystore_dir = prj.root().join("keystore"); + let keystore_file = keystore_dir.join("testAccount"); + let sidecar = keystore_dir.join("testAccount.touchid"); + + cmd.args([ + "wallet", + "import", + "testAccount", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--keystore-dir", + keystore_dir.to_str().unwrap(), + "--unsafe-password", + "test", + ]) + .assert_success(); + fs::write(&sidecar, "malformed").unwrap(); + + cmd.cast_fuse() + .args([ + "wallet", + "remove", + "--name", + "testAccount", + "--dir", + keystore_dir.to_str().unwrap(), + "--unsafe-password", + "test", + ]) + .assert_failure() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Error: failed to parse json file: "[..]/testAccount.touchid": expected value at line 1 column 1 + +"#]]); + assert!(keystore_file.exists()); + assert!(sidecar.exists()); +} + +#[casttest] +fn wallet_import_rejects_touch_id_suffix(prj: _, cmd: _) { + cmd.args([ + "wallet", + "import", + "testAccount.touchid", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--keystore-dir", + prj.root().to_str().unwrap(), + "--unsafe-password", + "test", + ]) + .assert_failure() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Error: account names ending in `.touchid` are reserved + +"#]]); +} + +#[casttest] +fn wallet_list_preserves_ambiguous_touch_id_file(prj: _, cmd: _) { + let keystore_path = prj.root().join("keystore"); + fs::create_dir_all(&keystore_path).unwrap(); + fs::write(keystore_path.join("ambiguous.touchid"), "malformed").unwrap(); + cmd.set_current_dir(prj.root()); + + cmd.cast_fuse().args(["wallet", "list", "--dir", "keystore"]).assert_success().stdout_eq(str![ + [r#"ambiguous.touchid (Local) + +"#] + ]); +} + +#[casttest] +fn wallet_list_retains_unknown_touch_id_sidecars_and_hides_recognized(prj: _, cmd: _) { + let keystore_path = prj.root().join("keystore"); + fs::create_dir_all(&keystore_path).unwrap(); + + fs::write( + keystore_path.join("recognized.touchid"), + valid_touch_id_sidecar_fixture(1, "user-presence"), + ) + .unwrap(); + + fs::write( + keystore_path.join("future.touchid"), + valid_touch_id_sidecar_fixture(2, "user-presence"), + ) + .unwrap(); + + fs::write( + keystore_path.join("invalid_payload.touchid"), + r#"{"version":1,"policy":"user-presence","se_key":"aa","sealed_password":"bb"}"#, + ) + .unwrap(); + + cmd.set_current_dir(prj.root()); + + cmd.cast_fuse().args(["wallet", "list", "--dir", "keystore"]).assert_success().stdout_eq(str![ + [r#"future.touchid (Local) +invalid_payload.touchid (Local) + +"#] + ]); +} + +// Tests that `cast wallet import --touch-id` fails without Touch ID support. +#[cfg(not(all(target_os = "macos", feature = "touch-id")))] +#[casttest] +fn wallet_import_touch_id_unsupported(prj: _, cmd: _) { + let keystore_path = prj.root().join("touch-id-keystore"); + cmd.args([ + "wallet", + "import", + "testAccount", + "--keystore-dir", + keystore_path.to_str().unwrap(), + "--unsafe-password", + "test", + "--touch-id", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + ]) + .assert_failure() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Error: `--touch-id` requires macOS and a cast build with the `touch-id` feature + +"#]]); + + assert!(!keystore_path.exists()); + assert!(!keystore_path.join("testAccount").exists()); + assert!(!keystore_path.join("testAccount.touchid").exists()); +} + +// Tests that `cast wallet new --touch-id` fails without Touch ID support. +#[cfg(not(all(target_os = "macos", feature = "touch-id")))] +#[casttest] +fn wallet_new_touch_id_unsupported(prj: _, cmd: _) { + let keystore_path = prj.root().join("touch-id-keystore"); + cmd.args([ + "wallet", + "new", + keystore_path.to_str().unwrap(), + "testAccount", + "--unsafe-password", + "test", + "--touch-id", + ]) + .assert_failure() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Error: `--touch-id` requires macOS and a cast build with the `touch-id` feature + +"#]]); + + assert!(!keystore_path.exists()); + assert!(!keystore_path.join("testAccount").exists()); + assert!(!keystore_path.join("testAccount.touchid").exists()); +} + +#[cfg(not(all(target_os = "macos", feature = "touch-id")))] +#[casttest] +fn wallet_touch_id_enroll_unsupported(prj: _, cmd: _) { + let keystore_dir = prj.root().join("touch-id-keystore"); + cmd.args([ + "wallet", + "import", + "testAccount", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--keystore-dir", + keystore_dir.to_str().unwrap(), + "--unsafe-password", + "test", + ]) + .assert_success(); + + cmd.cast_fuse() + .args([ + "wallet", + "touch-id", + "enroll", + "testAccount", + "--keystore-dir", + keystore_dir.to_str().unwrap(), + "--unsafe-password", + "test", + ]) + .assert_failure() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Error: `--touch-id` requires macOS and a cast build with the `touch-id` feature + +"#]]); + assert!(!keystore_dir.join("testAccount.touchid").exists()); +} + +#[cfg(all(target_os = "macos", feature = "touch-id"))] +#[casttest] +fn wallet_change_password_rejects_unsupported_touch_id_sidecar_before_rewrite(prj: _, cmd: _) { + let keystore_dir = prj.root().join("keystore"); + let sidecar = keystore_dir.join("testAccount.touchid"); + let sidecar_content = valid_touch_id_sidecar_fixture(2, "user-presence"); + + cmd.args([ + "wallet", + "import", + "testAccount", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--keystore-dir", + keystore_dir.to_str().unwrap(), + "--unsafe-password", + "old_password", + ]) + .assert_success(); + fs::write(&sidecar, &sidecar_content).unwrap(); + + cmd.cast_fuse() + .args([ + "wallet", + "change-password", + "testAccount", + "--keystore-dir", + keystore_dir.to_str().unwrap(), + "--unsafe-password", + "old_password", + "--unsafe-new-password", + "new_password", + ]) + .assert_failure() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Error: unsupported Touch ID sidecar version 2; re-enroll this keystore to regenerate it, or delete its `.touchid` sidecar to use the password prompt + +"#]]); + assert!(sidecar.exists()); + assert_eq!(fs::read_to_string(&sidecar).unwrap(), sidecar_content); + + cmd.cast_fuse() + .args([ + "wallet", + "decrypt-keystore", + "testAccount", + "--keystore-dir", + keystore_dir.to_str().unwrap(), + "--unsafe-password", + "old_password", + ]) + .assert_success(); + + cmd.cast_fuse() + .args([ + "wallet", + "decrypt-keystore", + "testAccount", + "--keystore-dir", + keystore_dir.to_str().unwrap(), + "--unsafe-password", + "new_password", + ]) + .assert_failure(); +} + +#[casttest] +fn wallet_change_password_refuses_unknown_touch_id_sidecar(prj: _, cmd: _) { + let keystore_dir = prj.root().join("keystore"); + let keystore_path = keystore_dir.join("testAccount"); + let sidecar_path = keystore_dir.join("testAccount.touchid"); + + cmd.args([ + "wallet", + "import", + "testAccount", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--keystore-dir", + keystore_dir.to_str().unwrap(), + "--unsafe-password", + "old_password", + ]) + .assert_success(); + + let unknown_sidecar_content = r#"{"application":"unrelated"}"#; + fs::write(&sidecar_path, unknown_sidecar_content).unwrap(); + + let original_keystore_bytes = fs::read(&keystore_path).unwrap(); + let original_sidecar_bytes = fs::read(&sidecar_path).unwrap(); + + cmd.cast_fuse() + .args([ + "wallet", + "change-password", + "testAccount", + "--keystore-dir", + keystore_dir.to_str().unwrap(), + "--unsafe-password", + "old_password", + "--unsafe-new-password", + "new_password", + ]) + .assert_failure(); + + assert_eq!(fs::read(&keystore_path).unwrap(), original_keystore_bytes); + assert_eq!(fs::read(&sidecar_path).unwrap(), original_sidecar_bytes); + + cmd.cast_fuse() + .args([ + "wallet", + "decrypt-keystore", + "testAccount", + "--keystore-dir", + keystore_dir.to_str().unwrap(), + "--unsafe-password", + "old_password", + ]) + .assert_success(); + + cmd.cast_fuse() + .args([ + "wallet", + "decrypt-keystore", + "testAccount", + "--keystore-dir", + keystore_dir.to_str().unwrap(), + "--unsafe-password", + "new_password", + ]) + .assert_failure(); +} + +#[cfg(not(all(target_os = "macos", feature = "touch-id")))] +#[casttest] +fn wallet_change_password_removes_touch_id_sidecar(prj: _, cmd: _) { + let keystore_dir = prj.root().join("keystore"); + let sidecar = keystore_dir.join("testAccount.touchid"); + + cmd.args([ + "wallet", + "import", + "testAccount", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--keystore-dir", + keystore_dir.to_str().unwrap(), + "--unsafe-password", + "old_password", + ]) + .assert_success(); + fs::write(&sidecar, valid_touch_id_sidecar_fixture(1, "user-presence")).unwrap(); + + cmd.cast_fuse() + .args([ + "wallet", + "change-password", + "testAccount", + "--keystore-dir", + keystore_dir.to_str().unwrap(), + "--unsafe-password", + "old_password", + "--unsafe-new-password", + "new_password", + ]) + .assert_success() + .stderr_eq(str![[r#" +Warning: Removed the stale Touch ID enrollment after changing the password + +"#]]); + assert!(!sidecar.exists()); +} diff --git a/crates/cast/tests/fixtures/PermitToken.sol b/crates/cast/tests/fixtures/PermitToken.sol new file mode 100644 index 0000000000000..2686e14d034f2 --- /dev/null +++ b/crates/cast/tests/fixtures/PermitToken.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +// ERC-2612 fixture with optional EIP-5267 discovery and a configurable domain version. +contract TestToken { + string public constant name = "Permit Token"; + string public version = "1"; + bool public discovery = true; + mapping(address => uint256) public nonces; + mapping(address => mapping(address => uint256)) public allowance; + event Approval(address indexed owner, address indexed spender, uint256 value); + + function setDomain(string calldata newVersion, bool discover) external { + version = newVersion; + discovery = discover; + } + + function DOMAIN_SEPARATOR() public view returns (bytes32) { + return keccak256(abi.encode( + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256(bytes(name)), keccak256(bytes(version)), block.chainid, address(this) + )); + } + + function eip712Domain() external view returns ( + bytes1, string memory, string memory, uint256, address, bytes32, uint256[] memory + ) { + require(discovery, "discovery disabled"); + return (hex"0f", name, version, block.chainid, address(this), bytes32(0), new uint256[](0)); + } + + function permit(address owner, address spender, uint256 value, uint256 deadline, + uint8 v, bytes32 r, bytes32 s) external { + require(block.timestamp <= deadline, "expired"); + bytes32 digest = keccak256(abi.encodePacked(hex"1901", DOMAIN_SEPARATOR(), keccak256(abi.encode( + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"), + owner, spender, value, nonces[owner]++, deadline + )))); + require(owner != address(0) && ecrecover(digest, v, r, s) == owner, "invalid signature"); + allowance[owner][spender] = value; + emit Approval(owner, spender, value); + } +} diff --git a/crates/cast/tests/fixtures/TestToken.sol b/crates/cast/tests/fixtures/TestToken.sol new file mode 100644 index 0000000000000..53f0d62187e38 --- /dev/null +++ b/crates/cast/tests/fixtures/TestToken.sol @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract TestToken { + string public name = "Test Token"; + string public symbol = "TEST"; + uint8 public decimals = 18; + uint256 public totalSupply; + + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + constructor() { + totalSupply = 1000 * 10**decimals; + balanceOf[msg.sender] = totalSupply; + emit Transfer(address(0), msg.sender, totalSupply); + } + + function transfer(address to, uint256 amount) external returns (bool) { + require(balanceOf[msg.sender] >= amount, "Insufficient balance"); + balanceOf[msg.sender] -= amount; + balanceOf[to] += amount; + emit Transfer(msg.sender, to, amount); + return true; + } + + function approve(address spender, uint256 amount) external returns (bool) { + allowance[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } + + function transferFrom(address from, address to, uint256 amount) external returns (bool) { + require(balanceOf[from] >= amount, "Insufficient balance"); + require(allowance[from][msg.sender] >= amount, "Insufficient allowance"); + + balanceOf[from] -= amount; + balanceOf[to] += amount; + allowance[from][msg.sender] -= amount; + + emit Transfer(from, to, amount); + return true; + } + + function mint(address to, uint256 amount) external { + totalSupply += amount; + balanceOf[to] += amount; + emit Transfer(address(0), to, amount); + } + + function burn(uint256 amount) external { + uint256 balance = balanceOf[msg.sender]; + amount = (amount > balance) ? balance : amount; + + totalSupply -= amount; + balanceOf[msg.sender] -= amount; + emit Transfer(msg.sender, address(0), amount); + } +} diff --git a/crates/cast/tests/fixtures/TestVault.sol b/crates/cast/tests/fixtures/TestVault.sol new file mode 100644 index 0000000000000..eab24e7d2a659 --- /dev/null +++ b/crates/cast/tests/fixtures/TestVault.sol @@ -0,0 +1,261 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +contract TestVaultAsset { + string public name = "Test Vault Asset"; + string public symbol = "TVA"; + uint8 public decimals = 18; + uint256 public totalSupply; + + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + event Transfer(address indexed from, address indexed to, uint256 value); + event Approval(address indexed owner, address indexed spender, uint256 value); + + function transfer(address to, uint256 amount) external returns (bool) { + _transfer(msg.sender, to, amount); + return true; + } + + function approve(address spender, uint256 amount) external returns (bool) { + allowance[msg.sender][spender] = amount; + emit Approval(msg.sender, spender, amount); + return true; + } + + function transferFrom(address from, address to, uint256 amount) external returns (bool) { + uint256 allowed = allowance[from][msg.sender]; + require(allowed >= amount, "Insufficient allowance"); + allowance[from][msg.sender] = allowed - amount; + _transfer(from, to, amount); + return true; + } + + function mint(address to, uint256 amount) external { + totalSupply += amount; + balanceOf[to] += amount; + emit Transfer(address(0), to, amount); + } + + function _transfer(address from, address to, uint256 amount) internal { + require(balanceOf[from] >= amount, "Insufficient balance"); + balanceOf[from] -= amount; + balanceOf[to] += amount; + emit Transfer(from, to, amount); + } +} + +contract TestVault { + TestVaultAsset public immutable underlying; + string public name = "Test Vault"; + string public symbol = "TV"; + uint8 public decimals = 18; + uint256 public totalSupply; + + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + event Deposit(address indexed sender, address indexed owner, uint256 assets, uint256 shares); + event Withdraw( + address indexed sender, + address indexed receiver, + address indexed owner, + uint256 assets, + uint256 shares + ); + + constructor() { + underlying = new TestVaultAsset(); + underlying.mint(msg.sender, 1_000 ether); + } + + function asset() external view returns (address) { + return address(underlying); + } + + function totalAssets() external view returns (uint256) { + return underlying.balanceOf(address(this)); + } + + function convertToShares(uint256 assets) external pure returns (uint256) { + return assets; + } + + function convertToAssets(uint256 shares) external pure returns (uint256) { + return shares; + } + + // Conservative zero maxima exercise the CLI's compatibility warnings while writes remain live. + function maxDeposit(address) external pure returns (uint256) { + return 0; + } + + function previewDeposit(uint256 assets) external pure returns (uint256) { + return assets; + } + + function deposit(uint256 assets, address receiver) external returns (uint256 shares) { + shares = assets; + require(underlying.transferFrom(msg.sender, address(this), assets)); + _mint(receiver, shares); + emit Deposit(msg.sender, receiver, assets, shares); + } + + function maxMint(address) external pure returns (uint256) { + return 0; + } + + function previewMint(uint256 shares) external pure returns (uint256) { + return shares; + } + + function mint(uint256 shares, address receiver) external returns (uint256 assets) { + assets = shares; + require(underlying.transferFrom(msg.sender, address(this), assets)); + _mint(receiver, shares); + emit Deposit(msg.sender, receiver, assets, shares); + } + + function maxWithdraw(address) external pure returns (uint256) { + return 0; + } + + function previewWithdraw(uint256 assets) external pure returns (uint256) { + return assets; + } + + function withdraw(uint256 assets, address receiver, address owner) + external + returns (uint256 shares) + { + shares = assets; + _spendAllowance(owner, shares); + _burn(owner, shares); + require(underlying.transfer(receiver, assets)); + emit Withdraw(msg.sender, receiver, owner, assets, shares); + } + + function maxRedeem(address) external pure returns (uint256) { + return 0; + } + + function previewRedeem(uint256 shares) external pure returns (uint256) { + return shares; + } + + function redeem(uint256 shares, address receiver, address owner) + external + returns (uint256 assets) + { + assets = shares; + _spendAllowance(owner, shares); + _burn(owner, shares); + require(underlying.transfer(receiver, assets)); + emit Withdraw(msg.sender, receiver, owner, assets, shares); + } + + function _mint(address receiver, uint256 shares) internal { + totalSupply += shares; + balanceOf[receiver] += shares; + } + + function _burn(address owner, uint256 shares) internal { + require(balanceOf[owner] >= shares, "Insufficient shares"); + balanceOf[owner] -= shares; + totalSupply -= shares; + } + + function _spendAllowance(address owner, uint256 shares) internal { + if (msg.sender != owner) { + uint256 allowed = allowance[owner][msg.sender]; + require(allowed >= shares, "Insufficient allowance"); + allowance[owner][msg.sender] = allowed - shares; + } + } +} + +contract TestAsyncVault { + uint256 public totalSupply; + + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + function name() external pure virtual returns (string memory) { + return "Test Async Vault"; + } + + function symbol() external pure virtual returns (string memory) { + return "TAV"; + } + + function decimals() external pure virtual returns (uint8) { + return 18; + } + + function supportsInterface(bytes4 interfaceId) external pure returns (bool) { + return interfaceId == 0xce3bbe50 || interfaceId == 0x620ee8e4; + } + + function asset() external pure returns (address) { + return 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; + } + + function totalAssets() external pure returns (uint256) { + return 1 ether; + } + + function convertToShares(uint256 assets) external pure returns (uint256) { + return assets; + } + + function convertToAssets(uint256 shares) external pure returns (uint256) { + return shares; + } + + function maxDeposit(address) external pure returns (uint256) { + return 0; + } + + function previewDeposit(uint256) external pure returns (uint256) { + revert("ERC-7540 async deposit"); + } + + function maxMint(address) external pure returns (uint256) { + return 0; + } + + function previewMint(uint256) external pure returns (uint256) { + revert("ERC-7540 async deposit"); + } + + function maxWithdraw(address) external pure returns (uint256) { + return 0; + } + + function previewWithdraw(uint256) external pure returns (uint256) { + revert("ERC-7540 async redeem"); + } + + function maxRedeem(address) external pure returns (uint256) { + return 0; + } + + function previewRedeem(uint256) external pure returns (uint256) { + revert("ERC-7540 async redeem"); + } +} + +contract TestMissingMetadataVault is TestAsyncVault { + function name() external pure override returns (string memory) { + revert(); + } + + function symbol() external pure override returns (string memory) { + revert(); + } + + function decimals() external pure override returns (uint8) { + revert(); + } +} diff --git a/crates/cast/tests/fixtures/cast_logs_decoded.stdout b/crates/cast/tests/fixtures/cast_logs_decoded.stdout new file mode 100644 index 0000000000000..b7353288ad734 --- /dev/null +++ b/crates/cast/tests/fixtures/cast_logs_decoded.stdout @@ -0,0 +1,17 @@ +- address: 0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE + blockHash: 0x439b61565dacbc09a6d54378dff60d9b0400496d7a5a060cfdfdd899262f466c + blockNumber: 12421182 + data: 0x000000000000000000000000000000000000027fd7b375dda5ef932dac18d302 + logIndex: 15 + removed: false + topics: [ + 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef + 0x000000000000000000000000ab5801a7d398351b8be11c439e05c5b3259aec9b + 0x00000000000000000000000068a99f89e475a078645f4bac491360afe255dff1 + ] + transactionHash: 0xb65bcbb85c1633b0ab4e4886c3cd8eeaeb63edbb39cacdb9223fdcf4454fd2c7 + transactionIndex: 8 + decoded: + from: 0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B + to: 0x68A99f89E475a078645f4BAC491360aFe255Dff1 + value: 50693552078053777804071047582466 [5.069e31] diff --git a/crates/cast/tests/fixtures/interface_inherited_struct.json b/crates/cast/tests/fixtures/interface_inherited_struct.json new file mode 100644 index 0000000000000..b82c0829b5ac7 --- /dev/null +++ b/crates/cast/tests/fixtures/interface_inherited_struct.json @@ -0,0 +1 @@ +[{"type":"function","name":"test","inputs":[{"name":"param","type":"tuple","internalType":"struct IBase.TestStruct","components":[{"name":"asset","type":"address","internalType":"address"}]}],"outputs":[],"stateMutability":"nonpayable"}] diff --git a/crates/cast/tests/fixtures/safe/v1.4.1/README.md b/crates/cast/tests/fixtures/safe/v1.4.1/README.md new file mode 100644 index 0000000000000..8d34a1791a0fa --- /dev/null +++ b/crates/cast/tests/fixtures/safe/v1.4.1/README.md @@ -0,0 +1,35 @@ +# Safe v1.4.1 runtime fixtures + +These raw binary files contain the official Safe v1.4.1 deployed runtime +bytecode used by the Cast Safe CLI end-to-end tests. They are vendored so the +tests remain deterministic and do not require a public RPC endpoint or a live +Safe Transaction Service. + +## Provenance and license + +The fixtures correspond to the official +[Safe Contracts v1.4.1 release](https://github.com/safe-fndn/safe-smart-account/releases/tag/v1.4.1) +at commit +[`bf943f80fec5ac647159d26161446ac5d716a294`](https://github.com/safe-fndn/safe-smart-account/tree/bf943f80fec5ac647159d26161446ac5d716a294). +Their canonical deployment addresses and code hashes are recorded in the +[`safe-deployments` v1.4.1 assets](https://github.com/safe-global/safe-deployments/tree/a1e93fb2978877ba6ecd2ca3b82785edc75f5493/src/assets/v1.4.1). + +The upstream Safe contracts are licensed under the +[GNU Lesser General Public License v3.0](https://github.com/safe-fndn/safe-smart-account/blob/bf943f80fec5ac647159d26161446ac5d716a294/LICENSE). + +| Fixture | Deployment asset | Canonical address | Runtime bytes | Keccak-256 code hash | +| --- | --- | --- | ---: | --- | +| `SafeL2.runtime.bin` | [`safe_l2.json`](https://github.com/safe-global/safe-deployments/blob/a1e93fb2978877ba6ecd2ca3b82785edc75f5493/src/assets/v1.4.1/safe_l2.json) | `0x29fcB43b46531BcA003ddC8FCB67FFE91900C762` | 24,421 | `0xb1f926978a0f44a2c0ec8fe822418ae969bd8c3f18d61e5103100339894f81ff` | +| `SafeProxyFactory.runtime.bin` | [`safe_proxy_factory.json`](https://github.com/safe-global/safe-deployments/blob/a1e93fb2978877ba6ecd2ca3b82785edc75f5493/src/assets/v1.4.1/safe_proxy_factory.json) | `0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67` | 3,054 | `0x50c3cdc4074750a7a974204a716c999edd37482f907608d960b2b025ee0b3317` | +| `SimulateTxAccessor.runtime.bin` | [`simulate_tx_accessor.json`](https://github.com/safe-global/safe-deployments/blob/a1e93fb2978877ba6ecd2ca3b82785edc75f5493/src/assets/v1.4.1/simulate_tx_accessor.json) | `0x3d4BA2E0884aa488718476ca2FB8Efc291A46199` | 850 | `0x91f82615581fc73b190b83d72e883608b25e392f72322035df1b13d51766cf8d` | + +`SimulateTxAccessor` contains an immutable self-address. Its fixture uses the +canonical address in that immutable word, matching the deployed code hash. + +## Format and verification + +The files contain deployed runtime bytes without a `0x` prefix or line breaks; +they are not contract creation bytecode. The Safe CLI tests load them with +`include_bytes!` and verify both their byte lengths and Keccak-256 hashes before +installing them at their canonical addresses with `anvil_setCode`. See +[`safe.rs`](../../../cli/safe.rs). diff --git a/crates/cast/tests/fixtures/safe/v1.4.1/SafeL2.runtime.bin b/crates/cast/tests/fixtures/safe/v1.4.1/SafeL2.runtime.bin new file mode 100644 index 0000000000000..ec207bf852908 Binary files /dev/null and b/crates/cast/tests/fixtures/safe/v1.4.1/SafeL2.runtime.bin differ diff --git a/crates/cast/tests/fixtures/safe/v1.4.1/SafeProxyFactory.runtime.bin b/crates/cast/tests/fixtures/safe/v1.4.1/SafeProxyFactory.runtime.bin new file mode 100644 index 0000000000000..ff59e55698a83 Binary files /dev/null and b/crates/cast/tests/fixtures/safe/v1.4.1/SafeProxyFactory.runtime.bin differ diff --git a/crates/cast/tests/fixtures/safe/v1.4.1/SimulateTxAccessor.runtime.bin b/crates/cast/tests/fixtures/safe/v1.4.1/SimulateTxAccessor.runtime.bin new file mode 100644 index 0000000000000..c3b359c9a7a2e Binary files /dev/null and b/crates/cast/tests/fixtures/safe/v1.4.1/SimulateTxAccessor.runtime.bin differ diff --git a/crates/cast/tests/fixtures/tempo_earn_callback.hex b/crates/cast/tests/fixtures/tempo_earn_callback.hex new file mode 100644 index 0000000000000..07a7d1df5e566 --- /dev/null +++ b/crates/cast/tests/fixtures/tempo_earn_callback.hex @@ -0,0 +1 @@ +00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000009060606060606060606060606060606060606060606060606060606060606060600000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000006000000000000000000000000005050505050505050505050505050505050505050101010101010101010101010101010101010101010101010101010101010101000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000a003030303030303030303030300000000000000000000000000000000000000000404040404040404040404040404040400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004002020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202020202 diff --git a/crates/cheatcodes/Cargo.toml b/crates/cheatcodes/Cargo.toml index f62bde4323e71..8ee48ff4b9d1d 100644 --- a/crates/cheatcodes/Cargo.toml +++ b/crates/cheatcodes/Cargo.toml @@ -20,10 +20,13 @@ foundry-common.workspace = true foundry-compilers.workspace = true foundry-config.workspace = true foundry-evm-core.workspace = true +foundry-evm-fuzz.workspace = true foundry-evm-traces.workspace = true foundry-wallets.workspace = true forge-script-sequence.workspace = true +solar.workspace = true + alloy-dyn-abi.workspace = true alloy-evm.workspace = true alloy-json-abi.workspace = true @@ -45,22 +48,57 @@ alloy-chains.workspace = true alloy-ens.workspace = true base64.workspace = true -dialoguer = "0.11" +dialoguer.workspace = true eyre.workspace = true itertools.workspace = true jsonpath_lib.workspace = true -k256.workspace = true -memchr = "2.7" +k256 = { workspace = true, features = ["expose-field"] } +memchr.workspace = true p256 = "0.13" +ed25519-consensus = "2.1" ecdsa = "0.16" rand.workspace = true +monad-revm = { workspace = true, optional = true } revm.workspace = true -revm-inspectors.workspace = true semver.workspace = true serde_json.workspace = true thiserror.workspace = true toml = { workspace = true, features = ["preserve_order"] } +toml_edit.workspace = true tracing.workspace = true walkdir.workspace = true proptest.workspace = true serde.workspace = true + +tempo-hardfork.workspace = true +tempo-contracts.workspace = true +tempo-precompiles.workspace = true +tempo-primitives.workspace = true + +[dev-dependencies] +tempo-alloy.workspace = true +tempfile.workspace = true +tempo-precompiles = { workspace = true, features = ["test-utils"] } + +[features] +default = ["optimism"] +monad = [ + "dep:monad-revm", + "foundry-config/monad", + "foundry-evm-core/monad", + "foundry-evm-fuzz/monad", + "foundry-evm-traces/monad", +] +base = [ + "foundry-common/base", + "foundry-evm-core/base", + "foundry-evm-fuzz/base", + "foundry-evm-traces/base", +] +optimism = [ + "foundry-common/optimism", + "foundry-evm-core/optimism", + "foundry-evm-fuzz/optimism", + "foundry-evm-traces/optimism", + "forge-script-sequence/optimism", +] diff --git a/crates/cheatcodes/README.md b/crates/cheatcodes/README.md index 5be3bc9614d98..3031ce538783a 100644 --- a/crates/cheatcodes/README.md +++ b/crates/cheatcodes/README.md @@ -27,7 +27,7 @@ the Foundry cheatcodes externally. For example, here are some tools that make use of the JSON interface: -- Internally, this is used to generate [a simple Solidity interface](../../testdata/cheats/Vm.sol) for testing +- Internally, this is used to generate [a simple Solidity interface](../../testdata/utils/Vm.sol) for testing - Used by [`forge-std`](https://github.com/foundry-rs/forge-std) to generate [user-friendly Solidity interfaces](https://github.com/foundry-rs/forge-std/blob/master/src/Vm.sol) - (WIP) Used by [the Foundry book](https://github.com/foundry-rs/book) to generate [the cheatcodes reference](https://book.getfoundry.sh/cheatcodes) - ... diff --git a/crates/cheatcodes/assets/cheatcodes.json b/crates/cheatcodes/assets/cheatcodes.json index 93cdf40cd4393..5598e06a58591 100644 --- a/crates/cheatcodes/assets/cheatcodes.json +++ b/crates/cheatcodes/assets/cheatcodes.json @@ -234,6 +234,68 @@ } ] }, + { + "name": "EthStorageProof", + "description": "A Merkle proof for a single storage slot. Part of `EthGetProof`.", + "fields": [ + { + "name": "key", + "ty": "bytes32", + "description": "The storage slot." + }, + { + "name": "value", + "ty": "uint256", + "description": "The value stored at the slot." + }, + { + "name": "proof", + "ty": "bytes[]", + "description": "The RLP-encoded trie nodes from the storage root to the slot, root first." + } + ] + }, + { + "name": "EthGetProof", + "description": "An EIP-1186 account and storage proof. Returned by `eth_getProof`.", + "fields": [ + { + "name": "account", + "ty": "address", + "description": "The address of the account." + }, + { + "name": "balance", + "ty": "uint256", + "description": "The balance of the account." + }, + { + "name": "codeHash", + "ty": "bytes32", + "description": "The hash of the account's code." + }, + { + "name": "nonce", + "ty": "uint64", + "description": "The nonce of the account." + }, + { + "name": "storageHash", + "ty": "bytes32", + "description": "The root of the account's storage trie." + }, + { + "name": "accountProof", + "ty": "bytes[]", + "description": "The RLP-encoded trie nodes from the state root to the account, root first." + }, + { + "name": "storageProof", + "ty": "EthStorageProof[]", + "description": "The proofs for the requested storage slots, in the order they were requested." + } + ] + }, { "name": "DirEntry", "description": "A single entry in a directory listing. Returned by `readDir`.", @@ -463,6 +525,16 @@ "name": "depth", "ty": "uint64", "description": "Call depth traversed during the recording of state differences" + }, + { + "name": "oldNonce", + "ty": "uint64", + "description": "The previous nonce of the accessed account." + }, + { + "name": "newNonce", + "ty": "uint64", + "description": "The new nonce of the accessed account." } ] }, @@ -504,32 +576,37 @@ }, { "name": "Gas", - "description": "Gas used. Returned by `lastCallGas`.", + "description": "Gas measured for the last completed call or create frame, from the callee's perspective,\n including nested execution. Isolated transactions include intrinsic gas.\n Regular gas (the EIP's execution gas) and EIP-8037 state gas are reported separately.\n Without EIP-8037, state creation uses the ordinary gas schedule and `gasStateUsed` is zero.\n See and .", "fields": [ { "name": "gasLimit", "ty": "uint64", - "description": "The gas limit of the call." + "description": "Regular gas available to the frame at entry. Excludes the EIP-8037 state gas reservoir." }, { "name": "gasTotalUsed", "ty": "uint64", - "description": "The total gas used." + "description": "Regular gas spent by the frame, before refunds. Excludes EIP-8037 state gas; see `gasStateUsed`.\n With isolation, includes intrinsic gas and the regular-gas calldata floor." }, { "name": "gasMemoryUsed", "ty": "uint64", - "description": "DEPRECATED: The amount of gas used for memory expansion. Ref: " + "description": "DEPRECATED: always zero. Memory expansion costs are included in `gasTotalUsed`.\n Ref: ." }, { "name": "gasRefunded", "ty": "int64", - "description": "The amount of gas refunded." + "description": "Ordinary refund counter before transaction settlement; finalized for an isolated transaction.\n Can be negative in nested frames. State gas refills are already netted into `gasStateUsed`." }, { "name": "gasRemaining", "ty": "uint64", - "description": "The amount of gas remaining." + "description": "Regular gas left at frame end. Excludes the EIP-8037 state gas reservoir.\n State charges can draw from this allowance, so `gasLimit - gasRemaining` can include state gas." + }, + { + "name": "gasStateUsed", + "ty": "int64", + "description": "Net EIP-8037 state gas: state creation charges minus refills, including nested execution.\n Zero without EIP-8037 or if the frame reverted or halted. Can be negative when the frame\n undoes state created earlier in the same transaction; use signed arithmetic with `gasTotalUsed`.\n Their sum measures net consumption, not the gas limit needed to execute." } ] }, @@ -643,7 +720,7 @@ { "name": "partialMatch", "ty": "bool", - "description": "When true, only matches on the beginning of the revert data, otherwise, matches on entire revert data" + "description": "When true, only matches on the first 4 bytes (usually the selector) of the revert data, otherwise, matches on entire revert data" }, { "name": "revertData", @@ -713,7 +790,7 @@ { "func": { "id": "_expectCheatcodeRevert_2", - "description": "Expects an error on next cheatcode call that exactly matches the revert data.", + "description": "Expects an error on next cheatcode call that contains the revert data.", "declaration": "function _expectCheatcodeRevert(bytes calldata revertData) external;", "visibility": "external", "mutability": "", @@ -754,9 +831,9 @@ "func": { "id": "accesses", "description": "Gets all accessed reads and write slot from a `vm.record` session, for a given address.", - "declaration": "function accesses(address target) external returns (bytes32[] memory readSlots, bytes32[] memory writeSlots);", + "declaration": "function accesses(address target) external view returns (bytes32[] memory readSlots, bytes32[] memory writeSlots);", "visibility": "external", - "mutability": "", + "mutability": "view", "signature": "accesses(address)", "selector": "0x65bc9481", "selectorBytes": [ @@ -854,7 +931,7 @@ "func": { "id": "assertApproxEqAbsDecimal_1", "description": "Compares two `uint256` values. Expects difference to be less than or equal to `maxDelta`.\nFormats values with decimals in failure message. Includes error message into revert string on failure.", - "declaration": "function assertApproxEqAbsDecimal(uint256 left, uint256 right, uint256 maxDelta, uint256 decimals, string calldata error) external pure;", + "declaration": "function assertApproxEqAbsDecimal(uint256 left, uint256 right, uint256 maxDelta, uint256 decimals, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertApproxEqAbsDecimal(uint256,uint256,uint256,uint256,string)", @@ -894,7 +971,7 @@ "func": { "id": "assertApproxEqAbsDecimal_3", "description": "Compares two `int256` values. Expects difference to be less than or equal to `maxDelta`.\nFormats values with decimals in failure message. Includes error message into revert string on failure.", - "declaration": "function assertApproxEqAbsDecimal(int256 left, int256 right, uint256 maxDelta, uint256 decimals, string calldata error) external pure;", + "declaration": "function assertApproxEqAbsDecimal(int256 left, int256 right, uint256 maxDelta, uint256 decimals, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertApproxEqAbsDecimal(int256,int256,uint256,uint256,string)", @@ -934,7 +1011,7 @@ "func": { "id": "assertApproxEqAbs_1", "description": "Compares two `uint256` values. Expects difference to be less than or equal to `maxDelta`.\nIncludes error message into revert string on failure.", - "declaration": "function assertApproxEqAbs(uint256 left, uint256 right, uint256 maxDelta, string calldata error) external pure;", + "declaration": "function assertApproxEqAbs(uint256 left, uint256 right, uint256 maxDelta, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertApproxEqAbs(uint256,uint256,uint256,string)", @@ -974,7 +1051,7 @@ "func": { "id": "assertApproxEqAbs_3", "description": "Compares two `int256` values. Expects difference to be less than or equal to `maxDelta`.\nIncludes error message into revert string on failure.", - "declaration": "function assertApproxEqAbs(int256 left, int256 right, uint256 maxDelta, string calldata error) external pure;", + "declaration": "function assertApproxEqAbs(int256 left, int256 right, uint256 maxDelta, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertApproxEqAbs(int256,int256,uint256,string)", @@ -1014,7 +1091,7 @@ "func": { "id": "assertApproxEqRelDecimal_1", "description": "Compares two `uint256` values. Expects relative difference in percents to be less than or equal to `maxPercentDelta`.\n`maxPercentDelta` is an 18 decimal fixed point number, where 1e18 == 100%\nFormats values with decimals in failure message. Includes error message into revert string on failure.", - "declaration": "function assertApproxEqRelDecimal(uint256 left, uint256 right, uint256 maxPercentDelta, uint256 decimals, string calldata error) external pure;", + "declaration": "function assertApproxEqRelDecimal(uint256 left, uint256 right, uint256 maxPercentDelta, uint256 decimals, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertApproxEqRelDecimal(uint256,uint256,uint256,uint256,string)", @@ -1054,7 +1131,7 @@ "func": { "id": "assertApproxEqRelDecimal_3", "description": "Compares two `int256` values. Expects relative difference in percents to be less than or equal to `maxPercentDelta`.\n`maxPercentDelta` is an 18 decimal fixed point number, where 1e18 == 100%\nFormats values with decimals in failure message. Includes error message into revert string on failure.", - "declaration": "function assertApproxEqRelDecimal(int256 left, int256 right, uint256 maxPercentDelta, uint256 decimals, string calldata error) external pure;", + "declaration": "function assertApproxEqRelDecimal(int256 left, int256 right, uint256 maxPercentDelta, uint256 decimals, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertApproxEqRelDecimal(int256,int256,uint256,uint256,string)", @@ -1094,7 +1171,7 @@ "func": { "id": "assertApproxEqRel_1", "description": "Compares two `uint256` values. Expects relative difference in percents to be less than or equal to `maxPercentDelta`.\n`maxPercentDelta` is an 18 decimal fixed point number, where 1e18 == 100%\nIncludes error message into revert string on failure.", - "declaration": "function assertApproxEqRel(uint256 left, uint256 right, uint256 maxPercentDelta, string calldata error) external pure;", + "declaration": "function assertApproxEqRel(uint256 left, uint256 right, uint256 maxPercentDelta, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertApproxEqRel(uint256,uint256,uint256,string)", @@ -1134,7 +1211,7 @@ "func": { "id": "assertApproxEqRel_3", "description": "Compares two `int256` values. Expects relative difference in percents to be less than or equal to `maxPercentDelta`.\n`maxPercentDelta` is an 18 decimal fixed point number, where 1e18 == 100%\nIncludes error message into revert string on failure.", - "declaration": "function assertApproxEqRel(int256 left, int256 right, uint256 maxPercentDelta, string calldata error) external pure;", + "declaration": "function assertApproxEqRel(int256 left, int256 right, uint256 maxPercentDelta, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertApproxEqRel(int256,int256,uint256,string)", @@ -1174,7 +1251,7 @@ "func": { "id": "assertEqDecimal_1", "description": "Asserts that two `uint256` values are equal, formatting them with decimals in failure message.\nIncludes error message into revert string on failure.", - "declaration": "function assertEqDecimal(uint256 left, uint256 right, uint256 decimals, string calldata error) external pure;", + "declaration": "function assertEqDecimal(uint256 left, uint256 right, uint256 decimals, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertEqDecimal(uint256,uint256,uint256,string)", @@ -1214,7 +1291,7 @@ "func": { "id": "assertEqDecimal_3", "description": "Asserts that two `int256` values are equal, formatting them with decimals in failure message.\nIncludes error message into revert string on failure.", - "declaration": "function assertEqDecimal(int256 left, int256 right, uint256 decimals, string calldata error) external pure;", + "declaration": "function assertEqDecimal(int256 left, int256 right, uint256 decimals, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertEqDecimal(int256,int256,uint256,string)", @@ -1254,7 +1331,7 @@ "func": { "id": "assertEq_1", "description": "Asserts that two `bool` values are equal and includes error message into revert string on failure.", - "declaration": "function assertEq(bool left, bool right, string calldata error) external pure;", + "declaration": "function assertEq(bool left, bool right, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertEq(bool,bool,string)", @@ -1294,7 +1371,7 @@ "func": { "id": "assertEq_11", "description": "Asserts that two `string` values are equal and includes error message into revert string on failure.", - "declaration": "function assertEq(string calldata left, string calldata right, string calldata error) external pure;", + "declaration": "function assertEq(string calldata left, string calldata right, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertEq(string,string,string)", @@ -1334,7 +1411,7 @@ "func": { "id": "assertEq_13", "description": "Asserts that two `bytes` values are equal and includes error message into revert string on failure.", - "declaration": "function assertEq(bytes calldata left, bytes calldata right, string calldata error) external pure;", + "declaration": "function assertEq(bytes calldata left, bytes calldata right, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertEq(bytes,bytes,string)", @@ -1374,7 +1451,7 @@ "func": { "id": "assertEq_15", "description": "Asserts that two arrays of `bool` values are equal and includes error message into revert string on failure.", - "declaration": "function assertEq(bool[] calldata left, bool[] calldata right, string calldata error) external pure;", + "declaration": "function assertEq(bool[] calldata left, bool[] calldata right, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertEq(bool[],bool[],string)", @@ -1414,7 +1491,7 @@ "func": { "id": "assertEq_17", "description": "Asserts that two arrays of `uint256` values are equal and includes error message into revert string on failure.", - "declaration": "function assertEq(uint256[] calldata left, uint256[] calldata right, string calldata error) external pure;", + "declaration": "function assertEq(uint256[] calldata left, uint256[] calldata right, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertEq(uint256[],uint256[],string)", @@ -1454,7 +1531,7 @@ "func": { "id": "assertEq_19", "description": "Asserts that two arrays of `int256` values are equal and includes error message into revert string on failure.", - "declaration": "function assertEq(int256[] calldata left, int256[] calldata right, string calldata error) external pure;", + "declaration": "function assertEq(int256[] calldata left, int256[] calldata right, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertEq(int256[],int256[],string)", @@ -1514,7 +1591,7 @@ "func": { "id": "assertEq_21", "description": "Asserts that two arrays of `address` values are equal and includes error message into revert string on failure.", - "declaration": "function assertEq(address[] calldata left, address[] calldata right, string calldata error) external pure;", + "declaration": "function assertEq(address[] calldata left, address[] calldata right, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertEq(address[],address[],string)", @@ -1554,7 +1631,7 @@ "func": { "id": "assertEq_23", "description": "Asserts that two arrays of `bytes32` values are equal and includes error message into revert string on failure.", - "declaration": "function assertEq(bytes32[] calldata left, bytes32[] calldata right, string calldata error) external pure;", + "declaration": "function assertEq(bytes32[] calldata left, bytes32[] calldata right, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertEq(bytes32[],bytes32[],string)", @@ -1594,7 +1671,7 @@ "func": { "id": "assertEq_25", "description": "Asserts that two arrays of `string` values are equal and includes error message into revert string on failure.", - "declaration": "function assertEq(string[] calldata left, string[] calldata right, string calldata error) external pure;", + "declaration": "function assertEq(string[] calldata left, string[] calldata right, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertEq(string[],string[],string)", @@ -1634,7 +1711,7 @@ "func": { "id": "assertEq_27", "description": "Asserts that two arrays of `bytes` values are equal and includes error message into revert string on failure.", - "declaration": "function assertEq(bytes[] calldata left, bytes[] calldata right, string calldata error) external pure;", + "declaration": "function assertEq(bytes[] calldata left, bytes[] calldata right, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertEq(bytes[],bytes[],string)", @@ -1654,7 +1731,7 @@ "func": { "id": "assertEq_3", "description": "Asserts that two `uint256` values are equal and includes error message into revert string on failure.", - "declaration": "function assertEq(uint256 left, uint256 right, string calldata error) external pure;", + "declaration": "function assertEq(uint256 left, uint256 right, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertEq(uint256,uint256,string)", @@ -1694,7 +1771,7 @@ "func": { "id": "assertEq_5", "description": "Asserts that two `int256` values are equal and includes error message into revert string on failure.", - "declaration": "function assertEq(int256 left, int256 right, string calldata error) external pure;", + "declaration": "function assertEq(int256 left, int256 right, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertEq(int256,int256,string)", @@ -1734,7 +1811,7 @@ "func": { "id": "assertEq_7", "description": "Asserts that two `address` values are equal and includes error message into revert string on failure.", - "declaration": "function assertEq(address left, address right, string calldata error) external pure;", + "declaration": "function assertEq(address left, address right, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertEq(address,address,string)", @@ -1774,7 +1851,7 @@ "func": { "id": "assertEq_9", "description": "Asserts that two `bytes32` values are equal and includes error message into revert string on failure.", - "declaration": "function assertEq(bytes32 left, bytes32 right, string calldata error) external pure;", + "declaration": "function assertEq(bytes32 left, bytes32 right, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertEq(bytes32,bytes32,string)", @@ -1814,7 +1891,7 @@ "func": { "id": "assertFalse_1", "description": "Asserts that the given condition is false and includes error message into revert string on failure.", - "declaration": "function assertFalse(bool condition, string calldata error) external pure;", + "declaration": "function assertFalse(bool condition, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertFalse(bool,string)", @@ -1854,7 +1931,7 @@ "func": { "id": "assertGeDecimal_1", "description": "Compares two `uint256` values. Expects first value to be greater than or equal to second.\nFormats values with decimals in failure message. Includes error message into revert string on failure.", - "declaration": "function assertGeDecimal(uint256 left, uint256 right, uint256 decimals, string calldata error) external pure;", + "declaration": "function assertGeDecimal(uint256 left, uint256 right, uint256 decimals, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertGeDecimal(uint256,uint256,uint256,string)", @@ -1894,7 +1971,7 @@ "func": { "id": "assertGeDecimal_3", "description": "Compares two `int256` values. Expects first value to be greater than or equal to second.\nFormats values with decimals in failure message. Includes error message into revert string on failure.", - "declaration": "function assertGeDecimal(int256 left, int256 right, uint256 decimals, string calldata error) external pure;", + "declaration": "function assertGeDecimal(int256 left, int256 right, uint256 decimals, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertGeDecimal(int256,int256,uint256,string)", @@ -1934,7 +2011,7 @@ "func": { "id": "assertGe_1", "description": "Compares two `uint256` values. Expects first value to be greater than or equal to second.\nIncludes error message into revert string on failure.", - "declaration": "function assertGe(uint256 left, uint256 right, string calldata error) external pure;", + "declaration": "function assertGe(uint256 left, uint256 right, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertGe(uint256,uint256,string)", @@ -1974,7 +2051,7 @@ "func": { "id": "assertGe_3", "description": "Compares two `int256` values. Expects first value to be greater than or equal to second.\nIncludes error message into revert string on failure.", - "declaration": "function assertGe(int256 left, int256 right, string calldata error) external pure;", + "declaration": "function assertGe(int256 left, int256 right, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertGe(int256,int256,string)", @@ -2014,7 +2091,7 @@ "func": { "id": "assertGtDecimal_1", "description": "Compares two `uint256` values. Expects first value to be greater than second.\nFormats values with decimals in failure message. Includes error message into revert string on failure.", - "declaration": "function assertGtDecimal(uint256 left, uint256 right, uint256 decimals, string calldata error) external pure;", + "declaration": "function assertGtDecimal(uint256 left, uint256 right, uint256 decimals, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertGtDecimal(uint256,uint256,uint256,string)", @@ -2054,7 +2131,7 @@ "func": { "id": "assertGtDecimal_3", "description": "Compares two `int256` values. Expects first value to be greater than second.\nFormats values with decimals in failure message. Includes error message into revert string on failure.", - "declaration": "function assertGtDecimal(int256 left, int256 right, uint256 decimals, string calldata error) external pure;", + "declaration": "function assertGtDecimal(int256 left, int256 right, uint256 decimals, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertGtDecimal(int256,int256,uint256,string)", @@ -2094,7 +2171,7 @@ "func": { "id": "assertGt_1", "description": "Compares two `uint256` values. Expects first value to be greater than second.\nIncludes error message into revert string on failure.", - "declaration": "function assertGt(uint256 left, uint256 right, string calldata error) external pure;", + "declaration": "function assertGt(uint256 left, uint256 right, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertGt(uint256,uint256,string)", @@ -2134,7 +2211,7 @@ "func": { "id": "assertGt_3", "description": "Compares two `int256` values. Expects first value to be greater than second.\nIncludes error message into revert string on failure.", - "declaration": "function assertGt(int256 left, int256 right, string calldata error) external pure;", + "declaration": "function assertGt(int256 left, int256 right, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertGt(int256,int256,string)", @@ -2174,7 +2251,7 @@ "func": { "id": "assertLeDecimal_1", "description": "Compares two `uint256` values. Expects first value to be less than or equal to second.\nFormats values with decimals in failure message. Includes error message into revert string on failure.", - "declaration": "function assertLeDecimal(uint256 left, uint256 right, uint256 decimals, string calldata error) external pure;", + "declaration": "function assertLeDecimal(uint256 left, uint256 right, uint256 decimals, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertLeDecimal(uint256,uint256,uint256,string)", @@ -2214,7 +2291,7 @@ "func": { "id": "assertLeDecimal_3", "description": "Compares two `int256` values. Expects first value to be less than or equal to second.\nFormats values with decimals in failure message. Includes error message into revert string on failure.", - "declaration": "function assertLeDecimal(int256 left, int256 right, uint256 decimals, string calldata error) external pure;", + "declaration": "function assertLeDecimal(int256 left, int256 right, uint256 decimals, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertLeDecimal(int256,int256,uint256,string)", @@ -2254,7 +2331,7 @@ "func": { "id": "assertLe_1", "description": "Compares two `uint256` values. Expects first value to be less than or equal to second.\nIncludes error message into revert string on failure.", - "declaration": "function assertLe(uint256 left, uint256 right, string calldata error) external pure;", + "declaration": "function assertLe(uint256 left, uint256 right, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertLe(uint256,uint256,string)", @@ -2294,7 +2371,7 @@ "func": { "id": "assertLe_3", "description": "Compares two `int256` values. Expects first value to be less than or equal to second.\nIncludes error message into revert string on failure.", - "declaration": "function assertLe(int256 left, int256 right, string calldata error) external pure;", + "declaration": "function assertLe(int256 left, int256 right, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertLe(int256,int256,string)", @@ -2334,7 +2411,7 @@ "func": { "id": "assertLtDecimal_1", "description": "Compares two `uint256` values. Expects first value to be less than second.\nFormats values with decimals in failure message. Includes error message into revert string on failure.", - "declaration": "function assertLtDecimal(uint256 left, uint256 right, uint256 decimals, string calldata error) external pure;", + "declaration": "function assertLtDecimal(uint256 left, uint256 right, uint256 decimals, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertLtDecimal(uint256,uint256,uint256,string)", @@ -2374,7 +2451,7 @@ "func": { "id": "assertLtDecimal_3", "description": "Compares two `int256` values. Expects first value to be less than second.\nFormats values with decimals in failure message. Includes error message into revert string on failure.", - "declaration": "function assertLtDecimal(int256 left, int256 right, uint256 decimals, string calldata error) external pure;", + "declaration": "function assertLtDecimal(int256 left, int256 right, uint256 decimals, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertLtDecimal(int256,int256,uint256,string)", @@ -2414,7 +2491,7 @@ "func": { "id": "assertLt_1", "description": "Compares two `uint256` values. Expects first value to be less than second.\nIncludes error message into revert string on failure.", - "declaration": "function assertLt(uint256 left, uint256 right, string calldata error) external pure;", + "declaration": "function assertLt(uint256 left, uint256 right, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertLt(uint256,uint256,string)", @@ -2454,7 +2531,7 @@ "func": { "id": "assertLt_3", "description": "Compares two `int256` values. Expects first value to be less than second.\nIncludes error message into revert string on failure.", - "declaration": "function assertLt(int256 left, int256 right, string calldata error) external pure;", + "declaration": "function assertLt(int256 left, int256 right, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertLt(int256,int256,string)", @@ -2494,7 +2571,7 @@ "func": { "id": "assertNotEqDecimal_1", "description": "Asserts that two `uint256` values are not equal, formatting them with decimals in failure message.\nIncludes error message into revert string on failure.", - "declaration": "function assertNotEqDecimal(uint256 left, uint256 right, uint256 decimals, string calldata error) external pure;", + "declaration": "function assertNotEqDecimal(uint256 left, uint256 right, uint256 decimals, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertNotEqDecimal(uint256,uint256,uint256,string)", @@ -2534,7 +2611,7 @@ "func": { "id": "assertNotEqDecimal_3", "description": "Asserts that two `int256` values are not equal, formatting them with decimals in failure message.\nIncludes error message into revert string on failure.", - "declaration": "function assertNotEqDecimal(int256 left, int256 right, uint256 decimals, string calldata error) external pure;", + "declaration": "function assertNotEqDecimal(int256 left, int256 right, uint256 decimals, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertNotEqDecimal(int256,int256,uint256,string)", @@ -2574,7 +2651,7 @@ "func": { "id": "assertNotEq_1", "description": "Asserts that two `bool` values are not equal and includes error message into revert string on failure.", - "declaration": "function assertNotEq(bool left, bool right, string calldata error) external pure;", + "declaration": "function assertNotEq(bool left, bool right, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertNotEq(bool,bool,string)", @@ -2614,7 +2691,7 @@ "func": { "id": "assertNotEq_11", "description": "Asserts that two `string` values are not equal and includes error message into revert string on failure.", - "declaration": "function assertNotEq(string calldata left, string calldata right, string calldata error) external pure;", + "declaration": "function assertNotEq(string calldata left, string calldata right, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertNotEq(string,string,string)", @@ -2654,7 +2731,7 @@ "func": { "id": "assertNotEq_13", "description": "Asserts that two `bytes` values are not equal and includes error message into revert string on failure.", - "declaration": "function assertNotEq(bytes calldata left, bytes calldata right, string calldata error) external pure;", + "declaration": "function assertNotEq(bytes calldata left, bytes calldata right, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertNotEq(bytes,bytes,string)", @@ -2694,7 +2771,7 @@ "func": { "id": "assertNotEq_15", "description": "Asserts that two arrays of `bool` values are not equal and includes error message into revert string on failure.", - "declaration": "function assertNotEq(bool[] calldata left, bool[] calldata right, string calldata error) external pure;", + "declaration": "function assertNotEq(bool[] calldata left, bool[] calldata right, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertNotEq(bool[],bool[],string)", @@ -2734,7 +2811,7 @@ "func": { "id": "assertNotEq_17", "description": "Asserts that two arrays of `uint256` values are not equal and includes error message into revert string on failure.", - "declaration": "function assertNotEq(uint256[] calldata left, uint256[] calldata right, string calldata error) external pure;", + "declaration": "function assertNotEq(uint256[] calldata left, uint256[] calldata right, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertNotEq(uint256[],uint256[],string)", @@ -2774,7 +2851,7 @@ "func": { "id": "assertNotEq_19", "description": "Asserts that two arrays of `int256` values are not equal and includes error message into revert string on failure.", - "declaration": "function assertNotEq(int256[] calldata left, int256[] calldata right, string calldata error) external pure;", + "declaration": "function assertNotEq(int256[] calldata left, int256[] calldata right, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertNotEq(int256[],int256[],string)", @@ -2834,7 +2911,7 @@ "func": { "id": "assertNotEq_21", "description": "Asserts that two arrays of `address` values are not equal and includes error message into revert string on failure.", - "declaration": "function assertNotEq(address[] calldata left, address[] calldata right, string calldata error) external pure;", + "declaration": "function assertNotEq(address[] calldata left, address[] calldata right, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertNotEq(address[],address[],string)", @@ -2874,7 +2951,7 @@ "func": { "id": "assertNotEq_23", "description": "Asserts that two arrays of `bytes32` values are not equal and includes error message into revert string on failure.", - "declaration": "function assertNotEq(bytes32[] calldata left, bytes32[] calldata right, string calldata error) external pure;", + "declaration": "function assertNotEq(bytes32[] calldata left, bytes32[] calldata right, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertNotEq(bytes32[],bytes32[],string)", @@ -2914,7 +2991,7 @@ "func": { "id": "assertNotEq_25", "description": "Asserts that two arrays of `string` values are not equal and includes error message into revert string on failure.", - "declaration": "function assertNotEq(string[] calldata left, string[] calldata right, string calldata error) external pure;", + "declaration": "function assertNotEq(string[] calldata left, string[] calldata right, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertNotEq(string[],string[],string)", @@ -2954,7 +3031,7 @@ "func": { "id": "assertNotEq_27", "description": "Asserts that two arrays of `bytes` values are not equal and includes error message into revert string on failure.", - "declaration": "function assertNotEq(bytes[] calldata left, bytes[] calldata right, string calldata error) external pure;", + "declaration": "function assertNotEq(bytes[] calldata left, bytes[] calldata right, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertNotEq(bytes[],bytes[],string)", @@ -2974,7 +3051,7 @@ "func": { "id": "assertNotEq_3", "description": "Asserts that two `uint256` values are not equal and includes error message into revert string on failure.", - "declaration": "function assertNotEq(uint256 left, uint256 right, string calldata error) external pure;", + "declaration": "function assertNotEq(uint256 left, uint256 right, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertNotEq(uint256,uint256,string)", @@ -3014,7 +3091,7 @@ "func": { "id": "assertNotEq_5", "description": "Asserts that two `int256` values are not equal and includes error message into revert string on failure.", - "declaration": "function assertNotEq(int256 left, int256 right, string calldata error) external pure;", + "declaration": "function assertNotEq(int256 left, int256 right, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertNotEq(int256,int256,string)", @@ -3054,7 +3131,7 @@ "func": { "id": "assertNotEq_7", "description": "Asserts that two `address` values are not equal and includes error message into revert string on failure.", - "declaration": "function assertNotEq(address left, address right, string calldata error) external pure;", + "declaration": "function assertNotEq(address left, address right, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertNotEq(address,address,string)", @@ -3094,7 +3171,7 @@ "func": { "id": "assertNotEq_9", "description": "Asserts that two `bytes32` values are not equal and includes error message into revert string on failure.", - "declaration": "function assertNotEq(bytes32 left, bytes32 right, string calldata error) external pure;", + "declaration": "function assertNotEq(bytes32 left, bytes32 right, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertNotEq(bytes32,bytes32,string)", @@ -3134,7 +3211,7 @@ "func": { "id": "assertTrue_1", "description": "Asserts that the given condition is true and includes error message into revert string on failure.", - "declaration": "function assertTrue(bool condition, string calldata error) external pure;", + "declaration": "function assertTrue(bool condition, string calldata err) external pure;", "visibility": "external", "mutability": "pure", "signature": "assertTrue(bool,string)", @@ -3170,6 +3247,26 @@ "status": "stable", "safety": "safe" }, + { + "func": { + "id": "assumeImplicitApproval", + "description": "Skips a fuzz/invariant input unless `spender` is implicitly approved.", + "declaration": "function assumeImplicitApproval(address spender) external view;", + "visibility": "external", + "mutability": "view", + "signature": "assumeImplicitApproval(address)", + "selector": "0x7c8cd6d2", + "selectorBytes": [ + 124, + 140, + 214, + 210 + ] + }, + "group": "testing", + "status": "stable", + "safety": "safe" + }, { "func": { "id": "assumeNoRevert_0", @@ -3330,6 +3427,46 @@ "status": "stable", "safety": "unsafe" }, + { + "func": { + "id": "bound_0", + "description": "Returns an uint256 value bounded in given range and different from the current one.", + "declaration": "function bound(uint256 current, uint256 min, uint256 max) external view returns (uint256);", + "visibility": "external", + "mutability": "view", + "signature": "bound(uint256,uint256,uint256)", + "selector": "0x5a6c1eed", + "selectorBytes": [ + 90, + 108, + 30, + 237 + ] + }, + "group": "utilities", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "bound_1", + "description": "Returns an int256 value bounded in given range and different from the current one.", + "declaration": "function bound(int256 current, int256 min, int256 max) external view returns (int256);", + "visibility": "external", + "mutability": "view", + "signature": "bound(int256,int256,int256)", + "selector": "0x8f48fc07", + "selectorBytes": [ + 143, + 72, + 252, + 7 + ] + }, + "group": "utilities", + "status": "stable", + "safety": "safe" + }, { "func": { "id": "breakpoint_0", @@ -3614,9 +3751,9 @@ "func": { "id": "contains", "description": "Returns true if `search` is found in `subject`, false otherwise.", - "declaration": "function contains(string calldata subject, string calldata search) external returns (bool result);", + "declaration": "function contains(string calldata subject, string calldata search) external pure returns (bool result);", "visibility": "external", - "mutability": "", + "mutability": "pure", "signature": "contains(string,string)", "selector": "0x3fb18aec", "selectorBytes": [ @@ -3708,7 +3845,7 @@ }, "group": "utilities", "status": "stable", - "safety": "safe" + "safety": "unsafe" }, { "func": { @@ -3730,6 +3867,26 @@ "status": "stable", "safety": "safe" }, + { + "func": { + "id": "createEd25519Key", + "description": "Generates an Ed25519 key pair from a deterministic salt.\nReturns (publicKey, privateKey) as 32-byte values.", + "declaration": "function createEd25519Key(bytes32 salt) external pure returns (bytes32 publicKey, bytes32 privateKey);", + "visibility": "external", + "mutability": "pure", + "signature": "createEd25519Key(bytes32)", + "selector": "0x1ef3f27a", + "selectorBytes": [ + 30, + 243, + 242, + 122 + ] + }, + "group": "crypto", + "status": "stable", + "safety": "safe" + }, { "func": { "id": "createFork_0", @@ -3910,6 +4067,26 @@ "status": "stable", "safety": "safe" }, + { + "func": { + "id": "currentFilePath", + "description": "Get the source file path of the currently running test or script contract,\nrelative to the project root.", + "declaration": "function currentFilePath() external view returns (string memory path);", + "visibility": "external", + "mutability": "view", + "signature": "currentFilePath()", + "selector": "0x9b45555c", + "selectorBytes": [ + 155, + 69, + 85, + 92 + ] + }, + "group": "filesystem", + "status": "stable", + "safety": "safe" + }, { "func": { "id": "deal", @@ -4017,7 +4194,7 @@ { "func": { "id": "deployCode_0", - "description": "Deploys a contract from an artifact file. Takes in the relative path to the json file or the path to the\nartifact in the form of :: where and parts are optional.", + "description": "Deploys a contract from an artifact file. Takes in the relative path to the json file or the path to the\nartifact in the form of :: or :: where and\n/ parts are optional.\nReverts if the target artifact contains unlinked library placeholders.", "declaration": "function deployCode(string calldata artifactPath) external returns (address deployedAddress);", "visibility": "external", "mutability": "", @@ -4037,7 +4214,7 @@ { "func": { "id": "deployCode_1", - "description": "Deploys a contract from an artifact file. Takes in the relative path to the json file or the path to the\nartifact in the form of :: where and parts are optional.\nAdditionally accepts abi-encoded constructor arguments.", + "description": "Deploys a contract from an artifact file. Takes in the relative path to the json file or the path to the\nartifact in the form of :: or :: where and\n/ parts are optional.\nReverts if the target artifact contains unlinked library placeholders.\nAdditionally accepts abi-encoded constructor arguments.", "declaration": "function deployCode(string calldata artifactPath, bytes calldata constructorArgs) external returns (address deployedAddress);", "visibility": "external", "mutability": "", @@ -4057,7 +4234,7 @@ { "func": { "id": "deployCode_2", - "description": "Deploys a contract from an artifact file. Takes in the relative path to the json file or the path to the\nartifact in the form of :: where and parts are optional.\nAdditionally accepts `msg.value`.", + "description": "Deploys a contract from an artifact file. Takes in the relative path to the json file or the path to the\nartifact in the form of :: or :: where and\n/ parts are optional.\nReverts if the target artifact contains unlinked library placeholders.\nAdditionally accepts `msg.value`.", "declaration": "function deployCode(string calldata artifactPath, uint256 value) external returns (address deployedAddress);", "visibility": "external", "mutability": "", @@ -4077,7 +4254,7 @@ { "func": { "id": "deployCode_3", - "description": "Deploys a contract from an artifact file. Takes in the relative path to the json file or the path to the\nartifact in the form of :: where and parts are optional.\nAdditionally accepts abi-encoded constructor arguments and `msg.value`.", + "description": "Deploys a contract from an artifact file. Takes in the relative path to the json file or the path to the\nartifact in the form of :: or :: where and\n/ parts are optional.\nReverts if the target artifact contains unlinked library placeholders.\nAdditionally accepts abi-encoded constructor arguments and `msg.value`.", "declaration": "function deployCode(string calldata artifactPath, bytes calldata constructorArgs, uint256 value) external returns (address deployedAddress);", "visibility": "external", "mutability": "", @@ -4097,7 +4274,7 @@ { "func": { "id": "deployCode_4", - "description": "Deploys a contract from an artifact file, using the CREATE2 salt. Takes in the relative path to the json file or the path to the\nartifact in the form of :: where and parts are optional.", + "description": "Deploys a contract from an artifact file, using the CREATE2 salt. Takes in the relative path to the json file or the path to the\nartifact in the form of :: or :: where and\n/ parts are optional.\nReverts if the target artifact contains unlinked library placeholders.", "declaration": "function deployCode(string calldata artifactPath, bytes32 salt) external returns (address deployedAddress);", "visibility": "external", "mutability": "", @@ -4117,7 +4294,7 @@ { "func": { "id": "deployCode_5", - "description": "Deploys a contract from an artifact file, using the CREATE2 salt. Takes in the relative path to the json file or the path to the\nartifact in the form of :: where and parts are optional.\nAdditionally accepts abi-encoded constructor arguments.", + "description": "Deploys a contract from an artifact file, using the CREATE2 salt. Takes in the relative path to the json file or the path to the\nartifact in the form of :: or :: where and\n/ parts are optional.\nReverts if the target artifact contains unlinked library placeholders.\nAdditionally accepts abi-encoded constructor arguments.", "declaration": "function deployCode(string calldata artifactPath, bytes calldata constructorArgs, bytes32 salt) external returns (address deployedAddress);", "visibility": "external", "mutability": "", @@ -4137,7 +4314,7 @@ { "func": { "id": "deployCode_6", - "description": "Deploys a contract from an artifact file, using the CREATE2 salt. Takes in the relative path to the json file or the path to the\nartifact in the form of :: where and parts are optional.\nAdditionally accepts `msg.value`.", + "description": "Deploys a contract from an artifact file, using the CREATE2 salt. Takes in the relative path to the json file or the path to the\nartifact in the form of :: or :: where and\n/ parts are optional.\nReverts if the target artifact contains unlinked library placeholders.\nAdditionally accepts `msg.value`.", "declaration": "function deployCode(string calldata artifactPath, uint256 value, bytes32 salt) external returns (address deployedAddress);", "visibility": "external", "mutability": "", @@ -4157,7 +4334,7 @@ { "func": { "id": "deployCode_7", - "description": "Deploys a contract from an artifact file, using the CREATE2 salt. Takes in the relative path to the json file or the path to the\nartifact in the form of :: where and parts are optional.\nAdditionally accepts abi-encoded constructor arguments and `msg.value`.", + "description": "Deploys a contract from an artifact file, using the CREATE2 salt. Takes in the relative path to the json file or the path to the\nartifact in the form of :: or :: where and\n/ parts are optional.\nReverts if the target artifact contains unlinked library placeholders.\nAdditionally accepts abi-encoded constructor arguments and `msg.value`.", "declaration": "function deployCode(string calldata artifactPath, bytes calldata constructorArgs, uint256 value, bytes32 salt) external returns (address deployedAddress);", "visibility": "external", "mutability": "", @@ -4177,7 +4354,7 @@ { "func": { "id": "deriveKey_0", - "description": "Derive a private key from a provided mnenomic string (or mnenomic file path)\nat the derivation path `m/44'/60'/0'/0/{index}`.", + "description": "Derive a private key from a provided mnemonic string (or mnemonic file path)\nat the derivation path `m/44'/60'/0'/0/{index}`.", "declaration": "function deriveKey(string calldata mnemonic, uint32 index) external pure returns (uint256 privateKey);", "visibility": "external", "mutability": "pure", @@ -4197,7 +4374,7 @@ { "func": { "id": "deriveKey_1", - "description": "Derive a private key from a provided mnenomic string (or mnenomic file path)\nat `{derivationPath}{index}`.", + "description": "Derive a private key from a provided mnemonic string (or mnemonic file path)\nat `{derivationPath}{index}`.", "declaration": "function deriveKey(string calldata mnemonic, string calldata derivationPath, uint32 index) external pure returns (uint256 privateKey);", "visibility": "external", "mutability": "pure", @@ -4217,7 +4394,7 @@ { "func": { "id": "deriveKey_2", - "description": "Derive a private key from a provided mnenomic string (or mnenomic file path) in the specified language\nat the derivation path `m/44'/60'/0'/0/{index}`.", + "description": "Derive a private key from a provided mnemonic string (or mnemonic file path) in the specified language\nat the derivation path `m/44'/60'/0'/0/{index}`.", "declaration": "function deriveKey(string calldata mnemonic, uint32 index, string calldata language) external pure returns (uint256 privateKey);", "visibility": "external", "mutability": "pure", @@ -4237,7 +4414,7 @@ { "func": { "id": "deriveKey_3", - "description": "Derive a private key from a provided mnenomic string (or mnenomic file path) in the specified language\nat `{derivationPath}{index}`.", + "description": "Derive a private key from a provided mnemonic string (or mnemonic file path) in the specified language\nat `{derivationPath}{index}`.", "declaration": "function deriveKey(string calldata mnemonic, string calldata derivationPath, uint32 index, string calldata language) external pure returns (uint256 privateKey);", "visibility": "external", "mutability": "pure", @@ -4277,7 +4454,7 @@ { "func": { "id": "dumpState", - "description": "Dump a genesis JSON file's `allocs` to disk.", + "description": "Dumps a genesis JSON file's `allocs` to disk. Accounts created in the current transaction\nare ordered by deployment, followed by the remaining accounts in ascending address order.", "declaration": "function dumpState(string calldata pathToStateJson) external;", "visibility": "external", "mutability": "", @@ -4294,10 +4471,130 @@ "status": "stable", "safety": "unsafe" }, + { + "func": { + "id": "ecAddAffine", + "description": "Adds the secp256k1 affine points `point1 = (pointX1, pointY1)` and\n`point2 = (pointX2, pointY2)`.\nThe point at infinity is represented as `(0, 0)`.", + "declaration": "function ecAddAffine(uint256 pointX1, uint256 pointY1, uint256 pointX2, uint256 pointY2) external pure returns (uint256 resultX, uint256 resultY);", + "visibility": "external", + "mutability": "pure", + "signature": "ecAddAffine(uint256,uint256,uint256,uint256)", + "selector": "0x1af9eefc", + "selectorBytes": [ + 26, + 249, + 238, + 252 + ] + }, + "group": "crypto", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "ecAddProjective", + "description": "Adds the secp256k1 projective points `point1 = (pointX1, pointY1, pointZ1)` and\n`point2 = (pointX2, pointY2, pointZ2)`.\nThe point at infinity is accepted as `(0, y, 0)` for any non-zero `y` and returned as\n`(0, 1, 0)`. Any other result is normalized to `(x, y, 1)`.", + "declaration": "function ecAddProjective(uint256 pointX1, uint256 pointY1, uint256 pointZ1, uint256 pointX2, uint256 pointY2, uint256 pointZ2) external pure returns (uint256 resultX, uint256 resultY, uint256 resultZ);", + "visibility": "external", + "mutability": "pure", + "signature": "ecAddProjective(uint256,uint256,uint256,uint256,uint256,uint256)", + "selector": "0x45b12879", + "selectorBytes": [ + 69, + 177, + 40, + 121 + ] + }, + "group": "crypto", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "ecAffineToProjective", + "description": "Converts the secp256k1 affine point `(pointX, pointY)` to projective coordinates.\nThe point at infinity is converted from `(0, 0)` to `(0, 1, 0)`.", + "declaration": "function ecAffineToProjective(uint256 pointX, uint256 pointY) external pure returns (uint256 resultX, uint256 resultY, uint256 resultZ);", + "visibility": "external", + "mutability": "pure", + "signature": "ecAffineToProjective(uint256,uint256)", + "selector": "0xc1290181", + "selectorBytes": [ + 193, + 41, + 1, + 129 + ] + }, + "group": "crypto", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "ecMulAffine", + "description": "Multiplies the secp256k1 affine point `(pointX, pointY)` by `scalar`.\nThe scalar is reduced modulo the secp256k1 group order.\nThe point at infinity is represented as `(0, 0)`.", + "declaration": "function ecMulAffine(uint256 pointX, uint256 pointY, uint256 scalar) external pure returns (uint256 resultX, uint256 resultY);", + "visibility": "external", + "mutability": "pure", + "signature": "ecMulAffine(uint256,uint256,uint256)", + "selector": "0xc74ca094", + "selectorBytes": [ + 199, + 76, + 160, + 148 + ] + }, + "group": "crypto", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "ecMulProjective", + "description": "Multiplies the secp256k1 projective point `(pointX, pointY, pointZ)` by `scalar`.\nThe scalar is reduced modulo the secp256k1 group order.\nThe point at infinity is accepted as `(0, y, 0)` for any non-zero `y` and returned as\n`(0, 1, 0)`. Any other result is normalized to `(x, y, 1)`.", + "declaration": "function ecMulProjective(uint256 pointX, uint256 pointY, uint256 pointZ, uint256 scalar) external pure returns (uint256 resultX, uint256 resultY, uint256 resultZ);", + "visibility": "external", + "mutability": "pure", + "signature": "ecMulProjective(uint256,uint256,uint256,uint256)", + "selector": "0x031661fc", + "selectorBytes": [ + 3, + 22, + 97, + 252 + ] + }, + "group": "crypto", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "ecProjectiveToAffine", + "description": "Converts the secp256k1 projective point `(pointX, pointY, pointZ)` to affine coordinates.\nThe point at infinity is converted from `(0, y, 0)` for any non-zero `y` to `(0, 0)`.", + "declaration": "function ecProjectiveToAffine(uint256 pointX, uint256 pointY, uint256 pointZ) external pure returns (uint256 resultX, uint256 resultY);", + "visibility": "external", + "mutability": "pure", + "signature": "ecProjectiveToAffine(uint256,uint256,uint256)", + "selector": "0xe6be5995", + "selectorBytes": [ + 230, + 190, + 89, + 149 + ] + }, + "group": "crypto", + "status": "stable", + "safety": "safe" + }, { "func": { "id": "eip712HashStruct_0", - "description": "Generates the struct hash of the canonical EIP-712 type representation and its abi-encoded data.\nSupports 2 different inputs:\n 1. Name of the type (i.e. \"PermitSingle\"):\n * requires previous binding generation with `forge bind-json`.\n * bindings will be retrieved from the path configured in `foundry.toml`.\n 2. String representation of the type (i.e. \"Foo(Bar bar) Bar(uint256 baz)\").\n * Note: the cheatcode will use the canonical type even if the input is malformated\n with the wrong order of elements or with extra whitespaces.", + "description": "Generates the struct hash of the canonical EIP-712 type representation and its abi-encoded data.\nSupports 2 different inputs:\n 1. Name of the type (i.e. \"PermitSingle\"):\n * requires previous binding generation with `forge bind-json`.\n * bindings will be retrieved from the path configured in `foundry.toml`.\n 2. String representation of the type (i.e. \"Foo(Bar bar) Bar(uint256 baz)\").\n * Note: the cheatcode will use the canonical type even if the input is malformed\n with the wrong order of elements or with extra whitespaces.", "declaration": "function eip712HashStruct(string calldata typeNameOrDefinition, bytes calldata abiEncodedData) external pure returns (bytes32 typeHash);", "visibility": "external", "mutability": "pure", @@ -4337,7 +4634,7 @@ { "func": { "id": "eip712HashType_0", - "description": "Generates the hash of the canonical EIP-712 type representation.\nSupports 2 different inputs:\n 1. Name of the type (i.e. \"Transaction\"):\n * requires previous binding generation with `forge bind-json`.\n * bindings will be retrieved from the path configured in `foundry.toml`.\n 2. String representation of the type (i.e. \"Foo(Bar bar) Bar(uint256 baz)\").\n * Note: the cheatcode will output the canonical type even if the input is malformated\n with the wrong order of elements or with extra whitespaces.", + "description": "Generates the hash of the canonical EIP-712 type representation.\nSupports 2 different inputs:\n 1. Name of the type (i.e. \"Transaction\"):\n * requires previous binding generation with `forge bind-json`.\n * bindings will be retrieved from the path configured in `foundry.toml`.\n 2. String representation of the type (i.e. \"Foo(Bar bar) Bar(uint256 baz)\").\n * Note: the cheatcode will output the canonical type even if the input is malformed\n with the wrong order of elements or with extra whitespaces.", "declaration": "function eip712HashType(string calldata typeNameOrDefinition) external pure returns (bytes32 typeHash);", "visibility": "external", "mutability": "pure", @@ -5018,9 +5315,9 @@ "func": { "id": "eth_getLogs", "description": "Gets all the logs according to specified filter.", - "declaration": "function eth_getLogs(uint256 fromBlock, uint256 toBlock, address target, bytes32[] calldata topics) external returns (EthGetLogs[] memory logs);", + "declaration": "function eth_getLogs(uint256 fromBlock, uint256 toBlock, address target, bytes32[] calldata topics) external view returns (EthGetLogs[] memory logs);", "visibility": "external", - "mutability": "", + "mutability": "view", "signature": "eth_getLogs(uint256,uint256,address,bytes32[])", "selector": "0x35e1349b", "selectorBytes": [ @@ -5034,6 +5331,46 @@ "status": "stable", "safety": "safe" }, + { + "func": { + "id": "eth_getProof", + "description": "Gets the EIP-1186 account and storage proof of `target` at `blockNumber` from the active fork.\nThe proof is fetched from the fork's RPC endpoint and does not reflect local state changes.", + "declaration": "function eth_getProof(address target, bytes32[] calldata slots, uint256 blockNumber) external view returns (EthGetProof memory proof);", + "visibility": "external", + "mutability": "view", + "signature": "eth_getProof(address,bytes32[],uint256)", + "selector": "0xcd502c82", + "selectorBytes": [ + 205, + 80, + 44, + 130 + ] + }, + "group": "evm", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "executeTransaction", + "description": "Executes an RLP-encoded signed transaction with full EVM semantics (like `--isolate` mode).\nThe transaction is decoded from EIP-2718 format (type byte prefix + RLP payload) or legacy RLP.\nReturns the execution output bytes.\nThis cheatcode is not allowed in `forge script` contexts.", + "declaration": "function executeTransaction(bytes calldata rawTx) external returns (bytes memory);", + "visibility": "external", + "mutability": "", + "signature": "executeTransaction(bytes)", + "selector": "0x943d7209", + "selectorBytes": [ + 148, + 61, + 114, + 9 + ] + }, + "group": "evm", + "status": "stable", + "safety": "unsafe" + }, { "func": { "id": "exists", @@ -5256,16 +5593,36 @@ }, { "func": { - "id": "expectEmitAnonymous_0", - "description": "Prepare an expected anonymous log with (bool checkTopic1, bool checkTopic2, bool checkTopic3, bool checkData.).\nCall this function, then emit an anonymous event, then call a function. Internally after the call, we check if\nlogs were emitted in the expected order with the expected topics and data (as specified by the booleans).", - "declaration": "function expectEmitAnonymous(bool checkTopic0, bool checkTopic1, bool checkTopic2, bool checkTopic3, bool checkData) external;", + "id": "expectDelegateCall", + "description": "Expects a delegate call to an address with the specified calldata.\nCalldata can either be a strict or a partial match.", + "declaration": "function expectDelegateCall(address callee, bytes calldata data) external;", "visibility": "external", "mutability": "", - "signature": "expectEmitAnonymous(bool,bool,bool,bool,bool)", - "selector": "0xc948db5e", + "signature": "expectDelegateCall(address,bytes)", + "selector": "0x3d6ac1e7", "selectorBytes": [ - 201, - 72, + 61, + 106, + 193, + 231 + ] + }, + "group": "testing", + "status": "stable", + "safety": "unsafe" + }, + { + "func": { + "id": "expectEmitAnonymous_0", + "description": "Prepare an expected anonymous log with (bool checkTopic1, bool checkTopic2, bool checkTopic3, bool checkData.).\nCall this function, then emit an anonymous event, then call a function. Internally after the call, we check if\nlogs were emitted in the expected order with the expected topics and data (as specified by the booleans).", + "declaration": "function expectEmitAnonymous(bool checkTopic0, bool checkTopic1, bool checkTopic2, bool checkTopic3, bool checkData) external;", + "visibility": "external", + "mutability": "", + "signature": "expectEmitAnonymous(bool,bool,bool,bool,bool)", + "selector": "0xc948db5e", + "selectorBytes": [ + 201, + 72, 219, 94 ] @@ -5337,7 +5694,7 @@ { "func": { "id": "expectEmit_0", - "description": "Prepare an expected log with (bool checkTopic1, bool checkTopic2, bool checkTopic3, bool checkData.).\nCall this function, then emit an event, then call a function. Internally after the call, we check if\nlogs were emitted in the expected order with the expected topics and data (as specified by the booleans).", + "description": "Prepare an expected log with (bool checkTopic1, bool checkTopic2, bool checkTopic3, bool checkData.).\nCall this function, then emit an event, then call a function. Internally after the call, we check if\nlogs were emitted in the expected order with the expected topics and data (as specified by the booleans).\nMust be placed immediately before the call you want to assert on. If the next call reverts and the\nrevert is caught by the caller (low-level call or try/catch), the expectation remains active and may\nbe satisfied by a log emitted from a later call.", "declaration": "function expectEmit(bool checkTopic1, bool checkTopic2, bool checkTopic3, bool checkData) external;", "visibility": "external", "mutability": "", @@ -5377,7 +5734,7 @@ { "func": { "id": "expectEmit_2", - "description": "Prepare an expected log with all topic and data checks enabled.\nCall this function, then emit an event, then call a function. Internally after the call, we check if\nlogs were emitted in the expected order with the expected topics and data.", + "description": "Prepare an expected log with all topic and data checks enabled.\nCall this function, then emit an event, then call a function. Internally after the call, we check if\nlogs were emitted in the expected order with the expected topics and data.\nMust be placed immediately before the call you want to assert on. If the next call reverts and the\nrevert is caught by the caller (low-level call or try/catch), the expectation remains active and may\nbe satisfied by a log emitted from a later call.", "declaration": "function expectEmit() external;", "visibility": "external", "mutability": "", @@ -5494,6 +5851,66 @@ "status": "stable", "safety": "unsafe" }, + { + "func": { + "id": "expectKeychainAdminVerified", + "description": "Expects a call to `SignatureVerifier.verifyKeychainAdmin(account, digest, signature)`.\nThe supplied `digest` should already be domain-separated with chain ID, contract address,\nand account address.", + "declaration": "function expectKeychainAdminVerified(address account, bytes32 digest, bytes calldata signature) external;", + "visibility": "external", + "mutability": "", + "signature": "expectKeychainAdminVerified(address,bytes32,bytes)", + "selector": "0xcbe45e4f", + "selectorBytes": [ + 203, + 228, + 94, + 79 + ] + }, + "group": "testing", + "status": "stable", + "safety": "unsafe" + }, + { + "func": { + "id": "expectKeychainVerified", + "description": "Expects a call to `SignatureVerifier.verifyKeychain(account, digest, signature)`.", + "declaration": "function expectKeychainVerified(address account, bytes32 digest, bytes calldata signature) external;", + "visibility": "external", + "mutability": "", + "signature": "expectKeychainVerified(address,bytes32,bytes)", + "selector": "0x3040e515", + "selectorBytes": [ + 48, + 64, + 229, + 21 + ] + }, + "group": "testing", + "status": "stable", + "safety": "unsafe" + }, + { + "func": { + "id": "expectLogoURIUpdated", + "description": "Expects a TIP-20 `LogoURIUpdated(address indexed updater, string newLogoURI)` event.", + "declaration": "function expectLogoURIUpdated(address token, address updater, string calldata newLogoURI) external;", + "visibility": "external", + "mutability": "", + "signature": "expectLogoURIUpdated(address,address,string)", + "selector": "0xd450e23a", + "selectorBytes": [ + 212, + 80, + 226, + 58 + ] + }, + "group": "testing", + "status": "stable", + "safety": "unsafe" + }, { "func": { "id": "expectPartialRevert_0", @@ -5517,7 +5934,7 @@ { "func": { "id": "expectPartialRevert_1", - "description": "Expects an error on next call to reverter address, that starts with the revert data.", + "description": "Expects an error on next call to reverter address, that starts with the revert data.\nSee `expectRevert(address)` for `reverter` matching semantics.", "declaration": "function expectPartialRevert(bytes4 revertData, address reverter) external;", "visibility": "external", "mutability": "", @@ -5577,7 +5994,7 @@ { "func": { "id": "expectRevert_10", - "description": "Expects a `count` number of reverts from the upcoming calls from the reverter address that match the revert data.", + "description": "Expects a `count` number of reverts from the upcoming calls from the reverter address that match the revert data.\nSee `expectRevert(address)` for `reverter` matching semantics.", "declaration": "function expectRevert(bytes4 revertData, address reverter, uint64 count) external;", "visibility": "external", "mutability": "", @@ -5597,7 +6014,7 @@ { "func": { "id": "expectRevert_11", - "description": "Expects a `count` number of reverts from the upcoming calls from the reverter address that exactly match the revert data.", + "description": "Expects a `count` number of reverts from the upcoming calls from the reverter address that exactly match the revert data.\nSee `expectRevert(address)` for `reverter` matching semantics.", "declaration": "function expectRevert(bytes calldata revertData, address reverter, uint64 count) external;", "visibility": "external", "mutability": "", @@ -5637,7 +6054,7 @@ { "func": { "id": "expectRevert_3", - "description": "Expects an error with any revert data on next call to reverter address.", + "description": "Expects an error with any revert data on next call to reverter address.\nThe `reverter` argument is matched against the address associated with\nthe frame that produced the revert:\n - For a CALL: the address that was called.\n - For a CREATE / CREATE2: the would-be deployed address of the failed\n deployment (computed from the deployer + nonce, or salt + initcode).\nFor a single expected revert, the innermost reverting frame wins in\nnested CALL, CREATE, or mixed chains. With `count > 1`, nested\nCREATE / CREATE2 chains apply the same rule independently to each\niteration; nested CALL chains keep their existing\noutermost-call-per-iteration behavior.", "declaration": "function expectRevert(address reverter) external;", "visibility": "external", "mutability": "", @@ -5657,7 +6074,7 @@ { "func": { "id": "expectRevert_4", - "description": "Expects an error from reverter address on next call, with any revert data.", + "description": "Expects an error from reverter address on next call, with any revert data.\nSee `expectRevert(address)` for `reverter` matching semantics.", "declaration": "function expectRevert(bytes4 revertData, address reverter) external;", "visibility": "external", "mutability": "", @@ -5677,7 +6094,7 @@ { "func": { "id": "expectRevert_5", - "description": "Expects an error from reverter address on next call, that exactly matches the revert data.", + "description": "Expects an error from reverter address on next call, that exactly matches the revert data.\nSee `expectRevert(address)` for `reverter` matching semantics.", "declaration": "function expectRevert(bytes calldata revertData, address reverter) external;", "visibility": "external", "mutability": "", @@ -5757,7 +6174,7 @@ { "func": { "id": "expectRevert_9", - "description": "Expects a `count` number of reverts from the upcoming calls from the reverter address.", + "description": "Expects a `count` number of reverts from the upcoming calls from the reverter address.\nSee `expectRevert(address)` for `reverter` matching semantics.", "declaration": "function expectRevert(address reverter, uint64 count) external;", "visibility": "external", "mutability": "", @@ -5814,6 +6231,26 @@ "status": "stable", "safety": "unsafe" }, + { + "func": { + "id": "expectTip20LogoURIUpdated", + "description": "Expects a TIP-20 `LogoURIUpdated(address indexed updater, string newLogoURI)` event.", + "declaration": "function expectTip20LogoURIUpdated(address token, address updater, string calldata newLogoURI) external;", + "visibility": "external", + "mutability": "", + "signature": "expectTip20LogoURIUpdated(address,address,string)", + "selector": "0x4e134a2c", + "selectorBytes": [ + 78, + 19, + 74, + 44 + ] + }, + "group": "testing", + "status": "stable", + "safety": "unsafe" + }, { "func": { "id": "fee", @@ -5854,6 +6291,66 @@ "status": "stable", "safety": "safe" }, + { + "func": { + "id": "ffiBytes", + "description": "Performs a foreign function call via the terminal and decodes the output as hex bytes.", + "declaration": "function ffiBytes(string[] calldata commandInput) external returns (bytes memory result);", + "visibility": "external", + "mutability": "", + "signature": "ffiBytes(string[])", + "selector": "0x32730d81", + "selectorBytes": [ + 50, + 115, + 13, + 129 + ] + }, + "group": "filesystem", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "ffiString", + "description": "Performs a foreign function call via the terminal and returns the output as a string.", + "declaration": "function ffiString(string[] calldata commandInput) external returns (string memory result);", + "visibility": "external", + "mutability": "", + "signature": "ffiString(string[])", + "selector": "0x0d0d2f41", + "selectorBytes": [ + 13, + 13, + 47, + 65 + ] + }, + "group": "filesystem", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "ffiUint", + "description": "Performs a foreign function call via the terminal and parses the output as a `uint256`.", + "declaration": "function ffiUint(string[] calldata commandInput) external returns (uint256 result);", + "visibility": "external", + "mutability": "", + "signature": "ffiUint(string[])", + "selector": "0xcdf7c6c4", + "selectorBytes": [ + 205, + 247, + 198, + 196 + ] + }, + "group": "filesystem", + "status": "stable", + "safety": "safe" + }, { "func": { "id": "foundryVersionAtLeast", @@ -5894,6 +6391,26 @@ "status": "stable", "safety": "safe" }, + { + "func": { + "id": "fromRlp", + "description": "RLP decodes an RLP payload into a list of bytes.", + "declaration": "function fromRlp(bytes calldata rlp) external pure returns (bytes[] memory data);", + "visibility": "external", + "mutability": "pure", + "signature": "fromRlp(bytes)", + "selector": "0x1e1d8b63", + "selectorBytes": [ + 30, + 29, + 139, + 99 + ] + }, + "group": "utilities", + "status": "stable", + "safety": "safe" + }, { "func": { "id": "fsMetadata", @@ -5977,7 +6494,7 @@ { "func": { "id": "getBlobhashes", - "description": "Gets the blockhashes from the current transaction.\nNot available on EVM versions before Cancun.\nIf used on unsupported EVM versions it will revert.", + "description": "Gets the blobhashes from the current transaction.\nNot available on EVM versions before Cancun.\nIf used on unsupported EVM versions it will revert.", "declaration": "function getBlobhashes() external view returns (bytes32[] memory hashes);", "visibility": "external", "mutability": "view", @@ -6094,6 +6611,26 @@ "status": "stable", "safety": "safe" }, + { + "func": { + "id": "getChainId", + "description": "Gets the current `block.chainid` of the currently selected environment.\nYou should use this instead of `block.chainid` if you use `vm.selectFork` or `vm.createSelectFork`, as `block.chainid` could be assumed\nto be constant across a transaction, and as a result will get optimized out by the compiler.\nSee https://github.com/foundry-rs/foundry/issues/6180", + "declaration": "function getChainId() external view returns (uint256 blockChainId);", + "visibility": "external", + "mutability": "view", + "signature": "getChainId()", + "selector": "0x3408e470", + "selectorBytes": [ + 52, + 8, + 228, + 112 + ] + }, + "group": "evm", + "status": "stable", + "safety": "safe" + }, { "func": { "id": "getChain_0", @@ -6137,7 +6674,7 @@ { "func": { "id": "getCode", - "description": "Gets the creation bytecode from an artifact file. Takes in the relative path to the json file or the path to the\nartifact in the form of :: where and parts are optional.", + "description": "Gets the creation bytecode from an artifact file. Takes in the relative path to the json file or the path to the\nartifact in the form of :: or :: where and\n/ parts are optional. Use to select artifacts compiled with a specific profile\nfrom foundry.toml.", "declaration": "function getCode(string calldata artifactPath) external view returns (bytes memory creationBytecode);", "visibility": "external", "mutability": "view", @@ -6157,7 +6694,7 @@ { "func": { "id": "getDeployedCode", - "description": "Gets the deployed bytecode from an artifact file. Takes in the relative path to the json file or the path to the\nartifact in the form of :: where and parts are optional.", + "description": "Gets the deployed bytecode from an artifact file. Takes in the relative path to the json file or the path to the\nartifact in the form of :: or :: where and\n/ parts are optional.", "declaration": "function getDeployedCode(string calldata artifactPath) external view returns (bytes memory runtimeBytecode);", "visibility": "external", "mutability": "view", @@ -6234,6 +6771,26 @@ "status": "stable", "safety": "safe" }, + { + "func": { + "id": "getEvmVersion", + "description": "Returns the test or script execution evm version.\n**Note:** The execution evm version is not the same as the compilation one.", + "declaration": "function getEvmVersion() external pure returns (string memory evm);", + "visibility": "external", + "mutability": "pure", + "signature": "getEvmVersion()", + "selector": "0xaa2bb222", + "selectorBytes": [ + 170, + 43, + 178, + 34 + ] + }, + "group": "evm", + "status": "stable", + "safety": "safe" + }, { "func": { "id": "getFoundryVersion", @@ -6278,9 +6835,9 @@ "func": { "id": "getMappingKeyAndParentOf", "description": "Gets the map key and parent of a mapping at a given slot, for a given address.", - "declaration": "function getMappingKeyAndParentOf(address target, bytes32 elementSlot) external returns (bool found, bytes32 key, bytes32 parent);", + "declaration": "function getMappingKeyAndParentOf(address target, bytes32 elementSlot) external view returns (bool found, bytes32 key, bytes32 parent);", "visibility": "external", - "mutability": "", + "mutability": "view", "signature": "getMappingKeyAndParentOf(address,bytes32)", "selector": "0x876e24e6", "selectorBytes": [ @@ -6298,9 +6855,9 @@ "func": { "id": "getMappingLength", "description": "Gets the number of elements in the mapping at the given slot, for a given address.", - "declaration": "function getMappingLength(address target, bytes32 mappingSlot) external returns (uint256 length);", + "declaration": "function getMappingLength(address target, bytes32 mappingSlot) external view returns (uint256 length);", "visibility": "external", - "mutability": "", + "mutability": "view", "signature": "getMappingLength(address,bytes32)", "selector": "0x2f2fd63f", "selectorBytes": [ @@ -6318,9 +6875,9 @@ "func": { "id": "getMappingSlotAt", "description": "Gets the elements at index idx of the mapping at the given slot, for a given address. The\nindex must be less than the length of the mapping (i.e. the number of keys in the mapping).", - "declaration": "function getMappingSlotAt(address target, bytes32 mappingSlot, uint256 idx) external returns (bytes32 value);", + "declaration": "function getMappingSlotAt(address target, bytes32 mappingSlot, uint256 idx) external view returns (bytes32 value);", "visibility": "external", - "mutability": "", + "mutability": "view", "signature": "getMappingSlotAt(address,bytes32,uint256)", "selector": "0xebc73ab4", "selectorBytes": [ @@ -6358,9 +6915,9 @@ "func": { "id": "getNonce_1", "description": "Get the nonce of a `Wallet`.", - "declaration": "function getNonce(Wallet calldata wallet) external returns (uint64 nonce);", + "declaration": "function getNonce(Wallet calldata wallet) external view returns (uint64 nonce);", "visibility": "external", - "mutability": "", + "mutability": "view", "signature": "getNonce((address,uint256,uint256,uint256))", "selector": "0xa5748aad", "selectorBytes": [ @@ -6374,13 +6931,33 @@ "status": "stable", "safety": "safe" }, + { + "func": { + "id": "getRawBlockHeader", + "description": "Gets the RLP encoded block header for a given block number.\nReturns the block header in the same format as `cast block --raw`.", + "declaration": "function getRawBlockHeader(uint256 blockNumber) external view returns (bytes memory rlpHeader);", + "visibility": "external", + "mutability": "view", + "signature": "getRawBlockHeader(uint256)", + "selector": "0x2c667606", + "selectorBytes": [ + 44, + 102, + 118, + 6 + ] + }, + "group": "evm", + "status": "stable", + "safety": "safe" + }, { "func": { "id": "getRecordedLogs", "description": "Gets all the recorded logs.", - "declaration": "function getRecordedLogs() external returns (Log[] memory logs);", + "declaration": "function getRecordedLogs() external view returns (Log[] memory logs);", "visibility": "external", - "mutability": "", + "mutability": "view", "signature": "getRecordedLogs()", "selector": "0x191553a4", "selectorBytes": [ @@ -6394,6 +6971,66 @@ "status": "stable", "safety": "safe" }, + { + "func": { + "id": "getRecordedLogsJson", + "description": "Gets all the recorded logs, in JSON format.", + "declaration": "function getRecordedLogsJson() external view returns (string memory logsJson);", + "visibility": "external", + "mutability": "view", + "signature": "getRecordedLogsJson()", + "selector": "0x3b171111", + "selectorBytes": [ + 59, + 23, + 17, + 17 + ] + }, + "group": "evm", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "getSelectors", + "description": "Gets all function selectors from a contract artifact. Takes in the relative path to the json file or the path to the\nartifact in the form of :: where and parts are optional.", + "declaration": "function getSelectors(string calldata artifactPath) external view returns (bytes4[] memory selectors);", + "visibility": "external", + "mutability": "view", + "signature": "getSelectors(string)", + "selector": "0x13d5a709", + "selectorBytes": [ + 19, + 213, + 167, + 9 + ] + }, + "group": "filesystem", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "getSlotNumber", + "description": "Gets the current `block.slotnum`.\nUse this instead of `block.slotnum` after `vm.rollSlot`, as the compiler assumes\n`block.slotnum` is constant across a transaction and may optimize repeated reads away.\nNot available on EVM versions before Amsterdam.\nIf used on unsupported EVM versions it will revert.", + "declaration": "function getSlotNumber() external view returns (uint64 slotNumber);", + "visibility": "external", + "mutability": "view", + "signature": "getSlotNumber()", + "selector": "0xa50d337b", + "selectorBytes": [ + 165, + 13, + 51, + 123 + ] + }, + "group": "evm", + "status": "stable", + "safety": "safe" + }, { "func": { "id": "getStateDiff", @@ -6436,18 +7073,58 @@ }, { "func": { - "id": "getWallets", - "description": "Returns addresses of available unlocked wallets in the script environment.", - "declaration": "function getWallets() external returns (address[] memory wallets);", + "id": "getStorageAccesses", + "description": "Returns an array of `StorageAccess` from current `vm.stateStateDiffRecording` session", + "declaration": "function getStorageAccesses() external view returns (StorageAccess[] memory storageAccesses);", "visibility": "external", - "mutability": "", - "signature": "getWallets()", - "selector": "0xdb7a4605", + "mutability": "view", + "signature": "getStorageAccesses()", + "selector": "0x2899b1d0", "selectorBytes": [ - 219, - 122, - 70, - 5 + 40, + 153, + 177, + 208 + ] + }, + "group": "evm", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "getStorageSlots", + "description": "Returns an array of storage slots occupied by the specified variable.", + "declaration": "function getStorageSlots(address target, string calldata variableName) external view returns (uint256[] memory slots);", + "visibility": "external", + "mutability": "view", + "signature": "getStorageSlots(address,string)", + "selector": "0xefa136d9", + "selectorBytes": [ + 239, + 161, + 54, + 217 + ] + }, + "group": "evm", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "getWallets", + "description": "Returns addresses of available unlocked wallets in the script environment.", + "declaration": "function getWallets() external view returns (address[] memory wallets);", + "visibility": "external", + "mutability": "view", + "signature": "getWallets()", + "selector": "0xdb7a4605", + "selectorBytes": [ + 219, + 122, + 70, + 5 ] }, "group": "scripting", @@ -6554,6 +7231,46 @@ "status": "stable", "safety": "safe" }, + { + "func": { + "id": "isImplicitlyApproved", + "description": "Returns `true` if `spender` is on the active Tempo hardfork's implicit-approval list,\nmeaning it can pull TIP-20 tokens from `msg.sender` without a prior `approve()`.\nReturns `false` on non-Tempo networks.", + "declaration": "function isImplicitlyApproved(address spender) external view returns (bool implicitlyApproved);", + "visibility": "external", + "mutability": "view", + "signature": "isImplicitlyApproved(address)", + "selector": "0xa8da6aa7", + "selectorBytes": [ + 168, + 218, + 106, + 167 + ] + }, + "group": "evm", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "isIsolateMode", + "description": "Returns true if isolated test execution is enabled.", + "declaration": "function isIsolateMode() external view returns (bool result);", + "visibility": "external", + "mutability": "view", + "signature": "isIsolateMode()", + "selector": "0x17a5dbab", + "selectorBytes": [ + 23, + 165, + 219, + 171 + ] + }, + "group": "evm", + "status": "stable", + "safety": "safe" + }, { "func": { "id": "isPersistent", @@ -6577,7 +7294,7 @@ { "func": { "id": "keyExists", - "description": "Checks if `key` exists in a JSON object\n`keyExists` is being deprecated in favor of `keyExistsJson`. It will be removed in future versions.", + "description": "Checks if `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) exists in a JSON object.\n`keyExists` is being deprecated in favor of `keyExistsJson`. It will be removed in future versions.", "declaration": "function keyExists(string calldata json, string calldata key) external view returns (bool);", "visibility": "external", "mutability": "view", @@ -6599,7 +7316,7 @@ { "func": { "id": "keyExistsJson", - "description": "Checks if `key` exists in a JSON object.", + "description": "Checks if `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) exists in a JSON object.", "declaration": "function keyExistsJson(string calldata json, string calldata key) external view returns (bool);", "visibility": "external", "mutability": "view", @@ -6659,7 +7376,7 @@ { "func": { "id": "lastCallGas", - "description": "Gets the gas used in the last call from the callee perspective.", + "description": "DEPRECATED: use `lastFrameGas` instead.\nGets gas measurements for the last completed call, from the callee's perspective.\nUnlike `lastFrameGas`, CREATE and CREATE2 frames are not recorded; calls made by a constructor are.\nSee `Gas` for field semantics.", "declaration": "function lastCallGas() external view returns (Gas memory gas);", "visibility": "external", "mutability": "view", @@ -6673,6 +7390,28 @@ ] }, "group": "evm", + "status": { + "deprecated": "replaced by `lastFrameGas`" + }, + "safety": "safe" + }, + { + "func": { + "id": "lastFrameGas", + "description": "Gets gas measurements for the last completed call or create, from the callee's perspective.\nUnlike `lastCallGas`, CREATE and CREATE2 frames are recorded too. Cheatcode calls are never recorded.\nSee `Gas` for field semantics and .", + "declaration": "function lastFrameGas() external view returns (Gas memory gas);", + "visibility": "external", + "mutability": "view", + "signature": "lastFrameGas()", + "selector": "0x995d5b03", + "selectorBytes": [ + 153, + 93, + 91, + 3 + ] + }, + "group": "evm", "status": "stable", "safety": "safe" }, @@ -6716,6 +7455,26 @@ "status": "stable", "safety": "unsafe" }, + { + "func": { + "id": "loadTransient", + "description": "Loads a transient storage slot from an address.", + "declaration": "function loadTransient(address target, bytes32 slot) external view returns (bytes32 data);", + "visibility": "external", + "mutability": "view", + "signature": "loadTransient(address,bytes32)", + "selector": "0x69fd765a", + "selectorBytes": [ + 105, + 253, + 118, + 90 + ] + }, + "group": "evm", + "status": "stable", + "safety": "safe" + }, { "func": { "id": "makePersistent_0", @@ -6956,6 +7715,26 @@ "status": "stable", "safety": "unsafe" }, + { + "func": { + "id": "mockCall_4", + "description": "Mocks a call to an address, returning specified data.\nCalldata can either be strict or a partial match, e.g. if you only\npass a Solidity selector to the expected calldata, then the entire Solidity\nfunction will be mocked.\nOverload to control whether code is injected into `callee`. The other overloads etch a\nsingle byte into an empty account to circumvent Solidity's `extcodesize` check, with the\nside effect that unmocked calls to it no longer revert; `injectCode = false` leaves the\naccount codeless, so unmocked calls to it revert in the caller. Mocked calls that return\ndata still succeed, as Solidity checks `returndatasize()` instead of `extcodesize()` when\nreturn data is expected, but mocked calls to functions without return values may still\nrevert in the caller due to the `extcodesize` check.", + "declaration": "function mockCall(address callee, bytes calldata data, bytes calldata returnData, bool injectCode) external;", + "visibility": "external", + "mutability": "", + "signature": "mockCall(address,bytes,bytes,bool)", + "selector": "0xb4bc5617", + "selectorBytes": [ + 180, + 188, + 86, + 23 + ] + }, + "group": "evm", + "status": "stable", + "safety": "unsafe" + }, { "func": { "id": "mockCalls_0", @@ -7138,8 +7917,48 @@ }, { "func": { - "id": "parseJsonAddress", - "description": "Parses a string of JSON data at `key` and coerces it to `address`.", + "id": "parseJsonAddressArray_0", + "description": "Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `address[]`.", + "declaration": "function parseJsonAddressArray(string calldata json, string calldata key) external pure returns (address[] memory);", + "visibility": "external", + "mutability": "pure", + "signature": "parseJsonAddressArray(string,string)", + "selector": "0x2fce7883", + "selectorBytes": [ + 47, + 206, + 120, + 131 + ] + }, + "group": "json", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "parseJsonAddressArray_1", + "description": "Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `address[]`, or returns `defaultValue` if the key does not exist.", + "declaration": "function parseJsonAddressArray(string calldata json, string calldata key, address[] calldata defaultValue) external pure returns (address[] memory);", + "visibility": "external", + "mutability": "pure", + "signature": "parseJsonAddressArray(string,string,address[])", + "selector": "0x586afa51", + "selectorBytes": [ + 88, + 106, + 250, + 81 + ] + }, + "group": "json", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "parseJsonAddress_0", + "description": "Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `address`.", "declaration": "function parseJsonAddress(string calldata json, string calldata key) external pure returns (address);", "visibility": "external", "mutability": "pure", @@ -7158,18 +7977,18 @@ }, { "func": { - "id": "parseJsonAddressArray", - "description": "Parses a string of JSON data at `key` and coerces it to `address[]`.", - "declaration": "function parseJsonAddressArray(string calldata json, string calldata key) external pure returns (address[] memory);", + "id": "parseJsonAddress_1", + "description": "Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `address`, or returns `defaultValue` if the key does not exist.", + "declaration": "function parseJsonAddress(string calldata json, string calldata key, address defaultValue) external pure returns (address);", "visibility": "external", "mutability": "pure", - "signature": "parseJsonAddressArray(string,string)", - "selector": "0x2fce7883", + "signature": "parseJsonAddress(string,string,address)", + "selector": "0xddeecb75", "selectorBytes": [ - 47, - 206, - 120, - 131 + 221, + 238, + 203, + 117 ] }, "group": "json", @@ -7178,18 +7997,18 @@ }, { "func": { - "id": "parseJsonBool", - "description": "Parses a string of JSON data at `key` and coerces it to `bool`.", - "declaration": "function parseJsonBool(string calldata json, string calldata key) external pure returns (bool);", + "id": "parseJsonArrayLength", + "description": "Returns the length of the JSON array at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields).", + "declaration": "function parseJsonArrayLength(string calldata json, string calldata key) external pure returns (uint256 length);", "visibility": "external", "mutability": "pure", - "signature": "parseJsonBool(string,string)", - "selector": "0x9f86dc91", + "signature": "parseJsonArrayLength(string,string)", + "selector": "0xcdb6e4ac", "selectorBytes": [ - 159, - 134, - 220, - 145 + 205, + 182, + 228, + 172 ] }, "group": "json", @@ -7198,8 +8017,8 @@ }, { "func": { - "id": "parseJsonBoolArray", - "description": "Parses a string of JSON data at `key` and coerces it to `bool[]`.", + "id": "parseJsonBoolArray_0", + "description": "Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `bool[]`.", "declaration": "function parseJsonBoolArray(string calldata json, string calldata key) external pure returns (bool[] memory);", "visibility": "external", "mutability": "pure", @@ -7218,18 +8037,18 @@ }, { "func": { - "id": "parseJsonBytes", - "description": "Parses a string of JSON data at `key` and coerces it to `bytes`.", - "declaration": "function parseJsonBytes(string calldata json, string calldata key) external pure returns (bytes memory);", + "id": "parseJsonBoolArray_1", + "description": "Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `bool[]`, or returns `defaultValue` if the key does not exist.", + "declaration": "function parseJsonBoolArray(string calldata json, string calldata key, bool[] calldata defaultValue) external pure returns (bool[] memory);", "visibility": "external", "mutability": "pure", - "signature": "parseJsonBytes(string,string)", - "selector": "0xfd921be8", + "signature": "parseJsonBoolArray(string,string,bool[])", + "selector": "0xcc9d6da5", "selectorBytes": [ - 253, - 146, - 27, - 232 + 204, + 157, + 109, + 165 ] }, "group": "json", @@ -7238,18 +8057,38 @@ }, { "func": { - "id": "parseJsonBytes32", - "description": "Parses a string of JSON data at `key` and coerces it to `bytes32`.", - "declaration": "function parseJsonBytes32(string calldata json, string calldata key) external pure returns (bytes32);", + "id": "parseJsonBool_0", + "description": "Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `bool`.", + "declaration": "function parseJsonBool(string calldata json, string calldata key) external pure returns (bool);", "visibility": "external", "mutability": "pure", - "signature": "parseJsonBytes32(string,string)", - "selector": "0x1777e59d", + "signature": "parseJsonBool(string,string)", + "selector": "0x9f86dc91", "selectorBytes": [ - 23, - 119, - 229, - 157 + 159, + 134, + 220, + 145 + ] + }, + "group": "json", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "parseJsonBool_1", + "description": "Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `bool`, or returns `defaultValue` if the key does not exist.", + "declaration": "function parseJsonBool(string calldata json, string calldata key, bool defaultValue) external pure returns (bool);", + "visibility": "external", + "mutability": "pure", + "signature": "parseJsonBool(string,string,bool)", + "selector": "0xf147484e", + "selectorBytes": [ + 241, + 71, + 72, + 78 ] }, "group": "json", @@ -7258,8 +8097,8 @@ }, { "func": { - "id": "parseJsonBytes32Array", - "description": "Parses a string of JSON data at `key` and coerces it to `bytes32[]`.", + "id": "parseJsonBytes32Array_0", + "description": "Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `bytes32[]`.", "declaration": "function parseJsonBytes32Array(string calldata json, string calldata key) external pure returns (bytes32[] memory);", "visibility": "external", "mutability": "pure", @@ -7278,18 +8117,18 @@ }, { "func": { - "id": "parseJsonBytesArray", - "description": "Parses a string of JSON data at `key` and coerces it to `bytes[]`.", - "declaration": "function parseJsonBytesArray(string calldata json, string calldata key) external pure returns (bytes[] memory);", + "id": "parseJsonBytes32Array_1", + "description": "Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `bytes32[]`, or returns `defaultValue` if the key does not exist.", + "declaration": "function parseJsonBytes32Array(string calldata json, string calldata key, bytes32[] calldata defaultValue) external pure returns (bytes32[] memory);", "visibility": "external", "mutability": "pure", - "signature": "parseJsonBytesArray(string,string)", - "selector": "0x6631aa99", + "signature": "parseJsonBytes32Array(string,string,bytes32[])", + "selector": "0xd503fb9e", "selectorBytes": [ - 102, - 49, - 170, - 153 + 213, + 3, + 251, + 158 ] }, "group": "json", @@ -7298,18 +8137,18 @@ }, { "func": { - "id": "parseJsonInt", - "description": "Parses a string of JSON data at `key` and coerces it to `int256`.", - "declaration": "function parseJsonInt(string calldata json, string calldata key) external pure returns (int256);", + "id": "parseJsonBytes32_0", + "description": "Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `bytes32`.", + "declaration": "function parseJsonBytes32(string calldata json, string calldata key) external pure returns (bytes32);", "visibility": "external", "mutability": "pure", - "signature": "parseJsonInt(string,string)", - "selector": "0x7b048ccd", + "signature": "parseJsonBytes32(string,string)", + "selector": "0x1777e59d", "selectorBytes": [ - 123, - 4, - 140, - 205 + 23, + 119, + 229, + 157 ] }, "group": "json", @@ -7318,18 +8157,18 @@ }, { "func": { - "id": "parseJsonIntArray", - "description": "Parses a string of JSON data at `key` and coerces it to `int256[]`.", - "declaration": "function parseJsonIntArray(string calldata json, string calldata key) external pure returns (int256[] memory);", + "id": "parseJsonBytes32_1", + "description": "Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `bytes32`, or returns `defaultValue` if the key does not exist.", + "declaration": "function parseJsonBytes32(string calldata json, string calldata key, bytes32 defaultValue) external pure returns (bytes32);", "visibility": "external", "mutability": "pure", - "signature": "parseJsonIntArray(string,string)", - "selector": "0x9983c28a", + "signature": "parseJsonBytes32(string,string,bytes32)", + "selector": "0x8d792033", "selectorBytes": [ - 153, - 131, - 194, - 138 + 141, + 121, + 32, + 51 ] }, "group": "json", @@ -7338,18 +8177,18 @@ }, { "func": { - "id": "parseJsonKeys", - "description": "Returns an array of all the keys in a JSON object.", - "declaration": "function parseJsonKeys(string calldata json, string calldata key) external pure returns (string[] memory keys);", + "id": "parseJsonBytesArray_0", + "description": "Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `bytes[]`.", + "declaration": "function parseJsonBytesArray(string calldata json, string calldata key) external pure returns (bytes[] memory);", "visibility": "external", "mutability": "pure", - "signature": "parseJsonKeys(string,string)", - "selector": "0x213e4198", + "signature": "parseJsonBytesArray(string,string)", + "selector": "0x6631aa99", "selectorBytes": [ - 33, - 62, - 65, - 152 + 102, + 49, + 170, + 153 ] }, "group": "json", @@ -7358,18 +8197,158 @@ }, { "func": { - "id": "parseJsonString", - "description": "Parses a string of JSON data at `key` and coerces it to `string`.", - "declaration": "function parseJsonString(string calldata json, string calldata key) external pure returns (string memory);", + "id": "parseJsonBytesArray_1", + "description": "Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `bytes[]`, or returns `defaultValue` if the key does not exist.", + "declaration": "function parseJsonBytesArray(string calldata json, string calldata key, bytes[] calldata defaultValue) external pure returns (bytes[] memory);", "visibility": "external", "mutability": "pure", - "signature": "parseJsonString(string,string)", - "selector": "0x49c4fac8", + "signature": "parseJsonBytesArray(string,string,bytes[])", + "selector": "0xd1422c93", + "selectorBytes": [ + 209, + 66, + 44, + 147 + ] + }, + "group": "json", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "parseJsonBytes_0", + "description": "Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `bytes`.", + "declaration": "function parseJsonBytes(string calldata json, string calldata key) external pure returns (bytes memory);", + "visibility": "external", + "mutability": "pure", + "signature": "parseJsonBytes(string,string)", + "selector": "0xfd921be8", + "selectorBytes": [ + 253, + 146, + 27, + 232 + ] + }, + "group": "json", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "parseJsonBytes_1", + "description": "Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `bytes`, or returns `defaultValue` if the key does not exist.", + "declaration": "function parseJsonBytes(string calldata json, string calldata key, bytes calldata defaultValue) external pure returns (bytes memory);", + "visibility": "external", + "mutability": "pure", + "signature": "parseJsonBytes(string,string,bytes)", + "selector": "0xb3d931af", + "selectorBytes": [ + 179, + 217, + 49, + 175 + ] + }, + "group": "json", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "parseJsonIntArray_0", + "description": "Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `int256[]`.", + "declaration": "function parseJsonIntArray(string calldata json, string calldata key) external pure returns (int256[] memory);", + "visibility": "external", + "mutability": "pure", + "signature": "parseJsonIntArray(string,string)", + "selector": "0x9983c28a", + "selectorBytes": [ + 153, + 131, + 194, + 138 + ] + }, + "group": "json", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "parseJsonIntArray_1", + "description": "Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `int256[]`, or returns `defaultValue` if the key does not exist.", + "declaration": "function parseJsonIntArray(string calldata json, string calldata key, int256[] calldata defaultValue) external pure returns (int256[] memory);", + "visibility": "external", + "mutability": "pure", + "signature": "parseJsonIntArray(string,string,int256[])", + "selector": "0xea1f44aa", + "selectorBytes": [ + 234, + 31, + 68, + 170 + ] + }, + "group": "json", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "parseJsonInt_0", + "description": "Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `int256`.", + "declaration": "function parseJsonInt(string calldata json, string calldata key) external pure returns (int256);", + "visibility": "external", + "mutability": "pure", + "signature": "parseJsonInt(string,string)", + "selector": "0x7b048ccd", + "selectorBytes": [ + 123, + 4, + 140, + 205 + ] + }, + "group": "json", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "parseJsonInt_1", + "description": "Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `int256`, or returns `defaultValue` if the key does not exist.", + "declaration": "function parseJsonInt(string calldata json, string calldata key, int256 defaultValue) external pure returns (int256);", + "visibility": "external", + "mutability": "pure", + "signature": "parseJsonInt(string,string,int256)", + "selector": "0xfa78931b", "selectorBytes": [ - 73, - 196, 250, - 200 + 120, + 147, + 27 + ] + }, + "group": "json", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "parseJsonKeys", + "description": "Returns an array of all the keys in a JSON object.", + "declaration": "function parseJsonKeys(string calldata json, string calldata key) external pure returns (string[] memory keys);", + "visibility": "external", + "mutability": "pure", + "signature": "parseJsonKeys(string,string)", + "selector": "0x213e4198", + "selectorBytes": [ + 33, + 62, + 65, + 152 ] }, "group": "json", @@ -7378,8 +8357,8 @@ }, { "func": { - "id": "parseJsonStringArray", - "description": "Parses a string of JSON data at `key` and coerces it to `string[]`.", + "id": "parseJsonStringArray_0", + "description": "Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `string[]`.", "declaration": "function parseJsonStringArray(string calldata json, string calldata key) external pure returns (string[] memory);", "visibility": "external", "mutability": "pure", @@ -7396,10 +8375,70 @@ "status": "stable", "safety": "safe" }, + { + "func": { + "id": "parseJsonStringArray_1", + "description": "Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `string[]`, or returns `defaultValue` if the key does not exist.", + "declaration": "function parseJsonStringArray(string calldata json, string calldata key, string[] calldata defaultValue) external pure returns (string[] memory);", + "visibility": "external", + "mutability": "pure", + "signature": "parseJsonStringArray(string,string,string[])", + "selector": "0x88b70aff", + "selectorBytes": [ + 136, + 183, + 10, + 255 + ] + }, + "group": "json", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "parseJsonString_0", + "description": "Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `string`.", + "declaration": "function parseJsonString(string calldata json, string calldata key) external pure returns (string memory);", + "visibility": "external", + "mutability": "pure", + "signature": "parseJsonString(string,string)", + "selector": "0x49c4fac8", + "selectorBytes": [ + 73, + 196, + 250, + 200 + ] + }, + "group": "json", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "parseJsonString_1", + "description": "Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `string`, or returns `defaultValue` if the key does not exist.", + "declaration": "function parseJsonString(string calldata json, string calldata key, string calldata defaultValue) external pure returns (string memory);", + "visibility": "external", + "mutability": "pure", + "signature": "parseJsonString(string,string,string)", + "selector": "0x9d7824a8", + "selectorBytes": [ + 157, + 120, + 36, + 168 + ] + }, + "group": "json", + "status": "stable", + "safety": "safe" + }, { "func": { "id": "parseJsonTypeArray", - "description": "Parses a string of JSON data at `key` and coerces it to type array corresponding to `typeDescription`.", + "description": "Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to type array corresponding to `typeDescription`.", "declaration": "function parseJsonTypeArray(string calldata json, string calldata key, string calldata typeDescription) external pure returns (bytes memory);", "visibility": "external", "mutability": "pure", @@ -7439,7 +8478,7 @@ { "func": { "id": "parseJsonType_1", - "description": "Parses a string of JSON data at `key` and coerces it to type corresponding to `typeDescription`.", + "description": "Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to type corresponding to `typeDescription`.", "declaration": "function parseJsonType(string calldata json, string calldata key, string calldata typeDescription) external pure returns (bytes memory);", "visibility": "external", "mutability": "pure", @@ -7458,8 +8497,48 @@ }, { "func": { - "id": "parseJsonUint", - "description": "Parses a string of JSON data at `key` and coerces it to `uint256`.", + "id": "parseJsonUintArray_0", + "description": "Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `uint256[]`.", + "declaration": "function parseJsonUintArray(string calldata json, string calldata key) external pure returns (uint256[] memory);", + "visibility": "external", + "mutability": "pure", + "signature": "parseJsonUintArray(string,string)", + "selector": "0x522074ab", + "selectorBytes": [ + 82, + 32, + 116, + 171 + ] + }, + "group": "json", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "parseJsonUintArray_1", + "description": "Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `uint256[]`, or returns `defaultValue` if the key does not exist.", + "declaration": "function parseJsonUintArray(string calldata json, string calldata key, uint256[] calldata defaultValue) external pure returns (uint256[] memory);", + "visibility": "external", + "mutability": "pure", + "signature": "parseJsonUintArray(string,string,uint256[])", + "selector": "0xa7b8152a", + "selectorBytes": [ + 167, + 184, + 21, + 42 + ] + }, + "group": "json", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "parseJsonUint_0", + "description": "Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `uint256`.", "declaration": "function parseJsonUint(string calldata json, string calldata key) external pure returns (uint256);", "visibility": "external", "mutability": "pure", @@ -7478,18 +8557,18 @@ }, { "func": { - "id": "parseJsonUintArray", - "description": "Parses a string of JSON data at `key` and coerces it to `uint256[]`.", - "declaration": "function parseJsonUintArray(string calldata json, string calldata key) external pure returns (uint256[] memory);", + "id": "parseJsonUint_1", + "description": "Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `uint256`, or returns `defaultValue` if the key does not exist.", + "declaration": "function parseJsonUint(string calldata json, string calldata key, uint256 defaultValue) external pure returns (uint256);", "visibility": "external", "mutability": "pure", - "signature": "parseJsonUintArray(string,string)", - "selector": "0x522074ab", + "signature": "parseJsonUint(string,string,uint256)", + "selector": "0xbeb87f44", "selectorBytes": [ - 82, - 32, - 116, - 171 + 190, + 184, + 127, + 68 ] }, "group": "json", @@ -7519,7 +8598,7 @@ { "func": { "id": "parseJson_1", - "description": "ABI-encodes a JSON object at `key`.", + "description": "ABI-encodes a JSON object at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields).", "declaration": "function parseJson(string calldata json, string calldata key) external pure returns (bytes memory abiEncodedData);", "visibility": "external", "mutability": "pure", @@ -7538,7 +8617,47 @@ }, { "func": { - "id": "parseTomlAddress", + "id": "parseTomlAddressArray_0", + "description": "Parses a string of TOML data at `key` and coerces it to `address[]`.", + "declaration": "function parseTomlAddressArray(string calldata toml, string calldata key) external pure returns (address[] memory);", + "visibility": "external", + "mutability": "pure", + "signature": "parseTomlAddressArray(string,string)", + "selector": "0x65c428e7", + "selectorBytes": [ + 101, + 196, + 40, + 231 + ] + }, + "group": "toml", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "parseTomlAddressArray_1", + "description": "Parses a string of TOML data at `key` and coerces it to `address[]`, or returns `defaultValue` if the key does not exist.", + "declaration": "function parseTomlAddressArray(string calldata toml, string calldata key, address[] calldata defaultValue) external pure returns (address[] memory);", + "visibility": "external", + "mutability": "pure", + "signature": "parseTomlAddressArray(string,string,address[])", + "selector": "0xcbfdcaa6", + "selectorBytes": [ + 203, + 253, + 202, + 166 + ] + }, + "group": "toml", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "parseTomlAddress_0", "description": "Parses a string of TOML data at `key` and coerces it to `address`.", "declaration": "function parseTomlAddress(string calldata toml, string calldata key) external pure returns (address);", "visibility": "external", @@ -7546,10 +8665,170 @@ "signature": "parseTomlAddress(string,string)", "selector": "0x65e7c844", "selectorBytes": [ - 101, - 231, - 200, - 68 + 101, + 231, + 200, + 68 + ] + }, + "group": "toml", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "parseTomlAddress_1", + "description": "Parses a string of TOML data at `key` and coerces it to `address`, or returns `defaultValue` if the key does not exist.", + "declaration": "function parseTomlAddress(string calldata toml, string calldata key, address defaultValue) external pure returns (address);", + "visibility": "external", + "mutability": "pure", + "signature": "parseTomlAddress(string,string,address)", + "selector": "0x80db71c1", + "selectorBytes": [ + 128, + 219, + 113, + 193 + ] + }, + "group": "toml", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "parseTomlBoolArray_0", + "description": "Parses a string of TOML data at `key` and coerces it to `bool[]`.", + "declaration": "function parseTomlBoolArray(string calldata toml, string calldata key) external pure returns (bool[] memory);", + "visibility": "external", + "mutability": "pure", + "signature": "parseTomlBoolArray(string,string)", + "selector": "0x127cfe9a", + "selectorBytes": [ + 18, + 124, + 254, + 154 + ] + }, + "group": "toml", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "parseTomlBoolArray_1", + "description": "Parses a string of TOML data at `key` and coerces it to `bool[]`, or returns `defaultValue` if the key does not exist.", + "declaration": "function parseTomlBoolArray(string calldata toml, string calldata key, bool[] calldata defaultValue) external pure returns (bool[] memory);", + "visibility": "external", + "mutability": "pure", + "signature": "parseTomlBoolArray(string,string,bool[])", + "selector": "0x50ddbb4e", + "selectorBytes": [ + 80, + 221, + 187, + 78 + ] + }, + "group": "toml", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "parseTomlBool_0", + "description": "Parses a string of TOML data at `key` and coerces it to `bool`.", + "declaration": "function parseTomlBool(string calldata toml, string calldata key) external pure returns (bool);", + "visibility": "external", + "mutability": "pure", + "signature": "parseTomlBool(string,string)", + "selector": "0xd30dced6", + "selectorBytes": [ + 211, + 13, + 206, + 214 + ] + }, + "group": "toml", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "parseTomlBool_1", + "description": "Parses a string of TOML data at `key` and coerces it to `bool`, or returns `defaultValue` if the key does not exist.", + "declaration": "function parseTomlBool(string calldata toml, string calldata key, bool defaultValue) external pure returns (bool);", + "visibility": "external", + "mutability": "pure", + "signature": "parseTomlBool(string,string,bool)", + "selector": "0x6c254276", + "selectorBytes": [ + 108, + 37, + 66, + 118 + ] + }, + "group": "toml", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "parseTomlBytes32Array_0", + "description": "Parses a string of TOML data at `key` and coerces it to `bytes32[]`.", + "declaration": "function parseTomlBytes32Array(string calldata toml, string calldata key) external pure returns (bytes32[] memory);", + "visibility": "external", + "mutability": "pure", + "signature": "parseTomlBytes32Array(string,string)", + "selector": "0x3e716f81", + "selectorBytes": [ + 62, + 113, + 111, + 129 + ] + }, + "group": "toml", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "parseTomlBytes32Array_1", + "description": "Parses a string of TOML data at `key` and coerces it to `bytes32[]`, or returns `defaultValue` if the key does not exist.", + "declaration": "function parseTomlBytes32Array(string calldata toml, string calldata key, bytes32[] calldata defaultValue) external pure returns (bytes32[] memory);", + "visibility": "external", + "mutability": "pure", + "signature": "parseTomlBytes32Array(string,string,bytes32[])", + "selector": "0x36ad14a2", + "selectorBytes": [ + 54, + 173, + 20, + 162 + ] + }, + "group": "toml", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "parseTomlBytes32_0", + "description": "Parses a string of TOML data at `key` and coerces it to `bytes32`.", + "declaration": "function parseTomlBytes32(string calldata toml, string calldata key) external pure returns (bytes32);", + "visibility": "external", + "mutability": "pure", + "signature": "parseTomlBytes32(string,string)", + "selector": "0x8e214810", + "selectorBytes": [ + 142, + 33, + 72, + 16 ] }, "group": "toml", @@ -7558,18 +8837,18 @@ }, { "func": { - "id": "parseTomlAddressArray", - "description": "Parses a string of TOML data at `key` and coerces it to `address[]`.", - "declaration": "function parseTomlAddressArray(string calldata toml, string calldata key) external pure returns (address[] memory);", + "id": "parseTomlBytes32_1", + "description": "Parses a string of TOML data at `key` and coerces it to `bytes32`, or returns `defaultValue` if the key does not exist.", + "declaration": "function parseTomlBytes32(string calldata toml, string calldata key, bytes32 defaultValue) external pure returns (bytes32);", "visibility": "external", "mutability": "pure", - "signature": "parseTomlAddressArray(string,string)", - "selector": "0x65c428e7", + "signature": "parseTomlBytes32(string,string,bytes32)", + "selector": "0x88d60faa", "selectorBytes": [ - 101, - 196, - 40, - 231 + 136, + 214, + 15, + 170 ] }, "group": "toml", @@ -7578,18 +8857,18 @@ }, { "func": { - "id": "parseTomlBool", - "description": "Parses a string of TOML data at `key` and coerces it to `bool`.", - "declaration": "function parseTomlBool(string calldata toml, string calldata key) external pure returns (bool);", + "id": "parseTomlBytesArray_0", + "description": "Parses a string of TOML data at `key` and coerces it to `bytes[]`.", + "declaration": "function parseTomlBytesArray(string calldata toml, string calldata key) external pure returns (bytes[] memory);", "visibility": "external", "mutability": "pure", - "signature": "parseTomlBool(string,string)", - "selector": "0xd30dced6", + "signature": "parseTomlBytesArray(string,string)", + "selector": "0xb197c247", "selectorBytes": [ - 211, - 13, - 206, - 214 + 177, + 151, + 194, + 71 ] }, "group": "toml", @@ -7598,18 +8877,18 @@ }, { "func": { - "id": "parseTomlBoolArray", - "description": "Parses a string of TOML data at `key` and coerces it to `bool[]`.", - "declaration": "function parseTomlBoolArray(string calldata toml, string calldata key) external pure returns (bool[] memory);", + "id": "parseTomlBytesArray_1", + "description": "Parses a string of TOML data at `key` and coerces it to `bytes[]`, or returns `defaultValue` if the key does not exist.", + "declaration": "function parseTomlBytesArray(string calldata toml, string calldata key, bytes[] calldata defaultValue) external pure returns (bytes[] memory);", "visibility": "external", "mutability": "pure", - "signature": "parseTomlBoolArray(string,string)", - "selector": "0x127cfe9a", + "signature": "parseTomlBytesArray(string,string,bytes[])", + "selector": "0x7daa5e28", "selectorBytes": [ - 18, - 124, - 254, - 154 + 125, + 170, + 94, + 40 ] }, "group": "toml", @@ -7618,7 +8897,7 @@ }, { "func": { - "id": "parseTomlBytes", + "id": "parseTomlBytes_0", "description": "Parses a string of TOML data at `key` and coerces it to `bytes`.", "declaration": "function parseTomlBytes(string calldata toml, string calldata key) external pure returns (bytes memory);", "visibility": "external", @@ -7638,18 +8917,18 @@ }, { "func": { - "id": "parseTomlBytes32", - "description": "Parses a string of TOML data at `key` and coerces it to `bytes32`.", - "declaration": "function parseTomlBytes32(string calldata toml, string calldata key) external pure returns (bytes32);", + "id": "parseTomlBytes_1", + "description": "Parses a string of TOML data at `key` and coerces it to `bytes`, or returns `defaultValue` if the key does not exist.", + "declaration": "function parseTomlBytes(string calldata toml, string calldata key, bytes calldata defaultValue) external pure returns (bytes memory);", "visibility": "external", "mutability": "pure", - "signature": "parseTomlBytes32(string,string)", - "selector": "0x8e214810", + "signature": "parseTomlBytes(string,string,bytes)", + "selector": "0xc37b8f00", "selectorBytes": [ - 142, - 33, - 72, - 16 + 195, + 123, + 143, + 0 ] }, "group": "toml", @@ -7658,18 +8937,18 @@ }, { "func": { - "id": "parseTomlBytes32Array", - "description": "Parses a string of TOML data at `key` and coerces it to `bytes32[]`.", - "declaration": "function parseTomlBytes32Array(string calldata toml, string calldata key) external pure returns (bytes32[] memory);", + "id": "parseTomlIntArray_0", + "description": "Parses a string of TOML data at `key` and coerces it to `int256[]`.", + "declaration": "function parseTomlIntArray(string calldata toml, string calldata key) external pure returns (int256[] memory);", "visibility": "external", "mutability": "pure", - "signature": "parseTomlBytes32Array(string,string)", - "selector": "0x3e716f81", + "signature": "parseTomlIntArray(string,string)", + "selector": "0xd3522ae6", "selectorBytes": [ - 62, - 113, - 111, - 129 + 211, + 82, + 42, + 230 ] }, "group": "toml", @@ -7678,18 +8957,18 @@ }, { "func": { - "id": "parseTomlBytesArray", - "description": "Parses a string of TOML data at `key` and coerces it to `bytes[]`.", - "declaration": "function parseTomlBytesArray(string calldata toml, string calldata key) external pure returns (bytes[] memory);", + "id": "parseTomlIntArray_1", + "description": "Parses a string of TOML data at `key` and coerces it to `int256[]`, or returns `defaultValue` if the key does not exist.", + "declaration": "function parseTomlIntArray(string calldata toml, string calldata key, int256[] calldata defaultValue) external pure returns (int256[] memory);", "visibility": "external", "mutability": "pure", - "signature": "parseTomlBytesArray(string,string)", - "selector": "0xb197c247", + "signature": "parseTomlIntArray(string,string,int256[])", + "selector": "0xfcc0379f", "selectorBytes": [ - 177, - 151, - 194, - 71 + 252, + 192, + 55, + 159 ] }, "group": "toml", @@ -7698,7 +8977,7 @@ }, { "func": { - "id": "parseTomlInt", + "id": "parseTomlInt_0", "description": "Parses a string of TOML data at `key` and coerces it to `int256`.", "declaration": "function parseTomlInt(string calldata toml, string calldata key) external pure returns (int256);", "visibility": "external", @@ -7718,18 +8997,18 @@ }, { "func": { - "id": "parseTomlIntArray", - "description": "Parses a string of TOML data at `key` and coerces it to `int256[]`.", - "declaration": "function parseTomlIntArray(string calldata toml, string calldata key) external pure returns (int256[] memory);", + "id": "parseTomlInt_1", + "description": "Parses a string of TOML data at `key` and coerces it to `int256`, or returns `defaultValue` if the key does not exist.", + "declaration": "function parseTomlInt(string calldata toml, string calldata key, int256 defaultValue) external pure returns (int256);", "visibility": "external", "mutability": "pure", - "signature": "parseTomlIntArray(string,string)", - "selector": "0xd3522ae6", + "signature": "parseTomlInt(string,string,int256)", + "selector": "0x887da112", "selectorBytes": [ - 211, - 82, - 42, - 230 + 136, + 125, + 161, + 18 ] }, "group": "toml", @@ -7758,7 +9037,47 @@ }, { "func": { - "id": "parseTomlString", + "id": "parseTomlStringArray_0", + "description": "Parses a string of TOML data at `key` and coerces it to `string[]`.", + "declaration": "function parseTomlStringArray(string calldata toml, string calldata key) external pure returns (string[] memory);", + "visibility": "external", + "mutability": "pure", + "signature": "parseTomlStringArray(string,string)", + "selector": "0x9f629281", + "selectorBytes": [ + 159, + 98, + 146, + 129 + ] + }, + "group": "toml", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "parseTomlStringArray_1", + "description": "Parses a string of TOML data at `key` and coerces it to `string[]`, or returns `defaultValue` if the key does not exist.", + "declaration": "function parseTomlStringArray(string calldata toml, string calldata key, string[] calldata defaultValue) external pure returns (string[] memory);", + "visibility": "external", + "mutability": "pure", + "signature": "parseTomlStringArray(string,string,string[])", + "selector": "0x6be16ce7", + "selectorBytes": [ + 107, + 225, + 108, + 231 + ] + }, + "group": "toml", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "parseTomlString_0", "description": "Parses a string of TOML data at `key` and coerces it to `string`.", "declaration": "function parseTomlString(string calldata toml, string calldata key) external pure returns (string memory);", "visibility": "external", @@ -7778,18 +9097,18 @@ }, { "func": { - "id": "parseTomlStringArray", - "description": "Parses a string of TOML data at `key` and coerces it to `string[]`.", - "declaration": "function parseTomlStringArray(string calldata toml, string calldata key) external pure returns (string[] memory);", + "id": "parseTomlString_1", + "description": "Parses a string of TOML data at `key` and coerces it to `string`, or returns `defaultValue` if the key does not exist.", + "declaration": "function parseTomlString(string calldata toml, string calldata key, string calldata defaultValue) external pure returns (string memory);", "visibility": "external", "mutability": "pure", - "signature": "parseTomlStringArray(string,string)", - "selector": "0x9f629281", + "signature": "parseTomlString(string,string,string)", + "selector": "0xc32ef60d", "selectorBytes": [ - 159, - 98, - 146, - 129 + 195, + 46, + 246, + 13 ] }, "group": "toml", @@ -7858,7 +9177,47 @@ }, { "func": { - "id": "parseTomlUint", + "id": "parseTomlUintArray_0", + "description": "Parses a string of TOML data at `key` and coerces it to `uint256[]`.", + "declaration": "function parseTomlUintArray(string calldata toml, string calldata key) external pure returns (uint256[] memory);", + "visibility": "external", + "mutability": "pure", + "signature": "parseTomlUintArray(string,string)", + "selector": "0xb5df27c8", + "selectorBytes": [ + 181, + 223, + 39, + 200 + ] + }, + "group": "toml", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "parseTomlUintArray_1", + "description": "Parses a string of TOML data at `key` and coerces it to `uint256[]`, or returns `defaultValue` if the key does not exist.", + "declaration": "function parseTomlUintArray(string calldata toml, string calldata key, uint256[] calldata defaultValue) external pure returns (uint256[] memory);", + "visibility": "external", + "mutability": "pure", + "signature": "parseTomlUintArray(string,string,uint256[])", + "selector": "0x5fc56675", + "selectorBytes": [ + 95, + 197, + 102, + 117 + ] + }, + "group": "toml", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "parseTomlUint_0", "description": "Parses a string of TOML data at `key` and coerces it to `uint256`.", "declaration": "function parseTomlUint(string calldata toml, string calldata key) external pure returns (uint256);", "visibility": "external", @@ -7878,18 +9237,18 @@ }, { "func": { - "id": "parseTomlUintArray", - "description": "Parses a string of TOML data at `key` and coerces it to `uint256[]`.", - "declaration": "function parseTomlUintArray(string calldata toml, string calldata key) external pure returns (uint256[] memory);", + "id": "parseTomlUint_1", + "description": "Parses a string of TOML data at `key` and coerces it to `uint256`, or returns `defaultValue` if the key does not exist.", + "declaration": "function parseTomlUint(string calldata toml, string calldata key, uint256 defaultValue) external pure returns (uint256);", "visibility": "external", "mutability": "pure", - "signature": "parseTomlUintArray(string,string)", - "selector": "0xb5df27c8", + "signature": "parseTomlUint(string,string,uint256)", + "selector": "0x73317cb5", "selectorBytes": [ - 181, - 223, - 39, - 200 + 115, + 49, + 124, + 181 ] }, "group": "toml", @@ -8236,6 +9595,26 @@ "status": "stable", "safety": "safe" }, + { + "func": { + "id": "publicKeyEd25519", + "description": "Derives the Ed25519 public key from a private key.", + "declaration": "function publicKeyEd25519(bytes32 privateKey) external pure returns (bytes32 publicKey);", + "visibility": "external", + "mutability": "pure", + "signature": "publicKeyEd25519(bytes32)", + "selector": "0x27f44236", + "selectorBytes": [ + 39, + 244, + 66, + 54 + ] + }, + "group": "crypto", + "status": "stable", + "safety": "safe" + }, { "func": { "id": "publicKeyP256", @@ -8260,9 +9639,9 @@ "func": { "id": "randomAddress", "description": "Returns a random `address`.", - "declaration": "function randomAddress() external returns (address);", + "declaration": "function randomAddress() external view returns (address);", "visibility": "external", - "mutability": "", + "mutability": "view", "signature": "randomAddress()", "selector": "0xd5bee9f5", "selectorBytes": [ @@ -8400,9 +9779,9 @@ "func": { "id": "randomUint_0", "description": "Returns a random uint256 value.", - "declaration": "function randomUint() external returns (uint256);", + "declaration": "function randomUint() external view returns (uint256);", "visibility": "external", - "mutability": "", + "mutability": "view", "signature": "randomUint()", "selector": "0x25124730", "selectorBytes": [ @@ -8420,9 +9799,9 @@ "func": { "id": "randomUint_1", "description": "Returns random uint256 value between the provided range (=min..=max).", - "declaration": "function randomUint(uint256 min, uint256 max) external returns (uint256);", + "declaration": "function randomUint(uint256 min, uint256 max) external view returns (uint256);", "visibility": "external", - "mutability": "", + "mutability": "view", "signature": "randomUint(uint256,uint256)", "selector": "0xd61b051b", "selectorBytes": [ @@ -8460,9 +9839,9 @@ "func": { "id": "readCallers", "description": "Reads the current `msg.sender` and `tx.origin` from state and reports if there is any active caller modification.", - "declaration": "function readCallers() external returns (CallerMode callerMode, address msgSender, address txOrigin);", + "declaration": "function readCallers() external view returns (CallerMode callerMode, address msgSender, address txOrigin);", "visibility": "external", - "mutability": "", + "mutability": "view", "signature": "readCallers()", "selector": "0x4ad0bac9", "selectorBytes": [ @@ -8654,7 +10033,67 @@ }, "group": "evm", "status": "stable", - "safety": "safe" + "safety": "safe" + }, + { + "func": { + "id": "registerMappingSstoreHook", + "description": "Registers a callback after exact mapping-element SSTOREs rooted at `rootSlot` in `target`'s effective storage account.\nThe callback signature is `function(address account, bytes32 computedSlot, bytes32 rootSlot,\nbytes32[] keys, bytes32 oldValue, bytes32 newValue) external`; keys are raw words in\nroot-to-leaf order. Only complete 64-byte Keccak chains observed after the latest mapping\nhook registration for `target` in the current top-level execution match; provenance is\ncleared between top-level executions. Resolution follows the complete chain to its terminal\nroot and ignores registered intermediate hashes. Offsets, incomplete or unknown chains,\nhashes computed before registration or in an earlier top-level execution, and source layouts\ndo not match. The contract that calls this cheatcode receives the callback. Registration\npersists across reverts and replaces the same target/root callback; callback state rolls back\nwith its enclosing context, callback reverts propagate, and hooks are suppressed throughout\ncallback subtrees. The callback must authenticate `msg.sender == address(vm)` to prevent\nexternal spoofing. Raw and mapping SSTORE hooks conflict per target, while multiple mapping\nroots may be registered.", + "declaration": "function registerMappingSstoreHook(address target, bytes32 rootSlot, bytes4 callback) external;", + "visibility": "external", + "mutability": "", + "signature": "registerMappingSstoreHook(address,bytes32,bytes4)", + "selector": "0x12d14123", + "selectorBytes": [ + 18, + 209, + 65, + 35 + ] + }, + "group": "evm", + "status": "stable", + "safety": "unsafe" + }, + { + "func": { + "id": "registerSloadHook", + "description": "Registers a callback invoked after each SLOAD against `target`'s effective storage account,\nincluding when its code runs by delegatecall.\nThe callback must have the signature `function(address,bytes32,bytes32) external`.\nRegistering another callback for the same target and access kind replaces it. Registration\nsurvives EVM reverts, while callback state follows the enclosing EVM context and rolls back\nwith it. Callback reverts propagate through the storage operation. Hooks are suppressed in\nthe callback and its entire call subtree. The callback must authenticate\n`msg.sender == address(vm)` to prevent external spoofing. Callback execution is hidden from\nmocks, expectations, log recording, and storage-access recording. It does not inherit\nstaticness. The callback runs as an ordinary call frame and consumes one of the 1024\nprotocol call-depth slots; a load at the maximum legal call depth can have its callback\nrejected as too deep, propagating as a failure of the load.", + "declaration": "function registerSloadHook(address target, bytes4 callback) external;", + "visibility": "external", + "mutability": "", + "signature": "registerSloadHook(address,bytes4)", + "selector": "0x37c7cf5e", + "selectorBytes": [ + 55, + 199, + 207, + 94 + ] + }, + "group": "evm", + "status": "stable", + "safety": "unsafe" + }, + { + "func": { + "id": "registerSstoreHook", + "description": "Registers a callback invoked after each SSTORE against `target`'s effective storage account,\nincluding when its code runs by delegatecall.\nThe callback must have the signature `function(address,bytes32,bytes32,bytes32) external`.\nRegistering another callback for the same target and access kind replaces it. Registration\nsurvives EVM reverts, while callback state follows the enclosing EVM context and rolls back\nwith it. Callback reverts propagate through the storage operation. Hooks are suppressed in\nthe callback and its entire call subtree. The callback must authenticate\n`msg.sender == address(vm)` to prevent external spoofing. Callback execution is hidden from\nmocks, expectations, log recording, and storage-access recording. It does not inherit\nstaticness. The callback runs as an ordinary call frame and consumes one of the 1024\nprotocol call-depth slots; a store at the maximum legal call depth can have its callback\nrejected as too deep, propagating as a failure of the store.", + "declaration": "function registerSstoreHook(address target, bytes4 callback) external;", + "visibility": "external", + "mutability": "", + "signature": "registerSstoreHook(address,bytes4)", + "selector": "0xe0c3bdd9", + "selectorBytes": [ + 224, + 195, + 189, + 217 + ] + }, + "group": "evm", + "status": "stable", + "safety": "unsafe" }, { "func": { @@ -8816,6 +10255,26 @@ "status": "stable", "safety": "unsafe" }, + { + "func": { + "id": "resolveEnv", + "description": "Resolves the env variable placeholders of a given input string.", + "declaration": "function resolveEnv(string calldata input) external returns (string memory);", + "visibility": "external", + "mutability": "", + "signature": "resolveEnv(string)", + "selector": "0xddd2128d", + "selectorBytes": [ + 221, + 210, + 18, + 141 + ] + }, + "group": "environment", + "status": "stable", + "safety": "safe" + }, { "func": { "id": "resumeGasMetering", @@ -9080,6 +10539,66 @@ "status": "stable", "safety": "unsafe" }, + { + "func": { + "id": "rollSlot", + "description": "Sets `block.slotnum` without changing the block number or timestamp.\nNot available on EVM versions before Amsterdam.\nIf used on unsupported EVM versions it will revert.", + "declaration": "function rollSlot(uint64 newSlotNumber) external;", + "visibility": "external", + "mutability": "", + "signature": "rollSlot(uint64)", + "selector": "0xf53fbdc4", + "selectorBytes": [ + 245, + 63, + 189, + 196 + ] + }, + "group": "evm", + "status": "stable", + "safety": "unsafe" + }, + { + "func": { + "id": "rpcJson_0", + "description": "Performs an Ethereum JSON-RPC request to the current fork URL and returns the JSON result.", + "declaration": "function rpcJson(string calldata method, string calldata params) external returns (string memory data);", + "visibility": "external", + "mutability": "", + "signature": "rpcJson(string,string)", + "selector": "0x9519f228", + "selectorBytes": [ + 149, + 25, + 242, + 40 + ] + }, + "group": "evm", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "rpcJson_1", + "description": "Performs an Ethereum JSON-RPC request to the given endpoint and returns the JSON result.", + "declaration": "function rpcJson(string calldata urlOrAlias, string calldata method, string calldata params) external returns (string memory data);", + "visibility": "external", + "mutability": "", + "signature": "rpcJson(string,string,string)", + "selector": "0x273b74f8", + "selectorBytes": [ + 39, + 59, + 116, + 248 + ] + }, + "group": "evm", + "status": "stable", + "safety": "safe" + }, { "func": { "id": "rpcUrl", @@ -9578,7 +11097,7 @@ }, "group": "utilities", "status": "stable", - "safety": "safe" + "safety": "unsafe" }, { "func": { @@ -9598,7 +11117,7 @@ }, "group": "utilities", "status": "stable", - "safety": "safe" + "safety": "unsafe" }, { "func": { @@ -9640,6 +11159,46 @@ "status": "stable", "safety": "safe" }, + { + "func": { + "id": "setEvmVersion", + "description": "Selects the test or script execution hardfork, e.g. `berlin`, `cancun`, or `tempo:T7`.\nUses the active network's version mappings and gas schedule. Does not change the network\nor Solidity compiler target. On Tempo, runtime changes do not rebuild instructions or\nprecompiles; configure `hardfork` before execution to select a different revision.\n**Note:** The execution evm version is not the same as the compilation one.", + "declaration": "function setEvmVersion(string calldata evm) external;", + "visibility": "external", + "mutability": "", + "signature": "setEvmVersion(string)", + "selector": "0x43179f5a", + "selectorBytes": [ + 67, + 23, + 159, + 90 + ] + }, + "group": "evm", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "setLogoURI", + "description": "Sets a TIP-20 token's logo URI directly in storage.\nThis bypasses the token admin check, but still validates the URI against T5 constraints.", + "declaration": "function setLogoURI(address token, string calldata newLogoURI) external;", + "visibility": "external", + "mutability": "", + "signature": "setLogoURI(address,string)", + "selector": "0x256edb36", + "selectorBytes": [ + 37, + 110, + 219, + 54 + ] + }, + "group": "evm", + "status": "stable", + "safety": "unsafe" + }, { "func": { "id": "setNonce", @@ -9700,6 +11259,26 @@ "status": "stable", "safety": "safe" }, + { + "func": { + "id": "setTip20LogoURI", + "description": "Sets a TIP-20 token's logo URI directly in storage.\nThis bypasses the token admin check, but still validates the URI against T5 constraints.", + "declaration": "function setTip20LogoURI(address token, string calldata newLogoURI) external;", + "visibility": "external", + "mutability": "", + "signature": "setTip20LogoURI(address,string)", + "selector": "0xb452dc23", + "selectorBytes": [ + 180, + 82, + 220, + 35 + ] + }, + "group": "evm", + "status": "stable", + "safety": "unsafe" + }, { "func": { "id": "shuffle", @@ -9784,9 +11363,9 @@ "func": { "id": "signCompact_0", "description": "Signs data with a `Wallet`.\nReturns a compact signature (`r`, `vs`) as per EIP-2098, where `vs` encodes both the\nsignature's `s` value, and the recovery id `v` in a single bytes32.\nThis format reduces the signature size from 65 to 64 bytes.", - "declaration": "function signCompact(Wallet calldata wallet, bytes32 digest) external returns (bytes32 r, bytes32 vs);", + "declaration": "function signCompact(Wallet calldata wallet, bytes32 digest) external pure returns (bytes32 r, bytes32 vs);", "visibility": "external", - "mutability": "", + "mutability": "pure", "signature": "signCompact((address,uint256,uint256,uint256),bytes32)", "selector": "0x3d0e292f", "selectorBytes": [ @@ -9920,6 +11499,66 @@ "status": "stable", "safety": "safe" }, + { + "func": { + "id": "signEd25519", + "description": "Signs a message with namespace using Ed25519.\nThe signature covers namespace || message for domain separation.\nReturns a 64-byte Ed25519 signature.", + "declaration": "function signEd25519(bytes calldata namespace, bytes calldata message, bytes32 privateKey) external pure returns (bytes memory signature);", + "visibility": "external", + "mutability": "pure", + "signature": "signEd25519(bytes,bytes,bytes32)", + "selector": "0xef609c65", + "selectorBytes": [ + 239, + 96, + 156, + 101 + ] + }, + "group": "crypto", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "signKeychain", + "description": "Signs `digest` as a Tempo V2 keychain signature for `account` using a secp256k1 access key.\nReturns the encoded signature bytes accepted by `SignatureVerifier.verifyKeychain`.", + "declaration": "function signKeychain(uint256 privateKey, address account, bytes32 digest) external pure returns (bytes memory signature);", + "visibility": "external", + "mutability": "pure", + "signature": "signKeychain(uint256,address,bytes32)", + "selector": "0x5804c690", + "selectorBytes": [ + 88, + 4, + 198, + 144 + ] + }, + "group": "crypto", + "status": "stable", + "safety": "safe" + }, + { + "func": { + "id": "signKeychainAdmin", + "description": "Signs `digest` as a Tempo V2 keychain signature for `account` using a root or admin secp256k1 key.\nReturns the encoded signature bytes accepted by `SignatureVerifier.verifyKeychainAdmin`.\nThe supplied `digest` should already be domain-separated with chain ID, contract address,\nand account address.", + "declaration": "function signKeychainAdmin(uint256 privateKey, address account, bytes32 digest) external pure returns (bytes memory signature);", + "visibility": "external", + "mutability": "pure", + "signature": "signKeychainAdmin(uint256,address,bytes32)", + "selector": "0xbc5fb2f7", + "selectorBytes": [ + 188, + 95, + 178, + 247 + ] + }, + "group": "crypto", + "status": "stable", + "safety": "safe" + }, { "func": { "id": "signP256", @@ -9940,13 +11579,33 @@ "status": "stable", "safety": "safe" }, + { + "func": { + "id": "signWithNonceUnsafe", + "description": "Signs `digest` with `privateKey` on the secp256k1 curve, using the given `nonce`\nas the raw ephemeral k value in ECDSA (instead of deriving it deterministically).", + "declaration": "function signWithNonceUnsafe(uint256 privateKey, bytes32 digest, uint256 nonce) external pure returns (uint8 v, bytes32 r, bytes32 s);", + "visibility": "external", + "mutability": "pure", + "signature": "signWithNonceUnsafe(uint256,bytes32,uint256)", + "selector": "0x2012783a", + "selectorBytes": [ + 32, + 18, + 120, + 58 + ] + }, + "group": "crypto", + "status": "stable", + "safety": "safe" + }, { "func": { "id": "sign_0", "description": "Signs data with a `Wallet`.", - "declaration": "function sign(Wallet calldata wallet, bytes32 digest) external returns (uint8 v, bytes32 r, bytes32 s);", + "declaration": "function sign(Wallet calldata wallet, bytes32 digest) external pure returns (uint8 v, bytes32 r, bytes32 s);", "visibility": "external", - "mutability": "", + "mutability": "pure", "signature": "sign((address,uint256,uint256,uint256),bytes32)", "selector": "0xb25c5a25", "selectorBytes": [ @@ -10105,7 +11764,7 @@ { "func": { "id": "snapshotGasLastCall_0", - "description": "Snapshot capture the gas usage of the last call by name from the callee perspective.", + "description": "DEPRECATED: use `snapshotGasLastFrame` instead.\nSnapshot capture the gas usage of the last call by name from the callee perspective.\nWithout isolation, measures regular counter consumption, including spillover but excluding reservoir-funded state gas.\nIsolated frames with zero net state gas use receipt gas; see .", "declaration": "function snapshotGasLastCall(string calldata name) external returns (uint256 gasUsed);", "visibility": "external", "mutability": "", @@ -10119,13 +11778,15 @@ ] }, "group": "evm", - "status": "stable", + "status": { + "deprecated": "replaced by `snapshotGasLastFrame`" + }, "safety": "unsafe" }, { "func": { "id": "snapshotGasLastCall_1", - "description": "Snapshot capture the gas usage of the last call by name in a group from the callee perspective.", + "description": "DEPRECATED: use `snapshotGasLastFrame` instead.\nSnapshot capture the gas usage of the last call by name in a group from the callee perspective.\nWithout isolation, measures regular counter consumption, including spillover but excluding reservoir-funded state gas.\nIsolated frames with zero net state gas use receipt gas; see .", "declaration": "function snapshotGasLastCall(string calldata group, string calldata name) external returns (uint256 gasUsed);", "visibility": "external", "mutability": "", @@ -10139,6 +11800,48 @@ ] }, "group": "evm", + "status": { + "deprecated": "replaced by `snapshotGasLastFrame`" + }, + "safety": "unsafe" + }, + { + "func": { + "id": "snapshotGasLastFrame_0", + "description": "Snapshot capture the gas usage of the last call or create by name from the callee perspective.\nWithout isolation, measures regular counter consumption, including spillover but excluding reservoir-funded state gas.\nIsolated frames with zero net state gas use receipt gas; see .", + "declaration": "function snapshotGasLastFrame(string calldata name) external returns (uint256 gasUsed);", + "visibility": "external", + "mutability": "", + "signature": "snapshotGasLastFrame(string)", + "selector": "0xb95207cc", + "selectorBytes": [ + 185, + 82, + 7, + 204 + ] + }, + "group": "evm", + "status": "stable", + "safety": "unsafe" + }, + { + "func": { + "id": "snapshotGasLastFrame_1", + "description": "Snapshot capture the gas usage of the last call or create by name in a group from the callee perspective.\nWithout isolation, measures regular counter consumption, including spillover but excluding reservoir-funded state gas.\nIsolated frames with zero net state gas use receipt gas; see .", + "declaration": "function snapshotGasLastFrame(string calldata group, string calldata name) external returns (uint256 gasUsed);", + "visibility": "external", + "mutability": "", + "signature": "snapshotGasLastFrame(string,string)", + "selector": "0x04eedcdf", + "selectorBytes": [ + 4, + 238, + 220, + 223 + ] + }, + "group": "evm", "status": "stable", "safety": "unsafe" }, @@ -10325,7 +12028,7 @@ { "func": { "id": "startMappingRecording", - "description": "Starts recording all map SSTOREs for later retrieval.", + "description": "Starts recording mapping SSTOREs for later retrieval.", "declaration": "function startMappingRecording() external;", "visibility": "external", "mutability": "", @@ -10425,7 +12128,7 @@ { "func": { "id": "startSnapshotGas_0", - "description": "Start a snapshot capture of the current gas usage by name.\nThe group name is derived from the contract name.", + "description": "Start a snapshot capture of the current gas usage by name.\nThe group name is derived from the contract name.\nMeasures gas consumed from the regular counter, including state spillover but excluding reservoir-funded state gas.", "declaration": "function startSnapshotGas(string calldata name) external;", "visibility": "external", "mutability": "", @@ -10445,7 +12148,7 @@ { "func": { "id": "startSnapshotGas_1", - "description": "Start a snapshot capture of the current gas usage by name in a group.", + "description": "Start a snapshot capture of the current gas usage by name in a group.\nMeasures gas consumed from the regular counter, including state spillover but excluding reservoir-funded state gas.", "declaration": "function startSnapshotGas(string calldata group, string calldata name) external;", "visibility": "external", "mutability": "", @@ -10565,7 +12268,7 @@ { "func": { "id": "stopMappingRecording", - "description": "Stops recording all map SSTOREs for later retrieval and clears the recorded data.", + "description": "Stops recording mapping SSTOREs and clears the recorded data.", "declaration": "function stopMappingRecording() external;", "visibility": "external", "mutability": "", @@ -10625,7 +12328,7 @@ { "func": { "id": "stopSnapshotGas_0", - "description": "Stop the snapshot capture of the current gas by latest snapshot name, capturing the gas used since the start.", + "description": "Stop the snapshot capture of the current gas by latest snapshot name, capturing the gas used since the start.\nMeasures gas consumed from the regular counter, including state spillover but excluding reservoir-funded state gas.", "declaration": "function stopSnapshotGas() external returns (uint256 gasUsed);", "visibility": "external", "mutability": "", @@ -10645,7 +12348,7 @@ { "func": { "id": "stopSnapshotGas_1", - "description": "Stop the snapshot capture of the current gas usage by name, capturing the gas used since the start.\nThe group name is derived from the contract name.", + "description": "Stop the snapshot capture of the current gas usage by name, capturing the gas used since the start.\nThe group name is derived from the contract name.\nMeasures gas consumed from the regular counter, including state spillover but excluding reservoir-funded state gas.", "declaration": "function stopSnapshotGas(string calldata name) external returns (uint256 gasUsed);", "visibility": "external", "mutability": "", @@ -10665,7 +12368,7 @@ { "func": { "id": "stopSnapshotGas_2", - "description": "Stop the snapshot capture of the current gas usage by name in a group, capturing the gas used since the start.", + "description": "Stop the snapshot capture of the current gas usage by name in a group, capturing the gas used since the start.\nMeasures gas consumed from the regular counter, including state spillover but excluding reservoir-funded state gas.", "declaration": "function stopSnapshotGas(string calldata group, string calldata name) external returns (uint256 gasUsed);", "visibility": "external", "mutability": "", @@ -10702,6 +12405,26 @@ "status": "stable", "safety": "unsafe" }, + { + "func": { + "id": "storeTransient", + "description": "Stores a value to an address' transient storage slot.\nThe value lasts until the end of the current transaction, like a value written with `TSTORE`.\nThis writes the journal directly rather than executing `TSTORE`.\nWith isolation enabled (the default), each top-level `CALL` from a test runs as its own\ntransaction with empty transient storage, so a value the test stored is not visible to that\ncall, even though a later `loadTransient` in the test still returns it. For the target to see\nthe value, store it and call the target inside the same call, for example through a helper.\n`STATICCALL` and `DELEGATECALL` stay in the surrounding transaction and can see its values;\n`DELEGATECALL` uses the caller's transient storage, not the target's.\nExample usage, where `f` is non-view and `helper.storeAndCall` stores then calls `target.f()`:\nvm.storeTransient(address(target), slot, value);\ntarget.f(); // With isolation, `f` reads 0 from `slot`.\nhelper.storeAndCall(address(target), slot, value); // `f` reads `value`.", + "declaration": "function storeTransient(address target, bytes32 slot, bytes32 value) external;", + "visibility": "external", + "mutability": "", + "signature": "storeTransient(address,bytes32,bytes32)", + "selector": "0x89b5a4cd", + "selectorBytes": [ + 137, + 181, + 164, + 205 + ] + }, + "group": "evm", + "status": "stable", + "safety": "unsafe" + }, { "func": { "id": "toBase64URL_0", @@ -10802,6 +12525,26 @@ "status": "stable", "safety": "safe" }, + { + "func": { + "id": "toRlp", + "description": "RLP encodes a list of bytes into an RLP payload.", + "declaration": "function toRlp(bytes[] calldata data) external pure returns (bytes memory);", + "visibility": "external", + "mutability": "pure", + "signature": "toRlp(bytes[])", + "selector": "0xa7ed3885", + "selectorBytes": [ + 167, + 237, + 56, + 133 + ] + }, + "group": "utilities", + "status": "stable", + "safety": "safe" + }, { "func": { "id": "toString_0", @@ -11062,6 +12805,26 @@ "status": "stable", "safety": "safe" }, + { + "func": { + "id": "verifyEd25519", + "description": "Verifies an Ed25519 signature over namespace || message.\nReturns true if signature is valid, false otherwise.", + "declaration": "function verifyEd25519(bytes calldata signature, bytes calldata namespace, bytes calldata message, bytes32 publicKey) external pure returns (bool valid);", + "visibility": "external", + "mutability": "pure", + "signature": "verifyEd25519(bytes,bytes,bytes,bytes32)", + "selector": "0xd08c2888", + "selectorBytes": [ + 208, + 140, + 40, + 136 + ] + }, + "group": "crypto", + "status": "stable", + "safety": "safe" + }, { "func": { "id": "warmSlot", @@ -11165,7 +12928,7 @@ { "func": { "id": "writeJson_1", - "description": "Write a serialized JSON object to an **existing** JSON file, replacing a value with key = \nThis is useful to replace a specific value of a JSON file, without having to parse the entire thing.", + "description": "Write a serialized JSON object to an **existing** JSON file, replacing a value with key = \nThis is useful to replace a specific value of a JSON file, without having to parse the entire thing.\nThis cheatcode will create new keys if they didn't previously exist.", "declaration": "function writeJson(string calldata json, string calldata path, string calldata valueKey) external;", "visibility": "external", "mutability": "", @@ -11225,7 +12988,7 @@ { "func": { "id": "writeToml_1", - "description": "Takes serialized JSON, converts to TOML and write a serialized TOML table to an **existing** TOML file, replacing a value with key = \nThis is useful to replace a specific value of a TOML file, without having to parse the entire thing.", + "description": "Takes serialized JSON, converts to TOML and write a serialized TOML table to an **existing** TOML file, replacing a value with key = \nThis is useful to replace a specific value of a TOML file, without having to parse the entire thing.\nThis cheatcode will create new keys if they didn't previously exist.", "declaration": "function writeToml(string calldata json, string calldata path, string calldata valueKey) external;", "visibility": "external", "mutability": "", diff --git a/crates/cheatcodes/assets/cheatcodes.schema.json b/crates/cheatcodes/assets/cheatcodes.schema.json index 9ffc13d6121d8..c98cfb69357bd 100644 --- a/crates/cheatcodes/assets/cheatcodes.schema.json +++ b/crates/cheatcodes/assets/cheatcodes.schema.json @@ -1,109 +1,88 @@ { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "Cheatcodes", "description": "Foundry cheatcodes. Learn more: ", "type": "object", - "required": [ - "cheatcodes", - "enums", - "errors", - "events", - "structs" - ], "properties": { "cheatcodes": { "description": "All the cheatcodes.", "type": "array", "items": { - "$ref": "#/definitions/Cheatcode" + "$ref": "#/$defs/Cheatcode" } }, "enums": { "description": "Cheatcode enums.", "type": "array", "items": { - "$ref": "#/definitions/Enum" + "$ref": "#/$defs/Enum" } }, "errors": { "description": "Cheatcode errors.", "type": "array", "items": { - "$ref": "#/definitions/Error" + "$ref": "#/$defs/Error" } }, "events": { "description": "Cheatcode events.", "type": "array", "items": { - "$ref": "#/definitions/Event" + "$ref": "#/$defs/Event" } }, "structs": { "description": "Cheatcode structs.", "type": "array", "items": { - "$ref": "#/definitions/Struct" + "$ref": "#/$defs/Struct" } } }, - "definitions": { + "required": [ + "errors", + "events", + "enums", + "structs", + "cheatcodes" + ], + "$defs": { "Cheatcode": { "description": "Specification of a single cheatcode. Extends [`Function`] with additional metadata.", "type": "object", - "required": [ - "func", - "group", - "safety", - "status" - ], "properties": { "func": { "description": "The Solidity function declaration.", - "allOf": [ - { - "$ref": "#/definitions/Function" - } - ] + "$ref": "#/$defs/Function" }, "group": { "description": "The group that the cheatcode belongs to.", - "allOf": [ - { - "$ref": "#/definitions/Group" - } - ] + "$ref": "#/$defs/Group" }, "safety": { - "description": "Whether the cheatcode is safe to use inside of scripts. E.g. it does not change state in an unexpected way.", - "allOf": [ - { - "$ref": "#/definitions/Safety" - } - ] + "description": "Whether the cheatcode is safe to use inside of scripts. E.g. it does not change state in an\nunexpected way.", + "$ref": "#/$defs/Safety" }, "status": { "description": "The current status of the cheatcode. E.g. whether it is stable or experimental, etc.", - "allOf": [ - { - "$ref": "#/definitions/Status" - } - ] + "$ref": "#/$defs/Status" } }, - "additionalProperties": false + "additionalProperties": false, + "required": [ + "func", + "group", + "status", + "safety" + ] }, "Enum": { "description": "A Solidity enumeration.", "type": "object", - "required": [ - "description", - "name", - "variants" - ], "properties": { "description": { - "description": "The description of the enum. This is a markdown string derived from the NatSpec documentation.", + "description": "The description of the enum.\nThis is a markdown string derived from the NatSpec documentation.", "type": "string" }, "name": { @@ -114,111 +93,102 @@ "description": "The variants of the enum.", "type": "array", "items": { - "$ref": "#/definitions/EnumVariant" + "$ref": "#/$defs/EnumVariant" } } - } + }, + "required": [ + "name", + "description", + "variants" + ] }, "EnumVariant": { "description": "A variant of an [`Enum`].", "type": "object", - "required": [ - "description", - "name" - ], "properties": { "description": { - "description": "The description of the variant. This is a markdown string derived from the NatSpec documentation.", + "description": "The description of the variant.\nThis is a markdown string derived from the NatSpec documentation.", "type": "string" }, "name": { "description": "The name of the variant.", "type": "string" } - } + }, + "required": [ + "name", + "description" + ] }, "Error": { "description": "A Solidity custom error.", "type": "object", - "required": [ - "declaration", - "description", - "name" - ], "properties": { "declaration": { "description": "The Solidity error declaration, including full type, parameter names, etc.", "type": "string" }, "description": { - "description": "The description of the error. This is a markdown string derived from the NatSpec documentation.", + "description": "The description of the error.\nThis is a markdown string derived from the NatSpec documentation.", "type": "string" }, "name": { "description": "The name of the error.", "type": "string" } - } + }, + "required": [ + "name", + "description", + "declaration" + ] }, "Event": { "description": "A Solidity event.", "type": "object", - "required": [ - "declaration", - "description", - "name" - ], "properties": { "declaration": { "description": "The Solidity event declaration, including full type, parameter names, etc.", "type": "string" }, "description": { - "description": "The description of the event. This is a markdown string derived from the NatSpec documentation.", + "description": "The description of the event.\nThis is a markdown string derived from the NatSpec documentation.", "type": "string" }, "name": { "description": "The name of the event.", "type": "string" } - } + }, + "required": [ + "name", + "description", + "declaration" + ] }, "Function": { "description": "Solidity function.", "type": "object", - "required": [ - "declaration", - "description", - "id", - "mutability", - "selector", - "selectorBytes", - "signature", - "visibility" - ], "properties": { "declaration": { - "description": "The Solidity function declaration, including full type and parameter names, visibility, etc.", + "description": "The Solidity function declaration, including full type and parameter names, visibility,\netc.", "type": "string" }, "description": { - "description": "The description of the function. This is a markdown string derived from the NatSpec documentation.", + "description": "The description of the function.\nThis is a markdown string derived from the NatSpec documentation.", "type": "string" }, "id": { - "description": "The function's unique identifier. This is the function name, optionally appended with an index if it is overloaded.", + "description": "The function's unique identifier. This is the function name, optionally appended with an\nindex if it is overloaded.", "type": "string" }, "mutability": { "description": "The Solidity function state mutability attribute.", - "allOf": [ - { - "$ref": "#/definitions/Mutability" - } - ] + "$ref": "#/$defs/Mutability" }, "selector": { - "description": "The hex-encoded, \"0x\"-prefixed 4-byte function selector, which is the Keccak-256 hash of `signature`.", + "description": "The hex-encoded, \"0x\"-prefixed 4-byte function selector,\nwhich is the Keccak-256 hash of `signature`.", "type": "string" }, "selectorBytes": { @@ -227,97 +197,84 @@ "items": { "type": "integer", "format": "uint8", - "minimum": 0.0 + "maximum": 255, + "minimum": 0 }, "maxItems": 4, "minItems": 4 }, "signature": { - "description": "The standard function signature used to calculate `selector`. See the [Solidity docs] for more information.\n\n[Solidity docs]: https://docs.soliditylang.org/en/latest/abi-spec.html#function-selector", + "description": "The standard function signature used to calculate `selector`.\nSee the [Solidity docs] for more information.\n\n[Solidity docs]: https://docs.soliditylang.org/en/latest/abi-spec.html#function-selector", "type": "string" }, "visibility": { - "description": "The Solidity function visibility attribute. This is currently always `external`, but this may change in the future.", - "allOf": [ - { - "$ref": "#/definitions/Visibility" - } - ] + "description": "The Solidity function visibility attribute. This is currently always `external`, but this\nmay change in the future.", + "$ref": "#/$defs/Visibility" } - } + }, + "required": [ + "id", + "description", + "declaration", + "visibility", + "mutability", + "signature", + "selector", + "selectorBytes" + ] }, "Group": { - "description": "Cheatcode groups. Initially derived and modified from inline comments in [`forge-std`'s `Vm.sol`][vmsol].\n\n[vmsol]: https://github.com/foundry-rs/forge-std/blob/dcb0d52bc4399d37a6545848e3b8f9d03c77b98d/src/Vm.sol", + "description": "Cheatcode groups.\nInitially derived and modified from inline comments in [`forge-std`'s `Vm.sol`][vmsol].\n\n[vmsol]: https://github.com/foundry-rs/forge-std/blob/dcb0d52bc4399d37a6545848e3b8f9d03c77b98d/src/Vm.sol", "oneOf": [ { "description": "Cheatcodes that read from, or write to the current EVM execution state.\n\nExamples: any of the `record` cheatcodes, `chainId`, `coinbase`.\n\nSafety: ambiguous, depends on whether the cheatcode is read-only or not.", "type": "string", - "enum": [ - "evm" - ] + "const": "evm" }, { "description": "Cheatcodes that interact with how a test is run.\n\nExamples: `assume`, `skip`, `expectRevert`.\n\nSafety: ambiguous, depends on whether the cheatcode is read-only or not.", "type": "string", - "enum": [ - "testing" - ] + "const": "testing" }, { "description": "Cheatcodes that interact with how a script is run.\n\nExamples: `broadcast`, `startBroadcast`, `stopBroadcast`.\n\nSafety: safe.", "type": "string", - "enum": [ - "scripting" - ] + "const": "scripting" }, { "description": "Cheatcodes that interact with the OS or filesystem.\n\nExamples: `ffi`, `projectRoot`, `writeFile`.\n\nSafety: safe.", "type": "string", - "enum": [ - "filesystem" - ] + "const": "filesystem" }, { "description": "Cheatcodes that interact with the program's environment variables.\n\nExamples: `setEnv`, `envBool`, `envOr`.\n\nSafety: safe.", "type": "string", - "enum": [ - "environment" - ] + "const": "environment" }, { "description": "Utility cheatcodes that deal with string parsing and manipulation.\n\nExamples: `toString`. `parseBytes`.\n\nSafety: safe.", "type": "string", - "enum": [ - "string" - ] + "const": "string" }, { "description": "Utility cheatcodes that deal with parsing values from and converting values to JSON.\n\nExamples: `serializeJson`, `parseJsonUint`, `writeJson`.\n\nSafety: safe.", "type": "string", - "enum": [ - "json" - ] + "const": "json" }, { "description": "Utility cheatcodes that deal with parsing values from and converting values to TOML.\n\nExamples: `parseToml`, `writeToml`.\n\nSafety: safe.", "type": "string", - "enum": [ - "toml" - ] + "const": "toml" }, { "description": "Cryptography-related cheatcodes.\n\nExamples: `sign*`.\n\nSafety: safe.", "type": "string", - "enum": [ - "crypto" - ] + "const": "crypto" }, { "description": "Generic, uncategorized utilities.\n\nExamples: `toString`, `parse*`, `serialize*`.\n\nSafety: safe.", "type": "string", - "enum": [ - "utilities" - ] + "const": "utilities" } ] }, @@ -327,23 +284,17 @@ { "description": "Disallows modification or access of state.", "type": "string", - "enum": [ - "pure" - ] + "const": "pure" }, { "description": "Disallows modification of state.", "type": "string", - "enum": [ - "view" - ] + "const": "view" }, { "description": "Allows modification of state.", "type": "string", - "enum": [ - "" - ] + "const": "" } ] }, @@ -353,16 +304,12 @@ { "description": "The cheatcode is not safe to use in scripts.", "type": "string", - "enum": [ - "unsafe" - ] + "const": "unsafe" }, { "description": "The cheatcode is safe to use in scripts.", "type": "string", - "enum": [ - "safe" - ] + "const": "safe" } ] }, @@ -372,23 +319,16 @@ { "description": "The cheatcode and its API is currently stable.", "type": "string", - "enum": [ - "stable" - ] + "const": "stable" }, { - "description": "The cheatcode is unstable, meaning it may contain bugs and may break its API on any release.\n\nUse of experimental cheatcodes will result in a warning.", + "description": "The cheatcode is unstable, meaning it may contain bugs and may break its API on any\nrelease.\n\nUse of experimental cheatcodes will result in a warning.", "type": "string", - "enum": [ - "experimental" - ] + "const": "experimental" }, { "description": "The cheatcode has been deprecated, meaning it will be removed in a future release.\n\nContains the optional reason for deprecation.\n\nUse of deprecated cheatcodes is discouraged and will result in a warning.", "type": "object", - "required": [ - "deprecated" - ], "properties": { "deprecated": { "type": [ @@ -397,61 +337,55 @@ ] } }, - "additionalProperties": false + "additionalProperties": false, + "required": [ + "deprecated" + ] }, { "description": "The cheatcode has been removed and is no longer available for use.\n\nUse of removed cheatcodes will result in a hard error.", "type": "string", - "enum": [ - "removed" - ] + "const": "removed" }, { - "description": "The cheatcode is only used internally for foundry testing and may be changed or removed at any time.\n\nUse of internal cheatcodes is discouraged and will result in a warning.", + "description": "The cheatcode is only used internally for foundry testing and may be changed or removed at\nany time.\n\nUse of internal cheatcodes is discouraged and will result in a warning.", "type": "string", - "enum": [ - "internal" - ] + "const": "internal" } ] }, "Struct": { "description": "A Solidity struct.", "type": "object", - "required": [ - "description", - "fields", - "name" - ], "properties": { "description": { - "description": "The description of the struct. This is a markdown string derived from the NatSpec documentation.", + "description": "The description of the struct.\nThis is a markdown string derived from the NatSpec documentation.", "type": "string" }, "fields": { "description": "The fields of the struct.", "type": "array", "items": { - "$ref": "#/definitions/StructField" + "$ref": "#/$defs/StructField" } }, "name": { "description": "The name of the struct.", "type": "string" } - } + }, + "required": [ + "name", + "description", + "fields" + ] }, "StructField": { "description": "A [`Struct`] field.", "type": "object", - "required": [ - "description", - "name", - "ty" - ], "properties": { "description": { - "description": "The description of the field. This is a markdown string derived from the NatSpec documentation.", + "description": "The description of the field.\nThis is a markdown string derived from the NatSpec documentation.", "type": "string" }, "name": { @@ -462,7 +396,12 @@ "description": "The type of the field.", "type": "string" } - } + }, + "required": [ + "name", + "ty", + "description" + ] }, "Visibility": { "description": "Solidity function visibility attribute. See the [Solidity docs] for more information.\n\n[Solidity docs]: https://docs.soliditylang.org/en/latest/contracts.html#function-visibility", @@ -470,30 +409,22 @@ { "description": "The function is only visible externally.", "type": "string", - "enum": [ - "external" - ] + "const": "external" }, { "description": "Visible externally and internally.", "type": "string", - "enum": [ - "public" - ] + "const": "public" }, { "description": "Only visible internally.", "type": "string", - "enum": [ - "internal" - ] + "const": "internal" }, { "description": "Only visible in the current contract", "type": "string", - "enum": [ - "private" - ] + "const": "private" } ] } diff --git a/crates/cheatcodes/spec/Cargo.toml b/crates/cheatcodes/spec/Cargo.toml index 458f6b73067ad..c8abf9e0c58c6 100644 --- a/crates/cheatcodes/spec/Cargo.toml +++ b/crates/cheatcodes/spec/Cargo.toml @@ -20,7 +20,7 @@ alloy-sol-types = { workspace = true, features = ["json"] } serde.workspace = true # schema -schemars = { version = "0.8", optional = true } +schemars = { version = "1.1", optional = true } [dev-dependencies] serde_json.workspace = true diff --git a/crates/cheatcodes/spec/src/cheatcode.rs b/crates/cheatcodes/spec/src/cheatcode.rs index 69fe97781c719..89cfb31f1df32 100644 --- a/crates/cheatcodes/spec/src/cheatcode.rs +++ b/crates/cheatcodes/spec/src/cheatcode.rs @@ -135,7 +135,6 @@ impl Group { /// /// Some groups are inherently safe or unsafe, while others are ambiguous and will return /// `None`. - #[inline] pub const fn safety(self) -> Option { match self { Self::Evm | Self::Testing => None, @@ -151,7 +150,6 @@ impl Group { } /// Returns this value as a string. - #[inline] pub const fn as_str(self) -> &'static str { match self { Self::Evm => "evm", @@ -184,7 +182,6 @@ pub enum Safety { impl Safety { /// Returns this value as a string. - #[inline] pub const fn as_str(self) -> &'static str { match self { Self::Safe => "safe", @@ -193,7 +190,6 @@ impl Safety { } /// Returns whether this value is safe. - #[inline] pub const fn is_safe(self) -> bool { matches!(self, Self::Safe) } diff --git a/crates/cheatcodes/spec/src/function.rs b/crates/cheatcodes/spec/src/function.rs index 4c747fe152ee5..c67cc8ca80034 100644 --- a/crates/cheatcodes/spec/src/function.rs +++ b/crates/cheatcodes/spec/src/function.rs @@ -64,7 +64,6 @@ impl fmt::Display for Visibility { impl Visibility { /// Returns the string representation of the visibility. - #[inline] pub const fn as_str(self) -> &'static str { match self { Self::External => "external", @@ -99,7 +98,6 @@ impl fmt::Display for Mutability { impl Mutability { /// Returns the string representation of the mutability. - #[inline] pub const fn as_str(self) -> &'static str { match self { Self::Pure => "pure", diff --git a/crates/cheatcodes/spec/src/lib.rs b/crates/cheatcodes/spec/src/lib.rs index b6d4e9e75aa2e..4cf546a27cb63 100644 --- a/crates/cheatcodes/spec/src/lib.rs +++ b/crates/cheatcodes/spec/src/lib.rs @@ -1,7 +1,7 @@ //! Cheatcode specification for Foundry. #![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] +#![cfg_attr(docsrs, feature(doc_cfg))] use serde::{Deserialize, Serialize}; use std::{borrow::Cow, fmt}; @@ -15,6 +15,9 @@ pub use function::{Function, Mutability, Visibility}; mod items; pub use items::{Enum, EnumVariant, Error, Event, Struct, StructField}; +mod symbolic; +pub use symbolic::SymbolicVm; + mod vm; pub use vm::Vm; @@ -78,6 +81,8 @@ impl Cheatcodes<'static> { Vm::Log::STRUCT.clone(), Vm::Rpc::STRUCT.clone(), Vm::EthGetLogs::STRUCT.clone(), + Vm::EthStorageProof::STRUCT.clone(), + Vm::EthGetProof::STRUCT.clone(), Vm::DirEntry::STRUCT.clone(), Vm::FsMetadata::STRUCT.clone(), Vm::Wallet::STRUCT.clone(), @@ -117,8 +122,7 @@ mod tests { #[cfg(feature = "schema")] const SCHEMA_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../assets/cheatcodes.schema.json"); - const IFACE_PATH: &str = - concat!(env!("CARGO_MANIFEST_DIR"), "/../../../testdata/cheats/Vm.sol"); + const IFACE_PATH: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../../testdata/utils/Vm.sol"); /// Generates the `cheatcodes.json` file contents. fn json_cheatcodes() -> String { diff --git a/crates/cheatcodes/spec/src/symbolic.rs b/crates/cheatcodes/spec/src/symbolic.rs new file mode 100644 index 0000000000000..76a7a4195d07d --- /dev/null +++ b/crates/cheatcodes/spec/src/symbolic.rs @@ -0,0 +1,116 @@ +use alloy_sol_types::sol; + +sol! { + interface SymbolicVm { + function createAddress(string calldata name) external returns (address value); + function createBool(string calldata name) external returns (bool value); + function createBytes(string calldata name) external returns (bytes memory value); + function createBytes(uint256 len, string calldata name) external returns (bytes memory value); + function createCalldata(string calldata name) external returns (bytes memory value); + function createInt(uint256 bits, string calldata name) external returns (int256 value); + function createString(string calldata name) external returns (string memory value); + function createString(uint256 len, string calldata name) external returns (string memory value); + function createUint(uint256 bits, string calldata name) external returns (uint256 value); + function enableSymbolicStorage(address target) external; + function snapshotStorage(address target) external returns (uint256 id); + + function createUint8(string calldata name) external returns (uint8 value); + function createUint16(string calldata name) external returns (uint16 value); + function createUint24(string calldata name) external returns (uint24 value); + function createUint32(string calldata name) external returns (uint32 value); + function createUint40(string calldata name) external returns (uint40 value); + function createUint48(string calldata name) external returns (uint48 value); + function createUint56(string calldata name) external returns (uint56 value); + function createUint64(string calldata name) external returns (uint64 value); + function createUint72(string calldata name) external returns (uint72 value); + function createUint80(string calldata name) external returns (uint80 value); + function createUint88(string calldata name) external returns (uint88 value); + function createUint96(string calldata name) external returns (uint96 value); + function createUint104(string calldata name) external returns (uint104 value); + function createUint112(string calldata name) external returns (uint112 value); + function createUint120(string calldata name) external returns (uint120 value); + function createUint128(string calldata name) external returns (uint128 value); + function createUint136(string calldata name) external returns (uint136 value); + function createUint144(string calldata name) external returns (uint144 value); + function createUint152(string calldata name) external returns (uint152 value); + function createUint160(string calldata name) external returns (uint160 value); + function createUint168(string calldata name) external returns (uint168 value); + function createUint176(string calldata name) external returns (uint176 value); + function createUint184(string calldata name) external returns (uint184 value); + function createUint192(string calldata name) external returns (uint192 value); + function createUint200(string calldata name) external returns (uint200 value); + function createUint208(string calldata name) external returns (uint208 value); + function createUint216(string calldata name) external returns (uint216 value); + function createUint224(string calldata name) external returns (uint224 value); + function createUint232(string calldata name) external returns (uint232 value); + function createUint240(string calldata name) external returns (uint240 value); + function createUint248(string calldata name) external returns (uint248 value); + function createUint256(string calldata name) external returns (uint256 value); + + function createInt8(string calldata name) external returns (int8 value); + function createInt16(string calldata name) external returns (int16 value); + function createInt24(string calldata name) external returns (int24 value); + function createInt32(string calldata name) external returns (int32 value); + function createInt40(string calldata name) external returns (int40 value); + function createInt48(string calldata name) external returns (int48 value); + function createInt56(string calldata name) external returns (int56 value); + function createInt64(string calldata name) external returns (int64 value); + function createInt72(string calldata name) external returns (int72 value); + function createInt80(string calldata name) external returns (int80 value); + function createInt88(string calldata name) external returns (int88 value); + function createInt96(string calldata name) external returns (int96 value); + function createInt104(string calldata name) external returns (int104 value); + function createInt112(string calldata name) external returns (int112 value); + function createInt120(string calldata name) external returns (int120 value); + function createInt128(string calldata name) external returns (int128 value); + function createInt136(string calldata name) external returns (int136 value); + function createInt144(string calldata name) external returns (int144 value); + function createInt152(string calldata name) external returns (int152 value); + function createInt160(string calldata name) external returns (int160 value); + function createInt168(string calldata name) external returns (int168 value); + function createInt176(string calldata name) external returns (int176 value); + function createInt184(string calldata name) external returns (int184 value); + function createInt192(string calldata name) external returns (int192 value); + function createInt200(string calldata name) external returns (int200 value); + function createInt208(string calldata name) external returns (int208 value); + function createInt216(string calldata name) external returns (int216 value); + function createInt224(string calldata name) external returns (int224 value); + function createInt232(string calldata name) external returns (int232 value); + function createInt240(string calldata name) external returns (int240 value); + function createInt248(string calldata name) external returns (int248 value); + function createInt256(string calldata name) external returns (int256 value); + + function createBytes1(string calldata name) external returns (bytes1 value); + function createBytes2(string calldata name) external returns (bytes2 value); + function createBytes3(string calldata name) external returns (bytes3 value); + function createBytes4(string calldata name) external returns (bytes4 value); + function createBytes5(string calldata name) external returns (bytes5 value); + function createBytes6(string calldata name) external returns (bytes6 value); + function createBytes7(string calldata name) external returns (bytes7 value); + function createBytes8(string calldata name) external returns (bytes8 value); + function createBytes9(string calldata name) external returns (bytes9 value); + function createBytes10(string calldata name) external returns (bytes10 value); + function createBytes11(string calldata name) external returns (bytes11 value); + function createBytes12(string calldata name) external returns (bytes12 value); + function createBytes13(string calldata name) external returns (bytes13 value); + function createBytes14(string calldata name) external returns (bytes14 value); + function createBytes15(string calldata name) external returns (bytes15 value); + function createBytes16(string calldata name) external returns (bytes16 value); + function createBytes17(string calldata name) external returns (bytes17 value); + function createBytes18(string calldata name) external returns (bytes18 value); + function createBytes19(string calldata name) external returns (bytes19 value); + function createBytes20(string calldata name) external returns (bytes20 value); + function createBytes21(string calldata name) external returns (bytes21 value); + function createBytes22(string calldata name) external returns (bytes22 value); + function createBytes23(string calldata name) external returns (bytes23 value); + function createBytes24(string calldata name) external returns (bytes24 value); + function createBytes25(string calldata name) external returns (bytes25 value); + function createBytes26(string calldata name) external returns (bytes26 value); + function createBytes27(string calldata name) external returns (bytes27 value); + function createBytes28(string calldata name) external returns (bytes28 value); + function createBytes29(string calldata name) external returns (bytes29 value); + function createBytes30(string calldata name) external returns (bytes30 value); + function createBytes31(string calldata name) external returns (bytes31 value); + function createBytes32(string calldata name) external returns (bytes32 value); + } +} diff --git a/crates/cheatcodes/spec/src/vm.rs b/crates/cheatcodes/spec/src/vm.rs index 3b207c1b3250f..6d0e40ba1c032 100644 --- a/crates/cheatcodes/spec/src/vm.rs +++ b/crates/cheatcodes/spec/src/vm.rs @@ -96,18 +96,31 @@ interface Vm { address emitter; } - /// Gas used. Returned by `lastCallGas`. + /// Gas measured for the last completed call or create frame, from the callee's perspective, + /// including nested execution. Isolated transactions include intrinsic gas. + /// Regular gas (the EIP's execution gas) and EIP-8037 state gas are reported separately. + /// Without EIP-8037, state creation uses the ordinary gas schedule and `gasStateUsed` is zero. + /// See and . struct Gas { - /// The gas limit of the call. + /// Regular gas available to the frame at entry. Excludes the EIP-8037 state gas reservoir. uint64 gasLimit; - /// The total gas used. + /// Regular gas spent by the frame, before refunds. Excludes EIP-8037 state gas; see `gasStateUsed`. + /// With isolation, includes intrinsic gas and the regular-gas calldata floor. uint64 gasTotalUsed; - /// DEPRECATED: The amount of gas used for memory expansion. Ref: + /// DEPRECATED: always zero. Memory expansion costs are included in `gasTotalUsed`. + /// Ref: . uint64 gasMemoryUsed; - /// The amount of gas refunded. + /// Ordinary refund counter before transaction settlement; finalized for an isolated transaction. + /// Can be negative in nested frames. State gas refills are already netted into `gasStateUsed`. int64 gasRefunded; - /// The amount of gas remaining. + /// Regular gas left at frame end. Excludes the EIP-8037 state gas reservoir. + /// State charges can draw from this allowance, so `gasLimit - gasRemaining` can include state gas. uint64 gasRemaining; + /// Net EIP-8037 state gas: state creation charges minus refills, including nested execution. + /// Zero without EIP-8037 or if the frame reverted or halted. Can be negative when the frame + /// undoes state created earlier in the same transaction; use signed arithmetic with `gasTotalUsed`. + /// Their sum measures net consumption, not the gas limit needed to execute. + int64 gasStateUsed; } /// An RPC URL and its alias. Returned by `rpcUrlStructs`. @@ -140,6 +153,34 @@ interface Vm { bool removed; } + /// A Merkle proof for a single storage slot. Part of `EthGetProof`. + struct EthStorageProof { + /// The storage slot. + bytes32 key; + /// The value stored at the slot. + uint256 value; + /// The RLP-encoded trie nodes from the storage root to the slot, root first. + bytes[] proof; + } + + /// An EIP-1186 account and storage proof. Returned by `eth_getProof`. + struct EthGetProof { + /// The address of the account. + address account; + /// The balance of the account. + uint256 balance; + /// The hash of the account's code. + bytes32 codeHash; + /// The nonce of the account. + uint64 nonce; + /// The root of the account's storage trie. + bytes32 storageHash; + /// The RLP-encoded trie nodes from the state root to the account, root first. + bytes[] accountProof; + /// The proofs for the requested storage slots, in the order they were requested. + EthStorageProof[] storageProof; + } + /// A single entry in a directory listing. Returned by `readDir`. struct DirEntry { /// The error message, if any. @@ -278,6 +319,10 @@ interface Vm { StorageAccess[] storageAccesses; /// Call depth traversed during the recording of state differences uint64 depth; + /// The previous nonce of the accessed account. + uint64 oldNonce; + /// The new nonce of the accessed account. + uint64 newNonce; } /// The result of the `stopDebugTraceRecording` call @@ -349,7 +394,7 @@ interface Vm { struct PotentialRevert { /// The allowed origin of the revert opcode; address(0) allows reverts from any address address reverter; - /// When true, only matches on the beginning of the revert data, otherwise, matches on entire revert data + /// When true, only matches on the first 4 bytes (usually the selector) of the revert data, otherwise, matches on entire revert data bool partialMatch; /// The data to use to match encountered reverts bytes revertData; @@ -361,7 +406,8 @@ interface Vm { #[cheatcode(group = Evm, safety = Safe)] function addr(uint256 privateKey) external pure returns (address keyAddr); - /// Dump a genesis JSON file's `allocs` to disk. + /// Dumps a genesis JSON file's `allocs` to disk. Accounts created in the current transaction + /// are ordered by deployment, followed by the remaining accounts in ascending address order. #[cheatcode(group = Evm, safety = Unsafe)] function dumpState(string calldata pathToStateJson) external; @@ -371,12 +417,16 @@ interface Vm { /// Get the nonce of a `Wallet`. #[cheatcode(group = Evm, safety = Safe)] - function getNonce(Wallet calldata wallet) external returns (uint64 nonce); + function getNonce(Wallet calldata wallet) external view returns (uint64 nonce); /// Loads a storage slot from an address. #[cheatcode(group = Evm, safety = Safe)] function load(address target, bytes32 slot) external view returns (bytes32 data); + /// Loads a transient storage slot from an address. + #[cheatcode(group = Evm, safety = Safe)] + function loadTransient(address target, bytes32 slot) external view returns (bytes32 data); + /// Load a genesis JSON file's `allocs` into the in-memory EVM state. #[cheatcode(group = Evm, safety = Unsafe)] function loadAllocs(string calldata pathToAllocsJson) external; @@ -409,7 +459,57 @@ interface Vm { /// Gets all accessed reads and write slot from a `vm.record` session, for a given address. #[cheatcode(group = Evm, safety = Safe)] - function accesses(address target) external returns (bytes32[] memory readSlots, bytes32[] memory writeSlots); + function accesses(address target) external view returns (bytes32[] memory readSlots, bytes32[] memory writeSlots); + + /// Registers a callback invoked after each SLOAD against `target`'s effective storage account, + /// including when its code runs by delegatecall. + /// + /// The callback must have the signature `function(address,bytes32,bytes32) external`. + /// Registering another callback for the same target and access kind replaces it. Registration + /// survives EVM reverts, while callback state follows the enclosing EVM context and rolls back + /// with it. Callback reverts propagate through the storage operation. Hooks are suppressed in + /// the callback and its entire call subtree. The callback must authenticate + /// `msg.sender == address(vm)` to prevent external spoofing. Callback execution is hidden from + /// mocks, expectations, log recording, and storage-access recording. It does not inherit + /// staticness. The callback runs as an ordinary call frame and consumes one of the 1024 + /// protocol call-depth slots; a load at the maximum legal call depth can have its callback + /// rejected as too deep, propagating as a failure of the load. + #[cheatcode(group = Evm, safety = Unsafe)] + function registerSloadHook(address target, bytes4 callback) external; + + /// Registers a callback invoked after each SSTORE against `target`'s effective storage account, + /// including when its code runs by delegatecall. + /// + /// The callback must have the signature `function(address,bytes32,bytes32,bytes32) external`. + /// Registering another callback for the same target and access kind replaces it. Registration + /// survives EVM reverts, while callback state follows the enclosing EVM context and rolls back + /// with it. Callback reverts propagate through the storage operation. Hooks are suppressed in + /// the callback and its entire call subtree. The callback must authenticate + /// `msg.sender == address(vm)` to prevent external spoofing. Callback execution is hidden from + /// mocks, expectations, log recording, and storage-access recording. It does not inherit + /// staticness. The callback runs as an ordinary call frame and consumes one of the 1024 + /// protocol call-depth slots; a store at the maximum legal call depth can have its callback + /// rejected as too deep, propagating as a failure of the store. + #[cheatcode(group = Evm, safety = Unsafe)] + function registerSstoreHook(address target, bytes4 callback) external; + + /// Registers a callback after exact mapping-element SSTOREs rooted at `rootSlot` in `target`'s effective storage account. + /// + /// The callback signature is `function(address account, bytes32 computedSlot, bytes32 rootSlot, + /// bytes32[] keys, bytes32 oldValue, bytes32 newValue) external`; keys are raw words in + /// root-to-leaf order. Only complete 64-byte Keccak chains observed after the latest mapping + /// hook registration for `target` in the current top-level execution match; provenance is + /// cleared between top-level executions. Resolution follows the complete chain to its terminal + /// root and ignores registered intermediate hashes. Offsets, incomplete or unknown chains, + /// hashes computed before registration or in an earlier top-level execution, and source layouts + /// do not match. The contract that calls this cheatcode receives the callback. Registration + /// persists across reverts and replaces the same target/root callback; callback state rolls back + /// with its enclosing context, callback reverts propagate, and hooks are suppressed throughout + /// callback subtrees. The callback must authenticate `msg.sender == address(vm)` to prevent + /// external spoofing. Raw and mapping SSTORE hooks conflict per target, while multiple mapping + /// roots may be registered. + #[cheatcode(group = Evm, safety = Unsafe)] + function registerMappingSstoreHook(address target, bytes32 rootSlot, bytes4 callback) external; /// Record all account accesses as part of CREATE, CALL or SELFDESTRUCT opcodes in order, /// along with the context of the calls @@ -428,33 +528,49 @@ interface Vm { #[cheatcode(group = Evm, safety = Safe)] function getStateDiffJson() external view returns (string memory diff); - // -------- Recording Map Writes -------- + /// Returns an array of storage slots occupied by the specified variable. + #[cheatcode(group = Evm, safety = Safe)] + function getStorageSlots(address target, string calldata variableName) external view returns (uint256[] memory slots); + + /// Returns an array of `StorageAccess` from current `vm.stateStateDiffRecording` session + #[cheatcode(group = Evm, safety = Safe)] + function getStorageAccesses() external view returns (StorageAccess[] memory storageAccesses); + + // -------- Recording Mapping Accesses -------- - /// Starts recording all map SSTOREs for later retrieval. + /// Starts recording mapping SSTOREs for later retrieval. #[cheatcode(group = Evm, safety = Safe)] function startMappingRecording() external; - /// Stops recording all map SSTOREs for later retrieval and clears the recorded data. + /// Stops recording mapping SSTOREs and clears the recorded data. #[cheatcode(group = Evm, safety = Safe)] function stopMappingRecording() external; /// Gets the number of elements in the mapping at the given slot, for a given address. #[cheatcode(group = Evm, safety = Safe)] - function getMappingLength(address target, bytes32 mappingSlot) external returns (uint256 length); + function getMappingLength(address target, bytes32 mappingSlot) external view returns (uint256 length); /// Gets the elements at index idx of the mapping at the given slot, for a given address. The /// index must be less than the length of the mapping (i.e. the number of keys in the mapping). #[cheatcode(group = Evm, safety = Safe)] - function getMappingSlotAt(address target, bytes32 mappingSlot, uint256 idx) external returns (bytes32 value); + function getMappingSlotAt(address target, bytes32 mappingSlot, uint256 idx) external view returns (bytes32 value); /// Gets the map key and parent of a mapping at a given slot, for a given address. #[cheatcode(group = Evm, safety = Safe)] function getMappingKeyAndParentOf(address target, bytes32 elementSlot) external + view returns (bool found, bytes32 key, bytes32 parent); // -------- Block and Transaction Properties -------- + /// Gets the current `block.chainid` of the currently selected environment. + /// You should use this instead of `block.chainid` if you use `vm.selectFork` or `vm.createSelectFork`, as `block.chainid` could be assumed + /// to be constant across a transaction, and as a result will get optimized out by the compiler. + /// See https://github.com/foundry-rs/foundry/issues/6180 + #[cheatcode(group = Evm, safety = Safe)] + function getChainId() external view returns (uint256 blockChainId); + /// Sets `block.chainid`. #[cheatcode(group = Evm, safety = Unsafe)] function chainId(uint256 newChainId) external; @@ -490,7 +606,7 @@ interface Vm { #[cheatcode(group = Evm, safety = Unsafe)] function blobhashes(bytes32[] calldata hashes) external; - /// Gets the blockhashes from the current transaction. + /// Gets the blobhashes from the current transaction. /// Not available on EVM versions before Cancun. /// If used on unsupported EVM versions it will revert. #[cheatcode(group = Evm, safety = Unsafe)] @@ -507,6 +623,20 @@ interface Vm { #[cheatcode(group = Evm, safety = Safe)] function getBlockNumber() external view returns (uint256 height); + /// Sets `block.slotnum` without changing the block number or timestamp. + /// Not available on EVM versions before Amsterdam. + /// If used on unsupported EVM versions it will revert. + #[cheatcode(group = Evm, safety = Unsafe)] + function rollSlot(uint64 newSlotNumber) external; + + /// Gets the current `block.slotnum`. + /// Use this instead of `block.slotnum` after `vm.rollSlot`, as the compiler assumes + /// `block.slotnum` is constant across a transaction and may optimize repeated reads away. + /// Not available on EVM versions before Amsterdam. + /// If used on unsupported EVM versions it will revert. + #[cheatcode(group = Evm, safety = Safe)] + function getSlotNumber() external view returns (uint64 slotNumber); + /// Sets `tx.gasprice`. #[cheatcode(group = Evm, safety = Unsafe)] function txGasPrice(uint256 newGasPrice) external; @@ -522,6 +652,11 @@ interface Vm { #[cheatcode(group = Evm, safety = Safe)] function getBlockTimestamp() external view returns (uint256 timestamp); + /// Gets the RLP encoded block header for a given block number. + /// Returns the block header in the same format as `cast block --raw`. + #[cheatcode(group = Evm, safety = Safe)] + function getRawBlockHeader(uint256 blockNumber) external view returns (bytes memory rlpHeader); + /// Sets `block.blobbasefee` #[cheatcode(group = Evm, safety = Unsafe)] function blobBaseFee(uint256 newBlobBaseFee) external; @@ -538,6 +673,14 @@ interface Vm { #[cheatcode(group = Evm, safety = Unsafe)] function setBlockhash(uint256 blockNumber, bytes32 blockHash) external; + /// Executes an RLP-encoded signed transaction with full EVM semantics (like `--isolate` mode). + /// The transaction is decoded from EIP-2718 format (type byte prefix + RLP payload) or legacy RLP. + /// Returns the execution output bytes. + /// + /// This cheatcode is not allowed in `forge script` contexts. + #[cheatcode(group = Evm, safety = Unsafe)] + function executeTransaction(bytes calldata rawTx) external returns (bytes memory); + // -------- Account State -------- /// Sets an address' balance. @@ -564,6 +707,32 @@ interface Vm { #[cheatcode(group = Evm, safety = Unsafe)] function store(address target, bytes32 slot, bytes32 value) external; + /// Stores a value to an address' transient storage slot. + /// The value lasts until the end of the current transaction, like a value written with `TSTORE`. + /// This writes the journal directly rather than executing `TSTORE`. + /// With isolation enabled (the default), each top-level `CALL` from a test runs as its own + /// transaction with empty transient storage, so a value the test stored is not visible to that + /// call, even though a later `loadTransient` in the test still returns it. For the target to see + /// the value, store it and call the target inside the same call, for example through a helper. + /// `STATICCALL` and `DELEGATECALL` stay in the surrounding transaction and can see its values; + /// `DELEGATECALL` uses the caller's transient storage, not the target's. + /// Example usage, where `f` is non-view and `helper.storeAndCall` stores then calls `target.f()`: + /// vm.storeTransient(address(target), slot, value); + /// target.f(); // With isolation, `f` reads 0 from `slot`. + /// helper.storeAndCall(address(target), slot, value); // `f` reads `value`. + #[cheatcode(group = Evm, safety = Unsafe)] + function storeTransient(address target, bytes32 slot, bytes32 value) external; + + /// Sets a TIP-20 token's logo URI directly in storage. + /// This bypasses the token admin check, but still validates the URI against T5 constraints. + #[cheatcode(group = Evm, safety = Unsafe)] + function setTip20LogoURI(address token, string calldata newLogoURI) external; + + /// Sets a TIP-20 token's logo URI directly in storage. + /// This bypasses the token admin check, but still validates the URI against T5 constraints. + #[cheatcode(group = Evm, safety = Unsafe)] + function setLogoURI(address token, string calldata newLogoURI) external; + /// Marks the slots of an account and the account address as cold. #[cheatcode(group = Evm, safety = Unsafe)] function cool(address target) external; @@ -584,6 +753,35 @@ interface Vm { #[cheatcode(group = Evm, safety = Unsafe)] function coolSlot(address target, bytes32 slot) external; + /// Returns true if isolated test execution is enabled. + #[cheatcode(group = Evm, safety = Safe)] + function isIsolateMode() external view returns (bool result); + + /// Returns the test or script execution evm version. + /// + /// **Note:** The execution evm version is not the same as the compilation one. + #[cheatcode(group = Evm, safety = Safe)] + function getEvmVersion() external pure returns (string memory evm); + + /// Selects the test or script execution hardfork, e.g. `berlin`, `cancun`, or `tempo:T7`. + /// Uses the active network's version mappings and gas schedule. Does not change the network + /// or Solidity compiler target. On Tempo, runtime changes do not rebuild instructions or + /// precompiles; configure `hardfork` before execution to select a different revision. + /// + /// **Note:** The execution evm version is not the same as the compilation one. + #[cheatcode(group = Evm, safety = Safe)] + function setEvmVersion(string calldata evm) external; + + /// Returns `true` if `spender` is on the active Tempo hardfork's implicit-approval list, + /// meaning it can pull TIP-20 tokens from `msg.sender` without a prior `approve()`. + /// Returns `false` on non-Tempo networks. + #[cheatcode(group = Evm, safety = Safe)] + function isImplicitlyApproved(address spender) external view returns (bool implicitlyApproved); + + /// Skips a fuzz/invariant input unless `spender` is implicitly approved. + #[cheatcode(group = Testing, safety = Safe)] + function assumeImplicitApproval(address spender) external view; + // -------- Call Manipulation -------- // --- Mocks --- @@ -619,6 +817,21 @@ interface Vm { #[cheatcode(group = Evm, safety = Unsafe)] function mockCall(address callee, uint256 msgValue, bytes4 data, bytes calldata returnData) external; + /// Mocks a call to an address, returning specified data. + /// Calldata can either be strict or a partial match, e.g. if you only + /// pass a Solidity selector to the expected calldata, then the entire Solidity + /// function will be mocked. + /// + /// Overload to control whether code is injected into `callee`. The other overloads etch a + /// single byte into an empty account to circumvent Solidity's `extcodesize` check, with the + /// side effect that unmocked calls to it no longer revert; `injectCode = false` leaves the + /// account codeless, so unmocked calls to it revert in the caller. Mocked calls that return + /// data still succeed, as Solidity checks `returndatasize()` instead of `extcodesize()` when + /// return data is expected, but mocked calls to functions without return values may still + /// revert in the caller due to the `extcodesize` check. + #[cheatcode(group = Evm, safety = Unsafe)] + function mockCall(address callee, bytes calldata data, bytes calldata returnData, bool injectCode) external; + /// Mocks multiple calls to an address, returning specified data for each call. #[cheatcode(group = Evm, safety = Unsafe)] function mockCalls(address callee, bytes calldata data, bytes[] calldata returnData) external; @@ -698,7 +911,7 @@ interface Vm { /// Reads the current `msg.sender` and `tx.origin` from state and reports if there is any active caller modification. #[cheatcode(group = Evm, safety = Unsafe)] - function readCallers() external returns (CallerMode callerMode, address msgSender, address txOrigin); + function readCallers() external view returns (CallerMode callerMode, address msgSender, address txOrigin); // ----- Arbitrary Snapshots ----- @@ -713,33 +926,56 @@ interface Vm { // -------- Gas Snapshots -------- + /// DEPRECATED: use `snapshotGasLastFrame` instead. /// Snapshot capture the gas usage of the last call by name from the callee perspective. - #[cheatcode(group = Evm, safety = Unsafe)] + /// Without isolation, measures regular counter consumption, including spillover but excluding reservoir-funded state gas. + /// Isolated frames with zero net state gas use receipt gas; see . + #[cheatcode(group = Evm, safety = Unsafe, status = Deprecated(Some("replaced by `snapshotGasLastFrame`")))] function snapshotGasLastCall(string calldata name) external returns (uint256 gasUsed); + /// DEPRECATED: use `snapshotGasLastFrame` instead. /// Snapshot capture the gas usage of the last call by name in a group from the callee perspective. - #[cheatcode(group = Evm, safety = Unsafe)] + /// Without isolation, measures regular counter consumption, including spillover but excluding reservoir-funded state gas. + /// Isolated frames with zero net state gas use receipt gas; see . + #[cheatcode(group = Evm, safety = Unsafe, status = Deprecated(Some("replaced by `snapshotGasLastFrame`")))] function snapshotGasLastCall(string calldata group, string calldata name) external returns (uint256 gasUsed); + /// Snapshot capture the gas usage of the last call or create by name from the callee perspective. + /// Without isolation, measures regular counter consumption, including spillover but excluding reservoir-funded state gas. + /// Isolated frames with zero net state gas use receipt gas; see . + #[cheatcode(group = Evm, safety = Unsafe)] + function snapshotGasLastFrame(string calldata name) external returns (uint256 gasUsed); + + /// Snapshot capture the gas usage of the last call or create by name in a group from the callee perspective. + /// Without isolation, measures regular counter consumption, including spillover but excluding reservoir-funded state gas. + /// Isolated frames with zero net state gas use receipt gas; see . + #[cheatcode(group = Evm, safety = Unsafe)] + function snapshotGasLastFrame(string calldata group, string calldata name) external returns (uint256 gasUsed); + /// Start a snapshot capture of the current gas usage by name. /// The group name is derived from the contract name. + /// Measures gas consumed from the regular counter, including state spillover but excluding reservoir-funded state gas. #[cheatcode(group = Evm, safety = Unsafe)] function startSnapshotGas(string calldata name) external; /// Start a snapshot capture of the current gas usage by name in a group. + /// Measures gas consumed from the regular counter, including state spillover but excluding reservoir-funded state gas. #[cheatcode(group = Evm, safety = Unsafe)] function startSnapshotGas(string calldata group, string calldata name) external; /// Stop the snapshot capture of the current gas by latest snapshot name, capturing the gas used since the start. + /// Measures gas consumed from the regular counter, including state spillover but excluding reservoir-funded state gas. #[cheatcode(group = Evm, safety = Unsafe)] function stopSnapshotGas() external returns (uint256 gasUsed); /// Stop the snapshot capture of the current gas usage by name, capturing the gas used since the start. /// The group name is derived from the contract name. + /// Measures gas consumed from the regular counter, including state spillover but excluding reservoir-funded state gas. #[cheatcode(group = Evm, safety = Unsafe)] function stopSnapshotGas(string calldata name) external returns (uint256 gasUsed); /// Stop the snapshot capture of the current gas usage by name in a group, capturing the gas used since the start. + /// Measures gas consumed from the regular counter, including state spillover but excluding reservoir-funded state gas. #[cheatcode(group = Evm, safety = Unsafe)] function stopSnapshotGas(string calldata group, string calldata name) external returns (uint256 gasUsed); @@ -866,12 +1102,31 @@ interface Vm { external returns (bytes memory data); + /// Performs an Ethereum JSON-RPC request to the current fork URL and returns the JSON result. + #[cheatcode(group = Evm, safety = Safe)] + function rpcJson(string calldata method, string calldata params) external returns (string memory data); + + /// Performs an Ethereum JSON-RPC request to the given endpoint and returns the JSON result. + #[cheatcode(group = Evm, safety = Safe)] + function rpcJson(string calldata urlOrAlias, string calldata method, string calldata params) + external + returns (string memory data); + /// Gets all the logs according to specified filter. #[cheatcode(group = Evm, safety = Safe)] function eth_getLogs(uint256 fromBlock, uint256 toBlock, address target, bytes32[] calldata topics) external + view returns (EthGetLogs[] memory logs); + /// Gets the EIP-1186 account and storage proof of `target` at `blockNumber` from the active fork. + /// The proof is fetched from the fork's RPC endpoint and does not reflect local state changes. + #[cheatcode(group = Evm, safety = Safe)] + function eth_getProof(address target, bytes32[] calldata slots, uint256 blockNumber) + external + view + returns (EthGetProof memory proof); + // --- Behavior --- /// In forking mode, explicitly grant the given address cheatcode access. @@ -911,7 +1166,11 @@ interface Vm { /// Gets all the recorded logs. #[cheatcode(group = Evm, safety = Safe)] - function getRecordedLogs() external returns (Log[] memory logs); + function getRecordedLogs() external view returns (Log[] memory logs); + + /// Gets all the recorded logs, in JSON format. + #[cheatcode(group = Evm, safety = Safe)] + function getRecordedLogsJson() external view returns (string memory logsJson); // -------- Gas Metering -------- @@ -932,10 +1191,19 @@ interface Vm { // -------- Gas Measurement -------- - /// Gets the gas used in the last call from the callee perspective. - #[cheatcode(group = Evm, safety = Safe)] + /// DEPRECATED: use `lastFrameGas` instead. + /// Gets gas measurements for the last completed call, from the callee's perspective. + /// Unlike `lastFrameGas`, CREATE and CREATE2 frames are not recorded; calls made by a constructor are. + /// See `Gas` for field semantics. + #[cheatcode(group = Evm, safety = Safe, status = Deprecated(Some("replaced by `lastFrameGas`")))] function lastCallGas() external view returns (Gas memory gas); + /// Gets gas measurements for the last completed call or create, from the callee's perspective. + /// Unlike `lastCallGas`, CREATE and CREATE2 frames are recorded too. Cheatcode calls are never recorded. + /// See `Gas` for field semantics and . + #[cheatcode(group = Evm, safety = Safe)] + function lastFrameGas() external view returns (Gas memory gas); + // ======== Test Assertions and Utilities ======== /// If the condition is false, discard this run's fuzz inputs and generate new ones. @@ -1020,6 +1288,11 @@ interface Vm { #[cheatcode(group = Testing, safety = Unsafe)] function expectCall(address callee, uint256 msgValue, uint64 gas, bytes calldata data, uint64 count) external; + /// Expects a delegate call to an address with the specified calldata. + /// Calldata can either be a strict or a partial match. + #[cheatcode(group = Testing, safety = Unsafe)] + function expectDelegateCall(address callee, bytes calldata data) external; + /// Expect a call to an address with the specified `msg.value` and calldata, and a *minimum* amount of gas. #[cheatcode(group = Testing, safety = Unsafe)] function expectCallMinGas(address callee, uint256 msgValue, uint64 minGas, bytes calldata data) external; @@ -1032,6 +1305,9 @@ interface Vm { /// Prepare an expected log with (bool checkTopic1, bool checkTopic2, bool checkTopic3, bool checkData.). /// Call this function, then emit an event, then call a function. Internally after the call, we check if /// logs were emitted in the expected order with the expected topics and data (as specified by the booleans). + /// Must be placed immediately before the call you want to assert on. If the next call reverts and the + /// revert is caught by the caller (low-level call or try/catch), the expectation remains active and may + /// be satisfied by a log emitted from a later call. #[cheatcode(group = Testing, safety = Unsafe)] function expectEmit(bool checkTopic1, bool checkTopic2, bool checkTopic3, bool checkData) external; @@ -1043,6 +1319,9 @@ interface Vm { /// Prepare an expected log with all topic and data checks enabled. /// Call this function, then emit an event, then call a function. Internally after the call, we check if /// logs were emitted in the expected order with the expected topics and data. + /// Must be placed immediately before the call you want to assert on. If the next call reverts and the + /// revert is caught by the caller (low-level call or try/catch), the expectation remains active and may + /// be satisfied by a log emitted from a later call. #[cheatcode(group = Testing, safety = Unsafe)] function expectEmit() external; @@ -1096,6 +1375,25 @@ interface Vm { #[cheatcode(group = Testing, safety = Unsafe)] function expectCreate2(bytes calldata bytecode, address deployer) external; + /// Expects a TIP-20 `LogoURIUpdated(address indexed updater, string newLogoURI)` event. + #[cheatcode(group = Testing, safety = Unsafe)] + function expectTip20LogoURIUpdated(address token, address updater, string calldata newLogoURI) external; + + /// Expects a call to `SignatureVerifier.verifyKeychain(account, digest, signature)`. + #[cheatcode(group = Testing, safety = Unsafe)] + function expectKeychainVerified(address account, bytes32 digest, bytes calldata signature) external; + + /// Expects a call to `SignatureVerifier.verifyKeychainAdmin(account, digest, signature)`. + /// + /// The supplied `digest` should already be domain-separated with chain ID, contract address, + /// and account address. + #[cheatcode(group = Testing, safety = Unsafe)] + function expectKeychainAdminVerified(address account, bytes32 digest, bytes calldata signature) external; + + /// Expects a TIP-20 `LogoURIUpdated(address indexed updater, string newLogoURI)` event. + #[cheatcode(group = Testing, safety = Unsafe)] + function expectLogoURIUpdated(address token, address updater, string calldata newLogoURI) external; + /// Expects an error on next call with any revert data. #[cheatcode(group = Testing, safety = Unsafe)] function expectRevert() external; @@ -1109,14 +1407,28 @@ interface Vm { function expectRevert(bytes calldata revertData) external; /// Expects an error with any revert data on next call to reverter address. + /// + /// The `reverter` argument is matched against the address associated with + /// the frame that produced the revert: + /// - For a CALL: the address that was called. + /// - For a CREATE / CREATE2: the would-be deployed address of the failed + /// deployment (computed from the deployer + nonce, or salt + initcode). + /// + /// For a single expected revert, the innermost reverting frame wins in + /// nested CALL, CREATE, or mixed chains. With `count > 1`, nested + /// CREATE / CREATE2 chains apply the same rule independently to each + /// iteration; nested CALL chains keep their existing + /// outermost-call-per-iteration behavior. #[cheatcode(group = Testing, safety = Unsafe)] function expectRevert(address reverter) external; /// Expects an error from reverter address on next call, with any revert data. + /// See `expectRevert(address)` for `reverter` matching semantics. #[cheatcode(group = Testing, safety = Unsafe)] function expectRevert(bytes4 revertData, address reverter) external; /// Expects an error from reverter address on next call, that exactly matches the revert data. + /// See `expectRevert(address)` for `reverter` matching semantics. #[cheatcode(group = Testing, safety = Unsafe)] function expectRevert(bytes calldata revertData, address reverter) external; @@ -1133,14 +1445,17 @@ interface Vm { function expectRevert(bytes calldata revertData, uint64 count) external; /// Expects a `count` number of reverts from the upcoming calls from the reverter address. + /// See `expectRevert(address)` for `reverter` matching semantics. #[cheatcode(group = Testing, safety = Unsafe)] function expectRevert(address reverter, uint64 count) external; /// Expects a `count` number of reverts from the upcoming calls from the reverter address that match the revert data. + /// See `expectRevert(address)` for `reverter` matching semantics. #[cheatcode(group = Testing, safety = Unsafe)] function expectRevert(bytes4 revertData, address reverter, uint64 count) external; /// Expects a `count` number of reverts from the upcoming calls from the reverter address that exactly match the revert data. + /// See `expectRevert(address)` for `reverter` matching semantics. #[cheatcode(group = Testing, safety = Unsafe)] function expectRevert(bytes calldata revertData, address reverter, uint64 count) external; @@ -1149,6 +1464,7 @@ interface Vm { function expectPartialRevert(bytes4 revertData) external; /// Expects an error on next call to reverter address, that starts with the revert data. + /// See `expectRevert(address)` for `reverter` matching semantics. #[cheatcode(group = Testing, safety = Unsafe)] function expectPartialRevert(bytes4 revertData, address reverter) external; @@ -1160,7 +1476,7 @@ interface Vm { #[cheatcode(group = Testing, safety = Unsafe, status = Internal)] function _expectCheatcodeRevert(bytes4 revertData) external; - /// Expects an error on next cheatcode call that exactly matches the revert data. + /// Expects an error on next cheatcode call that contains the revert data. #[cheatcode(group = Testing, safety = Unsafe, status = Internal)] function _expectCheatcodeRevert(bytes calldata revertData) external; @@ -1193,7 +1509,7 @@ interface Vm { /// Asserts that the given condition is true and includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertTrue(bool condition, string calldata error) external pure; + function assertTrue(bool condition, string calldata err) external pure; /// Asserts that the given condition is false. #[cheatcode(group = Testing, safety = Safe)] @@ -1201,7 +1517,7 @@ interface Vm { /// Asserts that the given condition is false and includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertFalse(bool condition, string calldata error) external pure; + function assertFalse(bool condition, string calldata err) external pure; /// Asserts that two `bool` values are equal. #[cheatcode(group = Testing, safety = Safe)] @@ -1209,7 +1525,7 @@ interface Vm { /// Asserts that two `bool` values are equal and includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertEq(bool left, bool right, string calldata error) external pure; + function assertEq(bool left, bool right, string calldata err) external pure; /// Asserts that two `uint256` values are equal. #[cheatcode(group = Testing, safety = Safe)] @@ -1217,7 +1533,7 @@ interface Vm { /// Asserts that two `uint256` values are equal and includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertEq(uint256 left, uint256 right, string calldata error) external pure; + function assertEq(uint256 left, uint256 right, string calldata err) external pure; /// Asserts that two `int256` values are equal. #[cheatcode(group = Testing, safety = Safe)] @@ -1225,7 +1541,7 @@ interface Vm { /// Asserts that two `int256` values are equal and includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertEq(int256 left, int256 right, string calldata error) external pure; + function assertEq(int256 left, int256 right, string calldata err) external pure; /// Asserts that two `address` values are equal. #[cheatcode(group = Testing, safety = Safe)] @@ -1233,7 +1549,7 @@ interface Vm { /// Asserts that two `address` values are equal and includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertEq(address left, address right, string calldata error) external pure; + function assertEq(address left, address right, string calldata err) external pure; /// Asserts that two `bytes32` values are equal. #[cheatcode(group = Testing, safety = Safe)] @@ -1241,7 +1557,7 @@ interface Vm { /// Asserts that two `bytes32` values are equal and includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertEq(bytes32 left, bytes32 right, string calldata error) external pure; + function assertEq(bytes32 left, bytes32 right, string calldata err) external pure; /// Asserts that two `string` values are equal. #[cheatcode(group = Testing, safety = Safe)] @@ -1249,7 +1565,7 @@ interface Vm { /// Asserts that two `string` values are equal and includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertEq(string calldata left, string calldata right, string calldata error) external pure; + function assertEq(string calldata left, string calldata right, string calldata err) external pure; /// Asserts that two `bytes` values are equal. #[cheatcode(group = Testing, safety = Safe)] @@ -1257,7 +1573,7 @@ interface Vm { /// Asserts that two `bytes` values are equal and includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertEq(bytes calldata left, bytes calldata right, string calldata error) external pure; + function assertEq(bytes calldata left, bytes calldata right, string calldata err) external pure; /// Asserts that two arrays of `bool` values are equal. #[cheatcode(group = Testing, safety = Safe)] @@ -1265,7 +1581,7 @@ interface Vm { /// Asserts that two arrays of `bool` values are equal and includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertEq(bool[] calldata left, bool[] calldata right, string calldata error) external pure; + function assertEq(bool[] calldata left, bool[] calldata right, string calldata err) external pure; /// Asserts that two arrays of `uint256 values are equal. #[cheatcode(group = Testing, safety = Safe)] @@ -1273,7 +1589,7 @@ interface Vm { /// Asserts that two arrays of `uint256` values are equal and includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertEq(uint256[] calldata left, uint256[] calldata right, string calldata error) external pure; + function assertEq(uint256[] calldata left, uint256[] calldata right, string calldata err) external pure; /// Asserts that two arrays of `int256` values are equal. #[cheatcode(group = Testing, safety = Safe)] @@ -1281,7 +1597,7 @@ interface Vm { /// Asserts that two arrays of `int256` values are equal and includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertEq(int256[] calldata left, int256[] calldata right, string calldata error) external pure; + function assertEq(int256[] calldata left, int256[] calldata right, string calldata err) external pure; /// Asserts that two arrays of `address` values are equal. #[cheatcode(group = Testing, safety = Safe)] @@ -1289,7 +1605,7 @@ interface Vm { /// Asserts that two arrays of `address` values are equal and includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertEq(address[] calldata left, address[] calldata right, string calldata error) external pure; + function assertEq(address[] calldata left, address[] calldata right, string calldata err) external pure; /// Asserts that two arrays of `bytes32` values are equal. #[cheatcode(group = Testing, safety = Safe)] @@ -1297,7 +1613,7 @@ interface Vm { /// Asserts that two arrays of `bytes32` values are equal and includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertEq(bytes32[] calldata left, bytes32[] calldata right, string calldata error) external pure; + function assertEq(bytes32[] calldata left, bytes32[] calldata right, string calldata err) external pure; /// Asserts that two arrays of `string` values are equal. #[cheatcode(group = Testing, safety = Safe)] @@ -1305,7 +1621,7 @@ interface Vm { /// Asserts that two arrays of `string` values are equal and includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertEq(string[] calldata left, string[] calldata right, string calldata error) external pure; + function assertEq(string[] calldata left, string[] calldata right, string calldata err) external pure; /// Asserts that two arrays of `bytes` values are equal. #[cheatcode(group = Testing, safety = Safe)] @@ -1313,7 +1629,7 @@ interface Vm { /// Asserts that two arrays of `bytes` values are equal and includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertEq(bytes[] calldata left, bytes[] calldata right, string calldata error) external pure; + function assertEq(bytes[] calldata left, bytes[] calldata right, string calldata err) external pure; /// Asserts that two `uint256` values are equal, formatting them with decimals in failure message. #[cheatcode(group = Testing, safety = Safe)] @@ -1322,7 +1638,7 @@ interface Vm { /// Asserts that two `uint256` values are equal, formatting them with decimals in failure message. /// Includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertEqDecimal(uint256 left, uint256 right, uint256 decimals, string calldata error) external pure; + function assertEqDecimal(uint256 left, uint256 right, uint256 decimals, string calldata err) external pure; /// Asserts that two `int256` values are equal, formatting them with decimals in failure message. #[cheatcode(group = Testing, safety = Safe)] @@ -1331,7 +1647,7 @@ interface Vm { /// Asserts that two `int256` values are equal, formatting them with decimals in failure message. /// Includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertEqDecimal(int256 left, int256 right, uint256 decimals, string calldata error) external pure; + function assertEqDecimal(int256 left, int256 right, uint256 decimals, string calldata err) external pure; /// Asserts that two `bool` values are not equal. #[cheatcode(group = Testing, safety = Safe)] @@ -1339,7 +1655,7 @@ interface Vm { /// Asserts that two `bool` values are not equal and includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertNotEq(bool left, bool right, string calldata error) external pure; + function assertNotEq(bool left, bool right, string calldata err) external pure; /// Asserts that two `uint256` values are not equal. #[cheatcode(group = Testing, safety = Safe)] @@ -1347,7 +1663,7 @@ interface Vm { /// Asserts that two `uint256` values are not equal and includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertNotEq(uint256 left, uint256 right, string calldata error) external pure; + function assertNotEq(uint256 left, uint256 right, string calldata err) external pure; /// Asserts that two `int256` values are not equal. #[cheatcode(group = Testing, safety = Safe)] @@ -1355,7 +1671,7 @@ interface Vm { /// Asserts that two `int256` values are not equal and includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertNotEq(int256 left, int256 right, string calldata error) external pure; + function assertNotEq(int256 left, int256 right, string calldata err) external pure; /// Asserts that two `address` values are not equal. #[cheatcode(group = Testing, safety = Safe)] @@ -1363,7 +1679,7 @@ interface Vm { /// Asserts that two `address` values are not equal and includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertNotEq(address left, address right, string calldata error) external pure; + function assertNotEq(address left, address right, string calldata err) external pure; /// Asserts that two `bytes32` values are not equal. #[cheatcode(group = Testing, safety = Safe)] @@ -1371,7 +1687,7 @@ interface Vm { /// Asserts that two `bytes32` values are not equal and includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertNotEq(bytes32 left, bytes32 right, string calldata error) external pure; + function assertNotEq(bytes32 left, bytes32 right, string calldata err) external pure; /// Asserts that two `string` values are not equal. #[cheatcode(group = Testing, safety = Safe)] @@ -1379,7 +1695,7 @@ interface Vm { /// Asserts that two `string` values are not equal and includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertNotEq(string calldata left, string calldata right, string calldata error) external pure; + function assertNotEq(string calldata left, string calldata right, string calldata err) external pure; /// Asserts that two `bytes` values are not equal. #[cheatcode(group = Testing, safety = Safe)] @@ -1387,7 +1703,7 @@ interface Vm { /// Asserts that two `bytes` values are not equal and includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertNotEq(bytes calldata left, bytes calldata right, string calldata error) external pure; + function assertNotEq(bytes calldata left, bytes calldata right, string calldata err) external pure; /// Asserts that two arrays of `bool` values are not equal. #[cheatcode(group = Testing, safety = Safe)] @@ -1395,7 +1711,7 @@ interface Vm { /// Asserts that two arrays of `bool` values are not equal and includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertNotEq(bool[] calldata left, bool[] calldata right, string calldata error) external pure; + function assertNotEq(bool[] calldata left, bool[] calldata right, string calldata err) external pure; /// Asserts that two arrays of `uint256` values are not equal. #[cheatcode(group = Testing, safety = Safe)] @@ -1403,7 +1719,7 @@ interface Vm { /// Asserts that two arrays of `uint256` values are not equal and includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertNotEq(uint256[] calldata left, uint256[] calldata right, string calldata error) external pure; + function assertNotEq(uint256[] calldata left, uint256[] calldata right, string calldata err) external pure; /// Asserts that two arrays of `int256` values are not equal. #[cheatcode(group = Testing, safety = Safe)] @@ -1411,7 +1727,7 @@ interface Vm { /// Asserts that two arrays of `int256` values are not equal and includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertNotEq(int256[] calldata left, int256[] calldata right, string calldata error) external pure; + function assertNotEq(int256[] calldata left, int256[] calldata right, string calldata err) external pure; /// Asserts that two arrays of `address` values are not equal. #[cheatcode(group = Testing, safety = Safe)] @@ -1419,7 +1735,7 @@ interface Vm { /// Asserts that two arrays of `address` values are not equal and includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertNotEq(address[] calldata left, address[] calldata right, string calldata error) external pure; + function assertNotEq(address[] calldata left, address[] calldata right, string calldata err) external pure; /// Asserts that two arrays of `bytes32` values are not equal. #[cheatcode(group = Testing, safety = Safe)] @@ -1427,7 +1743,7 @@ interface Vm { /// Asserts that two arrays of `bytes32` values are not equal and includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertNotEq(bytes32[] calldata left, bytes32[] calldata right, string calldata error) external pure; + function assertNotEq(bytes32[] calldata left, bytes32[] calldata right, string calldata err) external pure; /// Asserts that two arrays of `string` values are not equal. #[cheatcode(group = Testing, safety = Safe)] @@ -1435,7 +1751,7 @@ interface Vm { /// Asserts that two arrays of `string` values are not equal and includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertNotEq(string[] calldata left, string[] calldata right, string calldata error) external pure; + function assertNotEq(string[] calldata left, string[] calldata right, string calldata err) external pure; /// Asserts that two arrays of `bytes` values are not equal. #[cheatcode(group = Testing, safety = Safe)] @@ -1443,7 +1759,7 @@ interface Vm { /// Asserts that two arrays of `bytes` values are not equal and includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertNotEq(bytes[] calldata left, bytes[] calldata right, string calldata error) external pure; + function assertNotEq(bytes[] calldata left, bytes[] calldata right, string calldata err) external pure; /// Asserts that two `uint256` values are not equal, formatting them with decimals in failure message. #[cheatcode(group = Testing, safety = Safe)] @@ -1452,7 +1768,7 @@ interface Vm { /// Asserts that two `uint256` values are not equal, formatting them with decimals in failure message. /// Includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertNotEqDecimal(uint256 left, uint256 right, uint256 decimals, string calldata error) external pure; + function assertNotEqDecimal(uint256 left, uint256 right, uint256 decimals, string calldata err) external pure; /// Asserts that two `int256` values are not equal, formatting them with decimals in failure message. #[cheatcode(group = Testing, safety = Safe)] @@ -1461,7 +1777,7 @@ interface Vm { /// Asserts that two `int256` values are not equal, formatting them with decimals in failure message. /// Includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertNotEqDecimal(int256 left, int256 right, uint256 decimals, string calldata error) external pure; + function assertNotEqDecimal(int256 left, int256 right, uint256 decimals, string calldata err) external pure; /// Compares two `uint256` values. Expects first value to be greater than second. #[cheatcode(group = Testing, safety = Safe)] @@ -1470,7 +1786,7 @@ interface Vm { /// Compares two `uint256` values. Expects first value to be greater than second. /// Includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertGt(uint256 left, uint256 right, string calldata error) external pure; + function assertGt(uint256 left, uint256 right, string calldata err) external pure; /// Compares two `int256` values. Expects first value to be greater than second. #[cheatcode(group = Testing, safety = Safe)] @@ -1479,7 +1795,7 @@ interface Vm { /// Compares two `int256` values. Expects first value to be greater than second. /// Includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertGt(int256 left, int256 right, string calldata error) external pure; + function assertGt(int256 left, int256 right, string calldata err) external pure; /// Compares two `uint256` values. Expects first value to be greater than second. /// Formats values with decimals in failure message. @@ -1489,7 +1805,7 @@ interface Vm { /// Compares two `uint256` values. Expects first value to be greater than second. /// Formats values with decimals in failure message. Includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertGtDecimal(uint256 left, uint256 right, uint256 decimals, string calldata error) external pure; + function assertGtDecimal(uint256 left, uint256 right, uint256 decimals, string calldata err) external pure; /// Compares two `int256` values. Expects first value to be greater than second. /// Formats values with decimals in failure message. @@ -1499,7 +1815,7 @@ interface Vm { /// Compares two `int256` values. Expects first value to be greater than second. /// Formats values with decimals in failure message. Includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertGtDecimal(int256 left, int256 right, uint256 decimals, string calldata error) external pure; + function assertGtDecimal(int256 left, int256 right, uint256 decimals, string calldata err) external pure; /// Compares two `uint256` values. Expects first value to be greater than or equal to second. #[cheatcode(group = Testing, safety = Safe)] @@ -1508,7 +1824,7 @@ interface Vm { /// Compares two `uint256` values. Expects first value to be greater than or equal to second. /// Includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertGe(uint256 left, uint256 right, string calldata error) external pure; + function assertGe(uint256 left, uint256 right, string calldata err) external pure; /// Compares two `int256` values. Expects first value to be greater than or equal to second. #[cheatcode(group = Testing, safety = Safe)] @@ -1517,7 +1833,7 @@ interface Vm { /// Compares two `int256` values. Expects first value to be greater than or equal to second. /// Includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertGe(int256 left, int256 right, string calldata error) external pure; + function assertGe(int256 left, int256 right, string calldata err) external pure; /// Compares two `uint256` values. Expects first value to be greater than or equal to second. /// Formats values with decimals in failure message. @@ -1527,7 +1843,7 @@ interface Vm { /// Compares two `uint256` values. Expects first value to be greater than or equal to second. /// Formats values with decimals in failure message. Includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertGeDecimal(uint256 left, uint256 right, uint256 decimals, string calldata error) external pure; + function assertGeDecimal(uint256 left, uint256 right, uint256 decimals, string calldata err) external pure; /// Compares two `int256` values. Expects first value to be greater than or equal to second. /// Formats values with decimals in failure message. @@ -1537,7 +1853,7 @@ interface Vm { /// Compares two `int256` values. Expects first value to be greater than or equal to second. /// Formats values with decimals in failure message. Includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertGeDecimal(int256 left, int256 right, uint256 decimals, string calldata error) external pure; + function assertGeDecimal(int256 left, int256 right, uint256 decimals, string calldata err) external pure; /// Compares two `uint256` values. Expects first value to be less than second. #[cheatcode(group = Testing, safety = Safe)] @@ -1546,7 +1862,7 @@ interface Vm { /// Compares two `uint256` values. Expects first value to be less than second. /// Includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertLt(uint256 left, uint256 right, string calldata error) external pure; + function assertLt(uint256 left, uint256 right, string calldata err) external pure; /// Compares two `int256` values. Expects first value to be less than second. #[cheatcode(group = Testing, safety = Safe)] @@ -1555,7 +1871,7 @@ interface Vm { /// Compares two `int256` values. Expects first value to be less than second. /// Includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertLt(int256 left, int256 right, string calldata error) external pure; + function assertLt(int256 left, int256 right, string calldata err) external pure; /// Compares two `uint256` values. Expects first value to be less than second. /// Formats values with decimals in failure message. @@ -1565,7 +1881,7 @@ interface Vm { /// Compares two `uint256` values. Expects first value to be less than second. /// Formats values with decimals in failure message. Includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertLtDecimal(uint256 left, uint256 right, uint256 decimals, string calldata error) external pure; + function assertLtDecimal(uint256 left, uint256 right, uint256 decimals, string calldata err) external pure; /// Compares two `int256` values. Expects first value to be less than second. /// Formats values with decimals in failure message. @@ -1575,7 +1891,7 @@ interface Vm { /// Compares two `int256` values. Expects first value to be less than second. /// Formats values with decimals in failure message. Includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertLtDecimal(int256 left, int256 right, uint256 decimals, string calldata error) external pure; + function assertLtDecimal(int256 left, int256 right, uint256 decimals, string calldata err) external pure; /// Compares two `uint256` values. Expects first value to be less than or equal to second. #[cheatcode(group = Testing, safety = Safe)] @@ -1584,7 +1900,7 @@ interface Vm { /// Compares two `uint256` values. Expects first value to be less than or equal to second. /// Includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertLe(uint256 left, uint256 right, string calldata error) external pure; + function assertLe(uint256 left, uint256 right, string calldata err) external pure; /// Compares two `int256` values. Expects first value to be less than or equal to second. #[cheatcode(group = Testing, safety = Safe)] @@ -1593,7 +1909,7 @@ interface Vm { /// Compares two `int256` values. Expects first value to be less than or equal to second. /// Includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertLe(int256 left, int256 right, string calldata error) external pure; + function assertLe(int256 left, int256 right, string calldata err) external pure; /// Compares two `uint256` values. Expects first value to be less than or equal to second. /// Formats values with decimals in failure message. @@ -1603,7 +1919,7 @@ interface Vm { /// Compares two `uint256` values. Expects first value to be less than or equal to second. /// Formats values with decimals in failure message. Includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertLeDecimal(uint256 left, uint256 right, uint256 decimals, string calldata error) external pure; + function assertLeDecimal(uint256 left, uint256 right, uint256 decimals, string calldata err) external pure; /// Compares two `int256` values. Expects first value to be less than or equal to second. /// Formats values with decimals in failure message. @@ -1613,7 +1929,7 @@ interface Vm { /// Compares two `int256` values. Expects first value to be less than or equal to second. /// Formats values with decimals in failure message. Includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertLeDecimal(int256 left, int256 right, uint256 decimals, string calldata error) external pure; + function assertLeDecimal(int256 left, int256 right, uint256 decimals, string calldata err) external pure; /// Compares two `uint256` values. Expects difference to be less than or equal to `maxDelta`. #[cheatcode(group = Testing, safety = Safe)] @@ -1622,7 +1938,7 @@ interface Vm { /// Compares two `uint256` values. Expects difference to be less than or equal to `maxDelta`. /// Includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertApproxEqAbs(uint256 left, uint256 right, uint256 maxDelta, string calldata error) external pure; + function assertApproxEqAbs(uint256 left, uint256 right, uint256 maxDelta, string calldata err) external pure; /// Compares two `int256` values. Expects difference to be less than or equal to `maxDelta`. #[cheatcode(group = Testing, safety = Safe)] @@ -1631,7 +1947,7 @@ interface Vm { /// Compares two `int256` values. Expects difference to be less than or equal to `maxDelta`. /// Includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertApproxEqAbs(int256 left, int256 right, uint256 maxDelta, string calldata error) external pure; + function assertApproxEqAbs(int256 left, int256 right, uint256 maxDelta, string calldata err) external pure; /// Compares two `uint256` values. Expects difference to be less than or equal to `maxDelta`. /// Formats values with decimals in failure message. @@ -1646,7 +1962,7 @@ interface Vm { uint256 right, uint256 maxDelta, uint256 decimals, - string calldata error + string calldata err ) external pure; /// Compares two `int256` values. Expects difference to be less than or equal to `maxDelta`. @@ -1662,7 +1978,7 @@ interface Vm { int256 right, uint256 maxDelta, uint256 decimals, - string calldata error + string calldata err ) external pure; /// Compares two `uint256` values. Expects relative difference in percents to be less than or equal to `maxPercentDelta`. @@ -1674,7 +1990,7 @@ interface Vm { /// `maxPercentDelta` is an 18 decimal fixed point number, where 1e18 == 100% /// Includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertApproxEqRel(uint256 left, uint256 right, uint256 maxPercentDelta, string calldata error) external pure; + function assertApproxEqRel(uint256 left, uint256 right, uint256 maxPercentDelta, string calldata err) external pure; /// Compares two `int256` values. Expects relative difference in percents to be less than or equal to `maxPercentDelta`. /// `maxPercentDelta` is an 18 decimal fixed point number, where 1e18 == 100% @@ -1685,7 +2001,7 @@ interface Vm { /// `maxPercentDelta` is an 18 decimal fixed point number, where 1e18 == 100% /// Includes error message into revert string on failure. #[cheatcode(group = Testing, safety = Safe)] - function assertApproxEqRel(int256 left, int256 right, uint256 maxPercentDelta, string calldata error) external pure; + function assertApproxEqRel(int256 left, int256 right, uint256 maxPercentDelta, string calldata err) external pure; /// Compares two `uint256` values. Expects relative difference in percents to be less than or equal to `maxPercentDelta`. /// `maxPercentDelta` is an 18 decimal fixed point number, where 1e18 == 100% @@ -1707,7 +2023,7 @@ interface Vm { uint256 right, uint256 maxPercentDelta, uint256 decimals, - string calldata error + string calldata err ) external pure; /// Compares two `int256` values. Expects relative difference in percents to be less than or equal to `maxPercentDelta`. @@ -1730,7 +2046,7 @@ interface Vm { int256 right, uint256 maxPercentDelta, uint256 decimals, - string calldata error + string calldata err ) external pure; /// Returns true if the current Foundry version is greater than or equal to the given version. @@ -1778,6 +2094,11 @@ interface Vm { #[cheatcode(group = Filesystem)] function projectRoot() external view returns (string memory path); + /// Get the source file path of the currently running test or script contract, + /// relative to the project root. + #[cheatcode(group = Filesystem)] + function currentFilePath() external view returns (string memory path); + /// Returns the time since unix epoch in milliseconds. #[cheatcode(group = Filesystem)] function unixTime() external view returns (uint256 milliseconds); @@ -1881,64 +2202,88 @@ interface Vm { function getArtifactPathByDeployedCode(bytes calldata deployedCode) external view returns (string memory path); /// Gets the creation bytecode from an artifact file. Takes in the relative path to the json file or the path to the - /// artifact in the form of :: where and parts are optional. + /// artifact in the form of :: or :: where and + /// / parts are optional. Use to select artifacts compiled with a specific profile + /// from foundry.toml. #[cheatcode(group = Filesystem)] function getCode(string calldata artifactPath) external view returns (bytes memory creationBytecode); - /// Deploys a contract from an artifact file. Takes in the relative path to the json file or the path to the + /// Gets all function selectors from a contract artifact. Takes in the relative path to the json file or the path to the /// artifact in the form of :: where and parts are optional. #[cheatcode(group = Filesystem)] + function getSelectors(string calldata artifactPath) external view returns (bytes4[] memory selectors); + + /// Deploys a contract from an artifact file. Takes in the relative path to the json file or the path to the + /// artifact in the form of :: or :: where and + /// / parts are optional. + /// Reverts if the target artifact contains unlinked library placeholders. + #[cheatcode(group = Filesystem)] function deployCode(string calldata artifactPath) external returns (address deployedAddress); /// Deploys a contract from an artifact file. Takes in the relative path to the json file or the path to the - /// artifact in the form of :: where and parts are optional. + /// artifact in the form of :: or :: where and + /// / parts are optional. + /// Reverts if the target artifact contains unlinked library placeholders. /// /// Additionally accepts abi-encoded constructor arguments. #[cheatcode(group = Filesystem)] function deployCode(string calldata artifactPath, bytes calldata constructorArgs) external returns (address deployedAddress); /// Deploys a contract from an artifact file. Takes in the relative path to the json file or the path to the - /// artifact in the form of :: where and parts are optional. + /// artifact in the form of :: or :: where and + /// / parts are optional. + /// Reverts if the target artifact contains unlinked library placeholders. /// /// Additionally accepts `msg.value`. #[cheatcode(group = Filesystem)] function deployCode(string calldata artifactPath, uint256 value) external returns (address deployedAddress); /// Deploys a contract from an artifact file. Takes in the relative path to the json file or the path to the - /// artifact in the form of :: where and parts are optional. + /// artifact in the form of :: or :: where and + /// / parts are optional. + /// Reverts if the target artifact contains unlinked library placeholders. /// /// Additionally accepts abi-encoded constructor arguments and `msg.value`. #[cheatcode(group = Filesystem)] function deployCode(string calldata artifactPath, bytes calldata constructorArgs, uint256 value) external returns (address deployedAddress); /// Deploys a contract from an artifact file, using the CREATE2 salt. Takes in the relative path to the json file or the path to the - /// artifact in the form of :: where and parts are optional. + /// artifact in the form of :: or :: where and + /// / parts are optional. + /// Reverts if the target artifact contains unlinked library placeholders. #[cheatcode(group = Filesystem)] function deployCode(string calldata artifactPath, bytes32 salt) external returns (address deployedAddress); /// Deploys a contract from an artifact file, using the CREATE2 salt. Takes in the relative path to the json file or the path to the - /// artifact in the form of :: where and parts are optional. + /// artifact in the form of :: or :: where and + /// / parts are optional. + /// Reverts if the target artifact contains unlinked library placeholders. /// /// Additionally accepts abi-encoded constructor arguments. #[cheatcode(group = Filesystem)] function deployCode(string calldata artifactPath, bytes calldata constructorArgs, bytes32 salt) external returns (address deployedAddress); /// Deploys a contract from an artifact file, using the CREATE2 salt. Takes in the relative path to the json file or the path to the - /// artifact in the form of :: where and parts are optional. + /// artifact in the form of :: or :: where and + /// / parts are optional. + /// Reverts if the target artifact contains unlinked library placeholders. /// /// Additionally accepts `msg.value`. #[cheatcode(group = Filesystem)] function deployCode(string calldata artifactPath, uint256 value, bytes32 salt) external returns (address deployedAddress); /// Deploys a contract from an artifact file, using the CREATE2 salt. Takes in the relative path to the json file or the path to the - /// artifact in the form of :: where and parts are optional. + /// artifact in the form of :: or :: where and + /// / parts are optional. + /// Reverts if the target artifact contains unlinked library placeholders. /// /// Additionally accepts abi-encoded constructor arguments and `msg.value`. #[cheatcode(group = Filesystem)] function deployCode(string calldata artifactPath, bytes calldata constructorArgs, uint256 value, bytes32 salt) external returns (address deployedAddress); /// Gets the deployed bytecode from an artifact file. Takes in the relative path to the json file or the path to the - /// artifact in the form of :: where and parts are optional. + /// artifact in the form of :: or :: where and + /// / parts are optional. #[cheatcode(group = Filesystem)] function getDeployedCode(string calldata artifactPath) external view returns (bytes memory runtimeBytecode); @@ -1986,6 +2331,18 @@ interface Vm { #[cheatcode(group = Filesystem)] function ffi(string[] calldata commandInput) external returns (bytes memory result); + /// Performs a foreign function call via the terminal and parses the output as a `uint256`. + #[cheatcode(group = Filesystem)] + function ffiUint(string[] calldata commandInput) external returns (uint256 result); + + /// Performs a foreign function call via the terminal and returns the output as a string. + #[cheatcode(group = Filesystem)] + function ffiString(string[] calldata commandInput) external returns (string memory result); + + /// Performs a foreign function call via the terminal and decodes the output as hex bytes. + #[cheatcode(group = Filesystem)] + function ffiBytes(string[] calldata commandInput) external returns (bytes memory result); + /// Performs a foreign function call via terminal and returns the exit code, stdout, and stderr. #[cheatcode(group = Filesystem)] function tryFfi(string[] calldata commandInput) external returns (FfiResult memory result); @@ -2014,6 +2371,10 @@ interface Vm { // ======== Environment Variables ======== + /// Resolves the env variable placeholders of a given input string. + #[cheatcode(group = Environment)] + function resolveEnv(string calldata input) external returns (string memory); + /// Sets environment variables. #[cheatcode(group = Environment)] function setEnv(string calldata name, string calldata value) external; @@ -2171,7 +2532,6 @@ interface Vm { function isContext(ForgeContext context) external view returns (bool result); // ======== Scripts ======== - // -------- Broadcasting Transactions -------- /// Has the next call (at this call depth only) create transactions that can later be signed and sent onchain. @@ -2258,7 +2618,7 @@ interface Vm { /// Returns addresses of available unlocked wallets in the script environment. #[cheatcode(group = Scripting)] - function getWallets() external returns (address[] memory wallets); + function getWallets() external view returns (address[] memory wallets); // ======== Utilities ======== @@ -2324,7 +2684,7 @@ interface Vm { function indexOf(string calldata input, string calldata key) external pure returns (uint256); /// Returns true if `search` is found in `subject`, false otherwise. #[cheatcode(group = String)] - function contains(string calldata subject, string calldata search) external returns (bool result); + function contains(string calldata subject, string calldata search) external pure returns (bool result); // ======== JSON Parsing and Manipulation ======== @@ -2333,18 +2693,18 @@ interface Vm { // NOTE: Please read https://book.getfoundry.sh/cheatcodes/parse-json to understand the // limitations and caveats of the JSON parsing cheats. - /// Checks if `key` exists in a JSON object + /// Checks if `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) exists in a JSON object. /// `keyExists` is being deprecated in favor of `keyExistsJson`. It will be removed in future versions. #[cheatcode(group = Json, status = Deprecated(Some("replaced by `keyExistsJson`")))] function keyExists(string calldata json, string calldata key) external view returns (bool); - /// Checks if `key` exists in a JSON object. + /// Checks if `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) exists in a JSON object. #[cheatcode(group = Json)] function keyExistsJson(string calldata json, string calldata key) external view returns (bool); /// ABI-encodes a JSON object. #[cheatcode(group = Json)] function parseJson(string calldata json) external pure returns (bytes memory abiEncodedData); - /// ABI-encodes a JSON object at `key`. + /// ABI-encodes a JSON object at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields). #[cheatcode(group = Json)] function parseJson(string calldata json, string calldata key) external pure returns (bytes memory abiEncodedData); @@ -2354,62 +2714,134 @@ interface Vm { // Type coercion works ONLY for discrete values or arrays. That means that the key must return a value or array, not // a JSON object. - /// Parses a string of JSON data at `key` and coerces it to `uint256`. + /// Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `uint256`. #[cheatcode(group = Json)] function parseJsonUint(string calldata json, string calldata key) external pure returns (uint256); - /// Parses a string of JSON data at `key` and coerces it to `uint256[]`. + /// Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `uint256`, or returns `defaultValue` if the key does not exist. + #[cheatcode(group = Json)] + function parseJsonUint(string calldata json, string calldata key, uint256 defaultValue) external pure returns (uint256); + /// Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `uint256[]`. #[cheatcode(group = Json)] function parseJsonUintArray(string calldata json, string calldata key) external pure returns (uint256[] memory); - /// Parses a string of JSON data at `key` and coerces it to `int256`. + /// Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `uint256[]`, or returns `defaultValue` if the key does not exist. + #[cheatcode(group = Json)] + function parseJsonUintArray(string calldata json, string calldata key, uint256[] calldata defaultValue) + external + pure + returns (uint256[] memory); + /// Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `int256`. #[cheatcode(group = Json)] function parseJsonInt(string calldata json, string calldata key) external pure returns (int256); - /// Parses a string of JSON data at `key` and coerces it to `int256[]`. + /// Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `int256`, or returns `defaultValue` if the key does not exist. + #[cheatcode(group = Json)] + function parseJsonInt(string calldata json, string calldata key, int256 defaultValue) external pure returns (int256); + /// Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `int256[]`. #[cheatcode(group = Json)] function parseJsonIntArray(string calldata json, string calldata key) external pure returns (int256[] memory); - /// Parses a string of JSON data at `key` and coerces it to `bool`. + /// Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `int256[]`, or returns `defaultValue` if the key does not exist. + #[cheatcode(group = Json)] + function parseJsonIntArray(string calldata json, string calldata key, int256[] calldata defaultValue) + external + pure + returns (int256[] memory); + /// Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `bool`. #[cheatcode(group = Json)] function parseJsonBool(string calldata json, string calldata key) external pure returns (bool); - /// Parses a string of JSON data at `key` and coerces it to `bool[]`. + /// Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `bool`, or returns `defaultValue` if the key does not exist. + #[cheatcode(group = Json)] + function parseJsonBool(string calldata json, string calldata key, bool defaultValue) external pure returns (bool); + /// Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `bool[]`. #[cheatcode(group = Json)] function parseJsonBoolArray(string calldata json, string calldata key) external pure returns (bool[] memory); - /// Parses a string of JSON data at `key` and coerces it to `address`. + /// Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `bool[]`, or returns `defaultValue` if the key does not exist. + #[cheatcode(group = Json)] + function parseJsonBoolArray(string calldata json, string calldata key, bool[] calldata defaultValue) + external + pure + returns (bool[] memory); + /// Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `address`. #[cheatcode(group = Json)] function parseJsonAddress(string calldata json, string calldata key) external pure returns (address); - /// Parses a string of JSON data at `key` and coerces it to `address[]`. + /// Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `address`, or returns `defaultValue` if the key does not exist. + #[cheatcode(group = Json)] + function parseJsonAddress(string calldata json, string calldata key, address defaultValue) external pure returns (address); + /// Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `address[]`. #[cheatcode(group = Json)] function parseJsonAddressArray(string calldata json, string calldata key) external pure returns (address[] memory); - /// Parses a string of JSON data at `key` and coerces it to `string`. + /// Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `address[]`, or returns `defaultValue` if the key does not exist. + #[cheatcode(group = Json)] + function parseJsonAddressArray(string calldata json, string calldata key, address[] calldata defaultValue) + external + pure + returns (address[] memory); + /// Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `string`. #[cheatcode(group = Json)] function parseJsonString(string calldata json, string calldata key) external pure returns (string memory); - /// Parses a string of JSON data at `key` and coerces it to `string[]`. + /// Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `string`, or returns `defaultValue` if the key does not exist. + #[cheatcode(group = Json)] + function parseJsonString(string calldata json, string calldata key, string calldata defaultValue) + external + pure + returns (string memory); + /// Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `string[]`. #[cheatcode(group = Json)] function parseJsonStringArray(string calldata json, string calldata key) external pure returns (string[] memory); - /// Parses a string of JSON data at `key` and coerces it to `bytes`. + /// Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `string[]`, or returns `defaultValue` if the key does not exist. + #[cheatcode(group = Json)] + function parseJsonStringArray(string calldata json, string calldata key, string[] calldata defaultValue) + external + pure + returns (string[] memory); + /// Returns the length of the JSON array at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields). + #[cheatcode(group = Json)] + function parseJsonArrayLength(string calldata json, string calldata key) external pure returns (uint256 length); + /// Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `bytes`. #[cheatcode(group = Json)] function parseJsonBytes(string calldata json, string calldata key) external pure returns (bytes memory); - /// Parses a string of JSON data at `key` and coerces it to `bytes[]`. + /// Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `bytes`, or returns `defaultValue` if the key does not exist. + #[cheatcode(group = Json)] + function parseJsonBytes(string calldata json, string calldata key, bytes calldata defaultValue) + external + pure + returns (bytes memory); + /// Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `bytes[]`. #[cheatcode(group = Json)] function parseJsonBytesArray(string calldata json, string calldata key) external pure returns (bytes[] memory); - /// Parses a string of JSON data at `key` and coerces it to `bytes32`. + /// Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `bytes[]`, or returns `defaultValue` if the key does not exist. + #[cheatcode(group = Json)] + function parseJsonBytesArray(string calldata json, string calldata key, bytes[] calldata defaultValue) + external + pure + returns (bytes[] memory); + /// Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `bytes32`. #[cheatcode(group = Json)] function parseJsonBytes32(string calldata json, string calldata key) external pure returns (bytes32); - /// Parses a string of JSON data at `key` and coerces it to `bytes32[]`. + /// Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `bytes32`, or returns `defaultValue` if the key does not exist. + #[cheatcode(group = Json)] + function parseJsonBytes32(string calldata json, string calldata key, bytes32 defaultValue) external pure returns (bytes32); + /// Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `bytes32[]`. #[cheatcode(group = Json)] function parseJsonBytes32Array(string calldata json, string calldata key) external pure returns (bytes32[] memory); + /// Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to `bytes32[]`, or returns `defaultValue` if the key does not exist. + #[cheatcode(group = Json)] + function parseJsonBytes32Array(string calldata json, string calldata key, bytes32[] calldata defaultValue) + external + pure + returns (bytes32[] memory); /// Parses a string of JSON data and coerces it to type corresponding to `typeDescription`. #[cheatcode(group = Json)] function parseJsonType(string calldata json, string calldata typeDescription) external pure returns (bytes memory); - /// Parses a string of JSON data at `key` and coerces it to type corresponding to `typeDescription`. + /// Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to type corresponding to `typeDescription`. #[cheatcode(group = Json)] function parseJsonType(string calldata json, string calldata key, string calldata typeDescription) external pure returns (bytes memory); - /// Parses a string of JSON data at `key` and coerces it to type array corresponding to `typeDescription`. + /// Parses a string of JSON data at `key` (a JSONPath selector; use `.foo`, not `foo`, for object fields) and coerces it to type array corresponding to `typeDescription`. #[cheatcode(group = Json)] function parseJsonTypeArray(string calldata json, string calldata key, string calldata typeDescription) external @@ -2527,6 +2959,7 @@ interface Vm { /// Write a serialized JSON object to an **existing** JSON file, replacing a value with key = /// This is useful to replace a specific value of a JSON file, without having to parse the entire thing. + /// This cheatcode will create new keys if they didn't previously exist. #[cheatcode(group = Json)] function writeJson(string calldata json, string calldata path, string calldata valueKey) external; @@ -2558,51 +2991,120 @@ interface Vm { /// Parses a string of TOML data at `key` and coerces it to `uint256`. #[cheatcode(group = Toml)] function parseTomlUint(string calldata toml, string calldata key) external pure returns (uint256); + /// Parses a string of TOML data at `key` and coerces it to `uint256`, or returns `defaultValue` if the key does not exist. + #[cheatcode(group = Toml)] + function parseTomlUint(string calldata toml, string calldata key, uint256 defaultValue) external pure returns (uint256); /// Parses a string of TOML data at `key` and coerces it to `uint256[]`. #[cheatcode(group = Toml)] function parseTomlUintArray(string calldata toml, string calldata key) external pure returns (uint256[] memory); + /// Parses a string of TOML data at `key` and coerces it to `uint256[]`, or returns `defaultValue` if the key does not exist. + #[cheatcode(group = Toml)] + function parseTomlUintArray(string calldata toml, string calldata key, uint256[] calldata defaultValue) + external + pure + returns (uint256[] memory); /// Parses a string of TOML data at `key` and coerces it to `int256`. #[cheatcode(group = Toml)] function parseTomlInt(string calldata toml, string calldata key) external pure returns (int256); + /// Parses a string of TOML data at `key` and coerces it to `int256`, or returns `defaultValue` if the key does not exist. + #[cheatcode(group = Toml)] + function parseTomlInt(string calldata toml, string calldata key, int256 defaultValue) external pure returns (int256); /// Parses a string of TOML data at `key` and coerces it to `int256[]`. #[cheatcode(group = Toml)] function parseTomlIntArray(string calldata toml, string calldata key) external pure returns (int256[] memory); + /// Parses a string of TOML data at `key` and coerces it to `int256[]`, or returns `defaultValue` if the key does not exist. + #[cheatcode(group = Toml)] + function parseTomlIntArray(string calldata toml, string calldata key, int256[] calldata defaultValue) + external + pure + returns (int256[] memory); /// Parses a string of TOML data at `key` and coerces it to `bool`. #[cheatcode(group = Toml)] function parseTomlBool(string calldata toml, string calldata key) external pure returns (bool); + /// Parses a string of TOML data at `key` and coerces it to `bool`, or returns `defaultValue` if the key does not exist. + #[cheatcode(group = Toml)] + function parseTomlBool(string calldata toml, string calldata key, bool defaultValue) external pure returns (bool); /// Parses a string of TOML data at `key` and coerces it to `bool[]`. #[cheatcode(group = Toml)] function parseTomlBoolArray(string calldata toml, string calldata key) external pure returns (bool[] memory); + /// Parses a string of TOML data at `key` and coerces it to `bool[]`, or returns `defaultValue` if the key does not exist. + #[cheatcode(group = Toml)] + function parseTomlBoolArray(string calldata toml, string calldata key, bool[] calldata defaultValue) + external + pure + returns (bool[] memory); /// Parses a string of TOML data at `key` and coerces it to `address`. #[cheatcode(group = Toml)] function parseTomlAddress(string calldata toml, string calldata key) external pure returns (address); + /// Parses a string of TOML data at `key` and coerces it to `address`, or returns `defaultValue` if the key does not exist. + #[cheatcode(group = Toml)] + function parseTomlAddress(string calldata toml, string calldata key, address defaultValue) external pure returns (address); /// Parses a string of TOML data at `key` and coerces it to `address[]`. #[cheatcode(group = Toml)] function parseTomlAddressArray(string calldata toml, string calldata key) external pure returns (address[] memory); + /// Parses a string of TOML data at `key` and coerces it to `address[]`, or returns `defaultValue` if the key does not exist. + #[cheatcode(group = Toml)] + function parseTomlAddressArray(string calldata toml, string calldata key, address[] calldata defaultValue) + external + pure + returns (address[] memory); /// Parses a string of TOML data at `key` and coerces it to `string`. #[cheatcode(group = Toml)] function parseTomlString(string calldata toml, string calldata key) external pure returns (string memory); + /// Parses a string of TOML data at `key` and coerces it to `string`, or returns `defaultValue` if the key does not exist. + #[cheatcode(group = Toml)] + function parseTomlString(string calldata toml, string calldata key, string calldata defaultValue) + external + pure + returns (string memory); /// Parses a string of TOML data at `key` and coerces it to `string[]`. #[cheatcode(group = Toml)] function parseTomlStringArray(string calldata toml, string calldata key) external pure returns (string[] memory); + /// Parses a string of TOML data at `key` and coerces it to `string[]`, or returns `defaultValue` if the key does not exist. + #[cheatcode(group = Toml)] + function parseTomlStringArray(string calldata toml, string calldata key, string[] calldata defaultValue) + external + pure + returns (string[] memory); /// Parses a string of TOML data at `key` and coerces it to `bytes`. #[cheatcode(group = Toml)] function parseTomlBytes(string calldata toml, string calldata key) external pure returns (bytes memory); + /// Parses a string of TOML data at `key` and coerces it to `bytes`, or returns `defaultValue` if the key does not exist. + #[cheatcode(group = Toml)] + function parseTomlBytes(string calldata toml, string calldata key, bytes calldata defaultValue) + external + pure + returns (bytes memory); /// Parses a string of TOML data at `key` and coerces it to `bytes[]`. #[cheatcode(group = Toml)] function parseTomlBytesArray(string calldata toml, string calldata key) external pure returns (bytes[] memory); + /// Parses a string of TOML data at `key` and coerces it to `bytes[]`, or returns `defaultValue` if the key does not exist. + #[cheatcode(group = Toml)] + function parseTomlBytesArray(string calldata toml, string calldata key, bytes[] calldata defaultValue) + external + pure + returns (bytes[] memory); /// Parses a string of TOML data at `key` and coerces it to `bytes32`. #[cheatcode(group = Toml)] function parseTomlBytes32(string calldata toml, string calldata key) external pure returns (bytes32); + /// Parses a string of TOML data at `key` and coerces it to `bytes32`, or returns `defaultValue` if the key does not exist. + #[cheatcode(group = Toml)] + function parseTomlBytes32(string calldata toml, string calldata key, bytes32 defaultValue) external pure returns (bytes32); /// Parses a string of TOML data at `key` and coerces it to `bytes32[]`. #[cheatcode(group = Toml)] function parseTomlBytes32Array(string calldata toml, string calldata key) external pure returns (bytes32[] memory); + /// Parses a string of TOML data at `key` and coerces it to `bytes32[]`, or returns `defaultValue` if the key does not exist. + #[cheatcode(group = Toml)] + function parseTomlBytes32Array(string calldata toml, string calldata key, bytes32[] calldata defaultValue) + external + pure + returns (bytes32[] memory); /// Parses a string of TOML data and coerces it to type corresponding to `typeDescription`. #[cheatcode(group = Toml)] @@ -2632,6 +3134,7 @@ interface Vm { /// Takes serialized JSON, converts to TOML and write a serialized TOML table to an **existing** TOML file, replacing a value with key = /// This is useful to replace a specific value of a TOML file, without having to parse the entire thing. + /// This cheatcode will create new keys if they didn't previously exist. #[cheatcode(group = Toml)] function writeToml(string calldata json, string calldata path, string calldata valueKey) external; @@ -2653,7 +3156,7 @@ interface Vm { /// Signs data with a `Wallet`. #[cheatcode(group = Crypto)] - function sign(Wallet calldata wallet, bytes32 digest) external returns (uint8 v, bytes32 r, bytes32 s); + function sign(Wallet calldata wallet, bytes32 digest) external pure returns (uint8 v, bytes32 r, bytes32 s); /// Signs data with a `Wallet`. /// @@ -2661,12 +3164,31 @@ interface Vm { /// signature's `s` value, and the recovery id `v` in a single bytes32. /// This format reduces the signature size from 65 to 64 bytes. #[cheatcode(group = Crypto)] - function signCompact(Wallet calldata wallet, bytes32 digest) external returns (bytes32 r, bytes32 vs); + function signCompact(Wallet calldata wallet, bytes32 digest) external pure returns (bytes32 r, bytes32 vs); /// Signs `digest` with `privateKey` using the secp256k1 curve. #[cheatcode(group = Crypto)] function sign(uint256 privateKey, bytes32 digest) external pure returns (uint8 v, bytes32 r, bytes32 s); + /// Signs `digest` with `privateKey` on the secp256k1 curve, using the given `nonce` + /// as the raw ephemeral k value in ECDSA (instead of deriving it deterministically). + #[cheatcode(group = Crypto)] + function signWithNonceUnsafe(uint256 privateKey, bytes32 digest, uint256 nonce) external pure returns (uint8 v, bytes32 r, bytes32 s); + + /// Signs `digest` as a Tempo V2 keychain signature for `account` using a secp256k1 access key. + /// + /// Returns the encoded signature bytes accepted by `SignatureVerifier.verifyKeychain`. + #[cheatcode(group = Crypto)] + function signKeychain(uint256 privateKey, address account, bytes32 digest) external pure returns (bytes memory signature); + + /// Signs `digest` as a Tempo V2 keychain signature for `account` using a root or admin secp256k1 key. + /// + /// Returns the encoded signature bytes accepted by `SignatureVerifier.verifyKeychainAdmin`. + /// The supplied `digest` should already be domain-separated with chain ID, contract address, + /// and account address. + #[cheatcode(group = Crypto)] + function signKeychainAdmin(uint256 privateKey, address account, bytes32 digest) external pure returns (bytes memory signature); + /// Signs `digest` with `privateKey` using the secp256k1 curve. /// /// Returns a compact signature (`r`, `vs`) as per EIP-2098, where `vs` encodes both the @@ -2723,25 +3245,114 @@ interface Vm { #[cheatcode(group = Crypto)] function publicKeyP256(uint256 privateKey) external pure returns (uint256 publicKeyX, uint256 publicKeyY); - /// Derive a private key from a provided mnenomic string (or mnenomic file path) + /// Converts the secp256k1 affine point `(pointX, pointY)` to projective coordinates. + /// The point at infinity is converted from `(0, 0)` to `(0, 1, 0)`. + #[cheatcode(group = Crypto)] + function ecAffineToProjective(uint256 pointX, uint256 pointY) + external + pure + returns (uint256 resultX, uint256 resultY, uint256 resultZ); + + /// Converts the secp256k1 projective point `(pointX, pointY, pointZ)` to affine coordinates. + /// The point at infinity is converted from `(0, y, 0)` for any non-zero `y` to `(0, 0)`. + #[cheatcode(group = Crypto)] + function ecProjectiveToAffine(uint256 pointX, uint256 pointY, uint256 pointZ) + external + pure + returns (uint256 resultX, uint256 resultY); + + /// Adds the secp256k1 affine points `point1 = (pointX1, pointY1)` and + /// `point2 = (pointX2, pointY2)`. + /// The point at infinity is represented as `(0, 0)`. + #[cheatcode(group = Crypto)] + function ecAddAffine(uint256 pointX1, uint256 pointY1, uint256 pointX2, uint256 pointY2) + external + pure + returns (uint256 resultX, uint256 resultY); + + /// Adds the secp256k1 projective points `point1 = (pointX1, pointY1, pointZ1)` and + /// `point2 = (pointX2, pointY2, pointZ2)`. + /// The point at infinity is accepted as `(0, y, 0)` for any non-zero `y` and returned as + /// `(0, 1, 0)`. Any other result is normalized to `(x, y, 1)`. + #[cheatcode(group = Crypto)] + function ecAddProjective( + uint256 pointX1, + uint256 pointY1, + uint256 pointZ1, + uint256 pointX2, + uint256 pointY2, + uint256 pointZ2 + ) + external + pure + returns (uint256 resultX, uint256 resultY, uint256 resultZ); + + /// Multiplies the secp256k1 affine point `(pointX, pointY)` by `scalar`. + /// The scalar is reduced modulo the secp256k1 group order. + /// The point at infinity is represented as `(0, 0)`. + #[cheatcode(group = Crypto)] + function ecMulAffine(uint256 pointX, uint256 pointY, uint256 scalar) + external + pure + returns (uint256 resultX, uint256 resultY); + + /// Multiplies the secp256k1 projective point `(pointX, pointY, pointZ)` by `scalar`. + /// The scalar is reduced modulo the secp256k1 group order. + /// The point at infinity is accepted as `(0, y, 0)` for any non-zero `y` and returned as + /// `(0, 1, 0)`. Any other result is normalized to `(x, y, 1)`. + #[cheatcode(group = Crypto)] + function ecMulProjective(uint256 pointX, uint256 pointY, uint256 pointZ, uint256 scalar) + external + pure + returns (uint256 resultX, uint256 resultY, uint256 resultZ); + + /// Generates an Ed25519 key pair from a deterministic salt. + /// Returns (publicKey, privateKey) as 32-byte values. + #[cheatcode(group = Crypto, safety = Safe)] + function createEd25519Key(bytes32 salt) external pure returns (bytes32 publicKey, bytes32 privateKey); + + /// Derives the Ed25519 public key from a private key. + #[cheatcode(group = Crypto, safety = Safe)] + function publicKeyEd25519(bytes32 privateKey) external pure returns (bytes32 publicKey); + + /// Signs a message with namespace using Ed25519. + /// The signature covers namespace || message for domain separation. + /// Returns a 64-byte Ed25519 signature. + #[cheatcode(group = Crypto, safety = Safe)] + function signEd25519(bytes calldata namespace, bytes calldata message, bytes32 privateKey) + external + pure + returns (bytes memory signature); + + /// Verifies an Ed25519 signature over namespace || message. + /// Returns true if signature is valid, false otherwise. + #[cheatcode(group = Crypto, safety = Safe)] + function verifyEd25519( + bytes calldata signature, + bytes calldata namespace, + bytes calldata message, + bytes32 publicKey + ) external pure returns (bool valid); + + /// Derive a private key from a provided mnemonic string (or mnemonic file path) /// at the derivation path `m/44'/60'/0'/0/{index}`. #[cheatcode(group = Crypto)] function deriveKey(string calldata mnemonic, uint32 index) external pure returns (uint256 privateKey); - /// Derive a private key from a provided mnenomic string (or mnenomic file path) + /// Derive a private key from a provided mnemonic string (or mnemonic file path) /// at `{derivationPath}{index}`. #[cheatcode(group = Crypto)] function deriveKey(string calldata mnemonic, string calldata derivationPath, uint32 index) external pure returns (uint256 privateKey); - /// Derive a private key from a provided mnenomic string (or mnenomic file path) in the specified language + /// Derive a private key from a provided mnemonic string (or mnemonic file path) in the specified language /// at the derivation path `m/44'/60'/0'/0/{index}`. #[cheatcode(group = Crypto)] function deriveKey(string calldata mnemonic, uint32 index, string calldata language) external pure returns (uint256 privateKey); - /// Derive a private key from a provided mnenomic string (or mnenomic file path) in the specified language + /// Derive a private key from a provided mnemonic string (or mnemonic file path) in the specified language /// at `{derivationPath}{index}`. #[cheatcode(group = Crypto)] function deriveKey(string calldata mnemonic, string calldata derivationPath, uint32 index, string calldata language) @@ -2809,13 +3420,17 @@ interface Vm { #[cheatcode(group = Utilities)] function ensNamehash(string calldata name) external pure returns (bytes32); + /// Returns an uint256 value bounded in given range and different from the current one. + #[cheatcode(group = Utilities)] + function bound(uint256 current, uint256 min, uint256 max) external view returns (uint256); + /// Returns a random uint256 value. #[cheatcode(group = Utilities)] - function randomUint() external returns (uint256); + function randomUint() external view returns (uint256); /// Returns random uint256 value between the provided range (=min..=max). #[cheatcode(group = Utilities)] - function randomUint(uint256 min, uint256 max) external returns (uint256); + function randomUint(uint256 min, uint256 max) external view returns (uint256); /// Returns a random `uint256` value of given bits. #[cheatcode(group = Utilities)] @@ -2823,7 +3438,11 @@ interface Vm { /// Returns a random `address`. #[cheatcode(group = Utilities)] - function randomAddress() external returns (address); + function randomAddress() external view returns (address); + + /// Returns an int256 value bounded in given range and different from the current one. + #[cheatcode(group = Utilities)] + function bound(int256 current, int256 min, int256 max) external view returns (int256); /// Returns a random `int256` value. #[cheatcode(group = Utilities)] @@ -2859,16 +3478,16 @@ interface Vm { function resumeTracing() external view; /// Utility cheatcode to copy storage of `from` contract to another `to` contract. - #[cheatcode(group = Utilities)] + #[cheatcode(group = Utilities, safety = Unsafe)] function copyStorage(address from, address to) external; /// Utility cheatcode to set arbitrary storage for given target address. - #[cheatcode(group = Utilities)] + #[cheatcode(group = Utilities, safety = Unsafe)] function setArbitraryStorage(address target) external; /// Utility cheatcode to set arbitrary storage for given target address and overwrite /// any storage slots that have been previously set. - #[cheatcode(group = Utilities)] + #[cheatcode(group = Utilities, safety = Unsafe)] function setArbitraryStorage(address target, bool overwrite) external; /// Sorts an array in ascending order. @@ -2901,7 +3520,7 @@ interface Vm { /// * bindings will be retrieved from the path configured in `foundry.toml`. /// /// 2. String representation of the type (i.e. "Foo(Bar bar) Bar(uint256 baz)"). - /// * Note: the cheatcode will output the canonical type even if the input is malformated + /// * Note: the cheatcode will output the canonical type even if the input is malformed /// with the wrong order of elements or with extra whitespaces. #[cheatcode(group = Utilities)] function eip712HashType(string calldata typeNameOrDefinition) external pure returns (bytes32 typeHash); @@ -2923,7 +3542,7 @@ interface Vm { /// * bindings will be retrieved from the path configured in `foundry.toml`. /// /// 2. String representation of the type (i.e. "Foo(Bar bar) Bar(uint256 baz)"). - /// * Note: the cheatcode will use the canonical type even if the input is malformated + /// * Note: the cheatcode will use the canonical type even if the input is malformed /// with the wrong order of elements or with extra whitespaces. #[cheatcode(group = Utilities)] function eip712HashStruct(string calldata typeNameOrDefinition, bytes calldata abiEncodedData) external pure returns (bytes32 typeHash); @@ -2941,6 +3560,13 @@ interface Vm { /// Generates a ready-to-sign digest of human-readable typed data following the EIP-712 standard. #[cheatcode(group = Utilities)] function eip712HashTypedData(string calldata jsonData) external pure returns (bytes32 digest); + + /// RLP encodes a list of bytes into an RLP payload. + #[cheatcode(group = Utilities)] + function toRlp(bytes[] calldata data) external pure returns (bytes memory); + /// RLP decodes an RLP payload into a list of bytes. + #[cheatcode(group = Utilities)] + function fromRlp(bytes calldata rlp) external pure returns (bytes[] memory data); } } diff --git a/crates/cheatcodes/src/base64.rs b/crates/cheatcodes/src/base64.rs index 4aa4ba74a0e4b..4d9597d8f654f 100644 --- a/crates/cheatcodes/src/base64.rs +++ b/crates/cheatcodes/src/base64.rs @@ -1,31 +1,40 @@ use crate::{Cheatcode, Cheatcodes, Result, Vm::*}; use alloy_sol_types::SolValue; use base64::prelude::*; +use foundry_evm_core::evm::FoundryEvmNetwork; impl Cheatcode for toBase64_0Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { data } = self; - Ok(BASE64_STANDARD.encode(data).abi_encode()) + encode_base64(data) } } impl Cheatcode for toBase64_1Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { data } = self; - Ok(BASE64_STANDARD.encode(data).abi_encode()) + encode_base64(data) } } impl Cheatcode for toBase64URL_0Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { data } = self; - Ok(BASE64_URL_SAFE.encode(data).abi_encode()) + encode_base64_url(data) } } +fn encode_base64(data: impl AsRef<[u8]>) -> Result { + Ok(BASE64_STANDARD.encode(data).abi_encode()) +} + +fn encode_base64_url(data: impl AsRef<[u8]>) -> Result { + Ok(BASE64_URL_SAFE.encode(data).abi_encode()) +} + impl Cheatcode for toBase64URL_1Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { data } = self; - Ok(BASE64_URL_SAFE.encode(data).abi_encode()) + encode_base64_url(data) } } diff --git a/crates/cheatcodes/src/config.rs b/crates/cheatcodes/src/config.rs index 4ea7f2db28800..1e1bc60a2cbf1 100644 --- a/crates/cheatcodes/src/config.rs +++ b/crates/cheatcodes/src/config.rs @@ -8,8 +8,8 @@ use foundry_config::{ cache::StorageCachingConfig, fs_permissions::FsAccessKind, }; use foundry_evm_core::opts::EvmOpts; +use foundry_evm_traces::identifier::ExternalIdentifierConfig; use std::{ - collections::HashMap, path::{Path, PathBuf}, time::Duration, }; @@ -21,8 +21,12 @@ use std::{ pub struct CheatsConfig { /// Whether the FFI cheatcode is enabled. pub ffi: bool, + /// Cheatcode selectors rejected before dispatch for restricted executions. + pub blocked_cheatcodes: Vec<[u8; 4]>, /// Use the create 2 factory in all cases including tests and non-broadcasting scripts. pub always_use_create_2_factory: bool, + /// Rewrite plain CREATE to CREATE2 for `forge script --batch`. + pub batch_rewrite_creates: bool, /// Sets a timeout for vm.prompt cheatcodes pub prompt_timeout: Duration, /// RPC storage caching settings determines what chains and endpoints to cache @@ -41,8 +45,8 @@ pub struct CheatsConfig { pub root: PathBuf, /// Absolute Path to broadcast dir i.e project_root/broadcast pub broadcast: PathBuf, - /// Paths (directories) where file reading/writing is allowed - pub allowed_paths: Vec, + /// Whether isolated test execution is enabled. + pub isolate: bool, /// How the evm was configured by the user pub evm_opts: EvmOpts, /// Address labels from config @@ -51,6 +55,15 @@ pub struct CheatsConfig { /// If Some, `vm.getDeployedCode` invocations are validated to be in scope of this list. /// If None, no validation is performed. pub available_artifacts: Option, + /// Artifacts used to resolve cheatcode artifact references. + /// Unlike `available_artifacts`, this is retained when artifact safety checks are disabled. + pub artifact_lookup: Option, + /// Whether to decode the storage layouts of contracts outside the local project in state + /// diffs, by fetching their verified source code from a block explorer. + pub decode_external_storage: bool, + /// Settings for looking contracts up on block explorers, resolved lazily against the chain a + /// test is running on: a `vm.createSelectFork` can change it after this config was built. + pub external_sources: ExternalIdentifierConfig, /// Currently running artifact. pub running_artifact: Option, /// Whether to enable legacy (non-reverting) assertions. @@ -59,18 +72,6 @@ pub struct CheatsConfig { pub seed: Option, /// Whether to allow `expectRevert` to work for internal calls. pub internal_expect_revert: bool, - /// Mapping of chain aliases to chain data - pub chains: HashMap, - /// Mapping of chain IDs to their aliases - pub chain_id_to_alias: HashMap, -} - -/// Chain data for getChain cheatcodes -#[derive(Clone, Debug)] -pub struct ChainData { - pub name: String, - pub chain_id: u64, - pub default_rpc_url: String, // Store default RPC URL } impl CheatsConfig { @@ -80,21 +81,23 @@ impl CheatsConfig { evm_opts: EvmOpts, available_artifacts: Option, running_artifact: Option, + batch_rewrite_creates: bool, ) -> Self { - let mut allowed_paths = vec![config.root.clone()]; - allowed_paths.extend(config.libs.iter().cloned()); - allowed_paths.extend(config.allow_paths.iter().cloned()); - let rpc_endpoints = config.rpc_endpoints.clone().resolved(); trace!(?rpc_endpoints, "using resolved rpc endpoints"); - // If user explicitly disabled safety checks, do not set available_artifacts + let artifact_lookup = available_artifacts.clone(); + // If user explicitly disabled safety checks, do not set available_artifacts. let available_artifacts = if config.unchecked_cheatcode_artifacts { None } else { available_artifacts }; + let mut labels = config.labels.clone(); + labels.extend(config.tracing.labels.clone()); Self { ffi: evm_opts.ffi, + blocked_cheatcodes: Vec::new(), always_use_create_2_factory: evm_opts.always_use_create_2_factory, + batch_rewrite_creates, prompt_timeout: Duration::from_secs(config.prompt_timeout), rpc_storage_caching: config.rpc_storage_caching.clone(), no_storage_caching: config.no_storage_caching, @@ -104,22 +107,31 @@ impl CheatsConfig { fs_permissions: config.fs_permissions.clone().joined(config.root.as_ref()), root: config.root.clone(), broadcast: config.root.clone().join(&config.broadcast), - allowed_paths, + isolate: config.isolate, evm_opts, - labels: config.labels.clone(), + labels, available_artifacts, + artifact_lookup, + decode_external_storage: config.decode_external_storage, + external_sources: ExternalIdentifierConfig::new(config), running_artifact, assertions_revert: config.assertions_revert, seed: config.fuzz.seed, internal_expect_revert: config.allow_internal_expect_revert, - chains: HashMap::new(), - chain_id_to_alias: HashMap::new(), } } /// Returns a new `CheatsConfig` configured with the given `Config` and `EvmOpts`. pub fn clone_with(&self, config: &Config, evm_opts: EvmOpts) -> Self { - Self::new(config, evm_opts, self.available_artifacts.clone(), self.running_artifact.clone()) + let mut cloned = Self::new( + config, + evm_opts, + self.artifact_lookup.clone().or_else(|| self.available_artifacts.clone()), + self.running_artifact.clone(), + self.batch_rewrite_creates, + ); + cloned.blocked_cheatcodes.clone_from(&self.blocked_cheatcodes); + cloned } /// Attempts to canonicalize (see [std::fs::canonicalize]) the path. @@ -127,7 +139,7 @@ impl CheatsConfig { /// Canonicalization fails for non-existing paths, in which case we just normalize the path. pub fn normalized_path(&self, path: impl AsRef) -> PathBuf { let path = self.root.join(path); - canonicalize(&path).unwrap_or_else(|_| normalize_path(&path)) + canonicalize(&path).unwrap_or_else(|_| canonicalize_existing_ancestor(&path)) } /// Returns true if the given path is allowed, if any path `allowed_paths` is an ancestor of the @@ -198,6 +210,9 @@ impl CheatsConfig { pub fn rpc_endpoint(&self, url_or_alias: &str) -> Result { if let Some(endpoint) = self.rpc_endpoints.get(url_or_alias) { Ok(endpoint.clone().try_resolve()) + } else if let Some(builtin_url) = foundry_config::builtin_rpc_url(url_or_alias) { + let url = RpcEndpointUrl::Url(builtin_url.to_string()); + Ok(RpcEndpoint::new(url).resolve()) } else { // check if it's a URL or a path to an existing file to an ipc socket if url_or_alias.starts_with("http") || @@ -221,84 +236,15 @@ impl CheatsConfig { } Ok(urls) } - - /// Initialize default chain data (similar to initializeStdChains in Solidity) - pub fn initialize_chain_data(&mut self) { - if !self.chains.is_empty() { - return; // Already initialized - } - - // Use the same function to create chains - let chains = create_default_chains(); - - // Add all chains to the config - for (alias, data) in chains { - self.set_chain_with_default_rpc_url(&alias, data); - } - } - - /// Set chain with default RPC URL (similar to setChainWithDefaultRpcUrl in Solidity) - pub fn set_chain_with_default_rpc_url(&mut self, alias: &str, data: ChainData) { - // Store the default RPC URL is already stored in the data - // No need to clone it separately - - // Add chain data - self.set_chain_data(alias, data); - } - - /// Set chain data for a specific alias - pub fn set_chain_data(&mut self, alias: &str, data: ChainData) { - // Remove old chain ID mapping if it exists - if let Some(old_data) = self.chains.get(alias) { - self.chain_id_to_alias.remove(&old_data.chain_id); - } - - // Add new mappings - self.chain_id_to_alias.insert(data.chain_id, alias.to_string()); - self.chains.insert(alias.to_string(), data); - } - - /// Get chain data by alias - pub fn get_chain_data_by_alias_non_mut(&self, alias: &str) -> Result { - // Initialize chains if not already done - if self.chains.is_empty() { - // Create a temporary copy with initialized chains - // This is inefficient but handles the edge case - let temp_chains = create_default_chains(); - - if let Some(data) = temp_chains.get(alias) { - return Ok(data.clone()); - } - } else { - // Normal path - chains are initialized - if let Some(data) = self.chains.get(alias) { - return Ok(data.clone()); - } - } - - // Chain not found in either case - Err(fmt_err!("vm.getChain: Chain with alias \"{}\" not found", alias)) - } - - /// Get RPC URL for an alias - pub fn get_rpc_url_non_mut(&self, alias: &str) -> Result { - // Try to get from config first - match self.rpc_endpoint(alias) { - Ok(endpoint) => Ok(endpoint.url()?), - Err(_) => { - // If not in config, try to get default URL - let chain_data = self.get_chain_data_by_alias_non_mut(alias)?; - Ok(chain_data.default_rpc_url) - } - } - } } impl Default for CheatsConfig { fn default() -> Self { Self { ffi: false, + blocked_cheatcodes: Vec::new(), always_use_create_2_factory: false, + batch_rewrite_creates: false, prompt_timeout: Duration::from_secs(120), rpc_storage_caching: Default::default(), no_storage_caching: false, @@ -308,425 +254,63 @@ impl Default for CheatsConfig { root: Default::default(), bind_json_path: PathBuf::default().join("utils").join("jsonBindings.sol"), broadcast: Default::default(), - allowed_paths: vec![], + isolate: Config::default().isolate, evm_opts: Default::default(), labels: Default::default(), available_artifacts: Default::default(), + artifact_lookup: Default::default(), + decode_external_storage: false, + external_sources: Default::default(), running_artifact: Default::default(), assertions_revert: true, seed: None, internal_expect_revert: false, - chains: HashMap::new(), - chain_id_to_alias: HashMap::new(), } } } -// Helper function to set default chains -fn create_default_chains() -> HashMap { - let mut chains = HashMap::new(); - - // Define all chains in one place - chains.insert( - "anvil".to_string(), - ChainData { - name: "Anvil".to_string(), - chain_id: 31337, - default_rpc_url: "http://127.0.0.1:8545".to_string(), - }, - ); - - chains.insert( - "mainnet".to_string(), - ChainData { - name: "Mainnet".to_string(), - chain_id: 1, - default_rpc_url: "https://eth.llamarpc.com".to_string(), - }, - ); - - chains.insert( - "sepolia".to_string(), - ChainData { - name: "Sepolia".to_string(), - chain_id: 11155111, - default_rpc_url: "https://sepolia.infura.io/v3/b9794ad1ddf84dfb8c34d6bb5dca2001" - .to_string(), - }, - ); - - chains.insert( - "holesky".to_string(), - ChainData { - name: "Holesky".to_string(), - chain_id: 17000, - default_rpc_url: "https://rpc.holesky.ethpandaops.io".to_string(), - }, - ); - - chains.insert( - "optimism".to_string(), - ChainData { - name: "Optimism".to_string(), - chain_id: 10, - default_rpc_url: "https://mainnet.optimism.io".to_string(), - }, - ); - - chains.insert( - "optimism_sepolia".to_string(), - ChainData { - name: "Optimism Sepolia".to_string(), - chain_id: 11155420, - default_rpc_url: "https://sepolia.optimism.io".to_string(), - }, - ); - - chains.insert( - "arbitrum_one".to_string(), - ChainData { - name: "Arbitrum One".to_string(), - chain_id: 42161, - default_rpc_url: "https://arb1.arbitrum.io/rpc".to_string(), - }, - ); - - chains.insert( - "arbitrum_one_sepolia".to_string(), - ChainData { - name: "Arbitrum One Sepolia".to_string(), - chain_id: 421614, - default_rpc_url: "https://sepolia-rollup.arbitrum.io/rpc".to_string(), - }, - ); - - chains.insert( - "arbitrum_nova".to_string(), - ChainData { - name: "Arbitrum Nova".to_string(), - chain_id: 42170, - default_rpc_url: "https://nova.arbitrum.io/rpc".to_string(), - }, - ); - - chains.insert( - "polygon".to_string(), - ChainData { - name: "Polygon".to_string(), - chain_id: 137, - default_rpc_url: "https://polygon-rpc.com".to_string(), - }, - ); - - chains.insert( - "polygon_amoy".to_string(), - ChainData { - name: "Polygon Amoy".to_string(), - chain_id: 80002, - default_rpc_url: "https://rpc-amoy.polygon.technology".to_string(), - }, - ); - - chains.insert( - "avalanche".to_string(), - ChainData { - name: "Avalanche".to_string(), - chain_id: 43114, - default_rpc_url: "https://api.avax.network/ext/bc/C/rpc".to_string(), - }, - ); - - chains.insert( - "avalanche_fuji".to_string(), - ChainData { - name: "Avalanche Fuji".to_string(), - chain_id: 43113, - default_rpc_url: "https://api.avax-test.network/ext/bc/C/rpc".to_string(), - }, - ); - - chains.insert( - "bnb_smart_chain".to_string(), - ChainData { - name: "BNB Smart Chain".to_string(), - chain_id: 56, - default_rpc_url: "https://bsc-dataseed1.binance.org".to_string(), - }, - ); - - chains.insert( - "bnb_smart_chain_testnet".to_string(), - ChainData { - name: "BNB Smart Chain Testnet".to_string(), - chain_id: 97, - default_rpc_url: "https://rpc.ankr.com/bsc_testnet_chapel".to_string(), - }, - ); - - chains.insert( - "gnosis_chain".to_string(), - ChainData { - name: "Gnosis Chain".to_string(), - chain_id: 100, - default_rpc_url: "https://rpc.gnosischain.com".to_string(), - }, - ); - - chains.insert( - "moonbeam".to_string(), - ChainData { - name: "Moonbeam".to_string(), - chain_id: 1284, - default_rpc_url: "https://rpc.api.moonbeam.network".to_string(), - }, - ); - - chains.insert( - "moonriver".to_string(), - ChainData { - name: "Moonriver".to_string(), - chain_id: 1285, - default_rpc_url: "https://rpc.api.moonriver.moonbeam.network".to_string(), - }, - ); - - chains.insert( - "moonbase".to_string(), - ChainData { - name: "Moonbase".to_string(), - chain_id: 1287, - default_rpc_url: "https://rpc.testnet.moonbeam.network".to_string(), - }, - ); - - chains.insert( - "base_sepolia".to_string(), - ChainData { - name: "Base Sepolia".to_string(), - chain_id: 84532, - default_rpc_url: "https://sepolia.base.org".to_string(), - }, - ); - - chains.insert( - "base".to_string(), - ChainData { - name: "Base".to_string(), - chain_id: 8453, - default_rpc_url: "https://mainnet.base.org".to_string(), - }, - ); - - chains.insert( - "blast_sepolia".to_string(), - ChainData { - name: "Blast Sepolia".to_string(), - chain_id: 168587773, - default_rpc_url: "https://sepolia.blast.io".to_string(), - }, - ); - - chains.insert( - "blast".to_string(), - ChainData { - name: "Blast".to_string(), - chain_id: 81457, - default_rpc_url: "https://rpc.blast.io".to_string(), - }, - ); - - chains.insert( - "fantom_opera".to_string(), - ChainData { - name: "Fantom Opera".to_string(), - chain_id: 250, - default_rpc_url: "https://rpc.ankr.com/fantom/".to_string(), - }, - ); - - chains.insert( - "fantom_opera_testnet".to_string(), - ChainData { - name: "Fantom Opera Testnet".to_string(), - chain_id: 4002, - default_rpc_url: "https://rpc.ankr.com/fantom_testnet/".to_string(), - }, - ); - - chains.insert( - "fraxtal".to_string(), - ChainData { - name: "Fraxtal".to_string(), - chain_id: 252, - default_rpc_url: "https://rpc.frax.com".to_string(), - }, - ); - - chains.insert( - "fraxtal_testnet".to_string(), - ChainData { - name: "Fraxtal Testnet".to_string(), - chain_id: 2522, - default_rpc_url: "https://rpc.testnet.frax.com".to_string(), - }, - ); - - chains.insert( - "berachain_bartio_testnet".to_string(), - ChainData { - name: "Berachain bArtio Testnet".to_string(), - chain_id: 80084, - default_rpc_url: "https://bartio.rpc.berachain.com".to_string(), - }, - ); - - chains.insert( - "flare".to_string(), - ChainData { - name: "Flare".to_string(), - chain_id: 14, - default_rpc_url: "https://flare-api.flare.network/ext/C/rpc".to_string(), - }, - ); - - chains.insert( - "flare_coston2".to_string(), - ChainData { - name: "Flare Coston2".to_string(), - chain_id: 114, - default_rpc_url: "https://coston2-api.flare.network/ext/C/rpc".to_string(), - }, - ); - - chains.insert( - "mode".to_string(), - ChainData { - name: "Mode".to_string(), - chain_id: 34443, - default_rpc_url: "https://mode.drpc.org".to_string(), - }, - ); - - chains.insert( - "mode_sepolia".to_string(), - ChainData { - name: "Mode Sepolia".to_string(), - chain_id: 919, - default_rpc_url: "https://sepolia.mode.network".to_string(), - }, - ); - - chains.insert( - "zora".to_string(), - ChainData { - name: "Zora".to_string(), - chain_id: 7777777, - default_rpc_url: "https://zora.drpc.org".to_string(), - }, - ); - - chains.insert( - "zora_sepolia".to_string(), - ChainData { - name: "Zora Sepolia".to_string(), - chain_id: 999999999, - default_rpc_url: "https://sepolia.rpc.zora.energy".to_string(), - }, - ); - - chains.insert( - "race".to_string(), - ChainData { - name: "Race".to_string(), - chain_id: 6805, - default_rpc_url: "https://racemainnet.io".to_string(), - }, - ); - - chains.insert( - "race_sepolia".to_string(), - ChainData { - name: "Race Sepolia".to_string(), - chain_id: 6806, - default_rpc_url: "https://racemainnet.io".to_string(), - }, - ); - - chains.insert( - "metal".to_string(), - ChainData { - name: "Metal".to_string(), - chain_id: 1750, - default_rpc_url: "https://metall2.drpc.org".to_string(), - }, - ); - - chains.insert( - "metal_sepolia".to_string(), - ChainData { - name: "Metal Sepolia".to_string(), - chain_id: 1740, - default_rpc_url: "https://testnet.rpc.metall2.com".to_string(), - }, - ); - - chains.insert( - "binary".to_string(), - ChainData { - name: "Binary".to_string(), - chain_id: 624, - default_rpc_url: "https://rpc.zero.thebinaryholdings.com".to_string(), - }, - ); - - chains.insert( - "binary_sepolia".to_string(), - ChainData { - name: "Binary Sepolia".to_string(), - chain_id: 625, - default_rpc_url: "https://rpc.zero.thebinaryholdings.com".to_string(), - }, - ); - - chains.insert( - "orderly".to_string(), - ChainData { - name: "Orderly".to_string(), - chain_id: 291, - default_rpc_url: "https://rpc.orderly.network".to_string(), - }, - ); - - chains.insert( - "orderly_sepolia".to_string(), - ChainData { - name: "Orderly Sepolia".to_string(), - chain_id: 4460, - default_rpc_url: "https://testnet-rpc.orderly.org".to_string(), - }, - ); - - chains +fn canonicalize_existing_ancestor(path: &Path) -> PathBuf { + let normalized = normalize_path(path); + let mut missing = Vec::new(); + let mut ancestor = normalized.as_path(); + while !ancestor.exists() { + let Some(name) = ancestor.file_name() else { return normalized }; + missing.push(name.to_owned()); + let Some(parent) = ancestor.parent() else { return normalized }; + ancestor = parent; + } + + let mut path = canonicalize(ancestor).unwrap_or_else(|_| ancestor.to_path_buf()); + for component in missing.iter().rev() { + path.push(component); + } + path } #[cfg(test)] mod tests { + use super::*; + use alloy_primitives::Address; use foundry_config::fs_permissions::PathPermission; + use tempfile::TempDir; - fn config(root: &str, fs_permissions: FsPermissions) -> CheatsConfig { + fn config(root: &Path, fs_permissions: FsPermissions) -> CheatsConfig { CheatsConfig::new( &Config { root: root.into(), fs_permissions, ..Default::default() }, Default::default(), None, None, + false, ) } #[test] fn test_allowed_paths() { - let root = "/my/project/root/"; - let config = config(root, FsPermissions::new(vec![PathPermission::read_write("./")])); + let temp = TempDir::new().unwrap(); + let root = temp.path().join("my/project/root"); + std::fs::create_dir_all(&root).unwrap(); + let config = config(&root, FsPermissions::new(vec![PathPermission::read_write("./")])); assert!(config.ensure_path_allowed("./t.txt", FsAccessKind::Read).is_ok()); assert!(config.ensure_path_allowed("./t.txt", FsAccessKind::Write).is_ok()); @@ -736,18 +320,72 @@ mod tests { assert!(config.ensure_path_allowed("../../root/t.txt", FsAccessKind::Write).is_err()); } + #[test] + fn test_batch_rewrite_creates_flag_plumbing() { + assert!(!CheatsConfig::default().batch_rewrite_creates); + + let on = CheatsConfig::new(&Config::default(), Default::default(), None, None, true); + assert!(on.batch_rewrite_creates); + + let cloned = on.clone_with(&Config::default(), Default::default()); + assert!(cloned.batch_rewrite_creates); + } + + #[test] + fn unchecked_artifacts_retain_lookup_without_validation() { + let config = Config { unchecked_cheatcode_artifacts: true, ..Default::default() }; + let cheats = CheatsConfig::new( + &config, + Default::default(), + Some(ContractsByArtifact::default()), + None, + false, + ); + + assert!(cheats.available_artifacts.is_none()); + assert!(cheats.artifact_lookup.is_some()); + + let cloned = cheats.clone_with(&config, Default::default()); + assert!(cloned.available_artifacts.is_none()); + assert!(cloned.artifact_lookup.is_some()); + } + + #[test] + fn clone_with_preserves_available_artifacts_without_lookup() { + let cheats = CheatsConfig { + available_artifacts: Some(ContractsByArtifact::default()), + ..Default::default() + }; + + let cloned = cheats.clone_with(&Config::default(), Default::default()); + assert!(cloned.available_artifacts.is_some()); + assert!(cloned.artifact_lookup.is_some()); + } + + #[test] + fn tracing_labels_override_legacy_labels() { + let address = Address::with_last_byte(1); + let mut config = Config::default(); + config.labels.insert(address, "legacy".to_string()); + config.tracing.labels.insert(address, "canonical".to_string()); + + let config = CheatsConfig::new(&config, Default::default(), None, None, false); + + assert_eq!(config.labels.get(&address).map(String::as_str), Some("canonical")); + } + #[test] fn test_is_foundry_toml() { - let root = "/my/project/root/"; + let root = Path::new("/my/project/root/"); let config = config(root, FsPermissions::new(vec![PathPermission::read_write("./")])); - let f = format!("{root}foundry.toml"); + let f = root.join("foundry.toml"); assert!(config.is_foundry_toml(f)); - let f = format!("{root}Foundry.toml"); + let f = root.join("Foundry.toml"); assert!(config.is_foundry_toml(f)); - let f = format!("{root}lib/other/foundry.toml"); + let f = root.join("lib/other/foundry.toml"); assert!(!config.is_foundry_toml(f)); } } diff --git a/crates/cheatcodes/src/crypto.rs b/crates/cheatcodes/src/crypto.rs index 0b1f9ffdd07a6..e80f4865d9b9b 100644 --- a/crates/cheatcodes/src/crypto.rs +++ b/crates/cheatcodes/src/crypto.rs @@ -4,55 +4,95 @@ use crate::{Cheatcode, Cheatcodes, Result, Vm::*}; use alloy_primitives::{Address, B256, U256, keccak256}; use alloy_signer::{Signer, SignerSync}; use alloy_signer_local::{ - LocalSigner, MnemonicBuilder, PrivateKeySigner, + MnemonicBuilder, PrivateKeySigner, coins_bip39::{ ChineseSimplified, ChineseTraditional, Czech, English, French, Italian, Japanese, Korean, Portuguese, Spanish, Wordlist, }, }; use alloy_sol_types::SolValue; +use foundry_common::wallet::{derive_private_key, derive_private_key_with_language}; +use foundry_evm_core::evm::FoundryEvmNetwork; use k256::{ - ecdsa::SigningKey, - elliptic_curve::{bigint::ArrayEncoding, sec1::ToEncodedPoint}, + AffinePoint, EncodedPoint, FieldBytes, FieldElement, ProjectivePoint, Scalar, + ecdsa::{SigningKey, hazmat}, + elliptic_curve::{ + bigint::{ArrayEncoding, U256 as K256U256}, + group::Group, + ops::Reduce, + sec1::{FromEncodedPoint, ToEncodedPoint}, + }, }; + use p256::ecdsa::{ Signature as P256Signature, SigningKey as P256SigningKey, signature::hazmat::PrehashSigner, }; +use ed25519_consensus::{ + Signature as Ed25519Signature, SigningKey as Ed25519SigningKey, + VerificationKey as Ed25519VerificationKey, +}; +use tempo_primitives::transaction::{KeychainSignature, PrimitiveSignature, TempoSignature}; + /// The BIP32 default derivation path prefix. const DEFAULT_DERIVATION_PATH_PREFIX: &str = "m/44'/60'/0'/0/"; +const PRIVATE_KEY_SIGNER_CACHE_LIMIT: usize = 64; impl Cheatcode for createWallet_0Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { walletLabel } = self; - create_wallet(&U256::from_be_bytes(keccak256(walletLabel).0), Some(walletLabel), state) + create_wallet(&keccak256(walletLabel).into(), Some(walletLabel), state) } } impl Cheatcode for createWallet_1Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { privateKey } = self; create_wallet(privateKey, None, state) } } impl Cheatcode for createWallet_2Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { privateKey, walletLabel } = self; create_wallet(privateKey, Some(walletLabel), state) } } impl Cheatcode for sign_0Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { wallet, digest } = self; let sig = sign(&wallet.privateKey, digest)?; Ok(encode_full_sig(sig)) } } +impl Cheatcode for signWithNonceUnsafeCall { + fn apply(&self, _state: &mut Cheatcodes) -> Result { + let pk: U256 = self.privateKey; + let digest: B256 = self.digest; + let nonce: U256 = self.nonce; + let sig: alloy_primitives::Signature = sign_with_nonce(&pk, &digest, &nonce)?; + Ok(encode_full_sig(sig)) + } +} + +impl Cheatcode for signKeychainCall { + fn apply(&self, state: &mut Cheatcodes) -> Result { + let Self { privateKey, account, digest } = self; + sign_keychain(state, privateKey, account, digest) + } +} + +impl Cheatcode for signKeychainAdminCall { + fn apply(&self, state: &mut Cheatcodes) -> Result { + let Self { privateKey, account, digest } = self; + sign_keychain(state, privateKey, account, digest) + } +} + impl Cheatcode for signCompact_0Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { wallet, digest } = self; let sig = sign(&wallet.privateKey, digest)?; Ok(encode_compact_sig(sig)) @@ -60,44 +100,44 @@ impl Cheatcode for signCompact_0Call { } impl Cheatcode for deriveKey_0Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { mnemonic, index } = self; derive_key::(mnemonic, DEFAULT_DERIVATION_PATH_PREFIX, *index) } } impl Cheatcode for deriveKey_1Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { mnemonic, derivationPath, index } = self; derive_key::(mnemonic, derivationPath, *index) } } impl Cheatcode for deriveKey_2Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { mnemonic, index, language } = self; derive_key_str(mnemonic, DEFAULT_DERIVATION_PATH_PREFIX, *index, language) } } impl Cheatcode for deriveKey_3Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { mnemonic, derivationPath, index, language } = self; derive_key_str(mnemonic, derivationPath, *index, language) } } impl Cheatcode for rememberKeyCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { privateKey } = self; - let wallet = parse_wallet(privateKey)?; + let wallet = with_private_key_signer(state, privateKey, |wallet| Ok(wallet.clone()))?; let address = inject_wallet(state, wallet); Ok(address.abi_encode()) } } impl Cheatcode for rememberKeys_0Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { mnemonic, derivationPath, count } = self; let wallets = derive_wallets::(mnemonic, derivationPath, *count)?; let mut addresses = Vec::
::with_capacity(wallets.len()); @@ -111,7 +151,7 @@ impl Cheatcode for rememberKeys_0Call { } impl Cheatcode for rememberKeys_1Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { mnemonic, derivationPath, language, count } = self; let wallets = derive_wallets_str(mnemonic, derivationPath, language, *count)?; let mut addresses = Vec::
::with_capacity(wallets.len()); @@ -124,30 +164,33 @@ impl Cheatcode for rememberKeys_1Call { } } -fn inject_wallet(state: &mut Cheatcodes, wallet: LocalSigner) -> Address { +fn inject_wallet( + state: &mut Cheatcodes, + wallet: PrivateKeySigner, +) -> Address { let address = wallet.address(); state.wallets().add_local_signer(wallet); address } impl Cheatcode for sign_1Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { privateKey, digest } = self; - let sig = sign(privateKey, digest)?; + let sig = sign_cached(state, privateKey, digest)?; Ok(encode_full_sig(sig)) } } impl Cheatcode for signCompact_1Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { privateKey, digest } = self; - let sig = sign(privateKey, digest)?; + let sig = sign_cached(state, privateKey, digest)?; Ok(encode_compact_sig(sig)) } } impl Cheatcode for sign_2Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { digest } = self; let sig = sign_with_wallet(state, None, digest)?; Ok(encode_full_sig(sig)) @@ -155,7 +198,7 @@ impl Cheatcode for sign_2Call { } impl Cheatcode for signCompact_2Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { digest } = self; let sig = sign_with_wallet(state, None, digest)?; Ok(encode_compact_sig(sig)) @@ -163,7 +206,7 @@ impl Cheatcode for signCompact_2Call { } impl Cheatcode for sign_3Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { signer, digest } = self; let sig = sign_with_wallet(state, Some(*signer), digest)?; Ok(encode_full_sig(sig)) @@ -171,7 +214,7 @@ impl Cheatcode for sign_3Call { } impl Cheatcode for signCompact_3Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { signer, digest } = self; let sig = sign_with_wallet(state, Some(*signer), digest)?; Ok(encode_compact_sig(sig)) @@ -179,14 +222,14 @@ impl Cheatcode for signCompact_3Call { } impl Cheatcode for signP256Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { privateKey, digest } = self; sign_p256(privateKey, digest) } } impl Cheatcode for publicKeyP256Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { privateKey } = self; let pub_key = parse_private_key_p256(privateKey)?.verifying_key().as_affine().to_encoded_point(false); @@ -197,17 +240,102 @@ impl Cheatcode for publicKeyP256Call { } } +impl Cheatcode for ecAffineToProjectiveCall { + fn apply(&self, _state: &mut Cheatcodes) -> Result { + let Self { pointX, pointY } = self; + let point = parse_affine_point(pointX, pointY, "point")?; + encode_projective_point(ProjectivePoint::from(point)) + } +} + +impl Cheatcode for ecProjectiveToAffineCall { + fn apply(&self, _state: &mut Cheatcodes) -> Result { + let Self { pointX, pointY, pointZ } = self; + let point = parse_projective_point(pointX, pointY, pointZ, "point")?; + encode_affine_point(point) + } +} + +impl Cheatcode for ecAddAffineCall { + fn apply(&self, _state: &mut Cheatcodes) -> Result { + let Self { pointX1, pointY1, pointX2, pointY2 } = self; + let lhs = parse_affine_point(pointX1, pointY1, "first point")?; + let rhs = parse_affine_point(pointX2, pointY2, "second point")?; + encode_affine_point(ProjectivePoint::from(lhs) + rhs) + } +} + +impl Cheatcode for ecAddProjectiveCall { + fn apply(&self, _state: &mut Cheatcodes) -> Result { + let Self { pointX1, pointY1, pointZ1, pointX2, pointY2, pointZ2 } = self; + let lhs = parse_projective_point(pointX1, pointY1, pointZ1, "first point")?; + let rhs = parse_projective_point(pointX2, pointY2, pointZ2, "second point")?; + encode_projective_point(lhs + rhs) + } +} + +impl Cheatcode for ecMulAffineCall { + fn apply(&self, _state: &mut Cheatcodes) -> Result { + let Self { pointX, pointY, scalar } = self; + let point = parse_affine_point(pointX, pointY, "point")?; + let scalar = reduce_ec_scalar(scalar); + encode_affine_point(ProjectivePoint::from(point) * scalar) + } +} + +impl Cheatcode for ecMulProjectiveCall { + fn apply(&self, _state: &mut Cheatcodes) -> Result { + let Self { pointX, pointY, pointZ, scalar } = self; + let point = parse_projective_point(pointX, pointY, pointZ, "point")?; + let scalar = reduce_ec_scalar(scalar); + encode_projective_point(point * scalar) + } +} + +impl Cheatcode for createEd25519KeyCall { + fn apply(&self, _state: &mut Cheatcodes) -> Result { + let Self { salt } = self; + create_ed25519_key(salt) + } +} + +impl Cheatcode for publicKeyEd25519Call { + fn apply(&self, _state: &mut Cheatcodes) -> Result { + let Self { privateKey } = self; + public_key_ed25519(privateKey) + } +} + +impl Cheatcode for signEd25519Call { + fn apply(&self, _state: &mut Cheatcodes) -> Result { + let Self { namespace, message, privateKey } = self; + sign_ed25519(namespace, message, privateKey) + } +} + +impl Cheatcode for verifyEd25519Call { + fn apply(&self, _state: &mut Cheatcodes) -> Result { + let Self { signature, namespace, message, publicKey } = self; + verify_ed25519(signature, namespace, message, publicKey) + } +} + /// Using a given private key, return its public ETH address, its public key affine x and y /// coordinates, and its private key (see the 'Wallet' struct) /// /// If 'label' is set to 'Some()', assign that label to the associated ETH address in state -fn create_wallet(private_key: &U256, label: Option<&str>, state: &mut Cheatcodes) -> Result { - let key = parse_private_key(private_key)?; - let addr = alloy_signer::utils::secret_key_to_address(&key); - - let pub_key = key.verifying_key().as_affine().to_encoded_point(false); - let pub_key_x = U256::from_be_bytes((*pub_key.x().unwrap()).into()); - let pub_key_y = U256::from_be_bytes((*pub_key.y().unwrap()).into()); +fn create_wallet( + private_key: &U256, + label: Option<&str>, + state: &mut Cheatcodes, +) -> Result { + let (addr, pub_key_x, pub_key_y) = with_private_key_signer(state, private_key, |wallet| { + let addr = wallet.address(); + let pub_key = wallet.credential().verifying_key().as_affine().to_encoded_point(false); + let pub_key_x = U256::from_be_bytes((*pub_key.x().unwrap()).into()); + let pub_key_y = U256::from_be_bytes((*pub_key.y().unwrap()).into()); + Ok((addr, pub_key_x, pub_key_y)) + })?; if let Some(label) = label { state.labels.insert(addr, label.into()); @@ -219,7 +347,7 @@ fn create_wallet(private_key: &U256, label: Option<&str>, state: &mut Cheatcodes fn encode_full_sig(sig: alloy_primitives::Signature) -> Vec { // Retrieve v, r and s from signature. - let v = U256::from(sig.v() as u64 + 27); + let v = U256::from(sig.v_byte()); let r = B256::from(sig.r()); let s = B256::from(sig.s()); (v, r, s).abi_encode() @@ -241,8 +369,115 @@ fn sign(private_key: &U256, digest: &B256) -> Result( + state: &mut Cheatcodes, + private_key: &U256, + digest: &B256, +) -> Result { + with_private_key_signer(state, private_key, |wallet| { + let sig = wallet.sign_hash_sync(digest)?; + debug_assert_eq!(sig.recover_address_from_prehash(digest)?, wallet.address()); + Ok(sig) + }) +} + +fn sign_keychain( + state: &mut Cheatcodes, + private_key: &U256, + account: &Address, + digest: &B256, +) -> Result { + let signing_hash = KeychainSignature::signing_hash(*digest, *account); + let inner = sign_cached(state, private_key, &signing_hash)?; + let signature = TempoSignature::Keychain(KeychainSignature::new( + *account, + PrimitiveSignature::Secp256k1(inner), + )); + Ok(signature.to_bytes().abi_encode()) +} + +/// Signs `digest` on secp256k1 using a user-supplied ephemeral nonce `k` (no RFC6979). +/// - `private_key` and `nonce` must be in (0, n) +/// - `digest` is a 32-byte prehash. +/// +/// # Warning +/// +/// Use [`sign_with_nonce`] with extreme caution! +/// Reusing the same nonce (`k`) with the same private key in ECDSA will leak the private key. +/// Always generate `nonce` with a cryptographically secure RNG, and never reuse it across +/// signatures. +fn sign_with_nonce( + private_key: &U256, + digest: &B256, + nonce: &U256, +) -> Result { + let d_scalar: Scalar = + ::from_repr(private_key.to_be_bytes().into()) + .into_option() + .ok_or_else(|| fmt_err!("invalid private key scalar"))?; + if bool::from(d_scalar.is_zero()) { + return Err(fmt_err!("private key cannot be 0")); + } + + let k_scalar: Scalar = + ::from_repr(nonce.to_be_bytes().into()) + .into_option() + .ok_or_else(|| fmt_err!("invalid nonce scalar"))?; + if bool::from(k_scalar.is_zero()) { + return Err(fmt_err!("nonce cannot be 0")); + } + + let mut z = [0u8; 32]; + z.copy_from_slice(digest.as_slice()); + let z_fb: FieldBytes = FieldBytes::from(z); + + // Hazmat signing using the scalar `d` (SignPrimitive is implemented for `Scalar`) + // Note: returns (Signature, Option) + let (sig_raw, recid_opt) = + >::try_sign_prehashed( + &d_scalar, k_scalar, &z_fb, + ) + .map_err(|e| fmt_err!("sign_prehashed failed: {e}"))?; + + // Enforce low-s; if mirrored, parity flips (we’ll account for it below if we use recid) + let (sig_low, flipped) = + if let Some(norm) = sig_raw.normalize_s() { (norm, true) } else { (sig_raw, false) }; + + let r_u256 = U256::from_be_bytes(sig_low.r().to_bytes().into()); + let s_u256 = U256::from_be_bytes(sig_low.s().to_bytes().into()); + + // Determine v parity in {0,1} + let v_parity = if let Some(id) = recid_opt { + let mut v = id.to_byte() & 1; + if flipped { + v ^= 1; + } + v + } else { + // Fallback: choose parity by recovery to expected address + let expected_addr = { + let sk: SigningKey = parse_private_key(private_key)?; + alloy_signer::utils::secret_key_to_address(&sk) + }; + // Try v = 0 + let cand0 = alloy_primitives::Signature::new(r_u256, s_u256, false); + if cand0.recover_address_from_prehash(digest).ok() == Some(expected_addr) { + return Ok(cand0); + } + // Try v = 1 + let cand1 = alloy_primitives::Signature::new(r_u256, s_u256, true); + if cand1.recover_address_from_prehash(digest).ok() == Some(expected_addr) { + return Ok(cand1); + } + return Err(fmt_err!("failed to determine recovery id for signature")); + }; + + let y_parity = v_parity != 0; + Ok(alloy_primitives::Signature::new(r_u256, s_u256, y_parity)) +} + +fn sign_with_wallet( + state: &mut Cheatcodes, signer: Option
, digest: &B256, ) -> Result { @@ -285,8 +520,77 @@ fn sign_p256(private_key: &U256, digest: &B256) -> Result { Ok((r_bytes, s_bytes).abi_encode()) } +fn parse_affine_point(x: &U256, y: &U256, name: &str) -> Result { + if x.is_zero() && y.is_zero() { + return Ok(AffinePoint::IDENTITY); + } + + let encoded = EncodedPoint::from_affine_coordinates( + &FieldBytes::from(x.to_be_bytes()), + &FieldBytes::from(y.to_be_bytes()), + false, + ); + AffinePoint::from_encoded_point(&encoded) + .into_option() + .ok_or_else(|| fmt_err!("invalid secp256k1 {name}")) +} + +fn parse_projective_point(x: &U256, y: &U256, z: &U256, name: &str) -> Result { + let x_field = parse_field_element(x, name)?; + let y_field = parse_field_element(y, name)?; + let z_field = parse_field_element(z, name)?; + + if bool::from(z_field.is_zero()) { + ensure!( + bool::from(x_field.is_zero()) && !bool::from(y_field.is_zero()), + "invalid secp256k1 {name}" + ); + return Ok(ProjectivePoint::IDENTITY); + } + + let z_inv = z_field.invert().expect("non-zero field element is invertible"); + let affine_x = U256::from_be_slice(&(x_field * z_inv).to_bytes()); + let affine_y = U256::from_be_slice(&(y_field * z_inv).to_bytes()); + + Ok(ProjectivePoint::from(parse_affine_point(&affine_x, &affine_y, name)?)) +} + +fn parse_field_element(value: &U256, name: &str) -> Result { + FieldElement::from_bytes(&FieldBytes::from(value.to_be_bytes())) + .into_option() + .ok_or_else(|| fmt_err!("invalid secp256k1 {name}")) +} + +fn reduce_ec_scalar(scalar: &U256) -> Scalar { + >::reduce_bytes(&scalar.to_be_bytes().into()) +} + +fn encode_affine_point(point: ProjectivePoint) -> Result { + if bool::from(point.is_identity()) { + return Ok((U256::ZERO, U256::ZERO).abi_encode()); + } + + let encoded = point.to_affine().to_encoded_point(false); + let x = U256::from_be_slice(encoded.x().expect("non-identity point has x coordinate")); + let y = U256::from_be_slice(encoded.y().expect("non-identity point has y coordinate")); + + Ok((x, y).abi_encode()) +} + +fn encode_projective_point(point: ProjectivePoint) -> Result { + if bool::from(point.is_identity()) { + return Ok((U256::ZERO, U256::ONE, U256::ZERO).abi_encode()); + } + + let encoded = point.to_affine().to_encoded_point(false); + let x = U256::from_be_slice(encoded.x().expect("non-identity point has x coordinate")); + let y = U256::from_be_slice(encoded.y().expect("non-identity point has y coordinate")); + + Ok((x, y, U256::ONE).abi_encode()) +} + fn validate_private_key(private_key: &U256) -> Result<()> { - ensure!(*private_key != U256::ZERO, "private key cannot be 0"); + ensure!(!private_key.is_zero(), "private key cannot be 0"); let order = U256::from_be_slice(&C::ORDER.to_be_byte_array()); ensure!( *private_key < order, @@ -307,41 +611,80 @@ fn parse_private_key_p256(private_key: &U256) -> Result { Ok(P256SigningKey::from_bytes((&private_key.to_be_bytes()).into())?) } +fn parse_signing_key_ed25519(private_key: &B256) -> Result { + Ed25519SigningKey::try_from(private_key.as_slice()) + .map_err(|e| fmt_err!("invalid Ed25519 private key: {e}")) +} + +fn create_ed25519_key(salt: &B256) -> Result { + let signing_key = parse_signing_key_ed25519(salt)?; + let public_key = B256::from_slice(signing_key.verification_key().as_ref()); + Ok((public_key, *salt).abi_encode()) +} + +fn public_key_ed25519(private_key: &B256) -> Result { + let signing_key = parse_signing_key_ed25519(private_key)?; + Ok(B256::from_slice(signing_key.verification_key().as_ref()).abi_encode()) +} + +fn sign_ed25519(namespace: &[u8], message: &[u8], private_key: &B256) -> Result { + let signing_key = parse_signing_key_ed25519(private_key)?; + let combined = [namespace, message].concat(); + let signature: [u8; 64] = signing_key.sign(&combined).into(); + Ok(signature.to_vec().abi_encode()) +} + +fn verify_ed25519(signature: &[u8], namespace: &[u8], message: &[u8], public_key: &B256) -> Result { + if signature.len() != 64 { + return Ok(false.abi_encode()); + } + + let Ok(verification_key) = Ed25519VerificationKey::try_from(public_key.as_slice()) else { + return Ok(false.abi_encode()); + }; + + let Ok(sig_bytes): Result<[u8; 64], _> = signature.try_into() else { + return Ok(false.abi_encode()); + }; + + let combined = [namespace, message].concat(); + let valid = verification_key.verify(&Ed25519Signature::from(sig_bytes), &combined).is_ok(); + Ok(valid.abi_encode()) +} + pub(super) fn parse_wallet(private_key: &U256) -> Result { parse_private_key(private_key).map(PrivateKeySigner::from) } -fn derive_key_str(mnemonic: &str, path: &str, index: u32, language: &str) -> Result { - match language { - "chinese_simplified" => derive_key::(mnemonic, path, index), - "chinese_traditional" => derive_key::(mnemonic, path, index), - "czech" => derive_key::(mnemonic, path, index), - "english" => derive_key::(mnemonic, path, index), - "french" => derive_key::(mnemonic, path, index), - "italian" => derive_key::(mnemonic, path, index), - "japanese" => derive_key::(mnemonic, path, index), - "korean" => derive_key::(mnemonic, path, index), - "portuguese" => derive_key::(mnemonic, path, index), - "spanish" => derive_key::(mnemonic, path, index), - _ => Err(fmt_err!("unsupported mnemonic language: {language:?}")), +pub(super) fn with_private_key_signer( + state: &mut Cheatcodes, + private_key: &U256, + f: impl FnOnce(&PrivateKeySigner) -> Result, +) -> Result { + if !state.private_key_signers.contains_key(private_key) + && state.private_key_signers.len() < PRIVATE_KEY_SIGNER_CACHE_LIMIT + { + let wallet = parse_wallet(private_key)?; + state.private_key_signers.insert(*private_key, wallet); } -} -fn derive_key(mnemonic: &str, path: &str, index: u32) -> Result { - fn derive_key_path(path: &str, index: u32) -> String { - let mut out = path.to_string(); - if !out.ends_with('/') { - out.push('/'); - } - out.push_str(&index.to_string()); - out + if let Some(wallet) = state.private_key_signers.get(private_key) { + f(wallet) + } else { + let wallet = parse_wallet(private_key)?; + f(&wallet) } +} + +fn derive_key_str(mnemonic: &str, path: &str, index: u32, language: &str) -> Result { + let private_key = derive_private_key_with_language(mnemonic, path, index, language) + .map_err(|e| fmt_err!("{e}"))?; + Ok(private_key.abi_encode()) +} - let wallet = MnemonicBuilder::::default() - .phrase(mnemonic) - .derivation_path(derive_key_path(path, index))? - .build()?; - let private_key = U256::from_be_bytes(wallet.credential().to_bytes().into()); +fn derive_key(mnemonic: &str, path: &str, index: u32) -> Result { + let private_key = + derive_private_key::(mnemonic, path, index).map_err(|e| fmt_err!("{e}"))?; Ok(private_key.abi_encode()) } @@ -350,7 +693,7 @@ fn derive_wallets_str( path: &str, language: &str, count: u32, -) -> Result>> { +) -> Result> { match language { "chinese_simplified" => derive_wallets::(mnemonic, path, count), "chinese_traditional" => derive_wallets::(mnemonic, path, count), @@ -370,19 +713,15 @@ fn derive_wallets( mnemonic: &str, path: &str, count: u32, -) -> Result>> { - let mut out = path.to_string(); - - if !out.ends_with('/') { - out.push('/'); - } +) -> Result> { + foundry_common::wallet::validate_bip32_path(path).map_err(|e| fmt_err!("{e}"))?; let mut wallets = Vec::with_capacity(count as usize); for idx in 0..count { - let wallet = MnemonicBuilder::::default() - .phrase(mnemonic) - .derivation_path(format!("{out}{idx}"))? - .build()?; + let full_path = foundry_common::wallet::derive_key_path_checked(path, idx) + .map_err(|e| fmt_err!("{e}"))?; + let wallet = + MnemonicBuilder::::default().phrase(mnemonic).derivation_path(full_path)?.build()?; wallets.push(wallet); } @@ -393,7 +732,17 @@ fn derive_wallets( mod tests { use super::*; use alloy_primitives::{FixedBytes, hex::FromHex}; + use alloy_sol_types::SolCall; + use k256::elliptic_curve::Curve; use p256::ecdsa::signature::hazmat::PrehashVerifier; + use tempo_contracts::precompiles::{IAccountKeychain, ISignatureVerifier}; + use tempo_hardfork::TempoHardfork; + use tempo_precompiles::{ + Precompile, + account_keychain::{AccountKeychain, KeyRestrictions, SignatureType}, + signature_verifier::SignatureVerifier, + storage::{StorageCtx, hashmap::HashMapStorageProvider}, + }; #[test] fn test_sign_p256() { @@ -438,4 +787,383 @@ mod tests { let result = sign_p256(&U256::ZERO, &digest); assert_eq!(result.err().unwrap().to_string(), "private key cannot be 0"); } + + #[test] + fn test_sign_with_nonce_varies_and_recovers() { + // Given a fixed private key and digest + let pk_u256: U256 = U256::ONE; + let digest = FixedBytes::from_hex( + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ) + .unwrap(); + + // Two distinct nonces + let n1: U256 = U256::from(123u64); + let n2: U256 = U256::from(456u64); + + // Sign with both nonces + let sig1 = sign_with_nonce(&pk_u256, &digest, &n1).expect("sig1"); + let sig2 = sign_with_nonce(&pk_u256, &digest, &n2).expect("sig2"); + + // (r,s) must differ when nonce differs + assert!( + sig1.r() != sig2.r() || sig1.s() != sig2.s(), + "signatures should differ with different nonces" + ); + + // ecrecover must yield the address for both signatures + let sk = parse_private_key(&pk_u256).unwrap(); + let expected = alloy_signer::utils::secret_key_to_address(&sk); + + assert_eq!(sig1.recover_address_from_prehash(&digest).unwrap(), expected); + assert_eq!(sig2.recover_address_from_prehash(&digest).unwrap(), expected); + } + + #[test] + fn test_sign_with_nonce_zero_nonce_errors() { + // nonce = 0 should be rejected + let pk_u256: U256 = U256::ONE; + let digest = FixedBytes::from_hex( + "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ) + .unwrap(); + let n0: U256 = U256::ZERO; + + let err = sign_with_nonce(&pk_u256, &digest, &n0).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("nonce cannot be 0"), "unexpected error: {msg}"); + } + + #[test] + fn test_sign_with_nonce_nonce_ge_order_errors() { + // nonce >= n should be rejected + use k256::Secp256k1; + // Curve order n as U256 + let n_u256 = U256::from_be_slice(&Secp256k1::ORDER.to_be_byte_array()); + + let pk_u256: U256 = U256::ONE; + let digest = FixedBytes::from_hex( + "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + ) + .unwrap(); + + // Try exactly n (>= n invalid) + let err = sign_with_nonce(&pk_u256, &digest, &n_u256).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("invalid nonce scalar"), "unexpected error: {msg}"); + } + + #[test] + fn test_sign_keychain_encodes_v2_signature_for_account() { + let private_key = U256::from(0xB0Bu64); + let account = Address::repeat_byte(0x11); + let digest = B256::repeat_byte(0x22); + let mut state = Cheatcodes::default(); + + let result = sign_keychain(&mut state, &private_key, &account, &digest).unwrap(); + let signature = Vec::::abi_decode(&result).unwrap(); + + assert_eq!(signature.len(), 86); + assert_eq!(signature[0], 0x04); + assert_eq!(Address::from_slice(&signature[1..21]), account); + + let parsed = TempoSignature::from_bytes(&signature).unwrap(); + assert!(parsed.is_v2_keychain()); + + let keychain = parsed.as_keychain().unwrap(); + let expected_key = parse_wallet(&private_key).unwrap().address(); + assert_eq!(keychain.user_address, account); + assert_eq!(keychain.key_id(&digest).unwrap(), expected_key); + } + + #[test] + fn private_key_signers_are_cached_for_repeated_lookup() { + let private_key = U256::from(0xB0Bu64); + let mut state = Cheatcodes::default(); + + let first = + with_private_key_signer(&mut state, &private_key, |wallet| Ok(wallet.address())) + .unwrap(); + assert_eq!(state.private_key_signers.len(), 1); + + let second = + with_private_key_signer(&mut state, &private_key, |wallet| Ok(wallet.address())) + .unwrap(); + assert_eq!(state.private_key_signers.len(), 1); + assert_eq!(first, second); + } + + #[test] + fn test_sign_keychain_matches_t6_signature_verifier_state() { + let root_pk = U256::from(0xA11CEu64); + let access_pk = U256::from(0xB0Bu64); + let admin_pk = U256::from(0xC0FFEEu64); + let revoked_pk = U256::from(0xBADu64); + let expired_pk = U256::from(0xE441u64); + let unknown_pk = U256::from(0xFACEu64); + + let root = parse_wallet(&root_pk).unwrap().address(); + let access_key = parse_wallet(&access_pk).unwrap().address(); + let admin_key = parse_wallet(&admin_pk).unwrap().address(); + let revoked_key = parse_wallet(&revoked_pk).unwrap().address(); + let expired_key = parse_wallet(&expired_pk).unwrap().address(); + + let hash = B256::repeat_byte(0x44); + let admin_hash = B256::repeat_byte(0x66); + + let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6); + storage.set_timestamp(U256::from(1_000u64)); + StorageCtx::enter(&mut storage, || { + let mut keychain = AccountKeychain::new(); + keychain.initialize()?; + keychain.set_tx_origin(root)?; + + authorize_t6_access_key(&mut keychain, root, access_key, u64::MAX)?; + authorize_t6_access_key(&mut keychain, root, revoked_key, u64::MAX)?; + authorize_t6_access_key(&mut keychain, root, expired_key, 1_005)?; + keychain.authorize_admin_key(root, admin_key, SignatureType::Secp256k1, None)?; + keychain.revoke_key(root, IAccountKeychain::revokeKeyCall { keyId: revoked_key })?; + + assert!(verify_keychain(root, hash, keychain_signature(&access_pk, root, hash))); + assert!(!verify_keychain(root, hash, keychain_signature(&revoked_pk, root, hash))); + assert!(!verify_keychain(root, hash, keychain_signature(&unknown_pk, root, hash))); + assert!(!verify_keychain( + Address::repeat_byte(0x99), + hash, + keychain_signature(&access_pk, root, hash) + )); + assert!(verify_keychain_admin( + root, + admin_hash, + keychain_signature(&admin_pk, root, admin_hash) + )); + assert!(verify_keychain_admin( + root, + admin_hash, + keychain_signature(&root_pk, root, admin_hash) + )); + assert!(!verify_keychain_admin( + root, + admin_hash, + keychain_signature(&access_pk, root, admin_hash) + )); + assert!(!verify_keychain_admin( + Address::repeat_byte(0x88), + admin_hash, + keychain_signature(&admin_pk, root, admin_hash) + )); + assert_keychain_signature_reverts(root, hash, vec![0x04]); + + Ok::<_, eyre::Report>(()) + }) + .unwrap(); + + storage.set_timestamp(U256::from(1_006u64)); + StorageCtx::enter(&mut storage, || { + assert!(!verify_keychain(root, hash, keychain_signature(&expired_pk, root, hash))); + Ok::<_, eyre::Report>(()) + }) + .unwrap(); + } + + fn authorize_t6_access_key( + keychain: &mut AccountKeychain, + account: Address, + key_id: Address, + expiry: u64, + ) -> eyre::Result<()> { + keychain.authorize_key( + account, + key_id, + SignatureType::Secp256k1, + KeyRestrictions { + expiry, + enforceLimits: false, + limits: vec![], + allowAnyCalls: true, + allowedCalls: vec![], + }, + None, + )?; + Ok(()) + } + + fn keychain_signature(private_key: &U256, account: Address, hash: B256) -> Vec { + let mut state = Cheatcodes::default(); + Vec::::abi_decode(&sign_keychain(&mut state, private_key, &account, &hash).unwrap()) + .unwrap() + } + + fn verify_keychain(account: Address, hash: B256, signature: Vec) -> bool { + let calldata = + ISignatureVerifier::verifyKeychainCall { account, hash, signature: signature.into() } + .abi_encode(); + + let output = SignatureVerifier::new().call(&calldata, Address::ZERO).unwrap(); + assert!(!output.is_revert(), "verifyKeychain reverted: {:?}", output.bytes); + ISignatureVerifier::verifyKeychainCall::abi_decode_returns(&output.bytes).unwrap() + } + + fn verify_keychain_admin(account: Address, hash: B256, signature: Vec) -> bool { + let calldata = ISignatureVerifier::verifyKeychainAdminCall { + account, + hash, + signature: signature.into(), + } + .abi_encode(); + + let output = SignatureVerifier::new().call(&calldata, Address::ZERO).unwrap(); + assert!(!output.is_revert(), "verifyKeychainAdmin reverted: {:?}", output.bytes); + ISignatureVerifier::verifyKeychainAdminCall::abi_decode_returns(&output.bytes).unwrap() + } + + fn assert_keychain_signature_reverts(account: Address, hash: B256, signature: Vec) { + let calldata = + ISignatureVerifier::verifyKeychainCall { account, hash, signature: signature.into() } + .abi_encode(); + + let output = SignatureVerifier::new().call(&calldata, Address::ZERO).unwrap(); + assert!(output.is_revert(), "malformed keychain signature should revert"); + } + + #[test] + fn test_create_ed25519_key_determinism() { + let salt = B256::repeat_byte(1u8); + let result1 = create_ed25519_key(&salt).unwrap(); + let result2 = create_ed25519_key(&salt).unwrap(); + assert_eq!(result1, result2, "same salt should produce same keys"); + } + + #[test] + fn test_create_ed25519_key_different_salts() { + let salt1 = B256::repeat_byte(1u8); + let salt2 = B256::repeat_byte(2u8); + let result1 = create_ed25519_key(&salt1).unwrap(); + let result2 = create_ed25519_key(&salt2).unwrap(); + assert_ne!(result1, result2, "different salts should produce different keys"); + } + + #[test] + fn test_public_key_ed25519_consistency() { + let salt = B256::repeat_byte(42u8); + let create_result = create_ed25519_key(&salt).unwrap(); + let (expected_public, private): (B256, B256) = + <(B256, B256)>::abi_decode(&create_result).unwrap(); + + let derived_public_result = public_key_ed25519(&private).unwrap(); + let derived_public = B256::abi_decode(&derived_public_result).unwrap(); + + assert_eq!(expected_public, derived_public, "derived public key should match"); + } + + #[test] + fn test_sign_and_verify_ed25519_valid() { + let salt = B256::repeat_byte(123u8); + let create_result = create_ed25519_key(&salt).unwrap(); + let (public_key, private_key): (B256, B256) = + <(B256, B256)>::abi_decode(&create_result).unwrap(); + + let namespace = b"test.namespace"; + let message = b"hello world"; + let sig_result = sign_ed25519(namespace, message, &private_key).unwrap(); + let sig_bytes: Vec = Vec::abi_decode(&sig_result).unwrap(); + + let verify_result = verify_ed25519(&sig_bytes, namespace, message, &public_key).unwrap(); + let valid = bool::abi_decode(&verify_result).unwrap(); + + assert!(valid, "signature should be valid"); + } + + #[test] + fn test_verify_ed25519_invalid_signature() { + let salt = B256::repeat_byte(123u8); + let create_result = create_ed25519_key(&salt).unwrap(); + let (public_key, _): (B256, B256) = <(B256, B256)>::abi_decode(&create_result).unwrap(); + + let invalid_sig = [0u8; 64]; + let namespace = b"test.namespace"; + let message = b"hello world"; + + let verify_result = verify_ed25519(&invalid_sig, namespace, message, &public_key).unwrap(); + let valid = bool::abi_decode(&verify_result).unwrap(); + + assert!(!valid, "invalid signature should not verify"); + } + + #[test] + fn test_verify_ed25519_namespace_separation() { + let salt = B256::repeat_byte(123u8); + let create_result = create_ed25519_key(&salt).unwrap(); + let (public_key, private_key): (B256, B256) = + <(B256, B256)>::abi_decode(&create_result).unwrap(); + + let namespace_a = b"namespace.a"; + let message = b"message"; + let sig_result = sign_ed25519(namespace_a, message, &private_key).unwrap(); + let sig_bytes: Vec = Vec::abi_decode(&sig_result).unwrap(); + + let namespace_b = b"namespace.b"; + let verify_result = verify_ed25519(&sig_bytes, namespace_b, message, &public_key).unwrap(); + let valid = bool::abi_decode(&verify_result).unwrap(); + assert!(!valid, "signature with namespace A should not verify with namespace B"); + + let verify_result = verify_ed25519(&sig_bytes, namespace_a, message, &public_key).unwrap(); + let valid = bool::abi_decode(&verify_result).unwrap(); + assert!(valid, "signature should verify with correct namespace"); + } + + #[test] + fn test_verify_ed25519_invalid_signature_length() { + let salt = B256::repeat_byte(123u8); + let create_result = create_ed25519_key(&salt).unwrap(); + let (public_key, _): (B256, B256) = <(B256, B256)>::abi_decode(&create_result).unwrap(); + + let invalid_sig = [0u8; 32]; + let namespace = b"test"; + let message = b"message"; + + let verify_result = verify_ed25519(&invalid_sig, namespace, message, &public_key).unwrap(); + let valid = bool::abi_decode(&verify_result).unwrap(); + assert!(!valid, "signature with wrong length should not verify"); + } + + const MNEMONIC: &str = "test test test test test test test test test test test junk"; + + #[test] + fn derive_key_rejects_harden_bit_overflow() { + let err = derive_key::(MNEMONIC, "m/44'/60'/0'/0/2147483648'", 0) + .unwrap_err() + .to_string(); + assert!(err.contains("harden bit"), "{err}"); + + let err = derive_key::(MNEMONIC, "m/44'/60'/0'/0/2147483648h", 0) + .unwrap_err() + .to_string(); + assert!(err.contains("harden bit"), "{err}"); + + let err = + derive_key::(MNEMONIC, "m/44'/60'/0'/0", foundry_common::wallet::BIP32_HARDEN) + .unwrap_err() + .to_string(); + assert!(err.contains("harden bit"), "{err}"); + + assert!(derive_key::(MNEMONIC, "m/44'/60'/0'/0", 0).is_ok()); + assert!( + derive_key::( + MNEMONIC, + &format!("m/44'/60'/0'/0/{}", foundry_common::wallet::BIP32_HARDEN - 1), + 0 + ) + .is_ok() + ); + } + + #[test] + fn remember_keys_rejects_harden_bit_overflow() { + let err = derive_wallets::(MNEMONIC, "m/44'/60'/0'/0/2147483648'", 1) + .unwrap_err() + .to_string(); + assert!(err.contains("harden bit"), "{err}"); + assert!(derive_wallets::(MNEMONIC, "m/44'/60'/0'/0", 1).is_ok()); + } } diff --git a/crates/cheatcodes/src/env.rs b/crates/cheatcodes/src/env.rs index 348d1d4e67bde..080aa72c9321f 100644 --- a/crates/cheatcodes/src/env.rs +++ b/crates/cheatcodes/src/env.rs @@ -3,13 +3,19 @@ use crate::{Cheatcode, Cheatcodes, Error, Result, Vm::*, string}; use alloy_dyn_abi::DynSolType; use alloy_sol_types::SolValue; +use foundry_evm_core::evm::FoundryEvmNetwork; use std::{env, sync::OnceLock}; /// Stores the forge execution context for the duration of the program. -static FORGE_CONTEXT: OnceLock = OnceLock::new(); +pub static FORGE_CONTEXT: OnceLock = OnceLock::new(); + +/// Returns the current forge execution context, if it has been set. +pub fn current_execution_context() -> Option { + FORGE_CONTEXT.get().copied() +} impl Cheatcode for setEnvCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { name: key, value } = self; if key.is_empty() { Err(fmt_err!("environment variable key can't be empty")) @@ -28,106 +34,115 @@ impl Cheatcode for setEnvCall { } } +impl Cheatcode for resolveEnvCall { + fn apply(&self, _state: &mut Cheatcodes) -> Result { + let Self { input } = self; + let resolved = foundry_config::resolve::interpolate(input) + .map_err(|e| fmt_err!("failed to resolve env var: {e}"))?; + Ok(resolved.abi_encode()) + } +} + impl Cheatcode for envExistsCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { name } = self; Ok(env::var(name).is_ok().abi_encode()) } } impl Cheatcode for envBool_0Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { name } = self; env(name, &DynSolType::Bool) } } impl Cheatcode for envUint_0Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { name } = self; env(name, &DynSolType::Uint(256)) } } impl Cheatcode for envInt_0Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { name } = self; env(name, &DynSolType::Int(256)) } } impl Cheatcode for envAddress_0Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { name } = self; env(name, &DynSolType::Address) } } impl Cheatcode for envBytes32_0Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { name } = self; env(name, &DynSolType::FixedBytes(32)) } } impl Cheatcode for envString_0Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { name } = self; env(name, &DynSolType::String) } } impl Cheatcode for envBytes_0Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { name } = self; env(name, &DynSolType::Bytes) } } impl Cheatcode for envBool_1Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { name, delim } = self; env_array(name, delim, &DynSolType::Bool) } } impl Cheatcode for envUint_1Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { name, delim } = self; env_array(name, delim, &DynSolType::Uint(256)) } } impl Cheatcode for envInt_1Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { name, delim } = self; env_array(name, delim, &DynSolType::Int(256)) } } impl Cheatcode for envAddress_1Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { name, delim } = self; env_array(name, delim, &DynSolType::Address) } } impl Cheatcode for envBytes32_1Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { name, delim } = self; env_array(name, delim, &DynSolType::FixedBytes(32)) } } impl Cheatcode for envString_1Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { name, delim } = self; env_array(name, delim, &DynSolType::String) } } impl Cheatcode for envBytes_1Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { name, delim } = self; env_array(name, delim, &DynSolType::Bytes) } @@ -135,7 +150,7 @@ impl Cheatcode for envBytes_1Call { // bool impl Cheatcode for envOr_0Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { name, defaultValue } = self; env_default(name, defaultValue, &DynSolType::Bool) } @@ -143,7 +158,7 @@ impl Cheatcode for envOr_0Call { // uint256 impl Cheatcode for envOr_1Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { name, defaultValue } = self; env_default(name, defaultValue, &DynSolType::Uint(256)) } @@ -151,7 +166,7 @@ impl Cheatcode for envOr_1Call { // int256 impl Cheatcode for envOr_2Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { name, defaultValue } = self; env_default(name, defaultValue, &DynSolType::Int(256)) } @@ -159,7 +174,7 @@ impl Cheatcode for envOr_2Call { // address impl Cheatcode for envOr_3Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { name, defaultValue } = self; env_default(name, defaultValue, &DynSolType::Address) } @@ -167,7 +182,7 @@ impl Cheatcode for envOr_3Call { // bytes32 impl Cheatcode for envOr_4Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { name, defaultValue } = self; env_default(name, defaultValue, &DynSolType::FixedBytes(32)) } @@ -175,7 +190,7 @@ impl Cheatcode for envOr_4Call { // string impl Cheatcode for envOr_5Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { name, defaultValue } = self; env_default(name, defaultValue, &DynSolType::String) } @@ -183,7 +198,7 @@ impl Cheatcode for envOr_5Call { // bytes impl Cheatcode for envOr_6Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { name, defaultValue } = self; env_default(name, defaultValue, &DynSolType::Bytes) } @@ -191,7 +206,7 @@ impl Cheatcode for envOr_6Call { // bool[] impl Cheatcode for envOr_7Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { name, delim, defaultValue } = self; env_array_default(name, delim, defaultValue, &DynSolType::Bool) } @@ -199,7 +214,7 @@ impl Cheatcode for envOr_7Call { // uint256[] impl Cheatcode for envOr_8Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { name, delim, defaultValue } = self; env_array_default(name, delim, defaultValue, &DynSolType::Uint(256)) } @@ -207,7 +222,7 @@ impl Cheatcode for envOr_8Call { // int256[] impl Cheatcode for envOr_9Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { name, delim, defaultValue } = self; env_array_default(name, delim, defaultValue, &DynSolType::Int(256)) } @@ -215,7 +230,7 @@ impl Cheatcode for envOr_9Call { // address[] impl Cheatcode for envOr_10Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { name, delim, defaultValue } = self; env_array_default(name, delim, defaultValue, &DynSolType::Address) } @@ -223,7 +238,7 @@ impl Cheatcode for envOr_10Call { // bytes32[] impl Cheatcode for envOr_11Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { name, delim, defaultValue } = self; env_array_default(name, delim, defaultValue, &DynSolType::FixedBytes(32)) } @@ -231,7 +246,7 @@ impl Cheatcode for envOr_11Call { // string[] impl Cheatcode for envOr_12Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { name, delim, defaultValue } = self; env_array_default(name, delim, defaultValue, &DynSolType::String) } @@ -239,15 +254,15 @@ impl Cheatcode for envOr_12Call { // bytes[] impl Cheatcode for envOr_13Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { name, delim, defaultValue } = self; - let default = defaultValue.to_vec(); + let default = defaultValue.clone(); env_array_default(name, delim, &default, &DynSolType::Bytes) } } impl Cheatcode for isContextCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { context } = self; Ok((FORGE_CONTEXT.get() == Some(context)).abi_encode()) } @@ -264,7 +279,16 @@ fn env(key: &str, ty: &DynSolType) -> Result { } fn env_default(key: &str, default: &T, ty: &DynSolType) -> Result { - Ok(env(key, ty).unwrap_or_else(|_| default.abi_encode())) + if env_is_missing(key) { + return Ok(default.abi_encode()); + } + let val = get_env(key)?; + // An empty string is a valid `string` value, but it can never parse as any other type, so a + // variable that is set but empty is treated as not found for the other typed overloads. + if val.is_empty() && !matches!(ty, DynSolType::String) { + return Ok(default.abi_encode()); + } + string::parse(&val, ty).map_err(map_env_err(key, &val)) } fn env_array(key: &str, delim: &str, ty: &DynSolType) -> Result { @@ -274,7 +298,17 @@ fn env_array(key: &str, delim: &str, ty: &DynSolType) -> Result { } fn env_array_default(key: &str, delim: &str, default: &T, ty: &DynSolType) -> Result { - Ok(env_array(key, delim, ty).unwrap_or_else(|_| default.abi_encode())) + if env_is_missing(key) { + return Ok(default.abi_encode()); + } + env_array(key, delim, ty) +} + +/// Returns `true` if the variable is not set at all. The `envOr` cheatcodes fall back to their +/// default only then (or when the variable is set but empty, for the scalar typed overloads); a +/// variable that is set to a value that cannot be parsed is an error. +fn env_is_missing(key: &str) -> bool { + matches!(env::var(key), Err(env::VarError::NotPresent)) } fn get_env(key: &str) -> Result { @@ -306,6 +340,60 @@ fn map_env_err<'a>(key: &'a str, value: &'a str) -> impl FnOnce(Error) -> Error mod tests { use super::*; + #[test] + fn env_or_returns_default_when_missing() { + let key = "env_or_missing"; + unsafe { + env::remove_var(key); + } + let default = 7u64; + let encoded = default.abi_encode(); + assert_eq!(env_default(key, &default, &DynSolType::Uint(256)).unwrap(), encoded); + assert_eq!(env_array_default(key, ",", &default, &DynSolType::Uint(256)).unwrap(), encoded); + } + + #[test] + fn env_or_returns_default_when_empty() { + let key = "env_or_empty"; + unsafe { + env::set_var(key, ""); + } + let default = 7u64; + assert_eq!( + env_default(key, &default, &DynSolType::Uint(256)).unwrap(), + default.abi_encode() + ); + assert_eq!( + env_array_default(key, ",", &default, &DynSolType::Uint(256)).unwrap(), + Vec::::new().abi_encode() + ); + // An empty string is a valid `string` value and must not be replaced by the default. + assert_eq!( + env_default(key, &"default", &DynSolType::String).unwrap(), + String::new().abi_encode() + ); + unsafe { + env::remove_var(key); + } + } + + #[test] + fn env_or_rejects_unparsable_value() { + let key = "env_or_unparsable"; + unsafe { + env::set_var(key, "not_a_number"); + } + + let err = env_default(key, &7u64, &DynSolType::Uint(256)).unwrap_err().to_string(); + assert!(err.contains("$env_or_unparsable"), "{err:?}"); + let err = + env_array_default(key, ",", &7u64, &DynSolType::Uint(256)).unwrap_err().to_string(); + assert!(err.contains("$env_or_unparsable"), "{err:?}"); + unsafe { + env::remove_var(key); + } + } + #[test] fn parse_env_uint() { let key = "parse_env_uint"; diff --git a/crates/cheatcodes/src/error.rs b/crates/cheatcodes/src/error.rs index ffa76f83e3b7e..fbdf2592891b8 100644 --- a/crates/cheatcodes/src/error.rs +++ b/crates/cheatcodes/src/error.rs @@ -1,5 +1,5 @@ use crate::Vm; -use alloy_primitives::{Address, Bytes, hex}; +use alloy_primitives::{Bytes, hex}; use alloy_signer::Error as SignerError; use alloy_signer_local::LocalSignerError; use alloy_sol_types::SolError; @@ -65,19 +65,6 @@ macro_rules! ensure { }; } -macro_rules! ensure_not_precompile { - ($address:expr, $ctxt:expr) => { - if $ctxt.is_precompile($address) { - return Err($crate::error::precompile_error($address)); - } - }; -} - -#[cold] -pub(crate) fn precompile_error(address: &Address) -> Error { - fmt_err!("cannot use precompile {address} as an argument") -} - /// Error thrown by cheatcodes. // This uses a custom repr to minimize the size of the error. // The repr is basically `enum { Cow<'static, str>, Cow<'static, [u8]> }` @@ -155,7 +142,6 @@ impl Error { } /// Returns the kind of this error. - #[inline] pub fn kind(&self) -> ErrorKind<'_> { let data = self.data(); if self.is_str { @@ -167,38 +153,31 @@ impl Error { } /// Returns the raw data of this error. - #[inline] - pub fn data(&self) -> &[u8] { + pub const fn data(&self) -> &[u8] { unsafe { &*self.data } } /// Returns `true` if this error is a human-readable string. - #[inline] - pub fn is_str(&self) -> bool { + pub const fn is_str(&self) -> bool { self.is_str } - #[inline] fn new_str(data: &'static str) -> Self { Self::_new(true, false, data.as_bytes()) } - #[inline] fn new_string(data: String) -> Self { Self::_new(true, true, Box::into_raw(data.into_boxed_str().into_boxed_bytes())) } - #[inline] fn new_bytes(data: &'static [u8]) -> Self { Self::_new(false, false, data) } - #[inline] fn new_vec(data: Vec) -> Self { Self::_new(false, true, Box::into_raw(data.into_boxed_slice())) } - #[inline] fn _new(is_str: bool, drop: bool, data: *const [u8]) -> Self { debug_assert!(!data.is_null()); Self { is_str, drop, data } @@ -262,7 +241,6 @@ impl From> for Error { } impl From for Error { - #[inline] fn from(value: Bytes) -> Self { Self::new_vec(value.into()) } @@ -284,7 +262,6 @@ impl_from!( alloy_dyn_abi::Error, alloy_primitives::SignatureError, alloy_consensus::crypto::RecoveryError, - eyre::Report, FsPathError, hex::FromHexError, BackendError, @@ -308,6 +285,12 @@ impl> From> for Error { } } +impl From for Error { + fn from(err: eyre::Report) -> Self { + Self::new_string(foundry_common::errors::display_chain(&err)) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/cheatcodes/src/evm.rs b/crates/cheatcodes/src/evm.rs index 7bb1084d03f1d..169be43ed8a93 100644 --- a/crates/cheatcodes/src/evm.rs +++ b/crates/cheatcodes/src/evm.rs @@ -2,45 +2,98 @@ use crate::{ BroadcastableTransaction, Cheatcode, Cheatcodes, CheatcodesExecutor, CheatsCtxt, Error, Result, - Vm::*, - inspector::{Ecx, RecordDebugStepInfo}, + Vm::*, env::FORGE_CONTEXT, inspector::RecordDebugStepInfo, }; -use alloy_consensus::TxEnvelope; +use alloy_consensus::{Transaction as _, Typed2718, transaction::SignerRecoverable}; +use alloy_evm::FromRecoveredTx; use alloy_genesis::{Genesis, GenesisAccount}; -use alloy_primitives::{Address, B256, Bytes, U256, map::HashMap}; +use alloy_network::eip2718::EIP4844_TX_TYPE_ID; +use alloy_primitives::{ + Address, B256, Bytes, U256, hex, keccak256, + map::{AddressMap, AddressSet, B256Map, HashMap}, +}; use alloy_rlp::Decodable; use alloy_sol_types::SolValue; -use foundry_common::fs::{read_json_file, write_json_file}; +use foundry_common::{ + TransactionMaybeSigned, + fs::{read_json_file, write_json_file}, + slot_identifier::{ + ENCODING_BYTES, ENCODING_DYN_ARRAY, ENCODING_INPLACE, ENCODING_MAPPING, SlotIdentifier, + SlotInfo, + }, + tempo::{TIP20_MAX_LOGO_URI_BYTES, Tip20LogoUriValidationError, validate_tip20_logo_uri}, +}; use foundry_evm_core::{ - ContextExt, - backend::{DatabaseExt, RevertStateSnapshotAction}, + FoundryBlock, FoundryChain, FoundryTransaction, + backend::{DatabaseError, DatabaseExt, JournaledState, RevertStateSnapshotAction}, constants::{CALLER, CHEATCODE_ADDRESS, HARDHAT_CONSOLE_ADDRESS, TEST_CONTRACT_ADDRESS}, + eip2935::{ + HISTORY_SERVE_WINDOW, HISTORY_STORAGE_ADDRESS, HISTORY_STORAGE_CODE, forward_fill_start, + history_storage_slot, history_storage_value, + }, + env::FoundryContextExt, + evm::{FoundryEvmNetwork, TxEnvFor, TxEnvelopeFor, merge_child_state, prepare_child_state}, utils::get_blob_base_fee_update_fraction_by_spec_id, }; -use foundry_evm_traces::StackSnapshotType; +use foundry_evm_traces::TraceRequirements; use itertools::Itertools; use rand::Rng; use revm::{ + Database, bytecode::Bytecode, - context::{Block, JournalTr}, - primitives::{KECCAK_EMPTY, hardfork::SpecId}, + context::{Block, ContextTr, JournalTr, Transaction, result::ExecutionResult}, + inspector::JournalExt, + primitives::hardfork::SpecId, state::Account, }; use std::{ collections::{BTreeMap, btree_map::Entry}, fmt::Display, path::Path, + str::FromStr, }; mod record_debug_step; -use record_debug_step::{convert_call_trace_to_debug_step, flatten_call_trace}; -use serde::Serialize; +use foundry_common::fmt::format_token_raw; +use foundry_config::{ExecutionSpec, evm_spec_id_from_str, fs_permissions::FsAccessKind}; +use record_debug_step::{convert_call_trace_ctx_to_debug_step, flatten_call_trace}; +use serde::{Serialize, Serializer, ser::SerializeMap}; + +#[cfg(feature = "monad")] +use foundry_evm_core::evm::refresh_chain_journal; mod fork; pub(crate) mod mapping; pub(crate) mod mock; pub(crate) mod prank; +/// JSON-serializable log entry for `getRecordedLogsJson`. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct LogJson { + /// The topics of the log, including the signature, if any. + topics: Vec, + /// The raw data of the log, hex-encoded with 0x prefix. + data: String, + /// The address of the log's emitter. + emitter: String, +} + +struct StateDump<'a>(&'a [(Address, GenesisAccount)]); + +impl Serialize for StateDump<'_> { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut map = serializer.serialize_map(Some(self.0.len()))?; + for (address, account) in self.0 { + map.serialize_entry(address, account)?; + } + map.end() + } +} + /// Records storage slots reads and writes. #[derive(Clone, Debug, Default)] pub struct RecordAccess { @@ -103,6 +156,11 @@ struct SlotStateDiff { previous_value: B256, /// Current storage value. new_value: B256, + /// Storage layout metadata (variable name, type, offset). + /// Only present when contract has storage layout output. + /// This includes decoded values when available. + #[serde(skip_serializing_if = "Option::is_none", flatten)] + slot_info: Option, } /// Balance diff info. @@ -115,14 +173,28 @@ struct BalanceDiff { new_value: U256, } +/// Nonce diff info. +#[derive(Serialize, Default)] +#[serde(rename_all = "camelCase")] +struct NonceDiff { + /// Initial nonce value. + previous_value: u64, + /// Current nonce value. + new_value: u64, +} + /// Account state diff info. #[derive(Serialize, Default)] #[serde(rename_all = "camelCase")] struct AccountStateDiffs { /// Address label, if any set. label: Option, + /// Contract identifier from artifact. e.g "src/Counter.sol:Counter" + contract: Option, /// Account balance changes. balance_diff: Option, + /// Account nonce changes. + nonce_diff: Option, /// State changes, per slot. state_diff: BTreeMap, } @@ -133,6 +205,9 @@ impl Display for AccountStateDiffs { if let Some(label) = &self.label { writeln!(f, "label: {label}")?; } + if let Some(contract) = &self.contract { + writeln!(f, "contract: {contract}")?; + } // Print balance diff if changed. if let Some(balance_diff) = &self.balance_diff && balance_diff.previous_value != balance_diff.new_value @@ -143,15 +218,49 @@ impl Display for AccountStateDiffs { balance_diff.previous_value, balance_diff.new_value )?; } + // Print nonce diff if changed. + if let Some(nonce_diff) = &self.nonce_diff + && nonce_diff.previous_value != nonce_diff.new_value + { + writeln!(f, "- nonce diff: {} → {}", nonce_diff.previous_value, nonce_diff.new_value)?; + } // Print state diff if any. if !&self.state_diff.is_empty() { writeln!(f, "- state diff:")?; for (slot, slot_changes) in &self.state_diff { - writeln!( - f, - "@ {slot}: {} → {}", - slot_changes.previous_value, slot_changes.new_value - )?; + match &slot_changes.slot_info { + Some(slot_info) => { + if let Some(decoded) = &slot_info.decoded { + // Have slot info with decoded values - show decoded values + writeln!( + f, + "@ {slot} ({}, {}): {} → {}", + slot_info.label, + slot_info.slot_type.dyn_sol_type, + format_token_raw(&decoded.previous_value), + format_token_raw(&decoded.new_value) + )?; + } else { + // Have slot info but no decoded values - show raw hex values + writeln!( + f, + "@ {slot} ({}, {}): {} → {}", + slot_info.label, + slot_info.slot_type.dyn_sol_type, + slot_changes.previous_value, + slot_changes.new_value + )?; + } + } + None => { + // No slot info - show raw hex values + writeln!( + f, + "@ {slot}: {} → {}", + slot_changes.previous_value, slot_changes.new_value + )?; + } + } } } @@ -160,39 +269,50 @@ impl Display for AccountStateDiffs { } impl Cheatcode for addrCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { privateKey } = self; - let wallet = super::crypto::parse_wallet(privateKey)?; - Ok(wallet.address().abi_encode()) + super::crypto::with_private_key_signer(state, privateKey, |wallet| { + Ok(wallet.address().abi_encode()) + }) } } impl Cheatcode for getNonce_0Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { account } = self; get_nonce(ccx, account) } } impl Cheatcode for getNonce_1Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { wallet } = self; get_nonce(ccx, &wallet.addr) } } impl Cheatcode for loadCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { target, slot } = *self; - ensure_not_precompile!(&target, ccx); - ccx.ecx.journaled_state.load_account(target)?; - let mut val = ccx.ecx.journaled_state.sload(target, slot.into())?; - if val.is_cold && val.data.is_zero() { + ccx.ecx.journal_mut().load_account(target)?; + let mut val = ccx + .ecx + .journal_mut() + .sload(target, slot.into()) + .map_err(|e| fmt_err!("failed to load storage slot: {:?}", e))?; + + if val.is_cold + && val.data.is_zero() + && !ccx.state.is_arbitrary_storage_slot_explicit(target, slot.into()) + { if ccx.state.has_arbitrary_storage(&target) { // If storage slot is untouched and load from a target with arbitrary storage, // then set random value for current slot. - let rand_value = ccx.state.rng().random(); + let rand_value = ccx + .state + .cached_arbitrary_storage_value(target, slot.into()) + .unwrap_or_else(|| ccx.state.rng().random()); ccx.state.arbitrary_storage.as_mut().unwrap().save( ccx.ecx, target, @@ -218,8 +338,16 @@ impl Cheatcode for loadCall { } } +impl Cheatcode for loadTransientCall { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { + let Self { target, slot } = *self; + let value = ccx.ecx.journal_mut().tload(target, slot.into()); + Ok(B256::from(value).abi_encode()) + } +} + impl Cheatcode for loadAllocsCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { pathToAllocsJson } = self; let path = Path::new(pathToAllocsJson); @@ -236,31 +364,45 @@ impl Cheatcode for loadAllocsCall { }; // Then, load the allocs into the database. - let (db, journal, _) = ccx.ecx.as_db_env_and_journal(); - db.load_allocs(&allocs, journal) - .map(|()| Vec::default()) - .map_err(|e| fmt_err!("failed to load allocs: {e}")) + let (db, inner) = ccx.ecx.db_journal_inner_mut(); + db.load_allocs(&allocs, inner).map_err(|e| fmt_err!("failed to load allocs: {e}"))?; + #[cfg(feature = "monad")] + refresh_chain_journal(ccx.ecx); + Ok(Default::default()) } } impl Cheatcode for cloneAccountCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { source, target } = self; - let (db, journal, _) = ccx.ecx.as_db_env_and_journal(); - let account = journal.load_account(db, *source)?; - let genesis = &genesis_account(account.data); - db.clone_account(genesis, target, journal)?; + let account = ccx.ecx.journal_mut().load_account(*source)?; + let genesis = genesis_account(account.data); + let (db, inner) = ccx.ecx.db_journal_inner_mut(); + db.clone_account(&genesis, target, inner)?; // Cloned account should persist in forked envs. - ccx.ecx.journaled_state.database.add_persistent_account(*target); + ccx.ecx.db_mut().add_persistent_account(*target); + #[cfg(feature = "monad")] + refresh_chain_journal(ccx.ecx); Ok(Default::default()) } } impl Cheatcode for dumpStateCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { pathToStateJson } = self; - let path = Path::new(pathToStateJson); + let path = ccx.state.config.ensure_path_allowed(pathToStateJson, FsAccessKind::Write)?; + ccx.state.config.ensure_not_foundry_toml(&path)?; + + let fork_id = ccx.active_fork_id(); + let created_accounts = ccx + .state + .created_accounts(fork_id) + .into_iter() + .filter(|address| { + ccx.ecx.journal().evm_state().get(address).is_some_and(Account::is_created) + }) + .collect::>(); // Do not include system account or empty accounts in the dump. let skip = |key: &Address, val: &Account| { @@ -273,22 +415,30 @@ impl Cheatcode for dumpStateCall { || val.is_empty() }; - let alloc = ccx + let mut alloc = ccx .ecx - .journaled_state - .state() + .journal_mut() + .evm_state_mut() .iter_mut() .filter(|(key, val)| !skip(key, val)) - .map(|(key, val)| (key, genesis_account(val))) + .map(|(key, val)| (*key, genesis_account(val))) .collect::>(); - write_json_file(path, &alloc)?; + let mut ordered_alloc = Vec::with_capacity(alloc.len()); + for address in created_accounts { + if let Some(account) = alloc.remove(&address) { + ordered_alloc.push((address, account)); + } + } + ordered_alloc.extend(alloc); + + write_json_file(&path, &StateDump(&ordered_alloc))?; Ok(Default::default()) } } impl Cheatcode for recordCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self {} = self; state.recording_accesses = true; state.accesses.clear(); @@ -297,14 +447,14 @@ impl Cheatcode for recordCall { } impl Cheatcode for stopRecordCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { state.recording_accesses = false; Ok(Default::default()) } } impl Cheatcode for accessesCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { target } = *self; let result = ( state.accesses.reads.entry(target).or_default().as_slice(), @@ -314,8 +464,38 @@ impl Cheatcode for accessesCall { } } +impl Cheatcode for registerSloadHookCall { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { + let Self { target, callback } = *self; + ccx.state.register_storage_load_hook(target, ccx.caller, callback.0); + Ok(Default::default()) + } +} + +impl Cheatcode for registerSstoreHookCall { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { + let Self { target, callback } = *self; + if ccx.state.has_mapping_storage_store_hooks(target) { + bail!("cannot register raw SSTORE hook: mapping SSTORE hooks already exist for target"); + } + ccx.state.register_storage_store_hook(target, ccx.caller, callback.0); + Ok(Default::default()) + } +} + +impl Cheatcode for registerMappingSstoreHookCall { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { + let Self { target, rootSlot, callback } = *self; + if !ccx.state.register_mapping_storage_store_hook(target, rootSlot, ccx.caller, callback.0) + { + bail!("cannot register mapping SSTORE hook: raw SSTORE hook already exists for target"); + } + Ok(Default::default()) + } +} + impl Cheatcode for recordLogsCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self {} = self; state.recorded_logs = Some(Default::default()); Ok(Default::default()) @@ -323,14 +503,30 @@ impl Cheatcode for recordLogsCall { } impl Cheatcode for getRecordedLogsCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self {} = self; Ok(state.recorded_logs.replace(Default::default()).unwrap_or_default().abi_encode()) } } +impl Cheatcode for getRecordedLogsJsonCall { + fn apply(&self, state: &mut Cheatcodes) -> Result { + let Self {} = self; + let logs = state.recorded_logs.replace(Default::default()).unwrap_or_default(); + let json_logs: Vec<_> = logs + .into_iter() + .map(|log| LogJson { + topics: log.topics.iter().map(|t| format!("{t}")).collect(), + data: hex::encode_prefixed(&log.data), + emitter: format!("{}", log.emitter), + }) + .collect(); + Ok(serde_json::to_string(&json_logs)?.abi_encode()) + } +} + impl Cheatcode for pauseGasMeteringCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self {} = self; state.gas_metering.paused = true; Ok(Default::default()) @@ -338,7 +534,7 @@ impl Cheatcode for pauseGasMeteringCall { } impl Cheatcode for resumeGasMeteringCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self {} = self; state.gas_metering.resume(); Ok(Default::default()) @@ -346,7 +542,7 @@ impl Cheatcode for resumeGasMeteringCall { } impl Cheatcode for resetGasMeteringCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self {} = self; state.gas_metering.reset(); Ok(Default::default()) @@ -354,7 +550,7 @@ impl Cheatcode for resetGasMeteringCall { } impl Cheatcode for lastCallGasCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self {} = self; let Some(last_call_gas) = &state.gas_metering.last_call_gas else { bail!("no external call was made yet"); @@ -363,190 +559,294 @@ impl Cheatcode for lastCallGasCall { } } +impl Cheatcode for lastFrameGasCall { + fn apply(&self, state: &mut Cheatcodes) -> Result { + let Self {} = self; + let Some(last_frame_gas) = &state.gas_metering.last_frame_gas else { + bail!("no external call or create was made yet"); + }; + Ok(last_frame_gas.abi_encode()) + } +} + +impl Cheatcode for getChainIdCall { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { + let Self {} = self; + Ok(U256::from(ccx.chain_id()).abi_encode()) + } +} + impl Cheatcode for chainIdCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { newChainId } = self; - ensure!(*newChainId <= U256::from(u64::MAX), "chain ID must be less than 2^64 - 1"); - ccx.ecx.cfg.chain_id = newChainId.to(); + ensure!(*newChainId <= U256::from(u64::MAX), "chain ID must be less than 2^64"); + ccx.ecx.cfg_env_mut().chain_id = newChainId.to(); Ok(Default::default()) } } impl Cheatcode for coinbaseCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { newCoinbase } = self; - ccx.ecx.block.beneficiary = *newCoinbase; + ccx.ecx.block_mut().set_beneficiary(*newCoinbase); Ok(Default::default()) } } impl Cheatcode for difficultyCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { newDifficulty } = self; ensure!( - ccx.ecx.cfg.spec < SpecId::MERGE, + ccx.spec().into() < SpecId::MERGE, "`difficulty` is not supported after the Paris hard fork, use `prevrandao` instead; \ see EIP-4399: https://eips.ethereum.org/EIPS/eip-4399" ); - ccx.ecx.block.difficulty = *newDifficulty; + ccx.ecx.block_mut().set_difficulty(*newDifficulty); Ok(Default::default()) } } impl Cheatcode for feeCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { newBasefee } = self; - ensure!(*newBasefee <= U256::from(u64::MAX), "base fee must be less than 2^64 - 1"); - ccx.ecx.block.basefee = newBasefee.saturating_to(); + ensure!(*newBasefee <= U256::from(u64::MAX), "base fee must be less than 2^64"); + let basefee: u64 = newBasefee.saturating_to(); + // Always record the override so `BASEFEE` reads the cheatcode value + // even inside the synthetic isolation transaction (which zeroes + // `block.basefee` for fee-accounting). See `EnvOverrides`. + let fork_id = ccx.active_fork_id(); + ccx.state.env_overrides.update(fork_id, |o| o.basefee = Some(basefee)); + // Outside isolation, also mutate the real env to preserve the + // historical behavior other code paths rely on. + if !ccx.state.in_isolation_context { + ccx.ecx.block_mut().set_basefee(basefee); + } Ok(Default::default()) } } impl Cheatcode for prevrandao_0Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { newPrevrandao } = self; ensure!( - ccx.ecx.cfg.spec >= SpecId::MERGE, + ccx.spec().into() >= SpecId::MERGE, "`prevrandao` is not supported before the Paris hard fork, use `difficulty` instead; \ see EIP-4399: https://eips.ethereum.org/EIPS/eip-4399" ); - ccx.ecx.block.prevrandao = Some(*newPrevrandao); + ccx.ecx.block_mut().set_prevrandao(Some(*newPrevrandao)); Ok(Default::default()) } } impl Cheatcode for prevrandao_1Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { newPrevrandao } = self; ensure!( - ccx.ecx.cfg.spec >= SpecId::MERGE, + ccx.spec().into() >= SpecId::MERGE, "`prevrandao` is not supported before the Paris hard fork, use `difficulty` instead; \ see EIP-4399: https://eips.ethereum.org/EIPS/eip-4399" ); - ccx.ecx.block.prevrandao = Some((*newPrevrandao).into()); + ccx.ecx.block_mut().set_prevrandao(Some((*newPrevrandao).into())); Ok(Default::default()) } } impl Cheatcode for blobhashesCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { hashes } = self; ensure!( - ccx.ecx.cfg.spec >= SpecId::CANCUN, + ccx.spec().into() >= SpecId::CANCUN, "`blobhashes` is not supported before the Cancun hard fork; \ see EIP-4844: https://eips.ethereum.org/EIPS/eip-4844" ); - ccx.ecx.tx.blob_hashes.clone_from(hashes); + // Always record the override so `BLOBHASH` returns the cheatcode + // value even inside the synthetic isolation transaction (which does + // not propagate `tx.blob_hashes`). See `EnvOverrides`. + let fork_id = ccx.active_fork_id(); + ccx.state.env_overrides.update(fork_id, |o| o.blob_hashes = Some(hashes.clone())); + // Outside isolation, also mutate the real env to preserve the + // historical behavior other code paths rely on. + if !ccx.state.in_isolation_context { + ccx.ecx.tx_mut().set_blob_hashes(hashes.clone()); + // force this as 4844 txtype + ccx.ecx.tx_mut().set_tx_type(EIP4844_TX_TYPE_ID); + } Ok(Default::default()) } } impl Cheatcode for getBlobhashesCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self {} = self; ensure!( - ccx.ecx.cfg.spec >= SpecId::CANCUN, + ccx.spec().into() >= SpecId::CANCUN, "`getBlobhashes` is not supported before the Cancun hard fork; \ see EIP-4844: https://eips.ethereum.org/EIPS/eip-4844" ); - Ok(ccx.ecx.tx.blob_hashes.clone().abi_encode()) + let fork_id = ccx.active_fork_id(); + let hashes = ccx + .state + .env_overrides + .get(fork_id) + .and_then(|o| o.blob_hashes.as_deref()) + .unwrap_or_else(|| ccx.tx_blob_hashes()); + Ok(hashes.to_vec().abi_encode()) } } impl Cheatcode for rollCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { newHeight } = self; - ccx.ecx.block.number = *newHeight; + let current_height = ccx.block_number(); + if ccx.spec().into() >= SpecId::PRAGUE && *newHeight > current_height { + let mut block_number = forward_fill_start(current_height, *newHeight); + while block_number < *newHeight { + let block_hash = + ccx.ecx.db_mut().block_hash(block_number.saturating_to()).unwrap_or_default(); + set_eip2935_blockhash(ccx.ecx, block_number, block_hash)?; + block_number += U256::ONE; + } + } + ccx.ecx.block_mut().set_number(*newHeight); Ok(Default::default()) } } impl Cheatcode for getBlockNumberCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self {} = self; - Ok(ccx.ecx.block.number.abi_encode()) + Ok(ccx.block_number().abi_encode()) + } +} + +impl Cheatcode for rollSlotCall { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { + ensure!( + ccx.spec().into() >= SpecId::AMSTERDAM, + "`rollSlot` is not supported before the Amsterdam hard fork; \ + see EIP-7843: https://eips.ethereum.org/EIPS/eip-7843" + ); + ccx.ecx.block_mut().set_slot_num(self.newSlotNumber); + Ok(Default::default()) + } +} + +impl Cheatcode for getSlotNumberCall { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { + ensure!( + ccx.spec().into() >= SpecId::AMSTERDAM, + "`getSlotNumber` is not supported before the Amsterdam hard fork; \ + see EIP-7843: https://eips.ethereum.org/EIPS/eip-7843" + ); + Ok(ccx.slot_num().abi_encode()) } } impl Cheatcode for txGasPriceCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { newGasPrice } = self; - ensure!(*newGasPrice <= U256::from(u64::MAX), "gas price must be less than 2^64 - 1"); - ccx.ecx.tx.gas_price = newGasPrice.saturating_to(); + ensure!(*newGasPrice <= U256::from(u64::MAX), "gas price must be less than 2^64"); + let gas_price: u128 = newGasPrice.saturating_to(); + // Always record the override so `GASPRICE` reads the cheatcode value + // even inside the synthetic isolation transaction (which zeroes + // `tx.gas_price` for fee-accounting). See `EnvOverrides`. + let fork_id = ccx.active_fork_id(); + ccx.state.env_overrides.update(fork_id, |o| o.gas_price = Some(gas_price)); + // Outside isolation, also mutate the real env to preserve the + // historical behavior other code paths rely on. Inside isolation we + // intentionally leave `tx.gas_price` at 0 so that the pranked caller + // does not need to pre-fund `gas * gasPrice` (see #7277). + if !ccx.state.in_isolation_context { + ccx.ecx.tx_mut().set_gas_price(gas_price); + } Ok(Default::default()) } } impl Cheatcode for warpCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { newTimestamp } = self; - ccx.ecx.block.timestamp = *newTimestamp; + ccx.ecx.block_mut().set_timestamp(*newTimestamp); Ok(Default::default()) } } impl Cheatcode for getBlockTimestampCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self {} = self; - Ok(ccx.ecx.block.timestamp.abi_encode()) + Ok(ccx.timestamp().abi_encode()) } } impl Cheatcode for blobBaseFeeCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { newBlobBaseFee } = self; ensure!( - ccx.ecx.cfg.spec >= SpecId::CANCUN, + ccx.spec().into() >= SpecId::CANCUN, "`blobBaseFee` is not supported before the Cancun hard fork; \ see EIP-4844: https://eips.ethereum.org/EIPS/eip-4844" ); - ccx.ecx.block.set_blob_excess_gas_and_price( + let spec: SpecId = ccx.spec().into(); + ccx.ecx.block_mut().set_blob_excess_gas_and_price( (*newBlobBaseFee).to(), - get_blob_base_fee_update_fraction_by_spec_id(ccx.ecx.cfg.spec), + get_blob_base_fee_update_fraction_by_spec_id(spec), ); Ok(Default::default()) } } impl Cheatcode for getBlobBaseFeeCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self {} = self; - Ok(ccx.ecx.block.blob_excess_gas().unwrap_or(0).abi_encode()) + Ok(ccx.blob_excess_gas().unwrap_or(0).abi_encode()) } } impl Cheatcode for dealCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { account: address, newBalance: new_balance } = *self; let account = journaled_account(ccx.ecx, address)?; let old_balance = std::mem::replace(&mut account.info.balance, new_balance); let record = DealRecord { address, old_balance, new_balance }; ccx.state.eth_deals.push(record); + #[cfg(feature = "monad")] + refresh_chain_journal(ccx.ecx); Ok(Default::default()) } } impl Cheatcode for etchCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { target, newRuntimeBytecode } = self; - ensure_not_precompile!(target, ccx); - ccx.ecx.journaled_state.load_account(*target)?; - let bytecode = Bytecode::new_raw_checked(Bytes::copy_from_slice(newRuntimeBytecode)) + ccx.ensure_not_precompile(target)?; + ccx.ecx.journal_mut().load_account(*target)?; + let bytecode = Bytecode::new_raw_checked(newRuntimeBytecode.clone()) .map_err(|e| fmt_err!("failed to create bytecode: {e}"))?; - ccx.ecx.journaled_state.set_code(*target, bytecode); + // Clear synthetic history only in local mode. On forks, preserve storage as for other + // etched accounts; clearing the ring can require one synchronous RPC read per uncached + // slot. + if *target == HISTORY_STORAGE_ADDRESS + && !ccx.is_forked_mode() + && bytecode.hash_slow() != keccak256(&HISTORY_STORAGE_CODE) + && ccx.ecx.journal_mut().evm_state()[target].info.code_hash + == keccak256(&HISTORY_STORAGE_CODE) + { + clear_eip2935_history_storage(ccx.ecx)?; + } + ccx.ecx.journal_mut().set_code(*target, bytecode); Ok(Default::default()) } } impl Cheatcode for resetNonceCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { account } = self; let account = journaled_account(ccx.ecx, *account)?; // Per EIP-161, EOA nonces start at 0, but contract nonces // start at 1. Comparing by code_hash instead of code // to avoid hitting the case where account's code is None. - let empty = account.info.code_hash == KECCAK_EMPTY; + let empty = account.info.is_empty_code_hash(); let nonce = if empty { 0 } else { 1 }; account.info.nonce = nonce; debug!(target: "cheatcodes", nonce, "reset"); @@ -555,7 +855,7 @@ impl Cheatcode for resetNonceCall { } impl Cheatcode for setNonceCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { account, newNonce } = *self; let account = journaled_account(ccx.ecx, account)?; // nonce must increment only @@ -571,7 +871,7 @@ impl Cheatcode for setNonceCall { } impl Cheatcode for setNonceUnsafeCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { account, newNonce } = *self; let account = journaled_account(ccx.ecx, account)?; account.info.nonce = newNonce; @@ -580,20 +880,46 @@ impl Cheatcode for setNonceUnsafeCall { } impl Cheatcode for storeCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { target, slot, value } = *self; - ensure_not_precompile!(&target, ccx); - // ensure the account is touched - let _ = journaled_account(ccx.ecx, target)?; - ccx.ecx.journaled_state.sstore(target, slot.into(), value.into())?; + ccx.ensure_not_precompile(&target)?; + ensure_loaded_account(ccx.ecx, target)?; + ccx.ecx + .journal_mut() + .sstore(target, slot.into(), value.into()) + .map_err(|e| fmt_err!("failed to store storage slot: {:?}", e))?; + ccx.state.mark_arbitrary_storage_slot_explicit(target, slot.into()); Ok(Default::default()) } } +impl Cheatcode for storeTransientCall { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { + let Self { target, slot, value } = *self; + ccx.ensure_not_precompile(&target)?; + ccx.ecx.journal_mut().tstore(target, slot.into(), value.into()); + Ok(Default::default()) + } +} + +impl Cheatcode for setTip20LogoURICall { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { + let Self { token, newLogoURI } = self; + set_tip20_logo_uri(ccx, token, newLogoURI) + } +} + +impl Cheatcode for setLogoURICall { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { + let Self { token, newLogoURI } = self; + set_tip20_logo_uri(ccx, token, newLogoURI) + } +} + impl Cheatcode for coolCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { target } = self; - if let Some(account) = ccx.ecx.journaled_state.state.get_mut(target) { + if let Some(account) = ccx.ecx.journal_mut().evm_state_mut().get_mut(target) { account.unmark_touch(); account.storage.values_mut().for_each(|slot| slot.mark_cold()); } @@ -602,7 +928,7 @@ impl Cheatcode for coolCall { } impl Cheatcode for accessListCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { access } = self; let access_list = access .iter() @@ -617,7 +943,7 @@ impl Cheatcode for accessListCall { } impl Cheatcode for noAccessListCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self {} = self; // Set to empty option in order to override previous applied access list. if state.access_list.is_some() { @@ -628,7 +954,7 @@ impl Cheatcode for noAccessListCall { } impl Cheatcode for warmSlotCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { target, slot } = *self; set_cold_slot(ccx, target, slot.into(), false); Ok(Default::default()) @@ -636,89 +962,115 @@ impl Cheatcode for warmSlotCall { } impl Cheatcode for coolSlotCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { target, slot } = *self; set_cold_slot(ccx, target, slot.into(), true); Ok(Default::default()) } } +impl Cheatcode for isIsolateModeCall { + fn apply(&self, state: &mut Cheatcodes) -> Result { + let Self {} = self; + Ok(state.config.isolate.abi_encode()) + } +} + impl Cheatcode for readCallersCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self {} = self; - read_callers(ccx.state, &ccx.ecx.tx.caller, ccx.ecx.journaled_state.depth()) + read_callers(ccx.state, &ccx.tx_caller(), ccx.depth()) } } impl Cheatcode for snapshotValue_0Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { name, value } = self; inner_value_snapshot(ccx, None, Some(name.clone()), value.to_string()) } } impl Cheatcode for snapshotValue_1Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { group, name, value } = self; inner_value_snapshot(ccx, Some(group.clone()), Some(name.clone()), value.to_string()) } } impl Cheatcode for snapshotGasLastCall_0Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { name } = self; - let Some(last_call_gas) = &ccx.state.gas_metering.last_call_gas else { + if ccx.state.gas_metering.last_call_gas.is_none() { bail!("no external call was made yet"); - }; - inner_last_gas_snapshot(ccx, None, Some(name.clone()), last_call_gas.gasTotalUsed) + } + let gas_used = ccx.state.gas_metering.last_call_snapshot_gas_used; + inner_last_gas_snapshot(ccx, None, Some(name.clone()), gas_used) } } impl Cheatcode for snapshotGasLastCall_1Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { name, group } = self; - let Some(last_call_gas) = &ccx.state.gas_metering.last_call_gas else { + if ccx.state.gas_metering.last_call_gas.is_none() { bail!("no external call was made yet"); - }; - inner_last_gas_snapshot( - ccx, - Some(group.clone()), - Some(name.clone()), - last_call_gas.gasTotalUsed, - ) + } + let gas_used = ccx.state.gas_metering.last_call_snapshot_gas_used; + inner_last_gas_snapshot(ccx, Some(group.clone()), Some(name.clone()), gas_used) + } +} + +impl Cheatcode for snapshotGasLastFrame_0Call { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { + let Self { name } = self; + if ccx.state.gas_metering.last_frame_gas.is_none() { + bail!("no external call or create was made yet"); + } + let gas_used = ccx.state.gas_metering.last_frame_snapshot_gas_used; + inner_last_gas_snapshot(ccx, None, Some(name.clone()), gas_used) + } +} + +impl Cheatcode for snapshotGasLastFrame_1Call { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { + let Self { name, group } = self; + if ccx.state.gas_metering.last_frame_gas.is_none() { + bail!("no external call or create was made yet"); + } + let gas_used = ccx.state.gas_metering.last_frame_snapshot_gas_used; + inner_last_gas_snapshot(ccx, Some(group.clone()), Some(name.clone()), gas_used) } } impl Cheatcode for startSnapshotGas_0Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { name } = self; inner_start_gas_snapshot(ccx, None, Some(name.clone())) } } impl Cheatcode for startSnapshotGas_1Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { group, name } = self; inner_start_gas_snapshot(ccx, Some(group.clone()), Some(name.clone())) } } impl Cheatcode for stopSnapshotGas_0Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self {} = self; inner_stop_gas_snapshot(ccx, None, None) } } impl Cheatcode for stopSnapshotGas_1Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { name } = self; inner_stop_gas_snapshot(ccx, None, Some(name.clone())) } } impl Cheatcode for stopSnapshotGas_2Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { group, name } = self; inner_stop_gas_snapshot(ccx, Some(group.clone()), Some(name.clone())) } @@ -726,14 +1078,14 @@ impl Cheatcode for stopSnapshotGas_2Call { // Deprecated in favor of `snapshotStateCall` impl Cheatcode for snapshotCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self {} = self; inner_snapshot_state(ccx) } } impl Cheatcode for snapshotStateCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self {} = self; inner_snapshot_state(ccx) } @@ -741,14 +1093,14 @@ impl Cheatcode for snapshotStateCall { // Deprecated in favor of `revertToStateCall` impl Cheatcode for revertToCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { snapshotId } = self; inner_revert_to_state(ccx, *snapshotId) } } impl Cheatcode for revertToStateCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { snapshotId } = self; inner_revert_to_state(ccx, *snapshotId) } @@ -756,14 +1108,14 @@ impl Cheatcode for revertToStateCall { // Deprecated in favor of `revertToStateAndDeleteCall` impl Cheatcode for revertToAndDeleteCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { snapshotId } = self; inner_revert_to_state_and_delete(ccx, *snapshotId) } } impl Cheatcode for revertToStateAndDeleteCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { snapshotId } = self; inner_revert_to_state_and_delete(ccx, *snapshotId) } @@ -771,53 +1123,79 @@ impl Cheatcode for revertToStateAndDeleteCall { // Deprecated in favor of `deleteStateSnapshotCall` impl Cheatcode for deleteSnapshotCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { snapshotId } = self; - inner_delete_state_snapshot(ccx, *snapshotId) + let result = ccx.ecx.db_mut().delete_state_snapshot(*snapshotId); + ccx.state.env_overrides.delete_snapshot(*snapshotId); + ccx.state.fork_block_number_override_snapshots.remove(snapshotId); + #[cfg(feature = "monad")] + ccx.state.context_snapshots.remove(snapshotId); + ccx.state.delete_created_accounts_snapshot(*snapshotId); + Ok(result.abi_encode()) } } impl Cheatcode for deleteStateSnapshotCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { snapshotId } = self; - inner_delete_state_snapshot(ccx, *snapshotId) + let result = ccx.ecx.db_mut().delete_state_snapshot(*snapshotId); + ccx.state.env_overrides.delete_snapshot(*snapshotId); + ccx.state.fork_block_number_override_snapshots.remove(snapshotId); + #[cfg(feature = "monad")] + ccx.state.context_snapshots.remove(snapshotId); + ccx.state.delete_created_accounts_snapshot(*snapshotId); + Ok(result.abi_encode()) } } // Deprecated in favor of `deleteStateSnapshotsCall` impl Cheatcode for deleteSnapshotsCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self {} = self; - inner_delete_state_snapshots(ccx) + ccx.ecx.db_mut().delete_state_snapshots(); + ccx.state.env_overrides.clear_snapshots(); + ccx.state.fork_block_number_override_snapshots.clear(); + #[cfg(feature = "monad")] + ccx.state.context_snapshots.clear(); + ccx.state.clear_created_accounts_snapshots(); + Ok(Default::default()) } } impl Cheatcode for deleteStateSnapshotsCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self {} = self; - inner_delete_state_snapshots(ccx) + ccx.ecx.db_mut().delete_state_snapshots(); + ccx.state.env_overrides.clear_snapshots(); + ccx.state.fork_block_number_override_snapshots.clear(); + #[cfg(feature = "monad")] + ccx.state.context_snapshots.clear(); + ccx.state.clear_created_accounts_snapshots(); + Ok(Default::default()) } } impl Cheatcode for startStateDiffRecordingCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self {} = self; - state.recorded_account_diffs_stack = Some(Default::default()); + state.start_state_diff_recording(); + // Enable mapping recording to track mapping slot accesses + state.mapping_slots.get_or_insert_default(); Ok(Default::default()) } } impl Cheatcode for stopAndReturnStateDiffCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self {} = self; get_state_diff(state) } } impl Cheatcode for getStateDiffCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let mut diffs = String::new(); - let state_diffs = get_recorded_state_diffs(state); + let state_diffs = get_recorded_state_diffs(ccx); for (address, state_diffs) in state_diffs { diffs.push_str(&format!("{address}\n")); diffs.push_str(&format!("{state_diffs}\n")); @@ -827,29 +1205,136 @@ impl Cheatcode for getStateDiffCall { } impl Cheatcode for getStateDiffJsonCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { - let state_diffs = get_recorded_state_diffs(state); + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { + let state_diffs = get_recorded_state_diffs(ccx); Ok(serde_json::to_string(&state_diffs)?.abi_encode()) } } +impl Cheatcode for getStorageSlotsCall { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { + let Self { target, variableName } = self; + + let storage_layout = get_contract_data(ccx, *target, true) + .and_then(|(_, data)| data.storage_layout.as_ref().map(|layout| layout.clone())) + .ok_or_else(|| fmt_err!("Storage layout not available for contract at {target}. Try compiling contracts with `--extra-output storageLayout`"))?; + + trace!(storage = ?storage_layout.storage, "fetched storage"); + + let variable_name_lower = variableName.to_lowercase(); + let storage = storage_layout + .storage + .iter() + .find(|s| s.label.to_lowercase() == variable_name_lower) + .ok_or_else(|| fmt_err!("variable '{variableName}' not found in storage layout"))?; + + let storage_type = storage_layout + .types + .get(&storage.storage_type) + .ok_or_else(|| fmt_err!("storage type not found for variable {variableName}"))?; + + if storage_type.encoding == ENCODING_MAPPING || storage_type.encoding == ENCODING_DYN_ARRAY + { + return Err(fmt_err!( + "cannot get storage slots for variables with mapping or dynamic array types" + )); + } + + let slot = U256::from_str(&storage.slot).map_err(|_| { + fmt_err!("invalid slot {} format for variable {variableName}", storage.slot) + })?; + + let mut slots = Vec::new(); + + // Always push the base slot + slots.push(slot); + + if storage_type.encoding == ENCODING_INPLACE { + // For inplace encoding, calculate the number of slots needed + let num_bytes = U256::from_str(&storage_type.number_of_bytes).map_err(|_| { + fmt_err!( + "invalid number_of_bytes {} for variable {variableName}", + storage_type.number_of_bytes + ) + })?; + let num_slots = num_bytes.div_ceil(U256::from(32)); + let num_slots = usize::try_from(num_slots).map_err(|_| { + fmt_err!("number_of_bytes {} exceeds host usize", storage_type.number_of_bytes) + })?; + + // Start from 1 since base slot is already added + for i in 1..num_slots { + slots.push(slot + U256::from(i)); + } + } + + if storage_type.encoding == ENCODING_BYTES { + // Try to check if it's a long bytes/string by reading the current storage + // value + if let Ok(value) = ccx.ecx.journal_mut().sload(*target, slot) { + let value_bytes = value.data.to_be_bytes::<32>(); + let length_byte = value_bytes[31]; + // Check if it's a long bytes/string (LSB is 1) + if length_byte & 1 == 1 { + // Calculate data slots for long bytes/string + let length: U256 = value.data >> 1; + let length = usize::try_from(length) + .map_err(|_| fmt_err!("long bytes/string length exceeds host usize"))?; + let num_data_slots = length.div_ceil(32); + let data_start = Into::::into(keccak256(B256::from(slot).0)); + + for i in 0..num_data_slots { + slots.push(data_start + U256::from(i)); + } + } + } + } + + Ok(slots.abi_encode()) + } +} + +impl Cheatcode for getStorageAccessesCall { + fn apply(&self, state: &mut Cheatcodes) -> Result { + let mut storage_accesses = Vec::new(); + + for account_access in state.recorded_account_diffs() { + storage_accesses.extend(account_access.storageAccesses.clone()); + } + + Ok(storage_accesses.abi_encode()) + } +} + impl Cheatcode for broadcastRawTransactionCall { - fn apply_full(&self, ccx: &mut CheatsCtxt, executor: &mut dyn CheatcodesExecutor) -> Result { - let tx = TxEnvelope::decode(&mut self.data.as_ref()) + fn apply_full( + &self, + ccx: &mut CheatsCtxt<'_, '_, FEN>, + executor: &mut dyn CheatcodesExecutor, + ) -> Result { + let tx = TxEnvelopeFor::::decode(&mut self.data.as_ref()) .map_err(|err| fmt_err!("failed to decode RLP-encoded transaction: {err}"))?; - let (db, journal, env) = ccx.ecx.as_db_env_and_journal(); - db.transact_from_tx( - &tx.clone().into(), - env.to_owned(), - journal, - &mut *executor.get_inspector(ccx.state), - )?; + let sender = + tx.recover_signer().map_err(|err| fmt_err!("failed to recover signer: {err}"))?; + let tx_env = TxEnvFor::::from_recovered_tx(&tx, sender); + let from = sender; + let contract_address = if tx.kind().is_create() { + let nonce = ccx.ecx.journal_mut().load_account(sender)?.data.info.nonce; + Some(sender.create(nonce)) + } else { + None + }; + + executor.transact_from_tx_on_db(ccx.state, ccx.ecx, tx_env)?; + #[cfg(feature = "monad")] + refresh_chain_journal(ccx.ecx); if ccx.state.broadcast.is_some() { ccx.state.broadcastable_transactions.push_back(BroadcastableTransaction { - rpc: ccx.ecx.journaled_state.database.active_fork_url(), - transaction: tx.try_into()?, + rpc: ccx.active_fork_url(), + transaction: TransactionMaybeSigned::Signed { tx, from }, + contract_address, }); } @@ -858,26 +1343,157 @@ impl Cheatcode for broadcastRawTransactionCall { } impl Cheatcode for setBlockhashCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { blockNumber, blockHash } = *self; - ensure!(blockNumber <= U256::from(u64::MAX), "blockNumber must be less than 2^64 - 1"); + ensure!(blockNumber <= U256::from(u64::MAX), "blockNumber must be less than 2^64"); ensure!( - blockNumber <= U256::from(ccx.ecx.block.number), + blockNumber <= ccx.block_number(), "block number must be less than or equal to the current block number" ); - ccx.ecx.journaled_state.database.set_blockhash(blockNumber, blockHash); + ccx.ecx.db_mut().set_blockhash(blockNumber, blockHash); + let current_block = ccx.block_number(); + if ccx.spec().into() >= SpecId::PRAGUE + && blockNumber < current_block + && current_block - blockNumber <= U256::from(HISTORY_SERVE_WINDOW) + { + set_eip2935_blockhash(ccx.ecx, blockNumber, blockHash)?; + } Ok(Default::default()) } } +impl Cheatcode for executeTransactionCall { + fn apply_full( + &self, + ccx: &mut CheatsCtxt<'_, '_, FEN>, + executor: &mut dyn CheatcodesExecutor, + ) -> Result { + // Block in script contexts. + if let Some(ctx) = FORGE_CONTEXT.get() + && *ctx == ForgeContext::ScriptGroup + { + return Err(fmt_err!("executeTransaction is not allowed in forge script")); + } + + // Decode the RLP-encoded signed transaction. + let tx = TxEnvelopeFor::::decode(&mut self.rawTx.as_ref()) + .map_err(|err| fmt_err!("failed to decode RLP-encoded transaction: {err}"))?; + + ensure!( + tx.ty() != 0x79, + "EIP-8130 transactions are not supported by vm.executeTransaction" + ); + + // Build TxEnv from the recovered transaction. + let sender = + tx.recover_signer().map_err(|err| fmt_err!("failed to recover signer: {err}"))?; + let tx_env = TxEnvFor::::from_recovered_tx(&tx, sender); + let chain_context = ccx.ecx.db().chain_context_for_synthetic_transaction(&tx_env)?; + let created_address = tx_env.kind().is_create().then(|| sender.create(tx_env.nonce())); + + // Save current env for restoration after execution. + let cached_evm_env = ccx.ecx.evm_clone(); + let cached_tx_env = ccx.ecx.tx_clone(); + + // Override env for isolated execution. + ccx.ecx.block_mut().set_basefee(0); + ccx.ecx.set_tx(tx_env); + ccx.ecx.tx_mut().set_gas_price(0); + ccx.ecx.tx_mut().set_gas_priority_fee(None); + + // Enable nonce checks for realistic simulation. + ccx.ecx.cfg_env_mut().disable_nonce_check = false; + + // Resolve the limit through the active EVM's concrete `Cfg` implementation. Replace the + // unlimited code-size override used for test contracts with the user-configured value so + // it does not implicitly make the initcode limit unlimited as well. + let code_size_limit = ccx.limit_contract_code_size(); + let configured_code_size_limit = ccx.state.config.evm_opts.env.code_size_limit; + ccx.ecx.cfg_env_mut().limit_contract_code_size = configured_code_size_limit; + let initcode_size_limit = ccx.max_initcode_size(); + ccx.ecx.cfg_env_mut().limit_contract_code_size = code_size_limit; + ccx.ecx.cfg_env_mut().limit_contract_initcode_size = Some(initcode_size_limit); + + // Reset the tx gas limit cap so revm applies the spec-defined default (EIP-7825). + // Normal test execution sets `Some(u64::MAX)` to disable the cap; clearing it here + // lets the nested EVM enforce the real network limit for realistic simulation. + ccx.ecx.cfg_env_mut().tx_gas_limit_cap = None; + + // Snapshot the modified env for EVM construction. + let modified_tx_env = ccx.ecx.tx_clone(); + + if let Some(address) = created_address { + let fork_id = ccx.active_fork_id(); + ccx.state.record_created_account(fork_id, address); + } + + // Clone journaled state and mark all accounts/slots cold. + let cold_state = prepare_child_state(ccx.ecx.journal_inner()); + + let mut res = None; + let mut cold_state = Some(cold_state); + let nested_evm_env = + executor.with_fresh_nested_evm(ccx.state, ccx.ecx, chain_context, &mut |evm| { + // SAFETY: closure is called exactly once by the executor. + evm.journal_inner_mut().state = cold_state.take().expect("called once"); + res = Some(evm.transact_raw(modified_tx_env.clone())); + Ok(()) + }); + let mut nested_evm_env = nested_evm_env?; + let res = res.unwrap(); + + // Restore env, preserving cheatcode cfg/block changes from the nested EVM + // but restoring the original tx and basefee (which we zeroed for the nested call) + // as well as cfg overrides that were applied only for the nested execution. + nested_evm_env.block_env.set_basefee(cached_evm_env.block_env.basefee()); + nested_evm_env.cfg_env.disable_nonce_check = cached_evm_env.cfg_env.disable_nonce_check; + nested_evm_env.cfg_env.limit_contract_initcode_size = + cached_evm_env.cfg_env.limit_contract_initcode_size; + nested_evm_env.cfg_env.tx_gas_limit_cap = cached_evm_env.cfg_env.tx_gas_limit_cap; + ccx.ecx.set_evm(nested_evm_env); + ccx.ecx.set_tx(cached_tx_env); + + let res = res.map_err(|e| fmt_err!("transaction execution failed: {e}"))?; + + // Merge state changes back into the parent journaled state. + merge_child_state(ccx.ecx.journal_mut().evm_state_mut(), res.state, false); + + // Keep Monad reserve balances aligned with the state merged from the nested EVM while + // preserving the outer transaction's execution context. + #[cfg(feature = "monad")] + refresh_chain_journal(ccx.ecx); + + // Return output bytes. + let output = match res.result { + ExecutionResult::Success { output, .. } => output.into_data(), + ExecutionResult::Halt { reason, .. } => { + return Err(fmt_err!("transaction halted: {reason:?}")); + } + ExecutionResult::Revert { output, .. } => { + return Err(fmt_err!("transaction reverted: {}", hex::encode_prefixed(&output))); + } + }; + + Ok(output.abi_encode()) + } +} + impl Cheatcode for startDebugTraceRecordingCall { - fn apply_full(&self, ccx: &mut CheatsCtxt, executor: &mut dyn CheatcodesExecutor) -> Result { - let Some(tracer) = executor.tracing_inspector().and_then(|t| t.as_mut()) else { + fn apply_full( + &self, + ccx: &mut CheatsCtxt<'_, '_, FEN>, + executor: &mut dyn CheatcodesExecutor, + ) -> Result { + let Some(tracer) = executor.tracing_inspector() else { return Err(Error::from("no tracer initiated, consider adding -vvv flag")); }; + if ccx.state.record_debug_steps_info.is_some() { + bail!("debug trace recording was already started"); + } + let mut info = RecordDebugStepInfo { // will be updated later start_node_idx: 0, @@ -885,13 +1501,9 @@ impl Cheatcode for startDebugTraceRecordingCall { original_tracer_config: *tracer.config(), }; - // turn on tracer configuration for recording - tracer.update_config(|config| { - config - .set_steps(true) - .set_memory_snapshots(true) - .set_stack_snapshots(StackSnapshotType::Full) - }); + // turn on tracer debug configuration for recording + *tracer.config_mut() = + TraceRequirements::none().with_debug(true).into_config().expect("cannot be None"); // track where the recording starts if let Some(last_node) = tracer.traces().nodes().last() { @@ -904,8 +1516,12 @@ impl Cheatcode for startDebugTraceRecordingCall { } impl Cheatcode for stopAndReturnDebugTraceRecordingCall { - fn apply_full(&self, ccx: &mut CheatsCtxt, executor: &mut dyn CheatcodesExecutor) -> Result { - let Some(tracer) = executor.tracing_inspector().and_then(|t| t.as_mut()) else { + fn apply_full( + &self, + ccx: &mut CheatsCtxt<'_, '_, FEN>, + executor: &mut dyn CheatcodesExecutor, + ) -> Result { + let Some(tracer) = executor.tracing_inspector() else { return Err(Error::from("no tracer initiated, consider adding -vvv flag")); }; @@ -918,7 +1534,7 @@ impl Cheatcode for stopAndReturnDebugTraceRecordingCall { let steps = flatten_call_trace(0, root, record_info.start_node_idx); let debug_steps: Vec = - steps.iter().map(|&step| convert_call_trace_to_debug_step(step)).collect(); + steps.iter().map(|step| convert_call_trace_ctx_to_debug_step(step)).collect(); // Free up memory by clearing the steps if they are not recorded outside of cheatcode usage. if !record_info.original_tracer_config.record_steps { tracer.traces_mut().nodes_mut().iter_mut().for_each(|node| { @@ -938,57 +1554,217 @@ impl Cheatcode for stopAndReturnDebugTraceRecordingCall { } } -pub(super) fn get_nonce(ccx: &mut CheatsCtxt, address: &Address) -> Result { - let account = ccx.ecx.journaled_state.load_account(*address)?; - Ok(account.info.nonce.abi_encode()) +impl Cheatcode for setEvmVersionCall { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { + let Self { evm } = self; + let spec_id = evm_spec_id_from_str(evm) + .ok_or_else(|| Error::from(format!("invalid evm version {evm}")))?; + ccx.state.execution_evm_version = Some(spec_id); + Ok(Default::default()) + } } -fn inner_snapshot_state(ccx: &mut CheatsCtxt) -> Result { - let (db, journal, mut env) = ccx.ecx.as_db_env_and_journal(); - Ok(db.snapshot_state(journal, &mut env).abi_encode()) +impl Cheatcode for getEvmVersionCall { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { + let spec = ccx.spec(); + Ok(spec.evm_version_name().to_lowercase().abi_encode()) + } } -fn inner_revert_to_state(ccx: &mut CheatsCtxt, snapshot_id: U256) -> Result { - let (db, journal, mut env) = ccx.ecx.as_db_env_and_journal(); - let result = if let Some(journaled_state) = - db.revert_state(snapshot_id, &*journal, &mut env, RevertStateSnapshotAction::RevertKeep) +pub(super) fn get_nonce( + ccx: &mut CheatsCtxt<'_, '_, FEN>, + address: &Address, +) -> Result { + let account = ccx.ecx.journal_mut().load_account(*address)?; + Ok(account.data.info.nonce.abi_encode()) +} + +fn inner_snapshot_state(ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { + let evm_env = ccx.ecx.evm_clone(); + let journaled_state = ccx.ecx.journal_inner().clone(); + let id = ccx.ecx.db_mut().snapshot_state(&journaled_state, &evm_env); + let fork_id = ccx.active_fork_id(); + let tx_gas_price = ccx.tx_gas_price(); + let tx_type = ccx.tx_type(); + let tx_blob_hashes = ccx.tx_blob_hashes().to_vec(); + ccx.state.env_overrides.save_snapshot(id, fork_id, tx_gas_price, tx_type, &tx_blob_hashes); + ccx.state.fork_block_number_override_snapshots.insert(id, ccx.state.fork_block_number_override); + #[cfg(feature = "monad")] { - // we reset the evm's journaled_state to the state of the snapshot previous state - ccx.ecx.journaled_state.inner = journaled_state; - true - } else { - false - }; - Ok(result.abi_encode()) + use foundry_evm_core::FoundryJournal as _; + + ccx.state + .context_snapshots + .insert(id, (ccx.ecx.chain().clone(), ccx.ecx.journal().capture_reserve_balance())); + } + ccx.state.snapshot_created_accounts(id, fork_id); + Ok(id.abi_encode()) } -fn inner_revert_to_state_and_delete(ccx: &mut CheatsCtxt, snapshot_id: U256) -> Result { - let (db, journal, mut env) = ccx.ecx.as_db_env_and_journal(); +/// Syncs the real `tx` fields to match restored `env_overrides`. +/// +/// `evm_clone` / `set_evm` only save/restore cfg+block, not tx. When a +/// cheatcode like `vm.blobhashes` or `vm.txGasPrice` mutates both the +/// override and the real tx, reverting to a snapshot where the override was +/// absent would leave the real tx field stale. This brings them back into +/// sync so callers that read the tx directly also see the rolled-back state. +fn sync_tx_after_env_override_restore(ccx: &mut CheatsCtxt<'_, '_, FEN>) { + let fork_id = ccx.active_fork_id(); + // Clone to avoid borrow conflicts when mutating ecx below. + let env_overrides = ccx.state.env_overrides.get(fork_id).cloned().unwrap_or_default(); + match env_overrides.gas_price { + Some(p) if !ccx.state.in_isolation_context => ccx.ecx.tx_mut().set_gas_price(p), + None => { + // Restore the pre-override gas_price recorded at snapshot time. + // Falling back to 0 would be wrong when the tx had a non-zero price + // (e.g. --gas-price flag, foundry.toml, or fork mode). + let pre = env_overrides.pre_override_gas_price.unwrap_or(0); + ccx.ecx.tx_mut().set_gas_price(pre); + } + _ => {} + } + match env_overrides.blob_hashes { + Some(hashes) if !ccx.state.in_isolation_context => { + ccx.ecx.tx_mut().set_blob_hashes(hashes); + ccx.ecx.tx_mut().set_tx_type(EIP4844_TX_TYPE_ID); + } + None => { + // Restore the pre-override blob hashes recorded at snapshot time. + let pre_hashes = env_overrides.pre_override_blob_hashes.unwrap_or_default(); + ccx.ecx.tx_mut().set_blob_hashes(pre_hashes); + // Restore pre-override tx_type; without this it stays at EIP4844 even + // after the blob hashes are cleared. + let pre_type = env_overrides.pre_override_tx_type.unwrap_or(0); + ccx.ecx.tx_mut().set_tx_type(pre_type); + } + _ => {} + } + ccx.state.env_overrides.remove_if_unset(fork_id); +} - let result = if let Some(journaled_state) = - db.revert_state(snapshot_id, &*journal, &mut env, RevertStateSnapshotAction::RevertRemove) - { - // we reset the evm's journaled_state to the state of the snapshot previous state - ccx.ecx.journaled_state.inner = journaled_state; - true - } else { - false - }; - Ok(result.abi_encode()) +fn restore_isolation_fee_accounting(ccx: &mut CheatsCtxt<'_, '_, FEN>) { + if !ccx.state.in_isolation_context { + return; + } + + let basefee = ccx.basefee(); + if basefee != 0 { + let fork_id = ccx.active_fork_id(); + ccx.state.env_overrides.update(fork_id, |o| { + o.implicit_basefee.get_or_insert(basefee); + }); + ccx.ecx.block_mut().set_basefee(0); + } + ccx.ecx.chain_mut().clear_transaction_fee_cache(); } -fn inner_delete_state_snapshot(ccx: &mut CheatsCtxt, snapshot_id: U256) -> Result { - let result = ccx.ecx.journaled_state.database.delete_state_snapshot(snapshot_id); - Ok(result.abi_encode()) +/// Records the live journal replaced by a snapshot restoration so that a failing enclosing frame +/// can reinstate it and unwind its journaled writes. +fn track_snapshot_restore( + ccx: &mut CheatsCtxt<'_, '_, FEN>, + journaled_state: JournaledState, + restored: &JournaledState, +) { + if !ccx.state.track_isolated_snapshots { + return; + } + if std::mem::take(&mut ccx.state.capture_isolated_snapshot_restore) { + ccx.state.isolated_snapshot_restores.push(journaled_state); + } + // The suspended parent of an isolated call adopts the restored journal on success. + if ccx.state.in_isolation_context { + ccx.state.pending_isolated_snapshot_journal = Some(restored.journal.clone()); + } } -fn inner_delete_state_snapshots(ccx: &mut CheatsCtxt) -> Result { - ccx.ecx.journaled_state.database.delete_state_snapshots(); - Ok(Default::default()) +fn inner_revert_to_state( + ccx: &mut CheatsCtxt<'_, '_, FEN>, + snapshot_id: U256, +) -> Result { + let mut evm_env = ccx.ecx.evm_clone(); + let caller = ccx.tx_caller(); + let journaled_state = ccx.ecx.journal_inner().clone(); + if let Some(restored) = ccx.ecx.db_mut().revert_state( + snapshot_id, + &journaled_state, + &mut evm_env, + caller, + RevertStateSnapshotAction::RevertKeep, + ) { + track_snapshot_restore(ccx, journaled_state, &restored); + ccx.ecx.set_journal_inner(restored); + #[cfg(feature = "monad")] + { + use foundry_evm_core::FoundryJournal as _; + + if let Some((context, state)) = ccx.state.context_snapshots.get(&snapshot_id) { + *ccx.ecx.chain_mut() = context.clone(); + ccx.ecx.journal_mut().restore_reserve_balance(state.clone()); + } + refresh_chain_journal(ccx.ecx); + } + ccx.ecx.set_evm(evm_env); + // `RevertKeep` keeps the backend snapshot alive for further + // reverts, so keep our matching env-overrides copy too. + ccx.state.env_overrides.restore_snapshot(snapshot_id, false); + if let Some(&fork_block_number) = + ccx.state.fork_block_number_override_snapshots.get(&snapshot_id) + { + ccx.state.fork_block_number_override = fork_block_number; + } + ccx.state.revert_created_accounts(snapshot_id, false); + sync_tx_after_env_override_restore(ccx); + restore_isolation_fee_accounting(ccx); + Ok(true.abi_encode()) + } else { + Ok(false.abi_encode()) + } } -fn inner_value_snapshot( - ccx: &mut CheatsCtxt, +fn inner_revert_to_state_and_delete( + ccx: &mut CheatsCtxt<'_, '_, FEN>, + snapshot_id: U256, +) -> Result { + let mut evm_env = ccx.ecx.evm_clone(); + let caller = ccx.tx_caller(); + let journaled_state = ccx.ecx.journal_inner().clone(); + if let Some(restored) = ccx.ecx.db_mut().revert_state( + snapshot_id, + &journaled_state, + &mut evm_env, + caller, + RevertStateSnapshotAction::RevertRemove, + ) { + track_snapshot_restore(ccx, journaled_state, &restored); + ccx.ecx.set_journal_inner(restored); + #[cfg(feature = "monad")] + { + use foundry_evm_core::FoundryJournal as _; + + if let Some((context, state)) = ccx.state.context_snapshots.remove(&snapshot_id) { + *ccx.ecx.chain_mut() = context; + ccx.ecx.journal_mut().restore_reserve_balance(state); + } + refresh_chain_journal(ccx.ecx); + } + ccx.ecx.set_evm(evm_env); + ccx.state.env_overrides.restore_snapshot(snapshot_id, true); + if let Some(fork_block_number) = + ccx.state.fork_block_number_override_snapshots.remove(&snapshot_id) + { + ccx.state.fork_block_number_override = fork_block_number; + } + ccx.state.revert_created_accounts(snapshot_id, true); + sync_tx_after_env_override_restore(ccx); + restore_isolation_fee_accounting(ccx); + Ok(true.abi_encode()) + } else { + Ok(false.abi_encode()) + } +} + +fn inner_value_snapshot( + ccx: &mut CheatsCtxt<'_, '_, FEN>, group: Option, name: Option, value: String, @@ -1000,8 +1776,8 @@ fn inner_value_snapshot( Ok(Default::default()) } -fn inner_last_gas_snapshot( - ccx: &mut CheatsCtxt, +fn inner_last_gas_snapshot( + ccx: &mut CheatsCtxt<'_, '_, FEN>, group: Option, name: Option, value: u64, @@ -1013,14 +1789,13 @@ fn inner_last_gas_snapshot( Ok(value.abi_encode()) } -fn inner_start_gas_snapshot( - ccx: &mut CheatsCtxt, +fn inner_start_gas_snapshot( + ccx: &mut CheatsCtxt<'_, '_, FEN>, group: Option, name: Option, ) -> Result { // Revert if there is an active gas snapshot as we can only have one active snapshot at a time. - if ccx.state.gas_metering.active_gas_snapshot.is_some() { - let (group, name) = ccx.state.gas_metering.active_gas_snapshot.as_ref().unwrap().clone(); + if let Some((group, name)) = &ccx.state.gas_metering.active_gas_snapshot { bail!("gas snapshot was already started with group: {group} and name: {name}"); } @@ -1030,7 +1805,7 @@ fn inner_start_gas_snapshot( group: group.clone(), name: name.clone(), gas_used: 0, - depth: ccx.ecx.journaled_state.depth(), + depth: ccx.depth(), }); ccx.state.gas_metering.active_gas_snapshot = Some((group, name)); @@ -1040,16 +1815,16 @@ fn inner_start_gas_snapshot( Ok(Default::default()) } -fn inner_stop_gas_snapshot( - ccx: &mut CheatsCtxt, +fn inner_stop_gas_snapshot( + ccx: &mut CheatsCtxt<'_, '_, FEN>, group: Option, name: Option, ) -> Result { // If group and name are not provided, use the last snapshot group and name. - let (group, name) = group.zip(name).unwrap_or_else(|| { - let (group, name) = ccx.state.gas_metering.active_gas_snapshot.as_ref().unwrap().clone(); - (group, name) - }); + let (group, name) = group + .zip(name) + .or_else(|| ccx.state.gas_metering.active_gas_snapshot.clone()) + .ok_or_else(|| fmt_err!("no active gas snapshot; call `startGasSnapshot` first"))?; if let Some(record) = ccx .state @@ -1092,8 +1867,8 @@ fn inner_stop_gas_snapshot( } // Derives the snapshot group and name from the provided group and name or the running contract. -fn derive_snapshot_name( - ccx: &CheatsCtxt, +fn derive_snapshot_name( + ccx: &CheatsCtxt<'_, '_, FEN>, group: Option, name: Option, ) -> (String, String) { @@ -1127,7 +1902,11 @@ fn derive_snapshot_name( /// - If no caller modification is active: /// - caller_mode will be equal to [CallerMode::None], /// - `msg.sender` and `tx.origin` will be equal to the default sender address. -fn read_callers(state: &Cheatcodes, default_sender: &Address, call_depth: usize) -> Result { +fn read_callers( + state: &Cheatcodes, + default_sender: &Address, + call_depth: usize, +) -> Result { let mut mode = CallerMode::None; let mut new_caller = default_sender; let mut new_origin = default_sender; @@ -1151,13 +1930,225 @@ fn read_callers(state: &Cheatcodes, default_sender: &Address, call_depth: usize) } /// Ensures the `Account` is loaded and touched. -pub(super) fn journaled_account<'a>( - ecx: Ecx<'a, '_, '_>, +pub(super) fn journaled_account< + CTX: ContextTr, Journal: JournalExt>, +>( + ecx: &mut CTX, addr: Address, -) -> Result<&'a mut Account> { - ecx.journaled_state.load_account(addr)?; - ecx.journaled_state.touch(addr); - Ok(ecx.journaled_state.state.get_mut(&addr).expect("account is loaded")) +) -> Result<&mut Account> { + ensure_loaded_account(ecx, addr)?; + Ok(ecx.journal_mut().evm_state_mut().get_mut(&addr).expect("account is loaded")) +} + +pub(super) fn ensure_loaded_account>>( + ecx: &mut CTX, + addr: Address, +) -> Result<()> { + ecx.journal_mut().load_account(addr)?; + ecx.journal_mut().touch_account(addr); + Ok(()) +} + +fn set_eip2935_blockhash< + CTX: ContextTr, Journal: JournalExt>, +>( + ecx: &mut CTX, + block_number: U256, + block_hash: B256, +) -> Result<()> { + let account_was_cold = ecx.journal_mut().load_account(HISTORY_STORAGE_ADDRESS)?.is_cold; + let account = + ecx.journal_mut().evm_state().get(&HISTORY_STORAGE_ADDRESS).expect("account is loaded"); + if account.info.code_hash() != keccak256(&HISTORY_STORAGE_CODE) { + restore_eip2935_cold_state(ecx, account_was_cold, None); + return Ok(()); + } + + let slot = history_storage_slot(block_number); + let slot_was_cold = ecx + .journal_mut() + .sstore(HISTORY_STORAGE_ADDRESS, slot, history_storage_value(block_hash)) + .map_err(|e| fmt_err!("failed to store EIP-2935 history slot: {:?}", e))? + .is_cold; + restore_eip2935_cold_state(ecx, account_was_cold, Some((slot, slot_was_cold))); + Ok(()) +} + +/// Clears the EIP-2935 history ring buffer with journaled writes, so that reverting the enclosing +/// frame restores the previous history storage. +/// +/// Warmth is preserved: slots that were cold stay cold. A database error reverts any partial +/// clearing. +fn clear_eip2935_history_storage< + CTX: ContextTr, Journal: JournalExt>, +>( + ecx: &mut CTX, +) -> Result<()> { + let checkpoint = ecx.journal_mut().checkpoint(); + for slot in 0..HISTORY_SERVE_WINDOW { + let slot = U256::from(slot); + match ecx.journal_mut().sstore(HISTORY_STORAGE_ADDRESS, slot, U256::ZERO) { + Ok(result) if result.is_cold => { + if let Some(storage_slot) = ecx + .journal_mut() + .evm_state_mut() + .get_mut(&HISTORY_STORAGE_ADDRESS) + .and_then(|account| account.storage.get_mut(&slot)) + { + storage_slot.is_cold = true; + } + } + Ok(_) => {} + Err(e) => { + ecx.journal_mut().checkpoint_revert(checkpoint); + return Err(fmt_err!("failed to clear EIP-2935 history slot: {e:?}")); + } + } + } + ecx.journal_mut().checkpoint_commit(); + Ok(()) +} + +fn restore_eip2935_cold_state< + CTX: ContextTr, Journal: JournalExt>, +>( + ecx: &mut CTX, + account_was_cold: bool, + slot_state: Option<(U256, bool)>, +) { + let Some(account) = ecx.journal_mut().evm_state_mut().get_mut(&HISTORY_STORAGE_ADDRESS) else { + return; + }; + if account_was_cold { + account.mark_cold(); + } + if let Some((slot, slot_was_cold)) = slot_state + && slot_was_cold + && let Some(storage_slot) = account.storage.get_mut(&slot) + { + storage_slot.is_cold = true; + } +} + +// Tempo TIP-1026 stores logoURI in TIP-20 storage slot 5, reusing the +// previously-unused domainSeparator slot. This mirrors Tempo's +// crates/precompiles/tests/storage_tests/solidity/testdata/tip20.layout.json +// fixture, where `logoUri` has slot "5". +const TIP20_LOGO_URI_SLOT_INDEX: u64 = 5; + +fn set_tip20_logo_uri( + ccx: &mut CheatsCtxt<'_, '_, FEN>, + token: &Address, + new_logo_uri: &str, +) -> Result { + validate_tip20_logo_uri(new_logo_uri).map_err(|err| match err { + Tip20LogoUriValidationError::LogoURITooLong => fmt_err!("LogoURITooLong"), + Tip20LogoUriValidationError::InvalidLogoURI => fmt_err!("InvalidLogoURI"), + })?; + ccx.ensure_not_precompile(token)?; + ensure_loaded_account(ccx.ecx, *token)?; + store_solidity_string( + ccx.ecx, + *token, + U256::from(TIP20_LOGO_URI_SLOT_INDEX), + new_logo_uri.as_bytes(), + ) +} + +fn store_solidity_string( + ecx: &mut CTX, + target: Address, + base_slot: U256, + bytes: &[u8], +) -> Result +where + CTX: ContextTr, Journal: JournalExt>, +{ + cleanup_long_string_tail(ecx, target, base_slot, bytes.len())?; + + if bytes.len() <= 31 { + ecx.journal_mut() + .sstore(target, base_slot, encode_short_string(bytes)) + .map_err(|e| fmt_err!("failed to store TIP-20 logo URI: {:?}", e))?; + return Ok(Default::default()); + } + + ecx.journal_mut() + .sstore(target, base_slot, U256::from(bytes.len() * 2 + 1)) + .map_err(|e| fmt_err!("failed to store TIP-20 logo URI length: {:?}", e))?; + + let slot_start = solidity_dynamic_data_slot(base_slot); + for (index, chunk) in bytes.chunks(32).enumerate() { + let mut chunk_bytes = [0u8; 32]; + chunk_bytes[..chunk.len()].copy_from_slice(chunk); + ecx.journal_mut() + .sstore(target, slot_start + U256::from(index), U256::from_be_bytes(chunk_bytes)) + .map_err(|e| fmt_err!("failed to store TIP-20 logo URI data: {:?}", e))?; + } + + Ok(Default::default()) +} + +fn cleanup_long_string_tail( + ecx: &mut CTX, + target: Address, + base_slot: U256, + new_len: usize, +) -> Result<()> +where + CTX: ContextTr, Journal: JournalExt>, +{ + let previous = ecx + .journal_mut() + .sload(target, base_slot) + .map_err(|e| fmt_err!("failed to load previous TIP-20 logo URI: {:?}", e))? + .data; + if !is_long_string(previous) { + return Ok(()); + } + + let Some(previous_len) = long_string_length(previous) else { + return Ok(()); + }; + let previous_chunks = string_chunks(previous_len); + let new_chunks = if new_len > 31 { string_chunks(new_len) } else { 0 }; + if previous_chunks <= new_chunks { + return Ok(()); + } + + let slot_start = solidity_dynamic_data_slot(base_slot); + for index in new_chunks..previous_chunks { + ecx.journal_mut() + .sstore(target, slot_start + U256::from(index), U256::ZERO) + .map_err(|e| fmt_err!("failed to clear previous TIP-20 logo URI data: {:?}", e))?; + } + + Ok(()) +} + +fn encode_short_string(bytes: &[u8]) -> U256 { + let mut storage_bytes = [0u8; 32]; + storage_bytes[..bytes.len()].copy_from_slice(bytes); + storage_bytes[31] = + u8::try_from(bytes.len() * 2).expect("short Solidity string length tag fits in u8"); + U256::from_be_bytes(storage_bytes) +} + +fn solidity_dynamic_data_slot(base_slot: U256) -> U256 { + Into::::into(keccak256(base_slot.to_be_bytes::<32>())) +} + +const fn is_long_string(slot_value: U256) -> bool { + (slot_value.to_be_bytes::<32>()[31] & 1) != 0 +} + +fn long_string_length(slot_value: U256) -> Option { + let length: U256 = (slot_value - U256::ONE) >> 1; + usize::try_from(length).ok().filter(|length| *length <= TIP20_MAX_LOGO_URI_BYTES) +} + +const fn string_chunks(byte_length: usize) -> usize { + byte_length.div_ceil(32) } /// Consumes recorded account accesses and returns them as an abi encoded @@ -1167,17 +2158,90 @@ pub(super) fn journaled_account<'a>( /// In the case where `stopAndReturnStateDiff` is called at a lower /// depth than `startStateDiffRecording`, multiple `Vec` /// will be flattened, preserving the order of the accesses. -fn get_state_diff(state: &mut Cheatcodes) -> Result { - let res = state - .recorded_account_diffs_stack - .replace(Default::default()) - .unwrap_or_default() - .into_iter() - .flatten() - .collect::>(); +fn get_state_diff(state: &mut Cheatcodes) -> Result { + let mut res = state.take_recorded_account_diffs_prefix(); + res.extend( + state + .recorded_account_diffs_stack + .replace(Default::default()) + .unwrap_or_default() + .into_iter() + .flatten(), + ); Ok(res.abi_encode()) } +/// Marks a reverted frame's account accesses, including their storage accesses, as reverted. +pub(crate) fn mark_account_accesses_reverted(accesses: &mut [AccountAccess]) { + for access in accesses { + access.reverted = true; + for storage_access in &mut access.storageAccesses { + storage_access.reverted = true; + } + } +} + +/// Moves a finished frame's account accesses into the enclosing recorded frame, or back onto the +/// stack when no enclosing frame was recorded, preserving the order of the accesses. +pub(crate) fn merge_recorded_frame( + stack: &mut Vec>, + mut accesses: Vec, +) { + if let Some(parent) = stack.last_mut() { + parent.append(&mut accesses); + } else { + stack.push(accesses); + } +} + +/// Appends an AccountAccess that resumes the recording of the current context. +pub(crate) fn append_storage_access( + last: &mut Vec, + storage_access: crate::Vm::StorageAccess, + storage_depth: u64, +) { + // Assert that there's an existing record for the current context. + if !last.is_empty() && last.first().unwrap().depth < storage_depth { + // Three cases to consider: + // 1. If there hasn't been a context switch since the start of this context, then add the + // storage access to the current context record. + // 2. If there's an existing Resume record, then add the storage access to it. + // 3. Otherwise, create a new Resume record based on the current context. + if last.len() == 1 { + last.first_mut().unwrap().storageAccesses.push(storage_access); + } else { + let last_record = last.last_mut().unwrap(); + if last_record.kind as u8 == crate::Vm::AccountAccessKind::Resume as u8 { + last_record.storageAccesses.push(storage_access); + } else { + let entry = last.first().unwrap(); + let resume_record = crate::Vm::AccountAccess { + chainInfo: crate::Vm::ChainInfo { + forkId: entry.chainInfo.forkId, + chainId: entry.chainInfo.chainId, + }, + accessor: entry.accessor, + account: entry.account, + kind: crate::Vm::AccountAccessKind::Resume, + initialized: entry.initialized, + storageAccesses: vec![storage_access], + reverted: entry.reverted, + // The remaining fields are defaults + oldBalance: U256::ZERO, + newBalance: U256::ZERO, + oldNonce: 0, + newNonce: 0, + value: U256::ZERO, + data: Bytes::new(), + deployedCode: Bytes::new(), + depth: entry.depth, + }; + last.push(resume_record); + } + } + } +} + /// Helper function that creates a `GenesisAccount` from a regular `Account`. fn genesis_account(account: &Account) -> GenesisAccount { GenesisAccount { @@ -1196,68 +2260,309 @@ fn genesis_account(account: &Account) -> GenesisAccount { } /// Helper function to returns state diffs recorded for each changed account. -fn get_recorded_state_diffs(state: &mut Cheatcodes) -> BTreeMap { +fn get_recorded_state_diffs( + ccx: &mut CheatsCtxt<'_, '_, FEN>, +) -> BTreeMap { let mut state_diffs: BTreeMap = BTreeMap::default(); - if let Some(records) = &state.recorded_account_diffs_stack { - records - .iter() - .flatten() - .filter(|account_access| { - !account_access.storageAccesses.is_empty() - || account_access.oldBalance != account_access.newBalance - }) - .for_each(|account_access| { - // Record account balance diffs. - if account_access.oldBalance != account_access.newBalance { - let account_diff = - state_diffs.entry(account_access.account).or_insert_with(|| { - AccountStateDiffs { - label: state.labels.get(&account_access.account).cloned(), - ..Default::default() - } - }); - // Update balance diff. Do not overwrite the initial balance if already set. - if let Some(diff) = &mut account_diff.balance_diff { - diff.new_value = account_access.newBalance; + + // First, collect all unique addresses we need to look up + let mut addresses_to_lookup = AddressSet::default(); + // Storage owner to the chain and bytecode address that executed each write. For a delegatecall, + // these are the proxy and implementation respectively. + let mut layout_sources = AddressMap::default(); + for account_access in ccx.state.recorded_account_diffs() { + if !account_access.storageAccesses.is_empty() + || account_access.oldBalance != account_access.newBalance + { + addresses_to_lookup.insert(account_access.account); + for storage_access in &account_access.storageAccesses { + if storage_access.isWrite && !storage_access.reverted { + addresses_to_lookup.insert(storage_access.account); + let source = ( + account_access.chainInfo.chainId.to::(), + account_access.chainInfo.forkId, + account_access.account, + ); + if let Some(existing) = layout_sources.get_mut(&storage_access.account) { + if *existing != Some(source) { + // The state-diff output has no chain dimension. If writes to one + // address came from different chains or implementations, decoding it + // with either layout would be misleading. + *existing = None; + } } else { - account_diff.balance_diff = Some(BalanceDiff { - previous_value: account_access.oldBalance, - new_value: account_access.newBalance, - }); + layout_sources.insert(storage_access.account, Some(source)); } } + } + } + } + + // Look up contract names and storage layouts for all addresses + let mut contract_names = AddressMap::default(); + let mut storage_layouts = AddressMap::default(); + for address in addresses_to_lookup { + if let Some((artifact_id, contract_data)) = get_contract_data(ccx, address, false) { + contract_names.insert(address, artifact_id.identifier()); + + // Also get storage layout if available + if let Some(storage_layout) = &contract_data.storage_layout { + storage_layouts.insert(address, storage_layout.clone()); + } + } + } + + // A delegatecall writes the caller's storage with the callee's layout. Prefer a local artifact + // for the recorded bytecode address; otherwise fetch that exact implementation on the chain + // where the write occurred. This remains correct for historical forks and upgraded proxies. + if ccx.state.config.decode_external_storage { + let current_chain_id = ccx.chain_id(); + let current_fork_id = ccx.active_fork_id().unwrap_or_default(); + let mut external_requests = BTreeMap::::new(); + let mut external_targets = Vec::new(); + for (storage_address, source) in layout_sources { + let Some((chain_id, fork_id, code_address)) = source else { + // Writes from multiple chains or bytecode addresses cannot share one trustworthy + // layout in the address-keyed output. + contract_names.remove(&storage_address); + storage_layouts.remove(&storage_address); + continue; + }; + let recorded_context_is_current = + chain_id == current_chain_id && fork_id == current_fork_id; + if !recorded_context_is_current { + contract_names.remove(&storage_address); + } + if recorded_context_is_current + && code_address == storage_address + && storage_layouts.contains_key(&storage_address) + { + continue; + } + + // A local artifact for the storage owner may describe a proxy rather than the code + // that executed the write. Replace it only with the recorded bytecode's layout. + storage_layouts.remove(&storage_address); + if recorded_context_is_current + && let Some((artifact_id, contract_data)) = + get_contract_data(ccx, code_address, false) + && let Some(layout) = &contract_data.storage_layout + { + contract_names.insert(storage_address, artifact_id.identifier()); + storage_layouts.insert(storage_address, layout.clone()); + } else { + external_requests.entry(chain_id).or_default().insert(code_address); + external_targets.push((storage_address, chain_id, code_address)); + } + } - // Record account state diffs. - for storage_access in &account_access.storageAccesses { - if storage_access.isWrite && !storage_access.reverted { - let account_diff = state_diffs - .entry(storage_access.account) - .or_insert_with(|| AccountStateDiffs { - label: state.labels.get(&storage_access.account).cloned(), + for (chain_id, addresses) in external_requests { + let external = crate::external_storage::storage_layouts( + &ccx.state.config.external_sources, + foundry_config::Chain::from(chain_id), + addresses.into_iter().collect(), + ); + for &(storage_address, target_chain_id, code_address) in &external_targets { + if target_chain_id == chain_id + && let Some((name, layout)) = external.get(&code_address) + { + contract_names.insert(storage_address, name.clone()); + storage_layouts.insert(storage_address, layout.clone()); + } + } + } + } + + // Now process the records + ccx.state + .recorded_account_diffs() + .filter(|account_access| { + !account_access.storageAccesses.is_empty() + || account_access.oldBalance != account_access.newBalance + || (!account_access.reverted && account_access.oldNonce != account_access.newNonce) + }) + .for_each(|account_access| { + // Record account balance diffs. + if account_access.oldBalance != account_access.newBalance { + let account_diff = state_diffs.entry(account_access.account).or_insert_with(|| { + AccountStateDiffs { + label: ccx.state.labels.get(&account_access.account).cloned(), + contract: contract_names.get(&account_access.account).cloned(), + ..Default::default() + } + }); + // Update balance diff. Do not overwrite the initial balance if already set. + if let Some(diff) = &mut account_diff.balance_diff { + diff.new_value = account_access.newBalance; + } else { + account_diff.balance_diff = Some(BalanceDiff { + previous_value: account_access.oldBalance, + new_value: account_access.newBalance, + }); + } + } + + // Record account nonce diffs. + if account_access.oldNonce != account_access.newNonce && !account_access.reverted { + let account_diff = state_diffs.entry(account_access.account).or_insert_with(|| { + AccountStateDiffs { + label: ccx.state.labels.get(&account_access.account).cloned(), + contract: contract_names.get(&account_access.account).cloned(), + ..Default::default() + } + }); + // Update nonce diff. Do not overwrite the initial nonce if already set. + if let Some(diff) = &mut account_diff.nonce_diff { + diff.new_value = account_access.newNonce; + } else { + account_diff.nonce_diff = Some(NonceDiff { + previous_value: account_access.oldNonce, + new_value: account_access.newNonce, + }); + } + } + + // Collect all storage accesses for this account + let raw_changes_by_slot = account_access + .storageAccesses + .iter() + .filter_map(|access| { + (access.isWrite && !access.reverted) + .then_some((access.slot, (access.previousValue, access.newValue))) + }) + .collect::>(); + + // Record account state diffs. + for storage_access in &account_access.storageAccesses { + if storage_access.isWrite && !storage_access.reverted { + let account_diff = + state_diffs.entry(storage_access.account).or_insert_with(|| { + AccountStateDiffs { + label: ccx.state.labels.get(&storage_access.account).cloned(), + contract: contract_names.get(&storage_access.account).cloned(), ..Default::default() - }); - // Update state diff. Do not overwrite the initial value if already set. - match account_diff.state_diff.entry(storage_access.slot) { - Entry::Vacant(slot_state_diff) => { - slot_state_diff.insert(SlotStateDiff { - previous_value: storage_access.previousValue, - new_value: storage_access.newValue, - }); - } - Entry::Occupied(mut slot_state_diff) => { - slot_state_diff.get_mut().new_value = storage_access.newValue; } + }); + let layout = storage_layouts.get(&storage_access.account); + // Update state diff. Do not overwrite the initial value if already set. + let entry = match account_diff.state_diff.entry(storage_access.slot) { + Entry::Vacant(slot_state_diff) => { + // Get storage layout info for this slot + // Include mapping slots if available for the account + let mapping_slots = ccx + .state + .mapping_slots + .as_ref() + .and_then(|slots| slots.get(&storage_access.account)); + + let slot_info = layout.and_then(|layout| { + let decoder = SlotIdentifier::new(layout.clone()); + decoder.identify(&storage_access.slot, mapping_slots).or_else( + || { + // Create a map of new values for bytes/string + // identification. These values are used to determine + // the length of the data which helps determine how many + // slots to search + let current_base_slot_values = raw_changes_by_slot + .iter() + .map(|(slot, (_, new_val))| (*slot, *new_val)) + .collect::>(); + decoder.identify_bytes_or_string( + &storage_access.slot, + ¤t_base_slot_values, + ) + }, + ) + }); + + slot_state_diff.insert(SlotStateDiff { + previous_value: storage_access.previousValue, + new_value: storage_access.newValue, + slot_info, + }) + } + Entry::Occupied(slot_state_diff) => { + let entry = slot_state_diff.into_mut(); + entry.new_value = storage_access.newValue; + entry + } + }; + + // Update decoded values if we have slot info + if let Some(slot_info) = &mut entry.slot_info { + slot_info.decode_values(entry.previous_value, storage_access.newValue); + if slot_info.is_bytes_or_string() { + slot_info.decode_bytes_or_string_values( + &storage_access.slot, + &raw_changes_by_slot, + ); } } } - }); - } + } + }); state_diffs } +/// EIP-1967 implementation storage slot +const EIP1967_IMPL_SLOT: &str = "360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc"; + +/// EIP-1822 UUPS implementation storage slot: keccak256("PROXIABLE") +const EIP1822_PROXIABLE_SLOT: &str = + "c5f16f0fcc639fa48a6947836d9850f504798523bf8c9a3a87d5876cf622bcf7"; + +/// Helper function to get the contract data from the deployed code at an address. +fn get_contract_data<'a, FEN: FoundryEvmNetwork>( + ccx: &'a mut CheatsCtxt<'_, '_, FEN>, + address: Address, + detect_proxy: bool, +) -> Option<(&'a foundry_compilers::ArtifactId, &'a foundry_common::contracts::ContractData)> { + // Check if we have available artifacts to match against + let artifacts = ccx.state.config.available_artifacts.as_ref()?; + + // Try to load the account and get its code + let account = ccx.ecx.journal_mut().load_account(address).ok()?; + let code = account.data.info.code.as_ref()?; + + // Skip if code is empty + if code.is_empty() { + return None; + } + + // Try to find the artifact by deployed code + let code_bytes = code.original_bytes(); + if let Some(result) = artifacts.find_by_deployed_code_exact(&code_bytes) { + return Some(result); + } + + // Fall back to known proxy artifacts when exact matching is impossible. + let hex_str = hex::encode(&code_bytes); + let find_by_suffix = + |suffix: &str| artifacts.iter().find(|(a, _)| a.identifier().ends_with(suffix)); + if detect_proxy + && hex_str.contains(EIP1967_IMPL_SLOT) + && let Some(result) = find_by_suffix(":TransparentUpgradeableProxy") + { + return Some(result); + } else if detect_proxy + && hex_str.contains(EIP1822_PROXIABLE_SLOT) + && let Some(result) = find_by_suffix(":UUPSUpgradeable") + { + return Some(result); + } + + // Fallback to fuzzy matching if exact match fails + artifacts.find_by_deployed_code(&code_bytes) +} + /// Helper function to set / unset cold storage slot of the target address. -fn set_cold_slot(ccx: &mut CheatsCtxt, target: Address, slot: U256, cold: bool) { - if let Some(account) = ccx.ecx.journaled_state.state.get_mut(&target) +fn set_cold_slot( + ccx: &mut CheatsCtxt<'_, '_, FEN>, + target: Address, + slot: U256, + cold: bool, +) { + if let Some(account) = ccx.ecx.journal_mut().evm_state_mut().get_mut(&target) && let Some(storage_slot) = account.storage.get_mut(&slot) { storage_slot.is_cold = cold; diff --git a/crates/cheatcodes/src/evm/fork.rs b/crates/cheatcodes/src/evm/fork.rs index aff4148b3912d..d9832bfbfe04b 100644 --- a/crates/cheatcodes/src/evm/fork.rs +++ b/crates/cheatcodes/src/evm/fork.rs @@ -3,242 +3,279 @@ use crate::{ json::json_value_to_token, }; use alloy_dyn_abi::DynSolValue; -use alloy_primitives::{B256, U256}; -use alloy_provider::Provider; +use alloy_evm::EvmEnv; +use alloy_network::AnyNetwork; +use alloy_primitives::{Address, B256, U256, map::AddressHashMap}; +use alloy_provider::{Provider, RootProvider}; use alloy_rpc_types::Filter; use alloy_sol_types::SolValue; use foundry_common::provider::ProviderBuilder; -use foundry_evm_core::{AsEnvMut, ContextExt, fork::CreateFork}; +use foundry_evm_core::{ + FoundryContextExt, + backend::{ContextUpdateFor, ForkAccountField, JournaledState, LocalForkId}, + evm::{BlockEnvFor, EvmFactoryFor, FoundryContextFor, FoundryEvmNetwork, SpecFor, TxEnvFor}, + fork::CreateFork, +}; +use revm::context::ContextTr; impl Cheatcode for activeForkCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self {} = self; - ccx.ecx - .journaled_state - .database - .active_fork_id() - .map(|id| id.abi_encode()) - .ok_or_else(|| fmt_err!("no active fork")) + ccx.active_fork_id().map(|id| id.abi_encode()).ok_or_else(|| fmt_err!("no active fork")) } } impl Cheatcode for createFork_0Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { urlOrAlias } = self; create_fork(ccx, urlOrAlias, None) } } impl Cheatcode for createFork_1Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { urlOrAlias, blockNumber } = self; create_fork(ccx, urlOrAlias, Some(blockNumber.saturating_to())) } } impl Cheatcode for createFork_2Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { urlOrAlias, txHash } = self; create_fork_at_transaction(ccx, urlOrAlias, txHash) } } impl Cheatcode for createSelectFork_0Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { urlOrAlias } = self; create_select_fork(ccx, urlOrAlias, None) } } impl Cheatcode for createSelectFork_1Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { urlOrAlias, blockNumber } = self; create_select_fork(ccx, urlOrAlias, Some(blockNumber.saturating_to())) } } impl Cheatcode for createSelectFork_2Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { urlOrAlias, txHash } = self; create_select_fork_at_transaction(ccx, urlOrAlias, txHash) } } impl Cheatcode for rollFork_0Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { blockNumber } = self; persist_caller(ccx); - let (db, journal, mut env) = ccx.ecx.as_db_env_and_journal(); - db.roll_fork(None, (*blockNumber).to(), &mut env, journal)?; - Ok(Default::default()) + let result = fork_env_op::(ccx.ecx, |db, evm_env, tx_env, inner| { + db.roll_fork(None, (*blockNumber).to(), evm_env, tx_env, inner) + .map(|context| ((), context)) + })?; + record_fork_roll(ccx, None); + Ok(result) } } impl Cheatcode for rollFork_1Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { txHash } = self; persist_caller(ccx); - let (db, journal, mut env) = ccx.ecx.as_db_env_and_journal(); - db.roll_fork_to_transaction(None, *txHash, &mut env, journal)?; - Ok(Default::default()) + let result = fork_env_op::(ccx.ecx, |db, evm_env, tx_env, inner| { + db.roll_fork_to_transaction(None, *txHash, evm_env, tx_env, inner) + .map(|context| ((), context)) + })?; + record_fork_roll(ccx, None); + Ok(result) } } impl Cheatcode for rollFork_2Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { forkId, blockNumber } = self; persist_caller(ccx); - let (db, journal, mut env) = ccx.ecx.as_db_env_and_journal(); - db.roll_fork(Some(*forkId), (*blockNumber).to(), &mut env, journal)?; - Ok(Default::default()) + let result = fork_env_op::(ccx.ecx, |db, evm_env, tx_env, inner| { + db.roll_fork(Some(*forkId), (*blockNumber).to(), evm_env, tx_env, inner) + .map(|context| ((), context)) + })?; + record_fork_roll(ccx, Some(*forkId)); + Ok(result) } } impl Cheatcode for rollFork_3Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { forkId, txHash } = self; persist_caller(ccx); - let (db, journal, mut env) = ccx.ecx.as_db_env_and_journal(); - db.roll_fork_to_transaction(Some(*forkId), *txHash, &mut env, journal)?; - Ok(Default::default()) + let result = fork_env_op::(ccx.ecx, |db, evm_env, tx_env, inner| { + db.roll_fork_to_transaction(Some(*forkId), *txHash, evm_env, tx_env, inner) + .map(|context| ((), context)) + })?; + record_fork_roll(ccx, Some(*forkId)); + Ok(result) } } impl Cheatcode for selectForkCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { forkId } = self; persist_caller(ccx); check_broadcast(ccx.state)?; - let (db, journal, mut env) = ccx.ecx.as_db_env_and_journal(); - db.select_fork(*forkId, &mut env, journal)?; - Ok(Default::default()) + let source_fork_id = ccx.active_fork_id(); + let initial = ccx.state.created_account_bindings(None); + let propagated = persistent_created_accounts(ccx); + let result = fork_env_op::(ccx.ecx, |db, evm_env, tx_env, inner| { + db.select_fork(*forkId, evm_env, tx_env, inner).map(|context| ((), context)) + })?; + record_fork_switch(ccx, source_fork_id, initial, propagated); + Ok(result) } } impl Cheatcode for transact_0Call { - fn apply_full(&self, ccx: &mut CheatsCtxt, executor: &mut dyn CheatcodesExecutor) -> Result { + fn apply_full( + &self, + ccx: &mut CheatsCtxt<'_, '_, FEN>, + executor: &mut dyn CheatcodesExecutor, + ) -> Result { let Self { txHash } = *self; transact(ccx, executor, txHash, None) } } impl Cheatcode for transact_1Call { - fn apply_full(&self, ccx: &mut CheatsCtxt, executor: &mut dyn CheatcodesExecutor) -> Result { + fn apply_full( + &self, + ccx: &mut CheatsCtxt<'_, '_, FEN>, + executor: &mut dyn CheatcodesExecutor, + ) -> Result { let Self { forkId, txHash } = *self; transact(ccx, executor, txHash, Some(forkId)) } } impl Cheatcode for allowCheatcodesCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { account } = self; - ccx.ecx.journaled_state.database.allow_cheatcode_access(*account); + ccx.ecx.db_mut().allow_cheatcode_access(*account); Ok(Default::default()) } } impl Cheatcode for makePersistent_0Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { account } = self; - ccx.ecx.journaled_state.database.add_persistent_account(*account); + ccx.ecx.db_mut().add_persistent_account(*account); Ok(Default::default()) } } impl Cheatcode for makePersistent_1Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { account0, account1 } = self; - ccx.ecx.journaled_state.database.add_persistent_account(*account0); - ccx.ecx.journaled_state.database.add_persistent_account(*account1); + ccx.ecx.db_mut().add_persistent_account(*account0); + ccx.ecx.db_mut().add_persistent_account(*account1); Ok(Default::default()) } } impl Cheatcode for makePersistent_2Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { account0, account1, account2 } = self; - ccx.ecx.journaled_state.database.add_persistent_account(*account0); - ccx.ecx.journaled_state.database.add_persistent_account(*account1); - ccx.ecx.journaled_state.database.add_persistent_account(*account2); + ccx.ecx.db_mut().add_persistent_account(*account0); + ccx.ecx.db_mut().add_persistent_account(*account1); + ccx.ecx.db_mut().add_persistent_account(*account2); Ok(Default::default()) } } impl Cheatcode for makePersistent_3Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { accounts } = self; for account in accounts { - ccx.ecx.journaled_state.database.add_persistent_account(*account); + ccx.ecx.db_mut().add_persistent_account(*account); } Ok(Default::default()) } } impl Cheatcode for revokePersistent_0Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { account } = self; - ccx.ecx.journaled_state.database.remove_persistent_account(account); + ccx.ecx.db_mut().remove_persistent_account(account); Ok(Default::default()) } } impl Cheatcode for revokePersistent_1Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { accounts } = self; for account in accounts { - ccx.ecx.journaled_state.database.remove_persistent_account(account); + ccx.ecx.db_mut().remove_persistent_account(account); } Ok(Default::default()) } } impl Cheatcode for isPersistentCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { account } = self; - Ok(ccx.ecx.journaled_state.database.is_persistent(account).abi_encode()) + Ok(ccx.is_persistent(account).abi_encode()) } } impl Cheatcode for rpc_0Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { method, params } = self; - let url = ccx - .ecx - .journaled_state - .database - .active_fork_url() - .ok_or_else(|| fmt_err!("no active fork URL found"))?; - rpc_call(&url, method, params) + let url = ccx.active_fork_url().ok_or_else(|| fmt_err!("no active fork URL found"))?; + let result = rpc_call(&url, method, params)?; + refresh_active_fork_state(ccx, method, params)?; + Ok(result) } } impl Cheatcode for rpc_1Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { urlOrAlias, method, params } = self; let url = state.config.rpc_endpoint(urlOrAlias)?.url()?; rpc_call(&url, method, params) } } +impl Cheatcode for rpcJson_0Call { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { + let Self { method, params } = self; + let url = ccx.active_fork_url().ok_or_else(|| fmt_err!("no active fork URL found"))?; + let result = rpc_json_call(&url, method, params)?; + refresh_active_fork_state(ccx, method, params)?; + Ok(result) + } +} + +impl Cheatcode for rpcJson_1Call { + fn apply(&self, state: &mut Cheatcodes) -> Result { + let Self { urlOrAlias, method, params } = self; + let url = state.config.rpc_endpoint(urlOrAlias)?.url()?; + rpc_json_call(&url, method, params) + } +} + impl Cheatcode for eth_getLogsCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { fromBlock, toBlock, target, topics } = self; let (Ok(from_block), Ok(to_block)) = (u64::try_from(fromBlock), u64::try_from(toBlock)) else { - bail!("blocks in block range must be less than 2^64 - 1") + bail!("blocks in block range must be less than 2^64") }; if topics.len() > 4 { bail!("topics array must contain at most 4 elements") } - let url = ccx - .ecx - .journaled_state - .database - .active_fork_url() - .ok_or_else(|| fmt_err!("no active fork URL found"))?; - let provider = ProviderBuilder::new(&url).build()?; + let provider = active_fork_provider(ccx)?; let mut filter = Filter::new().address(*target).from_block(from_block).to_block(to_block); for (i, &topic) in topics.iter().enumerate() { filter.topics[i] = topic.into(); @@ -266,51 +303,121 @@ impl Cheatcode for eth_getLogsCall { } } +impl Cheatcode for eth_getProofCall { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { + let Self { target, slots, blockNumber } = self; + let block_number = u64::try_from(blockNumber) + .map_err(|_| fmt_err!("block number must be less than 2^64"))?; + + let provider = active_fork_provider(ccx)?; + let proof = foundry_common::block_on(async move { + provider.get_proof(*target, slots.clone()).number(block_number).await + }) + .map_err(|e| fmt_err!("failed to get proof: {e}"))?; + + let storage_proof = proof + .storage_proof + .into_iter() + .map(|p| EthStorageProof { key: p.key.as_b256(), value: p.value, proof: p.proof }) + .collect(); + + Ok(EthGetProof { + account: proof.address, + balance: proof.balance, + codeHash: proof.code_hash, + nonce: proof.nonce, + storageHash: proof.storage_hash, + accountProof: proof.account_proof, + storageProof: storage_proof, + } + .abi_encode()) + } +} + +impl Cheatcode for getRawBlockHeaderCall { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { + let Self { blockNumber } = self; + let provider = active_fork_provider(ccx)?; + let block_number = u64::try_from(blockNumber) + .map_err(|_| fmt_err!("block number must be less than 2^64"))?; + let block = + foundry_common::block_on(async move { provider.get_block(block_number.into()).await }) + .map_err(|e| fmt_err!("failed to get block: {e}"))? + .ok_or_else(|| fmt_err!("block {block_number} not found"))?; + + let header: alloy_consensus::Header = block + .into_inner() + .header + .inner + .try_into_header() + .map_err(|e| fmt_err!("failed to convert to header: {e}"))?; + Ok(alloy_rlp::encode(&header).abi_encode()) + } +} + /// Creates and then also selects the new fork -fn create_select_fork(ccx: &mut CheatsCtxt, url_or_alias: &str, block: Option) -> Result { +fn create_select_fork( + ccx: &mut CheatsCtxt<'_, '_, FEN>, + url_or_alias: &str, + block: Option, +) -> Result { check_broadcast(ccx.state)?; let fork = create_fork_request(ccx, url_or_alias, block)?; - let (db, journal, mut env) = ccx.ecx.as_db_env_and_journal(); - let id = db.create_select_fork(fork, &mut env, journal)?; - Ok(id.abi_encode()) + let source_fork_id = ccx.active_fork_id(); + let initial = ccx.state.created_account_bindings(None); + let propagated = persistent_created_accounts(ccx); + let result = fork_env_op::(ccx.ecx, |db, evm_env, tx_env, inner| { + db.create_select_fork(fork, evm_env, tx_env, inner) + })?; + record_fork_switch(ccx, source_fork_id, initial, propagated); + Ok(result) } /// Creates a new fork -fn create_fork(ccx: &mut CheatsCtxt, url_or_alias: &str, block: Option) -> Result { +fn create_fork( + ccx: &mut CheatsCtxt<'_, '_, FEN>, + url_or_alias: &str, + block: Option, +) -> Result { let fork = create_fork_request(ccx, url_or_alias, block)?; - let id = ccx.ecx.journaled_state.database.create_fork(fork)?; + let id = ccx.ecx.db_mut().create_fork(fork)?; Ok(id.abi_encode()) } /// Creates and then also selects the new fork at the given transaction -fn create_select_fork_at_transaction( - ccx: &mut CheatsCtxt, +fn create_select_fork_at_transaction( + ccx: &mut CheatsCtxt<'_, '_, FEN>, url_or_alias: &str, transaction: &B256, ) -> Result { check_broadcast(ccx.state)?; let fork = create_fork_request(ccx, url_or_alias, None)?; - let (db, journal, mut env) = ccx.ecx.as_db_env_and_journal(); - let id = db.create_select_fork_at_transaction(fork, &mut env, journal, *transaction)?; - Ok(id.abi_encode()) + let source_fork_id = ccx.active_fork_id(); + let initial = ccx.state.created_account_bindings(None); + let propagated = persistent_created_accounts(ccx); + let result = fork_env_op::(ccx.ecx, |db, evm_env, tx_env, inner| { + db.create_select_fork_at_transaction(fork, evm_env, tx_env, inner, *transaction) + })?; + record_fork_switch(ccx, source_fork_id, initial, propagated); + Ok(result) } /// Creates a new fork at the given transaction -fn create_fork_at_transaction( - ccx: &mut CheatsCtxt, +fn create_fork_at_transaction( + ccx: &mut CheatsCtxt<'_, '_, FEN>, url_or_alias: &str, transaction: &B256, ) -> Result { let fork = create_fork_request(ccx, url_or_alias, None)?; - let id = ccx.ecx.journaled_state.database.create_fork_at_transaction(fork, *transaction)?; + let id = ccx.ecx.db_mut().create_fork_at_transaction(fork, *transaction)?; Ok(id.abi_encode()) } /// Creates the request object for a new fork request -fn create_fork_request( - ccx: &mut CheatsCtxt, +fn create_fork_request( + ccx: &mut CheatsCtxt<'_, '_, FEN>, url_or_alias: &str, block: Option, ) -> Result { @@ -320,22 +427,110 @@ fn create_fork_request( let url = rpc_endpoint.url()?; let mut evm_opts = ccx.state.config.evm_opts.clone(); evm_opts.fork_block_number = block; + evm_opts.fork_block_number_is_inferred = false; evm_opts.fork_retries = rpc_endpoint.config.retries; evm_opts.fork_retry_backoff = rpc_endpoint.config.retry_backoff; if let Some(Ok(auth)) = rpc_endpoint.auth { - evm_opts.fork_headers = Some(vec![format!("Authorization: {auth}")]); + let fork_headers = Some(vec![format!("Authorization: {auth}")]); + // The cached identity was discovered with other credentials, which may reach another + // backend behind the same URL. + if evm_opts.fork_headers != fork_headers { + evm_opts.fork_endpoint = None; + } + evm_opts.fork_headers = fork_headers; } let fork = CreateFork { enable_caching: !ccx.state.config.no_storage_caching && ccx.state.config.rpc_storage_caching.enable_for_endpoint(&url), url, - env: ccx.ecx.as_env_mut().to_owned(), evm_opts, }; Ok(fork) } -fn check_broadcast(state: &Cheatcodes) -> Result<()> { +/// Applies a fork/roll/transact context update to the active EVM context. +#[cfg(feature = "monad")] +fn apply_context_update( + ecx: &mut FoundryContextFor<'_, FEN>, + context_update: foundry_evm_core::backend::ContextUpdate>, +) { + match context_update { + foundry_evm_core::backend::ContextUpdate::Unchanged => {} + foundry_evm_core::backend::ContextUpdate::Replace(chain_context) => { + *ecx.chain_mut() = chain_context; + foundry_evm_core::evm::refresh_chain_journal(ecx); + } + foundry_evm_core::backend::ContextUpdate::Rebase => { + foundry_evm_core::evm::refresh_chain_journal(ecx); + } + } +} + +/// Clones the EVM and tx environments, runs a fork operation that may modify them, then writes +/// them back. This is the common pattern for all fork-switching cheatcodes (rollFork, selectFork, +/// createSelectFork). +fn fork_env_op( + ecx: &mut FoundryContextFor<'_, FEN>, + f: impl FnOnce( + &mut as ContextTr>::Db, + &mut EvmEnv, BlockEnvFor>, + &mut TxEnvFor, + &mut JournaledState, + ) -> eyre::Result<(T, ContextUpdateFor>)>, +) -> Result { + let mut evm_env = ecx.evm_clone(); + let mut tx_env = ecx.tx_clone(); + let (db, inner) = ecx.db_journal_inner_mut(); + let (result, context_update) = f(db, &mut evm_env, &mut tx_env, inner)?; + ecx.set_evm(evm_env); + ecx.set_tx(tx_env); + #[cfg(not(feature = "monad"))] + let _ = context_update; + #[cfg(feature = "monad")] + apply_context_update::(ecx, context_update); + Ok(result.abi_encode()) +} + +fn persistent_created_accounts( + ccx: &CheatsCtxt<'_, '_, FEN>, +) -> Vec<(Address, usize)> { + let fork_id = ccx.active_fork_id(); + ccx.state + .created_account_bindings(fork_id) + .into_iter() + .filter(|(address, _)| ccx.is_persistent(address)) + .collect() +} + +fn record_fork_switch( + ccx: &mut CheatsCtxt<'_, '_, FEN>, + source_fork_id: Option, + initial: AddressHashMap, + propagated: Vec<(Address, usize)>, +) { + let target_fork_id = ccx.active_fork_id(); + ccx.state.fork_block_number_override = ccx.active_fork_block_number(); + if source_fork_id != target_fork_id { + ccx.state.commit_created_account_changes(source_fork_id); + } + ccx.state.record_initial_created_accounts(target_fork_id, initial); + ccx.state.record_propagated_accounts(target_fork_id, propagated); +} + +fn record_fork_roll( + ccx: &mut CheatsCtxt<'_, '_, FEN>, + target_fork_id: Option, +) { + let active_fork_id = ccx.active_fork_id(); + let rolled_fork_id = target_fork_id.or(active_fork_id); + ccx.state.env_overrides.clear_implicit_basefee(rolled_fork_id); + if target_fork_id.is_none() || target_fork_id == active_fork_id { + ccx.state.fork_block_number_override = ccx.active_fork_block_number(); + ccx.state.commit_created_account_changes(active_fork_id); + } +} + +fn check_broadcast(state: &Cheatcodes) -> Result<()> { if state.broadcast.is_none() { Ok(()) } else { @@ -343,20 +538,17 @@ fn check_broadcast(state: &Cheatcodes) -> Result<()> { } } -fn transact( - ccx: &mut CheatsCtxt, - executor: &mut dyn CheatcodesExecutor, +fn transact( + ccx: &mut CheatsCtxt<'_, '_, FEN>, + executor: &mut dyn CheatcodesExecutor, transaction: B256, fork_id: Option, ) -> Result { - let (db, journal, env) = ccx.ecx.as_db_env_and_journal(); - db.transact( - fork_id, - transaction, - env.to_owned(), - journal, - &mut *executor.get_inspector(ccx.state), - )?; + let context_update = executor.transact_on_db(ccx.state, ccx.ecx, fork_id, transaction)?; + #[cfg(not(feature = "monad"))] + let _ = context_update; + #[cfg(feature = "monad")] + apply_context_update::(ccx.ecx, context_update); Ok(Default::default()) } @@ -364,22 +556,89 @@ fn transact( // state of caller contract is not lost when fork changes). // Applies to create, select and roll forks actions. // https://github.com/foundry-rs/foundry/issues/8004 -fn persist_caller(ccx: &mut CheatsCtxt) { - ccx.ecx.journaled_state.database.add_persistent_account(ccx.caller); +fn persist_caller(ccx: &mut CheatsCtxt<'_, '_, FEN>) { + ccx.ecx.db_mut().add_persistent_account(ccx.caller); +} + +/// Returns a provider for the active fork, configured with the RPC options the fork was created +/// with (e.g. auth headers, retries and timeouts). +fn active_fork_provider( + ccx: &CheatsCtxt<'_, '_, FEN>, +) -> Result> { + let fork = ccx.active_fork_options().ok_or_else(|| fmt_err!("no active fork URL found"))?; + Ok(fork.evm_opts.fork_provider_with_url(&fork.url)?) } /// Performs an Ethereum JSON-RPC request to the given endpoint. fn rpc_call(url: &str, method: &str, params: &str) -> Result { - let provider = ProviderBuilder::new(url).build()?; - let params_json: serde_json::Value = serde_json::from_str(params)?; - let result = - foundry_common::block_on(provider.raw_request(method.to_string().into(), params_json)) - .map_err(|err| fmt_err!("{method:?}: {err}"))?; + let result = rpc_result(url, method, params)?; let result_as_tokens = convert_to_bytes( - &json_value_to_token(&result).map_err(|err| fmt_err!("failed to parse result: {err}"))?, + &json_value_to_token(&result, None) + .map_err(|err| fmt_err!("failed to parse result: {err}"))?, ); - Ok(result_as_tokens.abi_encode()) + let payload = match &result_as_tokens { + DynSolValue::Bytes(b) => b.clone(), + _ => result_as_tokens.abi_encode(), + }; + Ok(DynSolValue::Bytes(payload).abi_encode()) +} + +/// Performs an Ethereum JSON-RPC request to the given endpoint and returns the JSON result. +fn rpc_json_call(url: &str, method: &str, params: &str) -> Result { + let result = rpc_result(url, method, params)?; + Ok(serde_json::to_string(&result)?.abi_encode()) +} + +/// Refreshes cached fork and journal state after a `vm.rpc` call on the active fork that mutates +/// the node directly. Only well-known Anvil/Hardhat account/storage setters are handled; +/// chain-advancing methods (e.g. `eth_sendTransaction`) need re-forking, not synchronization. +fn refresh_active_fork_state( + ccx: &mut CheatsCtxt<'_, '_, FEN>, + method: &str, + params: &str, +) -> Result<()> { + let account_field = match method { + "anvil_setBalance" + | "hardhat_setBalance" + | "tenderly_setBalance" + | "anvil_addBalance" + | "hardhat_addBalance" + | "tenderly_addBalance" => Some(ForkAccountField::Balance), + "anvil_setNonce" | "hardhat_setNonce" | "evm_setAccountNonce" => { + Some(ForkAccountField::Nonce) + } + "anvil_setCode" | "hardhat_setCode" => Some(ForkAccountField::Code), + "anvil_setStorageAt" | "hardhat_setStorageAt" => None, + _ => return Ok(()), + }; + + let Ok(params) = serde_json::from_str::(params) else { return Ok(()) }; + let Some(address) = + params.get(0).and_then(|v| v.as_str()).and_then(|s| s.parse::
().ok()) + else { + return Ok(()); + }; + + let (db, journaled_state) = ccx.ecx.db_journal_inner_mut(); + if let Some(field) = account_field { + db.refresh_fork_account(address, field, journaled_state)?; + } else { + let Some(slot) = + params.get(1).and_then(|v| v.as_str()).and_then(|s| s.parse::().ok()) + else { + return Ok(()); + }; + db.refresh_fork_storage(address, slot, journaled_state)?; + } + Ok(()) +} + +fn rpc_result(url: &str, method: &str, params: &str) -> Result { + let provider = ProviderBuilder::::new(url).build()?; + let params_json: serde_json::Value = serde_json::from_str(params)?; + foundry_common::block_on(provider.raw_request(method.to_string().into(), params_json)) + .map_err(|err| fmt_err!("{method:?}: {err}")) } /// Convert fixed bytes and address values to bytes in order to prevent encoding issues. @@ -391,9 +650,6 @@ fn convert_to_bytes(token: &DynSolValue) -> DynSolValue { DynSolValue::Bytes(bytes.as_slice()[..*size].to_vec()) } DynSolValue::Address(addr) => DynSolValue::Bytes(addr.to_vec()), - // Convert tuple values to prevent encoding issues. - // See: - DynSolValue::Tuple(vals) => DynSolValue::Tuple(vals.iter().map(convert_to_bytes).collect()), val => val.clone(), } } diff --git a/crates/cheatcodes/src/evm/mapping.rs b/crates/cheatcodes/src/evm/mapping.rs index 1c4dfe6086129..af5580774126a 100644 --- a/crates/cheatcodes/src/evm/mapping.rs +++ b/crates/cheatcodes/src/evm/mapping.rs @@ -1,64 +1,19 @@ use crate::{Cheatcode, Cheatcodes, Result, Vm::*}; -use alloy_primitives::{ - Address, B256, U256, keccak256, - map::{AddressHashMap, B256HashMap}, -}; +use alloy_primitives::{Address, B256}; use alloy_sol_types::SolValue; -use revm::{ - bytecode::opcode, - interpreter::{Interpreter, interpreter_types::Jumps}, -}; - -/// Recorded mapping slots. -#[derive(Clone, Debug, Default)] -pub struct MappingSlots { - /// Holds mapping parent (slots => slots) - pub parent_slots: B256HashMap, - - /// Holds mapping key (slots => key) - pub keys: B256HashMap, - - /// Holds mapping child (slots => slots[]) - pub children: B256HashMap>, - - /// Holds the last sha3 result `sha3_result => (data_low, data_high)`, this would only record - /// when sha3 is called with `size == 0x40`, and the lower 256 bits would be stored in - /// `data_low`, higher 256 bits in `data_high`. - /// This is needed for mapping_key detect if the slot is for some mapping and record that. - pub seen_sha3: B256HashMap<(B256, B256)>, -} - -impl MappingSlots { - /// Tries to insert a mapping slot. Returns true if it was inserted. - pub fn insert(&mut self, slot: B256) -> bool { - match self.seen_sha3.get(&slot).copied() { - Some((key, parent)) => { - if self.keys.contains_key(&slot) { - return false; - } - self.keys.insert(slot, key); - self.parent_slots.insert(slot, parent); - self.children.entry(parent).or_default().push(slot); - self.insert(parent); - true - } - None => false, - } - } -} +use foundry_common::mapping_slots::MappingSlots; +use foundry_evm_core::evm::FoundryEvmNetwork; impl Cheatcode for startMappingRecordingCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self {} = self; - if state.mapping_slots.is_none() { - state.mapping_slots = Some(Default::default()); - } + state.mapping_slots.get_or_insert_default(); Ok(Default::default()) } } impl Cheatcode for stopMappingRecordingCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self {} = self; state.mapping_slots = None; Ok(Default::default()) @@ -66,7 +21,7 @@ impl Cheatcode for stopMappingRecordingCall { } impl Cheatcode for getMappingLengthCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { target, mappingSlot } = self; let result = slot_child(state, target, mappingSlot).map(Vec::len).unwrap_or(0); Ok((result as u64).abi_encode()) @@ -74,10 +29,10 @@ impl Cheatcode for getMappingLengthCall { } impl Cheatcode for getMappingSlotAtCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { target, mappingSlot, idx } = self; let result = slot_child(state, target, mappingSlot) - .and_then(|set| set.get(idx.saturating_to::())) + .and_then(|set| usize::try_from(*idx).ok().and_then(|idx| set.get(idx))) .copied() .unwrap_or_default(); Ok(result.abi_encode()) @@ -85,7 +40,7 @@ impl Cheatcode for getMappingSlotAtCall { } impl Cheatcode for getMappingKeyAndParentOfCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { target, elementSlot: slot } = self; let mut found = false; let mut key = &B256::ZERO; @@ -105,40 +60,17 @@ impl Cheatcode for getMappingKeyAndParentOfCall { } } -fn mapping_slot<'a>(state: &'a Cheatcodes, target: &'a Address) -> Option<&'a MappingSlots> { +fn mapping_slot<'a, FEN: FoundryEvmNetwork>( + state: &'a Cheatcodes, + target: &'a Address, +) -> Option<&'a MappingSlots> { state.mapping_slots.as_ref()?.get(target) } -fn slot_child<'a>( - state: &'a Cheatcodes, +fn slot_child<'a, FEN: FoundryEvmNetwork>( + state: &'a Cheatcodes, target: &'a Address, slot: &'a B256, ) -> Option<&'a Vec> { mapping_slot(state, target)?.children.get(slot) } - -#[cold] -pub(crate) fn step(mapping_slots: &mut AddressHashMap, interpreter: &Interpreter) { - match interpreter.bytecode.opcode() { - opcode::KECCAK256 => { - if interpreter.stack.peek(1) == Ok(U256::from(0x40)) { - let address = interpreter.input.target_address; - let offset = interpreter.stack.peek(0).expect("stack size > 1").saturating_to(); - let data = interpreter.memory.slice_len(offset, 0x40); - let low = B256::from_slice(&data[..0x20]); - let high = B256::from_slice(&data[0x20..]); - let result = keccak256(&*data); - - mapping_slots.entry(address).or_default().seen_sha3.insert(result, (low, high)); - } - } - opcode::SSTORE => { - if let Some(mapping_slots) = mapping_slots.get_mut(&interpreter.input.target_address) - && let Ok(slot) = interpreter.stack.peek(0) - { - mapping_slots.insert(slot.into()); - } - } - _ => {} - } -} diff --git a/crates/cheatcodes/src/evm/mock.rs b/crates/cheatcodes/src/evm/mock.rs index dc2c2137ba524..9636e4141fd6f 100644 --- a/crates/cheatcodes/src/evm/mock.rs +++ b/crates/cheatcodes/src/evm/mock.rs @@ -1,7 +1,15 @@ use crate::{Cheatcode, Cheatcodes, CheatsCtxt, Result, Vm::*}; use alloy_primitives::{Address, Bytes, U256}; -use revm::{bytecode::Bytecode, context::JournalTr, interpreter::InstructionResult}; -use std::{cmp::Ordering, collections::VecDeque}; +use foundry_evm_core::evm::FoundryEvmNetwork; +use revm::{ + bytecode::Bytecode, + context::{ContextTr, JournalTr}, + interpreter::InstructionResult, +}; +use std::{ + cmp::Ordering, + collections::{BTreeMap, VecDeque}, +}; /// Mocked call data. #[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] @@ -39,7 +47,7 @@ impl Ord for MockCallDataContext { } impl Cheatcode for clearMockedCallsCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self {} = self; state.mocked_calls = Default::default(); Ok(Default::default()) @@ -47,7 +55,7 @@ impl Cheatcode for clearMockedCallsCall { } impl Cheatcode for mockCall_0Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { callee, data, returnData } = self; let _ = make_acc_non_empty(callee, ccx)?; @@ -57,16 +65,17 @@ impl Cheatcode for mockCall_0Call { } impl Cheatcode for mockCall_1Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { callee, msgValue, data, returnData } = self; - ccx.ecx.journaled_state.load_account(*callee)?; + let _ = make_acc_non_empty(callee, ccx)?; + mock_call(ccx.state, callee, data, Some(msgValue), returnData, InstructionResult::Return); Ok(Default::default()) } } impl Cheatcode for mockCall_2Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { callee, data, returnData } = self; let _ = make_acc_non_empty(callee, ccx)?; @@ -83,9 +92,10 @@ impl Cheatcode for mockCall_2Call { } impl Cheatcode for mockCall_3Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { callee, msgValue, data, returnData } = self; - ccx.ecx.journaled_state.load_account(*callee)?; + let _ = make_acc_non_empty(callee, ccx)?; + mock_call( ccx.state, callee, @@ -98,8 +108,20 @@ impl Cheatcode for mockCall_3Call { } } +impl Cheatcode for mockCall_4Call { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { + let Self { callee, data, returnData, injectCode } = self; + if *injectCode { + let _ = make_acc_non_empty(callee, ccx)?; + } + + mock_call(ccx.state, callee, data, None, returnData, InstructionResult::Return); + Ok(Default::default()) + } +} + impl Cheatcode for mockCalls_0Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { callee, data, returnData } = self; let _ = make_acc_non_empty(callee, ccx)?; @@ -109,16 +131,17 @@ impl Cheatcode for mockCalls_0Call { } impl Cheatcode for mockCalls_1Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { callee, msgValue, data, returnData } = self; - ccx.ecx.journaled_state.load_account(*callee)?; + let _ = make_acc_non_empty(callee, ccx)?; + mock_calls(ccx.state, callee, data, Some(msgValue), returnData, InstructionResult::Return); Ok(Default::default()) } } impl Cheatcode for mockCallRevert_0Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { callee, data, revertData } = self; let _ = make_acc_non_empty(callee, ccx)?; @@ -128,7 +151,7 @@ impl Cheatcode for mockCallRevert_0Call { } impl Cheatcode for mockCallRevert_1Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { callee, msgValue, data, revertData } = self; let _ = make_acc_non_empty(callee, ccx)?; @@ -138,7 +161,7 @@ impl Cheatcode for mockCallRevert_1Call { } impl Cheatcode for mockCallRevert_2Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { callee, data, revertData } = self; let _ = make_acc_non_empty(callee, ccx)?; @@ -155,7 +178,7 @@ impl Cheatcode for mockCallRevert_2Call { } impl Cheatcode for mockCallRevert_3Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { callee, msgValue, data, revertData } = self; let _ = make_acc_non_empty(callee, ccx)?; @@ -172,7 +195,7 @@ impl Cheatcode for mockCallRevert_3Call { } impl Cheatcode for mockFunctionCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { callee, target, data } = self; state.mocked_functions.entry(*callee).or_default().insert(data.clone(), *target); @@ -180,8 +203,8 @@ impl Cheatcode for mockFunctionCall { } } -fn mock_call( - state: &mut Cheatcodes, +fn mock_call( + state: &mut Cheatcodes, callee: &Address, cdata: &Bytes, value: Option<&U256>, @@ -191,8 +214,8 @@ fn mock_call( mock_calls(state, callee, cdata, value, std::slice::from_ref(rdata), ret_type) } -fn mock_calls( - state: &mut Cheatcodes, +fn mock_calls( + state: &mut Cheatcodes, callee: &Address, cdata: &Bytes, value: Option<&U256>, @@ -200,7 +223,7 @@ fn mock_calls( ret_type: InstructionResult, ) { state.mocked_calls.entry(*callee).or_default().insert( - MockCallDataContext { calldata: Bytes::copy_from_slice(cdata), value: value.copied() }, + MockCallDataContext { calldata: cdata.clone(), value: value.copied() }, rdata_vec .iter() .map(|rdata| MockCallReturnData { ret_type, data: rdata.clone() }) @@ -210,14 +233,134 @@ fn mock_calls( // Etches a single byte onto the account if it is empty to circumvent the `extcodesize` // check Solidity might perform. -fn make_acc_non_empty(callee: &Address, ecx: &mut CheatsCtxt) -> Result { - let acc = ecx.journaled_state.load_account(*callee)?; - - let empty_bytecode = acc.info.code.as_ref().is_none_or(Bytecode::is_empty); +fn make_acc_non_empty( + callee: &Address, + ccx: &mut CheatsCtxt<'_, '_, FEN>, +) -> Result { + let empty_bytecode = { + let acc = ccx.ecx.journal_mut().load_account(*callee)?; + acc.info.code.as_ref().is_none_or(Bytecode::is_empty) + }; if empty_bytecode { let code = Bytecode::new_raw(Bytes::from_static(&[0u8])); - ecx.journaled_state.set_code(*callee, code); + ccx.ecx.journal_mut().set_code(*callee, code); } Ok(Default::default()) } + +/// Finds the return data queue of the mock matching a call. +/// +/// An exact calldata and value match wins. Otherwise, the first mock in map order whose calldata +/// prefixes `input` and whose value, if set, equals `value` is used. +pub(crate) fn find_mock_returns<'a, T>( + mocks: &'a mut BTreeMap>, + input: &Bytes, + value: Option, +) -> Option<&'a mut VecDeque> { + let ctx = MockCallDataContext { calldata: input.clone(), value }; + // Reversed `Ord` puts all matches at or after `ctx`, with the exact key first. + mocks + .range_mut(ctx..) + .find(|(mock, _)| { + input.get(..mock.calldata.len()) == Some(&mock.calldata[..]) + && mock.value.is_none_or(|mock_value| Some(mock_value) == value) + }) + .map(|(_, v)| v) +} + +/// Consumes the front return data of a mock, keeping the last one for every later call. +pub(crate) fn advance_mock_returns(queue: &mut VecDeque) { + if queue.len() > 1 { + queue.pop_front(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mock( + calldata: &'static [u8], + value: Option, + result: u8, + ) -> (MockCallDataContext, VecDeque) { + ( + MockCallDataContext { + calldata: Bytes::from_static(calldata), + value: value.map(U256::from), + }, + VecDeque::from([result]), + ) + } + + #[test] + fn mock_matching_precedence() { + let mut mocks = BTreeMap::from([ + mock(b"call", Some(1), 1), + mock(b"call", Some(4), 9), + mock(b"call", None, 2), + mock(b"cal", Some(1), 3), + mock(b"cal", Some(0), 4), + mock(b"cal", Some(2), 5), + mock(b"cal", None, 6), + mock(b"ca", Some(3), 7), + mock(b"call-longer", Some(1), 8), + ]); + + for (input, value, expected) in [ + (b"call", Some(1), 1), + (b"call", Some(3), 2), + (b"call", None, 2), + (b"calx", Some(1), 3), + (b"calx", Some(3), 6), + ] { + let queue = + find_mock_returns(&mut mocks, &Bytes::from_static(input), value.map(U256::from)) + .unwrap(); + assert_eq!(queue.front(), Some(&expected), "input: {input:?}, value: {value:?}"); + } + } + + #[test] + fn absent_transfer_value_does_not_match_zero() { + let mut mocks = BTreeMap::from([mock(b"call", Some(0), 1)]); + let input = Bytes::from_static(b"call"); + assert!(find_mock_returns(&mut mocks, &input, None).is_none()); + assert_eq!( + find_mock_returns(&mut mocks, &input, Some(U256::ZERO)).unwrap().front(), + Some(&1) + ); + + mocks.extend([mock(b"cal", None, 2)]); + assert_eq!(find_mock_returns(&mut mocks, &input, None).unwrap().front(), Some(&2)); + } + + #[test] + fn empty_matched_queue_is_distinct_from_no_match() { + let mut mocks = BTreeMap::from([mock(b"cal", None, 1)]); + mocks.insert( + MockCallDataContext { calldata: Bytes::from_static(b"call"), value: None }, + VecDeque::new(), + ); + + let queue = find_mock_returns(&mut mocks, &Bytes::from_static(b"call"), None).unwrap(); + assert!(queue.is_empty()); + assert!(find_mock_returns(&mut mocks, &Bytes::from_static(b"other"), None).is_none()); + } + + #[test] + fn advancing_returns_keeps_last_result() { + let mut queue = VecDeque::from([1, 2, 3]); + advance_mock_returns(&mut queue); + assert_eq!(queue, VecDeque::from([2, 3])); + advance_mock_returns(&mut queue); + assert_eq!(queue, VecDeque::from([3])); + advance_mock_returns(&mut queue); + assert_eq!(queue, VecDeque::from([3])); + + queue.clear(); + advance_mock_returns(&mut queue); + assert!(queue.is_empty()); + } +} diff --git a/crates/cheatcodes/src/evm/prank.rs b/crates/cheatcodes/src/evm/prank.rs index 6c338fd97eec9..cbe8de46516f1 100644 --- a/crates/cheatcodes/src/evm/prank.rs +++ b/crates/cheatcodes/src/evm/prank.rs @@ -1,6 +1,6 @@ use crate::{Cheatcode, CheatsCtxt, Result, Vm::*, evm::journaled_account}; use alloy_primitives::Address; -use revm::context::JournalTr; +use foundry_evm_core::evm::FoundryEvmNetwork; /// Prank information. #[derive(Clone, Copy, Debug, Default)] @@ -25,7 +25,7 @@ pub struct Prank { impl Prank { /// Create a new prank. - pub fn new( + pub const fn new( prank_caller: Address, prank_origin: Address, new_caller: Address, @@ -48,77 +48,107 @@ impl Prank { /// Apply the prank by setting `used` to true if it is false /// Only returns self in the case it is updated (first application) - pub fn first_time_applied(&self) -> Option { + pub const fn first_time_applied(&self) -> Option { if self.used { None } else { Some(Self { used: true, ..*self }) } } + + /// Returns how the prank changes a call or create from `caller` at `depth`, if it applies. + pub(crate) fn changes_for(&self, depth: usize, caller: Address) -> Option { + if depth < self.depth || caller != self.prank_caller { + return None; + } + + // At the target depth we set `msg.sender`. + let new_caller = (depth == self.depth).then_some(self.new_caller); + + // At the target depth, or deeper, we set `tx.origin`. + let applied = new_caller.is_some() || self.new_origin.is_some(); + + Some(PrankChanges { + caller: new_caller, + origin: self.new_origin, + used: if applied { self.first_time_applied() } else { None }, + }) + } +} + +/// How a prank changes a call or create. +#[derive(Clone, Copy, Debug)] +pub(crate) struct PrankChanges { + /// The new `msg.sender`, set only at the prank depth. + pub(crate) caller: Option
, + /// The new `tx.origin`. + pub(crate) origin: Option
, + /// The prank marked as used, if this is its first application. + pub(crate) used: Option, } impl Cheatcode for prank_0Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { msgSender } = self; prank(ccx, msgSender, None, true, false) } } impl Cheatcode for startPrank_0Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { msgSender } = self; prank(ccx, msgSender, None, false, false) } } impl Cheatcode for prank_1Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { msgSender, txOrigin } = self; prank(ccx, msgSender, Some(txOrigin), true, false) } } impl Cheatcode for startPrank_1Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { msgSender, txOrigin } = self; prank(ccx, msgSender, Some(txOrigin), false, false) } } impl Cheatcode for prank_2Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { msgSender, delegateCall } = self; prank(ccx, msgSender, None, true, *delegateCall) } } impl Cheatcode for startPrank_2Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { msgSender, delegateCall } = self; prank(ccx, msgSender, None, false, *delegateCall) } } impl Cheatcode for prank_3Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { msgSender, txOrigin, delegateCall } = self; prank(ccx, msgSender, Some(txOrigin), true, *delegateCall) } } impl Cheatcode for startPrank_3Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { msgSender, txOrigin, delegateCall } = self; prank(ccx, msgSender, Some(txOrigin), false, *delegateCall) } } impl Cheatcode for stopPrankCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self {} = self; - ccx.state.pranks.remove(&ccx.ecx.journaled_state.depth()); + ccx.state.pranks.remove(&ccx.depth()); Ok(Default::default()) } } -fn prank( - ccx: &mut CheatsCtxt, +fn prank( + ccx: &mut CheatsCtxt<'_, '_, FEN>, new_caller: &Address, new_origin: Option<&Address>, single_call: bool, @@ -132,12 +162,12 @@ fn prank( // Ensure that code exists at `msg.sender` if delegate calling. if delegate_call { ensure!( - account.info.clone().code.is_some_and(|code| !code.is_empty()), + account.info.code.as_ref().is_some_and(|code| !code.is_empty()), "cannot `prank` delegate call from an EOA" ); } - let depth = ccx.ecx.journaled_state.depth(); + let depth = ccx.depth(); if let Some(Prank { used, single_call: current_single_call, .. }) = ccx.state.get_prank(depth) { ensure!(used, "cannot overwrite a prank until it is applied at least once"); // This case can only fail if the user calls `vm.startPrank` and then `vm.prank` later on. @@ -151,7 +181,7 @@ fn prank( let prank = Prank::new( ccx.caller, - ccx.ecx.tx.caller, + ccx.tx_caller(), *new_caller, new_origin.copied(), depth, @@ -168,3 +198,34 @@ fn prank( ccx.state.pranks.insert(prank.depth, prank); Ok(Default::default()) } + +#[cfg(test)] +mod tests { + use super::*; + + const CALLER: Address = Address::repeat_byte(0x01); + const SENDER: Address = Address::repeat_byte(0x02); + const ORIGIN: Address = Address::repeat_byte(0x03); + + #[test] + fn changes_for_applies_the_sender_only_at_the_prank_depth() { + let prank = Prank::new(CALLER, Address::ZERO, SENDER, None, 2, false, false); + assert!(prank.changes_for(1, CALLER).is_none()); + assert!(prank.changes_for(2, ORIGIN).is_none()); + + let at_depth = prank.changes_for(2, CALLER).unwrap(); + assert_eq!((at_depth.caller, at_depth.origin), (Some(SENDER), None)); + let used = at_depth.used.unwrap(); + assert!(used.used); + assert!(used.changes_for(2, CALLER).unwrap().used.is_none()); + + let deeper = prank.changes_for(3, CALLER).unwrap(); + assert_eq!((deeper.caller, deeper.origin), (None, None)); + assert!(deeper.used.is_none()); + + let with_origin = Prank::new(CALLER, Address::ZERO, SENDER, Some(ORIGIN), 2, false, false); + let deeper = with_origin.changes_for(3, CALLER).unwrap(); + assert_eq!((deeper.caller, deeper.origin), (None, Some(ORIGIN))); + assert!(deeper.used.is_some()); + } +} diff --git a/crates/cheatcodes/src/evm/record_debug_step.rs b/crates/cheatcodes/src/evm/record_debug_step.rs index ca9398b76c03e..3bd709d5db560 100644 --- a/crates/cheatcodes/src/evm/record_debug_step.rs +++ b/crates/cheatcodes/src/evm/record_debug_step.rs @@ -1,19 +1,24 @@ use alloy_primitives::{Bytes, U256}; - -use foundry_evm_traces::CallTraceArena; -use revm::{bytecode::opcode::OpCode, interpreter::InstructionResult}; - use foundry_evm_core::buffer::{BufferKind, get_buffer_accesses}; -use revm_inspectors::tracing::types::{CallTraceStep, RecordedMemory, TraceMemberOrder}; +use foundry_evm_traces::{ + CallTraceArena, CallTraceNode, CallTraceStep, RecordedMemory, TraceMemberOrder, +}; +use revm::{bytecode::opcode::OpCode, interpreter::InstructionResult}; use spec::Vm::DebugStep; +// Context for a CallTraceStep, includes depth and contract address. +pub(crate) struct CallTraceCtx<'a> { + pub node: &'a CallTraceNode, + pub step: &'a CallTraceStep, +} + // Do a depth first traverse of the nodes and steps and return steps // that are after `node_start_idx` -pub(crate) fn flatten_call_trace( +pub(crate) fn flatten_call_trace<'a>( root: usize, - arena: &CallTraceArena, + arena: &'a CallTraceArena, node_start_idx: usize, -) -> Vec<&CallTraceStep> { +) -> Vec> { let mut steps = Vec::new(); let mut record_started = false; @@ -31,7 +36,7 @@ fn recursive_flatten_call_trace<'a>( arena: &'a CallTraceArena, node_start_idx: usize, record_started: &mut bool, - flatten_steps: &mut Vec<&'a CallTraceStep>, + flatten_steps: &mut Vec>, ) { // Once node_idx exceeds node_start_idx, start recording steps // for all the recursive processing. @@ -43,11 +48,9 @@ fn recursive_flatten_call_trace<'a>( for order in &node.ordering { match order { - TraceMemberOrder::Step(step_idx) => { - if *record_started { - let step = &node.trace.steps[*step_idx]; - flatten_steps.push(step); - } + TraceMemberOrder::Step(step_idx) if *record_started => { + let step = &node.trace.steps[*step_idx]; + flatten_steps.push(CallTraceCtx { node, step }); } TraceMemberOrder::Call(call_idx) => { let child_node_idx = node.children[*call_idx]; @@ -65,25 +68,34 @@ fn recursive_flatten_call_trace<'a>( } // Function to convert CallTraceStep to DebugStep -pub(crate) fn convert_call_trace_to_debug_step(step: &CallTraceStep) -> DebugStep { - let opcode = step.op.get(); - let stack = get_stack_inputs_for_opcode(opcode, step.stack.as_ref()); - - let memory = get_memory_input_for_opcode(opcode, step.stack.as_ref(), step.memory.as_ref()); - - let is_out_of_gas = step.status == Some(InstructionResult::OutOfGas) - || step.status == Some(InstructionResult::MemoryOOG) - || step.status == Some(InstructionResult::MemoryLimitOOG) - || step.status == Some(InstructionResult::PrecompileOOG) - || step.status == Some(InstructionResult::InvalidOperandOOG); +pub(crate) fn convert_call_trace_ctx_to_debug_step(ctx: &CallTraceCtx) -> DebugStep { + let opcode = ctx.step.op.get(); + let stack = get_stack_inputs_for_opcode(opcode, ctx.step.stack.as_deref()); + + let memory = + get_memory_input_for_opcode(opcode, ctx.step.stack.as_deref(), ctx.step.memory.as_ref()); + + let is_out_of_gas = matches!( + ctx.step.status, + Some( + InstructionResult::OutOfGas + | InstructionResult::MemoryOOG + | InstructionResult::MemoryLimitOOG + | InstructionResult::PrecompileOOG + | InstructionResult::InvalidOperandOOG + ) + ); + + let depth = ctx.node.trace.depth as u64 + 1; + let contract_addr = ctx.node.execution_address(); DebugStep { stack, memoryInput: memory, - opcode: step.op.get(), - depth: step.depth, + opcode: ctx.step.op.get(), + depth, isOutOfGas: is_out_of_gas, - contractAddr: step.contract, + contractAddr: contract_addr, } } @@ -91,7 +103,7 @@ pub(crate) fn convert_call_trace_to_debug_step(step: &CallTraceStep) -> DebugSte // is the last value of the vector fn get_memory_input_for_opcode( opcode: u8, - stack: Option<&Vec>, + stack: Option<&[U256]>, memory: Option<&RecordedMemory>, ) -> Bytes { let mut memory_input = Bytes::new(); @@ -108,18 +120,16 @@ fn get_memory_input_for_opcode( } // The expected `stack` here is from the trace stack, where the top of the stack -// is the last value of the vector -fn get_stack_inputs_for_opcode(opcode: u8, stack: Option<&Vec>) -> Vec { - let mut inputs = Vec::new(); - - let Some(op) = OpCode::new(opcode) else { return inputs }; - let Some(stack_data) = stack else { return inputs }; - - let stack_input_size = op.inputs() as usize; - for i in 0..stack_input_size { - inputs.push(stack_data[stack_data.len() - 1 - i]); - } - inputs +// is the last value of the vector. +// +// A step that fails with a stack underflow is recorded with fewer items than its opcode +// consumes, so only the items that are actually present are returned. +fn get_stack_inputs_for_opcode(opcode: u8, stack: Option<&[U256]>) -> Vec { + let Some(op) = OpCode::new(opcode) else { return Vec::new() }; + let Some(stack_data) = stack else { return Vec::new() }; + + let stack_input_size = (op.inputs() as usize).min(stack_data.len()); + stack_data.iter().rev().take(stack_input_size).copied().collect() } fn get_slice_from_memory(memory: &Bytes, start_index: usize, size: usize) -> Bytes { @@ -142,3 +152,27 @@ fn get_slice_from_memory(memory: &Bytes, start_index: usize, size: usize) -> Byt // Return empty bytes with the size if not in range at all. Bytes::from(vec![0u8; size]) } + +#[cfg(test)] +mod tests { + use super::*; + use revm::bytecode::opcode; + + #[test] + fn stack_inputs_are_taken_from_the_top() { + let stack = [U256::ONE, U256::from(2), U256::from(3)]; + assert_eq!( + get_stack_inputs_for_opcode(opcode::ADD, Some(&stack)), + vec![U256::from(3), U256::from(2)] + ); + } + + #[test] + fn stack_inputs_are_truncated_on_underflow() { + assert_eq!(get_stack_inputs_for_opcode(opcode::ADD, Some(&[])), Vec::::new()); + assert_eq!( + get_stack_inputs_for_opcode(opcode::ADD, Some(&[U256::from(7)])), + vec![U256::from(7)] + ); + } +} diff --git a/crates/cheatcodes/src/expected_emit.rs b/crates/cheatcodes/src/expected_emit.rs new file mode 100644 index 0000000000000..4b83a4475fec8 --- /dev/null +++ b/crates/cheatcodes/src/expected_emit.rs @@ -0,0 +1,1051 @@ +//! Engine-free matching for the `expectEmit` cheatcodes. + +use crate::Error; +use alloy_dyn_abi::{DynSolValue, EventExt}; +use alloy_json_abi::Event; +use alloy_primitives::{ + Address, Bytes, Log, LogData as RawLog, hex, + map::{AddressHashMap, HashMap, hash_map::Entry}, +}; +use alloy_sol_types::SolValue; +use foundry_common::{abi::get_indexed_event, fmt::format_token}; +use foundry_evm_traces::{DecodedCallLog, identifier::SignaturesIdentifier}; +use std::collections::VecDeque; + +#[derive(Clone, Debug)] +pub struct ExpectedEmit { + /// The depth at which we expect this emit to have occurred + pub depth: usize, + /// The log we expect + pub log: Option, + /// The checks to perform: + /// ```text + /// ┌───────┬───────┬───────┬───────┬────┐ + /// │topic 0│topic 1│topic 2│topic 3│data│ + /// └───────┴───────┴───────┴───────┴────┘ + /// ``` + pub checks: [bool; 5], + /// If present, check originating address against this + pub address: Option
, + /// If present, relax the requirement that topic 0 must be present. This allows anonymous + /// events with no indexed topics to be matched. + pub anonymous: bool, + /// Whether the log was actually found in the subcalls + pub found: bool, + /// Number of times the log is expected to be emitted + pub count: u64, + /// Stores mismatch details if a log didn't match. + pub mismatch_error: Option, +} + +#[derive(Clone, Debug)] +pub enum EmitMismatch { + Log { actual: RawLog }, + Emitter { expected: Address, actual: Address }, +} + +impl EmitMismatch { + /// Formats the mismatch, calling `identifier` only when event signatures are needed. + pub fn to_error_msg<'a>( + &self, + identifier: impl FnOnce() -> Option<&'a SignaturesIdentifier>, + checks: [bool; 5], + expected: Option<&RawLog>, + anonymous: bool, + ) -> String { + match self { + Self::Log { actual } => { + let Some(expected) = expected else { + return "log != expected log".to_string(); + }; + let (expected_decoded, actual_decoded) = if anonymous { + (None, None) + } else { + identifier() + .map(|identifier| { + (decode_event(identifier, expected), decode_event(identifier, actual)) + }) + .unwrap_or_default() + }; + get_emit_mismatch_message( + checks, + expected, + actual, + anonymous, + expected_decoded.as_ref(), + actual_decoded.as_ref(), + ) + } + Self::Emitter { expected, actual } => { + format!("log emitter mismatch: expected={expected:#x}, got={actual:#x}") + } + } + } +} + +/// Fills or checks the expected emits against an emitted log. +/// +/// Returns the failure reason if the log violates an expectation. +pub(crate) fn observe_log(tracker: &mut ExpectedEmitTracker, log: &Log) -> Option<&'static str> { + // Fill or check the expected emits. + // We expect for emit checks to be filled as they're declared (from oldest to newest), + // so we fill them and push them to the back of the queue. + // If the user has properly filled all the emits, they'll end up in their original order. + // If not, the queue will not be in the order the events will be intended to be filled, + // and we'll be able to later detect this and bail. + + // First, we can return early if all events have been matched. + // This allows a contract to arbitrarily emit more events than expected (additive behavior), + // as long as all the previous events were matched in the order they were expected to be. + if tracker.iter().all(|(expected, _)| expected.found) { + return None; + } + + // Check count=0 expectations against this log - fail immediately if violated + for (expected_emit, _) in tracker.iter() { + if expected_emit.count == 0 + && !expected_emit.found + && let Some(expected_log) = &expected_emit.log + && checks_topics_and_data(expected_emit.checks, expected_log, log) + // Check revert address + && (expected_emit.address.is_none_or(|address| address == log.address)) + { + // This event was emitted but we expected it NOT to be (count=0). + return Some("log emitted but expected 0 times"); + } + } + + let should_fill_logs = tracker.iter().any(|(expected, _)| expected.log.is_none()); + let index_to_fill_or_check = if should_fill_logs { + // If there's anything to fill, we start with the last event to match in the queue + // (without taking into account events already matched). + tracker.iter().position(|(emit, _)| emit.found).unwrap_or(tracker.len()).saturating_sub(1) + } else { + // if all expected logs are filled, check any unmatched event + // in the declared order, so we start from the front (like a queue). + // Skip count=0 expectations as they are handled separately above + tracker.iter().position(|(emit, _)| !emit.found && emit.count > 0).unwrap_or(0) + }; + + // If there are only count=0 expectations left, we can return early + if !should_fill_logs && tracker.iter().all(|(emit, _)| emit.found || emit.count == 0) { + return None; + } + + let (mut event_to_fill_or_check, mut count_map) = + tracker.remove(index_to_fill_or_check).expect("we should have an emit to fill or check"); + + let Some(expected) = &event_to_fill_or_check.log else { + // Unless the caller is trying to match an anonymous event, the first topic must be + // filled. + if event_to_fill_or_check.anonymous || !log.topics().is_empty() { + event_to_fill_or_check.log = Some(log.data.clone()); + // If we only filled the expected log then we put it back at the same position. + tracker.insert(index_to_fill_or_check, (event_to_fill_or_check, count_map)); + } else { + return Some("use vm.expectEmitAnonymous to match anonymous events"); + } + + return None; + }; + + // Increment/set `count` for `log.address` and `log.data` + match count_map.entry(log.address) { + Entry::Occupied(mut entry) => { + let log_count_map = entry.get_mut(); + log_count_map.insert(&log.data); + } + Entry::Vacant(entry) => { + let mut log_count_map = LogCountMap::new(&event_to_fill_or_check); + if log_count_map.satisfies_checks(&log.data) { + log_count_map.insert(&log.data); + entry.insert(log_count_map); + } + } + } + + event_to_fill_or_check.found = || -> bool { + if !checks_topics_and_data(event_to_fill_or_check.checks, expected, log) { + event_to_fill_or_check.mismatch_error = + Some(EmitMismatch::Log { actual: log.data.clone() }); + return false; + } + + // Maybe match source address. + if let Some(expected) = event_to_fill_or_check.address + && expected != log.address + { + event_to_fill_or_check.mismatch_error = + Some(EmitMismatch::Emitter { expected, actual: log.address }); + return false; + } + + let expected_count = event_to_fill_or_check.count; + match event_to_fill_or_check.address { + Some(emitter) => count_map + .get(&emitter) + .is_some_and(|log_map| log_map.count(&log.data) >= expected_count), + None => count_map + .values() + .find(|log_map| log_map.satisfies_checks(&log.data)) + .is_some_and(|map| map.count(&log.data) >= expected_count), + } + }(); + + // If we found the event, we can push it to the back of the queue + // and begin expecting the next event. + if event_to_fill_or_check.found { + tracker.push_back((event_to_fill_or_check, count_map)); + } else { + // We did not match this event, so we need to keep waiting for the right one to + // appear. + tracker.push_front((event_to_fill_or_check, count_map)); + } + + None +} + +/// Handles expected emits specified by the `expectEmit` cheatcodes. +/// +/// The second element of the tuple counts the number of times the log has been emitted by a +/// particular address +pub type ExpectedEmitTracker = VecDeque<(ExpectedEmit, AddressHashMap)>; + +#[derive(Clone, Debug, Default)] +pub struct LogCountMap { + checks: [bool; 5], + expected_log: RawLog, + map: HashMap, +} + +impl LogCountMap { + /// Instantiates `LogCountMap`. + fn new(expected_emit: &ExpectedEmit) -> Self { + Self { + checks: expected_emit.checks, + expected_log: expected_emit.log.clone().expect("log should be filled here"), + map: Default::default(), + } + } + + /// Inserts a log into the map and increments the count. + /// + /// The log must pass all checks against the expected log for the count to increment. + /// + /// Returns true if the log was inserted and count was incremented. + fn insert(&mut self, log: &RawLog) -> bool { + // If its already in the map, increment the count without checking. + if self.map.contains_key(log) { + self.map.entry(log.clone()).and_modify(|c| *c += 1); + + return true; + } + + if !self.satisfies_checks(log) { + return false; + } + + self.map.entry(log.clone()).and_modify(|c| *c += 1).or_insert(1); + + true + } + + /// Checks the incoming raw log against the expected logs topics and data. + fn satisfies_checks(&self, log: &RawLog) -> bool { + checks_topics_and_data(self.checks, &self.expected_log, log) + } + + pub fn count(&self, log: &RawLog) -> u64 { + if !self.satisfies_checks(log) { + return 0; + } + + self.count_unchecked() + } + + pub fn count_unchecked(&self) -> u64 { + self.map.values().sum() + } +} + +fn checks_topics_and_data(checks: [bool; 5], expected: &RawLog, log: &RawLog) -> bool { + if log.topics().len() != expected.topics().len() { + return false; + } + + // Check topics. + if !log + .topics() + .iter() + .enumerate() + .filter(|(i, _)| checks[*i]) + .all(|(i, topic)| topic == &expected.topics()[i]) + { + return false; + } + + // Check data + if checks[4] && expected.data.as_ref() != log.data.as_ref() { + return false; + } + + true +} + +fn decode_event( + identifier: &foundry_evm_traces::identifier::SignaturesIdentifier, + log: &RawLog, +) -> Option { + let topics = log.topics(); + if topics.is_empty() { + return None; + } + let t0 = topics[0]; // event sig + // Try to identify the event + let event = foundry_common::block_on( + identifier.identify_event_with_indexed_count(t0, topics.len().saturating_sub(1)), + )?; + + // Check if event already has indexed information from signatures + let has_indexed_info = event.inputs.iter().any(|p| p.indexed); + // Only use get_indexed_event if the event doesn't have indexing info + let indexed_event = if has_indexed_info { event } else { get_indexed_event(event, log) }; + + // Try to decode the event + if let Ok(decoded) = indexed_event.decode_log(log) { + let params = reconstruct_params(&indexed_event, &decoded); + + let decoded_params = params + .into_iter() + .zip(indexed_event.inputs.iter()) + .map(|(param, input)| (input.name.clone(), format_token(¶m))) + .collect(); + + return Some(DecodedCallLog { + name: Some(indexed_event.name), + params: Some(decoded_params), + }); + } + + None +} + +/// Restore the order of the params of a decoded event +fn reconstruct_params(event: &Event, decoded: &alloy_dyn_abi::DecodedEvent) -> Vec { + let mut indexed = 0; + let mut unindexed = 0; + let mut inputs = vec![]; + for input in &event.inputs { + if input.indexed && indexed < decoded.indexed.len() { + inputs.push(decoded.indexed[indexed].clone()); + indexed += 1; + } else if unindexed < decoded.body.len() { + inputs.push(decoded.body[unindexed].clone()); + unindexed += 1; + } + } + inputs +} + +/// Gets a detailed mismatch message for emit assertions +pub(crate) fn get_emit_mismatch_message( + checks: [bool; 5], + expected: &RawLog, + actual: &RawLog, + is_anonymous: bool, + expected_decoded: Option<&DecodedCallLog>, + actual_decoded: Option<&DecodedCallLog>, +) -> String { + // Early return for completely different events or incompatible structures + + // 1. Different number of topics + if actual.topics().len() != expected.topics().len() { + let expected_name = expected_decoded.and_then(|d| d.name.as_deref()).unwrap_or("log"); + let actual_name = actual_decoded.and_then(|d| d.name.as_deref()).unwrap_or("log"); + let expected_topics = checked_topic_count(expected, is_anonymous); + let actual_topics = checked_topic_count(actual, is_anonymous); + + if expected_name == actual_name { + return format!( + "{actual_name} indexed topic count mismatch: expected {expected_topics}, got {actual_topics}" + ); + } + + return name_mismatched_logs(expected_decoded, actual_decoded); + } + + // 2. Different event signatures (for non-anonymous events) + if !is_anonymous + && checks[0] + && (!expected.topics().is_empty() && !actual.topics().is_empty()) + && expected.topics()[0] != actual.topics()[0] + { + return name_mismatched_logs(expected_decoded, actual_decoded); + } + + let expected_data = expected.data.as_ref(); + let actual_data = actual.data.as_ref(); + + // 3. Check data + if checks[4] && expected_data != actual_data { + // Different lengths or not ABI-encoded + if expected_data.len() != actual_data.len() + || !expected_data.len().is_multiple_of(32) + || expected_data.is_empty() + { + return name_mismatched_logs(expected_decoded, actual_decoded); + } + } + + // expected and actual events are the same, so check individual parameters + let mut mismatches = Vec::new(); + + // Check topics (indexed parameters) + for (i, (expected_topic, actual_topic)) in + expected.topics().iter().zip(actual.topics().iter()).enumerate() + { + // Skip topic[0] for non-anonymous events (already checked above) + if i == 0 && !is_anonymous { + continue; + } + + // Only check if the corresponding check flag is set + if i < checks.len() && checks[i] && expected_topic != actual_topic { + let param_idx = if is_anonymous { + i // For anonymous events, topic[0] is param 0 + } else { + i - 1 // For regular events, topic[0] is event signature, so topic[1] is param 0 + }; + mismatches + .push(format!("param {param_idx}: expected={expected_topic}, got={actual_topic}")); + } + } + + // Check data (non-indexed parameters) + if checks[4] && expected_data != actual_data { + let num_indexed_params = if is_anonymous { + expected.topics().len() + } else { + expected.topics().len().saturating_sub(1) + }; + + for (i, (expected_chunk, actual_chunk)) in + expected_data.chunks(32).zip(actual_data.chunks(32)).enumerate() + { + if expected_chunk != actual_chunk { + let param_idx = num_indexed_params + i; + mismatches.push(format!( + "param {}: expected={}, got={}", + param_idx, + hex::encode_prefixed(expected_chunk), + hex::encode_prefixed(actual_chunk) + )); + } + } + } + + if mismatches.is_empty() { + name_mismatched_logs(expected_decoded, actual_decoded) + } else { + // Build the error message with event names if available + let event_prefix = match (expected_decoded, actual_decoded) { + (Some(expected_dec), Some(actual_dec)) if expected_dec.name == actual_dec.name => { + format!( + "{} param mismatch", + expected_dec.name.as_ref().unwrap_or(&"log".to_string()) + ) + } + _ => { + if is_anonymous { + "anonymous log mismatch".to_string() + } else { + "log mismatch".to_string() + } + } + }; + + // Add parameter details if available from decoded events + let detailed_mismatches = if let (Some(expected_dec), Some(actual_dec)) = + (expected_decoded, actual_decoded) + && let (Some(expected_params), Some(actual_params)) = + (&expected_dec.params, &actual_dec.params) + { + mismatches + .into_iter() + .map(|basic_mismatch| { + // Try to find the parameter name and decoded value + if let Some(param_idx) = basic_mismatch + .split(' ') + .nth(1) + .and_then(|s| s.trim_end_matches(':').parse::().ok()) + && param_idx < expected_params.len() + && param_idx < actual_params.len() + { + let (expected_name, expected_value) = &expected_params[param_idx]; + let (_actual_name, actual_value) = &actual_params[param_idx]; + let param_name = if expected_name.is_empty() { + &format!("param{param_idx}") + } else { + expected_name + }; + return format!( + "{param_name}: expected={expected_value}, got={actual_value}", + ); + } + basic_mismatch + }) + .collect::>() + } else { + mismatches + }; + + format!("{} at {}", event_prefix, detailed_mismatches.join(", ")) + } +} + +/// Formats the generic mismatch message: "log != expected log" to include event names if available +fn name_mismatched_logs( + expected_decoded: Option<&DecodedCallLog>, + actual_decoded: Option<&DecodedCallLog>, +) -> String { + let expected_name = expected_decoded.and_then(|d| d.name.as_deref()).unwrap_or("log"); + let actual_name = actual_decoded.and_then(|d| d.name.as_deref()).unwrap_or("log"); + format!("{actual_name} != expected {expected_name}") +} + +fn checked_topic_count(log: &RawLog, is_anonymous: bool) -> usize { + if is_anonymous { log.topics().len() } else { log.topics().len().saturating_sub(1) } +} + +/// Why the tracked expected emits were not satisfied when a call ended. +#[derive(Debug)] +pub(crate) enum UnmetEmit { + /// An expected log was never matched. + Unmatched(ExpectedEmit), + /// A log was emitted a different number of times than expected. + Count { expected: u64, actual: u64 }, + /// The call did not succeed while logs were expected. + Failed, +} + +impl UnmetEmit { + /// Encodes the revert data, calling `identifier` only when event signatures are needed. + pub(crate) fn encode<'a>( + self, + identifier: impl FnOnce() -> Option<&'a SignaturesIdentifier>, + ) -> Bytes { + match self { + Self::Unmatched(expected) => { + let error_msg = expected + .mismatch_error + .as_ref() + .map(|mismatch| { + mismatch.to_error_msg( + identifier, + expected.checks, + expected.log.as_ref(), + expected.anonymous, + ) + }) + .unwrap_or_else(|| "log != expected log".to_string()); + error_msg.abi_encode().into() + } + Self::Count { expected, actual } => { + Error::encode(format!("log emitted {actual} times, expected {expected}")) + } + Self::Failed => Error::encode( + "expected an emit, but the call reverted instead. \ + ensure you're testing the happy path when using `expectEmit`", + ), + } + } +} + +/// Checks the entire tracker when a non-static call ends at any tracked expectation's `depth`. +/// +/// Clears the tracker only if all expectations are satisfied; otherwise leaves it untouched. +/// Callers must skip already-reverted calls to preserve their original failure. +pub(crate) fn check_call_emits( + tracker: &mut ExpectedEmitTracker, + depth: usize, + is_static: bool, + succeeded: bool, +) -> Option { + let should_check_emits = + tracker.iter().any(|(expected, _)| expected.depth == depth) && !is_static; + if !should_check_emits { + return None; + } + + let expected_counts = tracker + .iter() + .filter_map(|(expected, count_map)| { + let count = match expected.address { + Some(emitter) => match count_map.get(&emitter) { + Some(log_count) => expected + .log + .as_ref() + .map(|l| log_count.count(l)) + .unwrap_or_else(|| log_count.count_unchecked()), + None => 0, + }, + None => match &expected.log { + Some(log) => count_map.values().map(|logs| logs.count(log)).sum(), + None => count_map.values().map(|logs| logs.count_unchecked()).sum(), + }, + }; + + (count != expected.count).then_some((expected, count)) + }) + .collect::>(); + + if let Some((expected, _)) = + tracker.iter().find(|(expected, _)| !expected.found && expected.count > 0) + { + return Some(UnmetEmit::Unmatched(expected.clone())); + } + + if let Some((expected, count)) = expected_counts.first() { + return Some(if succeeded { + UnmetEmit::Count { expected: expected.count, actual: *count } + } else { + UnmetEmit::Failed + }); + } + + // Later calls need their own expectations. + tracker.clear(); + None +} + +/// Checks for leftover expected emits when the root call ends without reverting. +/// +/// Returns the failure message if any expectation is still unmatched. +/// Callers must skip already-reverted calls to preserve their original failure. +pub(crate) fn first_unmet_root_emit( + tracker: &mut ExpectedEmitTracker, + succeeded: bool, +) -> Option<&'static str> { + // A count=0 expectation is met when its log was never emitted. + for (expected, _) in tracker.iter_mut() { + if expected.count == 0 && !expected.found { + expected.found = true; + } + } + tracker.retain(|(expected, _)| !expected.found); + if tracker.is_empty() { + return None; + } + Some(if succeeded { + "expected an emit, but no logs were emitted afterwards. \ + you might have mismatched events or not enough events were emitted" + } else { + "expected an emit, but the call reverted instead. \ + ensure you're testing the happy path when using `expectEmit`" + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::{B256, address, b256}; + + const EMITTER: Address = address!("0x00000000000000000000000000000000000000aa"); + const OTHER: Address = address!("0x00000000000000000000000000000000000000bb"); + const TOPIC_A: B256 = + b256!("0x000000000000000000000000000000000000000000000000000000000000000a"); + const TOPIC_B: B256 = + b256!("0x000000000000000000000000000000000000000000000000000000000000000b"); + const ALL: [bool; 5] = [true; 5]; + + fn log(emitter: Address, topics: Vec, data: &'static [u8]) -> Log { + Log::new_unchecked(emitter, topics, Bytes::from_static(data)) + } + + fn expect(tracker: &mut ExpectedEmitTracker, expected: Option<&Log>, checks: [bool; 5]) { + expect_with(tracker, expected, checks, None, 1, false); + } + + fn expect_with( + tracker: &mut ExpectedEmitTracker, + expected: Option<&Log>, + checks: [bool; 5], + address: Option
, + count: u64, + anonymous: bool, + ) { + tracker.push_back(( + ExpectedEmit { + depth: 0, + log: expected.map(|log| log.data.clone()), + checks, + address, + anonymous, + found: false, + count, + mismatch_error: None, + }, + Default::default(), + )); + } + + fn found(tracker: &ExpectedEmitTracker) -> Vec { + tracker.iter().map(|(expected, _)| expected.found).collect() + } + + #[test] + fn matches_logs_in_order() { + let a = log(EMITTER, vec![TOPIC_A], b"a"); + let b = log(EMITTER, vec![TOPIC_B], b"b"); + let mut tracker = ExpectedEmitTracker::new(); + expect(&mut tracker, Some(&a), ALL); + expect(&mut tracker, Some(&b), ALL); + + assert_eq!(observe_log(&mut tracker, &a), None); + assert_eq!(observe_log(&mut tracker, &b), None); + assert_eq!(found(&tracker), [true, true]); + } + + #[test] + fn out_of_order_logs_leave_an_expectation_unmatched() { + let a = log(EMITTER, vec![TOPIC_A], b"a"); + let b = log(EMITTER, vec![TOPIC_B], b"b"); + let mut tracker = ExpectedEmitTracker::new(); + expect(&mut tracker, Some(&a), ALL); + expect(&mut tracker, Some(&b), ALL); + + assert_eq!(observe_log(&mut tracker, &b), None); + assert_eq!(observe_log(&mut tracker, &a), None); + assert_eq!(found(&tracker), [false, true]); + assert_eq!(tracker[0].0.log.as_ref(), Some(&b.data)); + } + + #[test] + fn fills_an_unfilled_expectation_with_the_next_log() { + let a = log(EMITTER, vec![TOPIC_A], b"a"); + let mut tracker = ExpectedEmitTracker::new(); + expect(&mut tracker, None, ALL); + + assert_eq!(observe_log(&mut tracker, &a), None); + assert_eq!(found(&tracker), [false]); + assert_eq!(tracker[0].0.log.as_ref(), Some(&a.data)); + + assert_eq!(observe_log(&mut tracker, &a), None); + assert_eq!(found(&tracker), [true]); + } + + #[test] + fn fills_the_last_pending_expectation_before_found_ones() { + let a = log(EMITTER, vec![TOPIC_A], b"a"); + let b = log(EMITTER, vec![TOPIC_B], b"b"); + let mut tracker = ExpectedEmitTracker::new(); + expect(&mut tracker, None, ALL); + expect(&mut tracker, None, ALL); + expect(&mut tracker, Some(&a), ALL); + tracker[2].0.found = true; + + assert_eq!(observe_log(&mut tracker, &b), None); + assert_eq!(tracker[0].0.log, None); + assert_eq!(tracker[1].0.log.as_ref(), Some(&b.data)); + assert_eq!(tracker[2].0.log.as_ref(), Some(&a.data)); + assert_eq!(found(&tracker), [false, false, true]); + } + + #[test] + fn counts_matching_logs() { + let a = log(EMITTER, vec![TOPIC_A], b"a"); + let mut tracker = ExpectedEmitTracker::new(); + expect_with(&mut tracker, Some(&a), ALL, None, 2, false); + + assert_eq!(observe_log(&mut tracker, &a), None); + assert_eq!(found(&tracker), [false]); + assert_eq!(observe_log(&mut tracker, &a), None); + assert_eq!(found(&tracker), [true]); + } + + #[test] + fn count_zero_fails_when_the_log_is_emitted() { + let a = log(EMITTER, vec![TOPIC_A], b"a"); + let b = log(EMITTER, vec![TOPIC_B], b"b"); + let mut tracker = ExpectedEmitTracker::new(); + expect_with(&mut tracker, Some(&a), ALL, None, 0, false); + + assert_eq!(observe_log(&mut tracker, &b), None); + assert_eq!(observe_log(&mut tracker, &a), Some("log emitted but expected 0 times")); + assert_eq!(found(&tracker), [false]); + } + + #[test] + fn anonymous_logs_require_expect_emit_anonymous() { + let anonymous = log(EMITTER, vec![], b"a"); + + let mut tracker = ExpectedEmitTracker::new(); + expect(&mut tracker, None, ALL); + assert_eq!( + observe_log(&mut tracker, &anonymous), + Some("use vm.expectEmitAnonymous to match anonymous events") + ); + assert!(tracker.is_empty()); + + let mut tracker = ExpectedEmitTracker::new(); + expect_with(&mut tracker, None, ALL, None, 1, true); + assert_eq!(observe_log(&mut tracker, &anonymous), None); + assert_eq!(tracker[0].0.log.as_ref(), Some(&anonymous.data)); + assert_eq!(observe_log(&mut tracker, &anonymous), None); + assert_eq!(found(&tracker), [true]); + } + + #[test] + fn filters_by_emitter_address() { + let a = log(EMITTER, vec![TOPIC_A], b"a"); + let from_other = log(OTHER, vec![TOPIC_A], b"a"); + let mut tracker = ExpectedEmitTracker::new(); + expect_with(&mut tracker, Some(&a), ALL, Some(EMITTER), 1, false); + + assert_eq!(observe_log(&mut tracker, &from_other), None); + assert_eq!(found(&tracker), [false]); + assert!(matches!( + tracker[0].0.mismatch_error, + Some(EmitMismatch::Emitter { expected: EMITTER, actual: OTHER }) + )); + + assert_eq!(observe_log(&mut tracker, &a), None); + assert_eq!(found(&tracker), [true]); + } + + #[test] + fn applies_check_flags() { + const TOPICS: [B256; 4] = [TOPIC_A, TOPIC_A, TOPIC_A, TOPIC_A]; + let expected = log(EMITTER, TOPICS.to_vec(), b"expected"); + + // Each flag only compares its own topic or the data. + for flag in 0..5 { + let actual = if flag < 4 { + let mut topics = TOPICS; + topics[flag] = TOPIC_B; + log(EMITTER, topics.to_vec(), b"expected") + } else { + log(EMITTER, TOPICS.to_vec(), b"other") + }; + + let mut unchecked = ALL; + unchecked[flag] = false; + let mut tracker = ExpectedEmitTracker::new(); + expect(&mut tracker, Some(&expected), unchecked); + assert_eq!(observe_log(&mut tracker, &actual), None); + assert_eq!(found(&tracker), [true], "flag {flag} unchecked"); + + let mut tracker = ExpectedEmitTracker::new(); + expect(&mut tracker, Some(&expected), ALL); + assert_eq!(observe_log(&mut tracker, &actual), None); + assert_eq!(found(&tracker), [false], "flag {flag} checked"); + assert!(matches!( + &tracker[0].0.mismatch_error, + Some(EmitMismatch::Log { actual: mismatch }) if mismatch == &actual.data + )); + } + } + + #[test] + fn mismatch_messages() { + let expected = log(EMITTER, vec![TOPIC_A], b"expected"); + let actual = log(EMITTER, vec![TOPIC_A], b"actual"); + + let emitter = EmitMismatch::Emitter { expected: EMITTER, actual: OTHER }; + assert_eq!( + emitter.to_error_msg(unused_identifier, ALL, Some(&expected.data), false), + "log emitter mismatch: expected=0x00000000000000000000000000000000000000aa, \ + got=0x00000000000000000000000000000000000000bb" + ); + + let mismatch = EmitMismatch::Log { actual: actual.data }; + assert_eq!( + mismatch.to_error_msg(unused_identifier, ALL, None, false), + "log != expected log" + ); + assert_eq!( + mismatch.to_error_msg(unused_identifier, ALL, Some(&expected.data), true), + "log != expected log" + ); + let mut resolved = false; + let resolve = || { + resolved = true; + None + }; + assert_eq!( + mismatch.to_error_msg(resolve, ALL, Some(&expected.data), false), + "log != expected log" + ); + assert!(resolved, "non-anonymous log mismatches resolve signatures"); + } + + /// Signatures are only resolved for non-anonymous log mismatches. + fn unused_identifier<'a>() -> Option<&'a SignaturesIdentifier> { + panic!("signatures identifier must not be resolved") + } + + #[test] + fn call_end_reports_logs_counted_across_emitters() { + // Found after two logs from one emitter, with a third from another emitter. + let a = log(EMITTER, vec![TOPIC_A], b"a"); + let mut tracker = ExpectedEmitTracker::new(); + expect_with(&mut tracker, Some(&a), ALL, None, 2, false); + let (expected, count_map) = &mut tracker[0]; + expected.found = true; + for (emitter, logs) in [(EMITTER, 2), (OTHER, 1)] { + let mut counts = LogCountMap::new(expected); + for _ in 0..logs { + counts.insert(&a.data); + } + count_map.insert(emitter, counts); + } + + let before = format!("{tracker:?}"); + for (succeeded, message) in [ + (true, "log emitted 3 times, expected 2"), + ( + false, + "expected an emit, but the call reverted instead. \ + ensure you're testing the happy path when using `expectEmit`", + ), + ] { + let unmet = check_call_emits(&mut tracker, 0, false, succeeded).unwrap(); + assert_eq!(unmet.encode(unused_identifier), Error::encode(message)); + assert_eq!(format!("{tracker:?}"), before); + } + } + + #[test] + fn root_end_reports_leftover_expectations() { + let a = log(EMITTER, vec![TOPIC_A], b"a"); + let mut tracker = ExpectedEmitTracker::new(); + expect_with(&mut tracker, Some(&a), ALL, None, 0, false); + expect(&mut tracker, Some(&a), ALL); + tracker[1].0.found = true; + + assert_eq!(first_unmet_root_emit(&mut tracker, true), None); + assert!(tracker.is_empty()); + + expect(&mut tracker, Some(&a), ALL); + assert_eq!( + first_unmet_root_emit(&mut tracker.clone(), true), + Some( + "expected an emit, but no logs were emitted afterwards. \ + you might have mismatched events or not enough events were emitted" + ) + ); + assert_eq!( + first_unmet_root_emit(&mut tracker, false), + Some( + "expected an emit, but the call reverted instead. \ + ensure you're testing the happy path when using `expectEmit`" + ) + ); + } + + #[test] + fn call_end_skips_static_and_unrelated_calls_then_clears_tracker() { + let a = log(EMITTER, vec![TOPIC_A], b"a"); + let b = log(OTHER, vec![TOPIC_B], b"b"); + let mut tracker = ExpectedEmitTracker::new(); + expect(&mut tracker, Some(&a), ALL); + expect(&mut tracker, Some(&b), ALL); + tracker[1].0.depth = 1; + assert_eq!(observe_log(&mut tracker, &b), None); + + for satisfied in [false, true] { + if satisfied { + assert_eq!(observe_log(&mut tracker, &a), None); + assert_eq!(observe_log(&mut tracker, &b), None); + } + let before = format!("{tracker:?}"); + for (depth, is_static) in [(0, true), (2, false)] { + assert!(check_call_emits(&mut tracker, depth, is_static, true).is_none()); + assert_eq!(format!("{tracker:?}"), before); + } + } + + assert!(check_call_emits(&mut tracker, 0, false, true).is_none()); + assert!(tracker.is_empty()); + } + + #[test] + fn call_end_checks_counts_at_other_depths() { + let a = log(EMITTER, vec![TOPIC_A], b"a"); + let b = log(OTHER, vec![TOPIC_B], b"b"); + let mut tracker = ExpectedEmitTracker::new(); + expect(&mut tracker, Some(&a), ALL); + expect_with(&mut tracker, Some(&b), ALL, Some(OTHER), 0, false); + tracker[1].0.depth = 1; + assert_eq!(observe_log(&mut tracker, &a), None); + let (expected, count_map) = &mut tracker[0]; + assert_eq!(expected.depth, 1); + let mut counts = LogCountMap::new(expected); + assert!(counts.insert(&b.data)); + count_map.insert(OTHER, counts); + let before = format!("{tracker:?}"); + + let unmet = check_call_emits(&mut tracker, 0, false, true).unwrap(); + assert!(matches!(unmet, UnmetEmit::Count { expected: 0, actual: 1 })); + assert_eq!(format!("{tracker:?}"), before); + } + + #[test] + fn call_end_unmatched_expectation_precedes_wrong_count_and_failed_call() { + let a = log(EMITTER, vec![TOPIC_A], b"a"); + let b = log(OTHER, vec![TOPIC_B], b"b"); + let mut tracker = ExpectedEmitTracker::new(); + expect(&mut tracker, Some(&a), ALL); + tracker[0].0.found = true; + expect(&mut tracker, Some(&b), ALL); + tracker[1].0.depth = 1; + let before = format!("{tracker:?}"); + + for succeeded in [false, true] { + let unmet = check_call_emits(&mut tracker, 0, false, succeeded).unwrap(); + assert!(matches!(&unmet, UnmetEmit::Unmatched(expected) + if expected.log.as_ref() == Some(&b.data))); + assert_eq!( + unmet.encode(unused_identifier), + Bytes::from("log != expected log".abi_encode()) + ); + assert_eq!(format!("{tracker:?}"), before); + } + } + + #[test] + fn unmatched_emit_uses_plain_abi_encoding_and_lazy_signature_resolution() { + let a = log(EMITTER, vec![TOPIC_A], b"a"); + let actual = log(EMITTER, vec![TOPIC_A], b"actual"); + let mismatch = EmitMismatch::Log { actual: actual.data }; + for (expected_log, mismatch_error, anonymous, message) in [ + (None, Some(mismatch.clone()), false, "log != expected log"), + (Some(&a), Some(mismatch.clone()), true, "log != expected log"), + ( + Some(&a), + Some(EmitMismatch::Emitter { expected: EMITTER, actual: OTHER }), + false, + "log emitter mismatch: expected=0x00000000000000000000000000000000000000aa, \ + got=0x00000000000000000000000000000000000000bb", + ), + ] { + let mut tracker = ExpectedEmitTracker::new(); + expect_with(&mut tracker, expected_log, ALL, None, 1, anonymous); + tracker[0].0.mismatch_error = mismatch_error; + let unmet = check_call_emits(&mut tracker, 0, false, true).unwrap(); + assert_eq!(unmet.encode(unused_identifier), Bytes::from(message.abi_encode())); + } + + let mut tracker = ExpectedEmitTracker::new(); + expect(&mut tracker, Some(&a), ALL); + tracker[0].0.mismatch_error = Some(mismatch); + let unmet = check_call_emits(&mut tracker, 0, false, true).unwrap(); + let mut resolved = false; + let encoded = unmet.encode(|| { + resolved = true; + None + }); + assert!(resolved, "non-anonymous log mismatches resolve signatures"); + assert_eq!(encoded, Bytes::from("log != expected log".abi_encode())); + } +} diff --git a/crates/cheatcodes/src/external_storage.rs b/crates/cheatcodes/src/external_storage.rs new file mode 100644 index 0000000000000..61d6cf971de2b --- /dev/null +++ b/crates/cheatcodes/src/external_storage.rs @@ -0,0 +1,80 @@ +//! Storage layouts for contracts that aren't part of the local project. +//! +//! [`foundry_common::external_storage`] turns a verified source into a storage layout and caches +//! the result; [`ExternalIdentifier`] finds that source on a block explorer. This module is the +//! seam between them, and owns the state that has to outlive a single lookup. + +use alloy_primitives::{ + Address, + map::{AddressMap, HashMap}, +}; +use foundry_common::external_storage::{fetch_external_storage_layouts, lock_until}; +use foundry_compilers::artifacts::StorageLayout; +use foundry_config::Chain; +use foundry_evm_traces::identifier::{ExternalIdentifier, ExternalIdentifierConfig}; +use std::{ + sync::{Arc, LazyLock, Mutex}, + time::Instant, +}; + +/// Chain id to the identifier for that chain, or `None` if one couldn't be built. +type Identifiers = HashMap>>>; + +/// The [`ExternalIdentifier`] in use for each chain, shared by every test in the process. +/// +/// One identifier per chain rather than one per lookup: it carries the metadata it has already +/// fetched and the budget for how long identification may go on for, and neither means anything +/// unless it survives across calls. +static IDENTIFIERS: LazyLock> = LazyLock::new(Default::default); + +/// Resolves the storage layouts of contracts outside the local project. +/// +/// Returns the layouts it could resolve; anything absent stays undecoded. Costs nothing for +/// addresses already resolved in this process or by a previous run. +pub(crate) fn storage_layouts( + sources: &ExternalIdentifierConfig, + chain: Chain, + addresses: Vec
, +) -> AddressMap<(String, Arc)> { + fetch_external_storage_layouts( + chain, + addresses, + sources.storage_timeout(), + |unresolved, timeout| { + let deadline = Instant::now().checked_add(timeout).unwrap_or_else(Instant::now); + let Some(identifier) = identifier(sources, chain, deadline) else { + return Default::default(); + }; + let Some(mut identifier) = lock_until(&identifier, deadline) else { + return Default::default(); + }; + let remaining = deadline.saturating_duration_since(Instant::now()); + foundry_common::block_on(identifier.get_metadata(unresolved, remaining)) + }, + ) +} + +/// The identifier for `chain`, building it on first use. +/// +/// Warns once per chain when there is nothing to look contracts up with, since the alternative is +/// silently decoding nothing for a run the user explicitly asked to decode. +fn identifier( + sources: &ExternalIdentifierConfig, + chain: Chain, + deadline: Instant, +) -> Option>> { + let mut identifiers = lock_until(&IDENTIFIERS, deadline)?; + identifiers + .entry(chain.id()) + .or_insert_with(|| match sources.storage_identifier(chain) { + Some(identifier) => Some(Arc::new(Mutex::new(identifier))), + None => { + let _ = sh_warn!( + "cannot decode external storage on chain {chain}: no matching block explorer \ + is configured" + ); + None + } + }) + .clone() +} diff --git a/crates/cheatcodes/src/fs.rs b/crates/cheatcodes/src/fs.rs index a9585a31d99fa..4438d81e1ab5a 100644 --- a/crates/cheatcodes/src/fs.rs +++ b/crates/cheatcodes/src/fs.rs @@ -1,22 +1,24 @@ //! Implementations of [`Filesystem`](spec::Group::Filesystem) cheatcodes. use super::string::parse; -use crate::{Cheatcode, Cheatcodes, CheatcodesExecutor, CheatsCtxt, Result, Vm::*}; +use crate::{ + Cheatcode, Cheatcodes, CheatcodesExecutor, CheatsCtxt, Result, Vm::*, inspector::exec_create, +}; use alloy_dyn_abi::DynSolType; use alloy_json_abi::ContractObject; -use alloy_network::AnyTransactionReceipt; -use alloy_primitives::{Bytes, U256, hex, map::Entry}; -use alloy_provider::network::ReceiptResponse; +use alloy_network::{Network, ReceiptResponse}; +use alloy_primitives::{Bytes, FixedBytes, U256, hex, map::Entry}; use alloy_sol_types::SolValue; use dialoguer::{Input, Password}; use forge_script_sequence::{BroadcastReader, TransactionWithMetadata}; -use foundry_common::fs; +use foundry_common::{contracts::ContractData, fs}; use foundry_config::fs_permissions::FsAccessKind; +use foundry_evm_core::{FoundryTransaction, env::FoundryContextExt, evm::FoundryEvmNetwork}; +use foundry_evm_traces::CallKind; use revm::{context::CreateScheme, interpreter::CreateInputs}; -use revm_inspectors::tracing::types::CallKind; use semver::Version; use std::{ - io::{BufRead, BufReader, Write}, + io::{BufRead, BufReader}, path::{Path, PathBuf}, process::Command, sync::mpsc, @@ -25,8 +27,88 @@ use std::{ }; use walkdir::WalkDir; +/// Parsed artifact path components. +#[derive(Debug, Default, PartialEq, Eq)] +struct ParsedArtifactPath<'a> { + file: Option, + contract_name: Option<&'a str>, + version: Option, + profile: Option<&'a str>, +} + +/// Parses an artifact path string into its components. +/// +/// Supports the following formats: +/// - `path/to/contract.sol` +/// - `path/to/contract.sol:ContractName` +/// - `path/to/contract.sol:ContractName:0.8.23` +/// - `path/to/contract.sol:ContractName:profile` +/// - `path/to/contract.sol:0.8.23` +/// - `path/to/contract.sol:profile` +/// - `ContractName` +/// - `ContractName:0.8.23` +/// - `ContractName:profile` +fn parse_artifact_path(path: &str) -> std::result::Result, String> { + // A Windows drive separator belongs to the file, not the artifact's suffix fields. + // Recognize it on every host so parsing does not depend on the current platform. + let unprefixed = path.strip_prefix(r"\\?\").unwrap_or(path); + let drive_prefix_len = match unprefixed.as_bytes() { + [drive, b':', b'/' | b'\\', ..] if drive.is_ascii_alphabetic() => { + path.len() - unprefixed.len() + 2 + } + _ => 0, + }; + let mut parts = path[drive_prefix_len..].split(':'); + + let mut file = None; + let mut contract_name = None; + let mut version = None; + let mut profile = None; + + let path_or_name = parts.next().unwrap(); + let path_or_name = &path[..drive_prefix_len + path_or_name.len()]; + if path_or_name.contains('.') { + file = Some(PathBuf::from(path_or_name)); + if let Some(name_or_version_or_profile) = parts.next() { + if name_or_version_or_profile.contains('.') + || Version::parse(name_or_version_or_profile).is_ok() + { + version = Some(name_or_version_or_profile); + } else { + contract_name = Some(name_or_version_or_profile); + if let Some(version_or_profile) = parts.next() { + if version_or_profile.contains('.') + || Version::parse(version_or_profile).is_ok() + { + version = Some(version_or_profile); + } else { + profile = Some(version_or_profile); + } + } + } + } + } else { + contract_name = Some(path_or_name); + if let Some(version_or_profile) = parts.next() { + if version_or_profile.contains('.') || Version::parse(version_or_profile).is_ok() { + version = Some(version_or_profile); + } else { + profile = Some(version_or_profile); + } + } + } + + let version = if let Some(version) = version { + Some(Version::parse(version).map_err(|e| format!("failed parsing version: {e}"))?) + } else { + None + }; + + Ok(ParsedArtifactPath { file, contract_name, version, profile }) +} + impl Cheatcode for existsCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { path } = self; let path = state.config.ensure_path_allowed(path, FsAccessKind::Read)?; Ok(path.exists().abi_encode()) @@ -34,7 +116,7 @@ impl Cheatcode for existsCall { } impl Cheatcode for fsMetadataCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { path } = self; let path = state.config.ensure_path_allowed(path, FsAccessKind::Read)?; @@ -60,7 +142,7 @@ impl Cheatcode for fsMetadataCall { } impl Cheatcode for isDirCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { path } = self; let path = state.config.ensure_path_allowed(path, FsAccessKind::Read)?; Ok(path.is_dir().abi_encode()) @@ -68,7 +150,7 @@ impl Cheatcode for isDirCall { } impl Cheatcode for isFileCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { path } = self; let path = state.config.ensure_path_allowed(path, FsAccessKind::Read)?; Ok(path.is_file().abi_encode()) @@ -76,14 +158,27 @@ impl Cheatcode for isFileCall { } impl Cheatcode for projectRootCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self {} = self; Ok(state.config.root.display().to_string().abi_encode()) } } +impl Cheatcode for currentFilePathCall { + fn apply(&self, state: &mut Cheatcodes) -> Result { + let Self {} = self; + let artifact = state + .config + .running_artifact + .as_ref() + .ok_or_else(|| fmt_err!("no running contract found"))?; + let relative = artifact.source.strip_prefix(&state.config.root).unwrap_or(&artifact.source); + Ok(relative.display().to_string().abi_encode()) + } +} + impl Cheatcode for unixTimeCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self {} = self; let difference = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -93,7 +188,7 @@ impl Cheatcode for unixTimeCall { } impl Cheatcode for closeFileCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { path } = self; let path = state.config.ensure_path_allowed(path, FsAccessKind::Read)?; @@ -104,7 +199,7 @@ impl Cheatcode for closeFileCall { } impl Cheatcode for copyFileCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { from, to } = self; let from = state.config.ensure_path_allowed(from, FsAccessKind::Read)?; let to = state.config.ensure_path_allowed(to, FsAccessKind::Write)?; @@ -116,7 +211,7 @@ impl Cheatcode for copyFileCall { } impl Cheatcode for createDirCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { path, recursive } = self; let path = state.config.ensure_path_allowed(path, FsAccessKind::Write)?; if *recursive { fs::create_dir_all(path) } else { fs::create_dir(path) }?; @@ -125,44 +220,44 @@ impl Cheatcode for createDirCall { } impl Cheatcode for readDir_0Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { path } = self; read_dir(state, path.as_ref(), 1, false) } } impl Cheatcode for readDir_1Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { path, maxDepth } = self; read_dir(state, path.as_ref(), *maxDepth, false) } } impl Cheatcode for readDir_2Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { path, maxDepth, followLinks } = self; read_dir(state, path.as_ref(), *maxDepth, *followLinks) } } impl Cheatcode for readFileCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { path } = self; let path = state.config.ensure_path_allowed(path, FsAccessKind::Read)?; - Ok(fs::read_to_string(path)?.abi_encode()) + Ok(fs::locked_read_to_string(path)?.abi_encode()) } } impl Cheatcode for readFileBinaryCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { path } = self; let path = state.config.ensure_path_allowed(path, FsAccessKind::Read)?; - Ok(fs::read(path)?.abi_encode()) + Ok(fs::locked_read(path)?.abi_encode()) } } impl Cheatcode for readLineCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { path } = self; let path = state.config.ensure_path_allowed(path, FsAccessKind::Read)?; @@ -188,25 +283,27 @@ impl Cheatcode for readLineCall { } impl Cheatcode for readLinkCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { linkPath: path } = self; - let path = state.config.ensure_path_allowed(path, FsAccessKind::Read)?; - let target = fs::read_link(path)?; + // Validate the resolved target, but keep the link itself for read_link. + state.config.ensure_path_allowed(path, FsAccessKind::Read)?; + let target = fs::read_link(state.config.root.join(path))?; Ok(target.display().to_string().abi_encode()) } } impl Cheatcode for removeDirCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { path, recursive } = self; let path = state.config.ensure_path_allowed(path, FsAccessKind::Write)?; + state.config.ensure_not_foundry_toml(&path)?; if *recursive { fs::remove_dir_all(path) } else { fs::remove_dir(path) }?; Ok(Default::default()) } } impl Cheatcode for removeFileCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { path } = self; let path = state.config.ensure_path_allowed(path, FsAccessKind::Write)?; state.config.ensure_not_foundry_toml(&path)?; @@ -223,29 +320,27 @@ impl Cheatcode for removeFileCall { } impl Cheatcode for writeFileCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { path, data } = self; write_file(state, path.as_ref(), data.as_bytes()) } } impl Cheatcode for writeFileBinaryCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { path, data } = self; write_file(state, path.as_ref(), data) } } impl Cheatcode for writeLineCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { path, data: line } = self; let path = state.config.ensure_path_allowed(path, FsAccessKind::Write)?; state.config.ensure_not_foundry_toml(&path)?; if state.fs_commit { - let mut file = std::fs::OpenOptions::new().append(true).create(true).open(path)?; - - writeln!(file, "{line}")?; + fs::locked_write_line(path, line)?; } Ok(Default::default()) @@ -253,7 +348,7 @@ impl Cheatcode for writeLineCall { } impl Cheatcode for getArtifactPathByCodeCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { code } = self; let (artifact_id, _) = state .config @@ -267,7 +362,7 @@ impl Cheatcode for getArtifactPathByCodeCall { } impl Cheatcode for getArtifactPathByDeployedCodeCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { deployedCode } = self; let (artifact_id, _) = state .config @@ -281,70 +376,123 @@ impl Cheatcode for getArtifactPathByDeployedCodeCall { } impl Cheatcode for getCodeCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { artifactPath: path } = self; Ok(get_artifact_code(state, path, false)?.abi_encode()) } } impl Cheatcode for getDeployedCodeCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { artifactPath: path } = self; Ok(get_artifact_code(state, path, true)?.abi_encode()) } } +impl Cheatcode for getSelectorsCall { + fn apply(&self, state: &mut Cheatcodes) -> Result { + let Self { artifactPath: path } = self; + let selectors: Vec> = match get_artifact_source(state, path)? { + ArtifactSource::InMemory(data) => data.abi.functions().map(|f| f.selector()).collect(), + ArtifactSource::Disk(path) => { + let data = read_artifact_file(state, &path)?; + // Parse as raw JSON rather than `ContractObject` so we can still read selectors + // from artifacts with unlinked bytecode (which `ContractObject` rejects). + let json: serde_json::Value = serde_json::from_str(&data)?; + let abi = + json.get("abi").ok_or_else(|| fmt_err!("no `abi` field in artifact JSON"))?; + let abi: alloy_json_abi::JsonAbi = + serde_json::from_value(abi.clone()).map_err(|e| fmt_err!("{e}"))?; + abi.functions().map(|f| f.selector()).collect() + } + }; + Ok(selectors.abi_encode()) + } +} + impl Cheatcode for deployCode_0Call { - fn apply_full(&self, ccx: &mut CheatsCtxt, executor: &mut dyn CheatcodesExecutor) -> Result { + fn apply_full( + &self, + ccx: &mut CheatsCtxt<'_, '_, FEN>, + executor: &mut dyn CheatcodesExecutor, + ) -> Result { let Self { artifactPath: path } = self; deploy_code(ccx, executor, path, None, None, None) } } impl Cheatcode for deployCode_1Call { - fn apply_full(&self, ccx: &mut CheatsCtxt, executor: &mut dyn CheatcodesExecutor) -> Result { + fn apply_full( + &self, + ccx: &mut CheatsCtxt<'_, '_, FEN>, + executor: &mut dyn CheatcodesExecutor, + ) -> Result { let Self { artifactPath: path, constructorArgs: args } = self; deploy_code(ccx, executor, path, Some(args), None, None) } } impl Cheatcode for deployCode_2Call { - fn apply_full(&self, ccx: &mut CheatsCtxt, executor: &mut dyn CheatcodesExecutor) -> Result { + fn apply_full( + &self, + ccx: &mut CheatsCtxt<'_, '_, FEN>, + executor: &mut dyn CheatcodesExecutor, + ) -> Result { let Self { artifactPath: path, value } = self; deploy_code(ccx, executor, path, None, Some(*value), None) } } impl Cheatcode for deployCode_3Call { - fn apply_full(&self, ccx: &mut CheatsCtxt, executor: &mut dyn CheatcodesExecutor) -> Result { + fn apply_full( + &self, + ccx: &mut CheatsCtxt<'_, '_, FEN>, + executor: &mut dyn CheatcodesExecutor, + ) -> Result { let Self { artifactPath: path, constructorArgs: args, value } = self; deploy_code(ccx, executor, path, Some(args), Some(*value), None) } } impl Cheatcode for deployCode_4Call { - fn apply_full(&self, ccx: &mut CheatsCtxt, executor: &mut dyn CheatcodesExecutor) -> Result { + fn apply_full( + &self, + ccx: &mut CheatsCtxt<'_, '_, FEN>, + executor: &mut dyn CheatcodesExecutor, + ) -> Result { let Self { artifactPath: path, salt } = self; deploy_code(ccx, executor, path, None, None, Some((*salt).into())) } } impl Cheatcode for deployCode_5Call { - fn apply_full(&self, ccx: &mut CheatsCtxt, executor: &mut dyn CheatcodesExecutor) -> Result { + fn apply_full( + &self, + ccx: &mut CheatsCtxt<'_, '_, FEN>, + executor: &mut dyn CheatcodesExecutor, + ) -> Result { let Self { artifactPath: path, constructorArgs: args, salt } = self; deploy_code(ccx, executor, path, Some(args), None, Some((*salt).into())) } } impl Cheatcode for deployCode_6Call { - fn apply_full(&self, ccx: &mut CheatsCtxt, executor: &mut dyn CheatcodesExecutor) -> Result { + fn apply_full( + &self, + ccx: &mut CheatsCtxt<'_, '_, FEN>, + executor: &mut dyn CheatcodesExecutor, + ) -> Result { let Self { artifactPath: path, value, salt } = self; deploy_code(ccx, executor, path, None, Some(*value), Some((*salt).into())) } } impl Cheatcode for deployCode_7Call { - fn apply_full(&self, ccx: &mut CheatsCtxt, executor: &mut dyn CheatcodesExecutor) -> Result { + fn apply_full( + &self, + ccx: &mut CheatsCtxt<'_, '_, FEN>, + executor: &mut dyn CheatcodesExecutor, + ) -> Result { let Self { artifactPath: path, constructorArgs: args, value, salt } = self; deploy_code(ccx, executor, path, Some(args), Some(*value), Some((*salt).into())) } @@ -352,15 +500,32 @@ impl Cheatcode for deployCode_7Call { /// Helper function to deploy contract from artifact code. /// Uses CREATE2 scheme if salt specified. -fn deploy_code( - ccx: &mut CheatsCtxt, - executor: &mut dyn CheatcodesExecutor, +fn deploy_code( + ccx: &mut CheatsCtxt<'_, '_, FEN>, + executor: &mut dyn CheatcodesExecutor, path: &str, constructor_args: Option<&Bytes>, value: Option, salt: Option, ) -> Result { + // Synthetic creation bypasses the CREATE opcode's static-context check. + if ccx.is_static { + return Err(crate::Error::from(Bytes::new())); + } + let mut bytecode = get_artifact_code(ccx.state, path, false)?.to_vec(); + + let depth = ccx.depth(); + + // Broadcast the synthetic create only if it was requested by the broadcaster at the broadcast + // depth, as for native creates. + if let Some(broadcast) = &mut ccx.state.broadcast + && depth == broadcast.depth + && ccx.caller == broadcast.original_caller + { + broadcast.deploy_from_code = true; + } + if let Some(args) = constructor_args { bytecode.extend_from_slice(args); } @@ -368,16 +533,59 @@ fn deploy_code( let scheme = if let Some(salt) = salt { CreateScheme::Create2 { salt } } else { CreateScheme::Create }; - let outcome = executor.exec_create( - CreateInputs { - caller: ccx.caller, + // The nested EVM executes the synthetic create one level deeper, so apply the prank at the + // original depth just as the native create inspector would. + let mut caller = ccx.caller; + if let Some(prank) = ccx.state.get_prank(depth) + && let Some(changes) = prank.changes_for(depth, caller) + { + if let Some(new_caller) = changes.caller { + caller = new_caller; + } + if let Some(new_origin) = changes.origin { + ccx.ecx.tx_mut().set_caller(new_origin); + } + if let Some(used) = changes.used { + ccx.state.pranks.insert(depth, used); + } + } + + ccx.state.deploy_code_depth = Some(depth); + let outcome = exec_create( + executor, + CreateInputs::new( + caller, scheme, - value: value.unwrap_or(U256::ZERO), - init_code: bytecode.into(), - gas_limit: ccx.gas_limit, - }, + value.unwrap_or(U256::ZERO), + bytecode.into(), + ccx.gas_limit, + 0, + ), ccx, - )?; + ); + ccx.state.deploy_code_depth = None; + + // Clear the flag in case the synthetic create was not broadcast, and end a single-call + // broadcast at the original depth as native create cleanup would. + if let Some(broadcast) = &mut ccx.state.broadcast { + broadcast.deploy_from_code = false; + if broadcast.single_call && depth == broadcast.depth { + ccx.ecx.tx_mut().set_caller(broadcast.original_origin); + ccx.state.broadcast = None; + } + } + + // Restore the prank state at the original depth as native create cleanup would. + if let Some(prank) = ccx.state.get_prank(depth).copied() + && depth == prank.depth + { + ccx.ecx.tx_mut().set_caller(prank.prank_origin); + if prank.single_call { + ccx.state.pranks.remove(&depth); + } + } + + let outcome = outcome?; if !outcome.result.result.is_ok() { return Err(crate::Error::from(outcome.result.output)); @@ -388,53 +596,92 @@ fn deploy_code( Ok(address.abi_encode()) } -/// Returns the path to the json artifact depending on the input +/// Resolved location of an artifact referenced by a cheatcode path argument. +enum ArtifactSource<'a> { + /// The artifact was matched in the in-memory `available_artifacts` list. + InMemory(&'a ContractData), + /// The artifact must be read from the given path on disk. + Disk(PathBuf), +} + +/// Resolves a cheatcode artifact reference to its source. /// -/// Can parse following input formats: +/// Can parse the following input formats: /// - `path/to/artifact.json` /// - `path/to/contract.sol` /// - `path/to/contract.sol:ContractName` /// - `path/to/contract.sol:ContractName:0.8.23` +/// - `path/to/contract.sol:ContractName:profile` /// - `path/to/contract.sol:0.8.23` +/// - `path/to/contract.sol:profile` /// - `ContractName` /// - `ContractName:0.8.23` -fn get_artifact_code(state: &Cheatcodes, path: &str, deployed: bool) -> Result { - let path = if path.ends_with(".json") { - PathBuf::from(path) - } else { - let mut parts = path.split(':'); - - let mut file = None; - let mut contract_name = None; - let mut version = None; - - let path_or_name = parts.next().unwrap(); - if path_or_name.contains('.') { - file = Some(PathBuf::from(path_or_name)); - if let Some(name_or_version) = parts.next() { - if name_or_version.contains('.') { - version = Some(name_or_version); - } else { - contract_name = Some(name_or_version); - version = parts.next(); - } - } - } else { - contract_name = Some(path_or_name); - version = parts.next(); - } +/// - `ContractName:profile` +fn get_artifact_source<'a, FEN: FoundryEvmNetwork>( + state: &'a Cheatcodes, + path: &str, +) -> Result> { + if path.ends_with(".json") { + let path = state.config.ensure_path_allowed(path, FsAccessKind::Read)?; + return Ok(ArtifactSource::Disk(path)); + } - let version = if let Some(version) = version { - Some(Version::parse(version).map_err(|e| fmt_err!("failed parsing version: {e}"))?) + let artifacts = + state.config.available_artifacts.as_ref().or(state.config.artifact_lookup.as_ref()); + let resolve_source = |file: PathBuf| { + let cwd = state + .config + .running_artifact + .as_ref() + .and_then(|artifact| artifact.source.parent()) + .unwrap_or(&state.config.paths.root); + let relative_cwd = cwd.strip_prefix(&state.config.paths.root).unwrap_or(cwd); + let has_matching_remapping = state.config.paths.remappings.iter().any(|remapping| { + remapping.context.as_ref().is_none_or(|context| relative_cwd.starts_with(context)) + && file.strip_prefix(&remapping.name).is_ok() + }); + + if has_matching_remapping { + state.config.paths.resolve_library_import(cwd, &file).map_or(file, |resolved| { + resolved.strip_prefix(&state.config.paths.root).unwrap_or(&resolved).to_path_buf() + }) } else { - None - }; + file + } + }; + let exact_identifier = path + .rsplit_once(':') + .map(|(source, contract)| (resolve_source(PathBuf::from(source)), contract)) + .filter(|(source, contract)| { + artifacts.into_iter().flat_map(|artifacts| artifacts.iter()).any(|(id, _)| { + id.source == *source + && id.name.split('.').next().is_some_and(|name| name == *contract) + }) + }); - // Use available artifacts list if present - if let Some(artifacts) = &state.config.available_artifacts { - let filtered = artifacts + let parsed = match parse_artifact_path(path) { + Ok(parsed) => parsed, + Err(_) if exact_identifier.is_some() => { + ParsedArtifactPath { file: None, contract_name: None, version: None, profile: None } + } + Err(error) => return Err(fmt_err!("failed to parse artifact path: {error}")), + }; + let ParsedArtifactPath { file, contract_name, version, profile } = parsed; + let file = file.map(resolve_source); + + // Use the artifact lookup if present. + if let Some(artifacts) = artifacts { + let ambiguous_file_profile = + file.is_some() && version.is_none() && profile.is_none() && contract_name.is_some(); + let filter_artifacts = |treat_ambiguous_as_profile: bool| -> Vec<_> { + artifacts .iter() .filter(|(id, _)| { + if let Some((source, contract)) = &exact_identifier { + return id.source == *source + && id.name.split('.').next().is_some_and(|name| name == *contract); + } + // name might be in the form of "Counter.0.8.23" let id_name = id.name.split('.').next().unwrap(); @@ -443,11 +690,6 @@ fn get_artifact_code(state: &Cheatcodes, path: &str, deployed: bool) -> Result Result>(); - - let artifact = match &filtered[..] { - [] => Err(fmt_err!("no matching artifact found")), - [artifact] => Ok(*artifact), - filtered => { - let mut filtered = filtered.to_vec(); - // If we know the current script/test contract solc version, try to filter by it + .collect() + }; + + let mut filtered = filter_artifacts(false); + if filtered.is_empty() && ambiguous_file_profile { + filtered = filter_artifacts(true); + } + + let artifact = match &filtered[..] { + [] => None, + [artifact] => Some(Ok(*artifact)), + filtered => { + let mut filtered = filtered.to_vec(); + // If we know the current script/test contract solc version, try to filter by it + Some( state .config .running_artifact .as_ref() .and_then(|running| { - // Firstly filter by version - filtered.retain(|(id, _)| id.version == running.version); - - // Return artifact if only one matched - if filtered.len() == 1 { - return Some(filtered[0]); + // Only filter by running version if user did NOT specify a version + if exact_identifier.is_some() || version.is_none() { + filtered.retain(|(id, _)| id.version == running.version); + + // Return artifact if only one matched + if filtered.len() == 1 { + return Some(filtered[0]); + } } - // Try filtering by profile as well - filtered.retain(|(id, _)| id.profile == running.profile); + // Only filter by running profile if user did NOT specify a profile + if exact_identifier.is_some() || profile.is_none() { + filtered.retain(|(id, _)| id.profile == running.profile); - if filtered.len() == 1 { Some(filtered[0]) } else { None } - }) - .ok_or_else(|| fmt_err!("multiple matching artifacts found")) - } - }?; + return (filtered.len() == 1).then(|| filtered[0]); + } - let maybe_bytecode = if deployed { - artifact.1.deployed_bytecode().cloned() - } else { - artifact.1.bytecode().cloned() - }; + None + }) + .ok_or_else(|| fmt_err!("multiple matching artifacts found")), + ) + } + }; - return maybe_bytecode - .ok_or_else(|| fmt_err!("no bytecode for contract; is it abstract or unlinked?")); - } else { - let path_in_artifacts = - match (file.map(|f| f.to_string_lossy().to_string()), contract_name) { - (Some(file), Some(contract_name)) => { - PathBuf::from(format!("{file}/{contract_name}.json")) - } - (None, Some(contract_name)) => { - PathBuf::from(format!("{contract_name}.sol/{contract_name}.json")) - } - (Some(file), None) => { - let name = file.replace(".sol", ""); - PathBuf::from(format!("{file}/{name}.json")) - } - _ => bail!("invalid artifact path"), - }; + if let Some(artifact) = artifact { + return Ok(ArtifactSource::InMemory(artifact?.1)); + } + } - state.config.paths.artifacts.join(path_in_artifacts) + // Fallback: construct path manually when no artifacts list or no match found + let path_in_artifacts = match (file.map(|f| f.to_string_lossy().to_string()), contract_name) { + (Some(file), Some(contract_name)) => PathBuf::from(format!("{file}/{contract_name}.json")), + (None, Some(contract_name)) => { + PathBuf::from(format!("{contract_name}.sol/{contract_name}.json")) + } + (Some(file), None) => { + let name = file.replace(".sol", ""); + PathBuf::from(format!("{file}/{name}.json")) } + _ => bail!("invalid artifact path"), }; + let path = state.config.paths.artifacts.join(path_in_artifacts); let path = state.config.ensure_path_allowed(path, FsAccessKind::Read)?; - let data = fs::read_to_string(path)?; - let artifact = serde_json::from_str::(&data)?; - let maybe_bytecode = if deployed { artifact.deployed_bytecode } else { artifact.bytecode }; + Ok(ArtifactSource::Disk(path)) +} + +/// Reads an artifact JSON file, mapping I/O errors to a helpful message when the +/// lookup fell through the in-memory artifacts list. +fn read_artifact_file( + state: &Cheatcodes, + path: &Path, +) -> Result { + fs::read_to_string(path).map_err(|e| { + if state.config.available_artifacts.is_some() { + fmt_err!("no matching artifact found") + } else { + e.into() + } + }) +} + +/// Returns the bytecode from a JSON artifact file. +/// +/// See [`get_artifact_source`] for the supported path formats. +/// +/// This function is safe to use with contracts that have library dependencies. +/// `alloy_json_abi::ContractObject` validates bytecode during JSON parsing and will +/// reject artifacts with unlinked library placeholders. +fn get_artifact_code( + state: &Cheatcodes, + path: &str, + deployed: bool, +) -> Result { + let maybe_bytecode = match get_artifact_source(state, path)? { + ArtifactSource::InMemory(data) => { + if deployed { data.deployed_bytecode() } else { data.bytecode() }.cloned() + } + ArtifactSource::Disk(path) => { + let data = read_artifact_file(state, &path)?; + let artifact = serde_json::from_str::(&data)?; + if deployed { artifact.deployed_bytecode } else { artifact.bytecode } + } + }; maybe_bytecode.ok_or_else(|| fmt_err!("no bytecode for contract; is it abstract or unlinked?")) } impl Cheatcode for ffiCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { commandInput: input } = self; + let stdout = ffi_stdout(state, input)?; + Ok(decode_ffi_stdout(&stdout).abi_encode()) + } +} - let output = ffi(state, input)?; - // TODO: check exit code? - if !output.stderr.is_empty() { - let stderr = String::from_utf8_lossy(&output.stderr); - error!(target: "cheatcodes", ?input, ?stderr, "non-empty stderr"); - } - // we already hex-decoded the stdout in `ffi` - Ok(output.stdout.abi_encode()) +impl Cheatcode for ffiUintCall { + fn apply(&self, state: &mut Cheatcodes) -> Result { + let Self { commandInput: input } = self; + parse(&ffi_stdout(state, input)?, &DynSolType::Uint(256)) + } +} + +impl Cheatcode for ffiStringCall { + fn apply(&self, state: &mut Cheatcodes) -> Result { + let Self { commandInput: input } = self; + Ok(ffi_stdout(state, input)?.abi_encode()) + } +} + +impl Cheatcode for ffiBytesCall { + fn apply(&self, state: &mut Cheatcodes) -> Result { + let Self { commandInput: input } = self; + let stdout = ffi_stdout(state, input)?; + Ok(hex::decode(&stdout) + .map_err(|err| fmt_err!("failed parsing ffi stdout as bytes: {err}"))? + .abi_encode()) } } impl Cheatcode for tryFfiCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { commandInput: input } = self; ffi(state, input).map(|res| res.abi_encode()) } } impl Cheatcode for promptCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { promptText: text } = self; prompt(state, text, prompt_input).map(|res| res.abi_encode()) } } impl Cheatcode for promptSecretCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { promptText: text } = self; prompt(state, text, prompt_password).map(|res| res.abi_encode()) } } impl Cheatcode for promptSecretUintCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { promptText: text } = self; parse(&prompt(state, text, prompt_password)?, &DynSolType::Uint(256)) } } impl Cheatcode for promptAddressCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { promptText: text } = self; parse(&prompt(state, text, prompt_input)?, &DynSolType::Address) } } impl Cheatcode for promptUintCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { promptText: text } = self; parse(&prompt(state, text, prompt_input)?, &DynSolType::Uint(256)) } } -pub(super) fn write_file(state: &Cheatcodes, path: &Path, contents: &[u8]) -> Result { +pub(super) fn write_file( + state: &Cheatcodes, + path: &Path, + contents: &[u8], +) -> Result { let path = state.config.ensure_path_allowed(path, FsAccessKind::Write)?; // write access to foundry.toml is not allowed state.config.ensure_not_foundry_toml(&path)?; if state.fs_commit { - fs::write(path, contents)?; + fs::locked_write(path, contents)?; } Ok(Default::default()) } -fn read_dir(state: &Cheatcodes, path: &Path, max_depth: u64, follow_links: bool) -> Result { +fn read_dir( + state: &Cheatcodes, + path: &Path, + max_depth: u64, + follow_links: bool, +) -> Result { let root = state.config.ensure_path_allowed(path, FsAccessKind::Read)?; let paths: Vec = WalkDir::new(root) .min_depth(1) @@ -621,7 +944,44 @@ fn read_dir(state: &Cheatcodes, path: &Path, max_depth: u64, follow_links: bool) Ok(paths.abi_encode()) } -fn ffi(state: &Cheatcodes, input: &[String]) -> Result { +fn ffi(state: &Cheatcodes, input: &[String]) -> Result { + let output = ffi_command(state, input)?; + + let stdout = String::from_utf8(output.stdout)?; + let stdout = stdout.trim(); + Ok(FfiResult { + exitCode: output.status.code().unwrap_or(69), + stdout: decode_ffi_stdout(stdout), + stderr: output.stderr.into(), + }) +} + +fn ffi_stdout(state: &Cheatcodes, input: &[String]) -> Result { + let output = ffi_command(state, input)?; + let stdout = String::from_utf8(output.stdout)?; + if !output.status.success() { + return Err(fmt_err!( + "ffi command {:?} exited with code {}. stderr: {}", + input, + output.status.code().unwrap_or(69), + String::from_utf8_lossy(&output.stderr) + )); + } + if !output.stderr.is_empty() { + let stderr = String::from_utf8_lossy(&output.stderr); + warn!(target: "cheatcodes", ?input, ?stderr, "ffi command wrote to stderr"); + } + Ok(stdout.trim().to_string()) +} + +fn decode_ffi_stdout(stdout: &str) -> Bytes { + hex::decode(stdout).unwrap_or_else(|_| stdout.as_bytes().to_vec()).into() +} + +fn ffi_command( + state: &Cheatcodes, + input: &[String], +) -> Result { ensure!( state.config.ffi, "FFI is disabled; add the `--ffi` flag to allow tests to call external commands" @@ -632,25 +992,9 @@ fn ffi(state: &Cheatcodes, input: &[String]) -> Result { debug!(target: "cheatcodes", ?cmd, "invoking ffi"); - let output = cmd - .current_dir(&state.config.root) + cmd.current_dir(&state.config.root) .output() - .map_err(|err| fmt_err!("failed to execute command {cmd:?}: {err}"))?; - - // The stdout might be encoded on valid hex, or it might just be a string, - // so we need to determine which it is to avoid improperly encoding later. - let trimmed_stdout = String::from_utf8(output.stdout)?; - let trimmed_stdout = trimmed_stdout.trim(); - let encoded_stdout = if let Ok(hex) = hex::decode(trimmed_stdout) { - hex - } else { - trimmed_stdout.as_bytes().to_vec() - }; - Ok(FfiResult { - exitCode: output.status.code().unwrap_or(69), - stdout: encoded_stdout.into(), - stderr: output.stderr.into(), - }) + .map_err(|err| fmt_err!("failed to execute command {cmd:?}: {err}")) } fn prompt_input(prompt_text: &str) -> Result { @@ -661,8 +1005,8 @@ fn prompt_password(prompt_text: &str) -> Result { Password::new().with_prompt(prompt_text).interact() } -fn prompt( - state: &Cheatcodes, +fn prompt( + state: &Cheatcodes, prompt_text: &str, input: fn(&str) -> Result, ) -> Result { @@ -687,10 +1031,10 @@ fn prompt( } impl Cheatcode for getBroadcastCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { contractName, chainId, txType } = self; - let latest_broadcast = latest_broadcast( + let latest_broadcast = latest_broadcast::<::Network>( contractName, *chainId, &state.config.broadcast, @@ -702,13 +1046,13 @@ impl Cheatcode for getBroadcastCall { } impl Cheatcode for getBroadcasts_0Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { contractName, chainId, txType } = self; let reader = BroadcastReader::new(contractName.clone(), *chainId, &state.config.broadcast)? .with_tx_type(map_broadcast_tx_type(*txType)); - let broadcasts = reader.read()?; + let broadcasts = reader.read::<::Network>()?; let summaries = broadcasts .into_iter() @@ -723,12 +1067,12 @@ impl Cheatcode for getBroadcasts_0Call { } impl Cheatcode for getBroadcasts_1Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { contractName, chainId } = self; let reader = BroadcastReader::new(contractName.clone(), *chainId, &state.config.broadcast)?; - let broadcasts = reader.read()?; + let broadcasts = reader.read::<::Network>()?; let summaries = broadcasts .into_iter() @@ -743,11 +1087,11 @@ impl Cheatcode for getBroadcasts_1Call { } impl Cheatcode for getDeployment_0Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { contractName } = self; - let chain_id = ccx.ecx.cfg.chain_id; + let chain_id = ccx.chain_id(); - let latest_broadcast = latest_broadcast( + let latest_broadcast = latest_broadcast::<::Network>( contractName, chain_id, &ccx.state.config.broadcast, @@ -759,10 +1103,10 @@ impl Cheatcode for getDeployment_0Call { } impl Cheatcode for getDeployment_1Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { contractName, chainId } = self; - let latest_broadcast = latest_broadcast( + let latest_broadcast = latest_broadcast::<::Network>( contractName, *chainId, &state.config.broadcast, @@ -774,14 +1118,14 @@ impl Cheatcode for getDeployment_1Call { } impl Cheatcode for getDeploymentsCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { contractName, chainId } = self; let reader = BroadcastReader::new(contractName.clone(), *chainId, &state.config.broadcast)? .with_tx_type(CallKind::Create) .with_tx_type(CallKind::Create2); - let broadcasts = reader.read()?; + let broadcasts = reader.read::<::Network>()?; let summaries = broadcasts .into_iter() @@ -807,15 +1151,15 @@ fn map_broadcast_tx_type(tx_type: BroadcastTxType) -> CallKind { } } -fn parse_broadcast_results( - results: Vec<(TransactionWithMetadata, AnyTransactionReceipt)>, +fn parse_broadcast_results( + results: Vec<(TransactionWithMetadata, N::ReceiptResponse)>, ) -> Vec { results .into_iter() .map(|(tx, receipt)| BroadcastTxSummary { - txHash: receipt.transaction_hash, - blockNumber: receipt.block_number.unwrap_or_default(), - txType: match tx.opcode { + txHash: receipt.transaction_hash(), + blockNumber: receipt.block_number().unwrap_or_default(), + txType: match tx.call_kind { CallKind::Call => BroadcastTxType::Call, CallKind::Create => BroadcastTxType::Create, CallKind::Create2 => BroadcastTxType::Create2, @@ -827,19 +1171,22 @@ fn parse_broadcast_results( .collect() } -fn latest_broadcast( +fn latest_broadcast( contract_name: &String, chain_id: u64, broadcast_path: &Path, filters: Vec, -) -> Result { +) -> Result +where + N::TxEnvelope: for<'d> serde::Deserialize<'d>, +{ let mut reader = BroadcastReader::new(contract_name.clone(), chain_id, broadcast_path)?; for filter in filters { reader = reader.with_tx_type(filter); } - let broadcast = reader.read_latest()?; + let broadcast = reader.read_latest::()?; let results = reader.into_tx_receipts(broadcast); @@ -855,7 +1202,17 @@ fn latest_broadcast( mod tests { use super::*; use crate::CheatsConfig; - use std::sync::Arc; + use alloy_primitives::{Bloom, address, b256}; + use foundry_common::ContractsByArtifact; + use foundry_compilers::{ + ArtifactId, + artifacts::{ + BytecodeObject, CompactBytecode, CompactContractBytecode, remappings::Remapping, + }, + }; + use std::{env, fs as stdfs, str::FromStr, sync::Arc}; + use tempfile::TempDir; + use tempo_alloy::TempoNetwork; fn cheats() -> Cheatcodes { let config = CheatsConfig { @@ -884,6 +1241,29 @@ mod tests { assert_eq!(output.stdout, Bytes::from(msg.as_bytes())); } + #[test] + fn test_ffi_fails_on_error_code() { + let mut cheats = cheats(); + + // Use a command that is guaranteed to fail with a non-zero exit code on any platform. + #[cfg(unix)] + let args = vec!["false".to_string()]; + #[cfg(windows)] + let args = vec!["cmd".to_string(), "/c".to_string(), "exit 1".to_string()]; + + let result = Cheatcode::apply(&ffiCall { commandInput: args }, &mut cheats); + + // Assert that the cheatcode returned an error. + assert!(result.is_err(), "Expected ffi cheatcode to fail, but it succeeded"); + + // Assert that the error message contains the expected information. + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("exited with code 1"), + "Error message did not contain exit code: {err_msg}" + ); + } + #[test] fn test_artifact_parsing() { let s = include_str!("../../evm/test-data/solc-obj.json"); @@ -893,4 +1273,458 @@ mod tests { let artifact: ContractObject = serde_json::from_str(s).unwrap(); assert!(artifact.deployed_bytecode.is_some()); } + + #[test] + fn test_alloy_json_abi_rejects_unlinked_bytecode() { + let artifact_json = r#"{ + "abi": [], + "bytecode": "0x73__$987e73aeca5e61ce83e4cb0814d87beda9$__63baf2f868" + }"#; + + let result: Result = serde_json::from_str(artifact_json); + assert!(result.is_err(), "should reject unlinked bytecode with placeholders"); + let err = result.unwrap_err().to_string(); + assert!(err.contains("expected bytecode, found unlinked bytecode with placeholder")); + } + + #[test] + fn test_parse_artifact_path_file_only() { + let parsed = super::parse_artifact_path("path/to/Contract.sol").unwrap(); + assert_eq!(parsed.file, Some(PathBuf::from("path/to/Contract.sol"))); + assert_eq!(parsed.contract_name, None); + assert_eq!(parsed.version, None); + assert_eq!(parsed.profile, None); + } + + #[test] + fn test_parse_artifact_path_windows_drive() { + for file in [ + "C:/project/src/Contract.sol", + r"C:\project\src\Contract.sol", + r"\\?\C:\project\src\Contract.sol", + r"\\server\share\Contract.sol", + ] { + for (suffix, contract_name, version, profile) in [ + ("", None, None, None), + (":MyContract", Some("MyContract"), None, None), + (":0.8.23", None, Some(Version::new(0, 8, 23)), None), + (":MyContract:0.8.23", Some("MyContract"), Some(Version::new(0, 8, 23)), None), + (":MyContract:optimized", Some("MyContract"), None, Some("optimized")), + ] { + let input = format!("{file}{suffix}"); + assert_eq!( + parse_artifact_path(&input).unwrap(), + ParsedArtifactPath { + file: Some(PathBuf::from(file)), + contract_name, + version, + profile, + }, + "{input}" + ); + } + } + + // A one-letter contract name must still allow version and profile suffixes. + assert_eq!(parse_artifact_path("C:0.8.23").unwrap().version, Some(Version::new(0, 8, 23))); + assert_eq!(parse_artifact_path("C:optimized").unwrap().profile, Some("optimized")); + } + + #[test] + fn test_parse_artifact_path_file_and_contract() { + let parsed = super::parse_artifact_path("path/to/Contract.sol:MyContract").unwrap(); + assert_eq!(parsed.file, Some(PathBuf::from("path/to/Contract.sol"))); + assert_eq!(parsed.contract_name, Some("MyContract")); + assert_eq!(parsed.version, None); + assert_eq!(parsed.profile, None); + } + + #[test] + fn test_parse_artifact_path_file_contract_version() { + let parsed = super::parse_artifact_path("path/to/Contract.sol:MyContract:0.8.23").unwrap(); + assert_eq!(parsed.file, Some(PathBuf::from("path/to/Contract.sol"))); + assert_eq!(parsed.contract_name, Some("MyContract")); + assert_eq!(parsed.version, Some(semver::Version::new(0, 8, 23))); + assert_eq!(parsed.profile, None); + } + + #[test] + fn test_parse_artifact_path_file_contract_profile() { + let parsed = + super::parse_artifact_path("path/to/Contract.sol:MyContract:optimized").unwrap(); + assert_eq!(parsed.file, Some(PathBuf::from("path/to/Contract.sol"))); + assert_eq!(parsed.contract_name, Some("MyContract")); + assert_eq!(parsed.version, None); + assert_eq!(parsed.profile, Some("optimized")); + } + + #[test] + fn test_parse_artifact_path_file_and_version() { + let parsed = super::parse_artifact_path("path/to/Contract.sol:0.8.18").unwrap(); + assert_eq!(parsed.file, Some(PathBuf::from("path/to/Contract.sol"))); + assert_eq!(parsed.contract_name, None); + assert_eq!(parsed.version, Some(semver::Version::new(0, 8, 18))); + assert_eq!(parsed.profile, None); + } + + #[test] + fn test_parse_artifact_path_file_and_profile() { + // The parser keeps the two-part file form ambiguous. Artifact lookup can resolve this + // segment as a profile when no contract name matches. + let parsed = super::parse_artifact_path("Contract.sol:paris").unwrap(); + assert_eq!(parsed.file, Some(PathBuf::from("Contract.sol"))); + assert_eq!(parsed.contract_name, Some("paris")); + assert_eq!(parsed.version, None); + assert_eq!(parsed.profile, None); + } + + fn test_artifact( + source: &str, + name: &str, + profile: &str, + bytecode: Bytes, + ) -> (ArtifactId, CompactContractBytecode) { + ( + ArtifactId { + path: PathBuf::from(format!("{source}/{name}.json")), + name: name.to_owned(), + source: PathBuf::from(source), + version: Version::new(0, 8, 30), + build_id: String::new(), + profile: profile.to_owned(), + }, + CompactContractBytecode { + abi: Some(Default::default()), + bytecode: Some(CompactBytecode { + object: BytecodeObject::Bytecode(bytecode), + source_map: None, + link_references: Default::default(), + }), + deployed_bytecode: None, + }, + ) + } + + #[test] + fn test_get_artifact_code_resolves_file_profile_ambiguity() { + let default_bytecode = Bytes::from_static(&[0x60, 0x01]); + let paris_bytecode = Bytes::from_static(&[0x60, 0x02]); + let source = "src/GetCodeProfile.t.sol"; + let artifacts = ContractsByArtifact::new([ + test_artifact(source, "GetCodeProfile", "default", default_bytecode), + test_artifact(source, "GetCodeProfile", "paris", paris_bytecode.clone()), + ]); + let config = CheatsConfig { + available_artifacts: Some(artifacts), + root: PathBuf::from(&env!("CARGO_MANIFEST_DIR")), + ..Default::default() + }; + let cheats: Cheatcodes = Cheatcodes::new(Arc::new(config)); + + let bytecode = + super::get_artifact_code(&cheats, "src/GetCodeProfile.t.sol:paris", false).unwrap(); + + assert_eq!(bytecode, paris_bytecode); + } + + #[test] + fn test_get_artifact_code_exact_colon_path_uses_running_profile() { + let default_bytecode = Bytes::from_static(&[0x60, 0x01]); + let optimized_bytecode = Bytes::from_static(&[0x60, 0x02]); + let versioned_bytecode = Bytes::from_static(&[0x60, 0x03]); + let source = "src/Colon:Path.sol"; + let default = test_artifact(source, "Target", "default", default_bytecode); + let optimized = + test_artifact(source, "Target.optimized", "optimized", optimized_bytecode.clone()); + let mut versioned = test_artifact( + source, + "Target.0.8.29.optimized", + "optimized", + versioned_bytecode.clone(), + ); + versioned.0.version = Version::new(0, 8, 29); + let artifacts = ContractsByArtifact::new([default, optimized, versioned]); + for (patch, expected) in [(30, optimized_bytecode), (29, versioned_bytecode)] { + let running_artifact = ArtifactId { + source: PathBuf::from("test/Runner.t.sol"), + name: "Runner".to_owned(), + path: PathBuf::from("test/Runner.t.sol/Runner.json"), + version: Version::new(0, 8, patch), + build_id: String::new(), + profile: "optimized".to_owned(), + }; + let config = CheatsConfig { + available_artifacts: Some(artifacts.clone()), + running_artifact: Some(running_artifact), + root: PathBuf::from(&env!("CARGO_MANIFEST_DIR")), + ..Default::default() + }; + let cheats: Cheatcodes = Cheatcodes::new(Arc::new(config)); + let bytecode = + super::get_artifact_code(&cheats, "src/Colon:Path.sol:Target", false).unwrap(); + assert_eq!(bytecode, expected); + } + } + + #[test] + fn test_get_artifact_code_prefers_contract_name_over_file_profile_ambiguity() { + let profile_bytecode = Bytes::from_static(&[0x60, 0x02]); + let contract_bytecode = Bytes::from_static(&[0x60, 0x03]); + let source = "src/GetCodeProfile.t.sol"; + let artifacts = ContractsByArtifact::new([ + test_artifact(source, "GetCodeProfile", "paris", profile_bytecode), + test_artifact(source, "paris", "default", contract_bytecode.clone()), + ]); + let config = CheatsConfig { + available_artifacts: Some(artifacts), + root: PathBuf::from(&env!("CARGO_MANIFEST_DIR")), + ..Default::default() + }; + let cheats: Cheatcodes = Cheatcodes::new(Arc::new(config)); + + let bytecode = + super::get_artifact_code(&cheats, "src/GetCodeProfile.t.sol:paris", false).unwrap(); + + assert_eq!(bytecode, contract_bytecode); + } + + #[test] + fn test_get_artifact_code_resolves_remapping() { + let bytecode = Bytes::from_static(&[0x60, 0x01]); + let artifacts = ContractsByArtifact::new([test_artifact( + "src/Something.sol", + "Something", + "default", + bytecode.clone(), + )]); + let root = PathBuf::from(&env!("CARGO_MANIFEST_DIR")); + let paths = foundry_compilers::ProjectPathsConfig::builder() + .remapping(Remapping::from_str("@example/=src/").unwrap()) + .build_with_root(&root); + let config = CheatsConfig { + available_artifacts: Some(artifacts), + root, + paths, + ..Default::default() + }; + let cheats: Cheatcodes = Cheatcodes::new(Arc::new(config)); + + let resolved = + super::get_artifact_code(&cheats, "@example/Something.sol:Something", false).unwrap(); + + assert_eq!(resolved, bytecode); + } + + #[test] + fn test_get_artifact_code_remaps_exact_paths_before_profile_selection() { + for file in ["Target.sol", "Colon:Path.sol"] { + let source = format!("src/{file}"); + let remapped = format!("lib/alternate/{file}"); + let default_bytecode = Bytes::from_static(&[0x60, 0x02]); + let optimized_bytecode = Bytes::from_static(&[0x60, 0x03]); + let artifacts = ContractsByArtifact::new([ + test_artifact(&source, "Target", "default", Bytes::from_static(&[0x60, 0x01])), + test_artifact(&remapped, "Target", "default", default_bytecode.clone()), + test_artifact( + &remapped, + "Target.optimized", + "optimized", + optimized_bytecode.clone(), + ), + ]); + for (profile, expected) in + [("default", default_bytecode), ("optimized", optimized_bytecode)] + { + let root = PathBuf::from(&env!("CARGO_MANIFEST_DIR")); + let paths = foundry_compilers::ProjectPathsConfig::builder() + .remapping(Remapping::from_str("src/=lib/alternate/").unwrap()) + .build_with_root(&root); + let config = CheatsConfig { + available_artifacts: Some(artifacts.clone()), + running_artifact: Some( + test_artifact("test/Runner.t.sol", "Runner", profile, Bytes::new()).0, + ), + root, + paths, + ..Default::default() + }; + let cheats: Cheatcodes = Cheatcodes::new(Arc::new(config)); + let resolved = + super::get_artifact_code(&cheats, &format!("{source}:Target"), false).unwrap(); + assert_eq!(resolved, expected, "{source} in {profile} profile"); + } + } + } + + #[test] + fn test_get_artifact_code_preserves_project_path_on_library_collision() { + let root_bytecode = Bytes::from_static(&[0x60, 0x01]); + let library_bytecode = Bytes::from_static(&[0x60, 0x02]); + let artifacts = ContractsByArtifact::new([ + test_artifact("src/Thing.sol", "RootThing", "default", root_bytecode.clone()), + test_artifact("lib/src/Thing.sol", "LibThing", "default", library_bytecode), + ]); + let temp = TempDir::new().unwrap(); + let library = temp.path().join("lib"); + stdfs::create_dir_all(library.join("src")).unwrap(); + stdfs::write(library.join("src/Thing.sol"), "").unwrap(); + let paths = foundry_compilers::ProjectPathsConfig::builder() + .remappings([]) + .lib(library) + .build_with_root(temp.path()); + let config = CheatsConfig { + available_artifacts: Some(artifacts), + root: temp.path().to_path_buf(), + paths, + ..Default::default() + }; + let cheats: Cheatcodes = Cheatcodes::new(Arc::new(config)); + + let resolved = super::get_artifact_code(&cheats, "src/Thing.sol:RootThing", false).unwrap(); + + assert_eq!(resolved, root_bytecode); + } + + #[test] + fn test_parse_artifact_path_contract_only() { + let parsed = super::parse_artifact_path("MyContract").unwrap(); + assert_eq!(parsed.file, None); + assert_eq!(parsed.contract_name, Some("MyContract")); + assert_eq!(parsed.version, None); + assert_eq!(parsed.profile, None); + } + + #[test] + fn test_parse_artifact_path_contract_and_version() { + let parsed = super::parse_artifact_path("MyContract:0.8.23").unwrap(); + assert_eq!(parsed.file, None); + assert_eq!(parsed.contract_name, Some("MyContract")); + assert_eq!(parsed.version, Some(semver::Version::new(0, 8, 23))); + assert_eq!(parsed.profile, None); + } + + #[test] + fn test_parse_artifact_path_contract_and_profile() { + let parsed = super::parse_artifact_path("MyContract:optimized").unwrap(); + assert_eq!(parsed.file, None); + assert_eq!(parsed.contract_name, Some("MyContract")); + assert_eq!(parsed.version, None); + assert_eq!(parsed.profile, Some("optimized")); + } + + #[test] + fn test_parse_artifact_path_profile_names() { + // Test various profile name patterns + for profile in ["v1", "v2", "paris", "optimized", "default", "prod", "dev"] { + let path = format!("MyContract:{profile}"); + let parsed = super::parse_artifact_path(&path).unwrap(); + assert_eq!(parsed.contract_name, Some("MyContract")); + assert_eq!(parsed.profile, Some(profile)); + assert_eq!(parsed.version, None); + } + } + + #[test] + fn test_parse_artifact_path_invalid_version() { + // Invalid semver should be treated as profile + let parsed = super::parse_artifact_path("MyContract:invalid").unwrap(); + assert_eq!(parsed.contract_name, Some("MyContract")); + assert_eq!(parsed.profile, Some("invalid")); + assert_eq!(parsed.version, None); + } + + fn unique_temp_dir(prefix: &str) -> PathBuf { + env::temp_dir().join(format!( + "foundry-cheatcodes-{prefix}-{}", + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() + )) + } + + #[test] + fn test_latest_broadcast_reads_tempo_sequences() { + let root = unique_temp_dir("tempo-broadcast"); + let broadcast_path = root.join("broadcast"); + let sequence_dir = broadcast_path.join("Counter.s.sol").join("31337"); + stdfs::create_dir_all(&sequence_dir).unwrap(); + + let tx_hash = "0x04548a0ea27e2cccc1479af3c2ff02da4d4d3ea46af8e8d7edaa49f6ea27073f"; + let block_hash = "0x860f788b251ece768e63b0d3906d156f652d843848b71c7fe81faacd49139d66"; + let from = "0xa70ab0448e66cd77995bfbba5c5b64b41a85f3fd"; + let contract_address = "0x20c0000000000000000000000000000000000000"; + let zero_bloom = format!("{:#x}", Bloom::ZERO); + + let sequence = serde_json::json!({ + "transactions": [{ + "hash": tx_hash, + "transactionType": "CREATE", + "contractName": "Counter", + "contractAddress": contract_address, + "function": serde_json::Value::Null, + "arguments": serde_json::Value::Null, + "transaction": { + "type": "0x76", + "from": from, + "to": serde_json::Value::Null, + "data": "0x", + "value": "0x0", + "gas": "0x5208", + "nonce": "0x0", + "accessList": [], + "calls": [], + "nonceKey": "0x0", + "feePayerSignature": serde_json::Value::Null, + "validBefore": serde_json::Value::Null, + "validAfter": serde_json::Value::Null, + "keyAuthorization": serde_json::Value::Null, + "aaAuthorizationList": [] + }, + "additionalContracts": [], + "isFixedGasLimit": false + }], + "receipts": [{ + "type": "0x76", + "status": "0x1", + "cumulativeGasUsed": "0x5208", + "logs": [], + "logsBloom": zero_bloom, + "transactionHash": tx_hash, + "transactionIndex": "0x0", + "blockHash": block_hash, + "blockNumber": "0x7", + "gasUsed": "0x5208", + "effectiveGasPrice": "0x1", + "from": from, + "to": serde_json::Value::Null, + "contractAddress": contract_address, + "feePayer": from + }], + "libraries": [], + "pending": [], + "returns": {}, + "timestamp": 1, + "chain": 31337, + "commit": serde_json::Value::Null + }); + + fs::write_json_file(&sequence_dir.join("run-1.json"), &sequence).unwrap(); + + let latest = latest_broadcast::( + &"Counter".to_owned(), + 31337, + &broadcast_path, + vec![CallKind::Create], + ) + .unwrap(); + + assert_eq!( + latest.txHash, + b256!("04548a0ea27e2cccc1479af3c2ff02da4d4d3ea46af8e8d7edaa49f6ea27073f") + ); + assert_eq!(latest.blockNumber, 7); + assert!(matches!(latest.txType, BroadcastTxType::Create)); + assert_eq!(latest.contractAddress, address!("20c0000000000000000000000000000000000000")); + assert!(latest.success); + + stdfs::remove_dir_all(root).unwrap(); + } } diff --git a/crates/cheatcodes/src/inspector.rs b/crates/cheatcodes/src/inspector.rs index 276caeef59d66..4f0c4e19747cc 100644 --- a/crates/cheatcodes/src/inspector.rs +++ b/crates/cheatcodes/src/inspector.rs @@ -1,160 +1,172 @@ //! Cheatcode EVM inspector. use crate::{ - CheatsConfig, CheatsCtxt, DynCheatcode, Error, Result, + Cheatcode, CheatsConfig, CheatsCtxt, Error, Result, Vm::{self, AccountAccess}, evm::{ - DealRecord, GasRecord, RecordAccess, - mapping::{self, MappingSlots}, - mock::{MockCallDataContext, MockCallReturnData}, + DealRecord, GasRecord, RecordAccess, append_storage_access, journaled_account, + mark_account_accesses_reverted, merge_recorded_frame, + mock::{self, MockCallDataContext, MockCallReturnData}, prank::Prank, }, + expected_emit::{self, ExpectedEmitTracker}, inspector::utils::CommonCreateInput, script::{Broadcast, Wallets}, test::{ assume::AssumeNoRevert, - expect::{ - self, ExpectedCallData, ExpectedCallTracker, ExpectedCallType, ExpectedCreate, - ExpectedEmitTracker, ExpectedRevert, ExpectedRevertKind, - }, + expect::{self, ExpectedCallTracker, ExpectedCreate, ExpectedRevert, ExpectedRevertKind}, revert_handlers, }, utils::IgnoredTraces, }; -use alloy_consensus::BlobTransactionSidecar; -use alloy_evm::eth::EthEvmContext; -use alloy_network::TransactionBuilder4844; +use alloy_consensus::BlobTransactionSidecarVariant; +use alloy_network::{Ethereum, Network, TransactionBuilder}; use alloy_primitives::{ Address, B256, Bytes, Log, TxKind, U256, hex, map::{AddressHashMap, HashMap, HashSet}, }; -use alloy_rpc_types::{ - AccessList, - request::{TransactionInput, TransactionRequest}, +use alloy_rpc_types::AccessList; +use alloy_signer_local::PrivateKeySigner; +use alloy_sol_types::{SolCall, SolInterface}; +use foundry_common::{ + FoundryTransactionBuilder, SELECTOR_LEN, TransactionMaybeSigned, + mapping_slots::{ + MappingSlots, PendingMappingHash, capture_hash as capture_mapping_hash, + record_hash as record_mapping_hash, step as mapping_step, + }, }; -use alloy_sol_types::{SolCall, SolInterface, SolValue}; -use foundry_common::{SELECTOR_LEN, TransactionMaybeSigned, evm::Breakpoints}; use foundry_evm_core::{ - InspectorExt, + Breakpoints, EvmEnv, FoundryTransaction, InspectorExt, abi::Vm::stopExpectSafeMemoryCall, - backend::{DatabaseError, DatabaseExt, RevertDiagnostic}, + backend::{ + ContextUpdateFor, DatabaseError, DatabaseExt, JournaledState, LocalForkId, RevertDiagnostic, + }, constants::{CHEATCODE_ADDRESS, HARDHAT_CONSOLE_ADDRESS, MAGIC_ASSUME}, - evm::{FoundryEvm, new_evm_with_existing_context}, + env::FoundryContextExt, + evm::{ + BlockEnvFor, ChainFor, EthEvmNetwork, EvmFactoryFor, FoundryContextFor, FoundryEvmFactory, + FoundryEvmNetwork, NestedEvmClosureFor, SpecFor, TransactionRequestFor, TxEnvFor, + with_inherited_evm, + }, }; -use foundry_evm_traces::{TracingInspector, TracingInspectorConfig}; -use foundry_wallets::multi_wallet::MultiWallet; +use foundry_evm_traces::{ + TracingInspector, TracingInspectorConfig, identifier::SignaturesIdentifier, +}; +use foundry_wallets::wallet_multi::MultiWallet; use itertools::Itertools; use proptest::test_runner::{RngAlgorithm, TestRng, TestRunner}; use rand::Rng; use revm::{ - Inspector, Journal, + Inspector, JournalEntry, bytecode::opcode as op, - context::{BlockEnv, JournalTr, LocalContext, TransactionType, result::EVMError}, + context::{Cfg, ContextTr, Host, JournalTr, Transaction, TransactionType, result::EVMError}, context_interface::{CreateScheme, transaction::SignedAuthorization}, handler::FrameResult, interpreter::{ - CallInputs, CallOutcome, CallScheme, CreateInputs, CreateOutcome, FrameInput, Gas, Host, - InstructionResult, Interpreter, InterpreterAction, InterpreterResult, - interpreter_types::{Jumps, LoopControl, MemoryTr}, + CallInput, CallInputs, CallOutcome, CallScheme, CallValue, CreateInputs, CreateOutcome, + FrameInput, Gas, InstructionResult, Interpreter, InterpreterAction, InterpreterResult, + interpreter_types::{Jumps, LoopControl, MemoryTr, ReturnData}, }, - state::EvmStorageSlot, }; use serde_json::Value; use std::{ cmp::max, collections::{BTreeMap, VecDeque}, + fmt::Debug, fs::File, io::BufReader, ops::Range, path::PathBuf, - sync::Arc, + sync::{Arc, OnceLock}, }; +mod env_overrides; +pub use env_overrides::EnvOverrideState; + mod utils; -pub type Ecx<'a, 'b, 'c> = &'a mut EthEvmContext<&'b mut (dyn DatabaseExt + 'c)>; +pub mod analysis; +pub use analysis::CheatcodeAnalysis; -/// Helper trait for obtaining complete [revm::Inspector] instance from mutable reference to -/// [Cheatcodes]. -/// -/// This is needed for cases when inspector itself needs mutable access to [Cheatcodes] state and -/// allows us to correctly execute arbitrary EVM frames from inside cheatcode implementations. -pub trait CheatcodesExecutor { - /// Core trait method accepting mutable reference to [Cheatcodes] and returning - /// [revm::Inspector]. - fn get_inspector<'a>(&'a mut self, cheats: &'a mut Cheatcodes) -> Box; - - /// Obtains [FoundryEvm] instance and executes the given CREATE frame. - fn exec_create( +/// Helper trait for running nested EVM operations from inside cheatcode implementations. +pub trait CheatcodesExecutor { + /// Runs a closure with a nested EVM built from the current context. + /// The inspector is assembled internally — never exposed to the caller. + fn with_nested_evm( &mut self, - inputs: CreateInputs, - ccx: &mut CheatsCtxt, - ) -> Result> { - with_evm(self, ccx, |evm| { - evm.inner.ctx.journaled_state.depth += 1; - - let frame = FrameInput::Create(Box::new(inputs)); + cheats: &mut Cheatcodes, + ecx: &mut FoundryContextFor<'_, FEN>, + f: NestedEvmClosureFor<'_, FEN>, + ) -> Result<(), EVMError>; - let outcome = match evm.run_execution(frame)? { - FrameResult::Call(_) => unreachable!(), - FrameResult::Create(create) => create, - }; - - evm.inner.ctx.journaled_state.depth -= 1; + /// Replays a historical transaction on the database. Inspector is assembled internally. + fn transact_on_db( + &mut self, + cheats: &mut Cheatcodes, + ecx: &mut FoundryContextFor<'_, FEN>, + fork_id: Option, + transaction: B256, + ) -> eyre::Result>>; + + /// Executes a `TransactionRequest` on the database. Inspector is assembled internally. + fn transact_from_tx_on_db( + &mut self, + cheats: &mut Cheatcodes, + ecx: &mut FoundryContextFor<'_, FEN>, + tx: TxEnvFor, + ) -> eyre::Result<()>; + + /// Runs a closure with a fresh nested EVM using the current environment and database. + /// Unlike `with_nested_evm`, this starts an independent journal and does not write back. + /// The caller is responsible for state merging. Used by `executeTransactionCall`. + /// Returns the final EVM environment after the closure runs (consumed without cloning). + #[allow(clippy::type_complexity)] + fn with_fresh_nested_evm( + &mut self, + cheats: &mut Cheatcodes, + ecx: &mut FoundryContextFor<'_, FEN>, + chain_context: ChainFor, + f: NestedEvmClosureFor<'_, FEN>, + ) -> Result, BlockEnvFor>, EVMError>; - Ok(outcome) - }) - } - - fn console_log(&mut self, ccx: &mut CheatsCtxt, msg: &str) { - self.get_inspector(ccx.state).console_log(msg); - } + /// Simulates `console.log` invocation. + fn console_log(&mut self, msg: &str); /// Returns a mutable reference to the tracing inspector if it is available. - fn tracing_inspector(&mut self) -> Option<&mut Option> { + fn tracing_inspector(&mut self) -> Option<&mut TracingInspector> { None } } -/// Constructs [FoundryEvm] and runs a given closure with it. -fn with_evm( - executor: &mut E, - ccx: &mut CheatsCtxt, - f: F, -) -> Result> -where - E: CheatcodesExecutor + ?Sized, - F: for<'a, 'b> FnOnce( - &mut FoundryEvm<'a, &'b mut dyn InspectorExt>, - ) -> Result>, -{ - let mut inspector = executor.get_inspector(ccx.state); - let error = std::mem::replace(&mut ccx.ecx.error, Ok(())); - - let ctx = EthEvmContext { - block: ccx.ecx.block.clone(), - cfg: ccx.ecx.cfg.clone(), - tx: ccx.ecx.tx.clone(), - journaled_state: Journal { - inner: ccx.ecx.journaled_state.inner.clone(), - database: &mut *ccx.ecx.journaled_state.database as &mut dyn DatabaseExt, - }, - local: LocalContext::default(), - chain: (), - error, - }; - - let mut evm = new_evm_with_existing_context(ctx, &mut *inspector); - - let res = f(&mut evm)?; +/// Builds a sub-EVM from the current context and executes the given CREATE frame. +pub(crate) fn exec_create( + executor: &mut dyn CheatcodesExecutor, + inputs: CreateInputs, + ccx: &mut CheatsCtxt<'_, '_, FEN>, +) -> std::result::Result> { + let fee_token = ccx.tx_fee_token(); + let tx_origin = ccx.tx_caller(); + let mut inputs = Some(inputs); + let mut outcome = None; + executor.with_nested_evm(ccx.state, ccx.ecx, &mut |evm| { + evm.tx_mut().set_fee_token(fee_token); + evm.tx_mut().set_caller(tx_origin); + let inputs = inputs.take().unwrap(); + evm.journal_inner_mut().depth += 1; + + let frame = FrameInput::Create(Box::new(inputs)); + + let result = match evm.run_execution(frame)? { + FrameResult::Call(_) => unreachable!(), + FrameResult::Create(create) => create, + }; - ccx.ecx.journaled_state.inner = evm.inner.ctx.journaled_state.inner; - ccx.ecx.block = evm.inner.ctx.block; - ccx.ecx.tx = evm.inner.ctx.tx; - ccx.ecx.cfg = evm.inner.ctx.cfg; - ccx.ecx.error = evm.inner.ctx.error; + evm.journal_inner_mut().depth -= 1; - Ok(res) + outcome = Some(result); + Ok(()) + })?; + Ok(outcome.unwrap()) } /// Basic implementation of [CheatcodesExecutor] that simply returns the [Cheatcodes] instance as an @@ -162,10 +174,59 @@ where #[derive(Debug, Default, Clone, Copy)] struct TransparentCheatcodesExecutor; -impl CheatcodesExecutor for TransparentCheatcodesExecutor { - fn get_inspector<'a>(&'a mut self, cheats: &'a mut Cheatcodes) -> Box { - Box::new(cheats) +impl CheatcodesExecutor for TransparentCheatcodesExecutor { + fn with_nested_evm( + &mut self, + cheats: &mut Cheatcodes, + ecx: &mut FoundryContextFor<'_, FEN>, + f: NestedEvmClosureFor<'_, FEN>, + ) -> Result<(), EVMError> { + with_inherited_evm::(ecx, cheats, f) + } + + fn with_fresh_nested_evm( + &mut self, + cheats: &mut Cheatcodes, + ecx: &mut FoundryContextFor<'_, FEN>, + chain_context: ChainFor, + f: NestedEvmClosureFor<'_, FEN>, + ) -> Result, BlockEnvFor>, EVMError> { + let depth = ecx.journal().depth(); + let evm_env = ecx.evm_clone(); + let (db, _) = ecx.db_journal_inner_mut(); + let mut evm = + FEN::EvmFactory::default().create_nested_evm_with_inspector(db, evm_env, cheats); + evm.journal_inner_mut().depth = depth; + *evm.chain_mut() = chain_context; + f(&mut *evm)?; + Ok(evm.to_evm_env()) + } + + fn transact_on_db( + &mut self, + cheats: &mut Cheatcodes, + ecx: &mut FoundryContextFor<'_, FEN>, + fork_id: Option, + transaction: B256, + ) -> eyre::Result>> { + let evm_env = ecx.evm_clone(); + let outer_tx_env = ecx.tx_clone(); + let (db, inner) = ecx.db_journal_inner_mut(); + db.transact(fork_id, transaction, evm_env, &outer_tx_env, inner, cheats) + } + + fn transact_from_tx_on_db( + &mut self, + cheats: &mut Cheatcodes, + ecx: &mut FoundryContextFor<'_, FEN>, + tx: TxEnvFor, + ) -> eyre::Result<()> { + let evm_env = ecx.evm_clone(); + let (db, inner) = ecx.db_journal_inner_mut(); + db.transact_from_tx(tx, evm_env, inner, cheats) } + + fn console_log(&mut self, _msg: &str) {} } macro_rules! try_or_return { @@ -193,7 +254,6 @@ impl Clone for TestContext { impl TestContext { /// Clears the context. - #[inline] pub fn clear(&mut self) { self.opened_read_files.clear(); } @@ -201,11 +261,13 @@ impl TestContext { /// Helps collecting transactions from different forks. #[derive(Clone, Debug)] -pub struct BroadcastableTransaction { +pub struct BroadcastableTransaction { /// The optional RPC URL. pub rpc: Option, /// The transaction to broadcast. - pub transaction: TransactionMaybeSigned, + pub transaction: TransactionMaybeSigned, + /// The CREATE address captured from account state for a pre-signed transaction. + pub contract_address: Option
, } #[derive(Clone, Debug, Copy)] @@ -216,6 +278,59 @@ pub struct RecordDebugStepInfo { pub original_tracer_config: TracingInspectorConfig, } +/// A callback registered for a storage access hook. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct StorageHook { + /// Contract that receives the callback. + pub callback_target: Address, + /// Callback function selector. + pub callback_selector: [u8; 4], +} + +#[derive(Clone, Debug)] +enum PendingStorageHook { + Load { + account: Address, + slot: U256, + hook: StorageHook, + }, + Store { + account: Address, + slot: U256, + old_value: U256, + mapping: Option<(B256, Vec)>, + hook: StorageHook, + }, +} + +#[derive(Clone, Debug)] +struct ActiveStorageHook { + parent_depth: usize, + callback_target: Address, + callback_input: Bytes, + saved_gas: Gas, + saved_return_data: Bytes, + saved_stack_item: Option, + journal_start: usize, + inspector_state: StorageHookInspectorState, + outcome: Option<(InstructionResult, Bytes)>, +} + +#[derive(Clone, Debug)] +struct StorageHookInspectorState { + accesses: RecordAccess, + recording_accesses: bool, + mapping_slots: Option>, + recorded_logs: Option>, + mocked_calls: HashMap>>, + mocked_functions: HashMap>, + expected_revert: Option, + assume_no_revert: Option, + expected_calls: ExpectedCallTracker, + expected_emits: ExpectedEmitTracker, + expected_creates: Vec, +} + /// Holds gas metering state. #[derive(Clone, Debug, Default)] pub struct GasMetering { @@ -235,6 +350,22 @@ pub struct GasMetering { /// Cache of the amount of gas used in previous call. /// This is used by the `lastCallGas` cheatcode. pub last_call_gas: Option, + /// Gas used by `snapshotGasLastCall`. + pub(crate) last_call_snapshot_gas_used: u64, + + /// Cache of the amount of gas used in previous call or create frame. + /// This is used by the `lastFrameGas` cheatcode. + pub last_frame_gas: Option, + /// Gas used by `snapshotGasLastFrame`. + pub(crate) last_frame_snapshot_gas_used: u64, + + /// Post-refund gas used by the isolated transaction wrapping the current frame, and the + /// account-creation state gas in it that the outer opcode charged before entering the frame. + isolated_snapshot_gas_used: Option<(u64, u64)>, + + /// Caller depth, gas charged to the last isolated frame, and the transaction gas that replaces + /// it in the next region sample at that depth. + pending_isolated_region_gas: Option<(usize, u64, u64)>, /// True if gas recording is enabled. pub recording: bool, @@ -246,12 +377,14 @@ pub struct GasMetering { impl GasMetering { /// Start the gas recording. - pub fn start(&mut self) { + pub const fn start(&mut self) { self.recording = true; + self.last_gas_used = 0; + self.pending_isolated_region_gas = None; } /// Stop the gas recording. - pub fn stop(&mut self) { + pub const fn stop(&mut self) { self.recording = false; } @@ -271,6 +404,31 @@ impl GasMetering { self.reset = true; self.paused_frames.clear(); } + + /// Preserves the receipt gas of an isolated transaction for gas snapshots. + /// + /// `precharged_state` is the account-creation state gas in `gas_used` that the outer opcode + /// already charged before entering the isolated frame. + pub const fn set_isolated_snapshot_gas_used(&mut self, gas_used: u64, precharged_state: u64) { + self.isolated_snapshot_gas_used = Some((gas_used, precharged_state)); + } + + /// Takes the receipt gas of the isolated transaction that wrapped the ending frame. + /// + /// Region snapshots replace the gas charged to the frame with the transaction gas, without + /// changing the interpreter's gas. The transaction gas can be lower because of refunds, or + /// higher because of intrinsic gas that did not fit in the frame's budget. + const fn take_isolated_snapshot_gas_used(&mut self, depth: usize, gas: &Gas) -> Option { + let Some((gas_used, precharged_state)) = self.isolated_snapshot_gas_used.take() else { + return None; + }; + if self.recording { + // The caller's sample already includes the precharged state gas. + self.pending_isolated_region_gas = + Some((depth, gas.total_gas_spent(), gas_used.saturating_sub(precharged_state))); + } + Some(gas_used) + } } /// Holds data about arbitrary storage. @@ -279,17 +437,20 @@ pub struct ArbitraryStorage { /// Mapping of arbitrary storage addresses to generated values (slot, arbitrary value). /// (SLOADs return random value if storage slot wasn't accessed). /// Changed values are recorded and used to copy storage to different addresses. - pub values: HashMap>, + values: HashMap>, /// Mapping of address with storage copied to arbitrary storage address source. - pub copies: HashMap, + copies: HashMap, /// Address with storage slots that should be overwritten even if previously set. - pub overwrites: HashSet
, + overwrites: HashSet
, + /// Storage slots explicitly written with `vm.store`, grouped by address. + explicit_slots: HashMap>, } impl ArbitraryStorage { /// Marks an address with arbitrary storage. pub fn mark_arbitrary(&mut self, address: &Address, overwrite: bool) { self.values.insert(*address, HashMap::default()); + self.explicit_slots.remove(address); if overwrite { self.overwrites.insert(*address); } else { @@ -301,16 +462,82 @@ impl ArbitraryStorage { pub fn mark_copy(&mut self, from: &Address, to: &Address) { if self.values.contains_key(from) { self.copies.insert(*to, *from); + if let Some(slots) = self.explicit_slots.get(from).cloned() { + self.explicit_slots.insert(*to, slots); + } else { + self.explicit_slots.remove(to); + } } } + /// Marks a slot as explicitly written if the address has arbitrary or copied storage. + fn mark_explicit(&mut self, address: Address, slot: U256) { + if self.values.contains_key(&address) || self.copies.contains_key(&address) { + self.explicit_slots.entry(address).or_default().insert(slot); + } + } + + /// Returns whether a slot was explicitly written for the given address. + fn is_explicit(&self, address: Address, slot: U256) -> bool { + self.explicit_slots.get(&address).is_some_and(|slots| slots.contains(&slot)) + } + + /// Returns addresses explicitly marked with arbitrary storage. + fn targets(&self) -> impl Iterator + '_ { + self.values.keys().copied() + } + + /// Returns addresses explicitly marked with arbitrary storage and whether nonzero slots are + /// overwritten. + fn target_overwrite_modes(&self) -> impl Iterator + '_ { + self.values.keys().map(|address| (*address, self.overwrites.contains(address))) + } + + /// Returns addresses that copy storage from arbitrary-storage targets. + fn copied_targets(&self) -> impl Iterator + '_ { + self.copies.keys().copied() + } + + /// Returns copied arbitrary-storage targets and their source address. + fn copied_target_sources(&self) -> impl Iterator + '_ { + self.copies.iter().map(|(target, source)| (*target, *source)) + } + + /// Caches a concrete value for a slot on an arbitrary-storage address or copied target. + fn cache_value(&mut self, address: Address, slot: U256, data: U256) { + if let Some(values) = self.values.get_mut(&address) { + values.insert(slot, data); + return; + } + + let Some(source) = self.copies.get(&address).copied() else { + return; + }; + if let Some(values) = self.values.get_mut(&source) { + values.insert(slot, data); + } + } + + /// Returns a cached arbitrary value for a slot. + fn cached_value(&self, address: Address, slot: U256) -> Option { + self.values.get(&address).and_then(|values| values.get(&slot)).copied() + } + /// Saves arbitrary storage value for a given address: /// - store value in changed values cache. /// - update account's storage with given value. - pub fn save(&mut self, ecx: Ecx, address: Address, slot: U256, data: U256) { + pub fn save( + &mut self, + ecx: &mut CTX, + address: Address, + slot: U256, + data: U256, + ) { self.values.get_mut(&address).expect("missing arbitrary address entry").insert(slot, data); - if let Ok(mut account) = ecx.journaled_state.load_account(address) { - account.storage.insert(slot, EvmStorageSlot::new(data, 0)); + if ecx.journal_mut().load_account(address).is_ok() { + ecx.journal_mut() + .sstore(address, slot, data) + .expect("could not set arbitrary storage value"); } } @@ -319,7 +546,13 @@ impl ArbitraryStorage { /// existing value. /// - if no value was yet generated for given slot, then save new value in cache and update both /// source and target storages. - pub fn copy(&mut self, ecx: Ecx, target: Address, slot: U256, new_value: U256) -> U256 { + pub fn copy( + &mut self, + ecx: &mut CTX, + target: Address, + slot: U256, + new_value: U256, + ) -> U256 { let source = self.copies.get(&target).expect("missing arbitrary copy target entry"); let storage_cache = self.values.get_mut(source).expect("missing arbitrary source storage"); let value = match storage_cache.get(&slot) { @@ -327,22 +560,52 @@ impl ArbitraryStorage { None => { storage_cache.insert(slot, new_value); // Update source storage with new value. - if let Ok(mut source_account) = ecx.journaled_state.load_account(*source) { - source_account.storage.insert(slot, EvmStorageSlot::new(new_value, 0)); + if ecx.journal_mut().load_account(*source).is_ok() { + ecx.journal_mut() + .sstore(*source, slot, new_value) + .expect("could not copy arbitrary storage value"); } new_value } }; // Update target storage with new value. - if let Ok(mut target_account) = ecx.journaled_state.load_account(target) { - target_account.storage.insert(slot, EvmStorageSlot::new(value, 0)); + if ecx.journal_mut().load_account(target).is_ok() { + ecx.journal_mut().sstore(target, slot, value).expect("could not set storage"); } value } } /// List of transactions that can be broadcasted. -pub type BroadcastableTransactions = VecDeque; +pub type BroadcastableTransactions = VecDeque>; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum CreatedAccountsFrameKind { + Call, + Create, +} + +#[derive(Clone, Copy, Debug)] +struct CreatedAccountsFrame { + kind: CreatedAccountsFrameKind, + depth: usize, + checkpoint: usize, +} + +#[derive(Clone, Copy, Debug)] +struct CreatedAccountChange { + fork_id: Option, + address: Address, + creation: usize, + previous: Option, + committed: bool, +} + +#[derive(Clone, Debug)] +struct CreatedAccountsSnapshot { + fork_id: Option, + bindings: AddressHashMap, +} /// An EVM inspector that handles calls to various cheatcodes, each with their own behavior. /// @@ -362,12 +625,20 @@ pub type BroadcastableTransactions = VecDeque; /// cheatcode address: by default, the caller, test contract and newly deployed contracts are /// allowed to execute cheatcodes #[derive(Clone, Debug)] -pub struct Cheatcodes { +pub struct Cheatcodes { + /// Solar compiler instance, to grant syntactic and semantic analysis capabilities + pub analysis: Option, + /// The block environment /// /// Used in the cheatcode handler to overwrite the block environment separately from the /// execution block environment. - pub block: Option, + pub block: Option>, + + /// The active fork block override updated by a fork-switching cheatcode. + /// + /// This persists fork changes made through a copy-on-write backend between invariant calls. + pub fork_block_number_override: Option, /// Currently active EIP-7702 delegations that will be consumed when building the next /// transaction. Set by `vm.attachDelegation()` and consumed via `.take()` during @@ -375,7 +646,7 @@ pub struct Cheatcodes { pub active_delegations: Vec, /// The active EIP-4844 blob that will be attached to the next call. - pub active_blob_sidecar: Option, + pub active_blob_sidecar: Option, /// The gas price. /// @@ -411,6 +682,27 @@ pub struct Cheatcodes { /// merged into the previous vector. pub recorded_account_diffs_stack: Option>>, + /// Account accesses performed by the test runner before user code can start recording. + pending_account_diffs: Option>, + + /// Completed account accesses prepended to the active user recording session. + recorded_account_diffs_prefix: Option>, + + /// Successfully created accounts in execution order. + created_accounts: Vec
, + + /// The creation currently represented by each address on each fork. + created_account_bindings: HashMap<(Option, Address), usize>, + + /// Revertible changes to creation bindings made by EVM create frames. + created_account_changes: Vec, + + /// Creation-list checkpoints for frames observed by this inspector. + created_accounts_frames: Vec, + + /// Creation lists captured by state snapshots. + created_accounts_snapshots: HashMap, + /// The information of the debug step recording. pub record_debug_steps_info: Option, @@ -438,7 +730,7 @@ pub struct Cheatcodes { pub broadcast: Option, /// Scripting based transactions - pub broadcastable_transactions: BroadcastableTransactions, + pub broadcastable_transactions: BroadcastableTransactions, /// Current EIP-2930 access lists. pub access_list: Option, @@ -446,9 +738,18 @@ pub struct Cheatcodes { /// Additional, user configurable context this Inspector has access to when inspecting a call. pub config: Arc, + /// Additional addresses recognized as cheatcode contracts by this executor. + pub extra_cheatcode_addresses: &'static [Address], + /// Test-scoped context holding data that needs to be reset every test run pub test_context: TestContext, + /// Revert payloads minted by the `skip` cheatcode during the current test call. + /// + /// A top-level revert is only classified as a skip when its data byte-equals one of these + /// payloads, so user-crafted `FOUNDRY::SKIP` revert data never skips a test on its own. + pub skip_payloads: Vec, + /// Whether to commit FS changes such as file creations, writes and deletes. /// Used to prevent duplicate changes file executing non-committing calls. pub fs_commit: bool, @@ -489,10 +790,79 @@ pub struct Cheatcodes { /// Addresses with arbitrary storage. pub arbitrary_storage: Option, + /// SLOAD callbacks keyed by effective storage address. + storage_load_hooks: AddressHashMap, + /// SSTORE callbacks keyed by effective storage address. + storage_store_hooks: AddressHashMap, + /// Mapping SSTORE callbacks keyed by effective storage address and root slot. + mapping_storage_store_hooks: AddressHashMap>, + /// Execution-local provenance used only by mapping storage hooks. + storage_hook_mapping_slots: AddressHashMap, + /// A 64-byte Keccak operation awaiting successful completion. + pending_mapping_hash: Option, + /// Whether any storage hook map contains a callback. + storage_hooks_registered: bool, + /// Matching storage access captured before the opcode executes. + pending_storage_hook: Option, + /// Synthetic callback frame currently executing or awaiting parent cleanup. + active_storage_hook: Option, + /// Deprecated cheatcodes mapped to the reason. Used to report warnings on test results. pub deprecated: HashMap<&'static str, Option<&'static str>>, + /// Main script contract, when script execution protection is enabled. + pub script_address: Option
, /// Unlocked wallets used in scripts and testing of scripts. pub wallets: Option, + /// Parsed secp256k1 private-key signers for repeated `vm.addr` / `vm.sign` calls. + pub private_key_signers: HashMap, + /// Signatures identifier for decoding events and functions + signatures_identifier: OnceLock>, + /// Used to determine whether the broadcasted call has dynamic gas limit. + pub dynamic_gas_limit: bool, + // Custom execution evm version. + pub execution_evm_version: Option>, + + /// Per-fork opcode environment overrides and their state-snapshot copies. + pub env_overrides: EnvOverrideState, + + /// Per-state-snapshot copies of [`Self::fork_block_number_override`]. + pub fork_block_number_override_snapshots: HashMap>, + + /// Transaction-position context and Monad's reserve-balance-tracker state captured atomically + /// alongside state snapshots. + #[cfg(feature = "monad")] + pub context_snapshots: + HashMap, monad_revm::reserve_balance::tracker::ReserveBalanceTracker)>, + + /// Whether we are currently executing inside an isolation context, i.e. + /// the synthetic inner transaction wrapped by + /// `InspectorStackRefMut::transact_inner` (used by `--gas-report` and + /// `--isolate`). + /// + /// Toggled by the inspector stack around the inner `transact_raw` + /// call. Cheatcodes that mutate the tx/block env consult this flag and + /// route the change through `EnvOverrides` instead of the actual env + /// when `true`, so they don't fight with the fee-accounting zeroing. + pub in_isolation_context: bool, + + /// Journal restored by a state snapshot inside an isolated transaction, to be applied to its + /// suspended parent alongside the returned state. + pub pending_isolated_snapshot_journal: Option>, + + /// Whether snapshot restorations belong to the tracked transaction (an isolated call, or the + /// top-level transaction when isolation is disabled) and must be unwound by failing frames. + pub track_isolated_snapshots: bool, + + /// Journals replaced by snapshot restorations that may need to be reinstated when an + /// enclosing frame of the tracked transaction fails. + pub isolated_snapshot_restores: Vec, + + /// Whether the next snapshot restoration is the first one since its calling frame started, + /// and so must be recorded in `isolated_snapshot_restores`. + pub capture_isolated_snapshot_restore: bool, + + /// Depth of the in-flight `deployCode` call, whose create frame runs one level deeper. + pub deploy_code_depth: Option, } // This is not derived because calling this in `fn new` with `..Default::default()` creates a second @@ -504,14 +874,17 @@ impl Default for Cheatcodes { } } -impl Cheatcodes { +impl Cheatcodes { /// Creates a new `Cheatcodes` with the given settings. pub fn new(config: Arc) -> Self { Self { + analysis: None, fs_commit: true, labels: config.labels.clone(), config, + extra_cheatcode_addresses: &[], block: Default::default(), + fork_block_number_override: Default::default(), active_delegations: Default::default(), active_blob_sidecar: Default::default(), gas_price: Default::default(), @@ -522,6 +895,13 @@ impl Cheatcodes { accesses: Default::default(), recording_accesses: Default::default(), recorded_account_diffs_stack: Default::default(), + pending_account_diffs: Default::default(), + recorded_account_diffs_prefix: Default::default(), + created_accounts: Default::default(), + created_account_bindings: Default::default(), + created_account_changes: Default::default(), + created_accounts_frames: Default::default(), + created_accounts_snapshots: Default::default(), recorded_logs: Default::default(), record_debug_steps_info: Default::default(), mocked_calls: Default::default(), @@ -534,6 +914,7 @@ impl Cheatcodes { broadcastable_transactions: Default::default(), access_list: Default::default(), test_context: Default::default(), + skip_payloads: Default::default(), serialized_jsons: Default::default(), eth_deals: Default::default(), gas_metering: Default::default(), @@ -545,8 +926,255 @@ impl Cheatcodes { test_runner: Default::default(), ignored_traces: Default::default(), arbitrary_storage: Default::default(), + storage_load_hooks: Default::default(), + storage_store_hooks: Default::default(), + mapping_storage_store_hooks: Default::default(), + storage_hook_mapping_slots: Default::default(), + pending_mapping_hash: Default::default(), + storage_hooks_registered: Default::default(), + pending_storage_hook: Default::default(), + active_storage_hook: Default::default(), deprecated: Default::default(), + script_address: Default::default(), wallets: Default::default(), + private_key_signers: Default::default(), + signatures_identifier: Default::default(), + dynamic_gas_limit: Default::default(), + execution_evm_version: None, + env_overrides: Default::default(), + fork_block_number_override_snapshots: Default::default(), + #[cfg(feature = "monad")] + context_snapshots: Default::default(), + in_isolation_context: false, + pending_isolated_snapshot_journal: None, + track_isolated_snapshots: false, + isolated_snapshot_restores: Vec::new(), + capture_isolated_snapshot_restore: false, + deploy_code_depth: None, + } + } + + /// Sets additional addresses recognized as cheatcode contracts. + #[inline] + pub const fn set_extra_cheatcode_addresses(&mut self, addresses: &'static [Address]) { + self.extra_cheatcode_addresses = addresses; + } + + /// Enables cheatcode analysis capabilities by providing a solar compiler instance. + pub fn set_analysis(&mut self, analysis: CheatcodeAnalysis) { + self.analysis = Some(analysis); + } + + /// Starts an internal account diff recording session for test runner setup. + pub fn start_internal_state_diff_recording(&mut self) -> bool { + if self.recorded_account_diffs_stack.is_some() + || self.recorded_account_diffs_prefix.is_some() + { + return false; + } + self.recorded_account_diffs_stack = Some(Default::default()); + true + } + + /// Stops an internal account diff recording session without leaving recording enabled. + pub fn stop_internal_state_diff_recording(&mut self) -> Vec { + self.recorded_account_diffs_stack.take().unwrap_or_default().into_iter().flatten().collect() + } + + /// Makes account accesses captured by the test runner available to the next recording session. + pub fn set_pending_account_diffs(&mut self, accesses: Vec) { + self.pending_account_diffs = (!accesses.is_empty()).then(|| Arc::from(accesses)); + } + + /// Starts a user account diff recording session, including pending test runner accesses. + pub fn start_state_diff_recording(&mut self) { + self.recorded_account_diffs_prefix = self.pending_account_diffs.take(); + self.recorded_account_diffs_stack = Some(Default::default()); + } + + /// Returns completed and active account accesses in execution order. + pub fn recorded_account_diffs(&self) -> impl Iterator { + self.recorded_account_diffs_prefix + .iter() + .flat_map(|prefix| prefix.iter()) + .chain(self.recorded_account_diffs_stack.iter().flatten().flatten()) + } + + /// Takes completed account accesses from the active user recording session. + pub fn take_recorded_account_diffs_prefix(&mut self) -> Vec { + self.recorded_account_diffs_prefix + .take() + .map(|prefix| prefix.as_ref().to_vec()) + .unwrap_or_default() + } + + /// Returns the current creation bound to each address on the given fork. + pub(crate) fn created_account_bindings( + &self, + fork_id: Option, + ) -> AddressHashMap { + self.created_account_bindings + .iter() + .filter_map(|(&(event_fork_id, address), &creation)| { + (event_fork_id == fork_id).then_some((address, creation)) + }) + .collect() + } + + /// Returns successfully created accounts bound to the given fork in creation order. + pub(crate) fn created_accounts(&self, fork_id: Option) -> Vec
{ + let bindings = self.created_account_bindings(fork_id); + self.created_accounts + .iter() + .enumerate() + .filter_map(|(index, &address)| { + (bindings.get(&address) == Some(&index)).then_some(address) + }) + .collect() + } + + /// Records a successfully created account. + pub(crate) fn record_created_account( + &mut self, + fork_id: Option, + address: Address, + ) { + let creation = self.created_accounts.len(); + self.created_accounts.push(address); + let previous = self.created_account_bindings.insert((fork_id, address), creation); + self.created_account_changes.push(CreatedAccountChange { + fork_id, + address, + creation, + previous, + committed: false, + }); + } + + /// Keeps creation bindings saved with an outgoing fork across later frame reverts. + pub(crate) fn commit_created_account_changes(&mut self, fork_id: Option) { + for change in &mut self.created_account_changes { + if change.fork_id == fork_id { + change.committed = true; + } + } + } + + /// Records shared pre-fork creations on a newly selected fork without replacing local ones. + pub(crate) fn record_initial_created_accounts( + &mut self, + fork_id: Option, + accounts: impl IntoIterator, + ) { + for (address, creation) in accounts { + self.created_account_bindings.entry((fork_id, address)).or_insert(creation); + } + } + + /// Records creations propagated to a fork with persistent account state. + pub(crate) fn record_propagated_accounts( + &mut self, + fork_id: Option, + accounts: impl IntoIterator, + ) { + self.created_account_bindings + .extend(accounts.into_iter().map(|(address, creation)| ((fork_id, address), creation))); + } + + /// Captures creation ordering alongside a state snapshot. + pub(crate) fn snapshot_created_accounts( + &mut self, + snapshot_id: U256, + fork_id: Option, + ) { + let bindings = self.created_account_bindings(fork_id); + self.created_accounts_snapshots + .insert(snapshot_id, CreatedAccountsSnapshot { fork_id, bindings }); + } + + /// Restores creation ordering from a state snapshot. + pub(crate) fn revert_created_accounts(&mut self, snapshot_id: U256, remove: bool) { + let snapshot = if remove { + self.created_accounts_snapshots.remove(&snapshot_id) + } else { + self.created_accounts_snapshots.get(&snapshot_id).cloned() + }; + if let Some(snapshot) = snapshot { + self.created_account_bindings.retain(|(fork_id, _), _| *fork_id != snapshot.fork_id); + self.created_account_bindings.extend( + snapshot + .bindings + .into_iter() + .map(|(address, creation)| ((snapshot.fork_id, address), creation)), + ); + } + } + + /// Deletes one captured creation-order snapshot. + pub(crate) fn delete_created_accounts_snapshot(&mut self, snapshot_id: U256) { + self.created_accounts_snapshots.remove(&snapshot_id); + } + + /// Deletes all captured creation-order snapshots. + pub(crate) fn clear_created_accounts_snapshots(&mut self) { + self.created_accounts_snapshots.clear(); + } + + fn start_created_accounts_frame( + &mut self, + reset: bool, + kind: CreatedAccountsFrameKind, + depth: usize, + ) { + if reset { + self.created_accounts.clear(); + self.created_account_bindings.clear(); + self.created_account_changes.clear(); + self.created_accounts_frames.clear(); + // Earlier snapshots contain no creations from the new root transaction. + for snapshot in self.created_accounts_snapshots.values_mut() { + snapshot.bindings.clear(); + } + } + self.created_accounts_frames.push(CreatedAccountsFrame { + kind, + depth, + checkpoint: self.created_account_changes.len(), + }); + } + + fn finish_created_accounts_frame( + &mut self, + success: bool, + kind: CreatedAccountsFrameKind, + depth: usize, + ) { + let Some(frame) = self + .created_accounts_frames + .last() + .copied() + .filter(|frame| frame.kind == kind && frame.depth == depth) + else { + return; + }; + let checkpoint = frame.checkpoint; + self.created_accounts_frames.pop(); + if !success { + while self.created_account_changes.len() > checkpoint { + let change = self.created_account_changes.pop().expect("length checked"); + if change.committed { + continue; + } + let key = (change.fork_id, change.address); + if self.created_account_bindings.get(&key) != Some(&change.creation) { + continue; + } + if let Some(previous) = change.previous { + self.created_account_bindings.insert(key, previous); + } else { + self.created_account_bindings.remove(&key); + } + } } } @@ -572,12 +1200,27 @@ impl Cheatcodes { self.active_delegations.push(authorization); } + /// Returns the signatures identifier. + pub fn signatures_identifier(&self) -> Option<&SignaturesIdentifier> { + self.signatures_identifier + .get_or_init(|| { + if let Some(artifacts) = &self.config.available_artifacts { + return SignaturesIdentifier::new_offline_with_abis( + artifacts.values().map(|contract| &contract.abi), + ) + .ok(); + } + SignaturesIdentifier::new(true).ok() + }) + .as_ref() + } + /// Decodes the input data and applies the cheatcode. fn apply_cheatcode( &mut self, - ecx: Ecx, + ecx: &mut FoundryContextFor<'_, FEN>, call: &CallInputs, - executor: &mut dyn CheatcodesExecutor, + executor: &mut dyn CheatcodesExecutor, ) -> Result { // decode the cheatcode call let decoded = Vm::VmCalls::abi_decode(&call.input.bytes(ecx)).map_err(|e| { @@ -592,29 +1235,64 @@ impl Cheatcodes { e })?; - let caller = call.caller; + let caller = call.transfer_from(); // ensure the caller is allowed to execute cheatcodes, // but only if the backend is in forking mode - ecx.journaled_state.database.ensure_cheatcode_access_forking_mode(&caller)?; + ecx.db_mut().ensure_cheatcode_access_forking_mode(&caller)?; apply_dispatch( &decoded, - &mut CheatsCtxt { state: self, ecx, gas_limit: call.gas_limit, caller }, + &mut CheatsCtxt { + state: self, + ecx, + gas_limit: call.gas_limit, + caller, + is_static: call.is_static, + }, executor, ) } + /// Decodes the input data and applies Monad-specific cheatcodes. + #[cfg(feature = "monad")] + fn apply_monad_cheatcode( + &mut self, + ecx: &mut FoundryContextFor<'_, FEN>, + call: &CallInputs, + ) -> Result { + let input = call.input.bytes(ecx); + let caller = call.transfer_from(); + + // ensure the caller is allowed to execute cheatcodes, + // but only if the backend is in forking mode + ecx.db_mut().ensure_cheatcode_access_forking_mode(&caller)?; + + crate::monad::apply_monad_cheatcode( + &mut CheatsCtxt { + state: self, + ecx, + gas_limit: call.gas_limit, + caller, + is_static: call.is_static, + }, + &input, + ) + } + /// Grants cheat code access for new contracts if the caller also has /// cheatcode access or the new contract is created in top most call. /// /// There may be cheatcodes in the constructor of the new contract, in order to allow them /// automatically we need to determine the new address. - fn allow_cheatcodes_on_create(&self, ecx: Ecx, caller: Address, created_address: Address) { - if ecx.journaled_state.depth <= 1 - || ecx.journaled_state.database.has_cheatcode_access(&caller) - { - ecx.journaled_state.database.allow_cheatcode_access(created_address); + fn allow_cheatcodes_on_create( + &self, + ecx: &mut FoundryContextFor, + caller: Address, + created_address: Address, + ) { + if ecx.journal().depth() <= 1 || ecx.db().has_cheatcode_access(&caller) { + ecx.db_mut().allow_cheatcode_access(created_address); } } @@ -623,12 +1301,12 @@ impl Cheatcodes { /// If the transaction type is [TransactionType::Legacy] we need to upgrade it to /// [TransactionType::Eip2930] in order to use access lists. Other transaction types support /// access lists themselves. - fn apply_accesslist(&mut self, ecx: Ecx) { + fn apply_accesslist(&mut self, ecx: &mut FoundryContextFor) { if let Some(access_list) = &self.access_list { - ecx.tx.access_list = access_list.clone(); + ecx.tx_mut().set_access_list(access_list.clone()); - if ecx.tx.tx_type == TransactionType::Legacy as u8 { - ecx.tx.tx_type = TransactionType::Eip2930 as u8; + if ecx.tx().tx_type() == TransactionType::Legacy as u8 { + ecx.tx_mut().set_tx_type(TransactionType::Eip2930 as u8); } } } @@ -637,7 +1315,7 @@ impl Cheatcodes { /// /// Cleanup any previously applied cheatcodes that altered the state in such a way that revm's /// revert would run into issues. - pub fn on_revert(&mut self, ecx: Ecx) { + pub fn on_revert(&mut self, ecx: &mut FoundryContextFor) { trace!(deals=?self.eth_deals.len(), "rolling back deals"); // Delay revert clean up until expected revert is handled, if set. @@ -646,7 +1324,7 @@ impl Cheatcodes { } // we only want to apply cleanup top level - if ecx.journaled_state.depth() > 0 { + if ecx.journal().depth() > 0 { return; } @@ -654,26 +1332,41 @@ impl Cheatcodes { // This will prevent overflow issues in revm's [`JournaledState::journal_revert`] routine // which rolls back any transfers. while let Some(record) = self.eth_deals.pop() { - if let Some(acc) = ecx.journaled_state.state.get_mut(&record.address) { + if let Some(acc) = ecx.journal_mut().evm_state_mut().get_mut(&record.address) { acc.info.balance = record.old_balance; } } } + /// Handles a call, accounting for whether the executor will isolate it as a transaction. + /// + /// If `isolate_call` is true, the executor owns the transaction nonce increment when the call + /// proceeds to execution. pub fn call_with_executor( &mut self, - ecx: Ecx, + ecx: &mut FoundryContextFor<'_, FEN>, call: &mut CallInputs, - executor: &mut impl CheatcodesExecutor, + executor: &mut dyn CheatcodesExecutor, + isolate_call: bool, ) -> Option { + // Apply custom execution evm version. + if let Some(spec_id) = self.execution_evm_version { + EvmFactoryFor::::set_execution_spec(ecx, spec_id); + } + let gas = Gas::new(call.gas_limit); - let curr_depth = ecx.journaled_state.depth(); + let curr_depth = ecx.journal().depth(); + self.start_created_accounts_frame( + curr_depth == 0, + CreatedAccountsFrameKind::Call, + curr_depth, + ); // At the root call to test function or script `run()`/`setUp()` functions, we are // decreasing sender nonce to ensure that it matches on-chain nonce once we start // broadcasting. if curr_depth == 0 { - let sender = ecx.tx.caller; + let sender = ecx.tx().caller(); let account = match super::evm::journaled_account(ecx, sender) { Ok(account) => account, Err(err) => { @@ -684,6 +1377,9 @@ impl Cheatcodes { gas, }, memory_offset: call.return_memory_offset.clone(), + was_precompile_called: false, + precompile_call_logs: vec![], + charged_new_account_state_gas: call.charged_new_account_state_gas, }); } }; @@ -693,7 +1389,7 @@ impl Cheatcodes { trace!(target: "cheatcodes", %sender, nonce=account.info.nonce, prev, "corrected nonce"); } - if call.target_address == CHEATCODE_ADDRESS { + if call.transfer_to() == CHEATCODE_ADDRESS { return match self.apply_cheatcode(ecx, call, executor) { Ok(retdata) => Some(CallOutcome { result: InterpreterResult { @@ -702,6 +1398,9 @@ impl Cheatcodes { gas, }, memory_offset: call.return_memory_offset.clone(), + was_precompile_called: true, + precompile_call_logs: vec![], + charged_new_account_state_gas: call.charged_new_account_state_gas, }), Err(err) => Some(CallOutcome { result: InterpreterResult { @@ -710,62 +1409,141 @@ impl Cheatcodes { gas, }, memory_offset: call.return_memory_offset.clone(), + was_precompile_called: false, + precompile_call_logs: vec![], + charged_new_account_state_gas: call.charged_new_account_state_gas, }), }; } - if call.target_address == HARDHAT_CONSOLE_ADDRESS { + #[cfg(feature = "monad")] + if crate::monad::is_monad_cheatcode_call(self.extra_cheatcode_addresses, call.transfer_to()) + { + let checkpoint = ecx.journal_mut().checkpoint(); + return match self.apply_monad_cheatcode(ecx, call) { + Ok(retdata) => { + ecx.journal_mut().checkpoint_commit(); + Some(CallOutcome { + result: InterpreterResult { + result: InstructionResult::Return, + output: retdata.into(), + gas, + }, + memory_offset: call.return_memory_offset.clone(), + was_precompile_called: true, + precompile_call_logs: vec![], + charged_new_account_state_gas: call.charged_new_account_state_gas, + }) + } + Err(err) => { + ecx.journal_mut().checkpoint_revert(checkpoint); + Some(CallOutcome { + result: InterpreterResult { + result: InstructionResult::Revert, + output: err.abi_encode().into(), + gas, + }, + memory_offset: call.return_memory_offset.clone(), + was_precompile_called: false, + precompile_call_logs: vec![], + charged_new_account_state_gas: call.charged_new_account_state_gas, + }) + } + }; + } + + if call.transfer_to() == HARDHAT_CONSOLE_ADDRESS { return None; } + // `expectRevert`: track max call depth. This is also done in `initialize_interp`, but + // precompile calls don't create an interpreter frame so we must also track it here. + // The callee executes at `curr_depth + 1`. + if let Some(expected) = &mut self.expected_revert { + expected.max_depth = max(curr_depth + 1, expected.max_depth); + } + // Handle expected calls + if let Some(expected) = self.expected_calls.get_mut(&call.bytecode_address) { + let input = call.input.as_bytes(ecx); + expect::observe_call( + expected, + &input, + call.transfer_value(), + call.gas_limit, + call.scheme, + ); + } - // Grab the different calldatas expected. - if let Some(expected_calls_for_target) = self.expected_calls.get_mut(&call.bytecode_address) - { - // Match every partial/full calldata - for (calldata, (expected, actual_count)) in expected_calls_for_target { - // Increment actual times seen if... - // The calldata is at most, as big as this call's input, and - if calldata.len() <= call.input.len() && - // Both calldata match, taking the length of the assumed smaller one (which will have at least the selector), and - *calldata == call.input.bytes(ecx)[..calldata.len()] && - // The value matches, if provided - expected - .value.is_none_or(|value| Some(value) == call.transfer_value()) && - // The gas matches, if provided - expected.gas.is_none_or(|gas| gas == call.gas_limit) && - // The minimum gas matches, if provided - expected.min_gas.is_none_or(|min_gas| min_gas <= call.gas_limit) - { - *actual_count += 1; + // Apply our prank + if let Some(prank) = self.get_prank(curr_depth).copied() { + // Apply delegate call, `call.caller`` will not equal `prank.prank_caller` + if prank.delegate_call && curr_depth == prank.depth && call.scheme.is_delegate_call() { + call.target_address = prank.new_caller; + call.caller = prank.new_caller; + if let Some(new_origin) = prank.new_origin { + ecx.tx_mut().set_caller(new_origin); + } + if let Some(used) = prank.first_time_applied() { + self.pranks.insert(curr_depth, used); + } + } + + if let Some(changes) = prank.changes_for(curr_depth, call.transfer_from()) { + if let Some(new_caller) = changes.caller { + // Ensure new caller is loaded and touched + let _ = journaled_account(ecx, new_caller); + call.caller = new_caller; + } + if let Some(new_origin) = changes.origin { + ecx.tx_mut().set_caller(new_origin); + } + if let Some(used) = changes.used { + self.pranks.insert(curr_depth, used); } } } // Handle mocked calls if let Some(mocks) = self.mocked_calls.get_mut(&call.bytecode_address) { - let ctx = MockCallDataContext { - calldata: call.input.bytes(ecx), - value: call.transfer_value(), - }; + let input = call.input.bytes(ecx); + if let Some(return_data_queue) = + mock::find_mock_returns(mocks, &input, call.transfer_value()) + && let Some(return_data) = return_data_queue.front().map(|x| x.to_owned()) + { + if let Some(value) = call.transfer_value() { + let checkpoint = ecx.journal_mut().checkpoint(); + match ecx.journal_mut().transfer_loaded( + call.transfer_from(), + call.transfer_to(), + value, + ) { + None => { + if return_data.ret_type.is_ok() { + ecx.journal_mut().checkpoint_commit(); + } else { + ecx.journal_mut().checkpoint_revert(checkpoint); + } + } + Some(err) => { + ecx.journal_mut().checkpoint_revert(checkpoint); + return Some(CallOutcome { + result: InterpreterResult { + result: err.into(), + output: Bytes::new(), + gas, + }, + memory_offset: call.return_memory_offset.clone(), + was_precompile_called: false, + precompile_call_logs: vec![], + charged_new_account_state_gas: call.charged_new_account_state_gas, + }); + } + } + } + + mock::advance_mock_returns(return_data_queue); - if let Some(return_data_queue) = match mocks.get_mut(&ctx) { - Some(queue) => Some(queue), - None => mocks - .iter_mut() - .find(|(mock, _)| { - call.input.bytes(ecx).get(..mock.calldata.len()) == Some(&mock.calldata[..]) - && mock.value.is_none_or(|value| Some(value) == call.transfer_value()) - }) - .map(|(_, v)| v), - } && let Some(return_data) = if return_data_queue.len() == 1 { - // If the mocked calls stack has a single element in it, don't empty it - return_data_queue.front().map(|x| x.to_owned()) - } else { - // Else, we pop the front element - return_data_queue.pop_front() - } { return Some(CallOutcome { result: InterpreterResult { result: return_data.ret_type, @@ -773,60 +1551,38 @@ impl Cheatcodes { gas, }, memory_offset: call.return_memory_offset.clone(), + was_precompile_called: true, + precompile_call_logs: vec![], + charged_new_account_state_gas: call.charged_new_account_state_gas, }); } } - // Apply our prank - if let Some(prank) = &self.get_prank(curr_depth) { - // Apply delegate call, `call.caller`` will not equal `prank.prank_caller` - if prank.delegate_call - && curr_depth == prank.depth - && let CallScheme::DelegateCall = call.scheme - { - call.target_address = prank.new_caller; - call.caller = prank.new_caller; - if let Some(new_origin) = prank.new_origin { - ecx.tx.caller = new_origin; - } - } - - if curr_depth >= prank.depth && call.caller == prank.prank_caller { - let mut prank_applied = false; - - // At the target depth we set `msg.sender` - if curr_depth == prank.depth { - call.caller = prank.new_caller; - prank_applied = true; - } - - // At the target depth, or deeper, we set `tx.origin` - if let Some(new_origin) = prank.new_origin { - ecx.tx.caller = new_origin; - prank_applied = true; - } - - // If prank applied for first time, then update - if prank_applied && let Some(applied_prank) = prank.first_time_applied() { - self.pranks.insert(curr_depth, applied_prank); - } - } - } - // Apply EIP-2930 access list self.apply_accesslist(ecx); // Apply our broadcast - if let Some(broadcast) = &self.broadcast { + if let Some(broadcast) = &mut self.broadcast { + // Additional check as transfers in forge scripts seem to be estimated at 2300 + // by revm leading to "Intrinsic gas too low" failure when simulated on chain. + let is_fixed_gas_limit = call.gas_limit >= 21_000 && !self.dynamic_gas_limit; + self.dynamic_gas_limit = false; + // We only apply a broadcast *to a specific depth*. // // We do this because any subsequent contract calls *must* exist on chain and - // we only want to grab *this* call, not internal ones - if curr_depth == broadcast.depth && call.caller == broadcast.original_caller { + // we only want to grab *this* call, not internal ones. `deployCode` routed through + // the CREATE2 factory runs one level deeper in a nested EVM. + if (curr_depth == broadcast.depth || broadcast.deploy_from_code) + && call.transfer_from() == broadcast.original_caller + { + // Reset deploy from code flag for upcoming calls. + broadcast.deploy_from_code = false; + // At the target depth we set `msg.sender` & tx.origin. // We are simulating the caller as being an EOA, so *both* must be set to the // broadcast.origin. - ecx.tx.caller = broadcast.new_origin; + ecx.tx_mut().set_caller(broadcast.new_origin); call.caller = broadcast.new_origin; // Add a `legacy` transaction to the VecDeque. We use a legacy transaction here @@ -834,7 +1590,7 @@ impl Cheatcodes { // into 1559, in the cli package, relatively easily once we // know the target chain supports EIP-1559. if !call.is_static { - if let Err(err) = ecx.journaled_state.load_account(broadcast.new_origin) { + if let Err(err) = ecx.journal_mut().load_account(broadcast.new_origin) { return Some(CallOutcome { result: InterpreterResult { result: InstructionResult::Revert, @@ -842,26 +1598,29 @@ impl Cheatcodes { gas, }, memory_offset: call.return_memory_offset.clone(), + was_precompile_called: false, + precompile_call_logs: vec![], + charged_new_account_state_gas: call.charged_new_account_state_gas, }); } - let is_fixed_gas_limit = check_if_fixed_gas_limit(&ecx, call.gas_limit); - - let input = TransactionInput::new(call.input.bytes(ecx)); - - let account = - ecx.journaled_state.inner.state().get_mut(&broadcast.new_origin).unwrap(); - - let mut tx_req = TransactionRequest { - from: Some(broadcast.new_origin), - to: Some(TxKind::from(Some(call.target_address))), - value: call.transfer_value(), - input, - nonce: Some(account.info.nonce), - chain_id: Some(ecx.cfg.chain_id), - gas: if is_fixed_gas_limit { Some(call.gas_limit) } else { None }, - ..Default::default() - }; + let input = call.input.bytes(ecx); + let chain_id = ecx.cfg().chain_id(); + let rpc = ecx.db().active_fork_url(); + let fee_token = ecx.tx().fee_token(); + let nonce = + ecx.journal().evm_state().get(&broadcast.new_origin).unwrap().info.nonce; + + let mut tx_req = TransactionRequestFor::::default() + .with_from(broadcast.new_origin) + .with_to(call.transfer_to()) + .with_value(call.transfer_value().unwrap_or_default()) + .with_input(input) + .with_nonce(nonce) + .with_chain_id(chain_id); + if is_fixed_gas_limit { + tx_req.set_gas_limit(call.gas_limit) + } let active_delegations = std::mem::take(&mut self.active_delegations); // Set active blob sidecar, if any. @@ -876,6 +1635,9 @@ impl Cheatcodes { gas, }, memory_offset: call.return_memory_offset.clone(), + was_precompile_called: false, + precompile_call_logs: vec![], + charged_new_account_state_gas: call.charged_new_account_state_gas, }); } tx_req.set_blob_sidecar(blob_sidecar); @@ -883,27 +1645,44 @@ impl Cheatcodes { // Apply active EIP-7702 delegations, if any. if !active_delegations.is_empty() { - for auth in &active_delegations { - let Ok(authority) = auth.recover_authority() else { - continue; - }; - if authority == broadcast.new_origin { - // Increment nonce of broadcasting account to reflect signed - // authorization. - account.info.nonce += 1; - } + if let Err(err) = apply_authorization_nonces::( + ecx, + &active_delegations, + broadcast.new_origin, + chain_id, + ) { + return Some(CallOutcome { + result: InterpreterResult { + result: InstructionResult::Revert, + output: err.abi_encode().into(), + gas, + }, + memory_offset: call.return_memory_offset.clone(), + was_precompile_called: false, + precompile_call_logs: vec![], + charged_new_account_state_gas: call.charged_new_account_state_gas, + }); } - tx_req.authorization_list = Some(active_delegations); + tx_req.set_authorization_list(active_delegations); + } + if let Some(fee_token) = fee_token { + tx_req.set_fee_token(fee_token); } - self.broadcastable_transactions.push_back(BroadcastableTransaction { - rpc: ecx.journaled_state.database.active_fork_url(), - transaction: tx_req.into(), + rpc, + transaction: TransactionMaybeSigned::new(tx_req), + contract_address: None, }); debug!(target: "cheatcodes", tx=?self.broadcastable_transactions.back().unwrap(), "broadcastable call"); - // Explicitly increment nonce if calls are not isolated. - if !self.config.evm_opts.isolate { + // Isolated transactions increment the nonce during execution. Nested + // broadcasts do not start a separate transaction and need this increment. + if !isolate_call { + let account = ecx + .journal_mut() + .evm_state_mut() + .get_mut(&broadcast.new_origin) + .unwrap(); let prev = account.info.nonce; account.info.nonce += 1; debug!(target: "cheatcodes", address=%broadcast.new_origin, nonce=prev+1, prev, "incremented nonce"); @@ -917,6 +1696,9 @@ impl Cheatcodes { gas, }, memory_offset: call.return_memory_offset.clone(), + was_precompile_called: false, + precompile_call_logs: vec![], + charged_new_account_state_gas: call.charged_new_account_state_gas, }); } } @@ -926,21 +1708,20 @@ impl Cheatcodes { if let Some(recorded_account_diffs_stack) = &mut self.recorded_account_diffs_stack { // Determine if account is "initialized," ie, it has a non-zero balance, a non-zero // nonce, a non-zero KECCAK_EMPTY codehash, or non-empty code - let initialized; - let old_balance; - if let Ok(acc) = ecx.journaled_state.load_account(call.target_address) { - initialized = acc.info.exists(); - old_balance = acc.info.balance; - } else { - initialized = false; - old_balance = U256::ZERO; - } + let (initialized, old_balance, old_nonce) = + if let Ok(acc) = ecx.journal_mut().load_account(call.transfer_to()) { + (acc.data.info.exists(), acc.data.info.balance, acc.data.info.nonce) + } else { + (false, U256::ZERO, 0) + }; + let kind = match call.scheme { CallScheme::Call => crate::Vm::AccountAccessKind::Call, CallScheme::CallCode => crate::Vm::AccountAccessKind::CallCode, CallScheme::DelegateCall => crate::Vm::AccountAccessKind::DelegateCall, CallScheme::StaticCall => crate::Vm::AccountAccessKind::StaticCall, }; + // Record this call by pushing it to a new pending vector; all subsequent calls at // that depth will be pushed to the same vector. When the call ends, the // RecordedAccountAccess (and all subsequent RecordedAccountAccesses) will be @@ -948,25 +1729,23 @@ impl Cheatcodes { // as "warm" if the call from which they were accessed is reverted recorded_account_diffs_stack.push(vec![AccountAccess { chainInfo: crate::Vm::ChainInfo { - forkId: ecx.journaled_state.db().active_fork_id().unwrap_or_default(), - chainId: U256::from(ecx.cfg.chain_id), + forkId: ecx.db().active_fork_id().unwrap_or_default(), + chainId: U256::from(ecx.cfg().chain_id()), }, - accessor: call.caller, + accessor: call.transfer_from(), account: call.bytecode_address, kind, initialized, oldBalance: old_balance, newBalance: U256::ZERO, // updated on call_end + oldNonce: old_nonce, + newNonce: 0, // updated on call_end value: call.call_value(), data: call.input.bytes(ecx), reverted: false, deployedCode: Bytes::new(), storageAccesses: vec![], // updated on step - depth: ecx - .journaled_state - .depth() - .try_into() - .expect("journaled state depth exceeds u64"), + depth: ecx.journal().depth().try_into().expect("journaled state depth exceeds u64"), }]); } @@ -1000,6 +1779,61 @@ impl Cheatcodes { self.arbitrary_storage.get_or_insert_with(ArbitraryStorage::default) } + /// Returns addresses explicitly marked with arbitrary storage. + pub fn arbitrary_storage_targets(&self) -> impl Iterator + '_ { + self.arbitrary_storage.as_ref().into_iter().flat_map(ArbitraryStorage::targets) + } + + /// Returns addresses explicitly marked with arbitrary storage and whether nonzero slots are + /// overwritten. + pub fn arbitrary_storage_target_overwrite_modes( + &self, + ) -> impl Iterator + '_ { + self.arbitrary_storage + .as_ref() + .into_iter() + .flat_map(ArbitraryStorage::target_overwrite_modes) + } + + /// Returns addresses that copy storage from arbitrary-storage targets. + pub fn arbitrary_storage_copied_targets(&self) -> impl Iterator + '_ { + self.arbitrary_storage.as_ref().into_iter().flat_map(ArbitraryStorage::copied_targets) + } + + /// Returns copied arbitrary-storage targets and their source address. + pub fn arbitrary_storage_copied_target_sources( + &self, + ) -> impl Iterator + '_ { + self.arbitrary_storage + .as_ref() + .into_iter() + .flat_map(ArbitraryStorage::copied_target_sources) + } + + /// Caches a concrete replay value for a slot on an arbitrary-storage address or copied target. + pub fn cache_arbitrary_storage_value(&mut self, address: Address, slot: U256, value: U256) { + if let Some(storage) = &mut self.arbitrary_storage { + storage.cache_value(address, slot, value); + } + } + + /// Marks a slot as explicitly written with `vm.store`. + pub fn mark_arbitrary_storage_slot_explicit(&mut self, address: Address, slot: U256) { + if let Some(storage) = &mut self.arbitrary_storage { + storage.mark_explicit(address, slot); + } + } + + /// Returns whether a slot was explicitly written with `vm.store`. + pub fn is_arbitrary_storage_slot_explicit(&self, address: Address, slot: U256) -> bool { + self.arbitrary_storage.as_ref().is_some_and(|storage| storage.is_explicit(address, slot)) + } + + /// Returns a cached arbitrary-storage replay value for a slot. + pub fn cached_arbitrary_storage_value(&self, address: Address, slot: U256) -> Option { + self.arbitrary_storage.as_ref().and_then(|storage| storage.cached_value(address, slot)) + } + /// Whether the given address has arbitrary storage. pub fn has_arbitrary_storage(&self, address: &Address) -> bool { match &self.arbitrary_storage { @@ -1036,18 +1870,209 @@ impl Cheatcodes { None => false, } } -} -impl Inspector> for Cheatcodes { + /// Registers an SLOAD callback, replacing the existing callback for `target`. + pub fn register_storage_load_hook( + &mut self, + target: Address, + callback_target: Address, + callback_selector: [u8; 4], + ) { + self.storage_load_hooks.insert(target, StorageHook { callback_target, callback_selector }); + self.storage_hooks_registered = true; + } + + /// Registers an SSTORE callback, replacing the existing callback for `target`. + pub fn register_storage_store_hook( + &mut self, + target: Address, + callback_target: Address, + callback_selector: [u8; 4], + ) { + self.storage_store_hooks.insert(target, StorageHook { callback_target, callback_selector }); + self.storage_hooks_registered = true; + } + + /// Registers a mapping SSTORE callback. Returns false when a raw hook conflicts. + pub fn register_mapping_storage_store_hook( + &mut self, + target: Address, + root_slot: B256, + callback_target: Address, + callback_selector: [u8; 4], + ) -> bool { + if self.storage_store_hooks.contains_key(&target) { + return false; + } + self.storage_hook_mapping_slots.remove(&target); + self.mapping_storage_store_hooks + .entry(target) + .or_default() + .insert(root_slot, StorageHook { callback_target, callback_selector }); + self.storage_hooks_registered = true; + true + } + + /// Returns registered mapping SSTORE callbacks. + pub fn mapping_storage_store_hooks( + &self, + ) -> impl Iterator + '_ { + self.mapping_storage_store_hooks + .iter() + .flat_map(|(target, hooks)| hooks.iter().map(|(root, hook)| (*target, *root, *hook))) + } + + /// Returns whether mapping hooks conflict with a raw store hook. + pub fn has_mapping_storage_store_hooks(&self, target: Address) -> bool { + self.mapping_storage_store_hooks.get(&target).is_some_and(|hooks| !hooks.is_empty()) + } + + /// Returns registered SLOAD callbacks. + pub fn storage_load_hooks(&self) -> impl Iterator + '_ { + self.storage_load_hooks.iter().map(|(target, hook)| (*target, *hook)) + } + + /// Returns registered SSTORE callbacks. + pub fn storage_store_hooks(&self) -> impl Iterator + '_ { + self.storage_store_hooks.iter().map(|(target, hook)| (*target, *hook)) + } + + /// Returns whether any storage callback is registered. + #[inline] + pub const fn has_storage_hooks(&self) -> bool { + self.storage_hooks_registered + } + + /// Clears execution-local mapping provenance while preserving hook registrations. + pub fn clear_storage_hook_mapping_slots(&mut self) { + self.storage_hook_mapping_slots.clear(); + } + + /// Returns whether a synthetic storage-hook callback or one of its child calls is executing. #[inline] - fn initialize_interp(&mut self, interpreter: &mut Interpreter, ecx: Ecx) { + pub const fn is_storage_hook_active(&self) -> bool { + self.active_storage_hook.is_some() + } + + /// Returns whether `call` is the synthetic callback for the active storage hook. + pub fn is_storage_hook_callback( + &self, + ecx: &FoundryContextFor<'_, FEN>, + call: &CallInputs, + ) -> bool { + self.active_storage_hook.as_ref().is_some_and(|active| { + active.outcome.is_none() + && ecx.journal().depth() == active.parent_depth + && call.transfer_from() == CHEATCODE_ADDRESS + && call.transfer_to() == active.callback_target + && call.input.bytes(ecx) == active.callback_input + }) + } + + fn finish_storage_hook_call( + &mut self, + ecx: &FoundryContextFor<'_, FEN>, + call: &CallInputs, + outcome: &CallOutcome, + ) -> bool { + let Some(active) = self.active_storage_hook.as_mut() else { return false }; + if active.outcome.is_some() + || ecx.journal().depth() != active.parent_depth + || call.transfer_from() != CHEATCODE_ADDRESS + || call.transfer_to() != active.callback_target + || call.input.bytes(ecx) != active.callback_input + { + return false; + } + active.outcome = Some((outcome.result.result, outcome.result.output.clone())); + true + } + + #[inline(always)] + pub fn has_step_hooks(&self) -> bool { + self.broadcast.is_some() + || self.gas_metering.paused + || self.gas_metering.reset + || self.recording_accesses + || self.recorded_account_diffs_stack.is_some() + || !self.allowed_mem_writes.is_empty() + || self.mapping_slots.is_some() + || self.gas_metering.recording + || self.has_active_env_overrides() + || self.has_storage_hooks() + } + + #[inline(always)] + pub fn has_step_end_hooks(&self) -> bool { + self.gas_metering.paused + || self.gas_metering.touched + || self.arbitrary_storage.is_some() + || self.mapping_slots.is_some() + || self.has_active_env_overrides() + || self.has_storage_hooks() + } + + #[inline(always)] + pub fn has_log_hooks(&self) -> bool { + !self.expected_emits.is_empty() || self.recorded_logs.is_some() + } + + #[inline(always)] + pub fn has_recording_accesses_only_step_hook(&self) -> bool { + self.recording_accesses + && self.broadcast.is_none() + && !self.gas_metering.paused + && !self.gas_metering.reset + && self.recorded_account_diffs_stack.is_none() + && self.allowed_mem_writes.is_empty() + && self.mapping_slots.is_none() + && !self.has_storage_hooks() + && !self.gas_metering.recording + && !self.has_active_env_overrides() + } + + #[inline(always)] + fn has_active_env_overrides(&self) -> bool { + self.env_overrides.is_any_set() + } + + /// Returns struct definitions from the analysis, if available. + pub fn struct_defs(&self) -> Option<&foundry_common::fmt::StructDefinitions> { + self.analysis.as_ref().and_then(|analysis| analysis.struct_defs().ok()) + } +} + +const fn frame_gas(result: &InterpreterResult) -> Vm::Gas { + let gas = &result.gas; + // A halt consumes the regular gas restored while rolling back state gas. + let regular_gas_spent = if result.is_halt() { + gas.total_gas_spent() + } else { + gas.total_gas_spent().saturating_sub(gas.state_gas_spilled()) + }; + Vm::Gas { + gasLimit: gas.limit(), + gasTotalUsed: regular_gas_spent, + gasMemoryUsed: 0, + gasRefunded: gas.refunded(), + gasRemaining: gas.remaining(), + gasStateUsed: if result.is_ok() { gas.state_gas_spent() } else { 0 }, + } +} + +impl Inspector> for Cheatcodes { + fn initialize_interp( + &mut self, + interpreter: &mut Interpreter, + ecx: &mut FoundryContextFor<'_, FEN>, + ) { // When the first interpreter is initialized we've circumvented the balance and gas checks, // so we apply our actual block data with the correct fees and all. if let Some(block) = self.block.take() { - ecx.block = block; + ecx.set_block(block); } if let Some(gas_price) = self.gas_price.take() { - ecx.tx.gas_price = gas_price; + ecx.tx_mut().set_gas_price(gas_price); } // Record gas for current frame. @@ -1057,14 +2082,51 @@ impl Inspector> for Cheatcodes { // `expectRevert`: track the max call depth during `expectRevert` if let Some(expected) = &mut self.expected_revert { - expected.max_depth = max(ecx.journaled_state.depth(), expected.max_depth); + expected.max_depth = max(ecx.journal().depth(), expected.max_depth); } } - #[inline] - fn step(&mut self, interpreter: &mut Interpreter, ecx: Ecx) { + fn step(&mut self, interpreter: &mut Interpreter, ecx: &mut FoundryContextFor<'_, FEN>) { self.pc = interpreter.bytecode.pc(); + if !self.has_step_hooks() { + return; + } + + if self.finish_storage_hook_callback(interpreter, ecx) { + return; + } + + if self.broadcast.is_some() { + self.set_gas_limit_type(interpreter); + } + + // Broadcasting changes outgoing calls, not the caller of the script's current frame. + // Only protect the broadcasting frame; callbacks into the script have their own caller. + if interpreter.bytecode.opcode() == op::CALLER + && let Some(broadcast) = &self.broadcast + && let Some(script_address) = self.script_address + && ecx.journal().depth() == broadcast.depth + && interpreter.input.target_address == script_address + && interpreter.input.bytecode_address == Some(script_address) + && interpreter.input.caller_address != broadcast.new_origin + { + interpreter.bytecode.set_action(InterpreterAction::new_return( + InstructionResult::Revert, + Bytes::from( + format!( + "Usage of `msg.sender` inside a `broadcast` in script contract detected. \ + `msg.sender` is `{:#x}`, not the broadcast sender `{:#x}`. \ + Use the `--sender` flag or pass the deployer address directly instead.", + interpreter.input.caller_address, broadcast.new_origin, + ) + .into_bytes(), + ), + interpreter.gas, + )); + return; + } + // `pauseGasMetering`: pause / resume interpreter gas. if self.gas_metering.paused { self.meter_gas(interpreter); @@ -1089,23 +2151,77 @@ impl Inspector> for Cheatcodes { if !self.allowed_mem_writes.is_empty() { self.check_mem_opcodes( interpreter, - ecx.journaled_state.depth().try_into().expect("journaled state depth exceeds u64"), + ecx.journal().depth().try_into().expect("journaled state depth exceeds u64"), ); } - // `startMappingRecording`: record SSTORE and KECCAK256. - if let Some(mapping_slots) = &mut self.mapping_slots { - mapping::step(mapping_slots, interpreter); + if self.mapping_slots.is_some() || !self.mapping_storage_store_hooks.is_empty() { + // `startMappingRecording`: record SSTORE. + if let Some(mapping_slots) = &mut self.mapping_slots { + mapping_step(mapping_slots, interpreter); + } + + let account = interpreter.input.target_address; + let mapping_hook_active = self.active_storage_hook.is_none() + && self + .mapping_storage_store_hooks + .get(&account) + .is_some_and(|hooks| !hooks.is_empty()); + if mapping_hook_active { + mapping_step(&mut self.storage_hook_mapping_slots, interpreter); + } + self.pending_mapping_hash = if self.mapping_slots.is_some() || mapping_hook_active { + capture_mapping_hash(interpreter) + } else { + None + }; } // `snapshotGas*`: take a snapshot of the current gas. if self.gas_metering.recording { self.meter_gas_record(interpreter, ecx); } + + // Capture the opcode for `step_end` to use, since by the time + // `step_end` runs the PC has already advanced past it. Also peek the + // BLOBHASH index now (still on top of stack before execution) so we + // can look up the override later. + if !self.env_overrides.is_empty() { + let fork_id = ecx.db().active_fork_id(); + if let Some(env_overrides) = + self.env_overrides.get_mut(fork_id).filter(|o| o.is_any_set()) + { + // Always clear stale pending state first so a leftover value from + // a prior step (e.g. when `peek` failed, or when an override + // wasn't actually used) cannot leak into the next opcode. + env_overrides.pending_opcode = None; + env_overrides.pending_blobhash_index = None; + + let opcode = interpreter.bytecode.opcode(); + match opcode { + op::BASEFEE | op::GASPRICE => { + env_overrides.pending_opcode = Some(opcode); + } + op::BLOBHASH => { + env_overrides.pending_opcode = Some(opcode); + env_overrides.pending_blobhash_index = + interpreter.stack.peek(0).ok().and_then(|index| index.try_into().ok()); + } + _ => {} + } + } + } + + if self.active_storage_hook.is_none() { + self.capture_storage_hook(interpreter, ecx); + } } - #[inline] - fn step_end(&mut self, interpreter: &mut Interpreter, ecx: Ecx) { + fn step_end(&mut self, interpreter: &mut Interpreter, ecx: &mut FoundryContextFor<'_, FEN>) { + if !self.has_step_end_hooks() { + return; + } + if self.gas_metering.paused { self.meter_gas_end(interpreter); } @@ -1118,41 +2234,144 @@ impl Inspector> for Cheatcodes { if self.arbitrary_storage.is_some() { self.arbitrary_storage_end(interpreter, ecx); } + + if let Some(pending) = self.pending_mapping_hash.take() + && interpreter + .bytecode + .action + .as_ref() + .and_then(InterpreterAction::instruction_result) + .is_none() + { + if let Some(mapping_slots) = &mut self.mapping_slots { + record_mapping_hash(mapping_slots, interpreter, pending); + } + if self + .mapping_storage_store_hooks + .get(&pending.address) + .is_some_and(|hooks| !hooks.is_empty()) + && self.active_storage_hook.is_none() + { + record_mapping_hash(&mut self.storage_hook_mapping_slots, interpreter, pending); + } + } + + if self.active_storage_hook.is_none() { + self.invoke_pending_storage_hook(interpreter, ecx); + } + + // Apply opcode-level env overrides (basefee/gasprice/blobhash). Needed + // in isolation mode where the actual tx/block env is zeroed for + // fee-accounting; in non-isolation mode the override and the real env + // are kept in sync by the cheatcode handlers, so this is a no-op fixup. + // + // We must only rewrite the stack if the opcode actually completed + // successfully and pushed its result; otherwise (stack underflow on + // BLOBHASH, OOG before push, etc.) the stack is in an error state and + // a blind `pop()+push()` would corrupt the failing frame. + if !self.env_overrides.is_empty() { + let fork_id = ecx.db().active_fork_id(); + if self.env_overrides.get(fork_id).is_some_and(|o| o.is_any_set()) { + // Mirrors the pattern used by `meter_gas_record`: when `action` is + // `Some` with an `instruction_result`, the opcode has set a + // non-continue result (halt/revert/error) — i.e. it didn't push + // its normal result. `None` means "still running", which is the + // success path for a stack-only opcode in `step_end`. + let opcode_failed = interpreter + .bytecode + .action + .as_ref() + .and_then(|a| a.instruction_result()) + .is_some(); + if opcode_failed { + if let Some(env_overrides) = self.env_overrides.get_mut(fork_id) { + env_overrides.pending_opcode = None; + env_overrides.pending_blobhash_index = None; + } + } else { + self.apply_env_overrides(interpreter, fork_id); + } + } + } } - fn log(&mut self, interpreter: &mut Interpreter, _ecx: Ecx, log: Log) { - if !self.expected_emits.is_empty() { - expect::handle_expect_emit(self, &log, interpreter); + fn log(&mut self, _ecx: &mut FoundryContextFor<'_, FEN>, log: Log) { + if !self.expected_emits.is_empty() + && let Some(err) = expect::handle_expect_emit(self, &log, None) + { + // Because we do not have access to the interpreter here, we cannot fail the test + // immediately. In most cases the failure will still be caught on `call_end`. + // In the rare case it is not, we log the error here. + let _ = sh_err!("{err:?}"); } // `recordLogs` - if let Some(storage_recorded_logs) = &mut self.recorded_logs { - storage_recorded_logs.push(Vm::Log { - topics: log.data.topics().to_vec(), - data: log.data.data.clone(), - emitter: log.address, - }); + record_logs(&mut self.recorded_logs, &log); + } + + fn log_full( + &mut self, + interpreter: &mut Interpreter, + _ecx: &mut FoundryContextFor<'_, FEN>, + log: Log, + ) { + if !self.expected_emits.is_empty() { + expect::handle_expect_emit(self, &log, Some(interpreter)); } + + // `recordLogs` + record_logs(&mut self.recorded_logs, &log); } - fn call(&mut self, ecx: Ecx, inputs: &mut CallInputs) -> Option { - Self::call_with_executor(self, ecx, inputs, &mut TransparentCheatcodesExecutor) + fn call( + &mut self, + ecx: &mut FoundryContextFor<'_, FEN>, + inputs: &mut CallInputs, + ) -> Option { + if self.is_storage_hook_callback(ecx, inputs) { + return None; + } + Self::call_with_executor(self, ecx, inputs, &mut TransparentCheatcodesExecutor, false) } - fn call_end(&mut self, ecx: Ecx, call: &CallInputs, outcome: &mut CallOutcome) { - let cheatcode_call = call.target_address == CHEATCODE_ADDRESS - || call.target_address == HARDHAT_CONSOLE_ADDRESS; + fn call_end( + &mut self, + ecx: &mut FoundryContextFor<'_, FEN>, + call: &CallInputs, + outcome: &mut CallOutcome, + ) { + let isolated_snapshot_gas_used = self + .gas_metering + .take_isolated_snapshot_gas_used(ecx.journal().depth(), &outcome.result.gas); + if self.finish_storage_hook_call(ecx, call, outcome) { + return; + } + + let cheatcode_call = call.transfer_to() == CHEATCODE_ADDRESS + || call.transfer_to() == HARDHAT_CONSOLE_ADDRESS; + #[cfg(feature = "monad")] + let cheatcode_call = cheatcode_call + || crate::monad::is_monad_cheatcode_call( + self.extra_cheatcode_addresses, + call.transfer_to(), + ); + let curr_depth = ecx.journal().depth(); + + self.finish_created_accounts_frame( + outcome.result.is_ok(), + CreatedAccountsFrameKind::Call, + curr_depth, + ); // Clean up pranks/broadcasts if it's not a cheatcode call end. We shouldn't do // it for cheatcode calls because they are not applied for cheatcodes in the `call` hook. // This should be placed before the revert handling, because we might exit early there if !cheatcode_call { // Clean up pranks - let curr_depth = ecx.journaled_state.depth(); if let Some(prank) = &self.get_prank(curr_depth) && curr_depth == prank.depth { - ecx.tx.caller = prank.prank_origin; + ecx.tx_mut().set_caller(prank.prank_origin); // Clean single-call prank once we have returned to the original depth if prank.single_call { @@ -1164,7 +2383,7 @@ impl Inspector> for Cheatcodes { if let Some(broadcast) = &self.broadcast && curr_depth == broadcast.depth { - ecx.tx.caller = broadcast.original_origin; + ecx.tx_mut().set_caller(broadcast.original_origin); // Clean single-call broadcast once we have returned to the original depth if broadcast.single_call { @@ -1178,11 +2397,11 @@ impl Inspector> for Cheatcodes { // Record current reverter address before processing the expect revert if call reverted, // expect revert is set with expected reverter address and no actual reverter set yet. if outcome.result.is_revert() && assume_no_revert.reverted_by.is_none() { - assume_no_revert.reverted_by = Some(call.target_address); + assume_no_revert.reverted_by = Some(call.transfer_to()); } // allow multiple cheatcode calls at the same depth - let curr_depth = ecx.journaled_state.depth(); + let curr_depth = ecx.journal().depth(); if curr_depth <= assume_no_revert.depth && !cheatcode_call { // Discard run if we're at the same depth as cheatcode, call reverted, and no // specific reason was supplied @@ -1207,10 +2426,9 @@ impl Inspector> for Cheatcodes { outcome.result.output = error.abi_encode().into(); } }; - } else { - // Call didn't revert, reset `assume_no_revert` state. - self.assume_no_revert = None; } + // Call didn't revert, reset `assume_no_revert` state. + self.assume_no_revert = None; } } @@ -1218,30 +2436,31 @@ impl Inspector> for Cheatcodes { if let Some(expected_revert) = &mut self.expected_revert { // Record current reverter address and call scheme before processing the expect revert // if call reverted. - if outcome.result.is_revert() { + let call_failed = !outcome.result.result.is_ok(); + if call_failed { // Record current reverter address if expect revert is set with expected reverter // address and no actual reverter was set yet or if we're expecting more than one // revert. if expected_revert.reverter.is_some() && (expected_revert.reverted_by.is_none() || expected_revert.count > 1) { - expected_revert.reverted_by = Some(call.target_address); + expected_revert.reverted_by = Some(call.transfer_to()); } } - let curr_depth = ecx.journaled_state.depth(); + let curr_depth = ecx.journal().depth(); if curr_depth <= expected_revert.depth { - let needs_processing = match expected_revert.kind { - ExpectedRevertKind::Default => !cheatcode_call, - // `pending_processing` == true means that we're in the `call_end` hook for - // `vm.expectCheatcodeRevert` and shouldn't expect revert here - ExpectedRevertKind::Cheatcode { pending_processing } => { - cheatcode_call && !pending_processing - } - }; + let needs_processing = expected_revert.needs_processing( + cheatcode_call, + call_failed, + curr_depth, + self.config.internal_expect_revert, + ); if needs_processing { let mut expected_revert = std::mem::take(&mut self.expected_revert).unwrap(); + let clear_last_frame_gas = + matches!(expected_revert.kind, ExpectedRevertKind::Default); return match revert_handlers::handle_expect_revert( cheatcode_call, false, @@ -1259,7 +2478,10 @@ impl Inspector> for Cheatcodes { Ok((_, retdata)) => { expected_revert.actual_count += 1; if expected_revert.actual_count < expected_revert.count { - self.expected_revert = Some(expected_revert.clone()); + self.expected_revert = Some(expected_revert); + } + if clear_last_frame_gas { + self.gas_metering.last_frame_gas = None; } outcome.result.result = InstructionResult::Return; outcome.result.output = retdata; @@ -1283,34 +2505,27 @@ impl Inspector> for Cheatcodes { return; } - // Record the gas usage of the call, this allows the `lastCallGas` cheatcode to - // retrieve the gas usage of the last call. - let gas = outcome.result.gas; - self.gas_metering.last_call_gas = Some(crate::Vm::Gas { - gasLimit: gas.limit(), - gasTotalUsed: gas.spent(), - gasMemoryUsed: 0, - gasRefunded: gas.refunded(), - gasRemaining: gas.remaining(), - }); + // Record the gas usage of the call, this allows the `lastFrameGas` cheatcode to + // retrieve the gas usage of the last call or create. + let frame_gas = frame_gas(&outcome.result); + let snapshot_gas_used = + isolated_snapshot_gas_used.unwrap_or_else(|| outcome.result.gas.total_gas_spent()); + self.gas_metering.last_call_gas = Some(frame_gas.clone()); + self.gas_metering.last_frame_gas = Some(frame_gas); + self.gas_metering.last_call_snapshot_gas_used = snapshot_gas_used; + self.gas_metering.last_frame_snapshot_gas_used = snapshot_gas_used; // If `startStateDiffRecording` has been called, update the `reverted` status of the // previous call depth's recorded accesses, if any if let Some(recorded_account_diffs_stack) = &mut self.recorded_account_diffs_stack { // The root call cannot be recorded. - if ecx.journaled_state.depth() > 0 - && let Some(last_recorded_depth) = &mut recorded_account_diffs_stack.pop() + if ecx.journal().depth() > 0 + && let Some(mut last_recorded_depth) = recorded_account_diffs_stack.pop() { // Update the reverted status of all deeper calls if this call reverted, in // accordance with EVM behavior if outcome.result.is_revert() { - last_recorded_depth.iter_mut().for_each(|element| { - element.reverted = true; - element - .storageAccesses - .iter_mut() - .for_each(|storage_access| storage_access.reverted = true); - }) + mark_account_accesses_reverted(&mut last_recorded_depth); } if let Some(call_access) = last_recorded_depth.first_mut() { @@ -1318,129 +2533,61 @@ impl Inspector> for Cheatcodes { // changes. Depending on the depth the cheat was // called at, there may not be any pending // calls to update if execution has percolated up to a higher depth. - let curr_depth = ecx.journaled_state.depth(); + let curr_depth = ecx.journal().depth(); if call_access.depth == curr_depth as u64 - && let Ok(acc) = ecx.journaled_state.load_account(call.target_address) + && let Ok(acc) = ecx.journal_mut().load_account(call.transfer_to()) { debug_assert!(access_is_call(call_access.kind)); - call_access.newBalance = acc.info.balance; - } - // Merge the last depth's AccountAccesses into the AccountAccesses at the - // current depth, or push them back onto the pending - // vector if higher depths were not recorded. This - // preserves ordering of accesses. - if let Some(last) = recorded_account_diffs_stack.last_mut() { - last.append(last_recorded_depth); - } else { - recorded_account_diffs_stack.push(last_recorded_depth.clone()); + call_access.newBalance = acc.data.info.balance; + call_access.newNonce = acc.data.info.nonce; } + merge_recorded_frame(recorded_account_diffs_stack, last_recorded_depth); } } } - // At the end of the call, - // we need to check if we've found all the emits. - // We know we've found all the expected emits in the right order - // if the queue is fully matched. - // If it's not fully matched, then either: - // 1. Not enough events were emitted (we'll know this because the amount of times we - // inspected events will be less than the size of the queue) 2. The wrong events - // were emitted (The inspected events should match the size of the queue, but still some - // events will not be matched) - - // First, check that we're at the call depth where the emits were declared from. - let should_check_emits = self - .expected_emits - .iter() - .any(|(expected, _)| { - let curr_depth = ecx.journaled_state.depth(); - expected.depth == curr_depth - }) && - // Ignore staticcalls - !call.is_static; - if should_check_emits { - let expected_counts = self - .expected_emits - .iter() - .filter_map(|(expected, count_map)| { - let count = match expected.address { - Some(emitter) => match count_map.get(&emitter) { - Some(log_count) => expected - .log - .as_ref() - .map(|l| log_count.count(l)) - .unwrap_or_else(|| log_count.count_unchecked()), - None => 0, - }, - None => match &expected.log { - Some(log) => count_map.values().map(|logs| logs.count(log)).sum(), - None => count_map.values().map(|logs| logs.count_unchecked()).sum(), - }, - }; - - if count != expected.count { Some((expected, count)) } else { None } - }) - .collect::>(); - - // Revert if not all emits expected were matched. - if self.expected_emits.iter().any(|(expected, _)| !expected.found && expected.count > 0) - { - outcome.result.result = InstructionResult::Revert; - outcome.result.output = "log != expected log".abi_encode().into(); - return; - } - - if !expected_counts.is_empty() { - let msg = if outcome.result.is_ok() { - let (expected, count) = expected_counts.first().unwrap(); - format!("log emitted {count} times, expected {}", expected.count) - } else { - "expected an emit, but the call reverted instead. \ - ensure you're testing the happy path when using `expectEmit`" - .to_string() - }; - - outcome.result.result = InstructionResult::Revert; - outcome.result.output = Error::encode(msg); - return; - } - - // All emits were found, we're good. - // Clear the queue, as we expect the user to declare more events for the next call - // if they wanna match further events. - self.expected_emits.clear() - } - // this will ensure we don't have false positives when trying to diagnose reverts in fork // mode let diag = self.fork_revert_diagnostic.take(); - // if there's a revert and a previous call was diagnosed as fork related revert then we can - // return a better error here - if outcome.result.is_revert() - && let Some(err) = diag - { - outcome.result.output = Error::encode(err.to_error_msg(&self.labels)); + // If the call already reverted, preserve that primary failure and skip post-call + // expect* validation so it cannot overwrite the original revert. + if outcome.result.is_revert() { + // if there's a revert and a previous call was diagnosed as fork related revert then we + // can return a better error here + if let Some(err) = diag { + outcome.result.output = Error::encode(err.to_error_msg(&self.labels)); + } + return; + } + + if let Some(unmet) = expected_emit::check_call_emits( + &mut self.expected_emits, + ecx.journal().depth(), + call.is_static, + outcome.result.is_ok(), + ) { + outcome.result.result = InstructionResult::Revert; + outcome.result.output = unmet.encode(|| self.signatures_identifier()); return; } // try to diagnose reverts in multi-fork mode where a call is made to an address that does // not exist - if let TxKind::Call(test_contract) = ecx.tx.kind { + if let TxKind::Call(test_contract) = ecx.tx().kind() { // if a call to a different contract than the original test contract returned with // `Stop` we check if the contract actually exists on the active fork - if ecx.journaled_state.db().is_forked_mode() + if ecx.db().is_forked_mode() && outcome.result.result == InstructionResult::Stop - && call.target_address != test_contract + && call.transfer_to() != test_contract { - let journaled_state = ecx.journaled_state.clone(); self.fork_revert_diagnostic = - ecx.journaled_state.db().diagnose_revert(call.target_address, &journaled_state); + ecx.db().diagnose_revert(call.transfer_to(), ecx.journal().evm_state()); } } // If the depth is 0, then this is the root call terminating - if ecx.journaled_state.depth() == 0 { + if ecx.journal().depth() == 0 { // If we already have a revert, we shouldn't run the below logic as it can obfuscate an // earlier error that happened first with unrelated information about // another error when using cheatcodes. @@ -1452,87 +2599,50 @@ impl Inspector> for Cheatcodes { // cheatcodes. // Match expected calls - for (address, calldatas) in &self.expected_calls { - // Loop over each address, and for each address, loop over each calldata it expects. - for (calldata, (expected, actual_count)) in calldatas { - // Grab the values we expect to see - let ExpectedCallData { gas, min_gas, value, count, call_type } = expected; - - let failed = match call_type { - // If the cheatcode was called with a `count` argument, - // we must check that the EVM performed a CALL with this calldata exactly - // `count` times. - ExpectedCallType::Count => *count != *actual_count, - // If the cheatcode was called without a `count` argument, - // we must check that the EVM performed a CALL with this calldata at least - // `count` times. The amount of times to check was - // the amount of time the cheatcode was called. - ExpectedCallType::NonCount => *count > *actual_count, - }; - if failed { - let expected_values = [ - Some(format!("data {}", hex::encode_prefixed(calldata))), - value.as_ref().map(|v| format!("value {v}")), - gas.map(|g| format!("gas {g}")), - min_gas.map(|g| format!("minimum gas {g}")), - ] - .into_iter() - .flatten() - .join(", "); - let but = if outcome.result.is_ok() { - let s = if *actual_count == 1 { "" } else { "s" }; - format!("was called {actual_count} time{s}") - } else { - "the call reverted instead; \ - ensure you're testing the happy path when using `expectCall`" - .to_string() - }; - let s = if *count == 1 { "" } else { "s" }; - let msg = format!( - "expected call to {address} with {expected_values} \ - to be called {count} time{s}, but {but}" - ); - outcome.result.result = InstructionResult::Revert; - outcome.result.output = Error::encode(msg); - - return; - } - } + if let Some(msg) = + expect::first_unmet_call(&self.expected_calls, outcome.result.is_ok()) + { + outcome.result.result = InstructionResult::Revert; + outcome.result.output = Error::encode(msg); + return; } // Check if we have any leftover expected emits - // First, if any emits were found at the root call, then we its ok and we remove them. - self.expected_emits.retain(|(expected, _)| expected.count > 0 && !expected.found); - // If not empty, we got mismatched emits - if !self.expected_emits.is_empty() { - let msg = if outcome.result.is_ok() { - "expected an emit, but no logs were emitted afterwards. \ - you might have mismatched events or not enough events were emitted" - } else { - "expected an emit, but the call reverted instead. \ - ensure you're testing the happy path when using `expectEmit`" - }; + if let Some(msg) = expected_emit::first_unmet_root_emit( + &mut self.expected_emits, + outcome.result.is_ok(), + ) { outcome.result.result = InstructionResult::Revert; outcome.result.output = Error::encode(msg); return; } // Check for leftover expected creates - if let Some(expected_create) = self.expected_creates.first() { - let msg = format!( - "expected {} call by address {} for bytecode {} but not found", - expected_create.create_scheme, - hex::encode_prefixed(expected_create.deployer), - hex::encode_prefixed(&expected_create.bytecode), - ); + if let Some(msg) = expect::first_unmet_create(&self.expected_creates) { outcome.result.result = InstructionResult::Revert; outcome.result.output = Error::encode(msg); } } } - fn create(&mut self, ecx: Ecx, mut input: &mut CreateInputs) -> Option { + fn create( + &mut self, + ecx: &mut FoundryContextFor<'_, FEN>, + mut input: &mut CreateInputs, + ) -> Option { + // Apply custom execution evm version. + if let Some(spec_id) = self.execution_evm_version { + EvmFactoryFor::::set_execution_spec(ecx, spec_id); + } + let gas = Gas::new(input.gas_limit()); + let curr_depth = ecx.journal().depth(); + self.start_created_accounts_frame( + curr_depth == 0, + CreatedAccountsFrameKind::Create, + curr_depth, + ); + // Check if we should intercept this create if self.intercept_next_create_call { // Reset the flag @@ -1545,33 +2655,24 @@ impl Inspector> for Cheatcodes { return Some(CreateOutcome { result: InterpreterResult { result: InstructionResult::Revert, output, gas }, address: None, + charged_create_state_gas: input.charged_create_state_gas(), }); } - let curr_depth = ecx.journaled_state.depth(); - // Apply our prank - if let Some(prank) = &self.get_prank(curr_depth) - && curr_depth >= prank.depth - && input.caller() == prank.prank_caller + if let Some(prank) = self.get_prank(curr_depth) + && let Some(changes) = prank.changes_for(curr_depth, input.caller()) { - let mut prank_applied = false; - - // At the target depth we set `msg.sender` - if curr_depth == prank.depth { - input.set_caller(prank.new_caller); - prank_applied = true; + if let Some(new_caller) = changes.caller { + // Ensure new caller is loaded and touched + let _ = journaled_account(ecx, new_caller); + input.set_caller(new_caller); } - - // At the target depth, or deeper, we set `tx.origin` - if let Some(new_origin) = prank.new_origin { - ecx.tx.caller = new_origin; - prank_applied = true; + if let Some(new_origin) = changes.origin { + ecx.tx_mut().set_caller(new_origin); } - - // If prank applied for first time, then update - if prank_applied && let Some(applied_prank) = prank.first_time_applied() { - self.pranks.insert(curr_depth, applied_prank); + if let Some(used) = changes.used { + self.pranks.insert(curr_depth, used); } } @@ -1579,11 +2680,11 @@ impl Inspector> for Cheatcodes { self.apply_accesslist(ecx); // Apply our broadcast - if let Some(broadcast) = &self.broadcast + if let Some(broadcast) = &mut self.broadcast && curr_depth >= broadcast.depth && input.caller() == broadcast.original_caller { - if let Err(err) = ecx.journaled_state.load_account(broadcast.new_origin) { + if let Err(err) = ecx.journal_mut().load_account(broadcast.new_origin) { return Some(CreateOutcome { result: InterpreterResult { result: InstructionResult::Revert, @@ -1591,28 +2692,34 @@ impl Inspector> for Cheatcodes { gas, }, address: None, + charged_create_state_gas: input.charged_create_state_gas(), }); } - ecx.tx.caller = broadcast.new_origin; + ecx.tx_mut().set_caller(broadcast.new_origin); + + if curr_depth == broadcast.depth || broadcast.deploy_from_code { + // Reset deploy from code flag for upcoming calls; + broadcast.deploy_from_code = false; - if curr_depth == broadcast.depth { input.set_caller(broadcast.new_origin); - let is_fixed_gas_limit = check_if_fixed_gas_limit(&ecx, input.gas_limit()); - let account = &ecx.journaled_state.inner.state()[&broadcast.new_origin]; + let rpc = ecx.db().active_fork_url(); + let fee_token = ecx.tx().fee_token(); + let account = &ecx.journal().evm_state()[&broadcast.new_origin]; + let mut tx_req = TransactionRequestFor::::default() + .with_from(broadcast.new_origin) + .with_kind(TxKind::Create) + .with_value(input.value()) + .with_input(input.init_code()) + .with_nonce(account.info.nonce); + if let Some(fee_token) = fee_token { + tx_req.set_fee_token(fee_token); + } self.broadcastable_transactions.push_back(BroadcastableTransaction { - rpc: ecx.journaled_state.database.active_fork_url(), - transaction: TransactionRequest { - from: Some(broadcast.new_origin), - to: None, - value: Some(input.value()), - input: TransactionInput::new(input.init_code()), - nonce: Some(account.info.nonce), - gas: if is_fixed_gas_limit { Some(input.gas_limit()) } else { None }, - ..Default::default() - } - .into(), + rpc, + transaction: TransactionMaybeSigned::new(tx_req), + contract_address: None, }); input.log_debug(self, &input.scheme().unwrap_or(CreateScheme::Create)); @@ -1622,12 +2729,14 @@ impl Inspector> for Cheatcodes { // Allow cheatcodes from the address of the new contract let address = input.allow_cheatcodes(self, ecx); + self.record_created_account(ecx.db().active_fork_id(), address); + // If `recordAccountAccesses` has been called, record the create if let Some(recorded_account_diffs_stack) = &mut self.recorded_account_diffs_stack { recorded_account_diffs_stack.push(vec![AccountAccess { chainInfo: crate::Vm::ChainInfo { - forkId: ecx.journaled_state.db().active_fork_id().unwrap_or_default(), - chainId: U256::from(ecx.cfg.chain_id), + forkId: ecx.db().active_fork_id().unwrap_or_default(), + chainId: U256::from(ecx.cfg().chain_id()), }, accessor: input.caller(), account: address, @@ -1635,6 +2744,8 @@ impl Inspector> for Cheatcodes { initialized: true, oldBalance: U256::ZERO, // updated on create_end newBalance: U256::ZERO, // updated on create_end + oldNonce: 0, // new contract starts with nonce 0 + newNonce: 1, // updated on create_end (contracts start with nonce 1) value: input.value(), data: input.init_code(), reverted: false, @@ -1647,19 +2758,33 @@ impl Inspector> for Cheatcodes { None } - fn create_end(&mut self, ecx: Ecx, call: &CreateInputs, outcome: &mut CreateOutcome) { + fn create_end( + &mut self, + ecx: &mut FoundryContextFor<'_, FEN>, + call: &CreateInputs, + outcome: &mut CreateOutcome, + ) { + let isolated_snapshot_gas_used = self + .gas_metering + .take_isolated_snapshot_gas_used(ecx.journal().depth(), &outcome.result.gas); let call = Some(call); - let curr_depth = ecx.journaled_state.depth(); + let curr_depth = ecx.journal().depth(); + + self.finish_created_accounts_frame( + outcome.result.is_ok(), + CreatedAccountsFrameKind::Create, + curr_depth, + ); // Clean up pranks if let Some(prank) = &self.get_prank(curr_depth) && curr_depth == prank.depth { - ecx.tx.caller = prank.prank_origin; + ecx.tx_mut().set_caller(prank.prank_origin); // Clean single-call prank once we have returned to the original depth if prank.single_call { - std::mem::take(&mut self.pranks); + self.pranks.remove(&curr_depth); } } @@ -1667,7 +2792,7 @@ impl Inspector> for Cheatcodes { if let Some(broadcast) = &self.broadcast && curr_depth == broadcast.depth { - ecx.tx.caller = broadcast.original_origin; + ecx.tx_mut().set_caller(broadcast.original_origin); // Clean single-call broadcast once we have returned to the original depth if broadcast.single_call { @@ -1675,124 +2800,140 @@ impl Inspector> for Cheatcodes { } } - // Handle expected reverts - if let Some(expected_revert) = &self.expected_revert - && curr_depth <= expected_revert.depth - && matches!(expected_revert.kind, ExpectedRevertKind::Default) - { - let mut expected_revert = std::mem::take(&mut self.expected_revert).unwrap(); - return match revert_handlers::handle_expect_revert( - false, - true, - self.config.internal_expect_revert, - &expected_revert, - outcome.result.result, - outcome.result.output.clone(), - &self.config.available_artifacts, - ) { - Ok((address, retdata)) => { - expected_revert.actual_count += 1; - if expected_revert.actual_count < expected_revert.count { - self.expected_revert = Some(expected_revert.clone()); + // Handle expected reverts. + if let Some(expected_revert) = &mut self.expected_revert { + // Record the would-be deployed address as the reverter, picking the innermost + // reverting CREATE: this hook runs at every depth, the deepest frame fires + // first, and the `is_none()` lock pins it. For `count > 1` the lock is + // released after each successful iteration (see below) so each iteration + // independently records its own innermost CREATE. + // + // This intentionally differs from `call_end` for `count > 1`, where + // legacy nested CALL handling reports the outermost call per iteration. + // + // `outcome.address` is `None` for pre-frame rejection (depth/balance/nonce); + // in that case the surrounding `call_end` records the caller as the reverter. + if outcome.result.is_revert() + && expected_revert.reverter.is_some() + && expected_revert.reverted_by.is_none() + && let Some(addr) = outcome.address + { + expected_revert.reverted_by = Some(addr); + } + + if curr_depth <= expected_revert.depth + && matches!(expected_revert.kind, ExpectedRevertKind::Default) + { + let mut expected_revert = std::mem::take(&mut self.expected_revert).unwrap(); + return match revert_handlers::handle_expect_revert( + false, + true, + self.config.internal_expect_revert, + &expected_revert, + outcome.result.result, + outcome.result.output.clone(), + &self.config.available_artifacts, + ) { + Ok((address, retdata)) => { + expected_revert.actual_count += 1; + if expected_revert.actual_count < expected_revert.count { + // Reset so the next iteration's innermost CREATE wins again. + expected_revert.reverted_by = None; + self.expected_revert = Some(expected_revert.clone()); + } + + outcome.result.result = InstructionResult::Return; + outcome.result.output = retdata; + outcome.address = address; + self.gas_metering.last_frame_gas = None; } + Err(err) => { + outcome.result.result = InstructionResult::Revert; + outcome.result.output = err.abi_encode().into(); + } + }; + } + } - outcome.result.result = InstructionResult::Return; - outcome.result.output = retdata; - outcome.address = address; - } - Err(err) => { - outcome.result.result = InstructionResult::Revert; - outcome.result.output = err.abi_encode().into(); - } - }; + if curr_depth > 0 { + // Record the gas usage of the create frame, this allows the `lastFrameGas` cheatcode to + // retrieve the gas usage of the last call or create. + self.gas_metering.last_frame_gas = Some(frame_gas(&outcome.result)); + self.gas_metering.last_frame_snapshot_gas_used = + isolated_snapshot_gas_used.unwrap_or_else(|| outcome.result.gas.total_gas_spent()); } // If `startStateDiffRecording` has been called, update the `reverted` status of the - // previous call depth's recorded accesses, if any - if let Some(recorded_account_diffs_stack) = &mut self.recorded_account_diffs_stack { - // The root call cannot be recorded. - if curr_depth > 0 - && let Some(last_depth) = &mut recorded_account_diffs_stack.pop() - { - // Update the reverted status of all deeper calls if this call reverted, in - // accordance with EVM behavior - if outcome.result.is_revert() { - last_depth.iter_mut().for_each(|element| { - element.reverted = true; - element - .storageAccesses - .iter_mut() - .for_each(|storage_access| storage_access.reverted = true); - }) - } + // previous call depth's recorded accesses, if any. + if let Some(recorded_account_diffs_stack) = &mut self.recorded_account_diffs_stack + && let Some(mut last_depth) = recorded_account_diffs_stack.pop() + { + // Update the reverted status of all deeper calls if this call reverted, in + // accordance with EVM behavior. + if outcome.result.is_revert() { + mark_account_accesses_reverted(&mut last_depth); + } - if let Some(create_access) = last_depth.first_mut() { - // Assert that we're at the correct depth before recording post-create state - // changes. Depending on what depth the cheat was called at, there - // may not be any pending calls to update if execution has - // percolated up to a higher depth. - let depth = ecx.journaled_state.depth(); - if create_access.depth == depth as u64 { - debug_assert_eq!( - create_access.kind as u8, - crate::Vm::AccountAccessKind::Create as u8 - ); - if let Some(address) = outcome.address - && let Ok(created_acc) = ecx.journaled_state.load_account(address) - { - create_access.newBalance = created_acc.info.balance; - create_access.deployedCode = - created_acc.info.code.clone().unwrap_or_default().original_bytes(); - } - } - // Merge the last depth's AccountAccesses into the AccountAccesses at the - // current depth, or push them back onto the pending - // vector if higher depths were not recorded. This - // preserves ordering of accesses. - if let Some(last) = recorded_account_diffs_stack.last_mut() { - last.append(last_depth); - } else { - recorded_account_diffs_stack.push(last_depth.clone()); + if let Some(create_access) = last_depth.first_mut() { + // Update post-create state only if recording began before this frame. + if create_access.depth == curr_depth as u64 { + debug_assert_eq!( + create_access.kind as u8, + crate::Vm::AccountAccessKind::Create as u8 + ); + if let Some(address) = outcome.address + && let Ok(created_acc) = ecx.journal_mut().load_account(address) + { + create_access.newBalance = created_acc.data.info.balance; + create_access.newNonce = created_acc.data.info.nonce; + create_access.deployedCode = + created_acc.data.info.code.clone().unwrap_or_default().original_bytes(); } } } + merge_recorded_frame(recorded_account_diffs_stack, last_depth); } // Match the create against expected_creates if !self.expected_creates.is_empty() && let (Some(address), Some(call)) = (outcome.address, call) - && let Ok(created_acc) = ecx.journaled_state.load_account(address) + && let Ok(created_acc) = ecx.journal_mut().load_account(address) { - let bytecode = created_acc.info.code.clone().unwrap_or_default().original_bytes(); - if let Some((index, _)) = - self.expected_creates.iter().find_position(|expected_create| { - expected_create.deployer == call.caller - && expected_create.create_scheme.eq(call.scheme.into()) - && expected_create.bytecode == bytecode - }) - { - self.expected_creates.swap_remove(index); - } + let bytecode = created_acc.data.info.code.clone().unwrap_or_default().original_bytes(); + expect::observe_create( + &mut self.expected_creates, + call.caller(), + || call.scheme().into(), + &bytecode, + ); } } } -impl InspectorExt for Cheatcodes { - fn should_use_create2_factory(&mut self, ecx: Ecx, inputs: &CreateInputs) -> bool { - if let CreateScheme::Create2 { .. } = inputs.scheme { - let depth = ecx.journaled_state.depth(); - let target_depth = if let Some(prank) = &self.get_prank(depth) { - prank.depth - } else if let Some(broadcast) = &self.broadcast { - broadcast.depth - } else { - 1 - }; - - depth == target_depth - && (self.broadcast.is_some() || self.config.always_use_create_2_factory) +impl InspectorExt for Cheatcodes { + fn should_use_create2_factory(&mut self, depth: usize, inputs: &CreateInputs) -> bool { + // `deployCode` executes its create frame in a nested EVM one level deeper, so match it + // at the depth of the cheatcode call, as for native creates. + let depth = + if self.deploy_code_depth.is_some_and(|d| d + 1 == depth) { depth - 1 } else { depth }; + let target_depth = if let Some(prank) = &self.get_prank(depth) { + prank.depth + } else if let Some(broadcast) = &self.broadcast { + broadcast.depth } else { - false + 1 + }; + + if depth != target_depth { + return false; + } + + match inputs.scheme() { + CreateScheme::Create2 { .. } => { + self.broadcast.is_some() || self.config.always_use_create_2_factory + } + CreateScheme::Create => self.config.batch_rewrite_creates && self.broadcast.is_some(), + _ => false, } } @@ -1801,7 +2942,7 @@ impl InspectorExt for Cheatcodes { } } -impl Cheatcodes { +impl Cheatcodes { #[cold] fn meter_gas(&mut self, interpreter: &mut Interpreter) { if let Some(paused_gas) = self.gas_metering.paused_frames.last() { @@ -1818,22 +2959,38 @@ impl Cheatcodes { } #[cold] - fn meter_gas_record(&mut self, interpreter: &mut Interpreter, ecx: Ecx) { + fn meter_gas_record( + &mut self, + interpreter: &mut Interpreter, + ecx: &mut FoundryContextFor<'_, FEN>, + ) { if interpreter.bytecode.action.as_ref().and_then(|i| i.instruction_result()).is_none() { + let curr_depth = ecx.journal().depth(); + let isolated_region_gas = match self.gas_metering.pending_isolated_region_gas { + Some((depth, charged, used)) if depth == curr_depth => { + self.gas_metering.pending_isolated_region_gas = None; + Some((charged, used)) + } + _ => None, + }; self.gas_metering.gas_records.iter_mut().for_each(|record| { - let curr_depth = ecx.journaled_state.depth(); if curr_depth == record.depth { // Skip the first opcode of the first call frame as it includes the gas cost of // creating the snapshot. if self.gas_metering.last_gas_used != 0 { - let gas_diff = - interpreter.gas.spent().saturating_sub(self.gas_metering.last_gas_used); + let mut gas_diff = interpreter + .gas + .total_gas_spent() + .saturating_sub(self.gas_metering.last_gas_used); + if let Some((charged, used)) = isolated_region_gas { + gas_diff = gas_diff.saturating_sub(charged).saturating_add(used); + } record.gas_used = record.gas_used.saturating_add(gas_diff); } // Update `last_gas_used` to the current spent gas for the next iteration to // compare against. - self.gas_metering.last_gas_used = interpreter.gas.spent(); + self.gas_metering.last_gas_used = interpreter.gas.total_gas_spent(); } }); } @@ -1850,8 +3007,11 @@ impl Cheatcodes { } #[cold] - fn meter_gas_reset(&mut self, interpreter: &mut Interpreter) { - interpreter.gas = Gas::new(interpreter.gas.limit()); + const fn meter_gas_reset(&mut self, interpreter: &mut Interpreter) { + let mut gas = Gas::new(interpreter.gas.limit()); + gas.memory_mut().words_num = interpreter.gas.memory().words_num; + gas.memory_mut().expansion_cost = interpreter.gas.memory().expansion_cost; + interpreter.gas = gas; self.gas_metering.reset = false; } @@ -1863,7 +3023,7 @@ impl Cheatcodes { // Reset gas if spent is less than refunded. // This can happen if gas was paused / resumed or reset. // https://github.com/foundry-rs/foundry/issues/4370 - if interpreter.gas.spent() + if interpreter.gas.total_gas_spent() < u64::try_from(interpreter.gas.refunded()).unwrap_or_default() { interpreter.gas = Gas::new(interpreter.gas.limit()); @@ -1871,6 +3031,55 @@ impl Cheatcodes { } } + /// Applies opcode-level overrides for `BASEFEE`, `GASPRICE` and `BLOBHASH`. + /// + /// Called from `step_end` *after* the opcode has executed and only when the + /// opcode succeeded (the caller checks `instruction_result`). The opcode + /// pushed its (possibly zeroed) result onto the stack; we replace the top + /// of stack with the cheatcode-set override. This is what makes `vm.fee`, + /// `vm.txGasPrice` and `vm.blobhashes` visible to called contracts under + /// `--isolate` / `--gas-report`, where the inner transaction zeroes the + /// real fee fields for fee-accounting purposes. + /// + /// We can't read the just-executed opcode from `interpreter.bytecode.opcode()` + /// here because the PC has already advanced; instead `step` stashes it in + /// `env_overrides.pending_opcode` for us. + #[cold] + fn apply_env_overrides(&mut self, interpreter: &mut Interpreter, fork_id: Option) { + let Some(env_overrides) = self.env_overrides.get_mut(fork_id) else { return }; + let Some(opcode) = env_overrides.pending_opcode.take() else { return }; + // Each overridden opcode pushed one value; replace it with the override. + let value = match opcode { + op::BASEFEE => env_overrides.basefee_override().map(U256::from), + op::GASPRICE => env_overrides.gas_price_override().map(U256::from), + // BLOBHASH popped the index captured in `step` and pushed the hash. + op::BLOBHASH => env_overrides + .pending_blobhash_index + .take() + .and_then(|index| env_overrides.blob_hash_override(index)) + .map(Into::into), + _ => None, + }; + if let Some(value) = value { + Self::replace_top_of_stack(interpreter, value); + } + } + + /// Replaces the top of the interpreter stack with `value`. + /// + /// The caller must only invoke this after a successful opcode that pushed + /// a value onto the stack; the `pop()` is therefore expected to succeed. + /// If it does not (e.g. because of a bug in the caller's success gating) + /// we bail out instead of pushing on top of an unexpected stack, which + /// would silently grow the stack and corrupt the frame. + fn replace_top_of_stack(interpreter: &mut Interpreter, value: U256) { + if interpreter.stack.pop().is_err() { + debug_assert!(false, "env override expected opcode result on stack"); + return; + } + let _ = interpreter.stack.push(value); + } + /// Generates or copies arbitrary values for storage slots. /// Invoked in inspector `step_end` (when the current opcode is not executed), if current opcode /// to execute is `SLOAD` and storage slot is cold. @@ -1879,13 +3088,21 @@ impl Cheatcodes { /// cache) from mapped source address to the target address. /// - generates arbitrary value and saves it in target address storage. #[cold] - fn arbitrary_storage_end(&mut self, interpreter: &mut Interpreter, ecx: Ecx) { + fn arbitrary_storage_end( + &mut self, + interpreter: &mut Interpreter, + ecx: &mut FoundryContextFor<'_, FEN>, + ) { let (key, target_address) = if interpreter.bytecode.opcode() == op::SLOAD { (try_or_return!(interpreter.stack.peek(0)), interpreter.input.target_address) } else { return; }; + if self.is_arbitrary_storage_slot_explicit(target_address, key) { + return; + } + let Some(value) = ecx.sload(target_address, key) else { return; }; @@ -1894,7 +3111,9 @@ impl Cheatcodes { || self.should_overwrite_arbitrary_storage(&target_address, key) { if self.has_arbitrary_storage(&target_address) { - let arbitrary_value = self.rng().random(); + let arbitrary_value = self + .cached_arbitrary_storage_value(target_address, key) + .unwrap_or_else(|| self.rng().random()); self.arbitrary_storage.as_mut().unwrap().save( ecx, target_address, @@ -1913,6 +3132,250 @@ impl Cheatcodes { } } + /// Restores parent interpreter state after a synthetic storage-hook callback. + /// + /// Returns whether a failed callback was propagated to the parent frame. + #[inline] + pub fn finish_storage_hook_callback( + &mut self, + interpreter: &mut Interpreter, + ecx: &mut FoundryContextFor<'_, FEN>, + ) -> bool { + let Some(active) = self.active_storage_hook.as_ref() else { return false }; + let Some((result, output)) = active.outcome.clone() else { return false }; + + let active = self.active_storage_hook.take().expect("active storage hook exists"); + Self::restore_storage_hook_access(ecx, active.journal_start); + self.restore_storage_hook_inspector_state(active.inspector_state); + let _ = interpreter.stack.pop(); + if let Some(item) = active.saved_stack_item { + let result = interpreter.stack.push(item); + debug_assert!(result, "reserved storage-hook stack slot must be available"); + } + interpreter.gas = active.saved_gas; + interpreter.return_data.set_buffer(active.saved_return_data); + + if result.is_ok() { + false + } else { + interpreter.bytecode.set_action(InterpreterAction::new_return( + InstructionResult::Revert, + output, + interpreter.gas, + )); + true + } + } + + fn take_storage_hook_inspector_state(&mut self) -> StorageHookInspectorState { + StorageHookInspectorState { + accesses: std::mem::take(&mut self.accesses), + recording_accesses: std::mem::replace(&mut self.recording_accesses, false), + mapping_slots: self.mapping_slots.take(), + recorded_logs: self.recorded_logs.take(), + mocked_calls: std::mem::take(&mut self.mocked_calls), + mocked_functions: std::mem::take(&mut self.mocked_functions), + expected_revert: self.expected_revert.take(), + assume_no_revert: self.assume_no_revert.take(), + expected_calls: std::mem::take(&mut self.expected_calls), + expected_emits: std::mem::take(&mut self.expected_emits), + expected_creates: std::mem::take(&mut self.expected_creates), + } + } + + fn restore_storage_hook_inspector_state(&mut self, state: StorageHookInspectorState) { + self.accesses = state.accesses; + self.recording_accesses = state.recording_accesses; + self.mapping_slots = state.mapping_slots; + self.recorded_logs = state.recorded_logs; + self.mocked_calls = state.mocked_calls; + self.mocked_functions = state.mocked_functions; + self.expected_revert = state.expected_revert; + self.assume_no_revert = state.assume_no_revert; + self.expected_calls = state.expected_calls; + self.expected_emits = state.expected_emits; + self.expected_creates = state.expected_creates; + } + + fn restore_storage_hook_access(ecx: &mut FoundryContextFor<'_, FEN>, journal_start: usize) { + let (_, journal) = ecx.db_journal_inner_mut(); + let entries = + journal.journal.drain(journal_start.min(journal.journal.len())..).collect_vec(); + for entry in entries { + match entry { + JournalEntry::AccountWarmed { address } => { + journal.state.get_mut(&address).expect("warmed account exists").mark_cold(); + } + JournalEntry::StorageWarmed { address, key } => { + // TODO(@mablr): Preserve the EIP-2200 `original_value` when bumping the REVM + // family to 42. REVM 41's `mark_cold` resets it for a slot first warmed and + // modified by the callback. + journal + .state + .get_mut(&address) + .expect("warmed account exists") + .storage + .get_mut(&key) + .expect("warmed storage slot exists") + .mark_cold(); + } + entry => journal.journal.push(entry), + } + } + } + + fn capture_storage_hook( + &mut self, + interpreter: &Interpreter, + ecx: &mut FoundryContextFor<'_, FEN>, + ) { + self.pending_storage_hook = None; + if self.active_storage_hook.is_some() { + return; + } + let account = interpreter.input.target_address; + match interpreter.bytecode.opcode() { + op::SLOAD => { + let slot = try_or_return!(interpreter.stack.peek(0)); + let Some(hook) = self.storage_load_hooks.get(&account).copied() else { return }; + self.pending_storage_hook = Some(PendingStorageHook::Load { account, slot, hook }); + } + op::SSTORE => { + let slot = try_or_return!(interpreter.stack.peek(0)); + let (hook, mapping) = if let Some(hook) = self.storage_store_hooks.get(&account) { + (*hook, None) + } else { + let Some(provenance) = self + .storage_hook_mapping_slots + .get(&account) + .and_then(|slots| slots.resolve(slot.into())) + else { + return; + }; + let Some(hook) = self + .mapping_storage_store_hooks + .get(&account) + .and_then(|hooks| hooks.get(&provenance.root_slot)) + .copied() + else { + return; + }; + (hook, Some((provenance.root_slot, provenance.keys))) + }; + let checkpoint = ecx.journal_mut().checkpoint(); + let old_value = + ecx.sload(account, slot).map(|value| value.data).unwrap_or_default(); + ecx.journal_mut().checkpoint_revert(checkpoint); + self.pending_storage_hook = + Some(PendingStorageHook::Store { account, slot, old_value, mapping, hook }); + } + _ => {} + } + } + + fn invoke_pending_storage_hook( + &mut self, + interpreter: &mut Interpreter, + ecx: &mut FoundryContextFor<'_, FEN>, + ) { + let Some(pending) = self.pending_storage_hook.take() else { return }; + if interpreter + .bytecode + .action + .as_ref() + .and_then(InterpreterAction::instruction_result) + .is_some() + { + return; + } + + let (hook, input, saved_stack_item) = match pending { + PendingStorageHook::Load { account, slot, hook } => { + let value = try_or_return!(interpreter.stack.peek(0)); + let mut input = Vec::with_capacity(4 + 32 * 3); + input.extend_from_slice(&hook.callback_selector); + input.extend_from_slice(account.into_word().as_slice()); + input.extend_from_slice(&slot.to_be_bytes::<32>()); + input.extend_from_slice(&value.to_be_bytes::<32>()); + (hook, Bytes::from(input), Some(value)) + } + PendingStorageHook::Store { account, slot, old_value, mapping, hook } => { + let new_value = + ecx.sload(account, slot).map(|value| value.data).unwrap_or_default(); + let mut input = Vec::with_capacity(4 + 32 * 4); + input.extend_from_slice(&hook.callback_selector); + input.extend_from_slice(account.into_word().as_slice()); + input.extend_from_slice(&slot.to_be_bytes::<32>()); + if let Some((root, keys)) = mapping { + input.extend_from_slice(root.as_slice()); + input.extend_from_slice(&U256::from(32 * 6).to_be_bytes::<32>()); + input.extend_from_slice(&old_value.to_be_bytes::<32>()); + input.extend_from_slice(&new_value.to_be_bytes::<32>()); + input.extend_from_slice(&U256::from(keys.len()).to_be_bytes::<32>()); + for key in keys { + input.extend_from_slice(key.as_slice()); + } + } else { + input.extend_from_slice(&old_value.to_be_bytes::<32>()); + input.extend_from_slice(&new_value.to_be_bytes::<32>()); + } + (hook, Bytes::from(input), None) + } + }; + + let journal_start = ecx.db_journal_inner_mut().1.journal.len(); + let account = match ecx.journal_mut().load_account_with_code(hook.callback_target) { + Ok(account) => account, + Err(err) => { + interpreter.bytecode.set_action(InterpreterAction::new_return( + InstructionResult::Revert, + Error::encode(err), + interpreter.gas, + )); + return; + } + }; + let known_bytecode = + (account.info.code_hash(), account.info.code.clone().unwrap_or_default()); + let saved_gas = interpreter.gas; + let saved_return_data = Bytes::copy_from_slice(interpreter.return_data.buffer()); + let gas_limit = interpreter.gas.remaining(); + let parent_depth = ecx.journal().depth(); + if saved_stack_item.is_some() { + let result = interpreter.stack.pop(); + debug_assert!(result.is_ok(), "captured SLOAD result must be on the stack"); + } + let inspector_state = self.take_storage_hook_inspector_state(); + + self.active_storage_hook = Some(ActiveStorageHook { + parent_depth, + callback_target: hook.callback_target, + callback_input: input.clone(), + saved_gas, + saved_return_data, + saved_stack_item, + journal_start, + inspector_state, + outcome: None, + }); + interpreter.bytecode.set_action(InterpreterAction::NewFrame(FrameInput::Call(Box::new( + CallInputs { + input: CallInput::Bytes(input), + return_memory_offset: 0..0, + gas_limit, + reservoir: 0, + bytecode_address: hook.callback_target, + known_bytecode, + target_address: hook.callback_target, + caller: CHEATCODE_ADDRESS, + value: CallValue::Transfer(U256::ZERO), + scheme: CallScheme::Call, + is_static: false, + charged_new_account_state_gas: false, + }, + )))); + } + /// Records storage slots reads and writes. #[cold] fn record_accesses(&mut self, interpreter: &mut Interpreter) { @@ -1931,20 +3394,30 @@ impl Cheatcodes { } #[cold] - fn record_state_diffs(&mut self, interpreter: &mut Interpreter, ecx: Ecx) { + fn record_state_diffs( + &mut self, + interpreter: &mut Interpreter, + ecx: &mut FoundryContextFor<'_, FEN>, + ) { let Some(account_accesses) = &mut self.recorded_account_diffs_stack else { return }; match interpreter.bytecode.opcode() { op::SELFDESTRUCT => { // Ensure that we're not selfdestructing a context recording was initiated on let Some(last) = account_accesses.last_mut() else { return }; - // get previous balance and initialized status of the target account + // get previous balance, nonce and initialized status of the target account let target = try_or_return!(interpreter.stack.peek(0)); let target = Address::from_word(B256::from(target)); - let (initialized, old_balance) = ecx - .journaled_state + let (initialized, old_balance, old_nonce) = ecx + .journal_mut() .load_account(target) - .map(|account| (account.info.exists(), account.info.balance)) + .map(|account| { + ( + account.data.info.exists(), + account.data.info.balance, + account.data.info.nonce, + ) + }) .unwrap_or_default(); // load balance of this account @@ -1956,8 +3429,8 @@ impl Cheatcodes { // register access for the target account last.push(crate::Vm::AccountAccess { chainInfo: crate::Vm::ChainInfo { - forkId: ecx.journaled_state.database.active_fork_id().unwrap_or_default(), - chainId: U256::from(ecx.cfg.chain_id), + forkId: ecx.db().active_fork_id().unwrap_or_default(), + chainId: U256::from(ecx.cfg().chain_id()), }, accessor: interpreter.input.target_address, account: target, @@ -1965,13 +3438,15 @@ impl Cheatcodes { initialized, oldBalance: old_balance, newBalance: old_balance + value, + oldNonce: old_nonce, + newNonce: old_nonce, // nonce doesn't change on selfdestruct value, data: Bytes::new(), reverted: false, deployedCode: Bytes::new(), storageAccesses: vec![], depth: ecx - .journaled_state + .journal() .depth() .try_into() .expect("journaled state depth exceeds u64"), @@ -1985,14 +3460,12 @@ impl Cheatcodes { let address = interpreter.input.target_address; // Try to include present value for informational purposes, otherwise assume - // it's not set (zero value) - let mut present_value = U256::ZERO; - // Try to load the account and the slot's present value - if ecx.journaled_state.load_account(address).is_ok() - && let Some(previous) = ecx.sload(address, key) - { - present_value = previous.data; - } + // it's not set (zero value). Revert the checkpoint so this read does not warm the + // slot for the actual SLOAD opcode. + let checkpoint = ecx.journal_mut().checkpoint(); + let present_value = + ecx.sload(address, key).map(|previous| previous.data).unwrap_or_default(); + ecx.journal_mut().checkpoint_revert(checkpoint); let access = crate::Vm::StorageAccess { account: interpreter.input.target_address, slot: key.into(), @@ -2001,11 +3474,8 @@ impl Cheatcodes { newValue: present_value.into(), reverted: false, }; - let curr_depth = ecx - .journaled_state - .depth() - .try_into() - .expect("journaled state depth exceeds u64"); + let curr_depth = + ecx.journal().depth().try_into().expect("journaled state depth exceeds u64"); append_storage_access(last, access, curr_depth); } op::SSTORE => { @@ -2015,13 +3485,12 @@ impl Cheatcodes { let value = try_or_return!(interpreter.stack.peek(1)); let address = interpreter.input.target_address; // Try to load the account and the slot's previous value, otherwise, assume it's - // not set (zero value) - let mut previous_value = U256::ZERO; - if ecx.journaled_state.load_account(address).is_ok() - && let Some(previous) = ecx.sload(address, key) - { - previous_value = previous.data; - } + // not set (zero value). Revert the checkpoint so this read does not warm the slot + // for the actual SSTORE opcode. + let checkpoint = ecx.journal_mut().checkpoint(); + let previous_value = + ecx.sload(address, key).map(|previous| previous.data).unwrap_or_default(); + ecx.journal_mut().checkpoint_revert(checkpoint); let access = crate::Vm::StorageAccess { account: address, @@ -2031,11 +3500,8 @@ impl Cheatcodes { newValue: value.into(), reverted: false, }; - let curr_depth = ecx - .journaled_state - .depth() - .try_into() - .expect("journaled state depth exceeds u64"); + let curr_depth = + ecx.journal().depth().try_into().expect("journaled state depth exceeds u64"); append_storage_access(last, access, curr_depth); } @@ -2050,24 +3516,19 @@ impl Cheatcodes { }; let address = Address::from_word(B256::from(try_or_return!(interpreter.stack.peek(0)))); - let initialized; - let balance; - if let Ok(acc) = ecx.journaled_state.load_account(address) { - initialized = acc.info.exists(); - balance = acc.info.balance; - } else { - initialized = false; - balance = U256::ZERO; - } - let curr_depth = ecx - .journaled_state - .depth() - .try_into() - .expect("journaled state depth exceeds u64"); + let checkpoint = ecx.journal_mut().checkpoint(); + let (initialized, balance, nonce) = ecx + .journal_mut() + .load_account(address) + .map(|acc| (acc.data.info.exists(), acc.data.info.balance, acc.data.info.nonce)) + .unwrap_or_default(); + ecx.journal_mut().checkpoint_revert(checkpoint); + let curr_depth = + ecx.journal().depth().try_into().expect("journaled state depth exceeds u64"); let account_access = crate::Vm::AccountAccess { chainInfo: crate::Vm::ChainInfo { - forkId: ecx.journaled_state.database.active_fork_id().unwrap_or_default(), - chainId: U256::from(ecx.cfg.chain_id), + forkId: ecx.db().active_fork_id().unwrap_or_default(), + chainId: U256::from(ecx.cfg().chain_id()), }, accessor: interpreter.input.target_address, account: address, @@ -2075,6 +3536,8 @@ impl Cheatcodes { initialized, oldBalance: balance, newBalance: balance, + oldNonce: nonce, + newNonce: nonce, // EXT* operations don't change nonce value: U256::ZERO, data: Bytes::new(), reverted: false, @@ -2201,9 +3664,14 @@ impl Cheatcodes { if to == CHEATCODE_ADDRESS { let args_offset = try_or_return!(interpreter.stack.peek(3)).saturating_to::(); let args_size = try_or_return!(interpreter.stack.peek(4)).saturating_to::(); - let memory_word = interpreter.memory.slice_len(args_offset, args_size); - if memory_word[..SELECTOR_LEN] == stopExpectSafeMemoryCall::SELECTOR { - return + // CALL has not expanded input memory yet. + if args_size >= SELECTOR_LEN + && args_offset.saturating_add(args_size) <= interpreter.memory.size() + { + let memory_word = interpreter.memory.slice_len(args_offset, args_size); + if memory_word[..SELECTOR_LEN] == stopExpectSafeMemoryCall::SELECTOR { + return + } } } @@ -2266,6 +3734,21 @@ impl Cheatcodes { (REVERT, 0, 1, false), ); } + + #[cold] + fn set_gas_limit_type(&mut self, interpreter: &mut Interpreter) { + match interpreter.bytecode.opcode() { + // Both creation opcodes forward remaining gas, even when rewritten to factory calls. + op::CREATE | op::CREATE2 => self.dynamic_gas_limit = true, + op::CALL => { + // If first element of the stack is close to current remaining gas then assume + // dynamic gas limit. + self.dynamic_gas_limit = + try_or_return!(interpreter.stack.peek(0)) >= interpreter.gas.remaining() - 100 + } + _ => self.dynamic_gas_limit = false, + } + } } /// Helper that expands memory, stores a revert string pertaining to a disallowed memory write, @@ -2283,7 +3766,7 @@ fn disallowed_mem_write( "memory write at offset 0x{:02X} of size 0x{:02X} not allowed; safe range: {}", dest_offset, size, - ranges.iter().map(|r| format!("(0x{:02X}, 0x{:02X}]", r.start, r.end)).join(" U ") + ranges.iter().map(|r| format!("[0x{:02X}, 0x{:02X})", r.start, r.end)).join(" U ") ); interpreter.bytecode.set_action(InterpreterAction::new_return( @@ -2293,22 +3776,8 @@ fn disallowed_mem_write( )); } -// Determines if the gas limit on a given call was manually set in the script and should therefore -// not be overwritten by later estimations -fn check_if_fixed_gas_limit(ecx: &Ecx, call_gas_limit: u64) -> bool { - // If the gas limit was not set in the source code it is set to the estimated gas left at the - // time of the call, which should be rather close to configured gas limit. - // TODO: Find a way to reliably make this determination. - // For example by generating it in the compilation or EVM simulation process - ecx.tx.gas_limit > ecx.block.gas_limit && - call_gas_limit <= ecx.block.gas_limit - // Transfers in forge scripts seem to be estimated at 2300 by revm leading to "Intrinsic - // gas too low" failure when simulated on chain - && call_gas_limit > 2300 -} - /// Returns true if the kind of account access is a call. -fn access_is_call(kind: crate::Vm::AccountAccessKind) -> bool { +const fn access_is_call(kind: crate::Vm::AccountAccessKind) -> bool { matches!( kind, crate::Vm::AccountAccessKind::Call @@ -2318,75 +3787,76 @@ fn access_is_call(kind: crate::Vm::AccountAccessKind) -> bool { ) } -/// Appends an AccountAccess that resumes the recording of the current context. -fn append_storage_access( - last: &mut Vec, - storage_access: crate::Vm::StorageAccess, - storage_depth: u64, -) { - // Assert that there's an existing record for the current context. - if !last.is_empty() && last.first().unwrap().depth < storage_depth { - // Three cases to consider: - // 1. If there hasn't been a context switch since the start of this context, then add the - // storage access to the current context record. - // 2. If there's an existing Resume record, then add the storage access to it. - // 3. Otherwise, create a new Resume record based on the current context. - if last.len() == 1 { - last.first_mut().unwrap().storageAccesses.push(storage_access); - } else { - let last_record = last.last_mut().unwrap(); - if last_record.kind as u8 == crate::Vm::AccountAccessKind::Resume as u8 { - last_record.storageAccesses.push(storage_access); - } else { - let entry = last.first().unwrap(); - let resume_record = crate::Vm::AccountAccess { - chainInfo: crate::Vm::ChainInfo { - forkId: entry.chainInfo.forkId, - chainId: entry.chainInfo.chainId, - }, - accessor: entry.accessor, - account: entry.account, - kind: crate::Vm::AccountAccessKind::Resume, - initialized: entry.initialized, - storageAccesses: vec![storage_access], - reverted: entry.reverted, - // The remaining fields are defaults - oldBalance: U256::ZERO, - newBalance: U256::ZERO, - value: U256::ZERO, - data: Bytes::new(), - deployedCode: Bytes::new(), - depth: entry.depth, - }; - last.push(resume_record); - } - } +/// Records a log into the recorded logs vector, if it exists. +fn record_logs(recorded_logs: &mut Option>, log: &Log) { + if let Some(storage_recorded_logs) = recorded_logs { + storage_recorded_logs.push(Vm::Log { + topics: log.data.topics().to_vec(), + data: log.data.data.clone(), + emitter: log.address, + }); } } +/// Returns the [`spec::Cheatcode`] definition for a given [`spec::CheatcodeDef`] implementor. +const fn cheatcode_of(_: &T) -> &'static spec::Cheatcode<'static> { + T::CHEATCODE +} + +fn cheatcode_name(cheat: &spec::Cheatcode<'static>) -> &'static str { + cheatcode_signature(cheat).split('(').next().unwrap() +} + +const fn cheatcode_id(cheat: &spec::Cheatcode<'static>) -> &'static str { + cheat.func.id +} + +const fn cheatcode_signature(cheat: &spec::Cheatcode<'static>) -> &'static str { + cheat.func.signature +} + /// Dispatches the cheatcode call to the appropriate function. -fn apply_dispatch( +fn apply_dispatch( calls: &Vm::VmCalls, - ccx: &mut CheatsCtxt, - executor: &mut dyn CheatcodesExecutor, + ccx: &mut CheatsCtxt<'_, '_, FEN>, + executor: &mut dyn CheatcodesExecutor, ) -> Result { - let cheat = calls_as_dyn_cheatcode(calls); + // Extract metadata for logging/deprecation via CheatcodeDef. + macro_rules! get_cheatcode { + ($($variant:ident),*) => { + match calls { + $(Vm::VmCalls::$variant(cheat) => cheatcode_of(cheat),)* + } + }; + } + let cheat = vm_calls!(get_cheatcode); - let _guard = debug_span!(target: "cheatcodes", "apply", id = %cheat.id()).entered(); - trace!(target: "cheatcodes", cheat = ?cheat.as_debug(), "applying"); + let _guard = debug_span!(target: "cheatcodes", "apply", id = %cheatcode_id(cheat)).entered(); + trace!(target: "cheatcodes", cheat = %cheatcode_signature(cheat), "applying"); - if let spec::Status::Deprecated(replacement) = *cheat.status() { - ccx.state.deprecated.insert(cheat.signature(), replacement); + if let spec::Status::Deprecated(replacement) = cheat.status { + ccx.state.deprecated.insert(cheatcode_signature(cheat), replacement); } - // Apply the cheatcode. - let mut result = cheat.dyn_apply(ccx, executor); + // Monomorphized dispatch: calls apply_full directly, no trait objects. + macro_rules! dispatch { + ($($variant:ident),*) => { + match calls { + $(Vm::VmCalls::$variant(cheat) => Cheatcode::apply_full(cheat, ccx, executor),)* + } + }; + } + let mut result = if ccx.state.config.blocked_cheatcodes.contains(&cheat.func.selector_bytes) { + Err(fmt_err!("disabled during restricted execution")) + } else { + vm_calls!(dispatch) + }; // Format the error message to include the cheatcode name. if let Err(e) = &mut result && e.is_str() { - let name = cheat.name(); + let name = cheatcode_name(cheat); // Skip showing the cheatcode name for: // - assertions: too verbose, and can already be inferred from the error message // - `rpcUrl`: forge-std relies on it in `getChainWithUpdatedRpcUrl` @@ -2406,23 +3876,234 @@ fn apply_dispatch( result } -fn calls_as_dyn_cheatcode(calls: &Vm::VmCalls) -> &dyn DynCheatcode { - macro_rules! as_dyn { - ($($variant:ident),*) => { - match calls { - $(Vm::VmCalls::$variant(cheat) => cheat,)* - } - }; +/// Increments the nonce of every authority whose authorization would be applied on-chain. +/// +/// Mirrors EIP-7702 processing: authorizations are checked in order after the transaction has +/// incremented the sender nonce, and invalid authorizations are skipped without changing the +/// authority nonce. +fn apply_authorization_nonces( + ecx: &mut FoundryContextFor<'_, FEN>, + authorizations: &[SignedAuthorization], + sender: Address, + chain_id: u64, +) -> Result<()> { + for auth in authorizations { + if (!auth.chain_id.is_zero() && auth.chain_id != U256::from(chain_id)) + || auth.nonce() == u64::MAX + { + continue; + } + let Ok(authority) = auth.recover_authority() else { continue }; + // The authority code check is skipped because attaching the delegation already replaced + // the local code that EIP-7702 validates. + let account = journaled_account(ecx, authority)?; + // The sender nonce has not been incremented for the transaction yet. + if auth.nonce() == account.info.nonce + u64::from(authority == sender) { + account.info.nonce += 1; + } } - vm_calls!(as_dyn) + Ok(()) } /// Helper function to check if frame execution will exit. -fn will_exit(action: &InterpreterAction) -> bool { +const fn will_exit(action: &InterpreterAction) -> bool { match action { InterpreterAction::Return(result) => { - result.result.is_ok_or_revert() || result.result.is_error() + result.result.is_ok_or_revert() || result.result.is_halt() } _ => false, } } + +#[cfg(test)] +mod tests { + use super::*; + use env_overrides::EnvOverrides; + + fn cheats(flag: bool, broadcast: Option) -> Cheatcodes { + let config = CheatsConfig { batch_rewrite_creates: flag, ..Default::default() }; + let mut cheats = Cheatcodes::new(Arc::new(config)); + cheats.broadcast = broadcast; + cheats + } + + fn create_inputs() -> CreateInputs { + CreateInputs::new(Address::ZERO, CreateScheme::Create, U256::ZERO, Bytes::new(), 100_000, 0) + } + + fn broadcast_at(depth: usize) -> Broadcast { + Broadcast { depth, ..Default::default() } + } + + #[test] + fn flag_off_with_broadcast_returns_false() { + let mut cheats = cheats(false, Some(broadcast_at(1))); + assert!(!cheats.should_use_create2_factory(1, &create_inputs())); + } + + #[test] + fn flag_on_without_broadcast_returns_false() { + let mut cheats = cheats(true, None); + assert!(!cheats.should_use_create2_factory(1, &create_inputs())); + } + + #[test] + fn flag_on_with_broadcast_depth_mismatch_returns_false() { + let mut cheats = cheats(true, Some(broadcast_at(2))); + assert!(!cheats.should_use_create2_factory(1, &create_inputs())); + } + + #[test] + fn flag_on_with_broadcast_depth_match_returns_true() { + let mut cheats = cheats(true, Some(broadcast_at(1))); + assert!(cheats.should_use_create2_factory(1, &create_inputs())); + } + + #[test] + fn default_cheatcodes_have_no_opcode_hooks() { + let cheats = Cheatcodes::::new(Arc::default()); + assert!(!cheats.has_step_hooks()); + assert!(!cheats.has_step_end_hooks()); + assert!(!cheats.has_log_hooks()); + } + + #[test] + fn active_cheatcode_state_enables_opcode_hooks() { + let mut cheats = Cheatcodes::::new(Arc::default()); + + cheats.recording_accesses = true; + assert!(cheats.has_step_hooks()); + assert!(!cheats.has_step_end_hooks()); + assert!(cheats.has_recording_accesses_only_step_hook()); + + cheats.recording_accesses = false; + cheats.gas_metering.touched = true; + assert!(!cheats.has_step_hooks()); + assert!(cheats.has_step_end_hooks()); + assert!(!cheats.has_recording_accesses_only_step_hook()); + + cheats.gas_metering.touched = false; + cheats.register_storage_load_hook(Address::ZERO, Address::ZERO, [0; 4]); + assert!(cheats.has_step_hooks()); + assert!(cheats.has_step_end_hooks()); + assert!(!cheats.has_recording_accesses_only_step_hook()); + } + + #[test] + fn mixed_step_hooks_disable_record_access_fast_path() { + let mut cheats = Cheatcodes::::new(Arc::default()); + cheats.recording_accesses = true; + + cheats.gas_metering.reset = true; + assert!(!cheats.has_recording_accesses_only_step_hook()); + + cheats.gas_metering.reset = false; + cheats.env_overrides.update(None, |o| o.basefee = Some(1)); + assert!(!cheats.has_recording_accesses_only_step_hook()); + } + + #[test] + fn env_override_hook_predicates() { + fn assert_hooks(cheats: &Cheatcodes, active: bool, case: &str) { + assert_eq!(cheats.has_step_hooks(), active, "step hooks: {case}"); + assert_eq!(cheats.has_step_end_hooks(), active, "step_end hooks: {case}"); + } + + let mut cheats = Cheatcodes::::new(Arc::default()); + assert_hooks(&cheats, false, "empty map"); + let snapshot_id = U256::from(7); + cheats.env_overrides.save_snapshot(snapshot_id, None, 0, 0, &[]); + + cheats.env_overrides.update(None, |_| {}); + assert_hooks(&cheats, false, "inactive entry"); + + let active = [ + EnvOverrides { basefee: Some(1), ..Default::default() }, + EnvOverrides { implicit_basefee: Some(1), ..Default::default() }, + EnvOverrides { gas_price: Some(1), ..Default::default() }, + EnvOverrides { blob_hashes: Some(vec![B256::ZERO]), ..Default::default() }, + ]; + for overrides in active { + let case = format!("{overrides:?}"); + cheats.env_overrides.update(None, |o| *o = overrides); + assert_hooks(&cheats, true, &case); + } + + // Overrides on a fork that isn't active still enable the hooks. + cheats.env_overrides.restore_snapshot(snapshot_id, false); + cheats.env_overrides.update(Some(U256::from(1)), |o| o.basefee = Some(1)); + assert_hooks(&cheats, true, "override on another fork"); + + // Restoring a snapshot taken without overrides turns the hooks off. + cheats.env_overrides.restore_snapshot(snapshot_id, false); + assert_hooks(&cheats, false, "after restoring an empty snapshot"); + } + + #[test] + fn active_log_state_enables_log_hooks() { + let mut cheats = Cheatcodes::::new(Arc::default()); + + cheats.recorded_logs = Some(Default::default()); + assert!(cheats.has_log_hooks()); + + cheats.recorded_logs = None; + cheats.expected_emits.push_back(( + crate::expected_emit::ExpectedEmit { + depth: 0, + log: None, + checks: [false; 5], + address: None, + anonymous: false, + found: false, + count: 1, + mismatch_error: None, + }, + Default::default(), + )); + assert!(cheats.has_log_hooks()); + } + + #[test] + fn frame_gas_reports_settled_components() { + for mut gas in [Gas::new(100_000), Gas::new_with_regular_gas_and_reservoir(100_000, 50_000)] + { + assert!(gas.record_regular_cost(1_000)); + assert!(gas.record_state_cost(20_000)); + + let mut result = InterpreterResult::new(InstructionResult::Stop, Bytes::new(), gas); + let reported = frame_gas(&result); + assert_eq!(reported.gasTotalUsed, 1_000); + assert_eq!(reported.gasStateUsed, 20_000); + + result.result = InstructionResult::Revert; + assert_eq!(frame_gas(&result).gasStateUsed, 0); + } + + let mut gas = Gas::new(100_000); + gas.refill_reservoir(20_000); + let result = InterpreterResult::new(InstructionResult::Stop, Bytes::new(), gas); + assert_eq!(frame_gas(&result).gasStateUsed, -20_000); + + let mut gas = Gas::new(100_000); + assert!(gas.record_state_cost(20_000)); + gas.spend_all(); + let result = InterpreterResult::new(InstructionResult::OutOfGas, Bytes::new(), gas); + let reported = frame_gas(&result); + assert_eq!(reported.gasTotalUsed, 100_000); + assert_eq!(reported.gasStateUsed, 0); + } + + #[test] + fn arbitrary_storage_cache_value_routes_copied_targets_to_source() { + let mut storage = ArbitraryStorage::default(); + let source = Address::repeat_byte(0x11); + let copied = Address::repeat_byte(0x22); + let slot = U256::from(7); + + storage.mark_arbitrary(&source, false); + storage.mark_copy(&source, &copied); + storage.cache_value(copied, slot, U256::ZERO); + + assert_eq!(storage.cached_value(source, slot), Some(U256::ZERO)); + } +} diff --git a/crates/cheatcodes/src/inspector/analysis.rs b/crates/cheatcodes/src/inspector/analysis.rs new file mode 100644 index 0000000000000..806caa50a2080 --- /dev/null +++ b/crates/cheatcodes/src/inspector/analysis.rs @@ -0,0 +1,168 @@ +//! Cheatcode information, extracted from the syntactic and semantic analysis of the sources. + +use foundry_common::fmt::{StructDefinitions, TypeDefMap}; +use solar::sema::{self, Compiler, Gcx, hir}; +use std::sync::{Arc, OnceLock}; +use thiserror::Error; + +/// Represents a failure in one of the lazy analysis steps. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum AnalysisError { + /// Indicates that the resolution of struct definitions failed. + #[error("unable to resolve struct definitions")] + StructDefinitionsResolutionFailed, +} + +/// Provides cached, on-demand syntactic and semantic analysis of a completed `Compiler` instance. +/// +/// This struct acts as a facade over the `Compiler`, offering lazy-loaded analysis for tools like +/// cheatcode inspectors. It assumes the compiler has already completed parsing and lowering. +/// +/// # Adding with new analyses types +/// +/// To add support for a new type of cached analysis, follow this pattern: +/// +/// 1. Add a new `pub OnceCell>` field to `CheatcodeAnalysis`, where `T` is +/// the type of the data that you are adding support for. +/// +/// 2. Implement a getter method for the new field. Inside the getter, use +/// `self.field.get_or_init()` to compute and cache the value on the first call. +/// +/// 3. Inside the closure passed to `get_or_init()`, create a dedicated visitor to traverse the HIR +/// using `self.compiler.enter()` and collect the required data. +/// +/// This ensures all analyses remain lazy, efficient, and consistent with the existing design. +#[derive(Clone)] +pub struct CheatcodeAnalysis { + /// A shared, thread-safe reference to solar's `Compiler` instance. + pub compiler: Arc, + + /// Cached struct definitions in the sources. + /// Used to keep field order when parsing JSON values. + struct_defs: OnceLock>, +} + +impl std::fmt::Debug for CheatcodeAnalysis { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CheatcodeAnalysis") + .field("compiler", &"") + .field("struct_defs", &self.struct_defs) + .finish() + } +} + +impl CheatcodeAnalysis { + pub const fn new(compiler: Arc) -> Self { + Self { compiler, struct_defs: OnceLock::new() } + } + + /// Lazily initializes and returns the struct definitions. + pub fn struct_defs(&self) -> Result<&StructDefinitions, &AnalysisError> { + self.struct_defs + .get_or_init(|| { + self.compiler.enter(|compiler| { + let gcx = compiler.gcx(); + + StructDefinitionResolver::new(gcx).process() + }) + }) + .as_ref() + } +} + +// -- STRUCT DEFINITIONS ------------------------------------------------------- + +/// Generates a map of all struct definitions from the HIR using the resolved `Ty` system. +struct StructDefinitionResolver<'gcx> { + gcx: Gcx<'gcx>, + struct_defs: TypeDefMap, +} + +impl<'gcx> StructDefinitionResolver<'gcx> { + /// Constructs a new generator. + pub const fn new(gcx: Gcx<'gcx>) -> Self { + Self { gcx, struct_defs: TypeDefMap::new() } + } + + /// Processes the HIR to generate all the struct definitions. + pub fn process(mut self) -> Result { + for id in self.hir().strukt_ids() { + self.resolve_struct_definition(id)?; + } + Ok(self.struct_defs.into()) + } + + #[inline] + fn hir(&self) -> &'gcx hir::Hir<'gcx> { + &self.gcx.hir + } + + /// The recursive core of the generator. Resolves a single struct and adds it to the cache. + fn resolve_struct_definition(&mut self, id: hir::StructId) -> Result<(), AnalysisError> { + let qualified_name = self.get_fully_qualified_name(id); + if self.struct_defs.contains_key(&qualified_name) { + return Ok(()); + } + + let hir = self.hir(); + let strukt = hir.strukt(id); + let mut fields = Vec::with_capacity(strukt.fields.len()); + + for &field_id in strukt.fields { + let var = hir.variable(field_id); + let name = + var.name.ok_or(AnalysisError::StructDefinitionsResolutionFailed)?.to_string(); + if let Some(ty_str) = self.ty_to_string(self.gcx.type_of_hir_ty(&var.ty)) { + fields.push((name, ty_str)); + } + } + + // Only insert if there are fields, to avoid adding empty entries + if !fields.is_empty() { + self.struct_defs.insert(qualified_name, fields); + } + + Ok(()) + } + + /// Converts a resolved `Ty` into its canonical string representation. + fn ty_to_string(&mut self, ty: sema::Ty<'gcx>) -> Option { + let ty = ty.peel_refs(); + let res = match ty.kind { + sema::ty::TyKind::Elementary(e) => e.to_string(), + sema::ty::TyKind::Array(ty, size) => { + let inner_type = self.ty_to_string(ty)?; + format!("{inner_type}[{size}]") + } + sema::ty::TyKind::DynArray(ty) => { + let inner_type = self.ty_to_string(ty)?; + format!("{inner_type}[]") + } + sema::ty::TyKind::Struct(id) => { + // Ensure the nested struct is resolved before proceeding. + self.resolve_struct_definition(id).ok()?; + self.get_fully_qualified_name(id) + } + sema::ty::TyKind::Udvt(ty, _) => self.ty_to_string(ty)?, + // For now, map enums to `uint8` + sema::ty::TyKind::Enum(_) => "uint8".to_string(), + // For now, map contracts to `address` + sema::ty::TyKind::Contract(_) => "address".to_string(), + // Explicitly disallow unsupported types + _ => return None, + }; + + Some(res) + } + + /// Helper to get the fully qualified name `Contract.Struct`. + fn get_fully_qualified_name(&self, id: hir::StructId) -> String { + let hir = self.hir(); + let strukt = hir.strukt(id); + if let Some(contract_id) = strukt.contract { + format!("{}.{}", hir.contract(contract_id).name.as_str(), strukt.name.as_str()) + } else { + strukt.name.as_str().into() + } + } +} diff --git a/crates/cheatcodes/src/inspector/env_overrides.rs b/crates/cheatcodes/src/inspector/env_overrides.rs new file mode 100644 index 0000000000000..6e79725022e67 --- /dev/null +++ b/crates/cheatcodes/src/inspector/env_overrides.rs @@ -0,0 +1,322 @@ +//! Cheatcode overrides of the `BASEFEE`, `GASPRICE` and `BLOBHASH` opcode values. + +use alloy_primitives::{B256, U256, map::HashMap}; +use foundry_evm_core::backend::LocalForkId; + +/// Env overrides keyed by fork ID (`None` is the local, non-forked state). +type EnvOverridesByFork = HashMap, EnvOverrides>; + +/// Environment overrides applied at the opcode level. +/// +/// In isolation mode (and inside the synthetic transactions used by +/// `--gas-report` / `--isolate`) the transaction environment is zeroed for +/// fee-accounting purposes, so cheatcodes that mutate the env (e.g. +/// `vm.fee`, `vm.txGasPrice`, `vm.blobhashes`) cannot rely on those +/// mutations being visible to contracts via the `BASEFEE`, `GASPRICE` and +/// `BLOBHASH` opcodes. These overrides are applied in `step_end` to fix +/// the value that was just pushed onto the stack. +/// +/// # Semantics when invoked from inside the synthetic isolation transaction +/// +/// `vm.fee` / `vm.txGasPrice` / `vm.blobhashes` consult +/// [`Cheatcodes::in_isolation_context`](crate::Cheatcodes::in_isolation_context); when set, they +/// only update these overrides (so `tx.gas_price = 0` continues to apply to fee accounting and +/// EIP-4844 inner-tx validation does not reject the synthetic call) and +/// leave the real env untouched. After the inner transaction returns, the +/// outer env is restored from the cached snapshot taken before +/// `transact_inner`, which means: +/// +/// - the override **does** persist for subsequent `BASEFEE`, `GASPRICE` and `BLOBHASH` reads (this +/// hook fires in `step_end` regardless of isolation), +/// - `vm.getBlobhashes()` also consults these overrides, so it returns the correct value. +/// - but the real `block.basefee` / `tx.gas_price` / `tx.blob_hashes` do **not** reflect the +/// cheatcode value, so other non-opcode env consumers will not see it. +/// +/// Calling these cheatcodes outside isolation behaves as before (real env +/// is also mutated and the override mirrors it). +#[derive(Clone, Debug, Default)] +pub struct EnvOverrides { + /// Override for the `BASEFEE` opcode (set via `vm.fee`). + pub basefee: Option, + /// Base fee restored from a snapshot during isolation, valid until the fork is rolled. + pub implicit_basefee: Option, + /// Override for the `GASPRICE` opcode (set via `vm.txGasPrice`). + pub gas_price: Option, + /// Override for the `BLOBHASH` opcode (set via `vm.blobhashes`). + pub blob_hashes: Option>, + /// `tx.gas_price` captured at snapshot time when no gas_price override was + /// active. `sync_tx_after_env_override_restore` uses this to restore the + /// real pre-override value (not hardcoded 0) on revert. + pub pre_override_gas_price: Option, + /// `tx.tx_type` captured at snapshot time when no blob_hashes override was + /// active. Prevents tx_type being stuck at EIP4844 after reverting from a + /// blobhashes-set state. + pub pre_override_tx_type: Option, + /// `tx.blob_hashes` captured at snapshot time when no blob_hashes override + /// was active. + pub pre_override_blob_hashes: Option>, + /// The opcode about to run (captured in `step`, consumed in `step_end`), + /// used to know what was just executed when `step_end` fires — at that + /// point `interpreter.bytecode.opcode()` already points at the *next* + /// instruction. + pub(super) pending_opcode: Option, + /// Pending index for the `BLOBHASH` opcode, captured in `step` (where + /// the index is still on top of the stack) for use in `step_end` (after + /// the opcode has consumed it and pushed the looked-up hash). + pub(super) pending_blobhash_index: Option, +} + +impl EnvOverrides { + /// Whether any override is set. + #[inline] + pub const fn is_any_set(&self) -> bool { + self.basefee.is_some() + || self.implicit_basefee.is_some() + || self.gas_price.is_some() + || self.blob_hashes.is_some() + } + + /// Returns the value the `BASEFEE` opcode reads, if overridden. + #[inline] + pub fn basefee_override(&self) -> Option { + self.basefee.or(self.implicit_basefee) + } + + /// Returns the value the `GASPRICE` opcode reads, if overridden. + #[inline] + pub const fn gas_price_override(&self) -> Option { + self.gas_price + } + + /// Returns the hash the `BLOBHASH` opcode reads at `index`, if overridden. + /// + /// Out-of-range indices read the zero hash, per EIP-4844. + #[inline] + pub fn blob_hash_override(&self, index: u64) -> Option { + let blob_hashes = self.blob_hashes.as_ref()?; + Some(blob_hashes.get(index as usize).copied().unwrap_or_default()) + } +} + +/// Per-fork environment overrides and their state-snapshot copies. +/// +/// Snapshot copies are required because backend snapshots do not include inspector state. +#[derive(Clone, Debug, Default)] +pub struct EnvOverrideState { + by_fork: EnvOverridesByFork, + snapshots: HashMap, +} + +impl EnvOverrideState { + /// Returns whether no fork has an override entry, active or not. + #[inline] + pub fn is_empty(&self) -> bool { + self.by_fork.is_empty() + } + + /// Returns whether any fork, not only the active one, has an override set. + #[inline] + pub fn is_any_set(&self) -> bool { + self.by_fork.values().any(EnvOverrides::is_any_set) + } + + /// Returns the overrides entry for `fork_id`, even if no override is set. + #[inline] + pub fn get(&self, fork_id: Option) -> Option<&EnvOverrides> { + self.by_fork.get(&fork_id) + } + + /// Returns the overrides entry for `fork_id` mutably, without inserting one. + #[inline] + pub(super) fn get_mut(&mut self, fork_id: Option) -> Option<&mut EnvOverrides> { + self.by_fork.get_mut(&fork_id) + } + + /// Updates the overrides for `fork_id`, inserting an empty entry first if absent. + pub(crate) fn update( + &mut self, + fork_id: Option, + f: impl FnOnce(&mut EnvOverrides), + ) { + f(self.by_fork.entry(fork_id).or_default()); + } + + /// Clears the base fee restored for isolation on `fork_id`, without inserting an entry. + pub(crate) fn clear_implicit_basefee(&mut self, fork_id: Option) { + if let Some(overrides) = self.by_fork.get_mut(&fork_id) { + overrides.implicit_basefee = None; + } + } + + /// Removes the entry for `fork_id` if no override is set on it. + pub(crate) fn remove_if_unset(&mut self, fork_id: Option) { + if self.by_fork.get(&fork_id).is_some_and(|overrides| !overrides.is_any_set()) { + self.by_fork.remove(&fork_id); + } + } + + /// Saves all overrides, recording the active fork's non-overridden transaction values. + pub(crate) fn save_snapshot( + &mut self, + snapshot_id: U256, + active_fork_id: Option, + tx_gas_price: u128, + tx_type: u8, + tx_blob_hashes: &[B256], + ) { + let mut snapshot = self.by_fork.clone(); + let active = snapshot.entry(active_fork_id).or_default(); + if active.gas_price.is_none() { + active.pre_override_gas_price = Some(tx_gas_price); + } + if active.blob_hashes.is_none() { + active.pre_override_tx_type = Some(tx_type); + active.pre_override_blob_hashes = Some(tx_blob_hashes.to_vec()); + } + self.snapshots.insert(snapshot_id, snapshot); + } + + /// Restores the overrides saved under `snapshot_id`, deleting the copy if `delete` is set. + /// + /// Does nothing if no copy was saved under `snapshot_id`. + pub(crate) fn restore_snapshot(&mut self, snapshot_id: U256, delete: bool) { + let snapshot = if delete { + self.snapshots.remove(&snapshot_id) + } else { + self.snapshots.get(&snapshot_id).cloned() + }; + if let Some(snapshot) = snapshot { + self.by_fork = snapshot; + } + } + + /// Deletes the copy saved under `snapshot_id`. + pub(crate) fn delete_snapshot(&mut self, snapshot_id: U256) { + self.snapshots.remove(&snapshot_id); + } + + /// Deletes all saved copies. + pub(crate) fn clear_snapshots(&mut self) { + self.snapshots.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const FORK: Option = Some(U256::from_limbs([1, 0, 0, 0])); + const SNAPSHOT: U256 = U256::from_limbs([7, 0, 0, 0]); + + #[test] + fn override_values() { + let mut overrides = EnvOverrides::default(); + assert_eq!(overrides.basefee_override(), None); + assert_eq!(overrides.gas_price_override(), None); + assert_eq!(overrides.blob_hash_override(0), None); + + overrides.implicit_basefee = Some(1); + assert_eq!(overrides.basefee_override(), Some(1)); + overrides.basefee = Some(2); + assert_eq!(overrides.basefee_override(), Some(2)); + + overrides.gas_price = Some(3); + assert_eq!(overrides.gas_price_override(), Some(3)); + + let hash = B256::repeat_byte(1); + overrides.blob_hashes = Some(vec![hash]); + assert_eq!(overrides.blob_hash_override(0), Some(hash)); + assert_eq!(overrides.blob_hash_override(1), Some(B256::ZERO)); + assert_eq!(overrides.blob_hash_override(u64::MAX), Some(B256::ZERO)); + } + + #[test] + fn tracks_overrides_on_every_fork() { + let mut state = EnvOverrideState::default(); + assert!(state.is_empty()); + assert!(!state.is_any_set()); + + state.update(None, |_| {}); + assert!(!state.is_empty()); + assert!(!state.is_any_set()); + + state.update(FORK, |o| o.gas_price = Some(1)); + assert!(state.is_any_set()); + + state.remove_if_unset(None); + state.remove_if_unset(FORK); + assert!(state.get(None).is_none()); + assert_eq!(state.get(FORK).and_then(EnvOverrides::gas_price_override), Some(1)); + + state.clear_implicit_basefee(None); + assert!(state.get(None).is_none(), "clearing must not insert an entry"); + state.update(FORK, |o| o.implicit_basefee = Some(2)); + state.clear_implicit_basefee(FORK); + assert_eq!(state.get(FORK).and_then(EnvOverrides::basefee_override), None); + } + + #[test] + fn save_snapshot_records_tx_values_that_are_not_overridden() { + let mut state = EnvOverrideState::default(); + let tx_hashes = [B256::repeat_byte(1)]; + + state.save_snapshot(SNAPSHOT, None, 10, 3, &tx_hashes); + let snapshot = &state.snapshots[&SNAPSHOT]; + let active = &snapshot[&None]; + assert!(!active.is_any_set()); + assert_eq!(active.pre_override_gas_price, Some(10)); + assert_eq!(active.pre_override_tx_type, Some(3)); + assert_eq!(active.pre_override_blob_hashes.as_deref(), Some(&tx_hashes[..])); + assert!(state.is_empty(), "saving a snapshot must not change the live overrides"); + + state.update(None, |o| { + o.gas_price = Some(1); + o.blob_hashes = Some(vec![]); + }); + state.update(FORK, |o| o.basefee = Some(2)); + state.save_snapshot(SNAPSHOT, None, 10, 3, &tx_hashes); + let snapshot = &state.snapshots[&SNAPSHOT]; + let active = &snapshot[&None]; + assert_eq!(active.pre_override_gas_price, None); + assert_eq!(active.pre_override_tx_type, None); + assert_eq!(active.pre_override_blob_hashes, None); + assert_eq!(snapshot[&FORK].pre_override_gas_price, None, "only the active fork"); + } + + #[test] + fn restores_snapshots() { + let mut state = EnvOverrideState::default(); + state.update(None, |o| o.basefee = Some(1)); + state.save_snapshot(SNAPSHOT, None, 0, 0, &[]); + + state.update(None, |o| o.basefee = Some(2)); + state.restore_snapshot(SNAPSHOT, false); + assert_eq!(state.get(None).and_then(EnvOverrides::basefee_override), Some(1)); + + // A kept snapshot can be restored again. + state.update(None, |o| o.basefee = Some(3)); + state.restore_snapshot(SNAPSHOT, true); + assert_eq!(state.get(None).and_then(EnvOverrides::basefee_override), Some(1)); + + // A deleted snapshot is gone, so restoring it leaves the overrides untouched. + state.update(None, |o| o.basefee = Some(4)); + state.restore_snapshot(SNAPSHOT, false); + assert_eq!(state.get(None).and_then(EnvOverrides::basefee_override), Some(4)); + } + + #[test] + fn deletes_snapshots() { + let mut state = EnvOverrideState::default(); + state.save_snapshot(SNAPSHOT, None, 0, 0, &[]); + state.save_snapshot(SNAPSHOT + U256::from(1), None, 0, 0, &[]); + state.update(None, |o| o.basefee = Some(1)); + + state.delete_snapshot(SNAPSHOT); + state.restore_snapshot(SNAPSHOT, false); + assert!(state.is_any_set()); + + state.clear_snapshots(); + state.restore_snapshot(SNAPSHOT + U256::from(1), false); + assert!(state.is_any_set()); + } +} diff --git a/crates/cheatcodes/src/inspector/utils.rs b/crates/cheatcodes/src/inspector/utils.rs index 58d1f2f90d7b7..a7c7307fa2681 100644 --- a/crates/cheatcodes/src/inspector/utils.rs +++ b/crates/cheatcodes/src/inspector/utils.rs @@ -1,7 +1,10 @@ -use super::Ecx; use crate::inspector::Cheatcodes; use alloy_primitives::{Address, Bytes, U256}; -use revm::interpreter::{CreateInputs, CreateScheme}; +use foundry_evm_core::evm::{FoundryContextFor, FoundryEvmNetwork}; +use revm::{ + context::{ContextTr, JournalTr}, + interpreter::{CreateInputs, CreateScheme}, +}; /// Common behaviour of legacy and EOF create inputs. pub(crate) trait CommonCreateInput { @@ -11,30 +14,42 @@ pub(crate) trait CommonCreateInput { fn init_code(&self) -> Bytes; fn scheme(&self) -> Option; fn set_caller(&mut self, caller: Address); - fn log_debug(&self, cheatcode: &mut Cheatcodes, scheme: &CreateScheme); - fn allow_cheatcodes(&self, cheatcodes: &mut Cheatcodes, ecx: Ecx) -> Address; + fn log_debug( + &self, + cheatcode: &mut Cheatcodes, + scheme: &CreateScheme, + ); + fn allow_cheatcodes( + &self, + cheatcodes: &mut Cheatcodes, + ecx: &mut FoundryContextFor<'_, FEN>, + ) -> Address; } impl CommonCreateInput for &mut CreateInputs { fn caller(&self) -> Address { - self.caller + CreateInputs::caller(self) } fn gas_limit(&self) -> u64 { - self.gas_limit + CreateInputs::gas_limit(self) } fn value(&self) -> U256 { - self.value + CreateInputs::value(self) } fn init_code(&self) -> Bytes { - self.init_code.clone() + CreateInputs::init_code(self).clone() } fn scheme(&self) -> Option { - Some(self.scheme) + Some(CreateInputs::scheme(self)) } fn set_caller(&mut self, caller: Address) { - self.caller = caller; + CreateInputs::set_call(self, caller); } - fn log_debug(&self, cheatcode: &mut Cheatcodes, scheme: &CreateScheme) { + fn log_debug( + &self, + cheatcode: &mut Cheatcodes, + scheme: &CreateScheme, + ) { let kind = match scheme { CreateScheme::Create => "create", CreateScheme::Create2 { .. } => "create2", @@ -42,15 +57,16 @@ impl CommonCreateInput for &mut CreateInputs { }; debug!(target: "cheatcodes", tx=?cheatcode.broadcastable_transactions.back().unwrap(), "broadcastable {kind}"); } - fn allow_cheatcodes(&self, cheatcodes: &mut Cheatcodes, ecx: Ecx) -> Address { - let old_nonce = ecx - .journaled_state - .state - .get(&self.caller) - .map(|acc| acc.info.nonce) - .unwrap_or_default(); + fn allow_cheatcodes( + &self, + cheatcodes: &mut Cheatcodes, + ecx: &mut FoundryContextFor<'_, FEN>, + ) -> Address { + let caller = CreateInputs::caller(self); + let old_nonce = + ecx.journal().evm_state().get(&caller).map(|acc| acc.info.nonce).unwrap_or_default(); let created_address = self.created_address(old_nonce); - cheatcodes.allow_cheatcodes_on_create(ecx, self.caller, created_address); + cheatcodes.allow_cheatcodes_on_create(ecx, caller, created_address); created_address } } diff --git a/crates/cheatcodes/src/json.rs b/crates/cheatcodes/src/json.rs index 932555041d7da..fe863f29c4e3f 100644 --- a/crates/cheatcodes/src/json.rs +++ b/crates/cheatcodes/src/json.rs @@ -2,170 +2,147 @@ use crate::{Cheatcode, Cheatcodes, Result, Vm::*, string}; use alloy_dyn_abi::{DynSolType, DynSolValue, Resolver, eip712_parser::EncodeType}; -use alloy_primitives::{Address, B256, I256, hex}; +use alloy_primitives::{Address, B256, I256, U256, hex}; use alloy_sol_types::SolValue; -use foundry_common::fs; +use foundry_common::{fmt::StructDefinitions, fs}; use foundry_config::fs_permissions::FsAccessKind; +use foundry_evm_core::evm::FoundryEvmNetwork; use serde_json::{Map, Value}; -use std::{borrow::Cow, collections::BTreeMap}; +use std::{ + borrow::Cow, + collections::{BTreeMap, BTreeSet}, +}; impl Cheatcode for keyExistsCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { json, key } = self; check_json_key_exists(json, key) } } impl Cheatcode for keyExistsJsonCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { json, key } = self; check_json_key_exists(json, key) } } impl Cheatcode for parseJson_0Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { json } = self; - parse_json(json, "$") + parse_json(json, "$", state.struct_defs()) } } impl Cheatcode for parseJson_1Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { json, key } = self; - parse_json(json, key) + parse_json(json, key, state.struct_defs()) } } -impl Cheatcode for parseJsonUintCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { - let Self { json, key } = self; - parse_json_coerce(json, key, &DynSolType::Uint(256)) - } -} - -impl Cheatcode for parseJsonUintArrayCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { - let Self { json, key } = self; - parse_json_coerce(json, key, &DynSolType::Array(Box::new(DynSolType::Uint(256)))) - } -} - -impl Cheatcode for parseJsonIntCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { - let Self { json, key } = self; - parse_json_coerce(json, key, &DynSolType::Int(256)) - } -} - -impl Cheatcode for parseJsonIntArrayCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { - let Self { json, key } = self; - parse_json_coerce(json, key, &DynSolType::Array(Box::new(DynSolType::Int(256)))) - } -} - -impl Cheatcode for parseJsonBoolCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { - let Self { json, key } = self; - parse_json_coerce(json, key, &DynSolType::Bool) - } -} - -impl Cheatcode for parseJsonBoolArrayCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { - let Self { json, key } = self; - parse_json_coerce(json, key, &DynSolType::Array(Box::new(DynSolType::Bool))) - } -} - -impl Cheatcode for parseJsonAddressCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { - let Self { json, key } = self; - parse_json_coerce(json, key, &DynSolType::Address) - } -} - -impl Cheatcode for parseJsonAddressArrayCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { - let Self { json, key } = self; - parse_json_coerce(json, key, &DynSolType::Array(Box::new(DynSolType::Address))) - } -} - -impl Cheatcode for parseJsonStringCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { - let Self { json, key } = self; - parse_json_coerce(json, key, &DynSolType::String) - } -} - -impl Cheatcode for parseJsonStringArrayCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { - let Self { json, key } = self; - parse_json_coerce(json, key, &DynSolType::Array(Box::new(DynSolType::String))) - } -} - -impl Cheatcode for parseJsonBytesCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { - let Self { json, key } = self; - parse_json_coerce(json, key, &DynSolType::Bytes) - } -} +macro_rules! impl_parse_json { + ($call:ident, $call_with_default:ident, $ty:expr) => { + impl Cheatcode for $call { + fn apply(&self, _state: &mut Cheatcodes) -> Result { + let Self { json, key } = self; + parse_json_coerce(json, key, &$ty) + } + } -impl Cheatcode for parseJsonBytesArrayCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { - let Self { json, key } = self; - parse_json_coerce(json, key, &DynSolType::Array(Box::new(DynSolType::Bytes))) - } + impl Cheatcode for $call_with_default { + fn apply(&self, _state: &mut Cheatcodes) -> Result { + let Self { json, key, defaultValue } = self; + parse_json_coerce_default(json, key, &$ty, defaultValue) + } + } + }; } -impl Cheatcode for parseJsonBytes32Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { +impl_parse_json!(parseJsonUint_0Call, parseJsonUint_1Call, DynSolType::Uint(256)); +impl_parse_json!( + parseJsonUintArray_0Call, + parseJsonUintArray_1Call, + DynSolType::Array(Box::new(DynSolType::Uint(256))) +); +impl_parse_json!(parseJsonInt_0Call, parseJsonInt_1Call, DynSolType::Int(256)); +impl_parse_json!( + parseJsonIntArray_0Call, + parseJsonIntArray_1Call, + DynSolType::Array(Box::new(DynSolType::Int(256))) +); +impl_parse_json!(parseJsonBool_0Call, parseJsonBool_1Call, DynSolType::Bool); +impl_parse_json!( + parseJsonBoolArray_0Call, + parseJsonBoolArray_1Call, + DynSolType::Array(Box::new(DynSolType::Bool)) +); +impl_parse_json!(parseJsonAddress_0Call, parseJsonAddress_1Call, DynSolType::Address); +impl_parse_json!( + parseJsonAddressArray_0Call, + parseJsonAddressArray_1Call, + DynSolType::Array(Box::new(DynSolType::Address)) +); +impl_parse_json!(parseJsonString_0Call, parseJsonString_1Call, DynSolType::String); +impl_parse_json!( + parseJsonStringArray_0Call, + parseJsonStringArray_1Call, + DynSolType::Array(Box::new(DynSolType::String)) +); + +impl Cheatcode for parseJsonArrayLengthCall { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { json, key } = self; - parse_json_coerce(json, key, &DynSolType::FixedBytes(32)) + parse_json_array_length(json, key) } } -impl Cheatcode for parseJsonBytes32ArrayCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { - let Self { json, key } = self; - parse_json_coerce(json, key, &DynSolType::Array(Box::new(DynSolType::FixedBytes(32)))) - } -} +impl_parse_json!(parseJsonBytes_0Call, parseJsonBytes_1Call, DynSolType::Bytes); +impl_parse_json!( + parseJsonBytesArray_0Call, + parseJsonBytesArray_1Call, + DynSolType::Array(Box::new(DynSolType::Bytes)) +); +impl_parse_json!(parseJsonBytes32_0Call, parseJsonBytes32_1Call, DynSolType::FixedBytes(32)); +impl_parse_json!( + parseJsonBytes32Array_0Call, + parseJsonBytes32Array_1Call, + DynSolType::Array(Box::new(DynSolType::FixedBytes(32))) +); impl Cheatcode for parseJsonType_0Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { json, typeDescription } = self; - parse_json_coerce(json, "$", &resolve_type(typeDescription)?).map(|v| v.abi_encode()) + parse_json_coerce(json, "$", &resolve_type(typeDescription, state.struct_defs())?) + .map(|v| v.abi_encode()) } } impl Cheatcode for parseJsonType_1Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { json, key, typeDescription } = self; - parse_json_coerce(json, key, &resolve_type(typeDescription)?).map(|v| v.abi_encode()) + parse_json_coerce(json, key, &resolve_type(typeDescription, state.struct_defs())?) + .map(|v| v.abi_encode()) } } impl Cheatcode for parseJsonTypeArrayCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { json, key, typeDescription } = self; - let ty = resolve_type(typeDescription)?; + let ty = resolve_type(typeDescription, state.struct_defs())?; parse_json_coerce(json, key, &DynSolType::Array(Box::new(ty))).map(|v| v.abi_encode()) } } impl Cheatcode for parseJsonKeysCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { json, key } = self; parse_json_keys(json, key) } } impl Cheatcode for serializeJsonCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { objectKey, value } = self; *state.serialized_jsons.entry(objectKey.into()).or_default() = serde_json::from_str(value)?; Ok(value.abi_encode()) @@ -173,56 +150,56 @@ impl Cheatcode for serializeJsonCall { } impl Cheatcode for serializeBool_0Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { objectKey, valueKey, value } = self; serialize_json(state, objectKey, valueKey, (*value).into()) } } impl Cheatcode for serializeUint_0Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { objectKey, valueKey, value } = self; serialize_json(state, objectKey, valueKey, (*value).into()) } } impl Cheatcode for serializeInt_0Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { objectKey, valueKey, value } = self; serialize_json(state, objectKey, valueKey, (*value).into()) } } impl Cheatcode for serializeAddress_0Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { objectKey, valueKey, value } = self; serialize_json(state, objectKey, valueKey, (*value).into()) } } impl Cheatcode for serializeBytes32_0Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { objectKey, valueKey, value } = self; serialize_json(state, objectKey, valueKey, DynSolValue::FixedBytes(*value, 32)) } } impl Cheatcode for serializeString_0Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { objectKey, valueKey, value } = self; serialize_json(state, objectKey, valueKey, value.clone().into()) } } impl Cheatcode for serializeBytes_0Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { objectKey, valueKey, value } = self; serialize_json(state, objectKey, valueKey, value.to_vec().into()) } } impl Cheatcode for serializeBool_1Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { objectKey, valueKey, values } = self; serialize_json( state, @@ -234,7 +211,7 @@ impl Cheatcode for serializeBool_1Call { } impl Cheatcode for serializeUint_1Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { objectKey, valueKey, values } = self; serialize_json( state, @@ -246,7 +223,7 @@ impl Cheatcode for serializeUint_1Call { } impl Cheatcode for serializeInt_1Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { objectKey, valueKey, values } = self; serialize_json( state, @@ -258,7 +235,7 @@ impl Cheatcode for serializeInt_1Call { } impl Cheatcode for serializeAddress_1Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { objectKey, valueKey, values } = self; serialize_json( state, @@ -270,7 +247,7 @@ impl Cheatcode for serializeAddress_1Call { } impl Cheatcode for serializeBytes32_1Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { objectKey, valueKey, values } = self; serialize_json( state, @@ -282,7 +259,7 @@ impl Cheatcode for serializeBytes32_1Call { } impl Cheatcode for serializeString_1Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { objectKey, valueKey, values } = self; serialize_json( state, @@ -294,7 +271,7 @@ impl Cheatcode for serializeString_1Call { } impl Cheatcode for serializeBytes_1Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { objectKey, valueKey, values } = self; serialize_json( state, @@ -308,26 +285,27 @@ impl Cheatcode for serializeBytes_1Call { } impl Cheatcode for serializeJsonType_0Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { typeDescription, value } = self; - let ty = resolve_type(typeDescription)?; + let ty = resolve_type(typeDescription, state.struct_defs())?; let value = ty.abi_decode(value)?; - let value = serialize_value_as_json(value)?; + let value = + foundry_common::fmt::serialize_value_as_json(value, state.struct_defs(), false)?; Ok(value.to_string().abi_encode()) } } impl Cheatcode for serializeJsonType_1Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { objectKey, valueKey, typeDescription, value } = self; - let ty = resolve_type(typeDescription)?; + let ty = resolve_type(typeDescription, state.struct_defs())?; let value = ty.abi_decode(value)?; serialize_json(state, objectKey, valueKey, value) } } impl Cheatcode for serializeUintToHexCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { objectKey, valueKey, value } = self; let hex = format!("0x{value:x}"); serialize_json(state, objectKey, valueKey, hex.into()) @@ -335,7 +313,7 @@ impl Cheatcode for serializeUintToHexCall { } impl Cheatcode for writeJson_0Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { json, path } = self; let json = serde_json::from_str(json).unwrap_or_else(|_| Value::String(json.to_owned())); let json_string = serde_json::to_string_pretty(&json)?; @@ -344,19 +322,22 @@ impl Cheatcode for writeJson_0Call { } impl Cheatcode for writeJson_1Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { - let Self { json, path, valueKey } = self; - let json = serde_json::from_str(json).unwrap_or_else(|_| Value::String(json.to_owned())); + fn apply(&self, state: &mut Cheatcodes) -> Result { + let Self { json: value, path, valueKey } = self; + // Read, parse, and update the JSON object. + // If the file doesn't exist, start with an empty JSON object so the file is created. let data_path = state.config.ensure_path_allowed(path, FsAccessKind::Read)?; - let data_s = fs::read_to_string(data_path)?; - let data = serde_json::from_str(&data_s)?; - let value = - jsonpath_lib::replace_with(data, &canonicalize_json_path(valueKey), &mut |_| { - Some(json.clone()) - })?; - - let json_string = serde_json::to_string_pretty(&value)?; + let mut data = if data_path.exists() { + let data_string = fs::locked_read_to_string(&data_path)?; + serde_json::from_str(&data_string).unwrap_or_else(|_| Value::String(data_string)) + } else { + Value::Object(Default::default()) + }; + upsert_json_value(&mut data, value, valueKey)?; + + // Write the updated content back to the file + let json_string = serde_json::to_string_pretty(&data)?; super::fs::write_file(state, path.as_ref(), json_string.as_bytes()) } } @@ -368,10 +349,10 @@ pub(super) fn check_json_key_exists(json: &str, key: &str) -> Result { Ok(exists.abi_encode()) } -pub(super) fn parse_json(json: &str, path: &str) -> Result { +pub(super) fn parse_json(json: &str, path: &str, defs: Option<&StructDefinitions>) -> Result { let value = parse_json_str(json)?; let selected = select(&value, path)?; - let sol = json_to_sol(&selected)?; + let sol = json_to_sol(defs, &selected)?; Ok(encode(sol)) } @@ -384,6 +365,20 @@ pub(super) fn parse_json_coerce(json: &str, path: &str, ty: &DynSolType) -> Resu parse_json_as(value, ty).map(|v| v.abi_encode()) } +pub(super) fn parse_json_coerce_default( + json: &str, + path: &str, + ty: &DynSolType, + default: &T, +) -> Result { + let json = parse_json_str(json)?; + match select(&json, path)?.as_slice() { + [] => Ok(default.abi_encode()), + [value] => parse_json_as(value, ty).map(|value| value.abi_encode()), + _ => bail!("path {path:?} must return exactly one JSON value"), + } +} + /// Parses given [serde_json::Value] as a [DynSolValue]. pub(super) fn parse_json_as(value: &Value, ty: &DynSolType) -> Result { let to_string = |v: &Value| { @@ -396,6 +391,7 @@ pub(super) fn parse_json_as(value: &Value, ty: &DynSolType) -> Result parse_json_array(array, ty), (Value::Object(object), ty) => parse_json_map(object, ty), (Value::String(s), DynSolType::String) => Ok(DynSolValue::String(s.clone())), + (Value::String(s), DynSolType::Uint(_) | DynSolType::Int(_)) => string::parse_value(s, ty), _ => string::parse_value(&to_string(value), ty), } } @@ -458,14 +454,92 @@ pub(super) fn parse_json_keys(json: &str, key: &str) -> Result { Ok(keys.abi_encode()) } +pub(super) fn parse_json_array_length(json: &str, key: &str) -> Result { + let json = parse_json_str(json)?; + let values = select(&json, key)?; + let [value] = values[..] else { + bail!("key {key:?} must return exactly one JSON array"); + }; + let Value::Array(array) = value else { + bail!("JSON value at {key:?} is not an array"); + }; + Ok(U256::from(array.len()).abi_encode()) +} + fn parse_json_str(json: &str) -> Result { - serde_json::from_str(json).map_err(|e| fmt_err!("failed parsing JSON: {e}")) + let json = strip_json_comments(json)?; + serde_json::from_str(&json).map_err(|e| fmt_err!("failed parsing JSON: {e}")) } -fn json_to_sol(json: &[&Value]) -> Result> { +fn strip_json_comments(json: &str) -> Result> { + let bytes = json.as_bytes(); + let mut stripped = None; + let mut index = 0; + let mut in_string = false; + + while index < bytes.len() { + if in_string { + match bytes[index] { + b'\\' => index += 2, + b'"' => { + in_string = false; + index += 1; + } + _ => index += 1, + } + continue; + } + + match (bytes[index], bytes.get(index + 1)) { + (b'"', _) => { + in_string = true; + index += 1; + } + (b'/', Some(b'/')) => { + let stripped = stripped.get_or_insert_with(|| bytes.to_vec()); + while index < bytes.len() && !matches!(bytes[index], b'\r' | b'\n') { + stripped[index] = b' '; + index += 1; + } + } + (b'/', Some(b'*')) => { + let stripped = stripped.get_or_insert_with(|| bytes.to_vec()); + stripped[index] = b' '; + stripped[index + 1] = b' '; + index += 2; + + let mut closed = false; + while index < bytes.len() { + if bytes[index] == b'*' && bytes.get(index + 1) == Some(&b'/') { + stripped[index] = b' '; + stripped[index + 1] = b' '; + index += 2; + closed = true; + break; + } + if !matches!(bytes[index], b'\r' | b'\n') { + stripped[index] = b' '; + } + index += 1; + } + ensure!(closed, "failed parsing JSON: unterminated block comment"); + } + _ => index += 1, + } + } + + Ok(match stripped { + Some(stripped) => { + String::from_utf8(stripped).expect("comment stripping preserves valid UTF-8").into() + } + None => json.into(), + }) +} + +fn json_to_sol(defs: Option<&StructDefinitions>, json: &[&Value]) -> Result> { let mut sol = Vec::with_capacity(json.len()); for value in json { - sol.push(json_value_to_token(value)?); + sol.push(json_value_to_token(value, defs)?); } Ok(sol) } @@ -493,7 +567,7 @@ fn encode(values: Vec) -> Vec { /// Canonicalize a json path key to always start from the root of the document. /// Read more about json path syntax: pub(super) fn canonicalize_json_path(path: &str) -> Cow<'_, str> { - if !path.starts_with('$') { format!("${path}").into() } else { path.into() } + if path.starts_with('$') { path.into() } else { format!("${path}").into() } } /// Converts a JSON [`Value`] to a [`DynSolValue`] by trying to guess encoded type. For safer @@ -503,22 +577,72 @@ pub(super) fn canonicalize_json_path(path: &str) -> Cow<'_, str> { /// it will call itself to convert each of it's value and encode the whole as a /// Tuple #[instrument(target = "cheatcodes", level = "trace", ret)] -pub(super) fn json_value_to_token(value: &Value) -> Result { +pub(super) fn json_value_to_token( + value: &Value, + defs: Option<&StructDefinitions>, +) -> Result { + if let Some(defs) = defs { + _json_value_to_token(value, defs) + } else { + _json_value_to_token(value, &StructDefinitions::default()) + } +} + +fn _json_value_to_token(value: &Value, defs: &StructDefinitions) -> Result { match value { Value::Null => Ok(DynSolValue::FixedBytes(B256::ZERO, 32)), Value::Bool(boolean) => Ok(DynSolValue::Bool(*boolean)), - Value::Array(array) => { - array.iter().map(json_value_to_token).collect::>().map(DynSolValue::Array) - } - value @ Value::Object(_) => { - // See: [#3647](https://github.com/foundry-rs/foundry/pull/3647) - let ordered_object: BTreeMap = - serde_json::from_value(value.clone()).unwrap(); - ordered_object + Value::Array(array) => array + .iter() + .map(|v| _json_value_to_token(v, defs)) + .collect::>() + .map(DynSolValue::Array), + Value::Object(map) => { + // Try to find a struct definition that matches the object keys. + let keys: BTreeSet<_> = map.keys().map(|s| s.as_str()).collect(); + let matching_defs = defs .values() - .map(json_value_to_token) - .collect::>() - .map(DynSolValue::Tuple) + .filter(|fields| { + fields.len() == keys.len() + && fields.iter().map(|(name, _)| name.as_str()).collect::>() + == keys + }) + .collect::>(); + + if let Some(fields) = matching_defs.first() { + // Found a struct with matching field names, use the order from the definition. + fields + .iter() + .map(|(name, type_description)| { + // unwrap is safe because we know the key exists. + let value = map.get(name).unwrap(); + let unambiguous = matching_defs.iter().all(|fields| { + fields + .iter() + .find(|(field, _)| field == name) + .is_some_and(|(_, ty)| ty == type_description) + }); + if unambiguous + && let Some(parsed) = parse_fixed_array(value, type_description, defs) + { + parsed + } else { + _json_value_to_token(value, defs) + } + }) + .collect::>() + .map(DynSolValue::Tuple) + } else { + // Fallback to alphabetical sorting if no matching struct is found. + // See: [#3647](https://github.com/foundry-rs/foundry/pull/3647) + let ordered_object: BTreeMap<_, _> = + map.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); + ordered_object + .values() + .map(|value| _json_value_to_token(value, defs)) + .collect::>() + .map(DynSolValue::Tuple) + } } Value::Number(number) => { if let Some(f) = number.as_f64() { @@ -563,6 +687,7 @@ pub(super) fn json_value_to_token(value: &Value) -> Result { Err(fmt_err!("unsupported JSON number: {number}")) } Value::String(string) => { + // Hanfl hex strings if let Some(mut val) = string.strip_prefix("0x") { let s; if val.len() == 39 { @@ -580,51 +705,78 @@ pub(super) fn json_value_to_token(value: &Value) -> Result { }); } } + + // Handle large numbers that were potentially encoded as strings because they exceed the + // capacity of a 64-bit integer. + // Note that number-like strings that *could* fit in an `i64`/`u64` will fall through + // and be treated as literal strings. + if let Ok(n) = string.parse::() + && i64::try_from(n).is_err() + { + return Ok(DynSolValue::Int(n, 256)); + } else if let Ok(n) = string.parse::() + && u64::try_from(n).is_err() + { + return Ok(DynSolValue::Uint(n, 256)); + } + + // Otherwise, treat as a regular string Ok(DynSolValue::String(string.to_owned())) } } } -/// Serializes given [DynSolValue] into a [serde_json::Value]. -fn serialize_value_as_json(value: DynSolValue) -> Result { - match value { - DynSolValue::Bool(b) => Ok(Value::Bool(b)), - DynSolValue::String(s) => { - // Strings are allowed to contain stringified JSON objects, so we try to parse it like - // one first. - if let Ok(map) = serde_json::from_str(&s) { - Ok(Value::Object(map)) - } else { - Ok(Value::String(s)) - } - } - DynSolValue::Bytes(b) => Ok(Value::String(hex::encode_prefixed(b))), - DynSolValue::FixedBytes(b, size) => Ok(Value::String(hex::encode_prefixed(&b[..size]))), - DynSolValue::Int(i, _) => { - // let serde handle number parsing - let n = serde_json::from_str(&i.to_string())?; - Ok(Value::Number(n)) - } - DynSolValue::Uint(i, _) => { - // let serde handle number parsing - let n = serde_json::from_str(&i.to_string())?; - Ok(Value::Number(n)) +fn parse_fixed_array( + value: &Value, + type_description: &str, + defs: &StructDefinitions, +) -> Option> { + let root_end = type_description.find('[')?; + let root = &type_description[..root_end]; + let (ty, custom) = match defs.get(root) { + Ok(Some(_)) => { + (DynSolType::parse(&format!("bool{}", &type_description[root_end..])).ok()?, true) } - DynSolValue::Address(a) => Ok(Value::String(a.to_string())), - DynSolValue::Array(e) | DynSolValue::FixedArray(e) => { - Ok(Value::Array(e.into_iter().map(serialize_value_as_json).collect::>()?)) - } - DynSolValue::CustomStruct { name: _, prop_names, tuple } => { - let values = - tuple.into_iter().map(serialize_value_as_json).collect::>>()?; - let map = prop_names.into_iter().zip(values).collect(); + Ok(None) if root.contains('.') => return None, + Ok(None) => (DynSolType::parse(type_description).ok()?, false), + Err(_) => return None, + }; + if !contains_fixed_array(&ty) { + return None; + } + + Some(if custom { parse_json_custom_array(value, &ty, defs) } else { parse_json_as(value, &ty) }) +} - Ok(Value::Object(map)) +fn parse_json_custom_array( + value: &Value, + ty: &DynSolType, + defs: &StructDefinitions, +) -> Result { + match (value, ty) { + (Value::Array(values), DynSolType::Array(inner)) => values + .iter() + .map(|value| parse_json_custom_array(value, inner, defs)) + .collect::>() + .map(DynSolValue::Array), + (Value::Array(values), DynSolType::FixedArray(inner, len)) => { + ensure!(values.len() == *len, "array length mismatch"); + values + .iter() + .map(|value| parse_json_custom_array(value, inner, defs)) + .collect::>() + .map(DynSolValue::FixedArray) } - DynSolValue::Tuple(values) => Ok(Value::Array( - values.into_iter().map(serialize_value_as_json).collect::>()?, - )), - DynSolValue::Function(_) => bail!("cannot serialize function pointer"), + (_, DynSolType::Bool) => _json_value_to_token(value, defs), + _ => bail!("expected array"), + } +} + +fn contains_fixed_array(ty: &DynSolType) -> bool { + match ty { + DynSolType::FixedArray(_, _) => true, + DynSolType::Array(inner) => contains_fixed_array(inner), + _ => false, } } @@ -636,13 +788,13 @@ fn serialize_value_as_json(value: DynSolValue) -> Result { /// object, so that the user can use that as a value to a new invocation of the same function with a /// new object key. This enables the user to reuse the same function to crate arbitrarily complex /// object structures (JSON). -fn serialize_json( - state: &mut Cheatcodes, +fn serialize_json( + state: &mut Cheatcodes, object_key: &str, value_key: &str, value: DynSolValue, ) -> Result { - let value = serialize_value_as_json(value)?; + let value = foundry_common::fmt::serialize_value_as_json(value, state.struct_defs(), false)?; let map = state.serialized_jsons.entry(object_key.into()).or_default(); map.insert(value_key.into(), value); let stringified = serde_json::to_string(map).unwrap(); @@ -650,38 +802,183 @@ fn serialize_json( } /// Resolves a [DynSolType] from user input. -pub(super) fn resolve_type(type_description: &str) -> Result { +pub(super) fn resolve_type( + type_description: &str, + struct_defs: Option<&StructDefinitions>, +) -> Result { + let ordered_ty = |ty| -> Result { + if let Some(defs) = struct_defs { reorder_type(ty, defs) } else { Ok(ty) } + }; + if let Ok(ty) = DynSolType::parse(type_description) { - return Ok(ty); + return ordered_ty(ty); }; - if let Ok(encoded) = EncodeType::parse(type_description) { - let main_type = encoded.types[0].type_name; + if let Ok(encoded) = EncodeType::parse(type_description) + && let Some(main) = encoded.types.first() + { + let main_type = main.type_name; let mut resolver = Resolver::default(); - for t in encoded.types { + for t in &encoded.types { resolver.ingest(t.to_owned()); } - return Ok(resolver.resolve(main_type)?); - }; + // Get the alphabetically-sorted type from the resolver, and reorder if necessary. + return ordered_ty(resolver.resolve(main_type)?); + } bail!("type description should be a valid Solidity type or a EIP712 `encodeType` string") } +/// Upserts a value into a JSON object based on a dot-separated key. +/// +/// This function navigates through a mutable `serde_json::Value` object using a +/// path-like key. It creates nested JSON objects if they do not exist along the path. +/// The value is inserted at the final key in the path. +/// +/// # Arguments +/// +/// * `data` - A mutable reference to the `serde_json::Value` to be modified. +/// * `value` - The string representation of the value to upsert. This string is first parsed as +/// JSON, and if that fails, it's treated as a plain JSON string. +/// * `key` - A dot-separated string representing the path to the location for upserting. +pub(super) fn upsert_json_value(data: &mut Value, value: &str, key: &str) -> Result<()> { + let parts = split_value_key(key)?; + + // Separate the final key from the path. + // Traverse the objects, creating intermediary ones if necessary. + if let Some((key_to_insert, path_to_parent)) = parts.split_last() { + let mut current_level = data; + + for segment in path_to_parent { + if !current_level.is_object() { + return Err(fmt_err!("path segment '{segment}' does not resolve to an object.")); + } + current_level = current_level + .as_object_mut() + .unwrap() + .entry(segment.clone()) + .or_insert(Value::Object(Map::new())); + } + + // Upsert the new value + if let Some(parent_obj) = current_level.as_object_mut() { + parent_obj.insert( + key_to_insert.clone(), + serde_json::from_str(value).unwrap_or_else(|_| Value::String(value.to_owned())), + ); + } else { + return Err(fmt_err!("final destination is not an object, cannot insert key.")); + } + } + + Ok(()) +} + +/// Splits the `valueKey` of a write cheatcode into the object keys leading to the value. +pub(super) fn split_value_key(key: &str) -> Result> { + // Parse the path key into segments. + let canonical_key = canonicalize_json_path(key); + let parts = canonical_key + .strip_prefix("$.") + .unwrap_or(key) + .split('.') + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect::>(); + + if parts.is_empty() { + return Err(fmt_err!("'valueKey' cannot be empty or just '$'")); + } + + Ok(parts) +} + +/// Recursively traverses a `DynSolType` and reorders the fields of any +/// `CustomStruct` variants according to the provided `StructDefinitions`. +/// +/// This is necessary because the EIP-712 resolver sorts struct fields alphabetically, +/// but we want to respect the order defined in the Solidity source code. +fn reorder_type(ty: DynSolType, struct_defs: &StructDefinitions) -> Result { + match ty { + DynSolType::CustomStruct { name, prop_names, tuple } => { + if let Some(def) = struct_defs.get(&name)? { + // The incoming `prop_names` and `tuple` are alphabetically sorted. + let type_map: std::collections::HashMap = + prop_names.into_iter().zip(tuple).collect(); + + let mut sorted_props = Vec::with_capacity(def.len()); + let mut sorted_tuple = Vec::with_capacity(def.len()); + for (field_name, _) in def { + sorted_props.push(field_name.clone()); + if let Some(field_ty) = type_map.get(field_name) { + sorted_tuple.push(reorder_type(field_ty.clone(), struct_defs)?); + } else { + bail!( + "mismatch between struct definition and type description: field '{field_name}' not found in provided type for struct '{name}'" + ); + } + } + Ok(DynSolType::CustomStruct { name, prop_names: sorted_props, tuple: sorted_tuple }) + } else { + // No definition found, so we can't reorder. However, we still reorder its children + // in case they have known structs. + let new_tuple = tuple + .into_iter() + .map(|t| reorder_type(t, struct_defs)) + .collect::>>()?; + Ok(DynSolType::CustomStruct { name, prop_names, tuple: new_tuple }) + } + } + DynSolType::Array(inner) => { + Ok(DynSolType::Array(Box::new(reorder_type(*inner, struct_defs)?))) + } + DynSolType::FixedArray(inner, len) => { + Ok(DynSolType::FixedArray(Box::new(reorder_type(*inner, struct_defs)?), len)) + } + DynSolType::Tuple(inner) => Ok(DynSolType::Tuple( + inner.into_iter().map(|t| reorder_type(t, struct_defs)).collect::>>()?, + )), + _ => Ok(ty), + } +} + #[cfg(test)] mod tests { use super::*; use alloy_primitives::FixedBytes; - use proptest::strategy::Strategy; + use foundry_common::fmt::{TypeDefMap, serialize_value_as_json}; + use proptest::{arbitrary::any, prop_oneof, strategy::Strategy}; + use std::collections::HashSet; + + #[test] + fn test_parse_json_comments() { + let value = parse_json_str( + r#"{ + // A line comment. + "value": 42, + /* A block comment. */ + "url": "https://example.com/path/*literal*/" + }"#, + ) + .unwrap(); - fn contains_tuple(value: &DynSolValue) -> bool { - match value { - DynSolValue::Tuple(_) | DynSolValue::CustomStruct { .. } => true, - DynSolValue::Array(v) | DynSolValue::FixedArray(v) => { - v.first().is_some_and(contains_tuple) - } - _ => false, - } + assert_eq!(value["value"], 42); + assert_eq!(value["url"], "https://example.com/path/*literal*/"); + + let error = parse_json_str(r#"{"value": 42} /* unterminated"#).unwrap_err(); + assert!(error.to_string().contains("unterminated block comment")); + } + + fn valid_value(value: &DynSolValue) -> bool { + (match value { + DynSolValue::String(s) if s == "{}" => false, + + DynSolValue::Tuple(_) | DynSolValue::CustomStruct { .. } => false, + + DynSolValue::Array(v) | DynSolValue::FixedArray(v) => v.iter().all(valid_value), + _ => true, + }) && value.as_type().is_some() } /// [DynSolValue::Bytes] of length 32 and 20 are converted to [DynSolValue::FixedBytes] and @@ -709,18 +1006,55 @@ mod tests { } fn guessable_types() -> impl proptest::strategy::Strategy { - proptest::arbitrary::any::() - .prop_map(fixup_guessable) - .prop_filter("tuples are not supported", |v| !contains_tuple(v)) - .prop_filter("filter out values without type", |v| v.as_type().is_some()) + any::().prop_map(fixup_guessable).prop_filter("invalid value", valid_value) + } + + /// A proptest strategy for generating a (simple) `DynSolValue::CustomStruct` + /// and its corresponding `StructDefinitions` object. + fn custom_struct_strategy() -> impl Strategy { + // Define a strategy for basic field names and values. + let field_name_strat = "[a-z]{4,12}"; + let field_value_strat = prop_oneof![ + any::().prop_map(DynSolValue::Bool), + any::().prop_map(|v| DynSolValue::Uint(U256::from(v), 256)), + any::<[u8; 20]>().prop_map(Address::from).prop_map(DynSolValue::Address), + any::<[u8; 32]>().prop_map(B256::from).prop_map(|b| DynSolValue::FixedBytes(b, 32)), + ".*".prop_filter("invalid string value", |s| s != "{}").prop_map(DynSolValue::String), + ]; + + // Combine them to create a list of unique fields that preserve the random order. + let fields_strat = proptest::collection::vec((field_name_strat, field_value_strat), 1..8) + .prop_map(|fields| { + let mut unique_fields = Vec::with_capacity(fields.len()); + let mut seen_names = HashSet::new(); + for (name, value) in fields { + if seen_names.insert(name.clone()) { + unique_fields.push((name, value)); + } + } + unique_fields + }); + + // Generate the `CustomStruct` and its definition. + ("[A-Z][a-z]{4,8}", fields_strat).prop_map(|(struct_name, fields)| { + let (prop_names, tuple): (Vec, Vec) = + fields.clone().into_iter().unzip(); + let def_fields: Vec<(String, String)> = fields + .iter() + .map(|(name, value)| (name.clone(), value.as_type().unwrap().to_string())) + .collect(); + let mut defs_map = TypeDefMap::default(); + defs_map.insert(struct_name.clone(), def_fields); + (defs_map.into(), DynSolValue::CustomStruct { name: struct_name, prop_names, tuple }) + }) } // Tests to ensure that conversion [DynSolValue] -> [serde_json::Value] -> [DynSolValue] proptest::proptest! { #[test] fn test_json_roundtrip_guessed(v in guessable_types()) { - let json = serialize_value_as_json(v.clone()).unwrap(); - let value = json_value_to_token(&json).unwrap(); + let json = serialize_value_as_json(v.clone(), None, false).unwrap(); + let value = json_value_to_token(&json, None).unwrap(); // do additional abi_encode -> abi_decode to avoid zero signed integers getting decoded as unsigned and causing assert_eq to fail. let decoded = v.as_type().unwrap().abi_decode(&value.abi_encode()).unwrap(); @@ -728,10 +1062,276 @@ mod tests { } #[test] - fn test_json_roundtrip(v in proptest::arbitrary::any::().prop_filter("filter out values without type", |v| v.as_type().is_some())) { - let json = serialize_value_as_json(v.clone()).unwrap(); + fn test_json_roundtrip(v in any::().prop_filter("filter out values without type", |v| v.as_type().is_some())) { + let json = serialize_value_as_json(v.clone(), None, false).unwrap(); let value = parse_json_as(&json, &v.as_type().unwrap()).unwrap(); - assert_eq!(value, v); + assert_eq!(value, v); } + + #[test] + fn test_json_roundtrip_with_struct_defs((struct_defs, v) in custom_struct_strategy()) { + let json = serialize_value_as_json(v.clone(), Some(&struct_defs), false).unwrap(); + let sol_type = v.as_type().unwrap(); + let parsed_value = parse_json_as(&json, &sol_type).unwrap(); + assert_eq!(parsed_value, v); + } + } + + #[test] + fn test_resolve_type_with_definitions() -> Result<()> { + // Define a struct with fields in a specific order (not alphabetical) + let mut struct_defs = TypeDefMap::new(); + struct_defs.insert( + "Apple".to_string(), + vec![ + ("color".to_string(), "string".to_string()), + ("sweetness".to_string(), "uint8".to_string()), + ("sourness".to_string(), "uint8".to_string()), + ], + ); + struct_defs.insert( + "FruitStall".to_string(), + vec![ + ("name".to_string(), "string".to_string()), + ("apples".to_string(), "Apple[]".to_string()), + ], + ); + + // Simulate resolver output: type string, using alphabetical order for fields. + let ty_desc = "FruitStall(Apple[] apples,string name)Apple(string color,uint8 sourness,uint8 sweetness)"; + + // Resolve type and ensure struct definition order is preserved. + let ty = resolve_type(ty_desc, Some(&struct_defs.into())).unwrap(); + if let DynSolType::CustomStruct { name, prop_names, tuple } = ty { + assert_eq!(name, "FruitStall"); + assert_eq!(prop_names, vec!["name", "apples"]); + assert_eq!(tuple.len(), 2); + assert_eq!(tuple[0], DynSolType::String); + + if let DynSolType::Array(apple_ty_boxed) = &tuple[1] + && let DynSolType::CustomStruct { name, prop_names, tuple } = &**apple_ty_boxed + { + assert_eq!(*name, "Apple"); + // Check that the inner struct's fields are also in definition order. + assert_eq!(*prop_names, vec!["color", "sweetness", "sourness"]); + assert_eq!( + *tuple, + vec![DynSolType::String, DynSolType::Uint(8), DynSolType::Uint(8)] + ); + + return Ok(()); + } + } + panic!("Expected FruitStall and Apple to be CustomStruct"); + } + + #[test] + fn test_resolve_type_without_definitions() -> Result<()> { + // Simulate resolver output: type string, using alphabetical order for fields. + let ty_desc = "Person(bool active,uint256 age,string name)"; + + // Resolve the type without providing any struct definitions and ensure that original + // (alphabetical) order is unchanged. + let ty = resolve_type(ty_desc, None).unwrap(); + if let DynSolType::CustomStruct { name, prop_names, tuple } = ty { + assert_eq!(name, "Person"); + assert_eq!(prop_names, vec!["active", "age", "name"]); + assert_eq!(tuple.len(), 3); + assert_eq!(tuple, vec![DynSolType::Bool, DynSolType::Uint(256), DynSolType::String]); + return Ok(()); + } + panic!("Expected Person to be CustomStruct"); + } + + #[test] + fn test_resolve_type_bare_struct_name_errors_instead_of_panicking() { + // `EncodeType::parse` succeeds with an empty type list for inputs without `(`. + let err = resolve_type("Foo", None).unwrap_err(); + assert!( + err.to_string().contains("valid Solidity type or a EIP712 `encodeType` string"), + "unexpected error: {err}" + ); + } + + #[test] + fn test_parse_fixed_array() { + let mut struct_defs = TypeDefMap::new(); + struct_defs.insert( + "Contract.Child".to_string(), + vec![("value".to_string(), "uint256".to_string())], + ); + let struct_defs = StructDefinitions::from(struct_defs); + + let value = serde_json::json!([[1], [2]]); + assert!(parse_fixed_array(&value, "uint256[1][]", &struct_defs).unwrap().is_ok()); + assert!(parse_fixed_array(&value, "uint256[", &struct_defs).is_none()); + assert!(parse_fixed_array(&value, "uint256[0]", &struct_defs).is_none()); + assert!(parse_fixed_array(&value, "Contract.Child[", &struct_defs).is_none()); + assert!(parse_fixed_array(&value, "Missing.Child[1]", &struct_defs).is_none()); + + let value = serde_json::json!([[{"value": 1}, {"value": 2}]]); + let parsed = + parse_fixed_array(&value, "Contract.Child[2][1]", &struct_defs).unwrap().unwrap(); + assert!(matches!( + parsed, + DynSolValue::FixedArray(outer) + if matches!(&outer[..], [DynSolValue::FixedArray(inner)] if matches!(&inner[..], [DynSolValue::Tuple(_), DynSolValue::Tuple(_)])) + )); + + let value = serde_json::json!([[{"value": 1}], [{"value": 2}]]); + assert!(parse_fixed_array(&value, "Contract.Child[1][]", &struct_defs).unwrap().is_ok()); + let value = serde_json::json!([[]]); + assert!(parse_fixed_array(&value, "Contract.Child[][1]", &struct_defs).unwrap().is_ok()); + + let mut ambiguous_defs = TypeDefMap::new(); + ambiguous_defs + .insert("A.Child".to_string(), vec![("value".to_string(), "uint256".to_string())]); + ambiguous_defs + .insert("B.Child".to_string(), vec![("value".to_string(), "uint256".to_string())]); + let ambiguous_defs = StructDefinitions::from(ambiguous_defs); + let value = serde_json::json!([{"value": 1}]); + assert!(parse_fixed_array(&value, "Child[1]", &ambiguous_defs).is_none()); + assert!(parse_fixed_array(&value, "A.Child[1]", &ambiguous_defs).unwrap().is_ok()); + } + + #[test] + fn test_resolve_type_for_array_of_structs() -> Result<()> { + // Define a struct with fields in a specific, non-alphabetical order. + let mut struct_defs = TypeDefMap::new(); + struct_defs.insert( + "Item".to_string(), + vec![ + ("name".to_string(), "string".to_string()), + ("price".to_string(), "uint256".to_string()), + ("id".to_string(), "uint256".to_string()), + ], + ); + + // Simulate resolver output: type string, using alphabetical order for fields. + let ty_desc = "Item(uint256 id,string name,uint256 price)"; + + // Resolve type and ensure struct definition order is preserved. + let ty = resolve_type(ty_desc, Some(&struct_defs.into())).unwrap(); + let array_ty = DynSolType::Array(Box::new(ty)); + if let DynSolType::Array(item_ty) = array_ty + && let DynSolType::CustomStruct { name, prop_names, tuple } = *item_ty + { + assert_eq!(name, "Item"); + assert_eq!(prop_names, vec!["name", "price", "id"]); + assert_eq!( + tuple, + vec![DynSolType::String, DynSolType::Uint(256), DynSolType::Uint(256)] + ); + return Ok(()); + } + panic!("Expected CustomStruct in array"); + } + + #[test] + fn test_parse_json_missing_field() { + // Define a struct with a specific field order. + let mut struct_defs = TypeDefMap::new(); + struct_defs.insert( + "Person".to_string(), + vec![ + ("name".to_string(), "string".to_string()), + ("age".to_string(), "uint256".to_string()), + ], + ); + + // JSON missing the "age" field + let json_str = r#"{ "name": "Alice" }"#; + + // Simulate resolver output: type string, using alphabetical order for fields. + let type_description = "Person(uint256 age,string name)"; + let ty = resolve_type(type_description, Some(&struct_defs.into())).unwrap(); + + // Now, attempt to parse the incomplete JSON using the ordered type. + let json_value: Value = serde_json::from_str(json_str).unwrap(); + let result = parse_json_as(&json_value, &ty); + + // Should fail with a missing field error because `parse_json_map` requires all fields. + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("field \"age\" not found in JSON object")); + } + + #[test] + fn test_serialize_json_with_struct_def_order() { + // Define a struct with a specific, non-alphabetical field order. + let mut struct_defs = TypeDefMap::new(); + struct_defs.insert( + "Item".to_string(), + vec![ + ("name".to_string(), "string".to_string()), + ("id".to_string(), "uint256".to_string()), + ("active".to_string(), "bool".to_string()), + ], + ); + + // Create a DynSolValue instance for the struct. + let item_struct = DynSolValue::CustomStruct { + name: "Item".to_string(), + prop_names: vec!["name".to_string(), "id".to_string(), "active".to_string()], + tuple: vec![ + DynSolValue::String("Test Item".to_string()), + DynSolValue::Uint(U256::from(123), 256), + DynSolValue::Bool(true), + ], + }; + + // Serialize the value to JSON and verify that the order is preserved. + let json_value = + serialize_value_as_json(item_struct, Some(&struct_defs.into()), false).unwrap(); + let json_string = serde_json::to_string(&json_value).unwrap(); + assert_eq!(json_string, r#"{"name":"Test Item","id":123,"active":true}"#); + } + + #[test] + fn test_json_full_cycle_typed_with_struct_defs() { + // Define a struct with a specific, non-alphabetical field order. + let mut struct_defs = TypeDefMap::new(); + struct_defs.insert( + "Wallet".to_string(), + vec![ + ("owner".to_string(), "address".to_string()), + ("balance".to_string(), "uint256".to_string()), + ("id".to_string(), "bytes32".to_string()), + ], + ); + + // Create the "original" DynSolValue instance. + let owner_address = Address::repeat_byte(1); + let wallet_id = B256::repeat_byte(2); + let original_wallet = DynSolValue::CustomStruct { + name: "Wallet".to_string(), + prop_names: vec!["owner".to_string(), "balance".to_string(), "id".to_string()], + tuple: vec![ + DynSolValue::Address(owner_address), + DynSolValue::Uint(U256::from(5000), 256), + DynSolValue::FixedBytes(wallet_id, 32), + ], + }; + + // Serialize it. The resulting JSON should respect the struct definition order. + let json_value = serialize_value_as_json( + original_wallet.clone(), + Some(&struct_defs.clone().into()), + false, + ) + .unwrap(); + let json_string = serde_json::to_string(&json_value).unwrap(); + assert_eq!( + json_string, + format!(r#"{{"owner":"{owner_address}","balance":5000,"id":"{wallet_id}"}}"#) + ); + + // Resolve the type, which should also respect the struct definition order. + let type_description = "Wallet(uint256 balance,bytes32 id,address owner)"; + let resolved_type = resolve_type(type_description, Some(&struct_defs.into())).unwrap(); + + // Parse the JSON using the correctly ordered resolved type. Ensure that it is identical to + // the original one. + let parsed_value = parse_json_as(&json_value, &resolved_type).unwrap(); + assert_eq!(parsed_value, original_wallet); } } diff --git a/crates/cheatcodes/src/lib.rs b/crates/cheatcodes/src/lib.rs index f7b30baab0519..314e732bdabec 100644 --- a/crates/cheatcodes/src/lib.rs +++ b/crates/cheatcodes/src/lib.rs @@ -3,7 +3,7 @@ //! Foundry cheatcodes implementations. #![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] +#![cfg_attr(docsrs, feature(doc_cfg))] #![allow(elided_lifetimes_in_paths)] // Cheats context uses 3 lifetimes #[macro_use] @@ -15,14 +15,20 @@ pub extern crate foundry_cheatcodes_spec as spec; #[macro_use] extern crate tracing; -use alloy_evm::eth::EthEvmContext; -use alloy_primitives::Address; -use foundry_evm_core::backend::DatabaseExt; -use spec::Status; +use alloy_primitives::{Address, B256, U256}; +use foundry_evm_core::{ + FoundryTransaction, + backend::{DatabaseExt, LocalForkId}, + env::FoundryContextExt, + evm::{FoundryContextFor, FoundryEvmNetwork, SpecFor}, + fork::CreateFork, +}; +use revm::context::{Block, Cfg, ContextTr, JournalTr, Transaction}; pub use Vm::ForgeContext; pub use config::CheatsConfig; pub use error::{Error, ErrorKind, Result}; +pub use foundry_evm_core::evm::NestedEvmClosureFor; pub use inspector::{ BroadcastableTransaction, BroadcastableTransactions, Cheatcodes, CheatcodesExecutor, }; @@ -40,21 +46,31 @@ mod crypto; mod version; mod env; -pub use env::set_execution_context; +pub use env::{current_execution_context, set_execution_context}; mod evm; +mod expected_emit; + +mod external_storage; + mod fs; mod inspector; +pub use inspector::CheatcodeAnalysis; mod json; +#[cfg(feature = "monad")] +mod monad; + mod script; pub use script::{Wallets, WalletsInner}; mod string; +mod tempo; + mod test; pub use test::expect::ExpectedCallTracker; @@ -63,11 +79,11 @@ mod toml; mod utils; /// Cheatcode implementation. -pub(crate) trait Cheatcode: CheatcodeDef + DynCheatcode { +pub(crate) trait Cheatcode: CheatcodeDef { /// Applies this cheatcode to the given state. /// /// Implement this function if you don't need access to the EVM data. - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let _ = state; unimplemented!("{}", Self::CHEATCODE.func.id) } @@ -76,7 +92,7 @@ pub(crate) trait Cheatcode: CheatcodeDef + DynCheatcode { /// /// Implement this function if you need access to the EVM data. #[inline(always)] - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { self.apply(ccx.state) } @@ -84,86 +100,167 @@ pub(crate) trait Cheatcode: CheatcodeDef + DynCheatcode { /// /// Implement this function if you need access to the executor. #[inline(always)] - fn apply_full(&self, ccx: &mut CheatsCtxt, executor: &mut dyn CheatcodesExecutor) -> Result { + fn apply_full( + &self, + ccx: &mut CheatsCtxt<'_, '_, FEN>, + executor: &mut dyn CheatcodesExecutor, + ) -> Result { let _ = executor; self.apply_stateful(ccx) } } -pub(crate) trait DynCheatcode: 'static { - fn cheatcode(&self) -> &'static spec::Cheatcode<'static>; +/// The cheatcode context. +pub struct CheatsCtxt<'a, 'db, FEN: FoundryEvmNetwork + 'db> { + /// The cheatcodes inspector state. + pub(crate) state: &'a mut Cheatcodes, + /// The EVM context. + pub(crate) ecx: &'a mut FoundryContextFor<'db, FEN>, + /// The original `msg.sender`. + pub(crate) caller: Address, + /// Gas limit of the current cheatcode call. + pub(crate) gas_limit: u64, + /// Whether the current cheatcode call is static. + pub(crate) is_static: bool, +} - fn as_debug(&self) -> &dyn std::fmt::Debug; +impl CheatsCtxt<'_, '_, FEN> { + pub(crate) fn ensure_not_precompile(&self, address: &Address) -> Result<()> { + if self.is_precompile(address) { Err(precompile_error(address)) } else { Ok(()) } + } - fn dyn_apply(&self, ccx: &mut CheatsCtxt, executor: &mut dyn CheatcodesExecutor) -> Result; -} + pub(crate) fn is_precompile(&self, address: &Address) -> bool { + self.ecx.journal().precompile_addresses().contains(address) + } -impl DynCheatcode for T { + /// Returns the current call depth. #[inline] - fn cheatcode(&self) -> &'static spec::Cheatcode<'static> { - Self::CHEATCODE + pub(crate) fn depth(&self) -> usize { + self.ecx.journal().depth() } + /// Returns the active hardfork. #[inline] - fn as_debug(&self) -> &dyn std::fmt::Debug { - self + pub(crate) fn spec(&self) -> SpecFor { + self.ecx.cfg().spec() } + /// Returns the chain ID. #[inline] - fn dyn_apply(&self, ccx: &mut CheatsCtxt, executor: &mut dyn CheatcodesExecutor) -> Result { - self.apply_full(ccx, executor) + pub(crate) fn chain_id(&self) -> u64 { + self.ecx.cfg().chain_id() } -} -impl dyn DynCheatcode { - pub(crate) fn name(&self) -> &'static str { - self.cheatcode().func.signature.split('(').next().unwrap() + /// Returns the maximum initcode size. + #[inline] + pub(crate) fn max_initcode_size(&self) -> usize { + self.ecx.cfg().max_initcode_size() } - pub(crate) fn id(&self) -> &'static str { - self.cheatcode().func.id + /// Returns the configured contract code size limit, if any. + #[inline] + pub(crate) fn limit_contract_code_size(&self) -> Option { + self.ecx.cfg_env().limit_contract_code_size } - pub(crate) fn signature(&self) -> &'static str { - self.cheatcode().func.signature + /// Returns the block number. + #[inline] + pub(crate) fn block_number(&self) -> U256 { + self.ecx.block().number() } - pub(crate) fn status(&self) -> &Status<'static> { - &self.cheatcode().status + /// Returns the block timestamp. + #[inline] + pub(crate) fn timestamp(&self) -> U256 { + self.ecx.block().timestamp() } -} -/// The cheatcode context, used in `Cheatcode`. -pub struct CheatsCtxt<'cheats, 'evm, 'db, 'db2> { - /// The cheatcodes inspector state. - pub(crate) state: &'cheats mut Cheatcodes, - /// The EVM data. - pub(crate) ecx: &'evm mut EthEvmContext<&'db mut (dyn DatabaseExt + 'db2)>, - /// The original `msg.sender`. - pub(crate) caller: Address, - /// Gas limit of the current cheatcode call. - pub(crate) gas_limit: u64, -} + /// Returns the block base fee. + #[inline] + pub(crate) fn basefee(&self) -> u64 { + self.ecx.block().basefee() + } -impl<'db, 'db2> std::ops::Deref for CheatsCtxt<'_, '_, 'db, 'db2> { - type Target = EthEvmContext<&'db mut (dyn DatabaseExt + 'db2)>; + /// Returns the block slot number. + #[inline] + pub(crate) fn slot_num(&self) -> u64 { + self.ecx.block().slot_num() + } - #[inline(always)] - fn deref(&self) -> &Self::Target { - self.ecx + /// Returns the block excess blob gas, if any. + #[inline] + pub(crate) fn blob_excess_gas(&self) -> Option { + self.ecx.block().blob_excess_gas() } -} -impl std::ops::DerefMut for CheatsCtxt<'_, '_, '_, '_> { - #[inline(always)] - fn deref_mut(&mut self) -> &mut Self::Target { - &mut *self.ecx + /// Returns the transaction caller (`tx.origin`). + #[inline] + pub(crate) fn tx_caller(&self) -> Address { + self.ecx.tx().caller() } -} -impl CheatsCtxt<'_, '_, '_, '_> { + /// Returns the transaction gas price. #[inline] - pub(crate) fn is_precompile(&self, address: &Address) -> bool { - self.ecx.journaled_state.inner.precompiles.contains(address) + pub(crate) fn tx_gas_price(&self) -> u128 { + self.ecx.tx().gas_price() } + + /// Returns the transaction type. + #[inline] + pub(crate) fn tx_type(&self) -> u8 { + self.ecx.tx().tx_type() + } + + /// Returns the transaction blob versioned hashes. + #[inline] + pub(crate) fn tx_blob_hashes(&self) -> &[B256] { + self.ecx.tx().blob_versioned_hashes() + } + + /// Returns the transaction fee token, if any. + #[inline] + pub(crate) fn tx_fee_token(&self) -> Option
{ + self.ecx.tx().fee_token() + } + + /// Returns the active fork ID, if any. + #[inline] + pub(crate) fn active_fork_id(&self) -> Option { + self.ecx.db().active_fork_id() + } + + /// Returns the active fork URL, if any. + #[inline] + pub(crate) fn active_fork_url(&self) -> Option { + self.ecx.db().active_fork_url() + } + + /// Returns the active fork block number, if any. + #[inline] + pub(crate) fn active_fork_block_number(&self) -> Option { + self.ecx.db().active_fork_block_number() + } + + /// Returns the active fork options, if any. + #[inline] + pub(crate) fn active_fork_options(&self) -> Option { + self.ecx.db().active_fork_options() + } + + /// Returns whether a fork is active. + #[inline] + pub(crate) fn is_forked_mode(&self) -> bool { + self.ecx.db().is_forked_mode() + } + + /// Returns whether `account` persists across forks. + #[inline] + pub(crate) fn is_persistent(&self, account: &Address) -> bool { + self.ecx.db().is_persistent(account) + } +} + +#[cold] +fn precompile_error(address: &Address) -> Error { + fmt_err!("cannot use precompile {address} as an argument") } diff --git a/crates/cheatcodes/src/monad.rs b/crates/cheatcodes/src/monad.rs new file mode 100644 index 0000000000000..9ee5309c76c43 --- /dev/null +++ b/crates/cheatcodes/src/monad.rs @@ -0,0 +1,241 @@ +//! Monad staking cheatcodes. + +use crate::{CheatsCtxt, Result}; +use alloy_primitives::{Address, U256}; +use alloy_sol_types::SolInterface; +use foundry_evm_core::{constants::MONAD_CHEATCODE_ADDRESS, evm::FoundryEvmNetwork}; +use monad_revm::{ + api::block::{ + syscall_on_epoch_change_calldata, syscall_reward_calldata, syscall_snapshot_calldata, + }, + staking::{ + StorageReader, + constants::SYSTEM_ADDRESS, + storage::{STAKING_ADDRESS, global_slots, validator_key, validator_offsets}, + write::{ + StakingStorage, handle_syscall_on_epoch_change, handle_syscall_reward, + handle_syscall_snapshot, + }, + }, +}; +use revm::{ + context::{ContextTr, JournalTr}, + precompile::PrecompileHalt, + primitives::Log, +}; + +alloy_sol_types::sol! { + /// Monad-specific cheatcodes. Accessible via `MonadVm(MONAD_CHEATCODE_ADDRESS)`. + interface MonadVm { + /// Sets the current epoch and delay period for the staking precompile. + function setEpoch(uint64 epoch, bool inDelayPeriod) external; + + /// Sets the current block proposer validator ID. + function setProposer(uint64 valId) external; + + /// Directly sets a validator's accumulated reward per token. + function setAccumulator(uint64 valId, uint256 value) external; + + /// Distribute block reward via the real syscallReward handler. + function blockReward(address author, uint256 reward) external; + + /// Execute syscallSnapshot. + function epochSnapshot() external; + + /// Execute syscallOnEpochChange. + function epochChange(uint64 newEpoch) external; + + /// Convenience: epochSnapshot() then epochChange(newEpoch). + function epochBoundary(uint64 newEpoch) external; + } +} + +pub(crate) fn is_monad_cheatcode_call( + extra_cheatcode_addresses: &[Address], + target: Address, +) -> bool { + target == MONAD_CHEATCODE_ADDRESS && extra_cheatcode_addresses.contains(&target) +} + +pub(crate) fn apply_monad_cheatcode( + ccx: &mut CheatsCtxt<'_, '_, FEN>, + input: &[u8], +) -> Result { + let decoded = MonadVm::MonadVmCalls::abi_decode(input).map_err(|e| { + if let alloy_sol_types::Error::UnknownSelector { selector, .. } = e { + let msg = format!( + "unknown monad cheatcode with selector {selector}; \ + check that your MonadVm interface matches this forge version" + ); + return alloy_sol_types::Error::Other(std::borrow::Cow::Owned(msg)); + } + e + })?; + + match decoded { + MonadVm::MonadVmCalls::setEpoch(call) => apply_set_epoch(ccx, call), + MonadVm::MonadVmCalls::setProposer(call) => apply_set_proposer(ccx, call), + MonadVm::MonadVmCalls::setAccumulator(call) => apply_set_accumulator(ccx, call), + MonadVm::MonadVmCalls::blockReward(call) => apply_block_reward(ccx, call), + MonadVm::MonadVmCalls::epochSnapshot(call) => apply_epoch_snapshot(ccx, call), + MonadVm::MonadVmCalls::epochChange(call) => apply_epoch_change(ccx, call), + MonadVm::MonadVmCalls::epochBoundary(call) => apply_epoch_boundary(ccx, call), + } +} + +fn u64_left_aligned(v: u64) -> U256 { + let mut bytes = [0u8; 32]; + bytes[0..8].copy_from_slice(&v.to_be_bytes()); + U256::from_be_bytes(bytes) +} + +fn sstore_staking( + ccx: &mut CheatsCtxt<'_, '_, FEN>, + key: U256, + value: U256, +) -> Result<()> { + ccx.ecx.journal_mut().load_account(STAKING_ADDRESS)?; + ccx.ecx + .journal_mut() + .sstore(STAKING_ADDRESS, key, value) + .map_err(|e| fmt_err!("staking sstore failed: {:?}", e))?; + Ok(()) +} + +struct CheatsCtxtStorage<'a, 'b, 'db, FEN: FoundryEvmNetwork> { + ccx: &'a mut CheatsCtxt<'b, 'db, FEN>, +} + +impl StorageReader for CheatsCtxtStorage<'_, '_, '_, FEN> { + fn sload(&mut self, key: U256) -> core::result::Result { + self.ccx + .ecx + .journal_mut() + .sload(STAKING_ADDRESS, key) + .map(|r| r.data) + .map_err(|e| PrecompileHalt::Other(format!("sload failed: {e:?}").into())) + } +} + +impl StakingStorage for CheatsCtxtStorage<'_, '_, '_, FEN> { + fn sstore(&mut self, key: U256, value: U256) -> core::result::Result<(), PrecompileHalt> { + self.ccx + .ecx + .journal_mut() + .sstore(STAKING_ADDRESS, key, value) + .map(|_| ()) + .map_err(|e| PrecompileHalt::Other(format!("sstore failed: {e:?}").into())) + } + + fn transfer( + &mut self, + from: Address, + to: Address, + amount: U256, + ) -> core::result::Result<(), PrecompileHalt> { + if amount.is_zero() { + return Ok(()); + } + + match self.ccx.ecx.journal_mut().transfer(from, to, amount) { + Ok(None) => Ok(()), + Ok(Some(e)) => Err(PrecompileHalt::Other(format!("transfer failed: {e:?}").into())), + Err(e) => Err(PrecompileHalt::Other(format!("transfer error: {e:?}").into())), + } + } + + fn emit_log(&mut self, log: Log) -> core::result::Result<(), PrecompileHalt> { + self.ccx.ecx.journal_mut().log(log); + Ok(()) + } +} + +fn apply_set_epoch( + ccx: &mut CheatsCtxt<'_, '_, FEN>, + call: MonadVm::setEpochCall, +) -> Result { + let MonadVm::setEpochCall { epoch, inDelayPeriod } = call; + sstore_staking(ccx, global_slots::EPOCH, u64_left_aligned(epoch))?; + + let boundary_val = if inDelayPeriod { + let mut bytes = [0u8; 32]; + bytes[0] = 1; + U256::from_be_bytes(bytes) + } else { + U256::ZERO + }; + sstore_staking(ccx, global_slots::IN_BOUNDARY, boundary_val)?; + Ok(Default::default()) +} + +fn apply_set_proposer( + ccx: &mut CheatsCtxt<'_, '_, FEN>, + call: MonadVm::setProposerCall, +) -> Result { + sstore_staking(ccx, global_slots::PROPOSER_VAL_ID, u64_left_aligned(call.valId))?; + Ok(Default::default()) +} + +fn apply_set_accumulator( + ccx: &mut CheatsCtxt<'_, '_, FEN>, + call: MonadVm::setAccumulatorCall, +) -> Result { + sstore_staking( + ccx, + validator_key(call.valId, validator_offsets::ACCUMULATED_REWARD_PER_TOKEN), + call.value, + )?; + Ok(Default::default()) +} + +fn apply_block_reward( + ccx: &mut CheatsCtxt<'_, '_, FEN>, + call: MonadVm::blockRewardCall, +) -> Result { + let MonadVm::blockRewardCall { author, reward } = call; + let calldata = syscall_reward_calldata(author, reward); + + let mut storage = CheatsCtxtStorage { ccx }; + handle_syscall_reward(&mut storage, &calldata, u64::MAX, &SYSTEM_ADDRESS, U256::ZERO) + .map_err(|e| fmt_err!("blockReward failed: {e}"))?; + + storage + .ccx + .ecx + .journal_mut() + .balance_incr(STAKING_ADDRESS, reward) + .map_err(|e| fmt_err!("blockReward balance increment failed: {e:?}"))?; + + Ok(Default::default()) +} + +fn apply_epoch_snapshot( + ccx: &mut CheatsCtxt<'_, '_, FEN>, + _call: MonadVm::epochSnapshotCall, +) -> Result { + let calldata = syscall_snapshot_calldata(); + let mut storage = CheatsCtxtStorage { ccx }; + handle_syscall_snapshot(&mut storage, &calldata, u64::MAX, &SYSTEM_ADDRESS) + .map_err(|e| fmt_err!("epochSnapshot failed: {e}"))?; + Ok(Default::default()) +} + +fn apply_epoch_change( + ccx: &mut CheatsCtxt<'_, '_, FEN>, + call: MonadVm::epochChangeCall, +) -> Result { + let calldata = syscall_on_epoch_change_calldata(call.newEpoch); + let mut storage = CheatsCtxtStorage { ccx }; + handle_syscall_on_epoch_change(&mut storage, &calldata, u64::MAX, &SYSTEM_ADDRESS) + .map_err(|e| fmt_err!("epochChange failed: {e}"))?; + Ok(Default::default()) +} + +fn apply_epoch_boundary( + ccx: &mut CheatsCtxt<'_, '_, FEN>, + call: MonadVm::epochBoundaryCall, +) -> Result { + apply_epoch_snapshot(ccx, MonadVm::epochSnapshotCall {})?; + apply_epoch_change(ccx, MonadVm::epochChangeCall { newEpoch: call.newEpoch })?; + Ok(Default::default()) +} diff --git a/crates/cheatcodes/src/script.rs b/crates/cheatcodes/src/script.rs index 209bc4d02f435..bae0c9727a48f 100644 --- a/crates/cheatcodes/src/script.rs +++ b/crates/cheatcodes/src/script.rs @@ -1,117 +1,113 @@ //! Implementations of [`Scripting`](spec::Group::Scripting) cheatcodes. -use crate::{Cheatcode, CheatsCtxt, Result, Vm::*}; +use crate::{Cheatcode, CheatsCtxt, Result, Vm::*, evm::journaled_account}; use alloy_consensus::{SidecarBuilder, SimpleCoder}; -use alloy_primitives::{Address, B256, U256, Uint}; +use alloy_primitives::{Address, B256, U256}; use alloy_rpc_types::Authorization; use alloy_signer::SignerSync; use alloy_signer_local::PrivateKeySigner; use alloy_sol_types::SolValue; -use foundry_wallets::{WalletSigner, multi_wallet::MultiWallet}; +use foundry_evm_core::evm::FoundryEvmNetwork; +use foundry_wallets::{WalletSigner, wallet_multi::MultiWallet}; use parking_lot::Mutex; use revm::{ bytecode::Bytecode, - context::JournalTr, + context::{ContextTr, JournalTr}, context_interface::transaction::SignedAuthorization, primitives::{KECCAK_EMPTY, hardfork::SpecId}, }; use std::sync::Arc; impl Cheatcode for broadcast_0Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self {} = self; broadcast(ccx, None, true) } } impl Cheatcode for broadcast_1Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { signer } = self; broadcast(ccx, Some(signer), true) } } impl Cheatcode for broadcast_2Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { privateKey } = self; broadcast_key(ccx, privateKey, true) } } impl Cheatcode for attachDelegation_0Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { signedDelegation } = self; attach_delegation(ccx, signedDelegation, false) } } impl Cheatcode for attachDelegation_1Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { signedDelegation, crossChain } = self; attach_delegation(ccx, signedDelegation, *crossChain) } } impl Cheatcode for signDelegation_0Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { implementation, privateKey } = *self; sign_delegation(ccx, privateKey, implementation, None, false, false) } } impl Cheatcode for signDelegation_1Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { implementation, privateKey, nonce } = *self; sign_delegation(ccx, privateKey, implementation, Some(nonce), false, false) } } impl Cheatcode for signDelegation_2Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { implementation, privateKey, crossChain } = *self; sign_delegation(ccx, privateKey, implementation, None, crossChain, false) } } impl Cheatcode for signAndAttachDelegation_0Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { implementation, privateKey } = *self; sign_delegation(ccx, privateKey, implementation, None, false, true) } } impl Cheatcode for signAndAttachDelegation_1Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { implementation, privateKey, nonce } = *self; sign_delegation(ccx, privateKey, implementation, Some(nonce), false, true) } } impl Cheatcode for signAndAttachDelegation_2Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { implementation, privateKey, crossChain } = *self; sign_delegation(ccx, privateKey, implementation, None, crossChain, true) } } /// Helper function to attach an EIP-7702 delegation. -fn attach_delegation( - ccx: &mut CheatsCtxt, +fn attach_delegation( + ccx: &mut CheatsCtxt<'_, '_, FEN>, delegation: &SignedDelegation, cross_chain: bool, ) -> Result { let SignedDelegation { v, r, s, nonce, implementation } = delegation; // Set chain id to 0 if universal deployment is preferred. // See https://github.com/ethereum/EIPs/blob/master/EIPS/eip-7702.md#protection-from-malleability-cross-chain - let chain_id = if cross_chain { U256::from(0) } else { U256::from(ccx.ecx.cfg.chain_id) }; + let chain_id = if cross_chain { U256::ZERO } else { U256::from(ccx.chain_id()) }; let auth = Authorization { address: *implementation, nonce: *nonce, chain_id }; - let signed_auth = SignedAuthorization::new_unchecked( - auth, - *v, - U256::from_be_bytes(r.0), - U256::from_be_bytes(s.0), - ); + let signed_auth = SignedAuthorization::new_unchecked(auth, *v, (*r).into(), (*s).into()); write_delegation(ccx, signed_auth.clone())?; ccx.state.add_delegation(signed_auth); Ok(Default::default()) @@ -119,9 +115,9 @@ fn attach_delegation( /// Helper function to sign and attach (if needed) an EIP-7702 delegation. /// Uses the provided nonce, otherwise retrieves and increments the nonce of the EOA. -fn sign_delegation( - ccx: &mut CheatsCtxt, - private_key: Uint<256, 4>, +fn sign_delegation( + ccx: &mut CheatsCtxt<'_, '_, FEN>, + private_key: U256, implementation: Address, nonce: Option, cross_chain: bool, @@ -131,16 +127,19 @@ fn sign_delegation( let nonce = if let Some(nonce) = nonce { nonce } else { - let authority_acc = ccx.ecx.journaled_state.load_account(signer.address())?; + let account_nonce = { + let authority_acc = ccx.ecx.journal_mut().load_account(signer.address())?; + authority_acc.info.nonce + }; // Calculate next nonce considering existing active delegations next_delegation_nonce( &ccx.state.active_delegations, signer.address(), &ccx.state.broadcast, - authority_acc.data.info.nonce, + account_nonce, ) }; - let chain_id = if cross_chain { U256::from(0) } else { U256::from(ccx.ecx.cfg.chain_id) }; + let chain_id = if cross_chain { U256::ZERO } else { U256::from(ccx.chain_id()) }; let auth = Authorization { address: implementation, nonce, chain_id }; let sig = signer.sign_hash_sync(&auth.signature_hash())?; @@ -173,7 +172,7 @@ fn next_delegation_nonce( { Some(auth) => { // Increment nonce of last recorded delegation. - auth.nonce + 1 + auth.nonce() + 1 } None => { // First time a delegation is added for this authority. @@ -189,21 +188,27 @@ fn next_delegation_nonce( } } -fn write_delegation(ccx: &mut CheatsCtxt, auth: SignedAuthorization) -> Result<()> { +fn write_delegation( + ccx: &mut CheatsCtxt<'_, '_, FEN>, + auth: SignedAuthorization, +) -> Result<()> { let authority = auth.recover_authority().map_err(|e| format!("{e}"))?; - let authority_acc = ccx.ecx.journaled_state.load_account(authority)?; + let account_nonce = { + let authority_acc = ccx.ecx.journal_mut().load_account(authority)?; + authority_acc.info.nonce + }; let expected_nonce = next_delegation_nonce( &ccx.state.active_delegations, authority, &ccx.state.broadcast, - authority_acc.data.info.nonce, + account_nonce, ); - if expected_nonce != auth.nonce { + if expected_nonce != auth.nonce() { return Err(format!( "invalid nonce for {authority:?}: expected {expected_nonce}, got {}", - auth.nonce + auth.nonce() ) .into()); } @@ -211,52 +216,56 @@ fn write_delegation(ccx: &mut CheatsCtxt, auth: SignedAuthorization) -> Result<( if auth.address.is_zero() { // Set empty code if the delegation address of authority is 0x. // See https://github.com/ethereum/EIPs/blob/master/EIPS/eip-7702.md#behavior. - ccx.ecx.journaled_state.set_code_with_hash(authority, Bytecode::default(), KECCAK_EMPTY); + ccx.ecx.journal_mut().set_code_with_hash(authority, Bytecode::default(), KECCAK_EMPTY); } else { let bytecode = Bytecode::new_eip7702(*auth.address()); - ccx.ecx.journaled_state.set_code(authority, bytecode); + ccx.ecx.journal_mut().set_code(authority, bytecode); } Ok(()) } impl Cheatcode for attachBlobCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { blob } = self; ensure!( - ccx.ecx.cfg.spec >= SpecId::CANCUN, + ccx.spec().into() >= SpecId::CANCUN, "`attachBlob` is not supported before the Cancun hard fork; \ see EIP-4844: https://eips.ethereum.org/EIPS/eip-4844" ); let sidecar: SidecarBuilder = SidecarBuilder::from_slice(blob); - let sidecar = sidecar.build().map_err(|e| format!("{e}"))?; - ccx.state.active_blob_sidecar = Some(sidecar); + let sidecar_variant = if ccx.spec().into() < SpecId::OSAKA { + sidecar.build_4844().map_err(|e| format!("{e}"))?.into() + } else { + sidecar.build_7594().map_err(|e| format!("{e}"))?.into() + }; + ccx.state.active_blob_sidecar = Some(sidecar_variant); Ok(Default::default()) } } impl Cheatcode for startBroadcast_0Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self {} = self; broadcast(ccx, None, false) } } impl Cheatcode for startBroadcast_1Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { signer } = self; broadcast(ccx, Some(signer), false) } } impl Cheatcode for startBroadcast_2Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { privateKey } = self; broadcast_key(ccx, privateKey, false) } } impl Cheatcode for stopBroadcastCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self {} = self; let Some(broadcast) = ccx.state.broadcast.take() else { bail!("no broadcast in progress to stop"); @@ -267,7 +276,7 @@ impl Cheatcode for stopBroadcastCall { } impl Cheatcode for getWalletsCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let wallets = ccx.state.wallets().signers().unwrap_or_default(); Ok(wallets.abi_encode()) } @@ -285,6 +294,8 @@ pub struct Broadcast { pub depth: usize, /// Whether the prank stops by itself after the next call pub single_call: bool, + /// Whether `vm.deployCode` cheatcode is used to deploy from code. + pub deploy_from_code: bool, } /// Contains context for wallet management. @@ -318,12 +329,6 @@ impl Wallets { .unwrap_or_else(|| panic!("not all instances were dropped")) } - /// Locks inner Mutex and adds a signer to the [MultiWallet]. - pub fn add_private_key(&self, private_key: &B256) -> Result<()> { - self.add_local_signer(PrivateKeySigner::from_bytes(private_key)?); - Ok(()) - } - /// Locks inner Mutex and adds a signer to the [MultiWallet]. pub fn add_local_signer(&self, wallet: PrivateKeySigner) { self.inner.lock().multi_wallet.add_signer(WalletSigner::Local(wallet)); @@ -334,14 +339,15 @@ impl Wallets { Ok(self.inner.lock().multi_wallet.signers()?.keys().copied().collect()) } + /// Locks inner Mutex and returns all available addresses in the [MultiWallet]. + pub fn addresses(&self) -> Vec
{ + self.inner.lock().multi_wallet.available_addresses() + } + /// Number of signers in the [MultiWallet]. pub fn len(&self) -> usize { let mut inner = self.inner.lock(); - let signers = inner.multi_wallet.signers(); - if signers.is_err() { - return 0; - } - signers.unwrap().len() + inner.multi_wallet.signers().map_or(0, |signers| signers.len()) } /// Whether the [MultiWallet] is empty. @@ -351,8 +357,12 @@ impl Wallets { } /// Sets up broadcasting from a script using `new_origin` as the sender. -fn broadcast(ccx: &mut CheatsCtxt, new_origin: Option<&Address>, single_call: bool) -> Result { - let depth = ccx.ecx.journaled_state.depth(); +fn broadcast( + ccx: &mut CheatsCtxt<'_, '_, FEN>, + new_origin: Option<&Address>, + single_call: bool, +) -> Result { + let depth = ccx.depth(); ensure!( ccx.state.get_prank(depth).is_none(), "you have an active prank; broadcasting and pranks are not compatible" @@ -373,13 +383,17 @@ fn broadcast(ccx: &mut CheatsCtxt, new_origin: Option<&Address>, single_call: bo } } } + let new_origin = new_origin.unwrap_or(ccx.tx_caller()); + // Ensure new origin is loaded and touched. + let _ = journaled_account(ccx.ecx, new_origin)?; let broadcast = Broadcast { - new_origin: new_origin.unwrap_or(ccx.ecx.tx.caller), + new_origin, original_caller: ccx.caller, - original_origin: ccx.ecx.tx.caller, + original_origin: ccx.tx_caller(), depth, single_call, + deploy_from_code: false, }; debug!(target: "cheatcodes", ?broadcast, "started"); ccx.state.broadcast = Some(broadcast); @@ -389,7 +403,11 @@ fn broadcast(ccx: &mut CheatsCtxt, new_origin: Option<&Address>, single_call: bo /// Sets up broadcasting from a script with the sender derived from `private_key`. /// Adds this private key to `state`'s `wallets` vector to later be used for signing /// if broadcast is successful. -fn broadcast_key(ccx: &mut CheatsCtxt, private_key: &U256, single_call: bool) -> Result { +fn broadcast_key( + ccx: &mut CheatsCtxt<'_, '_, FEN>, + private_key: &U256, + single_call: bool, +) -> Result { let wallet = super::crypto::parse_wallet(private_key)?; let new_origin = wallet.address(); diff --git a/crates/cheatcodes/src/string.rs b/crates/cheatcodes/src/string.rs index 6b3825b6b7d08..a1ff1ba415cdf 100644 --- a/crates/cheatcodes/src/string.rs +++ b/crates/cheatcodes/src/string.rs @@ -4,10 +4,11 @@ use crate::{Cheatcode, Cheatcodes, Result, Vm::*}; use alloy_dyn_abi::{DynSolType, DynSolValue}; use alloy_primitives::{U256, hex}; use alloy_sol_types::SolValue; +use foundry_evm_core::evm::FoundryEvmNetwork; // address impl Cheatcode for toString_0Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { value } = self; Ok(value.to_string().abi_encode()) } @@ -15,7 +16,7 @@ impl Cheatcode for toString_0Call { // bytes impl Cheatcode for toString_1Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { value } = self; Ok(value.to_string().abi_encode()) } @@ -23,7 +24,7 @@ impl Cheatcode for toString_1Call { // bytes32 impl Cheatcode for toString_2Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { value } = self; Ok(value.to_string().abi_encode()) } @@ -31,7 +32,7 @@ impl Cheatcode for toString_2Call { // bool impl Cheatcode for toString_3Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { value } = self; Ok(value.to_string().abi_encode()) } @@ -39,7 +40,7 @@ impl Cheatcode for toString_3Call { // uint256 impl Cheatcode for toString_4Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { value } = self; Ok(value.to_string().abi_encode()) } @@ -47,84 +48,84 @@ impl Cheatcode for toString_4Call { // int256 impl Cheatcode for toString_5Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { value } = self; Ok(value.to_string().abi_encode()) } } impl Cheatcode for parseBytesCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { stringifiedValue } = self; parse(stringifiedValue, &DynSolType::Bytes) } } impl Cheatcode for parseAddressCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { stringifiedValue } = self; parse(stringifiedValue, &DynSolType::Address) } } impl Cheatcode for parseUintCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { stringifiedValue } = self; parse(stringifiedValue, &DynSolType::Uint(256)) } } impl Cheatcode for parseIntCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { stringifiedValue } = self; parse(stringifiedValue, &DynSolType::Int(256)) } } impl Cheatcode for parseBytes32Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { stringifiedValue } = self; parse(stringifiedValue, &DynSolType::FixedBytes(32)) } } impl Cheatcode for parseBoolCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { stringifiedValue } = self; parse(stringifiedValue, &DynSolType::Bool) } } impl Cheatcode for toLowercaseCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { input } = self; Ok(input.to_lowercase().abi_encode()) } } impl Cheatcode for toUppercaseCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { input } = self; Ok(input.to_uppercase().abi_encode()) } } impl Cheatcode for trimCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { input } = self; Ok(input.trim().abi_encode()) } } impl Cheatcode for replaceCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { input, from, to } = self; Ok(input.replace(from, to).abi_encode()) } } impl Cheatcode for splitCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { input, delimiter } = self; let parts: Vec<&str> = input.split(delimiter).collect(); Ok(parts.abi_encode()) @@ -132,14 +133,14 @@ impl Cheatcode for splitCall { } impl Cheatcode for indexOfCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { input, key } = self; Ok(input.find(key).map(U256::from).unwrap_or(U256::MAX).abi_encode()) } } impl Cheatcode for containsCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { subject, search } = self; Ok(subject.contains(search).abi_encode()) } @@ -166,7 +167,6 @@ where } } -#[instrument(target = "cheatcodes", level = "debug", skip(ty), fields(%ty), ret)] pub(super) fn parse_value(s: &str, ty: &DynSolType) -> Result { match ty.coerce_str(s) { Ok(value) => Ok(value), @@ -194,10 +194,10 @@ fn parse_value_fallback(s: &str, ty: &DynSolType) -> Option { - if !s.starts_with("0x") && hex::check_raw(s) { - return Some(Err("missing hex prefix (\"0x\") for hex string")); - } + | DynSolType::Bytes + if !s.starts_with("0x") && hex::check_raw(s) => + { + return Some(Err("missing hex prefix (\"0x\") for hex string")); } _ => {} } diff --git a/crates/cheatcodes/src/tempo.rs b/crates/cheatcodes/src/tempo.rs new file mode 100644 index 0000000000000..9b085f97ace09 --- /dev/null +++ b/crates/cheatcodes/src/tempo.rs @@ -0,0 +1,45 @@ +//! Cheatcodes that delegate to the imported Tempo precompile crates. + +use std::str::FromStr; + +use alloy_sol_types::SolValue; +use foundry_evm_core::evm::FoundryEvmNetwork; +use spec::Vm::{assumeImplicitApprovalCall, isImplicitlyApprovedCall}; +use tempo_hardfork::TempoHardfork; +use tempo_precompiles::address_registry; + +use crate::{Cheatcode, CheatsCtxt, Error, Result}; +use foundry_config::ExecutionSpec; +use foundry_evm_core::constants::MAGIC_ASSUME; + +impl Cheatcode for isImplicitlyApprovedCall { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { + let Self { spender } = *self; + let approved = match active_tempo_hardfork(ccx) { + Some(hardfork) => address_registry::is_implicitly_approved(spender, hardfork), + None => false, + }; + Ok(approved.abi_encode()) + } +} + +impl Cheatcode for assumeImplicitApprovalCall { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { + let Self { spender } = *self; + let approved = match active_tempo_hardfork(ccx) { + Some(hardfork) => address_registry::is_implicitly_approved(spender, hardfork), + None => false, + }; + if approved { Ok(Default::default()) } else { Err(Error::from(MAGIC_ASSUME)) } + } +} + +/// Resolves the active Tempo hardfork from the cheatcode-visible spec, or `None` on non-Tempo +/// networks. Goes through the spec's stable string name because the context is generic over +/// the network type. +fn active_tempo_hardfork( + ccx: &CheatsCtxt<'_, '_, FEN>, +) -> Option { + let spec = ccx.spec(); + TempoHardfork::from_str(&spec.evm_version_name()).ok() +} diff --git a/crates/cheatcodes/src/test.rs b/crates/cheatcodes/src/test.rs index 683c5131bcddf..f0b80c8d312f8 100644 --- a/crates/cheatcodes/src/test.rs +++ b/crates/cheatcodes/src/test.rs @@ -2,10 +2,10 @@ use crate::{Cheatcode, Cheatcodes, CheatsCtxt, Result, Vm::*}; use alloy_chains::Chain as AlloyChain; -use alloy_primitives::{Address, U256}; +use alloy_primitives::{Address, Bytes, U256}; use alloy_sol_types::SolValue; use foundry_common::version::SEMVER_VERSION; -use foundry_evm_core::constants::MAGIC_SKIP; +use foundry_evm_core::{constants::MAGIC_SKIP, evm::FoundryEvmNetwork}; use std::str::FromStr; pub(crate) mod assert; @@ -14,28 +14,28 @@ pub(crate) mod expect; pub(crate) mod revert_handlers; impl Cheatcode for breakpoint_0Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { char } = self; breakpoint(ccx.state, &ccx.caller, char, true) } } impl Cheatcode for breakpoint_1Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { char, value } = self; breakpoint(ccx.state, &ccx.caller, char, *value) } } impl Cheatcode for getFoundryVersionCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self {} = self; Ok(SEMVER_VERSION.abi_encode()) } } impl Cheatcode for rpcUrlCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { rpcAlias } = self; let url = state.config.rpc_endpoint(rpcAlias)?.url()?.abi_encode(); Ok(url) @@ -43,21 +43,21 @@ impl Cheatcode for rpcUrlCall { } impl Cheatcode for rpcUrlsCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self {} = self; state.config.rpc_urls().map(|urls| urls.abi_encode()) } } impl Cheatcode for rpcUrlStructsCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self {} = self; state.config.rpc_urls().map(|urls| urls.abi_encode()) } } impl Cheatcode for sleepCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { duration } = self; let sleep_duration = std::time::Duration::from_millis(duration.saturating_to()); std::thread::sleep(sleep_duration); @@ -66,35 +66,28 @@ impl Cheatcode for sleepCall { } impl Cheatcode for skip_0Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { skipTest } = *self; - skip_1Call { skipTest, reason: String::new() }.apply_stateful(ccx) + skip(ccx, skipTest, "") } } impl Cheatcode for skip_1Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { skipTest, reason } = self; - if *skipTest { - // Skip should not work if called deeper than at test level. - // Since we're not returning the magic skip bytes, this will cause a test failure. - ensure!(ccx.ecx.journaled_state.depth <= 1, "`skip` can only be used at test level"); - Err([MAGIC_SKIP, reason.as_bytes()].concat().into()) - } else { - Ok(Default::default()) - } + skip(ccx, *skipTest, reason) } } impl Cheatcode for getChain_0Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { chainAlias } = self; get_chain(state, chainAlias) } } impl Cheatcode for getChain_1Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { chainId } = self; // Convert the chainId to a string and use the existing get_chain function let chain_id_str = chainId.to_string(); @@ -102,8 +95,31 @@ impl Cheatcode for getChain_1Call { } } +/// Reverts with the magic skip payload and records it in the state, so that the executor can +/// distinguish this genuine skip from user-crafted revert data carrying the same prefix. +fn skip( + ccx: &mut CheatsCtxt<'_, '_, FEN>, + skip_test: bool, + reason: &str, +) -> Result { + if !skip_test { + return Ok(Default::default()); + } + // Skip should not work if called deeper than at test level. + // Since we're not returning the magic skip bytes, this will cause a test failure. + ensure!(ccx.depth() <= 1, "`skip` can only be used at test level"); + let payload = Bytes::from([MAGIC_SKIP, reason.as_bytes()].concat()); + ccx.state.skip_payloads.push(payload.clone()); + Err(payload.into()) +} + /// Adds or removes the given breakpoint to the state. -fn breakpoint(state: &mut Cheatcodes, caller: &Address, s: &str, add: bool) -> Result { +fn breakpoint( + state: &mut Cheatcodes, + caller: &Address, + s: &str, + add: bool, +) -> Result { let mut chars = s.chars(); let (Some(point), None) = (chars.next(), chars.next()) else { bail!("breakpoints must be exactly one character"); @@ -120,7 +136,7 @@ fn breakpoint(state: &mut Cheatcodes, caller: &Address, s: &str, add: bool) -> R } /// Gets chain information for the given alias. -fn get_chain(state: &mut Cheatcodes, chain_alias: &str) -> Result { +fn get_chain(state: &mut Cheatcodes, chain_alias: &str) -> Result { // Parse the chain alias - works for both chain names and IDs let alloy_chain = AlloyChain::from_str(chain_alias) .map_err(|_| fmt_err!("invalid chain alias: {chain_alias}"))?; diff --git a/crates/cheatcodes/src/test/assert.rs b/crates/cheatcodes/src/test/assert.rs index 0bcf3e7a9e1b7..289d321f2a71b 100644 --- a/crates/cheatcodes/src/test/assert.rs +++ b/crates/cheatcodes/src/test/assert.rs @@ -1,67 +1,85 @@ use crate::{CheatcodesExecutor, CheatsCtxt, Result, Vm::*}; -use alloy_primitives::{I256, U256, U512, hex}; +use alloy_primitives::{I256, U256, U512, uint}; use foundry_evm_core::{ abi::console::{format_units_int, format_units_uint}, backend::GLOBAL_FAIL_SLOT, constants::CHEATCODE_ADDRESS, + decode::ASSERTION_FAILED_PREFIX, + evm::FoundryEvmNetwork, }; use itertools::Itertools; -use revm::context::JournalTr; -use std::fmt::{Debug, Display}; +use revm::context::{ContextTr, JournalTr}; +use std::{borrow::Cow, fmt}; -const EQ_REL_DELTA_RESOLUTION: U256 = U256::from_limbs([18, 0, 0, 0]); +const EQ_REL_DELTA_RESOLUTION: U256 = uint!(18_U256); -#[derive(Debug, thiserror::Error)] -#[error("assertion failed")] -struct SimpleAssertionError; +struct ComparisonAssertionError<'a, T> { + kind: AssertionKind, + left: &'a T, + right: &'a T, +} -#[derive(thiserror::Error, Debug)] -enum ComparisonAssertionError<'a, T> { - Ne { left: &'a T, right: &'a T }, - Eq { left: &'a T, right: &'a T }, - Ge { left: &'a T, right: &'a T }, - Gt { left: &'a T, right: &'a T }, - Le { left: &'a T, right: &'a T }, - Lt { left: &'a T, right: &'a T }, -} - -macro_rules! format_values { - ($self:expr, $format_fn:expr) => { - match $self { - Self::Ne { left, right } => format!("{} == {}", $format_fn(left), $format_fn(right)), - Self::Eq { left, right } => format!("{} != {}", $format_fn(left), $format_fn(right)), - Self::Ge { left, right } => format!("{} < {}", $format_fn(left), $format_fn(right)), - Self::Gt { left, right } => format!("{} <= {}", $format_fn(left), $format_fn(right)), - Self::Le { left, right } => format!("{} > {}", $format_fn(left), $format_fn(right)), - Self::Lt { left, right } => format!("{} >= {}", $format_fn(left), $format_fn(right)), +#[derive(Clone, Copy)] +enum AssertionKind { + Eq, + Ne, + Gt, + Ge, + Lt, + Le, +} + +impl AssertionKind { + const fn inverse(self) -> Self { + match self { + Self::Eq => Self::Ne, + Self::Ne => Self::Eq, + Self::Gt => Self::Le, + Self::Ge => Self::Lt, + Self::Lt => Self::Ge, + Self::Le => Self::Gt, } - }; + } + + const fn to_str(self) -> &'static str { + match self { + Self::Eq => "==", + Self::Ne => "!=", + Self::Gt => ">", + Self::Ge => ">=", + Self::Lt => "<", + Self::Le => "<=", + } + } +} + +impl ComparisonAssertionError<'_, T> { + fn format_values(&self, f: impl Fn(&T) -> D) -> String { + format!("{} {} {}", f(self.left), self.kind.inverse().to_str(), f(self.right)) + } } -impl ComparisonAssertionError<'_, T> { +impl ComparisonAssertionError<'_, T> { fn format_for_values(&self) -> String { - format_values!(self, T::to_string) + self.format_values(T::to_string) } } -impl ComparisonAssertionError<'_, Vec> { +impl ComparisonAssertionError<'_, Vec> { fn format_for_arrays(&self) -> String { - let formatter = |v: &Vec| format!("[{}]", v.iter().format(", ")); - format_values!(self, formatter) + self.format_values(|v| format!("[{}]", v.iter().format(", "))) } } impl ComparisonAssertionError<'_, U256> { fn format_with_decimals(&self, decimals: &U256) -> String { - let formatter = |v: &U256| format_units_uint(v, decimals); - format_values!(self, formatter) + self.format_values(|v| format_units_uint(v, decimals)) } } impl ComparisonAssertionError<'_, I256> { fn format_with_decimals(&self, decimals: &U256) -> String { - let formatter = |v: &I256| format_units_int(v, decimals); - format_values!(self, formatter) + self.format_values(|v| format_units_int(v, decimals)) } } @@ -108,8 +126,8 @@ enum EqRelDelta { Undefined, } -impl Display for EqRelDelta { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl fmt::Display for EqRelDelta { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Defined(delta) => write!(f, "{}", format_delta_percent(delta)), Self::Undefined => write!(f, "undefined"), @@ -146,7 +164,7 @@ impl EqRelAssertionError { format_units_uint(&f.left, decimals), format_units_uint(&f.right, decimals), format_delta_percent(&f.max_delta), - &f.real_delta, + f.real_delta, ), Self::Overflow => self.to_string(), } @@ -161,92 +179,101 @@ impl EqRelAssertionError { format_units_int(&f.left, decimals), format_units_int(&f.right, decimals), format_delta_percent(&f.max_delta), - &f.real_delta, + f.real_delta, ), Self::Overflow => self.to_string(), } } } -type ComparisonResult<'a, T> = Result, ComparisonAssertionError<'a, T>>; +type ComparisonResult<'a, T> = Result<(), ComparisonAssertionError<'a, T>>; -fn handle_assertion_result( - result: core::result::Result, ERR>, - ccx: &mut CheatsCtxt, - executor: &mut dyn CheatcodesExecutor, - error_formatter: impl Fn(&ERR) -> String, +#[cold] +fn handle_assertion_result( + ccx: &mut CheatsCtxt<'_, '_, FEN>, + executor: &mut dyn CheatcodesExecutor, + err: E, + error_formatter: Option<&dyn Fn(&E) -> String>, error_msg: Option<&str>, - format_error: bool, ) -> Result { - match result { - Ok(_) => Ok(Default::default()), - Err(err) => { - let error_msg = error_msg.unwrap_or("assertion failed"); - let msg = if format_error { - format!("{error_msg}: {}", error_formatter(&err)) - } else { - error_msg.to_string() - }; - if ccx.state.config.assertions_revert { - Err(msg.into()) - } else { - executor.console_log(ccx, &msg); - ccx.ecx.journaled_state.sstore( - CHEATCODE_ADDRESS, - GLOBAL_FAIL_SLOT, - U256::from(1), - )?; - Ok(Default::default()) - } - } + let error_msg = error_msg.unwrap_or(ASSERTION_FAILED_PREFIX); + let msg = if let Some(error_formatter) = error_formatter { + Cow::Owned(format!("{error_msg}: {}", error_formatter(&err))) + } else { + Cow::Borrowed(error_msg) + }; + handle_assertion_result_mono(ccx, executor, msg) +} + +fn handle_assertion_result_mono( + ccx: &mut CheatsCtxt<'_, '_, FEN>, + executor: &mut dyn CheatcodesExecutor, + msg: Cow<'_, str>, +) -> Result { + if ccx.state.config.assertions_revert { + Err(msg.into_owned().into()) + } else { + executor.console_log(&msg); + ccx.ecx.journal_mut().sstore(CHEATCODE_ADDRESS, GLOBAL_FAIL_SLOT, U256::ONE)?; + Ok(Default::default()) } } /// Implements [crate::Cheatcode] for pairs of cheatcodes. /// /// Accepts a list of pairs of cheatcodes, where the first cheatcode is the one that doesn't contain -/// a custom error message, and the second one contains it at `error` field. +/// a custom error message, and the second one contains it at `err` field. /// -/// Passed `args` are the common arguments for both cheatcode structs (excluding `error` field). +/// Passed `args` are the common arguments for both cheatcode structs (excluding `err` field). /// /// Macro also accepts an optional closure that formats the error returned by the assertion. macro_rules! impl_assertions { - (|$($arg:ident),*| $body:expr, $format_error:literal, $(($no_error:ident, $with_error:ident)),* $(,)?) => { - impl_assertions!(@args_tt |($($arg),*)| $body, |e| e.to_string(), $format_error, $(($no_error, $with_error),)*); + (|$($arg:ident),*| $body:expr, false, $(($no_error:ident, $with_error:ident)),* $(,)?) => { + impl_assertions! { @args_tt |($($arg),*)| $body, None, $(($no_error, $with_error)),* } }; (|$($arg:ident),*| $body:expr, $(($no_error:ident, $with_error:ident)),* $(,)?) => { - impl_assertions!(@args_tt |($($arg),*)| $body, |e| e.to_string(), true, $(($no_error, $with_error),)*); + impl_assertions! { @args_tt |($($arg),*)| $body, Some(&ToString::to_string), $(($no_error, $with_error)),* } }; (|$($arg:ident),*| $body:expr, $error_formatter:expr, $(($no_error:ident, $with_error:ident)),* $(,)?) => { - impl_assertions!(@args_tt |($($arg),*)| $body, $error_formatter, true, $(($no_error, $with_error)),*); + impl_assertions! { @args_tt |($($arg),*)| $body, Some(&$error_formatter), $(($no_error, $with_error)),* } }; + // We convert args to `tt` and later expand them back into tuple to allow usage of expanded args inside of // each assertion type context. - (@args_tt |$args:tt| $body:expr, $error_formatter:expr, $format_error:literal, $(($no_error:ident, $with_error:ident)),* $(,)?) => { + (@args_tt |$args:tt| $body:expr, $error_formatter:expr, $(($no_error:ident, $with_error:ident)),* $(,)?) => { $( - impl_assertions!(@impl $no_error, $with_error, $args, $body, $error_formatter, $format_error); + impl_assertions! { @impl $no_error, $with_error, $args, $body, $error_formatter } )* }; - (@impl $no_error:ident, $with_error:ident, ($($arg:ident),*), $body:expr, $error_formatter:expr, $format_error:literal) => { + + (@impl $no_error:ident, $with_error:ident, ($($arg:ident),*), $body:expr, $error_formatter:expr) => { impl crate::Cheatcode for $no_error { - fn apply_full( + fn apply_full( &self, - ccx: &mut CheatsCtxt, - executor: &mut dyn CheatcodesExecutor, + ccx: &mut CheatsCtxt<'_, '_, FEN>, + executor: &mut dyn CheatcodesExecutor, ) -> Result { let Self { $($arg),* } = self; - handle_assertion_result($body, ccx, executor, $error_formatter, None, $format_error) + match $body { + Ok(()) => Ok(Default::default()), + Err(err) => handle_assertion_result(ccx, executor, err, $error_formatter, None) + } } } impl crate::Cheatcode for $with_error { - fn apply_full( + fn apply_full( &self, - ccx: &mut CheatsCtxt, - executor: &mut dyn CheatcodesExecutor, + ccx: &mut CheatsCtxt<'_, '_, FEN>, + executor: &mut dyn CheatcodesExecutor, ) -> Result { - let Self { $($arg),*, error} = self; - handle_assertion_result($body, ccx, executor, $error_formatter, Some(error), $format_error) + let Self { $($arg,)* err } = self; + match $body { + Ok(()) => Ok(Default::default()), + Err(assertion_err) => { + handle_assertion_result(ccx, executor, assertion_err, $error_formatter, Some(err)) + } + } } } }; @@ -266,38 +293,25 @@ impl_assertions! { impl_assertions! { |left, right| assert_eq(left, right), - |e| e.format_for_values(), + ComparisonAssertionError::format_for_values, (assertEq_0Call, assertEq_1Call), (assertEq_2Call, assertEq_3Call), (assertEq_4Call, assertEq_5Call), (assertEq_6Call, assertEq_7Call), (assertEq_8Call, assertEq_9Call), (assertEq_10Call, assertEq_11Call), -} - -impl_assertions! { - |left, right| assert_eq(&hex::encode_prefixed(left), &hex::encode_prefixed(right)), - |e| e.format_for_values(), (assertEq_12Call, assertEq_13Call), } impl_assertions! { |left, right| assert_eq(left, right), - |e| e.format_for_arrays(), + ComparisonAssertionError::format_for_arrays, (assertEq_14Call, assertEq_15Call), (assertEq_16Call, assertEq_17Call), (assertEq_18Call, assertEq_19Call), (assertEq_20Call, assertEq_21Call), (assertEq_22Call, assertEq_23Call), (assertEq_24Call, assertEq_25Call), -} - -impl_assertions! { - |left, right| assert_eq( - &left.iter().map(hex::encode_prefixed).collect::>(), - &right.iter().map(hex::encode_prefixed).collect::>(), - ), - |e| e.format_for_arrays(), (assertEq_26Call, assertEq_27Call), } @@ -310,38 +324,25 @@ impl_assertions! { impl_assertions! { |left, right| assert_not_eq(left, right), - |e| e.format_for_values(), + ComparisonAssertionError::format_for_values, (assertNotEq_0Call, assertNotEq_1Call), (assertNotEq_2Call, assertNotEq_3Call), (assertNotEq_4Call, assertNotEq_5Call), (assertNotEq_6Call, assertNotEq_7Call), (assertNotEq_8Call, assertNotEq_9Call), (assertNotEq_10Call, assertNotEq_11Call), -} - -impl_assertions! { - |left, right| assert_not_eq(&hex::encode_prefixed(left), &hex::encode_prefixed(right)), - |e| e.format_for_values(), (assertNotEq_12Call, assertNotEq_13Call), } impl_assertions! { |left, right| assert_not_eq(left, right), - |e| e.format_for_arrays(), + ComparisonAssertionError::format_for_arrays, (assertNotEq_14Call, assertNotEq_15Call), (assertNotEq_16Call, assertNotEq_17Call), (assertNotEq_18Call, assertNotEq_19Call), (assertNotEq_20Call, assertNotEq_21Call), (assertNotEq_22Call, assertNotEq_23Call), (assertNotEq_24Call, assertNotEq_25Call), -} - -impl_assertions! { - |left, right| assert_not_eq( - &left.iter().map(hex::encode_prefixed).collect::>(), - &right.iter().map(hex::encode_prefixed).collect::>(), - ), - |e| e.format_for_arrays(), (assertNotEq_26Call, assertNotEq_27Call), } @@ -354,7 +355,7 @@ impl_assertions! { impl_assertions! { |left, right| assert_gt(left, right), - |e| e.format_for_values(), + ComparisonAssertionError::format_for_values, (assertGt_0Call, assertGt_1Call), (assertGt_2Call, assertGt_3Call), } @@ -368,7 +369,7 @@ impl_assertions! { impl_assertions! { |left, right| assert_ge(left, right), - |e| e.format_for_values(), + ComparisonAssertionError::format_for_values, (assertGe_0Call, assertGe_1Call), (assertGe_2Call, assertGe_3Call), } @@ -382,7 +383,7 @@ impl_assertions! { impl_assertions! { |left, right| assert_lt(left, right), - |e| e.format_for_values(), + ComparisonAssertionError::format_for_values, (assertLt_0Call, assertLt_1Call), (assertLt_2Call, assertLt_3Call), } @@ -396,7 +397,7 @@ impl_assertions! { impl_assertions! { |left, right| assert_le(left, right), - |e| e.format_for_values(), + ComparisonAssertionError::format_for_values, (assertLe_0Call, assertLe_1Call), (assertLe_2Call, assertLe_3Call), } @@ -452,27 +453,59 @@ impl_assertions! { (assertApproxEqRelDecimal_2Call, assertApproxEqRelDecimal_3Call), } -fn assert_true(condition: bool) -> Result, SimpleAssertionError> { - if condition { Ok(Default::default()) } else { Err(SimpleAssertionError) } +const fn assert_true(condition: bool) -> Result<(), ()> { + if condition { Ok(()) } else { Err(()) } } -fn assert_false(condition: bool) -> Result, SimpleAssertionError> { - if !condition { Ok(Default::default()) } else { Err(SimpleAssertionError) } +const fn assert_false(condition: bool) -> Result<(), ()> { + assert_true(!condition) } fn assert_eq<'a, T: PartialEq>(left: &'a T, right: &'a T) -> ComparisonResult<'a, T> { if left == right { - Ok(Default::default()) + Ok(()) } else { - Err(ComparisonAssertionError::Eq { left, right }) + Err(ComparisonAssertionError { kind: AssertionKind::Eq, left, right }) } } fn assert_not_eq<'a, T: PartialEq>(left: &'a T, right: &'a T) -> ComparisonResult<'a, T> { - if left != right { - Ok(Default::default()) + if left == right { + Err(ComparisonAssertionError { kind: AssertionKind::Ne, left, right }) + } else { + Ok(()) + } +} + +fn assert_gt<'a, T: PartialOrd>(left: &'a T, right: &'a T) -> ComparisonResult<'a, T> { + if left > right { + Ok(()) + } else { + Err(ComparisonAssertionError { kind: AssertionKind::Gt, left, right }) + } +} + +fn assert_ge<'a, T: PartialOrd>(left: &'a T, right: &'a T) -> ComparisonResult<'a, T> { + if left >= right { + Ok(()) } else { - Err(ComparisonAssertionError::Ne { left, right }) + Err(ComparisonAssertionError { kind: AssertionKind::Ge, left, right }) + } +} + +fn assert_lt<'a, T: PartialOrd>(left: &'a T, right: &'a T) -> ComparisonResult<'a, T> { + if left < right { + Ok(()) + } else { + Err(ComparisonAssertionError { kind: AssertionKind::Lt, left, right }) + } +} + +fn assert_le<'a, T: PartialOrd>(left: &'a T, right: &'a T) -> ComparisonResult<'a, T> { + if left <= right { + Ok(()) + } else { + Err(ComparisonAssertionError { kind: AssertionKind::Le, left, right }) } } @@ -483,7 +516,7 @@ fn get_delta_int(left: I256, right: I256) -> U256 { if left_sign == right_sign { if left_abs > right_abs { left_abs - right_abs } else { right_abs - left_abs } } else { - left_abs + right_abs + left_abs.wrapping_add(right_abs) } } @@ -500,11 +533,11 @@ fn uint_assert_approx_eq_abs( left: U256, right: U256, max_delta: U256, -) -> Result, Box>> { +) -> Result<(), Box>> { let delta = left.abs_diff(right); if delta <= max_delta { - Ok(Default::default()) + Ok(()) } else { Err(Box::new(EqAbsAssertionError { left, right, max_delta, real_delta: delta })) } @@ -514,11 +547,11 @@ fn int_assert_approx_eq_abs( left: I256, right: I256, max_delta: U256, -) -> Result, Box>> { +) -> Result<(), Box>> { let delta = get_delta_int(left, right); if delta <= max_delta { - Ok(Default::default()) + Ok(()) } else { Err(Box::new(EqAbsAssertionError { left, right, max_delta, real_delta: delta })) } @@ -528,24 +561,23 @@ fn uint_assert_approx_eq_rel( left: U256, right: U256, max_delta: U256, -) -> Result, EqRelAssertionError> { +) -> Result<(), EqRelAssertionError> { if right.is_zero() { if left.is_zero() { - return Ok(Default::default()); - } else { - return Err(EqRelAssertionError::Failure(Box::new(EqRelAssertionFailure { - left, - right, - max_delta, - real_delta: EqRelDelta::Undefined, - }))); - }; + return Ok(()); + } + return Err(EqRelAssertionError::Failure(Box::new(EqRelAssertionFailure { + left, + right, + max_delta, + real_delta: EqRelDelta::Undefined, + }))); } let delta = calc_delta_full::(left.abs_diff(right), right)?; if delta <= max_delta { - Ok(Default::default()) + Ok(()) } else { Err(EqRelAssertionError::Failure(Box::new(EqRelAssertionFailure { left, @@ -560,24 +592,23 @@ fn int_assert_approx_eq_rel( left: I256, right: I256, max_delta: U256, -) -> Result, EqRelAssertionError> { +) -> Result<(), EqRelAssertionError> { if right.is_zero() { if left.is_zero() { - return Ok(Default::default()); - } else { - return Err(EqRelAssertionError::Failure(Box::new(EqRelAssertionFailure { - left, - right, - max_delta, - real_delta: EqRelDelta::Undefined, - }))); + return Ok(()); } + return Err(EqRelAssertionError::Failure(Box::new(EqRelAssertionFailure { + left, + right, + max_delta, + real_delta: EqRelDelta::Undefined, + }))); } let delta = calc_delta_full::(get_delta_int(left, right), right.unsigned_abs())?; if delta <= max_delta { - Ok(Default::default()) + Ok(()) } else { Err(EqRelAssertionError::Failure(Box::new(EqRelAssertionFailure { left, @@ -587,35 +618,3 @@ fn int_assert_approx_eq_rel( }))) } } - -fn assert_gt<'a, T: PartialOrd>(left: &'a T, right: &'a T) -> ComparisonResult<'a, T> { - if left > right { - Ok(Default::default()) - } else { - Err(ComparisonAssertionError::Gt { left, right }) - } -} - -fn assert_ge<'a, T: PartialOrd>(left: &'a T, right: &'a T) -> ComparisonResult<'a, T> { - if left >= right { - Ok(Default::default()) - } else { - Err(ComparisonAssertionError::Ge { left, right }) - } -} - -fn assert_lt<'a, T: PartialOrd>(left: &'a T, right: &'a T) -> ComparisonResult<'a, T> { - if left < right { - Ok(Default::default()) - } else { - Err(ComparisonAssertionError::Lt { left, right }) - } -} - -fn assert_le<'a, T: PartialOrd>(left: &'a T, right: &'a T) -> ComparisonResult<'a, T> { - if left <= right { - Ok(Default::default()) - } else { - Err(ComparisonAssertionError::Le { left, right }) - } -} diff --git a/crates/cheatcodes/src/test/assume.rs b/crates/cheatcodes/src/test/assume.rs index 98126bcee7621..f6b78e4634494 100644 --- a/crates/cheatcodes/src/test/assume.rs +++ b/crates/cheatcodes/src/test/assume.rs @@ -1,6 +1,6 @@ use crate::{Cheatcode, Cheatcodes, CheatsCtxt, Error, Result}; use alloy_primitives::Address; -use foundry_evm_core::constants::MAGIC_ASSUME; +use foundry_evm_core::{constants::MAGIC_ASSUME, evm::FoundryEvmNetwork}; use spec::Vm::{ PotentialRevert, assumeCall, assumeNoRevert_0Call, assumeNoRevert_1Call, assumeNoRevert_2Call, }; @@ -34,7 +34,7 @@ impl AcceptableRevertParameters { Self { reason: potential_revert.revertData.to_vec(), partial_match: potential_revert.partialMatch, - reverter: if potential_revert.reverter == Address::ZERO { + reverter: if potential_revert.reverter.is_zero() { None } else { Some(potential_revert.reverter) @@ -44,42 +44,42 @@ impl AcceptableRevertParameters { } impl Cheatcode for assumeCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { condition } = self; if *condition { Ok(Default::default()) } else { Err(Error::from(MAGIC_ASSUME)) } } } impl Cheatcode for assumeNoRevert_0Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { - assume_no_revert(ccx.state, ccx.ecx.journaled_state.depth, vec![]) + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { + assume_no_revert(ccx.state, ccx.depth(), vec![]) } } impl Cheatcode for assumeNoRevert_1Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { potentialRevert } = self; assume_no_revert( ccx.state, - ccx.ecx.journaled_state.depth, + ccx.depth(), vec![AcceptableRevertParameters::from(potentialRevert)], ) } } impl Cheatcode for assumeNoRevert_2Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { potentialReverts } = self; assume_no_revert( ccx.state, - ccx.ecx.journaled_state.depth, + ccx.depth(), potentialReverts.iter().map(AcceptableRevertParameters::from).collect(), ) } } -fn assume_no_revert( - state: &mut Cheatcodes, +fn assume_no_revert( + state: &mut Cheatcodes, depth: usize, parameters: Vec, ) -> Result { diff --git a/crates/cheatcodes/src/test/expect.rs b/crates/cheatcodes/src/test/expect.rs index 5dc68dc9230b3..3bdf6f53eae08 100644 --- a/crates/cheatcodes/src/test/expect.rs +++ b/crates/cheatcodes/src/test/expect.rs @@ -1,31 +1,38 @@ -use std::{ - collections::VecDeque, - fmt::{self, Display}, -}; +use std::fmt::{self, Display}; -use crate::{Cheatcode, Cheatcodes, CheatsCtxt, Error, Result, Vm::*}; +use crate::{ + Cheatcode, Cheatcodes, CheatsCtxt, Error, Result, + Vm::*, + expected_emit::{ExpectedEmit, observe_log}, +}; use alloy_primitives::{ - Address, Bytes, LogData as RawLog, U256, - map::{AddressHashMap, HashMap, hash_map::Entry}, + Address, Bytes, Log, LogData as RawLog, U256, hex, keccak256, + map::{HashMap, hash_map::Entry}, }; -use revm::{ - context::JournalTr, - interpreter::{ - InstructionResult, Interpreter, InterpreterAction, interpreter_types::LoopControl, - }, +use alloy_sol_types::{SolCall, SolValue}; +use foundry_evm_core::evm::FoundryEvmNetwork; +use itertools::Itertools; +use revm::interpreter::{ + CallScheme, InstructionResult, Interpreter, InterpreterAction, interpreter_types::LoopControl, }; +use tempo_contracts::precompiles::ISignatureVerifier; +use tempo_precompiles::SIGNATURE_VERIFIER_ADDRESS; use super::revert_handlers::RevertParameters; /// Tracks the expected calls per address. /// -/// For each address, we track the expected calls per call data. We track it in such manner -/// so that we don't mix together calldatas that only contain selectors and calldatas that contain -/// selector and arguments (partial and full matches). +/// For each address, we track the expected calls per call data and optional call scheme. We track +/// it in such manner so that we don't mix together calldatas that only contain selectors and +/// calldatas that contain selector and arguments (partial and full matches), or unrestricted calls +/// and calls restricted to a specific scheme. /// /// This then allows us to customize the matching behavior for each call data on the /// `ExpectedCallData` struct and track how many times we've actually seen the call on the second /// element of the tuple. -pub type ExpectedCallTracker = HashMap>; +pub type ExpectedCallTracker = HashMap; + +/// Tracks calldata, scheme and count expectations for one target. +pub type ExpectedCallsForTarget = HashMap<(Bytes, Option), (ExpectedCallData, u64)>; #[derive(Clone, Debug)] pub struct ExpectedCallData { @@ -69,7 +76,7 @@ pub enum ExpectedRevertKind { #[derive(Clone, Debug)] pub struct ExpectedRevert { /// The expected data returned by the revert, None being any. - pub reason: Option>, + pub reason: Option, /// The depth at which the revert is expected. pub depth: usize, /// The type of expected revert. @@ -88,30 +95,6 @@ pub struct ExpectedRevert { pub actual_count: u64, } -#[derive(Clone, Debug)] -pub struct ExpectedEmit { - /// The depth at which we expect this emit to have occurred - pub depth: usize, - /// The log we expect - pub log: Option, - /// The checks to perform: - /// ```text - /// ┌───────┬───────┬───────┬───────┬────┐ - /// │topic 0│topic 1│topic 2│topic 3│data│ - /// └───────┴───────┴───────┴───────┴────┘ - /// ``` - pub checks: [bool; 5], - /// If present, check originating address against this - pub address: Option
, - /// If present, relax the requirement that topic 0 must be present. This allows anonymous - /// events with no indexed topics to be matched. - pub anonymous: bool, - /// Whether the log was actually found in the subcalls - pub found: bool, - /// Number of times the log is expected to be emitted - pub count: u64, -} - #[derive(Clone, Debug)] pub struct ExpectedCreate { /// The address that deployed the contract @@ -148,7 +131,7 @@ impl From for CreateScheme { } impl CreateScheme { - pub fn eq(&self, create_scheme: Self) -> bool { + pub const fn eq(&self, create_scheme: Self) -> bool { matches!( (self, create_scheme), (Self::Create, Self::Create) | (Self::Create2, Self::Create2 { .. }) @@ -157,28 +140,38 @@ impl CreateScheme { } impl Cheatcode for expectCall_0Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { callee, data } = self; - expect_call(state, callee, data, None, None, None, 1, ExpectedCallType::NonCount) + expect_call(state, callee, data, None, None, None, None, 1, ExpectedCallType::NonCount) } } impl Cheatcode for expectCall_1Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { callee, data, count } = self; - expect_call(state, callee, data, None, None, None, *count, ExpectedCallType::Count) + expect_call(state, callee, data, None, None, None, None, *count, ExpectedCallType::Count) } } impl Cheatcode for expectCall_2Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { callee, msgValue, data } = self; - expect_call(state, callee, data, Some(msgValue), None, None, 1, ExpectedCallType::NonCount) + expect_call( + state, + callee, + data, + Some(msgValue), + None, + None, + None, + 1, + ExpectedCallType::NonCount, + ) } } impl Cheatcode for expectCall_3Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { callee, msgValue, data, count } = self; expect_call( state, @@ -187,6 +180,7 @@ impl Cheatcode for expectCall_3Call { Some(msgValue), None, None, + None, *count, ExpectedCallType::Count, ) @@ -194,7 +188,7 @@ impl Cheatcode for expectCall_3Call { } impl Cheatcode for expectCall_4Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { callee, msgValue, gas, data } = self; expect_call( state, @@ -203,6 +197,7 @@ impl Cheatcode for expectCall_4Call { Some(msgValue), Some(*gas), None, + None, 1, ExpectedCallType::NonCount, ) @@ -210,7 +205,7 @@ impl Cheatcode for expectCall_4Call { } impl Cheatcode for expectCall_5Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { callee, msgValue, gas, data, count } = self; expect_call( state, @@ -219,14 +214,32 @@ impl Cheatcode for expectCall_5Call { Some(msgValue), Some(*gas), None, + None, *count, ExpectedCallType::Count, ) } } +impl Cheatcode for expectDelegateCallCall { + fn apply(&self, state: &mut Cheatcodes) -> Result { + let Self { callee, data } = self; + expect_call( + state, + callee, + data, + None, + None, + None, + Some(CallScheme::DelegateCall), + 1, + ExpectedCallType::NonCount, + ) + } +} + impl Cheatcode for expectCallMinGas_0Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { callee, msgValue, minGas, data } = self; expect_call( state, @@ -235,6 +248,7 @@ impl Cheatcode for expectCallMinGas_0Call { Some(msgValue), None, Some(*minGas), + None, 1, ExpectedCallType::NonCount, ) @@ -242,7 +256,7 @@ impl Cheatcode for expectCallMinGas_0Call { } impl Cheatcode for expectCallMinGas_1Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { callee, msgValue, minGas, data, count } = self; expect_call( state, @@ -251,6 +265,7 @@ impl Cheatcode for expectCallMinGas_1Call { Some(msgValue), None, Some(*minGas), + None, *count, ExpectedCallType::Count, ) @@ -258,11 +273,11 @@ impl Cheatcode for expectCallMinGas_1Call { } impl Cheatcode for expectEmit_0Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { checkTopic1, checkTopic2, checkTopic3, checkData } = *self; expect_emit( ccx.state, - ccx.ecx.journaled_state.depth(), + ccx.depth(), [true, checkTopic1, checkTopic2, checkTopic3, checkData], None, false, @@ -272,11 +287,11 @@ impl Cheatcode for expectEmit_0Call { } impl Cheatcode for expectEmit_1Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { checkTopic1, checkTopic2, checkTopic3, checkData, emitter } = *self; expect_emit( ccx.state, - ccx.ecx.journaled_state.depth(), + ccx.depth(), [true, checkTopic1, checkTopic2, checkTopic3, checkData], Some(emitter), false, @@ -286,25 +301,25 @@ impl Cheatcode for expectEmit_1Call { } impl Cheatcode for expectEmit_2Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self {} = self; - expect_emit(ccx.state, ccx.ecx.journaled_state.depth(), [true; 5], None, false, 1) + expect_emit(ccx.state, ccx.depth(), [true; 5], None, false, 1) } } impl Cheatcode for expectEmit_3Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { emitter } = *self; - expect_emit(ccx.state, ccx.ecx.journaled_state.depth(), [true; 5], Some(emitter), false, 1) + expect_emit(ccx.state, ccx.depth(), [true; 5], Some(emitter), false, 1) } } impl Cheatcode for expectEmit_4Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { checkTopic1, checkTopic2, checkTopic3, checkData, count } = *self; expect_emit( ccx.state, - ccx.ecx.journaled_state.depth(), + ccx.depth(), [true, checkTopic1, checkTopic2, checkTopic3, checkData], None, false, @@ -314,11 +329,11 @@ impl Cheatcode for expectEmit_4Call { } impl Cheatcode for expectEmit_5Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { checkTopic1, checkTopic2, checkTopic3, checkData, emitter, count } = *self; expect_emit( ccx.state, - ccx.ecx.journaled_state.depth(), + ccx.depth(), [true, checkTopic1, checkTopic2, checkTopic3, checkData], Some(emitter), false, @@ -328,32 +343,25 @@ impl Cheatcode for expectEmit_5Call { } impl Cheatcode for expectEmit_6Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { count } = *self; - expect_emit(ccx.state, ccx.ecx.journaled_state.depth(), [true; 5], None, false, count) + expect_emit(ccx.state, ccx.depth(), [true; 5], None, false, count) } } impl Cheatcode for expectEmit_7Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { emitter, count } = *self; - expect_emit( - ccx.state, - ccx.ecx.journaled_state.depth(), - [true; 5], - Some(emitter), - false, - count, - ) + expect_emit(ccx.state, ccx.depth(), [true; 5], Some(emitter), false, count) } } impl Cheatcode for expectEmitAnonymous_0Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { checkTopic0, checkTopic1, checkTopic2, checkTopic3, checkData } = *self; expect_emit( ccx.state, - ccx.ecx.journaled_state.depth(), + ccx.depth(), [checkTopic0, checkTopic1, checkTopic2, checkTopic3, checkData], None, true, @@ -363,11 +371,11 @@ impl Cheatcode for expectEmitAnonymous_0Call { } impl Cheatcode for expectEmitAnonymous_1Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { checkTopic0, checkTopic1, checkTopic2, checkTopic3, checkData, emitter } = *self; expect_emit( ccx.state, - ccx.ecx.journaled_state.depth(), + ccx.depth(), [checkTopic0, checkTopic1, checkTopic2, checkTopic3, checkData], Some(emitter), true, @@ -377,92 +385,145 @@ impl Cheatcode for expectEmitAnonymous_1Call { } impl Cheatcode for expectEmitAnonymous_2Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self {} = self; - expect_emit(ccx.state, ccx.ecx.journaled_state.depth(), [true; 5], None, true, 1) + expect_emit(ccx.state, ccx.depth(), [true; 5], None, true, 1) } } impl Cheatcode for expectEmitAnonymous_3Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { emitter } = *self; - expect_emit(ccx.state, ccx.ecx.journaled_state.depth(), [true; 5], Some(emitter), true, 1) + expect_emit(ccx.state, ccx.depth(), [true; 5], Some(emitter), true, 1) } } impl Cheatcode for expectCreateCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { bytecode, deployer } = self; expect_create(state, bytecode.clone(), *deployer, CreateScheme::Create) } } impl Cheatcode for expectCreate2Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { bytecode, deployer } = self; expect_create(state, bytecode.clone(), *deployer, CreateScheme::Create2) } } +impl Cheatcode for expectTip20LogoURIUpdatedCall { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { + let Self { token, updater, newLogoURI } = self; + expect_logo_uri_updated(ccx, token, updater, newLogoURI) + } +} + +impl Cheatcode for expectKeychainVerifiedCall { + fn apply(&self, state: &mut Cheatcodes) -> Result { + let Self { account, digest, signature } = self; + expect_keychain_verified(state, *account, *digest, signature.clone(), false) + } +} + +impl Cheatcode for expectKeychainAdminVerifiedCall { + fn apply(&self, state: &mut Cheatcodes) -> Result { + let Self { account, digest, signature } = self; + expect_keychain_verified(state, *account, *digest, signature.clone(), true) + } +} + +impl Cheatcode for expectLogoURIUpdatedCall { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { + let Self { token, updater, newLogoURI } = self; + expect_logo_uri_updated(ccx, token, updater, newLogoURI) + } +} + +fn expect_keychain_verified( + state: &mut Cheatcodes, + account: Address, + digest: alloy_primitives::B256, + signature: Bytes, + admin: bool, +) -> Result { + let calldata = if admin { + ISignatureVerifier::verifyKeychainAdminCall { account, hash: digest, signature } + .abi_encode() + } else { + ISignatureVerifier::verifyKeychainCall { account, hash: digest, signature }.abi_encode() + }; + expect_call( + state, + &SIGNATURE_VERIFIER_ADDRESS, + &Bytes::from(calldata), + None, + None, + None, + None, + 1, + ExpectedCallType::NonCount, + ) +} + +fn expect_logo_uri_updated( + ccx: &mut CheatsCtxt<'_, '_, FEN>, + token: &Address, + updater: &Address, + new_logo_uri: &str, +) -> Result { + let expected_emit = ExpectedEmit { + depth: ccx.depth(), + log: Some(RawLog::new_unchecked( + vec![keccak256("LogoURIUpdated(address,string)"), updater.into_word()], + new_logo_uri.abi_encode().into(), + )), + checks: [true, true, false, false, true], + address: Some(*token), + anonymous: false, + found: false, + count: 1, + mismatch_error: None, + }; + ccx.state.expected_emits.push_back((expected_emit, Default::default())); + Ok(Default::default()) +} + impl Cheatcode for expectRevert_0Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self {} = self; - expect_revert(ccx.state, None, ccx.ecx.journaled_state.depth(), false, false, None, 1) + expect_revert(ccx.state, None, ccx.depth(), false, false, None, 1) } } impl Cheatcode for expectRevert_1Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { revertData } = self; - expect_revert( - ccx.state, - Some(revertData.as_ref()), - ccx.ecx.journaled_state.depth(), - false, - false, - None, - 1, - ) + expect_revert(ccx.state, Some(revertData.as_ref()), ccx.depth(), false, false, None, 1) } } impl Cheatcode for expectRevert_2Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { revertData } = self; - expect_revert( - ccx.state, - Some(revertData), - ccx.ecx.journaled_state.depth(), - false, - false, - None, - 1, - ) + expect_revert(ccx.state, Some(revertData), ccx.depth(), false, false, None, 1) } } impl Cheatcode for expectRevert_3Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { reverter } = self; - expect_revert( - ccx.state, - None, - ccx.ecx.journaled_state.depth(), - false, - false, - Some(*reverter), - 1, - ) + expect_revert(ccx.state, None, ccx.depth(), false, false, Some(*reverter), 1) } } impl Cheatcode for expectRevert_4Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { revertData, reverter } = self; expect_revert( ccx.state, Some(revertData.as_ref()), - ccx.ecx.journaled_state.depth(), + ccx.depth(), false, false, Some(*reverter), @@ -472,79 +533,47 @@ impl Cheatcode for expectRevert_4Call { } impl Cheatcode for expectRevert_5Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { revertData, reverter } = self; - expect_revert( - ccx.state, - Some(revertData), - ccx.ecx.journaled_state.depth(), - false, - false, - Some(*reverter), - 1, - ) + expect_revert(ccx.state, Some(revertData), ccx.depth(), false, false, Some(*reverter), 1) } } impl Cheatcode for expectRevert_6Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { count } = self; - expect_revert(ccx.state, None, ccx.ecx.journaled_state.depth(), false, false, None, *count) + expect_revert(ccx.state, None, ccx.depth(), false, false, None, *count) } } impl Cheatcode for expectRevert_7Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { revertData, count } = self; - expect_revert( - ccx.state, - Some(revertData.as_ref()), - ccx.ecx.journaled_state.depth(), - false, - false, - None, - *count, - ) + expect_revert(ccx.state, Some(revertData.as_ref()), ccx.depth(), false, false, None, *count) } } impl Cheatcode for expectRevert_8Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { revertData, count } = self; - expect_revert( - ccx.state, - Some(revertData), - ccx.ecx.journaled_state.depth(), - false, - false, - None, - *count, - ) + expect_revert(ccx.state, Some(revertData), ccx.depth(), false, false, None, *count) } } impl Cheatcode for expectRevert_9Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { reverter, count } = self; - expect_revert( - ccx.state, - None, - ccx.ecx.journaled_state.depth(), - false, - false, - Some(*reverter), - *count, - ) + expect_revert(ccx.state, None, ccx.depth(), false, false, Some(*reverter), *count) } } impl Cheatcode for expectRevert_10Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { revertData, reverter, count } = self; expect_revert( ccx.state, Some(revertData.as_ref()), - ccx.ecx.journaled_state.depth(), + ccx.depth(), false, false, Some(*reverter), @@ -554,12 +583,12 @@ impl Cheatcode for expectRevert_10Call { } impl Cheatcode for expectRevert_11Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { revertData, reverter, count } = self; expect_revert( ccx.state, Some(revertData), - ccx.ecx.journaled_state.depth(), + ccx.depth(), false, false, Some(*reverter), @@ -569,27 +598,19 @@ impl Cheatcode for expectRevert_11Call { } impl Cheatcode for expectPartialRevert_0Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { revertData } = self; - expect_revert( - ccx.state, - Some(revertData.as_ref()), - ccx.ecx.journaled_state.depth(), - false, - true, - None, - 1, - ) + expect_revert(ccx.state, Some(revertData.as_ref()), ccx.depth(), false, true, None, 1) } } impl Cheatcode for expectPartialRevert_1Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { revertData, reverter } = self; expect_revert( ccx.state, Some(revertData.as_ref()), - ccx.ecx.journaled_state.depth(), + ccx.depth(), false, true, Some(*reverter), @@ -599,60 +620,44 @@ impl Cheatcode for expectPartialRevert_1Call { } impl Cheatcode for _expectCheatcodeRevert_0Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { - expect_revert(ccx.state, None, ccx.ecx.journaled_state.depth(), true, false, None, 1) + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { + expect_revert(ccx.state, None, ccx.depth(), true, false, None, 1) } } impl Cheatcode for _expectCheatcodeRevert_1Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { revertData } = self; - expect_revert( - ccx.state, - Some(revertData.as_ref()), - ccx.ecx.journaled_state.depth(), - true, - false, - None, - 1, - ) + expect_revert(ccx.state, Some(revertData.as_ref()), ccx.depth(), true, false, None, 1) } } impl Cheatcode for _expectCheatcodeRevert_2Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { revertData } = self; - expect_revert( - ccx.state, - Some(revertData), - ccx.ecx.journaled_state.depth(), - true, - false, - None, - 1, - ) + expect_revert(ccx.state, Some(revertData), ccx.depth(), true, false, None, 1) } } impl Cheatcode for expectSafeMemoryCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { min, max } = *self; - expect_safe_memory(ccx.state, min, max, ccx.ecx.journaled_state.depth().try_into()?) + expect_safe_memory(ccx.state, min, max, ccx.depth().try_into()?) } } impl Cheatcode for stopExpectSafeMemoryCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self {} = self; - ccx.state.allowed_mem_writes.remove(&ccx.ecx.journaled_state.depth().try_into()?); + ccx.state.allowed_mem_writes.remove(&ccx.depth().try_into()?); Ok(Default::default()) } } impl Cheatcode for expectSafeMemoryCallCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { min, max } = *self; - expect_safe_memory(ccx.state, min, max, (ccx.ecx.journaled_state.depth() + 1).try_into()?) + expect_safe_memory(ccx.state, min, max, (ccx.depth() + 1).try_into()?) } } @@ -662,7 +667,7 @@ impl RevertParameters for ExpectedRevert { } fn reason(&self) -> Option<&[u8]> { - self.reason.as_deref() + self.reason.as_ref().map(|b| &***b) } fn partial_match(&self) -> bool { @@ -670,6 +675,52 @@ impl RevertParameters for ExpectedRevert { } } +impl ExpectedRevert { + /// Returns whether a call ending at `depth`, at or above the expectation's depth, consumes + /// this expectation. + /// + /// With `internal_expect_revert` enabled, a same-depth revert can satisfy it, but it must not + /// be consumed by external calls that succeed (e.g. calls to non-contract addresses that + /// return `Stop` before Solidity's own revert). + pub(crate) const fn needs_processing( + &self, + cheatcode_call: bool, + call_failed: bool, + depth: usize, + internal_expect_revert: bool, + ) -> bool { + let went_deeper = self.max_depth > self.depth; + match self.kind { + ExpectedRevertKind::Default => { + // Cheatcode reverts propagate up; let the outer frame catch them. + if cheatcode_call { + return false; + } + // Any failure satisfies the expectation. + if call_failed { + return true; + } + // Traditional expectRevert: succeeded external call went deeper. + if !internal_expect_revert && went_deeper { + return true; + } + // Test function returned: catch dangling expectations. + if depth == 0 { + return true; + } + // Same-depth success with internal mode off is an error; with it on, + // keep waiting for the actual revert. + !internal_expect_revert + } + // `pending_processing == true` means we're in the `call_end` hook for + // `vm.expectCheatcodeRevert` and shouldn't expect a revert here. + ExpectedRevertKind::Cheatcode { pending_processing } => { + cheatcode_call && !pending_processing + } + } + } +} + /// Handles expected calls specified by the `expectCall` cheatcodes. /// /// It can handle calls in two ways: @@ -687,13 +738,14 @@ impl RevertParameters for ExpectedRevert { /// address(0xc4f3) and selector `0xd34db33f` to be made at least once. If the amount of calls is /// 0, the test will fail. If the call is made more than once, the test will pass. #[expect(clippy::too_many_arguments)] // It is what it is -fn expect_call( - state: &mut Cheatcodes, +fn expect_call( + state: &mut Cheatcodes, target: &Address, calldata: &Bytes, value: Option<&U256>, mut gas: Option, mut min_gas: Option, + scheme: Option, count: u64, call_type: ExpectedCallType, ) -> Result { @@ -718,19 +770,17 @@ fn expect_call( // Get the expected calls for this target. // In this case, as we're using counted expectCalls, we should not be able to set them // more than once. - ensure!( - !expecteds.contains_key(calldata), - "counted expected calls can only bet set once" - ); + let key = (calldata.clone(), scheme); + ensure!(!expecteds.contains_key(&key), "counted expected calls can only bet set once"); expecteds.insert( - calldata.clone(), + key, (ExpectedCallData { value: value.copied(), gas, min_gas, count, call_type }, 0), ); } ExpectedCallType::NonCount => { // Check if the expected calldata exists. // If it does, increment the count by one as we expect to see it one more time. - match expecteds.entry(calldata.clone()) { + match expecteds.entry((calldata.clone(), scheme)) { Entry::Occupied(mut entry) => { let (expected, _) = entry.get_mut(); // Ensure we're not overwriting a counted expectCall. @@ -754,16 +804,111 @@ fn expect_call( Ok(Default::default()) } -fn expect_emit( - state: &mut Cheatcodes, +/// Counts a call against every matching expectation registered for one target. +pub(crate) fn observe_call( + expected_calls_for_target: &mut ExpectedCallsForTarget, + input: &[u8], + value: Option, + gas_limit: u64, + scheme: CallScheme, +) { + // Match every partial/full calldata. + for ((calldata, expected_scheme), (expected, actual_count)) in expected_calls_for_target { + // Increment actual times seen if all of the following hold. + // The calldata is at most as big as this call's input. + if calldata.len() <= input.len() && + // Both calldata match, taking the length of the assumed smaller one (which will have at least the selector). + input.get(..calldata.len()) == Some(calldata.as_ref()) && + // The value matches, if provided. + expected.value.is_none_or(|expected_value| Some(expected_value) == value) && + // The gas matches, if provided. + expected.gas.is_none_or(|gas| gas == gas_limit) && + // The minimum gas matches, if provided. + expected.min_gas.is_none_or(|min_gas| min_gas <= gas_limit) && + // The call scheme matches, if provided. + expected_scheme.is_none_or(|expected_scheme| expected_scheme == scheme) + { + *actual_count += 1; + } + } +} + +/// Returns the failure message for the smallest unmet call expectation, if any. +/// +/// Expectations are ordered by address, calldata and call scheme. +/// `succeeded` is false for both reverts and halts. +pub(crate) fn first_unmet_call(tracker: &ExpectedCallTracker, succeeded: bool) -> Option { + let (address, calldata, scheme, expected, actual_count) = tracker + .iter() + .flat_map(|(address, calldatas)| { + calldatas.iter().map(move |((calldata, scheme), (expected, actual_count))| { + (address, calldata, scheme, expected, actual_count) + }) + }) + .filter(|(_, _, _, expected, actual_count)| match expected.call_type { + // Counted expectations require exactly the requested number of calls. + ExpectedCallType::Count => expected.count != **actual_count, + // Non-counted expectations require at least the requested number of calls. + ExpectedCallType::NonCount => expected.count > **actual_count, + }) + .min_by_key(|(address, calldata, scheme, ..)| { + (*address, *calldata, call_scheme_rank(**scheme)) + })?; + + let ExpectedCallData { gas, min_gas, value, count, .. } = expected; + let expected_values = [ + Some(format!("data {}", hex::encode_prefixed(calldata))), + value.as_ref().map(|v| format!("value {v}")), + gas.map(|g| format!("gas {g}")), + min_gas.map(|g| format!("minimum gas {g}")), + scheme.map(|scheme| format!("call type {scheme:?}")), + ] + .into_iter() + .flatten() + .join(", "); + let but = if succeeded { + let s = if *actual_count == 1 { "" } else { "s" }; + format!("was called {actual_count} time{s}") + } else { + "the call reverted instead; \ + ensure you're testing the happy path when using `expectCall`" + .to_string() + }; + let s = if *count == 1 { "" } else { "s" }; + Some(format!( + "expected call to {address} with {expected_values} \ + to be called {count} time{s}, but {but}" + )) +} + +const fn call_scheme_rank(scheme: Option) -> u8 { + match scheme { + None => 0, + Some(CallScheme::Call) => 1, + Some(CallScheme::CallCode) => 2, + Some(CallScheme::DelegateCall) => 3, + Some(CallScheme::StaticCall) => 4, + } +} + +fn expect_emit( + state: &mut Cheatcodes, depth: usize, checks: [bool; 5], address: Option
, anonymous: bool, count: u64, ) -> Result { - let expected_emit = - ExpectedEmit { depth, checks, address, found: false, log: None, anonymous, count }; + let expected_emit = ExpectedEmit { + depth, + checks, + address, + found: false, + log: None, + anonymous, + count, + mismatch_error: None, + }; if let Some(found_emit_pos) = state.expected_emits.iter().position(|(emit, _)| emit.found) { // The order of emits already found (back of queue) should not be modified, hence push any // new emit before first found emit. @@ -776,184 +921,27 @@ fn expect_emit( Ok(Default::default()) } -pub(crate) fn handle_expect_emit( - state: &mut Cheatcodes, - log: &alloy_primitives::Log, - interpreter: &mut Interpreter, -) { - // Fill or check the expected emits. - // We expect for emit checks to be filled as they're declared (from oldest to newest), - // so we fill them and push them to the back of the queue. - // If the user has properly filled all the emits, they'll end up in their original order. - // If not, the queue will not be in the order the events will be intended to be filled, - // and we'll be able to later detect this and bail. - - // First, we can return early if all events have been matched. - // This allows a contract to arbitrarily emit more events than expected (additive behavior), - // as long as all the previous events were matched in the order they were expected to be. - if state.expected_emits.iter().all(|(expected, _)| expected.found) { - return; - } - - let should_fill_logs = state.expected_emits.iter().any(|(expected, _)| expected.log.is_none()); - let index_to_fill_or_check = if should_fill_logs { - // If there's anything to fill, we start with the last event to match in the queue - // (without taking into account events already matched). - state - .expected_emits - .iter() - .position(|(emit, _)| emit.found) - .unwrap_or(state.expected_emits.len()) - .saturating_sub(1) - } else { - // Otherwise, if all expected logs are filled, we start to check any unmatched event - // in the declared order, so we start from the front (like a queue). - 0 - }; - - let (mut event_to_fill_or_check, mut count_map) = state - .expected_emits - .remove(index_to_fill_or_check) - .expect("we should have an emit to fill or check"); - - let Some(expected) = &event_to_fill_or_check.log else { - // Unless the caller is trying to match an anonymous event, the first topic must be - // filled. - if event_to_fill_or_check.anonymous || !log.topics().is_empty() { - event_to_fill_or_check.log = Some(log.data.clone()); - // If we only filled the expected log then we put it back at the same position. - state - .expected_emits - .insert(index_to_fill_or_check, (event_to_fill_or_check, count_map)); - } else { - interpreter.bytecode.set_action(InterpreterAction::new_return( - InstructionResult::Revert, - Error::encode("use vm.expectEmitAnonymous to match anonymous events"), - interpreter.gas, - )); - } - return; - }; - - // Increment/set `count` for `log.address` and `log.data` - match count_map.entry(log.address) { - Entry::Occupied(mut entry) => { - // Checks and inserts the log into the map. - // If the log doesn't pass the checks, it is ignored and `count` is not incremented. - let log_count_map = entry.get_mut(); - log_count_map.insert(&log.data); - } - Entry::Vacant(entry) => { - let mut log_count_map = LogCountMap::new(&event_to_fill_or_check); - - if log_count_map.satisfies_checks(&log.data) { - log_count_map.insert(&log.data); - - // Entry is only inserted if it satisfies the checks. - entry.insert(log_count_map); - } - } - } - - event_to_fill_or_check.found = || -> bool { - if !checks_topics_and_data(event_to_fill_or_check.checks, expected, log) { - return false; - } - - // Maybe match source address. - if event_to_fill_or_check.address.is_some_and(|addr| addr != log.address) { - return false; - } - - let expected_count = event_to_fill_or_check.count; - - match event_to_fill_or_check.address { - Some(emitter) => count_map - .get(&emitter) - .is_some_and(|log_map| log_map.count(&log.data) >= expected_count), - None => count_map - .values() - .find(|log_map| log_map.satisfies_checks(&log.data)) - .is_some_and(|map| map.count(&log.data) >= expected_count), - } - }(); - - // If we found the event, we can push it to the back of the queue - // and begin expecting the next event. - if event_to_fill_or_check.found { - state.expected_emits.push_back((event_to_fill_or_check, count_map)); - } else { - // We did not match this event, so we need to keep waiting for the right one to - // appear. - state.expected_emits.push_front((event_to_fill_or_check, count_map)); - } -} - -/// Handles expected emits specified by the `expectEmit` cheatcodes. +/// Applies an `expectEmit` failure for `log` by reverting the current call, when an interpreter +/// is available. /// -/// The second element of the tuple counts the number of times the log has been emitted by a -/// particular address -pub type ExpectedEmitTracker = VecDeque<(ExpectedEmit, AddressHashMap)>; - -#[derive(Clone, Debug, Default)] -pub struct LogCountMap { - checks: [bool; 5], - expected_log: RawLog, - map: HashMap, +/// Without an interpreter, the failure reason is returned to the caller instead. +pub(crate) fn handle_expect_emit( + state: &mut Cheatcodes, + log: &Log, + interpreter: Option<&mut Interpreter>, +) -> Option<&'static str> { + let reason = observe_log(&mut state.expected_emits, log)?; + let Some(interpreter) = interpreter else { return Some(reason) }; + interpreter.bytecode.set_action(InterpreterAction::new_return( + InstructionResult::Revert, + Error::encode(reason), + interpreter.gas, + )); + None } -impl LogCountMap { - /// Instantiates `LogCountMap`. - fn new(expected_emit: &ExpectedEmit) -> Self { - Self { - checks: expected_emit.checks, - expected_log: expected_emit.log.clone().expect("log should be filled here"), - map: Default::default(), - } - } - - /// Inserts a log into the map and increments the count. - /// - /// The log must pass all checks against the expected log for the count to increment. - /// - /// Returns true if the log was inserted and count was incremented. - fn insert(&mut self, log: &RawLog) -> bool { - // If its already in the map, increment the count without checking. - if self.map.contains_key(log) { - self.map.entry(log.clone()).and_modify(|c| *c += 1); - - return true; - } - - if !self.satisfies_checks(log) { - return false; - } - - self.map.entry(log.clone()).and_modify(|c| *c += 1).or_insert(1); - - true - } - - /// Checks the incoming raw log against the expected logs topics and data. - fn satisfies_checks(&self, log: &RawLog) -> bool { - checks_topics_and_data(self.checks, &self.expected_log, log) - } - - pub fn count(&self, log: &RawLog) -> u64 { - if !self.satisfies_checks(log) { - return 0; - } - - self.count_unchecked() - } - - pub fn count_unchecked(&self) -> u64 { - self.map.values().sum() - } -} - -fn expect_create( - state: &mut Cheatcodes, +fn expect_create( + state: &mut Cheatcodes, bytecode: Bytes, deployer: Address, create_scheme: CreateScheme, @@ -964,8 +952,8 @@ fn expect_create( Ok(Default::default()) } -fn expect_revert( - state: &mut Cheatcodes, +fn expect_revert( + state: &mut Cheatcodes, reason: Option<&[u8]>, depth: usize, cheatcode: bool, @@ -978,7 +966,7 @@ fn expect_revert( "you must call another function prior to expecting a second revert" ); state.expected_revert = Some(ExpectedRevert { - reason: reason.map(<[_]>::to_vec), + reason: reason.map(Bytes::copy_from_slice), depth, kind: if cheatcode { ExpectedRevertKind::Cheatcode { pending_processing: true } @@ -995,34 +983,316 @@ fn expect_revert( Ok(Default::default()) } -fn checks_topics_and_data(checks: [bool; 5], expected: &RawLog, log: &RawLog) -> bool { - if log.topics().len() != expected.topics().len() { - return false; +fn expect_safe_memory( + state: &mut Cheatcodes, + start: u64, + end: u64, + depth: u64, +) -> Result { + ensure!(start < end, "memory range start ({start}) is greater than end ({end})"); + #[expect(clippy::single_range_in_vec_init)] // Wanted behaviour + let offsets = state.allowed_mem_writes.entry(depth).or_insert_with(|| vec![0..0x60]); + offsets.push(start..end); + Ok(Default::default()) +} + +/// Removes the first create expectation matched by a completed create. +/// +/// `create_scheme` is only called for expectations with a matching deployer. +pub(crate) fn observe_create( + expected_creates: &mut Vec, + deployer: Address, + create_scheme: impl Fn() -> CreateScheme, + bytecode: &Bytes, +) { + if let Some((index, _)) = expected_creates.iter().find_position(|expected_create| { + expected_create.deployer == deployer + && expected_create.create_scheme.eq(create_scheme()) + && expected_create.bytecode == *bytecode + }) { + expected_creates.swap_remove(index); + } +} + +/// Returns the failure message for the first unmet create expectation, if any. +pub(crate) fn first_unmet_create(expected_creates: &[ExpectedCreate]) -> Option { + let expected_create = expected_creates.first()?; + Some(format!( + "expected {} call by address {} for bytecode {} but not found", + expected_create.create_scheme, + hex::encode_prefixed(expected_create.deployer), + hex::encode_prefixed(&expected_create.bytecode), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::{address, bytes}; + + const TARGET: Address = address!("0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f"); + + fn expected( + value: Option, + gas: Option, + min_gas: Option, + count: u64, + call_type: ExpectedCallType, + ) -> ExpectedCallData { + ExpectedCallData { value, gas, min_gas, count, call_type } } - // Check topics. - if !log - .topics() - .iter() - .enumerate() - .filter(|(i, _)| checks[*i]) - .all(|(i, topic)| topic == &expected.topics()[i]) - { - return false; + fn tracker( + calldata: Bytes, + scheme: Option, + data: ExpectedCallData, + ) -> ExpectedCallTracker { + let mut tracker = ExpectedCallTracker::default(); + tracker.entry(TARGET).or_default().insert((calldata, scheme), (data, 0)); + tracker } - // Check data - if checks[4] && expected.data.as_ref() != log.data.as_ref() { - return false; + fn seen(tracker: &ExpectedCallTracker, calldata: &Bytes, scheme: Option) -> u64 { + tracker[&TARGET][&(calldata.clone(), scheme)].1 } - true -} + fn observe(tracker: &mut ExpectedCallTracker, input: &[u8], value: Option, gas: u64) { + observe_call(tracker.get_mut(&TARGET).unwrap(), input, value, gas, CallScheme::Call); + } -fn expect_safe_memory(state: &mut Cheatcodes, start: u64, end: u64, depth: u64) -> Result { - ensure!(start < end, "memory range start ({start}) is greater than end ({end})"); - #[expect(clippy::single_range_in_vec_init)] // Wanted behaviour - let offsets = state.allowed_mem_writes.entry(depth).or_insert_with(|| vec![0..0x60]); - offsets.push(start..end); - Ok(Default::default()) + #[test] + fn observe_call_matches_calldata_prefix() { + let selector = bytes!("771602f7"); + let full = bytes!("771602f7aabb"); + let mut t = tracker( + selector.clone(), + None, + expected(None, None, None, 1, ExpectedCallType::NonCount), + ); + t.entry(TARGET).or_default().insert( + (full.clone(), None), + (expected(None, None, None, 1, ExpectedCallType::NonCount), 0), + ); + + observe(&mut t, &full, None, 0); + observe(&mut t, &selector, None, 0); + observe(&mut t, &bytes!("12345678"), None, 0); + + assert_eq!(seen(&t, &selector, None), 2); + assert_eq!(seen(&t, &full, None), 1); + } + + #[test] + fn observe_call_filters_value() { + let calldata = bytes!("c290d691"); + let mut t = tracker( + calldata.clone(), + None, + expected(Some(U256::from(1)), None, None, 1, ExpectedCallType::NonCount), + ); + + observe(&mut t, &calldata, Some(U256::from(2)), 0); + observe(&mut t, &calldata, None, 0); + assert_eq!(seen(&t, &calldata, None), 0); + + observe(&mut t, &calldata, Some(U256::from(1)), 0); + assert_eq!(seen(&t, &calldata, None), 1); + + // An expected zero value does not match a call without a transfer value. + let mut zero = tracker( + calldata.clone(), + None, + expected(Some(U256::ZERO), None, None, 1, ExpectedCallType::NonCount), + ); + observe(&mut zero, &calldata, None, 0); + assert_eq!(seen(&zero, &calldata, None), 0); + observe(&mut zero, &calldata, Some(U256::ZERO), 0); + assert_eq!(seen(&zero, &calldata, None), 1); + + // No expected value matches any transfer value. + let mut any = tracker( + calldata.clone(), + None, + expected(None, None, None, 1, ExpectedCallType::NonCount), + ); + observe(&mut any, &calldata, Some(U256::from(5)), 0); + assert_eq!(seen(&any, &calldata, None), 1); + } + + #[test] + fn observe_call_filters_gas_and_min_gas() { + let calldata = bytes!("771602f7"); + let mut gas = tracker( + calldata.clone(), + None, + expected(None, Some(25_000), None, 1, ExpectedCallType::NonCount), + ); + observe(&mut gas, &calldata, None, 24_999); + observe(&mut gas, &calldata, None, 25_001); + assert_eq!(seen(&gas, &calldata, None), 0); + observe(&mut gas, &calldata, None, 25_000); + assert_eq!(seen(&gas, &calldata, None), 1); + + let mut min_gas = tracker( + calldata.clone(), + None, + expected(None, None, Some(50_000), 1, ExpectedCallType::NonCount), + ); + observe(&mut min_gas, &calldata, None, 49_999); + assert_eq!(seen(&min_gas, &calldata, None), 0); + observe(&mut min_gas, &calldata, None, 50_000); + observe(&mut min_gas, &calldata, None, 60_000); + assert_eq!(seen(&min_gas, &calldata, None), 2); + } + + #[test] + fn observe_call_filters_scheme() { + let calldata = bytes!("771602f7"); + let mut t = tracker( + calldata.clone(), + Some(CallScheme::DelegateCall), + expected(None, None, None, 1, ExpectedCallType::NonCount), + ); + + observe_call(t.get_mut(&TARGET).unwrap(), &calldata, None, 0, CallScheme::Call); + assert_eq!(seen(&t, &calldata, Some(CallScheme::DelegateCall)), 0); + + observe_call(t.get_mut(&TARGET).unwrap(), &calldata, None, 0, CallScheme::DelegateCall); + assert_eq!(seen(&t, &calldata, Some(CallScheme::DelegateCall)), 1); + } + + #[test] + fn first_unmet_call_compares_counts() { + let calldata = bytes!("771602f7"); + let mut count = + tracker(calldata.clone(), None, expected(None, None, None, 2, ExpectedCallType::Count)); + let mut non_count = + tracker(calldata, None, expected(None, None, None, 2, ExpectedCallType::NonCount)); + + for seen in [1, 3] { + count.get_mut(&TARGET).unwrap().values_mut().next().unwrap().1 = seen; + assert!(first_unmet_call(&count, true).is_some(), "count with {seen} calls"); + } + count.get_mut(&TARGET).unwrap().values_mut().next().unwrap().1 = 2; + assert_eq!(first_unmet_call(&count, true), None); + + non_count.get_mut(&TARGET).unwrap().values_mut().next().unwrap().1 = 1; + assert!(first_unmet_call(&non_count, true).is_some()); + for seen in [2, 3] { + non_count.get_mut(&TARGET).unwrap().values_mut().next().unwrap().1 = seen; + assert_eq!(first_unmet_call(&non_count, true), None, "non-count with {seen} calls"); + } + } + + #[test] + fn first_unmet_call_reports_smallest_unmet_key() { + let low = address!("0000000000000000000000000000000000000001"); + let high = address!("0000000000000000000000000000000000000002"); + let ordered = [ + (low, bytes!("01"), None, ""), + (low, bytes!("01"), Some(CallScheme::Call), ", call type Call"), + (low, bytes!("01"), Some(CallScheme::CallCode), ", call type CallCode"), + (low, bytes!("01"), Some(CallScheme::DelegateCall), ", call type DelegateCall"), + (low, bytes!("01"), Some(CallScheme::StaticCall), ", call type StaticCall"), + (low, bytes!("02"), None, ""), + (high, bytes!("00"), None, ""), + (high, bytes!("01"), None, ""), + ]; + + for offset in 0..ordered.len() { + let mut t = ExpectedCallTracker::default(); + // A satisfied expectation must not hide a later failure. + t.entry(Address::ZERO).or_default().insert( + (Bytes::new(), None), + (expected(None, None, None, 1, ExpectedCallType::NonCount), 1), + ); + for index in (0..ordered.len()).rev() { + let (address, calldata, scheme, _) = &ordered[(index + offset) % ordered.len()]; + t.entry(*address).or_default().insert( + (calldata.clone(), *scheme), + (expected(None, None, None, 1, ExpectedCallType::NonCount), 0), + ); + } + + for (address, calldata, scheme, suffix) in &ordered { + assert_eq!( + first_unmet_call(&t, true).unwrap(), + format!( + "expected call to {address} with data {}{suffix} to be called 1 time, \ + but was called 0 times", + hex::encode_prefixed(calldata), + ), + ); + t.get_mut(address).unwrap().remove(&(calldata.clone(), *scheme)); + } + assert_eq!(first_unmet_call(&t, true), None); + } + } + + #[test] + fn first_unmet_call_message() { + let calldata = bytes!("771602f7"); + let mut t = tracker( + calldata.clone(), + Some(CallScheme::DelegateCall), + expected(Some(U256::from(1)), Some(2), Some(3), 2, ExpectedCallType::Count), + ); + t.get_mut(&TARGET).unwrap().values_mut().next().unwrap().1 = 1; + + assert_eq!( + first_unmet_call(&t, true).unwrap(), + "expected call to 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f with data 0x771602f7, \ + value 1, gas 2, minimum gas 3, call type DelegateCall to be called 2 times, \ + but was called 1 time" + ); + assert_eq!( + first_unmet_call(&t, false).unwrap(), + "expected call to 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f with data 0x771602f7, \ + value 1, gas 2, minimum gas 3, call type DelegateCall to be called 2 times, \ + but the call reverted instead; ensure you're testing the happy path when using \ + `expectCall`" + ); + + let single = + tracker(calldata, None, expected(None, None, None, 1, ExpectedCallType::NonCount)); + assert_eq!( + first_unmet_call(&single, true).unwrap(), + "expected call to 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f with data 0x771602f7 \ + to be called 1 time, but was called 0 times" + ); + } + + #[test] + fn observe_create_converts_the_scheme_only_for_a_matching_deployer() { + let bytecode = bytes!("6080"); + let mut expected_creates = vec![ExpectedCreate { + deployer: Address::ZERO, + bytecode: bytecode.clone(), + create_scheme: CreateScheme::Create, + }]; + + observe_create(&mut expected_creates, TARGET, || unreachable!(), &bytecode); + assert_eq!(expected_creates.len(), 1); + + observe_create(&mut expected_creates, Address::ZERO, || CreateScheme::Create, &bytecode); + assert!(expected_creates.is_empty()); + } + + #[test] + fn internal_expect_revert_waits_for_failure_before_root() { + let expected_revert = ExpectedRevert { + reason: None, + depth: 1, + kind: ExpectedRevertKind::Default, + partial_match: false, + reverter: None, + reverted_by: None, + max_depth: 1, + count: 1, + actual_count: 0, + }; + + assert!(expected_revert.needs_processing(false, true, 1, true)); + assert!(!expected_revert.needs_processing(false, false, 1, true)); + } } diff --git a/crates/cheatcodes/src/test/revert_handlers.rs b/crates/cheatcodes/src/test/revert_handlers.rs index 99dcc069d6825..ededa3720a146 100644 --- a/crates/cheatcodes/src/test/revert_handlers.rs +++ b/crates/cheatcodes/src/test/revert_handlers.rs @@ -1,9 +1,9 @@ use crate::{Error, Result}; -use alloy_primitives::{Address, Bytes, address, hex}; +use alloy_primitives::{Address, Bytes, hex}; use alloy_sol_types::{SolError, SolValue}; use foundry_common::ContractsByArtifact; use foundry_evm_core::decode::RevertDecoder; -use revm::interpreter::{InstructionResult, return_ok}; +use revm::interpreter::InstructionResult; use spec::Vm; use super::{ @@ -20,17 +20,7 @@ use super::{ static DUMMY_CALL_OUTPUT: Bytes = Bytes::from_static(&[0u8; 8192]); /// Same reasoning as [DUMMY_CALL_OUTPUT], but for creates. -const DUMMY_CREATE_ADDRESS: Address = address!("0x0000000000000000000000000000000000000001"); - -fn stringify(data: &[u8]) -> String { - if let Ok(s) = String::abi_decode(data) { - return s; - } - if data.is_ascii() { - return std::str::from_utf8(data).unwrap().to_owned(); - } - hex::encode_prefixed(data) -} +const DUMMY_CREATE_ADDRESS: Address = Address::with_last_byte(1); /// Common parameters for expected or assumed reverts. Allows for code reuse. pub(crate) trait RevertParameters { @@ -79,41 +69,60 @@ fn handle_revert( return Ok(()); }; - if !expected_reason.is_empty() && retdata.is_empty() { - bail!("call reverted as expected, but without data"); - } - - let mut actual_revert: Vec = retdata.to_vec(); + let actual_revert = if retdata.is_empty() && !expected_reason.is_empty() { + if status == InstructionResult::Revert { + bail!("call reverted as expected, but without data"); + } + RevertDecoder::new().decode(retdata, Some(status)).into_bytes() + } else { + retdata.to_vec() + }; // Compare only the first 4 bytes if partial match. - if revert_params.partial_match() && actual_revert.get(..4) == expected_reason.get(..4) { + if revert_params.partial_match() + && let (Some(actual_prefix), Some(expected_prefix)) = + (actual_revert.get(..4), expected_reason.get(..4)) + && actual_prefix == expected_prefix + { + return Ok(()); + } + + // Compare complete payloads before decoding, which may ignore trailing ABI data. + if actual_revert == expected_reason { return Ok(()); } - // Try decoding as known errors. - actual_revert = decode_revert(actual_revert); + // Unwrap string errors for legacy raw-message matching. + let actual_reason = decode_revert(actual_revert); - if actual_revert == expected_reason - || (is_cheatcode && memchr::memmem::find(&actual_revert, expected_reason).is_some()) + if actual_reason == expected_reason + || (is_cheatcode && memchr::memmem::find(&actual_reason, expected_reason).is_some()) { - Ok(()) - } else { - let (actual, expected) = if let Some(contracts) = known_contracts { - let decoder = RevertDecoder::new().with_abis(contracts.values().map(|c| &c.abi)); - ( - &decoder.decode(actual_revert.as_slice(), Some(status)), - &decoder.decode(expected_reason, Some(status)), - ) - } else { - (&stringify(&actual_revert), &stringify(expected_reason)) - }; + return Ok(()); + } - if expected == actual { - return Ok(()); - } + let (actual, expected) = if let Some(contracts) = known_contracts { + let decoder = RevertDecoder::new().with_abis(contracts.values().map(|c| &c.abi)); + ( + &decoder.decode(actual_reason.as_slice(), Some(status)), + &decoder.decode(expected_reason, Some(status)), + ) + } else { + (&stringify(&actual_reason), &stringify(expected_reason)) + }; - Err(fmt_err!("Error != expected error: {} != {}", actual, expected)) + // Lossy decoding can render distinct payloads identically; append the raw data to + // disambiguate. Note that `retdata` is the original revert data, unlike `actual_reason`, + // which may have been synthesized from the status or unwrapped by `decode_revert`. + if expected == actual { + return Err(fmt_err!( + "Error != expected error: {actual} (raw {}) != {expected} (raw {})", + hex::encode_prefixed(retdata), + hex::encode_prefixed(expected_reason) + )); } + + Err(fmt_err!("Error != expected error: {} != {}", actual, expected)) } pub(crate) fn handle_assume_no_revert( @@ -173,10 +182,7 @@ pub(crate) fn handle_expect_revert( if expected_revert.count == 0 { // If no specific reason or reverter is expected, we just check if it reverted if expected_revert.reverter.is_none() && expected_revert.reason.is_none() { - ensure!( - matches!(status, return_ok!()), - "call reverted when it was expected not to revert" - ); + ensure!(status.is_ok(), "call reverted when it was expected not to revert"); return Ok(success_return()); } @@ -186,7 +192,10 @@ pub(crate) fn handle_expect_revert( // If we expect no reverts with a specific reason/reverter, but got a revert, // we need to check if it matches our criteria - if !matches!(status, return_ok!()) { + if status.is_ok() { + // No revert occurred, which is what we expected + Ok(success_return()) + } else { // We got a revert, but we expected 0 reverts // We need to check if this revert matches our expected criteria @@ -199,7 +208,7 @@ pub(crate) fn handle_expect_revert( } // Reason check - let expected_reason = expected_revert.reason.as_deref(); + let expected_reason = expected_revert.reason(); if let Some(expected_reason) = expected_reason { let mut actual_revert: Vec = retdata.to_vec(); actual_revert = decode_revert(actual_revert); @@ -212,12 +221,12 @@ pub(crate) fn handle_expect_revert( match (reason_match, reverter_match) { (Some(true), Some(true)) => Err(fmt_err!( "expected 0 reverts with reason: {}, from address: {}, but got one", - &stringify(expected_reason.unwrap_or_default()), + stringify(expected_reason.unwrap_or_default()), expected_revert.reverter.unwrap() )), (Some(true), None) => Err(fmt_err!( "expected 0 reverts with reason: {}, but got one", - &stringify(expected_reason.unwrap_or_default()) + stringify(expected_reason.unwrap_or_default()) )), (None, Some(true)) => Err(fmt_err!( "expected 0 reverts from address: {}, but got one", @@ -229,35 +238,34 @@ pub(crate) fn handle_expect_revert( let decoded_revert = decode_revert(retdata.to_vec()); // Provide more specific error messages based on what was expected - if expected_revert.reverter.is_some() && expected_revert.reason.is_some() { - Err(fmt_err!( - "call reverted with '{}' from {}, but expected 0 reverts with reason '{}' from {}", - &stringify(&decoded_revert), - expected_revert.reverted_by.unwrap_or_default(), - &stringify(expected_reason.unwrap_or_default()), - expected_revert.reverter.unwrap() - )) - } else if expected_revert.reverter.is_some() { - Err(fmt_err!( - "call reverted with '{}' from {}, but expected 0 reverts from {}", - &stringify(&decoded_revert), - expected_revert.reverted_by.unwrap_or_default(), - expected_revert.reverter.unwrap() - )) + if let Some(reverter) = expected_revert.reverter { + if expected_revert.reason.is_some() { + Err(fmt_err!( + "call reverted with '{}' from {}, but expected 0 reverts with reason '{}' from {}", + stringify(&decoded_revert), + expected_revert.reverted_by.unwrap_or_default(), + stringify(expected_reason.unwrap_or_default()), + reverter + )) + } else { + Err(fmt_err!( + "call reverted with '{}' from {}, but expected 0 reverts from {}", + stringify(&decoded_revert), + expected_revert.reverted_by.unwrap_or_default(), + reverter + )) + } } else { Err(fmt_err!( "call reverted with '{}' when it was expected not to revert", - &stringify(&decoded_revert) + stringify(&decoded_revert) )) } } } - } else { - // No revert occurred, which is what we expected - Ok(success_return()) } } else { - ensure!(!matches!(status, return_ok!()), "next call did not revert as expected"); + ensure!(!status.is_ok(), "next call did not revert as expected"); handle_revert( is_cheatcode, @@ -271,6 +279,16 @@ pub(crate) fn handle_expect_revert( } } +fn stringify(data: &[u8]) -> String { + if let Ok(s) = String::abi_decode(data) { + return s; + } + if data.is_ascii() { + return std::str::from_utf8(data).unwrap().to_owned(); + } + hex::encode_prefixed(data) +} + fn decode_revert(revert: Vec) -> Vec { if matches!( revert.get(..4).map(|s| s.try_into().unwrap()), diff --git a/crates/cheatcodes/src/toml.rs b/crates/cheatcodes/src/toml.rs index 4ae16340ec6be..85140a2742a11 100644 --- a/crates/cheatcodes/src/toml.rs +++ b/crates/cheatcodes/src/toml.rs @@ -4,167 +4,159 @@ use crate::{ Cheatcode, Cheatcodes, Result, Vm::*, json::{ - canonicalize_json_path, check_json_key_exists, parse_json, parse_json_coerce, - parse_json_keys, resolve_type, + check_json_key_exists, parse_json, parse_json_coerce, parse_json_coerce_default, + parse_json_keys, resolve_type, split_value_key, }, }; use alloy_dyn_abi::DynSolType; use alloy_sol_types::SolValue; -use foundry_common::fs; +use foundry_common::{fmt::StructDefinitions, fs}; use foundry_config::fs_permissions::FsAccessKind; +use foundry_evm_core::evm::FoundryEvmNetwork; use serde_json::Value as JsonValue; use toml::Value as TomlValue; +use toml_edit::{DocumentMut, Item, Table, TableLike}; impl Cheatcode for keyExistsTomlCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { toml, key } = self; check_json_key_exists(&toml_to_json_string(toml)?, key) } } impl Cheatcode for parseToml_0Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { toml } = self; - parse_toml(toml, "$") + parse_toml( + toml, + "$", + state.analysis.as_ref().and_then(|analysis| analysis.struct_defs().ok()), + ) } } impl Cheatcode for parseToml_1Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { toml, key } = self; - parse_toml(toml, key) + parse_toml( + toml, + key, + state.analysis.as_ref().and_then(|analysis| analysis.struct_defs().ok()), + ) } } -impl Cheatcode for parseTomlUintCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { - let Self { toml, key } = self; - parse_toml_coerce(toml, key, &DynSolType::Uint(256)) - } -} - -impl Cheatcode for parseTomlUintArrayCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { - let Self { toml, key } = self; - parse_toml_coerce(toml, key, &DynSolType::Array(Box::new(DynSolType::Uint(256)))) - } -} - -impl Cheatcode for parseTomlIntCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { - let Self { toml, key } = self; - parse_toml_coerce(toml, key, &DynSolType::Int(256)) - } -} - -impl Cheatcode for parseTomlIntArrayCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { - let Self { toml, key } = self; - parse_toml_coerce(toml, key, &DynSolType::Array(Box::new(DynSolType::Int(256)))) - } -} - -impl Cheatcode for parseTomlBoolCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { - let Self { toml, key } = self; - parse_toml_coerce(toml, key, &DynSolType::Bool) - } -} - -impl Cheatcode for parseTomlBoolArrayCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { - let Self { toml, key } = self; - parse_toml_coerce(toml, key, &DynSolType::Array(Box::new(DynSolType::Bool))) - } -} - -impl Cheatcode for parseTomlAddressCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { - let Self { toml, key } = self; - parse_toml_coerce(toml, key, &DynSolType::Address) - } -} - -impl Cheatcode for parseTomlAddressArrayCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { - let Self { toml, key } = self; - parse_toml_coerce(toml, key, &DynSolType::Array(Box::new(DynSolType::Address))) - } -} - -impl Cheatcode for parseTomlStringCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { - let Self { toml, key } = self; - parse_toml_coerce(toml, key, &DynSolType::String) - } -} - -impl Cheatcode for parseTomlStringArrayCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { - let Self { toml, key } = self; - parse_toml_coerce(toml, key, &DynSolType::Array(Box::new(DynSolType::String))) - } -} - -impl Cheatcode for parseTomlBytesCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { - let Self { toml, key } = self; - parse_toml_coerce(toml, key, &DynSolType::Bytes) - } -} - -impl Cheatcode for parseTomlBytesArrayCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { - let Self { toml, key } = self; - parse_toml_coerce(toml, key, &DynSolType::Array(Box::new(DynSolType::Bytes))) - } -} +macro_rules! impl_parse_toml { + ($call:ident, $call_with_default:ident, $ty:expr) => { + impl Cheatcode for $call { + fn apply(&self, _state: &mut Cheatcodes) -> Result { + let Self { toml, key } = self; + parse_toml_coerce(toml, key, &$ty) + } + } -impl Cheatcode for parseTomlBytes32Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { - let Self { toml, key } = self; - parse_toml_coerce(toml, key, &DynSolType::FixedBytes(32)) - } + impl Cheatcode for $call_with_default { + fn apply(&self, _state: &mut Cheatcodes) -> Result { + let Self { toml, key, defaultValue } = self; + parse_toml_coerce_default(toml, key, &$ty, defaultValue) + } + } + }; } -impl Cheatcode for parseTomlBytes32ArrayCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { - let Self { toml, key } = self; - parse_toml_coerce(toml, key, &DynSolType::Array(Box::new(DynSolType::FixedBytes(32)))) - } -} +impl_parse_toml!(parseTomlUint_0Call, parseTomlUint_1Call, DynSolType::Uint(256)); +impl_parse_toml!( + parseTomlUintArray_0Call, + parseTomlUintArray_1Call, + DynSolType::Array(Box::new(DynSolType::Uint(256))) +); +impl_parse_toml!(parseTomlInt_0Call, parseTomlInt_1Call, DynSolType::Int(256)); +impl_parse_toml!( + parseTomlIntArray_0Call, + parseTomlIntArray_1Call, + DynSolType::Array(Box::new(DynSolType::Int(256))) +); +impl_parse_toml!(parseTomlBool_0Call, parseTomlBool_1Call, DynSolType::Bool); +impl_parse_toml!( + parseTomlBoolArray_0Call, + parseTomlBoolArray_1Call, + DynSolType::Array(Box::new(DynSolType::Bool)) +); +impl_parse_toml!(parseTomlAddress_0Call, parseTomlAddress_1Call, DynSolType::Address); +impl_parse_toml!( + parseTomlAddressArray_0Call, + parseTomlAddressArray_1Call, + DynSolType::Array(Box::new(DynSolType::Address)) +); +impl_parse_toml!(parseTomlString_0Call, parseTomlString_1Call, DynSolType::String); +impl_parse_toml!( + parseTomlStringArray_0Call, + parseTomlStringArray_1Call, + DynSolType::Array(Box::new(DynSolType::String)) +); +impl_parse_toml!(parseTomlBytes_0Call, parseTomlBytes_1Call, DynSolType::Bytes); +impl_parse_toml!( + parseTomlBytesArray_0Call, + parseTomlBytesArray_1Call, + DynSolType::Array(Box::new(DynSolType::Bytes)) +); +impl_parse_toml!(parseTomlBytes32_0Call, parseTomlBytes32_1Call, DynSolType::FixedBytes(32)); +impl_parse_toml!( + parseTomlBytes32Array_0Call, + parseTomlBytes32Array_1Call, + DynSolType::Array(Box::new(DynSolType::FixedBytes(32))) +); impl Cheatcode for parseTomlType_0Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { toml, typeDescription } = self; - parse_toml_coerce(toml, "$", &resolve_type(typeDescription)?).map(|v| v.abi_encode()) + parse_toml_coerce( + toml, + "$", + &resolve_type( + typeDescription, + state.analysis.as_ref().and_then(|analysis| analysis.struct_defs().ok()), + )?, + ) + .map(|v| v.abi_encode()) } } impl Cheatcode for parseTomlType_1Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { toml, key, typeDescription } = self; - parse_toml_coerce(toml, key, &resolve_type(typeDescription)?).map(|v| v.abi_encode()) + parse_toml_coerce( + toml, + key, + &resolve_type( + typeDescription, + state.analysis.as_ref().and_then(|analysis| analysis.struct_defs().ok()), + )?, + ) + .map(|v| v.abi_encode()) } } impl Cheatcode for parseTomlTypeArrayCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { toml, key, typeDescription } = self; - let ty = resolve_type(typeDescription)?; + let ty = resolve_type( + typeDescription, + state.analysis.as_ref().and_then(|analysis| analysis.struct_defs().ok()), + )?; parse_toml_coerce(toml, key, &DynSolType::Array(Box::new(ty))).map(|v| v.abi_encode()) } } impl Cheatcode for parseTomlKeysCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { toml, key } = self; parse_toml_keys(toml, key) } } impl Cheatcode for writeToml_0Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { json, path } = self; let value = serde_json::from_str(json).unwrap_or_else(|_| JsonValue::String(json.to_owned())); @@ -175,22 +167,20 @@ impl Cheatcode for writeToml_0Call { } impl Cheatcode for writeToml_1Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { - let Self { json, path, valueKey } = self; - let json = - serde_json::from_str(json).unwrap_or_else(|_| JsonValue::String(json.to_owned())); + fn apply(&self, state: &mut Cheatcodes) -> Result { + let Self { json: value, path, valueKey } = self; + // Read and parse the TOML file, keeping its formatting and comments. + // If the file doesn't exist, start with an empty document so the file is created. let data_path = state.config.ensure_path_allowed(path, FsAccessKind::Read)?; - let toml_data = fs::read_to_string(data_path)?; - let json_data: JsonValue = - toml::from_str(&toml_data).map_err(|e| fmt_err!("failed parsing TOML: {e}"))?; - let value = - jsonpath_lib::replace_with(json_data, &canonicalize_json_path(valueKey), &mut |_| { - Some(json.clone()) - })?; - - let toml_string = format_json_to_toml(value)?; - super::fs::write_file(state, path.as_ref(), toml_string.as_bytes()) + let mut document = if data_path.exists() { + parse_toml_document(&fs::locked_read_to_string(&data_path)?)? + } else { + DocumentMut::new() + }; + upsert_toml_value(&mut document, value, valueKey)?; + + super::fs::write_file(state, path.as_ref(), document.to_string().as_bytes()) } } @@ -200,8 +190,8 @@ fn parse_toml_str(toml: &str) -> Result { } /// Parse a TOML string and return the value at the given path. -fn parse_toml(toml: &str, key: &str) -> Result { - parse_json(&toml_to_json_string(toml)?, key) +fn parse_toml(toml: &str, key: &str, struct_defs: Option<&StructDefinitions>) -> Result { + parse_json(&toml_to_json_string(toml)?, key, struct_defs) } /// Parse a TOML string and return the value at the given path, coercing it to the given type. @@ -209,6 +199,17 @@ fn parse_toml_coerce(toml: &str, key: &str, ty: &DynSolType) -> Result { parse_json_coerce(&toml_to_json_string(toml)?, key, ty) } +/// Parse a TOML string and return the value at the given path, coercing it to the given type, or +/// return the default if the path does not exist. +fn parse_toml_coerce_default( + toml: &str, + key: &str, + ty: &DynSolType, + default: &T, +) -> Result { + parse_json_coerce_default(&toml_to_json_string(toml)?, key, ty, default) +} + /// Parse a TOML string and return an array of all keys at the given path. fn parse_toml_keys(toml: &str, key: &str) -> Result { parse_json_keys(&toml_to_json_string(toml)?, key) @@ -228,14 +229,17 @@ fn format_json_to_toml(json: JsonValue) -> Result { } /// Convert a TOML value to a JSON value. -fn toml_to_json_value(toml: TomlValue) -> JsonValue { +pub(super) fn toml_to_json_value(toml: TomlValue) -> JsonValue { match toml { TomlValue::String(s) => match s.as_str() { "null" => JsonValue::Null, _ => JsonValue::String(s), }, TomlValue::Integer(i) => JsonValue::Number(i.into()), - TomlValue::Float(f) => JsonValue::Number(serde_json::Number::from_f64(f).unwrap()), + TomlValue::Float(f) => match serde_json::Number::from_f64(f) { + Some(n) => JsonValue::Number(n), + None => JsonValue::String(f.to_string()), + }, TomlValue::Boolean(b) => JsonValue::Bool(b), TomlValue::Array(a) => JsonValue::Array(a.into_iter().map(toml_to_json_value).collect()), TomlValue::Table(t) => { @@ -264,3 +268,314 @@ fn json_to_toml_value(json: JsonValue) -> TomlValue { JsonValue::Null => TomlValue::String("null".to_string()), } } + +/// Parses a TOML string into a document that keeps its formatting and comments. +fn parse_toml_document(toml: &str) -> Result { + toml.parse().map_err(|e| fmt_err!("failed parsing TOML: {e}")) +} + +/// Inserts or replaces the value at `key` in a TOML document, creating intermediate tables if +/// necessary. +/// +/// Only the item at `key` is rewritten, so comments and formatting elsewhere in the document are +/// kept. +fn upsert_toml_value(document: &mut DocumentMut, value: &str, key: &str) -> Result<()> { + let parts = split_value_key(key)?; + + // Separate the final key from the path. + // Traverse the tables, creating implicit intermediary ones if necessary. + if let Some((key_to_insert, path_to_parent)) = parts.split_last() { + let mut current_level = document.as_item_mut(); + + for segment in path_to_parent { + let is_inline = current_level.is_inline_table(); + let Some(table) = current_level.as_table_like_mut() else { + return Err(fmt_err!("path segment '{segment}' does not resolve to an object.")); + }; + if !table.contains_key(segment) { + let mut intermediary = Table::new(); + intermediary.set_implicit(true); + insert_toml_item(table, is_inline, segment, Item::Table(intermediary)); + } + current_level = table.get_mut(segment).unwrap(); + } + + let is_inline = current_level.is_inline_table(); + let Some(parent) = current_level.as_table_like_mut() else { + return Err(fmt_err!("final destination is not an object, cannot insert key.")); + }; + + let value = + serde_json::from_str(value).unwrap_or_else(|_| JsonValue::String(value.to_owned())); + let mut item = json_to_toml_item(value)?; + + // Replace an existing item in place: `insert` would reset the key's formatting, which holds + // the comments above it. + match parent.get_mut(key_to_insert) { + Some(existing) if !existing.is_none() => { + if existing.is_value() { + // Keep inline values inline instead of turning them into table sections. + item = item.into_value().map_or_else(|item| item, Item::Value); + } + let shape_changed = + std::mem::discriminant(existing) != std::mem::discriminant(&item); + // The comments above a table header are part of the table's decor. + let header_prefix = + tables(existing).first().and_then(|table| table.decor().prefix()).cloned(); + match (&*existing, &mut item) { + (Item::Value(old), Item::Value(new)) => { + *new.decor_mut() = old.decor().clone(); + } + (old, new) => { + for (old, new) in tables(old).into_iter().zip(tables_mut(new)) { + *new.decor_mut() = old.decor().clone(); + new.set_position(old.position()); + if !old.is_implicit() { + new.set_implicit(false); + } + } + } + } + let new_is_value = item.is_value(); + *existing = item; + if shape_changed { + let mut key = parent.key_mut(key_to_insert).expect("replaced key must exist"); + let decor = key.leaf_decor_mut(); + decor.clear(); + if new_is_value { + decor.set_suffix(" "); + if let Some(prefix) = header_prefix { + decor.set_prefix(prefix); + } + } + } + } + _ => insert_toml_item(parent, is_inline, key_to_insert, item), + } + } + + Ok(()) +} + +/// Inserts a new `item` at `key` into `parent`. +/// +/// Inline tables can only hold values. There, the new value takes over the whitespace or comment +/// that followed the previous last value, so the inline table keeps its layout. +fn insert_toml_item(parent: &mut dyn TableLike, is_inline: bool, key: &str, mut item: Item) { + if is_inline { + let trailing = + parent.iter_mut().filter_map(|(_, item)| item.as_value_mut()).last().and_then(|last| { + let suffix = last.decor().suffix().cloned(); + last.decor_mut().set_suffix(""); + suffix + }); + item = item.into_value().map_or_else(|item| item, Item::Value); + if let Some(value) = item.as_value_mut() { + value.decor_mut().clear(); + if let Some(trailing) = trailing { + value.decor_mut().set_suffix(trailing); + } + } + } + parent.insert(key, item); +} + +/// Converts a JSON value to a TOML item, formatted the same way as [`format_json_to_toml`]. +fn json_to_toml_item(value: JsonValue) -> Result { + const KEY: &str = "value"; + + let wrapper = JsonValue::Object([(KEY.to_string(), value)].into_iter().collect()); + let mut item = parse_toml_document(&format_json_to_toml(wrapper)?)? + .remove(KEY) + .ok_or_else(|| fmt_err!("failed to serialize TOML value"))?; + // Drop the layout of the temporary document so new tables are placed after their parent. + reset_table_layout(&mut item); + Ok(item) +} + +/// Recursively clears the document position and header whitespace of all tables in `item`. +fn reset_table_layout(item: &mut Item) { + for table in tables_mut(item) { + table.set_position(None); + table.decor_mut().clear(); + for (_, item) in table.iter_mut() { + reset_table_layout(item); + } + } +} + +/// Returns the tables of `item` that are written with a header. +fn tables(item: &Item) -> Vec<&Table> { + match item { + Item::Table(table) => vec![table], + Item::ArrayOfTables(array) => array.iter().collect(), + _ => Vec::new(), + } +} + +/// Returns the tables of `item` that are written with a header. +fn tables_mut(item: &mut Item) -> Vec<&mut Table> { + match item { + Item::Table(table) => vec![table], + Item::ArrayOfTables(array) => array.iter_mut().collect(), + _ => Vec::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const CONFIG: &str = r#"# Deployment config. + +owner = "0x000000000000000000000000000000000000dEaD" # multisig +deployed_at = 2024-04-27T11:57:21Z +max_supply = 0xffff_ffff +salt = 'literal-string' + +# Mainnet settings. +[mainnet] +token = "0x0000000000000000000000000000000000000000" # filled by script +limits = { daily = 1_000, weekly = 5_000 } + +# Base settings. +[base] +chain_id = 8453 +"#; + + fn upsert(toml: &str, value: &str, key: &str) -> Result { + let mut document = parse_toml_document(toml)?; + upsert_toml_value(&mut document, value, key)?; + Ok(document.to_string()) + } + + #[test] + fn upsert_toml_keeps_comment_above_key() { + let toml = "key1 = \"1\"\n\n# this is key2\nkey2 = \"2\"\n"; + assert_eq!( + upsert(toml, "abcd", ".key2").unwrap(), + "key1 = \"1\"\n\n# this is key2\nkey2 = \"abcd\"\n" + ); + } + + #[test] + fn upsert_toml_replaces_only_the_value() { + let address = "0x000000000000000000000000000000000000bEEF"; + assert_eq!( + upsert(CONFIG, address, ".mainnet.token").unwrap(), + CONFIG.replace( + r#"token = "0x0000000000000000000000000000000000000000""#, + &format!(r#"token = "{address}""#) + ) + ); + assert_eq!( + upsert(CONFIG, "2000", ".mainnet.limits.daily").unwrap(), + CONFIG.replace("daily = 1_000", "daily = 2000") + ); + assert_eq!( + upsert(CONFIG, "{\"chain_id\": 10}", "base").unwrap(), + CONFIG.replace("chain_id = 8453", "chain_id = 10") + ); + assert_eq!( + upsert(CONFIG, "{\"daily\": 2000}", ".mainnet.limits").unwrap(), + CONFIG.replace("daily = 1_000, weekly = 5_000", "daily = 2000") + ); + } + + #[test] + fn upsert_toml_adds_keys() { + assert_eq!( + upsert(CONFIG, "30000000", ".base.gas_limit").unwrap(), + format!("{CONFIG}gas_limit = 30000000\n") + ); + assert_eq!( + upsert(CONFIG, "{\"block\": 123}", ".optimism.contracts").unwrap(), + format!("{CONFIG}\n[optimism.contracts]\nblock = 123\n") + ); + assert_eq!( + upsert(CONFIG, "{\"monthly\": 9}", ".mainnet.limits.extra").unwrap(), + CONFIG.replace("weekly = 5_000 }", "weekly = 5_000, extra = { monthly = 9 } }") + ); + } + + #[test] + fn upsert_toml_preserves_inline_table_formatting_when_adding_keys() { + let toml = "limits = { daily = 1_000, weekly = 5_000 }\n"; + assert_eq!( + upsert(toml, "9000", ".limits.monthly").unwrap(), + "limits = { daily = 1_000, weekly = 5_000, monthly = 9000 }\n" + ); + assert_eq!( + upsert(toml, "9000", ".limits.extra.monthly").unwrap(), + "limits = { daily = 1_000, weekly = 5_000, extra = { monthly = 9000 } }\n" + ); + + let toml = "limits={daily=1_000,weekly = 5_000}\n"; + assert_eq!( + upsert(toml, "9000", ".limits.monthly").unwrap(), + "limits={daily=1_000,weekly = 5_000, monthly = 9000}\n" + ); + + let toml = "limits = {\n # Keep this cap conservative.\n daily = 1_000,\n weekly = 5_000,\n}\n"; + assert_eq!( + upsert(toml, "9000", ".limits.monthly").unwrap(), + "limits = {\n # Keep this cap conservative.\n daily = 1_000,\n weekly = 5_000, monthly = 9000,\n}\n" + ); + } + + #[test] + fn upsert_toml_replaces_arrays_of_tables_in_place() { + let toml = "# Production RPC endpoints.\n[[rpc]]\nurl = \"old\"\n"; + assert_eq!( + upsert(toml, r#"[{"url":"new"}]"#, ".rpc").unwrap(), + "# Production RPC endpoints.\n[[rpc]]\nurl = \"new\"\n" + ); + } + + #[test] + fn upsert_toml_keeps_comments_when_the_item_shape_changes() { + let toml = "# Deployment settings.\n[deployment] # maintained by the deploy script\nchain_id = 1\n"; + assert_eq!( + upsert(toml, "disabled", ".deployment").unwrap(), + "# Deployment settings.\ndeployment = \"disabled\"\n" + ); + + let toml = "# Production RPC endpoint.\n[rpc]\nurl = \"a\"\n"; + assert_eq!( + upsert(toml, r#"[{"url":"a"},{"url":"b"}]"#, ".rpc").unwrap(), + "# Production RPC endpoint.\n[[rpc]]\nurl = \"a\"\n\n[[rpc]]\nurl = \"b\"\n" + ); + + let toml = "# Production RPC endpoints.\nrpc = []\n"; + assert_eq!( + upsert(toml, r#"[{"url":"a"},{"url":"b"}]"#, ".rpc").unwrap(), + "# Production RPC endpoints.\nrpc = [{ url = \"a\" }, { url = \"b\" }]\n" + ); + } + + #[test] + fn upsert_toml_formats_new_values_like_write_toml() { + let value = r#"{"list": ["0x01", "0x02"], "empty": {}, "nested": {"a": {"b": 1}}}"#; + let expected = format_json_to_toml( + serde_json::json!({ "new": serde_json::from_str::(value).unwrap() }), + ) + .unwrap(); + assert_eq!(upsert("", value, ".new").unwrap(), expected); + } + + #[test] + fn upsert_toml_errors() { + assert_eq!( + upsert(CONFIG, "1", ".owner.x").unwrap_err().to_string(), + "final destination is not an object, cannot insert key." + ); + assert_eq!( + upsert(CONFIG, "1", ".owner.x.y").unwrap_err().to_string(), + "path segment 'x' does not resolve to an object." + ); + assert_eq!( + upsert(CONFIG, "1", "$.").unwrap_err().to_string(), + "'valueKey' cannot be empty or just '$'" + ); + } +} diff --git a/crates/cheatcodes/src/utils.rs b/crates/cheatcodes/src/utils.rs index 5872e60640b15..a0772b72edd25 100644 --- a/crates/cheatcodes/src/utils.rs +++ b/crates/cheatcodes/src/utils.rs @@ -3,14 +3,16 @@ use crate::{Cheatcode, Cheatcodes, CheatcodesExecutor, CheatsCtxt, Result, Vm::*}; use alloy_dyn_abi::{DynSolType, DynSolValue, Resolver, TypedData, eip712_parser::EncodeType}; use alloy_ens::namehash; -use alloy_primitives::{B64, Bytes, U256, aliases::B32, keccak256, map::HashMap}; +use alloy_primitives::{B64, Bytes, I256, U256, aliases::B32, keccak256, map::HashMap}; +use alloy_rlp::{Decodable, Encodable}; use alloy_sol_types::SolValue; use foundry_common::{TYPE_BINDING_PREFIX, fs}; use foundry_config::fs_permissions::FsAccessKind; -use foundry_evm_core::constants::DEFAULT_CREATE2_DEPLOYER; +use foundry_evm_core::{constants::DEFAULT_CREATE2_DEPLOYER, evm::FoundryEvmNetwork}; +use foundry_evm_fuzz::strategies::BoundMutator; use proptest::prelude::Strategy; use rand::{Rng, RngCore, seq::SliceRandom}; -use revm::context::JournalTr; +use revm::context::{ContextTr, JournalTr}; use std::path::PathBuf; /// Contains locations of traces ignored via cheatcodes. @@ -28,7 +30,7 @@ pub struct IgnoredTraces { } impl Cheatcode for labelCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { account, newLabel } = self; state.labels.insert(*account, newLabel.clone()); Ok(Default::default()) @@ -36,7 +38,7 @@ impl Cheatcode for labelCall { } impl Cheatcode for getLabelCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { account } = self; Ok(match state.labels.get(account) { Some(label) => label.abi_encode(), @@ -46,57 +48,78 @@ impl Cheatcode for getLabelCall { } impl Cheatcode for computeCreateAddressCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { nonce, deployer } = self; - ensure!(*nonce <= U256::from(u64::MAX), "nonce must be less than 2^64 - 1"); + ensure!(*nonce <= U256::from(u64::MAX), "nonce must be less than 2^64"); Ok(deployer.create(nonce.to()).abi_encode()) } } impl Cheatcode for computeCreate2Address_0Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { salt, initCodeHash, deployer } = self; Ok(deployer.create2(salt, initCodeHash).abi_encode()) } } impl Cheatcode for computeCreate2Address_1Call { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { salt, initCodeHash } = self; Ok(DEFAULT_CREATE2_DEPLOYER.create2(salt, initCodeHash).abi_encode()) } } impl Cheatcode for ensNamehashCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { name } = self; Ok(namehash(name).abi_encode()) } } +impl Cheatcode for bound_0Call { + fn apply(&self, state: &mut Cheatcodes) -> Result { + let Self { current, min, max } = *self; + let Some(mutated) = U256::bound(current, min, max, state.test_runner()) else { + bail!("cannot bound {current} in [{min}, {max}] range") + }; + Ok(mutated.abi_encode()) + } +} + +impl Cheatcode for bound_1Call { + fn apply(&self, state: &mut Cheatcodes) -> Result { + let Self { current, min, max } = *self; + let Some(mutated) = I256::bound(current, min, max, state.test_runner()) else { + bail!("cannot bound {current} in [{min}, {max}] range") + }; + Ok(mutated.abi_encode()) + } +} + impl Cheatcode for randomUint_0Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { random_uint(state, None, None) } } impl Cheatcode for randomUint_1Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { min, max } = *self; random_uint(state, None, Some((min, max))) } } impl Cheatcode for randomUint_2Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { bits } = *self; random_uint(state, Some(bits), None) } } impl Cheatcode for randomAddressCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { - Ok(DynSolValue::type_strategy(&DynSolType::Address) + fn apply(&self, state: &mut Cheatcodes) -> Result { + Ok(DynSolType::Address + .value_strategy() .new_tree(state.test_runner()) .unwrap() .current() @@ -105,59 +128,57 @@ impl Cheatcode for randomAddressCall { } impl Cheatcode for randomInt_0Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { random_int(state, None) } } impl Cheatcode for randomInt_1Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { bits } = *self; random_int(state, Some(bits)) } } impl Cheatcode for randomBoolCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let rand_bool: bool = state.rng().random(); Ok(rand_bool.abi_encode()) } } impl Cheatcode for randomBytesCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { len } = *self; - ensure!( - len <= U256::from(usize::MAX), - format!("bytes length cannot exceed {}", usize::MAX) - ); - let mut bytes = vec![0u8; len.to::()]; + let len = usize::try_from(len) + .map_err(|_| fmt_err!("bytes length cannot exceed {}", usize::MAX))?; + let mut bytes = vec![0u8; len]; state.rng().fill_bytes(&mut bytes); Ok(bytes.abi_encode()) } } impl Cheatcode for randomBytes4Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let rand_u32 = state.rng().next_u32(); Ok(B32::from(rand_u32).abi_encode()) } } impl Cheatcode for randomBytes8Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let rand_u64 = state.rng().next_u64(); Ok(B64::from(rand_u64).abi_encode()) } } impl Cheatcode for pauseTracingCall { - fn apply_full( + fn apply_full( &self, - ccx: &mut crate::CheatsCtxt, - executor: &mut dyn CheatcodesExecutor, + ccx: &mut CheatsCtxt<'_, '_, FEN>, + executor: &mut dyn CheatcodesExecutor, ) -> Result { - let Some(tracer) = executor.tracing_inspector().and_then(|t| t.as_ref()) else { + let Some(tracer) = executor.tracing_inspector() else { // No tracer -> nothing to pause return Ok(Default::default()); }; @@ -175,12 +196,12 @@ impl Cheatcode for pauseTracingCall { } impl Cheatcode for resumeTracingCall { - fn apply_full( + fn apply_full( &self, - ccx: &mut crate::CheatsCtxt, - executor: &mut dyn CheatcodesExecutor, + ccx: &mut CheatsCtxt<'_, '_, FEN>, + executor: &mut dyn CheatcodesExecutor, ) -> Result { - let Some(tracer) = executor.tracing_inspector().and_then(|t| t.as_ref()) else { + let Some(tracer) = executor.tracing_inspector() else { // No tracer -> nothing to unpause return Ok(Default::default()); }; @@ -198,19 +219,18 @@ impl Cheatcode for resumeTracingCall { } impl Cheatcode for interceptInitcodeCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self {} = self; - if !state.intercept_next_create_call { - state.intercept_next_create_call = true; - } else { + if state.intercept_next_create_call { bail!("vm.interceptInitcode() has already been called") } + state.intercept_next_create_call = true; Ok(Default::default()) } } impl Cheatcode for setArbitraryStorage_0Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { target } = self; ccx.state.arbitrary_storage().mark_arbitrary(target, false); @@ -219,7 +239,7 @@ impl Cheatcode for setArbitraryStorage_0Call { } impl Cheatcode for setArbitraryStorage_1Call { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { target, overwrite } = self; ccx.state.arbitrary_storage().mark_arbitrary(target, *overwrite); @@ -228,7 +248,7 @@ impl Cheatcode for setArbitraryStorage_1Call { } impl Cheatcode for copyStorageCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { from, to } = self; ensure!( @@ -236,10 +256,11 @@ impl Cheatcode for copyStorageCall { "target address cannot have arbitrary storage" ); - if let Ok(from_account) = ccx.ecx.journaled_state.load_account(*from) { + if let Ok(from_account) = ccx.ecx.journal_mut().load_account(*from) { let from_storage = from_account.storage.clone(); - if let Ok(mut to_account) = ccx.ecx.journaled_state.load_account(*to) { - to_account.storage = from_storage; + if ccx.ecx.journal_mut().load_account(*to).is_ok() { + // SAFETY: We ensured the account was already loaded. + ccx.ecx.journal_mut().evm_state_mut().get_mut(to).unwrap().storage = from_storage; if let Some(arbitrary_storage) = &mut ccx.state.arbitrary_storage { arbitrary_storage.mark_copy(from, to); } @@ -251,7 +272,7 @@ impl Cheatcode for copyStorageCall { } impl Cheatcode for sortCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { array } = self; let mut sorted_values = array.clone(); @@ -262,7 +283,7 @@ impl Cheatcode for sortCall { } impl Cheatcode for shuffleCall { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { array } = self; let mut shuffled_values = array.clone(); @@ -274,20 +295,24 @@ impl Cheatcode for shuffleCall { } impl Cheatcode for setSeedCall { - fn apply_stateful(&self, ccx: &mut CheatsCtxt) -> Result { + fn apply_stateful(&self, ccx: &mut CheatsCtxt<'_, '_, FEN>) -> Result { let Self { seed } = self; ccx.state.set_seed(*seed); Ok(Default::default()) } } -/// Helper to generate a random `uint` value (with given bits or bounded if specified) -/// from type strategy. -fn random_uint(state: &mut Cheatcodes, bits: Option, bounds: Option<(U256, U256)>) -> Result { +fn random_uint( + state: &mut Cheatcodes, + bits: Option, + bounds: Option<(U256, U256)>, +) -> Result { if let Some(bits) = bits { - // Generate random with specified bits. - ensure!(bits <= U256::from(256), "number of bits cannot exceed 256"); - return Ok(DynSolValue::type_strategy(&DynSolType::Uint(bits.to::())) + let bits = usize::try_from(bits) + .ok() + .filter(|bits| *bits <= 256) + .ok_or_else(|| fmt_err!("number of bits cannot exceed 256"))?; + return Ok(DynSolValue::type_strategy(&DynSolType::Uint(bits)) .new_tree(state.test_runner()) .unwrap() .current() @@ -296,30 +321,31 @@ fn random_uint(state: &mut Cheatcodes, bits: Option, bounds: Option<(U256, if let Some((min, max)) = bounds { ensure!(min <= max, "min must be less than or equal to max"); - // Generate random between range min..=max let exclusive_modulo = max - min; let mut random_number: U256 = state.rng().random(); if exclusive_modulo != U256::MAX { - let inclusive_modulo = exclusive_modulo + U256::from(1); + let inclusive_modulo = exclusive_modulo + U256::ONE; random_number %= inclusive_modulo; } random_number += min; return Ok(random_number.abi_encode()); } - // Generate random `uint256` value. - Ok(DynSolValue::type_strategy(&DynSolType::Uint(256)) + Ok(DynSolType::Uint(256) + .value_strategy() .new_tree(state.test_runner()) .unwrap() .current() .abi_encode()) } -/// Helper to generate a random `int` value (with given bits if specified) from type strategy. -fn random_int(state: &mut Cheatcodes, bits: Option) -> Result { - let no_bits = bits.unwrap_or(U256::from(256)); - ensure!(no_bits <= U256::from(256), "number of bits cannot exceed 256"); - Ok(DynSolValue::type_strategy(&DynSolType::Int(no_bits.to::())) +fn random_int(state: &mut Cheatcodes, bits: Option) -> Result { + let bits = bits.unwrap_or(U256::from(256)); + let bits = usize::try_from(bits) + .ok() + .filter(|bits| *bits <= 256) + .ok_or_else(|| fmt_err!("number of bits cannot exceed 256"))?; + Ok(DynSolValue::type_strategy(&DynSolType::Int(bits)) .new_tree(state.test_runner()) .unwrap() .current() @@ -327,7 +353,7 @@ fn random_int(state: &mut Cheatcodes, bits: Option) -> Result { } impl Cheatcode for eip712HashType_0Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { typeNameOrDefinition } = self; let type_def = get_canonical_type_def(typeNameOrDefinition, state, None)?; @@ -337,7 +363,7 @@ impl Cheatcode for eip712HashType_0Call { } impl Cheatcode for eip712HashType_1Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { bindingsPath, typeName } = self; let path = state.config.ensure_path_allowed(bindingsPath, FsAccessKind::Read)?; @@ -348,7 +374,7 @@ impl Cheatcode for eip712HashType_1Call { } impl Cheatcode for eip712HashStruct_0Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { typeNameOrDefinition, abiEncodedData } = self; let type_def = get_canonical_type_def(typeNameOrDefinition, state, None)?; @@ -359,7 +385,7 @@ impl Cheatcode for eip712HashStruct_0Call { } impl Cheatcode for eip712HashStruct_1Call { - fn apply(&self, state: &mut Cheatcodes) -> Result { + fn apply(&self, state: &mut Cheatcodes) -> Result { let Self { bindingsPath, typeName, abiEncodedData } = self; let path = state.config.ensure_path_allowed(bindingsPath, FsAccessKind::Read)?; @@ -370,7 +396,7 @@ impl Cheatcode for eip712HashStruct_1Call { } impl Cheatcode for eip712HashTypedDataCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { jsonData } = self; let typed_data: TypedData = serde_json::from_str(jsonData)?; let digest = typed_data.eip712_signing_hash()?; @@ -381,9 +407,9 @@ impl Cheatcode for eip712HashTypedDataCall { /// Returns EIP-712 canonical type definition from the provided string type representation or type /// name. If type name provided, then it looks up bindings from file generated by `forge bind-json`. -fn get_canonical_type_def( +fn get_canonical_type_def( name_or_def: &String, - state: &mut Cheatcodes, + state: &mut Cheatcodes, path: Option, ) -> Result { let type_def = if name_or_def.contains('(') { @@ -470,3 +496,25 @@ fn get_struct_hash(primary: &str, type_def: &String, abi_encoded_data: &Bytes) - Ok(keccak256(&bytes_to_hash).to_vec()) } + +impl Cheatcode for toRlpCall { + fn apply(&self, _state: &mut Cheatcodes) -> Result { + let Self { data } = self; + + let mut buf = Vec::new(); + data.encode(&mut buf); + + Ok(Bytes::from(buf).abi_encode()) + } +} + +impl Cheatcode for fromRlpCall { + fn apply(&self, _state: &mut Cheatcodes) -> Result { + let Self { rlp } = self; + + let decoded: Vec = Vec::::decode(&mut rlp.as_ref()) + .map_err(|e| fmt_err!("Failed to decode RLP: {e}"))?; + + Ok(decoded.abi_encode()) + } +} diff --git a/crates/cheatcodes/src/version.rs b/crates/cheatcodes/src/version.rs index 84eace8f0017e..2b8f81518a621 100644 --- a/crates/cheatcodes/src/version.rs +++ b/crates/cheatcodes/src/version.rs @@ -1,25 +1,33 @@ use crate::{Cheatcode, Cheatcodes, Result, Vm::*}; use alloy_sol_types::SolValue; use foundry_common::version::SEMVER_VERSION; +use foundry_evm_core::evm::FoundryEvmNetwork; use semver::Version; use std::cmp::Ordering; impl Cheatcode for foundryVersionCmpCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { version } = self; foundry_version_cmp(version).map(|cmp| (cmp as i8).abi_encode()) } } impl Cheatcode for foundryVersionAtLeastCall { - fn apply(&self, _state: &mut Cheatcodes) -> Result { + fn apply(&self, _state: &mut Cheatcodes) -> Result { let Self { version } = self; foundry_version_cmp(version).map(|cmp| cmp.is_ge().abi_encode()) } } fn foundry_version_cmp(version: &str) -> Result { - version_cmp(SEMVER_VERSION.split('-').next().unwrap(), version) + version_cmp(strip_semver_metadata(SEMVER_VERSION), version) +} + +/// Strips pre-release (e.g. `-nightly`, `-dev`) and build metadata +/// (e.g. `+..`) from a version string +/// so we compare on `MAJOR.MINOR.PATCH` only. +fn strip_semver_metadata(version: &str) -> &str { + version.split(['-', '+']).next().unwrap() } fn version_cmp(version_a: &str, version_b: &str) -> Result { @@ -41,3 +49,61 @@ fn parse_version(version: &str) -> Result { } Ok(version) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strips_build_metadata_only() { + // Tagged release: `1.7.1+..` + assert_eq!(strip_semver_metadata("1.7.1+abc1234567.1737036656.release"), "1.7.1"); + } + + #[test] + fn strips_pre_release_and_build_metadata() { + // Nightly: `1.7.1-nightly+..` + assert_eq!(strip_semver_metadata("1.7.1-nightly+abc1234567.1737036656.release"), "1.7.1"); + // Dev: `1.7.1-dev+..` + assert_eq!(strip_semver_metadata("1.7.1-dev+abc1234567.1737036656.debug"), "1.7.1"); + } + + #[test] + fn strips_plain_version() { + assert_eq!(strip_semver_metadata("1.7.1"), "1.7.1"); + } + + #[test] + fn version_cmp_orders_correctly() { + assert_eq!(version_cmp("1.7.1", "1.7.1").unwrap(), Ordering::Equal); + assert_eq!(version_cmp("1.7.1", "1.7.0").unwrap(), Ordering::Greater); + assert_eq!(version_cmp("1.7.1", "1.7.2").unwrap(), Ordering::Less); + assert_eq!(version_cmp("1.7.1", "0.0.1").unwrap(), Ordering::Greater); + assert_eq!(version_cmp("1.7.1", "99.0.0").unwrap(), Ordering::Less); + } + + #[test] + fn parse_version_rejects_pre_release_and_build_metadata() { + // User-supplied versions must be plain `MAJOR.MINOR.PATCH`. + assert!(parse_version("1.7.1-nightly").is_err()); + assert!(parse_version("1.7.1+abc").is_err()); + assert!(parse_version("not-a-version").is_err()); + assert!(parse_version("1.7.1").is_ok()); + } + + #[test] + fn cmp_works_against_full_semver_version_strings() { + // Simulate comparing each shape of `SEMVER_VERSION` against a user-supplied version. + for current in [ + "1.7.1+abc1234567.1737036656.release", + "1.7.1-nightly+abc1234567.1737036656.release", + "1.7.1-dev+abc1234567.1737036656.debug", + "1.7.1", + ] { + let stripped = strip_semver_metadata(current); + assert_eq!(version_cmp(stripped, "1.7.1").unwrap(), Ordering::Equal); + assert_eq!(version_cmp(stripped, "1.7.0").unwrap(), Ordering::Greater); + assert_eq!(version_cmp(stripped, "1.7.2").unwrap(), Ordering::Less); + } + } +} diff --git a/crates/chisel/Cargo.toml b/crates/chisel/Cargo.toml index 22626aea77a91..51472100dbbc8 100644 --- a/crates/chisel/Cargo.toml +++ b/crates/chisel/Cargo.toml @@ -1,11 +1,8 @@ [package] name = "chisel" -authors = [ - "clabby ", - "asnared ", -] description = "Fast, utilitarian, and verbose Solidity REPL" +authors.workspace = true version.workspace = true edition.workspace = true rust-version.workspace = true @@ -25,9 +22,15 @@ path = "bin/main.rs" forge-fmt.workspace = true foundry-cli.workspace = true foundry-common.workspace = true -foundry-compilers = { workspace = true, features = ["project-util", "full"] } +foundry-compilers.workspace = true foundry-config.workspace = true foundry-evm.workspace = true +foundry-evm-networks.workspace = true + + +tempfile.workspace = true + +solar.workspace = true alloy-dyn-abi = { workspace = true, features = ["arbitrary"] } alloy-primitives = { workspace = true, features = [ @@ -41,27 +44,46 @@ alloy-json-abi.workspace = true clap = { version = "4", features = ["derive", "env", "wrap_help"] } dirs.workspace = true eyre.workspace = true -regex.workspace = true reqwest.workspace = true -revm.workspace = true -rustyline = "15" +rustyline = "18" +itertools.workspace = true semver.workspace = true serde_json.workspace = true serde.workspace = true -solang-parser.workspace = true -solar-parse.workspace = true -strum = { workspace = true, features = ["derive"] } time = { version = "0.3", features = ["formatting"] } yansi.workspace = true tracing.workspace = true walkdir.workspace = true [dev-dependencies] +anvil.workspace = true +foundry-test-utils.workspace = true +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } tracing-subscriber.workspace = true +# REPL tests only work on Unix. +[target.'cfg(unix)'.dev-dependencies] +rexpect.workspace = true + [features] -default = ["jemalloc"] +default = ["jemalloc", "asm-keccak", "optimism"] asm-keccak = ["alloy-primitives/asm-keccak"] jemalloc = ["foundry-cli/jemalloc"] mimalloc = ["foundry-cli/mimalloc"] tracy-allocator = ["foundry-cli/tracy-allocator"] +monad = [ + "foundry-common/monad", + "foundry-config/monad", + "foundry-evm/monad", + "foundry-cli/monad", +] +base = [ + "foundry-common/base", + "foundry-evm/base", + "foundry-cli/base", +] +optimism = [ + "foundry-common/optimism", + "foundry-evm/optimism", + "foundry-cli/optimism", +] diff --git a/crates/chisel/src/args.rs b/crates/chisel/src/args.rs index 7c5d0e278d71b..6db7b5ad4b5ef 100644 --- a/crates/chisel/src/args.rs +++ b/crates/chisel/src/args.rs @@ -1,224 +1,253 @@ use crate::{ - history::chisel_history_file, - prelude::{ChiselCommand, ChiselDispatcher, DispatchResult, SolidityHelper}, + dispatcher::view_session, + opts::{Chisel, ChiselSubcommand}, + prelude::{ChiselCommand, ChiselDispatcher, SolidityHelper}, }; use clap::Parser; use eyre::{Context, Result}; -use foundry_cli::{ - handler, - utils::{self, LoadConfig}, -}; +use foundry_cli::utils::{self, LoadConfig}; use foundry_common::fs; -use foundry_config::{ - Config, - figment::{ - Metadata, Profile, Provider, - value::{Dict, Map}, - }, +use foundry_config::Config; +use foundry_evm::{ + core::evm::{EthEvmNetwork, FoundryEvmNetwork, TempoEvmNetwork}, + executors::ExecutorBuilder, + opts::EvmOpts, }; +use foundry_evm_networks::{NetworkConfigs, NetworkVariant}; use rustyline::{Editor, config::Configurer, error::ReadlineError}; -use std::path::PathBuf; -use tracing::debug; +use std::{ops::ControlFlow, path::PathBuf}; use yansi::Paint; -use crate::opts::{Chisel, ChiselSubcommand}; +#[cfg(feature = "base")] +use foundry_evm::core::evm::BaseEvmNetwork; + +#[cfg(feature = "monad")] +use foundry_evm::core::evm::MonadEvmNetwork; + +#[cfg(feature = "optimism")] +use foundry_evm::core::evm::OpEvmNetwork; /// Run the `chisel` command line interface. pub fn run() -> Result<()> { - setup()?; + foundry_cli::opts::GlobalArgs::check_markdown_help::(); + + let warnings = setup()?; let args = Chisel::parse(); args.global.init()?; + for warning in warnings { + let _ = foundry_common::sh_warn!("{warning}"); + } args.global.tokio_runtime().block_on(run_command(args)) } /// Setup the global logger and other utilities. -pub fn setup() -> Result<()> { - utils::install_crypto_provider(); - handler::install(); +pub fn setup() -> Result> { + let warnings = utils::common_setup(); utils::subscriber(); - utils::load_dotenv(); - Ok(()) + Ok(warnings) +} + +macro_rules! try_cf { + ($e:expr) => { + match $e { + ControlFlow::Continue(()) => {} + ControlFlow::Break(()) => return Ok(()), + } + }; } /// Run the subcommand. pub async fn run_command(args: Chisel) -> Result<()> { - // Keeps track of whether or not an interrupt was the last input - let mut interrupt = false; + if let Some(ChiselSubcommand::View { id }) = &args.cmd { + return view_session(id); + } // Load configuration - let (config, evm_opts) = args.load_config_and_evm_opts()?; + let (mut config, mut evm_opts) = args.load_config_and_evm_opts()?; + + evm_opts.networks = + infer_network_from_chain_id(evm_opts.networks, config.chain.map(|chain| chain.id()))?; + evm_opts.infer_network_from_fork().await?; + evm_opts.pin_fork_block().await?; + config.networks = evm_opts.networks; + let local_networks = evm_opts.networks; + let local_chain_id = evm_opts.env.chain_id.or(config.chain.map(|chain| chain.id())); + + match evm_opts.networks.execution_network() { + NetworkVariant::Tempo => { + Box::pin(run_command_with_network::( + args, + config, + evm_opts, + ExecutorBuilder::::new(), + local_networks, + local_chain_id, + )) + .await + } + #[cfg(feature = "base")] + NetworkVariant::Base => { + Box::pin(run_command_with_network::( + args, + config, + evm_opts, + ExecutorBuilder::::new(), + local_networks, + local_chain_id, + )) + .await + } + #[cfg(feature = "monad")] + NetworkVariant::Monad => { + Box::pin(run_command_with_network::( + args, + config, + evm_opts, + ExecutorBuilder::::new(), + local_networks, + local_chain_id, + )) + .await + } + #[cfg(feature = "optimism")] + NetworkVariant::Optimism => { + Box::pin(run_command_with_network::( + args, + config, + evm_opts, + ExecutorBuilder::::new(), + local_networks, + local_chain_id, + )) + .await + } + NetworkVariant::Ethereum => { + Box::pin(run_command_with_network::( + args, + config, + evm_opts, + ExecutorBuilder::::new(), + local_networks, + local_chain_id, + )) + .await + } + } +} +fn infer_network_from_chain_id( + networks: NetworkConfigs, + chain_id: Option, +) -> Result { + if let Some(chain_id) = chain_id { + networks.try_with_chain_id(chain_id).map_err(eyre::Report::msg) + } else { + Ok(networks) + } +} + +async fn run_command_with_network( + args: Chisel, + config: Config, + evm_opts: EvmOpts, + executor_builder: ExecutorBuilder, + local_networks: NetworkConfigs, + local_chain_id: Option, +) -> Result<()> { + let fork_network_is_inferred = evm_opts.fork_network_is_inferred; + let fork_chain_id_is_inferred = evm_opts.fork_chain_id_is_inferred; // Create a new cli dispatcher - let mut dispatcher = ChiselDispatcher::new(crate::session_source::SessionSourceConfig { + let mut dispatcher = ChiselDispatcher::::new(crate::source::SessionSourceConfig { // Enable traces if any level of verbosity was passed traces: config.verbosity > 0, foundry_config: config, no_vm: args.no_vm, evm_opts, - backend: None, + executor_builder, + local_networks: Some(local_networks), + local_chain_id, + fork_network_is_inferred, + fork_chain_id_is_inferred, + resolved_hardfork: None, + source_chain_id: None, + cached_backend: None, calldata: None, + ir_minimum: args.ir_minimum, + fork_url_required: false, })?; // Execute prelude Solidity source files evaluate_prelude(&mut dispatcher, args.prelude).await?; - // Check for chisel subcommands - match &args.cmd { - Some(ChiselSubcommand::List) => { - let sessions = dispatcher.dispatch_command(ChiselCommand::ListSessions, &[]).await; - match sessions { - DispatchResult::CommandSuccess(Some(session_list)) => { - sh_println!("{session_list}")?; - } - DispatchResult::CommandFailed(e) => sh_err!("{e}")?, - _ => panic!("Unexpected result: Please report this bug."), - } - return Ok(()); - } - Some(ChiselSubcommand::Load { id }) | Some(ChiselSubcommand::View { id }) => { - // For both of these subcommands, we need to attempt to load the session from cache - match dispatcher.dispatch_command(ChiselCommand::Load, &[id]).await { - DispatchResult::CommandSuccess(_) => { /* Continue */ } - DispatchResult::CommandFailed(e) => { - sh_err!("{e}")?; - return Ok(()); - } - _ => panic!("Unexpected result! Please report this bug."), - } - - // If the subcommand was `view`, print the source and exit. - if matches!(args.cmd, Some(ChiselSubcommand::View { .. })) { - match dispatcher.dispatch_command(ChiselCommand::Source, &[]).await { - DispatchResult::CommandSuccess(Some(source)) => { - sh_println!("{source}")?; - } - _ => panic!("Unexpected result! Please report this bug."), - } - return Ok(()); - } - } - Some(ChiselSubcommand::ClearCache) => { - match dispatcher.dispatch_command(ChiselCommand::ClearCache, &[]).await { - DispatchResult::CommandSuccess(Some(msg)) => sh_println!("{}", msg.green())?, - DispatchResult::CommandFailed(e) => sh_err!("{e}")?, - _ => panic!("Unexpected result! Please report this bug."), - } - return Ok(()); - } - Some(ChiselSubcommand::Eval { command }) => { - dispatch_repl_line(&mut dispatcher, command).await?; - return Ok(()); - } - None => { /* No chisel subcommand present; Continue */ } + if let Some(cmd) = args.cmd { + try_cf!(handle_cli_command(&mut dispatcher, cmd).await?); + return Ok(()); } - // Create a new rustyline Editor let mut rl = Editor::::new()?; - rl.set_helper(Some(SolidityHelper::default())); - - // automatically add lines to history + rl.set_helper(Some(dispatcher.helper.clone())); rl.set_auto_add_history(true); - - // load history - if let Some(chisel_history) = chisel_history_file() { - let _ = rl.load_history(&chisel_history); + if let Some(path) = chisel_history_file() { + let _ = rl.load_history(&path); } - // Print welcome header sh_println!("Welcome to Chisel! Type `{}` to show available commands.", "!help".green())?; - // Begin Rustyline loop + // REPL loop. + let mut interrupt = false; loop { - // Get the prompt from the dispatcher - // Variable based on status of the last entry let prompt = dispatcher.get_prompt(); - - // Read the next line - let next_string = rl.readline(prompt.as_ref()); - - // Try to read the string - match next_string { + match rl.readline(prompt.as_ref()) { Ok(line) => { debug!("dispatching next line: {line}"); - // Clear interrupt flag + // Clear interrupt flag. interrupt = false; - // Dispatch and match results - let errored = dispatch_repl_line(&mut dispatcher, &line).await?; - rl.helper_mut().unwrap().set_errored(errored); + // Dispatch and match results. + let r = dispatcher.dispatch(&line).await; + dispatcher.helper.set_errored(r.is_err()); + match r { + Ok(ControlFlow::Continue(())) => {} + Ok(ControlFlow::Break(())) => break, + Err(e) => { + sh_err!("{}", foundry_common::errors::display_chain(&e))?; + } + } } Err(ReadlineError::Interrupted) => { if interrupt { break; - } else { - sh_println!("(To exit, press Ctrl+C again)")?; - interrupt = true; } + sh_println!("(To exit, press Ctrl+C again)")?; + interrupt = true; } Err(ReadlineError::Eof) => break, Err(err) => { - sh_err!("{err:?}")?; + sh_err!("{err}")?; break; } } } - if let Some(chisel_history) = chisel_history_file() { - let _ = rl.save_history(&chisel_history); + if let Some(path) = chisel_history_file() { + let _ = rl.save_history(&path); } Ok(()) } -/// [Provider] impl -impl Provider for Chisel { - fn metadata(&self) -> Metadata { - Metadata::named("Script Args Provider") - } - - fn data(&self) -> Result, foundry_config::figment::Error> { - Ok(Map::from([(Config::selected_profile(), Dict::default())])) - } -} - -/// Evaluate a single Solidity line. -async fn dispatch_repl_line(dispatcher: &mut ChiselDispatcher, line: &str) -> Result { - let r = dispatcher.dispatch(line).await; - match &r { - DispatchResult::Success(msg) | DispatchResult::CommandSuccess(msg) => { - debug!(%line, ?msg, "dispatch success"); - if let Some(msg) = msg { - sh_println!("{}", msg.green())?; - } - } - DispatchResult::UnrecognizedCommand(e) => sh_err!("{e}")?, - DispatchResult::SolangParserFailed(e) => { - sh_err!("{}", "Compilation error".red())?; - sh_eprintln!("{}", format!("{e:?}").red())?; - } - DispatchResult::FileIoError(e) => sh_err!("{}", format!("File IO - {e}").red())?, - DispatchResult::CommandFailed(msg) | DispatchResult::Failure(Some(msg)) => { - sh_err!("{}", msg.red())? - } - DispatchResult::Failure(None) => sh_err!( - "Please report this bug as a github issue if it persists: https://github.com/foundry-rs/foundry/issues/new/choose" - )?, - } - Ok(r.is_error()) -} - /// Evaluate multiple Solidity source files contained within a /// Chisel prelude directory. -async fn evaluate_prelude( - dispatcher: &mut ChiselDispatcher, +async fn evaluate_prelude( + dispatcher: &mut ChiselDispatcher, maybe_prelude: Option, ) -> Result<()> { let Some(prelude_dir) = maybe_prelude else { return Ok(()) }; if prelude_dir.is_file() { sh_println!("{} {}", "Loading prelude source file:".yellow(), prelude_dir.display())?; - load_prelude_file(dispatcher, prelude_dir).await?; + try_cf!(load_prelude_file(dispatcher, prelude_dir).await?); sh_println!("{}\n", "Prelude source file loaded successfully!".green())?; } else { let prelude_sources = fs::files_with_ext(&prelude_dir, "sol"); @@ -226,7 +255,7 @@ async fn evaluate_prelude( for source_file in prelude_sources { print_success_msg = true; sh_println!("{} {}", "Loading prelude source file:".yellow(), source_file.display())?; - load_prelude_file(dispatcher, source_file).await?; + try_cf!(load_prelude_file(dispatcher, source_file).await?); } if print_success_msg { @@ -237,11 +266,33 @@ async fn evaluate_prelude( } /// Loads a single Solidity file into the prelude. -async fn load_prelude_file(dispatcher: &mut ChiselDispatcher, file: PathBuf) -> Result<()> { +async fn load_prelude_file( + dispatcher: &mut ChiselDispatcher, + file: PathBuf, +) -> Result> { let prelude = fs::read_to_string(file) .wrap_err("Could not load source file. Are you sure this path is correct?")?; - dispatch_repl_line(dispatcher, &prelude).await?; - Ok(()) + dispatcher.dispatch_solidity(&prelude).await +} + +async fn handle_cli_command( + d: &mut ChiselDispatcher, + cmd: ChiselSubcommand, +) -> Result> { + match cmd { + ChiselSubcommand::List => d.dispatch_command(ChiselCommand::ListSessions).await, + ChiselSubcommand::Load { id } => d.dispatch_command(ChiselCommand::Load { id }).await, + ChiselSubcommand::View { id } => { + view_session(&id)?; + Ok(ControlFlow::Continue(())) + } + ChiselSubcommand::ClearCache => d.dispatch_command(ChiselCommand::ClearCache).await, + ChiselSubcommand::Eval { command } => d.dispatch(&command).await, + } +} + +fn chisel_history_file() -> Option { + foundry_config::Config::foundry_dir().map(|p| p.join(".chisel_history")) } #[cfg(test)] @@ -253,4 +304,53 @@ mod tests { fn verify_cli() { Chisel::command().debug_assert(); } + + /// Base chain IDs resolved to Optimism before Base support existed, so a build without the + /// `base` feature — which is what release binaries ship — must keep resolving them that way. + #[test] + #[cfg(all(not(feature = "base"), feature = "optimism"))] + fn chain_id_without_base_still_resolves_to_optimism() { + for chain_id in [8453, 84532] { + let networks = infer_network_from_chain_id(NetworkConfigs::default(), Some(chain_id)) + .unwrap_or_else(|error| panic!("chain ID {chain_id} must still resolve: {error}")); + assert!(networks.is_optimism(), "chain ID {chain_id} must resolve to Optimism"); + } + } + + #[test] + #[cfg(not(feature = "monad"))] + fn chain_id_rejects_disabled_monad_network() { + let error = infer_network_from_chain_id(NetworkConfigs::default(), Some(143)).unwrap_err(); + + assert_eq!( + error.to_string(), + "cannot infer execution network from chain ID 143: network family `monad` is not \ + enabled in this build" + ); + } + + #[test] + fn explicit_ethereum_overrides_chain_id_inference() { + let ethereum = NetworkConfigs::with_ethereum(); + for chain_id in [8453, 143] { + assert_eq!(infer_network_from_chain_id(ethereum, Some(chain_id)).unwrap(), ethereum); + } + } + + #[tokio::test] + async fn prelude_does_not_dispatch_chisel_commands() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("prelude.sol"); + std::fs::write(&file, "!calldata 0x00").unwrap(); + let config = crate::source::SessionSourceConfig:: { + foundry_config: Config { + solc: Some(foundry_config::SolcReq::Version(semver::Version::new(0, 8, 29))), + ..Default::default() + }, + ..Default::default() + }; + let mut dispatcher = ChiselDispatcher::new(config).unwrap(); + + assert!(load_prelude_file(&mut dispatcher, file).await.is_err()); + } } diff --git a/crates/chisel/src/cmd.rs b/crates/chisel/src/cmd.rs index 24987af2d7e7e..ea1ef8335e6f7 100644 --- a/crates/chisel/src/cmd.rs +++ b/crates/chisel/src/cmd.rs @@ -1,203 +1,192 @@ -//! ChiselCommand -//! -//! This module holds the [ChiselCommand] enum, which contains all builtin commands that -//! can be executed within the REPL. +use crate::prelude::CHISEL_CHAR; +use alloy_primitives::Address; +use clap::{CommandFactory, Parser}; +use itertools::Itertools; +use yansi::Paint; -use crate::prelude::ChiselDispatcher; -use std::{error::Error, str::FromStr}; -use strum::EnumIter; - -/// Builtin chisel command variants -#[derive(Debug, EnumIter)] +/// Chisel REPL commands. +#[derive(Debug, Parser)] +#[command(disable_help_flag = true, disable_help_subcommand = true)] pub enum ChiselCommand { - /// Print helpful information about chisel + /// Display all commands. + #[command(visible_alias = "h", next_help_heading = "General")] Help, - /// Quit the REPL + + /// Quit the REPL. + #[command(visible_alias = "q")] Quit, - /// Clear the current session source + + /// Executes a shell command. + #[command(visible_alias = "e")] + Exec { + /// Command to execute. + command: String, + /// Command arguments. + #[arg(trailing_var_arg = true)] + args: Vec, + }, + + /// Clear the current session source. + #[command(visible_alias = "c", next_help_heading = "Session")] Clear, - /// Print the generated source contract + + /// Print the generated source contract. + #[command(visible_alias = "so")] Source, - /// Save the current session to the cache - /// Takes: `` - Save, - /// Load a previous session from cache - /// Takes: `` + + /// Save the current session to the cache. /// + /// RPC endpoints and credentials are excluded from saved sessions. + #[command(visible_alias = "s")] + Save { + /// Optional session ID. + id: Option, + }, + + /// Load a previous session from cache. /// WARNING: This will overwrite the current session (though the current session will be - /// optimistically cached) - Load, - /// List all cached sessions + /// optimistically cached). + /// + /// Credentials come from this invocation. Use `!fork ` or start Chisel with `--fork-url` + /// to restore a forked session. + #[command(visible_alias = "l")] + Load { + /// Session ID to load. + id: String, + }, + + /// List all cached sessions. + #[command(name = "list", visible_alias = "ls")] ListSessions, - /// Clear the cache of all stored sessions + + /// Clear the cache of all stored sessions. + #[command(name = "clearcache", visible_alias = "cc")] ClearCache, - /// Fork an RPC in the current session - /// Takes - Fork, - /// Enable / disable traces for the current session + + /// Export the current REPL session source to a Script file. + #[command(visible_alias = "ex")] + Export, + + /// Fetch an interface of a verified contract on Etherscan. + #[command(visible_alias = "fe")] + Fetch { + /// Contract address. + addr: Address, + /// Interface name. + name: String, + }, + + /// Open the current session in an editor. + Edit, + + /// Fork an RPC in the current session. + #[command(visible_alias = "f", next_help_heading = "Environment")] + Fork { + /// Fork URL, environment variable, or RPC endpoints alias (empty to return to local + /// network). + url: Option, + }, + + /// Enable / disable traces for the current session. + #[command(visible_alias = "t")] Traces, - /// Set calldata (`msg.data`) for the current session (appended after function selector) - Calldata, - /// Dump the raw memory + + /// Set calldata (`msg.data`) for the current session (appended after function selector). Clears + /// it if no argument provided. + #[command(visible_alias = "cd")] + Calldata { + /// Calldata (empty to clear). + data: Option, + }, + + /// Dump the raw memory. + #[command(name = "memdump", visible_alias = "md", next_help_heading = "Debug")] MemDump, - /// Dump the raw stack + + /// Dump the raw stack. + #[command(name = "stackdump", visible_alias = "sd")] StackDump, - /// Export the current REPL session source to a Script file - Export, - /// Fetch an interface of a verified contract on Etherscan - /// Takes: ` ` and optional `` (defaults to `1` / mainnet if - /// not specified). - Fetch, - /// Executes a shell command - Exec, - /// Display the raw value of a variable's stack allocation. - RawStack, - /// Open the current session in an editor - Edit, + + /// Display the raw value of a variable's stack allocation. For variables that are > 32 bytes in + /// length, this will display their memory pointer. + #[command(name = "rawstack", visible_alias = "rs")] + RawStack { + /// Variable name. + var: String, + }, } -/// Attempt to convert a string slice to a `ChiselCommand` -impl FromStr for ChiselCommand { - type Err = Box; - - fn from_str(s: &str) -> Result { - match s.to_lowercase().as_ref() { - "help" | "h" => Ok(Self::Help), - "quit" | "q" => Ok(Self::Quit), - "clear" | "c" => Ok(Self::Clear), - "source" | "so" => Ok(Self::Source), - "save" | "s" => Ok(Self::Save), - "list" | "ls" => Ok(Self::ListSessions), - "load" | "l" => Ok(Self::Load), - "clearcache" | "cc" => Ok(Self::ClearCache), - "fork" | "f" => Ok(Self::Fork), - "traces" | "t" => Ok(Self::Traces), - "calldata" | "cd" => Ok(Self::Calldata), - "memdump" | "md" => Ok(Self::MemDump), - "stackdump" | "sd" => Ok(Self::StackDump), - "export" | "ex" => Ok(Self::Export), - "fetch" | "fe" => Ok(Self::Fetch), - "exec" | "e" => Ok(Self::Exec), - "rawstack" | "rs" => Ok(Self::RawStack), - "edit" => Ok(Self::Edit), - _ => Err(ChiselDispatcher::make_error(format!( - "Unknown command \"{s}\"! See available commands with `!help`.", - )) - .into()), - } +impl ChiselCommand { + pub fn parse(input: &str) -> eyre::Result { + let args = input.split_whitespace(); + let args = std::iter::once("chisel").chain(args); + Self::try_parse_from(args) + .map_err(|e| eyre::eyre!("{}; for more information, see `!help`", e.kind())) } -} -/// A category for [ChiselCommand]s -#[derive(Debug, EnumIter)] -pub enum CmdCategory { - /// General category - General, - /// Session category - Session, - /// Environment category - Env, - /// Debug category - Debug, + pub fn format_help() -> String { + let cmd = Self::command(); + let mut categories = Vec::new(); + let mut cat = None; + for sub in cmd.get_subcommands() { + if let Some(cat_) = sub.get_next_help_heading() + && Some(cat_) != cat + { + cat = Some(cat_); + categories.push((cat_, vec![])); + } + categories.last_mut().unwrap().1.push(sub); + } + format!( + "{}\n{}", + format!("{CHISEL_CHAR} Chisel help\n=============").cyan(), + categories + .iter() + .map(|(cat, cat_cmds)| { + format!( + "{}\n{}\n", + cat.magenta(), + cat_cmds + .iter() + .map(|&cmd| format!( + "\t{}{} - {}", + std::iter::once(cmd.get_name()) + .chain(cmd.get_visible_aliases()) + .map(|s| format!("!{}", s.green())) + .format(" | "), + { + let usage = get_usage(cmd); + if usage.is_empty() { + String::new() + } else { + format!(" {usage}") + } + } + .green(), + cmd.get_about().expect("command is missing about"), + )) + .format("\n") + ) + }) + .format("\n") + ) + } } -impl core::fmt::Display for CmdCategory { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - let string = match self { - Self::General => "General", - Self::Session => "Session", - Self::Env => "Environment", - Self::Debug => "Debug", - }; - f.write_str(string) +fn get_usage(cmd: &clap::Command) -> String { + let s = cmd.clone().render_usage().to_string(); + if let Some(idx) = s.find(['[', '<']) { + return s[idx..].to_string(); } + String::new() } -/// A command descriptor type -pub type CmdDescriptor = (&'static [&'static str], &'static str, CmdCategory); - -/// Convert a `ChiselCommand` into a `CmdDescriptor` tuple -impl From for CmdDescriptor { - fn from(cmd: ChiselCommand) -> Self { - match cmd { - // General - ChiselCommand::Help => (&["help", "h"], "Display all commands", CmdCategory::General), - ChiselCommand::Quit => (&["quit", "q"], "Quit Chisel", CmdCategory::General), - ChiselCommand::Exec => ( - &["exec [args]", "e [args]"], - "Execute a shell command and print the output", - CmdCategory::General, - ), - // Session - ChiselCommand::Clear => { - (&["clear", "c"], "Clear current session source", CmdCategory::Session) - } - ChiselCommand::Source => ( - &["source", "so"], - "Display the source code of the current session", - CmdCategory::Session, - ), - ChiselCommand::Save => ( - &["save [id]", "s [id]"], - "Save the current session to cache", - CmdCategory::Session, - ), - ChiselCommand::Load => ( - &["load ", "l "], - "Load a previous session ID from cache", - CmdCategory::Session, - ), - ChiselCommand::ListSessions => { - (&["list", "ls"], "List all cached sessions", CmdCategory::Session) - } - ChiselCommand::ClearCache => ( - &["clearcache", "cc"], - "Clear the chisel cache of all stored sessions", - CmdCategory::Session, - ), - ChiselCommand::Export => ( - &["export", "ex"], - "Export the current session source to a script file", - CmdCategory::Session, - ), - ChiselCommand::Fetch => ( - &["fetch ", "fe "], - "Fetch the interface of a verified contract on Etherscan", - CmdCategory::Session, - ), - // Environment - ChiselCommand::Fork => ( - &["fork ", "f "], - "Fork an RPC for the current session. Supply 0 arguments to return to a local network", - CmdCategory::Env, - ), - ChiselCommand::Traces => ( - &["traces", "t"], - "Enable / disable traces for the current session", - CmdCategory::Env, - ), - ChiselCommand::Calldata => ( - &["calldata [data]", "cd [data]"], - "Set calldata (`msg.data`) for the current session (appended after function selector). Clears it if no argument provided.", - CmdCategory::Env, - ), - // Debug - ChiselCommand::MemDump => { - (&["memdump", "md"], "Dump the raw memory of the current state", CmdCategory::Debug) - } - ChiselCommand::StackDump => ( - &["stackdump", "sd"], - "Dump the raw stack of the current state", - CmdCategory::Debug, - ), - ChiselCommand::Edit => { - (&["edit"], "Open the current session in an editor", CmdCategory::Session) - } - ChiselCommand::RawStack => ( - &["rawstack ", "rs "], - "Display the raw value of a variable's stack allocation. For variables that are > 32 bytes in length, this will display their memory pointer.", - CmdCategory::Debug, - ), - } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn print_help() { + let _ = sh_eprintln!("{}", ChiselCommand::format_help()); } } diff --git a/crates/chisel/src/dispatcher.rs b/crates/chisel/src/dispatcher.rs index 374864e448add..15bd6bbe9cdeb 100644 --- a/crates/chisel/src/dispatcher.rs +++ b/crates/chisel/src/dispatcher.rs @@ -4,38 +4,39 @@ //! of both builtin commands and Solidity snippets. use crate::{ - prelude::{ - ChiselCommand, ChiselResult, ChiselSession, CmdCategory, CmdDescriptor, - SessionSourceConfig, SolidityHelper, - }, - session_source::SessionSource, + executor::InspectResult, + prelude::{ChiselCommand, ChiselResult, ChiselSession, SessionSourceConfig, SolidityHelper}, + source::{InvocationRpc, SessionSource}, }; -use alloy_json_abi::{InternalType, JsonAbi}; use alloy_primitives::{Address, hex}; +use eyre::{Context, Result}; use forge_fmt::FormatterConfig; -use foundry_config::RpcEndpointUrl; +use foundry_cli::utils::fetch_abi_from_etherscan; +use foundry_config::{Chain, Config, RpcEndpointUrl}; use foundry_evm::{ + core::evm::{EthEvmNetwork, FoundryEvmNetwork}, decode::decode_console_logs, + executors::ExecutorBuilder, traces::{ CallTraceDecoder, CallTraceDecoderBuilder, TraceKind, decode_trace_arena, identifier::{SignaturesIdentifier, TraceIdentifiers}, render_trace_arena, }, }; -use regex::Regex; +use foundry_evm_networks::{NetworkConfigs, NetworkVariant}; use reqwest::Url; -use serde::{Deserialize, Serialize}; -use solang_parser::diagnostics::Diagnostic; +use solar::{ + parse::lexer::token::{RawLiteralKind, RawTokenKind}, + sema::ast::Base, +}; use std::{ borrow::Cow, - error::Error, io::Write, + ops::ControlFlow, path::{Path, PathBuf}, process::Command, - sync::LazyLock, }; -use strum::IntoEnumIterator; -use tracing::debug; +use tempfile::Builder; use yansi::Paint; /// Prompt arrow character. @@ -49,100 +50,28 @@ pub const COMMAND_LEADER: char = '!'; /// Chisel character pub const CHISEL_CHAR: &str = "⚒️"; -/// Matches Solidity comments -static COMMENT_RE: LazyLock = - LazyLock::new(|| Regex::new(r"^\s*(?://.*\s*$)|(/*[\s\S]*?\*/\s*$)").unwrap()); - -/// Matches Ethereum addresses that are not strings -static ADDRESS_RE: LazyLock = LazyLock::new(|| { - Regex::new(r#"(?m)(([^"']\s*)|^)(?P
0x[a-fA-F0-9]{40})((\s*[^"'\w])|$)"#).unwrap() -}); - /// Chisel input dispatcher #[derive(Debug)] -pub struct ChiselDispatcher { - /// A Chisel Session - pub session: ChiselSession, -} - -/// Chisel dispatch result variants -#[derive(Debug)] -pub enum DispatchResult { - /// A Generic Dispatch Success - Success(Option), - /// A Generic Failure - Failure(Option), - /// A successful ChiselCommand Execution - CommandSuccess(Option), - /// A failure to parse a Chisel Command - UnrecognizedCommand(Box), - /// The solang parser failed - SolangParserFailed(Vec), - /// A Command Failed with error message - CommandFailed(String), - /// File IO Error - FileIoError(Box), -} - -impl DispatchResult { - /// Returns `true` if the result is an error. - pub fn is_error(&self) -> bool { - matches!( - self, - Self::Failure(_) - | Self::CommandFailed(_) - | Self::UnrecognizedCommand(_) - | Self::SolangParserFailed(_) - | Self::FileIoError(_) - ) - } -} - -/// A response from the Etherscan API's `getabi` action -#[derive(Debug, Serialize, Deserialize)] -pub struct EtherscanABIResponse { - /// The status of the response - /// "1" = success | "0" = failure - pub status: String, - /// The message supplied by the API - pub message: String, - /// The result returned by the API. Will be `None` if the request failed. - pub result: Option, -} - -/// Used to format ABI parameters into valid solidity function / error / event param syntax -/// TODO: Smarter resolution of storage location, defaults to "memory" for all types -/// that cannot be stored on the stack. -macro_rules! format_param { - ($param:expr) => {{ - let param = $param; - let memory = param.is_complex_type() - || param.selector_type() == "string" - || param.selector_type() == "bytes"; - format!("{}{}", param.ty, if memory { " memory" } else { "" }) - }}; +pub struct ChiselDispatcher { + pub session: ChiselSession, + pub helper: SolidityHelper, + last_result: Option, + /// RPC settings supplied to this invocation, independent of previously loaded sessions. + invocation_rpc: InvocationRpc, } /// Helper function that formats solidity source with the given [FormatterConfig] pub fn format_source(source: &str, config: FormatterConfig) -> eyre::Result { - match forge_fmt::parse(source) { - Ok(parsed) => { - let mut formatted_source = String::default(); - - if forge_fmt::format_to(&mut formatted_source, parsed, config).is_err() { - eyre::bail!("Could not format source!"); - } - - Ok(formatted_source) - } - Err(_) => eyre::bail!("Formatter could not parse source!"), - } + let formatted = forge_fmt::format(source, config).into_result()?; + Ok(formatted) } -impl ChiselDispatcher { +impl ChiselDispatcher { /// Associated public function to create a new Dispatcher instance - pub fn new(config: SessionSourceConfig) -> eyre::Result { - ChiselSession::new(config).map(|session| Self { session }) + pub fn new(config: SessionSourceConfig) -> eyre::Result { + let invocation_rpc = InvocationRpc::capture(&config.foundry_config, &config.evm_opts); + let session = ChiselSession::new(config)?; + Ok(Self { session, helper: Default::default(), last_result: None, invocation_rpc }) } /// Returns the optional ID of the current session. @@ -151,13 +80,13 @@ impl ChiselDispatcher { } /// Returns the [`SessionSource`]. - pub fn source(&self) -> &SessionSource { - &self.session.session_source + pub const fn source(&self) -> &SessionSource { + &self.session.source } /// Returns the [`SessionSource`]. - pub fn source_mut(&mut self) -> &mut SessionSource { - &mut self.session.session_source + pub const fn source_mut(&mut self) -> &mut SessionSource { + &mut self.session.source } fn format_source(&self) -> eyre::Result { @@ -184,836 +113,854 @@ impl ChiselDispatcher { } } - /// Dispatches a [ChiselCommand] - /// - /// ### Takes - /// - /// - A [ChiselCommand] - /// - An array of arguments - /// - /// ### Returns - /// - /// A [DispatchResult] containing feedback on the dispatch's execution. - pub async fn dispatch_command(&mut self, cmd: ChiselCommand, args: &[&str]) -> DispatchResult { - match cmd { - ChiselCommand::Help => { - let all_descriptors = - ChiselCommand::iter().map(CmdDescriptor::from).collect::>(); - DispatchResult::CommandSuccess(Some(format!( - "{}\n{}", - format!("{CHISEL_CHAR} Chisel help\n=============").cyan(), - CmdCategory::iter() - .map(|cat| { - // Get commands in the current category - let cat_cmds = &all_descriptors - .iter() - .filter(|(_, _, c)| { - std::mem::discriminant(c) == std::mem::discriminant(&cat) - }) - .collect::>(); - - // Format the help menu for the current category - format!( - "{}\n{}\n", - cat.magenta(), - cat_cmds - .iter() - .map(|(cmds, desc, _)| format!( - "\t{} - {}", - cmds.iter() - .map(|cmd| format!("!{}", cmd.green())) - .collect::>() - .join(" | "), - desc - )) - .collect::>() - .join("\n") - ) - }) - .collect::>() - .join("\n") - ))) - } - ChiselCommand::Quit => { - // Exit the process with status code `0` for success. - std::process::exit(0); - } - ChiselCommand::Clear => { - self.source_mut().drain_run(); - self.source_mut().drain_global_code(); - self.source_mut().drain_top_level_code(); - DispatchResult::CommandSuccess(Some(String::from("Cleared session!"))) - } - ChiselCommand::Save => { - if args.len() <= 1 { - // If a new name was supplied, overwrite the ID of the current session. - if args.len() == 1 { - // TODO: Should we delete the old cache file if the id of the session - // changes? - self.session.id = Some(args[0].to_owned()); - } - - if let Err(e) = self.session.write() { - return DispatchResult::FileIoError(e.into()); - } - DispatchResult::CommandSuccess(Some(format!( - "Saved session to cache with ID = {}", - self.session.id.as_ref().unwrap() - ))) - } else { - DispatchResult::CommandFailed(Self::make_error(format!( - "Too many arguments supplied: [{}]. Please check command syntax.", - args.join(", ") - ))) - } - } - ChiselCommand::Load => { - if args.len() != 1 { - // Must supply a session ID as the argument. - return DispatchResult::CommandFailed(Self::make_error( - "Must supply a session ID as the argument.", - )); + /// Dispatches an input as a command via [Self::dispatch_command] or as a Solidity snippet. + pub async fn dispatch(&mut self, input: &str) -> Result> { + if let Some(command) = input.strip_prefix(COMMAND_LEADER) { + return match ChiselCommand::parse(command) { + Ok(cmd) => self.dispatch_command(cmd).await, + Err(e) => { + eyre::bail!("unrecognized command: {e}"); } + }; + } - // Use args as the name - let name = args[0]; - // Try to save the current session before loading another - // Don't save an empty session - if !self.source().run_code.is_empty() { - if let Err(e) = self.session.write() { - return DispatchResult::FileIoError(e.into()); - } - let _ = sh_println!("{}", "Saved current session!".green()); - } + self.dispatch_solidity(input).await + } - // Parse the arguments - let new_session = match name { - "latest" => ChiselSession::latest(), - _ => ChiselSession::load(name), - }; - - // WARNING: Overwrites the current session - if let Ok(mut new_session) = new_session { - // Regenerate [IntermediateOutput]; It cannot be serialized. - // - // SAFETY - // Should never panic due to the checks performed when the session was created - // in the first place. - new_session.session_source.build().unwrap(); - - self.session = new_session; - DispatchResult::CommandSuccess(Some(format!( - "Loaded Chisel session! (ID = {})", - self.session.id.as_ref().unwrap() - ))) - } else { - DispatchResult::CommandFailed(Self::make_error("Failed to load session!")) - } + /// Dispatches an input as Solidity without interpreting Chisel commands. + pub(crate) async fn dispatch_solidity(&mut self, mut input: &str) -> Result> { + input = input.trim(); + let (only_trivia, new_input) = preprocess(input, self.last_result.as_deref())?; + input = &*new_input; + + let source = self.source_mut(); + + // If the input is a comment, add it to the run code so we avoid running with empty input + if only_trivia { + debug!(?input, "matched trivia"); + if !input.is_empty() { + source.add_run_code(input); } - ChiselCommand::ListSessions => match ChiselSession::list_sessions() { - Ok(sessions) => DispatchResult::CommandSuccess(Some(format!( - "{}\n{}", - format!("{CHISEL_CHAR} Chisel Sessions").cyan(), - sessions - .iter() - .map(|(time, name)| { - format!("{} - {}", format!("{time:?}").blue(), name) - }) - .collect::>() - .join("\n") - ))), - Err(_) => DispatchResult::CommandFailed(Self::make_error( - "No sessions found. Use the `!save` command to save a session.", - )), - }, - ChiselCommand::Source => match self.format_source() { - Ok(formatted_source) => DispatchResult::CommandSuccess(Some( - SolidityHelper::new().highlight(&formatted_source).into_owned(), - )), - Err(_) => { - DispatchResult::CommandFailed(String::from("Failed to format session source")) - } - }, - ChiselCommand::ClearCache => match ChiselSession::clear_cache() { - Ok(_) => { - self.session.id = None; - DispatchResult::CommandSuccess(Some(String::from("Cleared chisel cache!"))) - } - Err(_) => DispatchResult::CommandFailed(Self::make_error( - "Failed to clear cache! Check file permissions or disk space.", - )), - }, - ChiselCommand::Fork => { - if args.is_empty() || args[0].trim().is_empty() { - self.source_mut().config.evm_opts.fork_url = None; - return DispatchResult::CommandSuccess(Some( - "Now using local environment.".to_string(), - )); - } - if args.len() != 1 { - return DispatchResult::CommandFailed(Self::make_error( - "Must supply a session ID as the argument.", - )); - } - let arg = *args.first().unwrap(); - - // If the argument is an RPC alias designated in the - // `[rpc_endpoints]` section of the `foundry.toml` within - // the pwd, use the URL matched to the key. - let endpoint = if let Some(endpoint) = - self.source_mut().config.foundry_config.rpc_endpoints.get(arg) - { - endpoint.clone() - } else { - RpcEndpointUrl::Env(arg.to_string()).into() - }; - let fork_url = match endpoint.resolve().url() { - Ok(fork_url) => fork_url, - Err(e) => { - return DispatchResult::CommandFailed(Self::make_error(format!( - "\"{}\" ENV Variable not set!", - e.var - ))); - } - }; + return Ok(ControlFlow::Continue(())); + } - // Check validity of URL - if Url::parse(&fork_url).is_err() { - return DispatchResult::CommandFailed(Self::make_error( - "Invalid fork URL! Please provide a valid RPC endpoint URL.", - )); - } + // Create new source with exact input appended and parse + let (new_source, do_execute) = source.clone_with_new_line(input.to_string())?; + + let InspectResult { + control_flow, + formatted_output, + last_result, + clear_last_result, + replay_input, + } = source.inspect(input).await?; + let (new_source, do_execute) = if let Some(input) = replay_input { + source.clone_with_new_line(input)? + } else { + (new_source, do_execute) + }; + if clear_last_result { + self.last_result = None; + } else if let Some(last_result) = last_result { + self.last_result = Some(last_result); + } + if let Some(res) = &formatted_output { + let _ = sh_println!("{res}"); + } + if control_flow.is_break() { + debug!(%input, ?formatted_output, "inspect success"); + return Ok(ControlFlow::Continue(())); + } - // Create success message before moving the fork_url - let success_msg = format!("Set fork URL to {}", &fork_url.yellow()); + if do_execute { + self.execute_and_replace(new_source).await?; + } else { + let out = new_source.build()?; + debug!(%input, ?out, "skipped execute and rebuild source"); + *self.source_mut() = new_source; + } + Ok(ControlFlow::Continue(())) + } - // Update the fork_url inside of the [SessionSourceConfig]'s [EvmOpts] - // field - self.source_mut().config.evm_opts.fork_url = Some(fork_url); + /// Decodes traces in the given [`ChiselResult`]. + // TODO: Add `known_contracts` back in. + pub async fn decode_traces( + session_config: &SessionSourceConfig, + result: &mut ChiselResult, + // known_contracts: &ContractsByArtifact, + ) -> eyre::Result { + let chain_id = session_config.source_chain_id.map(Chain::from); + let resolved_hardfork = session_config.resolved_hardfork; - // Clear the backend so that it is re-instantiated with the new fork - // upon the next execution of the session source. - self.source_mut().config.backend = None; + let builder = CallTraceDecoderBuilder::new() + .with_labels(result.labeled_addresses.clone()) + .with_signature_identifier(SignaturesIdentifier::from_config( + &session_config.foundry_config, + )?) + .with_networks(session_config.foundry_config.networks) + .with_chain_id(chain_id.map(|c| c.id())) + .with_hardfork(resolved_hardfork); + let mut decoder = builder.build(); - DispatchResult::CommandSuccess(Some(success_msg)) - } - ChiselCommand::Traces => { - self.source_mut().config.traces = !self.source_mut().config.traces; - DispatchResult::CommandSuccess(Some(format!( - "{} traces!", - if self.source_mut().config.traces { "Enabled" } else { "Disabled" } - ))) + let mut identifier = + TraceIdentifiers::new().with_external(&session_config.foundry_config, chain_id)?; + if !identifier.is_empty() { + for (_, trace) in &mut result.traces { + decoder.identify(trace, &mut identifier); } - ChiselCommand::Calldata => { - // remove empty space, double quotes, and 0x prefix - let arg = args - .first() - .map(|s| s.trim_matches(|c: char| c.is_whitespace() || c == '"' || c == '\'')) - .map(|s| s.strip_prefix("0x").unwrap_or(s)) - .unwrap_or(""); - - if arg.is_empty() { - self.source_mut().config.calldata = None; - return DispatchResult::CommandSuccess(Some("Calldata cleared.".to_string())); - } + } + Ok(decoder) + } - let calldata = hex::decode(arg); - match calldata { - Ok(calldata) => { - self.source_mut().config.calldata = Some(calldata); - DispatchResult::CommandSuccess(Some(format!( - "Set calldata to '{}'", - arg.yellow() - ))) - } - Err(e) => DispatchResult::CommandFailed(Self::make_error(format!( - "Invalid calldata: {e}" - ))), - } - } - ChiselCommand::MemDump | ChiselCommand::StackDump => { - match self.source_mut().execute().await { - Ok((_, res)) => { - if let Some((stack, mem, _)) = res.state.as_ref() { - if matches!(cmd, ChiselCommand::MemDump) { - // Print memory by word - (0..mem.len()).step_by(32).for_each(|i| { - let _ = sh_println!( - "{}: {}", - format!("[0x{:02x}:0x{:02x}]", i, i + 32).yellow(), - hex::encode_prefixed(&mem[i..i + 32]).cyan() - ); - }); - } else { - // Print all stack items - (0..stack.len()).rev().for_each(|i| { - let _ = sh_println!( - "{}: {}", - format!("[{}]", stack.len() - i - 1).yellow(), - format!("0x{:02x}", stack[i]).cyan() - ); - }); - } - DispatchResult::CommandSuccess(None) - } else { - DispatchResult::CommandFailed(Self::make_error( - "Run function is empty.", - )) - } - } - Err(e) => DispatchResult::CommandFailed(Self::make_error(e.to_string())), - } + /// Display the gathered traces of a REPL execution. + pub async fn show_traces( + decoder: &CallTraceDecoder, + result: &mut ChiselResult, + ) -> eyre::Result<()> { + if result.traces.is_empty() { + return Ok(()); + } + + sh_println!("{}", "Traces:".green())?; + for (kind, trace) in &mut result.traces { + // Display all Setup + Execution traces. + if matches!(kind, TraceKind::Setup | TraceKind::Execution) { + decode_trace_arena(trace, decoder).await; + sh_println!("{}", render_trace_arena(trace))?; } - ChiselCommand::Export => { - // Check if the current session inherits `Script.sol` before exporting - - // Check if the pwd is a foundry project - if !Path::new("foundry.toml").exists() { - return DispatchResult::CommandFailed(Self::make_error( - "Must be in a foundry project to export source to script.", - )); - } + } - // Create "script" dir if it does not already exist. - if !Path::new("script").exists() - && let Err(e) = std::fs::create_dir_all("script") - { - return DispatchResult::CommandFailed(Self::make_error(e.to_string())); - } + Ok(()) + } - match self.format_source() { - Ok(formatted_source) => { - // Write session source to `script/REPL.s.sol` - if let Err(e) = - std::fs::write(PathBuf::from("script/REPL.s.sol"), formatted_source) - { - return DispatchResult::CommandFailed(Self::make_error(e.to_string())); - } - - DispatchResult::CommandSuccess(Some(String::from( - "Exported session source to script/REPL.s.sol!", - ))) + async fn execute_and_replace(&mut self, mut new_source: SessionSource) -> Result<()> { + let mut res = new_source.execute().await?; + let failed = !res.success; + if new_source.config.traces || failed { + if let Ok(decoder) = Self::decode_traces(&new_source.config, &mut res).await { + Self::show_traces(&decoder, &mut res).await?; + + // Show console logs, if there are any + let decoded_logs = decode_console_logs(&res.logs); + if !decoded_logs.is_empty() { + let _ = sh_println!("{}", "Logs:".green()); + for log in decoded_logs { + let _ = sh_println!(" {log}"); } - Err(_) => DispatchResult::CommandFailed(String::from( - "Failed to format session source", - )), } } - ChiselCommand::Fetch => { - if args.len() != 2 && args.len() != 3 { - return DispatchResult::CommandFailed(Self::make_error( - "Incorrect number of arguments supplied. Expected:
(optional, defaults to 1 if not provided).", - )); - } - let request_url = format!( - "https://api.etherscan.io/v2/api?chainId={}&module=contract&action=getabi&address={}{}", - args.get(2).unwrap_or(&"1"), - args[0], - if let Some(api_key) = - self.source().config.foundry_config.etherscan_api_key.as_ref() - { - format!("&apikey={api_key}") - } else { - String::default() - } - ); - - // TODO: Not the cleanest method of building a solidity interface from - // the ABI, but does the trick. Might want to pull this logic elsewhere - // and/or refactor at some point. - match reqwest::get(&request_url).await { - Ok(response) => { - let json = response.json::().await.unwrap(); - if json.status == "1" && json.result.is_some() { - let abi = json.result.unwrap(); - let abi: serde_json::Result = serde_json::from_str(&abi); - if let Ok(abi) = abi { - let mut interface = format!( - "// Interface of {}\ninterface {} {{\n", - args[0], args[1] - ); - - // Add error definitions - abi.errors().for_each(|err| { - interface.push_str(&format!( - "\terror {}({});\n", - err.name, - err.inputs - .iter() - .map(|input| { - let mut param_type = &input.ty; - // If complex type then add the name of custom type. - // see . - if input.is_complex_type() - && let Some( - InternalType::Enum { contract: _, ty } - | InternalType::Struct { contract: _, ty } - | InternalType::Other { contract: _, ty }, - ) = &input.internal_type - { - param_type = ty; - } - format!("{} {}", param_type, input.name) - }) - .collect::>() - .join(",") - )); - }); - // Add event definitions - abi.events().for_each(|event| { - interface.push_str(&format!( - "\tevent {}({});\n", - event.name, - event - .inputs - .iter() - .map(|input| { - let mut formatted = input.ty.to_string(); - if input.indexed { - formatted.push_str(" indexed"); - } - formatted - }) - .collect::>() - .join(",") - )); - }); - // Add function definitions - abi.functions().for_each(|func| { - interface.push_str(&format!( - "\tfunction {}({}) external{}{};\n", - func.name, - func.inputs - .iter() - .map(|input| format_param!(input)) - .collect::>() - .join(","), - match func.state_mutability { - alloy_json_abi::StateMutability::Pure => " pure", - alloy_json_abi::StateMutability::View => " view", - alloy_json_abi::StateMutability::Payable => " payable", - _ => "", - }, - if func.outputs.is_empty() { - String::default() - } else { - format!( - " returns ({})", - func.outputs - .iter() - .map(|output| format_param!(output)) - .collect::>() - .join(",") - ) - } - )); - }); - // Close interface definition - interface.push('}'); - - // Add the interface to the source outright - no need to verify - // syntax via compilation and/or - // parsing. - self.source_mut().with_global_code(&interface); - - DispatchResult::CommandSuccess(Some(format!( - "Added {}'s interface to source as `{}`", - args[0], args[1] - ))) - } else { - DispatchResult::CommandFailed(Self::make_error( - "Contract is not verified!", - )) - } - } else if let Some(error_msg) = json.result { - DispatchResult::CommandFailed(Self::make_error(format!( - "Could not fetch interface - \"{error_msg}\"" - ))) - } else { - DispatchResult::CommandFailed(Self::make_error(format!( - "Could not fetch interface - \"{}\"", - json.message - ))) - } - } - Err(e) => DispatchResult::CommandFailed(Self::make_error(format!( - "Failed to communicate with Etherscan API: {e}" - ))), - } + if failed { + // If the contract execution failed, continue on without + // updating the source. + eyre::bail!("Failed to execute edited contract!"); } - ChiselCommand::Exec => { - if args.is_empty() { - return DispatchResult::CommandFailed(Self::make_error( - "No command supplied! Please provide a valid command after '!'.", - )); - } + } - let mut cmd = Command::new(args[0]); - if args.len() > 1 { - cmd.args(args[1..].iter().copied()); - } + // the code could be compiled, save it + *self.source_mut() = new_source; - match cmd.output() { - Ok(output) => { - std::io::stdout().write_all(&output.stdout).unwrap(); - std::io::stdout().write_all(&output.stderr).unwrap(); - DispatchResult::CommandSuccess(None) - } - Err(e) => DispatchResult::CommandFailed(e.to_string()), - } - } - ChiselCommand::Edit => { - // create a temp file with the content of the run code - let mut temp_file_path = std::env::temp_dir(); - temp_file_path.push("chisel-tmp.sol"); - let result = std::fs::File::create(&temp_file_path) - .map(|mut file| file.write_all(self.source().run_code.as_bytes())); - if let Err(e) = result { - return DispatchResult::CommandFailed(format!( - "Could not write to a temporary file: {e}" - )); - } + Ok(()) + } +} - // open the temp file with the editor - let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vim".to_string()); - let mut cmd = Command::new(editor); - cmd.arg(&temp_file_path); - - match cmd.status() { - Ok(status) => { - if !status.success() { - if let Some(status_code) = status.code() { - return DispatchResult::CommandFailed(format!( - "Editor exited with status {status_code}" - )); - } else { - return DispatchResult::CommandFailed( - "Editor exited without a status code".to_string(), - ); - } - } - } - Err(_) => { - return DispatchResult::CommandFailed( - "Editor exited without a status code".to_string(), - ); - } - } +/// [`ChiselCommand`] implementations. +impl ChiselDispatcher { + /// Dispatches a [`ChiselCommand`]. + pub async fn dispatch_command(&mut self, cmd: ChiselCommand) -> Result> { + match cmd { + ChiselCommand::Quit => Ok(ControlFlow::Break(())), + cmd => self.dispatch_command_impl(cmd).await.map(ControlFlow::Continue), + } + } - let mut new_session_source = self.source().clone(); - if let Ok(edited_code) = std::fs::read_to_string(temp_file_path) { - new_session_source.drain_run(); - new_session_source.with_run_code(&edited_code); - } else { - return DispatchResult::CommandFailed( - "Could not read the edited file".to_string(), - ); - } + async fn dispatch_command_impl(&mut self, cmd: ChiselCommand) -> Result<()> { + match cmd { + ChiselCommand::Help => self.show_help(), + ChiselCommand::Quit => unreachable!(), + ChiselCommand::Clear => self.clear_source(), + ChiselCommand::Save { id } => self.save_session(id), + ChiselCommand::Load { id } => self.load_session(&id), + ChiselCommand::ListSessions => self.list_sessions(), + ChiselCommand::Source => self.show_source(), + ChiselCommand::ClearCache => self.clear_cache(), + ChiselCommand::Fork { url } => self.set_fork(url).await, + ChiselCommand::Traces => self.toggle_traces(), + ChiselCommand::Calldata { data } => self.set_calldata(data.as_deref()), + ChiselCommand::MemDump => self.show_mem_dump().await, + ChiselCommand::StackDump => self.show_stack_dump().await, + ChiselCommand::Export => self.export(), + ChiselCommand::Fetch { addr, name } => self.fetch_interface(addr, name).await, + ChiselCommand::Exec { command, args } => self.exec_command(command, args), + ChiselCommand::Edit => self.edit_session().await, + ChiselCommand::RawStack { var } => self.show_raw_stack(var).await, + } + } - // if the editor exited successfully, try to compile the new code - match new_session_source.execute().await { - Ok((_, mut res)) => { - let failed = !res.success; - if new_session_source.config.traces || failed { - if let Ok(decoder) = - Self::decode_traces(&new_session_source.config, &mut res).await - { - if let Err(e) = Self::show_traces(&decoder, &mut res).await { - return DispatchResult::CommandFailed(e.to_string()); - }; - - // Show console logs, if there are any - let decoded_logs = decode_console_logs(&res.logs); - if !decoded_logs.is_empty() { - let _ = sh_println!("{}", "Logs:".green()); - for log in decoded_logs { - let _ = sh_println!(" {log}"); - } - } - } - - if failed { - // If the contract execution failed, continue on without - // updating the source. - return DispatchResult::CommandFailed(Self::make_error( - "Failed to execute edited contract!", - )); - } - } - - // the code could be compiled, save it - *self.source_mut() = new_session_source; - DispatchResult::CommandSuccess(Some(String::from( - "Successfully edited `run()` function's body!", - ))) - } - Err(_) => { - DispatchResult::CommandFailed("The code could not be compiled".to_string()) - } - } - } - ChiselCommand::RawStack => { - let len = args.len(); - if len != 1 { - let msg = match len { - 0 => "No variable supplied!", - _ => "!rawstack only takes one argument.", - }; - return DispatchResult::CommandFailed(Self::make_error(msg)); - } + pub(crate) fn show_help(&self) -> Result<()> { + sh_println!("{}", ChiselCommand::format_help()) + } - // Store the variable that we want to inspect - let to_inspect = args.first().unwrap(); + pub(crate) fn clear_source(&mut self) -> Result<()> { + self.source_mut().clear(); + self.last_result = None; + sh_println!("Cleared session!") + } - // Get a mutable reference to the session source - let source = self.source_mut(); + pub(crate) fn save_session(&mut self, id: Option) -> Result<()> { + let previous_id = self.session.id.clone(); - // Copy the variable's stack contents into a bytes32 variable without updating - // the current session source. - let line = format!("bytes32 __raw__; assembly {{ __raw__ := {to_inspect} }}"); - if let Ok((new_source, _)) = source.clone_with_new_line(line) { - match new_source.inspect("__raw__").await { - Ok((_, Some(res))) => return DispatchResult::CommandSuccess(Some(res)), - Ok((_, None)) => {} - Err(e) => return DispatchResult::CommandFailed(Self::make_error(e)), - } - } + // If a new name was supplied, overwrite the ID of the current session. + if let Some(id) = id { + self.session.id = Some(id); + } + + let new_cache_file = match self.session.write() { + Ok(path) => path, + Err(error) => { + self.session.id = previous_id; + return Err(error); + } + }; - DispatchResult::CommandFailed( - "Variable must exist within `run()` function.".to_string(), - ) + if let (Some(previous_id), Some(current_id)) = (previous_id, self.session.id.as_deref()) + && previous_id != current_id + { + let old_cache_file = + format!("{}chisel-{previous_id}.json", ChiselSession::::cache_dir()?); + let same_cache_file = std::fs::canonicalize(&old_cache_file).ok() + == std::fs::canonicalize(&new_cache_file).ok(); + if !same_cache_file { + ChiselSession::::remove_cached_session(&previous_id)?; } } + + sh_println!("Saved session to cache with ID = {}", self.session.id.as_ref().unwrap()) } - /// Dispatches an input as a command via [Self::dispatch_command] or as a Solidity snippet. - pub async fn dispatch(&mut self, mut input: &str) -> DispatchResult { - // Check if the input is a builtin command. - // Commands are denoted with a `!` leading character. - if input.starts_with(COMMAND_LEADER) { - let split: Vec<&str> = input.split_whitespace().collect(); - let raw_cmd = &split[0][1..]; - - return match raw_cmd.parse::() { - Ok(cmd) => self.dispatch_command(cmd, &split[1..]).await, - Err(e) => DispatchResult::UnrecognizedCommand(e), - }; + pub(crate) fn load_session(&mut self, id: &str) -> Result<()> { + // Try to save the current session before loading another. + // Don't save an empty session. + if !self.source().run_code.is_empty() { + self.session.write()?; + sh_println!("{}", "Saved current session!".green())?; } - if input.trim().is_empty() { - debug!("empty dispatch input"); - return DispatchResult::Success(None); + + let executor_builder = self.session.source.config.executor_builder.clone(); + let mut new_session = match id { + "latest" => ChiselSession::::latest(executor_builder), + id => ChiselSession::::load(id, executor_builder), } + .wrap_err("failed to load session")?; - // Get a mutable reference to the session source - let source = self.source_mut(); + ensure_loaded_session_network_matches( + &self.session.source.config.foundry_config, + &new_session.source.config.foundry_config, + id, + )?; + new_session.source.config.foundry_config.force = + self.session.source.config.foundry_config.force; + new_session.source.config.restore_credentials(&self.invocation_rpc)?; + new_session.source.config.initialize_local_context(); + new_session.source.build()?; + self.session = new_session; + self.last_result = None; + sh_println!( + "Loaded Chisel session! (ID = {})", + self.session.id.as_deref().unwrap_or("") + ) + } - // If the input is a comment, add it to the run code so we avoid running with empty input - if COMMENT_RE.is_match(input) { - debug!(%input, "matched comment"); - source.with_run_code(input); - return DispatchResult::Success(None); - } - - // If there is an address (or multiple addresses) in the input, ensure that they are - // encoded with a valid checksum per EIP-55. - let mut heap_input = input.to_string(); - ADDRESS_RE.captures_iter(input).for_each(|m| { - // Convert the match to a string slice - let match_str = m.name("address").expect("exists").as_str(); - - // We can always safely unwrap here due to the regex matching. - let addr: Address = match_str.parse().expect("Valid address regex"); - // Replace all occurrences of the address with a checksummed version - heap_input = heap_input.replace(match_str, &addr.to_string()); - }); - // Replace the old input with the formatted input. - input = &heap_input; + pub(crate) fn list_sessions(&self) -> Result<()> { + let sessions = ChiselSession::::get_sessions()?; + if sessions.is_empty() { + eyre::bail!("No sessions found. Use the `!save` command to save a session."); + } + sh_println!( + "{}\n{}", + format!("{CHISEL_CHAR} Chisel Sessions").cyan(), + sessions + .iter() + .map(|(time, name)| format!("{} - {}", format!("{time:?}").blue(), name)) + .collect::>() + .join("\n") + ) + } - // Create new source with exact input appended and parse - let (mut new_source, do_execute) = match source.clone_with_new_line(input.to_string()) { - Ok(new) => new, - Err(e) => { - return DispatchResult::CommandFailed(Self::make_error(format!( - "Failed to parse input! {e}" - ))); - } + pub(crate) fn show_source(&self) -> Result<()> { + let formatted = self.format_source().wrap_err("failed to format session source")?; + let highlighted = self.helper.highlight(&formatted); + sh_println!("{highlighted}") + } + + pub(crate) fn clear_cache(&mut self) -> Result<()> { + ChiselSession::::clear_cache().wrap_err("failed to clear cache")?; + self.session.id = None; + sh_println!("Cleared chisel cache!") + } + + pub(crate) async fn set_fork(&mut self, url: Option) -> Result<()> { + self.source_mut().config.initialize_local_context(); + + let Some(url) = url else { + return self.clear_fork(); }; - // TODO: Cloning / parsing the session source twice on non-inspected inputs kinda sucks. - // Should change up how this works. - match source.inspect(input).await { - // Continue and print - Ok((true, Some(res))) => { - let _ = sh_println!("{res}"); - } - Ok((true, None)) => {} - // Return successfully - Ok((false, res)) => { - debug!(%input, ?res, "inspect success"); - return DispatchResult::Success(res); - } + // If the argument is an RPC alias designated in the + // `[rpc_endpoints]` section of the `foundry.toml` within + // the pwd, use the URL matched to the key. + let endpoint = if let Some(endpoint) = + self.source_mut().config.foundry_config.rpc_endpoints.get(&url) + { + endpoint.clone() + } else { + RpcEndpointUrl::Env(url).into() + }; + let endpoint = endpoint.resolve(); + let fork_url = endpoint.url()?; + let fork_headers = + endpoint.auth.transpose()?.map(|auth| vec![format!("Authorization: {auth}")]); - // Return with the error - Err(e) => return DispatchResult::CommandFailed(Self::make_error(e)), + if let Err(e) = Url::parse(&fork_url) { + eyre::bail!("invalid fork URL: {e}"); } - if do_execute { - match new_source.execute().await { - Ok((_, mut res)) => { - let failed = !res.success; - - // If traces are enabled or there was an error in execution, show the execution - // traces. - if (new_source.config.traces || failed) - && let Ok(decoder) = Self::decode_traces(&new_source.config, &mut res).await - { - if let Err(e) = Self::show_traces(&decoder, &mut res).await { - return DispatchResult::CommandFailed(e.to_string()); - }; - - // Show console logs, if there are any - let decoded_logs = decode_console_logs(&res.logs); - if !decoded_logs.is_empty() { - let _ = sh_println!("{}", "Logs:".green()); - for log in decoded_logs { - let _ = sh_println!(" {log}"); - } - } - - // If the contract execution failed, continue on without adding the new - // line to the source. - if failed { - return DispatchResult::Failure(Some(Self::make_error( - "Failed to execute REPL contract!", - ))); - } - } + let mut fork_opts = self.source().config.evm_opts.clone(); + fork_opts.fork_url = Some(fork_url.clone()); + fork_opts.fork_headers = fork_headers; + fork_opts.fork_block_number = None; + fork_opts.fork_block_number_is_inferred = false; + let explicit_network = + fork_opts.networks.has_network_selection() && !fork_opts.fork_network_is_inferred; + let identity = fork_opts.discover_fork_endpoint().await?; + let target = identity.network; + let current_opts = &self.source().config.evm_opts; + let current = network_variant(current_opts.networks); + ensure_fork_network_matches(current, target)?; + + let networks = if explicit_network { + current_opts.networks + } else { + current_opts.networks.with_rpc_profile(identity.network_profile) + }; + if fork_opts.env.chain_id.is_none() || fork_opts.fork_chain_id_is_inferred { + fork_opts.env.chain_id = Some(identity.execution_chain_id); + fork_opts.fork_chain_id_is_inferred = true; + } + fork_opts.networks = networks; + fork_opts.fork_endpoint = Some(identity.clone()); + fork_opts.fork_network_is_inferred = !explicit_network; + fork_opts.pin_fork_block().await?; + self.invocation_rpc.set_fork(fork_url.clone(), fork_opts.fork_headers.clone()); + let chain_id_is_inferred = fork_opts.fork_chain_id_is_inferred; + let source = self.source_mut(); + source.config.evm_opts = fork_opts; + source.config.fork_network_is_inferred = !explicit_network; + source.config.fork_chain_id_is_inferred = chain_id_is_inferred; + source.config.foundry_config.networks = networks; + source.config.foundry_config.chain = Some(Chain::from(identity.source_chain_id)); + source.config.resolved_hardfork = None; + source.config.source_chain_id = None; + source.config.fork_url_required = false; + // Clear the backend so that it is re-instantiated with the new fork + // upon the next execution of the session source. + source.config.cached_backend = None; + + sh_println!("Set fork URL to {}", fork_url.yellow())?; + + Ok(()) + } - // Replace the old session source with the new version - *self.source_mut() = new_source; + fn clear_fork(&mut self) -> Result<()> { + let current = network_variant(self.source().config.evm_opts.networks); + let local_networks = + self.source().config.local_networks.unwrap_or(self.source().config.evm_opts.networks); + let target = network_variant(local_networks); + ensure_fork_network_matches(current, target)?; - DispatchResult::Success(None) - } - Err(e) => DispatchResult::Failure(Some(e.to_string())), + let source = self.source_mut(); + source.config.evm_opts.fork_url = None; + source.config.fork_url_required = false; + source.config.evm_opts.fork_block_number = None; + source.config.evm_opts.fork_block_number_is_inferred = false; + source.config.evm_opts.networks = local_networks; + source.config.evm_opts.env.chain_id = source.config.local_chain_id; + source.config.evm_opts.fork_network_is_inferred = false; + source.config.evm_opts.fork_chain_id_is_inferred = false; + source.config.fork_network_is_inferred = false; + source.config.fork_chain_id_is_inferred = false; + source.config.foundry_config.networks = local_networks; + source.config.foundry_config.chain = source.config.local_chain_id.map(Chain::from); + source.config.resolved_hardfork = None; + source.config.source_chain_id = None; + source.config.cached_backend = None; + sh_println!("Now using local environment.") + } + + pub(crate) fn toggle_traces(&mut self) -> Result<()> { + let t = &mut self.source_mut().config.traces; + *t = !*t; + sh_println!("{} traces!", if *t { "Enabled" } else { "Disabled" }) + } + + pub(crate) fn set_calldata(&mut self, data: Option<&str>) -> Result<()> { + // remove empty space, double quotes, and 0x prefix + let arg = data + .map(|s| s.trim_matches(|c: char| c.is_whitespace() || c == '"' || c == '\'')) + .map(|s| s.strip_prefix("0x").unwrap_or(s)) + .unwrap_or(""); + + if arg.is_empty() { + self.source_mut().config.calldata = None; + sh_println!("Calldata cleared.")?; + return Ok(()); + } + + let calldata = hex::decode(arg); + match calldata { + Ok(calldata) => { + self.source_mut().config.calldata = Some(calldata); + sh_println!("Set calldata to '{}'", arg.yellow()) } - } else { - match new_source.build() { - Ok(out) => { - debug!(%input, ?out, "skipped execute and rebuild source"); - *self.source_mut() = new_source; - DispatchResult::Success(None) - } - Err(e) => DispatchResult::Failure(Some(e.to_string())), + Err(e) => { + eyre::bail!("Invalid calldata: {e}"); } } } - /// Decodes traces in the [ChiselResult] - /// TODO: Add `known_contracts` back in. - /// - /// ### Takes - /// - /// - A reference to a [SessionSourceConfig] - /// - A mutable reference to a [ChiselResult] - /// - /// ### Returns - /// - /// Optionally, a [CallTraceDecoder] - pub async fn decode_traces( - session_config: &SessionSourceConfig, - result: &mut ChiselResult, - // known_contracts: &ContractsByArtifact, - ) -> eyre::Result { - let mut decoder = CallTraceDecoderBuilder::new() - .with_labels(result.labeled_addresses.clone()) - .with_signature_identifier(SignaturesIdentifier::from_config( - &session_config.foundry_config, - )?) - .build(); - - let mut identifier = TraceIdentifiers::new().with_etherscan( - &session_config.foundry_config, - session_config.evm_opts.get_remote_chain_id().await, - )?; - if !identifier.is_empty() { - for (_, trace) in &mut result.traces { - decoder.identify(trace, &mut identifier); - } + pub(crate) async fn show_mem_dump(&mut self) -> Result<()> { + let res = self.source_mut().execute().await?; + let Some((_, mem)) = res.state.as_ref() else { + eyre::bail!("Run function is empty."); + }; + for i in (0..mem.len()).step_by(32) { + let _ = sh_println!( + "{}: {}", + format!("[0x{:02x}:0x{:02x}]", i, i + 32).yellow(), + hex::encode_prefixed(&mem[i..i + 32]).cyan() + ); } - Ok(decoder) + Ok(()) } - /// Display the gathered traces of a REPL execution. - /// - /// ### Takes - /// - /// - A reference to a [CallTraceDecoder] - /// - A mutable reference to a [ChiselResult] - /// - /// ### Returns - /// - /// Optionally, a unit type signifying a successful result. - pub async fn show_traces( - decoder: &CallTraceDecoder, - result: &mut ChiselResult, - ) -> eyre::Result<()> { - if result.traces.is_empty() { - eyre::bail!( - "Unexpected error: No traces gathered. Please report this as a bug: https://github.com/foundry-rs/foundry/issues/new?assignees=&labels=T-bug&template=BUG-FORM.yml" + pub(crate) async fn show_stack_dump(&mut self) -> Result<()> { + let res = self.source_mut().execute().await?; + let Some((stack, _)) = res.state.as_ref() else { + eyre::bail!("Run function is empty."); + }; + for i in (0..stack.len()).rev() { + let _ = sh_println!( + "{}: {}", + format!("[{}]", stack.len() - i - 1).yellow(), + format!("0x{:02x}", stack[i]).cyan() ); } + Ok(()) + } - sh_println!("{}", "Traces:".green())?; - for (kind, trace) in &mut result.traces { - // Display all Setup + Execution traces. - if matches!(kind, TraceKind::Setup | TraceKind::Execution) { - decode_trace_arena(trace, decoder).await; - sh_println!("{}", render_trace_arena(trace))?; - } + pub(crate) fn export(&self) -> Result<()> { + // Check if the pwd is a foundry project + if !Path::new("foundry.toml").exists() { + eyre::bail!("Must be in a foundry project to export source to script."); + } + + // Create "script" dir if it does not already exist. + if !Path::new("script").exists() { + std::fs::create_dir_all("script")?; } + let formatted_source = self.format_source()?; + std::fs::write(PathBuf::from("script/REPL.s.sol"), formatted_source)?; + sh_println!("Exported session source to script/REPL.s.sol!") + } + + /// Fetches an interface from Etherscan + pub(crate) async fn fetch_interface(&mut self, address: Address, name: String) -> Result<()> { + let abis = fetch_abi_from_etherscan(address, &self.source().config.foundry_config, false) + .await + .wrap_err("Failed to fetch ABI from Etherscan")?; + let (abi, _) = abis + .into_iter() + .next() + .ok_or_else(|| eyre::eyre!("No ABI found for address {address} on Etherscan"))?; + let code = forge_fmt::format(&abi.to_sol(&name, None), FormatterConfig::default()) + .into_result()?; + self.source_mut().add_global_code(&code); + sh_println!("Added {address}'s interface to source as `{name}`") + } + + pub(crate) fn exec_command(&self, command: String, args: Vec) -> Result<()> { + let mut cmd = Command::new(command); + cmd.args(args); + let _ = cmd.status()?; Ok(()) } - /// Format a type that implements [std::fmt::Display] as a chisel error string. - /// - /// ### Takes - /// - /// A generic type implementing the [std::fmt::Display] trait. - /// - /// ### Returns - /// - /// A formatted error [String]. - pub fn make_error(msg: T) -> String { - format!("{}", msg.red()) + pub(crate) async fn edit_session(&mut self) -> Result<()> { + // create a temp file with the content of the run code + let mut tmp = Builder::new() + .prefix("chisel-") + .suffix(".sol") + .tempfile() + .wrap_err("Could not create temporary file")?; + tmp.as_file_mut() + .write_all(self.source().run_code.as_bytes()) + .wrap_err("Could not write to temporary file")?; + + // open the temp file with the editor + let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vim".to_string()); + let mut cmd = Command::new(editor); + cmd.arg(tmp.path()); + let st = cmd.status()?; + if !st.success() { + eyre::bail!("Editor exited with {st}"); + } + + let edited_code = std::fs::read_to_string(tmp.path())?; + let mut new_source = self.source().clone(); + new_source.clear_run(); + new_source.add_run_code(&edited_code); + + // if the editor exited successfully, try to compile the new code + self.execute_and_replace(new_source).await?; + sh_println!("Successfully edited `run()` function's body!") } + + pub(crate) async fn show_raw_stack(&mut self, var: String) -> Result<()> { + let source = self.source_mut(); + let line = format!("bytes32 __raw__; assembly {{ __raw__ := {var} }}"); + if let Ok((new_source, _)) = source.clone_with_new_line(line) + && let InspectResult { formatted_output: Some(res), .. } = + new_source.inspect("__raw__").await? + { + sh_println!("{res}")?; + return Ok(()); + } + + eyre::bail!("Variable must exist within `run()` function."); + } +} + +fn config_network_name(config: &Config) -> &'static str { + config.networks.active_network_name().unwrap_or("ethereum") +} + +fn network_variant(networks: NetworkConfigs) -> NetworkVariant { + networks.resolved_network().unwrap_or_default() +} + +fn ensure_fork_network_matches(current: NetworkVariant, target: NetworkVariant) -> Result<()> { + if current != target { + eyre::bail!( + "cannot switch this Chisel session from network `{current}` to `{target}`. Restart \ + Chisel with `--network {target}` or a fork URL for that network.", + ); + } + Ok(()) +} + +fn ensure_loaded_session_network_matches( + current: &Config, + loaded: &Config, + id: &str, +) -> Result<()> { + let current_network = config_network_name(current); + let loaded_network = config_network_name(loaded); + if current_network != loaded_network { + eyre::bail!( + "Chisel session `{id}` was saved for network `{loaded_network}`, but the current \ + network is `{current_network}`. Rerun with `--network {loaded_network}` to load it.", + ); + } + Ok(()) +} + +/// Expands the previous result, checksums addresses, and returns whether the input only contained +/// trivia (comments, whitespace). +fn preprocess<'a>(input: &'a str, last_result: Option<&str>) -> Result<(bool, Cow<'a, str>)> { + let mut only_trivia = true; + let mut replacements = Vec::new(); + for (pos, token) in solar::parse::Cursor::new(input).with_position() { + use RawTokenKind::{BlockComment, LineComment, Literal, Whitespace}; + + match token.kind { + Whitespace | LineComment { .. } => continue, + BlockComment { terminated, .. } if !terminated => { + eyre::bail!("unterminated block comment"); + } + BlockComment { .. } => continue, + _ => {} + } + only_trivia = false; + + let range = pos..pos + token.len as usize; + if &input[range.clone()] == "$_" { + let last_result = last_result.ok_or_else(|| eyre::eyre!("no previous result"))?; + replacements.push((range, format!("({last_result})"))); + continue; + } + + // Ensure that addresses are correctly checksummed. + if let Literal { kind: RawLiteralKind::Int { base: Base::Hexadecimal, .. } } = token.kind + && token.len == 42 + && let Ok(addr) = input[range.clone()].parse::
() + { + replacements.push((range, addr.to_checksum_buffer(None).to_string())); + } + } + + if replacements.is_empty() { + Ok((only_trivia, Cow::Borrowed(input))) + } else { + let mut new_input = input.to_string(); + for (range, replacement) in replacements.into_iter().rev() { + new_input.replace_range(range, &replacement); + } + Ok((only_trivia, Cow::Owned(new_input))) + } +} + +/// Displays cached source without restoring credentials or building an execution environment. +pub(crate) fn view_session(id: &str) -> Result<()> { + // Source rendering is independent of the session's execution network. + let executor_builder = ExecutorBuilder::::new(); + let session = match id { + "latest" => ChiselSession::latest(executor_builder), + id => ChiselSession::load(id, executor_builder), + } + .wrap_err("failed to load session")?; + sh_println!("Loaded Chisel session! (ID = {})", session.id.as_deref().unwrap_or(""))?; + let formatted = + format_source(&session.source.to_repl_source(), session.source.config.foundry_config.fmt) + .wrap_err("failed to format session source")?; + sh_println!("{}", SolidityHelper::default().highlight(&formatted)) } #[cfg(test)] mod tests { use super::*; + use foundry_evm::opts::EvmOpts; + + fn config_with_network(network: Option<&str>) -> Config { + let mut config = Config::default(); + if let Some(network) = network { + config.networks = serde_json::from_value(serde_json::json!({ + "network": network, + "celo": false, + "bypass_prevrandao": false, + })) + .unwrap(); + } + config + } + + #[test] + fn config_network_name_defaults_to_ethereum() { + assert_eq!(config_network_name(&Config::default()), "ethereum"); + } + + #[test] + fn ensure_fork_network_matches_accepts_same_family() { + ensure_fork_network_matches(NetworkVariant::Ethereum, NetworkVariant::Ethereum).unwrap(); + ensure_fork_network_matches(NetworkVariant::Tempo, NetworkVariant::Tempo).unwrap(); + } + + #[tokio::test(flavor = "multi_thread")] + async fn setting_fork_preserves_explicit_celo_context() { + let (_api, handle) = anvil::spawn(anvil::NodeConfig::test()).await; + let networks = NetworkConfigs::with_celo(); + let config = SessionSourceConfig:: { + foundry_config: Config { + networks, + solc: Some(foundry_config::SolcReq::Version(semver::Version::new(0, 8, 29))), + ..Default::default() + }, + evm_opts: EvmOpts { networks, ..Default::default() }, + local_networks: Some(networks), + ..Default::default() + }; + let mut dispatcher = ChiselDispatcher::new(config).unwrap(); + + dispatcher.set_fork(Some(handle.http_endpoint())).await.unwrap(); + assert!(dispatcher.source().config.evm_opts.networks.is_celo()); + assert!(!dispatcher.source().config.evm_opts.fork_network_is_inferred); + let encoded = serde_json::to_string(&dispatcher.source().config).unwrap(); + let mut restored = + serde_json::from_str::>(&encoded).unwrap(); + restored.restore_credentials(&dispatcher.invocation_rpc).unwrap(); + assert_eq!(restored.evm_opts.fork_url, Some(handle.http_endpoint())); + + dispatcher.clear_fork().unwrap(); + assert!(dispatcher.source().config.evm_opts.networks.is_celo()); + } + + #[test] + #[cfg(feature = "monad")] + fn ensure_fork_network_matches_rejects_cross_family_change() { + let err = ensure_fork_network_matches(NetworkVariant::Ethereum, NetworkVariant::Monad) + .unwrap_err(); + assert_eq!( + err.to_string(), + "cannot switch this Chisel session from network `ethereum` to `monad`. Restart Chisel \ + with `--network monad` or a fork URL for that network." + ); + } #[test] - fn test_comment_regex() { - assert!(COMMENT_RE.is_match("// line comment")); - assert!(COMMENT_RE.is_match(" \n// line \tcomment\n")); - assert!(!COMMENT_RE.is_match("// line \ncomment")); + #[cfg(feature = "monad")] + fn clearing_startup_fork_preserves_inferred_monad_context() { + let networks = NetworkConfigs::with_monad(); + let evm_opts = EvmOpts { + fork_url: Some("http://localhost:8545".to_string()), + networks, + env: foundry_evm::opts::Env { chain_id: Some(143), ..Default::default() }, + ..Default::default() + }; + let config = SessionSourceConfig:: { + foundry_config: Config { + solc: Some(foundry_config::SolcReq::Version(semver::Version::new(0, 8, 29))), + networks, + chain: Some(Chain::from(143u64)), + ..Default::default() + }, + evm_opts, + local_networks: Some(networks), + local_chain_id: Some(143), + ..Default::default() + }; + let mut dispatcher = ChiselDispatcher::new(config).unwrap(); - assert!(COMMENT_RE.is_match("/* block comment */")); - assert!(COMMENT_RE.is_match(" \t\n /* block \n \t comment */\n")); - assert!(!COMMENT_RE.is_match("/* block \n \t comment */\nwith \tother")); + dispatcher.clear_fork().unwrap(); + + let config = &dispatcher.source().config; + assert!(config.evm_opts.fork_url.is_none()); + assert!(config.evm_opts.networks.is_monad()); + assert_eq!(config.evm_opts.env.chain_id, Some(143)); + assert!(config.foundry_config.networks.is_monad()); + assert_eq!(config.foundry_config.chain.map(|chain| chain.id()), Some(143)); } #[test] - fn test_address_regex() { - assert!(ADDRESS_RE.is_match("0xe5f3aF50FE5d0bF402a3C6F55ccC47d4307922d4")); - assert!(ADDRESS_RE.is_match(" 0xe5f3aF50FE5d0bF402a3C6F55ccC47d4307922d4 ")); - assert!(ADDRESS_RE.is_match("0xe5f3aF50FE5d0bF402a3C6F55ccC47d4307922d4,")); - assert!(ADDRESS_RE.is_match("(0xe5f3aF50FE5d0bF402a3C6F55ccC47d4307922d4)")); - assert!(!ADDRESS_RE.is_match("0xe5f3aF50FE5d0bF402a3C6F55ccC47d4307922d4aaa")); - assert!(!ADDRESS_RE.is_match("'0xe5f3aF50FE5d0bF402a3C6F55ccC47d4307922d4'")); - assert!(!ADDRESS_RE.is_match("' 0xe5f3aF50FE5d0bF402a3C6F55ccC47d4307922d4'")); - assert!(!ADDRESS_RE.is_match("'0xe5f3aF50FE5d0bF402a3C6F55ccC47d4307922d4'")); + fn ensure_loaded_session_network_matches_rejects_different_network() { + let current = config_with_network(None); + let loaded = config_with_network(Some("tempo")); + + let err = ensure_loaded_session_network_matches(¤t, &loaded, "42").unwrap_err(); + assert_eq!( + err.to_string(), + "Chisel session `42` was saved for network `tempo`, but the current network is \ + `ethereum`. Rerun with `--network tempo` to load it." + ); + } + + #[test] + #[cfg(feature = "monad")] + fn ensure_loaded_session_network_matches_rejects_monad_on_default_network() { + let current = config_with_network(None); + let loaded = config_with_network(Some("monad")); + + let err = ensure_loaded_session_network_matches(¤t, &loaded, "43").unwrap_err(); + assert_eq!( + err.to_string(), + "Chisel session `43` was saved for network `monad`, but the current network is \ + `ethereum`. Rerun with `--network monad` to load it." + ); + } + + #[test] + fn ensure_loaded_session_network_matches_accepts_same_network() { + let current = config_with_network(Some("tempo")); + let loaded = config_with_network(Some("tempo")); + + ensure_loaded_session_network_matches(¤t, &loaded, "42").unwrap(); + } + + #[cfg(feature = "base")] + #[test] + fn ensure_loaded_session_network_matches_preserves_base() { + let base = config_with_network(Some("base")); + ensure_loaded_session_network_matches(&base, &base, "42").unwrap(); + + let err = + ensure_loaded_session_network_matches(&Config::default(), &base, "42").unwrap_err(); + assert!(err.to_string().contains("Rerun with `--network base`"), "{err}"); + } + + #[test] + fn test_trivia() { + fn only_trivia(s: &str) -> bool { + let (only_trivia, _new_input) = preprocess(s, None).unwrap(); + only_trivia + } + assert!(only_trivia("// line comment")); + assert!(only_trivia(" \n// line \tcomment\n")); + assert!(!only_trivia("// line \ncomment")); + + assert!(only_trivia("/* block comment */")); + assert!(only_trivia(" \t\n /* block \n \t comment */\n")); + assert!(!only_trivia("/* block \n \t comment */\nwith \tother")); + } + + #[test] + fn test_last_result_preprocessing() { + let result = "abi.decode(hex\"2a\", (uint256))"; + let (_, input) = preprocess("uint256 answer = $_;", Some(result)).unwrap(); + assert_eq!(input, format!("uint256 answer = ({result});")); + + let literal = r#"string memory value = "$_"; // $_"#; + let (_, input) = preprocess(literal, Some(result)).unwrap(); + assert_eq!(input, literal); + + assert_eq!(preprocess("$_", None).unwrap_err().to_string(), "no previous result"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn explicit_fork_refreshes_credentials_used_by_saved_sessions() { + let (_api, handle) = anvil::spawn(anvil::NodeConfig::test()).await; + let endpoint = handle.http_endpoint(); + let config = SessionSourceConfig:: { + foundry_config: Config { + solc: Some(foundry_config::SolcReq::Version(semver::Version::new(0, 8, 29))), + rpc_endpoints: serde_json::from_value(serde_json::json!({ + "current": { "endpoint": endpoint, "auth": "Bearer current-token" } + })) + .unwrap(), + ..Default::default() + }, + evm_opts: EvmOpts { + fork_url: Some("https://old.invalid/old-token".into()), + fork_headers: Some(vec!["Authorization: Bearer old-token".into()]), + rpc_headers: Some(vec!["X-Invocation: current".into()]), + ..Default::default() + }, + ..Default::default() + }; + let mut dispatcher = ChiselDispatcher::new(config).unwrap(); + + dispatcher.set_fork(Some("current".into())).await.unwrap(); + let encoded = serde_json::to_string(&dispatcher.source().config).unwrap(); + // Leaving fork mode keeps the invocation's endpoint available for later session loads. + dispatcher.clear_fork().unwrap(); + let mut restored = + serde_json::from_str::>(&encoded).unwrap(); + restored.restore_credentials(&dispatcher.invocation_rpc).unwrap(); + + assert_eq!(restored.evm_opts.fork_url.as_deref(), Some(endpoint.as_str())); + assert_eq!(restored.foundry_config.eth_rpc_url.as_deref(), Some(endpoint.as_str())); + assert_eq!( + restored.evm_opts.fork_headers, + Some(vec!["Authorization: Bearer current-token".into()]) + ); + assert_eq!(restored.evm_opts.rpc_headers, Some(vec!["X-Invocation: current".into()])); + + // Switching to a raw URL drops the previous alias's endpoint-specific authorization. + dispatcher.set_fork(Some(endpoint.clone())).await.unwrap(); + restored.restore_credentials(&dispatcher.invocation_rpc).unwrap(); + assert_eq!(restored.evm_opts.fork_headers, None); + assert_eq!(restored.evm_opts.rpc_headers, Some(vec!["X-Invocation: current".into()])); + + assert!(dispatcher.set_fork(Some("not-a-url".into())).await.is_err()); + restored.restore_credentials(&dispatcher.invocation_rpc).unwrap(); + assert_eq!(restored.evm_opts.fork_url.as_deref(), Some(endpoint.as_str())); } } diff --git a/crates/chisel/src/executor.rs b/crates/chisel/src/executor.rs index 89bbebfa91d44..b64e5383f7af6 100644 --- a/crates/chisel/src/executor.rs +++ b/crates/chisel/src/executor.rs @@ -2,146 +2,124 @@ //! //! This module contains the execution logic for the [SessionSource]. -use crate::prelude::{ - ChiselDispatcher, ChiselResult, ChiselRunner, IntermediateOutput, SessionSource, SolidityHelper, -}; +use crate::prelude::{ChiselDispatcher, ChiselResult, ChiselRunner, SessionSource, SolidityHelper}; use alloy_dyn_abi::{DynSolType, DynSolValue}; use alloy_json_abi::EventParam; use alloy_primitives::{Address, B256, U256, hex}; -use core::fmt::Debug; use eyre::{Result, WrapErr}; use foundry_compilers::Artifact; use foundry_evm::{ - backend::Backend, decode::decode_console_logs, executors::ExecutorBuilder, - inspectors::CheatsConfig, traces::TraceMode, + backend::Backend, + core::evm::FoundryEvmNetwork, + decode::decode_console_logs, + inspectors::CheatsConfig, + opts::{ExecutionSpecContext, resolve_execution_spec}, + traces::TraceRequirements, +}; +use solar::{ + ast::{ElementaryType, LitKind, StmtKind as AstStmtKind, StrKind, UnOpKind, yul}, + interface::Session, + sema::{ + hir::{Event, Expr, ExprKind, StmtKind}, + ty::{Gcx, Ty, TyKind}, + }, }; -use solang_parser::pt::{self, CodeLocation}; -use std::str::FromStr; -use tracing::debug; +use std::ops::ControlFlow; use yansi::Paint; -const USIZE_MAX_AS_U256: U256 = U256::from_limbs([usize::MAX as u64, 0, 0, 0]); +/// Result of inspecting a Solidity snippet. +#[derive(Debug)] +pub struct InspectResult { + /// Whether the input was fully handled by inspection. + pub control_flow: ControlFlow<()>, + /// The formatted value to display, if any. + pub formatted_output: Option, + /// An expression that recreates the inspected value, if any. + pub last_result: Option, + /// Whether the previous reusable result should be cleared. + pub clear_last_result: bool, + /// Input to execute and persist after inspection, if it differs from the original input. + pub replay_input: Option, +} + +impl InspectResult { + const fn empty(control_flow: ControlFlow<()>) -> Self { + Self { + control_flow, + formatted_output: None, + last_result: None, + clear_last_result: false, + replay_input: None, + } + } +} + +struct YulInspection { + inspector_input: String, + replay_input: String, +} + +fn yul_inspection(input: &str, session_source: &str) -> Option { + let sess = Session::builder().with_buffer_emitter(Default::default()).build(); + sess.enter_sequential(|| { + let arena = solar::ast::Arena::new(); + let mut parser = solar::parse::Parser::from_source_code( + &sess, + &arena, + "ChiselInput.sol".to_string().into(), + input, + ) + .ok()?; + let stmt = parser.parse_stmt().map_err(|err| err.emit()).ok()?; + if !parser.token.is_eof() { + return None; + } + let AstStmtKind::Assembly(assembly) = &stmt.kind else { return None }; + let last = assembly.block.stmts.last()?; + let yul::StmtKind::Expr(expr) = &last.kind else { return None }; + + let expr_range = sess.source_map().span_to_source(expr.span).ok()?.data; + let expression = input.get(expr_range.clone())?; + let result_var = std::iter::once("__chisel_yul_result".to_string()) + .chain((1..).map(|suffix| format!("__chisel_yul_result_{suffix}"))) + .find(|name| !input.contains(name) && !session_source.contains(name))?; + + let mut assembly = input.to_string(); + assembly.replace_range(expr_range.clone(), &format!("{result_var} := {expression}")); + let inspector_input = format!( + "uint256 {result_var}; {assembly}\nbytes memory inspectoor = abi.encode({result_var});" + ); + + let mut replay_input = input.to_string(); + replay_input.replace_range(expr_range, &format!("pop({expression})")); + Some(YulInspection { inspector_input, replay_input }) + }) +} /// Executor implementation for [SessionSource] -impl SessionSource { +impl SessionSource { /// Runs the source with the [ChiselRunner] - /// - /// ### Returns - /// - /// Optionally, a tuple containing the [Address] of the deployed REPL contract as well as - /// the [ChiselResult]. - /// - /// Returns an error if compilation fails. - pub async fn execute(&mut self) -> Result<(Address, ChiselResult)> { + pub async fn execute(&mut self) -> Result { + eyre::ensure!( + !self.config.fork_url_required || self.config.evm_opts.fork_url.is_some(), + "this saved Chisel session requires a current fork endpoint before execution" + ); // Recompile the project and ensure no errors occurred. - let compiled = self.build()?; - if let Some((_, contract)) = - compiled.clone().compiler_output.contracts_into_iter().find(|(name, _)| name == "REPL") - { - // These *should* never panic after a successful compilation. + let output = self.build()?; + + let (bytecode, final_pc) = output.enter(|output| -> Result<_> { + let contract = output + .repl_contract() + .ok_or_else(|| eyre::eyre!("failed to find REPL contract"))?; + trace!(?contract, "REPL contract"); let bytecode = contract .get_bytecode_bytes() .ok_or_else(|| eyre::eyre!("No bytecode found for `REPL` contract"))?; - let deployed_bytecode = contract - .get_deployed_bytecode_bytes() - .ok_or_else(|| eyre::eyre!("No deployed bytecode found for `REPL` contract"))?; - - // Fetch the run function's body statement - let run_func_statements = compiled.intermediate.run_func_body()?; - - // Record loc of first yul block return statement (if any). - // This is used to decide which is the final statement within the `run()` method. - // see . - let last_yul_return = run_func_statements.iter().find_map(|statement| { - if let pt::Statement::Assembly { loc: _, dialect: _, flags: _, block } = statement - && let Some(statement) = block.statements.last() - && let pt::YulStatement::FunctionCall(yul_call) = statement - && yul_call.id.name == "return" - { - return Some(statement.loc()); - } - None - }); - - // Find the last statement within the "run()" method and get the program - // counter via the source map. - if let Some(final_statement) = run_func_statements.last() { - // If the final statement is some type of block (assembly, unchecked, or regular), - // we need to find the final statement within that block. Otherwise, default to - // the source loc of the final statement of the `run()` function's block. - // - // There is some code duplication within the arms due to the difference between - // the [pt::Statement] type and the [pt::YulStatement] types. - let mut source_loc = match final_statement { - pt::Statement::Assembly { loc: _, dialect: _, flags: _, block } => { - // Select last non variable declaration statement, see . - let last_statement = block.statements.iter().rev().find(|statement| { - !matches!(statement, pt::YulStatement::VariableDeclaration(_, _, _)) - }); - if let Some(statement) = last_statement { - statement.loc() - } else { - // In the case where the block is empty, attempt to grab the statement - // before the asm block. Because we use saturating sub to get the second - // to last index, this can always be safely unwrapped. - run_func_statements - .get(run_func_statements.len().saturating_sub(2)) - .unwrap() - .loc() - } - } - pt::Statement::Block { loc: _, unchecked: _, statements } => { - if let Some(statement) = statements.last() { - statement.loc() - } else { - // In the case where the block is empty, attempt to grab the statement - // before the block. Because we use saturating sub to get the second to - // last index, this can always be safely unwrapped. - run_func_statements - .get(run_func_statements.len().saturating_sub(2)) - .unwrap() - .loc() - } - } - _ => final_statement.loc(), - }; - - // Consider yul return statement as final statement (if it's loc is lower) . - if let Some(yul_return) = last_yul_return - && yul_return.end() < source_loc.start() - { - source_loc = yul_return; - } - - // Map the source location of the final statement of the `run()` function to its - // corresponding runtime program counter - let final_pc = { - let offset = source_loc.start() as u32; - let length = (source_loc.end() - source_loc.start()) as u32; - contract - .get_source_map_deployed() - .unwrap() - .unwrap() - .into_iter() - .zip(InstructionIter::new(&deployed_bytecode)) - .filter(|(s, _)| s.offset() == offset && s.length() == length) - .map(|(_, i)| i.pc) - .max() - .unwrap_or_default() - }; - - // Create a new runner - let mut runner = self.prepare_runner(final_pc).await?; - - // Return [ChiselResult] or bubble up error - runner.run(bytecode.into_owned()) - } else { - // Return a default result if no statements are present. - Ok((Address::ZERO, ChiselResult::default())) - } - } else { - eyre::bail!("Failed to find REPL contract!") - } + Ok((bytecode.into_owned(), output.final_pc(contract)?)) + })?; + let final_pc = final_pc.unwrap_or_default(); + let mut runner = self.build_runner(final_pc).await?; + runner.run(bytecode) } /// Inspect a contract element inside of the current session @@ -152,16 +130,26 @@ impl SessionSource { /// /// ### Returns /// - /// If the input is valid `Ok((continue, formatted_output))` where: - /// - `continue` is true if the input should be appended to the source - /// - `formatted_output` is the formatted value, if any - pub async fn inspect(&self, input: &str) -> Result<(bool, Option)> { + /// If the input is valid, returns its [`InspectResult`]. + pub async fn inspect(&self, input: &str) -> Result { let line = format!("bytes memory inspectoor = abi.encode({input});"); - let mut source = match self.clone_with_new_line(line.clone()) { - Ok((source, _)) => source, + let (mut source, replay_input) = match self.clone_with_new_line(line) { + Ok((source, _)) => (source, None), Err(err) => { - debug!(%err, "failed to build new source"); - return Ok((true, None)); + debug!(%err, "failed to build new source for inspection"); + let Some(inspection) = yul_inspection(input, &self.to_repl_source()) else { + return Ok(InspectResult::empty(ControlFlow::Continue(()))); + }; + if self + .clone_with_new_line(input.to_string()) + .is_ok_and(|(source, _)| source.build().is_ok()) + { + return Ok(InspectResult::empty(ControlFlow::Continue(()))); + } + let Ok((source, _)) = self.clone_with_new_line(inspection.inspector_input) else { + return Ok(InspectResult::empty(ControlFlow::Continue(()))); + }; + (source, Some(inspection.replay_input)) } }; @@ -170,16 +158,36 @@ impl SessionSource { // Events and tuples fails compilation due to it not being able to be encoded in // `inspectoor`. If that happens, try executing without the inspector. let (mut res, err) = match source.execute().await { - Ok((_, res)) => (res, None), + Ok(res) => (res, None), Err(err) => { debug!(?err, %input, "execution failed"); + let should_execute = self + .clone_with_new_line(input.to_string()) + .ok() + .and_then(|(source, do_execute)| { + if !do_execute { + return None; + } + source.build().ok().map(|output| { + output.enter(|output| { + let body = output.run_func_body(); + let Some(last) = body.last() else { return false }; + let StmtKind::Expr(expr) = last.kind else { return false }; + should_continue(expr) + }) + }) + }) + .unwrap_or(false); + if should_execute { + return Ok(InspectResult::empty(ControlFlow::Continue(()))); + } match source_without_inspector.execute().await { - Ok((_, res)) => (res, Some(err)), + Ok(res) => (res, Some(err)), Err(_) => { if self.config.foundry_config.verbosity >= 3 { sh_err!("Could not inspect: {err}")?; } - return Ok((true, None)); + return Ok(InspectResult::empty(ControlFlow::Continue(()))); } } } @@ -187,20 +195,20 @@ impl SessionSource { // If abi-encoding the input failed, check whether it is an event if let Some(err) = err { - let generated_output = source_without_inspector - .generated_output - .as_ref() - .ok_or_else(|| eyre::eyre!("Could not find generated output!"))?; - - let intermediate_contract = generated_output - .intermediate - .intermediate_contracts - .get("REPL") - .ok_or_else(|| eyre::eyre!("Could not find intermediate contract!"))?; - - if let Some(event_definition) = intermediate_contract.event_definitions.get(input) { - let formatted = format_event_definition(event_definition)?; - return Ok((false, Some(formatted))); + let output = source_without_inspector.build()?; + + let formatted_event = output.enter(|output| { + let gcx = output.gcx(); + output.get_event(input).map(|eid| format_event_definition(gcx, gcx.hir.event(eid))) + }); + if let Some(formatted_event) = formatted_event { + return Ok(InspectResult { + control_flow: ControlFlow::Break(()), + formatted_output: Some(formatted_event?), + last_result: None, + clear_last_result: false, + replay_input: None, + }); } // we were unable to check the event @@ -209,13 +217,14 @@ impl SessionSource { } debug!(%err, %input, "failed abi encode input"); - return Ok((false, None)); + return Ok(InspectResult::empty(ControlFlow::Break(()))); } + drop(source_without_inspector); - let Some((stack, memory, _)) = &res.state else { + let Some((stack, memory)) = &res.state else { // Show traces and logs, if there are any, and return an error if let Ok(decoder) = ChiselDispatcher::decode_traces(&source.config, &mut res).await { - ChiselDispatcher::show_traces(&decoder, &mut res).await?; + ChiselDispatcher::::show_traces(&decoder, &mut res).await?; } let decoded_logs = decode_console_logs(&res.logs); if !decoded_logs.is_empty() { @@ -228,122 +237,114 @@ impl SessionSource { return Err(eyre::eyre!("Failed to inspect expression")); }; - let generated_output = source - .generated_output - .as_ref() - .ok_or_else(|| eyre::eyre!("Could not find generated output!"))?; - - // If the expression is a variable declaration within the REPL contract, use its type; - // otherwise, attempt to infer the type. - let contract_expr = generated_output - .intermediate - .repl_contract_expressions - .get(input) - .or_else(|| source.infer_inner_expr_type()); - - // If the current action is a function call, we get its return type - // otherwise it returns None - let function_call_return_type = - Type::get_function_return_type(contract_expr, &generated_output.intermediate); - - let (contract_expr, ty) = if let Some(function_call_return_type) = function_call_return_type - { - (function_call_return_type.0, function_call_return_type.1) - } else { - match contract_expr.and_then(|e| { - Type::ethabi(e, Some(&generated_output.intermediate)).map(|ty| (e, ty)) - }) { - Some(res) => res, - // this type was denied for inspection, continue - None => return Ok((true, None)), - } + // Either the expression referred to by `input`, or the last expression, + // which was wrapped in `abi.encode`. + let generated_output = source.build()?; + + // Inside the compiler closure, infer the DynSolType of the inspected expression and + // determine whether the REPL should continue. + let res_ty = generated_output.enter(|out| -> Option<(bool, DynSolType)> { + let gcx = out.gcx(); + + // Find the appended `bytes memory inspectoor = abi.encode();` and pull out the + // first call argument. + let block = out.run_func_body(); + let last = block.last()?; + let StmtKind::DeclSingle(vid) = last.kind else { return None }; + let var = gcx.hir.variable(vid); + let init = var.initializer?; + let ExprKind::Call(_callee, args) = &init.kind else { return None }; + let inner_expr = args.exprs().next()?; + + let ty = expr_to_dyn(gcx, inner_expr)?; + Some((should_continue(inner_expr), ty)) + }); + + let Some((cont, ty)) = res_ty else { + return Ok(InspectResult::empty(ControlFlow::Continue(()))); }; // the file compiled correctly, thus the last stack item must be the memory offset of // the `bytes memory inspectoor` value - let mut offset = stack.last().unwrap().to::(); - let mem_offset = &memory[offset..offset + 32]; - let len = U256::try_from_be_slice(mem_offset).unwrap().to::(); - offset += 32; - let data = &memory[offset..offset + len]; - // `tokens` is guaranteed to have the same length as the provided types - let token = - DynSolType::abi_decode(&ty, data).wrap_err("Could not decode inspected values")?; - Ok((should_continue(contract_expr), Some(format_token(token)))) - } - - /// Gracefully attempts to extract the type of the expression within the `abi.encode(...)` - /// call inserted by the inspect function. - /// - /// ### Takes - /// - /// A reference to a [SessionSource] - /// - /// ### Returns - /// - /// Optionally, a [Type] - fn infer_inner_expr_type(&self) -> Option<&pt::Expression> { - let out = self.generated_output.as_ref()?; - let run = out.intermediate.run_func_body().ok()?.last(); - match run { - Some(pt::Statement::VariableDefinition( - _, - _, - Some(pt::Expression::FunctionCall(_, _, args)), - )) => { - // We can safely unwrap the first expression because this function - // will only be called on a session source that has just had an - // `inspectoor` variable appended to it. - Some(args.first().unwrap()) - } - _ => None, - } + let data = (|| -> Option<_> { + let mut offset: usize = stack.last()?.try_into().ok()?; + debug!("inspect memory @ {offset}: {}", hex::encode(memory)); + let mem_offset = memory.get(offset..offset + 32)?; + let len: usize = U256::try_from_be_slice(mem_offset)?.try_into().ok()?; + offset += 32; + memory.get(offset..offset + len) + })(); + let Some(data) = data else { + eyre::bail!("Failed to inspect last expression: could not retrieve data from memory"); + }; + let clear_last_result = dyn_ty_contains_function(&ty); + let last_result = (!clear_last_result) + .then(|| format!("abi.decode(hex\"{}\", ({ty}))", hex::encode(data))); + let token = ty.abi_decode(data).wrap_err("Could not decode inspected values")?; + let c = if cont || replay_input.is_some() { + ControlFlow::Continue(()) + } else { + ControlFlow::Break(()) + }; + Ok(InspectResult { + control_flow: c, + formatted_output: Some(format_token(token)), + last_result, + clear_last_result, + replay_input, + }) } - /// Prepare a runner for the Chisel REPL environment - /// - /// ### Takes - /// - /// The final statement's program counter for the ChiselInspector - /// - /// ### Returns - /// - /// A configured [ChiselRunner] - async fn prepare_runner(&mut self, final_pc: usize) -> Result { - let env = - self.config.evm_opts.evm_env().await.expect("Could not instantiate fork environment"); - - // Create an in-memory backend - let backend = match self.config.backend.take() { - Some(backend) => backend, - None => { - let fork = self.config.evm_opts.get_fork(&self.config.foundry_config, env.clone()); - let backend = Backend::spawn(fork)?; - self.config.backend = Some(backend.clone()); - backend - } + async fn build_runner(&mut self, final_pc: usize) -> Result> { + let backend = if let Some(backend) = &self.config.cached_backend { + backend.clone() + } else { + let opts = &self.config.evm_opts; + let backend = Backend::spawn(opts.get_fork( + &self.config.foundry_config, + opts.env.chain_id.unwrap_or_default(), + None, + ))?; + self.config.cached_backend = Some(backend.clone()); + backend }; - - // Build a new executor - let executor = ExecutorBuilder::new() + let (mut evm_env, tx_env) = backend.env(&self.config.evm_opts).await?; + let fork_context = backend.fork()?.as_ref().map(|fork| fork.context()); + let fork_chain_id = fork_context.map(|context| context.source_chain_id); + let fork_hardfork = fork_context.and_then(|context| context.hardfork); + self.config.source_chain_id = fork_chain_id; + self.config.resolved_hardfork = resolve_execution_spec( + self.config.foundry_config.evm_version, + self.config.foundry_config.hardfork, + &mut evm_env, + ExecutionSpecContext::local_or_fork(fork_chain_id, fork_hardfork), + None, + ); + + let executor = self + .config + .executor_builder + .clone() .inspectors(|stack| { - stack.chisel_state(final_pc).trace_mode(TraceMode::Call).cheatcodes( - CheatsConfig::new( - &self.config.foundry_config, - self.config.evm_opts.clone(), - None, - None, + stack + .logs(self.config.foundry_config.live_logs) + .chisel_state(final_pc) + .trace_requirements(TraceRequirements::none().with_calls(true)) + .cheatcodes( + CheatsConfig::new( + &self.config.foundry_config, + self.config.evm_opts.clone(), + None, + None, + false, + ) + .into(), ) - .into(), - ) }) .gas_limit(self.config.evm_opts.gas_limit()) - .spec_id(self.config.foundry_config.evm_spec_id()) .legacy_assertions(self.config.foundry_config.legacy_assertions) - .build(env, backend); + .build(evm_env, tx_env, backend, self.config.evm_opts.networks); - // Create a [ChiselRunner] with a default balance of [U256::MAX] and - // the sender [Address::zero]. Ok(ChiselRunner::new(executor, U256::MAX, Address::ZERO, self.config.calldata.clone())) } } @@ -355,6 +356,15 @@ fn format_token(token: DynSolValue) -> String { DynSolValue::Address(a) => { format!("Type: {}\n└ Data: {}", "address".red(), a.cyan()) } + DynSolValue::Function(f) => { + let (address, selector) = f.as_address_and_selector(); + format!( + "Type: {}\n├ Address: {}\n└ Selector: {}", + "function".red(), + address.cyan(), + selector.cyan() + ) + } DynSolValue::FixedBytes(b, byte_len) => { format!( "Type: {}\n└ Data: {}", @@ -369,10 +379,8 @@ fn format_token(token: DynSolValue) -> String { format!( "0x{}", format!("{i:x}") - .char_indices() - .skip(64 - bit_len / 4) - .take(bit_len / 4) - .map(|(_, c)| c) + .chars() + .skip(if i.is_negative() { 64 - bit_len / 4 } else { 0 }) .collect::() ) .cyan(), @@ -384,16 +392,7 @@ fn format_token(token: DynSolValue) -> String { format!( "Type: {}\n├ Hex: {}\n├ Hex (full word): {}\n└ Decimal: {}", format!("uint{bit_len}").red(), - format!( - "0x{}", - format!("{i:x}") - .char_indices() - .skip(64 - bit_len / 4) - .take(bit_len / 4) - .map(|(_, c)| c) - .collect::() - ) - .cyan(), + format!("0x{i:x}").cyan(), hex::encode_prefixed(B256::from(i)).cyan(), i.cyan() ) @@ -401,17 +400,20 @@ fn format_token(token: DynSolValue) -> String { DynSolValue::Bool(b) => { format!("Type: {}\n└ Value: {}", "bool".red(), b.cyan()) } - DynSolValue::String(_) | DynSolValue::Bytes(_) => { + DynSolValue::Bytes(bytes) => { + format!( + "Type: {}\n└ Data: {}", + "dynamic bytes".red(), + hex::encode_prefixed(bytes).cyan() + ) + } + token @ DynSolValue::String(_) => { let hex = hex::encode(token.abi_encode()); - let s = token.as_str(); + let s = token.as_str().expect("matched string value"); format!( - "Type: {}\n{}├ Hex (Memory):\n├─ Length ({}): {}\n├─ Contents ({}): {}\n├ Hex (Tuple Encoded):\n├─ Pointer ({}): {}\n├─ Length ({}): {}\n└─ Contents ({}): {}", - if s.is_some() { "string" } else { "dynamic bytes" }.red(), - if let Some(s) = s { - format!("├ UTF-8: {}\n", s.cyan()) - } else { - String::default() - }, + "Type: {}\n├ UTF-8: {}\n├ Hex (Memory):\n├─ Length ({}): {}\n├─ Contents ({}): {}\n├ Hex (Tuple Encoded):\n├─ Pointer ({}): {}\n├─ Length ({}): {}\n└─ Contents ({}): {}", + "string".red(), + s.cyan(), "[0x00:0x20]".yellow(), format!("0x{}", &hex[64..128]).cyan(), "[0x20:..]".yellow(), @@ -461,50 +463,37 @@ fn format_token(token: DynSolValue) -> String { } } -/// Formats a [pt::EventDefinition] into an inspection message -/// -/// ### Takes -/// -/// An borrowed [pt::EventDefinition] -/// -/// ### Returns -/// -/// A formatted [pt::EventDefinition] for use in inspection output. -/// -/// TODO: Verbosity option -fn format_event_definition(event_definition: &pt::EventDefinition) -> Result { - let event_name = event_definition.name.as_ref().expect("Event has a name").to_string(); - let inputs = event_definition - .fields +/// Formats an [`Event`] into an inspection message. +// TODO: Verbosity option +fn format_event_definition(gcx: Gcx<'_>, event: &Event<'_>) -> Result { + let event_name = event.name.as_str().to_string(); + let inputs = event + .parameters .iter() - .map(|param| { - let name = param - .name - .as_ref() - .map(ToString::to_string) - .unwrap_or_else(|| "".to_string()); - let kind = Type::from_expression(¶m.ty) - .and_then(Type::into_builtin) + .map(|&pid| { + let var = gcx.hir.variable(pid); + let name = + var.name.map(|n| n.as_str().to_string()).unwrap_or_else(|| "".into()); + let kind = solar_ty_to_dyn(gcx, gcx.type_of_item(pid.into())) .ok_or_else(|| eyre::eyre!("Invalid type in event {event_name}"))?; Ok(EventParam { name, ty: kind.to_string(), components: vec![], - indexed: param.indexed, + indexed: var.indexed, internal_type: None, }) }) .collect::>>()?; - let event = - alloy_json_abi::Event { name: event_name, inputs, anonymous: event_definition.anonymous }; + let event = alloy_json_abi::Event { name: event_name, inputs, anonymous: event.anonymous }; Ok(format!( "Type: {}\n├ Name: {}\n├ Signature: {:?}\n└ Selector: {:?}", "event".red(), SolidityHelper::new().highlight(&format!( "{}({})", - &event.name, - &event + event.name, + event .inputs .iter() .map(|param| format!( @@ -514,7 +503,7 @@ fn format_event_definition(event_definition: &pt::EventDefinition) -> Result>() @@ -525,906 +514,230 @@ fn format_event_definition(event_definition: &pt::EventDefinition) -> Result), - - /// (type, length) - FixedArray(Box, usize), - - /// (type, index) - ArrayIndex(Box, Option), - - /// (types) - Tuple(Vec>), - - /// (name, params, returns) - Function(Box, Vec>, Vec>), - - /// (lhs, rhs) - Access(Box, String), - - /// (types) - Custom(Vec), +/// Converts an [`Expr`] directly to a [`DynSolType`] for ABI inspection. +fn expr_to_dyn(gcx: Gcx<'_>, expr: &Expr<'_>) -> Option { + gcx.type_of_expr(expr.id).and_then(|ty| solar_expr_ty_to_dyn(gcx, ty, expr)) } -impl Type { - /// Convert a [pt::Expression] to a [Type] - /// - /// ### Takes - /// - /// A reference to a [pt::Expression] to convert. - /// - /// ### Returns - /// - /// Optionally, an owned [Type] - fn from_expression(expr: &pt::Expression) -> Option { - match expr { - pt::Expression::Type(_, ty) => Self::from_type(ty), - - pt::Expression::Variable(ident) => Some(Self::Custom(vec![ident.name.clone()])), - - // array - pt::Expression::ArraySubscript(_, expr, num) => { - // if num is Some then this is either an index operation (arr[]) - // or a FixedArray statement (new uint256[]) - Self::from_expression(expr).and_then(|ty| { - let boxed = Box::new(ty); - let num = num.as_deref().and_then(parse_number_literal).and_then(|n| { - // overflow check - if n > USIZE_MAX_AS_U256 { - None - } else { - Some(n.to::()) - } - }); - match expr.as_ref() { - // statement - pt::Expression::Type(_, _) => { - if let Some(num) = num { - Some(Self::FixedArray(boxed, num)) - } else { - Some(Self::Array(boxed)) - } - } - // index - pt::Expression::Variable(_) => { - Some(Self::ArrayIndex(boxed, num)) - } - _ => None - } - }) - } - pt::Expression::ArrayLiteral(_, values) => { - values.first().and_then(Self::from_expression).map(|ty| { - Self::FixedArray(Box::new(ty), values.len()) - }) - } - - // tuple - pt::Expression::List(_, params) => Some(Self::Tuple(map_parameters(params))), - - // . - pt::Expression::MemberAccess(_, lhs, rhs) => { - Self::from_expression(lhs).map(|lhs| { - Self::Access(Box::new(lhs), rhs.name.clone()) - }) - } - - // - pt::Expression::Parenthesis(_, inner) | // () - pt::Expression::New(_, inner) | // new - pt::Expression::UnaryPlus(_, inner) | // + - // ops - pt::Expression::BitwiseNot(_, inner) | // ~ - pt::Expression::ArraySlice(_, inner, _, _) | // [*start*:*end*] - // assign ops - pt::Expression::PreDecrement(_, inner) | // -- - pt::Expression::PostDecrement(_, inner) | // -- - pt::Expression::PreIncrement(_, inner) | // ++ - pt::Expression::PostIncrement(_, inner) | // ++ - pt::Expression::Assign(_, inner, _) | // = ... - pt::Expression::AssignAdd(_, inner, _) | // += ... - pt::Expression::AssignSubtract(_, inner, _) | // -= ... - pt::Expression::AssignMultiply(_, inner, _) | // *= ... - pt::Expression::AssignDivide(_, inner, _) | // /= ... - pt::Expression::AssignModulo(_, inner, _) | // %= ... - pt::Expression::AssignAnd(_, inner, _) | // &= ... - pt::Expression::AssignOr(_, inner, _) | // |= ... - pt::Expression::AssignXor(_, inner, _) | // ^= ... - pt::Expression::AssignShiftLeft(_, inner, _) | // <<= ... - pt::Expression::AssignShiftRight(_, inner, _) // >>= ... - => Self::from_expression(inner), - - // *condition* ? : - pt::Expression::ConditionalOperator(_, _, if_true, if_false) => { - Self::from_expression(if_true).or_else(|| Self::from_expression(if_false)) - } - - // address - pt::Expression::AddressLiteral(_, _) => Some(Self::Builtin(DynSolType::Address)), - pt::Expression::HexNumberLiteral(_, s, _) => { - match s.parse::
() { - Ok(addr) => { - if *s == addr.to_checksum(None) { - Some(Self::Builtin(DynSolType::Address)) - } else { - Some(Self::Builtin(DynSolType::Uint(256))) - } - }, - _ => { - Some(Self::Builtin(DynSolType::Uint(256))) - } - } - } - - // uint and int - // invert - pt::Expression::Negate(_, inner) => Self::from_expression(inner).map(Self::invert_int), - - // int if either operand is int - // TODO: will need an update for Solidity v0.8.18 user defined operators: - // https://github.com/ethereum/solidity/issues/13718#issuecomment-1341058649 - pt::Expression::Add(_, lhs, rhs) | - pt::Expression::Subtract(_, lhs, rhs) | - pt::Expression::Multiply(_, lhs, rhs) | - pt::Expression::Divide(_, lhs, rhs) => { - match (Self::ethabi(lhs, None), Self::ethabi(rhs, None)) { - (Some(DynSolType::Int(_)), Some(DynSolType::Int(_))) | - (Some(DynSolType::Int(_)), Some(DynSolType::Uint(_))) | - (Some(DynSolType::Uint(_)), Some(DynSolType::Int(_))) => { - Some(Self::Builtin(DynSolType::Int(256))) - } - _ => { - Some(Self::Builtin(DynSolType::Uint(256))) - } - } - } - - // always assume uint - pt::Expression::Modulo(_, _, _) | - pt::Expression::Power(_, _, _) | - pt::Expression::BitwiseOr(_, _, _) | - pt::Expression::BitwiseAnd(_, _, _) | - pt::Expression::BitwiseXor(_, _, _) | - pt::Expression::ShiftRight(_, _, _) | - pt::Expression::ShiftLeft(_, _, _) | - pt::Expression::NumberLiteral(_, _, _, _) => Some(Self::Builtin(DynSolType::Uint(256))), - - // TODO: Rational numbers - pt::Expression::RationalNumberLiteral(_, _, _, _, _) => { - Some(Self::Builtin(DynSolType::Uint(256))) - } - - // bool - pt::Expression::BoolLiteral(_, _) | - pt::Expression::And(_, _, _) | - pt::Expression::Or(_, _, _) | - pt::Expression::Equal(_, _, _) | - pt::Expression::NotEqual(_, _, _) | - pt::Expression::Less(_, _, _) | - pt::Expression::LessEqual(_, _, _) | - pt::Expression::More(_, _, _) | - pt::Expression::MoreEqual(_, _, _) | - pt::Expression::Not(_, _) => Some(Self::Builtin(DynSolType::Bool)), - - // string - pt::Expression::StringLiteral(_) => Some(Self::Builtin(DynSolType::String)), - - // bytes - pt::Expression::HexLiteral(_) => Some(Self::Builtin(DynSolType::Bytes)), - - // function - pt::Expression::FunctionCall(_, name, args) => { - Self::from_expression(name).map(|name| { - let args = args.iter().map(Self::from_expression).collect(); - Self::Function(Box::new(name), args, vec![]) - }) - } - pt::Expression::NamedFunctionCall(_, name, args) => { - Self::from_expression(name).map(|name| { - let args = args.iter().map(|arg| Self::from_expression(&arg.expr)).collect(); - Self::Function(Box::new(name), args, vec![]) - }) - } - - // explicitly None - pt::Expression::Delete(_, _) | pt::Expression::FunctionCallBlock(_, _, _) => None, - } - } - - /// Convert a [pt::Type] to a [Type] - /// - /// ### Takes - /// - /// A reference to a [pt::Type] to convert. - /// - /// ### Returns - /// - /// Optionally, an owned [Type] - fn from_type(ty: &pt::Type) -> Option { - let ty = match ty { - pt::Type::Address | pt::Type::AddressPayable | pt::Type::Payable => { - Self::Builtin(DynSolType::Address) - } - pt::Type::Bool => Self::Builtin(DynSolType::Bool), - pt::Type::String => Self::Builtin(DynSolType::String), - pt::Type::Int(size) => Self::Builtin(DynSolType::Int(*size as usize)), - pt::Type::Uint(size) => Self::Builtin(DynSolType::Uint(*size as usize)), - pt::Type::Bytes(size) => Self::Builtin(DynSolType::FixedBytes(*size as usize)), - pt::Type::DynamicBytes => Self::Builtin(DynSolType::Bytes), - pt::Type::Mapping { value, .. } => Self::from_expression(value)?, - pt::Type::Function { params, returns, .. } => { - let params = map_parameters(params); - let returns = returns - .as_ref() - .map(|(returns, _)| map_parameters(returns)) - .unwrap_or_default(); - Self::Function( - Box::new(Self::Custom(vec!["__fn_type__".to_string()])), - params, - returns, - ) - } - // TODO: Rational numbers - pt::Type::Rational => return None, - }; - Some(ty) +/// Whether execution should continue after inspecting this expression. +#[inline] +fn should_continue(expr: &Expr<'_>) -> bool { + match &expr.kind { + // assignments and compound assignments + ExprKind::Assign(_, _, _) => true, + // Delete expressions. + ExprKind::Delete(_) => true, + // ++/-- pre/post operations + ExprKind::Unary(op, _) => matches!( + op.kind, + UnOpKind::PreInc | UnOpKind::PreDec | UnOpKind::PostInc | UnOpKind::PostDec + ), + // Array.pop() + ExprKind::Call(callee, _) => match &callee.kind { + ExprKind::Member(_, ident) => ident.as_str() == "pop", + _ => false, + }, + _ => false, } +} - /// Handle special expressions like [global variables](https://docs.soliditylang.org/en/latest/cheatsheet.html#global-variables) - /// - /// See: - fn map_special(self) -> Self { - if !matches!(self, Self::Function(_, _, _) | Self::Access(_, _) | Self::Custom(_)) { - return self; - } - - let mut types = Vec::with_capacity(5); - let mut args = None; - self.recurse(&mut types, &mut args); - - let len = types.len(); - if len == 0 { - return self; - } - - // Type members, like array, bytes etc - #[expect(clippy::single_match)] - match &self { - Self::Access(inner, access) => { - if let Some(ty) = inner.as_ref().clone().try_as_ethabi(None) { - // Array / bytes members - let ty = Self::Builtin(ty); - match access.as_str() { - "length" if ty.is_dynamic() || ty.is_array() || ty.is_fixed_bytes() => { - return Self::Builtin(DynSolType::Uint(256)); - } - "pop" if ty.is_dynamic_array() => return ty, - _ => {} - } - } - } - _ => {} - } - - let this = { - let name = types.last().unwrap().as_str(); - match len { - 0 => unreachable!(), - 1 => match name { - "gasleft" | "addmod" | "mulmod" => Some(DynSolType::Uint(256)), - "keccak256" | "sha256" | "blockhash" => Some(DynSolType::FixedBytes(32)), - "ripemd160" => Some(DynSolType::FixedBytes(20)), - "ecrecover" => Some(DynSolType::Address), - _ => None, - }, - 2 => { - let access = types.first().unwrap().as_str(); - match name { - "block" => match access { - "coinbase" => Some(DynSolType::Address), - "timestamp" | "difficulty" | "prevrandao" | "number" | "gaslimit" - | "chainid" | "basefee" | "blobbasefee" => Some(DynSolType::Uint(256)), - _ => None, - }, - "msg" => match access { - "sender" => Some(DynSolType::Address), - "gas" => Some(DynSolType::Uint(256)), - "value" => Some(DynSolType::Uint(256)), - "data" => Some(DynSolType::Bytes), - "sig" => Some(DynSolType::FixedBytes(4)), - _ => None, - }, - "tx" => match access { - "origin" => Some(DynSolType::Address), - "gasprice" => Some(DynSolType::Uint(256)), - _ => None, - }, - "abi" => match access { - "decode" => { - // args = Some([Bytes(_), Tuple(args)]) - // unwrapping is safe because this is first compiled by solc so - // it is guaranteed to be a valid call - let mut args = args.unwrap(); - let last = args.pop().unwrap(); - match last { - Some(ty) => { - return match ty { - Self::Tuple(_) => ty, - ty => Self::Tuple(vec![Some(ty)]), - }; - } - None => None, - } - } - s if s.starts_with("encode") => Some(DynSolType::Bytes), - _ => None, - }, - "address" => match access { - "balance" => Some(DynSolType::Uint(256)), - "code" => Some(DynSolType::Bytes), - "codehash" => Some(DynSolType::FixedBytes(32)), - "send" => Some(DynSolType::Bool), - _ => None, - }, - "type" => match access { - "name" => Some(DynSolType::String), - "creationCode" | "runtimeCode" => Some(DynSolType::Bytes), - "interfaceId" => Some(DynSolType::FixedBytes(4)), - "min" | "max" => Some( - // Either a builtin or an enum - (|| args?.pop()??.into_builtin())() - .unwrap_or(DynSolType::Uint(256)), - ), - _ => None, - }, - "string" => match access { - "concat" => Some(DynSolType::String), - _ => None, - }, - "bytes" => match access { - "concat" => Some(DynSolType::Bytes), - _ => None, - }, - _ => None, - } - } - _ => None, - } - }; - - this.map(Self::Builtin).unwrap_or_else(|| match types.last().unwrap().as_str() { - "this" | "super" => Self::Custom(types), - _ => match self { - Self::Custom(_) | Self::Access(_, _) => Self::Custom(types), - Self::Function(_, _, _) => self, - _ => unreachable!(), - }, - }) - } +/// Maps a solar [`ElementaryType`] to a [`DynSolType`]. +const fn elementary_to_dyn(et: ElementaryType) -> Option { + Some(match et { + ElementaryType::Address(_) => DynSolType::Address, + ElementaryType::Bool => DynSolType::Bool, + ElementaryType::String => DynSolType::String, + ElementaryType::Bytes => DynSolType::Bytes, + ElementaryType::Int(size) => DynSolType::Int(size.bits() as usize), + ElementaryType::UInt(size) => DynSolType::Uint(size.bits() as usize), + ElementaryType::FixedBytes(size) => DynSolType::FixedBytes(size.bytes() as usize), + // Fixed-point numbers are not yet representable as DynSolType. + ElementaryType::Fixed(_, _) | ElementaryType::UFixed(_, _) => return None, + }) +} - /// Recurses over itself, appending all the idents and function arguments in the order that they - /// are found - fn recurse(&self, types: &mut Vec, args: &mut Option>>) { - match self { - Self::Builtin(ty) => types.push(ty.to_string()), - Self::Custom(tys) => types.extend(tys.clone()), - Self::Access(expr, name) => { - types.push(name.clone()); - expr.recurse(types, args); - } - Self::Function(fn_name, fn_args, _fn_ret) => { - if args.is_none() && !fn_args.is_empty() { - *args = Some(fn_args.clone()); - } - fn_name.recurse(types, args); - } - _ => {} +fn dyn_ty_contains_function(ty: &DynSolType) -> bool { + match ty { + DynSolType::Function => true, + DynSolType::Array(inner) | DynSolType::FixedArray(inner, _) => { + dyn_ty_contains_function(inner) } + DynSolType::Tuple(types) => types.iter().any(dyn_ty_contains_function), + _ => false, } +} - /// Infers a custom type's true type by recursing up the parse tree - /// - /// ### Takes - /// - A reference to the [IntermediateOutput] - /// - An array of custom types generated by the `MemberAccess` arm of [Self::from_expression] - /// - An optional contract name. This should always be `None` when this function is first - /// called. - /// - /// ### Returns - /// - /// If successful, an `Ok(Some(DynSolType))` variant. - /// If gracefully failed, an `Ok(None)` variant. - /// If failed, an `Err(e)` variant. - fn infer_custom_type( - intermediate: &IntermediateOutput, - custom_type: &mut Vec, - contract_name: Option, - ) -> Result> { - if let Some("this") | Some("super") = custom_type.last().map(String::as_str) { - custom_type.pop(); - } - if custom_type.is_empty() { - return Ok(None); - } - - // If a contract exists with the given name, check its definitions for a match. - // Otherwise look in the `run` - if let Some(contract_name) = contract_name { - let intermediate_contract = intermediate - .intermediate_contracts - .get(&contract_name) - .ok_or_else(|| eyre::eyre!("Could not find intermediate contract!"))?; - - let cur_type = custom_type.last().unwrap(); - if let Some(func) = intermediate_contract.function_definitions.get(cur_type) { - // Check if the custom type is a function pointer member access - if let res @ Some(_) = func_members(func, custom_type) { - return Ok(res); - } - - // Because tuple types cannot be passed to `abi.encode`, we will only be - // receiving functions that have 0 or 1 return parameters here. - if func.returns.is_empty() { - eyre::bail!( - "This call expression does not return any values to inspect. Insert as statement." - ) - } - - // Empty return types check is done above - let (_, param) = func.returns.first().unwrap(); - // Return type should always be present - let return_ty = ¶m.as_ref().unwrap().ty; - - // If the return type is a variable (not a type expression), re-enter the recursion - // on the same contract for a variable / struct search. It could be a contract, - // struct, array, etc. - if let pt::Expression::Variable(ident) = return_ty { - custom_type.push(ident.name.clone()); - return Self::infer_custom_type(intermediate, custom_type, Some(contract_name)); - } - - // Check if our final function call alters the state. If it does, we bail so that it - // will be inserted normally without inspecting. If the state mutability was not - // expressly set, the function is inferred to alter state. - if let Some(pt::FunctionAttribute::Mutability(_mut)) = func - .attributes - .iter() - .find(|attr| matches!(attr, pt::FunctionAttribute::Mutability(_))) - { - if let pt::Mutability::Payable(_) = _mut { - eyre::bail!("This function mutates state. Insert as a statement.") - } - } else { - eyre::bail!("This function mutates state. Insert as a statement.") - } - - Ok(Self::ethabi(return_ty, Some(intermediate))) - } else if let Some(var) = intermediate_contract.variable_definitions.get(cur_type) { - Self::infer_var_expr(&var.ty, Some(intermediate), custom_type) - } else if let Some(strukt) = intermediate_contract.struct_definitions.get(cur_type) { - let inner_types = strukt - .fields - .iter() - .map(|var| { - Self::ethabi(&var.ty, Some(intermediate)) - .ok_or_else(|| eyre::eyre!("Struct `{cur_type}` has invalid fields")) - }) - .collect::>>()?; - Ok(Some(DynSolType::Tuple(inner_types))) - } else { - eyre::bail!( - "Could not find any definition in contract \"{contract_name}\" for type: {custom_type:?}" - ) - } - } else { - // Check if the custom type is a variable or function within the REPL contract before - // anything. If it is, we can stop here. - if let Ok(res) = Self::infer_custom_type(intermediate, custom_type, Some("REPL".into())) - { - return Ok(res); - } - - // Check if the first element of the custom type is a known contract. If it is, begin - // our recursion on that contract's definitions. - let name = custom_type.last().unwrap(); - let contract = intermediate.intermediate_contracts.get(name); - if contract.is_some() { - let contract_name = custom_type.pop(); - return Self::infer_custom_type(intermediate, custom_type, contract_name); - } - - // See [`Type::infer_var_expr`] - let name = custom_type.last().unwrap(); - if let Some(expr) = intermediate.repl_contract_expressions.get(name) { - return Self::infer_var_expr(expr, Some(intermediate), custom_type); - } - - // The first element of our custom type was neither a variable or a function within the - // REPL contract, move on to globally available types gracefully. - Ok(None) - } +/// Maps a solar [`Ty`] to a [`DynSolType`]. +fn solar_expr_ty_to_dyn<'gcx>(gcx: Gcx<'gcx>, ty: Ty<'gcx>, expr: &Expr<'_>) -> Option { + // `expr` is the inspected expression inside Chisel's generated `abi.encode(...)` call. Solar + // currently reports hex string literals as `StringLiteral`, but solc ABI-encodes + // `hex"..."` literals as dynamic bytes in that context. + let expr = expr.peel_parens(); + if matches!(expr.kind, ExprKind::Lit(lit) if matches!(lit.kind, LitKind::Str(StrKind::Hex, ..))) + { + return Some(DynSolType::Bytes); } - /// Infers the type from a variable's type - fn infer_var_expr( - expr: &pt::Expression, - intermediate: Option<&IntermediateOutput>, - custom_type: &mut Vec, - ) -> Result> { - // Resolve local (in `run` function) or global (in the `REPL` or other contract) variable - let res = match &expr { - // Custom variable handling - pt::Expression::Variable(ident) => { - let name = &ident.name; - - if let Some(intermediate) = intermediate { - // expression in `run` - if let Some(expr) = intermediate.repl_contract_expressions.get(name) { - Self::infer_var_expr(expr, Some(intermediate), custom_type) - } else if intermediate.intermediate_contracts.contains_key(name) { - if custom_type.len() > 1 { - // There is still some recursing left to do: jump into the contract. - custom_type.pop(); - Self::infer_custom_type(intermediate, custom_type, Some(name.clone())) - } else { - // We have no types left to recurse: return the address of the contract. - Ok(Some(DynSolType::Address)) - } - } else { - Err(eyre::eyre!("Could not infer variable type")) - } - } else { - Ok(None) - } - } - ty => Ok(Self::ethabi(ty, intermediate)), - }; - // re-run everything with the resolved variable in case we're accessing a builtin member - // for example array or bytes length etc - match res { - Ok(Some(ty)) => { - let box_ty = Box::new(Self::Builtin(ty.clone())); - let access = Self::Access(box_ty, custom_type.drain(..).next().unwrap_or_default()); - if let Some(mapped) = access.map_special().try_as_ethabi(intermediate) { - Ok(Some(mapped)) - } else { - Ok(Some(ty)) - } - } - res => res, - } - } + solar_ty_to_dyn(gcx, ty) +} - /// Attempt to convert this type into a [DynSolType] - /// - /// ### Takes - /// An immutable reference to an [IntermediateOutput] - /// - /// ### Returns - /// Optionally, a [DynSolType] - fn try_as_ethabi(self, intermediate: Option<&IntermediateOutput>) -> Option { - match self { - Self::Builtin(ty) => Some(ty), - Self::Tuple(types) => Some(DynSolType::Tuple(types_to_parameters(types, intermediate))), - Self::Array(inner) => match *inner { - ty @ Self::Custom(_) => ty.try_as_ethabi(intermediate), - _ => inner - .try_as_ethabi(intermediate) - .map(|inner| DynSolType::Array(Box::new(inner))), - }, - Self::FixedArray(inner, size) => match *inner { - ty @ Self::Custom(_) => ty.try_as_ethabi(intermediate), - _ => inner - .try_as_ethabi(intermediate) - .map(|inner| DynSolType::FixedArray(Box::new(inner), size)), - }, - ty @ Self::ArrayIndex(_, _) => ty.into_array_index(intermediate), - Self::Function(ty, _, _) => ty.try_as_ethabi(intermediate), - // should have been mapped to `Custom` in previous steps - Self::Access(_, _) => None, - Self::Custom(mut types) => { - // Cover any local non-state-modifying function call expressions - intermediate.and_then(|intermediate| { - Self::infer_custom_type(intermediate, &mut types, None).ok().flatten() - }) - } +fn solar_ty_to_dyn<'gcx>(gcx: Gcx<'gcx>, ty: Ty<'gcx>) -> Option { + match ty.kind { + TyKind::Elementary(et) => elementary_to_dyn(et), + TyKind::Ref(inner, _) => solar_ty_to_dyn(gcx, inner), + TyKind::Array(elem, n) => { + let inner = solar_ty_to_dyn(gcx, elem)?; + let size: usize = n.try_into().ok()?; + Some(DynSolType::FixedArray(Box::new(inner), size)) } - } - - /// Equivalent to `Type::from_expression` + `Type::map_special` + `Type::try_as_ethabi` - fn ethabi( - expr: &pt::Expression, - intermediate: Option<&IntermediateOutput>, - ) -> Option { - Self::from_expression(expr) - .map(Self::map_special) - .and_then(|ty| ty.try_as_ethabi(intermediate)) - } - - /// Get the return type of a function call expression. - fn get_function_return_type<'a>( - contract_expr: Option<&'a pt::Expression>, - intermediate: &IntermediateOutput, - ) -> Option<(&'a pt::Expression, DynSolType)> { - let function_call = match contract_expr? { - pt::Expression::FunctionCall(_, function_call, _) => function_call, - _ => return None, - }; - let (contract_name, function_name) = match function_call.as_ref() { - pt::Expression::MemberAccess(_, contract_name, function_name) => { - (contract_name, function_name) - } - _ => return None, - }; - let contract_name = match contract_name.as_ref() { - pt::Expression::Variable(contract_name) => contract_name.to_owned(), - _ => return None, - }; - - let pt::Expression::Variable(contract_name) = - intermediate.repl_contract_expressions.get(&contract_name.name)? - else { - return None; - }; - - let contract = intermediate - .intermediate_contracts - .get(&contract_name.name)? - .function_definitions - .get(&function_name.name)?; - let return_parameter = contract.as_ref().returns.first()?.to_owned().1?; - Self::ethabi(&return_parameter.ty, Some(intermediate)).map(|p| (contract_expr.unwrap(), p)) - } - - /// Inverts Int to Uint and vice-versa. - fn invert_int(self) -> Self { - match self { - Self::Builtin(DynSolType::Uint(n)) => Self::Builtin(DynSolType::Int(n)), - Self::Builtin(DynSolType::Int(n)) => Self::Builtin(DynSolType::Uint(n)), - x => x, + TyKind::DynArray(elem) => { + let inner = solar_ty_to_dyn(gcx, elem)?; + Some(DynSolType::Array(Box::new(inner))) } - } - - /// Returns the `DynSolType` contained by `Type::Builtin` - #[inline] - fn into_builtin(self) -> Option { - match self { - Self::Builtin(ty) => Some(ty), - _ => None, + TyKind::Slice(array) => solar_ty_to_dyn(gcx, array), + TyKind::Tuple(tys) => { + Some(DynSolType::Tuple(tys.iter().filter_map(|t| solar_ty_to_dyn(gcx, *t)).collect())) } - } - - /// Returns the resulting `DynSolType` of indexing self - fn into_array_index(self, intermediate: Option<&IntermediateOutput>) -> Option { - match self { - Self::Array(inner) | Self::FixedArray(inner, _) | Self::ArrayIndex(inner, _) => { - match inner.try_as_ethabi(intermediate) { - Some(DynSolType::Array(inner)) | Some(DynSolType::FixedArray(inner, _)) => { - Some(*inner) - } - Some(DynSolType::Bytes) - | Some(DynSolType::String) - | Some(DynSolType::FixedBytes(_)) => Some(DynSolType::FixedBytes(1)), - ty => ty, - } + TyKind::Mapping(_, _) => None, + TyKind::Struct(sid) => Some(DynSolType::Tuple( + gcx.struct_field_types(sid).iter().filter_map(|t| solar_ty_to_dyn(gcx, *t)).collect(), + )), + TyKind::Enum(_) => Some(DynSolType::Uint(8)), + TyKind::Udvt(inner, _) => solar_ty_to_dyn(gcx, inner), + TyKind::Contract(_) => Some(DynSolType::Address), + TyKind::Fn(f) if f.is_external() => Some(DynSolType::Function), + TyKind::Fn(_) => None, + TyKind::Type(inner) => solar_ty_to_dyn(gcx, inner), + TyKind::Meta(inner) => solar_ty_to_dyn(gcx, inner), + TyKind::IntLiteral(neg, size, _) => { + let bits = (size.bits() as usize).max(8); + // Round up to the nearest multiple of 8 bits, capped at 256. + let bits = bits.div_ceil(8) * 8; + let bits = bits.min(256); + if neg { + Some(DynSolType::Int(bits.max(8))) + } else { + Some(DynSolType::Uint(bits.max(8))) } - _ => None, - } - } - - /// Returns whether this type is dynamic - #[inline] - fn is_dynamic(&self) -> bool { - match self { - // TODO: Note, this is not entirely correct. Fixed arrays of non-dynamic types are - // not dynamic, nor are tuples of non-dynamic types. - Self::Builtin(DynSolType::Bytes | DynSolType::String | DynSolType::Array(_)) => true, - Self::Array(_) => true, - _ => false, } - } - - /// Returns whether this type is an array - #[inline] - fn is_array(&self) -> bool { - matches!( - self, - Self::Array(_) - | Self::FixedArray(_, _) - | Self::Builtin(DynSolType::Array(_)) - | Self::Builtin(DynSolType::FixedArray(_, _)) - ) - } - - /// Returns whether this type is a dynamic array (can call push, pop) - #[inline] - fn is_dynamic_array(&self) -> bool { - matches!(self, Self::Array(_) | Self::Builtin(DynSolType::Array(_))) - } - - fn is_fixed_bytes(&self) -> bool { - matches!(self, Self::Builtin(DynSolType::FixedBytes(_))) - } -} - -/// Returns Some if the custom type is a function member access -/// -/// Ref: -#[inline] -fn func_members(func: &pt::FunctionDefinition, custom_type: &[String]) -> Option { - if !matches!(func.ty, pt::FunctionTy::Function) { - return None; - } - - let vis = func.attributes.iter().find_map(|attr| match attr { - pt::FunctionAttribute::Visibility(vis) => Some(vis), - _ => None, - }); - match vis { - Some(pt::Visibility::External(_)) | Some(pt::Visibility::Public(_)) => { - match custom_type.first().unwrap().as_str() { - "address" => Some(DynSolType::Address), - "selector" => Some(DynSolType::FixedBytes(4)), - _ => None, + TyKind::StringLiteral(valid_utf8, _) => { + if valid_utf8 { + Some(DynSolType::String) + } else { + Some(DynSolType::Bytes) } } + TyKind::Module(_) + | TyKind::BuiltinModule(_) + | TyKind::Error(_, _) + | TyKind::Event(_, _) + | TyKind::Err(_) => None, _ => None, } } -/// Whether execution should continue after inspecting this expression -#[inline] -fn should_continue(expr: &pt::Expression) -> bool { - match expr { - // assignments - pt::Expression::PreDecrement(_, _) | // -- - pt::Expression::PostDecrement(_, _) | // -- - pt::Expression::PreIncrement(_, _) | // ++ - pt::Expression::PostIncrement(_, _) | // ++ - pt::Expression::Assign(_, _, _) | // = ... - pt::Expression::AssignAdd(_, _, _) | // += ... - pt::Expression::AssignSubtract(_, _, _) | // -= ... - pt::Expression::AssignMultiply(_, _, _) | // *= ... - pt::Expression::AssignDivide(_, _, _) | // /= ... - pt::Expression::AssignModulo(_, _, _) | // %= ... - pt::Expression::AssignAnd(_, _, _) | // &= ... - pt::Expression::AssignOr(_, _, _) | // |= ... - pt::Expression::AssignXor(_, _, _) | // ^= ... - pt::Expression::AssignShiftLeft(_, _, _) | // <<= ... - pt::Expression::AssignShiftRight(_, _, _) // >>= ... - => { - true - } - - // Array.pop() - pt::Expression::FunctionCall(_, lhs, _) => { - match lhs.as_ref() { - pt::Expression::MemberAccess(_, _inner, access) => access.name == "pop", - _ => false - } - } - - _ => false - } -} +#[cfg(test)] +mod tests { + use super::*; + use crate::source::SessionSourceConfig; + use foundry_compilers::{error::SolcError, solc::Solc}; + use foundry_config::Config; + use foundry_evm::{core::evm::EthEvmNetwork, opts::EvmOpts}; + use foundry_evm_networks::{NetworkConfigs, celo::transfer::CELO_TRANSFER_ADDRESS}; + use foundry_test_utils::util::SOLC_VERSION; + use solar::sema::Compiler; + use std::sync::Mutex; -fn map_parameters(params: &[(pt::Loc, Option)]) -> Vec> { - params - .iter() - .map(|(_, param)| param.as_ref().and_then(|param| Type::from_expression(¶m.ty))) - .collect() -} + #[cfg(feature = "monad")] + use foundry_evm::{ + core::{constants::MONAD_CHEATCODE_ADDRESS, evm::MonadEvmNetwork}, + executors::ExecutorBuilder, + }; + + type TestSessionSource = SessionSource; + + #[tokio::test] + async fn saved_fork_cannot_execute_without_a_current_endpoint() { + let dir = tempfile::tempdir().unwrap(); + let mut source = TestSessionSource::new(SessionSourceConfig { + foundry_config: Config { + solc: Some(foundry_config::SolcReq::Local(dir.path().join("missing-solc"))), + ..Default::default() + }, + fork_url_required: true, + no_vm: true, + ..Default::default() + }) + .unwrap(); -fn types_to_parameters( - types: Vec>, - intermediate: Option<&IntermediateOutput>, -) -> Vec { - types.into_iter().filter_map(|ty| ty.and_then(|ty| ty.try_as_ethabi(intermediate))).collect() -} + let error = source.execute().await.unwrap_err(); -fn parse_number_literal(expr: &pt::Expression) -> Option { - match expr { - pt::Expression::NumberLiteral(_, num, exp, unit) => { - let num = U256::from_str(num).unwrap_or(U256::ZERO); - let exp = exp.parse().unwrap_or(0u32); - if exp > 77 { - None - } else { - let exp = U256::from(10usize.pow(exp)); - let unit_mul = unit_multiplier(unit).ok()?; - Some(num * exp * unit_mul) - } - } - pt::Expression::HexNumberLiteral(_, num, unit) => { - let unit_mul = unit_multiplier(unit).ok()?; - num.parse::().map(|num| num * unit_mul).ok() - } - // TODO: Rational numbers - pt::Expression::RationalNumberLiteral(..) => None, - _ => None, + assert_eq!( + error.to_string(), + "this saved Chisel session requires a current fork endpoint before execution" + ); } -} -#[inline] -fn unit_multiplier(unit: &Option) -> Result { - if let Some(unit) = unit { - let mul = match unit.name.as_str() { - "seconds" => 1, - "minutes" => 60, - "hours" => 60 * 60, - "days" => 60 * 60 * 24, - "weeks" => 60 * 60 * 24 * 7, - "wei" => 1, - "gwei" => 10_usize.pow(9), - "ether" => 10_usize.pow(18), - other => eyre::bail!("unknown unit: {other}"), - }; - Ok(U256::from(mul)) - } else { - Ok(U256::from(1)) + async fn assert_celo_transfer_precompile(config: SessionSourceConfig) { + let mut source = SessionSource::::new(config).unwrap(); + let mut runner = source.build_runner(0).await.unwrap(); + let from = Address::with_last_byte(1); + let to = Address::with_last_byte(2); + let amount = U256::from(4); + runner.executor.set_balance(from, U256::from(10)).unwrap(); + runner.executor.set_balance(to, U256::ONE).unwrap(); + + let mut input = vec![0u8; 96]; + input[12..32].copy_from_slice(from.as_slice()); + input[44..64].copy_from_slice(to.as_slice()); + input[64..96].copy_from_slice(&amount.to_be_bytes::<32>()); + let result = runner + .executor + .transact_raw(Address::ZERO, CELO_TRANSFER_ADDRESS, input.into(), U256::ZERO) + .unwrap(); + + assert!(!result.reverted); + assert_eq!(runner.executor.get_balance(from).unwrap(), U256::from(6)); + assert_eq!(runner.executor.get_balance(to).unwrap(), U256::from(5)); } -} - -#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -struct Instruction { - pub pc: usize, - pub opcode: u8, - pub data: [u8; 32], - pub data_len: u8, -} -struct InstructionIter<'a> { - bytes: &'a [u8], - offset: usize, -} + #[tokio::test(flavor = "multi_thread")] + async fn celo_network_reaches_fresh_and_restored_runners() { + let networks = NetworkConfigs::with_celo(); + let mut evm_opts = EvmOpts { networks, ..Default::default() }; + evm_opts.env.gas_limit = 30_000_000u64.into(); + let config = SessionSourceConfig:: { + foundry_config: Config { networks, ..Default::default() }, + evm_opts, + ..Default::default() + }; -impl<'a> InstructionIter<'a> { - pub fn new(bytes: &'a [u8]) -> Self { - Self { bytes, offset: 0 } - } -} + assert_celo_transfer_precompile(config.clone()).await; -impl Iterator for InstructionIter<'_> { - type Item = Instruction; - fn next(&mut self) -> Option { - let pc = self.offset; - self.offset += 1; - let opcode = *self.bytes.get(pc)?; - let (data, data_len) = if matches!(opcode, 0x60..=0x7F) { - let mut data = [0; 32]; - let data_len = (opcode - 0x60 + 1) as usize; - data[..data_len].copy_from_slice(&self.bytes[self.offset..self.offset + data_len]); - self.offset += data_len; - (data, data_len as u8) - } else { - ([0; 32], 0) - }; - Some(Instruction { pc, opcode, data, data_len }) + let encoded = serde_json::to_string(&config).unwrap(); + let mut restored = + serde_json::from_str::>(&encoded).unwrap(); + restored.initialize_local_context(); + assert_celo_transfer_precompile(restored).await; } -} -#[cfg(test)] -mod tests { - use super::*; - use foundry_compilers::{error::SolcError, solc::Solc}; - use semver::Version; - use std::sync::Mutex; + #[cfg(feature = "monad")] + #[tokio::test(flavor = "multi_thread")] + async fn chisel_runner_uses_dispatched_monad_tooling() { + let networks = NetworkConfigs::with_monad(); + let mut source = SessionSource::::new(SessionSourceConfig { + foundry_config: Config { networks, ..Default::default() }, + evm_opts: EvmOpts { networks, ..Default::default() }, + executor_builder: ExecutorBuilder::::new(), + ..Default::default() + }) + .unwrap(); - #[test] - fn test_const() { - assert_eq!(USIZE_MAX_AS_U256.to::(), usize::MAX as u64); - assert_eq!(USIZE_MAX_AS_U256.to::(), usize::MAX as u64); + let runner = source.build_runner(0).await.unwrap(); + assert_eq!( + runner.executor.inspector().extra_cheatcode_addresses(), + &[MONAD_CHEATCODE_ADDRESS] + ); } #[test] @@ -1434,42 +747,41 @@ mod tests { &[ // units // uint - ("1 seconds", Uint(256)), - ("1 minutes", Uint(256)), - ("1 hours", Uint(256)), - ("1 days", Uint(256)), - ("1 weeks", Uint(256)), - ("1 wei", Uint(256)), - ("1 gwei", Uint(256)), - ("1 ether", Uint(256)), + ("1 seconds", Uint(8)), + ("1 minutes", Uint(8)), + ("1 hours", Uint(16)), + ("1 days", Uint(24)), + ("1 weeks", Uint(24)), + ("1 wei", Uint(8)), + ("1 gwei", Uint(32)), + ("1 ether", Uint(64)), // int - ("-1 seconds", Int(256)), - ("-1 minutes", Int(256)), - ("-1 hours", Int(256)), - ("-1 days", Int(256)), - ("-1 weeks", Int(256)), - ("-1 wei", Int(256)), - ("-1 gwei", Int(256)), - ("-1 ether", Int(256)), + ("-1 seconds", Int(8)), + ("-1 minutes", Int(8)), + ("-1 hours", Int(16)), + ("-1 days", Int(24)), + ("-1 weeks", Int(24)), + ("-1 wei", Int(8)), + ("-1 gwei", Int(32)), + ("-1 ether", Int(64)), // - ("true ? 1 : 0", Uint(256)), - ("true ? -1 : 0", Int(256)), + ("true ? 1 : 0", Uint(8)), // misc // // ops // uint - ("1 + 1", Uint(256)), - ("1 - 1", Uint(256)), - ("1 * 1", Uint(256)), - ("1 / 1", Uint(256)), - ("1 % 1", Uint(256)), - ("1 ** 1", Uint(256)), - ("1 | 1", Uint(256)), - ("1 & 1", Uint(256)), - ("1 ^ 1", Uint(256)), - ("1 >> 1", Uint(256)), - ("1 << 1", Uint(256)), + ("1 + 1", Uint(8)), + ("1 - 1", Uint(8)), + ("1 * 1", Uint(8)), + ("1 / 1", Uint(8)), + ("1 % 1", Uint(8)), + ("1 ** 1", Uint(8)), + ("1 | 1", Uint(8)), + ("1 & 1", Uint(8)), + ("1 ^ 1", Uint(8)), + ("1 >> 1", Uint(8)), + ("1 << 1", Uint(8)), // int ("int(1) + 1", Int(256)), ("int(1) - 1", Int(256)), @@ -1504,10 +816,6 @@ mod tests { ("true || true", Bool), ("true == true", Bool), ("true != true", Bool), - ("true < true", Bool), - ("true <= true", Bool), - ("true > true", Bool), - ("true >= true", Bool), ("!true", Bool), // ] @@ -1516,12 +824,11 @@ mod tests { let source = &mut source(); let array_expressions: &[(&str, DynSolType)] = &[ - ("[1, 2, 3]", fixed_array(DynSolType::Uint(256), 3)), + ("[1, 2, 3]", fixed_array(DynSolType::Uint(8), 3)), ("[uint8(1), 2, 3]", fixed_array(DynSolType::Uint(8), 3)), ("[int8(1), 2, 3]", fixed_array(DynSolType::Int(8), 3)), ("new uint256[](3)", array(DynSolType::Uint(256))), ("uint256[] memory a = new uint256[](3);\na[0]", DynSolType::Uint(256)), - ("uint256[] memory a = new uint256[](3);\na[0:3]", array(DynSolType::Uint(256))), ]; generic_type_test(source, array_expressions); generic_type_test(source, EXPRESSIONS); @@ -1541,13 +848,13 @@ mod tests { // int and uint ("uint", Uint(256)), ("uint(1)", Uint(256)), - ("1", Uint(256)), - ("0x01", Uint(256)), + ("1", Uint(8)), + ("0x01", Uint(8)), ("int", Int(256)), ("int(1)", Int(256)), ("int(-1)", Int(256)), - ("-1", Int(256)), - ("-0x01", Int(256)), + ("-1", Int(8)), + ("-0x01", Int(8)), // // address @@ -1581,7 +888,7 @@ mod tests { types.push((format!("bytes{b}(0x00)"), DynSolType::FixedBytes(b))); } - for n in 0..=32 { + for n in 1..=32 { types.push(( format!("uint256[{n}]"), DynSolType::FixedArray(Box::new(DynSolType::Uint(256)), n), @@ -1601,23 +908,22 @@ mod tests { use DynSolType::*; &[ // abi - ("abi.decode(bytes, (uint8[13]))", Tuple(vec![FixedArray(Box::new(Uint(8)), 13)])), - ("abi.decode(bytes, (address, bytes))", Tuple(vec![Address, Bytes])), - ("abi.decode(bytes, (uint112, uint48))", Tuple(vec![Uint(112), Uint(48)])), - ("abi.encode(_, _)", Bytes), - ("abi.encodePacked(_, _)", Bytes), - ("abi.encodeWithSelector(bytes4, _, _)", Bytes), - ("abi.encodeCall(function(), (_, _))", Bytes), - ("abi.encodeWithSignature(string, _, _)", Bytes), + ("abi.decode(bytes(\"\"), (uint8[13]))", FixedArray(Box::new(Uint(8)), 13)), + ("abi.decode(bytes(\"\"), (address, bytes))", Tuple(vec![Address, Bytes])), + ("abi.decode(bytes(\"\"), (uint112, uint48))", Tuple(vec![Uint(112), Uint(48)])), + ("abi.encode(1, 2)", Bytes), + ("abi.encodePacked(uint256(1), uint256(2))", Bytes), + ("abi.encodeWithSelector(bytes4(0), 1, 2)", Bytes), + ("abi.encodeWithSignature(\"f(uint256)\", 1)", Bytes), // // ("bytes.concat()", Bytes), - ("bytes.concat(_)", Bytes), - ("bytes.concat(_, _)", Bytes), + ("bytes.concat(bytes(\"\"))", Bytes), + ("bytes.concat(bytes(\"\"), bytes(\"\"))", Bytes), ("string.concat()", String), - ("string.concat(_)", String), - ("string.concat(_, _)", String), + ("string.concat(\"\")", String), + ("string.concat(\"\", \"\")", String), // // block @@ -1648,24 +954,24 @@ mod tests { // // - ("blockhash(uint)", FixedBytes(32)), - ("keccak256(bytes)", FixedBytes(32)), - ("sha256(bytes)", FixedBytes(32)), - ("ripemd160(bytes)", FixedBytes(20)), - ("ecrecover(bytes32, uint8, bytes32, bytes32)", Address), - ("addmod(uint, uint, uint)", Uint(256)), - ("mulmod(uint, uint, uint)", Uint(256)), + ("blockhash(0)", FixedBytes(32)), + ("keccak256(bytes(\"\"))", FixedBytes(32)), + ("sha256(bytes(\"\"))", FixedBytes(32)), + ("ripemd160(bytes(\"\"))", FixedBytes(20)), + ("ecrecover(bytes32(0), 0, bytes32(0), bytes32(0))", Address), + ("addmod(1, 2, 3)", Uint(256)), + ("mulmod(1, 2, 3)", Uint(256)), // // address - ("address(_)", Address), + ("address(0)", Address), ("address(this)", Address), // ("super", Type::Custom("super".to_string)) // (selfdestruct(address payable), None) - ("address.balance", Uint(256)), - ("address.code", Bytes), - ("address.codehash", FixedBytes(32)), - ("address.send(uint256)", Bool), + ("address(0).balance", Uint(256)), + ("address(0).code", Bytes), + ("address(0).codehash", FixedBytes(32)), + ("payable(address(0)).send(1)", Bool), // (address.transfer(uint256), None) // @@ -1680,8 +986,8 @@ mod tests { ("type(uint256).max", Uint(256)), ("type(int128).max", Int(128)), ("type(int256).max", Int(256)), - ("type(Enum1).min", Uint(256)), - ("type(Enum1).max", Uint(256)), + ("type(Enum1).min", Uint(8)), + ("type(Enum1).max", Uint(8)), // function ("this.run.address", Address), ("this.run.selector", FixedBytes(4)), @@ -1692,13 +998,13 @@ mod tests { } #[track_caller] - fn source() -> SessionSource { + fn source() -> TestSessionSource { // synchronize solc install static PRE_INSTALL_SOLC_LOCK: Mutex = Mutex::new(false); // on some CI targets installing results in weird malformed solc files, we try installing it // multiple times - let version = "0.8.20"; + let version = SOLC_VERSION; for _ in 0..3 { let mut is_preinstalled = PRE_INSTALL_SOLC_LOCK.lock().unwrap(); if !*is_preinstalled { @@ -1723,8 +1029,7 @@ mod tests { } } - let solc = Solc::find_or_install(&Version::new(0, 8, 19)).expect("could not install solc"); - SessionSource::new(solc, Default::default()) + SessionSource::new(Default::default()).unwrap() } fn array(ty: DynSolType) -> DynSolType { @@ -1735,63 +1040,86 @@ mod tests { DynSolType::FixedArray(Box::new(ty), len) } - fn parse(s: &mut SessionSource, input: &str, clear: bool) -> IntermediateOutput { + /// Lowers the given snippet appended to the REPL contract via solar's HIR pipeline (without + /// invoking solc) and returns the resulting `DynSolType` of the last expression statement in + /// the run() body. + /// + /// Tests bypass `SessionSource::build` (which routes through foundry-compilers + solc) so the + /// Solar type table can be exercised directly without compiling each snippet through solc. + fn get_type_ethabi(s: &mut TestSessionSource, input: &str, clear: bool) -> Option { if clear { - s.drain_run(); - s.drain_top_level_code(); - s.drain_global_code(); + s.clear(); } + // Always declare sample types so `type(...)` tests have concrete definitions. *s = s.clone_with_new_line("enum Enum1 { A }".into()).unwrap().0; + *s = s.clone_with_new_line("contract C {}".into()).unwrap().0; + *s = s.clone_with_new_line("interface I {}".into()).unwrap().0; let input = format!("{};", input.trim_end().trim_end_matches(';')); - let (mut _s, _) = s.clone_with_new_line(input).unwrap(); - *s = _s.clone(); - let s = &mut _s; - - if let Err(e) = s.parse() { - for err in e { - let _ = sh_eprintln!("{}:{}: {}", err.loc.start(), err.loc.end(), err.message); + let (new_source, _) = s.clone_with_new_line(input).unwrap(); + *s = new_source.clone(); + + let src = new_source.to_repl_source(); + let opts = solar::interface::config::CompileOpts::default(); + let sess = solar::interface::Session::builder() + .opts(opts) + .with_buffer_emitter(Default::default()) + .build(); + let mut compiler = Compiler::new(sess); + + compiler.enter_mut(|c| -> Option { + // Stage 1: parse, lower, and analyze (mutable access required). + let analyzed = { + let mut pcx = c.parse(); + let file = c + .sess() + .source_map() + .new_source_file( + std::path::PathBuf::from(new_source.file_name.clone()), + src.clone(), + ) + .ok()?; + pcx.add_file(file); + pcx.parse(); + matches!(c.lower_asts(), Ok(ControlFlow::Continue(()))) + && matches!(c.analysis(), Ok(ControlFlow::Continue(()))) + }; + if !analyzed { + return None; } - let source = s.to_repl_source(); - panic!("could not parse input:\n{source}") - } - s.generate_intermediate_output().expect("could not generate intermediate output") - } - - fn expr(stmts: &[pt::Statement]) -> pt::Expression { - match stmts.last().expect("no statements") { - pt::Statement::Expression(_, e) => e.clone(), - s => panic!("Not an expression: {s:?}"), - } - } - - fn get_type( - s: &mut SessionSource, - input: &str, - clear: bool, - ) -> (Option, IntermediateOutput) { - let intermediate = parse(s, input, clear); - let run_func_body = intermediate.run_func_body().expect("no run func body"); - let expr = expr(run_func_body); - (Type::from_expression(&expr).map(Type::map_special), intermediate) - } - fn get_type_ethabi(s: &mut SessionSource, input: &str, clear: bool) -> Option { - let (ty, intermediate) = get_type(s, input, clear); - ty.and_then(|ty| ty.try_as_ethabi(Some(&intermediate))) + // Stage 2: walk HIR (immutable access). + let gcx = c.gcx(); + let hir = &gcx.hir; + let repl = hir.contracts().find(|c| c.name.as_str() == "REPL")?; + let run_fid = repl + .functions() + .find(|&f| hir.function(f).name.as_ref().map(|n| n.as_str()) == Some("run"))?; + let body = hir.function(run_fid).body?; + let last = body.last()?; + let expr = match last.kind { + StmtKind::Expr(e) => e, + _ => return None, + }; + expr_to_dyn(gcx, expr) + }) } - fn generic_type_test<'a, T, I>(s: &mut SessionSource, input: I) + fn generic_type_test<'a, T, I>(s: &mut TestSessionSource, input: I) where T: AsRef + std::fmt::Display + 'a, I: IntoIterator + 'a, { - for (input, expected) in input.into_iter() { + let mut failures = Vec::new(); + for (input, expected) in input { let input = input.as_ref(); let ty = get_type_ethabi(s, input, true); - assert_eq!(ty.as_ref(), Some(expected), "\n{input}"); + if ty.as_ref() != Some(expected) { + failures.push(format!("{input}: got {ty:?}, expected {expected:?}")); + } } + assert!(failures.is_empty(), "\n{}", failures.join("\n")); } fn init_tracing() { diff --git a/crates/chisel/src/history.rs b/crates/chisel/src/history.rs deleted file mode 100644 index 927ae0fcf4bf7..0000000000000 --- a/crates/chisel/src/history.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! chisel history file - -use std::path::PathBuf; - -/// The name of the chisel history file -pub const CHISEL_HISTORY_FILE_NAME: &str = ".chisel_history"; - -/// Returns the path to foundry's chisel history file that's stored at `~/.foundry/.chisel_history` -pub fn chisel_history_file() -> Option { - foundry_config::Config::foundry_dir().map(|p| p.join(CHISEL_HISTORY_FILE_NAME)) -} diff --git a/crates/chisel/src/lib.rs b/crates/chisel/src/lib.rs index 32a29caefc1b8..5f8a99e7c83e7 100644 --- a/crates/chisel/src/lib.rs +++ b/crates/chisel/src/lib.rs @@ -1,24 +1,32 @@ //! Chisel is a fast, utilitarian, and verbose Solidity REPL. -#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] #![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![cfg_attr(docsrs, feature(doc_cfg))] #[macro_use] extern crate foundry_common; +#[macro_use] +extern crate tracing; pub mod args; + pub mod cmd; + pub mod dispatcher; + pub mod executor; -pub mod history; + pub mod opts; + pub mod runner; + pub mod session; -pub mod session_source; -pub mod solidity_helper; + +pub mod source; + +mod solidity_helper; +pub use solidity_helper::SolidityHelper; pub mod prelude { - pub use crate::{ - cmd::*, dispatcher::*, runner::*, session::*, session_source::*, solidity_helper::*, - }; + pub use crate::{cmd::*, dispatcher::*, runner::*, session::*, solidity_helper::*, source::*}; } diff --git a/crates/chisel/src/opts.rs b/crates/chisel/src/opts.rs index c678b3895c6fc..701cbbcd10ca1 100644 --- a/crates/chisel/src/opts.rs +++ b/crates/chisel/src/opts.rs @@ -1,13 +1,9 @@ use clap::{Parser, Subcommand}; -use foundry_cli::opts::{BuildOpts, GlobalArgs}; -use foundry_common::{ - evm::EvmArgs, - version::{LONG_VERSION, SHORT_VERSION}, -}; +use foundry_cli::opts::{BuildOpts, EvmArgs, GlobalArgs}; +use foundry_common::version::{LONG_VERSION, SHORT_VERSION}; use std::path::PathBuf; -// Loads project's figment and merges the build cli arguments into it -foundry_config::merge_impl_figment_convert!(Chisel, build, evm); +foundry_config::impl_figment_convert!(Chisel, build, evm); /// Fast, utilitarian, and verbose Solidity REPL. #[derive(Debug, Parser)] @@ -31,10 +27,17 @@ pub struct Chisel { #[arg(long, help_heading = "REPL options", long_help = format!( "Disable the default `Vm` import.\n\n\ The import is disabled by default if the Solc version is less than {}.", - crate::session_source::MIN_VM_VERSION + crate::source::MIN_VM_VERSION ))] pub no_vm: bool, + /// Enable viaIR with minimum optimization + /// + /// This can fix most of the "stack too deep" errors while resulting a + /// relatively accurate source map. + #[arg(long, help_heading = "REPL options")] + pub ir_minimum: bool, + #[command(flatten)] pub build: BuildOpts, @@ -45,25 +48,27 @@ pub struct Chisel { /// Chisel binary subcommands #[derive(Debug, Subcommand)] pub enum ChiselSubcommand { - /// List all cached sessions + /// List all cached sessions. List, - /// Load a cached session + /// Load a cached session. + /// + /// Credentials come from this invocation. Supply --fork-url to restore a forked session. Load { /// The ID of the session to load. id: String, }, - /// View the source of a cached session + /// View the source of a cached session without restoring RPC credentials or compiling it. View { /// The ID of the session to load. id: String, }, - /// Clear all cached chisel sessions from the cache directory + /// Clear all cached chisel sessions from the cache directory. ClearCache, - /// Simple evaluation of a command without entering the REPL + /// Simple evaluation of a command without entering the REPL. Eval { /// The command to be evaluated. command: String, diff --git a/crates/chisel/src/runner.rs b/crates/chisel/src/runner.rs index c2d44f0f51fff..053e7706503ff 100644 --- a/crates/chisel/src/runner.rs +++ b/crates/chisel/src/runner.rs @@ -6,10 +6,10 @@ use alloy_primitives::{Address, Bytes, Log, U256, map::AddressHashMap}; use eyre::Result; use foundry_evm::{ + core::evm::FoundryEvmNetwork, executors::{DeployResult, Executor, RawCallResult}, traces::{TraceKind, Traces}, }; -use revm::interpreter::{InstructionResult, return_ok}; /// The function selector of the REPL contract's entrypoint, the `run()` function. static RUN_SELECTOR: [u8; 4] = [0xc0, 0x40, 0x62, 0x26]; @@ -19,9 +19,9 @@ static RUN_SELECTOR: [u8; 4] = [0xc0, 0x40, 0x62, 0x26]; /// Based off of foundry's forge cli runner for scripting. /// See: [runner](cli::cmd::forge::script::runner.rs) #[derive(Debug)] -pub struct ChiselRunner { +pub struct ChiselRunner { /// The Executor - pub executor: Executor, + pub executor: Executor, /// An initial balance pub initial_balance: U256, /// The sender @@ -46,13 +46,13 @@ pub struct ChiselResult { /// Return data pub returned: Bytes, /// Called address - pub address: Option
, + pub address: Address, /// EVM State at the final instruction of the `run()` function - pub state: Option<(Vec, Vec, Option)>, + pub state: Option<(Vec, Vec)>, } /// ChiselRunner implementation -impl ChiselRunner { +impl ChiselRunner { /// Create a new [ChiselRunner] /// /// ### Takes @@ -62,8 +62,8 @@ impl ChiselRunner { /// ### Returns /// /// A new [ChiselRunner] - pub fn new( - executor: Executor, + pub const fn new( + executor: Executor, initial_balance: U256, sender: Address, input: Option>, @@ -72,17 +72,7 @@ impl ChiselRunner { } /// Run a contract as a REPL session - /// - /// ### Takes - /// - /// The creation bytecode of the REPL contract - /// - /// ### Returns - /// - /// A tuple containing the deployed address of the bytecode as well as a - /// [ChiselResult] containing information about the result of the call to the deployed REPL - /// contract. - pub fn run(&mut self, bytecode: Bytes) -> Result<(Address, ChiselResult)> { + pub fn run(&mut self, bytecode: Bytes) -> Result { // Set the sender's balance to [U256::MAX] for deployment of the REPL contract. self.executor.set_balance(self.sender, U256::MAX)?; @@ -102,112 +92,20 @@ impl ChiselRunner { calldata.append(&mut input); } - // Call the "run()" function of the REPL contract - let call_res = self.call(self.sender, address, Bytes::from(calldata), U256::from(0), true); + let res = self.executor.transact_raw(self.sender, address, calldata.into(), U256::ZERO)?; - call_res.map(|res| (address, res)) - } - - /// Executes the call. - /// - /// This will commit the changes if `commit` is true. - /// - /// This will return _estimated_ gas instead of the precise gas the call would consume, so it - /// can be used as `gas_limit`. - /// - /// Taken from Forge's script runner. - fn call( - &mut self, - from: Address, - to: Address, - calldata: Bytes, - value: U256, - commit: bool, - ) -> eyre::Result { - let fs_commit_changed = - if let Some(cheatcodes) = &mut self.executor.inspector_mut().cheatcodes { - let original_fs_commit = cheatcodes.fs_commit; - cheatcodes.fs_commit = false; - original_fs_commit != cheatcodes.fs_commit - } else { - false - }; - - let mut res = self.executor.call_raw(from, to, calldata.clone(), value)?; - let mut gas_used = res.gas_used; - if matches!(res.exit_reason, Some(return_ok!())) { - // store the current gas limit and reset it later - let init_gas_limit = self.executor.env().tx.gas_limit; - - // the executor will return the _exact_ gas value this transaction consumed, setting - // this value as gas limit will result in `OutOfGas` so to come up with a - // better estimate we search over a possible range we pick a higher gas - // limit 3x of a succeeded call should be safe - let mut highest_gas_limit = gas_used * 3; - let mut lowest_gas_limit = gas_used; - let mut last_highest_gas_limit = highest_gas_limit; - while (highest_gas_limit - lowest_gas_limit) > 1 { - let mid_gas_limit = (highest_gas_limit + lowest_gas_limit) / 2; - self.executor.env_mut().tx.gas_limit = mid_gas_limit; - let res = self.executor.call_raw(from, to, calldata.clone(), value)?; - match res.exit_reason { - Some(InstructionResult::Revert) - | Some(InstructionResult::OutOfGas) - | Some(InstructionResult::OutOfFunds) => { - lowest_gas_limit = mid_gas_limit; - } - _ => { - highest_gas_limit = mid_gas_limit; - // if last two successful estimations only vary by 10%, we consider this to - // sufficiently accurate - const ACCURACY: u64 = 10; - if (last_highest_gas_limit - highest_gas_limit) * ACCURACY - / last_highest_gas_limit - < 1 - { - // update the gas - gas_used = highest_gas_limit; - break; - } - last_highest_gas_limit = highest_gas_limit; - } - } - } - // reset gas limit in the executor environment to its original value - self.executor.env_mut().tx.gas_limit = init_gas_limit; - } - - // if we changed `fs_commit` during gas limit search, re-execute the call with original - // value - if fs_commit_changed { - if let Some(cheatcodes) = &mut self.executor.inspector_mut().cheatcodes { - cheatcodes.fs_commit = !cheatcodes.fs_commit; - } - - res = self.executor.call_raw(from, to, calldata.clone(), value)?; - } - - if commit { - // if explicitly requested we can now commit the call - res = self.executor.transact_raw(from, to, calldata, value)?; - } - - let RawCallResult { result, reverted, logs, traces, labels, chisel_state, .. } = res; + let RawCallResult { + result, reverted, logs, traces, labels, chisel_state, gas_used, .. + } = res; Ok(ChiselResult { returned: result, success: !reverted, gas_used, logs, - traces: traces - .map(|traces| { - // Manually adjust gas for the trace to add back the stipend/real used gas - - vec![(TraceKind::Execution, traces)] - }) - .unwrap_or_default(), + traces: traces.map(|traces| vec![(TraceKind::Execution, traces)]).unwrap_or_default(), labeled_addresses: labels, - address: None, + address, state: chisel_state, }) } diff --git a/crates/chisel/src/session.rs b/crates/chisel/src/session.rs index 3f7bffdf5ece4..015bad15f13af 100644 --- a/crates/chisel/src/session.rs +++ b/crates/chisel/src/session.rs @@ -5,21 +5,54 @@ use crate::prelude::{SessionSource, SessionSourceConfig}; use eyre::Result; +use foundry_cli::utils::parse_json; +use foundry_evm::{core::evm::FoundryEvmNetwork, executors::ExecutorBuilder}; use serde::{Deserialize, Serialize}; -use std::path::Path; +use std::{ + fs::{self, File}, + io::{Read, Write}, + path::{Path, PathBuf}, +}; use time::{OffsetDateTime, format_description}; +#[cfg(unix)] +use std::os::unix::fs::{DirBuilderExt, PermissionsExt}; + +/// Rejects a session id that would let `chisel-.json` escape the cache directory when +/// concatenated into a path (e.g. `../../etc/cron.d/evil`, which yields the literal path +/// component `chisel-..`, followed by a real `..` component once the id itself contains a `/`). +/// Also rejects `:` to prevent targeting Windows Alternate Data Streams (ADS). +fn validate_session_id(id: &str) -> Result<()> { + if id.is_empty() || id == "." || id == ".." || id.contains(['/', '\\', ':']) { + eyre::bail!( + "invalid Chisel session id `{id}`: must not be empty, `.`, `..`, or contain a path \ + separator or `:`" + ); + } + Ok(()) +} + /// A Chisel REPL Session #[derive(Debug, Serialize, Deserialize)] -pub struct ChiselSession { +#[serde(bound = "")] +pub struct ChiselSession { /// The `SessionSource` object that houses the REPL session. - pub session_source: SessionSource, + pub source: SessionSource, /// The current session's identifier pub id: Option, } // ChiselSession Common Associated Functions -impl ChiselSession { +impl ChiselSession { + fn deserialize_cached(contents: &str, executor_builder: ExecutorBuilder) -> Result { + let mut session: Self = parse_json(contents)?; + session.source.config.clear_credentials(); + // A session load must not run project cleanup requested by cached configuration. + session.source.config.foundry_config.force = false; + session.source.config.executor_builder = executor_builder; + Ok(session) + } + /// Create a new `ChiselSession` with a specified `solc` version and configuration. /// /// ### Takes @@ -29,10 +62,9 @@ impl ChiselSession { /// ### Returns /// /// A new instance of [ChiselSession] - pub fn new(config: SessionSourceConfig) -> Result { - let solc = config.solc()?; + pub fn new(config: SessionSourceConfig) -> Result { // Return initialized ChiselSession with set solc version - Ok(Self { session_source: SessionSource::new(solc, config), id: None }) + Ok(Self { source: SessionSource::new(config)?, id: None }) } /// Render the full source code for the current session. @@ -46,7 +78,7 @@ impl ChiselSession { /// This function will not panic, but will return a blank string if the /// session's [SessionSource] is None. pub fn contract_source(&self) -> String { - self.session_source.to_repl_source() + self.source.to_repl_source() } /// Clears the cache directory @@ -69,15 +101,31 @@ impl ChiselSession { Ok(()) } + /// Removes a cached session if it exists. + pub fn remove_cached_session(id: &str) -> Result<()> { + validate_session_id(id)?; + let cache_file = format!("{}chisel-{id}.json", Self::cache_dir()?); + match std::fs::remove_file(cache_file) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } + } + /// Writes the ChiselSession to a file by serializing it to a JSON string /// /// ### Returns /// /// Returns the path of the new cache file pub fn write(&mut self) -> Result { - // Try to create the cache directory - let cache_dir = Self::cache_dir()?; - std::fs::create_dir_all(&cache_dir)?; + self.write_to(&Self::cache_dir()?) + } + + fn write_to(&mut self, cache_dir: &str) -> Result { + if let Some(id) = &self.id { + validate_session_id(id)?; + } + Self::secure_cache_dir(cache_dir)?; let cache_file_name = match self.id.as_ref() { Some(id) => { @@ -86,7 +134,7 @@ impl ChiselSession { } None => { // Get the next session cache ID / file - let (id, file_name) = Self::next_cached_session()?; + let (id, file_name) = Self::next_cached_session_in(cache_dir)?; // Set the session's ID self.id = Some(id); // Return the new session's cache file name @@ -94,9 +142,15 @@ impl ChiselSession { } }; - // Write the current ChiselSession to that file - let serialized_contents = serde_json::to_string_pretty(self)?; - std::fs::write(&cache_file_name, serialized_contents)?; + // The temporary file is private from creation, and replacement does not follow a + // destination symlink or retain the permissions of an older session. + let mut file = tempfile::NamedTempFile::new_in(cache_dir)?; + #[cfg(unix)] + file.as_file().set_permissions(fs::Permissions::from_mode(0o600))?; + serde_json::to_writer_pretty(&mut file, self)?; + file.flush()?; + file.as_file().sync_all()?; + file.persist(&cache_file_name).map_err(|err| err.error)?; // Return the full cache file path // Ex: /home/user/.foundry/cache/chisel/chisel-0.json @@ -108,30 +162,29 @@ impl ChiselSession { /// ### Returns /// /// Optionally, returns a tuple containing the next cached session's id and file name. + /// + /// Uses one past the highest numeric ID to avoid collisions after deletion. pub fn next_cached_session() -> Result<(String, String)> { - let cache_dir = Self::cache_dir()?; - let mut entries = std::fs::read_dir(&cache_dir)?; - - // If there are no existing cached sessions, just create the first one: "chisel-0.json" - let mut latest = if let Some(e) = entries.next() { - e? - } else { - return Ok((String::from("0"), format!("{cache_dir}chisel-0.json"))); - }; - - let mut session_num = 1; - // Get the latest cached session - for entry in entries { - let entry = entry?; - if entry.metadata()?.modified()? >= latest.metadata()?.modified()? { - latest = entry; - } + Self::next_cached_session_in(&Self::cache_dir()?) + } - // Increase session_num counter rather than cloning the iterator and using `.count` - session_num += 1; - } + fn next_cached_session_in(cache_dir: &str) -> Result<(String, String)> { + let next_id = std::fs::read_dir(cache_dir)? + .filter_map(|entry| entry.ok()) + .filter_map(|entry| { + entry + .file_name() + .to_str()? + .strip_prefix("chisel-")? + .strip_suffix(".json")? + .parse::() + .ok() + }) + .max() + .map_or(Some(0), |max| max.checked_add(1)) + .ok_or_else(|| eyre::eyre!("no unused chisel session id available"))?; - Ok((format!("{session_num}"), format!("{cache_dir}chisel-{session_num}.json"))) + Ok((format!("{next_id}"), format!("{cache_dir}chisel-{next_id}.json"))) } /// The Chisel Cache Directory @@ -154,45 +207,31 @@ impl ChiselSession { /// /// The unit type if the operation was successful. pub fn create_cache_dir() -> Result<()> { - let cache_dir = Self::cache_dir()?; - if !Path::new(&cache_dir).exists() { - std::fs::create_dir_all(&cache_dir)?; - } - Ok(()) + Self::secure_cache_dir(&Self::cache_dir()?) } - /// Lists all available cached sessions - /// - /// ### Returns - /// - /// Optionally, a vector containing tuples of session IDs and cache-file names. - pub fn list_sessions() -> Result> { + /// Returns a list of all available cached sessions. + pub fn get_sessions() -> Result> { // Read the cache directory entries let cache_dir = Self::cache_dir()?; - let entries = std::fs::read_dir(cache_dir)?; + let entries = Self::cached_session_files(&cache_dir)?; // For each entry, get the file name and modified time let mut sessions = Vec::new(); for entry in entries { - let entry = entry?; let modified_time = entry.metadata()?.modified()?; let file_name = entry.file_name(); let file_name = file_name .into_string() .map_err(|e| eyre::eyre!(format!("{}", e.to_string_lossy())))?; sessions.push(( - systemtime_strftime(modified_time, "[year]-[month]-[day] [hour]:[minute]:[second]") - .unwrap(), + OffsetDateTime::from(modified_time).format(&format_description::parse( + "[year]-[month]-[day] [hour]:[minute]:[second]", + )?)?, file_name, )); } - - if sessions.is_empty() { - eyre::bail!("No sessions found!") - } else { - // Return the list of sessions and their modified times - Ok(sessions) - } + Ok(sessions) } /// Loads a specific ChiselSession from the specified cache file @@ -204,11 +243,22 @@ impl ChiselSession { /// ### Returns /// /// Optionally, an owned instance of the loaded chisel session. - pub fn load(id: &str) -> Result { - let cache_dir = Self::cache_dir()?; - let contents = std::fs::read_to_string(Path::new(&format!("{cache_dir}chisel-{id}.json")))?; - let chisel_env: Self = serde_json::from_str(&contents)?; - Ok(chisel_env) + pub fn load(id: &str, executor_builder: ExecutorBuilder) -> Result { + Self::load_from(id, &Self::cache_dir()?, executor_builder) + } + + fn load_from( + id: &str, + cache_dir: &str, + executor_builder: ExecutorBuilder, + ) -> Result { + validate_session_id(id)?; + Self::secure_cache_dir(cache_dir)?; + let contents = Self::read_cached_file(Path::new(&format!("{cache_dir}chisel-{id}.json")))?; + let mut session = Self::deserialize_cached(&contents, executor_builder)?; + // Use the requested ID even if the cached ID is missing or stale. + session.id = Some(id.to_string()); + Ok(session) } /// Gets the most recent chisel session from the cache dir @@ -217,11 +267,13 @@ impl ChiselSession { /// /// Optionally, the file name of the most recently modified cached session. pub fn latest_cached_session() -> Result { - let cache_dir = Self::cache_dir()?; - let mut entries = std::fs::read_dir(cache_dir)?; - let mut latest = entries.next().ok_or_else(|| eyre::eyre!("No entries found!"))??; + Self::latest_cached_session_in(&Self::cache_dir()?) + } + + fn latest_cached_session_in(cache_dir: &str) -> Result { + let mut entries = Self::cached_session_files(cache_dir)?.into_iter(); + let mut latest = entries.next().ok_or_else(|| eyre::eyre!("No entries found!"))?; for entry in entries { - let entry = entry?; if entry.metadata()?.modified()? > latest.metadata()?.modified()? { latest = entry; } @@ -238,19 +290,537 @@ impl ChiselSession { /// ### Returns /// /// Optionally, an owned instance of the most recently modified cached session. - pub fn latest() -> Result { - let last_session = Self::latest_cached_session()?; - let last_session_contents = std::fs::read_to_string(Path::new(&last_session))?; - let chisel_env: Self = serde_json::from_str(&last_session_contents)?; - Ok(chisel_env) + pub fn latest(executor_builder: ExecutorBuilder) -> Result { + Self::latest_from(&Self::cache_dir()?, executor_builder) + } + + fn latest_from(cache_dir: &str, executor_builder: ExecutorBuilder) -> Result { + let last_session = Self::latest_cached_session_in(cache_dir)?; + let last_session_contents = Self::read_cached_file(Path::new(&last_session))?; + let mut session = Self::deserialize_cached(&last_session_contents, executor_builder)?; + // Bind the session to the file that was loaded. + session.id = Self::session_id_from_cache_file_name(&last_session); + Ok(session) + } + + /// Extracts the session id from a `.../chisel-.json` cache file path. + fn session_id_from_cache_file_name(path: &str) -> Option { + Path::new(path).file_stem()?.to_str()?.strip_prefix("chisel-").map(str::to_string) + } + + /// Protects private source and credentials in both new and legacy cache directories. + fn secure_cache_dir(cache_dir: &str) -> Result<()> { + // Remove trailing separators so symlink_metadata inspects the directory entry itself. + let path = Path::new(cache_dir).components().collect::(); + let mut builder = fs::DirBuilder::new(); + builder.recursive(true); + #[cfg(unix)] + builder.mode(0o700); + builder.create(&path)?; + eyre::ensure!( + fs::symlink_metadata(&path)?.is_dir(), + "Chisel cache must be a directory, not a symlink" + ); + #[cfg(unix)] + fs::set_permissions(&path, fs::Permissions::from_mode(0o700))?; + Ok(()) + } + + /// Reads a regular session file after restricting legacy file permissions. + fn read_cached_file(path: &Path) -> Result { + eyre::ensure!( + fs::symlink_metadata(path)?.is_file(), + "Chisel session must be a regular file" + ); + let mut file = File::open(path)?; + eyre::ensure!(file.metadata()?.is_file(), "Chisel session must be a regular file"); + #[cfg(unix)] + file.set_permissions(fs::Permissions::from_mode(0o600))?; + let mut contents = String::new(); + file.read_to_string(&mut contents)?; + Ok(contents) + } + + /// Excludes temporary saves, unrelated files, and symlinks from session discovery. + fn cached_session_files(cache_dir: &str) -> Result> { + Self::secure_cache_dir(cache_dir)?; + let mut sessions = Vec::new(); + for entry in fs::read_dir(cache_dir)? { + let entry = entry?; + if entry.file_type()?.is_file() + && let Some(name) = entry.file_name().to_str() + && let Some(id) = + name.strip_prefix("chisel-").and_then(|name| name.strip_suffix(".json")) + && validate_session_id(id).is_ok() + { + sessions.push(entry); + } + } + Ok(sessions) } } -/// Generic helper function that attempts to convert a type that has -/// an [`Into`] implementation into a formatted date string. -fn systemtime_strftime(dt: T, format: &str) -> Result -where - T: Into, -{ - Ok(dt.into().format(&format_description::parse(format)?)?) +#[cfg(test)] +mod tests { + use super::*; + use foundry_config::{Config, SolcReq}; + use foundry_evm::core::evm::EthEvmNetwork; + use semver::Version; + + #[cfg(unix)] + use std::os::unix::fs::symlink; + + #[cfg(feature = "monad")] + use foundry_evm::core::{constants::MONAD_CHEATCODE_ADDRESS, evm::MonadEvmNetwork}; + + /// Deleted sessions must not cause the next ID to collide with an existing file. + #[test] + fn next_cached_session_skips_gaps_left_by_deleted_sessions() { + let dir = tempfile::tempdir().unwrap(); + let cache_dir = format!("{}/", dir.path().to_str().unwrap()); + + // Sessions 0 and 2 exist; session 1 was deleted or renamed away, leaving a gap. + std::fs::write(format!("{cache_dir}chisel-0.json"), "{\"id\":\"0\"}").unwrap(); + std::fs::write(format!("{cache_dir}chisel-2.json"), "{\"id\":\"2\"}").unwrap(); + + let (next_id, next_file) = + ChiselSession::::next_cached_session_in(&cache_dir).unwrap(); + + // Counting entries would select the occupied ID 2. + assert_eq!(next_id, "3", "must skip past the gap instead of reusing the occupied id 2"); + assert_eq!(next_file, format!("{cache_dir}chisel-3.json")); + + assert_eq!( + std::fs::read_to_string(format!("{cache_dir}chisel-0.json")).unwrap(), + "{\"id\":\"0\"}" + ); + assert_eq!( + std::fs::read_to_string(format!("{cache_dir}chisel-2.json")).unwrap(), + "{\"id\":\"2\"}" + ); + } + + #[test] + fn next_cached_session_does_not_overflow_on_a_usize_max_named_session() { + let dir = tempfile::tempdir().unwrap(); + let cache_dir = format!("{}/", dir.path().to_str().unwrap()); + std::fs::write(format!("{cache_dir}chisel-{}.json", usize::MAX), "{}").unwrap(); + + let result = ChiselSession::::next_cached_session_in(&cache_dir); + assert!(result.is_err(), "must error instead of panicking or wrapping to a reused id"); + } + + #[test] + fn deserialized_sessions_do_not_restore_force() { + let session = ChiselSession::::new(SessionSourceConfig { + foundry_config: Config { + force: true, + solc: Some(SolcReq::Version(Version::new(0, 8, 29))), + ..Default::default() + }, + no_vm: true, + ..Default::default() + }) + .unwrap(); + assert!(session.source.config.foundry_config.force); + + let serialized = serde_json::to_string(&session).unwrap(); + let session = ChiselSession::::deserialize_cached( + &serialized, + ExecutorBuilder::::new(), + ) + .unwrap(); + + assert!(!session.source.config.foundry_config.force); + } + + #[cfg(feature = "monad")] + #[test] + fn deserialized_sessions_use_active_monad_tooling() { + let session = ChiselSession::::new(SessionSourceConfig { + executor_builder: ExecutorBuilder::::new(), + ..Default::default() + }) + .unwrap(); + let serialized = serde_json::to_string(&session).unwrap(); + + let session = ChiselSession::::deserialize_cached( + &serialized, + ExecutorBuilder::::new(), + ) + .unwrap(); + + assert_eq!( + session.source.config.executor_builder.extra_cheatcode_addresses(), + &[MONAD_CHEATCODE_ADDRESS] + ); + } + + /// A session id containing a path separator lets `chisel-.json` escape the cache + /// directory once resolved: `chisel-x/../../../foo.json` has real `..` path components + /// after the `x` segment, walking back out past the cache directory entirely. + /// Also verifies that `:` is rejected to prevent targeting NTFS Alternate Data Streams (ADS). + #[test] + fn path_traversal_ids_are_rejected() { + for id in [ + "../evil", + "x/../../../../../../tmp/pwned", + "..", + ".", + "", + "sub/dir", + "back\\slash", + ":colon", + "foo:bar", + "session:1", + ] { + let err = validate_session_id(id).unwrap_err(); + assert!(err.to_string().contains("invalid Chisel session id"), "{id:?}: {err}"); + } + + // ordinary numeric and name-like ids remain accepted + for id in ["0", "42", "my-session", "my_session"] { + validate_session_id(id).unwrap(); + } + } + + #[test] + fn load_rejects_path_traversal_id() { + let err = ChiselSession::::load( + "../../evil", + ExecutorBuilder::::new(), + ) + .unwrap_err(); + assert!(err.to_string().contains("invalid Chisel session id"), "{err}"); + } + + #[test] + fn remove_cached_session_rejects_path_traversal_id() { + let err = ChiselSession::::remove_cached_session("../../evil").unwrap_err(); + assert!(err.to_string().contains("invalid Chisel session id"), "{err}"); + } + + fn session_for_normalization_tests() -> ChiselSession { + ChiselSession::::new(SessionSourceConfig { + foundry_config: Config { + solc: Some(SolcReq::Version(Version::new(0, 8, 29))), + ..Default::default() + }, + no_vm: true, + ..Default::default() + }) + .unwrap() + } + + /// Loading uses the filename rather than a stale or missing cached ID. + #[test] + fn load_normalizes_id_ignoring_a_stale_or_missing_embedded_id() { + let dir = tempfile::tempdir().unwrap(); + let cache_dir = format!("{}/", dir.path().to_str().unwrap()); + + let mut session = session_for_normalization_tests(); + session.id = Some("stale-name".to_string()); + let serialized = serde_json::to_string(&session).unwrap(); + std::fs::write(format!("{cache_dir}chisel-5.json"), &serialized).unwrap(); + + let loaded = ChiselSession::::load_from( + "5", + &cache_dir, + ExecutorBuilder::::new(), + ) + .unwrap(); + assert_eq!(loaded.id.as_deref(), Some("5"), "must use the requested id, not the stale one"); + + let without_id = serialized.replacen("\"stale-name\"", "null", 1); + std::fs::write(format!("{cache_dir}chisel-7.json"), without_id).unwrap(); + let loaded = ChiselSession::::load_from( + "7", + &cache_dir, + ExecutorBuilder::::new(), + ) + .unwrap(); + assert_eq!(loaded.id.as_deref(), Some("7"), "a null embedded id must not survive the load"); + } + + #[test] + fn latest_normalizes_id_from_the_resolved_file_name() { + let dir = tempfile::tempdir().unwrap(); + let cache_dir = format!("{}/", dir.path().to_str().unwrap()); + + let session = session_for_normalization_tests(); + // New sessions serialize with a null ID. + let serialized = serde_json::to_string(&session).unwrap(); + std::fs::write(format!("{cache_dir}chisel-9.json"), serialized).unwrap(); + + let loaded = ChiselSession::::latest_from( + &cache_dir, + ExecutorBuilder::::new(), + ) + .unwrap(); + assert_eq!(loaded.id.as_deref(), Some("9")); + } + + #[test] + fn session_id_from_cache_file_name_strips_prefix_and_extension() { + assert_eq!( + ChiselSession::::session_id_from_cache_file_name( + "/home/user/.foundry/cache/chisel/chisel-42.json" + ), + Some("42".to_string()) + ); + assert_eq!( + ChiselSession::::session_id_from_cache_file_name( + "/home/user/.foundry/cache/chisel/not-a-session-file.json" + ), + None + ); + } + + #[test] + fn write_rejects_path_traversal_id() { + let mut session = ChiselSession::::new(SessionSourceConfig { + foundry_config: Config { + solc: Some(SolcReq::Version(Version::new(0, 8, 29))), + ..Default::default() + }, + no_vm: true, + ..Default::default() + }) + .unwrap(); + session.id = Some("../../evil".to_string()); + + let err = session.write().unwrap_err(); + assert!(err.to_string().contains("invalid Chisel session id"), "{err}"); + } + + #[cfg(unix)] + #[test] + fn saved_sessions_are_private() { + let dir = tempfile::tempdir().unwrap(); + let cache = dir.path().join("chisel"); + std::fs::create_dir(&cache).unwrap(); + std::fs::set_permissions(&cache, std::fs::Permissions::from_mode(0o755)).unwrap(); + let file = cache.join("chisel-private.json"); + std::fs::write(&file, "old session").unwrap(); + std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).unwrap(); + let mut session = session_for_normalization_tests(); + session.id = Some("private".to_string()); + session.source.run_code = "uint256 privateValue = 42;".to_string(); + + session.write_to(&format!("{}/", cache.display())).unwrap(); + + assert_eq!(std::fs::metadata(&cache).unwrap().permissions().mode() & 0o777, 0o700); + assert_eq!(std::fs::metadata(&file).unwrap().permissions().mode() & 0o777, 0o600); + let saved: ChiselSession = + serde_json::from_str(&std::fs::read_to_string(file).unwrap()).unwrap(); + assert_eq!(saved.source.run_code, session.source.run_code); + } + + #[cfg(unix)] + #[test] + fn new_cache_directories_are_private_without_changing_existing_parents() { + let dir = tempfile::tempdir().unwrap(); + fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o755)).unwrap(); + let cache = dir.path().join(".foundry/cache/chisel"); + let mut session = session_for_normalization_tests(); + + let file = session.write_to(&format!("{}/", cache.display())).unwrap(); + + assert_eq!(fs::metadata(dir.path()).unwrap().permissions().mode() & 0o777, 0o755); + for path in [dir.path().join(".foundry"), dir.path().join(".foundry/cache"), cache] { + assert_eq!(fs::metadata(path).unwrap().permissions().mode() & 0o777, 0o700); + } + assert_eq!(fs::metadata(file).unwrap().permissions().mode() & 0o777, 0o600); + } + + #[cfg(unix)] + #[test] + fn saving_replaces_symlinks_without_changing_their_targets() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("unrelated.json"); + fs::write(&target, "untouched").unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o644)).unwrap(); + let cache = dir.path().join("chisel"); + fs::create_dir(&cache).unwrap(); + let destination = cache.join("chisel-linked.json"); + symlink(&target, &destination).unwrap(); + let mut session = session_for_normalization_tests(); + session.id = Some("linked".to_string()); + + session.write_to(&format!("{}/", cache.display())).unwrap(); + + assert!(fs::symlink_metadata(&destination).unwrap().is_file()); + assert_eq!(fs::metadata(destination).unwrap().permissions().mode() & 0o777, 0o600); + assert_eq!(fs::read_to_string(&target).unwrap(), "untouched"); + assert_eq!(fs::metadata(target).unwrap().permissions().mode() & 0o777, 0o644); + } + + #[cfg(unix)] + #[test] + fn cache_directory_symlinks_are_rejected_without_changing_targets() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("unrelated"); + fs::create_dir(&target).unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o755)).unwrap(); + let cache = dir.path().join("chisel"); + symlink(&target, &cache).unwrap(); + let mut session = session_for_normalization_tests(); + + let result = session.write_to(&format!("{}/", cache.display())); + + assert_eq!( + result.unwrap_err().to_string(), + "Chisel cache must be a directory, not a symlink" + ); + assert_eq!(fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o755); + assert_eq!(fs::read_dir(target).unwrap().count(), 0); + } + + #[cfg(unix)] + #[test] + fn loading_legacy_sessions_restricts_file_and_directory_permissions() { + let dir = tempfile::tempdir().unwrap(); + let cache_dir = format!("{}/", dir.path().display()); + let path = dir.path().join("chisel-legacy.json"); + let session = session_for_normalization_tests(); + fs::write(&path, serde_json::to_vec(&session).unwrap()).unwrap(); + fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o755)).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); + + let loaded = ChiselSession::::load_from( + "legacy", + &cache_dir, + ExecutorBuilder::::new(), + ) + .unwrap(); + + assert_eq!(loaded.id.as_deref(), Some("legacy")); + assert_eq!(fs::metadata(dir.path()).unwrap().permissions().mode() & 0o777, 0o700); + assert_eq!(fs::metadata(path).unwrap().permissions().mode() & 0o777, 0o600); + } + + #[cfg(unix)] + #[test] + fn loading_rejects_symlinks_without_changing_their_targets() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("unrelated.json"); + fs::write(&target, "untouched").unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o644)).unwrap(); + symlink(&target, dir.path().join("chisel-linked.json")).unwrap(); + + let result = ChiselSession::::load_from( + "linked", + &format!("{}/", dir.path().display()), + ExecutorBuilder::::new(), + ); + + assert_eq!(result.unwrap_err().to_string(), "Chisel session must be a regular file"); + assert_eq!(fs::read_to_string(&target).unwrap(), "untouched"); + assert_eq!(fs::metadata(target).unwrap().permissions().mode() & 0o777, 0o644); + } + + #[test] + fn session_discovery_ignores_incomplete_and_unrelated_files() { + let dir = tempfile::tempdir().unwrap(); + let cache_dir = format!("{}/", dir.path().display()); + let mut session = session_for_normalization_tests(); + session.id = Some("saved".to_string()); + let saved = session.write_to(&cache_dir).unwrap(); + fs::write(dir.path().join(".tmp-incomplete"), "{").unwrap(); + fs::write(dir.path().join("unrelated.json"), "unrelated").unwrap(); + fs::write(dir.path().join("chisel-.json"), "invalid").unwrap(); + fs::create_dir(dir.path().join("chisel-directory.json")).unwrap(); + #[cfg(unix)] + symlink(&saved, dir.path().join("chisel-linked.json")).unwrap(); + + let sessions = ChiselSession::::cached_session_files(&cache_dir).unwrap(); + + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].path(), Path::new(&saved)); + assert_eq!( + ChiselSession::::latest_cached_session_in(&cache_dir).unwrap(), + saved + ); + let latest = ChiselSession::::latest_from( + &cache_dir, + ExecutorBuilder::::new(), + ) + .unwrap(); + assert_eq!(latest.id.as_deref(), Some("saved")); + } + + #[test] + fn failed_save_cleans_up_temporary_files() { + let dir = tempfile::tempdir().unwrap(); + let cache_dir = format!("{}/", dir.path().display()); + let destination = dir.path().join("chisel-blocked.json"); + fs::create_dir(&destination).unwrap(); + fs::write(destination.join("sentinel"), "untouched").unwrap(); + let mut session = session_for_normalization_tests(); + session.id = Some("blocked".to_string()); + + assert!(session.write_to(&cache_dir).is_err()); + + assert_eq!(fs::read_dir(dir.path()).unwrap().count(), 1); + assert_eq!(fs::read_to_string(destination.join("sentinel")).unwrap(), "untouched"); + } + + #[test] + fn loading_legacy_fork_sessions_discards_cached_credentials() { + let dir = tempfile::tempdir().unwrap(); + let cache_dir = format!("{}/", dir.path().display()); + let mut session = session_for_normalization_tests(); + session.source.run_code = "uint256 privateValue = 42;".into(); + session.source.config.calldata = Some(vec![0xde, 0xad, 0xbe, 0xef]); + let mut legacy = serde_json::to_value(&session).unwrap(); + let config = &mut legacy["source"]["config"]; + config.as_object_mut().unwrap().remove("fork_url_required"); + config["foundry_config"]["eth_rpc_url"] = "https://rpc.invalid/legacy-token".into(); + config["foundry_config"]["eth_rpc_jwt"] = "legacy-jwt".into(); + config["foundry_config"]["eth_rpc_headers"] = + serde_json::json!(["Authorization: legacy-header"]); + config["foundry_config"]["etherscan_api_key"] = "legacy-api-key".into(); + config["foundry_config"]["etherscan"] = serde_json::json!({ + "mainnet": { "key": "legacy-explorer-key", "chain": 1 } + }); + config["foundry_config"]["rpc_endpoints"] = serde_json::json!({ + "mainnet": "https://rpc.invalid/legacy-endpoint" + }); + config["evm_opts"]["eth_rpc_url"] = "https://rpc.invalid/legacy-token".into(); + config["evm_opts"]["eth_rpc_jwt"] = "legacy-jwt".into(); + config["evm_opts"]["eth_rpc_headers"] = serde_json::json!(["Authorization: legacy-header"]); + config["evm_opts"]["fork_headers"] = + serde_json::json!(["Authorization: legacy-fork-header"]); + let path = dir.path().join("chisel-legacy.json"); + fs::write(&path, serde_json::to_vec(&legacy).unwrap()).unwrap(); + + for loaded in [ + ChiselSession::::load_from( + "legacy", + &cache_dir, + ExecutorBuilder::::new(), + ) + .unwrap(), + ChiselSession::::latest_from( + &cache_dir, + ExecutorBuilder::::new(), + ) + .unwrap(), + ] { + let config = &loaded.source.config; + assert!(config.fork_url_required); + assert_eq!(config.foundry_config.eth_rpc_url, None); + assert_eq!(config.foundry_config.eth_rpc_jwt, None); + assert_eq!(config.foundry_config.eth_rpc_headers, None); + assert_eq!(config.foundry_config.etherscan_api_key, None); + assert!(config.foundry_config.etherscan.is_empty()); + assert!(config.foundry_config.rpc_endpoints.is_empty()); + assert_eq!(config.evm_opts.fork_url, None); + assert_eq!(config.evm_opts.rpc_jwt, None); + assert_eq!(config.evm_opts.rpc_headers, None); + assert_eq!(config.evm_opts.fork_headers, None); + assert_eq!(config.calldata, session.source.config.calldata); + assert_eq!(loaded.source.run_code, session.source.run_code); + } + } } diff --git a/crates/chisel/src/session_source.rs b/crates/chisel/src/session_source.rs deleted file mode 100644 index 3a6d0827ddacf..0000000000000 --- a/crates/chisel/src/session_source.rs +++ /dev/null @@ -1,683 +0,0 @@ -//! Session Source -//! -//! This module contains the `SessionSource` struct, which is a minimal wrapper around -//! the REPL contract's source code. It provides simple compilation, parsing, and -//! execution helpers. - -use alloy_primitives::map::HashMap; -use eyre::Result; -use forge_fmt::solang_ext::SafeUnwrap; -use foundry_compilers::{ - artifacts::{CompilerOutput, Settings, SolcInput, Source, Sources}, - compilers::solc::Solc, -}; -use foundry_config::{Config, SolcReq}; -use foundry_evm::{backend::Backend, opts::EvmOpts}; -use semver::Version; -use serde::{Deserialize, Serialize}; -use solang_parser::{diagnostics::Diagnostic, pt}; -use std::{fs, path::PathBuf}; -use walkdir::WalkDir; -use yansi::Paint; - -/// The minimum Solidity version of the `Vm` interface. -pub const MIN_VM_VERSION: Version = Version::new(0, 6, 2); - -/// Solidity source for the `Vm` interface in [forge-std](https://github.com/foundry-rs/forge-std) -static VM_SOURCE: &str = include_str!("../../../testdata/cheats/Vm.sol"); - -/// Intermediate output for the compiled [SessionSource] -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct IntermediateOutput { - /// All expressions within the REPL contract's run function and top level scope. - #[serde(skip)] - pub repl_contract_expressions: HashMap, - /// Intermediate contracts - #[serde(skip)] - pub intermediate_contracts: IntermediateContracts, -} - -/// A refined intermediate parse tree for a contract that enables easy lookups -/// of definitions. -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct IntermediateContract { - /// All function definitions within the contract - #[serde(skip)] - pub function_definitions: HashMap>, - /// All event definitions within the contract - #[serde(skip)] - pub event_definitions: HashMap>, - /// All struct definitions within the contract - #[serde(skip)] - pub struct_definitions: HashMap>, - /// All variable definitions within the top level scope of the contract - #[serde(skip)] - pub variable_definitions: HashMap>, -} - -/// A defined type for a map of contract names to [IntermediateContract]s -type IntermediateContracts = HashMap; - -/// Full compilation output for the [SessionSource] -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct GeneratedOutput { - /// The [IntermediateOutput] component - pub intermediate: IntermediateOutput, - /// The [CompilerOutput] component - pub compiler_output: CompilerOutput, -} - -/// Configuration for the [SessionSource] -#[derive(Clone, Debug, Default, Serialize, Deserialize)] -pub struct SessionSourceConfig { - /// Foundry configuration - pub foundry_config: Config, - /// EVM Options - pub evm_opts: EvmOpts, - /// Disable the default `Vm` import. - pub no_vm: bool, - #[serde(skip)] - /// In-memory REVM db for the session's runner. - pub backend: Option, - /// Optionally enable traces for the REPL contract execution - pub traces: bool, - /// Optionally set calldata for the REPL contract execution - pub calldata: Option>, -} - -impl SessionSourceConfig { - /// Returns the solc version to use - /// - /// Solc version precedence - /// - Foundry configuration / `--use` flag - /// - Latest installed version via SVM - /// - Default: Latest 0.8.19 - pub(crate) fn solc(&self) -> Result { - let solc_req = if let Some(solc_req) = self.foundry_config.solc.clone() { - solc_req - } else if let Some(version) = Solc::installed_versions().into_iter().max() { - SolcReq::Version(version) - } else { - if !self.foundry_config.offline { - sh_print!("{}", "No solidity versions installed! ".green())?; - } - // use default - SolcReq::Version(Version::new(0, 8, 19)) - }; - - match solc_req { - SolcReq::Version(version) => { - let solc = if let Some(solc) = Solc::find_svm_installed_version(&version)? { - solc - } else { - if self.foundry_config.offline { - eyre::bail!("can't install missing solc {version} in offline mode") - } - sh_println!("{}", format!("Installing solidity version {version}...").green())?; - Solc::blocking_install(&version)? - }; - Ok(solc) - } - SolcReq::Local(solc) => { - if !solc.is_file() { - eyre::bail!("`solc` {} does not exist", solc.display()); - } - Ok(Solc::new(solc)?) - } - } - } -} - -/// REPL Session Source wrapper -/// -/// Heavily based on soli's [`ConstructedSource`](https://github.com/jpopesculian/soli/blob/master/src/main.rs#L166) -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct SessionSource { - /// The file name - pub file_name: PathBuf, - /// The contract name - pub contract_name: String, - /// The solidity compiler version - pub solc: Solc, - /// Global level solidity code - /// - /// Typically, global-level code is present between the contract definition and the first - /// function (usually constructor) - pub global_code: String, - /// Top level solidity code - /// - /// Typically, this is code seen above the constructor - pub top_level_code: String, - /// Code existing within the "run()" function's scope - pub run_code: String, - /// The generated output - pub generated_output: Option, - /// Session Source configuration - pub config: SessionSourceConfig, -} - -impl SessionSource { - /// Creates a new source given a solidity compiler version - /// - /// # Panics - /// - /// If no Solc binary is set, cannot be found or the `--version` command fails - /// - /// ### Takes - /// - /// - An instance of [Solc] - /// - An instance of [SessionSourceConfig] - /// - /// ### Returns - /// - /// A new instance of [SessionSource] - #[track_caller] - pub fn new(solc: Solc, mut config: SessionSourceConfig) -> Self { - if solc.version < MIN_VM_VERSION && !config.no_vm { - tracing::info!(version=%solc.version, minimum=%MIN_VM_VERSION, "Disabling VM injection"); - config.no_vm = true; - } - - Self { - file_name: PathBuf::from("ReplContract.sol".to_string()), - contract_name: "REPL".to_string(), - solc, - config, - global_code: Default::default(), - top_level_code: Default::default(), - run_code: Default::default(), - generated_output: None, - } - } - - /// Clones a [SessionSource] without copying the [GeneratedOutput], as it will - /// need to be regenerated as soon as new code is added. - /// - /// ### Returns - /// - /// A shallow-cloned [SessionSource] - pub fn shallow_clone(&self) -> Self { - Self { - file_name: self.file_name.clone(), - contract_name: self.contract_name.clone(), - solc: self.solc.clone(), - global_code: self.global_code.clone(), - top_level_code: self.top_level_code.clone(), - run_code: self.run_code.clone(), - generated_output: None, - config: self.config.clone(), - } - } - - /// Clones the [SessionSource] and appends a new line of code. Will return - /// an error result if the new line fails to be parsed. - /// - /// ### Returns - /// - /// Optionally, a shallow-cloned [SessionSource] with the passed content appended to the - /// source code. - pub fn clone_with_new_line(&self, mut content: String) -> Result<(Self, bool)> { - let new_source = self.shallow_clone(); - if let Some(parsed) = parse_fragment(new_source.solc, new_source.config, &content) - .or_else(|| { - let new_source = self.shallow_clone(); - content.push(';'); - parse_fragment(new_source.solc, new_source.config, &content) - }) - .or_else(|| { - let new_source = self.shallow_clone(); - content = content.trim_end().trim_end_matches(';').to_string(); - parse_fragment(new_source.solc, new_source.config, &content) - }) - { - let mut new_source = self.shallow_clone(); - // Flag that tells the dispatcher whether to build or execute the session - // source based on the scope of the new code. - match parsed { - ParseTreeFragment::Function => new_source.with_run_code(&content), - ParseTreeFragment::Contract => new_source.with_top_level_code(&content), - ParseTreeFragment::Source => new_source.with_global_code(&content), - }; - - Ok((new_source, matches!(parsed, ParseTreeFragment::Function))) - } else { - eyre::bail!("\"{}\"", content.trim().to_owned()); - } - } - - // Fillers - - /// Appends global-level code to the source - pub fn with_global_code(&mut self, content: &str) -> &mut Self { - self.global_code.push_str(content.trim()); - self.global_code.push('\n'); - self.generated_output = None; - self - } - - /// Appends top-level code to the source - pub fn with_top_level_code(&mut self, content: &str) -> &mut Self { - self.top_level_code.push_str(content.trim()); - self.top_level_code.push('\n'); - self.generated_output = None; - self - } - - /// Appends code to the "run()" function - pub fn with_run_code(&mut self, content: &str) -> &mut Self { - self.run_code.push_str(content.trim()); - self.run_code.push('\n'); - self.generated_output = None; - self - } - - // Drains - - /// Clears global code from the source - pub fn drain_global_code(&mut self) -> &mut Self { - String::clear(&mut self.global_code); - self.generated_output = None; - self - } - - /// Clears top-level code from the source - pub fn drain_top_level_code(&mut self) -> &mut Self { - String::clear(&mut self.top_level_code); - self.generated_output = None; - self - } - - /// Clears the "run()" function's code - pub fn drain_run(&mut self) -> &mut Self { - String::clear(&mut self.run_code); - self.generated_output = None; - self - } - - /// Generates and [`SolcInput`] from the source. - /// - /// ### Returns - /// - /// A [`SolcInput`] object containing forge-std's `Vm` interface as well as the REPL contract - /// source. - pub fn compiler_input(&self) -> SolcInput { - let mut sources = Sources::new(); - sources.insert(self.file_name.clone(), Source::new(self.to_repl_source())); - - let remappings = self.config.foundry_config.get_all_remappings().collect::>(); - - // Include Vm.sol if forge-std remapping is not available - if !self.config.no_vm && !remappings.iter().any(|r| r.name.starts_with("forge-std")) { - sources.insert(PathBuf::from("forge-std/Vm.sol"), Source::new(VM_SOURCE)); - } - - let settings = Settings { - remappings, - evm_version: self - .config - .foundry_config - .evm_version - .normalize_version_solc(&self.solc.version), - ..Default::default() - }; - - // we only care about the solidity source, so we can safely unwrap - SolcInput::resolve_and_build(sources, settings) - .into_iter() - .next() - .map(|i| i.sanitized(&self.solc.version)) - .expect("Solidity source not found") - } - - /// Compiles the source using [solang_parser] - /// - /// ### Returns - /// - /// A [pt::SourceUnit] if successful. - /// A vec of [solang_parser::diagnostics::Diagnostic]s if unsuccessful. - pub fn parse(&self) -> Result> { - solang_parser::parse(&self.to_repl_source(), 0).map(|(pt, _)| pt) - } - - /// Generate intermediate contracts for all contract definitions in the compilation source. - /// - /// ### Returns - /// - /// Optionally, a map of contract names to a vec of [IntermediateContract]s. - pub fn generate_intermediate_contracts(&self) -> Result> { - let mut res_map = HashMap::default(); - let parsed_map = self.compiler_input().sources; - for source in parsed_map.values() { - Self::get_intermediate_contract(&source.content, &mut res_map); - } - Ok(res_map) - } - - /// Generate intermediate output for the REPL contract - pub fn generate_intermediate_output(&self) -> Result { - // Parse generate intermediate contracts - let intermediate_contracts = self.generate_intermediate_contracts()?; - - // Construct variable definitions - let variable_definitions = intermediate_contracts - .get("REPL") - .ok_or_else(|| eyre::eyre!("Could not find intermediate REPL contract!"))? - .variable_definitions - .clone() - .into_iter() - .map(|(k, v)| (k, v.ty)) - .collect::>(); - // Construct intermediate output - let mut intermediate_output = IntermediateOutput { - repl_contract_expressions: variable_definitions, - intermediate_contracts, - }; - - // Add all statements within the run function to the repl_contract_expressions map - for (key, val) in intermediate_output - .run_func_body()? - .clone() - .iter() - .flat_map(Self::get_statement_definitions) - { - intermediate_output.repl_contract_expressions.insert(key, val); - } - - Ok(intermediate_output) - } - - /// Compile the contract - /// - /// ### Returns - /// - /// Optionally, a [CompilerOutput] object that contains compilation artifacts. - pub fn compile(&self) -> Result { - // Compile the contract - let compiled = self.solc.compile_exact(&self.compiler_input())?; - - // Extract compiler errors - let errors = - compiled.errors.iter().filter(|error| error.severity.is_error()).collect::>(); - if !errors.is_empty() { - eyre::bail!( - "Compiler errors:\n{}", - errors.into_iter().map(|err| err.to_string()).collect::() - ); - } - - Ok(compiled) - } - - /// Builds the SessionSource from input into the complete CompiledOutput - /// - /// ### Returns - /// - /// Optionally, a [GeneratedOutput] object containing both the [CompilerOutput] and the - /// [IntermediateOutput]. - pub fn build(&mut self) -> Result { - // Compile - let compiler_output = self.compile()?; - - // Generate intermediate output - let intermediate_output = self.generate_intermediate_output()?; - - // Construct generated output - let generated_output = - GeneratedOutput { intermediate: intermediate_output, compiler_output }; - self.generated_output = Some(generated_output.clone()); // ehhh, need to not clone this. - Ok(generated_output) - } - - /// Convert the [SessionSource] to a valid Script contract - /// - /// ### Returns - /// - /// The [SessionSource] represented as a Forge Script contract. - pub fn to_script_source(&self) -> String { - let Version { major, minor, patch, .. } = self.solc.version; - let Self { contract_name, global_code, top_level_code, run_code, config, .. } = self; - - let script_import = - if !config.no_vm { "import {Script} from \"forge-std/Script.sol\";\n" } else { "" }; - - format!( - r#" -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^{major}.{minor}.{patch}; - -{script_import} -{global_code} - -contract {contract_name} is Script {{ - {top_level_code} - - /// @notice Script entry point - function run() public {{ - {run_code} - }} -}}"#, - ) - } - - /// Convert the [SessionSource] to a valid REPL contract - /// - /// ### Returns - /// - /// The [SessionSource] represented as a REPL contract. - pub fn to_repl_source(&self) -> String { - let Version { major, minor, patch, .. } = self.solc.version; - let Self { contract_name, global_code, top_level_code, run_code, config, .. } = self; - let (mut vm_import, mut vm_constant) = (String::new(), String::new()); - if !config.no_vm { - // Check if there's any `forge-std` remapping and determine proper path to it by - // searching remapping path. - if let Some(remapping) = config - .foundry_config - .remappings - .iter() - .find(|remapping| remapping.name == "forge-std/") - && let Some(vm_path) = WalkDir::new(&remapping.path.path) - .into_iter() - .filter_map(|e| e.ok()) - .find(|e| e.file_name() == "Vm.sol") - { - vm_import = format!("import {{Vm}} from \"{}\";\n", vm_path.path().display()); - vm_constant = "Vm internal constant vm = Vm(address(uint160(uint256(keccak256(\"hevm cheat code\")))));\n".to_string(); - } - } - - format!( - r#" -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^{major}.{minor}.{patch}; - -{vm_import} -{global_code} - -contract {contract_name} {{ - {vm_constant} - {top_level_code} - - /// @notice REPL contract entry point - function run() public {{ - {run_code} - }} -}}"#, - ) - } - - /// Gets the [IntermediateContract] for a Solidity source string and inserts it into the - /// passed `res_map`. In addition, recurses on any imported files as well. - /// - /// ### Takes - /// - `content` - A Solidity source string - /// - `res_map` - A mutable reference to a map of contract names to [IntermediateContract]s - pub fn get_intermediate_contract( - content: &str, - res_map: &mut HashMap, - ) { - if let Ok((pt::SourceUnit(source_unit_parts), _)) = solang_parser::parse(content, 0) { - let func_defs = source_unit_parts - .into_iter() - .filter_map(|sup| match sup { - pt::SourceUnitPart::ImportDirective(i) => match i { - pt::Import::Plain(s, _) - | pt::Import::Rename(s, _, _) - | pt::Import::GlobalSymbol(s, _, _) => { - let s = match s { - pt::ImportPath::Filename(s) => s.string, - pt::ImportPath::Path(p) => p.to_string(), - }; - let path = PathBuf::from(s); - - match fs::read_to_string(path) { - Ok(source) => { - Self::get_intermediate_contract(&source, res_map); - None - } - Err(_) => None, - } - } - }, - pt::SourceUnitPart::ContractDefinition(cd) => { - let mut intermediate = IntermediateContract::default(); - - cd.parts.into_iter().for_each(|part| match part { - pt::ContractPart::FunctionDefinition(def) => { - // Only match normal function definitions here. - if matches!(def.ty, pt::FunctionTy::Function) { - intermediate - .function_definitions - .insert(def.name.clone().unwrap().name, def); - } - } - pt::ContractPart::EventDefinition(def) => { - let event_name = def.name.safe_unwrap().name.clone(); - intermediate.event_definitions.insert(event_name, def); - } - pt::ContractPart::StructDefinition(def) => { - let struct_name = def.name.safe_unwrap().name.clone(); - intermediate.struct_definitions.insert(struct_name, def); - } - pt::ContractPart::VariableDefinition(def) => { - let var_name = def.name.safe_unwrap().name.clone(); - intermediate.variable_definitions.insert(var_name, def); - } - _ => {} - }); - Some((cd.name.safe_unwrap().name.clone(), intermediate)) - } - _ => None, - }) - .collect::>(); - res_map.extend(func_defs); - } - } - - /// Helper to deconstruct a statement - /// - /// ### Takes - /// - /// A reference to a [pt::Statement] - /// - /// ### Returns - /// - /// A vector containing tuples of the inner expressions' names, types, and storage locations. - pub fn get_statement_definitions(statement: &pt::Statement) -> Vec<(String, pt::Expression)> { - match statement { - pt::Statement::VariableDefinition(_, def, _) => { - vec![(def.name.safe_unwrap().name.clone(), def.ty.clone())] - } - pt::Statement::Expression(_, pt::Expression::Assign(_, left, _)) => { - if let pt::Expression::List(_, list) = left.as_ref() { - list.iter() - .filter_map(|(_, param)| { - param.as_ref().and_then(|param| { - param - .name - .as_ref() - .map(|name| (name.name.clone(), param.ty.clone())) - }) - }) - .collect() - } else { - Vec::default() - } - } - _ => Vec::default(), - } - } -} - -impl IntermediateOutput { - /// Helper function that returns the body of the REPL contract's "run" function. - /// - /// ### Returns - /// - /// Optionally, the last statement within the "run" function of the REPL contract. - pub fn run_func_body(&self) -> Result<&Vec> { - match self - .intermediate_contracts - .get("REPL") - .ok_or_else(|| eyre::eyre!("Could not find REPL intermediate contract!"))? - .function_definitions - .get("run") - .ok_or_else(|| eyre::eyre!("Could not find run function definition in REPL contract!"))? - .body - .as_ref() - .ok_or_else(|| eyre::eyre!("Could not find run function body!"))? - { - pt::Statement::Block { statements, .. } => Ok(statements), - _ => eyre::bail!("Could not find statements within run function body!"), - } - } -} - -/// A Parse Tree Fragment -/// -/// Used to determine whether an input will go to the "run()" function, -/// the top level of the contract, or in global scope. -#[derive(Debug)] -pub enum ParseTreeFragment { - /// Code for the global scope - Source, - /// Code for the top level of the contract - Contract, - /// Code for the "run()" function - Function, -} - -/// Parses a fragment of solidity code with solang_parser and assigns -/// it a scope within the [SessionSource]. -pub fn parse_fragment( - solc: Solc, - config: SessionSourceConfig, - buffer: &str, -) -> Option { - let mut base = SessionSource::new(solc, config); - - match base.clone().with_run_code(buffer).parse() { - Ok(_) => return Some(ParseTreeFragment::Function), - Err(e) => debug_errors(&e), - } - match base.clone().with_top_level_code(buffer).parse() { - Ok(_) => return Some(ParseTreeFragment::Contract), - Err(e) => debug_errors(&e), - } - match base.with_global_code(buffer).parse() { - Ok(_) => return Some(ParseTreeFragment::Source), - Err(e) => debug_errors(&e), - } - - None -} - -fn debug_errors(errors: &[Diagnostic]) { - if !tracing::enabled!(tracing::Level::DEBUG) { - return; - } - - for error in errors { - tracing::debug!("error: {}", error.message); - } -} diff --git a/crates/chisel/src/solidity_helper.rs b/crates/chisel/src/solidity_helper.rs index 1c588b5e60231..c3212f8e89962 100644 --- a/crates/chisel/src/solidity_helper.rs +++ b/crates/chisel/src/solidity_helper.rs @@ -1,7 +1,5 @@ -//! SolidityHelper -//! //! This module contains the `SolidityHelper`, a [rustyline::Helper] implementation for -//! usage in Chisel. It is ported from [soli](https://github.com/jpopesculian/soli/blob/master/src/main.rs). +//! usage in Chisel. It was originally ported from [soli](https://github.com/jpopesculian/soli/blob/master/src/main.rs). use crate::{ dispatcher::PROMPT_ARROW, @@ -14,12 +12,13 @@ use rustyline::{ hint::Hinter, validate::{ValidationContext, ValidationResult, Validator}, }; -use solar_parse::{ - Lexer, +use solar::parse::{ + Cursor, Lexer, interface::Session, - token::{Token, TokenKind}, + lexer::token::{RawLiteralKind, RawTokenKind}, + token::Token, }; -use std::{borrow::Cow, ops::Range, str::FromStr}; +use std::{borrow::Cow, cell::RefCell, fmt, ops::Range, rc::Rc}; use yansi::{Color, Style}; /// The maximum length of an ANSI prefix + suffix characters using [SolidityHelper]. @@ -31,8 +30,13 @@ use yansi::{Color, Style}; /// * 4 - suffix: `\x1B[0m` const MAX_ANSI_LEN: usize = 9; -/// A rustyline helper for Solidity code +/// A rustyline helper for Solidity code. +#[derive(Clone)] pub struct SolidityHelper { + inner: Rc>, +} + +struct Inner { errored: bool, do_paint: bool, @@ -45,24 +49,36 @@ impl Default for SolidityHelper { } } +impl fmt::Debug for SolidityHelper { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let this = self.inner.borrow(); + f.debug_struct("SolidityHelper") + .field("errored", &this.errored) + .field("do_paint", &this.do_paint) + .finish_non_exhaustive() + } +} + impl SolidityHelper { /// Create a new SolidityHelper. pub fn new() -> Self { Self { - errored: false, - do_paint: yansi::is_enabled(), - sess: Session::builder().with_silent_emitter(None).build(), + inner: Rc::new(RefCell::new(Inner { + errored: false, + do_paint: yansi::is_enabled(), + sess: Session::builder().with_silent_emitter(None).build(), + })), } } /// Returns whether the helper is in an errored state. pub fn errored(&self) -> bool { - self.errored + self.inner.borrow().errored } /// Set the errored field. pub fn set_errored(&mut self, errored: bool) -> &mut Self { - self.errored = errored; + self.inner.borrow_mut().errored = errored; self } @@ -73,7 +89,7 @@ impl SolidityHelper { } // Highlight commands separately - if input.starts_with(COMMAND_LEADER) { + if let Some(full_cmd) = input.strip_prefix(COMMAND_LEADER) { let (cmd, rest) = match input.split_once(' ') { Some((cmd, rest)) => (cmd, Some(rest)), None => (input, None), @@ -84,7 +100,7 @@ impl SolidityHelper { // cmd out.push(COMMAND_LEADER); - let cmd_res = ChiselCommand::from_str(cmd); + let cmd_res = ChiselCommand::parse(full_cmd); let style = (if cmd_res.is_ok() { Color::Green } else { Color::Red }).foreground(); Self::paint_unchecked(cmd, style, &mut out); @@ -131,21 +147,46 @@ impl SolidityHelper { /// Validate that a source snippet is closed (i.e., all braces and parenthesis are matched). fn validate_closed(&self, input: &str) -> ValidationResult { - let mut depth = [0usize; 3]; - self.enter(|sess| { - for token in Lexer::new(sess, input) { - match token.kind { - TokenKind::OpenDelim(delim) => { - depth[delim as usize] += 1; + use RawLiteralKind::Str; + use RawTokenKind::{BlockComment, CloseDelim, Literal, OpenDelim}; + let mut stack = vec![]; + for token in Cursor::new(input) { + match token.kind { + OpenDelim(delim) => stack.push(delim), + CloseDelim(delim) => match (stack.pop(), delim) { + (Some(open), close) if open == close => {} + (Some(wanted), _) => { + let wanted = wanted.to_open_str(); + return ValidationResult::Invalid(Some(format!( + "Mismatched brackets: `{wanted}` is not properly closed" + ))); } - TokenKind::CloseDelim(delim) => { - depth[delim as usize] = depth[delim as usize].saturating_sub(1); + (None, c) => { + let c = c.to_close_str(); + return ValidationResult::Invalid(Some(format!( + "Mismatched brackets: `{c}` is unpaired" + ))); } - _ => {} + }, + + Literal { kind: Str { terminated, .. } } if !terminated => { + return ValidationResult::Incomplete; + } + + BlockComment { terminated, .. } if !terminated => { + return ValidationResult::Incomplete; } + + _ => {} } - }); - if depth == [0; 3] { ValidationResult::Valid(None) } else { ValidationResult::Incomplete } + } + + // There are open brackets that are not properly closed. + if !stack.is_empty() { + return ValidationResult::Incomplete; + } + + ValidationResult::Valid(None) } /// Formats `input` with `style` into `out`, without checking `style.wrapping` or @@ -168,12 +209,13 @@ impl SolidityHelper { /// Returns whether to color the output. fn do_paint(&self) -> bool { - self.do_paint + self.inner.borrow().do_paint } /// Enters the session. fn enter(&self, f: impl FnOnce(&Session)) { - self.sess.enter(|| f(&self.sess)); + let this = self.inner.borrow(); + this.sess.enter_sequential(|| f(&this.sess)); } } @@ -211,7 +253,7 @@ impl Highlighter for SolidityHelper { if let Some(i) = out.find(PROMPT_ARROW) { let style = - if self.errored { Color::Red.foreground() } else { Color::Green.foreground() }; + if self.errored() { Color::Red.foreground() } else { Color::Green.foreground() }; out.replace_range(i..=i + 2, &Self::paint_unchecked_owned(PROMPT_ARROW_STR, style)); } @@ -238,9 +280,12 @@ impl Helper for SolidityHelper {} #[expect(non_upper_case_globals)] #[deny(unreachable_patterns)] fn token_style(token: &Token) -> Style { - use solar_parse::{ + use solar::parse::{ interface::kw::*, - token::{TokenKind::*, TokenLitKind::*}, + token::{ + TokenKind::{Arrow, Comment, FatArrow, Ident, Literal}, + TokenLitKind::{HexStr, Str, UnicodeStr}, + }, }; match token.kind { @@ -264,3 +309,64 @@ fn token_style(token: &Token) -> Style { _ => Color::Primary.foreground(), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate() { + let helper = SolidityHelper::new(); + let dbg_r = |r: ValidationResult| match r { + ValidationResult::Incomplete => "Incomplete".to_string(), + ValidationResult::Invalid(inner) => format!("Invalid({inner:?})"), + ValidationResult::Valid(inner) => format!("Valid({inner:?})"), + _ => "Unknown result".to_string(), + }; + let valid = |input: &str| { + let r = helper.validate_closed(input); + assert!(matches!(r, ValidationResult::Valid(None)), "{input:?}: {}", dbg_r(r)) + }; + let incomplete = |input: &str| { + let r = helper.validate_closed(input); + assert!(matches!(r, ValidationResult::Incomplete), "{input:?}: {}", dbg_r(r)) + }; + let invalid = |input: &str| { + let r = helper.validate_closed(input); + assert!(matches!(r, ValidationResult::Invalid(Some(_))), "{input:?}: {}", dbg_r(r)) + }; + + valid("1"); + valid("1 + 2"); + + valid("()"); + valid("{}"); + valid("[]"); + + incomplete("("); + incomplete("(("); + incomplete("["); + incomplete("{"); + incomplete("({"); + valid("({})"); + + invalid(")"); + invalid("]"); + invalid("}"); + invalid("(}"); + invalid("(})"); + invalid("[}"); + invalid("[}]"); + + incomplete("\""); + incomplete("\'"); + valid("\"\""); + valid("\'\'"); + + incomplete("/*"); + incomplete("/*/*"); + valid("/* */"); + valid("/* /* */"); + valid("/* /* */ */"); + } +} diff --git a/crates/chisel/src/source.rs b/crates/chisel/src/source.rs new file mode 100644 index 0000000000000..6d12cfc025d03 --- /dev/null +++ b/crates/chisel/src/source.rs @@ -0,0 +1,1163 @@ +//! Session Source +//! +//! This module contains the `SessionSource` struct, which is a minimal wrapper around +//! the REPL contract's source code. It provides simple compilation, parsing, and +//! execution helpers. + +use eyre::Result; +use foundry_compilers::{ + Artifact, ProjectCompileOutput, + artifacts::{ConfigurableContractArtifact, Source, Sources}, + project::ProjectCompiler, + solc::Solc, +}; +use foundry_config::{ + Config, EtherscanConfigs, FoundryHardfork, RpcEndpoints, SolcReq, cache::CachedEndpoints, +}; +use foundry_evm::{ + backend::Backend, + core::{bytecode::InstIter, evm::FoundryEvmNetwork}, + executors::ExecutorBuilder, + opts::EvmOpts, +}; +use foundry_evm_networks::NetworkConfigs; +use semver::Version; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use solar::{ + ast::{ItemKind, StmtKind as AstStmtKind, yul}, + interface::{Span, diagnostics::EmittedDiagnostics}, + sema::{ + CompilerRef, + hir::{Block, Contract, EventId, ItemId, Stmt, StmtKind as HirStmtKind}, + ty::Gcx, + }, +}; +use std::{ + cell::OnceCell, + fmt, + path::{Path, PathBuf}, +}; +use walkdir::WalkDir; + +/// The minimum Solidity version of the `Vm` interface. +pub const MIN_VM_VERSION: Version = Version::new(0, 6, 2); + +/// Solidity source for the `Vm` interface in [forge-std](https://github.com/foundry-rs/forge-std) +static VM_SOURCE: &str = include_str!("../../../testdata/utils/Vm.sol"); + +/// [`SessionSource`] build output. +pub struct GeneratedOutput { + output: ProjectCompileOutput, + /// The full path of the generated REPL source in the compilation. + source_path: PathBuf, +} + +impl fmt::Debug for GeneratedOutput { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("GeneratedOutput").finish_non_exhaustive() + } +} + +impl GeneratedOutput { + /// Enters the solar compiler context, providing access to the HIR and `Gcx`. + pub fn enter( + &self, + f: impl for<'a, 'b, 'gcx> FnOnce(GeneratedOutputRef<'a, 'b, 'gcx>) -> R + Send, + ) -> R { + let source_path = &self.source_path; + self.output + .parser() + .solc() + .compiler() + .enter(|c| f(GeneratedOutputRef { output: &self.output, compiler: c, source_path })) + } +} + +/// A scoped reference to a [`GeneratedOutput`] together with an entered solar compiler. +pub struct GeneratedOutputRef<'a, 'b, 'gcx> { + output: &'a ProjectCompileOutput, + pub(crate) compiler: &'b CompilerRef<'gcx>, + /// The full path of the generated REPL source in the compilation. + source_path: &'a Path, +} + +impl<'gcx> GeneratedOutputRef<'_, '_, 'gcx> { + pub fn gcx(&self) -> Gcx<'gcx> { + self.compiler.gcx() + } + + pub fn repl_contract(&self) -> Option<&ConfigurableContractArtifact> { + self.output.find(self.source_path, "REPL") + } + + /// Looks up the REPL contract in the HIR. + pub fn repl_contract_hir(&self) -> Option<&'gcx Contract<'gcx>> { + self.gcx().hir.contracts().find(|c| { + c.name.as_str() == "REPL" + && self.gcx().sources.get(c.source).and_then(|s| s.file.name.as_real()) + == Some(self.source_path) + }) + } + + /// Returns the body block of the REPL `run()` function. + pub fn run_func_body(&self) -> Block<'gcx> { + let hir = &self.gcx().hir; + let c = self.repl_contract_hir().expect("REPL contract not found in HIR"); + let f = c + .functions() + .find(|&f| { + let f = hir.function(f); + f.name.is_some_and(|n| n.as_str() == "run") && f.parameters.is_empty() + }) + .expect("`run()` function not found in REPL contract"); + hir.function(f).body.expect("`run()` function does not have a body") + } + + /// Returns the [`EventId`] of an event named `input` in the REPL contract, if any. + pub fn get_event(&self, input: &str) -> Option { + let hir = &self.gcx().hir; + let c = self.repl_contract_hir()?; + c.items.iter().find_map(|id| { + if let ItemId::Event(eid) = id + && hir.event(*eid).name.as_str() == input + { + Some(*eid) + } else { + None + } + }) + } + + pub fn final_pc(&self, contract: &ConfigurableContractArtifact) -> Result> { + let deployed_bytecode = contract + .get_deployed_bytecode() + .ok_or_else(|| eyre::eyre!("No deployed bytecode found for `REPL` contract"))?; + let deployed_bytecode_bytes = deployed_bytecode + .bytes() + .ok_or_else(|| eyre::eyre!("No deployed bytecode found for `REPL` contract"))?; + + // Fetch the run function's body statement + let run_body = self.run_func_body(); + + // Record loc of first yul block return statement (if any). + // This is used to decide which is the final statement within the `run()` method. + // see . + // + // Walk the AST of the REPL source to find a top-level `return(...)` call + // inside any `assembly { ... }` block in `run()`. This lets us pick the + // meaningful Yul return span even when HIR represents the block coarsely. + let last_yul_return_span: Option = self.first_yul_return_span(); + + // Find the last statement within the "run()" method and get the program + // counter via the source map. + let Some(last_stmt) = run_body.last() else { return Ok(None) }; + + // If the final statement is some type of block (unchecked or regular), + // we need to find the final statement within that block. Otherwise, default to + // the source loc of the final statement of the `run()` function's block. + // + // Inline assembly blocks are handled separately via + // `trailing_assembly_last_stmt_span`, which walks the AST to recover the last + // meaningful Yul statement. + let source_stmt = match &last_stmt.kind { + HirStmtKind::UncheckedBlock(stmts) | HirStmtKind::Block(stmts) => { + if let Some(stmt) = stmts.last() { + stmt + } else { + // In the case where the block is empty, attempt to grab the statement + // before the block. Because we use saturating sub to get the second to + // last index, this can always be safely unwrapped. + &run_body[run_body.len().saturating_sub(2)] + } + } + _ => last_stmt, + }; + // If the trailing statement is an assembly block, prefer the last meaningful + // (non-`let`) Yul statement's span as the source location for `final_pc`. + // See . + // + // `trailing_assembly_last_stmt_span` verifies via the AST that the HIR node + // corresponds to an assembly block and supplies the concrete Yul span to use. + let mut source_span = + if matches!(last_stmt.kind, HirStmtKind::AssemblyBlock(_) | HirStmtKind::Err(_)) + && let Some(span) = self.trailing_assembly_last_stmt_span() + { + span + } else { + self.stmt_span_without_semicolon(source_stmt) + }; + + // Consider yul return statement as final statement (if it's loc is lower). + if let Some(yul_return_span) = last_yul_return_span + && yul_return_span.hi() < source_span.lo() + { + source_span = yul_return_span; + } + + // Map the source location of the final statement of the `run()` function to its + // corresponding runtime program counter + let result = self + .compiler + .sess() + .source_map() + .span_to_source(source_span) + .map_err(|e| eyre::eyre!("failed to resolve span: {e:?}"))?; + let range = result.data; + let offset = range.start as u32; + let length = range.len() as u32; + trace!(%offset, %length, "find pc"); + let final_pc = contract + .get_source_map_deployed() + .ok_or_else(|| eyre::eyre!("No source map found for `REPL` contract"))?? + .into_iter() + .zip(InstIter::new(deployed_bytecode_bytes).with_pc().map(|(pc, _)| pc)) + .filter(|(s, _)| s.offset() == offset && s.length() == length) + .map(|(_, pc)| pc) + .max(); + trace!(?final_pc); + Ok(final_pc) + } + + /// Statements' ranges in the solc source map do not include the semicolon. + fn stmt_span_without_semicolon(&self, stmt: &Stmt<'_>) -> Span { + match stmt.kind { + HirStmtKind::DeclSingle(id) => { + let decl = self.gcx().hir.variable(id); + if let Some(expr) = decl.initializer { + stmt.span.with_hi(expr.span.hi()) + } else { + stmt.span + } + } + HirStmtKind::DeclMulti(_, expr) => stmt.span.with_hi(expr.span.hi()), + HirStmtKind::Expr(expr) => expr.span, + _ => stmt.span, + } + } + + /// Returns the AST `run()` body of the REPL contract, if any. + /// + /// Returns the AST `run()` body so inline assembly blocks can be inspected at + /// Yul-statement granularity. + fn repl_run_ast_body(&self) -> Option<&'gcx solar::ast::Block<'gcx>> { + let contract = self.repl_contract_hir()?; + let source = self.gcx().sources.get(contract.source)?; + let ast = source.ast.as_ref()?; + + let contract_ast = ast.items.iter().find_map(|i| match &i.kind { + ItemKind::Contract(c) if c.name.as_str() == "REPL" => Some(c), + _ => None, + })?; + contract_ast.body.iter().find_map(|i| match &i.kind { + ItemKind::Function(f) + if f.header.name.is_some_and(|n| n.as_str() == "run") + && f.header.parameters.is_empty() => + { + f.body.as_ref() + } + _ => None, + }) + } + + /// Returns the span of the first top-level `return(...)` call inside any + /// `assembly { ... }` block in the REPL `run()` function, if any. + fn first_yul_return_span(&self) -> Option { + let run_body = self.repl_run_ast_body()?; + for stmt in run_body.stmts.iter() { + let AstStmtKind::Assembly(asm) = &stmt.kind else { continue }; + for ystmt in asm.block.stmts.iter() { + if let yul::StmtKind::Expr(e) = &ystmt.kind + && let yul::ExprKind::Call(call) = &e.kind + && call.name.as_str() == "return" + { + return Some(ystmt.span); + } + } + } + None + } + + /// If the last statement of the REPL `run()` function is an `assembly { ... }` block, + /// returns the span of its last non-`let` (i.e. non-VarDecl) Yul statement. + /// + /// This mirrors the legacy behavior used to pick a meaningful end-of-function PC when + /// the trailing statement is inline assembly. + fn trailing_assembly_last_stmt_span(&self) -> Option { + let run_body = self.repl_run_ast_body()?; + let AstStmtKind::Assembly(asm) = &run_body.stmts.last()?.kind else { return None }; + asm.block + .stmts + .iter() + .rev() + .find(|s| !matches!(s.kind, yul::StmtKind::VarDecl(_, _))) + .map(|s| s.span) + } +} + +/// Configuration for the [SessionSource] +/// +/// Serialization is derived, but credential-bearing fields are always written through sanitizing +/// getters, so every serialization path omits RPC and explorer credentials. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(remote = "Self", bound = "")] +pub struct SessionSourceConfig { + /// Foundry configuration + #[serde(getter = "Self::persisted_foundry_config")] + pub foundry_config: Config, + /// EVM Options + #[serde(getter = "Self::persisted_evm_opts")] + pub evm_opts: EvmOpts, + /// Executor tooling selected by the concrete network dispatch. + #[serde(skip)] + pub executor_builder: ExecutorBuilder, + /// Network family to restore when leaving fork mode. + #[serde(default)] + pub local_networks: Option, + /// Chain ID to restore when leaving fork mode. + #[serde(default)] + pub local_chain_id: Option, + /// Whether the saved fork network was inferred from its endpoint. + #[serde(default)] + pub fork_network_is_inferred: bool, + /// Whether the saved chain ID was inferred from its endpoint. + #[serde(default)] + pub fork_chain_id_is_inferred: bool, + /// Exact network hardfork selected for the latest execution. + #[serde(skip)] + pub resolved_hardfork: Option, + /// Source chain used for trace decoding and external identifiers. + #[serde(skip)] + pub source_chain_id: Option, + /// Disable the default `Vm` import. + pub no_vm: bool, + /// Cached execution backend and its fork identity. + #[serde(skip)] + pub(crate) cached_backend: Option>, + /// Optionally enable traces for the REPL contract execution + pub traces: bool, + /// Optionally set calldata for the REPL contract execution + pub calldata: Option>, + /// Enable viaIR with minimum optimization + /// + /// This can fix most of the "stack too deep" errors while resulting a + /// relatively accurate source map. + pub ir_minimum: bool, + /// Whether a cached session needs the current invocation's fork endpoint. + #[serde(default, getter = "Self::persisted_fork_url_required")] + pub(crate) fork_url_required: bool, +} + +impl SessionSourceConfig { + /// Captures the local execution context for sessions saved before it was persisted explicitly. + pub fn initialize_local_context(&mut self) { + self.evm_opts.fork_network_is_inferred = self.fork_network_is_inferred; + self.evm_opts.fork_chain_id_is_inferred = self.fork_chain_id_is_inferred; + if self.local_networks.is_none() { + self.local_networks = Some(self.evm_opts.networks); + self.local_chain_id = + self.evm_opts.env.chain_id.or(self.foundry_config.chain.map(|chain| chain.id())); + } + } + + /// Detect the solc version to know if VM can be injected. + pub fn detect_solc(&mut self) -> Result<()> { + if self.foundry_config.solc.is_none() { + let version = Solc::ensure_installed(&"*".parse().unwrap())?; + self.foundry_config.solc = Some(SolcReq::Version(version)); + } + if !self.no_vm + && let Some(version) = self.foundry_config.solc_version() + && version < MIN_VM_VERSION + { + info!(%version, minimum=%MIN_VM_VERSION, "Disabling VM injection"); + self.no_vm = true; + } + Ok(()) + } + + /// Removes credentials from legacy caches while retaining whether the session was forked. + pub(crate) fn clear_credentials(&mut self) { + self.fork_url_required |= self.evm_opts.fork_url.is_some(); + let none = InvocationRpc::default(); + none.apply_config_credentials(&mut self.foundry_config); + none.apply_evm_credentials(&mut self.evm_opts); + } + + /// Uses credentials from this invocation, never values persisted by an older Chisel version. + pub(crate) fn restore_credentials(&mut self, current: &InvocationRpc) -> Result<()> { + let forked = self.persisted_fork_url_required(); + if forked && current.fork_url.is_none() { + eyre::bail!( + "this saved Chisel session requires a fork endpoint; use !fork or restart Chisel with --fork-url to load it" + ); + } + current.apply_config_credentials(&mut self.foundry_config); + current.apply_evm_credentials(&mut self.evm_opts); + current.apply_transport(&mut self.foundry_config, &mut self.evm_opts); + if !forked { + self.evm_opts.fork_url = None; + } + self.fork_url_required = false; + self.evm_opts.fork_endpoint = None; + self.evm_opts.expected_fork_endpoint = None; + self.resolved_hardfork = None; + self.source_chain_id = None; + self.cached_backend = None; + Ok(()) + } + + fn persisted_foundry_config(&self) -> Config { + let mut config = self.foundry_config.clone(); + InvocationRpc::default().apply_config_credentials(&mut config); + config + } + + fn persisted_evm_opts(&self) -> EvmOpts { + let mut evm_opts = self.evm_opts.clone(); + InvocationRpc::default().apply_evm_credentials(&mut evm_opts); + evm_opts + } + + /// Records that the session was forked even though its endpoint is not persisted. + const fn persisted_fork_url_required(&self) -> bool { + self.fork_url_required || self.evm_opts.fork_url.is_some() + } +} + +impl Serialize for SessionSourceConfig { + fn serialize(&self, serializer: S) -> Result { + Self::serialize(self, serializer) + } +} + +impl<'de, FEN: FoundryEvmNetwork> Deserialize<'de> for SessionSourceConfig { + fn deserialize>(deserializer: D) -> Result { + Self::deserialize(deserializer) + } +} + +/// RPC and explorer settings owned by the current Chisel invocation. +/// +/// Loading a session restores credentials and transport settings from this invocation. +/// The default value holds no credentials and is used to strip them from persisted sessions. +#[derive(Clone, Debug, Default)] +pub(crate) struct InvocationRpc { + eth_rpc_url: Option, + eth_rpc_jwt: Option, + eth_rpc_headers: Option>, + etherscan_api_key: Option, + etherscan: EtherscanConfigs, + rpc_endpoints: RpcEndpoints, + cached_endpoints: CachedEndpoints, + eth_rpc_timeout: Option, + eth_rpc_accept_invalid_certs: bool, + eth_rpc_no_proxy: bool, + fork_url: Option, + fork_headers: Option>, + rpc_jwt: Option, + rpc_headers: Option>, + rpc_timeout: Option, + rpc_accept_invalid_certs: bool, + rpc_no_proxy: bool, +} + +impl InvocationRpc { + /// Captures the RPC settings resolved for this invocation. + pub(crate) fn capture(config: &Config, evm_opts: &EvmOpts) -> Self { + Self { + eth_rpc_url: config.eth_rpc_url.clone(), + eth_rpc_jwt: config.eth_rpc_jwt.clone(), + eth_rpc_headers: config.eth_rpc_headers.clone(), + etherscan_api_key: config.etherscan_api_key.clone(), + etherscan: config.etherscan.clone(), + rpc_endpoints: config.rpc_endpoints.clone(), + cached_endpoints: config.rpc_storage_caching.endpoints.clone(), + eth_rpc_timeout: config.eth_rpc_timeout, + eth_rpc_accept_invalid_certs: config.eth_rpc_accept_invalid_certs, + eth_rpc_no_proxy: config.eth_rpc_no_proxy, + fork_url: evm_opts.fork_url.clone(), + fork_headers: evm_opts.fork_headers.clone(), + rpc_jwt: evm_opts.rpc_jwt.clone(), + rpc_headers: evm_opts.rpc_headers.clone(), + rpc_timeout: evm_opts.rpc_timeout, + rpc_accept_invalid_certs: evm_opts.rpc_accept_invalid_certs, + rpc_no_proxy: evm_opts.rpc_no_proxy, + } + } + + /// Replaces the fork endpoint and its endpoint-specific headers after `!fork `. + pub(crate) fn set_fork(&mut self, url: String, headers: Option>) { + self.eth_rpc_url = Some(url.clone()); + self.fork_url = Some(url); + self.fork_headers = headers; + } + + fn apply_config_credentials(&self, config: &mut Config) { + config.eth_rpc_url.clone_from(&self.eth_rpc_url); + config.eth_rpc_jwt.clone_from(&self.eth_rpc_jwt); + config.eth_rpc_headers.clone_from(&self.eth_rpc_headers); + config.etherscan_api_key.clone_from(&self.etherscan_api_key); + config.etherscan.clone_from(&self.etherscan); + config.rpc_endpoints.clone_from(&self.rpc_endpoints); + config.rpc_storage_caching.endpoints.clone_from(&self.cached_endpoints); + } + + fn apply_evm_credentials(&self, evm_opts: &mut EvmOpts) { + evm_opts.fork_url.clone_from(&self.fork_url); + evm_opts.fork_headers.clone_from(&self.fork_headers); + evm_opts.rpc_jwt.clone_from(&self.rpc_jwt); + evm_opts.rpc_headers.clone_from(&self.rpc_headers); + } + + const fn apply_transport(&self, config: &mut Config, evm_opts: &mut EvmOpts) { + config.eth_rpc_timeout = self.eth_rpc_timeout; + config.eth_rpc_accept_invalid_certs = self.eth_rpc_accept_invalid_certs; + config.eth_rpc_no_proxy = self.eth_rpc_no_proxy; + evm_opts.rpc_timeout = self.rpc_timeout; + evm_opts.rpc_accept_invalid_certs = self.rpc_accept_invalid_certs; + evm_opts.rpc_no_proxy = self.rpc_no_proxy; + } +} + +/// REPL Session Source wrapper +/// +/// Heavily based on soli's [`ConstructedSource`](https://github.com/jpopesculian/soli/blob/master/src/main.rs#L166) +#[derive(Debug, Serialize, Deserialize)] +#[serde(bound = "")] +pub struct SessionSource { + /// The file name + pub file_name: String, + /// The contract name + pub contract_name: String, + + /// Session Source configuration + pub config: SessionSourceConfig, + + /// Global level Solidity code. + /// + /// Above and outside all contract declarations, in the global context. + pub global_code: String, + /// Top level Solidity code. + /// + /// Within the contract declaration, but outside of the `run()` function. + pub contract_code: String, + /// The code to be executed in the `run()` function. + pub run_code: String, + + /// Cached VM source code. + #[serde(skip, default = "vm_source")] + vm_source: Source, + /// The generated output + #[serde(skip)] + output: OnceCell, +} + +fn vm_source() -> Source { + Source::new(VM_SOURCE) +} + +impl Clone for SessionSource { + fn clone(&self) -> Self { + Self { + file_name: self.file_name.clone(), + contract_name: self.contract_name.clone(), + global_code: self.global_code.clone(), + contract_code: self.contract_code.clone(), + run_code: self.run_code.clone(), + config: self.config.clone(), + vm_source: self.vm_source.clone(), + output: Default::default(), + } + } +} + +impl SessionSource { + /// Creates a new source given a solidity compiler version + /// + /// # Panics + /// + /// If no Solc binary is set, cannot be found or the `--version` command fails + /// + /// ### Takes + /// + /// - An instance of [Solc] + /// - An instance of [SessionSourceConfig] + /// + /// ### Returns + /// + /// A new instance of [SessionSource] + pub fn new(mut config: SessionSourceConfig) -> Result { + config.detect_solc()?; + Ok(Self { + file_name: "ReplContract.sol".to_string(), + contract_name: "REPL".to_string(), + config, + global_code: Default::default(), + contract_code: Default::default(), + run_code: Default::default(), + vm_source: vm_source(), + output: Default::default(), + }) + } + + /// Clones the [SessionSource] and appends a new line of code. + /// + /// Returns `true` if the new line was added to `run()`. + pub fn clone_with_new_line(&self, mut content: String) -> Result<(Self, bool)> { + if let Some((new_source, fragment)) = self + .parse_fragment(&content) + .or_else(|| { + content.push(';'); + self.parse_fragment(&content) + }) + .or_else(|| { + content = content.trim_end().trim_end_matches(';').to_string(); + self.parse_fragment(&content) + }) + { + Ok((new_source, matches!(fragment, ParseTreeFragment::Function))) + } else { + eyre::bail!("\"{}\"", content.trim()); + } + } + + /// Parses a fragment of Solidity code in memory and assigns it a scope within the + /// [`SessionSource`]. + fn parse_fragment(&self, buffer: &str) -> Option<(Self, ParseTreeFragment)> { + #[track_caller] + fn debug_errors(errors: &EmittedDiagnostics) { + debug!("{errors}"); + } + + let mut this = self.clone(); + match this.add_run_code(buffer).parse() { + Ok(()) => return Some((this, ParseTreeFragment::Function)), + Err(e) => debug_errors(&e), + } + this = self.clone(); + match this.add_contract_code(buffer).parse() { + Ok(()) => return Some((this, ParseTreeFragment::Contract)), + Err(e) => debug_errors(&e), + } + this = self.clone(); + match this.add_global_code(buffer).parse() { + Ok(()) => return Some((this, ParseTreeFragment::Source)), + Err(e) => debug_errors(&e), + } + None + } + + /// Append global-level code to the source. + pub fn add_global_code(&mut self, content: &str) -> &mut Self { + self.global_code.push_str(content.trim()); + self.global_code.push('\n'); + self.clear_output(); + self + } + + /// Append contract-level code to the source. + pub fn add_contract_code(&mut self, content: &str) -> &mut Self { + self.contract_code.push_str(content.trim()); + self.contract_code.push('\n'); + self.clear_output(); + self + } + + /// Append code to the `run()` function of the REPL contract. + pub fn add_run_code(&mut self, content: &str) -> &mut Self { + self.run_code.push_str(content.trim()); + self.run_code.push('\n'); + self.clear_output(); + self + } + + /// Clears all source code. + pub fn clear(&mut self) { + String::clear(&mut self.global_code); + String::clear(&mut self.contract_code); + String::clear(&mut self.run_code); + self.clear_output(); + } + + /// Clear the `run()` function code. + pub fn clear_run(&mut self) -> &mut Self { + String::clear(&mut self.run_code); + self.clear_output(); + self + } + + fn clear_output(&mut self) { + self.output.take(); + } + + /// Compiles the source if necessary. + pub fn build(&self) -> Result<&GeneratedOutput> { + // TODO: mimics `get_or_try_init` + if let Some(output) = self.output.get() { + return Ok(output); + } + let output = self.compile()?; + Ok(self.output.get_or_init(|| output)) + } + + /// Compiles the source. + #[cold] + fn compile(&self) -> Result { + let sources = self.get_sources(); + + let mut project = self.config.foundry_config.ephemeral_project()?; + let source_path = project.root().join(&self.file_name); + self.config.foundry_config.disable_optimizations(&mut project, self.config.ir_minimum); + let mut output = ProjectCompiler::with_sources(&project, sources)?.compile()?; + + if output.has_compiler_errors() { + eyre::bail!("{output}"); + } + + // Drive HIR lowering and analysis so that subsequent `enter` queries can use them. + // Chisel inspects expression values, so enable Solar's expression type table. + let compiler = output.parser_mut().solc_mut().compiler_mut(); + compiler.enter_mut(|c| { + let _ = c.lower_asts(); + let _ = c.analysis(); + }); + + Ok(GeneratedOutput { output, source_path }) + } + + fn get_sources(&self) -> Sources { + let mut sources = Sources::new(); + + let src = self.to_repl_source(); + sources.insert(self.file_name.clone().into(), Source::new(src)); + + // Include Vm.sol if forge-std remapping is not available. + if !self.config.no_vm + && !self + .config + .foundry_config + .get_all_remappings() + .any(|r| r.name.starts_with("forge-std")) + { + sources.insert("forge-std/Vm.sol".into(), self.vm_source.clone()); + } + + sources + } + + /// Construct the REPL source. + pub fn to_repl_source(&self) -> String { + let Self { + contract_name, + global_code, + contract_code: top_level_code, + run_code, + config, + .. + } = self; + let (mut vm_import, mut vm_constant) = (String::new(), String::new()); + // Check if there's any `forge-std` remapping and determine proper path to it by + // searching remapping path. + if !config.no_vm + && let Some(remapping) = config + .foundry_config + .remappings + .iter() + .find(|remapping| remapping.name == "forge-std/") + && let Some(vm_path) = WalkDir::new(&remapping.path.path) + .into_iter() + .filter_map(|e| e.ok()) + .find(|e| e.file_name() == "Vm.sol") + { + vm_import = format!( + "import {{Vm}} from \"{}\";\n", + vm_path.path().to_string_lossy().replace('\\', "/") + ); + vm_constant = "Vm internal constant vm = Vm(address(uint160(uint256(keccak256(\"hevm cheat code\")))));\n".to_string(); + } + + format!( + r#" +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0; + +{vm_import} +{global_code} + +contract {contract_name} {{ + {vm_constant} + {top_level_code} + + /// @notice REPL contract entry point + function run() public {{ + {run_code} + }} +}}"#, + ) + } + + /// Parse the current source in memory using Solar. + pub(crate) fn parse(&self) -> Result<(), EmittedDiagnostics> { + let sess = + solar::interface::Session::builder().with_buffer_emitter(Default::default()).build(); + let _ = sess.enter_sequential(|| -> solar::interface::Result<()> { + let arena = solar::ast::Arena::new(); + let filename = self.file_name.clone().into(); + let src = self.to_repl_source(); + let mut parser = solar::parse::Parser::from_source_code(&sess, &arena, filename, src)?; + let _ast = parser.parse_file().map_err(|e| e.emit())?; + Ok(()) + }); + sess.dcx.emitted_errors().unwrap() + } +} + +/// A Parse Tree Fragment +/// +/// Used to determine whether an input will go to the "run()" function, +/// the top level of the contract, or in global scope. +#[derive(Debug)] +enum ParseTreeFragment { + /// Code for the global scope + Source, + /// Code for the top level of the contract + Contract, + /// Code for the "run()" function + Function, +} + +#[cfg(test)] +mod tests { + use super::*; + use foundry_compilers::artifacts::remappings::{RelativeRemapping, RelativeRemappingPathBuf}; + use foundry_evm::core::evm::EthEvmNetwork; + use std::fs; + + #[test] + fn initialize_local_context_migrates_legacy_session() { + let mut config = SessionSourceConfig::::default(); + config.evm_opts.networks = NetworkConfigs::with_tempo(); + config.evm_opts.env.chain_id = Some(4217); + + config.initialize_local_context(); + + assert_eq!(config.local_networks, Some(NetworkConfigs::with_tempo())); + assert_eq!(config.local_chain_id, Some(4217)); + + config.evm_opts.networks = NetworkConfigs::default(); + config.evm_opts.env.chain_id = Some(1); + config.initialize_local_context(); + + assert_eq!(config.local_networks, Some(NetworkConfigs::with_tempo())); + assert_eq!(config.local_chain_id, Some(4217)); + } + + #[test] + fn serialized_session_restores_fork_inference_provenance() { + let config = SessionSourceConfig:: { + fork_network_is_inferred: true, + fork_chain_id_is_inferred: true, + ..Default::default() + }; + let encoded = serde_json::to_string(&config).unwrap(); + let mut decoded = + serde_json::from_str::>(&encoded).unwrap(); + + assert!(!decoded.evm_opts.fork_network_is_inferred); + assert!(!decoded.evm_opts.fork_chain_id_is_inferred); + decoded.initialize_local_context(); + assert!(decoded.evm_opts.fork_network_is_inferred); + assert!(decoded.evm_opts.fork_chain_id_is_inferred); + } + + #[test] + fn legacy_session_without_rpc_transport_flags_deserializes() { + let config = SessionSourceConfig::::default(); + let mut legacy_session = serde_json::to_value(config).unwrap(); + let evm_opts = legacy_session["evm_opts"].as_object_mut().expect("serialized EVM options"); + assert!(evm_opts.remove("eth_rpc_accept_invalid_certs").is_some()); + assert!(evm_opts.remove("eth_rpc_no_proxy").is_some()); + + let decoded = + serde_json::from_value::>(legacy_session).unwrap(); + + assert!(!decoded.evm_opts.rpc_accept_invalid_certs); + assert!(!decoded.evm_opts.rpc_no_proxy); + } + + /// Regression test for . + /// + /// `to_repl_source()` must use forward slashes in the Vm import path regardless of OS, + /// because Solidity import statements require `/` as the path separator. + #[test] + fn test_vm_import_path_uses_forward_slashes() { + let tmp = tempfile::tempdir().unwrap(); + let vm_sol = tmp.path().join("Vm.sol"); + fs::write(&vm_sol, "// dummy").unwrap(); + + let remapping = RelativeRemapping { + context: None, + name: "forge-std/".to_string(), + path: RelativeRemappingPathBuf { parent: None, path: tmp.path().to_path_buf() }, + }; + + let mut config: SessionSourceConfig = SessionSourceConfig { + foundry_config: Config { + solc: Some(SolcReq::Version(Version::new(0, 8, 29))), + remappings: vec![remapping], + ..Default::default() + }, + ..Default::default() + }; + // Pre-set solc so detect_solc() skips the ensure_installed I/O. + config.detect_solc().unwrap(); + + let source = SessionSource { + file_name: "ReplContract.sol".to_string(), + contract_name: "REPL".to_string(), + config, + global_code: Default::default(), + contract_code: Default::default(), + run_code: Default::default(), + vm_source: vm_source(), + output: Default::default(), + }; + + let repl = source.to_repl_source(); + let import_line = repl.lines().find(|l| l.contains("import {Vm}")).unwrap(); + assert!( + !import_line.contains('\\'), + "Vm import path must not contain backslashes, got: {import_line}" + ); + assert!(import_line.contains('/'), "Vm import path must use forward slashes"); + } + + #[test] + fn session_serialization_omits_credentials() { + let mut config = SessionSourceConfig::::default(); + config.foundry_config.eth_rpc_url = + Some("https://user:synthetic-password@rpc.invalid/key".into()); + config.foundry_config.eth_rpc_jwt = Some("synthetic-jwt".into()); + config.foundry_config.eth_rpc_headers = + Some(vec!["Authorization: synthetic-header".into()]); + config.foundry_config.etherscan_api_key = Some("synthetic-api-key".into()); + config.foundry_config.etherscan = serde_json::from_value(serde_json::json!({ + "mainnet": { "key": "synthetic-explorer-key", "chain": 1 } + })) + .unwrap(); + config.foundry_config.rpc_endpoints = serde_json::from_value(serde_json::json!({ + "mainnet": "https://rpc.invalid/synthetic-endpoint-key" + })) + .unwrap(); + config.foundry_config.rpc_storage_caching.endpoints = + "synthetic-cache-endpoint-key".parse().unwrap(); + config.evm_opts.fork_url = config.foundry_config.eth_rpc_url.clone(); + config.evm_opts.rpc_jwt = config.foundry_config.eth_rpc_jwt.clone(); + config.evm_opts.rpc_headers = config.foundry_config.eth_rpc_headers.clone(); + config.evm_opts.fork_headers = Some(vec!["Authorization: synthetic-fork-header".into()]); + config.evm_opts.fork_block_number = Some(42); + config.calldata = Some(vec![0xde, 0xad, 0xbe, 0xef]); + config.traces = true; + + let encoded = serde_json::to_string(&config).unwrap(); + let decoded: SessionSourceConfig = serde_json::from_str(&encoded).unwrap(); + + assert_eq!(decoded.foundry_config.eth_rpc_url, None); + assert_eq!(decoded.foundry_config.eth_rpc_jwt, None); + assert_eq!(decoded.foundry_config.eth_rpc_headers, None); + assert_eq!(decoded.foundry_config.etherscan_api_key, None); + assert!(decoded.foundry_config.etherscan.is_empty()); + assert!(decoded.foundry_config.rpc_endpoints.is_empty()); + assert_eq!(decoded.foundry_config.rpc_storage_caching.endpoints.to_string(), "all"); + assert_eq!(decoded.evm_opts.fork_url, None); + assert_eq!(decoded.evm_opts.rpc_jwt, None); + assert_eq!(decoded.evm_opts.rpc_headers, None); + assert_eq!(decoded.evm_opts.fork_headers, None); + assert_eq!(decoded.evm_opts.fork_block_number, Some(42)); + assert_eq!(decoded.calldata, Some(vec![0xde, 0xad, 0xbe, 0xef])); + assert!(decoded.traces); + assert!(decoded.fork_url_required); + assert_eq!(config.foundry_config.eth_rpc_jwt.as_deref(), Some("synthetic-jwt")); + assert_eq!(config.evm_opts.fork_url, config.foundry_config.eth_rpc_url); + } + + /// Pins the persisted schema so that a new field is a deliberate decision to cache or skip it. + #[test] + fn session_serialization_round_trips_persisted_fields() { + let config = SessionSourceConfig:: { + foundry_config: Config { optimizer_runs: Some(500), ..Default::default() }, + evm_opts: EvmOpts { fork_block_number: Some(42), ..Default::default() }, + local_networks: Some(NetworkConfigs::with_tempo()), + local_chain_id: Some(4217), + fork_network_is_inferred: true, + fork_chain_id_is_inferred: true, + no_vm: true, + traces: true, + calldata: Some(vec![1, 2, 3]), + ir_minimum: true, + fork_url_required: true, + source_chain_id: Some(1), + ..Default::default() + }; + + let encoded = serde_json::to_value(&config).unwrap(); + let mut keys = encoded.as_object().unwrap().keys().map(String::as_str).collect::>(); + keys.sort_unstable(); + assert_eq!( + keys, + [ + "calldata", + "evm_opts", + "fork_chain_id_is_inferred", + "fork_network_is_inferred", + "fork_url_required", + "foundry_config", + "ir_minimum", + "local_chain_id", + "local_networks", + "no_vm", + "traces", + ] + ); + + let decoded = + serde_json::from_value::>(encoded).unwrap(); + assert_eq!(decoded.foundry_config.optimizer_runs, Some(500)); + assert_eq!(decoded.evm_opts.fork_block_number, Some(42)); + assert!(decoded.local_networks.is_some_and(|networks| networks.is_tempo())); + assert_eq!(decoded.local_chain_id, Some(4217)); + assert!(decoded.fork_network_is_inferred); + assert!(decoded.fork_chain_id_is_inferred); + assert!(decoded.no_vm); + assert!(decoded.traces); + assert_eq!(decoded.calldata, Some(vec![1, 2, 3])); + assert!(decoded.ir_minimum); + assert!(decoded.fork_url_required); + assert_eq!(decoded.source_chain_id, None); + } + + #[test] + fn saved_fork_restores_current_credentials_and_execution_settings() { + let mut saved = SessionSourceConfig::::default(); + saved.evm_opts.fork_url = Some("https://rpc.invalid/old-token".into()); + saved.evm_opts.fork_block_number = Some(42); + saved.evm_opts.env.chain_id = Some(1); + saved.foundry_config.optimizer_runs = Some(500); + saved.foundry_config.eth_rpc_accept_invalid_certs = true; + saved.foundry_config.eth_rpc_no_proxy = true; + saved.evm_opts.rpc_accept_invalid_certs = true; + saved.evm_opts.rpc_no_proxy = true; + saved.calldata = Some(vec![1, 2, 3]); + saved.ir_minimum = true; + let encoded = serde_json::to_string(&saved).unwrap(); + let mut saved = + serde_json::from_str::>(&encoded).unwrap(); + let mut current = SessionSourceConfig::::default(); + current.foundry_config.eth_rpc_url = Some("https://rpc.invalid/new-token".into()); + current.foundry_config.eth_rpc_jwt = Some("current-jwt".into()); + current.foundry_config.eth_rpc_headers = Some(vec!["Authorization: current-header".into()]); + current.foundry_config.etherscan_api_key = Some("current-api-key".into()); + current.foundry_config.rpc_endpoints = serde_json::from_value(serde_json::json!({ + "mainnet": "https://rpc.invalid/current-endpoint-token" + })) + .unwrap(); + current.foundry_config.etherscan = serde_json::from_value(serde_json::json!({ + "mainnet": { "key": "current-explorer-key", "chain": 1 } + })) + .unwrap(); + current.foundry_config.rpc_storage_caching.endpoints = + "current-cache-endpoint-token".parse().unwrap(); + current.evm_opts.fork_url = current.foundry_config.eth_rpc_url.clone(); + current.evm_opts.rpc_jwt = current.foundry_config.eth_rpc_jwt.clone(); + current.evm_opts.rpc_headers = current.foundry_config.eth_rpc_headers.clone(); + current.evm_opts.fork_headers = Some(vec!["Authorization: current-fork-header".into()]); + current.evm_opts.fork_block_number = Some(100); + current.evm_opts.env.chain_id = Some(10); + current.foundry_config.eth_rpc_timeout = Some(30); + current.evm_opts.rpc_timeout = Some(30); + + saved + .restore_credentials(&InvocationRpc::capture( + ¤t.foundry_config, + ¤t.evm_opts, + )) + .unwrap(); + + assert_eq!(saved.foundry_config.eth_rpc_url, current.foundry_config.eth_rpc_url); + assert_eq!(saved.foundry_config.eth_rpc_jwt, current.foundry_config.eth_rpc_jwt); + assert_eq!(saved.foundry_config.eth_rpc_headers, current.foundry_config.eth_rpc_headers); + assert_eq!( + saved.foundry_config.etherscan_api_key, + current.foundry_config.etherscan_api_key + ); + assert_eq!(saved.foundry_config.etherscan, current.foundry_config.etherscan); + assert_eq!(saved.foundry_config.rpc_endpoints, current.foundry_config.rpc_endpoints); + assert_eq!( + saved.foundry_config.rpc_storage_caching.endpoints, + current.foundry_config.rpc_storage_caching.endpoints + ); + assert_eq!(saved.evm_opts.fork_url, current.evm_opts.fork_url); + assert_eq!(saved.evm_opts.rpc_jwt, current.evm_opts.rpc_jwt); + assert_eq!(saved.evm_opts.rpc_headers, current.evm_opts.rpc_headers); + assert_eq!(saved.evm_opts.fork_headers, current.evm_opts.fork_headers); + assert!(!saved.foundry_config.eth_rpc_accept_invalid_certs); + assert!(!saved.foundry_config.eth_rpc_no_proxy); + assert!(!saved.evm_opts.rpc_accept_invalid_certs); + assert!(!saved.evm_opts.rpc_no_proxy); + assert_eq!(saved.foundry_config.eth_rpc_timeout, Some(30)); + assert_eq!(saved.evm_opts.rpc_timeout, Some(30)); + assert_eq!(saved.evm_opts.fork_block_number, Some(42)); + assert_eq!(saved.evm_opts.env.chain_id, Some(1)); + assert_eq!(saved.foundry_config.optimizer_runs, Some(500)); + assert_eq!(saved.calldata, Some(vec![1, 2, 3])); + assert!(saved.ir_minimum); + } + + #[test] + fn legacy_session_credentials_are_not_reused() { + let mut legacy = SessionSourceConfig::::default(); + legacy.foundry_config.eth_rpc_jwt = Some("legacy-jwt".into()); + legacy.foundry_config.etherscan_api_key = Some("legacy-key".into()); + legacy.evm_opts.rpc_headers = Some(vec!["Authorization: legacy-header".into()]); + legacy.evm_opts.fork_headers = Some(vec!["Authorization: legacy-fork-header".into()]); + legacy.restore_credentials(&InvocationRpc::default()).unwrap(); + + assert_eq!(legacy.foundry_config.eth_rpc_jwt, None); + assert_eq!(legacy.foundry_config.etherscan_api_key, None); + assert_eq!(legacy.evm_opts.rpc_headers, None); + assert_eq!(legacy.evm_opts.fork_headers, None); + } + + #[test] + fn saved_fork_requires_current_endpoint() { + let mut saved = SessionSourceConfig::::default(); + saved.evm_opts.fork_url = Some("https://rpc.invalid/legacy-token".into()); + saved.clear_credentials(); + assert_eq!(saved.evm_opts.fork_url, None); + assert!(saved.fork_url_required); + + let error = saved.restore_credentials(&InvocationRpc::default()).unwrap_err(); + + assert_eq!( + error.to_string(), + "this saved Chisel session requires a fork endpoint; use !fork or restart Chisel with --fork-url to load it" + ); + } + + #[test] + fn saved_local_session_stays_local_with_current_fork_endpoint() { + let mut saved = SessionSourceConfig::::default(); + let mut current = SessionSourceConfig::::default(); + current.evm_opts.fork_url = Some("https://rpc.invalid/current-token".into()); + + saved + .restore_credentials(&InvocationRpc::capture( + ¤t.foundry_config, + ¤t.evm_opts, + )) + .unwrap(); + + assert_eq!(saved.evm_opts.fork_url, None); + assert!(!saved.fork_url_required); + } +} diff --git a/crates/chisel/tests/cache.rs b/crates/chisel/tests/cache.rs deleted file mode 100644 index 75da9683f6d8d..0000000000000 --- a/crates/chisel/tests/cache.rs +++ /dev/null @@ -1,212 +0,0 @@ -use chisel::session::ChiselSession; -use foundry_compilers::artifacts::EvmVersion; -use foundry_config::Config; -use std::path::Path; - -#[test] -fn test_cache_directory() { - // Get the cache dir - // Should be ~/.foundry/cache/chisel - let cache_dir = ChiselSession::cache_dir().unwrap(); - - // Validate the cache directory - let home_dir = dirs::home_dir().unwrap(); - assert_eq!(cache_dir, format!("{}/.foundry/cache/chisel/", home_dir.to_str().unwrap())); -} - -#[test] -fn test_create_cache_directory() { - // Get the cache dir - let cache_dir = ChiselSession::cache_dir().unwrap(); - - // Create the cache directory - ChiselSession::create_cache_dir().unwrap(); - - // Validate the cache directory - assert!(Path::new(&cache_dir).exists()); -} - -#[test] -fn test_write_session() { - // Create the cache directory if it doesn't exist - let cache_dir = ChiselSession::cache_dir().unwrap(); - ChiselSession::create_cache_dir().unwrap(); - - // Force the solc version to be 0.8.19 - let foundry_config = Config { evm_version: EvmVersion::London, ..Default::default() }; - - // Create a new session - let mut env = ChiselSession::new(chisel::session_source::SessionSourceConfig { - foundry_config, - ..Default::default() - }) - .unwrap_or_else(|e| panic!("Failed to create ChiselSession!, {e}")); - - // Write the session - let cached_session_name = env.write().unwrap(); - - // Count the number of items in the cache_dir directory - let mut num_items = std::fs::read_dir(&cache_dir).unwrap().count(); - num_items = if num_items > 0 { num_items - 1 } else { 0 }; - - // Validate the session - assert_eq!(cached_session_name, format!("{cache_dir}chisel-{num_items}.json")); -} - -#[test] -fn test_write_session_with_name() { - // Create the cache directory if it doesn't exist - let cache_dir = ChiselSession::cache_dir().unwrap(); - ChiselSession::create_cache_dir().unwrap(); - - // Force the solc version to be 0.8.19 - let foundry_config = Config { evm_version: EvmVersion::London, ..Default::default() }; - - // Create a new session - let mut env = ChiselSession::new(chisel::session_source::SessionSourceConfig { - foundry_config, - ..Default::default() - }) - .unwrap_or_else(|e| panic!("Failed to create ChiselSession! {e}")); - env.id = Some(String::from("test")); - - // Write the session - let cached_session_name = env.write().unwrap(); - - // Validate the session - assert_eq!(cached_session_name, format!("{cache_dir}chisel-test.json")); -} - -#[test] -fn test_clear_cache() { - // Create a session to validate clearing a non-empty cache directory - let cache_dir = ChiselSession::cache_dir().unwrap(); - - // Force the solc version to be 0.8.19 - let foundry_config = Config { evm_version: EvmVersion::London, ..Default::default() }; - - ChiselSession::create_cache_dir().unwrap(); - let mut env = ChiselSession::new(chisel::session_source::SessionSourceConfig { - foundry_config, - ..Default::default() - }) - .unwrap_or_else(|_| panic!("Failed to create ChiselSession!")); - env.write().unwrap(); - - // Clear the cache - ChiselSession::clear_cache().unwrap(); - - // Validate there are no items in the cache dir - let num_items = std::fs::read_dir(cache_dir).unwrap().count(); - assert_eq!(num_items, 0); -} - -#[test] -fn test_list_sessions() { - // Create and clear the cache directory - ChiselSession::create_cache_dir().unwrap(); - ChiselSession::clear_cache().unwrap(); - - // Force the solc version to be 0.8.19 - let foundry_config = Config { evm_version: EvmVersion::London, ..Default::default() }; - - // Create a new session - let mut env = ChiselSession::new(chisel::session_source::SessionSourceConfig { - foundry_config, - ..Default::default() - }) - .unwrap_or_else(|e| panic!("Failed to create ChiselSession! {e}")); - - env.write().unwrap(); - - // List the sessions - let sessions = ChiselSession::list_sessions().unwrap(); - - // Validate the sessions - assert_eq!(sessions.len(), 1); - assert_eq!(sessions[0].1, "chisel-0.json"); -} - -#[test] -fn test_load_cache() { - // Create and clear the cache directory - ChiselSession::create_cache_dir().unwrap(); - ChiselSession::clear_cache().unwrap(); - - // Force the solc version to be 0.8.19 - let foundry_config = Config { evm_version: EvmVersion::London, ..Default::default() }; - - // Create a new session - let mut env = ChiselSession::new(chisel::session_source::SessionSourceConfig { - foundry_config, - ..Default::default() - }) - .unwrap_or_else(|e| panic!("Failed to create ChiselSession! {e}")); - env.write().unwrap(); - - // Load the session - let new_env = ChiselSession::load("0"); - - // Validate the session - assert!(new_env.is_ok()); - let new_env = new_env.unwrap(); - assert_eq!(new_env.id.unwrap(), String::from("0")); - assert_eq!(new_env.session_source.to_repl_source(), env.session_source.to_repl_source()); -} - -#[test] -fn test_write_same_session_multiple_times() { - // Create and clear the cache directory - ChiselSession::create_cache_dir().unwrap(); - ChiselSession::clear_cache().unwrap(); - - // Force the solc version to be 0.8.19 - let foundry_config = Config { evm_version: EvmVersion::London, ..Default::default() }; - - // Create a new session - let mut env = ChiselSession::new(chisel::session_source::SessionSourceConfig { - foundry_config, - ..Default::default() - }) - .unwrap_or_else(|e| panic!("Failed to create ChiselSession! {e}")); - env.write().unwrap(); - env.write().unwrap(); - env.write().unwrap(); - env.write().unwrap(); - assert_eq!(ChiselSession::list_sessions().unwrap().len(), 1); -} - -#[test] -fn test_load_latest_cache() { - // Create and clear the cache directory - ChiselSession::create_cache_dir().unwrap(); - ChiselSession::clear_cache().unwrap(); - - // Force the solc version to be 0.8.19 - let foundry_config = Config { evm_version: EvmVersion::London, ..Default::default() }; - - // Create sessions - let mut env = ChiselSession::new(chisel::session_source::SessionSourceConfig { - foundry_config: foundry_config.clone(), - ..Default::default() - }) - .unwrap_or_else(|e| panic!("Failed to create ChiselSession! {e}")); - env.write().unwrap(); - - let wait_time = std::time::Duration::from_millis(100); - std::thread::sleep(wait_time); - - let mut env2 = ChiselSession::new(chisel::session_source::SessionSourceConfig { - foundry_config, - ..Default::default() - }) - .unwrap_or_else(|e| panic!("Failed to create ChiselSession! {e}")); - env2.write().unwrap(); - - // Load the latest session - let new_env = ChiselSession::latest().unwrap(); - - // Validate the session - assert_eq!(new_env.id.unwrap(), "1"); - assert_eq!(new_env.session_source.to_repl_source(), env.session_source.to_repl_source()); -} diff --git a/crates/chisel/tests/it/input.rs b/crates/chisel/tests/it/input.rs new file mode 100644 index 0000000000000..467db1a5eb230 --- /dev/null +++ b/crates/chisel/tests/it/input.rs @@ -0,0 +1,91 @@ +use foundry_test_utils::{snapbox, str}; +use std::{fs, path::Path}; + +fn command(root: &Path) -> snapbox::cmd::Command { + snapbox::cmd::Command::new(env!("CARGO_BIN_EXE_chisel")) + .env_clear() + .env("HOME", root) + .env("NO_COLOR", "1") + .env("TERM", "dumb") + .current_dir(root) + .args(["--offline", "--no-vm", "--use", "0.8.29"]) +} + +#[test] +fn eval_rejects_unterminated_block_comments() { + let root = tempfile::tempdir().unwrap(); + for input in ["/*", "/**", "/* closed */ /*", "uint256 x = 1; /*"] { + command(root.path()).args(["eval", input]).assert().code(1).stdout_eq(str![""]).stderr_eq( + str![[r#" +Error: unterminated block comment + +"#]], + ); + } +} + +#[test] +fn eval_accepts_complete_comments() { + let root = tempfile::tempdir().unwrap(); + for input in ["/* closed */", "/** closed */", "/* first\nsecond */", "// /*"] { + command(root.path()) + .args(["eval", input]) + .assert() + .success() + .stdout_eq(str![""]) + .stderr_eq(str![""]); + } +} + +#[test] +fn prelude_rejects_unterminated_block_comments() { + let root = tempfile::tempdir().unwrap(); + fs::create_dir(root.path().join("prelude")).unwrap(); + for input in ["/*", "/**", "/* closed */ /*", "uint256 x = 1; /*"] { + fs::write(root.path().join("prelude/input.sol"), input).unwrap(); + for prelude in ["prelude/input.sol", "prelude"] { + command(root.path()) + .args(["--prelude", prelude, "eval", ""]) + .assert() + .code(1) + .stdout_eq(str![[r#" +Loading prelude source file: [..]input.sol + +"#]]) + .stderr_eq(str![[r#" +Error: unterminated block comment + +"#]]); + } + } +} + +#[test] +fn prelude_accepts_complete_comments() { + let root = tempfile::tempdir().unwrap(); + fs::create_dir(root.path().join("prelude")).unwrap(); + fs::write(root.path().join("prelude/input.sol"), "/* first\nsecond */").unwrap(); + + command(root.path()) + .args(["--prelude", "prelude/input.sol", "eval", ""]) + .assert() + .success() + .stdout_eq(str![[r#" +Loading prelude source file: [..]input.sol +Prelude source file loaded successfully! + + +"#]]) + .stderr_eq(str![""]); + command(root.path()) + .args(["--prelude", "prelude", "eval", ""]) + .assert() + .success() + .stdout_eq(str![[r#" +Loading prelude source file: [..]input.sol +All prelude source files loaded successfully! + + +"#]]) + .stderr_eq(str![""]); +} diff --git a/crates/chisel/tests/it/main.rs b/crates/chisel/tests/it/main.rs new file mode 100644 index 0000000000000..4f4669f01b59e --- /dev/null +++ b/crates/chisel/tests/it/main.rs @@ -0,0 +1,7 @@ +#[cfg(unix)] +mod repl; + +#[cfg(unix)] +mod session_cache; + +mod input; diff --git a/crates/chisel/tests/it/repl/mod.rs b/crates/chisel/tests/it/repl/mod.rs new file mode 100644 index 0000000000000..bb2d740739ade --- /dev/null +++ b/crates/chisel/tests/it/repl/mod.rs @@ -0,0 +1,677 @@ +mod session; +use chisel::session::ChiselSession as CachedChiselSession; +use foundry_evm::core::evm::EthEvmNetwork; +use foundry_test_utils::util::{OTHER_SOLC_VERSION, SOLC_VERSION}; +use session::ChiselSession; +use std::{ + fs, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, +}; + +struct CacheCleanup(Vec); + +impl Drop for CacheCleanup { + fn drop(&mut self) { + for id in &self.0 { + let _ = CachedChiselSession::::remove_cached_session(id); + } + } +} + +fn unique_cache_id(prefix: &str) -> String { + let timestamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + format!("{prefix}-{}-{timestamp}", std::process::id()) +} + +fn cache_file(id: &str) -> PathBuf { + PathBuf::from(format!( + "{}chisel-{id}.json", + CachedChiselSession::::cache_dir().unwrap() + )) +} + +macro_rules! repl_test { + ($name:ident, | $cmd:ident | $test:expr) => { + repl_test!($name, "", |$cmd| $test); + }; + ($name:ident, $flags:expr, | $cmd:ident | $test:expr) => { + repl_test!($name, $flags, init = false, |$cmd| $test); + }; + ($name:ident, $flags:expr,init = $init:expr, | $cmd:ident | $test:expr) => { + #[test] + #[allow(unused_mut)] + fn $name() { + let mut $cmd = ChiselSession::new(stringify!($name), $flags, $init); + $test; + return (); // Fix "go to definition" due to `tokio::test`. + } + }; +} + +repl_test!(repl_help, |repl| { + repl.sendln_raw("!h"); + repl.expect("Chisel help"); + repl.expect_prompt(); +}); + +repl_test!(command_output_after_raw_input, |repl| { + repl.sendln_raw("!help"); + repl.expect("Chisel help"); + repl.expect_prompt(); + + // A synchronous command must retain its output even after an explicit prompt wait. + repl.sendln("uint256(7)"); + repl.expect("Hex: 0x7"); + repl.expect("Decimal: 7"); + repl.sendln("uint256(8)"); + repl.expect("Decimal: 8"); +}); + +repl_test!(sendln_waits_for_save, |repl| { + let id = unique_cache_id("wait-for-save"); + let _cleanup = CacheCleanup(vec![id.clone()]); + + // The first command must finish before the caller observes its filesystem effects. + repl.sendln(&format!("!save {id}")); + assert!(cache_file(&id).is_file()); + repl.expect(&format!("Saved session to cache with ID = {id}")); +}); + +repl_test!(save_renamed_session_removes_stale_cache, |repl| { + let old_id = unique_cache_id("rename-old"); + let new_id = unique_cache_id("rename-new"); + let _cleanup = CacheCleanup(vec![old_id.clone(), new_id.clone()]); + + repl.sendln(&format!("!save {old_id}")); + repl.sendln(&format!("!save {new_id}")); + repl.sendln_raw("!list"); + repl.expect(&format!("chisel-{new_id}.json")); + repl.expect_prompt(); + + // A renamed session must not leave the previous cache entry loadable. + repl.sendln_raw(&format!("!load {old_id}")); + repl.expect("failed to load session"); + repl.expect_prompt(); + + repl.sendln_raw(&format!("!load {new_id}")); + repl.expect(&format!("Loaded Chisel session! (ID = {new_id})")); + repl.expect_prompt(); +}); + +repl_test!(save_case_only_rename_preserves_destination, |repl| { + let old_id = unique_cache_id("rename-case").to_ascii_lowercase(); + let new_id = old_id.to_ascii_uppercase(); + let _cleanup = CacheCleanup(vec![old_id.clone(), new_id.clone()]); + + repl.sendln(&format!("!save {old_id}")); + let old_cache_file = cache_file(&old_id); + let new_cache_file = cache_file(&new_id); + let paths_alias = + std::fs::canonicalize(&old_cache_file).ok() == std::fs::canonicalize(&new_cache_file).ok(); + + repl.sendln(&format!("!save {new_id}")); + + // On case-insensitive filesystems, both IDs resolve to the same cache path. + repl.sendln_raw(&format!("!load {new_id}")); + repl.expect(&format!("Loaded Chisel session! (ID = {new_id})")); + repl.expect_prompt(); + + if !paths_alias { + repl.sendln_raw(&format!("!load {old_id}")); + repl.expect("failed to load session"); + repl.expect_prompt(); + } +}); + +repl_test!(failed_save_restores_previous_session_id, |repl| { + let first_id = unique_cache_id("failed-save-first"); + let second_id = unique_cache_id("failed-save-second"); + let _cleanup = CacheCleanup(vec![first_id.clone(), second_id.clone()]); + + repl.sendln(&format!("!save {first_id}")); + // A directory at the destination makes a valid ID fail when writing the cache file. + let blocked_cache = tempfile::Builder::new() + .prefix("chisel-failed-save-") + .suffix(".json") + .tempdir_in(CachedChiselSession::::cache_dir().unwrap()) + .unwrap(); + let blocked_name = blocked_cache.path().file_name().unwrap().to_str().unwrap(); + let blocked_id = blocked_name.strip_prefix("chisel-").unwrap().strip_suffix(".json").unwrap(); + repl.sendln_raw(&format!("!save {blocked_id}")); + repl.expect("Is a directory"); + repl.expect_prompt(); + + // A failed rename must not lose the ID of the last successfully saved file. + repl.sendln(&format!("!save {second_id}")); + repl.sendln_raw("!list"); + repl.expect(&format!("chisel-{second_id}.json")); + repl.expect_prompt(); + + repl.sendln_raw(&format!("!load {first_id}")); + repl.expect("failed to load session"); + repl.expect_prompt(); +}); + +repl_test!(load_session_preserves_active_force, "--force", |repl| { + let id = unique_cache_id("active-force"); + let _cleanup = CacheCleanup(vec![id.clone()]); + + repl.sendln(&format!("!save {id}")); + + let out_dir = repl.project().root().join("out"); + fs::create_dir_all(&out_dir).unwrap(); + fs::write(out_dir.join("sentinel"), []).unwrap(); + + repl.sendln(&format!("!load {id}")); + + assert!(!out_dir.exists()); +}); + +// Test abi encode/decode. +repl_test!(abi_encode_decode, |repl| { + repl.sendln("bytes memory encoded = abi.encode(42, \"hello\")"); + repl.sendln("(uint num, string memory str) = abi.decode(encoded, (uint, string))"); + repl.sendln("num"); + repl.expect("42"); + repl.sendln("str"); + repl.expect("hello"); +}); + +// Issue #5253: Dynamic bytes should be displayed as their raw value. +repl_test!(dynamic_bytes_display, |repl| { + repl.sendln(r#"bytes memory encoded = abi.encode("Not initialized")"#); + repl.sendln("bytes memory decoded = abi.decode(encoded, (bytes))"); + repl.sendln(r#"bytes memory raw = bytes("Not initialized")"#); + + repl.sendln("encoded"); + repl.expect( + "Data: 0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000f4e6f7420696e697469616c697a65640000000000000000000000000000000000", + ); + + repl.sendln("decoded"); + repl.expect("Data: 0x4e6f7420696e697469616c697a6564"); + + repl.sendln("raw"); + repl.expect("Data: 0x4e6f7420696e697469616c697a6564"); +}); + +// Test 0x prefixed strings. +repl_test!(hex_string_interpretation, |repl| { + repl.sendln("string memory s = \"0x1234\""); + repl.sendln("s"); + // Should be treated as string, not hex literal. + repl.expect("0x1234"); +}); + +// Hex literals in the generated inspector `abi.encode(...)` call are ABI-encoded as dynamic bytes. +repl_test!(hex_literal_inspection_type, |repl| { + repl.sendln("hex\"6869\""); + repl.expect("Type: dynamic bytes"); + repl.sendln("(hex\"6869\")"); + repl.expect("Type: dynamic bytes"); +}); + +// Test cheatcodes availability. +repl_test!(cheatcodes_available, "", init = true, |repl| { + repl.sendln("address alice = address(0x1)"); + + repl.sendln("alice.balance"); + repl.expect("Decimal: 0"); + + repl.sendln("vm.deal();"); + repl.expect("Wrong argument count for function call"); + + repl.sendln("vm.deal(alice, 1 ether);"); + + repl.sendln("alice.balance"); + repl.expect("Decimal: 1000000000000000000"); +}); + +// Test empty inputs. +repl_test!(empty_input, |repl| { + for line in " \n \n\n \t \t \n \n\t\t\t\t \n \n".split('\n') { + repl.sendln(line); + } +}); + +// Issue #4130: Test type(intN).min correctness. +repl_test!(int_min_values, |repl| { + repl.sendln("type(int8).min"); + repl.expect("-128"); + repl.sendln("type(int256).min"); + repl.expect("-57896044618658097711785492504343953926634992332820282019728792003956564819968"); +}); + +// Issue #5370: The last evaluated result can be reused in subsequent input. +repl_test!(last_result, |repl| { + repl.sendln("type(uint256).max"); + repl.sendln("uint256 MAX = $_"); + repl.sendln("MAX"); + repl.expect( + "Decimal: 115792089237316195423570985008687907853269984665640564039457584007913129639935", + ); + + repl.sendln("uint256 value = 1"); + repl.sendln("value = 2"); + repl.sendln("uint256 assigned = $_"); + repl.sendln("assigned"); + repl.expect("Decimal: 2"); + + repl.sendln(r#""hello""#); + repl.sendln("string memory greeting = $_"); + repl.sendln("greeting"); + repl.expect("UTF-8: hello"); +}); + +repl_test!(last_result_resets_with_session, |repl| { + let saved_id = unique_cache_id("last-result-saved"); + let loaded_id = unique_cache_id("last-result-loaded"); + let _cleanup = CacheCleanup(vec![saved_id.clone(), loaded_id.clone()]); + + repl.sendln("uint256 persisted = 7"); + repl.sendln(&format!("!save {saved_id}")); + std::fs::copy(cache_file(&saved_id), cache_file(&loaded_id)).unwrap(); + + repl.sendln(r#""stale""#); + repl.sendln_raw(&format!("!load {loaded_id}")); + repl.expect(&format!("Loaded Chisel session! (ID = {loaded_id})")); + repl.expect_prompt(); + repl.sendln_raw("$_"); + repl.expect("no previous result"); + repl.expect_prompt(); + + repl.sendln("persisted"); + repl.sendln("!clear"); + repl.sendln_raw("$_"); + repl.expect("no previous result"); + repl.expect_prompt(); +}); + +// Issue #4393: Test edit command with traces. +repl_test!(edit_with_traces, |repl| { + repl.sendln("!traces"); + repl.expect("Enabled traces!"); + repl.sendln("uint x = 42"); + repl.sendln("!edit"); + repl.expect("Traces:"); + repl.expect("Successfully edited `run()` function's body!"); + repl.sendln("x"); + repl.expect("Decimal: 42"); +}); + +// Test tuple support. +repl_test!(tuples, |repl| { + repl.sendln("(uint a, uint b) = (1, 2)"); + repl.sendln("a"); + repl.expect("Decimal: 1"); + repl.sendln("b"); + repl.expect("Decimal: 2"); +}); + +// Issue #4467: Test import. +repl_test!(import, "", init = true, |repl| { + repl.sendln("import {Counter} from \"src/Counter.sol\""); + repl.sendln("Counter c = new Counter()"); + repl.sendln("c.number()"); + repl.expect("Decimal: 0"); + repl.sendln("c.increment();"); + repl.sendln("c.number()"); + repl.expect("Decimal: 1"); +}); + +// Issue #4617: Test code after assembly return. +repl_test!(assembly_return, |repl| { + repl.sendln("uint x = 1;"); + repl.sendln("assembly { mstore(0x0, 0x1337) return(0x0, 0x20) }"); + repl.sendln("x = 2;"); + repl.sendln("!md"); + // Should work without errors. + repl.expect("[0x00:0x20]: 0x0000000000000000000000000000000000000000000000000000000000001337"); +}); + +// Issue #4652: Test commands with trailing whitespace. +repl_test!(trailing_whitespace, |repl| { + repl.sendln("uint x = 42 "); + repl.sendln("x"); + repl.expect("Decimal: 42"); +}); + +// Issue #4652: Test that solc flags are respected. +repl_test!(solc_flags, &format!("--use {OTHER_SOLC_VERSION}"), |repl| { + repl.sendln(&format!("pragma solidity ={SOLC_VERSION};")); + repl.expect("invalid compiler version"); +}); + +// Issue #4915: `chisel eval` +repl_test!(eval_subcommand, "eval type(uint8).max", |repl| { + repl.expect("Decimal: 255"); +}); + +// Issue #17125: external function references display their address and selector. +repl_test!(external_function_references, |repl| { + repl.sendln_raw( + "interface IProbe { function value() external view returns (uint256); function pair() external view returns (uint256, bool); function ping() external; }", + ); + repl.expect_prompt(); + + repl.sendln("IProbe(address(0x1234)).value"); + repl.expect("Type: function"); + repl.expect("Address: 0x0000000000000000000000000000000000001234"); + repl.expect("Selector: 0x3fa4f245"); + + repl.sendln("IProbe(address(0x1234)).pair"); + repl.expect("Type: function"); + repl.expect("Address: 0x0000000000000000000000000000000000001234"); + repl.expect("Selector: 0xa8aa1b31"); + + repl.sendln("IProbe(address(0x1234)).ping"); + repl.expect("Type: function"); + repl.expect("Address: 0x0000000000000000000000000000000000001234"); + repl.expect("Selector: 0x5c36b186"); + + repl.sendln("uint256(1)"); + repl.sendln("[IProbe(address(0x1234)).ping]"); + repl.expect("Type: function"); + repl.expect("Selector: 0x5c36b186"); + repl.sendln("$_"); + repl.expect("no previous result"); +}); + +// Issue #4963: inspect the value of the final inline assembly expression. +repl_test!(inline_assembly_expression, |repl| { + repl.sendln("uint256 value = 1"); + repl.sendln("assembly { value := 2 add(value, 0) } // trailing comment"); + repl.expect("Decimal: 2"); + repl.sendln("value"); + repl.expect("Decimal: 2"); + + repl.sendln("assembly { let __chisel_yul_result := 3 add(__chisel_yul_result, 0) }"); + repl.expect("Decimal: 3"); + + repl.sendln("uint256 __chisel_yul_result_1 = 0"); + repl.sendln("assembly { add(3, 4) }"); + repl.expect("Decimal: 7"); +}); + +repl_test!( + eval_tempo_network_uses_tempo_executor, + "--network tempo eval address(0xfeEC000000000000000000000000000000000000).code.length", + |repl| { + repl.expect("Decimal: 1"); + } +); + +repl_test!( + eval_tempo_chain_id_uses_tempo_executor, + "--chain 4217 eval address(0xfeEC000000000000000000000000000000000000).code.length", + |repl| { + repl.expect("Decimal: 1"); + } +); + +#[cfg(feature = "base")] +repl_test!( + eval_base_network_uses_base_executor, + "--network base --hardfork base:Beryl --chain 8453", + |repl| { + repl.sendln_raw( + "interface IActivationRegistry { function admin() external view returns (address); }", + ); + repl.expect_prompt(); + repl.sendln("IActivationRegistry(0x8453000000000000000000000000000000000001).admin()"); + repl.expect("Data: 0xcE3a3bEE7E72E2A24079f3c0Cb3b97740ED425A9"); + } +); + +repl_test!( + eval_tempo_named_chain_uses_tempo_executor, + "--chain tempo eval address(0xfeEC000000000000000000000000000000000000).code.length", + |repl| { + repl.expect("Decimal: 1"); + } +); + +#[cfg(feature = "monad")] +repl_test!(eval_monad_network_option_runs, "--network monad eval uint256(block.chainid)", |repl| { + repl.expect("Decimal: 31337"); +}); + +#[cfg(feature = "monad")] +repl_test!(eval_monad_chain_id_option_runs, "--chain 143 eval uint256(block.chainid)", |repl| { + repl.expect("Decimal: 143"); +}); + +// Issue #4938: Test memory/stack dumps with assembly. +repl_test!(assembly_memory_dump, |repl| { + let input = r#" +uint256 value = 12345; +string memory str; +assembly { + str := add(mload(0x40), 0x80) + mstore(0x40, add(str, 0x20)) + mstore(str, 0) + let end := str +} +"#; + repl.sendln_raw(input.trim()); + repl.expect_prompts(3); + repl.sendln("value"); + repl.expect("Decimal: 12345"); + repl.sendln("!md"); + repl.expect("[0x00:0x20]"); +}); + +// Assembly as the final statement with a return — exercises the path where both +// `first_yul_return_span` and `trailing_assembly_last_stmt_span` resolve to the same `return(...)` +// span (no subsequent Solidity statement after the assembly block). +repl_test!(assembly_return_final, |repl| { + repl.sendln("uint x = 0xbeef;"); + repl.sendln("assembly { mstore(0x0, sload(0)) return(0x0, 0x20) }"); + repl.sendln("!md"); + repl.expect("[0x00:0x20]"); +}); + +// Assembly block without a `return(...)` call as an intermediate statement, exercises +// `first_yul_return_span` returning `None` while a subsequent Solidity statement is still evaluated +// correctly. +repl_test!(assembly_no_return_intermediate, |repl| { + repl.sendln("uint x = 1;"); + repl.sendln("assembly { x := add(x, 1) }"); + repl.sendln("x"); + repl.expect("Decimal: 2"); +}); + +// Issue #5051, #8978: Test EVM version normalization. +repl_test!(flaky_evm_version_normalization, "--use 0.7.6 --evm-version london", |repl| { + repl.sendln("uint x;"); + repl.sendln("x"); + repl.expect("Decimal: 0"); +}); + +// Issue #5481: Test function return values are displayed. +repl_test!(function_return_display, |repl| { + repl.sendln("function add(uint a, uint b) public pure returns (uint) { return a + b; }"); + repl.sendln("add(2, 3)"); + repl.expect("Decimal: 5"); +}); + +// Issue #5737: Test bytesN return types. +repl_test!(bytes_length_type, |repl| { + repl.sendln("bytes10 b = bytes10(0)"); + repl.sendln("b.length"); + repl.expect("Decimal: 10"); +}); + +// Issue #5737: Test bytesN indexing return type. +repl_test!(bytes_index_type, |repl| { + repl.sendln("bytes32 b = bytes32(uint256(0x0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20))"); + repl.sendln("b[3]"); + repl.expect("Data: 0x0400000000000000000000000000000000000000000000000000000000000000"); +}); + +// Issue #6618: Test fetching interface with structs. +repl_test!(fetch_interface_with_structs, |repl| { + repl.sendln_raw("!fe 0x5ff137d4b0fdcd49dca30c7cf57e578a026d2789 IEntryPoint"); + repl.expect( + "Added 0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789's interface to source as `IEntryPoint`", + ); + repl.expect_prompt(); + repl.sendln("uint256 x = 1;"); + repl.sendln("x"); + repl.expect("Decimal: 1"); +}); + +// Issue #7035: Test that hex strings aren't checksummed as addresses. +repl_test!(hex_string_no_checksum, |repl| { + repl.sendln("function test(string memory s) public pure returns (string memory) { return s; }"); + repl.sendln("test(\"0xe5f3af50fe5d0bf402a3c6f55ccc47d4307922d4\")"); + // Should return the exact string, not checksummed. + repl.expect("0xe5f3af50fe5d0bf402a3c6f55ccc47d4307922d4"); +}); + +// Issue #7050: Test enum min/max operations. +repl_test!(enum_min_max, |repl| { + repl.sendln("enum Color { Red, Green, Blue }"); + repl.sendln("type(Color).min"); + repl.expect("Decimal: 0"); + repl.sendln("type(Color).max"); + repl.expect("Decimal: 2"); +}); + +// Issue #7193: Test that inspected delete expressions persist in the session. +repl_test!(delete_expression_persistence, |repl| { + repl.sendln("uint256 value = 42"); + repl.sendln("delete value"); + repl.sendln("value"); + repl.expect("Decimal: 0"); +}); + +// Issue #9377: Test correct hex formatting for uint256. +repl_test!(uint256_hex_formatting, |repl| { + repl.sendln("uint256 x = 42"); + // Full word hex should be 64 chars (256 bits). + repl.sendln("x"); + repl.expect("0x000000000000000000000000000000000000000000000000000000000000002a"); +}); + +// Issue #9377: Test that full words are printed correctly. +repl_test!(full_word_hex_formatting, |repl| { + repl.sendln(r#"uint256(keccak256(abi.encode(uint256(keccak256("AgoraStableSwapStorage.OracleStorage")) - 1))) & ~uint256(0xff)"#); + repl.expect( + "Hex (full word): 0x0a6b316b47a0cd26c1b582ae3dcffbd175283c221c3cb3d1c614e3e47f62a700", + ); +}); + +// Test that uint is printed properly with any size. +repl_test!(uint_formatting, |repl| { + for size in (8..=256).step_by(8) { + repl.sendln(&format!("type(uint{size}).max")); + repl.expect(&format!("Hex: 0x{}", "f".repeat(size / 4))); + + repl.sendln(&format!("uint{size}(2)")); + repl.expect("Hex: 0x2"); + } +}); + +// Test that int is printed properly with any size. +repl_test!(int_formatting, |repl| { + for size in (8..=256).step_by(8) { + let size_minus_1: usize = size / 4 - 1; + repl.sendln(&format!("type(int{size}).max")); + repl.expect(&format!("Hex: 0x7{}", "f".repeat(size_minus_1))); + + repl.sendln(&format!("int{size}(2)")); + repl.expect("Hex: 0x2"); + + repl.sendln(&format!("type(int{size}).min")); + repl.expect(&format!("Hex: 0x8{}", "0".repeat(size_minus_1))); + + repl.sendln(&format!("int{size}(-2)")); + repl.expect(&format!("Hex: 0x{}e", "f".repeat(size_minus_1))); + } +}); + +repl_test!(uninitialized_variables, |repl| { + repl.sendln("uint256 x;"); + repl.sendln("address y;"); + repl.sendln("assembly { y := not(x) }"); + + repl.sendln("x"); + repl.expect("Hex: 0x0"); + + repl.sendln("y"); + repl.expect("Data: 0xFFfFfFffFFfffFFfFFfFFFFFffFFFffffFfFFFfF"); +}); + +repl_test!(chisel_can_run_with_live_logs_flag, "--live-logs", init = true, |repl| { + repl.sendln("import {console} from 'forge-std/Script.sol';"); + repl.sendln("console.log('Hello, World!');"); + repl.expect("Hello, World!"); + + repl.sendln("console.log('Goodbye, World!');"); + repl.expect("Hello, World!"); // old log is also printed + repl.expect("Goodbye, World!"); +}); + +// A user-defined `run` overload must not shadow the generated `run()` entrypoint. +repl_test!(run_overload_does_not_shadow_entrypoint, |repl| { + repl.sendln("function run(uint256 x) public pure returns (uint256) { return x; }"); + repl.sendln("uint256 y = 7"); + + repl.sendln("y"); + repl.expect("Decimal: 7"); + + repl.sendln("run(3)"); + repl.expect("Decimal: 3"); +}); + +// Assembly in a user-defined `run` overload must not affect the generated `run()` entrypoint. +repl_test!(run_overload_assembly_does_not_shadow_entrypoint, |repl| { + repl.sendln("function run(uint256) public pure { assembly { return(0, 0) } }"); + + repl.sendln("uint256(7)"); + repl.expect("Decimal: 7"); +}); + +// Interactive block comments must keep their contents together until they are closed. +repl_test!(multiline_block_comment_continues, |repl| { + repl.sendln("uint256 value = 1;"); + repl.sendln_raw("/*\nvalue = 2;\n*/"); + repl.expect_prompt(); + + repl.sendln("value"); + repl.expect("Decimal: 1"); + + // Code preceding an open comment must also wait for the closing delimiter. + repl.sendln_raw("value = 3; /*\nvalue = 4;\n*/"); + repl.expect_prompt(); + + repl.sendln("value"); + repl.expect("Decimal: 3"); +}); + +#[test] +fn imported_repl_does_not_shadow_wrapper() { + // Cover both sides of artifact path ordering, with and without matching file names. + for (file_index, file) in + ["A.sol", "Z.sol", "A/ReplContract.sol", "Z/ReplContract.sol"].into_iter().enumerate() + { + // Missing run() exposes incorrect HIR selection; an existing run() exposes wrong execution. + for (body_index, body) in ["", "function run() public pure {}"].into_iter().enumerate() { + let name = format!("imported_repl_{file_index}_{body_index}"); + let mut repl = ChiselSession::new(&name, "", false); + let source = repl.project().root().join(file); + fs::create_dir_all(source.parent().unwrap()).unwrap(); + fs::write(source, format!("contract REPL {{ {body} }}")).unwrap(); + repl.sendln(&format!("import {{REPL as ImportedREPL}} from \"./{file}\";")); + + repl.sendln("uint256 value = 7;"); + repl.sendln("value"); + repl.expect("Decimal: 7"); + repl.sendln("value += 1;"); + repl.sendln("value"); + repl.expect("Decimal: 8"); + } + } +} diff --git a/crates/chisel/tests/it/repl/session.rs b/crates/chisel/tests/it/repl/session.rs new file mode 100644 index 0000000000000..e1b8c3b59b1c5 --- /dev/null +++ b/crates/chisel/tests/it/repl/session.rs @@ -0,0 +1,143 @@ +use foundry_compilers::PathStyle; +use foundry_test_utils::TestProject; +use rexpect::{reader::Options, session::PtySession, spawn_with_options}; + +// Some inputs compile and evaluate code before the next prompt is printed; 3s is tight on CI. +const TIMEOUT_SECS: u64 = 10; +const PROMPT: &str = "➜ "; + +/// Testing session for Chisel. +pub struct ChiselSession { + session: Box, + project: Box, + is_repl: bool, + /// Unread output from the last completed synchronous command. + output: Option, +} + +static SUBCOMMANDS: &[&str] = &["list", "load", "view", "clear-cache", "eval", "help"]; + +fn is_repl(args: &[String]) -> bool { + args.is_empty() + || !SUBCOMMANDS.iter().any(|subcommand| args.iter().any(|arg| arg == subcommand)) +} + +#[allow(dead_code)] +impl ChiselSession { + pub fn new(name: &str, flags: &str, init: bool) -> Self { + let project = foundry_test_utils::TestProject::new(name, PathStyle::Dapptools); + if init { + foundry_test_utils::util::initialize(project.root()); + project.initialize_default_contracts(); + } + + let bin = env!("CARGO_BIN_EXE_chisel"); + let mut command = std::process::Command::new(bin); + + // Keep terminal output readable and use a non-interactive editor for `!edit`. + command.current_dir(project.root()); + command.env("NO_COLOR", "1"); + command.env("TERM", "dumb"); + command.env("EDITOR", "true"); + + command.env("ETHERSCAN_API_KEY", foundry_test_utils::rpc::next_etherscan_api_key()); + + if !flags.is_empty() { + command.args(flags.split_whitespace()); + } + let args = command.get_args().map(|s| s.to_str().unwrap().to_string()).collect::>(); + + let session = spawn_with_options( + command, + Options { + timeout_ms: Some(TIMEOUT_SECS * 1000), + strip_ansi_escape_codes: false, + encoding: rexpect::Encoding::UTF8, + }, + ) + .unwrap(); + + let is_repl = is_repl(&args); + let mut session = + Self { session: Box::new(session), project: Box::new(project), is_repl, output: None }; + + // Expect initial prompt only if we're in the REPL. + if session.is_repl() { + session.expect("Welcome to Chisel!"); + session.expect_prompt(); + } + + session + } + + pub fn project(&self) -> &TestProject { + &self.project + } + + pub const fn is_repl(&self) -> bool { + self.is_repl + } + + /// Send one complete REPL input and wait for its next prompt. + /// + /// Retain the command's output for subsequent `expect` calls. Send multiple independent + /// inputs separately, or use `sendln_raw` and explicitly consume their prompts. + #[track_caller] + pub fn sendln(&mut self, line: &str) { + self.sendln_raw(line); + if self.is_repl() { + self.output = Some(self.read_until_prompt()); + } + } + + /// Send a line to the REPL without expecting the prompt to appear. + /// + /// You might want to call `expect_prompt` after this. + #[track_caller] + pub fn sendln_raw(&mut self, line: &str) { + self.output = None; + match self.session.send_line(line) { + Ok(_) => (), + Err(e) => { + panic!("failed to send line {line:?}: {e}") + } + } + } + + /// Expect the needle to appear. + #[track_caller] + pub fn expect(&mut self, needle: &str) { + if let Some(output) = &mut self.output { + let Some(pos) = output.find(needle) else { + panic!("expected {needle:?} in completed command output: {output:?}"); + }; + output.drain(..pos + needle.len()); + return; + } + match self.session.exp_string(needle) { + Ok(_) => (), + Err(e) => { + panic!("failed to expect {needle:?}: {e}") + } + } + } + + /// Expect the prompt to appear. + #[track_caller] + pub fn expect_prompt(&mut self) { + self.read_until_prompt(); + } + + #[track_caller] + fn read_until_prompt(&mut self) -> String { + self.session.exp_string(PROMPT).unwrap_or_else(|e| panic!("failed to expect prompt: {e}")) + } + + /// Expect the prompt to appear `n` times. + #[track_caller] + pub fn expect_prompts(&mut self, n: usize) { + for _ in 0..n { + self.expect_prompt(); + } + } +} diff --git a/crates/chisel/tests/it/session_cache.rs b/crates/chisel/tests/it/session_cache.rs new file mode 100644 index 0000000000000..f886d1db210cc --- /dev/null +++ b/crates/chisel/tests/it/session_cache.rs @@ -0,0 +1,110 @@ +use chisel::session::ChiselSession; +use foundry_evm::core::evm::EthEvmNetwork; +use foundry_test_utils::{snapbox, str}; +use std::{fs, path::Path}; + +use std::os::unix::fs::PermissionsExt; + +fn command(home: &Path) -> snapbox::cmd::Command { + snapbox::cmd::Command::new(env!("CARGO_BIN_EXE_chisel")) + .env_clear() + .env("HOME", home) + .env("NO_COLOR", "1") + .env("TERM", "dumb") + .current_dir(home) + .args(["--offline", "--no-vm"]) +} + +#[test] +fn save_omits_environment_and_config_credentials() { + let home = tempfile::tempdir().unwrap(); + fs::write( + home.path().join("foundry.toml"), + r#" +[rpc_endpoints] +private = { endpoint = "https://rpc.invalid/synthetic-token", auth = "Bearer synthetic-auth" } +[etherscan] +mainnet = { key = "synthetic-explorer-key", chain = 1 } +"#, + ) + .unwrap(); + + command(home.path()) + .args(["--use", "0.8.29"]) + .env("ETHERSCAN_API_KEY", "synthetic-api-key") + .env("FOUNDRY_ETH_RPC_JWT", "synthetic-jwt") + .env("FOUNDRY_ETH_RPC_HEADERS", r#"["Authorization: Bearer synthetic-header"]"#) + .args(["eval", "!save credentials"]) + .assert() + .success() + .stdout_eq(str![[r#" +Saved session to cache with ID = credentials + +"#]]) + .stderr_eq(str![""]); + + let cache = home.path().join(".foundry/cache/chisel"); + let path = cache.join("chisel-credentials.json"); + let session: ChiselSession = + serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + let config = session.source.config; + assert_eq!(config.foundry_config.etherscan_api_key, None); + assert_eq!(config.foundry_config.eth_rpc_jwt, None); + assert_eq!(config.foundry_config.eth_rpc_headers, None); + assert!(config.foundry_config.rpc_endpoints.is_empty()); + assert!(config.foundry_config.etherscan.is_empty()); + assert_eq!(config.evm_opts.rpc_jwt, None); + assert_eq!(config.evm_opts.rpc_headers, None); + assert_eq!(fs::metadata(cache).unwrap().permissions().mode() & 0o777, 0o700); + assert_eq!(fs::metadata(path).unwrap().permissions().mode() & 0o777, 0o600); +} + +#[test] +fn view_saved_fork_source_without_endpoint_or_compiler() { + let home = tempfile::tempdir().unwrap(); + fs::create_dir(home.path().join(".svm")).unwrap(); + command(home.path()) + .args(["--use", "0.8.29", "--network", "tempo", "eval", "!save forked"]) + .assert() + .success() + .stdout_eq(str![[r#" +Saved session to cache with ID = forked + +"#]]) + .stderr_eq(str![""]); + let path = home.path().join(".foundry/cache/chisel/chisel-forked.json"); + let mut session: ChiselSession = + serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); + session.source.config.evm_opts.fork_url = Some("https://rpc.invalid/synthetic-token".into()); + session.source.run_code = "uint256 privateValue = 42;".into(); + fs::write(&path, serde_json::to_vec(&session).unwrap()).unwrap(); + fs::write(home.path().join("prelude.sol"), "invalid Solidity").unwrap(); + + for config in + ["[profile.default]\neth_rpc_url = \"http://127.0.0.1:0\"\n", "[profile.default]\n"] + { + fs::write(home.path().join("foundry.toml"), config).unwrap(); + for id in ["forked", "latest"] { + command(home.path()) + .args(["--prelude", "prelude.sol", "view", id]) + .assert() + .success() + .stdout_eq(str![[r#" +Loaded Chisel session! (ID = forked) +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0; + +contract REPL { + /// @notice REPL contract entry point + function run() public { + uint256 privateValue = 42; + } +} + + +"#]]) + .stderr_eq(str![""]); + } + } + assert_eq!(fs::read_dir(home.path().join(".svm")).unwrap().count(), 0); +} diff --git a/crates/cli-markdown/Cargo.toml b/crates/cli-markdown/Cargo.toml new file mode 100644 index 0000000000000..7f039685d2cf1 --- /dev/null +++ b/crates/cli-markdown/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "foundry-cli-markdown" +description = "Generate Markdown documentation for clap CLIs" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +clap = { version = "4", features = ["env"] } + +[dev-dependencies] +clap = { version = "4", features = ["derive"] } +pretty_assertions = "1" diff --git a/crates/cli-markdown/src/lib.rs b/crates/cli-markdown/src/lib.rs new file mode 100644 index 0000000000000..7702fcf15b9a7 --- /dev/null +++ b/crates/cli-markdown/src/lib.rs @@ -0,0 +1,582 @@ +//! Generate Markdown documentation for clap command-line tools. +//! +//! This is a fork of [`clap-markdown`](https://crates.io/crates/clap-markdown) with the following +//! enhancements: +//! - Support for grouped options by help heading ([PR #48](https://github.com/ConnorGray/clap-markdown/pull/48)) +//! - Show environment variable names for arguments ([PR #50](https://github.com/ConnorGray/clap-markdown/pull/50)) +//! - Add version information to generated Markdown ([PR #52](https://github.com/ConnorGray/clap-markdown/pull/52)) + +use std::{ + collections::BTreeMap, + fmt::{self, Write}, +}; + +use clap::builder::PossibleValue; + +/// Options to customize the structure of the output Markdown document. +#[non_exhaustive] +pub struct MarkdownOptions { + title: Option, + show_footer: bool, + show_table_of_contents: bool, + show_aliases: bool, +} + +impl MarkdownOptions { + /// Construct a default instance of `MarkdownOptions`. + pub const fn new() -> Self { + Self { title: None, show_footer: true, show_table_of_contents: true, show_aliases: true } + } + + /// Set a custom title to use in the generated document. + pub fn title(mut self, title: String) -> Self { + self.title = Some(title); + self + } + + /// Whether to show the default footer advertising `clap-markdown`. + pub const fn show_footer(mut self, show: bool) -> Self { + self.show_footer = show; + self + } + + /// Whether to show the default table of contents. + pub const fn show_table_of_contents(mut self, show: bool) -> Self { + self.show_table_of_contents = show; + self + } + + /// Whether to show aliases for arguments and commands. + pub const fn show_aliases(mut self, show: bool) -> Self { + self.show_aliases = show; + self + } +} + +impl Default for MarkdownOptions { + fn default() -> Self { + Self::new() + } +} + +/// Format the help information for `command` as Markdown. +pub fn help_markdown() -> String { + let command = C::command(); + help_markdown_command(&command) +} + +/// Format the help information for `command` as Markdown, with custom options. +pub fn help_markdown_custom(options: &MarkdownOptions) -> String { + let command = C::command(); + help_markdown_command_custom(&command, options) +} + +/// Format the help information for `command` as Markdown. +pub fn help_markdown_command(command: &clap::Command) -> String { + help_markdown_command_custom(command, &Default::default()) +} + +/// Format the help information for `command` as Markdown, with custom options. +pub fn help_markdown_command_custom(command: &clap::Command, options: &MarkdownOptions) -> String { + let mut buffer = String::with_capacity(100); + write_help_markdown(&mut buffer, command, options); + buffer +} + +/// Format the help information for `command` as Markdown and print it. +/// +/// Output is printed to the standard output. +#[allow(clippy::disallowed_macros)] +pub fn print_help_markdown() { + let command = C::command(); + let mut buffer = String::with_capacity(100); + write_help_markdown(&mut buffer, &command, &Default::default()); + println!("{buffer}"); +} + +fn write_help_markdown(buffer: &mut String, command: &clap::Command, options: &MarkdownOptions) { + let title_name = get_canonical_name(command); + + let title = match options.title { + Some(ref title) => title.to_owned(), + None => format!("Command-Line Help for `{title_name}`"), + }; + writeln!(buffer, "# {title}\n",).unwrap(); + + writeln!( + buffer, + "This document contains the help content for the `{title_name}` command-line program.\n", + ) + .unwrap(); + + // Write the version if available (PR #52) + if let Some(version) = command.get_version() { + let version_str = version.to_string(); + + if version_str.contains('\n') { + // Multi-line version: use a code block + writeln!(buffer, "**Version:**\n\n```\n{}\n```\n", version_str.trim()).unwrap(); + } else { + // Single-line version: use inline code + writeln!(buffer, "**Version:** `{version_str}`\n").unwrap(); + } + } + + // Write the table of contents + if options.show_table_of_contents { + writeln!(buffer, "**Command Overview:**\n").unwrap(); + build_table_of_contents_markdown(buffer, Vec::new(), command, 0).unwrap(); + writeln!(buffer).unwrap(); + } + + // Write the commands/subcommands sections + build_command_markdown(buffer, Vec::new(), command, 0, options).unwrap(); + + // Write the footer + if options.show_footer { + write!( + buffer, + r#"
+ + + This document was generated automatically by + clap-markdown. + +"# + ) + .unwrap(); + } +} + +fn build_table_of_contents_markdown( + buffer: &mut String, + parent_command_path: Vec, + command: &clap::Command, + _depth: usize, +) -> std::fmt::Result { + // Don't document commands marked with `clap(hide = true)` + if command.is_hide_set() { + return Ok(()); + } + + let title_name = get_canonical_name(command); + + let command_path = { + let mut command_path = parent_command_path; + command_path.push(title_name); + command_path + }; + + writeln!(buffer, "* [`{}`↴](#{})", command_path.join(" "), command_path.join("-"),)?; + + for subcommand in command.get_subcommands() { + build_table_of_contents_markdown(buffer, command_path.clone(), subcommand, _depth + 1)?; + } + + Ok(()) +} + +fn build_command_markdown( + buffer: &mut String, + parent_command_path: Vec, + command: &clap::Command, + _depth: usize, + options: &MarkdownOptions, +) -> std::fmt::Result { + // Don't document commands marked with `clap(hide = true)` + if command.is_hide_set() { + return Ok(()); + } + + let title_name = get_canonical_name(command); + + let command_path = { + let mut command_path = parent_command_path.clone(); + command_path.push(title_name); + command_path + }; + + // Write the markdown heading + writeln!(buffer, "## `{}`\n", command_path.join(" "))?; + + if let Some(long_about) = command.get_long_about() { + writeln!(buffer, "{long_about}\n")?; + } else if let Some(about) = command.get_about() { + writeln!(buffer, "{about}\n")?; + } + + if let Some(help) = command.get_before_long_help() { + writeln!(buffer, "{help}\n")?; + } else if let Some(help) = command.get_before_help() { + writeln!(buffer, "{help}\n")?; + } + + writeln!( + buffer, + "**Usage:** `{}{}`\n", + if parent_command_path.is_empty() { + String::new() + } else { + let mut s = parent_command_path.join(" "); + s.push(' '); + s + }, + command.clone().render_usage().to_string().replace("Usage: ", "") + )?; + + if options.show_aliases { + let aliases = command.get_visible_aliases().collect::>(); + if let Some(aliases_str) = get_alias_string(&aliases) { + writeln!( + buffer, + "**{}:** {aliases_str}\n", + pluralize(aliases.len(), "Command Alias", "Command Aliases") + )?; + } + } + + if let Some(help) = command.get_after_long_help() { + writeln!(buffer, "{help}\n")?; + } else if let Some(help) = command.get_after_help() { + writeln!(buffer, "{help}\n")?; + } + + // Subcommands + if command.get_subcommands().next().is_some() { + writeln!(buffer, "###### **Subcommands:**\n")?; + + for subcommand in command.get_subcommands() { + if subcommand.is_hide_set() { + continue; + } + + let title_name = get_canonical_name(subcommand); + let about = match subcommand.get_about() { + Some(about) => about.to_string(), + None => String::new(), + }; + + writeln!(buffer, "* `{title_name}` — {about}",)?; + } + + writeln!(buffer)?; + } + + // Arguments (positional) + if command.get_positionals().next().is_some() { + writeln!(buffer, "###### **Arguments:**\n")?; + + for pos_arg in command.get_positionals() { + write_arg_markdown(buffer, pos_arg)?; + } + + writeln!(buffer)?; + } + + // Options (grouped by help heading) - PR #48 + let non_pos: Vec<_> = + command.get_arguments().filter(|arg| !arg.is_positional() && !arg.is_hide_set()).collect(); + + if !non_pos.is_empty() { + // Group arguments by help heading + let mut grouped_args: BTreeMap<&str, Vec<&clap::Arg>> = BTreeMap::new(); + + for arg in non_pos { + let heading = arg.get_help_heading().unwrap_or("Options"); + grouped_args.entry(heading).or_default().push(arg); + } + + // Write each group with its heading + for (heading, args) in grouped_args { + writeln!(buffer, "###### **{heading}:**\n")?; + + for arg in args { + write_arg_markdown(buffer, arg)?; + } + + writeln!(buffer)?; + } + } + + // Include extra space between commands + write!(buffer, "\n\n")?; + + for subcommand in command.get_subcommands() { + build_command_markdown(buffer, command_path.clone(), subcommand, _depth + 1, options)?; + } + + Ok(()) +} + +fn write_arg_markdown(buffer: &mut String, arg: &clap::Arg) -> fmt::Result { + // Markdown list item + write!(buffer, "* ")?; + + let value_name: String = match arg.get_value_names() { + Some([name, ..]) => name.as_str().to_owned(), + Some([]) => unreachable!("clap Arg::get_value_names() returned Some(..) of empty list"), + None => arg.get_id().to_string().to_ascii_uppercase(), + }; + + match (arg.get_short(), arg.get_long()) { + (Some(short), Some(long)) => { + if arg.get_action().takes_values() { + write!(buffer, "`-{short}`, `--{long} <{value_name}>`")? + } else { + write!(buffer, "`-{short}`, `--{long}`")? + } + } + (Some(short), None) => { + if arg.get_action().takes_values() { + write!(buffer, "`-{short} <{value_name}>`")? + } else { + write!(buffer, "`-{short}`")? + } + } + (None, Some(long)) => { + if arg.get_action().takes_values() { + write!(buffer, "`--{long} <{value_name}>`")? + } else { + write!(buffer, "`--{long}`")? + } + } + (None, None) => { + debug_assert!( + arg.is_positional(), + "unexpected non-positional Arg with neither short nor long name: {arg:?}" + ); + write!(buffer, "`<{value_name}>`",)?; + } + } + + if let Some(aliases) = arg.get_visible_aliases().as_deref() + && let Some(aliases_str) = get_alias_string(aliases) + { + write!(buffer, " [{}: {aliases_str}]", pluralize(aliases.len(), "alias", "aliases"))?; + } + + if let Some(help) = arg.get_long_help() { + buffer.push_str(&indent(&help.to_string(), " — ", " ")) + } else if let Some(short_help) = arg.get_help() { + writeln!(buffer, " — {short_help}")?; + } else { + writeln!(buffer)?; + } + + // Arg default values + if !arg.get_default_values().is_empty() { + let default_values: String = arg + .get_default_values() + .iter() + .map(|value| format!("`{}`", value.to_string_lossy())) + .collect::>() + .join(", "); + + if arg.get_default_values().len() > 1 { + writeln!(buffer, "\n Default values: {default_values}")?; + } else { + writeln!(buffer, "\n Default value: {default_values}")?; + } + } + + // Arg possible values + let possible_values: Vec = + arg.get_possible_values().into_iter().filter(|pv| !pv.is_hide_set()).collect(); + + if !possible_values.is_empty() && !matches!(arg.get_action(), clap::ArgAction::SetTrue) { + let any_have_help: bool = possible_values.iter().any(|pv| pv.get_help().is_some()); + + if any_have_help { + let text: String = possible_values + .iter() + .map(|pv| match pv.get_help() { + Some(help) => { + format!(" - `{}`:\n {}\n", pv.get_name(), help) + } + None => format!(" - `{}`\n", pv.get_name()), + }) + .collect::>() + .join(""); + + writeln!(buffer, "\n Possible values:\n{text}")?; + } else { + let text: String = possible_values + .iter() + .map(|pv| format!("`{}`", pv.get_name())) + .collect::>() + .join(", "); + + writeln!(buffer, "\n Possible values: {text}\n")?; + } + } + + // Arg environment variable (PR #50) + if !arg.is_hide_env_set() + && let Some(env) = arg.get_env() + { + writeln!(buffer, "\n Environment variable: `{}`", env.to_string_lossy())?; + } + + Ok(()) +} + +/// Utility function to get the canonical name of a command. +fn get_canonical_name(command: &clap::Command) -> String { + command + .get_display_name() + .or_else(|| command.get_bin_name()) + .map(|name| name.to_owned()) + .unwrap_or_else(|| command.get_name().to_owned()) +} + +/// Indents non-empty lines. The output always ends with a newline. +fn indent(s: &str, first: &str, rest: &str) -> String { + if s.is_empty() { + return "\n".to_string(); + } + let mut result = String::new(); + let mut first_line = true; + + for line in s.lines() { + if !line.is_empty() { + result.push_str(if first_line { first } else { rest }); + result.push_str(line); + first_line = false; + } + result.push('\n'); + } + result +} + +fn get_alias_string(aliases: &[&str]) -> Option { + if aliases.is_empty() { + return None; + } + + Some(aliases.iter().map(|alias| format!("`{alias}`")).collect::>().join(", ")) +} + +const fn pluralize<'a>(count: usize, singular: &'a str, plural: &'a str) -> &'a str { + if count == 1 { singular } else { plural } +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::{Arg, Command}; + use pretty_assertions::assert_eq; + + #[test] + fn test_indent() { + assert_eq!(&indent("Header\n\nMore info", "___", "~~~~"), "___Header\n\n~~~~More info\n"); + assert_eq!( + &indent("Header\n\nMore info\n", "___", "~~~~"), + &indent("Header\n\nMore info", "___", "~~~~"), + ); + assert_eq!(&indent("", "___", "~~~~"), "\n"); + assert_eq!(&indent("\n", "___", "~~~~"), "\n"); + } + + #[test] + fn test_version_output() { + let app = Command::new("test-app").version("1.2.3").about("A test application"); + + let markdown = + help_markdown_command_custom(&app, &MarkdownOptions::new().show_footer(false)); + + assert!(markdown.contains("**Version:** `1.2.3`"), "Should contain version"); + } + + #[test] + fn test_multiline_version() { + let multi_line_version = "my-cli 1.2.3 (abc123)\nmy-lib 2.0.0 (789xyz)"; + + let app = Command::new("my-cli").version(multi_line_version).about("Multi-version CLI"); + + let markdown = + help_markdown_command_custom(&app, &MarkdownOptions::new().show_footer(false)); + + assert!(markdown.contains("**Version:**\n\n```"), "Should use code block for multi-line"); + } + + #[test] + fn test_env_var_output() { + let app = Command::new("env-test").about("Test env var output").arg( + Arg::new("config") + .short('c') + .long("config") + .env("CONFIG_PATH") + .help("Path to config file"), + ); + + let markdown = + help_markdown_command_custom(&app, &MarkdownOptions::new().show_footer(false)); + + assert!( + markdown.contains("Environment variable: `CONFIG_PATH`"), + "Should show env var. Output: {markdown}" + ); + } + + #[test] + fn test_grouped_options() { + let app = Command::new("grouped-app") + .about("Test app with grouped options") + .arg( + Arg::new("verbose") + .short('v') + .long("verbose") + .help("Enable verbose output") + .help_heading("General Options") + .action(clap::ArgAction::SetTrue), + ) + .arg( + Arg::new("input") + .short('i') + .long("input") + .help("Input file") + .help_heading("File Options") + .value_name("FILE"), + ) + .arg( + Arg::new("format") + .short('f') + .long("format") + .help("Output format") + .value_name("FORMAT"), + ); + + let markdown = + help_markdown_command_custom(&app, &MarkdownOptions::new().show_footer(false)); + + assert!(markdown.contains("###### **File Options:**"), "Should have File Options heading"); + assert!( + markdown.contains("###### **General Options:**"), + "Should have General Options heading" + ); + assert!(markdown.contains("###### **Options:**"), "Should have default Options heading"); + } + + #[test] + fn test_no_grouped_options_backward_compatibility() { + let app = Command::new("simple-app") + .about("Test app without grouped options") + .arg( + Arg::new("verbose") + .short('v') + .long("verbose") + .help("Enable verbose output") + .action(clap::ArgAction::SetTrue), + ) + .arg( + Arg::new("output").short('o').long("output").help("Output file").value_name("FILE"), + ); + + let markdown = + help_markdown_command_custom(&app, &MarkdownOptions::new().show_footer(false)); + + assert!(markdown.contains("###### **Options:**"), "Should have default Options heading"); + assert!(markdown.contains("`-v`, `--verbose`"), "Should have verbose option"); + assert!(markdown.contains("`-o`, `--output `"), "Should have output option"); + } +} diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 0bff1f1f0461c..8e00d67e0630b 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -13,19 +13,22 @@ repository.workspace = true workspace = true [dependencies] -forge-fmt.workspace = true +foundry-cli-markdown.workspace = true foundry-common.workspace = true foundry-config.workspace = true -foundry-debugger.workspace = true foundry-evm.workspace = true -foundry-wallets.workspace = true -foundry-block-explorers.workspace = true +foundry-evm-networks.workspace = true +foundry-wallets = { workspace = true, features = ["browser", "tempo"] } -foundry-compilers = { workspace = true, features = ["full"] } -solar-sema.workspace = true +tempo-primitives.workspace = true + +foundry-compilers.workspace = true +solar.workspace = true alloy-eips.workspace = true alloy-dyn-abi.workspace = true +alloy-network.workspace = true +alloy-signer.workspace = true alloy-json-abi.workspace = true alloy-primitives.workspace = true alloy-provider.workspace = true @@ -35,6 +38,8 @@ alloy-ens = { workspace = true, features = ["provider"] } cfg-if = "1.0" clap = { version = "4", features = ["derive", "env", "unicode", "wrap_help"] } +clap_complete.workspace = true +clap_complete_nushell.workspace = true color-eyre.workspace = true dotenvy = "0.15" eyre.workspace = true @@ -42,21 +47,22 @@ futures.workspace = true indicatif.workspace = true itertools.workspace = true mimalloc = { workspace = true, optional = true } +path-slash.workspace = true rayon.workspace = true regex = { workspace = true, default-features = false } serde_json.workspace = true serde.workspace = true +semver.workspace = true +soldeer-commands.workspace = true +toml.workspace = true strsim = "0.11" -strum = { workspace = true, features = ["derive"] } tokio = { workspace = true, features = ["macros"] } tracing-subscriber = { workspace = true, features = ["registry", "env-filter"] } tracing.workspace = true -tracy-client = { workspace = true, optional = true, features = ["demangle"] } yansi.workspace = true rustls = { workspace = true, features = ["ring"] } -dunce.workspace = true -tracing-tracy = { version = "0.11", optional = true } +tracing-tracy = { version = "0.12", optional = true, features = ["demangle"] } [dev-dependencies] tempfile.workspace = true @@ -65,7 +71,25 @@ tempfile.workspace = true tikv-jemallocator = { workspace = true, optional = true } [features] +default = ["optimism"] tracy = ["dep:tracing-tracy"] -tracy-allocator = ["dep:tracy-client", "tracy"] +tracy-allocator = ["tracy"] jemalloc = ["dep:tikv-jemallocator"] mimalloc = ["dep:mimalloc"] +monad = [ + "foundry-common/monad", + "foundry-config/monad", + "foundry-evm-networks/monad", + "foundry-evm/monad", +] +base = [ + "foundry-config/base", + "foundry-evm-networks/base", + "foundry-common/base", + "foundry-evm/base", +] +optimism = [ + "foundry-evm-networks/optimism", + "foundry-common/optimism", + "foundry-evm/optimism", +] diff --git a/crates/cli/src/clap.rs b/crates/cli/src/clap.rs new file mode 100644 index 0000000000000..f7f2a25420976 --- /dev/null +++ b/crates/cli/src/clap.rs @@ -0,0 +1,44 @@ +use clap_complete::{Shell as ClapCompleteShell, aot::Generator}; +use clap_complete_nushell::Nushell; + +#[derive(Clone, Copy)] +pub enum Shell { + ClapCompleteShell(ClapCompleteShell), + Nushell, +} + +impl clap::ValueEnum for Shell { + fn value_variants<'a>() -> &'a [Self] { + &[ + Self::ClapCompleteShell(ClapCompleteShell::Bash), + Self::ClapCompleteShell(ClapCompleteShell::Zsh), + Self::ClapCompleteShell(ClapCompleteShell::Fish), + Self::ClapCompleteShell(ClapCompleteShell::PowerShell), + Self::ClapCompleteShell(ClapCompleteShell::Elvish), + Self::Nushell, + ] + } + + fn to_possible_value(&self) -> Option { + match self { + Self::ClapCompleteShell(shell) => shell.to_possible_value(), + Self::Nushell => Some(clap::builder::PossibleValue::new("nushell")), + } + } +} + +impl Generator for Shell { + fn file_name(&self, name: &str) -> String { + match self { + Self::ClapCompleteShell(shell) => shell.file_name(name), + Self::Nushell => Nushell.file_name(name), + } + } + + fn generate(&self, cmd: &clap::Command, buf: &mut dyn std::io::Write) { + match self { + Self::ClapCompleteShell(shell) => shell.generate(cmd, buf), + Self::Nushell => Nushell.generate(cmd, buf), + } + } +} diff --git a/crates/cli/src/handler.rs b/crates/cli/src/handler.rs index 30a3d9ec342ab..763466d21185d 100644 --- a/crates/cli/src/handler.rs +++ b/crates/cli/src/handler.rs @@ -1,22 +1,16 @@ use eyre::EyreHandler; -use itertools::Itertools; use std::{error::Error, fmt}; /// A custom context type for Foundry specific error reporting via `eyre`. +#[derive(Default)] pub struct Handler { debug_handler: Option>, } -impl Default for Handler { - fn default() -> Self { - Self::new() - } -} - impl Handler { /// Create a new instance of the `Handler`. pub fn new() -> Self { - Self { debug_handler: None } + Self::default() } /// Override the debug handler with a custom one. @@ -28,8 +22,7 @@ impl Handler { impl EyreHandler for Handler { fn display(&self, error: &(dyn Error + 'static), f: &mut fmt::Formatter<'_>) -> fmt::Result { - use fmt::Display; - foundry_common::errors::dedup_chain(error).into_iter().format("; ").fmt(f) + f.write_str(&foundry_common::errors::display_chain(error)) } fn debug(&self, error: &(dyn Error + 'static), f: &mut fmt::Formatter<'_>) -> fmt::Result { diff --git a/crates/cli/src/install.rs b/crates/cli/src/install.rs new file mode 100644 index 0000000000000..3c18d07e42ac1 --- /dev/null +++ b/crates/cli/src/install.rs @@ -0,0 +1,896 @@ +//! Dependency installation shared by Forge commands. + +use crate::{ + lockfile::{DepIdentifier, DepMap, FOUNDRY_LOCK, Lockfile}, + opts::Dependency, + utils::{Git, LoadConfig}, +}; +use clap::{Parser, ValueHint}; +use eyre::{Context, Result}; +use foundry_common::fs; +use foundry_config::{Config, impl_figment_convert_basic}; +use regex::Regex; +use semver::Version; +use soldeer_commands::{Command, Verbosity, commands::install::Install}; +use std::{ + io::{ErrorKind, IsTerminal}, + path::{Path, PathBuf}, + str, + sync::LazyLock, +}; +use yansi::Paint; + +static DEPENDENCY_VERSION_TAG_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r"^v?\d+(\.\d+)*$").unwrap()); + +/// CLI arguments for `forge install`. +#[derive(Clone, Debug, Parser)] +#[command(override_usage = "forge install [OPTIONS] [DEPENDENCIES]... + forge install [OPTIONS] /@... + forge install [OPTIONS] =/@... + forge install [OPTIONS] @git url>...)] + forge install [OPTIONS] ...")] +pub struct InstallArgs { + /// The dependencies to install. + /// + /// A dependency can be a raw URL, or the path to a GitHub repository. + /// + /// Additionally, a ref can be provided by adding @ to the dependency path. + /// + /// A ref can be: + /// - A branch: master + /// - A tag: v1.2.3 + /// - A commit: 8e8128 + /// + /// For exact match, a ref can be provided with `@tag=`, `@branch=` or `@rev=` prefix. + /// + /// Target installation directory can be added via `=` suffix. + /// The dependency will installed to `lib/`. + dependencies: Vec, + + /// The project's root path. + /// + /// By default root of the Git repository, if in one, + /// or the current working directory. + #[arg(long, value_hint = ValueHint::DirPath, value_name = "PATH")] + pub root: Option, + + /// Do not create a commit after installing. + /// + /// This is a noop flag kept for backwards compatibility, as `forge install` no longer commits + /// by default. Use `--commit` to opt into creating a commit. + #[arg(long, hide = true)] + pub no_commit: bool, + + #[command(flatten)] + opts: DependencyInstallOpts, +} + +impl_figment_convert_basic!(InstallArgs); + +impl InstallArgs { + pub async fn run(mut self) -> Result<()> { + if self.root.is_none() { + self.root = std::env::current_dir()? + .ancestors() + .find(|root| root.join(Config::FILE_NAME).is_file()) + .map(Path::to_path_buf); + } + let mut config = self.load_config()?; + self.opts.install(&mut config, self.dependencies).await + } +} + +#[derive(Clone, Copy, Debug, Default, Parser)] +pub struct DependencyInstallOpts { + /// Perform shallow clones instead of deep ones. + /// + /// Improves performance and reduces disk usage, but prevents switching branches or tags. + #[arg(long)] + pub shallow: bool, + + /// Install without adding the dependency as a submodule. + #[arg(long)] + pub no_git: bool, + + /// Create a commit after installing the dependencies. + #[arg(long)] + pub commit: bool, +} + +impl DependencyInstallOpts { + pub fn git(self, config: &Config) -> Git<'_> { + Git::from_config(config).shallow(self.shallow) + } + + /// Installs all missing dependencies. + /// + /// See also [`Self::install`]. + /// + /// Returns true if any dependency was installed. + pub fn install_missing_dependencies(self, config: &mut Config) -> bool { + let lib = config.install_lib_dir(); + if self.git(config).has_missing_dependencies(Some(lib)).unwrap_or(false) { + let _ = sh_status!("Missing dependencies found. Installing now..."); + + if self.install_existing_dependencies(config).is_err() { + let _ = + sh_warn!("Your project has missing dependencies that could not be installed."); + } + true + } else { + false + } + } + + /// Restores existing dependencies without running asynchronous package installation. + fn install_existing_dependencies(self, config: &mut Config) -> Result<()> { + let git = self.git(config); + let install_lib_dir = config.install_lib_dir(); + let libs = git.root.join(install_lib_dir); + let (lockfile, out_of_sync_deps) = self.sync_lockfile(config, &git)?; + + if !self.no_git { + // Use the root of the git repository to look for submodules. + let root = Git::root_of(git.root)?; + match git.has_submodules(Some(&root)) { + Ok(true) => { + sh_status!("Updating dependencies in {}", libs.display())?; + + // recursively fetch all submodules (without fetching latest) + git.submodule_update(false, false, false, true, Some(&libs))?; + + // checkout submodules at the revs recorded in `foundry.lock` + if let Some(out_of_sync) = &out_of_sync_deps { + for (rel_path, dep_id) in out_of_sync { + git.checkout_at(dep_id.checkout_id(), &git.root.join(rel_path))?; + } + } + + lockfile.write()?; + } + Err(err) => { + sh_err!("Failed to check for submodules: {err}")?; + } + _ => { + // no submodules, nothing to do + } + } + } + + fs::create_dir_all(&libs)?; + + // update `libs` in config if not included yet + if !config.libs.iter().any(|p| p == install_lib_dir) { + config.libs.push(install_lib_dir.to_path_buf()); + config.update_libs()?; + } + + Ok(()) + } + + fn sync_lockfile<'a>( + self, + config: &Config, + git: &'a Git<'_>, + ) -> Result<(Lockfile<'a>, Option)> { + let libs = git.root.join(config.install_lib_dir()); + let mut lockfile = Lockfile::new(&config.root); + if !self.no_git { + lockfile = lockfile.with_git(git); + + // Check if submodules are uninitialized, if so, we need to fetch all submodules + // This is to ensure that foundry.lock syncs successfully and doesn't error out, when + // looking for commits/tags in submodules + if git.submodules_uninitialized()? { + trace!(lib = %libs.display(), "submodules uninitialized"); + git.submodule_update(false, false, false, true, Some(&libs))?; + } + } + + let out_of_sync_deps = lockfile.sync(config.install_lib_dir())?; + + Ok((lockfile, out_of_sync_deps)) + } + + /// Installs all dependencies + pub async fn install(self, config: &mut Config, dependencies: Vec) -> Result<()> { + if dependencies.is_empty() { + return self.install_existing_dependencies(config); + } + + let Self { no_git, commit, .. } = self; + + let git = self.git(config); + + let install_lib_dir = config.install_lib_dir(); + let libs = git.root.join(install_lib_dir); + + let (mut lockfile, out_of_sync_deps) = self.sync_lockfile(config, &git)?; + + fs::create_dir_all(&libs)?; + + let installer = Installer { git, commit }; + for dep in dependencies { + if dep + .name() + .split(['/', '\\']) + .any(|component| component.is_empty() || matches!(component, "." | "..")) + { + eyre::bail!("invalid dependency name: {}", dep.name()); + } + let path = libs.join(dep.name()); + let rel_path = path + .strip_prefix(git.root) + .wrap_err("Library directory is not relative to the repository root")?; + sh_status!( + "Installing {} in {} (url: {}, tag: {})", + dep.name, + path.display(), + dep.url.as_deref().unwrap_or("None"), + dep.tag.as_deref().unwrap_or("None") + )?; + + // this tracks the actual installed tag + let installed_tag; + let mut dep_id = None; + if no_git { + installed_tag = installer.install_as_folder(&dep, &path)?; + } else { + if commit { + git.ensure_clean()?; + } + installed_tag = installer.install_as_submodule(&dep, &path)?; + + let mut new_insertion = false; + // Pin branch to submodule if branch is used + if let Some(tag_or_branch) = &installed_tag { + // First, check if this tag has a branch + dep_id = Some(DepIdentifier::resolve_type(&git, &path, tag_or_branch)?); + if git.has_branch(tag_or_branch, &path)? + && dep_id.as_ref().is_some_and(|id| id.is_branch()) + { + // always work with relative paths when directly modifying submodules + git.set_submodule_branch(rel_path, tag_or_branch)?; + let root = Git::root_of(git.root)?; + git.root(&root).add_literal(Path::new(".gitmodules"))?; + + let rev = git.get_rev(tag_or_branch, &path)?; + + dep_id = Some(DepIdentifier::Branch { + name: tag_or_branch.clone(), + rev, + r#override: false, + }); + } + + trace!(?dep_id, ?tag_or_branch, "resolved dep id"); + if let Some(dep_id) = &dep_id { + new_insertion = true; + lockfile.insert(rel_path.to_path_buf(), dep_id.clone()); + } + + if commit { + // update .gitmodules which is at the root of the repo, + // not necessarily at the root of the current Foundry project + let root = Git::root_of(git.root)?; + git.root(&root).add(Some(".gitmodules"))?; + } + } + + if new_insertion + || out_of_sync_deps.as_ref().is_some_and(|o| !o.is_empty()) + || !lockfile.exists() + { + lockfile.write()?; + } + + // commit the installation + if commit { + let mut msg = String::with_capacity(128); + msg.push_str("forge install: "); + msg.push_str(dep.name()); + + if let Some(tag) = &installed_tag { + msg.push_str("\n\n"); + + if let Some(dep_id) = &dep_id { + msg.push_str(&dep_id.to_string()); + } else { + msg.push_str(tag); + } + } + + if !lockfile.is_empty() { + git.root(&config.root).add(Some(FOUNDRY_LOCK))?; + } + git.commit(&msg)?; + } + } + + let mut msg = format!(" {} {}", "Installed".green(), dep.name); + if let Some(tag) = dep.tag.or(installed_tag) { + msg.push(' '); + + if let Some(dep_id) = dep_id { + msg.push_str(&dep_id.to_string()); + } else { + msg.push_str(tag.as_str()); + } + } + sh_status!("{msg}")?; + + // Check if the dependency has soldeer.lock and install soldeer dependencies + install_soldeer_deps_if_needed(&path).await.map_err(|e| { + eyre::eyre!( + "Failed to install soldeer dependencies for {}: {e}\n\ + Run `forge soldeer install` in {} to retry.", + dep.name, + path.display() + ) + })?; + } + + // update `libs` in config if not included yet + if !config.libs.iter().any(|p| p == install_lib_dir) { + config.libs.push(install_lib_dir.to_path_buf()); + config.update_libs()?; + } + + Ok(()) + } +} + +/// Installs missing dependencies and reloads config only to discover new remappings. +pub fn install_missing_dependencies( + config: &mut Config, + reload: impl FnOnce() -> Result, +) -> Result<(), E> { + if DependencyInstallOpts::default().install_missing_dependencies(config) + && config.auto_detect_remappings + { + *config = reload()?; + } + Ok(()) +} + +/// Checks if a dependency has soldeer.lock and installs soldeer dependencies if needed. +async fn install_soldeer_deps_if_needed(dep_path: &Path) -> Result<()> { + let soldeer_lock = dep_path.join("soldeer.lock"); + + if soldeer_lock.exists() { + sh_status!(" Found soldeer.lock, installing soldeer dependencies...")?; + + // Change to the dependency directory and run soldeer install + let original_dir = std::env::current_dir()?; + std::env::set_current_dir(dep_path)?; + + let result = soldeer_commands::run( + Command::Install(Install::default()), + Verbosity::new( + foundry_common::shell::verbosity(), + if foundry_common::shell::is_quiet() { 1 } else { 0 }, + ), + ) + .await; + + // Change back to original directory + std::env::set_current_dir(original_dir)?; + + result.map_err(|e| eyre::eyre!("Failed to run soldeer install: {e}"))?; + sh_status!(" Soldeer dependencies installed successfully")?; + } + + Ok(()) +} + +#[derive(Clone, Copy, Debug)] +struct Installer<'a> { + git: Git<'a>, + commit: bool, +} + +struct NewSubmoduleGuard { + root: PathBuf, + relative_path: PathBuf, + path: PathBuf, + module_dir: PathBuf, + gitmodules_contents: Option>, + armed: bool, +} + +impl NewSubmoduleGuard { + const fn disarm(&mut self) { + self.armed = false; + } + + fn rollback(&self) { + let git = Git::new(&self.root); + if let Err(err) = git.remove_index_path(&self.relative_path) { + warn!(%err, "failed to remove submodule after installation failure"); + } + if self.path.exists() + && let Err(err) = fs::remove_dir_all(&self.path) + { + warn!(%err, "failed to remove dependency after installation failure"); + } + if self.module_dir.exists() + && let Err(err) = fs::remove_dir_all(&self.module_dir) + { + warn!(%err, "failed to remove submodule Git directory after installation failure"); + } + if let Err(err) = git.remove_submodule_config(&self.relative_path) { + warn!(%err, "failed to remove submodule config after installation failure"); + } + restore_file(&self.root.join(".gitmodules"), self.gitmodules_contents.as_deref()); + if let Err(err) = git.add_literal(Path::new(".gitmodules")) { + warn!(%err, "failed to restore staged .gitmodules after installation failure"); + } + } +} + +impl Drop for NewSubmoduleGuard { + fn drop(&mut self) { + if self.armed { + self.rollback(); + } + } +} + +/// Removes a newly created dependency directory if its installation fails. +struct NewFolderGuard<'a> { + path: &'a Path, + armed: bool, +} + +impl<'a> NewFolderGuard<'a> { + fn new(path: &'a Path) -> Result { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + // Claim the destination before cloning so rollback cannot remove an existing path. + fs::create_dir(path)?; + Ok(Self { path, armed: true }) + } + + const fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for NewFolderGuard<'_> { + fn drop(&mut self) { + if self.armed + && let Err(err) = std::fs::remove_dir_all(self.path) + && err.kind() != ErrorKind::NotFound + { + warn!(%err, path = %self.path.display(), "failed to remove dependency after installation failure"); + } + } +} + +fn restore_file(path: &Path, contents: Option<&[u8]>) { + let result = match contents { + Some(contents) => fs::write(path, contents), + None if path.exists() => fs::remove_file(path), + None => Ok(()), + }; + if let Err(err) = result { + warn!(%err, path = %path.display(), "failed to restore file after installation failure"); + } +} + +impl Installer<'_> { + /// Installs the dependency as an ordinary folder instead of a submodule + fn install_as_folder(self, dep: &Dependency, path: &Path) -> Result> { + let url = dep.require_url()?; + let mut guard = NewFolderGuard::new(path)?; + Git::clone(dep.tag.is_none(), url, Some(&path))?; + let mut dep = dep.clone(); + + if dep.tag.is_none() { + // try to find latest semver release tag + dep.tag = self.last_tag(path); + } + + // checkout the tag if necessary, using recursive checkout to properly clean up + // nested submodules that may exist on the default branch but not on the target tag. + // See: https://github.com/foundry-rs/foundry/issues/13688 + self.git_checkout(&dep, path, true)?; + + trace!("updating dependency submodules recursively"); + self.git.root(path).submodule_update( + false, + false, + false, + true, + std::iter::empty::(), + )?; + + // remove nested .git directories from submodules before removing the top-level .git + Self::remove_nested_git_dirs(path)?; + + // remove git artifacts + fs::remove_dir_all(path.join(".git"))?; + + guard.disarm(); + Ok(dep.tag) + } + + /// Recursively removes `.git` files/directories from nested submodules within `root`. + /// + /// Submodules typically have a `.git` file (not a directory) pointing to the parent's + /// `.git/modules/` directory. This cleans those up so the result is a plain folder tree. + fn remove_nested_git_dirs(root: &Path) -> Result<()> { + Self::remove_nested_git_dirs_inner(root, root) + } + + fn remove_nested_git_dirs_inner(root: &Path, dir: &Path) -> Result<()> { + let entries = match std::fs::read_dir(dir) { + Ok(entries) => entries, + Err(_) => return Ok(()), + }; + for entry in entries { + let entry = entry?; + let ft = entry.file_type()?; + + // never follow symlinks + if ft.is_symlink() { + continue; + } + + let path = entry.path(); + if path.file_name() == Some(".git".as_ref()) && path.parent() != Some(root) { + if ft.is_dir() { + fs::remove_dir_all(&path)?; + } else { + fs::remove_file(&path)?; + } + } else if ft.is_dir() { + Self::remove_nested_git_dirs_inner(root, &path)?; + } + } + Ok(()) + } + + /// Installs the dependency as new submodule. + /// + /// This will add the git submodule to the given dir, initialize it and checkout the tag if + /// provided or try to find the latest semver, release tag. + fn install_as_submodule(self, dep: &Dependency, path: &Path) -> Result> { + let root = Git::root_of(self.git.root)?; + let relative_path = path.strip_prefix(&root)?; + let git = self.git.root(&root); + let gitmodules = root.join(".gitmodules"); + let gitmodules_contents = gitmodules.exists().then(|| fs::read(&gitmodules)).transpose()?; + let has_mapping = git.has_submodule_mapping(relative_path)?; + let is_gitlink = git.is_gitlink(relative_path)?; + if has_mapping != is_gitlink { + eyre::bail!( + "cannot safely install dependency at {} because .gitmodules already contains a matching submodule", + relative_path.display() + ); + } + let mut guard = if is_gitlink { + None + } else { + let module_dir = git.absolute_git_dir()?.join("modules").join(relative_path); + let gitmodules_safe = !gitmodules.is_symlink() + && if gitmodules.exists() { + git.is_normal_tracked_file(Path::new(".gitmodules"))? + } else { + !git.has_index_entries(Path::new(".gitmodules"))? + }; + let can_rollback = !path.is_symlink() + && !path.exists() + && !module_dir.exists() + && !git.has_index_entries(relative_path)? + && !git.has_submodule_config(relative_path)? + && git.is_path_clean(relative_path)? + && gitmodules_safe; + if !can_rollback { + eyre::bail!( + "cannot safely install dependency at {} because the target or .gitmodules has existing changes", + relative_path.display() + ); + } + Some(NewSubmoduleGuard { + root, + relative_path: relative_path.to_path_buf(), + path: path.to_path_buf(), + module_dir, + gitmodules_contents, + armed: true, + }) + }; + + // install the dep + self.git_submodule(dep, path)?; + + let mut dep = dep.clone(); + if dep.tag.is_none() { + // try to find latest semver release tag + dep.tag = self.last_tag(path); + } + + // checkout the tag if necessary + self.git_checkout(&dep, path, true)?; + + trace!("updating dependency submodules recursively"); + self.git.root(path).submodule_update( + false, + false, + false, + true, + std::iter::empty::(), + )?; + + // sync submodules config with changes in .gitmodules, see + self.git.root(path).submodule_sync()?; + + if let Some(guard) = &mut guard { + guard.disarm(); + } + if self.commit { + self.git.add_literal(path)?; + } + + Ok(dep.tag) + } + + fn last_tag(self, path: &Path) -> Option { + if self.git.shallow { + None + } else { + self.git_semver_tags(path).ok().and_then(|mut tags| tags.pop()).map(|(tag, _)| tag) + } + } + + /// Returns all semver git tags sorted in ascending order + fn git_semver_tags(self, path: &Path) -> Result> { + let out = self.git.root(path).tag()?; + let mut tags = Vec::new(); + // tags are commonly prefixed which would make them not semver: v1.2.3 is not a semantic + // version + let common_prefixes = &["v-", "v", "release-", "release"]; + for tag in out.lines() { + let mut maybe_semver = tag; + for &prefix in common_prefixes { + if let Some(rem) = tag.strip_prefix(prefix) { + maybe_semver = rem; + break; + } + } + match Version::parse(maybe_semver) { + Ok(v) => { + // ignore if additional metadata, like rc, beta, etc... + if v.build.is_empty() && v.pre.is_empty() { + tags.push((tag.to_string(), v)); + } + } + Err(err) => { + warn!(?err, ?maybe_semver, "No semver tag"); + } + } + } + + tags.sort_by(|(_, a), (_, b)| a.cmp(b)); + + Ok(tags) + } + + /// Install the given dependency as git submodule in `target_dir`. + fn git_submodule(self, dep: &Dependency, path: &Path) -> Result<()> { + let url = dep.require_url()?; + + // make path relative to the git root, already checked above + let path = path.strip_prefix(self.git.root).unwrap(); + + trace!(?dep, url, ?path, "installing git submodule"); + self.git.submodule_add(true, url, path) + } + + fn git_checkout(self, dep: &Dependency, path: &Path, recurse: bool) -> Result { + // no need to checkout if there is no tag + let Some(mut tag) = dep.tag.clone() else { return Ok(String::new()) }; + + let mut is_branch = false; + // only try to match tag if current terminal is a tty + if std::io::stdout().is_terminal() { + if tag.is_empty() { + tag = self.match_tag(&tag, path)?; + } else if let Some(branch) = self.match_branch(&tag, path)? { + trace!(?tag, ?branch, "selecting branch for given tag"); + tag = branch; + is_branch = true; + } + } + let url = dep.url.as_ref().unwrap(); + + let res = self.git.root(path).checkout(recurse, &tag); + if let Err(mut e) = res { + // remove dependency on failed checkout + fs::remove_dir_all(path)?; + let error = e.to_string(); + if error.contains("did not match any file(s) known to git") + || error.contains("fatal: invalid reference:") + { + e = eyre::eyre!("Tag: \"{tag}\" not found for repo \"{url}\"!") + } + return Err(e); + } + + if is_branch { Ok(tag) } else { Ok(String::new()) } + } + + /// disambiguate tag if it is a version tag + fn match_tag(self, tag: &str, path: &Path) -> Result { + // only try to match if it looks like a version tag + if !DEPENDENCY_VERSION_TAG_REGEX.is_match(tag) { + return Ok(tag.into()); + } + + // generate candidate list by filtering `git tag` output, valid ones are those "starting + // with" the user-provided tag (ignoring the starting 'v'), for example, if the user + // specifies 1.5, then v1.5.2 is a valid candidate, but v3.1.5 is not + let trimmed_tag = tag.trim_start_matches('v').to_string(); + let output = self.git.root(path).tag()?; + let mut candidates: Vec = output + .trim() + .lines() + .filter(|x| x.trim_start_matches('v').starts_with(&trimmed_tag)) + .map(|x| x.to_string()) + .rev() + .collect(); + + // no match found, fall back to the user-provided tag + if candidates.is_empty() { + return Ok(tag.into()); + } + + // have exact match + for candidate in &candidates { + if candidate == tag { + return Ok(tag.into()); + } + } + + // only one candidate, ask whether the user wants to accept or not + if candidates.len() == 1 { + let matched_tag = &candidates[0]; + let input = prompt!( + "Found a similar version tag: {matched_tag}, do you want to use this instead? [Y/n] " + )?; + return if match_yn(input) { Ok(matched_tag.clone()) } else { Ok(tag.into()) }; + } + + // multiple candidates, ask the user to choose one or skip + candidates.insert(0, String::from("SKIP AND USE ORIGINAL TAG")); + sh_status!("There are multiple matching tags:")?; + for (i, candidate) in candidates.iter().enumerate() { + sh_status!("[{i}] {candidate}")?; + } + + let n_candidates = candidates.len(); + loop { + let input: String = + prompt!("Please select a tag (0-{}, default: 1): ", n_candidates - 1)?; + let s = input.trim(); + // default selection, return first candidate + let n = if s.is_empty() { Ok(1) } else { s.parse() }; + // match user input, 0 indicates skipping and use original tag + match n { + Ok(0) => return Ok(tag.into()), + Ok(i) if (1..=n_candidates).contains(&i) => { + let c = &candidates[i]; + sh_status!("[{i}] {c} selected")?; + return Ok(c.clone()); + } + _ => {} + } + } + } + + fn match_branch(self, tag: &str, path: &Path) -> Result> { + // fetch remote branches and check for tag + let output = self.git.root(path).remote_branches()?; + + let mut candidates = output + .lines() + .map(|x| x.trim().trim_start_matches("origin/")) + .filter(|x| x.starts_with(tag)) + .map(ToString::to_string) + .rev() + .collect::>(); + + trace!(?candidates, ?tag, "found branch candidates"); + + // no match found, fall back to the user-provided tag + if candidates.is_empty() { + return Ok(None); + } + + // have exact match + for candidate in &candidates { + if candidate == tag { + return Ok(Some(tag.to_string())); + } + } + + // only one candidate, ask whether the user wants to accept or not + if candidates.len() == 1 { + let matched_tag = &candidates[0]; + let input = prompt!( + "Found a similar branch: {matched_tag}, do you want to use this instead? [Y/n] " + )?; + return if match_yn(input) { Ok(Some(matched_tag.clone())) } else { Ok(None) }; + } + + // multiple candidates, ask the user to choose one or skip + candidates.insert(0, format!("{tag} (original branch)")); + sh_status!("There are multiple matching branches:")?; + for (i, candidate) in candidates.iter().enumerate() { + sh_status!("[{i}] {candidate}")?; + } + + let n_candidates = candidates.len(); + let input: String = prompt!( + "Please select a tag (0-{}, default: 1, Press to cancel): ", + n_candidates - 1 + )?; + let input = input.trim(); + + // default selection, return None + if input.is_empty() { + sh_status!("Canceled branch matching")?; + return Ok(None); + } + + // match user input, 0 indicates skipping and use original tag + match input.parse::() { + Ok(0) => Ok(Some(tag.into())), + Ok(i) if (1..=n_candidates).contains(&i) => { + let c = &candidates[i]; + sh_status!("[{i}] {c} selected")?; + Ok(Some(c.clone())) + } + _ => Ok(None), + } + } +} + +/// Matches on the result of a prompt for yes/no. +/// +/// Defaults to true. +fn match_yn(input: String) -> bool { + let s = input.trim().to_lowercase(); + matches!(s.as_str(), "" | "y" | "yes") +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + #[ignore = "slow"] + fn get_oz_tags() { + let tmp = tempdir().unwrap(); + let git = Git::new(tmp.path()); + let installer = Installer { git, commit: false }; + + git.init().unwrap(); + + let dep: Dependency = "openzeppelin/openzeppelin-contracts".parse().unwrap(); + let libs = tmp.path().join("libs"); + fs::create_dir(&libs).unwrap(); + let submodule = libs.join("openzeppelin-contracts"); + installer.git_submodule(&dep, &submodule).unwrap(); + assert!(submodule.exists()); + + let tags = installer.git_semver_tags(&submodule).unwrap(); + assert!(!tags.is_empty()); + let v480: Version = "4.8.0".parse().unwrap(); + assert!(tags.iter().any(|(_, v)| v == &v480)); + } +} diff --git a/crates/cli/src/json.rs b/crates/cli/src/json.rs new file mode 100644 index 0000000000000..b9df7790c5ca9 --- /dev/null +++ b/crates/cli/src/json.rs @@ -0,0 +1,329 @@ +//! Shared JSON output primitives for Foundry CLIs. + +use alloy_dyn_abi::DynSolValue; +use eyre::Result; +use foundry_common::{ + fmt::{format_tokens, serialize_value_as_json}, + sh_println, shell, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, to_string}; + +/// The current version of Foundry's top-level JSON output envelope. +pub const JSON_SCHEMA_VERSION: u32 = 1; + +/// Stable top-level envelope for complete machine-readable command output. +/// +/// This envelope represents a terminal command outcome. Long-running commands +/// that stream intermediate records should use a separate event type and reserve +/// this shape for final, complete results. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct JsonEnvelope { + /// Version of the envelope schema. + pub schema_version: u32, + /// Whether the command completed successfully. + /// + /// Only meaningful for a complete/terminal command outcome. + pub success: bool, + /// Command-specific payload. + pub data: Option, + /// Structured errors emitted by the command. + pub errors: Vec, + /// Structured warnings emitted by the command. + pub warnings: Vec, +} + +impl JsonEnvelope { + /// Creates a successful envelope with command-specific data. + pub const fn success(data: T) -> Self { + Self { + schema_version: JSON_SCHEMA_VERSION, + success: true, + data: Some(data), + errors: Vec::new(), + warnings: Vec::new(), + } + } + + /// Creates a successful envelope with command-specific data and warnings. + pub const fn success_with_warnings(data: T, warnings: Vec) -> Self { + Self { + schema_version: JSON_SCHEMA_VERSION, + success: true, + data: Some(data), + errors: Vec::new(), + warnings, + } + } + + /// Creates a failed envelope with command-specific data and structured errors. + pub const fn failure_with_data(data: T, errors: Vec) -> Self { + Self { + schema_version: JSON_SCHEMA_VERSION, + success: false, + data: Some(data), + errors, + warnings: Vec::new(), + } + } +} + +/// A terminal JSON command failure that preserves command-specific output data. +#[derive(Debug)] +pub struct JsonError { + /// Command-specific payload to include in the failure envelope. + pub data: Value, + /// Structured errors emitted by the command. + pub errors: Vec, +} + +impl JsonError { + /// Creates a terminal JSON failure with command-specific data and one error. + pub fn new(data: impl Serialize, error: JsonMessage) -> serde_json::Result { + Ok(Self { data: serde_json::to_value(data)?, errors: vec![error] }) + } +} + +impl std::fmt::Display for JsonError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.errors.first().map_or("JSON command failed", |error| &error.message)) + } +} + +impl std::error::Error for JsonError {} + +impl JsonEnvelope<()> { + /// Creates a failed envelope with one structured error. + pub fn error(error: JsonMessage) -> Self { + Self::failure(vec![error]) + } + + /// Creates a failed envelope with structured errors. + pub const fn failure(errors: Vec) -> Self { + Self { + schema_version: JSON_SCHEMA_VERSION, + success: false, + data: None, + errors, + warnings: Vec::new(), + } + } +} + +/// Severity level for a structured JSON diagnostic. +/// +/// These levels classify diagnostics attached to an envelope. Progress, +/// informational, and debug records should be modeled as command output data or +/// stream events instead. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum JsonMessageLevel { + /// Error message. + Error, + /// Warning message. + Warning, +} + +/// Structured diagnostic entry for JSON output. +/// +/// Diagnostics describe errors and warnings associated with command output. They +/// are not intended for progress, informational, or debug events. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct JsonMessage { + /// Diagnostic severity level. + pub level: JsonMessageLevel, + /// Stable machine-readable diagnostic code. + pub code: String, + /// Human-readable diagnostic message. + pub message: String, + /// Optional structured context for the diagnostic. + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +impl JsonMessage { + /// Creates a structured error without details. + pub fn error(code: impl Into, message: impl Into) -> Self { + Self { + level: JsonMessageLevel::Error, + code: code.into(), + message: message.into(), + details: None, + } + } + + /// Creates a structured warning without details. + pub fn warning(code: impl Into, message: impl Into) -> Self { + Self { + level: JsonMessageLevel::Warning, + code: code.into(), + message: message.into(), + details: None, + } + } + + /// Adds structured details to the diagnostic. + pub fn with_details(mut self, details: Value) -> Self { + self.details = Some(details); + self + } +} + +/// Prints a serializable object: envelope-wrapped in `--json` mode, pretty-printed otherwise. +/// +/// Use this for objects that have no human-readable `Display` format (block data, RPC responses, +/// etc.). +pub fn print_json_object(value: T) -> Result<()> { + if foundry_common::shell::is_json() { + print_json_success(value) + } else { + sh_println!("{}", serde_json::to_string_pretty(&value)?)?; + Ok(()) + } +} + +/// Prints a value as one compact JSON line on stdout and flushes. +/// +/// Bypasses the shell verbosity layer so `--quiet` cannot suppress structured +/// output the caller explicitly asked for. +pub fn print_json(value: &T) -> Result<()> { + let s = to_string(value)?; + let mut shell = foundry_common::shell::Shell::get(); + let out = shell.out(); + writeln!(out, "{s}")?; + out.flush()?; + Ok(()) +} + +/// Prints a successful JSON envelope to stdout. +pub fn print_json_success(data: T) -> Result<()> { + print_json(&JsonEnvelope::success(data)) +} + +/// Prints a successful JSON envelope with warnings to stdout. +pub fn print_json_success_with_warnings( + data: T, + warnings: Vec, +) -> Result<()> { + print_json(&JsonEnvelope::success_with_warnings(data, warnings)) +} + +/// Prints command output that may already be JSON: parsed and envelope-wrapped in `--json` mode, +/// plain text otherwise. If the output is not valid JSON, it is wrapped as a scalar string. +pub fn print_json_value_or_scalar(value: impl AsRef + std::fmt::Display) -> Result<()> { + if shell::is_json() { + match serde_json::from_str::(value.as_ref()) { + Ok(value) => print_json_success(value), + Err(_) => print_json_success(value.as_ref()), + } + } else { + sh_println!("{value}")?; + Ok(()) + } +} + +/// Prints a scalar value: JSON envelope in `--json` mode, plain text otherwise. +pub fn print_scalar(value: impl Serialize + std::fmt::Display) -> Result<()> { + if shell::is_json() { + print_json_success(value) + } else { + sh_println!("{value}")?; + Ok(()) + } +} + +/// Prints a list of serializable items: JSON envelope wrapping an array in `--json` mode, +/// one item per line otherwise. +pub fn print_list(items: &[T]) -> Result<()> { + if shell::is_json() { + print_json_success(items) + } else { + for item in items { + sh_println!("{item}")?; + } + Ok(()) + } +} + +/// Prints ABI-decoded tokens: JSON envelope wrapping a value array in `--json` mode, +/// one formatted token per line otherwise. +pub fn print_tokens(tokens: &[DynSolValue]) -> Result<()> { + if shell::is_json() { + let values = tokens + .iter() + .cloned() + .map(|t| serialize_value_as_json(t, None, true)) + .collect::>>()?; + print_json_success(values) + } else { + format_tokens(tokens).try_for_each(|t| sh_println!("{t}")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{json, to_value}; + + #[derive(Serialize)] + struct BuildData { + contracts: usize, + } + + #[test] + fn success_envelope_serializes_all_top_level_fields() { + let envelope = JsonEnvelope::success(BuildData { contracts: 2 }); + + let json = to_string(&envelope).unwrap(); + + assert_eq!( + json, + r#"{"schema_version":1,"success":true,"data":{"contracts":2},"errors":[],"warnings":[]}"# + ); + } + + #[test] + fn warning_details_are_structured() { + let warning = JsonMessage::warning("compiler.remappings", "auto-detected remappings") + .with_details(json!({ "count": 3 })); + let envelope = + JsonEnvelope::success_with_warnings(BuildData { contracts: 1 }, vec![warning]); + + let value = to_value(&envelope).unwrap(); + + assert_eq!(value["success"], true); + assert_eq!(value["warnings"][0]["level"], "warning"); + assert_eq!(value["warnings"][0]["code"], "compiler.remappings"); + assert_eq!(value["warnings"][0]["details"]["count"], 3); + } + + #[test] + fn failure_envelope_serializes_null_data_and_structured_errors() { + let error = JsonMessage::error("config.invalid", "invalid foundry.toml") + .with_details(json!({ "path": "foundry.toml" })); + let envelope = JsonEnvelope::error(error); + + let value = to_value(&envelope).unwrap(); + + assert_eq!(value["schema_version"], JSON_SCHEMA_VERSION); + assert_eq!(value["success"], false); + assert!(value["data"].is_null()); + assert_eq!(value["errors"][0]["level"], "error"); + assert_eq!(value["errors"][0]["code"], "config.invalid"); + assert_eq!(value["errors"][0]["details"]["path"], "foundry.toml"); + assert_eq!(value["warnings"], json!([])); + } + + #[test] + fn failure_with_data_envelope_preserves_data() { + let envelope = JsonEnvelope::failure_with_data( + json!({ "compatible": false }), + vec![JsonMessage::error("compatibility.failed", "compatibility check failed")], + ); + let value = to_value(envelope).unwrap(); + + assert_eq!(value["success"], false); + assert_eq!(value["data"]["compatible"], false); + assert_eq!(value["errors"][0]["code"], "compatibility.failed"); + } +} diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 9c1fb848edaa9..77dfa6d492162 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -3,7 +3,7 @@ //! Common CLI utilities. #![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] +#![cfg_attr(docsrs, feature(doc_cfg))] #[macro_use] extern crate foundry_common; @@ -11,6 +11,13 @@ extern crate foundry_common; #[macro_use] extern crate tracing; +pub mod clap; pub mod handler; +pub mod install; +pub mod json; +pub mod lockfile; pub mod opts; pub mod utils; + +#[cfg(feature = "tracy")] +tracing_tracy::client::register_demangler!(); diff --git a/crates/cli/src/lockfile.rs b/crates/cli/src/lockfile.rs new file mode 100644 index 0000000000000..4324008d78fbc --- /dev/null +++ b/crates/cli/src/lockfile.rs @@ -0,0 +1,620 @@ +//! foundry.lock handler type. + +use crate::utils::{Git, SubmoduleCheckoutStatus}; +use alloy_primitives::map::HashMap; +use eyre::{Context, OptionExt, Result}; +use foundry_common::fs::canonicalize_path; +use serde::{Deserialize, Serialize}; +use std::{ + collections::{BTreeMap, hash_map::Entry}, + path::{Path, PathBuf}, +}; + +pub const FOUNDRY_LOCK: &str = "foundry.lock"; + +/// A type alias for a HashMap of dependencies keyed by relative path to the submodule dir. +pub type DepMap = HashMap; + +/// A difference between `foundry.lock` and an installed dependency submodule. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum LockfileMismatch { + /// An installed dependency is not recorded in the lockfile. + MissingLockEntry { path: PathBuf, actual: Option }, + /// A lockfile entry does not have a corresponding dependency submodule. + MissingSubmodule { path: PathBuf, expected: String }, + /// The index contains a dependency gitlink without a `.gitmodules` mapping. + MissingSubmoduleMapping { path: PathBuf }, + /// A dependency submodule has not been initialized. + Uninitialized { path: PathBuf, expected: Option }, + /// A dependency submodule has merge conflicts. + Conflicted { path: PathBuf }, + /// The installed revision differs from the locked revision. + Revision { path: PathBuf, expected: String, actual: String }, +} + +impl LockfileMismatch { + /// Returns the dependency path associated with this mismatch. + pub fn path(&self) -> &Path { + match self { + Self::MissingLockEntry { path, .. } + | Self::MissingSubmodule { path, .. } + | Self::MissingSubmoduleMapping { path } + | Self::Uninitialized { path, .. } + | Self::Conflicted { path } + | Self::Revision { path, .. } => path, + } + } +} + +impl std::fmt::Display for LockfileMismatch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingLockEntry { path, actual: Some(actual) } => { + write!(f, "{}: missing from foundry.lock (found {actual})", path.display()) + } + Self::MissingLockEntry { path, actual: None } => { + write!(f, "{}: missing from foundry.lock", path.display()) + } + Self::MissingSubmodule { path, expected } => write!( + f, + "{}: dependency submodule is missing (expected {expected})", + path.display() + ), + Self::MissingSubmoduleMapping { path } => { + write!(f, "{}: dependency submodule is missing from .gitmodules", path.display()) + } + Self::Uninitialized { path, expected: Some(expected) } => write!( + f, + "{}: dependency submodule is not initialized (expected {expected})", + path.display() + ), + Self::Uninitialized { path, expected: None } => { + write!(f, "{}: dependency submodule is not initialized", path.display()) + } + Self::Conflicted { path } => { + write!(f, "{}: dependency submodule has merge conflicts", path.display()) + } + Self::Revision { path, expected, actual } => { + write!(f, "{}: expected {expected}, found {actual}", path.display()) + } + } + } +} + +/// A lockfile handler that keeps track of the dependencies and their current state. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Lockfile<'a> { + /// A map of the dependencies keyed by relative path to the submodule dir. + #[serde(flatten)] + deps: DepMap, + /// This is optional to handle no-git scencarios. + #[serde(skip)] + git: Option<&'a Git<'a>>, + /// Absolute path to the lockfile. + #[serde(skip)] + lockfile_path: PathBuf, +} + +impl<'a> Lockfile<'a> { + /// Create a new [`Lockfile`] instance. + /// + /// `project_root` is the absolute path to the project root. + /// + /// You will need to call [`Lockfile::read`] or [`Lockfile::sync`] to load the lockfile. + pub fn new(project_root: &Path) -> Self { + Self { deps: HashMap::default(), git: None, lockfile_path: project_root.join(FOUNDRY_LOCK) } + } + + /// Set the git instance to be used for submodule operations. + pub const fn with_git(mut self, git: &'a Git<'_>) -> Self { + self.git = Some(git); + self + } + + /// Sync the foundry.lock file with the current state of `git submodules`. + /// + /// If the lockfile and git submodules are out of sync, it returns a [`DepMap`] consisting of + /// _only_ the out-of-sync dependencies. + /// + /// This method writes the lockfile to project root if: + /// - The lockfile does not exist. + /// - The lockfile is out of sync with the git submodules. + pub fn sync(&mut self, lib: &Path) -> Result> { + match self.read() { + Ok(_) => {} + Err(e) if !e.to_string().contains("Lockfile not found") => { + return Err(e); + } + _ => {} + } + + if let Some(git) = &self.git { + let submodules = git.submodules()?; + + if submodules.is_empty() { + trace!("No submodules found. Skipping sync."); + return Ok(None); + } + + let modules_with_branch = git + .read_submodules_with_branch(&Git::root_of(git.root)?, lib.file_name().unwrap())?; + + let mut out_of_sync: DepMap = HashMap::default(); + for sub in &submodules { + let rel_path = sub.path(); + let rev = sub.rev(); + + let entry = self.deps.entry(rel_path.clone()); + + match entry { + Entry::Occupied(e) if e.get().rev() != rev => { + out_of_sync.insert(rel_path.clone(), e.get().clone()); + } + Entry::Vacant(e) => { + // Check if there is branch specified for the submodule at rel_path in + // .gitmodules + let maybe_branch = modules_with_branch.get(rel_path).cloned(); + + trace!(?maybe_branch, submodule = ?rel_path, "submodule branch"); + if let Some(branch) = maybe_branch { + let dep_id = DepIdentifier::Branch { + name: branch, + rev: rev.to_string(), + r#override: false, + }; + e.insert(dep_id.clone()); + out_of_sync.insert(rel_path.clone(), dep_id); + continue; + } + + let dep_id = DepIdentifier::Rev { rev: rev.to_string(), r#override: false }; + trace!(submodule=?rel_path, ?dep_id, "submodule dep_id"); + e.insert(dep_id.clone()); + out_of_sync.insert(rel_path.clone(), dep_id); + } + _ => {} + } + } + + return Ok(if out_of_sync.is_empty() { None } else { Some(out_of_sync) }); + } + + Ok(None) + } + + /// Checks whether the lockfile matches dependency submodules without modifying either. + pub fn check(&mut self) -> Result> { + let lockfile_exists = self.exists(); + if lockfile_exists { + self.read().wrap_err("Failed to read foundry.lock")?; + } else { + self.deps.clear(); + } + + let git = self.git.ok_or_eyre("Git is required to check foundry.lock")?; + let project_root = + canonicalize_path(self.lockfile_path.parent().expect("lockfile path has a parent"))?; + let git_root = match Git::root_of(git.root) { + Ok(root) => root, + Err(_) + if !lockfile_exists + && !project_root.ancestors().any(|root| root.join(".git").exists()) => + { + return Ok(Vec::new()); + } + Err(err) => return Err(err), + }; + let project_prefix = project_root.strip_prefix(&git_root).map_err(|_| { + eyre::eyre!("Project root is not contained in Git root {}", git_root.display()) + })?; + + let repository_path = relative_path(&git_root, &project_root); + let git_submodules = + git.submodules_in_worktree(&repository_path, &git_root, project_prefix)?; + let mut submodules = BTreeMap::new(); + for submodule in &git_submodules { + submodules.insert(submodule.path().clone(), submodule); + } + + let mut mismatches = Vec::new(); + for (path, dep) in &self.deps { + let expected = dep.rev(); + let Some(submodule) = submodules.remove(path) else { + mismatches.push(LockfileMismatch::MissingSubmodule { + path: path.clone(), + expected: expected.to_string(), + }); + continue; + }; + match submodule.status() { + SubmoduleCheckoutStatus::Uninitialized => { + mismatches.push(LockfileMismatch::Uninitialized { + path: path.clone(), + expected: Some(expected.to_string()), + }); + } + SubmoduleCheckoutStatus::Conflicted => { + mismatches.push(LockfileMismatch::Conflicted { path: path.clone() }); + } + SubmoduleCheckoutStatus::MissingMapping => { + mismatches + .push(LockfileMismatch::MissingSubmoduleMapping { path: path.clone() }); + } + SubmoduleCheckoutStatus::Current | SubmoduleCheckoutStatus::Modified + if submodule.rev() != expected => + { + mismatches.push(LockfileMismatch::Revision { + path: path.clone(), + expected: expected.to_string(), + actual: submodule.rev().to_string(), + }); + } + SubmoduleCheckoutStatus::Current | SubmoduleCheckoutStatus::Modified => {} + } + } + for (path, submodule) in submodules { + match submodule.status() { + SubmoduleCheckoutStatus::MissingMapping => { + mismatches.push(LockfileMismatch::MissingSubmoduleMapping { path }); + } + SubmoduleCheckoutStatus::Uninitialized => { + mismatches.push(LockfileMismatch::MissingLockEntry { + path: path.clone(), + actual: None, + }); + mismatches.push(LockfileMismatch::Uninitialized { path, expected: None }); + } + SubmoduleCheckoutStatus::Conflicted => { + mismatches.push(LockfileMismatch::MissingLockEntry { + path: path.clone(), + actual: None, + }); + mismatches.push(LockfileMismatch::Conflicted { path }); + } + SubmoduleCheckoutStatus::Current | SubmoduleCheckoutStatus::Modified => { + mismatches.push(LockfileMismatch::MissingLockEntry { + path, + actual: Some(submodule.rev().to_string()), + }); + } + } + } + mismatches.sort_by(|a, b| a.path().cmp(b.path())); + Ok(mismatches) + } + + /// Loads the lockfile from the project root. + /// + /// Throws an error if the lockfile does not exist. + pub fn read(&mut self) -> Result<()> { + if !self.lockfile_path.exists() { + return Err(eyre::eyre!("Lockfile not found at {}", self.lockfile_path.display())); + } + + let lockfile_str = foundry_common::fs::read_to_string(&self.lockfile_path)?; + + self.deps = serde_json::from_str(&lockfile_str)?; + + trace!(lockfile = ?self.deps, "loaded lockfile"); + + Ok(()) + } + + /// Writes the lockfile to the project root. + pub fn write(&self) -> Result<()> { + let ordered_deps: BTreeMap<_, _> = self.deps.clone().into_iter().collect(); + foundry_common::fs::write_pretty_json_file(&self.lockfile_path, &ordered_deps)?; + trace!(at= ?self.lockfile_path, "wrote lockfile"); + + Ok(()) + } + + /// Insert a dependency into the lockfile. + /// If the dependency already exists, it will be updated. + /// + /// Note: This does not write the updated lockfile to disk, only inserts the dep in-memory. + pub fn insert(&mut self, path: PathBuf, dep_id: DepIdentifier) { + self.deps.insert(path, dep_id); + } + + /// Get the [`DepIdentifier`] for a submodule at a given path. + pub fn get(&self, path: &Path) -> Option<&DepIdentifier> { + self.deps.get(path) + } + + /// Removes a dependency from the lockfile. + /// + /// Note: This does not write the updated lockfile to disk, only removes the dep in-memory. + pub fn remove(&mut self, path: &Path) -> Option { + self.deps.remove(path) + } + + /// Override a dependency in the lockfile. + /// + /// Returns the overridden/previous [`DepIdentifier`]. + /// This is used in `forge update` to decide whether a dep's tag/branch/rev should be updated. + /// + /// Throws an error if the dependency is not found in the lockfile. + pub fn override_dep( + &mut self, + dep: &Path, + mut new_dep_id: DepIdentifier, + ) -> Result { + let prev = self + .deps + .get_mut(dep) + .map(|d| { + new_dep_id.mark_override(); + std::mem::replace(d, new_dep_id) + }) + .ok_or_eyre(format!("Dependency not found in lockfile: {}", dep.display()))?; + + Ok(prev) + } + + /// Returns the num of dependencies in the lockfile. + pub fn len(&self) -> usize { + self.deps.len() + } + + /// Returns whether the lockfile is empty. + pub fn is_empty(&self) -> bool { + self.deps.is_empty() + } + + /// Returns an iterator over the lockfile. + pub fn iter(&self) -> impl Iterator { + self.deps.iter() + } + + /// Returns an mutable iterator over the lockfile. + pub fn iter_mut(&mut self) -> impl Iterator { + self.deps.iter_mut() + } + + pub fn exists(&self) -> bool { + self.lockfile_path.exists() + } +} + +fn relative_path(path: &Path, base: &Path) -> PathBuf { + let common = + path.components().zip(base.components()).take_while(|(path, base)| path == base).count(); + let mut relative = PathBuf::new(); + for _ in common..base.components().count() { + relative.push(".."); + } + relative.extend(path.components().skip(common)); + relative +} + +// Implement .iter() for &LockFile + +/// Identifies whether a dependency (submodule) is referenced by a branch, +/// tag or rev (commit hash). +/// +/// Each enum variant consists of an `r#override` flag which is used in `forge update` to decide +/// whether to update a dep or not. This flag is skipped during serialization. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum DepIdentifier { + /// `name` of the branch and the `rev` it is currently pointing to. + /// Running `forge update`, will update the `name` branch to the latest `rev`. + #[serde(rename = "branch")] + Branch { + name: String, + rev: String, + #[serde(skip)] + r#override: bool, + }, + /// Release tag `name` and the `rev` it is currently pointing to. + /// Running `forge update` does not update the tag/rev. + /// Dependency will remain pinned to the existing tag/rev unless r#override like so `forge + /// update owner/dep@tag=different_tag`. + #[serde(rename = "tag")] + Tag { + name: String, + rev: String, + #[serde(skip)] + r#override: bool, + }, + /// Commit hash `rev` the submodule is currently pointing to. + /// Running `forge update` does not update the rev. + /// Dependency will remain pinned to the existing rev unless r#override. + #[serde(rename = "rev", untagged)] + Rev { + rev: String, + #[serde(skip)] + r#override: bool, + }, +} + +impl DepIdentifier { + /// Resolves the [`DepIdentifier`] for a submodule at a given path. + /// `lib_path` is the absolute path to the submodule. + pub fn resolve_type(git: &Git<'_>, lib_path: &Path, s: &str) -> Result { + trace!(lib_path = ?lib_path, resolving_type = ?s, "resolving submodule identifier"); + // Get the tags for the submodule + if git.has_tag(s, lib_path)? { + let rev = git.get_rev(s, lib_path)?; + return Ok(Self::Tag { name: String::from(s), rev, r#override: false }); + } + + if git.has_branch(s, lib_path)? { + let rev = git.get_rev(s, lib_path)?; + return Ok(Self::Branch { name: String::from(s), rev, r#override: false }); + } + + if git.has_rev(s, lib_path)? { + return Ok(Self::Rev { rev: String::from(s), r#override: false }); + } + + Err(eyre::eyre!("Could not resolve tag type for submodule at path {}", lib_path.display())) + } + + /// Get the commit hash of the dependency. + pub fn rev(&self) -> &str { + match self { + Self::Branch { rev, .. } => rev, + Self::Tag { rev, .. } => rev, + Self::Rev { rev, .. } => rev, + } + } + + /// Get the name of the dependency. + /// + /// In case of a Rev, this will return the commit hash. + pub fn name(&self) -> &str { + match self { + Self::Branch { name, .. } => name, + Self::Tag { name, .. } => name, + Self::Rev { rev, .. } => rev, + } + } + + /// Get the name/rev to checkout at. + pub fn checkout_id(&self) -> &str { + match self { + Self::Branch { name, .. } => name, + Self::Tag { name, .. } => name, + Self::Rev { rev, .. } => rev, + } + } + + /// Marks as dependency as overridden. + pub const fn mark_override(&mut self) { + match self { + Self::Branch { r#override, .. } => *r#override = true, + Self::Tag { r#override, .. } => *r#override = true, + Self::Rev { r#override, .. } => *r#override = true, + } + } + + /// Returns whether the dependency has been overridden. + pub const fn overridden(&self) -> bool { + match self { + Self::Branch { r#override, .. } => *r#override, + Self::Tag { r#override, .. } => *r#override, + Self::Rev { r#override, .. } => *r#override, + } + } + + /// Returns whether the dependency is a branch. + pub const fn is_branch(&self) -> bool { + matches!(self, Self::Branch { .. }) + } +} + +impl std::fmt::Display for DepIdentifier { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Branch { name, rev, .. } => write!(f, "branch={name}@{rev}"), + Self::Tag { name, rev, .. } => write!(f, "tag={name}@{rev}"), + Self::Rev { rev, .. } => write!(f, "rev={rev}"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn serde_dep_identifier() { + let branch = DepIdentifier::Branch { + name: "main".to_string(), + rev: "b7954c3e9ce1d487b49489f5800f52f4b77b7351".to_string(), + r#override: false, + }; + + let tag = DepIdentifier::Tag { + name: "v0.1.0".to_string(), + rev: "b7954c3e9ce1d487b49489f5800f52f4b77b7351".to_string(), + r#override: false, + }; + + let rev = DepIdentifier::Rev { + rev: "b7954c3e9ce1d487b49489f5800f52f4b77b7351".to_string(), + r#override: false, + }; + + let branch_str = serde_json::to_string(&branch).unwrap(); + let tag_str = serde_json::to_string(&tag).unwrap(); + let rev_str = serde_json::to_string(&rev).unwrap(); + + assert_eq!( + branch_str, + r#"{"branch":{"name":"main","rev":"b7954c3e9ce1d487b49489f5800f52f4b77b7351"}}"# + ); + assert_eq!( + tag_str, + r#"{"tag":{"name":"v0.1.0","rev":"b7954c3e9ce1d487b49489f5800f52f4b77b7351"}}"# + ); + assert_eq!(rev_str, r#"{"rev":"b7954c3e9ce1d487b49489f5800f52f4b77b7351"}"#); + + let branch_de: DepIdentifier = serde_json::from_str(&branch_str).unwrap(); + let tag_de: DepIdentifier = serde_json::from_str(&tag_str).unwrap(); + let rev_de: DepIdentifier = serde_json::from_str(&rev_str).unwrap(); + + assert_eq!(branch, branch_de); + assert_eq!(tag, tag_de); + assert_eq!(rev, rev_de); + } + + #[test] + fn test_write_ordered_deps() { + let dir = tempdir().unwrap(); + let mut lockfile = Lockfile::new(dir.path()); + lockfile.insert( + PathBuf::from("z_dep"), + DepIdentifier::Rev { rev: "3".to_string(), r#override: false }, + ); + lockfile.insert( + PathBuf::from("a_dep"), + DepIdentifier::Rev { rev: "1".to_string(), r#override: false }, + ); + lockfile.insert( + PathBuf::from("c_dep"), + DepIdentifier::Rev { rev: "2".to_string(), r#override: false }, + ); + let _ = lockfile.write(); + let contents = fs::read_to_string(lockfile.lockfile_path).unwrap(); + let expected = r#"{ + "a_dep": { + "rev": "1" + }, + "c_dep": { + "rev": "2" + }, + "z_dep": { + "rev": "3" + } +}"#; + assert_eq!(contents.trim(), expected.trim()); + + let mut lockfile = Lockfile::new(dir.path()); + lockfile.read().unwrap(); + lockfile.insert( + PathBuf::from("x_dep"), + DepIdentifier::Rev { rev: "4".to_string(), r#override: false }, + ); + let _ = lockfile.write(); + let contents = fs::read_to_string(lockfile.lockfile_path).unwrap(); + let expected = r#"{ + "a_dep": { + "rev": "1" + }, + "c_dep": { + "rev": "2" + }, + "x_dep": { + "rev": "4" + }, + "z_dep": { + "rev": "3" + } +}"#; + assert_eq!(contents.trim(), expected.trim()); + } +} diff --git a/crates/cli/src/opts/build/core.rs b/crates/cli/src/opts/build/core.rs index 27e9e05f7853c..461e51def73d3 100644 --- a/crates/cli/src/opts/build/core.rs +++ b/crates/cli/src/opts/build/core.rs @@ -9,13 +9,14 @@ use foundry_compilers::{ utils::canonicalized, }; use foundry_config::{ - Config, Remappings, figment, + Config, DenyLevel, Remappings, figment::{ - Figment, Metadata, Profile, Provider, + self, Figment, Metadata, Profile, Provider, error::Kind::InvalidType, value::{Dict, Map, Value}, }, filter::SkipBuildFilter, + providers::relative_remapping_preserving_context_boundary, }; use serde::Serialize; use std::path::PathBuf; @@ -33,10 +34,10 @@ pub struct BuildOpts { #[serde(skip)] pub no_cache: bool, - /// Enable dynamic test linking. + /// Disable dynamic test linking. #[arg(long, conflicts_with = "no_cache")] #[serde(skip)] - pub dynamic_test_linking: bool, + pub no_dynamic_test_linking: bool, /// Set pre-linked libraries. #[arg(long, help_heading = "Linker options", env = "DAPP_LIBRARIES")] @@ -48,9 +49,26 @@ pub struct BuildOpts { #[serde(skip_serializing_if = "Vec::is_empty")] pub ignored_error_codes: Vec, - /// Warnings will trigger a compiler error - #[arg(long, help_heading = "Compiler options")] + /// A compiler error will be triggered at the specified diagnostic level. + /// + /// Replaces the deprecated `--deny-warnings` flag. + /// + /// Possible values: + /// - `never`: Do not treat any diagnostics as errors. + /// - `warnings`: Treat warnings as errors. + /// - `notes`: Treat both, warnings and notes, as errors. + #[arg( + long, + short = 'D', + help_heading = "Compiler options", + value_name = "LEVEL", + conflicts_with = "deny_warnings" + )] #[serde(skip)] + pub deny: Option, + + /// Deprecated: use `--deny=warnings` instead. + #[arg(long = "deny-warnings", hide = true)] pub deny_warnings: bool, /// Do not auto-detect the `solc` version. @@ -58,9 +76,9 @@ pub struct BuildOpts { #[serde(skip)] pub no_auto_detect: bool, - /// Specify the solc version, or a path to a local solc, to build with. + /// Specify the solc version, path, or executable name on `PATH` to build with. /// - /// Valid values are in the format `x.y.z`, `solc:x.y.z` or `path/to/solc`. + /// Valid values are in the format `x.y.z`, `solc:x.y.z`, `path/to/solc`, or `solc`. #[arg( long = "use", alias = "compiler-version", @@ -77,11 +95,6 @@ pub struct BuildOpts { #[serde(skip)] pub offline: bool, - /// Use the Yul intermediate representation compilation pipeline. - #[arg(long, help_heading = "Compiler options")] - #[serde(skip)] - pub via_ir: bool, - /// Changes compilation to only use literal content and not URLs. #[arg(long, help_heading = "Compiler options")] #[serde(skip)] @@ -166,25 +179,48 @@ impl BuildOpts { // Loads project's figment and merges the build cli arguments into it impl<'a> From<&'a BuildOpts> for Figment { fn from(args: &'a BuildOpts) -> Self { - let mut figment = if let Some(ref config_path) = args.project_paths.config_path { - if !config_path.exists() { - panic!("error: config-path `{}` does not exist", config_path.display()) - } - if !config_path.ends_with(Config::FILE_NAME) { - panic!("error: the config-path must be a path to a foundry.toml file") - } + let root = if let Some(config_path) = &args.project_paths.config_path { + assert!( + config_path.exists(), + "error: config-path `{}` does not exist", + config_path.display() + ); + assert!( + config_path.ends_with(Config::FILE_NAME), + "error: the config-path must be a path to a foundry.toml file" + ); let config_path = canonicalized(config_path); - Config::figment_with_root(config_path.parent().unwrap()) + config_path.parent().unwrap().to_path_buf() } else { - Config::figment_with_root(args.project_paths.project_root()) + args.project_paths.project_root() }; + let mut figment = Config::figment_with_root(&root); // remappings should stack - let mut remappings = Remappings::new_with_remappings(args.project_paths.get_remappings()) - .with_figment(&figment); - remappings - .extend(figment.extract_inner::>("remappings").unwrap_or_default()); - figment = figment.merge(("remappings", remappings.into_inner())).merge(args); + let remappings_root = canonicalized(&root); + let cli_remappings = args + .project_paths + .get_remappings() + .into_iter() + .map(|remapping| { + let remapping = relative_remapping_preserving_context_boundary(remapping, &root) + .to_relative_remapping(); + relative_remapping_preserving_context_boundary(remapping, &remappings_root) + .to_relative_remapping() + }) + .collect(); + let mut remappings = Remappings::new_with_remappings(cli_remappings).with_figment(&figment); + let generated_remappings = Remappings::generated_from_figment(&figment); + remappings.extend_with_config_remappings( + figment.extract_inner::>("remappings").unwrap_or_default(), + &generated_remappings, + ); + let remappings = remappings + .into_inner() + .into_iter() + .map(|remapping| relative_remapping_preserving_context_boundary(remapping, &root)) + .collect::>(); + figment = figment.merge(("remappings", remappings)).merge(args); if let Some(skip) = &args.skip { let mut skip = skip.iter().map(|s| s.file_pattern().to_string()).collect::>(); @@ -219,11 +255,10 @@ impl Provider for BuildOpts { } if self.deny_warnings { - dict.insert("deny_warnings".to_string(), true.into()); - } - - if self.via_ir { - dict.insert("via_ir".to_string(), true.into()); + dict.insert("deny".to_string(), figment::value::Value::serialize(DenyLevel::Warnings)?); + _ = sh_warn!("`--deny-warnings` is being deprecated in favor of `--deny warnings`."); + } else if let Some(deny) = self.deny { + dict.insert("deny".to_string(), figment::value::Value::serialize(deny)?); } if self.use_literal_content { @@ -244,8 +279,8 @@ impl Provider for BuildOpts { dict.insert("cache".to_string(), false.into()); } - if self.dynamic_test_linking { - dict.insert("dynamic_test_linking".to_string(), true.into()); + if self.no_dynamic_test_linking { + dict.insert("dynamic_test_linking".to_string(), false.into()); } if self.build_info { @@ -260,6 +295,18 @@ impl Provider for BuildOpts { dict.insert("optimizer".to_string(), optimize.into()); } + if self.compiler.via_ir { + dict.insert("via_ir".to_string(), true.into()); + } + + if self.compiler.via_ssa_cfg { + dict.insert("via_ssa_cfg".to_string(), true.into()); + } + + if self.compiler.experimental { + dict.insert("experimental".to_string(), true.into()); + } + if !self.compiler.extra_output.is_empty() { let selection: Vec<_> = self.compiler.extra_output.iter().map(|s| s.to_string()).collect(); diff --git a/crates/cli/src/opts/build/mod.rs b/crates/cli/src/opts/build/mod.rs index 53e6d2d94e88d..430bea7600b42 100644 --- a/crates/cli/src/opts/build/mod.rs +++ b/crates/cli/src/opts/build/mod.rs @@ -9,7 +9,7 @@ mod paths; pub use self::paths::ProjectPathOpts; mod utils; -pub use self::utils::{solar_pcx_from_build_opts, solar_pcx_from_solc_project}; +pub use self::utils::*; // A set of solc compiler settings that can be set via command line arguments, which are intended // to be merged into an existing `foundry_config::Config`. @@ -42,6 +42,27 @@ pub struct CompilerOpts { #[serde(skip_serializing_if = "Option::is_none")] pub optimizer_runs: Option, + /// Use the Yul intermediate representation compilation pipeline. + #[arg(long, help_heading = "Compiler options")] + #[serde(skip)] + pub via_ir: bool, + + /// Turn on SSA CFG-based code generation via the IR (experimental). + /// + /// This passes `--via-ssa-cfg` to solc. Implies `--via-ir`. Requires `--experimental` to be + /// set (as of Solidity 0.8.35+). This is false by default. + #[arg(long, help_heading = "Compiler options")] + #[serde(skip)] + pub via_ssa_cfg: bool, + + /// Enable Solidity's experimental mode. + /// + /// This passes `--experimental` to solc, which is required by Solidity 0.8.35+ for + /// experimental features. + #[arg(long, help_heading = "Compiler options")] + #[serde(skip)] + pub experimental: bool, + /// Extra output to include in the contract's artifact. /// /// Example keys: evm.assembly, ewasm, ir, irOptimized, metadata @@ -80,6 +101,18 @@ mod tests { ); } + #[test] + fn can_parse_experimental() { + let args: CompilerOpts = CompilerOpts::parse_from(["foundry-cli", "--experimental"]); + assert!(args.experimental); + } + + #[test] + fn can_parse_via_ssa_cfg() { + let args: CompilerOpts = CompilerOpts::parse_from(["foundry-cli", "--via-ssa-cfg"]); + assert!(args.via_ssa_cfg); + } + #[test] fn can_parse_extra_output_files() { let args: CompilerOpts = CompilerOpts::parse_from([ diff --git a/crates/cli/src/opts/build/paths.rs b/crates/cli/src/opts/build/paths.rs index 70953887ace95..72b4a056e179f 100644 --- a/crates/cli/src/opts/build/paths.rs +++ b/crates/cli/src/opts/build/paths.rs @@ -58,7 +58,14 @@ pub struct ProjectPathOpts { pub hardhat: bool, /// Path to the config file. - #[arg(long, value_hint = ValueHint::FilePath, value_name = "FILE")] + /// + /// The project root is the config file's parent directory. + #[arg( + long, + conflicts_with = "root", + value_hint = ValueHint::FilePath, + value_name = "FILE" + )] #[serde(skip)] pub config_path: Option, } @@ -81,15 +88,63 @@ impl ProjectPathOpts { /// Returns the remappings to add to the config pub fn get_remappings(&self) -> Vec { let mut remappings = self.remappings.clone(); - if let Some(env_remappings) = - self.remappings_env.as_ref().and_then(|env| remappings_from_env_var(env)) + if let Some(remappings_env) = self.remappings_env.as_deref() + && let Some(env_remappings) = remappings_from_env_var(remappings_env) { - remappings.extend(env_remappings.expect("Failed to parse env var remappings")); + match env_remappings { + Ok(env_remappings) => remappings.extend(env_remappings), + Err(err) => { + let _ = sh_warn!( + "failed to parse env var remappings from `{remappings_env}`: {err}" + ); + } + } } remappings } } +#[cfg(test)] +mod tests { + use super::ProjectPathOpts; + + #[test] + fn get_remappings_ignores_invalid_remappings_env_var() { + let env_name = "FOUNDRY_CLI_TEST_INVALID_REMAPPINGS"; + unsafe { + std::env::set_var(env_name, "this-is-not-a-remapping"); + } + + let opts = + ProjectPathOpts { remappings_env: Some(env_name.to_string()), ..Default::default() }; + let remappings = opts.get_remappings(); + assert!(remappings.is_empty()); + + unsafe { + std::env::remove_var(env_name); + } + } + + #[test] + fn get_remappings_parses_valid_remappings_env_var() { + let env_name = "FOUNDRY_CLI_TEST_VALID_REMAPPINGS"; + unsafe { + std::env::set_var(env_name, "forge-std/=lib/forge-std/src/"); + } + + let opts = + ProjectPathOpts { remappings_env: Some(env_name.to_string()), ..Default::default() }; + let remappings = opts.get_remappings(); + assert_eq!(remappings.len(), 1); + assert_eq!(remappings[0].name, "forge-std/"); + assert_eq!(remappings[0].path, "lib/forge-std/src/"); + + unsafe { + std::env::remove_var(env_name); + } + } +} + foundry_config::impl_figment_convert!(ProjectPathOpts); // Make this args a `figment::Provider` so that it can be merged into the `Config` diff --git a/crates/cli/src/opts/build/utils.rs b/crates/cli/src/opts/build/utils.rs index a85d7866df4be..351531d81584b 100644 --- a/crates/cli/src/opts/build/utils.rs +++ b/crates/cli/src/opts/build/utils.rs @@ -1,30 +1,75 @@ -use crate::{opts::BuildOpts, utils::LoadConfig}; - use eyre::Result; +use foundry_common::fs::canonicalize_path; use foundry_compilers::{ - CompilerInput, Graph, Project, + CompilerInput, Graph, Project, ProjectCompileOutput, ProjectPathsConfig, artifacts::{Source, Sources}, - multi::{MultiCompilerLanguage, MultiCompilerParsedSource}, - solc::{SolcLanguage, SolcVersionedInput}, + multi::{MultiCompilerLanguage, MultiCompilerParser}, + solc::{SOLC_EXTENSIONS, SolcLanguage, SolcVersionedInput}, +}; +use foundry_config::Config; +use rayon::prelude::*; +use solar::{interface::MIN_SOLIDITY_VERSION, sema::ParsingContext}; +use std::{ + collections::HashSet, + path::{Path, PathBuf}, }; -use solar_sema::{ParsingContext, interface::Session}; -use std::path::PathBuf; -/// Builds a Solar [`solar_sema::ParsingContext`] from [`BuildOpts`]. +#[cfg(windows)] +use path_slash::PathExt as _; +#[cfg(windows)] +use std::os::windows::ffi::OsStrExt as _; + +/// Describes the Solidity sources encountered while configuring Solar. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct SolarSourceStatus { + /// Whether any Solar-compatible sources were configured. + has_compatible_sources: bool, + /// Whether any sources require a Solidity version unsupported by Solar. + has_unsupported_sources: bool, +} + +impl SolarSourceStatus { + /// Returns whether compatible sources were configured and no unsupported sources were found. + pub const fn is_fully_supported(&self) -> bool { + self.has_compatible_sources && !self.has_unsupported_sources + } +} + +/// Configures a Solar parsing context with all Solar-compatible project sources. /// -/// * Configures include paths, remappings and registers all in-memory sources so that solar can -/// operate without touching disk. -/// * If no `project` is provided, it will spin up a new ephemeral project. -/// * If no `target_paths` are provided, all project files are processed. -/// * Only processes the subset of sources with the most up-to-date Solitidy version. -pub fn solar_pcx_from_build_opts<'sess>( - sess: &'sess Session, - build: &BuildOpts, +/// Returns `true` if any compatible sources were configured. +pub fn configure_pcx_all_sources( + pcx: &mut ParsingContext<'_>, + config: &Config, project: Option<&Project>, target_paths: Option<&[PathBuf]>, -) -> Result> { +) -> Result { + let status = configure_pcx_all_sources_with_status(pcx, config, project, target_paths)?; + if !status.has_compatible_sources && !status.has_unsupported_sources { + eyre::bail!("no Solidity sources"); + } + Ok(status.has_compatible_sources) +} + +/// Configures a Solar parsing context with all Solar-compatible project sources and reports +/// whether unsupported sources were skipped. +pub fn configure_pcx_all_sources_with_status( + pcx: &mut ParsingContext<'_>, + config: &Config, + project: Option<&Project>, + target_paths: Option<&[PathBuf]>, +) -> Result { + configure_pcx_with_sources(pcx, config, project, target_paths, true) +} + +fn configure_pcx_with_sources( + pcx: &mut ParsingContext<'_>, + config: &Config, + project: Option<&Project>, + target_paths: Option<&[PathBuf]>, + all_versions: bool, +) -> Result { // Process build options - let config = build.load_config()?; let project = match project { Some(project) => project, None => &config.ephemeral_project()?, @@ -35,31 +80,153 @@ pub fn solar_pcx_from_build_opts<'sess>( Some(targets) => { let mut sources = Sources::new(); for t in targets { - let path = dunce::canonicalize(t)?; + let path = canonicalize_path(t)?; let source = Source::read(&path)?; sources.insert(path, source); } sources } // Otherwise, process all project files - None => project.paths.read_input_files()?, + None => { + let mut sources = project.paths.read_input_files()?; + if let Some(filter) = &project.sparse_output { + sources.retain(|path, _| filter.is_match(path)); + } + sources + } }; - // Only process sources with latest Solidity version to avoid conflicts. - let graph = Graph::::resolve_sources(&project.paths, sources)?; - let (version, sources, _) = graph - // resolve graph into mapping language -> version -> sources + // Process Solar-compatible sources and use the latest version for the compiler input. + let graph = Graph::::resolve_sources(&project.paths, sources)?; + let Some(versioned_sources) = graph + // Resolve graph into mapping language -> version -> sources .into_sources_by_version(project)? .sources .into_iter() - // only interested in Solidity sources + // Only interested in Solidity sources .find(|(lang, _)| *lang == MultiCompilerLanguage::Solc(SolcLanguage::Solidity)) - .ok_or_else(|| eyre::eyre!("no Solidity sources"))? - .1 - .into_iter() - // always pick the latest version - .max_by(|(v1, _, _), (v2, _, _)| v1.cmp(v2)) - .unwrap(); + .map(|(_, sources)| sources) + else { + return Ok(SolarSourceStatus::default()); + }; + + let mut has_unsupported_sources = false; + let versioned_sources = versioned_sources.into_iter().filter(|(version, sources, _)| { + if version < &MIN_SOLIDITY_VERSION && !sources.is_empty() { + has_unsupported_sources = true; + false + } else { + true + } + }); + let (version, sources) = if all_versions { + versioned_sources.fold( + (MIN_SOLIDITY_VERSION, Sources::default()), + |(version, mut sources), (source_version, version_sources, _)| { + sources.extend(version_sources); + (version.max(source_version), sources) + }, + ) + } else { + versioned_sources + .max_by(|(v1, _, _), (v2, _, _)| v1.cmp(v2)) + .map_or((MIN_SOLIDITY_VERSION, Sources::default()), |(v, s, _)| (v, s)) + }; + + let has_sources = !sources.is_empty(); + if !all_versions && !has_sources { + sh_warn!("no files found. Solar doesn't support Solidity versions prior to 0.8.0")?; + } + + let solc = SolcVersionedInput::build( + sources, + config.solc_settings()?, + SolcLanguage::Solidity, + version, + ); + + configure_pcx_from_solc(pcx, &project.paths, &solc, true); + + Ok(SolarSourceStatus { has_compatible_sources: has_sources, has_unsupported_sources }) +} + +/// Extracts Solar-compatible sources from a [`ProjectCompileOutput`]. +/// +/// # Note: +/// uses `output.graph().source_files()` and `output.artifact_ids()` rather than `output.sources()` +/// because sources aren't populated when build is skipped when there are no changes in the source +/// code. +pub fn get_solar_sources_from_compile_output( + config: &Config, + output: &ProjectCompileOutput, + target_paths: Option<&[PathBuf]>, + ignored_paths: Option<&[PathBuf]>, +) -> Result { + let is_solidity_file = |path: &Path| -> bool { + path.extension().and_then(|s| s.to_str()).is_some_and(|ext| SOLC_EXTENSIONS.contains(&ext)) + }; + + let is_ignored = |path: &Path| -> bool { + if let Some(ignored) = ignored_paths { + ignored.iter().any(|ignored_path| path == ignored_path) + } else { + false + } + }; + + // Collect source path targets + let mut source_paths: HashSet = if let Some(targets) = target_paths + && !targets.is_empty() + { + let mut source_paths = HashSet::new(); + for path in targets.iter().filter_map(|path| { + is_solidity_file(path).then(|| canonicalize_path(path).ok()).flatten() + }) { + if source_paths.insert(path.clone()) { + // `imports` already includes transitive dependencies. + source_paths.extend( + output + .graph() + .imports(&path) + .into_iter() + .filter(|import| !is_ignored(import)) + .map(Path::to_path_buf), + ); + } + } + + source_paths + } else { + output + .graph() + .source_files() + .filter_map(|idx| { + let path = output.graph().node_path(idx).to_path_buf(); + is_solidity_file(&path).then_some(path) + }) + .collect() + }; + + // Read all sources and find the latest version. + let (version, sources) = { + let (mut max_version, mut sources) = (MIN_SOLIDITY_VERSION, Sources::new()); + for (id, _) in output.artifact_ids() { + if let Ok(path) = canonicalize_path(&id.source) + && source_paths.remove(&path) + { + if id.version < MIN_SOLIDITY_VERSION { + continue; + } else if max_version < id.version { + max_version = id.version; + }; + + let source = Source::read(&path)?; + sources.insert(path, source); + } + } + + (max_version, sources) + }; let solc = SolcVersionedInput::build( sources, @@ -68,43 +235,101 @@ pub fn solar_pcx_from_build_opts<'sess>( version, ); - Ok(solar_pcx_from_solc_project(sess, project, &solc, true)) + Ok(solc) +} + +/// Configures a [`ParsingContext`] from a [`ProjectCompileOutput`]. +pub fn configure_pcx_from_compile_output( + pcx: &mut ParsingContext<'_>, + config: &Config, + output: &ProjectCompileOutput, + target_paths: Option<&[PathBuf]>, +) -> Result<()> { + let solc = get_solar_sources_from_compile_output(config, output, target_paths, None)?; + configure_pcx_from_solc(pcx, &config.project_paths(), &solc, true); + Ok(()) } -/// Builds a Solar [`solar_sema::ParsingContext`] from a [`foundry_compilers::Project`] and a -/// [`SolcVersionedInput`]. +/// Converts a Windows path to Solar's slash form while retaining path prefixes and trailing +/// directory boundaries. +#[cfg(windows)] +fn solar_slash_path(path: &Path) -> String { + let has_trailing_separator = path + .as_os_str() + .encode_wide() + .last() + .is_some_and(|c| c == u16::from(b'/') || c == u16::from(b'\\')); + let mut path = path.to_slash_lossy().into_owned(); + if has_trailing_separator && !path.ends_with('/') { + path.push('/'); + } + path +} + +/// Configures a [`ParsingContext`] from [`ProjectPathsConfig`] and [`SolcVersionedInput`]. /// -/// * Configures include paths, remappings. -/// * Source files can be manually added if the param `add_source_file` is set to `false`. -pub fn solar_pcx_from_solc_project<'sess>( - sess: &'sess Session, - project: &Project, - solc: &SolcVersionedInput, +/// - Configures include paths, remappings. +/// - Source files are added if `add_source_file` is set +pub fn configure_pcx_from_solc( + pcx: &mut ParsingContext<'_>, + project_paths: &ProjectPathsConfig, + vinput: &SolcVersionedInput, add_source_files: bool, -) -> ParsingContext<'sess> { - // Configure the parsing context with the paths, remappings and sources - let mut pcx = ParsingContext::new(sess); - - pcx.file_resolver - .set_current_dir(solc.cli_settings.base_path.as_ref().unwrap_or(&project.paths.root)); - for remapping in &project.paths.remappings { - pcx.file_resolver.add_import_remapping(solar_sema::interface::config::ImportRemapping { - context: remapping.context.clone().unwrap_or_default(), +) { + configure_pcx_from_solc_cli(pcx, project_paths, &vinput.cli_settings); + if add_source_files { + let sources = vinput + .input + .sources + .par_iter() + .filter_map(|(path, source)| { + #[cfg(windows)] + let path = PathBuf::from(solar_slash_path(path)); + #[cfg(not(windows))] + let path = path.clone(); + pcx.sess.source_map().new_source_file(path, source.content.as_str()).ok() + }) + .collect::>(); + pcx.add_files(sources); + } +} + +fn configure_pcx_from_solc_cli( + pcx: &mut ParsingContext<'_>, + project_paths: &ProjectPathsConfig, + cli_settings: &foundry_compilers::solc::CliSettings, +) { + let base_path = cli_settings.base_path.as_ref().unwrap_or(&project_paths.root); + pcx.file_resolver.set_base_path(base_path); + pcx.file_resolver.set_current_dir(base_path); + for remapping in &project_paths.remappings { + let context = remapping.context.clone().unwrap_or_default(); + // Solar compares the context directly with the parent source path. Match the slash form + // used above instead of allowing mixed Windows separators to change prefix semantics. + #[cfg(windows)] + let context = solar_slash_path(Path::new(&context)); + pcx.file_resolver.add_import_remapping(solar::sema::interface::config::ImportRemapping { + context, prefix: remapping.name.clone(), path: remapping.path.clone(), }); } - pcx.file_resolver.add_include_paths(solc.cli_settings.include_paths.iter().cloned()); + pcx.file_resolver.add_include_paths(cli_settings.include_paths.iter().cloned()); +} - if add_source_files { - for (path, source) in &solc.input.sources { - if let Ok(src_file) = - sess.source_map().new_source_file(path.clone(), source.content.as_str()) - { - pcx.add_file(src_file); - } +#[cfg(all(test, windows))] +mod tests { + use super::*; + + #[test] + fn solar_slash_path_preserves_windows_prefixes_and_boundaries() { + for (path, expected) in [ + (r"lib\outer\", "lib/outer/"), + (r"\\server\share\project/", r"\\server\share/project/"), + (r"\\?\C:\project/", r"\\?\C:/project/"), + (r"\\?\UNC\server\share\project/", r"\\?\UNC\server\share/project/"), + ] { + assert_eq!(solar_slash_path(Path::new(path)), expected); } } - - pcx } diff --git a/crates/cli/src/opts/chain.rs b/crates/cli/src/opts/chain.rs index 2762481e08e86..bb5ae15da6278 100644 --- a/crates/cli/src/opts/chain.rs +++ b/crates/cli/src/opts/chain.rs @@ -2,7 +2,6 @@ use clap::builder::{PossibleValuesParser, TypedValueParser}; use eyre::Result; use foundry_config::{Chain, NamedChain}; use std::ffi::OsStr; -use strum::VariantNames; /// Custom Clap value parser for [`Chain`]s. /// @@ -14,7 +13,7 @@ pub struct ChainValueParser { impl Default for ChainValueParser { fn default() -> Self { - Self { inner: PossibleValuesParser::from(NamedChain::VARIANTS) } + Self { inner: PossibleValuesParser::from(NamedChain::VARIANT_NAMES) } } } @@ -32,11 +31,11 @@ impl TypedValueParser for ChainValueParser { if let Ok(id) = s.parse() { Ok(Chain::from_id(id)) } else { - // NamedChain::VARIANTS is a subset of all possible variants, since there are aliases: - // mumbai instead of polygon-mumbai etc + // NamedChain::VARIANT_NAMES is a subset of all possible variants, since there are + // aliases: amoy instead of polygon-amoy etc // - // Parse first as NamedChain, if it fails parse with NamedChain::VARIANTS for displaying - // the error to the user + // Parse first as NamedChain, if it fails parse with NamedChain::VARIANT_NAMES for + // displaying the error to the user s.parse() .map_err(|_| self.inner.parse_ref(cmd, arg, value).unwrap_err()) .map(Chain::from_named) diff --git a/crates/cli/src/opts/dependency.rs b/crates/cli/src/opts/dependency.rs index ad7c9e0bc66e4..f65239c0d87c7 100644 --- a/crates/cli/src/opts/dependency.rs +++ b/crates/cli/src/opts/dependency.rs @@ -87,6 +87,8 @@ impl FromStr for Dependency { token.as_str(), project.trim_end_matches(".git") )) + } else if dependency.starts_with("git@") { + Some(format!("git@{brand}.{tld}:{}", project.trim_end_matches(".git"))) } else { Some(format!("https://{brand}.{tld}/{}", project.trim_end_matches(".git"))) } @@ -160,7 +162,7 @@ mod tests { #[test] fn parses_dependencies() { - [ + for (input, expected_path, expected_tag, expected_alias) in [ ("gakonst/lootloose", "https://github.com/gakonst/lootloose", None, None), ("github.com/gakonst/lootloose", "https://github.com/gakonst/lootloose", None, None), ( @@ -176,17 +178,12 @@ mod tests { None, ), ( - "git@github.com:gakonst/lootloose@v1", - "https://github.com/gakonst/lootloose", - Some("v1"), - None, - ), - ( + "git@github.com:gakonst/lootloose@tag=v1", "git@github.com:gakonst/lootloose", - "https://github.com/gakonst/lootloose", - None, + Some("v1"), None, ), + ("git@github.com:gakonst/lootloose", "git@github.com:gakonst/lootloose", None, None), ( "https://gitlab.com/gakonst/lootloose", "https://gitlab.com/gakonst/lootloose", @@ -237,20 +234,18 @@ mod tests { Some("loot"), ), ( - "loot=git@github.com:gakonst/lootloose@v1", - "https://github.com/gakonst/lootloose", + "loot=git@github.com:gakonst/lootloose@tag=v1", + "git@github.com:gakonst/lootloose", Some("v1"), Some("loot"), ), - ] - .iter() - .for_each(|(input, expected_path, expected_tag, expected_alias)| { + ] { let dep = Dependency::from_str(input).unwrap(); assert_eq!(dep.url, Some(expected_path.to_string())); assert_eq!(dep.tag, expected_tag.map(ToString::to_string)); assert_eq!(dep.name, "lootloose"); assert_eq!(dep.alias, expected_alias.map(ToString::to_string)); - }); + } } #[test] @@ -270,28 +265,26 @@ mod tests { #[test] fn parses_contract_info() { - [ + for (input, expected_path, expected_name) in [ ( "src/contracts/Contracts.sol:Contract", Some("src/contracts/Contracts.sol"), "Contract", ), ("Contract", None, "Contract"), - ] - .iter() - .for_each(|(input, expected_path, expected_name)| { + ] { let contract = ContractInfo::from_str(input).unwrap(); assert_eq!(contract.path, expected_path.map(ToString::to_string)); assert_eq!(contract.name, expected_name.to_string()); - }); + } } #[test] fn contract_info_should_reject_without_name() { - ["src/contracts/", "src/contracts/Contracts.sol"].iter().for_each(|input| { + for input in ["src/contracts/", "src/contracts/Contracts.sol"] { let contract = ContractInfo::from_str(input); - assert!(contract.is_err()) - }); + assert!(contract.is_err()); + } } #[test] diff --git a/crates/cli/src/opts/evm.rs b/crates/cli/src/opts/evm.rs new file mode 100644 index 0000000000000..d422da3f32e51 --- /dev/null +++ b/crates/cli/src/opts/evm.rs @@ -0,0 +1,503 @@ +//! CLI arguments for configuring the EVM settings. + +use crate::opts::RpcCommonOpts; +use alloy_primitives::{Address, B256, U256}; +use clap::Parser; +use foundry_common::shell; +use foundry_config::{ + Chain, Config, FoundryHardfork, + figment::{ + self, Metadata, Profile, Provider, + error::Kind::InvalidType, + value::{Dict, Map, Value}, + }, +}; +use foundry_evm_networks::NetworkConfigs; +use serde::Serialize; + +/// `EvmArgs` and `EnvArgs` take the highest precedence in the Config/Figment hierarchy. +/// +/// All vars are opt-in, their default values are expected to be set by the +/// [`foundry_config::Config`], and are always present ([`foundry_config::Config::default`]) +/// +/// Both have corresponding types in the `evm_adapters` crate which have mandatory fields. +/// The expected workflow is +/// 1. load the [`foundry_config::Config`] +/// 2. merge with `EvmArgs` into a `figment::Figment` +/// 3. extract `evm_adapters::Opts` from the merged `Figment` +/// +/// # Example +/// +/// ```ignore +/// use foundry_config::Config; +/// use forge::executor::opts::EvmOpts; +/// use foundry_cli::opts::EvmArgs; +/// # fn t(args: EvmArgs) { +/// let figment = Config::figment_with_root(".").merge(args); +/// let opts = figment.extract::().unwrap(); +/// # } +/// ``` +#[derive(Clone, Debug, Default, Serialize, Parser)] +#[command(next_help_heading = "EVM options", about = None, long_about = None)] // override doc +pub struct EvmArgs { + /// Common RPC options (URL, timeout, rate limiting, etc.). + #[command(flatten)] + #[serde(flatten)] + pub rpc: RpcCommonOpts, + + /// Fetch state from a specific block number over a remote endpoint. + /// + /// See --rpc-url. + #[arg(long, requires = "rpc_url", value_name = "BLOCK")] + #[serde(skip_serializing_if = "Option::is_none")] + pub fork_block_number: Option, + + /// Fetch fork state by block number instead of hash. + /// + /// Use for RPC endpoints that cannot serve state by block hash. Number-based reads + /// cannot guarantee a consistent snapshot if the remote chain reorganizes and do not + /// use the disk state cache. Transaction replay still requires hash-addressed state. + #[arg(long)] + #[serde(skip)] + pub fork_state_by_number: bool, + + /// Number of retries. + /// + /// See --rpc-url. + #[arg(long, requires = "rpc_url", value_name = "RETRIES")] + #[serde(skip_serializing_if = "Option::is_none")] + pub fork_retries: Option, + + /// Initial retry backoff on encountering errors. + /// + /// See --rpc-url. + #[arg(long, requires = "rpc_url", value_name = "BACKOFF")] + #[serde(skip_serializing_if = "Option::is_none")] + pub fork_retry_backoff: Option, + + /// Explicitly disables the use of RPC caching. + /// + /// All storage slots are read entirely from the endpoint. + /// + /// This flag overrides the project's configuration file. + /// + /// See --rpc-url. + #[arg(long)] + #[serde(skip)] + pub no_storage_caching: bool, + + /// Disable parent-block BAL cache prewarming for transaction-hash fork cheatcodes. + /// + /// Preceding transactions are still replayed when prewarming is enabled. + #[arg(long)] + #[serde(skip)] + pub no_fork_bal: bool, + + /// The initial balance of deployed test contracts. + #[arg(long, value_name = "BALANCE")] + #[serde(skip_serializing_if = "Option::is_none")] + pub initial_balance: Option, + + /// The address which will be executing tests/scripts. + #[arg(long, value_name = "ADDRESS")] + #[serde(skip_serializing_if = "Option::is_none")] + pub sender: Option
, + + /// Enable the FFI cheatcode. + #[arg(long)] + #[serde(skip)] + pub ffi: bool, + + /// Whether to show `console.log` outputs in realtime during script/test execution + #[arg(long)] + #[serde(skip)] + pub live_logs: bool, + + /// Use the create 2 factory in all cases including tests and non-broadcasting scripts. + #[arg(long)] + #[serde(skip)] + pub always_use_create_2_factory: bool, + + /// The CREATE2 deployer address to use, this will override the one in the config. + #[arg(long, value_name = "ADDRESS")] + #[serde(skip_serializing_if = "Option::is_none")] + pub create2_deployer: Option
, + + /// All ethereum environment related arguments + #[command(flatten)] + #[serde(flatten)] + pub env: EnvArgs, + + /// Whether to enable isolation of calls. + /// In isolation mode all top-level calls are executed as a separate transaction in a separate + /// EVM context, enabling more precise gas accounting and transaction state changes. + #[arg(long)] + #[serde(skip)] + pub isolate: bool, + + /// Whether to disable isolation of calls. + #[arg(long, conflicts_with = "isolate")] + #[serde(skip)] + pub no_isolate: bool, + + /// The runtime EVM hardfork to use. + /// + /// Network-specific hardforks must be namespaced, for example `tempo:T5`. + #[arg(long, value_name = "HARDFORK")] + #[serde(skip_serializing_if = "Option::is_none")] + pub hardfork: Option, + + /// Network selection. + #[command(flatten)] + #[serde(skip)] + pub networks: NetworkConfigs, +} + +// Make this set of options a `figment::Provider` so that it can be merged into the `Config` +impl Provider for EvmArgs { + fn metadata(&self) -> Metadata { + Metadata::named("Evm Opts Provider") + } + + fn data(&self) -> Result, figment::Error> { + let value = Value::serialize(self)?; + let error = InvalidType(value.to_actual(), "map".into()); + let mut dict = value.into_dict().ok_or(error)?; + + if shell::verbosity() > 0 { + // need to merge that manually otherwise `from_occurrences` does not work + let verbosity = shell::verbosity(); + dict.insert("verbosity".to_string(), verbosity.into()); + } + + if self.ffi { + dict.insert("ffi".to_string(), self.ffi.into()); + } + + if self.live_logs { + dict.insert("live_logs".to_string(), self.live_logs.into()); + } + + if self.no_isolate { + dict.insert("isolate".to_string(), false.into()); + } else if self.isolate { + dict.insert("isolate".to_string(), self.isolate.into()); + } + + if self.always_use_create_2_factory { + dict.insert( + "always_use_create_2_factory".to_string(), + self.always_use_create_2_factory.into(), + ); + } + + if self.no_storage_caching { + dict.insert("no_storage_caching".to_string(), self.no_storage_caching.into()); + } + + // Merge serde-skipped fields from the common RPC options. + if self.rpc.no_rpc_rate_limit { + dict.insert("no_rpc_rate_limit".to_string(), true.into()); + } + if self.rpc.accept_invalid_certs { + dict.insert("eth_rpc_accept_invalid_certs".to_string(), true.into()); + } + if self.rpc.no_proxy { + dict.insert("eth_rpc_no_proxy".to_string(), true.into()); + } + + // Only insert network flags when explicitly set via CLI to avoid overriding + // values from foundry.toml (NetworkConfigs is flattened in Config). + if let Some(network) = self.networks.resolved_network() { + dict.insert("network".to_string(), network.name().into()); + } + if self.networks.is_celo() { + dict.insert("celo".to_string(), true.into()); + } + + let mut data = Map::from([(Config::selected_profile(), dict)]); + if self.fork_state_by_number { + data.entry(Profile::Global) + .or_default() + .insert("fork_state_by_number".to_string(), true.into()); + } + if self.no_fork_bal { + // Environment values use the global profile, which overrides the selected profile. + data.entry(Profile::Global).or_default().insert("no_fork_bal".to_string(), true.into()); + } + Ok(data) + } +} + +/// Configures the executor environment during tests. +#[derive(Clone, Debug, Default, Serialize, Parser)] +#[command(next_help_heading = "Executor environment config")] +pub struct EnvArgs { + /// EIP-170: Contract code size limit in bytes. Useful to increase this because of tests. By + /// default, it is 0x6000 (~25kb). + #[arg(long, value_name = "CODE_SIZE")] + #[serde(skip_serializing_if = "Option::is_none")] + pub code_size_limit: Option, + + /// The chain name or EIP-155 chain ID. + #[arg(long, visible_alias = "chain-id", value_name = "CHAIN")] + #[serde(rename = "chain_id", skip_serializing_if = "Option::is_none", serialize_with = "id")] + pub chain: Option, + + /// The gas price. + #[arg(long, value_name = "GAS_PRICE")] + #[serde(skip_serializing_if = "Option::is_none")] + pub gas_price: Option, + + /// The base fee in a block. + #[arg(long, visible_alias = "base-fee", value_name = "FEE")] + #[serde(skip_serializing_if = "Option::is_none")] + pub block_base_fee_per_gas: Option, + + /// The transaction origin. + #[arg(long, value_name = "ADDRESS")] + #[serde(skip_serializing_if = "Option::is_none")] + pub tx_origin: Option
, + + /// The coinbase of the block. + #[arg(long, value_name = "ADDRESS")] + #[serde(skip_serializing_if = "Option::is_none")] + pub block_coinbase: Option
, + + /// The timestamp of the block. + #[arg(long, value_name = "TIMESTAMP")] + #[serde(skip_serializing_if = "Option::is_none")] + pub block_timestamp: Option, + + /// The block number. + #[arg(long, value_name = "BLOCK")] + #[serde(skip_serializing_if = "Option::is_none")] + pub block_number: Option, + + /// The block difficulty. + #[arg(long, value_name = "DIFFICULTY")] + #[serde(skip_serializing_if = "Option::is_none")] + pub block_difficulty: Option, + + /// The block prevrandao value. NOTE: Before merge this field was mix_hash. + #[arg(long, value_name = "PREVRANDAO")] + #[serde(skip_serializing_if = "Option::is_none")] + pub block_prevrandao: Option, + + /// The block gas limit. + #[arg(long, visible_alias = "gas-limit", value_name = "BLOCK_GAS_LIMIT")] + #[serde(skip_serializing_if = "Option::is_none")] + pub block_gas_limit: Option, + + /// The memory limit per EVM execution in bytes. + /// If this limit is exceeded, a `MemoryLimitOOG` result is thrown. + /// + /// The default is 128MiB. + #[arg(long, value_name = "MEMORY_LIMIT")] + #[serde(skip_serializing_if = "Option::is_none")] + pub memory_limit: Option, + + /// Whether to disable the block gas limit checks. + #[arg(long, visible_aliases = &["no-block-gas-limit", "no-gas-limit"])] + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub disable_block_gas_limit: bool, + + /// Whether to enable tx gas limit checks as imposed by Osaka (EIP-7825). + #[arg(long, visible_alias = "tx-gas-limit")] + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub enable_tx_gas_limit: bool, +} + +/// We have to serialize chain IDs and not names because when extracting an EVM `Env`, it expects +/// `chain_id` to be `u64`. +fn id(chain: &Option, s: S) -> Result { + if let Some(chain) = chain { + s.serialize_u64(chain.id()) + } else { + // skip_serializing_if = "Option::is_none" should prevent this branch from being taken + unreachable!() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use foundry_config::{ + NamedChain, + figment::{Figment, providers::Serialized}, + }; + + #[test] + fn fork_state_by_number_cli_preserves_config_unless_explicit() { + for profile in ["default", "ci"] { + for configured in [false, true] { + for environment in [None, Some(false), Some(true)] { + for flag in [false, true] { + let config = + Config { fork_state_by_number: configured, ..Default::default() }; + let mut figment = + Figment::from(Serialized::defaults(config).profile(profile)) + .select(profile); + if let Some(environment) = environment { + figment = figment + .merge(Serialized::global("fork_state_by_number", environment)); + } + let args = EvmArgs::parse_from( + ["foundry-cli"] + .into_iter() + .chain(flag.then_some("--fork-state-by-number")), + ); + let merged = Config::from_provider(figment.merge(args)).unwrap(); + assert_eq!( + merged.fork_state_by_number, + flag || environment.unwrap_or(configured), + "profile={profile}, configured={configured}, environment={environment:?}, flag={flag}", + ); + } + } + } + } + } + + #[test] + fn fork_bal_cli_preserves_config_unless_explicit() { + for profile in ["default", "ci"] { + for configured in [false, true] { + for environment in [None, Some(false), Some(true)] { + for flag in [false, true] { + let config = Config { no_fork_bal: configured, ..Default::default() }; + let mut figment = + Figment::from(Serialized::defaults(config).profile(profile)) + .select(profile); + if let Some(environment) = environment { + figment = figment.merge(Serialized::global("no_fork_bal", environment)); + } + let args = EvmArgs::parse_from( + ["foundry-cli"].into_iter().chain(flag.then_some("--no-fork-bal")), + ); + let merged = Config::from_provider(figment.merge(args)).unwrap(); + assert_eq!( + merged.no_fork_bal, + flag || environment.unwrap_or(configured), + "profile={profile}, configured={configured}, environment={environment:?}, flag={flag}", + ); + } + } + } + } + } + + #[test] + fn compute_units_per_second_skips_when_none() { + let args = EvmArgs::default(); + let data = args.data().expect("provider data"); + let dict = data.get(&Config::selected_profile()).expect("profile dict"); + assert!( + !dict.contains_key("compute_units_per_second"), + "compute_units_per_second should be skipped when None" + ); + } + + #[test] + fn compute_units_per_second_present_when_some() { + let args = EvmArgs { + rpc: RpcCommonOpts { compute_units_per_second: Some(1000), ..Default::default() }, + ..Default::default() + }; + let data = args.data().expect("provider data"); + let dict = data.get(&Config::selected_profile()).expect("profile dict"); + let val = dict.get("compute_units_per_second").expect("cups present"); + assert_eq!(val, &Value::from(1000u64)); + } + + #[test] + fn celo_network_is_included_in_provider_data() { + let args = EvmArgs { networks: NetworkConfigs::with_celo(), ..Default::default() }; + let data = args.data().expect("provider data"); + let dict = data.get(&Config::selected_profile()).expect("profile dict"); + + assert_eq!(dict.get("celo"), Some(&Value::from(true))); + assert!(!dict.contains_key("network")); + } + + #[test] + fn explicit_ethereum_network_is_included_in_provider_data() { + let args = EvmArgs { networks: NetworkConfigs::with_ethereum(), ..Default::default() }; + let data = args.data().expect("provider data"); + let dict = data.get(&Config::selected_profile()).expect("profile dict"); + + assert_eq!(dict.get("network"), Some(&Value::from("ethereum"))); + assert!(!dict.contains_key("celo")); + } + + #[test] + fn rpc_url_arg_does_not_read_eth_rpc_url_env() { + use clap::CommandFactory; + + let command = EvmArgs::command(); + let rpc_url = + command.get_arguments().find(|arg| arg.get_id() == "rpc_url").expect("rpc_url arg"); + + assert!(rpc_url.get_env().is_none()); + } + + #[test] + fn can_parse_chain_id() { + let args = EvmArgs { + env: EnvArgs { chain: Some(NamedChain::Mainnet.into()), ..Default::default() }, + ..Default::default() + }; + let config = Config::from_provider(Config::figment().merge(args)).unwrap(); + assert_eq!(config.chain, Some(NamedChain::Mainnet.into())); + + let env = EnvArgs::parse_from(["foundry-cli", "--chain-id", "goerli"]); + assert_eq!(env.chain, Some(NamedChain::Goerli.into())); + } + + #[cfg(feature = "base")] + #[test] + fn can_parse_namespaced_base_hardfork() { + let args = EvmArgs::parse_from(["foundry-cli", "--hardfork", "base:Beryl"]); + assert_eq!(args.hardfork.map(String::from).as_deref(), Some("base:Beryl")); + + let config = Config::from_provider(Config::figment().merge(args)).unwrap(); + assert!(config.networks.is_base()); + assert_eq!(config.hardfork.map(String::from).as_deref(), Some("base:Beryl")); + } + + #[test] + fn hardfork_arg_selects_network() { + let args = EvmArgs::parse_from(["foundry-cli", "--hardfork", "tempo:T5"]); + let hardfork = "tempo:T5".parse::().unwrap(); + assert_eq!(args.hardfork, Some(hardfork)); + + let config = Config::from_provider(Config::figment().merge(args)).unwrap(); + assert_eq!(config.hardfork, Some(hardfork)); + assert!(config.networks.is_tempo()); + } + + #[test] + fn test_memory_limit() { + let args = EvmArgs { + env: EnvArgs { chain: Some(NamedChain::Mainnet.into()), ..Default::default() }, + ..Default::default() + }; + let config = Config::from_provider(Config::figment().merge(args)).unwrap(); + assert_eq!(config.memory_limit, Config::default().memory_limit); + + let env = EnvArgs::parse_from(["foundry-cli", "--memory-limit", "100"]); + assert_eq!(env.memory_limit, Some(100)); + } + + #[test] + fn test_chain_id() { + let env = EnvArgs::parse_from(["foundry-cli", "--chain-id", "1"]); + assert_eq!(env.chain, Some(Chain::mainnet())); + + let env = EnvArgs::parse_from(["foundry-cli", "--chain-id", "mainnet"]); + assert_eq!(env.chain, Some(Chain::mainnet())); + let args = EvmArgs { env, ..Default::default() }; + let config = Config::from_provider(Config::figment().merge(args)).unwrap(); + assert_eq!(config.chain, Some(Chain::mainnet())); + } +} diff --git a/crates/cli/src/opts/global.rs b/crates/cli/src/opts/global.rs index cf5c2917aaebe..c26cc91d24d9c 100644 --- a/crates/cli/src/opts/global.rs +++ b/crates/cli/src/opts/global.rs @@ -3,6 +3,7 @@ use foundry_common::{ shell::{ColorChoice, OutputFormat, OutputMode, Shell, Verbosity}, version::{IS_NIGHTLY_VERSION, NIGHTLY_VERSION_WARNING_MESSAGE}, }; +use foundry_config::{Config, figment::Profile}; use serde::{Deserialize, Serialize}; /// Global arguments for the CLI. @@ -18,7 +19,8 @@ pub struct GlobalArgs { /// - 2 (-vv): Print logs for all tests. /// - 3 (-vvv): Print execution traces for failing tests. /// - 4 (-vvvv): Print execution traces for all tests, and setup traces for failing tests. - /// - 5 (-vvvvv): Print execution and setup traces for all tests, including storage changes. + /// - 5 (-vvvvv): Print execution and setup traces for all tests, including storage changes and + /// backtraces with line numbers. #[arg(help_heading = "Display options", global = true, short, long, verbatim_doc_comment, conflicts_with = "quiet", action = ArgAction::Count)] verbosity: Verbosity, @@ -30,6 +32,16 @@ pub struct GlobalArgs { #[arg(help_heading = "Display options", global = true, long, alias = "format-json", conflicts_with_all = &["quiet", "color"])] json: bool, + /// Format log messages as Markdown. + #[arg( + help_heading = "Display options", + global = true, + long, + alias = "markdown", + conflicts_with = "json" + )] + md: bool, + /// The color of the log messages. #[arg(help_heading = "Display options", global = true, long, value_enum)] color: Option, @@ -37,13 +49,46 @@ pub struct GlobalArgs { /// Number of threads to use. Specifying 0 defaults to the number of logical cores. #[arg(global = true, long, short = 'j', visible_alias = "jobs")] threads: Option, + + /// The configuration profile to use. + #[arg(global = true, long, value_name = "PROFILE")] + profile: Option, } impl GlobalArgs { + /// Check if `--markdown-help` was passed and print CLI reference as Markdown, then exit. + /// + /// This must be called **before** parsing arguments, since commands with required + /// subcommands would fail parsing before the flag is checked. + pub fn check_markdown_help() { + if std::env::args_os().take_while(|a| a != "--").any(|a| a == "--markdown-help") { + // Pre-parse: `Shell` is not initialized yet, so `sh_*` is unavailable. + foundry_cli_markdown::print_help_markdown::(); + std::process::exit(0); + } + } + /// Initialize the global options. pub fn init(&self) -> eyre::Result<()> { + if let Some(profile) = &self.profile + && let Err(selected) = Config::try_set_selected_profile(profile.clone()) + { + eyre::bail!( + "configuration profile was already initialized as `{selected}`, cannot select \ + `{profile}`" + ); + } + let _ = Config::selected_profile(); + // Set the global shell. - self.shell().set(); + let shell = self.shell(); + // Argument takes precedence over the env var global color choice. + match shell.color_choice() { + ColorChoice::Auto => {} + ColorChoice::Always => yansi::enable(), + ColorChoice::Never => yansi::disable(), + } + shell.set(); // Initialize the thread pool only if `threads` was requested to avoid unnecessary overhead. if self.threads.is_some() { @@ -51,9 +96,9 @@ impl GlobalArgs { } // Display a warning message if the current version is not stable. - if std::env::var("FOUNDRY_DISABLE_NIGHTLY_WARNING").is_err() + if IS_NIGHTLY_VERSION && !self.json - && IS_NIGHTLY_VERSION + && std::env::var_os("FOUNDRY_DISABLE_NIGHTLY_WARNING").is_none() { let _ = sh_warn!("{}", NIGHTLY_VERSION_WARNING_MESSAGE); } @@ -68,9 +113,12 @@ impl GlobalArgs { false => OutputMode::Normal, }; let color = self.json.then_some(ColorChoice::Never).or(self.color).unwrap_or_default(); - let format = match self.json { - true => OutputFormat::Json, - false => OutputFormat::Text, + let format = if self.json { + OutputFormat::Json + } else if self.md { + OutputFormat::Markdown + } else { + OutputFormat::Text }; Shell::new_with(format, mode, color, self.verbosity) diff --git a/crates/cli/src/opts/mod.rs b/crates/cli/src/opts/mod.rs index 3b6b914c14aaa..0d1c1a2d6fb29 100644 --- a/crates/cli/src/opts/mod.rs +++ b/crates/cli/src/opts/mod.rs @@ -1,13 +1,21 @@ mod build; mod chain; mod dependency; +mod evm; mod global; mod rpc; +mod rpc_common; +mod tempo; +mod tracing; mod transaction; pub use build::*; pub use chain::*; pub use dependency::*; +pub use evm::*; pub use global::*; pub use rpc::*; +pub use rpc_common::*; +pub use tempo::*; +pub use tracing::*; pub use transaction::*; diff --git a/crates/cli/src/opts/rpc.rs b/crates/cli/src/opts/rpc.rs index f9369385859f6..69f62308044fd 100644 --- a/crates/cli/src/opts/rpc.rs +++ b/crates/cli/src/opts/rpc.rs @@ -1,15 +1,13 @@ -use crate::opts::ChainValueParser; -use alloy_chains::ChainKind; +use crate::opts::{ChainValueParser, RpcCommonOpts}; use clap::Parser; use eyre::Result; -use foundry_block_explorers::EtherscanApiVersion; use foundry_config::{ - Chain, Config, + Chain, Config, FigmentProviders, figment::{ - self, Metadata, Profile, + self, Figment, Metadata, Profile, value::{Dict, Map}, }, - impl_figment_convert_cast, + find_project_root, impl_figment_convert_cast, }; use foundry_wallets::WalletOpts; use serde::Serialize; @@ -18,17 +16,11 @@ use std::borrow::Cow; const FLASHBOTS_URL: &str = "https://rpc.flashbots.net/fast"; #[derive(Clone, Debug, Default, Parser)] +#[command(next_help_heading = "Rpc options")] pub struct RpcOpts { - /// The RPC endpoint, default value is http://localhost:8545. - #[arg(short = 'r', long = "rpc-url", env = "ETH_RPC_URL")] - pub url: Option, - - /// Allow insecure RPC connections (accept invalid HTTPS certificates). - /// - /// When the provider's inner runtime transport variant is HTTP, this configures the reqwest - /// client to accept invalid certificates. - #[arg(short = 'k', long = "insecure", default_value = "false")] - pub accept_invalid_certs: bool, + /// Common RPC options (URL, timeout, rate limiting, etc.). + #[command(flatten)] + pub common: RpcCommonOpts, /// Use the Flashbots RPC URL with fast mode (). /// @@ -40,7 +32,7 @@ pub struct RpcOpts { /// JWT Secret for the RPC endpoint. /// - /// The JWT secret will be used to create a JWT for a RPC. For example, the following can be + /// The JWT secret will be used to create a JWT for an RPC. For example, the following can be /// used to simulate a CL `engine_forkchoiceUpdated` call: /// /// cast rpc --jwt-secret engine_forkchoiceUpdatedV2 @@ -50,17 +42,13 @@ pub struct RpcOpts { #[arg(long, env = "ETH_RPC_JWT_SECRET")] pub jwt_secret: Option, - /// Timeout for the RPC request in seconds. - /// - /// The specified timeout will be used to override the default timeout for RPC requests. - /// - /// Default value: 45 - #[arg(long, env = "ETH_RPC_TIMEOUT")] - pub rpc_timeout: Option, - /// Specify custom headers for RPC requests. #[arg(long, alias = "headers", env = "ETH_RPC_HEADERS", value_delimiter(','))] pub rpc_headers: Option>, + + /// Print the equivalent curl command instead of making the RPC request. + #[arg(long)] + pub curl: bool, } impl_figment_convert_cast!(RpcOpts); @@ -78,13 +66,23 @@ impl figment::Provider for RpcOpts { impl RpcOpts { /// Returns the RPC endpoint. pub fn url<'a>(&'a self, config: Option<&'a Config>) -> Result>> { - let url = match (self.flashbots, self.url.as_deref(), config) { - (true, ..) => Some(Cow::Borrowed(FLASHBOTS_URL)), - (false, Some(url), _) => Some(Cow::Borrowed(url)), - (false, None, Some(config)) => config.get_rpc_url().transpose()?, - (false, None, None) => None, - }; - Ok(url) + self.url_with_env(config, std::env::var("ETH_RPC_URL").ok()) + } + + fn url_with_env<'a>( + &'a self, + config: Option<&'a Config>, + env_url: Option, + ) -> Result>> { + if self.flashbots { + Ok(Some(Cow::Borrowed(FLASHBOTS_URL))) + } else if let Some(url) = self.common.rpc_url.as_deref() { + Ok(Some(Cow::Borrowed(url))) + } else if let Some(url) = env_url { + Ok(Some(Cow::Owned(url))) + } else { + self.common.url(config) + } } /// Returns the JWT secret. @@ -98,24 +96,30 @@ impl RpcOpts { } pub fn dict(&self) -> Dict { - let mut dict = Dict::new(); + let mut dict = self.common.dict(); + // `self.url(None)` already accounts for `flashbots` and the `ETH_RPC_URL` env var, + // so a single insert here covers both. if let Ok(Some(url)) = self.url(None) { dict.insert("eth_rpc_url".into(), url.into_owned().into()); } if let Ok(Some(jwt)) = self.jwt(None) { dict.insert("eth_rpc_jwt".into(), jwt.into_owned().into()); } - if let Some(rpc_timeout) = self.rpc_timeout { - dict.insert("eth_rpc_timeout".into(), rpc_timeout.into()); - } if let Some(headers) = &self.rpc_headers { dict.insert("eth_rpc_headers".into(), headers.clone().into()); } - if self.accept_invalid_certs { - dict.insert("eth_rpc_accept_invalid_certs".into(), true.into()); + if self.curl { + dict.insert("eth_rpc_curl".into(), true.into()); } dict } + + pub fn into_figment(self, all: bool) -> Figment { + let root = find_project_root(None).expect("could not determine project root"); + Config::with_root(&root) + .to_figment(if all { FigmentProviders::All } else { FigmentProviders::Cast }) + .merge(self) + } } #[derive(Clone, Debug, Default, Serialize, Parser)] @@ -125,16 +129,6 @@ pub struct EtherscanOpts { #[serde(rename = "etherscan_api_key", skip_serializing_if = "Option::is_none")] pub key: Option, - /// The Etherscan API version. - #[arg( - short, - long = "etherscan-api-version", - alias = "api-version", - env = "ETHERSCAN_API_VERSION" - )] - #[serde(rename = "etherscan_api_version", skip_serializing_if = "Option::is_none")] - pub api_version: Option, - /// The chain name or EIP-155 chain ID. #[arg( short, @@ -176,16 +170,8 @@ impl EtherscanOpts { dict.insert("etherscan_api_key".into(), key.into()); } - if let Some(api_version) = &self.api_version { - dict.insert("etherscan_api_version".into(), api_version.to_string().into()); - } - if let Some(chain) = self.chain { - if let ChainKind::Id(id) = chain.kind() { - dict.insert("chain_id".into(), (*id).into()); - } else { - dict.insert("chain_id".into(), chain.to_string().into()); - } + dict.insert("chain_id".into(), chain.id().into()); } dict } @@ -227,6 +213,7 @@ impl figment::Provider for EthereumOpts { #[cfg(test)] mod tests { use super::*; + use clap::CommandFactory; #[test] fn parse_etherscan_opts() { @@ -238,4 +225,54 @@ mod tests { EtherscanOpts::parse_from(["foundry-cli", "--etherscan-api-key", ""]); assert!(!args.has_key()); } + + // + #[test] + fn named_chain_dict_inserts_numeric_id() { + // Chain 9745 is recognized as NamedChain::Plasma by alloy-chains. + // Previously, dict() would insert chain_id as the string "plasma", + // causing deserialization failure when EvmOpts expects u64. + let args = EtherscanOpts::parse_from(["foundry-cli", "--chain", "9745"]); + let dict = args.dict(); + let chain_id = dict.get("chain_id").expect("chain_id should be present"); + let id: u64 = chain_id.deserialize().expect("chain_id should deserialize as u64"); + assert_eq!(id, 9745); + } + + #[test] + fn rpc_url_arg_does_not_read_eth_rpc_url_env() { + let command = RpcOpts::command(); + let rpc_url = + command.get_arguments().find(|arg| arg.get_id() == "rpc_url").expect("rpc_url arg"); + + assert!(rpc_url.get_env().is_none()); + } + + #[test] + fn rpc_url_resolves_eth_rpc_url_env() { + let args = RpcOpts::default(); + let url = args + .url_with_env(None, Some("http://127.0.0.1:8545".to_string())) + .expect("url") + .expect("url"); + + assert_eq!(url.as_ref(), "http://127.0.0.1:8545"); + } + + #[test] + fn explicit_rpc_url_takes_precedence_over_eth_rpc_url_env() { + let args = RpcOpts { + common: RpcCommonOpts { + rpc_url: Some("http://127.0.0.1:8546".to_string()), + ..Default::default() + }, + ..Default::default() + }; + let url = args + .url_with_env(None, Some("http://127.0.0.1:8545".to_string())) + .expect("url") + .expect("url"); + + assert_eq!(url.as_ref(), "http://127.0.0.1:8546"); + } } diff --git a/crates/cli/src/opts/rpc_common.rs b/crates/cli/src/opts/rpc_common.rs new file mode 100644 index 0000000000000..6a5fe5ed4e9e4 --- /dev/null +++ b/crates/cli/src/opts/rpc_common.rs @@ -0,0 +1,119 @@ +//! Common RPC options shared between `RpcOpts` and `EvmArgs`. + +use clap::Parser; +use eyre::Result; +use foundry_config::{ + Config, + figment::{ + self, Metadata, Profile, + value::{Dict, Map}, + }, +}; +use serde::Serialize; +use std::borrow::Cow; + +/// Common RPC-related options shared across CLI commands. +/// +/// This struct holds fields that both [`super::RpcOpts`] (cast) and +/// [`super::EvmArgs`] (forge/script) need, eliminating duplication and +/// making the two structs composable. +/// +/// Note: `ETH_RPC_URL` is intentionally **not** bound here as a clap env +/// fallback; otherwise it would be inherited by `EvmArgs` and silently +/// fork all `forge test` runs. Cast resolves `ETH_RPC_URL` explicitly +/// at the call site (see [`super::RpcOpts::url`]). +#[derive(Clone, Debug, Default, Serialize, Parser)] +pub struct RpcCommonOpts { + /// The RPC endpoint. + #[arg(short, long, visible_alias = "fork-url", value_name = "URL")] + #[serde(rename = "eth_rpc_url", skip_serializing_if = "Option::is_none")] + pub rpc_url: Option, + + /// Allow insecure RPC connections (accept invalid HTTPS certificates). + /// + /// When the provider's inner runtime transport variant is HTTP, this configures the reqwest + /// client to accept invalid certificates. + #[arg(short = 'k', long = "insecure", default_value = "false")] + #[serde(skip)] + pub accept_invalid_certs: bool, + + /// Timeout for the RPC request in seconds. + /// + /// The specified timeout will be used to override the default timeout for RPC requests. + /// + /// Default value: 45 + #[arg(long, env = "ETH_RPC_TIMEOUT")] + #[serde(rename = "eth_rpc_timeout", skip_serializing_if = "Option::is_none")] + pub rpc_timeout: Option, + + /// Disable automatic proxy detection. + /// + /// Use this in sandboxed environments (e.g., Cursor IDE sandbox, macOS App Sandbox) where + /// system proxy detection causes crashes. When enabled, HTTP_PROXY/HTTPS_PROXY environment + /// variables and system proxy settings will be ignored. + #[arg(long = "no-proxy", alias = "disable-proxy", default_value = "false")] + #[serde(skip)] + pub no_proxy: bool, + + /// Sets the number of assumed available compute units per second for this provider. + /// + /// default value: 330 + /// + /// See also + #[arg(long, alias = "cups", value_name = "CUPS")] + #[serde(skip_serializing_if = "Option::is_none")] + pub compute_units_per_second: Option, + + /// Disables rate limiting for this node's provider. + /// + /// See also + #[arg(long, value_name = "NO_RATE_LIMITS", visible_alias = "no-rate-limit")] + #[serde(skip)] + pub no_rpc_rate_limit: bool, +} + +impl figment::Provider for RpcCommonOpts { + fn metadata(&self) -> Metadata { + Metadata::named("RpcCommonOpts") + } + + fn data(&self) -> Result, figment::Error> { + Ok(Map::from([(Config::selected_profile(), self.dict())])) + } +} + +impl RpcCommonOpts { + /// Returns the RPC endpoint URL, resolving from CLI args or config. + pub fn url<'a>(&'a self, config: Option<&'a Config>) -> Result>> { + let url = match (self.rpc_url.as_deref(), config) { + (Some(url), _) => Some(Cow::Borrowed(url)), + (None, Some(config)) => config.get_rpc_url().transpose()?, + (None, None) => None, + }; + Ok(url) + } + + /// Builds a figment-compatible dictionary from these options. + pub fn dict(&self) -> Dict { + let mut dict = Dict::new(); + if let Ok(Some(url)) = self.url(None) { + dict.insert("eth_rpc_url".into(), url.into_owned().into()); + } + if let Some(rpc_timeout) = self.rpc_timeout { + dict.insert("eth_rpc_timeout".into(), rpc_timeout.into()); + } + if self.accept_invalid_certs { + dict.insert("eth_rpc_accept_invalid_certs".into(), true.into()); + } + if self.no_proxy { + dict.insert("eth_rpc_no_proxy".into(), true.into()); + } + if let Some(cups) = self.compute_units_per_second { + dict.insert("compute_units_per_second".into(), cups.into()); + } + if self.no_rpc_rate_limit { + dict.insert("no_rpc_rate_limit".into(), true.into()); + } + dict + } +} diff --git a/crates/cli/src/opts/tempo.rs b/crates/cli/src/opts/tempo.rs new file mode 100644 index 0000000000000..0fe96c548055b --- /dev/null +++ b/crates/cli/src/opts/tempo.rs @@ -0,0 +1,531 @@ +use alloy_network::{Network, TransactionBuilder}; +use alloy_primitives::{Address, B256, ruint::aliases::U256}; +use alloy_signer::{Signature, Signer}; +use clap::Parser; +use eyre::Result; +use foundry_common::{ + FoundryTransactionBuilder, + tempo::{TempoSponsor, resolve_tempo_sponsor_signer}, +}; +use foundry_evm::hardfork::TempoHardfork; +use std::{num::NonZeroU64, path::PathBuf, str::FromStr, sync::Arc}; + +use crate::utils::{now, parse_fee_token_address}; + +mod session; +pub use session::TEMPO_SESSION_ID_ENV; + +/// CLI options for Tempo transactions. +#[derive(Clone, Debug, Default, Parser)] +#[command(next_help_heading = "Tempo")] +pub struct TempoOpts { + /// Use a live Tempo wallet session for signing. + /// + /// When set, Foundry resolves the authorization witness from + /// `$TEMPO_HOME/wallet/store.json` and signs with that Accounts access key on behalf of its + /// root account. + #[arg(long = "tempo.session", id = "tempo_session", value_name = "SESSION_ID")] + pub session: Option, + + /// Fee token address, numeric TIP-20 token id, or known symbol for Tempo transactions. + /// + /// When set, builds a Tempo (type 0x76) transaction that pays gas fees + /// in the specified token. Known symbols are PathUSD, AlphaUSD, BetaUSD, ThetaUSD, + /// and OUSD. + /// + /// If this is not set, the fee token is chosen according to network rules. See the Tempo docs + /// for more information. + #[arg(long = "tempo.fee-token", value_parser = parse_fee_token_address)] + pub fee_token: Option
, + + /// Opt into TIP-1009 expiring-nonce mode with a validity window. + /// + /// Convenience flag that combines `--tempo.expiring-nonce` with a relative + /// `--tempo.valid-before`. Sets nonce_key = U256::MAX and valid_before = now + seconds. The + /// nonce defaults to 0; see `--tempo.expiring-nonce` for how an explicit nonce is used. + /// + /// Maximum value is 300 seconds; networks that have not activated the T11 hardfork reject + /// windows above 30 seconds. The transaction must be mined before the deadline or it becomes + /// permanently invalid, giving safe retry semantics: retries produce a fresh tx hash and the + /// old tx can never land late. + #[arg(long = "tempo.expires", value_name = "SECONDS", value_parser = parse_expires_seconds)] + pub expires: Option, + + /// Nonce key for Tempo parallelizable nonces. + /// + /// When set, builds a Tempo (type 0x76) transaction with the specified nonce key, + /// allowing multiple transactions with the same nonce but different keys + /// to be executed in parallel. If not set, the protocol nonce key (0) will be used. + /// + /// For more information see . + #[arg(long = "tempo.nonce-key", value_name = "NONCE_KEY", conflicts_with = "lane")] + pub nonce_key: Option, + + /// Named nonce lane for Tempo parallelizable nonces. + /// + /// Resolves a friendly lane name (e.g. `deploy`, `payments`) to a `nonce_key` via a + /// shared lanes file (default: `tempo.lanes.toml` at the project root). The lanes file + /// is a TOML map of `name = ` entries, e.g.: + /// + /// ```toml + /// deploy = 1 + /// ops = 2 + /// payments = 3 + /// ``` + /// + /// Mutually exclusive with `--tempo.nonce-key`. + #[arg(long = "tempo.lane", value_name = "NAME")] + pub lane: Option, + + /// Path to the Tempo lanes file used by `--tempo.lane`. + /// + /// Defaults to `tempo.lanes.toml` at the project root. + #[arg(long = "tempo.lanes-file", value_name = "PATH")] + pub lanes_file: Option, + + /// Sponsor (fee payer) address for Tempo sponsored transactions. + #[arg(long = "tempo.sponsor", value_name = "ADDRESS")] + pub sponsor: Option
, + + /// Sign Tempo sponsor digests in-band with the given signer URI. + /// + /// Supported forms include `env://VAR`, `keystore://PATH`, `account://NAME`, + /// `ledger://`, `trezor://`, `aws://`, `gcp://`, `turnkey://`, and + /// `private-key://KEY`. + #[arg( + long = "tempo.sponsor-signer", + value_name = "SIGNER", + requires = "sponsor", + conflicts_with = "sponsor_sig" + )] + pub sponsor_signer: Option, + + /// Sponsor (fee payer) signature for Tempo sponsored transactions. + /// + /// The sponsor signs the `fee_payer_signature_hash` to commit to paying gas fees + /// on behalf of the sender. Provide as a hex-encoded signature. + #[arg( + long = "tempo.sponsor-sig", + alias = "tempo.sponsor-signature", + value_parser = parse_signature, + requires = "sponsor", + conflicts_with = "sponsor_signer" + )] + pub sponsor_sig: Option, + + /// Remote sponsor (fee payer) service URL for Cast transaction commands. + /// + /// When set, the user-signed transaction is forwarded to this URL via + /// `eth_signRawTransaction`. The service adds its fee payer signature and returns + /// the fully-sponsored transaction, which is then submitted via the regular RPC. + /// No local sponsor key is required. + /// + /// This option is supported by `cast send` and `cast erc20`. Forge commands currently support + /// local sponsorship through `--tempo.sponsor` and `--tempo.sponsor-signer` instead. + /// + /// Example: `cast send 0x... --sponsor-url https://sponsor.moderato.tempo.xyz` + #[arg( + long = "sponsor-url", + alias = "tempo.sponsor-url", + value_name = "URL", + conflicts_with_all = &["sponsor", "sponsor_signer", "sponsor_sig", "print_sponsor_hash"], + env = "TEMPO_SPONSOR_URL" + )] + pub sponsor_url: Option, + + /// Print the sponsor signature hash and exit. + /// + /// Computes the `fee_payer_signature_hash` for the transaction so that a sponsor + /// knows what hash to sign. The transaction is not sent. + #[arg( + long = "tempo.print-sponsor-hash", + conflicts_with_all = &["sponsor_signer", "sponsor_sig", "sponsor_url"] + )] + pub print_sponsor_hash: bool, + + /// Access key ID for Tempo Keychain signature transactions. + /// + /// Used during gas estimation to override the key_id that would normally be + /// recovered from the signature. + #[arg(long = "tempo.key-id")] + pub key_id: Option
, + + /// Enable expiring nonce mode for Tempo transactions. + /// + /// Sets nonce_key to U256::MAX, enabling time-bounded transaction validity via + /// `--tempo.valid-before` and `--tempo.valid-after`. + /// + /// The nonce is not sequential in this mode and defaults to 0. From the T12 hardfork + /// (TIP-1106) an explicitly supplied nonce is kept as an opaque discriminator, so otherwise + /// identical transactions with different nonces are distinct. Earlier hardforks reject any + /// nonce other than 0. `forge script` takes no nonce and from T12 uses each transaction's + /// ordinal across the complete script plan, including completed transactions on resume. + #[arg(long = "tempo.expiring-nonce", requires = "valid_before", conflicts_with = "expires")] + pub expiring_nonce: bool, + + /// Upper bound timestamp for Tempo expiring nonce transactions. + /// + /// The transaction is only valid before this unix timestamp. + /// Requires `--tempo.expiring-nonce`. + #[arg(long = "tempo.valid-before", conflicts_with = "expires")] + pub valid_before: Option, + + /// Lower bound timestamp for Tempo expiring nonce transactions. + /// + /// The transaction is only valid after this unix timestamp. + /// Requires `--tempo.expiring-nonce`. + #[arg(long = "tempo.valid-after")] + pub valid_after: Option, +} + +impl TempoOpts { + /// Returns `true` if any Tempo-specific option is set. + pub const fn is_tempo(&self) -> bool { + self.fee_token.is_some() + || self.expires.is_some() + || self.nonce_key.is_some() + || self.lane.is_some() + || self.sponsor.is_some() + || self.sponsor_signer.is_some() + || self.sponsor_sig.is_some() + || self.sponsor_url.is_some() + || self.print_sponsor_hash + || self.key_id.is_some() + || self.expiring_nonce + || self.valid_before.is_some() + || self.valid_after.is_some() + } + + /// Returns the absolute `valid_before` unix timestamp derived from `--tempo.expires`, if set. + pub fn expires_at(&self) -> Option { + let secs = self.expires?; + Some(now().as_secs() + secs) + } + + /// Resolves `--tempo.expires` into concrete expiring-nonce fields. + /// + /// This computes the relative deadline once so later calls to [`Self::apply`] reuse the same + /// `valid_before` timestamp instead of deriving a fresh one. + pub fn resolve_expires(&mut self) -> Option { + let ts = self.expires_at()?; + self.expiring_nonce = true; + self.valid_before = Some(ts); + self.expires = None; + Some(ts) + } + + /// Returns `true` if a sponsor signature should be attached before submission. + pub const fn has_sponsor_submission(&self) -> bool { + self.sponsor.is_some() || self.sponsor_signer.is_some() || self.sponsor_sig.is_some() + } + + /// Resolves sponsor CLI options into a reusable sponsor config for transaction submission. + pub async fn sponsor_config(&self) -> Result> { + let Some(sponsor) = self.sponsor else { + return Ok(None); + }; + + let signer = if let Some(spec) = &self.sponsor_signer { + Some(Arc::new(Box::pin(resolve_tempo_sponsor_signer(spec)).await?)) + } else { + None + }; + + if let Some(signer) = &signer { + let signer_address = signer.address(); + if signer_address != sponsor { + eyre::bail!( + "Tempo sponsor signer address {signer_address} does not match --tempo.sponsor {sponsor}" + ); + } + } + + if signer.is_none() && self.sponsor_sig.is_none() { + eyre::bail!( + "--tempo.sponsor requires either --tempo.sponsor-signer or --tempo.sponsor-sig" + ); + } + + Ok(Some(TempoSponsor::new(sponsor, signer, self.sponsor_sig))) + } + + /// Applies Tempo-specific options to a transaction request. + /// + /// All setters are no-ops for non-Tempo networks, so this is safe to call unconditionally. + /// + /// In expiring nonce mode `nonce` is the TIP-1106 discriminator rather than a sequential + /// nonce: an explicit value is kept as supplied and `None` selects 0. + pub fn apply(&self, tx: &mut N::TransactionRequest, nonce: Option) + where + N::TransactionRequest: FoundryTransactionBuilder, + { + // Handle expiring nonce mode: sets nonce_key=U256::MAX and never reads nonce state. + // --tempo.expires is a convenience alias that also sets valid_before = now + duration. + if self.expiring_nonce || self.expires.is_some() { + tx.set_nonce(nonce.unwrap_or_default()); + tx.set_nonce_key(U256::MAX); + } else { + if let Some(nonce) = nonce { + tx.set_nonce(nonce); + } + if let Some(nonce_key) = self.nonce_key { + tx.set_nonce_key(nonce_key); + } + } + + if let Some(fee_token) = self.fee_token { + tx.set_fee_token(fee_token); + } + + // --tempo.expires sets valid_before relative to now; --tempo.valid-before takes a raw + // unix timestamp. The two flags are mutually exclusive (enforced by clap). + let effective_valid_before = self.expires_at().or(self.valid_before); + if let Some(valid_before) = effective_valid_before + && let Some(v) = NonZeroU64::new(valid_before) + { + tx.set_valid_before(v); + } + if let Some(valid_after) = self.valid_after + && let Some(v) = NonZeroU64::new(valid_after) + { + tx.set_valid_after(v); + } + + if let Some(key_id) = self.key_id { + tx.set_key_id(key_id); + } + + // Force AA tx type if sponsoring or printing sponsor hash. + // Note: the fee_payer_signature is NOT set here. It must be applied AFTER + // gas estimation so that `--tempo.print-sponsor-hash` and + // `--tempo.sponsor-signature` produce identical gas estimates. Callers + // should call `set_fee_payer_signature` on the built tx request. + if (self.has_sponsor_submission() || self.sponsor_url.is_some() || self.print_sponsor_hash) + && tx.nonce_key().is_none() + { + tx.set_nonce_key(U256::ZERO); + } + } +} + +fn parse_signature(s: &str) -> Result { + Signature::from_str(s).map_err(|e| format!("invalid signature: {e}")) +} + +/// Largest TIP-1009 validity window accepted by any hardfork. The window was 30 seconds before +/// T11; the node enforces the limit of its active hardfork. +const MAX_EXPIRES_SECS: u64 = TempoHardfork::T11.expiring_nonce_max_expiry_secs(); + +/// Parses a seconds value for `--tempo.expires`, capped at [`MAX_EXPIRES_SECS`]. +fn parse_expires_seconds(s: &str) -> Result { + let secs: u64 = s + .parse() + .map_err(|_| format!("invalid value '{s}': expected an integer number of seconds"))?; + if secs > MAX_EXPIRES_SECS { + return Err(format!("expires must be at most {MAX_EXPIRES_SECS} seconds (got {secs})")); + } + Ok(secs) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::address; + use foundry_common::tempo::{BETA_USD_ADDRESS, OUSD_ADDRESS, PATH_USD_ADDRESS}; + + #[test] + fn parses_lane_arg() { + let opts = TempoOpts::try_parse_from(["", "--tempo.lane", "deploy"]).unwrap(); + assert_eq!(opts.lane.as_deref(), Some("deploy")); + assert!(opts.nonce_key.is_none()); + } + + #[test] + fn lane_conflicts_with_nonce_key() { + let err = + TempoOpts::try_parse_from(["", "--tempo.lane", "deploy", "--tempo.nonce-key", "1"]) + .unwrap_err(); + assert!( + err.to_string().contains("cannot be used with"), + "expected clap conflict error, got: {err}", + ); + } + + #[test] + fn parse_expires_flag() { + let opts = TempoOpts::try_parse_from(["", "--tempo.expires", "300"]).unwrap(); + assert_eq!(opts.expires, Some(300)); + + let opts = TempoOpts::try_parse_from(["", "--tempo.expires", "10"]).unwrap(); + assert_eq!(opts.expires, Some(10)); + + // exceeds the post-T11 maximum + assert!(TempoOpts::try_parse_from(["", "--tempo.expires", "301"]).is_err()); + + // conflicts with --tempo.expiring-nonce + assert!( + TempoOpts::try_parse_from([ + "", + "--tempo.expires", + "30", + "--tempo.expiring-nonce", + "--tempo.valid-before", + "999" + ]) + .is_err() + ); + } + + #[test] + fn resolve_expires_materializes_valid_before() { + let before = now().as_secs(); + let mut opts = TempoOpts::try_parse_from(["", "--tempo.expires", "10"]).unwrap(); + + let resolved = opts.resolve_expires().unwrap(); + let after = now().as_secs(); + + assert!(resolved >= before + 10); + assert!(resolved <= after + 10); + assert!(opts.expiring_nonce); + assert_eq!(opts.valid_before, Some(resolved)); + assert_eq!(opts.expires, None); + assert_eq!(opts.expires_at(), None); + } + + #[test] + fn parse_fee_token_address_id_and_symbol() { + let opts = TempoOpts::try_parse_from([ + "", + "--tempo.fee-token", + "0x20C0000000000000000000000000000000000002", + ]) + .unwrap(); + assert_eq!(opts.fee_token, Some(address!("0x20C0000000000000000000000000000000000002")),); + + // AlphaUSD token ID is 1u64 + let opts_with_id = TempoOpts::try_parse_from(["", "--tempo.fee-token", "1"]).unwrap(); + assert_eq!( + opts_with_id.fee_token, + Some(address!("0x20C0000000000000000000000000000000000001")), + ); + + let opts_with_symbol = + TempoOpts::try_parse_from(["", "--tempo.fee-token", "PathUSD"]).unwrap(); + assert_eq!(opts_with_symbol.fee_token, Some(PATH_USD_ADDRESS)); + + let opts_with_mixed_case_symbol = + TempoOpts::try_parse_from(["", "--tempo.fee-token", "bEtAuSd"]).unwrap(); + assert_eq!(opts_with_mixed_case_symbol.fee_token, Some(BETA_USD_ADDRESS)); + + let opts_with_ousd = TempoOpts::try_parse_from(["", "--tempo.fee-token", "OUSD"]).unwrap(); + assert_eq!(opts_with_ousd.fee_token, Some(OUSD_ADDRESS)); + + let err = TempoOpts::try_parse_from(["", "--tempo.fee-token", "unknownusd"]).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("expected address, numeric TIP-20 token id")); + assert!(msg.contains("PathUSD, AlphaUSD, BetaUSD, ThetaUSD, OUSD")); + } + + #[test] + fn parse_sponsor_signer() { + let opts = TempoOpts::try_parse_from([ + "", + "--tempo.sponsor", + "0x1111111111111111111111111111111111111111", + "--tempo.sponsor-signer", + "env://TEMPO_SPONSOR_PK", + ]) + .unwrap(); + + assert_eq!(opts.sponsor, Some(Address::repeat_byte(0x11))); + assert_eq!(opts.sponsor_signer.as_deref(), Some("env://TEMPO_SPONSOR_PK")); + assert!(opts.sponsor_sig.is_none()); + assert!(opts.is_tempo()); + assert!(opts.has_sponsor_submission()); + } + + #[test] + fn sponsor_signer_requires_sponsor() { + assert!( + TempoOpts::try_parse_from(["", "--tempo.sponsor-signer", "env://SPONSOR"]).is_err() + ); + } + + #[test] + fn parse_sponsor_signature_alias() { + let opts = TempoOpts::try_parse_from([ + "", + "--tempo.sponsor", + "0x1111111111111111111111111111111111111111", + "--tempo.sponsor-signature", + "0x0eb96ca19e8a77102767a41fc85a36afd5c61ccb09911cec5d3e86e193d9c5ae3a456401896b1b6055311536bf00a718568c744d8c1f9df59879e8350220ca182b", + ]) + .unwrap(); + + assert_eq!(opts.sponsor, Some(Address::repeat_byte(0x11))); + assert!(opts.sponsor_sig.is_some()); + } + + #[test] + fn print_sponsor_hash_allows_sponsor_address() { + let opts = TempoOpts::try_parse_from([ + "", + "--tempo.print-sponsor-hash", + "--tempo.sponsor", + "0x1111111111111111111111111111111111111111", + ]) + .unwrap(); + + assert!(opts.print_sponsor_hash); + assert_eq!(opts.sponsor, Some(Address::repeat_byte(0x11))); + } + + #[test] + fn print_sponsor_hash_conflicts_with_sponsor_signature() { + assert!( + TempoOpts::try_parse_from([ + "", + "--tempo.print-sponsor-hash", + "--tempo.sponsor", + "0x1111111111111111111111111111111111111111", + "--tempo.sponsor-signature", + "0x0eb96ca19e8a77102767a41fc85a36afd5c61ccb09911cec5d3e86e193d9c5ae3a456401896b1b6055311536bf00a718568c744d8c1f9df59879e8350220ca182b", + ]) + .is_err() + ); + } + + #[test] + fn parse_sponsor_url() { + let opts = + TempoOpts::try_parse_from(["", "--sponsor-url", "https://sponsor.tempo.xyz/tp_abc123"]) + .unwrap(); + assert_eq!(opts.sponsor_url.as_deref(), Some("https://sponsor.tempo.xyz/tp_abc123")); + assert!(opts.is_tempo()); + } + + #[test] + fn sponsor_url_alias() { + let opts = TempoOpts::try_parse_from([ + "", + "--tempo.sponsor-url", + "https://sponsor.tempo.xyz/tp_abc123", + ]) + .unwrap(); + assert_eq!(opts.sponsor_url.as_deref(), Some("https://sponsor.tempo.xyz/tp_abc123")); + } + + #[test] + fn sponsor_url_conflicts_with_sponsor() { + assert!( + TempoOpts::try_parse_from([ + "", + "--sponsor-url", + "https://sponsor.tempo.xyz", + "--tempo.sponsor", + "0x1111111111111111111111111111111111111111", + ]) + .is_err() + ); + } +} diff --git a/crates/cli/src/opts/tempo/session.rs b/crates/cli/src/opts/tempo/session.rs new file mode 100644 index 0000000000000..e813ea98295b3 --- /dev/null +++ b/crates/cli/src/opts/tempo/session.rs @@ -0,0 +1,458 @@ +use super::TempoOpts; +use crate::utils::now; +use alloy_primitives::{Address, B256}; +use eyre::{Result, WrapErr}; +use foundry_common::tempo::{ResolvedSessionSigner, resolve_live_session_signer}; +use foundry_wallets::{MultiWalletOpts, WalletOpts}; +use std::str::FromStr; + +/// Environment variable used to pass a Tempo wallet session to child commands. +pub const TEMPO_SESSION_ID_ENV: &str = "TEMPO_SESSION_ID"; + +impl TempoOpts { + /// Returns the effective session id, preferring the CLI flag over `TEMPO_SESSION_ID`. + pub fn session_id(&self) -> Result> { + if let Some(session) = self.session { + return Ok(Some(session)); + } + + let raw = match std::env::var(TEMPO_SESSION_ID_ENV) { + Ok(raw) => raw, + Err(std::env::VarError::NotPresent) => return Ok(None), + Err(std::env::VarError::NotUnicode(_)) => { + eyre::bail!("invalid {TEMPO_SESSION_ID_ENV}: value is not valid UTF-8"); + } + }; + + let raw = raw.trim(); + if raw.is_empty() { + return Ok(None); + } + + B256::from_str(raw).map(Some).wrap_err_with(|| { + format!("invalid {TEMPO_SESSION_ID_ENV}: expected 32-byte hex session id") + }) + } + + /// Resolves the configured Tempo wallet session for single-wallet commands. + /// + /// Explicit session configuration is fail-closed: if a session id was provided but no live + /// session can be loaded, callers must not fall back to any long-lived signer. + pub fn session_signer_for_wallet( + &self, + wallet: &WalletOpts, + expected_chain_id: u64, + ) -> Result> { + let Some(session_id) = self.session_id()? else { + return Ok(None); + }; + ensure_no_explicit_wallet_signer(wallet)?; + Ok(Some(resolve_session_signer(session_id, wallet.from, expected_chain_id)?)) + } + + /// Resolves the configured Tempo wallet session for multi-wallet commands. + pub fn session_signer_for_multi_wallet( + &self, + wallets: &MultiWalletOpts, + expected_sender: Option
, + expected_chain_id: u64, + ) -> Result> { + let Some(session_id) = self.session_id()? else { + return Ok(None); + }; + ensure_no_explicit_multi_wallet_signer(wallets)?; + Ok(Some(resolve_session_signer(session_id, expected_sender, expected_chain_id)?)) + } + + /// Resolves the configured Tempo wallet session for multi-chain commands. + /// + /// This validates the session and root sender without forcing a command-level chain. Callers + /// that select signers per transaction must scope the returned signer to + /// `session.chain_id`. + pub fn session_signer_for_multi_wallet_any_chain( + &self, + wallets: &MultiWalletOpts, + expected_sender: Option
, + ) -> Result> { + let Some(session_id) = self.session_id()? else { + return Ok(None); + }; + ensure_no_explicit_multi_wallet_signer(wallets)?; + let resolved = resolve_session(session_id)?; + ensure_expected_sender(expected_sender, resolved.access_key.account())?; + Ok(Some(resolved)) + } + + /// Resolves only the root sender for a configured Tempo wallet session. + /// + /// Multi-chain scripts need the sender before execution so `vm.startBroadcast()` records the + /// session root, but their chain validation must wait until broadcast sequences reveal the real + /// transaction chains. + pub fn session_sender_for_multi_wallet( + &self, + wallets: &MultiWalletOpts, + expected_sender: Option
, + ) -> Result> { + Ok(self + .session_signer_for_multi_wallet_any_chain(wallets, expected_sender)? + .map(|resolved| resolved.access_key.account())) + } +} + +/// Loads the live session signer and validates it against the command context. +/// +/// The session must be active on the requested chain, and any explicit sender must match the +/// session root account. +fn resolve_session_signer( + session_id: B256, + expected_sender: Option
, + expected_chain_id: u64, +) -> Result { + let resolved = resolve_session(session_id)?; + + if resolved.session.chain_id != expected_chain_id { + eyre::bail!( + "Tempo session {session_id:?} is for chain {}, but command is using chain {}", + resolved.session.chain_id, + expected_chain_id + ); + } + + ensure_expected_sender(expected_sender, resolved.access_key.account())?; + Ok(resolved) +} + +fn resolve_session(session_id: B256) -> Result { + resolve_live_session_signer(session_id, now().as_secs())? + .ok_or_else(|| eyre::eyre!("Tempo session {session_id:?} is not active or has no live key")) +} + +fn ensure_expected_sender(expected_sender: Option
, session_sender: Address) -> Result<()> { + if let Some(from) = expected_sender + && from != session_sender + { + eyre::bail!("sender {from} does not match Tempo session root account {session_sender}"); + } + + Ok(()) +} + +/// Rejects single-wallet signer options when a Tempo session is already selected. +/// +/// Session signing must fail closed instead of falling back to a long-lived or explicit signer. +fn ensure_no_explicit_wallet_signer(wallet: &WalletOpts) -> Result<()> { + if wallet.has_explicit_signer() { + eyre::bail!( + "--tempo.session/TEMPO_SESSION_ID cannot be combined with explicit wallet signer options" + ); + } + Ok(()) +} + +trait ExplicitSignerOpts { + fn has_explicit_signer(&self) -> bool; +} + +impl ExplicitSignerOpts for WalletOpts { + fn has_explicit_signer(&self) -> bool { + self.raw.interactive + || self.raw.private_key.is_some() + || self.raw.mnemonic.is_some() + || self.keystore_path.is_some() + || self.keystore_account_name.is_some() + || self.ledger + || self.trezor + || self.aws + || self.gcp + || self.turnkey + || self.tempo_access_key.is_some() + } +} + +/// Rejects multi-wallet signer options when a Tempo session is already selected. +/// +/// Script and broadcast session signing must not fall back to long-lived, browser, or explicit +/// signers. +fn ensure_no_explicit_multi_wallet_signer(wallets: &MultiWalletOpts) -> Result<()> { + if wallets.has_explicit_signer() { + eyre::bail!( + "--tempo.session/TEMPO_SESSION_ID cannot be combined with explicit wallet signer options" + ); + } + Ok(()) +} + +impl ExplicitSignerOpts for MultiWalletOpts { + fn has_explicit_signer(&self) -> bool { + self.interactive + || self.interactives > 0 + || self.private_key.is_some() + || self.private_keys.is_some() + || self.mnemonics.is_some() + || self.keystore_paths.is_some() + || self.keystore_account_names.is_some() + || self.ledger + || self.trezor + || self.aws + || self.gcp + || self.turnkey + || self.browser.browser + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_signer::{Signer, SignerSync}; + use clap::Parser; + use foundry_common::tempo::{ + GeneratedSessionKey, SessionAuthorizationRequest, SessionEntry, TEMPO_HOME_ENV, + upsert_session_entry, + }; + use std::{num::NonZeroU64, sync::Mutex}; + use tempo_primitives::transaction::{CallScope, PrimitiveSignature}; + + const SESSION_PRIVATE_KEY: &str = + "0x59c6995e998f97a5a004497e5da3b5d2b2b66a87f064d39c44da0b6d6e4f8ff0"; + const ROOT_PRIVATE_KEY: &str = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + + static ENV_MUTEX: Mutex<()> = Mutex::new(()); + + fn with_clean_session_env(test: impl FnOnce()) { + with_session_env(None, test); + } + + fn with_clean_session_home(test: impl FnOnce()) { + let tmp = tempfile::tempdir().unwrap(); + with_session_env(Some(tmp.path()), test); + } + + fn with_session_env(tempo_home: Option<&std::path::Path>, test: impl FnOnce()) { + let _guard = ENV_MUTEX.lock().unwrap(); + // SAFETY: serialized with other tests that mutate Tempo env vars. + unsafe { + std::env::remove_var(TEMPO_SESSION_ID_ENV); + if let Some(tempo_home) = tempo_home { + std::env::set_var(TEMPO_HOME_ENV, tempo_home); + } + } + test(); + // SAFETY: serialized with other tests that mutate Tempo env vars. + unsafe { + std::env::remove_var(TEMPO_SESSION_ID_ENV); + std::env::remove_var(TEMPO_HOME_ENV); + } + } + + fn session_id(byte: u8) -> B256 { + B256::repeat_byte(byte) + } + + fn active_session_entry(session_id: B256) -> SessionEntry { + let foundry_wallets::WalletSigner::Local(root) = + foundry_wallets::utils::create_private_key_signer(ROOT_PRIVATE_KEY).unwrap() + else { + unreachable!("a raw private key always creates a local signer") + }; + let key = GeneratedSessionKey::from_private_key(SESSION_PRIVATE_KEY).unwrap(); + let prepared = SessionAuthorizationRequest { + session_id, + root_account: root.address(), + chain_id: 4217, + key_address: key.address(), + expiry: NonZeroU64::new(u64::MAX).unwrap(), + scope: vec![CallScope { target: Address::repeat_byte(0xaa), selector_rules: vec![] }], + spend_limits: vec![], + } + .prepare(0) + .unwrap(); + let signature = root.sign_hash_sync(&prepared.authorization.signature_hash()).unwrap(); + let authorization = + prepared.authorization.clone().into_signed(PrimitiveSignature::Secp256k1(signature)); + prepared.into_active_entry(key, &authorization).unwrap() + } + + fn root_address() -> Address { + foundry_wallets::utils::create_private_key_signer(ROOT_PRIVATE_KEY).unwrap().address() + } + + #[test] + fn parses_tempo_session_cli_arg() { + with_clean_session_env(|| { + let id = session_id(0x11); + let opts = + TempoOpts::try_parse_from(["", "--tempo.session", &format!("{id:?}")]).unwrap(); + + assert_eq!(opts.session, Some(id)); + assert_eq!(opts.session_id().unwrap(), Some(id)); + }); + } + + #[test] + fn tempo_session_env_is_used_when_cli_arg_is_absent() { + with_clean_session_env(|| { + let id = session_id(0x22); + // SAFETY: serialized with other tests that mutate Tempo env vars. + unsafe { std::env::set_var(TEMPO_SESSION_ID_ENV, format!("{id:?}")) }; + let opts = TempoOpts::default(); + + assert_eq!(opts.session_id().unwrap(), Some(id)); + }); + } + + #[test] + fn tempo_session_cli_arg_overrides_env() { + with_clean_session_env(|| { + let env_id = session_id(0x33); + let cli_id = session_id(0x44); + // SAFETY: serialized with other tests that mutate Tempo env vars. + unsafe { std::env::set_var(TEMPO_SESSION_ID_ENV, format!("{env_id:?}")) }; + + let opts = + TempoOpts::try_parse_from(["", "--tempo.session", &format!("{cli_id:?}")]).unwrap(); + + assert_eq!(opts.session_id().unwrap(), Some(cli_id)); + }); + } + + #[test] + fn invalid_tempo_session_env_fails_closed() { + with_clean_session_env(|| { + // SAFETY: serialized with other tests that mutate Tempo env vars. + unsafe { std::env::set_var(TEMPO_SESSION_ID_ENV, "not-a-session-id") }; + let err = TempoOpts::default().session_id().unwrap_err(); + + assert!(err.to_string().contains(TEMPO_SESSION_ID_ENV), "{err}"); + }); + } + + #[cfg(unix)] + #[test] + fn non_unicode_tempo_session_env_fails_closed() { + use std::{ffi::OsString, os::unix::ffi::OsStringExt}; + + with_clean_session_env(|| { + // SAFETY: serialized with other tests that mutate Tempo env vars. + unsafe { + std::env::set_var(TEMPO_SESSION_ID_ENV, OsString::from_vec(vec![0xff])); + } + let err = TempoOpts::default().session_id().unwrap_err(); + + assert!(err.to_string().contains("value is not valid UTF-8"), "{err}"); + }); + } + + #[test] + fn tempo_session_rejects_explicit_wallet_signers() { + let opts = TempoOpts { session: Some(session_id(0x55)), ..Default::default() }; + let wallet = WalletOpts { + raw: foundry_wallets::RawWalletOpts { + private_key: Some("0xdead".to_string()), + ..Default::default() + }, + ..Default::default() + }; + + let err = opts.session_signer_for_wallet(&wallet, 4217).unwrap_err(); + assert!(err.to_string().contains("explicit wallet signer"), "{err}"); + } + + #[test] + fn absent_tempo_session_does_not_reject_explicit_wallet_signers() { + with_clean_session_env(|| { + let opts = TempoOpts::default(); + let wallet = WalletOpts { + raw: foundry_wallets::RawWalletOpts { + private_key: Some("0xdead".to_string()), + ..Default::default() + }, + ..Default::default() + }; + + assert!(opts.session_signer_for_wallet(&wallet, 4217).unwrap().is_none()); + }); + } + + #[test] + fn tempo_session_rejects_explicit_multi_wallet_signers() { + let opts = TempoOpts { session: Some(session_id(0x66)), ..Default::default() }; + let wallets = + MultiWalletOpts { private_key: Some("0xdead".to_string()), ..Default::default() }; + + let err = opts.session_signer_for_multi_wallet(&wallets, None, 4217).unwrap_err(); + assert!(err.to_string().contains("explicit wallet signer"), "{err}"); + } + + #[test] + fn absent_tempo_session_does_not_reject_explicit_multi_wallet_signers() { + with_clean_session_env(|| { + let opts = TempoOpts::default(); + let wallets = + MultiWalletOpts { private_key: Some("0xdead".to_string()), ..Default::default() }; + + assert!(opts.session_signer_for_multi_wallet(&wallets, None, 4217).unwrap().is_none()); + }); + } + + #[test] + fn tempo_session_rejects_wrong_chain() { + with_clean_session_home(|| { + let id = session_id(0x77); + upsert_session_entry(active_session_entry(id)).unwrap(); + let opts = TempoOpts { session: Some(id), ..Default::default() }; + + let err = opts.session_signer_for_wallet(&WalletOpts::default(), 1).unwrap_err(); + + assert!(err.to_string().contains("is for chain 4217"), "{err}"); + }); + } + + #[test] + fn tempo_session_sender_does_not_validate_chain() { + with_clean_session_home(|| { + let id = session_id(0x99); + upsert_session_entry(active_session_entry(id)).unwrap(); + let opts = TempoOpts { session: Some(id), ..Default::default() }; + let wallets = MultiWalletOpts::default(); + + let sender = opts.session_sender_for_multi_wallet(&wallets, None).unwrap(); + + assert_eq!(sender, Some(root_address())); + }); + } + + #[test] + fn tempo_session_signer_any_chain_returns_session_chain() { + with_clean_session_home(|| { + let id = session_id(0xaa); + upsert_session_entry(active_session_entry(id)).unwrap(); + let opts = TempoOpts { session: Some(id), ..Default::default() }; + let wallets = MultiWalletOpts::default(); + + let session = opts + .session_signer_for_multi_wallet_any_chain(&wallets, Some(root_address())) + .unwrap() + .unwrap(); + + assert_eq!(session.session.chain_id, 4217); + assert_eq!(session.access_key.account(), root_address()); + }); + } + + #[test] + fn tempo_session_rejects_sender_mismatch() { + with_clean_session_home(|| { + let id = session_id(0x88); + upsert_session_entry(active_session_entry(id)).unwrap(); + let opts = TempoOpts { session: Some(id), ..Default::default() }; + let wallets = MultiWalletOpts::default(); + + let err = opts + .session_signer_for_multi_wallet(&wallets, Some(Address::repeat_byte(0x22)), 4217) + .unwrap_err(); + + assert!(err.to_string().contains("does not match Tempo session root account"), "{err}"); + }); + } +} diff --git a/crates/cli/src/opts/tracing.rs b/crates/cli/src/opts/tracing.rs new file mode 100644 index 0000000000000..4249c0f7300f1 --- /dev/null +++ b/crates/cli/src/opts/tracing.rs @@ -0,0 +1,154 @@ +//! CLI arguments for configuring trace rendering. + +use std::str::FromStr; + +use alloy_primitives::{Address, map::AddressHashMap}; +use clap::{Parser, ValueHint}; +use foundry_config::TracingConfig; +use serde::Serialize; + +/// CLI arguments for trace rendering. +#[derive(Clone, Debug, Default, Serialize, Parser)] +#[command(about = None, long_about = None)] +pub struct TracingArgs { + /// Identify internal functions in traces. + /// + /// This will trace internal functions and decode stack parameters. + /// + /// Parameters stored in memory (such as bytes or arrays) are currently decoded only when a + /// single function is matched, similarly to `--debug`, for performance reasons. + #[arg(long, help_heading = "Trace options")] + #[serde(skip)] + pub decode_internal: bool, + + /// Maximum depth of rendered traces. + #[arg(long, value_name = "DEPTH", help_heading = "Trace options")] + #[serde(skip_serializing_if = "Option::is_none")] + pub trace_depth: Option, + + /// Disable labels in traces. + #[arg(long, help_heading = "Trace options")] + #[serde(skip)] + pub disable_labels: bool, + + /// Hide addresses in trace parameters when a label is available. + #[arg(long, help_heading = "Trace options")] + #[serde(skip)] + pub compact_labels: bool, + + /// Label addresses in traces. + /// + /// Example: 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045:vitalik.eth + #[arg( + long = "labels", + visible_alias = "label", + value_name = "ADDRESS:LABEL", + value_hint = ValueHint::Other, + help_heading = "Trace options" + )] + #[serde(skip_serializing_if = "Vec::is_empty")] + pub labels: Vec, + + /// Disable external trace identification using Sourcify or Etherscan. + #[arg(long, help_heading = "Trace options")] + #[serde(skip)] + pub disable_external_identification: bool, +} + +impl TracingArgs { + /// Resolves CLI overrides against the configured trace rendering settings. + pub fn resolve(&self, config: &TracingConfig, verbosity: u8) -> TracingConfig { + let mut tracing = config.clone(); + tracing.verbosity = tracing.verbosity.max(verbosity); + tracing.labels.extend(self.parsed_labels()); + tracing.disable_labels |= self.disable_labels; + tracing.compact_labels |= self.compact_labels; + tracing.trace_depth = self.trace_depth.or(tracing.trace_depth); + tracing.decode_internal |= self.decode_internal; + if self.disable_external_identification { + tracing.external_identification_timeout = 0; + } + tracing + } + + /// Resolves trace rendering settings for an RPC call-tracer response. + pub fn resolve_call_tracer(&self, config: &TracingConfig, verbosity: u8) -> TracingConfig { + let mut tracing = self.resolve(config, verbosity); + tracing.decode_internal = false; + tracing + } + + fn parsed_labels(&self) -> AddressHashMap { + self.labels + .iter() + .filter_map(|label| { + let (address, label) = label.split_once(':')?; + let address = Address::from_str(address).ok()?; + Some((address, label.to_string())) + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + + use super::*; + + #[test] + fn resolve_merges_cli_overrides() { + let address = Address::with_last_byte(1); + let config = TracingConfig { + verbosity: 2, + labels: AddressHashMap::from_iter([(address, "config".to_string())]), + disable_labels: false, + compact_labels: false, + trace_depth: Some(1), + decode_internal: false, + external_identification_timeout: 10, + }; + let args = TracingArgs { + decode_internal: true, + trace_depth: Some(2), + disable_labels: true, + compact_labels: true, + labels: vec![format!("{address}:cli")], + disable_external_identification: true, + }; + + let tracing = args.resolve(&config, 3); + assert_eq!(tracing.verbosity, 3); + assert_eq!(tracing.labels.get(&address), Some(&"cli".to_string())); + assert!(tracing.disable_labels); + assert!(tracing.compact_labels); + assert_eq!(tracing.trace_depth, Some(2)); + assert!(tracing.decode_internal); + assert_eq!(tracing.external_identification_timeout, 0); + } + + #[test] + fn resolve_preserves_external_identification_timeout() { + let config = TracingConfig { external_identification_timeout: 17, ..Default::default() }; + + let tracing = TracingArgs::default().resolve(&config, 0); + + assert_eq!(tracing.external_identification_timeout, 17); + } + + #[test] + fn tracing_verbosity_is_independent_from_global_verbosity() { + let config = TracingConfig { verbosity: 5, ..Default::default() }; + + assert_eq!(TracingArgs::default().resolve(&config, 1).verbosity, 5); + assert_eq!(TracingArgs::default().resolve(&config, 0).verbosity, 5); + assert_eq!(TracingArgs::default().resolve(&config, 6).verbosity, 6); + } + + #[test] + fn call_tracer_does_not_decode_internal_functions() { + let config = TracingConfig { decode_internal: true, ..Default::default() }; + let args = TracingArgs { decode_internal: true, ..Default::default() }; + + assert!(!args.resolve_call_tracer(&config, 0).decode_internal); + } +} diff --git a/crates/cli/src/opts/transaction.rs b/crates/cli/src/opts/transaction.rs index 45557932f7a11..86b5a64892766 100644 --- a/crates/cli/src/opts/transaction.rs +++ b/crates/cli/src/opts/transaction.rs @@ -1,12 +1,15 @@ use std::str::FromStr; +use super::TempoOpts; use crate::utils::{parse_ether_value, parse_json}; use alloy_eips::{eip2930::AccessList, eip7702::SignedAuthorization}; +use alloy_network::{Network, TransactionBuilder}; use alloy_primitives::{Address, U64, U256, hex}; use alloy_rlp::Decodable; use clap::Parser; +use foundry_common::FoundryTransactionBuilder; -/// CLI helper to parse a EIP-7702 authorization list. +/// CLI helper to parse an EIP-7702 authorization list. /// Can be either a hex-encoded signed authorization or an address. #[derive(Clone, Debug)] pub enum CliAuthorizationList { @@ -25,7 +28,7 @@ impl FromStr for CliAuthorizationList { } else if let Ok(auth) = SignedAuthorization::decode(&mut hex::decode(s)?.as_ref()) { Ok(Self::Signed(auth)) } else { - eyre::bail!("Failed to decode authorization") + eyre::bail!("Failed to decode authorization"); } } } @@ -76,11 +79,18 @@ pub struct TransactionOpts { #[arg(long)] pub legacy: bool, - /// Send a EIP-4844 blob transaction. + /// Send a blob transaction using EIP-7594 (PeerDAS) format. + /// + /// Note: Use with `--eip4844` for the legacy EIP-4844 format. #[arg(long, conflicts_with = "legacy")] pub blob: bool, - /// Gas price for EIP-4844 blob transaction. + /// Send a blob transaction using EIP-4844 (legacy) format instead of EIP-7594. Must be used + /// with `--blob`. + #[arg(long, conflicts_with = "legacy", requires = "blob")] + pub eip4844: bool, + + /// Gas price for EIP-7594/EIP-4844 blob transaction. #[arg(long, conflicts_with = "legacy", value_parser = parse_ether_value, env = "ETH_BLOB_GAS_PRICE", value_name = "BLOB_PRICE")] pub blob_gas_price: Option, @@ -88,7 +98,7 @@ pub struct TransactionOpts { /// /// Can be either a hex-encoded signed authorization or an address. #[arg(long, conflicts_with_all = &["legacy", "blob"])] - pub auth: Option, + pub auth: Vec, /// EIP-2930 access list. /// @@ -97,6 +107,44 @@ pub struct TransactionOpts { /// the `cast access-list` command. #[arg(long, value_parser = parse_json::)] pub access_list: Option>, + + #[command(flatten)] + pub tempo: TempoOpts, +} + +impl TransactionOpts { + /// Applies gas, value, fee, and network-specific options to a transaction request. + pub fn apply(&self, tx: &mut N::TransactionRequest, legacy: bool) + where + N::TransactionRequest: FoundryTransactionBuilder, + { + if let Some(gas_limit) = self.gas_limit { + tx.set_gas_limit(gas_limit.to()); + } + + if let Some(value) = self.value { + tx.set_value(value); + } + + if let Some(gas_price) = self.gas_price { + if legacy { + tx.set_gas_price(gas_price.to()); + } else { + tx.set_max_fee_per_gas(gas_price.to()); + } + } + + if !legacy && let Some(priority_fee) = self.priority_gas_price { + tx.set_max_priority_fee_per_gas(priority_fee.to()); + } + + if let Some(max_blob_fee) = self.blob_gas_price { + tx.set_max_fee_per_blob_gas(max_blob_fee.to()) + } + + // set network-specific options + self.tempo.apply::(tx, self.nonce.map(|n| n.to())); + } } #[cfg(test)] diff --git a/crates/cli/src/utils/abi.rs b/crates/cli/src/utils/abi.rs index e159db9c05ab0..99d83cfb001a1 100644 --- a/crates/cli/src/utils/abi.rs +++ b/crates/cli/src/utils/abi.rs @@ -2,57 +2,48 @@ use alloy_chains::Chain; use alloy_ens::NameOrAddress; use alloy_json_abi::Function; use alloy_primitives::{Address, hex}; -use alloy_provider::{Provider, network::AnyNetwork}; +use alloy_provider::{Network, Provider}; use eyre::{OptionExt, Result}; -use foundry_block_explorers::EtherscanApiVersion; use foundry_common::abi::{ encode_function_args, encode_function_args_raw, get_func, get_func_etherscan, }; use futures::future::join_all; -async fn resolve_name_args>(args: &[String], provider: &P) -> Vec { - join_all(args.iter().map(|arg| async { - if arg.contains('.') { - let addr = NameOrAddress::Name(arg.to_string()).resolve(provider).await; - match addr { - Ok(addr) => addr.to_string(), - Err(_) => arg.to_string(), - } - } else { - arg.to_string() - } - })) - .await -} - -pub async fn parse_function_args>( +pub async fn parse_function_args>( sig: &str, args: Vec, to: Option
, chain: Chain, provider: &P, etherscan_api_key: Option<&str>, - etherscan_api_version: EtherscanApiVersion, + etherscan_api_url: Option<&str>, ) -> Result<(Vec, Option)> { if sig.trim().is_empty() { - eyre::bail!("Function signature or calldata must be provided.") + eyre::bail!("Function signature or calldata must be provided."); } let args = resolve_name_args(&args, provider).await; + // Try to decode as hex calldata first, otherwise treat as function signature if let Ok(data) = hex::decode(sig) { return Ok((data, None)); + } else if sig.starts_with("0x") || sig.starts_with("0X") { + let e = hex::decode(sig).unwrap_err(); + eyre::bail!("Invalid hex calldata '{}': {e}", sig); } let func = if sig.contains('(') { // a regular function signature with parentheses get_func(sig)? } else { + info!( + "function signature does not contain parentheses, fetching function data from Etherscan" + ); let etherscan_api_key = etherscan_api_key.ok_or_eyre( - "If you wish to fetch function data from Etherscan, please provide an Etherscan API key.", + "Function signature does not contain parentheses. If you wish to fetch function data from Etherscan, please provide an API key.", )?; let to = to.ok_or_eyre("A 'to' address must be provided to fetch function data.")?; - get_func_etherscan(sig, to, &args, chain, etherscan_api_key, etherscan_api_version).await? + get_func_etherscan(sig, to, &args, chain, etherscan_api_key, etherscan_api_url).await? }; if to.is_none() { @@ -62,3 +53,21 @@ pub async fn parse_function_args>( Ok((encode_function_args(&func, &args)?, Some(func))) } } + +async fn resolve_name_args>( + args: &[String], + provider: &P, +) -> Vec { + join_all(args.iter().map(|arg| async { + if arg.contains('.') { + let addr = NameOrAddress::Name(arg.clone()).resolve(provider).await; + match addr { + Ok(addr) => addr.to_string(), + Err(_) => arg.clone(), + } + } else { + arg.clone() + } + })) + .await +} diff --git a/crates/cli/src/utils/allocator.rs b/crates/cli/src/utils/allocator.rs index 2431e35d08cff..2af3e6a9b48a5 100644 --- a/crates/cli/src/utils/allocator.rs +++ b/crates/cli/src/utils/allocator.rs @@ -1,10 +1,11 @@ //! Abstract global allocator implementation. -#[cfg(feature = "mimalloc")] -use mimalloc as _; #[cfg(all(feature = "jemalloc", unix))] use tikv_jemallocator as _; +#[cfg(feature = "mimalloc")] +use mimalloc as _; + // If neither jemalloc nor mimalloc are enabled, use explicitly the system allocator. // By default jemalloc is enabled on Unix systems. cfg_if::cfg_if! { @@ -20,8 +21,7 @@ cfg_if::cfg_if! { // Wrap the allocator if the `tracy-allocator` feature is enabled. cfg_if::cfg_if! { if #[cfg(feature = "tracy-allocator")] { - type AllocatorWrapper = tracy_client::ProfiledAllocator; - tracy_client::register_demangler!(); + type AllocatorWrapper = tracing_tracy::client::ProfiledAllocator; const fn new_allocator_wrapper() -> AllocatorWrapper { AllocatorWrapper::new(AllocatorInner {}, 100) } diff --git a/crates/cli/src/utils/cmd.rs b/crates/cli/src/utils/cmd.rs index d73fa63416aec..7781b89086bea 100644 --- a/crates/cli/src/utils/cmd.rs +++ b/crates/cli/src/utils/cmd.rs @@ -1,40 +1,36 @@ -use alloy_json_abi::JsonAbi; -use alloy_primitives::{Address, Bytes, map::HashMap}; +use alloy_dyn_abi::{DynSolValue, Specifier}; +use alloy_json_abi::{Constructor, JsonAbi}; use eyre::{Result, WrapErr}; use foundry_common::{ - ContractsByArtifact, TestFunctionExt, compile::ProjectCompiler, fs, selectors::SelectorKind, - shell, + TestFunctionExt, fmt::parse_tokens, fs, fs::json_files, selectors::SelectorKind, shell, }; use foundry_compilers::{ - Artifact, ArtifactId, ProjectCompileOutput, - artifacts::{CompactBytecode, Settings}, - cache::{CacheEntry, CompilerCache}, - utils::read_json_file, + Artifact, ArtifactId, ProjectCompileOutput, artifacts::CompactBytecode, utils::read_json_file, +}; +use foundry_config::{ + Chain, Config, NamedChain, + error::ExtractConfigError, + figment::{Figment, Provider}, }; -use foundry_config::{Chain, Config, NamedChain, error::ExtractConfigError, figment::Figment}; -use foundry_debugger::Debugger; use foundry_evm::{ + core::evm::FoundryEvmNetwork, executors::{DeployResult, EvmError, RawCallResult}, opts::EvmOpts, traces::{ - CallTraceDecoder, CallTraceDecoderBuilder, TraceKind, Traces, - debug::{ContractSources, DebugTraceIdentifier}, - decode_trace_arena, - identifier::{SignaturesCache, SignaturesIdentifier, TraceIdentifiers}, - render_trace_arena_inner, + CallTraceDecoder, TraceKind, Traces, decode_trace_arena, identifier::SignaturesCache, + prune_trace_depth, render_trace_arena_inner, trace_arena_at_depth, }, }; use std::{ fmt::Write, path::{Path, PathBuf}, - str::FromStr, }; use yansi::Paint; -/// Given a `Project`'s output, removes the matching ABI, Bytecode and -/// Runtime Bytecode of the given contract. +/// Given a `Project`'s output, finds the contract by path and name and returns its +/// ABI, creation bytecode, and `ArtifactId`. #[track_caller] -pub fn remove_contract( +pub fn find_contract_artifacts( output: ProjectCompileOutput, path: &Path, name: &str, @@ -58,7 +54,7 @@ pub fn remove_contract( Did you mean `{suggestion}`?"# ); } - eyre::bail!(err) + eyre::bail!(err); }; let abi = contract @@ -74,47 +70,6 @@ pub fn remove_contract( Ok((abi, bin, id)) } -/// Helper function for finding a contract by ContractName -// TODO: Is there a better / more ergonomic way to get the artifacts given a project and a -// contract name? -pub fn get_cached_entry_by_name( - cache: &CompilerCache, - name: &str, -) -> Result<(PathBuf, CacheEntry)> { - let mut cached_entry = None; - let mut alternatives = Vec::new(); - - for (abs_path, entry) in &cache.files { - for artifact_name in entry.artifacts.keys() { - if artifact_name == name { - if cached_entry.is_some() { - eyre::bail!( - "contract with duplicate name `{}`. please pass the path instead", - name - ) - } - cached_entry = Some((abs_path.to_owned(), entry.to_owned())); - } else { - alternatives.push(artifact_name); - } - } - } - - if let Some(entry) = cached_entry { - return Ok(entry); - } - - let mut err = format!("could not find artifact: `{name}`"); - if let Some(suggestion) = super::did_you_mean(name, &alternatives).pop() { - err = format!( - r#"{err} - - Did you mean `{suggestion}`?"# - ); - } - eyre::bail!(err) -} - /// Returns error if constructor has arguments. pub fn ensure_clean_constructor(abi: &JsonAbi) -> Result<()> { if let Some(constructor) = &abi.constructor @@ -164,8 +119,10 @@ pub fn init_progress(len: u64, label: &str) -> indicatif::ProgressBar { /// True if the network calculates gas costs differently. pub fn has_different_gas_calc(chain_id: u64) -> bool { - if let Some(chain) = Chain::from(chain_id).named() { - return chain.is_arbitrum() + let chain = Chain::from(chain_id); + if let Some(chain) = chain.named() { + return chain.is_tempo() + || chain.is_arbitrum() || chain.is_elastic() || matches!( chain, @@ -173,17 +130,25 @@ pub fn has_different_gas_calc(chain_id: u64) -> bool { | NamedChain::AcalaMandalaTestnet | NamedChain::AcalaTestnet | NamedChain::Etherlink - | NamedChain::EtherlinkTestnet + | NamedChain::EtherlinkShadownet | NamedChain::Karura | NamedChain::KaruraTestnet + | NamedChain::Kusama | NamedChain::Mantle | NamedChain::MantleSepolia - | NamedChain::MantleTestnet + | NamedChain::MegaEth + | NamedChain::MegaEthTestnet + | NamedChain::Metis + | NamedChain::Monad + | NamedChain::MonadTestnet | NamedChain::Moonbase | NamedChain::Moonbeam | NamedChain::MoonbeamDev | NamedChain::Moonriver - | NamedChain::Metis + | NamedChain::Plume + | NamedChain::PlumeTestnet + | NamedChain::Polkadot + | NamedChain::PolkadotTestnet ); } false @@ -210,7 +175,19 @@ pub trait LoadConfig { /// Load and sanitize the [`Config`] based on the options provided in self. fn load_config(&self) -> Result { - self.load_config_no_warnings().inspect(emit_warnings) + load_config_from_provider(self.figment()) + } + + /// Loads config and installs missing project dependencies. + fn load_config_with_dependencies(&self) -> Result { + let mut config = self.load_config()?; + self.install_missing_dependencies(&mut config)?; + Ok(config) + } + + /// Installs missing dependencies, reloading config only for automatic remapping discovery. + fn install_missing_dependencies(&self, config: &mut Config) -> Result<(), ExtractConfigError> { + crate::install::install_missing_dependencies(config, || self.load_config()) } /// Same as [`LoadConfig::load_config`] but does not emit warnings. @@ -240,6 +217,10 @@ pub trait LoadConfig { let mut evm_opts = figment.extract::().map_err(ExtractConfigError::new)?; let config = Config::from_provider(figment)?.sanitized(); + if config.networks != Default::default() { + evm_opts.networks = config.networks; + } + // update the fork url if it was an alias if let Some(fork_url) = config.get_rpc_url() { trace!(target: "forge::config", ?fork_url, "Update EvmOpts fork url"); @@ -250,6 +231,11 @@ pub trait LoadConfig { } } +/// Loads and sanitizes [`Config`] from a provider and emits generated warnings. +pub fn load_config_from_provider(provider: T) -> Result { + Config::from_provider(provider).map(Config::sanitized).inspect(emit_warnings) +} + impl LoadConfig for T where for<'a> Figment: From<&'a T>, @@ -281,6 +267,30 @@ pub fn read_constructor_args_file(constructor_args_path: PathBuf) -> Result Result> { + if constructor.inputs.len() != constructor_args.len() { + eyre::bail!( + "Constructor argument count mismatch: expected {} but got {}", + constructor.inputs.len(), + constructor_args.len() + ); + } + + let mut params = Vec::with_capacity(constructor.inputs.len()); + for (input, arg) in constructor.inputs.iter().zip(constructor_args) { + let ty = input + .resolve() + .wrap_err_with(|| format!("Could not resolve constructor arg: input={input}"))?; + params.push((ty, arg)); + } + let params = params.iter().map(|(ty, arg)| (ty, arg.as_str())); + parse_tokens(params).map_err(Into::into) +} + /// A slimmed down return from the executor used for returning minimal trace + gas metering info #[derive(Debug)] pub struct TraceResult { @@ -291,22 +301,25 @@ pub struct TraceResult { impl TraceResult { /// Create a new [`TraceResult`] from a [`RawCallResult`]. - pub fn from_raw(raw: RawCallResult, trace_kind: TraceKind) -> Self { + pub fn from_raw( + raw: RawCallResult, + trace_kind: TraceKind, + ) -> Self { let RawCallResult { gas_used, traces, reverted, .. } = raw; Self { success: !reverted, traces: traces.map(|arena| vec![(trace_kind, arena)]), gas_used } } } -impl From for TraceResult { - fn from(result: DeployResult) -> Self { +impl From> for TraceResult { + fn from(result: DeployResult) -> Self { Self::from_raw(result.raw, TraceKind::Deployment) } } -impl TryFrom> for TraceResult { - type Error = EvmError; +impl TryFrom, EvmError>> for TraceResult { + type Error = EvmError; - fn try_from(value: Result) -> Result { + fn try_from(value: Result, EvmError>) -> Result { match value { Ok(result) => Ok(Self::from(result)), Err(EvmError::Execution(err)) => Ok(Self::from_raw(err.raw, TraceKind::Deployment)), @@ -315,107 +328,18 @@ impl TryFrom> for TraceResult { } } -impl From for TraceResult { - fn from(result: RawCallResult) -> Self { +impl From> for TraceResult { + fn from(result: RawCallResult) -> Self { Self::from_raw(result, TraceKind::Execution) } } -impl TryFrom> for TraceResult { - type Error = EvmError; - - fn try_from(value: Result) -> Result { - match value { - Ok(result) => Ok(Self::from(result)), - Err(err) => Err(EvmError::from(err)), - } - } -} - -/// labels the traces, conditionally prints them or opens the debugger -#[expect(clippy::too_many_arguments)] -pub async fn handle_traces( - mut result: TraceResult, - config: &Config, - chain: Option, - contracts_bytecode: &HashMap, - labels: Vec, - with_local_artifacts: bool, - debug: bool, - decode_internal: bool, - disable_label: bool, -) -> Result<()> { - let (known_contracts, mut sources) = if with_local_artifacts { - let _ = sh_println!("Compiling project to generate artifacts"); - let project = config.project()?; - let compiler = ProjectCompiler::new(); - let output = compiler.compile(&project)?; - ( - Some(ContractsByArtifact::new( - output.artifact_ids().map(|(id, artifact)| (id, artifact.clone().into())), - )), - ContractSources::from_project_output(&output, project.root(), None)?, - ) - } else { - (None, ContractSources::default()) - }; - - let labels = labels.iter().filter_map(|label_str| { - let mut iter = label_str.split(':'); - - if let Some(addr) = iter.next() - && let (Ok(address), Some(label)) = (Address::from_str(addr), iter.next()) - { - return Some((address, label.to_string())); - } - None - }); - let config_labels = config.labels.clone().into_iter(); - - let mut builder = CallTraceDecoderBuilder::new() - .with_labels(labels.chain(config_labels)) - .with_signature_identifier(SignaturesIdentifier::from_config(config)?) - .with_label_disabled(disable_label); - let mut identifier = TraceIdentifiers::new().with_etherscan(config, chain)?; - if let Some(contracts) = &known_contracts { - builder = builder.with_known_contracts(contracts); - identifier = identifier.with_local_and_bytecodes(contracts, contracts_bytecode); - } - - let mut decoder = builder.build(); - - for (_, trace) in result.traces.as_deref_mut().unwrap_or_default() { - decoder.identify(trace, &mut identifier); - } - - if decode_internal || debug { - if let Some(ref etherscan_identifier) = identifier.etherscan { - sources.merge(etherscan_identifier.get_compiled_contracts().await?); - } - - if debug { - let mut debugger = Debugger::builder() - .traces(result.traces.expect("missing traces")) - .decoder(&decoder) - .sources(sources) - .build(); - debugger.try_run_tui()?; - return Ok(()); - } - - decoder.debug_identifier = Some(DebugTraceIdentifier::new(sources)); - } - - print_traces(&mut result, &decoder, shell::verbosity() > 0, shell::verbosity() > 4).await?; - - Ok(()) -} - pub async fn print_traces( result: &mut TraceResult, decoder: &CallTraceDecoder, verbose: bool, state_changes: bool, + trace_depth: Option, ) -> Result<()> { let traces = result.traces.as_mut().expect("No traces found"); @@ -425,7 +349,18 @@ pub async fn print_traces( for (_, arena) in traces { decode_trace_arena(arena, decoder).await; - sh_println!("{}", render_trace_arena_inner(arena, verbose, state_changes))?; + + if shell::is_json() + && let Some(trace_depth) = trace_depth + { + let arena = trace_arena_at_depth(arena, trace_depth); + sh_println!("{}", render_trace_arena_inner(&arena, verbose, state_changes))?; + } else { + if let Some(trace_depth) = trace_depth { + prune_trace_depth(arena, trace_depth); + } + sh_println!("{}", render_trace_arena_inner(arena, verbose, state_changes))?; + } } if shell::is_json() { @@ -466,3 +401,93 @@ pub fn cache_local_signatures(output: &ProjectCompileOutput) -> Result<()> { signatures.save(&path); Ok(()) } + +/// Traverses all files at `folder_path`, parses any JSON ABI files found, +/// and caches their function/event/error signatures to the local signatures cache. +pub fn cache_signatures_from_abis(folder_path: impl AsRef) -> Result<()> { + let Some(cache_dir) = Config::foundry_cache_dir() else { + eyre::bail!("Failed to get `cache_dir` to generate local signatures."); + }; + let path = cache_dir.join("signatures"); + let mut signatures = SignaturesCache::load(&path); + + json_files(folder_path.as_ref()) + .filter_map(|path| std::fs::read_to_string(&path).ok()) + .filter_map(|content| serde_json::from_str::(&content).ok()) + .for_each(|json_abi| signatures.extend_from_abi(&json_abi)); + + signatures.save(&path); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::b256; + use foundry_config::TracingConfig; + use std::fs; + use tempfile::tempdir; + + struct TracingConfigArgs; + + impl LoadConfig for TracingConfigArgs { + fn figment(&self) -> Figment { + Config::figment() + .merge(("verbosity", 2u8)) + .merge(("tracing", TracingConfig { verbosity: 4, ..Default::default() })) + } + } + + #[test] + fn tracing_verbosity_is_independent_from_evm_opts() { + let (config, evm_opts) = TracingConfigArgs.load_config_and_evm_opts_no_warnings().unwrap(); + + assert_eq!(config.verbosity, 2); + assert_eq!(config.tracing.verbosity, 4); + assert_eq!(evm_opts.verbosity, 2); + } + + #[test] + fn test_cache_signatures_from_abis() { + let temp_dir = tempdir().unwrap(); + let abi_json = r#"[ + { + "type": "function", + "name": "myCustomFunction", + "inputs": [{"name": "amount", "type": "uint256"}], + "outputs": [], + "stateMutability": "nonpayable" + }, + { + "type": "event", + "name": "MyCustomEvent", + "inputs": [{"name": "value", "type": "uint256", "indexed": false}], + "anonymous": false + }, + { + "type": "error", + "name": "MyCustomError", + "inputs": [{"name": "code", "type": "uint256"}] + } + ]"#; + + let abi_path = temp_dir.path().join("test.json"); + fs::write(&abi_path, abi_json).unwrap(); + + cache_signatures_from_abis(temp_dir.path()).unwrap(); + + let cache_dir = Config::foundry_cache_dir().unwrap(); + let cache_path = cache_dir.join("signatures"); + let cache = SignaturesCache::load(&cache_path); + + let func_selector: alloy_primitives::Selector = "0x2e2dbaf7".parse().unwrap(); + assert!(cache.contains_key(&SelectorKind::Function(func_selector))); + + let event_selector = + b256!("0x8cc20c47f3a2463817352f75dec0dbf43a7a771b5f6817a92bd5724c1f4aa745"); + assert!(cache.contains_key(&SelectorKind::Event(event_selector))); + + let error_selector: alloy_primitives::Selector = "0xd35f45de".parse().unwrap(); + assert!(cache.contains_key(&SelectorKind::Error(error_selector))); + } +} diff --git a/crates/cli/src/utils/default_directives.txt b/crates/cli/src/utils/default_directives.txt new file mode 100644 index 0000000000000..60b9cccd0558e --- /dev/null +++ b/crates/cli/src/utils/default_directives.txt @@ -0,0 +1,11 @@ +[ + // Low level networking + "hyper=off", + "hyper_util=off", + "h2=off", + "rustls=off", + // Tokio + "mio=off", + // Too verbose + "jsonpath_lib=off", +] diff --git a/crates/cli/src/utils/mod.rs b/crates/cli/src/utils/mod.rs index 4d9cb27872e22..ce8eaeac5c607 100644 --- a/crates/cli/src/utils/mod.rs +++ b/crates/cli/src/utils/mod.rs @@ -1,24 +1,23 @@ use alloy_json_abi::JsonAbi; -use alloy_primitives::{U256, map::HashMap}; -use alloy_provider::{Provider, network::AnyNetwork}; +use alloy_primitives::{Address, U256, map::HashMap}; +use alloy_provider::{Network, Provider, RootProvider, network::AnyNetwork}; use eyre::{ContextCompat, Result}; -use foundry_common::{ - provider::{ProviderBuilder, RetryProvider}, - shell, -}; +use foundry_common::{provider::ProviderBuilder, shell}; use foundry_config::{Chain, Config}; use itertools::Itertools; +use path_slash::PathExt; use regex::Regex; use serde::de::DeserializeOwned; use std::{ - ffi::OsStr, + collections::{BTreeMap, BTreeSet}, + ffi::{OsStr, OsString}, path::{Path, PathBuf}, process::{Command, Output, Stdio}, str::FromStr, - sync::LazyLock, + sync::{LazyLock, OnceLock}, time::{Duration, SystemTime, UNIX_EPOCH}, }; -use tracing_subscriber::prelude::*; +use tracing_subscriber::{EnvFilter, prelude::*, reload}; mod cmd; pub use cmd::*; @@ -32,6 +31,9 @@ pub use abi::*; mod allocator; pub use allocator::*; +mod tempo; +pub use tempo::*; + // reexport all `foundry_config::utils` #[doc(hidden)] pub use foundry_config::utils::*; @@ -44,6 +46,13 @@ pub const STATIC_FUZZ_SEED: [u8; 32] = [ 0x5d, 0x64, 0x0b, 0x19, 0xad, 0xf0, 0xe3, 0x57, 0xb8, 0xd4, 0xbe, 0x7d, 0x49, 0xee, 0x70, 0xe6, ]; +/// Applies an optional percentage multiplier to a gas estimate. +pub fn apply_gas_estimate_multiplier(estimate: u64, multiplier: Option) -> Result { + let Some(multiplier) = multiplier else { return Ok(estimate) }; + let adjusted = u128::from(estimate) * u128::from(multiplier) / 100; + adjusted.try_into().map_err(|_| eyre::eyre!("multiplied gas estimate exceeds u64")) +} + /// Regex used to parse `.gitmodules` file and capture the submodule path and branch. pub static SUBMODULE_BRANCH_REGEX: LazyLock = LazyLock::new(|| Regex::new(r#"\[submodule "([^"]+)"\](?:[^\[]*?branch = ([^\s]+))"#).unwrap()); @@ -51,6 +60,10 @@ pub static SUBMODULE_BRANCH_REGEX: LazyLock = pub static SUBMODULE_STATUS_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"^[\s+-]?([a-f0-9]+)\s+([^\s]+)(?:\s+\([^)]+\))?$").unwrap()); +/// Handle to reload the tracing `EnvFilter` at runtime. +static FILTER_RELOAD_HANDLE: OnceLock> = + OnceLock::new(); + /// Useful extensions to [`std::path::Path`]. pub trait FoundryPathExt { /// Returns true if the [`Path`] ends with `.t.sol` @@ -81,24 +94,44 @@ impl> FoundryPathExt for T { } } -/// Initializes a tracing Subscriber for logging +/// Initializes a tracing Subscriber for logging. +/// +/// The `EnvFilter` is wrapped in a [`reload::Layer`] so it can be reconfigured at runtime via +/// [`update_tracing_filter`]. pub fn subscriber() { - let registry = tracing_subscriber::Registry::default() - .with(tracing_subscriber::EnvFilter::from_default_env()); + let (filter_layer, reload_handle) = reload::Layer::new(env_filter()); + let registry = tracing_subscriber::Registry::default().with(filter_layer); #[cfg(feature = "tracy")] let registry = registry.with(tracing_tracy::TracyLayer::default()); - registry.with(tracing_subscriber::fmt::layer()).init() + registry.with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr)).init(); + let _ = FILTER_RELOAD_HANDLE.set(reload_handle); } -pub fn abi_to_solidity(abi: &JsonAbi, name: &str) -> Result { - let s = abi.to_sol(name, None); - let s = forge_fmt::format(&s)?; - Ok(s) +/// Replaces the active tracing `EnvFilter` at runtime. +/// +/// `directives` is parsed as an [`EnvFilter`] (e.g. `"info"`, `"debug,hyper=off"`). +/// This is a no-op if [`subscriber`] has not been called yet. +pub fn update_tracing_filter(directives: &str) { + let Some(handle) = FILTER_RELOAD_HANDLE.get() else { + return; + }; + let Ok(new_filter) = directives.parse::() else { + return; + }; + let _ = handle.reload(new_filter); +} + +fn env_filter() -> EnvFilter { + const DEFAULT_DIRECTIVES: &[&str] = &include!("./default_directives.txt"); + let mut filter = EnvFilter::from_default_env(); + for &directive in DEFAULT_DIRECTIVES { + filter = filter.add_directive(directive.parse().unwrap()); + } + filter } -/// Returns a [RetryProvider] instantiated using [Config]'s -/// RPC -pub fn get_provider(config: &Config) -> Result { +/// Returns a [`RootProvider`] instantiated using [Config]'s RPC settings. +pub fn get_provider(config: &Config) -> Result> { get_provider_builder(config)?.build() } @@ -106,33 +139,13 @@ pub fn get_provider(config: &Config) -> Result { /// /// Defaults to `http://localhost:8545` and `Mainnet`. pub fn get_provider_builder(config: &Config) -> Result { - let url = config.get_rpc_url_or_localhost_http()?; - let mut builder = ProviderBuilder::new(url.as_ref()); - - builder = builder.accept_invalid_certs(config.eth_rpc_accept_invalid_certs); - - if let Ok(chain) = config.chain.unwrap_or_default().try_into() { - builder = builder.chain(chain); - } - - if let Some(jwt) = config.get_rpc_jwt_secret()? { - builder = builder.jwt(jwt.as_ref()); - } - - if let Some(rpc_timeout) = config.eth_rpc_timeout { - builder = builder.timeout(Duration::from_secs(rpc_timeout)); - } - - if let Some(rpc_headers) = config.eth_rpc_headers.clone() { - builder = builder.headers(rpc_headers); - } - - Ok(builder) + ProviderBuilder::from_config(config) } -pub async fn get_chain

(chain: Option, provider: P) -> Result +pub async fn get_chain(chain: Option, provider: P) -> Result where - P: Provider, + N: Network, + P: Provider, { match chain { Some(chain) => Ok(chain), @@ -147,8 +160,8 @@ where /// If the string represents an untagged amount (e.g. "100") then /// it is interpreted as wei. pub fn parse_ether_value(value: &str) -> Result { - Ok(if value.starts_with("0x") { - U256::from_str_radix(value, 16)? + Ok(if value.starts_with("0x") || value.starts_with("0X") { + U256::from_str(value)? } else { alloy_dyn_abi::DynSolType::coerce_str(&alloy_dyn_abi::DynSolType::Uint(256), value)? .as_uint() @@ -184,28 +197,49 @@ pub fn now() -> Duration { SystemTime::now().duration_since(UNIX_EPOCH).expect("time went backwards") } -/// Loads a dotenv file, from the cwd and the project root, ignoring potential failure. -/// -/// We could use `warn!` here, but that would imply that the dotenv file can't configure -/// the logging behavior of Foundry. -/// -/// Similarly, we could just use `eprintln!`, but colors are off limits otherwise dotenv is implied -/// to not be able to configure the colors. It would also mess up the JSON output. -pub fn load_dotenv() { - let load = |p: &Path| { - dotenvy::from_path(p.join(".env")).ok(); - }; +/// Common setup for all CLI tools. Does not include [tracing subscriber](subscriber). +pub fn common_setup() -> Vec { + install_crypto_provider(); + crate::handler::install(); + let warnings = load_dotenv(); + enable_paint(); + warnings +} +/// Loads dotenv files from the cwd and project root, deferring diagnostics until shell setup. +pub fn load_dotenv() -> Vec { // we only want the .env file of the cwd and project root // `find_project_root` calls `current_dir` internally so both paths are either both `Ok` or // both `Err` - if let (Ok(cwd), Ok(prj_root)) = (std::env::current_dir(), find_project_root(None)) { - load(&prj_root); - if cwd != prj_root { + let mut paths = Vec::new(); + if let (Ok(cwd), Ok(project_root)) = (std::env::current_dir(), find_project_root(None)) { + let project_env = project_root.join(".env"); + if project_env.is_file() { + paths.push(project_env); + } + if cwd != project_root { // prj root and cwd can be identical - load(&cwd); + let cwd_env = cwd.join(".env"); + if cwd_env.is_file() { + paths.push(cwd_env); + } } - }; + } + + let mut warnings = Vec::new(); + for path in paths { + if let Err(err) = dotenvy::from_path(&path) { + // Parse errors contain the source line, which may include secrets. + let reason = match err { + dotenvy::Error::LineParse(..) => { + "invalid syntax; remaining variables were not loaded".to_string() + } + _ => err.to_string(), + }; + warnings.push(format!("Failed to load {}: {}", path.display(), reason)); + } + } + warnings } /// Sets the default [`yansi`] color output condition. @@ -231,6 +265,48 @@ pub fn install_crypto_provider() { .expect("Failed to install default rustls crypto provider"); } +/// Fetches the ABI of a contract from Etherscan. +/// +/// If `follow_proxy` is set and Etherscan reports the contract as a proxy, the ABI of its +/// implementation is appended after the proxy's own. Failing to fetch the implementation only +/// produces a warning. +pub async fn fetch_abi_from_etherscan( + address: Address, + config: &foundry_config::Config, + follow_proxy: bool, +) -> Result> { + let chain = config.chain.unwrap_or_default(); + let client = config + .get_etherscan_config_with_chain(Some(chain))? + .ok_or_else(|| eyre::eyre!("No Etherscan API key configured for chain {chain}"))? + .into_client_with_no_proxy(config.eth_rpc_no_proxy)?; + let fetch_abis = async |address| -> Result<_> { + let source = client.contract_source_code(address).await?; + let implementation = source + .items + .first() + .filter(|item| item.proxy != 0) + .and_then(|item| item.implementation); + let abis = source + .abis()? + .into_iter() + .zip(source.items.into_iter().map(|item| item.contract_name)) + .collect::>(); + Ok((abis, implementation)) + }; + let (mut abis, implementation) = fetch_abis(address).await?; + if follow_proxy && let Some(implementation) = implementation { + sh_status!( + "Contract at {address} is a proxy, fetching implementation at {implementation}..." + )?; + match fetch_abis(implementation).await { + Ok((implementation, _)) => abis.extend(implementation), + Err(err) => sh_warn!("Could not fetch implementation ABI: {err}")?, + } + } + Ok(abis) +} + /// Useful extensions to [`std::process::Command`]. pub trait CommandUtils { /// Returns the command's output if execution is successful, otherwise, throws an error. @@ -280,7 +356,7 @@ impl CommandUtils for Command { }; if !msg.is_empty() { err.push(':'); - err.push(if msg.lines().count() == 0 { ' ' } else { '\n' }); + err.push(if msg.lines().count() == 1 { ' ' } else { '\n' }); err.push_str(&msg); } Err(eyre::eyre!(err)) @@ -303,12 +379,10 @@ pub struct Git<'a> { } impl<'a> Git<'a> { - #[inline] pub fn new(root: &'a Path) -> Self { Self { root, quiet: shell::is_quiet(), shallow: false } } - #[inline] pub fn from_config(config: &'a Config) -> Self { Self::new(config.root.as_path()) } @@ -321,25 +395,6 @@ impl<'a> Git<'a> { Ok(PathBuf::from(output)) } - pub fn clone_with_branch( - shallow: bool, - from: impl AsRef, - branch: impl AsRef, - to: Option>, - ) -> Result<()> { - Self::cmd_no_root() - .stderr(Stdio::inherit()) - .args(["clone", "--recurse-submodules"]) - .args(shallow.then_some("--depth=1")) - .args(shallow.then_some("--shallow-submodules")) - .arg("-b") - .arg(branch) - .arg(from) - .args(to) - .exec() - .map(drop) - } - pub fn clone( shallow: bool, from: impl AsRef, @@ -373,33 +428,40 @@ impl<'a> Git<'a> { .map(drop) } - #[inline] - pub fn root(self, root: &Path) -> Git<'_> { + pub const fn root(self, root: &Path) -> Git<'_> { Git { root, ..self } } - #[inline] - pub fn quiet(self, quiet: bool) -> Self { + pub const fn quiet(self, quiet: bool) -> Self { Self { quiet, ..self } } /// True to perform shallow clones - #[inline] - pub fn shallow(self, shallow: bool) -> Self { + pub const fn shallow(self, shallow: bool) -> Self { Self { shallow, ..self } } pub fn checkout(self, recursive: bool, tag: impl AsRef) -> Result<()> { + let tag = tag.as_ref(); + validate_checkout_ref(tag)?; self.cmd() .arg("checkout") .args(recursive.then_some("--recurse-submodules")) .arg(tag) + .arg("--") .exec() .map(drop) } + /// Returns the current HEAD commit hash of the current branch. + pub fn head(self) -> Result { + self.cmd().args(["rev-parse", "HEAD"]).get_stdout_lossy() + } + pub fn checkout_at(self, tag: impl AsRef, at: &Path) -> Result<()> { - self.cmd_at(at).arg("checkout").arg(tag).exec().map(drop) + let tag = tag.as_ref(); + validate_checkout_ref(tag)?; + self.cmd_at(at).arg("checkout").arg(tag).arg("--").exec().map(drop) } pub fn init(self) -> Result<()> { @@ -422,6 +484,10 @@ impl<'a> Git<'a> { self.cmd().arg("add").args(paths).exec().map(drop) } + pub fn add_literal(self, path: &Path) -> Result<()> { + self.cmd().args(["--literal-pathspecs", "add", "--"]).arg(path).exec().map(drop) + } + pub fn reset(self, hard: bool, tree: impl AsRef) -> Result<()> { self.cmd().arg("reset").args(hard.then_some("--hard")).arg(tree).exec().map(drop) } @@ -447,6 +513,14 @@ impl<'a> Git<'a> { self.cmd().arg("rm").args(force.then_some("--force")).args(paths).exec().map(drop) } + pub fn remove_index_path(self, path: &Path) -> Result<()> { + self.cmd() + .args(["--literal-pathspecs", "rm", "--cached", "--force", "--"]) + .arg(path) + .exec() + .map(drop) + } + pub fn commit(self, msg: &str) -> Result<()> { let output = self .cmd() @@ -474,10 +548,22 @@ impl<'a> Git<'a> { self.cmd().args(["rev-parse", "--is-inside-work-tree"]).status().map(|s| s.success()) } + pub fn is_repo_root(self) -> Result { + self.cmd().args(["rev-parse", "--show-cdup"]).get_stdout_lossy().map(|s| s.is_empty()) + } + pub fn is_clean(self) -> Result { self.cmd().args(["status", "--porcelain"]).exec().map(|out| out.stdout.is_empty()) } + pub fn is_path_clean(self, path: &Path) -> Result { + self.cmd() + .args(["--literal-pathspecs", "status", "--porcelain", "--"]) + .arg(path) + .exec() + .map(|out| out.stdout.is_empty()) + } + pub fn has_branch(self, branch: impl AsRef, at: &Path) -> Result { self.cmd_at(at) .args(["branch", "--list", "--no-color"]) @@ -596,14 +682,71 @@ ignore them in the `.gitignore` file." I: IntoIterator, S: AsRef, { + let paths = paths.into_iter().map(|path| path.as_ref().to_owned()).collect::>(); + if self.submodules_initialized(&paths).unwrap_or(false) { + return Ok(false); + } + self.cmd() .args(["submodule", "status"]) - .args(paths) + .args(&paths) .get_stdout_lossy() .map(|stdout| stdout.lines().any(|line| line.starts_with('-'))) } - /// Returns true if the given path has no submodules by checking `git submodule status` + /// Returns true if all submodules matching `paths` have initialized worktrees. + fn submodules_initialized(self, paths: &[OsString]) -> Result { + // Let Git resolve relative roots. + if !self.root.is_absolute() { + return Ok(false); + } + let Some(root) = find_git_root(self.root)? else { + return Ok(true); + }; + let root = root.as_path(); + if paths.iter().any(|path| { + Path::new(path) + .components() + .any(|component| matches!(component, std::path::Component::ParentDir)) + }) { + return Ok(false); + } + let relative_root = self.root.strip_prefix(root).unwrap_or_else(|_| Path::new("")); + let gitmodules = root.join(".gitmodules"); + if !gitmodules.is_file() { + // Removing .gitmodules does not remove gitlinks from the index. Let Git handle + // any remaining submodules, including those without a .gitmodules mapping. + return Ok(false); + } + + let output = Command::new("git") + .args(["config", "--null", "--file"]) + .arg(gitmodules) + .args(["--get-regexp", r"^submodule\..*\.path$"]) + .get_stdout_lossy()?; + + for entry in output.split_terminator('\0') { + let (_, path) = entry + .split_once('\n') + .ok_or_else(|| eyre::eyre!("invalid submodule path config"))?; + let path = Path::new(path); + let matches = paths.is_empty() + || paths.iter().any(|prefix| { + let prefix = Path::new(prefix); + if prefix.is_absolute() { + prefix.strip_prefix(root).is_ok_and(|prefix| path.starts_with(prefix)) + } else { + path.starts_with(relative_root.join(prefix)) + } + }); + if matches && !root.join(path).join(".git").exists() { + return Ok(false); + } + } + Ok(true) + } + + /// Returns true if the given path has submodules by checking `git submodule status` pub fn has_submodules(self, paths: I) -> Result where I: IntoIterator, @@ -623,6 +766,7 @@ ignore them in the `.gitignore` file." path: impl AsRef, ) -> Result<()> { self.cmd() + .arg("--literal-pathspecs") .stderr(self.stderr()) .args(["submodule", "add"]) .args(self.shallow.then_some("--depth=1")) @@ -646,6 +790,7 @@ ignore them in the `.gitignore` file." S: AsRef, { self.cmd() + .arg("--literal-pathspecs") .stderr(self.stderr()) .args(["submodule", "update", "--progress", "--init"]) .args(self.shallow.then_some("--depth=1")) @@ -653,6 +798,7 @@ ignore them in the `.gitignore` file." .args(remote.then_some("--remote")) .args(no_fetch.then_some("--no-fetch")) .args(recursive.then_some("--recursive")) + .arg("--") .args(paths) .exec() .map(drop) @@ -672,10 +818,9 @@ ignore them in the `.gitignore` file." /// /// Ref: pub fn submodules_uninitialized(self) -> Result { - self.cmd() - .args(["submodule", "status"]) - .get_stdout_lossy() - .map(|stdout| stdout.lines().any(|line| line.starts_with('-'))) + // keep behavior consistent with `has_missing_dependencies`, but avoid duplicating the + // "submodule status has '-' prefix" logic. + self.has_missing_dependencies(std::iter::empty::<&OsStr>()) } /// Initializes the git submodules. @@ -687,23 +832,291 @@ ignore them in the `.gitignore` file." self.cmd().args(["submodule", "status"]).get_stdout_lossy().map(|stdout| stdout.parse())? } + /// Returns submodules at or below `path`, with paths relative to this Git root. + pub fn submodules_in(&self, path: &Path) -> Result> { + self.submodules_in_worktree(path, self.root, Path::new("")) + } + + /// Returns submodules at or below `path`, with paths relative to this Git root, using mappings + /// from the enclosing worktree. + pub fn submodules_in_worktree( + &self, + path: &Path, + worktree_root: &Path, + worktree_prefix: &Path, + ) -> Result> { + let pathspec = if path.as_os_str().is_empty() { Path::new(".") } else { path }; + let output = self + .cmd() + .args(["--literal-pathspecs", "ls-files", "--stage", "-z", "--"]) + .arg(pathspec) + .exec()?; + let (_, mappings) = self.submodule_mappings_at(worktree_root)?; + let mut gitlinks = BTreeMap::new(); + for entry in output.stdout.split(|byte| *byte == 0).filter(|entry| !entry.is_empty()) { + let Some(separator) = entry.iter().position(|byte| *byte == b'\t') else { + return Err(eyre::eyre!("invalid index entry")); + }; + let mut fields = std::str::from_utf8(&entry[..separator])?.split_ascii_whitespace(); + let Some(mode) = fields.next() else { + return Err(eyre::eyre!("invalid index entry")); + }; + let Some(rev) = fields.next() else { + return Err(eyre::eyre!("invalid index entry")); + }; + let Some(stage) = fields.next() else { + return Err(eyre::eyre!("invalid index entry")); + }; + if fields.next().is_some() { + return Err(eyre::eyre!("invalid index entry")); + } + if mode != "160000" { + continue; + } + + let submodule_path = PathBuf::from(std::str::from_utf8(&entry[separator + 1..])?); + let worktree_path = + foundry_common::fs::normalize_path(&worktree_prefix.join(&submodule_path)); + if !mappings.contains(&worktree_path) { + gitlinks.insert( + submodule_path.clone(), + SubmoduleCheckout { + status: SubmoduleCheckoutStatus::MissingMapping, + rev: rev.to_string(), + path: submodule_path, + }, + ); + continue; + } + if stage != "0" { + gitlinks.insert( + submodule_path.clone(), + SubmoduleCheckout { + status: SubmoduleCheckoutStatus::Conflicted, + rev: rev.to_string(), + path: submodule_path, + }, + ); + continue; + } + + let status = self + .cmd() + .args(["--literal-pathspecs", "submodule", "status", "--"]) + .arg(&submodule_path) + .get_stdout_lossy()?; + let (status, rev) = match status.as_bytes().first() { + Some(b'-') => (SubmoduleCheckoutStatus::Uninitialized, &status[1..]), + Some(b'+') => (SubmoduleCheckoutStatus::Modified, &status[1..]), + Some(b'U') => (SubmoduleCheckoutStatus::Conflicted, &status[1..]), + Some(_) => (SubmoduleCheckoutStatus::Current, status.as_str()), + None => return Err(eyre::eyre!("missing submodule status")), + }; + let rev = rev + .split_ascii_whitespace() + .next() + .ok_or_else(|| eyre::eyre!("invalid submodule status"))?; + gitlinks.insert( + submodule_path.clone(), + SubmoduleCheckout { status, rev: rev.to_string(), path: submodule_path }, + ); + } + Ok(gitlinks.into_values().collect()) + } + pub fn submodule_sync(self) -> Result<()> { self.cmd().stderr(self.stderr()).args(["submodule", "sync"]).exec().map(drop) } - pub fn cmd(self) -> Command { + /// Get the URL of a submodule from git config + pub fn submodule_url(self, path: &Path) -> Result> { + self.cmd() + .args(["config", "--get", &format!("submodule.{}.url", path.to_slash_lossy())]) + .get_stdout_lossy() + .map(|url| Some(url.trim().to_string())) + } + + /// Returns whether `.gitmodules` contains the default section name or an exact path mapping. + pub fn has_submodule_mapping(self, path: &Path) -> Result { + let (names, paths) = self.submodule_mappings()?; + Ok(names.contains(path) || paths.contains(path)) + } + + fn submodule_mappings(self) -> Result<(BTreeSet, BTreeSet)> { + self.submodule_mappings_at(self.root) + } + + fn submodule_mappings_at(self, root: &Path) -> Result<(BTreeSet, BTreeSet)> { + let gitmodules = root.join(".gitmodules"); + if !gitmodules.exists() { + return Ok(Default::default()); + } + + let output = self + .cmd() + .args(["config", "--null", "--file"]) + .arg(gitmodules) + .args(["--get-regexp", r"^submodule\..*"]) + .output()?; + match output.status.code() { + Some(0) => { + let mut names = BTreeSet::new(); + let mut paths = BTreeSet::new(); + for entry in + output.stdout.split(|byte| *byte == 0).filter(|entry| !entry.is_empty()) + { + let Some(separator) = entry.iter().position(|byte| *byte == b'\n') else { + return Err(eyre::eyre!("invalid submodule mapping entry")); + }; + let key = std::str::from_utf8(&entry[..separator])?; + let value = std::str::from_utf8(&entry[separator + 1..])?; + let Some(key) = key.strip_prefix("submodule.") else { continue }; + let Some((name, field)) = key.rsplit_once('.') else { continue }; + names.insert(PathBuf::from(name)); + if field == "path" { + paths.insert(PathBuf::from(value)); + } + } + Ok((names, paths)) + } + Some(1) => Ok(Default::default()), + _ => Err(eyre::eyre!( + "failed to inspect .gitmodules: {}", + String::from_utf8_lossy(&output.stderr).trim() + )), + } + } + + /// Returns whether the index contains a submodule at the given path. + pub fn is_gitlink(self, path: &Path) -> Result { + self.cmd().args(["ls-files", "--stage", "-z", "--"]).arg(path).exec().map(|output| { + let expected_path = path.to_slash_lossy(); + output.stdout.split(|byte| *byte == 0).any(|entry| { + entry.starts_with(b"160000 ") + && entry + .iter() + .position(|byte| *byte == b'\t') + .and_then(|separator| entry.get(separator + 1..)) + == Some(expected_path.as_bytes()) + }) + }) + } + + /// Returns whether the index contains any entry at or below the given path. + pub fn has_index_entries(self, path: &Path) -> Result { + self.cmd() + .args(["--literal-pathspecs", "ls-files", "--stage", "-z", "--"]) + .arg(path) + .exec() + .map(|output| !output.stdout.is_empty()) + } + + /// Returns whether a regular stage-0 index entry has no flags and matches the worktree. + pub fn is_normal_tracked_file(self, path: &Path) -> Result { + let output = self + .cmd() + .args(["--literal-pathspecs", "ls-files", "--stage", "-v", "-z", "--"]) + .arg(path) + .exec()?; + let mut entries = output.stdout.split(|byte| *byte == 0).filter(|entry| !entry.is_empty()); + let Some(entry) = entries.next() else { return Ok(false) }; + if entries.next().is_some() { + return Ok(false); + } + let Some(separator) = entry.iter().position(|byte| *byte == b'\t') else { + return Err(eyre::eyre!("invalid index entry")); + }; + if entry.get(separator + 1..) != Some(path.to_slash_lossy().as_bytes()) { + return Ok(false); + } + let mut fields = std::str::from_utf8(&entry[..separator])?.split_ascii_whitespace(); + if fields.next() != Some("H") || !matches!(fields.next(), Some("100644" | "100755")) { + return Ok(false); + } + let Some(index_hash) = fields.next() else { return Ok(false) }; + if fields.next() != Some("0") || fields.next().is_some() { + return Ok(false); + } + let worktree_hash = self.cmd().args(["hash-object", "--"]).arg(path).get_stdout_lossy()?; + Ok(worktree_hash.trim() == index_hash) + } + + /// Returns whether local config contains values for the given submodule. + pub fn has_submodule_config(self, path: &Path) -> Result { + let pattern = format!(r"^submodule\.{}\.", regex::escape(&path.to_slash_lossy())); + let output = self.cmd().args(["config", "--local", "--get-regexp", &pattern]).output()?; + match output.status.code() { + Some(0) => Ok(true), + Some(1) => Ok(false), + _ => Err(eyre::eyre!( + "failed to inspect submodule config: {}", + String::from_utf8_lossy(&output.stderr).trim() + )), + } + } + + /// Removes all local config values for the given submodule. + pub fn remove_submodule_config(self, path: &Path) -> Result<()> { + if self.has_submodule_config(path)? { + let section = format!("submodule.{}", path.to_slash_lossy()); + self.cmd().args(["config", "--local", "--remove-section", §ion]).exec()?; + } + Ok(()) + } + + /// Returns the absolute path to the repository's Git directory. + pub fn absolute_git_dir(self) -> Result { + self.cmd().args(["rev-parse", "--absolute-git-dir"]).get_stdout_lossy().map(PathBuf::from) + } + + /// Returns the fetch URL of the given remote, or `None` if it doesn't exist. + pub fn remote_url(self, name: &str) -> Option { + self.cmd().args(["remote", "get-url", name]).get_stdout_lossy().ok() + } + + /// Sets the branch for a submodule. + pub fn set_submodule_branch(self, rel_path: &Path, branch: &str) -> Result<()> { + self.cmd().args(["submodule", "set-branch", "-b", branch]).arg(rel_path).exec().map(drop) + } + + /// Returns remote branch names as a newline-separated string. + pub fn remote_branches(self) -> Result { + self.cmd().args(["branch", "-r"]).get_stdout_lossy() + } + + /// Fetches a branch from origin and checks out a local tracking branch at the given path. + pub fn fetch_and_checkout_branch(self, at: &Path, branch: &str) -> Result<()> { + self.cmd_at(at).args(["fetch", "--", "origin", branch]).exec().map_err(|e| { + eyre::eyre!( + "Could not fetch latest changes for branch {branch} in submodule at {}: {e}", + at.display() + ) + })?; + self.cmd_at(at) + .args(["checkout", "-B", branch, &format!("origin/{branch}")]) + .exec() + .map_err(|e| { + eyre::eyre!( + "Could not checkout and track origin/{branch} for submodule at {}: {e}", + at.display() + ) + })?; + Ok(()) + } + + fn cmd(self) -> Command { let mut cmd = Self::cmd_no_root(); cmd.current_dir(self.root); cmd } - pub fn cmd_at(self, path: &Path) -> Command { + fn cmd_at(self, path: &Path) -> Command { let mut cmd = Self::cmd_no_root(); cmd.current_dir(path); cmd } - pub fn cmd_no_root() -> Command { + fn cmd_no_root() -> Command { let mut cmd = Command::new("git"); cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); cmd @@ -715,6 +1128,13 @@ ignore them in the `.gitignore` file." } } +fn validate_checkout_ref(reference: &OsStr) -> Result<()> { + if reference.is_empty() || reference.as_encoded_bytes().starts_with(b"-") { + eyre::bail!("Git checkout reference must not be empty or start with `-`"); + } + Ok(()) +} + /// Deserialized `git submodule status lib/dep` output. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)] pub struct Submodule { @@ -725,7 +1145,7 @@ pub struct Submodule { } impl Submodule { - pub fn new(rev: String, path: PathBuf) -> Self { + pub const fn new(rev: String, path: PathBuf) -> Self { Self { rev, path } } @@ -733,11 +1153,48 @@ impl Submodule { &self.rev } - pub fn path(&self) -> &PathBuf { + pub const fn path(&self) -> &PathBuf { + &self.path + } +} + +/// A submodule checkout inspected without changing its worktree or index. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubmoduleCheckout { + status: SubmoduleCheckoutStatus, + rev: String, + path: PathBuf, +} + +impl SubmoduleCheckout { + pub const fn status(&self) -> SubmoduleCheckoutStatus { + self.status + } + + pub fn rev(&self) -> &str { + &self.rev + } + + pub const fn path(&self) -> &PathBuf { &self.path } } +/// State of an inspected submodule checkout. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SubmoduleCheckoutStatus { + /// The checkout matches the commit recorded by the superproject. + Current, + /// The submodule has not been initialized. + Uninitialized, + /// The checkout differs from the commit recorded by the superproject. + Modified, + /// The submodule has merge conflicts. + Conflicted, + /// The index contains a gitlink without a corresponding `.gitmodules` mapping. + MissingMapping, +} + impl FromStr for Submodule { type Err = eyre::Report; @@ -758,11 +1215,11 @@ impl FromStr for Submodule { pub struct Submodules(pub Vec); impl Submodules { - pub fn len(&self) -> usize { + pub const fn len(&self) -> usize { self.0.len() } - pub fn is_empty(&self) -> bool { + pub const fn is_empty(&self) -> bool { self.0.is_empty() } } @@ -791,6 +1248,63 @@ mod tests { use std::{env, fs::File, io::Write}; use tempfile::tempdir; + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + + #[test] + fn applies_gas_estimate_multiplier() { + assert_eq!(apply_gas_estimate_multiplier(21_000, None).unwrap(), 21_000); + assert_eq!(apply_gas_estimate_multiplier(21_000, Some(150)).unwrap(), 31_500); + assert_eq!( + apply_gas_estimate_multiplier(21_000, Some(1_000_000_000_000_000)).unwrap(), + 210_000_000_000_000_000 + ); + assert!(apply_gas_estimate_multiplier(u64::MAX, Some(101)).is_err()); + } + + #[test] + fn checkout_does_not_parse_revision_as_option() { + let tmp = tempdir().unwrap(); + let git = Git::new(tmp.path()); + git.init().unwrap(); + fs::write(tmp.path().join("file"), "clean\n").unwrap(); + git.add(["file"]).unwrap(); + git.commit("initial").unwrap(); + fs::write(tmp.path().join("file"), "dirty\n").unwrap(); + + assert!(git.checkout_at("-f", tmp.path()).is_err()); + assert_eq!(fs::read_to_string(tmp.path().join("file")).unwrap(), "dirty\n"); + } + + #[cfg(unix)] + #[test] + fn fetch_does_not_parse_branch_as_option() { + let remote = tempdir().unwrap(); + let remote_git = Git::new(remote.path()); + remote_git.init().unwrap(); + fs::write(remote.path().join("file"), "content\n").unwrap(); + remote_git.add(["file"]).unwrap(); + remote_git.commit("initial").unwrap(); + + let tmp = tempdir().unwrap(); + Git::clone(false, remote.path(), Some(tmp.path())).unwrap(); + + let marker = tmp.path().join("upload-pack-ran"); + let upload_pack = tmp.path().join("upload-pack"); + fs::write( + &upload_pack, + format!("#!/bin/sh\ntouch '{}'\nexec git-upload-pack \"$@\"\n", marker.display()), + ) + .unwrap(); + let mut permissions = std::fs::metadata(&upload_pack).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&upload_pack, permissions).unwrap(); + + let branch = format!("--upload-pack={}", upload_pack.display()); + assert!(Git::new(tmp.path()).fetch_and_checkout_branch(tmp.path(), &branch).is_err()); + assert!(!marker.exists()); + } + #[test] fn parse_submodule_status() { let s = "+8829465a08cac423dcf59852f21e448449c1a1a8 lib/openzeppelin-contracts (v4.8.0-791-g8829465a)"; @@ -822,6 +1336,52 @@ mod tests { assert_eq!(subs[1].path(), Path::new("lib/openzeppelin-contracts")); } + #[test] + fn deserialize_submodule() { + let submodule: Submodule = + parse_json(r#"{"rev":"8829465a08cac423dcf59852f21e448449c1a1a8","path":"lib/dep"}"#) + .unwrap(); + assert_eq!(submodule.rev(), "8829465a08cac423dcf59852f21e448449c1a1a8"); + assert_eq!(submodule.path(), Path::new("lib/dep")); + assert_eq!( + serde_json::to_value(submodule).unwrap(), + serde_json::json!({ + "rev": "8829465a08cac423dcf59852f21e448449c1a1a8", + "path": "lib/dep", + }) + ); + } + + #[test] + fn skips_submodule_status_if_dependencies_are_initialized() { + let tmp = tempdir().unwrap(); + let root = tmp.path(); + std::fs::create_dir(root.join(".git")).unwrap(); + std::fs::write( + root.join(".gitmodules"), + r#"[submodule "lib/forge-std"] + path = lib/forge-std + url = https://github.com/foundry-rs/forge-std +"#, + ) + .unwrap(); + std::fs::create_dir_all(root.join("lib/forge-std/.git")).unwrap(); + + let git = Git::new(root); + assert!(git.submodules_initialized(&["lib".into()]).unwrap()); + assert!(git.submodules_initialized(&[root.join("lib").into()]).unwrap()); + + let nested = root.join("packages/contracts"); + std::fs::create_dir_all(&nested).unwrap(); + assert!(!Git::new(&nested).submodules_initialized(&["../../lib".into()]).unwrap()); + + // The fast path succeeds even though this is not a real Git repository. + assert!(!git.has_missing_dependencies(["lib"]).unwrap()); + + std::fs::remove_dir(root.join("lib/forge-std/.git")).unwrap(); + assert!(!git.submodules_initialized(&["lib".into()]).unwrap()); + } + #[test] fn foundry_path_ext_works() { let p = Path::new("contracts/MyTest.t.sol"); @@ -831,6 +1391,16 @@ mod tests { assert!(!p.is_sol_test()); } + #[test] + fn parse_ether_value_accepts_hex_prefixed_wei() { + assert_eq!(parse_ether_value("0x10").unwrap(), U256::from(16)); + assert_eq!(parse_ether_value("0X10").unwrap(), U256::from(16)); + assert_eq!(parse_ether_value("0x12").unwrap(), U256::from(0x12)); + assert_eq!(parse_ether_value("0xff").unwrap(), U256::from(0xff)); + assert_eq!(parse_ether_value("100").unwrap(), U256::from(100)); + assert_eq!(parse_ether_value("1ether").unwrap(), U256::from(1000000000000000000u128)); + } + // loads .env from cwd and project dir, See [`find_project_root()`] #[test] fn can_load_dotenv() { @@ -844,19 +1414,31 @@ mod tests { let mut cwd_file = File::create(cwd_env).unwrap(); let mut prj_file = File::create(nested.join(".env")).unwrap(); - cwd_file.write_all("TESTCWDKEY=cwd_val".as_bytes()).unwrap(); + cwd_file.write_all(b"TESTCWDKEY=cwd_val\nBROWSER=./project-browser").unwrap(); cwd_file.sync_all().unwrap(); - prj_file.write_all("TESTPRJKEY=prj_val".as_bytes()).unwrap(); + prj_file.write_all(b"TESTPRJKEY=prj_val\nBROWSER=./nested-browser").unwrap(); prj_file.sync_all().unwrap(); + let browser = env::var_os("BROWSER"); + unsafe { env::remove_var("BROWSER") }; let cwd = env::current_dir().unwrap(); - env::set_current_dir(nested).unwrap(); + env::set_current_dir(&nested).unwrap(); load_dotenv(); env::set_current_dir(cwd).unwrap(); + let loaded_browser = env::var_os("BROWSER"); + unsafe { + if let Some(browser) = browser { + env::set_var("BROWSER", browser); + } else { + env::remove_var("BROWSER"); + } + } + assert_eq!(env::var("TESTCWDKEY").unwrap(), "cwd_val"); assert_eq!(env::var("TESTPRJKEY").unwrap(), "prj_val"); + assert_eq!(loaded_browser.as_deref(), Some(OsStr::new("./project-browser"))); } #[test] @@ -943,4 +1525,96 @@ mod tests { "v4.8.0-791-g8829465a" ); } + + #[test] + fn skips_submodule_status_outside_repository() { + let tmp = tempdir().unwrap(); + let root = tmp.path().join("nested/project"); + fs::create_dir_all(&root).unwrap(); + let git = Git::new(&root); + assert!(git.submodules_initialized(&["lib".into()]).unwrap()); + assert!(!git.has_missing_dependencies(["lib"]).unwrap()); + } + + #[test] + fn keeps_submodule_status_without_gitmodules() { + let tmp = tempdir().unwrap(); + let git = Git::new(tmp.path()); + git.init().unwrap(); + fs::write(tmp.path().join("tracked"), "tracked file").unwrap(); + git.add(["tracked"]).unwrap(); + + assert!(!git.submodules_initialized(&["lib".into()]).unwrap()); + assert!(git.has_missing_dependencies(["lib"]).is_err()); + + let nested = tmp.path().join("packages/contracts"); + fs::create_dir_all(&nested).unwrap(); + let git = git.root(&nested); + assert!(!git.submodules_initialized(&["lib".into()]).unwrap()); + assert!(git.has_missing_dependencies(["lib"]).is_err()); + } + + #[test] + fn detects_missing_dependencies_with_deleted_gitmodules() { + let tmp = tempdir().unwrap(); + let git = Git::new(tmp.path()); + git.init().unwrap(); + let gitmodules = tmp.path().join(".gitmodules"); + fs::write(&gitmodules, "[submodule \"lib/dep\"]\n\tpath = lib/dep\n\turl = ../dep\n") + .unwrap(); + git.add([".gitmodules"]).unwrap(); + git.cmd() + .args([ + "update-index", + "--add", + "--cacheinfo", + "160000,1111111111111111111111111111111111111111,lib/dep", + ]) + .exec() + .unwrap(); + assert!(git.has_missing_dependencies(["lib"]).unwrap()); + + fs::remove_file(gitmodules).unwrap(); + assert!(!git.submodules_initialized(&["lib".into()]).unwrap()); + assert!(git.has_missing_dependencies(["lib"]).unwrap()); + + let nested = tmp.path().join("nested"); + fs::create_dir(&nested).unwrap(); + assert!(!git.root(&nested).submodules_initialized(&[]).unwrap()); + assert!(git.root(&nested).submodules_uninitialized().unwrap()); + + git.cmd().args(["rm", "--cached", ".gitmodules"]).exec().unwrap(); + assert!(!git.submodules_initialized(&["lib".into()]).unwrap()); + assert!(git.has_missing_dependencies(["lib"]).is_err()); + } + + #[test] + fn keeps_submodule_status_in_worktree_without_gitmodules() { + let tmp = tempdir().unwrap(); + let root = tmp.path().join("main"); + fs::create_dir(&root).unwrap(); + let git = Git::new(&root); + git.init().unwrap(); + git.cmd() + .args([ + "-c", + "user.name=Foundry", + "-c", + "user.email=foundry@example.com", + "commit", + "--no-gpg-sign", + "--allow-empty", + "-m", + "test: initialize worktree fixture", + ]) + .exec() + .unwrap(); + let worktree = tmp.path().join("worktree"); + git.cmd().args(["worktree", "add", "--detach"]).arg(&worktree).exec().unwrap(); + + assert!(worktree.join(".git").is_file()); + let git = git.root(&worktree); + assert!(!git.submodules_initialized(&["lib".into()]).unwrap()); + assert!(git.has_missing_dependencies(["lib"]).is_err()); + } } diff --git a/crates/cli/src/utils/tempo.rs b/crates/cli/src/utils/tempo.rs new file mode 100644 index 0000000000000..1580843b7f6d4 --- /dev/null +++ b/crates/cli/src/utils/tempo.rs @@ -0,0 +1,233 @@ +//! Tempo utilities: fee token parsing and named nonce lanes (2D nonces). +//! +//! A "lane" is a friendly alias for a Tempo `nonce_key` (a [`U256`]). Lanes are defined in a +//! shared TOML file (default `tempo.lanes.toml` at the project root) so a team can reserve +//! independent sequential nonce streams for parallel scripts without coordinating on raw +//! `U256` selectors. +//! +//! Example `tempo.lanes.toml`: +//! +//! ```toml +//! deploy = 1 +//! ops = 2 +//! payments = 3 +//! ``` +//! +//! ```bash +//! cast erc20 transfer ... --tempo.lane payments +//! ``` + +use crate::opts::TempoOpts; +use alloy_primitives::{Address, U256}; +use eyre::{Result, eyre}; +use foundry_common::tempo::{ + ALPHA_USD_ADDRESS, BETA_USD_ADDRESS, OUSD_ADDRESS, PATH_USD_ADDRESS, THETA_USD_ADDRESS, +}; +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, + str::FromStr, +}; +use tempo_primitives::TempoAddressExt; + +/// Default name of the lanes file at the project root. +pub const DEFAULT_LANES_FILE: &str = "tempo.lanes.toml"; + +/// Result of resolving a `--tempo.lane ` argument against a lanes file. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ResolvedLane { + /// The lane name as provided on the CLI. + pub name: String, + /// The `nonce_key` the lane resolved to. + pub nonce_key: U256, +} + +/// Parses a fee token address, numeric TIP-20 token id, or known Tempo fee-token symbol. +pub fn parse_fee_token_address(symbol_or_address: &str) -> eyre::Result

{ + parse_fee_token_symbol(symbol_or_address).map_or_else( + || { + if let Ok(address) = Address::from_str(symbol_or_address) { + return Ok(address); + } + + symbol_or_address.parse::().map(token_id_to_address).map_err(|e| { + eyre!( + "invalid fee token '{symbol_or_address}': expected address, numeric TIP-20 token \ + id, or one of PathUSD, AlphaUSD, BetaUSD, ThetaUSD, OUSD: {e}" + ) + }) + }, + Ok, + ) +} + +fn parse_fee_token_symbol(symbol: &str) -> Option
{ + match symbol.trim().to_ascii_lowercase().as_str() { + "pathusd" | "path_usd" | "path-usd" | "usd" | "default" => Some(PATH_USD_ADDRESS), + "alphausd" | "alpha_usd" | "alpha-usd" => Some(ALPHA_USD_ADDRESS), + "betausd" | "beta_usd" | "beta-usd" => Some(BETA_USD_ADDRESS), + "thetausd" | "theta_usd" | "theta-usd" => Some(THETA_USD_ADDRESS), + "ousd" => Some(OUSD_ADDRESS), + _ => None, + } +} + +fn token_id_to_address(token_id: u64) -> Address { + let mut address_bytes = [0u8; 20]; + address_bytes[..12].copy_from_slice(&Address::TIP20_PREFIX); + address_bytes[12..20].copy_from_slice(&token_id.to_be_bytes()); + Address::from(address_bytes) +} + +/// Loads a TOML lanes file from `path`. +/// +/// Each top-level key is a lane name, and the value is the `nonce_key` (an integer or a +/// decimal/hex string parsed as [`U256`]). +pub fn load_lanes(path: &Path) -> Result> { + let contents = std::fs::read_to_string(path) + .map_err(|e| eyre!("failed to read tempo lanes file {}: {}", path.display(), e))?; + parse_lanes(&contents) + .map_err(|e| eyre!("failed to parse tempo lanes file {}: {}", path.display(), e)) +} + +fn parse_lanes(contents: &str) -> Result> { + let raw: BTreeMap = toml::from_str(contents)?; + let mut out = BTreeMap::new(); + for (name, value) in raw { + let nonce_key = match value { + toml::Value::Integer(n) => { + if n < 0 { + return Err(eyre!("invalid nonce_key for lane '{name}': must be non-negative")); + } + U256::from(n as u64) + } + toml::Value::String(s) => U256::from_str(s.trim()) + .map_err(|e| eyre!("invalid nonce_key for lane '{name}': {e}"))?, + other => { + return Err(eyre!( + "invalid nonce_key for lane '{name}': expected integer or string, got {}", + other.type_str(), + )); + } + }; + out.insert(name, nonce_key); + } + Ok(out) +} + +/// Resolves `opts.lane` against a lanes file and writes the resulting `nonce_key` to +/// `opts.nonce_key`. Returns the resolved lane (or `None` if no `--tempo.lane` was set). +/// +/// `root` is the project root used to locate the default lanes file +/// (`/tempo.lanes.toml`) when `--tempo.lanes-file` was not provided. +pub fn resolve_lane(opts: &mut TempoOpts, root: &Path) -> Result> { + let Some(lane_name) = opts.lane.clone() else { return Ok(None) }; + + let path: PathBuf = opts.lanes_file.clone().unwrap_or_else(|| root.join(DEFAULT_LANES_FILE)); + + if !path.exists() { + return Err(eyre!( + "tempo lanes file not found at {}\n\ + create it with `name = ` entries, e.g.:\n \ + deploy = 1\n \ + ops = 2\n \ + payments = 3", + path.display(), + )); + } + + let lanes = load_lanes(&path)?; + + let nonce_key = lanes.get(&lane_name).copied().ok_or_else(|| { + let mut known: Vec<&str> = lanes.keys().map(String::as_str).collect(); + known.sort_unstable(); + eyre!( + "lane '{lane_name}' not found in {} (known lanes: {})", + path.display(), + if known.is_empty() { "".to_string() } else { known.join(", ") }, + ) + })?; + + opts.nonce_key = Some(nonce_key); + Ok(Some(ResolvedLane { name: lane_name, nonce_key })) +} + +/// Prints `lane: (nonce_key=, nonce=)` to stderr (so it doesn't pollute +/// stdout for commands like `cast mktx` whose stdout is meant to be piped), giving +/// visibility into which 2D nonce lane was used. +pub fn maybe_print_resolved_lane(resolved: Option<&ResolvedLane>, nonce: u64) -> Result<()> { + if let Some(lane) = resolved { + sh_eprintln!("lane: {} (nonce_key={}, nonce={})", lane.name, lane.nonce_key, nonce)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_int_and_string_lane_values() { + let toml = r#" +deploy = 1 +ops = 2 +payments = "3" +big = "115792089237316195423570985008687907853269984665640564039457584007913129639935" +"#; + let lanes = parse_lanes(toml).unwrap(); + assert_eq!(lanes.get("deploy"), Some(&U256::ONE)); + assert_eq!(lanes.get("ops"), Some(&U256::from(2u64))); + assert_eq!(lanes.get("payments"), Some(&U256::from(3u64))); + assert_eq!(lanes.get("big"), Some(&U256::MAX)); + } + + #[test] + fn parse_lanes_rejects_invalid_string() { + let toml = "broken = \"not-a-number\""; + let err = parse_lanes(toml).unwrap_err(); + assert!(err.to_string().contains("invalid nonce_key for lane 'broken'")); + } + + #[test] + fn resolve_lane_sets_nonce_key_and_returns_resolved() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(DEFAULT_LANES_FILE); + std::fs::write(&path, "deploy = 7\npayments = 42\n").unwrap(); + + let mut opts = TempoOpts { lane: Some("payments".to_string()), ..Default::default() }; + let resolved = resolve_lane(&mut opts, dir.path()).unwrap().unwrap(); + assert_eq!(resolved.name, "payments"); + assert_eq!(resolved.nonce_key, U256::from(42u64)); + assert_eq!(opts.nonce_key, Some(U256::from(42u64))); + } + + #[test] + fn resolve_lane_returns_none_when_no_lane() { + let dir = tempfile::tempdir().unwrap(); + let mut opts = TempoOpts::default(); + let resolved = resolve_lane(&mut opts, dir.path()).unwrap(); + assert!(resolved.is_none()); + assert!(opts.nonce_key.is_none()); + } + + #[test] + fn resolve_lane_errors_when_file_missing() { + let dir = tempfile::tempdir().unwrap(); + let mut opts = TempoOpts { lane: Some("deploy".to_string()), ..Default::default() }; + let err = resolve_lane(&mut opts, dir.path()).unwrap_err(); + assert!(err.to_string().contains("tempo lanes file not found")); + } + + #[test] + fn resolve_lane_errors_when_lane_unknown() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(DEFAULT_LANES_FILE); + std::fs::write(&path, "deploy = 1\nops = 2\n").unwrap(); + + let mut opts = TempoOpts { lane: Some("payments".to_string()), ..Default::default() }; + let err = resolve_lane(&mut opts, dir.path()).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("lane 'payments' not found")); + assert!(msg.contains("deploy, ops")); + } +} diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 76f42de2f0f6b..182e74d38b067 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -18,6 +18,7 @@ foundry-common-fmt.workspace = true foundry-compilers.workspace = true foundry-config.workspace = true +alloy-chains.workspace = true alloy-dyn-abi = { workspace = true, features = ["arbitrary", "eip712"] } alloy-eips.workspace = true alloy-json-abi.workspace = true @@ -29,27 +30,34 @@ alloy-primitives = { workspace = true, features = [ "rlp", ] } alloy-provider.workspace = true +alloy-signer.workspace = true alloy-pubsub.workspace = true alloy-rpc-client.workspace = true alloy-rpc-types = { workspace = true, features = ["eth", "engine"] } -alloy-serde.workspace = true +alloy-rpc-types-engine = { workspace = true, features = ["jwt-aws-lc-rs"] } alloy-sol-types.workspace = true -alloy-transport-http = { workspace = true, features = [ - "reqwest", - "reqwest-rustls-tls", -] } alloy-transport-ipc.workspace = true alloy-transport-ws.workspace = true alloy-transport.workspace = true +alloy-transport-mpp.workspace = true alloy-consensus = { workspace = true, features = ["k256"] } alloy-network.workspace = true -solar-parse.workspace = true -solar-sema.workspace = true +op-alloy-network = { workspace = true, optional = true } +op-alloy-rpc-types = { workspace = true, optional = true } + +# Base +base-common-network = { workspace = true, optional = true, features = ["serde", "std"] } +base-common-rpc-types = { workspace = true, optional = true, features = ["serde", "std"] } + +revm.workspace = true + +solar.workspace = true tower.workspace = true clap = { version = "4", features = ["derive", "env", "unicode", "wrap_help"] } +alloy-rlp.workspace = true comfy-table.workspace = true dunce.workspace = true eyre.workspace = true @@ -62,24 +70,49 @@ reqwest.workspace = true semver.workspace = true serde = { workspace = true, features = ["derive"] } serde_json.workspace = true +tempfile.workspace = true thiserror.workspace = true tokio.workspace = true tracing.workspace = true url.workspace = true +wait-timeout.workspace = true walkdir.workspace = true yansi.workspace = true anstream.workspace = true anstyle.workspace = true -terminal_size.workspace = true ciborium.workspace = true flate2.workspace = true +tempo-alloy.workspace = true +tempo-primitives.workspace = true +mpp.workspace = true +foundry-wallets = { workspace = true, features = ["browser", "tempo"] } +alloy-signer-local = { workspace = true, features = ["mnemonic-all-languages"] } +base64.workspace = true +sha2 = "0.11" [build-dependencies] chrono.workspace = true -vergen = { workspace = true, features = ["build", "git", "gitcl"] } +vergen = { workspace = true, features = ["build", "emit_and_set"] } [dev-dependencies] tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } axum = { workspace = true } +foundry-evm-hardforks.workspace = true +k256 = { workspace = true } + +[features] +default = ["optimism"] +monad = ["foundry-config/monad"] +base = [ + "dep:base-common-network", + "dep:base-common-rpc-types", + "foundry-common-fmt/base", + "foundry-config/base", +] +optimism = [ + "dep:op-alloy-network", + "dep:op-alloy-rpc-types", + "foundry-common-fmt/optimism", +] diff --git a/crates/common/build.rs b/crates/common/build.rs index aaba00e0d2da7..b2bcadf66d18b 100644 --- a/crates/common/build.rs +++ b/crates/common/build.rs @@ -1,53 +1,35 @@ -use std::{env, error::Error}; +#![expect(clippy::disallowed_macros)] use chrono::DateTime; -use vergen::EmitBuilder; +use std::{error::Error, path::PathBuf}; -#[expect(clippy::disallowed_macros)] fn main() -> Result<(), Box> { - // Re-run the build script if the build script itself changes or if the - // environment variables change. println!("cargo:rerun-if-changed=build.rs"); - println!("cargo:rerun-if-env-changed=TAG_NAME"); - println!("cargo:rerun-if-env-changed=PROFILE"); - - EmitBuilder::builder() - .build_date() - .build_timestamp() - .git_describe(false, true, None) - .git_sha(false) - .emit_and_set()?; - - // Set the Git SHA of the latest commit. - let sha = env::var("VERGEN_GIT_SHA")?; - let sha_short = &sha[..10]; - - // Set the version suffix and whether the version is a nightly build. - // if not on a tag: 0.3.0-dev+ba03de0019.1737036656.debug - // if on a tag: 0.3.0-stable+ba03de0019.1737036656.release - let tag_name = env::var("TAG_NAME") - .or_else(|_| env::var("CARGO_TAG_NAME")) - .unwrap_or_else(|_| String::from("dev")); - let (is_nightly, version_suffix) = if tag_name.contains("nightly") { - (true, "-nightly".to_string()) - } else { - (false, format!("-{tag_name}")) - }; - - // Whether the version is a nightly build. + + let build = vergen::Build::builder().build_date(true).build_timestamp(true).build(); + let git = vergen::Gitcl::builder().describe(false, true, None).sha(false).build(); + + vergen::Emitter::new().add_instructions(&build)?.add_instructions(&git)?.emit_and_set()?; + + // `vergen` warns and carries on when it cannot reach a git worktree, so `VERGEN_GIT_SHA` is + // simply unset when building outside one, e.g. from a release source tarball. Fall back the + // same way `TAG_NAME` does below instead of assuming the variable is set and long enough. + let sha = try_env_var("VERGEN_GIT_SHA").unwrap_or_else(|| String::from("unknown")); + let sha_short = sha.get(..10).unwrap_or(&sha); + + let tag_name = try_env_var("TAG_NAME").unwrap_or_else(|| String::from("dev")); + let version = release_version(&env_var("CARGO_PKG_VERSION"), &tag_name); + let is_nightly = tag_name.starts_with("nightly"); + if is_nightly { println!("cargo:rustc-env=FOUNDRY_IS_NIGHTLY_VERSION=true"); } - // Set formatted version strings - let pkg_version = env::var("CARGO_PKG_VERSION")?; - - // Append the profile to the version string - let out_dir = env::var("OUT_DIR").unwrap(); - let profile = out_dir.rsplit(std::path::MAIN_SEPARATOR).nth(3).unwrap(); + // `PROFILE` captures only release or debug. Get the actual name from the out directory. + let out_dir = PathBuf::from(env_var("OUT_DIR")); + let profile = out_dir.components().rev().nth(3).unwrap().as_os_str().to_str().unwrap(); - // Set the build timestamp. - let build_timestamp = env::var("VERGEN_BUILD_TIMESTAMP")?; + let build_timestamp = env_var("VERGEN_BUILD_TIMESTAMP"); let build_timestamp_unix = DateTime::parse_from_rfc3339(&build_timestamp)?.timestamp(); // The SemVer compatible version information for Foundry. @@ -57,16 +39,14 @@ fn main() -> Result<(), Box> { // - The build profile. // Example: forge 0.3.0-nightly+3cb96bde9b.1737036656.debug println!( - "cargo:rustc-env=FOUNDRY_SEMVER_VERSION={pkg_version}{version_suffix}+{sha_short}.{build_timestamp_unix}.{profile}" + "cargo:rustc-env=FOUNDRY_SEMVER_VERSION={version}+{sha_short}.{build_timestamp_unix}.{profile}" ); // The short version information for the Foundry CLI. // - The latest version from Cargo.toml // - The short SHA of the latest commit. // Example: 0.3.0-dev (3cb96bde9b) - println!( - "cargo:rustc-env=FOUNDRY_SHORT_VERSION={pkg_version}{version_suffix} ({sha_short} {build_timestamp})" - ); + println!("cargo:rustc-env=FOUNDRY_SHORT_VERSION={version} ({sha_short} {build_timestamp})"); // The long version information for the Foundry CLI. // - The latest version from Cargo.toml. @@ -83,12 +63,44 @@ fn main() -> Result<(), Box> { // Build Timestamp: 2025-01-16T15:04:03.522021223Z (1737039843) // Build Profile: debug // ``` - println!("cargo:rustc-env=FOUNDRY_LONG_VERSION_0=Version: {pkg_version}{version_suffix}"); - println!("cargo:rustc-env=FOUNDRY_LONG_VERSION_1=Commit SHA: {sha}"); - println!( - "cargo:rustc-env=FOUNDRY_LONG_VERSION_2=Build Timestamp: {build_timestamp} ({build_timestamp_unix})" + let long_version = format!( + "\ +Version: {version} +Commit SHA: {sha} +Build Timestamp: {build_timestamp} ({build_timestamp_unix}) +Build Profile: {profile}" ); - println!("cargo:rustc-env=FOUNDRY_LONG_VERSION_3=Build Profile: {profile}"); + assert_eq!(long_version.lines().count(), 4); + for (i, line) in long_version.lines().enumerate() { + println!("cargo:rustc-env=FOUNDRY_LONG_VERSION_{i}={line}"); + } + + // The long SHA of the latest commit. + // + // Example: 5186142d3bb4d1be7bb4ade548b77c8e2270717e + println!("cargo:rustc-env=FOUNDRY_COMMIT_SHA={sha}"); Ok(()) } + +fn env_var(name: &str) -> String { + try_env_var(name).unwrap() +} + +fn release_version(pkg_version: &str, tag_name: &str) -> String { + if let Some(version) = tag_name.strip_prefix('v') { + return version.to_owned(); + } + + // Normalize `nightly-` to `nightly` so tarball and Docker nightly + // artifacts produce the same version string. The commit identifier is + // already included in the SemVer build metadata (after `+`). + let normalized = if tag_name.starts_with("nightly-") { "nightly" } else { tag_name }; + + format!("{pkg_version}-{normalized}") +} + +fn try_env_var(name: &str) -> Option { + println!("cargo:rerun-if-env-changed={name}"); + std::env::var(name).ok() +} diff --git a/crates/common/fmt/Cargo.toml b/crates/common/fmt/Cargo.toml index 2c9c5223df30f..209eedc94f857 100644 --- a/crates/common/fmt/Cargo.toml +++ b/crates/common/fmt/Cargo.toml @@ -16,18 +16,35 @@ workspace = true [dependencies] alloy-primitives.workspace = true alloy-dyn-abi = { workspace = true, features = ["eip712"] } +eyre.workspace = true # ui alloy-consensus.workspace = true +op-alloy-consensus = { workspace = true, optional = true } alloy-network.workspace = true alloy-rpc-types = { workspace = true, features = ["eth"] } +op-alloy-rpc-types = { workspace = true, optional = true } alloy-serde.workspace = true serde.workspace = true serde_json.workspace = true chrono.workspace = true revm.workspace = true yansi.workspace = true +comfy-table.workspace = true + +# Base +base-common-consensus = { workspace = true, optional = true, features = ["std"] } +base-common-rpc-types = { workspace = true, optional = true, features = ["std"] } + +# Tempo +tempo-alloy.workspace = true [dev-dependencies] foundry-macros.workspace = true similar-asserts.workspace = true +proptest.workspace = true + +[features] +default = ["optimism"] +base = ["dep:base-common-consensus", "dep:base-common-rpc-types"] +optimism = ["dep:op-alloy-consensus", "dep:op-alloy-rpc-types"] diff --git a/crates/common/fmt/proptest-regressions/dynamic.txt b/crates/common/fmt/proptest-regressions/dynamic.txt new file mode 100644 index 0000000000000..9e16cd7d3eae1 --- /dev/null +++ b/crates/common/fmt/proptest-regressions/dynamic.txt @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 885aa25152cd93b8ddf5e98d7bfdc995d70d059b823b5589e793df41be92d9ce # shrinks to l = 0, h = 18446744073709551616 diff --git a/crates/common/fmt/src/console.rs b/crates/common/fmt/src/console.rs index 43c2a4621528b..6e5227c5c3ffe 100644 --- a/crates/common/fmt/src/console.rs +++ b/crates/common/fmt/src/console.rs @@ -1,7 +1,11 @@ use super::UIfmt; use alloy_primitives::{Address, Bytes, FixedBytes, I256, U256}; +use comfy_table::{ContentLineStyle, LineStyle, Table, TableStyle}; use std::fmt::{self, Write}; +/// Maximum accepted `%e` precision. +const MAX_EXPONENTIAL_PRECISION: usize = 1024; + /// A piece is a portion of the format string which represents the next part to emit. #[derive(Clone, Debug, PartialEq, Eq)] pub enum Piece<'a> { @@ -108,7 +112,10 @@ impl<'a> Parser<'a> { let n = self.integer(start); if let Some((_, 'e')) = self.peek() { self.chars.next(); - return Ok(FormatSpec::Exponential(n)); + return n + .filter(|&precision| precision <= MAX_EXPONENTIAL_PRECISION) + .map(|precision| FormatSpec::Exponential(Some(precision))) + .ok_or(ParseArgError::Err); } } @@ -222,24 +229,13 @@ impl ConsoleFmt for U256 { let integer = amount / exp10; let decimal = (amount % exp10).to_string(); let decimal = format!("{decimal:0>log$}").trim_end_matches('0').to_string(); - if !decimal.is_empty() { - format!("{integer}.{decimal}e{log}") - } else { + if decimal.is_empty() { format!("{integer}e{log}") - } - } - FormatSpec::Exponential(Some(precision)) => { - let exp10 = Self::from(10).pow(Self::from(precision)); - let amount = *self; - let integer = amount / exp10; - let decimal = (amount % exp10).to_string(); - let decimal = format!("{decimal:0>precision$}").trim_end_matches('0').to_string(); - if !decimal.is_empty() { - format!("{integer}.{decimal}") } else { - format!("{integer}") + format!("{integer}.{decimal}e{log}") } } + FormatSpec::Exponential(Some(precision)) => format_fixed(*self, "", precision), } } } @@ -266,29 +262,34 @@ impl ConsoleFmt for I256 { let integer = (amount / exp10).twos_complement(); let decimal = (amount % exp10).twos_complement().to_string(); let decimal = format!("{decimal:0>log$}").trim_end_matches('0').to_string(); - if !decimal.is_empty() { - format!("{sign}{integer}.{decimal}e{log}") - } else { + if decimal.is_empty() { format!("{sign}{integer}e{log}") + } else { + format!("{sign}{integer}.{decimal}e{log}") } } FormatSpec::Exponential(Some(precision)) => { let amount = *self; let sign = if amount.is_negative() { "-" } else { "" }; - let exp10 = Self::exp10(precision); - let integer = (amount / exp10).twos_complement(); - let decimal = (amount % exp10).twos_complement().to_string(); - let decimal = format!("{decimal:0>precision$}").trim_end_matches('0').to_string(); - if !decimal.is_empty() { - format!("{sign}{integer}.{decimal}") - } else { - format!("{sign}{integer}") - } + format_fixed(amount.unsigned_abs(), sign, precision) } } } } +fn format_fixed(amount: U256, sign: &str, precision: usize) -> String { + let (integer, decimal) = U256::from(10) + .checked_pow(U256::from(precision)) + .map_or((U256::ZERO, amount), |exp10| (amount / exp10, amount % exp10)); + let decimal = decimal.to_string(); + let decimal = format!("{decimal:0>precision$}").trim_end_matches('0').to_string(); + if decimal.is_empty() { + format!("{sign}{integer}") + } else { + format!("{sign}{integer}.{decimal}") + } +} + impl ConsoleFmt for Address { fn fmt(&self, spec: FormatSpec) -> String { match spec { @@ -407,10 +408,39 @@ fn format_spec<'a>( } } +pub fn console_table_format( + keys: Option<&[&dyn ConsoleFmt]>, + values: &[&dyn ConsoleFmt], +) -> String { + let keys_strings: Vec = match keys { + Some(keys) => keys.iter().map(|k| k.fmt(FormatSpec::String)).collect(), + None => (0..values.len()).map(|i| i.to_string()).collect(), + }; + let values_strings: Vec = values.iter().map(|v| v.fmt(FormatSpec::String)).collect(); + + const STYLE: TableStyle = TableStyle::new() + .top_border(LineStyle::new('┌', '─', '┬', '┐')) + .header_lines(ContentLineStyle::new('│', '│', '│')) + .header_separator(LineStyle::new('├', '─', '┼', '┤')) + .content_lines(ContentLineStyle::new('│', '│', '│')) + .bottom_border(LineStyle::new('└', '─', '┴', '┘')); + + let mut table = Table::new(); + table.load_style(STYLE); + table.set_header(vec!["(index)", "Values"]); + for i in 0..keys_strings.len().max(values_strings.len()) { + let key = keys_strings.get(i).map(String::as_str).unwrap_or(""); + let value = values_strings.get(i).map(String::as_str).unwrap_or(""); + table.add_row(vec![key, value]); + } + table.to_string() +} + #[cfg(test)] mod tests { + use super::*; - use alloy_primitives::{B256, address}; + use alloy_primitives::{address, b256, bytes}; use foundry_macros::ConsoleFmt; use std::str::FromStr; @@ -483,9 +513,7 @@ mod tests { assert_eq!("NaN", fmt_1("%x", &true)); assert_eq!("'true'", fmt_1("%o", &true)); - let b32 = - B256::from_str("0xdeadbeef00000000000000000000000000000000000000000000000000000000") - .unwrap(); + let b32 = b256!("0xdeadbeef00000000000000000000000000000000000000000000000000000000"); assert_eq!( "0xdeadbeef00000000000000000000000000000000000000000000000000000000", fmt_1("%s", &b32) @@ -510,7 +538,7 @@ mod tests { assert_eq!("0xdEADBEeF00000000000000000000000000000000", fmt_1("%x", &addr)); assert_eq!("'0xdEADBEeF00000000000000000000000000000000'", fmt_1("%o", &addr)); - let bytes = Bytes::from_str("0xdeadbeef").unwrap(); + let bytes = bytes!("0xdeadbeef"); assert_eq!("0xdeadbeef", fmt_1("%s", &bytes)); assert_eq!("NaN", fmt_1("%d", &bytes)); assert_eq!("NaN", fmt_1("%i", &bytes)); @@ -564,6 +592,53 @@ mod tests { ); } + // Overflow used to panic or silently produce incorrect digits. + #[test] + fn test_console_log_exponential_precision_overflow() { + let fmt_1 = |spec: &str, arg: &dyn ConsoleFmt| console_format(spec, &[arg]); + + // 10^256 wraps to zero with unchecked exponentiation. + assert_eq!(format!("0.{}1", "0".repeat(255)), fmt_1("%256e", &U256::ONE)); + + // 10^78 overflows U256; 10^77 still fits. + let ten_pow_77 = U256::from(10).pow(U256::from(77u64)); + assert_eq!("0.1", fmt_1("%78e", &ten_pow_77)); + + assert_eq!("1", fmt_1("%77e", &ten_pow_77)); + + // 10^77 exceeds I256::MAX. + assert_eq!(format!("0.{}1", "0".repeat(76)), fmt_1("%77e", &I256::try_from(1).unwrap())); + assert_eq!(format!("-0.{}1", "0".repeat(76)), fmt_1("%77e", &I256::try_from(-1).unwrap())); + + // Preserve the value at the maximum accepted precision. + assert_eq!(format!("0.{}1", "0".repeat(1023)), fmt_1("%1024e", &U256::ONE)); + + // Invalid precisions remain literal and do not consume the value. + assert_eq!("%1025e 1", fmt_1("%1025e", &U256::ONE)); + assert_eq!("%99999999999999999999e 1", fmt_1("%99999999999999999999e", &U256::ONE)); + + assert_eq!("1", fmt_1("%18e", &U256::from(1_000_000_000_000_000_000u64))); + + assert_eq!("0", fmt_1("%0e", &U256::ZERO)); + assert_eq!("0", fmt_1("%256e", &U256::ZERO)); + + // Check signed and unsigned extrema at their overflow boundaries. + let expect_fallback = |digits: String, precision: usize, sign: &str| { + let padded = format!("{digits:0>precision$}"); + let trimmed = padded.trim_end_matches('0'); + if trimmed.is_empty() { format!("{sign}0") } else { format!("{sign}0.{trimmed}") } + }; + assert_eq!(expect_fallback(U256::MAX.to_string(), 78, ""), fmt_1("%78e", &U256::MAX)); + assert_eq!( + expect_fallback(I256::MIN.unsigned_abs().to_string(), 77, "-"), + fmt_1("%77e", &I256::MIN) + ); + assert_eq!( + expect_fallback(I256::MAX.unsigned_abs().to_string(), 77, ""), + fmt_1("%77e", &I256::MAX) + ); + } + #[test] fn test_console_log_format() { let mut log1 = Log1 { p_0: "foo %s".to_string(), p_1: U256::from(100) }; @@ -610,4 +685,80 @@ mod tests { let call = Logs::Log1(log1); assert_eq!(call.fmt(Default::default()), "foo 42 bar"); } + + #[test] + fn test_console_table_format() { + // auto-indexed, uint256 values + let values: &[&dyn ConsoleFmt] = &[&U256::from(100), &U256::from(200), &U256::from(300)]; + assert_eq!( + console_table_format(None, values), + "┌─────────┬────────┐\n\ + │ (index) │ Values │\n\ + ├─────────┼────────┤\n\ + │ 0 │ 100 │\n\ + │ 1 │ 200 │\n\ + │ 2 │ 300 │\n\ + └─────────┴────────┘" + ); + + // string keys, uint256 values + // key col expands to fit "charlie123" and value col expands to fit "20000000000000000" + let keys: &[&dyn ConsoleFmt] = + &[&String::from("alice"), &String::from("bob"), &String::from("charlie123")]; + let values: &[&dyn ConsoleFmt] = &[ + &U256::ONE, + &U256::from_str("20000000000000000").unwrap(), + &U256::from_str("30000000000").unwrap(), + ]; + assert_eq!( + console_table_format(Some(keys), values), + "┌────────────┬───────────────────┐\n\ + │ (index) │ Values │\n\ + ├────────────┼───────────────────┤\n\ + │ alice │ 1 │\n\ + │ bob │ 20000000000000000 │\n\ + │ charlie123 │ 30000000000 │\n\ + └────────────┴───────────────────┘" + ); + + // empty table + assert_eq!( + console_table_format(None, &[]), + "┌─────────┬────────┐\n\ + │ (index) │ Values │\n\ + ├─────────┼────────┤\n\ + └─────────┴────────┘" + ); + + // more keys than values + let keys: &[&dyn ConsoleFmt] = + &[&String::from("alice"), &String::from("bob"), &String::from("charlie")]; + let values: &[&dyn ConsoleFmt] = &[&U256::ONE, &U256::from(2)]; + assert_eq!( + console_table_format(Some(keys), values), + "┌─────────┬────────┐\n\ + │ (index) │ Values │\n\ + ├─────────┼────────┤\n\ + │ alice │ 1 │\n\ + │ bob │ 2 │\n\ + │ charlie │ │\n\ + └─────────┴────────┘" + ); + + // more values than keys + let keys: &[&dyn ConsoleFmt] = &[&String::from("alice"), &String::from("bob")]; + let values: &[&dyn ConsoleFmt] = + &[&U256::ONE, &U256::from(2), &U256::from(3), &U256::from(4)]; + assert_eq!( + console_table_format(Some(keys), values), + "┌─────────┬────────┐\n\ + │ (index) │ Values │\n\ + ├─────────┼────────┤\n\ + │ alice │ 1 │\n\ + │ bob │ 2 │\n\ + │ │ 3 │\n\ + │ │ 4 │\n\ + └─────────┴────────┘" + ); + } } diff --git a/crates/common/fmt/src/dynamic.rs b/crates/common/fmt/src/dynamic.rs index 44e8fbd662b95..9e8dcef30d912 100644 --- a/crates/common/fmt/src/dynamic.rs +++ b/crates/common/fmt/src/dynamic.rs @@ -1,7 +1,12 @@ use super::{format_int_exp, format_uint_exp}; use alloy_dyn_abi::{DynSolType, DynSolValue}; use alloy_primitives::hex; -use std::fmt; +use eyre::Result; +use serde_json::{Map, Value}; +use std::{ + collections::{BTreeMap, HashMap}, + fmt, +}; /// [`DynSolValue`] formatter. struct DynValueFormatter { @@ -102,14 +107,12 @@ struct DynValueDisplay<'a> { impl<'a> DynValueDisplay<'a> { /// Creates a new [`Display`](fmt::Display) wrapper for the given value. - #[inline] - fn new(value: &'a DynSolValue, raw: bool) -> Self { + const fn new(value: &'a DynSolValue, raw: bool) -> Self { Self { value, formatter: DynValueFormatter { raw } } } } impl fmt::Display for DynValueDisplay<'_> { - #[inline] fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.formatter.value(self.value, f) } @@ -146,6 +149,159 @@ pub fn format_token_raw(value: &DynSolValue) -> String { DynValueDisplay::new(value, true).to_string() } +/// Serializes given [DynSolValue] into a [serde_json::Value]. +/// +/// If `strict` is `true`, numeric values are serialized according to their Solidity type width: +/// integers with `bits <= 64` become JSON numbers, while wider integer types are serialized as +/// strings, even when a particular runtime value would fit into `i64`/`u64`. +pub fn serialize_value_as_json( + value: DynSolValue, + defs: Option<&StructDefinitions>, + strict: bool, +) -> Result { + if let Some(defs) = defs { + _serialize_value_as_json(value, defs, strict) + } else { + _serialize_value_as_json(value, &StructDefinitions::default(), strict) + } +} + +fn _serialize_value_as_json( + value: DynSolValue, + defs: &StructDefinitions, + strict: bool, +) -> Result { + match value { + DynSolValue::Bool(b) => Ok(Value::Bool(b)), + DynSolValue::String(s) => { + // Strings are allowed to contain stringified JSON objects, so we try to parse it like + // one first. + if let Ok(map) = serde_json::from_str(&s) { + Ok(Value::Object(map)) + } else { + Ok(Value::String(s)) + } + } + DynSolValue::Bytes(b) => Ok(Value::String(hex::encode_prefixed(b))), + DynSolValue::FixedBytes(b, size) => Ok(Value::String(hex::encode_prefixed(&b[..size]))), + DynSolValue::Int(i, bits) => { + match (i64::try_from(i), strict) { + // In strict mode, return as number only if the type dictates so + (Ok(n), true) if bits <= 64 => Ok(Value::Number(n.into())), + // In normal mode, return as number if the number can be accurately represented. + (Ok(n), false) => Ok(Value::Number(n.into())), + // Otherwise, fallback to its string representation to preserve precision and ensure + // compatibility with alloy's `DynSolType` coercion. + _ => Ok(Value::String(i.to_string())), + } + } + DynSolValue::Uint(i, bits) => { + match (u64::try_from(i), strict) { + // In strict mode, return as number only if the type dictates so + (Ok(n), true) if bits <= 64 => Ok(Value::Number(n.into())), + // In normal mode, return as number if the number can be accurately represented. + (Ok(n), false) => Ok(Value::Number(n.into())), + // Otherwise, fallback to its string representation to preserve precision and ensure + // compatibility with alloy's `DynSolType` coercion. + _ => Ok(Value::String(i.to_string())), + } + } + DynSolValue::Address(a) => Ok(Value::String(a.to_string())), + DynSolValue::Array(e) | DynSolValue::FixedArray(e) => Ok(Value::Array( + e.into_iter() + .map(|v| _serialize_value_as_json(v, defs, strict)) + .collect::>()?, + )), + DynSolValue::CustomStruct { name, prop_names, tuple } => { + let values = tuple + .into_iter() + .map(|v| _serialize_value_as_json(v, defs, strict)) + .collect::>>()?; + let mut map: HashMap = prop_names.into_iter().zip(values).collect(); + + // If the struct def is known, manually build a `Map` to preserve the order. + if let Some(fields) = defs.get(&name)? { + let mut ordered_map = Map::with_capacity(fields.len()); + for (field_name, _) in fields { + if let Some(serialized_value) = map.remove(field_name) { + ordered_map.insert(field_name.clone(), serialized_value); + } + } + // Explicitly return a `Value::Object` to avoid ambiguity. + return Ok(Value::Object(ordered_map)); + } + + // Otherwise, fall back to alphabetical sorting for deterministic output. + Ok(Value::Object(map.into_iter().collect::>())) + } + DynSolValue::Tuple(values) => Ok(Value::Array( + values + .into_iter() + .map(|v| _serialize_value_as_json(v, defs, strict)) + .collect::>()?, + )), + DynSolValue::Function(_) => { + eyre::bail!("cannot serialize function pointer"); + } + } +} + +// -- STRUCT DEFINITIONS ------------------------------------------------------- + +pub type TypeDefMap = BTreeMap>; + +#[derive(Debug, Clone, Default)] +pub struct StructDefinitions(TypeDefMap); + +impl From for StructDefinitions { + fn from(map: TypeDefMap) -> Self { + Self::new(map) + } +} + +impl StructDefinitions { + pub const fn new(map: TypeDefMap) -> Self { + Self(map) + } + + pub fn keys(&self) -> impl Iterator { + self.0.keys() + } + + pub fn values(&self) -> impl Iterator { + self.0.values().map(|v| v.as_slice()) + } + + pub fn get(&self, key: &str) -> eyre::Result> { + if let Some(value) = self.0.get(key) { + return Ok(Some(value)); + } + + let matches: Vec<&[(String, String)]> = self + .0 + .iter() + .filter_map(|(k, v)| { + if let Some((_, struct_name)) = k.split_once('.') + && struct_name == key + { + return Some(v.as_slice()); + } + None + }) + .collect(); + + match matches.len() { + 0 => Ok(None), + 1 => Ok(Some(matches[0])), + _ => { + eyre::bail!( + "there are several structs with the same name. Use `.{key}` instead." + ); + } + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -182,4 +338,95 @@ mod tests { "0xFb6916095cA1Df60bb79ce92cE3EA74c37c5d359" ); } + + #[test] + fn strict_uint256_array_is_homogeneous() { + let small = U256::ONE; + let big = U256::ONE << 200; + + let arr = + DynSolValue::Array(vec![DynSolValue::Uint(small, 256), DynSolValue::Uint(big, 256)]); + + let json = serialize_value_as_json(arr, None, true).unwrap(); + + assert_eq!( + json, + serde_json::json!([ + "1", + "1606938044258990275541962092341162602522202993782792835301376" + ]) + ); + } + + proptest::proptest! { + #[test] + fn test_serialize_uint_as_json(l in 0u64..u64::MAX, h in ((u64::MAX as u128) + 1)..u128::MAX) { + let l_min_bits = (64 - l.leading_zeros()) as usize; + let h_min_bits = (128 - h.leading_zeros()) as usize; + + // values that fit in u64 should be serialized as a number in !strict mode + assert_eq!( + serialize_value_as_json(DynSolValue::Uint(l.try_into().unwrap(), l_min_bits), None, false).unwrap(), + serde_json::json!(l) + ); + // values that dont fit in u64 should be serialized as a string in !strict mode + assert_eq!( + serialize_value_as_json(DynSolValue::Uint(h.try_into().unwrap(), h_min_bits), None, false).unwrap(), + serde_json::json!(h.to_string()) + ); + + // values should be serialized according to the type + // since l_min_bits <= 64, expect the serialization to be a number + assert_eq!( + serialize_value_as_json(DynSolValue::Uint(l.try_into().unwrap(), l_min_bits), None, true).unwrap(), + serde_json::json!(l) + ); + // since `h_min_bits` is specified for the number `l`, expect the serialization to be a string + // even though `l` fits in a u64 + assert_eq!( + serialize_value_as_json(DynSolValue::Uint(l.try_into().unwrap(), h_min_bits), None, true).unwrap(), + serde_json::json!(l.to_string()) + ); + // since `h_min_bits` is specified for the number `h`, expect the serialization to be a string + assert_eq!( + serialize_value_as_json(DynSolValue::Uint(h.try_into().unwrap(), h_min_bits), None, true).unwrap(), + serde_json::json!(h.to_string()) + ); + } + + #[test] + fn test_serialize_int_as_json(l in 0i64..=i64::MAX, h in ((i64::MAX as i128) + 1)..=i128::MAX) { + let l_min_bits = (64 - (l as u64).leading_zeros()) as usize + 1; + let h_min_bits = (128 - (h as u128).leading_zeros()) as usize + 1; + + // values that fit in i64 should be serialized as a number in !strict mode + assert_eq!( + serialize_value_as_json(DynSolValue::Int(l.try_into().unwrap(), l_min_bits), None, false).unwrap(), + serde_json::json!(l) + ); + // values that dont fit in i64 should be serialized as a string in !strict mode + assert_eq!( + serialize_value_as_json(DynSolValue::Int(h.try_into().unwrap(), h_min_bits), None, false).unwrap(), + serde_json::json!(h.to_string()) + ); + + // values should be serialized according to the type + // since l_min_bits <= 64, expect the serialization to be a number + assert_eq!( + serialize_value_as_json(DynSolValue::Int(l.try_into().unwrap(), l_min_bits), None, true).unwrap(), + serde_json::json!(l) + ); + // since `h_min_bits` is specified for the number `l`, expect the serialization to be a string + // even though `l` fits in an i64 + assert_eq!( + serialize_value_as_json(DynSolValue::Int(l.try_into().unwrap(), h_min_bits), None, true).unwrap(), + serde_json::json!(l.to_string()) + ); + // since `h_min_bits` is specified for the number `h`, expect the serialization to be a string + assert_eq!( + serialize_value_as_json(DynSolValue::Int(h.try_into().unwrap(), h_min_bits), None, true).unwrap(), + serde_json::json!(h.to_string()) + ); + } + } } diff --git a/crates/common/fmt/src/exp.rs b/crates/common/fmt/src/exp.rs index c3645a2129313..73054f3805f59 100644 --- a/crates/common/fmt/src/exp.rs +++ b/crates/common/fmt/src/exp.rs @@ -15,7 +15,6 @@ use yansi::Paint; /// 1234124124 -> 1.23e9 /// 10000000 -> 1e7 /// ``` -#[inline] pub fn to_exp_notation(value: U256, precision: usize, trim_end_zeros: bool, sign: Sign) -> String { let stringified = value.to_string(); let exponent = stringified.len() - 1; diff --git a/crates/common/fmt/src/lib.rs b/crates/common/fmt/src/lib.rs index f571b1a450fb4..45ed47263ce32 100644 --- a/crates/common/fmt/src/lib.rs +++ b/crates/common/fmt/src/lib.rs @@ -3,13 +3,19 @@ #![cfg_attr(not(test), warn(unused_crate_dependencies))] mod console; -pub use console::{ConsoleFmt, FormatSpec, console_format}; +pub use console::{ConsoleFmt, FormatSpec, console_format, console_table_format}; mod dynamic; -pub use dynamic::{format_token, format_token_raw, format_tokens, format_tokens_raw, parse_tokens}; +pub use dynamic::{ + StructDefinitions, TypeDefMap, format_token, format_token_raw, format_tokens, + format_tokens_raw, parse_tokens, serialize_value_as_json, +}; mod exp; pub use exp::{format_int_exp, format_uint_exp, to_exp_notation}; mod ui; -pub use ui::{EthValue, UIfmt, get_pretty_block_attr, get_pretty_tx_attr}; +pub use ui::{ + EthValue, UIfmt, UIfmtHeaderExt, UIfmtReceiptExt, UIfmtSignatureExt, get_pretty_block_attr, + get_pretty_receipt_attr, get_pretty_tx_attr, +}; diff --git a/crates/common/fmt/src/ui.rs b/crates/common/fmt/src/ui.rs index 893d48f55c06c..a8ef79a8d31f9 100644 --- a/crates/common/fmt/src/ui.rs +++ b/crates/common/fmt/src/ui.rs @@ -1,19 +1,41 @@ //! Helper trait and functions to format Ethereum types. use alloy_consensus::{ - Eip658Value, Receipt, ReceiptWithBloom, Transaction as TxTrait, TxEnvelope, TxType, Typed2718, + BlockHeader, Eip658Value, Signed, Transaction as TxTrait, TxEip1559, TxEip2930, + TxEip4844Variant, TxEip7702, TxEnvelope, TxLegacy, TxReceipt, Typed2718, + transaction::TxHashRef, }; use alloy_network::{ - AnyHeader, AnyReceiptEnvelope, AnyRpcBlock, AnyRpcTransaction, AnyTransactionReceipt, - AnyTxEnvelope, ReceiptResponse, + AnyRpcBlock, AnyRpcHeader, AnyRpcTransaction, AnyTransactionReceipt, AnyTxEnvelope, + BlockResponse, Network, ReceiptResponse, primitives::HeaderResponse, +}; +use alloy_primitives::{ + Address, Bloom, Bytes, FixedBytes, I256, Signature, U8, U64, U256, Uint, hex, }; -use alloy_primitives::{Address, Bloom, Bytes, FixedBytes, I256, U8, U64, U256, Uint, hex}; use alloy_rpc_types::{ AccessListItem, Block, BlockTransactions, Header, Log, Transaction, TransactionReceipt, }; use alloy_serde::{OtherFields, WithOtherFields}; use revm::context_interface::transaction::SignedAuthorization; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; +use std::num::NonZeroU64; +use tempo_alloy::{ + primitives::{ + AASigned, TempoSignature, TempoTransaction, TempoTxEnvelope, + transaction::{Call, PrimitiveSignature}, + }, + rpc::{TempoHeaderResponse, TempoTransactionReceipt}, +}; + +#[cfg(feature = "base")] +use base_common_consensus::{ + BaseReceipt, BaseTxEnvelope, Eip8130Signed, TxDeposit as BaseTxDeposit, +}; +#[cfg(feature = "base")] +use base_common_rpc_types::{BaseTransactionReceipt, Transaction as BaseRpcTransaction}; + +#[cfg(feature = "optimism")] +use op_alloy_consensus::{OpTxEnvelope, TxDeposit, TxPostExec}; /// length of the name column for pretty formatting `{:>20}{value}` const NAME_COLUMN_LEN: usize = 20usize; @@ -47,7 +69,9 @@ impl UIfmt for Option { impl UIfmt for [T] { fn pretty(&self) -> String { - if !self.is_empty() { + if self.is_empty() { + "[]".to_string() + } else { let mut s = String::with_capacity(self.len() * 64); s.push_str("[\n"); for item in self { @@ -59,15 +83,13 @@ impl UIfmt for [T] { } s.push(']'); s - } else { - "[]".to_string() } } } impl UIfmt for String { fn pretty(&self) -> String { - self.to_string() + self.clone() } } @@ -77,6 +99,12 @@ impl UIfmt for u64 { } } +impl UIfmt for NonZeroU64 { + fn pretty(&self) -> String { + self.get().pretty() + } +} + impl UIfmt for u128 { fn pretty(&self) -> String { self.to_string() @@ -113,12 +141,6 @@ impl UIfmt for Bloom { } } -impl UIfmt for TxType { - fn pretty(&self) -> String { - (*self as u8).to_string() - } -} - impl UIfmt for Vec { fn pretty(&self) -> String { self[..].pretty() @@ -158,37 +180,19 @@ impl UIfmt for Eip658Value { } } -impl UIfmt for AnyTransactionReceipt { +impl UIfmt for Signature { fn pretty(&self) -> String { - let Self { - inner: - TransactionReceipt { - transaction_hash, - transaction_index, - block_hash, - block_number, - from, - to, - gas_used, - contract_address, - effective_gas_price, - inner: - AnyReceiptEnvelope { - r#type: transaction_type, - inner: - ReceiptWithBloom { - receipt: Receipt { status, cumulative_gas_used, logs }, - logs_bloom, - }, - }, - blob_gas_price, - blob_gas_used, - }, - other, - } = self; + format!("[r: {}, s: {}, y_parity: {}]", self.r(), self.s(), self.v()) + } +} - let mut pretty = format!( - " +/// Pretty-prints the common fields of any `TransactionReceipt`. +fn pretty_receipt(receipt: &TransactionReceipt, tx_type: u8) -> String +where + T::Log: Serialize, +{ + let mut pretty = format!( + " blockHash {} blockNumber {} contractAddress {} @@ -205,31 +209,90 @@ transactionIndex {} type {} blobGasPrice {} blobGasUsed {}", - block_hash.pretty(), - block_number.pretty(), - contract_address.pretty(), - cumulative_gas_used.pretty(), - effective_gas_price.pretty(), - from.pretty(), - gas_used.pretty(), - serde_json::to_string(&logs).unwrap(), - logs_bloom.pretty(), - self.state_root().pretty(), - status.pretty(), - transaction_hash.pretty(), - transaction_index.pretty(), - transaction_type, - blob_gas_price.pretty(), - blob_gas_used.pretty() - ); + receipt.block_hash.pretty(), + receipt.block_number.pretty(), + receipt.contract_address.pretty(), + receipt.inner.cumulative_gas_used().pretty(), + receipt.effective_gas_price.pretty(), + receipt.from.pretty(), + receipt.gas_used.pretty(), + serde_json::to_string(receipt.inner.logs()).unwrap(), + receipt.inner.bloom().pretty(), + receipt.inner.status_or_post_state().as_post_state().pretty(), + receipt.inner.status_or_post_state().pretty(), + receipt.transaction_hash.pretty(), + receipt.transaction_index.pretty(), + tx_type, + receipt.blob_gas_price.pretty(), + receipt.blob_gas_used.pretty() + ); + + if let Some(to) = receipt.to { + pretty.push_str(&format!("\nto {}", to.pretty())); + } - if let Some(to) = to { - pretty.push_str(&format!("\nto {}", to.pretty())); - } + pretty +} - // additional captured fields - pretty.push_str(&other.pretty()); +impl UIfmt for TransactionReceipt { + fn pretty(&self) -> String { + pretty_receipt(self, self.transaction_type() as u8) + } +} + +#[cfg(feature = "base")] +impl UIfmt for BaseTransactionReceipt { + fn pretty(&self) -> String { + let (deposit_nonce, deposit_receipt_version) = match &self.inner.inner.receipt { + BaseReceipt::Deposit(receipt) => { + (receipt.deposit_nonce, receipt.deposit_receipt_version) + } + _ => (None, None), + }; + format!( + "{} +l1GasPrice {} +l1GasUsed {} +l1Fee {} +l1FeeScalar {} +l1BaseFeeScalar {} +l1BlobBaseFee {} +l1BlobBaseFeeScalar {} +operatorFeeScalar {} +operatorFeeConstant {} +daFootprintGasScalar {} +depositNonce {} +depositReceiptVersion {} +payer {} +phaseStatuses {} +metadata {}", + pretty_receipt(&self.inner, self.inner.inner.receipt.tx_type() as u8).trim_end(), + self.l1_block_info.l1_gas_price.pretty(), + self.l1_block_info.l1_gas_used.pretty(), + self.l1_block_info.l1_fee.pretty(), + self.l1_block_info.l1_fee_scalar.map(|value| value.to_string()).unwrap_or_default(), + self.l1_block_info.l1_base_fee_scalar.pretty(), + self.l1_block_info.l1_blob_base_fee.pretty(), + self.l1_block_info.l1_blob_base_fee_scalar.pretty(), + self.l1_block_info.operator_fee_scalar.pretty(), + self.l1_block_info.operator_fee_constant.pretty(), + self.l1_block_info + .da_footprint_gas_scalar + .map(|value| value.to_string()) + .unwrap_or_default(), + deposit_nonce.pretty(), + deposit_receipt_version.pretty(), + self.payer.pretty(), + self.phase_statuses.pretty(), + self.metadata.pretty(), + ) + } +} +impl UIfmt for AnyTransactionReceipt { + fn pretty(&self) -> String { + let mut pretty = pretty_receipt(&self.inner, self.inner.inner.r#type); + pretty.push_str(&self.other.pretty()); pretty } } @@ -260,13 +323,13 @@ transactionIndex: {}", } } -impl UIfmt for Block> { +impl UIfmt for Block { fn pretty(&self) -> String { format!( " {} transactions: {}", - pretty_block_basics(self), + pretty_generic_header_response(&self.header), self.transactions.pretty() ) } @@ -310,184 +373,360 @@ impl UIfmt for AccessListItem { } } -impl UIfmt for TxEnvelope { +impl UIfmt for TxLegacy { fn pretty(&self) -> String { - match &self { - Self::Eip2930(tx) => format!( - " -accessList {} + format!( + " chainId {} -gasLimit {} +nonce {} gasPrice {} -hash {} -input {} +gasLimit {} +to {} +value {} +input {}", + self.chain_id.pretty(), + self.nonce.pretty(), + self.gas_price.pretty(), + self.gas_limit.pretty(), + self.to().pretty(), + self.value.pretty(), + self.input.pretty(), + ) + } +} + +impl UIfmt for TxEip2930 { + fn pretty(&self) -> String { + format!( + " +chainId {} nonce {} -r {} -s {} +gasPrice {} +gasLimit {} to {} -type {} value {} -yParity {}", - self.access_list() - .map(|a| a.iter().collect::>()) - .unwrap_or_default() - .pretty(), - self.chain_id().pretty(), - self.gas_limit().pretty(), - self.gas_price().pretty(), - self.tx_hash().pretty(), - self.input().pretty(), - self.nonce().pretty(), - FixedBytes::from(tx.signature().r()).pretty(), - FixedBytes::from(tx.signature().s()).pretty(), - self.to().pretty(), - self.ty(), - self.value().pretty(), - (if tx.signature().v() { 1u64 } else { 0 }).pretty(), - ), - Self::Eip1559(tx) => format!( - " accessList {} +input {}", + self.chain_id.pretty(), + self.nonce.pretty(), + self.gas_price.pretty(), + self.gas_limit.pretty(), + self.to().pretty(), + self.value.pretty(), + self.access_list.pretty(), + self.input.pretty(), + ) + } +} + +impl UIfmt for TxEip1559 { + fn pretty(&self) -> String { + format!( + " chainId {} +nonce {} gasLimit {} -hash {} -input {} maxFeePerGas {} maxPriorityFeePerGas {} +to {} +value {} +accessList {} +input {}", + self.chain_id.pretty(), + self.nonce.pretty(), + self.gas_limit.pretty(), + self.max_fee_per_gas.pretty(), + self.max_priority_fee_per_gas.pretty(), + self.to().pretty(), + self.value.pretty(), + self.access_list.pretty(), + self.input.pretty(), + ) + } +} + +impl UIfmt for TxEip4844Variant { + fn pretty(&self) -> String { + use alloy_consensus::TxEip4844; + let tx: &TxEip4844 = match self { + Self::TxEip4844(tx) => tx, + Self::TxEip4844WithSidecar(tx) => tx.tx(), + }; + format!( + " +chainId {} nonce {} -r {} -s {} +gasLimit {} +maxFeePerGas {} +maxPriorityFeePerGas {} to {} -type {} value {} -yParity {}", - self.access_list() - .map(|a| a.iter().collect::>()) - .unwrap_or_default() - .pretty(), - self.chain_id().pretty(), - self.gas_limit().pretty(), - self.tx_hash().pretty(), - self.input().pretty(), - self.max_fee_per_gas().pretty(), - self.max_priority_fee_per_gas().pretty(), - self.nonce().pretty(), - FixedBytes::from(tx.signature().r()).pretty(), - FixedBytes::from(tx.signature().s()).pretty(), - self.to().pretty(), - self.ty(), - self.value().pretty(), - (if tx.signature().v() { 1u64 } else { 0 }).pretty(), - ), - Self::Eip4844(tx) => format!( - " accessList {} blobVersionedHashes {} +maxFeePerBlobGas {} +input {}", + tx.chain_id.pretty(), + tx.nonce.pretty(), + tx.gas_limit.pretty(), + tx.max_fee_per_gas.pretty(), + tx.max_priority_fee_per_gas.pretty(), + tx.to.pretty(), + tx.value.pretty(), + tx.access_list.pretty(), + tx.blob_versioned_hashes.pretty(), + tx.max_fee_per_blob_gas.pretty(), + tx.input.pretty(), + ) + } +} + +impl UIfmt for TxEip7702 { + fn pretty(&self) -> String { + format!( + " chainId {} +nonce {} gasLimit {} -hash {} -input {} -maxFeePerBlobGas {} maxFeePerGas {} maxPriorityFeePerGas {} -nonce {} -r {} -s {} to {} -type {} value {} -yParity {}", - self.access_list() - .map(|a| a.iter().collect::>()) - .unwrap_or_default() - .pretty(), - self.blob_versioned_hashes().unwrap_or(&[]).pretty(), - self.chain_id().pretty(), - self.gas_limit().pretty(), - self.tx_hash().pretty(), - self.input().pretty(), - self.max_fee_per_blob_gas().pretty(), - self.max_fee_per_gas().pretty(), - self.max_priority_fee_per_gas().pretty(), - self.nonce().pretty(), - FixedBytes::from(tx.signature().r()).pretty(), - FixedBytes::from(tx.signature().s()).pretty(), - self.to().pretty(), - self.ty(), - self.value().pretty(), - (if tx.signature().v() { 1u64 } else { 0 }).pretty(), - ), - Self::Eip7702(tx) => format!( - " accessList {} authorizationList {} -chainId {} +input {}", + self.chain_id.pretty(), + self.nonce.pretty(), + self.gas_limit.pretty(), + self.max_fee_per_gas.pretty(), + self.max_priority_fee_per_gas.pretty(), + self.to.pretty(), + self.value.pretty(), + self.access_list.pretty(), + self.authorization_list.pretty(), + self.input.pretty(), + ) + } +} + +#[cfg(feature = "optimism")] +impl UIfmt for TxDeposit { + fn pretty(&self) -> String { + format!( + " +sourceHash {} +from {} +to {} +mint {} +value {} gasLimit {} -hash {} -input {} +isSystemTransaction {} +input {}", + self.source_hash.pretty(), + self.from.pretty(), + self.to().pretty(), + self.mint.pretty(), + self.value.pretty(), + self.gas_limit.pretty(), + self.is_system_transaction, + self.input.pretty(), + ) + } +} + +#[cfg(feature = "base")] +impl UIfmt for BaseTxDeposit { + fn pretty(&self) -> String { + format!( + " +sourceHash {} +from {} +to {} +mint {} +value {} +gasLimit {} +isSystemTransaction {} +input {}", + self.source_hash.pretty(), + self.from.pretty(), + self.to().pretty(), + self.mint.pretty(), + self.value.pretty(), + self.gas_limit.pretty(), + self.is_system_transaction, + self.input.pretty(), + ) + } +} + +#[cfg(feature = "base")] +impl UIfmt for Eip8130Signed { + fn pretty(&self) -> String { + let tx = self.tx(); + // `calls` is grouped into phases, and `accountChanges` can be long, so both are summarized + // by count rather than dumped inline. + format!( + " +chainId {} +sender {} +payer {} +nonceKey {} +nonceSequence {} +validAfter {} +validBefore {} maxFeePerGas {} maxPriorityFeePerGas {} +gasLimit {} +accountChanges {} +callPhases {} +calls {} +metadata {} +senderAuth {} +payerAuth {}", + tx.chain_id.pretty(), + tx.sender.pretty(), + tx.payer.pretty(), + tx.nonce_key.pretty(), + tx.nonce_sequence.pretty(), + tx.valid_after.pretty(), + tx.valid_before.pretty(), + tx.max_fee_per_gas.pretty(), + tx.max_priority_fee_per_gas.pretty(), + tx.gas_limit.pretty(), + tx.account_changes.len(), + tx.calls.len(), + tx.calls.iter().map(Vec::len).sum::(), + tx.metadata.pretty(), + self.sender_auth().pretty(), + self.payer_auth().pretty(), + ) + } +} + +#[cfg(feature = "optimism")] +impl UIfmt for TxPostExec { + fn pretty(&self) -> String { + format!( + " +blockNumber {} +gasRefundEntries {:?} +input {}", + self.payload.block_number.pretty(), + self.payload.gas_refund_entries, + self.input.pretty(), + ) + } +} + +impl UIfmt for Call { + fn pretty(&self) -> String { + format!( + "to: {}, value: {}, input: {}", + self.to.into_to().pretty(), + self.value.pretty(), + self.input.pretty(), + ) + } +} + +impl UIfmt for TempoTransaction { + fn pretty(&self) -> String { + format!( + " +chainId {} +feeToken {} +maxPriorityFeePerGas {} +maxFeePerGas {} +gasLimit {} +calls {} +accessList {} +nonceKey {} nonce {} -r {} -s {} -to {} +feePayerSignature {} +validBefore {} +validAfter {}", + self.chain_id.pretty(), + self.fee_token.pretty(), + self.max_priority_fee_per_gas.pretty(), + self.max_fee_per_gas.pretty(), + self.gas_limit.pretty(), + self.calls.pretty(), + self.access_list.pretty(), + self.nonce_key.pretty(), + self.nonce.pretty(), + self.fee_payer_signature.pretty(), + self.valid_before.pretty(), + self.valid_after.pretty(), + ) + } +} + +impl UIfmt for TempoSignature { + fn pretty(&self) -> String { + serde_json::to_string(self).unwrap_or_default() + } +} + +impl UIfmt for AASigned { + fn pretty(&self) -> String { + format!( + " +hash {} type {} -value {} -yParity {}", - self.access_list() - .map(|a| a.iter().collect::>()) - .unwrap_or_default() - .pretty(), - self.authorization_list() - .as_ref() - .map(|l| l.iter().collect::>()) - .unwrap_or_default() - .pretty(), - self.chain_id().pretty(), - self.gas_limit().pretty(), - self.tx_hash().pretty(), - self.input().pretty(), - self.max_fee_per_gas().pretty(), - self.max_priority_fee_per_gas().pretty(), - self.nonce().pretty(), - FixedBytes::from(tx.signature().r()).pretty(), - FixedBytes::from(tx.signature().s()).pretty(), - self.to().pretty(), - self.ty(), - self.value().pretty(), - (if tx.signature().v() { 1u64 } else { 0 }).pretty(), - ), - _ => format!( - " -gas {} -gasPrice {} +{} +tempoSignature {}", + self.hash().pretty(), + self.tx().ty(), + self.tx().pretty().trim_start(), + self.signature().pretty(), + ) + } +} + +impl UIfmt for Signed +where + Self: TxHashRef, +{ + fn pretty(&self) -> String { + format!( + " hash {} -input {} -nonce {} +type {} +{} r {} s {} -to {} -type {} -v {} -value {}", - self.gas_limit().pretty(), - self.gas_price().pretty(), - self.tx_hash().pretty(), - self.input().pretty(), - self.nonce().pretty(), - self.as_legacy() - .map(|tx| FixedBytes::from(tx.signature().r()).pretty()) - .unwrap_or_default(), - self.as_legacy() - .map(|tx| FixedBytes::from(tx.signature().s()).pretty()) - .unwrap_or_default(), - self.to().pretty(), - self.ty(), - self.as_legacy() - .map(|tx| (if tx.signature().v() { 1u64 } else { 0 }).pretty()) - .unwrap_or_default(), - self.value().pretty(), - ), +yParity {}", + self.tx_hash().pretty(), + self.ty(), + self.tx().pretty().trim_start(), + FixedBytes::from(self.signature().r()).pretty(), + FixedBytes::from(self.signature().s()).pretty(), + (if self.signature().v() { 1u64 } else { 0 }).pretty(), + ) + } +} + +impl UIfmt for TxEnvelope { + fn pretty(&self) -> String { + match self { + Self::Legacy(tx) => tx.pretty(), + Self::Eip2930(tx) => tx.pretty(), + Self::Eip1559(tx) => tx.pretty(), + Self::Eip4844(tx) => tx.pretty(), + Self::Eip7702(tx) => tx.pretty(), + } + } +} + +#[cfg(feature = "base")] +impl UIfmt for BaseTxEnvelope { + fn pretty(&self) -> String { + match self { + Self::Legacy(tx) => tx.pretty(), + Self::Eip2930(tx) => tx.pretty(), + Self::Eip1559(tx) => tx.pretty(), + Self::Eip7702(tx) => tx.pretty(), + Self::Deposit(tx) => tx.inner().pretty(), + Self::Eip8130(tx) => tx.pretty(), } } } @@ -500,245 +739,45 @@ impl UIfmt for AnyTxEnvelope { format!( " hash {} -type {} +type {:#x} {} ", tx.hash.pretty(), tx.ty(), - tx.inner.fields.pretty(), + tx.inner.fields.pretty().trim_start(), ) } } } } -impl UIfmt for Transaction { + +#[cfg(feature = "optimism")] +impl UIfmt for OpTxEnvelope { fn pretty(&self) -> String { - match &self.inner.inner() { - TxEnvelope::Eip2930(tx) => format!( - " -accessList {} -blockHash {} -blockNumber {} -chainId {} -from {} -gasLimit {} -gasPrice {} -hash {} -input {} -nonce {} -r {} -s {} -to {} -transactionIndex {} -type {} -value {} -yParity {}", - self.inner - .access_list() - .map(|a| a.iter().collect::>()) - .unwrap_or_default() - .pretty(), - self.block_hash.pretty(), - self.block_number.pretty(), - self.chain_id().pretty(), - self.inner.signer().pretty(), - self.gas_limit().pretty(), - self.gas_price().pretty(), - self.inner.tx_hash().pretty(), - self.input().pretty(), - self.nonce().pretty(), - FixedBytes::from(tx.signature().r()).pretty(), - FixedBytes::from(tx.signature().s()).pretty(), - self.to().pretty(), - self.transaction_index.pretty(), - self.inner.ty(), - self.value().pretty(), - (if tx.signature().v() { 1u64 } else { 0 }).pretty(), - ), - TxEnvelope::Eip1559(tx) => format!( - " -accessList {} -blockHash {} -blockNumber {} -chainId {} -from {} -gasLimit {} -hash {} -input {} -maxFeePerGas {} -maxPriorityFeePerGas {} -nonce {} -r {} -s {} -to {} -transactionIndex {} -type {} -value {} -yParity {}", - self.inner - .access_list() - .map(|a| a.iter().collect::>()) - .unwrap_or_default() - .pretty(), - self.block_hash.pretty(), - self.block_number.pretty(), - self.chain_id().pretty(), - self.inner.signer().pretty(), - self.gas_limit().pretty(), - tx.hash().pretty(), - self.input().pretty(), - self.max_fee_per_gas().pretty(), - self.max_priority_fee_per_gas().pretty(), - self.nonce().pretty(), - FixedBytes::from(tx.signature().r()).pretty(), - FixedBytes::from(tx.signature().s()).pretty(), - self.to().pretty(), - self.transaction_index.pretty(), - self.inner.ty(), - self.value().pretty(), - (if tx.signature().v() { 1u64 } else { 0 }).pretty(), - ), - TxEnvelope::Eip4844(tx) => format!( - " -accessList {} -blobVersionedHashes {} -blockHash {} -blockNumber {} -chainId {} -from {} -gasLimit {} -hash {} -input {} -maxFeePerBlobGas {} -maxFeePerGas {} -maxPriorityFeePerGas {} -nonce {} -r {} -s {} -to {} -transactionIndex {} -type {} -value {} -yParity {}", - self.inner - .access_list() - .map(|a| a.iter().collect::>()) - .unwrap_or_default() - .pretty(), - self.blob_versioned_hashes().unwrap_or(&[]).pretty(), - self.block_hash.pretty(), - self.block_number.pretty(), - self.chain_id().pretty(), - self.inner.signer().pretty(), - self.gas_limit().pretty(), - tx.hash().pretty(), - self.input().pretty(), - self.max_fee_per_blob_gas().pretty(), - self.max_fee_per_gas().pretty(), - self.max_priority_fee_per_gas().pretty(), - self.nonce().pretty(), - FixedBytes::from(tx.signature().r()).pretty(), - FixedBytes::from(tx.signature().s()).pretty(), - self.to().pretty(), - self.transaction_index.pretty(), - self.inner.ty(), - self.value().pretty(), - (if tx.signature().v() { 1u64 } else { 0 }).pretty(), - ), - TxEnvelope::Eip7702(tx) => format!( - " -accessList {} -authorizationList {} -blockHash {} -blockNumber {} -chainId {} -from {} -gasLimit {} -hash {} -input {} -maxFeePerGas {} -maxPriorityFeePerGas {} -nonce {} -r {} -s {} -to {} -transactionIndex {} -type {} -value {} -yParity {}", - self.inner - .access_list() - .map(|a| a.iter().collect::>()) - .unwrap_or_default() - .pretty(), - self.authorization_list() - .as_ref() - .map(|l| l.iter().collect::>()) - .unwrap_or_default() - .pretty(), - self.block_hash.pretty(), - self.block_number.pretty(), - self.chain_id().pretty(), - self.inner.signer().pretty(), - self.gas_limit().pretty(), - tx.hash().pretty(), - self.input().pretty(), - self.max_fee_per_gas().pretty(), - self.max_priority_fee_per_gas().pretty(), - self.nonce().pretty(), - FixedBytes::from(tx.signature().r()).pretty(), - FixedBytes::from(tx.signature().s()).pretty(), - self.to().pretty(), - self.transaction_index.pretty(), - self.inner.ty(), - self.value().pretty(), - (if tx.signature().v() { 1u64 } else { 0 }).pretty(), - ), - _ => format!( - " -blockHash {} -blockNumber {} -from {} -gas {} -gasPrice {} -hash {} -input {} -nonce {} -r {} -s {} -to {} -transactionIndex {} -v {} -value {}", - self.block_hash.pretty(), - self.block_number.pretty(), - self.inner.signer().pretty(), - self.gas_limit().pretty(), - self.gas_price().pretty(), - self.inner.tx_hash().pretty(), - self.input().pretty(), - self.nonce().pretty(), - self.inner - .as_legacy() - .map(|tx| FixedBytes::from(tx.signature().r()).pretty()) - .unwrap_or_default(), - self.inner - .as_legacy() - .map(|tx| FixedBytes::from(tx.signature().s()).pretty()) - .unwrap_or_default(), - self.to().pretty(), - self.transaction_index.pretty(), - self.inner - .as_legacy() - .map(|tx| (if tx.signature().v() { 1u64 } else { 0 }).pretty()) - .unwrap_or_default(), - self.value().pretty(), - ), + match self { + Self::Legacy(tx) => tx.pretty(), + Self::Eip2930(tx) => tx.pretty(), + Self::Eip1559(tx) => tx.pretty(), + Self::Eip7702(tx) => tx.pretty(), + Self::Deposit(tx) => tx.pretty(), + Self::PostExec(tx) => tx.pretty(), } } } -impl UIfmt for Transaction { +impl UIfmt for TempoTxEnvelope { + fn pretty(&self) -> String { + match self { + Self::Legacy(tx) => tx.pretty(), + Self::Eip2930(tx) => tx.pretty(), + Self::Eip1559(tx) => tx.pretty(), + Self::Eip7702(tx) => tx.pretty(), + Self::AA(tx) => tx.pretty(), + } + } +} + +impl UIfmt for Transaction { fn pretty(&self) -> String { format!( " @@ -747,14 +786,43 @@ blockNumber {} from {} transactionIndex {} effectiveGasPrice {} -{} - ", +{}", self.block_hash.pretty(), self.block_number.pretty(), self.inner.signer().pretty(), self.transaction_index.pretty(), self.effective_gas_price.pretty(), - self.inner.pretty(), + self.inner.inner().pretty().trim_start(), + ) + } +} + +#[cfg(feature = "base")] +impl UIfmt for BaseRpcTransaction { + fn pretty(&self) -> String { + format!( + " +depositNonce {} +depositReceiptVersion {} +{}", + self.deposit_nonce.pretty(), + self.deposit_receipt_version.pretty(), + self.inner.pretty().trim_start(), + ) + } +} + +#[cfg(feature = "optimism")] +impl UIfmt for op_alloy_rpc_types::Transaction { + fn pretty(&self) -> String { + format!( + " +depositNonce {} +depositReceiptVersion {} +{}", + self.deposit_nonce.pretty(), + self.deposit_receipt_version.pretty(), + self.inner.pretty().trim_start(), ) } } @@ -783,6 +851,7 @@ impl UIfmt for WithOtherFields { #[expect(missing_docs)] pub enum EthValue { U64(U64), + Address(Address), U256(U256), U64Array(Vec), U256Array(Vec), @@ -800,6 +869,7 @@ impl UIfmt for EthValue { match self { Self::U64(num) => num.pretty(), Self::U256(num) => num.pretty(), + Self::Address(addr) => addr.pretty(), Self::U64Array(arr) => arr.pretty(), Self::U256Array(arr) => arr.pretty(), Self::Other(val) => val.to_string().trim_matches('"').to_string(), @@ -822,64 +892,287 @@ impl UIfmt for SignedAuthorization { } } +pub trait UIfmtHeaderExt { + fn size_pretty(&self) -> String; + fn total_difficulty_pretty(&self) -> String; +} + +impl UIfmtHeaderExt for Header { + fn size_pretty(&self) -> String { + self.size.pretty() + } + + fn total_difficulty_pretty(&self) -> String { + self.total_difficulty.unwrap_or_else(|| self.difficulty()).pretty() + } +} + +impl UIfmtHeaderExt for AnyRpcHeader { + fn size_pretty(&self) -> String { + self.size.pretty() + } + + fn total_difficulty_pretty(&self) -> String { + self.total_difficulty.unwrap_or_else(|| self.difficulty()).pretty() + } +} + +impl UIfmtHeaderExt for TempoHeaderResponse { + fn size_pretty(&self) -> String { + self.inner.size.pretty() + } + + fn total_difficulty_pretty(&self) -> String { + self.inner.total_difficulty.unwrap_or_else(|| self.inner.difficulty()).pretty() + } +} + +pub trait UIfmtSignatureExt { + fn signature_pretty(&self) -> Option<(String, String, String)>; +} + +impl UIfmtSignatureExt for TxEnvelope { + fn signature_pretty(&self) -> Option<(String, String, String)> { + Some(pretty_signature_fields(self.signature())) + } +} + +impl UIfmtSignatureExt for AnyTxEnvelope { + fn signature_pretty(&self) -> Option<(String, String, String)> { + self.as_envelope().and_then(|envelope| envelope.signature_pretty()) + } +} + +#[cfg(feature = "base")] +impl UIfmtSignatureExt for BaseTxEnvelope { + fn signature_pretty(&self) -> Option<(String, String, String)> { + self.signature().map(pretty_signature_fields) + } +} + +#[cfg(feature = "optimism")] +impl UIfmtSignatureExt for OpTxEnvelope { + fn signature_pretty(&self) -> Option<(String, String, String)> { + self.signature().map(pretty_signature_fields) + } +} + +impl UIfmtSignatureExt for TempoTxEnvelope { + fn signature_pretty(&self) -> Option<(String, String, String)> { + let sig = match self { + Self::Legacy(tx) => Some(tx.signature()), + Self::Eip2930(tx) => Some(tx.signature()), + Self::Eip1559(tx) => Some(tx.signature()), + Self::Eip7702(tx) => Some(tx.signature()), + Self::AA(tempo_tx) => { + if let TempoSignature::Primitive(PrimitiveSignature::Secp256k1(sig)) = + tempo_tx.signature() + { + Some(sig) + } else { + None + } + } + }?; + Some(pretty_signature_fields(sig)) + } +} + +fn pretty_signature_fields(signature: &Signature) -> (String, String, String) { + ( + FixedBytes::from(signature.r()).pretty(), + FixedBytes::from(signature.s()).pretty(), + U8::from_le_slice(&signature.as_bytes()[64..]).pretty(), + ) +} + +pub trait UIfmtReceiptExt { + fn logs_pretty(&self) -> String; + fn logs_bloom_pretty(&self) -> String; + fn tx_type_pretty(&self) -> String; +} + +fn receipt_logs_pretty(receipt: &TransactionReceipt) -> String +where + T::Log: Serialize, +{ + serde_json::to_string(receipt.inner.logs()).unwrap_or_default() +} + +fn receipt_logs_bloom_pretty(receipt: &TransactionReceipt) -> String { + receipt.inner.bloom().pretty() +} + +impl UIfmtReceiptExt for TransactionReceipt { + fn logs_pretty(&self) -> String { + receipt_logs_pretty(self) + } + + fn logs_bloom_pretty(&self) -> String { + receipt_logs_bloom_pretty(self) + } + + fn tx_type_pretty(&self) -> String { + self.transaction_type().to_string() + } +} + +#[cfg(feature = "base")] +impl UIfmtReceiptExt for BaseTransactionReceipt { + fn logs_pretty(&self) -> String { + receipt_logs_pretty(&self.inner) + } + + fn logs_bloom_pretty(&self) -> String { + receipt_logs_bloom_pretty(&self.inner) + } + + fn tx_type_pretty(&self) -> String { + self.inner.inner.receipt.tx_type().to_string() + } +} + +impl UIfmtReceiptExt for AnyTransactionReceipt { + fn logs_pretty(&self) -> String { + receipt_logs_pretty(&self.inner) + } + + fn logs_bloom_pretty(&self) -> String { + receipt_logs_bloom_pretty(&self.inner) + } + + fn tx_type_pretty(&self) -> String { + self.inner.inner.r#type.to_string() + } +} + +impl UIfmt for TempoTransactionReceipt { + fn pretty(&self) -> String { + let receipt = &self.inner; + + let mut pretty = format!( + " +blockHash {} +blockNumber {} +contractAddress {} +cumulativeGasUsed {} +effectiveGasPrice {} +from {} +gasUsed {} +logs {} +logsBloom {} +root {} +status {} +transactionHash {} +transactionIndex {} +type {} +feePayer {} +feeToken {}", + receipt.block_hash().pretty(), + receipt.block_number().pretty(), + receipt.contract_address().pretty(), + receipt.cumulative_gas_used().pretty(), + receipt.effective_gas_price().pretty(), + receipt.from().pretty(), + receipt.gas_used().pretty(), + serde_json::to_string(receipt.inner.logs()).unwrap(), + receipt.inner.logs_bloom.pretty(), + self.state_root().pretty(), + receipt.status().pretty(), + receipt.transaction_hash().pretty(), + receipt.transaction_index().pretty(), + receipt.inner.receipt.tx_type as u8, + self.fee_payer.pretty(), + self.fee_token.pretty(), + ); + + if let Some(to) = receipt.to() { + pretty.push_str(&format!("\nto {}", to.pretty())); + } + + pretty + } +} + +impl UIfmtReceiptExt for TempoTransactionReceipt { + fn logs_pretty(&self) -> String { + receipt_logs_pretty(&self.inner) + } + + fn logs_bloom_pretty(&self) -> String { + self.inner.inner.logs_bloom.pretty() + } + + fn tx_type_pretty(&self) -> String { + (self.inner.inner.receipt.tx_type as u8).to_string() + } +} + /// Returns the `UiFmt::pretty()` formatted attribute of the transactions -pub fn get_pretty_tx_attr(transaction: &Transaction, attr: &str) -> Option { - let sig = match &transaction.inner.inner() { - AnyTxEnvelope::Ethereum(envelope) => match &envelope { - TxEnvelope::Eip2930(tx) => Some(tx.signature()), - TxEnvelope::Eip1559(tx) => Some(tx.signature()), - TxEnvelope::Eip4844(tx) => Some(tx.signature()), - TxEnvelope::Eip7702(tx) => Some(tx.signature()), - TxEnvelope::Legacy(tx) => Some(tx.signature()), - }, - _ => None, - }; +pub fn get_pretty_tx_attr(transaction: &N::TransactionResponse, attr: &str) -> Option +where + N: Network, + N::TxEnvelope: UIfmtSignatureExt, +{ + let (r, s, v) = transaction.as_ref().signature_pretty().unwrap_or_default(); match attr { - "blockHash" | "block_hash" => Some(transaction.block_hash.pretty()), - "blockNumber" | "block_number" => Some(transaction.block_number.pretty()), - "from" => Some(transaction.inner.signer().pretty()), - "gas" => Some(transaction.gas_limit().pretty()), - "gasPrice" | "gas_price" => Some(Transaction::gas_price(transaction).pretty()), + "blockHash" | "block_hash" => { + Some(alloy_network::TransactionResponse::block_hash(transaction).pretty()) + } + "blockNumber" | "block_number" => { + Some(alloy_network::TransactionResponse::block_number(transaction).pretty()) + } + "from" => Some(alloy_network::TransactionResponse::from(transaction).pretty()), + "gas" => Some(TxTrait::gas_limit(transaction).pretty()), + "gasPrice" | "gas_price" => Some(TxTrait::max_fee_per_gas(transaction).pretty()), "hash" => Some(alloy_network::TransactionResponse::tx_hash(transaction).pretty()), - "input" => Some(transaction.input().pretty()), - "nonce" => Some(transaction.nonce().to_string()), - "s" => sig.map(|s| FixedBytes::from(s.s()).pretty()), - "r" => sig.map(|s| FixedBytes::from(s.r()).pretty()), - "to" => Some(transaction.to().pretty()), - "transactionIndex" | "transaction_index" => Some(transaction.transaction_index.pretty()), - "v" => sig.map(|s| U8::from_be_slice(&s.as_bytes()[64..]).pretty()), - "value" => Some(transaction.value().pretty()), + "input" => Some(TxTrait::input(transaction).pretty()), + "nonce" => Some(TxTrait::nonce(transaction).to_string()), + "s" => Some(s), + "r" => Some(r), + "to" => Some(TxTrait::to(transaction).pretty()), + "transactionIndex" | "transaction_index" => { + Some(alloy_network::TransactionResponse::transaction_index(transaction).pretty()) + } + "v" => Some(v), + "value" => Some(TxTrait::value(transaction).pretty()), _ => None, } } -/// Returns the `UiFmt::pretty()` formatted attribute of the given block -pub fn get_pretty_block_attr(block: &AnyRpcBlock, attr: &str) -> Option { +pub fn get_pretty_block_attr(block: &N::BlockResponse, attr: &str) -> Option +where + N: Network, + N::BlockResponse: BlockResponse
, + N::HeaderResponse: UIfmtHeaderExt, +{ match attr { - "baseFeePerGas" | "base_fee_per_gas" => Some(block.header.base_fee_per_gas.pretty()), - "difficulty" => Some(block.header.difficulty.pretty()), - "extraData" | "extra_data" => Some(block.header.extra_data.pretty()), - "gasLimit" | "gas_limit" => Some(block.header.gas_limit.pretty()), - "gasUsed" | "gas_used" => Some(block.header.gas_used.pretty()), - "hash" => Some(block.header.hash.pretty()), - "logsBloom" | "logs_bloom" => Some(block.header.logs_bloom.pretty()), - "miner" | "author" => Some(block.header.inner.beneficiary.pretty()), - "mixHash" | "mix_hash" => Some(block.header.mix_hash.pretty()), - "nonce" => Some(block.header.nonce.pretty()), - "number" => Some(block.header.number.pretty()), - "parentHash" | "parent_hash" => Some(block.header.parent_hash.pretty()), - "transactionsRoot" | "transactions_root" => Some(block.header.transactions_root.pretty()), - "receiptsRoot" | "receipts_root" => Some(block.header.receipts_root.pretty()), - "sha3Uncles" | "sha_3_uncles" => Some(block.header.ommers_hash.pretty()), - "size" => Some(block.header.size.pretty()), - "stateRoot" | "state_root" => Some(block.header.state_root.pretty()), - "timestamp" => Some(block.header.timestamp.pretty()), - "totalDifficulty" | "total_difficult" => Some(block.header.total_difficulty.pretty()), - "blobGasUsed" | "blob_gas_used" => Some(block.header.blob_gas_used.pretty()), - "excessBlobGas" | "excess_blob_gas" => Some(block.header.excess_blob_gas.pretty()), - "requestsHash" | "requests_hash" => Some(block.header.requests_hash.pretty()), + "baseFeePerGas" | "base_fee_per_gas" => Some(block.header().base_fee_per_gas().pretty()), + "difficulty" => Some(block.header().difficulty().pretty()), + "extraData" | "extra_data" => Some(block.header().extra_data().pretty()), + "gasLimit" | "gas_limit" => Some(block.header().gas_limit().pretty()), + "gasUsed" | "gas_used" => Some(block.header().gas_used().pretty()), + "hash" => Some(block.header().hash().pretty()), + "logsBloom" | "logs_bloom" => Some(block.header().logs_bloom().pretty()), + "miner" | "author" => Some(block.header().beneficiary().pretty()), + "mixHash" | "mix_hash" => Some(block.header().mix_hash().pretty()), + "nonce" => Some(block.header().nonce().pretty()), + "number" => Some(block.header().number().pretty()), + "parentHash" | "parent_hash" => Some(block.header().parent_hash().pretty()), + "transactionsRoot" | "transactions_root" => { + Some(block.header().transactions_root().pretty()) + } + "receiptsRoot" | "receipts_root" => Some(block.header().receipts_root().pretty()), + "sha3Uncles" | "sha_3_uncles" => Some(block.header().ommers_hash().pretty()), + "size" => Some(block.header().size_pretty()), + "stateRoot" | "state_root" => Some(block.header().state_root().pretty()), + "timestamp" => Some(block.header().timestamp().pretty()), + "totalDifficulty" | "total_difficulty" => Some(block.header().total_difficulty_pretty()), + "blobGasUsed" | "blob_gas_used" => Some(block.header().blob_gas_used().pretty()), + "excessBlobGas" | "excess_blob_gas" => Some(block.header().excess_blob_gas().pretty()), + "requestsHash" | "requests_hash" => Some(block.header().requests_hash().pretty()), other => { - if let Some(value) = block.other.get(other) { + if let Some(value) = block.other_fields().and_then(|fields| fields.get(other)) { let val = EthValue::from(value.clone()); return Some(val.pretty()); } @@ -888,42 +1181,34 @@ pub fn get_pretty_block_attr(block: &AnyRpcBlock, attr: &str) -> Option } } -fn pretty_block_basics(block: &Block>) -> String { - let Block { - header: - Header { - hash, - size, - total_difficulty, - inner: - AnyHeader { - parent_hash, - ommers_hash, - beneficiary, - state_root, - transactions_root, - receipts_root, - logs_bloom, - difficulty, - number, - gas_limit, - gas_used, - timestamp, - extra_data, - mix_hash, - nonce, - base_fee_per_gas, - withdrawals_root, - blob_gas_used, - excess_blob_gas, - parent_beacon_block_root, - requests_hash, - }, - }, - uncles: _, - transactions: _, - withdrawals: _, - } = block; +pub fn get_pretty_receipt_attr(receipt: &N::ReceiptResponse, attr: &str) -> Option +where + N: Network, + N::ReceiptResponse: ReceiptResponse + UIfmtReceiptExt, +{ + match attr { + "blockHash" | "block_hash" => Some(receipt.block_hash().pretty()), + "blockNumber" | "block_number" => Some(receipt.block_number().pretty()), + "contractAddress" | "contract_address" => Some(receipt.contract_address().pretty()), + "cumulativeGasUsed" | "cumulative_gas_used" => Some(receipt.cumulative_gas_used().pretty()), + "effectiveGasPrice" | "effective_gas_price" => Some(receipt.effective_gas_price().pretty()), + "from" => Some(receipt.from().pretty()), + "gasUsed" | "gas_used" => Some(receipt.gas_used().pretty()), + "logs" => Some(receipt.logs_pretty()), + "logsBloom" | "logs_bloom" => Some(receipt.logs_bloom_pretty()), + "root" | "stateRoot" | "state_root" => Some(receipt.state_root().pretty()), + "status" | "statusCode" | "status_code" => Some(receipt.status().pretty()), + "transactionHash" | "transaction_hash" => Some(receipt.transaction_hash().pretty()), + "transactionIndex" | "transaction_index" => Some(receipt.transaction_index().pretty()), + "to" => Some(receipt.to().pretty()), + "type" | "transaction_type" => Some(receipt.tx_type_pretty()), + "blobGasPrice" | "blob_gas_price" => Some(receipt.blob_gas_price().pretty()), + "blobGasUsed" | "blob_gas_used" => Some(receipt.blob_gas_used().pretty()), + _ => None, + } +} + +fn pretty_generic_header_response(header: &H) -> String { format!( " baseFeePerGas {} @@ -950,43 +1235,74 @@ totalDifficulty {} blobGasUsed {} excessBlobGas {} requestsHash {}", - base_fee_per_gas.pretty(), - difficulty.pretty(), - extra_data.pretty(), - gas_limit.pretty(), - gas_used.pretty(), - hash.pretty(), - logs_bloom.pretty(), - beneficiary.pretty(), - mix_hash.pretty(), - nonce.pretty(), - number.pretty(), - parent_hash.pretty(), - parent_beacon_block_root.pretty(), - transactions_root.pretty(), - receipts_root.pretty(), - ommers_hash.pretty(), - size.pretty(), - state_root.pretty(), - timestamp.pretty(), - chrono::DateTime::from_timestamp(*timestamp as i64, 0) - .expect("block timestamp in range") - .to_rfc2822(), - withdrawals_root.pretty(), - total_difficulty.pretty(), - blob_gas_used.pretty(), - excess_blob_gas.pretty(), - requests_hash.pretty(), + header.base_fee_per_gas().pretty(), + header.difficulty().pretty(), + header.extra_data().pretty(), + header.gas_limit().pretty(), + header.gas_used().pretty(), + header.hash().pretty(), + header.logs_bloom().pretty(), + header.beneficiary().pretty(), + header.mix_hash().pretty(), + header.nonce().pretty(), + header.number().pretty(), + header.parent_hash().pretty(), + header.parent_beacon_block_root().pretty(), + header.transactions_root().pretty(), + header.receipts_root().pretty(), + header.ommers_hash().pretty(), + header.size_pretty(), + header.state_root().pretty(), + header.timestamp().pretty(), + fmt_timestamp(header.timestamp()), + header.withdrawals_root().pretty(), + header.total_difficulty_pretty(), + header.blob_gas_used().pretty(), + header.excess_blob_gas().pretty(), + header.requests_hash().pretty(), ) } +/// Formats the timestamp to string +/// +/// Assumes timestamp is seconds, but handles millis if it is too large +fn fmt_timestamp(timestamp: u64) -> String { + // Tue Jan 19 2038 03:14:07 GMT+0000 + if timestamp > 2147483647 { + // assume this is in millis, incorrectly set to millis by a node + chrono::DateTime::from_timestamp_millis(timestamp as i64) + .expect("block timestamp in range") + .to_rfc3339() + } else { + // assume this is still in seconds + chrono::DateTime::from_timestamp(timestamp as i64, 0) + .expect("block timestamp in range") + .to_rfc2822() + } +} + #[cfg(test)] mod tests { + use super::*; - use alloy_primitives::B256; + use alloy_network::Ethereum; + use alloy_primitives::{B256, address, bytes}; use alloy_rpc_types::Authorization; use similar_asserts::assert_eq; - use std::str::FromStr; + + #[cfg(feature = "base")] + use base_common_consensus::{Call as BaseCall, TxEip8130}; + + #[test] + fn format_date_time() { + // Fri Aug 29 2025 08:05:38 GMT+0000 + let timestamp = 1756454738u64; + + let datetime = fmt_timestamp(timestamp); + assert_eq!(datetime, "Fri, 29 Aug 2025 08:05:38 +0000"); + let datetime = fmt_timestamp(timestamp * 1000); + assert_eq!(datetime, "2025-08-29T08:05:38+00:00"); + } #[test] fn can_format_bytes32() { @@ -1003,8 +1319,161 @@ mod tests { assert_eq!(b.pretty(), b32.pretty()); } + #[cfg(feature = "base")] + #[test] + fn can_pretty_print_eip8130_transaction() { + let base_call = BaseCall { to: Address::ZERO, data: Bytes::new() }; + let tx = TxEip8130 { + chain_id: 8453, + sender: Some(Address::with_last_byte(0x11)), + payer: Some(Address::with_last_byte(0x22)), + nonce_key: U256::from(7), + nonce_sequence: 3, + valid_after: 100, + valid_before: 200, + max_fee_per_gas: 1_000, + max_priority_fee_per_gas: 10, + gas_limit: 21_000, + metadata: Bytes::from_static(&[0xab]), + calls: vec![vec![base_call.clone(), base_call.clone()], vec![base_call]], + ..Default::default() + }; + let signed = + Eip8130Signed::new(tx, Bytes::from_static(&[0x01]), Bytes::from_static(&[0x02])); + + assert_eq!( + signed.pretty().trim(), + r"chainId 8453 +sender 0x0000000000000000000000000000000000000011 +payer 0x0000000000000000000000000000000000000022 +nonceKey 7 +nonceSequence 3 +validAfter 100 +validBefore 200 +maxFeePerGas 1000 +maxPriorityFeePerGas 10 +gasLimit 21000 +accountChanges 0 +callPhases 2 +calls 3 +metadata 0xab +senderAuth 0x01 +payerAuth 0x02" + ); + } + + #[cfg(feature = "base")] + #[test] + fn can_pretty_print_base_deposit_receipt_fields() { + let receipt: BaseTransactionReceipt = serde_json::from_value(serde_json::json!({ + "blockHash": "0x9e6a0fb7e22159d943d760608cc36a0fb596d1ab3c997146f5b7c55c8c718c67", + "blockNumber": "0x6cfef89", + "contractAddress": null, + "cumulativeGasUsed": "0xfa0d", + "depositNonce": "0x8a2d11", + "depositReceiptVersion": "0x1", + "effectiveGasPrice": "0x0", + "from": "0xdeaddeaddeaddeaddeaddeaddeaddeaddead0001", + "gasUsed": "0xfa0d", + "logs": [{ + "address": "0x4200000000000000000000000000000000000015", + "topics": [], + "data": "0x", + "removed": false, + "blockTimestampMs": "0x18bcfe568c8", + "blockNumber": "0x6cfef89", + "blockHash": null, + "transactionHash": null, + "transactionIndex": null, + "logIndex": null + }], + "logsBloom": format!("0x{}", "00".repeat(256)), + "status": "0x1", + "to": "0x4200000000000000000000000000000000000015", + "transactionHash": "0xb7c74afdeb7c89fb9de2c312f49b38cb7a850ba36e064734c5223a477e83fdc9", + "transactionIndex": "0x0", + "type": "0x7e", + "l1GasPrice": "0x3ef12787", + "l1GasUsed": "0x1177", + "l1Fee": "0x5bf1ab43d", + "l1BaseFeeScalar": "0x1", + "l1BlobBaseFee": "0x600ab8f05e64", + "l1BlobBaseFeeScalar": "0x1", + "operatorFeeScalar": "0x1", + "operatorFeeConstant": "0x1", + "daFootprintGasScalar": "0x1" + })) + .unwrap(); + + let pretty = receipt.pretty(); + let logs = receipt.logs_pretty(); + assert_eq!( + serde_json::from_str::(&logs).unwrap()[0]["blockTimestampMs"], + "0x18bcfe568c8" + ); + assert!(pretty.contains(&format!("logs {logs}")), "{pretty}"); + assert_eq!(receipt.logs_bloom_pretty(), receipt_logs_bloom_pretty(&receipt.inner)); + assert!(pretty.contains("l1Fee 24681034813"), "{pretty}"); + assert!(pretty.contains("operatorFeeScalar 1"), "{pretty}"); + assert!(pretty.contains("depositNonce 9055505"), "{pretty}"); + assert!(pretty.contains("depositReceiptVersion 1"), "{pretty}"); + } + + #[cfg(feature = "optimism")] #[test] fn can_pretty_print_optimism_tx() { + let s = r#" + { + "blockHash": "0x02b853cf50bc1c335b70790f93d5a390a35a166bea9c895e685cc866e4961cae", + "blockNumber": "0x1b4", + "from": "0x3b179DcfC5fAa677044c27dCe958e4BC0ad696A6", + "gas": "0x11cbbdc", + "gasPrice": "0x0", + "hash": "0x2642e960d3150244e298d52b5b0f024782253e6d0b2c9a01dd4858f7b4665a3f", + "input": "0xd294f093", + "nonce": "0xa2", + "to": "0x4a16A42407AA491564643E1dfc1fd50af29794eF", + "transactionIndex": "0x0", + "value": "0x0", + "v": "0x38", + "r": "0x6fca94073a0cf3381978662d46cf890602d3e9ccf6a31e4b69e8ecbd995e2bee", + "s": "0xe804161a2b56a37ca1f6f4c4b8bce926587afa0d9b1acc5165e6556c959d583", + "depositNonce": "", + "depositReceiptVersion": "0x1" + } + "#; + + let tx: op_alloy_rpc_types::Transaction = serde_json::from_str(s).unwrap(); + assert_eq!( + tx.pretty().trim(), + r" +depositNonce +depositReceiptVersion 1 +blockHash 0x02b853cf50bc1c335b70790f93d5a390a35a166bea9c895e685cc866e4961cae +blockNumber 436 +from 0x3b179DcfC5fAa677044c27dCe958e4BC0ad696A6 +transactionIndex 0 +effectiveGasPrice 0 +hash 0x2642e960d3150244e298d52b5b0f024782253e6d0b2c9a01dd4858f7b4665a3f +type 0 +chainId 10 +nonce 162 +gasPrice 0 +gasLimit 18660316 +to 0x4a16A42407AA491564643E1dfc1fd50af29794eF +value 0 +input 0xd294f093 +r 0x6fca94073a0cf3381978662d46cf890602d3e9ccf6a31e4b69e8ecbd995e2bee +s 0x0e804161a2b56a37ca1f6f4c4b8bce926587afa0d9b1acc5165e6556c959d583 +yParity 1 +" + .trim() + ); + } + + #[cfg(feature = "optimism")] + #[test] + fn can_pretty_print_optimism_tx_through_any() { let s = r#" { "blockHash": "0x02b853cf50bc1c335b70790f93d5a390a35a166bea9c895e685cc866e4961cae", @@ -1032,23 +1501,26 @@ mod tests { } "#; - let tx: WithOtherFields = serde_json::from_str(s).unwrap(); + let tx: WithOtherFields> = serde_json::from_str(s).unwrap(); assert_eq!(tx.pretty().trim(), r" blockHash 0x02b853cf50bc1c335b70790f93d5a390a35a166bea9c895e685cc866e4961cae blockNumber 436 from 0x3b179DcfC5fAa677044c27dCe958e4BC0ad696A6 -gas 18660316 -gasPrice 0 +transactionIndex 0 +effectiveGasPrice 0 hash 0x2642e960d3150244e298d52b5b0f024782253e6d0b2c9a01dd4858f7b4665a3f -input 0xd294f093 +type 0 +chainId 10 nonce 162 -r 0x6fca94073a0cf3381978662d46cf890602d3e9ccf6a31e4b69e8ecbd995e2bee -s 0x0e804161a2b56a37ca1f6f4c4b8bce926587afa0d9b1acc5165e6556c959d583 +gasPrice 0 +gasLimit 18660316 to 0x4a16A42407AA491564643E1dfc1fd50af29794eF -transactionIndex 0 -v 1 value 0 +input 0xd294f093 +r 0x6fca94073a0cf3381978662d46cf890602d3e9ccf6a31e4b69e8ecbd995e2bee +s 0x0e804161a2b56a37ca1f6f4c4b8bce926587afa0d9b1acc5165e6556c959d583 +yParity 1 index 435 l1BlockNumber 12691036 l1Timestamp 1624460128 @@ -1079,17 +1551,29 @@ txType 0 "v": "0x1", "r": "0x2a98c51c2782f664d3ce571fef0491b48f5ebbc5845fa513192e6e6b24ecdaa1", "s": "0x29b8e0c67aa9c11327e16556c591dc84a7aac2f6fc57c7f93901be8ee867aebc", - "chainId": "0x66a", - "accessList": [ - { "address": "0x2b371c0262ceab27face32fbb5270ddc6aa01ba4", "storageKeys": ["0x1122334455667788990011223344556677889900112233445566778899001122", "0x0000000000000000000000000000000000000000000000000000000000000000"] }, - { "address": "0x8e730df7c70d33118d9e5f79ab81aed0be6f6635", "storageKeys": [] } - ] + "chainId": "0x66a", + "accessList": [ + { "address": "0x2b371c0262ceab27face32fbb5270ddc6aa01ba4", "storageKeys": ["0x1122334455667788990011223344556677889900112233445566778899001122", "0x0000000000000000000000000000000000000000000000000000000000000000"] }, + { "address": "0x8e730df7c70d33118d9e5f79ab81aed0be6f6635", "storageKeys": [] } + ] } "#; - let tx: Transaction = serde_json::from_str(s).unwrap(); assert_eq!(tx.pretty().trim(), r" +blockHash 0x2b27fe2bbc8ce01ac7ae8bf74f793a197cf7edbe82727588811fa9a2c4776f81 +blockNumber 76573 +from 0x2b371c0262CEAb27fAcE32FBB5270dDc6Aa01ba4 +transactionIndex 2 +effectiveGasPrice 1000000000 +hash 0xbddbb685774d8a3df036ed9fb920b48f876090a57e9e90ee60921e0510ef7090 +type 1 +chainId 1642 +nonce 28 +gasPrice 1000000000 +gasLimit 27615 +to 0x8E730Df7C70D33118D9e5F79ab81aEd0bE6F6635 +value 0 accessList [ 0x2b371c0262CEAb27fAcE32FBB5270dDc6Aa01ba4 => [ 0x1122334455667788990011223344556677889900112233445566778899001122 @@ -1097,21 +1581,9 @@ accessList [ ] 0x8E730Df7C70D33118D9e5F79ab81aEd0bE6F6635 => [] ] -blockHash 0x2b27fe2bbc8ce01ac7ae8bf74f793a197cf7edbe82727588811fa9a2c4776f81 -blockNumber 76573 -chainId 1642 -from 0x2b371c0262CEAb27fAcE32FBB5270dDc6Aa01ba4 -gasLimit 27615 -gasPrice 1000000000 -hash 0xbddbb685774d8a3df036ed9fb920b48f876090a57e9e90ee60921e0510ef7090 input 0x9c0e3f7a0000000000000000000000000000000000000000000000000000000000000078000000000000000000000000000000000000000000000000000000000000002a -nonce 28 r 0x2a98c51c2782f664d3ce571fef0491b48f5ebbc5845fa513192e6e6b24ecdaa1 s 0x29b8e0c67aa9c11327e16556c591dc84a7aac2f6fc57c7f93901be8ee867aebc -to 0x8E730Df7C70D33118D9e5F79ab81aEd0bE6F6635 -transactionIndex 2 -type 1 -value 0 yParity 1 ".trim() ); @@ -1153,27 +1625,28 @@ yParity 1 assert_eq!( tx.pretty().trim(), r" -accessList [ - 0xC141a9A7463e6C4716d9FC0C056C054F46Bb2993 => [ - 0x0000000000000000000000000000000000000000000000000000000000000000 - ] -] blockHash 0x61abbe5e22738de0462046f5a5d6c4cd6bc1f3a6398e4457d5e293590e721125 blockNumber 30279 -chainId 1642 from 0xBaaDF00d42264eEb3FAFe6799d0b56cf55DF0F00 -gasLimit 100000 +transactionIndex 65 +effectiveGasPrice 20000000000 hash 0xa7231d4da0576fade5d3b9481f4cd52459ec59b9bbdbf4f60d6cd726b2a3a244 -input 0x48600055323160015500 +type 2 +chainId 1642 +nonce 300 +gasLimit 100000 maxFeePerGas 20000000000 maxPriorityFeePerGas 20000000000 -nonce 300 -r 0x396864e5f9132327defdb1449504252e1fa6bce73feb8cd6f348a342b198af34 -s 0x44dbba72e6d3304104848277143252ee43627c82f02d1ef8e404e1bf97c70158 to -transactionIndex 65 -type 2 value 0 +accessList [ + 0xC141a9A7463e6C4716d9FC0C056C054F46Bb2993 => [ + 0x0000000000000000000000000000000000000000000000000000000000000000 + ] +] +input 0x48600055323160015500 +r 0x396864e5f9132327defdb1449504252e1fa6bce73feb8cd6f348a342b198af34 +s 0x44dbba72e6d3304104848277143252ee43627c82f02d1ef8e404e1bf97c70158 yParity 1 " .trim() @@ -1183,57 +1656,58 @@ yParity 1 #[test] fn can_pretty_print_eip4884() { let s = r#"{ - "blockHash": "0xfc2715ff196e23ae613ed6f837abd9035329a720a1f4e8dce3b0694c867ba052", - "blockNumber": "0x2a1cb", - "from": "0xad01b55d7c3448b8899862eb335fbb17075d8de2", - "gas": "0x5208", - "gasPrice": "0x1d1a94a201c", - "maxFeePerGas": "0x1d1a94a201c", - "maxPriorityFeePerGas": "0x1d1a94a201c", - "maxFeePerBlobGas": "0x3e8", - "hash": "0x5ceec39b631763ae0b45a8fb55c373f38b8fab308336ca1dc90ecd2b3cf06d00", - "input": "0x", - "nonce": "0x1b483", - "to": "0x000000000000000000000000000000000000f1c1", - "transactionIndex": "0x0", - "value": "0x0", - "type": "0x3", - "accessList": [], - "chainId": "0x1a1f0ff42", - "blobVersionedHashes": [ - "0x01a128c46fc61395706686d6284f83c6c86dfc15769b9363171ea9d8566e6e76" - ], - "v": "0x0", - "r": "0x343c6239323a81ef61293cb4a4d37b6df47fbf68114adb5dd41581151a077da1", - "s": "0x48c21f6872feaf181d37cc4f9bbb356d3f10b352ceb38d1c3b190d749f95a11b", - "yParity": "0x0" - } + "blockHash": "0xfc2715ff196e23ae613ed6f837abd9035329a720a1f4e8dce3b0694c867ba052", + "blockNumber": "0x2a1cb", + "from": "0xad01b55d7c3448b8899862eb335fbb17075d8de2", + "gas": "0x5208", + "gasPrice": "0x1d1a94a201c", + "maxFeePerGas": "0x1d1a94a201c", + "maxPriorityFeePerGas": "0x1d1a94a201c", + "maxFeePerBlobGas": "0x3e8", + "hash": "0x5ceec39b631763ae0b45a8fb55c373f38b8fab308336ca1dc90ecd2b3cf06d00", + "input": "0x", + "nonce": "0x1b483", + "to": "0x000000000000000000000000000000000000f1c1", + "transactionIndex": "0x0", + "value": "0x0", + "type": "0x3", + "accessList": [], + "chainId": "0x1a1f0ff42", + "blobVersionedHashes": [ + "0x01a128c46fc61395706686d6284f83c6c86dfc15769b9363171ea9d8566e6e76" + ], + "v": "0x0", + "r": "0x343c6239323a81ef61293cb4a4d37b6df47fbf68114adb5dd41581151a077da1", + "s": "0x48c21f6872feaf181d37cc4f9bbb356d3f10b352ceb38d1c3b190d749f95a11b", + "yParity": "0x0" + } "#; let tx: Transaction = serde_json::from_str(s).unwrap(); assert_eq!( tx.pretty().trim(), r" -accessList [] -blobVersionedHashes [ - 0x01a128c46fc61395706686d6284f83c6c86dfc15769b9363171ea9d8566e6e76 -] blockHash 0xfc2715ff196e23ae613ed6f837abd9035329a720a1f4e8dce3b0694c867ba052 blockNumber 172491 -chainId 7011893058 from 0xAD01b55d7c3448B8899862eb335FBb17075d8DE2 -gasLimit 21000 +transactionIndex 0 +effectiveGasPrice 2000000000028 hash 0x5ceec39b631763ae0b45a8fb55c373f38b8fab308336ca1dc90ecd2b3cf06d00 -input 0x -maxFeePerBlobGas 1000 +type 3 +chainId 7011893058 +nonce 111747 +gasLimit 21000 maxFeePerGas 2000000000028 maxPriorityFeePerGas 2000000000028 -nonce 111747 -r 0x343c6239323a81ef61293cb4a4d37b6df47fbf68114adb5dd41581151a077da1 -s 0x48c21f6872feaf181d37cc4f9bbb356d3f10b352ceb38d1c3b190d749f95a11b to 0x000000000000000000000000000000000000f1C1 -transactionIndex 0 -type 3 value 0 +accessList [] +blobVersionedHashes [ + 0x01a128c46fc61395706686d6284f83c6c86dfc15769b9363171ea9d8566e6e76 +] +maxFeePerBlobGas 1000 +input 0x +r 0x343c6239323a81ef61293cb4a4d37b6df47fbf68114adb5dd41581151a077da1 +s 0x48c21f6872feaf181d37cc4f9bbb356d3f10b352ceb38d1c3b190d749f95a11b yParity 0 " .trim() @@ -1244,21 +1718,26 @@ yParity 0 fn print_block_w_txs() { let block = r#"{"number":"0x3","hash":"0xda53da08ef6a3cbde84c33e51c04f68c3853b6a3731f10baa2324968eee63972","parentHash":"0x689c70c080ca22bc0e681694fa803c1aba16a69c8b6368fed5311d279eb9de90","mixHash":"0x0000000000000000000000000000000000000000000000000000000000000000","nonce":"0x0000000000000000","sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347","logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","transactionsRoot":"0x7270c1c4440180f2bd5215809ee3d545df042b67329499e1ab97eb759d31610d","stateRoot":"0x29f32984517a7d25607da485b23cefabfd443751422ca7e603395e1de9bc8a4b","receiptsRoot":"0x056b23fbba480696b65fe5a59b8f2148a1299103c4f57df839233af2cf4ca2d2","miner":"0x0000000000000000000000000000000000000000","difficulty":"0x0","totalDifficulty":"0x0","extraData":"0x","size":"0x3e8","gasLimit":"0x6691b7","gasUsed":"0x5208","timestamp":"0x5ecedbb9","transactions":[{"hash":"0xc3c5f700243de37ae986082fd2af88d2a7c2752a0c0f7b9d6ac47c729d45e067","nonce":"0x2","blockHash":"0xda53da08ef6a3cbde84c33e51c04f68c3853b6a3731f10baa2324968eee63972","blockNumber":"0x3","transactionIndex":"0x0","from":"0xfdcedc3bfca10ecb0890337fbdd1977aba84807a","to":"0xdca8ce283150ab773bcbeb8d38289bdb5661de1e","value":"0x0","gas":"0x15f90","gasPrice":"0x4a817c800","input":"0x","v":"0x25","r":"0x19f2694eb9113656dbea0b925e2e7ceb43df83e601c4116aee9c0dd99130be88","s":"0x73e5764b324a4f7679d890a198ba658ba1c8cd36983ff9797e10b1b89dbb448e"}],"uncles":[]}"#; let block: Block = serde_json::from_str(block).unwrap(); - let output ="\nblockHash 0xda53da08ef6a3cbde84c33e51c04f68c3853b6a3731f10baa2324968eee63972 + let output = " +blockHash 0xda53da08ef6a3cbde84c33e51c04f68c3853b6a3731f10baa2324968eee63972 blockNumber 3 from 0xFdCeDC3bFca10eCb0890337fbdD1977aba84807a -gas 90000 -gasPrice 20000000000 +transactionIndex 0 +effectiveGasPrice 20000000000 hash 0xc3c5f700243de37ae986082fd2af88d2a7c2752a0c0f7b9d6ac47c729d45e067 -input 0x +type 0 +chainId 1 nonce 2 +gasPrice 20000000000 +gasLimit 90000 +to 0xdca8ce283150AB773BCbeB8d38289bdB5661dE1e +value 0 +input 0x r 0x19f2694eb9113656dbea0b925e2e7ceb43df83e601c4116aee9c0dd99130be88 s 0x73e5764b324a4f7679d890a198ba658ba1c8cd36983ff9797e10b1b89dbb448e -to 0xdca8ce283150AB773BCbeB8d38289bdB5661dE1e -transactionIndex 0 -v 0 -value 0".to_string(); - let txs = match block.transactions { +yParity 0" + .to_string(); + let txs = match block.transactions() { BlockTransactions::Full(txs) => txs, _ => panic!("not full transactions"), }; @@ -1290,19 +1769,12 @@ value 0".to_string(); fn uifmt_option_bytes() { assert_eq!(None::.pretty(), ""); assert_eq!( - Bytes::from_str("0x0000000000000000000000000000000000000000000000000000000000000064") - .unwrap() - .pretty(), + bytes!("0x0000000000000000000000000000000000000000000000000000000000000064").pretty(), "0x0000000000000000000000000000000000000000000000000000000000000064", ); assert_eq!( - Some( - Bytes::from_str( - "0x0000000000000000000000000000000000000000000000000000000000000064" - ) - .unwrap() - ) - .pretty(), + Some(bytes!("0x0000000000000000000000000000000000000000000000000000000000000064")) + .pretty(), "0x0000000000000000000000000000000000000000000000000000000000000064", ); } @@ -1310,41 +1782,47 @@ value 0".to_string(); #[test] fn test_pretty_tx_attr() { let block = r#"{"number":"0x3","hash":"0xda53da08ef6a3cbde84c33e51c04f68c3853b6a3731f10baa2324968eee63972","parentHash":"0x689c70c080ca22bc0e681694fa803c1aba16a69c8b6368fed5311d279eb9de90","mixHash":"0x0000000000000000000000000000000000000000000000000000000000000000","nonce":"0x0000000000000000","sha3Uncles":"0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347","logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","transactionsRoot":"0x7270c1c4440180f2bd5215809ee3d545df042b67329499e1ab97eb759d31610d","stateRoot":"0x29f32984517a7d25607da485b23cefabfd443751422ca7e603395e1de9bc8a4b","receiptsRoot":"0x056b23fbba480696b65fe5a59b8f2148a1299103c4f57df839233af2cf4ca2d2","miner":"0x0000000000000000000000000000000000000000","difficulty":"0x0","totalDifficulty":"0x0","extraData":"0x","size":"0x3e8","gasLimit":"0x6691b7","gasUsed":"0x5208","timestamp":"0x5ecedbb9","transactions":[{"hash":"0xc3c5f700243de37ae986082fd2af88d2a7c2752a0c0f7b9d6ac47c729d45e067","nonce":"0x2","blockHash":"0xda53da08ef6a3cbde84c33e51c04f68c3853b6a3731f10baa2324968eee63972","blockNumber":"0x3","transactionIndex":"0x0","from":"0xfdcedc3bfca10ecb0890337fbdd1977aba84807a","to":"0xdca8ce283150ab773bcbeb8d38289bdb5661de1e","value":"0x0","gas":"0x15f90","gasPrice":"0x4a817c800","input":"0x","v":"0x25","r":"0x19f2694eb9113656dbea0b925e2e7ceb43df83e601c4116aee9c0dd99130be88","s":"0x73e5764b324a4f7679d890a198ba658ba1c8cd36983ff9797e10b1b89dbb448e"}],"uncles":[]}"#; - let block: Block> = serde_json::from_str(block).unwrap(); - let txs = match block.transactions { + let block: Block = serde_json::from_str(block).unwrap(); + let txs = match block.transactions() { BlockTransactions::Full(txes) => txes, _ => panic!("not full transactions"), }; - assert_eq!(None, get_pretty_tx_attr(&txs[0], "")); - assert_eq!(Some("3".to_string()), get_pretty_tx_attr(&txs[0], "blockNumber")); + assert_eq!(None, get_pretty_tx_attr::(&txs[0], "")); + assert_eq!(Some("3".to_string()), get_pretty_tx_attr::(&txs[0], "blockNumber")); assert_eq!( Some("0xFdCeDC3bFca10eCb0890337fbdD1977aba84807a".to_string()), - get_pretty_tx_attr(&txs[0], "from") + get_pretty_tx_attr::(&txs[0], "from") + ); + assert_eq!(Some("90000".to_string()), get_pretty_tx_attr::(&txs[0], "gas")); + assert_eq!( + Some("20000000000".to_string()), + get_pretty_tx_attr::(&txs[0], "gasPrice") ); - assert_eq!(Some("90000".to_string()), get_pretty_tx_attr(&txs[0], "gas")); - assert_eq!(Some("20000000000".to_string()), get_pretty_tx_attr(&txs[0], "gasPrice")); assert_eq!( Some("0xc3c5f700243de37ae986082fd2af88d2a7c2752a0c0f7b9d6ac47c729d45e067".to_string()), - get_pretty_tx_attr(&txs[0], "hash") + get_pretty_tx_attr::(&txs[0], "hash") ); - assert_eq!(Some("0x".to_string()), get_pretty_tx_attr(&txs[0], "input")); - assert_eq!(Some("2".to_string()), get_pretty_tx_attr(&txs[0], "nonce")); + assert_eq!(Some("0x".to_string()), get_pretty_tx_attr::(&txs[0], "input")); + assert_eq!(Some("2".to_string()), get_pretty_tx_attr::(&txs[0], "nonce")); assert_eq!( Some("0x19f2694eb9113656dbea0b925e2e7ceb43df83e601c4116aee9c0dd99130be88".to_string()), - get_pretty_tx_attr(&txs[0], "r") + get_pretty_tx_attr::(&txs[0], "r") ); assert_eq!( Some("0x73e5764b324a4f7679d890a198ba658ba1c8cd36983ff9797e10b1b89dbb448e".to_string()), - get_pretty_tx_attr(&txs[0], "s") + get_pretty_tx_attr::(&txs[0], "s") ); assert_eq!( Some("0xdca8ce283150AB773BCbeB8d38289bdB5661dE1e".into()), - get_pretty_tx_attr(&txs[0], "to") + get_pretty_tx_attr::(&txs[0], "to") + ); + assert_eq!( + Some("0".to_string()), + get_pretty_tx_attr::(&txs[0], "transactionIndex") ); - assert_eq!(Some("0".to_string()), get_pretty_tx_attr(&txs[0], "transactionIndex")); - assert_eq!(Some("27".to_string()), get_pretty_tx_attr(&txs[0], "v")); - assert_eq!(Some("0".to_string()), get_pretty_tx_attr(&txs[0], "value")); + assert_eq!(Some("27".to_string()), get_pretty_tx_attr::(&txs[0], "v")); + assert_eq!(Some("0".to_string()), get_pretty_tx_attr::(&txs[0], "value")); } #[test] @@ -1367,7 +1845,7 @@ value 0".to_string(); "transactionsRoot": "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", "receiptsRoot": "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421", "stateRoot": "0xd5855eb08b3387c0af375e9cdb6acfc05eb8f519e419b874b6ff2ffda7ed1dff", - "difficulty": "0x27f07", + "difficulty": "0x1", "totalDifficulty": "0x27f07", "extraData": "0x0000000000000000000000000000000000000000000000000000000000000000", "size": "0x27f07", @@ -1376,59 +1854,81 @@ value 0".to_string(); "gasUsed": "0x9f759", "timestamp": "0x54e34e8e", "transactions": [], - "uncles": [] + "uncles": [], } ); - let block: AnyRpcBlock = serde_json::from_value(json).unwrap(); + let block: Block = serde_json::from_value(json).unwrap(); - assert_eq!(None, get_pretty_block_attr(&block, "")); - assert_eq!(Some("7".to_string()), get_pretty_block_attr(&block, "baseFeePerGas")); - assert_eq!(Some("163591".to_string()), get_pretty_block_attr(&block, "difficulty")); + assert_eq!(None, get_pretty_block_attr::(&block, "")); + assert_eq!( + Some("7".to_string()), + get_pretty_block_attr::(&block, "baseFeePerGas") + ); + assert_eq!(Some("1".to_string()), get_pretty_block_attr::(&block, "difficulty")); assert_eq!( Some("0x0000000000000000000000000000000000000000000000000000000000000000".to_string()), - get_pretty_block_attr(&block, "extraData") + get_pretty_block_attr::(&block, "extraData") + ); + assert_eq!( + Some("653145".to_string()), + get_pretty_block_attr::(&block, "gasLimit") + ); + assert_eq!( + Some("653145".to_string()), + get_pretty_block_attr::(&block, "gasUsed") ); - assert_eq!(Some("653145".to_string()), get_pretty_block_attr(&block, "gasLimit")); - assert_eq!(Some("653145".to_string()), get_pretty_block_attr(&block, "gasUsed")); assert_eq!( Some("0x0e670ec64341771606e55d6b4ca35a1a6b75ee3d5145a99d05921026d1527331".to_string()), - get_pretty_block_attr(&block, "hash") + get_pretty_block_attr::(&block, "hash") ); - assert_eq!(Some("0x0e670ec64341771606e55d6b4ca35a1a6b75ee3d5145a99d05921026d15273310e670ec64341771606e55d6b4ca35a1a6b75ee3d5145a99d05921026d15273310e670ec64341771606e55d6b4ca35a1a6b75ee3d5145a99d05921026d15273310e670ec64341771606e55d6b4ca35a1a6b75ee3d5145a99d05921026d15273310e670ec64341771606e55d6b4ca35a1a6b75ee3d5145a99d05921026d15273310e670ec64341771606e55d6b4ca35a1a6b75ee3d5145a99d05921026d15273310e670ec64341771606e55d6b4ca35a1a6b75ee3d5145a99d05921026d15273310e670ec64341771606e55d6b4ca35a1a6b75ee3d5145a99d05921026d1527331".to_string()), get_pretty_block_attr(&block, "logsBloom")); + assert_eq!(Some("0x0e670ec64341771606e55d6b4ca35a1a6b75ee3d5145a99d05921026d15273310e670ec64341771606e55d6b4ca35a1a6b75ee3d5145a99d05921026d15273310e670ec64341771606e55d6b4ca35a1a6b75ee3d5145a99d05921026d15273310e670ec64341771606e55d6b4ca35a1a6b75ee3d5145a99d05921026d15273310e670ec64341771606e55d6b4ca35a1a6b75ee3d5145a99d05921026d15273310e670ec64341771606e55d6b4ca35a1a6b75ee3d5145a99d05921026d15273310e670ec64341771606e55d6b4ca35a1a6b75ee3d5145a99d05921026d15273310e670ec64341771606e55d6b4ca35a1a6b75ee3d5145a99d05921026d1527331".to_string()), get_pretty_block_attr::(&block, "logsBloom")); assert_eq!( Some("0x0000000000000000000000000000000000000001".to_string()), - get_pretty_block_attr(&block, "miner") + get_pretty_block_attr::(&block, "miner") ); assert_eq!( Some("0x1010101010101010101010101010101010101010101010101010101010101010".to_string()), - get_pretty_block_attr(&block, "mixHash") + get_pretty_block_attr::(&block, "mixHash") + ); + assert_eq!( + Some("0x0000000000000000".to_string()), + get_pretty_block_attr::(&block, "nonce") ); - assert_eq!(Some("0x0000000000000000".to_string()), get_pretty_block_attr(&block, "nonce")); - assert_eq!(Some("436".to_string()), get_pretty_block_attr(&block, "number")); + assert_eq!(Some("436".to_string()), get_pretty_block_attr::(&block, "number")); assert_eq!( Some("0x9646252be9520f6e71339a8df9c55e4d7619deeb018d2a3f2d21fc165dde5eb5".to_string()), - get_pretty_block_attr(&block, "parentHash") + get_pretty_block_attr::(&block, "parentHash") ); assert_eq!( Some("0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421".to_string()), - get_pretty_block_attr(&block, "transactionsRoot") + get_pretty_block_attr::(&block, "transactionsRoot") ); assert_eq!( Some("0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421".to_string()), - get_pretty_block_attr(&block, "receiptsRoot") + get_pretty_block_attr::(&block, "receiptsRoot") ); assert_eq!( Some("0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347".to_string()), - get_pretty_block_attr(&block, "sha3Uncles") + get_pretty_block_attr::(&block, "sha3Uncles") ); - assert_eq!(Some("163591".to_string()), get_pretty_block_attr(&block, "size")); + assert_eq!(Some("163591".to_string()), get_pretty_block_attr::(&block, "size")); assert_eq!( Some("0xd5855eb08b3387c0af375e9cdb6acfc05eb8f519e419b874b6ff2ffda7ed1dff".to_string()), - get_pretty_block_attr(&block, "stateRoot") + get_pretty_block_attr::(&block, "stateRoot") + ); + assert_eq!( + Some("1424182926".to_string()), + get_pretty_block_attr::(&block, "timestamp") + ); + assert_eq!( + Some("163591".to_string()), + get_pretty_block_attr::(&block, "totalDifficulty") ); - assert_eq!(Some("1424182926".to_string()), get_pretty_block_attr(&block, "timestamp")); - assert_eq!(Some("163591".to_string()), get_pretty_block_attr(&block, "totalDifficulty")); + + let pretty = pretty_generic_header_response(block.header()); + assert!(pretty.contains("difficulty 1"), "{pretty}"); + assert!(pretty.contains("totalDifficulty 163591"), "{pretty}"); } #[test] @@ -1492,8 +1992,8 @@ l1GasUsed 1600 #[test] fn test_uifmt_for_signed_authorization() { let inner = Authorization { - chain_id: U256::from(1), - address: "0x000000000000000000000000000000000000dead".parse::
().unwrap(), + chain_id: U256::ONE, + address: address!("0x000000000000000000000000000000000000dead"), nonce: 42, }; let signed_authorization = @@ -1504,4 +2004,186 @@ l1GasUsed 1600 r#"{recoveredAuthority: 0xf3eaBD0de6Ca1aE7fC4D81FfD6C9a40e5D5D7e30, signedAuthority: {"chainId":"0x1","address":"0x000000000000000000000000000000000000dead","nonce":"0x2a","yParity":"0x1","r":"0x14","s":"0x1e"}}"# ); } + + #[test] + fn can_pretty_print_tempo_tx() { + let s = r#"{ + "type":"0x76", + "chainId":"0xa5bd", + "feeToken":"0x20c0000000000000000000000000000000000001", + "maxPriorityFeePerGas":"0x0", + "maxFeePerGas":"0x2cb417800", + "gas":"0x2d178", + "calls":[ + { + "data":null, + "input":"0x095ea7b3000000000000000000000000dec00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000989680", + "to":"0x20c0000000000000000000000000000000000000", + "value":"0x0" + }, + { + "data":null, + "input":"0xf8856c0f00000000000000000000000020c000000000000000000000000000000000000000000000000000000000000020c00000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000989680000000000000000000000000000000000000000000000000000000000097d330", + "to":"0xdec0000000000000000000000000000000000000", + "value":"0x0" + } + ], + "accessList":[], + "nonceKey":"0x0", + "nonce":"0x0", + "feePayerSignature":null, + "validBefore":null, + "validAfter":null, + "keyAuthorization":null, + "aaAuthorizationList":[], + "signature":{ + "pubKeyX":"0xaacc80b21e45fb11f349424dce3a2f23547f60c0ff2f8bcaede2a247545ce8dd", + "pubKeyY":"0x87abf0dbb7a5c9507efae2e43833356651b45ac576c2e61cec4e9c0f41fcbf6e", + "r":"0xcfd45c3b19745a42f80b134dcb02a8ba099a0e4e7be1984da54734aa81d8f29f", + "s":"0x74bb9170ae6d25bd510c83fe35895ee5712efe13980a5edc8094c534e23af85e", + "type":"webAuthn", + "webauthnData":"0x7b98b7a8e6c68d7eac741a52e6fdae0560ce3c16ef5427ad46d7a54d0ed86dd41d000000007b2274797065223a22776562617574686e2e676574222c226368616c6c656e6765223a2238453071464a7a50585167546e645473643649456659457776323173516e626966374c4741776e4b43626b222c226f726967696e223a2268747470733a2f2f74656d706f2d6465782e76657263656c2e617070222c2263726f73734f726967696e223a66616c73657d" + }, + "hash":"0x6d6d8c102064e6dee44abad2024a8b1d37959230baab80e70efbf9b0c739c4fd", + "blockHash":"0xc82b23589ceef5341ed307d33554714db6f9eefd4187a9ca8abc910a325b4689", + "blockNumber":"0x321fde", + "transactionIndex":"0x0", + "from":"0x566ff0f4a6114f8072ecdc8a7a8a13d8d0c6b45f", + "gasPrice":"0x2540be400" + }"#; + + let tx: Transaction = serde_json::from_str(s).unwrap(); + + assert_eq!( + tx.pretty().trim(), + r#" +blockHash 0xc82b23589ceef5341ed307d33554714db6f9eefd4187a9ca8abc910a325b4689 +blockNumber 3284958 +from 0x566Ff0f4a6114F8072ecDC8A7A8A13d8d0C6B45F +transactionIndex 0 +effectiveGasPrice 10000000000 +hash 0x6d6d8c102064e6dee44abad2024a8b1d37959230baab80e70efbf9b0c739c4fd +type 118 +chainId 42429 +feeToken 0x20C0000000000000000000000000000000000001 +maxPriorityFeePerGas 0 +maxFeePerGas 12000000000 +gasLimit 184696 +calls [ + to: 0x20C0000000000000000000000000000000000000, value: 0, input: 0x095ea7b3000000000000000000000000dec00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000989680 + to: 0xDEc0000000000000000000000000000000000000, value: 0, input: 0xf8856c0f00000000000000000000000020c000000000000000000000000000000000000000000000000000000000000020c00000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000989680000000000000000000000000000000000000000000000000000000000097d330 +] +accessList [] +nonceKey 0 +nonce 0 +feePayerSignature +validBefore +validAfter +tempoSignature {"type":"webAuthn","r":"0xcfd45c3b19745a42f80b134dcb02a8ba099a0e4e7be1984da54734aa81d8f29f","s":"0x74bb9170ae6d25bd510c83fe35895ee5712efe13980a5edc8094c534e23af85e","pubKeyX":"0xaacc80b21e45fb11f349424dce3a2f23547f60c0ff2f8bcaede2a247545ce8dd","pubKeyY":"0x87abf0dbb7a5c9507efae2e43833356651b45ac576c2e61cec4e9c0f41fcbf6e","webauthnData":"0x7b98b7a8e6c68d7eac741a52e6fdae0560ce3c16ef5427ad46d7a54d0ed86dd41d000000007b2274797065223a22776562617574686e2e676574222c226368616c6c656e6765223a2238453071464a7a50585167546e645473643649456659457776323173516e626966374c4741776e4b43626b222c226f726967696e223a2268747470733a2f2f74656d706f2d6465782e76657263656c2e617070222c2263726f73734f726967696e223a66616c73657d"} +"# + .trim() + ); + } + + #[test] + fn can_pretty_print_tempo_receipt() { + let s = r#"{"type":"0x76","status":"0x1","cumulativeGasUsed":"0x176d7f4","logs":[{"address":"0x20c0000000000000000000000000000000000000","topics":["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef","0x000000000000000000000000a70ab0448e66cd77995bfbba5c5b64b41a85f3fd","0x0000000000000000000000000000000000000000000000000000000000000001"],"data":"0x00000000000000000000000000000000000000000000000000000000000003e8","blockHash":"0x860f788b251ece768e63b0d3906d156f652d843848b71c7fe81faacd49139d66","blockNumber":"0x69a1d7","blockTimestamp":"0x69a5d790","transactionHash":"0x04548a0ea27e2cccc1479af3c2ff02da4d4d3ea46af8e8d7edaa49f6ea27073f","transactionIndex":"0x63","logIndex":"0xb8","removed":false},{"address":"0x20c0000000000000000000000000000000000003","topics":["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef","0x000000000000000000000000a70ab0448e66cd77995bfbba5c5b64b41a85f3fd","0x000000000000000000000000feec000000000000000000000000000000000000"],"data":"0x0000000000000000000000000000000000000000000000000000000000000417","blockHash":"0x860f788b251ece768e63b0d3906d156f652d843848b71c7fe81faacd49139d66","blockNumber":"0x69a1d7","blockTimestamp":"0x69a5d790","transactionHash":"0x04548a0ea27e2cccc1479af3c2ff02da4d4d3ea46af8e8d7edaa49f6ea27073f","transactionIndex":"0x63","logIndex":"0xb9","removed":false}],"logsBloom":"0x00000000000000000000000000000000000000000000010000000000000000000000000000000000000000000100000000000000000000000000000000040008000004200000000000000008000000000000000000040000000000000400000000000002000000000000000000000000000000000000000000000010000000000000000000000000000000000020000000000000800000000000000000000000000020000000000000000000000000000000000400000000000000000000000000000002000000000000000400000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000000","transactionHash":"0x04548a0ea27e2cccc1479af3c2ff02da4d4d3ea46af8e8d7edaa49f6ea27073f","transactionIndex":"0x63","blockHash":"0x860f788b251ece768e63b0d3906d156f652d843848b71c7fe81faacd49139d66","blockNumber":"0x69a1d7","gasUsed":"0xcc6a","effectiveGasPrice":"0x4a817c802","from":"0xa70ab0448e66cd77995bfbba5c5b64b41a85f3fd","to":"0x20c0000000000000000000000000000000000000","contractAddress":null,"feeToken":"0x20c0000000000000000000000000000000000003","feePayer":"0xa70ab0448e66cd77995bfbba5c5b64b41a85f3fd"}"#; + + let tx: TempoTransactionReceipt = serde_json::from_str(s).unwrap(); + + assert_eq!( + tx.pretty().trim(), + r#" +blockHash 0x860f788b251ece768e63b0d3906d156f652d843848b71c7fe81faacd49139d66 +blockNumber 6922711 +contractAddress +cumulativeGasUsed 24565748 +effectiveGasPrice 20000000002 +from 0xa70ab0448e66cD77995bfBBa5c5b64B41a85F3fd +gasUsed 52330 +logs [{"address":"0x20c0000000000000000000000000000000000000","topics":["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef","0x000000000000000000000000a70ab0448e66cd77995bfbba5c5b64b41a85f3fd","0x0000000000000000000000000000000000000000000000000000000000000001"],"data":"0x00000000000000000000000000000000000000000000000000000000000003e8","blockHash":"0x860f788b251ece768e63b0d3906d156f652d843848b71c7fe81faacd49139d66","blockNumber":"0x69a1d7","blockTimestamp":"0x69a5d790","transactionHash":"0x04548a0ea27e2cccc1479af3c2ff02da4d4d3ea46af8e8d7edaa49f6ea27073f","transactionIndex":"0x63","logIndex":"0xb8","removed":false},{"address":"0x20c0000000000000000000000000000000000003","topics":["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef","0x000000000000000000000000a70ab0448e66cd77995bfbba5c5b64b41a85f3fd","0x000000000000000000000000feec000000000000000000000000000000000000"],"data":"0x0000000000000000000000000000000000000000000000000000000000000417","blockHash":"0x860f788b251ece768e63b0d3906d156f652d843848b71c7fe81faacd49139d66","blockNumber":"0x69a1d7","blockTimestamp":"0x69a5d790","transactionHash":"0x04548a0ea27e2cccc1479af3c2ff02da4d4d3ea46af8e8d7edaa49f6ea27073f","transactionIndex":"0x63","logIndex":"0xb9","removed":false}] +logsBloom 0x00000000000000000000000000000000000000000000010000000000000000000000000000000000000000000100000000000000000000000000000000040008000004200000000000000008000000000000000000040000000000000400000000000002000000000000000000000000000000000000000000000010000000000000000000000000000000000020000000000000800000000000000000000000000020000000000000000000000000000000000400000000000000000000000000000002000000000000000400000000000000000000000000000000000040000000000000000000000000000000000000000000000000000000000000000000 +root +status true +transactionHash 0x04548a0ea27e2cccc1479af3c2ff02da4d4d3ea46af8e8d7edaa49f6ea27073f +transactionIndex 99 +type 118 +feePayer 0xa70ab0448e66cD77995bfBBa5c5b64B41a85F3fd +feeToken 0x20C0000000000000000000000000000000000003 +to 0x20C0000000000000000000000000000000000000 +"# + .trim() + ); + } + + #[test] + fn test_ethereum_receipt_uifmt() { + let s = r#"{"type":"0x2","status":"0x1","cumulativeGasUsed":"0x5208","logs":[],"logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","transactionHash":"0x1234567890123456789012345678901234567890123456789012345678901234","transactionIndex":"0x0","blockHash":"0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd","blockNumber":"0x1","gasUsed":"0x5208","effectiveGasPrice":"0x3b9aca00","from":"0x1234567890123456789012345678901234567890","to":"0x0987654321098765432109876543210987654321","contractAddress":null}"#; + let receipt: TransactionReceipt = serde_json::from_str(s).unwrap(); + + let pretty_output = receipt.pretty(); + + assert!(pretty_output.contains("blockHash")); + assert!(pretty_output.contains("blockNumber")); + assert!(pretty_output.contains("status")); + assert!(pretty_output.contains("gasUsed")); + assert!(pretty_output.contains("transactionHash")); + assert!(pretty_output.contains("type")); + assert!( + pretty_output + .contains("0x1234567890123456789012345678901234567890123456789012345678901234") + ); + assert!(pretty_output.contains("1 (success)")); + assert!(pretty_output.contains("0x0987654321098765432109876543210987654321")); + } + + #[test] + fn test_get_pretty_receipt_attr() { + let receipt_json = serde_json::json!({ + "type": "0x2", + "status": "0x1", + "cumulativeGasUsed": "0x5208", + "logs": [], + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "transactionHash": "0x1234567890123456789012345678901234567890123456789012345678901234", + "transactionIndex": "0x0", + "blockHash": "0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd", + "blockNumber": "0x1", + "gasUsed": "0x5208", + "effectiveGasPrice": "0x3b9aca00", + "from": "0x1234567890123456789012345678901234567890", + "to": "0x0987654321098765432109876543210987654321", + "contractAddress": null + }); + + let receipt: TransactionReceipt = serde_json::from_value(receipt_json).unwrap(); + + // Test basic receipt attributes + assert_eq!( + Some("0xabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd".to_string()), + get_pretty_receipt_attr::(&receipt, "blockHash") + ); + assert_eq!( + Some("1".to_string()), + get_pretty_receipt_attr::(&receipt, "blockNumber") + ); + assert_eq!( + Some("0x1234567890123456789012345678901234567890123456789012345678901234".to_string()), + get_pretty_receipt_attr::(&receipt, "transactionHash") + ); + assert_eq!( + Some("21000".to_string()), + get_pretty_receipt_attr::(&receipt, "gasUsed") + ); + assert_eq!( + Some("true".to_string()), + get_pretty_receipt_attr::(&receipt, "status") + ); + assert_eq!( + Some("EIP-1559".to_string()), + get_pretty_receipt_attr::(&receipt, "type") + ); + assert_eq!(Some("[]".to_string()), get_pretty_receipt_attr::(&receipt, "logs")); + assert!(get_pretty_receipt_attr::(&receipt, "logsBloom").is_some()); + } } diff --git a/crates/common/src/abi.rs b/crates/common/src/abi.rs index aee64473f135b..b105c356b9c3a 100644 --- a/crates/common/src/abi.rs +++ b/crates/common/src/abi.rs @@ -1,13 +1,11 @@ //! ABI related helper functions. -use alloy_dyn_abi::{DynSolType, DynSolValue, FunctionExt, JsonAbiExt}; +use alloy_chains::Chain; +use alloy_dyn_abi::{DynSolType, DynSolValue, FunctionExt, JsonAbiExt, Specifier}; use alloy_json_abi::{Error, Event, Function, Param}; use alloy_primitives::{Address, LogData, hex}; use eyre::{Context, ContextCompat, Result}; -use foundry_block_explorers::{ - Client, EtherscanApiVersion, contract::ContractMetadata, errors::EtherscanError, -}; -use foundry_config::Chain; +use foundry_block_explorers::{Client, contract::ContractMetadata, errors::EtherscanError}; use std::pin::Pin; pub fn encode_args(inputs: &[Param], args: I) -> Result> @@ -15,6 +13,12 @@ where I: IntoIterator, S: AsRef, { + let args: Vec = args.into_iter().collect(); + + if inputs.len() != args.len() { + eyre::bail!("encode length mismatch: expected {} types, got {}", inputs.len(), args.len()); + } + std::iter::zip(inputs, args) .map(|(input, arg)| coerce_value(&input.selector_type(), arg.as_ref())) .collect() @@ -47,6 +51,16 @@ where I: IntoIterator, S: AsRef, { + let args: Vec = args.into_iter().collect(); + + if func.inputs.len() != args.len() { + eyre::bail!( + "encode length mismatch: expected {} types, got {}", + func.inputs.len(), + args.len(), + ); + } + let params: Vec> = std::iter::zip(&func.inputs, args) .map(|(input, arg)| coerce_value(&input.selector_type(), arg.as_ref())) .collect::>>()? @@ -78,7 +92,7 @@ pub fn abi_decode_calldata( // in case the decoding worked but nothing was decoded if res.is_empty() { - eyre::bail!("no data was decoded") + eyre::bail!("no data was decoded"); } Ok(res) @@ -94,9 +108,28 @@ pub fn get_event(sig: &str) -> Result { Event::parse(sig).wrap_err("could not parse event signature") } +/// ABI-decodes the non-indexed parameters (the log data) of an event. +/// +/// Indexed parameters are stored in the log topics rather than in the data, so they are skipped. +/// This allows decoding the data of a log without knowing its topics. +pub fn abi_decode_event_data(event: &Event, data: &[u8]) -> Result> { + let body = DynSolType::Tuple( + event + .inputs + .iter() + .filter(|input| !input.indexed) + .map(|input| input.resolve()) + .collect::>()?, + ); + Ok(match body.abi_decode_sequence(data)? { + DynSolValue::Tuple(values) => values, + _ => unreachable!("body is a tuple"), + }) +} + /// Given an error signature string, it tries to parse it as a `Error` pub fn get_error(sig: &str) -> Result { - Error::parse(sig).wrap_err("could not parse event signature") + Error::parse(sig).wrap_err("could not parse error signature") } /// Given an event without indexed parameters and a rawlog, it tries to return the event with the @@ -129,9 +162,17 @@ pub async fn get_func_etherscan( args: &[String], chain: Chain, etherscan_api_key: &str, - etherscan_api_version: EtherscanApiVersion, + etherscan_api_url: Option<&str>, ) -> Result { - let client = Client::new_with_api_version(chain, etherscan_api_key, etherscan_api_version)?; + let client = if let Some(api_url) = etherscan_api_url { + Client::builder() + .with_api_key(etherscan_api_key) + .with_api_url(api_url)? + .with_url(api_url)? + .build()? + } else { + Client::new(chain, etherscan_api_key)? + }; let source = find_source(client, contract).await?; let metadata = source.items.first().wrap_err("etherscan returned empty metadata")?; @@ -157,25 +198,31 @@ pub fn find_source( trace!(%address, "find Etherscan source"); let source = client.contract_source_code(address).await?; let metadata = source.items.first().wrap_err("Etherscan returned no data")?; - if metadata.proxy == 0 { - Ok(source) - } else { - let implementation = metadata.implementation.unwrap(); + // `Proxy: 1` can still come with an empty `Implementation` (unresolved/unverified); + // treat that like "not a proxy" instead of panicking on `.unwrap()`. + let implementation = if metadata.proxy == 0 { None } else { metadata.implementation }; + if let Some(implementation) = implementation { sh_println!( "Contract at {address} is a proxy, trying to fetch source at {implementation}..." )?; match find_source(client, implementation).await { impl_source @ Ok(_) => impl_source, - Err(e) => { - let err = EtherscanError::ContractCodeNotVerified(address).to_string(); - if e.to_string() == err { - error!(%err); - Ok(source) - } else { - Err(e) - } + Err(e) + if matches!( + e.downcast_ref::(), + Some(EtherscanError::ContractCodeNotVerified(address)) if *address == implementation + ) => + { + error!(%e); + Ok(source) } + Err(e) => Err(e), + } + } else { + if metadata.proxy != 0 { + error!(%address, "Etherscan reports this contract as a proxy but returned no implementation address"); } + Ok(source) } }) } @@ -192,6 +239,69 @@ mod tests { use alloy_dyn_abi::EventExt; use alloy_primitives::{B256, U256}; + /// `Proxy: 1` with an empty `Implementation` used to panic on `.unwrap()` + /// (real-world shape, see `foundry_block_explorers`' own `can_deserialize_address_opt` test). + #[test] + fn test_proxy_without_implementation_does_not_panic() { + use foundry_block_explorers::contract::Metadata; + + let json = serde_json::json!({ + "SourceCode": "// dummy", + "ABI": "[]", + "ContractName": "Dummy", + "CompilerVersion": "v0.8.0+commit.c7dfd78e", + "OptimizationUsed": "0", + "Runs": "200", + "ConstructorArguments": "", + "EVMVersion": "Default", + "Library": "", + "LicenseType": "None", + "Proxy": "1", + "Implementation": "", + "SwarmSource": "" + }); + + let metadata: Metadata = + serde_json::from_value(json).expect("realistic Etherscan payload must deserialize"); + + // This is exactly the combination that used to reach `.unwrap()` on `None`. + assert_eq!(metadata.proxy, 1, "Proxy: 1 must deserialize to a nonzero proxy flag"); + assert_eq!( + metadata.implementation, None, + "an empty Implementation string must deserialize to None, not a parsed address" + ); + + // Must not panic: this is the exact decision `find_source` makes. + let implementation = if metadata.proxy == 0 { None } else { metadata.implementation }; + assert_eq!(implementation, None); + } + + #[test] + fn proxy_implementation_decision_follows_real_implementation() { + use alloy_primitives::address; + use foundry_block_explorers::contract::Metadata; + + let json = serde_json::json!({ + "SourceCode": "// dummy", + "ABI": "[]", + "ContractName": "Dummy", + "CompilerVersion": "v0.8.0+commit.c7dfd78e", + "OptimizationUsed": "0", + "Runs": "200", + "ConstructorArguments": "", + "EVMVersion": "Default", + "Library": "", + "LicenseType": "None", + "Proxy": "1", + "Implementation": "0x1F98431c8aD98523631AE4a59f267346ea31F984", + "SwarmSource": "" + }); + let metadata: Metadata = serde_json::from_value(json).unwrap(); + + let implementation = if metadata.proxy == 0 { None } else { metadata.implementation }; + assert_eq!(implementation, Some(address!("0x1F98431c8aD98523631AE4a59f267346ea31F984"))); + } + #[test] fn test_get_func() { let func = get_func("function foo(uint256 a, uint256 b) returns (uint256)"); @@ -257,4 +367,73 @@ mod tests { assert_eq!(parsed.indexed[1], DynSolValue::Uint(U256::from_be_bytes([3; 32]), 256)); assert_eq!(parsed.indexed[2], DynSolValue::Address(Address::from_word(param2))); } + + #[test] + fn test_abi_decode_event_data_ignores_indexed() { + let event = + get_event("event Ev(uint256 indexed a, string b, address indexed c, uint256 d)") + .unwrap(); + let data = DynSolValue::Tuple(vec![ + DynSolValue::String("hello".into()), + DynSolValue::Uint(U256::from(42), 256), + ]) + .abi_encode_params(); + + let decoded = abi_decode_event_data(&event, &data).unwrap(); + assert_eq!( + decoded, + vec![DynSolValue::String("hello".into()), DynSolValue::Uint(U256::from(42), 256)] + ); + } + + #[test] + fn test_abi_decode_event_data_without_indexed() { + let event = get_event("event Ev(uint256 a, address b)").unwrap(); + let addr = Address::random(); + let data = + DynSolValue::Tuple(vec![DynSolValue::Uint(U256::ONE, 256), DynSolValue::Address(addr)]) + .abi_encode_params(); + + let decoded = abi_decode_event_data(&event, &data).unwrap(); + assert_eq!(decoded, vec![DynSolValue::Uint(U256::ONE, 256), DynSolValue::Address(addr)]); + } + + #[test] + fn test_encode_args_length_validation() { + use alloy_json_abi::Param; + + let params = vec![ + Param { + name: "a".to_string(), + ty: "uint256".to_string(), + internal_type: None, + components: vec![], + }, + Param { + name: "b".to_string(), + ty: "address".to_string(), + internal_type: None, + components: vec![], + }, + ]; + + // Less arguments than parameters + let args = vec!["1"]; + let res = encode_args(¶ms, &args); + assert!(res.is_err()); + assert!(format!("{}", res.unwrap_err()).contains("encode length mismatch")); + + // Exact number of arguments and parameters + let args = vec!["1", "0x0000000000000000000000000000000000000001"]; + let res = encode_args(¶ms, &args); + assert!(res.is_ok()); + let values = res.unwrap(); + assert_eq!(values.len(), 2); + + // More arguments than parameters + let args = vec!["1", "0x0000000000000000000000000000000000000001", "extra"]; + let res = encode_args(¶ms, &args); + assert!(res.is_err()); + assert!(format!("{}", res.unwrap_err()).contains("encode length mismatch")); + } } diff --git a/crates/common/src/calc.rs b/crates/common/src/calc.rs index 54ad4439a5982..a100ee213ce96 100644 --- a/crates/common/src/calc.rs +++ b/crates/common/src/calc.rs @@ -1,7 +1,6 @@ //! Commonly used calculations. /// Returns the mean of the slice. -#[inline] pub fn mean(values: &[u64]) -> u64 { if values.is_empty() { return 0; @@ -11,8 +10,7 @@ pub fn mean(values: &[u64]) -> u64 { } /// Returns the median of a _sorted_ slice. -#[inline] -pub fn median_sorted(values: &[u64]) -> u64 { +pub const fn median_sorted(values: &[u64]) -> u64 { if values.is_empty() { return 0; } diff --git a/crates/common/src/comments/comment.rs b/crates/common/src/comments/comment.rs index b05a7bbdd1251..c19ade41ce9c8 100644 --- a/crates/common/src/comments/comment.rs +++ b/crates/common/src/comments/comment.rs @@ -1,11 +1,11 @@ //! Modified from [`rustc_ast::util::comments`](https://github.com/rust-lang/rust/blob/07d3fd1d9b9c1f07475b96a9d168564bf528db68/compiler/rustc_ast/src/util/comments.rs). -use solar_parse::{ +use solar::parse::{ ast::{CommentKind, Span}, interface::BytePos, }; -#[derive(Clone, Copy, PartialEq, Debug)] +#[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum CommentStyle { /// No code on either side of each line of the comment Isolated, @@ -17,6 +17,21 @@ pub enum CommentStyle { BlankLine, } +impl CommentStyle { + pub const fn is_mixed(&self) -> bool { + matches!(self, Self::Mixed) + } + pub const fn is_trailing(&self) -> bool { + matches!(self, Self::Trailing) + } + pub const fn is_isolated(&self) -> bool { + matches!(self, Self::Isolated) + } + pub const fn is_blank(&self) -> bool { + matches!(self, Self::BlankLine) + } +} + #[derive(Clone, Debug)] pub struct Comment { pub lines: Vec, @@ -31,7 +46,7 @@ impl Comment { self.span.lo() } - pub fn prefix(&self) -> Option<&'static str> { + pub const fn prefix(&self) -> Option<&'static str> { if self.lines.is_empty() { return None; } @@ -43,7 +58,7 @@ impl Comment { }) } - pub fn suffix(&self) -> Option<&'static str> { + pub const fn suffix(&self) -> Option<&'static str> { if self.lines.is_empty() { return None; } diff --git a/crates/common/src/comments/comments.rs b/crates/common/src/comments/comments.rs deleted file mode 100644 index fb13654f811fc..0000000000000 --- a/crates/common/src/comments/comments.rs +++ /dev/null @@ -1,193 +0,0 @@ -use super::comment::{Comment, CommentStyle}; -use solar_parse::{ - ast::{CommentKind, Span}, - interface::{source_map::SourceFile, BytePos, CharPos, SourceMap}, - lexer::token::RawTokenKind as TokenKind, -}; -use std::fmt; - -pub struct Comments { - comments: std::vec::IntoIter, -} - -impl fmt::Debug for Comments { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str("Comments")?; - f.debug_list().entries(self.iter()).finish() - } -} - -/// Returns `None` if the first `col` chars of `s` contain a non-whitespace char. -/// Otherwise returns `Some(k)` where `k` is first char offset after that leading -/// whitespace. Note that `k` may be outside bounds of `s`. -fn all_whitespace(s: &str, col: CharPos) -> Option { - let mut idx = 0; - for (i, ch) in s.char_indices().take(col.to_usize()) { - if !ch.is_whitespace() { - return None; - } - idx = i + ch.len_utf8(); - } - Some(idx) -} - -fn trim_whitespace_prefix(s: &str, col: CharPos) -> &str { - let len = s.len(); - match all_whitespace(s, col) { - Some(col) => { - if col < len { - &s[col..] - } else { - "" - } - } - None => s, - } -} - -fn split_block_comment_into_lines(text: &str, col: CharPos) -> Vec { - let mut res: Vec = vec![]; - let mut lines = text.lines(); - // just push the first line - res.extend(lines.next().map(|it| it.to_string())); - // for other lines, strip common whitespace prefix - for line in lines { - res.push(trim_whitespace_prefix(line, col).to_string()) - } - res -} - -/// Returns the `BytePos` of the beginning of the current line. -fn line_begin_pos(sf: &SourceFile, pos: BytePos) -> BytePos { - let pos = sf.relative_position(pos); - let line_index = sf.lookup_line(pos).unwrap(); - let line_start_pos = sf.lines()[line_index]; - sf.absolute_position(line_start_pos) -} - -fn gather_comments(sf: &SourceFile) -> Vec { - let text = sf.src.as_str(); - let start_bpos = sf.start_pos; - let mut pos = 0; - let mut comments: Vec = Vec::new(); - let mut code_to_the_left = false; - - let make_span = |range: std::ops::Range| { - Span::new(start_bpos + range.start as u32, start_bpos + range.end as u32) - }; - - /* - if let Some(shebang_len) = strip_shebang(text) { - comments.push(Comment { - style: CommentStyle::Isolated, - lines: vec![text[..shebang_len].to_string()], - pos: start_bpos, - }); - pos += shebang_len; - } - */ - - for token in solar_parse::Cursor::new(&text[pos..]) { - let token_range = pos..pos + token.len as usize; - let span = make_span(token_range.clone()); - let token_text = &text[token_range]; - match token.kind { - TokenKind::Whitespace => { - if let Some(mut idx) = token_text.find('\n') { - code_to_the_left = false; - - // NOTE(dani): this used to be `while`, but we want only a single blank line. - if let Some(next_newline) = token_text[idx + 1..].find('\n') { - idx += 1 + next_newline; - let pos = pos + idx; - comments.push(Comment { - is_doc: false, - kind: CommentKind::Line, - style: CommentStyle::BlankLine, - lines: vec![], - span: make_span(pos..pos), - }); - } - } - } - TokenKind::BlockComment { is_doc, .. } => { - let code_to_the_right = - !matches!(text[pos + token.len as usize..].chars().next(), Some('\r' | '\n')); - let style = match (code_to_the_left, code_to_the_right) { - (_, true) => CommentStyle::Mixed, - (false, false) => CommentStyle::Isolated, - (true, false) => CommentStyle::Trailing, - }; - let kind = CommentKind::Block; - - // Count the number of chars since the start of the line by rescanning. - let pos_in_file = start_bpos + BytePos(pos as u32); - let line_begin_in_file = line_begin_pos(sf, pos_in_file); - let line_begin_pos = (line_begin_in_file - start_bpos).to_usize(); - let col = CharPos(text[line_begin_pos..pos].chars().count()); - - let lines = split_block_comment_into_lines(token_text, col); - comments.push(Comment { is_doc, kind, style, lines, span }) - } - TokenKind::LineComment { is_doc } => { - comments.push(Comment { - is_doc, - kind: CommentKind::Line, - style: if code_to_the_left { - CommentStyle::Trailing - } else { - CommentStyle::Isolated - }, - lines: vec![token_text.to_string()], - span, - }); - } - _ => { - code_to_the_left = true; - } - } - pos += token.len as usize; - } - - comments -} - -impl Comments { - pub fn new(sf: &SourceFile) -> Self { - Self { comments: gather_comments(sf).into_iter() } - } - - pub fn peek(&self) -> Option<&Comment> { - self.comments.as_slice().first() - } - - #[allow(clippy::should_implement_trait)] - pub fn next(&mut self) -> Option { - self.comments.next() - } - - pub fn iter(&self) -> impl Iterator { - self.comments.as_slice().iter() - } - - pub fn trailing_comment( - &mut self, - sm: &SourceMap, - span: Span, - next_pos: Option, - ) -> Option { - if let Some(cmnt) = self.peek() { - if cmnt.style != CommentStyle::Trailing { - return None; - } - let span_line = sm.lookup_char_pos(span.hi()); - let comment_line = sm.lookup_char_pos(cmnt.pos()); - let next = next_pos.unwrap_or_else(|| cmnt.pos() + BytePos(1)); - if span.hi() < cmnt.pos() && cmnt.pos() < next && span_line.line == comment_line.line { - return Some(self.next().unwrap()); - } - } - - None - } -} diff --git a/crates/common/src/comments/inline_config.rs b/crates/common/src/comments/inline_config.rs new file mode 100644 index 0000000000000..9db7474ff90c7 --- /dev/null +++ b/crates/common/src/comments/inline_config.rs @@ -0,0 +1,572 @@ +use solar::{ + interface::{BytePos, RelativeBytePos, SourceMap, Span}, + parse::ast::{self, Visit}, +}; +use std::{ + borrow::Borrow, + collections::{HashMap, HashSet}, + hash::Hash, + ops::ControlFlow, + sync::atomic::{AtomicBool, Ordering}, +}; + +/// An inline suppression and its optional target range. +#[derive(Debug)] +struct DisabledRange { + /// Disabled range, or `None` if the directive has no target. + range: Option<(T, T)>, + /// Whether the range stems from a `disable-start`/`disable-end` block. + block: bool, + /// Span of the directive comment that created this range, used to report unused suppressions. + directive: Span, + /// Whether this range suppressed at least one diagnostic during the run. + used: AtomicBool, +} + +impl DisabledRange { + fn includes(&self, span: Span) -> bool { + self.range.is_some_and(|(lo, hi)| span.lo() >= lo && span.hi() <= hi) + } + + /// Marks the range as having suppressed a diagnostic and reports whether it includes `span`. + fn mark_if_includes(&self, span: Span) -> bool { + if self.includes(span) { + self.used.store(true, Ordering::Relaxed); + return true; + } + false + } +} + +/// An inline config item +#[derive(Clone, Debug)] +pub enum InlineConfigItem { + /// Disables the next code (AST) item regardless of newlines + DisableNextItem(I), + /// Disables formatting on the current line + DisableLine(I), + /// Disables formatting between the next newline and the newline after + DisableNextLine(I), + /// Disables formatting for any code that follows this and before the next "disable-end" + DisableStart(I), + /// Disables formatting for any code that precedes this and after the previous "disable-start" + DisableEnd(I), +} + +impl InlineConfigItem> { + /// Parse an inline config item from a string. Validates IDs against available IDs. + pub fn parse(s: &str, available_ids: &[&str]) -> Result { + let (disable, relevant) = s.split_once('(').unwrap_or((s, "")); + let mut ids = if relevant.is_empty() || relevant == "all)" { + vec!["all".to_string()] + } else { + match relevant.split_once(')') { + Some((id_str, _)) => id_str.split(',').map(|s| s.trim().to_string()).collect(), + None => return Err(InvalidInlineConfigItem::Syntax(s.into())), + } + }; + let mut seen = HashSet::new(); + ids.retain(|id| seen.insert(id.clone())); + + // Validate IDs + let mut invalid_ids = Vec::new(); + 'ids: for id in &ids { + if id == "all" { + continue; + } + for available_id in available_ids { + if *available_id == id { + continue 'ids; + } + } + invalid_ids.push(id.to_owned()); + } + + if !invalid_ids.is_empty() { + return Err(InvalidInlineConfigItem::Ids(invalid_ids)); + } + + let res = match disable { + "disable-next-item" => Self::DisableNextItem(ids), + "disable-line" => Self::DisableLine(ids), + "disable-next-line" => Self::DisableNextLine(ids), + "disable-start" => Self::DisableStart(ids), + "disable-end" => Self::DisableEnd(ids), + s => return Err(InvalidInlineConfigItem::Syntax(s.into())), + }; + + Ok(res) + } +} + +impl std::str::FromStr for InlineConfigItem<()> { + type Err = InvalidInlineConfigItem; + fn from_str(s: &str) -> Result { + Ok(match s { + "disable-next-item" => Self::DisableNextItem(()), + "disable-line" => Self::DisableLine(()), + "disable-next-line" => Self::DisableNextLine(()), + "disable-start" => Self::DisableStart(()), + "disable-end" => Self::DisableEnd(()), + s => return Err(InvalidInlineConfigItem::Syntax(s.into())), + }) + } +} + +#[derive(Debug)] +pub enum InvalidInlineConfigItem { + Syntax(String), + Ids(Vec), +} + +impl std::fmt::Display for InvalidInlineConfigItem { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Syntax(s) => write!(f, "invalid inline config item: {s}"), + Self::Ids(ids) => { + write!(f, "unknown id: '{}'", ids.join("', '")) + } + } + } +} + +/// A trait for `InlineConfigItem` types that can be iterated over to produce keys for storage. +pub trait ItemIdIterator { + type Item: Eq + Hash + Clone; + fn into_iter(self) -> impl IntoIterator; +} + +impl ItemIdIterator for () { + type Item = (); + fn into_iter(self) -> impl IntoIterator { + std::iter::once(()) + } +} + +impl ItemIdIterator for Vec { + type Item = String; + fn into_iter(self) -> impl IntoIterator { + self + } +} + +#[derive(Debug, Default)] +pub struct InlineConfig { + disabled_ranges: HashMap>, +} + +impl InlineConfig { + /// Build a new inline config with an iterator of inline config items and their locations in a + /// source file. + /// + /// # Panics + /// + /// Panics if `items` is not sorted in ascending order of [`Span`]s. + pub fn from_ast<'ast>( + items: impl IntoIterator)>, + ast: &'ast ast::SourceUnit<'ast>, + source_map: &SourceMap, + ) -> Self { + Self::build(items, source_map, |offset| NextItemFinder::new(offset).find(ast)) + } + + fn build( + items: impl IntoIterator)>, + source_map: &SourceMap, + mut find_next_item: impl FnMut(BytePos) -> Option, + ) -> Self { + let mut cfg = Self::new(); + let mut disabled_blocks = HashMap::>::new(); + + let mut prev_sp = Span::DUMMY; + for (sp, item) in items { + if cfg!(debug_assertions) { + assert!(sp >= prev_sp, "InlineConfig::new: unsorted items: {sp:?} < {prev_sp:?}"); + prev_sp = sp; + } + + cfg.disable_item(sp, item, source_map, &mut disabled_blocks, &mut find_next_item); + } + + for (id, blocks) in disabled_blocks { + for (lo, hi, directive) in blocks { + cfg.disable(id.clone(), Some((lo, hi)), true, directive); + } + } + + cfg + } + + fn new() -> Self { + Self { disabled_ranges: HashMap::new() } + } + + fn disable_many( + &mut self, + ids: I, + range: Option<(BytePos, BytePos)>, + block: bool, + directive: Span, + ) { + for id in ids.into_iter() { + self.disable(id, range, block, directive); + } + } + + fn disable( + &mut self, + id: I::Item, + range: Option<(BytePos, BytePos)>, + block: bool, + directive: Span, + ) { + self.disabled_ranges.entry(id).or_default().push(DisabledRange { + range, + block, + directive, + used: AtomicBool::new(false), + }); + } + + fn disable_item( + &mut self, + span: Span, + item: InlineConfigItem, + source_map: &SourceMap, + disabled_blocks: &mut HashMap>, + find_next_item: &mut dyn FnMut(BytePos) -> Option, + ) { + let result = source_map.span_to_source(span).unwrap(); + let file = result.file; + let comment_range = result.data; + let src = file.src.as_str(); + + #[allow(clippy::collapsible_match)] + match item { + InlineConfigItem::DisableNextItem(ids) => { + let range = find_next_item(span.hi()).map(|item| (item.lo(), item.hi())); + self.disable_many(ids, range, false, span); + } + InlineConfigItem::DisableLine(ids) => { + let start = src[..comment_range.start].rfind('\n').unwrap_or(0); + let end = src[comment_range.end..] + .find('\n') + .map_or(src.len(), |i| comment_range.end + i); + self.disable_many( + ids, + Some(( + file.absolute_position(RelativeBytePos::from_usize(start)), + file.absolute_position(RelativeBytePos::from_usize(end)), + )), + false, + span, + ); + } + InlineConfigItem::DisableNextLine(ids) => { + let range = src[comment_range.end..].find('\n').and_then(|offset| { + let next_line = comment_range.end + offset + 1; + (next_line < src.len()).then(|| { + let end = src[next_line..].find('\n').map_or(src.len(), |i| next_line + i); + ( + file.absolute_position(RelativeBytePos::from_usize( + comment_range.start, + )), + file.absolute_position(RelativeBytePos::from_usize(end)), + ) + }) + }); + self.disable_many(ids, range, false, span); + } + + InlineConfigItem::DisableStart(ids) => { + for id in ids.into_iter() { + disabled_blocks.entry(id).or_default().push(( + span.lo(), + // Use file end as fallback for unclosed blocks + file.absolute_position(RelativeBytePos::from_usize(src.len())), + span, + )); + } + } + InlineConfigItem::DisableEnd(ids) => { + for id in ids.into_iter() { + // An unmatched end closes no suppression and is ignored. + if let Some(blocks) = disabled_blocks.get_mut(&id) + && let Some((lo, _, directive)) = blocks.pop() + { + self.disable(id, Some((lo, span.hi())), true, directive); + } + } + } + } + } +} + +impl InlineConfig<()> { + /// Checks if a span is disabled (only applicable when inline config doesn't require an id). + pub fn is_disabled(&self, span: Span) -> bool { + if let Some(ranges) = self.disabled_ranges.get(&()) { + return ranges.iter().any(|range| range.includes(span)); + } + false + } + + /// Checks if a span is disabled by a `disable-start`/`disable-end` block, as opposed to a + /// line-based directive such as `disable-line`. + pub fn is_disabled_block(&self, span: Span) -> bool { + if let Some(ranges) = self.disabled_ranges.get(&()) { + return ranges.iter().any(|range| range.block && range.includes(span)); + } + false + } +} + +impl InlineConfig +where + I::Item: Borrow, +{ + /// Checks if a span is disabled for a specific id. Also checks against "all", which disables + /// all rules. + pub fn is_id_disabled(&self, span: Span, id: &str) -> bool { + let id_disabled = self.is_id_disabled_inner(span, id); + let all_disabled = id != "all" && self.is_id_disabled_inner(span, "all"); + id_disabled || all_disabled + } + + fn is_id_disabled_inner(&self, span: Span, id: &str) -> bool { + let Some(ranges) = self.disabled_ranges.get(id) else { return false }; + // Mark every matching range as used, not just the first, so overlapping suppressions are + // all credited when reporting unused ones. + let mut disabled = false; + for range in ranges { + disabled |= range.mark_if_includes(span); + } + disabled + } + + /// Returns the directive span and lint id of each suppression that never suppressed a + /// diagnostic during the run. + /// + /// Only suppressions for ids in `active` (or the catch-all `"all"`) are reported, so severity + /// filters and excluded lints do not produce false positives. Results are sorted by directive + /// location for stable output. + pub fn unused_suppressions(&self, active: &[&str]) -> Vec<(Span, String)> { + if active.is_empty() { + return Vec::new(); + } + let mut unused = Vec::new(); + for (id, ranges) in &self.disabled_ranges { + let id = id.borrow(); + if id != "all" && !active.contains(&id) { + continue; + } + for range in ranges { + if !range.used.load(Ordering::Relaxed) { + unused.push((range.directive, id.to_string())); + } + } + } + unused.sort_by(|(a, ida), (b, idb)| a.lo().cmp(&b.lo()).then_with(|| ida.cmp(idb))); + unused + } +} + +macro_rules! find_next_item { + ($self:expr, $x:expr, $span:expr, $walk:ident) => {{ + let span = $span; + // If the item is *entirely* before the offset, skip traversing it. + if span.hi() < $self.offset { + return ControlFlow::Continue(()); + } + // Check if this item starts after the offset. + if span.lo() > $self.offset { + return ControlFlow::Break(span); + } + // Otherwise, continue traversing inside this item. + $self.$walk($x) + }}; +} + +/// An AST visitor that finds the first `Item` that starts after a given offset. +#[derive(Debug)] +struct NextItemFinder { + /// The offset to search after. + offset: BytePos, +} + +impl NextItemFinder { + const fn new(offset: BytePos) -> Self { + Self { offset } + } + + /// Finds the next AST item or statement which a span that begins after the `offset`. + fn find<'ast>(&mut self, ast: &'ast ast::SourceUnit<'ast>) -> Option { + match self.visit_source_unit(ast) { + ControlFlow::Break(span) => Some(span), + ControlFlow::Continue(()) => None, + } + } +} + +impl<'ast> ast::Visit<'ast> for NextItemFinder { + type BreakValue = Span; + + fn visit_item(&mut self, item: &'ast ast::Item<'ast>) -> ControlFlow { + find_next_item!(self, item, item.span, walk_item) + } + + fn visit_stmt(&mut self, stmt: &'ast ast::Stmt<'ast>) -> ControlFlow { + find_next_item!(self, stmt, stmt.span, walk_stmt) + } + + fn visit_yul_stmt( + &mut self, + stmt: &'ast ast::yul::Stmt<'ast>, + ) -> ControlFlow { + find_next_item!(self, stmt, stmt.span, walk_yul_stmt) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + impl DisabledRange { + fn to_byte_pos(&self) -> DisabledRange { + DisabledRange:: { + range: self + .range + .map(|(lo, hi)| (BytePos::from_usize(lo), BytePos::from_usize(hi))), + block: self.block, + directive: Span::DUMMY, + used: AtomicBool::new(false), + } + } + + fn includes(&self, range: std::ops::Range) -> bool { + self.to_byte_pos().includes(Span::new( + BytePos::from_usize(range.start), + BytePos::from_usize(range.end), + )) + } + } + + #[test] + fn test_disabled_range_includes() { + let strict = DisabledRange { + range: Some((10, 20)), + block: false, + directive: Span::DUMMY, + used: AtomicBool::new(false), + }; + assert!(strict.includes(10..20)); + assert!(strict.includes(12..18)); + assert!(!strict.includes(5..15)); // Partial overlap fails + } + + #[test] + fn test_unused_suppressions_credits_all_overlapping_ranges() { + let mut config = InlineConfig::>::new(); + config.disable( + "lint1".to_string(), + Some((BytePos::from_usize(10), BytePos::from_usize(20))), + false, + Span::new(BytePos::from_usize(1), BytePos::from_usize(2)), + ); + config.disable( + "all".to_string(), + Some((BytePos::from_usize(5), BytePos::from_usize(25))), + true, + Span::new(BytePos::from_usize(3), BytePos::from_usize(4)), + ); + config.disable( + "lint1".to_string(), + Some((BytePos::from_usize(5), BytePos::from_usize(25))), + true, + Span::new(BytePos::from_usize(5), BytePos::from_usize(6)), + ); + + assert!( + config.is_id_disabled( + Span::new(BytePos::from_usize(12), BytePos::from_usize(18)), + "lint1" + ) + ); + assert!(config.unused_suppressions(&["lint1"]).is_empty()); + } + + #[test] + fn test_unused_suppressions_tracks_missing_targets_and_active_lints() { + let mut config = InlineConfig::>::new(); + let directive = Span::new(BytePos::from_usize(1), BytePos::from_usize(2)); + config.disable_many(vec!["lint1".to_string(), "all".to_string()], None, false, directive); + + assert!(config.unused_suppressions(&[]).is_empty()); + assert_eq!( + config.unused_suppressions(&["lint1"]), + vec![(directive, "all".to_string()), (directive, "lint1".to_string())] + ); + } + + #[test] + fn test_inline_config_item_from_str() { + assert!(matches!( + "disable-next-item".parse::>().unwrap(), + InlineConfigItem::DisableNextItem(()) + )); + assert!(matches!( + "disable-line".parse::>().unwrap(), + InlineConfigItem::DisableLine(()) + )); + assert!(matches!( + "disable-start".parse::>().unwrap(), + InlineConfigItem::DisableStart(()) + )); + assert!(matches!( + "disable-end".parse::>().unwrap(), + InlineConfigItem::DisableEnd(()) + )); + assert!("invalid".parse::>().is_err()); + } + + #[test] + fn test_inline_config_item_parse_with_lints() { + let lint_ids = vec!["lint1", "lint2"]; + + // No lints = "all" + match InlineConfigItem::parse("disable-line", &lint_ids).unwrap() { + InlineConfigItem::DisableLine(lints) => assert_eq!(lints, vec!["all"]), + _ => panic!("Wrong type"), + } + + // Valid single lint + match InlineConfigItem::parse("disable-start(lint1)", &lint_ids).unwrap() { + InlineConfigItem::DisableStart(lints) => assert_eq!(lints, vec!["lint1"]), + _ => panic!("Wrong type"), + } + + // Multiple lints with spaces + match InlineConfigItem::parse("disable-end(lint1, lint2)", &lint_ids).unwrap() { + InlineConfigItem::DisableEnd(lints) => assert_eq!(lints, vec!["lint1", "lint2"]), + _ => panic!("Wrong type"), + } + + // Duplicate lint IDs are normalized within a directive. + match InlineConfigItem::parse("disable-line(lint1, lint1)", &lint_ids).unwrap() { + InlineConfigItem::DisableLine(lints) => assert_eq!(lints, vec!["lint1"]), + _ => panic!("Wrong type"), + } + + // Invalid lint ID + assert!(matches!( + InlineConfigItem::parse("disable-line(unknown)", &lint_ids), + Err(InvalidInlineConfigItem::Ids(_)) + )); + + // Malformed syntax + assert!(matches!( + InlineConfigItem::parse("disable-line(lint1", &lint_ids), + Err(InvalidInlineConfigItem::Syntax(_)) + )); + } +} diff --git a/crates/common/src/comments/mod.rs b/crates/common/src/comments/mod.rs index 6ef9767e13883..a8ef50afd3267 100644 --- a/crates/common/src/comments/mod.rs +++ b/crates/common/src/comments/mod.rs @@ -1,15 +1,21 @@ -mod comment; - -use comment::{Comment, CommentStyle}; -use solar_parse::{ +use crate::iter::IterDelimited; +use solar::parse::{ ast::{CommentKind, Span}, interface::{BytePos, CharPos, SourceMap, source_map::SourceFile}, lexer::token::RawTokenKind as TokenKind, }; use std::fmt; +mod comment; +pub use comment::{Comment, CommentStyle}; + +pub mod inline_config; + +pub const DISABLE_START: &str = "forgefmt: disable-start"; +pub const DISABLE_END: &str = "forgefmt: disable-end"; + pub struct Comments { - comments: std::vec::IntoIter, + comments: std::collections::VecDeque, } impl fmt::Debug for Comments { @@ -19,177 +25,484 @@ impl fmt::Debug for Comments { } } -/// Returns `None` if the first `col` chars of `s` contain a non-whitespace char. -/// Otherwise returns `Some(k)` where `k` is first char offset after that leading -/// whitespace. Note that `k` may be outside bounds of `s`. -fn all_whitespace(s: &str, col: CharPos) -> Option { - let mut idx = 0; - for (i, ch) in s.char_indices().take(col.to_usize()) { - if !ch.is_whitespace() { - return None; +impl Comments { + pub fn new( + sf: &SourceFile, + _sm: &SourceMap, + normalize_cmnts: bool, + group_cmnts: bool, + tab_width: Option, + ) -> Self { + let gatherer = CommentGatherer::new(sf, normalize_cmnts, tab_width).gather(); + + Self { + comments: if group_cmnts { gatherer.group().into() } else { gatherer.comments.into() }, } - idx = i + ch.len_utf8(); } - Some(idx) -} -fn trim_whitespace_prefix(s: &str, col: CharPos) -> &str { - let len = s.len(); - match all_whitespace(s, col) { - Some(col) => { - if col < len { - &s[col..] - } else { - "" + pub fn peek(&self) -> Option<&Comment> { + self.comments.front() + } + + #[allow(clippy::should_implement_trait)] + pub fn next(&mut self) -> Option { + self.comments.pop_front() + } + + pub fn iter(&self) -> impl Iterator { + self.comments.iter() + } + + /// Adds a new comment at the beginning of the list. + /// + /// Should only be used when comments are gathered scattered, and must be manually sorted. + /// + /// **WARNING:** This struct works under the assumption that comments are always sorted by + /// ascending span position. It is the caller's responsibility to ensure that this premise + /// always holds true. + pub fn push_front(&mut self, cmnt: Comment) { + self.comments.push_front(cmnt) + } + + /// Finds the first trailing comment on the same line as `span_pos`, allowing for `Mixed` + /// style comments to appear before it. + /// + /// Returns the comment and its index in the buffer. + pub fn peek_trailing( + &self, + sm: &SourceMap, + span_pos: BytePos, + next_pos: Option, + ) -> Option<(&Comment, usize)> { + self.peek_trailing_with(span_pos, next_pos, |pos| { + sm.lookup_line(pos).ok().map(|line| line.line) + }) + } + + /// Finds a trailing comment as in [`Self::peek_trailing`], using a known source file. + /// + /// All comment positions and `span_pos` must belong to `file`. + pub fn peek_trailing_in_file( + &self, + file: &SourceFile, + span_pos: BytePos, + next_pos: Option, + ) -> Option<(&Comment, usize)> { + self.peek_trailing_with(span_pos, next_pos, |pos| { + file.lookup_line(file.relative_position(pos)) + }) + } + + fn peek_trailing_with( + &self, + span_pos: BytePos, + next_pos: Option, + line_at: impl Fn(BytePos) -> Option, + ) -> Option<(&Comment, usize)> { + self.comments.front()?; + let span_line = line_at(span_pos); + for (i, cmnt) in self.iter().enumerate() { + // If we have moved to the next line, we can stop. + let comment_line = line_at(cmnt.pos()); + if comment_line != span_line { + break; + } + + // The comment must start after the given span position. + if cmnt.pos() < span_pos { + continue; + } + + // The comment must be before the next element. + if cmnt.pos() >= next_pos.unwrap_or_else(|| cmnt.pos() + BytePos(1)) { + break; + } + + // Stop when we find a trailing or a non-mixed comment + match cmnt.style { + CommentStyle::Mixed => {} + CommentStyle::Trailing => return Some((cmnt, i)), + _ => break, } } - None => s, + None } } -fn split_block_comment_into_lines(text: &str, col: CharPos) -> Vec { - let mut res: Vec = vec![]; - let mut lines = text.lines(); - // just push the first line - res.extend(lines.next().map(|it| it.to_string())); - // for other lines, strip common whitespace prefix - for line in lines { - res.push(trim_whitespace_prefix(line, col).to_string()) - } - res +struct CommentGatherer<'ast> { + sf: &'ast SourceFile, + text: &'ast str, + start_bpos: BytePos, + pos: usize, + comments: Vec, + code_to_the_left: bool, + disabled_block_depth: usize, + tab_width: Option, } -/// Returns the `BytePos` of the beginning of the current line. -fn line_begin_pos(sf: &SourceFile, pos: BytePos) -> BytePos { - let pos = sf.relative_position(pos); - let line_index = sf.lookup_line(pos).unwrap(); - let line_start_pos = sf.lines()[line_index]; - sf.absolute_position(line_start_pos) -} +impl<'ast> CommentGatherer<'ast> { + fn new(sf: &'ast SourceFile, normalize_cmnts: bool, tab_width: Option) -> Self { + Self { + sf, + text: sf.src.as_str(), + start_bpos: sf.start_pos, + pos: 0, + comments: Vec::new(), + code_to_the_left: false, + disabled_block_depth: if normalize_cmnts { 0 } else { 1 }, + tab_width, + } + } -fn gather_comments(sf: &SourceFile) -> Vec { - let text = sf.src.as_str(); - let start_bpos = sf.start_pos; - let mut pos = 0; - let mut comments: Vec = Vec::new(); - let mut code_to_the_left = false; + /// Consumes the gatherer and returns the collected comments. + fn gather(mut self) -> Self { + for token in solar::parse::Cursor::new(&self.text[self.pos..]) { + self.process_token(token); + } + self + } - let make_span = |range: std::ops::Range| { - Span::new(start_bpos + range.start as u32, start_bpos + range.end as u32) - }; + /// Post-processes a list of comments to group consecutive comments. + /// + /// Necessary for properly indenting multi-line trailing comments, which would + /// otherwise be parsed as a `Trailing` followed by several `Isolated`. + fn group(self) -> Vec { + let mut processed = Vec::new(); + let mut cursor = self.comments.into_iter().peekable(); + + while let Some(mut current) = cursor.next() { + if current.kind == CommentKind::Line + && (current.style.is_trailing() || current.style.is_isolated()) + { + let mut ref_line = + self.sf.lookup_line(self.sf.relative_position(current.span.hi())).unwrap(); + while let Some(next_comment) = cursor.peek() { + if !next_comment.style.is_isolated() + || next_comment.kind != CommentKind::Line + || ref_line + 1 + != self + .sf + .lookup_line(self.sf.relative_position(next_comment.span.lo())) + .unwrap() + { + break; + } + + let next_to_merge = cursor.next().unwrap(); + current.lines.extend(next_to_merge.lines); + current.span = current.span.to(next_to_merge.span); + ref_line += 1; + } + } + + processed.push(current); + } + + processed + } - /* - if let Some(shebang_len) = strip_shebang(text) { - comments.push(Comment { - style: CommentStyle::Isolated, - lines: vec![text[..shebang_len].to_string()], - pos: start_bpos, - }); - pos += shebang_len; - } - */ - - for token in solar_parse::Cursor::new(&text[pos..]) { - let token_range = pos..pos + token.len as usize; - let span = make_span(token_range.clone()); - let token_text = &text[token_range]; + /// Creates a `Span` relative to the source file's start position. + fn make_span(&self, range: std::ops::Range) -> Span { + Span::new(self.start_bpos + range.start as u32, self.start_bpos + range.end as u32) + } + + /// Processes a single token from the source. + fn process_token(&mut self, token: solar::parse::lexer::token::RawToken) { + let token_range = self.pos..self.pos + token.len as usize; + let span = self.make_span(token_range.clone()); + let token_text = &self.text[token_range]; + + // Keep track of disabled blocks + if token_text.trim_start().contains(DISABLE_START) { + self.disabled_block_depth += 1; + } else if token_text.trim_start().contains(DISABLE_END) { + self.disabled_block_depth -= 1; + } + + #[allow(clippy::collapsible_match)] match token.kind { TokenKind::Whitespace => { if let Some(mut idx) = token_text.find('\n') { - code_to_the_left = false; + self.code_to_the_left = false; - // NOTE(dani): this used to be `while`, but we want only a single blank line. - if let Some(next_newline) = token_text[idx + 1..].find('\n') { + while let Some(next_newline) = token_text[idx + 1..].find('\n') { idx += 1 + next_newline; - let pos = pos + idx; - comments.push(Comment { + let pos = self.pos + idx; + self.comments.push(Comment { is_doc: false, kind: CommentKind::Line, style: CommentStyle::BlankLine, lines: vec![], - span: make_span(pos..pos), + span: self.make_span(pos..pos), }); + // If not disabled, early-exit as we want only a single blank line. + if self.disabled_block_depth == 0 { + break; + } } } } TokenKind::BlockComment { is_doc, .. } => { - let code_to_the_right = - !matches!(text[pos + token.len as usize..].chars().next(), Some('\r' | '\n')); - let style = match (code_to_the_left, code_to_the_right) { + let code_to_the_right = !matches!( + self.text[self.pos + token.len as usize..].chars().next(), + Some('\r' | '\n') + ); + let style = match (self.code_to_the_left, code_to_the_right) { (_, true) => CommentStyle::Mixed, (false, false) => CommentStyle::Isolated, (true, false) => CommentStyle::Trailing, }; let kind = CommentKind::Block; - // Count the number of chars since the start of the line by rescanning. - let pos_in_file = start_bpos + BytePos(pos as u32); - let line_begin_in_file = line_begin_pos(sf, pos_in_file); - let line_begin_pos = (line_begin_in_file - start_bpos).to_usize(); - let col = CharPos(text[line_begin_pos..pos].chars().count()); + // Measure the opening column, expanding tabs for non-doc comments. + let pos_in_file = self.start_bpos + BytePos(self.pos as u32); + let line_begin_in_file = line_begin_pos(self.sf, pos_in_file); + let line_begin_pos = (line_begin_in_file - self.start_bpos).to_usize(); + let tab_width = if is_doc { 1 } else { self.tab_width.unwrap_or(1) }; + let mut col = + CharPos(estimate_line_width(&self.text[line_begin_pos..self.pos], tab_width)); - let lines = split_block_comment_into_lines(token_text, col); - comments.push(Comment { is_doc, kind, style, lines, span }) + // To preserve alignment in multi-line non-doc comments, normalize the block based + // on its least-indented line. + if !is_doc && token_text.contains('\n') { + col = token_text.lines().skip(1).fold(col, |min, line| { + if line.is_empty() { + return min; + } + std::cmp::min( + CharPos(estimate_line_width( + &line[..line.len() - line.trim_start().len()], + tab_width, + )), + min, + ) + }) + }; + + let lines = self.split_block_comment_into_lines(token_text, is_doc, col); + self.comments.push(Comment { is_doc, kind, style, lines, span }) } TokenKind::LineComment { is_doc } => { - comments.push(Comment { + let line = + if self.disabled_block_depth != 0 { token_text } else { token_text.trim_end() }; + self.comments.push(Comment { is_doc, kind: CommentKind::Line, - style: if code_to_the_left { + style: if self.code_to_the_left { CommentStyle::Trailing } else { CommentStyle::Isolated }, - lines: vec![token_text.to_string()], + lines: vec![line.into()], span, }); } _ => { - code_to_the_left = true; + self.code_to_the_left = true; } } - pos += token.len as usize; + self.pos += token.len as usize; } - comments + /// Splits a block comment into lines, ensuring that each line is properly formatted. + fn split_block_comment_into_lines( + &self, + text: &str, + is_doc: bool, + col: CharPos, + ) -> Vec { + // if formatting is disabled, return as is + if self.disabled_block_depth != 0 { + return vec![text.into()]; + } + + let mut res: Vec = vec![]; + let mut lines = text.lines(); + if let Some(line) = lines.next() { + let line = line.trim_end(); + // Ensure first line of a doc comment only has the `/**` decorator + if is_doc && let Some((_, second)) = line.split_once("/**") { + res.push("/**".to_string()); + if !second.trim().is_empty() { + let line = normalize_block_comment_ws(second, col).trim_end(); + // Ensure last line of a doc comment only has the `*/` decorator + if let Some((first, _)) = line.split_once("*/") { + if !first.trim().is_empty() { + res.push(format_doc_block_comment(first.trim_end(), self.tab_width)); + } + res.push(" */".to_string()); + } else { + res.push(format_doc_block_comment(line.trim_end(), self.tab_width)); + } + } + } else { + res.push(line.to_string()); + } + } + + for (pos, line) in lines.delimited() { + let indent_end = line.len() - line.trim_start().len(); + let mut expanded = String::new(); + let line = if !is_doc + && let Some(tab_width) = self.tab_width + && line[..indent_end].contains('\t') + { + // Trim tab indentation in the same display columns used by the printer. + expanded.extend(std::iter::repeat_n( + ' ', + estimate_line_width(&line[..indent_end], tab_width), + )); + expanded.push_str(&line[indent_end..]); + expanded.as_str() + } else { + line + }; + let line = normalize_block_comment_ws(line, col).trim_end().to_string(); + if !is_doc { + res.push(line); + continue; + } + if pos.is_last { + // Ensure last line of a doc comment only has the `*/` decorator + if let Some((first, _)) = line.split_once("*/") + && !first.trim().is_empty() + { + res.push(format_doc_block_comment(first.trim_end(), self.tab_width)); + } + res.push(" */".to_string()); + } else { + res.push(format_doc_block_comment(&line, self.tab_width)); + } + } + res + } } -impl Comments { - pub fn new(sf: &SourceFile) -> Self { - Self { comments: gather_comments(sf).into_iter() } +/// Returns `None` if the first `col` chars of `s` contain a non-whitespace char. +/// Otherwise returns `Some(k)` where `k` is first char offset after that leading +/// whitespace. Note that `k` may be outside bounds of `s`. +fn all_whitespace(s: &str, col: CharPos) -> Option { + let mut idx = 0; + for (i, ch) in s.char_indices().take(col.to_usize()) { + if !ch.is_whitespace() { + return None; + } + idx = i + ch.len_utf8(); + } + Some(idx) +} + +/// Returns `Some(k)` where `k` is the byte offset of the first non-whitespace char. Returns `k = 0` +/// if `s` starts with a non-whitespace char. If `s` only contains whitespaces, returns `None`. +fn first_non_whitespace(s: &str) -> Option { + let mut len = 0; + for (i, ch) in s.char_indices() { + if ch.is_whitespace() { + len = ch.len_utf8() + } else { + return if i == 0 { Some(0) } else { Some(i + 1 - len) }; + } } + None +} - pub fn peek(&self) -> Option<&Comment> { - self.comments.as_slice().first() +/// Returns a slice of `s` with a whitespace prefix removed based on `col`. If the first `col` chars +/// of `s` are all whitespace, returns a slice starting after that prefix. +fn normalize_block_comment_ws(s: &str, col: CharPos) -> &str { + let len = s.len(); + if let Some(col) = all_whitespace(s, col) { + return if col < len { &s[col..] } else { "" }; } + if let Some(col) = first_non_whitespace(s) { + return &s[col..]; + } + s +} - #[allow(clippy::should_implement_trait)] - pub fn next(&mut self) -> Option { - self.comments.next() +/// Formats a doc block comment line so that they have the ` *` decorator. +fn format_doc_block_comment(line: &str, tab_width: Option) -> String { + if line.is_empty() { + return (" *").to_string(); } - pub fn iter(&self) -> impl Iterator { - self.comments.as_slice().iter() + if let Some((_, rest_of_line)) = line.split_once('*') { + if rest_of_line.is_empty() { + (" *").to_string() + } else if let Some(tab_width) = tab_width { + let mut normalized = String::from(" *"); + line_with_tabs( + &mut normalized, + rest_of_line, + tab_width, + Some(Consolidation::MinOneTab), + ); + normalized + } else { + format!(" *{rest_of_line}",) + } + } else if let Some(tab_width) = tab_width { + let mut normalized = String::from(" *\t"); + line_with_tabs(&mut normalized, line, tab_width, Some(Consolidation::WithoutSpaces)); + normalized + } else { + format!(" * {line}") } +} - pub fn trailing_comment( - &mut self, - sm: &SourceMap, - span: Span, - next_pos: Option, - ) -> Option { - if let Some(cmnt) = self.peek() { - if cmnt.style != CommentStyle::Trailing { - return None; - } - let span_line = sm.lookup_char_pos(span.hi()); - let comment_line = sm.lookup_char_pos(cmnt.pos()); - let next = next_pos.unwrap_or_else(|| cmnt.pos() + BytePos(1)); - if span.hi() < cmnt.pos() && cmnt.pos() < next && span_line.line == comment_line.line { - return Some(self.next().unwrap()); +pub enum Consolidation { + MinOneTab, + WithoutSpaces, +} + +/// Normalizes the leading whitespace of a string slice according to a given tab width. +/// +/// It aggregates and converts leading whitespace (spaces and tabs) into a representation that +/// maximizes the amount of tabs. +pub fn line_with_tabs( + output: &mut String, + line: &str, + tab_width: usize, + strategy: Option, +) { + // Find the end of the leading whitespace (any sequence of spaces and tabs) + let first_non_ws = line.find(|c| c != ' ' && c != '\t').unwrap_or(line.len()); + let (leading_ws, rest_of_line) = line.split_at(first_non_ws); + + // Compute its equivalent length and derive the required amount of tabs and spaces + let total_width = + leading_ws.chars().fold(0, |width, c| width + if c == ' ' { 1 } else { tab_width }); + let (mut num_tabs, mut num_spaces) = (total_width / tab_width, total_width % tab_width); + + // Adjust based on the desired config + match strategy { + Some(Consolidation::MinOneTab) => { + if num_tabs == 0 && num_spaces != 0 { + (num_tabs, num_spaces) = (1, 0); + } else if num_spaces != 0 { + (num_tabs, num_spaces) = (num_tabs + 1, 0); } } + Some(Consolidation::WithoutSpaces) if num_spaces != 0 => { + (num_tabs, num_spaces) = (num_tabs + 1, 0); + } + _ => (), + }; - None - } + // Append the normalized indentation and the rest of the line to the output + output.extend(std::iter::repeat_n('\t', num_tabs)); + output.extend(std::iter::repeat_n(' ', num_spaces)); + output.push_str(rest_of_line); +} + +/// Estimates the display width of a string, accounting for tabs. +pub fn estimate_line_width(line: &str, tab_width: usize) -> usize { + line.chars().fold(0, |width, c| width + if c == '\t' { tab_width } else { 1 }) +} + +/// Returns the `BytePos` of the beginning of the current line. +fn line_begin_pos(sf: &SourceFile, pos: BytePos) -> BytePos { + let pos = sf.relative_position(pos); + let line_index = sf.lookup_line(pos).unwrap(); + let line_start_pos = sf.lines()[line_index]; + sf.absolute_position(line_start_pos) } diff --git a/crates/common/src/compile.rs b/crates/common/src/compile.rs index ed98038fa146e..3497a7f4d0640 100644 --- a/crates/common/src/compile.rs +++ b/crates/common/src/compile.rs @@ -2,36 +2,56 @@ use crate::{ TestFunctionExt, - preprocessor::TestOptimizerPreprocessor, - reports::{ReportKind, report_kind}, + external_compiler::{ExternalCompilation, is_builtin_compiler_source}, + fs::canonicalize_path, + preprocessor::DynamicTestLinkingPreprocessor, shell, term::SpinnerReporter, }; -use comfy_table::{Cell, Color, Table, modifiers::UTF8_ROUND_CORNERS}; -use eyre::Result; +use alloy_json_abi::JsonAbi; +use comfy_table::{ + Cell, Color, Table, + presets::{ASCII_FULL, ASCII_MARKDOWN}, +}; +use eyre::{OptionExt, Result}; use foundry_block_explorers::contract::Metadata; use foundry_compilers::{ Artifact, Project, ProjectBuilder, ProjectCompileOutput, ProjectPathsConfig, SolcConfig, - artifacts::{BytecodeObject, Contract, Source, remappings::Remapping}, + artifacts::{ + BytecodeObject, Contract, Severity, Source, output_selection::OutputSelection, + remappings::Remapping, + }, compilers::{ - Compiler, + CompilationError, Compiler, solc::{Solc, SolcCompiler}, }, info::ContractInfo as CompilerContractInfo, + multi::{MultiCompiler, MultiCompilerSettings}, project::Preprocessor, report::{BasicStdoutReporter, NoReporter, Report}, solc::SolcSettings, }; +use foundry_config::Config; use num_format::{Locale, ToFormattedString}; +use revm::primitives::{eip170, eip3860, hardfork::SpecId}; +use solar::{ + ast::{Arena, ContractKind, ItemKind}, + interface::{Session, source_map::FileName}, + parse::Parser, +}; use std::{ - collections::BTreeMap, + collections::{BTreeMap, BTreeSet, HashMap, HashSet}, fmt::Display, io::IsTerminal, - path::{Path, PathBuf}, + path::{Component, Path, PathBuf}, str::FromStr, + sync::Arc, time::Instant, }; +/// A Solar compiler instance, to grant syntactic and semantic analysis capabilities. +pub type Analysis = Arc; + /// Builder type to configure how to compile a project. /// /// This is merely a wrapper for [`Project::compile()`] which also prints to stdout depending on its @@ -41,9 +61,6 @@ pub struct ProjectCompiler { /// The root of the project. project_root: PathBuf, - /// Whether we are going to verify the contracts after compilation. - verify: Option, - /// Whether to also print contract names. print_names: Option, @@ -53,17 +70,39 @@ pub struct ProjectCompiler { /// Whether to print anything at all. Overrides other `print` options. quiet: Option, + /// Whether to print the resolved settings for each compiler invocation. + print_compiler_settings: bool, + /// Whether to bail on compiler errors. bail: Option, /// Whether to ignore the contract initcode size limit introduced by EIP-3860. ignore_eip_3860: bool, - /// Extra files to include, that are not necessarily in the project's source dir. + /// Contract size limits used when reporting compiled contract sizes. + size_limits: ContractSizeLimits, + + /// Extra files to include, that are not necessarily in the project's source directory. files: Vec, + /// Complete source roots to retry if filtering changed Solidity's inheritance ordering. + source_order_fallback: Vec, + + /// Paths used by external adapters to select compiler-native build units. + selected_paths: Vec, + /// Whether to compile with dynamic linking tests and scripts. dynamic_test_linking: bool, + + /// Whether ABI acquisition may consult the compiler-owned ABI cache. + abi_cache: bool, + + /// External compiler configuration. + external_compilers: Option, + + /// Preserves the caller's artifact policy when ABI caching enables artifacts on a cloned + /// project. + external_writes: bool, } impl Default for ProjectCompiler { @@ -79,34 +118,33 @@ impl ProjectCompiler { pub fn new() -> Self { Self { project_root: PathBuf::new(), - verify: None, print_names: None, print_sizes: None, quiet: Some(crate::shell::is_quiet()), + print_compiler_settings: false, bail: None, ignore_eip_3860: false, + size_limits: ContractSizeLimits::default(), files: Vec::new(), + source_order_fallback: Vec::new(), + selected_paths: Vec::new(), dynamic_test_linking: false, + abi_cache: false, + external_compilers: None, + external_writes: true, } } - /// Sets whether we are going to verify the contracts after compilation. - #[inline] - pub fn verify(mut self, yes: bool) -> Self { - self.verify = Some(yes); - self - } - /// Sets whether to print contract names. #[inline] - pub fn print_names(mut self, yes: bool) -> Self { + pub const fn print_names(mut self, yes: bool) -> Self { self.print_names = Some(yes); self } /// Sets whether to print contract sizes. #[inline] - pub fn print_sizes(mut self, yes: bool) -> Self { + pub const fn print_sizes(mut self, yes: bool) -> Self { self.print_sizes = Some(yes); self } @@ -114,25 +152,39 @@ impl ProjectCompiler { /// Sets whether to print anything at all. Overrides other `print` options. #[inline] #[doc(alias = "silent")] - pub fn quiet(mut self, yes: bool) -> Self { + pub const fn quiet(mut self, yes: bool) -> Self { self.quiet = Some(yes); self } + /// Sets whether to print the resolved settings for each compiler invocation. + #[inline] + pub const fn print_compiler_settings(mut self, yes: bool) -> Self { + self.print_compiler_settings = yes; + self + } + /// Sets whether to bail on compiler errors. #[inline] - pub fn bail(mut self, yes: bool) -> Self { + pub const fn bail(mut self, yes: bool) -> Self { self.bail = Some(yes); self } /// Sets whether to ignore EIP-3860 initcode size limits. #[inline] - pub fn ignore_eip_3860(mut self, yes: bool) -> Self { + pub const fn ignore_eip_3860(mut self, yes: bool) -> Self { self.ignore_eip_3860 = yes; self } + /// Sets the contract size limits for size reports. + #[inline] + pub const fn size_limits(mut self, limits: ContractSizeLimits) -> Self { + self.size_limits = limits; + self + } + /// Sets extra files to include, that are not necessarily in the project's source dir. #[inline] pub fn files(mut self, files: impl IntoIterator) -> Self { @@ -140,19 +192,66 @@ impl ProjectCompiler { self } + /// Sets paths used by external adapters to select compiler-native build units. + pub fn selected_paths(mut self, paths: impl IntoIterator) -> Self { + self.selected_paths.extend(paths); + self + } + + /// Selects targets for external adapters and built-in sources where applicable. + pub fn target_files(mut self, paths: impl IntoIterator) -> Self { + for path in paths { + if is_builtin_compiler_source(&path) { + self.files.push(path.clone()); + } + self.selected_paths.push(path); + } + self + } + /// Sets if tests should be dynamically linked. #[inline] - pub fn dynamic_test_linking(mut self, preprocess: bool) -> Self { + pub const fn dynamic_test_linking(mut self, preprocess: bool) -> Self { self.dynamic_test_linking = preprocess; self } + + /// Enables explicitly configured external compilers for this build. + pub fn external_compilers(mut self, config: &Config) -> Self { + self.external_compilers = Some(config.clone()); + self + } + + /// Controls whether external artifacts and their cache may be published. + pub const fn external_artifacts(mut self, write: bool) -> Self { + self.external_writes = write; + self + } + + /// Reuses normal artifacts before consulting the compiler-owned ABI cache. + /// + /// The project must request ABI output and may include other contract outputs. + /// The project's artifact policy controls writes to the secondary cache. + pub const fn cache_abi(mut self) -> Self { + self.abi_cache = true; + self + } + + /// Retries once with these roots if compilation fails because a base contract is defined + /// after its derived contract. Removing roots can change traversal order in import cycles. + pub fn source_order_fallback(mut self, files: impl IntoIterator) -> Self { + self.source_order_fallback.extend(files); + self + } + /// Compiles the project. + #[instrument(target = "forge::compile", skip_all)] pub fn compile>( mut self, project: &Project, ) -> Result> where - TestOptimizerPreprocessor: Preprocessor, + DynamicTestLinkingPreprocessor: Preprocessor, { self.project_root = project.root().to_path_buf(); @@ -162,41 +261,66 @@ impl ProjectCompiler { // breaks compatibility with downstream crates like `foundry-cli`. This would need a // broader refactor across the call chain. Leaving it as-is for now until a larger // refactor is feasible. - if !project.paths.has_input_files() && self.files.is_empty() { + if !project.paths.has_input_files() + && self.files.is_empty() + && self.selected_paths.is_empty() + && self + .external_compilers + .as_ref() + .is_none_or(|config| config.external_compilers.is_empty()) + { sh_println!("Nothing to compile")?; std::process::exit(0); } // Taking is fine since we don't need these in `compile_with`. let files = std::mem::take(&mut self.files); + let fallback_files = std::mem::take(&mut self.source_order_fallback); + let explicit_selection = !files.is_empty() || !self.selected_paths.is_empty(); + let selected_paths = std::mem::take(&mut self.selected_paths); let preprocess = self.dynamic_test_linking; + let abi_cache = self.abi_cache; + let external_compilers = self.external_compilers.take(); + let external_writes = self.external_writes && !project.no_artifacts; self.compile_with(|| { - let sources = if !files.is_empty() { + let external = external_compilers + .as_ref() + .map(|config| { + ExternalCompilation::compile(config, &selected_paths, external_writes) + }) + .transpose()?; + let sources = if explicit_selection { Source::read_all(files)? } else { project.paths.read_input_files()? }; - let mut compiler = - foundry_compilers::project::ProjectCompiler::with_sources(project, sources)?; - if preprocess { - compiler = compiler.with_preprocessor(TestOptimizerPreprocessor); + let compile = |sources| { + let mut compiler = + foundry_compilers::project::ProjectCompiler::with_sources(project, sources)?; + if preprocess { + compiler = compiler.with_preprocessor(DynamicTestLinkingPreprocessor); + } + if abi_cache { compiler.compile_abi_cached() } else { compiler.compile() } + }; + let mut output = compile(sources)?; + // Solc error 2449 can be introduced by filtering compilation roots in an import + // cycle. Retry before reporting diagnostics or bailing on the filtered output. + if !fallback_files.is_empty() + && output.output().errors.iter().any(|error| error.error_code() == Some(2449)) + { + output = compile(Source::read_all(fallback_files)?)?; + } + if !output.has_compiler_errors() + && let Some(external) = external + { + external.merge(&mut output)?; } - compiler.compile().map_err(Into::into) + Ok(output) }) } /// Compiles the project with the given closure - /// - /// # Example - /// - /// ```ignore - /// use foundry_common::compile::ProjectCompiler; - /// let config = foundry_config::Config::load().unwrap(); - /// let prj = config.project().unwrap(); - /// ProjectCompiler::new().compile_with(|| Ok(prj.compile()?)).unwrap(); - /// ``` - #[instrument(target = "forge::compile", skip_all)] fn compile_with, F>( self, f: F, @@ -207,43 +331,50 @@ impl ProjectCompiler { let quiet = self.quiet.unwrap_or(false); let bail = self.bail.unwrap_or(true); - let output = with_compilation_reporter(quiet, || { - tracing::debug!("compiling project"); + let output = with_compilation_reporter_and_settings( + quiet, + Some(self.project_root.clone()), + self.print_compiler_settings, + || { + tracing::debug!("compiling project"); - let timer = Instant::now(); - let r = f(); - let elapsed = timer.elapsed(); + let timer = Instant::now(); + let r = f(); + let elapsed = timer.elapsed(); - tracing::debug!("finished compiling in {:.3}s", elapsed.as_secs_f64()); - r - })?; + tracing::debug!("finished compiling in {:.3}s", elapsed.as_secs_f64()); + r + }, + )?; if bail && output.has_compiler_errors() { - eyre::bail!("{output}") + eyre::bail!("{output}"); } - if !quiet { - if !shell::is_json() { - if output.is_unchanged() { - sh_println!("No files changed, compilation skipped")?; - } else { - // print the compiler output / warnings - sh_println!("{output}")?; - } + if !quiet && !shell::is_json() { + if output.is_unchanged() { + sh_println!("No files changed, compilation skipped")?; + } else { + // print the compiler output / warnings + sh_println!("{output}")?; } + } + // Quiet mode suppresses reports, but size limits still apply. + if !(shell::is_json() && output.has_compiler_errors()) { self.handle_output(&output)?; } Ok(output) } - /// If configured, this will print sizes or names + /// Prints requested reports and checks contract size limits. fn handle_output>( &self, output: &ProjectCompileOutput, ) -> Result<()> { - let print_names = self.print_names.unwrap_or(false); + let quiet = self.quiet.unwrap_or(false); + let print_names = self.print_names.unwrap_or(false) && !quiet; let print_sizes = self.print_sizes.unwrap_or(false); // print any sizes or names @@ -277,33 +408,61 @@ impl ProjectCompiler { } let mut size_report = - SizeReport { report_kind: report_kind(), contracts: BTreeMap::new() }; + SizeReport { contracts: BTreeMap::new(), limits: self.size_limits }; let mut artifacts: BTreeMap> = BTreeMap::new(); for (id, artifact) in output.artifact_ids().filter(|(id, _)| { // filter out forge-std specific contracts !id.source.to_string_lossy().contains("/forge-std/src/") }) { - artifacts.entry(id.name.clone()).or_default().push((id.source.clone(), artifact)); + artifacts.entry(id.name.clone()).or_default().push((id.source, artifact)); } + // Internal libraries are inlined into consumers and never deployed; skip them. + // Only artifacts whose ABI has no functions can be internal libraries, so restrict the + // solar parse to those sources to avoid a second full parse pass. + let abs_source = |path: &Path| -> PathBuf { + if path.is_absolute() { path.to_path_buf() } else { self.project_root.join(path) } + }; + let source_paths = artifacts + .values() + .flatten() + .filter(|(_, artifact)| { + artifact.abi.as_ref().is_some_and(|abi| abi.functions().next().is_none()) + }) + .map(|(path, _)| abs_source(path)) + .collect::>(); + let libraries = collect_libraries(&source_paths); + for (name, artifact_list) in artifacts { - for (path, artifact) in &artifact_list { + // A library with no functions in its ABI is internal-only; fail open if the ABI is + // missing. Filter first so the duplicate-name suffix below reflects kept contracts. + let kept = artifact_list + .iter() + .filter(|(path, artifact)| { + let is_library = libraries + .get(&abs_source(path)) + .is_some_and(|libs| libs.contains(&name)); + let has_no_abi_functions = artifact + .abi + .as_ref() + .is_some_and(|abi| abi.functions().next().is_none()); + !(is_library && has_no_abi_functions) + }) + .collect::>(); + + for (path, artifact) in &kept { let runtime_size = contract_size(*artifact, false).unwrap_or_default(); let init_size = contract_size(*artifact, true).unwrap_or_default(); - let is_dev_contract = artifact - .abi - .as_ref() - .map(|abi| { - abi.functions().any(|f| { - f.test_function_kind().is_known() - || matches!(f.name.as_str(), "IS_TEST" | "IS_SCRIPT") - }) + let is_dev_contract = artifact.abi.as_ref().is_some_and(|abi| { + abi.functions().any(|f| { + f.test_function_kind().is_known() + || matches!(f.name.as_str(), "IS_TEST" | "IS_SCRIPT") }) - .unwrap_or(false); + }); - let unique_name = if artifact_list.len() > 1 { + let unique_name = if kept.len() > 1 { format!( "{} ({})", name, @@ -320,18 +479,30 @@ impl ProjectCompiler { } } - sh_println!("{size_report}")?; + if !quiet { + sh_println!("{size_report}")?; + } + let runtime_eip = match size_report.limits.runtime { + CONTRACT_RUNTIME_SIZE_LIMIT => "EIP-170: ", + AMSTERDAM_CONTRACT_RUNTIME_SIZE_LIMIT => "EIP-7954: ", + _ => "", + }; eyre::ensure!( !size_report.exceeds_runtime_size_limit(), - "some contracts exceed the runtime size limit \ - (EIP-170: {CONTRACT_RUNTIME_SIZE_LIMIT} bytes)" + "some contracts exceed the runtime size limit ({runtime_eip}{} bytes)", + size_report.limits.runtime ); // Check size limits only if not ignoring EIP-3860 + let initcode_eip = match size_report.limits.initcode { + CONTRACT_INITCODE_SIZE_LIMIT => "EIP-3860: ", + AMSTERDAM_CONTRACT_INITCODE_SIZE_LIMIT => "EIP-7954: ", + _ => "", + }; eyre::ensure!( self.ignore_eip_3860 || !size_report.exceeds_initcode_size_limit(), - "some contracts exceed the initcode size limit \ - (EIP-3860: {CONTRACT_INITCODE_SIZE_LIMIT} bytes)" + "some contracts exceed the initcode size limit ({initcode_eip}{} bytes)", + size_report.limits.initcode ); } @@ -340,17 +511,80 @@ impl ProjectCompiler { } // https://eips.ethereum.org/EIPS/eip-170 -const CONTRACT_RUNTIME_SIZE_LIMIT: usize = 24576; +const CONTRACT_RUNTIME_SIZE_LIMIT: usize = eip170::MAX_CODE_SIZE; // https://eips.ethereum.org/EIPS/eip-3860 -const CONTRACT_INITCODE_SIZE_LIMIT: usize = 49152; +const CONTRACT_INITCODE_SIZE_LIMIT: usize = eip3860::MAX_INITCODE_SIZE; + +// https://eips.ethereum.org/EIPS/eip-7954 +const AMSTERDAM_CONTRACT_RUNTIME_SIZE_LIMIT: usize = 65_536; +const AMSTERDAM_CONTRACT_INITCODE_SIZE_LIMIT: usize = 131_072; + +const CONTRACT_RUNTIME_SIZE_WARN_THRESHOLD: usize = 18_000; +const CONTRACT_INITCODE_SIZE_WARN_THRESHOLD: usize = 36_000; + +/// Runtime and initcode byte-size limits for compiled contract size reports. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ContractSizeLimits { + /// Maximum deployed runtime bytecode size. + pub runtime: usize, + /// Maximum initcode bytecode size. + pub initcode: usize, +} + +impl ContractSizeLimits { + /// Creates a new set of contract size limits. + pub const fn new(runtime: usize, initcode: usize) -> Self { + Self { runtime, initcode } + } + + /// Creates limits from a runtime code-size limit, using the EIP-3860 2x initcode ratio. + pub const fn with_runtime_limit(runtime: usize) -> Self { + Self { runtime, initcode: runtime.saturating_mul(2) } + } + + /// Returns the protocol limits active for an EVM specification. + pub const fn for_spec_id(spec_id: SpecId) -> Self { + if spec_id.is_enabled_in(SpecId::AMSTERDAM) { + Self::new(AMSTERDAM_CONTRACT_RUNTIME_SIZE_LIMIT, AMSTERDAM_CONTRACT_INITCODE_SIZE_LIMIT) + } else { + Self::new(CONTRACT_RUNTIME_SIZE_LIMIT, CONTRACT_INITCODE_SIZE_LIMIT) + } + } + + const fn runtime_warning_threshold(self) -> usize { + scaled_threshold( + self.runtime, + CONTRACT_RUNTIME_SIZE_WARN_THRESHOLD, + CONTRACT_RUNTIME_SIZE_LIMIT, + ) + } + + const fn initcode_warning_threshold(self) -> usize { + scaled_threshold( + self.initcode, + CONTRACT_INITCODE_SIZE_WARN_THRESHOLD, + CONTRACT_INITCODE_SIZE_LIMIT, + ) + } +} + +impl Default for ContractSizeLimits { + fn default() -> Self { + Self::new(CONTRACT_RUNTIME_SIZE_LIMIT, CONTRACT_INITCODE_SIZE_LIMIT) + } +} + +const fn scaled_threshold(limit: usize, threshold: usize, default_limit: usize) -> usize { + limit.saturating_mul(threshold) / default_limit +} /// Contracts with info about their size pub struct SizeReport { - /// What kind of report to generate. - report_kind: ReportKind, /// `contract name -> info` pub contracts: BTreeMap, + /// Size limits used to calculate margins and failures. + pub limits: ContractSizeLimits, } impl SizeReport { @@ -376,26 +610,22 @@ impl SizeReport { /// Returns true if any contract exceeds the runtime size limit, excluding dev contracts. pub fn exceeds_runtime_size_limit(&self) -> bool { - self.max_runtime_size() > CONTRACT_RUNTIME_SIZE_LIMIT + self.max_runtime_size() > self.limits.runtime } /// Returns true if any contract exceeds the initcode size limit, excluding dev contracts. pub fn exceeds_initcode_size_limit(&self) -> bool { - self.max_init_size() > CONTRACT_INITCODE_SIZE_LIMIT + self.max_init_size() > self.limits.initcode } } impl Display for SizeReport { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { - match self.report_kind { - ReportKind::Text => { - writeln!(f, "\n{}", self.format_table_output())?; - } - ReportKind::JSON => { - writeln!(f, "{}", self.format_json_output())?; - } + if shell::is_json() { + writeln!(f, "{}", self.format_json_output())?; + } else { + writeln!(f, "\n{}", self.format_table_output())?; } - Ok(()) } } @@ -412,8 +642,8 @@ impl SizeReport { serde_json::json!({ "runtime_size": contract.runtime_size, "init_size": contract.init_size, - "runtime_margin": CONTRACT_RUNTIME_SIZE_LIMIT as isize - contract.runtime_size as isize, - "init_margin": CONTRACT_INITCODE_SIZE_LIMIT as isize - contract.init_size as isize, + "runtime_margin": self.limits.runtime as isize - contract.runtime_size as isize, + "init_margin": self.limits.initcode as isize - contract.init_size as isize, }), ) }) @@ -424,7 +654,11 @@ impl SizeReport { fn format_table_output(&self) -> Table { let mut table = Table::new(); - table.apply_modifier(UTF8_ROUND_CORNERS); + if shell::is_markdown() { + table.load_style(ASCII_MARKDOWN); + } else { + table.load_style(ASCII_FULL.with_rounded_corners()); + } table.set_header(vec![ Cell::new("Contract"), @@ -439,21 +673,26 @@ impl SizeReport { .contracts .iter() .filter(|(_, c)| !c.is_dev_contract && (c.runtime_size > 0 || c.init_size > 0)); + let runtime_warning_threshold = self.limits.runtime_warning_threshold(); + let initcode_warning_threshold = self.limits.initcode_warning_threshold(); for (name, contract) in contracts { - let runtime_margin = - CONTRACT_RUNTIME_SIZE_LIMIT as isize - contract.runtime_size as isize; - let init_margin = CONTRACT_INITCODE_SIZE_LIMIT as isize - contract.init_size as isize; - - let runtime_color = match contract.runtime_size { - ..18_000 => Color::Reset, - 18_000..=CONTRACT_RUNTIME_SIZE_LIMIT => Color::Yellow, - _ => Color::Red, + let runtime_margin = self.limits.runtime as isize - contract.runtime_size as isize; + let init_margin = self.limits.initcode as isize - contract.init_size as isize; + + let runtime_color = if contract.runtime_size < runtime_warning_threshold { + Color::Reset + } else if contract.runtime_size <= self.limits.runtime { + Color::Yellow + } else { + Color::Red }; - let init_color = match contract.init_size { - ..36_000 => Color::Reset, - 36_000..=CONTRACT_INITCODE_SIZE_LIMIT => Color::Yellow, - _ => Color::Red, + let init_color = if contract.init_size < initcode_warning_threshold { + Color::Reset + } else if contract.init_size <= self.limits.initcode { + Color::Yellow + } else { + Color::Red }; let locale = &Locale::en; @@ -470,6 +709,44 @@ impl SizeReport { } } +/// Parses each source file with solar and returns the library names declared in it. +/// +/// Files that fail to parse are skipped, so a missing entry means "unknown", not "no libraries". +fn collect_libraries(sources: &BTreeSet) -> HashMap> { + let mut result: HashMap> = HashMap::new(); + let sess = Session::builder().with_silent_emitter(None).build(); + let _ = sess.enter(|| -> solar::interface::Result<()> { + for path in sources { + let arena = Arena::new(); + let mut parser = match Parser::from_lazy_source_code( + &sess, + &arena, + FileName::from(path.clone()), + || std::fs::read_to_string(path), + ) { + Ok(parser) => parser, + Err(_) => continue, + }; + let Ok(ast) = parser.parse_file() else { continue }; + let libs = ast + .items + .iter() + .filter_map(|item| match &item.kind { + ItemKind::Contract(c) if c.kind == ContractKind::Library => { + Some(c.name.as_str().to_string()) + } + _ => None, + }) + .collect::>(); + if !libs.is_empty() { + result.insert(path.clone(), libs); + } + } + Ok(()) + }); + result +} + /// Returns the deployed or init size of the contract. fn contract_size(artifact: &T, initcode: bool) -> Option { let bytecode = if initcode { @@ -508,9 +785,8 @@ pub struct ContractInfo { /// Compiles target file path. /// -/// If `quiet` no solc related output will be emitted to stdout. -/// -/// If `verify` and it's a standalone script, throw error. Only allowed for projects. +/// If `quiet` is set, the compilation reporter's progress/status output is suppressed. +/// (When not suppressed, that output is emitted to stderr; see `with_compilation_reporter`.) /// /// **Note:** this expects the `target_path` to be absolute pub fn compile_target>( @@ -519,25 +795,86 @@ pub fn compile_target>( quiet: bool, ) -> Result> where - TestOptimizerPreprocessor: Preprocessor, + DynamicTestLinkingPreprocessor: Preprocessor, { ProjectCompiler::new().quiet(quiet).files([target_path.into()]).compile(project) } +/// Compiles the project requesting only ABI output. +pub fn compile_abi_project>( + project: &mut Project, + mut compiler: ProjectCompiler, +) -> Result> +where + DynamicTestLinkingPreprocessor: Preprocessor, +{ + project.update_output_selection(|selection| { + // Request ABI so compilers populate `contracts` without producing bytecode outputs. + *selection = OutputSelection::common_output_selection(["abi".to_string()]); + }); + // Cached ABI artifacts do not retain compiler diagnostics. + compiler.abi_cache = (compiler.abi_cache || project.no_artifacts) + && project.compiler_severity_filter == Severity::Error; + compiler.compile(project) +} + +/// Acquires ABI output with compiler-owned persistence separate from normal artifacts. +/// +/// Requests for additional files or full build info retain their existing output behavior. +pub fn compile_abi_project_cached>( + project: &mut Project, + mut compiler: ProjectCompiler, +) -> Result> +where + DynamicTestLinkingPreprocessor: Preprocessor, +{ + if !project.cached + || project.build_info + || project.artifacts.additional_files != Default::default() + || project.compiler_severity_filter != Severity::Error + { + return compile_abi_project(project, compiler); + } + let mut cached_project = project.clone(); + cached_project.no_artifacts = false; + compiler.abi_cache = true; + compiler.external_writes &= !project.no_artifacts; + compile_abi_project(&mut cached_project, compiler) +} + +/// Compiles the target contract requesting only ABI output and returns its ABI. +pub fn compile_target_abi( + project: &mut Project, + target_path: &Path, + target_name: &str, +) -> Result { + let target_path = canonicalize_path(target_path)?; + let output = compile_abi_project( + project, + ProjectCompiler::new().quiet(true).files([target_path.clone()]), + )?; + + let artifact = output + .find(&target_path, target_name) + .ok_or_eyre("failed to find target artifact when compiling for abi")?; + artifact.abi.clone().ok_or_eyre("target artifact does not have an ABI") +} + /// Creates a [Project] from an Etherscan source. -pub fn etherscan_project( - metadata: &Metadata, - target_path: impl AsRef, -) -> Result> { - let target_path = dunce::canonicalize(target_path.as_ref())?; +pub fn etherscan_project(metadata: &Metadata, target_path: &Path) -> Result { + let target_path = canonicalize_path(target_path)?; let sources_path = target_path.join(&metadata.contract_name); metadata.source_tree().write_to(&target_path)?; let mut settings = metadata.settings()?; // make remappings absolute with our root + // + // The remappings come from the explorer's copy of the contract's compiler settings, which is + // attacker controlled: a target of `../../..` would otherwise point the compiler at sources + // outside the checkout and let a verified contract pull arbitrary local files into the build. for remapping in &mut settings.remappings { - let new_path = sources_path.join(remapping.path.trim_start_matches('/')); + let new_path = sources_path.join(sanitize_relative_path(remapping.path.as_ref())); remapping.path = new_path.display().to_string(); } @@ -559,14 +896,18 @@ pub fn etherscan_project( .remappings(settings.remappings.clone()) .build_with_root(sources_path); + // TODO: detect vyper let v = metadata.compiler_version()?; let solc = Solc::find_or_install(&v)?; - let compiler = SolcCompiler::Specific(solc); + let compiler = MultiCompiler { solc: Some(SolcCompiler::Specific(solc)), vyper: None }; - Ok(ProjectBuilder::::default() - .settings(SolcSettings { - settings: SolcConfig::builder().settings(settings).build(), + Ok(ProjectBuilder::::default() + .settings(MultiCompilerSettings { + solc: SolcSettings { + settings: SolcConfig::builder().settings(settings).build(), + ..Default::default() + }, ..Default::default() }) .paths(paths) @@ -575,16 +916,69 @@ pub fn etherscan_project( .build(compiler)?) } +/// Normalizes an untrusted path relative to the source root, discarding leading separators, +/// drive prefixes, and parent components that would escape that root. +fn sanitize_relative_path(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::Normal(part) => normalized.push(part), + Component::ParentDir => { + normalized.pop(); + } + _ => {} + } + } + normalized +} + +/// Adds `storageLayout` to the compiler output selection for the given project. +pub fn add_storage_layout_output>( + project: &mut Project, +) { + project.artifacts.additional_values.storage_layout = true; + project.update_output_selection(|selection| { + for contract_selection in selection.0.values_mut() { + for selection in contract_selection.values_mut() { + selection.push("storageLayout".to_string()); + } + } + }) +} + /// Configures the reporter and runs the given closure. -pub fn with_compilation_reporter(quiet: bool, f: impl FnOnce() -> O) -> O { +/// +/// In TTY mode, [`SpinnerReporter`] paints the progress to stderr. The non-TTY fallback +/// still writes to stdout via `BasicStdoutReporter`; migrating that path to stderr is +/// part of the per-command stdout migration tracked in `docs/dev/output-channels.md` +/// (it would shift many existing snapshot tests at once). +pub fn with_compilation_reporter( + quiet: bool, + project_root: Option, + f: impl FnOnce() -> O, +) -> O { + with_compilation_reporter_and_settings(quiet, project_root, false, f) +} + +fn with_compilation_reporter_and_settings( + quiet: bool, + project_root: Option, + print_compiler_settings: bool, + f: impl FnOnce() -> O, +) -> O { #[expect(clippy::collapsible_else_if)] let reporter = if quiet || shell::is_json() { Report::new(NoReporter::default()) } else { - if std::io::stdout().is_terminal() { - Report::new(SpinnerReporter::spawn()) + if std::io::stderr().is_terminal() { + Report::new( + SpinnerReporter::spawn(project_root) + .with_compiler_settings(print_compiler_settings), + ) } else { - Report::new(BasicStdoutReporter::default()) + Report::new( + BasicStdoutReporter::default().with_compiler_settings(print_compiler_settings), + ) } }; @@ -599,7 +993,7 @@ pub fn with_compilation_reporter(quiet: bool, f: impl FnOnce() -> O) -> O { /// - `Counter` - contract name only #[derive(Clone, PartialEq, Eq)] pub enum PathOrContractInfo { - /// Non-canoncalized path provided via CLI. + /// Non-canonicalized path provided via CLI. Path(PathBuf), /// Contract info provided via CLI. ContractInfo(CompilerContractInfo), @@ -609,7 +1003,7 @@ impl PathOrContractInfo { /// Returns the path to the contract file if provided. pub fn path(&self) -> Option { match self { - Self::Path(path) => Some(path.to_path_buf()), + Self::Path(path) => Some(path.clone()), Self::ContractInfo(info) => info.path.as_ref().map(PathBuf::from), } } @@ -678,4 +1072,77 @@ mod tests { }) ); } + + #[test] + fn size_report_uses_configured_limits() { + let mut contracts = BTreeMap::new(); + contracts.insert( + "LargeContract".to_string(), + ContractInfo { runtime_size: 30_000, init_size: 60_000, is_dev_contract: false }, + ); + + let default_report = + SizeReport { contracts: contracts.clone(), limits: ContractSizeLimits::default() }; + assert!(default_report.exceeds_runtime_size_limit()); + assert!(default_report.exceeds_initcode_size_limit()); + + let custom_report = + SizeReport { contracts, limits: ContractSizeLimits::new(131_072, 262_144) }; + assert!(!custom_report.exceeds_runtime_size_limit()); + assert!(!custom_report.exceeds_initcode_size_limit()); + let output: serde_json::Value = + serde_json::from_str(&custom_report.format_json_output()).unwrap(); + assert_eq!( + output, + serde_json::json!({ + "LargeContract": { + "runtime_size": 30000, + "init_size": 60000, + "runtime_margin": 101072, + "init_margin": 202144, + } + }) + ); + } + + #[test] + fn contract_size_limits_derive_initcode_limit_from_runtime_limit() { + assert_eq!( + ContractSizeLimits::with_runtime_limit(50_000), + ContractSizeLimits::new(50_000, 100_000) + ); + } + + #[test] + fn contract_size_limits_follow_evm_spec() { + assert_eq!(ContractSizeLimits::for_spec_id(SpecId::OSAKA), ContractSizeLimits::default()); + assert_eq!( + ContractSizeLimits::for_spec_id(SpecId::AMSTERDAM), + ContractSizeLimits::new(65_536, 131_072) + ); + } + + #[test] + fn sanitized_remapping_paths_stay_inside_the_root() { + let root = Path::new("/tmp/sources"); + for path in ["../../../etc", "a/../../../etc", "/etc", "./a/../b"] { + let joined = root.join(sanitize_relative_path(Path::new(path))); + assert!( + joined.starts_with(root) && !joined.components().any(|c| c == Component::ParentDir), + "{path} escaped the root: {}", + joined.display() + ); + } + + for (path, expected) in [("src/../lib/", "lib"), ("./a/../b", "b"), ("a/../../lib", "lib")] + { + assert_eq!(sanitize_relative_path(Path::new(path)), Path::new(expected)); + } + + // Ordinary relative paths are left alone. + assert_eq!( + sanitize_relative_path(Path::new("lib/openzeppelin")), + Path::new("lib/openzeppelin") + ); + } } diff --git a/crates/common/src/constants.rs b/crates/common/src/constants.rs index 4d15967fe590a..4a86c9f0a2e64 100644 --- a/crates/common/src/constants.rs +++ b/crates/common/src/constants.rs @@ -1,8 +1,6 @@ //! Commonly used constants. -use alloy_eips::Typed2718; -use alloy_network::AnyTxEnvelope; -use alloy_primitives::{Address, B256, Signature, address}; +use alloy_primitives::{Address, address}; use std::time::Duration; /// The dev chain-id, inherited from hardhat @@ -11,9 +9,6 @@ pub const DEV_CHAIN_ID: u64 = 31337; /// The first four bytes of the call data for a function call specifies the function to be called. pub const SELECTOR_LEN: usize = 4; -/// Maximum size in bytes (0x6000) that a contract can have. -pub const CONTRACT_MAX_SIZE: usize = 24576; - /// Default request timeout for http requests /// /// Note: this is only used so that connections, that are discarded on the server side won't stay @@ -39,6 +34,22 @@ pub const ARBITRUM_SENDER: Address = address!("0x0000000000000000000000000000000 /// See also pub const OPTIMISM_SYSTEM_ADDRESS: Address = address!("0xdeaddeaddeaddeaddeaddeaddeaddeaddead0001"); +/// The system address, the sender of the first transaction in every block: +pub const MONAD_SYSTEM_ADDRESS: Address = address!("0x6f49a8F621353f12378d0046E7d7e4b9B249DC9e"); + +/// HyperEVM system address that credits HyperCore -> HyperEVM native transfers. +/// +/// These are legacy envelopes with `gasPrice = 0` and a receipt `gasUsed` of `0`, so replaying one +/// as a regular transaction fails base fee validation and aborts the whole block replay. +pub const HYPERLIQUID_SYSTEM_ADDRESS: Address = Address::repeat_byte(0x22); + +/// MegaETH system address for `Set Slots` in the MegaETH oracle. +/// +/// Transactions from this sender are submitted with gas price 0. +/// +/// See: +pub const MEGA_SYSTEM_ADDRESS: Address = address!("0xa887dcb9d5f39ef79272801d05abdf707cfbbd1d"); + /// Transaction identifier of System transaction types pub const SYSTEM_TRANSACTION_TYPE: u8 = 126; @@ -52,28 +63,18 @@ pub const TYPE_BINDING_PREFIX: &str = "string constant schema_"; /// /// Transactions from these senders usually don't have a any fee information OR set absurdly high fees that exceed the gas limit (See: ) /// -/// See: [ARBITRUM_SENDER], [OPTIMISM_SYSTEM_ADDRESS] and [Address::ZERO] -#[inline] +/// See: [ARBITRUM_SENDER], [OPTIMISM_SYSTEM_ADDRESS], [MONAD_SYSTEM_ADDRESS], +/// [MEGA_SYSTEM_ADDRESS], [HYPERLIQUID_SYSTEM_ADDRESS] and [Address::ZERO] pub fn is_known_system_sender(sender: Address) -> bool { - [ARBITRUM_SENDER, OPTIMISM_SYSTEM_ADDRESS, Address::ZERO].contains(&sender) -} - -pub fn is_impersonated_tx(tx: &AnyTxEnvelope) -> bool { - if let AnyTxEnvelope::Ethereum(tx) = tx { - return is_impersonated_sig(tx.signature(), tx.ty()); - } - false -} - -pub fn is_impersonated_sig(sig: &Signature, ty: u8) -> bool { - let impersonated_sig = - Signature::from_scalars_and_parity(B256::with_last_byte(1), B256::with_last_byte(1), false); - if ty != SYSTEM_TRANSACTION_TYPE - && (sig == &impersonated_sig || sig.r() == impersonated_sig.r()) - { - return true; - } - false + [ + ARBITRUM_SENDER, + OPTIMISM_SYSTEM_ADDRESS, + MONAD_SYSTEM_ADDRESS, + MEGA_SYSTEM_ADDRESS, + HYPERLIQUID_SYSTEM_ADDRESS, + Address::ZERO, + ] + .contains(&sender) } #[cfg(test)] diff --git a/crates/common/src/contracts.rs b/crates/common/src/contracts.rs index 8e011609bd5a7..77129715b303d 100644 --- a/crates/common/src/contracts.rs +++ b/crates/common/src/contracts.rs @@ -1,15 +1,16 @@ //! Commonly used contract types and functions. -use crate::{compile::PathOrContractInfo, strip_bytecode_placeholders}; +use crate::{compile::PathOrContractInfo, find_metadata_start, strip_bytecode_placeholders}; use alloy_dyn_abi::JsonAbiExt; use alloy_json_abi::{Event, Function, JsonAbi}; -use alloy_primitives::{Address, B256, Bytes, Selector, hex}; +use alloy_primitives::{Address, B256, Bytes, Selector, address, hex}; use eyre::{OptionExt, Result}; use foundry_compilers::{ ArtifactId, Project, ProjectCompileOutput, artifacts::{ - BytecodeObject, CompactBytecode, CompactContractBytecode, CompactDeployedBytecode, - ConfigurableContractArtifact, ContractBytecodeSome, Offsets, + BytecodeObject, CompactBytecode, CompactContractBytecode, CompactContractBytecodeCow, + CompactDeployedBytecode, ConfigurableContractArtifact, ContractBytecodeSome, Offsets, + StorageLayout, }, utils::canonicalized, }; @@ -28,6 +29,11 @@ use std::{ const CALL_PROTECTION_BYTECODE_PREFIX: [u8; 21] = hex!("730000000000000000000000000000000000000000"); +/// Isolated account used to deploy libraries needed only while executing locally. +/// +/// `address(uint160(uint256(keccak256("foundry library deployer"))))` +pub const LIBRARY_DEPLOYER: Address = address!("0x1F95D37F27EA0dEA9C252FC09D5A6eaA97647353"); + /// Subset of [CompactBytecode] excluding sourcemaps. #[expect(missing_docs)] #[derive(Debug, Clone)] @@ -43,6 +49,27 @@ impl BytecodeData { } } +/// Sorts and merges overlapping or adjacent byte ranges. +fn normalize_offsets(offsets: &mut Vec) { + offsets.sort_by_key(|o| o.start); + + let mut merged: Vec = Vec::with_capacity(offsets.len()); + for offset in offsets.drain(..) { + if let Some(last) = merged.last_mut() { + let last_end = last.start as u64 + last.length as u64; + if offset.start as u64 <= last_end { + let this_end = offset.start as u64 + offset.length as u64; + if this_end > last_end { + last.length = (this_end - last.start as u64) as u32; + } + continue; + } + } + merged.push(offset); + } + *offsets = merged; +} + impl From for BytecodeData { fn from(bytecode: CompactBytecode) -> Self { Self { @@ -75,6 +102,8 @@ pub struct ContractData { pub bytecode: Option, /// Contract runtime code. pub deployed_bytecode: Option, + /// Contract storage layout, if available. + pub storage_layout: Option>, } impl ContractData { @@ -99,6 +128,68 @@ impl ContractData { } } +/// Builder for creating a `ContractsByArtifact` instance, optionally including storage layouts +/// from project compile output. +pub struct ContractsByArtifactBuilder<'a> { + /// All compiled artifact bytecodes (borrowed). + artifacts: BTreeMap>, + /// Optionally collected storage layouts for matching artifact IDs. + storage_layouts: BTreeMap, +} + +impl<'a> ContractsByArtifactBuilder<'a> { + /// Creates a new builder from artifacts with present bytecode iterator. + pub fn new( + artifacts: impl IntoIterator)>, + ) -> Self { + Self { artifacts: artifacts.into_iter().collect(), storage_layouts: BTreeMap::new() } + } + + /// Add storage layouts from the given `ProjectCompileOutput` to known artifacts. + pub fn with_output(self, output: &ProjectCompileOutput, base: &Path) -> Self { + self.with_storage_layouts(output.artifact_ids().filter_map(|(id, artifact)| { + artifact + .storage_layout + .as_ref() + .map(|layout| (id.with_stripped_file_prefixes(base), layout.clone())) + })) + } + + /// Add storage layouts. + pub fn with_storage_layouts( + mut self, + layouts: impl IntoIterator, + ) -> Self { + self.storage_layouts.extend(layouts); + self + } + + /// Builds `ContractsByArtifact`. + pub fn build(self) -> ContractsByArtifact { + let map = self + .artifacts + .into_iter() + .filter_map(|(id, artifact)| { + let name = id.name.clone(); + let CompactContractBytecodeCow { abi, bytecode, deployed_bytecode } = artifact; + + Some(( + id.clone(), + ContractData { + name, + abi: abi?.into_owned(), + bytecode: bytecode.map(|b| b.into_owned().into()), + deployed_bytecode: deployed_bytecode.map(|b| b.into_owned().into()), + storage_layout: self.storage_layouts.get(&id).map(|l| Arc::new(l.clone())), + }, + )) + }) + .collect(); + + ContractsByArtifact(Arc::new(map)) + } +} + type ArtifactWithContractRef<'a> = (&'a ArtifactId, &'a ContractData); /// Wrapper type that maps an artifact to a contract ABI and bytecode. @@ -120,6 +211,7 @@ impl ContractsByArtifact { abi: abi?, bytecode: bytecode.map(Into::into), deployed_bytecode: deployed_bytecode.map(Into::into), + storage_layout: None, }, )) }) @@ -180,17 +272,63 @@ impl ContractsByArtifact { /// Finds a contract which deployed bytecode exactly matches the given code. Accounts for link /// references and immutables. pub fn find_by_deployed_code_exact(&self, code: &[u8]) -> Option> { + self.find_by_deployed_code_exact_preferred(code, |_| true) + } + + /// Same as [`Self::find_by_deployed_code_exact`], preferring artifacts matching `preferred` + /// only among equally strong matches. Metadata-exact matches always take precedence. + pub fn find_by_deployed_code_exact_preferred( + &self, + code: &[u8], + preferred: impl Fn(&ArtifactId) -> bool, + ) -> Option> { + self.find_by_deployed_code_exact_inner(code, false, preferred).0 + } + + /// Finds the only contract whose deployed bytecode exactly matches the given code. + pub fn find_by_deployed_code_exact_unique( + &self, + code: &[u8], + ) -> Option> { + self.find_by_deployed_code_exact_inner(code, true, |_| false).0 + } + + /// Finds a deployed-code match, preferring exact metadata matches and rejecting ambiguous + /// partial metadata matches. A single partial match is accepted. + pub fn find_by_deployed_code_exact_unambiguous( + &self, + code: &[u8], + ) -> Result>> { + let (matched, ambiguous_partial) = + self.find_by_deployed_code_exact_inner(code, false, |_| true); + if ambiguous_partial { + eyre::bail!( + "Multiple local contracts match the deployed bytecode with different metadata. Specify the contract as :" + ); + } + Ok(matched) + } + + fn find_by_deployed_code_exact_inner( + &self, + code: &[u8], + unique: bool, + preferred: impl Fn(&ArtifactId) -> bool, + ) -> (Option>, bool) { // Immediately return None if the code is empty. if code.is_empty() { - return None; + return (None, false); } - self.iter().find(|(_, contract)| { + let mut partial_match = None; + let mut exact_match = None; + let mut ambiguous_partial = false; + 'contracts: for (id, contract) in self.iter() { let Some(deployed_bytecode) = &contract.deployed_bytecode else { - return false; + continue; }; let Some(deployed_code) = &deployed_bytecode.object else { - return false; + continue; }; let len = match deployed_code { @@ -198,8 +336,18 @@ impl ContractsByArtifact { BytecodeObject::Unlinked(bytes) => bytes.len() / 2, }; - if len != code.len() { - return false; + // Vyper artifacts contain the complete runtime, but omit the immutable data + // appended on deployment. Compare that runtime in full, without treating any of + // its bytes (or the immutable suffix) as Solidity metadata. + let is_vyper = id.source.extension().is_some_and(|extension| extension == "vy"); + let code = if is_vyper { + let Some(runtime) = code.get(..len) else { continue }; + runtime + } else { + code + }; + if len == 0 || len != code.len() { + continue; } // Collect ignored offsets by chaining link and immutable references. @@ -215,26 +363,39 @@ impl ContractsByArtifact { // ignore it as it includes library address determined at runtime. // See https://docs.soliditylang.org/en/latest/contracts.html#call-protection-for-libraries and // https://github.com/NomicFoundation/hardhat/blob/af7807cf38842a4f56e7f4b966b806e39631568a/packages/hardhat-verify/src/internal/solc/bytecode.ts#L172 - let has_call_protection = match deployed_code { - BytecodeObject::Bytecode(bytes) => { - bytes.starts_with(&CALL_PROTECTION_BYTECODE_PREFIX) - } - BytecodeObject::Unlinked(bytes) => { - if let Ok(bytes) = - Bytes::from_str(&bytes[..CALL_PROTECTION_BYTECODE_PREFIX.len() * 2]) - { + let has_call_protection = !is_vyper + && match deployed_code { + BytecodeObject::Bytecode(bytes) => { bytes.starts_with(&CALL_PROTECTION_BYTECODE_PREFIX) - } else { - false } - } - }; + BytecodeObject::Unlinked(bytes) => { + if let Ok(bytes) = + Bytes::from_str(&bytes[..CALL_PROTECTION_BYTECODE_PREFIX.len() * 2]) + { + bytes.starts_with(&CALL_PROTECTION_BYTECODE_PREFIX) + } else { + false + } + } + }; if has_call_protection { ignored.push(Offsets { start: 1, length: 20 }); } - ignored.sort_by_key(|o| o.start); + let metadata_start = if is_vyper { None } else { find_metadata_start(code) }; + + // Keep the trailing CBOR length in the comparison: it is read from `code`, so ignoring + // it would let crafted code declare arbitrary bytes as metadata. + if let Some(metadata) = metadata_start { + ignored.push(Offsets { + start: metadata as u32, + length: (code.len() - 2 - metadata) as u32, + }); + } + + // Merge ranges from independent sources before slicing between them. + normalize_offsets(&mut ignored); let mut left = 0; for offset in ignored { @@ -252,13 +413,13 @@ impl ContractsByArtifact { }; if !matched { - return false; + continue 'contracts; } left = right + offset.length as usize; } - if left < code.len() { + let is_partial = if left < code.len() { match deployed_code { BytecodeObject::Bytecode(bytes) => bytes[left..] == code[left..], BytecodeObject::Unlinked(bytes) => { @@ -271,8 +432,38 @@ impl ContractsByArtifact { } } else { true + }; + + if !is_partial { + continue; } - }) + + let matches_metadata = metadata_start.is_none_or(|metadata| match deployed_code { + BytecodeObject::Bytecode(bytes) => bytes[metadata..] == code[metadata..], + BytecodeObject::Unlinked(bytes) => Bytes::from_str(&bytes[metadata * 2..]) + .is_ok_and(|bytes| bytes == code[metadata..]), + }); + + if matches_metadata { + if unique && exact_match.is_some() { + return (None, false); + } + if exact_match.is_none() || preferred(id) { + exact_match = Some((id, contract)); + } + if !unique && preferred(id) { + return (exact_match, false); + } + } else { + ambiguous_partial |= partial_match.is_some(); + if preferred(id) || partial_match.is_none_or(|(id, _)| !preferred(id)) { + partial_match = Some((id, contract)); + } + } + } + + let ambiguous_partial = exact_match.is_none() && ambiguous_partial; + (if unique { exact_match } else { exact_match.or(partial_match) }, ambiguous_partial) } /// Finds a contract which has the same contract name or identifier as `id`. If more than one is @@ -281,25 +472,14 @@ impl ContractsByArtifact { &self, id: &str, ) -> Result>> { - let contracts = self - .iter() - .filter(|(artifact, _)| artifact.name == id || artifact.identifier() == id) - .collect::>(); - - if contracts.len() > 1 { + let mut iter = + self.iter().filter(|(artifact, _)| artifact.name == id || artifact.identifier() == id); + let first = iter.next(); + if first.is_some() && iter.next().is_some() { eyre::bail!("{id} has more than one implementation."); } - Ok(contracts.first().copied()) - } - - /// Finds abi for contract which has the same contract name or identifier as `id`. - pub fn find_abi_by_name_or_identifier(&self, id: &str) -> Option { - self.iter() - .find(|(artifact, _)| { - artifact.name.split(".").next().unwrap() == id || artifact.identifier() == id - }) - .map(|(_, contract)| contract.abi.clone()) + Ok(first) } /// Finds abi by name or source path @@ -308,7 +488,7 @@ impl ContractsByArtifact { pub fn find_abi_by_name_or_src_path(&self, name_or_path: &str) -> Option<(JsonAbi, String)> { self.iter() .find(|(artifact, _)| { - artifact.name == name_or_path || artifact.source == PathBuf::from(name_or_path) + artifact.name == name_or_path || artifact.source == Path::new(name_or_path) }) .map(|(_, contract)| (contract.abi.clone(), contract.name.clone())) } @@ -318,7 +498,7 @@ impl ContractsByArtifact { let mut funcs = BTreeMap::new(); let mut events = BTreeMap::new(); let mut errors_abi = JsonAbi::new(); - for (_name, contract) in self.iter() { + for contract in self.values() { for func in contract.abi.functions() { funcs.insert(func.selector(), func.clone()); } @@ -362,6 +542,21 @@ pub type ContractsByAddress = BTreeMap; /// Very simple fuzzy matching of contract bytecode. /// /// Returns a value between `0.0` (identical) and `1.0` (completely different). +/// Returns whether `creation_code` is exactly this contract's linked creation bytecode followed by +/// a complete, canonically encoded constructor argument tuple. +pub fn matches_contract_creation(contract: &ContractData, creation_code: &[u8]) -> bool { + let Some(bytecode) = contract.bytecode() else { return false }; + let Some(arguments) = creation_code.strip_prefix(bytecode.as_ref()) else { return false }; + match contract.abi.constructor() { + Some(constructor) => constructor + .abi_decode_input(arguments) + .ok() + .and_then(|values| constructor.abi_encode_input(&values).ok()) + .is_some_and(|encoded| encoded == arguments), + None => arguments.is_empty(), + } +} + pub fn bytecode_diff_score<'a>(mut a: &'a [u8], mut b: &'a [u8]) -> f64 { // Make sure `a` is the longer one. if a.len() < b.len() { @@ -391,7 +586,7 @@ pub fn bytecode_diff_score<'a>(mut a: &'a [u8], mut b: &'a [u8]) -> f64 { /// # Safety /// /// `a` must be at least as long as `b`. -unsafe fn count_different_bytes(a: &[u8], b: &[u8]) -> usize { +const unsafe fn count_different_bytes(a: &[u8], b: &[u8]) -> usize { // This could've been written as `std::iter::zip(a, b).filter(|(x, y)| x != y).count()`, // however this function is very hot, and has been written to be as primitive as // possible for lower optimization levels. @@ -466,23 +661,14 @@ pub fn find_target_path(project: &Project, identifier: &PathOrContractInfo) -> R PathOrContractInfo::ContractInfo(info) => { if let Some(path) = info.path.as_ref() { let path = canonicalized(project.root().join(path)); - let sources = project.sources()?; - let contract_path = sources - .iter() - .find_map(|(src_path, _)| { - if **src_path == path { - return Some(src_path.clone()); - } - None - }) - .ok_or_else(|| { - eyre::eyre!( - "Could not find source file for contract `{}` at {}", - info.name, - path.strip_prefix(project.root()).unwrap().display() - ) - })?; - return Ok(contract_path); + if !path.is_file() { + eyre::bail!( + "Could not find source file for contract `{}` at {}", + info.name, + path.strip_prefix(project.root()).unwrap_or(&path).display() + ); + } + return Ok(path); } // If ContractInfo.path hasn't been provided we try to find the contract using the name. // This will fail if projects have multiple contracts with the same name. In that case, @@ -500,8 +686,17 @@ pub fn find_matching_contract_artifact( target_name: Option<&str>, ) -> eyre::Result { if let Some(name) = target_name { - output - .remove(target_path, name) + if let Some(artifact) = output.remove(target_path, name) { + return Ok(artifact); + } + + let target_path = canonicalized(target_path); + let matching_source = output.artifact_ids().find_map(|(id, _artifact)| { + (id.name == name && canonicalized(&id.source) == target_path).then(|| id.source.clone()) + }); + + matching_source + .and_then(|source| output.remove(&source, name)) .ok_or_eyre(format!("Could not find artifact `{name}` in the compiled artifacts")) } else { let possible_targets = output @@ -523,7 +718,7 @@ pub fn find_matching_contract_artifact( // If all artifact_ids in `possible_targets` have the same name (without ".", indicates // additional compiler profiles), it means that there are multiple contracts in the // same file. - if !target_id.name.contains(".") + if !target_id.name.contains('.') && possible_targets.iter().any(|(id, _)| id.name != target_id.name) { eyre::bail!( @@ -535,15 +730,80 @@ pub fn find_matching_contract_artifact( // same but `id.path` is different. let artifact = possible_targets .iter() - .find_map(|(id, artifact)| if id.profile == "default" { Some(*artifact) } else { None }) + .find_map(|(id, artifact)| (id.profile == "default").then_some(*artifact)) .unwrap_or(target_artifact); Ok(artifact.clone()) } } + #[cfg(test)] mod tests { use super::*; + use alloy_dyn_abi::DynSolValue; + use alloy_primitives::U256; + use semver::Version; + + fn deployed_artifact(name: &str, code: Bytes) -> (ArtifactId, CompactContractBytecode) { + deployed_artifact_with_immutables(name, code, Default::default()) + } + + fn deployed_artifact_with_immutables( + name: &str, + code: Bytes, + immutable_references: BTreeMap>, + ) -> (ArtifactId, CompactContractBytecode) { + ( + ArtifactId { + path: format!("out/{name}.json").into(), + name: name.to_owned(), + source: format!("src/{name}.sol").into(), + version: Version::new(0, 8, 30), + build_id: String::new(), + profile: "default".to_owned(), + }, + CompactContractBytecode { + abi: Some(Default::default()), + bytecode: None, + deployed_bytecode: Some(CompactDeployedBytecode { + bytecode: Some(CompactBytecode { + object: BytecodeObject::Bytecode(code), + source_map: None, + link_references: Default::default(), + }), + immutable_references, + }), + }, + ) + } + + #[test] + fn exact_creation_match_requires_canonical_constructor_suffix() { + let abi = JsonAbi::parse(["constructor(uint256 value)"]).unwrap(); + let bytecode = Bytes::from_static(&[0x60, 0x00]); + let contract = ContractData { + name: "C".to_owned(), + abi, + bytecode: Some(BytecodeData { + object: Some(BytecodeObject::Bytecode(bytecode.clone())), + link_references: BTreeMap::new(), + immutable_references: BTreeMap::new(), + }), + deployed_bytecode: None, + storage_layout: None, + }; + let arguments = contract + .abi + .constructor() + .unwrap() + .abi_encode_input(&[DynSolValue::Uint(U256::ONE, 256)]) + .unwrap(); + let creation = [bytecode.as_ref(), &arguments].concat(); + + assert!(matches_contract_creation(&contract, &creation)); + assert!(!matches_contract_creation(&contract, &creation[..creation.len() - 1])); + assert!(!matches_contract_creation(&contract, &[creation, vec![0]].concat())); + } #[test] fn bytecode_diffing() { @@ -567,4 +827,162 @@ mod tests { assert!(contracts.find_by_deployed_code_exact(&[]).is_none()); } + + #[test] + fn find_by_deployed_code_exact_unique_rejects_ambiguity() { + let code = Bytes::from_static(&[0x60, 0x00]); + let contracts = ContractsByArtifact::new([ + deployed_artifact("A", code.clone()), + deployed_artifact("B", code.clone()), + ]); + + assert!(contracts.find_by_deployed_code_exact_unique(&code).is_none()); + + let contracts = ContractsByArtifact::new([deployed_artifact("A", code.clone())]); + assert_eq!(contracts.find_by_deployed_code_exact_unique(&code).unwrap().0.name, "A"); + } + + #[test] + fn find_by_deployed_code_exact_unique_rejects_partial_metadata_match() { + let artifact_code = Bytes::from_static(&[0x60, 0x00, 0xa0, 0x00, 0x01]); + let deployed_code = Bytes::from_static(&[0x60, 0x00, 0xf6, 0x00, 0x01]); + let contracts = ContractsByArtifact::new([deployed_artifact("A", artifact_code)]); + + assert!(contracts.find_by_deployed_code_exact(&deployed_code).is_some()); + assert!(contracts.find_by_deployed_code_exact_unique(&deployed_code).is_none()); + } + + #[test] + fn find_by_deployed_code_exact_unambiguous_preserves_single_partial() { + let artifact_code = Bytes::from_static(&[0x60, 0x00, 0xa0, 0x00, 0x01]); + let deployed_code = Bytes::from_static(&[0x60, 0x00, 0xf6, 0x00, 0x01]); + let contracts = ContractsByArtifact::new([deployed_artifact("A", artifact_code)]); + + assert_eq!( + contracts + .find_by_deployed_code_exact_unambiguous(&deployed_code) + .unwrap() + .unwrap() + .0 + .name, + "A" + ); + assert!(contracts.find_by_deployed_code_exact_unambiguous(&[0x00]).unwrap().is_none()); + } + + #[test] + fn find_by_deployed_code_exact_unambiguous_prefers_exact_over_ambiguous_partial() { + let partial_code = Bytes::from_static(&[0x60, 0x00, 0xa0, 0x00, 0x01]); + let deployed_code = Bytes::from_static(&[0x60, 0x00, 0xf6, 0x00, 0x01]); + for exact_name in ["A", "Z"] { + let contracts = ContractsByArtifact::new([ + deployed_artifact("B", partial_code.clone()), + deployed_artifact("C", partial_code.clone()), + ]); + assert!(contracts.find_by_deployed_code_exact(&deployed_code).is_some()); + assert!(contracts.find_by_deployed_code_exact_unique(&deployed_code).is_none()); + assert_eq!( + contracts + .find_by_deployed_code_exact_unambiguous(&deployed_code) + .unwrap_err() + .to_string(), + "Multiple local contracts match the deployed bytecode with different metadata. Specify the contract as :" + ); + + let contracts = ContractsByArtifact::new([ + deployed_artifact(exact_name, deployed_code.clone()), + deployed_artifact("B", partial_code.clone()), + deployed_artifact("C", partial_code.clone()), + ]); + assert_eq!( + contracts + .find_by_deployed_code_exact_unambiguous(&deployed_code) + .unwrap() + .unwrap() + .0 + .name, + exact_name + ); + } + } + + /// Tests an immutable reference within a library call-protection prefix. + #[test] + fn find_by_deployed_code_exact_handles_overlapping_ignored_ranges() { + // Call-protection prefix covering [1, 21). + let mut code = vec![0x73u8]; + code.extend(std::iter::repeat_n(0u8, 20)); + // Bytes matched after the ignored range. + code.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD]); + let code = Bytes::from(code); + + // Immutable reference [5, 8) lies within [1, 21). + let immutable_references = + BTreeMap::from([("someImmutable".to_string(), vec![Offsets { start: 5, length: 3 }])]); + + let contracts = ContractsByArtifact::new([deployed_artifact_with_immutables( + "LibWithEarlyImmutable", + code.clone(), + immutable_references, + )]); + + // The overlap must not panic or prevent a match. + assert!(contracts.find_by_deployed_code_exact(&code).is_some()); + } + + #[test] + fn find_by_deployed_code_exact_rejects_forged_metadata_length() { + // `PUSH1 0 PUSH1 1` followed by one byte of CBOR metadata and its length. + let contracts = ContractsByArtifact::new([deployed_artifact( + "A", + Bytes::from_static(&[0x60, 0x00, 0x60, 0x01, 0xa0, 0x00, 0x01]), + )]); + + // Different metadata with the same length is still a partial match. + assert!( + contracts + .find_by_deployed_code_exact(&[0x60, 0x00, 0x60, 0x01, 0xf6, 0x00, 0x01]) + .is_some() + ); + // A forged length that declares modified code as metadata is not. + assert!( + contracts + .find_by_deployed_code_exact(&[0x60, 0x00, 0x60, 0x02, 0xa0, 0x00, 0x04]) + .is_none() + ); + } + + #[test] + fn normalize_offsets_merges_overlaps_and_adjacency() { + // Contained overlap. + let mut offsets = vec![Offsets { start: 1, length: 20 }, Offsets { start: 5, length: 3 }]; + normalize_offsets(&mut offsets); + assert_eq!(offsets, vec![Offsets { start: 1, length: 20 }]); + + // Extending overlap. + let mut offsets = vec![Offsets { start: 1, length: 20 }, Offsets { start: 15, length: 15 }]; + normalize_offsets(&mut offsets); + assert_eq!(offsets, vec![Offsets { start: 1, length: 29 }]); + + // Adjacent ranges. + let mut offsets = vec![Offsets { start: 1, length: 20 }, Offsets { start: 21, length: 4 }]; + normalize_offsets(&mut offsets); + assert_eq!(offsets, vec![Offsets { start: 1, length: 24 }]); + + // Disjoint ranges. + let mut offsets = vec![Offsets { start: 1, length: 5 }, Offsets { start: 10, length: 5 }]; + normalize_offsets(&mut offsets); + assert_eq!( + offsets, + vec![Offsets { start: 1, length: 5 }, Offsets { start: 10, length: 5 }] + ); + + // Unsorted ranges. + let mut offsets = vec![Offsets { start: 10, length: 5 }, Offsets { start: 1, length: 5 }]; + normalize_offsets(&mut offsets); + assert_eq!( + offsets, + vec![Offsets { start: 1, length: 5 }, Offsets { start: 10, length: 5 }] + ); + } } diff --git a/crates/common/src/errors/artifacts.rs b/crates/common/src/errors/artifacts.rs deleted file mode 100644 index 33814c57e4ddc..0000000000000 --- a/crates/common/src/errors/artifacts.rs +++ /dev/null @@ -1,12 +0,0 @@ -//! Errors that can occur when working with `solc` artifacts - -/// Error when encountering unlinked code -#[derive(Clone, Debug, thiserror::Error)] -pub enum UnlinkedByteCode { - /// `bytecode` is unlinked - #[error("Contract `{0}` has unlinked bytecode. Please check all libraries settings.")] - Bytecode(String), - /// `deployedBytecode` is unlinked - #[error("Contract `{0}` has unlinked deployed Bytecode. Please check all libraries settings.")] - DeployedBytecode(String), -} diff --git a/crates/common/src/errors/fs.rs b/crates/common/src/errors/fs.rs index a0a67f34cc480..964761057bf30 100644 --- a/crates/common/src/errors/fs.rs +++ b/crates/common/src/errors/fs.rs @@ -3,41 +3,42 @@ use std::{ path::{Path, PathBuf}, }; -#[expect(unused_imports)] -use std::fs::{self, File}; - /// Various error variants for `fs` operations that serve as an addition to the io::Error which /// does not provide any information about the path. #[derive(Debug, thiserror::Error)] #[expect(missing_docs)] pub enum FsPathError { - /// Provides additional path context for [`fs::write`]. + /// Provides additional path context for [`std::fs::write`]. #[error("failed to write to {path:?}: {source}")] Write { source: io::Error, path: PathBuf }, - /// Provides additional path context for [`fs::read`]. + /// Provides additional path context for [`std::fs::read`]. #[error("failed to read from {path:?}: {source}")] Read { source: io::Error, path: PathBuf }, - /// Provides additional path context for [`fs::copy`]. + /// Provides additional path context for [`std::fs::copy`]. #[error("failed to copy from {from:?} to {to:?}: {source}")] Copy { source: io::Error, from: PathBuf, to: PathBuf }, - /// Provides additional path context for [`fs::read_link`]. + /// Provides additional path context for [`std::fs::read_link`]. #[error("failed to read from {path:?}: {source}")] ReadLink { source: io::Error, path: PathBuf }, - /// Provides additional path context for [`File::create`]. + /// Provides additional path context for [`std::fs::File::create`]. #[error("failed to create file {path:?}: {source}")] CreateFile { source: io::Error, path: PathBuf }, - /// Provides additional path context for [`fs::remove_file`]. + /// Provides additional path context for [`std::fs::remove_file`]. #[error("failed to remove file {path:?}: {source}")] RemoveFile { source: io::Error, path: PathBuf }, - /// Provides additional path context for [`fs::create_dir`]. + /// Provides additional path context for [`std::fs::create_dir`]. #[error("failed to create dir {path:?}: {source}")] CreateDir { source: io::Error, path: PathBuf }, - /// Provides additional path context for [`fs::remove_dir`]. + /// Provides additional path context for [`std::fs::remove_dir`]. #[error("failed to remove dir {path:?}: {source}")] RemoveDir { source: io::Error, path: PathBuf }, - /// Provides additional path context for [`File::open`]. + /// Provides additional path context for [`std::fs::File::open`]. #[error("failed to open file {path:?}: {source}")] Open { source: io::Error, path: PathBuf }, + #[error("failed to lock file {path:?}: {source}")] + Lock { source: io::Error, path: PathBuf }, + #[error("failed to unlock file {path:?}: {source}")] + Unlock { source: io::Error, path: PathBuf }, /// Provides additional path context for the file whose contents should be parsed as JSON. #[error("failed to parse json file: {path:?}: {source}")] ReadJson { source: serde_json::Error, path: PathBuf }, @@ -47,50 +48,60 @@ pub enum FsPathError { } impl FsPathError { - /// Returns the complementary error variant for [`fs::write`]. + /// Returns the complementary error variant for [`std::fs::write`]. pub fn write(source: io::Error, path: impl Into) -> Self { Self::Write { source, path: path.into() } } - /// Returns the complementary error variant for [`fs::read`]. + /// Returns the complementary error variant for [`std::fs::read`]. pub fn read(source: io::Error, path: impl Into) -> Self { Self::Read { source, path: path.into() } } - /// Returns the complementary error variant for [`fs::copy`]. + /// Returns the complementary error variant for [`std::fs::copy`]. pub fn copy(source: io::Error, from: impl Into, to: impl Into) -> Self { Self::Copy { source, from: from.into(), to: to.into() } } - /// Returns the complementary error variant for [`fs::read_link`]. + /// Returns the complementary error variant for [`std::fs::read_link`]. pub fn read_link(source: io::Error, path: impl Into) -> Self { Self::ReadLink { source, path: path.into() } } - /// Returns the complementary error variant for [`File::create`]. + /// Returns the complementary error variant for [`std::fs::File::create`]. pub fn create_file(source: io::Error, path: impl Into) -> Self { Self::CreateFile { source, path: path.into() } } - /// Returns the complementary error variant for [`fs::remove_file`]. + /// Returns the complementary error variant for [`std::fs::remove_file`]. pub fn remove_file(source: io::Error, path: impl Into) -> Self { Self::RemoveFile { source, path: path.into() } } - /// Returns the complementary error variant for [`fs::create_dir`]. + /// Returns the complementary error variant for [`std::fs::create_dir`]. pub fn create_dir(source: io::Error, path: impl Into) -> Self { Self::CreateDir { source, path: path.into() } } - /// Returns the complementary error variant for [`fs::remove_dir`]. + /// Returns the complementary error variant for [`std::fs::remove_dir`]. pub fn remove_dir(source: io::Error, path: impl Into) -> Self { Self::RemoveDir { source, path: path.into() } } - /// Returns the complementary error variant for [`File::open`]. + /// Returns the complementary error variant for [`std::fs::File::open`]. pub fn open(source: io::Error, path: impl Into) -> Self { Self::Open { source, path: path.into() } } + + /// Returns the complementary error variant when locking a file. + pub fn lock(source: io::Error, path: impl Into) -> Self { + Self::Lock { source, path: path.into() } + } + + /// Returns the complementary error variant when unlocking a file. + pub fn unlock(source: io::Error, path: impl Into) -> Self { + Self::Unlock { source, path: path.into() } + } } impl AsRef for FsPathError { @@ -105,6 +116,8 @@ impl AsRef for FsPathError { | Self::CreateFile { path, .. } | Self::RemoveFile { path, .. } | Self::Open { path, .. } + | Self::Lock { path, .. } + | Self::Unlock { path, .. } | Self::ReadJson { path, .. } | Self::WriteJson { path, .. } => path, } @@ -122,7 +135,9 @@ impl From for io::Error { | FsPathError::RemoveDir { source, .. } | FsPathError::CreateFile { source, .. } | FsPathError::RemoveFile { source, .. } - | FsPathError::Open { source, .. } => source, + | FsPathError::Open { source, .. } + | FsPathError::Lock { source, .. } + | FsPathError::Unlock { source, .. } => source, FsPathError::ReadJson { source, .. } | FsPathError::WriteJson { source, .. } => { source.into() diff --git a/crates/common/src/errors/mod.rs b/crates/common/src/errors/mod.rs index 5ecd1dcc04cb0..46763f5da4a29 100644 --- a/crates/common/src/errors/mod.rs +++ b/crates/common/src/errors/mod.rs @@ -3,9 +3,6 @@ mod fs; pub use fs::FsPathError; -mod artifacts; -pub use artifacts::*; - mod private { use eyre::Chain; use std::error::Error; @@ -44,9 +41,29 @@ fn all_sources(err: &E) -> Vec { err.chain().map(|cause| cause.to_string().trim().to_string()).collect() } +/// Converts solar errors to an eyre error. +pub fn convert_solar_errors(dcx: &solar::interface::diagnostics::DiagCtxt) -> eyre::Result<()> { + match dcx.emitted_errors() { + Some(Ok(())) => Ok(()), + Some(Err(e)) if !e.is_empty() => { + eyre::bail!("solar reported errors:\n\n{e}"); + } + _ if dcx.has_errors().is_err() => { + // Non-buffer emitter: diagnostics already went to stderr; include the count. + let n = dcx.err_count(); + let plural = if n == 1 { "" } else { "s" }; + eyre::bail!( + "solar reported {n} error{plural}; see the diagnostic{plural} printed above" + ); + } + _ => Ok(()), + } +} + #[cfg(test)] mod tests { use super::*; + use solar::interface::diagnostics::{DiagCtxt, SilentEmitter}; #[test] fn dedups_contained() { @@ -65,4 +82,35 @@ mod tests { let chained = display_chain(&ee); assert_eq!(chained, "my error: hello"); } + + /// Regression test for the "non-buffer emitter" branch of [`convert_solar_errors`]. + /// + /// Simulates an unhandled solar edge case: the linter installs a non-buffer (stderr-style) + /// emitter, errors are emitted to it, and only the count is recoverable afterwards. The + /// returned eyre error must reference the count and direct the user to the diagnostics that + /// were already printed above. + #[test] + fn solar_non_buffer_emitter_singular() { + let dcx = DiagCtxt::new(Box::new(SilentEmitter::new_silent())); + dcx.err("boom").emit(); + + let err = convert_solar_errors(&dcx).unwrap_err(); + assert_eq!(err.to_string(), "solar reported 1 error; see the diagnostic printed above"); + } + + #[test] + fn solar_non_buffer_emitter_plural() { + let dcx = DiagCtxt::new(Box::new(SilentEmitter::new_silent())); + dcx.err("boom 1").emit(); + dcx.err("boom 2").emit(); + + let err = convert_solar_errors(&dcx).unwrap_err(); + assert_eq!(err.to_string(), "solar reported 2 errors; see the diagnostics printed above"); + } + + #[test] + fn solar_no_errors_is_ok() { + let dcx = DiagCtxt::new(Box::new(SilentEmitter::new_silent())); + assert!(convert_solar_errors(&dcx).is_ok()); + } } diff --git a/crates/common/src/evm.rs b/crates/common/src/evm.rs deleted file mode 100644 index 90f973a7d06c5..0000000000000 --- a/crates/common/src/evm.rs +++ /dev/null @@ -1,333 +0,0 @@ -//! CLI arguments for configuring the EVM settings. - -use alloy_primitives::{Address, B256, U256, map::HashMap}; -use clap::Parser; -use eyre::ContextCompat; -use foundry_config::{ - Chain, Config, - figment::{ - self, Metadata, Profile, Provider, - error::Kind::InvalidType, - value::{Dict, Map, Value}, - }, -}; -use serde::Serialize; - -use crate::shell; - -/// Map keyed by breakpoints char to their location (contract address, pc) -pub type Breakpoints = HashMap; - -/// `EvmArgs` and `EnvArgs` take the highest precedence in the Config/Figment hierarchy. -/// -/// All vars are opt-in, their default values are expected to be set by the -/// [`foundry_config::Config`], and are always present ([`foundry_config::Config::default`]) -/// -/// Both have corresponding types in the `evm_adapters` crate which have mandatory fields. -/// The expected workflow is -/// 1. load the [`foundry_config::Config`] -/// 2. merge with `EvmArgs` into a `figment::Figment` -/// 3. extract `evm_adapters::Opts` from the merged `Figment` -/// -/// # Example -/// -/// ```ignore -/// use foundry_config::Config; -/// use forge::executor::opts::EvmOpts; -/// use foundry_common::evm::EvmArgs; -/// # fn t(args: EvmArgs) { -/// let figment = Config::figment_with_root(".").merge(args); -/// let opts = figment.extract::().unwrap(); -/// # } -/// ``` -#[derive(Clone, Debug, Default, Serialize, Parser)] -#[command(next_help_heading = "EVM options", about = None, long_about = None)] // override doc -pub struct EvmArgs { - /// Fetch state over a remote endpoint instead of starting from an empty state. - /// - /// If you want to fetch state from a specific block number, see --fork-block-number. - #[arg(long, short, visible_alias = "rpc-url", value_name = "URL")] - #[serde(rename = "eth_rpc_url", skip_serializing_if = "Option::is_none")] - pub fork_url: Option, - - /// Fetch state from a specific block number over a remote endpoint. - /// - /// See --fork-url. - #[arg(long, requires = "fork_url", value_name = "BLOCK")] - #[serde(skip_serializing_if = "Option::is_none")] - pub fork_block_number: Option, - - /// Number of retries. - /// - /// See --fork-url. - #[arg(long, requires = "fork_url", value_name = "RETRIES")] - #[serde(skip_serializing_if = "Option::is_none")] - pub fork_retries: Option, - - /// Initial retry backoff on encountering errors. - /// - /// See --fork-url. - #[arg(long, requires = "fork_url", value_name = "BACKOFF")] - #[serde(skip_serializing_if = "Option::is_none")] - pub fork_retry_backoff: Option, - - /// Explicitly disables the use of RPC caching. - /// - /// All storage slots are read entirely from the endpoint. - /// - /// This flag overrides the project's configuration file. - /// - /// See --fork-url. - #[arg(long)] - #[serde(skip)] - pub no_storage_caching: bool, - - /// The initial balance of deployed test contracts. - #[arg(long, value_name = "BALANCE")] - #[serde(skip_serializing_if = "Option::is_none")] - pub initial_balance: Option, - - /// The address which will be executing tests/scripts. - #[arg(long, value_name = "ADDRESS")] - #[serde(skip_serializing_if = "Option::is_none")] - pub sender: Option
, - - /// Enable the FFI cheatcode. - #[arg(long)] - #[serde(skip)] - pub ffi: bool, - - /// Use the create 2 factory in all cases including tests and non-broadcasting scripts. - #[arg(long)] - #[serde(skip)] - pub always_use_create_2_factory: bool, - - /// The CREATE2 deployer address to use, this will override the one in the config. - #[arg(long, value_name = "ADDRESS")] - #[serde(skip_serializing_if = "Option::is_none")] - pub create2_deployer: Option
, - - /// Sets the number of assumed available compute units per second for this provider - /// - /// default value: 330 - /// - /// See also --fork-url and - #[arg(long, alias = "cups", value_name = "CUPS", help_heading = "Fork config")] - pub compute_units_per_second: Option, - - /// Disables rate limiting for this node's provider. - /// - /// See also --fork-url and - #[arg( - long, - value_name = "NO_RATE_LIMITS", - help_heading = "Fork config", - visible_alias = "no-rate-limit" - )] - #[serde(skip)] - pub no_rpc_rate_limit: bool, - - /// All ethereum environment related arguments - #[command(flatten)] - #[serde(flatten)] - pub env: EnvArgs, - - /// Whether to enable isolation of calls. - /// In isolation mode all top-level calls are executed as a separate transaction in a separate - /// EVM context, enabling more precise gas accounting and transaction state changes. - #[arg(long)] - #[serde(skip)] - pub isolate: bool, - - /// Whether to enable Odyssey features. - #[arg(long, alias = "alphanet")] - #[serde(skip)] - pub odyssey: bool, -} - -// Make this set of options a `figment::Provider` so that it can be merged into the `Config` -impl Provider for EvmArgs { - fn metadata(&self) -> Metadata { - Metadata::named("Evm Opts Provider") - } - - fn data(&self) -> Result, figment::Error> { - let value = Value::serialize(self)?; - let error = InvalidType(value.to_actual(), "map".into()); - let mut dict = value.into_dict().ok_or(error)?; - - if shell::verbosity() > 0 { - // need to merge that manually otherwise `from_occurrences` does not work - dict.insert("verbosity".to_string(), shell::verbosity().into()); - } - - if self.ffi { - dict.insert("ffi".to_string(), self.ffi.into()); - } - - if self.isolate { - dict.insert("isolate".to_string(), self.isolate.into()); - } - - if self.odyssey { - dict.insert("odyssey".to_string(), self.odyssey.into()); - } - - if self.always_use_create_2_factory { - dict.insert( - "always_use_create_2_factory".to_string(), - self.always_use_create_2_factory.into(), - ); - } - - if self.no_storage_caching { - dict.insert("no_storage_caching".to_string(), self.no_storage_caching.into()); - } - - if self.no_rpc_rate_limit { - dict.insert("no_rpc_rate_limit".to_string(), self.no_rpc_rate_limit.into()); - } - - if let Some(fork_url) = &self.fork_url { - dict.insert("eth_rpc_url".to_string(), fork_url.clone().into()); - } - - Ok(Map::from([(Config::selected_profile(), dict)])) - } -} - -/// Configures the executor environment during tests. -#[derive(Clone, Debug, Default, Serialize, Parser)] -#[command(next_help_heading = "Executor environment config")] -pub struct EnvArgs { - /// EIP-170: Contract code size limit in bytes. Useful to increase this because of tests. By - /// default, it is 0x6000 (~25kb). - #[arg(long, value_name = "CODE_SIZE")] - #[serde(skip_serializing_if = "Option::is_none")] - pub code_size_limit: Option, - - /// The chain name or EIP-155 chain ID. - #[arg(long, visible_alias = "chain-id", value_name = "CHAIN")] - #[serde(rename = "chain_id", skip_serializing_if = "Option::is_none", serialize_with = "id")] - pub chain: Option, - - /// The gas price. - #[arg(long, value_name = "GAS_PRICE")] - #[serde(skip_serializing_if = "Option::is_none")] - pub gas_price: Option, - - /// The base fee in a block. - #[arg(long, visible_alias = "base-fee", value_name = "FEE")] - #[serde(skip_serializing_if = "Option::is_none")] - pub block_base_fee_per_gas: Option, - - /// The transaction origin. - #[arg(long, value_name = "ADDRESS")] - #[serde(skip_serializing_if = "Option::is_none")] - pub tx_origin: Option
, - - /// The coinbase of the block. - #[arg(long, value_name = "ADDRESS")] - #[serde(skip_serializing_if = "Option::is_none")] - pub block_coinbase: Option
, - - /// The timestamp of the block. - #[arg(long, value_name = "TIMESTAMP")] - #[serde(skip_serializing_if = "Option::is_none")] - pub block_timestamp: Option, - - /// The block number. - #[arg(long, value_name = "BLOCK")] - #[serde(skip_serializing_if = "Option::is_none")] - pub block_number: Option, - - /// The block difficulty. - #[arg(long, value_name = "DIFFICULTY")] - #[serde(skip_serializing_if = "Option::is_none")] - pub block_difficulty: Option, - - /// The block prevrandao value. NOTE: Before merge this field was mix_hash. - #[arg(long, value_name = "PREVRANDAO")] - #[serde(skip_serializing_if = "Option::is_none")] - pub block_prevrandao: Option, - - /// The block gas limit. - #[arg(long, visible_alias = "gas-limit", value_name = "GAS_LIMIT")] - #[serde(skip_serializing_if = "Option::is_none")] - pub block_gas_limit: Option, - - /// The memory limit per EVM execution in bytes. - /// If this limit is exceeded, a `MemoryLimitOOG` result is thrown. - /// - /// The default is 128MiB. - #[arg(long, value_name = "MEMORY_LIMIT")] - #[serde(skip_serializing_if = "Option::is_none")] - pub memory_limit: Option, - - /// Whether to disable the block gas limit checks. - #[arg(long, visible_alias = "no-gas-limit")] - #[serde(skip_serializing_if = "std::ops::Not::not")] - pub disable_block_gas_limit: bool, -} - -impl EvmArgs { - /// Ensures that fork url exists and returns its reference. - pub fn ensure_fork_url(&self) -> eyre::Result<&String> { - self.fork_url.as_ref().wrap_err("Missing `--fork-url` field.") - } -} - -/// We have to serialize chain IDs and not names because when extracting an EVM `Env`, it expects -/// `chain_id` to be `u64`. -fn id(chain: &Option, s: S) -> Result { - if let Some(chain) = chain { - s.serialize_u64(chain.id()) - } else { - // skip_serializing_if = "Option::is_none" should prevent this branch from being taken - unreachable!() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use foundry_config::NamedChain; - - #[test] - fn can_parse_chain_id() { - let args = EvmArgs { - env: EnvArgs { chain: Some(NamedChain::Mainnet.into()), ..Default::default() }, - ..Default::default() - }; - let config = Config::from_provider(Config::figment().merge(args)).unwrap(); - assert_eq!(config.chain, Some(NamedChain::Mainnet.into())); - - let env = EnvArgs::parse_from(["foundry-common", "--chain-id", "goerli"]); - assert_eq!(env.chain, Some(NamedChain::Goerli.into())); - } - - #[test] - fn test_memory_limit() { - let args = EvmArgs { - env: EnvArgs { chain: Some(NamedChain::Mainnet.into()), ..Default::default() }, - ..Default::default() - }; - let config = Config::from_provider(Config::figment().merge(args)).unwrap(); - assert_eq!(config.memory_limit, Config::default().memory_limit); - - let env = EnvArgs::parse_from(["foundry-common", "--memory-limit", "100"]); - assert_eq!(env.memory_limit, Some(100)); - } - - #[test] - fn test_chain_id() { - let env = EnvArgs::parse_from(["foundry-common", "--chain-id", "1"]); - assert_eq!(env.chain, Some(Chain::mainnet())); - - let env = EnvArgs::parse_from(["foundry-common", "--chain-id", "mainnet"]); - assert_eq!(env.chain, Some(Chain::mainnet())); - let args = EvmArgs { env, ..Default::default() }; - let config = Config::from_provider(Config::figment().merge(args)).unwrap(); - assert_eq!(config.chain, Some(Chain::mainnet())); - } -} diff --git a/crates/common/src/external_compiler.rs b/crates/common/src/external_compiler.rs new file mode 100644 index 0000000000000..39b2ec11da0fc --- /dev/null +++ b/crates/common/src/external_compiler.rs @@ -0,0 +1,870 @@ +//! External compiler adapter protocol and process transport. + +use crate::fs::canonicalize_path; +use alloy_primitives::hex; +use eyre::{Context, ContextCompat, Result, bail, ensure}; +use foundry_compilers::{ + ArtifactFile, ArtifactOutput, Artifacts, ConfigurableArtifacts, ProjectCompileOutput, + artifacts::{ConfigurableContractArtifact, Severity, contract::Contract}, + compilers::{Compiler, Language, multi::MultiCompilerLanguage}, + contracts::{VersionedContract, VersionedContracts}, +}; +use foundry_config::{Config, DenyLevel, ExternalCompiler}; +use semver::Version; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::{ + collections::{BTreeMap, BTreeSet}, + ffi::OsStr, + fs::{self, File}, + io::{BufRead, BufReader, Read, Write}, + path::{Component, Path, PathBuf}, + process::{Child, ChildStdin, ChildStdout, Command, Stdio}, + thread::JoinHandle, +}; + +const PROTOCOL_VERSION: &str = "1.0"; +const MAX_PROTOCOL_LINE_BYTES: u64 = 16 * 1024 * 1024; +const EXTERNAL_CACHE_DIR: &str = "external-compilers"; +const EXTERNAL_ARTIFACT_DIR: &str = ".external"; + +/// External artifacts and the unit inventory used to publish them. +pub(crate) struct ExternalCompilation<'a> { + config: &'a Config, + artifacts: Artifacts, + contracts: VersionedContracts, + write_outputs: bool, + complete_discovery: bool, + active_units: BTreeMap>, + pending_cache: Vec, +} + +impl<'a> ExternalCompilation<'a> { + /// Runs adapters, staging their output until the built-in compilation succeeds. + pub(crate) fn compile( + config: &'a Config, + selected_paths: &[PathBuf], + write_outputs: bool, + ) -> Result { + let mut compilation = Self { + config, + artifacts: Artifacts::default(), + contracts: VersionedContracts::default(), + write_outputs, + complete_discovery: selected_paths.is_empty(), + active_units: BTreeMap::new(), + pending_cache: Vec::new(), + }; + let mut adapter_keys = BTreeSet::new(); + for adapter in &config.external_compilers { + ensure!( + adapter_keys.insert(adapter.id.to_ascii_lowercase()), + "duplicate or case-insensitive external compiler adapter ID `{}`", + adapter.id + ); + ensure_portable_child(&config.out.join(EXTERNAL_ARTIFACT_DIR), &adapter.id, None)?; + ensure_portable_child(&config.cache_path.join(EXTERNAL_CACHE_DIR), &adapter.id, None)?; + AdapterClient::new(config, adapter, selected_paths)?.compile(&mut compilation)?; + } + Ok(compilation) + } + + /// Merges validated external artifacts into a built-in compilation result. + pub(crate) fn merge(self, output: &mut ProjectCompileOutput) -> Result<()> + where + C: Compiler, + { + for (source, contracts) in &self.artifacts { + for name in contracts.keys() { + ensure!( + output.find(source, name).is_none(), + "external compiler artifact conflicts with built-in artifact {}:{name}", + source.display() + ); + } + } + + if self.write_outputs { + let output_root = self.config.out.join(EXTERNAL_ARTIFACT_DIR); + let cache_root = self.config.cache_path.join(EXTERNAL_CACHE_DIR); + let active_adapters = self.active_units.keys().cloned().collect(); + if self.config.cache { + retire_children(&cache_root, &active_adapters, None)?; + if self.complete_discovery { + for (adapter, units) in &self.active_units { + retire_children(&cache_root.join(adapter), units, Some("json"))?; + } + } + for cache in &self.pending_cache { + write_cache(&cache.path, &cache.contents)?; + } + } + retire_children(&output_root, &active_adapters, None)?; + for (adapter, units) in &self.active_units { + let adapter_root = output_root.join(adapter); + if self.complete_discovery { + retire_children(&adapter_root, units, None)?; + } + for unit in units { + let unit_root = adapter_root.join(unit); + if unit_root.exists() { + fs::remove_dir_all(unit_root)?; + } + } + } + self.artifacts.write_all()?; + } + + if self.artifacts.is_empty() { + return Ok(()); + } + let mut merged = output.compiled_artifacts().clone(); + for (source, contracts) in self.artifacts { + merged.as_mut().entry(source).or_default().extend(contracts); + } + for (source, contracts) in self.contracts { + output.output_mut().contracts.as_mut().entry(source).or_default().extend(contracts); + } + output.set_compiled_artifacts(merged); + Ok(()) + } +} + +struct AdapterClient<'a> { + config: &'a Config, + cache_root: PathBuf, + adapter: &'a ExternalCompiler, + selected_paths: &'a [PathBuf], + command: PathBuf, +} + +impl<'a> AdapterClient<'a> { + fn new( + config: &'a Config, + adapter: &'a ExternalCompiler, + selected_paths: &'a [PathBuf], + ) -> Result { + validate_id("adapter", &adapter.id)?; + ensure!(!adapter.roots.is_empty(), "external compiler `{}` has no roots", adapter.id); + let command = resolve_file(&config.root, &adapter.command).wrap_err_with(|| { + format!("failed to resolve external compiler adapter `{}`", adapter.id) + })?; + Ok(Self { + config, + cache_root: config.cache_path.join(EXTERNAL_CACHE_DIR).join(&adapter.id), + adapter, + selected_paths, + command, + }) + } + + fn compile(&self, output: &mut ExternalCompilation<'_>) -> Result<()> { + let mut process = + AdapterProcess::spawn(&self.config.root, &self.command, &self.adapter.args) + .wrap_err_with(|| { + format!("failed to start external compiler `{}`", self.adapter.id) + })?; + let result = self.compile_units(output, &mut process); + process.finish(result) + } + + fn compile_units( + &self, + output: &mut ExternalCompilation<'_>, + process: &mut AdapterProcess, + ) -> Result<()> { + let initialized: InitializeResult = process.request( + "initialize", + json!({ + "protocols": [PROTOCOL_VERSION], + "host": {"name": "forge", "version": env!("CARGO_PKG_VERSION")}, + "target": "evm", + }), + )?; + ensure!( + initialized.protocol == PROTOCOL_VERSION, + "external compiler `{}` selected unsupported protocol `{}`", + self.adapter.id, + initialized.protocol + ); + + let roots = self + .adapter + .roots + .iter() + .map(|path| normalize_root_path(path).map(|path| self.config.root.join(path))) + .collect::>>()?; + let discovery: DiscoverResult = process.request( + "discover", + json!({ + "roots": roots, + "settings": self.adapter.settings, + "selected_paths": self.selected_paths, + }), + )?; + + let mut active_units = BTreeSet::new(); + let mut unit_keys = BTreeSet::new(); + for unit in discovery.units { + validate_id("build unit", &unit.id)?; + ensure!( + active_units.insert(unit.id.clone()), + "external compiler `{}` returned duplicate unit ID `{}`", + self.adapter.id, + unit.id + ); + ensure!( + !unit.compiler.name.is_empty(), + "external compiler `{}` unit `{}` returned an empty compiler name", + self.adapter.id, + unit.id + ); + ensure!( + unit_keys.insert(unit.id.to_ascii_lowercase()), + "external compiler `{}` unit ID `{}` collides on case-insensitive filesystems", + self.adapter.id, + unit.id + ); + ensure_portable_child(&self.cache_root, &unit.id, Some("json"))?; + ensure_portable_child( + &self.config.out.join(EXTERNAL_ARTIFACT_DIR).join(&self.adapter.id), + &unit.id, + None, + )?; + ensure!( + unit.capabilities.contains("build/1"), + "external compiler `{}` unit `{}` does not support `build/1`", + self.adapter.id, + unit.id + ); + let fingerprint = self.fingerprint(&unit)?; + let cache_path = self.cache_root.join(format!("{}.json", unit.id)); + let result = if self.config.cache && !self.config.force && unit.cacheable { + read_cache(&cache_path, &fingerprint)? + } else { + None + }; + let (result, fresh) = match result { + Some(result) => (result, false), + None => { + let result: CompileResult = process + .request("compile", json!({"unit": unit.id, "fingerprint": fingerprint}))?; + (result, true) + } + }; + emit_diagnostics(&self.adapter.id, &unit.id, &result.diagnostics, self.config.deny)?; + let cache = (output.write_outputs && fresh && self.config.cache && unit.cacheable) + .then(|| { + serde_json::to_vec(&CacheEntry { + fingerprint: fingerprint.clone(), + result: &result, + }) + }) + .transpose()?; + self.add_artifacts(output, &unit, &fingerprint, result, fresh)?; + if let Some(contents) = cache { + output.pending_cache.push(PendingCache { path: cache_path, contents }); + } + } + + output.active_units.insert(self.adapter.id.clone(), active_units); + Ok(()) + } + + fn fingerprint(&self, unit: &DiscoveredUnit) -> Result { + let mut hasher = Sha256::new(); + hash_part(&mut hasher, PROTOCOL_VERSION.as_bytes()); + hash_part(&mut hasher, self.command.as_os_str().as_encoded_bytes()); + hash_part(&mut hasher, &fs::read(&self.command)?); + hash_part(&mut hasher, &serde_json::to_vec(&self.adapter.args)?); + hash_part(&mut hasher, &serde_json::to_vec(&self.adapter.settings)?); + hash_part(&mut hasher, &serde_json::to_vec(unit)?); + + let mut inputs = unit + .inputs + .iter() + .map(|path| resolve_file(&self.config.root, path)) + .collect::>>()?; + inputs.sort(); + inputs.dedup(); + for input in inputs { + hash_part(&mut hasher, input.as_os_str().as_encoded_bytes()); + hash_part(&mut hasher, &fs::read(&input)?); + } + Ok(hex::encode(hasher.finalize())) + } + + fn add_artifacts( + &self, + output: &mut ExternalCompilation<'_>, + unit: &DiscoveredUnit, + fingerprint: &str, + result: CompileResult, + fresh: bool, + ) -> Result<()> { + let version = Version::parse(&unit.compiler.version).wrap_err_with(|| { + format!( + "external compiler `{}` unit `{}` returned non-SemVer compiler version `{}`", + self.adapter.id, unit.id, unit.compiler.version + ) + })?; + let unit_out = + self.config.out.join(EXTERNAL_ARTIFACT_DIR).join(&self.adapter.id).join(&unit.id); + let mut artifact_paths = BTreeSet::new(); + for artifact in result.artifacts { + let name = artifact.name.clone(); + validate_id("contract", &name)?; + ensure!( + !name.contains(['.', '-']), + "external compiler contract name must contain only ASCII letters, digits, or underscores: {name}" + ); + let source = validate_relative_path("source unit", &artifact.source)?.to_path_buf(); + let source_path = resolve_file(&self.config.root, &source)?; + ensure!( + output + .artifacts + .get(&source_path) + .and_then(|existing| existing.get(&name)) + .is_none(), + "external compiler `{}` returned duplicate artifact {}:{}", + self.adapter.id, + source.display(), + name + ); + let artifact_path = unit_out.join(&source).join(format!("{name}.json")); + let relative_artifact_path = source.join(format!("{name}.json")); + ensure!( + artifact_paths + .insert(relative_artifact_path.to_string_lossy().to_ascii_lowercase()), + "external compiler `{}` unit `{}` artifact path `{}` collides on case-insensitive filesystems", + self.adapter.id, + unit.id, + relative_artifact_path.display() + ); + let (contract, compiler_contract) = artifact.into_foundry_outputs()?; + let build_id = format!( + "external:{}:{}:{}:{fingerprint}", + self.adapter.id, + unit.id, + if unit.capabilities.contains("forge-tests/1") { "forge-tests" } else { "build" } + ); + if fresh { + output + .contracts + .as_mut() + .entry(source_path.clone()) + .or_default() + .entry(name.clone()) + .or_default() + .push(VersionedContract { + contract: compiler_contract, + version: version.clone(), + build_id: build_id.clone(), + profile: self.config.profile.to_string(), + }); + } + output + .artifacts + .as_mut() + .entry(source_path) + .or_default() + .entry(name) + .or_default() + .push(ArtifactFile { + artifact: contract, + file: artifact_path, + version: version.clone(), + build_id, + profile: self.config.profile.to_string(), + }); + } + Ok(()) + } +} + +struct AdapterProcess { + child: Child, + stdin: Option, + stdout: BufReader, + stderr: Option>>>, + request_id: u64, +} + +impl AdapterProcess { + fn spawn(root: &Path, command: &Path, args: &[String]) -> Result { + let mut child = Command::new(command) + .args(args) + .current_dir(root) + .env_clear() + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + let stdin = child.stdin.take().context("adapter stdin was not piped")?; + let stdout = child.stdout.take().context("adapter stdout was not piped")?; + let mut child_stderr = child.stderr.take().context("adapter stderr was not piped")?; + let stderr = std::thread::spawn(move || { + let mut bytes = Vec::new(); + let mut chunk = [0_u8; 8192]; + loop { + let read = child_stderr.read(&mut chunk)?; + if read == 0 { + break; + } + let remaining = MAX_PROTOCOL_LINE_BYTES.saturating_sub(bytes.len() as u64) as usize; + bytes.extend_from_slice(&chunk[..read.min(remaining)]); + } + Ok(bytes) + }); + Ok(Self { + child, + stdin: Some(stdin), + stdout: BufReader::new(stdout), + stderr: Some(stderr), + request_id: 0, + }) + } + + fn request( + &mut self, + method: &'static str, + params: P, + ) -> Result { + self.request_id += 1; + let request = Request { id: self.request_id, method, params }; + let stdin = self.stdin.as_mut().context("adapter stdin is closed")?; + serde_json::to_writer(&mut *stdin, &request)?; + stdin.write_all(b"\n")?; + stdin.flush()?; + + let mut line = String::new(); + self.stdout + .by_ref() + .take(MAX_PROTOCOL_LINE_BYTES + 1) + .read_line(&mut line) + .wrap_err_with(|| format!("failed reading `{method}` response"))?; + ensure!(!line.is_empty(), "adapter exited before responding to `{method}`"); + ensure!( + line.len() as u64 <= MAX_PROTOCOL_LINE_BYTES, + "adapter `{method}` response exceeds {MAX_PROTOCOL_LINE_BYTES} bytes" + ); + let response: Response = serde_json::from_str(&line) + .wrap_err_with(|| format!("invalid adapter response to `{method}`"))?; + ensure!(response.id == self.request_id, "adapter response ID does not match request"); + match (response.result, response.error) { + (Some(result), None) => Ok(result), + (None, Some(error)) => bail!("adapter error {}: {}", error.code, error.message), + _ => bail!("adapter response must contain exactly one of `result` or `error`"), + } + } + + fn finish(mut self, result: Result<()>) -> Result<()> { + drop(self.stdin.take()); + if result.is_err() { + let _ = self.child.kill(); + } + let status = self.child.wait()?; + let stderr = self + .stderr + .take() + .context("adapter stderr reader missing")? + .join() + .map_err(|_| eyre::eyre!("adapter stderr reader panicked"))??; + if stderr.is_empty() { + result?; + } else { + result.wrap_err_with(|| { + format!("adapter stderr: {}", String::from_utf8_lossy(&stderr).trim_end()) + })?; + } + ensure!( + status.success(), + "adapter exited with {status}: {}", + String::from_utf8_lossy(&stderr) + ); + Ok(()) + } +} + +impl Drop for AdapterProcess { + fn drop(&mut self) { + if self.child.try_wait().ok().flatten().is_none() { + let _ = self.child.kill(); + let _ = self.child.wait(); + } + } +} + +#[derive(Serialize)] +struct Request

{ + id: u64, + method: &'static str, + params: P, +} + +#[derive(Deserialize)] +struct Response { + id: u64, + result: Option, + error: Option, +} + +#[derive(Deserialize)] +struct ProtocolError { + code: String, + message: String, +} + +#[derive(Deserialize)] +struct InitializeResult { + protocol: String, +} + +#[derive(Deserialize)] +struct DiscoverResult { + units: Vec, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct DiscoveredUnit { + id: String, + compiler: CompilerIdentity, + inputs: Vec, + #[serde(default)] + capabilities: BTreeSet, + #[serde(default)] + effective_settings: Value, + #[serde(default)] + cacheable: bool, +} + +#[derive(Deserialize, Serialize)] +struct CompilerIdentity { + name: String, + version: String, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct CompileResult { + #[serde(default)] + diagnostics: Vec, + #[serde(default)] + artifacts: Vec, +} + +#[derive(Deserialize, Serialize)] +struct ExternalDiagnostic { + severity: Severity, + message: String, + #[serde(default)] + code: Option, + #[serde(default)] + source: Option, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct ExternalArtifact { + source: PathBuf, + name: String, + contract: Contract, + #[serde(default)] + metadata: Option, + #[serde(default)] + source_id: Option, +} + +impl ExternalArtifact { + fn into_foundry_outputs(mut self) -> Result<(ConfigurableContractArtifact, Contract)> { + let method_identifiers = self + .contract + .abi + .get_or_insert_with(Default::default) + .functions() + .map(|function| (function.signature(), hex::encode(function.selector()))) + .collect::>(); + if let Some(evm) = &mut self.contract.evm { + ensure!( + evm.bytecode.is_some() || evm.deployed_bytecode.is_none(), + "external artifact {} has runtime bytecode without creation bytecode", + self.name + ); + for bytecode in evm.bytecode.iter().chain( + evm.deployed_bytecode.iter().filter_map(|deployed| deployed.bytecode.as_ref()), + ) { + ensure!( + bytecode.object.is_bytecode() && bytecode.link_references.is_empty(), + "external artifact {} must provide fully linked bytecode", + self.name + ); + } + evm.method_identifiers = method_identifiers.clone(); + } + let mut artifact = ConfigurableArtifacts::default().contract_to_artifact( + &self.source, + &self.name, + self.contract.clone(), + None, + ); + artifact.raw_metadata = self.metadata.as_ref().map(serde_json::to_string).transpose()?; + artifact.id = self.source_id; + artifact.method_identifiers = Some(method_identifiers); + Ok((artifact, self.contract)) + } +} + +#[derive(Deserialize, Serialize)] +struct CacheEntry { + fingerprint: String, + result: R, +} + +struct PendingCache { + path: PathBuf, + contents: Vec, +} + +/// Returns whether an artifact can be selected as a Forge test contract. +pub fn external_artifact_is_test_eligible(build_id: &str) -> bool { + !is_external_artifact(build_id) + || build_id.split(':').nth(3).is_some_and(|role| role == "forge-tests") +} + +/// Returns whether an artifact was produced by an external compiler adapter. +pub fn is_external_artifact(build_id: &str) -> bool { + build_id.starts_with("external:") +} + +/// Returns whether a path belongs to a compiler built into Foundry. +pub fn is_builtin_compiler_source(path: &Path) -> bool { + path.extension().and_then(OsStr::to_str).is_some_and(|extension| { + MultiCompilerLanguage::FILE_EXTENSIONS + .iter() + .any(|candidate| extension.eq_ignore_ascii_case(candidate)) + }) +} + +fn emit_diagnostics( + adapter: &str, + unit: &str, + diagnostics: &[ExternalDiagnostic], + deny: DenyLevel, +) -> Result<()> { + let mut errors = Vec::new(); + for diagnostic in diagnostics { + let code = diagnostic.code.as_deref().map(|code| format!(" [{code}]")).unwrap_or_default(); + let source = diagnostic + .source + .as_ref() + .map(|source| format!(" {}", source.display())) + .unwrap_or_default(); + let message = format!( + "external compiler `{adapter}` unit `{unit}`{code}{source}: {}", + diagnostic.message + ); + match diagnostic.severity { + Severity::Error => errors.push(message), + Severity::Warning if deny.warnings() => errors.push(message), + Severity::Warning => sh_warn!("{message}")?, + Severity::Info => tracing::info!("{message}"), + } + } + ensure!(errors.is_empty(), "{}", errors.join("\n")); + Ok(()) +} + +fn read_cache(path: &Path, fingerprint: &str) -> Result> { + let Ok(file) = File::open(path) else { return Ok(None) }; + let entry: CacheEntry = serde_json::from_reader(file) + .wrap_err_with(|| format!("failed to read external compiler cache {}", path.display()))?; + if entry.fingerprint != fingerprint { + return Ok(None); + } + Ok(Some(serde_json::from_value(entry.result)?)) +} + +fn write_cache(path: &Path, contents: &[u8]) -> Result<()> { + let parent = path.parent().context("external compiler cache path has no parent")?; + fs::create_dir_all(parent)?; + let mut temp = tempfile::NamedTempFile::new_in(parent)?; + temp.write_all(contents)?; + temp.as_file_mut().flush()?; + temp.persist(path).map_err(|error| error.error)?; + Ok(()) +} + +fn storage_entries(root: &Path) -> Result> { + match fs::symlink_metadata(root) { + Ok(metadata) => { + ensure!( + !metadata.is_symlink(), + "external compiler storage path is a symlink: {}", + root.display() + ); + Ok(Some(fs::read_dir(root)?)) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error.into()), + } +} + +fn retire_children(root: &Path, active: &BTreeSet, extension: Option<&str>) -> Result<()> { + let Some(entries) = storage_entries(root)? else { return Ok(()) }; + for entry in entries { + let entry = entry?; + let path = entry.path(); + let name = if let Some(extension) = extension { + if path.extension() != Some(OsStr::new(extension)) { + continue; + } + path.file_stem() + } else { + path.file_name() + }; + if name.is_some_and(|name| !active.contains(name.to_string_lossy().as_ref())) { + if entry.file_type()?.is_dir() { + fs::remove_dir_all(path)?; + } else { + fs::remove_file(path)?; + } + } + } + Ok(()) +} + +fn ensure_portable_child(root: &Path, name: &str, extension: Option<&str>) -> Result<()> { + let Some(entries) = storage_entries(root)? else { return Ok(()) }; + for entry in entries { + let entry = entry?; + let path = entry.path(); + let Some(candidate) = (if let Some(extension) = extension { + (path.extension() == Some(OsStr::new(extension))).then(|| path.file_stem()).flatten() + } else { + path.file_name() + }) else { + continue; + }; + let candidate = candidate.to_string_lossy(); + ensure!( + candidate != name || !entry.file_type()?.is_symlink(), + "external compiler storage path is a symlink: {}", + path.display() + ); + ensure!( + candidate == name || !candidate.eq_ignore_ascii_case(name), + "external compiler storage namespace `{name}` collides with existing `{candidate}` on case-insensitive filesystems" + ); + } + Ok(()) +} + +fn validate_id(kind: &str, value: &str) -> Result<()> { + ensure!(!value.is_empty(), "external compiler {kind} ID cannot be empty"); + ensure!( + !matches!(value, "." | "..") + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')), + "invalid external compiler {kind} ID `{value}`" + ); + Ok(()) +} + +fn validate_relative_path<'a>(kind: &str, path: &'a Path) -> Result<&'a Path> { + ensure!(!path.as_os_str().is_empty(), "external compiler {kind} cannot be empty"); + ensure!(path.is_relative(), "external compiler {kind} must be relative: {}", path.display()); + ensure!( + path.components().all(|component| matches!(component, Component::Normal(_))), + "external compiler {kind} contains an unsafe component: {}", + path.display() + ); + Ok(path) +} + +fn normalize_root_path(path: &Path) -> Result<&Path> { + if path == Path::new(".") { Ok(Path::new("")) } else { validate_relative_path("root", path) } +} + +fn resolve_file(root: &Path, path: &Path) -> Result { + let path = if path.is_absolute() { path.to_path_buf() } else { root.join(path) }; + let path = canonicalize_path(&path) + .wrap_err_with(|| format!("failed to canonicalize {}", path.display()))?; + ensure!(path.is_file(), "external compiler input is not a file: {}", path.display()); + Ok(path) +} + +fn hash_part(hasher: &mut Sha256, bytes: &[u8]) { + hasher.update((bytes.len() as u64).to_le_bytes()); + hasher.update(bytes); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(unix)] + use std::os::unix::fs::symlink; + + #[test] + fn rejects_unsafe_protocol_paths_and_ids() { + assert!(validate_relative_path("source", Path::new("src/main.fe")).is_ok()); + assert!(validate_relative_path("source", Path::new("../outside.fe")).is_err()); + assert!(validate_relative_path("source", Path::new("/outside.fe")).is_err()); + assert_eq!(normalize_root_path(Path::new(".")).unwrap(), Path::new("")); + assert!(validate_id("unit", "app-1").is_ok()); + assert!(validate_id("unit", ".").is_err()); + assert!(validate_id("unit", "..").is_err()); + assert!(validate_id("unit", "../app").is_err()); + } + + #[test] + fn rejects_case_insensitive_storage_collisions() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir(dir.path().join("App")).unwrap(); + assert!(ensure_portable_child(dir.path(), "app", None).is_err()); + ensure_portable_child(dir.path(), "App", None).unwrap(); + + fs::write(dir.path().join("Build.json"), "{}").unwrap(); + assert!(ensure_portable_child(dir.path(), "build", Some("json")).is_err()); + ensure_portable_child(dir.path(), "Build", Some("json")).unwrap(); + } + + #[cfg(unix)] + #[test] + fn rejects_linked_storage() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("target"); + fs::create_dir(&target).unwrap(); + let sentinel = target.join("keep"); + fs::write(&sentinel, "unchanged").unwrap(); + let link = dir.path().join("linked"); + symlink(&target, &link).unwrap(); + + assert!(retire_children(&link, &Default::default(), None).is_err()); + assert!(ensure_portable_child(dir.path(), "linked", None).is_err()); + assert_eq!(fs::read_to_string(sentinel).unwrap(), "unchanged"); + } + + #[test] + fn rejects_unlinked_bytecode() { + for field in ["bytecode", "deployedBytecode"] { + let mut value = serde_json::json!({ + "source": "native/src/lib.fe", + "name": "Token", + "contract": {"evm": {"bytecode": {"object": "00"}}} + }); + value["contract"]["evm"][field] = serde_json::json!({ + "object": "0000000000000000000000000000000000000000", + "linkReferences": {"native/src/lib.fe": {"Library": [{"start": 0, "length": 20}]}} + }); + let artifact: ExternalArtifact = serde_json::from_value(value).unwrap(); + assert_eq!( + artifact.into_foundry_outputs().unwrap_err().to_string(), + "external artifact Token must provide fully linked bytecode" + ); + } + } +} diff --git a/crates/common/src/external_storage.rs b/crates/common/src/external_storage.rs new file mode 100644 index 0000000000000..019dd96d9da3e --- /dev/null +++ b/crates/common/src/external_storage.rs @@ -0,0 +1,745 @@ +//! Storage layouts for contracts that are not part of the local project. +//! +//! When a forked test touches a contract that isn't in the local artifacts, its storage slots can +//! still be decoded by compiling the verified source a block explorer has for it. +//! +//! Finding that source is the caller's job — `foundry-evm-traces` already knows how to ask a block +//! explorer for it. This module owns what happens next: compiling it for a storage layout, which +//! costs a full `solc` invocation, and making sure that cost is paid once. +//! `forge test` runs test contracts in parallel, so lookups are: +//! +//! - deduplicated process-wide by `LOOKUPS`, so concurrent tests touching the same contract compile +//! it once instead of racing each other; +//! - persisted to disk once resolved, so later runs skip straight to the layout. +//! +//! Unverified responses are memoized only for this run so later runs can discover newly verified +//! contracts. + +use crate::fs; +use alloy_chains::Chain; +use alloy_primitives::{Address, map::AddressMap}; +use foundry_block_explorers::contract::Metadata; +use foundry_compilers::{ + artifacts::{ + CompilerOutput, SolcInput, SolcLanguage, Source, Sources, StorageLayout, + output_selection::OutputSelection, + }, + solc::Solc, +}; +use foundry_config::Config; +use serde::{Deserialize, Serialize}; +use std::{ + io::{Read, Seek, SeekFrom}, + path::{Path, PathBuf}, + process::{Command, Stdio}, + sync::{ + Arc, LazyLock, Mutex, MutexGuard, TryLockError, + atomic::{AtomicU64, Ordering}, + }, + time::{Duration, Instant}, +}; +use wait_timeout::ChildExt; + +/// First solc release that emits `storageLayout`. Matches the floor `cast storage` enforces. +const MIN_STORAGE_LAYOUT_SOLC: semver::Version = semver::Version::new(0, 6, 5); +/// First solc release with `--base-path` support. +const BASE_PATH_SOLC: semver::Version = semver::Version::new(0, 6, 9); +/// First solc release with `--no-import-callback` support. +const NO_IMPORT_CALLBACK_SOLC: semver::Version = semver::Version::new(0, 8, 22); + +/// A resolved storage layout, or `None` if the contract has no layout we can use. +/// +/// A `None` means the lookup concluded that there is nothing to decode with: the contract is +/// unverified, is Vyper, failed to compile, or compiled to an empty layout. It is memoized for +/// the rest of the run. +type ExternalStorageLayout = Option<(String, Arc)>; + +/// A single address' lookup slot: `None` until the lookup completes, then its memoized result. +type LookupSlot = Arc>>; + +/// Per-(chain, address) lookup slots, shared across the whole process. +/// +/// The outer mutex only guards the map; the work itself happens while holding the inner mutex of a +/// single entry, so lookups for different addresses still run concurrently while lookups for the +/// same address wait for the first one to finish and then reuse its result. +static LOOKUPS: LazyLock>> = + LazyLock::new(Default::default); + +/// Bounds expensive compiler installation and execution across parallel tests. +static COMPILER: Mutex<()> = Mutex::new(()); + +/// Makes cache publication paths unique within one process; the PID separates processes. +static CACHE_TEMP_ID: AtomicU64 = AtomicU64::new(0); + +/// Resolves the storage layouts of `addresses` on `chain`, compiling verified sources as needed. +/// +/// `fetch_sources` is only called for the addresses still unknown after the in-process and +/// on-disk caches have been consulted, so a warm run makes no network requests at all. For each +/// address it is handed, it returns that exact contract's verified source, or `None` if the block +/// explorer conclusively reports that there is none. +/// +/// An address `fetch_sources` leaves out is one it reached no conclusion about. Those are left +/// unresolved rather than remembered as having no layout, so a later call tries again instead of +/// letting one explorer outage disable decoding for the rest of the run. +/// +/// Addresses without a usable layout are absent from the returned map. +pub fn fetch_external_storage_layouts( + chain: Chain, + addresses: impl IntoIterator, + timeout: Duration, + fetch_sources: impl FnOnce(&[Address], Duration) -> AddressMap>, +) -> AddressMap<(String, Arc)> { + let cache_dir = Config::foundry_etherscan_chain_cache_dir(chain); + resolve(chain.id(), cache_dir.as_deref(), addresses, timeout, fetch_sources) +} + +/// [`fetch_external_storage_layouts`] with the cache location supplied, so tests can point it +/// somewhere other than the user's home directory. +fn resolve( + chain_id: u64, + cache_dir: Option<&Path>, + addresses: impl IntoIterator, + timeout: Duration, + fetch_sources: impl FnOnce(&[Address], Duration) -> AddressMap>, +) -> AddressMap<(String, Arc)> { + let deadline = Instant::now().checked_add(timeout).unwrap_or_else(Instant::now); + let mut resolved = AddressMap::default(); + + // Claim a lookup slot per address. Sorting keeps the acquisition order identical in every + // thread, so holding several slots at once cannot deadlock. + let mut addresses = addresses.into_iter().collect::>(); + addresses.sort_unstable(); + addresses.dedup(); + let slots = { + let Some(mut lookups) = lock_until(&LOOKUPS, deadline) else { + warn!(target: "external-storage", "external storage lookup timed out"); + return resolved; + }; + addresses + .into_iter() + .map(|address| (address, lookups.entry((chain_id, address)).or_default().clone())) + .collect::>() + }; + + let mut pending = Vec::new(); + for (address, slot) in &slots { + // Waits for a concurrent lookup of the same address to finish, if any. + let Some(guard) = lock_until(slot, deadline) else { + warn!(target: "external-storage", %address, "external storage lookup timed out"); + return resolved; + }; + if let Some(cached) = &*guard { + if let Some((name, layout)) = cached { + resolved.insert(*address, (name.clone(), layout.clone())); + } + continue; + } + pending.push((*address, guard)); + } + + if pending.is_empty() { + return resolved; + } + + // Serve whatever the disk cache already resolved, so the remaining work is only for addresses + // this machine has no layout for yet. + pending.retain_mut(|(address, guard)| { + let Some(cached) = read_cached_layout(cache_dir, *address) else { + return true; + }; + resolved.insert(*address, cached.clone()); + **guard = Some(Some(cached)); + false + }); + + if pending.is_empty() { + return resolved; + } + + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + warn!(target: "external-storage", "external storage lookup timed out"); + return resolved; + } + let sources = + fetch_sources(&pending.iter().map(|(address, _)| *address).collect::>(), remaining); + + for (address, mut guard) in pending { + let Some(source) = sources.get(&address) else { + // The lookup reached no conclusion. Leave the slot unresolved so the next call + // retries, rather than recording "no layout" on the strength of an outage. + continue; + }; + + let Some(metadata) = source else { + // Retry unverified contracts on the next run, when sources may be available. + *guard = Some(None); + continue; + }; + + let layout = compile_storage_layout(address, metadata, deadline); + if layout.is_none() && Instant::now() >= deadline { + // Exhausting this call's budget says nothing about whether the contract has a + // usable layout. Leave it unresolved so a later call can retry. + continue; + } + if let Some((name, layout)) = &layout { + resolved.insert(address, (name.clone(), layout.clone())); + write_cached_layout(cache_dir, address, name, layout); + } + // Compilation failures are memoized only for this run. Unlike an explicit unverified + // response, they may be transient and must not become a persistent negative entry. + *guard = Some(layout); + } + + resolved +} + +/// Compiles a verified source with `storageLayout` output enabled and extracts the layout. +fn compile_storage_layout( + address: Address, + metadata: &Metadata, + deadline: Instant, +) -> Option<(String, Arc)> { + if metadata.is_vyper() { + trace!(target: "external-storage", %address, "skipping vyper contract"); + return None; + } + + // Older solc has no `storageLayout` output at all, so compiling would cost a full solc run to + // produce nothing. `cast storage` bumps such contracts to `MIN_SOLC`; here there is no user + // asking about one specific contract, so just leave them undecoded. + match metadata.compiler_version() { + Ok(version) if version < MIN_STORAGE_LAYOUT_SOLC => { + trace!(target: "external-storage", %address, %version, "solc too old for storage layouts"); + return None; + } + Ok(_) => {} + Err(err) => { + warn!(target: "external-storage", %address, %err, "could not read compiler version"); + return None; + } + } + + let sources = metadata + .sources() + .into_iter() + .map(|(path, source)| (PathBuf::from(path), Source::new(source.content))) + .collect::(); + if sources.is_empty() { + trace!(target: "external-storage", %address, "verified metadata has no sources"); + return None; + } + let Some(_compiler) = lock_until(&COMPILER, deadline) else { + warn!(target: "external-storage", %address, "external storage compilation timed out"); + return None; + }; + + // Compile standard JSON with every source supplied inline in an empty sandbox. Newer solc + // versions disable the filesystem import callback explicitly; the empty working/base directory + // prevents older versions from resolving omitted sources. + let version = metadata.compiler_version().ok()?; + let mut settings = match metadata.settings() { + Ok(settings) => settings, + Err(err) => { + warn!(target: "external-storage", %address, %err, "failed to read compiler settings"); + return None; + } + }; + settings.output_selection = + OutputSelection::common_output_selection(["storageLayout".to_string()]); + let input = SolcInput::new(SolcLanguage::Solidity, sources, settings).sanitized(&version); + let svm_version = semver::Version::new(version.major, version.minor, version.patch); + let solc = match Solc::find_svm_installed_version(&svm_version) { + Ok(Some(solc)) => solc, + Ok(None) => { + let remaining = deadline.saturating_duration_since(Instant::now()); + let installed = + crate::block_on(tokio::time::timeout(remaining, Solc::install(&svm_version))); + match installed { + Ok(Ok(solc)) => solc, + Ok(Err(err)) => { + warn!(target: "external-storage", %address, %err, "failed to install compiler"); + return None; + } + Err(_) => { + warn!(target: "external-storage", %address, "compiler installation timed out"); + return None; + } + } + } + Err(err) => { + warn!(target: "external-storage", %address, %err, "failed to find compiler"); + return None; + } + }; + let remaining = deadline.saturating_duration_since(Instant::now()); + let output = match run_solc(&solc, &version, &input, remaining) { + Ok(output) => output, + Err(err) => { + warn!(target: "external-storage", %address, %err, "failed to compile contract"); + return None; + } + }; + + let name = metadata.contract_name.clone(); + let mut matches = output + .contracts + .values() + .filter_map(|contracts| contracts.get(&name)) + .filter(|contract| !contract.storage_layout.storage.is_empty()); + let layout = matches.next().map(|contract| contract.storage_layout.clone()); + if matches.next().is_some() { + warn!(target: "external-storage", %address, %name, "multiple artifacts match contract name"); + return None; + } + + let Some(layout) = layout else { + warn!(target: "external-storage", %address, %name, "no storage layout in compiled artifacts"); + return None; + }; + + Some((name, Arc::new(layout))) +} + +/// Runs solc in an empty directory and terminates it if the remaining lookup budget expires. +fn run_solc( + solc: &Solc, + version: &semver::Version, + input: &SolcInput, + timeout: Duration, +) -> Result { + if timeout.is_zero() { + return Err("compilation timed out".to_string()); + } + + let sandbox = tempfile::tempdir().map_err(|err| err.to_string())?; + let mut stdin = tempfile::tempfile().map_err(|err| err.to_string())?; + serde_json::to_writer(&mut stdin, input).map_err(|err| err.to_string())?; + stdin.seek(SeekFrom::Start(0)).map_err(|err| err.to_string())?; + let mut stdout = tempfile::tempfile().map_err(|err| err.to_string())?; + let mut stderr = tempfile::tempfile().map_err(|err| err.to_string())?; + + let mut command = Command::new(&solc.solc); + command.arg("--standard-json").current_dir(sandbox.path()); + if version >= &BASE_PATH_SOLC { + command.arg("--base-path").arg(sandbox.path()); + } + if version >= &NO_IMPORT_CALLBACK_SOLC { + command.arg("--no-import-callback"); + } + command + .stdin(Stdio::from(stdin)) + .stdout(Stdio::from(stdout.try_clone().map_err(|err| err.to_string())?)) + .stderr(Stdio::from(stderr.try_clone().map_err(|err| err.to_string())?)); + + let mut child = command.spawn().map_err(|err| err.to_string())?; + let status = match child.wait_timeout(timeout) { + Ok(Some(status)) => status, + Ok(None) => { + let _ = child.kill(); + let _ = child.wait(); + return Err("compilation timed out".to_string()); + } + Err(err) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(err.to_string()); + } + }; + if !status.success() { + stderr.seek(SeekFrom::Start(0)).map_err(|err| err.to_string())?; + let mut message = String::new(); + stderr.read_to_string(&mut message).map_err(|err| err.to_string())?; + return Err(if message.trim().is_empty() { + format!("solc exited with {status}") + } else { + message + }); + } + + stdout.seek(SeekFrom::Start(0)).map_err(|err| err.to_string())?; + serde_json::from_reader(stdout).map_err(|err| err.to_string()) +} + +/// Disk representation of a resolved lookup. Cleared by `forge cache clean`. +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct CachedStorageLayout { + /// Bumped whenever a change makes previously written entries wrong. Entries written by any + /// other version are ignored, so a fix doesn't need users to clear their cache by hand. + version: u32, + contract_name: String, + storage_layout: StorageLayout, +} + +/// Current [`CachedStorageLayout`] format. +const CACHE_VERSION: u32 = 2; + +/// Path of the cache entry for `address`. +fn cache_path(cache_dir: &Path, address: Address) -> PathBuf { + cache_dir.join("storage_layouts").join(format!("{address}.json")) +} + +/// Reads the layout a previous run resolved for `address`, if there is one. +fn read_cached_layout( + cache_dir: Option<&Path>, + address: Address, +) -> Option<(String, Arc)> { + let path = cache_path(cache_dir?, address); + let cached = fs::read_json_file::(&path).ok()?; + if cached.version != CACHE_VERSION { + trace!(target: "external-storage", %address, cached.version, "ignoring stale cache entry"); + return None; + } + trace!(target: "external-storage", %address, "using cached storage layout"); + Some((cached.contract_name, Arc::new(cached.storage_layout))) +} + +/// Persists a resolved layout so later runs can skip the fetch and the compile. +/// +/// The entry is written to a temporary file and renamed into place, so parallel tests writing the +/// same address cannot leave a reader with a half-written file. +fn write_cached_layout( + cache_dir: Option<&Path>, + address: Address, + name: &str, + layout: &StorageLayout, +) { + let Some(cache_dir) = cache_dir else { return }; + let path = cache_path(cache_dir, address); + let Some(parent) = path.parent() else { return }; + if let Err(err) = std::fs::create_dir_all(parent) { + warn!(target: "external-storage", %address, %err, "failed to create storage layout cache"); + return; + } + + let cached = CachedStorageLayout { + version: CACHE_VERSION, + contract_name: name.to_string(), + storage_layout: layout.clone(), + }; + + // A uniquely created file prevents threads and processes from sharing a writer. Persisting it + // in the destination directory publishes a complete JSON document with one atomic rename. + let tmp_path = path.with_extension(format!( + "json.{}.{}.tmp", + std::process::id(), + CACHE_TEMP_ID.fetch_add(1, Ordering::Relaxed) + )); + match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp_path) { + Ok(_) => {} + Err(err) => { + warn!(target: "external-storage", %address, %err, "failed to create cache temporary file"); + return; + } + } + let write = fs::write_json_file(&tmp_path, &cached).and_then(|()| { + std::fs::rename(&tmp_path, &path) + .map_err(|err| crate::errors::FsPathError::write(err, path.as_path())) + }); + if let Err(err) = write { + warn!(target: "external-storage", %address, %err, "failed to cache storage layout"); + let _ = std::fs::remove_file(&tmp_path); + } +} + +/// Acquires a lock without exceeding the caller's deadline. +/// +/// Poisoned locks are recovered: everything guarded this way only holds memoized lookup state. +pub fn lock_until(mutex: &Mutex, deadline: Instant) -> Option> { + loop { + match mutex.try_lock() { + Ok(guard) => return Some(guard), + Err(TryLockError::Poisoned(err)) => return Some(err.into_inner()), + Err(TryLockError::WouldBlock) => { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return None; + } + std::thread::sleep(remaining.min(Duration::from_millis(1))); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use foundry_block_explorers::contract::SourceCodeMetadata; + + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + + /// [`LOOKUPS`] is keyed by chain, so giving every test its own chain id keeps them from + /// seeing each other's memoized results. + fn next_chain_id() -> u64 { + static NEXT: AtomicU64 = AtomicU64::new(1); + NEXT.fetch_add(1, Ordering::Relaxed) + } + + #[test] + fn round_trips_a_resolved_layout() { + let cache_dir = tempfile::tempdir().unwrap(); + let address = Address::with_last_byte(1); + + // Nothing cached yet. + assert!(read_cached_layout(Some(cache_dir.path()), address).is_none()); + + let layout = StorageLayout::default(); + write_cached_layout(Some(cache_dir.path()), address, "Counter", &layout); + + let (name, cached) = read_cached_layout(Some(cache_dir.path()), address).unwrap(); + assert_eq!(name, "Counter"); + assert_eq!(*cached, layout); + } + + #[test] + fn ignores_cache_entries_written_by_another_version() { + let cache_dir = tempfile::tempdir().unwrap(); + let address = Address::with_last_byte(1); + write_cached_layout(Some(cache_dir.path()), address, "Counter", &StorageLayout::default()); + + let path = cache_path(cache_dir.path(), address); + let stale = std::fs::read_to_string(&path) + .unwrap() + .replace(&format!("\"version\":{CACHE_VERSION}"), "\"version\":0"); + std::fs::write(&path, stale).unwrap(); + + assert!(read_cached_layout(Some(cache_dir.path()), address).is_none()); + } + + #[test] + fn write_leaves_no_temporary_files_behind() { + let cache_dir = tempfile::tempdir().unwrap(); + let address = Address::with_last_byte(1); + write_cached_layout(Some(cache_dir.path()), address, "Counter", &StorageLayout::default()); + + let entries = std::fs::read_dir(cache_dir.path().join("storage_layouts")) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect::>(); + assert_eq!(entries, [format!("{address}.json").as_str()]); + } + + #[test] + fn serves_the_disk_cache_without_looking_anything_up() { + let cache_dir = tempfile::tempdir().unwrap(); + let chain_id = next_chain_id(); + let cached = Address::with_last_byte(1); + let fresh = Address::with_last_byte(2); + write_cached_layout(Some(cache_dir.path()), cached, "Counter", &StorageLayout::default()); + + let mut asked_for = Vec::new(); + let resolved = resolve( + chain_id, + Some(cache_dir.path()), + [cached, fresh], + Duration::from_secs(1), + |addresses, _| { + asked_for.extend_from_slice(addresses); + AddressMap::default() + }, + ); + + // Only the uncached address reaches the lookup, and the cached one still comes back. + assert_eq!(asked_for, [fresh]); + assert_eq!(resolved.keys().copied().collect::>(), [cached]); + assert_eq!(resolved[&cached].0, "Counter"); + } + + #[test] + fn remembers_a_conclusive_miss_for_the_rest_of_the_run() { + let cache_dir = tempfile::tempdir().unwrap(); + let chain_id = next_chain_id(); + let address = Address::with_last_byte(1); + + let mut lookups = 0; + let mut unverified = |addresses: &[Address], _: Duration| { + lookups += 1; + addresses.iter().map(|address| (*address, None)).collect::>() + }; + + assert!( + resolve( + chain_id, + Some(cache_dir.path()), + [address], + Duration::from_secs(1), + &mut unverified, + ) + .is_empty() + ); + assert!( + resolve( + chain_id, + Some(cache_dir.path()), + [address], + Duration::from_secs(1), + &mut unverified, + ) + .is_empty() + ); + assert_eq!(lookups, 1, "an unverified contract should only be looked up once"); + assert!(!cache_path(cache_dir.path(), address).exists()); + } + + #[test] + fn retries_an_address_the_lookup_reached_no_conclusion_about() { + let cache_dir = tempfile::tempdir().unwrap(); + let chain_id = next_chain_id(); + let address = Address::with_last_byte(1); + + // An explorer outage: the lookup answers for nothing it was asked about. + let mut lookups = 0; + let mut unavailable = |_: &[Address], _: Duration| { + lookups += 1; + AddressMap::default() + }; + + assert!( + resolve( + chain_id, + Some(cache_dir.path()), + [address], + Duration::from_secs(1), + &mut unavailable, + ) + .is_empty() + ); + assert!( + resolve( + chain_id, + Some(cache_dir.path()), + [address], + Duration::from_secs(1), + &mut unavailable, + ) + .is_empty() + ); + assert_eq!(lookups, 2, "an outage must not be remembered as \"no layout\""); + } + + #[test] + fn waiting_for_an_inflight_lookup_respects_the_timeout() { + let chain_id = next_chain_id(); + let address = Address::with_last_byte(1); + let slot = { + let mut lookups = LOOKUPS.lock().unwrap(); + lookups.entry((chain_id, address)).or_default().clone() + }; + let _inflight = slot.lock().unwrap(); + let started = Instant::now(); + let mut fetched = false; + + let result = resolve(chain_id, None, [address], Duration::from_millis(20), |_, _| { + fetched = true; + AddressMap::default() + }); + + assert!(result.is_empty()); + assert!(!fetched); + assert!(started.elapsed() < Duration::from_secs(1)); + } + + #[cfg(unix)] + #[test] + fn solc_is_killed_when_compilation_times_out() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("solc"); + std::fs::write(&path, "#!/bin/sh\nexec sleep 10\n").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + let solc = Solc { + solc: path, + version: NO_IMPORT_CALLBACK_SOLC, + base_path: None, + allow_paths: Default::default(), + include_paths: Default::default(), + extra_args: Vec::new(), + }; + let input = SolcInput::new(SolcLanguage::Solidity, Default::default(), Default::default()); + let started = Instant::now(); + + assert!( + run_solc(&solc, &NO_IMPORT_CALLBACK_SOLC, &input, Duration::from_millis(20)).is_err() + ); + assert!(started.elapsed() < Duration::from_secs(1)); + } + + #[cfg(unix)] + #[test] + fn solc_runs_in_an_empty_sandbox_with_imports_disabled() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("solc"); + std::fs::write( + &path, + "#!/bin/sh\ncase \" $* \" in *\" --no-import-callback \"*) ;; *) exit 1;; esac\n[ -z \"$(ls -A)\" ] || exit 1\nprintf '{\"contracts\":{}}'\n", + ) + .unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + let solc = Solc { + solc: path, + version: NO_IMPORT_CALLBACK_SOLC, + base_path: None, + allow_paths: Default::default(), + include_paths: Default::default(), + extra_args: Vec::new(), + }; + let input = SolcInput::new(SolcLanguage::Solidity, Default::default(), Default::default()); + + assert!(run_solc(&solc, &NO_IMPORT_CALLBACK_SOLC, &input, Duration::from_secs(1)).is_ok()); + } + + fn source_less_metadata() -> Metadata { + Metadata { + source_code: SourceCodeMetadata::Sources(Default::default()), + abi: "[]".to_string(), + contract_name: "MissingSources".to_string(), + compiler_version: "v0.8.30".to_string(), + optimization_used: 0, + runs: 0, + constructor_arguments: Default::default(), + evm_version: String::new(), + library: String::new(), + license_type: String::new(), + proxy: 0, + implementation: None, + swarm_source: String::new(), + } + } + + #[test] + fn retries_after_the_compilation_budget_expires() { + let chain_id = next_chain_id(); + let address = Address::with_last_byte(1); + let metadata = Metadata { + source_code: SourceCodeMetadata::SourceCode( + "pragma solidity ^0.8.30; contract Counter { uint256 public count; }".to_string(), + ), + contract_name: "Counter".to_string(), + ..source_less_metadata() + }; + // Keep compilation from progressing after fetching consumes the shared budget. + let _compiler = COMPILER.lock().unwrap(); + let result = + resolve(chain_id, None, [address], Duration::from_millis(20), |_, remaining| { + std::thread::sleep(remaining); + [(address, Some(metadata))].into_iter().collect() + }); + assert!(result.is_empty()); + + let mut retried = false; + resolve(chain_id, None, [address], Duration::from_secs(1), |addresses, _| { + assert_eq!(addresses, [address]); + retried = true; + AddressMap::default() + }); + assert!(retried, "a compilation timeout must not be memoized as no layout"); + } + + #[test] + fn source_less_metadata_is_rejected_without_compiling() { + let metadata = source_less_metadata(); + assert!(compile_storage_layout(Address::ZERO, &metadata, Instant::now()).is_none()); + } +} diff --git a/crates/common/src/fs.rs b/crates/common/src/fs.rs index 840a1563fec00..e896fcf30723f 100644 --- a/crates/common/src/fs.rs +++ b/crates/common/src/fs.rs @@ -5,10 +5,13 @@ use flate2::{Compression, read::GzDecoder, write::GzEncoder}; use serde::{Serialize, de::DeserializeOwned}; use std::{ fs::{self, File}, - io::{BufReader, BufWriter, Write}, + io::{BufReader, BufWriter, Read, Seek, SeekFrom, Write}, path::{Component, Path, PathBuf}, }; +#[cfg(unix)] +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + /// The [`fs`](self) result type. pub type Result = std::result::Result; @@ -59,6 +62,31 @@ pub fn read_json_gzip_file(path: &Path) -> Result { .map_err(|source| FsPathError::ReadJson { source, path: path.into() }) } +/// Reads the entire contents of a locked shared file into a string. +pub fn locked_read_to_string(path: impl AsRef) -> Result { + let path = path.as_ref(); + let contents = locked_read(path)?; + String::from_utf8(contents).map_err(|err| FsPathError::read(std::io::Error::other(err), path)) +} + +/// Reads the entire contents of a locked shared file into a bytes vector. +pub fn locked_read(path: impl AsRef) -> Result> { + let path = path.as_ref(); + let mut file = + fs::OpenOptions::new().read(true).open(path).map_err(|err| FsPathError::open(err, path))?; + file.lock_shared().map_err(|err| FsPathError::lock(err, path))?; + let contents = read_inner(path, &mut file)?; + file.unlock().map_err(|err| FsPathError::unlock(err, path))?; + Ok(contents) +} + +fn read_inner(path: &Path, file: &mut File) -> Result> { + let file_len = file.metadata().map_err(|err| FsPathError::open(err, path))?.len() as usize; + let mut buffer = Vec::with_capacity(file_len); + file.read_to_end(&mut buffer).map_err(|err| FsPathError::read(err, path))?; + Ok(buffer) +} + /// Writes the object as a JSON object. pub fn write_json_file(path: &Path, obj: &T) -> Result<()> { let file = create_file(path)?; @@ -70,7 +98,27 @@ pub fn write_json_file(path: &Path, obj: &T) -> Result<()> { /// Writes the object as a pretty JSON object. pub fn write_pretty_json_file(path: &Path, obj: &T) -> Result<()> { - let file = create_file(path)?; + write_pretty_json(path, obj, create_file(path)?) +} + +/// Writes an object as pretty JSON with owner-only permissions on Unix. +pub fn write_sensitive_json_file(path: &Path, obj: &T) -> Result<()> { + let mut options = File::options(); + // Truncate only after restricting permissions so a failed `chmod` preserves existing contents. + options.write(true).create(true).truncate(false); + #[cfg(unix)] + options.mode(0o600); + + let file = options.open(path).map_err(|err| FsPathError::create_file(err, path))?; + #[cfg(unix)] + file.set_permissions(fs::Permissions::from_mode(0o600)) + .map_err(|err| FsPathError::write(err, path))?; + file.set_len(0).map_err(|err| FsPathError::write(err, path))?; + + write_pretty_json(path, obj, file) +} + +fn write_pretty_json(path: &Path, obj: &T, file: File) -> Result<()> { let mut writer = BufWriter::new(file); serde_json::to_writer_pretty(&mut writer, obj) .map_err(|source| FsPathError::WriteJson { source, path: path.into() })?; @@ -84,10 +132,9 @@ pub fn write_json_gzip_file(path: &Path, obj: &T) -> Result<()> { let mut encoder = GzEncoder::new(writer, Compression::default()); serde_json::to_writer(&mut encoder, obj) .map_err(|source| FsPathError::WriteJson { source, path: path.into() })?; - encoder - .finish() - .map_err(serde_json::Error::io) - .map_err(|source| FsPathError::WriteJson { source, path: path.into() })?; + // Ensure we surface any I/O errors on final gzip write and buffer flush. + let mut inner_writer = encoder.finish().map_err(|e| FsPathError::write(e, path))?; + inner_writer.flush().map_err(|e| FsPathError::write(e, path))?; Ok(()) } @@ -97,6 +144,55 @@ pub fn write(path: impl AsRef, contents: impl AsRef<[u8]>) -> Result<()> { fs::write(path, contents).map_err(|err| FsPathError::write(err, path)) } +/// Writes all content in an exclusive locked file. +pub fn locked_write(path: impl AsRef, contents: impl AsRef<[u8]>) -> Result<()> { + let path = path.as_ref(); + let mut file = fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(false) + .open(path) + .map_err(|err| FsPathError::open(err, path))?; + file.lock().map_err(|err| FsPathError::lock(err, path))?; + file.set_len(0).map_err(|err| FsPathError::write(err, path))?; + file.write_all(contents.as_ref()).map_err(|err| FsPathError::write(err, path))?; + file.unlock().map_err(|err| FsPathError::unlock(err, path)) +} + +/// Writes a line in an exclusive locked file. +pub fn locked_write_line(path: impl AsRef, line: &str) -> Result<()> { + let path = path.as_ref(); + if cfg!(windows) { + return locked_write_line_windows(path, line); + } + + let mut file = std::fs::OpenOptions::new() + .append(true) + .create(true) + .open(path) + .map_err(|err| FsPathError::open(err, path))?; + + file.lock().map_err(|err| FsPathError::lock(err, path))?; + writeln!(file, "{line}").map_err(|err| FsPathError::write(err, path))?; + file.unlock().map_err(|err| FsPathError::unlock(err, path)) +} + +// Locking fails on Windows if the file is opened in append mode. +fn locked_write_line_windows(path: &Path, line: &str) -> Result<()> { + let mut file = std::fs::OpenOptions::new() + .write(true) + .truncate(false) + .create(true) + .open(path) + .map_err(|err| FsPathError::open(err, path))?; + file.lock().map_err(|err| FsPathError::lock(err, path))?; + + file.seek(SeekFrom::End(0)).map_err(|err| FsPathError::write(err, path))?; + writeln!(file, "{line}").map_err(|err| FsPathError::write(err, path))?; + + file.unlock().map_err(|err| FsPathError::unlock(err, path)) +} + /// Wrapper for `std::fs::copy` pub fn copy(from: impl AsRef, to: impl AsRef) -> Result { let from = from.as_ref(); @@ -193,6 +289,23 @@ pub fn canonicalize_path(path: impl AsRef) -> std::io::Result { #[cfg(test)] mod tests { use super::*; + use std::{sync::mpsc, thread, time::Duration}; + + #[cfg(unix)] + #[test] + fn test_write_sensitive_json_file_permissions() { + let dir = tempfile::tempdir().unwrap(); + for name in ["new", "existing"] { + let path = dir.path().join(name); + if name == "existing" { + fs::write(&path, []).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); + } + + write_sensitive_json_file(&path, &()).unwrap(); + assert_eq!(fs::metadata(path).unwrap().permissions().mode() & 0o777, 0o600); + } + } #[test] fn test_normalize_path() { @@ -200,4 +313,37 @@ mod tests { let normalized = normalize_path(p); assert_eq!(normalized, PathBuf::from("/file.txt")); } + + #[test] + fn test_locked_write_waits_before_truncating() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("locked.txt"); + fs::write(&path, b"original contents").unwrap(); + let mut reader = File::open(&path).unwrap(); + reader.lock_shared().unwrap(); + + let (started_tx, started_rx) = mpsc::channel(); + let (finished_tx, finished_rx) = mpsc::channel(); + let writer_path = path.clone(); + let writer = thread::spawn(move || { + started_tx.send(()).unwrap(); + let result = locked_write(writer_path, b"new"); + finished_tx.send(()).unwrap(); + result + }); + started_rx.recv().unwrap(); + // Give the writer time to reach the held lock before inspecting the original contents. + let pending = finished_rx.recv_timeout(Duration::from_millis(100)); + let mut contents = Vec::new(); + let read_result = reader.read_to_end(&mut contents); + let unlock_result = reader.unlock(); + drop(reader); + writer.join().unwrap().unwrap(); + + unlock_result.unwrap(); + assert_eq!(pending, Err(mpsc::RecvTimeoutError::Timeout)); + read_result.unwrap(); + assert_eq!(contents, b"original contents"); + assert_eq!(fs::read(&path).unwrap(), b"new"); + } } diff --git a/crates/common/src/io/macros.rs b/crates/common/src/io/macros.rs index 17e999051f681..2f132a2ae290d 100644 --- a/crates/common/src/io/macros.rs +++ b/crates/common/src/io/macros.rs @@ -1,4 +1,4 @@ -/// Prints a message to [`stdout`][std::io::stdout] and reads a line from stdin into a String. +/// Prints a message to [`stderr`][std::io::stderr] and reads a line from stdin into a String. /// /// Returns `Result`, so sometimes `T` must be explicitly specified, like in `str::parse`. /// @@ -20,10 +20,10 @@ macro_rules! prompt { }; ($($tt:tt)+) => {{ - let _ = $crate::sh_print!($($tt)+); - match ::std::io::Write::flush(&mut ::std::io::stdout()) { + let _ = $crate::sh_eprint!($($tt)+); + match ::std::io::Write::flush(&mut ::std::io::stderr()) { ::core::result::Result::Ok(()) => $crate::prompt!(), - ::core::result::Result::Err(e) => ::core::result::Result::Err(::eyre::eyre!("Could not flush stdout: {e}")) + ::core::result::Result::Err(e) => ::core::result::Result::Err(::eyre::eyre!("Could not flush stderr: {e}")) } }}; } @@ -102,6 +102,36 @@ macro_rules! sh_println { }; } +/// Prints a status message to stderr with a trailing newline. +/// +/// Use for human-facing diagnostic prose ("Compiling…", "Deploying contract…") +/// that is not the command's primary machine-readable result. +#[macro_export] +macro_rules! sh_status { + ($($args:tt)*) => { + $crate::sh_eprintln!($($args)*) + }; +} + +/// Prints a progress message to stderr with a trailing newline. +/// +/// Use for transient progress updates outside the spinner. +/// +/// Suppressed when: +/// - `--quiet` is set, or +/// - stderr is not a tty (e.g. CI logs, piped consumers). +/// +/// Always returns `Ok(())`; progress is best-effort and never fails the caller. +#[macro_export] +macro_rules! sh_progress { + ($($args:tt)*) => {{ + if $crate::shell::is_err_tty() && !$crate::shell::is_quiet() { + let _ = $crate::sh_eprintln!($($args)*); + } + ::core::result::Result::<(), ::eyre::Report>::Ok(()) + }}; +} + /// Prints a raw formatted message to stderr, with a trailing newline. /// /// **Note**: if `verbosity` is set to `Quiet`, this is a no-op. @@ -146,7 +176,7 @@ macro_rules! __sh_dispatch { // Ensure that the global shell lock is held for as little time as possible. // Also avoids deadlocks in case of nested calls. (@impl $f:ident $shell:expr, $($args:tt)*) => { - match ::core::format_args!($($args)*) { + match format!($($args)*) { fmt => $crate::Shell::$f($shell, fmt), } }; @@ -176,9 +206,18 @@ mod tests { sh_eprintln!("eprintln")?; sh_eprintln!("eprintln {}", "arg")?; + sh_status!("status")?; + sh_status!("status {}", "arg")?; + + sh_progress!("progress")?; + sh_progress!("progress {}", "arg")?; + sh_println!("{:?}", { sh_println!("hi")?; - "nested" + solar::data_structures::fmt::from_fn(|f| { + let _ = sh_println!("even more nested"); + write!(f, "hi 2") + }) })?; Ok(()) @@ -195,4 +234,64 @@ mod tests { Ok(()) } + + /// Asserts that every macro routes to the channel documented in + /// `docs/dev/output-channels.md`. + #[test] + fn routing_contract() -> eyre::Result<()> { + let mut shell = crate::Shell::captured(); + + // stdout: machine-readable result + sh_print!(&mut shell, "out-print")?; + sh_println!(&mut shell, "out-println")?; + + // stderr: diagnostics + raw stderr + sh_eprint!(&mut shell, "err-print")?; + sh_eprintln!(&mut shell, "err-println")?; + crate::Shell::warn(&mut shell, "warn-msg")?; + crate::Shell::error(&mut shell, "err-msg")?; + + let stdout = std::str::from_utf8(shell.captured_stdout().unwrap()).unwrap(); + let stderr = std::str::from_utf8(shell.captured_stderr().unwrap()).unwrap(); + + // stdout only contains what `sh_print!`/`sh_println!` produced. + assert_eq!(stdout, "out-printout-println\n"); + + // stderr received the eprint/warn/error output and no stdout content. + assert!(stderr.contains("err-print"), "stderr missing eprint: {stderr:?}"); + assert!(stderr.contains("err-println"), "stderr missing eprintln: {stderr:?}"); + assert!(stderr.contains("warn-msg"), "stderr missing warn: {stderr:?}"); + assert!(stderr.contains("err-msg"), "stderr missing error: {stderr:?}"); + assert!(!stderr.contains("out-print"), "stdout content leaked to stderr: {stderr:?}"); + assert!(!stderr.contains("out-println"), "stdout content leaked to stderr: {stderr:?}"); + + Ok(()) + } + + /// `--quiet` currently suppresses both stdout and stderr diagnostics, but `sh_err!` must + /// always be visible. The stdout half of this is intentional for now; it will be flipped + /// to "stdout is never suppressed" once the prose `sh_println!` call sites in forge/script + /// are migrated to `sh_status!` (see `docs/dev/output-channels.md`). + #[test] + fn quiet_contract() -> eyre::Result<()> { + let mut shell = crate::Shell::captured(); + shell.set_output_mode(crate::shell::OutputMode::Quiet); + + sh_println!(&mut shell, "result")?; + sh_eprintln!(&mut shell, "diag")?; + crate::Shell::warn(&mut shell, "warned")?; + crate::Shell::error(&mut shell, "boom")?; + + let stdout = std::str::from_utf8(shell.captured_stdout().unwrap()).unwrap(); + let stderr = std::str::from_utf8(shell.captured_stderr().unwrap()).unwrap(); + + // Today's behavior: stdout is suppressed by --quiet. Pinned here so the future + // migration that flips this bypass has to deliberately update the test. + assert!(stdout.is_empty(), "stdout leaked through --quiet: {stdout:?}"); + assert!(!stderr.contains("diag"), "eprintln leaked through --quiet: {stderr:?}"); + assert!(!stderr.contains("warned"), "warn leaked through --quiet: {stderr:?}"); + assert!(stderr.contains("boom"), "sh_err was suppressed by --quiet: {stderr:?}"); + + Ok(()) + } } diff --git a/crates/common/src/io/shell.rs b/crates/common/src/io/shell.rs index 5482fb2d3a4ad..e95f49dbf423c 100644 --- a/crates/common/src/io/shell.rs +++ b/crates/common/src/io/shell.rs @@ -38,48 +38,35 @@ pub fn is_quiet() -> bool { Shell::get().output_mode().is_quiet() } +/// Returns whether stderr is a terminal (tty). +/// +/// Used to gate progress/spinner output that only makes sense for interactive use. +pub fn is_err_tty() -> bool { + Shell::get().is_err_tty() +} + +/// Returns whether stdout is a terminal (tty). +/// +/// Used to gate machine-readable stdout records that would duplicate the status prose +/// already shown on stderr in interactive sessions. +pub fn is_out_tty() -> bool { + Shell::get().is_out_tty() +} + /// Returns whether the output format is [`OutputFormat::Json`]. pub fn is_json() -> bool { Shell::get().is_json() } -/// The global shell instance. -static GLOBAL_SHELL: OnceLock> = OnceLock::new(); - -/// Terminal width. -pub enum TtyWidth { - /// Not a terminal, or could not determine size. - NoTty, - /// A known width. - Known(usize), - /// A guess at the width. - Guess(usize), +/// Returns whether the output format is [`OutputFormat::Markdown`]. +pub fn is_markdown() -> bool { + Shell::get().is_markdown() } -impl TtyWidth { - /// Returns the width of the terminal from the environment, if known. - pub fn get() -> Self { - // use stderr - #[cfg(unix)] - let opt = terminal_size::terminal_size_of(std::io::stderr()); - #[cfg(not(unix))] - let opt = terminal_size::terminal_size(); - match opt { - Some((w, _)) => Self::Known(w.0 as usize), - None => Self::NoTty, - } - } - - /// Returns the width used by progress bars for the tty. - pub fn progress_max_width(&self) -> Option { - match *self { - Self::NoTty => None, - Self::Known(width) | Self::Guess(width) => Some(width), - } - } -} +/// The global shell instance. +static GLOBAL_SHELL: OnceLock> = OnceLock::new(); -#[derive(Debug, Default, Clone, Copy, PartialEq)] +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] /// The requested output mode. pub enum OutputMode { /// Default output @@ -91,40 +78,43 @@ pub enum OutputMode { impl OutputMode { /// Returns true if the output mode is `Normal`. - #[inline] pub fn is_normal(self) -> bool { self == Self::Normal } /// Returns true if the output mode is `Quiet`. - #[inline] pub fn is_quiet(self) -> bool { self == Self::Quiet } } /// The requested output format. -#[derive(Debug, Default, Clone, Copy, PartialEq)] +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub enum OutputFormat { /// Plain text output. #[default] Text, /// JSON output. Json, + /// Plain text with markdown tables. + Markdown, } impl OutputFormat { /// Returns true if the output format is `Text`. - #[inline] pub fn is_text(self) -> bool { self == Self::Text } /// Returns true if the output format is `Json`. - #[inline] pub fn is_json(self) -> bool { self == Self::Json } + + /// Returns true if the output format is `Markdown`. + pub fn is_markdown(self) -> bool { + self == Self::Markdown + } } /// The verbosity level. @@ -170,15 +160,18 @@ enum ShellOut { Stream { stdout: AutoStream, stderr: AutoStream, + stdout_tty: bool, stderr_tty: bool, color_choice: ColorChoice, }, /// A write object that ignores all output. Empty(std::io::Empty), + /// Captures stdout and stderr into in-memory buffers. Intended for tests. + Captured { stdout: Vec, stderr: Vec }, } /// Whether messages should use color output. -#[derive(Debug, Default, PartialEq, Clone, Copy, Serialize, Deserialize, ValueEnum)] +#[derive(Debug, Default, PartialEq, Eq, Clone, Copy, Serialize, Deserialize, ValueEnum)] pub enum ColorChoice { /// Intelligently guess whether to use color output (default). #[default] @@ -190,7 +183,6 @@ pub enum ColorChoice { } impl Default for Shell { - #[inline] fn default() -> Self { Self::new() } @@ -199,7 +191,6 @@ impl Default for Shell { impl Shell { /// Creates a new shell (color choice and verbosity), defaulting to 'auto' color and verbose /// output. - #[inline] pub fn new() -> Self { Self::new_with( OutputFormat::Text, @@ -210,7 +201,6 @@ impl Shell { } /// Creates a new shell with the given color choice and verbosity. - #[inline] pub fn new_with( format: OutputFormat, mode: OutputMode, @@ -222,6 +212,7 @@ impl Shell { stdout: AutoStream::new(std::io::stdout(), color.to_anstream_color_choice()), stderr: AutoStream::new(std::io::stderr(), color.to_anstream_color_choice()), color_choice: color, + stdout_tty: std::io::stdout().is_terminal(), stderr_tty: std::io::stderr().is_terminal(), }, output_format: format, @@ -232,8 +223,7 @@ impl Shell { } /// Creates a shell that ignores all output. - #[inline] - pub fn empty() -> Self { + pub const fn empty() -> Self { Self { output: ShellOut::Empty(std::io::empty()), output_format: OutputFormat::Text, @@ -243,6 +233,37 @@ impl Shell { } } + /// Creates a shell that captures stdout and stderr into in-memory buffers. + /// + /// Intended for tests that want to assert how a piece of code routes output + /// between stdout and stderr. Use [`Shell::captured_stdout`] and + /// [`Shell::captured_stderr`] to read the buffers back. + pub const fn captured() -> Self { + Self { + output: ShellOut::Captured { stdout: Vec::new(), stderr: Vec::new() }, + output_format: OutputFormat::Text, + output_mode: OutputMode::Normal, + verbosity: 0, + needs_clear: AtomicBool::new(false), + } + } + + /// Returns the captured stdout buffer, if this shell was created via [`Shell::captured`]. + pub fn captured_stdout(&self) -> Option<&[u8]> { + match &self.output { + ShellOut::Captured { stdout, .. } => Some(stdout), + _ => None, + } + } + + /// Returns the captured stderr buffer, if this shell was created via [`Shell::captured`]. + pub fn captured_stderr(&self) -> Option<&[u8]> { + match &self.output { + ShellOut::Captured { stderr, .. } => Some(stderr), + _ => None, + } + } + /// Acquire a lock to the global shell. /// /// Initializes it with the default values if it has not been set yet. @@ -263,7 +284,6 @@ impl Shell { } /// Sets whether the next print should clear the current line and returns the previous value. - #[inline] pub fn set_needs_clear(&self, needs_clear: bool) -> bool { self.needs_clear.swap(needs_clear, Ordering::Relaxed) } @@ -273,91 +293,86 @@ impl Shell { self.output_format.is_json() } + /// Returns `true` if the output format is Markdown. + pub fn is_markdown(&self) -> bool { + self.output_format.is_markdown() + } + /// Returns `true` if the verbosity level is `Quiet`. pub fn is_quiet(&self) -> bool { self.output_mode.is_quiet() } /// Returns `true` if the `needs_clear` flag is set. - #[inline] pub fn needs_clear(&self) -> bool { self.needs_clear.load(Ordering::Relaxed) } - /// Returns `true` if the `needs_clear` flag is unset. - #[inline] - pub fn is_cleared(&self) -> bool { - !self.needs_clear() - } - - /// Returns the width of the terminal in spaces, if any. - #[inline] - pub fn err_width(&self) -> TtyWidth { - match self.output { - ShellOut::Stream { stderr_tty: true, .. } => TtyWidth::get(), - _ => TtyWidth::NoTty, - } - } - /// Gets the output format of the shell. - #[inline] - pub fn output_format(&self) -> OutputFormat { + pub const fn output_format(&self) -> OutputFormat { self.output_format } /// Gets the output mode of the shell. - #[inline] - pub fn output_mode(&self) -> OutputMode { + pub const fn output_mode(&self) -> OutputMode { self.output_mode } /// Gets the verbosity of the shell when [`OutputMode::Normal`] is set. - #[inline] - pub fn verbosity(&self) -> Verbosity { + pub const fn verbosity(&self) -> Verbosity { self.verbosity } /// Sets the verbosity level. - pub fn set_verbosity(&mut self, verbosity: Verbosity) { + pub const fn set_verbosity(&mut self, verbosity: Verbosity) { self.verbosity = verbosity; } + /// Sets the output mode. + pub const fn set_output_mode(&mut self, output_mode: OutputMode) { + self.output_mode = output_mode; + } + /// Gets the current color choice. /// /// If we are not using a color stream, this will always return `Never`, even if the color /// choice has been set to something else. - #[inline] - pub fn color_choice(&self) -> ColorChoice { + pub const fn color_choice(&self) -> ColorChoice { match self.output { ShellOut::Stream { color_choice, .. } => color_choice, - ShellOut::Empty(_) => ColorChoice::Never, + ShellOut::Empty(_) | ShellOut::Captured { .. } => ColorChoice::Never, } } /// Returns `true` if stderr is a tty. - #[inline] - pub fn is_err_tty(&self) -> bool { + pub const fn is_err_tty(&self) -> bool { match self.output { ShellOut::Stream { stderr_tty, .. } => stderr_tty, - ShellOut::Empty(_) => false, + ShellOut::Empty(_) | ShellOut::Captured { .. } => false, + } + } + + /// Returns `true` if stdout is a tty. + pub const fn is_out_tty(&self) -> bool { + match self.output { + ShellOut::Stream { stdout_tty, .. } => stdout_tty, + ShellOut::Empty(_) | ShellOut::Captured { .. } => false, } } /// Whether `stderr` supports color. - #[inline] pub fn err_supports_color(&self) -> bool { match &self.output { ShellOut::Stream { stderr, .. } => supports_color(stderr.current_choice()), - ShellOut::Empty(_) => false, + ShellOut::Empty(_) | ShellOut::Captured { .. } => false, } } /// Whether `stdout` supports color. - #[inline] pub fn out_supports_color(&self) -> bool { match &self.output { ShellOut::Stream { stdout, .. } => supports_color(stdout.current_choice()), - ShellOut::Empty(_) => false, + ShellOut::Empty(_) | ShellOut::Captured { .. } => false, } } @@ -413,6 +428,10 @@ impl Shell { /// Write a styled fragment with the default color. Use the [`sh_print!`] macro instead. /// /// **Note**: if `verbosity` is set to `Quiet`, this is a no-op. + // + // TODO: stdout is the canonical machine-readable result of a command and should NOT be + // suppressed by `--quiet` (see `docs/dev/output-channels.md`). Flip this once the major + // prose `sh_println!` call sites in forge/script have been migrated to `sh_status!`. pub fn print_out(&mut self, fragment: impl fmt::Display) -> Result<()> { match self.output_mode { OutputMode::Quiet => Ok(()), @@ -489,20 +508,20 @@ impl ShellOut { } /// Gets stdout as a [`io::Write`](Write) trait object. - #[inline] fn stdout(&mut self) -> &mut dyn Write { match self { Self::Stream { stdout, .. } => stdout, Self::Empty(e) => e, + Self::Captured { stdout, .. } => stdout, } } /// Gets stderr as a [`io::Write`](Write) trait object. - #[inline] fn stderr(&mut self) -> &mut dyn Write { match self { Self::Stream { stderr, .. } => stderr, Self::Empty(e) => e, + Self::Captured { stderr, .. } => stderr, } } @@ -534,8 +553,7 @@ impl ShellOut { impl ColorChoice { /// Converts our color choice to [`anstream`]'s version. - #[inline] - fn to_anstream_color_choice(self) -> anstream::ColorChoice { + const fn to_anstream_color_choice(self) -> anstream::ColorChoice { match self { Self::Always => anstream::ColorChoice::Always, Self::Never => anstream::ColorChoice::Never, @@ -544,8 +562,7 @@ impl ColorChoice { } } -#[inline] -fn supports_color(choice: anstream::ColorChoice) -> bool { +const fn supports_color(choice: anstream::ColorChoice) -> bool { match choice { anstream::ColorChoice::Always | anstream::ColorChoice::AlwaysAnsi diff --git a/crates/common/src/io/stdin.rs b/crates/common/src/io/stdin.rs index 17b40a2cff1fe..f9da53f305f28 100644 --- a/crates/common/src/io/stdin.rs +++ b/crates/common/src/io/stdin.rs @@ -20,7 +20,6 @@ where } /// Shortcut for `(unwrap(a), unwrap(b))`. -#[inline] pub fn unwrap2(a: Option, b: Option) -> Result<(A, B)> where A: FromStr, @@ -50,7 +49,6 @@ where } /// Short-hand for `unwrap(value, true)`. -#[inline] pub fn unwrap_line(value: Option) -> Result where T: FromStr, @@ -62,7 +60,6 @@ where /// Reads bytes from [`stdin`][io::stdin] into a String. /// /// If `read_line` is true, stop at the first newline (the `0xA` byte). -#[inline] pub fn parse(read_line: bool) -> Result where T: FromStr, @@ -72,7 +69,6 @@ where } /// Short-hand for `parse(true)`. -#[inline] pub fn parse_line() -> Result where T: FromStr, @@ -84,7 +80,6 @@ where /// Reads bytes from [`stdin`][io::stdin] into a String. /// /// If `read_line` is true, stop at the first newline (the `0xA` byte). -#[inline] pub fn read(read_line: bool) -> Result { let bytes = read_bytes(read_line)?; @@ -106,8 +101,11 @@ pub fn read_bytes(read_line: bool) -> Result> { let mut buf = String::new(); stdin.read_line(&mut buf)?; // remove the trailing newline - if let Some(b'\n') = buf.as_bytes().last() { + if matches!(buf.as_bytes().last(), Some(b'\n')) { buf.pop(); + if matches!(buf.as_bytes().last(), Some(b'\r')) { + buf.pop(); + } } Ok(buf.into_bytes()) } else { diff --git a/crates/common/src/iter.rs b/crates/common/src/iter.rs new file mode 100644 index 0000000000000..09d16c5f30ae4 --- /dev/null +++ b/crates/common/src/iter.rs @@ -0,0 +1,31 @@ +use std::iter::Peekable; + +pub struct Delimited { + is_first: bool, + iter: Peekable, +} + +pub trait IterDelimited: Iterator + Sized { + fn delimited(self) -> Delimited { + Delimited { is_first: true, iter: self.peekable() } + } +} + +impl IterDelimited for I {} + +pub struct IteratorPosition { + pub is_first: bool, + pub is_last: bool, +} + +impl Iterator for Delimited { + type Item = (IteratorPosition, I::Item); + + fn next(&mut self) -> Option { + let item = self.iter.next()?; + let position = + IteratorPosition { is_first: self.is_first, is_last: self.iter.peek().is_none() }; + self.is_first = false; + Some((position, item)) + } +} diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index c672f014df226..a7f6415945a9e 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -3,7 +3,7 @@ //! Common utilities for building and using foundry's tools. #![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] +#![cfg_attr(docsrs, feature(doc_cfg))] #[expect(unused_extern_crates)] // Used by `ConsoleFmt`. extern crate self as foundry_common; @@ -23,23 +23,30 @@ pub mod compile; pub mod constants; pub mod contracts; pub mod errors; -pub mod evm; +pub mod external_compiler; +pub mod external_storage; pub mod fs; +pub mod iter; +pub mod mapping_slots; mod preprocessor; pub mod provider; -pub mod reports; pub mod retry; pub mod selectors; pub mod serde_helpers; +pub mod slot_identifier; +pub mod tempo; pub mod term; pub mod traits; pub mod transactions; mod utils; pub mod version; +pub mod wallet; +pub use compile::Analysis; pub use constants::*; pub use contracts::*; pub use io::{Shell, shell, stdin}; +pub use preprocessor::is_deploy_helper_path; pub use traits::*; pub use transactions::*; pub use utils::*; diff --git a/crates/common/src/mapping_slots.rs b/crates/common/src/mapping_slots.rs new file mode 100644 index 0000000000000..2084da86f8ad7 --- /dev/null +++ b/crates/common/src/mapping_slots.rs @@ -0,0 +1,161 @@ +use alloy_primitives::{ + Address, B256, U256, + map::{AddressHashMap, B256HashMap}, +}; +use revm::{ + bytecode::opcode, + interpreter::{Interpreter, interpreter_types::Jumps}, +}; + +/// Provenance recovered for a Solidity mapping storage slot. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MappingProvenance { + /// The terminal mapping root slot. + pub root_slot: B256, + /// Mapping keys ordered from the root mapping to the accessed value. + pub keys: Vec, +} + +/// Recorded mapping slots. +#[derive(Clone, Debug, Default)] +pub struct MappingSlots { + /// Holds mapping parent (slots => slots) + pub parent_slots: B256HashMap, + + /// Holds mapping key (slots => key) + pub keys: B256HashMap, + + /// Holds mapping child (slots => slots[]) + pub children: B256HashMap>, + + /// Holds the last sha3 result `sha3_result => (data_low, data_high)`, this would only record + /// when sha3 is called with `size == 0x40`, and the lower 256 bits would be stored in + /// `data_low`, higher 256 bits in `data_high`. + /// This is needed for mapping_key detect if the slot is for some mapping and record that. + pub seen_sha3: B256HashMap<(B256, B256)>, +} + +impl MappingSlots { + /// Records the result and two input words of a 64-byte `KECCAK256` operation. + pub fn record_hash(&mut self, result: B256, key: B256, parent: B256) { + self.seen_sha3.insert(result, (key, parent)); + } + + /// Resolves a computed slot to its terminal mapping root and root-to-leaf keys. + pub fn resolve(&self, slot: B256) -> Option { + let mut current = slot; + let mut keys = Vec::new(); + while let Some((key, parent)) = self.seen_sha3.get(¤t).copied() { + keys.push(key); + current = parent; + if keys.len() > self.seen_sha3.len() { + return None; + } + } + if keys.is_empty() { + return None; + } + keys.reverse(); + Some(MappingProvenance { root_slot: current, keys }) + } + + /// Tries to insert a mapping slot. Returns true if it was inserted. + pub fn insert(&mut self, slot: B256) -> bool { + match self.seen_sha3.get(&slot).copied() { + Some((key, parent)) => { + if self.keys.insert(slot, key).is_some() { + return false; + } + self.parent_slots.insert(slot, parent); + self.children.entry(parent).or_default().push(slot); + self.insert(parent); + true + } + None => false, + } + } +} + +/// A pending 64-byte Keccak operation captured before execution. +#[derive(Clone, Copy, Debug)] +pub struct PendingMappingHash { + /// The effective storage address of the executing frame. + pub address: Address, + /// The memory offset containing the Keccak preimage. + pub offset: usize, +} + +/// Captures a 64-byte Keccak operation before execution. +pub fn capture_hash(interpreter: &Interpreter) -> Option { + if interpreter.bytecode.opcode() != opcode::KECCAK256 + || interpreter.stack.peek(1).ok()? != U256::from(0x40) + { + return None; + } + Some(PendingMappingHash { + address: interpreter.input.target_address, + offset: interpreter.stack.peek(0).ok()?.try_into().ok()?, + }) +} + +/// Records a successfully executed 64-byte Keccak operation after memory expansion. +pub fn record_hash( + mapping_slots: &mut AddressHashMap, + interpreter: &Interpreter, + pending: PendingMappingHash, +) { + let Ok(result) = interpreter.stack.peek(0) else { return }; + let data = interpreter.memory.slice_len(pending.offset, 0x40); + let key = B256::from_slice(&data[..0x20]); + let parent = B256::from_slice(&data[0x20..]); + mapping_slots.entry(pending.address).or_default().record_hash(result.into(), key, parent); +} + +/// Function to be used in `Inspector::step` to record mapping slots. +#[cold] +pub fn step(mapping_slots: &mut AddressHashMap, interpreter: &Interpreter) { + if interpreter.bytecode.opcode() == opcode::SSTORE + && let Some(mapping_slots) = mapping_slots.get_mut(&interpreter.input.target_address) + && let Ok(slot) = interpreter.stack.peek(0) + { + mapping_slots.insert(slot.into()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::keccak256; + + #[test] + fn resolves_mapping_keys_from_root_to_leaf() { + let root = B256::with_last_byte(1); + let owner = B256::with_last_byte(2); + let spender = B256::with_last_byte(3); + let inner = keccak256([owner.as_slice(), root.as_slice()].concat()); + let slot = keccak256([spender.as_slice(), inner.as_slice()].concat()); + let mut slots = MappingSlots::default(); + slots.record_hash(inner, owner, root); + slots.record_hash(slot, spender, inner); + + assert_eq!( + slots.resolve(slot), + Some(MappingProvenance { root_slot: root, keys: vec![owner, spender] }) + ); + } + + #[test] + fn rejects_plain_slots_offsets_and_cycles() { + let root = B256::with_last_byte(1); + let key = B256::with_last_byte(2); + let slot = keccak256([key.as_slice(), root.as_slice()].concat()); + let mut slots = MappingSlots::default(); + slots.record_hash(slot, key, root); + + assert!(slots.resolve(root).is_none()); + assert!(slots.resolve(B256::from(Into::::into(slot) + U256::ONE)).is_none()); + + slots.record_hash(root, key, slot); + assert!(slots.resolve(slot).is_none()); + } +} diff --git a/crates/common/src/preprocessor/data.rs b/crates/common/src/preprocessor/data.rs index 6cc988137e167..b920323629a2f 100644 --- a/crates/common/src/preprocessor/data.rs +++ b/crates/common/src/preprocessor/data.rs @@ -1,9 +1,9 @@ use super::span_to_range; use foundry_compilers::artifacts::{Source, Sources}; use path_slash::PathExt; -use solar_parse::interface::{Session, SourceMap}; -use solar_sema::{ - hir::{Contract, ContractId, Hir}, +use solar::sema::{ + Gcx, + hir::{Contract, ContractId}, interface::source_map::FileName, }; use std::{ @@ -15,42 +15,6 @@ use std::{ /// Contract id -> Contract data definition mapping. pub type PreprocessorData = BTreeMap; -/// Collects preprocessor data from referenced contracts. -pub(crate) fn collect_preprocessor_data( - sess: &Session, - hir: &Hir<'_>, - referenced_contracts: &HashSet, -) -> PreprocessorData { - let mut data = PreprocessorData::default(); - for contract_id in referenced_contracts { - let contract = hir.contract(*contract_id); - let source = hir.source(contract.source); - - let FileName::Real(path) = &source.file.name else { - continue; - }; - - let contract_data = - ContractData::new(hir, *contract_id, contract, path, source, sess.source_map()); - data.insert(*contract_id, contract_data); - } - data -} - -/// Creates helper libraries for contracts with a non-empty constructor. -/// -/// See [`ContractData::build_helper`] for more details. -pub(crate) fn create_deploy_helpers(data: &BTreeMap) -> Sources { - let mut deploy_helpers = Sources::new(); - for (contract_id, contract) in data { - if let Some(code) = contract.build_helper() { - let path = format!("foundry-pp/DeployHelper{}.sol", contract_id.get()); - deploy_helpers.insert(path.into(), Source::new(code)); - } - } - deploy_helpers -} - /// Keeps data about a contract constructor. #[derive(Debug)] pub struct ContractConstructorData { @@ -58,13 +22,19 @@ pub struct ContractConstructorData { pub abi_encode_args: String, /// Constructor struct fields. pub struct_fields: String, + /// Generated helper contract identifier. + pub helper_contract: String, + /// Generated constructor argument struct identifier. + pub args_struct: String, + /// Generated ABI encoding function identifier. + pub encode_function: String, + /// Generated helper source-unit path. + pub helper_path: PathBuf, } /// Keeps data about a single contract definition. #[derive(Debug)] pub(crate) struct ContractData { - /// HIR Id of the contract. - contract_id: ContractId, /// Path of the source file. path: PathBuf, /// Name of the contract @@ -77,47 +47,65 @@ pub(crate) struct ContractData { impl ContractData { fn new( - hir: &Hir<'_>, + gcx: Gcx<'_>, contract_id: ContractId, contract: &Contract<'_>, path: &Path, - source: &solar_sema::hir::Source<'_>, - source_map: &SourceMap, + source: &solar::sema::hir::Source<'_>, + reserved_identifiers: &str, + source_units: &[PathBuf], ) -> Self { - let artifact = format!("{}:{}", path.to_slash_lossy(), contract.name); + let artifact = + solidity_string_content(&format!("{}:{}", path.to_slash_lossy(), contract.name)); // Process data for contracts with constructor and parameters. let constructor_data = contract .ctor - .map(|ctor_id| hir.function(ctor_id)) + .map(|ctor_id| gcx.hir.function(ctor_id)) .filter(|ctor| !ctor.parameters.is_empty()) .map(|ctor| { + let contract_id = contract_id.index(); let mut abi_encode_args = vec![]; let mut struct_fields = vec![]; let mut arg_index = 0; for param_id in ctor.parameters { - let src = source.file.src.as_str(); - let loc = span_to_range(source_map, hir.variable(*param_id).span); - let mut new_src = src[loc].replace(" memory ", " ").replace(" calldata ", " "); - if let Some(ident) = hir.variable(*param_id).name { - abi_encode_args.push(format!("args.{}", ident.name)); + let param = gcx.hir.variable(*param_id); + let loc = span_to_range(gcx.sess.source_map(), param.ty.span); + let ty = &source.file.src[loc]; + let name = if let Some(ident) = param.name { + ident.name.to_string() } else { - // Generate an unique name if constructor arg doesn't have one. + // Generate a unique name if the constructor arg does not have one. arg_index += 1; - abi_encode_args.push(format!("args.foundry_pp_ctor_arg{arg_index}")); - new_src.push_str(&format!(" foundry_pp_ctor_arg{arg_index}")); - } - struct_fields.push(new_src); + unique_identifier( + reserved_identifiers, + format!("foundry_pp_ctor_arg{arg_index}"), + ) + }; + abi_encode_args.push(format!("args.{name}")); + struct_fields.push(format!("{ty} {name}")); } ContractConstructorData { abi_encode_args: abi_encode_args.join(", "), struct_fields: struct_fields.join("; "), + helper_contract: unique_identifier( + reserved_identifiers, + format!("DeployHelper{contract_id}"), + ), + args_struct: unique_identifier( + reserved_identifiers, + "FoundryPpConstructorArgs".to_string(), + ), + encode_function: unique_identifier( + reserved_identifiers, + format!("encodeArgs{contract_id}"), + ), + helper_path: deploy_helper_path(contract_id, source_units), } }); Self { - contract_id, path: path.to_path_buf(), name: contract.name.to_string(), constructor_data, @@ -169,13 +157,16 @@ impl ContractData { /// vm.deployCode("artifact path", encodeArgs335(DeployHelper335.FoundryPpConstructorArgs({name: name, symbol: symbol}))) /// ``` pub fn build_helper(&self) -> Option { - let Self { contract_id, path, name, constructor_data, artifact: _ } = self; + let Self { path, name, constructor_data, artifact: _ } = self; let Some(constructor_details) = constructor_data else { return None }; - let contract_id = contract_id.get(); let struct_fields = &constructor_details.struct_fields; let abi_encode_args = &constructor_details.abi_encode_args; + let helper_contract = &constructor_details.helper_contract; + let args_struct = &constructor_details.args_struct; + let encode_function = &constructor_details.encode_function; + let path = solidity_string_content(path.to_slash_lossy().as_ref()); let helper = format!( r#" // SPDX-License-Identifier: MIT @@ -183,19 +174,143 @@ pragma solidity >=0.4.0; import "{path}"; -abstract contract DeployHelper{contract_id} is {name} {{ - struct FoundryPpConstructorArgs {{ +abstract contract {helper_contract} is {name} {{ + struct {args_struct} {{ {struct_fields}; }} }} -function encodeArgs{contract_id}(DeployHelper{contract_id}.FoundryPpConstructorArgs memory args) pure returns (bytes memory) {{ +function {encode_function}({helper_contract}.{args_struct} memory args) pure returns (bytes memory) {{ return abi.encode({abi_encode_args}); }} "#, - path = path.to_slash_lossy(), ); Some(helper) } } + +fn unique_identifier(source: &str, mut identifier: String) -> String { + while source.contains(&identifier) { + identifier.push('_'); + } + identifier +} + +fn solidity_string_content(value: &str) -> String { + value.chars().fold(String::new(), |mut escaped, char| { + match char { + '\\' => escaped.push_str("\\\\"), + '"' => escaped.push_str("\\\""), + '\n' => escaped.push_str("\\n"), + '\r' => escaped.push_str("\\r"), + '\t' => escaped.push_str("\\t"), + char => escaped.push(char), + } + escaped + }) +} + +/// Collects preprocessor data from referenced contracts. +pub(crate) fn collect_preprocessor_data( + gcx: Gcx<'_>, + referenced_contracts: &HashSet, + root_dir: &Path, + source_units: &[PathBuf], +) -> PreprocessorData { + let mut data = PreprocessorData::default(); + let reserved_identifiers = gcx + .hir + .source_ids() + .map(|source_id| gcx.hir.source(source_id).file.src.as_str()) + .collect::>() + .join("\n"); + for contract_id in referenced_contracts { + let contract = gcx.hir.contract(*contract_id); + let source = gcx.hir.source(contract.source); + + let FileName::Real(path) = &source.file.name else { + continue; + }; + + // Match the compiler input paths in generated imports and artifact references. + let path = path.strip_prefix(root_dir).unwrap_or(path); + let contract_data = ContractData::new( + gcx, + *contract_id, + contract, + path, + source, + &reserved_identifiers, + source_units, + ); + data.insert(*contract_id, contract_data); + } + data +} + +/// Creates helper libraries for contracts with a non-empty constructor. +/// +/// See [`ContractData::build_helper`] for more details. +pub(crate) fn create_deploy_helpers(data: &BTreeMap) -> Sources { + let mut deploy_helpers = Sources::new(); + for contract in data.values() { + if let Some(code) = contract.build_helper() { + let path = contract.constructor_data.as_ref().unwrap().helper_path.clone(); + deploy_helpers.insert(path, Source::new(code)); + } + } + deploy_helpers +} + +/// Directory of the deploy helper sources generated by the preprocessor. +const DEPLOY_HELPERS_DIR: &str = "foundry-pp"; + +/// Returns a generated helper path that cannot overwrite an existing source unit. +pub(crate) fn deploy_helper_path(contract_id: usize, source_units: &[PathBuf]) -> PathBuf { + let mut stem = format!("DeployHelper{contract_id}"); + loop { + let path = PathBuf::from(format!("{DEPLOY_HELPERS_DIR}/{stem}.sol")); + if !source_units.iter().any(|source_unit| source_unit == &path) { + return path; + } + stem.push('_'); + } +} + +/// Returns whether the path points to a deploy helper source generated by the preprocessor. +/// +/// These sources only exist in the compiler input and are never written to disk. +pub fn is_deploy_helper_path(path: &Path) -> bool { + path.parent().and_then(Path::file_name).is_some_and(|dir| dir == DEPLOY_HELPERS_DIR) + && path + .file_stem() + .and_then(|stem| stem.to_str()) + .is_some_and(|stem| stem.starts_with("DeployHelper")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deploy_helper_path_does_not_replace_source_units() { + let source_units = [ + PathBuf::from("foundry-pp/DeployHelper7.sol"), + PathBuf::from("foundry-pp/DeployHelper7_.sol"), + ]; + + assert_eq!( + deploy_helper_path(7, &source_units), + PathBuf::from("foundry-pp/DeployHelper7__.sol") + ); + } + + #[test] + fn is_deploy_helper_path_matches_generated_helpers() { + assert!(is_deploy_helper_path(Path::new("foundry-pp/DeployHelper7.sol"))); + assert!(is_deploy_helper_path(Path::new("/project/foundry-pp/DeployHelper7_.sol"))); + assert!(!is_deploy_helper_path(Path::new("/project/src/DeployHelper7.sol"))); + assert!(!is_deploy_helper_path(Path::new("/project/foundry-pp/Counter.sol"))); + } +} diff --git a/crates/common/src/preprocessor/deps.rs b/crates/common/src/preprocessor/deps.rs index 01d90f3a0380e..fddf31bfc2cab 100644 --- a/crates/common/src/preprocessor/deps.rs +++ b/crates/common/src/preprocessor/deps.rs @@ -1,13 +1,28 @@ use super::{ - data::{ContractData, PreprocessorData}, + data::{ContractData, PreprocessorData, deploy_helper_path}, span_to_range, }; -use foundry_compilers::Updates; +use crate::fs::{canonicalize_path, normalize_path}; +use foundry_compilers::{ + ProjectPathsConfig, Updates, + artifacts::{SolcLanguage, remappings::Remapping}, + project::{NativeDependencies, NativeDependencyState, PreprocessorState}, +}; use itertools::Itertools; -use solar_parse::interface::Session; -use solar_sema::{ - hir::{CallArgs, ContractId, Expr, ExprKind, Hir, NamedArg, Stmt, StmtKind, TypeKind, Visit}, - interface::{SourceMap, data_structures::Never, source_map::FileName}, +use path_slash::PathExt; +use solar::{ + ast::{ItemKind, UserDefinableOperator}, + sema::{ + Gcx, Hir, + builtins::Builtin, + hir::{ + CallArgs, CallArgsKind, CallOptions, Contract, ContractId, ContractKind, Expr, + ExprKind, Function, FunctionId, FunctionKind, Modifier, Res, SourceId, StateMutability, + Stmt, StmtKind, TypeKind, UsingDirective, UsingEntryKind, Variable, VariableId, + Visibility, Visit, + }, + interface::{SourceMap, Symbol, data_structures::Never, source_map::FileName}, + }, }; use std::{ collections::{BTreeMap, BTreeSet, HashSet}, @@ -15,6 +30,43 @@ use std::{ path::{Path, PathBuf}, }; +/// Compiler and source context whose validation would be lost by rewriting construction. +#[derive(Clone, Copy)] +pub(super) struct ConstructorContext { + pub abi_coder_v2: bool, + pub supports_create2: bool, +} + +impl ConstructorContext { + fn for_source(mut self, gcx: Gcx<'_>, source: SourceId) -> Self { + let ast = gcx + .sources + .get_file(&gcx.hir.source(source).file) + .and_then(|(_, source)| source.ast.as_ref()); + let Some(ast) = ast else { + self.abi_coder_v2 = false; + return self; + }; + for item in ast.items.iter() { + if let ItemKind::Pragma(pragma) = &item.kind + && let Some((name, Some(value))) = pragma.tokens.as_name_and_value() + { + match (name.as_str(), value.as_str()) { + ("abicoder", "v1") => { + self.abi_coder_v2 = false; + return self; + } + ("abicoder", "v2") | ("experimental", "ABIEncoderV2") => { + self.abi_coder_v2 = true; + } + _ => {} + } + } + } + self + } +} + /// Holds data about referenced source contracts and bytecode dependencies. pub(crate) struct PreprocessorDependencies { // Mapping contract id to preprocess -> contract bytecode dependencies. @@ -24,75 +76,251 @@ pub(crate) struct PreprocessorDependencies { } impl PreprocessorDependencies { + #[allow(clippy::too_many_arguments)] pub fn new( - sess: &Session, - hir: &Hir<'_>, + gcx: Gcx<'_>, + constructor_context: ConstructorContext, paths: &[PathBuf], - src_dir: &Path, - root_dir: &Path, + script_paths: &HashSet, + project_paths: &ProjectPathsConfig, + source_units: &[PathBuf], mocks: &mut HashSet, + preprocessor_state: &mut PreprocessorState, ) -> Self { + let relative_paths = project_paths.paths_relative(); + let src_dir = &relative_paths.sources; + let root_dir = &project_paths.root; + let remappings = &project_paths.remappings; let mut preprocessed_contracts = BTreeMap::new(); let mut referenced_contracts = HashSet::new(); - for contract_id in hir.contract_ids() { - let contract = hir.contract(contract_id); - let source = hir.source(contract.source); + let mut current_mocks = HashSet::new(); + let mut current_native_dependencies = NativeDependencies::new(); + let candidate_files = + paths.iter().map(|path| normalize_path(&root_dir.join(path))).collect::>(); + let mut conservative_files = HashSet::new(); + let global_using_dependencies = using_dependency_sources( + gcx, + gcx.hir + .source_ids() + .flat_map(|id| gcx.hir.source(id).usings) + .filter(|directive| directive.global), + ); - let FileName::Real(path) = &source.file.name else { - continue; - }; + // Helper closure for iterating candidate contracts to preprocess (tests and scripts). + let candidate_contracts = || { + gcx.hir.contract_ids().filter_map(|id| { + let contract = gcx.hir.contract(id); + let source = gcx.hir.source(contract.source); + let FileName::Real(path) = &source.file.name else { + return None; + }; - // Collect dependencies only for tests and scripts. - if !paths.contains(path) { - let path = path.display(); - trace!("{path} is not test or script"); - continue; + if !paths.contains(path) { + trace!("{} is not test or script", path.display()); + return None; + } + + Some((id, contract, source, path)) + }) + }; + + // An internal call can observe return data left by another function. Analyze the whole + // owning contract and its reachable helpers before deciding which scopes can be rewritten. + let mut native_return_data_contracts = HashSet::new(); + for (id, _, _, _) in candidate_contracts() { + let mut observer = ReturnDataObserver::new(gcx); + let _ = observer.visit_nested_contract(id); + if observer.observes_return_data { + native_return_data_contracts.insert(id); + native_return_data_contracts.extend(observer.contracts); } + } - // Do not collect dependencies for mock contracts. Walk through base contracts and - // check if they're from src dir. - if contract.linearized_bases.iter().any(|base_contract_id| { - let base_contract = hir.contract(*base_contract_id); - let FileName::Real(path) = &hir.source(base_contract.source).file.name else { - return false; - }; - path.starts_with(src_dir) - }) { - // Record mock contracts to be evicted from preprocessed cache. - mocks.insert(root_dir.join(path)); - let path = path.display(); - trace!("found mock contract {path}"); + // Collect current mocks. + for (_, contract, _, path) in candidate_contracts() { + let full_path = normalize_path(&root_dir.join(path)); + let mut inherited_dependencies = BTreeSet::new(); + let mut inherits_source_contract = false; + for base_id in contract.linearized_bases { + let base = gcx.hir.contract(*base_id); + if let FileName::Real(base_path) = &gcx.hir.source(base.source).file.name { + let base_path = normalize_path(&root_dir.join(base_path)); + let is_source_contract = is_path_in_dir(&base_path, src_dir, root_dir); + inherits_source_contract |= is_source_contract; + if base_path != full_path { + inherited_dependencies.insert(base_path); + } + } + } + if inherits_source_contract { + trace!("found mock contract {}", full_path.display()); + current_mocks.insert(full_path.clone()); + } + if !inherited_dependencies.is_empty() { + add_native_dependencies( + &mut current_native_dependencies, + full_path, + inherited_dependencies, + ); + } + } + + // Collect dependencies for non-mock test/script contracts. + for (contract_id, contract, source, path) in candidate_contracts() { + let full_path = normalize_path(&root_dir.join(path)); + + if current_mocks.contains(&full_path) { + trace!("{} is a mock, skipping", path.display()); continue; - } else { - // Make sure current contract is not in list of mocks (could happen when a contract - // which used to be a mock is refactored to a non-mock implementation). - mocks.remove(&root_dir.join(path)); } + // Treat the contract as a script when its file lives under the configured script + // directory, or when it inherits from a `Script` base (forge-std). The inheritance + // check covers atypical layouts where script contracts are placed under `src/`. + let is_script = script_paths.contains(path) + || contract + .linearized_bases + .iter() + .skip(1) + .any(|base_id| gcx.hir.contract(*base_id).name.as_str() == "Script"); let mut deps_collector = BytecodeDependencyCollector::new( - sess.source_map(), - hir, - source.file.src.as_str(), + gcx, + contract_id, + path, src_dir, + root_dir, + is_script, + constructor_context.for_source(gcx, contract.source), ); + deps_collector.preserve_native_bytecode = + native_return_data_contracts.contains(&contract_id); + let mut using_dependencies = global_using_dependencies.clone(); + using_dependencies.extend(using_dependency_sources( + gcx, + source.usings.iter().chain( + contract + .linearized_bases + .iter() + .flat_map(|base_id| gcx.hir.contract(*base_id).usings), + ), + )); + for source_id in using_dependencies { + deps_collector.collect_source_dependencies(source_id); + } // Analyze current contract. let _ = deps_collector.walk_contract(contract); + if deps_collector.has_unresolved_native_dependency { + conservative_files.insert(full_path.clone()); + } + if !deps_collector.native_dependencies.is_empty() { + add_native_dependencies( + &mut current_native_dependencies, + full_path.clone(), + deps_collector.native_dependencies, + ); + } + deps_collector.dependencies.retain(|dependency| { + let dependency_id = dependency.referenced_contract; + let dependency = gcx.hir.contract(dependency_id); + let dependency_source = gcx.hir.source(dependency.source); + let FileName::Real(dependency_path) = &dependency_source.file.name else { + conservative_files.insert(full_path.clone()); + return false; + }; + let has_constructor_args = dependency + .ctor + .is_some_and(|ctor_id| !gcx.hir.function(ctor_id).parameters.is_empty()); + if can_rewrite( + dependency_path, + path, + root_dir, + source_units, + remappings, + has_constructor_args, + dependency_id, + ) { + true + } else { + add_native_dependencies( + &mut current_native_dependencies, + full_path.clone(), + [normalize_path(&root_dir.join(dependency_path))], + ); + false + } + }); // Ignore empty test contracts declared in source files with other contracts. if !deps_collector.dependencies.is_empty() { preprocessed_contracts.insert(contract_id, deps_collector.dependencies); } - // Record collected referenced contract ids. - referenced_contracts.extend(deps_collector.referenced_contracts); } + + for file in conservative_files { + current_native_dependencies.insert(file, NativeDependencyState::Conservative); + } + + // Replace classifications only for files examined in this compiler job. This clears stale + // mocks after a file is refactored while preserving fallback state across narrower jobs. + for file in candidate_files { + let state = current_native_dependencies.remove(&file); + if preprocessor_state.update(file.clone(), state) { + mocks.remove(&file); + } + } + mocks.extend(current_mocks); + + for dependencies in preprocessed_contracts.values() { + referenced_contracts.extend(dependencies.iter().map(|dep| dep.referenced_contract)); + } + Self { preprocessed_contracts, referenced_contracts } } } +/// Adds exact dependency paths unless the source is already classified conservatively. +fn add_native_dependencies( + dependencies: &mut NativeDependencies, + file: PathBuf, + incoming: impl IntoIterator, +) { + match dependencies.entry(file) { + std::collections::btree_map::Entry::Vacant(entry) => { + entry.insert(NativeDependencyState::Known(incoming.into_iter().collect())); + } + std::collections::btree_map::Entry::Occupied(entry) => { + if let NativeDependencyState::Known(dependencies) = entry.into_mut() { + dependencies.extend(incoming); + } + } + } +} + +/// Returns sources whose code can be embedded through the given `using for` directives. +fn using_dependency_sources<'gcx>( + gcx: Gcx<'gcx>, + directives: impl IntoIterator>, +) -> HashSet { + directives + .into_iter() + .flat_map(|directive| directive.entries) + .flat_map(|entry| match entry.kind { + UsingEntryKind::Library(contract_id) => vec![gcx.hir.contract(contract_id).source], + UsingEntryKind::Functions(function_ids) => function_ids + .iter() + .map(|function_id| gcx.hir.function(*function_id).source) + .collect(), + UsingEntryKind::Err(_) => Vec::new(), + }) + .collect() +} + /// Represents a bytecode dependency kind. #[derive(Debug)] enum BytecodeDependencyKind { /// `type(Contract).creationCode` CreationCode, + /// `type(Contract).runtimeCode`. + RuntimeCode, /// `new Contract`. New { /// Contract name. @@ -105,8 +333,6 @@ enum BytecodeDependencyKind { value: Option, /// `salt` (if any) used when creating contract. salt: Option, - /// Whether it's a try contract creation statement. - try_stmt: bool, }, } @@ -119,94 +345,419 @@ pub(crate) struct BytecodeDependency { loc: Range, /// HIR id of referenced contract. referenced_contract: ContractId, + /// The original expression must reach Solc to preserve its validation. + preserve_native: bool, } /// Walks over contract HIR and collects [`BytecodeDependency`]s and referenced contracts. -struct BytecodeDependencyCollector<'hir> { +struct BytecodeDependencyCollector<'gcx, 'src> { /// Source map, used for determining contract item locations. - source_map: &'hir SourceMap, - /// Parsed HIR. - hir: &'hir Hir<'hir>, - /// Source content of current contract. - src: &'hir str, + gcx: Gcx<'gcx>, + /// Contract whose lexically owned bytecode references may be rewritten. + owner_contract: ContractId, + /// Constructor validation context of the source being rewritten. + constructor_context: ConstructorContext, + /// Source path of the current contract. + source_path: PathBuf, /// Project source dir, used to determine if referenced contract is a source contract. - src_dir: &'hir Path, + src_dir: &'src Path, + /// Project root, used to compare relative and absolute source paths. + root_dir: &'src Path, + /// Whether the contract being analyzed lives in a script file. + /// Script bytecode references must not be rewritten: native script CREATE/CREATE2 frames + /// are handled by the script execution inspector, and `type(Contract).creationCode` must keep + /// its native mutability semantics. + is_script: bool, + /// Whether `type(Contract).creationCode` should keep native Solidity semantics. + preserve_native_creation_code: bool, + /// Whether bytecode references are being visited outside the owning contract's rewrite scope. + preserve_native_bytecode: bool, /// Dependencies collected for current contract. dependencies: Vec, - /// Unique HIR ids of contracts referenced from current contract. - referenced_contracts: HashSet, + /// Dependencies that cannot be rewritten and remain embedded in the importer bytecode. + native_dependencies: BTreeSet, + /// Whether a native dependency could not be assigned a stable filesystem identity. + has_unresolved_native_dependency: bool, + /// Functions followed while finding transitively embedded implementation code. + visited_functions: HashSet, + /// Imported sources already classified as native dependencies. + visited_sources: HashSet, + /// Constants followed while finding embedded initializers, including aliases and cycles. + visited_variables: HashSet, } -impl<'hir> BytecodeDependencyCollector<'hir> { +impl<'gcx, 'src> BytecodeDependencyCollector<'gcx, 'src> { fn new( - source_map: &'hir SourceMap, - hir: &'hir Hir<'hir>, - src: &'hir str, - src_dir: &'hir Path, + gcx: Gcx<'gcx>, + owner_contract: ContractId, + source_path: &Path, + src_dir: &'src Path, + root_dir: &'src Path, + is_script: bool, + constructor_context: ConstructorContext, ) -> Self { Self { - source_map, - hir, - src, + gcx, + owner_contract, + constructor_context, + source_path: normalize_path(&root_dir.join(source_path)), src_dir, + root_dir, + is_script, + preserve_native_creation_code: false, + preserve_native_bytecode: false, dependencies: vec![], - referenced_contracts: HashSet::default(), + native_dependencies: BTreeSet::new(), + has_unresolved_native_dependency: false, + visited_functions: HashSet::new(), + visited_sources: HashSet::new(), + visited_variables: HashSet::new(), } } - /// Collects reference identified as bytecode dependency of analyzed contract. - /// Discards any reference that is not in project src directory (e.g. external - /// libraries or mock contracts that extend source contracts). + /// Visits embedded implementation code without allowing edits outside the owning contract. + fn collect_function_dependency(&mut self, function_id: FunctionId) { + let function = self.gcx.hir.function(function_id); + if function.contract == Some(self.owner_contract) + || !self.visited_functions.insert(function_id) + { + return; + } + + let source = self.gcx.hir.source(function.source); + if let FileName::Real(path) = &source.file.name { + let path = normalize_path(&self.root_dir.join(path)); + if path != self.source_path { + self.native_dependencies.insert(path); + } + } else { + self.has_unresolved_native_dependency = true; + } + + let previous = self.preserve_native_bytecode; + self.preserve_native_bytecode = true; + let _ = self.visit_function(function); + self.preserve_native_bytecode = previous; + } + + /// Visits an expression for dependencies without rewriting within its source range. + fn collect_native_expr(&mut self, expr: &'gcx Expr<'gcx>) { + let previous = self.preserve_native_bytecode; + self.preserve_native_bytecode = true; + let _ = self.visit_expr(expr); + self.preserve_native_bytecode = previous; + } + + /// Records a source containing embedded code and all of its transitive imports. + fn collect_source_dependencies(&mut self, source_id: SourceId) { + if !self.visited_sources.insert(source_id) { + return; + } + let source = self.gcx.hir.source(source_id); + if let FileName::Real(path) = &source.file.name { + let path = normalize_path(&self.root_dir.join(path)); + if path != self.source_path { + self.native_dependencies.insert(path); + } + } else { + self.has_unresolved_native_dependency = true; + } + for &(_, imported_source) in source.imports { + self.collect_source_dependencies(imported_source); + } + } + + /// Classifies a bytecode dependency as rewritable or native. fn collect_dependency(&mut self, dependency: BytecodeDependency) { - let contract = self.hir.contract(dependency.referenced_contract); - let source = self.hir.source(contract.source); + let contract = self.gcx.hir.contract(dependency.referenced_contract); + let source = self.gcx.hir.source(contract.source); let FileName::Real(path) = &source.file.name else { + self.has_unresolved_native_dependency = true; return; }; + let native_path = normalize_path(&self.root_dir.join(path)); + + if self.preserve_native_bytecode || dependency.preserve_native { + self.native_dependencies.insert(native_path); + return; + } - if !path.starts_with(self.src_dir) { + if matches!(&dependency.kind, BytecodeDependencyKind::RuntimeCode) { + self.native_dependencies.insert(native_path); + return; + } + + // Script bytecode references must not be rewritten. See field doc on `is_script`. + if self.is_script { + match &dependency.kind { + BytecodeDependencyKind::CreationCode | BytecodeDependencyKind::RuntimeCode => { + trace!("skip creationCode in script"); + self.native_dependencies.insert(native_path); + return; + } + BytecodeDependencyKind::New { .. } => { + trace!("skip new-expression in script"); + self.native_dependencies.insert(native_path); + return; + } + } + } + + // `type(Contract).creationCode` has native `pure` semantics. Rewriting it to a `view` + // cheatcode call would make valid pure functions fail to compile. + if self.preserve_native_creation_code + && matches!(&dependency.kind, BytecodeDependencyKind::CreationCode) + { + trace!("skip creationCode in native creationCode context"); + self.native_dependencies.insert(native_path); + return; + } + + let has_constructor_args = contract + .ctor + .is_some_and(|ctor_id| !self.gcx.hir.function(ctor_id).parameters.is_empty()); + // Solidity only permits a custom layout on the most-derived contract, so the generated + // constructor helper cannot inherit a target that declares one; keep this dependency + // native. + if contract.layout.is_some() && has_constructor_args { + trace!("skip dependency on custom-layout contract"); + self.native_dependencies.insert(native_path); + return; + } + + // Constructor parameter types are copied into a derived helper contract. Private + // constants used as array dimensions are not accessible in that scope. + if constructor_uses_private_constants(self.gcx, contract) { + self.native_dependencies.insert(native_path); + return; + } + + // Remapped imports can have absolute or symlinked paths, while compiler input paths are + // relative and configured source directories can be canonicalized. + if !is_path_in_dir(path, self.src_dir, self.root_dir) { let path = path.display(); - trace!("ignore dependency {path}"); + trace!("keep external dependency {path} native"); + self.native_dependencies.insert(native_path); return; } - self.referenced_contracts.insert(dependency.referenced_contract); self.dependencies.push(dependency); } + + /// Follows constants whose initializer can embed code from another source. + fn collect_variable_dependency(&mut self, id: VariableId) { + let variable = self.gcx.hir.variable(id); + if !variable.is_constant() || !self.visited_variables.insert(id) { + return; + } + if let FileName::Real(path) = &self.gcx.hir.source(variable.source).file.name { + let path = normalize_path(&self.root_dir.join(path)); + if path != self.source_path { + self.native_dependencies.insert(path); + } + } else { + self.has_unresolved_native_dependency = true; + } + if let Some(initializer) = variable.initializer { + self.collect_native_expr(initializer); + } + } +} + +/// Returns whether constructor parameter types reference private constants. +fn constructor_uses_private_constants(gcx: Gcx<'_>, contract: &Contract<'_>) -> bool { + contract.ctor.is_some_and(|ctor| { + gcx.hir.function(ctor).parameters.iter().any(|¶m| { + gcx.hir + .variable(param) + .ty + .visit(&gcx.hir, &mut |ty| { + if let TypeKind::Array(array) = &ty.kind + && let Some(size) = array.size + { + size.visit(&mut |expr| { + if gcx.resolved_variable(expr).is_some_and(|var| { + gcx.hir.variable(var).visibility == Some(Visibility::Private) + }) { + return ControlFlow::Break(()); + } + ControlFlow::Continue(()) + })?; + } + ControlFlow::Continue(()) + }) + .is_break() + }) + }) +} + +/// Returns whether generated helper and artifact references preserve the source-unit identity. +fn can_rewrite( + path: &Path, + source_path: &Path, + root_dir: &Path, + source_units: &[PathBuf], + remappings: &[Remapping], + has_constructor_args: bool, + contract_id: ContractId, +) -> bool { + let generated_path = path.strip_prefix(root_dir).unwrap_or(path); + if !source_units.iter().any(|source_unit| source_unit == generated_path) + || source_units.iter().filter(|source_unit| source_unit.ends_with(generated_path)).count() + != 1 + { + return false; + } + + // Runtime artifact lookup uses the running test's context, which can differ from the source + // containing an inherited helper. Any remapping matching the generated path is therefore + // unsafe unless every possible runtime context is known. + if remappings.iter().any(|remapping| remapping_matches_path(remapping, generated_path)) { + return false; + } + + if !has_constructor_args { + return true; + } + + let helper_path = deploy_helper_path(contract_id.index(), source_units); + !remappings.iter().any(|remapping| { + // The test imports the generated helper, which in turn imports the dependency. + remapping_applies(remapping, &helper_path, source_path, root_dir) + || remapping_applies(remapping, generated_path, &helper_path, root_dir) + }) +} + +/// Returns whether `path` resolves within `dir`, accepting relative, absolute, and symlinked paths. +fn is_path_in_dir(path: &Path, dir: &Path, root_dir: &Path) -> bool { + let path = normalize_path(&root_dir.join(path)); + let dir = normalize_path(&root_dir.join(dir)); + path.starts_with(&dir) + || canonicalize_path(path) + .is_ok_and(|path| canonicalize_path(dir).is_ok_and(|dir| path.starts_with(dir))) +} + +/// Returns whether a generated import would be redirected by `remapping`. +fn remapping_applies( + remapping: &Remapping, + import_path: &Path, + source_unit: &Path, + root_dir: &Path, +) -> bool { + let source_unit = source_unit.strip_prefix(root_dir).unwrap_or(source_unit).to_slash_lossy(); + remapping + .context + .as_ref() + .is_none_or(|context| source_unit.starts_with(Path::new(context).to_slash_lossy().as_ref())) + && remapping_matches_path(remapping, import_path) } -impl<'hir> Visit<'hir> for BytecodeDependencyCollector<'hir> { +/// Returns whether `path` has the string prefix selected by `remapping`. +fn remapping_matches_path(remapping: &Remapping, path: &Path) -> bool { + path.to_slash_lossy().starts_with(&remapping.name) +} + +impl<'gcx> Visit<'gcx> for BytecodeDependencyCollector<'gcx, '_> { type BreakValue = Never; - fn hir(&self) -> &'hir Hir<'hir> { - self.hir + fn hir(&self) -> &'gcx Hir<'gcx> { + &self.gcx.hir + } + + fn visit_function(&mut self, func: &'gcx Function<'gcx>) -> ControlFlow { + let previous = self.preserve_native_creation_code; + self.preserve_native_creation_code = previous + || func.state_mutability == StateMutability::Pure + || matches!(func.kind, FunctionKind::Modifier); + self.walk_function(func)?; + self.preserve_native_creation_code = previous; + ControlFlow::Continue(()) } - fn visit_expr(&mut self, expr: &'hir Expr<'hir>) -> ControlFlow { + fn visit_var(&mut self, var: &'gcx Variable<'gcx>) -> ControlFlow { + let previous = self.preserve_native_creation_code; + self.preserve_native_creation_code |= var.is_constant(); + self.walk_var(var)?; + self.preserve_native_creation_code = previous; + ControlFlow::Continue(()) + } + + fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow { + #[allow(clippy::collapsible_match)] match &expr.kind { - ExprKind::Call(call_expr, call_args, named_args) => { + ExprKind::Ident(resolutions) => { + for &resolution in *resolutions { + match resolution { + Res::Namespace(source_id) => { + self.collect_source_dependencies(source_id); + } + Res::Item(item) => { + if let Some(function_id) = item.as_function() { + self.collect_function_dependency(function_id); + } else if let Some(variable_id) = item.as_variable() { + self.collect_variable_dependency(variable_id); + } + } + _ => {} + } + } + } + ExprKind::Call(callee, call_args) => { + let (call_expr, named_args) = callee.split_call_options(); if let Some(dependency) = handle_call_expr( - self.src, - self.source_map, + self.gcx, + self.constructor_context, expr, call_expr, call_args, - named_args, - false, + &named_args, ) { self.collect_dependency(dependency); + // Call options are copied into the replacement expression. Keep their + // bytecode references native so edits cannot overlap the outer replacement. + self.visit_expr(call_expr)?; + if let Some(call_options) = named_args { + for arg in call_options.args { + self.collect_native_expr(&arg.value); + } + } + self.visit_call_args(call_args)?; + return ControlFlow::Continue(()); + } + if let Some(function_id) = self.gcx.resolved_function(call_expr) { + self.collect_function_dependency(function_id); } } ExprKind::Member(member_expr, ident) => { + // Solar does not resolve `Library.function` member expressions as functions. The + // internal library implementation is embedded in the caller, so classify the + // library source and its imports as native dependencies directly. + if let ExprKind::Ident(resolutions) = member_expr.kind { + for resolution in resolutions { + if let Res::Item(item) = resolution + && let Some(contract_id) = item.as_contract() + { + let contract = self.gcx.hir.contract(contract_id); + if contract.kind == ContractKind::Library { + self.collect_source_dependencies(contract.source); + } + } + } + } if let ExprKind::TypeCall(ty) = &member_expr.kind && let TypeKind::Custom(contract_id) = &ty.kind - && ident.name.as_str() == "creationCode" && let Some(contract_id) = contract_id.as_contract() + && let kind = match ident.name.as_str() { + "creationCode" => BytecodeDependencyKind::CreationCode, + "runtimeCode" => BytecodeDependencyKind::RuntimeCode, + _ => return self.walk_expr(expr), + } { self.collect_dependency(BytecodeDependency { - kind: BytecodeDependencyKind::CreationCode, - loc: span_to_range(self.source_map, expr.span), + kind, + loc: span_to_range(self.gcx.sess.source_map(), expr.span), referenced_contract: contract_id, + preserve_native: false, }); } } @@ -215,20 +766,17 @@ impl<'hir> Visit<'hir> for BytecodeDependencyCollector<'hir> { self.walk_expr(expr) } - fn visit_stmt(&mut self, stmt: &'hir Stmt<'hir>) -> ControlFlow { + fn visit_stmt(&mut self, stmt: &'gcx Stmt<'gcx>) -> ControlFlow { if let StmtKind::Try(stmt_try) = stmt.kind - && let ExprKind::Call(call_expr, call_args, named_args) = &stmt_try.expr.kind - && let Some(dependency) = handle_call_expr( - self.src, - self.source_map, - &stmt_try.expr, - call_expr, - call_args, - named_args, - true, - ) + && let ExprKind::Call(callee, ..) = &stmt_try.expr.kind + && let (call_expr, _) = callee.split_call_options() + && matches!(call_expr.kind, ExprKind::New(_)) { - self.collect_dependency(dependency); + // Keep try deployments native: a static-context violation halts the current frame, + // whereas a deployment cheatcode revert could be caught by an untyped try. Typed + // returns also require native creation to preserve the constructor catch boundary. + self.collect_native_expr(&stmt_try.expr); + for clause in stmt_try.clauses { for &var in clause.args { self.visit_nested_var(var)?; @@ -245,42 +793,49 @@ impl<'hir> Visit<'hir> for BytecodeDependencyCollector<'hir> { /// Helper function to analyze and extract bytecode dependency from a given call expression. fn handle_call_expr( - src: &str, - source_map: &SourceMap, + gcx: Gcx<'_>, + context: ConstructorContext, parent_expr: &Expr<'_>, call_expr: &Expr<'_>, call_args: &CallArgs<'_>, - named_args: &Option<&[NamedArg<'_>]>, - try_stmt: bool, + call_options: &Option<&CallOptions<'_>>, ) -> Option { if let ExprKind::New(ty_new) = &call_expr.kind && let TypeKind::Custom(item_id) = ty_new.kind && let Some(contract_id) = item_id.as_contract() { - let name_loc = span_to_range(source_map, ty_new.span); - let name = &src[name_loc]; + let source_map = gcx.sess.source_map(); + let name = source_map.span_to_snippet(ty_new.span).ok()?; - // Calculate offset to remove named args, e.g. for an expression like - // `new Counter {value: 333} ( address(this))` - // the offset will be used to replace `{value: 333} ( ` with `(` - let call_args_offset = if named_args.is_some() && !call_args.is_empty() { - (call_args.span.lo() - ty_new.span.hi()).to_usize() - } else { + // Calculate the offset to remove call options and parentheses between the new type and + // constructor arguments. For example, in `new Counter {value: 333} (address(this))`, the + // offset is used to replace `{value: 333} (` with `(`. This also removes closing + // parentheses around the callee when no call options are present. + let call_args_offset = if call_args.is_empty() { 0 + } else { + (call_args.span.lo() - ty_new.span.hi()).to_usize() }; let args_len = parent_expr.span.hi() - ty_new.span.hi(); return Some(BytecodeDependency { kind: BytecodeDependencyKind::New { - name: name.to_string(), + name, args_length: args_len.to_usize(), call_args_offset, - value: named_arg(src, named_args, "value", source_map), - salt: named_arg(src, named_args, "salt", source_map), - try_stmt, + value: named_arg(call_options, "value", source_map), + salt: named_arg(call_options, "salt", source_map), }, - loc: span_to_range(source_map, call_expr.span), + // The HIR callee excludes parentheses, so start at the full call expression. + loc: span_to_range(source_map, parent_expr.span.with_hi(call_expr.span.hi())), referenced_contract: contract_id, + preserve_native: !valid_constructor_call( + gcx, + context, + contract_id, + call_args, + call_options, + ), }); } None @@ -288,30 +843,37 @@ fn handle_call_expr( /// Helper function to extract value of a given named arg. fn named_arg( - src: &str, - named_args: &Option<&[NamedArg<'_>]>, + call_options: &Option<&CallOptions<'_>>, arg: &str, source_map: &SourceMap, ) -> Option { - named_args.unwrap_or_default().iter().find(|named_arg| named_arg.name.as_str() == arg).map( - |named_arg| { - let named_arg_loc = span_to_range(source_map, named_arg.value.span); - src[named_arg_loc].to_string() - }, - ) + call_options + .map(|options| options.args) + .unwrap_or_default() + .iter() + .find(|named_arg| named_arg.name.as_str() == arg) + .and_then(|named_arg| source_map.span_to_snippet(named_arg.value.span).ok()) } /// Goes over all test/script files and replaces bytecode dependencies with cheatcode /// invocations. +/// +/// Try deployments remain native to preserve their constructor failure boundaries. pub(crate) fn remove_bytecode_dependencies( - hir: &Hir<'_>, + gcx: Gcx<'_>, deps: &PreprocessorDependencies, data: &PreprocessorData, ) -> Updates { let mut updates = Updates::default(); + let reserved_identifiers = gcx + .hir + .source_ids() + .map(|source_id| gcx.hir.source(source_id).file.src.as_str()) + .collect::>() + .join("\n"); for (contract_id, deps) in &deps.preprocessed_contracts { - let contract = hir.contract(*contract_id); - let source = hir.source(contract.source); + let contract = gcx.hir.contract(*contract_id); + let source = gcx.hir.source(contract.source); let FileName::Real(path) = &source.file.name else { continue; }; @@ -319,10 +881,12 @@ pub(crate) fn remove_bytecode_dependencies( let updates = updates.entry(path.clone()).or_default(); let mut used_helpers = BTreeSet::new(); - let vm_interface_name = format!("VmContractHelper{}", contract_id.get()); + let vm_interface_name = unique_identifier( + &reserved_identifiers, + format!("VmContractHelper{}", contract_id.index()), + ); // `address(uint160(uint256(keccak256("hevm cheat code"))))` let vm = format!("{vm_interface_name}(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D)"); - for dep in deps { let Some(ContractData { artifact, constructor_data, .. }) = data.get(&dep.referenced_contract) @@ -339,19 +903,18 @@ pub(crate) fn remove_bytecode_dependencies( format!("{vm}.getCode(\"{artifact}\")"), )); } + BytecodeDependencyKind::RuntimeCode => { + unreachable!("runtimeCode is never rewritten") + } BytecodeDependencyKind::New { name, args_length, call_args_offset, value, salt, - try_stmt, } => { - let (mut update, closing_seq) = if *try_stmt { - (String::new(), "})") - } else { - (format!("{name}(payable("), "})))") - }; + let mut update = format!("{name}(payable("); + let closing_seq = "})))"; update.push_str(&format!("{vm}.deployCode({{")); update.push_str(&format!("_artifact: \"{artifact}\"")); @@ -365,14 +928,22 @@ pub(crate) fn remove_bytecode_dependencies( update.push_str(&format!("_salt: {salt}")); } - if constructor_data.is_some() { - // Insert our helper + if let Some(constructor_data) = constructor_data { + // Insert our helper. used_helpers.insert(dep.referenced_contract); + let helper_contract = unique_identifier( + &reserved_identifiers, + constructor_data.helper_contract.clone(), + ); + let encode_function = unique_identifier( + &reserved_identifiers, + constructor_data.encode_function.clone(), + ); update.push_str(", "); update.push_str(&format!( - "_args: encodeArgs{id}(DeployHelper{id}.FoundryPpConstructorArgs", - id = dep.referenced_contract.get() + "_args: {}({}.{}", + encode_function, helper_contract, constructor_data.args_struct, )); updates.insert((dep.loc.start, dep.loc.end + call_args_offset, update)); @@ -388,12 +959,23 @@ pub(crate) fn remove_bytecode_dependencies( } }; } - let helper_imports = used_helpers.into_iter().map(|id| { - let id = id.get(); - format!( - "import {{DeployHelper{id}, encodeArgs{id}}} from \"foundry-pp/DeployHelper{id}.sol\";", - ) - }).join("\n"); + + let helper_imports = used_helpers + .into_iter() + .map(|id| { + let constructor = data[&id].constructor_data.as_ref().unwrap(); + let helper_contract = &constructor.helper_contract; + let encode_function = &constructor.encode_function; + let local_helper = + unique_identifier(&reserved_identifiers, helper_contract.clone()); + let local_encoder = + unique_identifier(&reserved_identifiers, encode_function.clone()); + let helper_import = import_alias(helper_contract, &local_helper); + let encoder_import = import_alias(encode_function, &local_encoder); + let helper_path = constructor.helper_path.to_slash_lossy(); + format!("import {{{helper_import}, {encoder_import}}} from \"{helper_path}\";",) + }) + .join("\n"); updates.insert(( source.file.src.len(), source.file.src.len(), @@ -410,10 +992,317 @@ interface {vm_interface_name} {{ function deployCode(string memory _artifact, uint256 _value, bytes32 _salt) external returns (address); function deployCode(string memory _artifact, bytes memory _args, uint256 _value) external returns (address); function deployCode(string memory _artifact, bytes memory _args, uint256 _value, bytes32 _salt) external returns (address); - function getCode(string memory _artifact) external returns (bytes memory); + function getCode(string memory _artifact) external view returns (bytes memory); }}"# ), )); } updates } + +/// Returns an identifier that cannot collide with text in the original source. +fn unique_identifier(source: &str, mut identifier: String) -> String { + while source.contains(&identifier) { + identifier.push('_'); + } + identifier +} + +fn import_alias(identifier: &str, local: &str) -> String { + if identifier == local { identifier.to_string() } else { format!("{identifier} as {local}") } +} + +/// Checks constraints that disappear when `new` is replaced by a cheatcode call. +/// The generated argument struct retains type checks, but not source ABI-coder restrictions. +/// Keep parameterized ABI-coder-v1 calls native rather than duplicating Solc ABI validation. +fn valid_constructor_call( + gcx: Gcx<'_>, + context: ConstructorContext, + id: ContractId, + args: &CallArgs<'_>, + options: &Option<&CallOptions<'_>>, +) -> bool { + let contract = gcx.hir.contract(id); + if contract.kind != ContractKind::Contract { + return false; + } + let constructor = contract.ctor.map(|id| gcx.hir.function(id)); + let parameters = constructor.map_or(&[][..], |ctor| ctor.parameters); + if args.len() != parameters.len() || (!parameters.is_empty() && !context.abi_coder_v2) { + return false; + } + if let CallArgsKind::Named(args) = args.kind { + let mut names = HashSet::new(); + if args.iter().any(|arg| { + !names.insert(arg.name.name) + || !parameters.iter().any(|id| { + gcx.hir.variable(*id).name.is_some_and(|name| name.name == arg.name.name) + }) + }) { + return false; + } + } + if let Some(options) = options { + let mut names = HashSet::new(); + if options.args.iter().any(|arg| { + !names.insert(arg.name.name) + || match arg.name.as_str() { + "salt" => !context.supports_create2, + "value" => constructor + .is_none_or(|ctor| ctor.state_mutability != StateMutability::Payable), + _ => true, + } + }) { + return false; + } + } + true +} + +/// Finds return-buffer observations in a contract and the helpers it can call internally. +struct ReturnDataObserver<'gcx> { + gcx: Gcx<'gcx>, + observes_return_data: bool, + functions: HashSet, + contracts: HashSet, + visited_contracts: HashSet, + member_functions: HashSet<(FunctionId, Symbol)>, + sources: HashSet, + operator_functions: HashSet<(FunctionId, UserDefinableOperator)>, +} + +impl<'gcx> ReturnDataObserver<'gcx> { + fn new(gcx: Gcx<'gcx>) -> Self { + Self { + gcx, + observes_return_data: false, + functions: HashSet::new(), + contracts: HashSet::new(), + visited_contracts: HashSet::new(), + member_functions: HashSet::new(), + sources: HashSet::new(), + operator_functions: HashSet::new(), + } + } + + fn collect_member_functions(&mut self, source: SourceId, contract: Option) { + let bases = contract + .into_iter() + .flat_map(|id| self.gcx.hir.contract(id).linearized_bases) + .copied() + .collect::>(); + for &id in &bases { + self.member_functions.extend( + self.gcx + .hir + .contract(id) + .functions() + .filter_map(|id| self.gcx.hir.function(id).name.map(|name| (id, name.name))), + ); + } + let directives = self + .gcx + .hir + .source(source) + .usings + .iter() + .chain(bases.iter().flat_map(|&id| self.gcx.hir.contract(id).usings)) + .chain( + self.gcx + .hir + .source_ids() + .flat_map(|id| self.gcx.hir.source(id).usings) + .filter(|directive| directive.global), + ); + for directive in directives { + for entry in directive.entries { + match entry.kind { + UsingEntryKind::Library(id) => self.member_functions.extend( + self.gcx.hir.contract(id).functions().filter_map(|id| { + self.gcx.hir.function(id).name.map(|name| (id, name.name)) + }), + ), + UsingEntryKind::Functions(ids) => { + if let Some(operator) = entry.operator { + self.operator_functions.extend(ids.iter().map(|&id| (id, operator))); + } + self.member_functions.extend(ids.iter().copied().filter_map(|id| { + entry + .name + .or_else(|| self.gcx.hir.function(id).name.map(|name| name.name)) + .map(|name| (id, name)) + })) + } + UsingEntryKind::Err(_) => {} + } + } + } + } +} + +impl<'gcx> Visit<'gcx> for ReturnDataObserver<'gcx> { + type BreakValue = Never; + + fn hir(&self) -> &'gcx Hir<'gcx> { + &self.gcx.hir + } + + fn visit_nested_source(&mut self, id: SourceId) -> ControlFlow { + if self.sources.insert(id) { + let source = self.gcx.hir.source(id); + self.walk_nested_source(id)?; + for &(_, id) in source.imports { + self.visit_nested_source(id)?; + } + } + ControlFlow::Continue(()) + } + + fn visit_nested_contract(&mut self, id: ContractId) -> ControlFlow { + if self.visited_contracts.insert(id) { + let contract = self.gcx.hir.contract(id); + self.contracts.insert(id); + // State initializers run before functions are visited, but can already call methods + // supplied by contract-scoped using directives. + self.collect_member_functions(contract.source, Some(id)); + // An inherited observer can call a derived override that produces return data. Treat + // the complete inheritance hierarchy as one execution scope before rewriting it. + for &base in contract.linearized_bases { + if base != id { + self.visit_nested_contract(base)?; + } + } + self.walk_contract(contract)?; + } + ControlFlow::Continue(()) + } + + fn visit_nested_function(&mut self, id: FunctionId) -> ControlFlow { + if self.functions.insert(id) { + let function = self.gcx.hir.function(id); + self.collect_member_functions(function.source, function.contract); + if let Some(id) = function.contract { + self.contracts.insert(id); + } + self.walk_function(function)?; + } + ControlFlow::Continue(()) + } + + fn visit_modifier(&mut self, modifier: &'gcx Modifier<'gcx>) -> ControlFlow { + if let Some(id) = modifier.id.as_function() { + self.visit_nested_function(id)?; + } + self.walk_modifier(modifier) + } + + fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow { + let operator = match &expr.kind { + ExprKind::Unary(op, _) => UserDefinableOperator::from_unop(op.kind), + ExprKind::Binary(_, op, _) => UserDefinableOperator::from_binop(op.kind), + _ => None, + }; + if let Some(operator) = operator { + // Type checking has not selected an overload yet, so visit every visible binding. + let functions = self + .operator_functions + .iter() + .filter_map(|&(id, bound)| (bound == operator).then_some(id)) + .collect::>(); + for id in functions { + self.visit_nested_function(id)?; + } + } + match &expr.kind { + ExprKind::Ident(resolutions) => { + for resolution in *resolutions { + match resolution { + Res::Builtin(Builtin::YulReturndatasize | Builtin::YulReturndatacopy) => { + self.observes_return_data = true; + } + Res::Item(item) => { + if let Some(id) = item.as_function() { + self.visit_nested_function(id)?; + } + } + _ => {} + } + } + } + ExprKind::Call(callee, _) => { + let (callee, _) = callee.split_call_options(); + if let Some(id) = self.gcx.resolved_function(callee) { + self.visit_nested_function(id)?; + } + } + ExprKind::Member(member, name) => { + // Resolve the complete namespace path, including renamed re-exports and library + // members, without visiting unrelated declarations in the imported sources. + let mut names = vec![*name]; + let mut root = member.peel_parens(); + while let ExprKind::Member(parent, name) = &root.kind { + names.push(*name); + root = parent.peel_parens(); + } + if let ExprKind::Ident(resolutions) = &root.kind { + names.reverse(); + for resolution in *resolutions { + if let Res::Namespace(source) = resolution { + if let Some(resolutions) = + self.gcx.source_path_resolutions(&names, *source, None) + && let Some(targets) = resolutions.last() + { + for target in targets { + if let Res::Item(item) = target { + if let Some(id) = item.as_function() { + self.visit_nested_function(id)?; + } else if let Some(id) = item.as_variable() { + self.visit_nested_var(id)?; + } + } + } + } else { + // Retain the conservative fallback when name resolution is + // incomplete. + self.visit_nested_source(*source)?; + } + } + } + } + // Type checking has not run, so include every visible overload of an inherited + // or using-for method with this name. + let functions = self + .member_functions + .iter() + .copied() + .filter_map(|(id, attached_name)| (attached_name == name.name).then_some(id)) + .collect::>(); + for id in functions { + self.visit_nested_function(id)?; + } + if let ExprKind::Ident(resolutions) = member.peel_parens().kind { + for resolution in resolutions { + if let Res::Item(item) = resolution + && let Some(id) = item.as_contract() + && self.gcx.hir.contract(id).kind == ContractKind::Library + { + for id in self.gcx.hir.contract(id).functions() { + if self + .gcx + .hir + .function(id) + .name + .is_some_and(|ident| ident.name == name.name) + { + self.visit_nested_function(id)?; + } + } + } + } + } + } + _ => {} + } + self.walk_expr(expr) + } +} diff --git a/crates/common/src/preprocessor/mod.rs b/crates/common/src/preprocessor/mod.rs index fca7546e74a72..ec9e6c17ab9bc 100644 --- a/crates/common/src/preprocessor/mod.rs +++ b/crates/common/src/preprocessor/mod.rs @@ -1,109 +1,223 @@ +use crate::{errors::convert_solar_errors, fs::normalize_path}; use foundry_compilers::{ - Compiler, Language, ProjectPathsConfig, apply_updates, - artifacts::SolcLanguage, + ProjectPathsConfig, SourceParser, apply_updates, + artifacts::{EvmVersion, SolcLanguage}, error::Result, multi::{MultiCompiler, MultiCompilerInput, MultiCompilerLanguage}, - project::Preprocessor, + project::{ + NativeDependencies, NativeDependencyState, Preprocessor, PreprocessorState, + merge_native_dependencies, + }, + resolver::{ + Graph, + parse::{SolData, SolParser}, + }, solc::{SolcCompiler, SolcVersionedInput}, }; -use solar_parse::{ - ast::Span, - interface::{Session, SourceMap}, +use solar::parse::{ast::Span, interface::SourceMap}; +use std::{ + collections::{BTreeSet, HashSet}, + ops::{ControlFlow, Range}, + path::PathBuf, }; -use solar_sema::{ParsingContext, thread_local::ThreadLocal}; -use std::{collections::HashSet, ops::Range, path::PathBuf}; mod data; use data::{collect_preprocessor_data, create_deploy_helpers}; -mod deps; -use deps::{PreprocessorDependencies, remove_bytecode_dependencies}; +pub use data::is_deploy_helper_path; -/// Returns the range of the given span in the source map. -#[track_caller] -fn span_to_range(source_map: &SourceMap, span: Span) -> Range { - source_map.span_to_source(span).unwrap().1 -} +mod deps; +use deps::{ConstructorContext, PreprocessorDependencies, remove_bytecode_dependencies}; +/// Preprocessor that replaces static bytecode linking in tests and scripts (`new Contract`) with +/// dynamic linkage through (`Vm.create*`). +/// +/// This allows for more efficient caching when iterating on tests. +/// +/// See . #[derive(Debug)] -pub struct TestOptimizerPreprocessor; +pub struct DynamicTestLinkingPreprocessor; + +impl Preprocessor for DynamicTestLinkingPreprocessor { + fn cache_version(&self) -> u64 { + 8 + } -impl Preprocessor for TestOptimizerPreprocessor { fn preprocess( + &self, + solc: &SolcCompiler, + input: &mut SolcVersionedInput, + paths: &ProjectPathsConfig, + mocks: &mut HashSet, + ) -> Result<()> { + let source_units = input.input.sources.keys().cloned().collect::>(); + self.preprocess_with_dependencies( + solc, + input, + paths, + mocks, + &mut PreprocessorState::untracked(), + &source_units, + ) + } + + #[instrument(name = "DynamicTestLinkingPreprocessor::preprocess", skip_all)] + fn preprocess_with_dependencies( &self, _solc: &SolcCompiler, input: &mut SolcVersionedInput, paths: &ProjectPathsConfig, mocks: &mut HashSet, + preprocessor_state: &mut PreprocessorState, + source_units: &[PathBuf], ) -> Result<()> { // Skip if we are not preprocessing any tests or scripts. Avoids unnecessary AST parsing. if !input.input.sources.iter().any(|(path, _)| paths.is_test_or_script(path)) { - trace!("no tests or sources to preprocess"); + trace!("no tests or scripts to preprocess"); return Ok(()); } - let sess = solar_session_from_solc(input); - let _ = sess.enter_parallel(|| -> solar_parse::interface::Result { - // Set up the parsing context with the project paths. - let mut parsing_context = solar_pcx_from_solc_no_sources(&sess, input, paths); + let constructor_context = ConstructorContext { + abi_coder_v2: input.version >= semver::Version::new(0, 8, 0), + // Without an explicit target, preserve native validation rather than guessing the + // selected compiler's default EVM version. + supports_create2: input + .input + .settings + .evm_version + .is_some_and(|version| version >= EvmVersion::Constantinople), + }; + let original_sources = input.input.sources.clone(); + let mut parser_paths = paths.clone(); + parser_paths.include_paths.extend(input.cli_settings.include_paths.iter().cloned()); + let mut compiler = + foundry_compilers::resolver::parse::SolParser::new(parser_paths.with_language_ref()) + .into_compiler(); + let result = compiler.enter_mut(|compiler| -> solar::interface::Result { + let mut pcx = compiler.parse(); // Add the sources into the context. // Include all sources in the source map so as to not re-load them from disk, but only // parse and preprocess tests and scripts. let mut preprocessed_paths = vec![]; + let mut script_paths = HashSet::new(); let sources = &mut input.input.sources; for (path, source) in sources.iter() { - if let Ok(src_file) = - sess.source_map().new_source_file(path.clone(), source.content.as_str()) + if let Ok(src_file) = compiler + .sess() + .source_map() + .new_source_file(path.clone(), source.content.as_str()) && paths.is_test_or_script(path) { - parsing_context.add_file(src_file); + pcx.add_file(src_file); + if paths.is_script(path) { + script_paths.insert(path.clone()); + } preprocessed_paths.push(path.clone()); } } // Parse and preprocess. - let hir_arena = ThreadLocal::new(); - if let Some(gcx) = parsing_context.parse_and_lower(&hir_arena)? { - let hir = &gcx.get().hir; - // Collect tests and scripts dependencies and identify mock contracts. - let deps = PreprocessorDependencies::new( - &sess, - hir, - &preprocessed_paths, - &paths.paths_relative().sources, - &paths.root, - mocks, - ); - // Collect data of source contracts referenced in tests and scripts. - let data = collect_preprocessor_data(&sess, hir, &deps.referenced_contracts); - - // Extend existing sources with preprocessor deploy helper sources. - sources.extend(create_deploy_helpers(&data)); - - // Generate and apply preprocessor source updates. - apply_updates(sources, remove_bytecode_dependencies(hir, &deps, &data)); - } + pcx.parse(); + let ControlFlow::Continue(()) = compiler.lower_asts()? else { return Ok(()) }; + compiler.dcx().has_errors()?; + let gcx = compiler.gcx(); + // Collect tests and scripts dependencies and identify mock contracts. + // Script paths are passed separately so salted new-expressions are left untouched + // (Foundry's broadcast redirects native CREATE2 through the deterministic factory, + // but vm.deployCode runs at a deeper depth and bypasses that redirect). + let deps = PreprocessorDependencies::new( + gcx, + constructor_context, + &preprocessed_paths, + &script_paths, + paths, + source_units, + mocks, + preprocessor_state, + ); + // Collect data of source contracts referenced in tests and scripts. + let data = collect_preprocessor_data( + gcx, + &deps.referenced_contracts, + &paths.root, + source_units, + ); + + // Extend existing sources with preprocessor deploy helper sources. + sources.extend(create_deploy_helpers(&data)); + + // Generate and apply preprocessor source updates. + apply_updates(sources, remove_bytecode_dependencies(gcx, &deps, &data)); Ok(()) }); - // Warn if any diagnostics emitted during content parsing. - if let Err(err) = sess.emitted_errors().unwrap() { - warn!("failed preprocessing {err}"); + let diagnostics = convert_solar_errors(compiler.dcx()); + if result.is_err() || diagnostics.is_err() { + let reason = diagnostics.err().map(|err| err.to_string()); + if let Some(err) = &reason { + warn!(%err, "dynamic test linking analysis failed; using native bytecode"); + } else { + warn!("dynamic test linking analysis failed; using native bytecode"); + } + input.input.sources = original_sources; + let dependencies = native_import_dependencies(&parser_paths, &input.input.sources); + let affected = dependencies.len(); + for (path, state) in dependencies { + if preprocessor_state.update(path.clone(), Some(state)) { + mocks.remove(&path); + } + } + if affected > 0 { + let reason = reason + .as_deref() + .and_then(solar_error_summary) + .unwrap_or_else(|| "Solar analysis failed".to_string()); + let _ = sh_warn!("dynamic test linking disabled for {affected} files: {reason}"); + } } Ok(()) } } -impl Preprocessor for TestOptimizerPreprocessor { +impl Preprocessor for DynamicTestLinkingPreprocessor { + fn cache_version(&self) -> u64 { + 8 + } + fn preprocess( &self, compiler: &MultiCompiler, - input: &mut ::Input, + input: &mut MultiCompilerInput, paths: &ProjectPathsConfig, mocks: &mut HashSet, + ) -> Result<()> { + let source_units = match input { + MultiCompilerInput::Solc(input) => { + input.input.sources.keys().cloned().collect::>() + } + _ => Vec::new(), + }; + self.preprocess_with_dependencies( + compiler, + input, + paths, + mocks, + &mut PreprocessorState::untracked(), + &source_units, + ) + } + + fn preprocess_with_dependencies( + &self, + compiler: &MultiCompiler, + input: &mut MultiCompilerInput, + paths: &ProjectPathsConfig, + mocks: &mut HashSet, + preprocessor_state: &mut PreprocessorState, + source_units: &[PathBuf], ) -> Result<()> { // Preprocess only Solc compilers. let MultiCompilerInput::Solc(input) = input else { return Ok(()) }; @@ -111,45 +225,551 @@ impl Preprocessor for TestOptimizerPreprocessor { let Some(solc) = &compiler.solc else { return Ok(()) }; let paths = paths.clone().with_language::(); - self.preprocess(solc, input, &paths, mocks) + >::preprocess_with_dependencies( + self, + solc, + input, + &paths, + mocks, + preprocessor_state, + source_units, + ) } } -fn solar_session_from_solc(solc: &SolcVersionedInput) -> Session { - use solar_parse::interface::config; - - Session::builder() - .with_buffer_emitter(Default::default()) - .opts(config::Opts { - language: match solc.input.language { - SolcLanguage::Solidity => config::Language::Solidity, - SolcLanguage::Yul => config::Language::Yul, - _ => unimplemented!(), - }, - - // TODO: ... - /* - evm_version: solc.input.settings.evm_version, - */ - ..Default::default() - }) - .build() +/// Tracks transitive imports of unmodified tests and scripts after analysis fails. +fn native_import_dependencies( + paths: &ProjectPathsConfig, + sources: &foundry_compilers::artifacts::Sources, +) -> NativeDependencies { + let Ok(graph) = Graph::::resolve_sources(paths, sources.clone()) else { + return sources + .keys() + .filter(|path| paths.is_test_or_script(path)) + .map(|path| { + (normalize_path(&paths.root.join(path)), NativeDependencyState::Conservative) + }) + .collect(); + }; + + // Keep conservative invalidation only where import extraction itself is incomplete. + let mut incomplete = HashSet::new(); + if graph.nodes.iter().any(|node| node.data.parse_result.is_err()) { + incomplete.extend( + graph + .nodes + .iter() + .filter(|node| SolData::parse(node.content(), node.path()).parse_result.is_err()) + .map(|node| normalize_path(node.path())), + ); + } + let graph_paths = graph.files().keys().cloned().collect::>(); + let (_, edges) = graph.into_sources(); + incomplete.extend(edges.unresolved_imports().iter().map(|(_, path)| normalize_path(path))); + + let mut dependencies = NativeDependencies::new(); + for path in sources.keys().filter(|path| paths.is_test_or_script(path)) { + let graph_path = paths.root.join(path); + let path = normalize_path(&graph_path); + let state = if graph_paths.contains(&graph_path) { + let imports = + edges.imports(&graph_path).into_iter().map(normalize_path).collect::>(); + if incomplete.contains(&path) || incomplete.iter().any(|path| imports.contains(path)) { + NativeDependencyState::Conservative + } else { + NativeDependencyState::Known(imports) + } + } else { + NativeDependencyState::Conservative + }; + merge_native_dependencies(&mut dependencies, NativeDependencies::from([(path, state)])); + } + dependencies } -fn solar_pcx_from_solc_no_sources<'sess>( - sess: &'sess Session, - solc: &SolcVersionedInput, - paths: &ProjectPathsConfig, -) -> ParsingContext<'sess> { - let mut pcx = ParsingContext::new(sess); - pcx.file_resolver.set_current_dir(solc.cli_settings.base_path.as_ref().unwrap_or(&paths.root)); - for remapping in &paths.remappings { - pcx.file_resolver.add_import_remapping(solar_sema::interface::config::ImportRemapping { - context: remapping.context.clone().unwrap_or_default(), - prefix: remapping.name.clone(), - path: remapping.path.clone(), - }); +/// Extracts the first rendered Solar error header without ANSI codes. +fn solar_error_summary(reason: &str) -> Option { + anstream::adapter::strip_str(reason) + .to_string() + .lines() + .map(str::trim) + .find(|line| line.starts_with("error:") || line.starts_with("error[")) + .map(str::to_string) +} + +/// Returns the range of the given span in the source map. +#[track_caller] +fn span_to_range(source_map: &SourceMap, span: Span) -> Range { + source_map.span_to_range(span).unwrap() +} + +#[cfg(test)] +mod tests { + use super::*; + use foundry_compilers::{CompilerInput, artifacts::Source, solc::SolcSettings}; + use semver::Version; + + fn input() -> (tempfile::TempDir, ProjectPathsConfig, SolcVersionedInput) { + let root = tempfile::tempdir().unwrap(); + let paths = ProjectPathsConfig::builder().root(root.path()).build().unwrap(); + let sources = [ + ("src/Dep.sol", "contract Dep {}"), + ("test/Mock.sol", "import '../src/Dep.sol'; contract Mock is Dep {}"), + ( + "test/Deploy.sol", + "import '../src/Dep.sol'; contract Deploy { bytes constant CODE = type(Dep).creationCode; bytes32 constant CODE_HASH = keccak256(type(Dep).creationCode); bytes32 immutable codeHash = keccak256(type(Dep).creationCode); function deploy() public returns (Dep) { return new Dep(); } function native() public pure returns (bytes memory) { return type(Dep).creationCode; } }", + ), + ] + .into_iter() + .map(|(path, content)| (PathBuf::from(path), Source::new(content))) + .collect(); + let input = SolcVersionedInput::build( + sources, + SolcSettings::default(), + SolcLanguage::Solidity, + Version::new(0, 8, 30), + ); + (root, paths, input) + } + + fn assert_preprocessed( + paths: &ProjectPathsConfig, + input: &SolcVersionedInput, + mocks: &HashSet, + ) { + let source = &input.input.sources[&PathBuf::from("test/Deploy.sol")].content; + assert!(!source.contains("return new Dep();"), "eligible deployment was not rewritten"); + assert!(source.contains("type(Dep).creationCode"), "native dependency was lost"); + assert!( + source.contains("bytes constant CODE = type(Dep).creationCode"), + "constant creation code was rewritten" + ); + assert!( + source.contains("bytes32 constant CODE_HASH = keccak256(type(Dep).creationCode)"), + "constant creation code hash was rewritten" + ); + assert!( + !source.contains("immutable codeHash = keccak256(type(Dep).creationCode)"), + "constant preservation leaked into the immutable initializer" + ); + assert!( + source.contains("immutable codeHash = keccak256(VmContractHelper"), + "immutable creation code was not rewritten" + ); + assert!(mocks.contains(&paths.root.join("test/Mock.sol"))); + } + + #[test] + fn direct_solc_preprocess_tracks_mocks_without_cache_context() { + let (_root, paths, mut input) = input(); + let mut mocks = HashSet::new(); + >::preprocess( + &DynamicTestLinkingPreprocessor, + &SolcCompiler::default(), + &mut input, + &paths, + &mut mocks, + ) + .unwrap(); + assert_preprocessed(&paths, &input, &mocks); + } + + #[test] + fn direct_multi_preprocess_tracks_mocks_without_cache_context() { + let (_root, paths, input) = input(); + let mut input = MultiCompilerInput::Solc(Box::new(input)); + let mut mocks = HashSet::new(); + >::preprocess( + &DynamicTestLinkingPreprocessor, + &MultiCompiler { solc: Some(SolcCompiler::default()), vyper: None }, + &mut input, + paths.with_language_ref(), + &mut mocks, + ) + .unwrap(); + let MultiCompilerInput::Solc(input) = input else { unreachable!() }; + assert_preprocessed(&paths, &input, &mocks); + } + + #[test] + fn constructor_abi_context_preserves_source_pragmas_and_version_defaults() { + for (minor, pragma, native) in [ + (7, "", true), + (8, "", false), + (8, "pragma abicoder /* comment */ v1;", true), + (7, "pragma abicoder v2;", false), + (7, "pragma experimental ABIEncoderV2;", false), + ] { + let (_root, paths, mut input) = input(); + input.version = Version::new(0, minor, 6); + input.input.sources = [ + ("src/Dep.sol", "contract Dep { constructor(uint256 n) {} }".to_string()), + ("test/Deploy.sol", format!("{pragma} import '../src/Dep.sol'; contract Deploy {{ function deploy() public {{ new Dep(7); }} }}")), + // A pragma in another source must not change this source's ABI context. + ("test/Other.sol", "pragma abicoder v2; import '../src/Dep.sol'; contract Other { function deploy() public { new Dep(7); } }".to_string()), + ].into_iter().map(|(path, source)| (PathBuf::from(path), Source::new(source))).collect(); + >::preprocess( + &DynamicTestLinkingPreprocessor, + &SolcCompiler::default(), + &mut input, + &paths, + &mut HashSet::new(), + ) + .unwrap(); + let source = &input.input.sources[&PathBuf::from("test/Deploy.sol")].content; + assert_eq!(source.contains("new Dep(7)"), native, "0.{minor}.6 {pragma}"); + let other = &input.input.sources[&PathBuf::from("test/Other.sol")].content; + assert!(!other.contains("new Dep(7)"), "unrelated v2 source was not rewritten"); + } + } + + #[test] + fn constructor_salt_uses_compiler_evm_target() { + for (evm_version, native) in [ + (None, true), + (Some(EvmVersion::Byzantium), true), + (Some(EvmVersion::Constantinople), false), + (Some(EvmVersion::Prague), false), + ] { + let (_root, paths, mut input) = input(); + input.input.settings.evm_version = evm_version; + input.input.sources.insert( + PathBuf::from("test/Deploy.sol"), + Source::new("pragma abicoder v1; import '../src/Dep.sol'; contract Deploy { function deploy() public { new Dep{salt: bytes32(0)}(); } function plain() public { new Dep(); } }"), + ); + >::preprocess( + &DynamicTestLinkingPreprocessor, + &SolcCompiler::default(), + &mut input, + &paths, + &mut HashSet::new(), + ) + .unwrap(); + let source = &input.input.sources[&PathBuf::from("test/Deploy.sol")].content; + assert_eq!(source.contains("new Dep{salt:"), native, "{evm_version:?}"); + assert!( + !source.contains("new Dep();"), + "ordinary parameterless CREATE was not rewritten" + ); + } + } + + #[test] + fn constructor_call_options_are_preserved() { + let (_root, paths, mut input) = input(); + input.input.sources.insert( + PathBuf::from("src/Dep.sol"), + Source::new("contract Dep { constructor() payable {} }"), + ); + input.input.sources.insert( + PathBuf::from("test/Deploy.sol"), + Source::new("import '../src/Dep.sol'; contract Deploy { function deploy() public { new Dep{value: 1, salt: bytes32(7)}(); } }"), + ); + >::preprocess( + &DynamicTestLinkingPreprocessor, + &SolcCompiler::default(), + &mut input, + &paths, + &mut HashSet::new(), + ) + .unwrap(); + let source = &input.input.sources[&PathBuf::from("test/Deploy.sol")].content; + assert!(!source.contains("new Dep{value:"), "deployment was not rewritten"); + assert!(source.contains("_value: 1"), "value option was not preserved"); + assert!(source.contains("_salt: bytes32(7)"), "salt option was not preserved"); + } + + #[test] + fn return_data_fallback_follows_helpers_without_disabling_unrelated_contracts() { + for (helper, declarations, expression) in [ + ( + "function size(uint256) view returns (uint256 n) { assembly { n := returndatasize() } }", + "import * as R from '../src/Read.sol';", + "R.size(0)", + ), + ( + "function size(uint256) view returns (uint256 n) { assembly { n := returndatasize() } }", + "import {size} from '../src/Read.sol'; using {size} for uint256;", + "uint256(0).size()", + ), + ( + "function size(uint256) view returns (uint256 n) { assembly { n := returndatasize() } }", + "import {size as readSize} from '../src/Read.sol'; using {readSize} for uint256;", + "uint256(0).readSize()", + ), + ( + "library R { function size(uint256) internal view returns (uint256 n) { assembly { n := returndatasize() } } }", + "import {R} from '../src/Read.sol'; using R for uint256;", + "uint256(0).size()", + ), + ( + "library R { function size(uint256) internal view returns (uint256 n) { assembly { n := returndatasize() } } }", + "import {R} from '../src/Read.sol';", + "R.size(0)", + ), + ( + "library R { function size(uint256) internal pure returns (uint256 n) { assembly { n := returndatasize() } } }", + "import {R} from '../src/Read.sol';", + "(R).size(0)", + ), + ( + "type Word is uint256; using {size as -} for Word global; function size(Word) pure returns (Word) { uint256 n; assembly { n := returndatasize() } return Word.wrap(n); }", + "import {Word} from '../src/Read.sol';", + "Word.unwrap(-Word.wrap(0))", + ), + ( + "type Word is uint256; using {size as +} for Word global; function size(Word, Word) pure returns (Word) { uint256 n; assembly { n := returndatasize() } return Word.wrap(n); }", + "import {Word} from '../src/Read.sol';", + "Word.unwrap(Word.wrap(0) + Word.wrap(0))", + ), + ] { + let (_root, paths, mut input) = input(); + input.input.sources.insert(PathBuf::from("src/Read.sol"), Source::new(helper)); + let observer = format!( + "{declarations} import '../src/Dep.sol'; contract Observer {{ function observe() public {{ new Dep(); require({expression} == 0); }} }}" + ); + let safe = "import '../src/Read.sol'; import '../src/Dep.sol'; contract Safe { function deploy() public { new Dep(); } }"; + input.input.sources.insert(PathBuf::from("test/Observer.sol"), Source::new(&observer)); + input.input.sources.insert(PathBuf::from("test/Safe.sol"), Source::new(safe)); + >::preprocess( + &DynamicTestLinkingPreprocessor, + &SolcCompiler::default(), + &mut input, + &paths, + &mut HashSet::new(), + ) + .unwrap(); + assert_eq!( + input.input.sources[&PathBuf::from("test/Observer.sol")].content.as_str(), + observer + ); + assert_ne!(input.input.sources[&PathBuf::from("test/Safe.sol")].content.as_str(), safe); + } + } + + #[test] + fn return_data_fallback_includes_inherited_observers() { + assert_return_data_scope_native( + "abstract contract Base { function make() internal virtual; function observe() public { make(); uint256 n; assembly { n := returndatasize() } require(n == 0); } } contract Derived is Base { function make() internal override { new Dep(); } }", + ); + } + + #[test] + fn return_data_fallback_collects_using_before_initializers() { + assert_return_data_scope_native( + "library Reader { function size(uint256) internal view returns (uint256 n) { assembly { n := returndatasize() } } } contract Initializer { using Reader for uint256; Dep target = new Dep(); uint256 observed = uint256(0).size(); function observe() public view { require(observed == 0); } }", + ); + } + + fn assert_return_data_scope_native(declarations: &str) { + let (_root, paths, mut input) = input(); + let observer = format!("import '../src/Dep.sol'; {declarations}"); + let safe = + "import '../src/Dep.sol'; contract Safe { function deploy() public { new Dep(); } }"; + input.input.sources.insert(PathBuf::from("test/Observer.sol"), Source::new(&observer)); + input.input.sources.insert(PathBuf::from("test/Safe.sol"), Source::new(safe)); + >::preprocess( + &DynamicTestLinkingPreprocessor, + &SolcCompiler::default(), + &mut input, + &paths, + &mut HashSet::new(), + ) + .unwrap(); + assert_eq!( + input.input.sources[&PathBuf::from("test/Observer.sol")].content.as_str(), + observer + ); + assert_ne!(input.input.sources[&PathBuf::from("test/Safe.sol")].content.as_str(), safe); + } + + #[test] + fn return_data_namespace_resolution_selects_exports() { + for (namespace, expression, native) in [ + ("Read", "R.identity(7)", false), + ("Read", "R.size(0)", true), + ("Export", "R.identity(7)", false), + ("Export", "R.observe(0)", true), + ("Nested", "R.Inner.identity(7)", false), + ("Nested", "R.Inner.observe(0)", true), + ("Read", "R.Reader.identity(7)", false), + ("Read", "R.Reader.size(0)", true), + ("Export", "R.Renamed.identity(7)", false), + ("Export", "R.Renamed.size(0)", true), + ] { + let (_root, paths, mut input) = input(); + for (path, source) in [ + ( + "src/Read.sol", + "function identity(uint256 n) pure returns (uint256) { return n; } function size(uint256) view returns (uint256 n) { assembly { n := returndatasize() } } library Reader { function identity(uint256 n) internal pure returns (uint256) { return n; } function size(uint256) internal view returns (uint256 n) { assembly { n := returndatasize() } } }", + ), + ( + "src/Export.sol", + "import {size as observe, identity, Reader as Renamed} from './Read.sol';", + ), + ("src/Nested.sol", "import * as Inner from './Export.sol';"), + ] { + input.input.sources.insert(PathBuf::from(path), Source::new(source)); + } + let source = format!( + "import '../src/Dep.sol'; import * as R from '../src/{namespace}.sol'; contract Case {{ function run() public {{ new Dep(); {expression}; }} }}" + ); + input.input.sources.insert(PathBuf::from("test/Case.sol"), Source::new(&source)); + >::preprocess( + &DynamicTestLinkingPreprocessor, + &SolcCompiler::default(), + &mut input, + &paths, + &mut HashSet::new(), + ) + .unwrap(); + let actual = &input.input.sources[&PathBuf::from("test/Case.sol")].content; + assert_eq!(actual.contains("new Dep();"), native, "{namespace}: {expression}"); + } + } + + #[test] + fn analysis_fallback_tracks_transitive_imports() { + let (_root, mut paths, mut input) = input(); + paths.remappings.push("dep/=lib/dep/".parse().unwrap()); + input.input.sources = [ + ("lib/dep/Leaf.sol", "contract Leaf {}"), + ("src/Middle.sol", "import 'dep/Leaf.sol'; contract Middle {}"), + ("src/Unrelated.sol", "contract Unrelated {}"), + ("test/Fallback.sol", "import '../src/Middle.sol'; contract Fallback is Missing {}"), + ("test/Independent.sol", "contract Independent {}"), + ("script/Deploy.sol", "import '../src/Middle.sol'; contract Deploy {}"), + ] + .into_iter() + .map(|(path, source)| (PathBuf::from(path), Source::new(source))) + .collect(); + for (path, source) in &input.input.sources { + let path = paths.root.join(path); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, source.content.as_str()).unwrap(); + } + let original = input.input.sources.clone(); + let mut mocks = HashSet::from([paths.root.join("test/Fallback.sol")]); + >::preprocess( + &DynamicTestLinkingPreprocessor, + &SolcCompiler::default(), + &mut input, + &paths, + &mut mocks, + ) + .unwrap(); + assert_eq!(input.input.sources, original); + assert!(mocks.is_empty()); + let dependencies = native_import_dependencies(&paths, &input.input.sources); + let imports = BTreeSet::from([ + paths.root.join("src/Middle.sol"), + paths.root.join("lib/dep/Leaf.sol"), + ]); + assert_eq!( + dependencies, + NativeDependencies::from([ + ( + paths.root.join("test/Fallback.sol"), + NativeDependencyState::Known(imports.clone()) + ), + (paths.root.join("script/Deploy.sol"), NativeDependencyState::Known(imports)), + ( + paths.root.join("test/Independent.sol"), + NativeDependencyState::Known(BTreeSet::new()) + ), + ]) + ); + } + + #[test] + fn analysis_fallback_limits_incomplete_imports_to_consumers() { + let (_root, paths, mut input) = input(); + input.input.sources = [ + ("src/AHealthy.sol", "contract AHealthy {}"), + ( + "test/Fallback.sol", + "import /* Solar's regex fallback cannot recover this */ '../src/AHealthy.sol'; contract Fallback { function fail() public { throw; } }", + ), + ( + "test/Healthy.sol", + "import '../src/AHealthy.sol'; contract Healthy is AHealthy {}", + ), + ("test/Independent.sol", "contract Independent {}"), + ] + .into_iter() + .map(|(path, source)| (PathBuf::from(path), Source::new(source))) + .collect(); + for (path, source) in &input.input.sources { + let path = paths.root.join(path); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, source.content.as_str()).unwrap(); + } + let dependencies = native_import_dependencies(&paths, &input.input.sources); + assert_eq!( + dependencies, + NativeDependencies::from([ + (paths.root.join("test/Fallback.sol"), NativeDependencyState::Conservative), + ( + paths.root.join("test/Healthy.sol"), + NativeDependencyState::Known(BTreeSet::from([paths + .root + .join("src/AHealthy.sol")])) + ), + ( + paths.root.join("test/Independent.sol"), + NativeDependencyState::Known(BTreeSet::new()) + ), + ]) + ); + } + + #[test] + fn analysis_fallback_merges_normalized_graph_paths() { + let (_root, mut paths, mut input) = input(); + paths.remappings.push("test/z/:src/=lib/alternate/".parse().unwrap()); + input.input.sources = [ + ("src/Dep.sol", "contract Dep {}"), + ("lib/alternate/Dep.sol", "contract Dep {}"), + ("test/A.sol", "import '../src/Dep.sol'; contract A is Dep {}"), + ("test/z/../A.sol", "import '../src/Dep.sol'; contract A is Dep {}"), + ] + .into_iter() + .map(|(path, source)| (PathBuf::from(path), Source::new(source))) + .collect(); + for (path, source) in &input.input.sources { + let path = paths.root.join(path); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, source.content.as_str()).unwrap(); + } + let dependencies = native_import_dependencies(&paths, &input.input.sources); + assert_eq!( + dependencies, + NativeDependencies::from([( + paths.root.join("test/A.sol"), + NativeDependencyState::Known(BTreeSet::from([ + paths.root.join("src/Dep.sol"), + paths.root.join("lib/alternate/Dep.sol") + ])) + )]) + ); + } + + #[test] + fn solar_error_summary_accepts_coded_and_colored_errors() { + assert_eq!( + solar_error_summary( + "solar reported errors:\n\n\u{1b}[1;91merror\u{1b}[0m\u{1b}[1m: boom\u{1b}[0m" + ), + Some("error: boom".to_string()) + ); + assert_eq!( + solar_error_summary("solar reported errors:\n\n\u{1b}[31merror[1234]: boom\u{1b}[0m"), + Some("error[1234]: boom".to_string()) + ); + assert_eq!( + solar_error_summary("solar reported errors:\n\nerror: boom"), + Some("error: boom".to_string()) + ); + assert_eq!(solar_error_summary("solar reported 1 error"), None); } - pcx.file_resolver.add_include_paths(solc.cli_settings.include_paths.iter().cloned()); - pcx } diff --git a/crates/common/src/provider/curl_transport.rs b/crates/common/src/provider/curl_transport.rs new file mode 100644 index 0000000000000..a44fc9dca49c2 --- /dev/null +++ b/crates/common/src/provider/curl_transport.rs @@ -0,0 +1,239 @@ +//! Transport that outputs equivalent curl commands instead of making RPC requests. + +use alloy_json_rpc::{RequestPacket, ResponsePacket}; +use alloy_rpc_types_engine::{Claims, JwtSecret}; +use alloy_transport::{TransportError, TransportFut}; +use eyre::Context; +use serde_json::Value; +use tower::Service; +use url::Url; + +/// A transport that prints curl commands instead of executing RPC requests. +/// +/// When a request is made through this transport, it will print the equivalent +/// curl command to stderr and return a dummy successful response. +#[derive(Clone, Debug)] +pub struct CurlTransport { + /// The URL to connect to. + url: Url, + /// The headers to use for requests. + headers: Vec, + /// The JWT to use for requests. + jwt: Option, +} + +impl CurlTransport { + /// Create a new curl transport with the given URL. + pub const fn new(url: Url) -> Self { + Self { url, headers: vec![], jwt: None } + } + + /// Set the headers for the transport. + pub fn with_headers(mut self, headers: Vec) -> Self { + self.headers = headers; + self + } + + /// Set the JWT Secret for the transport. + pub fn with_jwt(mut self, jwt: Option) -> Self { + self.jwt = jwt; + self + } + + /// Generate a curl command for a request. + fn generate_curl_command(&self, req: &RequestPacket) -> eyre::Result { + let payload_str = serde_json::to_string(req).unwrap_or_default(); + let escaped_payload = shell_escape(&payload_str); + + let mut cmd = String::from("curl -X POST"); + cmd.push_str(" -H 'Content-Type: application/json'"); + + if let Some(jwt_secret) = &self.jwt { + append_jwt_auth(&mut cmd, jwt_secret)?; + } + + for h in &self.headers { + cmd.push_str(&format!(" -H '{}'", shell_escape(h))); + } + + cmd.push_str(&format!(" --data-raw '{escaped_payload}'")); + cmd.push_str(&format!(" '{}'", shell_escape(self.url.as_str()))); + + Ok(cmd) + } + + /// Handle a request by printing the curl command. + pub fn request(&self, req: RequestPacket) -> TransportFut<'static> { + let curl_cmd_result = self.generate_curl_command(&req); + + Box::pin(async move { + match curl_cmd_result { + Ok(curl_cmd) => { + let _ = crate::sh_println!("{curl_cmd}"); + std::process::exit(0); + } + Err(e) => { + let _ = crate::sh_eprintln!("Error: {e:?}"); + std::process::exit(1); + } + } + }) + } +} + +impl Service for CurlTransport { + type Response = ResponsePacket; + type Error = TransportError; + type Future = TransportFut<'static>; + + #[inline] + fn poll_ready( + &mut self, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } + + #[inline] + fn call(&mut self, req: RequestPacket) -> Self::Future { + self.request(req) + } +} + +impl Service for &CurlTransport { + type Response = ResponsePacket; + type Error = TransportError; + type Future = TransportFut<'static>; + + #[inline] + fn poll_ready( + &mut self, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } + + #[inline] + fn call(&mut self, req: RequestPacket) -> Self::Future { + self.request(req) + } +} + +/// Escapes a string for use in a single-quoted shell argument. +fn shell_escape(s: &str) -> String { + s.replace('\'', "'\"'\"'") +} + +/// Build a JWT using a secret +fn build_jwt(jwt_secret: &str) -> eyre::Result { + // Decode jwt from hex, then generate claims (iat with current timestamp) + let secret = JwtSecret::from_hex(jwt_secret)?; + let claims = Claims::default(); + let token = secret.encode(&claims)?; + Ok(token) +} + +/// Appends a JWT Authorization header to a curl command. +fn append_jwt_auth(cmd: &mut String, jwt_secret: &str) -> eyre::Result<()> { + let jwt = build_jwt(jwt_secret).wrap_err("Invalid --jwt-secret provided")?; + + cmd.push_str(&format!(" -H 'Authorization: Bearer {}'", shell_escape(jwt.as_str()))); + + Ok(()) +} + +/// Generates a curl command for an RPC request. +/// +/// This is a standalone helper that can be used to generate curl commands +/// without going through the transport layer. +pub fn generate_curl_command( + url: &str, + method: &str, + params: Value, + headers: Option<&[String]>, + jwt_secret: Option<&str>, +) -> eyre::Result { + let payload = serde_json::json!({ + "jsonrpc": "2.0", + "method": method, + "params": params, + "id": 1 + }); + let payload_str = serde_json::to_string(&payload).unwrap_or_default(); + let escaped_payload = shell_escape(&payload_str); + + let mut cmd = String::from("curl -X POST"); + cmd.push_str(" -H 'Content-Type: application/json'"); + + if let Some(secret) = jwt_secret { + append_jwt_auth(&mut cmd, secret)?; + } + + if let Some(hdrs) = headers { + for h in hdrs { + cmd.push_str(&format!(" -H '{}'", shell_escape(h))); + } + } + + cmd.push_str(&format!(" --data-raw '{escaped_payload}'")); + cmd.push_str(&format!(" '{}'", shell_escape(url))); + + Ok(cmd) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_json_rpc::{Id, Request}; + + fn make_test_request() -> RequestPacket { + let req: Request> = Request::new("eth_blockNumber", Id::Number(1), vec![]); + let serialized = req.serialize().unwrap(); + RequestPacket::Single(serialized) + } + + #[test] + fn test_basic_curl_command() { + let transport = CurlTransport::new("https://eth.example.com".parse().unwrap()); + let req = make_test_request(); + let cmd = transport.generate_curl_command(&req).unwrap(); + assert!(cmd.contains("eth_blockNumber")); + assert!(cmd.contains("https://eth.example.com")); + assert!(cmd.contains("jsonrpc")); + } + + #[test] + fn test_curl_with_headers() { + let transport = CurlTransport::new("https://eth.example.com".parse().unwrap()) + .with_headers(vec!["X-Custom: value".to_string()]); + let req = make_test_request(); + let cmd = transport.generate_curl_command(&req).unwrap(); + assert!(cmd.contains("X-Custom: value")); + } + + #[test] + fn test_curl_with_jwt() { + let jwt_secret = "5c43996d0d150a81f06ae452fce38120d97a4156650aec7487b3384bfe32edae"; + let transport = CurlTransport::new("https://eth.example.com".parse().unwrap()) + .with_jwt(Some(jwt_secret.to_string())); + let req = make_test_request(); + let cmd = transport.generate_curl_command(&req).unwrap(); + + let jwt = cmd + .split("Authorization: Bearer ") + .nth(1) + .expect("missing Authorization header") + .split('\'') + .next() + .expect("malformed Authorization header"); + + let secret = JwtSecret::from_hex(jwt_secret).unwrap(); + secret.validate(jwt).unwrap(); + } + + #[test] + fn test_shell_escape() { + let escaped = shell_escape("it's a test"); + assert_eq!(escaped, "it'\"'\"'s a test"); + } +} diff --git a/crates/common/src/provider/fee.rs b/crates/common/src/provider/fee.rs new file mode 100644 index 0000000000000..abdf4c65ab405 --- /dev/null +++ b/crates/common/src/provider/fee.rs @@ -0,0 +1,351 @@ +//! EIP-1559 fee estimation with configurable presets. +//! +//! Estimates `maxFeePerGas` and `maxPriorityFeePerGas` from `eth_feeHistory`, +//! where the chosen [`Eip1559FeeEstimatePreset`] selects the reward percentile +//! used for the priority fee and the multiplier applied to the base fee. + +use alloy_consensus::BlockHeader; +use alloy_eips::BlockNumberOrTag; +use alloy_network::{BlockResponse, Network}; +use alloy_provider::{Provider, utils::Eip1559Estimation}; +use eyre::{Result, WrapErr}; +use foundry_config::Eip1559FeeEstimatePreset; + +/// The number of past blocks sampled from `eth_feeHistory` for fee estimation. +const FEE_HISTORY_BLOCKS: u64 = 10; + +/// The minimum priority fee to provide, in wei. +const MIN_PRIORITY_FEE: u128 = 1; + +/// Gas used ratio below which a block is treated as near-empty and its reward is +/// dropped from sampling, so one-off tips in otherwise idle blocks do not +/// dominate the cross-block median on low-traffic chains. Local heuristic: there +/// is no protocol-defined utilization threshold for priority-fee estimation. +const MIN_GAS_USED_RATIO: f64 = 0.1; + +/// The resolved EIP-1559 fees together with the base fee they were derived from. +/// +/// The base fee is surfaced so callers can present a fee breakdown (max fee / +/// priority fee / base fee) instead of a single, easily-misread "gas price". +#[derive(Clone, Copy, Debug)] +pub struct ResolvedEip1559Fees { + /// `maxFeePerGas`. + pub max_fee_per_gas: u128, + /// `maxPriorityFeePerGas`. + pub max_priority_fee_per_gas: u128, + /// The base fee of the latest block the estimate was derived from. + pub base_fee_per_gas: u128, +} + +impl ResolvedEip1559Fees { + /// Returns the max fee and priority fee as an [`Eip1559Estimation`], dropping + /// the base fee. + pub const fn estimation(&self) -> Eip1559Estimation { + Eip1559Estimation { + max_fee_per_gas: self.max_fee_per_gas, + max_priority_fee_per_gas: self.max_priority_fee_per_gas, + } + } +} + +/// Estimates EIP-1559 fees for `provider` using the given `preset`. +/// +/// `preset` controls the reward percentile sampled for the priority fee and the +/// base-fee multiplier used to build `maxFeePerGas`. +pub async fn estimate_eip1559_fees( + provider: &P, + preset: Eip1559FeeEstimatePreset, +) -> Result +where + P: Provider, + N: Network, +{ + let fee_history = provider + .get_fee_history( + FEE_HISTORY_BLOCKS, + BlockNumberOrTag::Latest, + &[preset.reward_percentile()], + ) + .await + .wrap_err("Failed to fetch fee history for EIP-1559 estimation")?; + + // Use the base fee of the latest mined block. If the fee history omits or + // zeroes it, read it from the latest block header; if that is also absent the + // chain does not support EIP-1559, so error instead of guessing. + let base_fee_per_gas = match fee_history.latest_block_base_fee() { + Some(base_fee) if base_fee != 0 => base_fee, + _ => provider + .get_block_by_number(BlockNumberOrTag::Latest) + .await + .wrap_err("Failed to fetch latest block for EIP-1559 base fee")? + .ok_or_else(|| eyre::eyre!("Latest block not found"))? + .header() + .as_ref() + .base_fee_per_gas() + .ok_or_else(|| { + eyre::eyre!( + "Chain does not appear to support EIP-1559; try adding --legacy to your command." + ) + })? + .into(), + }; + + let max_priority_fee_per_gas = estimate_priority_fee( + fee_history.reward.as_deref().unwrap_or_default(), + &fee_history.gas_used_ratio, + ); + + let (num, den) = preset.base_fee_multiplier(); + let max_fee_per_gas = base_fee_per_gas + .checked_mul(num) + .map_or(u128::MAX, |scaled| scaled / den) + .saturating_add(max_priority_fee_per_gas); + + Ok(ResolvedEip1559Fees { max_fee_per_gas, max_priority_fee_per_gas, base_fee_per_gas }) +} + +/// Applies, in order, the optional `browser_suggested_tip`, `with_gas_price` and +/// `priority_gas_price` overrides to estimated EIP-1559 fees. +/// +/// `with_gas_price` overrides only `maxFeePerGas` and `priority_gas_price` only +/// `maxPriorityFeePerGas`. Errors if the result has `maxPriorityFeePerGas` above +/// `maxFeePerGas`. +pub fn resolve_broadcast_eip1559_fees( + mut fees: ResolvedEip1559Fees, + with_gas_price: Option, + priority_gas_price: Option, + browser_suggested_tip: Option, +) -> Result { + // Raise both caps by the same delta so `maxFeePerGas` keeps its buffer above + // the higher tip. + if let Some(suggested_tip) = browser_suggested_tip + && suggested_tip > fees.max_priority_fee_per_gas + { + let delta = suggested_tip - fees.max_priority_fee_per_gas; + fees.max_fee_per_gas = fees.max_fee_per_gas.saturating_add(delta); + fees.max_priority_fee_per_gas = suggested_tip; + } + + if let Some(max_fee_per_gas) = with_gas_price { + fees.max_fee_per_gas = max_fee_per_gas; + } + + if let Some(max_priority_fee_per_gas) = priority_gas_price { + fees.max_priority_fee_per_gas = max_priority_fee_per_gas; + } + + if fees.max_priority_fee_per_gas > fees.max_fee_per_gas { + eyre::bail!( + "maxPriorityFeePerGas ({}) cannot be higher than maxFeePerGas ({})", + fees.max_priority_fee_per_gas, + fees.max_fee_per_gas, + ); + } + + Ok(fees) +} + +/// Estimates the priority fee as the median of the non-zero per-block rewards, +/// never returning less than [`MIN_PRIORITY_FEE`]. +/// +/// Rewards from blocks below [`MIN_GAS_USED_RATIO`] are dropped first so one-off +/// tips in idle blocks do not dominate the median. If that empties the sample +/// but at least half the blocks still carry a tip, the unfiltered median is used +/// instead, so chronically light chains do not collapse to [`MIN_PRIORITY_FEE`]. +/// The filter is skipped when `gas_used_ratio` does not line up with `rewards` +/// (e.g. `gasUsedRatio: null`, which alloy deserializes as an empty vec). +fn estimate_priority_fee(rewards: &[Vec], gas_used_ratio: &[f64]) -> u128 { + let apply_filter = gas_used_ratio.len() == rewards.len(); + let non_zero_rewards = |filtered: bool| { + rewards + .iter() + .enumerate() + .filter(|(i, _)| { + !filtered + || (gas_used_ratio[*i].is_finite() && gas_used_ratio[*i] >= MIN_GAS_USED_RATIO) + }) + .filter_map(|(_, reward)| reward.first().copied()) + .filter(|reward| *reward > 0) + .collect::>() + }; + + let mut rewards = non_zero_rewards(apply_filter); + // If every sampled block is near-empty but tipping is the norm across the + // sample (at least half the blocks carry a tip), fall back to the unfiltered + // non-zero median; a one-off tip on an idle chain (#13885) stays excluded. + if apply_filter && rewards.is_empty() { + let unfiltered = non_zero_rewards(false); + if unfiltered.len() * 2 >= gas_used_ratio.len() { + rewards = unfiltered; + } + } + if rewards.is_empty() { + return MIN_PRIORITY_FEE; + } + + rewards.sort_unstable(); + + let n = rewards.len(); + // `midpoint` avoids overflow when averaging the two middle values. + let median = + if n % 2 == 0 { rewards[n / 2 - 1].midpoint(rewards[n / 2]) } else { rewards[n / 2] }; + + std::cmp::max(median, MIN_PRIORITY_FEE) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A gas used ratio above [`MIN_GAS_USED_RATIO`]. + const BUSY: f64 = 0.5; + + #[test] + fn priority_fee_median_of_busy_blocks() { + // Empty rewards -> minimum priority fee. + assert_eq!(estimate_priority_fee(&[], &[]), MIN_PRIORITY_FEE); + assert_eq!(estimate_priority_fee(&[vec![0], vec![0]], &[BUSY, BUSY]), MIN_PRIORITY_FEE); + + // Median of non-zero rewards from busy blocks. + assert_eq!(estimate_priority_fee(&[vec![1], vec![3], vec![5]], &[BUSY, BUSY, BUSY]), 3); + assert_eq!(estimate_priority_fee(&[vec![2], vec![4]], &[BUSY, BUSY]), 3); + } + + #[test] + fn priority_fee_ignores_near_empty_blocks() { + // Rewards from blocks below MIN_GAS_USED_RATIO are dropped. + let rewards = vec![vec![0u128], vec![41_000_000_000_000u128], vec![0u128]]; + let ratios = vec![0.0, 0.001, 0.0]; + assert_eq!(estimate_priority_fee(&rewards, &ratios), MIN_PRIORITY_FEE); + + // Only busy blocks are sampled -> median of [2, 3] = 2. + let rewards = vec![vec![500u128], vec![2u128], vec![800u128], vec![3u128], vec![999u128]]; + let ratios = vec![0.01, BUSY, 0.03, 0.7, 0.02]; + assert_eq!(estimate_priority_fee(&rewards, &ratios), 2); + + // The threshold is inclusive. + assert_eq!(estimate_priority_fee(&[vec![10], vec![20]], &[0.1, 0.09]), 10); + + // NaN/infinite ratios fail the filter; the fallback restores the median. + assert_eq!(estimate_priority_fee(&[vec![5], vec![6]], &[f64::NAN, f64::INFINITY]), 5); + } + + #[test] + fn priority_fee_falls_back_when_tipping_is_the_norm() { + // Light chain, every block tipped: fallback keeps the median. + let rewards = vec![vec![1_000u128]; 10]; + let ratios = vec![0.05; 10]; + assert_eq!(estimate_priority_fee(&rewards, &ratios), 1_000); + + // Exactly half the blocks tip -> `>=` is inclusive, fallback fires. + let rewards = vec![vec![0u128], vec![10u128], vec![0u128], vec![20u128]]; + let ratios = vec![0.01; 4]; + assert_eq!(estimate_priority_fee(&rewards, &ratios), 15); + + // One-off tip on an idle chain (#13885): under half tip, no fallback. + let mut rewards = vec![vec![0u128]; 10]; + rewards[3] = vec![41_000_000_000_000u128]; + let ratios = vec![0.01; 10]; + assert_eq!(estimate_priority_fee(&rewards, &ratios), MIN_PRIORITY_FEE); + } + + #[test] + fn priority_fee_skips_filter_on_length_mismatch() { + // A mismatched `gas_used_ratio` (e.g. `gasUsedRatio: null`) disables the + // filter, so the unfiltered non-zero median is used. + assert_eq!(estimate_priority_fee(&[vec![7], vec![9], vec![11]], &[BUSY]), 9); + assert_eq!(estimate_priority_fee(&[vec![7]], &[BUSY, BUSY, BUSY]), 7); + assert_eq!(estimate_priority_fee(&[vec![2], vec![4]], &[]), 3); + } + + /// Drives the public async estimator so `gas_used_ratio` wiring is covered. + #[tokio::test] + async fn estimate_filters_near_empty_block_outliers() { + use alloy_provider::{ProviderBuilder, mock::Asserter}; + use alloy_rpc_types::FeeHistory; + + let base = 20_000_000_000u128; // 20 gwei + let big = 41_000_000_000_000u128; // outlier tip in near-empty blocks + let fee_history = FeeHistory { + base_fee_per_gas: vec![base; 11], + gas_used_ratio: vec![0.001; 10], + base_fee_per_blob_gas: vec![1; 11], + blob_gas_used_ratio: vec![0.0; 10], + oldest_block: 1, + reward: Some(vec![ + vec![0u128], + vec![big], + vec![0u128], + vec![big], + vec![0u128], + vec![big], + vec![0u128], + vec![0u128], + vec![0u128], + vec![0u128], + ]), + }; + + let asserter = Asserter::new(); + asserter.push_success(&fee_history); + let provider = ProviderBuilder::new_with_network::() + .connect_mocked_client(asserter); + + let fees = + estimate_eip1559_fees(&provider, Eip1559FeeEstimatePreset::Market).await.unwrap(); + + // Near-empty outliers are dropped -> min priority, max_fee = 2 * base + min. + assert_eq!(fees.base_fee_per_gas, base); + assert_eq!(fees.max_priority_fee_per_gas, MIN_PRIORITY_FEE); + assert_eq!(fees.max_fee_per_gas, 40_000_000_001); + } + + fn fees(max: u128, priority: u128) -> ResolvedEip1559Fees { + ResolvedEip1559Fees { + max_fee_per_gas: max, + max_priority_fee_per_gas: priority, + base_fee_per_gas: 100, + } + } + + #[test] + fn resolve_overrides_each_field_independently() { + // No overrides: passthrough. + let r = resolve_broadcast_eip1559_fees(fees(300, 50), None, None, None).unwrap(); + assert_eq!((r.max_fee_per_gas, r.max_priority_fee_per_gas), (300, 50)); + + // `--with-gas-price` overrides only max, `--priority-gas-price` only priority. + let r = resolve_broadcast_eip1559_fees(fees(300, 50), Some(500), None, None).unwrap(); + assert_eq!((r.max_fee_per_gas, r.max_priority_fee_per_gas), (500, 50)); + let r = resolve_broadcast_eip1559_fees(fees(300, 50), None, Some(80), None).unwrap(); + assert_eq!((r.max_fee_per_gas, r.max_priority_fee_per_gas), (300, 80)); + + // Invalid when the resulting priority exceeds max. + let err = resolve_broadcast_eip1559_fees(fees(300, 50), None, Some(400), None).unwrap_err(); + assert!(err.to_string().contains("cannot be higher than maxFeePerGas")); + } + + #[test] + fn resolve_browser_tip_raises_both_caps_by_delta() { + // Higher tip raises priority to the tip and max by the same delta. + let r = resolve_broadcast_eip1559_fees(fees(300, 50), None, None, Some(120)).unwrap(); + assert_eq!((r.max_fee_per_gas, r.max_priority_fee_per_gas), (370, 120)); // 300 + (120 - 50) + + // Lower tip is ignored; max saturates instead of overflowing. + let r = resolve_broadcast_eip1559_fees(fees(300, 50), None, None, Some(10)).unwrap(); + assert_eq!((r.max_fee_per_gas, r.max_priority_fee_per_gas), (300, 50)); + let r = resolve_broadcast_eip1559_fees(fees(u128::MAX, 50), None, None, Some(120)).unwrap(); + assert_eq!((r.max_fee_per_gas, r.max_priority_fee_per_gas), (u128::MAX, 120)); + } + + #[test] + fn market_preset_max_fee_formula() { + // Market preset: max_fee = base_fee * 2 + priority_fee. + let preset = Eip1559FeeEstimatePreset::Market; + let (num, den) = preset.base_fee_multiplier(); + let base_fee = 2_000_000_000u128; // 2 gwei + let priority = estimate_priority_fee(&[vec![100], vec![300]], &[BUSY, BUSY]); + let max_fee = base_fee.saturating_mul(num) / den + priority; + assert_eq!(max_fee, base_fee * 2 + priority); + } +} diff --git a/crates/common/src/provider/mod.rs b/crates/common/src/provider/mod.rs index b74904556241f..f31c1c06caf6c 100644 --- a/crates/common/src/provider/mod.rs +++ b/crates/common/src/provider/mod.rs @@ -1,26 +1,42 @@ //! Provider-related instantiation and usage utilities. +pub mod curl_transport; +pub mod fee; +pub mod mpp; pub mod runtime_transport; use crate::{ - ALCHEMY_FREE_TIER_CUPS, REQUEST_TIMEOUT, provider::runtime_transport::RuntimeTransportBuilder, + ALCHEMY_FREE_TIER_CUPS, REQUEST_TIMEOUT, + provider::{curl_transport::CurlTransport, runtime_transport::RuntimeTransportBuilder}, }; +use alloy_chains::NamedChain; +use alloy_json_rpc::{RequestPacket, ResponsePacket}; +use alloy_network::{Network, NetworkWallet}; use alloy_provider::{ Identity, ProviderBuilder as AlloyProviderBuilder, RootProvider, - fillers::{ChainIdFiller, FillProvider, GasFiller, JoinFill, NonceFiller, WalletFiller}, + fillers::{FillProvider, JoinFill, RecommendedFillers, WalletFiller}, network::{AnyNetwork, EthereumWallet}, }; -use alloy_rpc_client::ClientBuilder; -use alloy_transport::{layers::RetryBackoffLayer, utils::guess_local_url}; +use alloy_rpc_client::{ClientBuilder, RpcClient}; +use alloy_transport::{ + TransportError, TransportFut, layers::RetryBackoffLayer, utils::guess_local_url, +}; use eyre::{Result, WrapErr}; -use foundry_config::NamedChain; +use foundry_config::Config; use reqwest::Url; use std::{ + marker::PhantomData, net::SocketAddr, path::{Path, PathBuf}, str::FromStr, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + task::{Context, Poll}, time::Duration, }; +use tower::Service; use url::ParseError; /// The assumed block time for unknown chains. @@ -34,56 +50,67 @@ const POLL_INTERVAL_BLOCK_TIME_SCALE_FACTOR: f32 = 0.6; pub type RetryProvider = RootProvider; /// Helper type alias for a retry provider with a signer -pub type RetryProviderWithSigner = FillProvider< - JoinFill< - JoinFill< - Identity, - JoinFill< - GasFiller, - JoinFill< - alloy_provider::fillers::BlobGasFiller, - JoinFill, - >, - >, - >, - WalletFiller, - >, +pub type RetryProviderWithSigner = FillProvider< + JoinFill::RecommendedFillers>, WalletFiller>, RootProvider, N, >; -/// Constructs a provider with a 100 millisecond interval poll if it's a localhost URL (most likely -/// an anvil or other dev node) and with the default, or 7 second otherwise. -/// -/// See [`try_get_http_provider`] for more details. +/// A round-robin transport that distributes requests across multiple transports. /// -/// # Panics -/// -/// Panics if the URL is invalid. -/// -/// # Examples -/// -/// ``` -/// use foundry_common::provider::get_http_provider; -/// -/// let retry_provider = get_http_provider("http://localhost:8545"); -/// ``` -#[inline] -#[track_caller] -pub fn get_http_provider(builder: impl AsRef) -> RetryProvider { - try_get_http_provider(builder).unwrap() +/// Each request is sent to exactly one transport, rotating through the list. +/// Failover on error is handled by the retry layer above this service. +#[derive(Clone)] +pub struct RoundRobinService { + transports: Arc>, + next: Arc, } -/// Constructs a provider with a 100 millisecond interval poll if it's a localhost URL (most likely -/// an anvil or other dev node) and with the default, or 7 second otherwise. -#[inline] -pub fn try_get_http_provider(builder: impl AsRef) -> Result { - ProviderBuilder::new(builder.as_ref()).build() +impl RoundRobinService { + /// Creates a new round-robin service from a non-empty list of transports. + /// + /// # Panics + /// + /// Panics if `transports` is empty. + pub fn new(transports: Vec) -> Self { + assert!(!transports.is_empty(), "RoundRobinService requires at least one transport"); + Self { transports: Arc::new(transports), next: Arc::new(AtomicUsize::new(0)) } + } +} + +impl Service for RoundRobinService +where + S: Service< + RequestPacket, + Response = ResponsePacket, + Error = TransportError, + Future = TransportFut<'static>, + > + Clone + + Send + + Sync + + 'static, +{ + type Response = ResponsePacket; + type Error = TransportError; + type Future = TransportFut<'static>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, req: RequestPacket) -> Self::Future { + let transports = self.transports.clone(); + let idx = self.next.fetch_add(1, Ordering::Relaxed) % transports.len(); + let mut transport = transports[idx].clone(); + transport.call(req) + } } /// Helper type to construct a `RetryProvider` +/// +/// This builder is generic over the network type `N`, defaulting to `AnyNetwork`. #[derive(Debug)] -pub struct ProviderBuilder { +pub struct ProviderBuilder { // Note: this is a result, so we can easily chain builder calls url: Result, chain: NamedChain, @@ -98,10 +125,16 @@ pub struct ProviderBuilder { is_local: bool, /// Whether to accept invalid certificates. accept_invalid_certs: bool, + /// Whether to disable automatic proxy detection. + no_proxy: bool, + /// Whether to output curl commands instead of making requests. + curl_mode: bool, + /// Phantom data for the network type. + _network: PhantomData, } -impl ProviderBuilder { - /// Creates a new builder instance +impl ProviderBuilder { + /// Creates a new ProviderBuilder helper instance. pub fn new(url_str: &str) -> Self { // a copy is needed for the next lines to work let mut url_str = url_str; @@ -131,7 +164,7 @@ impl ProviderBuilder { } _ => Err(err), }) - .wrap_err_with(|| format!("invalid provider URL: {url_str:?}")); + .wrap_err_with(|| format!("invalid provider URL: {:?}", redact_url(url_str))); // Use the final URL string to guess if it's a local URL. let is_local = url.as_ref().is_ok_and(|url| guess_local_url(url.as_str())); @@ -148,7 +181,46 @@ impl ProviderBuilder { headers: vec![], is_local, accept_invalid_certs: false, + no_proxy: false, + curl_mode: false, + _network: PhantomData, + } + } + + /// Constructs a [ProviderBuilder] instantiated using [Config] values. + /// + /// Defaults to `http://localhost:8545` and `Mainnet`. + pub fn from_config(config: &Config) -> Result { + let url = config.get_rpc_url_or_localhost_http()?; + let mut builder = Self::from_config_with_url(config, url.as_ref())?; + + if let Ok(chain) = config.chain.unwrap_or_default().try_into() { + builder = builder.chain(chain); } + + Ok(builder) + } + + /// Constructs a [ProviderBuilder] for `url`, applying transport options from [Config]. + pub fn from_config_with_url(config: &Config, url: &str) -> Result { + let mut builder = Self::new(url) + .accept_invalid_certs(config.eth_rpc_accept_invalid_certs) + .no_proxy(config.eth_rpc_no_proxy) + .curl_mode(config.eth_rpc_curl); + + if let Some(jwt) = config.get_rpc_jwt_secret()? { + builder = builder.jwt(jwt.as_ref()); + } + + if let Some(rpc_timeout) = config.eth_rpc_timeout { + builder = builder.timeout(Duration::from_secs(rpc_timeout)); + } + + if let Some(rpc_headers) = config.eth_rpc_headers.clone() { + builder = builder.headers(rpc_headers); + } + + Ok(builder) } /// Enables a request timeout. @@ -157,19 +229,19 @@ impl ProviderBuilder { /// response body has finished. /// /// Default is no timeout. - pub fn timeout(mut self, timeout: Duration) -> Self { + pub const fn timeout(mut self, timeout: Duration) -> Self { self.timeout = timeout; self } /// Sets the chain of the node the provider will connect to - pub fn chain(mut self, chain: NamedChain) -> Self { + pub const fn chain(mut self, chain: NamedChain) -> Self { self.chain = chain; self } /// How often to retry a failed request - pub fn max_retry(mut self, max_retry: u32) -> Self { + pub const fn max_retry(mut self, max_retry: u32) -> Self { self.max_retry = max_retry; self } @@ -188,7 +260,7 @@ impl ProviderBuilder { } /// The starting backoff delay to use after the first failed request - pub fn initial_backoff(mut self, initial_backoff: u64) -> Self { + pub const fn initial_backoff(mut self, initial_backoff: u64) -> Self { self.initial_backoff = initial_backoff; self } @@ -196,7 +268,7 @@ impl ProviderBuilder { /// Sets the number of assumed available compute units per second /// /// See also, - pub fn compute_units_per_second(mut self, compute_units_per_second: u64) -> Self { + pub const fn compute_units_per_second(mut self, compute_units_per_second: u64) -> Self { self.compute_units_per_second = compute_units_per_second; self } @@ -204,7 +276,10 @@ impl ProviderBuilder { /// Sets the number of assumed available compute units per second /// /// See also, - pub fn compute_units_per_second_opt(mut self, compute_units_per_second: Option) -> Self { + pub const fn compute_units_per_second_opt( + mut self, + compute_units_per_second: Option, + ) -> Self { if let Some(cups) = compute_units_per_second { self.compute_units_per_second = cups; } @@ -214,7 +289,7 @@ impl ProviderBuilder { /// Sets the provider to be local. /// /// This is useful for local dev nodes. - pub fn local(mut self, is_local: bool) -> Self { + pub const fn local(mut self, is_local: bool) -> Self { self.is_local = is_local; self } @@ -222,7 +297,7 @@ impl ProviderBuilder { /// Sets aggressive `max_retry` and `initial_backoff` values /// /// This is only recommend for local dev nodes - pub fn aggressive(self) -> Self { + pub const fn aggressive(self) -> Self { self.max_retry(100).initial_backoff(100).local(true) } @@ -246,13 +321,36 @@ impl ProviderBuilder { } /// Sets whether to accept invalid certificates. - pub fn accept_invalid_certs(mut self, accept_invalid_certs: bool) -> Self { + pub const fn accept_invalid_certs(mut self, accept_invalid_certs: bool) -> Self { self.accept_invalid_certs = accept_invalid_certs; self } + /// Sets whether to disable automatic proxy detection. + /// + /// This can help in sandboxed environments (e.g., Cursor IDE sandbox, macOS App Sandbox) + /// where system proxy detection via SCDynamicStore causes crashes. + pub const fn no_proxy(mut self, no_proxy: bool) -> Self { + self.no_proxy = no_proxy; + self + } + + /// Sets whether to output curl commands instead of making requests. + /// + /// When enabled, the provider will print equivalent curl commands to stdout + /// instead of actually executing the RPC requests. + pub const fn curl_mode(mut self, curl_mode: bool) -> Self { + self.curl_mode = curl_mode; + self + } + /// Constructs the `RetryProvider` taking all configs into account. - pub fn build(self) -> Result { + pub fn build(self) -> Result> { + Ok(RootProvider::new(self.build_client()?)) + } + + /// Constructs a shared RPC client with the configured transport, authentication, and retries. + pub fn build_client(self) -> Result { let Self { url, chain, @@ -264,17 +362,30 @@ impl ProviderBuilder { headers, is_local, accept_invalid_certs, + no_proxy, + curl_mode, + .. } = self; let url = url?; + let no_proxy = no_proxy || is_local; let retry_layer = RetryBackoffLayer::new(max_retry, initial_backoff, compute_units_per_second); + // If curl_mode is enabled, use CurlTransport instead of RuntimeTransport + if curl_mode { + let transport = CurlTransport::new(url).with_headers(headers).with_jwt(jwt); + let client = ClientBuilder::default().layer(retry_layer).transport(transport, is_local); + + return Ok(client); + } + let transport = RuntimeTransportBuilder::new(url) .with_timeout(timeout) .with_headers(headers) .with_jwt(jwt) .accept_invalid_certs(accept_invalid_certs) + .no_proxy(no_proxy) .build(); let client = ClientBuilder::default().layer(retry_layer).transport(transport, is_local); @@ -290,16 +401,19 @@ impl ProviderBuilder { ); } - let provider = AlloyProviderBuilder::<_, _, AnyNetwork>::default() - .connect_provider(RootProvider::new(client)); - - Ok(provider) + Ok(client) } +} - /// Constructs the `RetryProvider` with a wallet. - pub fn build_with_wallet(self, wallet: EthereumWallet) -> Result { +impl ProviderBuilder { + /// Constructs a `RetryProvider` backed by multiple URLs using round-robin load balancing. + /// + /// Each request is sent to exactly one transport, rotating through the list via + /// [`RoundRobinService`]. There is no health scoring or endpoint deprioritization. + /// On failure, the `RetryBackoffLayer` retries the request, which naturally hits + /// the next transport in the rotation. + pub fn build_fallback(self, urls: Vec) -> Result> { let Self { - url, chain, max_retry, initial_backoff, @@ -307,39 +421,141 @@ impl ProviderBuilder { compute_units_per_second, jwt, headers, - is_local, accept_invalid_certs, + no_proxy, + curl_mode, + .. } = self; - let url = url?; - let retry_layer = - RetryBackoffLayer::new(max_retry, initial_backoff, compute_units_per_second); + eyre::ensure!(!urls.is_empty(), "at least one fork URL is required"); + eyre::ensure!(!curl_mode, "curl mode is not supported with multiple fork URLs"); + + // Build a RuntimeTransport for each URL, using the same URL normalization + // as ProviderBuilder::new() (handles localhost:port, raw socket addrs, IPC paths) + let mut parsed_urls = Vec::with_capacity(urls.len()); + let transports: Vec<_> = urls + .iter() + .map(|url_str| { + let builder = Self::new(url_str); + let url = builder.url?; + let transport_no_proxy = no_proxy || builder.is_local; + parsed_urls.push(url.clone()); + Ok(RuntimeTransportBuilder::new(url) + .with_timeout(timeout) + .with_headers(headers.clone()) + .with_jwt(jwt.clone()) + .accept_invalid_certs(accept_invalid_certs) + .no_proxy(transport_no_proxy) + .build()) + }) + .collect::>>()?; - let transport = RuntimeTransportBuilder::new(url) - .with_timeout(timeout) - .with_headers(headers) - .with_jwt(jwt) - .accept_invalid_certs(accept_invalid_certs) - .build(); + let round_robin = RoundRobinService::new(transports); - let client = ClientBuilder::default().layer(retry_layer).transport(transport, is_local); + let retry_layer = + RetryBackoffLayer::new(max_retry, initial_backoff, compute_units_per_second); + // Use normalized/parsed URLs for local detection, consistent with build() + let is_local = parsed_urls.iter().all(|url| guess_local_url(url.as_str())); + let client = ClientBuilder::default().layer(retry_layer).transport(round_robin, is_local); if !is_local { client.set_poll_interval( chain .average_blocktime_hint() + .map(|hint| hint.min(DEFAULT_UNKNOWN_CHAIN_BLOCK_TIME)) .unwrap_or(DEFAULT_UNKNOWN_CHAIN_BLOCK_TIME) .mul_f32(POLL_INTERVAL_BLOCK_TIME_SCALE_FACTOR), ); } - let provider = AlloyProviderBuilder::<_, _, AnyNetwork>::default() + let provider = + AlloyProviderBuilder::<_, _, N>::default().connect_provider(RootProvider::new(client)); + + Ok(provider) + } + + /// Constructs the `RetryProvider` with a wallet. + pub fn build_with_wallet + Clone>( + self, + wallet: W, + ) -> Result> + where + N: RecommendedFillers, + { + let provider = self.build()?; + Ok(AlloyProviderBuilder::<_, _, N>::default() .with_recommended_fillers() .wallet(wallet) - .connect_provider(RootProvider::new(client)); + .connect_provider(provider)) + } +} - Ok(provider) +/// Returns whether an RPC transport error reports JSON-RPC method-not-found. +/// +/// Some providers encode JSON-RPC errors inside an HTTP error response instead of returning a +/// normal JSON-RPC response. Only the exact `-32601` code is treated as method unavailability; +/// authentication, internal, and transport errors must remain visible to callers. +pub fn is_rpc_method_not_found(error: &TransportError) -> bool { + rpc_error_code(error) == Some(-32601) +} + +/// Returns an RPC URL safe for display by retaining only its scheme, host, and port. +pub fn redact_url(raw: &str) -> String { + let Ok(mut redacted) = Url::parse(raw) else { + return "".to_owned(); + }; + let _ = redacted.set_username(""); + let _ = redacted.set_password(None); + redacted.set_path(""); + redacted.set_query(None); + redacted.set_fragment(None); + redacted.to_string() +} + +fn rpc_error_code(error: &TransportError) -> Option { + if let Some(response) = error.as_error_resp() { + return Some(response.code); } + let TransportError::Transport(error) = error else { return None }; + error.as_http_error().and_then(|error| rpc_error_code_from_body(&error.body)) +} + +fn rpc_error_code_from_body(body: &str) -> Option { + // HTTP transports may append human-readable diagnostics after the JSON-RPC body. Parse the + // first complete JSON value instead of requiring the entire body to be JSON. + let value = + serde_json::Deserializer::from_str(body).into_iter::().next()?.ok()?; + let error = value.get("error").unwrap_or(&value); + error.get("code")?.as_i64() +} + +/// Constructs a provider with a 100 millisecond interval poll if it's a localhost URL (most likely +/// an anvil or other dev node) and with the default, or 7 second otherwise. +/// +/// See [`try_get_http_provider`] for more details. +/// +/// # Panics +/// +/// Panics if the URL is invalid. +/// +/// # Examples +/// +/// ``` +/// use foundry_common::provider::get_http_provider; +/// +/// let retry_provider = get_http_provider("http://localhost:8545"); +/// ``` +#[inline] +#[track_caller] +pub fn get_http_provider(builder: impl AsRef) -> RetryProvider { + try_get_http_provider(builder).unwrap() +} + +/// Constructs a provider with a 100 millisecond interval poll if it's a localhost URL (most likely +/// an anvil or other dev node) and with the default, or 7 second otherwise. +#[inline] +pub fn try_get_http_provider(builder: impl AsRef) -> Result { + ProviderBuilder::new(builder.as_ref()).build() } #[cfg(not(windows))] @@ -353,24 +569,119 @@ fn resolve_path(path: &Path) -> Result { #[cfg(windows)] fn resolve_path(path: &Path) -> Result { - if let Some(s) = path.to_str() { - if s.starts_with(r"\\.\pipe\") { - return Ok(path.to_path_buf()); - } + if let Some(s) = path.to_str() + && s.starts_with(r"\\.\pipe\") + { + return Ok(path.to_path_buf()); + } + if path.is_absolute() { + Ok(path.to_path_buf()) + } else { + std::env::current_dir().map(|d| d.join(path)).map_err(drop) } - Err(()) } #[cfg(test)] mod tests { + use alloy_json_rpc::ErrorPayload; + use super::*; + #[test] + fn redacts_url_credentials_and_resource() { + let url = "https://user:password@example.com:8545/private-key?token=secret#fragment"; + + assert_eq!(redact_url(url), "https://example.com:8545/"); + assert_eq!(redact_url("not a URL with secret"), ""); + } + + #[test] + fn invalid_provider_url_error_is_redacted() { + let builder = ProviderBuilder::::new( + "https://example.com:bad/private-api-key?token=secret", + ); + + let error = builder.url.unwrap_err().to_string(); + assert!(error.contains("")); + assert!(!error.contains("private-api-key")); + assert!(!error.contains("secret")); + } + + #[test] + fn method_not_found_classification_is_exact() { + let method_not_found = TransportError::ErrorResp(ErrorPayload::method_not_found()); + let internal_error = TransportError::ErrorResp(ErrorPayload::internal_error()); + let http_method_not_found = alloy_transport::TransportErrorKind::http_error( + 403, + r#"{"jsonrpc":"2.0","error":{"code":-32601,"message":"method not allowed"}}"# + .to_string(), + ); + let http_internal_error = alloy_transport::TransportErrorKind::http_error( + 500, + r#"{"jsonrpc":"2.0","error":{"code":-32603,"message":"internal error"}}"#.to_string(), + ); + let http_method_not_found_with_diagnostics = + alloy_transport::TransportErrorKind::http_error( + 403, + concat!( + r#"{"jsonrpc":"2.0","error":{"code":-32601,"message":"method not allowed"}}"#, + "\n\nHTTP diagnostics:\nstatus: 403 Forbidden" + ) + .to_string(), + ); + let transport_error = alloy_transport::TransportErrorKind::backend_gone(); + + assert!(is_rpc_method_not_found(&method_not_found)); + assert!(is_rpc_method_not_found(&http_method_not_found)); + assert!(is_rpc_method_not_found(&http_method_not_found_with_diagnostics)); + assert!(!is_rpc_method_not_found(&internal_error)); + assert!(!is_rpc_method_not_found(&http_internal_error)); + assert!(!is_rpc_method_not_found(&transport_error)); + } + #[test] fn can_auto_correct_missing_prefix() { - let builder = ProviderBuilder::new("localhost:8545"); + let builder = ProviderBuilder::::new("localhost:8545"); assert!(builder.url.is_ok()); let url = builder.url.unwrap(); assert_eq!(url, Url::parse("http://localhost:8545").unwrap()); } + + #[test] + fn from_config_applies_rpc_transport_options() { + let config = Config { + eth_rpc_url: Some("http://example.com".to_string()), + chain: Some(NamedChain::Polygon.into()), + eth_rpc_accept_invalid_certs: true, + eth_rpc_no_proxy: true, + eth_rpc_timeout: Some(7), + ..Default::default() + }; + + let builder = ProviderBuilder::::from_config(&config).unwrap(); + + assert!(builder.accept_invalid_certs); + assert!(builder.no_proxy); + assert_eq!(builder.timeout, Duration::from_secs(7)); + assert_eq!(builder.chain, NamedChain::Polygon); + } + + #[test] + fn from_config_with_url_overrides_rpc_url() { + let config = Config { + eth_rpc_url: Some("http://configured.example".to_string()), + chain: Some(NamedChain::Polygon.into()), + eth_rpc_timeout: Some(7), + ..Default::default() + }; + + let builder = + ProviderBuilder::::from_config_with_url(&config, "http://sequence.example") + .unwrap(); + + assert_eq!(builder.url.unwrap().as_str(), "http://sequence.example/"); + assert_eq!(builder.timeout, Duration::from_secs(7)); + assert_eq!(builder.chain, NamedChain::Mainnet); + } } diff --git a/crates/common/src/provider/mpp/mod.rs b/crates/common/src/provider/mpp/mod.rs new file mode 100644 index 0000000000000..947cb0726e2d9 --- /dev/null +++ b/crates/common/src/provider/mpp/mod.rs @@ -0,0 +1,6 @@ +//! MPP (Machine Payments Protocol) support for 402-gated RPC endpoints. +//! +//! [`transport`] composes Foundry policy with the canonical Alloy MPP +//! transports. + +pub mod transport; diff --git a/crates/common/src/provider/mpp/transport.rs b/crates/common/src/provider/mpp/transport.rs new file mode 100644 index 0000000000000..c3e2bf0b6974a --- /dev/null +++ b/crates/common/src/provider/mpp/transport.rs @@ -0,0 +1,666 @@ +//! Foundry policy for the canonical MPP Alloy HTTP transport. + +use alloy_chains::Chain; +use alloy_json_rpc::{RequestPacket, ResponsePacket, RpcError}; +use alloy_transport::{TransportError, TransportErrorKind, TransportFut}; +use alloy_transport_mpp::{MppHttpTransport, MppWsConnect}; +use mpp::{ + MppError, PaymentErrorDetails, + client::{ + PaymentContext, PaymentProvider, TempoAccountsProvider, + tempo::{ + autoswap::{AutoswapConfig, DEFAULT_SLIPPAGE_BPS}, + session::store::{SqliteChannelStore, SqliteChannelStoreOptions}, + }, + }, + protocol::{ + core::{PaymentChallenge, PaymentCredential}, + intents::{ChargeRequest, SessionRequest}, + }, +}; +use std::{ + collections::HashMap, + env, fmt, io, + io::IsTerminal, + process::{Command, Stdio}, + sync::{Arc, Mutex, MutexGuard, PoisonError}, + task, +}; +use tempo_alloy::accounts::{TempoAccountsError, TempoAccountsStore}; +use tower::Service; +use url::Url; + +/// Keep high-fanout fork database reads from overwhelming paid RPC endpoints. +const MAX_CONCURRENT_MPP_HTTP_REQUESTS: usize = 4; + +/// Open a channel with 0.02 tokens when the server does not suggest a deposit. +const DEFAULT_MPP_SESSION_DEPOSIT: u128 = 20_000; + +/// Never let a paid RPC reserve more than one six-decimal token automatically. +const MAX_MPP_SESSION_DEPOSIT: u128 = 1_000_000; + +/// The MPP transport used by Foundry's runtime transport builder. +#[derive(Clone, Debug)] +pub struct LazyMppHttpTransport(MppHttpTransport); + +impl LazyMppHttpTransport { + /// Create a transport that opens Tempo Accounts only after a paid challenge. + pub fn lazy(client: reqwest::Client, url: Url, headers: reqwest::header::HeaderMap) -> Self { + let provider = LazyAccountsProvider::new(url.to_string()); + Self( + MppHttpTransport::new(client, url, provider) + .with_headers(headers) + .with_max_concurrent_requests(MAX_CONCURRENT_MPP_HTTP_REQUESTS), + ) + } + + /// Return the underlying HTTP client. + pub const fn client(&self) -> &reqwest::Client { + self.0.client() + } +} + +impl Service for LazyMppHttpTransport { + type Response = ResponsePacket; + type Error = TransportError; + type Future = TransportFut<'static>; + + fn poll_ready(&mut self, cx: &mut task::Context<'_>) -> task::Poll> { + self.0.poll_ready(cx) + } + + fn call(&mut self, request: RequestPacket) -> Self::Future { + let retry = request.clone(); + let mut transport = self.0.clone(); + let provider = self.0.payment_provider().clone(); + Box::pin(async move { + match transport.call(request).await { + Err(error) => { + let Some(problem) = insufficient_balance_details(&error) + .filter(|problem| problem.problem_type.ends_with("/insufficient-balance")) + else { + return Err(error); + }; + let context = provider.take_funding_context(problem.challenge_id.as_deref()); + if run_interactive_tempo_fund(&context) + .await + .map_err(TransportErrorKind::custom)? + { + match transport.call(retry).await { + Err(error) => { + let Some(problem) = + insufficient_balance_details(&error).filter(|problem| { + problem.problem_type.ends_with("/insufficient-balance") + }) + else { + return Err(error); + }; + let context = + provider.take_funding_context(problem.challenge_id.as_deref()); + Err(with_transport_funding_help(error, &context)) + } + result => result, + } + } else { + Err(with_transport_funding_help(error, &context)) + } + } + result => result, + } + }) + } +} + +/// Build the canonical MPP WebSocket connector with Foundry's lazy Accounts +/// provider. +pub(crate) fn lazy_mpp_ws_connect(url: &Url) -> MppWsConnect { + let mut origin = url.clone(); + let http_scheme = match origin.scheme() { + "ws" => Some("http"), + "wss" => Some("https"), + _ => None, + }; + if let Some(http_scheme) = http_scheme { + let _ = origin.set_scheme(http_scheme); + } + MppWsConnect::new(url.to_string(), LazyAccountsProvider::new(origin.to_string())) +} + +/// Lazily resolves a chain-bound MPP Charge provider from Tempo Accounts. +/// +/// HTTP payment mechanics live in `alloy-transport-mpp`; this wrapper contains +/// only Foundry CLI policy: account-store discovery, optional interactive login +/// and funding, and session channel configuration. +#[derive(Clone)] +pub struct LazyAccountsProvider { + inner: Arc, TempoAccountsProvider>>>, + funding_by_challenge: Arc>>, + origin: String, +} + +impl fmt::Debug for LazyAccountsProvider { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("LazyAccountsProvider") + .field("origin", &redacted_url(&self.origin)) + .finish_non_exhaustive() + } +} + +impl LazyAccountsProvider { + pub(super) fn new(origin: String) -> Self { + Self { + inner: Arc::new(Mutex::new(HashMap::new())), + funding_by_challenge: Arc::new(Mutex::new(HashMap::new())), + origin, + } + } + + fn resolve(&self, chain_id: Option) -> Result { + let mut providers = lock_map(&self.inner); + if let Some(provider) = providers.get(&chain_id) { + return Ok(provider.clone()); + } + + let mut provider = TempoAccountsProvider::from_default_store().map_err(|error| { + MppError::InvalidConfig(format!( + "RPC endpoint returned HTTP 402 Payment Required, but the Tempo Accounts store \ + could not provide a Charge wallet: {error}\n\nAuthorize an access key with:\n \ + cast tempo login\n\nIn a headless environment, use:\n cast tempo login --no-browser" + )) + })?; + if let Some(chain_id) = chain_id { + provider = provider.with_expected_chain_id(chain_id); + } + let request_url = + Url::parse(&self.origin).map_err(|error| MppError::InvalidConfig(error.to_string()))?; + let store = SqliteChannelStore::open(SqliteChannelStoreOptions { + namespace: request_url.origin().ascii_serialization(), + path: None, + request_url: Some(redacted_url(&self.origin)), + }) + .map_err(|error| { + MppError::InvalidConfig(format!("failed to open Tempo channel store: {error}")) + })?; + provider = provider + .with_autoswap(AutoswapConfig::new( + crate::tempo::PATH_USD_ADDRESS, + DEFAULT_SLIPPAGE_BPS, + )) + .with_session_store(Arc::new(store)) + .with_session_default_deposit(DEFAULT_MPP_SESSION_DEPOSIT) + .with_session_top_up_amount(DEFAULT_MPP_SESSION_DEPOSIT) + .with_session_max_deposit(MAX_MPP_SESSION_DEPOSIT); + providers.insert(chain_id, provider.clone()); + Ok(provider) + } + + fn invalidate(&self) { + lock_map(&self.inner).clear(); + } + + fn funding_context(&self, challenge: &PaymentChallenge) -> FundingContext { + let (chain_id, token) = extract_challenge_chain_and_currency(challenge); + let context = FundingContext { + wallet_address: lock_map(&self.inner) + .values() + .next() + .and_then(|provider| provider.wallet().active_account().ok()) + .or_else(|| { + TempoAccountsStore::try_open_default().ok().flatten()?.active_account().ok() + }), + token, + chain_id: chain_id.map(Chain::from_id), + }; + let mut contexts = lock_map(&self.funding_by_challenge); + if contexts.len() >= 32 + && !contexts.contains_key(&challenge.id) + && let Some(oldest) = contexts.keys().next().cloned() + { + contexts.remove(&oldest); + } + contexts.insert(challenge.id.clone(), context.clone()); + context + } + + fn take_funding_context(&self, challenge_id: Option<&str>) -> FundingContext { + let mut contexts = lock_map(&self.funding_by_challenge); + let context = + challenge_id.and_then(|challenge_id| contexts.remove(challenge_id)).or_else(|| { + (contexts.len() == 1) + .then(|| contexts.keys().next().cloned()) + .flatten() + .and_then(|challenge_id| contexts.remove(&challenge_id)) + }); + context.unwrap_or_else(|| FundingContext { + wallet_address: TempoAccountsStore::try_open_default() + .ok() + .flatten() + .and_then(|store| store.active_account().ok()), + ..Default::default() + }) + } + + async fn needs_access_key( + &self, + challenge: &PaymentChallenge, + chain_id: u64, + ) -> Result { + match TempoAccountsStore::try_open_default() { + Ok(None) => Ok(true), + Ok(Some(_)) => { + let provider = self.resolve(Some(chain_id))?; + has_access_key_for_challenge(&provider, challenge, chain_id) + .await + .map(|has_access_key| !has_access_key) + } + Err(error) => Err(MppError::InvalidConfig(format!( + "failed to inspect Tempo Accounts store: {error}" + ))), + } + } +} + +impl PaymentProvider for LazyAccountsProvider { + fn supports(&self, method: &str, intent: &str) -> bool { + method == "tempo" && matches!(intent, "session" | "charge") + } + + async fn pay(&self, challenge: &PaymentChallenge) -> Result { + let (chain_id, _) = extract_challenge_chain_and_currency(challenge); + let provider = self.resolve(chain_id)?; + match provider.pay(challenge).await { + Ok(credential) => Ok(credential), + Err(error @ MppError::InsufficientBalance(_)) => { + let context = self.funding_context(challenge); + if run_interactive_tempo_fund(&context).await? { + provider.pay(challenge).await + } else { + Err(with_funding_help(error, &context)) + } + } + Err(error) => Err(error), + } + } + + async fn pay_with_context( + &self, + challenge: &PaymentChallenge, + context: PaymentContext, + ) -> Result { + let (chain_id, _) = extract_challenge_chain_and_currency(challenge); + let provider = self.resolve(chain_id)?; + match provider.pay_with_context(challenge, context.clone()).await { + Ok(credential) => Ok(credential), + Err(error @ MppError::InsufficientBalance(_)) => { + let funding = self.funding_context(challenge); + if run_interactive_tempo_fund(&funding).await? { + provider.pay_with_context(challenge, context).await + } else { + Err(with_funding_help(error, &funding)) + } + } + Err(error) => Err(error), + } + } + + async fn prepare_http_payment_challenge( + &self, + challenge: &PaymentChallenge, + _context: PaymentContext, + ) -> Result, MppError> { + self.funding_context(challenge); + let (Some(chain_id), _) = extract_challenge_chain_and_currency(challenge) else { + return Ok(Some(challenge.clone())); + }; + if !interactive_login_allowed() + || !Url::parse(&self.origin) + .is_ok_and(|origin| crate::tempo::is_known_tempo_endpoint(&origin)) + { + return Ok(Some(challenge.clone())); + } + + if !self.needs_access_key(challenge, chain_id).await? { + return Ok(Some(challenge.clone())); + } + + let config = crate::tempo::EnsureAccessKeyConfig::from_env(chain_id); + crate::tempo::ensure_access_key(config).await.map_err(|error| { + MppError::InvalidConfig(format!("Tempo access key authorization failed: {error}")) + })?; + self.invalidate(); + Ok(None) + } + + async fn commit_payment( + &self, + challenge: &PaymentChallenge, + credential: &PaymentCredential, + ) -> Result<(), MppError> { + lock_map(&self.funding_by_challenge).remove(&challenge.id); + let (chain_id, _) = extract_challenge_chain_and_currency(challenge); + self.resolve(chain_id)?.commit_payment(challenge, credential).await + } + + async fn rollback_payment( + &self, + challenge: &PaymentChallenge, + credential: &PaymentCredential, + ) -> Result<(), MppError> { + let (chain_id, _) = extract_challenge_chain_and_currency(challenge); + self.resolve(chain_id)?.rollback_payment(challenge, credential).await + } + + fn abandon_payment(&self, challenge: &PaymentChallenge, credential: &PaymentCredential) { + let (chain_id, _) = extract_challenge_chain_and_currency(challenge); + if let Ok(provider) = self.resolve(chain_id) { + provider.abandon_payment(challenge, credential); + } + } + + fn accept_payment_header(&self) -> Option { + Some("tempo/session, tempo/charge;q=0.5".to_owned()) + } +} + +async fn has_access_key_for_challenge( + provider: &TempoAccountsProvider, + challenge: &PaymentChallenge, + chain_id: u64, +) -> Result { + if challenge.intent.as_str() == "charge" { + return provider.has_access_key_for_challenge(challenge).await; + } + match provider.wallet().clone().with_chain_id(chain_id).active_access_key() { + Ok(_) => Ok(true), + Err(TempoAccountsError::MissingAccessKey { .. }) => Ok(false), + Err(error) => Err(MppError::InvalidConfig(format!( + "failed to inspect Tempo Accounts access key: {error}" + ))), + } +} + +#[derive(Clone, Debug, Default)] +struct FundingContext { + wallet_address: Option, + token: Option, + chain_id: Option, +} + +impl FundingContext { + fn help(&self) -> String { + let mut command = "tempo wallet fund".to_owned(); + if let Some(address) = self.wallet_address { + command.push_str(&format!(" --address {address}")); + } + if let Some(chain) = self.chain_id.filter(|chain| chain.is_tempo()) { + command.push_str(&format!(" --network {chain}")); + } + let token = self + .token + .as_ref() + .map(|token| format!("Requested payment token: {token}\n\n")) + .unwrap_or_default(); + format!( + "\n\nTempo wallet payment could not be funded for this paid RPC request.\n\n{token}\ + Fund the wallet, then rerun the command:\n {command}\n\n\ + If this CLI is running on a remote or headless host, use:\n {command} --no-browser" + ) + } +} + +fn with_funding_help(error: MppError, context: &FundingContext) -> MppError { + MppError::InsufficientBalance(Some(format!("{error}{}", context.help()))) +} + +fn insufficient_balance_details(error: &TransportError) -> Option { + let RpcError::Transport(kind) = error else { + return None; + }; + let http = kind.as_http_error().filter(|http| http.status == 402)?; + let mut deserializer = serde_json::Deserializer::from_str(&http.body); + ::deserialize(&mut deserializer).ok() +} + +fn with_transport_funding_help(error: TransportError, context: &FundingContext) -> TransportError { + match error { + RpcError::Transport(TransportErrorKind::HttpError(http)) => { + TransportErrorKind::http_error(http.status, format!("{}{}", http.body, context.help())) + } + error => error, + } +} + +fn interactive_login_allowed() -> bool { + !cfg!(test) && env::var_os("CI").is_none() && io::stderr().is_terminal() +} + +fn interactive_fund_allowed() -> bool { + if cfg!(test) || env::var_os("CI").is_some() { + return false; + } + if env::var("FOUNDRY_MPP_NO_AUTO_FUND").ok().is_some_and(|value| { + !(value == "0" || value.eq_ignore_ascii_case("false") || value.eq_ignore_ascii_case("off")) + }) { + return false; + } + io::stdin().is_terminal() && io::stderr().is_terminal() +} + +async fn run_interactive_tempo_fund(context: &FundingContext) -> Result { + if !interactive_fund_allowed() { + return Ok(false); + } + + let binary = env::var("TEMPO_BIN").unwrap_or_else(|_| "tempo".to_owned()); + let mut args = vec!["wallet".to_owned(), "fund".to_owned()]; + if let Some(address) = context.wallet_address { + args.push("--address".to_owned()); + args.push(address.to_string()); + } + if let Some(chain) = context.chain_id.filter(|chain| chain.is_tempo()) { + args.push("--network".to_owned()); + args.push(chain.to_string()); + } + let help = context.help(); + let status = tokio::task::spawn_blocking(move || { + Command::new(binary) + .args(args) + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .status() + }) + .await + .map_err(|error| MppError::InvalidConfig(format!("failed to join wallet fund: {error}{help}")))? + .map_err(|error| { + MppError::InvalidConfig(format!("failed to run wallet fund: {error}{help}")) + })?; + if status.success() { + Ok(true) + } else { + Err(MppError::InvalidConfig(format!("wallet fund exited with status {status}{help}"))) + } +} + +/// Extract `(chainId, currency)` from a Tempo Charge or Session challenge. +pub(super) fn extract_challenge_chain_and_currency( + challenge: &PaymentChallenge, +) -> (Option, Option) { + use mpp::protocol::methods::tempo::{TempoChargeExt, TempoSessionExt}; + + if challenge.method.as_str() != "tempo" { + return (None, None); + } + match challenge.intent.as_str() { + "charge" => challenge + .request + .decode::() + .map(|request| (request.chain_id(), Some(request.currency))) + .unwrap_or_default(), + "session" => challenge + .request + .decode::() + .map(|request| (request.chain_id(), Some(request.currency))) + .unwrap_or_default(), + _ => (None, None), + } +} + +fn lock_map(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(PoisonError::into_inner) +} + +fn redacted_url(raw: &str) -> String { + let Ok(mut redacted) = Url::parse(raw) else { + return "".to_owned(); + }; + let _ = redacted.set_username(""); + let _ = redacted.set_password(None); + redacted.set_query(None); + redacted.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use mpp::protocol::core::{Base64UrlJson, IntentName, MethodName}; + + fn challenge(method: &str, intent: &str) -> PaymentChallenge { + PaymentChallenge { + id: "test".to_owned(), + realm: "rpc.example".to_owned(), + method: MethodName::new(method), + intent: IntentName::new(intent), + request: Base64UrlJson::from_value(&serde_json::json!({ + "amount": "1", + "currency": "0x20c0000000000000000000000000000000000000", + "recipient": "0x0000000000000000000000000000000000000001", + "methodDetails": {"chainId": 42431} + })) + .unwrap(), + expires: None, + description: None, + digest: None, + opaque: None, + } + } + + #[test] + fn extracts_tempo_charge_routing() { + assert_eq!( + extract_challenge_chain_and_currency(&challenge("tempo", "charge")), + (Some(42431), Some("0x20c0000000000000000000000000000000000000".to_owned())) + ); + assert_eq!( + extract_challenge_chain_and_currency(&challenge("tempo", "session")), + (Some(42431), Some("0x20c0000000000000000000000000000000000000".to_owned())) + ); + assert_eq!( + extract_challenge_chain_and_currency(&challenge("stripe", "charge")), + (None, None) + ); + } + + #[test] + fn advertises_sessions_before_charges() { + let provider = LazyAccountsProvider::new("https://rpc.example".to_owned()); + assert_eq!( + provider.accept_payment_header().as_deref(), + Some("tempo/session, tempo/charge;q=0.5") + ); + } + + #[test] + fn debug_redacts_origin_secrets() { + let provider = LazyAccountsProvider::new( + "https://user:password@example.com/rpc?token=secret".to_owned(), + ); + let debug = format!("{provider:?}"); + assert!(!debug.contains("password")); + assert!(!debug.contains("secret")); + assert!(debug.contains("https://example.com/rpc")); + } + + #[tokio::test] + async fn missing_accounts_store_is_detected_before_provider_resolution() { + let _guard = crate::tempo::test_env_mutex().lock().await; + let directory = tempfile::tempdir().unwrap(); + // SAFETY: serialized with every other test that mutates TEMPO_HOME. + unsafe { env::set_var(crate::tempo::TEMPO_HOME_ENV, directory.path()) }; + + let provider = LazyAccountsProvider::new("https://rpc.mpp.tempo.xyz".to_owned()); + assert!(provider.needs_access_key(&challenge("tempo", "charge"), 42431).await.unwrap()); + assert!(lock_map(&provider.inner).is_empty()); + + // SAFETY: serialized with every other test that mutates TEMPO_HOME. + unsafe { env::remove_var(crate::tempo::TEMPO_HOME_ENV) }; + } + + #[test] + fn recognizes_structured_insufficient_balance_with_http_diagnostics() { + let error = TransportErrorKind::http_error( + 402, + concat!( + r#"{"type":"https://paymentauth.org/problems/insufficient-balance","#, + r#""title":"Insufficient Balance","status":402,"detail":"fund me"}"#, + "\n\nHTTP diagnostics:\nserver: test" + ) + .to_owned(), + ); + let problem = insufficient_balance_details(&error).unwrap(); + assert!(problem.problem_type.ends_with("/insufficient-balance")); + } + + #[test] + fn appends_funding_help_to_structured_402() { + let error = TransportErrorKind::http_error( + 402, + r#"{"type":"https://paymentauth.org/problems/insufficient-balance"}"#.to_owned(), + ); + let error = with_transport_funding_help( + error, + &FundingContext { + token: Some("0x20c0000000000000000000000000000000000000".to_owned()), + chain_id: Some(Chain::from_id(42431)), + ..Default::default() + }, + ); + let RpcError::Transport(kind) = error else { panic!("expected transport error") }; + let http = kind.as_http_error().expect("expected HTTP error"); + assert_eq!(http.status, 402); + assert!(http.body.contains("insufficient-balance")); + assert!(http.body.contains("Requested payment token")); + assert!(http.body.contains("tempo wallet fund")); + } + + #[test] + fn funding_contexts_are_selected_by_challenge_id() { + let provider = LazyAccountsProvider::new("https://rpc.mpp.tempo.xyz".to_owned()); + let first = challenge("tempo", "charge"); + let mut second = challenge("tempo", "charge"); + second.id = "second".to_owned(); + second.request = Base64UrlJson::from_value(&serde_json::json!({ + "amount": "1", + "currency": "0x20c0000000000000000000000000000000000001", + "recipient": "0x0000000000000000000000000000000000000001", + "methodDetails": {"chainId": 4217} + })) + .unwrap(); + + provider.funding_context(&first); + provider.funding_context(&second); + let second_context = provider.take_funding_context(Some("second")); + let first_context = provider.take_funding_context(Some("test")); + + assert_eq!(second_context.chain_id, Some(Chain::from_id(4217))); + assert_eq!( + second_context.token.as_deref(), + Some("0x20c0000000000000000000000000000000000001") + ); + assert_eq!(first_context.chain_id, Some(Chain::from_id(42431))); + assert_eq!( + first_context.token.as_deref(), + Some("0x20c0000000000000000000000000000000000000") + ); + } +} diff --git a/crates/common/src/provider/runtime_transport.rs b/crates/common/src/provider/runtime_transport.rs index 1130ff706bb90..ae2f31e042856 100644 --- a/crates/common/src/provider/runtime_transport.rs +++ b/crates/common/src/provider/runtime_transport.rs @@ -1,29 +1,59 @@ //! Runtime transport that connects on first request, which can take either of an HTTP, -//! WebSocket, or IPC transport and supports retries based on CUPS logic. - -use crate::{DEFAULT_USER_AGENT, REQUEST_TIMEOUT}; +//! WebSocket, or IPC transport. Retries are handled by a client layer (e.g., +//! `RetryBackoffLayer`) when used. + +use crate::{ + DEFAULT_USER_AGENT, REQUEST_TIMEOUT, + provider::{ + mpp::transport::{LazyMppHttpTransport, lazy_mpp_ws_connect}, + redact_url, + }, +}; use alloy_json_rpc::{RequestPacket, ResponsePacket}; use alloy_pubsub::{PubSubConnect, PubSubFrontend}; -use alloy_rpc_types::engine::{Claims, JwtSecret}; +use alloy_rpc_types_engine::{Claims, JwtSecret}; use alloy_transport::{ Authorization, BoxTransport, TransportError, TransportErrorKind, TransportFut, + utils::guess_local_url, }; -use alloy_transport_http::Http; use alloy_transport_ipc::IpcConnect; use alloy_transport_ws::WsConnect; +use regex::{Captures, Regex}; use reqwest::header::{HeaderName, HeaderValue}; -use std::{fmt, path::PathBuf, str::FromStr, sync::Arc}; +use std::{ + error::Error as StdError, + fmt, + path::PathBuf, + str::FromStr, + sync::{Arc, LazyLock}, +}; use thiserror::Error; -use tokio::sync::RwLock; +use tokio::{ + runtime::{Handle, Id}, + sync::RwLock, +}; use tower::Service; use url::Url; +/// Known MPP-enabled RPC host suffixes. +/// +/// Endpoints matching these patterns always use the MPP WebSocket transport, +/// regardless of whether local MPP keys have been discovered. +const KNOWN_MPP_HOSTS: &[&str] = &[".mpp.tempo.xyz", ".mpp.moderato.tempo.xyz"]; + +static HTTP_URL_RE: LazyLock = + LazyLock::new(|| Regex::new(r#"(?i)https?://[^\s<>"']+"#).expect("valid URL regex")); + /// An enum representing the different transports that can be used to connect to a runtime. /// Only meant to be used internally by [RuntimeTransport]. #[derive(Clone, Debug)] pub enum InnerTransport { - /// HTTP transport - Http(Http), + /// HTTP transport with lazy MPP 402 handling. + /// + /// For known Tempo endpoints, the MPP layer additionally runs the + /// `wallet.tempo.xyz` device-code flow on a 402 when no local access key + /// is configured (see [`crate::tempo::ensure_access_key`]). + Http(LazyMppHttpTransport), /// WebSocket transport Ws(PubSubFrontend), /// IPC transport @@ -37,10 +67,6 @@ pub enum RuntimeTransportError { #[error("Internal transport error: {0} with {1}")] TransportError(TransportError, String), - /// Failed to lock the transport - #[error("Failed to lock the transport")] - LockError, - /// Invalid URL scheme #[error("URL scheme is not supported: {0}")] BadScheme(String), @@ -67,11 +93,12 @@ pub enum RuntimeTransportError { /// A runtime transport is a custom [`alloy_transport::Transport`] that only connects when the /// *first* request is made. When the first request is made, it will connect to the runtime using /// either an HTTP WebSocket, or IPC transport depending on the URL used. -/// It also supports retries for rate-limiting and timeout-related errors. -#[derive(Clone, Debug, Error)] +/// Retries for rate-limiting and timeout-related errors are handled by an external +/// client layer (e.g., `RetryBackoffLayer`) when configured. +#[derive(Clone, Debug)] pub struct RuntimeTransport { /// The inner actual transport used. - inner: Arc>>, + inner: Arc>>, /// The URL to connect to. url: Url, /// The headers to use for requests. @@ -82,6 +109,8 @@ pub struct RuntimeTransport { timeout: std::time::Duration, /// Whether to accept invalid certificates. accept_invalid_certs: bool, + /// Whether to disable automatic proxy detection. + no_proxy: bool, } /// A builder for [RuntimeTransport]. @@ -92,17 +121,19 @@ pub struct RuntimeTransportBuilder { jwt: Option, timeout: std::time::Duration, accept_invalid_certs: bool, + no_proxy: bool, } impl RuntimeTransportBuilder { /// Create a new builder with the given URL. - pub fn new(url: Url) -> Self { + pub const fn new(url: Url) -> Self { Self { url, headers: vec![], jwt: None, timeout: REQUEST_TIMEOUT, accept_invalid_certs: false, + no_proxy: false, } } @@ -119,17 +150,26 @@ impl RuntimeTransportBuilder { } /// Set the timeout for the transport. - pub fn with_timeout(mut self, timeout: std::time::Duration) -> Self { + pub const fn with_timeout(mut self, timeout: std::time::Duration) -> Self { self.timeout = timeout; self } /// Set whether to accept invalid certificates. - pub fn accept_invalid_certs(mut self, accept_invalid_certs: bool) -> Self { + pub const fn accept_invalid_certs(mut self, accept_invalid_certs: bool) -> Self { self.accept_invalid_certs = accept_invalid_certs; self } + /// Set whether to disable automatic proxy detection. + /// + /// This can help in sandboxed environments (e.g., Cursor IDE sandbox, macOS App Sandbox) + /// where system proxy detection via SCDynamicStore causes crashes. + pub const fn no_proxy(mut self, no_proxy: bool) -> Self { + self.no_proxy = no_proxy; + self + } + /// Builds the [RuntimeTransport] and returns it in a disconnected state. /// The runtime transport will then connect when the first request happens. pub fn build(self) -> RuntimeTransport { @@ -140,13 +180,14 @@ impl RuntimeTransportBuilder { jwt: self.jwt, timeout: self.timeout, accept_invalid_certs: self.accept_invalid_certs, + no_proxy: self.no_proxy, } } } impl fmt::Display for RuntimeTransport { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "RuntimeTransport {}", self.url) + write!(f, "RuntimeTransport {}", redact_url(self.url.as_str())) } } @@ -161,12 +202,7 @@ impl RuntimeTransport { } } - /// Creates a new reqwest client from this transport. - pub fn reqwest_client(&self) -> Result { - let mut client_builder = reqwest::Client::builder() - .timeout(self.timeout) - .tls_built_in_root_certs(self.url.scheme() == "https") - .danger_accept_invalid_certs(self.accept_invalid_certs); + fn reqwest_headers(&self) -> Result { let mut headers = reqwest::header::HeaderMap::new(); // If there's a JWT, add it to the headers if we can decode it. @@ -183,7 +219,7 @@ impl RuntimeTransport { // Add any custom headers. for header in &self.headers { - let make_err = || RuntimeTransportError::BadHeader(header.to_string()); + let make_err = || RuntimeTransportError::BadHeader(header.clone()); let (key, val) = header.split_once(':').ok_or_else(make_err)?; @@ -201,28 +237,81 @@ impl RuntimeTransport { ); } + // If MPP_API_KEY is set, attach it as x-api-key for gated MPP proxies. + // Does not override an explicit x-api-key header from the user. + if !headers.contains_key(HeaderName::from_static("x-api-key")) + && let Ok(api_key) = std::env::var("MPP_API_KEY") + { + let api_key = api_key.trim(); + if !api_key.is_empty() { + let mut value = HeaderValue::from_str(api_key) + .map_err(|_| RuntimeTransportError::BadHeader("MPP_API_KEY".to_string()))?; + value.set_sensitive(true); + headers.insert(HeaderName::from_static("x-api-key"), value); + } + } + + Ok(headers) + } + + fn reqwest_client_with_headers( + &self, + headers: reqwest::header::HeaderMap, + ) -> Result { + let mut client_builder = reqwest::Client::builder() + .timeout(self.timeout) + .danger_accept_invalid_certs(self.accept_invalid_certs); + + // Disable automatic proxy detection if requested. This helps in sandboxed environments + // (e.g., Cursor IDE sandbox, macOS App Sandbox) where system proxy detection via + // SCDynamicStore causes crashes. See: https://github.com/foundry-rs/foundry/issues/12733 + if self.no_proxy || guess_local_url(self.url.as_str()) { + client_builder = client_builder.no_proxy(); + } + client_builder = client_builder.default_headers(headers); Ok(client_builder.build()?) } - /// Connects to an HTTP [alloy_transport_http::Http] transport. + /// Creates a new reqwest client from this transport. + pub fn reqwest_client(&self) -> Result { + self.reqwest_client_with_headers(self.reqwest_headers()?) + } + + /// Connects to an HTTP transport with lazy MPP 402 handling. fn connect_http(&self) -> Result { - let client = self.reqwest_client()?; - Ok(InnerTransport::Http(Http::with_client(client, self.url.clone()))) + let headers = self.reqwest_headers()?; + let client = self.reqwest_client_with_headers(headers.clone())?; + Ok(InnerTransport::Http(LazyMppHttpTransport::lazy(client, self.url.clone(), headers))) } /// Connects to a WS transport. + /// + /// Uses the canonical Alloy MPP WebSocket transport when the endpoint is a + /// known MPP service. + /// Otherwise falls back to alloy's plain [`WsConnect`] with zero overhead. async fn connect_ws(&self) -> Result { let auth = self.jwt.as_ref().and_then(|jwt| build_auth(jwt.clone()).ok()); - let mut ws = WsConnect::new(self.url.to_string()); - if let Some(auth) = auth { - ws = ws.with_auth(auth); + + let service = if is_known_mpp_endpoint(&self.url) { + let mut ws = lazy_mpp_ws_connect(&self.url); + if let Some(auth) = auth { + ws = ws.with_auth(auth); + } + ws.into_service().await.map_err(|e| { + RuntimeTransportError::TransportError(e, redact_url(self.url.as_str())) + })? + } else { + let mut ws = WsConnect::new(self.url.to_string()); + if let Some(auth) = auth { + ws = ws.with_auth(auth); + } + ws.into_service().await.map_err(|e| { + RuntimeTransportError::TransportError(e, redact_url(self.url.as_str())) + })? }; - let service = ws - .into_service() - .await - .map_err(|e| RuntimeTransportError::TransportError(e, self.url.to_string()))?; + Ok(InnerTransport::Ws(service)) } @@ -239,34 +328,40 @@ impl RuntimeTransport { /// Sends a request using the underlying transport. /// If this is the first request, it will connect to the appropriate transport depending on the - /// URL scheme. When sending the request, retries will be automatically handled depending - /// on the parameters set on the [RuntimeTransport]. + /// URL scheme. Retries are performed by an external client layer (e.g., `RetryBackoffLayer`), + /// if such a layer is configured by the caller. /// For sending the actual request, this action is delegated down to the /// underlying transport through Tower's [tower::Service::call]. See tower's [tower::Service] /// trait for more information. pub fn request(&self, req: RequestPacket) -> TransportFut<'static> { let this = self.clone(); Box::pin(async move { - let mut inner = this.inner.read().await; - if inner.is_none() { + let runtime = Handle::current().id(); + let inner = this.inner.read().await; + let transport = if let Some((id, transport)) = &*inner + && *id == runtime + { + transport.clone() + } else { drop(inner); - { - let mut inner_mut = this.inner.write().await; - if inner_mut.is_none() { - *inner_mut = - Some(this.connect().await.map_err(TransportErrorKind::custom)?); - } + let mut inner = this.inner.write().await; + // Connections may own tasks and I/O resources on their originating runtime. + // Reconnect when a shared client is used from another runtime. + if inner.as_ref().is_none_or(|(id, _)| *id != runtime) { + *inner = + Some((runtime, this.connect().await.map_err(TransportErrorKind::custom)?)); } - inner = this.inner.read().await; - } - - // SAFETY: We just checked that the inner transport exists. - match inner.clone().expect("must've been initialized") { - InnerTransport::Http(mut http) => http.call(req), - InnerTransport::Ws(mut ws) => ws.call(req), - InnerTransport::Ipc(mut ipc) => ipc.call(req), + inner.as_ref().expect("transport is connected").1.clone() + }; + + match transport { + InnerTransport::Http(mut http) => http + .call(req) + .await + .map_err(|error| redact_http_transport_error(error, &this.url)), + InnerTransport::Ws(mut ws) => ws.call(req).await, + InnerTransport::Ipc(mut ipc) => ipc.call(req).await, } - .await }) } @@ -279,6 +374,40 @@ impl RuntimeTransport { } } +/// Returns `true` if `url` points to a known MPP-enabled RPC service. +fn is_known_mpp_endpoint(url: &Url) -> bool { + url.host_str().is_some_and(|host| KNOWN_MPP_HOSTS.iter().any(|suffix| host.ends_with(suffix))) +} + +fn redact_http_transport_error(error: TransportError, endpoint: &Url) -> TransportError { + let alloy_json_rpc::RpcError::Transport(TransportErrorKind::Custom(source)) = &error else { + return error; + }; + let safe_endpoint = redact_url(endpoint.as_str()); + + let mut message = String::new(); + let mut error: Option<&(dyn StdError + 'static)> = Some(source.as_ref()); + while let Some(source) = error { + if !message.is_empty() { + message.push_str(": "); + } + message.push_str(&source.to_string()); + error = source.source(); + } + let message = HTTP_URL_RE.replace_all(&message, |captures: &Captures<'_>| { + let candidate = &captures[0]; + let Ok(url) = Url::parse(candidate) else { return candidate.to_owned() }; + if url.host() == endpoint.host() + && url.port_or_known_default() == endpoint.port_or_known_default() + { + safe_endpoint.clone() + } else { + candidate.to_owned() + } + }); + TransportErrorKind::custom_str(&message) +} + impl tower::Service for RuntimeTransport { type Response = ResponsePacket; type Error = TransportError; @@ -351,6 +480,89 @@ fn url_to_file_path(url: &Url) -> Result { mod tests { use super::*; use reqwest::header::HeaderMap; + use std::io; + + #[derive(Debug, Error)] + #[error("request to https://example.com/private-api-key failed")] + struct ProviderError { + #[source] + source: io::Error, + } + + #[test] + fn http_transport_errors_preserve_provider_guidance() { + let endpoint = + Url::parse("https://user:password@example.com/private-api-key?token=secret").unwrap(); + let error = TransportErrorKind::custom(ProviderError { + source: io::Error::other( + "Authorize an access key with:\n cast tempo login --no-browser", + ), + }); + + let report = redact_http_transport_error(error, &endpoint).to_string(); + + assert!(report.contains("https://example.com/")); + assert!(report.contains("cast tempo login --no-browser")); + assert!(!report.contains("password")); + assert!(!report.contains("private-api-key")); + assert!(!report.contains("secret")); + } + + #[test] + fn http_transport_errors_redact_endpoint_paths() { + let endpoint = + Url::parse("https://user:password@example.com/private-api-key?token=secret").unwrap(); + let error = TransportErrorKind::custom_str(concat!( + "request to https://example.com/private-api-key failed: connection refused\n\n", + "Authorize an access key with:\n cast tempo login" + )); + + let error = redact_http_transport_error(error, &endpoint); + let report = error.to_string(); + + assert!(report.contains("https://example.com/")); + assert!(!report.contains("password")); + assert!(!report.contains("private-api-key")); + assert!(!report.contains("secret")); + assert!(report.to_lowercase().contains("connection refused")); + assert!(report.contains("cast tempo login")); + } + + #[test] + fn http_transport_errors_redact_normalized_endpoint_variants() { + let endpoint = + Url::parse("https://user:password@example.com/private-api-key?token=secret").unwrap(); + let error = TransportErrorKind::custom_str( + "request to https://USER:normalized@example.com:443/different%2Fpath?key=other failed", + ); + + let report = redact_http_transport_error(error, &endpoint).to_string(); + + assert!(report.contains("https://example.com/")); + assert!(!report.contains("normalized")); + assert!(!report.contains("different")); + assert!(!report.contains("other")); + } + + #[tokio::test] + async fn websocket_error_redacts_url_credentials() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + drop(listener); + let url = Url::parse(&format!( + "ws://user:password@{address}/private-api-key?token=secret#fragment" + )) + .unwrap(); + let transport = RuntimeTransportBuilder::new(url).build(); + + let error = transport.connect_ws().await.unwrap_err().to_string(); + + assert!(error.contains(&format!("ws://{address}/"))); + assert!(!error.contains("user")); + assert!(!error.contains("password")); + assert!(!error.contains("private-api-key")); + assert!(!error.contains("secret")); + } #[tokio::test] async fn test_user_agent_header() { diff --git a/crates/common/src/reports.rs b/crates/common/src/reports.rs deleted file mode 100644 index cb00a7eda43c7..0000000000000 --- a/crates/common/src/reports.rs +++ /dev/null @@ -1,15 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use crate::shell; - -#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -pub enum ReportKind { - #[default] - Text, - JSON, -} - -/// Determine the kind of report to generate based on the current shell. -pub fn report_kind() -> ReportKind { - if shell::is_json() { ReportKind::JSON } else { ReportKind::Text } -} diff --git a/crates/common/src/retry.rs b/crates/common/src/retry.rs index a8e8188e43246..ec102d3ef0446 100644 --- a/crates/common/src/retry.rs +++ b/crates/common/src/retry.rs @@ -23,12 +23,12 @@ pub struct Retry { impl Retry { /// Creates a new `Retry` instance. - pub fn new(retries: u32, delay: Duration) -> Self { + pub const fn new(retries: u32, delay: Duration) -> Self { Self { retries, delay } } /// Creates a new `Retry` instance with no delay between retries. - pub fn new_no_delay(retries: u32) -> Self { + pub const fn new_no_delay(retries: u32) -> Self { Self::new(retries, Duration::ZERO) } diff --git a/crates/common/src/selectors.rs b/crates/common/src/selectors.rs index 8597498643f83..0acbf3353eb6a 100644 --- a/crates/common/src/selectors.rs +++ b/crates/common/src/selectors.rs @@ -17,9 +17,15 @@ use std::{ time::Duration, }; -const BASE_URL: &str = "https://api.openchain.xyz"; -const SELECTOR_LOOKUP_URL: &str = "https://api.openchain.xyz/signature-database/v1/lookup"; -const SELECTOR_IMPORT_URL: &str = "https://api.openchain.xyz/signature-database/v1/import"; +const BASE_URL: &str = "https://api.4byte.sourcify.dev"; +const SELECTOR_LOOKUP_URL: &str = "https://api.4byte.sourcify.dev/signature-database/v1/lookup"; +const SELECTOR_IMPORT_URL: &str = "https://api.4byte.sourcify.dev/signature-database/v1/import"; + +/// The selector registry uses Express's default 100 KiB JSON body limit. +const SELECTOR_IMPORT_BODY_LIMIT: usize = 100 * 1024; + +/// The selector registry accepts at most this many signatures of each kind per database insert. +const SELECTOR_IMPORT_SIGNATURE_LIMIT: usize = 1000; /// The standard request timeout for API requests. const REQ_TIMEOUT: Duration = Duration::from_secs(15); @@ -65,6 +71,8 @@ impl OpenChainClient { .send() .await .inspect_err(|err| self.on_reqwest_err(err))? + .error_for_status() + .inspect_err(|err| self.on_reqwest_err(err))? .text() .await .inspect_err(|err| self.on_reqwest_err(err)) @@ -83,6 +91,8 @@ impl OpenChainClient { .send() .await .inspect_err(|err| self.on_reqwest_err(err))? + .error_for_status() + .inspect_err(|err| self.on_reqwest_err(err))? .json() .await .inspect_err(|err| self.on_reqwest_err(err)) @@ -106,7 +116,7 @@ impl OpenChainClient { if is_connectivity_err(err) { warn!("spurious network detected for OpenChain"); let previous = self.timedout_requests.fetch_add(1, Ordering::SeqCst); - if previous >= self.max_timedout_requests { + if previous + 1 >= self.max_timedout_requests { self.set_spurious(); } } @@ -124,7 +134,7 @@ impl OpenChainClient { fn ensure_not_spurious(&self) -> eyre::Result<()> { if self.is_spurious() { - eyre::bail!("Spurious connection detected") + eyre::bail!("Spurious connection detected"); } Ok(()) } @@ -173,7 +183,9 @@ impl OpenChainClient { let text = self.get_text(url).await?; let SignatureResponse { ok, result } = match serde_json::from_str(&text) { Ok(response) => response, - Err(err) => eyre::bail!("could not decode response: {err}: {text}"), + Err(err) => { + eyre::bail!("could not decode response: {err}: {text}"); + } }; if !ok { eyre::bail!("OpenChain returned an error: {text}"); @@ -214,10 +226,10 @@ impl OpenChainClient { eyre::bail!( "Calldata too short: expected at least 8 characters (excluding 0x prefix), got {}.", calldata.len() - ) + ); } - let mut sigs = self.decode_function_selector(calldata[..8].parse().unwrap()).await?; + let mut sigs = self.decode_function_selector(calldata[..8].parse()?).await?; // Retain only signatures that can be decoded. sigs.retain(|sig| abi_decode_calldata(sig, calldata, true, true).is_ok()); Ok(sigs) @@ -265,7 +277,7 @@ impl OpenChainClient { let (_, data) = calldata.split_at(8); if !data.len().is_multiple_of(64) { - eyre::bail!("\nInvalid calldata size") + eyre::bail!("\nInvalid calldata size"); } let row_length = data.len() / 64; @@ -296,13 +308,14 @@ impl OpenChainClient { abi.functions() .map(|func| func.signature()) .chain(abi.errors().map(|error| error.signature())) - .collect::>() }) + .unique() .collect(); let events = abis .iter() .flat_map(|abi| abi.events().map(|event| event.signature())) + .unique() .collect::>(); SelectorImportRequest { function: functions_and_errors, event: events } @@ -314,7 +327,20 @@ impl OpenChainClient { } }; - Ok(self.post_json(SELECTOR_IMPORT_URL, &request).await?) + let mut response: Option = None; + for request in request.into_chunks()? { + let chunk: SelectorImportResponse = + self.post_json(SELECTOR_IMPORT_URL, &request).await?; + if let Some(response) = &mut response { + response.result.function.imported.extend(chunk.result.function.imported); + response.result.function.duplicated.extend(chunk.result.function.duplicated); + response.result.event.imported.extend(chunk.result.event.imported); + response.result.event.duplicated.extend(chunk.result.event.duplicated); + } else { + response = Some(chunk); + } + } + Ok(response.expect("selector import always contains at least one request")) } } @@ -371,7 +397,7 @@ pub enum SelectorKind { impl SelectorKind { /// Returns the function selector if it is a function OR custom error. - pub fn as_function(&self) -> Option { + pub const fn as_function(&self) -> Option { match *self { Self::Function(selector) | Self::Error(selector) => Some(selector), _ => None, @@ -379,7 +405,7 @@ impl SelectorKind { } /// Returns the event selector if it is an event. - pub fn as_event(&self) -> Option { + pub const fn as_event(&self) -> Option { match *self { Self::Event(hash) => Some(hash), _ => None, @@ -444,7 +470,7 @@ pub struct RawSelectorImportData { } impl RawSelectorImportData { - pub fn is_empty(&self) -> bool { + pub const fn is_empty(&self) -> bool { self.function.is_empty() && self.event.is_empty() && self.error.is_empty() } } @@ -462,6 +488,49 @@ struct SelectorImportRequest { event: OpenChainSignatures, } +impl SelectorImportRequest { + fn into_chunks(self) -> eyre::Result> { + let empty_request_len = serde_json::to_vec(&Self::default()).unwrap().len(); + let mut chunks = Vec::new(); + let mut chunk = Self::default(); + let mut chunk_len = empty_request_len; + + for (is_event, signature) in self + .function + .into_iter() + .map(|signature| (false, signature)) + .chain(self.event.into_iter().map(|signature| (true, signature))) + { + let signature_len = serde_json::to_vec(&signature).unwrap().len(); + if empty_request_len + signature_len > SELECTOR_IMPORT_BODY_LIMIT { + eyre::bail!("selector signature exceeds the registry request body limit"); + } + let needs_comma = + if is_event { !chunk.event.is_empty() } else { !chunk.function.is_empty() }; + let signatures_full = if is_event { + chunk.event.len() == SELECTOR_IMPORT_SIGNATURE_LIMIT + } else { + chunk.function.len() == SELECTOR_IMPORT_SIGNATURE_LIMIT + }; + if (signatures_full + || chunk_len + signature_len + usize::from(needs_comma) + > SELECTOR_IMPORT_BODY_LIMIT) + && chunk_len > empty_request_len + { + chunks.push(chunk); + chunk = Self::default(); + chunk_len = empty_request_len; + } + + let signatures = if is_event { &mut chunk.event } else { &mut chunk.function }; + chunk_len += signature_len + usize::from(!signatures.is_empty()); + signatures.push(signature); + } + chunks.push(chunk); + Ok(chunks) + } +} + #[derive(Debug, Deserialize)] struct SelectorImportEffect { imported: HashMap, @@ -482,18 +551,18 @@ pub struct SelectorImportResponse { impl SelectorImportResponse { /// Print info about the functions which were uploaded or already known pub fn describe(&self) { - self.result.function.imported.iter().for_each(|(k, v)| { + for (k, v) in &self.result.function.imported { let _ = sh_println!("Imported: Function {k}: {v}"); - }); - self.result.event.imported.iter().for_each(|(k, v)| { + } + for (k, v) in &self.result.event.imported { let _ = sh_println!("Imported: Event {k}: {v}"); - }); - self.result.function.duplicated.iter().for_each(|(k, v)| { + } + for (k, v) in &self.result.function.duplicated { let _ = sh_println!("Duplicated: Function {k}: {v}"); - }); - self.result.event.duplicated.iter().for_each(|(k, v)| { + } + for (k, v) in &self.result.event.duplicated { let _ = sh_println!("Duplicated: Event {k}: {v}"); - }); + } let _ = sh_println!("Selectors successfully uploaded to OpenChain"); } @@ -535,6 +604,7 @@ pub fn parse_signatures(tokens: Vec) -> ParsedSignatures { RawSelectorImportData::default(), |mut data, signature| { let mut split = signature.split(' '); + #[allow(clippy::collapsible_match)] match split.next() { Some("function") => { if let Some(sig) = split.next() { @@ -585,6 +655,24 @@ struct Signature { #[cfg(test)] mod tests { use super::*; + use axum::{ + Router, + http::StatusCode, + routing::{get, post}, + }; + + async fn spawn_status_server() -> (String, tokio::task::JoinHandle<()>) { + let app = Router::new() + .route("/unavailable", get(|| async { StatusCode::SERVICE_UNAVAILABLE })) + .route("/bad-request", get(|| async { StatusCode::BAD_REQUEST })) + .route("/post-unavailable", post(|| async { StatusCode::SERVICE_UNAVAILABLE })); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let handle = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (format!("http://{addr}"), handle) + } #[test] fn test_parse_signatures() { @@ -642,4 +730,87 @@ mod tests { ParsedSignatures { signatures: Default::default(), ..Default::default() } ); } + + #[test] + fn selector_import_requests_stay_within_registry_limits() { + let suffix = "x".repeat(100); + let function = (0..3_000) + .map(|i| format!("functionName{i:04}{suffix}(address,uint256)")) + .collect::>(); + let expected = function.clone(); + let chunks = SelectorImportRequest { function, event: Vec::new() }.into_chunks().unwrap(); + + assert!(chunks.len() > 3); + assert!(chunks.iter().all(|chunk| chunk.function.len() <= SELECTOR_IMPORT_SIGNATURE_LIMIT + && serde_json::to_vec(chunk).unwrap().len() <= SELECTOR_IMPORT_BODY_LIMIT)); + assert_eq!( + chunks.into_iter().flat_map(|chunk| chunk.function).collect::>(), + expected + ); + } + + #[tokio::test] + async fn spurious_marked_on_timeout_threshold() { + // Use an unreachable local port to trigger a quick connect error. + let client = OpenChainClient::new().expect("client must build"); + let url = "http://127.0.0.1:9"; // Discard port; typically closed and fails fast. + + // After MAX_TIMEDOUT_REQ - 1 failures we should NOT be spurious. + for i in 0..(MAX_TIMEDOUT_REQ - 1) { + let _ = client.get_text(url).await; // expect an error and internal counter increment + assert!(!client.is_spurious(), "unexpected spurious after {} failed attempts", i + 1); + } + + // The Nth failure (N == MAX_TIMEDOUT_REQ) should flip the spurious flag. + let _ = client.get_text(url).await; + assert!(client.is_spurious(), "expected spurious after threshold failures"); + } + + #[tokio::test] + async fn spurious_marked_on_http_5xx_threshold() { + let (base_url, server_task) = spawn_status_server().await; + let client = OpenChainClient::new().expect("client must build"); + + for i in 0..(MAX_TIMEDOUT_REQ - 1) { + let result = client.get_text(format!("{base_url}/unavailable")).await; + assert!(result.is_err(), "expected HTTP 503 on attempt {}", i + 1); + assert!(!client.is_spurious(), "unexpected spurious after {} failed attempts", i + 1); + } + + let result = client.get_text(format!("{base_url}/unavailable")).await; + assert!(result.is_err()); + assert!(client.is_spurious(), "expected spurious after threshold HTTP 503 responses"); + + server_task.abort(); + } + + #[tokio::test] + async fn post_http_5xx_counts_as_connectivity_failure() { + let (base_url, server_task) = spawn_status_server().await; + let client = OpenChainClient::new().expect("client must build"); + let body = serde_json::json!({}); + + for _ in 0..MAX_TIMEDOUT_REQ { + let result = client + .post_json::<_, serde_json::Value>(&format!("{base_url}/post-unavailable"), &body) + .await; + assert!(result.is_err(), "expected HTTP 503"); + } + + assert!(client.is_spurious(), "expected HTTP 503 POSTs to mark the connection spurious"); + server_task.abort(); + } + + #[tokio::test] + async fn http_4xx_does_not_count_as_connectivity_failure() { + let (base_url, server_task) = spawn_status_server().await; + let client = OpenChainClient::new().expect("client must build"); + + assert!(client.get_text(format!("{base_url}/bad-request")).await.is_err()); + assert!(client.get_text(format!("{base_url}/missing")).await.is_err()); + assert_eq!(client.timedout_requests.load(Ordering::SeqCst), 0); + assert!(!client.is_spurious()); + + server_task.abort(); + } } diff --git a/crates/common/src/serde_helpers.rs b/crates/common/src/serde_helpers.rs index e643997abbd13..46e9f31d3cffd 100644 --- a/crates/common/src/serde_helpers.rs +++ b/crates/common/src/serde_helpers.rs @@ -1,6 +1,6 @@ //! Misc Serde helpers for foundry crates. -use alloy_primitives::U256; +use alloy_primitives::{U64, U256}; use serde::{Deserialize, Deserializer, de}; use std::str::FromStr; @@ -37,47 +37,25 @@ impl FromStr for Numeric { } } -/// Deserializes the input into an `Option`, using [`from_int_or_hex`] to deserialize the -/// inner value. -pub fn from_int_or_hex_opt<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - match Option::::deserialize(deserializer)? { - Some(val) => val.try_into_u256().map(Some), - None => Ok(None), - } -} - -/// An enum that represents either a [serde_json::Number] integer, or a hex [U256]. -#[derive(Debug, Deserialize)] +/// Helper type to parse a `u64` from a JSON number or a hex/decimal string. +#[derive(Copy, Clone, Deserialize)] #[serde(untagged)] -pub enum NumberOrHexU256 { - /// An integer - Int(serde_json::Number), - /// A hex U256 - Hex(U256), +pub enum Numeric64 { + /// A JSON number. + Num(u64), + /// A hex or decimal string. + U64(U64), } -impl NumberOrHexU256 { - /// Tries to convert this into a [U256]]. - pub fn try_into_u256(self) -> Result { - match self { - Self::Int(num) => U256::from_str(num.to_string().as_str()).map_err(E::custom), - Self::Hex(val) => Ok(val), +impl From for u64 { + fn from(n: Numeric64) -> Self { + match n { + Numeric64::Num(n) => n, + Numeric64::U64(n) => n.to::(), } } } -/// Deserializes the input into a U256, accepting both 0x-prefixed hex and decimal strings with -/// arbitrary precision, defined by serde_json's [`Number`](serde_json::Number). -pub fn from_int_or_hex<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - NumberOrHexU256::deserialize(deserializer)?.try_into_u256() -} - /// Helper type to deserialize sequence of numbers #[derive(Deserialize)] #[serde(untagged)] @@ -90,6 +68,16 @@ pub enum NumericSeq { Num(u64), } +/// Helper type to deserialize a single `u64` from either a direct value or a one-element sequence. +#[derive(Deserialize)] +#[serde(untagged)] +pub enum Numeric64ValueOrSeq { + /// Single parameter sequence (e.g `[1]`). + Seq([Numeric64; 1]), + /// Single value. + Value(Numeric64), +} + /// Deserializes a number from hex or int pub fn deserialize_number<'de, D>(deserializer: D) -> Result where @@ -126,6 +114,35 @@ where Ok(num) } +/// Deserializes single `u64` params: `1, [1], ["0x01"]`. +pub fn deserialize_u64_seq<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let num = match Numeric64ValueOrSeq::deserialize(deserializer)? { + Numeric64ValueOrSeq::Seq(seq) => seq[0].into(), + Numeric64ValueOrSeq::Value(num) => num.into(), + }; + + Ok(num) +} + +/// Deserializes an optional integer from a single-element params sequence. +/// Accepts `[]`, `[null]`, `[n]`, `["0x.."]`. +pub fn deserialize_u64_seq_opt<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let seq = Vec::>::deserialize(deserializer)?; + if seq.len() > 1 { + return Err(de::Error::custom(format!( + "expected params sequence with length 0 or 1 but got {}", + seq.len() + ))); + } + Ok(seq.into_iter().next().flatten().map(Into::into)) +} + pub mod duration { use serde::{Deserialize, Deserializer}; use std::time::Duration; @@ -147,3 +164,82 @@ pub mod duration { d.try_into().map_err(serde::de::Error::custom) } } + +#[cfg(test)] +mod tests { + use super::*; + use serde::de::IntoDeserializer; + use serde_json::json; + + fn parse_u64_param(value: serde_json::Value) -> Result { + deserialize_u64_seq(value.into_deserializer()) + } + + fn parse_optional_u64_param( + value: serde_json::Value, + ) -> Result, serde_json::Error> { + deserialize_u64_seq_opt(value.into_deserializer()) + } + + #[test] + fn deserialize_u64_seq_accepts_single_param_sequence_and_direct_value() { + let valid_values = [ + json!([100]), + json!(100), + json!(["0x64"]), + json!("0x64"), + json!(["100"]), + json!("100"), + ]; + for value in valid_values { + assert_eq!(parse_u64_param(value).unwrap(), 100); + } + + for value in [json!([u64::MAX]), json!(u64::MAX)] { + assert_eq!(parse_u64_param(value).unwrap(), u64::MAX); + } + } + + #[test] + fn deserialize_u64_seq_rejects_invalid_shape_and_overflow() { + for value in [ + json!([]), + json!([1, 2]), + json!([null]), + json!(null), + json!(["0x10000000000000000"]), + json!("0x10000000000000000"), + json!(["18446744073709551616"]), + json!("18446744073709551616"), + ] { + assert!(parse_u64_param(value).is_err()); + } + } + + #[test] + fn deserialize_u64_seq_opt_accepts_empty_null_and_single_param_sequence() { + for value in [json!([]), json!([null])] { + assert_eq!(parse_optional_u64_param(value).unwrap(), None); + } + + for value in [json!([100]), json!(["0x64"]), json!(["100"])] { + assert_eq!(parse_optional_u64_param(value).unwrap(), Some(100)); + } + + assert_eq!(parse_optional_u64_param(json!([u64::MAX])).unwrap(), Some(u64::MAX)); + } + + #[test] + fn deserialize_u64_seq_opt_rejects_invalid_shape_and_overflow() { + for value in [ + json!([1, 2]), + json!(100), + json!("0x64"), + json!([["0x64"]]), + json!(["0x10000000000000000"]), + json!(["18446744073709551616"]), + ] { + assert!(parse_optional_u64_param(value).is_err()); + } + } +} diff --git a/crates/common/src/slot_identifier.rs b/crates/common/src/slot_identifier.rs new file mode 100644 index 0000000000000..554b253bea8b2 --- /dev/null +++ b/crates/common/src/slot_identifier.rs @@ -0,0 +1,1035 @@ +//! Storage slot identification and decoding utilities for Solidity storage layouts. +//! +//! This module provides functionality to identify and decode storage slots based on +//! Solidity storage layout information from the compiler. + +use crate::mapping_slots::MappingSlots; +use alloy_dyn_abi::{DynSolType, DynSolValue}; +use alloy_primitives::{B256, U256, hex, keccak256, map::B256Map}; +use foundry_common_fmt::format_token_raw; +use foundry_compilers::artifacts::{Storage, StorageLayout, StorageType}; +use serde::Serialize; +use std::{collections::BTreeMap, str::FromStr, sync::Arc}; +use tracing::trace; + +/// "inplace" encoding type for variables that fit in one storage slot i.e 32 bytes +pub const ENCODING_INPLACE: &str = "inplace"; +/// "mapping" encoding type for Solidity mappings, which use keccak256 hash-based storage +pub const ENCODING_MAPPING: &str = "mapping"; +/// "bytes" encoding type for bytes and string types, which use either inplace or keccak256 +/// hash-based storage depending on length +pub const ENCODING_BYTES: &str = "bytes"; +/// "dynamic_array" encoding type for dynamic arrays, which uses keccak256 hash-based storage +pub const ENCODING_DYN_ARRAY: &str = "dynamic_array"; + +/// Information about a storage slot including its label, type, and decoded values. +#[derive(Clone, Serialize, Debug)] +pub struct SlotInfo { + /// The variable name from the storage layout. + /// + /// For top-level variables: just the variable name (e.g., "myVariable") + /// For struct members: dotted path (e.g., "myStruct.memberName") + /// For array elements: name with indices (e.g., "myArray\[0\]", "matrix\[1\]\[2\]") + /// For nested structures: full path (e.g., "outer.inner.field") + /// For mappings: base name with keys (e.g., "balances\[0x1234...\]")/ex + pub label: String, + /// The Solidity type information + #[serde(rename = "type", serialize_with = "serialize_slot_type")] + pub slot_type: StorageTypeInfo, + /// Offset within the storage slot (for packed storage) + pub offset: i64, + /// The storage slot number as a string + pub slot: String, + /// For struct members, contains nested SlotInfo for each member + /// + /// This is populated when a struct's members / fields are packed in a single slot. + #[serde(skip_serializing_if = "Option::is_none")] + pub members: Option>, + /// Decoded values (if available) - used for struct members + #[serde(skip_serializing_if = "Option::is_none")] + pub decoded: Option, + /// Decoded mapping keys (serialized as "key" for single, "keys" for multiple) + #[serde( + skip_serializing_if = "Option::is_none", + flatten, + serialize_with = "serialize_mapping_keys" + )] + pub keys: Option>, +} + +/// Wrapper type that holds both the original type label and the parsed DynSolType. +/// +/// We need both because: +/// - `label`: Used for serialization to ensure output matches user expectations +/// - `dyn_sol_type`: The parsed type used for actual value decoding +#[derive(Clone, Debug)] +pub struct StorageTypeInfo { + /// The original type label from storage layout (e.g., "uint256", "address", "mapping(address + /// => uint256)") + pub label: String, + /// The parsed dynamic Solidity type used for decoding + pub dyn_sol_type: DynSolType, +} + +impl SlotInfo { + /// Decodes a single storage value based on the slot's type information. + /// + /// Note: For decoding [`DynSolType::Bytes`] or [`DynSolType::String`] that span multiple slots, + /// use [`SlotInfo::decode_bytes_or_string`]. + pub fn decode(&self, value: B256) -> Option { + // Storage values are always 32 bytes, stored as a single word + let mut actual_type = &self.slot_type.dyn_sol_type; + // Unwrap nested arrays to get to the base element type. + while let DynSolType::FixedArray(elem_type, _) = actual_type { + actual_type = elem_type.as_ref(); + } + + // Special handling for bytes and string types + match actual_type { + DynSolType::Bytes | DynSolType::String => { + // Decode bytes/string from storage + // The last byte contains the length * 2 for short strings/bytes + // or length * 2 + 1 for long strings/bytes + let length_byte = value.0[31]; + + if length_byte & 1 == 0 { + // Short string/bytes (less than 32 bytes) + let length = ((length_byte >> 1) as usize).min(31); + // Extract data + let data = if length == 0 { Vec::new() } else { value.0[0..length].to_vec() }; + + // Create the appropriate value based on type + if matches!(actual_type, DynSolType::String) { + let str_val = if data.is_empty() { + String::new() + } else { + String::from_utf8(data).unwrap_or_default() + }; + Some(DynSolValue::String(str_val)) + } else { + Some(DynSolValue::Bytes(data)) + } + } else { + // Long string/bytes (32 bytes or more) + // The actual data is stored at keccak256(slot) + // Return None for long values - they need decode_bytes_or_string() + None + } + } + _ => { + // Decode based on the actual type + actual_type.abi_decode(&value.0).ok() + } + } + } + + /// Slot is of type [`DynSolType::Bytes`] or [`DynSolType::String`] + pub const fn is_bytes_or_string(&self) -> bool { + matches!(self.slot_type.dyn_sol_type, DynSolType::Bytes | DynSolType::String) + } + + /// Decodes a [`DynSolType::Bytes`] or [`DynSolType::String`] value + /// that spans across multiple slots. + pub fn decode_bytes_or_string( + &mut self, + base_slot: &B256, + storage_values: &B256Map, + ) -> Option { + // Only process bytes/string types + if !self.is_bytes_or_string() { + return None; + } + + // Try to handle as long bytes/string + self.aggregate_bytes_or_strings(base_slot, storage_values).map(|data| { + match self.slot_type.dyn_sol_type { + DynSolType::String => { + DynSolValue::String(String::from_utf8(data).unwrap_or_default()) + } + DynSolType::Bytes => DynSolValue::Bytes(data), + _ => unreachable!(), + } + }) + } + + /// Decodes both previous and new [`DynSolType::Bytes`] or [`DynSolType::String`] values + /// that span across multiple slots using state diff data. + /// + /// Accepts a mapping of storage_slot to (previous_value, new_value). + pub fn decode_bytes_or_string_values( + &mut self, + base_slot: &B256, + storage_accesses: &BTreeMap, + ) { + // Only process bytes/string types + if !self.is_bytes_or_string() { + return; + } + + // Get both previous and new values from the storage accesses + if let Some((prev_base_value, new_base_value)) = storage_accesses.get(base_slot) { + // Reusable closure to decode bytes/string based on length encoding + let mut decode_value = |base_value: B256, is_new: bool| { + let length_byte = base_value.0[31]; + if length_byte & 1 == 1 { + // Long bytes/string - aggregate from multiple slots + let value_map = storage_accesses + .iter() + .map(|(slot, (prev, new))| (*slot, if is_new { *new } else { *prev })) + .collect::>(); + self.decode_bytes_or_string(base_slot, &value_map) + } else { + // Short bytes/string - decode directly from base slot + self.decode(base_value) + } + }; + + // Decode previous value + let prev_decoded = decode_value(*prev_base_value, false); + + // Decode new value + let new_decoded = decode_value(*new_base_value, true); + + // Set decoded values if both were successfully decoded + if let (Some(prev), Some(new)) = (prev_decoded, new_decoded) { + self.decoded = Some(DecodedSlotValues { previous_value: prev, new_value: new }); + } + } + } + + /// Aggregates a [`DynSolType::Bytes`] or [`DynSolType::String`] value that spans across + /// multiple slots by looking up the length in the base_slot. + /// + /// Returns the aggregated raw bytes. + fn aggregate_bytes_or_strings( + &mut self, + base_slot: &B256, + storage_values: &B256Map, + ) -> Option> { + if !self.is_bytes_or_string() { + return None; + } + + // Check if it's a long bytes/string by looking at the base value + if let Some(base_value) = storage_values.get(base_slot) { + let length_byte = base_value.0[31]; + + // Check if value is long + if length_byte & 1 == 1 { + // Long bytes/string - populate members + let length: U256 = Into::::into(*base_value) >> 1; + let byte_len = length.try_into().unwrap_or(usize::MAX); + let num_slots = byte_len.div_ceil(32).min(256); + let data_start = Into::::into(keccak256(base_slot.0)); + + let mut members = Vec::new(); + let mut full_data = Vec::with_capacity(num_slots * 32); + + for i in 0..num_slots { + let data_slot = B256::from(data_start + U256::from(i)); + let data_slot_u256 = data_start + U256::from(i); + + // Create member info for this data slot with indexed label + let member_info = Self { + label: format!("{}[{}]", self.label, i), + slot_type: StorageTypeInfo { + label: self.slot_type.label.clone(), + dyn_sol_type: DynSolType::FixedBytes(32), + }, + offset: 0, + slot: data_slot_u256.to_string(), + members: None, + decoded: None, + keys: None, + }; + + if let Some(value) = storage_values.get(&data_slot) { + // Collect data + let bytes_to_take = + std::cmp::min(32, byte_len.saturating_sub(full_data.len())); + full_data.extend_from_slice(&value.0[..bytes_to_take]); + } + + members.push(member_info); + } + + // Set the members field + if !members.is_empty() { + self.members = Some(members); + } + + return Some(full_data); + } + } + + None + } + + /// Decodes storage values (previous and new) and populates the decoded field. + /// For structs with members, it decodes each member individually. + pub fn decode_values(&mut self, previous_value: B256, new_value: B256) { + // If this is a struct with members, decode each member individually + if let Some(members) = &mut self.members { + for member in members.iter_mut() { + let offset = member.offset as usize; + let size = match &member.slot_type.dyn_sol_type { + DynSolType::Uint(bits) | DynSolType::Int(bits) => bits / 8, + DynSolType::Address => 20, + DynSolType::Bool => 1, + DynSolType::FixedBytes(size) => *size, + _ => 32, // Default to full word + }; + + // Extract and decode member values + let mut prev_bytes = [0u8; 32]; + let mut new_bytes = [0u8; 32]; + + if offset + size <= 32 { + // In Solidity storage, values are right-aligned + // For offset 0, we want the rightmost bytes + // For offset 16 (for a uint128), we want bytes 0-16 + // For packed storage: offset 0 is at the rightmost position + // offset 0, size 16 -> read bytes 16-32 (rightmost) + // offset 16, size 16 -> read bytes 0-16 (leftmost) + let byte_start = 32 - offset - size; + prev_bytes[32 - size..] + .copy_from_slice(&previous_value.0[byte_start..byte_start + size]); + new_bytes[32 - size..] + .copy_from_slice(&new_value.0[byte_start..byte_start + size]); + } + + // Decode the member values + if let (Ok(prev_val), Ok(new_val)) = ( + member.slot_type.dyn_sol_type.abi_decode(&prev_bytes), + member.slot_type.dyn_sol_type.abi_decode(&new_bytes), + ) { + member.decoded = + Some(DecodedSlotValues { previous_value: prev_val, new_value: new_val }); + } + } + // For structs with members, we don't need a top-level decoded value + } else { + // For non-struct types, decode directly + // Note: decode() returns None for long bytes/strings, which will be handled by + // decode_bytes_or_string() + if let (Some(prev), Some(new)) = (self.decode(previous_value), self.decode(new_value)) { + self.decoded = Some(DecodedSlotValues { previous_value: prev, new_value: new }); + } + } + } +} + +/// Custom serializer for StorageTypeInfo that only outputs the label +fn serialize_slot_type(info: &StorageTypeInfo, serializer: S) -> Result +where + S: serde::Serializer, +{ + serializer.serialize_str(&info.label) +} + +/// Custom serializer for mapping keys +fn serialize_mapping_keys(keys: &Option>, serializer: S) -> Result +where + S: serde::Serializer, +{ + use serde::ser::SerializeMap; + + if let Some(keys) = keys { + let len = if keys.is_empty() { 0 } else { 1 }; + let mut map = serializer.serialize_map(Some(len))?; + if keys.len() == 1 { + map.serialize_entry("key", &keys[0])?; + } else if keys.len() > 1 { + map.serialize_entry("keys", keys)?; + } + map.end() + } else { + serializer.serialize_none() + } +} + +/// Decoded storage slot values +#[derive(Clone, Debug)] +pub struct DecodedSlotValues { + /// Initial decoded storage value + pub previous_value: DynSolValue, + /// Current decoded storage value + pub new_value: DynSolValue, +} + +impl Serialize for DecodedSlotValues { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + + let mut state = serializer.serialize_struct("DecodedSlotValues", 2)?; + state.serialize_field("previousValue", &format_token_raw(&self.previous_value))?; + state.serialize_field("newValue", &format_token_raw(&self.new_value))?; + state.end() + } +} + +/// Storage slot identifier that uses Solidity [`StorageLayout`] to identify storage slots. +#[derive(Clone)] +pub struct SlotIdentifier { + storage_layout: Arc, + parsed_types: BTreeMap>, +} + +impl SlotIdentifier { + /// Creates a new SlotIdentifier with the given storage layout. + pub fn new(storage_layout: Arc) -> Self { + let parsed_types = storage_layout + .types + .iter() + .map(|(id, storage_type)| (id.clone(), parse_sol_type(&storage_type.label))) + .collect(); + Self { storage_layout, parsed_types } + } + + fn parsed_type(&self, storage_type: &str) -> Option<&DynSolType> { + self.parsed_types.get(storage_type).and_then(Option::as_ref) + } + + /// Identifies a storage slots type using the [`StorageLayout`]. + /// + /// It can also identify whether a slot belongs to a mapping if provided with [`MappingSlots`]. + pub fn identify(&self, slot: &B256, mapping_slots: Option<&MappingSlots>) -> Option { + trace!(?slot, "identifying slot"); + let slot_u256 = Into::::into(*slot); + let slot_str = slot_u256.to_string(); + + for storage in &self.storage_layout.storage { + let storage_type = self.storage_layout.types.get(&storage.storage_type)?; + let dyn_type = self.parsed_type(&storage.storage_type); + + // Check if we're able to match on a slot from the layout i.e any of the base slots. + // This will always be the case for primitive types that fit in a single slot. + if storage.slot == slot_str + && let Some(parsed_type) = dyn_type.cloned() + { + // Successfully parsed - handle arrays or simple types + let label = if let DynSolType::FixedArray(_, _) = &parsed_type { + format!("{}{}", storage.label, get_array_base_indices(&parsed_type)) + } else { + storage.label.clone() + }; + + return Some(SlotInfo { + label, + slot_type: StorageTypeInfo { + label: storage_type.label.clone(), + dyn_sol_type: parsed_type, + }, + offset: storage.offset, + slot: storage.slot.clone(), + members: None, + decoded: None, + keys: None, + }); + } + + // Encoding types: + if storage_type.encoding == ENCODING_INPLACE { + // Can be of type FixedArrays or Structs + // Handles the case where the accessed `slot` is maybe different from the base slot. + let array_start_slot = U256::from_str(&storage.slot).ok()?; + + if let Some(parsed_type) = dyn_type + && let DynSolType::FixedArray(_, _) = parsed_type + && let Some(slot_info) = self.handle_array_slot( + storage, + storage_type, + parsed_type, + slot_u256, + array_start_slot, + &slot_str, + ) + { + return Some(slot_info); + } + + // If type parsing fails and the label is a struct + if is_struct(&storage_type.label) { + let struct_start_slot = U256::from_str(&storage.slot).ok()?; + if let Some(slot_info) = self.handle_struct( + &storage.label, + storage_type, + slot_u256, + struct_start_slot, + storage.offset, + &slot_str, + 0, + ) { + return Some(slot_info); + } + } + } else if storage_type.encoding == ENCODING_MAPPING + && let Some(mapping_slots) = mapping_slots + && let Some(info) = + self.handle_mapping(storage, storage_type, slot, &slot_str, mapping_slots) + { + return Some(info); + } + } + + None + } + + /// Identifies a bytes or string storage slot by checking all bytes/string variables + /// in the storage layout and using their base slot values from the provided storage changes. + /// + /// # Arguments + /// * `slot` - The slot being identified + /// * `storage_values` - Map of storage slots to their current values + pub fn identify_bytes_or_string( + &self, + slot: &B256, + storage_values: &B256Map, + ) -> Option { + let slot_u256 = Into::::into(*slot); + let slot_str = slot_u256.to_string(); + + // Search through all bytes/string variables in the storage layout + for storage in &self.storage_layout.storage { + if let Some(storage_type) = self.storage_layout.types.get(&storage.storage_type) + && storage_type.encoding == ENCODING_BYTES + { + let Some(base_slot) = U256::from_str(&storage.slot).map(B256::from).ok() else { + continue; + }; + // Get the base slot value from storage_values + if let Some(base_value) = storage_values.get(&base_slot) + && let Some(info) = self.handle_bytes_string( + storage, + storage_type, + slot_u256, + &slot_str, + base_value, + ) + { + return Some(info); + } + } + } + + None + } + + /// Handles identification of array slots. + /// + /// # Arguments + /// * `storage` - The storage metadata from the layout + /// * `storage_type` - Type information for the storage slot + /// * `slot` - The target slot being identified + /// * `array_start_slot` - The starting slot of the array in storage i.e base_slot + /// * `slot_str` - String representation of the slot for output + fn handle_array_slot( + &self, + storage: &Storage, + storage_type: &StorageType, + parsed_type: &DynSolType, + slot: U256, + array_start_slot: U256, + slot_str: &str, + ) -> Option { + // Check if slot is within array bounds + let total_bytes = storage_type.number_of_bytes.parse::().ok()?; + let total_slots = total_bytes.div_ceil(32); + + if slot >= array_start_slot && slot < array_start_slot + U256::from(total_slots) { + let index = (slot - array_start_slot).to::(); + // Format the array element label based on array dimensions + let label = match parsed_type { + DynSolType::FixedArray(inner, _) => { + if let DynSolType::FixedArray(_, inner_size) = inner.as_ref() { + // 2D array: calculate row and column + let row = index / (*inner_size as u64); + let col = index % (*inner_size as u64); + format!("{}[{row}][{col}]", storage.label) + } else { + // 1D array + format!("{}[{index}]", storage.label) + } + } + _ => storage.label.clone(), + }; + + return Some(SlotInfo { + label, + slot_type: StorageTypeInfo { + label: storage_type.label.clone(), + dyn_sol_type: parsed_type.clone(), + }, + offset: 0, + slot: slot_str.to_string(), + members: None, + decoded: None, + keys: None, + }); + } + + None + } + + /// Handles identification of struct slots. + /// + /// Recursively resolves struct members to find the exact member corresponding + /// to the target slot. Handles both single-slot (packed) and multi-slot structs. + /// + /// # Arguments + /// * `base_label` - The label/name for this struct or member + /// * `storage_type` - Type information for the storage + /// * `target_slot` - The target slot being identified + /// * `struct_start_slot` - The starting slot of this struct + /// * `offset` - Offset within the slot (for packed storage) + /// * `slot_str` - String representation of the slot for output + /// * `depth` - Current recursion depth + #[allow(clippy::too_many_arguments)] + fn handle_struct( + &self, + base_label: &str, + storage_type: &StorageType, + target_slot: U256, + struct_start_slot: U256, + offset: i64, + slot_str: &str, + depth: usize, + ) -> Option { + // Limit recursion depth to prevent stack overflow + const MAX_DEPTH: usize = 10; + if depth > MAX_DEPTH { + return None; + } + + let members = storage_type + .other + .get("members") + .and_then(|v| serde_json::from_value::>(v.clone()).ok())?; + + // If this is the exact slot we're looking for (struct's base slot) + if struct_start_slot == target_slot + // Find the member at slot offset 0 (the member that starts at this slot) + && let Some(first_member) = members.iter().find(|m| m.slot == "0") + { + let member_type_info = self.storage_layout.types.get(&first_member.storage_type)?; + + // Check if we have a single-slot struct (all members have slot "0") + let is_single_slot = members.iter().all(|m| m.slot == "0"); + + if is_single_slot { + // Build member info for single-slot struct + let mut member_infos = Vec::new(); + for member in &members { + if let Some(member_type_info) = + self.storage_layout.types.get(&member.storage_type) + && let Some(member_type) = self.parsed_type(&member.storage_type).cloned() + { + member_infos.push(SlotInfo { + label: member.label.clone(), + slot_type: StorageTypeInfo { + label: member_type_info.label.clone(), + dyn_sol_type: member_type, + }, + offset: member.offset, + slot: slot_str.to_string(), + members: None, + decoded: None, + keys: None, + }); + } + } + + // Build the CustomStruct type + let struct_name = + storage_type.label.strip_prefix("struct ").unwrap_or(&storage_type.label); + let prop_names: Vec = members.iter().map(|m| m.label.clone()).collect(); + let member_types: Vec = + member_infos.iter().map(|info| info.slot_type.dyn_sol_type.clone()).collect(); + + let parsed_type = DynSolType::CustomStruct { + name: struct_name.to_string(), + prop_names, + tuple: member_types, + }; + + return Some(SlotInfo { + label: base_label.to_string(), + slot_type: StorageTypeInfo { + label: storage_type.label.clone(), + dyn_sol_type: parsed_type, + }, + offset, + slot: slot_str.to_string(), + decoded: None, + members: if member_infos.is_empty() { None } else { Some(member_infos) }, + keys: None, + }); + } + + // Multi-slot struct - return the first member. + let member_label = format!("{}.{}", base_label, first_member.label); + + // If the first member is itself a struct, recurse + if is_struct(&member_type_info.label) { + return self.handle_struct( + &member_label, + member_type_info, + target_slot, + struct_start_slot, + first_member.offset, + slot_str, + depth + 1, + ); + } + + // Return the first member as a primitive + return Some(SlotInfo { + label: member_label, + slot_type: StorageTypeInfo { + label: member_type_info.label.clone(), + dyn_sol_type: self.parsed_type(&first_member.storage_type)?.clone(), + }, + offset: first_member.offset, + slot: slot_str.to_string(), + decoded: None, + members: None, + keys: None, + }); + } + + // Not the base slot - search through members + for member in &members { + let member_slot_offset = U256::from_str(&member.slot).ok()?; + let member_slot = struct_start_slot + member_slot_offset; + let member_type_info = self.storage_layout.types.get(&member.storage_type)?; + let member_label = format!("{}.{}", base_label, member.label); + + // If this member is a struct, recurse into it + if is_struct(&member_type_info.label) { + let slot_info = self.handle_struct( + &member_label, + member_type_info, + target_slot, + member_slot, + member.offset, + slot_str, + depth + 1, + ); + + if member_slot == target_slot || slot_info.is_some() { + return slot_info; + } + } + + if member_slot == target_slot { + // Found the exact member slot + + // Regular member + let member_type = self.parsed_type(&member.storage_type)?.clone(); + return Some(SlotInfo { + label: member_label, + slot_type: StorageTypeInfo { + label: member_type_info.label.clone(), + dyn_sol_type: member_type, + }, + offset: member.offset, + slot: slot_str.to_string(), + members: None, + decoded: None, + keys: None, + }); + } + } + + None + } + + /// Handles identification of mapping slots. + /// + /// Identifies mapping entries by walking up the parent chain to find the base slot, + /// then decodes the keys and builds the appropriate label. + /// + /// # Arguments + /// * `storage` - The storage metadata from the layout + /// * `storage_type` - Type information for the storage + /// * `slot` - The accessed slot being identified + /// * `slot_str` - String representation of the slot for output + /// * `mapping_slots` - Tracked mapping slot accesses for key resolution + fn handle_mapping( + &self, + storage: &Storage, + storage_type: &StorageType, + slot: &B256, + slot_str: &str, + mapping_slots: &MappingSlots, + ) -> Option { + trace!( + "handle_mapping: storage.slot={}, slot={:?}, has_keys={}, has_parents={}", + storage.slot, + slot, + mapping_slots.keys.contains_key(slot), + mapping_slots.parent_slots.contains_key(slot) + ); + + // Verify it's actually a mapping type + if storage_type.encoding != ENCODING_MAPPING { + return None; + } + + // Check if this slot is a known mapping entry + if !mapping_slots.keys.contains_key(slot) { + return None; + } + + // Convert storage.slot to B256 for comparison + let storage_slot_b256 = B256::from(U256::from_str(&storage.slot).ok()?); + + // Walk up the parent chain to collect keys and validate the base slot + let mut current_slot = *slot; + let mut keys_to_decode = Vec::new(); + let mut found_base = false; + + while let Some((key, parent)) = + mapping_slots.keys.get(¤t_slot).zip(mapping_slots.parent_slots.get(¤t_slot)) + { + keys_to_decode.push(*key); + + // Check if the parent is our base storage slot + if *parent == storage_slot_b256 { + found_base = true; + break; + } + + // Move up to the parent for the next iteration + current_slot = *parent; + } + + if !found_base { + trace!("Mapping slot {} does not match any parent in chain", storage.slot); + return None; + } + + // Resolve the mapping type to get all key types and the final value type + let (key_types, value_type_label, full_type_label) = + self.resolve_mapping_type(&storage.storage_type)?; + + // Reverse keys to process from outermost to innermost + keys_to_decode.reverse(); + + // Build the label with decoded keys and collect decoded key values + let mut label = storage.label.clone(); + let mut decoded_keys = Vec::new(); + + // Decode each key using the corresponding type + for (i, key) in keys_to_decode.iter().enumerate() { + if let Some(key_type_label) = key_types.get(i) + && let Some(sol_type) = parse_sol_type(key_type_label) + && let Ok(decoded) = sol_type.abi_decode(&key.0) + { + let decoded_key_str = format_token_raw(&decoded); + decoded_keys.push(decoded_key_str.clone()); + label = format!("{label}[{decoded_key_str}]"); + } else { + let hex_key = hex::encode_prefixed(key.0); + decoded_keys.push(hex_key.clone()); + label = format!("{label}[{hex_key}]"); + } + } + + // Parse the final value type for decoding. + // Contract types (e.g., "contract IPool") are addresses under the hood. + let dyn_sol_type = parse_sol_type(&value_type_label).unwrap_or(DynSolType::Bytes); + + Some(SlotInfo { + label, + slot_type: StorageTypeInfo { label: full_type_label, dyn_sol_type }, + offset: storage.offset, + slot: slot_str.to_string(), + members: None, + decoded: None, + keys: Some(decoded_keys), + }) + } + + /// Handles identification of bytes/string storage slots. + /// + /// Bytes and strings in Solidity use a special storage layout: + /// - Short values (<32 bytes): stored in the same slot with length * 2 + /// - Long values (>=32 bytes): length * 2 + 1 in main slot, data at keccak256(slot) + /// + /// This function checks if the given slot is: + /// 1. A main slot for a bytes/string variable + /// 2. A data slot for any long bytes/string variable in the storage layout + /// + /// # Arguments + /// * `slot` - The accessed slot being identified + /// * `slot_str` - String representation of the slot for output + /// * `base_slot_value` - The value at the base slot (used to determine length for long + /// bytes/strings) + fn handle_bytes_string( + &self, + storage: &Storage, + storage_type: &StorageType, + slot: U256, + slot_str: &str, + base_slot_value: &B256, + ) -> Option { + // Only handle bytes/string encoded variables for this specific storage entry + if storage_type.encoding != ENCODING_BYTES { + return None; + } + + // Check if this is the main slot for this variable + let base_slot = U256::from_str(&storage.slot).ok()?; + if slot == base_slot { + // Parse the type to get the correct DynSolType + let dyn_type = if storage_type.label == "string" { + DynSolType::String + } else if storage_type.label == "bytes" { + DynSolType::Bytes + } else { + return None; + }; + + return Some(SlotInfo { + label: storage.label.clone(), + slot_type: StorageTypeInfo { + label: storage_type.label.clone(), + dyn_sol_type: dyn_type, + }, + offset: storage.offset, + slot: slot_str.to_string(), + members: None, + decoded: None, + keys: None, + }); + } + + // Check if it could be a data slot for this long bytes/string + // Calculate where data slots would start for this variable + let data_start = + Into::::into(alloy_primitives::keccak256(base_slot.to_be_bytes::<32>())); + + // Get the length from the base slot value to calculate exact number of slots + // For long bytes/strings, the length is stored as (length * 2 + 1) in the base slot + let length_byte = base_slot_value.0[31]; + if length_byte & 1 == 1 { + // It's a long bytes/string + let length = Into::::into(*base_slot_value) >> 1; + // Calculate number of slots needed (round up) + let num_slots = (length + U256::from(31)) / U256::from(32); + + // Check if our slot is within the data region + if slot >= data_start && slot < data_start + num_slots { + let slot_index = (slot - data_start).try_into().unwrap_or(usize::MAX); + + return Some(SlotInfo { + label: format!("{}[{}]", storage.label, slot_index), + slot_type: StorageTypeInfo { + label: storage_type.label.clone(), + // Type is assigned as FixedBytes(32) for data slots + dyn_sol_type: DynSolType::FixedBytes(32), + }, + offset: 0, + slot: slot_str.to_string(), + members: None, + decoded: None, + keys: None, + }); + } + } + + None + } + + fn resolve_mapping_type(&self, type_ref: &str) -> Option<(Vec, String, String)> { + let storage_type = self.storage_layout.types.get(type_ref)?; + + if storage_type.encoding != ENCODING_MAPPING { + // Not a mapping, return the type as-is + return Some((vec![], storage_type.label.clone(), storage_type.label.clone())); + } + + // Get key and value type references + let key_type_ref = storage_type.key.as_ref()?; + let value_type_ref = storage_type.value.as_ref()?; + + // Resolve the key type + let key_type = self.storage_layout.types.get(key_type_ref)?; + let mut key_types = vec![key_type.label.clone()]; + + // Check if the value is another mapping (nested case) + if let Some(value_storage_type) = self.storage_layout.types.get(value_type_ref) { + if value_storage_type.encoding == ENCODING_MAPPING { + // Recursively resolve the nested mapping + let (nested_keys, final_value, _) = self.resolve_mapping_type(value_type_ref)?; + key_types.extend(nested_keys); + return Some((key_types, final_value, storage_type.label.clone())); + } + // Value is not a mapping, we're done + return Some((key_types, value_storage_type.label.clone(), storage_type.label.clone())); + } + + None + } +} + +/// Returns the base indices for array types, e.g. "\[0\]\[0\]" for 2D arrays. +fn get_array_base_indices(dyn_type: &DynSolType) -> String { + match dyn_type { + DynSolType::FixedArray(inner, _) => { + if let DynSolType::FixedArray(_, _) = inner.as_ref() { + // Nested array (2D or higher) + format!("[0]{}", get_array_base_indices(inner)) + } else { + // Simple 1D array + "[0]".to_string() + } + } + _ => String::new(), + } +} + +/// Parses a storage type label into a [`DynSolType`], returning `None` for labels that have no +/// direct ABI equivalent (mappings, structs). +/// +/// Handles `contract X` types (which are addresses under the hood) and `enum X` types +/// (which are uint8) that `DynSolType::parse` doesn't recognize. +fn parse_sol_type(label: &str) -> Option { + let scalar = if label.starts_with("contract ") { + "address" + } else if label.starts_with("enum ") { + "uint8" + } else { + return DynSolType::parse(label).ok(); + }; + let suffix = label.find('[').map_or("", |index| &label[index..]); + DynSolType::parse(&format!("{scalar}{suffix}")).ok() +} + +/// Checks if a given type label represents a struct type. +pub fn is_struct(s: &str) -> bool { + s.starts_with("struct ") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preserves_contract_and_enum_array_dimensions() { + for (label, expected) in [ + ("contract IERC20", "address"), + ("contract IERC20[]", "address[]"), + ("contract IERC20[4][]", "address[4][]"), + ("enum Example.Status", "uint8"), + ("enum Example.Status[4]", "uint8[4]"), + ("enum Example.Status[][4]", "uint8[][4]"), + ] { + assert_eq!(parse_sol_type(label), DynSolType::parse(expected).ok(), "{label}"); + } + } +} diff --git a/crates/common/src/tempo/auth.rs b/crates/common/src/tempo/auth.rs new file mode 100644 index 0000000000000..d15a25f89bdb7 --- /dev/null +++ b/crates/common/src/tempo/auth.rs @@ -0,0 +1,625 @@ +//! Tempo wallet device-code authorization flow. +//! +//! Implements the CLI side of the tempoxyz/accounts `cli-auth` device-code +//! protocol: generates a local secp256k1 access key, creates a PKCE-protected +//! device code, opens `wallet.tempo.xyz/cli-auth?code=` in the browser, +//! polls until the user authorizes the key on their passkey wallet, and writes +//! the resulting access key to the Tempo Accounts `store.json`. + +use crate::tempo::decode_key_authorization; +use alloy_primitives::{Address, B256, hex}; +use alloy_signer_local::PrivateKeySigner; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use eyre::Result; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + env, + sync::LazyLock, + time::{Duration, Instant}, +}; +use tempo_alloy::accounts::{TempoAccountsKeyAuthorization, TempoAccountsStore}; +use tempo_primitives::transaction::{SignatureType, SignedKeyAuthorization}; +use tokio::sync::Mutex; + +#[cfg(any(unix, windows))] +use std::process::Command; + +/// Default device-code service URL (production wallet.tempo.xyz). +const DEFAULT_CLI_AUTH_URL: &str = "https://wallet.tempo.xyz/cli-auth"; + +/// Env var to override the device-code service URL (for tests / staging). +const TEMPO_CLI_AUTH_URL_ENV: &str = "TEMPO_CLI_AUTH_URL"; + +const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(2); +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(300); + +/// Per-process serialization of concurrent `ensure_access_key` calls. +/// +/// Prevents two `cast` invocations in the same process from racing two browser +/// popups for the same chain. +static AUTH_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); + +/// Configuration for [`ensure_access_key`]. +#[derive(Clone, Debug)] +pub struct EnsureAccessKeyConfig { + /// Chain ID the access key is being authorized for. + pub chain_id: u64, + /// Device-code service base URL. Defaults to [`DEFAULT_CLI_AUTH_URL`]. + pub(crate) service_url: String, + /// Poll interval. + pub(crate) poll_interval: Duration, + /// Total timeout for the authorization flow. + pub(crate) timeout: Duration, + /// If `true`, print the authorization URL to stderr instead of opening a + /// browser. + pub no_browser: bool, +} + +impl EnsureAccessKeyConfig { + /// Build a config from the environment for the given chain. + /// + /// `no_browser` defaults to `true` under `CI`; callers (e.g. `cast tempo + /// login --no-browser`) may override it. + pub fn from_env(chain_id: u64) -> Self { + Self { + chain_id, + service_url: env::var(TEMPO_CLI_AUTH_URL_ENV) + .unwrap_or_else(|_| DEFAULT_CLI_AUTH_URL.to_string()), + poll_interval: DEFAULT_POLL_INTERVAL, + timeout: DEFAULT_TIMEOUT, + no_browser: env::var_os("CI").is_some(), + } + } +} + +/// Open `url` via the OS default browser handler. On platforms without a known +/// opener, this is a no-op (the URL is still printed by [`ensure_access_key`]). +fn open_browser(_url: &str) { + #[cfg(target_os = "macos")] + let _ = Command::new("open").arg(_url).spawn(); + #[cfg(target_os = "windows")] + let _ = Command::new("cmd").args(["/c", "start", "", _url]).spawn(); + #[cfg(all(unix, not(target_os = "macos")))] + let _ = Command::new("xdg-open").arg(_url).spawn(); +} + +/// Result of [`ensure_access_key`]. +#[derive(Debug, Clone)] +pub struct AccessKeyOutcome { + pub wallet_address: Address, + pub key_address: Address, + pub chain_id: u64, +} + +/// Run the device-code flow, persist the resulting key to `store.json`, and +/// return the new entry's identifying fields. +pub async fn ensure_access_key(cfg: EnsureAccessKeyConfig) -> Result { + let _guard = AUTH_LOCK.lock().await; + + let signer = PrivateKeySigner::random(); + let key_address = signer.address(); + // The server requires uncompressed SEC1 (65-byte `0x04 || X || Y`); the + // default `to_sec1_bytes()` would emit the compressed 33-byte form. + let pub_key_hex = hex::encode_prefixed( + signer.credential().verifying_key().to_encoded_point(false).as_bytes(), + ); + + let code_verifier = random_code_verifier(); + let client = reqwest::Client::builder().timeout(Duration::from_secs(30)).build()?; + let service = cfg.service_url.trim_end_matches('/'); + + let create_req = CreateCodeRequest { + chain_id: cfg.chain_id, + code_challenge: sha256_b64url(&code_verifier), + key_type: "secp256k1", + pub_key: pub_key_hex, + }; + let code = create_code_with_retry(&client, service, &create_req, cfg.timeout).await?; + + let browser_url = format!("{service}?code={code}"); + if cfg.no_browser { + let _ = crate::sh_eprintln!("Open this URL to authorize: {browser_url}"); + } else { + let _ = crate::sh_eprintln!( + "Opening wallet.tempo to authorize an access key…\n {browser_url}" + ); + open_browser(&browser_url); + } + + let poll = PollRequest { code_verifier }; + let started = Instant::now(); + loop { + // Retry transient network/5xx/429 failures within `cfg.timeout`. + let send_res = client.post(format!("{service}/poll/{code}")).json(&poll).send().await; + + let resp = match send_res { + Ok(r) => r, + Err(e) if is_transient_error(&e) && started.elapsed() < cfg.timeout => { + tracing::debug!(error = %e, "transient error polling device code, retrying"); + tokio::time::sleep(cfg.poll_interval).await; + continue; + } + Err(e) => return Err(e.into()), + }; + + let status = resp.status(); + if !status.is_success() { + if is_transient_status(status) && started.elapsed() < cfg.timeout { + tracing::debug!(%status, "transient HTTP status polling device code, retrying"); + tokio::time::sleep(cfg.poll_interval).await; + continue; + } + let body = resp.text().await.unwrap_or_default(); + eyre::bail!("device-code poll failed ({status}): {body}"); + } + + let body: PollResponse = resp.json().await?; + match body { + PollResponse::Pending => { + if started.elapsed() > cfg.timeout { + eyre::bail!("timed out waiting for wallet authorization (code {code})"); + } + tokio::time::sleep(cfg.poll_interval).await; + } + PollResponse::Expired => { + eyre::bail!("device code {code} expired before authorization"); + } + PollResponse::Authorized { account_address, key_authorization } => { + let key_authorization = key_authorization.ok_or_else(|| { + eyre::eyre!("wallet authorized response missing key_authorization") + })?; + let signed = key_authorization.into_signed()?; + // Reject mismatches before persisting — an unusable store + // entry would silently break the next 402 retry. + if signed.authorization.key_id != key_address { + eyre::bail!( + "wallet authorized key {} but the locally generated key is {}", + signed.authorization.key_id, + key_address, + ); + } + if signed.authorization.chain_id != cfg.chain_id { + eyre::bail!( + "wallet authorized chain {} but {} was requested", + signed.authorization.chain_id, + cfg.chain_id, + ); + } + if signed.authorization.key_type != SignatureType::Secp256k1 { + eyre::bail!( + "wallet returned keyType {:?} but secp256k1 was requested", + signed.authorization.key_type, + ); + } + // A 402 access key is a limited key; an admin key is never valid here. + if signed.authorization.is_admin() { + eyre::bail!( + "wallet returned an admin key authorization, expected a limited access key" + ); + } + // A T6 account-bound authorization must target the authorizing account. + if let Some(account) = signed.authorization.account + && account != account_address + { + eyre::bail!( + "wallet authorized account {account} but the authorizing account is {account_address}", + ); + } + let chain_id = signed.authorization.chain_id; + TempoAccountsStore::default_path()?.upsert_secp256k1_access_key( + account_address, + &signer, + &signed, + )?; + return Ok(AccessKeyOutcome { + wallet_address: account_address, + key_address, + chain_id, + }); + } + } + } +} + +fn is_transient_error(err: &reqwest::Error) -> bool { + err.is_timeout() || err.is_connect() || err.is_request() +} + +fn is_transient_status(status: reqwest::StatusCode) -> bool { + status.is_server_error() || status == reqwest::StatusCode::TOO_MANY_REQUESTS +} + +/// POST `/code` with exponential backoff on transient errors, bounded by `timeout`. +async fn create_code_with_retry( + client: &reqwest::Client, + service: &str, + req: &CreateCodeRequest, + timeout: Duration, +) -> Result { + let started = Instant::now(); + let mut backoff = Duration::from_millis(500); + loop { + let send_res = client.post(format!("{service}/code")).json(req).send().await; + + match send_res { + Ok(resp) => { + let status = resp.status(); + if status.is_success() { + let CreateCodeResponse { code } = resp.json().await?; + return Ok(code); + } + if is_transient_status(status) && started.elapsed() < timeout { + tracing::debug!(%status, "transient HTTP status creating device code, retrying"); + tokio::time::sleep(backoff).await; + backoff = (backoff * 2).min(Duration::from_secs(5)); + continue; + } + let body = resp.text().await.unwrap_or_default(); + eyre::bail!("device-code create failed ({status}): {body}"); + } + Err(e) if is_transient_error(&e) && started.elapsed() < timeout => { + tracing::debug!(error = %e, "transient error creating device code, retrying"); + tokio::time::sleep(backoff).await; + backoff = (backoff * 2).min(Duration::from_secs(5)); + } + Err(e) => return Err(e.into()), + } + } +} + +fn random_code_verifier() -> String { + let bytes = B256::random(); + URL_SAFE_NO_PAD.encode(bytes.as_slice()) +} + +fn sha256_b64url(input: &str) -> String { + let digest = Sha256::digest(input.as_bytes()); + URL_SAFE_NO_PAD.encode(digest) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct CreateCodeRequest { + /// `0x`-hex per the SDK schema (server accepts hex string or bigint, not a plain JSON number). + #[serde(serialize_with = "serialize_u64_hex")] + chain_id: u64, + code_challenge: String, + key_type: &'static str, + pub_key: String, +} + +fn serialize_u64_hex(v: &u64, s: S) -> std::result::Result { + s.serialize_str(&format!("0x{v:x}")) +} + +#[derive(Deserialize)] +struct CreateCodeResponse { + code: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct PollRequest { + code_verifier: String, +} + +/// Matches `tempoxyz/wallet` poll response shape. +#[derive(Deserialize)] +#[serde(tag = "status", rename_all = "lowercase")] +enum PollResponse { + Pending, + Expired, + Authorized { + #[serde(rename = "accountAddress", alias = "account_address")] + account_address: Address, + #[serde(rename = "keyAuthorization", alias = "key_authorization", default)] + key_authorization: Option, + }, +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum PollKeyAuthorization { + Accounts(Box), + Legacy(String), +} + +impl PollKeyAuthorization { + fn into_signed(self) -> Result { + match self { + Self::Accounts(authorization) => Ok(authorization.into_signed()), + Self::Legacy(encoded) => decode_key_authorization(&encoded), + } + } +} + +/// Returns `true` if `url`'s host is `tempo.xyz` or a subdomain of it. +pub(crate) fn is_known_tempo_endpoint(url: &url::Url) -> bool { + url.host_str().is_some_and(|host| host == "tempo.xyz" || host.ends_with(".tempo.xyz")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tempo::{TEMPO_HOME_ENV, read_tempo_accounts_store, test_env_mutex}; + use alloy_primitives::address; + use axum::{Json, Router, extract::State, routing::post}; + use std::sync::{Arc, Mutex}; + + #[test] + fn pkce_challenge_matches_sdk_format() { + // Vector from RFC 7636 §4.2. + let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + let challenge = sha256_b64url(verifier); + assert_eq!(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"); + } + + /// Recover the EOA from a SEC1-encoded public key (compressed or + /// uncompressed). + fn address_from_sec1_hex(s: &str) -> Address { + let stripped = s.strip_prefix("0x").unwrap_or(s); + let bytes = hex::decode(stripped).expect("valid hex"); + let vk = k256::ecdsa::VerifyingKey::from_sec1_bytes(&bytes).expect("valid SEC1 pubkey"); + Address::from_public_key(&vk) + } + + /// Shape of the `keyAuthorization` the mock `/poll` returns. + #[derive(Clone, Copy, Default)] + struct MockAuthShape { + /// Return a T6 admin key authorization. + admin: bool, + /// Bind the authorization to this account. + account: Option

, + } + + #[derive(Clone)] + struct MockState { + wallet: Arc>>, + /// Derived from the `pubKey` posted to `/code` so `/poll` can echo + /// back a matching `keyId`, like a real wallet would. + key_id: Arc>>, + /// Chain ID the mock `/poll` returns in `keyAuthorization`. + poll_chain_id: u64, + /// Shape of the returned authorization, for exercising rejection paths. + shape: MockAuthShape, + /// Emit the current Tempo Accounts SDK response instead of the legacy + /// RLP compatibility response. + current_wire: bool, + } + + async fn create_code_handler( + State(state): State, + Json(body): Json, + ) -> Json { + // Sanity: required fields present and chainId is a 0x-hex string, + // matching the SDK wire format the live server enforces. + let pub_key = body + .get("pubKey") + .and_then(|v| v.as_str()) + .unwrap_or_else(|| panic!("pubKey missing: {body}")); + assert!(body.get("codeChallenge").is_some(), "codeChallenge missing: {body}"); + let chain_id = body.get("chainId").unwrap_or_else(|| panic!("chainId missing: {body}")); + let chain_str = chain_id + .as_str() + .unwrap_or_else(|| panic!("chainId must be string, got {chain_id}: {body}")); + assert!(chain_str.starts_with("0x"), "chainId must be 0x-hex, got {chain_str}"); + let wallet = Address::with_last_byte(0x42); + *state.wallet.lock().unwrap() = Some(wallet); + *state.key_id.lock().unwrap() = Some(address_from_sec1_hex(pub_key)); + Json(serde_json::json!({ "code": "ABCDEFGH" })) + } + + /// Build the RLP-hex `SignedKeyAuthorization` blob the live server returns + /// in the `key_authorization` field. + fn signed_key_auth_hex( + chain_id: u64, + key_id: Address, + expiry: u64, + shape: MockAuthShape, + ) -> String { + use alloy_rlp::Encodable; + use tempo_primitives::transaction::{KeyAuthorization, PrimitiveSignature}; + let mut auth = KeyAuthorization::unrestricted(chain_id, SignatureType::Secp256k1, key_id); + if shape.admin { + // An admin authorization carries no expiry; bind it to its account (or zero, which the + // `is_admin` check rejects before the account is even inspected). + auth = auth.into_admin(shape.account.unwrap_or(Address::ZERO)); + } else { + auth = auth.with_expiry(expiry); + if let Some(account) = shape.account { + auth = auth.with_account(account); + } + } + let sig: PrimitiveSignature = serde_json::from_value(serde_json::json!({ + "type": "secp256k1", "r": "0x0", "s": "0x0", "yParity": 0 + })) + .unwrap(); + let signed = auth.into_signed(sig); + let mut buf = Vec::new(); + signed.encode(&mut buf); + hex::encode_prefixed(buf) + } + + async fn poll_handler(State(state): State) -> Json { + let wallet = state.wallet.lock().unwrap().expect("create_code must be called first"); + let key_id = state.key_id.lock().unwrap().expect("create_code must be called first"); + if state.current_wire { + Json(serde_json::json!({ + "status": "authorized", + "accountAddress": wallet, + "keyAuthorization": { + "address": key_id, + "chainId": state.poll_chain_id, + "expiry": 9_999_999_999u64, + "keyId": key_id, + "keyType": "secp256k1", + "limits": [], + "signature": { + "type": "secp256k1", + "r": "0x0", + "s": "0x0", + "yParity": 0, + }, + }, + })) + } else { + Json(serde_json::json!({ + "status": "authorized", + "account_address": wallet, + "key_authorization": + signed_key_auth_hex(state.poll_chain_id, key_id, 9_999_999_999, state.shape), + })) + } + } + + /// Spawn a mock wallet.tempo server whose `/poll` echoes `poll_chain_id`. + async fn spawn_mock_wallet(poll_chain_id: u64) -> (String, tokio::task::JoinHandle<()>) { + spawn_mock_wallet_inner(poll_chain_id, MockAuthShape::default(), true).await + } + + /// Spawn a mock wallet.tempo server with a custom authorization shape. + async fn spawn_mock_wallet_with( + poll_chain_id: u64, + shape: MockAuthShape, + ) -> (String, tokio::task::JoinHandle<()>) { + spawn_mock_wallet_inner(poll_chain_id, shape, false).await + } + + async fn spawn_mock_wallet_inner( + poll_chain_id: u64, + shape: MockAuthShape, + current_wire: bool, + ) -> (String, tokio::task::JoinHandle<()>) { + let app = Router::new() + .route("/code", post(create_code_handler)) + .route("/poll/{code}", post(poll_handler)) + .with_state(MockState { + wallet: Arc::default(), + key_id: Arc::default(), + poll_chain_id, + shape, + current_wire, + }); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let handle = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (format!("http://{addr}"), handle) + } + + fn test_cfg(service_url: String) -> EnsureAccessKeyConfig { + EnsureAccessKeyConfig { + chain_id: 4217, + service_url, + poll_interval: Duration::from_millis(10), + timeout: Duration::from_secs(2), + no_browser: true, + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn ensure_access_key_happy_path_writes_accounts_store() { + // SAFETY: serialized with other tests that mutate TEMPO_HOME. + let _g = test_env_mutex().lock().await; + let tmp = tempfile::tempdir().unwrap(); + unsafe { std::env::set_var(TEMPO_HOME_ENV, tmp.path()) }; + + let (service_url, server) = spawn_mock_wallet(4217).await; + let outcome = ensure_access_key(test_cfg(service_url)).await.unwrap(); + + let expected_wallet = Address::with_last_byte(0x42); + assert_eq!(outcome.chain_id, 4217); + assert_eq!(outcome.wallet_address, expected_wallet); + + let file = read_tempo_accounts_store().expect("store.json written"); + assert_eq!(file.keys.len(), 1); + let entry = &file.keys[0]; + assert_eq!(entry.wallet_address, outcome.wallet_address); + assert_eq!(entry.key_address, outcome.key_address); + assert_eq!(entry.chain_id, 4217); + assert_eq!(entry.expiry, Some(9_999_999_999)); + let decoded = entry.key_authorization.as_ref().expect("pending authorization"); + assert_eq!(decoded.authorization.chain_id, 4217); + + server.abort(); + unsafe { std::env::remove_var(TEMPO_HOME_ENV) }; + } + + #[tokio::test(flavor = "multi_thread")] + async fn ensure_access_key_rejects_wrong_chain_id() { + // Wallet returns chain 99999 but client requested 4217 → must reject + // and persist nothing, else discovery would later fail to find a key + // for the requested chain. + let _g = test_env_mutex().lock().await; + let tmp = tempfile::tempdir().unwrap(); + unsafe { std::env::set_var(TEMPO_HOME_ENV, tmp.path()) }; + + let (service_url, server) = spawn_mock_wallet(99999).await; + let err = ensure_access_key(test_cfg(service_url)).await.unwrap_err(); + assert!( + err.to_string().contains("wallet authorized chain 99999 but 4217 was requested"), + "expected chain mismatch error, got: {err}" + ); + assert!(read_tempo_accounts_store().is_none_or(|f| f.keys.is_empty())); + + server.abort(); + unsafe { std::env::remove_var(TEMPO_HOME_ENV) }; + } + + #[tokio::test(flavor = "multi_thread")] + async fn ensure_access_key_rejects_admin_authorization() { + // An admin key authorization from the wallet must be rejected before store.json is written. + let _g = test_env_mutex().lock().await; + let tmp = tempfile::tempdir().unwrap(); + unsafe { std::env::set_var(TEMPO_HOME_ENV, tmp.path()) }; + + // Bind the admin auth to the mock wallet account (0x..42) so it is rejected purely for + // being an admin key, not for an account mismatch. + let account = Address::with_last_byte(0x42); + let shape = MockAuthShape { admin: true, account: Some(account) }; + let (service_url, server) = spawn_mock_wallet_with(4217, shape).await; + + let err = ensure_access_key(test_cfg(service_url)).await.unwrap_err(); + assert!( + err.to_string().contains("admin key authorization"), + "expected admin-key rejection, got: {err}" + ); + assert!( + read_tempo_accounts_store().is_none_or(|f| f.keys.is_empty()), + "an admin authorization must not be persisted to store.json" + ); + + server.abort(); + unsafe { std::env::remove_var(TEMPO_HOME_ENV) }; + } + + #[tokio::test(flavor = "multi_thread")] + async fn ensure_access_key_rejects_cross_account_binding() { + // An authorization bound to an account other than the authorizing one must be rejected + // before store.json is written. + let _g = test_env_mutex().lock().await; + let tmp = tempfile::tempdir().unwrap(); + unsafe { std::env::set_var(TEMPO_HOME_ENV, tmp.path()) }; + + // The mock authorizes account 0x..42 but binds the authorization to 0x..dead. + let other = address!("0x000000000000000000000000000000000000dead"); + let shape = MockAuthShape { admin: false, account: Some(other) }; + let (service_url, server) = spawn_mock_wallet_with(4217, shape).await; + + let err = ensure_access_key(test_cfg(service_url)).await.unwrap_err(); + assert!( + err.to_string().contains("wallet authorized account"), + "expected cross-account rejection, got: {err}" + ); + assert!( + read_tempo_accounts_store().is_none_or(|f| f.keys.is_empty()), + "a cross-account authorization must not be persisted to store.json" + ); + + server.abort(); + unsafe { std::env::remove_var(TEMPO_HOME_ENV) }; + } +} diff --git a/crates/common/src/tempo/hardfork.rs b/crates/common/src/tempo/hardfork.rs new file mode 100644 index 0000000000000..fb02596b034a5 --- /dev/null +++ b/crates/common/src/tempo/hardfork.rs @@ -0,0 +1,184 @@ +//! Tempo hardfork activation queries. + +use crate::{FoundryTransactionBuilder, provider::is_rpc_method_not_found}; +use alloy_network::{Network, TransactionBuilder}; +use alloy_provider::Provider; +use alloy_transport::{TransportError, TransportErrorKind}; +use eyre::Result; +use serde::Deserialize; +use tempo_alloy::{chainspec::hardfork::TempoHardfork, rpc::ForkSchedule}; +use tempo_primitives::transaction::TEMPO_EXPIRING_NONCE_KEY; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct AnvilNodeInfo { + hard_fork: Option, + network: Option, +} + +/// Returns whether `hardfork` is active at the head of the chain behind `provider`. +/// +/// Queries `tempo_forkSchedule` and falls back to `anvil_nodeInfo` for Anvil nodes, which do not +/// serve the fork schedule. Fails when neither source reports a hardfork known to this build. +pub async fn is_tempo_hardfork_active>( + provider: &P, + hardfork: TempoHardfork, +) -> Result { + Ok(active_tempo_hardfork(provider).await? >= hardfork) +} + +/// Returns the Tempo hardfork active on the RPC, falling back to `anvil_nodeInfo` for nodes that +/// do not serve the fork schedule. +pub async fn active_tempo_hardfork>( + provider: &P, +) -> Result { + match fork_schedule_hardfork(provider).await { + Ok(hardfork) => Ok(hardfork), + Err(err) if is_rpc_method_not_found(&err) => match anvil_tempo_hardfork(provider).await { + Ok(Some(hardfork)) => Ok(hardfork), + _ => Err(err.into()), + }, + Err(err) => Err(err.into()), + } +} + +/// Fails when `tx` carries a non-zero expiring nonce and the chain is known to predate T12. +/// +/// TIP-1106 turns the nonce of an expiring nonce transaction into an opaque discriminator from +/// T12 on. Earlier hardforks reject any value other than zero. When the active hardfork cannot be +/// determined the check is skipped and validation is left to the node. +pub async fn ensure_expiring_nonce_discriminator_active>( + provider: &P, + tx: &N::TransactionRequest, +) -> Result<()> +where + N::TransactionRequest: FoundryTransactionBuilder, +{ + if tx.nonce_key() == Some(TEMPO_EXPIRING_NONCE_KEY) + && let Some(nonce) = tx.nonce().filter(|nonce| *nonce != 0) + && matches!(is_tempo_hardfork_active(provider, TempoHardfork::T12).await, Ok(false)) + { + eyre::bail!( + "expiring nonce transactions must use nonce 0 before the Tempo T12 hardfork, got nonce {nonce}; non-zero expiring nonce discriminators (TIP-1106) are not active on this chain" + ); + } + Ok(()) +} + +async fn fork_schedule_hardfork>( + provider: &P, +) -> Result { + let schedule = provider.raw_request::<_, ForkSchedule>("tempo_forkSchedule".into(), ()).await?; + schedule.active.parse::().map_err(TransportErrorKind::custom) +} + +async fn anvil_tempo_hardfork>( + provider: &P, +) -> Result, TransportError> { + let info = provider.raw_request::<_, AnvilNodeInfo>("anvil_nodeInfo".into(), ()).await?; + Ok(hardfork_from_anvil_node_info(&info)) +} + +fn hardfork_from_anvil_node_info(info: &AnvilNodeInfo) -> Option { + if info.network.as_deref() != Some("tempo") { + return None; + } + info.hard_fork.as_deref()?.parse().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_json_rpc::ErrorPayload; + use alloy_primitives::U256; + use alloy_provider::{ProviderBuilder, mock::Asserter}; + use tempo_alloy::{TempoNetwork, rpc::TempoTransactionRequest}; + + fn mocked_provider(asserter: Asserter) -> impl Provider { + ProviderBuilder::new().network::().connect_mocked_client(asserter) + } + + #[tokio::test] + async fn tempo_fork_schedule_detects_t3_activation() { + for (active, expected) in [("T2", false), ("T3", true), ("T13", true), ("T14", true)] { + let asserter = Asserter::new(); + asserter.push_success(&serde_json::json!({ "active": active, "schedule": [] })); + let provider = mocked_provider(asserter); + assert_eq!( + is_tempo_hardfork_active(&provider, TempoHardfork::T3).await.unwrap(), + expected + ); + } + } + + #[tokio::test] + async fn tempo_fork_schedule_rejects_unknown_hardfork() { + let asserter = Asserter::new(); + asserter.push_success(&serde_json::json!({ "active": "FutureFork", "schedule": [] })); + let provider = mocked_provider(asserter); + assert!(is_tempo_hardfork_active(&provider, TempoHardfork::T3).await.is_err()); + } + + #[test] + fn hardfork_from_anvil_node_info_requires_tempo_network() { + let info = |network: &str, hard_fork: &str| AnvilNodeInfo { + network: Some(network.to_string()), + hard_fork: Some(hard_fork.to_string()), + }; + assert_eq!(hardfork_from_anvil_node_info(&info("tempo", "T3")), Some(TempoHardfork::T3)); + assert_eq!(hardfork_from_anvil_node_info(&info("tempo", "T11")), Some(TempoHardfork::T11)); + assert_eq!(hardfork_from_anvil_node_info(&info("tempo", "FutureFork")), None); + assert_eq!(hardfork_from_anvil_node_info(&info("ethereum", "T3")), None); + } + + #[tokio::test] + async fn anvil_node_info_fallback_detects_hardfork_activation() { + let asserter = Asserter::new(); + for _ in 0..2 { + asserter.push_failure(ErrorPayload { + code: -32601, + message: "Method not found".into(), + data: None, + }); + asserter.push_success(&serde_json::json!({ "network": "tempo", "hardFork": "T3" })); + } + let provider = + ProviderBuilder::new().network::().connect_mocked_client(asserter); + assert!(is_tempo_hardfork_active(&provider, TempoHardfork::T3).await.unwrap()); + assert!(!is_tempo_hardfork_active(&provider, TempoHardfork::T4).await.unwrap()); + } + + #[tokio::test] + async fn expiring_nonce_discriminator_requires_t12_when_hardfork_is_known() { + let request = |nonce_key, nonce| { + let mut tx = TempoTransactionRequest::default(); + tx.set_nonce_key(nonce_key); + tx.set_nonce(nonce); + tx + }; + let discriminator = request(TEMPO_EXPIRING_NONCE_KEY, 7); + + // `None` leaves the mock without a response, so any hardfork query fails. + for (tx, active, accepted) in [ + (&discriminator, Some("T11"), false), + (&discriminator, Some("T12"), true), + (&discriminator, Some("FutureFork"), true), + (&discriminator, None, true), + (&request(TEMPO_EXPIRING_NONCE_KEY, 0), None, true), + (&request(U256::from(1), 7), None, true), + ] { + let asserter = Asserter::new(); + if let Some(active) = active { + asserter.push_success(&serde_json::json!({ "active": active, "schedule": [] })); + } + let provider = mocked_provider(asserter); + let result = ensure_expiring_nonce_discriminator_active(&provider, tx).await; + assert_eq!( + result.is_ok(), + accepted, + "nonce {:?} at {active:?}: {result:?}", + tx.nonce() + ); + } + } +} diff --git a/crates/common/src/tempo/keystore.rs b/crates/common/src/tempo/keystore.rs new file mode 100644 index 0000000000000..26e8cacf1ac10 --- /dev/null +++ b/crates/common/src/tempo/keystore.rs @@ -0,0 +1,172 @@ +//! Tempo Accounts store discovery and local key metadata. + +use alloy_primitives::{Address, hex}; +use alloy_rlp::Decodable; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use tempo_alloy::accounts::{TempoAccountsStore, default_accounts_store_path}; +use tempo_primitives::{ + SignatureType, + transaction::{SignedKeyAuthorization, TokenLimit}, +}; + +/// Environment variable to override the Tempo home directory. +pub const TEMPO_HOME_ENV: &str = "TEMPO_HOME"; + +/// Cryptographic key type used by Tempo Accounts access keys. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum KeyType { + #[default] + Secp256k1, + P256, + WebAuthn, +} + +impl From for KeyType { + fn from(value: SignatureType) -> Self { + match value { + SignatureType::Secp256k1 => Self::Secp256k1, + SignatureType::P256 => Self::P256, + SignatureType::WebAuthn => Self::WebAuthn, + } + } +} + +/// Per-token spending limit exposed by the local keychain commands. +#[derive(Debug, Clone)] +pub struct StoredTokenLimit { + pub currency: Address, + pub limit: String, + pub period: u64, +} + +impl From<&TokenLimit> for StoredTokenLimit { + fn from(value: &TokenLimit) -> Self { + Self { currency: value.token, limit: value.limit.to_string(), period: value.period } + } +} + +/// Non-secret view of one access key in the Tempo Accounts store. +#[derive(Clone, Default)] +pub struct KeyEntry { + pub wallet_address: Address, + pub chain_id: u64, + pub key_type: KeyType, + pub key_address: Address, + pub key_authorization: Option, + pub expiry: Option, + pub limits: Vec, + locally_signable: bool, +} + +impl std::fmt::Debug for KeyEntry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("KeyEntry") + .field("wallet_address", &self.wallet_address) + .field("chain_id", &self.chain_id) + .field("key_type", &self.key_type) + .field("key_address", &self.key_address) + .field("has_key", &self.locally_signable) + .field("has_key_authorization", &self.key_authorization.is_some()) + .field("expiry", &self.expiry) + .field("limits", &self.limits) + .finish() + } +} + +impl KeyEntry { + /// Construct non-secret metadata for one Tempo Accounts access key. + pub const fn new( + wallet_address: Address, + chain_id: u64, + key_type: KeyType, + key_address: Address, + ) -> Self { + Self { + wallet_address, + chain_id, + key_type, + key_address, + key_authorization: None, + expiry: None, + limits: Vec::new(), + locally_signable: false, + } + } + + /// Attach a pending authorization to this metadata view. + #[doc(hidden)] + pub fn with_key_authorization(mut self, authorization: SignedKeyAuthorization) -> Self { + self.key_authorization = Some(authorization); + self + } + + /// Whether the Accounts store contains usable local signing material. + pub const fn has_inline_key(&self) -> bool { + self.locally_signable + } + + /// Override local signing availability when constructing diagnostic fixtures. + #[doc(hidden)] + pub const fn with_locally_signable(mut self, locally_signable: bool) -> Self { + self.locally_signable = locally_signable; + self + } +} + +/// Snapshot used by the existing keychain list/show/doctor commands. +#[derive(Debug, Default)] +pub struct AccountsStoreView { + pub keys: Vec, +} + +/// Return the canonical Tempo Accounts `store.json` path. +pub fn tempo_accounts_store_path() -> Option { + default_accounts_store_path().ok() +} + +/// Read non-secret metadata from the canonical Tempo Accounts store. +pub fn read_tempo_accounts_store() -> Option { + let store = match TempoAccountsStore::try_open_default() { + Ok(Some(store)) => store, + Ok(None) => return None, + Err(error) => { + tracing::warn!(%error, "failed to open Tempo Accounts store"); + return None; + } + }; + let keys = match store.access_keys() { + Ok(keys) => keys, + Err(error) => { + tracing::warn!(%error, path = %store.path().display(), "failed to read Tempo Accounts access keys"); + return None; + } + }; + Some(AccountsStoreView { + keys: keys + .into_iter() + .map(|key| KeyEntry { + wallet_address: key.account(), + chain_id: key.chain_id(), + key_type: key.key_type().into(), + key_address: key.address(), + key_authorization: key.key_authorization().cloned(), + expiry: key.expiry(), + limits: key.limits().iter().map(Into::into).collect(), + locally_signable: key.is_locally_signable(), + }) + .collect(), + }) +} + +/// Decode an RLP key authorization stored by Tempo Accounts. +pub fn decode_key_authorization(encoded: &str) -> eyre::Result { + let bytes = hex::decode(encoded)?; + let mut bytes = bytes.as_slice(); + let authorization = T::decode(&mut bytes)?; + if !bytes.is_empty() { + eyre::bail!("key authorization has trailing bytes"); + } + Ok(authorization) +} diff --git a/crates/common/src/tempo/lane.rs b/crates/common/src/tempo/lane.rs new file mode 100644 index 0000000000000..3f4742bafa457 --- /dev/null +++ b/crates/common/src/tempo/lane.rs @@ -0,0 +1,132 @@ +//! Tempo T5 payment-lane classification shared by anvil and cast. + +use alloy_eips::eip2718::Decodable2718; +use serde::{Deserialize, Serialize}; +use tempo_primitives::TempoTxEnvelope; + +/// Structured T5 payment-lane classification for Foundry-facing APIs. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PaymentLaneClassification { + /// The classified lane. + pub lane: PaymentLane, + /// Convenience boolean for consumers that only need the lane predicate. + pub payment: bool, + /// Structured reason for general-lane classification, when known. + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +impl PaymentLaneClassification { + /// Constructs a payment-lane classification. + pub const fn payment() -> Self { + Self { lane: PaymentLane::Payment, payment: true, reason: None } + } + + /// Constructs a general-lane classification with a structured reason. + pub const fn general(reason: PaymentLaneReason) -> Self { + Self { lane: PaymentLane::General, payment: false, reason: Some(reason) } + } +} + +/// Payment-lane classifier output lane. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PaymentLane { + Payment, + General, +} + +/// Stable Foundry-facing reasons for general-lane classification. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PaymentLaneReason { + /// The active network is not Tempo. + NotTempo, + /// Tempo is active but the T5 classifier is not active. + T5NotActive, + /// The transaction type cannot be classified by Tempo's payment-lane classifier. + UnsupportedTransactionType, + /// Tempo's T5 classifier classified the transaction as general. + NotPaymentLane, +} + +/// Classifies a raw EIP-2718 encoded transaction with Tempo's T5 payment-lane classifier. +/// +/// Bytes that do not decode as a Tempo envelope (e.g. EIP-4844 or Optimism deposit +/// transactions) classify as [`PaymentLaneReason::UnsupportedTransactionType`]; callers that +/// need to reject invalid transactions should validate the bytes beforehand. +pub fn classify_payment_lane(mut raw: &[u8]) -> PaymentLaneClassification { + let Ok(tx) = TempoTxEnvelope::decode_2718(&mut raw) else { + return PaymentLaneClassification::general(PaymentLaneReason::UnsupportedTransactionType); + }; + + if tx.is_payment_v2() { + PaymentLaneClassification::payment() + } else { + PaymentLaneClassification::general(PaymentLaneReason::NotPaymentLane) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_consensus::{Signed, TxEip1559}; + use alloy_eips::eip2718::Encodable2718; + use alloy_primitives::{Address, Signature, TxKind, U256, address}; + use alloy_sol_types::SolCall; + use tempo_alloy::contracts::precompiles::ITIP20; + + const PAYMENT_TOKEN: Address = address!("20c0000000000000000000000000000000000001"); + + fn encode_eip1559(to: Address, input: Vec) -> Vec { + let tx = TxEip1559 { to: TxKind::Call(to), input: input.into(), ..Default::default() }; + TempoTxEnvelope::Eip1559(Signed::new_unhashed(tx, Signature::test_signature())) + .encoded_2718() + } + + #[test] + fn classifies_tip20_transfer_as_payment() { + let input = ITIP20::transferCall { to: Address::ZERO, amount: U256::ONE }.abi_encode(); + let raw = encode_eip1559(PAYMENT_TOKEN, input); + + assert_eq!(classify_payment_lane(&raw), PaymentLaneClassification::payment()); + } + + #[test] + fn classifies_non_payment_call_as_general() { + let raw = encode_eip1559(address!("1234567890123456789012345678901234567890"), vec![]); + + assert_eq!( + classify_payment_lane(&raw), + PaymentLaneClassification::general(PaymentLaneReason::NotPaymentLane) + ); + } + + #[test] + fn classifies_non_tempo_transaction_type_as_unsupported() { + // EIP-4844 (0x03) and Optimism deposit (0x7e) have no Tempo envelope variant. + for type_byte in [0x03_u8, 0x7e] { + let raw = [type_byte, 0xc0]; + + assert_eq!( + classify_payment_lane(&raw), + PaymentLaneClassification::general(PaymentLaneReason::UnsupportedTransactionType) + ); + } + } + + #[test] + fn serializes_stable_json_shape() { + let payment = serde_json::to_value(PaymentLaneClassification::payment()).unwrap(); + assert_eq!(payment, serde_json::json!({ "lane": "payment", "payment": true })); + + let general = + serde_json::to_value(PaymentLaneClassification::general(PaymentLaneReason::NotTempo)) + .unwrap(); + assert_eq!( + general, + serde_json::json!({ "lane": "general", "payment": false, "reason": "not_tempo" }) + ); + } +} diff --git a/crates/common/src/tempo/mod.rs b/crates/common/src/tempo/mod.rs new file mode 100644 index 0000000000000..588abd00ae5ed --- /dev/null +++ b/crates/common/src/tempo/mod.rs @@ -0,0 +1,472 @@ +//! Tempo network utilities. + +pub mod auth; + +use crate::FoundryTransactionBuilder; +use alloy_network::{Network, NetworkTransactionBuilder, TransactionBuilder}; +use alloy_primitives::{Address, B256, Signature, TxKind, address}; +use alloy_provider::Provider; +use alloy_signer::Signer; +use alloy_sol_types::SolCall; +use eyre::{Context, Result}; +use foundry_wallets::{RawWalletOpts, WalletOpts, WalletSigner}; +use std::sync::Arc; +pub use tempo_alloy::contracts::precompiles::PATH_USD_ADDRESS; +use tempo_alloy::contracts::precompiles::{ + IFeeManager, IStablecoinDEX, ITIP20, STABLECOIN_DEX_ADDRESS, TIP_FEE_MANAGER_ADDRESS, +}; +use tempo_primitives::TempoAddressExt; + +mod hardfork; +mod keystore; +mod lane; +mod session; +mod session_policy; +#[cfg(test)] +mod test_utils; +mod tip20; + +pub(crate) use auth::is_known_tempo_endpoint; +pub use auth::{AccessKeyOutcome, EnsureAccessKeyConfig, ensure_access_key}; +pub use hardfork::{ + active_tempo_hardfork, ensure_expiring_nonce_discriminator_active, is_tempo_hardfork_active, +}; +pub use keystore::*; +pub use lane::{PaymentLane, PaymentLaneClassification, PaymentLaneReason, classify_payment_lane}; +pub use session::*; +pub use session_policy::{ + GeneratedSessionKey, PreparedSessionAuthorization, SessionAuthorizationRequest, + SessionSpendLimit, +}; +pub use tip20::{ + TIP20_ALLOWED_LOGO_URI_SCHEMES, TIP20_MAX_LOGO_URI_BYTES, Tip20LogoUriValidationError, + validate_tip20_logo_uri, +}; + +#[cfg(test)] +pub(crate) use test_utils::test_env_mutex; + +#[cfg(test)] +mod tests; + +/// Reserved Tempo TIP20 fee-token addresses created during Foundry genesis. +/// +/// Unlike [`PATH_USD_ADDRESS`], these tokens are not defined by the canonical +/// `tempo-contracts` crate; they only exist in Foundry's local genesis setup, so +/// they are defined here as the single source of truth and re-exported elsewhere. +pub const ALPHA_USD_ADDRESS: Address = address!("0x20C0000000000000000000000000000000000001"); +pub const BETA_USD_ADDRESS: Address = address!("0x20C0000000000000000000000000000000000002"); +pub const THETA_USD_ADDRESS: Address = address!("0x20C0000000000000000000000000000000000003"); +pub const OUSD_ADDRESS: Address = address!("0x20c0000000000000000000006a37DA5C996874BE"); + +/// Gas sponsor configuration for Tempo fee-payer signatures. +#[derive(Clone, Debug)] +pub struct TempoSponsor { + sponsor: Address, + signer: Option>, + signature: Option, +} + +impl TempoSponsor { + pub const fn new( + sponsor: Address, + signer: Option>, + signature: Option, + ) -> Self { + Self { sponsor, signer, signature } + } + + pub const fn sponsor(&self) -> Address { + self.sponsor + } + + /// Resolves the fee token paid by this sponsor and applies it to the transaction request. + /// + /// This must happen before computing a sponsor digest, because Tempo sponsor signatures commit + /// to the fee token. + pub async fn resolve_and_set_fee_token( + &self, + provider: Option<&dyn Provider>, + tx: &mut N::TransactionRequest, + ) -> Result> + where + N: Network, + N::TransactionRequest: Default + FoundryTransactionBuilder, + { + resolve_and_set_fee_token(provider, tx, Some(self.sponsor)).await + } + + pub async fn attach_and_print( + &self, + tx: &mut N::TransactionRequest, + sender: Address, + ) -> Result + where + N::TransactionRequest: FoundryTransactionBuilder, + { + if self.sponsor == sender { + eyre::bail!( + "invalid Tempo sponsorship: sponsor {} must not equal transaction sender", + self.sponsor + ); + } + + let digest = tx.compute_sponsor_hash(sender).ok_or_else(|| { + eyre::eyre!( + "failed to compute Tempo sponsor digest; make sure this is a complete Tempo AA transaction" + ) + })?; + + let preview = TempoSponsorPreview { + sponsor: self.sponsor, + fee_token: tx.fee_token(), + valid_before: tx.valid_before().map(|v| v.get()), + valid_after: tx.valid_after().map(|v| v.get()), + digest, + }; + preview.print()?; + + let signature = if let Some(signature) = self.signature { + signature + } else if let Some(signer) = &self.signer { + signer.sign_hash(&digest).await.context("failed to sign Tempo sponsor digest")? + } else { + eyre::bail!("missing Tempo sponsor signature or signer"); + }; + + let recovered = signature + .recover_address_from_prehash(&digest) + .context("failed to recover Tempo sponsor signature")?; + if recovered != self.sponsor { + eyre::bail!( + "Tempo sponsor signature recovered {recovered}, expected {}; the signature must \ + cover this exact transaction's sponsor digest — when signing a digest produced \ + with `--tempo.print-sponsor-hash`, pin --nonce, --gas-limit, --gas-price and \ + --priority-gas-price on both commands so the digest does not change in between", + self.sponsor + ); + } + if recovered == sender { + eyre::bail!( + "invalid Tempo sponsorship: recovered fee payer {recovered} must not equal transaction sender" + ); + } + + tx.set_fee_payer_signature(signature); + Ok(preview) + } +} + +/// User-visible sponsor digest metadata for a single outgoing Tempo transaction. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TempoSponsorPreview { + pub sponsor: Address, + pub fee_token: Option
, + pub valid_before: Option, + pub valid_after: Option, + pub digest: B256, +} + +impl TempoSponsorPreview { + pub fn print(&self) -> Result<()> { + crate::sh_eprintln!("Tempo sponsor: {}", self.sponsor)?; + crate::sh_eprintln!( + "Tempo fee token: {}", + self.fee_token.map_or_else(|| "network default".to_string(), |addr| addr.to_string()) + )?; + crate::sh_eprintln!( + "Tempo validity: after {}, before {}", + self.valid_after.map_or_else(|| "none".to_string(), |v| v.to_string()), + self.valid_before.map_or_else(|| "none".to_string(), |v| v.to_string()) + )?; + crate::sh_eprintln!("Tempo sponsor digest: {:?}", self.digest)?; + Ok(()) + } +} + +/// Resolves a `--tempo.sponsor-signer` URI into a Foundry wallet signer. +pub async fn resolve_tempo_sponsor_signer(spec: &str) -> Result { + let spec = spec.trim(); + let (scheme, value) = spec + .split_once("://") + .map(|(scheme, value)| (scheme.to_ascii_lowercase(), value)) + .unwrap_or_else(|| (spec.to_ascii_lowercase(), "")); + + match scheme.as_str() { + "env" => { + if value.is_empty() { + eyre::bail!("env:// sponsor signer requires an environment variable name"); + } + let private_key = std::env::var(value) + .wrap_err_with(|| format!("{value} environment variable is required"))?; + foundry_wallets::utils::create_private_key_signer(&private_key) + } + "private-key" => { + if value.is_empty() { + eyre::bail!("private-key:// sponsor signer requires a private key"); + } + foundry_wallets::utils::create_private_key_signer(value) + } + "keystore" => { + if value.is_empty() { + eyre::bail!("keystore:// sponsor signer requires a keystore path"); + } + WalletOpts { keystore_path: Some(value.to_string()), ..Default::default() } + .signer() + .await + } + "account" => { + if value.is_empty() { + eyre::bail!("account:// sponsor signer requires an account name"); + } + WalletOpts { keystore_account_name: Some(value.to_string()), ..Default::default() } + .signer() + .await + } + "ledger" => { + let raw = RawWalletOpts { + hd_path: (!value.is_empty()).then(|| value.to_string()), + ..Default::default() + }; + WalletOpts { ledger: true, raw, ..Default::default() }.signer().await + } + "trezor" => { + let raw = RawWalletOpts { + hd_path: (!value.is_empty()).then(|| value.to_string()), + ..Default::default() + }; + WalletOpts { trezor: true, raw, ..Default::default() }.signer().await + } + "aws" => WalletOpts { aws: true, ..Default::default() }.signer().await, + "gcp" => WalletOpts { gcp: true, ..Default::default() }.signer().await, + "turnkey" => WalletOpts { turnkey: true, ..Default::default() }.signer().await, + "browser" => { + eyre::bail!( + "browser:// sponsor signing is not supported by the current browser wallet API; use --tempo.sponsor-sig or another sponsor signer" + ); + } + _ => { + eyre::bail!( + "unsupported Tempo sponsor signer `{spec}`; expected env://VAR, keystore://PATH, account://NAME, ledger://, trezor://, aws://, gcp://, turnkey://, or private-key://KEY" + ); + } + } +} + +/// Placeholder rendered by `Debug` impls in place of secret key material. +fn redacted_debug(value: &str) -> &'static str { + if value.trim().is_empty() { "" } else { "" } +} + +/// Resolves and applies the Tempo fee token selected by the network. +/// +/// This must happen before computing a sponsor digest, because Tempo sponsor signatures commit to +/// the fee token. The selected network's request type decides whether a fee token applies, so a +/// local Tempo node is handled the same way as a canonical Tempo chain. +pub async fn resolve_and_set_fee_token( + provider: Option<&dyn Provider>, + tx: &mut N::TransactionRequest, + fee_payer: Option
, +) -> Result> +where + N: Network, + N::TransactionRequest: Default + FoundryTransactionBuilder, +{ + if let Some(fee_token) = tx.fee_token() { + return Ok(Some(fee_token)); + } + if !tx.supports_fee_token() { + return Ok(None); + } + let fee_payer = fee_payer.or_else(|| tx.from()); + let calls = tx.tempo_calls(); + let has_call_list = tx.has_tempo_call_list(); + let is_aa = tx.is_tempo_aa(); + let tx_from = tx.from(); + + // A stored fee-token preference would classify a contract creation as Tempo AA, but AA + // transactions require a non-empty call list. Leave CREATE requests as Ethereum transactions; + // the protocol still applies the account's stored fee-token preference when charging fees. + if !has_call_list && calls.iter().any(|(to, _)| to.is_create()) { + return Ok(None); + } + + let immediate_user_token = + infer_fee_token_from_set_user_token_call(&calls, is_aa, tx_from, fee_payer); + let stored_fee_token = if immediate_user_token.is_none() + && let (Some(provider), Some(fee_payer)) = (provider, fee_payer) + { + stored_user_fee_token(provider, fee_payer).await? + } else { + None + }; + let inferred_fee_token = + if immediate_user_token.is_none() && stored_fee_token.is_none() && !calls.is_empty() { + infer_fee_token_from_tip20_calls(&calls, tx_from, fee_payer) + .or_else(|| infer_fee_token_from_stablecoin_dex_calls(&calls, has_call_list)) + } else { + None + }; + let fee_token = immediate_user_token.or(stored_fee_token).or(inferred_fee_token); + + let Some(fee_token) = fee_token else { return Ok(None) }; + tx.set_fee_token(fee_token); + Ok(Some(fee_token)) +} + +async fn stored_user_fee_token( + provider: &dyn Provider, + fee_payer: Address, +) -> Result> +where + N: Network, + N::TransactionRequest: Default + NetworkTransactionBuilder, +{ + let call = IFeeManager::userTokensCall { user: fee_payer }; + let tx = N::TransactionRequest::default() + .with_to(TIP_FEE_MANAGER_ADDRESS) + .with_input(call.abi_encode()); + let output = provider + .call(tx) + .await + .wrap_err_with(|| format!("failed to resolve Tempo fee token for {fee_payer}"))?; + let fee_token = IFeeManager::userTokensCall::abi_decode_returns(&output) + .wrap_err("failed to decode Tempo fee token lookup")?; + Ok((!fee_token.is_zero()).then_some(fee_token)) +} + +fn infer_fee_token_from_tip20_calls( + calls: &[(TxKind, &[u8])], + tx_from: Option
, + fee_payer: Option
, +) -> Option
{ + if calls.is_empty() || !calls.iter().all(|(_, input)| is_tip20_fee_token_call(input)) { + return None; + } + + let target = common_call_target(calls)?; + if fee_payer != tx_from { + return None; + } + target.is_tip20().then_some(target) +} + +fn infer_fee_token_from_set_user_token_call( + calls: &[(TxKind, &[u8])], + is_aa: bool, + tx_from: Option
, + fee_payer: Option
, +) -> Option
{ + if is_aa || fee_payer != tx_from { + return None; + } + + let (to, input) = calls.first()?; + if *to != TxKind::Call(TIP_FEE_MANAGER_ADDRESS) { + return None; + } + + let call = IFeeManager::setUserTokenCall::abi_decode(input).ok()?; + call.token.is_tip20().then_some(call.token) +} + +fn infer_fee_token_from_stablecoin_dex_calls( + calls: &[(TxKind, &[u8])], + has_call_list: bool, +) -> Option
{ + if has_call_list && calls.len() != 1 { + return None; + } + let (to, input) = calls.first()?; + if *to != TxKind::Call(STABLECOIN_DEX_ADDRESS) { + return None; + } + decode_stablecoin_dex_fee_token(input) +} + +fn common_call_target(calls: &[(TxKind, &[u8])]) -> Option
{ + let mut targets = calls.iter().map(|(to, _)| match to { + TxKind::Call(target) => Some(*target), + TxKind::Create => None, + }); + let target = targets.next()??; + targets.all(|next| next == Some(target)).then_some(target) +} + +fn is_tip20_fee_token_call(input: &[u8]) -> bool { + input.get(..4).is_some_and(|selector| { + selector == ITIP20::transferCall::SELECTOR + || selector == ITIP20::transferWithMemoCall::SELECTOR + }) +} + +fn decode_stablecoin_dex_fee_token(input: &[u8]) -> Option
{ + let selector = input.get(..4)?; + let token = if selector == IStablecoinDEX::swapExactAmountInCall::SELECTOR { + IStablecoinDEX::swapExactAmountInCall::abi_decode(input).ok().map(|call| call.tokenIn) + } else if selector == IStablecoinDEX::swapExactAmountOutCall::SELECTOR { + IStablecoinDEX::swapExactAmountOutCall::abi_decode(input).ok().map(|call| call.tokenIn) + } else { + None + }?; + token.is_tip20().then_some(token) +} + +/// Returns the known symbol for a Tempo fee token without making an RPC call. +pub const fn known_fee_token_symbol(fee_token: Address) -> Option<&'static str> { + match fee_token { + PATH_USD_ADDRESS => Some("PathUSD"), + ALPHA_USD_ADDRESS => Some("AlphaUSD"), + BETA_USD_ADDRESS => Some("BetaUSD"), + THETA_USD_ADDRESS => Some("ThetaUSD"), + OUSD_ADDRESS => Some("OUSD"), + _ => None, + } +} + +async fn resolve_fee_token_symbol(provider: &P, fee_token: Address) -> Option +where + N: Network, + N::TransactionRequest: Default + NetworkTransactionBuilder, + P: Provider, +{ + if let Some(symbol) = known_fee_token_symbol(fee_token) { + return Some(symbol.to_string()); + } + + let tx = N::TransactionRequest::default() + .with_to(fee_token) + .with_input(ITIP20::symbolCall.abi_encode()); + let output = provider.call(tx).await.ok()?; + let symbol = ITIP20::symbolCall::abi_decode_returns(&output).ok()?; + (!symbol.is_empty()).then_some(symbol) +} + +/// Prints the fee token selected for display. +/// +/// Unknown symbols are resolved on-chain only when a provider is supplied, because some provider +/// modes such as `--curl` must preserve the first RPC request for the user's intended action. +pub async fn maybe_print_fee_token( + provider: Option<&P>, + fee_token: Option
, +) -> Result<()> +where + N: Network, + N::TransactionRequest: Default + NetworkTransactionBuilder, + P: Provider, +{ + if let Some(fee_token) = fee_token { + let symbol = if let Some(symbol) = known_fee_token_symbol(fee_token) { + Some(symbol.to_string()) + } else if let Some(provider) = provider { + resolve_fee_token_symbol(provider, fee_token).await + } else { + None + }; + match symbol { + Some(symbol) => sh_status!("Paying gas in {} ({})", symbol, fee_token)?, + None => sh_status!("Paying gas in {}", fee_token)?, + } + } + Ok(()) +} diff --git a/crates/common/src/tempo/session.rs b/crates/common/src/tempo/session.rs new file mode 100644 index 0000000000000..bbb00d7324968 --- /dev/null +++ b/crates/common/src/tempo/session.rs @@ -0,0 +1,446 @@ +//! Tempo temporary access-key lifecycle backed by the canonical Accounts store. + +use super::KeyType; +use alloy_primitives::{Address, B256, Selector, U256}; +use eyre::ensure; +use foundry_wallets::TempoAccountsWallet; +use serde::{Deserialize, Serialize}; +use std::{fmt, num::NonZeroU64, time::SystemTime}; +use tempo_alloy::accounts::TempoAccountsStore; +use tempo_primitives::transaction::{ + CallScope, KeyAuthorization, SelectorRule, SignatureType, SignedKeyAuthorization, TokenLimit, +}; + +/// Status derived from a managed access key's Accounts record. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SessionStatus { + #[default] + Pending, + Active, + Revoked, + Expired, +} + +/// Spending limit attached to a managed access key. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct SessionTokenLimit { + pub currency: Address, + pub limit: String, +} + +/// Transient key material used while adding an access key to the Accounts store. +#[derive(Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct SessionKeyMaterial { + #[serde(default)] + pub key_type: KeyType, + pub key: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key_authorization: Option, +} + +impl fmt::Debug for SessionKeyMaterial { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SessionKeyMaterial") + .field("key_type", &self.key_type) + .field("key", &super::redacted_debug(&self.key)) + .field( + "key_authorization", + &self.key_authorization.as_deref().map(super::redacted_debug), + ) + .finish() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct SessionSelectorRule { + pub selector: Selector, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub recipients: Vec
, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct SessionCallScope { + pub target: Address, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub selector_rules: Vec, +} + +/// Foundry's command-facing view of one Accounts access key. +/// +/// This is not a persistence schema. The source of truth is always +/// `$TEMPO_HOME/wallet/store.json`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct SessionEntry { + pub session_id: B256, + pub root_account: Address, + pub chain_id: u64, + pub key_address: Address, + pub expiry: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scope: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limits: Option>, + #[serde(default)] + pub status: SessionStatus, + /// Present only before the entry is written to the Accounts store. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key: Option, +} + +impl SessionEntry { + pub const fn is_expired_at(&self, now: u64) -> bool { + now >= self.expiry + } + + pub fn has_live_key_at(&self, now: u64) -> bool { + self.status == SessionStatus::Active && !self.is_expired_at(now) + } +} + +/// A live managed key pinned into an Accounts wallet. +#[derive(Debug)] +pub struct ResolvedSessionSigner { + pub session: SessionEntry, + pub access_key: TempoAccountsWallet, +} + +pub fn read_session_entry(session_id: B256) -> eyre::Result> { + let now = + SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default().as_secs(); + Ok(read_session_entries(now)? + .and_then(|entries| entries.into_iter().find(|entry| entry.session_id == session_id))) +} + +/// Resolve a live managed key from the Accounts store and pin it for signing. +pub fn resolve_live_session_signer( + session_id: B256, + now: u64, +) -> eyre::Result> { + mark_expired_session_entries(now)?; + let Some(session) = read_session_entries(now)? + .and_then(|entries| entries.into_iter().find(|entry| entry.session_id == session_id)) + else { + return Ok(None); + }; + if !session.has_live_key_at(now) { + return Ok(None); + } + + let wallet = TempoAccountsWallet::from_default_store()?.with_chain_id(session.chain_id); + let selected = + wallet.access_key(session.root_account, session.chain_id, session.key_address)?; + let access_key = TempoAccountsWallet::from_access_key(selected); + Ok(Some(ResolvedSessionSigner { session, access_key })) +} + +/// Validate that a signed authorization describes the command-facing managed key. +pub(crate) fn validate_signed_session_authorization( + session: &SessionEntry, + expected_key_type: SignatureType, + authorization: &SignedKeyAuthorization, +) -> eyre::Result<()> { + let auth = &authorization.authorization; + ensure!( + auth.key_id == session.key_address, + "session {} key_authorization key_id is {}, expected {}", + session.session_id, + auth.key_id, + session.key_address + ); + ensure!( + auth.chain_id == session.chain_id, + "session {} key_authorization chain_id is {}, expected {}", + session.session_id, + auth.chain_id, + session.chain_id + ); + ensure!( + auth.key_type == expected_key_type, + "session {} key_authorization key_type is {:?}, expected {:?}", + session.session_id, + auth.key_type, + expected_key_type + ); + ensure!(!auth.is_admin(), "session access key cannot be an admin key"); + if let Some(account) = auth.account { + ensure!( + account == session.root_account, + "session authorization is bound to account {account}, expected {}", + session.root_account + ); + } + ensure!( + auth.witness() == Some(session.session_id), + "session authorization witness is {:?}, expected {}", + auth.witness(), + session.session_id + ); + ensure!( + authorization.recover_signer()? == session.root_account, + "session authorization was not signed by {}", + session.root_account + ); + validate_session_authorization_policy(session, auth) +} + +fn validate_session_authorization_policy( + session: &SessionEntry, + authorization: &KeyAuthorization, +) -> eyre::Result<()> { + let expiry = NonZeroU64::new(session.expiry) + .ok_or_else(|| eyre::eyre!("session expiry cannot be zero"))?; + ensure!(authorization.expiry == Some(expiry), "session authorization expiry does not match"); + + let expected_limits = canonical_session_limits(session)?; + let actual_limits = authorization.limits.as_deref().map(canonical_authorization_limits); + ensure!(actual_limits == expected_limits, "session authorization limits do not match"); + + let expected_scope = canonical_session_scope(session); + let actual_scope = authorization.allowed_calls.as_deref().map(canonical_authorization_scope); + ensure!(actual_scope == expected_scope, "session authorization call scope does not match"); + Ok(()) +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +struct CanonicalTokenLimit { + token: Address, + limit: U256, + period: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +struct CanonicalCallScope { + target: Address, + selector_rules: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +struct CanonicalSelectorRule { + selector: [u8; 4], + recipients: Vec
, +} + +fn canonical_session_limits( + session: &SessionEntry, +) -> eyre::Result>> { + let Some(limits) = session.limits.as_deref() else { + return Ok(None); + }; + let mut limits = limits + .iter() + .map(|limit| { + Ok(CanonicalTokenLimit { + token: limit.currency, + limit: parse_limit(&limit.limit)?, + period: 0, + }) + }) + .collect::>>()?; + limits.sort(); + Ok(Some(limits)) +} + +fn canonical_authorization_limits(limits: &[TokenLimit]) -> Vec { + let mut limits = limits + .iter() + .map(|limit| CanonicalTokenLimit { + token: limit.token, + limit: limit.limit, + period: limit.period, + }) + .collect::>(); + limits.sort(); + limits +} + +fn canonical_session_scope(session: &SessionEntry) -> Option> { + let mut scopes = session + .scope + .as_deref()? + .iter() + .map(|scope| CanonicalCallScope { + target: scope.target, + selector_rules: canonical_session_selector_rules(&scope.selector_rules), + }) + .collect::>(); + scopes.sort(); + Some(scopes) +} + +fn canonical_authorization_scope(scopes: &[CallScope]) -> Vec { + let mut scopes = scopes + .iter() + .map(|scope| CanonicalCallScope { + target: scope.target(), + selector_rules: canonical_authorization_selector_rules(&scope.selector_rules), + }) + .collect::>(); + scopes.sort(); + scopes +} + +fn canonical_session_selector_rules(rules: &[SessionSelectorRule]) -> Vec { + let mut rules = rules + .iter() + .map(|rule| { + let mut recipients = rule.recipients.clone(); + recipients.sort(); + CanonicalSelectorRule { selector: rule.selector.into(), recipients } + }) + .collect::>(); + rules.sort(); + rules +} + +fn canonical_authorization_selector_rules(rules: &[SelectorRule]) -> Vec { + let mut rules = rules + .iter() + .map(|rule| { + let mut recipients = rule.recipients.clone(); + recipients.sort(); + CanonicalSelectorRule { selector: rule.selector(), recipients } + }) + .collect::>(); + rules.sort(); + rules +} + +fn parse_limit(raw: &str) -> eyre::Result { + let raw = raw.trim(); + if let Some(hex) = raw.strip_prefix("0x") { U256::from_str_radix(hex, 16) } else { raw.parse() } + .map_err(|err| eyre::eyre!("invalid session spending limit `{raw}`: {err}")) +} + +/// Add a generated access key to the canonical Accounts store. +pub fn upsert_session_entry(entry: SessionEntry) -> eyre::Result<()> { + let key = entry + .key + .as_ref() + .ok_or_else(|| eyre::eyre!("managed access key has no local signing material"))?; + ensure!(key.key_type == KeyType::Secp256k1, "only secp256k1 managed access keys are supported"); + let signer = foundry_wallets::utils::create_local_signer(&key.key)?; + ensure!( + signer.address() == entry.key_address, + "managed access key resolves to {}, expected {}", + signer.address(), + entry.key_address + ); + let encoded = key + .key_authorization + .as_deref() + .ok_or_else(|| eyre::eyre!("managed access key has no signed authorization"))?; + let authorization = super::decode_key_authorization::(encoded)?; + validate_signed_session_authorization(&entry, SignatureType::Secp256k1, &authorization)?; + TempoAccountsStore::default_path()?.upsert_secp256k1_access_key( + entry.root_account, + &signer, + &authorization, + )?; + Ok(()) +} + +/// Retire local signing material in `store.json` for the selected managed key. +/// +/// The non-secret account, chain, policy, and authorization witness remain available for +/// on-chain revoke retries. +pub fn retire_session_entry(session_id: B256) -> eyre::Result { + let Some(entry) = read_session_entry(session_id)? else { + return Ok(false); + }; + TempoAccountsStore::default_path()? + .retire_access_key(entry.root_account, entry.chain_id, entry.key_address) + .map_err(Into::into) +} + +/// Retire expired managed access keys and return the number changed. +pub fn mark_expired_session_entries(now: u64) -> eyre::Result { + let Some(entries) = read_session_entries(now)? else { + return Ok(0); + }; + let store = TempoAccountsStore::default_path()?; + let mut retired = 0; + for entry in entries { + if entry.status == SessionStatus::Expired + && store.retire_access_key(entry.root_account, entry.chain_id, entry.key_address)? + { + retired += 1; + } + } + Ok(retired) +} + +fn read_session_entries(now: u64) -> eyre::Result>> { + let Some(store) = TempoAccountsStore::try_open_default()? else { + return Ok(None); + }; + let sessions = store + .access_keys()? + .into_iter() + .filter_map(|key| { + let authorization = key.key_authorization()?; + let session_id = key.authorization_witness()?; + let expiry = key.expiry()?; + let status = if expiry <= now { + SessionStatus::Expired + } else if key.is_locally_signable() { + SessionStatus::Active + } else { + SessionStatus::Revoked + }; + Some(SessionEntry { + session_id, + root_account: key.account(), + chain_id: key.chain_id(), + key_address: key.address(), + expiry, + scope: authorization.allowed_calls.as_ref().map(|scopes| { + scopes + .iter() + .map(|scope| SessionCallScope { + target: scope.target(), + selector_rules: scope + .selector_rules + .iter() + .map(|rule| SessionSelectorRule { + selector: rule.selector().into(), + recipients: rule.recipients.clone(), + }) + .collect(), + }) + .collect() + }), + limits: authorization.limits.as_ref().map(|limits| { + limits + .iter() + .map(|limit| SessionTokenLimit { + currency: limit.token, + limit: limit.limit.to_string(), + }) + .collect() + }), + status, + key: None, + }) + }) + .collect(); + Ok(Some(sessions)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn debug_redacts_transient_key_material() { + let key = SessionKeyMaterial { + key_type: KeyType::Secp256k1, + key: "0xPRIVATE_KEY_MUST_NOT_LEAK".into(), + key_authorization: Some("0xAUTH_MUST_NOT_LEAK".into()), + }; + let rendered = format!("{key:?}"); + assert!(!rendered.contains("PRIVATE_KEY_MUST_NOT_LEAK")); + assert!(!rendered.contains("AUTH_MUST_NOT_LEAK")); + assert!(rendered.contains("")); + } +} diff --git a/crates/common/src/tempo/session_policy.rs b/crates/common/src/tempo/session_policy.rs new file mode 100644 index 0000000000000..f4456c8f4d6bd --- /dev/null +++ b/crates/common/src/tempo/session_policy.rs @@ -0,0 +1,471 @@ +//! Pure Tempo session policy construction. + +use super::{ + KeyType, SessionCallScope, SessionEntry, SessionKeyMaterial, SessionSelectorRule, + SessionStatus, SessionTokenLimit, session::validate_signed_session_authorization, +}; +use alloy_primitives::{Address, B256, U256, hex}; +use alloy_rlp::Encodable; +use alloy_signer_local::PrivateKeySigner; +use eyre::ensure; +use std::{fmt, num::NonZeroU64}; +use tempo_primitives::transaction::{ + CallScope, KeyAuthorization, SelectorRule, SignatureType, SignedKeyAuthorization, TokenLimit, +}; + +/// Typed spending limit for a temporary session access key. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SessionSpendLimit { + pub token: Address, + pub amount: U256, +} + +/// Typed inputs needed to authorize a temporary session access key. +/// +/// This intentionally excludes CLI flag grammar, RPC submission, signer selection, and child +/// process lifecycle. Callers supply already-parsed policy values and handle IO separately. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionAuthorizationRequest { + pub session_id: B256, + pub root_account: Address, + pub chain_id: u64, + pub key_address: Address, + pub expiry: NonZeroU64, + pub scope: Vec, + pub spend_limits: Vec, +} + +/// Prepared local session metadata plus the Tempo authorization that the root must sign. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PreparedSessionAuthorization { + pub entry: SessionEntry, + pub authorization: KeyAuthorization, +} + +impl SessionAuthorizationRequest { + /// Validate this request and build the unsigned Tempo [`KeyAuthorization`]. + pub fn prepare(&self, now: u64) -> eyre::Result { + ensure!(!self.session_id.is_zero(), "session id cannot be zero"); + ensure!(!self.root_account.is_zero(), "session root account cannot be zero"); + ensure!(self.chain_id != 0, "session chain id cannot be zero"); + ensure!(!self.key_address.is_zero(), "session key address cannot be zero"); + ensure!( + self.key_address != self.root_account, + "session key address must differ from the root account" + ); + + let expiry = self.expiry.get(); + ensure!( + expiry > now, + "session expiry {expiry} must be greater than current timestamp {now}" + ); + ensure!(!self.scope.is_empty(), "session authorization requires a call scope"); + + let authorization = KeyAuthorization::unrestricted( + self.chain_id, + SignatureType::Secp256k1, + self.key_address, + ) + .with_expiry(expiry) + .with_limits(session_spend_limits_to_authorization(&self.spend_limits)) + .with_allowed_calls(self.scope.clone()) + .with_witness(self.session_id); + + Ok(PreparedSessionAuthorization { + entry: SessionEntry { + session_id: self.session_id, + root_account: self.root_account, + chain_id: self.chain_id, + key_address: self.key_address, + expiry, + scope: Some(session_scopes_to_entry(&self.scope)), + limits: Some(session_spend_limits_to_entry(&self.spend_limits)), + status: SessionStatus::Pending, + key: None, + }, + authorization, + }) + } +} + +impl PreparedSessionAuthorization { + /// Attach key material and a root-signed authorization for the Tempo Accounts store. + pub fn into_active_entry( + mut self, + session_key: GeneratedSessionKey, + signed_authorization: &SignedKeyAuthorization, + ) -> eyre::Result { + ensure!( + session_key.address == self.entry.key_address, + "session key material resolves to {}, expected {}", + session_key.address, + self.entry.key_address + ); + validate_signed_session_authorization( + &self.entry, + SignatureType::Secp256k1, + signed_authorization, + )?; + + let mut buf = Vec::new(); + signed_authorization.encode(&mut buf); + self.entry.status = SessionStatus::Active; + self.entry.key = Some(SessionKeyMaterial { + key_type: KeyType::Secp256k1, + key: session_key.private_key, + key_authorization: Some(hex::encode_prefixed(buf)), + }); + Ok(self.entry) + } +} + +/// Locally generated secp256k1 session key material. +#[derive(Clone, PartialEq, Eq)] +pub struct GeneratedSessionKey { + address: Address, + private_key: String, +} + +impl GeneratedSessionKey { + /// Generate a fresh random secp256k1 session key. + pub fn random() -> Self { + Self::from_signer(&PrivateKeySigner::random()) + } + + /// Build a session key from an existing 32-byte secp256k1 private key. + pub fn from_private_key(private_key: impl AsRef) -> eyre::Result { + let signer = private_key.as_ref().parse::()?; + Ok(Self::from_signer(&signer)) + } + + /// The signer address derived from this session key. + pub const fn address(&self) -> Address { + self.address + } + + /// Hex-encoded 32-byte private key with `0x` prefix. + pub fn private_key(&self) -> &str { + &self.private_key + } + + fn from_signer(signer: &PrivateKeySigner) -> Self { + Self { address: signer.address(), private_key: hex::encode_prefixed(signer.to_bytes()) } + } +} + +impl fmt::Debug for GeneratedSessionKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("GeneratedSessionKey") + .field("address", &self.address) + .finish_non_exhaustive() + } +} + +fn session_spend_limits_to_entry(limits: &[SessionSpendLimit]) -> Vec { + limits + .iter() + .map(|limit| SessionTokenLimit { currency: limit.token, limit: limit.amount.to_string() }) + .collect() +} + +fn session_spend_limits_to_authorization(limits: &[SessionSpendLimit]) -> Vec { + limits + .iter() + .map(|limit| TokenLimit { token: limit.token, limit: limit.amount, period: 0 }) + .collect() +} + +fn session_scopes_to_entry(scope: &[CallScope]) -> Vec { + scope + .iter() + .map(|scope| SessionCallScope { + target: scope.target(), + selector_rules: session_selector_rules_to_entry(&scope.selector_rules), + }) + .collect() +} + +fn session_selector_rules_to_entry(rules: &[SelectorRule]) -> Vec { + rules + .iter() + .map(|rule| SessionSelectorRule { + selector: rule.selector().into(), + recipients: rule.recipients.clone(), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::Selector; + use alloy_signer::SignerSync; + use tempo_primitives::transaction::PrimitiveSignature; + + const ROOT_PRIVATE_KEY: &str = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + const SESSION_PRIVATE_KEY: &str = + "0x59c6995e998f97a5a004497e5da3b5d2b2b66a87f064d39c44da0b6d6e4f8ff0"; + + #[test] + fn prepared_session_authorization_builds_entry_and_key_authorization() { + let session_id = B256::repeat_byte(0x42); + let root = Address::repeat_byte(0x11); + let key = Address::repeat_byte(0x22); + let target = Address::repeat_byte(0x33); + let token = Address::repeat_byte(0x44); + let recipient = Address::repeat_byte(0x55); + + let request = SessionAuthorizationRequest { + session_id, + root_account: root, + chain_id: 4217, + key_address: key, + expiry: NonZeroU64::new(1_700_000_600).unwrap(), + scope: vec![CallScope { + target, + selector_rules: vec![SelectorRule { + selector: [0x12, 0x34, 0x56, 0x78], + recipients: vec![recipient], + }], + }], + spend_limits: vec![SessionSpendLimit { token, amount: U256::ZERO }], + }; + + let prepared = request.prepare(1_700_000_000).unwrap(); + + assert_eq!(prepared.entry.session_id, session_id); + assert_eq!(prepared.entry.root_account, root); + assert_eq!(prepared.entry.chain_id, 4217); + assert_eq!(prepared.entry.key_address, key); + assert_eq!(prepared.entry.expiry, 1_700_000_600); + assert_eq!(prepared.entry.status, SessionStatus::Pending); + assert!(prepared.entry.key.is_none()); + assert_eq!( + prepared.entry.scope, + Some(vec![SessionCallScope { + target, + selector_rules: vec![SessionSelectorRule { + selector: Selector::from_slice(&[0x12, 0x34, 0x56, 0x78]), + recipients: vec![recipient], + }], + }]) + ); + assert_eq!( + prepared.entry.limits, + Some(vec![SessionTokenLimit { currency: token, limit: "0".to_string() }]) + ); + + assert_eq!(prepared.authorization.chain_id, 4217); + assert_eq!(prepared.authorization.key_type, SignatureType::Secp256k1); + assert_eq!(prepared.authorization.key_id, key); + assert_eq!(prepared.authorization.expiry.map(NonZeroU64::get), Some(1_700_000_600)); + assert_eq!(prepared.authorization.witness(), Some(session_id)); + assert_eq!( + prepared.authorization.limits, + Some(vec![TokenLimit { token, limit: U256::ZERO, period: 0 }]) + ); + assert_eq!( + prepared.authorization.allowed_calls, + Some(vec![CallScope { + target, + selector_rules: vec![SelectorRule { + selector: [0x12, 0x34, 0x56, 0x78], + recipients: vec![recipient], + }], + }]) + ); + } + + #[test] + fn prepared_session_authorization_rejects_invalid_policy() { + let base = SessionAuthorizationRequest { + session_id: B256::repeat_byte(0x42), + root_account: Address::repeat_byte(0x11), + chain_id: 4217, + key_address: Address::repeat_byte(0x22), + expiry: NonZeroU64::new(1_700_000_600).unwrap(), + scope: vec![CallScope { target: Address::repeat_byte(0x33), selector_rules: vec![] }], + spend_limits: vec![], + }; + + let mut expired = base.clone(); + expired.expiry = NonZeroU64::new(1_700_000_000).unwrap(); + assert!(expired.prepare(1_700_000_000).is_err()); + + let mut no_scope = base.clone(); + no_scope.scope = vec![]; + let error = no_scope.prepare(1_700_000_000).unwrap_err(); + assert!(error.to_string().contains("call scope")); + + let mut zero_root = base.clone(); + zero_root.root_account = Address::ZERO; + assert!(zero_root.prepare(1_700_000_000).is_err()); + + let mut zero_key = base.clone(); + zero_key.key_address = Address::ZERO; + assert!(zero_key.prepare(1_700_000_000).is_err()); + + let mut root_key = base; + root_key.key_address = root_key.root_account; + assert!(root_key.prepare(1_700_000_000).is_err()); + } + + #[test] + fn signed_session_authorization_activates_entry_with_key_material() { + let root: PrivateKeySigner = ROOT_PRIVATE_KEY.parse().unwrap(); + let session_key = GeneratedSessionKey::from_private_key(SESSION_PRIVATE_KEY).unwrap(); + let request = SessionAuthorizationRequest { + session_id: B256::repeat_byte(0x66), + root_account: root.address(), + chain_id: 4217, + key_address: session_key.address(), + expiry: NonZeroU64::new(1_700_000_600).unwrap(), + scope: vec![CallScope { target: Address::repeat_byte(0x33), selector_rules: vec![] }], + spend_limits: vec![], + }; + let prepared = request.prepare(1_700_000_000).unwrap(); + let signature = root.sign_hash_sync(&prepared.authorization.signature_hash()).unwrap(); + let signed = + prepared.authorization.clone().into_signed(PrimitiveSignature::Secp256k1(signature)); + + let entry = prepared.into_active_entry(session_key, &signed).unwrap(); + + assert_eq!(entry.status, SessionStatus::Active); + let key = entry.key.unwrap(); + assert_eq!(key.key_type, KeyType::Secp256k1); + assert_eq!(key.key, SESSION_PRIVATE_KEY); + assert!(key.key_authorization.unwrap().starts_with("0x")); + } + + #[test] + fn prepared_session_authorization_enforces_empty_spend_policy() { + let target = Address::repeat_byte(0x33); + let request = SessionAuthorizationRequest { + session_id: B256::repeat_byte(0x68), + root_account: Address::repeat_byte(0x11), + chain_id: 4217, + key_address: Address::repeat_byte(0x22), + expiry: NonZeroU64::new(1_700_000_600).unwrap(), + scope: vec![CallScope { target, selector_rules: vec![] }], + spend_limits: vec![], + }; + + let prepared = request.prepare(1_700_000_000).unwrap(); + + assert_eq!( + prepared.entry.scope, + Some(vec![SessionCallScope { target, selector_rules: vec![] }]) + ); + assert_eq!(prepared.entry.limits, Some(vec![])); + assert_eq!( + prepared.authorization.allowed_calls, + Some(vec![CallScope { target, selector_rules: vec![] }]) + ); + assert_eq!(prepared.authorization.limits, Some(vec![])); + } + + #[test] + fn signed_session_authorization_rejects_policy_mismatch() { + let root: PrivateKeySigner = ROOT_PRIVATE_KEY.parse().unwrap(); + let session_key = GeneratedSessionKey::from_private_key(SESSION_PRIVATE_KEY).unwrap(); + let token = Address::repeat_byte(0x44); + let request = SessionAuthorizationRequest { + session_id: B256::repeat_byte(0x67), + root_account: root.address(), + chain_id: 4217, + key_address: session_key.address(), + expiry: NonZeroU64::new(1_700_000_600).unwrap(), + scope: vec![CallScope { target: Address::repeat_byte(0x33), selector_rules: vec![] }], + spend_limits: vec![SessionSpendLimit { token, amount: U256::ZERO }], + }; + let prepared = request.prepare(1_700_000_000).unwrap(); + let mut authorization = prepared.authorization.clone(); + authorization.limits = None; + let signature = root.sign_hash_sync(&authorization.signature_hash()).unwrap(); + let signed = authorization.into_signed(PrimitiveSignature::Secp256k1(signature)); + + let error = prepared.into_active_entry(session_key, &signed).unwrap_err(); + + assert!(error.to_string().contains("limits")); + } + + #[test] + fn signed_session_authorization_rejects_session_id_mismatch() { + let root: PrivateKeySigner = ROOT_PRIVATE_KEY.parse().unwrap(); + let session_key = GeneratedSessionKey::from_private_key(SESSION_PRIVATE_KEY).unwrap(); + let request = SessionAuthorizationRequest { + session_id: B256::repeat_byte(0x70), + root_account: root.address(), + chain_id: 4217, + key_address: session_key.address(), + expiry: NonZeroU64::new(1_700_000_600).unwrap(), + scope: vec![CallScope { target: Address::repeat_byte(0x33), selector_rules: vec![] }], + spend_limits: vec![], + }; + let prepared = request.prepare(1_700_000_000).unwrap(); + let signature = root.sign_hash_sync(&prepared.authorization.signature_hash()).unwrap(); + let signed = prepared.authorization.into_signed(PrimitiveSignature::Secp256k1(signature)); + + let mut other_request = request; + other_request.session_id = B256::repeat_byte(0x71); + let other_prepared = other_request.prepare(1_700_000_000).unwrap(); + + let error = other_prepared.into_active_entry(session_key, &signed).unwrap_err(); + + assert!(error.to_string().contains("witness")); + } + + #[test] + fn signed_session_authorization_accepts_order_independent_policy_match() { + let root: PrivateKeySigner = ROOT_PRIVATE_KEY.parse().unwrap(); + let session_key = GeneratedSessionKey::from_private_key(SESSION_PRIVATE_KEY).unwrap(); + let token_a = Address::repeat_byte(0x44); + let token_b = Address::repeat_byte(0x45); + let target_a = Address::repeat_byte(0x46); + let target_b = Address::repeat_byte(0x47); + let recipient_a = Address::repeat_byte(0x48); + let recipient_b = Address::repeat_byte(0x49); + let request = SessionAuthorizationRequest { + session_id: B256::repeat_byte(0x69), + root_account: root.address(), + chain_id: 4217, + key_address: session_key.address(), + expiry: NonZeroU64::new(1_700_000_600).unwrap(), + scope: vec![ + CallScope { + target: target_a, + selector_rules: vec![SelectorRule { + selector: [0x12, 0x34, 0x56, 0x78], + recipients: vec![recipient_a, recipient_b], + }], + }, + CallScope { target: target_b, selector_rules: vec![] }, + ], + spend_limits: vec![ + SessionSpendLimit { token: token_a, amount: U256::ONE }, + SessionSpendLimit { token: token_b, amount: U256::from(2) }, + ], + }; + let prepared = request.prepare(1_700_000_000).unwrap(); + let mut authorization = prepared.authorization.clone(); + authorization.limits.as_mut().unwrap().reverse(); + authorization.allowed_calls.as_mut().unwrap().reverse(); + authorization.allowed_calls.as_mut().unwrap()[1].selector_rules[0].recipients.reverse(); + let signature = root.sign_hash_sync(&authorization.signature_hash()).unwrap(); + let signed = authorization.into_signed(PrimitiveSignature::Secp256k1(signature)); + + let entry = prepared.into_active_entry(session_key, &signed).unwrap(); + + assert_eq!(entry.status, SessionStatus::Active); + } + + #[test] + fn generated_session_key_roundtrips_without_debug_leaking_private_key() { + let session_key = GeneratedSessionKey::from_private_key(SESSION_PRIVATE_KEY).unwrap(); + + assert_eq!(session_key.private_key(), SESSION_PRIVATE_KEY); + assert_ne!(session_key.address(), Address::ZERO); + assert!(!format!("{session_key:?}").contains(SESSION_PRIVATE_KEY)); + } +} diff --git a/crates/common/src/tempo/test_utils.rs b/crates/common/src/tempo/test_utils.rs new file mode 100644 index 0000000000000..66994b8db7ae3 --- /dev/null +++ b/crates/common/src/tempo/test_utils.rs @@ -0,0 +1,8 @@ +/// Process-wide mutex used by tests that mutate `TEMPO_HOME`. +/// +/// Returns a [`tokio::sync::Mutex`] so async tests can hold it across `.await` +/// points without tripping `clippy::await_holding_lock`. +pub(crate) fn test_env_mutex() -> &'static tokio::sync::Mutex<()> { + static M: std::sync::OnceLock> = std::sync::OnceLock::new(); + M.get_or_init(|| tokio::sync::Mutex::new(())) +} diff --git a/crates/common/src/tempo/tests.rs b/crates/common/src/tempo/tests.rs new file mode 100644 index 0000000000000..2ac7334d4278b --- /dev/null +++ b/crates/common/src/tempo/tests.rs @@ -0,0 +1,678 @@ +use alloy_provider::{Provider, ProviderBuilder}; +use eyre::WrapErr; +use foundry_evm_hardforks::TempoHardfork; +use serde::Deserialize; +use std::env; + +use crate::FoundryTransactionBuilder; +use alloy_network::{Ethereum, TransactionBuilder}; +use alloy_primitives::{Address, Bytes, TxKind, U256, address}; +use alloy_provider::mock::Asserter; +use alloy_rpc_types::TransactionRequest; +use alloy_sol_types::{SolCall, SolValue}; +use tempo_alloy::{ + TempoNetwork, + contracts::precompiles::{IFeeManager, IStablecoinDEX, ITIP20, STABLECOIN_DEX_ADDRESS}, + rpc::TempoTransactionRequest, +}; +use tempo_primitives::transaction::{Call, SignatureType}; + +use super::{ + ALPHA_USD_ADDRESS, BETA_USD_ADDRESS, OUSD_ADDRESS, PATH_USD_ADDRESS, THETA_USD_ADDRESS, + TIP_FEE_MANAGER_ADDRESS, TempoSponsor, known_fee_token_symbol, resolve_and_set_fee_token, + resolve_fee_token_symbol, +}; + +#[test] +fn browser_gas_estimation_uses_conservative_webauthn_hint() { + let request = TempoTransactionRequest { + inner: TransactionRequest { + to: Some(TxKind::Create), + value: Some(U256::from(7)), + input: vec![0x60, 0x00].into(), + ..Default::default() + }, + ..Default::default() + }; + let estimate_request = request.browser_wallet_gas_estimation_request(); + + assert_eq!(estimate_request.key_type, Some(SignatureType::WebAuthn)); + assert_eq!(estimate_request.key_data, Some(Bytes::from_static(&[0x05, 0x78]))); + let json = serde_json::to_value(&estimate_request).unwrap(); + assert_eq!(json["keyType"], "webAuthn"); + assert_eq!(json["keyData"], "0x0578"); + assert_eq!( + estimate_request.calls, + vec![Call { to: TxKind::Create, value: U256::from(7), input: vec![0x60, 0x00].into() }] + ); + assert_eq!(estimate_request.inner.to, None); + assert_eq!(estimate_request.inner.value, None); + assert_eq!(estimate_request.inner.input.input(), None); + assert_eq!(request.key_type, None); + assert_eq!(request.key_data, None); + assert_eq!(request.inner.to, Some(TxKind::Create)); +} + +#[test] +fn browser_gas_estimation_preserves_complete_signer_hints() { + for key_type in [SignatureType::Secp256k1, SignatureType::P256, SignatureType::WebAuthn] { + let request = TempoTransactionRequest { + key_type: Some(key_type), + key_data: Some(Bytes::from_static(&[0x01])), + ..Default::default() + }; + let estimate_request = request.browser_wallet_gas_estimation_request(); + assert_eq!(estimate_request.key_type, request.key_type); + assert_eq!(estimate_request.key_data, request.key_data); + } +} + +#[test] +fn browser_gas_estimation_fills_missing_webauthn_key_data() { + let request = + TempoTransactionRequest { key_type: Some(SignatureType::WebAuthn), ..Default::default() }; + let estimate_request = request.browser_wallet_gas_estimation_request(); + assert_eq!(estimate_request.key_type, request.key_type); + assert_eq!(estimate_request.key_data, Some(Bytes::from_static(&[0x05, 0x78]))); +} + +#[test] +fn browser_gas_estimation_replaces_key_data_without_key_type() { + let request = TempoTransactionRequest { + key_data: Some(Bytes::from_static(&[0x01])), + ..Default::default() + }; + let estimate_request = request.browser_wallet_gas_estimation_request(); + assert_eq!(estimate_request.key_type, Some(SignatureType::WebAuthn)); + assert_eq!(estimate_request.key_data, Some(Bytes::from_static(&[0x05, 0x78]))); +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct TempoForkSchedule { + schedule: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ForkInfo { + name: String, +} + +async fn check_fork_schedule(rpc_url: &str) -> eyre::Result<()> { + let provider = ProviderBuilder::new().connect_http(rpc_url.parse()?); + let schedule: TempoForkSchedule = provider.raw_request("tempo_forkSchedule".into(), ()).await?; + for fork in &schedule.schedule { + fork.name.parse::()?; + } + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fork_schedule_parses_configured_rpcs() -> eyre::Result<()> { + let mut checked_any = false; + + for (network, env_key) in [ + ("mainnet", "TEMPO_MAINNET_RPC_URL"), + ("testnet", "TEMPO_TESTNET_RPC_URL"), + ("devnet", "TEMPO_DEVNET_RPC_URL"), + ] { + let rpc_url = match env::var(env_key) { + Ok(url) if !url.is_empty() => url, + _ => continue, + }; + checked_any = true; + + check_fork_schedule(&rpc_url) + .await + .wrap_err_with(|| format!("[{network}] {env_key}={rpc_url}"))?; + } + + if !checked_any { + let _ = crate::sh_eprintln!( + "Missing Tempo RPC env vars. Skipping Tempo fork schedule compatibility test." + ); + } + + Ok(()) +} + +#[tokio::test] +async fn explicit_fee_token_overrides_stored_user_token_when_applied() -> eyre::Result<()> { + let asserter = Asserter::new(); + let provider = + ProviderBuilder::new_with_network::().connect_mocked_client(asserter); + let explicit = Address::repeat_byte(0x42); + let fee_payer = Address::repeat_byte(0x11); + let mut tx = TempoTransactionRequest { + inner: TransactionRequest::default().with_from(fee_payer), + fee_token: Some(explicit), + ..Default::default() + }; + + assert_eq!( + resolve_and_set_fee_token::(Some(&provider), &mut tx, Some(fee_payer),) + .await?, + Some(explicit) + ); + assert_eq!(tx.fee_token, Some(explicit)); + Ok(()) +} + +#[tokio::test] +async fn default_fee_token_resolution_leaves_transaction_fee_token_unset() -> eyre::Result<()> { + let explicit = Address::repeat_byte(0x42); + let mut tx = TempoTransactionRequest { fee_token: Some(explicit), ..Default::default() }; + + let resolved = resolve_and_set_fee_token::(None, &mut tx, None).await?; + assert_eq!(resolved, Some(explicit)); + assert_eq!(tx.fee_token, Some(explicit)); + + let mut tx = TempoTransactionRequest::default(); + let resolved = resolve_and_set_fee_token::(None, &mut tx, None).await?; + assert_eq!(resolved, None); + assert_eq!(tx.fee_token, None); + Ok(()) +} + +#[tokio::test] +async fn send_fee_token_resolution_can_skip_lookup_for_curl_mode() -> eyre::Result<()> { + let asserter = Asserter::new(); + let provider = + ProviderBuilder::new_with_network::().connect_mocked_client(asserter.clone()); + let fee_payer = Address::repeat_byte(0x11); + let mut tx = TempoTransactionRequest::default(); + + asserter.push_success(&BETA_USD_ADDRESS.abi_encode()); + assert_eq!( + resolve_and_set_fee_token::(Some(&provider), &mut tx, Some(fee_payer),) + .await?, + Some(BETA_USD_ADDRESS) + ); + assert_eq!(tx.fee_token, Some(BETA_USD_ADDRESS)); + + let mut tx = TempoTransactionRequest::default(); + assert_eq!( + resolve_and_set_fee_token::(None, &mut tx, Some(fee_payer),).await?, + None + ); + assert_eq!(tx.fee_token, None); + + Ok(()) +} + +#[tokio::test] +async fn unset_user_token_does_not_stamp_default_fee_token() -> eyre::Result<()> { + let asserter = Asserter::new(); + let provider = + ProviderBuilder::new_with_network::().connect_mocked_client(asserter.clone()); + let fee_payer = Address::repeat_byte(0x11); + let mut tx = TempoTransactionRequest::default(); + + asserter.push_success(&Address::ZERO.abi_encode()); + + assert_eq!( + resolve_and_set_fee_token::(Some(&provider), &mut tx, Some(fee_payer),) + .await?, + None + ); + assert_eq!(tx.fee_token, None); + + Ok(()) +} + +#[tokio::test] +async fn contract_creation_does_not_stamp_stored_fee_token() -> eyre::Result<()> { + let asserter = Asserter::new(); + let provider = + ProviderBuilder::new_with_network::().connect_mocked_client(asserter); + let fee_payer = Address::repeat_byte(0x11); + let mut tx = TempoTransactionRequest { + inner: TransactionRequest { + from: Some(fee_payer), + to: Some(TxKind::Create), + input: vec![0x60, 0x00].into(), + ..Default::default() + }, + ..Default::default() + }; + + assert_eq!( + resolve_and_set_fee_token::(Some(&provider), &mut tx, Some(fee_payer),) + .await?, + None + ); + assert_eq!(tx.fee_token, None); + Ok(()) +} + +#[tokio::test] +async fn sponsor_fee_token_resolution_uses_sponsor_address() -> eyre::Result<()> { + let asserter = Asserter::new(); + let provider = + ProviderBuilder::new_with_network::().connect_mocked_client(asserter.clone()); + let sponsor = TempoSponsor::new(Address::repeat_byte(0x22), None, None); + let sender = Address::repeat_byte(0x11); + let mut tx = TempoTransactionRequest { + inner: TransactionRequest::default().with_from(sender), + ..Default::default() + }; + + asserter.push_success(&BETA_USD_ADDRESS.abi_encode()); + + assert_eq!( + sponsor.resolve_and_set_fee_token::(Some(&provider), &mut tx,).await?, + Some(BETA_USD_ADDRESS) + ); + assert_eq!(tx.fee_token, Some(BETA_USD_ADDRESS)); + Ok(()) +} + +#[tokio::test] +async fn sponsor_fee_token_resolution_preserves_explicit_token() -> eyre::Result<()> { + let asserter = Asserter::new(); + let provider = + ProviderBuilder::new_with_network::().connect_mocked_client(asserter); + let explicit = Address::repeat_byte(0x42); + let sponsor = TempoSponsor::new(Address::repeat_byte(0x22), None, None); + let mut tx = TempoTransactionRequest { fee_token: Some(explicit), ..Default::default() }; + + assert_eq!( + sponsor.resolve_and_set_fee_token::(Some(&provider), &mut tx,).await?, + Some(explicit) + ); + assert_eq!(tx.fee_token, Some(explicit)); + Ok(()) +} + +#[tokio::test] +async fn distribute_reward_does_not_infer_fee_token() -> eyre::Result<()> { + let mut tx = TempoTransactionRequest { + inner: TransactionRequest::default() + .with_to(ALPHA_USD_ADDRESS) + .with_input(ITIP20::distributeRewardCall { amount: U256::ONE }.abi_encode()), + ..Default::default() + }; + + assert_eq!(resolve_and_set_fee_token::(None, &mut tx, None,).await?, None); + assert_eq!(tx.fee_token, None); + Ok(()) +} + +#[tokio::test] +async fn tip20_transfer_calls_infer_called_token() -> eyre::Result<()> { + for input in [ + ITIP20::transferCall { to: Address::repeat_byte(0x01), amount: U256::ONE }.abi_encode(), + ITIP20::transferWithMemoCall { + to: Address::repeat_byte(0x01), + amount: U256::ONE, + memo: Default::default(), + } + .abi_encode(), + ] { + let mut tx = TempoTransactionRequest { + inner: TransactionRequest::default().with_to(ALPHA_USD_ADDRESS).with_input(input), + ..Default::default() + }; + + assert_eq!( + resolve_and_set_fee_token::(None, &mut tx, None,).await?, + Some(ALPHA_USD_ADDRESS) + ); + assert_eq!(tx.fee_token, Some(ALPHA_USD_ADDRESS)); + } + Ok(()) +} + +#[tokio::test] +async fn sponsored_single_tip20_call_does_not_infer_called_token() -> eyre::Result<()> { + let sender = Address::repeat_byte(0x11); + let sponsor = Address::repeat_byte(0x22); + let mut tx = TempoTransactionRequest { + inner: TransactionRequest::default() + .with_from(sender) + .with_to(ALPHA_USD_ADDRESS) + .with_input( + ITIP20::transferCall { to: Address::repeat_byte(0x01), amount: U256::ONE } + .abi_encode(), + ), + ..Default::default() + }; + + assert_eq!( + resolve_and_set_fee_token::(None, &mut tx, Some(sponsor),).await?, + None + ); + assert_eq!(tx.fee_token, None); + Ok(()) +} + +#[tokio::test] +async fn non_matching_tip20_selectors_do_not_infer_fee_token() -> eyre::Result<()> { + let mut tx = TempoTransactionRequest { + inner: TransactionRequest::default().with_to(ALPHA_USD_ADDRESS).with_input( + ITIP20::approveCall { spender: Address::repeat_byte(0x01), amount: U256::ONE } + .abi_encode(), + ), + ..Default::default() + }; + + assert_eq!(resolve_and_set_fee_token::(None, &mut tx, None,).await?, None); + assert_eq!(tx.fee_token, None); + Ok(()) +} + +#[tokio::test] +async fn self_paid_set_user_token_overrides_stored_fee_token() -> eyre::Result<()> { + let asserter = Asserter::new(); + let provider = + ProviderBuilder::new_with_network::().connect_mocked_client(asserter); + let sender = Address::repeat_byte(0x11); + let mut tx = TempoTransactionRequest { + inner: TransactionRequest::default() + .with_from(sender) + .with_to(TIP_FEE_MANAGER_ADDRESS) + .with_input(IFeeManager::setUserTokenCall { token: ALPHA_USD_ADDRESS }.abi_encode()), + ..Default::default() + }; + + assert_eq!( + resolve_and_set_fee_token::(Some(&provider), &mut tx, Some(sender),).await?, + Some(ALPHA_USD_ADDRESS) + ); + assert_eq!(tx.fee_token, Some(ALPHA_USD_ADDRESS)); + Ok(()) +} + +#[tokio::test] +async fn aa_set_user_token_is_not_inferred() -> eyre::Result<()> { + let sender = Address::repeat_byte(0x11); + let mut tx = TempoTransactionRequest { + inner: TransactionRequest::default() + .with_from(sender) + .with_to(TIP_FEE_MANAGER_ADDRESS) + .with_input(IFeeManager::setUserTokenCall { token: ALPHA_USD_ADDRESS }.abi_encode()), + nonce_key: Some(U256::from(7)), + ..Default::default() + }; + + assert_eq!( + resolve_and_set_fee_token::(None, &mut tx, Some(sender),).await?, + None + ); + assert_eq!(tx.fee_token, None); + Ok(()) +} + +#[tokio::test] +async fn tip20_batch_infers_only_when_calls_match_sender_and_token() -> eyre::Result<()> { + let sender = Address::repeat_byte(0x11); + let transfer = + ITIP20::transferCall { to: Address::repeat_byte(0x01), amount: U256::ONE }.abi_encode(); + let transfer_with_memo = ITIP20::transferWithMemoCall { + to: Address::repeat_byte(0x01), + amount: U256::ONE, + memo: Default::default(), + } + .abi_encode(); + + let mut tx = TempoTransactionRequest { + inner: TransactionRequest::default().with_from(sender), + calls: vec![ + tempo_call(ALPHA_USD_ADDRESS, transfer.clone()), + tempo_call(ALPHA_USD_ADDRESS, transfer_with_memo.clone()), + ], + ..Default::default() + }; + assert_eq!( + resolve_and_set_fee_token::(None, &mut tx, Some(sender),).await?, + Some(ALPHA_USD_ADDRESS) + ); + assert_eq!(tx.fee_token, Some(ALPHA_USD_ADDRESS)); + + let mut tx = TempoTransactionRequest { + inner: TransactionRequest::default().with_from(sender), + calls: vec![ + tempo_call(ALPHA_USD_ADDRESS, transfer.clone()), + tempo_call(BETA_USD_ADDRESS, transfer_with_memo.clone()), + ], + ..Default::default() + }; + assert_eq!( + resolve_and_set_fee_token::(None, &mut tx, Some(sender),).await?, + None + ); + + let mut tx = TempoTransactionRequest { + inner: TransactionRequest::default().with_from(sender), + calls: vec![ + tempo_call(ALPHA_USD_ADDRESS, transfer), + tempo_call(ALPHA_USD_ADDRESS, vec![0xde, 0xad, 0xbe, 0xef]), + ], + ..Default::default() + }; + assert_eq!( + resolve_and_set_fee_token::(None, &mut tx, Some(sender),).await?, + None + ); + + let mut tx = TempoTransactionRequest { + inner: TransactionRequest::default().with_from(sender), + calls: vec![tempo_call(ALPHA_USD_ADDRESS, transfer_with_memo)], + ..Default::default() + }; + assert_eq!( + resolve_and_set_fee_token::(None, &mut tx, Some(Address::repeat_byte(0x22)),) + .await?, + None + ); + Ok(()) +} + +#[tokio::test] +async fn non_tip20_transfer_is_not_inferred() -> eyre::Result<()> { + let sender = Address::repeat_byte(0x11); + let erc20 = Address::repeat_byte(0xab); + let mut tx = TempoTransactionRequest { + inner: TransactionRequest::default().with_from(sender).with_to(erc20).with_input( + ITIP20::transferCall { to: Address::repeat_byte(0x01), amount: U256::ONE }.abi_encode(), + ), + ..Default::default() + }; + + assert_eq!( + resolve_and_set_fee_token::(None, &mut tx, Some(sender),).await?, + None + ); + assert_eq!(tx.fee_token, None); + Ok(()) +} + +#[tokio::test] +async fn tempo_call_inspection_matches_built_aa_call_list() -> eyre::Result<()> { + let transfer = + ITIP20::transferCall { to: Address::repeat_byte(0x01), amount: U256::ONE }.abi_encode(); + let transfer_with_memo = ITIP20::transferWithMemoCall { + to: Address::repeat_byte(0x01), + amount: U256::ONE, + memo: Default::default(), + } + .abi_encode(); + let mut tx = TempoTransactionRequest { + inner: TransactionRequest::default().with_to(ALPHA_USD_ADDRESS).with_input(transfer), + calls: vec![tempo_call(ALPHA_USD_ADDRESS, transfer_with_memo)], + ..Default::default() + }; + + let calls = tx.tempo_calls(); + assert_eq!(calls.len(), 2); + assert!(calls.iter().all(|(to, _)| *to == TxKind::Call(ALPHA_USD_ADDRESS))); + assert_eq!( + resolve_and_set_fee_token::(None, &mut tx, None,).await?, + Some(ALPHA_USD_ADDRESS) + ); + assert_eq!(tx.fee_token, Some(ALPHA_USD_ADDRESS)); + Ok(()) +} + +#[tokio::test] +async fn non_tip20_stablecoin_dex_token_in_is_not_inferred() -> eyre::Result<()> { + let erc20 = Address::repeat_byte(0xab); + let mut tx = TempoTransactionRequest { + inner: TransactionRequest::default().with_to(STABLECOIN_DEX_ADDRESS).with_input( + IStablecoinDEX::swapExactAmountInCall { + tokenIn: erc20, + tokenOut: BETA_USD_ADDRESS, + amountIn: 1, + minAmountOut: 1, + } + .abi_encode(), + ), + ..Default::default() + }; + + assert_eq!(resolve_and_set_fee_token::(None, &mut tx, None,).await?, None); + assert_eq!(tx.fee_token, None); + Ok(()) +} + +#[tokio::test] +async fn stablecoin_dex_swaps_infer_token_in() -> eyre::Result<()> { + for input in [ + IStablecoinDEX::swapExactAmountInCall { + tokenIn: ALPHA_USD_ADDRESS, + tokenOut: BETA_USD_ADDRESS, + amountIn: 1, + minAmountOut: 1, + } + .abi_encode(), + IStablecoinDEX::swapExactAmountOutCall { + tokenIn: ALPHA_USD_ADDRESS, + tokenOut: BETA_USD_ADDRESS, + amountOut: 1, + maxAmountIn: 1, + } + .abi_encode(), + ] { + let mut tx = TempoTransactionRequest { + inner: TransactionRequest::default().with_to(STABLECOIN_DEX_ADDRESS).with_input(input), + ..Default::default() + }; + + assert_eq!( + resolve_and_set_fee_token::(None, &mut tx, None,).await?, + Some(ALPHA_USD_ADDRESS) + ); + assert_eq!(tx.fee_token, Some(ALPHA_USD_ADDRESS)); + } + Ok(()) +} + +#[tokio::test] +async fn stablecoin_dex_batch_inference_requires_one_call() -> eyre::Result<()> { + let input = IStablecoinDEX::swapExactAmountInCall { + tokenIn: ALPHA_USD_ADDRESS, + tokenOut: BETA_USD_ADDRESS, + amountIn: 1, + minAmountOut: 1, + } + .abi_encode(); + + let mut tx = TempoTransactionRequest { + calls: vec![tempo_call(STABLECOIN_DEX_ADDRESS, input.clone())], + ..Default::default() + }; + assert_eq!( + resolve_and_set_fee_token::(None, &mut tx, None,).await?, + Some(ALPHA_USD_ADDRESS) + ); + + let mut tx = TempoTransactionRequest { + calls: vec![ + tempo_call(STABLECOIN_DEX_ADDRESS, input.clone()), + tempo_call(STABLECOIN_DEX_ADDRESS, input), + ], + ..Default::default() + }; + assert_eq!(resolve_and_set_fee_token::(None, &mut tx, None,).await?, None); + assert_eq!(tx.fee_token, None); + Ok(()) +} + +#[tokio::test] +async fn stored_fee_token_overrides_inferred_fee_token() -> eyre::Result<()> { + let asserter = Asserter::new(); + let provider = + ProviderBuilder::new_with_network::().connect_mocked_client(asserter.clone()); + let fee_payer = Address::repeat_byte(0x11); + let mut tx = TempoTransactionRequest { + inner: TransactionRequest::default() + .with_from(fee_payer) + .with_to(ALPHA_USD_ADDRESS) + .with_input( + ITIP20::transferCall { to: Address::repeat_byte(0x01), amount: U256::ONE } + .abi_encode(), + ), + ..Default::default() + }; + + asserter.push_success(&BETA_USD_ADDRESS.abi_encode()); + assert_eq!( + resolve_and_set_fee_token::(Some(&provider), &mut tx, Some(fee_payer),) + .await?, + Some(BETA_USD_ADDRESS) + ); + assert_eq!(tx.fee_token, Some(BETA_USD_ADDRESS)); + Ok(()) +} + +// No responses are queued, so a fee-token lookup would fail this test. +#[tokio::test] +async fn non_tempo_requests_do_not_resolve_fee_token() -> eyre::Result<()> { + let provider = ProviderBuilder::new().connect_mocked_client(Asserter::new()); + let fee_payer = Address::repeat_byte(0x11); + let mut tx = + TransactionRequest::default().with_from(fee_payer).with_to(ALPHA_USD_ADDRESS).with_input( + ITIP20::transferCall { to: Address::repeat_byte(0x01), amount: U256::ONE }.abi_encode(), + ); + + assert_eq!( + resolve_and_set_fee_token::(Some(&provider), &mut tx, Some(fee_payer)).await?, + None + ); + Ok(()) +} + +fn tempo_call(to: Address, input: Vec) -> Call { + Call { to: TxKind::Call(to), value: U256::ZERO, input: input.into() } +} + +#[test] +fn resolves_known_fee_token_symbols() { + for (fee_token, symbol) in [ + (PATH_USD_ADDRESS, "PathUSD"), + (ALPHA_USD_ADDRESS, "AlphaUSD"), + (BETA_USD_ADDRESS, "BetaUSD"), + (THETA_USD_ADDRESS, "ThetaUSD"), + (OUSD_ADDRESS, "OUSD"), + ] { + assert_eq!(known_fee_token_symbol(fee_token), Some(symbol)); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn resolves_fee_token_symbol_from_tempo_mainnet() -> eyre::Result<()> { + let provider = ProviderBuilder::new_with_network::() + .connect_http("https://rpc.tempo.xyz".parse()?); + let valid_fee_token = address!("0x20C00000000000000000000014f22CA97301EB73"); + + assert_eq!( + resolve_fee_token_symbol(&provider, valid_fee_token).await.as_deref(), + Some("USDT0") + ); + + // Non-existent fee token should not cause an error, but return None + let invalid_fee_token = address!("0x20C0000000000000000000000000000000000004"); + assert_eq!(resolve_fee_token_symbol(&provider, invalid_fee_token).await.as_deref(), None); + Ok(()) +} diff --git a/crates/common/src/tempo/tip20.rs b/crates/common/src/tempo/tip20.rs new file mode 100644 index 0000000000000..72fa330fa3f1d --- /dev/null +++ b/crates/common/src/tempo/tip20.rs @@ -0,0 +1,103 @@ +//! Shared TIP-20 helpers. + +use std::fmt; + +pub const TIP20_MAX_LOGO_URI_BYTES: usize = 256; +pub const TIP20_ALLOWED_LOGO_URI_SCHEMES: &[&str] = &["https", "http", "ipfs", "data"]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Tip20LogoUriValidationError { + LogoURITooLong, + InvalidLogoURI, +} + +impl fmt::Display for Tip20LogoUriValidationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::LogoURITooLong => f.write_str("LogoURITooLong"), + Self::InvalidLogoURI => f.write_str("InvalidLogoURI"), + } + } +} + +impl std::error::Error for Tip20LogoUriValidationError {} + +pub fn validate_tip20_logo_uri(uri: &str) -> Result<(), Tip20LogoUriValidationError> { + if uri.len() > TIP20_MAX_LOGO_URI_BYTES { + return Err(Tip20LogoUriValidationError::LogoURITooLong); + } + + if uri.is_empty() { + return Ok(()); + } + + let Some((scheme, _)) = uri.split_once(':') else { + return Err(Tip20LogoUriValidationError::InvalidLogoURI); + }; + + let mut bytes = scheme.bytes(); + if !bytes.next().is_some_and(|b| b.is_ascii_alphabetic()) + || !bytes.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.')) + || !TIP20_ALLOWED_LOGO_URI_SCHEMES + .iter() + .any(|allowed| scheme.eq_ignore_ascii_case(allowed)) + { + return Err(Tip20LogoUriValidationError::InvalidLogoURI); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validates_empty_and_allowed_schemes_case_insensitively() { + for uri in [ + "", + "https://example.com/logo.png", + "HTTP://example.com/logo.png", + "ipfs://bafybeigdyrzt", + "DATA:image/png;base64,abcd", + ] { + validate_tip20_logo_uri(uri).unwrap(); + } + } + + #[test] + fn rejects_invalid_schemes_and_overlong_values() { + for uri in [ + "ftp://example.com/logo.png", + "1https://example.com/logo.png", + "https+foo://example.com/logo.png", + "example.com/logo.png", + ] { + assert_eq!( + validate_tip20_logo_uri(uri).unwrap_err(), + Tip20LogoUriValidationError::InvalidLogoURI + ); + } + + assert_eq!( + validate_tip20_logo_uri(&format!("https://{}", "a".repeat(249))).unwrap_err(), + Tip20LogoUriValidationError::LogoURITooLong + ); + } + + #[test] + fn validates_logo_uri_byte_length_boundaries() { + validate_tip20_logo_uri(&format!("https://{}", "a".repeat(248))).unwrap(); + + let multibyte = format!("https://{}", "é".repeat(124)); + assert_eq!(multibyte.len(), TIP20_MAX_LOGO_URI_BYTES); + validate_tip20_logo_uri(&multibyte).unwrap(); + + let too_long = format!("https://{}é", "a".repeat(247)); + assert_eq!(too_long.len(), TIP20_MAX_LOGO_URI_BYTES + 1); + assert_eq!( + validate_tip20_logo_uri(&too_long).unwrap_err(), + Tip20LogoUriValidationError::LogoURITooLong + ); + } +} diff --git a/crates/common/src/term.rs b/crates/common/src/term.rs index c8cb9549fb1ef..ff5334eb4b9ad 100644 --- a/crates/common/src/term.rs +++ b/crates/common/src/term.rs @@ -1,9 +1,8 @@ //! terminal utils use foundry_compilers::{ artifacts::remappings::Remapping, - report::{self, BasicStdoutReporter, Reporter}, + report::{self, Reporter}, }; -use foundry_config::find_project_root; use itertools::Itertools; use semver::Version; use std::{ @@ -40,8 +39,11 @@ pub struct TermSettings { impl TermSettings { /// Returns a new [`TermSettings`], configured from the current environment. + /// + /// Progress is written to stderr (see [`Spinner::tick`]), so it is enabled only + /// when stderr is a terminal. pub fn from_env() -> Self { - Self { indicate_progress: std::io::stdout().is_terminal() } + Self { indicate_progress: std::io::stderr().is_terminal() } } } @@ -75,8 +77,10 @@ impl Spinner { let indicator = self.indicator[self.idx % self.indicator.len()].green(); let indicator = Paint::new(format!("[{indicator}]")).bold(); - let _ = sh_print!("\r\x33[2K\r{indicator} {}", self.message); - io::stdout().flush().unwrap(); + // Progress is a diagnostic, not data: write to stderr so stdout stays clean + // for machine-readable output. + let _ = sh_eprint!("\r\x1B[2K\r{indicator} {}", self.message); + io::stderr().flush().unwrap(); self.idx = self.idx.wrapping_add(1); } @@ -94,6 +98,10 @@ impl Spinner { pub struct SpinnerReporter { /// The sender to the spinner thread. sender: mpsc::Sender, + /// The project root path for trimming file paths in verbose output. + project_root: Option, + /// Whether to print the resolved settings for each compiler invocation. + print_compiler_settings: bool, } impl SpinnerReporter { @@ -102,24 +110,34 @@ impl SpinnerReporter { /// The spinner's message will be updated via the `reporter` events /// /// On drop the channel will disconnect and the thread will terminate - pub fn spawn() -> Self { + pub fn spawn(project_root: Option) -> Self { let (sender, rx) = mpsc::channel::(); std::thread::Builder::new() .name("spinner".into()) .spawn(move || { let mut spinner = Spinner::new("Compiling..."); + // Only emit the trailing newline (so past messages aren't overwritten by + // future ticks) when the spinner is actually painting to stderr. When + // `no_progress` is set the spinner is a no-op, so we shouldn't pollute + // stderr with blank lines either. + let emits_progress = !spinner.no_progress; loop { spinner.tick(); match rx.try_recv() { Ok(SpinnerMsg::Msg(msg)) => { spinner.message(msg); - // new line so past messages are not overwritten - let _ = sh_println!(); + if emits_progress { + // new line so past messages are not overwritten + // (matches the spinner channel: stderr) + let _ = sh_eprintln!(); + } } Ok(SpinnerMsg::Shutdown(ack)) => { - // end with a newline - let _ = sh_println!(); + if emits_progress { + // end with a newline (matches the spinner channel: stderr) + let _ = sh_eprintln!(); + } let _ = ack.send(()); break; } @@ -130,7 +148,13 @@ impl SpinnerReporter { }) .expect("failed to spawn thread"); - Self { sender } + Self { sender, project_root, print_compiler_settings: false } + } + + /// Sets whether resolved compiler settings are included in progress output. + pub const fn with_compiler_settings(mut self, yes: bool) -> Self { + self.print_compiler_settings = yes; + self } fn send_msg(&self, msg: impl Into) { @@ -157,14 +181,12 @@ impl Reporter for SpinnerReporter { // Verbose message with dirty files displays first to avoid being overlapped // by the spinner in .tick() which prints repeatedly over the same line. if shell::verbosity() >= 5 { - let project_root = find_project_root(None); - self.send_msg(format!( "Files to compile:\n{}", dirty_files .iter() .map(|path| { - let trimmed_path = if let Ok(project_root) = &project_root { + let trimmed_path = if let Some(project_root) = &self.project_root { path.strip_prefix(project_root).unwrap_or(path) } else { path @@ -186,6 +208,21 @@ impl Reporter for SpinnerReporter { )); } + fn on_compiler_settings( + &self, + compiler_name: &str, + version: &Version, + profile: &str, + settings: &str, + ) { + if self.print_compiler_settings { + self.send_msg(format!( + "Compiler settings for {compiler_name} {}.{}.{} (profile: {profile}): {settings}", + version.major, version.minor, version.patch + )); + } + } + fn on_compiler_success(&self, compiler_name: &str, version: &Version, duration: &Duration) { self.send_msg(format!( "{} {}.{}.{} finished in {duration:.2?}", @@ -210,19 +247,6 @@ impl Reporter for SpinnerReporter { } } -/// If the output medium is terminal, this calls `f` within the [`SpinnerReporter`] that displays a -/// spinning cursor to display solc progress. -/// -/// If no terminal is available this falls back to common `println!` in [`BasicStdoutReporter`]. -pub fn with_spinner_reporter(f: impl FnOnce() -> T) -> T { - let reporter = if TERM_SETTINGS.indicate_progress { - report::Report::new(SpinnerReporter::spawn()) - } else { - report::Report::new(BasicStdoutReporter::default()) - }; - report::with_scoped(&reporter, f) -} - #[cfg(test)] mod tests { use super::*; @@ -240,7 +264,7 @@ mod tests { #[test] fn can_format_properly() { - let r = SpinnerReporter::spawn(); + let r = SpinnerReporter::spawn(None); let remappings: Vec = vec![ "library/=library/src/".parse().unwrap(), "weird-erc20/=lib/weird-erc20/src/".parse().unwrap(), diff --git a/crates/common/src/traits.rs b/crates/common/src/traits.rs index 21e39a5adc6fb..15d5e6cf3ab9c 100644 --- a/crates/common/src/traits.rs +++ b/crates/common/src/traits.rs @@ -8,20 +8,68 @@ use std::{fmt, path::Path}; /// Test filter. pub trait TestFilter: Send + Sync { /// Returns whether the test should be included. - fn matches_test(&self, test_name: &str) -> bool; + fn matches_test(&self, test_signature: &str) -> bool; /// Returns whether the contract should be included. fn matches_contract(&self, contract_name: &str) -> bool; /// Returns a contract with the given path should be included. fn matches_path(&self, path: &Path) -> bool; + + /// Returns whether the test should be included for the given contract. + /// + /// `contract_id` is the full artifact identifier (`path:Contract`). + fn matches_test_function_kind_in_contract( + &self, + _contract_id: &str, + func: &Function, + kind: TestFunctionKind, + ) -> bool { + kind.is_any_test() && self.matches_test(&func.signature()) + } + + /// Returns whether the test should be included for the given contract. + /// + /// `contract_id` is the full artifact identifier (`path:Contract`). + fn matches_test_function_in_contract(&self, contract_id: &str, func: &Function) -> bool { + self.matches_test_function_kind_in_contract(contract_id, func, func.test_function_kind()) + } +} + +impl<'a> dyn TestFilter + 'a { + /// Returns `true` if the function kind is runnable and matches the given filter. + pub fn matches_test_function_kind(&self, func: &Function, kind: TestFunctionKind) -> bool { + kind.is_any_test() && self.matches_test(&func.signature()) + } + + /// Returns `true` if the function is a test function that matches the given filter. + pub fn matches_test_function(&self, func: &Function) -> bool { + self.matches_test_function_kind(func, func.test_function_kind()) + } +} + +/// A test filter that filters out nothing. +#[derive(Clone, Debug, Default)] +pub struct EmptyTestFilter(()); +impl TestFilter for EmptyTestFilter { + fn matches_test(&self, _test_signature: &str) -> bool { + true + } + + fn matches_contract(&self, _contract_name: &str) -> bool { + true + } + + fn matches_path(&self, _path: &Path) -> bool { + true + } } /// Extension trait for `Function`. pub trait TestFunctionExt { /// Returns the kind of test function. fn test_function_kind(&self) -> TestFunctionKind { - TestFunctionKind::classify(self.tfe_as_str(), self.tfe_has_inputs()) + TestFunctionKind::classify(self.tfe_as_str(), self.tfe_has_inputs(), false) } /// Returns `true` if this function is a `setUp` function. @@ -59,6 +107,11 @@ pub trait TestFunctionExt { self.test_function_kind().is_invariant_test() } + /// Returns `true` if this function is a symbolic test. + fn is_symbolic_test(&self) -> bool { + self.test_function_kind().is_symbolic_test() + } + /// Returns `true` if this function is an `afterInvariant` function. fn is_after_invariant(&self) -> bool { self.test_function_kind().is_after_invariant() @@ -127,6 +180,8 @@ pub enum TestFunctionKind { InvariantTest, /// `table*`, with arguments. TableTest, + /// `check*` or `prove*`, when selected by symbolic test mode. + SymbolicTest, /// `afterInvariant`. AfterInvariant, /// `fixture*`. @@ -137,8 +192,7 @@ pub enum TestFunctionKind { impl TestFunctionKind { /// Classify a function. - #[inline] - pub fn classify(name: &str, has_inputs: bool) -> Self { + pub fn classify(name: &str, has_inputs: bool, symbolic_entrypoints: bool) -> Self { match () { _ if name.starts_with("test") => { let should_fail = name.starts_with("testFail"); @@ -152,7 +206,12 @@ impl TestFunctionKind { Self::InvariantTest } _ if name.starts_with("table") => Self::TableTest, - _ if name.eq_ignore_ascii_case("setup") => Self::Setup, + _ if symbolic_entrypoints + && (name.starts_with("check") || name.starts_with("prove")) => + { + Self::SymbolicTest + } + _ if name.eq_ignore_ascii_case("setup") && !has_inputs => Self::Setup, _ if name.eq_ignore_ascii_case("afterinvariant") => Self::AfterInvariant, _ if name.starts_with("fixture") => Self::Fixture, _ => Self::Unknown, @@ -169,6 +228,7 @@ impl TestFunctionKind { Self::FuzzTest { should_fail: true } => "fuzz fail", Self::InvariantTest => "invariant", Self::TableTest => "table", + Self::SymbolicTest => "symbolic", Self::AfterInvariant => "afterInvariant", Self::Fixture => "fixture", Self::Unknown => "unknown", @@ -186,7 +246,11 @@ impl TestFunctionKind { pub const fn is_any_test(&self) -> bool { matches!( self, - Self::UnitTest { .. } | Self::FuzzTest { .. } | Self::TableTest | Self::InvariantTest + Self::UnitTest { .. } + | Self::FuzzTest { .. } + | Self::TableTest + | Self::InvariantTest + | Self::SymbolicTest ) } @@ -198,7 +262,7 @@ impl TestFunctionKind { /// Returns `true` if this function is a unit test. #[inline] - pub fn is_unit_test(&self) -> bool { + pub const fn is_unit_test(&self) -> bool { matches!(self, Self::UnitTest { .. }) } @@ -220,6 +284,12 @@ impl TestFunctionKind { matches!(self, Self::TableTest) } + /// Returns `true` if this function is a symbolic test. + #[inline] + pub const fn is_symbolic_test(&self) -> bool { + matches!(self, Self::SymbolicTest) + } + /// Returns `true` if this function is an `afterInvariant` function. #[inline] pub const fn is_after_invariant(&self) -> bool { @@ -235,7 +305,7 @@ impl TestFunctionKind { /// Returns `true` if this function kind is known. #[inline] pub const fn is_known(&self) -> bool { - !matches!(self, Self::Unknown) + !self.is_unknown() } /// Returns `true` if this function kind is unknown. @@ -262,3 +332,34 @@ impl ErrorExt for T { alloy_sol_types::Revert::from(self.to_string()).abi_encode().into() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_setup_classification() { + // setUp() with no params should be classified as Setup + assert_eq!(TestFunctionKind::classify("setUp", false, false), TestFunctionKind::Setup); + + // setUp(bytes memory) with params should NOT be classified as Setup + // This is common in Gnosis Safe/Zodiac modules + assert_eq!(TestFunctionKind::classify("setUp", true, false), TestFunctionKind::Unknown); + } + + #[test] + fn test_symbolic_classification() { + assert_eq!( + TestFunctionKind::classify("checkSymbolic", true, true), + TestFunctionKind::SymbolicTest + ); + assert_eq!( + TestFunctionKind::classify("proveSymbolic", false, true), + TestFunctionKind::SymbolicTest + ); + assert_eq!( + TestFunctionKind::classify("checkSymbolic", true, false), + TestFunctionKind::Unknown + ); + } +} diff --git a/crates/common/src/transactions.rs b/crates/common/src/transactions.rs deleted file mode 100644 index 80089cbe37ad9..0000000000000 --- a/crates/common/src/transactions.rs +++ /dev/null @@ -1,294 +0,0 @@ -//! Wrappers for transactions. - -use alloy_consensus::{Transaction, TxEnvelope, transaction::SignerRecoverable}; -use alloy_eips::eip7702::SignedAuthorization; -use alloy_network::AnyTransactionReceipt; -use alloy_primitives::{Address, TxKind, U256}; -use alloy_provider::{ - Provider, - network::{AnyNetwork, ReceiptResponse, TransactionBuilder}, -}; -use alloy_rpc_types::{BlockId, TransactionRequest}; -use alloy_serde::WithOtherFields; -use eyre::Result; -use foundry_common_fmt::UIfmt; -use serde::{Deserialize, Serialize}; - -/// Helper type to carry a transaction along with an optional revert reason -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct TransactionReceiptWithRevertReason { - /// The underlying transaction receipt - #[serde(flatten)] - pub receipt: AnyTransactionReceipt, - - /// The revert reason string if the transaction status is failed - #[serde(skip_serializing_if = "Option::is_none", rename = "revertReason")] - pub revert_reason: Option, -} - -impl TransactionReceiptWithRevertReason { - /// Returns if the status of the transaction is 0 (failure) - pub fn is_failure(&self) -> bool { - !self.receipt.inner.inner.inner.receipt.status.coerce_status() - } - - /// Updates the revert reason field using `eth_call` and returns an Err variant if the revert - /// reason was not successfully updated - pub async fn update_revert_reason>( - &mut self, - provider: &P, - ) -> Result<()> { - self.revert_reason = self.fetch_revert_reason(provider).await?; - Ok(()) - } - - async fn fetch_revert_reason>( - &self, - provider: &P, - ) -> Result> { - if !self.is_failure() { - return Ok(None); - } - - let transaction = provider - .get_transaction_by_hash(self.receipt.transaction_hash) - .await - .map_err(|err| eyre::eyre!("unable to fetch transaction: {err}"))? - .ok_or_else(|| eyre::eyre!("transaction not found"))?; - - if let Some(block_hash) = self.receipt.block_hash { - let mut call_request: WithOtherFields = - transaction.inner.inner.clone_inner().into(); - call_request.set_from(transaction.inner.inner.signer()); - match provider.call(call_request).block(BlockId::Hash(block_hash.into())).await { - Err(e) => return Ok(extract_revert_reason(e.to_string())), - Ok(_) => eyre::bail!("no revert reason as transaction succeeded"), - } - } - eyre::bail!("unable to fetch block_hash") - } -} - -impl From for TransactionReceiptWithRevertReason { - fn from(receipt: AnyTransactionReceipt) -> Self { - Self { receipt, revert_reason: None } - } -} - -impl From for AnyTransactionReceipt { - fn from(receipt_with_reason: TransactionReceiptWithRevertReason) -> Self { - receipt_with_reason.receipt - } -} - -impl UIfmt for TransactionReceiptWithRevertReason { - fn pretty(&self) -> String { - if let Some(revert_reason) = &self.revert_reason { - format!( - "{} -revertReason {}", - self.receipt.pretty(), - revert_reason - ) - } else { - self.receipt.pretty() - } - } -} - -impl UIfmt for TransactionMaybeSigned { - fn pretty(&self) -> String { - match self { - Self::Signed { tx, .. } => tx.pretty(), - Self::Unsigned(tx) => format!( - " -accessList {} -chainId {} -gasLimit {} -gasPrice {} -input {} -maxFeePerBlobGas {} -maxFeePerGas {} -maxPriorityFeePerGas {} -nonce {} -to {} -type {} -value {}", - tx.access_list - .as_ref() - .map(|a| a.iter().collect::>()) - .unwrap_or_default() - .pretty(), - tx.chain_id.pretty(), - tx.gas_limit().unwrap_or_default(), - tx.gas_price.pretty(), - tx.input.input.pretty(), - tx.max_fee_per_blob_gas.pretty(), - tx.max_fee_per_gas.pretty(), - tx.max_priority_fee_per_gas.pretty(), - tx.nonce.pretty(), - tx.to.as_ref().map(|a| a.to()).unwrap_or_default().pretty(), - tx.transaction_type.unwrap_or_default(), - tx.value.pretty(), - ), - } - } -} - -fn extract_revert_reason>(error_string: S) -> Option { - let message_substr = "execution reverted: "; - error_string - .as_ref() - .find(message_substr) - .map(|index| error_string.as_ref().split_at(index + message_substr.len()).1.to_string()) -} - -/// Returns the `UiFmt::pretty()` formatted attribute of the transaction receipt -pub fn get_pretty_tx_receipt_attr( - receipt: &TransactionReceiptWithRevertReason, - attr: &str, -) -> Option { - match attr { - "blockHash" | "block_hash" => Some(receipt.receipt.block_hash.pretty()), - "blockNumber" | "block_number" => Some(receipt.receipt.block_number.pretty()), - "contractAddress" | "contract_address" => Some(receipt.receipt.contract_address.pretty()), - "cumulativeGasUsed" | "cumulative_gas_used" => { - Some(receipt.receipt.inner.inner.inner.receipt.cumulative_gas_used.pretty()) - } - "effectiveGasPrice" | "effective_gas_price" => { - Some(receipt.receipt.effective_gas_price.to_string()) - } - "gasUsed" | "gas_used" => Some(receipt.receipt.gas_used.to_string()), - "logs" => Some(receipt.receipt.inner.inner.inner.receipt.logs.as_slice().pretty()), - "logsBloom" | "logs_bloom" => Some(receipt.receipt.inner.inner.inner.logs_bloom.pretty()), - "root" | "stateRoot" | "state_root " => Some(receipt.receipt.state_root().pretty()), - "status" | "statusCode" | "status_code" => { - Some(receipt.receipt.inner.inner.inner.receipt.status.pretty()) - } - "transactionHash" | "transaction_hash" => Some(receipt.receipt.transaction_hash.pretty()), - "transactionIndex" | "transaction_index" => { - Some(receipt.receipt.transaction_index.pretty()) - } - "type" | "transaction_type" => Some(receipt.receipt.inner.inner.r#type.to_string()), - "revertReason" | "revert_reason" => Some(receipt.revert_reason.pretty()), - _ => None, - } -} - -/// Used for broadcasting transactions -/// A transaction can either be a [`TransactionRequest`] waiting to be signed -/// or a [`TxEnvelope`], already signed -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(untagged)] -pub enum TransactionMaybeSigned { - Signed { - #[serde(flatten)] - tx: TxEnvelope, - from: Address, - }, - Unsigned(WithOtherFields), -} - -impl TransactionMaybeSigned { - /// Creates a new (unsigned) transaction for broadcast - pub fn new(tx: WithOtherFields) -> Self { - Self::Unsigned(tx) - } - - /// Creates a new signed transaction for broadcast. - pub fn new_signed( - tx: TxEnvelope, - ) -> core::result::Result { - let from = tx.recover_signer()?; - Ok(Self::Signed { tx, from }) - } - - pub fn is_unsigned(&self) -> bool { - matches!(self, Self::Unsigned(_)) - } - - pub fn as_unsigned_mut(&mut self) -> Option<&mut WithOtherFields> { - match self { - Self::Unsigned(tx) => Some(tx), - _ => None, - } - } - - pub fn from(&self) -> Option
{ - match self { - Self::Signed { from, .. } => Some(*from), - Self::Unsigned(tx) => tx.from, - } - } - - pub fn input(&self) -> Option<&[u8]> { - match self { - Self::Signed { tx, .. } => Some(tx.input()), - Self::Unsigned(tx) => tx.input.input().map(|i| i.as_ref()), - } - } - - pub fn to(&self) -> Option { - match self { - Self::Signed { tx, .. } => Some(tx.kind()), - Self::Unsigned(tx) => tx.to, - } - } - - pub fn value(&self) -> Option { - match self { - Self::Signed { tx, .. } => Some(tx.value()), - Self::Unsigned(tx) => tx.value, - } - } - - pub fn gas(&self) -> Option { - match self { - Self::Signed { tx, .. } => Some(tx.gas_limit() as u128), - Self::Unsigned(tx) => tx.gas_limit().map(|g| g as u128), - } - } - - pub fn nonce(&self) -> Option { - match self { - Self::Signed { tx, .. } => Some(tx.nonce()), - Self::Unsigned(tx) => tx.nonce, - } - } - - pub fn authorization_list(&self) -> Option> { - match self { - Self::Signed { tx, .. } => tx.authorization_list().map(|auths| auths.to_vec()), - Self::Unsigned(tx) => tx.authorization_list.as_deref().map(|auths| auths.to_vec()), - } - .filter(|auths| !auths.is_empty()) - } -} - -impl From for TransactionMaybeSigned { - fn from(tx: TransactionRequest) -> Self { - Self::new(WithOtherFields::new(tx)) - } -} - -impl TryFrom for TransactionMaybeSigned { - type Error = alloy_consensus::crypto::RecoveryError; - - fn try_from(tx: TxEnvelope) -> core::result::Result { - Self::new_signed(tx) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_extract_revert_reason() { - let error_string_1 = "server returned an error response: error code 3: execution reverted: Transaction too old"; - let error_string_2 = "server returned an error response: error code 3: Invalid signature"; - - assert_eq!(extract_revert_reason(error_string_1), Some("Transaction too old".to_string())); - assert_eq!(extract_revert_reason(error_string_2), None); - } -} diff --git a/crates/common/src/transactions/broadcast.rs b/crates/common/src/transactions/broadcast.rs new file mode 100644 index 0000000000000..95bfb679c616f --- /dev/null +++ b/crates/common/src/transactions/broadcast.rs @@ -0,0 +1,136 @@ +use alloy_consensus::Transaction; +use alloy_eips::eip7702::SignedAuthorization; +use alloy_network::{Network, NetworkTransactionBuilder, TransactionBuilder}; +use alloy_primitives::{Address, Bytes, U256}; +use foundry_common_fmt::UIfmt; +use serde::{Deserialize, Serialize}; + +use super::FoundryTransactionBuilder; + +/// Used for broadcasting transactions +/// A transaction can either be a `TransactionRequest` waiting to be signed +/// or a `TxEnvelope`, already signed +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(untagged)] +pub enum TransactionMaybeSigned { + Signed { + #[serde(flatten)] + tx: N::TxEnvelope, + from: Address, + }, + Unsigned(N::TransactionRequest), +} + +impl TransactionMaybeSigned { + /// Creates a new (unsigned) transaction for broadcast + pub const fn new(tx: N::TransactionRequest) -> Self { + Self::Unsigned(tx) + } + + pub const fn is_unsigned(&self) -> bool { + matches!(self, Self::Unsigned(_)) + } + + pub const fn as_unsigned_mut(&mut self) -> Option<&mut N::TransactionRequest> { + match self { + Self::Unsigned(tx) => Some(tx), + _ => None, + } + } + + pub fn from(&self) -> Option
{ + match self { + Self::Signed { from, .. } => Some(*from), + Self::Unsigned(tx) => tx.from(), + } + } + + pub fn input(&self) -> Option<&Bytes> { + match self { + Self::Signed { tx, .. } => Some(tx.input()), + Self::Unsigned(tx) => tx.input(), + } + } + + pub fn to(&self) -> Option
{ + match self { + Self::Signed { tx, .. } => tx.to(), + Self::Unsigned(tx) => tx.to(), + } + } + + pub fn value(&self) -> Option { + match self { + Self::Signed { tx, .. } => Some(tx.value()), + Self::Unsigned(tx) => tx.value(), + } + } + + pub fn gas(&self) -> Option { + match self { + Self::Signed { tx, .. } => Some(tx.gas_limit() as u128), + Self::Unsigned(tx) => tx.gas_limit().map(|g| g as u128), + } + } + + pub fn nonce(&self) -> Option { + match self { + Self::Signed { tx, .. } => Some(tx.nonce()), + Self::Unsigned(tx) => tx.nonce(), + } + } + + pub fn authorization_list(&self) -> Option> + where + N::TransactionRequest: FoundryTransactionBuilder, + { + match self { + Self::Signed { tx, .. } => tx.authorization_list().map(|auths| auths.to_vec()), + Self::Unsigned(tx) => tx.authorization_list().cloned(), + } + .filter(|auths| !auths.is_empty()) + } +} + +impl UIfmt for TransactionMaybeSigned +where + N::TxEnvelope: UIfmt, + N::TransactionRequest: FoundryTransactionBuilder, +{ + fn pretty(&self) -> String { + match self { + Self::Signed { tx, .. } => tx.pretty(), + Self::Unsigned(tx) => format!( + " +accessList {} +chainId {} +gasLimit {} +gasPrice {} +input {} +maxFeePerBlobGas {} +maxFeePerGas {} +maxPriorityFeePerGas {} +nonce {} +to {} +type {} +value {}", + tx.access_list() + .as_ref() + .map(|a| a.iter().collect::>()) + .unwrap_or_default() + .pretty(), + tx.chain_id().pretty(), + tx.gas_limit().unwrap_or_default(), + tx.gas_price().pretty(), + tx.input().pretty(), + tx.max_fee_per_blob_gas().pretty(), + tx.max_fee_per_gas().pretty(), + tx.max_priority_fee_per_gas().pretty(), + tx.nonce().pretty(), + tx.to().pretty(), + NetworkTransactionBuilder::::output_tx_type(tx), + tx.value().pretty(), + ), + } + } +} diff --git a/crates/common/src/transactions/builder.rs b/crates/common/src/transactions/builder.rs new file mode 100644 index 0000000000000..99799727c876e --- /dev/null +++ b/crates/common/src/transactions/builder.rs @@ -0,0 +1,565 @@ +use alloy_consensus::{ + BlobTransactionSidecar, BlobTransactionSidecarEip7594, BlobTransactionSidecarVariant, +}; +use alloy_eips::{Encodable2718, eip7702::SignedAuthorization}; +use alloy_network::{AnyNetwork, Ethereum, Network, NetworkTransactionBuilder, NetworkWallet}; +use alloy_primitives::{Address, B256, Signature, TxKind, U256}; +use alloy_provider::Provider; +use alloy_rpc_types::{TransactionRequest, serde_helpers::WithOtherFields}; +use eyre::Result; +use foundry_wallets::TempoAccountsWallet; +use std::num::NonZeroU64; +use tempo_alloy::{TempoNetwork, rpc::TempoTransactionRequest}; +use tempo_primitives::{SignatureType, TempoTxType, transaction::Call}; + +#[cfg(feature = "base")] +use base_common_network::Base; +#[cfg(feature = "base")] +use base_common_rpc_types::BaseTransactionRequest; + +#[cfg(feature = "optimism")] +use op_alloy_network::Optimism; +#[cfg(feature = "optimism")] +use op_alloy_rpc_types::OpTransactionRequest; + +/// Composite transaction builder trait for Foundry transactions. +/// +/// This extends the base `TransactionBuilder` trait with the same methods as +/// [`alloy_network::TransactionBuilder4844`] for handling blob transaction sidecars, and +/// [`alloy_network::TransactionBuilder7702`] for handling EIP-7702 authorization lists. +/// +/// By default, all methods have no-op implementations, so this can be implemented for any Network. +/// +/// If the Network supports Eip4844 blob transactions implement these methods: +/// - [`FoundryTransactionBuilder::max_fee_per_blob_gas`] +/// - [`FoundryTransactionBuilder::set_max_fee_per_blob_gas`] +/// - [`FoundryTransactionBuilder::blob_versioned_hashes`] +/// - [`FoundryTransactionBuilder::set_blob_versioned_hashes`] +/// - [`FoundryTransactionBuilder::blob_sidecar`] +/// - [`FoundryTransactionBuilder::set_blob_sidecar`] +/// +/// If the Network supports EIP-7702 authorization lists, implement these methods: +/// - [`FoundryTransactionBuilder::authorization_list`] +/// - [`FoundryTransactionBuilder::set_authorization_list`] +/// +/// If the Network supports Tempo transactions, implement these methods: +/// - [`FoundryTransactionBuilder::set_fee_token`] +/// - [`FoundryTransactionBuilder::set_nonce_key`] +/// - [`FoundryTransactionBuilder::set_key_id`] +/// - [`FoundryTransactionBuilder::set_valid_before`] +/// - [`FoundryTransactionBuilder::set_valid_after`] +/// - [`FoundryTransactionBuilder::set_fee_payer_signature`] +pub trait FoundryTransactionBuilder: NetworkTransactionBuilder { + /// Reset gas limit + fn reset_gas_limit(&mut self); + + /// Get the max fee per blob gas for the transaction. + fn max_fee_per_blob_gas(&self) -> Option { + None + } + + /// Set the max fee per blob gas for the transaction. + fn set_max_fee_per_blob_gas(&mut self, _max_fee_per_blob_gas: u128) {} + + /// Builder-pattern method for setting max fee per blob gas. + fn with_max_fee_per_blob_gas(mut self, max_fee_per_blob_gas: u128) -> Self { + self.set_max_fee_per_blob_gas(max_fee_per_blob_gas); + self + } + + /// Gets the EIP-4844 blob versioned hashes of the transaction. + /// + /// These may be set independently of the sidecar, e.g. when the sidecar + /// has been pruned but the hashes are still needed for `eth_call`. + fn blob_versioned_hashes(&self) -> Option<&[B256]> { + None + } + + /// Sets the EIP-4844 blob versioned hashes of the transaction. + fn set_blob_versioned_hashes(&mut self, _hashes: Vec) {} + + /// Builder-pattern method for setting the EIP-4844 blob versioned hashes. + fn with_blob_versioned_hashes(mut self, hashes: Vec) -> Self { + self.set_blob_versioned_hashes(hashes); + self + } + + /// Gets the blob sidecar (either EIP-4844 or EIP-7594 variant) of the transaction. + fn blob_sidecar(&self) -> Option<&BlobTransactionSidecarVariant> { + None + } + + /// Sets the blob sidecar (either EIP-4844 or EIP-7594 variant) of the transaction. + /// + /// Note: This will also set the versioned blob hashes accordingly: + /// [BlobTransactionSidecarVariant::versioned_hashes] + fn set_blob_sidecar(&mut self, _sidecar: BlobTransactionSidecarVariant) {} + + /// Builder-pattern method for setting the blob sidecar of the transaction. + fn with_blob_sidecar(mut self, sidecar: BlobTransactionSidecarVariant) -> Self { + self.set_blob_sidecar(sidecar); + self + } + + /// Gets the EIP-4844 blob sidecar if the current sidecar is of that variant. + fn blob_sidecar_4844(&self) -> Option<&BlobTransactionSidecar> { + self.blob_sidecar().and_then(|s| s.as_eip4844()) + } + + /// Sets the EIP-4844 blob sidecar of the transaction. + fn set_blob_sidecar_4844(&mut self, sidecar: BlobTransactionSidecar) { + self.set_blob_sidecar(BlobTransactionSidecarVariant::Eip4844(sidecar)); + } + + /// Builder-pattern method for setting the EIP-4844 blob sidecar of the transaction. + fn with_blob_sidecar_4844(mut self, sidecar: BlobTransactionSidecar) -> Self { + self.set_blob_sidecar_4844(sidecar); + self + } + + /// Gets the EIP-7594 blob sidecar if the current sidecar is of that variant. + fn blob_sidecar_7594(&self) -> Option<&BlobTransactionSidecarEip7594> { + self.blob_sidecar().and_then(|s| s.as_eip7594()) + } + + /// Sets the EIP-7594 blob sidecar of the transaction. + fn set_blob_sidecar_7594(&mut self, sidecar: BlobTransactionSidecarEip7594) { + self.set_blob_sidecar(BlobTransactionSidecarVariant::Eip7594(sidecar)); + } + + /// Builder-pattern method for setting the EIP-7594 blob sidecar of the transaction. + fn with_blob_sidecar_7594(mut self, sidecar: BlobTransactionSidecarEip7594) -> Self { + self.set_blob_sidecar_7594(sidecar); + self + } + + /// Get the EIP-7702 authorization list for the transaction. + fn authorization_list(&self) -> Option<&Vec> { + None + } + + /// Sets the EIP-7702 authorization list. + fn set_authorization_list(&mut self, _authorization_list: Vec) {} + + /// Builder-pattern method for setting the authorization list. + fn with_authorization_list(mut self, authorization_list: Vec) -> Self { + self.set_authorization_list(authorization_list); + self + } + + /// Get the fee token for a Tempo transaction. + fn fee_token(&self) -> Option
{ + None + } + + /// Gets top-level Tempo calls for local fee-token inference. + fn tempo_calls(&self) -> Vec<(TxKind, &[u8])> { + Vec::new() + } + + /// Returns true when [`Self::tempo_calls`] came from a Tempo AA `calls` list. + fn has_tempo_call_list(&self) -> bool { + false + } + + /// Returns true when this request will be built as a Tempo AA transaction. + fn is_tempo_aa(&self) -> bool { + false + } + + /// Returns true when this request type can pay fees in a Tempo fee token. + fn supports_fee_token(&self) -> bool { + false + } + + /// Set the fee token for a Tempo transaction. + fn set_fee_token(&mut self, _fee_token: Address) {} + + /// Builder-pattern method for setting the Tempo fee token. + fn with_fee_token(mut self, fee_token: Address) -> Self { + self.set_fee_token(fee_token); + self + } + + /// Get the 2D nonce key for a Tempo transaction. + fn nonce_key(&self) -> Option { + None + } + + /// Set the 2D nonce key for the Tempo transaction. + fn set_nonce_key(&mut self, _nonce_key: U256) {} + + /// Builder-pattern method for setting a 2D nonce key for a Tempo transaction. + fn with_nonce_key(mut self, nonce_key: U256) -> Self { + self.set_nonce_key(nonce_key); + self + } + + /// Clone this request and prepare it for browser-wallet gas estimation. + /// + /// Complete signer hints are preserved. Missing or incomplete hints default to a conservative + /// WebAuthn signature size. + fn browser_wallet_gas_estimation_request(&self) -> Self + where + Self: Clone, + { + self.clone() + } + + /// Get the access key ID for a Tempo transaction. + fn key_id(&self) -> Option
{ + None + } + + /// Set the access key ID for a Tempo transaction. + /// + /// Used during gas estimation to override the key_id that would normally be + /// recovered from the signature. + fn set_key_id(&mut self, _key_id: Address) {} + + /// Builder-pattern method for setting the Tempo access key ID. + fn with_key_id(mut self, key_id: Address) -> Self { + self.set_key_id(key_id); + self + } + + /// Get the valid_before timestamp for a Tempo expiring nonce transaction. + fn valid_before(&self) -> Option { + None + } + + /// Set the valid_before timestamp for a Tempo expiring nonce transaction. + fn set_valid_before(&mut self, _valid_before: NonZeroU64) {} + + /// Builder-pattern method for setting the valid_before timestamp. + fn with_valid_before(mut self, valid_before: NonZeroU64) -> Self { + self.set_valid_before(valid_before); + self + } + + /// Get the valid_after timestamp for a Tempo expiring nonce transaction. + fn valid_after(&self) -> Option { + None + } + + /// Set the valid_after timestamp for a Tempo expiring nonce transaction. + fn set_valid_after(&mut self, _valid_after: NonZeroU64) {} + + /// Builder-pattern method for setting the valid_after timestamp. + fn with_valid_after(mut self, valid_after: NonZeroU64) -> Self { + self.set_valid_after(valid_after); + self + } + + /// Get the fee payer (sponsor) signature for a Tempo sponsored transaction. + fn fee_payer_signature(&self) -> Option { + None + } + + /// Set the fee payer (sponsor) signature for a Tempo sponsored transaction. + fn set_fee_payer_signature(&mut self, _signature: Signature) {} + + /// Builder-pattern method for setting the fee payer signature. + fn with_fee_payer_signature(mut self, signature: Signature) -> Self { + self.set_fee_payer_signature(signature); + self + } + + /// Computes the sponsor (fee payer) signature hash for this transaction. + /// + /// This builds an unsigned consensus-level transaction from the request and computes + /// the hash that a sponsor needs to sign. Returns `None` for networks that don't + /// support sponsored transactions. + fn compute_sponsor_hash(&self, _from: Address) -> Option { + None + } + + /// Prepare a Tempo access-key wallet before gas estimation or sponsorship. + fn prepare_with_tempo_wallet<'a>( + &'a mut self, + _provider: &'a impl Provider, + _wallet: &'a TempoAccountsWallet, + ) -> impl Future> + Send + 'a + where + Self: Send, + { + std::future::ready(Err(eyre::eyre!( + "Tempo access-key wallets are not supported for this network" + ))) + } + + /// Converts a CREATE transaction into an AA-compatible call entry. + /// + /// Tempo AA transactions use a `calls` list instead of `to`+`input`. Must be + /// called before gas estimation so the RPC sees the correct tx structure. + /// No-op for non-Tempo networks. + fn convert_create_to_call(&mut self) {} + + /// Clears the `to` and `value` fields for batch transactions that use `calls`. + /// + /// In Tempo AA batch transactions, targets are specified in the `calls` field, not in `to`. + /// If `to` is set, `build_aa()` would add a spurious extra call. Must be called after + /// `prepare()` sets `kind`/`to` but before gas estimation. + /// No-op for non-Tempo networks. + fn clear_batch_to(&mut self) {} + + /// Sign a prepared request using a Tempo access-key wallet. + fn sign_with_tempo_wallet( + self, + _wallet: &TempoAccountsWallet, + ) -> impl Future>> + Send { + std::future::ready(Err(eyre::eyre!( + "Tempo access-key wallets are not supported for this network" + ))) + } +} + +impl FoundryTransactionBuilder for TransactionRequest { + fn reset_gas_limit(&mut self) { + self.gas = None; + } + + fn max_fee_per_blob_gas(&self) -> Option { + self.max_fee_per_blob_gas + } + + fn set_max_fee_per_blob_gas(&mut self, max_fee_per_blob_gas: u128) { + self.max_fee_per_blob_gas = Some(max_fee_per_blob_gas); + } + + fn blob_versioned_hashes(&self) -> Option<&[B256]> { + self.blob_versioned_hashes.as_deref() + } + + fn set_blob_versioned_hashes(&mut self, hashes: Vec) { + self.blob_versioned_hashes = Some(hashes); + } + + fn blob_sidecar(&self) -> Option<&BlobTransactionSidecarVariant> { + self.sidecar.as_ref() + } + + fn set_blob_sidecar(&mut self, sidecar: BlobTransactionSidecarVariant) { + self.sidecar = Some(sidecar); + self.populate_blob_hashes(); + } + + fn authorization_list(&self) -> Option<&Vec> { + self.authorization_list.as_ref() + } + + fn set_authorization_list(&mut self, authorization_list: Vec) { + self.authorization_list = Some(authorization_list); + } +} + +impl FoundryTransactionBuilder for WithOtherFields { + fn reset_gas_limit(&mut self) { + self.gas = None; + } + + fn max_fee_per_blob_gas(&self) -> Option { + self.max_fee_per_blob_gas + } + + fn set_max_fee_per_blob_gas(&mut self, max_fee_per_blob_gas: u128) { + self.max_fee_per_blob_gas = Some(max_fee_per_blob_gas); + } + + fn blob_versioned_hashes(&self) -> Option<&[B256]> { + self.blob_versioned_hashes.as_deref() + } + + fn set_blob_versioned_hashes(&mut self, hashes: Vec) { + self.blob_versioned_hashes = Some(hashes); + } + + fn blob_sidecar(&self) -> Option<&BlobTransactionSidecarVariant> { + self.sidecar.as_ref() + } + + fn set_blob_sidecar(&mut self, sidecar: BlobTransactionSidecarVariant) { + self.sidecar = Some(sidecar); + self.populate_blob_hashes(); + } + + fn authorization_list(&self) -> Option<&Vec> { + self.authorization_list.as_ref() + } + + fn set_authorization_list(&mut self, authorization_list: Vec) { + self.authorization_list = Some(authorization_list); + } +} + +#[cfg(feature = "base")] +impl FoundryTransactionBuilder for BaseTransactionRequest { + fn reset_gas_limit(&mut self) { + self.as_mut().gas = None; + } + + fn authorization_list(&self) -> Option<&Vec> { + self.as_ref().authorization_list.as_ref() + } + + fn set_authorization_list(&mut self, authorization_list: Vec) { + self.as_mut().authorization_list = Some(authorization_list); + } +} + +#[cfg(feature = "optimism")] +impl FoundryTransactionBuilder for OpTransactionRequest { + fn reset_gas_limit(&mut self) { + self.as_mut().gas = None; + } + + fn authorization_list(&self) -> Option<&Vec> { + self.as_ref().authorization_list.as_ref() + } + + fn set_authorization_list(&mut self, authorization_list: Vec) { + self.as_mut().authorization_list = Some(authorization_list); + } +} + +/// Viem's Tempo formatter uses a 1,400-byte WebAuthn placeholder when the signature is not yet +/// available. Tempo RPC encodes that size as a two-byte big-endian `keyData` value (`0x0578`). +/// +/// See . +const TEMPO_BROWSER_WEBAUTHN_DATA_SIZE: u16 = 1_400; + +impl FoundryTransactionBuilder for TempoTransactionRequest { + fn reset_gas_limit(&mut self) { + self.gas = None; + } + + fn authorization_list(&self) -> Option<&Vec> { + self.authorization_list.as_ref() + } + + fn set_authorization_list(&mut self, authorization_list: Vec) { + self.authorization_list = Some(authorization_list); + } + + fn fee_token(&self) -> Option
{ + self.fee_token + } + + fn tempo_calls(&self) -> Vec<(TxKind, &[u8])> { + self.calls + .iter() + .map(|call| (call.to, call.input.as_ref())) + .chain(self.inner.to.map(|to| { + (to, self.inner.input.input().map_or(&[] as &[u8], |input| input.as_ref())) + })) + .collect() + } + + fn has_tempo_call_list(&self) -> bool { + !self.calls.is_empty() + } + + fn is_tempo_aa(&self) -> bool { + NetworkTransactionBuilder::::output_tx_type(self) == TempoTxType::AA + } + + fn supports_fee_token(&self) -> bool { + true + } + + fn set_fee_token(&mut self, fee_token: Address) { + self.fee_token = Some(fee_token); + } + + fn nonce_key(&self) -> Option { + self.nonce_key + } + + fn set_nonce_key(&mut self, nonce_key: U256) { + self.nonce_key = Some(nonce_key); + } + + fn browser_wallet_gas_estimation_request(&self) -> Self { + let mut request = self.clone(); + if request.key_type.is_none() { + request.key_type = Some(SignatureType::WebAuthn); + request.key_data = Some(TEMPO_BROWSER_WEBAUTHN_DATA_SIZE.to_be_bytes().into()); + } else if matches!(request.key_type, Some(SignatureType::WebAuthn)) + && request.key_data.is_none() + { + request.key_data = Some(TEMPO_BROWSER_WEBAUTHN_DATA_SIZE.to_be_bytes().into()); + } + request.convert_create_to_call(); + request + } + + fn key_id(&self) -> Option
{ + self.key_id + } + + fn set_key_id(&mut self, key_id: Address) { + self.key_id = Some(key_id); + } + + fn valid_before(&self) -> Option { + self.valid_before + } + + fn set_valid_before(&mut self, valid_before: NonZeroU64) { + self.valid_before = Some(valid_before); + } + + fn valid_after(&self) -> Option { + self.valid_after + } + + fn set_valid_after(&mut self, valid_after: NonZeroU64) { + self.valid_after = Some(valid_after); + } + + fn fee_payer_signature(&self) -> Option { + self.fee_payer_signature + } + + fn set_fee_payer_signature(&mut self, signature: Signature) { + self.fee_payer_signature = Some(signature); + } + + fn compute_sponsor_hash(&self, from: Address) -> Option { + let tx = self.clone().build_aa().ok()?; + Some(tx.fee_payer_signature_hash(from)) + } + + async fn prepare_with_tempo_wallet<'a>( + &'a mut self, + provider: &'a impl Provider, + wallet: &'a TempoAccountsWallet, + ) -> Result + where + Self: Send, + { + wallet.prepare_request(provider, self).await.map_err(Into::into) + } + + fn convert_create_to_call(&mut self) { + if self.calls.is_empty() && self.inner.to.is_some_and(|to| to.is_create()) { + let input = self.inner.input.input().cloned().unwrap_or_default(); + let value = self.inner.value.unwrap_or(U256::ZERO); + self.calls.push(Call { to: TxKind::Create, value, input }); + self.inner.input = Default::default(); + self.inner.value = None; + self.inner.to = None; + } + } + + fn clear_batch_to(&mut self) { + if !self.calls.is_empty() { + self.inner.to = None; + self.inner.value = None; + } + } + + async fn sign_with_tempo_wallet(self, wallet: &TempoAccountsWallet) -> Result> { + wallet.sign_request(self).await.map(|envelope| envelope.encoded_2718()).map_err(Into::into) + } +} diff --git a/crates/common/src/transactions/mod.rs b/crates/common/src/transactions/mod.rs new file mode 100644 index 0000000000000..74943c81f44fc --- /dev/null +++ b/crates/common/src/transactions/mod.rs @@ -0,0 +1,9 @@ +//! Wrappers for transactions. + +mod broadcast; +mod builder; +mod receipt; + +pub use broadcast::*; +pub use builder::*; +pub use receipt::*; diff --git a/crates/common/src/transactions/receipt.rs b/crates/common/src/transactions/receipt.rs new file mode 100644 index 0000000000000..441a9ca2f1182 --- /dev/null +++ b/crates/common/src/transactions/receipt.rs @@ -0,0 +1,167 @@ +use alloy_network::{AnyNetwork, AnyTransactionReceipt, Network, TransactionResponse}; +use alloy_primitives::Address; +use alloy_provider::{ + Provider, + network::{ReceiptResponse, TransactionBuilder}, +}; +use alloy_rpc_types::{BlockId, TransactionReceipt}; +use eyre::Result; +use foundry_common_fmt::{UIfmt, UIfmtReceiptExt, get_pretty_receipt_attr}; +use serde::{Deserialize, Serialize}; +use tempo_alloy::rpc::TempoTransactionReceipt; + +#[cfg(feature = "base")] +use base_common_rpc_types::BaseTransactionReceipt; + +#[cfg(feature = "optimism")] +use op_alloy_rpc_types::OpTransactionReceipt; + +/// Helper trait providing `contract_address` setter for generic `ReceiptResponse` +pub trait FoundryReceiptResponse { + /// Sets address of the created contract, or `None` if the transaction was not a deployment. + fn set_contract_address(&mut self, contract_address: Address); +} + +impl FoundryReceiptResponse for TransactionReceipt { + fn set_contract_address(&mut self, contract_address: Address) { + self.contract_address = Some(contract_address); + } +} + +#[cfg(feature = "base")] +impl FoundryReceiptResponse for BaseTransactionReceipt { + fn set_contract_address(&mut self, contract_address: Address) { + self.inner.contract_address = Some(contract_address); + } +} + +#[cfg(feature = "optimism")] +impl FoundryReceiptResponse for OpTransactionReceipt { + fn set_contract_address(&mut self, contract_address: Address) { + self.inner.contract_address = Some(contract_address); + } +} + +impl FoundryReceiptResponse for TempoTransactionReceipt { + fn set_contract_address(&mut self, contract_address: Address) { + self.contract_address = Some(contract_address); + } +} + +/// Helper type to carry a transaction along with an optional revert reason +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct TransactionReceiptWithRevertReason { + /// The underlying transaction receipt + #[serde(flatten)] + pub receipt: N::ReceiptResponse, + + /// The revert reason string if the transaction status is failed + #[serde(skip_serializing_if = "Option::is_none", rename = "revertReason")] + pub revert_reason: Option, +} + +impl TransactionReceiptWithRevertReason +where + N::TxEnvelope: Clone, + N::ReceiptResponse: UIfmtReceiptExt, +{ + /// Updates the revert reason field using `eth_call` and returns an Err variant if the revert + /// reason was not successfully updated + pub async fn update_revert_reason(&mut self, provider: &dyn Provider) -> Result<()> { + self.revert_reason = self.fetch_revert_reason(provider).await?; + Ok(()) + } + + async fn fetch_revert_reason(&self, provider: &dyn Provider) -> Result> { + // If the transaction succeeded, there is no revert reason to fetch + if self.receipt.status() { + return Ok(None); + } + + let transaction = provider + .get_transaction_by_hash(self.receipt.transaction_hash()) + .await + .map_err(|err| eyre::eyre!("unable to fetch transaction: {err}"))? + .ok_or_else(|| eyre::eyre!("transaction not found"))?; + + if let Some(block_hash) = self.receipt.block_hash() { + let mut call_request: N::TransactionRequest = transaction.as_ref().clone().into(); + call_request.set_from(transaction.from()); + match provider.call(call_request).block(BlockId::hash(block_hash)).await { + Err(e) => return Ok(extract_revert_reason(e.to_string())), + Ok(_) => { + eyre::bail!("no revert reason as transaction succeeded"); + } + } + } + eyre::bail!("unable to fetch block_hash"); + } +} + +impl From for TransactionReceiptWithRevertReason { + fn from(receipt: AnyTransactionReceipt) -> Self { + Self { receipt, revert_reason: None } + } +} + +impl From> for AnyTransactionReceipt { + fn from(receipt_with_reason: TransactionReceiptWithRevertReason) -> Self { + receipt_with_reason.receipt + } +} + +impl UIfmt for TransactionReceiptWithRevertReason +where + N::ReceiptResponse: UIfmt, +{ + fn pretty(&self) -> String { + if let Some(revert_reason) = &self.revert_reason { + format!( + "{} +revertReason {}", + self.receipt.pretty(), + revert_reason + ) + } else { + self.receipt.pretty() + } + } +} + +fn extract_revert_reason>(error_string: S) -> Option { + let message_substr = "execution reverted: "; + error_string + .as_ref() + .find(message_substr) + .map(|index| error_string.as_ref().split_at(index + message_substr.len()).1.to_string()) +} + +/// Returns the `UiFmt::pretty()` formatted attribute of the transaction receipt with revert reason +pub fn get_pretty_receipt_w_reason_attr( + receipt: &TransactionReceiptWithRevertReason, + attr: &str, +) -> Option +where + N: Network, + N::ReceiptResponse: UIfmtReceiptExt, +{ + // Handle revert reason first, then delegate to the receipt formatting function + if matches!(attr, "revertReason" | "revert_reason") { + return Some(receipt.revert_reason.pretty()); + } + get_pretty_receipt_attr::(&receipt.receipt, attr) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_revert_reason() { + let error_string_1 = "server returned an error response: error code 3: execution reverted: Transaction too old"; + let error_string_2 = "server returned an error response: error code 3: Invalid signature"; + + assert_eq!(extract_revert_reason(error_string_1), Some("Transaction too old".to_string())); + assert_eq!(extract_revert_reason(error_string_2), None); + } +} diff --git a/crates/common/src/utils.rs b/crates/common/src/utils.rs index 919ee64dfaa86..562530ce36ab6 100644 --- a/crates/common/src/utils.rs +++ b/crates/common/src/utils.rs @@ -1,9 +1,14 @@ //! Uncategorised utilities. use alloy_primitives::{B256, Bytes, U256, hex, keccak256}; -use foundry_compilers::artifacts::BytecodeObject; +use foundry_compilers::{ + Project, + artifacts::{BytecodeObject, SolcLanguage}, + error::SolcError, + flatten::{Flattener, FlattenerError}, +}; use regex::Regex; -use std::sync::LazyLock; +use std::{path::Path, sync::LazyLock}; static BYTECODE_PLACEHOLDER_RE: LazyLock = LazyLock::new(|| Regex::new(r"__\$.{34}\$__").expect("invalid regex")); @@ -41,21 +46,44 @@ pub fn block_on_handle( /// ); /// ``` pub fn erc7201(id: &str) -> B256 { - let x = U256::from_be_bytes(keccak256(id).0) - U256::from(1); + let x = Into::::into(keccak256(id)) - U256::ONE; keccak256(x.to_be_bytes::<32>()) & B256::from(!U256::from(0xff)) } -/// Utility function to ignore metadata hash of the given bytecode. +/// Utility function to find the start of the metadata in the bytecode. /// This assumes that the metadata is at the end of the bytecode. -pub fn ignore_metadata_hash(bytecode: &[u8]) -> &[u8] { +pub fn find_metadata_start(bytecode: &[u8]) -> Option { // Get the last two bytes of the bytecode to find the length of CBOR metadata. - let Some((rest, metadata_len_bytes)) = bytecode.split_last_chunk() else { return bytecode }; + let (rest, metadata_len_bytes) = bytecode.split_last_chunk()?; let metadata_len = u16::from_be_bytes(*metadata_len_bytes) as usize; if metadata_len > rest.len() { - return bytecode; + return None; + } + ciborium::from_reader::(&rest[rest.len() - metadata_len..]) + .is_ok() + .then(|| rest.len() - metadata_len) +} + +/// Utility function to ignore metadata hash of the given bytecode. +/// This assumes that the metadata is at the end of the bytecode. +pub fn ignore_metadata_hash(bytecode: &[u8]) -> &[u8] { + if let Some(metadata) = find_metadata_start(bytecode) { + &bytecode[..metadata] + } else { + bytecode } - let (rest, metadata) = rest.split_at(rest.len() - metadata_len); - if ciborium::from_reader::(metadata).is_ok() { rest } else { bytecode } +} + +/// Returns whether the CBOR metadata at the end of the bytecode contains a metadata hash +/// (`ipfs`, `bzzr0` or `bzzr1`), which commits to the contract's sources and compiler settings. +pub fn has_metadata_hash(bytecode: &[u8]) -> bool { + let Some(start) = find_metadata_start(bytecode) else { return false }; + let Ok(ciborium::Value::Map(entries)) = + ciborium::from_reader::(&bytecode[start..bytecode.len() - 2]) + else { + return false; + }; + entries.iter().any(|(key, _)| matches!(key.as_text(), Some("ipfs" | "bzzr0" | "bzzr1"))) } /// Strips all __$xxx$__ placeholders from the bytecode if it's an unlinked bytecode. @@ -66,10 +94,28 @@ pub fn strip_bytecode_placeholders(bytecode: &BytecodeObject) -> Option { match &bytecode { BytecodeObject::Bytecode(bytes) => Some(bytes.clone()), BytecodeObject::Unlinked(s) => { - // Replace all __$xxx$__ placeholders with 32 zero bytes - let s = (*BYTECODE_PLACEHOLDER_RE).replace_all(s, "00".repeat(40)); + // Replace all __$xxx$__ placeholders with 20 zero bytes (40 hex chars) + let s = (*BYTECODE_PLACEHOLDER_RE).replace_all(s, "00".repeat(20)); let bytes = hex::decode(s.as_bytes()); Some(bytes.ok()?.into()) } } } + +/// Flattens the given target of the project. Falls back to the old flattening implementation +/// if the target cannot be compiled successfully. This would be the case if the target has invalid +/// syntax. (e.g. Solang) +pub fn flatten(project: Project, target_path: &Path) -> eyre::Result { + // Save paths for fallback before Flattener::new takes ownership + let paths = project.paths.clone(); + let flattened = match Flattener::new(project, target_path) { + Ok(flattener) => Ok(flattener.flatten()), + Err(FlattenerError::Compilation(_)) => { + paths.with_language::().flatten(target_path) + } + Err(FlattenerError::Other(err)) => Err(err), + } + .map_err(|err: SolcError| eyre::eyre!("Failed to flatten: {err}"))?; + + Ok(flattened) +} diff --git a/crates/common/src/version.rs b/crates/common/src/version.rs index 30c49ed0cfa4a..5d771c8345207 100644 --- a/crates/common/src/version.rs +++ b/crates/common/src/version.rs @@ -23,3 +23,6 @@ pub const IS_NIGHTLY_VERSION: bool = option_env!("FOUNDRY_IS_NIGHTLY_VERSION").i /// The warning message for nightly versions. pub const NIGHTLY_VERSION_WARNING_MESSAGE: &str = "This is a nightly build of Foundry. It is recommended to use the latest stable version. \ To mute this warning set `FOUNDRY_DISABLE_NIGHTLY_WARNING` in your environment. \n"; + +/// The long SHA of the latest commit. +pub const COMMIT_SHA: &str = env!("FOUNDRY_COMMIT_SHA"); diff --git a/crates/common/src/wallet.rs b/crates/common/src/wallet.rs new file mode 100644 index 0000000000000..5771afed20baf --- /dev/null +++ b/crates/common/src/wallet.rs @@ -0,0 +1,152 @@ +use alloy_primitives::U256; +use alloy_signer_local::{ + MnemonicBuilder, PrivateKeySigner, + coins_bip39::{ + ChineseSimplified, ChineseTraditional, Czech, English, French, Italian, Japanese, Korean, + Portuguese, Spanish, Wordlist, + }, +}; + +/// BIP-32 harden bit (`0x8000_0000`). Indices at or above this already encode hardened. +pub const BIP32_HARDEN: u32 = 0x8000_0000; + +/// Reject BIP-32 path components whose parsed index overflows the harden bit. +/// +/// `coins-bip32` parses `2147483648'` as `harden_index(0x8000_0000)` which wraps to `0`, +/// silently selecting the wrong (unhardened) child. Raw `2147483648` is also rejected +/// because it already equals the harden bit (`0'`). +/// +/// This is only an overflow guard. One trailing `'` or `h` is stripped so the numeric +/// index can be checked; malformed syntax is left to [`MnemonicBuilder`]. +pub fn validate_bip32_path(path: &str) -> Result<(), String> { + for c in path.split('/') { + let num = c.strip_suffix('\'').or_else(|| c.strip_suffix('h')).unwrap_or(c); + if let Ok(v) = num.parse::() + && v >= BIP32_HARDEN + { + return Err(format!( + "BIP32 component {c} overflows harden bit (index must be < {BIP32_HARDEN})" + )); + } + } + Ok(()) +} + +/// Appends `index` to `path`, inserting a `/` separator when needed. +pub fn derive_key_path(path: &str, index: u32) -> String { + let mut out = path.to_string(); + if !out.ends_with('/') { + out.push('/'); + } + out.push_str(&index.to_string()); + out +} + +/// [`derive_key_path`] plus the harden-bit overflow guard for mnemonic derive consumers. +pub fn derive_key_path_checked(path: &str, index: u32) -> Result { + if index >= BIP32_HARDEN { + return Err(format!( + "BIP32 index {index} already sets harden bit (must be < {BIP32_HARDEN})" + )); + } + validate_bip32_path(path)?; + Ok(derive_key_path(path, index)) +} + +/// Derives a private key from a BIP-39 mnemonic using the given BIP-32 path and index. +pub fn derive_private_key( + mnemonic: &str, + path: &str, + index: u32, +) -> Result { + let wallet = MnemonicBuilder::::default() + .phrase(mnemonic) + .derivation_path(derive_key_path_checked(path, index)?) + .map_err(|e| e.to_string())? + .build() + .map_err(|e| e.to_string())?; + Ok(U256::from_be_bytes(wallet.credential().to_bytes().into())) +} + +/// Derives a private key from a BIP-39 mnemonic, selecting the wordlist by name. +/// +/// Recognised language names: `chinese_simplified`, `chinese_traditional`, `czech`, `english`, +/// `french`, `italian`, `japanese`, `korean`, `portuguese`, `spanish`. +pub fn derive_private_key_with_language( + mnemonic: &str, + path: &str, + index: u32, + language: &str, +) -> Result { + match language { + "chinese_simplified" => derive_private_key::(mnemonic, path, index), + "chinese_traditional" => derive_private_key::(mnemonic, path, index), + "czech" => derive_private_key::(mnemonic, path, index), + "english" => derive_private_key::(mnemonic, path, index), + "french" => derive_private_key::(mnemonic, path, index), + "italian" => derive_private_key::(mnemonic, path, index), + "japanese" => derive_private_key::(mnemonic, path, index), + "korean" => derive_private_key::(mnemonic, path, index), + "portuguese" => derive_private_key::(mnemonic, path, index), + "spanish" => derive_private_key::(mnemonic, path, index), + _ => Err(format!("unsupported mnemonic language: {language:?}")), + } +} + +/// Constructs a [`PrivateKeySigner`] from a raw private key value. +/// +/// Returns `Err` when `private_key` is zero or its bytes are not a valid secp256k1 scalar. +pub fn private_key_from_u256(private_key: U256) -> Result { + if private_key.is_zero() { + return Err("private key cannot be zero".to_string()); + } + PrivateKeySigner::from_slice(&private_key.to_be_bytes::<32>()).map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + // anvil default test mnemonic + const MNEMONIC: &str = "test test test test test test test test test test test junk"; + + #[test] + fn validate_rejects_overflow_and_leaves_malformed_to_builder() { + assert!(validate_bip32_path("m/44'/60'/0'/0/2147483648").is_err()); + assert!(validate_bip32_path("m/2147483648'").is_err()); + assert!(validate_bip32_path("m/2147483648h").is_err()); + assert!(validate_bip32_path("m/02147483648").is_err()); + assert!(validate_bip32_path(&format!("m/{}", u32::MAX)).is_err()); + assert!(validate_bip32_path("m/44'/60'/0'/0/0").is_ok()); + assert!(validate_bip32_path("m/44'/60'/0'/0/0'").is_ok()); + assert!(validate_bip32_path(&format!("m/{}", BIP32_HARDEN - 1)).is_ok()); + assert!(validate_bip32_path("m/+2147483648").is_err()); + assert!(validate_bip32_path("m/not-a-number").is_ok()); + } + + #[test] + fn derive_key_path_stays_string() { + assert_eq!(derive_key_path("m/44'/60'/0'/0", 0), "m/44'/60'/0'/0/0"); + assert_eq!(derive_key_path("m/44'/60'/0'/0/", 1), "m/44'/60'/0'/0/1"); + } + + #[test] + fn derive_key_path_checked_rejects_overflowing_index() { + assert!(derive_key_path_checked("m/44'/60'/0'/0", BIP32_HARDEN).is_err()); + assert_eq!(derive_key_path_checked("m/44'/60'/0'/0", 0).unwrap(), "m/44'/60'/0'/0/0"); + } + + #[test] + fn derive_private_key_rejects_overflowing_path_and_index() { + let err = + derive_private_key::(MNEMONIC, "m/44'/60'/0'/0", BIP32_HARDEN).unwrap_err(); + assert!(err.contains("harden bit"), "{err}"); + + let err = + derive_private_key::(MNEMONIC, "m/44'/60'/0'/0/2147483648'", 0).unwrap_err(); + assert!(err.contains("harden bit") || err.contains("overflow"), "{err}"); + + let key = derive_private_key::(MNEMONIC, "m/44'/60'/0'/0", 0).unwrap(); + assert!(!key.is_zero()); + } +} diff --git a/crates/common/tests/abi.rs b/crates/common/tests/abi.rs new file mode 100644 index 0000000000000..16d16d12f8c88 --- /dev/null +++ b/crates/common/tests/abi.rs @@ -0,0 +1,54 @@ +//! Explorer source lookup tests. + +use alloy_primitives::{Address, map::HashMap}; +use axum::{Json, Router, extract::Query, routing::get}; +use foundry_block_explorers::Client; +use foundry_common::abi::find_source; +use serde_json::json; + +#[tokio::test] +async fn falls_back_to_proxy_source_for_unverified_implementation() { + let proxy = Address::repeat_byte(0x11); + let implementation = Address::repeat_byte(0x22); + let app = Router::new().route( + "/api", + get(move |Query(query): Query>| async move { + Json(if query["address"] == proxy.to_string() { + json!({ + "status": "1", + "message": "OK", + "result": [{ + "SourceCode": "contract Proxy {}", + "ABI": "[]", + "ContractName": "Proxy", + "CompilerVersion": "v0.8.30+commit.73712a01", + "OptimizationUsed": "0", + "EVMVersion": "Default", + "Proxy": "1", + "Implementation": implementation + }] + }) + } else { + json!({ + "status": "0", + "message": "NOTOK", + "result": "Contract source code not verified" + }) + }) + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/api", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let client = Client::builder() + .with_api_url(&url) + .unwrap() + .with_url(&url) + .unwrap() + .no_proxy() + .build() + .unwrap(); + let result = find_source(client, proxy).await; + server.abort(); + assert_eq!(result.unwrap().items[0].contract_name, "Proxy"); +} diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index 3bd2fa44b9e6a..41520bd407d62 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -20,10 +20,10 @@ foundry-compilers = { workspace = true, features = ["svm-solc"] } alloy-chains = { workspace = true, features = ["serde"] } alloy-primitives = { workspace = true, features = ["serde"] } -revm.workspace = true +foundry-evm-hardforks.workspace = true +foundry-evm-networks.workspace = true -solar-parse.workspace = true -solar-interface.workspace = true +solar.workspace = true dirs.workspace = true dunce.workspace = true @@ -34,28 +34,33 @@ globset = "0.4" heck.workspace = true itertools.workspace = true mesc.workspace = true -number_prefix = "0.4" +unit-prefix = "0.5.2" +rayon.workspace = true regex.workspace = true -reqwest.workspace = true semver = { workspace = true, features = ["serde"] } serde_json.workspace = true serde.workspace = true +shlex.workspace = true soldeer-core.workspace = true thiserror.workspace = true toml = { workspace = true, features = ["preserve_order"] } -toml_edit = "0.22" +toml_edit.workspace = true tracing.workspace = true walkdir.workspace = true +which.workspace = true yansi.workspace = true clap = { version = "4", features = ["derive"] } +strum = { workspace = true, features = ["derive"] } [target.'cfg(target_os = "windows")'.dependencies] path-slash = "0.2" [dev-dependencies] +snapbox.workspace = true similar-asserts.workspace = true figment = { workspace = true, features = ["test"] } tempfile.workspace = true [features] -isolate-by-default = [] +monad = ["foundry-evm-hardforks/monad", "foundry-evm-networks/monad"] +base = ["foundry-evm-hardforks/base", "foundry-evm-networks/base"] diff --git a/crates/config/README.md b/crates/config/README.md index bd2b683f1bae4..ac742cc05db01 100644 --- a/crates/config/README.md +++ b/crates/config/README.md @@ -54,255 +54,15 @@ and merge, at the per-key level: The selected profile is the value of the `FOUNDRY_PROFILE` environment variable, or if it is not set, "default". -### All Options +## Configuration Reference -The following is a foundry.toml file with all configuration options set. See also [/config/src/lib.rs](./src/lib.rs) and [/cli/tests/it/config.rs](../forge/tests/it/config.rs). +For a full list of all configuration options, see the [Foundry Book](https://getfoundry.sh/config/reference/overview): -```toml -## defaults for _all_ profiles -[profile.default] -src = 'src' -test = 'test' -script = 'script' -out = 'out' -libs = ['lib'] -auto_detect_remappings = true # recursive auto-detection of remappings -remappings = [] -# list of libraries to link in the form of `::
`: `"src/MyLib.sol:MyLib:0x8De6DDbCd5053d32292AAA0D2105A32d108484a6"` -# the supports remappings -libraries = [] -cache = true -cache_path = 'cache' -broadcast = 'broadcast' -# additional solc allow paths -allow_paths = [] -# additional solc include paths -include_paths = [] -force = false -evm_version = 'prague' -gas_reports = ['*'] -gas_reports_ignore = [] -## Sets the concrete solc version to use, this overrides the `auto_detect_solc` value -# solc = '0.8.10' -auto_detect_solc = true -offline = false -optimizer = false -optimizer_runs = 200 -model_checker = { contracts = { 'a.sol' = [ - 'A1', - 'A2', -], 'b.sol' = [ - 'B1', - 'B2', -] }, engine = 'chc', targets = [ - 'assert', - 'outOfBounds', -], timeout = 10000 } -verbosity = 0 -eth_rpc_url = "https://example.com/" -# Setting this option enables decoding of error traces from mainnet deployed / verified contracts via etherscan -etherscan_api_key = "YOURETHERSCANAPIKEY" -# ignore solc warnings for missing license and exceeded contract size -# known error codes are: ["unreachable", "unused-return", "unused-param", "unused-var", "code-size", "shadowing", "func-mutability", "license", "pragma-solidity", "virtual-interfaces", "same-varname", "too-many-warnings", "constructor-visibility", "init-code-size", "missing-receive-ether", "unnamed-return", "transient-storage"] -# additional warnings can be added using their numeric error code: ["license", 1337] -ignored_error_codes = ["license", "code-size"] -ignored_warnings_from = ["path_to_ignore"] -deny_warnings = false -match_test = "Foo" -no_match_test = "Bar" -match_contract = "Foo" -no_match_contract = "Bar" -match_path = "*/Foo*" -no_match_path = "*/Bar*" -no_match_coverage = "Baz" -# Number of threads to use. Specifying 0 defaults to the number of logical cores. -threads = 0 -# whether to show test execution progress -show_progress = true -ffi = false -always_use_create_2_factory = false -prompt_timeout = 120 -# These are the default callers, generated using `address(uint160(uint256(keccak256("foundry default caller"))))` -sender = '0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38' -tx_origin = '0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38' -initial_balance = '0xffffffffffffffffffffffff' -block_number = 0 -fork_block_number = 0 -chain_id = 1 -# NOTE due to a toml-rs limitation, this value needs to be a string if the desired gas limit exceeds 2**63-1 (9223372036854775807). -# `gas_limit = "max"` is equivalent to `gas_limit = "18446744073709551615"`. This is not recommended -# as it will make infinite loops effectively hang during execution. -gas_limit = 1073741824 -gas_price = 0 -block_base_fee_per_gas = 0 -block_coinbase = '0x0000000000000000000000000000000000000000' -block_timestamp = 0 -block_difficulty = 0 -block_prevrandao = '0x0000000000000000000000000000000000000000' -block_gas_limit = 30000000 -memory_limit = 134217728 -extra_output = ["metadata"] -extra_output_files = [] -names = false -sizes = false -via_ir = false -ast = false -# caches storage retrieved locally for certain chains and endpoints -# can also be restricted to `chains = ["optimism", "mainnet"]` -# by default all endpoints will be cached, alternative options are "remote" for only caching non localhost endpoints and "" -# to disable storage caching entirely set `no_storage_caching = true` -rpc_storage_caching = { chains = "all", endpoints = "all" } -# this overrides `rpc_storage_caching` entirely -no_storage_caching = false -# Whether to store the referenced sources in the metadata as literal data. -use_literal_content = false -# use ipfs method to generate the metadata hash, solc's default. -# To not include the metadata hash, to allow for deterministic code: https://docs.soliditylang.org/en/latest/metadata.html, use "none" -bytecode_hash = "ipfs" -# Whether to append the CBOR-encoded metadata file. -cbor_metadata = true -# How to treat revert (and require) reason strings. -# Possible values are: "default", "strip", "debug" and "verboseDebug". -# "default" does not inject compiler-generated revert strings and keeps user-supplied ones. -# "strip" removes all revert strings (if possible, i.e. if literals are used) keeping side-effects -# "debug" injects strings for compiler-generated internal reverts, implemented for ABI encoders V1 and V2 for now. -# "verboseDebug" even appends further information to user-supplied revert strings (not yet implemented) -revert_strings = "default" -# If this option is enabled, Solc is instructed to generate output (bytecode) only for the required contracts -# this can reduce compile time for `forge test` a bit but is considered experimental at this point. -sparse_mode = false -build_info = true -build_info_path = "build-info" -root = "root" -# Configures permissions for cheatcodes that touch the filesystem like `vm.writeFile` -# `access` restricts how the `path` can be accessed via cheatcodes -# `read-write` | `true` => `read` + `write` access allowed (`vm.readFile` + `vm.writeFile`) -# `none`| `false` => no access -# `read` => only read access (`vm.readFile`) -# `write` => only write access (`vm.writeFile`) -# The `allowed_paths` further lists the paths that are considered, e.g. `./` represents the project root directory -# By default, only read access is granted to the project's out dir, so generated artifacts can be read by default -# following example enables read-write access for the project dir : -# `fs_permissions = [{ access = "read-write", path = "./"}]` -fs_permissions = [{ access = "read", path = "./out"}] -# whether failed assertions should revert -# note that this only applies to native (cheatcode) assertions, invoked on Vm contract -assertions_revert = true -# whether `failed()` should be invoked to check if the test have failed -legacy_assertions = false -[fuzz] -runs = 256 -max_test_rejects = 65536 -seed = '0x3e8' -dictionary_weight = 40 -include_storage = true -include_push_bytes = true - -[invariant] -runs = 256 -depth = 500 -fail_on_revert = false -call_override = false -dictionary_weight = 80 -include_storage = true -include_push_bytes = true -shrink_run_limit = 5000 - -[fmt] -line_length = 100 -tab_width = 2 -bracket_spacing = true -``` - -#### Additional Optimizer settings - -Optimizer components can be tweaked with the `OptimizerDetails` object: - -See [Compiler Input Description `settings.optimizer.details`](https://docs.soliditylang.org/en/latest/using-the-compiler.html#compiler-input-and-output-json-description) - -The `optimizer_details` (`optimizerDetails` also works) settings must be prefixed with the profile they correspond -to: `[profile.default.optimizer_details]` -belongs to the `[profile.default]` profile - -```toml -[profile.default.optimizer_details] -constantOptimizer = true -yul = true -# this sets the `yulDetails` of the `optimizer_details` for the `default` profile -[profile.default.optimizer_details.yulDetails] -stackAllocation = true -optimizerSteps = 'dhfoDgvulfnTUtnIf' -``` - -#### RPC-Endpoints settings - -The `rpc_endpoints` value accepts a list of `alias = ""` pairs. - -The following example declares two pairs: -The alias `optimism` references the endpoint URL directly. -The alias `mainnet` references the environment variable `RPC_MAINNET` which holds the entire URL. -The alias `goerli` references an endpoint that will be interpolated with the value the `GOERLI_API_KEY` holds. - -Environment variables need to be wrapped in `${}` - -```toml -[rpc_endpoints] -optimism = "https://optimism.alchemyapi.io/v2/1234567" -mainnet = "${RPC_MAINNET}" -goerli = "https://eth-goerli.alchemyapi.io/v2/${GOERLI_API_KEY}" -``` - -#### Etherscan API Key settings - -The `etherscan` value accepts a list of `alias = "{key = "", url? ="", chain?= """""}"` items. - -the `key` attribute is always required and should contain the actual API key for that chain or an env var that holds the key in the form `${ENV_VAR}` -The `chain` attribute is optional if the `alias` is the already the `chain` name, such as in `mainnet = { key = "${ETHERSCAN_MAINNET_KEY}"}` -The optional `url` attribute can be used to explicitly set the Etherscan API url, this is the recommended setting for chains not natively supported by name. - -```toml -[etherscan] -mainnet = { key = "${ETHERSCAN_MAINNET_KEY}" } -mainnet2 = { key = "ABCDEFG", chain = "mainnet" } -optimism = { key = "1234576", chain = 42 } -unknownchain = { key = "ABCDEFG", url = "https://" } -``` - -##### Additional Model Checker settings - -[Solidity's built-in model checker](https://docs.soliditylang.org/en/latest/smtchecker.html#tutorial) -is an opt-in module that can be enabled via the `ModelChecker` object. - -See [Compiler Input Description `settings.modelChecker`](https://docs.soliditylang.org/en/latest/using-the-compiler.html#compiler-input-and-output-json-description) -and [the model checker's options](https://docs.soliditylang.org/en/latest/smtchecker.html#smtchecker-options-and-tuning). - -The module is available in `solc` release binaries for OSX and Linux. -The latter requires the z3 library version [4.8.8, 4.8.14] to be installed -in the system (SO version 4.8). - -Similarly to the optimizer settings above, the `model_checker` settings must be -prefixed with the profile they correspond to: `[profile.default.model_checker]` belongs -to the `[profile.default]` profile. - -```toml -[profile.default.model_checker] -contracts = { 'src/Contract.sol' = [ 'Contract' ] } -engine = 'chc' -timeout = 10000 -targets = [ 'assert' ] -``` - -The fields above are recommended when using the model checker. -Setting which contract should be verified is extremely important, otherwise all -available contracts will be verified which can consume a lot of time. -The recommended engine is `chc`, but `bmc` and `all` (runs both) are also -accepted. -It is also important to set a proper timeout (given in milliseconds), since the -default time given to the underlying solvers may not be enough. -If no verification targets are given, only assertions will be checked. - -The model checker will run when `forge build` is invoked, and will show -findings as warnings if any. +- [Default Configuration](https://getfoundry.sh/config/reference/default-config) - All `[profile.default]` options +- [Testing Configuration](https://getfoundry.sh/config/reference/testing) - Fuzz and invariant testing options +- [Formatter Configuration](https://getfoundry.sh/config/reference/formatter) - Code formatting options +- [Linter Configuration](https://getfoundry.sh/config/reference/linter) - Linting options +- [Doc Generator Configuration](https://getfoundry.sh/config/reference/doc-generator) - Documentation generation options ## Environment Variables @@ -314,4 +74,3 @@ supported, this means that `FOUNDRY_SRC` and `DAPP_SRC` are equivalent. Some exceptions to the above are [explicitly ignored](https://github.com/foundry-rs/foundry/blob/10440422e63aae660104e079dfccd5b0ae5fd720/config/src/lib.rs#L1539-L15522) due to security concerns. Environment variables take precedence over values in `foundry.toml`. Values are parsed as a loose form of TOML syntax. -Consider the following examples: diff --git a/crates/config/src/cache.rs b/crates/config/src/cache.rs index 229af4cf8227d..d67825340ab80 100644 --- a/crates/config/src/cache.rs +++ b/crates/config/src/cache.rs @@ -1,9 +1,9 @@ //! Support types for configuring storage caching use crate::Chain; -use number_prefix::NumberPrefix; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use std::{fmt, fmt::Formatter, str::FromStr}; +use unit_prefix::NumberPrefix; /// Settings to configure caching of remote. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -281,7 +281,7 @@ mod tests { block_explorer: 4230000, }, ChainCache { - name: "mumbai".to_string(), + name: "amoy".to_string(), blocks: vec![("1".to_string(), 1), ("2".to_string(), 2)], block_explorer: 0, }, @@ -301,7 +301,7 @@ mod tests { - Block Explorer (4.2 MB)\n\n\t\ - Block 1 (1.0 kB)\n\t\ - Block 2 (2.0 MB)\n\ - - mumbai (3.0 B)\n\t\ + - amoy (3.0 B)\n\t\ - Block Explorer (0.0 B)\n\n\t\ - Block 1 (1.0 B)\n\t\ - Block 2 (2.0 B)\n"; diff --git a/crates/config/src/compilation.rs b/crates/config/src/compilation.rs index bdd222348d055..cb93a1f6d7c5c 100644 --- a/crates/config/src/compilation.rs +++ b/crates/config/src/compilation.rs @@ -7,7 +7,7 @@ use foundry_compilers::{ solc::{Restriction, SolcRestrictions}, }; use semver::VersionReq; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; /// Keeps possible overrides for default settings which users may configure to construct additional /// settings profile. @@ -69,6 +69,7 @@ pub enum RestrictionsError { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct CompilationRestrictions { pub paths: GlobMatcher, + #[serde(default, deserialize_with = "deserialize_version_req")] pub version: Option, pub via_ir: Option, pub bytecode_hash: Option, @@ -85,6 +86,36 @@ pub struct CompilationRestrictions { pub max_evm_version: Option, } +/// Custom deserializer for version field that rejects ambiguous bare version numbers. +fn deserialize_version_req<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let opt_string: Option = Option::deserialize(deserializer)?; + let Some(opt_string) = opt_string else { + return Ok(None); + }; + + let version = opt_string.trim(); + // Reject bare versions like "0.8.11" that lack an operator prefix + if version.chars().next().is_some_and(|c| c.is_ascii_digit()) { + return Err(serde::de::Error::custom(format!( + "Invalid version format '{opt_string}' in compilation_restrictions. \ + Bare version numbers are ambiguous and default to caret requirements (e.g. '^{version}'). \ + Use an explicit constraint such as '={version}' for an exact version or '>={version}' for a minimum version." + ))); + } + + let req = VersionReq::parse(&opt_string).map_err(|e| { + serde::de::Error::custom(format!( + "Invalid version requirement '{opt_string}': {e}. \ + Examples: '=0.8.11' (exact), '>=0.8.11' (minimum), '>=0.8.11 <0.9.0' (range)." + )) + })?; + + Ok(Some(req)) +} + impl TryFrom for RestrictionsWithVersion { type Error = RestrictionsError; diff --git a/crates/config/src/coverage.rs b/crates/config/src/coverage.rs new file mode 100644 index 0000000000000..2900e4dc0166c --- /dev/null +++ b/crates/config/src/coverage.rs @@ -0,0 +1,184 @@ +//! Configuration for `forge coverage`. + +use clap::ValueEnum; +use semver::{Version, VersionReq}; +use serde::{Deserialize, Deserializer, Serialize}; +use std::path::PathBuf; + +/// Coverage report kinds that can be generated by `forge coverage`. +/// +/// Used both as a CLI value (`--report `) and as a TOML configuration +/// value under `[profile..coverage] report = ["..."]`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ValueEnum)] +#[serde(rename_all = "lowercase")] +pub enum CoverageReportKind { + #[default] + Summary, + Lcov, + Debug, + Bytecode, + /// JSON report mapping each test to the source items it covers. + Attribution, +} + +/// Configuration for `forge coverage`, exposed under `[coverage]` and +/// `[profile..coverage]` in `foundry.toml`. +/// +/// Fields here mirror the CLI flags accepted by `forge coverage`. CLI flags +/// take precedence over configuration values when both are provided. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CoverageConfig { + /// The report kinds to generate. Defaults to `["summary"]`. + #[serde(default = "default_report_kinds")] + pub report: Vec, + + /// The version of the LCOV "tracefile" format to use. + /// + /// Defaults to `1` (parsed as `1.0.0`). See `forge coverage --help` for the + /// supported variants and their differences. + #[serde(default = "default_lcov_version", deserialize_with = "deserialize_lcov_version")] + pub lcov_version: Version, + + /// Whether to enable `viaIR` with minimum optimization. Useful as a + /// workaround for "stack too deep" errors at the cost of source map + /// accuracy. + #[serde(default)] + pub ir_minimum: bool, + + /// Path to write the coverage report to (relative to the project root). If + /// unset, the report is written to the project root. + #[serde(default)] + pub report_file: Option, + + /// Whether to include library dependencies in the coverage report. + #[serde(default)] + pub include_libs: bool, + + /// Whether to exclude tests from the coverage report. + #[serde(default)] + pub exclude_tests: bool, + + /// Glob patterns of source files to exclude from the coverage report. + /// + /// Patterns are matched against project-root-relative paths using the + /// `globset` crate's `Glob` semantics (`**`, `*`, `?`, character classes). + /// A file is skipped if any pattern matches. + /// + /// Example: + /// ```toml + /// [profile.default.coverage] + /// skip_files = ["test/**", "script/**", "src/mocks/**"] + /// ``` + #[serde(default)] + pub skip_files: Vec, +} + +impl Default for CoverageConfig { + fn default() -> Self { + Self { + report: default_report_kinds(), + lcov_version: default_lcov_version(), + ir_minimum: false, + report_file: None, + include_libs: false, + exclude_tests: false, + skip_files: Vec::new(), + } + } +} + +fn default_report_kinds() -> Vec { + vec![CoverageReportKind::Summary] +} + +const fn default_lcov_version() -> Version { + Version::new(1, 0, 0) +} + +fn deserialize_lcov_version<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let version = String::deserialize(deserializer)?; + parse_lcov_version(&version).map_err(serde::de::Error::custom) +} + +pub fn parse_lcov_version(s: &str) -> Result { + let vr = VersionReq::parse(&format!("={s}")).map_err(|e| e.to_string())?; + let [c] = &vr.comparators[..] else { + return Err("invalid version".to_string()); + }; + if c.op != semver::Op::Exact { + return Err("invalid version".to_string()); + } + if !c.pre.is_empty() { + return Err("pre-releases are not supported".to_string()); + } + Ok(Version::new(c.major, c.minor.unwrap_or(0), c.patch.unwrap_or(0))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_matches_cli_defaults() { + let cfg = CoverageConfig::default(); + assert_eq!(cfg.report, vec![CoverageReportKind::Summary]); + assert_eq!(cfg.lcov_version, default_lcov_version()); + assert!(!cfg.ir_minimum); + assert!(cfg.report_file.is_none()); + assert!(!cfg.include_libs); + assert!(!cfg.exclude_tests); + assert!(cfg.skip_files.is_empty()); + } + + #[test] + fn deserialize_from_toml() { + let toml = r#" + report = ["summary", "lcov"] + lcov_version = "2.2.0" + ir_minimum = true + report_file = "out/lcov.info" + include_libs = true + exclude_tests = true + skip_files = ["test/**", "src/mocks/**"] + "#; + let cfg: CoverageConfig = toml::from_str(toml).unwrap(); + assert_eq!(cfg.report, vec![CoverageReportKind::Summary, CoverageReportKind::Lcov]); + assert_eq!(cfg.lcov_version, Version::new(2, 2, 0)); + assert!(cfg.ir_minimum); + assert_eq!(cfg.report_file.as_deref(), Some(std::path::Path::new("out/lcov.info"))); + assert!(cfg.include_libs); + assert!(cfg.exclude_tests); + assert_eq!(cfg.skip_files, vec!["test/**".to_string(), "src/mocks/**".to_string()]); + } + + #[test] + fn deserialize_partial_uses_defaults() { + let toml = r#"skip_files = ["src/mocks/**"]"#; + let cfg: CoverageConfig = toml::from_str(toml).unwrap(); + // Defaulted fields keep their default values. + assert_eq!(cfg.report, vec![CoverageReportKind::Summary]); + assert_eq!(cfg.lcov_version, default_lcov_version()); + assert!(!cfg.ir_minimum); + // Set field came through. + assert_eq!(cfg.skip_files, vec!["src/mocks/**".to_string()]); + } + + #[test] + fn deserialize_lcov_version_accepts_cli_formats() { + for (input, expected) in [ + ("0", Version::new(0, 0, 0)), + ("1", default_lcov_version()), + ("1.0", default_lcov_version()), + ("1.1", Version::new(1, 1, 0)), + ("2", Version::new(2, 0, 0)), + ("2.2", Version::new(2, 2, 0)), + ] { + let cfg: CoverageConfig = + toml::from_str(&format!(r#"lcov_version = "{input}""#)).unwrap(); + assert_eq!(cfg.lcov_version, expected); + } + } +} diff --git a/crates/config/src/doc.rs b/crates/config/src/doc.rs index 90ba6a17ab0db..b4d358c64a10a 100644 --- a/crates/config/src/doc.rs +++ b/crates/config/src/doc.rs @@ -20,6 +20,9 @@ pub struct DocConfig { /// The repository url. #[serde(default, skip_serializing_if = "Option::is_none")] pub repository: Option, + /// The Git commit hash, tag, or branch used for source links. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub commit: Option, /// The path to source code (e.g. `tree/main/packages/contracts`). /// Useful for monorepos or for projects with source code located in specific directories. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -36,6 +39,7 @@ impl Default for DocConfig { homepage: Some(PathBuf::from("README.md")), title: String::default(), repository: None, + commit: None, path: None, ignore: Vec::default(), } diff --git a/crates/config/src/endpoints.rs b/crates/config/src/endpoints.rs index bf68915d1b7ba..41486d4786d63 100644 --- a/crates/config/src/endpoints.rs +++ b/crates/config/src/endpoints.rs @@ -63,22 +63,6 @@ pub enum RpcEndpointType { } impl RpcEndpointType { - /// Returns the string variant - pub fn as_endpoint_string(&self) -> Option<&RpcEndpointUrl> { - match self { - Self::String(url) => Some(url), - Self::Config(_) => None, - } - } - - /// Returns the config variant - pub fn as_endpoint_config(&self) -> Option<&RpcEndpoint> { - match self { - Self::Config(config) => Some(config), - Self::String(_) => None, - } - } - /// Returns the url or config this type holds /// /// # Error @@ -137,14 +121,6 @@ impl RpcEndpointUrl { } } - /// Returns the env variant - pub fn as_env(&self) -> Option<&str> { - match self { - Self::Env(val) => Some(val), - Self::Url(_) => None, - } - } - /// Returns the url this type holds /// /// # Error @@ -301,6 +277,11 @@ pub struct RpcEndpoint { /// endpoint url or env pub endpoint: RpcEndpointUrl, + /// Additional fallback endpoints for load-balanced multi-endpoint forking. + /// When set, requests are distributed across all endpoints (primary + extra) + /// with automatic failover. + pub extra_endpoints: Vec, + /// Token to be used as authentication pub auth: Option, @@ -317,6 +298,7 @@ impl RpcEndpoint { pub fn resolve(self) -> ResolvedRpcEndpoint { ResolvedRpcEndpoint { endpoint: self.endpoint.resolve(), + extra_endpoints: self.extra_endpoints.into_iter().map(|e| e.resolve()).collect(), auth: self.auth.map(|auth| auth.resolve()), config: self.config, } @@ -325,7 +307,7 @@ impl RpcEndpoint { impl fmt::Display for RpcEndpoint { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let Self { endpoint, auth, config } = self; + let Self { endpoint, auth, config, .. } = self; write!(f, "{endpoint}")?; write!(f, "{config}")?; if let Some(auth) = auth { @@ -340,16 +322,24 @@ impl Serialize for RpcEndpoint { where S: Serializer, { - if self.config.retries.is_none() - && self.config.retry_backoff.is_none() - && self.config.compute_units_per_second.is_none() - && self.auth.is_none() - { - // serialize as endpoint if there's no additional config + let has_config = self.config.retries.is_some() + || self.config.retry_backoff.is_some() + || self.config.compute_units_per_second.is_some() + || self.auth.is_some(); + + if !has_config && self.extra_endpoints.is_empty() { + // serialize as plain endpoint string if there's no additional config self.endpoint.serialize(serializer) } else { - let mut map = serializer.serialize_map(Some(4))?; - map.serialize_entry("endpoint", &self.endpoint)?; + let mut map = serializer.serialize_map(None)?; + if self.extra_endpoints.is_empty() { + map.serialize_entry("endpoint", &self.endpoint)?; + } else { + // Serialize all endpoints as an array under "endpoints" + let all: Vec<&RpcEndpointUrl> = + std::iter::once(&self.endpoint).chain(&self.extra_endpoints).collect(); + map.serialize_entry("endpoints", &all)?; + } map.serialize_entry("retries", &self.config.retries)?; map.serialize_entry("retry_backoff", &self.config.retry_backoff)?; map.serialize_entry("compute_units_per_second", &self.config.compute_units_per_second)?; @@ -372,10 +362,13 @@ impl<'de> Deserialize<'de> for RpcEndpoint { }); } + // Support both single "endpoint" and array "endpoints" for backwards compatibility #[derive(Deserialize)] struct RpcEndpointConfigInner { #[serde(alias = "url")] - endpoint: RpcEndpointUrl, + endpoint: Option, + /// Array of endpoint URLs for multi-endpoint load balancing + endpoints: Option>, retries: Option, retry_backoff: Option, compute_units_per_second: Option, @@ -384,14 +377,43 @@ impl<'de> Deserialize<'de> for RpcEndpoint { let RpcEndpointConfigInner { endpoint, + endpoints, retries, retry_backoff, compute_units_per_second, auth, } = serde_json::from_value(value).map_err(serde::de::Error::custom)?; + let (primary, extra) = match (endpoint, endpoints) { + // Single endpoint: endpoint = "..." + (Some(ep), None) => (ep, vec![]), + // Array of endpoints: endpoints = ["...", "..."] + (None, Some(mut eps)) => { + if eps.is_empty() { + return Err(serde::de::Error::custom( + "endpoints array must contain at least one URL", + )); + } + let primary = eps.remove(0); + (primary, eps) + } + // Both provided — error + (Some(_), Some(_)) => { + return Err(serde::de::Error::custom( + "cannot specify both `endpoint` and `endpoints`", + )); + } + // Neither provided — error + (None, None) => { + return Err(serde::de::Error::custom( + "must specify either `endpoint` or `endpoints`", + )); + } + }; + Ok(Self { - endpoint, + endpoint: primary, + extra_endpoints: extra, auth, config: RpcEndpointConfig { retries, retry_backoff, compute_units_per_second }, }) @@ -408,6 +430,7 @@ impl Default for RpcEndpoint { fn default() -> Self { Self { endpoint: RpcEndpointUrl::Url("http://localhost:8545".to_string()), + extra_endpoints: vec![], config: RpcEndpointConfig::default(), auth: None, } @@ -418,21 +441,38 @@ impl Default for RpcEndpoint { #[derive(Clone, Debug, PartialEq, Eq)] pub struct ResolvedRpcEndpoint { pub endpoint: Result, + /// Additional resolved endpoints for multi-endpoint load balancing. + pub extra_endpoints: Vec>, pub auth: Option>, pub config: RpcEndpointConfig, } impl ResolvedRpcEndpoint { - /// Returns the url this type holds, see [`RpcEndpoint::resolve`] + /// Returns the primary url this type holds, see [`RpcEndpoint::resolve`] pub fn url(&self) -> Result { self.endpoint.clone() } + /// Returns all resolved URLs (primary + extra) for multi-endpoint configurations. + /// Returns an empty vec if no extra endpoints are configured. + pub fn all_urls(&self) -> Result, UnresolvedEnvVarError> { + let primary = self.endpoint.clone()?; + if self.extra_endpoints.is_empty() { + return Ok(vec![primary]); + } + let mut urls = vec![primary]; + for ep in &self.extra_endpoints { + urls.push(ep.clone()?); + } + Ok(urls) + } + // Returns true if all environment variables are resolved successfully pub fn is_unresolved(&self) -> bool { let endpoint_err = self.endpoint.is_err(); - let auth_err = self.auth.as_ref().map(|auth| auth.is_err()).unwrap_or(false); - endpoint_err || auth_err + let extra_err = self.extra_endpoints.iter().any(|e| e.is_err()); + let auth_err = self.auth.as_ref().is_some_and(|auth| auth.is_err()); + endpoint_err || extra_err || auth_err } // Attempts to resolve unresolved environment variables into a new instance @@ -443,6 +483,11 @@ impl ResolvedRpcEndpoint { if let Err(err) = self.endpoint { self.endpoint = err.try_resolve() } + for ep in &mut self.extra_endpoints { + if let Err(err) = std::mem::replace(ep, Ok(String::new())) { + *ep = err.try_resolve(); + } + } if let Some(Err(err)) = self.auth { self.auth = Some(err.try_resolve()) } @@ -477,10 +522,36 @@ impl DerefMut for ResolvedRpcEndpoints { } } +/// Tempo mainnet's MPP-enabled RPC endpoint. +pub const TEMPO_RPC_URL: &str = "https://rpc.mpp.tempo.xyz"; + +/// Tempo testnet's MPP-enabled RPC endpoint. +pub const MODERATO_RPC_URL: &str = "https://rpc.mpp.moderato.tempo.xyz"; + +/// Returns the URL for a built-in RPC alias, if one exists. +/// +/// Built-in aliases act as fallbacks: they are only used when the alias has **not** been +/// defined by the user in `[rpc_endpoints]` or resolved via MESC. +pub fn builtin_rpc_url(alias: &str) -> Option<&'static str> { + match alias { + "tempo" => Some(TEMPO_RPC_URL), + "moderato" => Some(MODERATO_RPC_URL), + _ => None, + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn builtin_tempo_aliases_use_mpp_endpoints() { + assert_eq!(builtin_rpc_url("tempo"), Some(TEMPO_RPC_URL)); + assert_eq!(builtin_rpc_url("moderato"), Some(MODERATO_RPC_URL)); + assert!(TEMPO_RPC_URL.contains(".mpp.")); + assert!(MODERATO_RPC_URL.contains(".mpp.")); + } + #[test] fn serde_rpc_config() { let s = r#"{ @@ -495,6 +566,7 @@ mod tests { config, RpcEndpoint { endpoint: RpcEndpointUrl::Url("http://localhost:8545".to_string()), + extra_endpoints: vec![], config: RpcEndpointConfig { retries: Some(5), retry_backoff: Some(250), @@ -510,6 +582,7 @@ mod tests { config, RpcEndpoint { endpoint: RpcEndpointUrl::Url("http://localhost:8545".to_string()), + extra_endpoints: vec![], config: RpcEndpointConfig { retries: None, retry_backoff: None, @@ -519,4 +592,62 @@ mod tests { } ); } + + #[test] + fn serde_rpc_config_multi_endpoints() { + // Array of endpoints via "endpoints" key + let s = r#"{ + "endpoints": ["https://rpc1.example.com", "https://rpc2.example.com", "https://rpc3.example.com"], + "retries": 5, + "retry_backoff": 1000 + }"#; + let config: RpcEndpoint = serde_json::from_str(s).unwrap(); + assert_eq!( + config, + RpcEndpoint { + endpoint: RpcEndpointUrl::Url("https://rpc1.example.com".to_string()), + extra_endpoints: vec![ + RpcEndpointUrl::Url("https://rpc2.example.com".to_string()), + RpcEndpointUrl::Url("https://rpc3.example.com".to_string()), + ], + config: RpcEndpointConfig { + retries: Some(5), + retry_backoff: Some(1000), + compute_units_per_second: None, + }, + auth: None, + } + ); + + // Resolved URLs + let resolved = config.resolve(); + let all_urls = resolved.all_urls().unwrap(); + assert_eq!( + all_urls, + vec![ + "https://rpc1.example.com".to_string(), + "https://rpc2.example.com".to_string(), + "https://rpc3.example.com".to_string(), + ] + ); + } + + #[test] + fn serde_rpc_config_rejects_both_endpoint_and_endpoints() { + let s = r#"{ + "endpoint": "https://rpc1.example.com", + "endpoints": ["https://rpc2.example.com"] + }"#; + let result: Result = serde_json::from_str(s); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("cannot specify both")); + } + + #[test] + fn serde_rpc_config_rejects_empty_endpoints() { + let s = r#"{ "endpoints": [] }"#; + let result: Result = serde_json::from_str(s); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("at least one URL")); + } } diff --git a/crates/config/src/error.rs b/crates/config/src/error.rs index eba84a57da0dc..9881d29bd0aab 100644 --- a/crates/config/src/error.rs +++ b/crates/config/src/error.rs @@ -12,8 +12,8 @@ pub struct ExtractConfigError { } impl ExtractConfigError { - /// Wraps the figment error - pub fn new(error: figment::Error) -> Self { + /// Wraps the figment error. + pub const fn new(error: figment::Error) -> Self { Self { error } } } @@ -22,7 +22,7 @@ impl fmt::Display for ExtractConfigError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let mut unique_errors = Vec::with_capacity(self.error.count()); let mut unique = HashSet::with_capacity(self.error.count()); - for err in self.error.clone().into_iter() { + for err in self.error.clone() { let err = if err .metadata .as_ref() @@ -63,7 +63,7 @@ impl Error for ExtractConfigError { pub enum FoundryConfigError { /// An error thrown during toml parsing Toml(figment::Error), - /// Any other error thrown when constructing the config's figment + /// Any other error thrown when constructing the config's figment. Other(figment::Error), } @@ -140,6 +140,10 @@ pub enum SolidityErrorCode { TransientStorageUsed, /// There are more than 256 warnings. Ignoring the rest. TooManyWarnings, + /// Warning: 'transfer' is deprecated and scheduled for removal. + TransferDeprecated, + /// Warning: Natspec memory-safe-assembly special comment for inline assembly is deprecated. + NatspecMemorySafeAssemblyDeprecated, /// All other error codes Other(u64), } @@ -148,7 +152,7 @@ impl SolidityErrorCode { /// The textual identifier for this error /// /// Returns `Err(code)` if unknown error - pub fn as_str(&self) -> Result<&'static str, u64> { + pub const fn as_str(&self) -> Result<&'static str, u64> { let s = match self { Self::SpdxLicenseNotProvided => "license", Self::VisibilityForConstructorIsIgnored => "constructor-visibility", @@ -167,6 +171,8 @@ impl SolidityErrorCode { Self::PragmaSolidity => "pragma-solidity", Self::TransientStorageUsed => "transient-storage", Self::TooManyWarnings => "too-many-warnings", + Self::TransferDeprecated => "transfer-deprecated", + Self::NatspecMemorySafeAssemblyDeprecated => "natspec-memory-safe-assembly-deprecated", Self::Other(code) => return Err(*code), }; Ok(s) @@ -193,6 +199,8 @@ impl From for u64 { SolidityErrorCode::PragmaSolidity => 3420, SolidityErrorCode::TransientStorageUsed => 2394, SolidityErrorCode::TooManyWarnings => 4591, + SolidityErrorCode::TransferDeprecated => 9207, + SolidityErrorCode::NatspecMemorySafeAssemblyDeprecated => 2424, SolidityErrorCode::Other(code) => code, } } @@ -229,6 +237,8 @@ impl FromStr for SolidityErrorCode { "pragma-solidity" => Self::PragmaSolidity, "transient-storage" => Self::TransientStorageUsed, "too-many-warnings" => Self::TooManyWarnings, + "transfer-deprecated" => Self::TransferDeprecated, + "natspec-memory-safe-assembly-deprecated" => Self::NatspecMemorySafeAssemblyDeprecated, _ => return Err(format!("Unknown variant {s}")), }; @@ -255,6 +265,9 @@ impl From for SolidityErrorCode { 5740 => Self::Unreachable, 3420 => Self::PragmaSolidity, 2394 => Self::TransientStorageUsed, + 4591 => Self::TooManyWarnings, + 9207 => Self::TransferDeprecated, + 2424 => Self::NatspecMemorySafeAssemblyDeprecated, other => Self::Other(other), } } diff --git a/crates/config/src/etherscan.rs b/crates/config/src/etherscan.rs index d8aee66eb2bc2..f5df114990240 100644 --- a/crates/config/src/etherscan.rs +++ b/crates/config/src/etherscan.rs @@ -9,7 +9,6 @@ use figment::{ providers::Env, value::{Dict, Map}, }; -use foundry_block_explorers::EtherscanApiVersion; use heck::ToKebabCase; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use std::{ @@ -19,9 +18,6 @@ use std::{ time::Duration, }; -/// The user agent to use when querying the etherscan API. -pub const ETHERSCAN_USER_AGENT: &str = concat!("foundry/", env!("CARGO_PKG_VERSION")); - /// A [Provider] that provides Etherscan API key from the environment if it's not empty. /// /// This prevents `ETHERSCAN_API_KEY=""` if it's set but empty @@ -53,7 +49,11 @@ pub enum EtherscanConfigError { #[error(transparent)] Unresolved(#[from] UnresolvedEnvVarError), - #[error("No known Etherscan API URL for config{0} with chain `{1}`. Please specify a `url`")] + #[error( + "No known Etherscan API URL for chain `{1}`. To fix this, please:\n\ + 1. Specify a `url` {0}\n\ + 2. Verify the chain `{1}` is correct" + )] UnknownChain(String, Chain), #[error("At least one of `url` or `chain` must be present{0}")] @@ -83,14 +83,54 @@ impl EtherscanConfigs { self.configs.values().find(|config| config.chain == Some(chain)) } + /// Picks the explorer config to use, given the settings that select between the entries. + /// + /// `alias` names an entry outright and wins if it matches one. Otherwise the first entry whose + /// chain id matches `chain` is used, with `api_key` — which usually comes from an env var or a + /// CLI flag — overriding the key that entry carries. With no matching entry, `api_key` alone is + /// enough to build a config for `chain`. + /// + /// Shared by [`Config::get_etherscan_config_with_chain`] and by consumers that keep only a + /// snapshot of the config and resolve later, against a chain they don't learn until runtime. + pub fn resolve_for( + &self, + alias: Option<&str>, + api_key: Option<&str>, + chain: Option, + ) -> Result, EtherscanConfigError> { + if let Some(alias) = alias + && self.contains_key(alias) + { + return self.clone().resolved().remove(alias).transpose(); + } + + if let Some(res) = chain.and_then(|chain| self.clone().resolved().find_chain(chain)) { + match (res, api_key) { + (Ok(mut config), Some(key)) => { + config.key = key.to_string(); + return Ok(Some(config)); + } + (Ok(config), None) => return Ok(Some(config)), + (Err(err), None) => return Err(err), + // Unresolvable entry, but there is a key to fall back on. + (Err(_), Some(_)) => {} + } + } + + if let Some(key) = api_key { + return Ok(ResolvedEtherscanConfig::create(key, chain.unwrap_or_default())); + } + Ok(None) + } + /// Returns all (alias -> url) pairs - pub fn resolved(self, default_api_version: EtherscanApiVersion) -> ResolvedEtherscanConfigs { + pub fn resolved(self) -> ResolvedEtherscanConfigs { ResolvedEtherscanConfigs { configs: self .configs .into_iter() .map(|(name, e)| { - let resolved = e.resolve(Some(&name), default_api_version); + let resolved = e.resolve(Some(&name)); (name, resolved) }) .collect(), @@ -135,11 +175,11 @@ impl ResolvedEtherscanConfigs { self, chain: Chain, ) -> Option> { - for (_, config) in self.configs.into_iter() { + for (_, config) in self.configs { match config { Ok(c) if c.chain == Some(chain) => return Some(Ok(c)), Err(e) => return Some(Err(e)), - _ => continue, + _ => {} } } None @@ -174,9 +214,6 @@ pub struct EtherscanConfig { /// Etherscan API URL #[serde(default, skip_serializing_if = "Option::is_none")] pub url: Option, - /// Etherscan API Version. Defaults to v2 - #[serde(default, alias = "api-version", skip_serializing_if = "Option::is_none")] - pub api_version: Option, /// The etherscan API KEY that's required to make requests pub key: EtherscanApiKey, } @@ -191,11 +228,8 @@ impl EtherscanConfig { pub fn resolve( self, alias: Option<&str>, - default_api_version: EtherscanApiVersion, ) -> Result { - let Self { chain, mut url, key, api_version } = self; - - let api_version = api_version.unwrap_or(default_api_version); + let Self { chain, mut url, key } = self; if let Some(url) = &mut url { *url = interpolate(url)?; @@ -226,23 +260,17 @@ impl EtherscanConfig { match (chain, url) { (Some(chain), Some(api_url)) => Ok(ResolvedEtherscanConfig { api_url, - api_version, browser_url: chain.etherscan_urls().map(|(_, url)| url.to_string()), key, chain: Some(chain), }), - (Some(chain), None) => ResolvedEtherscanConfig::create(key, chain, api_version) - .ok_or_else(|| { - let msg = alias.map(|a| format!(" `{a}`")).unwrap_or_default(); - EtherscanConfigError::UnknownChain(msg, chain) - }), - (None, Some(api_url)) => Ok(ResolvedEtherscanConfig { - api_url, - browser_url: None, - key, - chain: None, - api_version, + (Some(chain), None) => ResolvedEtherscanConfig::create(key, chain).ok_or_else(|| { + let msg = alias.map(|a| format!("for `{a}`")).unwrap_or_default(); + EtherscanConfigError::UnknownChain(msg, chain) }), + (None, Some(api_url)) => { + Ok(ResolvedEtherscanConfig { api_url, browser_url: None, key, chain: None }) + } (None, None) => { let msg = alias .map(|a| format!(" for Etherscan config with unknown alias `{a}`")) @@ -264,9 +292,6 @@ pub struct ResolvedEtherscanConfig { pub browser_url: Option, /// The resolved API key. pub key: String, - /// Etherscan API Version. - #[serde(default)] - pub api_version: EtherscanApiVersion, /// The chain name or EIP-155 chain ID. #[serde(default, skip_serializing_if = "Option::is_none")] pub chain: Option, @@ -274,16 +299,11 @@ pub struct ResolvedEtherscanConfig { impl ResolvedEtherscanConfig { /// Creates a new instance using the api key and chain - pub fn create( - api_key: impl Into, - chain: impl Into, - api_version: EtherscanApiVersion, - ) -> Option { + pub fn create(api_key: impl Into, chain: impl Into) -> Option { let chain = chain.into(); let (api_url, browser_url) = chain.etherscan_urls()?; Some(Self { api_url: api_url.to_string(), - api_version, browser_url: Some(browser_url.to_string()), key: api_key.into(), chain: Some(chain), @@ -315,7 +335,21 @@ impl ResolvedEtherscanConfig { self, ) -> Result { - let Self { api_url, browser_url, key: api_key, chain, api_version } = self; + self.into_client_with_no_proxy(false) + } + + /// Same as [`Self::into_client`] but optionally disables automatic proxy detection. + /// + /// When `no_proxy` is `true`, calls [`foundry_block_explorers::ClientBuilder::no_proxy`], + /// which prevents system proxy lookups that can crash in sandboxed environments (e.g., + /// Cursor IDE, macOS App Sandbox). + /// See: + pub fn into_client_with_no_proxy( + self, + no_proxy: bool, + ) -> Result + { + let Self { api_url, browser_url, key: api_key, chain } = self; let chain = chain.unwrap_or_default(); let cache = Config::foundry_etherscan_chain_cache_dir(chain); @@ -327,20 +361,26 @@ impl ResolvedEtherscanConfig { } } - let api_url = into_url(&api_url)?; - let client = reqwest::Client::builder() - .user_agent(ETHERSCAN_USER_AGENT) - .tls_built_in_root_certs(api_url.scheme() == "https") - .build()?; + // Disable automatic proxy detection. In sandboxed environments (e.g., Cursor IDE, + // macOS App Sandbox), reqwest's system proxy lookup via SCDynamicStore can crash + // when the API returns NULL. See: https://github.com/foundry-rs/foundry/issues/12733 let mut client_builder = foundry_block_explorers::Client::builder() - .with_client(client) - .with_api_version(api_version) .with_api_key(api_key) .with_cache(cache, Duration::from_secs(24 * 60 * 60)); - if let Some(browser_url) = browser_url { + if no_proxy { + client_builder = client_builder.no_proxy(); + } + if let Some(ref browser_url) = browser_url { client_builder = client_builder.with_url(browser_url)?; } - client_builder.chain(chain)?.build() + + // Use the provided URL (either custom from foundry.toml or chain's default from resolve()) + client_builder = client_builder.with_api_url(&api_url)?; + // Fallback: Use api_url as browser URL if browser_url is not set + if browser_url.is_none() { + client_builder = client_builder.with_url(&api_url)?; + } + client_builder.build() } } @@ -361,22 +401,6 @@ pub enum EtherscanApiKey { } impl EtherscanApiKey { - /// Returns the key variant - pub fn as_key(&self) -> Option<&str> { - match self { - Self::Key(url) => Some(url), - Self::Env(_) => None, - } - } - - /// Returns the env variant - pub fn as_env(&self) -> Option<&str> { - match self { - Self::Env(val) => Some(val), - Self::Key(_) => None, - } - } - /// Returns the key this type holds /// /// # Error @@ -420,13 +444,6 @@ impl fmt::Display for EtherscanApiKey { } } -/// This is a hack to work around `IntoUrl`'s sealed private functions, which can't be called -/// normally. -#[inline] -fn into_url(url: impl reqwest::IntoUrl) -> std::result::Result { - url.into_url() -} - #[cfg(test)] mod tests { use super::*; @@ -441,36 +458,17 @@ mod tests { chain: Some(Mainnet.into()), url: None, key: EtherscanApiKey::Key("ABCDEFG".to_string()), - api_version: None, }, ); - let mut resolved = configs.resolved(EtherscanApiVersion::V2); + let mut resolved = configs.resolved(); let config = resolved.remove("mainnet").unwrap().unwrap(); - // None version = None - assert_eq!(config.api_version, EtherscanApiVersion::V2); - let client = config.into_client().unwrap(); - assert_eq!(*client.etherscan_api_version(), EtherscanApiVersion::V2); - } - - #[test] - fn can_create_v1_client_via_chain() { - let mut configs = EtherscanConfigs::default(); - configs.insert( - "mainnet".to_string(), - EtherscanConfig { - chain: Some(Mainnet.into()), - url: None, - api_version: Some(EtherscanApiVersion::V1), - key: EtherscanApiKey::Key("ABCDEG".to_string()), - }, - ); - let mut resolved = configs.resolved(EtherscanApiVersion::V2); - let config = resolved.remove("mainnet").unwrap().unwrap(); - assert_eq!(config.api_version, EtherscanApiVersion::V1); let client = config.into_client().unwrap(); - assert_eq!(*client.etherscan_api_version(), EtherscanApiVersion::V1); + assert_eq!( + client.etherscan_api_url().as_str(), + "https://api.etherscan.io/v2/api?chainid=1" + ); } #[test] @@ -482,11 +480,10 @@ mod tests { chain: Some(Mainnet.into()), url: Some("https://api.etherscan.io/api".to_string()), key: EtherscanApiKey::Key("ABCDEFG".to_string()), - api_version: None, }, ); - let mut resolved = configs.resolved(EtherscanApiVersion::V2); + let mut resolved = configs.resolved(); let config = resolved.remove("mainnet").unwrap().unwrap(); let _ = config.into_client().unwrap(); } @@ -500,12 +497,11 @@ mod tests { EtherscanConfig { chain: Some(Mainnet.into()), url: Some("https://api.etherscan.io/api".to_string()), - api_version: None, key: EtherscanApiKey::Env(format!("${{{env}}}")), }, ); - let mut resolved = configs.clone().resolved(EtherscanApiVersion::V2); + let mut resolved = configs.clone().resolved(); let config = resolved.remove("mainnet").unwrap(); assert!(config.is_err()); @@ -513,11 +509,12 @@ mod tests { std::env::set_var(env, "ABCDEFG"); } - let mut resolved = configs.resolved(EtherscanApiVersion::V2); + let mut resolved = configs.resolved(); let config = resolved.remove("mainnet").unwrap().unwrap(); assert_eq!(config.key, "ABCDEFG"); let client = config.into_client().unwrap(); - assert_eq!(*client.etherscan_api_version(), EtherscanApiVersion::V2); + // Custom URL should be used even when chain has a default URL + assert_eq!(client.etherscan_api_url().as_str(), "https://api.etherscan.io/api"); unsafe { std::env::remove_var(env); @@ -533,11 +530,10 @@ mod tests { chain: None, url: Some("https://api.etherscan.io/api".to_string()), key: EtherscanApiKey::Key("ABCDEFG".to_string()), - api_version: None, }, ); - let mut resolved = configs.clone().resolved(EtherscanApiVersion::V2); + let mut resolved = configs.clone().resolved(); let config = resolved.remove("blast_sepolia").unwrap().unwrap(); assert_eq!(config.chain, Some(Chain::blast_sepolia())); } @@ -548,13 +544,57 @@ mod tests { chain: None, url: Some("https://api.etherscan.io/api".to_string()), key: EtherscanApiKey::Key("ABCDEFG".to_string()), - api_version: None, }; - let resolved = - config.clone().resolve(Some("base_sepolia"), EtherscanApiVersion::V2).unwrap(); + let resolved = config.clone().resolve(Some("base_sepolia")).unwrap(); assert_eq!(resolved.chain, Some(Chain::base_sepolia())); - let resolved = config.resolve(Some("base-sepolia"), EtherscanApiVersion::V2).unwrap(); + let resolved = config.resolve(Some("base-sepolia")).unwrap(); assert_eq!(resolved.chain, Some(Chain::base_sepolia())); } + + #[test] + fn can_create_client_with_custom_url_for_chain_without_default_url() { + // Chains without default Etherscan URLs (e.g., Dev, AnvilHardhat networks) + // should work if a custom URL is provided in foundry.toml. + let mut configs = EtherscanConfigs::default(); + configs.insert( + "dev".to_string(), + EtherscanConfig { + chain: Some(Chain::dev()), + url: Some("https://custom.api.url/verify/etherscan".to_string()), + key: EtherscanApiKey::Key("test_key".to_string()), + }, + ); + + let mut resolved = configs.resolved(); + let config = resolved.remove("dev").unwrap().unwrap(); + let result = config.into_client(); + assert!( + result.is_ok(), + "Should succeed with custom URL even for chains without default Etherscan URLs" + ); + } + + #[test] + fn fails_without_custom_url_for_chain_without_default_url() { + // Chains without default Etherscan URLs (e.g., Dev, AnvilHardhat networks) + // should fail if no custom URL is provided in foundry.toml. + let mut configs = EtherscanConfigs::default(); + configs.insert( + "dev".to_string(), + EtherscanConfig { + chain: Some(Chain::dev()), + url: None, + key: EtherscanApiKey::Key("test_key".to_string()), + }, + ); + + let mut resolved = configs.resolved(); + let config = resolved.remove("dev").unwrap(); + + assert!( + config.is_err(), + "Should fail: chains without default Etherscan URLs require custom URL" + ); + } } diff --git a/crates/config/src/extend.rs b/crates/config/src/extend.rs new file mode 100644 index 0000000000000..5b4eb23322272 --- /dev/null +++ b/crates/config/src/extend.rs @@ -0,0 +1,76 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +/// Strategy for extending configuration from a base file. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ExtendStrategy { + /// Uses `admerge` figment strategy. + /// Arrays are concatenated (base elements + local elements). + /// Other values are replaced (local values override base values). + #[default] + ExtendArrays, + + /// Uses `merge` figment strategy. + /// Arrays are replaced entirely (local arrays replace base arrays). + /// Other values are replaced (local values override base values). + ReplaceArrays, + + /// Throws an error if any of the keys in the inherited toml file are also in `foundry.toml`. + NoCollision, +} + +/// Configuration for extending from a base file. +/// +/// Supports two formats: +/// - String: `extends = "base.toml"` +/// - Object: `extends = { path = "base.toml", strategy = "no-collision" }` +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum Extends { + /// Simple string path to base file + Path(String), + /// Detailed configuration with path and strategy + Config(ExtendConfig), +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExtendConfig { + pub path: String, + #[serde(default)] + pub strategy: Option, +} + +impl Extends { + /// Get the path to the base file + pub fn path(&self) -> &str { + match self { + Self::Path(path) => path, + Self::Config(config) => &config.path, + } + } + + /// Get the strategy to use for extending + pub fn strategy(&self) -> ExtendStrategy { + match self { + Self::Path(_) => ExtendStrategy::default(), + Self::Config(config) => config.strategy.unwrap_or_default(), + } + } +} + +// -- HELPERS ----------------------------------------------------------------- + +// Helper structs to only extract the 'extends' field and its strategy from the profiles +#[derive(Deserialize, Default)] +pub(crate) struct ExtendsPartialConfig { + #[serde(default)] + pub profile: Option>, +} + +#[derive(Deserialize, Default)] +pub(crate) struct ExtendsHelper { + #[serde(default)] + pub extends: Option, +} diff --git a/crates/config/src/external_compiler.rs b/crates/config/src/external_compiler.rs new file mode 100644 index 0000000000000..9d95eca64d5be --- /dev/null +++ b/crates/config/src/external_compiler.rs @@ -0,0 +1,20 @@ +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use std::path::PathBuf; + +/// Configuration for an external compiler adapter executable. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExternalCompiler { + /// Stable user-defined identifier used to namespace cache and artifact entries. + pub id: String, + /// Absolute or project-relative adapter executable path. + pub command: PathBuf, + /// Arguments passed to the adapter executable. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub args: Vec, + /// Compiler-native project roots, relative to the Foundry project root. + pub roots: Vec, + /// Opaque adapter-specific settings. + #[serde(default, skip_serializing_if = "Map::is_empty")] + pub settings: Map, +} diff --git a/crates/config/src/fee.rs b/crates/config/src/fee.rs new file mode 100644 index 0000000000000..959086dca4633 --- /dev/null +++ b/crates/config/src/fee.rs @@ -0,0 +1,79 @@ +//! EIP-1559 fee estimation configuration. + +use clap::ValueEnum; +use serde::{Deserialize, Serialize}; +use std::{fmt, str::FromStr}; + +/// Controls how EIP-1559 fees are estimated, analogous to wallet UIs that offer +/// "low" / "market" / "aggressive" gas options. +/// +/// Used both as a CLI value (`--estimate `) and as a TOML +/// configuration value (`eip1559_fee_estimate = "market"`). +/// +/// The estimate controls two parameters: +/// - the `eth_feeHistory` reward percentile used to derive the priority fee, and +/// - the multiplier applied to the base fee when building `maxFeePerGas`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ValueEnum)] +#[serde(rename_all = "lowercase")] +pub enum Eip1559FeeEstimatePreset { + /// Lower priority-fee percentile (10th) and a tighter base-fee buffer + /// (`base_fee * 1.5`). This only lowers the tip estimate and the max-fee cap, + /// not the base fee that is actually paid, and risks the transaction stalling + /// if the base fee rises. + Low, + /// Default: `base_fee * 2` plus a 20th-percentile priority fee. + #[default] + Market, + /// Higher priority-fee percentile (50th) to bid a larger tip for faster + /// inclusion. + Aggressive, +} + +impl Eip1559FeeEstimatePreset { + /// The reward percentile sampled from `eth_feeHistory` to estimate the + /// priority fee. + pub const fn reward_percentile(&self) -> f64 { + match self { + Self::Low => 10.0, + Self::Market => 20.0, + Self::Aggressive => 50.0, + } + } + + /// The base-fee multiplier, expressed as `(numerator, denominator)`, applied + /// when building `maxFeePerGas` so the transaction stays includable if the + /// base fee rises before it is mined. + pub const fn base_fee_multiplier(&self) -> (u128, u128) { + match self { + Self::Low => (3, 2), + Self::Market => (2, 1), + Self::Aggressive => (2, 1), + } + } +} + +impl fmt::Display for Eip1559FeeEstimatePreset { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + Self::Low => "low", + Self::Market => "market", + Self::Aggressive => "aggressive", + }; + f.write_str(s) + } +} + +impl FromStr for Eip1559FeeEstimatePreset { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "low" => Ok(Self::Low), + "market" => Ok(Self::Market), + "aggressive" => Ok(Self::Aggressive), + other => Err(format!( + "invalid EIP-1559 fee estimate preset: {other} (expected one of: low, market, aggressive)" + )), + } + } +} diff --git a/crates/config/src/filter.rs b/crates/config/src/filter.rs index b828891d1540b..7ce9516c8aa61 100644 --- a/crates/config/src/filter.rs +++ b/crates/config/src/filter.rs @@ -9,20 +9,6 @@ use std::{ str::FromStr, }; -/// Expand globs with a root path. -pub fn expand_globs( - root: &Path, - patterns: impl IntoIterator>, -) -> eyre::Result> { - let mut expanded = Vec::new(); - for pattern in patterns { - for paths in glob::glob(&root.join(pattern.as_ref()).display().to_string())? { - expanded.push(paths?); - } - } - Ok(expanded) -} - /// A `globset::Glob` that creates its `globset::GlobMatcher` when its created, so it doesn't need /// to be compiled when the filter functions `TestFilter` functions are called. #[derive(Clone, Debug)] @@ -59,9 +45,8 @@ impl GlobMatcher { if path.is_relative() && Path::new(self.glob().glob()).is_absolute() { if let Ok(canonicalized_path) = dunce::canonicalize(path) { return self.matcher.is_match(&canonicalized_path); - } else { - return false; } + return false; } false @@ -139,11 +124,11 @@ impl FileFilter for SkipBuildFilters { /// Only returns a match if _no_ exclusion filter matches fn is_match(&self, file: &Path) -> bool { self.matchers.iter().all(|matcher| { - if !matcher.is_match_exclude(file) { - false - } else { + if matcher.is_match_exclude(file) { file.strip_prefix(&self.project_root) .map_or(true, |stripped| matcher.is_match_exclude(stripped)) + } else { + false } }) } @@ -181,7 +166,7 @@ impl SkipBuildFilter { } /// Returns the pattern to match against a file - pub fn file_pattern(&self) -> &str { + pub const fn file_pattern(&self) -> &str { match self { Self::Tests => ".t.sol", Self::Scripts => ".s.sol", @@ -198,9 +183,70 @@ impl FromStr for SkipBuildFilter { } } +/// Expand globs with a root path. +/// +/// A trailing recursive component, like `src/**`, only matches subdirectories. Replace it with its +/// parent so directory-aware consumers cover every descendant without enumerating the tree. +pub fn expand_globs( + root: &Path, + patterns: impl IntoIterator>, +) -> eyre::Result> { + let mut expanded = Vec::new(); + for pattern in patterns { + let mut pattern = Path::new(pattern.as_ref()); + if pattern.ends_with("**") { + pattern = pattern.parent().unwrap_or_else(|| Path::new("")); + } + for path in glob::glob(&root.join(pattern).display().to_string())? { + expanded.push(path?); + } + } + Ok(expanded) +} + +/// Returns whether `file` is contained in `paths`, or lies underneath a directory contained in +/// `paths`. +/// +/// Expanded ignore globs can contain directories, e.g. `test/**` yields the directories under +/// `test`, so an ignore check must also match the files below every entry. `base` is used to +/// resolve `file` when it is relative. +pub fn is_ignored_path(file: &Path, paths: &[PathBuf], base: &Path) -> bool { + let joined = base.join(file); + paths.iter().any(|path| file.starts_with(path) || joined.starts_with(path)) +} + #[cfg(test)] mod tests { use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn test_expand_trailing_recursive_glob() { + let root = tempdir().unwrap(); + let src = root.path().join("src"); + fs::create_dir(&src).unwrap(); + + assert_eq!(expand_globs(root.path(), ["src/**"]).unwrap(), vec![src]); + } + + #[test] + fn test_is_ignored_path() { + let base = Path::new("/root"); + let ignored = [PathBuf::from("/root/test"), PathBuf::from("/root/src/A.sol")]; + + // Exact file matches, absolute and relative. + assert!(is_ignored_path(Path::new("/root/src/A.sol"), &ignored, base)); + assert!(is_ignored_path(Path::new("src/A.sol"), &ignored, base)); + + // Files under an ignored directory. + assert!(is_ignored_path(Path::new("/root/test/B.t.sol"), &ignored, base)); + assert!(is_ignored_path(Path::new("test/sub/C.t.sol"), &ignored, base)); + + // A directory prefix only matches whole path components. + assert!(!is_ignored_path(Path::new("/root/testOther.sol"), &ignored, base)); + assert!(!is_ignored_path(Path::new("/root/src/B.sol"), &ignored, base)); + } #[test] fn test_build_filter() { @@ -244,9 +290,11 @@ mod tests { assert!(matcher.is_match(Path::new("/home/user/projects/project/test/Contract.t.sol"))); // Absolute path that should not match the pattern - assert!(!matcher.is_match(Path::new("/home/user/other/project/test/Contract.t.sol"))); + assert!( + matcher.is_match_exclude(Path::new("/home/user/other/project/test/Contract.t.sol")) + ); // Relative path that should not match an absolute pattern - assert!(!matcher.is_match(Path::new("projects/project/test/Contract.t.sol"))); + assert!(matcher.is_match_exclude(Path::new("projects/project/test/Contract.t.sol"))); } } diff --git a/crates/config/src/fix.rs b/crates/config/src/fix.rs index fc8d765d2f47f..adea39f5ab4d1 100644 --- a/crates/config/src/fix.rs +++ b/crates/config/src/fix.rs @@ -21,14 +21,10 @@ impl TomlFile { Ok(Self { doc, path }) } - fn doc(&self) -> &toml_edit::DocumentMut { + const fn doc(&self) -> &toml_edit::DocumentMut { &self.doc } - fn doc_mut(&mut self) -> &mut toml_edit::DocumentMut { - &mut self.doc - } - fn path(&self) -> &Path { self.path.as_ref() } @@ -47,7 +43,7 @@ impl Deref for TomlFile { impl DerefMut for TomlFile { fn deref_mut(&mut self) -> &mut Self::Target { - self.doc_mut() + &mut self.doc } } @@ -87,7 +83,7 @@ impl TomlFile { } else { // insert profile section at the beginning of the map let mut profile_section = toml_edit::Table::new(); - profile_section.set_position(0); + profile_section.set_position(Some(0)); profile_section.set_implicit(true); self.insert(Config::PROFILE_SECTION, toml_edit::Item::Table(profile_section)); self.get_mut(Config::PROFILE_SECTION).expect("exists per above") @@ -143,9 +139,7 @@ fn fix_toml_non_strict_profiles( .as_table() .iter() .map(|(k, _)| k.to_string()) - .filter(|k| { - !(k == Config::PROFILE_SECTION || Config::STANDALONE_SECTIONS.contains(&k.as_str())) - }) + .filter(|k| !Config::is_standalone_section(k)) .collect::>(); // remove each profile and insert into [profile] section diff --git a/crates/config/src/fmt.rs b/crates/config/src/fmt.rs index 223b46d2ffd6b..ac5227e1c059c 100644 --- a/crates/config/src/fmt.rs +++ b/crates/config/src/fmt.rs @@ -29,24 +29,36 @@ pub struct FormatterConfig { pub override_spacing: bool, /// Wrap comments on `line_length` reached pub wrap_comments: bool, + /// Style of doc comments + pub docs_style: DocCommentStyle, /// Globs to ignore pub ignore: Vec, /// Add new line at start and end of contract declarations pub contract_new_lines: bool, /// Sort import statements alphabetically in groups (a group is separated by a newline). pub sort_imports: bool, + /// Choose between `import "a" as name` and `import * as name from "a"` + pub namespace_import_style: NamespaceImportStyle, + /// Whether to suppress spaces around the power operator (`**`). + pub pow_no_space: bool, + /// Style that determines if a broken list, should keep its elements together on their own + /// line, before breaking individually. + pub prefer_compact: PreferCompact, + /// Keep single imports on a single line even if they exceed line length. + pub single_line_imports: bool, } -/// Style of uint/int256 types -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +/// Style of integer types. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum IntTypes { - /// Print the explicit uint256 or int256 + /// Use the type defined in the source code. + Preserve, + /// Print the full length `uint256` or `int256`. + #[default] Long, - /// Print the implicit uint or int + /// Print the alias `uint` or `int`. Short, - /// Use the type defined in the source code - Preserve, } /// Style of underscores in number literals @@ -54,9 +66,9 @@ pub enum IntTypes { #[serde(rename_all = "snake_case")] pub enum NumberUnderscore { /// Use the underscores defined in the source code + #[default] Preserve, /// Remove all underscores - #[default] Remove, /// Add an underscore every thousand, if greater than 9999 /// e.g. 1000 -> 1000 and 10000 -> 10_000 @@ -66,19 +78,19 @@ pub enum NumberUnderscore { impl NumberUnderscore { /// Returns true if the option is `Preserve` #[inline] - pub fn is_preserve(self) -> bool { + pub const fn is_preserve(self) -> bool { matches!(self, Self::Preserve) } /// Returns true if the option is `Remove` #[inline] - pub fn is_remove(self) -> bool { + pub const fn is_remove(self) -> bool { matches!(self, Self::Remove) } /// Returns true if the option is `Remove` #[inline] - pub fn is_thousands(self) -> bool { + pub const fn is_thousands(self) -> bool { matches!(self, Self::Thousands) } } @@ -96,70 +108,67 @@ pub enum HexUnderscore { Bytes, } -impl HexUnderscore { - /// Returns true if the option is `Preserve` - #[inline] - pub fn is_preserve(self) -> bool { - matches!(self, Self::Preserve) - } - - /// Returns true if the option is `Remove` - #[inline] - pub fn is_remove(self) -> bool { - matches!(self, Self::Remove) - } - - /// Returns true if the option is `Remove` - #[inline] - pub fn is_bytes(self) -> bool { - matches!(self, Self::Bytes) - } +/// Style of doc comments +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DocCommentStyle { + /// Preserve the source code style + #[default] + Preserve, + /// Use single-line style (`///`) + Line, + /// Use block style (`/** .. */`) + Block, } /// Style of string quotes -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum QuoteStyle { - /// Use double quotes where possible + /// Use quotation mark defined in the source code. + Preserve, + /// Use double quotes where possible. + #[default] Double, - /// Use single quotes where possible + /// Use single quotes where possible. Single, - /// Use quotation mark defined in the source code - Preserve, } impl QuoteStyle { - /// Get associated quotation mark with option - pub fn quote(self) -> Option { + /// Returns the associated quotation mark character. + pub const fn quote(self) -> Option { match self { + Self::Preserve => None, Self::Double => Some('"'), Self::Single => Some('\''), - Self::Preserve => None, } } } /// Style of single line blocks in statements -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum SingleLineBlockStyle { + /// Preserve the original style + #[default] + Preserve, /// Prefer single line block when possible Single, /// Always use multiline block Multi, - /// Preserve the original style - Preserve, } /// Style of function header in case it doesn't fit -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum MultilineFuncHeaderStyle { - /// Write function parameters multiline first. - ParamsFirst, + /// Always write function parameters multiline. + #[serde(alias = "params_first")] // alias for backwards compatibility + ParamsAlways, /// Write function parameters multiline first when there is more than one param. ParamsFirstMulti, /// Write function attributes multiline first. + #[default] AttributesFirst, /// If function params or attrs are multiline. /// split the rest @@ -168,6 +177,66 @@ pub enum MultilineFuncHeaderStyle { AllParams, } +impl MultilineFuncHeaderStyle { + pub const fn all(&self) -> bool { + matches!(self, Self::All | Self::AllParams) + } + + pub const fn params_first(&self) -> bool { + matches!(self, Self::ParamsAlways | Self::ParamsFirstMulti) + } + + pub const fn attrib_first(&self) -> bool { + matches!(self, Self::AttributesFirst) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NamespaceImportStyle { + /// prefer plain imports: `import "source" as name;` + #[default] + PreferPlain, + /// prefer glob imports: `import * as name from "source";` + PreferGlob, + /// preserve the original style + Preserve, +} + +/// Style that determines if a broken list, should keep its elements together on their own line, +/// before breaking individually. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PreferCompact { + /// All elements are preferred consistent. + None, + /// Calls are preferred compact. Events and errors break consistently. + Calls, + /// Events are preferred compact. Calls and errors break consistently. + Events, + /// Errors are preferred compact. Calls and events break consistently. + Errors, + /// Events and errors are preferred compact. Calls break consistently. + EventsErrors, + /// All elements are preferred compact. + #[default] + All, +} + +impl PreferCompact { + pub const fn calls(&self) -> bool { + matches!(self, Self::All | Self::Calls) + } + + pub const fn events(&self) -> bool { + matches!(self, Self::All | Self::Events | Self::EventsErrors) + } + + pub const fn errors(&self) -> bool { + matches!(self, Self::All | Self::Errors | Self::EventsErrors) + } +} + /// Style of indent #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -184,17 +253,22 @@ impl Default for FormatterConfig { tab_width: 4, style: IndentStyle::Space, bracket_spacing: false, - int_types: IntTypes::Long, - multiline_func_header: MultilineFuncHeaderStyle::AttributesFirst, - quote_style: QuoteStyle::Double, - number_underscore: NumberUnderscore::Preserve, - hex_underscore: HexUnderscore::Remove, - single_line_statement_blocks: SingleLineBlockStyle::Preserve, + int_types: IntTypes::default(), + multiline_func_header: MultilineFuncHeaderStyle::default(), + quote_style: QuoteStyle::default(), + number_underscore: NumberUnderscore::default(), + hex_underscore: HexUnderscore::default(), + single_line_statement_blocks: SingleLineBlockStyle::default(), override_spacing: false, wrap_comments: false, ignore: vec![], contract_new_lines: false, sort_imports: false, + namespace_import_style: NamespaceImportStyle::default(), + pow_no_space: false, + prefer_compact: PreferCompact::default(), + docs_style: DocCommentStyle::default(), + single_line_imports: false, } } } diff --git a/crates/config/src/fs_permissions.rs b/crates/config/src/fs_permissions.rs index 1d2c35ff33bf9..e38e6040ac902 100644 --- a/crates/config/src/fs_permissions.rs +++ b/crates/config/src/fs_permissions.rs @@ -37,33 +37,60 @@ impl FsPermissions { /// Caution: This should be called with normalized paths if the `allowed_paths` are also /// normalized. pub fn is_path_allowed(&self, path: &Path, kind: FsAccessKind) -> bool { - self.find_permission(path).map(|perm| perm.is_granted(kind)).unwrap_or_default() + self.find_permission(path).is_some_and(|perm| perm.is_granted(kind)) } /// Returns the permission for the matching path. /// - /// This finds the longest matching path with resolved sym links, e.g. if we have the following - /// permissions: + /// This finds the longest matching path with resolved sym links and returns the highest + /// privilege permission. The algorithm works as follows: /// - /// `./out` = `read` - /// `./out/contracts` = `read-write` + /// 1. Find all permissions where the path matches (using longest path match) + /// 2. Return the highest privilege permission from those matches /// - /// And we check for `./out/contracts/MyContract.sol` we will get `read-write` as permission. + /// Example scenarios: + /// + /// ```text + /// ./out = read + /// ./out/contracts = read-write + /// ``` + /// Checking `./out/contracts/MyContract.sol` returns `read-write` (longest path match) + /// + /// ```text + /// ./out/contracts = read + /// ./out/contracts = write + /// ``` + /// Checking `./out/contracts/MyContract.sol` returns `write` (highest privilege, which also + /// grants read access) pub fn find_permission(&self, path: &Path) -> Option { - let mut permission: Option<&PathPermission> = None; + let mut max_path_len = 0; + let mut highest_permission = FsAccessPermission::None; + + // Find all matching permissions at the longest matching path for perm in &self.permissions { let permission_path = dunce::canonicalize(&perm.path).unwrap_or(perm.path.clone()); - if path.starts_with(permission_path) { - if let Some(active_perm) = permission.as_ref() { - // the longest path takes precedence - if perm.path < active_perm.path { - continue; + if path.starts_with(&permission_path) { + let path_len = permission_path.components().count(); + if path_len > max_path_len { + // Found a longer matching path, reset to this permission + max_path_len = path_len; + highest_permission = perm.access; + } else if path_len == max_path_len { + // Same path length, keep the highest privilege + highest_permission = match (highest_permission, perm.access) { + (FsAccessPermission::ReadWrite, _) + | (FsAccessPermission::Read, FsAccessPermission::Write) + | (FsAccessPermission::Write, FsAccessPermission::Read) => { + FsAccessPermission::ReadWrite + } + (FsAccessPermission::None, perm) => perm, + (existing_perm, _) => existing_perm, } } - permission = Some(perm); } } - permission.map(|perm| perm.access) + + (max_path_len > 0).then_some(highest_permission) } /// Updates all `allowed_paths` and joins ([`Path::join`]) the `root` with all entries @@ -85,12 +112,12 @@ impl FsPermissions { } /// Returns true if no permissions are configured - pub fn is_empty(&self) -> bool { + pub const fn is_empty(&self) -> bool { self.permissions.is_empty() } /// Returns the number of configured permissions - pub fn len(&self) -> usize { + pub const fn len(&self) -> usize { self.permissions.len() } } @@ -131,7 +158,7 @@ impl PathPermission { } /// Returns true if the access is allowed - pub fn is_granted(&self, kind: FsAccessKind) -> bool { + pub const fn is_granted(&self, kind: FsAccessKind) -> bool { self.access.is_granted(kind) } } @@ -160,22 +187,22 @@ pub enum FsAccessPermission { /// FS access is _not_ allowed #[default] None, - /// FS access is allowed, this includes `read` + `write` - ReadWrite, /// Only reading is allowed Read, /// Only writing is allowed Write, + /// FS access is allowed, this includes `read` + `write` + ReadWrite, } impl FsAccessPermission { /// Returns true if the access is allowed - pub fn is_granted(&self, kind: FsAccessKind) -> bool { + pub const fn is_granted(&self, kind: FsAccessKind) -> bool { match (self, kind) { (Self::ReadWrite, _) => true, - (Self::None, _) => false, - (Self::Read, FsAccessKind::Read) => true, (Self::Write, FsAccessKind::Write) => true, + (Self::Read, FsAccessKind::Read) => true, + (Self::None, _) => false, _ => false, } } @@ -270,4 +297,34 @@ mod tests { let permission = permissions.find_permission(Path::new("./out/MyContract.sol")).unwrap(); assert_eq!(FsAccessPermission::Write, permission); } + + #[test] + fn read_write_permission_combination() { + // When multiple permissions are defined for the same path, highest privilege wins + let permissions = FsPermissions::new(vec![ + PathPermission::read("./out/contracts"), + PathPermission::write("./out/contracts"), + ]); + + let permission = + permissions.find_permission(Path::new("./out/contracts/MyContract.sol")).unwrap(); + assert_eq!(FsAccessPermission::ReadWrite, permission); + } + + #[test] + fn longest_path_takes_precedence() { + let permissions = FsPermissions::new(vec![ + PathPermission::read_write("./out"), + PathPermission::read("./out/contracts"), + ]); + + // More specific path (./out/contracts) takes precedence even with lower privilege + let permission = + permissions.find_permission(Path::new("./out/contracts/MyContract.sol")).unwrap(); + assert_eq!(FsAccessPermission::Read, permission); + + // Broader path still applies to its own files + let permission = permissions.find_permission(Path::new("./out/other.sol")).unwrap(); + assert_eq!(FsAccessPermission::ReadWrite, permission); + } } diff --git a/crates/config/src/fuzz.rs b/crates/config/src/fuzz.rs index cb5290d54860f..96e3f2f1d2ae7 100644 --- a/crates/config/src/fuzz.rs +++ b/crates/config/src/fuzz.rs @@ -1,21 +1,24 @@ //! Configuration for fuzz testing. use alloy_primitives::U256; -use serde::{Deserialize, Serialize}; +use foundry_compilers::utils::canonicalized; +use serde::{Deserialize, Deserializer, Serialize, de::Error}; use std::path::PathBuf; /// Contains for fuzz testing #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct FuzzConfig { /// The number of test cases that must execute for each property test + #[serde(deserialize_with = "deserialize_fuzz_runs")] pub runs: u32, + /// Optional 1-based fuzz run to execute. + pub run: Option, + /// Optional fuzz worker ID to pair with `run`. + pub worker: Option, /// Fails the fuzzed test if a revert occurs. pub fail_on_revert: bool, - /// The maximum number of test case rejections allowed by proptest, to be - /// encountered during usage of `vm.assume` cheatcode. This will be used - /// to set the `max_global_rejects` value in proptest test runner config. - /// `max_local_rejects` option isn't exposed here since we're not using - /// `prop_filter`. + /// The maximum number of test case rejections allowed, + /// encountered during usage of `vm.assume` cheatcode. pub max_test_rejects: u32, /// Optional seed for the fuzzing RNG algorithm pub seed: Option, @@ -24,10 +27,11 @@ pub struct FuzzConfig { pub dictionary: FuzzDictionaryConfig, /// Number of runs to execute and include in the gas report. pub gas_report_samples: u32, + /// The fuzz corpus configuration. + #[serde(flatten)] + pub corpus: FuzzCorpusConfig, /// Path where fuzz failures are recorded and replayed. pub failure_persist_dir: Option, - /// Name of the file to record fuzz failures, defaults to `failures`. - pub failure_persist_file: Option, /// show `console.log` in fuzz test, defaults to `false` pub show_logs: bool, /// Optional timeout (in seconds) for each property test @@ -38,13 +42,15 @@ impl Default for FuzzConfig { fn default() -> Self { Self { runs: 256, + run: None, + worker: None, fail_on_revert: true, max_test_rejects: 65536, seed: None, dictionary: FuzzDictionaryConfig::default(), gas_report_samples: 256, + corpus: FuzzCorpusConfig { payable_value_weight: 0, ..Default::default() }, failure_persist_dir: None, - failure_persist_file: None, show_logs: false, timeout: None, } @@ -54,11 +60,7 @@ impl Default for FuzzConfig { impl FuzzConfig { /// Creates fuzz configuration to write failures in `{PROJECT_ROOT}/cache/fuzz` dir. pub fn new(cache_dir: PathBuf) -> Self { - Self { - failure_persist_dir: Some(cache_dir), - failure_persist_file: Some("failures".to_string()), - ..Default::default() - } + Self { failure_persist_dir: Some(cache_dir), ..Default::default() } } } @@ -76,24 +78,240 @@ pub struct FuzzDictionaryConfig { /// Once the fuzzer exceeds this limit, it will start evicting random entries /// /// This limit is put in place to prevent memory blowup. - #[serde(deserialize_with = "crate::deserialize_usize_or_max")] + #[serde( + deserialize_with = "crate::deserialize_usize_or_max", + serialize_with = "crate::serialize_usize_or_max" + )] pub max_fuzz_dictionary_addresses: usize, /// How many values to record at most. /// Once the fuzzer exceeds this limit, it will start evicting random entries - #[serde(deserialize_with = "crate::deserialize_usize_or_max")] + /// The dictionary always retains a zero seed, so the effective minimum is one. + #[serde( + deserialize_with = "crate::deserialize_usize_or_max", + serialize_with = "crate::serialize_usize_or_max" + )] pub max_fuzz_dictionary_values: usize, + /// How many literal values to seed from the AST, at most. + /// + /// This value is independent from the max amount of addresses and values. + #[serde( + deserialize_with = "crate::deserialize_usize_or_max", + serialize_with = "crate::serialize_usize_or_max" + )] + pub max_fuzz_dictionary_literals: usize, } impl Default for FuzzDictionaryConfig { fn default() -> Self { + const MB: usize = 1024 * 1024; + Self { dictionary_weight: 40, include_storage: true, include_push_bytes: true, - // limit this to 300MB - max_fuzz_dictionary_addresses: (300 * 1024 * 1024) / 20, - // limit this to 200MB - max_fuzz_dictionary_values: (200 * 1024 * 1024) / 32, + max_fuzz_dictionary_addresses: 300 * MB / 20, + max_fuzz_dictionary_values: 300 * MB / 32, + max_fuzz_dictionary_literals: 200 * MB / 32, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FuzzCorpusConfig { + // Path to corpus directory, enabled coverage guided fuzzing mode. + // If not set then sequences producing new coverage are not persisted and mutated. + pub corpus_dir: Option, + // Path to fuzz branch frontier artifacts for symbolic follow-up. + pub frontier_dir: Option, + // Maximum number of branch frontier records to write for one fuzz test. + pub frontier_limit: usize, + // Whether corpus to use gzip file compression and decompression. + pub corpus_gzip: bool, + // Number of mutations until entry marked as eligible to be flushed from in-memory corpus. + // Mutations will be performed at least `corpus_min_mutations` times. + pub corpus_min_mutations: usize, + // Number of corpus that won't be evicted from memory. + pub corpus_min_size: usize, + /// Whether to collect and display edge coverage metrics. + pub show_edge_coverage: bool, + /// Whether EVM edge coverage should use collision-free dense IDs. + pub evm_edge_coverage_collision_free: bool, + /// Whether EVM edge coverage IDs should include call-frame depth. + pub evm_edge_coverage_include_call_depth: bool, + /// Whether to collect edge coverage from native Rust crates compiled with + /// SanitizerCoverage instrumentation (e.g. precompile implementations). + /// Requires building forge with a `RUSTC_WRAPPER` that injects sancov flags. + pub sancov_edges: bool, + /// Whether to capture comparison operands from sancov-instrumented crates + /// and inject them into the fuzz dictionary. Independent of `sancov_edges`. + pub sancov_trace_cmp: bool, + /// Percent chance of generating fresh transaction input instead of continuing from corpus + /// input during coverage-guided campaigns. + #[serde(deserialize_with = "crate::deserialize_stringified_percent")] + pub corpus_random_sequence_weight: u32, + /// Percent chance that generated payable calls carry non-zero `msg.value`. + #[serde(deserialize_with = "crate::deserialize_stringified_percent")] + pub payable_value_weight: u32, + /// Weights for coverage-guided corpus mutation strategies. + #[serde(flatten)] + pub mutation_weights: FuzzCorpusMutationWeights, +} + +impl FuzzCorpusConfig { + pub const DEFAULT_CORPUS_RANDOM_SEQUENCE_WEIGHT: u32 = 10; + pub const ENSEMBLE_CORPUS_RANDOM_SEQUENCE_WEIGHT: u32 = 50; + pub const DEFAULT_FRONTIER_LIMIT: usize = 256; + + pub fn with_test(&mut self, contract: &str, test: &str) { + if let Some(corpus_dir) = &self.corpus_dir { + self.corpus_dir = Some(canonicalized(corpus_dir.join(contract).join(test))); + } + if let Some(frontier_dir) = &self.frontier_dir { + self.frontier_dir = Some(canonicalized(frontier_dir.join(contract).join(test))); + } + } + + /// Whether any edge coverage (EVM or sancov) should be collected. + pub const fn collect_edge_coverage(&self) -> bool { + self.corpus_dir.is_some() || self.show_edge_coverage || self.sancov_edges + } + + /// Whether the EVM `EdgeCovInspector` should be enabled. + /// + /// Disabled when sancov edge coverage is active — sancov provides the + /// coverage signal and EVM hits from the Solidity handler would dilute it. + /// Trace-cmp-only mode keeps EVM edges enabled since trace-cmp only + /// contributes dictionary entries, not edge coverage. + pub const fn collect_evm_edge_coverage(&self) -> bool { + !self.sancov_edges && (self.corpus_dir.is_some() || self.show_edge_coverage) + } + + /// Whether EVM comparison operand capture is enabled. + /// + /// EVM comparison operands for corpus mutation are only useful for coverage-guided fuzzing, so + /// they are derived from corpus mode and disabled when sancov edge coverage is active. + /// Frontier capture still records EVM comparison sites because those artifacts target + /// Solidity bytecode branches, not the active coverage guidance source. + pub fn collect_evm_cmp_log(&self) -> bool { + self.capture_branch_frontiers() + || (!self.sancov_edges + && self.corpus_dir.is_some() + && self.mutation_weights.effective().mutation_weight_cmp > 0) + } + + /// Whether fuzz branch frontier artifacts should be captured. + pub const fn capture_branch_frontiers(&self) -> bool { + self.frontier_dir.is_some() && self.frontier_limit > 0 + } + + /// Whether EVM edge coverage should use collision-free dense IDs. + pub const fn evm_edge_coverage_collision_free(&self) -> bool { + self.evm_edge_coverage_collision_free + } + + /// Whether EVM edge coverage IDs should include call-frame depth. + pub const fn evm_edge_coverage_include_call_depth(&self) -> bool { + self.evm_edge_coverage_include_call_depth + } + + /// Whether sancov edge coverage collection is enabled. + pub const fn collect_sancov_edges(&self) -> bool { + self.sancov_edges + } + + /// Whether sancov trace-cmp capture is enabled. + pub const fn collect_sancov_trace_cmp(&self) -> bool { + self.sancov_trace_cmp + } + + /// Whether either sancov coverage mode is active. + pub const fn sancov_active(&self) -> bool { + self.sancov_edges || self.sancov_trace_cmp + } + + /// Whether coverage guided fuzzing is enabled. + pub const fn is_coverage_guided(&self) -> bool { + self.corpus_dir.is_some() + } +} + +impl Default for FuzzCorpusConfig { + fn default() -> Self { + Self { + corpus_dir: None, + frontier_dir: None, + frontier_limit: Self::DEFAULT_FRONTIER_LIMIT, + corpus_gzip: true, + corpus_min_mutations: 5, + corpus_min_size: 0, + show_edge_coverage: false, + evm_edge_coverage_collision_free: true, + evm_edge_coverage_include_call_depth: false, + sancov_edges: false, + sancov_trace_cmp: false, + corpus_random_sequence_weight: Self::DEFAULT_CORPUS_RANDOM_SEQUENCE_WEIGHT, + payable_value_weight: 15, + mutation_weights: FuzzCorpusMutationWeights::default(), } } } + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FuzzCorpusMutationWeights { + /// Weight for splicing two corpus sequences. + pub mutation_weight_splice: u32, + /// Weight for repeating part of a corpus sequence. + pub mutation_weight_repeat: u32, + /// Weight for interleaving two corpus sequences. + pub mutation_weight_interleave: u32, + /// Weight for replacing a corpus sequence prefix with generated calls. + pub mutation_weight_prefix: u32, + /// Weight for replacing a corpus sequence suffix with generated calls. + pub mutation_weight_suffix: u32, + /// Weight for ABI-aware argument mutation. + pub mutation_weight_abi: u32, + /// Weight for comparison-operand guided argument mutation. + pub mutation_weight_cmp: u32, +} + +impl FuzzCorpusMutationWeights { + pub const fn total(&self) -> u64 { + self.mutation_weight_splice as u64 + + self.mutation_weight_repeat as u64 + + self.mutation_weight_interleave as u64 + + self.mutation_weight_prefix as u64 + + self.mutation_weight_suffix as u64 + + self.mutation_weight_abi as u64 + + self.mutation_weight_cmp as u64 + } + + /// Returns defaults if every configured weight is zero. + pub fn effective(self) -> Self { + if self.total() == 0 { Self::default() } else { self } + } +} + +impl Default for FuzzCorpusMutationWeights { + fn default() -> Self { + Self { + mutation_weight_splice: 1, + mutation_weight_repeat: 1, + mutation_weight_interleave: 1, + mutation_weight_prefix: 1, + mutation_weight_suffix: 1, + mutation_weight_abi: 1, + mutation_weight_cmp: 1, + } + } +} + +fn deserialize_fuzz_runs<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let runs = u32::deserialize(deserializer)?; + if runs == 0 { + return Err(D::Error::custom("`fuzz.runs` must be greater than 0")); + } + Ok(runs) +} diff --git a/crates/config/src/inline/mod.rs b/crates/config/src/inline/mod.rs index b463c1555ef9d..306ad3265d0e5 100644 --- a/crates/config/src/inline/mod.rs +++ b/crates/config/src/inline/mod.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeSet; + use crate::Config; use alloy_primitives::map::HashMap; use figment::{ @@ -5,6 +7,7 @@ use figment::{ value::{Dict, Map, Value}, }; use foundry_compilers::ProjectCompileOutput; +use foundry_evm_networks::NetworkVariant; use itertools::Itertools; mod natspec; @@ -23,6 +26,9 @@ pub enum InlineConfigErrorKind { /// An invalid profile has been provided. #[error("invalid profile `{0}`; valid profiles: {1}")] InvalidProfile(String, String), + /// A legacy Halmos inline annotation could not be translated. + #[error("invalid @custom:halmos annotation: {0}")] + InvalidHalmosConfig(String), } /// Wrapper error struct that catches config parsing errors, enriching them with context information @@ -75,29 +81,22 @@ impl InlineConfig { } else { self.contract_level.entry(natspec.contract.clone()).or_default() }; - let joined = natspec - .config_values() - .map(|s| { - // Replace `-` with `_` for backwards compatibility with the old parser. - if let Some(idx) = s.find('=') { - s[..idx].replace('-', "_") + &s[idx..] - } else { - s.to_string() - } - }) - .format("\n") - .to_string(); - let data = toml::from_str::(&joined).map_err(|e| InlineConfigError { - location: natspec.location_string(), - kind: InlineConfigErrorKind::Parse(e), - })?; - extend_data_map(map, &data); + if let Some(data) = parse_config_values(natspec, natspec.halmos_config_values()?)? { + extend_data_map(map, &data); + } + if let Some(data) = parse_config_values(natspec, natspec.config_values())? { + extend_data_map(map, &data); + } Ok(()) } /// Returns a [`figment::Provider`] for this [`InlineConfig`] at the given contract and function /// level. - pub fn provide<'a>(&'a self, contract: &'a str, function: &'a str) -> InlineConfigProvider<'a> { + pub const fn provide<'a>( + &'a self, + contract: &'a str, + function: &'a str, + ) -> InlineConfigProvider<'a> { InlineConfigProvider { inline: self, contract, function } } @@ -119,6 +118,64 @@ impl InlineConfig { self.get_function(contract, function).is_some_and(|map| !map.is_empty()) } + /// Returns the configured [`NetworkVariant`] for a given test, checking function-level first + /// then contract-level. Returns `None` if no network annotation is present. + pub fn network_for( + &self, + profile: &Profile, + contract: &str, + function: &str, + ) -> Option { + inline_value_for_profile( + profile, + &[self.get_function(contract, function), self.get_contract(contract)], + |dict| { + if let Some(Value::Dict(_, networks)) = dict.get("networks") + && let Some(Value::String(_, s)) = networks.get("network") + { + return s.parse().ok(); + } + None + }, + ) + } + + /// Returns whether contract-level inline config enables symbolic execution. + pub fn contract_symbolic_enabled( + &self, + profile: &Profile, + contract: &str, + default: bool, + ) -> bool { + inline_value_for_profile(profile, &[self.get_contract(contract)], |dict| { + if let Some(Value::Dict(_, symbolic)) = dict.get("symbolic") + && let Some(Value::Bool(_, enabled)) = symbolic.get("enabled") + { + return Some(*enabled); + } + None + }) + .unwrap_or(default) + } + + /// Returns all distinct [`NetworkVariant`]s referenced in any inline config annotation. + /// + /// This is used to determine whether a multi-network test pass is needed. + pub fn referenced_override_networks(&self, profile: &Profile) -> Vec { + let mut seen = BTreeSet::new(); + for (contract, function) in self.fn_level.keys() { + if let Some(v) = self.network_for(profile, contract, function) { + seen.insert(v); + } + } + for contract in self.contract_level.keys() { + if let Some(v) = self.network_for(profile, contract, "") { + seen.insert(v); + } + } + seen.into_iter().collect() + } + fn get_contract(&self, contract: &str) -> Option<&DataMap> { self.contract_level.get(contract) } @@ -129,6 +186,53 @@ impl InlineConfig { } } +fn inline_value_for_profile( + profile: &Profile, + levels: &[Option<&DataMap>], + value_from_dict: impl Fn(&Dict) -> Option + Copy, +) -> Option { + inline_value_for_exact_profile(profile, levels, value_from_dict).or_else(|| { + (profile != Profile::Default) + .then(|| inline_value_for_exact_profile(&Profile::Default, levels, value_from_dict)) + .flatten() + }) +} + +fn inline_value_for_exact_profile( + profile: &Profile, + levels: &[Option<&DataMap>], + value_from_dict: impl Fn(&Dict) -> Option + Copy, +) -> Option { + levels.iter().find_map(|data| data.and_then(|data| data.get(profile)).and_then(value_from_dict)) +} + +fn parse_config_values<'a>( + natspec: &NatSpec, + values: impl IntoIterator + 'a>, +) -> Result, InlineConfigError> { + let joined = values + .into_iter() + .map(|s| { + let s = s.borrow(); + // Replace `-` with `_` for backwards compatibility with the old parser. + if let Some(idx) = s.find('=') { + s[..idx].replace('-', "_") + &s[idx..] + } else { + s.to_string() + } + }) + .format("\n") + .to_string(); + if joined.is_empty() { + return Ok(None); + } + let data = toml::from_str::(&joined).map_err(|e| InlineConfigError { + location: natspec.location_string(), + kind: InlineConfigErrorKind::Parse(e), + })?; + Ok(Some(data)) +} + /// [`figment::Provider`] for [`InlineConfig`] at a given contract and function level. /// /// Created by [`InlineConfig::provide`]. @@ -184,3 +288,214 @@ fn extend_value(value: &mut Value, new: &Value) { (value, new) => *value = new.clone(), } } + +#[cfg(test)] +mod tests { + use super::*; + + fn natspec(docs: &str) -> NatSpec { + NatSpec { + contract: "test/Symbolic.t.sol:Symbolic".to_string(), + function: Some("check".to_string()), + line: "10:5".to_string(), + docs: docs.to_string(), + } + } + + #[test] + fn legacy_halmos_array_lengths_feed_symbolic_inline_config() { + let mut inline = InlineConfig::new(); + inline + .insert(&natspec( + "@custom:halmos --array-lengths 2,4 --invariant-depth 12 --width 8 --depth 99", + )) + .unwrap(); + + let config = Config::default() + .merge_inline_provider(inline.provide("test/Symbolic.t.sol:Symbolic", "check")) + .unwrap(); + + assert_eq!(config.symbolic.array_lengths, vec![2, 4]); + assert_eq!(config.symbolic.invariant_depth, 12); + assert_eq!(config.symbolic.width, Some(8)); + assert_eq!(config.symbolic.depth, Some(99)); + } + + #[test] + fn legacy_halmos_named_and_default_lengths_feed_symbolic_inline_config() { + let mut inline = InlineConfig::new(); + inline + .insert(&natspec( + "@custom:halmos --array-lengths values={2,4},data=8 --default-array-lengths 0,1 --default-bytes-lengths 0,65", + )) + .unwrap(); + + let config = Config::default() + .merge_inline_provider(inline.provide("test/Symbolic.t.sol:Symbolic", "check")) + .unwrap(); + + assert_eq!( + config.symbolic.dynamic_lengths, + std::collections::BTreeMap::from([ + ("data".to_string(), vec![8]), + ("values".to_string(), vec![2, 4]), + ]) + ); + assert_eq!(config.symbolic.default_array_lengths, vec![0, 1]); + assert_eq!(config.symbolic.default_bytes_lengths, vec![0, 65]); + } + + #[test] + fn native_symbolic_inline_config_overrides_legacy_halmos_translation() { + let mut inline = InlineConfig::new(); + inline + .insert(&natspec( + r#" +@custom:halmos --array-lengths 2 +forge-config: default.symbolic.array_lengths = [3] +forge-config: default.symbolic.default_dynamic_length = 4 +"#, + )) + .unwrap(); + + let config = Config::default() + .merge_inline_provider(inline.provide("test/Symbolic.t.sol:Symbolic", "check")) + .unwrap(); + + assert_eq!(config.symbolic.array_lengths, vec![3]); + assert_eq!(config.symbolic.default_dynamic_length, 4); + } + + #[test] + fn merge_inline_provider_uses_selected_profile() { + let mut inline = InlineConfig::new(); + inline + .insert(&natspec( + r#" +forge-config: default.fuzz.runs = 1 +forge-config: ci.fuzz.runs = 2 +"#, + )) + .unwrap(); + + let profile = Profile::new("ci"); + let config = Config { + profile: profile.clone(), + profiles: vec![Profile::Default, profile], + ..Default::default() + } + .merge_inline_provider(inline.provide("test/Symbolic.t.sol:Symbolic", "check")) + .unwrap(); + + assert_eq!(config.fuzz.runs, 2); + } + + #[test] + fn merge_inline_provider_preserves_root_for_default_profile_fallback() { + let mut inline = InlineConfig::new(); + inline.insert(&natspec("forge-config: default.isolate = false")).unwrap(); + + let profile = Profile::new("ci"); + let root = std::path::PathBuf::from("project-root"); + let config = Config { + profile: profile.clone(), + profiles: vec![Profile::Default, profile], + root: root.clone(), + ..Default::default() + } + .merge_inline_provider(inline.provide("test/Symbolic.t.sol:Symbolic", "check")) + .unwrap(); + + assert_eq!(config.root, root); + assert!(!config.isolate); + } + + #[test] + fn merge_inline_provider_ignores_inline_root() { + let mut inline = InlineConfig::new(); + inline.insert(&natspec(r#"forge-config: default.root = "/""#)).unwrap(); + + let root = std::path::PathBuf::from("project-root"); + let config = Config { root: root.clone(), ..Default::default() } + .merge_inline_provider(inline.provide("test/Symbolic.t.sol:Symbolic", "check")) + .unwrap(); + + assert_eq!(config.root, root); + } + + #[test] + fn contract_symbolic_enabled_reads_contract_inline_config() { + let mut inline = InlineConfig::new(); + inline + .insert(&NatSpec { + contract: "test/Symbolic.t.sol:Symbolic".to_string(), + function: None, + line: "10:5".to_string(), + docs: r#" +forge-config: default.symbolic.enabled = true +forge-config: ci.symbolic.enabled = false +"# + .to_string(), + }) + .unwrap(); + + assert!(inline.contract_symbolic_enabled( + &Profile::new("default"), + "test/Symbolic.t.sol:Symbolic", + false, + )); + assert!(!inline.contract_symbolic_enabled( + &Profile::new("ci"), + "test/Symbolic.t.sol:Symbolic", + true, + )); + assert!(inline.contract_symbolic_enabled( + &Profile::new("nightly"), + "test/Symbolic.t.sol:Symbolic", + false, + )); + assert!(!inline.contract_symbolic_enabled( + &Profile::new("default"), + "test/Other.t.sol:Other", + false, + )); + } + + #[test] + fn network_for_preserves_profile_then_level_precedence() { + let mut inline = InlineConfig::new(); + inline + .insert(&NatSpec { + contract: "test/Network.t.sol:Network".to_string(), + function: None, + line: "10:5".to_string(), + docs: r#" +forge-config: default.networks.network = "optimism" +forge-config: ci.networks.network = "tempo" +"# + .to_string(), + }) + .unwrap(); + inline + .insert(&NatSpec { + contract: "test/Network.t.sol:Network".to_string(), + function: Some("testNetwork".to_string()), + line: "20:5".to_string(), + docs: r#"forge-config: default.networks.network = "ethereum""#.to_string(), + }) + .unwrap(); + + assert_eq!( + inline.network_for( + &Profile::new("default"), + "test/Network.t.sol:Network", + "testNetwork" + ), + Some(NetworkVariant::Ethereum), + ); + assert_eq!( + inline.network_for(&Profile::new("ci"), "test/Network.t.sol:Network", "testNetwork"), + Some(NetworkVariant::Tempo), + ); + } +} diff --git a/crates/config/src/inline/natspec.rs b/crates/config/src/inline/natspec.rs index 400637d1cf45e..fe91a7c50c78b 100644 --- a/crates/config/src/inline/natspec.rs +++ b/crates/config/src/inline/natspec.rs @@ -6,15 +6,14 @@ use foundry_compilers::{ }; use itertools::Itertools; use serde_json::Value; -use solar_parse::{ - Parser, - ast::{ - Arena, CommentKind, Item, ItemKind, - interface::{self, Session}, - }, +use solar::{ + ast::{self, Span}, + interface::Session, }; use std::{collections::BTreeMap, path::Path}; +const HALMOS_CONFIG_PREFIX: &str = "@custom:halmos"; + /// Convenient struct to hold in-line per-test configurations #[derive(Clone, Debug, PartialEq, Eq)] pub struct NatSpec { @@ -22,7 +21,7 @@ pub struct NatSpec { pub contract: String, /// The function annotated with the natspec. None if the natspec is contract-level. pub function: Option, - /// The line the natspec appears, in the form `row:col:length`, i.e. `10:21:122`. + /// The line the natspec begins, in the form `line:column`, i.e. `10:21`. pub line: String, /// The actual natspec comment, without slashes or block punctuation. pub docs: String, @@ -32,28 +31,47 @@ impl NatSpec { /// Factory function that extracts a vector of [`NatSpec`] instances from /// a solc compiler output. The root path is to express contract base dirs. /// That is essential to match per-test configs at runtime. + #[instrument(name = "NatSpec::parse", skip_all)] pub fn parse(output: &ProjectCompileOutput, root: &Path) -> Vec { let mut natspecs: Vec = vec![]; + let compiler = output.parser().solc().compiler(); + let solar = SolarParser::new(compiler.sess()); let solc = SolcParser::new(); - let solar = SolarParser::new(); for (id, artifact) in output.artifact_ids() { - let abs_path = id.source.as_path(); - let path = abs_path.strip_prefix(root).unwrap_or(abs_path); + let path = id.source.as_path(); + let path = path.strip_prefix(root).unwrap_or(path); + let abs_path = &*root.join(path); let contract_name = id.name.split('.').next().unwrap(); // `id.identifier` but with the stripped path. let contract = format!("{}:{}", path.display(), id.name); - let mut used_solc_ast = false; - if let Some(ast) = &artifact.ast + let mut used_solar = false; + compiler.enter_sequential(|compiler| { + if let Some((_, source)) = compiler.gcx().get_ast_source(abs_path) + && let Some(ast) = &source.ast + { + solar.parse_ast(&mut natspecs, ast, &contract, contract_name); + used_solar = true; + } + }); + + if !used_solar { + warn!(?abs_path, %contract, "could not parse natspec with solar"); + } + + let used_solc = if !used_solar + && let Some(ast) = &artifact.ast && let Some(node) = solc.contract_root_node(&ast.nodes, &contract) { solc.parse(&mut natspecs, &contract, node, true); - used_solc_ast = true; - } + true + } else { + false + }; - if !used_solc_ast && let Ok(src) = std::fs::read_to_string(abs_path) { - solar.parse(&mut natspecs, &src, &contract, contract_name); + if !used_solar && !used_solc { + warn!(?abs_path, %contract, "could not parse natspec"); } } @@ -107,6 +125,250 @@ impl NatSpec { .map(|idx| line[idx + INLINE_CONFIG_PREFIX.len()..].trim()) }) } + + /// Returns Foundry inline config values translated from legacy Halmos annotations. + /// + /// Native `forge-config:` annotations remain preferred. This compatibility path only + /// translates the Halmos flags that map cleanly onto Foundry symbolic config. + pub fn halmos_config_values(&self) -> Result, InlineConfigError> { + let mut values = Vec::new(); + for line in self.docs.lines() { + let Some(idx) = line.find(HALMOS_CONFIG_PREFIX) else { continue }; + let args = line[idx + HALMOS_CONFIG_PREFIX.len()..].trim(); + translate_halmos_config(args, &mut values).map_err(|message| InlineConfigError { + location: self.location_string(), + kind: InlineConfigErrorKind::InvalidHalmosConfig(message), + })?; + } + Ok(values) + } +} + +fn translate_halmos_config(args: &str, values: &mut Vec) -> Result<(), String> { + let tokens = split_halmos_args(args)?; + let mut idx = 0; + while idx < tokens.len() { + let token = &tokens[idx]; + if let Some((arg, value)) = halmos_arg(token) { + let value = if let Some(value) = value { + value + } else { + idx += 1; + tokens.get(idx).ok_or_else(|| format!("missing value for {}", arg.flag))? + }; + emit_halmos_arg(values, arg, value)?; + } else if token.starts_with("--") + && tokens.get(idx + 1).is_some_and(|next| !next.starts_with("--")) + { + idx += 1; + } + idx += 1; + } + Ok(()) +} + +fn split_halmos_args(args: &str) -> Result, String> { + shlex::split(args).ok_or_else(|| "invalid shell quoting in @custom:halmos config".to_string()) +} + +#[derive(Clone, Copy)] +struct HalmosArg { + flag: &'static str, + parser: HalmosArgParser, + foundry_field: &'static str, +} + +#[derive(Clone, Copy)] +enum HalmosArgParser { + ArrayLengths, + Lengths, + U32, + String, +} + +const HALMOS_ARGS: &[HalmosArg] = &[ + HalmosArg { + flag: "--array-lengths", + parser: HalmosArgParser::ArrayLengths, + foundry_field: "array_lengths", + }, + HalmosArg { + flag: "--default-array-lengths", + parser: HalmosArgParser::Lengths, + foundry_field: "default_array_lengths", + }, + HalmosArg { + flag: "--default-bytes-lengths", + parser: HalmosArgParser::Lengths, + foundry_field: "default_bytes_lengths", + }, + HalmosArg { flag: "--loop", parser: HalmosArgParser::U32, foundry_field: "loop" }, + HalmosArg { + flag: "--invariant-depth", + parser: HalmosArgParser::U32, + foundry_field: "invariant_depth", + }, + HalmosArg { flag: "--width", parser: HalmosArgParser::U32, foundry_field: "width" }, + HalmosArg { flag: "--depth", parser: HalmosArgParser::U32, foundry_field: "depth" }, + HalmosArg { flag: "--solver-timeout", parser: HalmosArgParser::U32, foundry_field: "timeout" }, + HalmosArg { + flag: "--solver-timeout-branching", + parser: HalmosArgParser::U32, + foundry_field: "timeout", + }, + HalmosArg { + flag: "--solver-timeout-assertion", + parser: HalmosArgParser::U32, + foundry_field: "timeout", + }, + HalmosArg { flag: "--solver", parser: HalmosArgParser::String, foundry_field: "solver" }, + HalmosArg { + flag: "--solver-command", + parser: HalmosArgParser::String, + foundry_field: "solver_command", + }, +]; + +fn halmos_arg(token: &str) -> Option<(HalmosArg, Option<&str>)> { + let (flag, value) = + token.split_once('=').map_or((token, None), |(flag, value)| (flag, Some(value))); + HALMOS_ARGS.iter().copied().find(|arg| arg.flag == flag).map(|arg| (arg, value)) +} + +fn emit_halmos_arg(values: &mut Vec, arg: HalmosArg, value: &str) -> Result<(), String> { + match arg.parser { + HalmosArgParser::ArrayLengths => push_halmos_array_lengths(values, value), + HalmosArgParser::Lengths => push_halmos_lengths(values, arg.foundry_field, value, arg.flag), + HalmosArgParser::U32 => { + let value = value + .parse::() + .map_err(|_| format!("invalid value `{value}` for {}", arg.flag))?; + values.push(format!("default.symbolic.{} = {value}", arg.foundry_field)); + Ok(()) + } + HalmosArgParser::String => { + values.push(format!("default.symbolic.{} = {}", arg.foundry_field, toml_string(value))); + Ok(()) + } + } +} + +fn push_halmos_array_lengths(values: &mut Vec, value: &str) -> Result<(), String> { + match parse_halmos_array_lengths(value)? { + HalmosArrayLengths::Positional(lengths) => { + values.push(format!( + "default.symbolic.array_lengths = [{}]", + lengths.iter().format(", ") + )); + } + HalmosArrayLengths::Named(lengths) => { + values.push(format!( + "default.symbolic.dynamic_lengths = {{ {} }}", + lengths + .iter() + .map(|(name, lengths)| format!("{name} = [{}]", lengths.iter().format(", "))) + .format(", ") + )); + } + } + Ok(()) +} + +fn push_halmos_lengths( + values: &mut Vec, + field: &str, + value: &str, + flag: &str, +) -> Result<(), String> { + values.push(format!( + "default.symbolic.{field} = [{}]", + parse_halmos_lengths(value, flag)?.iter().format(", ") + )); + Ok(()) +} + +fn toml_string(value: &str) -> String { + format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) +} + +enum HalmosArrayLengths { + Positional(Vec), + Named(BTreeMap>), +} + +fn parse_halmos_array_lengths(value: &str) -> Result { + if value.contains('=') { + let mut named = BTreeMap::new(); + for entry in split_halmos_lengths_entries(value)? { + let Some((name, lengths)) = entry.split_once('=') else { + return Err(format!( + "mixed named and positional lengths in --array-lengths `{value}`" + )); + }; + let name = name.trim(); + if name.is_empty() { + return Err(format!("missing name in --array-lengths `{value}`")); + } + named + .insert(name.to_string(), parse_halmos_lengths(lengths.trim(), "--array-lengths")?); + } + if named.is_empty() { + return Err("missing value for --array-lengths".to_string()); + } + Ok(HalmosArrayLengths::Named(named)) + } else { + Ok(HalmosArrayLengths::Positional(parse_halmos_lengths(value, "--array-lengths")?)) + } +} + +fn parse_halmos_lengths(value: &str, flag: &str) -> Result, String> { + parse_halmos_length_set(value, flag) +} + +fn parse_halmos_length_set(value: &str, flag: &str) -> Result, String> { + let value = value.trim(); + let value = value.strip_prefix('{').and_then(|value| value.strip_suffix('}')).unwrap_or(value); + let mut lengths = Vec::new(); + for length in value.split(',') { + let length = length.trim(); + if length.is_empty() { + return Err(format!("invalid empty length in {flag} `{value}`")); + } + let length = length + .parse::() + .map_err(|_| format!("invalid length `{length}` in {flag} `{value}`"))?; + lengths.push(length); + } + if lengths.is_empty() { + return Err(format!("missing value for {flag}")); + } + Ok(lengths) +} + +fn split_halmos_lengths_entries(value: &str) -> Result, String> { + let mut entries = Vec::new(); + let mut start = 0usize; + let mut brace_depth = 0u8; + for (idx, ch) in value.char_indices() { + match ch { + '{' => brace_depth = brace_depth.saturating_add(1), + '}' => { + brace_depth = brace_depth + .checked_sub(1) + .ok_or_else(|| format!("unmatched `}}` in --array-lengths `{value}`"))?; + } + ',' if brace_depth == 0 => { + entries.push(value[start..idx].trim()); + start = idx + 1; + } + _ => {} + } + } + if brace_depth != 0 { + return Err(format!("unmatched `{{` in --array-lengths `{value}`")); + } + entries.push(value[start..].trim()); + Ok(entries) } struct SolcParser { @@ -114,7 +376,7 @@ struct SolcParser { } impl SolcParser { - fn new() -> Self { + const fn new() -> Self { Self { _private: () } } @@ -188,7 +450,7 @@ impl SolcParser { fn get_node_docs(&self, data: &BTreeMap) -> Option<(String, String)> { if let Value::Object(fn_docs) = data.get("documentation")? && let Value::String(comment) = fn_docs.get("text")? - && comment.contains(INLINE_CONFIG_PREFIX) + && contains_inline_config(comment) { let mut src_line = fn_docs .get("src") @@ -202,44 +464,41 @@ impl SolcParser { } } -struct SolarParser { - _private: (), +struct SolarParser<'a> { + sess: &'a Session, } -impl SolarParser { - fn new() -> Self { - Self { _private: () } +impl<'a> SolarParser<'a> { + const fn new(sess: &'a Session) -> Self { + Self { sess } } - fn parse( + fn parse_ast( &self, natspecs: &mut Vec, - src: &str, + source_unit: &ast::SourceUnit<'_>, contract_id: &str, contract_name: &str, ) { - // Fast path to avoid parsing the file. - if !src.contains(INLINE_CONFIG_PREFIX) { - return; - } - - let mut handle_docs = |item: &Item<'_>| { + let mut handle_docs = |item: &ast::Item<'_>| { if item.docs.is_empty() { return; } + let mut span = Span::DUMMY; let lines = item .docs .iter() .filter_map(|d| { let s = d.symbol.as_str(); - if !s.contains(INLINE_CONFIG_PREFIX) { + if !contains_inline_config(s) { return None; } + span = if span.is_dummy() { d.span } else { span.to(d.span) }; match d.kind { - CommentKind::Line => Some(s.trim().to_string()), - CommentKind::Block => Some( + ast::CommentKind::Line => Some(s.trim().to_string()), + ast::CommentKind::Block => Some( s.lines() - .filter(|line| line.contains(INLINE_CONFIG_PREFIX)) + .filter(|line| contains_inline_config(line)) .map(|line| line.trim_start().trim_start_matches('*').trim()) .collect::>() .join("\n"), @@ -250,11 +509,9 @@ impl SolarParser { if lines.is_empty() { return; } - let span = - item.docs.iter().map(|doc| doc.span).reduce(|a, b| a.to(b)).unwrap_or_default(); natspecs.push(NatSpec { contract: contract_id.to_string(), - function: if let ItemKind::Function(f) = &item.kind { + function: if let ast::ItemKind::Function(f) = &item.kind { Some( f.header .name @@ -264,14 +521,56 @@ impl SolarParser { } else { None }, - line: format!("{}:{}:0", span.lo().0, span.hi().0), + line: { + let (_, loc) = self.sess.source_map().span_to_location_info(span); + format!("{}:{}", loc.lo.line, loc.lo.col.0 + 1) + }, docs: lines, }); }; + for item in source_unit.items.iter() { + let ast::ItemKind::Contract(c) = &item.kind else { continue }; + if c.name.as_str() != contract_name { + continue; + } + + // Handle contract level doc comments. + handle_docs(item); + + // Handle function level doc comments. + for item in c.body.iter() { + let ast::ItemKind::Function(_) = &item.kind else { continue }; + handle_docs(item); + } + } + } +} + +fn contains_inline_config(s: &str) -> bool { + s.contains(INLINE_CONFIG_PREFIX) || s.contains(HALMOS_CONFIG_PREFIX) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use snapbox::{assert_data_eq, str}; + use solar::parse::{ + Parser, + ast::{Arena, interface}, + }; + + fn parse(natspecs: &mut Vec, src: &str, contract_id: &str, contract_name: &str) { + // Fast path to avoid parsing the file. + if !contains_inline_config(src) { + return; + } + let sess = Session::builder() .with_silent_emitter(Some("Inline config parsing failed".to_string())) .build(); + let solar = SolarParser::new(&sess); let _ = sess.enter(|| -> interface::Result<()> { let arena = Arena::new(); @@ -284,31 +583,11 @@ impl SolarParser { let source_unit = parser.parse_file().map_err(|e| e.emit())?; - for item in source_unit.items.iter() { - let ItemKind::Contract(c) = &item.kind else { continue }; - if c.name.as_str() != contract_name { - continue; - } - - // Handle contract level doc comments. - handle_docs(item); - - // Handle function level doc comments. - for item in c.body.iter() { - let ItemKind::Function(_) = &item.kind else { continue }; - handle_docs(item); - } - } + solar.parse_ast(natspecs, &source_unit, contract_id, contract_name); Ok(()) }); } -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; #[test] fn can_reject_invalid_profiles() { @@ -365,41 +644,45 @@ function f2() {} /** forge-config: default.fuzz.runs = 800 */ function f3() {} } "; let mut natspecs = vec![]; - let id = || "path.sol:C".to_string(); - let solar_parser = SolarParser::new(); - solar_parser.parse(&mut natspecs, src, &id(), "C"); - assert_eq!( - natspecs, - [ - // f1 - NatSpec { - contract: id(), - function: Some("f1".to_string()), - line: "14:134:0".to_string(), - docs: "forge-config: default.fuzz.runs = 600\nforge-config: default.fuzz.runs = 601".to_string(), - }, - // f2 - NatSpec { - contract: id(), - function: Some("f2".to_string()), - line: "164:208:0".to_string(), - docs: "forge-config: default.fuzz.runs = 700".to_string(), - }, - // f3 - NatSpec { - contract: id(), - function: Some("f3".to_string()), - line: "226:270:0".to_string(), - docs: "forge-config: default.fuzz.runs = 800".to_string(), - }, - // f4 - NatSpec { - contract: id(), - function: Some("f4".to_string()), - line: "289:391:0".to_string(), - docs: "forge-config: default.fuzz.runs = 1024\nforge-config: default.fuzz.max-test-rejects = 500".to_string(), - }, - ] + parse(&mut natspecs, src, "path.sol:C", "C"); + assert_data_eq!( + format!("{natspecs:#?}"), + str![[r#" +[ + NatSpec { + contract: "path.sol:C", + function: Some( + "f1", + ), + line: "2:14", + docs: "forge-config: default.fuzz.runs = 600/nforge-config: default.fuzz.runs = 601", + }, + NatSpec { + contract: "path.sol:C", + function: Some( + "f2", + ), + line: "7:8", + docs: "forge-config: default.fuzz.runs = 700", + }, + NatSpec { + contract: "path.sol:C", + function: Some( + "f3", + ), + line: "8:18", + docs: "forge-config: default.fuzz.runs = 800", + }, + NatSpec { + contract: "path.sol:C", + function: Some( + "f4", + ), + line: "10:1", + docs: "forge-config: default.fuzz.runs = 1024/nforge-config: default.fuzz.max-test-rejects = 500", + }, +] +"#]] ); } @@ -422,19 +705,21 @@ contract FuzzInlineConf is DSTest { } "#; let mut natspecs = vec![]; - let solar = SolarParser::new(); - let id = || "inline/FuzzInlineConf.t.sol:FuzzInlineConf".to_string(); - solar.parse(&mut natspecs, src, &id(), "FuzzInlineConf"); - assert_eq!( - natspecs, - [ - NatSpec { - contract: id(), - function: Some("testInlineConfFuzz".to_string()), - line: "141:255:0".to_string(), - docs: "forge-config: default.fuzz.runs = 1024\nforge-config: default.fuzz.max-test-rejects = 500".to_string(), - }, - ] + parse(&mut natspecs, src, "inline/FuzzInlineConf.t.sol:FuzzInlineConf", "FuzzInlineConf"); + assert_data_eq!( + format!("{natspecs:#?}"), + str![[r#" +[ + NatSpec { + contract: "inline/FuzzInlineConf.t.sol:FuzzInlineConf", + function: Some( + "testInlineConfFuzz", + ), + line: "8:5", + docs: "forge-config: default.fuzz.runs = 1024/nforge-config: default.fuzz.max-test-rejects = 500", + }, +] +"#]] ); } @@ -473,8 +758,8 @@ contract FuzzInlineConf is DSTest { fn natspec() -> NatSpec { let conf = r" - forge-config: default.fuzz.runs = 600 - forge-config: ci.fuzz.runs = 500 + forge-config: default.fuzz.runs = 600 + forge-config: ci.fuzz.runs = 500 ========= SOME NOISY TEXT ============= 䩹𧀫Jx닧Ʀ̳盅K擷􅟽Ɂw첊}ꏻk86ᖪk-檻ܴ렝[Dz𐤬oᘓƤ ꣖ۻ%Ƅ㪕ς:(饁΍av/烲ڻ̛߉橞㗡𥺃̹M봓䀖ؿ̄󵼁)𯖛d􂽰񮍃 @@ -511,31 +796,44 @@ contract FuzzInlineConf2 is DSTest { } "#; let mut natspecs = vec![]; - let solar = SolarParser::new(); - let id = || "inline/FuzzInlineConf.t.sol:FuzzInlineConf".to_string(); - solar.parse(&mut natspecs, src, &id(), "FuzzInlineConf"); - assert_eq!( - natspecs, - [NatSpec { - contract: id(), - function: Some("testInlineConfFuzz1".to_string()), - line: "142:181:0".to_string(), - docs: "forge-config: default.fuzz.runs = 1".to_string(), - },] + parse(&mut natspecs, src, "inline/FuzzInlineConf.t.sol:FuzzInlineConf", "FuzzInlineConf"); + assert_data_eq!( + format!("{natspecs:#?}"), + str![[r#" +[ + NatSpec { + contract: "inline/FuzzInlineConf.t.sol:FuzzInlineConf", + function: Some( + "testInlineConfFuzz1", + ), + line: "8:6", + docs: "forge-config: default.fuzz.runs = 1", + }, +] +"#]] ); let mut natspecs = vec![]; - let id = || "inline/FuzzInlineConf2.t.sol:FuzzInlineConf2".to_string(); - solar.parse(&mut natspecs, src, &id(), "FuzzInlineConf2"); - assert_eq!( - natspecs, - [NatSpec { - contract: id(), - function: Some("testInlineConfFuzz2".to_string()), - line: "264:303:0".to_string(), - // should not get config from previous contract - docs: "forge-config: default.fuzz.runs = 2".to_string(), - },] + parse( + &mut natspecs, + src, + "inline/FuzzInlineConf2.t.sol:FuzzInlineConf2", + "FuzzInlineConf2", + ); + assert_data_eq!( + format!("{natspecs:#?}"), + str![[r#" +[ + NatSpec { + contract: "inline/FuzzInlineConf2.t.sol:FuzzInlineConf2", + function: Some( + "testInlineConfFuzz2", + ), + line: "13:5", + docs: "forge-config: default.fuzz.runs = 2", + }, +] +"#]] ); } @@ -555,25 +853,181 @@ contract FuzzInlineConf is DSTest { function testInlineConfFuzz2() {} }"#; let mut natspecs = vec![]; - let solar = SolarParser::new(); - let id = || "inline/FuzzInlineConf.t.sol:FuzzInlineConf".to_string(); - solar.parse(&mut natspecs, src, &id(), "FuzzInlineConf"); + parse(&mut natspecs, src, "inline/FuzzInlineConf.t.sol:FuzzInlineConf", "FuzzInlineConf"); + assert_data_eq!( + format!("{natspecs:#?}"), + str![[r#" +[ + NatSpec { + contract: "inline/FuzzInlineConf.t.sol:FuzzInlineConf", + function: None, + line: "7:1", + docs: "forge-config: default.fuzz.runs = 1", + }, + NatSpec { + contract: "inline/FuzzInlineConf.t.sol:FuzzInlineConf", + function: Some( + "testInlineConfFuzz1", + ), + line: "9:5", + docs: "forge-config: default.fuzz.runs = 3", + }, +] +"#]] + ); + } + + #[test] + fn translates_legacy_halmos_array_lengths() { + let natspec = NatSpec { + contract: "dir/TestContract.t.sol:SymbolicContract".to_string(), + function: Some("checkBytes".to_string()), + line: "10:1".to_string(), + docs: "@custom:halmos --loop 256 --array-lengths 2,4,8 --depth 100".to_string(), + }; + assert_eq!( - natspecs, - [ - NatSpec { - contract: id(), - function: None, - line: "101:140:0".to_string(), - docs: "forge-config: default.fuzz.runs = 1".to_string(), - }, - NatSpec { - contract: id(), - function: Some("testInlineConfFuzz1".to_string()), - line: "181:220:0".to_string(), - docs: "forge-config: default.fuzz.runs = 3".to_string(), - } + natspec.halmos_config_values().unwrap(), + vec![ + "default.symbolic.loop = 256", + "default.symbolic.array_lengths = [2, 4, 8]", + "default.symbolic.depth = 100", + ] + ); + } + + #[test] + fn translates_named_halmos_array_lengths() { + let natspec = NatSpec { + contract: "dir/TestContract.t.sol:SymbolicContract".to_string(), + function: Some("checkBytes".to_string()), + line: "10:1".to_string(), + docs: "@custom:halmos --array-lengths values={2,4},data=8".to_string(), + }; + + assert_eq!( + natspec.halmos_config_values().unwrap(), + vec!["default.symbolic.dynamic_lengths = { data = [8], values = [2, 4] }"] + ); + } + + #[test] + fn translates_halmos_default_dynamic_length_sets() { + let natspec = NatSpec { + contract: "dir/TestContract.t.sol:SymbolicContract".to_string(), + function: Some("checkBytes".to_string()), + line: "10:1".to_string(), + docs: + "@custom:halmos --default-array-lengths 0,1,2 --default-bytes-lengths={0,65,1024}" + .to_string(), + }; + + assert_eq!( + natspec.halmos_config_values().unwrap(), + vec![ + "default.symbolic.default_array_lengths = [0, 1, 2]", + "default.symbolic.default_bytes_lengths = [0, 65, 1024]", + ] + ); + } + + #[test] + fn translates_legacy_halmos_width_depth_and_solver_timeout() { + let natspec = NatSpec { + contract: "dir/TestContract.t.sol:SymbolicContract".to_string(), + function: Some("invariant_state".to_string()), + line: "10:1".to_string(), + docs: "@custom:halmos --width=32 --depth 128 --solver-timeout-branching 5".to_string(), + }; + + assert_eq!( + natspec.halmos_config_values().unwrap(), + vec![ + "default.symbolic.width = 32", + "default.symbolic.depth = 128", + "default.symbolic.timeout = 5", ] ); } + + #[test] + fn translates_legacy_halmos_solver_selection() { + let natspec = NatSpec { + contract: "dir/TestContract.t.sol:SymbolicContract".to_string(), + function: Some("check_solver".to_string()), + line: "10:1".to_string(), + docs: "@custom:halmos --solver cvc5 --solver-command \"bitwuzla --produce-models\"" + .to_string(), + }; + + assert_eq!( + natspec.halmos_config_values().unwrap(), + vec![ + "default.symbolic.solver = \"cvc5\"", + "default.symbolic.solver_command = \"bitwuzla --produce-models\"", + ] + ); + } + + #[test] + fn rejects_malformed_legacy_halmos_array_lengths() { + let natspec = NatSpec { + contract: "dir/TestContract.t.sol:SymbolicContract".to_string(), + function: Some("checkBytes".to_string()), + line: "10:1".to_string(), + docs: "@custom:halmos --array-lengths nope".to_string(), + }; + + let err = natspec.halmos_config_values().unwrap_err(); + + assert!(err.to_string().contains("invalid @custom:halmos annotation")); + assert!(err.to_string().contains("invalid length `nope`")); + } + + #[test] + fn ignores_unsupported_halmos_flags_while_translating_supported_ones() { + let natspec = NatSpec { + contract: "dir/TestContract.t.sol:SymbolicContract".to_string(), + function: Some("checkBytes".to_string()), + line: "10:1".to_string(), + docs: "@custom:halmos --unsupported value --loop 10 --another --array-lengths 2" + .to_string(), + }; + + assert_eq!( + natspec.halmos_config_values().unwrap(), + vec!["default.symbolic.loop = 10", "default.symbolic.array_lengths = [2]",] + ); + } + + #[test] + fn parse_solar_legacy_halmos_only_config() { + let src = r#" +contract SymbolicHalmosLengths { + /// @custom:halmos --array-lengths 3 + function checkArray(uint256[] memory values) public pure { + values; + } +} + "#; + let mut natspecs = vec![]; + parse( + &mut natspecs, + src, + "inline/SymbolicHalmosLengths.t.sol:SymbolicHalmosLengths", + "SymbolicHalmosLengths", + ); + + assert_eq!(natspecs.len(), 1); + assert_eq!( + natspecs[0].halmos_config_values().unwrap(), + vec!["default.symbolic.array_lengths = [3]"] + ); + } + + #[test] + fn split_halmos_args_rejects_unterminated_quote() { + let err = split_halmos_args(r#"--width "unterm"#).unwrap_err(); + assert_eq!(err, "invalid shell quoting in @custom:halmos config"); + } } diff --git a/crates/config/src/invariant.rs b/crates/config/src/invariant.rs index be775dcd146e2..2226fb5fcf587 100644 --- a/crates/config/src/invariant.rs +++ b/crates/config/src/invariant.rs @@ -1,8 +1,122 @@ //! Configuration for invariant testing -use crate::fuzz::FuzzDictionaryConfig; -use serde::{Deserialize, Serialize}; -use std::path::PathBuf; +use crate::fuzz::{FuzzCorpusConfig, FuzzDictionaryConfig}; +use serde::{ + Deserialize, Deserializer, Serialize, Serializer, + de::{Error, Visitor}, +}; +use std::{fmt, num::NonZeroUsize, path::PathBuf, str::FromStr}; + +/// Worker selection mode for invariant campaign sharding. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum InvariantWorkers { + /// Automatically derive invariant workers from the active `--jobs` / rayon thread pool. + #[default] + Auto, + /// Explicit user override for invariant campaign sharding. + Fixed(NonZeroUsize), +} + +impl Serialize for InvariantWorkers { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + Self::Auto => serializer.serialize_str("auto"), + Self::Fixed(workers) => workers.get().serialize(serializer), + } + } +} + +impl<'de> Deserialize<'de> for InvariantWorkers { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_any(InvariantWorkersVisitor) + } +} + +impl FromStr for InvariantWorkers { + type Err = String; + + fn from_str(value: &str) -> Result { + let value = value.trim(); + if value.eq_ignore_ascii_case("auto") { + return Ok(Self::Auto); + } + + let workers = value.parse::().map_err(|err| err.to_string())?; + fixed_workers(workers) + } +} + +struct InvariantWorkersVisitor; + +impl Visitor<'_> for InvariantWorkersVisitor { + type Value = InvariantWorkers; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("`auto` or a positive integer worker count") + } + + fn visit_str(self, value: &str) -> Result + where + E: Error, + { + value.parse().map_err(E::custom) + } + + fn visit_u64(self, value: u64) -> Result + where + E: Error, + { + let workers = usize::try_from(value).map_err(E::custom)?; + fixed_workers(workers).map_err(E::custom) + } + + fn visit_i64(self, value: i64) -> Result + where + E: Error, + { + let workers = + usize::try_from(value).map_err(|_| E::custom("invariant workers must be positive"))?; + fixed_workers(workers).map_err(E::custom) + } +} + +fn fixed_workers(workers: usize) -> Result { + NonZeroUsize::new(workers) + .map(InvariantWorkers::Fixed) + .ok_or_else(|| "invariant workers must be greater than 0".to_string()) +} + +/// Per-run invariant depth selection mode. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum InvariantDepthMode { + /// Execute every invariant run up to the configured `depth`. + #[default] + Fixed, + /// Sample every invariant run depth uniformly between `min_depth` and `depth`. + #[serde(alias = "uniform")] + Random, +} + +impl FromStr for InvariantDepthMode { + type Err = String; + + fn from_str(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "fixed" => Ok(Self::Fixed), + "random" | "uniform" => Ok(Self::Random), + value => { + Err(format!("unknown invariant depth mode `{value}`, expected `fixed` or `random`")) + } + } + } +} /// Contains for invariant testing #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -11,6 +125,15 @@ pub struct InvariantConfig { pub runs: u32, /// The number of calls executed to attempt to break invariants in one run. pub depth: u32, + /// Minimum sampled run depth when `depth_mode = "random"`. + pub min_depth: u32, + /// How to choose the effective depth for each invariant run. + pub depth_mode: InvariantDepthMode, + /// Worker selection mode used to shard invariant runs. + /// + /// Defaults to `auto`, which derives the worker count from `--jobs`. Use a positive integer + /// for an explicit worker count. + pub workers: InvariantWorkers, /// Fails the invariant fuzzing if a revert occurs pub fail_on_revert: bool, /// Allows overriding an unsafe external call when running invariant tests. eg. reentrancy @@ -26,23 +149,37 @@ pub struct InvariantConfig { pub max_assume_rejects: u32, /// Number of runs to execute and include in the gas report. pub gas_report_samples: u32, - /// Path where invariant corpus is stored. If not configured then coverage guided fuzzing is - /// disabled. - pub corpus_dir: Option, - /// Whether corpus to use gzip file compression and decompression. - pub corpus_gzip: bool, - // Number of corpus mutations until marked as eligible to be flushed from memory. - pub corpus_min_mutations: usize, - // Number of corpus that won't be evicted from memory. - pub corpus_min_size: usize, + /// The fuzz corpus configuration. + #[serde(flatten)] + pub corpus: FuzzCorpusConfig, + /// Whether `corpus_random_sequence_weight` was supplied by a config provider. + #[serde(default, skip_serializing)] + #[doc(hidden)] + pub corpus_random_sequence_weight_configured: bool, + /// Whether `workers` was supplied by a config provider. + #[serde(default, skip_serializing)] + #[doc(hidden)] + pub workers_configured: bool, /// Path where invariant failures are recorded and replayed. pub failure_persist_dir: Option, /// Whether to collect and display fuzzed selectors metrics. pub show_metrics: bool, - /// Optional timeout (in seconds) for each invariant test. + /// Optional campaign-global timeout (in seconds) for each invariant test. pub timeout: Option, /// Display counterexample as solidity calls. pub show_solidity: bool, + /// Maximum time (in seconds) between generated txs. + pub max_time_delay: Option, + /// Maximum number of blocks elapsed between generated txs. + pub max_block_delay: Option, + /// Number of calls to execute between invariant assertions. + /// + /// - `0`: Only assert on the last call of each run (fastest, but may miss exact breaking call) + /// - `1` (default): Assert after every call (current behavior, most precise) + /// - `N`: Assert every N calls AND always on the last call + /// + /// Example: `check_interval = 10` means assert after calls 10, 20, 30, ... and the last call. + pub check_interval: u32, } impl Default for InvariantConfig { @@ -50,20 +187,25 @@ impl Default for InvariantConfig { Self { runs: 256, depth: 500, + min_depth: 1, + depth_mode: InvariantDepthMode::default(), + workers: InvariantWorkers::default(), fail_on_revert: false, call_override: false, dictionary: FuzzDictionaryConfig { dictionary_weight: 80, ..Default::default() }, shrink_run_limit: 5000, max_assume_rejects: 65536, gas_report_samples: 256, - corpus_dir: None, - corpus_gzip: true, - corpus_min_mutations: 5, - corpus_min_size: 0, + corpus: FuzzCorpusConfig::default(), + corpus_random_sequence_weight_configured: false, + workers_configured: false, failure_persist_dir: None, show_metrics: true, timeout: None, show_solidity: false, + max_time_delay: None, + max_block_delay: None, + check_interval: 1, } } } @@ -71,23 +213,83 @@ impl Default for InvariantConfig { impl InvariantConfig { /// Creates invariant configuration to write failures in `{PROJECT_ROOT}/cache/fuzz` dir. pub fn new(cache_dir: PathBuf) -> Self { - Self { - runs: 256, - depth: 500, - fail_on_revert: false, - call_override: false, - dictionary: FuzzDictionaryConfig { dictionary_weight: 80, ..Default::default() }, - shrink_run_limit: 5000, - max_assume_rejects: 65536, - gas_report_samples: 256, - corpus_dir: None, - corpus_gzip: true, - corpus_min_mutations: 5, - corpus_min_size: 0, - failure_persist_dir: Some(cache_dir), - show_metrics: true, - timeout: None, - show_solidity: false, - } + Self { failure_persist_dir: Some(cache_dir), ..Default::default() } + } + + /// Returns true if generated invariant calls may advance block time or height. + pub const fn has_delay(&self) -> bool { + matches!(self.max_block_delay, Some(delay) if delay > 0) + || matches!(self.max_time_delay, Some(delay) if delay > 0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn invariant_workers_accept_auto_and_fixed_counts() { + assert_eq!("AUTO".parse::().unwrap(), InvariantWorkers::Auto); + assert_eq!( + serde_json::from_str::(r#""auto""#).unwrap(), + InvariantWorkers::Auto + ); + assert_eq!( + serde_json::from_str::(r#"4"#).unwrap(), + InvariantWorkers::Fixed(NonZeroUsize::new(4).unwrap()) + ); + assert_eq!( + serde_json::from_str::(r#""4""#).unwrap(), + InvariantWorkers::Fixed(NonZeroUsize::new(4).unwrap()) + ); + } + + #[test] + fn invariant_workers_default_to_auto() { + assert_eq!(InvariantWorkers::default(), InvariantWorkers::Auto); + assert_eq!(InvariantConfig::default().workers, InvariantWorkers::Auto); + } + + #[test] + fn invariant_workers_reject_zero() { + let err = serde_json::from_str::(r#"0"#).unwrap_err(); + assert!(err.to_string().contains("greater than 0")); + } + + #[test] + fn invariant_config_zero_delays_are_disabled() { + let mut config = InvariantConfig { max_block_delay: Some(0), ..Default::default() }; + assert!(!config.has_delay()); + + config.max_block_delay = None; + config.max_time_delay = Some(0); + assert!(!config.has_delay()); + + config.max_time_delay = Some(1); + assert!(config.has_delay()); + } + + #[test] + fn invariant_config_equality_includes_corpus_random_sequence_provenance() { + let explicit = InvariantConfig { + corpus_random_sequence_weight_configured: true, + ..InvariantConfig::default() + }; + + assert_ne!(InvariantConfig::default(), explicit); + } + + #[test] + fn invariant_depth_mode_accepts_fixed_and_random() { + assert_eq!("fixed".parse::().unwrap(), InvariantDepthMode::Fixed); + assert_eq!("uniform".parse::().unwrap(), InvariantDepthMode::Random); + assert_eq!( + serde_json::from_str::(r#""random""#).unwrap(), + InvariantDepthMode::Random + ); + assert_eq!( + serde_json::from_str::(r#""uniform""#).unwrap(), + InvariantDepthMode::Random + ); } } diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 9d265867a11d3..9c6e61015ba4f 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -3,13 +3,13 @@ //! Foundry configuration. #![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] +#![cfg_attr(docsrs, feature(doc_cfg))] #[macro_use] extern crate tracing; -use crate::cache::StorageCachingConfig; -use alloy_primitives::{Address, B256, FixedBytes, U256, address, map::AddressHashMap}; +use crate::{cache::StorageCachingConfig, etherscan::EtherscanEnvProvider}; +use alloy_primitives::{Address, B256, U256, address, map::AddressHashMap}; use eyre::{ContextCompat, WrapErr}; use figment::{ Error, Figment, Metadata, Profile, Provider, @@ -36,37 +36,42 @@ use foundry_compilers::{ vyper::{Vyper, VyperSettings}, }, error::SolcError, - multi::{MultiCompilerParsedSource, MultiCompilerRestrictions}, - solc::{CliSettings, SolcSettings}, + multi::{MultiCompilerParser, MultiCompilerRestrictions}, + solc::{CliSettings, SolcLanguage, SolcSettings}, + utils::canonicalize, }; use regex::Regex; -use revm::primitives::hardfork::SpecId; use semver::Version; -use serde::{Deserialize, Serialize, Serializer}; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; use std::{ borrow::Cow, collections::BTreeMap, - fs, - path::{Path, PathBuf}, + fs, io, + path::{Component, Path, PathBuf}, str::FromStr, }; +#[cfg(windows)] +use path_slash::PathBufExt as _; + mod macros; pub mod utils; +pub use foundry_evm_hardforks::{ + ExecutionSpec, FoundryHardfork, FromEvmVersion, evm_spec_id, evm_spec_id_from_str, +}; pub use utils::*; mod endpoints; pub use endpoints::{ ResolvedRpcEndpoint, ResolvedRpcEndpoints, RpcEndpoint, RpcEndpointUrl, RpcEndpoints, + builtin_rpc_url, }; mod etherscan; -use etherscan::{ - EtherscanConfigError, EtherscanConfigs, EtherscanEnvProvider, ResolvedEtherscanConfig, -}; +pub use etherscan::{EtherscanConfigError, EtherscanConfigs, ResolvedEtherscanConfig}; -mod resolve; +pub mod resolve; pub use resolve::UnresolvedEnvVarError; pub mod cache; @@ -79,11 +84,13 @@ pub mod lint; pub use lint::{LinterConfig, Severity as LintSeverity}; pub mod fs_permissions; -pub use fs_permissions::FsPermissions; use fs_permissions::PathPermission; +pub use fs_permissions::FsPermissions; + pub mod error; use error::ExtractConfigError; + pub use error::SolidityErrorCode; pub mod doc; @@ -100,17 +107,32 @@ pub mod fix; // reexport so cli types can implement `figment::Provider` to easily merge compiler arguments pub use alloy_chains::{Chain, NamedChain}; pub use figment; -use foundry_block_explorers::EtherscanApiVersion; pub mod providers; -pub use providers::Remappings; use providers::*; +pub use providers::Remappings; + mod fuzz; -pub use fuzz::{FuzzConfig, FuzzDictionaryConfig}; +pub use fuzz::{FuzzConfig, FuzzCorpusConfig, FuzzCorpusMutationWeights, FuzzDictionaryConfig}; mod invariant; -pub use invariant::InvariantConfig; +pub use invariant::{InvariantConfig, InvariantDepthMode, InvariantWorkers}; + +mod symbolic; +pub use symbolic::{SymbolicConfig, SymbolicExplorationOrder, SymbolicStorageLayout}; + +mod coverage; +pub use coverage::{CoverageConfig, CoverageReportKind, parse_lcov_version}; + +mod trace; +pub use trace::TracingConfig; + +mod fee; +pub use fee::Eip1559FeeEstimatePreset; + +pub mod mutation; +pub use mutation::{MutationConfig, MutatorType}; mod inline; pub use inline::{InlineConfig, InlineConfigError, NatSpec}; @@ -127,6 +149,18 @@ use bind_json::BindJsonConfig; mod compilation; pub use compilation::{CompilationRestrictions, SettingsOverrides}; +mod external_compiler; +pub use external_compiler::ExternalCompiler; + +pub mod extend; +use extend::Extends; +use foundry_evm_networks::NetworkConfigs; + +pub use semver; + +#[cfg(not(test))] +static SELECTED_PROFILE: std::sync::OnceLock = std::sync::OnceLock::new(); + /// Foundry configuration /// /// # Defaults @@ -158,7 +192,7 @@ pub use compilation::{CompilationRestrictions, SettingsOverrides}; /// the "default" meta-profile. /// /// Note that these behaviors differ from those of [`Config::figment()`]. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct Config { /// The selected profile. **(default: _default_ `default`)** /// @@ -181,50 +215,62 @@ pub struct Config { #[serde(default = "root_default", skip_serializing)] pub root: PathBuf, - /// path of the source contracts dir, like `src` or `contracts` + /// Configuration for extending from another foundry.toml (base) file. + /// + /// Can be either a string path or an object with path and strategy. + /// Base files cannot extend (inherit) other files. + #[serde(default, skip_serializing)] + pub extends: Option, + + /// Path of the sources directory. + /// + /// Defaults to `src`. pub src: PathBuf, - /// path of the test dir + /// Path of the tests directory. pub test: PathBuf, - /// path of the script dir + /// Path of the scripts directory. pub script: PathBuf, - /// path to where artifacts shut be written to + /// Path to the artifacts directory. pub out: PathBuf, - /// all library folders to include, `lib`, `node_modules` + /// Paths to all library folders, such as `lib`, or `node_modules`. pub libs: Vec, - /// `Remappings` to use for this repo + /// Remappings to use for this repo + #[serde(serialize_with = "remappings_serde::serialize")] pub remappings: Vec, - /// Whether to autodetect remappings by scanning the `libs` folders recursively + /// Whether to autodetect remappings. pub auto_detect_remappings: bool, - /// library addresses to link + /// Library addresses to link. pub libraries: Vec, - /// whether to enable cache + /// Whether to enable the build cache. pub cache: bool, - /// whether to dynamically link tests - pub dynamic_test_linking: bool, - /// where the cache is stored if enabled + /// The path to the cache store. pub cache_path: PathBuf, - /// where the gas snapshots are stored + /// Whether to dynamically link tests. + pub dynamic_test_linking: bool, + /// Where the gas snapshots are stored. pub snapshots: PathBuf, - /// whether to check for differences against previously stored gas snapshots + /// Whether to check for differences against previously stored gas snapshots. pub gas_snapshot_check: bool, - /// whether to emit gas snapshots to disk + /// Whether to emit gas snapshots to disk. pub gas_snapshot_emit: bool, - /// where the broadcast logs are stored + /// The path to store broadcast logs at. pub broadcast: PathBuf, - /// additional solc allow paths for `--allow-paths` + /// Additional paths passed to `solc --allow-paths`. pub allow_paths: Vec, - /// additional solc include paths for `--include-path` + /// Additional paths passed to `solc --include-path`. pub include_paths: Vec, - /// glob patterns to skip + /// Glob patterns for file paths to skip when building and executing contracts. pub skip: Vec, - /// whether to force a `project.clean()` + /// Whether to forcefully clean all project artifacts before running commands. pub force: bool, - /// evm version to use + /// The EVM version to use when building contracts. #[serde(with = "from_str_lowercase")] pub evm_version: EvmVersion, - /// list of contracts to report gas of + /// The runtime hardfork to use when executing tests and scripts. + pub hardfork: Option, + /// List of contracts to generate gas reports for. pub gas_reports: Vec, - /// list of contracts to ignore for gas reports + /// List of contracts to ignore for gas reports. pub gas_reports_ignore: Vec, /// Whether to include gas reports for tests. pub gas_reports_include_tests: bool, @@ -267,12 +313,17 @@ pub struct Config { pub optimizer_details: Option, /// Model checker settings. pub model_checker: Option, - /// verbosity to use + /// Verbosity to use for global output. pub verbosity: u8, /// url of the rpc server that should be used for any rpc calls pub eth_rpc_url: Option, /// Whether to accept invalid certificates for the rpc server. pub eth_rpc_accept_invalid_certs: bool, + /// Whether to disable automatic proxy detection for the rpc server. + /// + /// This can help in sandboxed environments (e.g., Cursor IDE sandbox, macOS App Sandbox) + /// where system proxy detection via SCDynamicStore causes crashes. + pub eth_rpc_no_proxy: bool, /// JWT secret that should be used for any rpc calls pub eth_rpc_jwt: Option, /// Timeout that should be used for any rpc calls @@ -286,19 +337,24 @@ pub struct Config { /// You can also the ETH_RPC_HEADERS env variable like so: /// `ETH_RPC_HEADERS="x-custom-header:value x-another-header:another-value"` pub eth_rpc_headers: Option>, + /// Print the equivalent curl command instead of making the RPC request. + pub eth_rpc_curl: bool, /// etherscan API key, or alias for an `EtherscanConfig` in `etherscan` table pub etherscan_api_key: Option, - /// etherscan API version - pub etherscan_api_version: Option, /// Multiple etherscan api configs and their aliases #[serde(default, skip_serializing_if = "EtherscanConfigs::is_empty")] pub etherscan: EtherscanConfigs, - /// list of solidity error codes to always silence in the compiler output + /// List of solidity error codes to always silence in the compiler output. pub ignored_error_codes: Vec, - /// list of file paths to ignore + /// List of (path prefix, solidity error codes) to silence in the compiler output. + pub ignored_error_codes_from: Vec<(PathBuf, Vec)>, + /// List of file paths to ignore. #[serde(rename = "ignored_warnings_from")] pub ignored_file_paths: Vec, - /// When true, compiler warnings are treated as errors + /// Diagnostic level (minimum) at which the process should finish with a non-zero exit. + pub deny: DenyLevel, + /// DEPRECATED: use `deny` instead. + #[serde(default, skip_serializing)] pub deny_warnings: bool, /// Only run test functions matching the specified regex pattern. #[serde(rename = "match_test")] @@ -323,6 +379,8 @@ pub struct Config { pub coverage_pattern_inverse: Option, /// Path where last test run failures are recorded. pub test_failures_file: PathBuf, + /// Path where mutation tests are cached, to resume running them + pub mutation_dir: PathBuf, /// Max concurrent threads to use. pub threads: Option, /// Whether to show test execution progress. @@ -331,12 +389,27 @@ pub struct Config { pub fuzz: FuzzConfig, /// Configuration for invariant testing pub invariant: InvariantConfig, + /// Configuration for symbolic testing + pub symbolic: SymbolicConfig, + /// Configuration for `forge coverage` + pub coverage: CoverageConfig, + /// Configuration for mutation testing + pub mutation: MutationConfig, + /// Configuration for trace rendering. + pub tracing: TracingConfig, /// Whether to allow ffi cheatcodes in test pub ffi: bool, + /// Whether to show `console.log` outputs in realtime during script/test execution + pub live_logs: bool, /// Whether to allow `expectRevert` for internal functions. pub allow_internal_expect_revert: bool, /// Use the create 2 factory in all cases including tests and non-broadcasting scripts. pub always_use_create_2_factory: bool, + /// Controls how EIP-1559 fees are estimated for `forge script` broadcasts + /// (`low` / `market` / `aggressive`). Defaults to `market`, which preserves + /// the historical behavior (`base_fee * 2 + 20th-percentile priority fee`). + #[serde(default)] + pub eip1559_fee_estimate: Eip1559FeeEstimatePreset, /// Sets a timeout in seconds for vm.prompt cheatcodes pub prompt_timeout: u64, /// The address which will be executing all tests @@ -353,6 +426,9 @@ pub struct Config { pub block_number: U256, /// pins the block number for the state fork pub fork_block_number: Option, + /// Fetch fork state by block number for RPCs that cannot serve it by hash. + #[serde(default)] + pub fork_state_by_number: bool, /// The chain name or EIP-155 chain ID. #[serde(rename = "chain_id", alias = "chain")] pub chain: Option, @@ -423,6 +499,15 @@ pub struct Config { /// If set to true, changes compilation pipeline to go through the Yul intermediate /// representation. pub via_ir: bool, + /// Whether to turn on SSA CFG-based code generation via the IR. + /// This is an experimental feature (as of 0.8.35) that requires `experimental` to be enabled. + /// Enabling this option will also enable `via_ir` if it is not already enabled. + pub via_ssa_cfg: bool, + /// Whether to enable Solidity's experimental mode. + /// + /// This passes `--experimental` to solc, which is required by Solidity 0.8.35+ for + /// experimental features. + pub experimental: bool, /// Whether to include the AST as JSON in the compiler output. pub ast: bool, /// RPC storage caching settings determines what chains and endpoints to cache @@ -430,6 +515,17 @@ pub struct Config { /// Disables storage caching entirely. This overrides any settings made in /// `rpc_storage_caching` pub no_storage_caching: bool, + /// Disables parent-block BAL cache prewarming for transaction-hash forks. + /// + /// Defaults to `false`. Independent of disk storage caching; preceding transactions are + /// still replayed when prewarming is enabled. + /// + /// Each fork retains the value set at creation, including for subsequent transaction-hash + /// rolls. Contract-level inline configuration applies to forks created in `setUp`. + /// Function-level inline configuration applies to forks created in that test, but does not + /// change forks already created by `setUp`. + #[serde(default)] + pub no_fork_bal: bool, /// Disables rate limiting entirely. This overrides any settings made in /// `compute_units_per_second` pub no_rpc_rate_limit: bool, @@ -438,12 +534,15 @@ pub struct Config { pub rpc_endpoints: RpcEndpoints, /// Whether to store the referenced sources in the metadata as literal data. pub use_literal_content: bool, - /// Whether to include the metadata hash. + /// The hash method used for the metadata hash appended to the bytecode. /// - /// The metadata hash is machine dependent. By default, this is set to [BytecodeHash::None] to allow for deterministic code, See: + /// Defaults to [BytecodeHash::Ipfs], matching solc. The hash depends on the source contents, + /// source paths, and compiler settings. Set to [BytecodeHash::None] to omit the hash without + /// disabling the CBOR metadata trailer. + /// See: #[serde(with = "from_str_lowercase")] pub bytecode_hash: BytecodeHash, - /// Whether to append the metadata hash to the bytecode. + /// Whether to append CBOR-encoded metadata to the bytecode. Defaults to `true`. /// /// If this is `false` and the `bytecode_hash` option above is not `None` solc will issue a /// warning. @@ -479,16 +578,27 @@ pub struct Config { /// Useful for more correct gas accounting and EVM behavior in general. pub isolate: bool, - /// Whether to disable the block gas limit. + /// Whether to disable the block gas limit checks. pub disable_block_gas_limit: bool, - /// Address labels + /// Whether to enable the tx gas limit checks as imposed by Osaka (EIP-7825). + pub enable_tx_gas_limit: bool, + + /// Deprecated address-label alias; use [`TracingConfig::labels`]. + #[serde(default, skip_serializing_if = "AddressHashMap::is_empty")] pub labels: AddressHashMap, /// Whether to enable safety checks for `vm.getCode` and `vm.getDeployedCode` invocations. /// If disabled, it is possible to access artifacts which were not recompiled or cached. pub unchecked_cheatcode_artifacts: bool, + /// Whether to decode the storage layouts of contracts outside the local project in state + /// diffs, by compiling the verified source a block explorer has for them. + /// + /// Resolved layouts are cached under the explorer cache directory; `forge cache clean` + /// clears them. + pub decode_external_storage: bool, + /// CREATE2 salt to use for the library deployment in scripts. pub create2_library_salt: B256, @@ -498,6 +608,10 @@ pub struct Config { /// Configuration for Vyper compiler pub vyper: VyperConfig, + /// Explicitly configured external compiler adapters. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub external_compilers: Vec, + /// Soldeer dependencies pub dependencies: Option, @@ -516,9 +630,9 @@ pub struct Config { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub extra_args: Vec, - /// Whether to enable Odyssey features. - #[serde(alias = "alphanet")] - pub odyssey: bool, + /// Networks with enabled features. + #[serde(flatten)] + pub networks: NetworkConfigs, /// Timeout for transactions in seconds. pub transaction_timeout: u64, @@ -551,13 +665,109 @@ pub struct Config { pub _non_exhaustive: (), } +/// Diagnostic level (minimum) at which the process should finish with a non-zero exit. +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum, Default, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum DenyLevel { + /// Always exit with zero code. + #[default] + Never, + /// Exit with a non-zero code if any warnings are found. + Warnings, + /// Exit with a non-zero code if any notes or warnings are found. + Notes, +} + +// Custom deserialization to make `DenyLevel` parsing case-insensitive and backwards compatible with +// booleans. +impl<'de> Deserialize<'de> for DenyLevel { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct DenyLevelVisitor; + + impl<'de> de::Visitor<'de> for DenyLevelVisitor { + type Value = DenyLevel; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("one of the following strings: `never`, `warnings`, `notes`") + } + + fn visit_bool(self, value: bool) -> Result + where + E: de::Error, + { + Ok(DenyLevel::from(value)) + } + + fn visit_str(self, value: &str) -> Result + where + E: de::Error, + { + DenyLevel::from_str(value).map_err(de::Error::custom) + } + } + + deserializer.deserialize_any(DenyLevelVisitor) + } +} + +impl FromStr for DenyLevel { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "warnings" | "warning" | "w" => Ok(Self::Warnings), + "notes" | "note" | "n" => Ok(Self::Notes), + "never" | "false" | "f" => Ok(Self::Never), + _ => Err(format!( + "unknown variant: found `{s}`, expected one of `never`, `warnings`, `notes`" + )), + } + } +} + +impl From for DenyLevel { + fn from(deny: bool) -> Self { + if deny { Self::Warnings } else { Self::Never } + } +} + +impl DenyLevel { + /// Returns `true` if the deny level includes warnings. + pub const fn warnings(&self) -> bool { + match self { + Self::Never => false, + Self::Warnings | Self::Notes => true, + } + } + + /// Returns `true` if the deny level includes notes. + pub const fn notes(&self) -> bool { + match self { + Self::Never | Self::Warnings => false, + Self::Notes => true, + } + } + + /// Returns `true` if the deny level is set to never (only errors). + pub const fn never(&self) -> bool { + match self { + Self::Never => true, + Self::Warnings | Self::Notes => false, + } + } +} + /// Mapping of fallback standalone sections. See [`FallbackProfileProvider`]. pub const STANDALONE_FALLBACK_SECTIONS: &[(&str, &str)] = &[("invariant", "fuzz")]; /// Deprecated keys and their replacements. /// /// See [Warning::DeprecatedKey] -pub const DEPRECATIONS: &[(&str, &str)] = &[("cancun", "evm_version = Cancun")]; +pub const DEPRECATIONS: &[(&str, &str)] = + &[("cancun", "evm_version = Cancun"), ("deny_warnings", "deny = warnings")]; impl Config { /// The default profile: "default" @@ -569,6 +779,9 @@ impl Config { /// TOML section for profiles pub const PROFILE_SECTION: &'static str = "profile"; + /// External config sections, ignored from warnings. + pub const EXTERNAL_SECTION: &'static str = "external"; + /// Standalone sections in the config which get integrated into the selected profile pub const STANDALONE_SECTIONS: &'static [&'static str] = &[ "rpc_endpoints", @@ -578,6 +791,10 @@ impl Config { "doc", "fuzz", "invariant", + "symbolic", + "coverage", + "mutation", + "tracing", "labels", "dependencies", "soldeer", @@ -585,9 +802,17 @@ impl Config { "bind_json", ]; + pub(crate) fn is_standalone_section>(section: &T) -> bool { + section == Self::PROFILE_SECTION + || section == Self::EXTERNAL_SECTION + || Self::STANDALONE_SECTIONS.iter().any(|s| section == *s) + } + /// File name of config toml file pub const FILE_NAME: &'static str = "foundry.toml"; + const DEFAULT_SRC: &'static str = "src"; + /// The name of the directory foundry reserves for itself under the user's home directory: `~` pub const FOUNDRY_DIR_NAME: &'static str = ".foundry"; @@ -597,7 +822,7 @@ impl Config { pub const DEFAULT_SENDER: Address = address!("0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38"); /// Default salt for create2 library deployments - pub const DEFAULT_CREATE2_LIBRARY_SALT: FixedBytes<32> = FixedBytes::<32>::ZERO; + pub const DEFAULT_CREATE2_LIBRARY_SALT: B256 = B256::ZERO; /// Default create2 deployer pub const DEFAULT_CREATE2_DEPLOYER: Address = @@ -625,6 +850,18 @@ impl Config { Self::from_provider(Self::figment_with_root(root.as_ref())) } + /// Loads the `Config` from the given root directory, allowing profile fallback. + /// + /// Unlike [`load_with_root`](Self::load_with_root), if the selected profile (via + /// `FOUNDRY_PROFILE`) does not exist in the config, this falls back to the default profile + /// instead of returning an error. This is useful for loading nested lib/dependency configs + /// that may not define all profiles the main project uses. + #[track_caller] + pub fn load_with_root_and_fallback(root: impl AsRef) -> Result { + let figment = Self::figment_with_root(root.as_ref()); + Self::from_figment_fallback(Figment::from(figment)) + } + /// Attempts to extract a `Config` from `provider`, returning the result. /// /// # Example @@ -642,7 +879,36 @@ impl Config { #[doc(alias = "try_from")] pub fn from_provider(provider: T) -> Result { trace!("load config with provider: {:?}", provider.metadata()); - Self::from_figment(Figment::from(provider)) + Self::from_figment(Figment::from(provider.legacy_labels())) + } + + /// Applies an inline provider on top of the current config without reloading external + /// providers such as `foundry.toml`, env vars, or remappings. + pub fn merge_inline_provider(&self, provider: T) -> Result { + let provider = Figment::from(provider.legacy_labels()).select(self.profile.clone()); + let invariant_corpus_random_sequence_weight_configured = + self.invariant.corpus_random_sequence_weight_configured + || provider.contains("invariant.corpus_random_sequence_weight") + || provider + .extract_inner::("invariant.corpus_random_sequence_weight_configured") + .unwrap_or(false); + let invariant_workers_configured = self.invariant.workers_configured + || provider.contains("invariant.workers") + || provider.extract_inner::("invariant.workers_configured").unwrap_or(false) + || provider.extract_inner::("invariant.workers").is_ok(); + let figment = self.to_figment(FigmentProviders::None).merge(provider); + let mut config = figment.extract::()?; + config.profile = self.profile.clone(); + config.profiles = self.profiles.clone(); + // The project root anchors `fs_permissions` and the `foundry.toml` write guard, so inline + // config must not be able to move it. + config.root = self.root.clone(); + config.invariant.corpus_random_sequence_weight_configured = + invariant_corpus_random_sequence_weight_configured; + config.invariant.workers_configured = invariant_workers_configured; + config.normalize_hardfork_settings()?; + + Ok(config) } #[doc(hidden)] @@ -652,31 +918,92 @@ impl Config { } fn from_figment(figment: Figment) -> Result { + Self::from_figment_inner(figment, true) + } + + /// Same as `from_figment` but allows unknown profiles, falling back to default profile. + /// Used when loading nested lib configs that may not define all profiles. + fn from_figment_fallback(figment: Figment) -> Result { + Self::from_figment_inner(figment, false) + } + + fn from_figment_inner( + figment: Figment, + strict_profile: bool, + ) -> Result { + let invariant_corpus_random_sequence_weight_configured = figment + .extract_inner::("invariant.corpus_random_sequence_weight_configured") + .unwrap_or_else(|_| { + figment_value_is_configured(&figment, "invariant.corpus_random_sequence_weight") + }); + let invariant_workers_configured = figment + .extract_inner::("invariant.workers_configured") + .unwrap_or_else(|_| figment_value_is_configured(&figment, "invariant.workers")); let mut config = figment.extract::().map_err(ExtractConfigError::new)?; - config.profile = figment.profile().clone(); + config.invariant.corpus_random_sequence_weight_configured = + invariant_corpus_random_sequence_weight_configured; + config.invariant.workers_configured = invariant_workers_configured; + let selected_profile = figment.profile().clone(); // The `"profile"` profile contains all the profiles as keys. - let mut add_profile = |profile: &Profile| { - if !config.profiles.contains(profile) { - config.profiles.push(profile.clone()); + fn add_profile(profiles: &mut Vec, profile: &Profile) { + if !profiles.contains(profile) { + profiles.push(profile.clone()); } - }; + } let figment = figment.select(Self::PROFILE_SECTION); if let Ok(data) = figment.data() && let Some(profiles) = data.get(&Profile::new(Self::PROFILE_SECTION)) { for profile in profiles.keys() { - add_profile(&Profile::new(profile)); + add_profile(&mut config.profiles, &Profile::new(profile)); + } + } + add_profile(&mut config.profiles, &Self::DEFAULT_PROFILE); + + // Check if the selected profile exists. + if config.profiles.contains(&selected_profile) { + config.profile = selected_profile; + } else { + // Fall back to the default profile. In strict mode (top-level loads), emit a warning + // so users are informed when an unknown profile (e.g. via `FOUNDRY_PROFILE`) is + // selected; the silent fallback is reserved for nested lib configs. + if strict_profile { + config + .warnings + .push(Warning::UnknownProfile { profile: selected_profile.to_string() }); } + config.profile = Self::DEFAULT_PROFILE; } - add_profile(&Self::DEFAULT_PROFILE); - add_profile(&config.profile); config.normalize_optimizer_settings(); + config.normalize_hardfork_settings().map_err(ExtractConfigError::new)?; + + // Validate optimizer_runs does not exceed u32::MAX (Solidity compiler limit) + if let Some(runs) = config.optimizer_runs + && runs > u32::MAX as usize + { + return Err(ExtractConfigError::new(Error::from(format!( + "`optimizer_runs` value {} exceeds maximum allowed value of {}", + runs, + u32::MAX + )))); + } Ok(config) } + fn normalize_hardfork_settings(&mut self) -> Result<(), Error> { + self.networks.validate().map_err(Error::from)?; + let Some(hardfork) = self.hardfork else { return Ok(()) }; + self.networks = self.networks.normalize_for_hardfork(hardfork).map_err(Error::from)?; + Ok(()) + } + + fn uses_default_src(&self) -> bool { + self.src == Path::new(Self::DEFAULT_SRC) + } + /// Returns the populated [Figment] using the requested [FigmentProviders] preset. /// /// This will merge various providers, such as env,toml,remappings into the figment if @@ -690,20 +1017,21 @@ impl Config { let root = self.root.as_path(); let profile = Self::selected_profile(); - let mut figment = Figment::default().merge(DappHardhatDirProvider(root)); + let mut figment = Figment::default() + .merge(DappHardhatDirProvider { root, detect_src: self.uses_default_src() }); // merge global foundry.toml file if let Some(global_toml) = Self::foundry_dir_toml().filter(|p| p.exists()) { figment = Self::merge_toml_provider( figment, - TomlFileProvider::new(None, global_toml).cached(), + TomlFileProvider::new(None, global_toml), profile.clone(), ); } // merge local foundry.toml file figment = Self::merge_toml_provider( figment, - TomlFileProvider::new(Some("FOUNDRY_CONFIG"), root.join(Self::FILE_NAME)).cached(), + TomlFileProvider::new(Some("FOUNDRY_CONFIG"), root.join(Self::FILE_NAME)), profile.clone(), ); @@ -712,12 +1040,14 @@ impl Config { .merge( Env::prefixed("DAPP_") .ignore(&["REMAPPINGS", "LIBRARIES", "FFI", "FS_PERMISSIONS"]) - .global(), + .global() + .legacy_labels(), ) .merge( Env::prefixed("DAPP_TEST_") .ignore(&["CACHE", "FUZZ_RUNS", "DEPTH", "FFI", "FS_PERMISSIONS"]) - .global(), + .global() + .legacy_labels(), ) .merge(DappEnvCompatProvider) .merge(EtherscanEnvProvider::default()) @@ -734,7 +1064,8 @@ impl Config { key.into() } }) - .global(), + .global() + .legacy_labels(), ) .select(profile.clone()); @@ -757,8 +1088,27 @@ impl Config { figment = figment.merge(remappings); } + let invariant_corpus_random_sequence_weight_configured = self + .invariant + .corpus_random_sequence_weight_configured + || figment + .extract_inner::("invariant.corpus_random_sequence_weight_configured") + .unwrap_or_else(|_| { + figment_value_is_configured(&figment, "invariant.corpus_random_sequence_weight") + }); + let invariant_workers_configured = self.invariant.workers_configured + || figment.extract_inner::("invariant.workers_configured").unwrap_or_else(|_| { + figment.extract_inner::("invariant.workers").is_ok() + }); + // normalize defaults figment = self.normalize_defaults(figment); + if invariant_corpus_random_sequence_weight_configured { + figment = figment.merge(("invariant.corpus_random_sequence_weight_configured", true)); + } + if invariant_workers_configured { + figment = figment.merge(("invariant.workers_configured", true)); + } Figment::from(self).merge(figment).select(profile) } @@ -805,6 +1155,7 @@ impl Config { self.broadcast = p(&root, &self.broadcast); self.cache_path = p(&root, &self.cache_path); self.snapshots = p(&root, &self.snapshots); + self.test_failures_file = p(&root, &self.test_failures_file); if let Some(build_info_path) = self.build_info_path { self.build_info_path = Some(p(&root, &build_info_path)); @@ -812,8 +1163,11 @@ impl Config { self.libs = self.libs.into_iter().map(|lib| p(&root, &lib)).collect(); - self.remappings = - self.remappings.into_iter().map(|r| RelativeRemapping::new(r.into(), &root)).collect(); + self.remappings = self + .remappings + .into_iter() + .map(|r| relative_remapping_preserving_context_boundary(r.into(), &root)) + .collect(); self.allow_paths = self.allow_paths.into_iter().map(|allow| p(&root, &allow)).collect(); @@ -841,7 +1195,7 @@ impl Config { /// Normalizes optimizer settings. /// See - pub fn normalized_optimizer_settings(mut self) -> Self { + pub const fn normalized_optimizer_settings(mut self) -> Self { self.normalize_optimizer_settings(); self } @@ -855,7 +1209,7 @@ impl Config { /// - with default settings, optimizer is set to false and optimizer runs to 200 /// - if optimizer is set and optimizer runs not specified, then optimizer runs is set to 200 /// - enable optimizer if not explicitly set and optimizer runs set to a value greater than 0 - pub fn normalize_optimizer_settings(&mut self) { + pub const fn normalize_optimizer_settings(&mut self) { match (self.optimizer, self.optimizer_runs) { // Default: set the optimizer to false and optimizer runs to 200. (None, None) => { @@ -871,9 +1225,11 @@ impl Config { } /// Returns the normalized [EvmVersion] for the current solc version, or the configured one. + /// + /// Only a pinned solc version is used; a local compiler path is never executed. pub fn get_normalized_evm_version(&self) -> EvmVersion { - if let Some(version) = self.solc_version() - && let Some(evm_version) = self.evm_version.normalize_version_solc(&version) + if let Some(SolcReq::Version(version)) = &self.solc + && let Some(evm_version) = self.evm_version.normalize_version_solc(version) { return evm_version; } @@ -899,6 +1255,7 @@ impl Config { /// Cleans up any duplicate `Remapping` and sorts them /// /// On windows this will convert any `\` in the remapping path into a `/` + #[allow(clippy::missing_const_for_fn)] pub fn sanitize_remappings(&mut self) { #[cfg(target_os = "windows")] { @@ -945,6 +1302,20 @@ impl Config { self.create_project(false, true) } + /// A cached, in-memory project that does not request any artifacts. + /// + /// Use this when you just want the source graph or the Solar compiler context. + pub fn solar_project(&self) -> Result, SolcError> { + let ui_testing = std::env::var_os("FOUNDRY_LINT_UI_TESTING").is_some(); + let mut project = self.create_project(self.cache && !ui_testing, false)?; + project.update_output_selection(|selection| { + // We have to request something to populate `contracts` in the output and thus + // artifacts. + *selection = OutputSelection::common_output_selection(["abi".into()]); + }); + Ok(project) + } + /// Builds mapping with additional settings profiles. fn additional_settings( &self, @@ -968,12 +1339,13 @@ impl Config { paths: &ProjectPathsConfig, ) -> Result>, SolcError> { - let mut map = BTreeMap::new(); + let mut map: BTreeMap> = + BTreeMap::new(); if self.compilation_restrictions.is_empty() { return Ok(BTreeMap::new()); } - let graph = Graph::::resolve(paths)?; + let graph = Graph::::resolve(paths)?; let (sources, _) = graph.into_sources(); for res in &self.compilation_restrictions { @@ -988,9 +1360,7 @@ impl Config { }) { let res: RestrictionsWithVersion<_> = res.clone().try_into().map_err(SolcError::msg)?; - if !map.contains_key(source) { - map.insert(source.clone(), res); - } else { + if map.contains_key(source) { let value = map.remove(source.as_path()).unwrap(); if let Some(merged) = value.clone().merge(res) { map.insert(source.clone(), merged); @@ -1005,6 +1375,8 @@ impl Config { ); map.insert(source.clone(), value); } + } else { + map.insert(source.clone(), res); } } } @@ -1018,6 +1390,10 @@ impl Config { pub fn create_project(&self, cached: bool, no_artifacts: bool) -> Result { let settings = self.compiler_settings()?; let paths = self.project_paths(); + + // Strip "./" prefix for consistent path matching + let parse_path = |path: &PathBuf| path.strip_prefix("./").unwrap_or(path).to_path_buf(); + let mut builder = Project::builder() .artifacts(self.configured_artifacts_handler()) .additional_settings(self.additional_settings(&settings)) @@ -1025,14 +1401,17 @@ impl Config { .settings(settings) .paths(paths) .ignore_error_codes(self.ignored_error_codes.iter().copied().map(Into::into)) - .ignore_paths(self.ignored_file_paths.clone()) - .set_compiler_severity_filter(if self.deny_warnings { + .ignore_error_codes_from(self.ignored_error_codes_from.iter().map(|(path, codes)| { + (parse_path(path), codes.iter().copied().map(Into::into).collect()) + })) + .ignore_paths(self.ignored_file_paths.iter().map(parse_path).collect()) + .set_compiler_severity_filter(if self.deny.warnings() { Severity::Warning } else { Severity::Error }) .set_offline(self.offline) - .set_cached(cached) + .set_cached(cached && !self.deny.warnings()) .set_build_info(!no_artifacts && self.build_info) .set_no_artifacts(no_artifacts); @@ -1043,37 +1422,179 @@ impl Config { let project = builder.build(self.compiler()?)?; + // `ProjectBuilder` slashes paths on Windows. Re-encode a contextual remapping's trailing + // directory boundary with the native separator so a later `Remapping::to_string` does not + // discard it while converting the context back to slash-separated solc syntax. + #[cfg(windows)] + let mut project = project; + #[cfg(windows)] + for remapping in &mut project.paths.remappings { + if let Some(context) = &mut remapping.context + && context.ends_with('/') + { + context.pop(); + context.push(std::path::MAIN_SEPARATOR); + } + } + if self.force { - self.cleanup(&project)?; + // Warnings are intentionally dropped here because `sh_warn!` is a circular + // dependency. Callers that need warnings should call `cleanup()` directly. + let _ = self.cleanup(&project); } Ok(project) } + /// Disables optimizations and enables viaIR with minimum optimization if `ir_minimum` is true. + pub fn disable_optimizations(&self, project: &mut Project, ir_minimum: bool) { + if ir_minimum { + // Enable viaIR with minimum optimization: https://github.com/ethereum/solidity/issues/12533#issuecomment-1013073350 + // And also in new releases of Solidity: https://github.com/ethereum/solidity/issues/13972#issuecomment-1628632202 + project.settings.solc.settings = std::mem::take(&mut project.settings.solc.settings) + .with_via_ir_minimum_optimization(); + + // Sanitize settings for solc 0.8.4 if version cannot be detected: https://github.com/foundry-rs/foundry/issues/9322 + // But keep the EVM version: https://github.com/ethereum/solidity/issues/15775 + let evm_version = project.settings.solc.evm_version; + let version = self.solc_version().unwrap_or_else(|| Version::new(0, 8, 4)); + project.settings.solc.settings.sanitize(&version, SolcLanguage::Solidity); + project.settings.solc.evm_version = evm_version; + } else { + project.settings.solc.optimizer.disable(); + project.settings.solc.optimizer.runs = None; + project.settings.solc.optimizer.details = None; + project.settings.solc.via_ir = None; + } + } + /// Cleans the project. + /// + /// Returns a list of warning messages for any non-fatal cleanup failures. Cleanup is + /// best-effort: all steps are attempted even if some fail. pub fn cleanup>( &self, project: &Project, - ) -> Result<(), SolcError> { - project.cleanup()?; + ) -> Result, SolcError> { + let mut warnings = Vec::new(); + + if let Some(coverage_cache) = self.coverage_cache_path() + && coverage_cache.exists() + { + let result = Self::lock_coverage_cache(&coverage_cache) + .and_then(|_lock| fs::remove_dir_all(&coverage_cache)); + if let Err(err) = result + && err.kind() != io::ErrorKind::NotFound + { + warnings.push(format!( + "failed to remove coverage cache {}: {err}", + coverage_cache.display() + )); + } + } + + let external_cache = self.root.join(&self.cache_path).join("external-compilers"); + if let Err(err) = fs::remove_dir_all(&external_cache) + && err.kind() != io::ErrorKind::NotFound + { + warnings.push(format!( + "failed to remove external compiler cache {}: {err}", + external_cache.display() + )); + } + + if let Err(err) = project.cleanup() { + warnings.push(format!("failed to clean project artifacts: {err}")); + } // Remove last test run failures file. - let _ = fs::remove_file(&self.test_failures_file); + if let Err(err) = fs::remove_file(&self.test_failures_file) + && err.kind() != io::ErrorKind::NotFound + { + warnings.push(format!( + "failed to remove test failures file {}: {err}", + self.test_failures_file.display() + )); + } + + // Remove mutation test cache directory + let _ = fs::remove_dir_all(project.root().join(&self.mutation_dir)); // Remove fuzz and invariant cache directories. - let remove_test_dir = |test_dir: &Option| { + let mut remove_test_dir = |test_dir: &Option| { if let Some(test_dir) = test_dir { let path = project.root().join(test_dir); - if path.exists() { - let _ = fs::remove_dir_all(&path); + if let Err(err) = fs::remove_dir_all(&path) + && err.kind() != io::ErrorKind::NotFound + { + warnings.push(format!( + "failed to remove test cache directory {}: {err}", + path.display() + )); } } }; remove_test_dir(&self.fuzz.failure_persist_dir); - remove_test_dir(&self.invariant.corpus_dir); + remove_test_dir(&self.fuzz.corpus.corpus_dir); + remove_test_dir(&self.fuzz.corpus.frontier_dir); + remove_test_dir(&self.invariant.corpus.corpus_dir); remove_test_dir(&self.invariant.failure_persist_dir); - Ok(()) + Ok(warnings) + } + + /// Marks directories owned by the coverage compiler cache. + pub const COVERAGE_CACHE_MARKER: &str = ".foundry-coverage-cache"; + + /// Returns the coverage cache directory when it does not overlap project data. + pub fn coverage_cache_path(&self) -> Option { + // Resolve existing ancestors so symlinks and parent components cannot make cleanup + // target a protected directory. Unresolvable paths disable this optional cache. + let resolve = |path: PathBuf| { + let existing = path.ancestors().find(|path| path.exists())?; + let suffix = path.strip_prefix(existing).ok()?; + if suffix.components().any(|part| !matches!(part, Component::Normal(_))) { + return None; + } + let path = canonicalize(existing).ok()?.join(suffix); + #[cfg(windows)] + let path = PathBuf::from(path.to_slash_lossy().as_ref()); + Some(path) + }; + let root = resolve(self.root.clone())?; + let cache = resolve(self.root.join(&self.cache_path).join("coverage"))?; + if root.starts_with(&cache) { + return None; + } + for path in [&self.src, &self.test, &self.script, &self.out] + .into_iter() + .chain(&self.libs) + .chain(&self.build_info_path) + { + let path = resolve(self.root.join(path))?; + if path.starts_with(&cache) || cache.starts_with(path) { + return None; + } + } + if cache.exists() && !cache.join(Self::COVERAGE_CACHE_MARKER).is_file() { + return None; + } + Some(cache) + } + + /// Locks the coverage cache for loading, publishing, or cleanup without waiting. + /// + /// Keep the lock file outside the cache directory and never remove it: all operations must + /// lock the same file even when cleanup removes the directory. + pub fn lock_coverage_cache(path: &Path) -> io::Result { + let lock = fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(path.with_extension("lock"))?; + lock.try_lock()?; + Ok(lock) } /// Ensures that the configured version is installed if explicitly set @@ -1097,15 +1618,7 @@ impl Config { Solc::blocking_install(version)? } } - SolcReq::Local(solc) => { - if !solc.is_file() { - return Err(SolcError::msg(format!( - "`solc` {} does not exist", - solc.display() - ))); - } - Solc::new(solc)? - } + SolcReq::Local(solc) => Solc::new(resolve_solc_path(solc)?)?, }; return Ok(Some(solc)); } @@ -1113,17 +1626,16 @@ impl Config { Ok(None) } - /// Returns the [SpecId] derived from the configured [EvmVersion] - #[inline] - pub fn evm_spec_id(&self) -> SpecId { - evm_spec_id(self.evm_version, self.odyssey) + /// Returns the Spec derived from the configured [EvmVersion] + pub fn evm_spec_id(&self) -> SPEC { + self.hardfork.map(Into::into).unwrap_or_else(|| evm_spec_id(self.evm_version)) } /// Returns whether the compiler version should be auto-detected /// /// Returns `false` if `solc_version` is explicitly set, otherwise returns the value of /// `auto_detect_solc` - pub fn is_auto_detect(&self) -> bool { + pub const fn is_auto_detect(&self) -> bool { if self.solc.is_some() { return false; } @@ -1159,7 +1671,7 @@ impl Config { .scripts(&self.script) .artifacts(&self.out) .libs(self.libs.iter()) - .remappings(self.get_all_remappings()) + .remappings(self.project_remappings()) .allowed_path(&self.root) .allowed_paths(&self.libs) .allowed_paths(&self.allow_paths) @@ -1215,6 +1727,50 @@ impl Config { self.remappings.iter().map(|m| m.clone().into()) } + /// Returns project remappings with absolute aliases for relative external filesystem contexts. + fn project_remappings(&self) -> Vec { + let remappings = self.get_all_remappings().collect::>(); + let mut adjusted = Vec::with_capacity(remappings.len()); + + // External source unit names remain absolute in the compiler input, while configured + // contexts are root-relative. Preserve the configured order and add an equivalent absolute + // context immediately after each relative form so both the project resolver and compiler + // select the same mapping. + for remapping in &remappings { + adjusted.push(remapping.clone()); + + let Some(context) = remapping.context.as_deref() else { continue }; + if Path::new(context).is_absolute() { + continue; + } + let Ok(context_path) = foundry_compilers::utils::normalize_solidity_import_path( + &self.root, + Path::new(context), + ) else { + continue; + }; + // `normalize_solidity_import_path` returns a slash path on Windows. Convert it back to + // a native path before it enters `ProjectBuilder`, which performs the one canonical + // slash conversion for compiler source-unit names. + #[cfg(windows)] + let context_path = PathBuf::from_slash(context_path.to_string_lossy()); + if context_path.starts_with(&self.root) { + continue; + } + let mut context_path = context_path.display().to_string(); + if context.ends_with(['/', '\\']) && !context_path.ends_with(['/', '\\']) { + context_path.push(std::path::MAIN_SEPARATOR); + } + + let mut absolute = remapping.clone(); + absolute.context = Some(context_path); + if !remappings.contains(&absolute) && !adjusted.contains(&absolute) { + adjusted.push(absolute); + } + } + adjusted + } + /// Returns the configured rpc jwt secret /// /// Returns: @@ -1249,7 +1805,7 @@ impl Config { /// # } /// ``` pub fn get_rpc_url(&self) -> Option, UnresolvedEnvVarError>> { - let maybe_alias = self.eth_rpc_url.as_ref().or(self.etherscan_api_key.as_ref())?; + let maybe_alias = self.eth_rpc_url.as_deref()?; if let Some(alias) = self.get_rpc_url_with_alias(maybe_alias) { Some(alias) } else { @@ -1294,6 +1850,10 @@ impl Config { return Some(Ok(Cow::Owned(mesc_url))); } + if let Some(builtin) = crate::endpoints::builtin_rpc_url(maybe_alias) { + return Some(Ok(Cow::Borrowed(builtin))); + } + None } @@ -1395,48 +1955,16 @@ impl Config { &self, chain: Option, ) -> Result, EtherscanConfigError> { - let default_api_version = self.etherscan_api_version.unwrap_or_default(); - - if let Some(maybe_alias) = self.etherscan_api_key.as_ref().or(self.eth_rpc_url.as_ref()) - && self.etherscan.contains_key(maybe_alias) - { - return self - .etherscan - .clone() - .resolved(default_api_version) - .remove(maybe_alias) - .transpose(); - } - - // try to find by comparing chain IDs after resolving - if let Some(res) = chain.or(self.chain).and_then(|chain| { - self.etherscan.clone().resolved(default_api_version).find_chain(chain) - }) { - match (res, self.etherscan_api_key.as_ref()) { - (Ok(mut config), Some(key)) => { - // we update the key, because if an etherscan_api_key is set, it should take - // precedence over the entry, since this is usually set via env var or CLI args. - config.key.clone_from(key); - return Ok(Some(config)); - } - (Ok(config), None) => return Ok(Some(config)), - (Err(err), None) => return Err(err), - (Err(_), Some(_)) => { - // use the etherscan key as fallback - } - } - } - - // etherscan fallback via API key - if let Some(key) = self.etherscan_api_key.as_ref() { - return Ok(ResolvedEtherscanConfig::create( - key, - chain.or(self.chain).unwrap_or_default(), - default_api_version, - )); - } + self.etherscan.resolve_for( + self.etherscan_alias(), + self.etherscan_api_key.as_deref(), + chain.or(self.chain), + ) + } - Ok(None) + /// The `[etherscan]` entry to prefer over matching on chain id, if it names one. + pub fn etherscan_alias(&self) -> Option<&str> { + self.etherscan_api_key.as_deref().or(self.eth_rpc_url.as_deref()) } /// Helper function to just get the API key @@ -1444,19 +1972,19 @@ impl Config { /// Optionally updates the config with the given `chain`. /// /// See also [Self::get_etherscan_config_with_chain] + #[expect(clippy::disallowed_macros)] pub fn get_etherscan_api_key(&self, chain: Option) -> Option { - self.get_etherscan_config_with_chain(chain).ok().flatten().map(|c| c.key) - } - - /// Helper function to get the API version. - /// - /// See also [Self::get_etherscan_config_with_chain] - pub fn get_etherscan_api_version(&self, chain: Option) -> EtherscanApiVersion { self.get_etherscan_config_with_chain(chain) + .map_err(|e| { + // `sh_warn!` is a circular dependency, preventing us from using it here. + eprintln!( + "{}: failed getting etherscan config: {e}", + yansi::Paint::yellow("Warning"), + ); + }) .ok() .flatten() - .map(|c| c.api_version) - .unwrap_or_default() + .map(|c| c.key) } /// Returns the remapping for the project's _src_ directory @@ -1553,7 +2081,11 @@ impl Config { debug_info: Vec::new(), }), model_checker, - via_ir: Some(self.via_ir), + // via_ssa_cfg implies via_ir only when via_ir is omitted, so we need to set both flags + // to true if via_ssa_cfg is enabled. + via_ir: Some(self.via_ir || self.via_ssa_cfg), + via_ssa_cfg: Some(self.via_ssa_cfg), + experimental: Some(self.experimental), // Not used. stop_after: None, // Set in project paths. @@ -1577,9 +2109,14 @@ impl Config { /// Returns the configured [VyperSettings] that includes: /// - evm version pub fn vyper_settings(&self) -> Result { + // Let `opt_level` override `optimize` so child profiles can switch away from an + // inherited optimization mode without sending both mutually exclusive settings to Vyper. + let optimize = if self.vyper.opt_level.is_some() { None } else { self.vyper.optimize }; + Ok(VyperSettings { evm_version: Some(self.evm_version), - optimize: self.vyper.optimize, + optimize, + opt_level: self.vyper.opt_level, bytecode_metadata: None, // TODO: We don't yet have a way to deserialize other outputs correctly, so request only // those for now. It should be enough to run tests and deploy contracts. @@ -1590,6 +2127,10 @@ impl Config { ]), search_paths: None, experimental_codegen: self.vyper.experimental_codegen, + debug: self.vyper.debug, + enable_decimals: self.vyper.enable_decimals, + venom_experimental: self.vyper.venom_experimental, + venom: self.vyper.venom.clone(), }) } @@ -1655,22 +2196,24 @@ impl Config { } fn _with_root(root: &Path) -> Self { - // autodetect paths - let paths = ProjectPathsConfig::builder().build_with_root::<()>(root); + // Autodetect the source, artifact and library directories from `root`. + let paths = ProjectPathsConfig::builder() + // The builder autodetects remappings too, which is a separate and far more expensive + // scan: it recursively walks every directory under every library path. Only the + // directories are read below, so opt out of it. + .remappings(Vec::new()) + .build_with_root::<()>(root); let artifacts: PathBuf = paths.artifacts.file_name().unwrap().into(); - Self { - root: paths.root, - src: paths.sources.file_name().unwrap().into(), - out: artifacts.clone(), - libs: paths.libraries.into_iter().map(|lib| lib.file_name().unwrap().into()).collect(), - remappings: paths - .remappings - .into_iter() - .map(|r| RelativeRemapping::new(r, root)) - .collect(), - fs_permissions: FsPermissions::new([PathPermission::read(artifacts)]), - ..Self::default() + let mut config = Self::default(); + if config.uses_default_src() { + config.src = paths.sources.file_name().unwrap().into(); } + config.root = paths.root; + config.out = artifacts.clone(); + config.libs = + paths.libraries.into_iter().map(|lib| lib.file_name().unwrap().into()).collect(); + config.fs_permissions = FsPermissions::new([PathPermission::read(artifacts)]); + config } /// Returns the default config but with hardhat paths @@ -1683,16 +2226,6 @@ impl Config { } } - /// Returns the default config that uses dapptools style paths - pub fn dapptools() -> Self { - Self { - chain: Some(Chain::from_id(99)), - block_timestamp: U256::ZERO, - block_number: U256::ZERO, - ..Self::default() - } - } - /// Extracts a basic subset of the config, used for initialisations. /// /// # Example @@ -1708,6 +2241,7 @@ impl Config { out: self.out, libs: self.libs, remappings: self.remappings, + network: self.networks.resolved_network().map(|network| network.name().to_string()), } } @@ -1822,8 +2356,26 @@ impl Config { } #[cfg(not(test))] { - static CACHE: std::sync::OnceLock = std::sync::OnceLock::new(); - CACHE.get_or_init(Self::force_selected_profile).clone() + SELECTED_PROFILE.get_or_init(Self::force_selected_profile).clone() + } + } + + /// Sets the selected profile before it is initialized. + /// + /// Returns the previously selected profile if it was already initialized to a different value. + pub fn try_set_selected_profile(profile: Profile) -> Result<(), Profile> { + #[cfg(test)] + { + let _ = profile; + Ok(()) + } + #[cfg(not(test))] + { + match SELECTED_PROFILE.set(profile) { + Ok(()) => Ok(()), + Err(profile) if SELECTED_PROFILE.get() == Some(&profile) => Ok(()), + Err(_) => Err(SELECTED_PROFILE.get().expect("profile initialized").clone()), + } } } @@ -1925,83 +2477,132 @@ impl Config { } /// Clears the foundry cache. - pub fn clean_foundry_cache() -> eyre::Result<()> { + /// + /// Returns warnings for any non-fatal deletion failures. + pub fn clean_foundry_cache() -> eyre::Result> { if let Some(cache_dir) = Self::foundry_cache_dir() { let path = cache_dir.as_path(); - let _ = fs::remove_dir_all(path); + if let Err(err) = fs::remove_dir_all(path) + && err.kind() != io::ErrorKind::NotFound + { + return Ok(vec![format!( + "failed to remove foundry cache at {}: {err}", + path.display() + )]); + } } else { eyre::bail!("failed to get foundry_cache_dir"); } - Ok(()) + Ok(vec![]) } /// Clears the foundry cache for `chain`. - pub fn clean_foundry_chain_cache(chain: Chain) -> eyre::Result<()> { + /// + /// Returns warnings for any non-fatal deletion failures. + pub fn clean_foundry_chain_cache(chain: Chain) -> eyre::Result> { if let Some(cache_dir) = Self::foundry_chain_cache_dir(chain) { let path = cache_dir.as_path(); - let _ = fs::remove_dir_all(path); + if let Err(err) = fs::remove_dir_all(path) + && err.kind() != io::ErrorKind::NotFound + { + return Ok(vec![format!( + "failed to remove foundry cache for chain {chain} at {}: {err}", + path.display() + )]); + } } else { eyre::bail!("failed to get foundry_chain_cache_dir"); } - Ok(()) + Ok(vec![]) } /// Clears the foundry cache for `chain` and `block`. - pub fn clean_foundry_block_cache(chain: Chain, block: u64) -> eyre::Result<()> { + /// + /// Returns warnings for any non-fatal deletion failures. + pub fn clean_foundry_block_cache(chain: Chain, block: u64) -> eyre::Result> { if let Some(cache_dir) = Self::foundry_block_cache_dir(chain, block) { let path = cache_dir.as_path(); - let _ = fs::remove_dir_all(path); + if let Err(err) = fs::remove_dir_all(path) + && err.kind() != io::ErrorKind::NotFound + { + return Ok(vec![format!( + "failed to remove foundry cache for chain {chain} block {block} at {}: {err}", + path.display() + )]); + } } else { eyre::bail!("failed to get foundry_block_cache_dir"); } - Ok(()) + Ok(vec![]) } /// Clears the foundry etherscan cache. - pub fn clean_foundry_etherscan_cache() -> eyre::Result<()> { + /// + /// Returns warnings for any non-fatal deletion failures. + pub fn clean_foundry_etherscan_cache() -> eyre::Result> { if let Some(cache_dir) = Self::foundry_etherscan_cache_dir() { let path = cache_dir.as_path(); - let _ = fs::remove_dir_all(path); + if let Err(err) = fs::remove_dir_all(path) + && err.kind() != io::ErrorKind::NotFound + { + return Ok(vec![format!( + "failed to remove foundry etherscan cache at {}: {err}", + path.display() + )]); + } } else { eyre::bail!("failed to get foundry_etherscan_cache_dir"); } - Ok(()) + Ok(vec![]) } /// Clears the foundry etherscan cache for `chain`. - pub fn clean_foundry_etherscan_chain_cache(chain: Chain) -> eyre::Result<()> { + /// + /// Returns warnings for any non-fatal deletion failures. + pub fn clean_foundry_etherscan_chain_cache(chain: Chain) -> eyre::Result> { if let Some(cache_dir) = Self::foundry_etherscan_chain_cache_dir(chain) { let path = cache_dir.as_path(); - let _ = fs::remove_dir_all(path); + if let Err(err) = fs::remove_dir_all(path) + && err.kind() != io::ErrorKind::NotFound + { + return Ok(vec![format!( + "failed to remove foundry etherscan cache for chain {chain} at {}: {err}", + path.display() + )]); + } } else { eyre::bail!("failed to get foundry_etherscan_cache_dir for chain: {}", chain); } - Ok(()) + Ok(vec![]) } /// List the data in the foundry cache. pub fn list_foundry_cache() -> eyre::Result { if let Some(cache_dir) = Self::foundry_rpc_cache_dir() { let mut cache = Cache { chains: vec![] }; - if !cache_dir.exists() { + let Some(entries) = Self::ignore_not_found(cache_dir.read_dir())? else { return Ok(cache); - } - if let Ok(entries) = cache_dir.as_path().read_dir() { - for entry in entries.flatten().filter(|x| x.path().is_dir()) { - match Chain::from_str(&entry.file_name().to_string_lossy()) { - Ok(chain) => cache.chains.push(Self::list_foundry_chain_cache(chain)?), - Err(_) => continue, - } + }; + for entry in entries { + let Some(entry) = Self::ignore_not_found(entry)? else { + continue; + }; + let Some(metadata) = Self::ignore_not_found(fs::metadata(entry.path()))? else { + continue; + }; + if !metadata.is_dir() { + continue; + } + if let Ok(chain) = Chain::from_str(&entry.file_name().to_string_lossy()) { + cache.chains.push(Self::list_foundry_chain_cache(chain)?); } - Ok(cache) - } else { - eyre::bail!("failed to access foundry_cache_dir"); } + Ok(cache) } else { eyre::bail!("failed to get foundry_cache_dir"); } @@ -2032,44 +2633,109 @@ impl Config { /// The path provided to this function should point to a cached chain folder. fn get_cached_blocks(chain_path: &Path) -> eyre::Result> { let mut blocks = vec![]; - if !chain_path.exists() { + let Some(entries) = Self::ignore_not_found(chain_path.read_dir())? else { return Ok(blocks); - } - for block in chain_path.read_dir()?.flatten() { - let file_type = block.file_type()?; - let file_name = block.file_name(); - let filepath = if file_type.is_dir() { - block.path().join("storage.json") - } else if file_type.is_file() - && file_name.to_string_lossy().chars().all(char::is_numeric) - { - block.path() - } else { + }; + for block in entries { + let Some(block) = Self::ignore_not_found(block)? else { continue; }; - blocks.push((file_name.to_string_lossy().into_owned(), fs::metadata(filepath)?.len())); + if let Some(block) = Self::get_cached_block(block)? { + blocks.push(block); + } } Ok(blocks) } + fn get_cached_block(block: fs::DirEntry) -> eyre::Result> { + let file_name = block.file_name(); + let Some(metadata) = Self::ignore_not_found(fs::symlink_metadata(block.path()))? else { + return Ok(None); + }; + let size = if metadata.is_dir() { + let Some(cache_files) = Self::ignore_not_found(block.path().read_dir())? else { + return Ok(None); + }; + let mut size = 0; + for cache_file in cache_files { + let Some(cache_file) = Self::ignore_not_found(cache_file)? else { + continue; + }; + size += Self::get_cache_file_size(cache_file)?.unwrap_or_default(); + } + if size == 0 { + return Ok(None); + } + size + } else if metadata.is_file() && file_name.to_string_lossy().chars().all(char::is_numeric) { + metadata.len() + } else { + return Ok(None); + }; + Ok(Some((file_name.to_string_lossy().into_owned(), size))) + } + + fn get_cache_file_size(cache_file: fs::DirEntry) -> eyre::Result> { + let Some(metadata) = Self::ignore_not_found(fs::symlink_metadata(cache_file.path()))? + else { + return Ok(None); + }; + let cache_file_name = cache_file.file_name(); + let cache_file_name = cache_file_name.to_string_lossy(); + if !metadata.is_file() + || (cache_file_name != "storage.json" + && !cache_file_name + .strip_prefix("storage-") + .and_then(|name| name.strip_suffix(".json")) + .is_some_and(|hash| { + hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit()) + })) + { + return Ok(None); + } + Ok(Some(metadata.len())) + } + /// The path provided to this function should point to the etherscan cache for a chain. fn get_cached_block_explorer_data(chain_path: &Path) -> eyre::Result { - if !chain_path.exists() { + let Some(entries) = Self::ignore_not_found(fs::read_dir(chain_path))? else { return Ok(0); + }; + Self::get_cached_dir_size(entries) + } + + fn get_cached_dir_size(entries: fs::ReadDir) -> eyre::Result { + let mut size = 0; + for entry in entries { + let Some(entry) = Self::ignore_not_found(entry)? else { + continue; + }; + size += Self::get_cached_entry_size(entry)?.unwrap_or_default(); } + Ok(size) + } - fn dir_size_recursive(mut dir: fs::ReadDir) -> eyre::Result { - dir.try_fold(0, |acc, file| { - let file = file?; - let size = match file.metadata()? { - data if data.is_dir() => dir_size_recursive(fs::read_dir(file.path())?)?, - data => data.len(), - }; - Ok(acc + size) - }) + fn get_cached_entry_size(entry: fs::DirEntry) -> eyre::Result> { + let Some(metadata) = Self::ignore_not_found(fs::symlink_metadata(entry.path()))? else { + return Ok(None); + }; + if metadata.is_dir() { + let Some(entries) = Self::ignore_not_found(fs::read_dir(entry.path()))? else { + return Ok(None); + }; + Ok(Some(Self::get_cached_dir_size(entries)?)) + } else { + Ok(Some(metadata.len())) } + } - dir_size_recursive(fs::read_dir(chain_path)?) + /// Treats cache entries removed during enumeration as absent. + fn ignore_not_found(result: io::Result) -> eyre::Result> { + match result { + Ok(value) => Ok(Some(value)), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(err.into()), + } } fn merge_toml_provider( @@ -2090,14 +2756,18 @@ impl Config { if profile != Self::DEFAULT_PROFILE { profiles.push(profile.clone()); } - let provider = toml_provider.strict_select(profiles); - - // apply any key fixes - let provider = &BackwardsCompatTomlProvider(ForcedSnakeCaseData(provider)); + // Apply key fixes before selecting profiles, while standalone sections and profile names + // are still distinguishable. + let provider = BackwardsCompatTomlProvider(ForcedSnakeCaseData(toml_provider)); + let provider = &provider.strict_select(profiles); + // Config files must not move the project root. Only profile merges drop `root`, since + // standalone sections may use it as a key, e.g. an `[rpc_endpoints]` alias. + let profile_provider = &IgnoreRootProvider(provider); // merge the default profile as a base if profile != Self::DEFAULT_PROFILE { - figment = figment.merge(provider.rename(Self::DEFAULT_PROFILE, profile.clone())); + figment = + figment.merge(profile_provider.rename(Self::DEFAULT_PROFILE, profile.clone())); } // merge special keys into config for standalone_key in Self::STANDALONE_SECTIONS { @@ -2114,7 +2784,7 @@ impl Config { } } // merge the profile - figment = figment.merge(provider); + figment = figment.merge(profile_provider); figment } @@ -2124,19 +2794,24 @@ impl Config { /// /// See also fn normalize_defaults(&self, mut figment: Figment) -> Figment { - // TODO: add a warning if evm_version is provided but incompatible if figment.contains("evm_version") { return figment; } - // Normalize `evm_version` based on the provided solc version. - if let Ok(solc) = figment.extract_inner::("solc") - && let Some(version) = solc - .try_version() - .ok() - .and_then(|version| self.evm_version.normalize_version_solc(&version)) + // Normalize `evm_version` based on the provided solc version. Local compiler paths are + // never executed while loading config. + if let Ok(SolcReq::Version(version)) = figment.extract_inner::("solc") + && let Some(version) = self.evm_version.normalize_version_solc(&version) + { + let profile = figment.profile().clone(); + figment = figment.merge(Serialized::default("evm_version", version).profile(profile)); + } + + // Normalize `deny` based on the provided `deny_warnings` value. + if figment.extract_inner::("deny_warnings").unwrap_or(false) + && figment.extract_inner("deny") == Ok(DenyLevel::Never) { - figment = figment.merge(("evm_version", version)); + figment = figment.merge(("deny", DenyLevel::Warnings)); } figment @@ -2194,6 +2869,10 @@ impl FigmentProviders { } } +fn figment_value_is_configured(figment: &Figment, key: &str) -> bool { + figment.find_metadata(key).is_some_and(|metadata| metadata.name.as_ref() != "Foundry Config") +} + /// Wrapper type for [`regex::Regex`] that implements [`PartialEq`] and [`serde`] traits. #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(transparent)] @@ -2309,8 +2988,17 @@ impl Provider for Config { #[track_caller] fn data(&self) -> Result, figment::Error> { let mut data = Serialized::defaults(self).data()?; + let root = Value::serialize(self.root.clone())?; + let labels = Value::serialize(&self.labels)?; + if let Some(entry) = data.get_mut(&Self::DEFAULT_PROFILE) { + entry.insert("root".to_string(), root.clone()); + entry.insert("labels".to_string(), labels.clone()); + normalize_legacy_labels_in_profile(entry); + } if let Some(entry) = data.get_mut(&self.profile) { - entry.insert("root".to_string(), Value::serialize(self.root.clone())?); + entry.insert("root".to_string(), root); + entry.insert("labels".to_string(), labels); + normalize_legacy_labels_in_profile(entry); } Ok(data) } @@ -2326,15 +3014,16 @@ impl Default for Config { profile: Self::DEFAULT_PROFILE, profiles: vec![Self::DEFAULT_PROFILE], fs_permissions: FsPermissions::new([PathPermission::read("out")]), - isolate: cfg!(feature = "isolate-by-default"), + isolate: true, root: root_default(), - src: "src".into(), + extends: None, + src: Self::DEFAULT_SRC.into(), test: "test".into(), script: "script".into(), out: "out".into(), libs: vec!["lib".into()], cache: true, - dynamic_test_linking: false, + dynamic_test_linking: true, cache_path: "cache".into(), broadcast: "broadcast".into(), snapshots: "snapshots".into(), @@ -2343,12 +3032,14 @@ impl Default for Config { allow_paths: vec![], include_paths: vec![], force: false, - evm_version: EvmVersion::Prague, + evm_version: EvmVersion::Osaka, + hardfork: None, gas_reports: vec!["*".to_string()], gas_reports_ignore: vec![], gas_reports_include_tests: false, solc: None, vyper: Default::default(), + external_compilers: Default::default(), auto_detect_solc: true, offline: false, optimizer: None, @@ -2367,38 +3058,48 @@ impl Default for Config { path_pattern_inverse: None, coverage_pattern_inverse: None, test_failures_file: "cache/test-failures".into(), + mutation_dir: "cache/mutation".into(), threads: None, show_progress: false, fuzz: FuzzConfig::new("cache/fuzz".into()), invariant: InvariantConfig::new("cache/invariant".into()), + symbolic: SymbolicConfig::default(), + coverage: CoverageConfig::default(), + mutation: MutationConfig::default(), + tracing: TracingConfig::default(), always_use_create_2_factory: false, + eip1559_fee_estimate: Eip1559FeeEstimatePreset::default(), ffi: false, + live_logs: false, allow_internal_expect_revert: false, prompt_timeout: 120, sender: Self::DEFAULT_SENDER, tx_origin: Self::DEFAULT_SENDER, initial_balance: U256::from((1u128 << 96) - 1), - block_number: U256::from(1), + block_number: U256::ONE, fork_block_number: None, + fork_state_by_number: false, chain: None, gas_limit: (1u64 << 30).into(), // ~1B code_size_limit: None, gas_price: None, block_base_fee_per_gas: 0, block_coinbase: Address::ZERO, - block_timestamp: U256::from(1), + block_timestamp: U256::ONE, block_difficulty: 0, block_prevrandao: Default::default(), block_gas_limit: None, disable_block_gas_limit: false, + enable_tx_gas_limit: false, memory_limit: 1 << 27, // 2**27 = 128MiB = 134_217_728 bytes eth_rpc_url: None, eth_rpc_accept_invalid_certs: false, + eth_rpc_no_proxy: false, eth_rpc_jwt: None, eth_rpc_timeout: None, eth_rpc_headers: None, + eth_rpc_curl: false, etherscan_api_key: None, - etherscan_api_version: None, verbosity: 0, remappings: vec![], auto_detect_remappings: true, @@ -2408,15 +3109,22 @@ impl Default for Config { SolidityErrorCode::ContractExceeds24576Bytes, SolidityErrorCode::ContractInitCodeSizeExceeds49152Bytes, SolidityErrorCode::TransientStorageUsed, + SolidityErrorCode::TransferDeprecated, + SolidityErrorCode::NatspecMemorySafeAssemblyDeprecated, ], + ignored_error_codes_from: vec![], ignored_file_paths: vec![], + deny: DenyLevel::Never, deny_warnings: false, via_ir: false, + via_ssa_cfg: false, + experimental: false, ast: false, rpc_storage_caching: Default::default(), rpc_endpoints: Default::default(), etherscan: Default::default(), no_storage_caching: false, + no_fork_bal: false, no_rpc_rate_limit: false, use_literal_content: false, bytecode_hash: BytecodeHash::Ipfs, @@ -2431,6 +3139,7 @@ impl Default for Config { bind_json: Default::default(), labels: Default::default(), unchecked_cheatcode_artifacts: false, + decode_external_storage: false, create2_library_salt: Self::DEFAULT_CREATE2_LIBRARY_SALT, create2_deployer: Self::DEFAULT_CREATE2_DEPLOYER, skip: vec![], @@ -2440,7 +3149,7 @@ impl Default for Config { legacy_assertions: false, warnings: vec![], extra_args: vec![], - odyssey: false, + networks: Default::default(), transaction_timeout: 120, additional_compiler_profiles: Default::default(), compilation_restrictions: Default::default(), @@ -2492,19 +3201,27 @@ pub enum SolcReq { /// Requires a specific solc version, that's either already installed (via `svm`) or will be /// auto installed (via `svm`) Version(Version), - /// Path to an existing local solc installation + /// Path to an existing local solc installation, or an executable name on `PATH`. Local(PathBuf), } +fn resolve_solc_path(solc: &Path) -> Result { + if solc.is_file() { + Ok(solc.to_path_buf()) + } else { + which::which(solc).map_err(|_| SolcError::msg(format!("`solc` {solc:?} does not exist"))) + } +} + impl SolcReq { /// Tries to get the solc version from the `SolcReq` /// /// If the `SolcReq` is a `Version` it will return the version, if it's a path to a binary it /// will try to get the version from the binary. - fn try_version(&self) -> Result { + pub fn try_version(&self) -> Result { match self { Self::Version(version) => Ok(version.clone()), - Self::Local(path) => Solc::new(path).map(|solc| solc.version), + Self::Local(path) => Solc::new(resolve_solc_path(path)?).map(|solc| solc.version), } } } @@ -2539,8 +3256,15 @@ pub struct BasicConfig { /// all library folders to include, `lib`, `node_modules` pub libs: Vec, /// `Remappings` to use for this repo - #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[serde( + default, + skip_serializing_if = "Vec::is_empty", + serialize_with = "remappings_serde::serialize" + )] pub remappings: Vec, + /// The explicitly selected network (e.g. `"ethereum"`, `"monad"`, or `"tempo"`). + #[serde(skip)] + pub network: Option, } impl BasicConfig { @@ -2548,17 +3272,80 @@ impl BasicConfig { /// /// This serializes to a table with the name of the profile pub fn to_string_pretty(&self) -> Result { - let s = toml::to_string_pretty(self)?; + let mut profile_body = toml::Value::try_from(self)?; + if let Some(ref network) = self.network + && let toml::Value::Table(ref mut table) = profile_body + { + table.insert("network".to_string(), toml::Value::String(network.clone())); + } + + let mut profile_section = toml::value::Table::new(); + profile_section.insert(self.profile.to_string(), profile_body); + + let mut document = toml::value::Table::new(); + document.insert("profile".to_string(), toml::Value::Table(profile_section)); + + if self.network.as_deref() == Some("tempo") { + let mut endpoints = toml::value::Table::new(); + endpoints.insert( + "tempo".to_string(), + toml::Value::String(crate::endpoints::TEMPO_RPC_URL.to_string()), + ); + endpoints.insert( + "moderato".to_string(), + toml::Value::String(crate::endpoints::MODERATO_RPC_URL.to_string()), + ); + document.insert("rpc_endpoints".to_string(), toml::Value::Table(endpoints)); + } + + let body = toml::to_string_pretty(&toml::Value::Table(document))?; Ok(format!( - "\ -[profile.{}] -{s} -# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options\n", - self.profile + "{body}\n# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options\n" )) } } +mod remappings_serde { + use foundry_compilers::artifacts::remappings::RelativeRemapping; + use serde::{Serialize, Serializer}; + + #[cfg(windows)] + use path_slash::PathExt as _; + #[cfg(windows)] + use std::path::Path; + + #[cfg(windows)] + fn slash_context(context: &str) -> String { + let has_trailing_separator = + context.as_bytes().last().is_some_and(|c| *c == b'/' || *c == b'\\'); + let mut context = Path::new(context).to_slash_lossy().into_owned(); + if has_trailing_separator && !context.ends_with('/') { + context.push('/'); + } + context + } + + pub fn serialize(remappings: &[RelativeRemapping], serializer: S) -> Result + where + S: Serializer, + { + remappings + .iter() + .map(|remapping| { + let Some(context) = &remapping.context else { return remapping.to_string() }; + let mut remapping = remapping.clone(); + remapping.context = None; + #[cfg(windows)] + let context = slash_context(context); + #[cfg(not(windows))] + let context = context.as_str(); + format!("{context}:{remapping}") + }) + .collect::>() + .serialize(serializer) + } +} + pub(crate) mod from_str_lowercase { use serde::{Deserialize, Deserializer, Serializer}; use std::str::FromStr; @@ -2603,19 +3390,112 @@ mod tests { use endpoints::{RpcAuth, RpcEndpointConfig}; use figment::error::Kind::InvalidType; use foundry_compilers::artifacts::{ - ModelCheckerEngine, YulDetails, vyper::VyperOptimizationMode, + ModelCheckerEngine, YulDetails, + vyper::{VyperOptimizationLevel, VyperOptimizationMode, VyperVenomSettings}, }; + use foundry_evm_hardforks::{TempoHardfork, latest_active_tempo_hardfork}; use similar_asserts::assert_eq; use soldeer_core::remappings::RemappingsLocation; - use std::{fs::File, io::Write}; + use std::{ + fs::File, + io::{self, Write}, + num::NonZeroUsize, + }; use tempfile::tempdir; + #[cfg(feature = "base")] + use foundry_evm_hardforks::BaseUpgrade; + // Helper function to clear `__warnings` in config, since it will be populated during loading // from file, causing testing problem when comparing to those created from `default()`, etc. fn clear_warning(config: &mut Config) { config.warnings = vec![]; } + fn mark_serialized_invariant_provenance(config: &mut Config) { + config.invariant.corpus_random_sequence_weight_configured = true; + config.invariant.workers_configured = true; + } + + #[test] + fn project_remappings_alias_relative_filesystem_contexts_in_place() { + let root = tempdir().unwrap(); + let project = root.path().join("project"); + fs::create_dir(&project).unwrap(); + let internal_dependency = project.join("dependency"); + fs::create_dir(&internal_dependency).unwrap(); + let dependency = root.path().join("dependency"); + let absolute_dependency = root.path().join("absolute-dependency"); + fs::create_dir(&dependency).unwrap(); + fs::create_dir(&absolute_dependency).unwrap(); + + let global_before = Remapping { + context: None, + name: "global-before/".into(), + path: "lib/global-before/".into(), + }; + let relative = Remapping { + context: Some(format!("../dependency{}", std::path::MAIN_SEPARATOR)), + name: "relative/".into(), + path: "lib/relative/".into(), + }; + let absolute = Remapping { + context: Some(format!( + "{}{}", + absolute_dependency.display(), + std::path::MAIN_SEPARATOR + )), + name: "absolute/".into(), + path: "lib/absolute/".into(), + }; + let missing = Remapping { + context: Some(format!("missing{}", std::path::MAIN_SEPARATOR)), + name: "missing/".into(), + path: "lib/missing/".into(), + }; + let global_after = Remapping { + context: None, + name: "global-after/".into(), + path: "lib/global-after/".into(), + }; + let internal = Remapping { + context: Some(format!("dependency{}", std::path::MAIN_SEPARATOR)), + name: "internal/".into(), + path: "lib/internal/".into(), + }; + let mut config = Config::with_root(&project); + config.remappings = [ + global_before.clone(), + internal.clone(), + relative.clone(), + absolute.clone(), + missing.clone(), + global_after.clone(), + ] + .map(Into::into) + .into(); + + let mut absolute_alias = relative.clone(); + let absolute_context = config.root.parent().unwrap().join("dependency"); + #[cfg(windows)] + let absolute_context = PathBuf::from_slash(absolute_context.to_string_lossy()); + let mut absolute_context = absolute_context.display().to_string(); + absolute_context.push(std::path::MAIN_SEPARATOR); + absolute_alias.context = Some(absolute_context); + assert_eq!( + config.project_remappings(), + vec![ + global_before, + internal, + relative, + absolute_alias, + absolute, + missing, + global_after + ] + ); + } + #[test] fn default_sender() { assert_eq!(Config::DEFAULT_SENDER, address!("0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38")); @@ -2635,6 +3515,90 @@ mod tests { assert!(!config.enable_caching(url, NamedChain::Dev)); } + #[test] + fn test_fork_state_by_number_config() { + figment::Jail::expect_with(|jail| { + assert!(!Config::load().unwrap().fork_state_by_number); + jail.create_file( + "foundry.toml", + r" + [profile.default] + fork_state_by_number = true + no_storage_caching = true + + [profile.ci] + fork_state_by_number = false + ", + )?; + let config = Config::load().unwrap(); + assert!(config.fork_state_by_number); + assert!(config.no_storage_caching); + + jail.set_env("FOUNDRY_PROFILE", "ci"); + let config = Config::load().unwrap(); + assert!(!config.fork_state_by_number); + assert!(config.no_storage_caching); + Ok(()) + }); + } + + #[test] + fn test_fork_state_by_number_environment() { + figment::Jail::expect_with(|jail| { + jail.create_file("foundry.toml", "[profile.default]\nfork_state_by_number = true\n")?; + jail.set_env("FOUNDRY_FORK_STATE_BY_NUMBER", "false"); + assert!(!Config::load().unwrap().fork_state_by_number); + jail.create_file("foundry.toml", "[profile.default]\nfork_state_by_number = false\n")?; + jail.set_env("FOUNDRY_FORK_STATE_BY_NUMBER", "true"); + assert!(Config::load().unwrap().fork_state_by_number); + jail.set_env("FOUNDRY_FORK_STATE_BY_NUMBER", "invalid"); + assert!(Config::load().is_err()); + Ok(()) + }); + } + + #[test] + fn test_fork_bal_config() { + figment::Jail::expect_with(|jail| { + assert!(!Config::load().unwrap().no_fork_bal); + jail.create_file( + "foundry.toml", + r" + [profile.default] + no_fork_bal = true + no_storage_caching = true + + [profile.ci] + no_fork_bal = false + ", + )?; + let config = Config::load().unwrap(); + assert!(config.no_fork_bal); + assert!(config.no_storage_caching); + + jail.set_env("FOUNDRY_PROFILE", "ci"); + let config = Config::load().unwrap(); + assert!(!config.no_fork_bal); + assert!(config.no_storage_caching); + Ok(()) + }); + } + + #[test] + fn test_fork_bal_environment() { + figment::Jail::expect_with(|jail| { + jail.create_file("foundry.toml", "[profile.default]\nno_fork_bal = true\n")?; + jail.set_env("FOUNDRY_NO_FORK_BAL", "false"); + assert!(!Config::load().unwrap().no_fork_bal); + jail.create_file("foundry.toml", "[profile.default]\nno_fork_bal = false\n")?; + jail.set_env("FOUNDRY_NO_FORK_BAL", "true"); + assert!(Config::load().unwrap().no_fork_bal); + jail.set_env("FOUNDRY_NO_FORK_BAL", "invalid"); + assert!(Config::load().is_err()); + Ok(()) + }); + } + #[test] fn test_install_dir() { figment::Jail::expect_with(|jail| { @@ -2768,6 +3732,29 @@ mod tests { }); } + #[test] + fn test_custom_src_skips_directory_auto_detection() { + figment::Jail::expect_with(|jail| { + fs::create_dir(jail.directory().join("contracts")).unwrap(); + + let config = Config { root: jail.directory().into(), ..Default::default() }; + let figment: Figment = config.into(); + let config = figment.extract::().unwrap(); + assert_eq!(config.src, PathBuf::from("contracts")); + + let config = Config { + root: jail.directory().into(), + src: "custom-src".into(), + ..Default::default() + }; + let figment: Figment = config.into(); + let config = figment.extract::().unwrap(); + assert_eq!(config.src, PathBuf::from("custom-src")); + + Ok(()) + }); + } + #[test] fn test_default_libs() { figment::Jail::expect_with(|jail| { @@ -3095,11 +4082,11 @@ mod tests { let config = Config::load().unwrap(); - assert!(config.etherscan.clone().resolved(EtherscanApiVersion::V2).has_unresolved()); + assert!(config.etherscan.clone().resolved().has_unresolved()); jail.set_env("_CONFIG_ETHERSCAN_MOONBEAM", "123456789"); - let configs = config.etherscan.resolved(EtherscanApiVersion::V2); + let configs = config.etherscan.resolved(); assert!(!configs.has_unresolved()); let mb_urls = Moonbeam.etherscan_urls().unwrap(); @@ -3113,7 +4100,6 @@ mod tests { api_url: mainnet_urls.0.to_string(), chain: Some(NamedChain::Mainnet.into()), browser_url: Some(mainnet_urls.1.to_string()), - api_version: EtherscanApiVersion::V2, key: "FX42Z3BBJJEWXWGYV2X1CIPRSCN".to_string(), } ), @@ -3123,7 +4109,6 @@ mod tests { api_url: mb_urls.0.to_string(), chain: Some(Moonbeam.into()), browser_url: Some(mb_urls.1.to_string()), - api_version: EtherscanApiVersion::V2, key: "123456789".to_string(), } ), @@ -3150,11 +4135,11 @@ mod tests { let config = Config::load().unwrap(); - assert!(config.etherscan.clone().resolved(EtherscanApiVersion::V2).has_unresolved()); + assert!(config.etherscan.clone().resolved().has_unresolved()); jail.set_env("_CONFIG_ETHERSCAN_MOONBEAM", "123456789"); - let configs = config.etherscan.resolved(EtherscanApiVersion::V2); + let configs = config.etherscan.resolved(); assert!(!configs.has_unresolved()); let mb_urls = Moonbeam.etherscan_urls().unwrap(); @@ -3168,7 +4153,6 @@ mod tests { api_url: mainnet_urls.0.to_string(), chain: Some(NamedChain::Mainnet.into()), browser_url: Some(mainnet_urls.1.to_string()), - api_version: EtherscanApiVersion::V2, key: "FX42Z3BBJJEWXWGYV2X1CIPRSCN".to_string(), } ), @@ -3178,7 +4162,6 @@ mod tests { api_url: mb_urls.0.to_string(), chain: Some(Moonbeam.into()), browser_url: Some(mb_urls.1.to_string()), - api_version: EtherscanApiVersion::V1, key: "123456789".to_string(), } ), @@ -3212,6 +4195,27 @@ mod tests { }); } + // any invalid entry invalidates whole [etherscan] sections + #[test] + fn test_resolve_etherscan_with_invalid_name() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r#" + [etherscan] + mainnet = { key = "FX42Z3BBJJEWXWGYV2X1CIPRSCN" } + an_invalid_name = { key = "FX42Z3BBJJEWXWGYV2X1CIPRSCN" } + "#, + )?; + + let config = Config::load().unwrap(); + let etherscan_config = config.get_etherscan_config(); + assert!(etherscan_config.is_none()); + + Ok(()) + }); + } + #[test] fn test_resolve_rpc_url() { figment::Jail::expect_with(|jail| { @@ -3270,19 +4274,19 @@ mod tests { r#" [profile.default] [rpc_endpoints] - polygonMumbai = "https://polygon-mumbai.g.alchemy.com/v2/${_RESOLVE_RPC_ALIAS}" + polygonAmoy = "https://polygon-amoy.g.alchemy.com/v2/${_RESOLVE_RPC_ALIAS}" "#, )?; let mut config = Config::load().unwrap(); - config.eth_rpc_url = Some("polygonMumbai".to_string()); + config.eth_rpc_url = Some("polygonAmoy".to_string()); assert!(config.get_rpc_url().unwrap().is_err()); jail.set_env("_RESOLVE_RPC_ALIAS", "123455"); let mut config = Config::load().unwrap(); - config.eth_rpc_url = Some("polygonMumbai".to_string()); + config.eth_rpc_url = Some("polygonAmoy".to_string()); assert_eq!( - "https://polygon-mumbai.g.alchemy.com/v2/123455", + "https://polygon-amoy.g.alchemy.com/v2/123455", config.get_rpc_url().unwrap().unwrap() ); @@ -3346,6 +4350,7 @@ mod tests { "mainnet", RpcEndpointType::Config(RpcEndpoint { endpoint: RpcEndpointUrl::Env("${_CONFIG_MAINNET}".to_string()), + extra_endpoints: vec![], config: RpcEndpointConfig { retries: Some(3), retry_backoff: Some(1000), @@ -3371,6 +4376,7 @@ mod tests { "mainnet", RpcEndpointType::Config(RpcEndpoint { endpoint: RpcEndpointUrl::Env("${_CONFIG_MAINNET}".to_string()), + extra_endpoints: vec![], config: RpcEndpointConfig { retries: Some(3), retry_backoff: Some(1000), @@ -3418,6 +4424,7 @@ mod tests { "mainnet", RpcEndpointType::Config(RpcEndpoint { endpoint: RpcEndpointUrl::Env("${_CONFIG_MAINNET}".to_string()), + extra_endpoints: vec![], config: RpcEndpointConfig { retries: Some(3), retry_backoff: Some(1000), @@ -3444,6 +4451,7 @@ mod tests { endpoint: RpcEndpointUrl::Url( "https://eth-mainnet.alchemyapi.io/v2/123455".to_string() ), + extra_endpoints: vec![], config: RpcEndpointConfig { retries: Some(3), retry_backoff: Some(1000), @@ -3532,7 +4540,7 @@ mod tests { [etherscan] optimism = { key = "https://etherscan-optimism.com/" } - mumbai = { key = "https://etherscan-mumbai.com/" } + amoy = { key = "https://etherscan-amoy.com/" } "#, )?; @@ -3541,10 +4549,10 @@ mod tests { let optimism = config.get_etherscan_api_key(Some(NamedChain::Optimism.into())); assert_eq!(optimism, Some("https://etherscan-optimism.com/".to_string())); - config.etherscan_api_key = Some("mumbai".to_string()); + config.etherscan_api_key = Some("amoy".to_string()); - let mumbai = config.get_etherscan_api_key(Some(NamedChain::PolygonMumbai.into())); - assert_eq!(mumbai, Some("https://etherscan-mumbai.com/".to_string())); + let amoy = config.get_etherscan_api_key(Some(NamedChain::PolygonAmoy.into())); + assert_eq!(amoy, Some("https://etherscan-amoy.com/".to_string())); Ok(()) }); @@ -3559,17 +4567,17 @@ mod tests { [profile.default] [etherscan] - mumbai = { key = "https://etherscan-mumbai.com/", chain = 80001 } + amoy = { key = "https://etherscan-amoy.com/", chain = 80002 } "#, )?; let config = Config::load().unwrap(); - let mumbai = config - .get_etherscan_config_with_chain(Some(NamedChain::PolygonMumbai.into())) + let amoy = config + .get_etherscan_config_with_chain(Some(NamedChain::PolygonAmoy.into())) .unwrap() .unwrap(); - assert_eq!(mumbai.key, "https://etherscan-mumbai.com/".to_string()); + assert_eq!(amoy.key, "https://etherscan-amoy.com/".to_string()); Ok(()) }); @@ -3584,18 +4592,18 @@ mod tests { [profile.default] [etherscan] - mumbai = { key = "https://etherscan-mumbai.com/", chain = 80001 , url = "https://verifier-url.com/"} + amoy = { key = "https://etherscan-amoy.com/", chain = 80002 , url = "https://verifier-url.com/"} "#, )?; let config = Config::load().unwrap(); - let mumbai = config - .get_etherscan_config_with_chain(Some(NamedChain::PolygonMumbai.into())) + let amoy = config + .get_etherscan_config_with_chain(Some(NamedChain::PolygonAmoy.into())) .unwrap() .unwrap(); - assert_eq!(mumbai.key, "https://etherscan-mumbai.com/".to_string()); - assert_eq!(mumbai.api_url, "https://verifier-url.com/".to_string()); + assert_eq!(amoy.key, "https://etherscan-amoy.com/".to_string()); + assert_eq!(amoy.api_url, "https://verifier-url.com/".to_string()); Ok(()) }); @@ -3608,23 +4616,23 @@ mod tests { "foundry.toml", r#" [profile.default] - eth_rpc_url = "mumbai" + eth_rpc_url = "amoy" [etherscan] - mumbai = { key = "https://etherscan-mumbai.com/" } + amoy = { key = "https://etherscan-amoy.com/" } [rpc_endpoints] - mumbai = "https://polygon-mumbai.g.alchemy.com/v2/mumbai" + amoy = "https://polygon-amoy.g.alchemy.com/v2/amoy" "#, )?; let config = Config::load().unwrap(); - let mumbai = config.get_etherscan_config_with_chain(None).unwrap().unwrap(); - assert_eq!(mumbai.key, "https://etherscan-mumbai.com/".to_string()); + let amoy = config.get_etherscan_config_with_chain(None).unwrap().unwrap(); + assert_eq!(amoy.key, "https://etherscan-amoy.com/".to_string()); - let mumbai_rpc = config.get_rpc_url().unwrap().unwrap(); - assert_eq!(mumbai_rpc, "https://polygon-mumbai.g.alchemy.com/v2/mumbai"); + let amoy_rpc = config.get_rpc_url().unwrap().unwrap(); + assert_eq!(amoy_rpc, "https://polygon-amoy.g.alchemy.com/v2/amoy"); Ok(()) }); } @@ -3745,6 +4753,7 @@ mod tests { block_difficulty = 0 block_prevrandao = '0x0000000000000000000000000000000000000000000000000000000000000000' block_number = 1 + fork_state_by_number = false block_timestamp = 1 use_literal_content = false bytecode_hash = 'ipfs' @@ -3762,13 +4771,14 @@ mod tests { gas_reports = ['*'] ignored_error_codes = [1878] ignored_warnings_from = ["something"] - deny_warnings = false + deny = "never" initial_balance = '0xffffffffffffffffffffffff' libraries = [] libs = ['lib'] memory_limit = 134217728 names = false no_storage_caching = false + no_fork_bal = false no_rpc_rate_limit = false offline = false optimizer = true @@ -3801,6 +4811,7 @@ mod tests { [invariant] runs = 256 depth = 500 + workers = 1 fail_on_revert = false call_override = false shrink_run_limit = 5000 @@ -3877,6 +4888,117 @@ mod tests { }); } + #[test] + fn test_solc_env_preserves_standalone_sections() { + for env in ["FOUNDRY_SOLC_VERSION", "DAPP_SOLC_VERSION"] { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r#" + [profile.default] + solc_version = "0.8.12" + + [profile.ci] + solc = "0.8.20" + + [etherscan] + mainnet = { key = "test-key" } + + [labels] + 0x0000000000000000000000000000000000000001 = "test-label" + + [rpc_endpoints] + mainnet = "https://example.com" + "#, + )?; + + for (profile, version) in [("default", 12), ("ci", 20)] { + jail.clear_env(); + jail.set_env("FOUNDRY_PROFILE", profile); + let config = Config::load().unwrap(); + assert_eq!(config.solc, Some(SolcReq::Version(Version::new(0, 8, version)))); + + jail.set_env(env, "0.6.6"); + let overridden = Config::load().unwrap(); + assert_eq!(overridden.solc, Some(SolcReq::Version(Version::new(0, 6, 6)))); + assert_eq!(overridden.rpc_endpoints, config.rpc_endpoints); + assert_eq!(overridden.etherscan, config.etherscan); + assert_eq!(overridden.labels, config.labels); + assert_eq!(overridden.tracing.labels, config.tracing.labels); + } + + jail.set_env("DAPP_SOLC_VERSION", "0.7.6"); + jail.set_env("FOUNDRY_SOLC_VERSION", "0.6.6"); + assert_eq!( + Config::load().unwrap().solc, + Some(SolcReq::Version(Version::new(0, 6, 6))), + ); + Ok(()) + }); + } + } + + #[test] + fn test_legacy_keys_in_standalone_named_profiles() { + for profile in ["coverage", "fuzz", "lint", "symbolic"] { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + &format!( + r#" + [profile.default] + solc = "0.8.12" + + [profile.{profile}] + solc_version = "0.8.20" + deny_warnings = true + labels = {{ "0x0000000000000000000000000000000000000001" = "test-label" }} + "#, + ), + )?; + jail.set_env("FOUNDRY_PROFILE", profile); + let config = Config::load().unwrap(); + assert_eq!(config.solc, Some(SolcReq::Version(Version::new(0, 8, 20)))); + assert_eq!(config.deny, DenyLevel::Warnings); + assert_eq!(config.tracing.labels, config.labels); + assert_eq!(config.labels.len(), 1); + + for env in ["FOUNDRY_SOLC_VERSION", "DAPP_SOLC_VERSION"] { + jail.set_env(env, "0.6.6"); + let overridden = Config::load().unwrap(); + assert_eq!(overridden.solc, Some(SolcReq::Version(Version::new(0, 6, 6)))); + jail.clear_env(); + jail.set_env("FOUNDRY_PROFILE", profile); + } + Ok(()) + }); + } + } + + #[test] + fn test_legacy_keys_preserve_rpc_aliases() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r#" + [rpc_endpoints] + solc_version = "https://compiler.example.com" + deny_warnings = "https://warnings.example.com" + "#, + )?; + + let config = Config::load().unwrap(); + assert_eq!( + config.rpc_endpoints, + RpcEndpoints::new([ + ("solc_version", RpcEndpointUrl::Url("https://compiler.example.com".into())), + ("deny_warnings", RpcEndpointUrl::Url("https://warnings.example.com".into())), + ]), + ); + Ok(()) + }); + } + // ensures the newer `solc` takes precedence over `solc_version` #[test] fn test_backwards_solc_version() { @@ -4042,6 +5164,7 @@ mod tests { out: "myout".into(), libs: default.libs.clone(), remappings: default.remappings.clone(), + network: None, } ); jail.set_env("FOUNDRY_PROFILE", r"other"); @@ -4054,6 +5177,7 @@ mod tests { out: "myout".into(), libs: default.libs.clone(), remappings: default.remappings, + network: None, } ); Ok(()) @@ -4141,29 +5265,46 @@ mod tests { figment::Jail::expect_with(|jail| { jail.create_file( "foundry.toml", - r" + r#" [fuzz] runs = 100 [invariant] runs = 120 + [symbolic] + enabled = true + max_paths = 12 + storage_layout = "generic" + [profile.ci.fuzz] runs = 420 [profile.ci.invariant] runs = 500 - ", + + [profile.ci.symbolic] + max_paths = 34 + dump_smt = true + "#, )?; let config = Config::load().unwrap(); assert_eq!(config.fuzz.runs, 100); assert_eq!(config.invariant.runs, 120); + assert!(config.symbolic.enabled); + assert_eq!(config.symbolic.max_paths, 12); + assert_eq!(config.symbolic.storage_layout, SymbolicStorageLayout::Generic); + assert!(!config.symbolic.dump_smt); jail.set_env("FOUNDRY_PROFILE", "ci"); let config = Config::load().unwrap(); assert_eq!(config.fuzz.runs, 420); assert_eq!(config.invariant.runs, 500); + assert!(config.symbolic.enabled); + assert_eq!(config.symbolic.max_paths, 34); + assert_eq!(config.symbolic.storage_layout, SymbolicStorageLayout::Generic); + assert!(config.symbolic.dump_smt); Ok(()) }); @@ -4243,6 +5384,39 @@ mod tests { }); } + #[test] + fn can_parse_libraries_with_whitespace() { + figment::Jail::expect_with(|jail| { + jail.set_env( + "FOUNDRY_LIBRARIES", + "[src/A.sol:A:0x1111111111111111111111111111111111111111, src/B.sol:B:0x2222222222222222222222222222222222222222]", + ); + let config = Config::load().unwrap(); + + similar_asserts::assert_eq!( + config.parsed_libraries().unwrap().libs, + BTreeMap::from([ + ( + PathBuf::from("src/A.sol"), + BTreeMap::from([( + "A".to_string(), + "0x1111111111111111111111111111111111111111".to_string(), + )]), + ), + ( + PathBuf::from("src/B.sol"), + BTreeMap::from([( + "B".to_string(), + "0x2222222222222222222222222222222222222222".to_string(), + )]), + ), + ]) + ); + + Ok(()) + }); + } + #[test] fn test_parse_many_libraries() { figment::Jail::expect_with(|jail| { @@ -4323,12 +5497,46 @@ mod tests { jail.create_file("foundry.toml", &default.to_string_pretty().unwrap())?; let mut other = Config::load().unwrap(); clear_warning(&mut other); - assert_eq!(default, other); + let mut serialized_default = default; + mark_serialized_invariant_provenance(&mut serialized_default); + assert_eq!(serialized_default, other); Ok(()) }); } + #[test] + fn config_serialization_preserves_context_directory_boundary() { + let remapping = "lib/outer/:inner/=lib/outer/lib/inner/"; + let config = Config { + remappings: vec![Remapping::from_str(remapping).unwrap().into()], + ..Default::default() + }; + + let serialized = toml::Value::try_from(&config).unwrap(); + assert_eq!(serialized["remappings"][0].as_str(), Some(remapping)); + + let serialized = toml::Value::try_from(config.into_basic()).unwrap(); + assert_eq!(serialized["remappings"][0].as_str(), Some(remapping)); + } + + #[cfg(windows)] + #[test] + fn config_serialization_preserves_verbatim_unc_context() { + let remapping = r"\\?\UNC\server\share\project\:inner/=lib/inner/"; + let expected = r"\\?\UNC\server\share/project/:inner/=lib/inner/"; + let config = Config { + remappings: vec![Remapping::from_str(remapping).unwrap().into()], + ..Default::default() + }; + + let serialized = toml::Value::try_from(&config).unwrap(); + assert_eq!(serialized["remappings"][0].as_str(), Some(expected)); + + let serialized = toml::Value::try_from(config.into_basic()).unwrap(); + assert_eq!(serialized["remappings"][0].as_str(), Some(expected)); + } + #[test] fn test_fs_permissions() { figment::Jail::expect_with(|jail| { @@ -4392,6 +5600,7 @@ mod tests { let s = loaded.to_string_pretty().unwrap(); jail.create_file("foundry.toml", &s)?; + mark_serialized_invariant_provenance(&mut loaded); let mut reloaded = Config::load().unwrap(); clear_warning(&mut reloaded); @@ -4442,6 +5651,7 @@ mod tests { let s = loaded.to_string_pretty().unwrap(); jail.create_file("foundry.toml", &s)?; + mark_serialized_invariant_provenance(&mut loaded); let mut reloaded = Config::load().unwrap(); clear_warning(&mut reloaded); @@ -4452,7 +5662,7 @@ mod tests { } #[test] - fn test_model_checker_settings_relative_paths() { + fn test_model_checker_settings_with_bool_flags() { figment::Jail::expect_with(|jail| { jail.create_file( "foundry.toml", @@ -4460,27 +5670,69 @@ mod tests { [profile.default] [profile.default.model_checker] - contracts = { 'a.sol' = [ 'A1', 'A2' ], 'b.sol' = [ 'B1', 'B2' ] } engine = 'chc' - targets = [ 'assert', 'outOfBounds' ] - timeout = 10000 + show_unproved = true + show_unsupported = true + show_proved_safe = false + div_mod_with_slacks = true ", )?; - let loaded = Config::load().unwrap().sanitized(); + let mut loaded = Config::load().unwrap(); + clear_warning(&mut loaded); - // NOTE(onbjerg): We have to canonicalize the path here using dunce because figment will - // canonicalize the jail path using the standard library. The standard library *always* - // transforms Windows paths to some weird extended format, which none of our code base - // does. - let dir = foundry_compilers::utils::canonicalize(jail.directory()) - .expect("Could not canonicalize jail path"); - assert_eq!( - loaded.model_checker, - Some(ModelCheckerSettings { - contracts: BTreeMap::from([ - ( - format!("{}", dir.join("a.sol").display()), - vec!["A1".to_string(), "A2".to_string()] + let mc = loaded.model_checker.as_ref().unwrap(); + assert_eq!(mc.show_unproved, Some(true)); + assert_eq!(mc.show_unsupported, Some(true)); + assert_eq!(mc.show_proved_safe, Some(false)); + assert_eq!(mc.div_mod_with_slacks, Some(true)); + + // Test round-trip: serialize and reload + let s = loaded.to_string_pretty().unwrap(); + jail.create_file("foundry.toml", &s)?; + + let mut reloaded = Config::load().unwrap(); + clear_warning(&mut reloaded); + + let mc_reloaded = reloaded.model_checker.as_ref().unwrap(); + assert_eq!(mc_reloaded.show_unproved, Some(true)); + assert_eq!(mc_reloaded.show_unsupported, Some(true)); + assert_eq!(mc_reloaded.show_proved_safe, Some(false)); + assert_eq!(mc_reloaded.div_mod_with_slacks, Some(true)); + + Ok(()) + }); + } + + #[test] + fn test_model_checker_settings_relative_paths() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r" + [profile.default] + + [profile.default.model_checker] + contracts = { 'a.sol' = [ 'A1', 'A2' ], 'b.sol' = [ 'B1', 'B2' ] } + engine = 'chc' + targets = [ 'assert', 'outOfBounds' ] + timeout = 10000 + ", + )?; + let loaded = Config::load().unwrap().sanitized(); + + // NOTE(onbjerg): We have to canonicalize the path here using dunce because figment will + // canonicalize the jail path using the standard library. The standard library *always* + // transforms Windows paths to some weird extended format, which none of our code base + // does. + let dir = foundry_compilers::utils::canonicalize(jail.directory()) + .expect("Could not canonicalize jail path"); + assert_eq!( + loaded.model_checker, + Some(ModelCheckerSettings { + contracts: BTreeMap::from([ + ( + format!("{}", dir.join("a.sol").display()), + vec!["A1".to_string(), "A2".to_string()] ), ( format!("{}", dir.join("b.sol").display()), @@ -4565,11 +5817,17 @@ mod tests { figment::Jail::expect_with(|jail| { jail.create_file( "foundry.toml", - r" + r#" [invariant] runs = 512 depth = 10 - ", + min_depth = 2 + depth_mode = "random" + workers = 4 + corpus_random_sequence_weight = 30 + payable_value_weight = 12 + mutation_weight_cmp = 7 + "#, )?; let loaded = Config::load().unwrap().sanitized(); @@ -4578,11 +5836,91 @@ mod tests { InvariantConfig { runs: 512, depth: 10, + min_depth: 2, + depth_mode: InvariantDepthMode::Random, + workers: InvariantWorkers::Fixed(NonZeroUsize::new(4).unwrap()), + corpus: FuzzCorpusConfig { + corpus_random_sequence_weight: 30, + payable_value_weight: 12, + mutation_weights: FuzzCorpusMutationWeights { + mutation_weight_cmp: 7, + ..Default::default() + }, + ..Default::default() + }, + corpus_random_sequence_weight_configured: true, + workers_configured: true, failure_persist_dir: Some(PathBuf::from("cache/invariant")), - corpus_dir: None, ..Default::default() } ); + assert!(loaded.invariant.corpus_random_sequence_weight_configured); + + Ok(()) + }); + } + + #[test] + fn test_invariant_corpus_random_sequence_weight_provenance() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r#" + [invariant] + depth = 10 + "#, + )?; + + let loaded = Config::load().unwrap(); + assert_eq!( + loaded.invariant.corpus.corpus_random_sequence_weight, + FuzzCorpusConfig::DEFAULT_CORPUS_RANDOM_SEQUENCE_WEIGHT + ); + assert!(!loaded.invariant.corpus_random_sequence_weight_configured); + assert!(!loaded.invariant.workers_configured); + + jail.create_file( + "foundry.toml", + r#" + [invariant] + corpus_random_sequence_weight = 10 + workers = 4 + "#, + )?; + + let loaded = Config::load().unwrap(); + assert_eq!( + loaded.invariant.corpus.corpus_random_sequence_weight, + FuzzCorpusConfig::DEFAULT_CORPUS_RANDOM_SEQUENCE_WEIGHT + ); + assert!(loaded.invariant.corpus_random_sequence_weight_configured); + assert_eq!( + loaded.invariant.workers, + InvariantWorkers::Fixed(NonZeroUsize::new(4).unwrap()) + ); + assert!(loaded.invariant.workers_configured); + + Ok(()) + }); + } + + #[test] + fn test_fuzz_corpus_random_sequence_weight_fallback_does_not_mark_invariant_configured() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r#" + [fuzz] + corpus_random_sequence_weight = 10 + "#, + )?; + + let loaded = Config::load().unwrap(); + assert_eq!( + loaded.invariant.corpus.corpus_random_sequence_weight, + FuzzCorpusConfig::DEFAULT_CORPUS_RANDOM_SEQUENCE_WEIGHT + ); + assert!(!loaded.invariant.corpus_random_sequence_weight_configured); Ok(()) }); @@ -4604,12 +5942,54 @@ mod tests { jail.set_env("FOUNDRY_FMT_LINE_LENGTH", "95"); jail.set_env("FOUNDRY_FUZZ_DICTIONARY_WEIGHT", "99"); + jail.set_env("FOUNDRY_FUZZ_MAX_FUZZ_DICTIONARY_VALUES", "max"); jail.set_env("FOUNDRY_INVARIANT_DEPTH", "5"); + jail.set_env("FOUNDRY_INVARIANT_MIN_DEPTH", "2"); + jail.set_env("FOUNDRY_INVARIANT_DEPTH_MODE", "random"); + jail.set_env("FOUNDRY_INVARIANT_WORKERS", "3"); + jail.set_env("FOUNDRY_INVARIANT_CORPUS_RANDOM_SEQUENCE_WEIGHT", "30"); + jail.set_env("FOUNDRY_INVARIANT_PAYABLE_VALUE_WEIGHT", "12"); + jail.set_env("FOUNDRY_INVARIANT_MUTATION_WEIGHT_CMP", "7"); + jail.set_env("FOUNDRY_SYMBOLIC_MAX_PATHS", "64"); + jail.set_env("FOUNDRY_SYMBOLIC_DUMP_SMT", "true"); let config = Config::load().unwrap(); assert_eq!(config.fmt.line_length, 95); assert_eq!(config.fuzz.dictionary.dictionary_weight, 99); + assert_eq!(config.fuzz.dictionary.max_fuzz_dictionary_values, usize::MAX); assert_eq!(config.invariant.depth, 5); + assert_eq!(config.invariant.min_depth, 2); + assert_eq!(config.invariant.depth_mode, InvariantDepthMode::Random); + assert_eq!( + config.invariant.workers, + InvariantWorkers::Fixed(NonZeroUsize::new(3).unwrap()) + ); + assert_eq!(config.invariant.corpus.corpus_random_sequence_weight, 30); + assert!(config.invariant.corpus_random_sequence_weight_configured); + assert_eq!(config.invariant.corpus.payable_value_weight, 12); + assert_eq!(config.invariant.corpus.mutation_weights.mutation_weight_cmp, 7); + assert_eq!(config.symbolic.max_paths, 64); + assert!(config.symbolic.dump_smt); + + Ok(()) + }); + } + + #[test] + fn test_invariant_workers_env_accepts_auto() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r" + [invariant] + workers = 3 + ", + )?; + + jail.set_env("FOUNDRY_INVARIANT_WORKERS", "auto"); + + let config = Config::load().unwrap(); + assert_eq!(config.invariant.workers, InvariantWorkers::Auto); Ok(()) }); @@ -4634,7 +6014,8 @@ mod tests { src: "src".into(), out: "out".into(), libs: vec!["lib".into()], - remappings: vec![] + remappings: vec![], + network: None, } ) ); @@ -4667,122 +6048,385 @@ mod tests { } #[test] - fn test_etherscan_api_key() { + fn hardfork_overrides_spec_id() { + let config = Config { + hardfork: Some(FoundryHardfork::Tempo(TempoHardfork::T3)), + ..Config::default() + }; + + assert_eq!(config.evm_spec_id::(), TempoHardfork::T3); + } + + #[test] + fn solc_settings_include_experimental_setting() { + let config = Config { experimental: true, ..Config::default() }; + let settings = config.solc_settings().unwrap(); + assert_eq!(settings.settings.experimental, Some(true)); + assert!(settings.cli_settings.extra_args.is_empty()); + + let config = Config { + experimental: false, + extra_args: vec!["--experimental".to_string()], + ..Config::default() + }; + let settings = config.solc_settings().unwrap(); + assert_eq!(settings.settings.experimental, Some(false)); + assert_eq!(settings.cli_settings.extra_args, vec!["--experimental".to_string()]); + } + + #[test] + fn solc_settings_include_via_ssa_cfg_setting() { + let config = Config { via_ssa_cfg: true, ..Config::default() }; + let settings = config.solc_settings().unwrap(); + assert_eq!(settings.settings.via_ssa_cfg, Some(true)); + assert!(settings.cli_settings.extra_args.is_empty()); + + let config = Config { + via_ssa_cfg: false, + extra_args: vec!["--via-ssa-cfg".to_string()], + ..Config::default() + }; + let settings = config.solc_settings().unwrap(); + assert_eq!(settings.settings.via_ssa_cfg, Some(false)); + assert_eq!(settings.cli_settings.extra_args, vec!["--via-ssa-cfg".to_string()]); + } + + #[test] + fn tempo_network_defaults_to_latest_tempo_hardfork() { figment::Jail::expect_with(|jail| { jail.create_file( "foundry.toml", - r" - [default] - ", + r#" + [profile.default] + network = "tempo" + "#, )?; - jail.set_env("ETHERSCAN_API_KEY", ""); - let loaded = Config::load().unwrap().sanitized(); - assert!(loaded.etherscan_api_key.is_none()); - jail.set_env("ETHERSCAN_API_KEY", "DUMMY"); - let loaded = Config::load().unwrap().sanitized(); - assert_eq!(loaded.etherscan_api_key, Some("DUMMY".into())); + let config = Config::load().unwrap(); + assert!(config.networks.is_tempo()); + assert_eq!(config.evm_spec_id::(), latest_active_tempo_hardfork()); Ok(()) }); } #[test] - fn test_etherscan_api_key_figment() { + fn tempo_hardfork_infers_tempo_network() { figment::Jail::expect_with(|jail| { jail.create_file( "foundry.toml", - r" - [default] - etherscan_api_key = 'DUMMY' - ", + r#" + [profile.default] + hardfork = "tempo:T3" + "#, )?; - jail.set_env("ETHERSCAN_API_KEY", "ETHER"); - - let figment = Config::figment_with_root(jail.directory()) - .merge(("etherscan_api_key", "USER_KEY")); - let loaded = Config::from_provider(figment).unwrap(); - assert_eq!(loaded.etherscan_api_key, Some("USER_KEY".into())); + let config = Config::load().unwrap(); + assert_eq!(config.hardfork, Some(FoundryHardfork::Tempo(TempoHardfork::T3))); + assert!(config.networks.is_tempo()); Ok(()) }); } + #[cfg(feature = "base")] #[test] - fn test_normalize_defaults() { + fn base_upgrade_infers_base_network() { figment::Jail::expect_with(|jail| { jail.create_file( "foundry.toml", - r" - [default] - solc = '0.8.13' - ", + r#" + [profile.default] + hardfork = "base:Beryl" + "#, )?; - let loaded = Config::load().unwrap().sanitized(); - assert_eq!(loaded.evm_version, EvmVersion::London); + let config = Config::load().unwrap(); + assert_eq!(config.hardfork, Some(FoundryHardfork::Base(BaseUpgrade::Beryl))); + assert!(config.networks.is_base()); + Ok(()) }); } - // a test to print the config, mainly used to update the example config in the README - #[expect(clippy::disallowed_macros)] #[test] - #[ignore] - fn print_config() { - let config = Config { - optimizer_details: Some(OptimizerDetails { - peephole: None, - inliner: None, - jumpdest_remover: None, - order_literals: None, - deduplicate: None, - cse: None, - constant_optimizer: Some(true), - yul: Some(true), - yul_details: Some(YulDetails { - stack_allocation: None, - optimizer_steps: Some("dhfoDgvulfnTUtnIf".to_string()), - }), - simple_counter_for_loop_unchecked_increment: None, - }), - ..Default::default() - }; - println!("{}", config.to_string_pretty().unwrap()); + #[cfg(feature = "monad")] + fn namespaced_hardfork_infers_monad_network() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r#" + [profile.default] + hardfork = "monad:MonadNine" + "#, + )?; + + let config = Config::load().unwrap(); + assert_eq!( + config.hardfork, + Some(FoundryHardfork::Monad(foundry_evm_hardforks::MonadHardfork::MonadNine)) + ); + assert_eq!( + config.evm_spec_id::(), + foundry_evm_hardforks::MonadHardfork::MonadNine + ); + assert_eq!( + config.hardfork.as_ref().and_then(FoundryHardfork::namespace), + Some("monad") + ); + assert_eq!( + config.hardfork.as_ref().map(FoundryHardfork::name).as_deref(), + Some("MonadNine") + ); + assert!(config.networks.is_monad()); + + Ok(()) + }); } #[test] - fn can_use_impl_figment_macro() { - #[derive(Default, Serialize)] - struct MyArgs { - #[serde(skip_serializing_if = "Option::is_none")] - root: Option, - } - impl_figment_convert!(MyArgs); - - impl Provider for MyArgs { - fn metadata(&self) -> Metadata { - Metadata::default() - } + #[cfg(feature = "monad")] + fn network_selectors_reject_profile_merged_hybrid() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r#" + [profile.default] + monad = true - fn data(&self) -> Result, Error> { - let value = Value::serialize(self)?; - let error = InvalidType(value.to_actual(), "map".into()); - let dict = value.into_dict().ok_or(error)?; - Ok(Map::from([(Config::selected_profile(), dict)])) - } - } + [profile.ci] + celo = true + "#, + )?; + jail.set_env("FOUNDRY_PROFILE", "ci"); - let _figment: Figment = From::from(&MyArgs::default()); + let err = Config::load().unwrap_err().to_string(); + assert!(err.contains( + "network selectors `celo = true` and `monad = true` conflict; select only one \ + network" + )); - #[derive(Default)] - struct Outer { - start: MyArgs, - other: MyArgs, - another: MyArgs, - } - impl_figment_convert!(Outer, start, other, another); + Ok(()) + }); + } + + #[test] + #[cfg(feature = "monad")] + fn network_selectors_reject_canonical_environment_conflict() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r#" + [profile.default] + tempo = true + "#, + )?; + jail.set_env("FOUNDRY_NETWORK", "monad"); + + let err = Config::load().unwrap_err().to_string(); + assert!(err.contains( + "network selectors `network = \"monad\"` and `tempo = true` conflict; select only \ + one network" + )); + + Ok(()) + }); + } + + #[test] + #[cfg(feature = "monad")] + fn matching_canonical_and_legacy_network_selectors_remain_valid() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r#" + [profile.default] + network = "monad" + monad = true + "#, + )?; + + let config = Config::load().unwrap(); + assert!(config.networks.is_monad()); + assert_eq!( + config.networks.resolved_network(), + Some(foundry_evm_networks::NetworkVariant::Monad) + ); + + Ok(()) + }); + } + + #[test] + fn celo_network_accepts_ethereum_hardfork() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r#" + [profile.default] + celo = true + hardfork = "prague" + "#, + )?; + + let config = Config::load().unwrap(); + assert!(config.networks.is_celo()); + assert_eq!( + config.hardfork, + Some(FoundryHardfork::Ethereum(foundry_evm_hardforks::EthereumHardfork::Prague)) + ); + + Ok(()) + }); + } + + #[test] + fn hardfork_rejects_conflicting_network() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r#" + [profile.default] + tempo = true + hardfork = "shanghai" + "#, + )?; + + let err = Config::load().unwrap_err(); + assert!( + err.to_string() + .to_lowercase() + .contains("hardfork `shanghai` conflicts with network config `tempo`") + ); + + Ok(()) + }); + } + + #[test] + fn test_etherscan_api_key() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r" + [default] + ", + )?; + jail.set_env("ETHERSCAN_API_KEY", ""); + let loaded = Config::load().unwrap().sanitized(); + assert!(loaded.etherscan_api_key.is_none()); + + jail.set_env("ETHERSCAN_API_KEY", "DUMMY"); + let loaded = Config::load().unwrap().sanitized(); + assert_eq!(loaded.etherscan_api_key, Some("DUMMY".into())); + + Ok(()) + }); + } + + #[test] + fn test_etherscan_api_key_figment() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r" + [default] + etherscan_api_key = 'DUMMY' + ", + )?; + jail.set_env("ETHERSCAN_API_KEY", "ETHER"); + + let figment = Config::figment_with_root(jail.directory()) + .merge(("etherscan_api_key", "USER_KEY")); + + let loaded = Config::from_provider(figment).unwrap(); + assert_eq!(loaded.etherscan_api_key, Some("USER_KEY".into())); + + Ok(()) + }); + } + + #[test] + fn test_normalize_defaults() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r" + [default] + solc = '0.8.13' + ", + )?; + + let loaded = Config::load().unwrap().sanitized(); + assert_eq!(loaded.evm_version, EvmVersion::London); + + let figment = Config::figment_with_root(jail.directory()).merge( + Serialized::default("evm_version", EvmVersion::Amsterdam) + .profile(Config::selected_profile()), + ); + let loaded = Config::from_provider(figment).unwrap().sanitized(); + assert_eq!(loaded.evm_version, EvmVersion::Amsterdam); + Ok(()) + }); + } + + // a test to print the config, mainly used to update the example config in the README + #[expect(clippy::disallowed_macros)] + #[test] + #[ignore] + fn print_config() { + let config = Config { + optimizer_details: Some(OptimizerDetails { + peephole: None, + inliner: None, + jumpdest_remover: None, + order_literals: None, + deduplicate: None, + cse: None, + constant_optimizer: Some(true), + yul: Some(true), + yul_details: Some(YulDetails { + stack_allocation: None, + optimizer_steps: Some("dhfoDgvulfnTUtnIf".to_string()), + }), + simple_counter_for_loop_unchecked_increment: None, + }), + ..Default::default() + }; + println!("{}", config.to_string_pretty().unwrap()); + } + + #[test] + fn can_use_impl_figment_macro() { + #[derive(Default, Serialize)] + struct MyArgs { + #[serde(skip_serializing_if = "Option::is_none")] + root: Option, + } + impl_figment_convert!(MyArgs); + + impl Provider for MyArgs { + fn metadata(&self) -> Metadata { + Metadata::default() + } + + fn data(&self) -> Result, Error> { + let value = Value::serialize(self)?; + let error = InvalidType(value.to_actual(), "map".into()); + let dict = value.into_dict().ok_or(error)?; + Ok(Map::from([(Config::selected_profile(), dict)])) + } + } + + let _figment: Figment = From::from(&MyArgs::default()); + + #[derive(Default)] + struct Outer { + start: MyArgs, + other: MyArgs, + another: MyArgs, + } + impl_figment_convert!(Outer, start, other, another); let _figment: Figment = From::from(&Outer::default()); } @@ -4797,6 +6441,18 @@ mod tests { writeln!(file, "{}", vec![' '; size_bytes - 1].iter().collect::()).unwrap(); } + fn fake_endpoint_block_cache( + chain_path: &Path, + block_number: &str, + endpoint: &str, + size_bytes: usize, + ) { + let block_path = chain_path.join(block_number); + let file_path = block_path.join(format!("storage-{endpoint}.json")); + let mut file = File::create(file_path).unwrap(); + writeln!(file, "{}", vec![' '; size_bytes - 1].iter().collect::()).unwrap(); + } + fn fake_block_cache_block_path_as_file( chain_path: &Path, block_number: &str, @@ -4810,6 +6466,13 @@ mod tests { let chain_dir = tempdir()?; fake_block_cache(chain_dir.path(), "1", 100); + fake_endpoint_block_cache( + chain_dir.path(), + "1", + "0000000000000000000000000000000000000000000000000000000000000000", + 50, + ); + fake_endpoint_block_cache(chain_dir.path(), "1", "backup", 75); fake_block_cache(chain_dir.path(), "2", 500); fake_block_cache_block_path_as_file(chain_dir.path(), "3", 900); // Pollution file that should not show up in the cached block @@ -4824,7 +6487,7 @@ mod tests { let block3 = &result.iter().find(|x| x.0 == "3").unwrap(); assert_eq!(block1.0, "1"); - assert_eq!(block1.1, 100); + assert_eq!(block1.1, 150); assert_eq!(block2.0, "2"); assert_eq!(block2.1, 500); assert_eq!(block3.0, "3"); @@ -4834,6 +6497,100 @@ mod tests { Ok(()) } + #[test] + fn list_cached_blocks_ignores_removed_entries() -> eyre::Result<()> { + let chain_dir = tempdir()?; + let block_path = chain_dir.path().join("1"); + fs::create_dir(&block_path)?; + File::create(block_path.join("storage.json"))?; + + let block = fs::read_dir(chain_dir.path())?.next().unwrap()?; + let cache_file = fs::read_dir(&block_path)?.next().unwrap()?; + fs::remove_dir_all(block_path)?; + + assert!(Config::get_cached_block(block)?.is_none()); + assert!(Config::get_cache_file_size(cache_file)?.is_none()); + Ok(()) + } + + #[test] + fn ignore_not_found_propagates_other_errors() { + assert!( + Config::ignore_not_found::<()>(Err(io::Error::from(io::ErrorKind::NotFound))) + .unwrap() + .is_none() + ); + + let err = + Config::ignore_not_found::<()>(Err(io::Error::from(io::ErrorKind::PermissionDenied))) + .unwrap_err(); + assert_eq!( + err.downcast_ref::().unwrap().kind(), + io::ErrorKind::PermissionDenied + ); + } + + #[test] + fn cache_listing_propagates_not_a_directory() -> eyre::Result<()> { + let cache_file = tempfile::NamedTempFile::new()?; + + let err = Config::get_cached_blocks(cache_file.path()).unwrap_err(); + assert_eq!(err.downcast_ref::().unwrap().kind(), io::ErrorKind::NotADirectory); + + let err = Config::get_cached_block_explorer_data(cache_file.path()).unwrap_err(); + assert_eq!(err.downcast_ref::().unwrap().kind(), io::ErrorKind::NotADirectory); + Ok(()) + } + + #[test] + fn list_cached_blocks_uses_replaced_entry_type() -> eyre::Result<()> { + let chain_dir = tempdir()?; + let block_path = chain_dir.path().join("1"); + File::create(&block_path)?; + let block = fs::read_dir(chain_dir.path())?.next().unwrap()?; + + fs::remove_file(&block_path)?; + fs::create_dir(&block_path)?; + fs::write(block_path.join("storage.json"), [0; 10])?; + + assert_eq!(Config::get_cached_block(block)?, Some(("1".to_string(), 10))); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn list_cached_blocks_ignores_symlinks() -> eyre::Result<()> { + let chain_dir = tempdir()?; + let target_dir = tempdir()?; + fs::write(target_dir.path().join("storage.json"), [0; 10])?; + std::os::unix::fs::symlink(target_dir.path(), chain_dir.path().join("1"))?; + + let block_path = chain_dir.path().join("2"); + fs::create_dir(&block_path)?; + let target_file = tempfile::NamedTempFile::new()?; + fs::write(target_file.path(), [0; 10])?; + std::os::unix::fs::symlink(target_file.path(), block_path.join("storage.json"))?; + + assert!(Config::get_cached_blocks(chain_dir.path())?.is_empty()); + Ok(()) + } + + #[test] + fn list_etherscan_cache_ignores_removed_entries() -> eyre::Result<()> { + let cache_dir = tempdir()?; + let sources_path = cache_dir.path().join("sources"); + fs::create_dir(&sources_path)?; + File::create(sources_path.join("metadata.json"))?; + + let sources = fs::read_dir(cache_dir.path())?.next().unwrap()?; + let metadata = fs::read_dir(&sources_path)?.next().unwrap()?; + fs::remove_dir_all(sources_path)?; + + assert!(Config::get_cached_entry_size(sources)?.is_none()); + assert!(Config::get_cached_entry_size(metadata)?.is_none()); + Ok(()) + } + #[test] fn list_etherscan_cache() -> eyre::Result<()> { fn fake_etherscan_cache(chain_path: &Path, address: &str, size_bytes: usize) { @@ -4953,32 +6710,41 @@ mod tests { ), ]) ); + assert_eq!(config.tracing.labels, config.labels); + assert_eq!( + config.warnings, + vec![Warning::DeprecatedKey { + old: "[labels]".to_string(), + new: "[tracing.labels]".to_string(), + }] + ); Ok(()) }); } #[test] - fn test_parse_vyper() { + fn test_parse_deprecated_profile_labels() { figment::Jail::expect_with(|jail| { jail.create_file( "foundry.toml", r#" - [vyper] - optimize = "codesize" - path = "/path/to/vyper" - experimental_codegen = true + [profile.default.labels] + 0x0000000000000000000000000000000000000001 = "Alice" "#, )?; let config = Config::load().unwrap(); + let labels = + AddressHashMap::from_iter(vec![(Address::with_last_byte(1), "Alice".to_string())]); + assert_eq!(config.labels, labels); + assert_eq!(config.tracing.labels, labels); assert_eq!( - config.vyper, - VyperConfig { - optimize: Some(VyperOptimizationMode::Codesize), - path: Some("/path/to/vyper".into()), - experimental_codegen: Some(true), - } + config.warnings, + vec![Warning::DeprecatedKey { + old: "labels".to_string(), + new: "tracing.labels".to_string(), + }] ); Ok(()) @@ -4986,16 +6752,364 @@ mod tests { } #[test] - fn test_parse_soldeer() { + fn test_deprecated_env_labels_use_tracing_section() { figment::Jail::expect_with(|jail| { - jail.create_file( - "foundry.toml", - r#" - [soldeer] - remappings_generate = true - remappings_regenerate = false - remappings_version = true - remappings_prefix = "@" + jail.set_env( + "FOUNDRY_LABELS", + r#"{ "0x0000000000000000000000000000000000000001" = "Alice" }"#, + ); + jail.set_env( + "FOUNDRY_TRACING_LABELS", + r#"{ "0x0000000000000000000000000000000000000001" = "Bob" }"#, + ); + + let config = Config::load().unwrap(); + assert_eq!( + config.labels, + AddressHashMap::from_iter([(Address::with_last_byte(1), "Alice".to_string(),)]) + ); + assert_eq!( + config.tracing.labels, + AddressHashMap::from_iter([(Address::with_last_byte(1), "Bob".to_string(),)]) + ); + + Ok(()) + }); + } + + #[test] + fn test_deprecated_labels_warn_for_inactive_profiles() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r#" + [profile.ci.labels] + 0x0000000000000000000000000000000000000001 = "Alice" + "#, + )?; + + let config = Config::load().unwrap(); + assert_eq!( + config.warnings, + vec![Warning::DeprecatedKey { + old: "labels".to_string(), + new: "tracing.labels".to_string(), + }] + ); + + Ok(()) + }); + } + + #[test] + fn test_tracing_serialization_keeps_global_verbosity() { + let address = Address::with_last_byte(1); + let labels = AddressHashMap::from_iter([(address, "Alice".to_string())]); + let config = Config { + tracing: TracingConfig { verbosity: 4, labels: labels.clone(), ..Default::default() }, + ..Default::default() + }; + + let serialized = toml::Value::try_from(&config).unwrap(); + let table = serialized.as_table().unwrap(); + assert_eq!(table["verbosity"].as_integer(), Some(0)); + assert!(!table.contains_key("labels")); + assert_eq!(table["tracing"]["verbosity"].as_integer(), Some(4)); + + let provided = Figment::from(&config).extract::().unwrap(); + assert_eq!(provided.verbosity, 0); + assert!(provided.labels.is_empty()); + assert_eq!(provided.tracing.labels, labels); + + let merged = config.merge_inline_provider(("ffi", true)).unwrap(); + assert_eq!(merged.tracing.verbosity, 4); + assert_eq!(merged.tracing.labels, config.tracing.labels); + } + + #[test] + fn test_legacy_programmatic_labels_survive_serialization() { + let address = Address::with_last_byte(1); + let labels = AddressHashMap::from_iter([(address, "Alice".to_string())]); + let config = Config { labels: labels.clone(), ..Default::default() }; + + let serialized = toml::Value::try_from(&config).unwrap(); + assert_eq!( + serialized["labels"].as_table().unwrap().values().next().and_then(|v| v.as_str()), + Some("Alice") + ); + + let provided = Config::from_provider(&config).unwrap(); + assert_eq!(provided.labels, labels); + assert_eq!(provided.tracing.labels, labels); + + let merged = config.merge_inline_provider(("ffi", true)).unwrap(); + assert_eq!(merged.labels, labels); + assert_eq!(merged.tracing.labels, labels); + } + + #[test] + fn test_parse_tracing_section() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r#" + [profile.default] + verbosity = 2 + + [tracing] + verbosity = 4 + disable_labels = true + compact_labels = true + trace_depth = 3 + decode_internal = true + external_identification_timeout = 9 + + [tracing.labels] + 0x0000000000000000000000000000000000000002 = "Bob" + "#, + )?; + + let config = Config::load().unwrap(); + assert_eq!(config.verbosity, 2); + assert_eq!(config.tracing.verbosity, 4); + assert!(config.tracing.disable_labels); + assert_eq!(config.tracing.trace_depth, Some(3)); + assert!(config.tracing.decode_internal); + assert!(config.tracing.compact_labels); + assert_eq!(config.tracing.external_identification_timeout, 9); + let labels = + AddressHashMap::from_iter(vec![(Address::with_last_byte(2), "Bob".to_string())]); + assert!(config.labels.is_empty()); + assert_eq!(config.tracing.labels, labels); + assert!(config.warnings.is_empty()); + + let serialized = config.to_string_pretty().unwrap(); + assert!(!serialized.contains("[labels]")); + assert!(serialized.contains("[tracing.labels]")); + + jail.create_file("foundry.toml", &serialized)?; + let reloaded = Config::load().unwrap(); + assert!(reloaded.labels.is_empty()); + assert_eq!(reloaded.tracing.labels, labels); + assert!(reloaded.warnings.is_empty()); + + Ok(()) + }); + } + + #[test] + fn test_external_identification_timeout_env() { + figment::Jail::expect_with(|jail| { + jail.set_env("FOUNDRY_TRACING_EXTERNAL_IDENTIFICATION_TIMEOUT", "0"); + + let config = Config::load().unwrap(); + + assert_eq!(config.tracing.external_identification_timeout, 0); + Ok(()) + }); + } + + #[test] + fn test_global_and_tracing_verbosity_are_independent() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r#" + [profile.default] + verbosity = 4 + + [tracing] + disable_labels = true + "#, + )?; + + let config = Config::load().unwrap(); + assert_eq!(config.verbosity, 4); + assert_eq!(config.tracing.verbosity, 0); + assert!(config.tracing.disable_labels); + assert_eq!(config.tracing.external_identification_timeout, 5); + + Ok(()) + }); + } + + #[test] + fn test_label_aliases_preserve_provider_precedence() { + let address = Address::with_last_byte(1); + let labels = |label: &str| AddressHashMap::from_iter([(address, label.to_string())]); + let provider = |global: &str, local: &str| { + let figment = Config::merge_toml_provider( + Figment::from(Config::default()), + Toml::string(global).nested(), + Config::DEFAULT_PROFILE, + ); + Config::merge_toml_provider( + figment, + Toml::string(local).nested(), + Config::DEFAULT_PROFILE, + ) + }; + + let config = Config::from_provider(provider( + r#"[tracing.labels] + 0x0000000000000000000000000000000000000001 = "global""#, + r#"[labels] + 0x0000000000000000000000000000000000000001 = "local""#, + )) + .unwrap(); + assert_eq!(config.tracing.labels, labels("local")); + + let config = Config::from_provider(provider( + r#"[labels] + 0x0000000000000000000000000000000000000001 = "global""#, + r#"[tracing.labels] + 0x0000000000000000000000000000000000000001 = "local""#, + )) + .unwrap(); + assert_eq!(config.tracing.labels, labels("local")); + } + + #[test] + fn test_malformed_tracing_labels_are_not_replaced() { + let provider = Toml::string( + r#" + [profile.default.labels] + 0x0000000000000000000000000000000000000001 = "legacy" + + [profile.default.tracing] + labels = "invalid" + "#, + ) + .nested(); + + assert!(Config::from_provider(provider).is_err()); + } + + #[test] + fn test_parse_vyper() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r#" + [vyper] + optimize = "O1" + path = "/path/to/vyper" + experimental_codegen = true + venom_experimental = false + debug = true + enable_decimals = true + + [vyper.venom] + disable_inlining = true + disable_cse = true + disable_sccp = false + disable_load_elimination = true + disable_dead_store_elimination = false + disable_algebraic_optimization = true + disable_branch_optimization = false + disable_assert_elimination = true + disable_mem2var = false + disable_simplify_cfg = true + disable_remove_unused_variables = false + inline_threshold = 15 + "#, + )?; + + let config = Config::load().unwrap(); + assert_eq!( + config.vyper, + VyperConfig { + optimize: Some(VyperOptimizationMode::O1), + opt_level: None, + path: Some("/path/to/vyper".into()), + experimental_codegen: Some(true), + venom_experimental: Some(false), + debug: Some(true), + enable_decimals: Some(true), + venom: Some(VyperVenomSettings { + disable_inlining: Some(true), + disable_cse: Some(true), + disable_sccp: Some(false), + disable_load_elimination: Some(true), + disable_dead_store_elimination: Some(false), + disable_algebraic_optimization: Some(true), + disable_branch_optimization: Some(false), + disable_assert_elimination: Some(true), + disable_mem2var: Some(false), + disable_simplify_cfg: Some(true), + disable_remove_unused_variables: Some(false), + inline_threshold: Some(15), + }), + } + ); + + Ok(()) + }); + } + + #[test] + fn test_vyper_settings_include_extended_config() { + let config = Config { + vyper: VyperConfig { + opt_level: Some(VyperOptimizationLevel::O3), + experimental_codegen: Some(true), + venom_experimental: Some(false), + debug: Some(true), + enable_decimals: Some(true), + venom: Some(VyperVenomSettings { + disable_cse: Some(true), + inline_threshold: Some(15), + ..Default::default() + }), + ..Default::default() + }, + ..Config::default().normalized_optimizer_settings() + }; + + let settings = config.vyper_settings().unwrap(); + assert_eq!(settings.optimize, None); + assert_eq!(settings.opt_level, Some(VyperOptimizationLevel::O3)); + assert_eq!(settings.experimental_codegen, Some(true)); + assert_eq!(settings.venom_experimental, Some(false)); + assert_eq!(settings.debug, Some(true)); + assert_eq!(settings.enable_decimals, Some(true)); + assert_eq!( + settings.venom, + Some(VyperVenomSettings { + disable_cse: Some(true), + inline_threshold: Some(15), + ..Default::default() + }) + ); + } + + #[test] + fn vyper_opt_level_overrides_optimize() { + let config = Config { + vyper: VyperConfig { + optimize: Some(VyperOptimizationMode::Gas), + opt_level: Some(VyperOptimizationLevel::O3), + ..Default::default() + }, + ..Config::default().normalized_optimizer_settings() + }; + + let settings = config.vyper_settings().unwrap(); + assert_eq!(settings.optimize, None); + assert_eq!(settings.opt_level, Some(VyperOptimizationLevel::O3)); + } + + #[test] + fn test_parse_soldeer() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r#" + [soldeer] + remappings_generate = true + remappings_regenerate = false + remappings_version = true + remappings_prefix = "@" remappings_location = "txt" recursive_deps = true "#, @@ -5081,4 +7195,2455 @@ mod tests { .unwrap(); assert_eq!(endpoint.url, "https://rpc.sophon.xyz"); } + + #[test] + fn test_get_etherscan_config_with_unknown_chain() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r#" + [etherscan] + mainnet = { chain = 3658348, key = "api-key"} + "#, + )?; + let config = Config::load().unwrap(); + let unknown_chain = Chain::from_id(3658348); + let result = config.get_etherscan_config_with_chain(Some(unknown_chain)); + assert!(result.is_err()); + let error_msg = result.unwrap_err().to_string(); + assert!(error_msg.contains("No known Etherscan API URL for chain `3658348`")); + assert!(error_msg.contains("Specify a `url`")); + assert!(error_msg.contains("Verify the chain `3658348` is correct")); + + Ok(()) + }); + } + + #[test] + fn test_get_etherscan_config_with_existing_chain_and_url() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r#" + [etherscan] + mainnet = { chain = 1, key = "api-key" } + "#, + )?; + let config = Config::load().unwrap(); + let unknown_chain = Chain::from_id(1); + let result = config.get_etherscan_config_with_chain(Some(unknown_chain)); + assert!(result.is_ok()); + Ok(()) + }); + } + + #[test] + fn test_can_inherit_a_base_toml() { + figment::Jail::expect_with(|jail| { + // Create base config file with optimizer_runs = 800 + jail.create_file( + "base-config.toml", + r#" + [profile.default] + optimizer_runs = 800 + + [invariant] + runs = 1000 + + [rpc_endpoints] + mainnet = "https://example.com" + optimism = "https://example-2.com/" + "#, + )?; + + // Create local config that inherits from base-config.toml + jail.create_file( + "foundry.toml", + r#" + [profile.default] + extends = "base-config.toml" + + [invariant] + runs = 333 + depth = 15 + + [rpc_endpoints] + mainnet = "https://test.xyz/rpc" + "#, + )?; + + let config = Config::load().unwrap(); + assert_eq!(config.extends, Some(Extends::Path("base-config.toml".to_string()))); + + // optimizer_runs should be inherited from base-config.toml + assert_eq!(config.optimizer_runs, Some(800)); + + // invariant settings should be overridden by local config + assert_eq!(config.invariant.runs, 333); + assert_eq!(config.invariant.depth, 15); + + // rpc_endpoints.mainnet should be overridden by local config + // optimism should be inherited from base config + let endpoints = config.rpc_endpoints.resolved(); + assert!( + endpoints.get("mainnet").unwrap().url().unwrap().contains("https://test.xyz/rpc") + ); + assert!(endpoints.get("optimism").unwrap().url().unwrap().contains("example-2.com")); + + Ok(()) + }); + } + + #[test] + fn inherited_symbolic_sections_preserve_source_precedence() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "base.toml", + r#" + [profile.default.symbolic] + max_paths = 10 + depth = 100 + "#, + )?; + jail.create_file( + "foundry.toml", + r#" + [profile.default] + extends = "base.toml" + + [symbolic] + max_paths = 20 + "#, + )?; + + let config = Config::load().unwrap(); + assert_eq!(config.symbolic.max_paths, 20); + assert_eq!(config.symbolic.depth, Some(100)); + + jail.create_file( + "base.toml", + r#" + [symbolic] + max_paths = 30 + depth = 200 + "#, + )?; + jail.create_file( + "foundry.toml", + r#" + [profile.default] + extends = "base.toml" + + [profile.default.symbolic] + max_paths = 40 + "#, + )?; + + let config = Config::load().unwrap(); + assert_eq!(config.symbolic.max_paths, 40); + assert_eq!(config.symbolic.depth, Some(200)); + + Ok(()) + }); + } + + #[test] + fn inherited_symbolic_sections_detect_effective_collisions() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "base.toml", + r#" + [profile.default.symbolic] + max_paths = 10 + "#, + )?; + jail.create_file( + "foundry.toml", + r#" + [profile.default] + extends = { path = "base.toml", strategy = "no-collision" } + + [symbolic] + max_paths = 20 + "#, + )?; + + let err = Config::load().unwrap_err().to_string(); + assert!(err.contains("Key collision detected"), "unexpected error: {err}"); + assert!(err.contains("symbolic"), "unexpected error: {err}"); + + Ok(()) + }); + } + + #[test] + fn inherited_label_aliases_preserve_source_precedence() { + figment::Jail::expect_with(|jail| { + let address = Address::with_last_byte(1); + + jail.create_file( + "base.toml", + r#" + [profile.default.tracing.labels] + 0x0000000000000000000000000000000000000001 = "base" + "#, + )?; + jail.create_file( + "foundry.toml", + r#" + [profile.default] + extends = "base.toml" + + [profile.default.labels] + 0x0000000000000000000000000000000000000001 = "local" + "#, + )?; + + let config = Config::load().unwrap(); + assert_eq!(config.tracing.labels.get(&address).map(String::as_str), Some("local")); + + jail.create_file( + "base.toml", + r#" + [profile.default.labels] + 0x0000000000000000000000000000000000000001 = "base" + "#, + )?; + jail.create_file( + "foundry.toml", + r#" + [profile.default] + extends = "base.toml" + + [profile.default.tracing.labels] + 0x0000000000000000000000000000000000000001 = "local" + "#, + )?; + + let config = Config::load().unwrap(); + assert_eq!(config.tracing.labels.get(&address).map(String::as_str), Some("local")); + + jail.create_file( + "base.toml", + r#" + [profile.default.tracing.labels] + 0x0000000000000000000000000000000000000001 = "base" + "#, + )?; + jail.create_file( + "foundry.toml", + r#" + [profile.default] + extends = "base.toml" + + [labels] + 0x0000000000000000000000000000000000000001 = "local" + "#, + )?; + + let config = Config::load().unwrap(); + assert_eq!(config.tracing.labels.get(&address).map(String::as_str), Some("local")); + assert_eq!( + config.warnings, + vec![Warning::DeprecatedKey { + old: "[labels]".to_string(), + new: "[tracing.labels]".to_string(), + }] + ); + + jail.create_file( + "base.toml", + r#" + [labels] + 0x0000000000000000000000000000000000000001 = "base" + "#, + )?; + jail.create_file( + "foundry.toml", + r#" + [profile.default] + extends = "base.toml" + + [profile.default.tracing.labels] + 0x0000000000000000000000000000000000000001 = "local" + "#, + )?; + + let config = Config::load().unwrap(); + assert_eq!(config.tracing.labels.get(&address).map(String::as_str), Some("local")); + + Ok(()) + }); + } + + #[test] + fn inherited_label_aliases_detect_effective_collisions() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "base.toml", + r#" + [profile.default.tracing.labels] + 0x0000000000000000000000000000000000000001 = "base" + "#, + )?; + jail.create_file( + "foundry.toml", + r#" + [profile.default] + extends = { path = "base.toml", strategy = "no-collision" } + + [labels] + 0x0000000000000000000000000000000000000001 = "local" + "#, + )?; + + let err = Config::load().unwrap_err().to_string(); + assert_eq!( + err, + "failed to extract foundry config:\n\ + foundry config error: Key collision detected in profile 'default' when extending \ + 'base.toml'. Conflicting keys: [\"tracing\"]. Use 'extends.strategy' or \ + 'extends_strategy' to specify how to handle conflicts.\n" + ); + + Ok(()) + }); + } + + #[test] + fn inherited_fuzz_section_remains_invariant_fallback() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "base.toml", + r#" + [fuzz] + include_storage = false + dictionary_weight = 99 + "#, + )?; + jail.create_file( + "foundry.toml", + r#" + [profile.default] + extends = "base.toml" + + [invariant] + runs = 420 + "#, + )?; + + let config = Config::load().unwrap(); + assert_eq!(config.invariant.runs, 420); + assert!(!config.invariant.dictionary.include_storage); + assert_eq!(config.invariant.dictionary.dictionary_weight, 99); + + Ok(()) + }); + } + + #[test] + fn test_inheritance_validation() { + figment::Jail::expect_with(|jail| { + // Test 1: Base file with 'extends' should fail + jail.create_file( + "base-with-inherit.toml", + r#" + [profile.default] + extends = "another.toml" + optimizer_runs = 800 + "#, + )?; + + jail.create_file( + "foundry.toml", + r#" + [profile.default] + extends = "base-with-inherit.toml" + "#, + )?; + + // Should fail because base file has 'extends' + let result = Config::load(); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Nested inheritance is not allowed")); + + // Test 2: Circular reference should fail + jail.create_file( + "foundry.toml", + r#" + [profile.default] + extends = "foundry.toml" + "#, + )?; + + let result = Config::load(); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("cannot inherit from itself")); + + // Test 3: Non-existent base file should fail + jail.create_file( + "foundry.toml", + r#" + [profile.default] + extends = "non-existent.toml" + "#, + )?; + + let result = Config::load(); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("does not exist") + || err_msg.contains("Failed to resolve inherited config path"), + "Error message: {err_msg}" + ); + + Ok(()) + }); + } + + #[test] + fn test_complex_inheritance_merging() { + figment::Jail::expect_with(|jail| { + // Create a comprehensive base config + jail.create_file( + "base.toml", + r#" + [profile.default] + optimizer = true + optimizer_runs = 1000 + via_ir = false + solc = "0.8.19" + + [invariant] + runs = 500 + depth = 100 + + [fuzz] + runs = 256 + seed = "0x123" + + [rpc_endpoints] + mainnet = "https://base-mainnet.com" + optimism = "https://base-optimism.com" + arbitrum = "https://base-arbitrum.com" + "#, + )?; + + // Create local config that overrides some values + jail.create_file( + "foundry.toml", + r#" + [profile.default] + extends = "base.toml" + optimizer_runs = 200 # Override + via_ir = true # Override + # optimizer and solc are inherited + + [invariant] + runs = 333 # Override + # depth is inherited + + # fuzz section is fully inherited + + [rpc_endpoints] + mainnet = "https://local-mainnet.com" # Override + # optimism and arbitrum are inherited + polygon = "https://local-polygon.com" # New + "#, + )?; + + let config = Config::load().unwrap(); + + // Check profile.default values + assert_eq!(config.optimizer, Some(true)); + assert_eq!(config.optimizer_runs, Some(200)); + assert_eq!(config.via_ir, true); + assert_eq!(config.solc, Some(SolcReq::Version(Version::new(0, 8, 19)))); + + // Check invariant section + assert_eq!(config.invariant.runs, 333); + assert_eq!(config.invariant.depth, 100); + + // Check fuzz section (fully inherited) + assert_eq!(config.fuzz.runs, 256); + assert_eq!(config.fuzz.seed, Some(U256::from(0x123))); + + // Check rpc_endpoints + let endpoints = config.rpc_endpoints.resolved(); + assert!(endpoints.get("mainnet").unwrap().url().unwrap().contains("local-mainnet")); + assert!(endpoints.get("optimism").unwrap().url().unwrap().contains("base-optimism")); + assert!(endpoints.get("arbitrum").unwrap().url().unwrap().contains("base-arbitrum")); + assert!(endpoints.get("polygon").unwrap().url().unwrap().contains("local-polygon")); + + Ok(()) + }); + } + + #[test] + fn test_inheritance_with_different_profiles() { + figment::Jail::expect_with(|jail| { + // Create base config with multiple profiles + jail.create_file( + "base.toml", + r#" + [profile.default] + optimizer = true + optimizer_runs = 200 + + [profile.ci] + optimizer = true + optimizer_runs = 10000 + via_ir = true + + [profile.dev] + optimizer = false + "#, + )?; + + // Local config inherits from base - only for default profile + jail.create_file( + "foundry.toml", + r#" + [profile.default] + extends = "base.toml" + verbosity = 3 + + [profile.ci] + optimizer_runs = 5000 # This doesn't inherit from base.toml's ci profile + "#, + )?; + + // Test default profile + let config = Config::load().unwrap(); + assert_eq!(config.optimizer, Some(true)); + assert_eq!(config.optimizer_runs, Some(200)); + assert_eq!(config.verbosity, 3); + + // Test CI profile (NO 'extends', so doesn't inherit from base) + jail.set_env("FOUNDRY_PROFILE", "ci"); + let config = Config::load().unwrap(); + assert_eq!(config.optimizer_runs, Some(5000)); + assert_eq!(config.optimizer, Some(true)); + // via_ir is not set in local ci profile and there's no 'extends', so default + assert_eq!(config.via_ir, false); + + Ok(()) + }); + } + + #[test] + fn test_inheritance_with_env_vars() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "base.toml", + r#" + [profile.default] + optimizer_runs = 500 + sender = "0x0000000000000000000000000000000000000001" + verbosity = 1 + "#, + )?; + + jail.create_file( + "foundry.toml", + r#" + [profile.default] + extends = "base.toml" + verbosity = 2 + "#, + )?; + + // Environment variables should override both base and local values + jail.set_env("FOUNDRY_OPTIMIZER_RUNS", "999"); + jail.set_env("FOUNDRY_VERBOSITY", "4"); + + let config = Config::load().unwrap(); + assert_eq!(config.optimizer_runs, Some(999)); + assert_eq!(config.verbosity, 4); + assert_eq!(config.sender, Address::with_last_byte(1)); + + Ok(()) + }); + } + + #[test] + fn test_inheritance_with_subdirectories() { + figment::Jail::expect_with(|jail| { + // Create base config in a subdirectory + jail.create_dir("configs")?; + jail.create_file( + "configs/base.toml", + r#" + [profile.default] + optimizer_runs = 800 + src = "contracts" + "#, + )?; + + // Reference it with relative path + jail.create_file( + "foundry.toml", + r#" + [profile.default] + extends = "configs/base.toml" + test = "tests" + "#, + )?; + + let config = Config::load().unwrap(); + assert_eq!(config.optimizer_runs, Some(800)); + assert_eq!(config.src, PathBuf::from("contracts")); + assert_eq!(config.test, PathBuf::from("tests")); + + // Test with parent directory reference + jail.create_dir("project")?; + jail.create_file( + "shared-base.toml", + r#" + [profile.default] + optimizer_runs = 1500 + "#, + )?; + + jail.create_file( + "project/foundry.toml", + r#" + [profile.default] + extends = "../shared-base.toml" + "#, + )?; + + std::env::set_current_dir(jail.directory().join("project")).unwrap(); + let config = Config::load().unwrap(); + assert_eq!(config.optimizer_runs, Some(1500)); + + Ok(()) + }); + } + + #[test] + fn test_inheritance_with_empty_files() { + figment::Jail::expect_with(|jail| { + // Empty base file + jail.create_file( + "base.toml", + r#" + [profile.default] + "#, + )?; + + jail.create_file( + "foundry.toml", + r#" + [profile.default] + extends = "base.toml" + optimizer_runs = 300 + "#, + )?; + + let config = Config::load().unwrap(); + assert_eq!(config.optimizer_runs, Some(300)); + + // Empty local file (only 'extends') + jail.create_file( + "base2.toml", + r#" + [profile.default] + optimizer_runs = 400 + via_ir = true + "#, + )?; + + jail.create_file( + "foundry.toml", + r#" + [profile.default] + extends = "base2.toml" + "#, + )?; + + let config = Config::load().unwrap(); + assert_eq!(config.optimizer_runs, Some(400)); + assert!(config.via_ir); + + Ok(()) + }); + } + + #[test] + fn test_inheritance_array_and_table_merging() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "base.toml", + r#" + [profile.default] + libs = ["lib", "node_modules"] + ignored_error_codes = [5667, 1878] + extra_output = ["metadata", "ir"] + + [profile.default.model_checker] + engine = "chc" + timeout = 10000 + targets = ["assert"] + + [profile.default.optimizer_details] + peephole = true + inliner = true + "#, + )?; + + jail.create_file( + "foundry.toml", + r#" + [profile.default] + extends = "base.toml" + libs = ["custom-lib"] # Concatenates with base array + ignored_error_codes = [2018] # Concatenates with base array + + [profile.default.model_checker] + timeout = 5000 # Overrides base value + # engine and targets are inherited + + [profile.default.optimizer_details] + jumpdest_remover = true # Adds new field + # peephole and inliner are inherited + "#, + )?; + + let config = Config::load().unwrap(); + + // Arrays are now concatenated with admerge (base + local) + assert_eq!( + config.libs, + vec![ + PathBuf::from("lib"), + PathBuf::from("node_modules"), + PathBuf::from("custom-lib") + ] + ); + assert_eq!( + config.ignored_error_codes, + vec![ + SolidityErrorCode::UnusedFunctionParameter, // 5667 from base.toml + SolidityErrorCode::SpdxLicenseNotProvided, // 1878 from base.toml + SolidityErrorCode::FunctionStateMutabilityCanBeRestricted // 2018 from local + ] + ); + + // Tables are deep-merged + assert_eq!(config.model_checker.as_ref().unwrap().timeout, Some(5000)); + assert_eq!( + config.model_checker.as_ref().unwrap().engine, + Some(ModelCheckerEngine::CHC) + ); + assert_eq!( + config.model_checker.as_ref().unwrap().targets, + Some(vec![ModelCheckerTarget::Assert]) + ); + + // optimizer_details table is actually merged, not replaced + assert_eq!(config.optimizer_details.as_ref().unwrap().peephole, Some(true)); + assert_eq!(config.optimizer_details.as_ref().unwrap().inliner, Some(true)); + assert_eq!(config.optimizer_details.as_ref().unwrap().jumpdest_remover, None); + + Ok(()) + }); + } + + #[test] + fn test_inheritance_with_special_sections() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "base.toml", + r#" + [profile.default] + # Base file should not have 'extends' to avoid nested inheritance + + [labels] + "0x0000000000000000000000000000000000000001" = "Alice" + "0x0000000000000000000000000000000000000002" = "Bob" + + [[profile.default.fs_permissions]] + access = "read" + path = "./src" + + [[profile.default.fs_permissions]] + access = "read-write" + path = "./cache" + "#, + )?; + + jail.create_file( + "foundry.toml", + r#" + [profile.default] + extends = "base.toml" + + [labels] + "0x0000000000000000000000000000000000000002" = "Bob Updated" + "0x0000000000000000000000000000000000000003" = "Charlie" + + [[profile.default.fs_permissions]] + access = "read" + path = "./test" + "#, + )?; + + let config = Config::load().unwrap(); + + // Labels should be merged + assert_eq!(config.labels.get(&Address::with_last_byte(1)), Some(&"Alice".to_string())); + assert_eq!( + config.labels.get(&Address::with_last_byte(2)), + Some(&"Bob Updated".to_string()) + ); + assert_eq!( + config.labels.get(&Address::with_last_byte(3)), + Some(&"Charlie".to_string()) + ); + + // fs_permissions array is now concatenated with addmerge (base + local) + assert_eq!(config.fs_permissions.permissions.len(), 3); // 2 from base + 1 from local + // Check that all permissions are present + assert!( + config + .fs_permissions + .permissions + .iter() + .any(|p| p.path.to_str().unwrap() == "./src") + ); + assert!( + config + .fs_permissions + .permissions + .iter() + .any(|p| p.path.to_str().unwrap() == "./cache") + ); + assert!( + config + .fs_permissions + .permissions + .iter() + .any(|p| p.path.to_str().unwrap() == "./test") + ); + + Ok(()) + }); + } + + #[test] + fn test_inheritance_with_compilation_settings() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "base.toml", + r#" + [profile.default] + solc = "0.8.19" + evm_version = "paris" + via_ir = false + optimizer = true + optimizer_runs = 200 + + [profile.default.optimizer_details] + peephole = true + inliner = false + jumpdest_remover = true + order_literals = false + deduplicate = true + cse = true + constant_optimizer = true + yul = true + + [profile.default.optimizer_details.yul_details] + stack_allocation = true + optimizer_steps = "dhfoDgvulfnTUtnIf" + "#, + )?; + + jail.create_file( + "foundry.toml", + r#" + [profile.default] + extends = "base.toml" + evm_version = "shanghai" # Override + optimizer_runs = 1000 # Override + + [profile.default.optimizer_details] + inliner = true # Override + # Rest inherited + "#, + )?; + + let config = Config::load().unwrap(); + + // Check compilation settings + assert_eq!(config.solc, Some(SolcReq::Version(Version::new(0, 8, 19)))); + assert_eq!(config.evm_version, EvmVersion::Shanghai); + assert_eq!(config.via_ir, false); + assert_eq!(config.optimizer, Some(true)); + assert_eq!(config.optimizer_runs, Some(1000)); + + // Check optimizer details - the table is actually merged + let details = config.optimizer_details.as_ref().unwrap(); + assert_eq!(details.peephole, Some(true)); + assert_eq!(details.inliner, Some(true)); + assert_eq!(details.jumpdest_remover, None); + assert_eq!(details.order_literals, None); + assert_eq!(details.deduplicate, Some(true)); + assert_eq!(details.cse, Some(true)); + assert_eq!(details.constant_optimizer, None); + assert_eq!(details.yul, Some(true)); + + // Check yul details - inherited from base + if let Some(yul_details) = details.yul_details.as_ref() { + assert_eq!(yul_details.stack_allocation, Some(true)); + assert_eq!(yul_details.optimizer_steps, Some("dhfoDgvulfnTUtnIf".to_string())); + } + + Ok(()) + }); + } + + #[test] + fn test_inheritance_with_remappings() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "base.toml", + r#" + [profile.default] + remappings = [ + "forge-std/=lib/forge-std/src/", + "@openzeppelin/=lib/openzeppelin-contracts/", + "ds-test/=lib/ds-test/src/" + ] + auto_detect_remappings = false + "#, + )?; + + jail.create_file( + "foundry.toml", + r#" + [profile.default] + extends = "base.toml" + remappings = [ + "@custom/=lib/custom/", + "ds-test/=lib/forge-std/lib/ds-test/src/" # Note: This will be added alongside base remappings + ] + "#, + )?; + + let config = Config::load().unwrap(); + + // Remappings array is now concatenated with admerge (base + local) + assert!(config.remappings.iter().any(|r| r.to_string().contains("@custom/"))); + assert!(config.remappings.iter().any(|r| r.to_string().contains("ds-test/"))); + assert!(config.remappings.iter().any(|r| r.to_string().contains("forge-std/"))); + assert!(config.remappings.iter().any(|r| r.to_string().contains("@openzeppelin/"))); + + // auto_detect_remappings should be inherited + assert!(!config.auto_detect_remappings); + + Ok(()) + }); + } + + #[test] + fn test_inheritance_with_multiple_profiles_and_single_file() { + figment::Jail::expect_with(|jail| { + // Create base config with prod and test profiles + jail.create_file( + "base.toml", + r#" + [profile.prod] + optimizer = true + optimizer_runs = 10000 + via_ir = true + + [profile.test] + optimizer = false + + [profile.test.fuzz] + runs = 100 + "#, + )?; + + // Local config inherits from base for prod profile + jail.create_file( + "foundry.toml", + r#" + [profile.prod] + extends = "base.toml" + evm_version = "shanghai" # Additional setting + + [profile.test] + extends = "base.toml" + + [profile.test.fuzz] + runs = 500 # Override + "#, + )?; + + // Test prod profile + jail.set_env("FOUNDRY_PROFILE", "prod"); + let config = Config::load().unwrap(); + assert_eq!(config.optimizer, Some(true)); + assert_eq!(config.optimizer_runs, Some(10000)); + assert_eq!(config.via_ir, true); + assert_eq!(config.evm_version, EvmVersion::Shanghai); + + // Test test profile + jail.set_env("FOUNDRY_PROFILE", "test"); + let config = Config::load().unwrap(); + assert_eq!(config.optimizer, Some(false)); + assert_eq!(config.fuzz.runs, 500); + + Ok(()) + }); + } + + #[test] + fn test_inheritance_with_multiple_profiles_and_files() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "prod.toml", + r#" + [profile.prod] + optimizer = true + optimizer_runs = 20000 + gas_limit = 50000000 + "#, + )?; + jail.create_file( + "dev.toml", + r#" + [profile.dev] + optimizer = true + optimizer_runs = 333 + gas_limit = 555555 + "#, + )?; + + // Local config with only both profiles + jail.create_file( + "foundry.toml", + r#" + [profile.dev] + extends = "dev.toml" + sender = "0x0000000000000000000000000000000000000001" + + [profile.prod] + extends = "prod.toml" + sender = "0x0000000000000000000000000000000000000002" + "#, + )?; + + // Test that prod profile correctly inherits even without a default profile + jail.set_env("FOUNDRY_PROFILE", "dev"); + let config = Config::load().unwrap(); + assert_eq!(config.optimizer, Some(true)); + assert_eq!(config.optimizer_runs, Some(333)); + assert_eq!(config.gas_limit, 555555.into()); + assert_eq!(config.sender, Address::with_last_byte(1)); + + // Test that prod profile correctly inherits even without a default profile + jail.set_env("FOUNDRY_PROFILE", "prod"); + let config = Config::load().unwrap(); + assert_eq!(config.optimizer, Some(true)); + assert_eq!(config.optimizer_runs, Some(20000)); + assert_eq!(config.gas_limit, 50000000.into()); + assert_eq!(config.sender, Address::with_last_byte(2)); + + Ok(()) + }); + } + + #[test] + fn test_extends_strategy_extend_arrays() { + figment::Jail::expect_with(|jail| { + // Create base config with arrays + jail.create_file( + "base.toml", + r#" + [profile.default] + libs = ["lib", "node_modules"] + ignored_error_codes = [5667, 1878] + optimizer_runs = 200 + "#, + )?; + + // Local config extends with extend-arrays strategy (concatenates arrays) + jail.create_file( + "foundry.toml", + r#" + [profile.default] + extends = "base.toml" + libs = ["mylib", "customlib"] + ignored_error_codes = [1234] + optimizer_runs = 500 + "#, + )?; + + let config = Config::load().unwrap(); + + // Arrays should be concatenated (base + local) + assert_eq!(config.libs.len(), 4); + assert!(config.libs.iter().any(|l| l.to_str() == Some("lib"))); + assert!(config.libs.iter().any(|l| l.to_str() == Some("node_modules"))); + assert!(config.libs.iter().any(|l| l.to_str() == Some("mylib"))); + assert!(config.libs.iter().any(|l| l.to_str() == Some("customlib"))); + + assert_eq!(config.ignored_error_codes.len(), 3); + assert!( + config.ignored_error_codes.contains(&SolidityErrorCode::UnusedFunctionParameter) + ); // 5667 + assert!( + config.ignored_error_codes.contains(&SolidityErrorCode::SpdxLicenseNotProvided) + ); // 1878 + assert!(config.ignored_error_codes.contains(&SolidityErrorCode::from(1234u64))); // 1234 - generic + + // Non-array values should be replaced + assert_eq!(config.optimizer_runs, Some(500)); + + Ok(()) + }); + } + + #[test] + fn test_extends_strategy_replace_arrays() { + figment::Jail::expect_with(|jail| { + // Create base config with arrays + jail.create_file( + "base.toml", + r#" + [profile.default] + libs = ["lib", "node_modules"] + ignored_error_codes = [5667, 1878] + optimizer_runs = 200 + "#, + )?; + + // Local config extends with replace-arrays strategy (replaces arrays entirely) + jail.create_file( + "foundry.toml", + r#" + [profile.default] + extends = { path = "base.toml", strategy = "replace-arrays" } + libs = ["mylib", "customlib"] + ignored_error_codes = [1234] + optimizer_runs = 500 + "#, + )?; + + let config = Config::load().unwrap(); + + // Arrays should be replaced entirely (only local values) + assert_eq!(config.libs.len(), 2); + assert!(config.libs.iter().any(|l| l.to_str() == Some("mylib"))); + assert!(config.libs.iter().any(|l| l.to_str() == Some("customlib"))); + assert!(!config.libs.iter().any(|l| l.to_str() == Some("lib"))); + assert!(!config.libs.iter().any(|l| l.to_str() == Some("node_modules"))); + + assert_eq!(config.ignored_error_codes.len(), 1); + assert!(config.ignored_error_codes.contains(&SolidityErrorCode::from(1234u64))); // 1234 + assert!( + !config.ignored_error_codes.contains(&SolidityErrorCode::UnusedFunctionParameter) + ); // 5667 + + // Non-array values should be replaced + assert_eq!(config.optimizer_runs, Some(500)); + + Ok(()) + }); + } + + #[test] + fn test_extends_strategy_no_collision_success() { + figment::Jail::expect_with(|jail| { + // Create base config + jail.create_file( + "base.toml", + r#" + [profile.default] + optimizer = true + optimizer_runs = 200 + src = "src" + "#, + )?; + + // Local config extends with no-collision strategy and no conflicts + jail.create_file( + "foundry.toml", + r#" + [profile.default] + extends = { path = "base.toml", strategy = "no-collision" } + test = "tests" + libs = ["lib"] + "#, + )?; + + let config = Config::load().unwrap(); + + // Values from base should be present + assert_eq!(config.optimizer, Some(true)); + assert_eq!(config.optimizer_runs, Some(200)); + assert_eq!(config.src, PathBuf::from("src")); + + // Values from local should be present + assert_eq!(config.test, PathBuf::from("tests")); + assert_eq!(config.libs.len(), 1); + assert!(config.libs.iter().any(|l| l.to_str() == Some("lib"))); + + Ok(()) + }); + } + + #[test] + fn test_extends_strategy_no_collision_error() { + figment::Jail::expect_with(|jail| { + // Create base config + jail.create_file( + "base.toml", + r#" + [profile.default] + optimizer = true + optimizer_runs = 200 + libs = ["lib", "node_modules"] + "#, + )?; + + // Local config extends with no-collision strategy but has conflicts + jail.create_file( + "foundry.toml", + r#" + [profile.default] + extends = { path = "base.toml", strategy = "no-collision" } + optimizer_runs = 500 + libs = ["mylib"] + "#, + )?; + + // Loading should fail due to key collision + let result = Config::load(); + + if let Ok(config) = result { + panic!( + "Expected error but got config with optimizer_runs: {:?}, libs: {:?}", + config.optimizer_runs, config.libs + ); + } + + let err = result.unwrap_err(); + let err_str = err.to_string(); + assert!( + err_str.contains("Key collision detected") || err_str.contains("collision"), + "Error message doesn't mention collision: {err_str}" + ); + + Ok(()) + }); + } + + #[test] + fn test_extends_both_syntaxes() { + figment::Jail::expect_with(|jail| { + // Create base config + jail.create_file( + "base.toml", + r#" + [profile.default] + libs = ["lib"] + optimizer = true + "#, + )?; + + // Test 1: Simple string syntax (should use default extend-arrays) + jail.create_file( + "foundry_string.toml", + r#" + [profile.default] + extends = "base.toml" + libs = ["custom"] + "#, + )?; + + // Test 2: Object syntax with explicit strategy + jail.create_file( + "foundry_object.toml", + r#" + [profile.default] + extends = { path = "base.toml", strategy = "replace-arrays" } + libs = ["custom"] + "#, + )?; + + // Test string syntax (default extend-arrays) + jail.set_env("FOUNDRY_CONFIG", "foundry_string.toml"); + let config = Config::load().unwrap(); + assert_eq!(config.libs.len(), 2); // Should concatenate + assert!(config.libs.iter().any(|l| l.to_str() == Some("lib"))); + assert!(config.libs.iter().any(|l| l.to_str() == Some("custom"))); + + // Test object syntax (replace-arrays) + jail.set_env("FOUNDRY_CONFIG", "foundry_object.toml"); + let config = Config::load().unwrap(); + assert_eq!(config.libs.len(), 1); // Should replace + assert!(config.libs.iter().any(|l| l.to_str() == Some("custom"))); + assert!(!config.libs.iter().any(|l| l.to_str() == Some("lib"))); + + Ok(()) + }); + } + + #[test] + fn test_extends_strategy_default_is_extend_arrays() { + figment::Jail::expect_with(|jail| { + // Create base config + jail.create_file( + "base.toml", + r#" + [profile.default] + libs = ["lib", "node_modules"] + optimizer = true + "#, + )?; + + // Local config extends without specifying strategy (should default to extend-arrays) + jail.create_file( + "foundry.toml", + r#" + [profile.default] + extends = "base.toml" + libs = ["custom"] + optimizer = false + "#, + )?; + + // Should work with default extend-arrays strategy + let config = Config::load().unwrap(); + + // Arrays should be concatenated by default + assert_eq!(config.libs.len(), 3); + assert!(config.libs.iter().any(|l| l.to_str() == Some("lib"))); + assert!(config.libs.iter().any(|l| l.to_str() == Some("node_modules"))); + assert!(config.libs.iter().any(|l| l.to_str() == Some("custom"))); + + // Non-array values should be replaced + assert_eq!(config.optimizer, Some(false)); + + Ok(()) + }); + } + + #[test] + fn test_deprecated_deny_warnings_is_handled() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r#" + [profile.default] + deny_warnings = true + "#, + )?; + let config = Config::load().unwrap(); + + // Assert that the deprecated flag is correctly interpreted + assert_eq!(config.deny, DenyLevel::Warnings); + Ok(()) + }); + } + + #[test] + fn warns_on_deprecated_keys_in_inactive_profiles() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r#" + [profile.default] + src = "src" + + [profile.ci] + deny_warnings = true + "#, + )?; + + let cfg = Config::load().unwrap(); + assert!( + cfg.warnings.iter().any(|w| matches!( + w, + crate::Warning::DeprecatedKey { old, new } + if old == "deny_warnings" && new == "deny = warnings" + )), + "Expected deprecated key warning for inactive profile, got: {:?}", + cfg.warnings + ); + Ok(()) + }); + } + + #[test] + fn warns_on_deprecated_profile_names() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r#" + [profile.cancun] + "#, + )?; + + let cfg = Config::load().unwrap(); + assert!( + cfg.warnings.iter().any(|w| matches!( + w, + crate::Warning::DeprecatedKey { old, new } + if old == "cancun" && new == "evm_version = Cancun" + )), + "Expected deprecated profile-name warning, got: {:?}", + cfg.warnings + ); + Ok(()) + }); + } + + #[test] + fn warns_on_unknown_keys_in_profile() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r#" + [profile.default] + unknown_key_xyz = 123 + "#, + )?; + + let cfg = Config::load().unwrap(); + assert!(cfg.warnings.iter().any( + |w| matches!(w, crate::Warning::UnknownKey { key, .. } if key == "unknown_key_xyz") + )); + Ok(()) + }); + } + + #[test] + fn no_unknown_key_warning_for_network_field() { + // Regression test: `network` is a flattened `Option` field of `NetworkConfigs`. It must + // not trigger an unknown-key warning, regardless of whether it is set. + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r#" + [profile.default] + network = "tempo" + "#, + )?; + + let cfg = Config::load().unwrap(); + assert!( + !cfg.warnings.iter().any( + |w| matches!(w, crate::Warning::UnknownKey { key, .. } if key == "network") + ), + "did not expect UnknownKey warning for `network`, got: {:?}", + cfg.warnings + ); + Ok(()) + }); + } + + #[test] + fn no_unknown_key_warning_for_legacy_tempo_alias() { + // Regression test: the legacy `tempo = true` alias must keep working without warnings. + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r#" + [profile.default] + tempo = true + "#, + )?; + + let cfg = Config::load().unwrap(); + assert!( + !cfg.warnings + .iter() + .any(|w| matches!(w, crate::Warning::UnknownKey { key, .. } if key == "tempo")), + "did not expect UnknownKey warning for `tempo`, got: {:?}", + cfg.warnings + ); + Ok(()) + }); + } + + #[test] + #[cfg(feature = "monad")] + fn no_unknown_key_warning_for_legacy_monad_alias() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r#" + [profile.default] + monad = true + "#, + )?; + + let cfg = Config::load().unwrap(); + assert!( + !cfg.warnings + .iter() + .any(|w| matches!(w, crate::Warning::UnknownKey { key, .. } if key == "monad")), + "did not expect UnknownKey warning for `monad`, got: {:?}", + cfg.warnings + ); + Ok(()) + }); + } + + #[test] + #[cfg(not(feature = "monad"))] + fn warns_for_monad_alias_without_monad_support() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r#" + [profile.default] + monad = true + "#, + )?; + + let cfg = Config::load().unwrap(); + assert!( + cfg.warnings + .iter() + .any(|w| matches!(w, crate::Warning::UnknownKey { key, .. } if key == "monad")), + "expected UnknownKey warning for `monad`, got: {:?}", + cfg.warnings + ); + Ok(()) + }); + } + + #[test] + fn fails_on_ambiguous_version_in_compilation_restrictions() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r#" + [profile.default] + src = "src" + + [[profile.default.compilation_restrictions]] + paths = "src/*.sol" + version = "0.8.11" + "#, + )?; + + let err = Config::load().expect_err("expected bare version to fail"); + let err_msg = err.to_string(); + assert!( + err_msg.contains("Invalid version format '0.8.11'") + && err_msg.contains("Bare version numbers are ambiguous"), + "Expected error about ambiguous version, got: {err_msg}" + ); + + Ok(()) + }); + } + + #[test] + fn accepts_explicit_version_requirements() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r#" + [profile.default] + src = "src" + + [[profile.default.compilation_restrictions]] + paths = "src/*.sol" + version = "=0.8.11" + + [[profile.default.compilation_restrictions]] + paths = "test/*.sol" + version = ">=0.8.11" + "#, + )?; + + let config = Config::load().expect("should accept explicit version requirements"); + assert_eq!(config.compilation_restrictions.len(), 2); + + Ok(()) + }); + } + + #[test] + fn warns_on_unknown_keys_in_all_config_sections() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r#" + [profile.default] + src = "src" + unknown_profile_key = "should_warn" + + # Standalone sections with unknown keys + [fmt] + line_length = 120 + unknown_fmt_key = "should_warn" + + [lint] + severity = ["high"] + unknown_lint_key = "should_warn" + + [doc] + out = "docs" + unknown_doc_key = "should_warn" + + [fuzz] + runs = 256 + unknown_fuzz_key = "should_warn" + + [invariant] + runs = 256 + unknown_invariant_key = "should_warn" + + [symbolic] + enabled = true + depth = 128 + unknown_symbolic_key = "should_warn" + + [mutation] + unknown_mutation_key = "should_warn" + + [vyper] + unknown_vyper_key = "should_warn" + + [bind_json] + out = "bindings.sol" + unknown_bind_json_key = "should_warn" + + # Nested profile sections with unknown keys + [profile.default.fmt] + line_length = 100 + unknown_nested_fmt_key = "should_warn" + + [profile.default.lint] + severity = ["low"] + unknown_nested_lint_key = "should_warn" + + [profile.default.doc] + out = "documentation" + unknown_nested_doc_key = "should_warn" + + [profile.default.fuzz] + runs = 512 + unknown_nested_fuzz_key = "should_warn" + + [profile.default.invariant] + runs = 512 + unknown_nested_invariant_key = "should_warn" + + [profile.default.symbolic] + max_paths = 512 + unknown_nested_symbolic_key = "should_warn" + + [profile.default.mutation] + unknown_nested_mutation_key = "should_warn" + + [profile.default.vyper] + unknown_nested_vyper_key = "should_warn" + + [profile.default.bind_json] + out = "nested_bindings.sol" + unknown_nested_bind_json_key = "should_warn" + + # Array sections with unknown keys + [[profile.default.compilation_restrictions]] + paths = "src/*.sol" + unknown_compilation_key = "should_warn" + + [[profile.default.additional_compiler_profiles]] + name = "via-ir" + via_ir = true + unknown_compiler_profile_key = "should_warn" + "#, + )?; + + let cfg = Config::load().unwrap(); + + // Expected warnings for profile-level unknown key + assert!( + cfg.warnings.iter().any(|w| matches!( + w, + crate::Warning::UnknownKey { key, .. } if key == "unknown_profile_key" + )), + "Expected warning for 'unknown_profile_key' in profile, got: {:?}", + cfg.warnings + ); + + // Expected warnings for standalone sections + let standalone_expected = [ + ("unknown_fmt_key", "fmt"), + ("unknown_lint_key", "lint"), + ("unknown_doc_key", "doc"), + ("unknown_fuzz_key", "fuzz"), + ("unknown_invariant_key", "invariant"), + ("unknown_symbolic_key", "symbolic"), + ("unknown_mutation_key", "mutation"), + ("unknown_vyper_key", "vyper"), + ("unknown_bind_json_key", "bind_json"), + ]; + + for (expected_key, expected_section) in standalone_expected { + assert!( + cfg.warnings.iter().any(|w| matches!( + w, + crate::Warning::UnknownSectionKey { key, section, .. } + if key == expected_key && section == expected_section + )), + "Expected warning for '{}' in standalone section '{}', got: {:?}", + expected_key, + expected_section, + cfg.warnings + ); + } + + // Expected warnings for nested profile sections + let nested_expected = [ + ("unknown_nested_fmt_key", "fmt"), + ("unknown_nested_lint_key", "lint"), + ("unknown_nested_doc_key", "doc"), + ("unknown_nested_fuzz_key", "fuzz"), + ("unknown_nested_invariant_key", "invariant"), + ("unknown_nested_symbolic_key", "symbolic"), + ("unknown_nested_mutation_key", "mutation"), + ("unknown_nested_vyper_key", "vyper"), + ("unknown_nested_bind_json_key", "bind_json"), + ]; + + for (expected_key, expected_section) in nested_expected { + assert!( + cfg.warnings.iter().any(|w| matches!( + w, + crate::Warning::UnknownSectionKey { key, section, .. } + if key == expected_key && section == expected_section + )), + "Expected warning for '{}' in nested section '{}', got: {:?}", + expected_key, + expected_section, + cfg.warnings + ); + } + + // Expected warnings for array item sections + let array_expected = [ + ("unknown_compilation_key", "compilation_restrictions"), + ("unknown_compiler_profile_key", "additional_compiler_profiles"), + ]; + + for (expected_key, expected_section) in array_expected { + assert!( + cfg.warnings.iter().any(|w| matches!( + w, + crate::Warning::UnknownSectionKey { key, section, .. } + if key == expected_key && section == expected_section + )), + "Expected warning for '{}' in array section '{}', got: {:?}", + expected_key, + expected_section, + cfg.warnings + ); + } + + // Verify total count of unknown key warnings + let unknown_key_warnings: Vec<_> = cfg + .warnings + .iter() + .filter(|w| { + matches!(w, crate::Warning::UnknownKey { .. }) + || matches!(w, crate::Warning::UnknownSectionKey { .. }) + }) + .collect(); + + // 1 profile key + 9 standalone + 9 nested + 2 array = 21 total + assert_eq!( + unknown_key_warnings.len(), + 21, + "Expected 21 unknown key warnings (1 profile + 9 standalone + 9 nested + 2 array), got {}: {:?}", + unknown_key_warnings.len(), + unknown_key_warnings + ); + + Ok(()) + }); + } + + #[test] + fn warns_on_unknown_keys_in_extended_config() { + figment::Jail::expect_with(|jail| { + // Create base config with unknown keys + jail.create_file( + "base.toml", + r#" + [profile.default] + optimizer_runs = 800 + unknown_base_profile_key = "should_warn" + + [lint] + severity = ["high"] + unknown_base_lint_key = "should_warn" + + [fmt] + line_length = 100 + unknown_base_fmt_key = "should_warn" + "#, + )?; + + // Create local config that extends base with its own unknown keys + jail.create_file( + "foundry.toml", + r#" + [profile.default] + extends = "base.toml" + src = "src" + unknown_local_profile_key = "should_warn" + + [lint] + unknown_local_lint_key = "should_warn" + + [fuzz] + runs = 512 + unknown_local_fuzz_key = "should_warn" + + [[profile.default.compilation_restrictions]] + paths = "src/*.sol" + unknown_local_restriction_key = "should_warn" + "#, + )?; + + let cfg = Config::load().unwrap(); + + // Verify base config values are inherited + assert_eq!(cfg.optimizer_runs, Some(800)); + + // Unknown keys from both base and local configs should be detected. + // Note: Due to how figment merges configs before validation, the source + // will show the local config file for all warnings. This is a known + // limitation - proper source attribution for extended configs would + // require validating each file before the merge. + + // Verify all expected unknown keys are detected + let expected_unknown_keys = ["unknown_base_profile_key", "unknown_local_profile_key"]; + for expected_key in expected_unknown_keys { + assert!( + cfg.warnings.iter().any(|w| matches!( + w, + crate::Warning::UnknownKey { key, .. } if key == expected_key + )), + "Expected warning for '{}', got: {:?}", + expected_key, + cfg.warnings + ); + } + + let expected_section_keys = [ + ("unknown_base_lint_key", "lint"), + ("unknown_base_fmt_key", "fmt"), + ("unknown_local_lint_key", "lint"), + ("unknown_local_fuzz_key", "fuzz"), + ("unknown_local_restriction_key", "compilation_restrictions"), + ]; + for (expected_key, expected_section) in expected_section_keys { + assert!( + cfg.warnings.iter().any(|w| matches!( + w, + crate::Warning::UnknownSectionKey { key, section, .. } + if key == expected_key && section == expected_section + )), + "Expected warning for '{}' in section '{}', got: {:?}", + expected_key, + expected_section, + cfg.warnings + ); + } + + // Verify total: 2 profile keys + 5 section keys = 7 warnings + let unknown_warnings: Vec<_> = cfg + .warnings + .iter() + .filter(|w| { + matches!(w, crate::Warning::UnknownKey { .. }) + || matches!(w, crate::Warning::UnknownSectionKey { .. }) + }) + .collect(); + assert_eq!( + unknown_warnings.len(), + 7, + "Expected 7 unknown key warnings, got {}: {:?}", + unknown_warnings.len(), + unknown_warnings + ); + + Ok(()) + }); + } + + // Test for issue #12844: FOUNDRY_PROFILE=nonexistent should warn and fall back to default. + #[test] + fn warns_on_unknown_profile() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r#" + [profile.default] + src = "src" + "#, + )?; + + jail.set_env("FOUNDRY_PROFILE", "nonexistent"); + let cfg = Config::load().expect("expected unknown profile to fall back to default"); + assert_eq!(cfg.profile, Config::DEFAULT_PROFILE); + assert!( + cfg.warnings.iter().any(|w| matches!( + w, + crate::Warning::UnknownProfile { profile } if profile == "nonexistent" + )), + "Expected UnknownProfile warning, got: {:?}", + cfg.warnings + ); + + Ok(()) + }); + } + + // Test for issue #13316: vyper config keys should not trigger unknown key warnings + #[test] + fn no_false_warnings_for_vyper_config_keys() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r#" + [profile.default] + src = "src" + + [vyper] + optimize = "O1" + path = "/usr/bin/vyper" + experimental_codegen = true + venom_experimental = false + debug = true + enable_decimals = true + + [vyper.venom] + disable_cse = true + inline_threshold = 15 + "#, + )?; + + let cfg = Config::load().unwrap(); + // None of the valid vyper keys should trigger warnings + let vyper_warnings: Vec<_> = cfg + .warnings + .iter() + .filter(|w| { + matches!( + w, + crate::Warning::UnknownSectionKey { section, .. } if section == "vyper" + ) + }) + .collect(); + + assert!( + vyper_warnings.is_empty(), + "Valid vyper keys should not trigger warnings, got: {vyper_warnings:?}" + ); + + Ok(()) + }); + } + + // Test for issue #13316: vyper config in profile should not trigger false warnings + #[test] + fn no_false_warnings_for_nested_vyper_config_keys() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r#" + [profile.default] + src = "src" + + [profile.default.vyper] + opt_level = "s" + path = "/opt/vyper/bin/vyper" + experimental_codegen = false + debug = true + enable_decimals = true + venom = { disable_sccp = true, disable_mem2var = false } + "#, + )?; + + let cfg = Config::load().unwrap(); + // None of the valid vyper keys should trigger warnings + let vyper_warnings: Vec<_> = cfg + .warnings + .iter() + .filter(|w| { + matches!( + w, + crate::Warning::UnknownSectionKey { section, .. } if section == "vyper" + ) + }) + .collect(); + + assert!( + vyper_warnings.is_empty(), + "Valid nested vyper keys should not trigger warnings, got: {vyper_warnings:?}" + ); + + Ok(()) + }); + } + + // Test for issue #13316: inline vyper config format should not trigger false warnings + // This matches the exact format used in https://github.com/pcaversaccio/snekmate + #[test] + fn no_false_warnings_for_inline_vyper_config() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r#" + [profile.default] + src = "src" + vyper = { optimize = "gas", debug = true } + + [profile.default-venom] + vyper = { opt_level = "O2", experimental_codegen = true, venom = { disable_cse = true } } + + [profile.ci-venom] + vyper = { venom_experimental = true, enable_decimals = true } + "#, + )?; + + let cfg = Config::load().unwrap(); + let vyper_warnings: Vec<_> = cfg + .warnings + .iter() + .filter(|w| { + matches!( + w, + crate::Warning::UnknownSectionKey { section, .. } if section == "vyper" + ) + }) + .collect(); + + assert!( + vyper_warnings.is_empty(), + "Valid inline vyper config should not trigger warnings, got: {vyper_warnings:?}" + ); + + Ok(()) + }); + } + + // Test for issue #13316: unknown vyper keys should still warn + #[test] + fn warns_on_unknown_vyper_keys() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r#" + [profile.default] + src = "src" + + [vyper] + optimize = "gas" + unknown_vyper_option = true + "#, + )?; + + let cfg = Config::load().unwrap(); + assert!( + cfg.warnings.iter().any(|w| matches!( + w, + crate::Warning::UnknownSectionKey { key, section, .. } + if key == "unknown_vyper_option" && section == "vyper" + )), + "Unknown vyper key should trigger warning, got: {:?}", + cfg.warnings + ); + + Ok(()) + }); + } + + // Test for issue #12844: known profile should work + #[test] + fn succeeds_on_known_profile() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r#" + [profile.default] + src = "src" + + [profile.ci] + src = "src" + fuzz = { runs = 10000 } + "#, + )?; + + jail.set_env("FOUNDRY_PROFILE", "ci"); + let config = Config::load().expect("known profile should work"); + assert_eq!(config.profile.as_str(), "ci"); + assert_eq!(config.fuzz.runs, 10000); + + Ok(()) + }); + } + + // Test for issue #12963: nested lib configs should fallback to default profile + // when they don't define the requested profile + #[test] + fn nested_lib_config_falls_back_to_default_profile() { + figment::Jail::expect_with(|jail| { + // Create a lib directory with only default profile + let lib_path = jail.directory().join("lib/mylib"); + std::fs::create_dir_all(&lib_path).unwrap(); + jail.create_file( + "lib/mylib/foundry.toml", + r#" + [profile.default] + src = "contracts" + "#, + )?; + + // Set a profile that doesn't exist in the lib + jail.set_env("FOUNDRY_PROFILE", "ci"); + + // load_with_root_and_fallback should succeed and fall back to default + let config = Config::load_with_root_and_fallback(&lib_path) + .expect("lib config should load with fallback"); + assert_eq!(config.profile, Config::DEFAULT_PROFILE); + assert_eq!(config.src.as_os_str(), "contracts"); + + Ok(()) + }); + } + + // Test for issue #12963: nested lib configs should use requested profile if it exists + #[test] + fn nested_lib_config_uses_profile_if_exists() { + figment::Jail::expect_with(|jail| { + // Create a lib directory with both default and ci profiles + let lib_path = jail.directory().join("lib/mylib"); + std::fs::create_dir_all(&lib_path).unwrap(); + jail.create_file( + "lib/mylib/foundry.toml", + r#" + [profile.default] + src = "contracts" + + [profile.ci] + src = "contracts" + fuzz = { runs = 5000 } + "#, + )?; + + // Set a profile that exists in the lib + jail.set_env("FOUNDRY_PROFILE", "ci"); + + // load_with_root_and_fallback should use the ci profile + let config = Config::load_with_root_and_fallback(&lib_path) + .expect("lib config should load with profile"); + assert_eq!(config.profile.as_str(), "ci"); + assert_eq!(config.fuzz.runs, 5000); + + Ok(()) + }); + } + + // Test for issue #13170: profile names with hyphens should work correctly + #[test] + fn succeeds_on_hyphenated_profile_name() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r#" + [profile.default] + src = "src" + + [profile.ci-venom] + src = "src" + fuzz = { runs = 7500 } + + [profile.default-venom] + src = "src" + fuzz = { runs = 8000 } + "#, + )?; + + // Test ci-venom profile + jail.set_env("FOUNDRY_PROFILE", "ci-venom"); + let config = Config::load().expect("hyphenated profile should work"); + assert_eq!(config.profile.as_str(), "ci-venom"); + assert_eq!(config.fuzz.runs, 7500); + + // Test default-venom profile + jail.set_env("FOUNDRY_PROFILE", "default-venom"); + let config = Config::load().expect("hyphenated profile should work"); + assert_eq!(config.profile.as_str(), "default-venom"); + assert_eq!(config.fuzz.runs, 8000); + + // Verify the profiles list contains hyphenated names + assert!( + config.profiles.iter().any(|p| p.as_str() == "ci-venom"), + "profiles should contain 'ci-venom', got: {:?}", + config.profiles + ); + assert!( + config.profiles.iter().any(|p| p.as_str() == "default-venom"), + "profiles should contain 'default-venom', got: {:?}", + config.profiles + ); + + Ok(()) + }); + } + + #[test] + fn standalone_section_name_can_be_used_as_profile_name() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r#" + [profile.symbolic] + eth-rpc-url = "https://example.com/" + "#, + )?; + jail.set_env("FOUNDRY_PROFILE", "symbolic"); + + let config = Config::load().unwrap(); + assert_eq!(config.profile.as_str(), "symbolic"); + assert_eq!(config.eth_rpc_url.as_deref(), Some("https://example.com/")); + + Ok(()) + }); + } + + // Test for issue #13170: hyphenated profile with nested config keys + #[test] + fn hyphenated_profile_with_nested_sections() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r#" + [profile.default] + src = "src" + + [profile.ci-venom] + src = "src" + optimizer_runs = 500 + + [profile.ci-venom.fuzz] + runs = 10000 + max_test_rejects = 350000 + + [profile.ci-venom.invariant] + runs = 375 + depth = 500 + "#, + )?; + + jail.set_env("FOUNDRY_PROFILE", "ci-venom"); + let config = + Config::load().expect("hyphenated profile with nested sections should work"); + assert_eq!(config.profile.as_str(), "ci-venom"); + assert_eq!(config.optimizer_runs, Some(500)); + assert_eq!(config.fuzz.runs, 10000); + assert_eq!(config.fuzz.max_test_rejects, 350000); + assert_eq!(config.invariant.runs, 375); + assert_eq!(config.invariant.depth, 500); + + Ok(()) + }); + } + + #[test] + fn coverage_section_in_profile() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r#" + [profile.default.coverage] + report = ["summary", "lcov"] + lcov_version = "2.2.0" + ir_minimum = true + report_file = "out/lcov.info" + include_libs = true + exclude_tests = true + skip_files = ["test/**", "src/mocks/**"] + "#, + )?; + let config = Config::load_with_root(jail.directory()).unwrap(); + assert_eq!( + config.coverage.report, + vec![CoverageReportKind::Summary, CoverageReportKind::Lcov] + ); + assert_eq!(config.coverage.lcov_version, semver::Version::new(2, 2, 0)); + assert!(config.coverage.ir_minimum); + assert_eq!( + config.coverage.report_file.as_deref(), + Some(std::path::Path::new("out/lcov.info")) + ); + assert!(config.coverage.include_libs); + assert!(config.coverage.exclude_tests); + assert_eq!( + config.coverage.skip_files, + vec!["test/**".to_string(), "src/mocks/**".to_string()] + ); + Ok(()) + }); + } + + #[test] + fn coverage_standalone_section_falls_back_to_default_profile() { + figment::Jail::expect_with(|jail| { + // Standalone `[coverage]` should populate the active profile, + // matching how `[fuzz]` / `[invariant]` work. + jail.create_file( + "foundry.toml", + r#" + [coverage] + skip_files = ["script/**"] + exclude_tests = true + "#, + )?; + let config = Config::load_with_root(jail.directory()).unwrap(); + assert_eq!(config.coverage.skip_files, vec!["script/**".to_string()]); + assert!(config.coverage.exclude_tests); + // Untouched fields keep their defaults. + assert_eq!(config.coverage.report, vec![CoverageReportKind::Summary]); + assert_eq!(config.coverage.lcov_version, semver::Version::new(1, 0, 0)); + Ok(()) + }); + } + + #[test] + fn coverage_per_profile_overrides_default() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r#" + [profile.default.coverage] + skip_files = ["script/**"] + + [profile.ci.coverage] + skip_files = ["test/**", "lib/**"] + exclude_tests = true + "#, + )?; + + // Default profile sees the default-profile coverage block. + let config = Config::load_with_root(jail.directory()).unwrap(); + assert_eq!(config.coverage.skip_files, vec!["script/**".to_string()]); + assert!(!config.coverage.exclude_tests); + + // CI profile sees its own override. + jail.set_env("FOUNDRY_PROFILE", "ci"); + let config = Config::load_with_root(jail.directory()).unwrap(); + assert_eq!( + config.coverage.skip_files, + vec!["test/**".to_string(), "lib/**".to_string()] + ); + assert!(config.coverage.exclude_tests); + Ok(()) + }); + } + + #[test] + fn coverage_cache_protects_project_paths() { + let directory = tempdir().unwrap(); + let root = directory.path().join("coverage"); + fs::create_dir(&root).unwrap(); + let mut config = Config::with_root(&root); + config.cache_path = "..".into(); + assert!(config.coverage_cache_path().is_none()); + config.cache_path = "src".into(); + assert!(config.coverage_cache_path().is_none()); + config.cache_path = "cache".into(); + let cache = config.coverage_cache_path().unwrap(); + fs::create_dir_all(&cache).unwrap(); + fs::write(cache.join("user-data"), "preserve").unwrap(); + assert!(config.coverage_cache_path().is_none()); + fs::write(cache.join(Config::COVERAGE_CACHE_MARKER), "").unwrap(); + assert_eq!(config.coverage_cache_path(), Some(cache)); + } + + #[cfg(windows)] + #[test] + fn coverage_cache_uses_compiler_paths() { + let directory = tempdir().unwrap(); + let mut config = Config::with_root(directory.path()); + config.cache_path = "custom-cache".into(); + config.out = "custom-out".into(); + let cache = config.coverage_cache_path().unwrap(); + fs::create_dir_all(&cache).unwrap(); + assert_eq!(cache.as_os_str(), canonicalize(&cache).unwrap().as_os_str()); + fs::write(cache.join(Config::COVERAGE_CACHE_MARKER), "").unwrap(); + assert_eq!(config.coverage_cache_path(), Some(cache)); + } + + #[cfg(unix)] + #[test] + fn coverage_cache_rejects_symlink_overlap() { + let directory = tempdir().unwrap(); + let root = directory.path(); + fs::create_dir(root.join("src")).unwrap(); + std::os::unix::fs::symlink(root.join("src"), root.join("cache")).unwrap(); + let config = Config::with_root(root); + assert!(config.coverage_cache_path().is_none()); + } + + #[test] + fn toml_root_does_not_override_project_root() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r#" + [profile.default] + root = "/elsewhere" + + [profile.ci] + root = "/elsewhere-ci" + + [profile.fmt] + + [profile.root] + + [fmt] + root = "/elsewhere-fmt" + + [rpc_endpoints] + root = "https://example.com" + "#, + )?; + let expected = Config::with_root(jail.directory()).root; + + let config = Config::load_with_root(jail.directory()).unwrap(); + assert_eq!(config.root, expected); + assert!( + config + .warnings + .iter() + .any(|w| matches!(w, crate::Warning::UnknownKey { key, .. } if key == "root")) + ); + assert!(config.rpc_endpoints.contains_key("root")); + assert!(config.profiles.contains(&Profile::new("root"))); + + for profile in ["ci", "fmt"] { + jail.set_env("FOUNDRY_PROFILE", profile); + let config = Config::load_with_root(jail.directory()).unwrap(); + assert_eq!(config.root, expected); + } + Ok(()) + }); + } } diff --git a/crates/config/src/lint.rs b/crates/config/src/lint.rs index fbb80c5d83cb8..d56d05f83ec1e 100644 --- a/crates/config/src/lint.rs +++ b/crates/config/src/lint.rs @@ -3,51 +3,143 @@ use clap::ValueEnum; use core::fmt; use serde::{Deserialize, Deserializer, Serialize}; -use solar_interface::diagnostics::Level; +use solar::{ast, interface::diagnostics::Level}; use std::str::FromStr; use yansi::Paint; -/// Contains the config and rule set +/// Contains the config and rule set. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct LinterConfig { /// Specifies which lints to run based on severity. /// - /// If uninformed, all severities are checked. + /// Defaults to high, medium, and low severity lints. pub severity: Vec, /// Deny specific lints based on their ID (e.g. "mixed-case-function"). pub exclude_lints: Vec, - /// Globs to ignore + /// Globs to ignore. pub ignore: Vec, /// Whether to run linting during `forge build`. /// /// Defaults to true. Set to false to disable automatic linting during builds. pub lint_on_build: bool, + + /// Configuration specific to individual lints. + pub lint_specific: LintSpecificConfig, } + impl Default for LinterConfig { fn default() -> Self { Self { lint_on_build: true, - severity: Vec::new(), + severity: vec![Severity::High, Severity::Med, Severity::Low], exclude_lints: Vec::new(), ignore: Vec::new(), + lint_specific: LintSpecificConfig::default(), } } } -/// Severity of a lint -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Serialize)] +/// Contract types that can be exempted from the multi-contract-file lint. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ContractException { + Interface, + Library, + AbstractContract, +} + +/// Configuration specific to individual lints. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct LintSpecificConfig { + /// Configurable patterns that should be excluded when performing `mixedCase` and + /// `PascalCase` lint checks. + /// + /// Defaults to ["ERC", "URI", "ID", "URL", "API", "JSON", "XML", "HTML", "HTTP", + /// "HTTPS"] to allow common names like `rescueERC20`, `ERC721TokenReceiver` or `tokenURI`. + pub mixed_case_exceptions: Vec, + + /// Contract types that are allowed to appear multiple times in the same file. + /// + /// Valid values: "interface", "library", "abstract_contract" + /// + /// Defaults to an empty array (all contract types are flagged when multiple exist). + /// Note: Regular contracts cannot be exempted and will always be flagged when multiple exist. + pub multi_contract_file_exceptions: Vec, +} + +impl Default for LintSpecificConfig { + fn default() -> Self { + Self { + mixed_case_exceptions: vec![ + "ERC".to_string(), + "URI".to_string(), + "ID".to_string(), + "URL".to_string(), + "API".to_string(), + "JSON".to_string(), + "XML".to_string(), + "HTML".to_string(), + "HTTP".to_string(), + "HTTPS".to_string(), + ], + multi_contract_file_exceptions: Vec::new(), + } + } +} + +impl LintSpecificConfig { + /// Checks if a given contract kind is included in the list of exceptions + pub fn is_exempted(&self, contract_kind: &ast::ContractKind) -> bool { + let exception_to_check = match contract_kind { + ast::ContractKind::Interface => ContractException::Interface, + ast::ContractKind::Library => ContractException::Library, + ast::ContractKind::AbstractContract => ContractException::AbstractContract, + // Regular contracts are always linted + ast::ContractKind::Contract => return false, + }; + + self.multi_contract_file_exceptions.contains(&exception_to_check) + } +} + +/// Severity of a lint. +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub enum Severity { High, Med, Low, Info, Gas, + CodeSize, } impl Severity { + const fn to_str(self) -> &'static str { + match self { + Self::High => "High", + Self::Med => "Med", + Self::Low => "Low", + Self::Info => "Info", + Self::Gas => "Gas", + Self::CodeSize => "CodeSize", + } + } + + const fn to_str_kebab(self) -> &'static str { + match self { + Self::High => "high", + Self::Med => "medium", + Self::Low => "low", + Self::Info => "info", + Self::Gas => "gas", + Self::CodeSize => "code-size", + } + } + pub fn color(&self, message: &str) -> String { match self { Self::High => Paint::red(message).bold().to_string(), @@ -55,6 +147,7 @@ impl Severity { Self::Low => Paint::yellow(message).bold().to_string(), Self::Info => Paint::cyan(message).bold().to_string(), Self::Gas => Paint::green(message).bold().to_string(), + Self::CodeSize => Paint::green(message).bold().to_string(), } } } @@ -63,21 +156,23 @@ impl From for Level { fn from(severity: Severity) -> Self { match severity { Severity::High | Severity::Med | Severity::Low => Self::Warning, - Severity::Info | Severity::Gas => Self::Note, + Severity::Info | Severity::Gas | Severity::CodeSize => Self::Note, } } } impl fmt::Display for Severity { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let colored = match self { - Self::High => self.color("High"), - Self::Med => self.color("Med"), - Self::Low => self.color("Low"), - Self::Info => self.color("Info"), - Self::Gas => self.color("Gas"), - }; - write!(f, "{colored}") + write!(f, "{}", self.color(self.to_str())) + } +} + +impl Serialize for Severity { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.to_str_kebab().serialize(serializer) } } @@ -102,8 +197,9 @@ impl FromStr for Severity { "low" => Ok(Self::Low), "info" => Ok(Self::Info), "gas" => Ok(Self::Gas), + "size" | "codesize" | "code-size" => Ok(Self::CodeSize), _ => Err(format!( - "unknown variant: found `{s}`, expected `one of `High`, `Med`, `Low`, `Info`, `Gas``" + "unknown variant: found `{s}`, expected `one of `High`, `Med`, `Low`, `Info`, `Gas`, `CodeSize`" )), } } diff --git a/crates/config/src/mutation.rs b/crates/config/src/mutation.rs new file mode 100644 index 0000000000000..d0daf2a5d4e06 --- /dev/null +++ b/crates/config/src/mutation.rs @@ -0,0 +1,90 @@ +//! Configuration for mutation testing. + +use serde::{Deserialize, Serialize}; +use strum::IntoEnumIterator; + +/// Represents each available mutation operator. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + Serialize, + Deserialize, + strum::Display, + strum::EnumString, + strum::EnumIter, +)] +#[serde(rename_all = "kebab-case")] +#[strum(serialize_all = "kebab-case")] +pub enum MutatorType { + Assembly, + Assignment, + BinaryOp, + DeleteExpression, + ElimDelegate, + Require, + UnaryOp, +} + +impl MutatorType { + /// Returns a list of all available mutator types. + pub fn all() -> Vec { + Self::iter().collect() + } + + /// Returns the operators that are excluded by default. + pub const fn default_excluded() -> Vec { + Vec::new() + } +} + +/// Configuration for mutation testing. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct MutationConfig { + /// Re-enable operators that are excluded by default. + pub include_operators: Vec, + /// Exclude additional operators beyond the defaults. + pub exclude_operators: Vec, + /// Per-mutant wall-clock timeout, in seconds. + /// + /// When set, each mutant's compile-and-test work is bounded by this + /// duration; mutants that exceed it are recorded as `TimedOut`. This is + /// the analog of `invariant.timeout` for mutation campaigns. + /// + /// Note: enforcement is best-effort. Background work for a timed-out + /// mutant may continue briefly until the underlying compile / test loop + /// reaches a checkpoint, but the worker slot is freed immediately so + /// other mutants can proceed. Cleanup backlog is bounded by the configured + /// mutation worker count. + pub timeout: Option, + /// Override `optimizer_runs` for mutation testing compile-and-test runs. + /// + /// This lets mutation campaigns use a faster compiler profile without + /// changing the project's normal build settings. + pub optimizer_runs: Option, + /// Override `via_ir` for mutation testing compile-and-test runs. + /// + /// This lets mutation campaigns disable the IR pipeline without changing + /// the project's normal build settings. + pub via_ir: Option, +} + +impl MutationConfig { + /// Returns the list of operators that are currently enabled. + /// + /// Effective set: `all() - default_excluded - exclude_operators + include_operators` + pub fn enabled_operators(&self) -> Vec { + let default_excluded = MutatorType::default_excluded(); + MutatorType::all() + .into_iter() + .filter(|op| { + let excluded = default_excluded.contains(op) || self.exclude_operators.contains(op); + let included = self.include_operators.contains(op); + !excluded || included + }) + .collect() + } +} diff --git a/crates/config/src/providers/ext.rs b/crates/config/src/providers/ext.rs index 79bd47b1b3678..ab9f9a39d3ad0 100644 --- a/crates/config/src/providers/ext.rs +++ b/crates/config/src/providers/ext.rs @@ -1,4 +1,4 @@ -use crate::{Config, utils}; +use crate::{Config, extend, utils}; use figment::{ Error, Figment, Metadata, Profile, Provider, providers::{Env, Format, Toml}, @@ -6,7 +6,10 @@ use figment::{ }; use foundry_compilers::ProjectPathsConfig; use heck::ToSnakeCase; -use std::path::{Path, PathBuf}; +use std::{ + cell::OnceCell, + path::{Path, PathBuf}, +}; pub(crate) trait ProviderExt: Provider + Sized { fn rename( @@ -39,6 +42,10 @@ pub(crate) trait ProviderExt: Provider + Sized { ) -> FallbackProfileProvider { FallbackProfileProvider::new(self, profile, fallback) } + + fn legacy_labels(self) -> LegacyLabelsProvider { + LegacyLabelsProvider(self) + } } impl ProviderExt for P {} @@ -46,18 +53,19 @@ impl ProviderExt for P {} /// A convenience provider to retrieve a toml file. /// This will return an error if the env var is set but the file does not exist pub(crate) struct TomlFileProvider { - pub env_var: Option<&'static str>, - pub default: PathBuf, - pub cache: Option, Error>>, + env_var: Option<&'static str>, + env_val: OnceCell>, + default: PathBuf, + cache: OnceCell, Error>>, } impl TomlFileProvider { - pub(crate) fn new(env_var: Option<&'static str>, default: impl Into) -> Self { - Self { env_var, default: default.into(), cache: None } + pub(crate) const fn new(env_var: Option<&'static str>, default: PathBuf) -> Self { + Self { env_var, env_val: OnceCell::new(), default, cache: OnceCell::new() } } - fn env_val(&self) -> Option { - self.env_var.and_then(Env::var) + fn env_val(&self) -> Option<&str> { + self.env_val.get_or_init(|| self.env_var.and_then(Env::var)).as_deref() } fn file(&self) -> PathBuf { @@ -74,28 +82,291 @@ impl TomlFileProvider { false } - pub(crate) fn cached(mut self) -> Self { - self.cache = Some(self.read()); - self - } - + /// Reads and processes the TOML configuration file, handling inheritance if configured. fn read(&self) -> Result, Error> { use serde::de::Error as _; - if let Some(file) = self.env_val() { - let path = Path::new(&file); - if !path.exists() { + + // Get the config file path and validate it exists + let local_path = self.file(); + if !local_path.exists() { + if let Some(file) = self.env_val() { return Err(Error::custom(format!( "Config file `{}` set in env var `{}` does not exist", file, self.env_var.unwrap() ))); } - Toml::file(file) + return Ok(Map::new()); + } + + // Create a provider for the local config file + let local_provider = Toml::file(local_path.clone()).nested(); + + // Parse the local config to check for extends field + let local_path_str = local_path.to_string_lossy(); + let local_content = std::fs::read_to_string(&local_path) + .map_err(|e| Error::custom(e.to_string()).with_path(&local_path_str))?; + let partial_config: extend::ExtendsPartialConfig = toml::from_str(&local_content) + .map_err(|e| Error::custom(e.to_string()).with_path(&local_path_str))?; + + // Check if the currently active profile has an 'extends' field + let selected_profile = Config::selected_profile(); + let extends_config = partial_config.profile.as_ref().and_then(|profiles| { + let profile_str = selected_profile.to_string(); + profiles.get(&profile_str).and_then(|cfg| cfg.extends.as_ref()) + }); + + // If inheritance is configured, load and merge the base config + if let Some(extends_config) = extends_config { + let extends_path = extends_config.path(); + let extends_strategy = extends_config.strategy(); + let relative_base_path = PathBuf::from(extends_path); + let local_dir = local_path.parent().ok_or_else(|| { + Error::custom(format!( + "Could not determine parent directory of config file: {}", + local_path.display() + )) + })?; + + let base_path = + foundry_compilers::utils::canonicalize(local_dir.join(&relative_base_path)) + .map_err(|e| { + Error::custom(format!( + "Failed to resolve inherited config path: {}: {e}", + relative_base_path.display() + )) + })?; + + // Validate the base config file exists + if !base_path.is_file() { + return Err(Error::custom(format!( + "Inherited config file does not exist or is not a file: {}", + base_path.display() + ))); + } + + // Prevent self-inheritance which would cause infinite recursion + if foundry_compilers::utils::canonicalize(&local_path).ok().as_ref() == Some(&base_path) + { + return Err(Error::custom(format!( + "Config file {} cannot inherit from itself.", + local_path.display() + ))); + } + + // Parse the base config to check for nested inheritance + let base_path_str = base_path.to_string_lossy(); + let base_content = std::fs::read_to_string(&base_path) + .map_err(|e| Error::custom(e.to_string()).with_path(&base_path_str))?; + let base_partial: extend::ExtendsPartialConfig = toml::from_str(&base_content) + .map_err(|e| Error::custom(e.to_string()).with_path(&base_path_str))?; + + // Check if the base file's same profile also has extends (nested inheritance) + let base_extends = base_partial + .profile + .as_ref() + .and_then(|profiles| { + let profile_str = selected_profile.to_string(); + profiles.get(&profile_str) + }) + .and_then(|profile| profile.extends.as_ref()); + + // Prevent nested inheritance to avoid complexity and potential cycles + if base_extends.is_some() { + return Err(Error::custom(format!( + "Nested inheritance is not allowed. Base file '{}' cannot have an 'extends' field in profile '{selected_profile}'.", + base_path.display() + ))); + } + + // Normalize standalone sections before merging so equivalent profile-qualified values + // have the same shape across inherited files. + let base_provider = NormalizeSymbolicProvider::new( + NormalizeTracingProvider::new( + Toml::file(base_path).nested().legacy_labels(), + selected_profile.clone(), + ), + selected_profile.clone(), + ); + let local_provider = NormalizeSymbolicProvider::new( + NormalizeTracingProvider::new( + local_provider.legacy_labels(), + selected_profile.clone(), + ), + selected_profile.clone(), + ); + + // Apply the selected merge strategy + match extends_strategy { + extend::ExtendStrategy::ExtendArrays => { + // Using 'admerge' strategy: + // - Arrays are concatenated (base elements + local elements) + // - Other values are replaced (local values override base values) + // - The extends field is preserved in the final configuration + Figment::new().merge(base_provider).admerge(local_provider).data() + } + extend::ExtendStrategy::ReplaceArrays => { + // Using 'merge' strategy: + // - Arrays are replaced entirely (local arrays replace base arrays) + // - Other values are replaced (local values override base values) + Figment::new().merge(base_provider).merge(local_provider).data() + } + extend::ExtendStrategy::NoCollision => { + // Check for key collisions between base and local configs + let base_data = base_provider.data()?; + let local_data = local_provider.data()?; + + let profile_key = Profile::new("profile"); + if let (Some(local_profiles), Some(base_profiles)) = + (local_data.get(&profile_key), base_data.get(&profile_key)) + { + // Extract dicts for the selected profile + let profile_str = selected_profile.to_string(); + let base_dict = base_profiles.get(&profile_str).and_then(|v| v.as_dict()); + let local_dict = local_profiles.get(&profile_str).and_then(|v| v.as_dict()); + + // Find colliding keys + if let (Some(local_dict), Some(base_dict)) = (local_dict, base_dict) { + let collisions: Vec<&String> = local_dict + .keys() + .filter(|key| { + // Ignore the "extends" key as it's expected + *key != "extends" && base_dict.contains_key(*key) + }) + .collect(); + + if !collisions.is_empty() { + return Err(Error::custom(format!( + "Key collision detected in profile '{profile_str}' when extending '{extends_path}'. \ + Conflicting keys: {collisions:?}. Use 'extends.strategy' or 'extends_strategy' to specify how to handle conflicts." + ))); + } + } + } + + // Safe to merge the configs without collisions + Figment::new().merge(base_provider).merge(local_provider).data() + } + } } else { - Toml::file(&self.default) + // No inheritance - return the local config as-is + local_provider.data() + } + } +} + +struct NormalizeSymbolicProvider

{ + provider: P, + selected_profile: Profile, +} + +impl

NormalizeSymbolicProvider

{ + const fn new(provider: P, selected_profile: Profile) -> Self { + Self { provider, selected_profile } + } +} + +struct NormalizeTracingProvider

{ + provider: P, + selected_profile: Profile, +} + +impl

NormalizeTracingProvider

{ + const fn new(provider: P, selected_profile: Profile) -> Self { + Self { provider, selected_profile } + } +} + +impl Provider for NormalizeTracingProvider

{ + fn metadata(&self) -> Metadata { + self.provider.metadata() + } + + fn data(&self) -> Result, Error> { + let mut data = self.provider.data()?; + normalize_tracing_section(&mut data, &self.selected_profile); + Ok(data) + } + + fn profile(&self) -> Option { + self.provider.profile() + } +} + +/// Moves the standalone tracing section into the selected profile before inherited configs are +/// merged. The deprecated standalone labels section remains in place for warning generation and +/// is normalized again after inheritance is resolved. +fn normalize_tracing_section(data: &mut Map, selected_profile: &Profile) { + let Some(tracing) = data.remove(&Profile::new("tracing")) else { return }; + + let profiles = data.entry(Profile::new(Config::PROFILE_SECTION)).or_default(); + let profile = + profiles.entry(selected_profile.to_string()).or_insert_with(|| Value::from(Dict::new())); + let Value::Dict(_, profile) = profile else { return }; + + match (profile.get_mut("tracing"), tracing) { + (Some(Value::Dict(_, profile_tracing)), tracing) => { + merge_missing(profile_tracing, tracing); + } + (None, tracing) => { + profile.insert("tracing".to_string(), Value::from(tracing)); + } + _ => {} + } +} + +impl Provider for NormalizeSymbolicProvider

{ + fn metadata(&self) -> Metadata { + self.provider.metadata() + } + + fn data(&self) -> Result, Error> { + let mut data = self.provider.data()?; + normalize_symbolic_section(&mut data, &self.selected_profile); + Ok(data) + } + + fn profile(&self) -> Option { + self.provider.profile() + } +} + +/// Moves the standalone symbolic section into the selected profile before inherited configs are +/// merged. +/// +/// This gives equivalent standalone and profile-qualified keys the same shape, so source +/// precedence and collision detection apply consistently across inherited files. +fn normalize_symbolic_section(data: &mut Map, selected_profile: &Profile) { + let Some(symbolic) = data.remove(&Profile::new("symbolic")) else { return }; + + let profiles = data.entry(Profile::new(Config::PROFILE_SECTION)).or_default(); + let profile = + profiles.entry(selected_profile.to_string()).or_insert_with(|| Value::from(Dict::new())); + let Value::Dict(_, profile) = profile else { return }; + + match (profile.get_mut("symbolic"), symbolic) { + (Some(Value::Dict(_, profile_symbolic)), symbolic) => { + merge_missing(profile_symbolic, symbolic); + } + (None, symbolic) => { + profile.insert("symbolic".to_string(), Value::from(symbolic)); + } + _ => {} + } +} + +/// Recursively fills missing values while preserving values from the higher-precedence source. +fn merge_missing(target: &mut Dict, fallback: Dict) { + for (key, value) in fallback { + match (target.get_mut(&key), value) { + (Some(Value::Dict(_, target)), Value::Dict(_, fallback)) => { + merge_missing(target, fallback); + } + (None, value) => { + target.insert(key, value); + } + _ => {} } - .nested() - .data() } } @@ -109,12 +380,15 @@ impl Provider for TomlFileProvider { } fn data(&self) -> Result, Error> { - if let Some(cache) = self.cache.as_ref() { cache.clone() } else { self.read() } + self.cache.get_or_init(|| self.read()).clone() } } -/// A Provider that ensures all keys are snake case if they're not standalone sections, See +/// A Provider that ensures all keys are snake case if they're not standalone sections. See /// `Config::STANDALONE_SECTIONS` +/// +/// For the `[profile]` section, profile names (like `ci-venom`) are preserved as-is, +/// but the top-level config keys within each profile are still converted to snake_case. pub(crate) struct ForcedSnakeCaseData

(pub(crate) P); impl Provider for ForcedSnakeCaseData

{ @@ -123,17 +397,48 @@ impl Provider for ForcedSnakeCaseData

{ } fn data(&self) -> Result, Error> { - let mut map = Map::new(); - for (profile, dict) in self.0.data()? { + let mut map = self.0.data()?; + for (profile, dict) in &mut map { if Config::STANDALONE_SECTIONS.contains(&profile.as_ref()) { // don't force snake case for keys in standalone sections - map.insert(profile, dict); continue; } - map.insert(profile, dict.into_iter().map(|(k, v)| (k.to_snake_case(), v)).collect()); + + if profile.as_str().as_str() == Config::PROFILE_SECTION { + // For the `[profile]` section, we need to preserve profile names (the keys) + // but snake_case the top-level config keys within each profile's dict. + let dict2 = std::mem::take(dict); + *dict = dict2 + .into_iter() + .map(|(profile_name, v)| { + // Keep the profile name exactly as-is (e.g., "ci-venom" stays "ci-venom") + let v = snake_case_profile_keys(v); + (profile_name, v) + }) + .collect(); + continue; + } + + let dict2 = std::mem::take(dict); + *dict = dict2.into_iter().map(|(k, v)| (k.to_snake_case(), v)).collect(); } Ok(map) } + + fn profile(&self) -> Option { + self.0.profile() + } +} + +/// Converts the top-level config keys in a profile value to snake_case. +fn snake_case_profile_keys(value: Value) -> Value { + match value { + Value::Dict(tag, dict) => { + let new_dict = dict.into_iter().map(|(k, v)| (k.to_snake_case(), v)).collect(); + Value::Dict(tag, new_dict) + } + other => other, + } } /// A Provider that handles breaking changes in toml files @@ -145,33 +450,135 @@ impl Provider for BackwardsCompatTomlProvider

{ } fn data(&self) -> Result, Error> { - let mut map = Map::new(); + let mut map = self.0.data()?; let solc_env = std::env::var("FOUNDRY_SOLC_VERSION") .or_else(|_| std::env::var("DAPP_SOLC_VERSION")) .map(Value::from) .ok(); - for (profile, mut dict) in self.0.data()? { - if let Some(v) = solc_env.clone() { - // ENV var takes precedence over config file - dict.insert("solc".to_string(), v); - } else if let Some(v) = dict.remove("solc_version") { - // only insert older variant if not already included - if !dict.contains_key("solc") { - dict.insert("solc".to_string(), v); + for (profile, dict) in &mut map { + if profile.as_str().as_str() == Config::PROFILE_SECTION { + for value in dict.values_mut() { + if let Value::Dict(_, dict) = value { + rewrite_legacy_profile_keys(dict, solc_env.as_ref()); + } } + } else if !Config::STANDALONE_SECTIONS.contains(&profile.as_ref()) { + rewrite_legacy_profile_keys(dict, solc_env.as_ref()); } + } + normalize_legacy_labels(&mut map); + Ok(map) + } + + fn profile(&self) -> Option { + self.0.profile() + } +} + +/// Applies compiler overrides and legacy key rewrites to a profile dictionary. +fn rewrite_legacy_profile_keys(dict: &mut Dict, solc_env: Option<&Value>) { + if let Some(v) = solc_env { + // ENV var takes precedence over config file. + dict.insert("solc".to_string(), v.clone()); + } else if let Some(v) = dict.remove("solc_version") { + // Only insert the older variant if not already included. + dict.entry("solc".to_string()).or_insert(v); + } + if let Some(v) = dict.remove("deny_warnings") { + dict.entry("deny".to_string()).or_insert(v); + } +} + +/// Drops the top-level `root` key from config file profiles. +/// +/// The project root is selected by the caller, not by config files. +pub(crate) struct IgnoreRootProvider

(pub(crate) P); + +impl Provider for IgnoreRootProvider

{ + fn metadata(&self) -> Metadata { + self.0.metadata() + } - if let Some(v) = dict.remove("odyssey") { - dict.insert("odyssey".to_string(), v); + fn data(&self) -> Result, Error> { + let mut map = self.0.data()?; + for (profile, dict) in &mut map { + // `[profile]` keys are profile names, so `[profile.root]` must be kept. + if profile.as_str().as_str() != Config::PROFILE_SECTION { + dict.remove("root"); } - map.insert(profile, dict); } Ok(map) } + + fn profile(&self) -> Option { + self.0.profile() + } +} + +/// Adapts deprecated labels from arbitrary external providers. +pub(crate) struct LegacyLabelsProvider

(pub(crate) P); + +impl Provider for LegacyLabelsProvider

{ + fn metadata(&self) -> Metadata { + self.0.metadata() + } + + fn data(&self) -> Result, Error> { + let mut map = self.0.data()?; + normalize_legacy_labels(&mut map); + Ok(map) + } + + fn profile(&self) -> Option { + self.0.profile() + } +} + +pub(crate) fn normalize_legacy_labels(map: &mut Map) { + if let Some(labels) = map.get(&Profile::new("labels")).cloned() { + merge_tracing_labels(&labels, map.entry(Profile::new("tracing")).or_default()); + } + + for (profile, dict) in map { + if profile.as_str().as_str() == Config::PROFILE_SECTION { + for value in dict.values_mut() { + if let Value::Dict(_, profile) = value { + normalize_legacy_labels_in_profile(profile); + } + } + } else if !Config::STANDALONE_SECTIONS.contains(&profile.as_ref()) { + normalize_legacy_labels_in_profile(dict); + } + } +} + +pub(crate) fn normalize_legacy_labels_in_profile(dict: &mut Dict) { + let Some(Value::Dict(_, labels)) = dict.get("labels").cloned() else { return }; + let tracing = dict.entry("tracing".to_string()).or_insert_with(|| Dict::new().into()); + if let Value::Dict(_, tracing) = tracing { + merge_tracing_labels(&labels, tracing); + } +} + +fn merge_tracing_labels(legacy: &Dict, tracing: &mut Dict) { + match tracing.get("labels") { + Some(Value::Dict(_, configured)) => { + let mut labels = legacy.clone(); + labels.extend(configured.clone()); + tracing.insert("labels".to_string(), labels.into()); + } + Some(_) => {} + None => { + tracing.insert("labels".to_string(), legacy.clone().into()); + } + } } /// A provider that sets the `src` and `output` path depending on their existence. -pub(crate) struct DappHardhatDirProvider<'a>(pub(crate) &'a Path); +pub(crate) struct DappHardhatDirProvider<'a> { + pub(crate) root: &'a Path, + pub(crate) detect_src: bool, +} impl Provider for DappHardhatDirProvider<'_> { fn metadata(&self) -> Metadata { @@ -180,18 +587,20 @@ impl Provider for DappHardhatDirProvider<'_> { fn data(&self) -> Result, Error> { let mut dict = Dict::new(); - dict.insert( - "src".to_string(), - ProjectPathsConfig::find_source_dir(self.0) - .file_name() - .unwrap() - .to_string_lossy() - .to_string() - .into(), - ); + if self.detect_src { + dict.insert( + "src".to_string(), + ProjectPathsConfig::find_source_dir(self.root) + .file_name() + .unwrap() + .to_string_lossy() + .to_string() + .into(), + ); + } dict.insert( "out".to_string(), - ProjectPathsConfig::find_artifacts_dir(self.0) + ProjectPathsConfig::find_artifacts_dir(self.root) .file_name() .unwrap() .to_string_lossy() @@ -204,8 +613,8 @@ impl Provider for DappHardhatDirProvider<'_> { // if only `node_modules` exists: include `node_modules` // include `lib` otherwise let mut libs = vec![]; - let node_modules = self.0.join("node_modules"); - let lib = self.0.join("lib"); + let node_modules = self.root.join("node_modules"); + let lib = self.root.join("lib"); if node_modules.exists() { if lib.exists() { libs.push(lib.file_name().unwrap().to_string_lossy().to_string()); @@ -335,6 +744,7 @@ impl Provider for RenameProfileProvider

{ fn metadata(&self) -> Metadata { self.provider.metadata() } + fn data(&self) -> Result, Error> { let mut data = self.provider.data()?; if let Some(data) = data.remove(&self.from) { @@ -342,6 +752,7 @@ impl Provider for RenameProfileProvider

{ } Ok(Default::default()) } + fn profile(&self) -> Option { Some(self.to.clone()) } @@ -378,31 +789,32 @@ impl Provider for UnwrapProfileProvider

{ fn metadata(&self) -> Metadata { self.provider.metadata() } + fn data(&self) -> Result, Error> { - self.provider.data().and_then(|mut data| { - if let Some(profiles) = data.remove(&self.wrapping_key) { - for (profile_str, profile_val) in profiles { - let profile = Profile::new(&profile_str); - if profile != self.profile { - continue; - } - match profile_val { - Value::Dict(_, dict) => return Ok(profile.collect(dict)), - bad_val => { - let mut err = Error::from(figment::error::Kind::InvalidType( - bad_val.to_actual(), - "dict".into(), - )); - err.metadata = Some(self.provider.metadata()); - err.profile = Some(self.profile.clone()); - return Err(err); - } + let mut data = self.provider.data()?; + if let Some(profiles) = data.remove(&self.wrapping_key) { + for (profile_str, profile_val) in profiles { + let profile = Profile::new(&profile_str); + if profile != self.profile { + continue; + } + match profile_val { + Value::Dict(_, dict) => return Ok(profile.collect(dict)), + bad_val => { + let mut err = Error::from(figment::error::Kind::InvalidType( + bad_val.to_actual(), + "dict".into(), + )); + err.metadata = Some(self.provider.metadata()); + err.profile = Some(self.profile.clone()); + return Err(err); } } } - Ok(Default::default()) - }) + } + Ok(Default::default()) } + fn profile(&self) -> Option { Some(self.profile.clone()) } @@ -439,15 +851,18 @@ impl Provider for WrapProfileProvider

{ fn metadata(&self) -> Metadata { self.provider.metadata() } + fn data(&self) -> Result, Error> { if let Some(inner) = self.provider.data()?.remove(&self.profile) { let value = Value::from(inner); - let dict = [(self.profile.to_string().to_snake_case(), value)].into_iter().collect(); + let mut dict = Dict::new(); + dict.insert(self.profile.as_str().as_str().to_snake_case(), value); Ok(self.wrapping_key.collect(dict)) } else { Ok(Default::default()) } } + fn profile(&self) -> Option { Some(self.profile.clone()) } @@ -492,6 +907,7 @@ impl Provider for OptionalStrictProfileProvider

{ fn metadata(&self) -> Metadata { self.provider.metadata() } + fn data(&self) -> Result, Error> { let mut figment = Figment::from(&self.provider); for profile in &self.profiles { @@ -512,6 +928,7 @@ impl Provider for OptionalStrictProfileProvider

{ err }) } + fn profile(&self) -> Option { self.profiles.last().cloned() } @@ -538,16 +955,33 @@ impl Provider for FallbackProfileProvider

{ } fn data(&self) -> Result, Error> { - let data = self.provider.data()?; - if let Some(fallback) = data.get(&self.fallback) { - let mut inner = data.get(&self.profile).cloned().unwrap_or_default(); - for (k, v) in fallback { - if !inner.contains_key(k) { - inner.insert(k.to_owned(), v.clone()); + let mut data = self.provider.data()?; + let invariant_corpus_random_sequence_weight_configured = self.profile == "invariant" + && data + .get(&self.profile) + .is_some_and(|inner| inner.contains_key("corpus_random_sequence_weight")); + let mark_invariant_corpus_random_sequence_weight_configured = + |inner: &mut Dict| -> Result<(), Error> { + if invariant_corpus_random_sequence_weight_configured { + inner.insert( + "corpus_random_sequence_weight_configured".to_string(), + Value::serialize(true)?, + ); } + Ok(()) + }; + + if let Some(fallback) = data.remove(&self.fallback) { + let mut inner = data.remove(&self.profile).unwrap_or_default(); + for (k, v) in fallback { + inner.entry(k).or_insert(v); } + mark_invariant_corpus_random_sequence_weight_configured(&mut inner)?; Ok(self.profile.collect(inner)) } else { + if let Some(inner) = data.get_mut(&self.profile) { + mark_invariant_corpus_random_sequence_weight_configured(inner)?; + } Ok(data) } } diff --git a/crates/config/src/providers/remappings.rs b/crates/config/src/providers/remappings.rs index 8bd2d5cacb2a3..a9f80f4e552a6 100644 --- a/crates/config/src/providers/remappings.rs +++ b/crates/config/src/providers/remappings.rs @@ -1,16 +1,36 @@ -use crate::{Config, foundry_toml_dirs, remappings_from_env_var, remappings_from_newline}; +use crate::{ + Config, FigmentProviders, foundry_toml_dir_entries, remappings_from_env_var, + remappings_from_newline, +}; use figment::{ Error, Figment, Metadata, Profile, Provider, value::{Dict, Map}, }; -use foundry_compilers::artifacts::remappings::{RelativeRemapping, Remapping}; +use foundry_compilers::artifacts::remappings::{RelativeRemapping, Remapping, RemappingDiscovery}; +use rayon::prelude::*; use std::{ borrow::Cow, - collections::{BTreeMap, HashSet, btree_map::Entry}, + cmp::Reverse, + collections::{ + BTreeMap, BTreeSet, HashMap, HashSet, btree_map::Entry, hash_map::Entry as HashEntry, + }, fs, - path::{Path, PathBuf}, + path::{Component, MAIN_SEPARATOR, Path, PathBuf}, }; +const GENERATED_REMAPPINGS_KEY: &str = "__generated_remappings"; + +#[derive(Default)] +struct RemappingsOutput { + /// All explicit, dependency, and auto-detected remappings exposed to the config. + remappings: Vec, + /// Contextual dependency refinements generated by this provider. + /// + /// These are tracked so the later CLI merge can distinguish them from equivalent explicit + /// contextual remappings. + generated_contextual_remappings: Vec, +} + /// Wrapper types over a `Vec` that only appends unique remappings. #[derive(Clone, Debug, Default)] pub struct Remappings { @@ -23,12 +43,12 @@ pub struct Remappings { impl Remappings { /// Create a new `Remappings` wrapper with an empty vector. - pub fn new() -> Self { + pub const fn new() -> Self { Self { remappings: Vec::new(), project_paths: Vec::new() } } /// Create a new `Remappings` wrapper with a vector of remappings. - pub fn new_with_remappings(remappings: Vec) -> Self { + pub const fn new_with_remappings(remappings: Vec) -> Self { Self { remappings, project_paths: Vec::new() } } @@ -49,25 +69,21 @@ impl Remappings { self } - /// Filters the remappings vector by name and context. - fn filter_key(r: &Remapping) -> String { - match &r.context { - Some(str) => str.clone() + &r.name.clone(), - None => r.name.clone(), - } - } - /// Consumes the wrapper and returns the inner remappings vector. pub fn into_inner(self) -> Vec { let mut seen = HashSet::new(); - self.remappings.iter().filter(|r| seen.insert(Self::filter_key(r))).cloned().collect() + self.remappings + .iter() + .filter(|r| seen.insert((r.context.as_deref(), r.name.as_str()))) + .cloned() + .collect() } /// Push an element to the remappings vector, but only if it's not already present. - fn push(&mut self, remapping: Remapping) { + fn push(&mut self, remapping: Remapping) -> bool { // Special handling for .sol file remappings, only allow one remapping per source file. if remapping.name.ends_with(".sol") && !remapping.path.ends_with(".sol") { - return; + return false; } if self.remappings.iter().any(|existing| { @@ -78,25 +94,20 @@ impl Remappings { && existing.path == remapping.path; } - // What we're doing here is filtering for ambiguous paths. For example, if we have - // @prb/math/=node_modules/@prb/math/src/ as existing, and - // @prb/=node_modules/@prb/ as the one being checked, - // we want to keep the already existing one, which is the first one. This way we avoid - // having to deal with ambiguous paths which is unwanted when autodetecting remappings. - // Remappings are added from root of the project down to libraries, so - // we also want to exclude any conflicting remappings added from libraries. For example, - // if we have `@utils/=src/` added in project remappings and `@utils/libraries/=src/` - // added in a dependency, we don't want to add the new one as it conflicts with project - // existing remapping. - let mut existing_name_path = existing.name.clone(); - if !existing_name_path.ends_with('/') { - existing_name_path.push('/') - } - let is_conflicting = remapping.name.starts_with(&existing_name_path) - || existing.name.starts_with(&remapping.name); - is_conflicting && existing.context == remapping.context + // Autodetected remappings are added from the root project down through its libraries, + // so an existing root alias remains authoritative over an equal or more specific + // dependency alias. For example, an existing `@utils/=src/` suppresses an incoming + // `@utils/libraries/=lib/utils/`, preventing a dependency from overriding part of the + // root namespace. The reverse direction is intentional: an existing + // `@prb/math/=src/math/` can coexist with an incoming `@prb/=lib/prb/`; the root alias + // resolves its subtree while the dependency alias acts as a fallback for the rest of + // the namespace. + // Compare without one optional trailing slash so `pkg` and `pkg/` denote the same + // alias while preserving significant repeated slashes. + remapping_name_is_prefix(&existing.name, &remapping.name) + && existing.context == remapping.context }) { - return; + return false; }; // Ignore remappings of root project src, test or script dir. @@ -106,10 +117,11 @@ impl Remappings { .iter() .any(|project_path| remapping.name.eq_ignore_ascii_case(&project_path.name)) { - return; + return false; }; self.remappings.push(remapping); + true } /// Extend the remappings vector, leaving out the remappings that are already present. @@ -118,6 +130,46 @@ impl Remappings { self.push(remapping); } } + + /// Extract generated contextual refinements from a Figment. + pub fn generated_from_figment(figment: &Figment) -> Vec { + figment.extract_inner(GENERATED_REMAPPINGS_KEY).unwrap_or_default() + } + + /// Merge config remappings while preserving CLI precedence over generated refinements. + /// + /// Narrow CLI aliases are overlaid into generated contexts; explicit contexts are unchanged. + pub fn extend_with_config_remappings( + &mut self, + config_remappings: Vec, + generated_contextual_remappings: &[Remapping], + ) { + let authoritative = self.remappings.clone(); + let mut suppressed = HashSet::new(); + let mut overlays = Vec::new(); + for (index, remapping) in config_remappings.iter().enumerate().filter(|(_, remapping)| { + remapping.context.is_some() && generated_contextual_remappings.contains(remapping) + }) { + if let Some(contextual) = contextual_overlays(&authoritative, remapping) { + overlays.extend(contextual); + } else { + suppressed.insert(index); + } + } + self.extend(overlays); + for (index, remapping) in config_remappings.into_iter().enumerate() { + if !suppressed.contains(&index) { + self.push(remapping); + } + } + } +} + +struct CachedNestedConfig { + src: PathBuf, + libs: Vec, + remappings: Vec, + file_remappings: Vec, } /// A figment provider that checks if the remappings were previously set and if they're unset looks @@ -151,10 +203,11 @@ impl RemappingsProvider<'_> { /// - `remappings.txt` /// - Environment variables /// - CLI parameters - fn get_remappings(&self, remappings: Vec) -> Result, Error> { + fn get_remappings(&self, remappings: Vec) -> Result { trace!("get all remappings from {:?}", self.root); - /// prioritizes remappings that are closer: shorter `path` + /// Prioritizes remappings by shortest path, then a `src` target, then lexical path. /// - ("a", "1/2") over ("a", "1/2/3") + /// - ("a", "1/src") over ("a", "1/lib") /// /// grouped by remapping context fn insert_closest( @@ -165,13 +218,16 @@ impl RemappingsProvider<'_> { ) { let context_mappings = mappings.entry(context).or_default(); match context_mappings.entry(key) { - Entry::Occupied(mut e) => { - if e.get().components().count() > path.components().count() { - e.insert(path); + Entry::Occupied(mut entry) => { + let existing = entry.get(); + if (path.components().count(), !path.ends_with("src"), &path) + < (existing.components().count(), !existing.ends_with("src"), existing) + { + entry.insert(path); } } - Entry::Vacant(e) => { - e.insert(path); + Entry::Vacant(entry) => { + entry.insert(path); } } } @@ -199,35 +255,156 @@ impl RemappingsProvider<'_> { } user_remappings.extend(remappings); + let mut authoritative_user_remappings = user_remappings.clone(); + for remapping in &mut authoritative_user_remappings { + if let Some(context) = &mut remapping.context { + *context = self.root.join(&*context).display().to_string(); + } + } // Let's now use the wrapper to conditionally extend the remappings with the autodetected // ones. We want to avoid duplicates, and the wrapper will handle this for us. let mut all_remappings = Remappings::new_with_remappings(user_remappings); // scan all library dirs and autodetect remappings - // TODO: if a lib specifies contexts for remappings manually, we need to figure out how to - // resolve that if self.auto_detect_remappings { + let (nested_foundry_remappings, auto_detected_remappings) = rayon::join( + || self.find_nested_foundry_remappings(), + || self.auto_detect_remappings(), + ); + let nested_foundry_remappings = nested_foundry_remappings?; + + let configured_package_entries = nested_foundry_remappings + .iter() + .filter(|(_, _, is_package_entry)| *is_package_entry) + .map(|(lib, remapping, _)| (lib.clone(), remapping.clone())) + .collect::>(); + let RemappingDiscovery { global, contextual } = auto_detected_remappings; + let mut global = global + .into_iter() + .map(|remapping| configured_auto_remapping(remapping, &configured_package_entries)) + .collect::>(); + let mut contextual = contextual + .into_iter() + .map(|remapping| configured_auto_remapping(remapping, &configured_package_entries)) + .collect::>(); + let safe_alias = |remapping: &Remapping| { + !["lib/", "src/", "contracts/"].contains(&remapping.name.as_str()) + }; + global.retain(&safe_alias); + contextual.retain(safe_alias); + let mut targets_by_alias = BTreeMap::<_, BTreeSet<_>>::new(); + for remapping in global.iter().chain(&contextual) { + targets_by_alias + .entry(remapping.name.clone()) + .or_default() + .insert(PathBuf::from(&remapping.path)); + } + let ambiguous_aliases = targets_by_alias + .into_iter() + .filter_map(|(name, targets)| (targets.len() > 1).then_some(name)) + .collect::>(); let mut lib_remappings = BTreeMap::new(); - // find all remappings of from libs that use a foundry.toml - for r in self.lib_foundry_toml_remappings() { - insert_closest(&mut lib_remappings, r.context, r.name, r.path.into()); + let mut explicit_contextual_remappings = Vec::new(); + for (_, r, _) in &nested_foundry_remappings { + if r.context.is_some() + && let Some(overlays) = contextual_overlays(&authoritative_user_remappings, r) + { + explicit_contextual_remappings.extend(overlays); + explicit_contextual_remappings.push(r.clone()); + } } - // use auto detection for all libs - for r in self - .lib_paths - .iter() - .map(|lib| self.root.join(lib)) - .inspect(|lib| trace!(?lib, "find all remappings")) - .flat_map(|lib| Remapping::find_many(&lib)) - { - // this is an additional safety check for weird auto-detected remappings - if ["lib/", "src/", "contracts/"].contains(&r.name.as_str()) { - trace!(target: "forge", "- skipping the remapping"); + let mut authoritative_remappings = authoritative_user_remappings; + authoritative_remappings.extend(explicit_contextual_remappings.iter().cloned()); + let mut contextual_remappings = Vec::new(); + for (lib, r, is_package_entry) in &nested_foundry_remappings { + if r.context.is_some() { continue; } + // A dependency can intentionally refine an auto-detected package root to its + // source directory. Scope that refinement to the dependency so root imports keep + // the broader package mapping. + if !is_package_entry + && global.iter().chain(&contextual).any(|auto| { + auto.name == r.name + && Path::new(&r.path) != Path::new(&auto.path) + && (auto + .context + .as_deref() + .is_some_and(|context| Path::new(context) == lib) + || (auto.context == r.context + && Path::new(&r.path).starts_with(&auto.path))) + }) + { + let mut contextual = r.clone(); + contextual.context = Some(format!("{}/", lib.display())); + if let Some(overlays) = + contextual_overlays(&authoritative_remappings, &contextual) + { + contextual_remappings.extend(overlays); + contextual_remappings.push(contextual); + } + } + insert_closest( + &mut lib_remappings, + r.context.clone(), + r.name.clone(), + PathBuf::from(&r.path), + ); + } + for remapping in contextual + .into_iter() + .filter(|remapping| ambiguous_aliases.contains(&remapping.name)) + .flat_map(expand_scoped_contextual_remapping) + { + if let Some(overlays) = contextual_overlays(&authoritative_remappings, &remapping) { + contextual_remappings.extend(overlays); + contextual_remappings.push(remapping); + } + } + contextual_remappings.sort_by(|a, b| { + let a_context = a.context.as_deref().unwrap_or_default(); + let b_context = b.context.as_deref().unwrap_or_default(); + Path::new(b_context) + .components() + .count() + .cmp(&Path::new(a_context).components().count()) + .then_with(|| a_context.cmp(b_context)) + }); + for r in global { insert_closest(&mut lib_remappings, r.context, r.name, r.path.into()); } + let explicit_remappings = all_remappings + .remappings + .iter() + .map(|r| relative_remapping_preserving_context_boundary(r.clone(), self.root)) + .collect::>(); + explicit_contextual_remappings.sort_by(|a, b| { + let a_context = a.context.as_deref().unwrap_or_default(); + let b_context = b.context.as_deref().unwrap_or_default(); + Path::new(b_context) + .components() + .count() + .cmp(&Path::new(a_context).components().count()) + .then_with(|| a_context.cmp(b_context)) + }); + for contextual in explicit_contextual_remappings { + let relative = + relative_remapping_preserving_context_boundary(contextual.clone(), self.root); + if !explicit_remappings.contains(&relative) { + all_remappings.push(contextual); + } + } + let mut generated_remappings = Vec::new(); + for contextual in contextual_remappings { + let relative = + relative_remapping_preserving_context_boundary(contextual.clone(), self.root); + if !explicit_remappings.contains(&relative) + && all_remappings.push(contextual.clone()) + { + generated_remappings.push(contextual); + } + } all_remappings.extend( lib_remappings .into_iter() @@ -240,57 +417,310 @@ impl RemappingsProvider<'_> { }) .collect(), ); + + return Ok(RemappingsOutput { + remappings: all_remappings.into_inner(), + generated_contextual_remappings: generated_remappings, + }); } - Ok(all_remappings.into_inner()) + Ok(RemappingsOutput { remappings: all_remappings.into_inner(), ..Default::default() }) } /// Returns all remappings declared in foundry.toml files of libraries - fn lib_foundry_toml_remappings(&self) -> impl Iterator + '_ { - self.lib_paths + fn find_nested_foundry_remappings(&self) -> Result, Error> { + let root = dunce::canonicalize(self.root).unwrap_or_else(|_| self.root.to_path_buf()); + let mut pending = self + .lib_paths .iter() - .map(|p| if p.is_absolute() { self.root.join("lib") } else { self.root.join(p) }) - .flat_map(foundry_toml_dirs) - .inspect(|lib| { - trace!("find all remappings of nested foundry.toml lib: {:?}", lib); - }) - .flat_map(|lib: PathBuf| { - // load config, of the nested lib if it exists - let Ok(config) = Config::load_with_root(&lib) else { return vec![] }; - let config = config.sanitized(); - - // if the configured _src_ directory is set to something that - // [Remapping::find_many()] doesn't classify as a src directory (src, contracts, - // lib), then we need to manually add a remapping here - let mut src_remapping = None; - if ![Path::new("src"), Path::new("contracts"), Path::new("lib")] - .contains(&config.src.as_path()) - && let Some(name) = lib.file_name().and_then(|s| s.to_str()) - { - let mut r = Remapping { - context: None, - name: format!("{name}/"), - path: format!("{}", lib.join(&config.src).display()), - }; - if !r.path.ends_with('/') { - r.path.push('/') - } - src_remapping = Some(r); + .map(|path| if path.is_absolute() { path.clone() } else { self.root.join(path) }) + .flat_map(foundry_toml_dir_entries) + .collect::>(); + let mut seen = HashSet::from([root.clone()]); + let mut configs = HashMap::>::new(); + let mut remappings = Vec::new(); + + while let Some(entry) = pending.pop_first() { + if entry.canonical == root { + continue; + } + + // Load dependency config inputs without recursively installing another remappings + // provider. Canonical identity is used only to read each config once; emitted paths + // retain the lexical dependency identity. + let config = match configs.entry(entry.canonical.clone()) { + HashEntry::Occupied(config) => config.into_mut(), + HashEntry::Vacant(config) => { + trace!(lib = ?entry.canonical, "find all remappings of nested foundry.toml"); + config.insert(load_nested_config(&entry.canonical)?) } + }; + let Some(config) = config else { + continue; + }; - // Eventually, we could set context for remappings at this location, - // taking into account the OS platform. We'll need to be able to handle nested - // contexts depending on dependencies for this to work. - // For now, we just leave the default context (none). - let mut remappings = - config.remappings.into_iter().map(Remapping::from).collect::>(); + remappings.extend(config.remappings.iter().cloned().map(|remapping| { + ( + entry.path.clone(), + rebase_nested_remapping(remapping, &entry.canonical, &entry.path), + false, + ) + })); + remappings.extend(config.file_remappings.iter().cloned().map(|remapping| { + (entry.path.clone(), RelativeRemapping::new(remapping, &entry.path).into(), false) + })); - if let Some(r) = src_remapping { - remappings.push(r); + // Preserve existing physical nested-config discovery. Symlink discovery is limited to + // direct configured-library entries; nested symlink graphs remain out of scope. + if !fs::symlink_metadata(&entry.path) + .is_ok_and(|metadata| metadata.file_type().is_symlink()) + && seen.insert(entry.canonical.clone()) + { + for lib in &config.libs { + let lib = if lib.is_absolute() { lib.clone() } else { entry.path.join(lib) }; + pending.extend(foundry_toml_dir_entries(lib).into_iter().filter(|entry| { + !fs::symlink_metadata(&entry.path) + .is_ok_and(|metadata| metadata.file_type().is_symlink()) + })); } - remappings + } + + // Custom source directories are not auto-detected. Standard source directories only + // need synthesis while missing; when present, package-root autodetection preserves + // imports that include the source directory, such as `forge-std/src/...`. + let standard_src = [Path::new("src"), Path::new("contracts"), Path::new("lib")]; + if (!standard_src.contains(&config.src.as_path()) + || !entry.canonical.join(&config.src).is_dir()) + && let Some(name) = entry.path.file_name().and_then(|name| name.to_str()) + { + let mut remapping = Remapping { + context: None, + name: format!("{name}/"), + path: entry.path.join(&config.src).display().to_string(), + }; + if !remapping.path.ends_with('/') { + remapping.path.push('/'); + } + remappings.push((entry.path, remapping, true)); + } + } + + Ok(remappings) + } + + /// Auto detect remappings from the lib paths + fn auto_detect_remappings(&self) -> RemappingDiscovery { + let mut discovery = RemappingDiscovery::default(); + for mut current in self + .lib_paths + .par_iter() + .map(|lib| { + let lib = self.root.join(lib); + trace!(?lib, "find all remappings"); + Remapping::find_many_with_context(&lib) }) + .collect::>() + { + discovery.global.append(&mut current.global); + discovery.contextual.append(&mut current.contextual); + } + discovery + } +} + +fn load_nested_config(root: &Path) -> Result, Error> { + let figment = Config::with_root(root).to_figment(FigmentProviders::Cast); + let Ok(config) = Config::from_figment_fallback(figment) else { return Ok(None) }; + let src = config.src.clone(); + let libs = config.libs.clone(); + let remappings = config.sanitized().remappings.into_iter().map(Remapping::from).collect(); + let remappings_file = root.join("remappings.txt"); + let file_remappings = if remappings_file.is_file() { + let content = fs::read_to_string(remappings_file).map_err(|err| err.to_string())?; + remappings_from_newline(&content) + .collect::, _>>() + .map_err::(|err| err.to_string().into())? + } else { + Vec::new() + }; + Ok(Some(CachedNestedConfig { src, libs, remappings, file_remappings })) +} + +fn remapping_name_is_prefix(prefix: &str, name: &str) -> bool { + let prefix = prefix.strip_suffix('/').unwrap_or(prefix); + let name = name.strip_suffix('/').unwrap_or(name); + prefix == name || name.strip_prefix(prefix).is_some_and(|suffix| suffix.starts_with('/')) +} + +fn contextual_overlays( + authoritative: &[Remapping], + refinement: &Remapping, +) -> Option> { + let applicable = |mapping: &&Remapping| { + mapping.context.as_deref().is_none_or(|context| { + refinement + .context + .as_deref() + .is_some_and(|refinement| context_starts_with(refinement, context)) + }) + }; + if authoritative + .iter() + .filter(applicable) + .any(|mapping| remapping_name_is_prefix(&mapping.name, &refinement.name)) + { + return None; + } + let mut overlays = authoritative + .iter() + .filter(applicable) + .filter(|mapping| remapping_name_is_prefix(&refinement.name, &mapping.name)) + .cloned() + .collect::>(); + overlays.sort_by_key(|mapping| Reverse(mapping.name.len())); + for overlay in &mut overlays { + overlay.context.clone_from(&refinement.context); + } + Some(overlays) +} + +fn context_starts_with(path: &str, base: &str) -> bool { + #[cfg(windows)] + { + use path_slash::PathBufExt as _; + + let path = PathBuf::from_slash(path); + let base = PathBuf::from_slash(base); + return path.starts_with(base); + } + #[cfg(not(windows))] + Path::new(path).starts_with(base) +} + +/// Narrows an npm scope mapping to its installed packages so missing siblings can use the global +/// hoisted-package fallback. +fn expand_scoped_contextual_remapping(remapping: Remapping) -> Vec { + let scope = remapping.name.trim_end_matches('/'); + let path = Path::new(&remapping.path); + if !scope.starts_with('@') + || path.file_name().and_then(|name| name.to_str()) != Some(scope) + || path.parent().and_then(|parent| parent.file_name()).and_then(|name| name.to_str()) + != Some("node_modules") + { + return vec![remapping]; + } + + let Ok(entries) = fs::read_dir(path) else { return vec![remapping] }; + let mut packages = Vec::new(); + for entry in entries { + let Ok(entry) = entry else { return vec![remapping] }; + if !entry.path().is_dir() { + continue; + } + let Ok(name) = entry.file_name().into_string() else { return vec![remapping] }; + let mut path = entry.path().display().to_string(); + if !path.ends_with(['/', '\\']) { + path.push(MAIN_SEPARATOR); + } + packages.push(Remapping { + context: remapping.context.clone(), + name: format!("{scope}/{name}/"), + path, + }); + } + packages.sort_by(|a, b| a.name.cmp(&b.name)); + if packages.is_empty() { vec![remapping] } else { packages } +} + +fn configured_auto_remapping( + mut remapping: Remapping, + configured_package_entries: &[(PathBuf, Remapping)], +) -> Remapping { + let path = Path::new(&remapping.path); + let context = remapping.context.as_deref().map(Path::new); + if let Some((_, (_, configured))) = configured_package_entries + .iter() + .filter(|(lib, configured)| { + configured.name == remapping.name + && context.is_none_or(|owner| lib != owner && lib.starts_with(owner)) + }) + .filter_map(|entry @ (lib, _)| { + if path.starts_with(lib) { + Some(((0, usize::MAX - lib.components().count()), entry)) + } else if context.is_some_and(|context| lib.starts_with(context)) + && lib.starts_with(path) + { + Some(((1, lib.components().count()), entry)) + } else { + None + } + }) + .min_by(|(rank_a, (lib_a, _)), (rank_b, (lib_b, _))| { + rank_a.cmp(rank_b).then_with(|| lib_a.cmp(lib_b)) + }) + { + remapping.path.clone_from(&configured.path); + } + remapping +} + +fn rebase_nested_remapping( + mut remapping: Remapping, + canonical: &Path, + lexical: &Path, +) -> Remapping { + let normalize = |path: &Path| { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + _ => normalized.push(component.as_os_str()), + } + } + normalized + }; + let rebase = |value: &str| { + let path = Path::new(value); + path.strip_prefix(canonical) + .map(|relative| lexical.join(relative).display().to_string()) + .unwrap_or_else(|_| value.to_string()) + }; + remapping.path = rebase(&remapping.path); + if let Some(context) = &mut remapping.context { + let has_boundary = context.ends_with(['/', '\\']); + *context = if Path::new(context).is_absolute() { + rebase(context) + } else { + normalize(&lexical.join(&*context)).display().to_string() + }; + if has_boundary && !context.ends_with(['/', '\\']) { + context.push(MAIN_SEPARATOR); + } } + remapping +} + +pub fn relative_remapping_preserving_context_boundary( + remapping: Remapping, + root: &Path, +) -> RelativeRemapping { + let has_boundary = + remapping.context.as_deref().is_some_and(|context| context.ends_with(['/', '\\'])); + let mut remapping = RelativeRemapping::new(remapping, root); + // Slash conversion on Windows only preserves a trailing native separator. + if has_boundary + && let Some(context) = &mut remapping.context + && !context.ends_with(MAIN_SEPARATOR) + { + if context.ends_with(['/', '\\']) { + context.pop(); + } + context.push(MAIN_SEPARATOR); + } + remapping } impl Provider for RemappingsProvider<'_> { @@ -299,7 +729,7 @@ impl Provider for RemappingsProvider<'_> { } fn data(&self) -> Result, Error> { - let remappings = match &self.remappings { + let output = match &self.remappings { Ok(remappings) => self.get_remappings(remappings.clone()), Err(err) => { if let figment::error::Kind::MissingField(_) = err.kind { @@ -311,14 +741,26 @@ impl Provider for RemappingsProvider<'_> { }?; // turn the absolute remapping into a relative one by stripping the `root` - let remappings = remappings + let remappings = output + .remappings .into_iter() - .map(|r| RelativeRemapping::new(r, self.root).to_string()) + .map(|r| relative_remapping_preserving_context_boundary(r, self.root).to_string()) + .collect::>(); + let generated_remappings = output + .generated_contextual_remappings + .into_iter() + .map(|r| relative_remapping_preserving_context_boundary(r, self.root).to_string()) .collect::>(); Ok(Map::from([( Config::selected_profile(), - Dict::from([("remappings".to_string(), figment::value::Value::from(remappings))]), + Dict::from([ + ("remappings".to_string(), figment::value::Value::from(remappings)), + ( + GENERATED_REMAPPINGS_KEY.to_string(), + figment::value::Value::from(generated_remappings), + ), + ]), )])) } @@ -331,6 +773,233 @@ impl Provider for RemappingsProvider<'_> { mod tests { use super::*; + #[cfg(unix)] + use std::os::unix::fs::symlink; + #[cfg(unix)] + use tempfile::tempdir; + + #[cfg(unix)] + #[test] + fn nested_remappings_ignore_symlinks_back_to_the_project_root() { + let temp = tempdir().unwrap(); + let root = temp.path(); + fs::create_dir(root.join("lib")).unwrap(); + fs::write(root.join(Config::FILE_NAME), "").unwrap(); + symlink(root, root.join("lib/self")).unwrap(); + let libs = vec![PathBuf::from("lib")]; + let provider = RemappingsProvider { + auto_detect_remappings: true, + lib_paths: Cow::Borrowed(&libs), + root, + remappings: Ok(vec![]), + }; + + assert!(provider.find_nested_foundry_remappings().unwrap().is_empty()); + } + + #[cfg(unix)] + #[test] + fn nested_remappings_visit_each_config_once_across_a_dependency_cycle() { + let temp = tempdir().unwrap(); + let root = temp.path().join("project"); + let dependency = temp.path().join("dependency"); + fs::create_dir_all(root.join("lib")).unwrap(); + fs::create_dir_all(dependency.join("lib")).unwrap(); + fs::create_dir(dependency.join("custom-source")).unwrap(); + fs::write(root.join(Config::FILE_NAME), "").unwrap(); + fs::write( + dependency.join(Config::FILE_NAME), + "[profile.default]\nsrc = \"custom-source\"\n", + ) + .unwrap(); + symlink(&dependency, root.join("lib/dependency-alias")).unwrap(); + symlink(&root, dependency.join("lib/back")).unwrap(); + let libs = vec![PathBuf::from("lib")]; + let provider = RemappingsProvider { + auto_detect_remappings: true, + lib_paths: Cow::Borrowed(&libs), + root: &root, + remappings: Ok(vec![]), + }; + + assert_eq!( + provider.find_nested_foundry_remappings().unwrap(), + vec![( + root.join("lib/dependency-alias"), + Remapping { + context: None, + name: "dependency-alias/".to_string(), + path: format!("{}/", root.join("lib/dependency-alias/custom-source").display()), + }, + true, + )] + ); + } + + #[cfg(unix)] + #[test] + fn nested_remappings_traverse_physical_dependency_after_symlink_alias() { + let temp = tempdir().unwrap(); + let root = temp.path().join("project"); + let dependency = root.join("lib/z-dependency"); + let nested = dependency.join("lib/nested"); + fs::create_dir_all(&nested).unwrap(); + fs::create_dir(nested.join("custom-source")).unwrap(); + fs::write(root.join(Config::FILE_NAME), "").unwrap(); + fs::write(dependency.join(Config::FILE_NAME), "[profile.default]\nlibs = [\"lib\"]\n") + .unwrap(); + fs::write(nested.join(Config::FILE_NAME), "[profile.default]\nsrc = \"custom-source\"\n") + .unwrap(); + symlink(&dependency, root.join("lib/a-dependency-alias")).unwrap(); + let libs = vec![PathBuf::from("lib")]; + let provider = RemappingsProvider { + auto_detect_remappings: true, + lib_paths: Cow::Borrowed(&libs), + root: &root, + remappings: Ok(vec![]), + }; + + assert!(provider.find_nested_foundry_remappings().unwrap().contains(&( + nested.clone(), + Remapping { + context: None, + name: "nested/".to_string(), + path: format!("{}/", nested.join("custom-source").display()), + }, + true, + ))); + } + + #[test] + fn relative_remapping_preserves_context_directory_boundary() { + let remapping = Remapping { + context: Some("lib/outer/".to_string()), + name: "inner/".to_string(), + path: format!("lib{MAIN_SEPARATOR}outer{MAIN_SEPARATOR}lib{MAIN_SEPARATOR}inner"), + }; + + let remapping = relative_remapping_preserving_context_boundary(remapping, Path::new(".")); + assert!(remapping.context.as_ref().unwrap().ends_with(MAIN_SEPARATOR)); + assert_eq!(remapping.to_string(), "lib/outer/:inner/=lib/outer/lib/inner/"); + } + + #[test] + fn config_merge_only_suppresses_generated_refinements() { + let cli = Remapping { + context: None, + name: "pkg/sub/".to_string(), + path: "src/local/".to_string(), + }; + let contextual = Remapping { + context: Some("lib/dep/".to_string()), + name: "pkg/".to_string(), + path: "lib/dep/vendor/pkg/".to_string(), + }; + let mut explicit = Remappings::new_with_remappings(vec![cli.clone()]); + explicit.extend_with_config_remappings(vec![contextual.clone()], &[]); + assert_eq!(explicit.into_inner(), vec![cli.clone(), contextual.clone()]); + + let global = Remapping { + context: None, + name: "pkg/".to_string(), + path: "lib/dep/lib/pkg/".to_string(), + }; + let refinement = Remapping { + context: Some("lib/dep/".to_string()), + name: "pkg/".to_string(), + path: "lib/dep/lib/pkg/contracts/".to_string(), + }; + let cli_deep = Remapping { + context: None, + name: "pkg/sub/deep/".to_string(), + path: "src/deep/".to_string(), + }; + let mut generated = Remappings::new_with_remappings(vec![cli.clone(), cli_deep.clone()]); + generated.extend_with_config_remappings( + vec![refinement.clone(), global.clone()], + std::slice::from_ref(&refinement), + ); + let mut overlay = cli.clone(); + overlay.context.clone_from(&refinement.context); + let mut deep_overlay = cli_deep.clone(); + deep_overlay.context.clone_from(&refinement.context); + assert_eq!( + generated.into_inner(), + vec![cli.clone(), cli_deep, deep_overlay, overlay, refinement, global.clone()] + ); + + let broad_cli = + Remapping { context: None, name: "pkg/".to_string(), path: "src/local/".to_string() }; + let mut generated = Remappings::new_with_remappings(vec![broad_cli.clone(), cli.clone()]); + generated.extend_with_config_remappings( + vec![contextual.clone(), global], + std::slice::from_ref(&contextual), + ); + assert_eq!(generated.into_inner(), vec![broad_cli, cli]); + + let contextual_cli = Remapping { + context: Some("lib/dep/".to_string()), + name: "pkg/".to_string(), + path: "src/contextual/".to_string(), + }; + let nested_refinement = Remapping { + context: Some("lib/dep/lib/nested/".to_string()), + name: "pkg/".to_string(), + path: "lib/dep/lib/nested/lib/pkg/".to_string(), + }; + let mut generated = Remappings::new_with_remappings(vec![contextual_cli.clone()]); + generated.extend_with_config_remappings( + vec![nested_refinement.clone()], + std::slice::from_ref(&nested_refinement), + ); + assert_eq!(generated.into_inner(), vec![contextual_cli]); + } + + #[cfg(windows)] + #[test] + fn contextual_overlay_accepts_mixed_windows_separators() { + let authoritative = Remapping { + context: Some(r"C:\workspace\lib/a/".to_string()), + name: "shared/".to_string(), + path: "src/override/".to_string(), + }; + let refinement = Remapping { + context: Some(r"C:\workspace\lib\a\lib\x\".to_string()), + name: "shared/".to_string(), + path: "lib/a/lib/x/lib/shared/src/".to_string(), + }; + + assert!(contextual_overlays(&[authoritative], &refinement).is_none()); + } + + #[test] + fn nested_remapping_groups_are_sorted() { + let root = tempfile::tempdir().unwrap(); + for dependency in ["zeta", "alpha"] { + let dependency = root.path().join("lib").join(dependency); + fs::create_dir_all(&dependency).unwrap(); + fs::write(dependency.join(Config::FILE_NAME), "[profile.default]\n").unwrap(); + fs::write(dependency.join("remappings.txt"), "pkg/=src/\n").unwrap(); + } + let libs = vec![PathBuf::from("lib")]; + let provider = RemappingsProvider { + auto_detect_remappings: true, + lib_paths: Cow::Borrowed(&libs), + root: root.path(), + remappings: Ok(Vec::new()), + }; + + let dependencies = provider + .find_nested_foundry_remappings() + .unwrap() + .into_iter() + .map(|(dependency, _, _)| { + dependency.file_name().unwrap().to_string_lossy().into_owned() + }) + .collect::>(); + assert_eq!(dependencies, ["alpha", "alpha", "zeta", "zeta"]); + } + #[test] fn test_sol_file_remappings() { let mut remappings = Remappings::new(); @@ -458,4 +1127,157 @@ mod tests { .any(|r| r.context == Some("prod/".to_string()) && r.path == "prod/Contract.sol") ); } + + #[test] + fn configured_auto_remapping_uses_nearest_owned_package() { + let configured = vec![ + ( + PathBuf::from("lib/shared"), + Remapping { + context: None, + name: "shared/".to_string(), + path: "lib/shared/outer-src/".to_string(), + }, + ), + ( + PathBuf::from("lib/shared/lib/shared"), + Remapping { + context: None, + name: "shared/".to_string(), + path: "lib/shared/lib/shared/inner-src/".to_string(), + }, + ), + ]; + let candidate = |context: &str, path: &str| Remapping { + context: Some(context.to_string()), + name: "shared/".to_string(), + path: path.to_string(), + }; + + assert_eq!( + configured_auto_remapping( + candidate("lib/shared/", "lib/shared/lib/shared/src/"), + &configured, + ), + candidate("lib/shared/", "lib/shared/lib/shared/inner-src/") + ); + assert_eq!( + configured_auto_remapping(candidate("lib/shared/", "lib/shared/lib/"), &configured), + candidate("lib/shared/", "lib/shared/lib/shared/inner-src/") + ); + + let nested = candidate("lib/shared/lib/nested/", "lib/shared/lib/nested/lib/shared/src/"); + assert_eq!( + configured_auto_remapping(nested.clone(), &configured), + nested, + "configured ancestors must not rewrite remappings owned by nested dependencies", + ); + } + + #[test] + fn test_root_remapping_prefix_precedence_is_directional() { + let remapping = |name: &str, path: &str| Remapping { + context: None, + name: name.to_string(), + path: path.to_string(), + }; + + let mut narrow_root = + Remappings::new_with_remappings(vec![remapping("pkg/sub/", "src/local/")]); + narrow_root.extend(vec![remapping("pkg/", "lib/pkg/src/")]); + assert_eq!( + narrow_root.into_inner(), + vec![remapping("pkg/sub/", "src/local/"), remapping("pkg/", "lib/pkg/src/")] + ); + + let mut broad_root = Remappings::new_with_remappings(vec![remapping("pkg/", "src/local/")]); + broad_root.extend(vec![ + remapping("pkg/sub/", "lib/pkg/src/sub/"), + remapping("pkg-other/", "lib/pkg-other/src/"), + ]); + assert_eq!( + broad_root.into_inner(), + vec![remapping("pkg/", "src/local/"), remapping("pkg-other/", "lib/pkg-other/src/")] + ); + + let mut duplicate = Remappings::new_with_remappings(vec![remapping("pkg/", "src/local/")]); + duplicate.extend(vec![remapping("pkg/", "lib/pkg/src/")]); + assert_eq!(duplicate.remappings, vec![remapping("pkg/", "src/local/")]); + + // The first remapping wins regardless of whether either name carries a trailing slash. + for (existing, incoming) in + [("pkg", "pkg"), ("pkg", "pkg/"), ("pkg/", "pkg"), ("pkg", "pkg/sub/")] + { + let mut duplicate = + Remappings::new_with_remappings(vec![remapping(existing, "src/local/")]); + duplicate.extend(vec![remapping(incoming, "lib/pkg/src/")]); + assert_eq!( + duplicate.remappings, + vec![remapping(existing, "src/local/")], + "{existing} then {incoming}" + ); + } + + // Repeated slashes are significant Solidity import-prefix characters. + for incoming in ["pkg/", "pkg/sub/"] { + let existing = remapping("pkg//", "src/double-slash/"); + let mut distinct = Remappings::new_with_remappings(vec![existing.clone()]); + let incoming = remapping(incoming, "lib/pkg/src/"); + distinct.extend(vec![incoming.clone()]); + assert_eq!(distinct.remappings, vec![existing, incoming]); + } + + let contextual_remapping = |context: &str, name: &str, path: &str| Remapping { + context: Some(context.to_string()), + name: name.to_string(), + path: path.to_string(), + }; + let mut same_context = Remappings::new_with_remappings(vec![contextual_remapping( + "src/", + "pkg/", + "src/local/", + )]); + same_context.extend(vec![contextual_remapping("src/", "pkg/sub/", "lib/pkg/src/sub/")]); + assert_eq!( + same_context.remappings, + vec![contextual_remapping("src/", "pkg/", "src/local/")] + ); + + let mut different_context = Remappings::new_with_remappings(vec![contextual_remapping( + "src/", + "pkg/", + "src/local/", + )]); + different_context.extend(vec![contextual_remapping( + "test/", + "pkg/sub/", + "lib/pkg/src/sub/", + )]); + assert_eq!( + different_context.remappings, + vec![ + contextual_remapping("src/", "pkg/", "src/local/"), + contextual_remapping("test/", "pkg/sub/", "lib/pkg/src/sub/"), + ] + ); + + let mut narrow_root_without_slash = + Remappings::new_with_remappings(vec![remapping("pkg/sub", "src/local/")]); + narrow_root_without_slash.extend(vec![remapping("pkg", "lib/pkg/src/")]); + assert_eq!( + narrow_root_without_slash.remappings, + vec![remapping("pkg/sub", "src/local/"), remapping("pkg", "lib/pkg/src/")] + ); + + let mut broad_root_without_slash = + Remappings::new_with_remappings(vec![remapping("pkg", "src/local/")]); + broad_root_without_slash.extend(vec![ + remapping("pkg/sub", "lib/pkg/src/sub/"), + remapping("pkg-other", "lib/pkg-other/src/"), + ]); + assert_eq!( + broad_root_without_slash.remappings, + vec![remapping("pkg", "src/local/"), remapping("pkg-other", "lib/pkg-other/src/")] + ); + } } diff --git a/crates/config/src/providers/warnings.rs b/crates/config/src/providers/warnings.rs index 8bb52921a7e1c..3707758c86d84 100644 --- a/crates/config/src/providers/warnings.rs +++ b/crates/config/src/providers/warnings.rs @@ -3,7 +3,120 @@ use figment::{ Error, Figment, Metadata, Profile, Provider, value::{Dict, Map, Value}, }; -use std::collections::BTreeMap; +use heck::ToSnakeCase; +use std::collections::{BTreeMap, BTreeSet}; + +/// Allowed keys for CompilationRestrictions. +const COMPILATION_RESTRICTIONS_KEYS: &[&str] = &[ + "paths", + "version", + "via_ir", + "bytecode_hash", + "min_optimizer_runs", + "optimizer_runs", + "max_optimizer_runs", + "min_evm_version", + "evm_version", + "max_evm_version", +]; + +/// Allowed keys for SettingsOverrides. +const SETTINGS_OVERRIDES_KEYS: &[&str] = + &["name", "via_ir", "evm_version", "optimizer", "optimizer_runs", "bytecode_hash"]; + +/// Allowed keys for external compiler adapter entries. +const EXTERNAL_COMPILER_KEYS: &[&str] = &["id", "command", "args", "roots", "settings"]; + +/// Allowed keys for VyperConfig. +/// Required because VyperConfig uses `skip_serializing_if = "Option::is_none"` on all fields, +/// causing the default serialization to produce an empty dict. +const VYPER_KEYS: &[&str] = &[ + "optimize", + "opt_level", + "optLevel", + "path", + "experimental_codegen", + "venom_experimental", + "debug", + "enable_decimals", + "venom", +]; + +/// Allowed keys for DocConfig. +/// Required because DocConfig uses `skip_serializing_if = "Option::is_none"` on some fields +/// (`repository`, `commit`, `path`), whose defaults are `None` and thus excluded from +/// serialization. +const DOC_KEYS: &[&str] = + &["out", "title", "book", "homepage", "repository", "commit", "path", "ignore"]; + +/// Allowed keys for SymbolicConfig. +/// Required because some compatibility aliases and empty length collections are skipped by default +/// serialization, but they are still valid user-facing config keys. +const SYMBOLIC_KEYS: &[&str] = &[ + "enabled", + "seed_corpus", + "use_fuzz_corpus", + "corpus_seed_limit", + "use_fuzz_frontiers", + "check_invariant_frontiers", + "frontier_limit", + "frontier_ids", + "frontier_pcs", + "frontier_selectors", + "solver", + "solver_command", + "solver_portfolio", + "timeout", + "loop", + "depth", + "width", + "max_depth", + "max_paths", + "invariant_depth", + "exploration_order", + "max_solver_queries", + "default_dynamic_length", + "max_dynamic_length", + "array_lengths", + "dynamic_lengths", + "default_array_lengths", + "default_bytes_lengths", + "max_calldata_bytes", + "symbolic_call_targets", + "dump_smt", + "storage_layout", +]; + +/// Allowed keys for TracingConfig. +/// Required because empty labels and optional trace depth are skipped by default serialization, +/// but they are still valid user-facing config keys. +const TRACING_KEYS: &[&str] = &[ + "verbosity", + "labels", + "disable_labels", + "compact_labels", + "trace_depth", + "decode_internal", + "external_identification_timeout", +]; + +/// Reserved keys that should not trigger unknown key warnings. +const RESERVED_KEYS: &[&str] = &["extends"]; + +/// Keys kept for backward compatibility that should not trigger unknown key warnings. +/// +/// Network flags are legacy aliases for canonical `network = "..."` values. They remain accepted +/// when the corresponding network support is compiled, but are no longer serialized. +const BACKWARD_COMPATIBLE_KEYS: &[&str] = &[ + "solc_version", + "tempo", + "optimism", + #[cfg(feature = "monad")] + "monad", +]; + +const LABELS_KEY: &str = "labels"; +const TRACING_LABELS_KEY: &str = "tracing.labels"; /// Generate warnings for unknown sections and deprecated keys pub struct WarningsProvider

{ @@ -28,7 +141,7 @@ impl WarningsProvider

{ pub fn for_figment(provider: P, figment: &Figment) -> Self { let old_warnings = { let warnings_res = figment.extract_inner(Self::WARNINGS_KEY); - if warnings_res.as_ref().err().map(|err| err.missing()).unwrap_or(false) { + if warnings_res.as_ref().err().is_some_and(|err| err.missing()) { Ok(vec![]) } else { warnings_res @@ -44,46 +157,271 @@ impl WarningsProvider

{ let mut out = self.old_warnings.clone()?; // Add warning for unknown sections. - out.extend( - data.keys() - .filter(|k| { - **k != Config::PROFILE_SECTION - && !Config::STANDALONE_SECTIONS.iter().any(|s| s == k) - }) - .map(|unknown_section| { - let source = self.provider.metadata().source.map(|s| s.to_string()); - Warning::UnknownSection { unknown_section: unknown_section.clone(), source } - }), - ); + out.extend(data.keys().filter(|k| !Config::is_standalone_section(k.as_str())).map( + |unknown_section| { + let source = self.provider.metadata().source.map(|s| s.to_string()); + Warning::UnknownSection { unknown_section: unknown_section.clone(), source } + }, + )); // Add warning for deprecated keys. let deprecated_key_warning = |key| { DEPRECATIONS.iter().find_map(|(deprecated_key, new_value)| { - if key == *deprecated_key { - Some(Warning::DeprecatedKey { - old: deprecated_key.to_string(), - new: new_value.to_string(), - }) - } else { - None - } + (key == *deprecated_key).then(|| Warning::DeprecatedKey { + old: deprecated_key.to_string(), + new: new_value.to_string(), + }) }) }; let profiles = data .iter() .filter(|(profile, _)| **profile == Config::PROFILE_SECTION) .map(|(_, dict)| dict); - out.extend(profiles.clone().flat_map(BTreeMap::keys).filter_map(deprecated_key_warning)); - out.extend( - profiles - .filter_map(|dict| dict.get(self.profile.as_str().as_str())) - .filter_map(Value::as_dict) - .flat_map(BTreeMap::keys) - .filter_map(deprecated_key_warning), - ); + + let deprecated_profile_keys = profiles + .clone() + .flat_map(|dict| { + dict.keys().chain(dict.values().filter_map(Value::as_dict).flat_map(BTreeMap::keys)) + }) + .collect::>(); + out.extend(deprecated_profile_keys.into_iter().filter_map(deprecated_key_warning)); + self.collect_deprecated_label_warnings(&data, profiles.clone(), &mut out); + + // Add warning for unknown keys within profiles (root keys only here). + if let Ok(default_map) = figment::providers::Serialized::defaults(&Config::default()).data() + && let Some(default_dict) = default_map.get(&Config::DEFAULT_PROFILE) + { + let mut allowed_keys: BTreeSet = default_dict.keys().cloned().collect(); + allowed_keys.insert("external_compilers".to_string()); + for profile_map in profiles.clone() { + for (profile, value) in profile_map { + let Some(profile_dict) = value.as_dict() else { + continue; + }; + + let source = self + .provider + .metadata() + .source + .map(|s| s.to_string()) + .unwrap_or(Config::FILE_NAME.to_string()); + for key in profile_dict.keys() { + let is_not_deprecated = !Self::is_deprecated_profile_key(key); + let is_not_allowed = !allowed_keys.contains(key) + && !allowed_keys.contains(&key.to_snake_case()); + let is_not_reserved = + !RESERVED_KEYS.contains(&key.as_str()) && key != Self::WARNINGS_KEY; + let is_not_backward_compatible = + !BACKWARD_COMPATIBLE_KEYS.contains(&key.as_str()); + + if is_not_deprecated + && is_not_allowed + && is_not_reserved + && is_not_backward_compatible + { + out.push(Warning::UnknownKey { + key: key.clone(), + profile: profile.clone(), + source: source.clone(), + }); + } + } + + // Add warning for unknown keys in nested sections within profiles. + self.collect_nested_section_warnings( + profile_dict, + default_dict, + &source, + &mut out, + ); + } + } + + // Add warning for unknown keys in standalone sections. + self.collect_standalone_section_warnings(&data, default_dict, &mut out); + } Ok(out) } + + /// Collects warnings for unknown keys in standalone sections like `[lint]`, `[fmt]`, etc. + fn collect_standalone_section_warnings( + &self, + data: &Map, + default_dict: &Dict, + out: &mut Vec, + ) { + let source = self + .provider + .metadata() + .source + .map(|s| s.to_string()) + .unwrap_or(Config::FILE_NAME.to_string()); + + for section_name in Config::STANDALONE_SECTIONS { + // Get the section from the parsed data + let section_profile = Profile::new(section_name); + let Some(section_dict) = data.get(§ion_profile) else { + continue; + }; + + // Get allowed keys for this section from the default config + // Special case for vyper: VyperConfig uses skip_serializing_if on all Option fields, + // so the default serialization produces an empty dict. Use explicit keys instead. + let allowed_keys: BTreeSet = if *section_name == "vyper" { + VYPER_KEYS.iter().map(|s| s.to_string()).collect() + } else if *section_name == "doc" { + DOC_KEYS.iter().map(|s| s.to_string()).collect() + } else if *section_name == "tracing" { + TRACING_KEYS.iter().map(|s| s.to_string()).collect() + } else if *section_name == "symbolic" { + SYMBOLIC_KEYS.iter().map(|s| s.to_string()).collect() + } else { + let Some(default_section_value) = default_dict.get(*section_name) else { + continue; + }; + let Some(default_section_dict) = default_section_value.as_dict() else { + continue; + }; + default_section_dict.keys().cloned().collect() + }; + + for key in section_dict.keys() { + let is_not_allowed = + !allowed_keys.contains(key) && !allowed_keys.contains(&key.to_snake_case()); + if is_not_allowed { + out.push(Warning::UnknownSectionKey { + key: key.clone(), + section: section_name.to_string(), + source: source.clone(), + }); + } + } + } + } + + /// Collects warnings for unknown keys in nested sections within profiles, + /// like `compilation_restrictions`. + fn collect_nested_section_warnings( + &self, + profile_dict: &Dict, + default_dict: &Dict, + source: &str, + out: &mut Vec, + ) { + // Check nested sections that are dicts (like `lint`, `fmt` when defined in profile) + for (key, value) in profile_dict { + let Some(nested_dict) = value.as_dict() else { + // Also check arrays of dicts (like `compilation_restrictions`) + if let Some(arr) = value.as_array() { + // Get allowed keys for known array item types + let allowed_keys = Self::get_array_item_allowed_keys(key); + + if allowed_keys.is_empty() { + continue; + } + + for item in arr { + let Some(item_dict) = item.as_dict() else { + continue; + }; + for item_key in item_dict.keys() { + let is_not_allowed = !allowed_keys.contains(item_key) + && !allowed_keys.contains(&item_key.to_snake_case()); + if is_not_allowed { + out.push(Warning::UnknownSectionKey { + key: item_key.clone(), + section: key.clone(), + source: source.to_string(), + }); + } + } + } + } + continue; + }; + + // Get allowed keys from the default config for this nested section + // Special case for vyper: VyperConfig uses skip_serializing_if on all Option fields, + // so the default serialization produces an empty dict. Use explicit keys instead. + let allowed_keys: BTreeSet = if key == "vyper" { + VYPER_KEYS.iter().map(|s| s.to_string()).collect() + } else if key == "doc" { + DOC_KEYS.iter().map(|s| s.to_string()).collect() + } else if key == "symbolic" { + SYMBOLIC_KEYS.iter().map(|s| s.to_string()).collect() + } else if key == "tracing" { + TRACING_KEYS.iter().map(|s| s.to_string()).collect() + } else { + let Some(default_value) = default_dict.get(key) else { + continue; + }; + let Some(default_nested_dict) = default_value.as_dict() else { + continue; + }; + default_nested_dict.keys().cloned().collect() + }; + + for nested_key in nested_dict.keys() { + let is_not_allowed = !allowed_keys.contains(nested_key) + && !allowed_keys.contains(&nested_key.to_snake_case()); + if is_not_allowed { + out.push(Warning::UnknownSectionKey { + key: nested_key.clone(), + section: key.clone(), + source: source.to_string(), + }); + } + } + } + } + + /// Returns the allowed keys for array item types based on the section name. + fn get_array_item_allowed_keys(section_name: &str) -> BTreeSet { + match section_name { + "compilation_restrictions" => { + COMPILATION_RESTRICTIONS_KEYS.iter().map(|s| s.to_string()).collect() + } + "additional_compiler_profiles" => { + SETTINGS_OVERRIDES_KEYS.iter().map(|s| s.to_string()).collect() + } + "external_compilers" => EXTERNAL_COMPILER_KEYS.iter().map(|s| s.to_string()).collect(), + _ => BTreeSet::new(), + } + } + + fn collect_deprecated_label_warnings<'a>( + &self, + data: &Map, + profiles: impl Iterator, + out: &mut Vec, + ) { + if data.contains_key(&Profile::new(LABELS_KEY)) { + out.push(Self::deprecated_label_warning("[labels]", "[tracing.labels]")); + } + + if profiles + .flat_map(BTreeMap::values) + .filter_map(Value::as_dict) + .any(|dict| dict.contains_key(LABELS_KEY)) + { + out.push(Self::deprecated_label_warning(LABELS_KEY, TRACING_LABELS_KEY)); + } + + if let Some(dict) = data.get(&self.profile) + && dict.contains_key(LABELS_KEY) + { + out.push(Self::deprecated_label_warning(LABELS_KEY, TRACING_LABELS_KEY)); + } + } + + fn deprecated_label_warning(old: &str, new: &str) -> Warning { + Warning::DeprecatedKey { old: old.to_string(), new: new.to_string() } + } + + fn is_deprecated_profile_key(key: &str) -> bool { + key == LABELS_KEY || DEPRECATIONS.iter().any(|(deprecated_key, _)| *deprecated_key == key) + } } impl Provider for WarningsProvider

{ diff --git a/crates/config/src/soldeer.rs b/crates/config/src/soldeer.rs index 24ecce0955f12..872748c8d317e 100644 --- a/crates/config/src/soldeer.rs +++ b/crates/config/src/soldeer.rs @@ -28,11 +28,15 @@ pub struct MapDependency { /// The git tag in case git is used as dependency source #[serde(default, skip_serializing_if = "Option::is_none")] pub tag: Option, + + /// An optional relative path to the project's root within the repository + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_root: Option, } /// Type for Soldeer configs, under dependencies tag in the foundry.toml #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct SoldeerDependencyConfig(BTreeMap); +pub struct SoldeerDependencyConfig(pub BTreeMap); impl AsRef for SoldeerDependencyConfig { fn as_ref(&self) -> &Self { diff --git a/crates/config/src/symbolic.rs b/crates/config/src/symbolic.rs new file mode 100644 index 0000000000000..40c1e9d68817d --- /dev/null +++ b/crates/config/src/symbolic.rs @@ -0,0 +1,205 @@ +//! Configuration for symbolic testing. + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +/// Storage modelling mode for symbolic tests. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SymbolicStorageLayout { + /// Model Solidity storage layout precisely where the symbolic executor knows the layout shape. + #[default] + Solidity, + /// Treat every storage read as potentially arbitrary symbolic storage. + Generic, + /// Treat unwritten symbolic storage reads as zero. + ZeroInit, +} + +/// Pending symbolic path exploration order. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SymbolicExplorationOrder { + /// Explore pending paths in first-in, first-out order. + #[default] + Bfs, + /// Explore pending paths in last-in, first-out order. + Dfs, +} + +/// Configuration for symbolic testing. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SymbolicConfig { + /// Whether symbolic tests are enabled. + pub enabled: bool, + /// Whether fuzz tests should be symbolically concretized into fuzz corpus seeds. + pub seed_corpus: bool, + /// Whether fuzz corpus seeds should guide symbolic fuzz-test exploration. + pub use_fuzz_corpus: bool, + /// Maximum number of fuzz corpus seeds to import for one symbolic run. + pub corpus_seed_limit: usize, + /// Whether fuzz branch frontiers should guide targeted symbolic fuzz-test seeding. + pub use_fuzz_frontiers: bool, + /// Whether invariant frontier seeding should first search for a property-breaking call. + #[serde(default)] + pub check_invariant_frontiers: bool, + /// Maximum number of fuzz branch frontiers to try for one symbolic run. + pub frontier_limit: usize, + /// Fuzz branch frontier artifact IDs to import. Empty imports by artifact order. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub frontier_ids: Vec, + /// Fuzz branch frontier comparison program counters to import. Empty imports any PC. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub frontier_pcs: Vec, + /// Fuzz branch frontier calldata selectors to import. Empty imports any selector. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub frontier_selectors: Vec, + /// Solver executable to invoke. + pub solver: String, + /// Exact solver command to invoke. When set, this overrides `solver`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub solver_command: Option, + /// Solver names or exact commands to race in parallel. Ignored when `solver_command` is set. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub solver_portfolio: Vec, + /// Optional SMT solver timeout in seconds. Also bounds the stateless deferred hard-arithmetic + /// solver phase and wall-clock symbolic invariant exploration before falling back to fuzzing. + pub timeout: Option, + /// Halmos-compatible loop bound accepted by config and annotations. + #[serde(default, rename = "loop", skip_serializing_if = "Option::is_none")] + pub loop_bound: Option, + /// Halmos-compatible execution depth alias. When set, this overrides `max_depth`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub depth: Option, + /// Halmos-compatible path width alias. When set, this overrides `max_paths`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub width: Option, + /// Maximum number of opcodes to execute along a path. + pub max_depth: u32, + /// Maximum number of symbolic paths to explore. + pub max_paths: u32, + /// Maximum number of calls in a bounded symbolic invariant sequence. + pub invariant_depth: u32, + /// Order used to select the next pending symbolic path. + #[serde(default)] + pub exploration_order: SymbolicExplorationOrder, + /// Maximum number of solver queries. + pub max_solver_queries: u32, + /// Default bounded length for dynamic ABI inputs. + pub default_dynamic_length: u32, + /// Maximum permitted bounded length for a dynamic ABI input. + pub max_dynamic_length: u32, + /// Per-dynamic-leaf bounded lengths, applied in ABI traversal order. + pub array_lengths: Vec, + /// Per-symbolic-input bounded lengths keyed by ABI argument name or generated symbolic name. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub dynamic_lengths: BTreeMap>, + /// Default bounded lengths for dynamic ABI arrays without an explicit length. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub default_array_lengths: Vec, + /// Default bounded lengths for ABI `bytes` and `string` values without an explicit length. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub default_bytes_lengths: Vec, + /// Maximum symbolic calldata size in bytes. + pub max_calldata_bytes: u32, + /// Whether symbolic call targets may be expanded over known deployed contracts. + pub symbolic_call_targets: bool, + /// Whether to dump SMT-LIB queries before invoking the configured solver. + pub dump_smt: bool, + /// Storage modelling mode used for symbolic storage reads. + pub storage_layout: SymbolicStorageLayout, +} + +impl Default for SymbolicConfig { + fn default() -> Self { + Self { + enabled: false, + seed_corpus: false, + use_fuzz_corpus: false, + corpus_seed_limit: 32, + use_fuzz_frontiers: false, + check_invariant_frontiers: false, + frontier_limit: 256, + frontier_ids: Vec::new(), + frontier_pcs: Vec::new(), + frontier_selectors: Vec::new(), + solver: "z3".to_string(), + solver_command: None, + solver_portfolio: Vec::new(), + timeout: Some(30), + loop_bound: None, + depth: None, + width: None, + max_depth: 10_000, + max_paths: 1_024, + invariant_depth: 10, + exploration_order: SymbolicExplorationOrder::default(), + max_solver_queries: 10_000, + default_dynamic_length: 2, + max_dynamic_length: 256, + array_lengths: Vec::new(), + dynamic_lengths: BTreeMap::new(), + default_array_lengths: Vec::new(), + default_bytes_lengths: Vec::new(), + max_calldata_bytes: 4_096, + symbolic_call_targets: false, + dump_smt: false, + storage_layout: SymbolicStorageLayout::Solidity, + } + } +} + +impl SymbolicConfig { + /// Returns the effective per-path opcode depth limit used by the symbolic executor. + /// + /// The Halmos-compatible `depth` alias takes precedence over `max_depth` so inline + /// compatibility annotations and native Foundry config resolve to one internal limit. + pub fn execution_depth(&self) -> u32 { + self.depth.unwrap_or(self.max_depth) + } + + /// Returns the effective symbolic path width limit used by the symbolic executor. + /// + /// The Halmos-compatible `width` alias takes precedence over `max_paths` so both + /// configuration spellings feed the same path exploration budget. + pub fn path_width(&self) -> u32 { + self.width.unwrap_or(self.max_paths) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_exploration_order_defaults_to_bfs() { + let value = serde_json::json!({ + "enabled": false, + "seed_corpus": false, + "use_fuzz_corpus": false, + "corpus_seed_limit": 32, + "use_fuzz_frontiers": false, + "frontier_limit": 256, + "frontier_ids": [], + "frontier_pcs": [], + "frontier_selectors": [], + "solver": "z3", + "timeout": 30, + "max_depth": 10000, + "max_paths": 1024, + "invariant_depth": 10, + "max_solver_queries": 10000, + "default_dynamic_length": 2, + "max_dynamic_length": 256, + "array_lengths": [], + "max_calldata_bytes": 4096, + "symbolic_call_targets": false, + "dump_smt": false, + "storage_layout": "solidity" + }); + + let config: SymbolicConfig = serde_json::from_value(value).unwrap(); + + assert_eq!(config.exploration_order, SymbolicExplorationOrder::Bfs); + } +} diff --git a/crates/config/src/trace.rs b/crates/config/src/trace.rs new file mode 100644 index 0000000000000..0d8dd21f02d1f --- /dev/null +++ b/crates/config/src/trace.rs @@ -0,0 +1,41 @@ +//! Configuration for trace rendering. + +use alloy_primitives::map::AddressHashMap; +use serde::{Deserialize, Serialize}; + +/// Configuration for trace rendering. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct TracingConfig { + /// Verbosity to use for trace rendering. + pub verbosity: u8, + /// Address labels to use in traces. + #[serde(default, skip_serializing_if = "AddressHashMap::is_empty")] + pub labels: AddressHashMap, + /// Whether to disable labels in traces. + pub disable_labels: bool, + /// Whether to hide addresses in trace parameters when labels are available. + pub compact_labels: bool, + /// Maximum depth of rendered traces. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trace_depth: Option, + /// Whether to identify internal functions in traces. + pub decode_internal: bool, + /// Cumulative Sourcify/Etherscan metadata lookup budget, in seconds. + /// Zero disables all external trace identification, including OpenChain. + pub external_identification_timeout: u64, +} + +impl Default for TracingConfig { + fn default() -> Self { + Self { + verbosity: 0, + labels: Default::default(), + disable_labels: false, + compact_labels: false, + trace_depth: None, + decode_internal: false, + external_identification_timeout: 5, + } + } +} diff --git a/crates/config/src/utils.rs b/crates/config/src/utils.rs index 8b316c0da9ca0..1da5b06698990 100644 --- a/crates/config/src/utils.rs +++ b/crates/config/src/utils.rs @@ -3,11 +3,7 @@ use crate::Config; use alloy_primitives::U256; use figment::value::Value; -use foundry_compilers::artifacts::{ - EvmVersion, - remappings::{Remapping, RemappingError}, -}; -use revm::primitives::hardfork::SpecId; +use foundry_compilers::artifacts::remappings::{Remapping, RemappingError}; use serde::{Deserialize, Deserializer, Serializer, de::Error}; use std::{ io, @@ -35,7 +31,7 @@ pub fn load_config_with_root(root: Option<&Path>) -> eyre::Result { pub fn find_git_root(relative_to: &Path) -> io::Result> { let root = if relative_to.is_absolute() { relative_to } else { &dunce::canonicalize(relative_to)? }; - Ok(root.ancestors().find(|p| p.join(".git").is_dir()).map(Path::to_path_buf)) + Ok(root.ancestors().find(|p| p.join(".git").exists()).map(Path::to_path_buf)) } /// Returns the root path to set for the project root. @@ -112,7 +108,7 @@ pub fn to_array_value(val: &str) -> Result { .trim_start_matches('[') .trim_end_matches(']') .split(',') - .map(|s| s.to_string()) + .map(|s| s.trim().to_string()) .collect::>() .into(), Value::Empty(_, _) => Vec::::new().into(), @@ -148,12 +144,38 @@ pub fn foundry_toml_dirs(root: impl AsRef) -> Vec { .max_depth(1) .into_iter() .filter_map(Result::ok) - .filter(|e| e.file_type().is_dir()) - .filter_map(|e| dunce::canonicalize(e.path()).ok()) - .filter(|p| p.join(Config::FILE_NAME).exists()) + .filter(|entry| entry.file_type().is_dir()) + .filter_map(|entry| dunce::canonicalize(entry.path()).ok()) + .filter(|path| path.join(Config::FILE_NAME).exists()) .collect() } +/// A depth-one dependency path and the canonical directory it resolves to. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct FoundryTomlDir { + /// The canonical directory used to identify the dependency config. + pub canonical: PathBuf, + /// The lexical path under the configured dependency directory. + pub path: PathBuf, +} + +/// Returns depth-one dependency entries that resolve to directories containing `foundry.toml`. +pub(crate) fn foundry_toml_dir_entries(root: impl AsRef) -> Vec { + let mut dirs = walkdir::WalkDir::new(root) + .max_depth(1) + .into_iter() + .filter_map(Result::ok) + .filter_map(|entry| { + let canonical = dunce::canonicalize(entry.path()).ok()?; + (canonical.is_dir() && canonical.join(Config::FILE_NAME).exists()) + .then(|| FoundryTomlDir { path: entry.path().to_path_buf(), canonical }) + }) + .collect::>(); + dirs.sort_unstable_by(|a, b| a.canonical.cmp(&b.canonical).then_with(|| a.path.cmp(&b.path))); + dirs.dedup(); + dirs +} + /// Returns a remapping for the given dir pub(crate) fn get_dir_remapping(dir: impl AsRef) -> Option { let dir = dir.as_ref(); @@ -213,7 +235,22 @@ where deserialize_u64_or_max(deserializer)?.try_into().map_err(D::Error::custom) } -/// Deserialize into `U256` from either a `u64` or a `U256` hex string. +/// Serialize a `usize` as `"max"` if it equals `usize::MAX`, as a string if it exceeds +/// `i64::MAX` (TOML integer limit), or as a plain number otherwise. +pub(crate) fn serialize_usize_or_max(value: &usize, serializer: S) -> Result +where + S: Serializer, +{ + if *value == usize::MAX { + serializer.serialize_str("max") + } else if *value > i64::MAX as usize { + serializer.serialize_str(&value.to_string()) + } else { + serializer.serialize_u64(*value as u64) + } +} + +/// Deserialize into `U256` from either a `u64`, a `U256` hex string, or a decimal string. pub fn deserialize_u64_to_u256<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, @@ -223,11 +260,16 @@ where enum NumericValue { U256(U256), U64(u64), + String(String), } match NumericValue::deserialize(deserializer)? { NumericValue::U64(n) => Ok(U256::from(n)), NumericValue::U256(n) => Ok(n), + NumericValue::String(s) => { + // Handle decimal strings (e.g., "18446744073709551615") + U256::from_str(&s).map_err(D::Error::custom) + } } } @@ -282,26 +324,82 @@ impl FromStr for Numeric { } } -/// Returns the [SpecId] derived from [EvmVersion] -#[inline] -pub fn evm_spec_id(evm_version: EvmVersion, odyssey: bool) -> SpecId { - if odyssey { - return SpecId::OSAKA; +#[cfg(all(test, unix))] +mod tests { + use super::{FoundryTomlDir, foundry_toml_dir_entries, foundry_toml_dirs}; + use std::{fs, os::unix::fs::symlink}; + use tempfile::tempdir; + + #[test] + fn finds_physical_foundry_toml_dirs_without_recursing() { + let temp = tempdir().unwrap(); + let root = temp.path().join("lib"); + let dependency = root.join("dependency"); + let nested = dependency.join("nested"); + fs::create_dir_all(&nested).unwrap(); + fs::write(dependency.join("foundry.toml"), "").unwrap(); + fs::write(nested.join("foundry.toml"), "").unwrap(); + + assert_eq!(foundry_toml_dirs(&root), vec![dunce::canonicalize(dependency).unwrap()]); + } + + #[test] + fn preserves_sorted_aliases_for_the_same_canonical_dependency() { + let temp = tempdir().unwrap(); + let root = temp.path().join("lib"); + let dependency = temp.path().join("dependency"); + fs::create_dir_all(&root).unwrap(); + fs::create_dir_all(&dependency).unwrap(); + fs::write(dependency.join("foundry.toml"), "").unwrap(); + symlink(&dependency, root.join("z-alias")).unwrap(); + symlink(&dependency, root.join("a-alias")).unwrap(); + + let canonical = dunce::canonicalize(dependency).unwrap(); + assert_eq!( + foundry_toml_dir_entries(&root), + vec![ + FoundryTomlDir { path: root.join("a-alias"), canonical: canonical.clone() }, + FoundryTomlDir { path: root.join("z-alias"), canonical }, + ] + ); } - match evm_version { - EvmVersion::Homestead => SpecId::HOMESTEAD, - EvmVersion::TangerineWhistle => SpecId::TANGERINE, - EvmVersion::SpuriousDragon => SpecId::SPURIOUS_DRAGON, - EvmVersion::Byzantium => SpecId::BYZANTIUM, - EvmVersion::Constantinople => SpecId::CONSTANTINOPLE, - EvmVersion::Petersburg => SpecId::PETERSBURG, - EvmVersion::Istanbul => SpecId::ISTANBUL, - EvmVersion::Berlin => SpecId::BERLIN, - EvmVersion::London => SpecId::LONDON, - EvmVersion::Paris => SpecId::MERGE, - EvmVersion::Shanghai => SpecId::SHANGHAI, - EvmVersion::Cancun => SpecId::CANCUN, - EvmVersion::Prague => SpecId::PRAGUE, - EvmVersion::Osaka => SpecId::OSAKA, + + #[test] + fn sorts_distinct_dependencies_by_canonical_path() { + let temp = tempdir().unwrap(); + let root = temp.path().join("lib"); + let dependency_a = temp.path().join("dependency-a"); + let dependency_z = temp.path().join("dependency-z"); + fs::create_dir_all(&root).unwrap(); + for dependency in [&dependency_z, &dependency_a] { + fs::create_dir_all(dependency).unwrap(); + fs::write(dependency.join("foundry.toml"), "").unwrap(); + } + symlink(&dependency_z, root.join("a-alias")).unwrap(); + symlink(&dependency_a, root.join("z-alias")).unwrap(); + + let canonical = foundry_toml_dir_entries(&root) + .into_iter() + .map(|entry| entry.canonical) + .collect::>(); + assert_eq!( + canonical, + vec![ + dunce::canonicalize(dependency_a).unwrap(), + dunce::canonicalize(dependency_z).unwrap(), + ] + ); + } + + #[test] + fn ignores_broken_and_cyclic_symlinks() { + let temp = tempdir().unwrap(); + let root = temp.path().join("lib"); + fs::create_dir_all(&root).unwrap(); + symlink("missing", root.join("broken")).unwrap(); + symlink("cycle-b", root.join("cycle-a")).unwrap(); + symlink("cycle-a", root.join("cycle-b")).unwrap(); + + assert!(foundry_toml_dir_entries(&root).is_empty()); } } diff --git a/crates/config/src/vyper.rs b/crates/config/src/vyper.rs index dbd47faec208d..f35b955cfd17b 100644 --- a/crates/config/src/vyper.rs +++ b/crates/config/src/vyper.rs @@ -1,18 +1,35 @@ //! Vyper specific configuration types. -use foundry_compilers::artifacts::vyper::VyperOptimizationMode; +use foundry_compilers::artifacts::vyper::{ + VyperOptimizationLevel, VyperOptimizationMode, VyperVenomSettings, +}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; #[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct VyperConfig { - /// Vyper optimization mode. "gas", "none" or "codesize" + /// Vyper optimization mode. #[serde(default, skip_serializing_if = "Option::is_none")] pub optimize: Option, + /// Vyper numeric optimization level. + #[serde(default, alias = "optLevel", skip_serializing_if = "Option::is_none")] + pub opt_level: Option, /// The Vyper instance to use if any. #[serde(default, skip_serializing_if = "Option::is_none")] pub path: Option, - /// Optionally enables experimental Venom pipeline + /// Enables Vyper's experimental code generation pipeline. #[serde(default, skip_serializing_if = "Option::is_none")] pub experimental_codegen: Option, + /// Enables Vyper's Venom pipeline through the `venomExperimental` standard-json setting. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub venom_experimental: Option, + /// Compile in debug mode. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub debug: Option, + /// Re-enable the Vyper decimal type. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enable_decimals: Option, + /// Fine-grained Venom optimizer settings. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub venom: Option, } diff --git a/crates/config/src/warning.rs b/crates/config/src/warning.rs index bbfe113d5a2ae..32a1183208892 100644 --- a/crates/config/src/warning.rs +++ b/crates/config/src/warning.rs @@ -46,6 +46,30 @@ pub enum Warning { /// is being removed completely without replacement new: String, }, + /// An unknown key was encountered in a profile in a TOML file + UnknownKey { + /// The unknown key name + key: String, + /// The profile where the key was found, if applicable + profile: String, + /// The config file where the key was found + source: String, + }, + /// An unknown key was encountered in a section in a TOML file + UnknownSectionKey { + /// The unknown key name + key: String, + /// The section where the key was found + section: String, + /// The config file where the key was found + source: String, + }, + /// The selected profile (via `FOUNDRY_PROFILE` or otherwise) does not exist in the config. + /// Falls back to the default profile. + UnknownProfile { + /// The selected profile that does not exist + profile: String, + }, } impl fmt::Display for Warning { @@ -87,6 +111,24 @@ impl fmt::Display for Warning { "Key `{old}` is being deprecated in favor of `{new}`. It will be removed in future versions." ) } + Self::UnknownKey { key, profile, source } => { + write!( + f, + "Found unknown `{key}` config for profile `{profile}` defined in {source}." + ) + } + Self::UnknownSectionKey { key, section, source } => { + write!( + f, + "Found unknown `{key}` config key in section `{section}` defined in {source}." + ) + } + Self::UnknownProfile { profile } => { + write!( + f, + "Selected profile `{profile}` does not exist; falling back to the default profile." + ) + } } } } diff --git a/crates/debugger/Cargo.toml b/crates/debugger/Cargo.toml index 4cf86e20a5045..3480a0a12b9e3 100644 --- a/crates/debugger/Cargo.toml +++ b/crates/debugger/Cargo.toml @@ -17,15 +17,28 @@ foundry-common.workspace = true foundry-compilers.workspace = true foundry-evm-traces.workspace = true foundry-evm-core.workspace = true -revm-inspectors.workspace = true +foundry-tui.workspace = true alloy-primitives.workspace = true +alloy-dyn-abi.workspace = true -crossterm = "0.28" +clap = { version = "4", features = ["derive"] } +crossterm.workspace = true eyre.workspace = true -ratatui = { version = "0.29", default-features = false, features = [ - "crossterm", -] } +ratatui = { workspace = true, features = ["crossterm"] } revm.workspace = true tracing.workspace = true serde.workspace = true + +[features] +default = ["optimism"] +base = [ + "foundry-common/base", + "foundry-evm-core/base", + "foundry-evm-traces/base", +] +optimism = [ + "foundry-common/optimism", + "foundry-evm-core/optimism", + "foundry-evm-traces/optimism", +] diff --git a/crates/debugger/src/builder.rs b/crates/debugger/src/builder.rs index 341da2767ca5d..3a304aef5d117 100644 --- a/crates/debugger/src/builder.rs +++ b/crates/debugger/src/builder.rs @@ -1,21 +1,41 @@ //! Debugger builder. -use crate::{DebugNode, Debugger, node::flatten_call_trace}; -use alloy_primitives::{Address, map::AddressHashMap}; -use foundry_common::{evm::Breakpoints, get_contract_name}; -use foundry_evm_traces::{CallTraceArena, CallTraceDecoder, Traces, debug::ContractSources}; +use crate::{ + Debugger, DebuggerLayout, debugger::DebuggerStats, node::flatten_call_trace_with_precompiles, +}; +use alloy_primitives::{ + Address, Bytes, + map::{AddressHashMap, HashMap}, +}; +use foundry_common::{ContractsByArtifact, get_contract_name, slot_identifier::SlotIdentifier}; +use foundry_evm_core::Breakpoints; +use foundry_evm_traces::{ + CallTraceArena, CallTraceDecoder, CallTraceNode, Traces, + debug::{ContractSources, DebugTraceIdentifier}, +}; + /// Debugger builder. #[derive(Debug, Default)] #[must_use = "builders do nothing unless you call `build` on them"] pub struct DebuggerBuilder { /// Debug traces returned from the EVM execution. - debug_arena: Vec, + trace_arenas: Vec, + /// Aggregate stats for the traces passed to the debugger. + stats: DebuggerStats, /// Identified contracts. identified_contracts: AddressHashMap, + /// Full artifact identifiers for identified contracts. + contract_identifiers: AddressHashMap, + /// Known local contracts and their compiler metadata. + known_contracts: ContractsByArtifact, + /// Active precompile labels for the current trace context. + precompile_labels: AddressHashMap, /// Map of source files. sources: ContractSources, /// Map of the debugger breakpoints. breakpoints: Breakpoints, + /// TUI layout selection. + layout: DebuggerLayout, } impl DebuggerBuilder { @@ -26,6 +46,8 @@ impl DebuggerBuilder { } /// Extends the debug arena. + /// + /// Internal calls are decoded during [`Self::build`], after resolving each frame's contract. #[inline] pub fn traces(mut self, traces: Traces) -> Self { for (_, arena) in traces { @@ -35,9 +57,17 @@ impl DebuggerBuilder { } /// Extends the debug arena. + /// + /// Internal calls are decoded during [`Self::build`], after resolving each frame's contract. #[inline] pub fn trace_arena(mut self, arena: CallTraceArena) -> Self { - flatten_call_trace(arena, &mut self.debug_arena); + if let Some(root) = arena.nodes().first() { + self.stats.session_trace_gas_used = + self.stats.session_trace_gas_used.saturating_add(root.trace.gas_used); + } + self.stats.session_subcalls = + self.stats.session_subcalls.saturating_add(arena.nodes().len().saturating_sub(1)); + self.trace_arenas.push(arena); self } @@ -52,9 +82,20 @@ impl DebuggerBuilder { /// Extends the identified contracts from a decoder. #[inline] - pub fn decoder(self, decoder: &CallTraceDecoder) -> Self { - let c = decoder.contracts.iter().map(|(k, v)| (*k, get_contract_name(v).to_string())); - self.identified_contracts(c) + pub fn decoder(mut self, decoder: &CallTraceDecoder) -> Self { + for (address, identifier) in &decoder.contracts { + self.identified_contracts.insert(*address, get_contract_name(identifier).to_string()); + self.contract_identifiers.insert(*address, identifier.clone()); + } + self.precompile_labels.extend(decoder.precompile_labels()); + self + } + + /// Sets known local contracts used to identify storage slots. + #[inline] + pub fn known_contracts(mut self, known_contracts: &ContractsByArtifact) -> Self { + self.known_contracts = known_contracts.clone(); + self } /// Extends the identified contracts. @@ -81,10 +122,192 @@ impl DebuggerBuilder { self } + /// Sets the TUI layout for the debugger. + #[inline] + pub const fn layout(mut self, layout: DebuggerLayout) -> Self { + self.layout = layout; + self + } + /// Builds the debugger. #[inline] pub fn build(self) -> Debugger { - let Self { debug_arena, identified_contracts, sources, breakpoints } = self; - Debugger::new(debug_arena, identified_contracts, sources, breakpoints) + let Self { + trace_arenas, + stats, + identified_contracts, + contract_identifiers, + known_contracts, + precompile_labels, + sources, + breakpoints, + layout, + } = self; + let slot_identifiers = contract_identifiers + .into_iter() + .filter_map(|(address, identifier)| { + let (_, contract) = + known_contracts.find_by_name_or_identifier(&identifier).ok().flatten()?; + let layout = contract.storage_layout.clone()?; + Some((address, SlotIdentifier::new(layout))) + }) + .collect(); + let mut identified_code = HashMap::default(); + let mut debug_arena = Vec::new(); + for mut arena in trace_arenas { + let contract_names = arena + .nodes_mut() + .iter_mut() + .map(|node| { + identify_node( + node, + &known_contracts, + &identified_contracts, + &sources, + &mut identified_code, + ) + }) + .collect::>(); + flatten_call_trace_with_precompiles( + arena, + &mut debug_arena, + &precompile_labels, + &contract_names, + ); + } + Debugger::new_with_stats( + debug_arena, + stats, + identified_contracts, + slot_identifiers, + sources, + breakpoints, + layout, + ) + } +} + +/// Identifies the contract executed by `node` from its recorded bytecode, since an address can +/// execute different code over time (e.g. after `vm.etch`), and decodes its internal calls. +fn identify_node( + node: &mut CallTraceNode, + known_contracts: &ContractsByArtifact, + identified_contracts: &AddressHashMap, + sources: &ContractSources, + identified_code: &mut HashMap<(Address, Bytes), Option>, +) -> Option { + let address = node.trace.address; + let address_name = identified_contracts.get(&address); + let contract_name = match &node.trace.bytecode { + Some(code) if !code.is_empty() && !node.trace.kind.is_any_create() => identified_code + .entry((address, code.clone())) + .or_insert_with(|| identify_code(known_contracts, address_name, code)) + .clone(), + _ => address_name.cloned(), + }; + if contract_name.as_ref() != address_name + && let Some(decoded) = node.trace.decoded.as_mut() + && decoded.label.as_ref() == address_name + { + decoded.label.clone_from(&contract_name); + } + if let Some(contract_name) = &contract_name + && !sources.artifacts_by_name.is_empty() + { + DebugTraceIdentifier::identify_node_steps_with_sources(node, sources, contract_name); + } + contract_name +} + +/// Identifies `code` by an exact match against local artifacts. Identities that aren't local +/// artifacts (e.g. from Etherscan) can't be checked, so they are kept when nothing matches. +fn identify_code( + known_contracts: &ContractsByArtifact, + address_name: Option<&String>, + code: &[u8], +) -> Option { + // Prefer the address identity only among equally strong runtime matches. + if let Some((id, _)) = known_contracts + .find_by_deployed_code_exact_preferred(code, |id| address_name == Some(&id.name)) + { + return Some(id.name.clone()); + } + // External identities cannot be checked against local artifacts. + address_name.filter(|name| !known_contracts.iter().any(|(id, _)| id.name == **name)).cloned() +} + +#[cfg(test)] +mod tests { + use super::*; + use foundry_evm_traces::{CallKind, CallTrace, CallTraceStep, TraceMemberOrder}; + use revm::{bytecode::opcode::OpCode, interpreter::InstructionResult}; + + fn step() -> CallTraceStep { + CallTraceStep { + pc: 0, + op: OpCode::STOP, + stack: None, + push_stack: None, + memory: None, + returndata: Bytes::new(), + gas_remaining: 0, + gas_refund_counter: 0, + gas_used: 0, + gas_cost: 0, + state_gas_cost: None, + state_gas_reservoir: None, + state_gas_spent: 0, + storage_change: None, + status: Some(InstructionResult::Stop), + immediate_bytes: None, + decoded: None, + } + } + + fn trace_arena(gas_used: u64, subcalls: usize) -> CallTraceArena { + let mut arena = CallTraceArena::default(); + + { + let root = &mut arena.nodes_mut()[0]; + root.trace.steps.push(step()); + root.trace.gas_limit = 1; + root.trace.gas_used = gas_used; + root.ordering.push(TraceMemberOrder::Step(0)); + + for idx in 1..=subcalls { + root.ordering.push(TraceMemberOrder::Call(idx - 1)); + root.children.push(idx); + } + } + + for idx in 1..=subcalls { + arena.nodes_mut().push(CallTraceNode { + parent: Some(0), + idx, + trace: CallTrace { depth: 1, kind: CallKind::Call, ..Default::default() }, + ..Default::default() + }); + } + + arena + } + + #[test] + fn trace_arena_accumulates_stats() { + let builder = Debugger::builder().trace_arena(trace_arena(100, 1)); + + assert_eq!(builder.stats.session_subcalls, 1); + assert_eq!(builder.stats.session_trace_gas_used, 100); + assert_eq!(builder.trace_arenas.len(), 1); + } + + #[test] + fn trace_arena_accumulates_session_stats_across_multiple_arenas() { + let builder = + Debugger::builder().trace_arena(trace_arena(100, 1)).trace_arena(trace_arena(200, 2)); + + assert_eq!(builder.stats.session_subcalls, 3); + assert_eq!(builder.stats.session_trace_gas_used, 300); + assert_eq!(builder.trace_arenas.len(), 2); } } diff --git a/crates/debugger/src/debugger.rs b/crates/debugger/src/debugger.rs index 776d149578b0f..986408e71aaee 100644 --- a/crates/debugger/src/debugger.rs +++ b/crates/debugger/src/debugger.rs @@ -2,17 +2,59 @@ use crate::{DebugNode, DebuggerBuilder, ExitReason, tui::TUI}; use alloy_primitives::map::AddressHashMap; +use clap::ValueEnum; use eyre::Result; -use foundry_common::evm::Breakpoints; +use foundry_common::slot_identifier::SlotIdentifier; +use foundry_evm_core::Breakpoints; use foundry_evm_traces::debug::ContractSources; use std::path::Path; +/// Debugger TUI layout selection. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)] +pub enum DebuggerLayout { + /// Select horizontal or vertical layout from the terminal size. + #[default] + Auto, + /// Force the two-column debugger layout. + Horizontal, + /// Force the single-column debugger layout. + Vertical, +} + +impl DebuggerLayout { + pub(crate) const fn next(self) -> Self { + match self { + Self::Auto | Self::Vertical => Self::Horizontal, + Self::Horizontal => Self::Vertical, + } + } + + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::Horizontal => "horizontal", + Self::Vertical => "vertical", + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct DebuggerStats { + /// Sum of root-call gas used across every trace arena passed to the debugger. + pub session_trace_gas_used: u64, + /// Number of subcalls in the traces passed to the debugger. + pub session_subcalls: usize, +} + pub struct DebuggerContext { pub debug_arena: Vec, + pub stats: Option, pub identified_contracts: AddressHashMap, + pub(crate) slot_identifiers: Option>, /// Source map of contract sources pub contracts_sources: ContractSources, pub breakpoints: Breakpoints, + pub layout: DebuggerLayout, } pub struct Debugger { @@ -27,7 +69,7 @@ impl Debugger { } /// Creates a new debugger. - pub fn new( + pub const fn new( debug_arena: Vec, identified_contracts: AddressHashMap, contracts_sources: ContractSources, @@ -36,9 +78,34 @@ impl Debugger { Self { context: DebuggerContext { debug_arena, + stats: None, identified_contracts, + slot_identifiers: None, contracts_sources, breakpoints, + layout: DebuggerLayout::Auto, + }, + } + } + + pub(crate) const fn new_with_stats( + debug_arena: Vec, + stats: DebuggerStats, + identified_contracts: AddressHashMap, + slot_identifiers: AddressHashMap, + contracts_sources: ContractSources, + breakpoints: Breakpoints, + layout: DebuggerLayout, + ) -> Self { + Self { + context: DebuggerContext { + debug_arena, + stats: Some(stats), + identified_contracts, + slot_identifiers: Some(slot_identifiers), + contracts_sources, + breakpoints, + layout, }, } } @@ -69,3 +136,59 @@ impl Debugger { crate::dump::dump(path, &self.context) } } + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::Address; + use foundry_common::ContractsByArtifactBuilder; + use foundry_compilers::{ + ArtifactId, + artifacts::{CompactContractBytecodeCow, StorageLayout}, + }; + use foundry_evm_traces::CallTraceDecoder; + use std::borrow::Cow; + + fn artifact_id(name: &str, profile: &str) -> ArtifactId { + ArtifactId { + path: format!("out/{profile}/{name}.json").into(), + name: name.to_string(), + source: format!("src/{name}.sol").into(), + version: "0.8.30".parse().unwrap(), + build_id: profile.to_string(), + profile: profile.to_string(), + } + } + + #[test] + fn builder_skips_ambiguous_storage_layout_matches() { + let ids = [ + artifact_id("Ambiguous", "default"), + artifact_id("Ambiguous", "optimized"), + artifact_id("Unique", "default"), + ]; + let known_contracts = ContractsByArtifactBuilder::new(ids.iter().cloned().map(|id| { + ( + id, + CompactContractBytecodeCow { + abi: Some(Cow::Owned(Default::default())), + ..Default::default() + }, + ) + })) + .with_storage_layouts(ids.iter().cloned().map(|id| (id, StorageLayout::default()))) + .build(); + let ambiguous_address = Address::repeat_byte(0x11); + let unique_address = Address::repeat_byte(0x22); + let mut decoder = CallTraceDecoder::default(); + decoder.contracts.insert(ambiguous_address, ids[0].identifier()); + decoder.contracts.insert(unique_address, ids[2].identifier()); + + let debugger = + Debugger::builder().decoder(&decoder).known_contracts(&known_contracts).build(); + let slot_identifiers = debugger.context.slot_identifiers.as_ref().unwrap(); + + assert!(!slot_identifiers.contains_key(&ambiguous_address)); + assert!(slot_identifiers.contains_key(&unique_address)); + } +} diff --git a/crates/debugger/src/dump.rs b/crates/debugger/src/dump.rs index 6a910d26f8de9..26af971ce3cb2 100644 --- a/crates/debugger/src/dump.rs +++ b/crates/debugger/src/dump.rs @@ -10,12 +10,6 @@ use foundry_evm_traces::debug::{ArtifactData, ContractSources, SourceData}; use serde::Serialize; use std::{collections::HashMap, path::Path}; -/// Dumps debugger data to a JSON file. -pub(crate) fn dump(path: &Path, context: &DebuggerContext) -> eyre::Result<()> { - write_json_file(path, &DebuggerDump::new(context))?; - Ok(()) -} - /// Holds info of debugger dump. #[derive(Serialize)] struct DebuggerDump<'a> { @@ -146,3 +140,9 @@ impl<'a> ArtifactDataDump<'a> { } } } + +/// Dumps debugger data to a JSON file. +pub(crate) fn dump(path: &Path, context: &DebuggerContext) -> eyre::Result<()> { + write_json_file(path, &DebuggerDump::new(context))?; + Ok(()) +} diff --git a/crates/debugger/src/lib.rs b/crates/debugger/src/lib.rs index 1c1bf9614ee2e..1340604edf3e2 100644 --- a/crates/debugger/src/lib.rs +++ b/crates/debugger/src/lib.rs @@ -3,7 +3,7 @@ //! Interactive Solidity TUI debugger and debugger data file dumper #![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] +#![cfg_attr(docsrs, feature(doc_cfg))] #[macro_use] extern crate foundry_common; @@ -23,5 +23,5 @@ mod node; pub use node::DebugNode; pub use builder::DebuggerBuilder; -pub use debugger::Debugger; +pub use debugger::{Debugger, DebuggerLayout}; pub use tui::{ExitReason, TUI}; diff --git a/crates/debugger/src/node.rs b/crates/debugger/src/node.rs index f7b18a978a4f4..55cfdfbd44b5d 100644 --- a/crates/debugger/src/node.rs +++ b/crates/debugger/src/node.rs @@ -1,8 +1,14 @@ -use alloy_primitives::{Address, Bytes}; -use foundry_evm_traces::{CallKind, CallTraceArena}; -use revm_inspectors::tracing::types::{CallTraceStep, TraceMemberOrder}; +use alloy_primitives::{Address, Bytes, hex, map::AddressHashMap}; +use foundry_evm_core::precompiles; +use foundry_evm_traces::{ + CallKind, CallTrace, CallTraceArena, CallTraceStep, DecodedCallTrace, DecodedTraceStep, + TraceMemberOrder, +}; +use revm::bytecode::opcode::OpCode; use serde::{Deserialize, Serialize}; +const PRECOMPILES_TRACE_LABEL: &str = "PRECOMPILES"; + /// Represents a part of the execution frame before the next call or end of the execution. #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct DebugNode { @@ -14,19 +20,49 @@ pub struct DebugNode { pub kind: CallKind, /// Calldata of the call. pub calldata: Bytes, + /// Return data produced by the call. + #[serde(default)] + pub returndata: Bytes, + /// The gas limit of the call. + pub gas_limit: u64, + /// Stable id for the original call trace node within the flattened debugger arena. + #[serde(default)] + pub trace_node_idx: usize, + /// Index of the first step in the original call trace node. + #[serde(default)] + pub step_offset: usize, + /// Decoded call data for the current execution context, if available. + #[serde(default)] + pub decoded: Option>, + /// Name of the contract whose code is executed in this context, if identified. + #[serde(default)] + pub contract_name: Option, /// The debug steps. pub steps: Vec, } impl DebugNode { /// Creates a new debug node. - pub fn new( + pub const fn new( address: Address, kind: CallKind, steps: Vec, calldata: Bytes, + gas_limit: u64, + decoded: Option>, ) -> Self { - Self { address, kind, steps, calldata } + Self { + address, + kind, + steps, + calldata, + returndata: Bytes::new(), + gas_limit, + trace_node_idx: 0, + step_offset: 0, + decoded, + contract_name: None, + } } } @@ -34,35 +70,87 @@ impl DebugNode { /// /// This is done by recursively traversing the call tree and collecting the steps in-between the /// calls. -pub fn flatten_call_trace(arena: CallTraceArena, out: &mut Vec) { +#[cfg(test)] +fn flatten_call_trace(arena: CallTraceArena, out: &mut Vec) { + flatten_call_trace_with_precompiles(arena, out, &AddressHashMap::default(), &[]); +} + +/// Flattens given [CallTraceArena] into a list of [DebugNode]s using active precompile labels. +/// +/// `contract_names` holds the identified contract name for each arena node, indexed by node index. +pub fn flatten_call_trace_with_precompiles( + arena: CallTraceArena, + out: &mut Vec, + precompile_labels: &AddressHashMap, + contract_names: &[Option], +) { #[derive(Debug, Clone, Copy)] struct PendingNode { node_idx: usize, steps_count: usize, + step_offset: usize, } - fn inner(arena: &CallTraceArena, node_idx: usize, out: &mut Vec) { - let mut pending = PendingNode { node_idx, steps_count: 0 }; + fn inner( + arena: &CallTraceArena, + node_idx: usize, + out: &mut Vec, + step_notices: &mut Vec<(usize, usize, String)>, + precompile_labels: &AddressHashMap, + ) { + let mut pending = PendingNode { node_idx, steps_count: 0, step_offset: 0 }; + let mut next_step_offset = 0; + let mut last_step_idx: Option = None; let node = &arena.nodes()[node_idx]; for order in &node.ordering { match order { TraceMemberOrder::Call(idx) => { + let child_idx = node.children[*idx]; + if let Some(step_idx) = last_step_idx.take() + && let Some(step) = node.trace.steps.get(step_idx) + && is_call_like_op(step.op) + && let Some(notice) = precompile_call_notice( + &arena.nodes()[child_idx].trace, + precompile_labels, + ) + { + step_notices.push((node_idx, step_idx, notice)); + } out.push(pending); - pending.steps_count = 0; - inner(arena, node.children[*idx], out); + pending = + PendingNode { node_idx, steps_count: 0, step_offset: next_step_offset }; + inner(arena, child_idx, out, step_notices, precompile_labels); } - TraceMemberOrder::Step(_) => { + TraceMemberOrder::Step(step_idx) => { + if pending.steps_count == 0 { + pending.step_offset = *step_idx; + } pending.steps_count += 1; + next_step_offset = step_idx.saturating_add(1); + last_step_idx = Some(*step_idx); + } + _ => { + last_step_idx = None; } - _ => {} } } out.push(pending); } let mut nodes = Vec::new(); - inner(&arena, 0, &mut nodes); + let mut step_notices = Vec::new(); + inner(&arena, 0, &mut nodes, &mut step_notices, precompile_labels); let mut arena_nodes = arena.into_nodes(); + for (node_idx, step_idx, notice) in step_notices { + if let Some(step) = + arena_nodes.get_mut(node_idx).and_then(|node| node.trace.steps.get_mut(step_idx)) + { + set_step_notice(step, notice); + } + } + + let trace_node_idx_offset = + out.iter().map(|node| node.trace_node_idx).max().map_or(0, |idx| idx.saturating_add(1)); for pending in nodes { let steps = { @@ -78,8 +166,505 @@ pub fn flatten_call_trace(arena: CallTraceArena, out: &mut Vec) { let call = &arena_nodes[pending.node_idx].trace; let calldata = if call.kind.is_any_create() { Bytes::new() } else { call.data.clone() }; - let node = DebugNode::new(call.address, call.kind, steps, calldata); + let mut node = DebugNode::new( + call.address, + call.kind, + steps, + calldata, + call.gas_limit, + call.decoded.clone(), + ); + node.returndata = call.output.clone(); + node.trace_node_idx = trace_node_idx_offset.saturating_add(pending.node_idx); + node.step_offset = pending.step_offset; + node.contract_name = contract_names.get(pending.node_idx).cloned().flatten(); out.push(node); } } + +fn set_step_notice(step: &mut CallTraceStep, notice: String) { + match step.decoded.as_deref_mut() { + None => step.decoded = Some(Box::new(DecodedTraceStep::Line(notice))), + Some(DecodedTraceStep::Line(line)) if line.is_empty() => *line = notice, + Some(_) => {} + } +} + +const fn is_call_like_op(op: OpCode) -> bool { + matches!( + op, + OpCode::CALL + | OpCode::STATICCALL + | OpCode::DELEGATECALL + | OpCode::CALLCODE + | OpCode::CREATE + | OpCode::CREATE2 + ) +} + +fn precompile_call_notice( + trace: &CallTrace, + precompile_labels: &AddressHashMap, +) -> Option { + decoded_precompile_call_notice(&trace.decoded) + .or_else(|| labeled_precompile_call_notice(trace, precompile_labels)) + .or_else(|| known_precompile_call_notice(trace)) +} + +fn decoded_precompile_call_notice(decoded: &Option>) -> Option { + let decoded = decoded.as_ref()?; + let label = decoded.label.as_deref()?; + if label != PRECOMPILES_TRACE_LABEL { + return None; + } + + Some(decoded_call_notice(label, decoded)) +} + +fn decoded_call_notice(label: &str, decoded: &DecodedCallTrace) -> String { + let Some(call_data) = &decoded.call_data else { + return format!("precompile: {label}"); + }; + let args = call_data.args.join(", "); + let function_name = + call_data.signature.split_once('(').map_or(call_data.signature.as_str(), |(name, _)| name); + let mut notice = format!("precompile: {label}::{function_name}({args})"); + if let Some(return_data) = decoded.return_data.as_deref() { + notice.push_str(" -> "); + notice.push_str(return_data); + } + notice +} + +fn labeled_precompile_call_notice( + trace: &CallTrace, + precompile_labels: &AddressHashMap, +) -> Option { + let label = precompile_labels.get(&trace.address)?; + if let Some(decoded) = trace.decoded.as_ref() { + return Some(decoded_call_notice( + decoded.label.as_deref().unwrap_or(label.as_str()), + decoded, + )); + } + + let mut notice = format!("precompile: {label} @ {}", trace.address); + append_raw_precompile_io(&mut notice, trace); + Some(notice) +} + +fn known_precompile_call_notice(trace: &CallTrace) -> Option { + if trace.address == precompiles::P256_VERIFY && trace.maybe_precompile != Some(true) { + return None; + } + let name = known_precompile_name(trace.address)?; + let mut notice = format!("precompile: {name} @ {}", trace.address); + append_raw_precompile_io(&mut notice, trace); + Some(notice) +} + +fn append_raw_precompile_io(notice: &mut String, trace: &CallTrace) { + if !trace.data.is_empty() { + notice.push_str(" input="); + notice.push_str(&hex::encode_prefixed(&trace.data)); + } + if !trace.output.is_empty() { + notice.push_str(" output="); + notice.push_str(&hex::encode_prefixed(&trace.output)); + } +} + +// Standard EVM fallback for traces that have no decoded precompile metadata. +const fn known_precompile_name(address: Address) -> Option<&'static str> { + match address { + precompiles::EC_RECOVER => Some("ecrecover"), + precompiles::SHA_256 => Some("sha256"), + precompiles::RIPEMD_160 => Some("ripemd"), + precompiles::IDENTITY => Some("identity"), + precompiles::MOD_EXP => Some("modexp"), + precompiles::EC_ADD => Some("ecadd"), + precompiles::EC_MUL => Some("ecmul"), + precompiles::EC_PAIRING => Some("ecpairing"), + precompiles::BLAKE_2F => Some("blake2f"), + precompiles::POINT_EVALUATION => Some("pointEvaluation"), + precompiles::BLS12_G1ADD => Some("bls12G1Add"), + precompiles::BLS12_G1MSM => Some("bls12G1Msm"), + precompiles::BLS12_G2ADD => Some("bls12G2Add"), + precompiles::BLS12_G2MSM => Some("bls12G2Msm"), + precompiles::BLS12_PAIRING_CHECK => Some("bls12PairingCheck"), + precompiles::BLS12_MAP_FP_TO_G1 => Some("bls12MapFpToG1"), + precompiles::BLS12_MAP_FP2_TO_G2 => Some("bls12MapFp2ToG2"), + precompiles::P256_VERIFY => Some("p256Verify"), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::bytes; + use foundry_evm_traces::{CallTraceNode, DecodedCallData, DecodedInternalCall}; + use revm::interpreter::InstructionResult; + + fn step(pc: usize) -> CallTraceStep { + CallTraceStep { + pc, + op: OpCode::STOP, + stack: None, + push_stack: None, + memory: None, + returndata: Bytes::new(), + gas_remaining: 0, + gas_refund_counter: 0, + gas_used: 0, + gas_cost: 0, + state_gas_cost: None, + state_gas_reservoir: None, + state_gas_spent: 0, + storage_change: None, + status: Some(InstructionResult::Stop), + immediate_bytes: None, + decoded: None, + } + } + + fn step_with_op(pc: usize, op: OpCode) -> CallTraceStep { + CallTraceStep { op, ..step(pc) } + } + + fn known_sha256_precompile_node() -> CallTraceNode { + CallTraceNode { + parent: Some(0), + idx: 1, + trace: CallTrace { + address: precompiles::SHA_256, + data: Bytes::from_static(b"hello"), + output: bytes!("2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"), + ..Default::default() + }, + ordering: Vec::new(), + ..Default::default() + } + } + + fn arena_with_child_after_staticcall(child_trace: CallTrace) -> CallTraceArena { + let mut arena = CallTraceArena::default(); + + { + let root = &mut arena.nodes_mut()[0]; + root.trace.steps = + vec![step_with_op(0, OpCode::STATICCALL), step_with_op(1, OpCode::STOP)]; + root.ordering = vec![ + TraceMemberOrder::Step(0), + TraceMemberOrder::Call(0), + TraceMemberOrder::Step(1), + ]; + root.children.push(1); + } + + arena.nodes_mut().push(CallTraceNode { + parent: Some(0), + idx: 1, + trace: child_trace, + ordering: Vec::new(), + ..Default::default() + }); + + arena + } + + fn decoded_fee_manager_trace() -> CallTrace { + CallTrace { + address: Address::repeat_byte(0x42), + decoded: Some(Box::new(DecodedCallTrace { + label: Some("FeeManager".to_string()), + call_data: Some(DecodedCallData { + signature: "userTokens(address)".to_string(), + args: vec!["0x0000000000000000000000000000000000000000".to_string()], + }), + return_data: Some("0x0000000000000000000000000000000000000000".to_string()), + })), + ..Default::default() + } + } + + fn assert_no_precompile_notice(step: &CallTraceStep) { + assert!( + !matches!( + step.decoded.as_deref(), + Some(DecodedTraceStep::Line(line)) if line.starts_with("precompile:") + ), + "unexpected precompile notice: {:?}", + step.decoded + ); + } + + fn assert_precompile_notice(step: &CallTraceStep) -> &str { + let Some(DecodedTraceStep::Line(notice)) = step.decoded.as_deref() else { + panic!("missing precompile notice"); + }; + notice + } + + #[test] + fn flatten_records_original_step_offsets_for_split_segments() { + let mut arena = CallTraceArena::default(); + + { + let root = &mut arena.nodes_mut()[0]; + root.trace.steps = vec![step(0), step(1), step(2)]; + root.ordering = vec![ + TraceMemberOrder::Step(0), + TraceMemberOrder::Call(0), + TraceMemberOrder::Step(1), + TraceMemberOrder::Step(2), + ]; + root.children.push(1); + } + + arena.nodes_mut().push(CallTraceNode { + parent: Some(0), + idx: 1, + trace: CallTrace { kind: CallKind::Call, steps: vec![step(10)], ..Default::default() }, + ordering: vec![TraceMemberOrder::Step(0)], + ..Default::default() + }); + + let mut flattened = Vec::new(); + flatten_call_trace(arena, &mut flattened); + + assert_eq!(flattened.len(), 3); + assert_eq!((flattened[0].trace_node_idx, flattened[0].step_offset), (0, 0)); + assert_eq!(flattened[0].steps.iter().map(|step| step.pc).collect::>(), [0]); + assert_eq!((flattened[1].trace_node_idx, flattened[1].step_offset), (1, 0)); + assert_eq!(flattened[1].steps.iter().map(|step| step.pc).collect::>(), [10]); + assert_eq!((flattened[2].trace_node_idx, flattened[2].step_offset), (0, 1)); + assert_eq!(flattened[2].steps.iter().map(|step| step.pc).collect::>(), [1, 2]); + } + + #[test] + fn flatten_keeps_trace_node_ids_unique_when_appending_multiple_arenas() { + let mut flattened = vec![DebugNode { trace_node_idx: 1, ..Default::default() }]; + + let mut arena = CallTraceArena::default(); + { + let root = &mut arena.nodes_mut()[0]; + root.trace.steps = vec![step(0)]; + root.ordering = vec![TraceMemberOrder::Step(0)]; + } + + flatten_call_trace(arena, &mut flattened); + + assert_eq!(flattened[1].trace_node_idx, 2); + } + + #[test] + fn flatten_annotates_parent_step_for_precompile_child_calls() { + let mut arena = CallTraceArena::default(); + + { + let root = &mut arena.nodes_mut()[0]; + root.trace.steps = + vec![step_with_op(0, OpCode::STATICCALL), step_with_op(1, OpCode::STOP)]; + root.ordering = vec![ + TraceMemberOrder::Step(0), + TraceMemberOrder::Call(0), + TraceMemberOrder::Step(1), + ]; + root.children.push(1); + } + + arena.nodes_mut().push(CallTraceNode { + parent: Some(0), + idx: 1, + trace: CallTrace { + decoded: Some(Box::new(DecodedCallTrace { + label: Some("PRECOMPILES".to_string()), + call_data: Some(DecodedCallData { + signature: "sha256(bytes)".to_string(), + args: vec!["0x68656c6c6f".to_string()], + }), + return_data: Some( + "0x2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + .to_string(), + ), + })), + ..Default::default() + }, + ordering: Vec::new(), + ..Default::default() + }); + + let mut flattened = Vec::new(); + flatten_call_trace(arena, &mut flattened); + + let Some(DecodedTraceStep::Line(notice)) = flattened[0].steps[0].decoded.as_deref() else { + panic!("missing precompile notice"); + }; + assert_eq!( + notice, + "precompile: PRECOMPILES::sha256(0x68656c6c6f) -> 0x2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + ); + } + + #[test] + fn flatten_marks_known_precompile_child_calls_without_decoded_trace() { + let mut arena = CallTraceArena::default(); + + { + let root = &mut arena.nodes_mut()[0]; + root.trace.steps = + vec![step_with_op(0, OpCode::STATICCALL), step_with_op(1, OpCode::STOP)]; + root.ordering = vec![ + TraceMemberOrder::Step(0), + TraceMemberOrder::Call(0), + TraceMemberOrder::Step(1), + ]; + root.children.push(1); + } + + arena.nodes_mut().push(known_sha256_precompile_node()); + + let mut flattened = Vec::new(); + flatten_call_trace(arena, &mut flattened); + + let Some(DecodedTraceStep::Line(notice)) = flattened[0].steps[0].decoded.as_deref() else { + panic!("missing precompile notice"); + }; + assert_eq!( + notice, + "precompile: sha256 @ 0x0000000000000000000000000000000000000002 input=0x68656c6c6f output=0x2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + ); + } + + #[test] + fn flatten_does_not_guess_unconfirmed_p256_precompile_calls() { + for maybe_precompile in [None, Some(false)] { + let arena = arena_with_child_after_staticcall(CallTrace { + address: precompiles::P256_VERIFY, + maybe_precompile, + ..Default::default() + }); + + let mut flattened = Vec::new(); + flatten_call_trace(arena, &mut flattened); + + assert_no_precompile_notice(&flattened[0].steps[0]); + } + + let arena = arena_with_child_after_staticcall(CallTrace { + address: precompiles::P256_VERIFY, + maybe_precompile: Some(true), + ..Default::default() + }); + let mut flattened = Vec::new(); + flatten_call_trace(arena, &mut flattened); + assert_eq!( + assert_precompile_notice(&flattened[0].steps[0]), + "precompile: p256Verify @ 0x0000000000000000000000000000000000000100" + ); + } + + #[test] + fn flatten_annotates_chain_labeled_precompile_child_calls_with_decoded_label() { + let address = Address::repeat_byte(0x42); + let arena = arena_with_child_after_staticcall(decoded_fee_manager_trace()); + let precompile_labels = AddressHashMap::from_iter([(address, "FeeManager".to_string())]); + + let mut flattened = Vec::new(); + flatten_call_trace_with_precompiles(arena, &mut flattened, &precompile_labels, &[]); + + assert_eq!( + assert_precompile_notice(&flattened[0].steps[0]), + "precompile: FeeManager::userTokens(0x0000000000000000000000000000000000000000) -> 0x0000000000000000000000000000000000000000" + ); + } + + #[test] + fn flatten_skips_decoded_label_without_active_precompile_label() { + let arena = arena_with_child_after_staticcall(decoded_fee_manager_trace()); + + let mut flattened = Vec::new(); + flatten_call_trace(arena, &mut flattened); + + assert_no_precompile_notice(&flattened[0].steps[0]); + } + + #[test] + fn flatten_preserves_existing_decoded_step_metadata_on_precompile_child_calls() { + let mut arena = CallTraceArena::default(); + + { + let root = &mut arena.nodes_mut()[0]; + let mut call_step = step_with_op(0, OpCode::STATICCALL); + call_step.decoded = Some(Box::new(DecodedTraceStep::InternalCall( + DecodedInternalCall { + func_name: "DebugMe::foo".to_string(), + args: Some(vec!["1".to_string()]), + return_data: Some(vec!["2".to_string()]), + }, + 1, + ))); + root.trace.steps = vec![call_step, step_with_op(1, OpCode::STOP)]; + root.ordering = vec![ + TraceMemberOrder::Step(0), + TraceMemberOrder::Call(0), + TraceMemberOrder::Step(1), + ]; + root.children.push(1); + } + arena.nodes_mut().push(known_sha256_precompile_node()); + + let mut flattened = Vec::new(); + flatten_call_trace(arena, &mut flattened); + + let Some(DecodedTraceStep::InternalCall(decoded, end_step)) = + flattened[0].steps[0].decoded.as_deref() + else { + panic!("expected existing internal call metadata to be preserved"); + }; + assert_eq!(decoded.func_name, "DebugMe::foo"); + assert_eq!(*end_step, 1); + } + + #[test] + fn flatten_skips_precompile_notice_without_preceding_step() { + let mut arena = CallTraceArena::default(); + + { + let root = &mut arena.nodes_mut()[0]; + root.trace.steps = vec![step_with_op(0, OpCode::STOP)]; + root.ordering = vec![TraceMemberOrder::Call(0), TraceMemberOrder::Step(0)]; + root.children.push(1); + } + arena.nodes_mut().push(known_sha256_precompile_node()); + + let mut flattened = Vec::new(); + flatten_call_trace(arena, &mut flattened); + + assert_no_precompile_notice(&flattened[0].steps[0]); + } + + #[test] + fn flatten_skips_precompile_notice_after_non_call_step() { + let mut arena = CallTraceArena::default(); + + { + let root = &mut arena.nodes_mut()[0]; + root.trace.steps = vec![step_with_op(0, OpCode::STOP), step_with_op(1, OpCode::STOP)]; + root.ordering = vec![ + TraceMemberOrder::Step(0), + TraceMemberOrder::Call(0), + TraceMemberOrder::Step(1), + ]; + root.children.push(1); + } + arena.nodes_mut().push(known_sha256_precompile_node()); + + let mut flattened = Vec::new(); + flatten_call_trace(arena, &mut flattened); + + assert_no_precompile_notice(&flattened[0].steps[0]); + } +} diff --git a/crates/debugger/src/tui/context.rs b/crates/debugger/src/tui/context.rs index 5f1d023640160..44be7f91c6bd6 100644 --- a/crates/debugger/src/tui/context.rs +++ b/crates/debugger/src/tui/context.rs @@ -1,19 +1,108 @@ //! Debugger context and event handler implementation. -use crate::{DebugNode, ExitReason, debugger::DebuggerContext}; -use alloy_primitives::{Address, hex}; +use super::storage::{ + StorageAccess, StorageSpace, hex_u256, next_storage_write_values, storage_access_at, + storage_accesses_until, +}; +use crate::{DebugNode, DebuggerLayout, ExitReason, debugger::DebuggerContext}; +use alloy_primitives::{ + Address, B256, U256, hex, + map::{B256Map, IndexMap}, +}; use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}; -use foundry_evm_core::buffer::BufferKind; +use foundry_compilers::artifacts::sourcemap::SourceElement; +use foundry_evm_core::buffer::{BufferKind, get_buffer_accesses}; +use foundry_evm_traces::{CallKind, CallTraceStep, debug::SourceData}; +use foundry_tui::TuiApp; +use ratatui::Frame; use revm::bytecode::opcode::OpCode; -use revm_inspectors::tracing::types::{CallKind, CallTraceStep}; -use std::ops::ControlFlow; +use std::{fmt::Write, ops::ControlFlow}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum StatusKind { + Info, + Error, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct StatusMessage { + pub(crate) kind: StatusKind, + pub(crate) text: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct ActiveInternalCallLocation { + pub(crate) trace_node_idx: usize, + pub(crate) marker_node_idx: usize, + pub(crate) marker_step_idx: usize, + pub(crate) entry_step: usize, + pub(crate) end_step: usize, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct ActiveInternalCallCache { + pub(crate) current_node_idx: usize, + pub(crate) trace_node_idx: usize, + pub(crate) absolute_step: usize, + pub(crate) location: Option, +} + +impl ActiveInternalCallCache { + pub(crate) const fn matches( + self, + current_node_idx: usize, + trace_node_idx: usize, + absolute_step: usize, + ) -> bool { + self.current_node_idx == current_node_idx + && self.trace_node_idx == trace_node_idx + && self.absolute_step == absolute_step + } +} /// This is currently used to remember last scroll position so screen doesn't wiggle as much. #[derive(Default)] pub(crate) struct DrawMemory { pub(crate) inner_call_index: usize, pub(crate) current_buf_startline: usize, + pub(crate) current_storage_startline: usize, pub(crate) current_stack_startline: usize, + pub(crate) active_internal_call: Option, +} + +#[derive(Default)] +struct OpcodeListState { + inner_call_index: Option, + max_pc: usize, +} + +/// The active prompt and its current contents. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum Prompt { + Pc(String), + BufferOffset(String), + Command(String), + OpcodeSearch(String), +} + +impl Prompt { + const fn input_mut(&mut self) -> &mut String { + match self { + Self::Pc(input) + | Self::BufferOffset(input) + | Self::Command(input) + | Self::OpcodeSearch(input) => input, + } + } + + fn accepts(&self, c: char) -> bool { + match self { + Self::Pc(_) => c.is_ascii_hexdigit() || matches!(c, 'x' | 'X' | ':'), + Self::BufferOffset(input) => is_buffer_offset_input_char(input, c), + Self::Command(_) => !c.is_control(), + Self::OpcodeSearch(_) => true, + } + } } pub(crate) struct TUIContext<'a> { @@ -21,18 +110,29 @@ pub(crate) struct TUIContext<'a> { /// Buffer for keys prior to execution, i.e. '10' + 'k' => move up 10 operations. pub(crate) key_buffer: String, + /// Current prompt, if one is active. + pub(crate) prompt: Option, + /// Last opcode search term, used by repeat-search shortcuts. + pub(crate) last_opcode_search: Option, + /// Last status or error message to show in the footer. + pub(crate) status: Option, /// Current step in the debug steps. pub(crate) current_step: usize, pub(crate) draw_memory: DrawMemory, - pub(crate) opcode_list: Vec, - pub(crate) last_index: usize, + opcode_list: OpcodeListState, pub(crate) stack_labels: bool, /// Whether to decode active buffer as utf8 or not. pub(crate) buf_utf: bool, pub(crate) show_shortcuts: bool, + pub(crate) show_opcodes: bool, + pub(crate) show_source: bool, + pub(crate) show_variables: bool, + pub(crate) show_stack: bool, + pub(crate) show_data: bool, /// The currently active buffer (memory, calldata, returndata) to be drawn. pub(crate) active_buffer: BufferKind, + active_storage: Option, } impl<'a> TUIContext<'a> { @@ -41,26 +141,38 @@ impl<'a> TUIContext<'a> { debugger_context, key_buffer: String::with_capacity(64), + prompt: None, + last_opcode_search: None, + status: None, current_step: 0, draw_memory: DrawMemory::default(), - opcode_list: Vec::new(), - last_index: 0, + opcode_list: OpcodeListState::default(), stack_labels: false, buf_utf: false, show_shortcuts: true, + show_opcodes: true, + show_source: true, + show_variables: true, + show_stack: true, + show_data: true, active_buffer: BufferKind::Memory, + active_storage: None, } } pub(crate) fn init(&mut self) { - self.gen_opcode_list(); + self.refresh_opcode_list_state(); } pub(crate) fn debug_arena(&self) -> &[DebugNode] { &self.debugger_context.debug_arena } + pub(crate) const fn layout(&self) -> DebuggerLayout { + self.debugger_context.layout + } + pub(crate) fn debug_call(&self) -> &DebugNode { &self.debug_arena()[self.draw_memory.inner_call_index] } @@ -85,29 +197,112 @@ impl<'a> TUIContext<'a> { &self.debug_steps()[self.current_step] } - fn gen_opcode_list(&mut self) { - self.opcode_list.clear(); - let debug_steps = - &self.debugger_context.debug_arena[self.draw_memory.inner_call_index].steps; - for step in debug_steps { - self.opcode_list.push(pretty_opcode(step)); - } + pub(super) const fn opcode_max_pc(&self) -> usize { + self.opcode_list.max_pc } - fn gen_opcode_list_if_necessary(&mut self) { - if self.last_index != self.draw_memory.inner_call_index { - self.gen_opcode_list(); - self.last_index = self.draw_memory.inner_call_index; + fn refresh_opcode_list_state(&mut self) { + let inner_call_index = self.draw_memory.inner_call_index; + if self.opcode_list.inner_call_index == Some(inner_call_index) { + return; } + + let debug_steps = &self.debugger_context.debug_arena[inner_call_index].steps; + self.opcode_list.max_pc = debug_steps.iter().map(|step| step.pc).max().unwrap_or(0); + self.opcode_list.inner_call_index = Some(inner_call_index); } fn active_buffer(&self) -> &[u8] { - match self.active_buffer { - BufferKind::Memory => self.current_step().memory.as_ref().unwrap().as_bytes(), + self.buffer(&self.active_buffer) + } + + pub(super) const fn active_storage(&self) -> Option { + self.active_storage + } + + pub(super) fn storage_accesses(&self, space: StorageSpace) -> IndexMap { + storage_accesses_until( + self.debug_arena(), + self.draw_memory.inner_call_index, + self.current_step, + space, + ) + } + + pub(super) fn storage_label( + &self, + space: StorageSpace, + slot: U256, + storage_values: Option<&B256Map>, + next_values: Option<&B256Map>, + ) -> Option { + if space != StorageSpace::Persistent { + return None; + } + // Storage layouts are keyed by address identity, so skip them for frames that executed + // different code than the address was identified as. + if self.debug_call().contract_name.as_ref() + != self.debugger_context.identified_contracts.get(self.address()) + { + return None; + } + let identifier = self.debugger_context.slot_identifiers.as_ref()?.get(self.address())?; + let slot = B256::from(slot); + identifier + .identify(&slot, None) + .or_else(|| { + storage_values.and_then(|values| identifier.identify_bytes_or_string(&slot, values)) + }) + .or_else(|| { + // Solc writes newly allocated string/bytes payload slots before their base-slot + // length, so retry with the next write values when the current state is stale. + next_values.and_then(|values| identifier.identify_bytes_or_string(&slot, values)) + }) + .map(|info| info.label) + } + + pub(super) fn next_storage_write_values(&self) -> B256Map { + next_storage_write_values( + self.debug_arena(), + self.draw_memory.inner_call_index, + self.current_step, + ) + } + + fn active_data_len(&self) -> usize { + self.active_storage.map_or_else( + || self.active_buffer().len().div_ceil(32), + |space| self.storage_accesses(space).len(), + ) + } + + fn buffer(&self, buffer: &BufferKind) -> &[u8] { + match buffer { + BufferKind::Memory => self.current_step().memory.as_ref().map_or(&[], |m| m.as_bytes()), BufferKind::Calldata => &self.debug_call().calldata, BufferKind::Returndata => &self.current_step().returndata, } } + + pub(crate) const fn active_buffer_name(&self) -> &'static str { + buffer_name(&self.active_buffer) + } + + /// Returns source map, source code and source name of the current line. + pub(crate) fn src_map(&self) -> Result<(SourceElement, &SourceData), String> { + let Some(contract_name) = &self.debug_call().contract_name else { + return Err(format!("Unknown contract at address {}", self.address())); + }; + + self.debugger_context + .contracts_sources + .find_source_mapping( + contract_name, + self.current_step().pc as u32, + self.call_kind().is_any_create(), + ) + .ok_or_else(|| format!("No source map for contract {contract_name}")) + } } impl TUIContext<'_> { @@ -117,12 +312,16 @@ impl TUIContext<'_> { Event::Mouse(event) => self.handle_mouse_event(event), _ => ControlFlow::Continue(()), }; - // Generate the list after the event has been handled. - self.gen_opcode_list_if_necessary(); + self.refresh_opcode_list_state(); ret } fn handle_key_event(&mut self, event: KeyEvent) -> ControlFlow { + if let Some(prompt) = self.prompt.take() { + self.handle_prompt_key_event(prompt, event); + return ControlFlow::Continue(()); + } + // Breakpoints if let KeyCode::Char(c) = event.code && c.is_alphabetic() @@ -138,18 +337,29 @@ impl TUIContext<'_> { // Exit KeyCode::Char('q') => return ControlFlow::Break(ExitReason::CharExit), - // Scroll up the memory buffer + // Scroll up the active data pane KeyCode::Char('k') | KeyCode::Up if control => self.repeat(|this| { - this.draw_memory.current_buf_startline = - this.draw_memory.current_buf_startline.saturating_sub(1); - }), - // Scroll down the memory buffer - KeyCode::Char('j') | KeyCode::Down if control => self.repeat(|this| { - let max_buf = (this.active_buffer().len() / 32).saturating_sub(1); - if this.draw_memory.current_buf_startline < max_buf { - this.draw_memory.current_buf_startline += 1; + if this.active_storage.is_some() { + this.draw_memory.current_storage_startline = + this.draw_memory.current_storage_startline.saturating_sub(1); + } else { + this.draw_memory.current_buf_startline = + this.draw_memory.current_buf_startline.saturating_sub(1); } }), + // Scroll down the active data pane + KeyCode::Char('j') | KeyCode::Down if control => { + let max_line = self.active_data_len().saturating_sub(1); + self.repeat(|this| { + if this.active_storage.is_some() { + if this.draw_memory.current_storage_startline < max_line { + this.draw_memory.current_storage_startline += 1; + } + } else if this.draw_memory.current_buf_startline < max_line { + this.draw_memory.current_buf_startline += 1; + } + }); + } // Move up KeyCode::Char('k') | KeyCode::Up => self.repeat(Self::step_back), @@ -172,35 +382,44 @@ impl TUIContext<'_> { // Cycle buffers KeyCode::Char('b') => { - self.active_buffer = self.active_buffer.next(); + if self.active_storage.take().is_none() { + self.active_buffer = self.active_buffer.next(); + } self.draw_memory.current_buf_startline = 0; + self.set_info(format!("Active buffer: {}", self.active_buffer_name())); } + // Cycle layout + KeyCode::Char('l') => self.cycle_layout(), + // Go to top of file KeyCode::Char('g') => { self.draw_memory.inner_call_index = 0; self.current_step = 0; + self.update_scroll_positions(); } // Go to bottom of file KeyCode::Char('G') => { self.draw_memory.inner_call_index = self.debug_arena().len() - 1; self.current_step = self.n_steps() - 1; + self.update_scroll_positions(); } // Go to previous call - KeyCode::Char('c') => { - self.draw_memory.inner_call_index = - self.draw_memory.inner_call_index.saturating_sub(1); + KeyCode::Char('c') if self.draw_memory.inner_call_index > 0 => { + self.draw_memory.inner_call_index -= 1; self.current_step = self.n_steps() - 1; + self.update_scroll_positions(); } // Go to next call - KeyCode::Char('C') => { - if self.debug_arena().len() > self.draw_memory.inner_call_index + 1 { - self.draw_memory.inner_call_index += 1; - self.current_step = 0; - } + KeyCode::Char('C') + if self.debug_arena().len() > self.draw_memory.inner_call_index + 1 => + { + self.draw_memory.inner_call_index += 1; + self.current_step = 0; + self.update_scroll_positions(); } // Step forward @@ -212,7 +431,8 @@ impl TUIContext<'_> { is_jump(step, prev) }) { - this.current_step += i + this.current_step += i; + this.update_scroll_positions(); } }), @@ -230,16 +450,69 @@ impl TUIContext<'_> { }) .map(|(i, _)| i) .unwrap_or_default(); + this.update_scroll_positions(); }), // Toggle stack labels - KeyCode::Char('t') => self.stack_labels = !self.stack_labels, + KeyCode::Char('t') => { + self.stack_labels = !self.stack_labels; + self.set_info(format!("Stack labels: {}", toggle_state(self.stack_labels))); + } // Toggle memory UTF-8 decoding - KeyCode::Char('m') => self.buf_utf = !self.buf_utf, + KeyCode::Char('m') => { + self.buf_utf = !self.buf_utf; + self.set_info(format!("UTF-8 decoding: {}", toggle_state(self.buf_utf))); + } + + // Go to program counter + KeyCode::Char('p') => { + self.key_buffer.clear(); + self.status = None; + self.prompt = Some(Prompt::Pc(String::new())); + } + + // Go to byte offset in the active buffer + KeyCode::Char('o') => { + self.key_buffer.clear(); + self.status = None; + if let Some(space) = self.active_storage { + self.prompt = Some(Prompt::Command(format!("{} ", space.command()))); + } else { + self.prompt = Some(Prompt::BufferOffset(String::new())); + } + } + + // Run debugger command + KeyCode::Char(':') => { + self.key_buffer.clear(); + self.status = None; + self.prompt = Some(Prompt::Command(String::new())); + } + + // Search opcodes in the current call + KeyCode::Char('/') => { + self.key_buffer.clear(); + self.status = None; + self.prompt = Some(Prompt::OpcodeSearch(String::new())); + } + + // Repeat opcode search forward + KeyCode::Char('n') => self.repeat(|this| { + this.repeat_opcode_search(SearchDirection::Forward); + }), + + // Repeat opcode search backward + KeyCode::Char('N') => self.repeat(|this| { + this.repeat_opcode_search(SearchDirection::Backward); + }), // Toggle help notice - KeyCode::Char('h') => self.show_shortcuts = !self.show_shortcuts, + KeyCode::Char('h') => { + self.show_shortcuts = !self.show_shortcuts; + let state = if self.show_shortcuts { "shown" } else { "hidden" }; + self.set_info(format!("Shortcut help: {state}")); + } // Numbers for repeating commands or breakpoints KeyCode::Char( @@ -258,24 +531,488 @@ impl TUIContext<'_> { ControlFlow::Continue(()) } - fn handle_breakpoint(&mut self, c: char) { - // Find the location of the called breakpoint in the whole debug arena (at this address with - // this pc) - if let Some((caller, pc)) = self.debugger_context.breakpoints.get(&c) { - for (i, node) in self.debug_arena().iter().enumerate() { - if node.address == *caller - && let Some(step) = node.steps.iter().position(|step| step.pc == *pc) - { - self.draw_memory.inner_call_index = i; - self.current_step = step; - break; + fn handle_prompt_key_event(&mut self, mut prompt: Prompt, event: KeyEvent) { + match event.code { + KeyCode::Esc => return, + KeyCode::Enter => { + match prompt { + Prompt::Pc(input) => self.goto_pc_from_input(&input), + Prompt::BufferOffset(input) => self.goto_buffer_offset_from_input(&input), + Prompt::Command(input) => self.run_command_from_input(&input), + Prompt::OpcodeSearch(input) => self.search_opcode_from_input(&input), + } + return; + } + KeyCode::Backspace => { + prompt.input_mut().pop(); + } + KeyCode::Char(c) + if !event.modifiers.contains(KeyModifiers::CONTROL) && prompt.accepts(c) => + { + prompt.input_mut().push(c); + } + _ => {} + } + self.prompt = Some(prompt); + } + + fn search_opcode_from_input(&mut self, input: &str) { + let query = input.trim(); + if query.is_empty() { + self.set_error("Enter an opcode search term".to_string()); + return; + } + + self.last_opcode_search = Some(query.to_string()); + self.search_opcode(query, SearchDirection::Forward); + } + + fn repeat_opcode_search(&mut self, direction: SearchDirection) { + let Some(query) = self.last_opcode_search.clone() else { + self.set_error("No previous opcode search".to_string()); + return; + }; + + self.search_opcode(&query, direction); + } + + fn search_opcode(&mut self, query: &str, direction: SearchDirection) { + let Some((node_index, step_index)) = find_opcode_match( + self.debug_arena(), + self.draw_memory.inner_call_index, + self.current_step, + query, + direction, + ) else { + self.set_error(format!("No opcode matching `{query}` in current call")); + return; + }; + + self.draw_memory.inner_call_index = node_index; + self.current_step = step_index; + self.update_scroll_positions(); + + let pc = self.current_step().pc; + let opcode = pretty_opcode(self.current_step()); + self.set_info(format!("Found `{query}` at PC 0x{pc:x} ({pc}): {opcode}")); + } + + fn goto_pc_from_input(&mut self, input: &str) { + self.move_to_pc_from_input(input, find_pc_target); + } + + fn move_to_pc_from_input( + &mut self, + input: &str, + find_target: impl Fn(&[DebugNode], usize, usize, usize) -> Option, + ) { + let candidates = match parse_pc_candidates(input) { + Ok(candidates) => candidates, + Err(err) => { + self.set_error(err); + return; + } + }; + + let mut found = Vec::new(); + for &candidate in &candidates { + if let Some(target) = find_target( + self.debug_arena(), + self.draw_memory.inner_call_index, + self.current_step, + candidate.pc, + ) { + found.push((candidate, target)); + } + } + + match found.as_slice() { + [] => { + let current = self.debug_call(); + let outside = candidates.iter().any(|candidate| { + pc_exists_outside_code_context(self.debug_arena(), current, candidate.pc) + }); + let pc = if let [candidate] = candidates.as_slice() { + let pc = candidate.pc; + format!("PC 0x{pc:x} ({pc})") + } else { + format!("PC `{}`", input.trim()) + }; + let mut msg = format!("{pc} not found in current contract"); + if outside { + msg.push_str("; it exists in another contract, switch calls first"); } + self.set_error(msg); + } + [(candidate, target)] => self.apply_pc_target(*candidate, *target), + _ => { + let input = input.trim(); + let options = found + .iter() + .map(|(candidate, _)| candidate.describe()) + .collect::>() + .join(" and "); + self.set_error(format!( + "Ambiguous PC `{input}`: {options} both exist; use d: or 0x" + )); + } + } + } + + fn apply_pc_target(&mut self, candidate: PcCandidate, target: StepTarget) { + let already_at_target = self.draw_memory.inner_call_index == target.node_index + && self.current_step == target.step_index; + + self.draw_memory.inner_call_index = target.node_index; + self.current_step = target.step_index; + self.draw_memory.current_buf_startline = 0; + self.draw_memory.current_stack_startline = 0; + self.update_scroll_positions(); + self.key_buffer.clear(); + + let pc = candidate.pc; + let scope = match target.scope { + StepTargetScope::CurrentNode => "current trace", + StepTargetScope::SameCodeContext => "same contract", + }; + let action = if already_at_target { "Already at" } else { "Jumped to" }; + self.set_info(format!("{action} PC 0x{pc:x} ({pc}) in {scope}")); + } + + fn goto_buffer_offset_from_input(&mut self, input: &str) { + self.goto_buffer_offset(self.active_buffer, input); + } + + fn goto_buffer_offset(&mut self, buffer: BufferKind, input: &str) { + let offset = match parse_buffer_offset(input) { + Ok(offset) => offset, + Err(err) => { + self.set_error(err); + return; + } + }; + + let buffer_name = buffer_name(&buffer); + let buffer_len = self.buffer(&buffer).len(); + if buffer_len == 0 { + self.set_error(format!("Current {buffer_name} buffer is empty")); + return; + } + + if offset >= buffer_len { + self.set_error(format!( + "{buffer_name} offset 0x{offset:x} ({offset}) is outside the {buffer_len}-byte buffer" + )); + return; + } + + self.active_buffer = buffer; + self.active_storage = None; + self.apply_buffer_offset(offset); + } + + fn run_command_from_input(&mut self, input: &str) { + let input = input.trim(); + let input = input.strip_prefix(':').unwrap_or(input).trim_start(); + if input.is_empty() { + self.set_error("Enter a debugger command".to_string()); + return; + } + + let mut parts = input.split_whitespace(); + let command = parts.next().unwrap(); + let is_continue_command = CONTINUE_COMMANDS.contains(&command); + if is_continue_command || PC_COMMANDS.contains(&command) { + let Some(pc) = parts.next() else { + return self.set_error(command_usage(command, "")); + }; + if parts.next().is_some() { + return self.set_error(command_usage(command, "")); + } + if is_continue_command { + self.move_to_pc_from_input(pc, find_next_pc_target); + } else { + self.goto_pc_from_input(pc); + } + } else if MEMORY_COMMANDS.contains(&command) { + self.run_buffer_command(command, BufferKind::Memory, parts); + } else if CALLDATA_COMMANDS.contains(&command) { + self.run_buffer_command(command, BufferKind::Calldata, parts); + } else if RETURNDATA_COMMANDS.contains(&command) { + self.run_buffer_command(command, BufferKind::Returndata, parts); + } else if STORAGE_COMMANDS.contains(&command) { + self.run_storage_command(command, StorageSpace::Persistent, parts); + } else if TRANSIENT_STORAGE_COMMANDS.contains(&command) { + self.run_storage_command(command, StorageSpace::Transient, parts); + } else if LINE_COMMANDS.contains(&command) { + let Some(line) = parts.next() else { + return self.set_error(command_usage(command, "")); + }; + if parts.next().is_some() { + return self.set_error(command_usage(command, "")); + } + self.goto_source_line_from_input(line); + } else if OPCODE_COMMANDS.contains(&command) { + self.run_pane_command(command, PaneCommand::Opcodes, parts); + } else if SOURCE_COMMANDS.contains(&command) { + self.run_pane_command(command, PaneCommand::Source, parts); + } else if VARIABLES_COMMANDS.contains(&command) { + self.run_pane_command(command, PaneCommand::Variables, parts); + } else if STACK_COMMANDS.contains(&command) { + self.run_pane_command(command, PaneCommand::Stack, parts); + } else if DATA_COMMANDS.contains(&command) { + self.run_pane_command(command, PaneCommand::Data, parts); + } else if HELP_COMMANDS.contains(&command) { + self.set_info(command_help()); + } else { + self.set_error(format!("Unknown command `{command}`; try `help`")); + } + } + + fn run_buffer_command<'a>( + &mut self, + command: &str, + buffer: BufferKind, + mut args: impl Iterator, + ) { + let Some(offset) = args.next() else { + self.select_buffer(buffer); + return; + }; + if args.next().is_some() { + return self.set_error(command_usage(command, "")); + } + self.goto_buffer_offset(buffer, offset); + } + + fn run_storage_command<'a>( + &mut self, + command: &str, + space: StorageSpace, + mut args: impl Iterator, + ) { + let Some(slot) = args.next() else { + self.select_storage(space); + return; + }; + if args.next().is_some() { + return self.set_error(command_usage(command, "")); + } + self.goto_storage_slot_from_input(slot, space); + } + + fn select_buffer(&mut self, buffer: BufferKind) { + self.active_buffer = buffer; + self.active_storage = None; + self.draw_memory.current_buf_startline = 0; + self.set_info(format!("Active buffer: {}", self.active_buffer_name())); + } + + fn select_storage(&mut self, space: StorageSpace) { + self.active_storage = Some(space); + self.draw_memory.current_storage_startline = 0; + self.set_info(format!("Active data: {}", space.noun())); + } + + fn goto_source_line_from_input(&mut self, input: &str) { + let line = match input.parse::() { + Ok(line) if line > 0 => line, + _ => { + self.set_error(format!( + "Invalid source line `{input}`; use a positive decimal line number" + )); + return; } + }; + + let (source_path, source_line, contract_name) = { + let (_, source) = match self.src_map() { + Ok(source) => source, + Err(err) => { + self.set_error(err); + return; + } + }; + let Some(source_line) = source_line_range(&source.source, line) else { + let line_count = source.source.lines().count().max(1); + self.set_error(format!( + "Source line {line} is outside {} ({line_count} lines)", + source.path.display() + )); + return; + }; + let contract_name = self + .debug_call() + .contract_name + .clone() + .expect("source mapping requires an identified contract"); + (source.path.clone(), source_line, contract_name) + }; + + let sources = &self.debugger_context.contracts_sources; + let Some(target) = find_step_target( + self.debug_arena(), + self.draw_memory.inner_call_index, + self.current_step, + |node, step| { + let Some((source_element, source)) = sources.find_source_mapping( + &contract_name, + step.pc as u32, + node.kind.is_any_create(), + ) else { + return false; + }; + source.path == source_path + && source_line.contains(&(source_element.offset() as usize)) + }, + ) else { + self.set_error(format!( + "No opcode mapped to {}:{line} in current contract", + source_path.display() + )); + return; + }; + + let already_at_target = self.draw_memory.inner_call_index == target.node_index + && self.current_step == target.step_index; + self.draw_memory.inner_call_index = target.node_index; + self.current_step = target.step_index; + self.draw_memory.current_buf_startline = 0; + self.draw_memory.current_stack_startline = 0; + self.update_scroll_positions(); + self.key_buffer.clear(); + + let pc = self.current_step().pc; + let action = if already_at_target { "Already at" } else { "Jumped to" }; + self.set_info(format!("{action} {}:{line} at PC 0x{pc:x} ({pc})", source_path.display())); + } + + fn run_pane_command<'a>( + &mut self, + command: &str, + pane: PaneCommand, + mut args: impl Iterator, + ) { + if args.next().is_some() { + return self.set_error(command_usage(command, "")); } + let shown = match pane { + PaneCommand::Opcodes => { + self.show_opcodes = !self.show_opcodes; + self.show_opcodes + } + PaneCommand::Source => { + self.show_source = !self.show_source; + self.show_source + } + PaneCommand::Variables => { + self.show_variables = !self.show_variables; + self.show_variables + } + PaneCommand::Stack => { + self.show_stack = !self.show_stack; + self.show_stack + } + PaneCommand::Data => { + self.show_data = !self.show_data; + self.show_data + } + }; + let state = if shown { "shown" } else { "hidden" }; + self.set_info(format!("{} pane: {state}", pane.label())); + } + + fn goto_storage_slot_from_input(&mut self, input: &str, space: StorageSpace) { + let slot = match parse_storage_slot(input) { + Ok(slot) => slot, + Err(err) => { + self.set_error(err); + return; + } + }; + + let Some(target) = find_storage_target( + self.debug_arena(), + self.draw_memory.inner_call_index, + self.current_step, + slot, + space, + ) else { + self.set_error(format!( + "{} slot {} not accessed in current call", + space.label(), + hex_u256(slot) + )); + return; + }; + + let access = target.access; + self.draw_memory.inner_call_index = target.node_index; + self.current_step = access.step_index(); + self.draw_memory.current_buf_startline = 0; + self.draw_memory.current_stack_startline = 0; + self.active_storage = Some(space); + self.draw_memory.current_storage_startline = + self.storage_accesses(space).get_index_of(&access.slot()).unwrap_or_default(); + self.update_scroll_positions(); + self.key_buffer.clear(); + self.set_info(format!( + "Jumped to {} at PC 0x{:x} ({})", + access.describe(), + access.pc(), + access.pc() + )); + } + + fn apply_buffer_offset(&mut self, offset: usize) { + self.draw_memory.current_buf_startline = offset / 32; + self.key_buffer.clear(); + let buffer_name = self.active_buffer_name(); + self.set_info(format!("Jumped to {buffer_name} offset 0x{offset:x} ({offset})")); + } + + fn set_info(&mut self, text: String) { + self.status = Some(StatusMessage { kind: StatusKind::Info, text }); + } + + fn set_error(&mut self, text: String) { + self.status = Some(StatusMessage { kind: StatusKind::Error, text }); + } + + fn handle_breakpoint(&mut self, c: char) { self.key_buffer.clear(); + + let Some((caller, pc)) = self.debugger_context.breakpoints.get(&c).copied() else { + self.set_error(format!("Breakpoint '{c}' not found")); + return; + }; + + let Some((inner_call_index, step_index)) = find_next_step_target( + self.debug_arena(), + self.draw_memory.inner_call_index, + self.current_step, + |node, step| node.address == caller && step.pc == pc, + ) else { + self.set_error(format!("Breakpoint '{c}' target not found in trace")); + return; + }; + + let already_at_target = self.draw_memory.inner_call_index == inner_call_index + && self.current_step == step_index; + + self.draw_memory.inner_call_index = inner_call_index; + self.current_step = step_index; + self.update_scroll_positions(); + + let action = if already_at_target { "Already at" } else { "Jumped to" }; + self.set_info(format!("{action} breakpoint '{c}' at PC 0x{pc:x} ({pc})")); } fn handle_mouse_event(&mut self, event: MouseEvent) -> ControlFlow { + if self.prompt.is_some() { + return ControlFlow::Continue(()); + } + match event.kind { MouseEventKind::ScrollUp => self.step_back(), MouseEventKind::ScrollDown => self.step(), @@ -292,6 +1029,7 @@ impl TUIContext<'_> { self.draw_memory.inner_call_index -= 1; self.current_step = self.n_steps() - 1; } + self.update_scroll_positions(); } fn step(&mut self) { @@ -301,6 +1039,42 @@ impl TUIContext<'_> { self.draw_memory.inner_call_index += 1; self.current_step = 0; } + self.update_scroll_positions(); + } + + fn update_scroll_positions(&mut self) { + if let Some(stack) = &self.current_step().stack + && !stack.is_empty() + { + self.draw_memory.current_stack_startline = + self.draw_memory.current_stack_startline.min(stack.len().saturating_sub(1)); + } + + if self.active_buffer == BufferKind::Memory + && let Some(line) = self.current_memory_write_line() + { + self.draw_memory.current_buf_startline = line; + } + + let buffer_len = self.active_buffer().len(); + if buffer_len > 0 { + let max_line = buffer_len.div_ceil(32) - 1; + self.draw_memory.current_buf_startline = + self.draw_memory.current_buf_startline.min(max_line); + } + } + + fn current_memory_write_line(&self) -> Option { + let memory_len = self.current_step().memory.as_ref()?.len(); + + if self.current_step > 0 { + let prev_step = &self.debug_steps()[self.current_step - 1]; + if let Some(line) = bounded_memory_write_start_line(prev_step, memory_len) { + return Some(line); + } + } + + bounded_memory_write_start_line(self.current_step(), memory_len) } /// Calls a closure `f` the number of times specified in the key buffer, and at least once. @@ -313,6 +1087,24 @@ impl TUIContext<'_> { fn n_steps(&self) -> usize { self.debug_steps().len() } + + fn cycle_layout(&mut self) { + let layout = self.debugger_context.layout.next(); + self.debugger_context.layout = layout; + self.set_info(format!("Debugger layout: {}", layout.as_str())); + } +} + +impl TuiApp for TUIContext<'_> { + type Exit = ExitReason; + + fn draw(&mut self, frame: &mut Frame<'_>) { + self.draw_layout(frame); + } + + fn handle_event(&mut self, event: Event) -> ControlFlow { + TUIContext::handle_event(self, event) + } } /// Grab number from buffer. Used for something like '10k' to move up 10 operations @@ -322,20 +1114,2173 @@ fn buffer_as_number(s: &str) -> usize { s.parse().unwrap_or(MIN).clamp(MIN, MAX) } -fn pretty_opcode(step: &CallTraceStep) -> String { - if let Some(immediate) = step.immediate_bytes.as_ref().filter(|b| !b.is_empty()) { - format!("{}(0x{})", step.op, hex::encode(immediate)) - } else { - step.op.to_string() +const fn toggle_state(enabled: bool) -> &'static str { + if enabled { "on" } else { "off" } +} + +const fn buffer_name(buffer: &BufferKind) -> &'static str { + match buffer { + BufferKind::Memory => "memory", + BufferKind::Calldata => "calldata", + BufferKind::Returndata => "returndata", } } -fn is_jump(step: &CallTraceStep, prev: &CallTraceStep) -> bool { - if !matches!(prev.op, OpCode::JUMP | OpCode::JUMPI) { +const CONTINUE_COMMANDS: &[&str] = &["continue", "cont", "c"]; +const PC_COMMANDS: &[&str] = &["pc", "p"]; +const MEMORY_COMMANDS: &[&str] = &["mem", "memory"]; +const CALLDATA_COMMANDS: &[&str] = &["calldata", "cd"]; +const RETURNDATA_COMMANDS: &[&str] = &["returndata", "ret", "rd"]; +const STORAGE_COMMANDS: &[&str] = &["storage", "store", "slot"]; +const TRANSIENT_STORAGE_COMMANDS: &[&str] = &["transient", "tslot"]; +const LINE_COMMANDS: &[&str] = &["line", "ln"]; +const OPCODE_COMMANDS: &[&str] = &["opcodes", "opcode", "ops"]; +const SOURCE_COMMANDS: &[&str] = &["source", "src"]; +const VARIABLES_COMMANDS: &[&str] = &["variables", "vars"]; +const STACK_COMMANDS: &[&str] = &["stack"]; +const DATA_COMMANDS: &[&str] = &["data"]; +const HELP_COMMANDS: &[&str] = &["help", "h"]; + +#[derive(Clone, Copy)] +enum PaneCommand { + Opcodes, + Source, + Variables, + Stack, + Data, +} + +impl PaneCommand { + const fn label(self) -> &'static str { + match self { + Self::Opcodes => "Opcodes", + Self::Source => "Source", + Self::Variables => "Variables", + Self::Stack => "Stack", + Self::Data => "Data", + } + } +} + +fn command_usage(command: &str, arg: &str) -> String { + if arg.is_empty() { format!("Usage: :{command}") } else { format!("Usage: :{command} {arg}") } +} + +fn command_help() -> String { + format!( + "Commands: {} , {} , {} [], {} [], {} [], {} [], {} [], {} , {}, {}, {}, {}, {}", + command_aliases(CONTINUE_COMMANDS), + command_aliases(PC_COMMANDS), + command_aliases(MEMORY_COMMANDS), + command_aliases(CALLDATA_COMMANDS), + command_aliases(RETURNDATA_COMMANDS), + command_aliases(STORAGE_COMMANDS), + command_aliases(TRANSIENT_STORAGE_COMMANDS), + command_aliases(LINE_COMMANDS), + command_aliases(OPCODE_COMMANDS), + command_aliases(SOURCE_COMMANDS), + command_aliases(VARIABLES_COMMANDS), + command_aliases(STACK_COMMANDS), + command_aliases(DATA_COMMANDS) + ) +} + +fn command_aliases(commands: &[&str]) -> String { + commands.iter().map(|command| format!(":{command}")).collect::>().join("/") +} + +fn is_buffer_offset_input_char(input: &str, c: char) -> bool { + if !(c.is_ascii_hexdigit() || matches!(c, 'x' | 'X' | ':')) { return false; } - let immediate_len = prev.immediate_bytes.as_ref().map_or(0, |b| b.len()); + let mut next = String::with_capacity(input.len() + c.len_utf8()); + next.push_str(input); + next.push(c); + is_buffer_offset_input_prefix(&next) +} - step.pc != prev.pc + 1 + immediate_len +fn is_buffer_offset_input_prefix(input: &str) -> bool { + if let Some(rest) = input.strip_prefix("0x").or_else(|| input.strip_prefix("0X")) { + return rest.chars().all(|c| c.is_ascii_hexdigit()); + } + + if let Some(rest) = input.strip_prefix("d:").or_else(|| input.strip_prefix("dec:")) { + return rest.chars().all(|c| c.is_ascii_digit()); + } + + input.chars().all(|c| c.is_ascii_hexdigit()) + || "d:".starts_with(input) + || "dec:".starts_with(input) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SearchDirection { + Forward, + Backward, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PcBase { + Hex, + Decimal, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct PcCandidate { + pc: usize, + base: PcBase, +} + +impl PcCandidate { + fn describe(self) -> String { + match self.base { + PcBase::Hex => format!("hex 0x{:x}", self.pc), + PcBase::Decimal => format!("decimal {}", self.pc), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum StepTargetScope { + CurrentNode, + SameCodeContext, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct StepTarget { + node_index: usize, + step_index: usize, + scope: StepTargetScope, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct StorageTarget { + node_index: usize, + access: StorageAccess, +} + +fn parse_pc_candidates(input: &str) -> Result, String> { + let input = input.trim(); + if input.is_empty() { + return Err("Enter a program counter".to_string()); + } + + if let Some(rest) = input.strip_prefix("0x").or_else(|| input.strip_prefix("0X")) { + return parse_pc_candidate(rest, 16, PcBase::Hex, input); + } + + if let Some(rest) = input.strip_prefix("d:").or_else(|| input.strip_prefix("dec:")) { + return parse_pc_candidate(rest, 10, PcBase::Decimal, input); + } + + if input.chars().any(|c| c.is_ascii_hexdigit() && c.is_ascii_alphabetic()) { + return parse_pc_candidate(input, 16, PcBase::Hex, input); + } + + if input.chars().all(|c| c.is_ascii_digit()) { + let decimal = parse_pc(input, 10, input)?; + let hex = parse_pc(input, 16, input)?; + if decimal == hex { + return Ok(vec![PcCandidate { pc: decimal, base: PcBase::Decimal }]); + } + return Ok(vec![ + PcCandidate { pc: decimal, base: PcBase::Decimal }, + PcCandidate { pc: hex, base: PcBase::Hex }, + ]); + } + + Err(format!("Invalid PC `{input}`; use 0x2a, 2a, or d:42")) +} + +fn parse_pc_candidate( + input: &str, + radix: u32, + base: PcBase, + original: &str, +) -> Result, String> { + Ok(vec![PcCandidate { pc: parse_pc(input, radix, original)?, base }]) +} + +fn parse_pc(input: &str, radix: u32, original: &str) -> Result { + if input.is_empty() { + return Err(format!("Invalid PC `{original}`; use 0x2a, 2a, or d:42")); + } + usize::from_str_radix(input, radix) + .map_err(|_| format!("Invalid PC `{original}`; use 0x2a, 2a, or d:42")) +} + +fn parse_buffer_offset(input: &str) -> Result { + let input = input.trim(); + if input.is_empty() { + return Err("Enter a buffer offset".to_string()); + } + + let (digits, radix) = + if let Some(rest) = input.strip_prefix("0x").or_else(|| input.strip_prefix("0X")) { + (rest, 16) + } else if let Some(rest) = input.strip_prefix("d:").or_else(|| input.strip_prefix("dec:")) { + (rest, 10) + } else { + (input, 16) + }; + + if digits.is_empty() { + return Err(invalid_buffer_offset(input)); + } + + usize::from_str_radix(digits, radix).map_err(|_| invalid_buffer_offset(input)) +} + +fn invalid_buffer_offset(input: &str) -> String { + format!("Invalid buffer offset `{input}`; use hex 0x20/20 or decimal d:32") +} + +fn parse_storage_slot(input: &str) -> Result { + let input = input.trim(); + if input.is_empty() { + return Err("Enter a storage slot".to_string()); + } + + let (digits, radix) = + if let Some(rest) = input.strip_prefix("0x").or_else(|| input.strip_prefix("0X")) { + (rest, 16) + } else if let Some(rest) = input.strip_prefix("d:").or_else(|| input.strip_prefix("dec:")) { + (rest, 10) + } else { + (input, 16) + }; + + let valid_digits = match radix { + 10 => digits.bytes().all(|b| b.is_ascii_digit()), + 16 => digits.bytes().all(|b| b.is_ascii_hexdigit()), + _ => unreachable!(), + }; + if digits.is_empty() || !valid_digits { + return Err(invalid_storage_slot(input)); + } + + U256::from_str_radix(digits, radix).map_err(|_| invalid_storage_slot(input)) +} + +fn invalid_storage_slot(input: &str) -> String { + format!("Invalid storage slot `{input}`; use hex 0x20/20 or decimal d:32") +} + +fn find_storage_target( + arena: &[DebugNode], + current_node_index: usize, + current_step: usize, + slot: U256, + space: StorageSpace, +) -> Option { + let current_node = arena.get(current_node_index)?; + let trace_node_idx = current_node.trace_node_idx; + let current_absolute_step = current_node.step_offset.saturating_add(current_step); + + storage_target_at(arena, current_node_index, current_step, slot, space) + .or_else(|| { + find_storage_target_after(arena, trace_node_idx, current_absolute_step, slot, space) + }) + .or_else(|| { + find_storage_target_before(arena, trace_node_idx, current_absolute_step, slot, space) + }) +} + +fn storage_target_at( + arena: &[DebugNode], + node_index: usize, + step_index: usize, + slot: U256, + space: StorageSpace, +) -> Option { + let node = arena.get(node_index)?; + storage_access_at(&node.steps, step_index) + .filter(|access| access.slot() == slot && access.space() == space) + .map(|access| StorageTarget { node_index, access }) +} + +fn find_storage_target_after( + arena: &[DebugNode], + trace_node_idx: usize, + current_absolute_step: usize, + slot: U256, + space: StorageSpace, +) -> Option { + let mut best = None; + + for (node_index, node) in arena.iter().enumerate() { + if node.trace_node_idx != trace_node_idx { + continue; + } + + for step_index in 0..node.steps.len() { + let absolute_step = node.step_offset.saturating_add(step_index); + if absolute_step <= current_absolute_step { + continue; + } + + let Some(access) = storage_access_at(&node.steps, step_index) + .filter(|access| access.slot() == slot && access.space() == space) + else { + continue; + }; + + match best { + Some((best_absolute_step, _, _)) if absolute_step >= best_absolute_step => {} + _ => best = Some((absolute_step, node_index, access)), + } + break; + } + } + + best.map(|(_, node_index, access)| StorageTarget { node_index, access }) +} + +fn find_storage_target_before( + arena: &[DebugNode], + trace_node_idx: usize, + current_absolute_step: usize, + slot: U256, + space: StorageSpace, +) -> Option { + let mut best = None; + + for (node_index, node) in arena.iter().enumerate() { + if node.trace_node_idx != trace_node_idx { + continue; + } + + for step_index in (0..node.steps.len()).rev() { + let absolute_step = node.step_offset.saturating_add(step_index); + if absolute_step >= current_absolute_step { + continue; + } + + let Some(access) = storage_access_at(&node.steps, step_index) + .filter(|access| access.slot() == slot && access.space() == space) + else { + continue; + }; + + match best { + Some((best_absolute_step, _, _)) if absolute_step <= best_absolute_step => {} + _ => best = Some((absolute_step, node_index, access)), + } + break; + } + } + + best.map(|(_, node_index, access)| StorageTarget { node_index, access }) +} + +fn find_pc_target( + arena: &[DebugNode], + current_node_index: usize, + current_step: usize, + pc: usize, +) -> Option { + find_step_target(arena, current_node_index, current_step, |_, step| step.pc == pc) +} + +fn find_next_pc_target( + arena: &[DebugNode], + current_node_index: usize, + current_step: usize, + pc: usize, +) -> Option { + let current_node = arena.get(current_node_index)?; + let (node_index, step_index) = + find_next_step_target(arena, current_node_index, current_step, |node, step| { + same_code_context(current_node, node) && step.pc == pc + })?; + let scope = if node_index == current_node_index { + StepTargetScope::CurrentNode + } else { + StepTargetScope::SameCodeContext + }; + + Some(StepTarget { node_index, step_index, scope }) +} + +fn find_next_step_target( + arena: &[DebugNode], + current_node_index: usize, + current_step: usize, + mut matches: impl FnMut(&DebugNode, &CallTraceStep) -> bool, +) -> Option<(usize, usize)> { + let current_node = arena.get(current_node_index)?; + + if let Some(step_index) = current_node + .steps + .iter() + .enumerate() + .skip(current_step.saturating_add(1)) + .find_map(|(i, step)| matches(current_node, step).then_some(i)) + { + return Some((current_node_index, step_index)); + } + + for (node_index, node) in arena.iter().enumerate().skip(current_node_index + 1) { + if let Some(step_index) = node.steps.iter().position(|step| matches(node, step)) { + return Some((node_index, step_index)); + } + } + + for (node_index, node) in arena.iter().enumerate().take(current_node_index) { + if let Some(step_index) = node.steps.iter().position(|step| matches(node, step)) { + return Some((node_index, step_index)); + } + } + + current_node + .steps + .iter() + .enumerate() + .take(current_step.saturating_add(1)) + .find_map(|(i, step)| matches(current_node, step).then_some((current_node_index, i))) +} + +fn find_step_target( + arena: &[DebugNode], + current_node_index: usize, + current_step: usize, + mut matches: impl FnMut(&DebugNode, &CallTraceStep) -> bool, +) -> Option { + let current_node = arena.get(current_node_index)?; + + if let Some(step_index) = find_step_in_current_node(current_node, current_step, &mut matches) { + return Some(StepTarget { + node_index: current_node_index, + step_index, + scope: StepTargetScope::CurrentNode, + }); + } + + for (node_index, node) in arena.iter().enumerate().skip(current_node_index + 1) { + if same_code_context(current_node, node) + && let Some(step_index) = node.steps.iter().position(|step| matches(node, step)) + { + return Some(StepTarget { + node_index, + step_index, + scope: StepTargetScope::SameCodeContext, + }); + } + } + + for (node_index, node) in arena.iter().enumerate().take(current_node_index).rev() { + if same_code_context(current_node, node) + && let Some(step_index) = node.steps.iter().rposition(|step| matches(node, step)) + { + return Some(StepTarget { + node_index, + step_index, + scope: StepTargetScope::SameCodeContext, + }); + } + } + + None +} + +fn find_step_in_current_node( + node: &DebugNode, + current_step: usize, + matches: &mut impl FnMut(&DebugNode, &CallTraceStep) -> bool, +) -> Option { + if node.steps.get(current_step).is_some_and(|step| matches(node, step)) { + return Some(current_step); + } + + node.steps + .iter() + .enumerate() + .skip(current_step.saturating_add(1)) + .find_map(|(i, step)| matches(node, step).then_some(i)) + .or_else(|| { + node.steps[..current_step.min(node.steps.len())] + .iter() + .enumerate() + .rev() + .find_map(|(i, step)| matches(node, step).then_some(i)) + }) +} + +fn source_line_range(source: &str, line: usize) -> Option> { + if line == 0 { + return None; + } + + let mut start = 0; + for _ in 1..line { + start += source.get(start..)?.find('\n')? + 1; + } + if start >= source.len() { + return None; + } + let end = source[start..].find('\n').map_or(source.len(), |offset| start + offset + 1); + Some(start..end) +} + +fn same_code_context(a: &DebugNode, b: &DebugNode) -> bool { + a.address == b.address + && a.kind.is_any_create() == b.kind.is_any_create() + && a.contract_name == b.contract_name +} + +fn pc_exists_outside_code_context(arena: &[DebugNode], current: &DebugNode, pc: usize) -> bool { + arena.iter().any(|node| { + !same_code_context(current, node) && node.steps.iter().any(|step| step.pc == pc) + }) +} + +fn find_opcode_match( + arena: &[DebugNode], + current_node_index: usize, + current_step: usize, + query: &str, + direction: SearchDirection, +) -> Option<(usize, usize)> { + let needle = query.trim().to_ascii_lowercase(); + if needle.is_empty() { + return None; + } + + let current_node = arena.get(current_node_index)?; + let trace_node_idx = current_node.trace_node_idx; + let current_absolute_step = current_node.step_offset.saturating_add(current_step); + let steps = || { + arena.iter().enumerate().filter(|(_, node)| node.trace_node_idx == trace_node_idx).flat_map( + |(node_index, node)| { + node.steps.iter().enumerate().map(move |(step_index, step)| { + (node_index, step_index, node.step_offset.saturating_add(step_index), step) + }) + }, + ) + }; + let target = match direction { + SearchDirection::Forward => steps() + .filter(|(_, _, absolute_step, _)| *absolute_step > current_absolute_step) + .chain( + steps().filter(|(_, _, absolute_step, _)| *absolute_step <= current_absolute_step), + ) + .find(|(_, _, _, step)| pretty_opcode(step).to_ascii_lowercase().contains(&needle)), + SearchDirection::Backward => steps() + .rev() + .filter(|(_, _, absolute_step, _)| *absolute_step < current_absolute_step) + .chain( + steps() + .rev() + .filter(|(_, _, absolute_step, _)| *absolute_step >= current_absolute_step), + ) + .find(|(_, _, _, step)| pretty_opcode(step).to_ascii_lowercase().contains(&needle)), + }; + target.map(|(node_index, step_index, _, _)| (node_index, step_index)) +} + +pub(super) fn pretty_opcode(step: &CallTraceStep) -> String { + let mut buf = String::new(); + write_pretty_opcode(&mut buf, step); + buf +} + +pub(super) fn write_pretty_opcode(buf: &mut String, step: &CallTraceStep) { + if let Some(immediate) = step.immediate_bytes.as_ref().filter(|b| !b.is_empty()) { + write!(buf, "{}({})", step.op, hex::encode_prefixed(immediate)).unwrap(); + } else { + write!(buf, "{}", step.op).unwrap(); + } +} + +fn memory_write_start_line(step: &CallTraceStep) -> Option { + let stack = step.stack.as_ref()?; + let access = get_buffer_accesses(step.op.get(), stack)?.write?; + if access.len == 0 { + return None; + } + Some(access.offset / 32) +} + +fn bounded_memory_write_start_line(step: &CallTraceStep, memory_len: usize) -> Option { + let line = memory_write_start_line(step)?; + (line < memory_len.div_ceil(32)).then_some(line) +} + +fn is_jump(step: &CallTraceStep, prev: &CallTraceStep) -> bool { + if !matches!(prev.op, OpCode::JUMP | OpCode::JUMPI) { + return false; + } + + let immediate_len = prev.immediate_bytes.as_ref().map_or(0, |b| b.len()); + + step.pc != prev.pc + 1 + immediate_len +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tui::storage::storage_values; + use alloy_primitives::{Bytes, keccak256, map::AddressHashMap}; + use foundry_common::slot_identifier::{ENCODING_BYTES, SlotIdentifier}; + use foundry_compilers::artifacts::{Storage, StorageLayout, StorageType, sourcemap::Parser}; + use foundry_evm_core::{Breakpoints, ic::PcIcMap}; + use foundry_evm_traces::{ + StorageChange, StorageChangeReason, + debug::{ArtifactData, ContractSources}, + }; + use revm::interpreter::InstructionResult; + use std::{collections::BTreeMap, path::PathBuf, sync::Arc}; + + fn step(pc: usize) -> CallTraceStep { + step_with_stack(pc, OpCode::STOP, &[]) + } + + fn step_with_immediate(pc: usize, op: OpCode, immediate: &'static [u8]) -> CallTraceStep { + CallTraceStep { + immediate_bytes: Some(Bytes::from_static(immediate)), + ..step_with_stack(pc, op, &[]) + } + } + + fn step_with_stack(pc: usize, op: OpCode, stack: &[usize]) -> CallTraceStep { + CallTraceStep { + pc, + op, + stack: (!stack.is_empty()).then(|| { + stack.iter().copied().map(U256::from).collect::>().into_boxed_slice() + }), + push_stack: None, + memory: None, + returndata: Bytes::new(), + gas_remaining: 0, + gas_refund_counter: 0, + gas_used: 0, + gas_cost: 0, + state_gas_cost: None, + state_gas_reservoir: None, + state_gas_spent: 0, + storage_change: None, + status: Some(InstructionResult::Stop), + immediate_bytes: None, + decoded: None, + } + } + + fn node(address: Address, kind: CallKind, pcs: &[usize]) -> DebugNode { + DebugNode::new( + address, + kind, + pcs.iter().copied().map(step).collect(), + Bytes::new(), + 0, + None, + ) + } + + fn context_with_arena(arena: Vec) -> DebuggerContext { + DebuggerContext { + debug_arena: arena, + stats: None, + identified_contracts: Default::default(), + slot_identifiers: None, + contracts_sources: ContractSources::default(), + breakpoints: Breakpoints::default(), + layout: Default::default(), + } + } + + fn context_with_source_lines(address: Address) -> DebuggerContext { + let mut node = node(address, CallKind::Call, &[0, 1, 2]); + node.contract_name = Some("Test".to_string()); + let mut context = context_with_arena(vec![node]); + context.identified_contracts.insert(address, "Test".to_string()); + + let build_id = "test-build".to_string(); + context.contracts_sources.sources_by_id.entry(build_id.clone()).or_default().insert( + 0, + Arc::new(SourceData { + source: Arc::new("line one\nline two\nline three\n".to_string()), + language: Default::default(), + path: PathBuf::from("src/Test.sol"), + contract_definitions: Vec::new(), + debug_scopes: Vec::new(), + }), + ); + context.contracts_sources.artifacts_by_name.insert( + "Test".to_string(), + vec![ArtifactData { + source_map: None, + source_map_runtime: Some( + Parser::new("0:8:0;9:8:0;18:10:0").collect::>().unwrap(), + ), + pc_ic_map: None, + pc_ic_map_runtime: Some(PcIcMap::new(&[0x00, 0x00, 0x00])), + build_id, + file_id: 0, + }], + ); + context + } + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::empty()) + } + + fn ctrl_key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::CONTROL) + } + + #[test] + fn layout_shortcut_cycles_only_concrete_layouts() { + let address = Address::repeat_byte(1); + let mut context = context_with_arena(vec![node(address, CallKind::Call, &[1])]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + + assert_eq!(tui.layout(), DebuggerLayout::Auto); + + let _ = tui.handle_key_event(key(KeyCode::Char('l'))); + assert_eq!(tui.layout(), DebuggerLayout::Horizontal); + assert_eq!(tui.status.as_ref().unwrap().text, "Debugger layout: horizontal"); + + let _ = tui.handle_key_event(key(KeyCode::Char('l'))); + assert_eq!(tui.layout(), DebuggerLayout::Vertical); + assert_eq!(tui.status.as_ref().unwrap().text, "Debugger layout: vertical"); + + let _ = tui.handle_key_event(key(KeyCode::Char('l'))); + assert_eq!(tui.layout(), DebuggerLayout::Horizontal); + assert_eq!(tui.status.as_ref().unwrap().text, "Debugger layout: horizontal"); + } + + #[test] + fn view_shortcuts_report_status() { + let address = Address::repeat_byte(1); + let mut context = context_with_arena(vec![node(address, CallKind::Call, &[1])]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + + assert_eq!(tui.active_buffer, BufferKind::Memory); + let _ = tui.handle_key_event(key(KeyCode::Char('b'))); + assert_eq!(tui.active_buffer, BufferKind::Calldata); + assert_eq!(tui.status.as_ref().unwrap().text, "Active buffer: calldata"); + + let _ = tui.handle_key_event(key(KeyCode::Char('t'))); + assert!(tui.stack_labels); + assert_eq!(tui.status.as_ref().unwrap().text, "Stack labels: on"); + let _ = tui.handle_key_event(key(KeyCode::Char('t'))); + assert!(!tui.stack_labels); + assert_eq!(tui.status.as_ref().unwrap().text, "Stack labels: off"); + + let _ = tui.handle_key_event(key(KeyCode::Char('m'))); + assert!(tui.buf_utf); + assert_eq!(tui.status.as_ref().unwrap().text, "UTF-8 decoding: on"); + let _ = tui.handle_key_event(key(KeyCode::Char('m'))); + assert!(!tui.buf_utf); + assert_eq!(tui.status.as_ref().unwrap().text, "UTF-8 decoding: off"); + + let _ = tui.handle_key_event(key(KeyCode::Char('h'))); + assert!(!tui.show_shortcuts); + assert_eq!(tui.status.as_ref().unwrap().text, "Shortcut help: hidden"); + let _ = tui.handle_key_event(key(KeyCode::Char('h'))); + assert!(tui.show_shortcuts); + assert_eq!(tui.status.as_ref().unwrap().text, "Shortcut help: shown"); + } + + #[test] + fn previous_call_shortcut_respects_root_boundary() { + let address = Address::repeat_byte(1); + let mut context = context_with_arena(vec![ + node(address, CallKind::Call, &[1, 2]), + node(address, CallKind::Call, &[3]), + ]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + + let _ = tui.handle_key_event(key(KeyCode::Char('c'))); + assert_eq!((tui.draw_memory.inner_call_index, tui.current_step), (0, 0)); + + tui.draw_memory.inner_call_index = 1; + let _ = tui.handle_key_event(key(KeyCode::Char('c'))); + assert_eq!((tui.draw_memory.inner_call_index, tui.current_step), (0, 1)); + } + + #[test] + fn breakpoint_shortcut_cycles_trace_hits() { + let address = Address::repeat_byte(1); + let other = Address::repeat_byte(2); + let mut context = context_with_arena(vec![ + node(other, CallKind::Call, &[1]), + node(address, CallKind::Call, &[42, 7, 42]), + node(address, CallKind::Call, &[8, 42]), + ]); + context.breakpoints.insert('a', (address, 42)); + let mut tui = TUIContext::new(&mut context); + tui.init(); + + let _ = tui.handle_key_event(key(KeyCode::Char('\''))); + let _ = tui.handle_key_event(key(KeyCode::Char('a'))); + + assert_eq!(tui.draw_memory.inner_call_index, 1); + assert_eq!(tui.current_step, 0); + let status = tui.status.as_ref().unwrap(); + assert_eq!(status.kind, StatusKind::Info); + assert_eq!(status.text, "Jumped to breakpoint 'a' at PC 0x2a (42)"); + + let _ = tui.handle_key_event(key(KeyCode::Char('\''))); + let _ = tui.handle_key_event(key(KeyCode::Char('a'))); + assert_eq!((tui.draw_memory.inner_call_index, tui.current_step), (1, 2)); + + let _ = tui.handle_key_event(key(KeyCode::Char('\''))); + let _ = tui.handle_key_event(key(KeyCode::Char('a'))); + assert_eq!((tui.draw_memory.inner_call_index, tui.current_step), (2, 1)); + + let _ = tui.handle_key_event(key(KeyCode::Char('\''))); + let _ = tui.handle_key_event(key(KeyCode::Char('a'))); + assert_eq!((tui.draw_memory.inner_call_index, tui.current_step), (1, 0)); + } + + #[test] + fn breakpoint_shortcut_reports_missing_key() { + let address = Address::repeat_byte(1); + let mut context = context_with_arena(vec![node(address, CallKind::Call, &[1])]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + + let _ = tui.handle_key_event(key(KeyCode::Char('\''))); + let _ = tui.handle_key_event(key(KeyCode::Char('z'))); + + assert_eq!(tui.draw_memory.inner_call_index, 0); + assert_eq!(tui.current_step, 0); + let status = tui.status.as_ref().unwrap(); + assert_eq!(status.kind, StatusKind::Error); + assert_eq!(status.text, "Breakpoint 'z' not found"); + } + + #[test] + fn breakpoint_shortcut_reports_missing_trace_target() { + let address = Address::repeat_byte(1); + let mut context = context_with_arena(vec![node(address, CallKind::Call, &[1])]); + context.breakpoints.insert('a', (address, 42)); + let mut tui = TUIContext::new(&mut context); + tui.init(); + + let _ = tui.handle_key_event(key(KeyCode::Char('\''))); + let _ = tui.handle_key_event(key(KeyCode::Char('a'))); + + assert_eq!(tui.draw_memory.inner_call_index, 0); + assert_eq!(tui.current_step, 0); + let status = tui.status.as_ref().unwrap(); + assert_eq!(status.kind, StatusKind::Error); + assert_eq!(status.text, "Breakpoint 'a' target not found in trace"); + } + + #[test] + fn parses_prefixed_hex_pc() { + assert_eq!( + parse_pc_candidates("0x2a").unwrap(), + vec![PcCandidate { pc: 42, base: PcBase::Hex }] + ); + assert_eq!( + parse_pc_candidates("0X2A").unwrap(), + vec![PcCandidate { pc: 42, base: PcBase::Hex }] + ); + } + + #[test] + fn parses_bare_hex_pc_with_letters() { + assert_eq!( + parse_pc_candidates("2a").unwrap(), + vec![PcCandidate { pc: 42, base: PcBase::Hex }] + ); + } + + #[test] + fn parses_explicit_decimal_pc() { + assert_eq!( + parse_pc_candidates("d:42").unwrap(), + vec![PcCandidate { pc: 42, base: PcBase::Decimal }] + ); + assert_eq!( + parse_pc_candidates("dec:42").unwrap(), + vec![PcCandidate { pc: 42, base: PcBase::Decimal }] + ); + } + + #[test] + fn parses_bare_digits_as_decimal_and_hex_candidates() { + assert_eq!( + parse_pc_candidates("10").unwrap(), + vec![ + PcCandidate { pc: 10, base: PcBase::Decimal }, + PcCandidate { pc: 16, base: PcBase::Hex }, + ] + ); + assert_eq!( + parse_pc_candidates("9").unwrap(), + vec![PcCandidate { pc: 9, base: PcBase::Decimal }] + ); + } + + #[test] + fn rejects_invalid_pc_input() { + assert!(parse_pc_candidates("").is_err()); + assert!(parse_pc_candidates("0x").is_err()); + assert!(parse_pc_candidates("xyz").is_err()); + assert!(parse_pc_candidates("184467440737095516160").is_err()); + } + + #[test] + fn parses_buffer_offsets_as_visible_hex_labels() { + assert_eq!(parse_buffer_offset("0x20").unwrap(), 32); + assert_eq!(parse_buffer_offset("d:32").unwrap(), 32); + assert_eq!(parse_buffer_offset("dec:32").unwrap(), 32); + assert_eq!(parse_buffer_offset("20").unwrap(), 32); + assert_eq!(parse_buffer_offset("2a").unwrap(), 42); + assert_eq!(parse_buffer_offset("a").unwrap(), 10); + + assert_eq!(parse_buffer_offset("").unwrap_err(), "Enter a buffer offset"); + assert_eq!( + parse_buffer_offset("0x").unwrap_err(), + "Invalid buffer offset `0x`; use hex 0x20/20 or decimal d:32" + ); + assert_eq!( + parse_buffer_offset("2x3").unwrap_err(), + "Invalid buffer offset `2x3`; use hex 0x20/20 or decimal d:32" + ); + } + + #[test] + fn parses_storage_slots_as_visible_hex_labels() { + assert_eq!(parse_storage_slot("0x20").unwrap(), U256::from(32)); + assert_eq!(parse_storage_slot("d:32").unwrap(), U256::from(32)); + assert_eq!(parse_storage_slot("dec:32").unwrap(), U256::from(32)); + assert_eq!(parse_storage_slot("20").unwrap(), U256::from(32)); + assert_eq!(parse_storage_slot("2a").unwrap(), U256::from(42)); + assert_eq!(parse_storage_slot("a").unwrap(), U256::from(10)); + + assert_eq!(parse_storage_slot("").unwrap_err(), "Enter a storage slot"); + assert_eq!( + parse_storage_slot("0x").unwrap_err(), + "Invalid storage slot `0x`; use hex 0x20/20 or decimal d:32" + ); + assert_eq!( + parse_storage_slot("2x3").unwrap_err(), + "Invalid storage slot `2x3`; use hex 0x20/20 or decimal d:32" + ); + assert_eq!( + parse_storage_slot("1_0").unwrap_err(), + "Invalid storage slot `1_0`; use hex 0x20/20 or decimal d:32" + ); + assert_eq!( + parse_storage_slot("_").unwrap_err(), + "Invalid storage slot `_`; use hex 0x20/20 or decimal d:32" + ); + assert_eq!( + parse_storage_slot("0x_").unwrap_err(), + "Invalid storage slot `0x_`; use hex 0x20/20 or decimal d:32" + ); + assert_eq!( + parse_storage_slot("d:_").unwrap_err(), + "Invalid storage slot `d:_`; use hex 0x20/20 or decimal d:32" + ); + } + + #[test] + fn filters_buffer_offset_input_to_parser_prefixes() { + assert!(is_buffer_offset_input_char("", '0')); + assert!(is_buffer_offset_input_char("0", 'x')); + assert!(is_buffer_offset_input_char("0x", '2')); + assert!(is_buffer_offset_input_char("2", 'a')); + assert!(is_buffer_offset_input_char("d", ':')); + assert!(is_buffer_offset_input_char("dec", ':')); + assert!(is_buffer_offset_input_char("dec:", '3')); + + assert!(!is_buffer_offset_input_char("", 'x')); + assert!(!is_buffer_offset_input_char("2", 'x')); + assert!(!is_buffer_offset_input_char("1", ':')); + assert!(!is_buffer_offset_input_char("DEC", ':')); + assert!(!is_buffer_offset_input_char("d:", 'a')); + } + + #[test] + fn finds_pc_in_current_node() { + let address = Address::repeat_byte(1); + let arena = vec![node(address, CallKind::Call, &[1, 2, 3])]; + + assert_eq!( + find_pc_target(&arena, 0, 0, 3), + Some(StepTarget { node_index: 0, step_index: 2, scope: StepTargetScope::CurrentNode }) + ); + } + + #[test] + fn repeated_pc_stays_current_then_prefers_next_then_previous() { + let address = Address::repeat_byte(1); + let arena = vec![node(address, CallKind::Call, &[1, 2, 3, 2])]; + + assert_eq!(find_pc_target(&arena, 0, 1, 2).unwrap().step_index, 1); + assert_eq!(find_pc_target(&arena, 0, 0, 2).unwrap().step_index, 1); + assert_eq!(find_pc_target(&arena, 0, 3, 2).unwrap().step_index, 3); + assert_eq!(find_pc_target(&arena, 0, 2, 2).unwrap().step_index, 3); + } + + #[test] + fn searches_later_then_earlier_same_code_context() { + let address = Address::repeat_byte(1); + let arena = vec![ + node(address, CallKind::Call, &[1]), + node(address, CallKind::Call, &[2]), + node(address, CallKind::Call, &[3]), + ]; + + assert_eq!(find_pc_target(&arena, 1, 0, 3).unwrap().node_index, 2); + assert_eq!(find_pc_target(&arena, 1, 0, 1).unwrap().node_index, 0); + } + + #[test] + fn does_not_search_different_address_or_creation_context() { + let address = Address::repeat_byte(1); + let other = Address::repeat_byte(2); + let arena = vec![ + node(address, CallKind::Call, &[1]), + node(other, CallKind::Call, &[2]), + node(address, CallKind::Create, &[3]), + ]; + + assert!(find_pc_target(&arena, 0, 0, 2).is_none()); + assert!(find_pc_target(&arena, 0, 0, 3).is_none()); + assert!(pc_exists_outside_code_context(&arena, &arena[0], 2)); + assert!(pc_exists_outside_code_context(&arena, &arena[0], 3)); + } + + #[test] + fn goto_resolves_unambiguous_bare_digits_and_reports_ambiguity() { + let address = Address::repeat_byte(1); + let mut context = context_with_arena(vec![node(address, CallKind::Call, &[10, 16, 42])]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + + tui.goto_pc_from_input("2a"); + assert_eq!(tui.current_step, 2); + assert_eq!(tui.status.as_ref().unwrap().kind, StatusKind::Info); + + tui.current_step = 0; + tui.goto_pc_from_input("10"); + assert_eq!(tui.current_step, 0); + assert!(tui.status.as_ref().unwrap().text.contains("Ambiguous PC")); + + tui.goto_pc_from_input("d:10"); + assert_eq!(tui.current_step, 0); + assert_eq!(tui.status.as_ref().unwrap().kind, StatusKind::Info); + } + + #[test] + fn goto_reports_pc_in_other_contract_without_moving() { + let address = Address::repeat_byte(1); + let other = Address::repeat_byte(2); + let mut context = context_with_arena(vec![ + node(address, CallKind::Call, &[1]), + node(other, CallKind::Call, &[42]), + ]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + + tui.goto_pc_from_input("2a"); + assert_eq!(tui.draw_memory.inner_call_index, 0); + assert_eq!(tui.current_step, 0); + let status = tui.status.as_ref().unwrap(); + assert_eq!(status.kind, StatusKind::Error); + assert!(status.text.contains("exists in another contract")); + } + + #[test] + fn goto_reports_ambiguous_input_in_other_contract_without_choosing_first_candidate() { + let address = Address::repeat_byte(1); + let other = Address::repeat_byte(2); + let mut context = context_with_arena(vec![ + node(address, CallKind::Call, &[1]), + node(other, CallKind::Call, &[16]), + ]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + + tui.goto_pc_from_input("10"); + let status = tui.status.as_ref().unwrap(); + assert_eq!(status.kind, StatusKind::Error); + assert!(status.text.starts_with("PC `10` not found")); + assert!(status.text.contains("exists in another contract")); + } + + #[test] + fn prompts_preserve_character_filters_and_block_navigation() { + for (shortcut, expected) in [ + ('p', Prompt::Pc("a".into())), + ('o', Prompt::BufferOffset("a".into())), + (':', Prompt::Command("aé".into())), + ('/', Prompt::OpcodeSearch("aé\n".into())), + ] { + let address = Address::repeat_byte(1); + let mut context = context_with_arena(vec![node(address, CallKind::Call, &[1, 42])]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + + let _ = tui.handle_key_event(key(KeyCode::Char(shortcut))); + for c in ['a', 'é', '\n'] { + let _ = tui.handle_key_event(key(KeyCode::Char(c))); + } + let _ = tui.handle_key_event(ctrl_key(KeyCode::Char('c'))); + let _ = tui.handle_key_event(key(KeyCode::Down)); + let _ = tui.handle_mouse_event(MouseEvent { + kind: MouseEventKind::ScrollDown, + column: 0, + row: 0, + modifiers: KeyModifiers::empty(), + }); + assert_eq!(tui.prompt, Some(expected)); + assert_eq!(tui.current_step, 0); + + let _ = tui.handle_key_event(key(KeyCode::Esc)); + assert_eq!(tui.prompt, None); + assert_eq!(tui.current_step, 0); + assert_eq!(tui.status, None); + let _ = tui.handle_key_event(key(KeyCode::Down)); + assert_eq!(tui.current_step, 1); + } + } + + #[test] + fn pc_input_mode_handles_keys_and_blocks_normal_commands() { + let address = Address::repeat_byte(1); + let mut context = context_with_arena(vec![node(address, CallKind::Call, &[1, 42])]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + + assert!(matches!(tui.handle_key_event(key(KeyCode::Char('p'))), ControlFlow::Continue(()))); + assert_eq!(tui.prompt, Some(Prompt::Pc(String::new()))); + + let _ = tui.handle_key_event(key(KeyCode::Char('q'))); + assert_eq!(tui.prompt, Some(Prompt::Pc(String::new()))); + assert_eq!(tui.current_step, 0); + + let _ = tui.handle_key_event(key(KeyCode::Char('2'))); + let _ = tui.handle_key_event(key(KeyCode::Char('a'))); + assert_eq!(tui.prompt, Some(Prompt::Pc("2a".into()))); + + let _ = tui.handle_key_event(key(KeyCode::Backspace)); + assert_eq!(tui.prompt, Some(Prompt::Pc("2".into()))); + let _ = tui.handle_key_event(key(KeyCode::Char('a'))); + let _ = tui.handle_key_event(key(KeyCode::Enter)); + + assert_eq!(tui.prompt, None); + assert_eq!(tui.current_step, 1); + assert_eq!(tui.status.as_ref().unwrap().kind, StatusKind::Info); + } + + #[test] + fn pc_input_escape_cancels_without_moving() { + let address = Address::repeat_byte(1); + let mut context = context_with_arena(vec![node(address, CallKind::Call, &[1, 42])]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + + let _ = tui.handle_key_event(key(KeyCode::Char('p'))); + let _ = tui.handle_key_event(key(KeyCode::Char('2'))); + let _ = tui.handle_key_event(key(KeyCode::Esc)); + + assert_eq!(tui.prompt, None); + assert_eq!(tui.current_step, 0); + assert_eq!(tui.status, None); + } + + #[test] + fn command_input_mode_handles_keys_and_blocks_normal_commands() { + let address = Address::repeat_byte(1); + let mut context = context_with_arena(vec![node(address, CallKind::Call, &[1, 42])]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + + assert!(matches!(tui.handle_key_event(key(KeyCode::Char(':'))), ControlFlow::Continue(()))); + assert_eq!(tui.prompt, Some(Prompt::Command(String::new()))); + + let _ = tui.handle_key_event(key(KeyCode::Char('q'))); + assert_eq!(tui.prompt, Some(Prompt::Command("q".into()))); + assert_eq!(tui.current_step, 0); + + let _ = tui.handle_key_event(key(KeyCode::Backspace)); + for c in "continue 2a".chars() { + let _ = tui.handle_key_event(key(KeyCode::Char(c))); + } + let _ = tui.handle_key_event(key(KeyCode::Enter)); + + assert_eq!(tui.prompt, None); + assert_eq!(tui.current_step, 1); + let status = tui.status.as_ref().unwrap(); + assert_eq!(status.kind, StatusKind::Info); + assert_eq!(status.text, "Jumped to PC 0x2a (42) in current trace"); + } + + #[test] + fn command_prompt_jumps_to_named_buffer_offset() { + let address = Address::repeat_byte(1); + let mut context = context_with_arena(vec![DebugNode::new( + address, + CallKind::Call, + vec![step(1)], + Bytes::from(vec![0; 96]), + 0, + None, + )]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + + tui.run_command_from_input("calldata 40"); + + assert_eq!(tui.active_buffer, BufferKind::Calldata); + assert_eq!(tui.draw_memory.current_buf_startline, 2); + assert_eq!(tui.status.as_ref().unwrap().text, "Jumped to calldata offset 0x40 (64)"); + } + + #[test] + fn labels_long_string_data_slots_written_before_length() { + let address = Address::repeat_byte(1); + let type_id = "t_string_storage".to_string(); + let identifier = SlotIdentifier::new(Arc::new(StorageLayout { + storage: vec![Storage { + ast_id: 1, + contract: "StorageTest".to_string(), + label: "text".to_string(), + offset: 0, + slot: "0".to_string(), + storage_type: type_id.clone(), + }], + types: BTreeMap::from([( + type_id, + StorageType { + encoding: ENCODING_BYTES.to_string(), + key: None, + label: "string".to_string(), + number_of_bytes: "32".to_string(), + value: None, + other: BTreeMap::new(), + }, + )]), + })); + let data_slot = keccak256(B256::ZERO).into(); + let mut data_access = step(1); + data_access.storage_change = Some(Box::new(StorageChange { + key: data_slot, + value: U256::ZERO, + had_value: None, + reason: StorageChangeReason::SSTORE, + })); + let mut base_access = step(2); + base_access.storage_change = Some(Box::new(StorageChange { + key: U256::ZERO, + value: U256::from(40 * 2 + 1), + had_value: None, + reason: StorageChangeReason::SSTORE, + })); + let mut context = context_with_arena(vec![DebugNode::new( + address, + CallKind::Call, + vec![data_access, base_access], + Bytes::new(), + 0, + None, + )]); + context.slot_identifiers = Some(AddressHashMap::from_iter([(address, identifier)])); + let mut tui = TUIContext::new(&mut context); + tui.current_step = 0; + let accesses = tui.storage_accesses(StorageSpace::Persistent); + let values = storage_values(&accesses); + let next_values = tui.next_storage_write_values(); + + assert_eq!( + tui.storage_label( + StorageSpace::Persistent, + data_slot, + Some(&values), + Some(&next_values), + ) + .as_deref(), + Some("text[0]") + ); + } + + #[test] + fn command_prompt_jumps_to_storage_slot_access() { + let address = Address::repeat_byte(1); + let mut first_store = step(2); + first_store.storage_change = Some(Box::new(StorageChange { + key: U256::ZERO, + value: U256::from(7), + had_value: None, + reason: StorageChangeReason::SSTORE, + })); + let mut store = step(42); + store.storage_change = Some(Box::new(StorageChange { + key: U256::ONE, + value: U256::from(42), + had_value: Some(U256::from(7)), + reason: StorageChangeReason::SSTORE, + })); + let mut context = context_with_arena(vec![DebugNode::new( + address, + CallKind::Call, + vec![step(1), first_store, store], + Bytes::new(), + 0, + None, + )]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + + tui.run_command_from_input("storage 1"); + + assert_eq!(tui.current_step, 2); + assert_eq!(tui.active_storage(), Some(StorageSpace::Persistent)); + assert_eq!(tui.storage_accesses(StorageSpace::Persistent).len(), 2); + assert_eq!( + tui.status.as_ref().unwrap().text, + "Jumped to storage SSTORE slot 0x1: 0x7 -> 0x2a at PC 0x2a (42)" + ); + } + + #[test] + fn command_prompt_jumps_to_transient_storage_slot_access() { + let address = Address::repeat_byte(1); + let steps = vec![step(1), step_with_stack(42, OpCode::TSTORE, &[0xbeef, 0x2a])]; + let mut context = context_with_arena(vec![DebugNode::new( + address, + CallKind::Call, + steps, + Bytes::new(), + 0, + None, + )]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + + tui.run_command_from_input("transient 2a"); + + assert_eq!(tui.current_step, 1); + assert_eq!(tui.active_storage(), Some(StorageSpace::Transient)); + assert_eq!( + tui.status.as_ref().unwrap().text, + "Jumped to transient storage TSTORE slot 0x2a = 0xbeef at PC 0x2a (42)" + ); + + tui.run_command_from_input("storage 2a"); + assert_eq!(tui.current_step, 1); + assert_eq!( + tui.status.as_ref().unwrap().text, + "Storage slot 0x2a not accessed in current call" + ); + } + + #[test] + fn command_prompt_searches_storage_across_split_call_segments() { + let address = Address::repeat_byte(1); + let mut store = step(42); + store.storage_change = Some(Box::new(StorageChange { + key: U256::ONE, + value: U256::from(42), + had_value: None, + reason: StorageChangeReason::SSTORE, + })); + + let mut first_store = step(1); + first_store.storage_change = Some(Box::new(StorageChange { + key: U256::ZERO, + value: U256::from(7), + had_value: None, + reason: StorageChangeReason::SSTORE, + })); + let mut first = + DebugNode::new(address, CallKind::Call, vec![first_store], Bytes::new(), 0, None); + first.trace_node_idx = 7; + first.step_offset = 0; + + let mut child_store = step(2); + child_store.storage_change = Some(Box::new(StorageChange { + key: U256::from(9), + value: U256::from(99), + had_value: None, + reason: StorageChangeReason::SSTORE, + })); + let mut child = DebugNode::new( + Address::repeat_byte(2), + CallKind::Call, + vec![child_store], + Bytes::new(), + 0, + None, + ); + child.trace_node_idx = 8; + child.step_offset = 1; + + let mut second = + DebugNode::new(address, CallKind::Call, vec![store], Bytes::new(), 0, None); + second.trace_node_idx = 7; + second.step_offset = 2; + + let mut context = context_with_arena(vec![first, child, second]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + + tui.run_command_from_input("storage 1"); + + assert_eq!(tui.draw_memory.inner_call_index, 2); + assert_eq!(tui.current_step, 0); + let accesses = tui.storage_accesses(StorageSpace::Persistent); + assert_eq!(accesses.len(), 2); + assert!(!accesses.contains_key(&U256::from(9))); + assert_eq!( + tui.status.as_ref().unwrap().text, + "Jumped to storage SSTORE slot 0x1 = 0x2a at PC 0x2a (42)" + ); + } + + #[test] + fn command_prompt_finds_warm_sload_from_stack_snapshots() { + let address = Address::repeat_byte(1); + let steps = + vec![step_with_stack(1, OpCode::SLOAD, &[1]), step_with_stack(2, OpCode::STOP, &[42])]; + let mut context = context_with_arena(vec![DebugNode::new( + address, + CallKind::Call, + steps, + Bytes::new(), + 0, + None, + )]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + + tui.run_command_from_input("store 1"); + + assert_eq!(tui.current_step, 0); + assert_eq!( + tui.status.as_ref().unwrap().text, + "Jumped to storage SLOAD slot 0x1 = 0x2a at PC 0x1 (1)" + ); + } + + #[test] + fn command_prompt_finds_warm_sstore_from_stack_snapshot() { + let address = Address::repeat_byte(1); + let steps = vec![step_with_stack(42, OpCode::SSTORE, &[42, 1])]; + let mut context = context_with_arena(vec![DebugNode::new( + address, + CallKind::Call, + steps, + Bytes::new(), + 0, + None, + )]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + + tui.run_command_from_input("slot 1"); + + assert_eq!(tui.current_step, 0); + assert_eq!( + tui.status.as_ref().unwrap().text, + "Jumped to storage SSTORE slot 0x1 = 0x2a at PC 0x2a (42)" + ); + } + + #[test] + fn command_prompt_ignores_failed_sstore_stack_snapshot() { + let address = Address::repeat_byte(1); + let mut store = step_with_stack(42, OpCode::SSTORE, &[42, 1]); + store.status = Some(InstructionResult::StateChangeDuringStaticCall); + let mut context = context_with_arena(vec![DebugNode::new( + address, + CallKind::Call, + vec![store], + Bytes::new(), + 0, + None, + )]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + + tui.run_command_from_input("slot 1"); + + assert_eq!(tui.current_step, 0); + let status = tui.status.as_ref().unwrap(); + assert_eq!(status.kind, StatusKind::Error); + assert_eq!(status.text, "Storage slot 0x1 not accessed in current call"); + } + + #[test] + fn command_prompt_ignores_failed_sstore_storage_change() { + let address = Address::repeat_byte(1); + let mut store = step_with_stack(42, OpCode::SSTORE, &[42, 1]); + store.storage_change = Some(Box::new(StorageChange { + key: U256::ONE, + value: U256::from(42), + had_value: Some(U256::ZERO), + reason: StorageChangeReason::SSTORE, + })); + store.status = Some(InstructionResult::OutOfGas); + let mut context = context_with_arena(vec![DebugNode::new( + address, + CallKind::Call, + vec![store], + Bytes::new(), + 0, + None, + )]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + + tui.run_command_from_input("slot 1"); + + assert_eq!(tui.current_step, 0); + let status = tui.status.as_ref().unwrap(); + assert_eq!(status.kind, StatusKind::Error); + assert_eq!(status.text, "Storage slot 0x1 not accessed in current call"); + } + + #[test] + fn command_prompt_accepts_optional_leading_colon() { + let address = Address::repeat_byte(1); + let mut context = context_with_arena(vec![node(address, CallKind::Call, &[1, 42])]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + + tui.run_command_from_input(":pc 2a"); + + assert_eq!(tui.current_step, 1); + assert_eq!(tui.status.as_ref().unwrap().text, "Jumped to PC 0x2a (42) in current trace"); + } + + #[test] + fn command_prompt_continues_to_next_pc_hit() { + let address = Address::repeat_byte(1); + let mut context = context_with_arena(vec![node(address, CallKind::Call, &[1, 42, 2, 42])]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + tui.current_step = 1; + + tui.run_command_from_input("pc 2a"); + assert_eq!(tui.current_step, 1); + + tui.run_command_from_input("continue 2a"); + assert_eq!(tui.current_step, 3); + } + + #[test] + fn command_prompt_jumps_to_source_line() { + let address = Address::repeat_byte(1); + let mut context = context_with_source_lines(address); + let mut tui = TUIContext::new(&mut context); + tui.init(); + + tui.run_command_from_input("line 2"); + + assert_eq!(tui.current_step, 1); + assert_eq!(tui.status.as_ref().unwrap().text, "Jumped to src/Test.sol:2 at PC 0x1 (1)"); + } + + #[test] + fn command_prompt_reports_help_and_usage_errors() { + let address = Address::repeat_byte(1); + let mut context = context_with_arena(vec![node(address, CallKind::Call, &[1])]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + + tui.run_command_from_input("help"); + assert_eq!(tui.status.as_ref().unwrap().kind, StatusKind::Info); + let help = &tui.status.as_ref().unwrap().text; + for commands in [ + CONTINUE_COMMANDS, + PC_COMMANDS, + MEMORY_COMMANDS, + CALLDATA_COMMANDS, + RETURNDATA_COMMANDS, + STORAGE_COMMANDS, + TRANSIENT_STORAGE_COMMANDS, + LINE_COMMANDS, + OPCODE_COMMANDS, + SOURCE_COMMANDS, + VARIABLES_COMMANDS, + STACK_COMMANDS, + DATA_COMMANDS, + ] { + assert!(help.contains(&command_aliases(commands))); + } + + tui.run_command_from_input("mem"); + let status = tui.status.as_ref().unwrap(); + assert_eq!(status.kind, StatusKind::Info); + assert_eq!(status.text, "Active buffer: memory"); + + tui.run_command_from_input("store"); + let status = tui.status.as_ref().unwrap(); + assert_eq!(status.kind, StatusKind::Info); + assert_eq!(status.text, "Active data: storage"); + + tui.run_command_from_input("store 1 2"); + let status = tui.status.as_ref().unwrap(); + assert_eq!(status.kind, StatusKind::Error); + assert_eq!(status.text, "Usage: :store "); + + tui.run_command_from_input("line"); + let status = tui.status.as_ref().unwrap(); + assert_eq!(status.kind, StatusKind::Error); + assert_eq!(status.text, "Usage: :line "); + } + + #[test] + fn command_prompt_toggles_panes() { + let address = Address::repeat_byte(1); + let mut context = context_with_arena(vec![node(address, CallKind::Call, &[1])]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + + tui.run_command_from_input("opcodes"); + assert!(!tui.show_opcodes); + assert_eq!(tui.status.as_ref().unwrap().text, "Opcodes pane: hidden"); + + tui.run_command_from_input(":ops"); + assert!(tui.show_opcodes); + assert_eq!(tui.status.as_ref().unwrap().text, "Opcodes pane: shown"); + + tui.run_command_from_input("source"); + assert!(!tui.show_source); + assert_eq!(tui.status.as_ref().unwrap().text, "Source pane: hidden"); + + tui.run_command_from_input(":src"); + assert!(tui.show_source); + assert_eq!(tui.status.as_ref().unwrap().text, "Source pane: shown"); + + tui.run_command_from_input("variables"); + assert!(!tui.show_variables); + assert_eq!(tui.status.as_ref().unwrap().text, "Variables pane: hidden"); + + tui.run_command_from_input(":vars"); + assert!(tui.show_variables); + assert_eq!(tui.status.as_ref().unwrap().text, "Variables pane: shown"); + + tui.run_command_from_input("stack"); + assert!(!tui.show_stack); + assert_eq!(tui.status.as_ref().unwrap().text, "Stack pane: hidden"); + + tui.run_command_from_input(":stack"); + assert!(tui.show_stack); + assert_eq!(tui.status.as_ref().unwrap().text, "Stack pane: shown"); + + tui.run_command_from_input("data"); + assert!(!tui.show_data); + assert_eq!(tui.status.as_ref().unwrap().text, "Data pane: hidden"); + + tui.run_command_from_input(":data"); + assert!(tui.show_data); + assert_eq!(tui.status.as_ref().unwrap().text, "Data pane: shown"); + + tui.run_command_from_input("stack extra"); + let status = tui.status.as_ref().unwrap(); + assert_eq!(status.kind, StatusKind::Error); + assert_eq!(status.text, "Usage: :stack"); + } + + #[test] + fn buffer_offset_input_mode_handles_calldata_offsets_and_blocks_normal_commands() { + let address = Address::repeat_byte(1); + let mut context = context_with_arena(vec![DebugNode::new( + address, + CallKind::Call, + vec![step(1)], + Bytes::from(vec![0; 96]), + 0, + None, + )]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + tui.active_buffer = BufferKind::Calldata; + + assert!(matches!(tui.handle_key_event(key(KeyCode::Char('o'))), ControlFlow::Continue(()))); + assert_eq!(tui.prompt, Some(Prompt::BufferOffset(String::new()))); + + let _ = tui.handle_key_event(key(KeyCode::Char('q'))); + assert_eq!(tui.prompt, Some(Prompt::BufferOffset(String::new()))); + assert_eq!(tui.draw_memory.current_buf_startline, 0); + + for c in "40".chars() { + let _ = tui.handle_key_event(key(KeyCode::Char(c))); + } + let _ = tui.handle_key_event(key(KeyCode::Enter)); + + assert_eq!(tui.prompt, None); + assert_eq!(tui.draw_memory.current_buf_startline, 2); + let status = tui.status.as_ref().unwrap(); + assert_eq!(status.kind, StatusKind::Info); + assert_eq!(status.text, "Jumped to calldata offset 0x40 (64)"); + } + + #[test] + fn buffer_offset_jumps_in_active_calldata_buffer() { + let address = Address::repeat_byte(1); + let mut context = context_with_arena(vec![DebugNode::new( + address, + CallKind::Call, + vec![step(1)], + Bytes::from(vec![0; 96]), + 0, + None, + )]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + tui.active_buffer = BufferKind::Calldata; + + tui.goto_buffer_offset_from_input("20"); + + assert_eq!(tui.draw_memory.current_buf_startline, 1); + assert_eq!(tui.status.as_ref().unwrap().text, "Jumped to calldata offset 0x20 (32)"); + } + + #[test] + fn buffer_offset_jumps_to_visible_hex_label_on_partial_last_line() { + let address = Address::repeat_byte(1); + let mut context = context_with_arena(vec![DebugNode::new( + address, + CallKind::Call, + vec![step(1)], + Bytes::from(vec![0; 65]), + 0, + None, + )]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + tui.active_buffer = BufferKind::Calldata; + + tui.goto_buffer_offset_from_input("40"); + + assert_eq!(tui.draw_memory.current_buf_startline, 2); + assert_eq!(tui.status.as_ref().unwrap().text, "Jumped to calldata offset 0x40 (64)"); + } + + #[test] + fn buffer_offset_reports_out_of_range_offsets_without_moving() { + let address = Address::repeat_byte(1); + let mut context = context_with_arena(vec![DebugNode::new( + address, + CallKind::Call, + vec![step(1)], + Bytes::from(vec![0; 64]), + 0, + None, + )]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + tui.active_buffer = BufferKind::Calldata; + tui.draw_memory.current_buf_startline = 1; + + tui.goto_buffer_offset_from_input("20"); + assert_eq!(tui.draw_memory.current_buf_startline, 1); + let status = tui.status.as_ref().unwrap(); + assert_eq!(status.kind, StatusKind::Info); + assert_eq!(status.text, "Jumped to calldata offset 0x20 (32)"); + + tui.draw_memory.current_buf_startline = 0; + tui.goto_buffer_offset_from_input("0x80"); + assert_eq!(tui.draw_memory.current_buf_startline, 0); + let status = tui.status.as_ref().unwrap(); + assert_eq!(status.kind, StatusKind::Error); + assert_eq!(status.text, "calldata offset 0x80 (128) is outside the 64-byte buffer"); + } + + #[test] + fn buffer_offset_escape_cancels_and_empty_buffer_reports_error() { + let address = Address::repeat_byte(1); + let mut context = context_with_arena(vec![node(address, CallKind::Call, &[1])]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + + let _ = tui.handle_key_event(key(KeyCode::Char('o'))); + let _ = tui.handle_key_event(key(KeyCode::Char('2'))); + let _ = tui.handle_key_event(key(KeyCode::Esc)); + + assert_eq!(tui.prompt, None); + assert_eq!(tui.draw_memory.current_buf_startline, 0); + assert_eq!(tui.status, None); + + tui.goto_buffer_offset_from_input("0"); + let status = tui.status.as_ref().unwrap(); + assert_eq!(status.kind, StatusKind::Error); + assert_eq!(status.text, "Current memory buffer is empty"); + } + + #[test] + fn buffer_scroll_reaches_partial_last_line() { + let address = Address::repeat_byte(1); + let mut context = context_with_arena(vec![DebugNode::new( + address, + CallKind::Call, + vec![step(1)], + Bytes::from(vec![0; 65]), + 0, + None, + )]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + tui.active_buffer = BufferKind::Calldata; + + let _ = tui.handle_key_event(ctrl_key(KeyCode::Char('j'))); + assert_eq!(tui.draw_memory.current_buf_startline, 1); + let _ = tui.handle_key_event(ctrl_key(KeyCode::Char('j'))); + assert_eq!(tui.draw_memory.current_buf_startline, 2); + let _ = tui.handle_key_event(ctrl_key(KeyCode::Char('j'))); + assert_eq!(tui.draw_memory.current_buf_startline, 2); + } + + #[test] + fn storage_scroll_repeats_without_exceeding_last_slot() { + let steps = + (0..3).map(|slot| step_with_stack(slot, OpCode::TSTORE, &[slot, slot])).collect(); + let mut context = context_with_arena(vec![DebugNode::new( + Address::ZERO, + CallKind::Call, + steps, + Bytes::new(), + 0, + None, + )]); + let mut tui = TUIContext::new(&mut context); + tui.current_step = 2; + tui.run_command_from_input("transient"); + + let _ = tui.handle_key_event(key(KeyCode::Char('2'))); + let _ = tui.handle_key_event(ctrl_key(KeyCode::Char('j'))); + assert_eq!(tui.draw_memory.current_storage_startline, 2); + + let _ = tui.handle_key_event(ctrl_key(KeyCode::Char('j'))); + assert_eq!(tui.draw_memory.current_storage_startline, 2); + } + + #[test] + fn opcode_search_wraps_and_is_case_insensitive() { + let arena = vec![DebugNode::new( + Address::repeat_byte(1), + CallKind::Call, + vec![ + step(1), + step_with_immediate(2, OpCode::PUSH4, &[0x95, 0xd8, 0x9b, 0x41]), + step_with_stack(3, OpCode::MSTORE, &[]), + ], + Bytes::new(), + 0, + None, + )]; + + assert_eq!( + find_opcode_match(&arena, 0, 0, "push4", SearchDirection::Forward), + Some((0, 1)) + ); + assert_eq!( + find_opcode_match(&arena, 0, 0, "95D89B41", SearchDirection::Forward), + Some((0, 1)) + ); + assert_eq!( + find_opcode_match(&arena, 0, 0, "mstore", SearchDirection::Backward), + Some((0, 2)) + ); + assert_eq!(find_opcode_match(&arena, 0, 0, "sload", SearchDirection::Forward), None); + } + + #[test] + fn opcode_search_input_mode_handles_keys_and_blocks_normal_commands() { + let address = Address::repeat_byte(1); + let mut context = context_with_arena(vec![DebugNode::new( + address, + CallKind::Call, + vec![ + step(1), + step_with_immediate(2, OpCode::PUSH4, &[0x95, 0xd8, 0x9b, 0x41]), + step_with_stack(3, OpCode::MSTORE, &[]), + ], + Bytes::new(), + 0, + None, + )]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + + assert!(matches!(tui.handle_key_event(key(KeyCode::Char('/'))), ControlFlow::Continue(()))); + assert_eq!(tui.prompt, Some(Prompt::OpcodeSearch(String::new()))); + + let _ = tui.handle_key_event(key(KeyCode::Char('q'))); + assert_eq!(tui.prompt, Some(Prompt::OpcodeSearch("q".into()))); + assert_eq!(tui.current_step, 0); + + let _ = tui.handle_key_event(key(KeyCode::Backspace)); + let _ = tui.handle_key_event(key(KeyCode::Char('9'))); + let _ = tui.handle_key_event(key(KeyCode::Char('5'))); + let _ = tui.handle_key_event(key(KeyCode::Enter)); + + assert_eq!(tui.prompt, None); + assert_eq!(tui.last_opcode_search.as_deref(), Some("95")); + assert_eq!(tui.current_step, 1); + assert_eq!(tui.status.as_ref().unwrap().kind, StatusKind::Info); + } + + #[test] + fn opcode_search_repeats_forward_and_backward() { + let address = Address::repeat_byte(1); + let mut context = context_with_arena(vec![DebugNode::new( + address, + CallKind::Call, + vec![ + step_with_stack(1, OpCode::MSTORE, &[]), + step(2), + step_with_stack(3, OpCode::MSTORE, &[]), + ], + Bytes::new(), + 0, + None, + )]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + + let _ = tui.handle_key_event(key(KeyCode::Char('/'))); + for c in "mstore".chars() { + let _ = tui.handle_key_event(key(KeyCode::Char(c))); + } + let _ = tui.handle_key_event(key(KeyCode::Enter)); + assert_eq!(tui.current_step, 2); + + let _ = tui.handle_key_event(key(KeyCode::Char('n'))); + assert_eq!(tui.current_step, 0); + + let _ = tui.handle_key_event(key(KeyCode::Char('N'))); + assert_eq!(tui.current_step, 2); + } + + #[test] + fn opcode_search_crosses_split_call_segments() { + let address = Address::repeat_byte(1); + let node = |address, trace_node_idx, step_offset, steps| { + let mut node = DebugNode::new(address, CallKind::Call, steps, Bytes::new(), 0, None); + node.trace_node_idx = trace_node_idx; + node.step_offset = step_offset; + node + }; + let arena = vec![ + node(address, 0, 0, vec![step_with_stack(1, OpCode::MSTORE, &[]), step(2)]), + node(Address::repeat_byte(2), 1, 0, vec![step_with_stack(3, OpCode::MSTORE, &[])]), + node(address, 0, 2, vec![step(4), step_with_stack(5, OpCode::MSTORE, &[])]), + ]; + + assert_eq!( + find_opcode_match(&arena, 0, 0, "mstore", SearchDirection::Forward), + Some((2, 1)) + ); + assert_eq!( + find_opcode_match(&arena, 2, 1, "mstore", SearchDirection::Forward), + Some((0, 0)) + ); + assert_eq!( + find_opcode_match(&arena, 0, 0, "mstore", SearchDirection::Backward), + Some((2, 1)) + ); + } + + #[test] + fn opcode_search_escape_cancels_without_moving() { + let address = Address::repeat_byte(1); + let mut context = context_with_arena(vec![node(address, CallKind::Call, &[1, 42])]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + + let _ = tui.handle_key_event(key(KeyCode::Char('/'))); + let _ = tui.handle_key_event(key(KeyCode::Char('s'))); + let _ = tui.handle_key_event(key(KeyCode::Esc)); + + assert_eq!(tui.prompt, None); + assert_eq!(tui.last_opcode_search, None); + assert_eq!(tui.current_step, 0); + assert_eq!(tui.status, None); + } + + #[test] + fn opcode_search_reports_empty_input_without_remembering_search() { + let address = Address::repeat_byte(1); + let mut context = context_with_arena(vec![node(address, CallKind::Call, &[1, 42])]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + + let _ = tui.handle_key_event(key(KeyCode::Char('/'))); + let _ = tui.handle_key_event(key(KeyCode::Enter)); + + assert_eq!(tui.current_step, 0); + assert_eq!(tui.last_opcode_search, None); + let status = tui.status.as_ref().unwrap(); + assert_eq!(status.kind, StatusKind::Error); + assert_eq!(status.text, "Enter an opcode search term"); + } + + #[test] + fn opcode_search_reports_repeat_without_previous_search() { + let address = Address::repeat_byte(1); + let mut context = context_with_arena(vec![node(address, CallKind::Call, &[1, 42])]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + + let _ = tui.handle_key_event(key(KeyCode::Char('n'))); + + assert_eq!(tui.current_step, 0); + let status = tui.status.as_ref().unwrap(); + assert_eq!(status.kind, StatusKind::Error); + assert_eq!(status.text, "No previous opcode search"); + } + + #[test] + fn opcode_search_reports_no_match_without_moving() { + let address = Address::repeat_byte(1); + let mut context = context_with_arena(vec![node(address, CallKind::Call, &[1, 42])]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + + let _ = tui.handle_key_event(key(KeyCode::Char('/'))); + for c in "sload".chars() { + let _ = tui.handle_key_event(key(KeyCode::Char(c))); + } + let _ = tui.handle_key_event(key(KeyCode::Enter)); + + assert_eq!(tui.current_step, 0); + assert_eq!(tui.last_opcode_search.as_deref(), Some("sload")); + let status = tui.status.as_ref().unwrap(); + assert_eq!(status.kind, StatusKind::Error); + assert_eq!(status.text, "No opcode matching `sload` in current call"); + } + + #[test] + fn memory_write_start_line_uses_write_offset() { + assert_eq!(memory_write_start_line(&step_with_stack(0, OpCode::MSTORE, &[0, 96])), Some(3)); + assert_eq!( + memory_write_start_line(&step_with_stack(0, OpCode::MSTORE8, &[0, 33])), + Some(1) + ); + assert_eq!(memory_write_start_line(&step(0)), None); + } + + #[test] + fn bounded_memory_write_start_line_requires_visible_non_empty_write() { + let write_at_128 = step_with_stack(0, OpCode::MSTORE, &[0, 128]); + assert_eq!(bounded_memory_write_start_line(&write_at_128, 160), Some(4)); + assert_eq!(bounded_memory_write_start_line(&write_at_128, 128), None); + + let zero_len_copy = step_with_stack(0, OpCode::CALLDATACOPY, &[0, 0, 1_000_000]); + assert_eq!(memory_write_start_line(&zero_len_copy), None); + assert_eq!(bounded_memory_write_start_line(&zero_len_copy, 32), None); + } + + #[test] + fn stepping_past_memory_write_without_memory_snapshot_keeps_scroll_position() { + let address = Address::repeat_byte(1); + let mut context = context_with_arena(vec![DebugNode::new( + address, + CallKind::Call, + vec![step_with_stack(1, OpCode::MSTORE, &[0, 128]), step(2)], + Bytes::new(), + 0, + None, + )]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + tui.draw_memory.current_buf_startline = 99; + + tui.step(); + + assert_eq!(tui.current_step, 1); + assert_eq!(tui.draw_memory.current_buf_startline, 99); + } + + #[test] + fn memory_write_autoscroll_only_applies_to_memory_buffer() { + let address = Address::repeat_byte(1); + let mut context = context_with_arena(vec![DebugNode::new( + address, + CallKind::Call, + vec![step_with_stack(1, OpCode::MSTORE, &[0, 128]), step(2)], + Bytes::from(vec![0; 256]), + 0, + None, + )]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + tui.active_buffer = BufferKind::Calldata; + tui.draw_memory.current_buf_startline = 7; + + tui.step(); + + assert_eq!(tui.current_step, 1); + assert_eq!(tui.draw_memory.current_buf_startline, 7); + } + + #[test] + fn navigation_clamps_scroll_positions_to_non_empty_data() { + let address = Address::repeat_byte(1); + let mut context = context_with_arena(vec![ + DebugNode::new( + address, + CallKind::Call, + vec![step_with_stack(1, OpCode::STOP, &[0, 1, 2])], + Bytes::from(vec![0; 64]), + 0, + None, + ), + DebugNode::new( + address, + CallKind::Call, + vec![step_with_stack(2, OpCode::STOP, &[0])], + Bytes::from(vec![0; 4]), + 0, + None, + ), + ]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + tui.active_buffer = BufferKind::Calldata; + tui.draw_memory.current_buf_startline = 1; + tui.draw_memory.current_stack_startline = 2; + + let _ = tui.handle_key_event(key(KeyCode::Char('C'))); + + assert_eq!(tui.draw_memory.inner_call_index, 1); + assert_eq!(tui.draw_memory.current_buf_startline, 0); + assert_eq!(tui.draw_memory.current_stack_startline, 0); + } + + #[test] + fn navigation_preserves_stack_scroll_across_empty_snapshot() { + let address = Address::repeat_byte(1); + let mut empty_stack = step(2); + empty_stack.stack = Some(Vec::new().into_boxed_slice()); + let mut context = context_with_arena(vec![DebugNode::new( + address, + CallKind::Call, + vec![ + step_with_stack(1, OpCode::STOP, &[0, 1, 2]), + empty_stack, + step_with_stack(3, OpCode::STOP, &[0, 1, 2]), + ], + Bytes::new(), + 0, + None, + )]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + tui.draw_memory.current_stack_startline = 2; + + tui.step(); + assert_eq!(tui.draw_memory.current_stack_startline, 2); + + tui.step(); + assert_eq!(tui.draw_memory.current_stack_startline, 2); + } } diff --git a/crates/debugger/src/tui/draw.rs b/crates/debugger/src/tui/draw.rs index 7b4c20b5df0a0..7a48677d40b02 100644 --- a/crates/debugger/src/tui/draw.rs +++ b/crates/debugger/src/tui/draw.rs @@ -1,10 +1,21 @@ //! TUI draw implementation. -use super::context::TUIContext; -use crate::op::OpcodeParam; -use foundry_compilers::artifacts::sourcemap::SourceElement; +use super::{ + context::{ + ActiveInternalCallCache, ActiveInternalCallLocation, Prompt, StatusKind, TUIContext, + write_pretty_opcode, + }, + storage::{StorageAccess, StorageSpace, hex_u256, storage_access_at, storage_values}, +}; +use crate::{DebuggerLayout, debugger::DebuggerStats, op::OpcodeParam}; +use alloy_dyn_abi::{DynSolType, Specifier, parser::Parameters}; +use alloy_primitives::{Address, U256, keccak256}; +use foundry_common::fmt::format_token; use foundry_evm_core::buffer::{BufferKind, get_buffer_accesses}; -use foundry_evm_traces::debug::SourceData; +use foundry_evm_traces::{ + CallKind, DecodedInternalCall, DecodedTraceStep, + debug::{DebugSourceScope, DebugVariable, decode_step_parameters, function_signature}, +}; use ratatui::{ Frame, layout::{Alignment, Constraint, Direction, Layout, Rect}, @@ -12,32 +23,32 @@ use ratatui::{ text::{Line, Span, Text}, widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}, }; -use revm_inspectors::tracing::types::CallKind; -use std::{collections::VecDeque, fmt::Write, io}; +use revm::interpreter::InstructionResult; +use std::{collections::VecDeque, fmt::Write}; impl TUIContext<'_> { - /// Draws the TUI layout and subcomponents to the given terminal. - pub(crate) fn draw(&self, terminal: &mut super::DebuggerTerminal) -> io::Result<()> { - terminal.draw(|f| self.draw_layout(f)).map(drop) - } - - #[inline] - fn draw_layout(&self, f: &mut Frame<'_>) { - // We need 100 columns to display a 32 byte word in the memory and stack panes. + pub(crate) fn draw_layout(&mut self, f: &mut Frame<'_>) { + // We need 100 columns to display a 32 byte word in the data and stack panes. let area = f.area(); - let min_width = 100; + let min_width = if self.show_data || self.show_stack { 100 } else { 1 }; let min_height = 16; if area.width < min_width || area.height < min_height { self.size_too_small(f, min_width, min_height); return; } - // The horizontal layout draws these panes at 50% width. - let min_column_width_for_horizontal = 200; - if area.width >= min_column_width_for_horizontal { - self.horizontal_layout(f); - } else { - self.vertical_layout(f); + match self.layout() { + DebuggerLayout::Horizontal => self.horizontal_layout(f), + DebuggerLayout::Vertical => self.vertical_layout(f), + DebuggerLayout::Auto => { + // The horizontal layout draws these panes at 50% width. + let min_column_width_for_horizontal = 200; + if area.width >= min_column_width_for_horizontal { + self.horizontal_layout(f); + } else { + self.vertical_layout(f); + } + } } } @@ -74,6 +85,8 @@ impl TUIContext<'_> { /// |-----------------------------| /// | op | /// |-----------------------------| + /// | variables | + /// |-----------------------------| /// | stack | /// |-----------------------------| /// | buf | @@ -83,110 +96,262 @@ impl TUIContext<'_> { /// | | /// |-----------------------------| /// ``` - fn vertical_layout(&self, f: &mut Frame<'_>) { + fn vertical_layout(&mut self, f: &mut Frame<'_>) { let area = f.area(); - let h_height = if self.show_shortcuts { 4 } else { 0 }; - - // NOTE: `Layout::split` always returns a slice of the same length as the number of - // constraints, so the `else` branch is unreachable. + let footer_height = self.footer_height(); // Split off footer. let [app, footer] = Layout::new( Direction::Vertical, - [Constraint::Ratio(100 - h_height, 100), Constraint::Ratio(h_height, 100)], + [Constraint::Min(0), Constraint::Length(footer_height)], ) .split(area)[..] else { unreachable!() }; - // Split the app in 4 vertically to construct all the panes. - let [op_pane, stack_pane, memory_pane, src_pane] = Layout::new( - Direction::Vertical, - [ - Constraint::Ratio(1, 6), - Constraint::Ratio(1, 6), - Constraint::Ratio(1, 6), - Constraint::Ratio(3, 6), - ], - ) - .split(app)[..] else { - unreachable!() - }; - - if self.show_shortcuts { + if footer_height > 0 { self.draw_footer(f, footer); } - self.draw_src(f, src_pane); - self.draw_op_list(f, op_pane); - self.draw_stack(f, stack_pane); - self.draw_buffer(f, memory_pane); + + let opcodes_weight = if self.show_opcodes { 1 } else { 0 }; + let source_weight = if self.show_source { 3 } else { 0 }; + let data_weight = if self.show_data { if self.show_source { 1 } else { 2 } } else { 0 }; + let total_weight = opcodes_weight + + data_weight + + source_weight + + if self.show_variables { 1 } else { 0 } + + if self.show_stack { 1 } else { 0 }; + let mut constraints = Vec::with_capacity(5); + if self.show_opcodes { + constraints.push(Constraint::Ratio(opcodes_weight, total_weight)); + } + if self.show_variables { + constraints.push(Constraint::Ratio(1, total_weight)); + } + if self.show_stack { + constraints.push(Constraint::Ratio(1, total_weight)); + } + if self.show_data { + constraints.push(Constraint::Ratio(data_weight, total_weight)); + } + if self.show_source { + constraints.push(Constraint::Ratio(source_weight, total_weight)); + } + + let panes = Layout::new(Direction::Vertical, constraints).split(app); + let mut panes = panes.iter(); + if self.show_opcodes { + self.draw_op_list(f, *panes.next().expect("opcodes pane is visible")); + } + if self.show_variables { + self.draw_variables(f, *panes.next().expect("variables pane is visible")); + } + if self.show_stack { + self.draw_stack(f, *panes.next().expect("stack pane is visible")); + } + if self.show_data { + self.draw_data(f, *panes.next().expect("data pane is visible")); + } + if self.show_source { + self.draw_src(f, *panes.next().expect("source pane is visible")); + } } /// Draws the layout in horizontal mode. /// /// ```text /// |-----------------|-----------| - /// | op | stack | + /// | op | variables | /// |-----------------|-----------| + /// | | stack | + /// | src |-----------| /// | | | - /// | src | buf | + /// | | buf | /// | | | /// |-----------------|-----------| /// ``` - fn horizontal_layout(&self, f: &mut Frame<'_>) { + fn horizontal_layout(&mut self, f: &mut Frame<'_>) { let area = f.area(); - let h_height = if self.show_shortcuts { 4 } else { 0 }; + let footer_height = self.footer_height(); // Split off footer. let [app, footer] = Layout::new( Direction::Vertical, - [Constraint::Ratio(100 - h_height, 100), Constraint::Ratio(h_height, 100)], + [Constraint::Min(0), Constraint::Length(footer_height)], ) .split(area)[..] else { unreachable!() }; - // Split app in 2 horizontally. - let [app_left, app_right] = - Layout::new(Direction::Horizontal, [Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]) - .split(app)[..] - else { - unreachable!() + let has_left_pane = self.show_opcodes || self.show_source; + let has_right_pane = self.show_variables || self.show_stack || self.show_data; + let (app_left, app_right) = match (has_left_pane, has_right_pane) { + (true, true) => { + let [app_left, app_right] = Layout::new( + Direction::Horizontal, + [Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)], + ) + .split(app)[..] else { + unreachable!() + }; + (Some(app_left), Some(app_right)) + } + (true, false) => (Some(app), None), + (false, true) => (None, Some(app)), + (false, false) => (None, None), }; - // Split left pane in 2 vertically to opcode list and source. - let [op_pane, src_pane] = - Layout::new(Direction::Vertical, [Constraint::Ratio(1, 4), Constraint::Ratio(3, 4)]) - .split(app_left)[..] - else { - unreachable!() + if footer_height > 0 { + self.draw_footer(f, footer); + } + if let Some(app_left) = app_left { + let total_weight = + if self.show_opcodes { 1 } else { 0 } + if self.show_source { 3 } else { 0 }; + let mut constraints = Vec::with_capacity(2); + if self.show_opcodes { + constraints.push(Constraint::Ratio(1, total_weight)); + } + if self.show_source { + constraints.push(Constraint::Ratio(3, total_weight)); + } + + let panes = Layout::new(Direction::Vertical, constraints).split(app_left); + let mut panes = panes.iter(); + if self.show_opcodes { + self.draw_op_list(f, *panes.next().expect("opcodes pane is visible")); + } + if self.show_source { + self.draw_src(f, *panes.next().expect("source pane is visible")); + } + } + + if let Some(app_right) = app_right { + let total_weight = if self.show_variables { 1 } else { 0 } + + if self.show_stack { 1 } else { 0 } + + if self.show_data { 2 } else { 0 }; + let mut constraints = Vec::with_capacity(3); + if self.show_variables { + constraints.push(Constraint::Ratio(1, total_weight)); + } + if self.show_stack { + constraints.push(Constraint::Ratio(1, total_weight)); + } + if self.show_data { + constraints.push(Constraint::Ratio(2, total_weight)); + } + + let panes = Layout::new(Direction::Vertical, constraints).split(app_right); + let mut panes = panes.iter(); + if self.show_variables { + self.draw_variables(f, *panes.next().expect("variables pane is visible")); + } + if self.show_stack { + self.draw_stack(f, *panes.next().expect("stack pane is visible")); + } + if self.show_data { + self.draw_data(f, *panes.next().expect("data pane is visible")); + } + } + } + + fn footer_height(&self) -> u16 { + let status_or_input = if matches!(self.prompt, Some(Prompt::Command(_))) { + 3 + } else { + u16::from(self.prompt.is_some() || self.status.is_some()) }; + let shortcuts = if self.show_shortcuts { 3 } else { 0 }; + status_or_input + shortcuts + } + + fn draw_footer(&self, f: &mut Frame<'_>, area: Rect) { + if let Some(Prompt::Command(input)) = &self.prompt { + self.draw_command_prompt(f, area, input); + return; + } + + let mut lines = Vec::with_capacity(self.footer_height() as usize); + + if let Some(Prompt::Pc(input)) = &self.prompt { + lines.push(Line::from(vec![ + Span::styled( + "Goto PC: ", + Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD), + ), + Span::raw(input.as_str()), + Span::styled("█", Style::new().fg(Color::Cyan)), + Span::styled( + " Enter: jump | Esc: cancel | hex: 0x2a/2a | decimal: d:42", + Style::new().add_modifier(Modifier::DIM), + ), + ])); + } else if let Some(Prompt::BufferOffset(input)) = &self.prompt { + lines.push(Line::from(vec![ + Span::styled( + format!("Goto {} offset: ", self.active_buffer_name()), + Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD), + ), + Span::raw(input.as_str()), + Span::styled("█", Style::new().fg(Color::Cyan)), + Span::styled( + " Enter: jump | Esc: cancel | hex: 0x20/20 | decimal: d:32", + Style::new().add_modifier(Modifier::DIM), + ), + ])); + } else if let Some(Prompt::OpcodeSearch(input)) = &self.prompt { + lines.push(Line::from(vec![ + Span::styled( + "Search opcodes: /", + Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD), + ), + Span::raw(input.as_str()), + Span::styled("█", Style::new().fg(Color::Cyan)), + Span::styled( + " Enter: jump | Esc: cancel | after search: n/N repeat", + Style::new().add_modifier(Modifier::DIM), + ), + ])); + } else if let Some(status) = &self.status { + let style = match status.kind { + StatusKind::Info => Style::new().fg(Color::Green), + StatusKind::Error => Style::new().fg(Color::Red).add_modifier(Modifier::BOLD), + }; + lines.push(Line::from(Span::styled(status.text.as_str(), style))); + } - // Split right pane horizontally to construct stack and memory. - let [stack_pane, memory_pane] = - Layout::new(Direction::Vertical, [Constraint::Ratio(1, 4), Constraint::Ratio(3, 4)]) - .split(app_right)[..] - else { + if self.show_shortcuts { + lines.extend(shortcut_lines()); + } + + let paragraph = + Paragraph::new(lines).alignment(Alignment::Center).wrap(Wrap { trim: false }); + f.render_widget(paragraph, area); + } + + fn draw_command_prompt(&self, f: &mut Frame<'_>, area: Rect, input: &str) { + let shortcuts = if self.show_shortcuts { 3 } else { 0 }; + let [prompt, shortcuts_area] = Layout::new( + Direction::Vertical, + [Constraint::Length(3), Constraint::Length(shortcuts)], + ) + .split(area)[..] else { unreachable!() }; + let prompt_line = command_prompt_line(input, prompt.width.saturating_sub(2)); + let block = Block::default().title("Command").borders(Borders::ALL); + let paragraph = Paragraph::new(prompt_line).block(block); + f.render_widget(paragraph, prompt); + if self.show_shortcuts { - self.draw_footer(f, footer); + self.draw_shortcuts(f, shortcuts_area); } - self.draw_src(f, src_pane); - self.draw_op_list(f, op_pane); - self.draw_stack(f, stack_pane); - self.draw_buffer(f, memory_pane); } - fn draw_footer(&self, f: &mut Frame<'_>, area: Rect) { - let l1 = "[q]: quit | [k/j]: prev/next op | [a/s]: prev/next jump | [c/C]: prev/next call | [g/G]: start/end | [b]: cycle memory/calldata/returndata buffers"; - let l2 = "[t]: stack labels | [m]: buffer decoding | [shift + j/k]: scroll stack | [ctrl + j/k]: scroll buffer | [']: goto breakpoint | [h] toggle help"; - let dimmed = Style::new().add_modifier(Modifier::DIM); - let lines = - vec![Line::from(Span::styled(l1, dimmed)), Line::from(Span::styled(l2, dimmed))]; - let paragraph = - Paragraph::new(lines).alignment(Alignment::Center).wrap(Wrap { trim: false }); + fn draw_shortcuts(&self, f: &mut Frame<'_>, area: Rect) { + let paragraph = Paragraph::new(shortcut_lines()) + .alignment(Alignment::Center) + .wrap(Wrap { trim: false }); f.render_widget(paragraph, area); } @@ -200,10 +365,10 @@ impl TUIContext<'_> { CallKind::DelegateCall => "Contract delegatecall", CallKind::AuthCall => "Contract authcall", }; - let title = format!( - "{} {} ", + let title = source_pane_title( call_kind_text, - source_name.map(|s| format!("| {s}")).unwrap_or_default() + source_name, + self.current_step_notice_text().map(step_notice_title), ); let block = Block::default().title(title).borders(Borders::ALL); let paragraph = Paragraph::new(text_output).block(block).wrap(Wrap { trim: false }); @@ -291,11 +456,11 @@ impl TUIContext<'_> { lines.push(u_num, line, u_text); } - let first = if !last_has_nl { + let first = if last_has_nl { + 0 + } else { lines.push_raw(h_num, &[Span::raw(last), Span::styled(actual[0], h_text)]); 1 - } else { - 0 }; // Skip the first line if it has already been handled above. @@ -337,54 +502,48 @@ impl TUIContext<'_> { (Text::from(lines.lines), source.path.to_str()) } - /// Returns source map, source code and source name of the current line. - fn src_map(&self) -> Result<(SourceElement, &SourceData), String> { - let address = self.address(); - let Some(contract_name) = self.debugger_context.identified_contracts.get(address) else { - return Err(format!("Unknown contract at address {address}")); + fn current_step_notice_text(&self) -> Option<&str> { + let DecodedTraceStep::Line(line) = self.current_step().decoded.as_deref()? else { + return None; }; - - self.debugger_context - .contracts_sources - .find_source_mapping( - contract_name, - self.current_step().pc as u32, - self.debug_call().kind.is_any_create(), - ) - .ok_or_else(|| format!("No source map for contract {contract_name}")) + (!line.is_empty()).then_some(line.as_str()) } fn draw_op_list(&self, f: &mut Frame<'_>, area: Rect) { let debug_steps = self.debug_steps(); - let max_pc = debug_steps.iter().map(|step| step.pc).max().unwrap_or(0); - let max_pc_len = hex_digits(max_pc); + // Opcode items are one line each; window them before `List::new` collects the iterator. + let visible_rows = area.height.saturating_sub(2) as usize; + let scroll_padding = usize::from(visible_rows >= 3); + let end = debug_steps + .len() + .min(visible_rows.max(self.current_step.saturating_add(scroll_padding + 1))); + let start = end.saturating_sub(visible_rows); + let pc_width = hex_digits(self.opcode_max_pc()); + let items = debug_steps[start..end].iter().map(|step| { + let mut row = String::with_capacity(pc_width + 1 + 8); + write!(row, "{:0>pc_width$x}|", step.pc).unwrap(); + write_pretty_opcode(&mut row, step); + ListItem::new(Span::styled(row, Style::new().fg(Color::White))) + }); - let items = debug_steps - .iter() - .enumerate() - .map(|(i, step)| { - let mut content = String::with_capacity(64); - write!(content, "{:0>max_pc_len$x}|", step.pc).unwrap(); - if let Some(op) = self.opcode_list.get(i) { - content.push_str(op); - } - ListItem::new(Span::styled(content, Style::new().fg(Color::White))) - }) - .collect::>(); - - let title = format!( - "Address: {} | PC: {} | Gas used in call: {}", + let step = self.current_step(); + let call_gas_used = self.debug_call().gas_limit.saturating_sub(step.gas_remaining); + let title = op_list_title( self.address(), - self.current_step().pc, - self.current_step().gas_used, + step.pc, + step.gas_remaining, + call_gas_used, + step.gas_refund_counter, + self.debugger_context.stats, ); let block = Block::default().title(title).borders(Borders::ALL); let list = List::new(items) .block(block) .highlight_symbol("▶") .highlight_style(Style::new().fg(Color::White).bg(Color::DarkGray)) - .scroll_padding(1); - let mut state = ListState::default().with_selected(Some(self.current_step)); + .scroll_padding(scroll_padding); + let mut state = + ListState::default().with_selected(Some(self.current_step.saturating_sub(start))); f.render_stateful_widget(list, area, &mut state); } @@ -406,33 +565,7 @@ impl TUIContext<'_> { .skip(self.draw_memory.current_stack_startline) .map(|(i, stack_item)| { let param = params.iter().find(|param| param.index == i); - let mut spans = Vec::with_capacity(1 + 32 * 2 + 3); - - // Stack index. - spans.push(Span::styled( - format!("{i:0min_len$}| "), - Style::new().fg(Color::White), - )); - - // Item hex bytes. - hex_bytes_spans(&stack_item.to_be_bytes::<32>(), &mut spans, |_, _| { - if param.is_some() { - Style::new().fg(Color::Cyan) - } else { - Style::new().fg(Color::White) - } - }); - - if self.stack_labels - && let Some(param) = param - { - spans.push(Span::raw("| ")); - spans.push(Span::raw(param.name)); - } - - spans.push(Span::raw("\n")); - - Line::from(spans) + stack_item_line(i, min_len, stack_item, param, self.stack_labels) }) .collect() }) @@ -444,11 +577,117 @@ impl TUIContext<'_> { f.render_widget(paragraph, area); } + fn draw_variables(&mut self, f: &mut Frame<'_>, area: Rect) { + let variables = self.scope_variables(); + let storage_access = self.current_storage_access_line(); + let step_notice = self.current_step_notice_text().map(step_notice_line); + let known = variables.iter().filter(|variable| variable.value.is_some()).count(); + let title = variables_title( + variables.len(), + known, + storage_access.is_some(), + step_notice.is_some(), + ); + + let mut text = variables.into_iter().map(scope_variable_line).collect::>(); + if let Some(step_notice) = step_notice { + if !text.is_empty() { + text.push(Line::from("")); + } + text.push(step_notice); + } + if let Some(storage_access) = storage_access { + if !text.is_empty() { + text.push(Line::from("")); + } + text.push(storage_access); + } + + if text.is_empty() { + text.push(Line::from(Span::styled( + "No variables in current scope", + Style::new().add_modifier(Modifier::DIM), + ))); + } + + let block = Block::default().title(title).borders(Borders::ALL); + let paragraph = Paragraph::new(text).block(block).wrap(Wrap { trim: true }); + f.render_widget(paragraph, area); + } + + fn current_storage_access_line(&self) -> Option> { + storage_access_at(self.debug_steps(), self.current_step).map(|access| { + let values = (access.space() == StorageSpace::Persistent) + .then(|| storage_values(&self.storage_accesses(access.space()))); + let next_values = (access.space() == StorageSpace::Persistent) + .then(|| self.next_storage_write_values()); + let label = self.storage_label( + access.space(), + access.slot(), + values.as_ref(), + next_values.as_ref(), + ); + storage_access_line(access, label.as_deref()) + }) + } + + fn draw_data(&mut self, f: &mut Frame<'_>, area: Rect) { + if let Some(space) = self.active_storage() { + self.draw_storage(f, area, space); + } else { + self.draw_buffer(f, area); + } + } + + fn draw_storage(&mut self, f: &mut Frame<'_>, area: Rect, space: StorageSpace) { + let accesses = self.storage_accesses(space); + let values = (space == StorageSpace::Persistent).then(|| storage_values(&accesses)); + let next_values = + (space == StorageSpace::Persistent).then(|| self.next_storage_write_values()); + let current_slot = storage_access_at(self.debug_steps(), self.current_step) + .filter(|access| access.space() == space) + .map(StorageAccess::slot); + self.draw_memory.current_storage_startline = + self.draw_memory.current_storage_startline.min(accesses.len().saturating_sub(1)); + + let index_width = decimal_digits(accesses.len()).max(2); + let mut lines = accesses + .values() + .copied() + .enumerate() + .skip(self.draw_memory.current_storage_startline) + .flat_map(|(index, access)| { + let label = + self.storage_label(space, access.slot(), values.as_ref(), next_values.as_ref()); + storage_slot_lines( + index, + index_width, + access, + label.as_deref(), + current_slot == Some(access.slot()), + ) + }) + .collect::>(); + if lines.is_empty() { + lines.push(Line::from(Span::styled( + format!("No {} accessed in current call", space.noun()), + Style::new().add_modifier(Modifier::DIM), + ))); + } + + let count = accesses.len(); + let suffix = if count == 1 { "slot" } else { "slots" }; + let title = format!("{}: {count} accessed {suffix}", space.label()); + let block = Block::default().title(title).borders(Borders::ALL); + let paragraph = Paragraph::new(lines).block(block).wrap(Wrap { trim: false }); + f.render_widget(paragraph, area); + } + fn draw_buffer(&self, f: &mut Frame<'_>, area: Rect) { let call = self.debug_call(); let step = self.current_step(); - let buf = match self.active_buffer { - BufferKind::Memory => step.memory.as_ref().unwrap().as_ref(), + let buf: &[u8] = match self.active_buffer { + BufferKind::Memory => step.memory.as_ref().map_or(&[], |memory| memory.as_ref()), BufferKind::Calldata => call.calldata.as_ref(), BufferKind::Returndata => step.returndata.as_ref(), }; @@ -520,9 +759,10 @@ impl TUIContext<'_> { let mut end = None; let idx = i * 32 + j; if let (Some(offset), Some(len), Some(color)) = (offset, len, color) { - end = Some(offset + len); - if (offset..offset + len).contains(&idx) { - // [offset, offset + len] is the memory region to be colored. + let offset_end = offset.saturating_add(len); + end = Some(offset_end); + if (offset..offset_end).contains(&idx) { + // [offset, offset + len) is the memory region to be colored. // If a byte at row i and column j in the memory panel // falls in this region, set the color. byte_color = color; @@ -530,7 +770,7 @@ impl TUIContext<'_> { } if let (Some(write_offset), Some(write_size)) = (write_offset, write_size) { // check for overlap with read region - let write_end = write_offset + write_size; + let write_end = write_offset.saturating_add(write_size); if let Some(read_end) = end { let read_start = offset.unwrap(); if (write_offset..write_end).contains(&read_end) { @@ -578,6 +818,573 @@ impl TUIContext<'_> { } } +#[derive(Clone, Debug, PartialEq, Eq)] +struct ScopeVariable { + kind: ScopeVariableKind, + name: String, + value: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ScopeVariableKind { + Parameter, + Return, + Local, +} + +struct ActiveInternalCall<'a> { + trace_node_idx: usize, + entry_step: usize, + end_step: usize, + decoded: &'a DecodedInternalCall, +} + +const PRECOMPILE_NOTICE_PREFIX: &str = "precompile:"; + +impl ScopeVariableKind { + const fn label(self) -> &'static str { + match self { + Self::Parameter => "param", + Self::Return => "return", + Self::Local => "local", + } + } + + const fn color(self) -> Color { + match self { + Self::Parameter => Color::Cyan, + Self::Return => Color::Green, + Self::Local => Color::White, + } + } +} + +impl TUIContext<'_> { + fn scope_variables(&mut self) -> Vec { + let (scope, start) = { + let Ok((source_element, source)) = self.src_map() else { + return Vec::new(); + }; + let start = source_element.offset() as usize; + let end = start.saturating_add(source_element.length() as usize); + let Some(scope) = source.find_debug_scope(start, end) else { + return Vec::new(); + }; + (scope.clone(), start) + }; + + let parameter_values = self.decode_parameter_values(&scope); + let return_values = self.decode_return_values(&scope); + let mut variables = Vec::new(); + + variables.extend(scope.parameters.iter().enumerate().map(|(i, variable)| ScopeVariable { + kind: ScopeVariableKind::Parameter, + name: variable_name(variable, i, "arg"), + value: parameter_values.as_ref().and_then(|values| values.get(i).cloned()), + })); + + variables.extend(scope.returns.iter().enumerate().map(|(i, variable)| ScopeVariable { + kind: ScopeVariableKind::Return, + name: variable_name(variable, i, "ret"), + value: return_values.as_ref().and_then(|values| values.get(i).cloned()), + })); + + variables.extend(scope.visible_locals(start).enumerate().map(|(i, variable)| { + ScopeVariable { + kind: ScopeVariableKind::Local, + name: variable_name(variable, i, "local"), + value: None, + } + })); + + variables + } + + fn decode_parameter_values(&mut self, scope: &DebugSourceScope) -> Option> { + let scope_signature = scope_function_signature(scope); + self.decode_internal_parameter_values(scope) + .or_else(|| decode_external_parameter_values(scope, &self.debug_call().calldata)) + .or_else(|| { + self.debug_call().decoded.as_ref().and_then(|decoded| { + let call_data = decoded.call_data.as_ref()?; + scope_signature + .as_deref() + .is_some_and(|signature| signature == call_data.signature) + .then(|| call_data.args.clone()) + }) + }) + } + + fn decode_return_values(&mut self, scope: &DebugSourceScope) -> Option> { + let current_step = self.absolute_current_step(); + if let Some(values) = self + .active_internal_call() + .and_then(|active| { + (current_step >= active.end_step + && decoded_internal_name_matches(&active.decoded.func_name, scope)) + .then(|| active.decoded.return_data.clone()) + }) + .flatten() + { + return Some(values); + } + + if self.current_step + 1 < self.debug_steps().len() + || !matches!( + self.current_step().status, + Some(InstructionResult::Return | InstructionResult::Stop) + ) + { + return None; + } + + decode_external_return_values( + scope, + &self.debug_call().calldata, + &self.debug_call().returndata, + ) + } + + fn decode_internal_parameter_values( + &mut self, + scope: &DebugSourceScope, + ) -> Option> { + let (args, trace_node_idx, entry_step) = { + let active = self.active_internal_call()?; + if !decoded_internal_name_matches(&active.decoded.func_name, scope) { + return None; + } + + (active.decoded.args.clone(), active.trace_node_idx, active.entry_step) + }; + + if let Some(args) = args { + return Some(args); + } + + let parameters = Parameters::parse(&scope.parameters_src).ok()?; + let node = self.debug_arena().iter().find(|node| { + node.trace_node_idx == trace_node_idx + && entry_step >= node.step_offset + && entry_step < node.step_offset.saturating_add(node.steps.len()) + })?; + let step = node.steps.get(entry_step.checked_sub(node.step_offset)?)?; + decode_step_parameters(¶meters, step, Some(node.calldata.as_ref())) + } + + fn active_internal_call(&mut self) -> Option> { + let current_node_idx = self.draw_memory.inner_call_index; + let trace_node_idx = self.debug_call().trace_node_idx; + let current_step = self.absolute_current_step(); + let location = if let Some(cache) = self.draw_memory.active_internal_call + && cache.matches(current_node_idx, trace_node_idx, current_step) + { + cache.location + } else { + let location = + self.find_active_internal_call(current_node_idx, trace_node_idx, current_step); + self.draw_memory.active_internal_call = Some(ActiveInternalCallCache { + current_node_idx, + trace_node_idx, + absolute_step: current_step, + location, + }); + location + }?; + + self.active_internal_call_at(location) + } + + fn find_active_internal_call( + &self, + current_node_idx: usize, + trace_node_idx: usize, + current_step: usize, + ) -> Option { + let mut active = None; + + for (node_idx, node) in + self.debug_arena().iter().enumerate().take(current_node_idx.saturating_add(1)) + { + if node.trace_node_idx != trace_node_idx { + continue; + } + + for (step_idx, step) in node.steps.iter().enumerate() { + let marker_step = node.step_offset.saturating_add(step_idx); + if marker_step > current_step { + break; + } + + let Some(decoded) = step.decoded.as_deref() else { continue }; + let DecodedTraceStep::InternalCall(_, end_step) = decoded else { continue }; + if current_step <= *end_step { + active = Some(ActiveInternalCallLocation { + trace_node_idx, + marker_node_idx: node_idx, + marker_step_idx: step_idx, + entry_step: marker_step.saturating_add(1), + end_step: *end_step, + }); + } + } + } + + active + } + + fn active_internal_call_at( + &self, + location: ActiveInternalCallLocation, + ) -> Option> { + let step = self + .debug_arena() + .get(location.marker_node_idx)? + .steps + .get(location.marker_step_idx)?; + let DecodedTraceStep::InternalCall(decoded, end_step) = step.decoded.as_deref()? else { + return None; + }; + (*end_step == location.end_step).then_some(ActiveInternalCall { + trace_node_idx: location.trace_node_idx, + entry_step: location.entry_step, + end_step: location.end_step, + decoded, + }) + } + + fn absolute_current_step(&self) -> usize { + self.debug_call().step_offset.saturating_add(self.current_step) + } +} + +fn source_pane_title( + call_kind_text: &str, + source_name: Option<&str>, + step_notice: Option<&str>, +) -> String { + let mut title = call_kind_text.to_string(); + if let Some(step_notice) = step_notice { + write!(title, " | {step_notice}").unwrap(); + } + if let Some(source_name) = source_name { + write!(title, " | {source_name}").unwrap(); + } + title.push(' '); + title +} + +fn variables_title( + total_variables: usize, + known_variables: usize, + has_storage_access: bool, + has_step_notice: bool, +) -> String { + if total_variables == 0 && !has_storage_access && !has_step_notice { + return "Variables".to_string(); + } + + let mut title = format!("Variables: {known_variables}/{total_variables}"); + if has_step_notice { + title.push_str(" | Trace"); + } + if has_storage_access { + title.push_str(" | Storage"); + } + title +} + +fn shortcut_lines() -> Vec> { + let dimmed = Style::new().add_modifier(Modifier::DIM); + vec![ + Line::from(Span::styled( + "[q] quit | [j/k] op | [a/s] jump | [c/C] call | [g/G] start/end | [p] PC | [o] offset", + dimmed, + )), + Line::from(Span::styled( + "[/] search | [:] command | [n/N] repeat | [l] layout | [b] buffer", + dimmed, + )), + Line::from(Span::styled( + "[t] labels | [m] decode | [h] help | [J/K] stack scroll | [ctrl+j/k] data scroll | ['] breakpoint", + dimmed, + )), + ] +} + +const COMMAND_PROMPT_HINT: &str = " Enter: run | Esc: cancel | help: command list"; + +fn command_prompt_line(input: &str, width: u16) -> Line<'static> { + let width = width as usize; + let fixed_width = 2; + let hint_width = text_width(COMMAND_PROMPT_HINT); + let input_width = text_width(input); + let include_hint = fixed_width + input_width + hint_width <= width; + let input_width = if include_hint { + width.saturating_sub(fixed_width + hint_width) + } else { + width.saturating_sub(fixed_width) + }; + + let mut spans = vec![ + Span::styled(":", Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::raw(input_tail(input, input_width)), + Span::styled("█", Style::new().fg(Color::Cyan)), + ]; + if include_hint { + spans.push(Span::styled(COMMAND_PROMPT_HINT, Style::new().add_modifier(Modifier::DIM))); + } + Line::from(spans) +} + +/// Returns the rendered display width of `text` in terminal cells. +fn text_width(text: &str) -> usize { + Span::raw(text).width() +} + +fn input_tail(input: &str, max_width: usize) -> String { + if text_width(input) <= max_width { + return input.to_string(); + } + if max_width == 0 { + return String::new(); + } + if max_width == 1 { + return "<".to_string(); + } + + // Reserve one cell for the leading `<` truncation indicator, then keep the widest + // suffix that fits in the remaining width. + let tail_width = max_width - 1; + let mut start = input.len(); + for (idx, _) in input.char_indices().rev() { + if text_width(&input[idx..]) > tail_width { + break; + } + start = idx; + } + format!("<{}", &input[start..]) +} + +fn step_notice_title(line: &str) -> &'static str { + if line.starts_with(PRECOMPILE_NOTICE_PREFIX) { "precompile call" } else { "decoded step" } +} + +fn step_notice_line(line: &str) -> Line<'static> { + Line::from(Span::styled(line.to_string(), Style::new().fg(Color::Magenta))) +} + +fn scope_variable_line(variable: ScopeVariable) -> Line<'static> { + let color = variable.kind.color(); + let mut spans = Vec::with_capacity(6); + spans.push(Span::styled(variable.kind.label(), Style::new().fg(Color::Gray))); + spans.push(Span::raw(" ")); + spans.push(Span::styled(variable.name, Style::new().fg(color).add_modifier(Modifier::BOLD))); + spans.push(Span::raw(" = ")); + if let Some(value) = variable.value { + spans.push(Span::styled(value, Style::new().fg(color))); + } else { + spans.push(Span::styled("", Style::new().fg(Color::Gray))); + } + Line::from(spans) +} + +fn storage_access_line(access: StorageAccess, label: Option<&str>) -> Line<'static> { + let mut spans = vec![Span::styled(access.describe(), Style::new().fg(Color::Yellow))]; + if let Some(label) = label { + spans.extend([ + Span::raw(" ("), + Span::styled(label.to_string(), Color::Cyan), + Span::raw(")"), + ]); + } + Line::from(spans) +} + +fn storage_slot_lines( + index: usize, + index_width: usize, + access: StorageAccess, + label: Option<&str>, + current: bool, +) -> [Line<'static>; 2] { + let value_style = if current { + Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD) + } else { + Style::new().fg(Color::White) + }; + let prefix_width = index_width + 2; + let mut slot_spans = vec![ + Span::styled(format!("{index:0index_width$}| "), Style::new().fg(Color::Gray)), + Span::styled(access.op(), value_style), + Span::raw(" slot "), + Span::styled(hex_u256(access.slot()), value_style), + ]; + if let Some(label) = label { + slot_spans.extend([ + Span::raw(" ("), + Span::styled(label.to_string(), Style::new().fg(Color::Cyan)), + Span::raw(")"), + ]); + } + [ + Line::from(slot_spans), + Line::from(vec![ + Span::raw(" ".repeat(prefix_width)), + Span::raw("value "), + Span::styled(hex_u256(access.value()), value_style), + ]), + ] +} + +fn variable_name(variable: &DebugVariable, index: usize, fallback_prefix: &str) -> String { + variable + .name + .as_deref() + .filter(|name| !name.is_empty()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| format!("{fallback_prefix}{index}")) +} + +fn decoded_internal_name_matches(decoded_name: &str, scope: &DebugSourceScope) -> bool { + if let Some((contract_name, function_name)) = decoded_name.rsplit_once("::") { + return contract_name == scope.contract_name + && decoded_function_matches(function_name, scope); + } + decoded_function_matches(decoded_name, scope) +} + +fn decoded_function_matches(decoded_name: &str, scope: &DebugSourceScope) -> bool { + if decoded_name == scope.function_name { + return true; + } + scope_function_signature(scope).as_deref().is_some_and(|signature| decoded_name == signature) +} + +fn decode_external_parameter_values( + scope: &DebugSourceScope, + calldata: &[u8], +) -> Option> { + let types = external_scope_parameter_types(scope, calldata)?; + + decode_abi_sequence(&types, &calldata[4..]) +} + +fn decode_external_return_values( + scope: &DebugSourceScope, + calldata: &[u8], + returndata: &[u8], +) -> Option> { + external_scope_parameter_types(scope, calldata)?; + let returns_src = scope.returns_src.as_deref()?; + let returns = Parameters::parse(returns_src).ok()?; + let types = resolved_types(&returns)?; + decode_abi_sequence(&types, returndata) +} + +fn external_scope_parameter_types( + scope: &DebugSourceScope, + calldata: &[u8], +) -> Option> { + if calldata.len() < 4 { + return None; + } + + let parameters = Parameters::parse(&scope.parameters_src).ok()?; + let types = resolved_types(¶meters)?; + let selector = function_selector(&scope.function_name, &types); + if calldata.get(..4)? != selector.as_slice() { + return None; + } + + Some(types) +} + +fn resolved_types(parameters: &Parameters<'_>) -> Option> { + parameters.params.iter().map(|param| param.resolve().ok()).collect() +} + +fn scope_function_signature(scope: &DebugSourceScope) -> Option { + let parameters = Parameters::parse(&scope.parameters_src).ok()?; + let types = resolved_types(¶meters)?; + Some(function_signature(&scope.function_name, &types)) +} + +fn function_selector(function_name: &str, types: &[DynSolType]) -> [u8; 4] { + let signature = function_signature(function_name, types); + keccak256(signature.as_bytes())[..4].try_into().unwrap() +} + +fn decode_abi_sequence(types: &[DynSolType], data: &[u8]) -> Option> { + if types.is_empty() { + return Some(Vec::new()); + } + + let value = DynSolType::Tuple(types.to_vec()).abi_decode_sequence(data).ok()?; + let values = value.as_fixed_seq()?; + Some(values.iter().map(format_token).collect()) +} + +fn op_list_title( + address: &Address, + pc: usize, + gas_remaining: u64, + call_gas_used: u64, + gas_refund_counter: u64, + stats: Option, +) -> String { + let address = full_checksum_address(address); + let mut title = format!( + "address: {address} | pc: 0x{pc:x} ({pc}) | gasLeft: {gas_remaining} | \ + callGasUsed: {call_gas_used} | gasRefund: {gas_refund_counter}" + ); + + if let Some(stats) = stats { + write!( + title, + " | sessionTraceGasUsed: {} | sessionSubcalls: {}", + stats.session_trace_gas_used, stats.session_subcalls + ) + .unwrap(); + } + + title +} + +fn full_checksum_address(address: &Address) -> String { + address.to_string() +} + +fn stack_item_line( + i: usize, + min_len: usize, + stack_item: &U256, + param: Option<&OpcodeParam>, + stack_labels: bool, +) -> Line<'static> { + let value_style = + if param.is_some() { Style::new().fg(Color::Cyan) } else { Style::new().fg(Color::White) }; + let mut spans = Vec::with_capacity(1 + 32 * 2 + 5); + + // Stack index. + spans.push(Span::styled(format!("{i:0min_len$}| "), Style::new().fg(Color::White))); + + // Item hex bytes. + hex_bytes_spans(&stack_item.to_be_bytes::<32>(), &mut spans, |_, _| value_style); + + spans.push(Span::raw(" | ")); + spans.push(Span::styled(stack_item.to_string(), value_style)); + + if stack_labels && let Some(param) = param { + spans.push(Span::raw(" | ")); + spans.push(Span::raw(param.name)); + } + + spans.push(Span::raw("\n")); + + Line::from(spans) +} + /// Wrapper around a list of [`Line`]s that prepends the line number on each new line. struct SourceLines<'a> { lines: Vec>, @@ -638,6 +1445,917 @@ fn hex_digits(n: usize) -> usize { #[cfg(test)] mod tests { + use super::{Prompt, TUIContext}; + use crate::{ + DebugNode, DebuggerLayout, + debugger::{DebuggerContext, DebuggerStats}, + op::OpcodeParam, + }; + use alloy_dyn_abi::parser::Parameters; + use alloy_primitives::{Address, Bytes, U256, address}; + use foundry_evm_core::{Breakpoints, buffer::BufferKind}; + use foundry_evm_traces::{ + CallKind, CallTraceStep, DecodedCallData, DecodedCallTrace, DecodedInternalCall, + DecodedTraceStep, StorageChange, StorageChangeReason, + debug::{ContractSources, DebugSourceScope, DebugVariable}, + }; + use ratatui::{ + Terminal, + backend::TestBackend, + layout::Rect, + style::{Color, Style}, + text::Line, + }; + use revm::{bytecode::opcode::OpCode, interpreter::InstructionResult}; + + fn line_text(line: &Line<'_>) -> String { + line.spans.iter().map(|span| span.content.as_ref()).collect() + } + + fn scope(function_name: &str, parameters_src: &str) -> DebugSourceScope { + DebugSourceScope { + contract_name: "DebugMe".to_string(), + function_name: function_name.to_string(), + range: 0..100, + body_range: 10..90, + parameters_src: parameters_src.to_string(), + returns_src: None, + parameters: Vec::new(), + returns: Vec::new(), + locals: Vec::new(), + } + } + + fn scope_with_returns( + function_name: &str, + parameters_src: &str, + returns_src: &str, + ) -> DebugSourceScope { + DebugSourceScope { + returns_src: Some(returns_src.to_string()), + ..scope(function_name, parameters_src) + } + } + + fn trace_step(stack: Vec) -> CallTraceStep { + CallTraceStep { + pc: 0, + op: OpCode::STOP, + stack: Some(stack.into_boxed_slice()), + push_stack: None, + memory: None, + returndata: Bytes::new(), + gas_remaining: 0, + gas_refund_counter: 0, + gas_used: 0, + gas_cost: 0, + state_gas_cost: None, + state_gas_reservoir: None, + state_gas_spent: 0, + storage_change: None, + status: Some(InstructionResult::Stop), + immediate_bytes: None, + decoded: None, + } + } + + fn internal_call_step(end_step: usize, return_data: Vec) -> CallTraceStep { + internal_call_step_named("DebugMe::foo", end_step, Some(Vec::new()), Some(return_data)) + } + + fn internal_call_step_named( + func_name: &str, + end_step: usize, + args: Option>, + return_data: Option>, + ) -> CallTraceStep { + let mut step = trace_step(Vec::new()); + step.decoded = Some(Box::new(DecodedTraceStep::InternalCall( + DecodedInternalCall { func_name: func_name.to_string(), args, return_data }, + end_step, + ))); + step + } + + fn internal_call_step_without_args(end_step: usize) -> CallTraceStep { + let mut step = trace_step(Vec::new()); + step.decoded = Some(Box::new(DecodedTraceStep::InternalCall( + DecodedInternalCall { + func_name: "DebugMe::foo".to_string(), + args: None, + return_data: None, + }, + end_step, + ))); + step + } + + fn debug_node( + trace_node_idx: usize, + step_offset: usize, + steps: Vec, + ) -> DebugNode { + let mut node = DebugNode::new(Address::ZERO, CallKind::Call, steps, Bytes::new(), 0, None); + node.trace_node_idx = trace_node_idx; + node.step_offset = step_offset; + node + } + + fn context_with_arena(arena: Vec) -> DebuggerContext { + DebuggerContext { + debug_arena: arena, + stats: None, + identified_contracts: Default::default(), + slot_identifiers: None, + contracts_sources: ContractSources::default(), + breakpoints: Breakpoints::default(), + layout: Default::default(), + } + } + + fn abi_word(value: U256) -> [u8; 32] { + value.to_be_bytes::<32>() + } + + #[test] + fn draw_buffer_handles_missing_memory_snapshot() { + let mut context = context_with_arena(vec![debug_node(0, 0, vec![trace_step(Vec::new())])]); + let tui = TUIContext::new(&mut context); + let backend = TestBackend::new(80, 4); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal.draw(|f| tui.draw_buffer(f, Rect::new(0, 0, 80, 4))).unwrap(); + + let screen = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect::(); + assert!(screen.contains("Memory (max expansion: 0 bytes)")); + } + + #[test] + fn draw_buffer_does_not_overflow_on_saturated_stack_offsets() { + // A stack of all-1s values (as produced by e.g. `not(0)` in inline assembly) saturates to + // `usize::MAX` when read via `get_buffer_accesses`. Regression test for the unfixed half + // of #6472: `offset + len` and `write_offset + write_size` used raw addition on values + // that can legitimately be `usize::MAX`, which panics with "attempt to add with overflow" + // in a debug build once a non-empty buffer reaches the affected closure. + let stack = vec![U256::MAX, U256::MAX]; + let mut step = trace_step(stack); + step.op = OpCode::LOG1; + + let mut node = debug_node(0, 0, vec![step]); + node.calldata = Bytes::from(vec![0u8; 64]); + let mut context = context_with_arena(vec![node]); + let mut tui = TUIContext::new(&mut context); + // Route the non-empty buffer through Calldata (a publicly constructible `Bytes`) rather + // than Memory (`RecordedMemory`'s content constructor is crate-private to + // revm-inspectors) - the offset/len arithmetic under test doesn't depend on which pane is + // active, only on the chunk loop actually running over a non-empty buffer. + tui.active_buffer = BufferKind::Calldata; + + let backend = TestBackend::new(80, 6); + let mut terminal = Terminal::new(backend).unwrap(); + + // Must not panic with "attempt to add with overflow". + terminal.draw(|f| tui.draw_buffer(f, Rect::new(0, 0, 80, 6))).unwrap(); + } + + #[test] + fn opcode_list_draws_visible_window() { + let steps = (0..64) + .chain([0x100]) + .map(|pc| { + let mut step = trace_step(Vec::new()); + step.pc = pc; + step.op = OpCode::ADD; + step + }) + .collect(); + let mut context = context_with_arena(vec![debug_node(0, 0, steps)]); + let mut tui = TUIContext::new(&mut context); + tui.init(); + tui.current_step = 50; + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal.draw(|f| tui.draw_op_list(f, Rect::new(0, 0, 80, 20))).unwrap(); + + let lines = terminal + .backend() + .buffer() + .content() + .chunks(80) + .map(|cells| cells.iter().map(|cell| cell.symbol()).collect::()) + .collect::>(); + assert!(lines[1].contains("022|ADD")); + assert!(lines[17].contains("▶032|ADD")); + assert!(lines[18].contains("033|ADD")); + + let backend = TestBackend::new(80, 3); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|f| tui.draw_op_list(f, Rect::new(0, 0, 80, 3))).unwrap(); + let screen = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect::(); + assert!(screen.contains("▶032|ADD")); + } + + #[test] + fn storage_explorer_draws_accessed_slots() { + let mut first = trace_step(Vec::new()); + first.storage_change = Some(Box::new(StorageChange { + key: U256::ZERO, + value: U256::from(42), + had_value: None, + reason: StorageChangeReason::SSTORE, + })); + let mut second = trace_step(Vec::new()); + second.storage_change = Some(Box::new(StorageChange { + key: U256::ONE, + value: U256::from(0xbeef), + had_value: None, + reason: StorageChangeReason::SSTORE, + })); + let mut latest = trace_step(Vec::new()); + latest.storage_change = Some(Box::new(StorageChange { + key: U256::ZERO, + value: U256::from(43), + had_value: Some(U256::from(42)), + reason: StorageChangeReason::SSTORE, + })); + let mut context = context_with_arena(vec![debug_node(0, 0, vec![first, second, latest])]); + let mut tui = TUIContext::new(&mut context); + tui.current_step = 2; + let backend = TestBackend::new(100, 6); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal + .draw(|f| tui.draw_storage(f, Rect::new(0, 0, 100, 6), super::StorageSpace::Persistent)) + .unwrap(); + + let screen = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect::(); + assert!(screen.contains("Storage: 2 accessed slots")); + assert!(screen.contains("SSTORE slot 0x0")); + assert!(screen.contains("value 0x2b")); + assert!(screen.contains("SSTORE slot 0x1")); + assert!(screen.contains("value 0xbeef")); + } + + #[test] + fn hidden_source_pane_omits_source_panel() { + let mut context = context_with_arena(vec![debug_node(0, 0, vec![trace_step(Vec::new())])]); + context.layout = DebuggerLayout::Horizontal; + let mut tui = TUIContext::new(&mut context); + tui.init(); + tui.show_source = false; + let backend = TestBackend::new(220, 30); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal.draw(|f| tui.draw_layout(f)).unwrap(); + + let screen = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect::(); + assert!(!screen.contains("Contract call")); + assert!(screen.contains("Memory (max expansion: 0 bytes)")); + } + + #[test] + fn hidden_opcodes_pane_omits_opcode_panel() { + let mut context = context_with_arena(vec![debug_node(0, 0, vec![trace_step(Vec::new())])]); + context.layout = DebuggerLayout::Horizontal; + let mut tui = TUIContext::new(&mut context); + tui.init(); + tui.show_opcodes = false; + let backend = TestBackend::new(220, 30); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal.draw(|f| tui.draw_layout(f)).unwrap(); + + let screen = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect::(); + assert!(!screen.contains("address:")); + assert!(screen.contains("Contract call")); + assert!(screen.contains("Memory (max expansion: 0 bytes)")); + } + + #[test] + fn hidden_variables_pane_omits_variables_panel() { + let mut context = context_with_arena(vec![debug_node(0, 0, vec![trace_step(Vec::new())])]); + context.layout = DebuggerLayout::Horizontal; + let mut tui = TUIContext::new(&mut context); + tui.init(); + tui.show_variables = false; + let backend = TestBackend::new(220, 30); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal.draw(|f| tui.draw_layout(f)).unwrap(); + + let screen = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect::(); + assert!(!screen.contains("Variables")); + assert!(screen.contains("Stack")); + assert!(screen.contains("Memory (max expansion: 0 bytes)")); + } + + #[test] + fn hidden_stack_pane_omits_stack_panel() { + let mut context = context_with_arena(vec![debug_node(0, 0, vec![trace_step(Vec::new())])]); + context.layout = DebuggerLayout::Horizontal; + let mut tui = TUIContext::new(&mut context); + tui.init(); + tui.show_stack = false; + let backend = TestBackend::new(220, 30); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal.draw(|f| tui.draw_layout(f)).unwrap(); + + let screen = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect::(); + assert!(!screen.contains("Stack:")); + assert!(screen.contains("Variables")); + assert!(screen.contains("Memory (max expansion: 0 bytes)")); + } + + #[test] + fn hidden_data_pane_omits_data_panel() { + let mut context = context_with_arena(vec![debug_node(0, 0, vec![trace_step(Vec::new())])]); + context.layout = DebuggerLayout::Horizontal; + let mut tui = TUIContext::new(&mut context); + tui.init(); + tui.show_opcodes = false; + tui.show_variables = false; + tui.show_stack = false; + tui.show_data = false; + let backend = TestBackend::new(80, 30); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal.draw(|f| tui.draw_layout(f)).unwrap(); + + let screen = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect::(); + assert!(!screen.contains("Memory (max expansion: 0 bytes)")); + assert!(screen.contains("Contract call")); + } + + #[test] + fn command_prompt_draws_in_bordered_block() { + let mut context = context_with_arena(vec![debug_node(0, 0, vec![trace_step(Vec::new())])]); + let mut tui = TUIContext::new(&mut context); + tui.prompt = Some(Prompt::Command("pc 0".to_string())); + let backend = TestBackend::new(120, 6); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal.draw(|f| tui.draw_footer(f, Rect::new(0, 0, 120, 6))).unwrap(); + + let screen = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect::(); + assert!(screen.contains("Command")); + assert!(screen.contains(":pc 0")); + assert!(screen.contains("Enter: run")); + assert!(screen.contains("[:] command")); + } + + #[test] + fn command_prompt_line_clips_long_input_to_tail() { + let input = "continue 0123456789abcdef"; + let line = super::command_prompt_line(input, 12); + let text = line_text(&line); + + assert!(line.width() <= 12); + assert_eq!(text, ":<789abcdef█"); + assert!(!text.contains("Enter: run")); + } + + #[test] + fn command_prompt_line_clips_wide_unicode_to_width() { + // Full-width characters render two cells each, so clipping must respect display + // width rather than character count. + let input = "界界界界界界界界"; + let line = super::command_prompt_line(input, 8); + + assert!(line.width() <= 8); + } + + #[test] + fn decode_external_parameter_values_decodes_named_params() { + let scope = scope("foo", "(uint256 amount, bool ok)"); + let parameters = Parameters::parse(&scope.parameters_src).unwrap(); + let types = super::resolved_types(¶meters).unwrap(); + let mut calldata = Vec::new(); + calldata.extend_from_slice(&super::function_selector(&scope.function_name, &types)); + calldata.extend_from_slice(&abi_word(U256::from(42))); + calldata.extend_from_slice(&abi_word(U256::ONE)); + + let values = super::decode_external_parameter_values(&scope, &calldata).unwrap(); + + assert_eq!(values, ["42", "true"]); + } + + #[test] + fn decode_external_parameter_values_rejects_selector_mismatch() { + let scope = scope("foo", "(uint256 amount)"); + let parameters = Parameters::parse("(uint256 amount)").unwrap(); + let types = super::resolved_types(¶meters).unwrap(); + let mut calldata = Vec::new(); + calldata.extend_from_slice(&super::function_selector("bar", &types)); + calldata.extend_from_slice(&abi_word(U256::from(42))); + + assert_eq!(super::decode_external_parameter_values(&scope, &calldata), None); + } + + #[test] + fn decode_external_return_values_decodes_named_returns() { + let scope = scope_with_returns("foo", "(uint256 amount)", "(uint256 total, bool ok)"); + let parameters = Parameters::parse(&scope.parameters_src).unwrap(); + let types = super::resolved_types(¶meters).unwrap(); + let mut calldata = Vec::new(); + calldata.extend_from_slice(&super::function_selector(&scope.function_name, &types)); + calldata.extend_from_slice(&abi_word(U256::from(42))); + let mut returndata = Vec::new(); + returndata.extend_from_slice(&abi_word(U256::from(99))); + returndata.extend_from_slice(&abi_word(U256::ONE)); + + let values = super::decode_external_return_values(&scope, &calldata, &returndata).unwrap(); + + assert_eq!(values, ["99", "true"]); + } + + #[test] + fn decode_return_values_uses_external_output_at_frame_end() { + let scope = scope_with_returns("foo", "()", "(uint256 total)"); + let parameters = Parameters::parse(&scope.parameters_src).unwrap(); + let types = super::resolved_types(¶meters).unwrap(); + let mut calldata = Vec::new(); + calldata.extend_from_slice(&super::function_selector(&scope.function_name, &types)); + let mut node = debug_node( + 0, + 0, + vec![trace_step(Vec::new()), { + let mut step = trace_step(Vec::new()); + step.op = OpCode::RETURN; + step.status = Some(InstructionResult::Return); + step + }], + ); + node.calldata = Bytes::from(calldata); + node.returndata = abi_word(U256::from(123)).into(); + let mut context = context_with_arena(vec![node]); + let mut tui = TUIContext::new(&mut context); + tui.current_step = 1; + + assert_eq!(tui.decode_return_values(&scope), Some(vec!["123".to_string()])); + } + + #[test] + fn decode_return_values_waits_for_external_frame_end() { + let scope = scope_with_returns("foo", "()", "(uint256 total)"); + let parameters = Parameters::parse(&scope.parameters_src).unwrap(); + let types = super::resolved_types(¶meters).unwrap(); + let mut calldata = Vec::new(); + calldata.extend_from_slice(&super::function_selector(&scope.function_name, &types)); + let mut node = debug_node(0, 0, vec![trace_step(Vec::new()), trace_step(Vec::new())]); + node.calldata = Bytes::from(calldata); + node.returndata = abi_word(U256::from(123)).into(); + let mut context = context_with_arena(vec![node]); + let mut tui = TUIContext::new(&mut context); + + assert_eq!(tui.decode_return_values(&scope), None); + } + + #[test] + fn scope_function_signature_includes_resolved_parameter_types() { + let scope = scope("foo", "(uint256 amount, bool ok)"); + + assert_eq!(super::scope_function_signature(&scope).as_deref(), Some("foo(uint256,bool)")); + } + + #[test] + fn decode_parameter_values_rejects_decoded_call_data_for_wrong_overload() { + let mut node = debug_node(0, 0, vec![trace_step(Vec::new())]); + node.decoded = Some(Box::new(DecodedCallTrace { + call_data: Some(DecodedCallData { + signature: "foo(address)".to_string(), + args: vec!["0x000000000000000000000000000000000000002a".to_string()], + }), + ..Default::default() + })); + let mut context = context_with_arena(vec![node]); + let mut tui = TUIContext::new(&mut context); + + assert_eq!(tui.decode_parameter_values(&scope("foo", "(uint256 amount)")), None); + } + + #[test] + fn decode_step_parameters_reads_static_values_from_stack() { + let step = trace_step(vec![U256::from(42), U256::ONE]); + let parameters = Parameters::parse("(uint256 amount, bool ok)").unwrap(); + let values = super::decode_step_parameters(¶meters, &step, None).unwrap(); + + assert_eq!(values, ["42", "true"]); + } + + #[test] + fn decode_internal_parameter_values_uses_absolute_entry_step() { + let mut context = context_with_arena(vec![ + debug_node(0, 0, vec![internal_call_step_without_args(2)]), + debug_node(1, 0, vec![trace_step(Vec::new())]), + debug_node(0, 1, vec![trace_step(vec![U256::from(42)])]), + ]); + let mut tui = TUIContext::new(&mut context); + tui.draw_memory.inner_call_index = 2; + + assert_eq!( + tui.decode_internal_parameter_values(&scope("foo", "(uint256 amount)")), + Some(vec!["42".to_string()]) + ); + } + + #[test] + fn decode_internal_parameter_values_passes_calldata_to_fallback_decoder() { + let digest = U256::from(0x1234); + let offset = 0x44; + let mut calldata = vec![0; offset]; + calldata.extend_from_slice(&[0x11, 0x22, 0x33]); + + let mut entry_node = + debug_node(0, 1, vec![trace_step(vec![digest, U256::from(offset), U256::from(3)])]); + entry_node.calldata = Bytes::from(calldata); + + let mut context = context_with_arena(vec![ + debug_node(0, 0, vec![internal_call_step_without_args(2)]), + debug_node(1, 0, vec![trace_step(Vec::new())]), + entry_node, + ]); + let mut tui = TUIContext::new(&mut context); + tui.draw_memory.inner_call_index = 2; + + assert_eq!( + tui.decode_internal_parameter_values(&scope( + "foo", + "(bytes32 digest, bytes calldata signature)" + )), + Some(vec![ + "0x0000000000000000000000000000000000000000000000000000000000001234".to_string(), + "0x112233".to_string(), + ]) + ); + } + + #[test] + fn decode_internal_parameter_values_accepts_matching_overload_args() { + let mut context = context_with_arena(vec![debug_node( + 0, + 0, + vec![internal_call_step_named( + "DebugMe::foo(uint256)", + 2, + Some(vec!["42".to_string()]), + None, + )], + )]); + let mut tui = TUIContext::new(&mut context); + + assert_eq!( + tui.decode_internal_parameter_values(&scope("foo", "(uint256 amount)")), + Some(vec!["42".to_string()]) + ); + } + + #[test] + fn decode_internal_parameter_values_rejects_wrong_overload_args() { + let mut context = context_with_arena(vec![debug_node( + 0, + 0, + vec![internal_call_step_named( + "DebugMe::foo(address)", + 2, + Some(vec!["0x000000000000000000000000000000000000002a".to_string()]), + None, + )], + )]); + let mut tui = TUIContext::new(&mut context); + + assert_eq!(tui.decode_internal_parameter_values(&scope("foo", "(uint256 amount)")), None); + } + + #[test] + fn decode_return_values_uses_absolute_internal_call_end_step() { + let mut context = context_with_arena(vec![debug_node( + 0, + 3, + vec![internal_call_step(4, vec!["99".to_string()]), trace_step(Vec::new())], + )]); + let mut tui = TUIContext::new(&mut context); + tui.current_step = 1; + + assert_eq!(tui.decode_return_values(&scope("foo", "()")), Some(vec!["99".to_string()])); + } + + #[test] + fn decode_return_values_finds_internal_call_split_by_child_node() { + let mut context = context_with_arena(vec![ + debug_node(0, 0, vec![internal_call_step(2, vec!["7".to_string()])]), + debug_node(1, 0, vec![trace_step(Vec::new())]), + debug_node(0, 2, vec![trace_step(Vec::new())]), + ]); + let mut tui = TUIContext::new(&mut context); + tui.draw_memory.inner_call_index = 2; + + assert_eq!(tui.decode_return_values(&scope("foo", "()")), Some(vec!["7".to_string()])); + } + + #[test] + fn decode_return_values_rejects_wrong_overload() { + let mut context = context_with_arena(vec![debug_node( + 0, + 0, + vec![ + internal_call_step_named( + "DebugMe::foo(address)", + 1, + None, + Some(vec!["99".to_string()]), + ), + trace_step(Vec::new()), + ], + )]); + let mut tui = TUIContext::new(&mut context); + tui.current_step = 1; + + assert_eq!(tui.decode_return_values(&scope("foo", "(uint256 amount)")), None); + } + + #[test] + fn active_internal_call_caches_by_current_node_and_step() { + let mut context = context_with_arena(vec![debug_node( + 0, + 0, + vec![internal_call_step(2, vec!["1".to_string()]), trace_step(Vec::new())], + )]); + let mut tui = TUIContext::new(&mut context); + + assert!(tui.active_internal_call().is_some()); + let cache = tui.draw_memory.active_internal_call; + assert!(cache.and_then(|cache| cache.location).is_some()); + + assert!(tui.active_internal_call().is_some()); + assert_eq!(tui.draw_memory.active_internal_call, cache); + + tui.current_step = 1; + assert!(tui.active_internal_call().is_some()); + assert_ne!(tui.draw_memory.active_internal_call, cache); + } + + #[test] + fn decoded_internal_name_matches_exact_contract_and_function() { + let scope = scope("foo", "()"); + + assert!(super::decoded_internal_name_matches("DebugMe::foo", &scope)); + assert!(!super::decoded_internal_name_matches("DebugMe::barfoo", &scope)); + assert!(!super::decoded_internal_name_matches("Other::foo", &scope)); + } + + #[test] + fn decoded_internal_name_matches_canonical_signature_for_overloads() { + let scope = scope("foo", "(uint256 amount)"); + + assert!(super::decoded_internal_name_matches("DebugMe::foo(uint256)", &scope)); + assert!(!super::decoded_internal_name_matches("DebugMe::foo(address)", &scope)); + assert!(!super::decoded_internal_name_matches("Other::foo(uint256)", &scope)); + } + + #[test] + fn scope_variable_line_marks_unavailable_locals() { + let variable = super::ScopeVariable { + kind: super::ScopeVariableKind::Local, + name: "sum".to_string(), + value: None, + }; + + assert_eq!(line_text(&super::scope_variable_line(variable)), "local sum = "); + } + + #[test] + fn storage_lines_format_sload_and_label() { + let mut step = trace_step(Vec::new()); + step.storage_change = Some(Box::new(StorageChange { + key: U256::ONE, + value: U256::from(42), + had_value: None, + reason: StorageChangeReason::SLOAD, + })); + let steps = [step]; + let access = super::storage_access_at(&steps, 0).unwrap(); + + assert_eq!( + line_text(&super::storage_access_line(access, Some("count"))), + "storage SLOAD slot 0x1 = 0x2a (count)" + ); + assert_eq!( + line_text(&super::storage_slot_lines(0, 2, access, Some("count"), false)[0]), + "00| SLOAD slot 0x1 (count)" + ); + } + + #[test] + fn storage_access_line_formats_sstore_with_previous_value() { + let mut step = trace_step(Vec::new()); + step.storage_change = Some(Box::new(StorageChange { + key: U256::ONE, + value: U256::from(42), + had_value: Some(U256::from(7)), + reason: StorageChangeReason::SSTORE, + })); + let steps = [step]; + let access = super::storage_access_at(&steps, 0).unwrap(); + + assert_eq!( + line_text(&super::storage_access_line(access, None)), + "storage SSTORE slot 0x1: 0x7 -> 0x2a" + ); + } + + #[test] + fn current_storage_access_line_uses_next_stack_snapshot_for_warm_sload() { + let mut step = trace_step(vec![U256::ONE]); + step.op = OpCode::SLOAD; + step.storage_change = None; + let next_step = trace_step(vec![U256::from(42)]); + let mut context = context_with_arena(vec![debug_node(0, 0, vec![step, next_step])]); + let tui = TUIContext::new(&mut context); + + assert_eq!( + line_text(&tui.current_storage_access_line().unwrap()), + "storage SLOAD slot 0x1 = 0x2a" + ); + } + + #[test] + fn current_storage_access_line_uses_next_stack_snapshot_for_tload() { + let mut step = trace_step(vec![U256::ONE]); + step.op = OpCode::TLOAD; + let next_step = trace_step(vec![U256::from(42)]); + let mut context = context_with_arena(vec![debug_node(0, 0, vec![step, next_step])]); + let tui = TUIContext::new(&mut context); + + assert_eq!( + line_text(&tui.current_storage_access_line().unwrap()), + "transient storage TLOAD slot 0x1 = 0x2a" + ); + } + + #[test] + fn variable_name_falls_back_for_unnamed_values() { + let variable = DebugVariable { name: None, declaration: 0..1, scope: 0..2 }; + + assert_eq!(super::variable_name(&variable, 2, "arg"), "arg2"); + } + + #[test] + fn current_step_notice_text_reads_decoded_line_steps() { + let mut step = trace_step(Vec::new()); + step.decoded = Some(Box::new(DecodedTraceStep::Line( + "precompile: PRECOMPILES::sha256(0x68656c6c6f)".to_string(), + ))); + let mut context = context_with_arena(vec![debug_node(0, 0, vec![step])]); + let tui = TUIContext::new(&mut context); + + assert_eq!( + tui.current_step_notice_text(), + Some("precompile: PRECOMPILES::sha256(0x68656c6c6f)") + ); + } + + #[test] + fn source_pane_title_includes_precompile_notice_before_source() { + assert_eq!( + super::source_pane_title( + "Contract call", + Some("test/Precompile.t.sol"), + Some("precompile call") + ), + "Contract call | precompile call | test/Precompile.t.sol " + ); + } + + #[test] + fn variables_title_tracks_decoded_step_notices() { + assert_eq!(super::variables_title(0, 0, false, false), "Variables"); + assert_eq!(super::variables_title(0, 0, false, true), "Variables: 0/0 | Trace"); + assert_eq!(super::variables_title(2, 1, true, true), "Variables: 1/2 | Trace | Storage"); + } + + #[test] + fn step_notice_line_highlights_precompile_clue() { + let line = super::step_notice_line("precompile: PRECOMPILES::sha256(0x68656c6c6f)"); + + assert_eq!(line_text(&line), "precompile: PRECOMPILES::sha256(0x68656c6c6f)"); + assert_eq!(line.spans[0].style, Style::new().fg(Color::Magenta)); + assert_eq!(super::step_notice_title(&line_text(&line)), "precompile call"); + } + + #[test] + fn op_list_title_includes_gas_and_subcall_stats() { + let stats = DebuggerStats { session_trace_gas_used: 789_012, session_subcalls: 3 }; + let address = Address::repeat_byte(0x42); + let title = super::op_list_title(&address, 0x2a, 123_456, 42, 7, Some(stats)); + + assert!(title.contains("pc: 0x2a (42)")); + assert!(title.contains(&format!("address: {}", super::full_checksum_address(&address)))); + assert!(title.contains("gasLeft: 123456")); + assert!(title.contains("sessionTraceGasUsed: 789012")); + assert!(title.contains("sessionSubcalls: 3")); + assert!(title.contains("callGasUsed: 42")); + assert!(title.contains("gasRefund: 7")); + } + + #[test] + fn op_list_title_omits_aggregate_stats_when_unavailable() { + let title = super::op_list_title(&Address::repeat_byte(0x42), 0x2a, 123_456, 42, 7, None); + + assert!(!title.contains("sessionTraceGasUsed")); + assert!(!title.contains("sessionSubcalls")); + } + + #[test] + fn op_list_title_uses_full_checksum_address() { + let address = address!("0xd8da6bf26964af9d7eed9e03e53415d37aa96045"); + let title = super::op_list_title(&address, 0x2a, 123_456, 42, 7, None); + + assert!(title.contains("address: 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045")); + assert!(!title.contains('…')); + } + + #[test] + fn stack_item_line_includes_decimal_preview() { + let line = super::stack_item_line(0, 2, &U256::from(42), None, false); + let text = line_text(&line); + + assert!(text.starts_with("00| ")); + assert!(text.ends_with("2a | 42\n")); + } + + #[test] + fn stack_item_line_keeps_stack_labels_after_decimal_preview() { + let param = OpcodeParam { name: "offset", index: 0 }; + let line = super::stack_item_line(0, 2, &U256::from(16), Some(¶m), true); + + assert!(line_text(&line).ends_with("10 | 16 | offset\n")); + } + + #[test] + fn stack_item_line_highlights_decimal_preview_for_opcode_params() { + let param = OpcodeParam { name: "offset", index: 0 }; + let line = super::stack_item_line(0, 2, &U256::from(16), Some(¶m), false); + let decimal = line.spans.iter().find(|span| span.content.as_ref() == "16").unwrap(); + + assert_eq!(decimal.style, Style::new().fg(Color::Cyan)); + } + #[test] fn decimal_digits() { assert_eq!(super::decimal_digits(0), 1); diff --git a/crates/debugger/src/tui/mod.rs b/crates/debugger/src/tui/mod.rs index 43a49d2348311..643b823faa07a 100644 --- a/crates/debugger/src/tui/mod.rs +++ b/crates/debugger/src/tui/mod.rs @@ -1,30 +1,14 @@ -//! The TUI implementation. +//! The debugger TUI. -use crossterm::{ - event::{self, DisableMouseCapture, EnableMouseCapture, Event}, - execute, - terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, -}; use eyre::Result; -use ratatui::{ - Terminal, - backend::{Backend, CrosstermBackend}, -}; -use std::{ - io, - ops::ControlFlow, - sync::{Arc, mpsc}, - thread, - time::{Duration, Instant}, -}; +use foundry_tui::{TuiFallbackReason, TuiMode, run_app_if_interactive, tui_mode}; mod context; use crate::debugger::DebuggerContext; use context::TUIContext; mod draw; - -type DebuggerTerminal = Terminal>; +mod storage; /// Debugger exit reason. #[derive(Debug)] @@ -33,135 +17,108 @@ pub enum ExitReason { CharExit, } -/// The TUI debugger. +/// The debugger TUI. pub struct TUI<'a> { debugger_context: &'a mut DebuggerContext, } impl<'a> TUI<'a> { /// Creates a new debugger. - pub fn new(debugger_context: &'a mut DebuggerContext) -> Self { + pub const fn new(debugger_context: &'a mut DebuggerContext) -> Self { Self { debugger_context } } /// Starts the debugger TUI. pub fn try_run(&mut self) -> Result { - let backend = CrosstermBackend::new(io::stdout()); - let terminal = Terminal::new(backend)?; - TerminalGuard::with(terminal, |terminal| self.try_run_real(terminal)) + self.run_inner() } #[instrument(target = "debugger", name = "run", skip_all, ret)] - fn try_run_real(&mut self, terminal: &mut DebuggerTerminal) -> Result { - // Create the context. + fn run_inner(&mut self) -> Result { let mut cx = TUIContext::new(self.debugger_context); - cx.init(); - - // Create an event listener in a different thread. - let (tx, rx) = mpsc::channel(); - thread::Builder::new() - .name("event-listener".into()) - .spawn(move || Self::event_listener(tx)) - .expect("failed to spawn thread"); - - // Start the event loop. - loop { - cx.draw(terminal)?; - match cx.handle_event(rx.recv()?) { - ControlFlow::Continue(()) => {} - ControlFlow::Break(reason) => return Ok(reason), - } - } - } - - fn event_listener(tx: mpsc::Sender) { - // This is the recommend tick rate from `ratatui`, based on their examples - let tick_rate = Duration::from_millis(200); - - let mut last_tick = Instant::now(); - loop { - // Poll events since last tick - if last tick is greater than tick_rate, we - // demand immediate availability of the event. This may affect interactivity, - // but I'm not sure as it is hard to test. - if event::poll(tick_rate.saturating_sub(last_tick.elapsed())).unwrap() { - let event = event::read().unwrap(); - if tx.send(event).is_err() { - return; - } - } - - // Force update if time has passed - if last_tick.elapsed() > tick_rate { - last_tick = Instant::now(); + match run_app_if_interactive(&mut cx)? { + Some(exit_reason) => Ok(exit_reason), + None => { + let message = match tui_mode() { + TuiMode::Fallback(reason) => non_interactive_debugger_message(reason), + TuiMode::Interactive => String::from( + "Cannot open the debugger TUI in this environment. Re-run in an \ + interactive terminal.", + ), + }; + eyre::bail!("{message} {}", debugger_dump_hint()); } } } } -// TODO: Update once on 1.82 -#[expect(deprecated)] -type PanicHandler = Box) + 'static + Sync + Send>; +fn non_interactive_debugger_message(reason: TuiFallbackReason) -> String { + format!( + "Cannot open the debugger TUI because {}. Re-run in an interactive terminal.", + reason.as_str() + ) +} -/// Handles terminal state. -#[must_use] -struct TerminalGuard { - terminal: Terminal, - hook: Option>, +const fn debugger_dump_hint() -> &'static str { + "Pass `--dump ` to export debugger steps." } -impl TerminalGuard { - fn with(terminal: Terminal, mut f: impl FnMut(&mut Terminal) -> T) -> T { - let mut guard = Self { terminal, hook: None }; - guard.setup(); - f(&mut guard.terminal) +#[cfg(test)] +mod tests { + use super::{TuiFallbackReason, debugger_dump_hint, non_interactive_debugger_message}; + use crate::{DebugNode, Debugger}; + use std::{env, ffi::OsString}; + + struct EnvVarGuard { + key: &'static str, + previous: Option, } - fn setup(&mut self) { - let previous = Arc::new(std::panic::take_hook()); - self.hook = Some(previous.clone()); - // We need to restore the terminal state before displaying the panic message. - // TODO: Use `std::panic::update_hook` when it's stable - std::panic::set_hook(Box::new(move |info| { - Self::half_restore(&mut std::io::stdout()); - (previous)(info) - })); - - let _ = enable_raw_mode(); - let _ = execute!(*self.terminal.backend_mut(), EnterAlternateScreen, EnableMouseCapture); - let _ = self.terminal.hide_cursor(); - let _ = self.terminal.clear(); + impl EnvVarGuard { + fn set(key: &'static str, value: &str) -> Self { + let previous = env::var_os(key); + unsafe { env::set_var(key, value) }; + Self { key, previous } + } } - fn restore(&mut self) { - if !std::thread::panicking() { - // Drop the current hook to guarantee that `self.hook` is the only reference to it. - let _ = std::panic::take_hook(); - // Restore the previous panic hook. - let prev = self.hook.take().unwrap(); - let prev = match Arc::try_unwrap(prev) { - Ok(prev) => prev, - Err(_) => unreachable!("`self.hook` is not the only reference to the panic hook"), - }; - std::panic::set_hook(prev); - - // NOTE: Our panic handler calls this function, so we only have to call it here if we're - // not panicking. - Self::half_restore(self.terminal.backend_mut()); + impl Drop for EnvVarGuard { + fn drop(&mut self) { + unsafe { + match &self.previous { + Some(value) => env::set_var(self.key, value), + None => env::remove_var(self.key), + } + } } + } - let _ = self.terminal.show_cursor(); + #[test] + fn fallback_message_includes_reason() { + let msg = non_interactive_debugger_message(TuiFallbackReason::Ci); + assert!(msg.contains("running in CI")); + assert!(!msg.contains("--dump ")); } - fn half_restore(w: &mut impl io::Write) { - let _ = disable_raw_mode(); - let _ = execute!(*w, LeaveAlternateScreen, DisableMouseCapture); + #[test] + fn dump_hint_includes_dump_flag() { + assert!(debugger_dump_hint().contains("--dump ")); } -} -impl Drop for TerminalGuard { - #[inline] - fn drop(&mut self) { - self.restore(); + #[test] + fn debugger_tui_falls_back_in_ci_with_dump_hint() { + let _ci = EnvVarGuard::set("CI", "1"); + let mut debugger = Debugger::new( + vec![DebugNode::default()], + Default::default(), + Default::default(), + Default::default(), + ); + + let message = debugger.try_run_tui().unwrap_err().to_string(); + + assert!(message.contains("running in CI")); + assert!(message.contains("--dump ")); } } diff --git a/crates/debugger/src/tui/storage.rs b/crates/debugger/src/tui/storage.rs new file mode 100644 index 0000000000000..999178ec2b243 --- /dev/null +++ b/crates/debugger/src/tui/storage.rs @@ -0,0 +1,228 @@ +//! Storage access helpers for debugger TUI views and commands. + +use crate::DebugNode; +use alloy_primitives::{ + B256, U256, + map::{B256Map, IndexMap}, +}; +use foundry_evm_traces::{CallTraceStep, StorageChangeReason}; +use revm::{bytecode::opcode, interpreter::InstructionResult}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum StorageAccessKind { + Load, + Store, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum StorageSpace { + Persistent, + Transient, +} + +impl StorageSpace { + pub(super) const fn noun(self) -> &'static str { + match self { + Self::Persistent => "storage", + Self::Transient => "transient storage", + } + } + + pub(super) const fn label(self) -> &'static str { + match self { + Self::Persistent => "Storage", + Self::Transient => "Transient storage", + } + } + + pub(super) const fn command(self) -> &'static str { + match self { + Self::Persistent => "storage", + Self::Transient => "transient", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct StorageAccess { + step_index: usize, + pc: usize, + space: StorageSpace, + kind: StorageAccessKind, + slot: U256, + value: U256, + previous: Option, +} + +impl StorageAccess { + pub(super) const fn step_index(self) -> usize { + self.step_index + } + + pub(super) const fn pc(self) -> usize { + self.pc + } + + pub(super) const fn slot(self) -> U256 { + self.slot + } + + pub(super) const fn space(self) -> StorageSpace { + self.space + } + + pub(super) const fn value(self) -> U256 { + self.value + } + + pub(super) const fn op(self) -> &'static str { + match (self.space, self.kind) { + (StorageSpace::Persistent, StorageAccessKind::Load) => "SLOAD", + (StorageSpace::Persistent, StorageAccessKind::Store) => "SSTORE", + (StorageSpace::Transient, StorageAccessKind::Load) => "TLOAD", + (StorageSpace::Transient, StorageAccessKind::Store) => "TSTORE", + } + } + + pub(super) fn describe(self) -> String { + let op = self.op(); + let space = self.space.noun(); + + match (self.kind, self.previous) { + (StorageAccessKind::Store, Some(previous)) => format!( + "{space} {op} slot {}: {} -> {}", + hex_u256(self.slot), + hex_u256(previous), + hex_u256(self.value) + ), + _ => format!("{space} {op} slot {} = {}", hex_u256(self.slot), hex_u256(self.value)), + } + } +} + +pub(super) fn storage_accesses_until( + arena: &[DebugNode], + current_node_index: usize, + current_step_index: usize, + space: StorageSpace, +) -> IndexMap { + let current_node = &arena[current_node_index]; + let current_absolute_step = current_node.step_offset.saturating_add(current_step_index); + let mut accesses = IndexMap::default(); + + for node in arena.iter().filter(|node| node.trace_node_idx == current_node.trace_node_idx) { + for (step_index, _) in node.steps.iter().enumerate() { + if node.step_offset.saturating_add(step_index) > current_absolute_step { + break; + } + if let Some(access) = storage_access_at(&node.steps, step_index) + && access.space() == space + { + accesses.insert(access.slot(), access); + } + } + } + + accesses +} + +pub(super) fn storage_values(accesses: &IndexMap) -> B256Map { + accesses.iter().map(|(slot, access)| (B256::from(*slot), B256::from(access.value()))).collect() +} + +/// Returns the value from the next persistent storage write to each slot in the current trace. +pub(super) fn next_storage_write_values( + arena: &[DebugNode], + current_node_index: usize, + current_step_index: usize, +) -> B256Map { + let current_node = &arena[current_node_index]; + let current_absolute_step = current_node.step_offset.saturating_add(current_step_index); + let mut next_accesses = B256Map::<(usize, B256)>::default(); + + for node in arena.iter().filter(|node| node.trace_node_idx == current_node.trace_node_idx) { + for (step_index, _) in node.steps.iter().enumerate() { + let absolute_step = node.step_offset.saturating_add(step_index); + if absolute_step <= current_absolute_step { + continue; + } + let Some(access) = storage_access_at(&node.steps, step_index).filter(|access| { + access.space() == StorageSpace::Persistent + && access.kind == StorageAccessKind::Store + }) else { + continue; + }; + let slot = B256::from(access.slot()); + let value = B256::from(access.value()); + match next_accesses.get_mut(&slot) { + Some((next_step, next_value)) if absolute_step < *next_step => { + *next_step = absolute_step; + *next_value = value; + } + None => { + next_accesses.insert(slot, (absolute_step, value)); + } + _ => {} + } + } + } + + next_accesses.into_iter().map(|(slot, (_, value))| (slot, value)).collect() +} + +pub(super) fn storage_access_at( + steps: &[CallTraceStep], + step_index: usize, +) -> Option { + let step = steps.get(step_index)?; + if matches!(step.op.get(), opcode::SSTORE | opcode::TSTORE) + && !step.status.is_none_or(InstructionResult::is_ok) + { + return None; + } + + if let Some(change) = step.storage_change.as_deref() { + let kind = match change.reason { + StorageChangeReason::SLOAD => StorageAccessKind::Load, + StorageChangeReason::SSTORE => StorageAccessKind::Store, + }; + return Some(StorageAccess { + step_index, + pc: step.pc, + space: StorageSpace::Persistent, + kind, + slot: change.key, + value: change.value, + previous: change.had_value, + }); + } + + let (space, kind) = match step.op.get() { + opcode::SLOAD => (StorageSpace::Persistent, StorageAccessKind::Load), + opcode::SSTORE => (StorageSpace::Persistent, StorageAccessKind::Store), + opcode::TLOAD => (StorageSpace::Transient, StorageAccessKind::Load), + opcode::TSTORE => (StorageSpace::Transient, StorageAccessKind::Store), + _ => return None, + }; + + if kind == StorageAccessKind::Load { + return Some(StorageAccess { + step_index, + pc: step.pc, + space, + kind, + slot: step.stack.as_deref()?.last().copied()?, + value: steps.get(step_index.checked_add(1)?)?.stack.as_deref()?.last().copied()?, + previous: None, + }); + } + + let mut stack = step.stack.as_deref()?.iter().rev(); + let slot = stack.next().copied()?; + let value = stack.next().copied()?; + Some(StorageAccess { step_index, pc: step.pc, space, kind, slot, value, previous: None }) +} + +pub(super) fn hex_u256(value: U256) -> String { + format!("{value:#x}") +} diff --git a/crates/doc/Cargo.toml b/crates/doc/Cargo.toml index 95e26df179cc4..ffa33e352829b 100644 --- a/crates/doc/Cargo.toml +++ b/crates/doc/Cargo.toml @@ -14,22 +14,24 @@ repository.workspace = true workspace = true [dependencies] -forge-fmt.workspace = true -foundry-common.workspace = true +solar.workspace = true + foundry-compilers.workspace = true +foundry-common.workspace = true foundry-config.workspace = true alloy-primitives.workspace = true - -derive_more.workspace = true eyre.workspace = true -itertools.workspace = true -mdbook = { version = "0.4", default-features = false, features = ["search"] } +markdown.workspace = true +path-slash.workspace = true rayon.workspace = true +serde = { workspace = true, features = ["derive"] } serde_json.workspace = true -serde.workspace = true -solang-parser.workspace = true -thiserror.workspace = true -toml.workspace = true tracing.workspace = true -regex.workspace = true + +[dev-dependencies] +tempfile.workspace = true + +[features] +default = ["optimism"] +optimism = ["foundry-common/optimism"] diff --git a/crates/doc/README.md b/crates/doc/README.md index 0d7bb2d24b733..d830cf408a2eb 100644 --- a/crates/doc/README.md +++ b/crates/doc/README.md @@ -1,25 +1,23 @@ -# Documentation (`doc`) +# forge-doc -Solidity documentation generator. It parses the source code and generates an mdbook -based on the parse tree and [NatSpec comments](https://docs.soliditylang.org/en/v0.8.17/natspec-format.html). +Solidity documentation generator powered by [`solar`](https://github.com/paradigmxyz/solar). -## Architecture +## Overview -The entrypoint for the documentation module is the `DocBuilder`. -The `DocBuilder` generates the mdbook in 3 phases: +`forge-doc` walks the solar HIR (High-level Intermediate Representation) and emits +[vocs](https://vocs.dev/docs)-flavoured MDX pages suitable for publishing as a documentation site. -1. Parse +### Key components -In this phase, builder invokes 2 parsers: [solang parser](https://github.com/hyperledger-labs/solang) and internal `Parser`. The solang parser produces the parse tree based on the source code. Afterwards, the internal parser walks the parse tree by implementing the `Visitor` trait from the `fmt` crate and saves important information about the parsed nodes, doc comments. - -Then, builder takes the output of the internal `Parser` and creates documents with additional information: the path of the original item, display identity, the target path where this document will be written. - -2. Preprocess - -The builder accepts an array of preprocessors which can be applied to documents produced in the `Parse` phase. The preprocessors can rearrange and/or change the array as well as modify the separate documents. - -At the end of this phase, the builder maintains a possibly modified collection of documents. - -3. Write - -At this point, builder has all necessary information to generate documentation for the source code. It takes every document, formats the source file contents and writes/copies additional files that are required for building documentation. +- **`DocBuilder`**: entry point, configures source roots, output directory, and optional + Git/deployment metadata, then calls `build()` to generate the docs. +- **`render`**: AST → MDX conversion with HIR lookups. Handles contracts, functions, structs, + enums, errors, events, and UDVTs, including explicit `@inheritdoc` and implicit NatSpec + inheritance, plus inline `{Link}` rewriting. Depends on HIR for inheritance and cross-reference + resolution. +- **`hir_ext`**: HIR-aware helpers including name-to-page mapping, inheritance links, effective + NatSpec resolution, and inline link replacement. +- **`utils`**: small utility functions, `git_source_url` and `read_deployments`. +- **`vocs`**: vocs site scaffolding, generates `vocs.config.ts`, `vocs.sidebar.ts` + (always regenerated, imported by `vocs.config.ts`), `package.json`, `.gitignore`, and + `src/pages/index.mdx`. diff --git a/crates/doc/src/builder.rs b/crates/doc/src/builder.rs index 0a736e6b46b81..09cab5b8a8928 100644 --- a/crates/doc/src/builder.rs +++ b/crates/doc/src/builder.rs @@ -1,52 +1,63 @@ use crate::{ - AsDoc, BufWriter, Document, ParseItem, ParseSource, Parser, Preprocessor, - document::DocumentContent, helpers::merge_toml_table, + hir_ext, render, + utils::{git_source_url, is_safe_output_path, read_deployments, relative_source_path}, + vocs, }; -use alloy_primitives::map::HashMap; -use forge_fmt::{FormatterConfig, Visitable}; +use eyre::Result; use foundry_compilers::{compilers::solc::SOLC_EXTENSIONS, utils::source_files_iter}; -use foundry_config::{DocConfig, filter::expand_globs}; -use itertools::Itertools; -use mdbook::MDBook; +use foundry_config::{ + DocConfig, + filter::{expand_globs, is_ignored_path}, +}; use rayon::prelude::*; +use solar::{config::CompilerStage, sema::Compiler}; use std::{ - cmp::Ordering, + collections::HashSet, fs, path::{Path, PathBuf}, + time::{Duration, Instant}, }; -use toml::value; -/// Build Solidity documentation for a project from natspec comments. -/// The builder parses the source files using [Parser], -/// then formats and writes the elements as the output. +/// Summary stats produced by [`DocBuilder::build`], surfaced to the user as +/// progress feedback by `forge doc`. +#[derive(Debug, Default, Clone)] +pub struct BuildStats { + /// Number of Solidity sources considered for rendering. + pub sources: usize, + /// Number of MDX pages written to disk. + pub pages: usize, + /// Total time spent generating MDX pages and pruning stale ones. + pub render_elapsed: Duration, + /// Total time spent writing the vocs site scaffold. + pub site_elapsed: Duration, +} + +/// Build Solidity documentation for a project from natspec comments using [`solar`]. #[derive(Debug)] pub struct DocBuilder { - /// The project root + /// Project root. pub root: PathBuf, /// Path to Solidity source files. pub sources: PathBuf, /// Paths to external libraries. pub libraries: Vec, - /// Flag whether to build mdbook. - pub should_build: bool, + /// Whether to also document files coming from external libraries. + pub include_libraries: bool, + /// Optional Git commit, tag, or branch used when building Git Source links. + pub commit: Option, + /// Optional current branch name; used as the `` segment of vocs + /// editLink URLs (which require an actual branch, not a commit/`HEAD`). + pub branch: Option, + /// Optional path to the deployments directory (relative to `root`). + /// `Some(None)` enables the preprocessor with the default `deployments` + /// path; `Some(Some(p))` overrides; `None` disables it entirely. + pub deployments: Option>, /// Documentation configuration. pub config: DocConfig, - /// The array of preprocessors to apply. - pub preprocessors: Vec>, - /// The formatter config. - pub fmt: FormatterConfig, - /// Whether to include libraries to the output. - pub include_libraries: bool, } -// TODO: consider using `tfio` impl DocBuilder { - pub(crate) const SRC: &'static str = "src"; - const SOL_EXT: &'static str = "sol"; - const README: &'static str = "README.md"; - const SUMMARY: &'static str = "SUMMARY.md"; - - /// Create new instance of builder. + /// Create a new builder. pub fn new( root: PathBuf, sources: PathBuf, @@ -58,410 +69,274 @@ impl DocBuilder { sources, libraries, include_libraries, - should_build: false, + commit: None, + branch: None, + deployments: None, config: DocConfig::default(), - preprocessors: Default::default(), - fmt: Default::default(), } } - /// Set `should_build` flag on the builder - pub fn with_should_build(mut self, should_build: bool) -> Self { - self.should_build = should_build; - self - } - - /// Set config on the builder. - pub fn with_config(mut self, config: DocConfig) -> Self { - self.config = config; - self - } - - /// Set formatter config on the builder. - pub fn with_fmt(mut self, fmt: FormatterConfig) -> Self { - self.fmt = fmt; - self - } - - /// Set preprocessors on the builder. - pub fn with_preprocessor(mut self, preprocessor: P) -> Self { - self.preprocessors.push(Box::new(preprocessor) as Box); - self - } - - /// Get the output directory - pub fn out_dir(&self) -> PathBuf { - self.root.join(&self.config.out) + /// Resolve the absolute output directory. + fn out_dir(&self) -> PathBuf { + if self.config.out.is_absolute() { + self.config.out.clone() + } else { + self.root.join(&self.config.out) + } } - /// Parse the sources and build the documentation. - pub fn build(self) -> eyre::Result<()> { - // Expand ignore globs - let ignored = expand_globs(&self.root, self.config.ignore.iter())?; + /// Run the documentation pipeline. + pub fn build(self, compiler: &mut Compiler) -> Result { + let out = self.out_dir(); + let pages_dir = out.join("src").join("pages"); + let render_started = Instant::now(); - // Collect and parse source files - let sources = source_files_iter(&self.sources, SOLC_EXTENSIONS) - .filter(|file| !ignored.contains(file)) - .collect::>(); + let ignored = expand_globs(&self.root, self.config.ignore.iter()).unwrap_or_else(|e| { + warn!("doc.ignore: failed to expand globs: {e}"); + Default::default() + }); - if sources.is_empty() { - sh_println!("No sources detected at {}", self.sources.display())?; - return Ok(()); + let mut sources: Vec<(PathBuf, bool)> = source_files_iter(&self.sources, SOLC_EXTENSIONS) + .filter(|p| !is_ignored_path(p, &ignored, &self.root)) + .map(|p| (p, false)) + .collect(); + + if self.include_libraries { + for lib_dir in &self.libraries { + let lib_sources = source_files_iter(lib_dir, SOLC_EXTENSIONS) + .filter(|p| !is_ignored_path(p, &ignored, &self.root)) + .map(|p| (p, true)); + sources.extend(lib_sources); + } } - let library_sources = self - .libraries - .iter() - .flat_map(|lib| source_files_iter(lib, SOLC_EXTENSIONS)) - .collect::>(); - - let combined_sources = sources - .iter() - .map(|path| (path, false)) - .chain(library_sources.iter().map(|path| (path, true))) - .collect::>(); - - let documents = combined_sources - .par_iter() - .enumerate() - .map(|(i, (path, from_library))| { - let path = *path; - let from_library = *from_library; + sources.sort_by(|(a, _), (b, _)| a.cmp(b)); + let sources_count = sources.len(); - // Read and parse source file - let source = fs::read_to_string(path)?; + let repo = self.config.repository.as_deref(); + let commit = self.commit.as_deref(); + let deployments_cfg = &self.deployments; + let root = &self.root; - let (mut source_unit, comments) = match solang_parser::parse(&source, i) { - Ok(res) => res, - Err(err) => { - if from_library { - // Ignore failures for library files - return Ok(Vec::new()); - } else { - return Err(eyre::eyre!( - "Failed to parse Solidity code for {}\nDebug info: {:?}", - path.display(), - err - )); - } - } - }; - - // Visit the parse tree - let mut doc = Parser::new(comments, source).with_fmt(self.fmt.clone()); - source_unit - .visit(&mut doc) - .map_err(|err| eyre::eyre!("Failed to parse source: {err}"))?; + let results = compiler.enter_mut(|compiler| -> Result> { + if compiler.gcx().stage() < Some(CompilerStage::Lowering) + && compiler.lower_asts().is_err() + { + // Diagnostics are already emitted via the solar session. + eyre::bail!("forge doc: HIR lowering failed; see diagnostics above"); + } - // Split the parsed items on top-level constants and rest. - let (items, consts): (Vec, Vec) = doc - .items() - .into_iter() - .partition(|item| !matches!(item.source, ParseSource::Variable(_))); + let gcx = compiler.gcx(); - // Attempt to group overloaded top-level functions - let mut remaining = Vec::with_capacity(items.len()); - let mut funcs: HashMap> = HashMap::default(); - for item in items { - if matches!(item.source, ParseSource::Function(_)) { - funcs.entry(item.source.ident()).or_default().push(item); - } else { - // Put the item back - remaining.push(item); - } - } - let (items, overloaded): ( - HashMap>, - HashMap>, - ) = funcs.into_iter().partition(|(_, v)| v.len() == 1); - remaining.extend(items.into_iter().flat_map(|(_, v)| v)); + // Restrict cross-reference resolution to files we'll actually emit pages for. + let allowed_sources: HashSet = sources + .iter() + .map(|(p, _)| if p.is_absolute() { p.clone() } else { root.join(p) }) + .collect(); - // Each regular item will be written into its own file. - let mut files = remaining - .into_iter() - .map(|item| { - let relative_path = path.strip_prefix(&self.root)?.join(item.filename()); - let target_path = self.config.out.join(Self::SRC).join(relative_path); - let ident = item.source.ident(); - Ok(Document::new( - path.clone(), - target_path, - from_library, - self.config.out.clone(), - ) - .with_content(DocumentContent::Single(item), ident)) - }) - .collect::>>()?; + let name_to_page = hir_ext::build_name_to_page(gcx, root, &allowed_sources); - // If top-level constants exist, they will be written to the same file. - if !consts.is_empty() { - let filestem = path.file_stem().and_then(|stem| stem.to_str()); + // Render each source in parallel. + // Record panicked user sources so we can fail after writing successful pages. + let results: Vec = sources + .par_iter() + .map(|(path, from_library)| -> RenderResult { + let abs_path = if path.is_absolute() { path.clone() } else { root.join(path) }; - let filename = { - let mut name = "constants".to_owned(); - if let Some(stem) = filestem { - name.push_str(&format!(".{stem}")); + let Some((_, ast_source)) = gcx.get_ast_source(&abs_path) else { + if !from_library { + warn!("AST source not found for {}", abs_path.display()); } - name.push_str(".md"); - name + return Ok(Vec::new()); }; - let relative_path = path.strip_prefix(&self.root)?.join(filename); - let target_path = self.config.out.join(Self::SRC).join(relative_path); + let Some(ast) = &ast_source.ast else { + if !from_library { + warn!("AST missing for {}", abs_path.display()); + } + return Ok(Vec::new()); + }; + + // For sources outside the project root (e.g. library deps that live under a + // different prefix), synthesise a safe relative path so that + // `pages_dir.join(rel_out_path)` can never escape the docs tree. + let rel_path = relative_source_path(root, &abs_path); - let identity = match filestem { - Some(stem) if stem.to_lowercase().contains("constants") => stem.to_owned(), - Some(stem) => format!("{stem} constants"), - None => "constants".to_owned(), + // Git source link (skipped on library files). + let git_url = if *from_library { + None + } else { + repo.and_then(|r| { + git_source_url(r, commit.unwrap_or("HEAD"), root, &abs_path) + }) }; - files.push( - Document::new( - path.clone(), - target_path, - from_library, - self.config.out.clone(), + // Deployments for this source's contracts. + // The legacy lookup is per file stem, so this source has one deployment list. + let deployments = deployments_cfg.as_ref().map_or_else(Vec::new, |dir| { + read_deployments(root, dir.as_deref(), &rel_path) + }); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + render::source( + ast, + &ast_source.file, + &rel_path, + &abs_path, + gcx, + &name_to_page, + git_url.as_deref(), + &deployments, ) - .with_content(DocumentContent::Constants(consts), identity), - ) - } - - // If overloaded functions exist, they will be written to the same file - if !overloaded.is_empty() { - for (ident, funcs) in overloaded { - let filename = funcs.first().expect("no overloaded functions").filename(); - let relative_path = path.strip_prefix(&self.root)?.join(filename); - let target_path = self.config.out.join(Self::SRC).join(relative_path); - files.push( - Document::new( - path.clone(), - target_path, - from_library, - self.config.out.clone(), - ) - .with_content(DocumentContent::OverloadedFunctions(funcs), ident), - ); + })); + match result { + Ok(pages) => Ok(pages), + Err(_) => { + // Ignore failures from library files; surface user errors. + if *from_library { + debug!("rendering failed for library file {}", abs_path.display()); + Ok(Vec::new()) + } else { + error!("rendering panicked for {}", abs_path.display()); + Err(abs_path) + } + } } - } - - Ok(files) - }) - .collect::>>()?; - - // Flatten results and apply preprocessors to files - let documents = self - .preprocessors - .iter() - .try_fold(documents.into_iter().flatten().collect_vec(), |docs, p| { - p.preprocess(docs) - })?; - - // Sort the results - let documents = documents.into_iter().sorted_by(|doc1, doc2| { - doc1.item_path.display().to_string().cmp(&doc2.item_path.display().to_string()) - }); - - // Write mdbook related files - self.write_mdbook( - documents.filter(|d| !d.from_library || self.include_libraries).collect_vec(), + }) + .collect(); + + Ok(results) + })?; + let all_pages = write_pages(results, &pages_dir)?; + let render_elapsed = render_started.elapsed(); + + // Generate vocs site scaffolding. + let site_started = Instant::now(); + vocs::write_site_files( + &out, + &self.config, + &all_pages, + &self.root, + &self.sources, + self.branch.as_deref(), + self.commit.as_deref(), )?; - - // Build the book if requested - if self.should_build { - MDBook::load(self.out_dir()) - .and_then(|book| book.build()) - .map_err(|err| eyre::eyre!("failed to build book: {err:?}"))?; - } - - Ok(()) + info!("wrote vocs site files to {}", out.display()); + let site_elapsed = site_started.elapsed(); + + Ok(BuildStats { + sources: sources_count, + pages: all_pages.len(), + render_elapsed, + site_elapsed, + }) } +} - fn write_mdbook(&self, documents: Vec) -> eyre::Result<()> { - let out_dir = self.out_dir(); - let out_dir_src = out_dir.join(Self::SRC); - fs::create_dir_all(&out_dir_src)?; - - // Write readme content if any - let homepage_content = { - // Default to the homepage README if it's available. - // If not, use the src README as a fallback. - let homepage_or_src_readme = self - .config - .homepage - .as_ref() - .map(|homepage| self.root.join(homepage)) - .unwrap_or_else(|| self.sources.join(Self::README)); - // Grab the root readme. - let root_readme = self.root.join(Self::README); - - // Check to see if there is a 'homepage' option specified in config. - // If not, fall back to src and root readme files, in that order. - if homepage_or_src_readme.exists() { - fs::read_to_string(homepage_or_src_readme)? - } else if root_readme.exists() { - fs::read_to_string(root_readme)? - } else { - String::new() +/// Successful pages or the path of a user source whose renderer panicked. +type RenderResult = std::result::Result, PathBuf>; + +/// Write successful pages, but do not change ownership or prune if a user source failed. +fn write_pages(results: Vec, pages_dir: &Path) -> Result> { + // Split rendered pages from panicked user sources. + let mut failed: Vec = Vec::new(); + let mut all_rel: Vec = Vec::new(); + for result in results { + let page_list = match result { + Ok(pages) => pages, + Err(path) => { + failed.push(path); + continue; } }; - - let readme_path = out_dir_src.join(Self::README); - fs::write(readme_path, homepage_content)?; - - // Write summary and section readmes - let mut summary = BufWriter::default(); - summary.write_title("Summary")?; - summary.write_link_list_item("Home", Self::README, 0)?; - self.write_summary_section(&mut summary, &documents.iter().collect::>(), None, 0)?; - fs::write(out_dir_src.join(Self::SUMMARY), summary.finish())?; - - // Write solidity syntax highlighting - fs::write(out_dir.join("solidity.min.js"), include_str!("../static/solidity.min.js"))?; - - // Write css files - fs::write(out_dir.join("book.css"), include_str!("../static/book.css"))?; - - // Write book config - fs::write(self.out_dir().join("book.toml"), self.book_config()?)?; - - // Write .gitignore - let gitignore = "book/"; - fs::write(self.out_dir().join(".gitignore"), gitignore)?; - - // Write doc files - for document in documents { - fs::create_dir_all( - document - .target_path - .parent() - .ok_or_else(|| eyre::format_err!("empty target path; noop"))?, - )?; - fs::write(&document.target_path, document.as_doc()?)?; - } - - Ok(()) - } - - fn book_config(&self) -> eyre::Result { - // Read the default book first - let mut book: value::Table = toml::from_str(include_str!("../static/book.toml"))?; - book["book"] - .as_table_mut() - .unwrap() - .insert(String::from("title"), self.config.title.clone().into()); - if let Some(ref repo) = self.config.repository { - // Create the full repository URL. - let git_repo_url = if let Some(path) = &self.config.path { - // If path is specified, append it to the repository URL. - format!("{}/{}", repo.trim_end_matches('/'), path.trim_start_matches('/')) - } else { - // If no path specified, use repository URL as-is. - repo.clone() - }; - - book["output"].as_table_mut().unwrap()["html"] - .as_table_mut() - .unwrap() - .insert(String::from("git-repository-url"), git_repo_url.into()); - } - - // Attempt to find the user provided book path - let book_path = { - if self.config.book.is_file() { - Some(self.config.book.clone()) - } else { - let book_path = self.config.book.join("book.toml"); - if book_path.is_file() { Some(book_path) } else { None } + for (rel_out_path, content) in page_list { + // Reject any output path that would escape the docs tree. + if !is_safe_output_path(&rel_out_path) { + warn!("skipping unsafe output path: {}", rel_out_path.display()); + continue; } - }; - - // Merge two book configs - if let Some(book_path) = book_path { - merge_toml_table(&mut book, toml::from_str(&fs::read_to_string(book_path)?)?); + let abs_out = pages_dir.join(&rel_out_path); + if let Some(parent) = abs_out.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&abs_out, content)?; + info!("wrote {}", abs_out.display()); + all_rel.push(rel_out_path); } - - Ok(toml::to_string_pretty(&book)?) } + all_rel.sort(); - fn write_summary_section( - &self, - summary: &mut BufWriter, - files: &[&Document], - base_path: Option<&Path>, - depth: usize, - ) -> eyre::Result<()> { - if files.is_empty() { - return Ok(()); - } + // Fail the build if any non-library source panicked during render. + if !failed.is_empty() { + let list = + failed.iter().map(|p| format!(" - {}", p.display())).collect::>().join("\n"); + eyre::bail!("forge doc: rendering panicked for {} source file(s):\n{list}", failed.len()); + } + update_manifest(pages_dir, &all_rel)?; + Ok(all_rel) +} - if let Some(path) = base_path { - let title = path.iter().next_back().unwrap().to_string_lossy(); - if depth == 1 { - summary.write_title(&title)?; - } else { - let summary_path = path.join(Self::README); - summary.write_link_list_item( - &format!("❱ {title}"), - &summary_path.display().to_string(), - depth - 1, - )?; - } +/// The manifest is the ownership boundary: only previously generated pages can be pruned. +fn update_manifest(pages_dir: &Path, all_rel: &[PathBuf]) -> Result<()> { + // Prune stale `.mdx` pages using a manifest of previously generated + // files. This covers every generated subtree (including library pages + // outside `src/`), while never touching user-authored pages that were + // never listed in the manifest. + let manifest_path = pages_dir.join(".forge-doc-manifest"); + // Missing or unreadable manifests grant no ownership over existing pages. + let prev_generated = fs::read_to_string(&manifest_path) + .unwrap_or_default() + .lines() + .filter(|line| !line.is_empty()) + .map(PathBuf::from) + .collect::>(); + let new_generated: HashSet = all_rel.iter().cloned().collect(); + for stale in prev_generated.difference(&new_generated) { + if !is_safe_output_path(stale) { + warn!("forge doc: ignoring unsafe manifest entry '{}'", stale.display()); + continue; } - - // Group entries by path depth - let mut grouped = HashMap::new(); - for file in files { - let path = file.item_path.strip_prefix(&self.root)?; - let key = path.iter().take(depth + 1).collect::(); - grouped.entry(key).or_insert_with(Vec::new).push(*file); + let stale_abs = pages_dir.join(stale); + if stale_abs.is_file() { + debug!("pruning stale page {}", stale_abs.display()); + let _ = fs::remove_file(&stale_abs); } - // Sort entries by path depth - let grouped = grouped.into_iter().sorted_by(|(lhs, _), (rhs, _)| { - let lhs_at_end = lhs.extension().map(|ext| ext == Self::SOL_EXT).unwrap_or_default(); - let rhs_at_end = rhs.extension().map(|ext| ext == Self::SOL_EXT).unwrap_or_default(); - if lhs_at_end == rhs_at_end { - lhs.cmp(rhs) - } else if lhs_at_end { - Ordering::Greater - } else { - Ordering::Less - } - }); - - let mut readme = BufWriter::new("\n\n# Contents\n"); - for (path, files) in grouped { - if path.extension().map(|ext| ext == Self::SOL_EXT).unwrap_or_default() { - for file in files { - let ident = &file.identity; - - let summary_path = file - .target_path - .strip_prefix(self.out_dir().strip_prefix(&self.root)?.join(Self::SRC))?; - summary.write_link_list_item( - ident, - &summary_path.display().to_string(), - depth, - )?; + } + // Write new manifest. + { + let mut manifest_lines: Vec = + all_rel.iter().map(|p| p.to_string_lossy().into_owned()).collect(); + manifest_lines.sort(); + fs::create_dir_all(pages_dir)?; + fs::write(&manifest_path, manifest_lines.join("\n") + "\n")?; + } + Ok(()) +} - let readme_path = base_path - .map(|path| summary_path.strip_prefix(path)) - .transpose()? - .unwrap_or(summary_path); - readme.write_link_list_item(ident, &readme_path.display().to_string(), 0)?; - } - } else { - let name = path.iter().next_back().unwrap().to_string_lossy(); - let readme_path = Path::new("/").join(&path).display().to_string(); - readme.write_link_list_item(&name, &readme_path, 0)?; - self.write_summary_section(summary, &files, Some(&path), depth + 1)?; - } - } - if !readme.is_empty() - && let Some(path) = base_path - { - let path = self.out_dir().join(Self::SRC).join(path); - fs::create_dir_all(&path)?; - fs::write(path.join(Self::README), readme.finish())?; - } - Ok(()) +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rendering_failure_keeps_previous_ownership() { + let dir = tempfile::tempdir().unwrap(); + let pages = dir.path(); + let manifest = pages.join(".forge-doc-manifest"); + fs::write(&manifest, "old.mdx\n").unwrap(); + fs::write(pages.join("old.mdx"), "old").unwrap(); + let results = vec![ + Ok(vec![(PathBuf::from("new.mdx"), "new".to_string())]), + Err(PathBuf::from("Broken.sol")), + Ok(vec![(PathBuf::from("after.mdx"), "after".to_string())]), + ]; + + let error = write_pages(results, pages).unwrap_err(); + assert_eq!( + error.to_string(), + "forge doc: rendering panicked for 1 source file(s):\n - Broken.sol" + ); + assert_eq!(fs::read_to_string(pages.join("new.mdx")).unwrap(), "new"); + assert_eq!(fs::read_to_string(pages.join("after.mdx")).unwrap(), "after"); + assert_eq!(fs::read_to_string(pages.join("old.mdx")).unwrap(), "old"); + assert_eq!(fs::read_to_string(manifest).unwrap(), "old.mdx\n"); } } diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs deleted file mode 100644 index cabc230e13b14..0000000000000 --- a/crates/doc/src/document.rs +++ /dev/null @@ -1,195 +0,0 @@ -use crate::{DocBuilder, ParseItem, PreprocessorId, PreprocessorOutput}; -use alloy_primitives::map::HashMap; -use std::{ - path::{Path, PathBuf}, - slice::IterMut, - sync::Mutex, -}; - -/// The wrapper around the [ParseItem] containing additional -/// information the original item and extra context for outputting it. -#[derive(Debug)] -pub struct Document { - /// The underlying parsed items. - pub content: DocumentContent, - /// The original item path. - pub item_path: PathBuf, - /// The original item file content. - pub item_content: String, - /// The target path where the document will be written. - pub target_path: PathBuf, - /// The document display identity. - pub identity: String, - /// The preprocessors results. - context: Mutex>, - /// Whether the document is from external library. - pub from_library: bool, - /// The target directory for the doc output. - pub out_target_dir: PathBuf, -} - -impl Document { - /// Create new instance of [Document]. - pub fn new( - item_path: PathBuf, - target_path: PathBuf, - from_library: bool, - out_target_dir: PathBuf, - ) -> Self { - Self { - item_path, - target_path, - from_library, - item_content: String::default(), - identity: String::default(), - content: DocumentContent::Empty, - out_target_dir, - context: Mutex::new(HashMap::default()), - } - } - - /// Set content and identity on the [Document]. - #[must_use] - pub fn with_content(mut self, content: DocumentContent, identity: String) -> Self { - self.content = content; - self.identity = identity; - self - } - - /// Add a preprocessor result to inner document context. - pub fn add_context(&self, id: PreprocessorId, output: PreprocessorOutput) { - let mut context = self.context.lock().expect("failed to lock context"); - context.insert(id, output); - } - - /// Read preprocessor result from context - pub fn get_from_context(&self, id: PreprocessorId) -> Option { - let context = self.context.lock().expect("failed to lock context"); - context.get(&id).cloned() - } - - fn try_relative_output_path(&self) -> Option<&Path> { - self.target_path.strip_prefix(&self.out_target_dir).ok()?.strip_prefix(DocBuilder::SRC).ok() - } - - /// Returns the relative path of the document output. - pub fn relative_output_path(&self) -> &Path { - self.try_relative_output_path().unwrap_or(self.target_path.as_path()) - } -} - -/// The content of the document. -#[derive(Debug)] -#[allow(clippy::large_enum_variant)] -pub enum DocumentContent { - Empty, - Single(ParseItem), - Constants(Vec), - OverloadedFunctions(Vec), -} - -impl DocumentContent { - pub(crate) fn len(&self) -> usize { - match self { - Self::Empty => 0, - Self::Single(_) => 1, - Self::Constants(items) => items.len(), - Self::OverloadedFunctions(items) => items.len(), - } - } - - pub(crate) fn get_mut(&mut self, index: usize) -> Option<&mut ParseItem> { - match self { - Self::Empty => None, - Self::Single(item) => { - if index == 0 { - Some(item) - } else { - None - } - } - Self::Constants(items) => items.get_mut(index), - Self::OverloadedFunctions(items) => items.get_mut(index), - } - } - - pub fn iter_items(&self) -> ParseItemIter<'_> { - match self { - Self::Empty => ParseItemIter { next: None, other: None }, - Self::Single(item) => ParseItemIter { next: Some(item), other: None }, - Self::Constants(items) => ParseItemIter { next: None, other: Some(items.iter()) }, - Self::OverloadedFunctions(items) => { - ParseItemIter { next: None, other: Some(items.iter()) } - } - } - } - - pub fn iter_items_mut(&mut self) -> ParseItemIterMut<'_> { - match self { - Self::Empty => ParseItemIterMut { next: None, other: None }, - Self::Single(item) => ParseItemIterMut { next: Some(item), other: None }, - Self::Constants(items) => { - ParseItemIterMut { next: None, other: Some(items.iter_mut()) } - } - Self::OverloadedFunctions(items) => { - ParseItemIterMut { next: None, other: Some(items.iter_mut()) } - } - } - } -} - -#[derive(Debug)] -pub struct ParseItemIter<'a> { - next: Option<&'a ParseItem>, - other: Option>, -} - -impl<'a> Iterator for ParseItemIter<'a> { - type Item = &'a ParseItem; - - fn next(&mut self) -> Option { - if let Some(next) = self.next.take() { - return Some(next); - } - if let Some(other) = self.other.as_mut() { - return other.next(); - } - - None - } -} - -#[derive(Debug)] -pub struct ParseItemIterMut<'a> { - next: Option<&'a mut ParseItem>, - other: Option>, -} - -impl<'a> Iterator for ParseItemIterMut<'a> { - type Item = &'a mut ParseItem; - - fn next(&mut self) -> Option { - if let Some(next) = self.next.take() { - return Some(next); - } - if let Some(other) = self.other.as_mut() { - return other.next(); - } - - None - } -} - -/// Read the preprocessor output variant from document context. -/// Returns [None] if there is no output. -macro_rules! read_context { - ($doc:expr, $id:expr, $variant:ident) => { - $doc.get_from_context($id).and_then(|out| match out { - // Only a single variant is matched. Otherwise the code is invalid. - PreprocessorOutput::$variant(inner) => Some(inner), - _ => None, - }) - }; -} - -pub(crate) use read_context; diff --git a/crates/doc/src/helpers.rs b/crates/doc/src/helpers.rs deleted file mode 100644 index 7b36e2e257b71..0000000000000 --- a/crates/doc/src/helpers.rs +++ /dev/null @@ -1,28 +0,0 @@ -use toml::{Value, value::Table}; - -/// Merge original toml table with the override. -pub(crate) fn merge_toml_table(table: &mut Table, override_table: Table) { - for (key, override_value) in override_table { - match table.get_mut(&key) { - Some(Value::Table(inner_table)) => { - // Override value must be a table, otherwise discard - if let Value::Table(inner_override) = override_value { - merge_toml_table(inner_table, inner_override); - } - } - Some(Value::Array(inner_array)) => { - // Override value must be an array, otherwise discard - if let Value::Array(inner_override) = override_value { - for entry in inner_override { - if !inner_array.contains(&entry) { - inner_array.push(entry); - } - } - } - } - _ => { - table.insert(key, override_value); - } - }; - } -} diff --git a/crates/doc/src/hir_ext.rs b/crates/doc/src/hir_ext.rs new file mode 100644 index 0000000000000..3b646a92d459a --- /dev/null +++ b/crates/doc/src/hir_ext.rs @@ -0,0 +1,1208 @@ +//! HIR-aware enrichments. +//! +//! Pure functions over `solar`'s HIR: +//! * `build_name_to_page`: maps contract names to their MDX page paths. +//! * `inheritance_links`: `**Inherits:**` line for a contract page. +//! * `natspec_doc`: resolves effective NatSpec for a callable item. +//! * `replace_inline_links`: rewrites `{Ident}` to markdown links. + +use crate::utils::{contract_kind_str, page_path, relative_source_path}; +use path_slash::PathBufExt; +use solar::{ + ast::{DataLocation, FunctionKind, ItemKind, NatSpecItem, NatSpecKind, Visibility}, + interface::{Span, source_map::FileName}, + sema::{ + Gcx, + hir::{ContractId, FunctionId, Item, ItemId, VariableId}, + ty::{Ty, TyAbiPrinter, TyAbiPrinterMode}, + }, +}; +use std::{ + collections::{HashMap, HashSet, hash_map::Entry}, + path::{Path, PathBuf}, +}; +use tracing::warn; + +// ── name-to-page map ────────────────────────────────────────────────────────── + +/// Maps Solidity identifiers and HIR ids to their output MDX page paths +/// relative to `pages/`. +#[derive(Debug, Default)] +pub struct NameToPage { + by_name: HashMap>, + by_contract: HashMap, +} + +impl NameToPage { + pub fn new() -> Self { + Self::default() + } + + /// Candidate pages defined for a top-level identifier, if any. + pub fn get(&self, name: &str) -> Option<&Vec> { + self.by_name.get(name) + } + + /// Exact page for a contract id, if it lives in an allowed source. + pub fn get_contract(&self, id: ContractId) -> Option<&PathBuf> { + self.by_contract.get(&id) + } +} + +/// Build the [`NameToPage`] index from HIR by re-deriving each item's output path. +/// +/// This mirrors the path computation in `render::source` so links can be resolved +/// before rendering begins. +/// +/// Only items whose source file is contained in `allowed_sources` (absolute paths) +/// are included, so cross-references cannot resolve to pages that won't be emitted. +pub fn build_name_to_page( + gcx: Gcx<'_>, + root: &Path, + allowed_sources: &HashSet, +) -> NameToPage { + let mut map = NameToPage::new(); + + // Sort by source path and name to preserve deterministic candidate ordering. + let mut items = gcx + .hir + .item_ids() + .filter_map(|item_id| { + let item = gcx.hir.item(item_id); + let prefix = match item { + Item::Contract(c) => contract_kind_str(c.kind), + Item::Struct(_) => "struct", + Item::Enum(_) => "enum", + Item::Error(_) => "error", + Item::Event(_) => "event", + Item::Udvt(_) => "type", + Item::Function(_) | Item::Variable(_) => return None, + }; + if item.contract().is_some() { + return None; + } + let FileName::Real(abs) = &gcx.hir.source(item.source()).file.name else { + return None; + }; + let name = item.name()?; + allowed_sources + .contains(abs) + .then(|| (relative_source_path(root, abs), name, prefix, item_id)) + }) + .collect::>(); + items.sort_by(|(a, an, _, _), (b, bn, _, _)| { + a.to_string_lossy().cmp(&b.to_string_lossy()).then_with(|| an.as_str().cmp(bn.as_str())) + }); + + for (rel, name, prefix, item_id) in items { + let page = page_path(&rel, prefix, name.as_str()); + let name_str = name.as_str().to_string(); + let entry = map.by_name.entry(name_str.clone()).or_default(); + if !entry.is_empty() { + warn!( + "forge doc: duplicate top-level name `{name_str}`; \ + cross-reference `{{{name_str}}}` will resolve by proximity to the referencing page" + ); + } + entry.push(page.clone()); + + // Record exact contract -> page so inheritance / id-keyed lookups + // don't go through the ambiguous name index. + if let ItemId::Contract(cid) = item_id { + map.by_contract.insert(cid, page); + } + } + + map +} + +/// Pick the best candidate page for a given cross-reference lookup. +/// +/// When only one candidate exists the choice is trivial. When multiple files define +/// the same top-level name the page whose *directory* shares the longest common +/// path prefix with `current_page` wins; ties fall back to the first entry (which +/// is deterministic because `build_name_to_page` sorts before inserting). +fn resolve_page<'a>(candidates: &'a [PathBuf], current_page: &Path) -> &'a PathBuf { + if candidates.len() == 1 { + return &candidates[0]; + } + let current_dir = current_page.parent().unwrap_or(Path::new("")); + candidates + .iter() + .max_by_key(|page| { + let page_dir = page.parent().unwrap_or(Path::new("")); + current_dir.components().zip(page_dir.components()).take_while(|(a, b)| a == b).count() + }) + .unwrap_or(&candidates[0]) +} + +// ── inheritance links ───────────────────────────────────────────────────────── + +/// Returns the `**Inherits:**` markdown string for a contract, or `None` if it has no bases. +/// +/// Each base is either a bare name (when no page is known) or a markdown link. +pub fn inheritance_links( + gcx: Gcx<'_>, + contract_id: ContractId, + name_to_page: &NameToPage, + current_page: &Path, +) -> Option { + let contract = gcx.hir.contract(contract_id); + if contract.bases.is_empty() { + return None; + } + + let parts: Vec = contract + .bases + .iter() + .map(|&base_id| { + let base = gcx.hir.contract(base_id); + let name = base.name.as_str(); + // Prefer the exact base id; only fall back to the ambiguous name + // index when the base has no rendered page of its own. + if let Some(page) = name_to_page.get_contract(base_id) { + let link = page_link(page, current_page); + format!("[{name}]({link})") + } else if let Some(candidates) = name_to_page.get(name) { + let page = resolve_page(candidates, current_page); + let link = page_link(page, current_page); + format!("[{name}]({link})") + } else { + name.to_string() + } + }) + .collect(); + + Some(format!("**Inherits:** {}", parts.join(", "))) +} + +// ── inheritdoc resolution ───────────────────────────────────────────────────── + +/// One rendered row of a public variable getter signature (its name, ABI type and the +/// inherited description). +pub struct GetterField { + pub name: Option, + pub ty: String, + pub description: String, +} + +/// Effective NatSpec for an item, resolved by Solar and aligned with the item's callable +/// signature. +#[derive(Default)] +pub struct NatSpecDoc { + pub notices: Vec, + pub devs: Vec, + pub params: Vec, + pub returns: Vec, + pub getter_params: Vec, + pub getter_returns: Vec, +} + +/// Resolves explicit NatSpec inheritance, or Foundry's conservative implicit inheritance policy. +pub fn natspec_doc(gcx: Gcx<'_>, item: ItemId, implicit: bool) -> Option { + let mut doc = effective_natspec_doc(gcx, item, implicit, &mut HashSet::new())?; + let callable = callable_function(gcx, item); + if let Some(fid) = callable + && matches!(item, ItemId::Variable(_)) + { + let function = gcx.hir.function(fid); + doc.getter_params = function + .parameters + .iter() + .enumerate() + .map(|(index, ¶meter)| getter_field(gcx, parameter, &doc.params[index])) + .collect(); + doc.getter_returns = function + .returns + .iter() + .enumerate() + .map(|(index, &return_)| getter_field(gcx, return_, &doc.returns[index])) + .collect(); + } + Some(doc) +} + +fn effective_natspec_doc( + gcx: Gcx<'_>, + item: ItemId, + implicit: bool, + visited: &mut HashSet, +) -> Option { + if !visited.insert(item) { + return None; + } + + let hir_item = gcx.hir.item(item); + let raw = gcx.hir.doc(hir_item.doc()).ast_comments(); + let has_local = raw.iter().any(|comment| !comment.natspec.is_empty()); + if !has_local { + if !implicit { + return Some(empty_natspec_doc(gcx, item)); + } + let bases = direct_base_items(gcx, item); + let [base] = bases.as_slice() else { return None }; + if !implicit_edge_compatible(gcx, item, *base) + || (!matches!(item, ItemId::Variable(_)) && !parameter_names_equal(gcx, item, *base)) + { + return None; + } + return effective_natspec_doc(gcx, *base, true, visited); + } + + let mut local = doc_from_view(gcx, item); + let mut inheritdoc = None; + let mut local_notice = false; + let mut local_dev = false; + let mut local_param = false; + let mut local_return = false; + let raw_items = + raw.iter().flat_map(|comment| comment.natspec.iter().copied()).collect::>(); + for entry in &raw_items { + match entry.kind { + NatSpecKind::Inheritdoc { contract } if inheritdoc.is_none() => { + inheritdoc = Some(contract) + } + NatSpecKind::Notice if continuation_parent(gcx, &raw_items, entry).is_none() => { + local_notice = true + } + NatSpecKind::Dev => local_dev = true, + NatSpecKind::Param { .. } => local_param = true, + NatSpecKind::Return { .. } => local_return = true, + _ => {} + } + } + let Some(alias) = inheritdoc else { return Some(local) }; + let contract = gcx.natspec_contract(alias.name, hir_item.source())?; + let source = exact_override_item(gcx, item, contract, &mut HashSet::new())?; + let inherited = effective_natspec_doc(gcx, source, true, visited)?; + + // Solar handles ordinary explicit inheritance, including positional remapping. The + // recursive source fills only sections that Solar cannot see because they depend on + // Foundry's implicit policy at the exact source declaration. + if !local_notice { + local.notices = inherited.notices; + } + if !local_dev { + local.devs = inherited.devs; + } + if !local_param { + for (description, inherited) in local.params.iter_mut().zip(inherited.params) { + *description = inherited; + } + } + if !local_return { + for (description, inherited) in local.returns.iter_mut().zip(inherited.returns) { + *description = inherited; + } + } + Some(local) +} + +fn empty_natspec_doc(gcx: Gcx<'_>, item: ItemId) -> NatSpecDoc { + let (params, returns) = callable_function(gcx, item) + .map(|id| { + let function = gcx.hir.function(id); + ( + vec![String::new(); function.parameters.len()], + vec![String::new(); function.returns.len()], + ) + }) + .unwrap_or_default(); + NatSpecDoc { params, returns, ..Default::default() } +} + +fn doc_from_view(gcx: Gcx<'_>, item: ItemId) -> NatSpecDoc { + let view = gcx.natspec_view(item); + let mut doc = empty_natspec_doc(gcx, item); + for natspec in view.items() { + if continuation_parent(gcx, view.items(), natspec).is_some() { + continue; + } + let content = positional_description(gcx, view.items(), std::slice::from_ref(natspec)); + match natspec.kind { + NatSpecKind::Notice => doc.notices.push(content), + NatSpecKind::Dev => doc.devs.push(content), + _ => {} + } + } + if let Some(fid) = callable_function(gcx, item) { + let function = gcx.hir.function(fid); + for (index, param) in doc.params[..function.parameters.len()].iter_mut().enumerate() { + *param = positional_description(gcx, view.items(), view.parameter(index)); + } + for (index, return_) in doc.returns[..function.returns.len()].iter_mut().enumerate() { + *return_ = positional_description(gcx, view.items(), view.return_(index)); + } + } + doc +} + +fn positional_description( + gcx: Gcx<'_>, + all_items: &[NatSpecItem], + items: &[NatSpecItem], +) -> String { + items + .iter() + .map(|item| { + let mut content = normalized_natspec_content(gcx, item); + for continuation in all_items.iter().filter(|candidate| { + continuation_parent(gcx, all_items, candidate) == Some(Some(item.span)) + }) { + content.push('\n'); + content.push_str(&normalized_natspec_content(gcx, continuation)); + } + content + }) + .collect::>() + .join("\n") +} + +fn normalized_natspec_content(gcx: Gcx<'_>, item: &NatSpecItem) -> String { + let source_map = gcx.sess.source_map(); + let snippet = source_map.span_to_snippet(item.span).ok(); + if snippet.as_deref().is_some_and(|snippet| snippet.starts_with("///")) { + return item.content().trim().to_string(); + } + if snippet.as_deref().is_some_and(|snippet| snippet.starts_with("/**")) { + return clean_block_doc_content(item.content()).trim().to_string(); + } + item.content() + .lines() + .enumerate() + .map( + |(index, line)| { + if index == 0 { line.to_string() } else { clean_block_doc_content(line) } + }, + ) + .collect::>() + .join("\n") + .trim() + .to_string() +} + +/// Returns the original tagged parent of a synthetic comment notice. The outer `Option` +/// distinguishes a continuation from a standalone untagged notice; the inner value is `None` when +/// Solar's resolved view omitted the parent because a local section replaced it. +fn continuation_parent( + gcx: Gcx<'_>, + all_items: &[NatSpecItem], + item: &NatSpecItem, +) -> Option> { + if !matches!(item.kind, NatSpecKind::Notice) { + return None; + } + + let source_map = gcx.sess.source_map(); + let snippet = source_map.span_to_snippet(item.span).ok()?; + let location = source_map.lookup_char_pos(item.span.lo()); + if snippet.starts_with("/**") { + let index = all_items.iter().position(|candidate| candidate.span == item.span)?; + let previous = all_items.get(index.checked_sub(1)?)?; + if !matches!( + previous.kind, + NatSpecKind::Notice + | NatSpecKind::Dev + | NatSpecKind::Param { .. } + | NatSpecKind::Return { .. } + ) { + return None; + } + let previous_location = source_map.lookup_char_pos(previous.span.lo()); + if location.line != previous_location.line + 1 + || !std::sync::Arc::ptr_eq(&location.file, &previous_location.file) + { + return None; + } + return Some(continuation_parent(gcx, all_items, previous).unwrap_or(Some(previous.span))); + } + if !snippet.starts_with("///") { + return None; + } + + let mut previous_line = location.line.checked_sub(2)?; + loop { + let line = location.file.get_line(previous_line)?.trim_start(); + let content = line.strip_prefix("///")?; + if content.trim().is_empty() { + return None; + } + let Some(tag) = content.trim_start().strip_prefix('@') else { + previous_line = previous_line.checked_sub(1)?; + continue; + }; + if !matches!(tag.split_whitespace().next(), Some("notice" | "dev" | "param" | "return")) { + return None; + } + return Some( + all_items + .iter() + .find(|candidate| { + let candidate_location = source_map.lookup_char_pos(candidate.span.lo()); + candidate_location.line == previous_line + 1 + && std::sync::Arc::ptr_eq(&candidate_location.file, &location.file) + }) + .map(|parent| parent.span), + ); + } +} + +fn getter_field(gcx: Gcx<'_>, variable: VariableId, description: &str) -> GetterField { + GetterField { + name: gcx.hir.variable(variable).name.map(|name| name.as_str().to_string()), + ty: render_ty(gcx, gcx.type_of_item(variable.into())), + description: description.to_string(), + } +} + +/// Renders a resolved type as its ABI signature string. +fn render_ty<'a>(gcx: Gcx<'a>, ty: Ty<'a>) -> String { + let mut out = String::new(); + let _ = TyAbiPrinter::new(gcx, &mut out, TyAbiPrinterMode::Signature).print(ty); + out +} + +/// Extracts function parameter types exactly as spelled in source for link anchors. +fn function_source_param_types(gcx: Gcx<'_>, fid: FunctionId) -> Option> { + let f = gcx.hir.function(fid); + let ast = gcx.sources.get(f.source)?.ast.as_ref()?; + let params = ast.items.iter().find_map(|item| match &item.kind { + ItemKind::Function(func) if item.span == f.span => Some(&func.header.parameters), + ItemKind::Contract(c) => c.body.iter().find_map(|member| match &member.kind { + ItemKind::Function(func) if member.span == f.span => Some(&func.header.parameters), + _ => None, + }), + _ => None, + })?; + let sm = gcx.sess.source_map(); + Some( + params + .vars + .iter() + .map(|variable| { + sm.span_to_snippet(variable.ty.span).unwrap_or_default().trim().to_string() + }) + .collect(), + ) +} + +/// Canonicalizes Solidity type aliases for generated link anchors. +fn normalize_sol_type(t: &str) -> String { + let bytes = t.as_bytes(); + let len = bytes.len(); + let mut out = String::with_capacity(len + 8); + let mut i = 0; + while i < len { + if bytes[i..].starts_with(b"uint") && !bytes.get(i + 4).is_some_and(u8::is_ascii_digit) { + out.push_str("uint256"); + i += 4; + } else if bytes[i..].starts_with(b"int") + && !bytes.get(i + 3).is_some_and(u8::is_ascii_digit) + { + out.push_str("int256"); + i += 3; + } else if let Some(ch) = t[i..].chars().next() { + out.push(ch); + i += ch.len_utf8(); + } else { + break; + } + } + out +} + +fn callable_function(gcx: Gcx<'_>, item: ItemId) -> Option { + match item { + ItemId::Function(id) => Some(id), + ItemId::Variable(id) => gcx.hir.variable(id).getter, + _ => None, + } +} + +fn exact_override_item( + gcx: Gcx<'_>, + item: ItemId, + owner: ContractId, + visited: &mut HashSet, +) -> Option { + if !visited.insert(item) { + return None; + } + if gcx.hir.item(item).contract() == Some(owner) { + return Some(item); + } + for &base in gcx.base_override_items(item) { + if let Some(found) = exact_override_item(gcx, base, owner, visited) { + return Some(found); + } + } + None +} + +fn direct_base_items(gcx: Gcx<'_>, item: ItemId) -> Vec { + let mut bases = Vec::new(); + let mut visited = HashSet::new(); + for &base in gcx.base_override_items(item) { + push_non_yul_base_items(gcx, base, &mut bases, &mut visited); + } + bases +} + +fn push_non_yul_base_items( + gcx: Gcx<'_>, + item: ItemId, + bases: &mut Vec, + visited: &mut HashSet, +) { + if !visited.insert(item) { + return; + } + if matches!(item, ItemId::Function(id) if gcx.hir.function(id).is_yul) { + for &base in gcx.base_override_items(item) { + push_non_yul_base_items(gcx, base, bases, visited); + } + } else { + bases.push(item); + } +} + +fn parameter_names_equal(gcx: Gcx<'_>, target: ItemId, base: ItemId) -> bool { + let names = |item| { + callable_function(gcx, item).map(|id| { + gcx.hir + .function(id) + .parameters + .iter() + .map(|&id| gcx.hir.variable(id).name.map(|name| name.name)) + .collect::>() + }) + }; + names(target) == names(base) +} + +fn implicit_edge_compatible(gcx: Gcx<'_>, target: ItemId, base: ItemId) -> bool { + let (Some(target_id), Some(base_id)) = + (callable_function(gcx, target), callable_function(gcx, base)) + else { + return false; + }; + let target_fn = gcx.hir.function(target_id); + let base_fn = gcx.hir.function(base_id); + if !base_fn.virtual_ + || target_fn.visibility == Visibility::Private + || base_fn.visibility == Visibility::Private + || (target_fn.body.is_none() && base_fn.body.is_some()) + { + return false; + } + let visibility_ok = if matches!(target, ItemId::Variable(_)) { + base_fn.visibility == Visibility::External + } else { + target_fn.visibility == base_fn.visibility + || (base_fn.visibility == Visibility::External + && target_fn.visibility == Visibility::Public) + }; + let target_mutability = match target { + ItemId::Variable(id) if gcx.hir.variable(id).is_constant() => { + solar::ast::StateMutability::Pure + } + _ => target_fn.state_mutability, + }; + let mutability_ok = target_mutability == base_fn.state_mutability + || matches!( + (target_mutability, base_fn.state_mutability), + ( + solar::ast::StateMutability::Pure, + solar::ast::StateMutability::View | solar::ast::StateMutability::NonPayable + ) | (solar::ast::StateMutability::View, solar::ast::StateMutability::NonPayable) + ); + if !visibility_ok || !mutability_ok { + return false; + } + let types_equal = |left: &[VariableId], right: &[VariableId], locations: bool| { + left.len() == right.len() + && left.iter().zip(right).all(|(&left, &right)| { + let left = gcx.type_of_item(left.into()); + let right = gcx.type_of_item(right.into()); + left.peel_refs() == right.peel_refs() && (!locations || left.loc() == right.loc()) + }) + }; + let external_types_equal = |left: &[VariableId], right: &[VariableId]| { + let normalize = |location| match location { + Some(DataLocation::Calldata) => Some(DataLocation::Memory), + location => location, + }; + left.len() == right.len() + && left.iter().zip(right).all(|(&left, &right)| { + let left_variable = gcx.hir.variable(left); + let right_variable = gcx.hir.variable(right); + let left = gcx.type_of_item(left.into()); + let right = gcx.type_of_item(right.into()); + let left_location = left_variable.data_location.or_else(|| left.loc()); + let right_location = right_variable.data_location.or_else(|| right.loc()); + left.peel_refs() == right.peel_refs() + && normalize(left_location) == normalize(right_location) + }) + }; + let target_returns = + gcx.type_of_item(target_id.into()).as_externally_callable_function(false, gcx).returns(); + let base_returns = + gcx.type_of_item(base_id.into()).as_externally_callable_function(false, gcx).returns(); + if target_returns != base_returns || !external_types_equal(target_fn.returns, base_fn.returns) { + return false; + } + if target_fn.kind == FunctionKind::Modifier { + return types_equal(target_fn.parameters, base_fn.parameters, false); + } + if base_fn.visibility == Visibility::External { + external_types_equal(target_fn.parameters, base_fn.parameters) + } else { + types_equal(target_fn.parameters, base_fn.parameters, true) + && types_equal(target_fn.returns, base_fn.returns, true) + } +} + +/// Strip the ` * ` block-comment line decoration from each line of a `/** */` NatSpec item's +/// content. Solar preserves raw source bytes, so continuation lines look like ` * text` and blank +/// separator lines look like ` *`. This normalises them to plain text / empty lines. +pub(crate) fn clean_block_doc_content(raw: &str) -> String { + raw.lines() + .map(|line| { + let t = line.trim_start(); + if let Some(rest) = t.strip_prefix('*') { + rest.strip_prefix(' ').unwrap_or(rest) + } else { + line + } + }) + .collect::>() + .join("\n") +} + +// ── inline link replacement ─────────────────────────────────────────────────── + +/// Members of the contract page currently being rendered. +/// +/// Used to resolve `{member}` and `{Contract-member}` references lexically: +/// a name that belongs to the current contract links to its heading anchor on +/// the same page instead of going through the global name index (which only +/// contains top-level items and could otherwise resolve to an unrelated page). +#[derive(Debug)] +pub struct LocalMembers { + /// The current contract's name. + name: String, + /// Member names with a heading (and thus an anchor) on the current page. + members: HashSet, + /// Heading and exact signature anchors rendered on the current page. + anchors: HashSet, + /// Effective inherited member names and their optional documentation pages. + inherited: HashMap>, + /// Inherited contracts and the members rendered on their exact pages. + inherited_contracts: HashMap, +} + +#[derive(Debug)] +enum InheritedContract { + Unique { id: ContractId, page: Option, anchors: HashSet }, + Ambiguous, +} + +/// Record the heading and exact signature anchor for a rendered Solidity function. +fn insert_function_anchors( + gcx: Gcx<'_>, + id: FunctionId, + anchors: &mut HashSet, +) -> Option { + let function = gcx.hir.function(id); + if function.is_yul || function.is_getter() { + return None; + } + let params = function_source_param_types(gcx, id)?; + let name = match function.kind { + FunctionKind::Constructor => "constructor".to_string(), + FunctionKind::Fallback => "fallback".to_string(), + FunctionKind::Receive => "receive".to_string(), + FunctionKind::Function | FunctionKind::Modifier => function.name?.as_str().to_string(), + }; + anchors.insert(slug_anchor_segment(&name)); + anchors.insert(function_signature_anchor(&name, ¶ms)); + Some(name) +} + +impl LocalMembers { + /// Create an empty member set for the contract `name`. + pub fn new(name: &str) -> Self { + Self { + name: name.to_string(), + members: HashSet::new(), + anchors: HashSet::new(), + inherited: HashMap::new(), + inherited_contracts: HashMap::new(), + } + } + + /// Create a member set populated with members declared by base contracts. + pub fn for_contract(gcx: Gcx<'_>, contract_id: ContractId, name_to_page: &NameToPage) -> Self { + let contract = gcx.hir.contract(contract_id); + let mut this = Self::new(contract.name.as_str()); + + // Solidity's linearization lists the current contract first, followed by bases in + // resolution order. Reserve the first inherited declaration even if its page is not + // rendered so a farther declaration cannot produce a confidently incorrect link. + for &base_id in contract.linearized_bases.iter().filter(|&&id| id != contract_id) { + let base = gcx.hir.contract(base_id); + let page = name_to_page.get_contract(base_id).cloned(); + let mut anchors = HashSet::new(); + + for &item_id in base.items { + let (name, is_inherited) = match item_id { + ItemId::Function(id) => { + let function = gcx.hir.function(id); + ( + insert_function_anchors(gcx, id, &mut anchors), + function.visibility != Visibility::Private + && function.kind != FunctionKind::Constructor, + ) + } + ItemId::Variable(id) => { + let variable = gcx.hir.variable(id); + ( + variable.name.map(|name| name.as_str().to_string()), + variable.visibility != Some(Visibility::Private), + ) + } + ItemId::Struct(id) => { + (Some(gcx.hir.strukt(id).name.as_str().to_string()), true) + } + ItemId::Enum(id) => (Some(gcx.hir.enumm(id).name.as_str().to_string()), true), + ItemId::Error(id) => (Some(gcx.hir.error(id).name.as_str().to_string()), true), + ItemId::Event(id) => (Some(gcx.hir.event(id).name.as_str().to_string()), true), + ItemId::Udvt(id) => (Some(gcx.hir.udvt(id).name.as_str().to_string()), true), + ItemId::Contract(_) => (None, false), + }; + if let Some(name) = name { + anchors.insert(slug_anchor_segment(&name)); + if is_inherited { + this.inherited.entry(name).or_insert_with(|| page.clone()); + } + } + } + + match this.inherited_contracts.entry(base.name.as_str().to_string()) { + Entry::Vacant(entry) => { + entry.insert(InheritedContract::Unique { id: base_id, page, anchors }); + } + Entry::Occupied(mut entry) => { + if matches!(entry.get(), InheritedContract::Unique { id, .. } if *id != base_id) + { + entry.insert(InheritedContract::Ambiguous); + } + } + } + } + + this + } + + /// Record a member that is rendered as a `### member` heading on the page. + pub fn insert(&mut self, member: &str) { + self.members.insert(member.to_string()); + self.anchors.insert(slug_anchor_segment(member)); + } + + /// Record an exact signature anchor rendered on the current page. + pub fn insert_anchor(&mut self, anchor: String) { + self.anchors.insert(anchor); + } + + /// Anchor for a bare `{member}` reference, if `member` is documented on this page. + /// + /// Overloads share the base heading slug; the first heading owns it. + fn member_anchor(&self, member: &str) -> Option { + self.members.contains(member).then(|| slug_anchor_segment(member)) + } + + /// Anchor for a qualified `{Contract-member[-params...]}` reference, if `member` is + /// documented on this page. + fn xref_member_anchor(&self, part: &str) -> Option { + let anchor = xref_part_anchor(part); + self.anchors.contains(&anchor).then_some(anchor) + } + + /// Page and anchor for a bare inherited-member reference. + /// + /// The outer option indicates whether the name is inherited; the inner option is absent when + /// the effective declaration has no rendered page. + fn inherited_member_link(&self, member: &str, current_page: &Path) -> Option> { + let page = self.inherited.get(member)?; + Some(page.as_ref().map(|page| { + format!("{}#{}", page_link(page, current_page), slug_anchor_segment(member)) + })) + } + + /// Exact page and anchor for a qualified inherited-contract member reference. + /// + /// The outer option indicates whether the contract is an inherited base; the inner option is + /// absent when that base has no rendered page or the named member has no rendered heading. + fn inherited_contract_member_link( + &self, + contract: &str, + part: &str, + current_page: &Path, + ) -> Option> { + let base = self.inherited_contracts.get(contract)?; + let InheritedContract::Unique { page, anchors, .. } = base else { + return Some(None); + }; + let anchor = xref_part_anchor(part); + Some(page.as_ref().and_then(|page| { + anchors.contains(&anchor).then(|| format!("{}#{anchor}", page_link(page, current_page))) + })) + } +} + +/// Escape a string for use as a markdown link label. +/// +/// Prevents MDX from treating user-controlled NatSpec label text as JSX or +/// breaking the surrounding markdown link syntax. +fn escape_link_label(s: &str) -> String { + s.replace('{', "{").replace('<', "<").replace('[', "\\[").replace(']', "\\]") +} + +/// Replace `{Ident}` and `{xref-Ident}` with markdown links using `name_to_page`. +/// +/// Matches the legacy pattern: `{[xref-]Ident[-part]}[label]` where `label` defaults +/// to `Ident`. +/// +/// Resolution prefers lexical proximity: a reference naming a member of the current +/// contract (`{member}`, or `{Contract-member}` where `Contract` is the current +/// contract) becomes an anchor-only link within the page; everything else goes +/// through the global `name_to_page` index. +pub fn replace_inline_links( + text: &str, + name_to_page: &NameToPage, + current_page: &Path, + local: Option<&LocalMembers>, +) -> String { + let mut out = String::with_capacity(text.len()); + let bytes = text.as_bytes(); + let mut i = 0; + + while i < bytes.len() { + if bytes[i] == b'{' { + // Try to parse {[xref-]Ident[-part]}[optional label]. + if let Some((end, ident, part, label)) = parse_inline_link(&text[i..]) { + // Strip the leading `xref-` prefix if present. + let lookup_name = ident.strip_prefix("xref-").unwrap_or(ident); + let lookup_name = if let Some(pos) = lookup_name.find('-') { + &lookup_name[..pos] + } else { + lookup_name + }; + + // Same-contract references resolve to anchor-only links: a bare + // `{member}` documented on this page, or `{Contract-member}` where + // `Contract` is the contract being rendered. + if let Some(local) = local { + let local_anchor = match part { + None => local.member_anchor(lookup_name).map(Some), + Some(member) if lookup_name == local.name => { + Some(local.xref_member_anchor(member)) + } + Some(_) => None, + }; + if let Some(anchor) = local_anchor { + if let Some(anchor) = anchor { + let default_display = match part { + Some(member) => format!("{lookup_name}.{member}"), + None => lookup_name.to_string(), + }; + let display = escape_link_label(label.unwrap_or(&default_display)); + out.push_str(&format!("[{display}](#{anchor})")); + } else { + let safe_name = lookup_name.replace('`', "'"); + out.push_str(&format!("`{safe_name}`")); + } + i += end; + continue; + } + + let inherited_link = match part { + None => local.inherited_member_link(lookup_name, current_page), + Some(member) => { + local.inherited_contract_member_link(lookup_name, member, current_page) + } + }; + if let Some(link) = inherited_link { + if let Some(link) = link { + let default_display = match part { + Some(member) => format!("{lookup_name}.{member}"), + None => lookup_name.to_string(), + }; + let display = escape_link_label(label.unwrap_or(&default_display)); + out.push_str(&format!("[{display}]({link})")); + } else { + let safe_name = lookup_name.replace('`', "'"); + out.push_str(&format!("`{safe_name}`")); + } + i += end; + continue; + } + } + + if let Some(candidates) = name_to_page.get(lookup_name) { + let page = resolve_page(candidates, current_page); + let mut link = page_link(page, current_page); + // Append the member anchor when the pattern is `{Type-member}`. + // Sanitize to ASCII alphanumerics and `_` only, Solidity identifiers + // never contain other characters, so this drops any injection attempt. + if let Some(member) = part { + let safe_member = xref_part_anchor(member); + if !safe_member.is_empty() { + link.push('#'); + link.push_str(&safe_member); + } + } + let default_display = if let Some(member) = part { + // default display: "Type.member" + format!("{lookup_name}.{member}") + } else { + lookup_name.to_string() + }; + let display = escape_link_label(label.unwrap_or(&default_display)); + out.push_str(&format!("[{display}]({link})")); + i += end; + continue; + } + + // Unresolved {Ident}, emit as inline code to avoid MDX treating it as a + // JS expression. Strip backticks to avoid breaking the fence. + let safe_name = lookup_name.replace('`', "'"); + out.push_str(&format!("`{safe_name}`")); + i += end; + continue; + } + // Bare `{` with no matching `}`, escape it. + out.push_str("{"); + i += 1; + continue; + } + + if bytes[i] == b'<' { + // Escape `<` that would be parsed as a JSX/HTML tag by MDX. + // A `<` is safe only when it's already part of a markdown link `` or + // a standard HTML entity. We unconditionally escape to `<` here + // since Solidity natspec does not produce markdown autolinks. + out.push_str("<"); + i += 1; + continue; + } + + // Advance by the full UTF-8 character to avoid corrupting multi-byte sequences. + let ch = text[i..].chars().next().unwrap(); + out.push(ch); + i += ch.len_utf8(); + } + + out +} + +pub(crate) fn function_signature_anchor(name: &str, params: &[String]) -> String { + let mut anchor = slug_anchor_segment(name); + for param in params { + let param = slug_anchor_segment(&normalize_sol_type(param)); + if !param.is_empty() { + anchor.push('-'); + anchor.push_str(¶m); + } + } + anchor +} + +fn xref_part_anchor(part: &str) -> String { + let mut pieces = part.split('-').filter(|piece| !piece.is_empty()); + let Some(member) = pieces.next() else { + return String::new(); + }; + let params = pieces.map(|piece| piece.to_string()).collect::>(); + function_signature_anchor(member, ¶ms) +} + +fn slug_anchor_segment(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut last_was_dash = false; + + for ch in s.chars().flat_map(char::to_lowercase) { + if ch.is_ascii_alphanumeric() || ch == '_' { + out.push(ch); + last_was_dash = false; + } else if ch != '$' && !last_was_dash && !out.is_empty() { + out.push('-'); + last_was_dash = true; + } + } + + if last_was_dash { + out.pop(); + } + + out +} + +/// Parse `{[xref-]Ident[-part]}[label]` starting at offset 0 in `s`. +/// +/// Returns `(consumed_bytes, ident, part, label)` on success. +fn parse_inline_link(s: &str) -> Option<(usize, &str, Option<&str>, Option<&str>)> { + let s = s.strip_prefix('{')?; + let close = s.find('}')?; + let inner = &s[..close]; + + // inner = "[xref-]Ident[-part]" + let (raw_ident, raw_part) = if let Some(rest) = inner.strip_prefix("xref-") { + if let Some(dash) = rest.find('-') { + (&inner[..("xref-".len() + dash)], Some(&rest[dash + 1..])) + } else { + (inner, None) + } + } else if let Some(dash) = inner.find('-') { + let candidate_ident = &inner[..dash]; + let candidate_part = &inner[dash + 1..]; + if candidate_ident.chars().all(|c| c.is_alphanumeric() || c == '_') + && !candidate_part.is_empty() + { + (candidate_ident, Some(candidate_part)) + } else { + (inner, None) + } + } else { + (inner, None) + }; + + let mut consumed = 1 + close + 1; // '{' + inner + '}' + + // Optional label: `[label]` + let rest = &s[close + 1..]; + let label = if rest.starts_with('[') { + if let Some(end) = rest.find(']') { + let lbl = &rest[1..end]; + consumed += end + 1; + Some(lbl) + } else { + None + } + } else { + None + }; + + Some((consumed, raw_ident, raw_part, label)) +} + +// ── path helpers ────────────────────────────────────────────────────────────── + +/// Produce a vocs-style link from `page` relative to `current_page`. +/// +/// vocs uses root-relative links (starting with `/`). Forward slashes are +/// always used so the URL stays correct on Windows. +fn page_link(page: &Path, _current_page: &Path) -> String { + // Strip .mdx extension and produce an absolute path from the pages root. + let without_ext = page.with_extension(""); + format!("/{}", without_ext.to_slash_lossy()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn full_signature_xref_links_member_anchor() { + let mut name_to_page = NameToPage::new(); + name_to_page + .by_name + .insert("ERC721".to_string(), vec![PathBuf::from("src/contract.ERC721.mdx")]); + + let out = replace_inline_links( + "See {xref-ERC721-_safeMint-address-uint256-}.", + &name_to_page, + Path::new("src/contract.Child.mdx"), + None, + ); + + assert_eq!( + out, + "See [ERC721._safeMint-address-uint256-](/src/contract.ERC721#_safemint-address-uint256)." + ); + } + + #[test] + fn same_contract_member_links_anchor_only() { + let name_to_page = NameToPage::new(); + let mut local = LocalMembers::new("ECDSA"); + local.insert("toEthSignedMessageHash"); + local.insert("tryRecover"); + + // Bare member reference -> anchor-only link. + let out = replace_inline_links( + "then calling {toEthSignedMessageHash} on it.", + &name_to_page, + Path::new("src/library.ECDSA.mdx"), + Some(&local), + ); + assert_eq!(out, "then calling [toEthSignedMessageHash](#toethsignedmessagehash) on it."); + + // `{Contract-member}` self-reference -> anchor-only link. + let out = replace_inline_links( + "Overload of {ECDSA-tryRecover} that ...", + &name_to_page, + Path::new("src/library.ECDSA.mdx"), + Some(&local), + ); + assert_eq!(out, "Overload of [ECDSA.tryRecover](#tryrecover) that ..."); + + // Unknown member still falls back to inline code. + let out = replace_inline_links( + "See {unknownMember}.", + &name_to_page, + Path::new("src/library.ECDSA.mdx"), + Some(&local), + ); + assert_eq!(out, "See `unknownMember`."); + + // Unknown qualified self-reference should not create a broken same-page anchor. + let out = replace_inline_links( + "See {ECDSA-doesNotExist}.", + &name_to_page, + Path::new("src/library.ECDSA.mdx"), + Some(&local), + ); + assert_eq!(out, "See `ECDSA`."); + } + + #[test] + fn local_member_wins_over_global_name() { + // A top-level item elsewhere shares the member's name; lexical + // proximity resolves to the same-page anchor, not the other page. + let mut name_to_page = NameToPage::new(); + name_to_page + .by_name + .insert("transfer".to_string(), vec![PathBuf::from("src/other/contract.transfer.mdx")]); + let mut local = LocalMembers::new("Token"); + local.insert("transfer"); + + let out = replace_inline_links( + "Calls {transfer}.", + &name_to_page, + Path::new("src/contract.Token.mdx"), + Some(&local), + ); + assert_eq!(out, "Calls [transfer](#transfer)."); + + // Without local context the global index still resolves. + let out = replace_inline_links( + "Calls {transfer}.", + &name_to_page, + Path::new("src/contract.Token.mdx"), + None, + ); + assert_eq!(out, "Calls [transfer](/src/other/contract.transfer)."); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index c5201e73efb0d..d9a3303abb49e 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -1,33 +1,15 @@ -//! The module for generating Solidity documentation. -//! -//! See [`DocBuilder`]. - +#![doc = include_str!("../README.md")] #![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] - -#[macro_use] -extern crate foundry_common; +#![cfg_attr(docsrs, feature(doc_cfg))] #[macro_use] extern crate tracing; mod builder; -pub use builder::DocBuilder; - -mod document; -pub use document::Document; - -mod helpers; - -mod parser; -pub use parser::{ - Comment, CommentTag, Comments, CommentsRef, ParseItem, ParseSource, Parser, error, -}; - -mod preprocessor; -pub use preprocessor::*; - -mod writer; -pub use writer::{AsDoc, AsDocResult, BufWriter, Markdown}; +mod hir_ext; +mod markdown; +mod render; +mod utils; +mod vocs; -pub use mdbook; +pub use builder::{BuildStats, DocBuilder}; diff --git a/crates/doc/src/markdown.rs b/crates/doc/src/markdown.rs new file mode 100644 index 0000000000000..7020093c5f438 --- /dev/null +++ b/crates/doc/src/markdown.rs @@ -0,0 +1,102 @@ +//! Shared Markdown code-region and MDX ESM handling. +//! +//! README and NatSpec escaping retain their own policies for malformed fences and prose. + +use markdown::{ParseOptions, mdast::Node, to_mdast}; +use std::ops::Range; + +/// Byte ranges that Markdown parses as code under `options`. An HTML entity would render literally +/// inside these ranges, so neutralization skips them. If malformed MDX cannot be parsed, returning +/// no ranges favors neutralizing possible ESM over preserving an invalid code example +/// byte-for-byte. +pub(crate) fn code_regions(text: &str, options: &ParseOptions) -> Vec> { + let Ok(tree) = to_mdast(text, options) else { return Vec::new() }; + let mut regions = Vec::new(); + collect_code_regions(&tree, &mut regions); + regions +} + +/// Collect fenced and inline code positions from the MDX-aware syntax tree. +fn collect_code_regions(node: &Node, regions: &mut Vec>) { + if matches!(node, Node::Code(_) | Node::InlineCode(_)) + && let Some(position) = node.position() + { + regions.push(position.start.offset..position.end.offset); + } + if let Some(children) = node.children() { + for child in children { + collect_code_regions(child, regions); + } + } +} + +/// Logical lines and their byte offsets in the original text. CRLF is one separator; lone CR and +/// LF are separators too. The separator bytes are excluded from the returned slices and preserved +/// in the source string. +pub(crate) fn logical_lines(text: &str) -> impl Iterator { + let bytes = text.as_bytes(); + let mut offset = 0; + std::iter::from_fn(move || { + if offset >= bytes.len() { + return None; + } + let start = offset; + let end = bytes[start..] + .iter() + .position(|&byte| byte == b'\n' || byte == b'\r') + .map_or(bytes.len(), |position| start + position); + offset = if end == bytes.len() { + end + } else if bytes[end] == b'\r' && bytes.get(end + 1) == Some(&b'\n') { + end + 2 + } else { + end + 1 + }; + Some((start, &text[start..end])) + }) +} + +/// Check a position against sorted, merged ranges while advancing monotonically. +pub(crate) fn region_contains( + regions: &[Range], + cursor: &mut usize, + position: usize, +) -> bool { + while regions.get(*cursor).is_some_and(|region| region.end <= position) { + *cursor += 1; + } + regions.get(*cursor).is_some_and(|region| region.start <= position) +} + +/// Neutralize any line MDX would parse as an ESM statement (`import ` or `export ` at column one): +/// the keyword's prefix becomes HTML entities, so the line renders the same but no longer +/// begins with an ESM token. NatSpec text can be inherited from a dependency via `@inheritdoc`, so +/// this must run wherever displayed prose is assembled. A keyword that falls inside a Markdown +/// code span or fenced code block is left untouched (see `code_regions`): the entity would render +/// literally and corrupt the example, and MDX would not execute it there. +pub(crate) fn neutralize_esm(text: &str) -> String { + let regions = code_regions(text, &ParseOptions::mdx()); + let mut region_cursor = 0; + let mut copied = 0; + let mut out = String::with_capacity(text.len()); + + for (line_start, line) in logical_lines(text) { + let replacement = if line.starts_with("import ") { + Some(("im", 2)) + } else if line.starts_with("export ") { + Some(("e", 1)) + } else { + None + }; + let Some((entity, prefix_len)) = replacement else { continue }; + if region_contains(®ions, &mut region_cursor, line_start) { + continue; + } + out.push_str(&text[copied..line_start]); + out.push_str(entity); + copied = line_start + prefix_len; + } + + out.push_str(&text[copied..]); + out +} diff --git a/crates/doc/src/parser/comment.rs b/crates/doc/src/parser/comment.rs deleted file mode 100644 index 8651410468ec3..0000000000000 --- a/crates/doc/src/parser/comment.rs +++ /dev/null @@ -1,265 +0,0 @@ -use alloy_primitives::map::HashMap; -use derive_more::{Deref, DerefMut, derive::Display}; -use solang_parser::doccomment::DocCommentTag; - -/// The natspec comment tag explaining the purpose of the comment. -/// See: . -#[derive(Clone, Debug, Display, PartialEq, Eq)] -pub enum CommentTag { - /// A title that should describe the contract/interface - Title, - /// The name of the author - Author, - /// Explain to an end user what this does - Notice, - /// Explain to a developer any extra details - Dev, - /// Documents a parameter just like in Doxygen (must be followed by parameter name) - Param, - /// Documents the return variables of a contract’s function - Return, - /// Copies all missing tags from the base function (must be followed by the contract name) - Inheritdoc, - /// Custom tag, semantics is application-defined - Custom(String), -} - -impl CommentTag { - fn from_str(s: &str) -> Option { - let trimmed = s.trim(); - let tag = match trimmed { - "title" => Self::Title, - "author" => Self::Author, - "notice" => Self::Notice, - "dev" => Self::Dev, - "param" => Self::Param, - "return" => Self::Return, - "inheritdoc" => Self::Inheritdoc, - _ if trimmed.starts_with("custom:") => { - // `@custom:param` tag will be parsed as `CommentTag::Param` due to a limitation - // on specifying parameter docs for unnamed function arguments. - let custom_tag = trimmed.trim_start_matches("custom:").trim(); - match custom_tag { - "param" => Self::Param, - _ => Self::Custom(custom_tag.to_owned()), - } - } - _ => { - warn!(target: "forge::doc", tag=trimmed, "unknown comment tag. custom tags must be preceded by `custom:`"); - return None; - } - }; - Some(tag) - } -} - -/// The natspec documentation comment. -/// -/// Ref: -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Comment { - /// The doc comment tag. - pub tag: CommentTag, - /// The doc comment value. - pub value: String, -} - -impl Comment { - /// Create new instance of [Comment]. - pub fn new(tag: CommentTag, value: String) -> Self { - Self { tag, value } - } - - /// Create new instance of [Comment] from [DocCommentTag] - /// if it has a valid natspec tag. - pub fn from_doc_comment(value: DocCommentTag) -> Option { - CommentTag::from_str(&value.tag).map(|tag| Self { tag, value: value.value }) - } - - /// Split the comment at first word. - /// Useful for [CommentTag::Param] and [CommentTag::Return] comments. - pub fn split_first_word(&self) -> Option<(&str, &str)> { - self.value.trim_start().split_once(' ') - } - - /// Match the first word of the comment with the expected. - /// Returns [None] if the word doesn't match. - /// Useful for [CommentTag::Param] and [CommentTag::Return] comments. - pub fn match_first_word(&self, expected: &str) -> Option<&str> { - self.split_first_word().and_then( - |(word, rest)| { - if word == expected { Some(rest) } else { None } - }, - ) - } - - /// Check if this comment is a custom tag. - pub fn is_custom(&self) -> bool { - matches!(self.tag, CommentTag::Custom(_)) - } -} - -/// The collection of natspec [Comment] items. -#[derive(Clone, Debug, Default, PartialEq, Deref, DerefMut)] -pub struct Comments(Vec); - -/// Forward the [Comments] function implementation to the [CommentsRef] -/// reference type. -macro_rules! ref_fn { - ($vis:vis fn $name:ident(&self$(, )?$($arg_name:ident: $arg:ty),*) -> $ret:ty) => { - /// Forward the function implementation to [CommentsRef] reference type. - $vis fn $name(&self, $($arg_name: $arg),*) -> $ret { - CommentsRef::from(self).$name($($arg_name),*) - } - }; -} - -impl Comments { - ref_fn!(pub fn include_tag(&self, tag: CommentTag) -> CommentsRef<'_>); - ref_fn!(pub fn include_tags(&self, tags: &[CommentTag]) -> CommentsRef<'_>); - ref_fn!(pub fn exclude_tags(&self, tags: &[CommentTag]) -> CommentsRef<'_>); - ref_fn!(pub fn contains_tag(&self, tag: &Comment) -> bool); - ref_fn!(pub fn find_inheritdoc_base(&self) -> Option<&'_ str>); - - /// Attempt to lookup - /// - /// Merges two comments collections by inserting [CommentTag] from the second collection - /// into the first unless they are present. - pub fn merge_inheritdoc( - &self, - ident: &str, - inheritdocs: Option>, - ) -> Self { - let mut result = Self(Vec::from_iter(self.iter().cloned())); - - if let (Some(inheritdocs), Some(base)) = (inheritdocs, self.find_inheritdoc_base()) { - let key = format!("{base}.{ident}"); - if let Some(other) = inheritdocs.get(&key) { - for comment in other.iter() { - if !result.contains_tag(comment) { - result.push(comment.clone()); - } - } - } - } - - result - } -} - -impl From> for Comments { - fn from(value: Vec) -> Self { - Self(value.into_iter().flat_map(Comment::from_doc_comment).collect()) - } -} - -/// The collection of references to natspec [Comment] items. -#[derive(Debug, Default, PartialEq, Deref)] -pub struct CommentsRef<'a>(Vec<&'a Comment>); - -impl<'a> CommentsRef<'a> { - /// Filter a collection of comments and return only those that match a provided tag. - pub fn include_tag(&self, tag: CommentTag) -> Self { - self.include_tags(&[tag]) - } - - /// Filter a collection of comments and return only those that match provided tags. - pub fn include_tags(&self, tags: &[CommentTag]) -> Self { - // Cloning only references here - CommentsRef(self.iter().copied().filter(|c| tags.contains(&c.tag)).collect()) - } - - /// Filter a collection of comments and return only those that do not match provided tags. - pub fn exclude_tags(&self, tags: &[CommentTag]) -> Self { - // Cloning only references here - CommentsRef(self.iter().copied().filter(|c| !tags.contains(&c.tag)).collect()) - } - - /// Check if the collection contains a target comment. - pub fn contains_tag(&self, target: &Comment) -> bool { - self.iter().any(|c| match (&c.tag, &target.tag) { - (CommentTag::Inheritdoc, CommentTag::Inheritdoc) => c.value == target.value, - (CommentTag::Param, CommentTag::Param) | (CommentTag::Return, CommentTag::Return) => { - c.split_first_word().map(|(name, _)| name) - == target.split_first_word().map(|(name, _)| name) - } - (tag1, tag2) => tag1 == tag2, - }) - } - - /// Find an [CommentTag::Inheritdoc] comment and extract the base. - fn find_inheritdoc_base(&self) -> Option<&'a str> { - self.iter() - .find(|c| matches!(c.tag, CommentTag::Inheritdoc)) - .and_then(|c| c.value.split_whitespace().next()) - } - - /// Filter a collection of comments and only return the custom tags. - pub fn get_custom_tags(&self) -> Self { - CommentsRef(self.iter().copied().filter(|c| c.is_custom()).collect()) - } -} - -impl<'a> From<&'a Comments> for CommentsRef<'a> { - fn from(value: &'a Comments) -> Self { - Self(value.iter().collect()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_comment_tag() { - assert_eq!(CommentTag::from_str("title"), Some(CommentTag::Title)); - assert_eq!(CommentTag::from_str(" title "), Some(CommentTag::Title)); - assert_eq!(CommentTag::from_str("author"), Some(CommentTag::Author)); - assert_eq!(CommentTag::from_str("notice"), Some(CommentTag::Notice)); - assert_eq!(CommentTag::from_str("dev"), Some(CommentTag::Dev)); - assert_eq!(CommentTag::from_str("param"), Some(CommentTag::Param)); - assert_eq!(CommentTag::from_str("return"), Some(CommentTag::Return)); - assert_eq!(CommentTag::from_str("inheritdoc"), Some(CommentTag::Inheritdoc)); - assert_eq!(CommentTag::from_str("custom:"), Some(CommentTag::Custom(String::new()))); - assert_eq!( - CommentTag::from_str("custom:some"), - Some(CommentTag::Custom("some".to_owned())) - ); - assert_eq!( - CommentTag::from_str(" custom: some "), - Some(CommentTag::Custom("some".to_owned())) - ); - - assert_eq!(CommentTag::from_str(""), None); - assert_eq!(CommentTag::from_str("custom"), None); - assert_eq!(CommentTag::from_str("sometag"), None); - } - - #[test] - fn test_is_custom() { - // Test custom tag. - let custom_comment = Comment::new( - CommentTag::from_str("custom:test").unwrap(), - "dummy custom tag".to_owned(), - ); - assert!(custom_comment.is_custom(), "Custom tag should return true for is_custom"); - - // Test non-custom tags. - let non_custom_tags = [ - CommentTag::Title, - CommentTag::Author, - CommentTag::Notice, - CommentTag::Dev, - CommentTag::Param, - CommentTag::Return, - CommentTag::Inheritdoc, - ]; - for tag in non_custom_tags { - let comment = Comment::new(tag.clone(), "Non-custom comment".to_string()); - assert!( - !comment.is_custom(), - "Non-custom tag {tag:?} should return false for is_custom" - ); - } - } -} diff --git a/crates/doc/src/parser/error.rs b/crates/doc/src/parser/error.rs deleted file mode 100644 index 770137f99d173..0000000000000 --- a/crates/doc/src/parser/error.rs +++ /dev/null @@ -1,17 +0,0 @@ -use forge_fmt::FormatterError; -use thiserror::Error; - -/// The parser error. -#[derive(Debug, Error)] -#[error(transparent)] -pub enum ParserError { - /// Formatter error. - #[error(transparent)] - Formatter(#[from] FormatterError), - /// Internal parser error. - #[error(transparent)] - Internal(#[from] eyre::Error), -} - -/// The parser result. -pub type ParserResult = std::result::Result; diff --git a/crates/doc/src/parser/item.rs b/crates/doc/src/parser/item.rs deleted file mode 100644 index 18fcd085eb45c..0000000000000 --- a/crates/doc/src/parser/item.rs +++ /dev/null @@ -1,187 +0,0 @@ -use crate::{Comments, error::ParserResult}; -use forge_fmt::{ - Comments as FmtComments, Formatter, FormatterConfig, InlineConfig, Visitor, - solang_ext::SafeUnwrap, -}; -use solang_parser::pt::{ - ContractDefinition, ContractTy, EnumDefinition, ErrorDefinition, EventDefinition, - FunctionDefinition, StructDefinition, TypeDefinition, VariableDefinition, -}; - -/// The parsed item. -#[derive(Debug, PartialEq)] -pub struct ParseItem { - /// The parse tree source. - pub source: ParseSource, - /// Item comments. - pub comments: Comments, - /// Children items. - pub children: Vec, - /// Formatted code string. - pub code: String, -} - -/// Defines a method that filters [ParseItem]'s children and returns the source pt token of the -/// children matching the target variant as well as its comments. -/// Returns [Option::None] if no children matching the variant are found. -macro_rules! filter_children_fn { - ($vis:vis fn $name:ident(&self, $variant:ident) -> $ret:ty) => { - /// Filter children items for [ParseSource::$variant] variants. - $vis fn $name(&self) -> Option> { - let items = self.children.iter().filter_map(|item| match item.source { - ParseSource::$variant(ref inner) => Some((inner, &item.comments, &item.code)), - _ => None, - }); - let items = items.collect::>(); - if !items.is_empty() { - Some(items) - } else { - None - } - } - }; -} - -/// Defines a method that returns [ParseSource] inner element if it matches -/// the variant -macro_rules! as_inner_source { - ($vis:vis fn $name:ident(&self, $variant:ident) -> $ret:ty) => { - /// Return inner element if it matches $variant. - /// If the element doesn't match, returns [None] - $vis fn $name(&self) -> Option<&$ret> { - match self.source { - ParseSource::$variant(ref inner) => Some(inner), - _ => None - } - } - }; -} - -impl ParseItem { - /// Create new instance of [ParseItem]. - pub fn new(source: ParseSource) -> Self { - Self { - source, - comments: Default::default(), - children: Default::default(), - code: Default::default(), - } - } - - /// Set comments on the [ParseItem]. - pub fn with_comments(mut self, comments: Comments) -> Self { - self.comments = comments; - self - } - - /// Set children on the [ParseItem]. - pub fn with_children(mut self, children: Vec) -> Self { - self.children = children; - self - } - - /// Set formatted code on the [ParseItem]. - pub fn with_code(mut self, source: &str, config: FormatterConfig) -> ParserResult { - let mut code = String::new(); - let mut fmt = Formatter::new( - &mut code, - source, - FmtComments::default(), - InlineConfig::default(), - config, - ); - - match self.source.clone() { - ParseSource::Contract(mut contract) => { - contract.parts = vec![]; - fmt.visit_contract(&mut contract)? - } - ParseSource::Function(mut func) => { - func.body = None; - fmt.visit_function(&mut func)? - } - ParseSource::Variable(mut var) => fmt.visit_var_definition(&mut var)?, - ParseSource::Event(mut event) => fmt.visit_event(&mut event)?, - ParseSource::Error(mut error) => fmt.visit_error(&mut error)?, - ParseSource::Struct(mut structure) => fmt.visit_struct(&mut structure)?, - ParseSource::Enum(mut enumeration) => fmt.visit_enum(&mut enumeration)?, - ParseSource::Type(mut ty) => fmt.visit_type_definition(&mut ty)?, - }; - - self.code = code; - - Ok(self) - } - - /// Format the item's filename. - pub fn filename(&self) -> String { - let prefix = match self.source { - ParseSource::Contract(ref c) => match c.ty { - ContractTy::Contract(_) => "contract", - ContractTy::Abstract(_) => "abstract", - ContractTy::Interface(_) => "interface", - ContractTy::Library(_) => "library", - }, - ParseSource::Function(_) => "function", - ParseSource::Variable(_) => "variable", - ParseSource::Event(_) => "event", - ParseSource::Error(_) => "error", - ParseSource::Struct(_) => "struct", - ParseSource::Enum(_) => "enum", - ParseSource::Type(_) => "type", - }; - let ident = self.source.ident(); - format!("{prefix}.{ident}.md") - } - - filter_children_fn!(pub fn variables(&self, Variable) -> VariableDefinition); - filter_children_fn!(pub fn functions(&self, Function) -> FunctionDefinition); - filter_children_fn!(pub fn events(&self, Event) -> EventDefinition); - filter_children_fn!(pub fn errors(&self, Error) -> ErrorDefinition); - filter_children_fn!(pub fn structs(&self, Struct) -> StructDefinition); - filter_children_fn!(pub fn enums(&self, Enum) -> EnumDefinition); - - as_inner_source!(pub fn as_contract(&self, Contract) -> ContractDefinition); - as_inner_source!(pub fn as_variable(&self, Variable) -> VariableDefinition); - as_inner_source!(pub fn as_function(&self, Function) -> FunctionDefinition); -} - -/// A wrapper type around pt token. -#[derive(Clone, Debug, PartialEq, Eq)] -#[allow(clippy::large_enum_variant)] -pub enum ParseSource { - /// Source contract definition. - Contract(Box), - /// Source function definition. - Function(FunctionDefinition), - /// Source variable definition. - Variable(VariableDefinition), - /// Source event definition. - Event(EventDefinition), - /// Source error definition. - Error(ErrorDefinition), - /// Source struct definition. - Struct(StructDefinition), - /// Source enum definition. - Enum(EnumDefinition), - /// Source type definition. - Type(TypeDefinition), -} - -impl ParseSource { - /// Get the identity of the source - pub fn ident(&self) -> String { - match self { - Self::Contract(contract) => contract.name.safe_unwrap().name.to_owned(), - Self::Variable(var) => var.name.safe_unwrap().name.to_owned(), - Self::Event(event) => event.name.safe_unwrap().name.to_owned(), - Self::Error(error) => error.name.safe_unwrap().name.to_owned(), - Self::Struct(structure) => structure.name.safe_unwrap().name.to_owned(), - Self::Enum(enumerable) => enumerable.name.safe_unwrap().name.to_owned(), - Self::Function(func) => { - func.name.as_ref().map_or(func.ty.to_string(), |n| n.name.to_owned()) - } - Self::Type(ty) => ty.name.name.to_owned(), - } - } -} diff --git a/crates/doc/src/parser/mod.rs b/crates/doc/src/parser/mod.rs deleted file mode 100644 index 7206dda98c060..0000000000000 --- a/crates/doc/src/parser/mod.rs +++ /dev/null @@ -1,390 +0,0 @@ -//! The parser module. - -use forge_fmt::{FormatterConfig, Visitable, Visitor}; -use itertools::Itertools; -use solang_parser::{ - doccomment::{DocComment, parse_doccomments}, - pt::{ - Comment as SolangComment, EnumDefinition, ErrorDefinition, EventDefinition, - FunctionDefinition, Identifier, Loc, SourceUnit, SourceUnitPart, StructDefinition, - TypeDefinition, VariableDefinition, - }, -}; - -/// Parser error. -pub mod error; -use error::{ParserError, ParserResult}; - -/// Parser item. -mod item; -pub use item::{ParseItem, ParseSource}; - -/// Doc comment. -mod comment; -pub use comment::{Comment, CommentTag, Comments, CommentsRef}; - -/// The documentation parser. This type implements a [Visitor] trait. -/// -/// While walking the parse tree, [Parser] will collect relevant source items and corresponding -/// doc comments. The resulting [ParseItem]s can be accessed by calling [Parser::items]. -#[derive(Debug, Default)] -pub struct Parser { - /// Initial comments from solang parser. - comments: Vec, - /// Parser context. - context: ParserContext, - /// Parsed results. - items: Vec, - /// Source file. - source: String, - /// The formatter config. - fmt: FormatterConfig, -} - -/// [Parser] context. -#[derive(Debug, Default)] -struct ParserContext { - /// Current visited parent. - parent: Option, - /// Current start pointer for parsing doc comments. - doc_start_loc: usize, -} - -impl Parser { - /// Create a new instance of [Parser]. - pub fn new(comments: Vec, source: String) -> Self { - Self { comments, source, ..Default::default() } - } - - /// Set formatter config on the [Parser] - pub fn with_fmt(mut self, fmt: FormatterConfig) -> Self { - self.fmt = fmt; - self - } - - /// Return the parsed items. Consumes the parser. - pub fn items(self) -> Vec { - self.items - } - - /// Visit the children elements with parent context. - /// This function memoizes the previous parent, sets the context - /// to a new one and invokes a visit function. The context will be reset - /// to the previous parent at the end of the function. - fn with_parent( - &mut self, - mut parent: ParseItem, - mut visit: impl FnMut(&mut Self) -> ParserResult<()>, - ) -> ParserResult { - let curr = self.context.parent.take(); - self.context.parent = Some(parent); - visit(self)?; - parent = self.context.parent.take().unwrap(); - self.context.parent = curr; - Ok(parent) - } - - /// Adds a child element to the parent item if it exists. - /// Otherwise the element will be added to a top-level items collection. - /// Moves the doc comment pointer to the end location of the child element. - fn add_element_to_parent(&mut self, source: ParseSource, loc: Loc) -> ParserResult<()> { - let child = self.new_item(source, loc.start())?; - if let Some(parent) = self.context.parent.as_mut() { - parent.children.push(child); - } else { - self.items.push(child); - } - self.context.doc_start_loc = loc.end(); - Ok(()) - } - - /// Create new [ParseItem] with comments and formatted code. - fn new_item(&mut self, source: ParseSource, loc_start: usize) -> ParserResult { - let docs = self.parse_docs(loc_start)?; - ParseItem::new(source).with_comments(docs).with_code(&self.source, self.fmt.clone()) - } - - /// Parse the doc comments from the current start location. - fn parse_docs(&mut self, end: usize) -> ParserResult { - self.parse_docs_range(self.context.doc_start_loc, end) - } - - /// Parse doc comments from the within specified range. - fn parse_docs_range(&mut self, start: usize, end: usize) -> ParserResult { - let mut res = vec![]; - for comment in parse_doccomments(&self.comments, start, end) { - match comment { - DocComment::Line { comment } => res.push(comment), - DocComment::Block { comments } => res.extend(comments), - } - } - - // Filter out `@solidity` and empty tags - // See https://docs.soliditylang.org/en/v0.8.17/assembly.html#memory-safety - let res = res - .into_iter() - .filter(|c| c.tag.trim() != "solidity" && !c.tag.trim().is_empty()) - .collect_vec(); - Ok(res.into()) - } -} - -impl Visitor for Parser { - type Error = ParserError; - - fn visit_source_unit(&mut self, source_unit: &mut SourceUnit) -> ParserResult<()> { - for source in &mut source_unit.0 { - match source { - SourceUnitPart::ContractDefinition(def) => { - // Create new contract parse item. - let contract = - self.new_item(ParseSource::Contract(def.clone()), def.loc.start())?; - - // Move the doc pointer to the contract location start. - self.context.doc_start_loc = def.loc.start(); - - // Parse child elements with current contract as parent - let contract = self.with_parent(contract, |doc| { - def.parts - .iter_mut() - .map(|d| d.visit(doc)) - .collect::>>()?; - Ok(()) - })?; - - // Move the doc pointer to the contract location end. - self.context.doc_start_loc = def.loc.end(); - - // Add contract to the parsed items. - self.items.push(contract); - } - SourceUnitPart::FunctionDefinition(func) => self.visit_function(func)?, - SourceUnitPart::EventDefinition(event) => self.visit_event(event)?, - SourceUnitPart::ErrorDefinition(error) => self.visit_error(error)?, - SourceUnitPart::StructDefinition(structure) => self.visit_struct(structure)?, - SourceUnitPart::EnumDefinition(enumerable) => self.visit_enum(enumerable)?, - SourceUnitPart::VariableDefinition(var) => self.visit_var_definition(var)?, - SourceUnitPart::TypeDefinition(ty) => self.visit_type_definition(ty)?, - _ => {} - }; - } - - Ok(()) - } - - fn visit_enum(&mut self, enumerable: &mut EnumDefinition) -> ParserResult<()> { - self.add_element_to_parent(ParseSource::Enum(enumerable.clone()), enumerable.loc) - } - - fn visit_var_definition(&mut self, var: &mut VariableDefinition) -> ParserResult<()> { - self.add_element_to_parent(ParseSource::Variable(var.clone()), var.loc) - } - - fn visit_function(&mut self, func: &mut FunctionDefinition) -> ParserResult<()> { - // If the function parameter doesn't have a name, try to set it with - // `@custom:name` tag if any was provided - let mut start_loc = func.loc.start(); - for (loc, param) in &mut func.params { - if let Some(param) = param - && param.name.is_none() - { - let docs = self.parse_docs_range(start_loc, loc.end())?; - let name_tag = docs.iter().find(|c| c.tag == CommentTag::Custom("name".to_owned())); - if let Some(name_tag) = name_tag - && let Some(name) = name_tag.value.trim().split(' ').next() - { - param.name = Some(Identifier { loc: Loc::Implicit, name: name.to_owned() }) - } - } - start_loc = loc.end(); - } - - self.add_element_to_parent(ParseSource::Function(func.clone()), func.loc) - } - - fn visit_struct(&mut self, structure: &mut StructDefinition) -> ParserResult<()> { - self.add_element_to_parent(ParseSource::Struct(structure.clone()), structure.loc) - } - - fn visit_event(&mut self, event: &mut EventDefinition) -> ParserResult<()> { - self.add_element_to_parent(ParseSource::Event(event.clone()), event.loc) - } - - fn visit_error(&mut self, error: &mut ErrorDefinition) -> ParserResult<()> { - self.add_element_to_parent(ParseSource::Error(error.clone()), error.loc) - } - - fn visit_type_definition(&mut self, def: &mut TypeDefinition) -> ParserResult<()> { - self.add_element_to_parent(ParseSource::Type(def.clone()), def.loc) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use solang_parser::parse; - - #[inline] - fn parse_source(src: &str) -> Vec { - let (mut source, comments) = parse(src, 0).expect("failed to parse source"); - let mut doc = Parser::new(comments, src.to_owned()); - source.visit(&mut doc).expect("failed to visit source"); - doc.items() - } - - macro_rules! test_single_unit { - ($test:ident, $src:expr, $variant:ident $identity:expr) => { - #[test] - fn $test() { - let items = parse_source($src); - assert_eq!(items.len(), 1); - let item = items.first().unwrap(); - assert!(item.comments.is_empty()); - assert!(item.children.is_empty()); - assert_eq!(item.source.ident(), $identity); - assert!(matches!(item.source, ParseSource::$variant(_))); - } - }; - } - - #[test] - fn empty_source() { - assert_eq!(parse_source(""), vec![]); - } - - test_single_unit!(single_function, "function someFn() { }", Function "someFn"); - test_single_unit!(single_variable, "uint256 constant VALUE = 0;", Variable "VALUE"); - test_single_unit!(single_event, "event SomeEvent();", Event "SomeEvent"); - test_single_unit!(single_error, "error SomeError();", Error "SomeError"); - test_single_unit!(single_struct, "struct SomeStruct { }", Struct "SomeStruct"); - test_single_unit!(single_enum, "enum SomeEnum { SOME, OTHER }", Enum "SomeEnum"); - test_single_unit!(single_contract, "contract Contract { }", Contract "Contract"); - - #[test] - fn multiple_shallow_contracts() { - let items = parse_source( - r" - contract A { } - contract B { } - contract C { } - ", - ); - assert_eq!(items.len(), 3); - - let first_item = items.first().unwrap(); - assert!(matches!(first_item.source, ParseSource::Contract(_))); - assert_eq!(first_item.source.ident(), "A"); - - let first_item = items.get(1).unwrap(); - assert!(matches!(first_item.source, ParseSource::Contract(_))); - assert_eq!(first_item.source.ident(), "B"); - - let first_item = items.get(2).unwrap(); - assert!(matches!(first_item.source, ParseSource::Contract(_))); - assert_eq!(first_item.source.ident(), "C"); - } - - #[test] - fn contract_with_children_items() { - let items = parse_source( - r" - event TopLevelEvent(); - - contract Contract { - event ContractEvent(); - error ContractError(); - struct ContractStruct { } - enum ContractEnum { } - - uint256 constant CONTRACT_CONSTANT; - bool contractVar; - - function contractFunction(uint256) external returns (uint256) { - bool localVar; // must be ignored - } - } - ", - ); - - assert_eq!(items.len(), 2); - - let event = items.first().unwrap(); - assert!(event.comments.is_empty()); - assert!(event.children.is_empty()); - assert_eq!(event.source.ident(), "TopLevelEvent"); - assert!(matches!(event.source, ParseSource::Event(_))); - - let contract = items.get(1).unwrap(); - assert!(contract.comments.is_empty()); - assert_eq!(contract.children.len(), 7); - assert_eq!(contract.source.ident(), "Contract"); - assert!(matches!(contract.source, ParseSource::Contract(_))); - assert!(contract.children.iter().all(|ch| ch.children.is_empty())); - assert!(contract.children.iter().all(|ch| ch.comments.is_empty())); - } - - #[test] - fn contract_with_fallback() { - let items = parse_source( - r" - contract Contract { - fallback() external payable {} - } - ", - ); - - assert_eq!(items.len(), 1); - - let contract = items.first().unwrap(); - assert!(contract.comments.is_empty()); - assert_eq!(contract.children.len(), 1); - assert_eq!(contract.source.ident(), "Contract"); - assert!(matches!(contract.source, ParseSource::Contract(_))); - - let fallback = contract.children.first().unwrap(); - assert_eq!(fallback.source.ident(), "fallback"); - assert!(matches!(fallback.source, ParseSource::Function(_))); - } - - #[test] - fn contract_with_doc_comments() { - let items = parse_source( - r" - pragma solidity ^0.8.19; - /// @name Test - /// no tag - ///@notice Cool contract - /// @ dev This is not a dev tag - /** - * @dev line one - * line 2 - */ - contract Test { - /*** my function - i like whitespace - */ - function test() {} - } - ", - ); - - assert_eq!(items.len(), 1); - - let contract = items.first().unwrap(); - assert_eq!(contract.comments.len(), 2); - assert_eq!( - *contract.comments.first().unwrap(), - Comment::new(CommentTag::Notice, "Cool contract".to_owned()) - ); - assert_eq!( - *contract.comments.get(1).unwrap(), - Comment::new(CommentTag::Dev, "line one\nline 2".to_owned()) - ); - - let function = contract.children.first().unwrap(); - assert_eq!( - *function.comments.first().unwrap(), - Comment::new(CommentTag::Notice, "my function\ni like whitespace".to_owned()) - ); - } -} diff --git a/crates/doc/src/preprocessor/contract_inheritance.rs b/crates/doc/src/preprocessor/contract_inheritance.rs deleted file mode 100644 index 832d44e55fed9..0000000000000 --- a/crates/doc/src/preprocessor/contract_inheritance.rs +++ /dev/null @@ -1,69 +0,0 @@ -use super::{Preprocessor, PreprocessorId}; -use crate::{Document, ParseSource, PreprocessorOutput, document::DocumentContent}; -use alloy_primitives::map::HashMap; -use forge_fmt::solang_ext::SafeUnwrap; -use std::path::PathBuf; - -/// [ContractInheritance] preprocessor id. -pub const CONTRACT_INHERITANCE_ID: PreprocessorId = PreprocessorId("contract_inheritance"); - -/// The contract inheritance preprocessor. -/// -/// It matches the documents with inner [`ParseSource::Contract`](crate::ParseSource) elements, -/// iterates over their [Base](solang_parser::pt::Base)s and attempts -/// to link them with the paths of the other contract documents. -/// -/// This preprocessor writes to [Document]'s context. -#[derive(Debug, Default)] -pub struct ContractInheritance { - /// Whether to capture inherited contracts from libraries. - pub include_libraries: bool, -} - -impl Preprocessor for ContractInheritance { - fn id(&self) -> PreprocessorId { - CONTRACT_INHERITANCE_ID - } - - fn preprocess(&self, documents: Vec) -> Result, eyre::Error> { - for document in &documents { - if let DocumentContent::Single(ref item) = document.content - && let ParseSource::Contract(ref contract) = item.source - { - let mut links = HashMap::default(); - - // Attempt to match bases to other contracts - for base in &contract.base { - let base_ident = base.name.identifiers.last().unwrap().name.clone(); - if let Some(linked) = self.try_link_base(&base_ident, &documents) { - links.insert(base_ident, linked); - } - } - - if !links.is_empty() { - // Write to context - document.add_context(self.id(), PreprocessorOutput::ContractInheritance(links)); - } - } - } - - Ok(documents) - } -} - -impl ContractInheritance { - fn try_link_base(&self, base: &str, documents: &Vec) -> Option { - for candidate in documents { - if candidate.from_library && !self.include_libraries { - continue; - } - if let DocumentContent::Single(ref item) = candidate.content - && let ParseSource::Contract(ref contract) = item.source - && base == contract.name.safe_unwrap().name - { - return Some(candidate.target_path.clone()); - } - } - None - } -} diff --git a/crates/doc/src/preprocessor/deployments.rs b/crates/doc/src/preprocessor/deployments.rs deleted file mode 100644 index 6c6c362387b96..0000000000000 --- a/crates/doc/src/preprocessor/deployments.rs +++ /dev/null @@ -1,90 +0,0 @@ -use super::{Preprocessor, PreprocessorId}; -use crate::{Document, PreprocessorOutput}; -use alloy_primitives::Address; -use std::{ - fs, - path::{Path, PathBuf}, -}; - -/// [Deployments] preprocessor id. -pub const DEPLOYMENTS_ID: PreprocessorId = PreprocessorId("deployments"); - -/// The deployments preprocessor. -/// -/// This preprocessor writes to [Document]'s context. -#[derive(Debug)] -pub struct Deployments { - /// The project root. - pub root: PathBuf, - /// The deployments directory. - pub deployments: Option, -} - -/// A contract deployment. -#[derive(Clone, Debug, serde::Deserialize)] -pub struct Deployment { - /// The contract address - pub address: Address, - /// The network name - pub network: Option, -} - -impl Preprocessor for Deployments { - fn id(&self) -> PreprocessorId { - DEPLOYMENTS_ID - } - - fn preprocess(&self, documents: Vec) -> Result, eyre::Error> { - let deployments_dir = - self.root.join(self.deployments.as_deref().unwrap_or(Path::new("deployments"))); - - // Gather all networks from the deployments directory. - let networks = fs::read_dir(&deployments_dir)? - .map(|entry| { - let entry = entry?; - let path = entry.path(); - if entry.file_type()?.is_dir() { - entry - .file_name() - .into_string() - .map_err(|e| eyre::eyre!("failed to extract directory name: {e:?}")) - } else { - eyre::bail!("not a directory: {}", path.display()) - } - }) - .collect::, _>>()?; - - // Iterate over all documents to find any deployments. - for document in &documents { - let mut deployments = Vec::default(); - - // Iterate over all networks and check if there is a deployment for the given contract. - for network in &networks { - // Clone the item path of the document and change it from ".sol" -> ".json" - let mut item_path_clone = document.item_path.clone(); - item_path_clone.set_extension("json"); - - // Determine the path of the deployment artifact relative to the root directory. - let deployment_path = - deployments_dir.join(network).join(item_path_clone.file_name().ok_or_else( - || eyre::eyre!("Failed to extract file name from item path"), - )?); - - // If the deployment file for the given contract is found, add the deployment - // address to the document context. - let mut deployment: Deployment = - serde_json::from_str(&fs::read_to_string(deployment_path)?)?; - deployment.network = Some(network.clone()); - deployments.push(deployment); - } - - // If there are any deployments for the given contract, add them to the document - // context. - if !deployments.is_empty() { - document.add_context(self.id(), PreprocessorOutput::Deployments(deployments)); - } - } - - Ok(documents) - } -} diff --git a/crates/doc/src/preprocessor/git_source.rs b/crates/doc/src/preprocessor/git_source.rs deleted file mode 100644 index fcf910359de63..0000000000000 --- a/crates/doc/src/preprocessor/git_source.rs +++ /dev/null @@ -1,44 +0,0 @@ -use super::{Preprocessor, PreprocessorId}; -use crate::{Document, PreprocessorOutput}; -use std::path::PathBuf; - -/// [GitSource] preprocessor id. -pub const GIT_SOURCE_ID: PreprocessorId = PreprocessorId("git_source"); - -/// The git source preprocessor. -/// -/// This preprocessor writes to [Document]'s context. -#[derive(Debug)] -pub struct GitSource { - /// The project root. - pub root: PathBuf, - /// The current commit hash. - pub commit: Option, - /// The repository url. - pub repository: Option, -} - -impl Preprocessor for GitSource { - fn id(&self) -> PreprocessorId { - GIT_SOURCE_ID - } - - fn preprocess(&self, documents: Vec) -> Result, eyre::Error> { - if let Some(ref repo) = self.repository { - let repo = repo.trim_end_matches('/'); - let commit = self.commit.clone().unwrap_or("master".to_owned()); - for document in &documents { - if document.from_library { - continue; - } - let git_url = format!( - "{repo}/blob/{commit}/{}", - document.item_path.strip_prefix(&self.root)?.display() - ); - document.add_context(self.id(), PreprocessorOutput::GitSource(git_url)); - } - } - - Ok(documents) - } -} diff --git a/crates/doc/src/preprocessor/infer_hyperlinks.rs b/crates/doc/src/preprocessor/infer_hyperlinks.rs deleted file mode 100644 index 1477998642a82..0000000000000 --- a/crates/doc/src/preprocessor/infer_hyperlinks.rs +++ /dev/null @@ -1,333 +0,0 @@ -use super::{Preprocessor, PreprocessorId}; -use crate::{Comments, Document, ParseItem, ParseSource}; -use forge_fmt::solang_ext::SafeUnwrap; -use regex::{Captures, Match, Regex}; -use std::{ - borrow::Cow, - path::{Path, PathBuf}, - sync::LazyLock, -}; - -/// A regex that matches `{identifier-part}` placeholders -/// -/// Overloaded functions are referenced by including the exact function arguments in the `part` -/// section of the placeholder. -static RE_INLINE_LINK: LazyLock = LazyLock::new(|| { - Regex::new(r"(?m)(\{(?Pxref-)?(?P[a-zA-Z_][0-9a-zA-Z_]*)(-(?P[a-zA-Z_][0-9a-zA-Z_-]*))?}(\[(?P(.*?))\])?)").unwrap() -}); - -/// [InferInlineHyperlinks] preprocessor id. -pub const INFER_INLINE_HYPERLINKS_ID: PreprocessorId = PreprocessorId("infer inline hyperlinks"); - -/// The infer hyperlinks preprocessor tries to map @dev tags to referenced items -/// Traverses the documents and attempts to find referenced items -/// comments for dev comment tags. -/// -/// This preprocessor replaces inline links in comments with the links to the referenced items. -#[derive(Debug, Default)] -#[non_exhaustive] -pub struct InferInlineHyperlinks; - -impl Preprocessor for InferInlineHyperlinks { - fn id(&self) -> PreprocessorId { - INFER_INLINE_HYPERLINKS_ID - } - - fn preprocess(&self, mut documents: Vec) -> Result, eyre::Error> { - // traverse all comments and try to match inline links and replace with inline links for - // markdown - let mut docs = Vec::with_capacity(documents.len()); - while !documents.is_empty() { - let mut document = documents.remove(0); - let target_path = document.relative_output_path().to_path_buf(); - for idx in 0..document.content.len() { - let (mut comments, item_children_len) = { - let item = document.content.get_mut(idx).unwrap(); - let comments = std::mem::take(&mut item.comments); - let children = item.children.len(); - (comments, children) - }; - Self::inline_doc_links(&documents, &target_path, &mut comments, &document); - document.content.get_mut(idx).unwrap().comments = comments; - - // we also need to iterate over all child items - // This is a bit horrible but we need to traverse all items in all documents - for child_idx in 0..item_children_len { - let mut comments = { - let item = document.content.get_mut(idx).unwrap(); - - std::mem::take(&mut item.children[child_idx].comments) - }; - Self::inline_doc_links(&documents, &target_path, &mut comments, &document); - document.content.get_mut(idx).unwrap().children[child_idx].comments = comments; - } - } - - docs.push(document); - } - - Ok(docs) - } -} - -impl InferInlineHyperlinks { - /// Finds the first match for the given link. - /// - /// All items get their own section in the markdown file. - /// This section uses the identifier of the item: `#functionname` - /// - /// Note: the target path is the relative path to the markdown file. - fn find_match<'a>( - link: &InlineLink<'a>, - target_path: &Path, - items: impl Iterator, - ) -> Option> { - for item in items { - match &item.source { - ParseSource::Contract(contract) => { - let name = &contract.name.safe_unwrap().name; - if name == link.identifier { - if link.part.is_none() { - return Some(InlineLinkTarget::borrowed( - name, - target_path.to_path_buf(), - )); - } - // try to find the referenced item in the contract's children - return Self::find_match(link, target_path, item.children.iter()); - } - } - ParseSource::Function(fun) => { - // TODO: handle overloaded functions - // functions can be overloaded so we need to keep track of how many matches we - // have so we can match the correct one - if let Some(id) = &fun.name { - // Note: constructors don't have a name - if id.name == link.ref_name() { - return Some(InlineLinkTarget::borrowed( - &id.name, - target_path.to_path_buf(), - )); - } - } else if link.ref_name() == "constructor" { - return Some(InlineLinkTarget::borrowed( - "constructor", - target_path.to_path_buf(), - )); - } - } - ParseSource::Variable(_) => {} - ParseSource::Event(ev) => { - let ev_name = &ev.name.safe_unwrap().name; - if ev_name == link.ref_name() { - return Some(InlineLinkTarget::borrowed( - ev_name, - target_path.to_path_buf(), - )); - } - } - ParseSource::Error(err) => { - let err_name = &err.name.safe_unwrap().name; - if err_name == link.ref_name() { - return Some(InlineLinkTarget::borrowed( - err_name, - target_path.to_path_buf(), - )); - } - } - ParseSource::Struct(structdef) => { - let struct_name = &structdef.name.safe_unwrap().name; - if struct_name == link.ref_name() { - return Some(InlineLinkTarget::borrowed( - struct_name, - target_path.to_path_buf(), - )); - } - } - ParseSource::Enum(_) => {} - ParseSource::Type(_) => {} - } - } - - None - } - - /// Attempts to convert inline links to markdown links. - fn inline_doc_links( - documents: &[Document], - target_path: &Path, - comments: &mut Comments, - parent: &Document, - ) { - // loop over all comments in the item - for comment in comments.iter_mut() { - let val = comment.value.clone(); - // replace all links with inline markdown links - for link in InlineLink::captures(val.as_str()) { - let target = if link.is_external() { - // find in all documents - documents.iter().find_map(|doc| { - Self::find_match( - &link, - doc.relative_output_path(), - doc.content.iter_items().flat_map(|item| { - Some(item).into_iter().chain(item.children.iter()) - }), - ) - }) - } else { - // find matches in the document - Self::find_match( - &link, - target_path, - parent - .content - .iter_items() - .flat_map(|item| Some(item).into_iter().chain(item.children.iter())), - ) - }; - if let Some(target) = target { - let display_value = link.markdown_link_display_value(); - let markdown_link = format!("[{display_value}]({target})"); - // replace the link with the markdown link - comment.value = - comment.value.as_str().replacen(link.as_str(), markdown_link.as_str(), 1); - } - } - } - } -} - -struct InlineLinkTarget<'a> { - section: Cow<'a, str>, - target_path: PathBuf, -} - -impl<'a> InlineLinkTarget<'a> { - fn borrowed(section: &'a str, target_path: PathBuf) -> Self { - Self { section: Cow::Borrowed(section), target_path } - } -} - -impl std::fmt::Display for InlineLinkTarget<'_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - // NOTE: the url should be absolute for markdown and section names are lowercase - write!(f, "/{}#{}", self.target_path.display(), self.section.to_lowercase()) - } -} - -/// A parsed link to an item. -#[derive(Debug)] -struct InlineLink<'a> { - outer: Match<'a>, - identifier: &'a str, - part: Option<&'a str>, - link: Option<&'a str>, -} - -impl<'a> InlineLink<'a> { - fn from_capture(cap: Captures<'a>) -> Option { - Some(Self { - outer: cap.get(1)?, - identifier: cap.name("identifier")?.as_str(), - part: cap.name("part").map(|m| m.as_str()), - link: cap.name("link").map(|m| m.as_str()), - }) - } - - fn captures(s: &'a str) -> impl Iterator + 'a { - RE_INLINE_LINK.captures(s).map(Self::from_capture).into_iter().flatten() - } - - /// Parses the first inline link. - #[allow(unused)] - fn capture(s: &'a str) -> Option { - let cap = RE_INLINE_LINK.captures(s)?; - Self::from_capture(cap) - } - - /// Returns the name of the link - fn markdown_link_display_value(&self) -> Cow<'_, str> { - if let Some(link) = self.link { - Cow::Borrowed(link) - } else if let Some(part) = self.part { - Cow::Owned(format!("{}-{}", self.identifier, part)) - } else { - Cow::Borrowed(self.identifier) - } - } - - /// Returns the name of the referenced item. - fn ref_name(&self) -> &str { - self.exact_identifier().split('-').next().unwrap() - } - - fn exact_identifier(&self) -> &str { - let mut name = self.identifier; - if let Some(part) = self.part { - name = part; - } - name - } - - /// Returns the name of the referenced item and its arguments, if any. - /// - /// Eg: `safeMint-address-uint256-` returns `("safeMint", ["address", "uint256"])` - #[expect(unused)] - fn ref_name_exact(&self) -> (&str, impl Iterator + '_) { - let identifier = self.exact_identifier(); - let mut iter = identifier.split('-'); - (iter.next().unwrap(), iter.filter(|s| !s.is_empty())) - } - - /// Returns the content of the matched link. - fn as_str(&self) -> &str { - self.outer.as_str() - } - - /// Returns true if the link is external. - fn is_external(&self) -> bool { - self.part.is_some() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_inline_links() { - let s = " {IERC165-supportsInterface} "; - let cap = RE_INLINE_LINK.captures(s).unwrap(); - - let identifier = cap.name("identifier").unwrap().as_str(); - assert_eq!(identifier, "IERC165"); - let part = cap.name("part").unwrap().as_str(); - assert_eq!(part, "supportsInterface"); - - let s = " {supportsInterface} "; - let cap = RE_INLINE_LINK.captures(s).unwrap(); - - let identifier = cap.name("identifier").unwrap().as_str(); - assert_eq!(identifier, "supportsInterface"); - - let s = "{xref-ERC721-_safeMint-address-uint256-}"; - let cap = RE_INLINE_LINK.captures(s).unwrap(); - - let identifier = cap.name("identifier").unwrap().as_str(); - assert_eq!(identifier, "ERC721"); - let identifier = cap.name("xref").unwrap().as_str(); - assert_eq!(identifier, "xref-"); - let identifier = cap.name("part").unwrap().as_str(); - assert_eq!(identifier, "_safeMint-address-uint256-"); - - let link = InlineLink::capture(s).unwrap(); - assert_eq!(link.ref_name(), "_safeMint"); - assert_eq!(link.as_str(), "{xref-ERC721-_safeMint-address-uint256-}"); - - let s = "{xref-ERC721-_safeMint-address-uint256-}[`Named link`]"; - let link = InlineLink::capture(s).unwrap(); - assert_eq!(link.link, Some("`Named link`")); - assert_eq!(link.markdown_link_display_value(), "`Named link`"); - } -} diff --git a/crates/doc/src/preprocessor/inheritdoc.rs b/crates/doc/src/preprocessor/inheritdoc.rs deleted file mode 100644 index fd70483824452..0000000000000 --- a/crates/doc/src/preprocessor/inheritdoc.rs +++ /dev/null @@ -1,91 +0,0 @@ -use super::{Preprocessor, PreprocessorId}; -use crate::{ - Comments, Document, ParseItem, ParseSource, PreprocessorOutput, document::DocumentContent, -}; -use alloy_primitives::map::HashMap; -use forge_fmt::solang_ext::SafeUnwrap; - -/// [`Inheritdoc`] preprocessor ID. -pub const INHERITDOC_ID: PreprocessorId = PreprocessorId("inheritdoc"); - -/// The inheritdoc preprocessor. -/// Traverses the documents and attempts to find inherited -/// comments for inheritdoc comment tags. -/// -/// This preprocessor writes to [Document]'s context. -#[derive(Debug, Default)] -#[non_exhaustive] -pub struct Inheritdoc; - -impl Preprocessor for Inheritdoc { - fn id(&self) -> PreprocessorId { - INHERITDOC_ID - } - - fn preprocess(&self, documents: Vec) -> Result, eyre::Error> { - for document in &documents { - if let DocumentContent::Single(ref item) = document.content { - let context = self.visit_item(item, &documents); - if !context.is_empty() { - document.add_context(self.id(), PreprocessorOutput::Inheritdoc(context)); - } - } - } - - Ok(documents) - } -} - -impl Inheritdoc { - fn visit_item(&self, item: &ParseItem, documents: &Vec) -> HashMap { - let mut context = HashMap::default(); - - // Match for the item first. - let matched = item - .comments - .find_inheritdoc_base() - .and_then(|base| self.try_match_inheritdoc(base, &item.source, documents)); - if let Some((key, comments)) = matched { - context.insert(key, comments); - } - - // Match item's children. - for ch in &item.children { - let matched = ch - .comments - .find_inheritdoc_base() - .and_then(|base| self.try_match_inheritdoc(base, &ch.source, documents)); - if let Some((key, comments)) = matched { - context.insert(key, comments); - } - } - - context - } - - fn try_match_inheritdoc( - &self, - base: &str, - source: &ParseSource, - documents: &Vec, - ) -> Option<(String, Comments)> { - for candidate in documents { - if let DocumentContent::Single(ref item) = candidate.content - && let ParseSource::Contract(ref contract) = item.source - && base == contract.name.safe_unwrap().name - { - // Not matched for the contract because it's a noop - // https://docs.soliditylang.org/en/v0.8.17/natspec-format.html#tags - - for children in &item.children { - // TODO: improve matching logic - if source.ident() == children.source.ident() { - let key = format!("{}.{}", base, source.ident()); - return Some((key, children.comments.clone())); - } - } - } - } - None - } -} diff --git a/crates/doc/src/preprocessor/mod.rs b/crates/doc/src/preprocessor/mod.rs deleted file mode 100644 index e51bf3cbba5de..0000000000000 --- a/crates/doc/src/preprocessor/mod.rs +++ /dev/null @@ -1,54 +0,0 @@ -//! Module containing documentation preprocessors. - -use crate::{Comments, Document}; -use alloy_primitives::map::HashMap; -use std::{fmt::Debug, path::PathBuf}; - -mod contract_inheritance; -pub use contract_inheritance::{CONTRACT_INHERITANCE_ID, ContractInheritance}; - -mod inheritdoc; -pub use inheritdoc::{INHERITDOC_ID, Inheritdoc}; - -mod infer_hyperlinks; -pub use infer_hyperlinks::{INFER_INLINE_HYPERLINKS_ID, InferInlineHyperlinks}; - -mod git_source; -pub use git_source::{GIT_SOURCE_ID, GitSource}; - -mod deployments; -pub use deployments::{DEPLOYMENTS_ID, Deployment, Deployments}; - -/// The preprocessor id. -#[derive(Debug, PartialEq, Eq, Hash)] -pub struct PreprocessorId(&'static str); - -/// Preprocessor output. -/// Wraps all existing preprocessor outputs -/// in a single abstraction. -#[derive(Clone, Debug)] -pub enum PreprocessorOutput { - /// The contract inheritance output. - /// The map of contract base idents to the path of the base contract. - ContractInheritance(HashMap), - /// The inheritdoc output. - /// The map of inherited item keys to their comments. - Inheritdoc(HashMap), - /// The git source output. - /// The git url of the item path. - GitSource(String), - /// The deployments output. - /// The deployment address of the item path. - Deployments(Vec), -} - -/// Trait for preprocessing and/or modifying existing documents -/// before writing the to disk. -pub trait Preprocessor: Debug { - /// The id of the preprocessor. - /// Used to write data to document context. - fn id(&self) -> PreprocessorId; - - /// Preprocess the collection of documents - fn preprocess(&self, documents: Vec) -> Result, eyre::Error>; -} diff --git a/crates/doc/src/render.rs b/crates/doc/src/render.rs new file mode 100644 index 0000000000000..ac5484208b9a4 --- /dev/null +++ b/crates/doc/src/render.rs @@ -0,0 +1,1417 @@ +//! Rendering: `solar` AST -> vocs MDX. + +use crate::{ + hir_ext::{self, NameToPage, clean_block_doc_content}, + markdown::{code_regions, logical_lines, neutralize_esm}, + utils::{Deployment, contract_kind_str, page_path}, +}; +use foundry_common::sh_warn; +use markdown::{ParseOptions, mdast::Node, to_mdast}; +use solar::{ + ast::{ + CommentKind, ContractKind, DocComments, FunctionKind, Item, ItemContract, ItemFunction, + ItemKind, NatSpecKind, ParameterList, SourceUnit, Span, VariableDefinition, + }, + interface::{ + Ident, + source_map::{FileName, SourceFile}, + }, + sema::{Gcx, hir}, +}; +use std::{ + fmt::Write as _, + ops::Range, + path::{Path, PathBuf}, + sync::Arc, +}; + +// ── rendering context ──────────────────────────────────────────────────────── + +struct Ctx<'a> { + src_text: &'a str, + src_start: usize, +} + +impl<'a> Ctx<'a> { + fn snippet(&self, span: Span) -> &'a str { + let lo = span.lo().to_usize().saturating_sub(self.src_start); + let hi = span.hi().to_usize().saturating_sub(self.src_start); + let lo = lo.min(self.src_text.len()); + let hi = hi.min(self.src_text.len()); + &self.src_text[lo..hi] + } + + fn dedented_snippet(&self, span: Span) -> String { + dedent(self.snippet(span)) + } +} + +/// The link environment for one page, including its local member anchors. +#[derive(Clone, Copy)] +struct Links<'a> { + names: &'a NameToPage, + page: &'a Path, + local: Option<&'a hir_ext::LocalMembers>, +} + +impl Links<'_> { + fn prose(self, text: &str) -> String { + hir_ext::replace_inline_links(text, self.names, self.page, self.local) + } + + fn description(self, text: &str) -> String { + replace_description_links(text, self.names, self.page, self.local) + } +} + +// ── contract ───────────────────────────────────────────────────────────────── + +#[allow(clippy::too_many_arguments)] +fn render_contract<'ast, 'gcx>( + c: &'ast ItemContract<'ast>, + docs: &'ast DocComments<'ast>, + ctx: &Ctx<'_>, + gcx: Gcx<'gcx>, + hir_id: Option, + name_to_page: &NameToPage, + page_path: &Path, + git_url: Option<&str>, + deployments: &[Deployment], +) -> String { + let name = c.name.as_str(); + + // Index the members rendered as headings on this page so `{member}` and + // `{Contract-member}` self-references resolve to anchor-only links. + let mut local = hir_id.map_or_else( + || hir_ext::LocalMembers::new(name), + |id| hir_ext::LocalMembers::for_contract(gcx, id, name_to_page), + ); + for member in c.body.iter() { + match &member.kind { + ItemKind::Variable(v) => { + if let Some(n) = v.name { + local.insert(n.as_str()); + } + } + ItemKind::Function(f) => { + local.insert(&function_heading(f)); + if let Some(anchor) = function_signature_anchor(f, ctx) { + local.insert_anchor(anchor); + } + } + ItemKind::Event(e) => local.insert(e.name.as_str()), + ItemKind::Error(e) => local.insert(e.name.as_str()), + ItemKind::Struct(s) => local.insert(s.name.as_str()), + ItemKind::Enum(e) => local.insert(e.name.as_str()), + ItemKind::Udvt(u) => local.insert(u.name.as_str()), + _ => {} + } + } + let links = Links { names: name_to_page, page: page_path, local: Some(&local) }; + + let comments = collect_comments(docs, links); + let mut out = write_page_header(name, first_notice(&comments), git_url); + write_deployments_table(&mut out, deployments); + + // inheritance links. + if let Some(id) = hir_id + && let Some(inherits) = hir_ext::inheritance_links(gcx, id, name_to_page, page_path) + { + writeln!(out, "{inherits}").unwrap(); + writeln!(out).unwrap(); + } + + write_comment_block(&mut out, &comments); + + // Group members. + let mut constants: Vec<(Span, &VariableDefinition<'_>, &DocComments<'_>)> = Vec::new(); + let mut state_vars: Vec<(Span, &VariableDefinition<'_>, &DocComments<'_>)> = Vec::new(); + let mut functions: Vec<(Span, &ItemFunction<'_>, &DocComments<'_>)> = Vec::new(); + let mut events = Vec::new(); + let mut errors = Vec::new(); + let mut structs = Vec::new(); + let mut enums = Vec::new(); + let mut udvts = Vec::new(); + + for member in c.body.iter() { + let s = member.span; + match &member.kind { + ItemKind::Variable(v) => { + // constants and immutables get their own section. + if v.mutability.is_some_and(|m| m.is_constant() || m.is_immutable()) { + constants.push((s, v, &member.docs)); + } else { + state_vars.push((s, v, &member.docs)); + } + } + ItemKind::Function(f) => functions.push((s, f, &member.docs)), + ItemKind::Event(_) => events.push(member), + ItemKind::Error(_) => errors.push(member), + ItemKind::Struct(_) => structs.push(member), + ItemKind::Enum(_) => enums.push(member), + ItemKind::Udvt(_) => udvts.push(member), + _ => {} + } + } + + let write_vars = + |out: &mut String, vars: &[(Span, &VariableDefinition<'_>, &DocComments<'_>)]| { + for (span, v, docs) in vars { + let vname = v.name.map(|n| n.as_str().to_string()).unwrap_or_default(); + writeln!(out, "### {vname}").unwrap(); + writeln!(out).unwrap(); + let mut c = collect_comments(docs, links); + // Explicit `@inheritdoc` merges into a partial local doc; implicit + // inheritance only runs when the variable has no local NatSpec at all. + let vid = hir_id.and_then(|cid| { + gcx.hir.contract(cid).items.iter().find_map(|&item| match item { + hir::ItemId::Variable(id) if gcx.hir.variable(id).span == *span => Some(id), + _ => None, + }) + }); + let inherited = vid.and_then(|id| { + let explicit = has_inheritdoc(docs); + (explicit || !has_local_natspec(docs)) + .then(|| hir_ext::natspec_doc(gcx, id.into(), !explicit)) + .flatten() + }); + let sanitize = |s: &str| links.prose(s); + let sanitize_description = |s: &str| links.description(s); + if let Some(base_doc) = &inherited { + c.inherit_descriptions(base_doc, &sanitize_description); + } + write_comment_block(out, &c); + write_code_block(out, &ctx.dedented_snippet(*span)); + // When the documentation is inherited from the variable's generated getter, + // render the getter's parameter and return signature instead of the declared + // (possibly mapping) type. + let getter_doc = inherited.as_ref().filter(|d| !d.getter_returns.is_empty()); + if let Some(base_doc) = getter_doc { + write_getter_table(out, "Parameters", &base_doc.getter_params, &sanitize); + write_getter_table(out, "Returns", &base_doc.getter_returns, &sanitize); + } else if !c.returns.is_empty() { + let ty = format!("`{}`", ctx.snippet(v.ty.span).trim()); + write_signature_table_header(out, "Returns"); + for (name, desc) in &c.returns { + // Solar parses `@return ` where the first word + // becomes `name` and the rest becomes `desc`. For unnamed returns the + // first word is actually part of the description, so recombine them. + let full_desc = + if desc.is_empty() { name.clone() } else { format!("{name} {desc}") }; + let desc_cell = escape_table_cell(&full_desc); + writeln!(out, "| <none> | {ty} | {desc_cell} |").unwrap(); + } + writeln!(out).unwrap(); + } + } + }; + + for (heading, vars) in [("Constants", constants), ("State Variables", state_vars)] { + if !vars.is_empty() { + writeln!(out, "## {heading}\n").unwrap(); + write_vars(&mut out, &vars); + } + } + + if !functions.is_empty() { + writeln!(out, "## Functions").unwrap(); + writeln!(out).unwrap(); + for (span, f, docs) in &functions { + let fn_name = match f.kind { + FunctionKind::Constructor => None, + FunctionKind::Fallback => Some("fallback".to_string()), + FunctionKind::Receive => Some("receive".to_string()), + FunctionKind::Function | FunctionKind::Modifier => { + f.header.name.map(|name| name.as_str().to_string()) + } + }; + // Explicit `@inheritdoc` merges into a partial local doc; implicit inheritance + // only runs when the function has no local NatSpec at all. + let inherited = fn_name.as_deref().and_then(|fname| { + let fid = hir_id.and_then(|cid| { + gcx.hir.contract(cid).items.iter().find_map(|&item| match item { + hir::ItemId::Function(id) if gcx.hir.function(id).span == *span => Some(id), + _ => None, + }) + }); + match (has_inheritdoc(docs), has_local_natspec(docs)) { + (true, _) => { + if hir_id.is_some() && fid.is_none() { + let _ = sh_warn!( + "forge doc: failed to find HIR function for `{}.{fname}` while resolving @inheritdoc", + c.name + ); + } + fid.and_then(|id| hir_ext::natspec_doc(gcx, id.into(), false)) + } + (false, false) => + fid.and_then(|id| hir_ext::natspec_doc(gcx, id.into(), true)), + (false, true) => None, + } + }); + render_function_section(&mut out, *span, f, docs, ctx, links, inherited.as_ref()); + } + } + + for (heading, items) in [ + ("Events", events), + ("Errors", errors), + ("Structs", structs), + ("Enums", enums), + ("Custom Types", udvts), + ] { + if !items.is_empty() { + writeln!(out, "## {heading}\n").unwrap(); + for item in items { + writeln!(out, "### {}\n", item.name().unwrap()).unwrap(); + let comments = collect_comments(&item.docs, links); + write_item_body(&mut out, item, &comments, ctx); + } + } + } + + out +} + +// ── free functions ──────────────────────────────────────────────────────────── + +fn render_free_functions( + name: &str, + overloads: &[(Span, &ItemFunction<'_>, &DocComments<'_>)], + ctx: &Ctx<'_>, + links: Links<'_>, + git_url: Option<&str>, +) -> String { + let title = if name.is_empty() { "function" } else { name }; + let first_comments = collect_comments(overloads[0].2, links); + let mut out = write_page_header(title, first_notice(&first_comments), git_url); + for (span, f, docs) in overloads { + render_function_section(&mut out, *span, f, docs, ctx, links, None); + } + out +} + +// ── constants ───────────────────────────────────────────────────────────────── + +fn render_constants( + stem: &str, + vars: &[(Span, &VariableDefinition<'_>, &DocComments<'_>)], + ctx: &Ctx<'_>, + links: Links<'_>, + git_url: Option<&str>, +) -> String { + let title = format!("{stem} Constants"); + let mut out = write_page_header(&title, None, git_url); + for (span, v, docs) in vars { + let name = v.name.map(|n| n.as_str().to_string()).unwrap_or_else(|| "_".to_string()); + writeln!(out, "## {name}").unwrap(); + writeln!(out).unwrap(); + let c = collect_comments(docs, links); + write_comment_block(&mut out, &c); + write_code_block(&mut out, &ctx.dedented_snippet(*span)); + } + out +} + +// ── standalone items ────────────────────────────────────────────────────────── + +/// Render the common body of a struct, enum, event, error, or value type. +fn write_item_body(out: &mut String, item: &Item<'_>, comments: &CommentData, ctx: &Ctx<'_>) { + write_comment_block(out, comments); + let mut snippet = ctx.dedented_snippet(item.span); + if matches!(item.kind, ItemKind::Udvt(_)) { + snippet.push(';'); + } + write_code_block(out, &snippet); + match &item.kind { + ItemKind::Struct(s) => write_struct_properties_table(out, s.fields, comments, ctx), + ItemKind::Enum(e) => write_enum_variants_table(out, e.variants, comments), + ItemKind::Event(e) => { + write_param_table(out, "Parameters", &e.parameters, comments, None, ctx) + } + ItemKind::Error(e) => { + write_param_table(out, "Parameters", &e.parameters, comments, None, ctx) + } + _ => {} + } +} + +// ── function section ────────────────────────────────────────────────────────── +fn render_function_section( + out: &mut String, + span: Span, + f: &ItemFunction<'_>, + docs: &DocComments<'_>, + ctx: &Ctx<'_>, + links: Links<'_>, + inherited: Option<&hir_ext::NatSpecDoc>, +) { + let heading = function_heading(f); + if let Some(anchor) = function_signature_anchor(f, ctx) { + writeln!(out, "").unwrap(); + writeln!(out).unwrap(); + } + writeln!(out, "### {heading}").unwrap(); + writeln!(out).unwrap(); + let mut c = collect_comments(docs, links); + let mut inherited_params = None; + // Merge inherited natspec for missing tags. + if let Some(inherited) = inherited { + let sanitize = |s: &str| links.prose(s); + let sanitize_description = |s: &str| links.description(s); + c.inherit_descriptions(inherited, &sanitize_description); + if c.params.is_empty() { + let params = inherited.params.iter().map(|desc| sanitize(desc)).collect::>(); + for (index, desc) in params.iter().enumerate() { + if let Some(name) = f.header.parameters.get(index).and_then(|param| param.name) { + c.params.push((name.as_str().to_string(), desc.clone())); + } + } + inherited_params = Some(params); + } + if c.returns.is_empty() { + for (index, desc) in inherited.returns.iter().enumerate() { + let name = f + .header + .returns + .as_ref() + .and_then(|returns| returns.get(index)) + .and_then(|return_| return_.name) + .map(|name| name.as_str().to_string()) + .unwrap_or_default(); + c.returns.push((name, sanitize(desc))); + } + } + } + write_comment_block(out, &c); + let hspan = if f.header.span.lo() == f.header.span.hi() { span } else { f.header.span }; + let snippet = ctx.dedented_snippet(hspan); + write_code_block(out, &format!("{snippet};")); + write_param_table( + out, + "Parameters", + &f.header.parameters, + &c, + inherited_params.as_deref(), + ctx, + ); + if let Some(returns) = &f.header.returns { + write_param_table(out, "Returns", returns, &c, None, ctx); + } +} + +fn function_heading(f: &ItemFunction<'_>) -> String { + match f.kind { + FunctionKind::Constructor => "constructor".to_string(), + FunctionKind::Fallback => "fallback".to_string(), + FunctionKind::Receive => "receive".to_string(), + FunctionKind::Function | FunctionKind::Modifier => { + f.header.name.map(|n| n.as_str().to_string()).unwrap_or_else(|| "function".to_string()) + } + } +} + +fn function_signature_anchor(f: &ItemFunction<'_>, ctx: &Ctx<'_>) -> Option { + let name = match f.kind { + FunctionKind::Constructor => "constructor".to_string(), + FunctionKind::Fallback => "fallback".to_string(), + FunctionKind::Receive => "receive".to_string(), + FunctionKind::Function | FunctionKind::Modifier => f.header.name?.as_str().to_string(), + }; + let params = f + .header + .parameters + .vars + .iter() + .map(|v| ctx.snippet(v.ty.span).trim().to_string()) + .collect::>(); + + Some(hir_ext::function_signature_anchor(&name, ¶ms)) +} + +// ── natspec comment collection ──────────────────────────────────────────────── + +#[derive(Clone, Copy, PartialEq, Eq)] +enum DescKind { + Notice, + Dev, +} + +struct Description { + kind: DescKind, + content: String, +} + +#[derive(Default)] +struct CommentData { + titles: Vec, + authors: Vec, + /// All notice/dev text in source order, tagged with their kind, with continuation + /// lines joined to their parent. Used for rendering to preserve correct paragraph + /// ordering and to italicize `@dev` paragraphs as a whole. + descriptions: Vec, + params: Vec<(String, String)>, + returns: Vec<(String, String)>, + customs: Vec<(String, String)>, + /// `@custom:name ` values, used to fill in unnamed function parameters. + unnamed_param_names: Vec, +} + +impl CommentData { + /// Fill missing notice/dev tags, keeping inherited notices before local descriptions. + fn inherit_descriptions( + &mut self, + inherited: &hir_ext::NatSpecDoc, + sanitize: &impl Fn(&str) -> String, + ) { + if !self.descriptions.iter().any(|d| d.kind == DescKind::Notice) { + let mut descriptions = inherited + .notices + .iter() + .map(|s| Description { kind: DescKind::Notice, content: sanitize(s) }) + .collect::>(); + descriptions.append(&mut self.descriptions); + self.descriptions = descriptions; + } + if !self.descriptions.iter().any(|d| d.kind == DescKind::Dev) { + self.descriptions.extend( + inherited + .devs + .iter() + .map(|s| Description { kind: DescKind::Dev, content: sanitize(s) }), + ); + } + } +} + +/// Collect natspec from doc comments, applying inline link replacement. +/// +/// Solar emits each `///` line as a separate `DocComment`. Lines without a `@` tag become +/// synthetic `@notice` items. We join adjacent synthetic items to the previous rendered section +/// so multi-line natspec tags form a single coherent block in source order. +fn collect_comments(docs: &DocComments<'_>, links: Links<'_>) -> CommentData { + let mut data = CommentData::default(); + + // Tags that are not user-facing natspec; do not warn on these. + const FILTERED_CUSTOM: &[&str] = &["solidity", "src", "use-src", "ast-id"]; + // Recognised natspec custom tags (mirror legacy behaviour). + const KNOWN_CUSTOM: &[&str] = &["name"]; + + // Track whether the previous DocComment was blank (empty natspec), which signals a + // paragraph break even between continuation lines. + let mut prev_doc_was_blank = false; + #[derive(Clone, Copy)] + enum LastSection { + Desc, // notice or dev (both go through descriptions) + Param, + Return, + } + let mut last_section: Option = None; + for doc in docs.iter() { + if doc.natspec.is_empty() { + prev_doc_was_blank = true; + continue; + } + + for item in doc.natspec.iter() { + let raw = doc.natspec_content(item); + // For /** */ block comments Solar preserves raw ` * ` line decorations inside the + // content range. Strip them so multi-line content renders cleanly. + let raw: &str = + if doc.kind == CommentKind::Block { &clean_block_doc_content(raw) } else { raw }; + + // Solar represents an untagged doc comment as a synthetic notice whose span is the + // whole comment. Treat it as a continuation when it follows a rendered section; + // this also joins adjacent line and block doc comments before fence detection. + let is_continuation = matches!(item.kind, NatSpecKind::Notice) && item.span == doc.span; + + let trimmed = raw.trim(); + if trimmed.is_empty() { + prev_doc_was_blank = true; + continue; + } + + // Keep descriptions raw until continuation lines have been joined. Only complete, + // standalone descriptions can safely identify fenced code blocks. + let content = trimmed.to_string(); + + if is_continuation && !prev_doc_was_blank { + let appended = match last_section { + Some(LastSection::Desc) => data.descriptions.last_mut().map(|d| &mut d.content), + Some(LastSection::Param) => data.params.last_mut().map(|(_, d)| d), + Some(LastSection::Return) => data.returns.last_mut().map(|(_, d)| d), + None => None, + }; + if let Some(last) = appended { + last.push('\n'); + last.push_str(&content); + prev_doc_was_blank = false; + continue; + } + } + + prev_doc_was_blank = false; + + match item.kind { + NatSpecKind::Title => data.titles.push(content), + NatSpecKind::Author => data.authors.push(content), + NatSpecKind::Notice => { + data.descriptions.push(Description { kind: DescKind::Notice, content }); + last_section = Some(LastSection::Desc); + } + NatSpecKind::Dev => { + data.descriptions.push(Description { kind: DescKind::Dev, content }); + last_section = Some(LastSection::Desc); + } + NatSpecKind::Param { name } => { + data.params.push((name.as_str().to_string(), content)); + last_section = Some(LastSection::Param); + } + NatSpecKind::Return { name } => { + data.returns.push(( + name.map(|name| name.as_str().to_string()).unwrap_or_default(), + content, + )); + last_section = Some(LastSection::Return); + } + NatSpecKind::Inheritdoc { .. } => {} // resolved separately via HIR + NatSpecKind::Custom { name } => { + let tag = name.as_str(); + if FILTERED_CUSTOM.contains(&tag) { + // Silently ignored. + } else if tag == "name" { + // `@custom:name ` -> unnamed param name (legacy parity). + let content = links.prose(&content); + if let Some(first) = content.split_whitespace().next() { + data.unnamed_param_names.push(first.to_string()); + } + } else { + // unknown-natspec-tag warning. + if !KNOWN_CUSTOM.contains(&tag) && !is_known_custom_tag(tag) { + warn!("unknown natspec custom tag: @custom:{tag}"); + } + data.customs.push((tag.to_string(), content)); + } + } + NatSpecKind::Internal { .. } => {} + } + } + } + + for content in data + .titles + .iter_mut() + .chain(&mut data.authors) + .chain(data.customs.iter_mut().map(|(_, content)| content)) + { + *content = sanitize_description_prose(content, links.names, links.page, links.local); + } + for (_, content) in data.params.iter_mut().chain(&mut data.returns) { + *content = links.prose(content); + } + for description in &mut data.descriptions { + description.content = links.description(&description.content); + } + + data +} + +/// Returns true if `tag` looks like a generally-recognised natspec custom tag. +/// +/// We accept any non-empty alphanumeric/dash identifier as "known enough" not +/// to warn, only obviously malformed tags trigger the warning channel. +fn is_known_custom_tag(tag: &str) -> bool { + !tag.is_empty() && tag.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') +} + +/// Returns the base contract name from `@inheritdoc Base`, or `None`. +/// Whether the declaration carries any local NatSpec item other than `@inheritdoc`. Solidity +/// only auto-inherits documentation for a member with none, so implicit inheritance is gated +/// on this being false; a local `@custom:*`, `@title` or `@author` counts as local +/// documentation just like `@notice`/`@dev`/`@param`/`@return`. +fn has_local_natspec(docs: &DocComments<'_>) -> bool { + docs.iter().any(|doc| { + doc.natspec.iter().any(|item| !matches!(item.kind, NatSpecKind::Inheritdoc { .. })) + }) +} + +/// Render a getter signature table (`Parameters` or `Returns`) from its inherited rows. +fn write_getter_table( + out: &mut String, + heading: &str, + fields: &[hir_ext::GetterField], + sanitize: &impl Fn(&str) -> String, +) { + if fields.is_empty() { + return; + } + write_signature_table_header(out, heading); + for field in fields { + let name = field + .name + .as_deref() + .map(escape_table_cell) + .unwrap_or_else(|| "<none>".to_string()); + let ty = escape_table_cell(&field.ty); + let desc = escape_table_cell(&sanitize(&field.description)); + writeln!(out, "| {name} | `{ty}` | {desc} |").unwrap(); + } + writeln!(out).unwrap(); +} + +fn has_inheritdoc(docs: &DocComments<'_>) -> bool { + docs.iter() + .flat_map(|doc| doc.natspec.iter()) + .any(|item| matches!(item.kind, NatSpecKind::Inheritdoc { .. })) +} + +fn first_notice(data: &CommentData) -> Option<&str> { + data.descriptions + .iter() + .find(|description| description.kind == DescKind::Notice) + .map(|description| description.content.as_str()) +} + +// ── markdown output helpers ─────────────────────────────────────────────────── + +fn write_frontmatter(out: &mut String, title: &str, description: Option<&str>) { + writeln!(out, "---").unwrap(); + writeln!(out, "title: \"{}\"", yaml_escape_double_quoted(title)).unwrap(); + if let Some(desc) = description { + // Collapse whitespace so multi-line notices stay on one line, then escape. + let collapsed: String = desc.split_whitespace().collect::>().join(" "); + writeln!(out, "description: \"{}\"", yaml_escape_double_quoted(&collapsed)).unwrap(); + } + writeln!(out, "---").unwrap(); + writeln!(out).unwrap(); +} + +/// Escape a string for use as a YAML double-quoted scalar. +/// +/// Per the YAML 1.2 spec, double-quoted scalars must escape `"` and `\`, and +/// any control character (including newline, tab, carriage return) must be +/// represented via an escape sequence rather than embedded literally. +fn yaml_escape_double_quoted(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + '\u{0}' => out.push_str("\\0"), + c if (c as u32) < 0x20 => { + out.push_str(&format!("\\x{:02x}", c as u32)); + } + c => out.push(c), + } + } + out +} + +/// Italicize a `@dev` block by wrapping it in `...` HTML tags. Surrounding +/// blank lines around the tags ensure MDX/CommonMark parses the inner content as +/// block-level markdown (lists, code fences, multiple paragraphs all work). +fn italicize_dev(content: &str) -> String { + let trimmed = content.trim_matches('\n'); + if trimmed.is_empty() { String::new() } else { format!("\n\n{trimmed}\n\n") } +} + +/// Replace inline links in a standalone notice or dev description while preserving complete, +/// top-level fenced code blocks. Other NatSpec fields use `replace_inline_links` directly because +/// their rendering context (notably table cells) cannot contain block-level Markdown. +fn replace_description_links( + text: &str, + name_to_page: &NameToPage, + current_page: &Path, + local: Option<&hir_ext::LocalMembers>, +) -> String { + let regions = fenced_description_regions(text); + let mut out = String::with_capacity(text.len()); + let mut rendered_regions = Vec::with_capacity(regions.len()); + let mut copied = 0; + for region in regions { + out.push_str(&sanitize_description_prose( + &text[copied..region.start], + name_to_page, + current_page, + local, + )); + let start = out.len(); + out.push_str(&text[region.clone()]); + rendered_regions.push(start..out.len()); + copied = region.end; + } + out.push_str(&sanitize_description_prose(&text[copied..], name_to_page, current_page, local)); + let mdx_regions = code_regions(&out, &ParseOptions::mdx()); + if rendered_regions.iter().all(|region| mdx_regions.contains(region)) { + out + } else { + sanitize_description_prose(text, name_to_page, current_page, local) + } +} + +fn sanitize_description_prose( + text: &str, + name_to_page: &NameToPage, + current_page: &Path, + local: Option<&hir_ext::LocalMembers>, +) -> String { + let text = hir_ext::replace_inline_links(text, name_to_page, current_page, local); + neutralize_fence_markers(&text) +} + +/// Keep rejected or incomplete fence markers from changing the Markdown context of subsequent +/// descriptions. Entities render as the original marker characters without acting as syntax. +fn neutralize_fence_markers(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let bytes = text.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if let marker @ (b'`' | b'~') = bytes[i] { + let length = bytes[i..].iter().take_while(|&&byte| byte == marker).count(); + if length >= 3 { + out.push_str(if marker == b'`' { "`" } else { "~" }); + out.push_str(&text[i + 1..i + length]); + i += length; + continue; + } + } + let ch = text[i..].chars().next().unwrap(); + out.push(ch); + i += ch.len_utf8(); + } + out +} + +/// Complete fenced code blocks that are direct children of the description document. Restricting +/// preservation to root-level blocks keeps list, quote, table, and unclosed-fence behavior on the +/// conservative escaping path. +fn fenced_description_regions(text: &str) -> Vec> { + let Ok(Node::Root(root)) = to_mdast(text, &ParseOptions::gfm()) else { + return Vec::new(); + }; + root.children + .iter() + .filter_map(|node| { + let Node::Code(_) = node else { return None }; + let position = node.position()?; + let range = position.start.offset..position.end.offset; + is_complete_fence(&text[range.clone()]).then_some(range) + }) + .collect() +} + +fn is_complete_fence(text: &str) -> bool { + let mut lines = logical_lines(text); + let Some((_, first)) = lines.next() else { return false }; + let Some((marker, length)) = fence_marker(first) else { return false }; + let mut last = None; + for (_, line) in lines { + last = Some(line); + } + let Some(last) = last else { return false }; + let indent = last.len() - last.trim_start_matches(' ').len(); + if indent > 3 { + return false; + } + let last = &last[indent..]; + let closing_length = last.chars().take_while(|&ch| ch == marker).count(); + closing_length >= length && last[closing_length..].trim().is_empty() +} + +fn fence_marker(line: &str) -> Option<(char, usize)> { + let indent = line.len() - line.trim_start_matches(' ').len(); + if indent > 3 { + return None; + } + let line = &line[indent..]; + let marker @ ('`' | '~') = line.chars().next()? else { return None }; + let length = line.chars().take_while(|&ch| ch == marker).count(); + (length >= 3).then_some((marker, length)) +} + +fn write_comment_block(out: &mut String, data: &CommentData) { + let mut block = String::new(); + if !data.titles.is_empty() { + let label = if data.titles.len() == 1 { "Title" } else { "Titles" }; + writeln!(block, "**{label}:** {}", data.titles.join(", ")).unwrap(); + writeln!(block).unwrap(); + } + if !data.authors.is_empty() { + let label = if data.authors.len() == 1 { "Author" } else { "Authors" }; + writeln!(block, "**{label}:** {}", data.authors.join(", ")).unwrap(); + writeln!(block).unwrap(); + } + // Render descriptions in source order (notices and devs interleaved, continuations joined). + // `@dev` paragraphs are wrapped in `_..._` per paragraph so each multi-line block renders + // as a single italic span (markdown emphasis cannot cross blank lines). + for desc in &data.descriptions { + match desc.kind { + DescKind::Notice => writeln!(block, "{}", desc.content).unwrap(), + DescKind::Dev => writeln!(block, "{}", italicize_dev(&desc.content)).unwrap(), + } + writeln!(block).unwrap(); + } + if !data.customs.is_empty() { + let label = if data.customs.len() == 1 { "Note" } else { "Notes" }; + writeln!(block, "**{label}:**").unwrap(); + writeln!(block).unwrap(); + for (tag, content) in &data.customs { + writeln!(block, "- **{tag}:** {content}").unwrap(); + } + writeln!(block).unwrap(); + } + // Neutralize the fully assembled block once: fence state stays continuous across the + // whole block, and every displayed line (authors, notices, custom notes), not just + // descriptions, is covered. + out.push_str(&neutralize_esm(&block)); +} + +fn write_code_block(out: &mut String, snippet: &str) { + writeln!(out, "```solidity").unwrap(); + writeln!(out, "{}", snippet.trim_end()).unwrap(); + writeln!(out, "```").unwrap(); + writeln!(out).unwrap(); +} + +fn write_page_header(title: &str, description: Option<&str>, git_url: Option<&str>) -> String { + let mut out = String::new(); + write_frontmatter(&mut out, title, description); + writeln!(out, "# {title}").unwrap(); + writeln!(out).unwrap(); + write_git_source(&mut out, git_url); + out +} + +/// Write link if `git_url` is set. +fn write_git_source(out: &mut String, git_url: Option<&str>) { + if let Some(url) = git_url { + writeln!(out, "[Git Source]({url})").unwrap(); + writeln!(out).unwrap(); + } +} + +/// Escape a value so it is safe inside a markdown (GFM) table cell: +/// - replace `|` with `\|` (column separator) +/// - replace newlines with `
` (cells must be one logical line) +/// - replace `\r` so CRLF natspec doesn't create stray spaces +fn escape_table_cell(s: &str) -> String { + s.replace('\\', "\\\\") + .replace('|', "\\|") + .replace("\r\n", "
") + .replace(['\n', '\r'], "
") +} + +/// Write the **Deployments** table for a contract page. +fn write_deployments_table(out: &mut String, deployments: &[Deployment]) { + if deployments.is_empty() { + return; + } + writeln!(out, "**Deployments**").unwrap(); + writeln!(out).unwrap(); + writeln!(out, "| Network | Address |").unwrap(); + writeln!(out, "| ------- | ------- |").unwrap(); + for d in deployments { + let network = escape_table_cell(d.network.as_deref().unwrap_or("-")); + writeln!(out, "| {network} | `{:#x}` |", d.address).unwrap(); + } + writeln!(out).unwrap(); +} + +fn write_param_table( + out: &mut String, + heading: &str, + params: &ParameterList<'_>, + comments: &CommentData, + inherited_params: Option<&[String]>, + ctx: &Ctx<'_>, +) { + if params.is_empty() { + return; + } + write_signature_table_header(out, heading); + let is_return = heading == "Returns"; + // Positional fall-back to `@custom:name ` for unnamed params + // (parameters only, return names aren't substituted). + let mut unnamed_iter = comments.unnamed_param_names.iter(); + for (index, var) in params.iter().enumerate() { + let name = match var.name { + Some(n) => n.as_str().to_string(), + None if !is_return => unnamed_iter.next().cloned().unwrap_or_else(|| "_".to_string()), + None => "<none>".to_string(), + }; + let ty = format!("`{}`", ctx.snippet(var.ty.span).trim()); + let desc = if is_return { + return_description(comments, index, var.name.map(|_| name.as_str())) + } else { + let named = comments.params.iter().find(|(n, _)| n == &name).map(|(_, d)| d.as_str()); + if var.name.is_none() { + inherited_params + .and_then(|params| params.get(index)) + .map(String::as_str) + .or(named) + .unwrap_or("") + } else { + named.unwrap_or("") + } + }; + let name = escape_table_cell(&name); + let desc = escape_table_cell(desc); + writeln!(out, "| {name} | {ty} | {desc} |").unwrap(); + } + writeln!(out).unwrap(); +} + +fn return_description<'a>( + comments: &'a CommentData, + index: usize, + return_name: Option<&str>, +) -> &'a str { + if let Some(return_name) = return_name + && let Some((_, desc)) = comments.returns.iter().find(|(n, _)| n == return_name) + { + return desc; + } + + let Some((doc_name, desc)) = comments.returns.get(index) else { + return ""; + }; + + if !doc_name.is_empty() { + return desc; + } + + match return_name { + Some(return_name) => desc.strip_prefix(return_name).and_then(strip_one_ws).unwrap_or(desc), + None => desc, + } +} + +fn strip_one_ws(s: &str) -> Option<&str> { + let mut chars = s.char_indices(); + let (_, first) = chars.next()?; + first.is_whitespace().then(|| chars.next().map(|(idx, _)| &s[idx..]).unwrap_or("")) +} + +fn write_struct_properties_table( + out: &mut String, + fields: &[VariableDefinition<'_>], + comments: &CommentData, + ctx: &Ctx<'_>, +) { + if fields.is_empty() { + return; + } + write_signature_table_header(out, "Properties"); + for field in fields { + let name = field.name.map(|n| n.as_str().to_string()).unwrap_or_else(|| "_".to_string()); + let ty = format!("`{}`", ctx.snippet(field.ty.span).trim()); + let desc = + comments.params.iter().find(|(n, _)| n == &name).map(|(_, d)| d.as_str()).unwrap_or(""); + let name = escape_table_cell(&name); + let desc = escape_table_cell(desc); + writeln!(out, "| {name} | {ty} | {desc} |").unwrap(); + } + writeln!(out).unwrap(); +} + +fn write_enum_variants_table(out: &mut String, variants: &[Ident], comments: &CommentData) { + if variants.is_empty() { + return; + } + writeln!(out, "**Variants**").unwrap(); + writeln!(out).unwrap(); + writeln!(out, "| Name | Description |").unwrap(); + writeln!(out, "| ---- | ----------- |").unwrap(); + for variant in variants { + let name = variant.as_str(); + let desc = + comments.params.iter().find(|(n, _)| n == name).map(|(_, d)| d.as_str()).unwrap_or(""); + let name = escape_table_cell(name); + let desc = escape_table_cell(desc); + writeln!(out, "| {name} | {desc} |").unwrap(); + } + writeln!(out).unwrap(); +} + +/// Common layout for parameters, returns, and struct properties. +fn write_signature_table_header(out: &mut String, heading: &str) { + writeln!(out, "**{heading}**\n\n| Name | Type | Description |\n| ---- | ---- | ----------- |") + .unwrap(); +} + +/// Find the HIR `ContractId` for a contract by name, requiring the contract to +/// live in the source file currently being rendered (compared via absolute path) +/// so contracts that share a file stem across `src/` and `lib/` cannot collide. +fn find_contract_id<'gcx>( + gcx: Gcx<'gcx>, + name: &str, + abs_sol_path: &Path, +) -> Option { + gcx.hir.contract_ids().find(|&id| { + let c = gcx.hir.contract(id); + if c.name.as_str() != name { + return false; + } + match &gcx.hir.source(c.source).file.name { + FileName::Real(p) => p == abs_sol_path, + _ => false, + } + }) +} + +/// Strip common leading whitespace from all non-empty lines. +fn dedent(s: &str) -> String { + let lines: Vec<&str> = s.lines().collect(); + if lines.is_empty() { + return s.to_string(); + } + let indent = lines + .iter() + .filter(|l| !l.trim().is_empty()) + .map(|l| l.len() - l.trim_start().len()) + .min() + .unwrap_or(0); + lines + .iter() + .map(|l| if l.len() >= indent { &l[indent..] } else { l.trim() }) + .collect::>() + .join("\n") +} + +// ── public entry point ─────────────────────────────────────────────────────── + +/// Render a single Solidity source file as a list of `(relative_output_path, mdx_content)` pairs. +#[allow(clippy::too_many_arguments)] +pub fn source<'ast, 'gcx>( + ast: &'ast SourceUnit<'ast>, + file: &Arc, + rel_sol_path: &Path, + abs_sol_path: &Path, + gcx: Gcx<'gcx>, + name_to_page: &NameToPage, + git_url: Option<&str>, + deployments: &[Deployment], +) -> Vec<(PathBuf, String)> { + let stem = rel_sol_path.file_stem().and_then(|s| s.to_str()).unwrap_or("constants"); + + let src_text = file.src.as_str(); + let src_start = file.start_pos.to_usize(); + let ctx = Ctx { src_text, src_start }; + + let mut pages: Vec<(PathBuf, String)> = Vec::new(); + let mut const_vars: Vec<(Span, &VariableDefinition<'_>, &DocComments<'_>)> = Vec::new(); + let mut free_fns: std::collections::BTreeMap< + String, + Vec<(Span, &ItemFunction<'_>, &DocComments<'_>)>, + > = Default::default(); + + for item in ast.items.iter() { + let span = item.span; + match &item.kind { + ItemKind::Pragma(_) | ItemKind::Import(_) | ItemKind::Using(_) => (), + ItemKind::Contract(c) => { + let kind_str = contract_kind_str(c.kind); + let page_path = page_path(rel_sol_path, kind_str, c.name.as_str()); + // Look up HIR contract id for inheritance/inheritdoc. + let hir_id = find_contract_id(gcx, c.name.as_str(), abs_sol_path); + // Deployments only apply to non-abstract, non-interface, non-library contracts. + let contract_deployments = if matches!(c.kind, ContractKind::Contract) + && rel_sol_path.file_stem().and_then(|s| s.to_str()) == Some(c.name.as_str()) + { + deployments + } else { + &[] + }; + let content = render_contract( + c, + &item.docs, + &ctx, + gcx, + hir_id, + name_to_page, + &page_path, + git_url, + contract_deployments, + ); + pages.push((page_path, content)); + } + + ItemKind::Function(f) => { + let name = f.header.name.map(|n| n.as_str().to_string()).unwrap_or_default(); + free_fns.entry(name).or_default().push((span, f, &item.docs)); + } + + ItemKind::Variable(v) => { + const_vars.push((span, v, &item.docs)); + } + + ItemKind::Struct(_) + | ItemKind::Enum(_) + | ItemKind::Udvt(_) + | ItemKind::Error(_) + | ItemKind::Event(_) => { + let prefix = match &item.kind { + ItemKind::Struct(_) => "struct", + ItemKind::Enum(_) => "enum", + ItemKind::Udvt(_) => "type", + ItemKind::Error(_) => "error", + ItemKind::Event(_) => "event", + _ => unreachable!(), + }; + let name = item.name().unwrap(); + let page_path = page_path(rel_sol_path, prefix, name.as_str()); + let comments = collect_comments( + &item.docs, + Links { names: name_to_page, page: &page_path, local: None }, + ); + let mut content = + write_page_header(name.as_str(), first_notice(&comments), git_url); + write_item_body(&mut content, item, &comments, &ctx); + pages.push((page_path, content)); + } + } + } + + for (name, overloads) in &free_fns { + let page_path = page_path(rel_sol_path, "function", name); + let content = render_free_functions( + name, + overloads, + &ctx, + Links { names: name_to_page, page: &page_path, local: None }, + git_url, + ); + pages.push((page_path, content)); + } + + if !const_vars.is_empty() { + let page_path = page_path(rel_sol_path, "constants", stem); + let content = render_constants( + stem, + &const_vars, + &ctx, + Links { names: name_to_page, page: &page_path, local: None }, + git_url, + ); + pages.push((page_path, content)); + } + + pages +} + +#[cfg(test)] +mod tests { + use super::*; + use markdown::MdxSignal; + + fn parse_mdx(text: &str) -> Node { + let mut options = ParseOptions::mdx(); + options.mdx_esm_parse = Some(Box::new(|_| MdxSignal::Ok)); + to_mdast(text, &options).unwrap() + } + + fn contains_mdx_esm(node: &Node) -> bool { + matches!(node, Node::MdxjsEsm(_)) + || node.children().is_some_and(|children| children.iter().any(contains_mdx_esm)) + } + + fn contains_mdx_expression(node: &Node) -> bool { + matches!(node, Node::MdxFlowExpression(_) | Node::MdxTextExpression(_)) + || node.children().is_some_and(|children| children.iter().any(contains_mdx_expression)) + } + + #[test] + fn preserves_complete_top_level_description_fences() { + let input = "Before < and {\n~~~solidity\nif (a < b) { revert(); }\n~~~\nAfter < and {"; + let output = replace_description_links( + input, + &NameToPage::new(), + Path::new("src/contract.Foo.mdx"), + None, + ); + + assert_eq!( + output, + "Before < and {\n~~~solidity\nif (a < b) { revert(); }\n~~~\nAfter < and {" + ); + } + + #[test] + fn conservatively_escapes_non_standalone_fences() { + for (input, expected) in [ + ( + "- ~~~\n example <\n ~~~\nOutside < and {", + "- ~~~\n example <\n ~~~\nOutside < and {", + ), + ("~~~\nexample < and {", "~~~\nexample < and {"), + ] { + assert_eq!( + replace_description_links( + input, + &NameToPage::new(), + Path::new("src/contract.Foo.mdx"), + None, + ), + expected + ); + } + } + + #[test] + fn rejected_fences_cannot_change_later_mdx_context() { + let name_to_page = NameToPage::new(); + let path = Path::new("src/contract.Foo.mdx"); + let first = replace_description_links("~~~", &name_to_page, path, None); + let second = replace_description_links("~~~\n{1+1}\n~~~", &name_to_page, path, None); + let output = format!("{first}\n\n{second}"); + assert_eq!(output, "~~~\n\n~~~\n{1+1}\n~~~"); + assert!(!contains_mdx_expression(&parse_mdx(&output))); + + let output = + replace_description_links("~~~\n ~~~\n{1+1}\n~~~", &name_to_page, path, None); + assert_eq!(output, "~~~\n ~~~\n`1+1`\n~~~"); + assert!(!contains_mdx_expression(&parse_mdx(&output))); + + let notice = replace_description_links("~~~\n{1+1}\n~~~", &name_to_page, path, None); + for prefix in ["**Title:**", "**Author:**", "- **note:**"] { + let metadata = sanitize_description_prose("metadata\n~~~", &name_to_page, path, None); + let output = format!("{prefix} {metadata}\n\n{notice}"); + assert!(!contains_mdx_expression(&parse_mdx(&output)), "{output}"); + } + } + + #[test] + fn preserves_line_endings_while_neutralizing_esm() { + assert_eq!( + neutralize_esm("Intro.\r\rexport const afterCarriageReturn = 1"), + "Intro.\r\rexport const afterCarriageReturn = 1" + ); + for (input, expected) in [ + ( + "```\rimport inside\r```\rexport outside", + "```\rimport inside\r```\rexport outside", + ), + ( + "```\r\nimport inside\r\n```\r\nexport outside", + "```\r\nimport inside\r\n```\r\nexport outside", + ), + ( + "`example:\rimport inside`\rexport outside", + "`example:\rimport inside`\rexport outside", + ), + ( + "`example:\r\nimport inside`\r\nexport outside", + "`example:\r\nimport inside`\r\nexport outside", + ), + ( + " ```\r import inside\r ```\rexport outside", + " ```\r import inside\r ```\rexport outside", + ), + ("```\rinside\r ```\rexport outside", "```\rinside\r ```\rexport outside"), + ] { + assert_eq!(neutralize_esm(input), expected); + } + assert_eq!( + neutralize_esm("Intro.\r\nimport outside"), + "Intro.\r\nimport outside" + ); + assert_eq!( + neutralize_esm("export first\r\npréface `span:\rimport inside`\r\nimport last"), + "export first\r\npréface `span:\rimport inside`\r\nimport last" + ); + } + + #[test] + fn traverses_sorted_code_regions() { + let input = "`first`\n ```\n import inside fence\n ```\n`last`\nexport outside"; + let expected = + "`first`\n ```\n import inside fence\n ```\n`last`\nexport outside"; + assert_eq!(neutralize_esm(input), expected); + } + + #[test] + fn preserves_code_across_mdx_edge_cases() { + for (input, expected) in [ + ( + "```\nimport inside\n```\nexport outside", + "```\nimport inside\n```\nexport outside", + ), + ( + "```\n~~~\nimport inside\n```\nexport outside", + "```\n~~~\nimport inside\n```\nexport outside", + ), + ( + "````\n```\nimport inside\n```\n````\nexport outside", + "````\n```\nimport inside\n```\n````\nexport outside", + ), + ( + "```\nimport inside\n```suffix\nexport inside\n```\nimport outside", + "```\nimport inside\n```suffix\nexport inside\n```\nimport outside", + ), + ( + "`example:\nimport inside`\nexport outside", + "`example:\nimport inside`\nexport outside", + ), + ( + "```\ninside\n ```\n```\nimport inside second\n```\nexport outside", + "```\ninside\n ```\n```\nimport inside second\n```\nexport outside", + ), + ( + "- ```\n import inside list\n ```\n\nexport outside", + "- ```\n import inside list\n ```\n\nexport outside", + ), + ( + " ```\n import inside indented\n ```\nexport outside", + " ```\n import inside indented\n ```\nexport outside", + ), + (" ```\n import inside unclosed", " ```\n import inside unclosed"), + ] { + assert_eq!(neutralize_esm(input), expected, "input:\n{input}"); + } + } + + #[test] + fn neutralized_output_contains_no_mdx_esm() { + let input = "import injected from \"x\"\n\n```js\nexport const example = 1\n```"; + assert!(contains_mdx_esm(&parse_mdx(input))); + + let output = neutralize_esm(input); + assert_eq!( + output, + "import injected from \"x\"\n\n```js\nexport const example = 1\n```" + ); + + let tree = parse_mdx(&output); + assert!(!contains_mdx_esm(&tree), "{tree:#?}"); + assert!(tree.children().is_some_and(|children| { + children.iter().any( + |node| matches!(node, Node::Code(code) if code.value == "export const example = 1"), + ) + })); + } + + #[test] + fn leaves_non_esm_prefixes_unchanged() { + let input = "important\nexporter\nimport_\nexport$\nImport value\nExport value\n import value\n\t export value\nimport(value)\nexport: value\nimport\tvalue"; + assert_eq!(neutralize_esm(input), input); + } + + #[test] + fn neutralizes_only_exact_mdx_esm_prefixes() { + assert_eq!( + neutralize_esm("import value\nexport value\nimport value\nexport value"), + "import value\nexport value\nimport value\nexport value" + ); + } + + #[test] + fn neutralizes_many_candidates_across_many_code_regions() { + let input = "`code`\nexport outside\n".repeat(10_000); + let output = neutralize_esm(&input); + assert_eq!(output.matches("`code`").count(), 10_000); + assert_eq!(output.matches("export outside").count(), 10_000); + } +} diff --git a/crates/doc/src/utils.rs b/crates/doc/src/utils.rs new file mode 100644 index 0000000000000..5fd1d8761a599 --- /dev/null +++ b/crates/doc/src/utils.rs @@ -0,0 +1,130 @@ +//! `GitSource` + `Deployments` helpers. +//! +//! Pure functions ported from the legacy `forge-doc` preprocessors: +//! * `git_source_url`: `/blob//` for a source file. +//! * `read_deployments`: load `

//.json` artifacts. + +use alloy_primitives::Address; +use path_slash::PathExt; +use serde::Deserialize; +use solar::ast::ContractKind; +use std::{ + fs, + path::{Component, Path, PathBuf}, +}; + +// ── git source ──────────────────────────────────────────────────────────────── + +/// Build the `{repo}/blob/{commit}/{rel}` URL for a source file. +/// +/// Returns `None` if `item_path` is not under `root` (i.e. for absolute external paths). +/// `commit` falls back to `"HEAD"` when empty (GitHub's `blob/HEAD/...` resolves +/// to the repository's default branch regardless of whether it is `main`, +/// `master`, or anything else). +/// +/// Path components are joined with `/` so the URL is well-formed on Windows. +pub fn git_source_url(repo: &str, commit: &str, root: &Path, item_path: &Path) -> Option { + let repo = repo.trim_end_matches('/'); + let commit = if commit.is_empty() { "HEAD" } else { commit }; + let rel = item_path.strip_prefix(root).ok()?; + Some(format!("{repo}/blob/{commit}/{}", rel.to_slash_lossy())) +} + +/// Return a `{repo}/raw/{commit}/...` URL suitable for embedding binary assets +/// (images, fonts, etc.) directly rather than the GitHub blob viewer page. +pub fn git_raw_url(repo: &str, commit: &str, root: &Path, item_path: &Path) -> Option { + let repo = repo.trim_end_matches('/'); + let commit = if commit.is_empty() { "HEAD" } else { commit }; + let rel = item_path.strip_prefix(root).ok()?; + Some(format!("{repo}/raw/{commit}/{}", rel.to_slash_lossy())) +} + +// ── deployments ────────────────────────────────────────────────────────────── + +/// A contract deployment entry, deserialised from `//.json`. +#[derive(Clone, Debug, Deserialize)] +pub struct Deployment { + /// The contract address. + pub address: Address, + /// The network name (filled in from the parent directory name). + pub network: Option, +} + +/// Read all deployments for a Solidity contract file. +/// +/// Walks `deployments_dir` (defaulting to `/deployments`), looks for a +/// `/.json` file in each top-level subdirectory, and +/// returns the parsed [`Deployment`]s tagged with their network name. +/// +/// Errors when reading individual entries are silently skipped to mirror the +/// legacy preprocessor's lenient behaviour. +pub fn read_deployments( + root: &Path, + deployments_dir: Option<&Path>, + contract_file: &Path, +) -> Vec { + let dir = root.join(deployments_dir.unwrap_or_else(|| Path::new("deployments"))); + let Ok(entries) = fs::read_dir(&dir) else { + return Vec::new(); + }; + + // Switch ".sol" -> ".json" and keep just the file name. + let mut filename: PathBuf = contract_file.to_path_buf(); + filename.set_extension("json"); + let Some(filename) = filename.file_name().map(PathBuf::from) else { return Vec::new() }; + + let mut out = Vec::new(); + for entry in entries.flatten() { + let Ok(file_type) = entry.file_type() else { continue }; + if !file_type.is_dir() { + continue; + } + let Ok(network) = entry.file_name().into_string() else { continue }; + let path = entry.path().join(&filename); + let Ok(content) = fs::read_to_string(&path) else { continue }; + let Ok(mut deployment) = serde_json::from_str::(&content) else { continue }; + deployment.network = Some(network); + out.push(deployment); + } + // Sort for deterministic output across platforms (fs::read_dir order is unspecified). + out.sort_by(|a, b| { + a.network.as_deref().unwrap_or("").cmp(b.network.as_deref().unwrap_or("")).then_with(|| { + let af = format!("{:#x}", a.address); + let bf = format!("{:#x}", b.address); + af.cmp(&bf) + }) + }); + out +} + +/// Map a source to its documentation location, retaining the legacy external-library layout. +pub(crate) fn relative_source_path(root: &Path, source: &Path) -> PathBuf { + source.strip_prefix(root).map(Path::to_path_buf).unwrap_or_else(|_| { + let components = source.components().collect::>(); + PathBuf::from("lib") + .join(components[components.len().saturating_sub(3)..].iter().collect::()) + }) +} + +/// Whether joining a generated path can stay within the documentation directory. +pub(crate) fn is_safe_output_path(path: &Path) -> bool { + !path.is_absolute() + && !path.components().any(|component| { + matches!(component, Component::ParentDir | Component::RootDir | Component::Prefix(_)) + }) +} + +/// The filename prefix used for a contract page. +pub(crate) const fn contract_kind_str(kind: ContractKind) -> &'static str { + match kind { + ContractKind::Contract => "contract", + ContractKind::AbstractContract => "abstract", + ContractKind::Interface => "interface", + ContractKind::Library => "library", + } +} + +/// Construct an item page beside its source in the documentation tree. +pub(crate) fn page_path(source: &Path, prefix: &str, name: &str) -> PathBuf { + source.parent().unwrap_or(Path::new("")).join(format!("{prefix}.{name}.mdx")) +} diff --git a/crates/doc/src/vocs.rs b/crates/doc/src/vocs.rs new file mode 100644 index 0000000000000..a37ccf67c892c --- /dev/null +++ b/crates/doc/src/vocs.rs @@ -0,0 +1,737 @@ +//! Vocs site file generation. +//! +//! Generates `vocs.config.ts`, `pages/index.mdx`, `package.json`, and `.gitignore` +//! from the emitted MDX pages. + +use crate::{ + markdown::{code_regions, neutralize_esm, region_contains}, + utils::{git_raw_url, git_source_url}, +}; +use foundry_common::fs::normalize_path; +use foundry_config::DocConfig; +use markdown::ParseOptions; +use path_slash::PathExt; +use std::{ + collections::HashMap, + fs, + path::{Path, PathBuf}, +}; + +/// Map from a Solidity source file location to its vocs page URL. +/// +/// Key is `(parent_dir_with_forward_slashes, file_stem)` of the source file. +/// Value is the root-relative vocs URL (no extension, leading `/`). +type SourceToUrl = HashMap<(String, String), String>; + +/// Write all vocs site scaffolding into `out_dir`. +/// +/// `pages` is the list of relative MDX paths emitted by the render step (relative to +/// `out_dir/pages/`). `root` is the project root and `sources` is the Solidity +/// sources directory; both are searched for a README to use as the homepage. +pub fn write_site_files( + out_dir: &Path, + config: &DocConfig, + pages: &[PathBuf], + root: &Path, + sources: &Path, + branch: Option<&str>, + commit: Option<&str>, +) -> eyre::Result<()> { + fs::create_dir_all(out_dir)?; + + // Write user-editable scaffold files only on first run so that manual + // customisations (tweaked vocs config, added npm deps, custom landing page) + // are not silently overwritten on subsequent `forge doc` runs. + write_if_absent(&out_dir.join(".gitignore"), "dist\nnode_modules\n")?; + write_if_absent(&out_dir.join("package.json"), package_json())?; + // The sidebar changes every time pages are added/removed so it lives in its own + // file that is always regenerated. vocs.config.ts imports it via a relative + // import, so users can freely edit the main config without losing changes on + // the next `forge doc` run. + fs::write(out_dir.join("vocs.sidebar.ts"), vocs_sidebar(pages))?; + write_if_absent(&out_dir.join("vocs.config.ts"), &vocs_config(config, branch))?; + + // Homepage: config.homepage -> /README.md -> /README.md -> empty. + // Rewrite relative links: `.sol` -> generated vocs page; everything else -> + // a `{repo}/blob/{commit}/...` URL when a repository is configured. + let (homepage_content, homepage_dir) = find_homepage(config, root, sources); + let src_to_url = build_source_to_url(pages); + let homepage_content = if let Some(base_dir) = homepage_dir.as_deref() { + rewrite_homepage_links( + &homepage_content, + base_dir, + root, + &src_to_url, + config.repository.as_deref(), + commit, + ) + } else { + homepage_content + }; + let homepage_content = homepage_content.strip_prefix('\u{feff}').unwrap_or(&homepage_content); + let homepage_content = neutralize_esm(&escape_mdx_outside_code_fences(homepage_content)); + let index_path = out_dir.join("src").join("pages").join("index.mdx"); + if let Some(parent) = index_path.parent() { + fs::create_dir_all(parent)?; + } + // Always regenerate: unlike user-editable scaffold files, index.mdx is + // derived from README and must reflect the latest source on every run. + fs::write(&index_path, &homepage_content)?; + + Ok(()) +} + +/// Write `content` to `path` only if the file does not already exist. +fn write_if_absent(path: &Path, content: &str) -> eyre::Result<()> { + if !path.exists() { + fs::write(path, content)?; + } + Ok(()) +} + +// ── vocs.config.ts ──────────────────────────────────────────────────────────── + +/// Generate the user-editable `vocs.config.ts`. +/// +/// The sidebar is imported from the always-regenerated `vocs.sidebar.ts` so +/// that users can customise this file freely without losing changes on re-runs. +fn vocs_config(config: &DocConfig, branch: Option<&str>) -> String { + let title = if config.title.is_empty() { "Documentation" } else { &config.title }; + + let mut ts = String::new(); + ts.push_str("import { defineConfig } from 'vocs/config'\n"); + ts.push_str("import { sidebar } from './vocs.sidebar'\n\n"); + ts.push_str("export default defineConfig({\n"); + ts.push_str(&format!(" title: {},\n", json_str(title))); + + if let Some(repo) = &config.repository { + // GitHub `edit//` requires a real branch name (it does not + // accept `HEAD`). Use the detected current branch when available, else + // fall back to `main`. + let edit_branch = branch.unwrap_or("main"); + ts.push_str(&format!( + " editLink: {{ pattern: '{}/edit/{edit_branch}/{{path}}' }},\n", + repo.trim_end_matches('/') + )); + } + + // Pin the shiki language bundle. Vocs otherwise scans every MDX page and + // eagerly loads any code-fence language it finds, which fails for fences + // like ```ml (OCaml, used by some READMEs as an ASCII tree). With an + // explicit `langs` list, unknown fences fall back to `plaintext` instead + // of crashing the highlighter at startup. + ts.push_str(" codeHighlight: {\n"); + ts.push_str(" fallbackLanguage: 'plaintext',\n"); + ts.push_str(" langs: [\n"); + ts.push_str( + " 'ansi', 'bash', 'diff', 'html', 'js', 'json', 'jsx',\n \ + 'markdown', 'md', 'mdx', 'plaintext', 'rust', 'sol', 'solidity',\n \ + 'toml', 'ts', 'tsx', 'yaml', 'zsh',\n", + ); + ts.push_str(" ],\n"); + ts.push_str(" },\n"); + + ts.push_str(" sidebar,\n"); + ts.push_str("})\n"); + ts +} + +fn vocs_sidebar(pages: &[PathBuf]) -> String { + let sidebar = build_sidebar(pages); + let mut ts = String::new(); + ts.push_str("// This file is generated by forge doc. Do not edit manually.\n"); + ts.push_str("// Re-run `forge doc` to update.\n\n"); + ts.push_str("export const sidebar = [\n"); + ts.push_str(&sidebar); + ts.push_str("]\n"); + ts +} + +/// Build a TypeScript sidebar array string from the list of relative page paths. +fn build_sidebar(pages: &[PathBuf]) -> String { + // Sort pages for deterministic output. + let mut sorted = pages.to_vec(); + sorted.sort(); + + // Group by parent directory -> type category -> (name, link). + // BTreeMap keeps dirs and type categories in sorted/canonical order. + let mut groups: std::collections::BTreeMap< + String, + std::collections::BTreeMap>, + > = Default::default(); + + for page in &sorted { + // Always emit forward-slash URLs so links work on Windows. + let link = format!("/{}", page.with_extension("").to_slash_lossy()); + let stem = page.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown"); + + // Split `type.Name` -> (kind, display_name). + let (kind, name) = stem.split_once('.').unwrap_or(("", stem)); + let cat = type_category_order(kind); + + let dir = page + .parent() + .and_then(|p| if p == Path::new("") { None } else { Some(p.to_slash_lossy()) }) + .map(|s| s.to_string()) + .unwrap_or_default(); + + groups.entry(dir).or_default().entry(cat).or_default().push((name.to_string(), link)); + } + + let mut out = String::new(); + for (dir, by_type) in &groups { + if dir.is_empty() { + // Top-level items, emit flat, preserving type prefix in name for clarity. + for items in by_type.values() { + for (name, link) in items { + out.push_str(&format!( + " {{ text: {}, link: {} }},\n", + json_str(name), + json_str(link) + )); + } + } + continue; + } + + // Does this directory have more than one type category? + let multi_type = by_type.len() > 1; + + out.push_str(&format!(" {{\n text: {},\n items: [\n", json_str(dir))); + + for (cat, items) in by_type { + if multi_type { + // Emit a collapsed sub-group for this type category. + let cat_label = type_category_label(*cat); + out.push_str(&format!( + " {{\n text: {},\n collapsed: true,\n items: [\n", + json_str(cat_label) + )); + for (name, link) in items { + out.push_str(&format!( + " {{ text: {}, link: {} }},\n", + json_str(name), + json_str(link) + )); + } + out.push_str(" ],\n },\n"); + } else { + // Single type, list items directly without a wrapper. + for (name, link) in items { + out.push_str(&format!( + " {{ text: {}, link: {} }},\n", + json_str(name), + json_str(link) + )); + } + } + } + + out.push_str(" ],\n },\n"); + } + out +} + +/// Canonical sort order for type categories in the sidebar. +fn type_category_order(kind: &str) -> u8 { + match kind { + "contract" => 0, + "abstract" => 1, + "interface" => 2, + "library" => 3, + "struct" => 4, + "enum" => 5, + "type" => 6, + "error" => 7, + "event" => 8, + "function" => 9, + "constants" => 10, + _ => 11, + } +} + +/// Human-readable label for a type category. +const fn type_category_label(cat: u8) -> &'static str { + match cat { + 0 => "Contracts", + 1 => "Abstract Contracts", + 2 => "Interfaces", + 3 => "Libraries", + 4 => "Structs", + 5 => "Enums", + 6 => "Types", + 7 => "Errors", + 8 => "Events", + 9 => "Functions", + 10 => "Constants", + _ => "Other", + } +} + +// ── homepage ────────────────────────────────────────────────────────────────── + +/// Locate the homepage markdown. +/// +/// Returns `(content, base_dir)` where `base_dir` is the absolute directory the +/// homepage file lives in (used to resolve its relative links). When no +/// homepage file is found, returns an empty string and no base dir. +fn find_homepage(config: &DocConfig, root: &Path, sources: &Path) -> (String, Option) { + // 1. Explicit homepage from config. + if let Some(hp) = &config.homepage { + let path = if hp.is_absolute() { hp.clone() } else { root.join(hp) }; + if let Ok(content) = fs::read_to_string(&path) { + return (content, path.parent().map(Path::to_path_buf)); + } + } + + // 2. /README.md (e.g. `src/README.md`). + let src_readme = if sources.is_absolute() { + sources.join("README.md") + } else { + root.join(sources).join("README.md") + }; + if let Ok(content) = fs::read_to_string(&src_readme) { + return (content, src_readme.parent().map(Path::to_path_buf)); + } + + // 3. /README.md + let readme = root.join("README.md"); + if let Ok(content) = fs::read_to_string(&readme) { + return (content, readme.parent().map(Path::to_path_buf)); + } + + // 4. Empty fallback. + (String::new(), None) +} + +// ── homepage link rewriting ─────────────────────────────────────────────────── + +/// Build a `(parent_dir, file_stem) -> vocs_url` map from the emitted MDX pages. +/// +/// Each page is named `..mdx`; the key mirrors the source `.sol` +/// file (parent dir + item name), so a README link to `path/to/Name.sol` +/// matches the page for the item whose name equals the file stem. +fn build_source_to_url(pages: &[PathBuf]) -> SourceToUrl { + let mut map = SourceToUrl::new(); + for page in pages { + let stem = page.file_stem().and_then(|s| s.to_str()).unwrap_or(""); + let Some((_kind, name)) = stem.split_once('.') else { continue }; + let dir = page.parent().map(|p| p.to_slash_lossy().into_owned()).unwrap_or_default(); + let url = format!("/{}", page.with_extension("").to_slash_lossy()); + map.insert((dir, name.to_string()), url); + } + map +} + +/// Escape MDX-sensitive characters (`{` and `<`) in plain-text regions of a +/// Markdown document, leaving fenced code blocks (` ``` ` or `~~~`) untouched. +/// +/// Without this, README content with template placeholders like `{FOO}` or +/// HTML-like tokens like `` would be interpreted as MDX expressions/JSX +/// and break `vocs dev` / `vocs build`. +fn escape_mdx_outside_code_fences(text: &str) -> String { + struct Fence { + marker: char, + len: usize, + } + + let mut out = String::with_capacity(text.len()); + let mut fence: Option = None; + for line in text.split_inclusive('\n') { + let trimmed = line.trim_start(); + if let Some(open) = fence.as_ref() { + out.push_str(line); + let marker_len = trimmed.chars().take_while(|&ch| ch == open.marker).count(); + // Fence markers are ASCII, so their character count is also the byte index. + let suffix = &trimmed[marker_len..]; + if marker_len >= open.len && suffix.trim().is_empty() { + fence = None; + } + } else { + let opening = trimmed.chars().next().and_then(|marker| { + if !matches!(marker, '`' | '~') { + return None; + } + let len = trimmed.chars().take_while(|&ch| ch == marker).count(); + if len < 3 || marker == '`' && trimmed[len..].contains('`') { + return None; + } + Some(Fence { marker, len }) + }); + if let Some(opening) = opening { + fence = Some(opening); + out.push_str(line); + continue; + } + + // Escape `{` and bare `<` (not already `<` or a known entity). + let mut inline_code_ticks = 0usize; + let mut pending_ticks = 0usize; + for ch in line.chars() { + if ch == '`' { + pending_ticks += 1; + out.push(ch); + continue; + } + + if pending_ticks > 0 { + if inline_code_ticks == 0 { + inline_code_ticks = pending_ticks; + } else if inline_code_ticks == pending_ticks { + inline_code_ticks = 0; + } + pending_ticks = 0; + } + + if inline_code_ticks > 0 { + out.push(ch); + } else { + match ch { + '{' => out.push_str(r"\{"), + '<' => out.push_str("<"), + c => out.push(c), + } + } + } + } + } + out +} + +/// Rewrite inline `[text](url)` markdown links in the homepage: +/// * `.sol` paths that resolve to a known page → vocs URL. +/// * Any other relative path under `root` → `{repo}/blob/{commit}/...`. +/// * Absolute URLs, anchors, and unresolved targets are left untouched. +/// * Code fences and inline code spans are left untouched. +fn rewrite_homepage_links( + text: &str, + base_dir: &Path, + root: &Path, + src_to_url: &SourceToUrl, + repo: Option<&str>, + commit: Option<&str>, +) -> String { + // The README is plain GitHub-flavored Markdown at this point, so parse it as such rather than + // as MDX, which may fail on unescaped `{`/`<` and would leave every region unprotected. + let code_regions = code_regions(text, &ParseOptions::gfm()); + let mut region_cursor = 0; + let mut out = String::with_capacity(text.len()); + let mut rest = text; + let mut consumed = 0usize; + while let Some(open) = rest.find("](") { + let abs_open = consumed + open; + out.push_str(&rest[..open + 2]); + rest = &rest[open + 2..]; + consumed += open + 2; + // Solidity syntax like `new address[](2)` inside code is not a link. + if region_contains(&code_regions, &mut region_cursor, abs_open) { + continue; + } + // Scan the URL, counting parens so we don't split on `(` / `)` inside it. + let bytes = rest.as_bytes(); + let mut i = 0; + let mut depth = 1usize; + let mut closed = false; + while i < bytes.len() { + match bytes[i] { + b'(' => { + depth += 1; + i += 1; + } + b')' => { + depth -= 1; + if depth == 0 { + closed = true; + break; + } + i += 1; + } + b'\\' => { + // Skip the escaped character; clamp so a trailing backslash + // can't index past the end of the text. + i = (i + 2).min(bytes.len()); + } + _ => { + i += 1; + } + } + } + let target = &rest[..i]; + if !closed { + out.push_str(target); + rest = &rest[i..]; + break; + } + match try_rewrite_target(target, base_dir, root, src_to_url, repo, commit) { + Some(new) => out.push_str(&new), + None => out.push_str(target), + } + out.push(')'); + rest = &rest[i + 1..]; + consumed += i + 1; + } + out.push_str(rest); + out +} + +/// Resolve `target` against `base_dir` and return either the matching vocs +/// page URL (`.sol`) or a `{repo}/blob/{commit}/...` URL for any other relative +/// path under `root`. +fn try_rewrite_target( + target: &str, + base_dir: &Path, + root: &Path, + src_to_url: &SourceToUrl, + repo: Option<&str>, + commit: Option<&str>, +) -> Option { + // Skip absolute URLs and pure anchors. + if target.is_empty() + || target.starts_with('#') + || target.starts_with("//") + || target.contains("://") + || target.starts_with("mailto:") + { + return None; + } + + // Split off `#fragment` / `?query`; we'll preserve the fragment on the rewrite. + let (path_part, suffix) = target.find(['#', '?']).map_or((target, ""), |i| target.split_at(i)); + if path_part.is_empty() { + return None; + } + + let path = Path::new(path_part); + // A leading `/` in a README link means "relative to project root", not an + // OS-absolute filesystem path. Resolve against `root` so `/src/Foo.sol` + // becomes `/src/Foo.sol` rather than failing to strip the root prefix. + let abs = if path.is_absolute() { + let without_root_prefix = path.strip_prefix("/").unwrap_or(path); + normalize_path(&root.join(without_root_prefix)) + } else { + normalize_path(&base_dir.join(path)) + }; + let rel = abs.strip_prefix(root).ok()?.to_path_buf(); + + // `.sol` -> vocs page. + if path.extension().and_then(|e| e.to_str()) == Some("sol") { + let stem = rel.file_stem().and_then(|s| s.to_str())?; + let dir = rel.parent().map(|p| p.to_slash_lossy().into_owned()).unwrap_or_default(); + if let Some(url) = src_to_url.get(&(dir, stem.to_string())) { + return Some(url.clone()); + } + } + + // Fall back to a repo URL for everything else under the project root. + // Use raw (download) URLs for image assets so they render inline rather + // than pointing at the GitHub blob viewer page. + let repo = repo?; + let is_image = path.extension().and_then(|e| e.to_str()).is_some_and(|ext| { + matches!( + ext.to_ascii_lowercase().as_str(), + "png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "ico" + ) + }); + let mut url = if is_image { + git_raw_url(repo, commit.unwrap_or("HEAD"), root, &abs)? + } else { + git_source_url(repo, commit.unwrap_or("HEAD"), root, &abs)? + }; + url.push_str(suffix); + Some(url) +} + +// ── package.json ────────────────────────────────────────────────────────────── + +const fn package_json() -> &'static str { + r#"{ + "scripts": { + "dev": "vocs dev", + "build": "vocs build", + "preview": "vocs preview" + }, + "dependencies": { + "react": "^19", + "react-dom": "^19", + "vocs": "https://pkg.pr.new/wevm/vocs@next", + "waku": "1.0.0-alpha.6" + } +} +"# +} + +// ── helpers ─────────────────────────────────────────────────────────────────── + +fn json_str(s: &str) -> String { + serde_json::to_string(s).expect("serializing a string cannot fail") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rewrites_links() { + let map = build_source_to_url(&[ + PathBuf::from("src/contract.Morpho.mdx"), + PathBuf::from("src/interfaces/interface.IMorpho.mdx"), + PathBuf::from("src/libraries/library.MathLib.mdx"), + ]); + let root = Path::new("/repo"); + let repo = Some("https://github.com/x/y"); + let commit = Some("abc123"); + let input = "[Morpho](./src/Morpho.sol), \ + [IMorpho](src/interfaces/IMorpho.sol#L10), \ + [Unknown](src/Unknown.sol), \ + [Contrib](./CONTRIBUTING.md), \ + [Logo](./img/logo.png), \ + [ext](https://x.com), \ + [anchor](#section)"; + + let out = rewrite_homepage_links(input, Path::new("/repo"), root, &map, repo, commit); + // .sol with known page -> vocs URL. + assert!(out.contains("[Morpho](/src/contract.Morpho)")); + // .sol with fragment -> vocs URL (fragment dropped, no anchor in MDX). + assert!(out.contains("[IMorpho](/src/interfaces/interface.IMorpho)")); + // .sol without a known page -> repo blob URL. + assert!(out.contains("[Unknown](https://github.com/x/y/blob/abc123/src/Unknown.sol)")); + // Other relative paths -> repo blob URL. + assert!(out.contains("[Contrib](https://github.com/x/y/blob/abc123/CONTRIBUTING.md)")); + assert!(out.contains("[Logo](https://github.com/x/y/raw/abc123/img/logo.png)")); + // Absolute URL and pure anchor untouched. + assert!(out.contains("[ext](https://x.com)")); + assert!(out.contains("[anchor](#section)")); + } + + #[test] + fn escape_mdx_leaves_code_fences_alone() { + let input = "\ +# Title + +Plain text with {placeholder} and here. +Inline code keeps `forge create ` and `{OWNER}` unchanged. + +```solidity +contract Foo { + mapping(address => uint256) public balances; +} +``` + +More text: {another} and . + +~~~shell +echo {not escaped} +~~~ + +End {brace}. +"; + let out = escape_mdx_outside_code_fences(input); + + // Plain-text regions: { → \{ and < → < (} and > are left alone). + assert!(out.contains(r"\{placeholder}"), "{{ in plain text should be escaped"); + assert!(out.contains("<TOKEN>"), "< in plain text should be escaped"); + assert!(out.contains(r"\{another}")); + assert!(out.contains("<bar/>")); + assert!(out.contains(r"\{brace}")); + assert!(out.contains("`forge create `"), "inline code span must be unchanged"); + assert!(out.contains("`{OWNER}`"), "inline code span braces must be unchanged"); + + // Inside ``` fences: untouched. + assert!( + out.contains("mapping(address => uint256)"), + "code fence content must be unchanged" + ); + assert!(!out.contains(r"mapping(address => uint256\)"), "no stray escaping inside fence"); + + // Inside ~~~ fences: untouched. + assert!(out.contains("echo {not escaped}"), "~~~ fence content must be unchanged"); + } + + #[test] + fn escape_mdx_supports_longer_backtick_fences() { + let input = "````markdown\n```solidity\ncontract Test {\n function value() external returns (uint256) {\n return 1;\n }\n}\n```\n````\n\nOutside {text}.\n"; + let out = escape_mdx_outside_code_fences(input); + + assert_eq!(out, input.replace("Outside {text}", r"Outside \{text}")); + } + + #[test] + fn escape_mdx_supports_longer_tilde_fences() { + let input = "~~~~markdown\n~~~solidity\ncontract Test {\n function value() external returns (uint256) {\n return 1;\n }\n}\n~~~\n~~~~\n\nOutside {text}.\n"; + let out = escape_mdx_outside_code_fences(input); + + assert_eq!(out, input.replace("Outside {text}", r"Outside \{text}")); + } + + #[test] + fn escape_mdx_rejects_backticks_in_fence_info() { + let input = "```bad`\n```still-bad`\n{process.exit(42)}\n"; + let out = escape_mdx_outside_code_fences(input); + + assert_eq!(out, input.replace("{process.exit(42)}", r"\{process.exit(42)}")); + } + + #[test] + fn json_str_emits_valid_typescript_strings() { + assert_eq!(json_str(r#"Acme\"#), r#""Acme\\""#); + assert_eq!(json_str("Bob's Docs"), r#""Bob's Docs""#); + assert_eq!(json_str(r#"Quote " Docs"#), r#""Quote \" Docs""#); + } + + #[test] + fn rewrite_homepage_links_leaves_code_alone() { + let map = build_source_to_url(&[PathBuf::from("src/contract.Foo.mdx")]); + let root = Path::new("/repo"); + let repo = Some("https://github.com/x/y"); + let commit = Some("abc123"); + let input = "\ +See [Foo](./src/Foo.sol) for details. + +```solidity +function deploy() external { + address[] memory targets = new address[](2); +} +``` + +Also uses `new address[](2)` inline, then links [Contrib](./CONTRIBUTING.md). + +A lone ` backtick is not a code span: [Logo](./img/logo.png). + + address[] memory indented = new address[](2); +"; + let expected = "\ +See [Foo](/src/contract.Foo) for details. + +```solidity +function deploy() external { + address[] memory targets = new address[](2); +} +``` + +Also uses `new address[](2)` inline, then links [Contrib](https://github.com/x/y/blob/abc123/CONTRIBUTING.md). + +A lone ` backtick is not a code span: [Logo](https://github.com/x/y/raw/abc123/img/logo.png). + + address[] memory indented = new address[](2); +"; + let out = rewrite_homepage_links(input, root, root, &map, repo, commit); + assert_eq!(out, expected); + } + + #[test] + fn rewrite_homepage_links_handles_trailing_unclosed_backslash() { + // A dangling `](` whose target ends in a backslash must not panic. + let map = SourceToUrl::new(); + let root = Path::new("/repo"); + let input = "See [Foo](abc\\"; + let out = rewrite_homepage_links( + input, + root, + root, + &map, + Some("https://github.com/x/y"), + Some("abc123"), + ); + assert_eq!(out, input, "unclosed target should be left untouched, not panic"); + } +} diff --git a/crates/doc/src/writer/as_doc.rs b/crates/doc/src/writer/as_doc.rs deleted file mode 100644 index 34127e2831765..0000000000000 --- a/crates/doc/src/writer/as_doc.rs +++ /dev/null @@ -1,328 +0,0 @@ -use crate::{ - CONTRACT_INHERITANCE_ID, CommentTag, Comments, CommentsRef, DEPLOYMENTS_ID, Document, - GIT_SOURCE_ID, INHERITDOC_ID, Markdown, PreprocessorOutput, - document::{DocumentContent, read_context}, - parser::ParseSource, - writer::BufWriter, -}; -use forge_fmt::solang_ext::SafeUnwrap; -use itertools::Itertools; -use solang_parser::pt::{Base, FunctionDefinition}; -use std::path::{Path, PathBuf}; - -/// The result of [`AsDoc::as_doc`]. -pub type AsDocResult = Result; - -/// A trait for formatting a parse unit as documentation. -pub trait AsDoc { - /// Formats a parse tree item into a doc string. - fn as_doc(&self) -> AsDocResult; -} - -impl AsDoc for String { - fn as_doc(&self) -> AsDocResult { - Ok(self.to_owned()) - } -} - -impl AsDoc for Comments { - fn as_doc(&self) -> AsDocResult { - CommentsRef::from(self).as_doc() - } -} - -impl AsDoc for CommentsRef<'_> { - // TODO: support other tags - fn as_doc(&self) -> AsDocResult { - let mut writer = BufWriter::default(); - - // Write author tag(s) - let authors = self.include_tag(CommentTag::Author); - if !authors.is_empty() { - writer.write_bold(&format!("Author{}:", if authors.len() == 1 { "" } else { "s" }))?; - writer.writeln_raw(authors.iter().map(|a| &a.value).join(", "))?; - writer.writeln()?; - } - - // Write notice tags - let notices = self.include_tag(CommentTag::Notice); - for n in notices.iter() { - writer.writeln_raw(&n.value)?; - writer.writeln()?; - } - - // Write dev tags - let devs = self.include_tag(CommentTag::Dev); - for d in devs.iter() { - writer.write_italic(&d.value)?; - writer.writeln()?; - } - - // Write custom tags - let customs = self.get_custom_tags(); - if !customs.is_empty() { - writer.write_bold(&format!("Note{}:", if customs.len() == 1 { "" } else { "s" }))?; - for c in customs.iter() { - writer.writeln_raw(format!( - "{}{}: {}", - if customs.len() == 1 { "" } else { "- " }, - &c.tag, - &c.value - ))?; - writer.writeln()?; - } - } - - Ok(writer.finish()) - } -} - -impl AsDoc for Base { - fn as_doc(&self) -> AsDocResult { - Ok(self.name.identifiers.iter().map(|ident| ident.name.to_owned()).join(".")) - } -} - -impl AsDoc for Document { - fn as_doc(&self) -> AsDocResult { - let mut writer = BufWriter::default(); - - match &self.content { - DocumentContent::OverloadedFunctions(items) => { - writer - .write_title(&format!("function {}", items.first().unwrap().source.ident()))?; - if let Some(git_source) = read_context!(self, GIT_SOURCE_ID, GitSource) { - writer.write_link("Git Source", &git_source)?; - writer.writeln()?; - } - - for item in items { - let func = item.as_function().unwrap(); - let mut heading = item.source.ident(); - if !func.params.is_empty() { - heading.push_str(&format!( - "({})", - func.params - .iter() - .map(|p| p.1.as_ref().map(|p| p.ty.to_string()).unwrap_or_default()) - .join(", ") - )); - } - writer.write_heading(&heading)?; - writer.write_section(&item.comments, &item.code)?; - } - } - DocumentContent::Constants(items) => { - writer.write_title("Constants")?; - if let Some(git_source) = read_context!(self, GIT_SOURCE_ID, GitSource) { - writer.write_link("Git Source", &git_source)?; - writer.writeln()?; - } - - for item in items { - let var = item.as_variable().unwrap(); - writer.write_heading(&var.name.safe_unwrap().name)?; - writer.write_section(&item.comments, &item.code)?; - } - } - DocumentContent::Single(item) => { - writer.write_title(&item.source.ident())?; - if let Some(git_source) = read_context!(self, GIT_SOURCE_ID, GitSource) { - writer.write_link("Git Source", &git_source)?; - writer.writeln()?; - } - - if let Some(deployments) = read_context!(self, DEPLOYMENTS_ID, Deployments) { - writer.write_deployments_table(deployments)?; - } - - match &item.source { - ParseSource::Contract(contract) => { - if !contract.base.is_empty() { - writer.write_bold("Inherits:")?; - - // we need this to find the _relative_ paths - let src_target_dir = self.target_src_dir(); - - let mut bases = vec![]; - let linked = - read_context!(self, CONTRACT_INHERITANCE_ID, ContractInheritance); - for base in &contract.base { - let base_doc = base.as_doc()?; - let base_ident = &base.name.identifiers.last().unwrap().name; - - let link = linked - .as_ref() - .and_then(|link| { - link.get(base_ident).map(|path| { - let path = Path::new("/").join( - path.strip_prefix(&src_target_dir) - .ok() - .unwrap_or(path), - ); - Markdown::Link(&base_doc, &path.display().to_string()) - .as_doc() - }) - }) - .transpose()? - .unwrap_or(base_doc); - - bases.push(link); - } - - writer.writeln_raw(bases.join(", "))?; - writer.writeln()?; - } - - writer.writeln_doc(&item.comments)?; - - if let Some(state_vars) = item.variables() { - writer.write_subtitle("State Variables")?; - state_vars.into_iter().try_for_each(|(item, comments, code)| { - let comments = comments.merge_inheritdoc( - &item.name.safe_unwrap().name, - read_context!(self, INHERITDOC_ID, Inheritdoc), - ); - - writer.write_heading(&item.name.safe_unwrap().name)?; - writer.write_section(&comments, code)?; - writer.writeln() - })?; - } - - if let Some(funcs) = item.functions() { - writer.write_subtitle("Functions")?; - - for (func, comments, code) in &funcs { - self.write_function(&mut writer, func, comments, code)?; - } - } - - if let Some(events) = item.events() { - writer.write_subtitle("Events")?; - events.into_iter().try_for_each(|(item, comments, code)| { - writer.write_heading(&item.name.safe_unwrap().name)?; - writer.write_section(comments, code)?; - writer.try_write_events_table(&item.fields, comments) - })?; - } - - if let Some(errors) = item.errors() { - writer.write_subtitle("Errors")?; - errors.into_iter().try_for_each(|(item, comments, code)| { - writer.write_heading(&item.name.safe_unwrap().name)?; - writer.write_section(comments, code)?; - writer.try_write_errors_table(&item.fields, comments) - })?; - } - - if let Some(structs) = item.structs() { - writer.write_subtitle("Structs")?; - structs.into_iter().try_for_each(|(item, comments, code)| { - writer.write_heading(&item.name.safe_unwrap().name)?; - writer.write_section(comments, code)?; - writer.try_write_properties_table(&item.fields, comments) - })?; - } - - if let Some(enums) = item.enums() { - writer.write_subtitle("Enums")?; - enums.into_iter().try_for_each(|(item, comments, code)| { - writer.write_heading(&item.name.safe_unwrap().name)?; - writer.write_section(comments, code) - })?; - } - } - - ParseSource::Function(func) => { - // TODO: cleanup - // Write function docs - writer.writeln_doc( - &item.comments.exclude_tags(&[CommentTag::Param, CommentTag::Return]), - )?; - - // Write function header - writer.write_code(&item.code)?; - - // Write function parameter comments in a table - let params = - func.params.iter().filter_map(|p| p.1.as_ref()).collect::>(); - writer.try_write_param_table(CommentTag::Param, ¶ms, &item.comments)?; - - // Write function return parameter comments in a table - let returns = - func.returns.iter().filter_map(|p| p.1.as_ref()).collect::>(); - writer.try_write_param_table( - CommentTag::Return, - &returns, - &item.comments, - )?; - - writer.writeln()?; - } - - ParseSource::Struct(ty) => { - writer.write_section(&item.comments, &item.code)?; - writer.try_write_properties_table(&ty.fields, &item.comments)?; - } - ParseSource::Event(ev) => { - writer.write_section(&item.comments, &item.code)?; - writer.try_write_events_table(&ev.fields, &item.comments)?; - } - ParseSource::Error(err) => { - writer.write_section(&item.comments, &item.code)?; - writer.try_write_errors_table(&err.fields, &item.comments)?; - } - ParseSource::Variable(_) | ParseSource::Enum(_) | ParseSource::Type(_) => { - writer.write_section(&item.comments, &item.code)?; - } - } - } - DocumentContent::Empty => (), - }; - - Ok(writer.finish()) - } -} - -impl Document { - /// Where all the source files are written to - fn target_src_dir(&self) -> PathBuf { - self.out_target_dir.join("src") - } - - /// Writes a function to the buffer. - fn write_function( - &self, - writer: &mut BufWriter, - func: &FunctionDefinition, - comments: &Comments, - code: &str, - ) -> Result<(), std::fmt::Error> { - let func_name = func.name.as_ref().map_or(func.ty.to_string(), |n| n.name.to_owned()); - let comments = - comments.merge_inheritdoc(&func_name, read_context!(self, INHERITDOC_ID, Inheritdoc)); - - // Write function name - writer.write_heading(&func_name)?; - - writer.writeln()?; - - // Write function docs - writer.writeln_doc(&comments.exclude_tags(&[CommentTag::Param, CommentTag::Return]))?; - - // Write function header - writer.write_code(code)?; - - // Write function parameter comments in a table - let params = func.params.iter().filter_map(|p| p.1.as_ref()).collect::>(); - writer.try_write_param_table(CommentTag::Param, ¶ms, &comments)?; - - // Write function return parameter comments in a table - let returns = func.returns.iter().filter_map(|p| p.1.as_ref()).collect::>(); - writer.try_write_param_table(CommentTag::Return, &returns, &comments)?; - - writer.writeln()?; - Ok(()) - } -} diff --git a/crates/doc/src/writer/buf_writer.rs b/crates/doc/src/writer/buf_writer.rs deleted file mode 100644 index e322995186505..0000000000000 --- a/crates/doc/src/writer/buf_writer.rs +++ /dev/null @@ -1,252 +0,0 @@ -use crate::{AsDoc, CommentTag, Comments, Deployment, Markdown, writer::traits::ParamLike}; -use itertools::Itertools; -use solang_parser::pt::{ErrorParameter, EventParameter, Parameter, VariableDeclaration}; -use std::{ - fmt::{self, Display, Write}, - sync::LazyLock, -}; - -/// Solidity language name. -const SOLIDITY: &str = "solidity"; - -/// Headers and separator for rendering parameter table. -const PARAM_TABLE_HEADERS: &[&str] = &["Name", "Type", "Description"]; -static PARAM_TABLE_SEPARATOR: LazyLock = - LazyLock::new(|| PARAM_TABLE_HEADERS.iter().map(|h| "-".repeat(h.len())).join("|")); - -/// Headers and separator for rendering the deployments table. -const DEPLOYMENTS_TABLE_HEADERS: &[&str] = &["Network", "Address"]; -static DEPLOYMENTS_TABLE_SEPARATOR: LazyLock = - LazyLock::new(|| DEPLOYMENTS_TABLE_HEADERS.iter().map(|h| "-".repeat(h.len())).join("|")); - -/// The buffered writer. -/// Writes various display items into the internal buffer. -#[derive(Debug, Default)] -pub struct BufWriter { - buf: String, -} - -impl BufWriter { - /// Create new instance of [BufWriter] from [ToString]. - pub fn new(content: impl ToString) -> Self { - Self { buf: content.to_string() } - } - - /// Returns true if the buffer is empty. - pub fn is_empty(&self) -> bool { - self.buf.is_empty() - } - - /// Write [AsDoc] implementation to the buffer. - pub fn write_doc(&mut self, doc: &T) -> fmt::Result { - write!(self.buf, "{}", doc.as_doc()?) - } - - /// Write [AsDoc] implementation to the buffer with newline. - pub fn writeln_doc(&mut self, doc: &T) -> fmt::Result { - writeln!(self.buf, "{}", doc.as_doc()?) - } - - /// Writes raw content to the buffer. - pub fn write_raw(&mut self, content: T) -> fmt::Result { - write!(self.buf, "{content}") - } - - /// Writes raw content to the buffer with newline. - pub fn writeln_raw(&mut self, content: T) -> fmt::Result { - writeln!(self.buf, "{content}") - } - - /// Writes newline to the buffer. - pub fn writeln(&mut self) -> fmt::Result { - writeln!(self.buf) - } - - /// Writes a title to the buffer formatted as [Markdown::H1]. - pub fn write_title(&mut self, title: &str) -> fmt::Result { - writeln!(self.buf, "{}", Markdown::H1(title)) - } - - /// Writes a subtitle to the bugger formatted as [Markdown::H2]. - pub fn write_subtitle(&mut self, subtitle: &str) -> fmt::Result { - writeln!(self.buf, "{}", Markdown::H2(subtitle)) - } - - /// Writes heading to the buffer formatted as [Markdown::H3]. - pub fn write_heading(&mut self, heading: &str) -> fmt::Result { - writeln!(self.buf, "{}", Markdown::H3(heading)) - } - - /// Writes text in italics to the buffer formatted as [Markdown::Italic]. - pub fn write_italic(&mut self, text: &str) -> fmt::Result { - writeln!(self.buf, "{}", Markdown::Italic(text)) - } - - /// Writes bold text to the buffer formatted as [Markdown::Bold]. - pub fn write_bold(&mut self, text: &str) -> fmt::Result { - writeln!(self.buf, "{}", Markdown::Bold(text)) - } - - /// Writes link to the buffer formatted as [Markdown::Link]. - pub fn write_link(&mut self, name: &str, path: &str) -> fmt::Result { - writeln!(self.buf, "{}", Markdown::Link(name, path)) - } - - /// Writes a list item to the buffer indented by specified depth. - pub fn write_list_item(&mut self, item: &str, depth: usize) -> fmt::Result { - let indent = " ".repeat(depth * 2); - writeln!(self.buf, "{indent}- {item}") - } - - /// Writes a link to the buffer as a list item. - pub fn write_link_list_item(&mut self, name: &str, path: &str, depth: usize) -> fmt::Result { - let link = Markdown::Link(name, path); - self.write_list_item(&link.as_doc()?, depth) - } - - /// Writes a solidity code block to the buffer. - pub fn write_code(&mut self, code: &str) -> fmt::Result { - writeln!(self.buf, "{}", Markdown::CodeBlock(SOLIDITY, code)) - } - - /// Write an item section to the buffer. First write comments, the item itself as code. - pub fn write_section(&mut self, comments: &Comments, code: &str) -> fmt::Result { - self.writeln_raw(comments.as_doc()?)?; - self.write_code(code)?; - self.writeln() - } - - /// Tries to write the table to the buffer. - /// Doesn't write anything if either params or comments are empty. - fn try_write_table( - &mut self, - tag: CommentTag, - params: &[T], - comments: &Comments, - heading: &str, - ) -> fmt::Result - where - T: ParamLike, - { - let comments = comments.include_tag(tag.clone()); - - // There is nothing to write. - if params.is_empty() || comments.is_empty() { - return Ok(()); - } - - self.write_bold(heading)?; - self.writeln()?; - - self.write_piped(&PARAM_TABLE_HEADERS.join("|"))?; - self.write_piped(&PARAM_TABLE_SEPARATOR)?; - - for (index, param) in params.iter().enumerate() { - let param_name = param.name(); - - let mut comment = param_name.as_ref().and_then(|name| { - comments.iter().find_map(|comment| comment.match_first_word(name)) - }); - - // If it's a return tag and couldn't match by first word, - // lookup the doc by index. - if comment.is_none() && matches!(tag, CommentTag::Return) { - comment = comments.get(index).map(|c| &*c.value); - } - - let row = [ - Markdown::Code(param_name.unwrap_or("")).as_doc()?, - Markdown::Code(¶m.type_name()).as_doc()?, - comment.unwrap_or_default().replace('\n', " "), - ]; - self.write_piped(&row.join("|"))?; - } - - self.writeln()?; - - Ok(()) - } - - /// Tries to write the properties table to the buffer. - /// Doesn't write anything if either params or comments are empty. - pub fn try_write_properties_table( - &mut self, - params: &[VariableDeclaration], - comments: &Comments, - ) -> fmt::Result { - self.try_write_table(CommentTag::Param, params, comments, "Properties") - } - - /// Tries to write the parameters table to the buffer. - /// Doesn't write anything if either params or comments are empty. - pub fn try_write_events_table( - &mut self, - params: &[EventParameter], - comments: &Comments, - ) -> fmt::Result { - self.try_write_table(CommentTag::Param, params, comments, "Parameters") - } - - /// Tries to write the parameters table to the buffer. - /// Doesn't write anything if either params or comments are empty. - pub fn try_write_errors_table( - &mut self, - params: &[ErrorParameter], - comments: &Comments, - ) -> fmt::Result { - self.try_write_table(CommentTag::Param, params, comments, "Parameters") - } - - /// Tries to write the parameters table to the buffer. - /// Doesn't write anything if either params or comments are empty. - pub fn try_write_param_table( - &mut self, - tag: CommentTag, - params: &[&Parameter], - comments: &Comments, - ) -> fmt::Result { - let heading = match &tag { - CommentTag::Param => "Parameters", - CommentTag::Return => "Returns", - _ => return Err(fmt::Error), - }; - - self.try_write_table(tag, params, comments, heading) - } - - /// Writes the deployment table to the buffer. - pub fn write_deployments_table(&mut self, deployments: Vec) -> fmt::Result { - self.write_bold("Deployments")?; - self.writeln()?; - - self.write_piped(&DEPLOYMENTS_TABLE_HEADERS.join("|"))?; - self.write_piped(&DEPLOYMENTS_TABLE_SEPARATOR)?; - - for deployment in deployments { - let mut network = deployment.network.ok_or(fmt::Error)?; - network[0..1].make_ascii_uppercase(); - - let row = [ - Markdown::Bold(&network).as_doc()?, - Markdown::Code(&format!("{:?}", deployment.address)).as_doc()?, - ]; - self.write_piped(&row.join("|"))?; - } - - self.writeln()?; - - Ok(()) - } - - /// Write content to the buffer surrounded by pipes. - pub fn write_piped(&mut self, content: &str) -> fmt::Result { - self.write_raw("|")?; - self.write_raw(content)?; - self.writeln_raw("|") - } - - /// Finish and return underlying buffer. - pub fn finish(self) -> String { - self.buf - } -} diff --git a/crates/doc/src/writer/markdown.rs b/crates/doc/src/writer/markdown.rs deleted file mode 100644 index 7583c35e79ef8..0000000000000 --- a/crates/doc/src/writer/markdown.rs +++ /dev/null @@ -1,44 +0,0 @@ -use crate::{AsDoc, AsDocResult}; - -/// The markdown format. -#[derive(Debug)] -pub enum Markdown<'a> { - /// H1 heading item. - H1(&'a str), - /// H2 heading item. - H2(&'a str), - /// H3 heading item. - H3(&'a str), - /// Italic item. - Italic(&'a str), - /// Bold item. - Bold(&'a str), - /// Link item. - Link(&'a str, &'a str), - /// Code item. - Code(&'a str), - /// Code block item. - CodeBlock(&'a str, &'a str), -} - -impl AsDoc for Markdown<'_> { - fn as_doc(&self) -> AsDocResult { - let doc = match self { - Self::H1(val) => format!("# {val}"), - Self::H2(val) => format!("## {val}"), - Self::H3(val) => format!("### {val}"), - Self::Italic(val) => format!("*{val}*"), - Self::Bold(val) => format!("**{val}**"), - Self::Link(val, link) => format!("[{val}]({link})"), - Self::Code(val) => format!("`{val}`"), - Self::CodeBlock(lang, val) => format!("```{lang}\n{val}\n```"), - }; - Ok(doc) - } -} - -impl std::fmt::Display for Markdown<'_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_fmt(format_args!("{}", self.as_doc()?)) - } -} diff --git a/crates/doc/src/writer/mod.rs b/crates/doc/src/writer/mod.rs deleted file mode 100644 index 0d7c94205d294..0000000000000 --- a/crates/doc/src/writer/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! The module for writing and formatting various parse tree items. - -mod as_doc; -mod buf_writer; -mod markdown; - -pub use as_doc::{AsDoc, AsDocResult}; -pub use buf_writer::BufWriter; -pub use markdown::Markdown; - -mod traits; diff --git a/crates/doc/src/writer/traits.rs b/crates/doc/src/writer/traits.rs deleted file mode 100644 index 0b79718d5102f..0000000000000 --- a/crates/doc/src/writer/traits.rs +++ /dev/null @@ -1,70 +0,0 @@ -//! Helper traits for writing documentation. - -use solang_parser::pt::Expression; - -/// Helper trait to abstract over a solang type that can be documented as parameter -pub(crate) trait ParamLike { - /// Returns the type of the parameter. - fn ty(&self) -> &Expression; - - /// Returns the type as a string. - fn type_name(&self) -> String { - self.ty().to_string() - } - - /// Returns the identifier of the parameter. - fn name(&self) -> Option<&str>; -} - -impl ParamLike for solang_parser::pt::Parameter { - fn ty(&self) -> &Expression { - &self.ty - } - - fn name(&self) -> Option<&str> { - self.name.as_ref().map(|id| id.name.as_str()) - } -} - -impl ParamLike for solang_parser::pt::VariableDeclaration { - fn ty(&self) -> &Expression { - &self.ty - } - - fn name(&self) -> Option<&str> { - self.name.as_ref().map(|id| id.name.as_str()) - } -} - -impl ParamLike for solang_parser::pt::EventParameter { - fn ty(&self) -> &Expression { - &self.ty - } - - fn name(&self) -> Option<&str> { - self.name.as_ref().map(|id| id.name.as_str()) - } -} - -impl ParamLike for solang_parser::pt::ErrorParameter { - fn ty(&self) -> &Expression { - &self.ty - } - - fn name(&self) -> Option<&str> { - self.name.as_ref().map(|id| id.name.as_str()) - } -} - -impl ParamLike for &T -where - T: ParamLike, -{ - fn ty(&self) -> &Expression { - T::ty(*self) - } - - fn name(&self) -> Option<&str> { - T::name(*self) - } -} diff --git a/crates/doc/static/book.css b/crates/doc/static/book.css deleted file mode 100644 index b5ce903f99b78..0000000000000 --- a/crates/doc/static/book.css +++ /dev/null @@ -1,13 +0,0 @@ -table { - margin: 0 auto; - border-collapse: collapse; - width: 100%; -} - -table td:first-child { - width: 15%; -} - -table td:nth-child(2) { - width: 25%; -} \ No newline at end of file diff --git a/crates/doc/static/book.toml b/crates/doc/static/book.toml deleted file mode 100644 index 617db3095a4b9..0000000000000 --- a/crates/doc/static/book.toml +++ /dev/null @@ -1,12 +0,0 @@ -# For more configuration see https://rust-lang.github.io/mdBook/format/configuration/index.html -[book] -src = "src" - -[output.html] -no-section-label = true -additional-js = ["solidity.min.js"] -additional-css = ["book.css"] -mathjax-support = true - -[output.html.fold] -enable = true diff --git a/crates/doc/static/solidity.min.js b/crates/doc/static/solidity.min.js deleted file mode 100644 index 1924932919cfe..0000000000000 --- a/crates/doc/static/solidity.min.js +++ /dev/null @@ -1,74 +0,0 @@ -hljs.registerLanguage("solidity",(()=>{"use strict";function e(){try{return!0 -}catch(e){return!1}} -var a=/-?(\b0[xX]([a-fA-F0-9]_?)*[a-fA-F0-9]|(\b[1-9](_?\d)*(\.((\d_?)*\d)?)?|\.\d(_?\d)*)([eE][-+]?\d(_?\d)*)?|\b0)(?!\w|\$)/ -;e()&&(a=a.source.replace(/\\b/g,"(?{ -var a=r(e),o=l(e),c=/[A-Za-z_$][A-Za-z_$0-9.]*/,d=e.inherit(e.TITLE_MODE,{ -begin:/[A-Za-z$_][0-9A-Za-z$_]*/,lexemes:c,keywords:n}),u={className:"params", -begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,lexemes:c,keywords:n, -contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,a,o,s]},_={ -className:"operator",begin:/:=|->/};return{keywords:n,lexemes:c, -contains:[a,o,i,t,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,s,_,{ -className:"function",lexemes:c,beginKeywords:"function",end:"{",excludeEnd:!0, -contains:[d,u,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,_]}]}}, -solAposStringMode:r,solQuoteStringMode:l,HEX_APOS_STRING_MODE:i, -HEX_QUOTE_STRING_MODE:t,SOL_NUMBER:s,isNegativeLookbehindAvailable:e} -;const{baseAssembly:c,solAposStringMode:d,solQuoteStringMode:u,HEX_APOS_STRING_MODE:_,HEX_QUOTE_STRING_MODE:m,SOL_NUMBER:b,isNegativeLookbehindAvailable:E}=o -;return e=>{for(var a=d(e),s=u(e),n=[],i=0;i<32;i++)n[i]=i+1 -;var t=n.map((e=>8*e)),r=[];for(i=0;i<=80;i++)r[i]=i -;var l=n.map((e=>"bytes"+e)).join(" ")+" ",o=t.map((e=>"uint"+e)).join(" ")+" ",g=t.map((e=>"int"+e)).join(" ")+" ",M=[].concat.apply([],t.map((e=>r.map((a=>e+"x"+a))))),p={ -keyword:"var bool string int uint "+g+o+"byte bytes "+l+"fixed ufixed "+M.map((e=>"fixed"+e)).join(" ")+" "+M.map((e=>"ufixed"+e)).join(" ")+" enum struct mapping address new delete if else for while continue break return throw emit try catch revert unchecked _ function modifier event constructor fallback receive error virtual override constant immutable anonymous indexed storage memory calldata external public internal payable pure view private returns import from as using pragma contract interface library is abstract type assembly", -literal:"true false wei gwei szabo finney ether seconds minutes hours days weeks years", -built_in:"self this super selfdestruct suicide now msg block tx abi blockhash gasleft assert require Error Panic sha3 sha256 keccak256 ripemd160 ecrecover addmod mulmod log0 log1 log2 log3 log4" -},O={className:"operator",begin:/[+\-!~*\/%<>&^|=]/ -},C=/[A-Za-z_$][A-Za-z_$0-9]*/,N={className:"params",begin:/\(/,end:/\)/, -excludeBegin:!0,excludeEnd:!0,lexemes:C,keywords:p, -contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,a,s,b,"self"]},f={ -begin:/\.\s*/,end:/[^A-Za-z0-9$_\.]/,excludeBegin:!0,excludeEnd:!0,keywords:{ -built_in:"gas value selector address length push pop send transfer call callcode delegatecall staticcall balance code codehash wrap unwrap name creationCode runtimeCode interfaceId min max" -},relevance:2},y=e.inherit(e.TITLE_MODE,{begin:/[A-Za-z$_][0-9A-Za-z$_]*/, -lexemes:C,keywords:p}),w={className:"built_in", -begin:(E()?"(?:HardhatConsole"]["abi"] + open(abi_file, "w").write(json.dumps(abi, separators=(",", ":"), indent=None)) diff --git a/crates/evm/abi/src/lib.rs b/crates/evm/abi/src/lib.rs index 8e9313c2f516e..0887a4a1c2cc6 100644 --- a/crates/evm/abi/src/lib.rs +++ b/crates/evm/abi/src/lib.rs @@ -1,6 +1,6 @@ //! Solidity ABI-related utilities and [`sol!`](alloy_sol_types::sol) definitions. #![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] +#![cfg_attr(docsrs, feature(doc_cfg))] pub mod console; diff --git a/crates/evm/core/Cargo.toml b/crates/evm/core/Cargo.toml index 222bf550c5ba3..5acd3f81ac2f1 100644 --- a/crates/evm/core/Cargo.toml +++ b/crates/evm/core/Cargo.toml @@ -16,12 +16,15 @@ workspace = true [dependencies] foundry-cheatcodes-spec.workspace = true foundry-common.workspace = true +foundry-compilers.workspace = true foundry-config.workspace = true foundry-evm-abi.workspace = true +foundry-evm-hardforks.workspace = true +foundry-evm-networks.workspace = true alloy-chains.workspace = true alloy-dyn-abi = { workspace = true, features = ["arbitrary", "eip712"] } -alloy-evm.workspace = true +alloy-evm = { workspace = true, features = ["rpc"] } alloy-genesis.workspace = true alloy-hardforks.workspace = true alloy-json-abi.workspace = true @@ -32,12 +35,40 @@ alloy-primitives = { workspace = true, features = [ "rlp", ] } alloy-provider.workspace = true +alloy-rpc-client.workspace = true alloy-network.workspace = true alloy-consensus.workspace = true -alloy-rpc-types.workspace = true +alloy-monad-evm = { workspace = true, optional = true } +alloy-eips.workspace = true +alloy-op-evm = { workspace = true, optional = true } +alloy-rpc-types = { workspace = true, features = ["anvil"] } alloy-sol-types.workspace = true +alloy-rlp.workspace = true foundry-fork-db.workspace = true +# Base +base-common-chains = { workspace = true, optional = true, features = ["std"] } +base-common-consensus = { workspace = true, optional = true, features = [ + "alloy-compat", + "evm", + "k256", + "serde", + "std", +] } +base-common-evm = { workspace = true, optional = true, features = [ + "blst", + "c-kzg", + "memory_limit", + "optional_block_gas_limit", + "optional_eip3607", + "optional_no_base_fee", + "serde", + "std", +] } +base-common-network = { workspace = true, optional = true, features = ["serde", "std"] } +base-common-precompiles = { workspace = true, optional = true, features = ["std"] } +base-common-rpc-types = { workspace = true, optional = true, features = ["serde", "std"] } + revm = { workspace = true, features = [ "std", "serde", @@ -45,13 +76,23 @@ revm = { workspace = true, features = [ "optional_eip3607", "optional_block_gas_limit", "optional_no_base_fee", + "optional_priority_fee_check", "arbitrary", "c-kzg", "blst", ] } revm-inspectors.workspace = true -op-revm.workspace = true -alloy-op-evm.workspace = true +monad-revm = { workspace = true, optional = true } +op-alloy-consensus = { workspace = true, features = ["k256"], optional = true } +op-alloy-network = { workspace = true, optional = true } +op-alloy-rpc-types = { workspace = true, optional = true } +op-revm = { workspace = true, optional = true } +tempo-revm.workspace = true +tempo-alloy.workspace = true +tempo-hardfork.workspace = true +tempo-contracts.workspace = true +tempo-evm.workspace = true +tempo-precompiles.workspace = true auto_impl.workspace = true eyre.workspace = true @@ -66,4 +107,47 @@ tracing.workspace = true url.workspace = true [dev-dependencies] +alloy-serde.workspace = true +alloy-signer.workspace = true +alloy-signer-local.workspace = true +anvil.workspace = true foundry-test-utils.workspace = true +tempo-primitives.workspace = true + +[[test]] +name = "base_replay" +path = "tests/base_replay.rs" +required-features = ["base"] + +[features] +default = ["optimism"] +monad = [ + "anvil/monad", + "dep:alloy-monad-evm", + "dep:monad-revm", + "foundry-evm-hardforks/monad", + "foundry-evm-networks/monad", +] +base = [ + "anvil/base", + "dep:base-common-chains", + "dep:base-common-consensus", + "dep:base-common-evm", + "dep:base-common-network", + "dep:base-common-precompiles", + "dep:base-common-rpc-types", + "foundry-common/base", + "foundry-config/base", + "foundry-evm-hardforks/base", + "foundry-evm-networks/base", +] +optimism = [ + "dep:op-alloy-consensus", + "dep:op-alloy-network", + "dep:op-alloy-rpc-types", + "dep:alloy-op-evm", + "dep:op-revm", + "foundry-common/optimism", + "foundry-evm-hardforks/optimism", + "foundry-evm-networks/optimism", +] diff --git a/crates/evm/core/src/backend/bal.rs b/crates/evm/core/src/backend/bal.rs new file mode 100644 index 0000000000000..77c9eb76b7647 --- /dev/null +++ b/crates/evm/core/src/backend/bal.rs @@ -0,0 +1,56 @@ +//! Block access list (BAL) reads for executing one transaction against its parent block's state. + +use super::{Backend, DatabaseError, DatabaseResult}; +use crate::evm::FoundryEvmNetwork; +use alloy_primitives::{Address, U256}; +use revm::{ + database_interface::bal::BalState, + state::{ + AccountInfo, + bal::{Bal, BlockAccessIndex}, + }, +}; +use std::sync::Arc; + +impl Backend { + /// Serves reads of state the block wrote before `index` from `bal`, and everything else from + /// the underlying database. + /// + /// Index `0` holds the pre-block system writes and transaction `i` is index `i + 1`, so + /// positioning the reads at a transaction's own index yields its prestate without replaying + /// the earlier transactions. Committing the transaction's state removes the list again, so + /// the backend must not execute further transactions of that block afterwards. + pub fn set_bal(&mut self, bal: Arc, index: BlockAccessIndex) { + self.bal = Some(BalState { + bal: Some(bal), + bal_index: index, + allow_db_fallback: true, + ..Default::default() + }); + } + + pub(super) fn apply_bal_account( + &self, + address: Address, + account: &mut Option, + ) -> DatabaseResult<()> { + if let Some(bal) = &self.bal { + bal.basic(address, account) + .map_err(|err| DatabaseError::GetAccount(address, Arc::new(err.into())))?; + } + Ok(()) + } + + pub(super) fn bal_storage( + &self, + address: Address, + index: U256, + ) -> DatabaseResult> { + let Some(bal) = &self.bal else { return Ok(None) }; + bal.storage(&address, index) + .map_err(|err| DatabaseError::GetStorage(address, index, Arc::new(err.into()))) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/evm/core/src/backend/bal/tests.rs b/crates/evm/core/src/backend/bal/tests.rs new file mode 100644 index 0000000000000..a87a69d15260f --- /dev/null +++ b/crates/evm/core/src/backend/bal/tests.rs @@ -0,0 +1,166 @@ +//! Tests for block access list state reads and commits. + +use crate::{ + backend::{Backend, CowBackend}, + evm::EthEvmNetwork, +}; +use alloy_eips::eip7928::{ + AccountChanges, BalanceChange, CodeChange, NonceChange, SlotChanges, StorageChange, +}; +use alloy_primitives::{Address, TxKind, U256, bytes}; +use revm::{ + Context, Database, DatabaseCommit, DatabaseRef, ExecuteEvm, MainBuilder, MainContext, + context::TxEnv, + context_interface::{ + either::Either, + transaction::{Authorization, RecoveredAuthority, RecoveredAuthorization}, + }, + database::DbAccount, + primitives::hardfork::SpecId, + state::{ + AccountInfo, Bytecode, + bal::{Bal, BlockAccessIndex}, + }, +}; +use std::sync::Arc; + +#[test] +fn bal_reads_use_index_and_fall_back_to_parent_state() { + let address = Address::with_last_byte(1); + let slot = U256::ZERO; + let mut backend = Backend::::spawn(None).unwrap(); + backend.insert_account_info( + address, + AccountInfo { nonce: 1, balance: U256::from(10), ..Default::default() }, + ); + backend.insert_account_storage(address, slot, U256::from(10)).unwrap(); + backend.insert_account_storage(address, U256::from(99), U256::from(77)).unwrap(); + let code = bytes!("60005400"); + let bal = Arc::new( + Bal::try_from_alloy(vec![AccountChanges { + balance_changes: vec![ + BalanceChange::new(BlockAccessIndex::new(0), U256::from(20)), + BalanceChange::new(BlockAccessIndex::new(1), U256::from(30)), + ], + code_changes: vec![CodeChange::new(BlockAccessIndex::new(0), code.clone())], + storage_changes: vec![SlotChanges::new( + slot, + vec![ + StorageChange::new(BlockAccessIndex::new(0), U256::from(20)), + StorageChange::new(BlockAccessIndex::new(1), U256::from(30)), + ], + )], + storage_reads: vec![U256::from(99)], + ..AccountChanges::new(address) + }]) + .unwrap(), + ); + + backend.set_bal(bal.clone(), BlockAccessIndex::new(1)); + let account = backend.basic(address).unwrap().unwrap(); + assert_eq!(account.balance, U256::from(20)); + assert_eq!(account.code.unwrap().original_bytes(), code); + assert_eq!(backend.storage(address, slot).unwrap(), U256::from(20)); + assert_eq!(backend.basic_ref(address).unwrap().unwrap().balance, U256::from(20)); + assert_eq!(backend.storage_ref(address, slot).unwrap(), U256::from(20)); + // A slot the block only read keeps its parent value. + assert_eq!(backend.storage_ref(address, U256::from(99)).unwrap(), U256::from(77)); + let mut cow = CowBackend::new_borrowed(&backend); + assert_eq!(cow.basic(address).unwrap().unwrap().balance, U256::from(20)); + assert_eq!(cow.storage(address, slot).unwrap(), U256::from(20)); + + backend.set_bal(bal, BlockAccessIndex::new(2)); + assert_eq!(backend.basic(address).unwrap().unwrap().balance, U256::from(30)); + assert_eq!(backend.storage(address, slot).unwrap(), U256::from(30)); + // State the list does not mention was untouched by the block and comes from the parent + // database. Execution sees an account the parent does not hold as absent. + assert_eq!(backend.storage(address, U256::from(100)).unwrap(), U256::ZERO); + assert_eq!(backend.basic(Address::with_last_byte(2)).unwrap(), None); + + // Committing removes the list again. + backend.commit(Default::default()); + assert_eq!(backend.basic(address).unwrap().unwrap().balance, U256::from(10)); + assert_eq!(backend.storage(address, slot).unwrap(), U256::from(10)); + assert_eq!(backend.basic_ref(address).unwrap().unwrap().nonce, 1); + assert_eq!(backend.storage_ref(address, slot).unwrap(), U256::from(10)); +} + +#[test] +fn bal_commit_preserves_target_and_untouched_code() { + let sender = Address::repeat_byte(0x11); + let authority = Address::repeat_byte(0x22); + let delegate = Address::repeat_byte(0x33); + let readonly = Address::repeat_byte(0x44); + let missing = Address::repeat_byte(0x66); + let mut backend = Backend::::spawn(None).unwrap(); + backend.mem_db.cache.accounts.insert(missing, DbAccount::new_not_existing()); + backend.insert_account_info(sender, AccountInfo { balance: U256::MAX, ..Default::default() }); + backend.insert_account_info(authority, AccountInfo::default()); + backend.insert_account_info(readonly, AccountInfo { nonce: 1, ..Default::default() }); + // Read code without touching either account, including an account absent from the parent. + let mut runtime = Vec::new(); + for address in [readonly, missing] { + runtime.push(0x73); + runtime.extend_from_slice(address.as_slice()); + runtime.extend_from_slice(&[0x3b, 0x50]); + } + runtime.push(0x00); + let runtime = Bytecode::new_legacy(runtime.into()); + backend.insert_account_info(delegate, AccountInfo::from_bytecode(runtime)); + let prefix_code = bytes!("600100"); + let bal = Bal::try_from_alloy(vec![ + AccountChanges::new(Address::ZERO), + AccountChanges::new(sender), + AccountChanges { + nonce_changes: vec![NonceChange::new(BlockAccessIndex::new(1), 1)], + code_changes: vec![CodeChange::new( + BlockAccessIndex::new(1), + Bytecode::new_eip7702(Address::repeat_byte(0x55)).original_bytes(), + )], + ..AccountChanges::new(authority) + }, + AccountChanges::new(delegate), + AccountChanges { + code_changes: vec![CodeChange::new(BlockAccessIndex::new(1), prefix_code.clone())], + ..AccountChanges::new(readonly) + }, + AccountChanges::new(missing), + ]) + .unwrap(); + backend.set_bal(Arc::new(bal), BlockAccessIndex::new(2)); + let authorization = RecoveredAuthorization::new_unchecked( + Authorization { chain_id: U256::ZERO, address: delegate, nonce: 1 }, + RecoveredAuthority::Valid(authority), + ); + let result = Context::mainnet() + .modify_cfg_chained(|cfg| cfg.set_spec_and_mainnet_gas_params(SpecId::PRAGUE)) + .with_db(&mut backend) + .build_mainnet() + .transact( + TxEnv::builder() + .caller(sender) + .kind(TxKind::Call(authority)) + .gas_limit(100_000) + .authorization_list(vec![Either::Right(authorization)]) + .build() + .unwrap(), + ) + .unwrap(); + assert!(result.result.is_success()); + assert!(result.state[&authority].is_touched()); + assert!(!result.state[&readonly].is_touched()); + assert!(result.state[&missing].is_loaded_as_not_existing()); + backend.commit(result.state); + + assert_eq!( + backend.basic_ref(authority).unwrap().unwrap().code, + Some(Bytecode::new_eip7702(delegate)) + ); + let account = backend.basic_ref(readonly).unwrap().unwrap(); + assert_eq!(account.code.as_ref().unwrap().original_bytes(), prefix_code); + assert_eq!( + backend.code_by_hash_ref(account.code_hash()).unwrap().original_bytes(), + prefix_code + ); + assert!(backend.basic_ref(missing).unwrap().is_none()); +} diff --git a/crates/evm/core/src/backend/cow.rs b/crates/evm/core/src/backend/cow.rs index b23aa750027e4..4a3762b39d90e 100644 --- a/crates/evm/core/src/backend/cow.rs +++ b/crates/evm/core/src/backend/cow.rs @@ -2,28 +2,43 @@ use super::BackendError; use crate::{ - AsEnvMut, Env, EnvMut, InspectorExt, + FoundryInspectorExt, backend::{ - Backend, DatabaseExt, JournaledState, LocalForkId, RevertStateSnapshotAction, - diagnostic::RevertDiagnostic, + Backend, ContextUpdateFor, DatabaseExt, ForkAccountField, JournaledState, LocalForkId, + RevertStateSnapshotAction, diagnostic::RevertDiagnostic, existing_account, + }, + evm::{ + ChainFor, EvmEnvFor, FoundryContextFor, FoundryEvmFactory, FoundryEvmNetwork, + HaltReasonFor, SpecFor, TxEnvFor, }, fork::{CreateFork, ForkId}, }; use alloy_evm::Evm; use alloy_genesis::GenesisAccount; -use alloy_primitives::{Address, B256, U256}; -use alloy_rpc_types::TransactionRequest; +use alloy_primitives::{Address, B256, TxKind, U256}; use eyre::WrapErr; use foundry_fork_db::DatabaseError; use revm::{ Database, DatabaseCommit, bytecode::Bytecode, + context::{ContextTr, Transaction}, context_interface::result::ResultAndState, database::DatabaseRef, - primitives::{HashMap as Map, hardfork::SpecId}, - state::{Account, AccountInfo}, + primitives::AddressMap, + state::{Account, AccountInfo, EvmState}, }; -use std::{borrow::Cow, collections::BTreeMap}; +use std::{borrow::Cow, collections::BTreeMap, fmt::Debug}; + +#[cfg(feature = "monad")] +use crate::evm::MonadEvmNetwork; +#[cfg(feature = "monad")] +use alloy_evm::EvmEnv; +#[cfg(feature = "monad")] +use alloy_monad_evm::MonadEvmFactory; +#[cfg(feature = "monad")] +use monad_revm::{MonadChainContext, MonadHardfork}; +#[cfg(feature = "monad")] +use revm::context::TxEnv; /// A wrapper around `Backend` that ensures only `revm::DatabaseRef` functions are called. /// @@ -41,22 +56,35 @@ use std::{borrow::Cow, collections::BTreeMap}; /// don't make use of them. Alternatively each test case would require its own `Backend` clone, /// which would add significant overhead for large fuzz sets even if the Database is not big after /// setup. -#[derive(Clone, Debug)] -pub struct CowBackend<'a> { +pub struct CowBackend<'a, FEN: FoundryEvmNetwork> { /// The underlying `Backend`. /// /// No calls on the `CowBackend` will ever persistently modify the `backend`'s state. - pub backend: Cow<'a, Backend>, - /// Keeps track of whether the backed is already initialized - is_initialized: bool, - /// The [SpecId] of the current backend. - spec_id: SpecId, + pub backend: Cow<'a, Backend>, + /// Pending initialization params for the backend on first mutable access. + /// `None` means the backend has already been initialized for the current call. + pending_init: Option<(SpecFor, Address, TxKind)>, +} + +impl Clone for CowBackend<'_, FEN> { + fn clone(&self) -> Self { + Self { backend: self.backend.clone(), pending_init: self.pending_init } + } +} + +impl Debug for CowBackend<'_, FEN> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CowBackend") + .field("backend", &self.backend) + .field("pending_init", &self.pending_init) + .finish() + } } -impl<'a> CowBackend<'a> { +impl<'a, FEN: FoundryEvmNetwork> CowBackend<'a, FEN> { /// Creates a new `CowBackend` with the given `Backend`. - pub fn new_borrowed(backend: &'a Backend) -> Self { - Self { backend: Cow::Borrowed(backend), is_initialized: false, spec_id: SpecId::default() } + pub const fn new_borrowed(backend: &'a Backend) -> Self { + Self { backend: Cow::Borrowed(backend), pending_init: None } } /// Executes the configured transaction of the `env` without committing state changes @@ -64,21 +92,37 @@ impl<'a> CowBackend<'a> { /// Note: in case there are any cheatcodes executed that modify the environment, this will /// update the given `env` with the new values. #[instrument(name = "inspect", level = "debug", skip_all)] - pub fn inspect( + pub fn inspect FoundryInspectorExt>>( &mut self, - env: &mut Env, - inspector: &mut I, - ) -> eyre::Result { + evm_env: &mut EvmEnvFor, + tx_env: &mut TxEnvFor, + inspector: I, + ) -> eyre::Result>> { + let chain_context = self.chain_context_for_synthetic_transaction(tx_env)?; + self.inspect_with_context(evm_env, tx_env, chain_context, inspector) + } + + /// Executes the configured transaction with explicit network-specific context. + #[instrument(name = "inspect", level = "debug", skip_all)] + pub fn inspect_with_context FoundryInspectorExt>>( + &mut self, + evm_env: &mut EvmEnvFor, + tx_env: &mut TxEnvFor, + chain_context: ChainFor, + inspector: I, + ) -> eyre::Result>> { // this is a new call to inspect with a new env, so even if we've cloned the backend // already, we reset the initialized state - self.is_initialized = false; - self.spec_id = env.evm_env.cfg_env.spec; + self.pending_init = Some((evm_env.cfg_env.spec, tx_env.caller(), tx_env.kind())); - let mut evm = crate::evm::new_evm_with_inspector(self, env.to_owned(), inspector); + let factory = FEN::EvmFactory::default(); + let mut evm = factory.create_foundry_evm_with_inspector(self, evm_env.clone(), inspector); + *evm.chain_mut() = chain_context; - let res = evm.transact(env.tx.clone()).wrap_err("EVM error")?; + let res = evm.transact(tx_env.clone()).wrap_err("EVM error")?; - *env = evm.as_env_mut().to_owned(); + *tx_env = evm.tx().clone(); + *evm_env = evm.finish().1; Ok(res) } @@ -93,54 +137,90 @@ impl<'a> CowBackend<'a> { /// Returns a mutable instance of the Backend. /// /// If this is the first time this is called, the backed is cloned and initialized. - fn backend_mut(&mut self, env: &EnvMut<'_>) -> &mut Backend { - if !self.is_initialized { + fn backend_mut(&mut self) -> &mut Backend { + if let Some((spec_id, caller, tx_kind)) = self.pending_init.take() { let backend = self.backend.to_mut(); - let mut env = env.to_owned(); - env.evm_env.cfg_env.spec = self.spec_id; - backend.initialize(&env); - self.is_initialized = true; + backend.initialize(spec_id, caller, tx_kind); return backend; } self.backend.to_mut() } +} - /// Returns a mutable instance of the Backend if it is initialized. - fn initialized_backend_mut(&mut self) -> Option<&mut Backend> { - if self.is_initialized { - return Some(self.backend.to_mut()); +#[cfg(feature = "monad")] +impl CowBackend<'_, MonadEvmNetwork> { + /// Tries to execute a canonical system transaction with explicit network-specific context. + #[instrument(name = "inspect_system_replay", level = "debug", skip_all)] + pub fn try_inspect_system_replay_with_context< + I: for<'db> FoundryInspectorExt>, + >( + &mut self, + evm_env: &mut EvmEnv, + tx_env: &mut TxEnv, + chain_context: MonadChainContext, + inspector: &mut I, + ) -> eyre::Result>> { + if crate::evm::protocol_system_call(tx_env)?.is_none() { + return Ok(None); } - None + + self.pending_init = Some((evm_env.cfg_env.spec, tx_env.caller(), tx_env.kind())); + + let factory = MonadEvmFactory::default(); + let mut evm = factory.create_nested_evm_with_inspector(self, evm_env.clone(), inspector); + *evm.chain_mut() = chain_context; + let result = evm.transact_raw(tx_env.clone())?; + + // A successful specialized replay replaces the EVM transaction with its synthetic system + // call. Keep the canonical envelope in `tx_env`; ordinary execution uses + // `inspect_with_context` above and copies inspector mutations back normally. + *evm_env = evm.to_evm_env(); + + Ok(Some(result)) } } -impl DatabaseExt for CowBackend<'_> { - fn snapshot_state(&mut self, journaled_state: &JournaledState, env: &mut EnvMut<'_>) -> U256 { - self.backend_mut(env).snapshot_state(journaled_state, env) +impl DatabaseExt for CowBackend<'_, FEN> { + fn chain_context_for_synthetic_transaction( + &self, + tx: &TxEnvFor, + ) -> eyre::Result> { + self.backend.chain_context_for_synthetic_transaction(tx) + } + + fn snapshot_state( + &mut self, + journaled_state: &JournaledState, + evm_env: &EvmEnvFor, + ) -> U256 { + self.backend_mut().snapshot_state(journaled_state, evm_env) } fn revert_state( &mut self, id: U256, journaled_state: &JournaledState, - current: &mut EnvMut<'_>, + evm_env: &mut EvmEnvFor, + caller: Address, action: RevertStateSnapshotAction, ) -> Option { - self.backend_mut(current).revert_state(id, journaled_state, current, action) + self.backend_mut().revert_state(id, journaled_state, evm_env, caller, action) } fn delete_state_snapshot(&mut self, id: U256) -> bool { - // delete state snapshot requires a previous snapshot to be initialized - if let Some(backend) = self.initialized_backend_mut() { - return backend.delete_state_snapshot(id); + // Snapshots can predate initialization; avoid cloning when the snapshot is missing. + if self.backend.state_snapshots().get(id).is_none() { + return false; } - false + self.backend_mut().delete_state_snapshot(id) } fn delete_state_snapshots(&mut self) { - if let Some(backend) = self.initialized_backend_mut() { - backend.delete_state_snapshots() + // Avoid cloning when there are no snapshots. + if self.backend.state_snapshots().is_empty() { + return; } + self.backend_mut().delete_state_snapshots() } fn create_fork(&mut self, fork: CreateFork) -> eyre::Result { @@ -158,44 +238,57 @@ impl DatabaseExt for CowBackend<'_> { fn select_fork( &mut self, id: LocalForkId, - env: &mut EnvMut<'_>, + evm_env: &mut EvmEnvFor, + tx_env: &mut TxEnvFor, journaled_state: &mut JournaledState, - ) -> eyre::Result<()> { - self.backend_mut(env).select_fork(id, env, journaled_state) + ) -> eyre::Result> { + self.backend_mut().select_fork(id, evm_env, tx_env, journaled_state) } fn roll_fork( &mut self, id: Option, block_number: u64, - env: &mut EnvMut<'_>, + evm_env: &mut EvmEnvFor, + tx_env: &TxEnvFor, journaled_state: &mut JournaledState, - ) -> eyre::Result<()> { - self.backend_mut(env).roll_fork(id, block_number, env, journaled_state) + ) -> eyre::Result> { + self.backend_mut().roll_fork(id, block_number, evm_env, tx_env, journaled_state) } fn roll_fork_to_transaction( &mut self, id: Option, transaction: B256, - env: &mut EnvMut<'_>, + evm_env: &mut EvmEnvFor, + tx_env: &TxEnvFor, journaled_state: &mut JournaledState, - ) -> eyre::Result<()> { - self.backend_mut(env).roll_fork_to_transaction(id, transaction, env, journaled_state) + ) -> eyre::Result> { + self.backend_mut().roll_fork_to_transaction( + id, + transaction, + evm_env, + tx_env, + journaled_state, + ) } fn transact( &mut self, id: Option, transaction: B256, - mut env: Env, + evm_env: EvmEnvFor, + outer_tx_env: &TxEnvFor, journaled_state: &mut JournaledState, - inspector: &mut dyn InspectorExt, - ) -> eyre::Result<()> { - self.backend_mut(&env.as_env_mut()).transact( + inspector: &mut dyn for<'db> FoundryInspectorExt< + ::FoundryContext<'db>, + >, + ) -> eyre::Result> { + self.backend_mut().transact( id, transaction, - env, + evm_env, + outer_tx_env, journaled_state, inspector, ) @@ -203,17 +296,14 @@ impl DatabaseExt for CowBackend<'_> { fn transact_from_tx( &mut self, - transaction: &TransactionRequest, - mut env: Env, + tx_env: TxEnvFor, + evm_env: EvmEnvFor, journaled_state: &mut JournaledState, - inspector: &mut dyn InspectorExt, + inspector: &mut dyn for<'db> FoundryInspectorExt< + ::FoundryContext<'db>, + >, ) -> eyre::Result<()> { - self.backend_mut(&env.as_env_mut()).transact_from_tx( - transaction, - env, - journaled_state, - inspector, - ) + self.backend_mut().transact_from_tx(tx_env, evm_env, journaled_state, inspector) } fn active_fork_id(&self) -> Option { @@ -224,6 +314,18 @@ impl DatabaseExt for CowBackend<'_> { self.backend.active_fork_url() } + fn active_fork_options(&self) -> Option { + self.backend.active_fork_options() + } + + fn active_fork_source_chain_id(&self) -> Option { + self.backend.active_fork_source_chain_id() + } + + fn active_fork_block_number(&self) -> Option { + self.backend.active_fork_block_number() + } + fn ensure_fork(&self, id: Option) -> eyre::Result { self.backend.ensure_fork(id) } @@ -232,12 +334,8 @@ impl DatabaseExt for CowBackend<'_> { self.backend.ensure_fork_id(id) } - fn diagnose_revert( - &self, - callee: Address, - journaled_state: &JournaledState, - ) -> Option { - self.backend.diagnose_revert(callee, journaled_state) + fn diagnose_revert(&self, callee: Address, evm_state: &EvmState) -> Option { + self.backend.diagnose_revert(callee, evm_state) } fn load_allocs( @@ -245,7 +343,7 @@ impl DatabaseExt for CowBackend<'_> { allocs: &BTreeMap, journaled_state: &mut JournaledState, ) -> Result<(), BackendError> { - self.backend_mut(&Env::default().as_env_mut()).load_allocs(allocs, journaled_state) + self.backend.to_mut().load_allocs(allocs, journaled_state) } fn clone_account( @@ -254,17 +352,31 @@ impl DatabaseExt for CowBackend<'_> { target: &Address, journaled_state: &mut JournaledState, ) -> Result<(), BackendError> { - self.backend_mut(&Env::default().as_env_mut()).clone_account( - source, - target, - journaled_state, - ) + self.backend.to_mut().clone_account(source, target, journaled_state) } fn is_persistent(&self, acc: &Address) -> bool { self.backend.is_persistent(acc) } + fn refresh_fork_account( + &mut self, + address: Address, + field: ForkAccountField, + journaled_state: &mut JournaledState, + ) -> Result<(), BackendError> { + self.backend.to_mut().refresh_fork_account(address, field, journaled_state) + } + + fn refresh_fork_storage( + &mut self, + address: Address, + slot: U256, + journaled_state: &mut JournaledState, + ) -> Result<(), BackendError> { + self.backend.to_mut().refresh_fork_storage(address, slot, journaled_state) + } + fn remove_persistent_account(&mut self, account: &Address) -> bool { self.backend.to_mut().remove_persistent_account(account) } @@ -290,7 +402,7 @@ impl DatabaseExt for CowBackend<'_> { } } -impl DatabaseRef for CowBackend<'_> { +impl DatabaseRef for CowBackend<'_, FEN> { type Error = DatabaseError; fn basic_ref(&self, address: Address) -> Result, Self::Error> { @@ -310,11 +422,12 @@ impl DatabaseRef for CowBackend<'_> { } } -impl Database for CowBackend<'_> { +impl Database for CowBackend<'_, FEN> { type Error = DatabaseError; fn basic(&mut self, address: Address) -> Result, Self::Error> { - DatabaseRef::basic_ref(self, address) + let spec = self.pending_init.map_or(self.backend.inner.spec_id, |(spec, ..)| spec); + Ok(existing_account(spec, DatabaseRef::basic_ref(self, address)?)) } fn code_by_hash(&mut self, code_hash: B256) -> Result { @@ -330,8 +443,8 @@ impl Database for CowBackend<'_> { } } -impl DatabaseCommit for CowBackend<'_> { - fn commit(&mut self, changes: Map) { +impl DatabaseCommit for CowBackend<'_, FEN> { + fn commit(&mut self, changes: AddressMap) { self.backend.to_mut().commit(changes) } } diff --git a/crates/evm/core/src/backend/error.rs b/crates/evm/core/src/backend/error.rs index 42456cceadd16..e9ed62a788284 100644 --- a/crates/evm/core/src/backend/error.rs +++ b/crates/evm/core/src/backend/error.rs @@ -24,8 +24,6 @@ pub enum BackendError { For a test environment, you can use `etch` to place the required bytecode at that address." )] MissingCreate2Deployer, - #[error("{0}")] - Other(String), } impl BackendError { @@ -60,6 +58,7 @@ impl> From> for BackendError { EVMError::Custom(err) => Self::msg(err), EVMError::Header(err) => Self::msg(err.to_string()), EVMError::Transaction(err) => Self::msg(err.to_string()), + EVMError::CustomAny(err) => Self::msg(err.to_string()), } } } diff --git a/crates/evm/core/src/backend/in_memory_db.rs b/crates/evm/core/src/backend/in_memory_db.rs index 4e35c84da59e2..0835a4dfe41b8 100644 --- a/crates/evm/core/src/backend/in_memory_db.rs +++ b/crates/evm/core/src/backend/in_memory_db.rs @@ -7,7 +7,7 @@ use revm::{ Database, DatabaseCommit, bytecode::Bytecode, database::{CacheDB, DatabaseRef, EmptyDB}, - primitives::HashMap as Map, + primitives::AddressMap, state::{Account, AccountInfo}, }; @@ -73,7 +73,7 @@ impl Database for MemDb { } impl DatabaseCommit for MemDb { - fn commit(&mut self, changes: Map) { + fn commit(&mut self, changes: AddressMap) { DatabaseCommit::commit(&mut self.inner, changes) } } @@ -122,13 +122,14 @@ impl DatabaseRef for EmptyDBWrapper { mod tests { use super::*; use alloy_primitives::b256; + use revm::database::InMemoryDB; /// Ensures the `Database(Ref)` implementation for `revm::CacheDB` works as expected /// /// Demonstrates how calling `Database::basic` works if an account does not exist #[test] fn cache_db_insert_basic_non_existing() { - let mut db = CacheDB::new(EmptyDB::default()); + let mut db = InMemoryDB::default(); let address = Address::random(); // call `basic` on a non-existing account let info = Database::basic(&mut db, address).unwrap(); @@ -148,7 +149,7 @@ mod tests { /// Demonstrates how to insert a new account but not mark it as non-existing #[test] fn cache_db_insert_basic_default() { - let mut db = CacheDB::new(EmptyDB::default()); + let mut db = InMemoryDB::default(); let address = Address::random(); // We use `basic_ref` here to ensure that the account is not marked as `NotExisting`. diff --git a/crates/evm/core/src/backend/mod.rs b/crates/evm/core/src/backend/mod.rs index abe2448523c6e..9d71d1dd093ac 100644 --- a/crates/evm/core/src/backend/mod.rs +++ b/crates/evm/core/src/backend/mod.rs @@ -1,42 +1,62 @@ //! Foundry's main executor backend abstraction and implementation. use crate::{ - AsEnvMut, Env, EnvMut, InspectorExt, + FoundryBlock, FoundryChain, FoundryInspectorExt, FoundryTransaction, FromAnyRpcTransaction, constants::{CALLER, CHEATCODE_ADDRESS, DEFAULT_CREATE2_DEPLOYER, TEST_CONTRACT_ADDRESS}, - evm::new_evm_with_inspector, - fork::{CreateFork, ForkId, MultiFork}, + evm::{ + BlockEnvFor, ChainFor, EthEvmNetwork, EvmEnvFor, FoundryContextFor, FoundryEvmFactory, + FoundryEvmNetwork, HaltReasonFor, SpecFor, TxEnvFor, + }, + fork::{CreateFork, Fork as RemoteFork, ForkId, ForkResult, MultiFork}, + opts::EvmOpts, state_snapshot::StateSnapshots, - utils::{configure_tx_env, configure_tx_req_env, get_blob_base_fee_update_fraction_by_spec_id}, + utils::{ + apply_chain_and_block_specific_env_changes_for_chain, + apply_chain_specific_tx_replay_env_changes_for_chain, + }, }; -use alloy_consensus::Typed2718; -use alloy_evm::Evm; +use alloy_chains::Chain; +use alloy_consensus::{BlockHeader, Typed2718}; +use alloy_eips::BlockNumHash; +use alloy_evm::{Evm, EvmEnv, EvmFactory, precompiles::PrecompilesMap}; use alloy_genesis::GenesisAccount; -use alloy_network::{AnyRpcBlock, AnyTxEnvelope, TransactionResponse}; -use alloy_primitives::{Address, B256, TxKind, U256, keccak256, uint}; -use alloy_rpc_types::{BlockNumberOrTag, Transaction, TransactionRequest}; +use alloy_network::{ + AnyNetwork, AnyRpcBlock, AnyRpcTransaction, BlockResponse, Network, TransactionResponse, +}; +use alloy_primitives::{Address, B256, ChainId, TxKind, U256, keccak256, map::AddressSet, uint}; +use alloy_rpc_types::{BlockNumberOrTag, BlockTransactions}; use eyre::Context; use foundry_common::{SYSTEM_TRANSACTION_TYPE, is_known_system_sender}; -pub use foundry_fork_db::{BlockchainDb, SharedBackend, cache::BlockchainDbMeta}; +use foundry_evm_networks::{NetworkConfigs, apply_bsc_p256_precompile}; +pub use foundry_fork_db::{ + AccountFetchPolicy, BlockchainDb, ForkBlock, ForkBlockEnv, SharedBackend, + cache::BlockchainDbMeta, +}; use revm::{ Database, DatabaseCommit, JournalEntry, bytecode::Bytecode, - context::JournalInner, - context_interface::{block::BlobExcessGasAndPrice, result::ResultAndState}, - database::{CacheDB, DatabaseRef}, + context::{Block, BlockEnv, CfgEnv, ContextTr, JournalInner, Transaction}, + context_interface::{journaled_state::account::JournaledAccountTr, result::ResultAndState}, + database::{AccountState, CacheDB, DatabaseRef, EmptyDB}, + database_interface::bal::BalState, inspector::NoOpInspector, - precompile::{PrecompileSpecId, Precompiles}, - primitives::{HashMap as Map, KECCAK_EMPTY, Log, hardfork::SpecId}, - state::{Account, AccountInfo, EvmState, EvmStorageSlot}, + primitives::{AddressMap, HashMap as Map, Log, hardfork::SpecId}, + state::{Account, AccountInfo, EvmState, EvmStorageSlot, TransactionId}, }; use std::{ - collections::{BTreeMap, HashMap, HashSet}, + collections::{BTreeMap, HashMap}, fmt::Debug, time::Instant, }; +#[cfg(feature = "monad")] +use crate::evm::monad::BlockContext; + mod diagnostic; pub use diagnostic::RevertDiagnostic; +mod bal; + mod error; pub use error::{BackendError, BackendResult, DatabaseError, DatabaseResult}; @@ -50,7 +70,7 @@ mod snapshot; pub use snapshot::{BackendStateSnapshot, RevertStateSnapshotAction, StateSnapshot}; // A `revm::Database` that is used in forking mode -type ForkDB = CacheDB; +type ForkDB = CacheDB>; /// Represents a numeric `ForkId` valid only for the existence of the `Backend`. /// @@ -58,10 +78,118 @@ type ForkDB = CacheDB; /// block` which can be reused by multiple tests, whereas the `LocalForkId` is unique within a test pub type LocalForkId = U256; +/// Transaction-context update required after a fork operation. +#[cfg(feature = "monad")] +pub enum ContextUpdate { + /// The operation did not change the active chain cursor or outer journal state. + Unchanged, + /// The active fork cursor changed and provides replacement chain context. + Replace(C), + /// The outer journal changed while the active chain context remained unchanged. + Rebase, +} + +/// Transaction-context update required after a fork operation, for a given [`FoundryEvmFactory`]. +/// +/// Only Monad's family-owned chain context needs to observe fork operations; every other network +/// has no use for this signal, so it collapses to `()` without the `monad` feature. +#[cfg(feature = "monad")] +pub type ContextUpdateFor = ContextUpdate<::Chain>; +#[cfg(not(feature = "monad"))] +pub type ContextUpdateFor = std::marker::PhantomData; + /// Represents the index of a fork in the created forks vector /// This is used for fast lookup type ForkLookupIndex = usize; +/// Inputs that define one transaction execution. +struct TransactionInputs { + evm_env: EvmEnvFor, + tx_env: TxEnvFor, + chain_context: ChainFor, + rpc_block_number: u64, + journaled_state: JournaledState, +} + +/// Environment and network configuration used while replaying transactions. +struct ReplayInputs { + fork_id: ForkId, + forks: MultiFork, BlockEnvFor>, + evm_env: EvmEnvFor, + networks: NetworkConfigs, +} + +/// Block data required to execute or position a fork at a transaction. +struct TransactionForkTarget { + fork_block: BlockNumHash, + transaction: AnyRpcTransaction, + block: AnyRpcBlock, + mined: bool, + position: Option, +} + +/// Position of a transaction in its canonical block. +#[derive(Clone, Copy)] +struct TransactionPosition { + index: usize, + #[cfg(feature = "monad")] + count: usize, +} + +/// A fork roll prepared for atomic publication. +#[cfg(feature = "monad")] +struct StagedForkRoll { + local_id: LocalForkId, + fork_id: ForkId, + fork_index: ForkLookupIndex, + fork: Fork>, +} + +/// Canonical chain position used to reconstruct network-specific transaction context. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ForkPosition { + /// The database contains all transactions through the fork's current block. + AfterBlock { block: BlockNumHash }, + /// The database contains the transactions before `transaction_index` in `block`. + BeforeTransaction { block: BlockNumHash, transaction_index: usize }, +} + +impl ForkPosition { + /// Returns the canonical position after committing `transaction_index`, if it immediately + /// follows this position. + fn after_transaction( + self, + block: BlockNumHash, + parent_hash: B256, + transaction_index: usize, + transaction_count: usize, + ) -> Option { + let is_next = match self { + Self::AfterBlock { block: previous } => { + transaction_index == 0 + && previous.number.checked_add(1) == Some(block.number) + && previous.hash == parent_hash + } + Self::BeforeTransaction { block: current, transaction_index: current_index } => { + current == block && current_index == transaction_index + } + }; + if !is_next { + return None; + } + let next_index = transaction_index.checked_add(1)?; + if next_index > transaction_count { + return None; + } + + Some(if next_index == transaction_count { + Self::AfterBlock { block } + } else { + Self::BeforeTransaction { block, transaction_index: next_index } + }) + } +} + /// All accounts that will have persistent storage across fork swaps. const DEFAULT_PERSISTENT_ACCOUNTS: [Address; 3] = [CHEATCODE_ADDRESS, DEFAULT_CREATE2_DEPLOYER, CALLER]; @@ -75,15 +203,42 @@ pub const GLOBAL_FAIL_SLOT: U256 = pub type JournaledState = JournalInner; +/// Account field changed by an out-of-band fork RPC mutation. +#[derive(Clone, Copy, Debug)] +pub enum ForkAccountField { + Balance, + Nonce, + Code, +} + +impl ForkAccountField { + fn update(self, target: &mut AccountInfo, refreshed: &AccountInfo) { + match self { + Self::Balance => target.balance = refreshed.balance, + Self::Nonce => target.nonce = refreshed.nonce, + Self::Code => { + target.code_hash = refreshed.code_hash(); + target.code = refreshed.code.clone(); + } + } + } +} + /// An extension trait that allows us to easily extend the `revm::Inspector` capabilities #[auto_impl::auto_impl(&mut)] -pub trait DatabaseExt: Database + DatabaseCommit + Debug { +pub trait DatabaseExt: + Database + DatabaseCommit + Debug +{ /// Creates a new state snapshot at the current point of execution. /// /// A state snapshot is associated with a new unique id that's created for the snapshot. /// State snapshots can be reverted: [DatabaseExt::revert_state], however, depending on the /// [RevertStateSnapshotAction], it will keep the snapshot alive or delete it. - fn snapshot_state(&mut self, journaled_state: &JournaledState, env: &mut EnvMut<'_>) -> U256; + fn snapshot_state( + &mut self, + journaled_state: &JournaledState, + evm_env: &EvmEnv, + ) -> U256; /// Reverts the snapshot if it exists /// @@ -93,15 +248,16 @@ pub trait DatabaseExt: Database + DatabaseCommit + Debug /// **N.B.** While this reverts the state of the evm to the snapshot, it keeps new logs made /// since the snapshots was created. This way we can show logs that were emitted between /// snapshot and its revert. - /// This will also revert any changes in the `Env` and replace it with the captured `Env` of - /// `Self::snapshot_state`. + /// This will also revert any changes in the `EvmEnv` and `TxEnv` and replace them with the + /// captured values from `Self::snapshot_state`. /// /// Depending on [RevertStateSnapshotAction] it will keep the snapshot alive or delete it. fn revert_state( &mut self, id: U256, journaled_state: &JournaledState, - env: &mut EnvMut<'_>, + evm_env: &mut EvmEnv, + caller: Address, action: RevertStateSnapshotAction, ) -> Option; @@ -120,12 +276,13 @@ pub trait DatabaseExt: Database + DatabaseCommit + Debug fn create_select_fork( &mut self, fork: CreateFork, - env: &mut EnvMut<'_>, + evm_env: &mut EvmEnv, + tx_env: &mut F::Tx, journaled_state: &mut JournaledState, - ) -> eyre::Result { + ) -> eyre::Result<(LocalForkId, ContextUpdateFor)> { let id = self.create_fork(fork)?; - self.select_fork(id, env, journaled_state)?; - Ok(id) + let context = self.select_fork(id, evm_env, tx_env, journaled_state)?; + Ok((id, context)) } /// Creates and also selects a new fork @@ -134,13 +291,14 @@ pub trait DatabaseExt: Database + DatabaseCommit + Debug fn create_select_fork_at_transaction( &mut self, fork: CreateFork, - env: &mut EnvMut<'_>, + evm_env: &mut EvmEnv, + tx_env: &mut F::Tx, journaled_state: &mut JournaledState, transaction: B256, - ) -> eyre::Result { + ) -> eyre::Result<(LocalForkId, ContextUpdateFor)> { let id = self.create_fork_at_transaction(fork, transaction)?; - self.select_fork(id, env, journaled_state)?; - Ok(id) + let context = self.select_fork(id, evm_env, tx_env, journaled_state)?; + Ok((id, context)) } /// Creates a new fork but does _not_ select it @@ -155,7 +313,7 @@ pub trait DatabaseExt: Database + DatabaseCommit + Debug /// Selects the fork's state /// - /// This will also modify the current `Env`. + /// This will also modify the current `EvmEnv` and `TxEnv`. /// /// **Note**: this does not change the local state, but swaps the remote state /// @@ -165,9 +323,10 @@ pub trait DatabaseExt: Database + DatabaseCommit + Debug fn select_fork( &mut self, id: LocalForkId, - env: &mut EnvMut<'_>, + evm_env: &mut EvmEnv, + tx_env: &mut F::Tx, journaled_state: &mut JournaledState, - ) -> eyre::Result<()>; + ) -> eyre::Result>; /// Updates the fork to given block number. /// @@ -180,9 +339,10 @@ pub trait DatabaseExt: Database + DatabaseCommit + Debug &mut self, id: Option, block_number: u64, - env: &mut EnvMut<'_>, + evm_env: &mut EvmEnv, + tx_env: &F::Tx, journaled_state: &mut JournaledState, - ) -> eyre::Result<()>; + ) -> eyre::Result>; /// Updates the fork to given transaction hash /// @@ -196,35 +356,53 @@ pub trait DatabaseExt: Database + DatabaseCommit + Debug &mut self, id: Option, transaction: B256, - env: &mut EnvMut<'_>, + evm_env: &mut EvmEnv, + tx_env: &F::Tx, journaled_state: &mut JournaledState, - ) -> eyre::Result<()>; + ) -> eyre::Result>; /// Fetches the given transaction for the fork and executes it, committing the state in the DB fn transact( &mut self, id: Option, transaction: B256, - env: Env, + evm_env: EvmEnv, + outer_tx_env: &F::Tx, journaled_state: &mut JournaledState, - inspector: &mut dyn InspectorExt, - ) -> eyre::Result<()>; + inspector: &mut dyn for<'db> FoundryInspectorExt>, + ) -> eyre::Result>; /// Executes a given TransactionRequest, commits the new state to the DB fn transact_from_tx( &mut self, - transaction: &TransactionRequest, - env: Env, + tx_env: F::Tx, + evm_env: EvmEnv, journaled_state: &mut JournaledState, - inspector: &mut dyn InspectorExt, + inspector: &mut dyn for<'db> FoundryInspectorExt>, ) -> eyre::Result<()>; + /// Returns transaction-position context for a synthetic transaction on the active database. + fn chain_context_for_synthetic_transaction(&self, tx: &F::Tx) -> eyre::Result { + Ok(F::Chain::for_transaction(tx)) + } + /// Returns the `ForkId` that's currently used in the database, if fork mode is on fn active_fork_id(&self) -> Option; /// Returns the Fork url that's currently used in the database, if fork mode is on fn active_fork_url(&self) -> Option; + /// Returns the options used to create the active fork, if fork mode is on. + fn active_fork_options(&self) -> Option; + + /// Returns the source chain ID of the active fork, independent of execution overrides. + fn active_fork_source_chain_id(&self) -> Option; + + /// Returns the active fork's current fork block number, if any. + fn active_fork_block_number(&self) -> Option { + None + } + /// Whether the database is currently in forked mode. fn is_forked_mode(&self) -> bool { self.active_fork_id().is_some() @@ -271,11 +449,7 @@ pub trait DatabaseExt: Database + DatabaseCommit + Debug /// the contract is deployed there. /// /// Returns a more useful error message if that's the case - fn diagnose_revert( - &self, - callee: Address, - journaled_state: &JournaledState, - ) -> Option; + fn diagnose_revert(&self, callee: Address, evm_state: &EvmState) -> Option; /// Loads the account allocs from the given `allocs` map into the passed [JournaledState]. /// @@ -300,6 +474,23 @@ pub trait DatabaseExt: Database + DatabaseCommit + Debug /// Returns true if the given account is currently marked as persistent. fn is_persistent(&self, acc: &Address) -> bool; + /// Refreshes an account field changed out-of-band on the active fork in any already-loaded + /// cache and journal entries. + fn refresh_fork_account( + &mut self, + address: Address, + field: ForkAccountField, + journaled_state: &mut JournaledState, + ) -> Result<(), BackendError>; + + /// Like [`refresh_fork_account`](Self::refresh_fork_account), but for a single storage slot. + fn refresh_fork_storage( + &mut self, + address: Address, + slot: U256, + journaled_state: &mut JournaledState, + ) -> Result<(), BackendError>; + /// Revokes persistent status from the given account. fn remove_persistent_account(&mut self, account: &Address) -> bool; @@ -377,8 +568,6 @@ pub trait DatabaseExt: Database + DatabaseCommit + Debug fn set_blockhash(&mut self, block_number: U256, block_hash: B256); } -struct _ObjectSafe(dyn DatabaseExt); - /// Provides the underlying `revm::Database` implementation. /// /// A `Backend` can be initialised in two forms: @@ -411,7 +600,7 @@ struct _ObjectSafe(dyn DatabaseExt); /// Multiple "forks" can be created `Backend::create_fork()`, however only 1 can be used by the /// `db`. However, their state can be hot-swapped by swapping the read half of `db` from one fork to /// another. -/// When swapping forks (`Backend::select_fork()`) we also update the current `Env` of the `EVM` +/// When swapping forks (`Backend::select_fork()`) we also update the current `EvmEnv` of the `EVM` /// accordingly, so that all `block.*` config values match /// /// When another for is selected [`DatabaseExt::select_fork()`] the entire storage, including @@ -431,11 +620,15 @@ struct _ObjectSafe(dyn DatabaseExt); /// **Note:** State snapshots work across fork-swaps, e.g. if fork `A` is currently active, then a /// snapshot is created before fork `B` is selected, then fork `A` will be the active fork again /// after reverting the snapshot. -#[derive(Clone, Debug)] #[must_use] -pub struct Backend { +pub struct Backend { + /// Active network configuration. + // TODO(monad-fen-lifecycle): Remove this after complete select/roll/transact slices move Monad + // fork-position interpretation to a concrete owner. Do not replace it with another generic + // policy or runtime family flag. + networks: NetworkConfigs, /// The access point for managing forks - forks: MultiFork, + forks: MultiFork, BlockEnvFor>, // The default in memory db mem_db: FoundryEvmInMemoryDB, /// The journaled_state to use to initialize new forks with @@ -459,17 +652,97 @@ pub struct Backend { /// /// If this is set, then the Backend is currently in forking mode active_fork_ids: Option<(LocalForkId, ForkLookupIndex)>, + /// RPC block number exposed while executing a historical transaction in a temporary backend. + fork_block_number_override: Option, + /// Optional BAL position for a single transaction against unchanged parent state. + bal: Option, /// holds additional Backend data - inner: BackendInner, + inner: BackendInner, +} + +impl Clone for Backend { + fn clone(&self) -> Self { + Self { + networks: self.networks, + forks: self.forks.clone(), + mem_db: self.mem_db.clone(), + fork_init_journaled_state: self.fork_init_journaled_state.clone(), + active_fork_ids: self.active_fork_ids, + fork_block_number_override: self.fork_block_number_override, + bal: self.bal.clone(), + inner: self.inner.clone(), + } + } +} + +impl Debug for Backend { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Backend") + .field("networks", &self.networks) + .field("forks", &self.forks) + .field("mem_db", &self.mem_db) + .field("fork_init_journaled_state", &self.fork_init_journaled_state) + .field("active_fork_ids", &self.active_fork_ids) + .field("bal", &self.bal) + .field("inner", &self.inner) + .finish() + } } -impl Backend { +impl Backend { /// Creates a new Backend with a spawned multi fork thread. /// /// If `fork` is `Some` this will use a `fork` database, otherwise with an in-memory /// database. pub fn spawn(fork: Option) -> eyre::Result { - Self::new(MultiFork::spawn(), fork) + Self::new(MultiFork::, BlockEnvFor>::spawn(), fork) + } + + /// Clones execution state with an independent registry for newly created forks. + /// + /// Remote caches remain shared, but forks created by this execution are released with it. + pub fn clone_with_fork_scope(&self) -> eyre::Result { + let mut backend = self.clone(); + backend.forks = self.forks.scoped()?; + Ok(backend) + } + + /// Returns the remote fork owned by the active backend, if execution is forked. + pub fn fork(&self) -> eyre::Result> { + let Some(id) = self.active_fork_id() else { return Ok(None) }; + let id = self.inner.issued_local_fork_ids.get(&id).expect("active fork is registered"); + self.forks.get_fork_info(id.clone()) + } + + /// Builds execution environments from this backend's selected remote block. + pub async fn env(&self, opts: &EvmOpts) -> eyre::Result<(EvmEnvFor, TxEnvFor)> { + opts.env_at_fork(self.fork()?.as_ref()).await + } + + /// Checks library-linking availability against this backend's remote state. + pub async fn can_use_create2_deployer(&self, deployer: Address) -> eyre::Result { + let Some(fork) = self.fork()? else { + return Ok(deployer == DEFAULT_CREATE2_DEPLOYER); + }; + let mut request = self + .active_fork_options() + .ok_or_else(|| eyre::eyre!("active fork configuration is unavailable"))?; + request.evm_opts.create2_deployer = deployer; + request.evm_opts.can_use_create2_deployer_at(Some(&fork)).await + } + + /// Reads a sender nonce at this backend's remote block before local execution. + pub async fn transaction_count(&self, sender: Address) -> eyre::Result { + let request = + self.active_fork_options().ok_or_else(|| eyre::eyre!("backend is not forked"))?; + let fork = self.fork()?.expect("active backend owns a fork"); + request.evm_opts.transaction_count_at_fork(sender, &fork).await + } + + /// Selects a new source using the existing manager's immutable remote caches. + pub fn replace_fork(&mut self, fork: Option) -> eyre::Result<()> { + *self = Self::new(self.forks.clone(), fork)?; + Ok(()) } /// Creates a new instance of `Backend` @@ -478,27 +751,36 @@ impl Backend { /// database. /// /// Prefer using [`spawn`](Self::spawn) instead. - pub fn new(forks: MultiFork, fork: Option) -> eyre::Result { + pub fn new( + forks: MultiFork, BlockEnvFor>, + fork: Option, + ) -> eyre::Result { trace!(target: "backend", forking_mode=?fork.is_some(), "creating executor backend"); // Note: this will take of registering the `fork` - let inner = BackendInner { - persistent_accounts: HashSet::from(DEFAULT_PERSISTENT_ACCOUNTS), - ..Default::default() - }; + let persistent_accounts = AddressSet::from_iter(DEFAULT_PERSISTENT_ACCOUNTS); + let inner = BackendInner { persistent_accounts, ..Default::default() }; let mut backend = Self { + networks: NetworkConfigs::default(), forks, mem_db: CacheDB::new(Default::default()), fork_init_journaled_state: inner.new_journaled_state(), active_fork_ids: None, + fork_block_number_override: None, + bal: None, inner, }; if let Some(fork) = fork { - let (fork_id, fork, _) = backend.forks.create_fork(fork)?; + let ForkResult { id: fork_id, backend: fork, fork: resolved, .. } = + backend.forks.create_fork(fork)?; + let context = resolved.context(); + let block = resolved.block(); let fork_db = ForkDB::new(fork); let fork_ids = backend.inner.insert_new_fork( fork_id.clone(), + block, + context.source_chain_id, fork_db, backend.inner.new_journaled_state(), ); @@ -515,11 +797,30 @@ impl Backend { /// as active pub(crate) fn new_with_fork( id: &ForkId, - fork: Fork, + fork: Fork>, + journaled_state: JournaledState, + networks: NetworkConfigs, + ) -> eyre::Result { + Self::new_with_fork_manager( + MultiFork::, BlockEnvFor>::spawn(), + id, + fork, + journaled_state, + networks, + ) + } + + fn new_with_fork_manager( + forks: MultiFork, BlockEnvFor>, + id: &ForkId, + mut fork: Fork>, journaled_state: JournaledState, + networks: NetworkConfigs, ) -> eyre::Result { - let mut backend = Self::spawn(None)?; - let fork_ids = backend.inner.insert_new_fork(id.clone(), fork.db, journaled_state); + let mut backend = Self::new(forks, None)?; + backend.networks = networks; + fork.journaled_state = journaled_state; + let fork_ids = backend.inner.insert_fork(id.clone(), fork); backend.inner.launched_with_fork = Some((id.clone(), fork_ids.0, fork_ids.1)); backend.active_fork_ids = Some(fork_ids); Ok(backend) @@ -528,14 +829,33 @@ impl Backend { /// Creates a new instance with a `BackendDatabase::InMemory` cache layer for the `CacheDB` pub fn clone_empty(&self) -> Self { Self { + networks: self.networks, forks: self.forks.clone(), mem_db: CacheDB::new(Default::default()), fork_init_journaled_state: self.inner.new_journaled_state(), active_fork_ids: None, + fork_block_number_override: None, + bal: None, inner: Default::default(), } } + /// Returns the active network configuration. + pub const fn networks(&self) -> NetworkConfigs { + self.networks + } + + /// Sets the active network configuration. + pub const fn set_networks(&mut self, networks: NetworkConfigs) { + self.networks = networks; + } + + /// Sets the block number the active fork reports while executing transactions of that block + /// on top of its parent's state. + pub const fn set_fork_block_number_override(&mut self, block_number: u64) { + self.fork_block_number_override = Some(block_number); + } + pub fn insert_account_info(&mut self, address: Address, account: AccountInfo) { if let Some(db) = self.active_fork_db_mut() { db.insert_account_info(address, account) @@ -568,16 +888,23 @@ impl Backend { storage: Map, ) -> Result<(), DatabaseError> { if let Some(db) = self.active_fork_db_mut() { - db.replace_account_storage(address, storage) + db.replace_account_storage(address, storage.into_iter().collect()) } else { - self.mem_db.replace_account_storage(address, storage) + self.mem_db.replace_account_storage(address, storage.into_iter().collect()) } } /// Returns all snapshots created in this backend - pub fn state_snapshots( + #[allow(clippy::type_complexity)] + pub const fn state_snapshots( &self, - ) -> &StateSnapshots> { + ) -> &StateSnapshots< + BackendStateSnapshot< + BackendDatabaseSnapshot>, + SpecFor, + BlockEnvFor, + >, + > { &self.inner.state_snapshots } @@ -589,8 +916,8 @@ impl Backend { /// This will also grant cheatcode access to the test account pub fn set_test_contract(&mut self, acc: Address) -> &mut Self { trace!(?acc, "setting test account"); - self.add_persistent_account(acc); - self.allow_cheatcode_access(acc); + self.inner.persistent_accounts.insert(acc); + self.inner.cheatcode_access_accounts.insert(acc); self } @@ -598,19 +925,18 @@ impl Backend { pub fn set_caller(&mut self, acc: Address) -> &mut Self { trace!(?acc, "setting caller account"); self.inner.caller = Some(acc); - self.allow_cheatcode_access(acc); + self.inner.cheatcode_access_accounts.insert(acc); self } /// Sets the current spec id - pub fn set_spec_id(&mut self, spec_id: SpecId) -> &mut Self { - trace!(?spec_id, "setting spec ID"); - self.inner.spec_id = spec_id; + pub fn set_spec_id(&mut self, spec_id: impl Into>) -> &mut Self { + self.inner.spec_id = spec_id.into(); self } /// Returns the set caller address - pub fn caller_address(&self) -> Option
{ + pub const fn caller_address(&self) -> Option
{ self.inner.caller } @@ -619,12 +945,12 @@ impl Backend { /// If an error occurs in a restored state snapshot, the test is considered failed. /// /// This returns whether there was a reverted state snapshot that recorded an error. - pub fn has_state_snapshot_failure(&self) -> bool { + pub const fn has_state_snapshot_failure(&self) -> bool { self.inner.has_state_snapshot_failure } /// Sets the state snapshot failure flag. - pub fn set_state_snapshot_failure(&mut self, has_state_snapshot_failure: bool) { + pub const fn set_state_snapshot_failure(&mut self, has_state_snapshot_failure: bool) { self.inner.has_state_snapshot_failure = has_state_snapshot_failure } @@ -632,7 +958,7 @@ impl Backend { pub(crate) fn update_fork_db( &self, active_journaled_state: &mut JournaledState, - target_fork: &mut Fork, + target_fork: &mut Fork>, ) { self.update_fork_db_contracts( self.inner.persistent_accounts.iter().copied(), @@ -646,7 +972,7 @@ impl Backend { &self, accounts: impl IntoIterator, active_journaled_state: &mut JournaledState, - target_fork: &mut Fork, + target_fork: &mut Fork>, ) { if let Some(db) = self.active_fork_db() { merge_account_data(accounts, db, active_journaled_state, target_fork) @@ -656,13 +982,13 @@ impl Backend { } /// Returns the memory db used if not in forking mode - pub fn mem_db(&self) -> &FoundryEvmInMemoryDB { + pub const fn mem_db(&self) -> &FoundryEvmInMemoryDB { &self.mem_db } /// Returns true if the `id` is currently active pub fn is_active_fork(&self, id: LocalForkId) -> bool { - self.active_fork_ids.map(|(i, _)| i == id).unwrap_or_default() + self.active_fork_ids.is_some_and(|(i, _)| i == id) } /// Returns `true` if the `Backend` is currently in forking mode @@ -671,27 +997,26 @@ impl Backend { } /// Returns the currently active `Fork`, if any - pub fn active_fork(&self) -> Option<&Fork> { + pub fn active_fork(&self) -> Option<&Fork>> { self.active_fork_ids.map(|(_, idx)| self.inner.get_fork(idx)) } /// Returns the currently active `Fork`, if any - pub fn active_fork_mut(&mut self) -> Option<&mut Fork> { + pub fn active_fork_mut(&mut self) -> Option<&mut Fork>> { self.active_fork_ids.map(|(_, idx)| self.inner.get_fork_mut(idx)) } /// Returns the currently active `ForkDB`, if any - pub fn active_fork_db(&self) -> Option<&ForkDB> { + pub fn active_fork_db(&self) -> Option<&ForkDB>> { self.active_fork().map(|f| &f.db) } /// Returns the currently active `ForkDB`, if any - pub fn active_fork_db_mut(&mut self) -> Option<&mut ForkDB> { + pub fn active_fork_db_mut(&mut self) -> Option<&mut ForkDB>> { self.active_fork_mut().map(|f| &mut f.db) } /// Returns the current database implementation as a `&dyn` value. - #[inline(always)] pub fn db(&self) -> &dyn Database { match self.active_fork_db() { Some(fork_db) => fork_db, @@ -700,7 +1025,6 @@ impl Backend { } /// Returns the current database implementation as a `&mut dyn` value. - #[inline(always)] pub fn db_mut(&mut self) -> &mut dyn Database { match self.active_fork_ids.map(|(_, idx)| &mut self.inner.get_fork_mut(idx).db) { Some(fork_db) => fork_db, @@ -709,7 +1033,9 @@ impl Backend { } /// Creates a snapshot of the currently active database - pub(crate) fn create_db_snapshot(&self) -> BackendDatabaseSnapshot { + pub(crate) fn create_db_snapshot( + &self, + ) -> BackendDatabaseSnapshot> { if let Some((id, idx)) = self.active_fork_ids { let fork = self.inner.get_fork(idx).clone(); let fork_id = self.inner.ensure_fork_id(id).cloned().expect("Exists; qed"); @@ -745,18 +1071,21 @@ impl Backend { /// Initializes settings we need to keep track of. /// /// We need to track these mainly to prevent issues when switching between different evms - pub(crate) fn initialize(&mut self, env: &Env) { - self.set_caller(env.tx.caller); - self.set_spec_id(env.evm_env.cfg_env.spec); + pub(crate) fn initialize( + &mut self, + spec_id: impl Into>, + caller: Address, + tx_kind: TxKind, + ) { + self.set_caller(caller); + self.set_spec_id(spec_id); - let test_contract = match env.tx.kind { + let test_contract = match tx_kind { TxKind::Call(to) => to, TxKind::Create => { - let nonce = self - .basic_ref(env.tx.caller) - .map(|b| b.unwrap_or_default().nonce) - .unwrap_or_default(); - env.tx.caller.create(nonce) + let nonce = + self.basic_ref(caller).map(|b| b.unwrap_or_default().nonce).unwrap_or_default(); + caller.create(nonce) } }; self.set_test_contract(test_contract); @@ -767,24 +1096,41 @@ impl Backend { /// Note: in case there are any cheatcodes executed that modify the environment, this will /// update the given `env` with the new values. #[instrument(name = "inspect", level = "debug", skip_all)] - pub fn inspect( + pub fn inspect FoundryInspectorExt>>( &mut self, - env: &mut Env, - inspector: &mut I, - ) -> eyre::Result { - self.initialize(env); - let mut evm = crate::evm::new_evm_with_inspector(self, env.to_owned(), inspector); - - let res = evm.transact(env.tx.clone()).wrap_err("EVM error")?; + evm_env: &mut EvmEnvFor, + tx_env: &mut TxEnvFor, + inspector: I, + ) -> eyre::Result>> { + let chain_context = self.chain_context_for_synthetic_transaction(tx_env)?; + self.inspect_with_context(evm_env, tx_env, chain_context, inspector) + } - *env = evm.as_env_mut().to_owned(); + /// Executes the configured test call with explicit network-specific context. + #[instrument(name = "inspect", level = "debug", skip_all)] + pub fn inspect_with_context FoundryInspectorExt>>( + &mut self, + evm_env: &mut EvmEnvFor, + tx_env: &mut TxEnvFor, + chain_context: ChainFor, + inspector: I, + ) -> eyre::Result>> { + self.initialize(evm_env.cfg_env.spec, tx_env.caller(), tx_env.kind()); + let factory = FEN::EvmFactory::default(); + let mut evm = + factory.create_foundry_evm_with_inspector(self, evm_env.to_owned(), inspector); + *evm.chain_mut() = chain_context; + let res = evm.transact(tx_env.clone()).wrap_err("EVM error")?; + + *tx_env = evm.tx().clone(); + *evm_env = evm.finish().1; Ok(res) } /// Returns true if the address is a precompile pub fn is_existing_precompile(&self, addr: &Address) -> bool { - self.inner.precompiles().contains(addr) + self.inner.precompile_addresses().contains(addr) } /// Sets the initial journaled state to use when initializing forks @@ -808,7 +1154,10 @@ impl Backend { .fork_init_journaled_state .state .iter() - .filter(|(addr, _)| !self.is_existing_precompile(addr) && !self.is_persistent(addr)) + .filter(|(addr, _)| { + !self.is_existing_precompile(addr) + && !self.inner.persistent_accounts.contains(*addr) + }) .map(|(addr, _)| addr) .copied() .collect::>(); @@ -844,191 +1193,898 @@ impl Backend { &self, id: LocalForkId, transaction: B256, - ) -> eyre::Result<(u64, AnyRpcBlock)> { + ) -> eyre::Result { let fork = self.inner.get_fork_by_id(id)?; - let tx = fork.db.db.get_transaction(transaction)?; + let tx = fork.backend().get_transaction(transaction)?; // get the block number we need to fork - if let Some(tx_block) = tx.block_number { - let block = fork.db.db.get_full_block(tx_block)?; + if let Some(tx_block) = tx.block_number() { + let tx_block_hash = tx + .block_hash() + .ok_or_else(|| eyre::eyre!("mined transaction is missing its block hash"))?; + let block = fork.backend().get_full_block(tx_block_hash)?; + eyre::ensure!( + block.header().number() == tx_block && block.header().hash == tx_block_hash, + "transaction block changed: expected {} ({}), got {} ({})", + tx_block, + tx_block_hash, + block.header().number(), + block.header().hash + ); + let position = if let BlockTransactions::Full(transactions) = block.transactions() { + let index = transactions.iter().position(|tx| tx.tx_hash() == transaction); + if self.networks.is_monad() && index.is_none() { + eyre::bail!( + "transaction {transaction:?} is missing from block {}", + block.header().number() + ); + } + index.map(|index| TransactionPosition { + index, + #[cfg(feature = "monad")] + count: transactions.len(), + }) + } else { + if self.networks.is_monad() { + eyre::bail!( + "block {} does not contain full transactions", + block.header().number() + ); + } + None + }; // we need to subtract 1 here because we want the state before the transaction // was mined - let fork_block = tx_block - 1; - Ok((fork_block, block)) + let fork_block = BlockNumHash::new( + tx_block.checked_sub(1).ok_or_else(|| { + eyre::eyre!("cannot replay a transaction in the genesis block") + })?, + block.header().parent_hash(), + ); + Ok(TransactionForkTarget { fork_block, transaction: tx, block, mined: true, position }) } else { - let block = fork.db.db.get_full_block(BlockNumberOrTag::Latest)?; + if self.networks.is_monad() { + eyre::bail!( + "transaction {transaction} is pending and has no canonical block context" + ); + } + let block = fork.backend().get_full_block(BlockNumberOrTag::Latest)?; - let number = block.header.number; + let fork_block = BlockNumHash::new(block.header().number(), block.header().hash); - Ok((number, block)) + Ok(TransactionForkTarget { + fork_block, + transaction: tx, + block, + mined: false, + position: None, + }) } } - /// Replays all the transactions at the forks current block that were mined before the `tx` - /// - /// Returns the _unmined_ transaction that corresponds to the given `tx_hash` - pub fn replay_until( - &mut self, - id: LocalForkId, - mut env: Env, - tx_hash: B256, - journaled_state: &mut JournaledState, - ) -> eyre::Result>> { - trace!(?id, ?tx_hash, "replay until transaction"); + /// Converts all transactions in a full RPC block into this backend's transaction environment. + #[cfg(feature = "monad")] + fn full_block_tx_envs(block: &AnyRpcBlock) -> eyre::Result>> { + let BlockTransactions::Full(transactions) = block.transactions() else { + eyre::bail!("block {} does not contain full transactions", block.header().number()); + }; + transactions.iter().map(TxEnvFor::::from_any_rpc_transaction).collect() + } - let persistent_accounts = self.inner.persistent_accounts.clone(); - let fork_id = self.ensure_fork_id(id)?.clone(); + /// Converts replayable transactions, skipping system envelopes this network cannot decode. + fn replay_tx_env(tx: &AnyRpcTransaction) -> eyre::Result>> { + let is_system = is_known_system_sender(tx.from()) || tx.ty() == SYSTEM_TRANSACTION_TYPE; + if is_system { + return Ok(TxEnvFor::::from_any_rpc_transaction(tx).ok()); + } - let fork = self.inner.get_fork_by_id_mut(id)?; - let full_block = - fork.db.db.get_full_block(env.evm_env.block_env.number.saturating_to::())?; - - for tx in full_block.inner.transactions.txns() { - // System transactions such as on L2s don't contain any pricing info so we skip them - // otherwise this would cause reverts - if is_known_system_sender(tx.inner().inner.signer()) - || tx.ty() == SYSTEM_TRANSACTION_TYPE - { - trace!(tx=?tx.tx_hash(), "skipping system transaction"); - continue; - } + TxEnvFor::::from_any_rpc_transaction(tx).map(Some) + } - if tx.tx_hash() == tx_hash { - // found the target transaction - return Ok(Some(tx.inner.clone())); - } - trace!(tx=?tx.tx_hash(), "committing transaction"); + /// Returns the transaction environments needed to construct exact block context. + #[cfg(feature = "monad")] + fn block_context_inputs_from_backend( + backend: &SharedBackend>, + block: &AnyRpcBlock, + ) -> eyre::Result> { + let current = Self::full_block_tx_envs(block)?; - commit_transaction( - &tx.inner, - &mut env.as_env_mut(), - journaled_state, - fork, - &fork_id, - &persistent_accounts, - &mut NoOpInspector, + let parent_hash = block.header().parent_hash(); + let parent_block = if parent_hash.is_zero() { + None + } else { + let parent_number = block.header().number().checked_sub(1).ok_or_else(|| { + eyre::eyre!("genesis block has non-zero parent hash {parent_hash}") + })?; + let parent = backend + .get_full_block(parent_hash) + .wrap_err_with(|| format!("failed to fetch parent block {parent_hash}"))?; + ensure_block_identity( + &parent, + BlockNumHash::new(parent_number, parent_hash), + "parent", )?; - } + Some(parent) + }; + let parent = + parent_block.as_ref().map(Self::full_block_tx_envs).transpose()?.unwrap_or_default(); - Ok(None) - } -} + let grandparent = if let Some(parent_block) = &parent_block { + let grandparent_hash = parent_block.header().parent_hash(); + if grandparent_hash.is_zero() { + Vec::new() + } else { + let block = backend.get_full_block(grandparent_hash).wrap_err_with(|| { + format!("failed to fetch grandparent block {grandparent_hash}") + })?; + let grandparent_number = + parent_block.header().number().checked_sub(1).ok_or_else(|| { + eyre::eyre!("genesis block has non-zero parent hash {grandparent_hash}") + })?; + ensure_block_identity( + &block, + BlockNumHash::new(grandparent_number, grandparent_hash), + "grandparent", + )?; + Self::full_block_tx_envs(&block)? + } + } else { + Vec::new() + }; -impl DatabaseExt for Backend { - fn snapshot_state(&mut self, journaled_state: &JournaledState, env: &mut EnvMut<'_>) -> U256 { - trace!("create snapshot"); - let id = self.inner.state_snapshots.insert(BackendStateSnapshot::new( - self.create_db_snapshot(), - journaled_state.clone(), - env.to_owned(), - )); - trace!(target: "backend", "Created new snapshot {}", id); - id + Ok(BlockContext::new(grandparent, parent, current)) } - fn revert_state( - &mut self, - id: U256, - current_state: &JournaledState, - current: &mut EnvMut<'_>, - action: RevertStateSnapshotAction, - ) -> Option { - trace!(?id, "revert snapshot"); - if let Some(mut snapshot) = self.inner.state_snapshots.remove_at(id) { - // Re-insert snapshot to persist it - if action.is_keep() { - self.inner.state_snapshots.insert_at(snapshot.clone(), id); - } + /// Returns the transaction environments needed to construct exact block context for a fork. + #[cfg(feature = "monad")] + fn block_context_inputs( + &self, + id: LocalForkId, + block: &AnyRpcBlock, + ) -> eyre::Result> { + let fork = self.inner.get_fork_by_id(id)?; + Self::block_context_inputs_from_backend(fork.backend(), block) + } - // https://github.com/foundry-rs/foundry/issues/3055 - // Check if an error occurred either during or before the snapshot. - // DSTest contracts don't have snapshot functionality, so this slot is enough to check - // for failure here. - if let Some(account) = current_state.state.get(&CHEATCODE_ADDRESS) - && let Some(slot) = account.storage.get(&GLOBAL_FAIL_SLOT) - && !slot.present_value.is_zero() - { - self.set_state_snapshot_failure(true); + /// Builds transaction context for `tx` at a known position in `block_context`. + #[cfg(feature = "monad")] + fn context_for_block_position( + block_context: BlockContext, + position: ForkPosition, + tx: &TxEnvFor, + ) -> eyre::Result> { + let cursor = match position { + ForkPosition::AfterBlock { .. } => block_context.into_child(), + ForkPosition::BeforeTransaction { transaction_index, .. } => { + block_context.before_transaction(transaction_index)? } + }; + Ok(cursor.next_transaction(tx)) + } - // merge additional logs - snapshot.merge(current_state); - let BackendStateSnapshot { db, mut journaled_state, env } = snapshot; - match db { - BackendDatabaseSnapshot::InMemory(mem_db) => { - self.mem_db = mem_db; - } - BackendDatabaseSnapshot::Forked(id, fork_id, idx, mut fork) => { - // there might be the case where the snapshot was created during `setUp` with - // another caller, so we need to ensure the caller account is present in the - // journaled state and database - let caller = current.tx.caller; - journaled_state.state.entry(caller).or_insert_with(|| { - let caller_account = current_state - .state - .get(&caller) - .map(|acc| acc.info.clone()) - .unwrap_or_default(); + /// Builds context for a synthetic transaction at a fork's current position. + #[cfg(feature = "monad")] + fn context_for_fork_synthetic_transaction( + &self, + id: LocalForkId, + tx: &TxEnvFor, + ) -> eyre::Result> { + if !self.networks.is_monad() { + return Ok(ChainFor::::for_transaction(tx)); + } - if !fork.db.cache.accounts.contains_key(&caller) { - // update the caller account which is required by the evm - fork.db.insert_account_info(caller, caller_account.clone()); - } - caller_account.into() - }); - self.inner.revert_state_snapshot(id, fork_id, idx, *fork); - self.active_fork_ids = Some((id, idx)) - } - } + let fork = self.inner.get_fork_by_id(id)?; + let (position_block, position) = match fork.position { + position @ (ForkPosition::AfterBlock { block } + | ForkPosition::BeforeTransaction { block, .. }) => (block, position), + }; + let block = fork.backend().get_full_block(position_block.hash).wrap_err_with(|| { + format!( + "failed to fetch fork block {} ({})", + position_block.number, position_block.hash + ) + })?; + ensure_block_identity(&block, position_block, "fork")?; + let context = Self::block_context_inputs_from_backend(fork.backend(), &block)?; + Self::context_for_block_position(context, position, tx) + } - update_current_env_with_fork_env(&mut current.as_env_mut(), env); - trace!(target: "backend", "Reverted snapshot {}", id); + /// Returns the block cursor matching the active fork's database position. + #[cfg(feature = "monad")] + pub fn block_context_for_synthetic_transaction( + &self, + ) -> eyre::Result>> { + if !self.networks.is_monad() { + return Ok(None); + } + let Some(id) = self.active_fork_id() else { + return Ok(None); + }; - Some(journaled_state) - } else { - warn!(target: "backend", "No snapshot to revert for {}", id); - None + let fork = self.inner.get_fork_by_id(id)?; + let (position_block, transaction_index) = match fork.position { + ForkPosition::AfterBlock { block } => (block, None), + ForkPosition::BeforeTransaction { block, transaction_index } => { + (block, Some(transaction_index)) + } + }; + let block = fork.backend().get_full_block(position_block.hash).wrap_err_with(|| { + format!( + "failed to fetch active fork block {} ({})", + position_block.number, position_block.hash + ) + })?; + ensure_block_identity(&block, position_block, "active fork")?; + let context = Self::block_context_inputs_from_backend(fork.backend(), &block)?; + + match transaction_index { + Some(index) => context.before_transaction(index).map(Some), + None => Ok(Some(context.into_child())), } } - fn delete_state_snapshot(&mut self, id: U256) -> bool { - self.inner.state_snapshots.remove_at(id).is_some() + /// Applies replay changes using the targeted fork's chain rather than the active environment. + fn apply_fork_tx_replay_env_changes( + &self, + id: LocalForkId, + evm_env: &mut EvmEnvFor, + ) -> eyre::Result<()> { + let fork_id = self.inner.ensure_fork_id(id).cloned()?; + let source_chain_id = self.inner.get_fork_by_id(id)?.source_chain_id; + self.apply_fork_tx_replay_env_changes_for(&fork_id, source_chain_id, evm_env) } - fn delete_state_snapshots(&mut self) { - self.inner.state_snapshots.clear() + /// Applies replay changes using an explicitly staged fork identity. + fn apply_fork_tx_replay_env_changes_for( + &self, + fork_id: &ForkId, + source_chain_id: ChainId, + evm_env: &mut EvmEnvFor, + ) -> eyre::Result<()> { + let fork_evm_env = self + .forks + .get_evm_env(fork_id.clone())? + .ok_or_else(|| eyre::eyre!("Requested fork `{fork_id}` does not exist"))?; + evm_env.cfg_env.chain_id = fork_evm_env.cfg_env.chain_id; + apply_chain_specific_tx_replay_env_changes_for_chain(evm_env, source_chain_id); + Ok(()) } - fn create_fork(&mut self, create_fork: CreateFork) -> eyre::Result { - trace!("create fork"); - let (fork_id, fork, _) = self.forks.create_fork(create_fork)?; + /// Populates a rolled active fork and the outer journal at the new block state. + fn populate_rolled_active_fork( + fork: &mut Fork>, + persistent_accounts: &AddressSet, + caller: Option
, + journaled_state: &mut JournaledState, + ) { + let mut persistent_addrs = persistent_accounts.clone(); + persistent_addrs.extend(caller); - let fork_db = ForkDB::new(fork); - let (id, _) = - self.inner.insert_new_fork(fork_id, fork_db, self.fork_init_journaled_state.clone()); - Ok(id) - } + fork.journaled_state.depth = journaled_state.depth; - fn create_fork_at_transaction( - &mut self, + for addr in persistent_addrs { + merge_journaled_state_data(addr, journaled_state, &mut fork.journaled_state); + } + + for (addr, acc) in &journaled_state.state { + if acc.is_created() && acc.is_touched() { + merge_journaled_state_data(*addr, journaled_state, &mut fork.journaled_state); + } else if !acc.is_created() { + let _ = fork.journaled_state.load_account(&mut fork.db, *addr); + } + } + + *journaled_state = fork.journaled_state.clone(); + } + + /// Reinitializes a rolled active fork before populating its journal at the new block state. + fn reset_rolled_active_fork( + fork: &mut Fork>, + fork_init_journaled_state: &JournaledState, + persistent_accounts: &AddressSet, + caller: Option
, + journaled_state: &mut JournaledState, + ) { + fork.journaled_state = fork_init_journaled_state.clone(); + Self::populate_rolled_active_fork(fork, persistent_accounts, caller, journaled_state); + } + + /// Reinitializes the stored journal of a rolled fork that is not active. + /// + /// The journal still holds the accounts that were loaded while the fork was last selected, at + /// the block it was on then. Selecting the fork again would keep serving those values, so it + /// starts over from the initial journal. Accounts created on the fork are carried over, as for + /// an active roll; persistent accounts and the caller are merged when the fork is selected. + fn reset_rolled_inactive_fork( + fork: &mut Fork>, + fork_init_journaled_state: &JournaledState, + ) { + let rolled = + std::mem::replace(&mut fork.journaled_state, fork_init_journaled_state.clone()); + for (addr, acc) in &rolled.state { + if acc.is_created() && acc.is_touched() { + merge_journaled_state_data(*addr, &rolled, &mut fork.journaled_state); + } + } + } + + /// Rolls a fork while preparing active transaction context before publishing the new fork. + fn roll_fork_with_context( + &mut self, + id: Option, + block_number: u64, + evm_env: &mut EvmEnvFor, + tx_env: Option<&TxEnvFor>, + journaled_state: &mut JournaledState, + ) -> eyre::Result> { + trace!(?id, ?block_number, "roll fork"); + let id = self.ensure_fork(id)?; + let rolled = self.forks.roll_fork(self.inner.ensure_fork_id(id).cloned()?, block_number)?; + self.apply_rolled_fork_with_context(id, rolled, evm_env, tx_env, journaled_state) + } + + fn roll_fork_exact_with_context( + &mut self, + id: LocalForkId, + block: BlockNumHash, + prewarm_bal: bool, + evm_env: &mut EvmEnvFor, + tx_env: Option<&TxEnvFor>, + journaled_state: &mut JournaledState, + ) -> eyre::Result> { + trace!(?id, ?block, "roll fork to exact block"); + let rolled = self.forks.roll_fork_exact_with_bal( + self.inner.ensure_fork_id(id).cloned()?, + block, + prewarm_bal, + )?; + self.apply_rolled_fork_with_context(id, rolled, evm_env, tx_env, journaled_state) + } + + fn apply_rolled_fork_with_context( + &mut self, + id: LocalForkId, + rolled: ForkResult, BlockEnvFor>, + evm_env: &mut EvmEnvFor, + _tx_env: Option<&TxEnvFor>, + journaled_state: &mut JournaledState, + ) -> eyre::Result> { + let ForkResult { id: fork_id, backend, env: fork_env, fork: resolved } = rolled; + let context = resolved.context(); + let block = resolved.block(); + let _affects_active = self.is_active_fork(id); + + #[cfg(feature = "monad")] + let context_update = if _affects_active + && self.networks.is_monad() + && let Some(tx) = _tx_env + { + let block_data = backend.get_full_block(block.hash).wrap_err_with(|| { + format!("failed to fetch rolled fork block {} ({})", block.number, block.hash) + })?; + ensure_block_identity(&block_data, block, "rolled fork")?; + let block_context = Self::block_context_inputs_from_backend(&backend, &block_data)?; + ContextUpdate::Replace(block_context.into_child().next_transaction(tx)) + } else { + ContextUpdate::Unchanged + }; + #[cfg(not(feature = "monad"))] + let context_update = std::marker::PhantomData; + + // Update the local mapping only after all context fetches and decoding have succeeded. + let idx = self.inner.roll_fork(id, fork_id, block, context.source_chain_id, backend)?; + + if let Some((active_id, active_idx)) = self.active_fork_ids + && active_id == id + { + let preserved_spec = evm_env.cfg_env.spec; + *evm_env = fork_env; + evm_env.cfg_env.set_spec_and_mainnet_gas_params(preserved_spec); + + let persistent_accounts = self.inner.persistent_accounts.clone(); + let caller = self.inner.caller; + let active = self.inner.get_fork_mut(active_idx); + Self::reset_rolled_active_fork( + active, + &self.fork_init_journaled_state, + &persistent_accounts, + caller, + journaled_state, + ); + } else { + let fork = self.inner.get_fork_mut(idx); + Self::reset_rolled_inactive_fork(fork, &self.fork_init_journaled_state); + } + + Ok(context_update) + } + + /// Rolls a fork to a transaction while reusing one precomputed block context for replay and + /// the final active-fork cursor. + fn roll_fork_to_transaction_with_context( + &mut self, + id: Option, + transaction: B256, + evm_env: &mut EvmEnvFor, + _tx_env: Option<&TxEnvFor>, + journaled_state: &mut JournaledState, + ) -> eyre::Result> { + if !self.networks.is_monad() { + return self.roll_fork_to_transaction_inner(id, transaction, evm_env, journaled_state); + } + + #[cfg(not(feature = "monad"))] + unreachable!("block context is only required when Monad support is enabled"); + + #[cfg(feature = "monad")] + { + trace!(?id, ?transaction, "roll fork to transaction"); + let id = self.ensure_fork(id)?; + let affects_active = self.is_active_fork(id); + let TransactionForkTarget { fork_block, block, position, .. } = + self.get_block_number_and_block_for_transaction(id, transaction)?; + let position = position.expect("Monad transaction target includes canonical position"); + let block_context = self.block_context_inputs(id, &block)?; + let context_update = if affects_active && let Some(tx) = _tx_env { + let fork_position = ForkPosition::BeforeTransaction { + block: BlockNumHash::new(block.header().number(), block.header().hash), + transaction_index: position.index, + }; + ContextUpdate::Replace(Self::context_for_block_position( + block_context.clone(), + fork_position, + tx, + )?) + } else if affects_active { + ContextUpdate::Unchanged + } else { + ContextUpdate::Rebase + }; + + let current_fork_id = self.inner.ensure_fork_id(id).cloned()?; + let ForkResult { id: fork_id, backend, env: fork_env, fork: resolved } = + self.forks.roll_fork_exact(current_fork_id, fork_block)?; + let staged_fork_journaled_state = if affects_active { + self.fork_init_journaled_state.clone() + } else { + self.inner.get_fork_by_id(id)?.journaled_state.clone() + }; + let mut staged_fork = self.inner.stage_fork_roll( + id, + fork_id, + fork_block, + resolved.context().source_chain_id, + backend, + staged_fork_journaled_state, + )?; + let mut staged_evm_env = evm_env.clone(); + let mut staged_journaled_state = journaled_state.clone(); + + if affects_active { + let preserved_spec = staged_evm_env.cfg_env.spec; + staged_evm_env = fork_env; + staged_evm_env.cfg_env.set_spec_and_mainnet_gas_params(preserved_spec); + Self::populate_rolled_active_fork( + &mut staged_fork.fork, + &self.inner.persistent_accounts, + self.inner.caller, + &mut staged_journaled_state, + ); + } + + update_env_block::( + &mut staged_evm_env, + &block, + staged_fork.fork.source_chain_id, + self.networks, + ); + let mut replay_env = staged_evm_env.clone(); + self.apply_fork_tx_replay_env_changes_for( + &staged_fork.fork_id, + staged_fork.fork.source_chain_id, + &mut replay_env, + )?; + let target = Self::replay_until( + &mut staged_fork.fork, + ReplayInputs { + fork_id: staged_fork.fork_id.clone(), + forks: self.forks.clone(), + evm_env: replay_env, + networks: self.networks, + }, + &block, + Some(&block_context), + transaction, + &mut staged_journaled_state, + &self.inner.persistent_accounts, + )?; + eyre::ensure!( + target.is_some(), + "transaction {transaction:?} is missing from block {}", + block.header().number() + ); + staged_fork.fork.position = ForkPosition::BeforeTransaction { + block: BlockNumHash::new(block.header().number(), block.header().hash), + transaction_index: position.index, + }; + + staged_fork.fork.block_env = Some(staged_evm_env.block_env.clone()); + self.inner.publish_fork_roll(staged_fork); + *evm_env = staged_evm_env; + *journaled_state = staged_journaled_state; + Ok(context_update) + } + } + + /// Performs a transaction-level roll on the provided backend, environment, and journal. + fn roll_fork_to_transaction_inner( + &mut self, + id: Option, + transaction: B256, + evm_env: &mut EvmEnvFor, + journaled_state: &mut JournaledState, + ) -> eyre::Result> { + trace!(?id, ?transaction, "roll fork to transaction"); + let id = self.ensure_fork(id)?; + let _affects_active = self.is_active_fork(id); + + let TransactionForkTarget { fork_block, block, mined, position, .. } = + self.get_block_number_and_block_for_transaction(id, transaction)?; + #[cfg(feature = "monad")] + let context_update = + if _affects_active { ContextUpdate::Unchanged } else { ContextUpdate::Rebase }; + #[cfg(not(feature = "monad"))] + let context_update = std::marker::PhantomData; + + // The parent roll must not prepare an intermediate synthetic context. + self.roll_fork_exact_with_context(id, fork_block, mined, evm_env, None, journaled_state)?; + + let source_chain_id = self.inner.get_fork_by_id(id)?.source_chain_id; + update_env_block::(evm_env, &block, source_chain_id, self.networks); + + let mut replay_env = evm_env.clone(); + self.apply_fork_tx_replay_env_changes(id, &mut replay_env)?; + let persistent_accounts = self.inner.persistent_accounts.clone(); + let target = if mined { + let fork_id = self.inner.ensure_fork_id(id)?.clone(); + let forks = self.forks.clone(); + let fork = self.inner.get_fork_by_id_mut(id)?; + Self::replay_until( + fork, + ReplayInputs { fork_id, forks, evm_env: replay_env, networks: self.networks }, + &block, + #[cfg(feature = "monad")] + None, + transaction, + journaled_state, + &persistent_accounts, + )? + } else { + None + }; + if target.is_some() + && let Some(position) = position + { + self.inner.get_fork_by_id_mut(id)?.position = ForkPosition::BeforeTransaction { + block: BlockNumHash::new(block.header().number(), block.header().hash), + transaction_index: position.index, + }; + } + + // Publish the local block environment only after replay and cursor updates succeed. + self.inner.get_fork_by_id_mut(id)?.block_env = Some(evm_env.block_env.clone()); + + Ok(context_update) + } + + /// Replays all the transactions at the forks current block that were mined before the `tx` + /// + /// Returns the _unmined_ transaction that corresponds to the given `tx_hash` + fn replay_until( + fork: &mut Fork>, + replay: ReplayInputs, + full_block: &AnyRpcBlock, + #[cfg(feature = "monad")] block_context: Option<&BlockContext>, + tx_hash: B256, + journaled_state: &mut JournaledState, + persistent_accounts: &AddressSet, + ) -> eyre::Result> { + let ReplayInputs { fork_id, forks, evm_env, networks } = replay; + trace!(?tx_hash, "replay until transaction"); + #[cfg(feature = "monad")] + eyre::ensure!( + !networks.is_monad() || block_context.is_some(), + "block context is required to replay transactions for this network" + ); + + let BlockTransactions::Full(transactions) = full_block.transactions() else { + eyre::bail!( + "block {} does not contain full transactions", + full_block.header().number() + ); + }; + let Some(target_index) = transactions.iter().position(|tx| tx.tx_hash() == tx_hash) else { + return Ok(None); + }; + if networks.is_monad() { + eyre::ensure!( + fork.position + .after_transaction( + BlockNumHash::new(full_block.header().number(), full_block.header().hash), + full_block.header().parent_hash(), + 0, + transactions.len(), + ) + .is_some(), + "block {} does not immediately follow the active fork position", + full_block.header().number() + ); + } + let target_tx = transactions[target_index].clone(); + let factory = FEN::EvmFactory::default(); + let mut txs_to_replay = Vec::with_capacity(target_index); + for (index, tx) in transactions[..target_index].iter().enumerate() { + let Some(tx_env) = Self::replay_tx_env(tx)? else { continue }; + let is_system = is_known_system_sender(tx.from()) || tx.ty() == SYSTEM_TRANSACTION_TYPE; + txs_to_replay.push((index, *tx.tx_hash(), tx_env, is_system)); + } + + // Replay all preceding transactions against a cloned ForkDB. + if !txs_to_replay.is_empty() { + let now = Instant::now(); + + // Stage the prefix against one cloned fork cache. The temporary backend also + // supplies the existing DatabaseExt boundary needed by nested execution. + let chain_id = evm_env.cfg_env.chain_id; + let timestamp = evm_env.block_env.timestamp().saturating_to(); + let mut replay_backend = Self::new_with_fork_manager( + forks, + &fork_id, + fork.clone(), + journaled_state.clone(), + networks, + )?; + + #[cfg(feature = "monad")] + if let Some(context) = block_context { + for (index, tx_hash, tx_env, is_system) in &txs_to_replay { + let mut evm = factory.create_nested_evm(&mut replay_backend, evm_env.clone()); + *evm.chain_mut() = context.transaction(*index); + inject_replay_precompiles(networks, evm.precompiles_mut(), chain_id, timestamp); + trace!(tx=?tx_hash, "committing transaction"); + let result = evm + .transact_replay(tx_env.clone(), *is_system) + .wrap_err("backend: failed replaying transaction")?; + drop(evm); + if let Some(result) = result { + replay_backend.commit(result.state); + } + } + } + #[cfg(feature = "monad")] + let replay_without_context = block_context.is_none(); + #[cfg(not(feature = "monad"))] + let replay_without_context = true; + if replay_without_context { + // Reuse one Foundry EVM for each consecutive batch of ordinary transactions. + // Recreate it after a system transaction commits so its journal cannot retain + // account state from before the separately executed system transaction. + let mut index = 0; + while index < txs_to_replay.len() { + let (_, tx_hash, tx_env, is_system) = &txs_to_replay[index]; + if *is_system { + trace!(tx=?tx_hash, "committing transaction"); + let mut replay = + factory.create_nested_evm(&mut replay_backend, evm_env.clone()); + inject_replay_precompiles( + networks, + replay.precompiles_mut(), + chain_id, + timestamp, + ); + if let Some(result) = replay + .transact_replay(tx_env.clone(), true) + .wrap_err("backend: failed replaying system transaction")? + { + drop(replay); + replay_backend.commit(result.state); + } + index += 1; + continue; + } + + let mut evm = factory.create_foundry_evm_with_inspector( + &mut replay_backend, + evm_env.clone(), + NoOpInspector, + ); + inject_replay_precompiles(networks, evm.precompiles_mut(), chain_id, timestamp); + while index < txs_to_replay.len() && !txs_to_replay[index].3 { + let (_, tx_hash, tx_env, _) = &txs_to_replay[index]; + trace!(tx=?tx_hash, "committing transaction"); + let state = evm + .transact(tx_env.clone()) + .wrap_err("backend: failed replaying transaction")? + .state; + evm.db_mut().commit(state); + index += 1; + } + } + } + + let (_, index) = replay_backend.active_fork_ids.expect("replay fork is active"); + fork.db = replay_backend.inner.take_fork(index).db; + + // Refresh journaled states from the updated database, preserving persistent + // accounts (cheatcode address, CREATE2 deployer, test contract, etc.). + fork.refresh_journaled_states(journaled_state, persistent_accounts)?; + + trace!(elapsed=?now.elapsed(), count=txs_to_replay.len(), "replayed transactions"); + } + + Ok(Some(target_tx)) + } +} + +#[cfg(feature = "monad")] +fn ensure_block_identity( + block: &AnyRpcBlock, + expected: BlockNumHash, + relation: &str, +) -> eyre::Result<()> { + eyre::ensure!( + block.header().number() == expected.number && block.header().hash == expected.hash, + "{relation} block changed: expected {} ({}), got {} ({})", + expected.number, + expected.hash, + block.header().number(), + block.header().hash + ); + Ok(()) +} + +impl DatabaseExt for Backend { + fn chain_context_for_synthetic_transaction( + &self, + tx: &TxEnvFor, + ) -> eyre::Result> { + #[cfg(feature = "monad")] + if let Some(context) = self.block_context_for_synthetic_transaction()? { + return Ok(context.next_transaction(tx)); + } + Ok(ChainFor::::for_transaction(tx)) + } + + fn snapshot_state( + &mut self, + journaled_state: &JournaledState, + evm_env: &EvmEnvFor, + ) -> U256 { + trace!("create snapshot"); + let id = self.inner.state_snapshots.insert(BackendStateSnapshot::new( + self.create_db_snapshot(), + journaled_state.clone(), + evm_env.clone(), + )); + trace!(target: "backend", "Created new snapshot {}", id); + id + } + + fn revert_state( + &mut self, + id: U256, + current_state: &JournaledState, + evm_env: &mut EvmEnvFor, + caller: Address, + action: RevertStateSnapshotAction, + ) -> Option { + trace!(?id, "revert snapshot"); + if let Some(mut snapshot) = self.inner.state_snapshots.remove_at(id) { + // Re-insert snapshot to persist it + if action.is_keep() { + self.inner.state_snapshots.insert_at(snapshot.clone(), id); + } + + // https://github.com/foundry-rs/foundry/issues/3055 + // Check if an error occurred either during or before the snapshot. + // DSTest contracts don't have snapshot functionality, so this slot is enough to check + // for failure here. + if let Some(account) = current_state.state.get(&CHEATCODE_ADDRESS) + && let Some(slot) = account.storage.get(&GLOBAL_FAIL_SLOT) + && !slot.present_value().is_zero() + { + self.set_state_snapshot_failure(true); + } + + // merge additional logs + snapshot.merge(current_state); + let BackendStateSnapshot { db, mut journaled_state, snap_evm_env } = snapshot; + // The snapshot restores state, not the call stack: keep the depth of the frame that + // reverts, otherwise reverting from a nested call desyncs the journal and tracer. + journaled_state.depth = current_state.depth; + match db { + BackendDatabaseSnapshot::InMemory(mem_db) => { + self.mem_db = mem_db; + self.active_fork_ids = None; + } + BackendDatabaseSnapshot::Forked(id, fork_id, idx, mut fork) => { + // there might be the case where the snapshot was created during `setUp` with + // another caller, so we need to ensure the caller account is present in the + // journaled state and database + journaled_state.state.entry(caller).or_insert_with(|| { + let caller_account = current_state + .state + .get(&caller) + .map(|acc| acc.info.clone()) + .unwrap_or_default(); + + if !fork.db.cache.accounts.contains_key(&caller) { + // update the caller account which is required by the evm + fork.db.insert_account_info(caller, caller_account.clone()); + } + caller_account.into() + }); + self.inner.revert_state_snapshot(id, fork_id, idx, *fork); + self.active_fork_ids = Some((id, idx)) + } + } + + *evm_env = snap_evm_env; + trace!(target: "backend", "Reverted snapshot {}", id); + + Some(journaled_state) + } else { + warn!(target: "backend", "No snapshot to revert for {}", id); + None + } + } + + fn delete_state_snapshot(&mut self, id: U256) -> bool { + self.inner.state_snapshots.remove_at(id).is_some() + } + + fn delete_state_snapshots(&mut self) { + self.inner.state_snapshots.clear() + } + + fn create_fork(&mut self, create_fork: CreateFork) -> eyre::Result { + trace!("create fork"); + let ForkResult { id: fork_id, backend: fork, fork: resolved, .. } = + self.forks.create_fork(create_fork)?; + let context = resolved.context(); + let block = resolved.block(); + let fork_db = ForkDB::new(fork); + let (id, _) = self.inner.insert_new_fork( + fork_id, + block, + context.source_chain_id, + fork_db, + self.fork_init_journaled_state.clone(), + ); + Ok(id) + } + + fn create_fork_at_transaction( + &mut self, fork: CreateFork, transaction: B256, ) -> eyre::Result { trace!(?transaction, "create fork at transaction"); let id = self.create_fork(fork)?; let fork_id = self.ensure_fork_id(id).cloned()?; - let mut env = self + let mut evm_env = self .forks - .get_env(fork_id)? - .ok_or_else(|| eyre::eyre!("Requested fork `{}` does not exit", id))?; + .get_evm_env(fork_id)? + .ok_or_else(|| eyre::eyre!("Requested fork `{}` does not exist", id))?; // we still need to roll to the transaction, but we only need an empty dummy state since we // don't need to update the active journaled state yet - self.roll_fork_to_transaction( + self.roll_fork_to_transaction_with_context( Some(id), transaction, - &mut env.as_env_mut(), + &mut evm_env, + None, &mut self.inner.new_journaled_state(), )?; @@ -1040,39 +2096,62 @@ impl DatabaseExt for Backend { fn select_fork( &mut self, id: LocalForkId, - env: &mut EnvMut<'_>, + evm_env: &mut EvmEnvFor, + tx_env: &mut TxEnvFor, active_journaled_state: &mut JournaledState, - ) -> eyre::Result<()> { + ) -> eyre::Result> { trace!(?id, "select fork"); if self.is_active_fork(id) { // nothing to do - return Ok(()); + #[cfg(feature = "monad")] + return Ok(ContextUpdate::Unchanged); + #[cfg(not(feature = "monad"))] + return Ok(std::marker::PhantomData); } - // Update block number and timestamp of active fork (if any) with current env values, - // in order to preserve values changed by using `roll` and `warp` cheatcodes. + #[cfg(feature = "monad")] + let chain_context = if self.networks.is_monad() { + Some(self.context_for_fork_synthetic_transaction(id, tx_env)?) + } else { + None + }; + + // Preserve roll/warp locally so clones sharing remote data cannot change each other's env. if let Some(active_fork_id) = self.active_fork_id() { - self.forks.update_block( - self.ensure_fork_id(active_fork_id).cloned()?, - env.block.number, - env.block.timestamp, - )?; + let fork_id = self.ensure_fork_id(active_fork_id).cloned()?; + let active = self.inner.get_fork_by_id_mut(active_fork_id)?; + if active.block_env.is_none() { + active.block_env = Some( + self.forks + .get_evm_env(fork_id)? + .ok_or_else(|| { + eyre::eyre!("Requested fork `{active_fork_id}` does not exist") + })? + .block_env, + ); + } + let block = active.block_env.as_mut().unwrap(); + block.set_number(evm_env.block_env.number()); + block.set_timestamp(evm_env.block_env.timestamp()); } let fork_id = self.ensure_fork_id(id).cloned()?; let idx = self.inner.ensure_fork_index(&fork_id)?; - let fork_env = self + let mut fork_evm_env = self .forks - .get_env(fork_id)? - .ok_or_else(|| eyre::eyre!("Requested fork `{}` does not exit", id))?; + .get_evm_env(fork_id)? + .ok_or_else(|| eyre::eyre!("Requested fork `{}` does not exist", id))?; + if let Some(block) = &self.inner.get_fork(idx).block_env { + fork_evm_env.block_env = block.clone(); + } // If we're currently in forking mode we need to update the journaled_state to this point, // this ensures the changes performed while the fork was active are recorded if let Some(active) = self.active_fork_mut() { active.journaled_state = active_journaled_state.clone(); - let caller = env.tx.caller; - let caller_account = active.journaled_state.state.get(&env.tx.caller).cloned(); + let caller = tx_env.caller(); + let caller_account = active.journaled_state.state.get(&caller).cloned(); let target_fork = self.inner.get_fork_mut(idx); // depth 0 will be the default value when the fork was created @@ -1137,11 +2216,11 @@ impl DatabaseExt for Backend { // another edge case where a fork is created and selected during setup with not // necessarily the same caller as for the test, however we must always // ensure that fork's state contains the current sender - let caller = env.tx.caller; + let caller = tx_env.caller(); fork.journaled_state.state.entry(caller).or_insert_with(|| { let caller_account = active_journaled_state .state - .get(&env.tx.caller) + .get(&caller) .map(|acc| acc.info.clone()) .unwrap_or_default(); @@ -1160,9 +2239,19 @@ impl DatabaseExt for Backend { self.active_fork_ids = Some((id, idx)); // Update current environment with environment of newly selected fork. - update_current_env_with_fork_env(env, fork_env); - - Ok(()) + // Preserve the configured spec (evm_version) from the current environment — the fork's + // evm_env is built with SPEC::default() and must not override the user's hardfork setting. + let preserved_spec = evm_env.cfg_env.spec; + let disable_fee_charge = evm_env.cfg_env.disable_fee_charge; + tx_env.set_chain_id(Some(fork_evm_env.cfg_env.chain_id)); + *evm_env = fork_evm_env; + evm_env.cfg_env.set_spec_and_mainnet_gas_params(preserved_spec); + evm_env.cfg_env.disable_fee_charge = disable_fee_charge; + + #[cfg(feature = "monad")] + return Ok(chain_context.map_or(ContextUpdate::Unchanged, ContextUpdate::Replace)); + #[cfg(not(feature = "monad"))] + Ok(std::marker::PhantomData) } /// This is effectively the same as [`Self::create_select_fork()`] but updating an existing @@ -1171,117 +2260,54 @@ impl DatabaseExt for Backend { &mut self, id: Option, block_number: u64, - env: &mut EnvMut<'_>, + evm_env: &mut EvmEnvFor, + tx_env: &TxEnvFor, journaled_state: &mut JournaledState, - ) -> eyre::Result<()> { - trace!(?id, ?block_number, "roll fork"); - let id = self.ensure_fork(id)?; - let (fork_id, backend, fork_env) = - self.forks.roll_fork(self.inner.ensure_fork_id(id).cloned()?, block_number)?; - // this will update the local mapping - self.inner.roll_fork(id, fork_id, backend)?; - - if let Some((active_id, active_idx)) = self.active_fork_ids { - // the currently active fork is the targeted fork of this call - if active_id == id { - // need to update the block's env settings right away, which is otherwise set when - // forks are selected `select_fork` - update_current_env_with_fork_env(env, fork_env); - - // we also need to update the journaled_state right away, this has essentially the - // same effect as selecting (`select_fork`) by discarding - // non-persistent storage from the journaled_state. This which will - // reset cached state from the previous block - let mut persistent_addrs = self.inner.persistent_accounts.clone(); - // we also want to copy the caller state here - persistent_addrs.extend(self.caller_address()); - - let active = self.inner.get_fork_mut(active_idx); - active.journaled_state = self.fork_init_journaled_state.clone(); - - active.journaled_state.depth = journaled_state.depth; - for addr in persistent_addrs { - merge_journaled_state_data(addr, journaled_state, &mut active.journaled_state); - } - - // Ensure all previously loaded accounts are present in the journaled state to - // prevent issues in the new journalstate, e.g. assumptions that accounts are loaded - // if the account is not touched, we reload it, if it's touched we clone it. - // - // Special case for accounts that are not created: we don't merge their state but - // load it in order to reflect their state at the new block (they should explicitly - // be marked as persistent if it is desired to keep state between fork rolls). - for (addr, acc) in &journaled_state.state { - if acc.is_created() { - if acc.is_touched() { - merge_journaled_state_data( - *addr, - journaled_state, - &mut active.journaled_state, - ); - } - } else { - let _ = active.journaled_state.load_account(&mut active.db, *addr); - } - } - - *journaled_state = active.journaled_state.clone(); - } - } - Ok(()) + ) -> eyre::Result> { + self.roll_fork_with_context(id, block_number, evm_env, Some(tx_env), journaled_state) } fn roll_fork_to_transaction( &mut self, id: Option, transaction: B256, - env: &mut EnvMut<'_>, + evm_env: &mut EvmEnvFor, + tx_env: &TxEnvFor, journaled_state: &mut JournaledState, - ) -> eyre::Result<()> { - trace!(?id, ?transaction, "roll fork to transaction"); - let id = self.ensure_fork(id)?; - - let (fork_block, block) = - self.get_block_number_and_block_for_transaction(id, transaction)?; - - // roll the fork to the transaction's parent block or latest if it's pending, because we - // need to fork off the parent block's state for tx level forking and then replay the txs - // before the tx in that block to get the state at the tx - self.roll_fork(Some(id), fork_block, env, journaled_state)?; - - // we need to update the env to the block - update_env_block(env, &block); - - // after we forked at the fork block we need to properly update the block env to the block - // env of the tx's block - let _ = - self.forks.update_block_env(self.inner.ensure_fork_id(id).cloned()?, env.block.clone()); - - let env = env.to_owned(); - - // replay all transactions that came before - self.replay_until(id, env, transaction, journaled_state)?; - - Ok(()) + ) -> eyre::Result> { + self.roll_fork_to_transaction_with_context( + id, + transaction, + evm_env, + Some(tx_env), + journaled_state, + ) } fn transact( &mut self, maybe_id: Option, transaction: B256, - mut env: Env, + mut evm_env: EvmEnvFor, + _outer_tx_env: &TxEnvFor, journaled_state: &mut JournaledState, - inspector: &mut dyn InspectorExt, - ) -> eyre::Result<()> { + inspector: &mut dyn for<'db> FoundryInspectorExt< + ::FoundryContext<'db>, + >, + ) -> eyre::Result> { trace!(?maybe_id, ?transaction, "execute transaction"); let persistent_accounts = self.inner.persistent_accounts.clone(); let id = self.ensure_fork(maybe_id)?; + let affects_active = self.is_active_fork(id); let fork_id = self.ensure_fork_id(id).cloned()?; - - let tx = { - let fork = self.inner.get_fork_by_id_mut(id)?; - fork.db.db.get_transaction(transaction)? - }; + let fork = self + .forks + .get_fork_info(fork_id.clone())? + .ok_or_else(|| eyre::eyre!("Requested fork `{id}` does not exist"))?; + eyre::ensure!( + !fork.state_by_number, + "transaction replay requires hash-addressed state; create a transaction-targeted fork or disable fork_state_by_number" + ); // This is a bit ambiguous because the user wants to transact an arbitrary transaction in // the current context, but we're assuming the user wants to transact the transaction as it @@ -1289,44 +2315,136 @@ impl DatabaseExt for Backend { // transaction in the block and then the transaction is transacted: // // So we modify the env to match the transaction's block. - let (_fork_block, block) = - self.get_block_number_and_block_for_transaction(id, transaction)?; - update_env_block(&mut env.as_env_mut(), &block); + let TransactionForkTarget { + transaction: tx, + block, + #[cfg(feature = "monad")] + position, + .. + } = self.get_block_number_and_block_for_transaction(id, transaction)?; + let tx_env = TxEnvFor::::from_any_rpc_transaction(&tx)?; + let source_chain_id = self.inner.get_fork_by_id(id)?.source_chain_id; + update_env_block::(&mut evm_env, &block, source_chain_id, self.networks); + self.apply_fork_tx_replay_env_changes(id, &mut evm_env)?; + + #[cfg(feature = "monad")] + let block_context = if self.networks.is_monad() { + Some(self.block_context_inputs(id, &block)?) + } else { + None + }; + #[cfg(feature = "monad")] + let chain_context = if let Some(context) = &block_context { + context.transaction( + position.expect("Monad transaction target includes canonical position").index, + ) + } else { + ChainFor::::for_transaction(&tx_env) + }; + + #[cfg(not(feature = "monad"))] + let chain_context = ChainFor::::for_transaction(&tx_env); + + #[cfg(feature = "monad")] + let next_position = if block_context.is_some() { + let position = position.expect("Monad transaction target includes canonical position"); + Some( + self.inner + .get_fork_by_id(id)? + .position + .after_transaction( + BlockNumHash::new(block.header().number(), block.header().hash), + block.header().parent_hash(), + position.index, + position.count, + ) + .ok_or_else(|| { + eyre::eyre!( + "transaction {transaction} does not immediately follow the active \ + fork position" + ) + })?, + ) + } else { + None + }; + #[cfg(feature = "monad")] + let context_update = if affects_active && let Some(context) = block_context { + ContextUpdate::Replace(Self::context_for_block_position( + context, + next_position.expect("block context has a next position"), + _outer_tx_env, + )?) + } else if affects_active { + ContextUpdate::Unchanged + } else { + ContextUpdate::Rebase + }; + #[cfg(not(feature = "monad"))] + let context_update = std::marker::PhantomData; + + let mut replay_journaled_state = if affects_active { + journaled_state.clone() + } else { + self.inner.get_fork_by_id(id)?.journaled_state.clone() + }; + if !affects_active { + for addr in persistent_accounts.iter().copied() { + merge_journaled_state_data(addr, journaled_state, &mut replay_journaled_state); + } + } let fork = self.inner.get_fork_by_id_mut(id)?; - commit_transaction( - &tx.inner, - &mut env.as_env_mut(), + commit_transaction::( + TransactionInputs { + evm_env, + tx_env, + chain_context, + rpc_block_number: block.header().number(), + journaled_state: replay_journaled_state, + }, journaled_state, fork, &fork_id, + self.networks, &persistent_accounts, inspector, - ) + )?; + #[cfg(feature = "monad")] + if let Some(position) = next_position { + fork.position = position; + } + Ok(context_update) } fn transact_from_tx( &mut self, - tx: &TransactionRequest, - mut env: Env, + tx_env: TxEnvFor, + evm_env: EvmEnvFor, journaled_state: &mut JournaledState, - inspector: &mut dyn InspectorExt, + inspector: &mut dyn for<'db> FoundryInspectorExt< + ::FoundryContext<'db>, + >, ) -> eyre::Result<()> { - trace!(?tx, "execute signed transaction"); - - self.commit(journaled_state.state.clone()); + trace!("execute signed transaction"); + // Run on a clone that includes the outer journal, then commit only the accounts the + // transaction touched, so other uncommitted writes stay in the journal for frame reverts. + let mut db = self.clone(); + db.commit(journaled_state.state.clone()); let res = { - configure_tx_req_env(&mut env.as_env_mut(), tx, None)?; - - let mut db = self.clone(); - let mut evm = new_evm_with_inspector(&mut db, env.to_owned(), inspector); - evm.journaled_state.depth = journaled_state.depth + 1; - evm.transact(env.tx)? + let depth = journaled_state.depth + 1; + let factory = FEN::EvmFactory::default(); + let chain_context = db.chain_context_for_synthetic_transaction(&tx_env)?; + let mut evm = factory.create_nested_evm_with_inspector(&mut db, evm_env, inspector); + *evm.chain_mut() = chain_context; + evm.journal_inner_mut().depth = depth; + evm.transact_raw(tx_env)? }; + db.commit(res.state.clone()); + update_state(&mut journaled_state.state, &mut db, None)?; self.commit(res.state); - update_state(&mut journaled_state.state, self, None)?; Ok(()) } @@ -1340,25 +2458,46 @@ impl DatabaseExt for Backend { self.forks.get_fork_url(fork.clone()).ok()? } + fn active_fork_options(&self) -> Option { + let fork = self.inner.issued_local_fork_ids.get(&self.active_fork_id()?)?; + self.forks.get_fork_options(fork.clone()).ok()? + } + + fn active_fork_source_chain_id(&self) -> Option { + Some(self.inner.get_fork_by_id(self.active_fork_id()?).ok()?.source_chain_id) + } + + fn active_fork_block_number(&self) -> Option { + if let Some(block_number) = self.fork_block_number_override { + return Some(block_number); + } + let fork = self.inner.get_fork_by_id(self.active_fork_id()?).ok()?; + Some(match fork.position { + ForkPosition::AfterBlock { block } | ForkPosition::BeforeTransaction { block, .. } => { + block.number + } + }) + } + fn ensure_fork(&self, id: Option) -> eyre::Result { if let Some(id) = id { if self.inner.issued_local_fork_ids.contains_key(&id) { return Ok(id); } - eyre::bail!("Requested fork `{}` does not exit", id) + eyre::bail!("Requested fork `{}` does not exist", id); + } + if let Some(id) = self.active_fork_id() { + Ok(id) + } else { + eyre::bail!("No fork active"); } - if let Some(id) = self.active_fork_id() { Ok(id) } else { eyre::bail!("No fork active") } } fn ensure_fork_id(&self, id: LocalForkId) -> eyre::Result<&ForkId> { self.inner.ensure_fork_id(id) } - fn diagnose_revert( - &self, - callee: Address, - journaled_state: &JournaledState, - ) -> Option { + fn diagnose_revert(&self, callee: Address, evm_state: &EvmState) -> Option { let active_id = self.active_fork_id()?; let active_fork = self.active_fork()?; @@ -1368,7 +2507,7 @@ impl DatabaseExt for Backend { return None; } - if !active_fork.is_contract(callee) && !is_contract_in_state(journaled_state, callee) { + if !active_fork.is_contract(callee) && !is_contract_in_state(evm_state, callee) { // no contract for `callee` available on current fork, check if available on other forks let mut available_on = Vec::new(); for (id, fork) in self.inner.forks_iter().filter(|(id, _)| *id != active_id) { @@ -1425,51 +2564,154 @@ impl DatabaseExt for Backend { ) -> Result<(), BackendError> { // Fetch the account from the journaled state. Will create a new account if it does // not already exist. - let mut state_acc = journaled_state.load_account(self, *target)?; + let mut state_acc = journaled_state.load_account_mut(self, *target)?; // Set the account's bytecode and code hash, if the `bytecode` field is present. if let Some(bytecode) = source.code.as_ref() { - state_acc.info.code_hash = keccak256(bytecode); + let bytecode_hash = keccak256(bytecode); let bytecode = Bytecode::new_raw(bytecode.0.clone().into()); - state_acc.info.code = Some(bytecode); + state_acc.set_code(bytecode_hash, bytecode); } + // Set the account's balance. + state_acc.set_balance(source.balance); + // Set the account's storage, if the `storage` field is present. - if let Some(storage) = source.storage.as_ref() { - state_acc.storage = storage - .iter() - .map(|(slot, value)| { - let slot = U256::from_be_bytes(slot.0); - ( + if let Some(acc) = journaled_state.state.get_mut(target) { + if let Some(storage) = source.storage.as_ref() { + for (slot, value) in storage { + let slot = (*slot).into(); + acc.storage.insert( slot, EvmStorageSlot::new_changed( - state_acc - .storage - .get(&slot) - .map(|s| s.present_value) - .unwrap_or_default(), - U256::from_be_bytes(value.0), - 0, + acc.storage.get(&slot).map(|s| s.present_value()).unwrap_or_default(), + (*value).into(), + TransactionId::ZERO, ), - ) - }) - .collect(); + ); + } + } + + // Set the account's nonce. + acc.info.nonce = source.nonce.unwrap_or_default(); + }; + + // Touch the account to ensure the loaded information persists if called in `setUp`. + journaled_state.touch(*target); + + Ok(()) + } + + fn add_persistent_account(&mut self, account: Address) -> bool { + trace!(?account, "add persistent account"); + self.inner.persistent_accounts.insert(account) + } + + fn refresh_fork_account( + &mut self, + address: Address, + field: ForkAccountField, + journaled_state: &mut JournaledState, + ) -> Result<(), BackendError> { + let Some(fork) = self.active_fork_mut() else { return Ok(()) }; + trace!(?address, ?field, "refresh fork account"); + fork.db.db.data().accounts.write().remove(&address); + + let cache_modified = fork + .db + .cache + .accounts + .get(&address) + .is_some_and(|account| account.account_state != AccountState::None); + if !cache_modified { + fork.db.cache.accounts.remove(&address); + } + + if !cache_modified + && !journaled_state.state.contains_key(&address) + && !fork.journaled_state.state.contains_key(&address) + { + return Ok(()); + } + + let mut refreshed = DatabaseRef::basic_ref(&fork.db.db, address)?.unwrap_or_default(); + if matches!(field, ForkAccountField::Code) { + fork.db.insert_contract(&mut refreshed); + } + if let Some(cached) = fork.db.cache.accounts.get_mut(&address) { + field.update(&mut cached.info, &refreshed); + if cached.account_state == AccountState::NotExisting && !refreshed.is_empty() { + cached.account_state = AccountState::None; + } + } + if let Some(journaled_account) = journaled_state.state.get_mut(&address) { + field.update(&mut journaled_account.info, &refreshed); + } + if let Some(journaled_account) = fork.journaled_state.state.get_mut(&address) { + field.update(&mut journaled_account.info, &refreshed); + } + Ok(()) + } + + fn refresh_fork_storage( + &mut self, + address: Address, + slot: U256, + journaled_state: &mut JournaledState, + ) -> Result<(), BackendError> { + let Some(fork) = self.active_fork_mut() else { return Ok(()) }; + trace!(?address, ?slot, "refresh fork storage"); + if let Some(storage) = fork.db.db.data().storage.write().get_mut(&address) { + storage.remove(&slot); + } + let cache_modified = fork + .db + .cache + .accounts + .get(&address) + .is_some_and(|account| account.account_state != AccountState::None); + if !cache_modified && let Some(account) = fork.db.cache.accounts.get_mut(&address) { + account.storage.remove(&slot); } - // Set the account's nonce and balance. - state_acc.info.nonce = source.nonce.unwrap_or_default(); - state_acc.info.balance = source.balance; - // Touch the account to ensure the loaded information persists if called in `setUp`. - journaled_state.touch(*target); + let outer_loaded = journaled_state + .state + .get(&address) + .is_some_and(|account| account.storage.contains_key(&slot)); + let fork_loaded = fork + .journaled_state + .state + .get(&address) + .is_some_and(|account| account.storage.contains_key(&slot)); + if !cache_modified && !outer_loaded && !fork_loaded { + return Ok(()); + } + let value = DatabaseRef::storage_ref(&fork.db.db, address, slot)?; + if let Some(account) = fork.db.cache.accounts.get_mut(&address) { + account.storage.insert(slot, value); + if account.account_state == AccountState::NotExisting && !value.is_zero() { + account.account_state = AccountState::None; + } + } + if let Some(storage) = journaled_state + .state + .get_mut(&address) + .and_then(|account| account.storage.get_mut(&slot)) + { + storage.present_value = value; + } + if let Some(storage) = fork + .journaled_state + .state + .get_mut(&address) + .and_then(|account| account.storage.get_mut(&slot)) + { + storage.present_value = value; + } Ok(()) } - fn add_persistent_account(&mut self, account: Address) -> bool { - trace!(?account, "add persistent account"); - self.inner.persistent_accounts.insert(account) - } - fn remove_persistent_account(&mut self, account: &Address) -> bool { trace!(?account, "remove persistent account"); self.inner.persistent_accounts.remove(account) @@ -1502,15 +2744,17 @@ impl DatabaseExt for Backend { } } -impl DatabaseRef for Backend { +impl DatabaseRef for Backend { type Error = DatabaseError; fn basic_ref(&self, address: Address) -> Result, Self::Error> { - if let Some(db) = self.active_fork_db() { - db.basic_ref(address) + let mut account = if let Some(db) = self.active_fork_db() { + db.basic_ref(address)? } else { - Ok(self.mem_db.basic_ref(address)?) - } + self.mem_db.basic_ref(address)? + }; + self.apply_bal_account(address, &mut account)?; + Ok(account) } fn code_by_hash_ref(&self, code_hash: B256) -> Result { @@ -1522,6 +2766,9 @@ impl DatabaseRef for Backend { } fn storage_ref(&self, address: Address, index: U256) -> Result { + if let Some(value) = self.bal_storage(address, index)? { + return Ok(value); + } if let Some(db) = self.active_fork_db() { DatabaseRef::storage_ref(db, address, index) } else { @@ -1538,8 +2785,17 @@ impl DatabaseRef for Backend { } } -impl DatabaseCommit for Backend { - fn commit(&mut self, changes: Map) { +impl DatabaseCommit for Backend { + fn commit(&mut self, changes: AddressMap) { + if self.bal.take().is_some() { + // Keep code read through BAL available for trace decoding. CacheDB's normal commit + // skips untouched accounts; touched accounts must retain the target's final values. + for (address, account) in &changes { + if !account.is_touched() && !account.is_loaded_as_not_existing() { + self.insert_account_info(*address, account.info.clone()); + } + } + } if let Some(db) = self.active_fork_db_mut() { db.commit(changes) } else { @@ -1548,14 +2804,16 @@ impl DatabaseCommit for Backend { } } -impl Database for Backend { +impl Database for Backend { type Error = DatabaseError; fn basic(&mut self, address: Address) -> Result, Self::Error> { - if let Some(db) = self.active_fork_db_mut() { - Ok(db.basic(address)?) + let mut account = if let Some(db) = self.active_fork_db_mut() { + db.basic(address)? } else { - Ok(self.mem_db.basic(address)?) - } + self.mem_db.basic(address)? + }; + self.apply_bal_account(address, &mut account)?; + Ok(existing_account(self.inner.spec_id, account)) } fn code_by_hash(&mut self, code_hash: B256) -> Result { @@ -1567,6 +2825,9 @@ impl Database for Backend { } fn storage(&mut self, address: Address, index: U256) -> Result { + if let Some(value) = self.bal_storage(address, index)? { + return Ok(value); + } if let Some(db) = self.active_fork_db_mut() { Ok(Database::storage(db, address, index)?) } else { @@ -1585,35 +2846,55 @@ impl Database for Backend { /// Variants of a [revm::Database] #[derive(Clone, Debug)] -pub enum BackendDatabaseSnapshot { +pub enum BackendDatabaseSnapshot { /// Simple in-memory [revm::Database] InMemory(FoundryEvmInMemoryDB), /// Contains the entire forking mode database - Forked(LocalForkId, ForkId, ForkLookupIndex, Box), + Forked(LocalForkId, ForkId, ForkLookupIndex, Box>), } /// Represents a fork #[derive(Clone, Debug)] -pub struct Fork { - db: ForkDB, +pub struct Fork { + db: ForkDB, journaled_state: JournaledState, + source_chain_id: ChainId, + position: ForkPosition, + /// Execution block overrides belong to this backend, never the shared remote cache. + block_env: Option, } -impl Fork { +impl Fork { + /// Returns a reference to the underlying [`SharedBackend`]. + pub const fn backend(&self) -> &SharedBackend { + &self.db.db + } + /// Returns true if the account is a contract pub fn is_contract(&self, acc: Address) -> bool { if let Ok(Some(acc)) = self.db.basic_ref(acc) - && acc.code_hash != KECCAK_EMPTY + && !acc.is_empty_code_hash() { return true; } - is_contract_in_state(&self.journaled_state, acc) + is_contract_in_state(&self.journaled_state.state, acc) + } + + /// Refreshes the given journaled state and the fork's own journaled state from the + /// database, preserving persistent accounts. + fn refresh_journaled_states( + &mut self, + journaled_state: &mut JournaledState, + persistent_accounts: &AddressSet, + ) -> Result<(), BackendError> { + update_state(&mut journaled_state.state, &mut self.db, Some(persistent_accounts))?; + update_state(&mut self.journaled_state.state, &mut self.db, Some(persistent_accounts))?; + Ok(()) } } /// Container type for various Backend related data -#[derive(Clone, Debug)] -pub struct BackendInner { +pub struct BackendInner { /// Stores the `ForkId` of the fork the `Backend` launched with from the start. /// /// In other words if [`Backend::spawn()`] was called with a `CreateFork` command, to launch @@ -1636,9 +2917,16 @@ pub struct BackendInner { pub created_forks: HashMap, /// Holds all created fork databases // Note: data is stored in an `Option` so we can remove it without reshuffling - pub forks: Vec>, + pub forks: Vec>>>, /// Contains state snapshots made at a certain point - pub state_snapshots: StateSnapshots>, + #[allow(clippy::type_complexity)] + pub state_snapshots: StateSnapshots< + BackendStateSnapshot< + BackendDatabaseSnapshot>, + SpecFor, + BlockEnvFor, + >, + >, /// Tracks whether there was a failure in a snapshot that was reverted /// /// The Test contract contains a bool variable that is set to true when an `assert` function @@ -1656,14 +2944,50 @@ pub struct BackendInner { /// All accounts that should be kept persistent when switching forks. /// This means all accounts stored here _don't_ use a separate storage section on each fork /// instead the use only one that's persistent across fork swaps. - pub persistent_accounts: HashSet
, + pub persistent_accounts: AddressSet, /// The configured spec id - pub spec_id: SpecId, + pub spec_id: SpecFor, /// All accounts that are allowed to execute cheatcodes - pub cheatcode_access_accounts: HashSet
, + pub cheatcode_access_accounts: AddressSet, +} + +impl Clone for BackendInner { + fn clone(&self) -> Self { + Self { + launched_with_fork: self.launched_with_fork.clone(), + issued_local_fork_ids: self.issued_local_fork_ids.clone(), + created_forks: self.created_forks.clone(), + forks: self.forks.clone(), + state_snapshots: self.state_snapshots.clone(), + has_state_snapshot_failure: self.has_state_snapshot_failure, + caller: self.caller, + next_fork_id: self.next_fork_id, + persistent_accounts: self.persistent_accounts.clone(), + spec_id: self.spec_id, + cheatcode_access_accounts: self.cheatcode_access_accounts.clone(), + } + } } -impl BackendInner { +impl Debug for BackendInner { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BackendInner") + .field("launched_with_fork", &self.launched_with_fork) + .field("issued_local_fork_ids", &self.issued_local_fork_ids) + .field("created_forks", &self.created_forks) + .field("forks", &self.forks) + .field("state_snapshots", &self.state_snapshots) + .field("has_state_snapshot_failure", &self.has_state_snapshot_failure) + .field("caller", &self.caller) + .field("next_fork_id", &self.next_fork_id) + .field("persistent_accounts", &self.persistent_accounts) + .field("spec_id", &self.spec_id) + .field("cheatcode_access_accounts", &self.cheatcode_access_accounts) + .finish() + } +} + +impl BackendInner { pub fn ensure_fork_id(&self, id: LocalForkId) -> eyre::Result<&ForkId> { self.issued_local_fork_ids .get(&id) @@ -1683,51 +3007,58 @@ impl BackendInner { /// Returns the underlying fork mapped to the index #[track_caller] - fn get_fork(&self, idx: ForkLookupIndex) -> &Fork { + fn get_fork(&self, idx: ForkLookupIndex) -> &Fork> { debug_assert!(idx < self.forks.len(), "fork lookup index must exist"); self.forks[idx].as_ref().unwrap() } /// Returns the underlying fork mapped to the index #[track_caller] - fn get_fork_mut(&mut self, idx: ForkLookupIndex) -> &mut Fork { + fn get_fork_mut(&mut self, idx: ForkLookupIndex) -> &mut Fork> { debug_assert!(idx < self.forks.len(), "fork lookup index must exist"); self.forks[idx].as_mut().unwrap() } /// Returns the underlying fork corresponding to the id #[track_caller] - fn get_fork_by_id_mut(&mut self, id: LocalForkId) -> eyre::Result<&mut Fork> { + fn get_fork_by_id_mut( + &mut self, + id: LocalForkId, + ) -> eyre::Result<&mut Fork>> { let idx = self.ensure_fork_index_by_local_id(id)?; Ok(self.get_fork_mut(idx)) } /// Returns the underlying fork corresponding to the id #[track_caller] - fn get_fork_by_id(&self, id: LocalForkId) -> eyre::Result<&Fork> { + fn get_fork_by_id(&self, id: LocalForkId) -> eyre::Result<&Fork>> { let idx = self.ensure_fork_index_by_local_id(id)?; Ok(self.get_fork(idx)) } /// Removes the fork - fn take_fork(&mut self, idx: ForkLookupIndex) -> Fork { + fn take_fork(&mut self, idx: ForkLookupIndex) -> Fork> { debug_assert!(idx < self.forks.len(), "fork lookup index must exist"); self.forks[idx].take().unwrap() } - fn set_fork(&mut self, idx: ForkLookupIndex, fork: Fork) { + fn set_fork(&mut self, idx: ForkLookupIndex, fork: Fork>) { self.forks[idx] = Some(fork) } /// Returns an iterator over Forks - pub fn forks_iter(&self) -> impl Iterator + '_ { + pub fn forks_iter( + &self, + ) -> impl Iterator>)> + '_ { self.issued_local_fork_ids .iter() .map(|(id, fork_id)| (*id, self.get_fork(self.created_forks[fork_id]))) } /// Returns a mutable iterator over all Forks - pub fn forks_iter_mut(&mut self) -> impl Iterator + '_ { + pub fn forks_iter_mut( + &mut self, + ) -> impl Iterator>> + '_ { self.forks.iter_mut().filter_map(|f| f.as_mut()) } @@ -1737,7 +3068,7 @@ impl BackendInner { id: LocalForkId, fork_id: ForkId, idx: ForkLookupIndex, - fork: Fork, + fork: Fork>, ) { self.created_forks.insert(fork_id.clone(), idx); self.issued_local_fork_ids.insert(id, fork_id); @@ -1749,14 +3080,22 @@ impl BackendInner { &mut self, id: LocalForkId, fork_id: ForkId, - db: ForkDB, + block: BlockNumHash, + source_chain_id: ChainId, + db: ForkDB>, journaled_state: JournaledState, ) -> ForkLookupIndex { let idx = self.forks.len(); self.issued_local_fork_ids.insert(id, fork_id.clone()); self.created_forks.insert(fork_id, idx); - let fork = Fork { db, journaled_state }; + let fork = Fork { + db, + journaled_state, + source_chain_id, + position: ForkPosition::AfterBlock { block }, + block_env: None, + }; self.forks.push(Some(fork)); idx } @@ -1765,46 +3104,115 @@ impl BackendInner { &mut self, id: LocalForkId, new_fork_id: ForkId, - backend: SharedBackend, + block: BlockNumHash, + source_chain_id: ChainId, + backend: SharedBackend>, ) -> eyre::Result { let fork_id = self.ensure_fork_id(id)?; let idx = self.ensure_fork_index(fork_id)?; if let Some(active) = self.forks[idx].as_mut() { - // we initialize a _new_ `ForkDB` but keep the state of persistent accounts + // Initialize a new `ForkDB` while retaining persistent account data. let mut new_db = ForkDB::new(backend); for addr in self.persistent_accounts.iter().copied() { merge_db_account_data(addr, &active.db, &mut new_db); } active.db = new_db; + active.source_chain_id = source_chain_id; + active.position = ForkPosition::AfterBlock { block }; + active.block_env = None; } - // update mappings self.issued_local_fork_ids.insert(id, new_fork_id.clone()); self.created_forks.insert(new_fork_id, idx); Ok(idx) } + /// Prepares a replacement for one fork without changing its local mapping or database. + #[cfg(feature = "monad")] + fn stage_fork_roll( + &self, + id: LocalForkId, + new_fork_id: ForkId, + block: BlockNumHash, + source_chain_id: ChainId, + backend: SharedBackend>, + journaled_state: JournaledState, + ) -> eyre::Result> { + let fork_id = self.ensure_fork_id(id)?; + let idx = self.ensure_fork_index(fork_id)?; + let current = self.get_fork(idx); + + // Initialize a new `ForkDB` with persistent account data and the prepared journal. The + // live fork remains untouched until publication. + let mut new_db = ForkDB::new(backend); + for addr in self.persistent_accounts.iter().copied() { + merge_db_account_data(addr, ¤t.db, &mut new_db); + } + + Ok(StagedForkRoll { + local_id: id, + fork_id: new_fork_id, + fork_index: idx, + fork: Fork { + db: new_db, + journaled_state, + source_chain_id, + position: ForkPosition::AfterBlock { block }, + block_env: None, + }, + }) + } + + /// Atomically publishes a previously prepared fork replacement. + #[cfg(feature = "monad")] + fn publish_fork_roll(&mut self, staged: StagedForkRoll) -> ForkLookupIndex { + let StagedForkRoll { local_id, fork_id, fork_index, fork } = staged; + self.set_fork(fork_index, fork); + self.issued_local_fork_ids.insert(local_id, fork_id.clone()); + self.created_forks.insert(fork_id, fork_index); + fork_index + } + /// Inserts a _new_ `ForkDB` and issues a new local fork identifier /// /// Also returns the index where the `ForDB` is stored pub fn insert_new_fork( &mut self, fork_id: ForkId, - db: ForkDB, + block: BlockNumHash, + source_chain_id: ChainId, + db: ForkDB>, journaled_state: JournaledState, + ) -> (LocalForkId, ForkLookupIndex) { + self.insert_fork( + fork_id, + Fork { + db, + journaled_state, + source_chain_id, + position: ForkPosition::AfterBlock { block }, + block_env: None, + }, + ) + } + + /// Inserts an existing fork while preserving its exact chain position. + fn insert_fork( + &mut self, + fork_id: ForkId, + fork: Fork>, ) -> (LocalForkId, ForkLookupIndex) { let idx = self.forks.len(); self.created_forks.insert(fork_id.clone(), idx); let id = self.next_id(); self.issued_local_fork_ids.insert(id, fork_id); - let fork = Fork { db, journaled_state }; self.forks.push(Some(fork)); (id, idx) } fn next_id(&mut self) -> U256 { let id = self.next_fork_id; - self.next_fork_id += U256::from(1); + self.next_fork_id += U256::ONE; id } @@ -1818,23 +3226,28 @@ impl BackendInner { self.issued_local_fork_ids.is_empty() } - pub fn precompiles(&self) -> &'static Precompiles { - Precompiles::new(PrecompileSpecId::from_spec_id(self.spec_id)) + pub fn precompile_addresses(&self) -> AddressSet { + let evm = FEN::EvmFactory::default().create_evm( + EmptyDB::default(), + EvmEnv::new(CfgEnv::new_with_spec(self.spec_id), Default::default()), + ); + evm.precompiles().addresses().copied().collect() } /// Returns a new, empty, `JournaledState` with set precompiles pub fn new_journaled_state(&self) -> JournaledState { let mut journal = { let mut journal_inner = JournalInner::new(); - journal_inner.set_spec_id(self.spec_id); + journal_inner.set_spec_id(self.spec_id.into()); journal_inner }; - journal.precompiles.extend(self.precompiles().addresses().copied()); + let precompile_addresses = self.precompile_addresses(); + journal.warm_addresses.set_precompile_addresses(&precompile_addresses); journal } } -impl Default for BackendInner { +impl Default for BackendInner { fn default() -> Self { Self { launched_with_fork: None, @@ -1846,10 +3259,10 @@ impl Default for BackendInner { caller: None, next_fork_id: Default::default(), persistent_accounts: Default::default(), - spec_id: SpecId::default(), + spec_id: SpecFor::::default(), // grant the cheatcode,default test and caller address access to execute cheatcodes // itself - cheatcode_access_accounts: HashSet::from([ + cheatcode_access_accounts: AddressSet::from_iter([ CHEATCODE_ADDRESS, TEST_CONTRACT_ADDRESS, CALLER, @@ -1858,22 +3271,15 @@ impl Default for BackendInner { } } -/// This updates the currently used env with the fork's environment -pub(crate) fn update_current_env_with_fork_env(current: &mut EnvMut<'_>, fork: Env) { - *current.block = fork.evm_env.block_env; - *current.cfg = fork.evm_env.cfg_env; - current.tx.chain_id = fork.tx.chain_id; -} - /// Clones the data of the given `accounts` from the `active` database into the `fork_db` /// This includes the data held in storage (`CacheDB`) and kept in the `JournaledState`. -pub(crate) fn merge_account_data( +pub(crate) fn merge_account_data( accounts: impl IntoIterator, active: &CacheDB, active_journaled_state: &mut JournaledState, - target_fork: &mut Fork, + target_fork: &mut Fork, ) { - for addr in accounts.into_iter() { + for addr in accounts { merge_db_account_data(addr, active, &mut target_fork.db); merge_journaled_state_data(addr, active_journaled_state, &mut target_fork.journaled_state); } @@ -1881,6 +3287,38 @@ pub(crate) fn merge_account_data( *active_journaled_state = target_fork.journaled_state.clone(); } +/// Returns `account` as execution at `spec` loads it. +/// +/// Foundry's databases report an account they do not hold as an existing empty account, and +/// forked state cannot tell the two apart because RPC endpoints report a missing account as empty. +/// From Spurious Dragon on an empty account does not exist (EIP-161), so it is loaded as absent; +/// loading it as existing would, for example, refund the EIP-7702 authorization of a fresh +/// authority. +pub fn existing_account( + spec: impl Into, + account: Option, +) -> Option { + if spec.into().is_enabled_in(SpecId::SPURIOUS_DRAGON) { + account.filter(|account| !account.is_empty()) + } else { + account + } +} + +/// Returns the account-loading policy required by a fork source. +/// +/// A recognized source chain ID is authoritative. The endpoint's execution profile is only a +/// source hint for custom chain IDs, where the chain ID cannot identify the RPC semantics. +pub fn account_fetch_policy_for_source( + source_chain_id: ChainId, + network_profile: NetworkConfigs, +) -> AccountFetchPolicy { + let source_chain = Chain::from_id(source_chain_id); + let is_tempo = + source_chain.is_tempo() || (source_chain.named().is_none() && network_profile.is_tempo()); + if is_tempo { AccountFetchPolicy::RequireAccountInfo } else { AccountFetchPolicy::Auto } +} + /// Clones the account data from the `active_journaled_state` into the `fork_journaled_state` fn merge_journaled_state_data( addr: Address, @@ -1900,19 +3338,19 @@ fn merge_journaled_state_data( } /// Clones the account data from the `active` db into the `ForkDB` -fn merge_db_account_data( +fn merge_db_account_data( addr: Address, active: &CacheDB, - fork_db: &mut ForkDB, + fork_db: &mut ForkDB, ) { trace!(?addr, "merging database data"); let Some(acc) = active.cache.accounts.get(&addr) else { return }; // port contract cache over - if let Some(code) = active.cache.contracts.get(&acc.info.code_hash) { + if let Some(code) = active.cache.contracts.get(&acc.info.code_hash()) { trace!("merging contract cache"); - fork_db.cache.contracts.insert(acc.info.code_hash, code.clone()); + fork_db.cache.contracts.insert(acc.info.code_hash(), code.clone()); } // port account storage over @@ -1935,60 +3373,75 @@ fn merge_db_account_data( } /// Returns true of the address is a contract -fn is_contract_in_state(journaled_state: &JournaledState, acc: Address) -> bool { - journaled_state - .state - .get(&acc) - .map(|acc| acc.info.code_hash != KECCAK_EMPTY) - .unwrap_or_default() +fn is_contract_in_state(evm_state: &EvmState, acc: Address) -> bool { + evm_state.get(&acc).is_some_and(|acc| !acc.info.is_empty_code_hash()) } -/// Updates the env's block with the block's data -fn update_env_block(env: &mut EnvMut<'_>, block: &AnyRpcBlock) { - env.block.timestamp = U256::from(block.header.timestamp); - env.block.beneficiary = block.header.beneficiary; - env.block.difficulty = block.header.difficulty; - env.block.prevrandao = Some(block.header.mix_hash.unwrap_or_default()); - env.block.basefee = block.header.base_fee_per_gas.unwrap_or_default(); - env.block.gas_limit = block.header.gas_limit; - env.block.number = U256::from(block.header.number); - - if let Some(excess_blob_gas) = block.header.excess_blob_gas { - env.block.blob_excess_gas_and_price = Some(BlobExcessGasAndPrice::new( - excess_blob_gas, - get_blob_base_fee_update_fraction_by_spec_id(env.cfg.spec), - )); - } +/// Updates the evm env's block with the block's data +fn update_env_block + Copy, BLOCK: FoundryBlock>( + evm_env: &mut EvmEnv, + block: &N::BlockResponse, + source_chain_id: ChainId, + networks: NetworkConfigs, +) { + let header = block.header(); + let block_env = &mut evm_env.block_env; + block_env.set_timestamp(U256::from(header.timestamp())); + block_env.set_beneficiary(header.beneficiary()); + block_env.set_difficulty(header.difficulty()); + block_env.set_prevrandao(header.mix_hash()); + block_env.set_basefee(header.base_fee_per_gas().unwrap_or_default()); + block_env.set_gas_limit(header.gas_limit()); + block_env.set_number(U256::from(header.number())); + block_env.set_slot_num(header.slot_number().unwrap_or_default()); + + apply_chain_and_block_specific_env_changes_for_chain::( + evm_env, + block, + source_chain_id, + networks, + ); } /// Executes the given transaction and commits state changes to the database _and_ the journaled /// state, with an inspector. -fn commit_transaction( - tx: &Transaction, - env: &mut EnvMut<'_>, +fn commit_transaction( + transaction: TransactionInputs, journaled_state: &mut JournaledState, - fork: &mut Fork, + fork: &mut Fork>, fork_id: &ForkId, - persistent_accounts: &HashSet
, - inspector: &mut dyn InspectorExt, + networks: NetworkConfigs, + persistent_accounts: &AddressSet, + inspector: &mut dyn for<'db> FoundryInspectorExt< + ::FoundryContext<'db>, + >, ) -> eyre::Result<()> { - configure_tx_env(env, tx); - + let TransactionInputs { + evm_env, + tx_env, + chain_context, + rpc_block_number, + journaled_state: replay_journaled_state, + } = transaction; let now = Instant::now(); + let base_state = replay_journaled_state.state.clone(); let res = { let fork = fork.clone(); - let journaled_state = journaled_state.clone(); let depth = journaled_state.depth; - let mut db = Backend::new_with_fork(fork_id, fork, journaled_state)?; - - let mut evm = crate::evm::new_evm_with_inspector(&mut db as _, env.to_owned(), inspector); - // Adjust inner EVM depth to ensure that inspectors receive accurate data. - evm.journaled_state.depth = depth + 1; - evm.transact(env.tx.clone()).wrap_err("backend: failed committing transaction")? + let mut db: Backend = + Backend::new_with_fork(fork_id, fork, replay_journaled_state, networks)?; + db.fork_block_number_override = Some(rpc_block_number); + db.commit(base_state.clone()); + + let mut evm = FEN::EvmFactory::default() + .create_nested_evm_with_inspector(&mut db, evm_env, inspector); + *evm.chain_mut() = chain_context; + evm.journal_inner_mut().depth = depth + 1; + evm.transact_raw(tx_env).wrap_err("backend: failed committing transaction")? }; trace!(elapsed = ?now.elapsed(), "transacted transaction"); - apply_state_changeset(res.state, journaled_state, fork, persistent_accounts)?; + apply_state_changeset(base_state, res.state, journaled_state, fork, persistent_accounts)?; Ok(()) } @@ -1997,10 +3450,10 @@ fn commit_transaction( pub fn update_state( state: &mut EvmState, db: &mut DB, - persistent_accounts: Option<&HashSet
>, + persistent_accounts: Option<&AddressSet>, ) -> Result<(), DB::Error> { for (addr, acc) in state.iter_mut() { - if !persistent_accounts.is_some_and(|accounts| accounts.contains(addr)) { + if persistent_accounts.is_none_or(|accounts| !accounts.contains(addr)) { acc.info = db.basic(*addr)?.unwrap_or_default(); for (key, val) in &mut acc.storage { val.present_value = db.storage(*addr, *key)?; @@ -2013,75 +3466,1264 @@ pub fn update_state( /// Applies the changeset of a transaction to the active journaled state and also commits it in the /// forked db -fn apply_state_changeset( - state: Map, +fn apply_state_changeset( + base_state: EvmState, + state: EvmState, journaled_state: &mut JournaledState, - fork: &mut Fork, - persistent_accounts: &HashSet
, + fork: &mut Fork, + persistent_accounts: &AddressSet, ) -> Result<(), BackendError> { - // commit the state and update the loaded accounts - fork.db.commit(state); - - update_state(&mut journaled_state.state, &mut fork.db, Some(persistent_accounts))?; - update_state(&mut fork.journaled_state.state, &mut fork.db, Some(persistent_accounts))?; - + // Refresh cloned journals against a cloned database so a failed read cannot publish only part + // of the transaction state. + let mut staged_db = fork.db.clone(); + let mut staged_journaled_state = journaled_state.clone(); + let mut staged_fork_journaled_state = fork.journaled_state.clone(); + let unchanged_persistent_accounts = + persistent_accounts.iter().filter(|addr| !state.contains_key(*addr)).copied().collect(); + staged_db.commit(base_state); + staged_db.commit(state); + update_state( + &mut staged_journaled_state.state, + &mut staged_db, + Some(&unchanged_persistent_accounts), + )?; + update_state( + &mut staged_fork_journaled_state.state, + &mut staged_db, + Some(&unchanged_persistent_accounts), + )?; + + fork.db = staged_db; + *journaled_state = staged_journaled_state; + fork.journaled_state = staged_fork_journaled_state; Ok(()) } +fn inject_replay_precompiles( + networks: NetworkConfigs, + precompiles: &mut PrecompilesMap, + chain_id: ChainId, + timestamp: u64, +) { + networks.inject_precompiles(precompiles); + apply_bsc_p256_precompile(precompiles, chain_id, timestamp); +} + #[cfg(test)] mod tests { - use crate::{backend::Backend, fork::CreateFork, opts::EvmOpts}; - use alloy_primitives::{Address, U256}; - use alloy_provider::Provider; - use foundry_common::provider::get_http_provider; + use super::{Fork, ForkAccountField, ReplayInputs, apply_state_changeset, update_env_block}; + use crate::{ + backend::{Backend, DatabaseExt, ForkPosition}, + evm::EthEvmNetwork, + fork::{CreateFork, ForkId, MultiFork}, + opts::EvmOpts, + }; + use alloy_consensus::{Signed, TxEnvelope, TxLegacy, transaction::Recovered}; + use alloy_eips::BlockNumHash; + use alloy_evm::EvmEnv; + use alloy_network::{ + AnyHeader, AnyNetwork, AnyRpcBlock, AnyRpcHeader, AnyRpcTransaction, AnyTxEnvelope, + AnyTxType, TransactionBuilder, UnknownTxEnvelope, UnknownTypedTransaction, + }; + use alloy_primitives::{ + Address, B256, Bytes, Signature, TxKind, U256, address, keccak256, map::AddressSet, + }; + use alloy_provider::{Provider, ProviderBuilder, mock::Asserter}; + use alloy_rpc_types::{ + Block, BlockTransactions, Transaction as RpcTransaction, TransactionRequest, + }; + use alloy_serde::WithOtherFields; + use alloy_sol_types::SolValue; + use anvil::{NodeConfig, spawn}; + use foundry_common::{SYSTEM_TRANSACTION_TYPE, provider::get_http_provider}; use foundry_config::{Config, NamedChain}; - use foundry_fork_db::cache::{BlockchainDb, BlockchainDbMeta}; - use revm::database::DatabaseRef; + use foundry_evm_networks::{NetworkConfigs, celo::transfer::CELO_TRANSFER_ADDRESS}; + use foundry_fork_db::{ + SharedBackend, + cache::{BlockchainDb, BlockchainDbMeta}, + }; + use revm::{ + context::{BlockEnv, JournalInner}, + database::{AccountState, CacheDB, DatabaseRef, DbAccount}, + primitives::{KECCAK_EMPTY, hardfork::SpecId}, + state::{Account, AccountInfo, EvmState, EvmStorageSlot, TransactionId}, + }; + + use std::sync::Arc; + + #[cfg(feature = "base")] + use crate::evm::{BaseEvmNetwork, base::base_code_sentinel_addresses}; + #[cfg(feature = "base")] + use alloy_primitives::bytes; + #[cfg(feature = "base")] + use base_common_chains::ChainConfig; + #[cfg(feature = "base")] + use base_common_consensus::{ + BaseTransactionInfo, BaseTxEnvelope, Predeploys, TxDeposit as BaseTxDeposit, + }; + #[cfg(feature = "base")] + use base_common_evm::{BaseSpecId, BaseUpgrade}; + #[cfg(feature = "base")] + use base_common_precompiles::ActivationRegistryStorage; + #[cfg(feature = "base")] + use base_common_rpc_types::Transaction as BaseRpcTransaction; + + #[cfg(feature = "optimism")] + use crate::evm::OpEvmNetwork; + #[cfg(feature = "optimism")] + use op_alloy_consensus::{ + OpTxEnvelope, TxDeposit as OpTxDeposit, transaction::OpTransactionInfo, + }; + #[cfg(feature = "optimism")] + use op_alloy_rpc_types::Transaction as OpRpcTransaction; + + #[cfg(any(feature = "base", feature = "optimism"))] + use alloy_consensus::Sealed; + #[cfg(any(feature = "base", feature = "optimism"))] + use alloy_eips::Typed2718; + + #[cfg(feature = "monad")] + use super::{ContextUpdate, ensure_block_identity}; + #[cfg(feature = "monad")] + use crate::evm::monad::BlockContext; + #[cfg(feature = "monad")] + use monad_revm::{ + MonadHardfork, + api::block::syscall_snapshot_calldata, + staking::{STAKING_ADDRESS, constants::SYSTEM_ADDRESS}, + }; + + fn fork_with_closed_backend() -> Fork { + let provider = + ProviderBuilder::<_, _, AnyNetwork>::default().connect_mocked_client(Asserter::new()); + let db = BlockchainDb::new( + BlockchainDbMeta::new(BlockEnv::default(), "http://localhost".to_string()), + None, + ); + let (backend, handler) = SharedBackend::new(provider, db, None); + drop(handler); + Fork { + db: CacheDB::new(backend), + journaled_state: JournalInner::new(), + source_chain_id: 1, + position: ForkPosition::AfterBlock { block: BlockNumHash::default() }, + block_env: None, + } + } + + fn rpc_block(number: u64, hash: B256, parent_hash: B256) -> AnyRpcBlock { + let header = AnyHeader { number, parent_hash, ..Default::default() }; + AnyRpcBlock::new( + Block::new( + AnyRpcHeader::from_sealed(header.seal(hash)), + BlockTransactions::Full(Vec::new()), + ) + .into(), + ) + } - const ENDPOINT: Option<&str> = option_env!("ETH_RPC_URL"); + fn rpc_transaction( + caller: Address, + nonce: u64, + value: u64, + gas_limit: u64, + recipient: Address, + hash: B256, + ) -> AnyRpcTransaction { + let tx = TxLegacy { + nonce, + gas_limit, + to: TxKind::Call(recipient), + value: U256::from(value), + ..Default::default() + }; + let signed = Signed::new_unchecked(tx, Signature::new(U256::ONE, U256::ONE, false), hash); + AnyRpcTransaction::new(WithOtherFields::new(RpcTransaction { + inner: Recovered::new_unchecked( + AnyTxEnvelope::Ethereum(TxEnvelope::Legacy(signed)), + caller, + ), + block_hash: None, + block_number: None, + transaction_index: None, + effective_gas_price: None, + block_timestamp: None, + })) + } + #[cfg(feature = "base")] #[tokio::test(flavor = "multi_thread")] - async fn can_read_write_cache() { - let Some(endpoint) = ENDPOINT else { return }; + async fn base_fork_prefix_replay_uses_source_chain_activation_admin() { + let sender = Address::with_last_byte(0x42); + let recorder = Address::with_last_byte(0x43); + let target = B256::with_last_byte(2); + let admin = + ChainConfig::activation_admin_address_for_upgrade_by_chain_id(8453, BaseUpgrade::Beryl) + .unwrap(); + // Call ActivationRegistry.admin(), require success, and store its return value in slot 0. + let code = revm::bytecode::Bytecode::new_legacy(bytes!( + "63f851a44060e01b60005260206000600460007384530000000000000000000000000000000000015afa60325760006000fd5b60005160005500" + )); + for chain_id in [8453, 31337, 84532] { + let forks = MultiFork::::spawn(); + let mut fork = fork_with_closed_backend(); + fork.source_chain_id = 8453; + fork.db.insert_account_info( + sender, + AccountInfo { balance: U256::from(10).pow(U256::from(18)), ..Default::default() }, + ); + fork.db.insert_account_info(recorder, AccountInfo::default().with_code(code.clone())); + for address in base_code_sentinel_addresses(BaseUpgrade::Beryl).chain([ + Address::ZERO, + Predeploys::L1_BLOCK_INFO, + Predeploys::BASE_FEE_VAULT, + Predeploys::L1_FEE_VAULT, + Predeploys::OPERATOR_FEE_VAULT, + ]) { + fork.db.insert_account_info(address, AccountInfo::default()); + } + for account in fork.db.cache.accounts.values_mut() { + account.account_state = AccountState::StorageCleared; + } + let mut block = rpc_block(1, B256::with_last_byte(1), B256::ZERO); + block.inner.transactions = BlockTransactions::Full(vec![ + rpc_transaction(sender, 0, 0, 200_000, recorder, B256::with_last_byte(1)), + rpc_transaction(sender, 1, 0, 21_000, recorder, target), + ]); + let mut cfg = revm::context::CfgEnv::new_with_spec(BaseSpecId::new(BaseUpgrade::Beryl)); + cfg.chain_id = chain_id; + let result = Backend::::replay_until( + &mut fork, + ReplayInputs { + fork_id: ForkId::new("http://localhost", Some(0)), + forks, + evm_env: EvmEnv::new(cfg, BlockEnv::default()), + networks: NetworkConfigs::with_base(), + }, + &block, + #[cfg(feature = "monad")] + None, + target, + &mut JournalInner::new(), + &AddressSet::default(), + ) + .unwrap(); + assert!(result.is_some()); + assert_eq!( + fork.db.storage_ref(recorder, U256::ZERO).unwrap(), + U256::from_be_slice(admin.as_slice()), + "execution chain {chain_id}" + ); + assert_eq!(fork.db.basic_ref(sender).unwrap().unwrap().nonce, 1); + } + } - let provider = get_http_provider(endpoint); + #[test] + fn fork_replay_backend_reuses_manager_on_current_thread_runtime() { + let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap(); + + let forks = runtime.block_on(async { + let forks = MultiFork::::spawn(); + let sender = Address::with_last_byte(0x42); + let recipient = Address::with_last_byte(0x43); + let target = B256::with_last_byte(2); + let mut fork = fork_with_closed_backend(); + fork.db.insert_account_info( + sender, + AccountInfo { balance: U256::from(100), ..Default::default() }, + ); + fork.db.insert_account_info(recipient, AccountInfo::default()); + fork.db.insert_account_info(Address::ZERO, AccountInfo::default()); + let mut block = rpc_block(1, B256::with_last_byte(1), B256::ZERO); + block.inner.transactions = BlockTransactions::Full(vec![ + rpc_transaction(sender, 0, 7, 21_000, recipient, B256::with_last_byte(1)), + rpc_transaction(sender, 1, 0, 21_000, recipient, target), + ]); + + let result = Backend::::replay_until( + &mut fork, + ReplayInputs { + fork_id: ForkId::new("http://localhost", Some(0)), + forks: forks.clone(), + evm_env: EvmEnv::default(), + networks: NetworkConfigs::default(), + }, + &block, + #[cfg(feature = "monad")] + None, + target, + &mut JournalInner::new(), + &AddressSet::default(), + ) + .unwrap(); + + assert!(result.is_some()); + assert_eq!(fork.db.basic_ref(recipient).unwrap().unwrap().balance, U256::from(7)); + assert_eq!(fork.db.basic_ref(sender).unwrap().unwrap().nonce, 1); + forks + }); + drop(runtime); + drop(forks); + } - let block_num = provider.get_block_number().await.unwrap(); + #[test] + fn fork_replay_keeps_system_skips_and_prefix_atomicity() { + let sender = Address::with_last_byte(0x42); + let recipient = Address::with_last_byte(0x43); + let system = address!("6f49a8f621353f12378d0046e7d7e4b9b249dc9e"); + let target = B256::with_last_byte(4); + + #[cfg(not(feature = "monad"))] + let contexts = [false]; + #[cfg(feature = "monad")] + let contexts = [false, true]; + for _with_context in contexts { + for invalid_nonce in [false, true] { + let mut fork = fork_with_closed_backend(); + fork.db.insert_account_info( + sender, + AccountInfo { balance: U256::from(100), ..Default::default() }, + ); + fork.db.insert_account_info(recipient, AccountInfo::default()); + fork.db.insert_account_info(Address::ZERO, AccountInfo::default()); + let block = AnyRpcBlock::new( + Block::new( + AnyRpcHeader::from_sealed( + AnyHeader { number: 1, ..Default::default() } + .seal(B256::with_last_byte(1)), + ), + BlockTransactions::Full(vec![ + rpc_transaction( + sender, + 0, + 7, + 21_000, + recipient, + B256::with_last_byte(1), + ), + // Ethereum must skip this envelope, not validate it as an ordinary + // call. + rpc_transaction(system, 0, 0, 0, recipient, B256::with_last_byte(2)), + rpc_transaction( + sender, + if invalid_nonce { 0 } else { 1 }, + 5, + 21_000, + recipient, + B256::with_last_byte(3), + ), + rpc_transaction(sender, 2, 2, 21_000, recipient, target), + ]), + ) + .into(), + ); + let networks = NetworkConfigs::default(); + #[cfg(feature = "monad")] + let networks = if _with_context { NetworkConfigs::with_monad() } else { networks }; + #[cfg(feature = "monad")] + let context = _with_context.then(|| { + BlockContext::::new(Vec::new(), Vec::new(), Vec::new()) + }); + let result = Backend::::replay_until( + &mut fork, + ReplayInputs { + fork_id: ForkId::new("http://localhost", Some(0)), + forks: MultiFork::spawn(), + evm_env: EvmEnv::default(), + networks, + }, + &block, + #[cfg(feature = "monad")] + context.as_ref(), + target, + &mut JournalInner::new(), + &AddressSet::default(), + ); + if invalid_nonce { + assert!(result.is_err()); + assert_eq!(fork.db.basic_ref(recipient).unwrap().unwrap().balance, U256::ZERO); + assert_eq!(fork.db.basic_ref(sender).unwrap().unwrap().nonce, 0); + } else { + assert!(result.unwrap().is_some()); + assert_eq!( + fork.db.basic_ref(recipient).unwrap().unwrap().balance, + U256::from(12) + ); + assert_eq!(fork.db.basic_ref(sender).unwrap().unwrap().nonce, 2); + } + } + } + } - let config = Config::figment(); - let mut evm_opts = config.extract::().unwrap(); - evm_opts.fork_block_number = Some(block_num); + #[test] + #[cfg(feature = "monad")] + fn fork_replay_executes_monad_system_prefix_with_or_without_profile() { + let transaction = |nonce, hash| { + let tx = TxLegacy { + nonce, + to: TxKind::Call(STAKING_ADDRESS), + input: syscall_snapshot_calldata(), + ..Default::default() + }; + let signed = + Signed::new_unchecked(tx, Signature::new(U256::ONE, U256::ONE, false), hash); + AnyRpcTransaction::new(WithOtherFields::new(RpcTransaction { + inner: Recovered::new_unchecked( + AnyTxEnvelope::Ethereum(TxEnvelope::Legacy(signed)), + SYSTEM_ADDRESS, + ), + block_hash: None, + block_number: None, + transaction_index: None, + effective_gas_price: None, + block_timestamp: None, + })) + }; + let target = B256::with_last_byte(2); + let mut block = rpc_block(1, B256::with_last_byte(1), B256::ZERO); + block.inner.transactions = BlockTransactions::Full(vec![ + transaction(3, B256::with_last_byte(1)), + transaction(4, target), + ]); + for with_context in [false, true] { + let mut fork = fork_with_closed_backend(); + fork.db.insert_account_info( + SYSTEM_ADDRESS, + AccountInfo { nonce: 3, ..Default::default() }, + ); + fork.db.insert_account_info(STAKING_ADDRESS, AccountInfo::default()); + fork.db.replace_account_storage(STAKING_ADDRESS, Default::default()).unwrap(); + let context = with_context.then(|| { + BlockContext::::new(Vec::new(), Vec::new(), Vec::new()) + }); + let result = Backend::::replay_until( + &mut fork, + ReplayInputs { + fork_id: ForkId::new("http://localhost", Some(0)), + forks: MultiFork::spawn(), + evm_env: EvmEnv::new( + revm::context::CfgEnv::new_with_spec(MonadHardfork::MonadNine), + BlockEnv::default(), + ), + networks: if with_context { + NetworkConfigs::with_monad() + } else { + NetworkConfigs::default() + }, + }, + &block, + context.as_ref(), + target, + &mut JournalInner::new(), + &AddressSet::default(), + ); + assert!(result.unwrap().is_some()); + assert_eq!(fork.db.basic_ref(SYSTEM_ADDRESS).unwrap().unwrap().nonce, 4); + } + } + + #[test] + #[cfg(feature = "monad")] + fn validates_block_identity() { + let hash = B256::with_last_byte(2); + let block = rpc_block(2, hash, B256::with_last_byte(1)); + assert!(ensure_block_identity(&block, BlockNumHash::new(2, hash), "parent").is_ok()); + + let err = + ensure_block_identity(&block, BlockNumHash::new(2, B256::with_last_byte(3)), "parent") + .unwrap_err(); + assert!(err.to_string().contains("parent block changed")); + + let err = + ensure_block_identity(&block, BlockNumHash::new(1, hash), "grandparent").unwrap_err(); + assert!(err.to_string().contains("grandparent block changed")); + } + + #[test] + fn failed_fork_state_refresh_does_not_publish_transaction_changes() { + let mut fork = fork_with_closed_backend(); + let externally_loaded = Address::with_last_byte(1); + let fork_loaded = Address::with_last_byte(2); + let committed = Address::with_last_byte(3); + let seeded = Address::with_last_byte(4); + let missing_slot = U256::ONE; + + let cached_external = AccountInfo { balance: U256::from(11), ..Default::default() }; + let cached_fork = AccountInfo { balance: U256::from(12), ..Default::default() }; + let cached_seeded = AccountInfo { balance: U256::from(14), ..Default::default() }; + fork.db.insert_account_info(externally_loaded, cached_external); + fork.db.insert_account_info(fork_loaded, cached_fork); + fork.db.insert_account_info(seeded, cached_seeded); + + let mut journaled_state = JournalInner::new(); + let external_account = + Account::default().with_info(AccountInfo { balance: U256::ONE, ..Default::default() }); + journaled_state.state.insert(externally_loaded, external_account); + + let mut fork_account = Account::default() + .with_info(AccountInfo { balance: U256::from(2), ..Default::default() }); + fork_account + .storage + .insert(missing_slot, EvmStorageSlot::new(U256::ZERO, TransactionId::ZERO)); + fork.journaled_state.state.insert(fork_loaded, fork_account); + + let mut committed_account = Account::default() + .with_info(AccountInfo { balance: U256::from(13), ..Default::default() }); + committed_account.mark_touch(); + let mut state = EvmState::default(); + state.insert(committed, committed_account); + + let mut seeded_account = Account::default() + .with_info(AccountInfo { balance: U256::from(15), ..Default::default() }); + seeded_account.mark_touch(); + let mut base_state = EvmState::default(); + base_state.insert(seeded, seeded_account); + + let result = apply_state_changeset( + base_state, + state, + &mut journaled_state, + &mut fork, + &AddressSet::default(), + ); + assert!(result.is_err()); + assert!(!fork.db.cache.accounts.contains_key(&committed)); + assert_eq!(fork.db.basic_ref(seeded).unwrap().unwrap().balance, U256::from(14)); + assert_eq!(journaled_state.state[&externally_loaded].info.balance, U256::ONE); + assert_eq!(fork.journaled_state.state[&fork_loaded].info.balance, U256::from(2)); + } + + #[test] + fn failed_fork_state_refresh_preserves_not_existing_account() { + let mut fork = fork_with_closed_backend(); + let address = Address::with_last_byte(1); + let missing_slot = U256::ONE; + fork.db.cache.accounts.insert(address, DbAccount::new_not_existing()); + + let mut journaled_state = JournalInner::new(); + let mut journaled_account = + Account::default().with_info(AccountInfo { balance: U256::ONE, ..Default::default() }); + journaled_account + .storage + .insert(missing_slot, EvmStorageSlot::new(U256::from(7), TransactionId::ZERO)); + journaled_state.state.insert(address, journaled_account); + + let mut touched_account = Account::default() + .with_info(AccountInfo { balance: U256::from(13), ..Default::default() }); + touched_account.mark_touch(); + let mut state = EvmState::default(); + state.insert(address, touched_account); + + let result = apply_state_changeset( + EvmState::default(), + state, + &mut journaled_state, + &mut fork, + &AddressSet::default(), + ); + assert!(result.is_err()); + assert_eq!(fork.db.cache.accounts[&address].account_state, AccountState::NotExisting); + assert_eq!(journaled_state.state[&address].info.balance, U256::ONE); + assert_eq!( + journaled_state.state[&address].storage[&missing_slot].present_value(), + U256::from(7) + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn refresh_fork_account_updates_loaded_journals() { + let (api, handle) = spawn(NodeConfig::test()).await; + let target = address!("0x0000000000000000000000000000000000001331"); + let code = + Bytes::from_static(&[0x60, 0x2a, 0x60, 0x00, 0x52, 0x60, 0x20, 0x60, 0x00, 0xf3]); + + let provider = handle.http_provider(); + let block_number = provider.get_block_number().await.unwrap(); + let mut evm_opts = Config::figment().extract::().unwrap(); + evm_opts.fork_url = Some(handle.http_endpoint()); + evm_opts.fork_block_number = Some(block_number); + let fork = evm_opts.get_fork(&Config::default(), 31_337, Some(block_number)).unwrap(); + let mut backend = Backend::::spawn(Some(fork)).unwrap(); + + let mut journaled_state = JournalInner::new(); + journaled_state.load_account(&mut backend, target).unwrap(); + journaled_state.state.get_mut(&target).unwrap().info.balance = U256::ONE; + let fork = backend.active_fork_mut().unwrap(); + fork.journaled_state.load_account(&mut fork.db, target).unwrap(); + fork.journaled_state.state.get_mut(&target).unwrap().info.balance = U256::from(2); + let cached = fork.db.cache.accounts.get_mut(&target).unwrap(); + cached.info.balance = U256::from(3); + cached.account_state = AccountState::Touched; + assert_eq!(journaled_state.state[&target].info.code_hash, KECCAK_EMPTY); + assert_eq!(fork.journaled_state.state[&target].info.code_hash, KECCAK_EMPTY); + + api.anvil_set_code(target, code.clone()).await.unwrap(); + backend.refresh_fork_account(target, ForkAccountField::Code, &mut journaled_state).unwrap(); + + let expected_hash = keccak256(&code); + let refreshed = &journaled_state.state[&target].info; + assert_eq!(refreshed.code_hash(), expected_hash); + assert_eq!(refreshed.code.as_ref().unwrap().original_bytes(), code); + assert_eq!(refreshed.balance, U256::ONE); + let refreshed = &backend.active_fork().unwrap().journaled_state.state[&target].info; + assert_eq!(refreshed.code_hash(), expected_hash); + assert_eq!(refreshed.code.as_ref().unwrap().original_bytes(), code); + assert_eq!(refreshed.balance, U256::from(2)); + let cached = &backend.active_fork().unwrap().db.cache.accounts[&target]; + assert_eq!(cached.info.code_hash(), expected_hash); + assert_eq!(cached.info.balance, U256::from(3)); + } + + #[test] + fn ethereum_replay_skips_unknown_system_envelopes_before_conversion() { + let transaction = |ty| { + let unknown = AnyTxEnvelope::Unknown(UnknownTxEnvelope { + hash: B256::ZERO, + inner: UnknownTypedTransaction { + ty: AnyTxType(ty), + fields: Default::default(), + memo: Default::default(), + }, + }); + AnyRpcTransaction::new(WithOtherFields::new(RpcTransaction { + inner: Recovered::new_unchecked(unknown, Address::with_last_byte(0x42)), + block_hash: None, + block_number: None, + transaction_index: None, + effective_gas_price: None, + block_timestamp: None, + })) + }; + + assert!( + Backend::::replay_tx_env(&transaction(SYSTEM_TRANSACTION_TYPE)) + .unwrap() + .is_none() + ); + assert!(Backend::::replay_tx_env(&transaction(0xff)).is_err()); + } + + #[cfg(feature = "optimism")] + #[test] + fn optimism_fork_transaction_hash_replay_executes_deposit_prefix() { + let sender = Address::with_last_byte(0xaa); + let recipient = Address::with_last_byte(0xbb); + let deposit = OpTxDeposit { + source_hash: B256::repeat_byte(0x11), + from: sender, + to: TxKind::Call(recipient), + mint: 1_000, + value: U256::from(600), + gas_limit: 100_000, + is_system_transaction: false, + input: Bytes::new(), + }; + let rpc = OpRpcTransaction::from_transaction( + Recovered::new_unchecked(OpTxEnvelope::Deposit(Sealed::new(deposit)), sender), + OpTransactionInfo::default(), + ); + let deposit_rpc = + serde_json::from_value::(serde_json::to_value(rpc).unwrap()) + .unwrap(); + assert_eq!(deposit_rpc.ty(), SYSTEM_TRANSACTION_TYPE); + + let target = B256::with_last_byte(2); + let mut block = rpc_block(1, B256::with_last_byte(1), B256::ZERO); + block.inner.transactions = BlockTransactions::Full(vec![ + rpc_transaction( + address!("6f49a8f621353f12378d0046e7d7e4b9b249dc9e"), + 0, + 0, + 0, + recipient, + B256::with_last_byte(3), + ), + deposit_rpc, + rpc_transaction(sender, 0, 0, 21_000, recipient, target), + ]); + let mut fork = fork_with_closed_backend(); + fork.db.insert_account_info(sender, AccountInfo::default()); + fork.db.insert_account_info(recipient, AccountInfo::default()); + + let result = Backend::::replay_until( + &mut fork, + ReplayInputs { + fork_id: ForkId::new("http://localhost", Some(0)), + forks: MultiFork::spawn(), + evm_env: EvmEnv::default(), + networks: NetworkConfigs::with_optimism(), + }, + &block, + #[cfg(feature = "monad")] + None, + target, + &mut JournalInner::new(), + &AddressSet::default(), + ) + .unwrap(); + + assert!(result.is_some()); + assert_eq!(fork.db.basic_ref(sender).unwrap().unwrap().balance, U256::from(400)); + assert_eq!(fork.db.basic_ref(recipient).unwrap().unwrap().balance, U256::from(600)); + } + + #[cfg(feature = "base")] + #[test] + fn base_fork_replay_refreshes_accounts_after_deposit() { + let deposit_sender = Address::with_last_byte(0xaa); + let sender = Address::with_last_byte(0xbb); + let destroyer = Address::with_last_byte(0xcc); + let recorder = Address::with_last_byte(0xdd); + let registry = ActivationRegistryStorage::ADDRESS; + + // Transfer the deposit value to the registry without invoking the registry precompile. + let mut destroyer_code = vec![0x73]; + destroyer_code.extend(registry); + destroyer_code.push(0xff); + let destroyer_code = revm::bytecode::Bytecode::new_legacy(destroyer_code.into()); + + // Store the registry balance in slot 0. + let mut recorder_code = vec![0x73]; + recorder_code.extend(registry); + recorder_code.extend([0x31, 0x5f, 0x55, 0x00]); + let recorder_code = revm::bytecode::Bytecode::new_legacy(recorder_code.into()); + + let deposit = BaseTxDeposit { + source_hash: B256::repeat_byte(0x11), + from: deposit_sender, + to: TxKind::Call(destroyer), + mint: 1_000, + value: U256::from(600), + gas_limit: 100_000, + is_system_transaction: false, + input: Bytes::new(), + }; + let rpc = BaseRpcTransaction::from_transaction( + Recovered::new_unchecked(BaseTxEnvelope::Deposit(Sealed::new(deposit)), deposit_sender), + BaseTransactionInfo::default(), + ); + let deposit_rpc = + serde_json::from_value::(serde_json::to_value(rpc).unwrap()) + .unwrap(); + assert_eq!(deposit_rpc.ty(), SYSTEM_TRANSACTION_TYPE); + + let target = B256::with_last_byte(3); + let mut block = rpc_block(1, B256::with_last_byte(1), B256::ZERO); + block.inner.transactions = BlockTransactions::Full(vec![ + deposit_rpc, + rpc_transaction(sender, 0, 0, 100_000, recorder, B256::with_last_byte(2)), + rpc_transaction(sender, 1, 0, 21_000, recorder, target), + ]); + + let mut fork = fork_with_closed_backend(); + fork.db.insert_account_info(deposit_sender, AccountInfo::default()); + fork.db.insert_account_info(sender, AccountInfo::default()); + fork.db.insert_account_info(destroyer, AccountInfo::default().with_code(destroyer_code)); + fork.db.insert_account_info(recorder, AccountInfo::default().with_code(recorder_code)); + let sentinel = revm::bytecode::Bytecode::new_legacy(Bytes::from_static( + crate::constants::SYSTEM_PRECOMPILE_STUB, + )); + fork.db.insert_account_info(registry, AccountInfo::default().with_code(sentinel)); + for address in base_code_sentinel_addresses(BaseUpgrade::Beryl).chain([ + Address::ZERO, + Predeploys::L1_BLOCK_INFO, + Predeploys::BASE_FEE_VAULT, + Predeploys::L1_FEE_VAULT, + Predeploys::OPERATOR_FEE_VAULT, + ]) { + fork.db.cache.accounts.entry(address).or_default(); + } + for account in fork.db.cache.accounts.values_mut() { + account.account_state = AccountState::StorageCleared; + } + + let mut cfg = revm::context::CfgEnv::new_with_spec(BaseSpecId::new(BaseUpgrade::Beryl)); + cfg.chain_id = 8453; + let result = Backend::::replay_until( + &mut fork, + ReplayInputs { + fork_id: ForkId::new("http://localhost", Some(0)), + forks: MultiFork::spawn(), + evm_env: EvmEnv::new(cfg, BlockEnv::default()), + networks: NetworkConfigs::with_base(), + }, + &block, + #[cfg(feature = "monad")] + None, + target, + &mut JournalInner::new(), + &AddressSet::default(), + ) + .unwrap(); + + assert!(result.is_some()); + assert_eq!(fork.db.basic_ref(deposit_sender).unwrap().unwrap().balance, U256::from(400)); + assert_eq!(fork.db.basic_ref(registry).unwrap().unwrap().balance, U256::from(600)); + assert_eq!(fork.db.storage_ref(recorder, U256::ZERO).unwrap(), U256::from(600)); + } + + #[tokio::test(flavor = "multi_thread")] + async fn celo_transaction_hash_fork_replays_transfer_precompile() { + let networks = NetworkConfigs::with_celo(); + let (api, handle) = spawn( + NodeConfig::test().with_chain_id(Some(NamedChain::Celo as u64)).with_networks(networks), + ) + .await; + let provider = handle.http_provider(); + let sender = provider.get_accounts().await.unwrap()[0]; + let recipient = Address::with_last_byte(0x99); + let transfer_amount = U256::from(1_000); + let target_amount = U256::ONE; + let nonce = provider.get_transaction_count(sender).await.unwrap(); + let gas_price = provider.get_gas_price().await.unwrap(); + + api.anvil_set_auto_mine(false).await.unwrap(); + api.send_transaction(WithOtherFields::new( + TransactionRequest::default() + .with_from(sender) + .with_to(CELO_TRANSFER_ADDRESS) + .with_nonce(nonce) + .with_gas_limit(100_000) + .with_gas_price(gas_price) + .with_input(Bytes::from((sender, recipient, transfer_amount).abi_encode())), + )) + .await + .unwrap(); + let target_hash = api + .send_transaction(WithOtherFields::new( + TransactionRequest::default() + .with_from(sender) + .with_to(recipient) + .with_nonce(nonce + 1) + .with_gas_limit(21_000) + .with_gas_price(gas_price) + .with_value(target_amount), + )) + .await + .unwrap(); + api.mine_one().await.unwrap(); + + assert_eq!(provider.get_balance(recipient).await.unwrap(), transfer_amount + target_amount); + + let endpoint = handle.http_endpoint(); + let fork_block_number = provider.get_block_number().await.unwrap(); + let evm_opts = EvmOpts { + fork_url: Some(endpoint.clone()), + fork_block_number: Some(fork_block_number), + networks, + ..Default::default() + }; + let fork = CreateFork { url: endpoint, enable_caching: false, evm_opts }; + let mut backend = Backend::::spawn(None).unwrap(); + backend.set_networks(networks); + + let fork_id = backend.create_fork_at_transaction(fork, target_hash).unwrap(); + let fork = backend.inner.get_fork_by_id(fork_id).unwrap(); + assert!(matches!( + fork.position, + ForkPosition::BeforeTransaction { transaction_index: 1, .. } + )); + assert_eq!( + fork.db.basic_ref(recipient).unwrap().unwrap_or_default().balance, + transfer_amount + ); + } + + #[test] + fn fork_position_advances_from_exact_transaction_predecessor() { + let parent_block = BlockNumHash::new(10, B256::with_last_byte(10)); + let block = BlockNumHash::new(11, B256::with_last_byte(11)); + let parent = ForkPosition::AfterBlock { block: parent_block }; + assert_eq!( + parent.after_transaction(block, parent_block.hash, 0, 2), + Some(ForkPosition::BeforeTransaction { block, transaction_index: 1 }) + ); + assert_eq!( + parent.after_transaction(block, parent_block.hash, 0, 1), + Some(ForkPosition::AfterBlock { block }) + ); - let (env, _block) = evm_opts.fork_evm_env(endpoint).await.unwrap(); + let before_first = ForkPosition::BeforeTransaction { block, transaction_index: 0 }; + assert_eq!( + before_first.after_transaction(block, parent_block.hash, 0, 2), + Some(ForkPosition::BeforeTransaction { block, transaction_index: 1 }) + ); + + let before_second = ForkPosition::BeforeTransaction { block, transaction_index: 1 }; + assert_eq!( + before_second.after_transaction(block, parent_block.hash, 1, 3), + Some(ForkPosition::BeforeTransaction { block, transaction_index: 2 }) + ); + assert_eq!( + before_second.after_transaction(block, parent_block.hash, 1, 2), + Some(ForkPosition::AfterBlock { block }) + ); + + assert_eq!(parent.after_transaction(block, B256::ZERO, 0, 1), None); + assert_eq!(parent.after_transaction(block, parent_block.hash, 1, 2), None); + assert_eq!(before_second.after_transaction(block, parent_block.hash, 0, 3), None); + assert_eq!(before_second.after_transaction(block, parent_block.hash, 2, 3), None); + assert_eq!(before_second.after_transaction(block, parent_block.hash, 1, 1), None); + assert_eq!(parent.after_transaction(block, parent_block.hash, 0, 0), None); + } + + #[test] + fn fork_block_env_updates_slot_number() { + let mut evm_env = + EvmEnv::new(revm::context::CfgEnv::::default(), BlockEnv::default()); + for slot_number in [Some(42), Some(u64::MAX), None, Some(0)] { + let header = AnyHeader { slot_number, ..Default::default() }; + let block = AnyRpcBlock::new( + Block::new( + AnyRpcHeader::from_sealed(header.seal(B256::ZERO)), + BlockTransactions::Full(Vec::new()), + ) + .into(), + ); + update_env_block::( + &mut evm_env, + &block, + NamedChain::Mainnet as u64, + NetworkConfigs::default(), + ); + assert_eq!(evm_env.block_env.slot_num, slot_number.unwrap_or_default()); + } + } + + #[test] + fn fork_replay_block_env_preserves_arbitrum_l1_number() { + let header = AnyHeader { number: 75_219_831, ..Default::default() }; + let mut block = AnyRpcBlock::new( + Block::new( + AnyRpcHeader::from_sealed(header.seal(B256::ZERO)), + BlockTransactions::Full(Vec::new()), + ) + .into(), + ); + block.other.insert("l1BlockNumber".to_string(), serde_json::json!("0x10276d3")); + let mut evm_env = + EvmEnv::new(revm::context::CfgEnv::::default(), BlockEnv::default()); + + update_env_block::( + &mut evm_env, + &block, + NamedChain::Arbitrum as u64, + NetworkConfigs::default(), + ); + + assert_eq!(evm_env.block_env.number, U256::from(16_938_707)); + } + + #[tokio::test(flavor = "multi_thread")] + async fn temporary_backend_preserves_fork_position() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let provider = handle.http_provider(); + let block_number = provider.get_block_number().await.unwrap(); + + let mut evm_opts = Config::figment().extract::().unwrap(); + evm_opts.fork_url = Some(handle.http_endpoint()); + evm_opts.fork_block_number = Some(block_number); + let fork = evm_opts.get_fork(&Config::default(), 31_337, Some(block_number)).unwrap(); + let mut backend = Backend::::spawn(Some(fork)).unwrap(); + let id = backend.active_fork_ids.unwrap().0; + let fork_id = backend.inner.ensure_fork_id(id).unwrap().clone(); + + for position in [ + ForkPosition::BeforeTransaction { + block: BlockNumHash::new(block_number + 1, B256::with_last_byte(1)), + transaction_index: 2, + }, + ForkPosition::AfterBlock { + block: BlockNumHash::new(block_number + 2, B256::with_last_byte(2)), + }, + ] { + backend.inner.get_fork_by_id_mut(id).unwrap().position = position; + let fork = backend.active_fork().unwrap().clone(); + let journaled_state = fork.journaled_state.clone(); + let mut temporary = Backend::::new_with_fork( + &fork_id, + fork, + journaled_state, + NetworkConfigs::default(), + ) + .unwrap(); + + assert_eq!(temporary.active_fork().unwrap().position, position); + let expected = match position { + ForkPosition::AfterBlock { block } + | ForkPosition::BeforeTransaction { block, .. } => block.number, + }; + assert_eq!(temporary.active_fork_block_number(), Some(expected)); + temporary.fork_block_number_override = Some(expected + 1); + assert_eq!(temporary.active_fork_block_number(), Some(expected + 1)); + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn fork_execution_scopes_release_their_registry() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let request = CreateFork { + url: handle.http_endpoint(), + enable_caching: false, + evm_opts: EvmOpts { fork_url: Some(handle.http_endpoint()), ..Default::default() }, + }; + let seed = Backend::::spawn(Some(request.clone())).unwrap(); + let initial = seed.ensure_fork_id(seed.active_fork_id().unwrap()).unwrap().clone(); + let shared = seed.forks.get_fork(initial.clone()).unwrap().unwrap(); + let mut execution = seed.clone_with_fork_scope().unwrap(); + let inherited = execution.forks.get_fork(initial.clone()).unwrap().unwrap(); + assert!(Arc::ptr_eq(&shared.data(), &inherited.data())); + + let local = execution.create_fork(request).unwrap(); + let local = execution.ensure_fork_id(local).unwrap().clone(); + assert!(execution.forks.get_fork(local.clone()).unwrap().is_some()); + assert!(seed.forks.get_fork(local.clone()).unwrap().is_none()); + let next = seed.clone_with_fork_scope().unwrap(); + assert!(next.forks.get_fork(local).unwrap().is_none()); + let (_other_api, other) = spawn(NodeConfig::test()).await; + let unique = execution + .create_fork(CreateFork { + url: other.http_endpoint(), + enable_caching: false, + evm_opts: EvmOpts { fork_url: Some(other.http_endpoint()), ..Default::default() }, + }) + .unwrap(); + let unique = execution.ensure_fork_id(unique).unwrap().clone(); + let unique = execution.forks.get_fork(unique).unwrap().unwrap(); + let cache = Arc::downgrade(&unique.data()); + drop(unique); + drop(execution); + assert!(cache.upgrade().is_none(), "execution-owned remote cache must be released"); + drop(seed); + + // The child retains the original handler even after its owner is gone. + assert!(next.forks.get_fork(initial).unwrap().is_some()); + assert!( + next.basic_ref(handle.dev_accounts().next().unwrap()).unwrap().unwrap().balance + > U256::ZERO + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn fork_clones_keep_block_overrides_local() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let opts = EvmOpts { fork_url: Some(handle.http_endpoint()), ..Default::default() }; + let request = CreateFork { + url: handle.http_endpoint(), + enable_caching: false, + evm_opts: opts.clone(), + }; + let mut first = Backend::::spawn(Some(request.clone())).unwrap(); + let initial_id = first.active_fork_id().unwrap(); + let other_id = first.create_fork(request).unwrap(); + let (mut first_env, mut first_tx) = first.env(&opts).await.unwrap(); + let mut second_env = first_env.clone(); + let mut second_tx = first_tx.clone(); + let original = first_env.block_env.clone(); + let mut second = first.clone(); + let mut first_journal = JournalInner::new(); + let mut second_journal = JournalInner::new(); + + second.select_fork(other_id, &mut second_env, &mut second_tx, &mut second_journal).unwrap(); + first_env.block_env.number = original.number + U256::ONE; + first_env.block_env.timestamp = original.timestamp + U256::from(100); + let changed = first_env.block_env.clone(); + first.select_fork(other_id, &mut first_env, &mut first_tx, &mut first_journal).unwrap(); + second + .select_fork(initial_id, &mut second_env, &mut second_tx, &mut second_journal) + .unwrap(); + assert_eq!(second_env.block_env.number, original.number); + assert_eq!(second_env.block_env.timestamp, original.timestamp); + first.select_fork(initial_id, &mut first_env, &mut first_tx, &mut first_journal).unwrap(); + assert_eq!(first_env.block_env.number, changed.number); + assert_eq!(first_env.block_env.timestamp, changed.timestamp); + } + + #[tokio::test(flavor = "multi_thread")] + #[cfg(feature = "monad")] + async fn fork_factory_boundary_preserves_explicit_execution_overrides() { + async fn pinned_opts(endpoint: String, networks: Option) -> EvmOpts { + let mut opts = EvmOpts { fork_url: Some(endpoint), ..Default::default() }; + if let Some(networks) = networks { + opts.networks = networks; + } + opts.infer_network_from_fork().await.unwrap(); + let identity = opts.fork_endpoint.clone().unwrap(); + let network_is_inferred = opts.fork_network_is_inferred; + opts.expect_fork_endpoint(identity, network_is_inferred); + opts.pin_fork_block().await.unwrap(); + opts + } + + fn target_fork(opts: EvmOpts, url: String) -> crate::fork::CreateFork { + crate::fork::CreateFork { url, enable_caching: false, evm_opts: opts } + } + + let (ethereum_base_api, ethereum_base) = spawn(NodeConfig::test()).await; + let (_ethereum_target_api, ethereum_target) = spawn(NodeConfig::test()).await; + let (monad_base_api, monad_base) = spawn(NodeConfig::test_monad()).await; + let (_monad_target_api, monad_target) = spawn(NodeConfig::test_monad()).await; + ethereum_base_api.mine_one().await.unwrap(); + monad_base_api.mine_one().await.unwrap(); + + let inferred_ethereum = pinned_opts(ethereum_base.http_endpoint(), None).await; + assert!(inferred_ethereum.fork_network_is_inferred); + let error = Backend::::spawn(Some(target_fork( + inferred_ethereum, + monad_target.http_endpoint(), + ))) + .unwrap_err(); + assert!( + error + .to_string() + .contains("cannot create a `monad` fork with an EVM instantiated for `ethereum`"), + "{error}" + ); + + let inferred_monad = pinned_opts(monad_base.http_endpoint(), None).await; + assert!(inferred_monad.fork_network_is_inferred); + let error = Backend::::spawn(Some(target_fork( + inferred_monad, + ethereum_target.http_endpoint(), + ))) + .unwrap_err(); + assert!( + error + .to_string() + .contains("cannot create a `ethereum` fork with an EVM instantiated for `monad`"), + "{error}" + ); + + let explicit_ethereum = + pinned_opts(ethereum_base.http_endpoint(), Some(NetworkConfigs::with_ethereum())).await; + assert!(!explicit_ethereum.fork_network_is_inferred); + let _backend = Backend::::spawn(Some(target_fork( + explicit_ethereum, + monad_target.http_endpoint(), + ))) + .unwrap(); + + let explicit_monad = + pinned_opts(monad_base.http_endpoint(), Some(NetworkConfigs::with_monad())).await; + assert!(!explicit_monad.fork_network_is_inferred); + let _backend = Backend::::spawn(Some(target_fork( + explicit_monad, + ethereum_target.http_endpoint(), + ))) + .unwrap(); + } - let fork = CreateFork { - enable_caching: true, - url: endpoint.to_string(), - env: env.clone(), - evm_opts, + #[tokio::test(flavor = "multi_thread")] + #[cfg(feature = "optimism")] + async fn optimism_fork_rejects_non_op_source() { + let (_op_api, op) = spawn(NodeConfig::test().with_optimism()).await; + let (_eth_api, eth) = spawn(NodeConfig::test()).await; + let mut opts = EvmOpts { fork_url: Some(op.http_endpoint()), ..Default::default() }; + opts.infer_network_from_fork().await.unwrap(); + assert!(opts.fork_network_is_inferred); + assert!(opts.networks.is_optimism()); + let original_fork = crate::fork::CreateFork { + url: op.http_endpoint(), + enable_caching: false, + evm_opts: opts.clone(), }; + let mut backend = Backend::::spawn(Some(original_fork)).unwrap(); + let original_id = backend.active_fork_id(); + let error = backend + .create_fork(crate::fork::CreateFork { + url: eth.http_endpoint(), + enable_caching: false, + evm_opts: opts.clone(), + }) + .unwrap_err(); + assert_eq!( + error.to_string(), + "cannot create a `ethereum` fork with an EVM instantiated for `optimism`; run the script with --rpc-url pointing to the forked chain to select its EVM" + ); + assert_eq!(backend.active_fork_id(), original_id); + } - let backend = Backend::spawn(Some(fork)).unwrap(); + #[tokio::test(flavor = "multi_thread")] + #[cfg(feature = "optimism")] + async fn optimism_fork_selection_preserves_disabled_fee_charging() { + let (_api, op) = spawn(NodeConfig::test().with_optimism()).await; + let mut opts = EvmOpts { fork_url: Some(op.http_endpoint()), ..Default::default() }; + opts.infer_network_from_fork().await.unwrap(); + let mut backend = Backend::::spawn(None).unwrap(); + let id = backend + .create_fork(crate::fork::CreateFork { + url: op.http_endpoint(), + enable_caching: false, + evm_opts: opts, + }) + .unwrap(); + let mut evm_env = EvmEnv::default(); + evm_env.cfg_env.disable_fee_charge = true; + backend + .select_fork(id, &mut evm_env, &mut Default::default(), &mut JournalInner::new()) + .unwrap(); + assert!(evm_env.cfg_env.disable_fee_charge); + } + + #[tokio::test(flavor = "multi_thread")] + #[cfg(all(feature = "optimism", feature = "monad"))] + async fn optimism_fork_selection_keeps_chain_context() { + let (_api, op) = spawn(NodeConfig::test().with_optimism()).await; + let mut opts = EvmOpts { fork_url: Some(op.http_endpoint()), ..Default::default() }; + opts.infer_network_from_fork().await.unwrap(); + let mut backend = Backend::::spawn(None).unwrap(); + let id = backend + .create_fork(crate::fork::CreateFork { + url: op.http_endpoint(), + enable_caching: false, + evm_opts: opts, + }) + .unwrap(); + let update = backend + .select_fork( + id, + &mut EvmEnv::default(), + &mut Default::default(), + &mut JournalInner::new(), + ) + .unwrap(); + assert!(matches!(update, ContextUpdate::Unchanged)); + } + + #[tokio::test(flavor = "multi_thread")] + async fn can_read_write_cache() { + let endpoint = &*foundry_test_utils::rpc::next_http_rpc_endpoint(); + let provider = get_http_provider(endpoint); + + let block_num = provider.get_block_number().await.unwrap(); + + let mut evm_opts = Config::figment().extract::().unwrap(); + evm_opts.fork_url = Some(endpoint.to_string()); + evm_opts.fork_block_number = Some(block_num); + + let backend = Backend::::spawn(evm_opts.get_fork( + &Config::default(), + NamedChain::Mainnet as u64, + None, + )) + .unwrap(); + let (evm_env, _) = backend.env(&evm_opts).await.unwrap(); + let fork = backend.fork().unwrap().unwrap(); + let fork_hash = fork.hash(); + let source_id = fork.source_id(); // some rng contract from etherscan - let address: Address = "63091244180ae240c87d1f528f5f269134cb07b3".parse().unwrap(); + let address = address!("0x63091244180ae240c87d1f528f5f269134cb07b3"); - let idx = U256::from(0u64); - let _value = backend.storage_ref(address, idx); + let num_slots = 5; let _account = backend.basic_ref(address); - - // fill some slots - let num_slots = 10u64; - for idx in 1..num_slots { + for idx in 0..num_slots { let _ = backend.storage_ref(address, U256::from(idx)); } drop(backend); - let meta = BlockchainDbMeta { - chain: None, - block_env: env.evm_env.block_env, - hosts: Default::default(), - }; + let meta = BlockchainDbMeta::new(evm_env.block_env, endpoint.to_string()) + .with_fork_identity(fork_hash, source_id); let db = BlockchainDb::new( meta, diff --git a/crates/evm/core/src/backend/snapshot.rs b/crates/evm/core/src/backend/snapshot.rs index 05329f0298005..f3c6cfee3d2ab 100644 --- a/crates/evm/core/src/backend/snapshot.rs +++ b/crates/evm/core/src/backend/snapshot.rs @@ -1,34 +1,56 @@ -use super::JournaledState; -use crate::Env; +use crate::backend::JournaledState; +use alloy_evm::EvmEnv; use alloy_primitives::{ B256, U256, - map::{AddressHashMap, HashMap}, + map::{AddressHashMap, U256Map}, }; use revm::state::AccountInfo; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Serialize, Serializer, ser::SerializeStruct}; +use std::collections::BTreeMap; /// A minimal abstraction of a state at a certain point in time -#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, Deserialize)] pub struct StateSnapshot { pub accounts: AddressHashMap, - pub storage: AddressHashMap>, - pub block_hashes: HashMap, + pub storage: AddressHashMap>, + pub block_hashes: U256Map, +} + +impl Serialize for StateSnapshot { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let accounts = self.accounts.iter().collect::>(); + let storage = self + .storage + .iter() + .map(|(address, storage)| (address, storage.iter().collect::>())) + .collect::>(); + let block_hashes = self.block_hashes.iter().collect::>(); + + let mut state = serializer.serialize_struct("StateSnapshot", 3)?; + state.serialize_field("accounts", &accounts)?; + state.serialize_field("storage", &storage)?; + state.serialize_field("block_hashes", &block_hashes)?; + state.end() + } } /// Represents a state snapshot taken during evm execution #[derive(Clone, Debug)] -pub struct BackendStateSnapshot { +pub struct BackendStateSnapshot { pub db: T, - /// The journaled_state state at a specific point + /// Complete context state at a specific point. pub journaled_state: JournaledState, - /// Contains the env at the time of the snapshot - pub env: Env, + /// Contains the evm env at the time of the snapshot + pub snap_evm_env: EvmEnv, } -impl BackendStateSnapshot { +impl BackendStateSnapshot { /// Takes a new state snapshot. - pub fn new(db: T, journaled_state: JournaledState, env: Env) -> Self { - Self { db, journaled_state, env } + pub const fn new(db: T, journaled_state: JournaledState, evm_env: EvmEnv) -> Self { + Self { db, journaled_state, snap_evm_env: evm_env } } /// Called when this state snapshot is reverted. @@ -57,7 +79,56 @@ pub enum RevertStateSnapshotAction { impl RevertStateSnapshotAction { /// Returns `true` if the action is to keep the state snapshot. - pub fn is_keep(&self) -> bool { + pub const fn is_keep(&self) -> bool { matches!(self, Self::RevertKeep) } } + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::Address; + + #[test] + fn state_snapshot_serializes_maps_in_key_order() { + let low_address = Address::with_last_byte(1); + let high_address = Address::with_last_byte(2); + let low_slot = U256::ONE; + let high_slot = U256::from(2); + let mut snapshot = StateSnapshot::default(); + + snapshot.accounts.insert(high_address, AccountInfo::from_balance(U256::from(2))); + snapshot.accounts.insert(low_address, AccountInfo::from_balance(U256::ONE)); + snapshot.storage.insert( + high_address, + [(high_slot, U256::from(200)), (low_slot, U256::from(100))].into_iter().collect(), + ); + snapshot.storage.insert( + low_address, + [(high_slot, U256::from(200)), (low_slot, U256::from(100))].into_iter().collect(), + ); + snapshot.block_hashes.insert(high_slot, B256::with_last_byte(200)); + snapshot.block_hashes.insert(low_slot, B256::with_last_byte(100)); + + let json = serde_json::to_string(&snapshot).unwrap(); + let storage_start = json.find("\"storage\"").unwrap(); + let block_hashes_start = json.find("\"block_hashes\"").unwrap(); + let accounts = &json[..storage_start]; + let storage = &json[storage_start..block_hashes_start]; + let block_hashes = &json[block_hashes_start..]; + let low_address = serde_json::to_string(&low_address).unwrap(); + let high_address = serde_json::to_string(&high_address).unwrap(); + let low_slot = serde_json::to_string(&low_slot).unwrap(); + let high_slot = serde_json::to_string(&high_slot).unwrap(); + + assert!(accounts.find(&low_address).unwrap() < accounts.find(&high_address).unwrap()); + let low_storage_start = storage.find(&low_address).unwrap(); + let high_storage_start = storage.find(&high_address).unwrap(); + assert!(low_storage_start < high_storage_start); + let low_storage = &storage[low_storage_start..high_storage_start]; + let high_storage = &storage[high_storage_start..]; + assert!(low_storage.find(&low_slot).unwrap() < low_storage.find(&high_slot).unwrap()); + assert!(high_storage.find(&low_slot).unwrap() < high_storage.find(&high_slot).unwrap()); + assert!(block_hashes.find(&low_slot).unwrap() < block_hashes.find(&high_slot).unwrap()); + } +} diff --git a/crates/evm/core/src/buffer.rs b/crates/evm/core/src/buffer.rs index e600708f43173..a08a23a16b1fd 100644 --- a/crates/evm/core/src/buffer.rs +++ b/crates/evm/core/src/buffer.rs @@ -2,7 +2,7 @@ use alloy_primitives::U256; use revm::bytecode::opcode; /// Used to keep track of which buffer is currently active to be drawn by the debugger. -#[derive(Debug, PartialEq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum BufferKind { Memory, Calldata, @@ -11,7 +11,7 @@ pub enum BufferKind { impl BufferKind { /// Helper to cycle through the active buffers. - pub fn next(&self) -> Self { + pub const fn next(&self) -> Self { match self { Self::Memory => Self::Calldata, Self::Calldata => Self::Returndata, @@ -83,17 +83,12 @@ pub fn get_buffer_accesses(op: u8, stack: &[U256]) -> Option { -2 => Some(1), -1 => Some(32), 0 => None, - 1.. => { - if (stack_index as usize) <= stack_len { - Some(stack[stack_len - stack_index as usize].saturating_to()) - } else { - None - } - } + 1.. => ((stack_index as usize) <= stack_len) + .then(|| stack[stack_len - stack_index as usize].saturating_to()), _ => panic!("invalid stack index"), }; - if buffer_access.0.is_some() || buffer_access.1.is_some() { + (buffer_access.0.is_some() || buffer_access.1.is_some()).then(|| { let (read, write) = buffer_access; let read_access = read.and_then(|b| { let (buffer, offset, len) = b; @@ -103,8 +98,6 @@ pub fn get_buffer_accesses(op: u8, stack: &[U256]) -> Option { let (offset, len) = b; Some(BufferAccess { offset: get_size(offset)?, len: get_size(len)? }) }); - Some(BufferAccesses { read: read_access, write: write_access }) - } else { - None - } + BufferAccesses { read: read_access, write: write_access } + }) } diff --git a/crates/evm/core/src/bytecode.rs b/crates/evm/core/src/bytecode.rs new file mode 100644 index 0000000000000..292c84ba5f60e --- /dev/null +++ b/crates/evm/core/src/bytecode.rs @@ -0,0 +1,247 @@ +use revm::bytecode::{OpCode, opcode}; +use std::{fmt, slice}; + +/// An iterator that yields opcodes and their immediate data. +/// +/// If the bytecode is not well-formed, the iterator will still yield opcodes, but the immediate +/// data may be incorrect. For example, if the bytecode is `PUSH2 0x69`, the iterator will yield +/// `PUSH2, &[]`. +#[derive(Clone, Debug)] +pub struct InstIter<'a> { + iter: slice::Iter<'a, u8>, +} + +impl fmt::Display for InstIter<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for (i, op) in self.clone().enumerate() { + if i > 0 { + f.write_str(" ")?; + } + write!(f, "{op}")?; + } + Ok(()) + } +} + +impl<'a> InstIter<'a> { + /// Create a new iterator over the given bytecode slice. + #[inline] + pub fn new(slice: &'a [u8]) -> Self { + Self { iter: slice.iter() } + } + + /// Returns a new iterator that also yields the program counter alongside the opcode and + /// immediate data. + #[inline] + pub const fn with_pc(self) -> InstIterWithPc<'a> { + InstIterWithPc { iter: self, pc: 0 } + } + + /// Returns the inner iterator. + #[inline] + pub const fn inner(&self) -> &slice::Iter<'a, u8> { + &self.iter + } + + /// Returns the inner iterator. + #[inline] + pub const fn inner_mut(&mut self) -> &mut slice::Iter<'a, u8> { + &mut self.iter + } + + /// Returns the inner iterator. + #[inline] + pub const fn into_inner(self) -> slice::Iter<'a, u8> { + self.iter + } +} + +impl<'a> Iterator for InstIter<'a> { + type Item = Inst<'a>; + + #[inline] + fn next(&mut self) -> Option { + self.iter.next().map(|&opcode| { + let opcode = OpCode::new_or_unknown(opcode); + let len = imm_len(opcode.get()) as usize; + let (immediate, rest) = self.iter.as_slice().split_at_checked(len).unwrap_or_default(); + self.iter = rest.iter(); + Inst { opcode, immediate } + }) + } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + let len = self.iter.len(); + ((len != 0) as usize, Some(len)) + } +} + +impl std::iter::FusedIterator for InstIter<'_> {} + +/// A bytecode iterator that yields opcodes and their immediate data, alongside the program counter. +/// +/// Created by calling [`InstIter::with_pc`]. +#[derive(Debug)] +pub struct InstIterWithPc<'a> { + iter: InstIter<'a>, + pc: usize, +} + +impl<'a> Iterator for InstIterWithPc<'a> { + type Item = (usize, Inst<'a>); + + #[inline] + fn next(&mut self) -> Option { + self.iter.next().map(|inst| { + let pc = self.pc; + self.pc += 1 + inst.immediate.len(); + (pc, inst) + }) + } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + self.iter.size_hint() + } +} + +impl std::iter::FusedIterator for InstIterWithPc<'_> {} + +/// An opcode and its immediate data. Returned by [`InstIter`]. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct Inst<'a> { + /// The opcode. + pub opcode: OpCode, + /// The immediate data, if any. + /// + /// If an opcode is missing immediate data, e.g. malformed or bytecode hash, this will be an + /// empty slice. + pub immediate: &'a [u8], +} + +impl fmt::Debug for Inst<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, f) + } +} + +impl fmt::Display for Inst<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.opcode)?; + match self.immediate { + [] => Ok(()), + imm => write!(f, " {:#x}", alloy_primitives::hex::display(imm)), + } + } +} + +/// Returns the length of the immediate data for the given opcode, or `0` if none. +#[inline] +const fn imm_len(op: u8) -> u8 { + match op { + opcode::PUSH1..=opcode::PUSH32 => op - opcode::PUSH0, + _ => 0, + } +} + +/// Returns a string representation of the given bytecode. +pub fn format_bytecode(bytecode: &[u8]) -> String { + let mut w = String::new(); + format_bytecode_to(bytecode, &mut w).unwrap(); + w +} + +/// Formats an EVM bytecode to the given writer. +pub fn format_bytecode_to(bytecode: &[u8], w: &mut W) -> fmt::Result { + write!(w, "{}", InstIter::new(bytecode)) +} + +#[cfg(test)] +mod tests { + use super::*; + use revm::bytecode::opcode as op; + + fn o(op: u8) -> OpCode { + OpCode::new_or_unknown(op) + } + + #[test] + fn iter_basic() { + let bytecode = [0x01, 0x02, 0x03, 0x04, 0x05]; + let mut iter = InstIter::new(&bytecode); + + assert_eq!(iter.next(), Some(Inst { opcode: o(0x01), immediate: &[] })); + assert_eq!(iter.next(), Some(Inst { opcode: o(0x02), immediate: &[] })); + assert_eq!(iter.next(), Some(Inst { opcode: o(0x03), immediate: &[] })); + assert_eq!(iter.next(), Some(Inst { opcode: o(0x04), immediate: &[] })); + assert_eq!(iter.next(), Some(Inst { opcode: o(0x05), immediate: &[] })); + assert_eq!(iter.next(), None); + } + + #[test] + fn iter_with_imm() { + let bytecode = [op::PUSH0, op::PUSH1, 0x69, op::PUSH2, 0x01, 0x02]; + let mut iter = InstIter::new(&bytecode); + + assert_eq!(iter.next(), Some(Inst { opcode: o(op::PUSH0), immediate: &[] })); + assert_eq!(iter.next(), Some(Inst { opcode: o(op::PUSH1), immediate: &[0x69] })); + assert_eq!(iter.next(), Some(Inst { opcode: o(op::PUSH2), immediate: &[0x01, 0x02] })); + assert_eq!(iter.next(), None); + } + + #[test] + fn iter_with_imm_too_short() { + let bytecode = [op::PUSH2, 0x69]; + let mut iter = InstIter::new(&bytecode); + + assert_eq!(iter.next(), Some(Inst { opcode: o(op::PUSH2), immediate: &[] })); + assert_eq!(iter.next(), None); + } + + #[test] + fn display() { + let bytecode = [op::PUSH0, op::PUSH1, 0x69, op::PUSH2, 0x01, 0x02]; + let s = format_bytecode(&bytecode); + assert_eq!(s, "PUSH0 PUSH1 0x69 PUSH2 0x0102"); + } + + #[test] + fn decode_push2_and_stop() { + // 0x61 0xAA 0xBB = PUSH2 0xAABB + // 0x00 = STOP + let code = vec![0x61, 0xAA, 0xBB, 0x00]; + let insns = InstIter::new(&code).with_pc().collect::>(); + + // PUSH2 then STOP + assert_eq!(insns.len(), 2); + + // PUSH2 at pc = 0 + let i0 = &insns[0]; + assert_eq!(i0.0, 0); + assert_eq!(i0.1.opcode, op::PUSH2); + assert_eq!(i0.1.immediate, &[0xAA, 0xBB]); + + // STOP at pc = 3 + let i1 = &insns[1]; + assert_eq!(i1.0, 3); + assert_eq!(i1.1.opcode, op::STOP); + assert!(i1.1.immediate.is_empty()); + } + + #[test] + fn decode_arithmetic_ops() { + // 0x01 = ADD, 0x02 = MUL, 0x03 = SUB, 0x04 = DIV + let code = vec![0x01, 0x02, 0x03, 0x04]; + let insns = InstIter::new(&code).with_pc().collect::>(); + + assert_eq!(insns.len(), 4); + + let expected = [(0, op::ADD), (1, op::MUL), (2, op::SUB), (3, op::DIV)]; + for ((pc, want_op), insn) in expected.iter().zip(insns.iter()) { + assert_eq!(insn.0, *pc); + assert_eq!(insn.1.opcode, *want_op); + assert!(insn.1.immediate.is_empty()); + } + } +} diff --git a/crates/evm/core/src/constants.rs b/crates/evm/core/src/constants.rs index c69098ea8d992..4d8cffa8569b7 100644 --- a/crates/evm/core/src/constants.rs +++ b/crates/evm/core/src/constants.rs @@ -1,5 +1,7 @@ use alloy_primitives::{Address, B256, address, b256, hex}; +pub use foundry_evm_networks::MONAD_CHEATCODE_ADDRESS; + /// The cheatcode handler address. /// /// This is the same address as the one used in DappTools's HEVM. @@ -17,7 +19,7 @@ pub const CHEATCODE_CONTRACT_HASH: B256 = /// The Hardhat console address. /// -/// See: +/// See: pub const HARDHAT_CONSOLE_ADDRESS: Address = address!("0x000000000000000000636F6e736F6c652e6c6f67"); /// Stores the caller address to be used as *sender* account for: @@ -29,7 +31,9 @@ pub const HARDHAT_CONSOLE_ADDRESS: Address = address!("0x000000000000000000636F6 pub const CALLER: Address = address!("0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38"); /// The default test contract address. -pub const TEST_CONTRACT_ADDRESS: Address = address!("0xb4c79daB8f259C7Aee6E5b2Aa729821864227e84"); +/// +/// Derived from `CALLER.create(1)`. +pub const TEST_CONTRACT_ADDRESS: Address = address!("0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496"); /// Magic return value returned by the `assume` cheatcode. pub const MAGIC_ASSUME: &[u8] = b"FOUNDRY::ASSUME"; @@ -37,15 +41,19 @@ pub const MAGIC_ASSUME: &[u8] = b"FOUNDRY::ASSUME"; /// Magic return value returned by the `skip` cheatcode. Optionally appended with a reason. pub const MAGIC_SKIP: &[u8] = b"FOUNDRY::SKIP"; -/// Test timeout return value. -pub const TEST_TIMEOUT: &str = "FOUNDRY::TEST_TIMEOUT"; - /// The address that deploys the default CREATE2 deployer contract. pub const DEFAULT_CREATE2_DEPLOYER_DEPLOYER: Address = address!("0x3fAB184622Dc19b6109349B94811493BF2a45362"); /// The default CREATE2 deployer. pub const DEFAULT_CREATE2_DEPLOYER: Address = address!("0x4e59b44847b379578588920ca78fbf26c0b4956c"); + +/// Sentinel bytecode for network-native precompiles that have no deployed code. +/// +/// The EIP-3541-reserved `0xEF` prefix makes `extcodesize` treat the account as non-empty while +/// execution remains handled by the precompile. +pub const SYSTEM_PRECOMPILE_STUB: &[u8] = &[0xEF]; + /// The initcode of the default CREATE2 deployer. pub const DEFAULT_CREATE2_DEPLOYER_CODE: &[u8] = &hex!( "604580600e600039806000f350fe7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe03601600081602082378035828234f58015156039578182fd5b8082525050506014600cf3" diff --git a/crates/evm/core/src/decode.rs b/crates/evm/core/src/decode.rs index a44adca2d08be..cae5f9dcf65a5 100644 --- a/crates/evm/core/src/decode.rs +++ b/crates/evm/core/src/decode.rs @@ -13,6 +13,12 @@ use itertools::Itertools; use revm::interpreter::InstructionResult; use std::{fmt, sync::OnceLock}; +/// Stable user-facing fallback for empty revert payloads. +pub const EMPTY_REVERT_DATA: &str = ""; + +/// Prefix used by Foundry assertion helpers in user-visible failure messages. +pub const ASSERTION_FAILED_PREFIX: &str = "assertion failed"; + /// A skip reason. #[derive(Clone, Debug, PartialEq, Eq)] pub struct SkipReason(pub Option); @@ -62,7 +68,7 @@ pub fn decode_console_log(log: &Log) -> Option { #[derive(Clone, Debug, Default)] pub struct RevertDecoder { /// The custom errors to use for decoding. - pub errors: HashMap>, + errors: HashMap>, } impl Default for &RevertDecoder { @@ -94,25 +100,15 @@ impl RevertDecoder { self } - /// Sets the ABI to use for error decoding, if it is present. - /// - /// Note that this is decently expensive as it will hash all errors for faster indexing. - pub fn with_abi_opt(mut self, abi: Option<&JsonAbi>) -> Self { - if let Some(abi) = abi { - self.extend_from_abi(abi); - } - self - } - /// Extends the decoder with the given ABI's custom errors. - pub fn extend_from_abis<'a>(&mut self, abi: impl IntoIterator) { + fn extend_from_abis<'a>(&mut self, abi: impl IntoIterator) { for abi in abi { self.extend_from_abi(abi); } } /// Extends the decoder with the given ABI's custom errors. - pub fn extend_from_abi(&mut self, abi: &JsonAbi) { + fn extend_from_abi(&mut self, abi: &JsonAbi) { for error in abi.errors() { self.push_error(error.clone()); } @@ -129,7 +125,7 @@ impl RevertDecoder { /// than user output. pub fn decode(&self, err: &[u8], status: Option) -> String { self.maybe_decode(err, status).unwrap_or_else(|| { - if err.is_empty() { "".to_string() } else { trimmed_hex(err) } + if err.is_empty() { EMPTY_REVERT_DATA.to_string() } else { trimmed_hex(err) } }) } @@ -137,10 +133,18 @@ impl RevertDecoder { /// /// See [`decode`](Self::decode) for more information. pub fn maybe_decode(&self, err: &[u8], status: Option) -> Option { - if let Some(reason) = SkipReason::decode(err) { - return Some(reason.to_string()); - } + self.maybe_decode_known(err) + .or_else(|| decode_as_non_empty_string(err)) + .or_else(|| Self::maybe_decode_fallback(err, status)) + } + /// Tries to decode the given revert bytes as one of the errors known to this decoder: + /// Solidity's `Error(string)` and `Panic(uint256)`, `Vm`'s custom errors, or custom errors + /// registered with this decoder. + /// + /// Unlike [`maybe_decode`](Self::maybe_decode), this returns `None` for unrecognized custom + /// errors instead of falling back to a generic `custom error ` representation. + pub fn maybe_decode_known(&self, err: &[u8]) -> Option { // Solidity's `Error(string)` (handled separately in order to strip revert: prefix) if let Some(ContractError(Revert(revert))) = RevertReason::decode(err) { return Some(revert.reason); @@ -151,29 +155,29 @@ impl RevertDecoder { return Some(e.to_string()); } - let string_decoded = decode_as_non_empty_string(err); - - if let Some((selector, data)) = err.split_first_chunk::() { - // Custom errors. - if let Some(errors) = self.errors.get(selector) { - for error in errors { - // If we don't decode, don't return an error, try to decode as a string - // later. - if let Ok(decoded) = error.abi_decode_input(data) { - return Some(format!( - "{}({})", - error.name, - decoded.iter().map(foundry_common::fmt::format_token).format(", ") - )); - } + // Custom errors. + if let Some((selector, data)) = err.split_first_chunk::() + && let Some(errors) = self.errors.get(selector) + { + for error in errors { + // If we don't decode, don't return an error, try to decode as a string later. + if let Ok(decoded) = error.abi_decode_input(data) { + return Some(format!( + "{}({})", + error.name, + decoded.iter().map(foundry_common::fmt::format_token).format(", ") + )); } } + } - if string_decoded.is_some() { - return string_decoded; - } + None + } - // Generic custom error. + /// Formats revert bytes that could not be decoded as a known error. + fn maybe_decode_fallback(err: &[u8], status: Option) -> Option { + // Generic custom error. + if let Some((selector, data)) = err.split_first_chunk::() { return Some({ let mut s = format!("custom error {}", hex::encode_prefixed(selector)); if !data.is_empty() { @@ -187,10 +191,6 @@ impl RevertDecoder { }); } - if string_decoded.is_some() { - return string_decoded; - } - if let Some(status) = status && !status.is_ok() { @@ -231,8 +231,8 @@ fn trimmed_hex(s: &[u8]) -> String { } else { format!( "{}…{} ({} bytes)", - &hex::encode(&s[..n / 2]), - &hex::encode(&s[s.len() - n / 2..]), + hex::encode(&s[..n / 2]), + hex::encode(&s[s.len() - n / 2..]), s.len(), ) } @@ -258,7 +258,7 @@ mod tests { error ValidationFailed(bytes); error InvalidNonce(); */ - let mut decoder = RevertDecoder::default(); + let mut decoder = RevertDecoder::new(); decoder.push_error("ValidationFailed(bytes)".parse().unwrap()); /* @@ -284,4 +284,22 @@ mod tests { ); assert_eq!(decoder.decode(data, None), "ValidationFailed(0x756688fe)"); } + + #[test] + fn maybe_decode_magic_skip_is_not_skip_marker() { + let decoder = RevertDecoder::new(); + let reason = decoder.maybe_decode(crate::constants::MAGIC_SKIP, None).unwrap(); + + assert_eq!(reason, "FOUNDRY::SKIP"); + assert!(SkipReason::decode_self(&reason).is_none()); + } + + #[test] + fn plain_string_is_not_a_known_error() { + let decoder = RevertDecoder::new(); + let data = b".,Bo"; + + assert_eq!(decoder.maybe_decode_known(data), None); + assert_eq!(decoder.maybe_decode(data, None).as_deref(), Some(".,Bo")); + } } diff --git a/crates/evm/core/src/eip2935.rs b/crates/evm/core/src/eip2935.rs new file mode 100644 index 0000000000000..e95b45822b632 --- /dev/null +++ b/crates/evm/core/src/eip2935.rs @@ -0,0 +1,37 @@ +//! EIP-2935 history storage helpers. + +use alloy_primitives::{Address, B256, U256}; + +pub use alloy_eips::eip2935::{ + HISTORY_SERVE_WINDOW, HISTORY_STORAGE_ADDRESS, HISTORY_STORAGE_CODE, +}; + +/// Returns whether `address` is the EIP-2935 history storage contract. +#[inline] +pub fn is_history_storage_address(address: &Address) -> bool { + *address == HISTORY_STORAGE_ADDRESS +} + +/// Returns the history storage ring slot for `block_number`. +#[inline] +pub fn history_storage_slot(block_number: U256) -> U256 { + block_number % U256::from(HISTORY_SERVE_WINDOW) +} + +/// Encodes a block hash as the history contract storage value. +#[inline] +pub const fn history_storage_value(block_hash: B256) -> U256 { + U256::from_be_bytes(block_hash.0) +} + +/// Returns the first block in the valid EIP-2935 window for `current_block`. +#[inline] +pub fn history_window_start(current_block: U256) -> U256 { + current_block.saturating_sub(U256::from(HISTORY_SERVE_WINDOW)) +} + +/// Returns the first block to backfill when rolling forward from `old_block` to `new_block`. +#[inline] +pub fn forward_fill_start(old_block: U256, new_block: U256) -> U256 { + old_block.max(history_window_start(new_block)) +} diff --git a/crates/evm/core/src/either_evm.rs b/crates/evm/core/src/either_evm.rs deleted file mode 100644 index 785a73b8d4354..0000000000000 --- a/crates/evm/core/src/either_evm.rs +++ /dev/null @@ -1,276 +0,0 @@ -use alloy_evm::{Database, EthEvm, Evm, EvmEnv, eth::EthEvmContext}; -use alloy_op_evm::OpEvm; -use alloy_primitives::{Address, Bytes}; -use op_revm::{OpContext, OpHaltReason, OpSpecId, OpTransaction, OpTransactionError}; -use revm::{ - DatabaseCommit, Inspector, - context::{ - BlockEnv, TxEnv, - result::{EVMError, ExecResultAndState, ExecutionResult, ResultAndState}, - }, - handler::PrecompileProvider, - interpreter::InterpreterResult, - primitives::hardfork::SpecId, -}; - -/// Alias for result type returned by [`Evm::transact`] methods. -type EitherEvmResult = - Result, EVMError>; - -/// Alias for result type returned by [`Evm::transact_commit`] methods. -type EitherExecResult = - Result, EVMError>; - -/// [`EitherEvm`] delegates its calls to one of the two evm implementations; either [`EthEvm`] or -/// [`OpEvm`]. -/// -/// Calls are delegated to [`OpEvm`] only if optimism is enabled. -/// -/// The call delegation is handled via its own implementation of the [`Evm`] trait. -/// -/// The [`Evm::transact`] and other such calls work over the [`OpTransaction`] type. -/// -/// However, the [`Evm::HaltReason`] and [`Evm::Error`] leverage the optimism [`OpHaltReason`] and -/// [`OpTransactionError`] as these are supersets of the eth types. This makes it easier to map eth -/// types to op types and also prevents ignoring of any error that maybe thrown by [`OpEvm`]. -#[allow(clippy::large_enum_variant)] -pub enum EitherEvm -where - DB: Database, -{ - /// [`EthEvm`] implementation. - Eth(EthEvm), - /// [`OpEvm`] implementation. - Op(OpEvm), -} - -impl EitherEvm -where - DB: Database, - I: Inspector> + Inspector>, - P: PrecompileProvider, Output = InterpreterResult> - + PrecompileProvider, Output = InterpreterResult>, -{ - /// Converts the [`EthEvm::transact`] result to [`EitherEvmResult`]. - fn map_eth_result( - &self, - result: Result, EVMError>, - ) -> EitherEvmResult { - match result { - Ok(result) => Ok(ResultAndState { - result: result.result.map_haltreason(OpHaltReason::Base), - state: result.state, - }), - Err(e) => Err(self.map_eth_err(e)), - } - } - - /// Converts the [`EthEvm::transact_commit`] result to [`EitherExecResult`]. - fn map_exec_result( - &self, - result: Result>, - ) -> EitherExecResult { - match result { - Ok(result) => { - // Map the halt reason - Ok(result.map_haltreason(OpHaltReason::Base)) - } - Err(e) => Err(self.map_eth_err(e)), - } - } - - /// Maps [`EVMError`] to [`EVMError`]. - fn map_eth_err(&self, err: EVMError) -> EVMError { - match err { - EVMError::Transaction(invalid_tx) => { - EVMError::Transaction(OpTransactionError::Base(invalid_tx)) - } - EVMError::Database(e) => EVMError::Database(e), - EVMError::Header(e) => EVMError::Header(e), - EVMError::Custom(e) => EVMError::Custom(e), - } - } -} - -impl Evm for EitherEvm -where - DB: Database, - I: Inspector> + Inspector>, - P: PrecompileProvider, Output = InterpreterResult> - + PrecompileProvider, Output = InterpreterResult>, -{ - type DB = DB; - type Error = EVMError; - type HaltReason = OpHaltReason; - type Tx = OpTransaction; - type Inspector = I; - type Precompiles = P; - type Spec = SpecId; - - fn chain_id(&self) -> u64 { - match self { - Self::Eth(evm) => evm.chain_id(), - Self::Op(evm) => evm.chain_id(), - } - } - - fn block(&self) -> &BlockEnv { - match self { - Self::Eth(evm) => evm.block(), - Self::Op(evm) => evm.block(), - } - } - - fn db_mut(&mut self) -> &mut Self::DB { - match self { - Self::Eth(evm) => evm.db_mut(), - Self::Op(evm) => evm.db_mut(), - } - } - - fn into_db(self) -> Self::DB - where - Self: Sized, - { - match self { - Self::Eth(evm) => evm.into_db(), - Self::Op(evm) => evm.into_db(), - } - } - - fn finish(self) -> (Self::DB, EvmEnv) - where - Self: Sized, - { - match self { - Self::Eth(evm) => evm.finish(), - Self::Op(evm) => { - let (db, env) = evm.finish(); - (db, map_env(env)) - } - } - } - - fn precompiles(&self) -> &Self::Precompiles { - match self { - Self::Eth(evm) => evm.precompiles(), - Self::Op(evm) => evm.precompiles(), - } - } - - fn precompiles_mut(&mut self) -> &mut Self::Precompiles { - match self { - Self::Eth(evm) => evm.precompiles_mut(), - Self::Op(evm) => evm.precompiles_mut(), - } - } - - fn inspector(&self) -> &Self::Inspector { - match self { - Self::Eth(evm) => evm.inspector(), - Self::Op(evm) => evm.inspector(), - } - } - - fn inspector_mut(&mut self) -> &mut Self::Inspector { - match self { - Self::Eth(evm) => evm.inspector_mut(), - Self::Op(evm) => evm.inspector_mut(), - } - } - - fn enable_inspector(&mut self) { - match self { - Self::Eth(evm) => evm.enable_inspector(), - Self::Op(evm) => evm.enable_inspector(), - } - } - - fn disable_inspector(&mut self) { - match self { - Self::Eth(evm) => evm.disable_inspector(), - Self::Op(evm) => evm.disable_inspector(), - } - } - - fn set_inspector_enabled(&mut self, enabled: bool) { - match self { - Self::Eth(evm) => evm.set_inspector_enabled(enabled), - Self::Op(evm) => evm.set_inspector_enabled(enabled), - } - } - - fn into_env(self) -> EvmEnv - where - Self: Sized, - { - match self { - Self::Eth(evm) => evm.into_env(), - Self::Op(evm) => map_env(evm.into_env()), - } - } - - fn transact( - &mut self, - tx: impl alloy_evm::IntoTxEnv, - ) -> Result, Self::Error> { - match self { - Self::Eth(evm) => { - let eth = evm.transact(tx.into_tx_env().base); - self.map_eth_result(eth) - } - Self::Op(evm) => evm.transact(tx), - } - } - - fn transact_commit( - &mut self, - tx: impl alloy_evm::IntoTxEnv, - ) -> Result, Self::Error> - where - Self::DB: DatabaseCommit, - { - match self { - Self::Eth(evm) => { - let eth = evm.transact_commit(tx.into_tx_env().base); - self.map_exec_result(eth) - } - Self::Op(evm) => evm.transact_commit(tx), - } - } - - fn transact_raw( - &mut self, - tx: Self::Tx, - ) -> Result, Self::Error> { - match self { - Self::Eth(evm) => { - let res = evm.transact_raw(tx.base); - self.map_eth_result(res) - } - Self::Op(evm) => evm.transact_raw(tx), - } - } - - fn transact_system_call( - &mut self, - caller: Address, - contract: Address, - data: Bytes, - ) -> Result, Self::Error> { - match self { - Self::Eth(evm) => { - let eth = evm.transact_system_call(caller, contract, data); - self.map_eth_result(eth) - } - Self::Op(evm) => evm.transact_system_call(caller, contract, data), - } - } -} - -/// Maps [`EvmEnv`] to [`EvmEnv`]. -fn map_env(env: EvmEnv) -> EvmEnv { - let eth_spec_id = env.spec_id().into_eth_spec(); - let cfg = env.cfg_env.with_spec(eth_spec_id); - EvmEnv { cfg_env: cfg, block_env: env.block_env } -} diff --git a/crates/evm/core/src/env.rs b/crates/evm/core/src/env.rs deleted file mode 100644 index 000f3ad06b0ed..0000000000000 --- a/crates/evm/core/src/env.rs +++ /dev/null @@ -1,103 +0,0 @@ -pub use alloy_evm::EvmEnv; -use revm::{ - Context, Database, Journal, JournalEntry, - context::{BlockEnv, CfgEnv, JournalInner, JournalTr, TxEnv}, - primitives::hardfork::SpecId, -}; - -/// Helper container type for [`EvmEnv`] and [`TxEnv`]. -#[derive(Clone, Debug, Default)] -pub struct Env { - pub evm_env: EvmEnv, - pub tx: TxEnv, -} - -/// Helper container type for [`EvmEnv`] and [`TxEnv`]. -impl Env { - pub fn default_with_spec_id(spec_id: SpecId) -> Self { - let mut cfg = CfgEnv::default(); - cfg.spec = spec_id; - - Self::from(cfg, BlockEnv::default(), TxEnv::default()) - } - - pub fn from(cfg: CfgEnv, block: BlockEnv, tx: TxEnv) -> Self { - Self { evm_env: EvmEnv { cfg_env: cfg, block_env: block }, tx } - } - - pub fn new_with_spec_id(cfg: CfgEnv, block: BlockEnv, tx: TxEnv, spec_id: SpecId) -> Self { - let mut cfg = cfg; - cfg.spec = spec_id; - - Self::from(cfg, block, tx) - } -} - -/// Helper struct with mutable references to the block and cfg environments. -pub struct EnvMut<'a> { - pub block: &'a mut BlockEnv, - pub cfg: &'a mut CfgEnv, - pub tx: &'a mut TxEnv, -} - -impl EnvMut<'_> { - /// Returns a copy of the environment. - pub fn to_owned(&self) -> Env { - Env { - evm_env: EvmEnv { cfg_env: self.cfg.to_owned(), block_env: self.block.to_owned() }, - tx: self.tx.to_owned(), - } - } -} - -pub trait AsEnvMut { - fn as_env_mut(&mut self) -> EnvMut<'_>; -} - -impl AsEnvMut for EnvMut<'_> { - fn as_env_mut(&mut self) -> EnvMut<'_> { - EnvMut { block: self.block, cfg: self.cfg, tx: self.tx } - } -} - -impl AsEnvMut for Env { - fn as_env_mut(&mut self) -> EnvMut<'_> { - EnvMut { - block: &mut self.evm_env.block_env, - cfg: &mut self.evm_env.cfg_env, - tx: &mut self.tx, - } - } -} - -impl, C> AsEnvMut - for Context -{ - fn as_env_mut(&mut self) -> EnvMut<'_> { - EnvMut { block: &mut self.block, cfg: &mut self.cfg, tx: &mut self.tx } - } -} - -pub trait ContextExt { - type DB: Database; - - fn as_db_env_and_journal( - &mut self, - ) -> (&mut Self::DB, &mut JournalInner, EnvMut<'_>); -} - -impl ContextExt - for Context, C> -{ - type DB = DB; - - fn as_db_env_and_journal( - &mut self, - ) -> (&mut Self::DB, &mut JournalInner, EnvMut<'_>) { - ( - &mut self.journaled_state.database, - &mut self.journaled_state.inner, - EnvMut { block: &mut self.block, cfg: &mut self.cfg, tx: &mut self.tx }, - ) - } -} diff --git a/crates/evm/core/src/env/block.rs b/crates/evm/core/src/env/block.rs new file mode 100644 index 0000000000000..4c462c2e8278d --- /dev/null +++ b/crates/evm/core/src/env/block.rs @@ -0,0 +1,140 @@ +//! Mutable block environments. + +use alloy_primitives::{Address, B256, U256}; +use revm::context::{Block, BlockEnv}; +use tempo_revm::TempoBlockEnv; + +/// Extension of [`Block`] with mutable setters, allowing EVM-agnostic mutation of block fields. +pub trait FoundryBlock: Block { + /// Sets the block number. + fn set_number(&mut self, number: U256); + + /// Sets the slot number. + fn set_slot_num(&mut self, slot_num: u64); + + /// Sets the beneficiary (coinbase) address. + fn set_beneficiary(&mut self, beneficiary: Address); + + /// Sets the block timestamp. + fn set_timestamp(&mut self, timestamp: U256); + + /// Sets the gas limit. + fn set_gas_limit(&mut self, gas_limit: u64); + + /// Sets the base fee per gas. + fn set_basefee(&mut self, basefee: u64); + + /// Sets the block difficulty. + fn set_difficulty(&mut self, difficulty: U256); + + /// Sets the prevrandao value. + fn set_prevrandao(&mut self, prevrandao: Option); + + /// Sets the excess blob gas and blob gasprice. + fn set_blob_excess_gas_and_price( + &mut self, + _excess_blob_gas: u64, + _base_fee_update_fraction: u64, + ); + + // Tempo methods + + /// Returns the milliseconds portion of the block timestamp. + fn timestamp_millis_part(&self) -> u64 { + 0 + } + + /// Sets the milliseconds portion of the block timestamp. + fn set_timestamp_millis_part(&mut self, _millis: u64) {} +} + +impl FoundryBlock for BlockEnv { + fn set_number(&mut self, number: U256) { + self.number = number; + } + + fn set_slot_num(&mut self, slot_num: u64) { + self.slot_num = slot_num; + } + + fn set_beneficiary(&mut self, beneficiary: Address) { + self.beneficiary = beneficiary; + } + + fn set_timestamp(&mut self, timestamp: U256) { + self.timestamp = timestamp; + } + + fn set_gas_limit(&mut self, gas_limit: u64) { + self.gas_limit = gas_limit; + } + + fn set_basefee(&mut self, basefee: u64) { + self.basefee = basefee; + } + + fn set_difficulty(&mut self, difficulty: U256) { + self.difficulty = difficulty; + } + + fn set_prevrandao(&mut self, prevrandao: Option) { + self.prevrandao = prevrandao; + } + + fn set_blob_excess_gas_and_price( + &mut self, + excess_blob_gas: u64, + base_fee_update_fraction: u64, + ) { + self.set_blob_excess_gas_and_price(excess_blob_gas, base_fee_update_fraction); + } +} + +impl FoundryBlock for TempoBlockEnv { + fn set_number(&mut self, number: U256) { + self.inner.set_number(number); + } + + fn set_slot_num(&mut self, slot_num: u64) { + self.inner.set_slot_num(slot_num); + } + + fn set_beneficiary(&mut self, beneficiary: Address) { + self.inner.set_beneficiary(beneficiary); + } + + fn set_timestamp(&mut self, timestamp: U256) { + self.inner.set_timestamp(timestamp); + } + + fn set_gas_limit(&mut self, gas_limit: u64) { + self.inner.set_gas_limit(gas_limit); + } + + fn set_basefee(&mut self, basefee: u64) { + self.inner.set_basefee(basefee); + } + + fn set_difficulty(&mut self, difficulty: U256) { + self.inner.set_difficulty(difficulty); + } + + fn set_prevrandao(&mut self, prevrandao: Option) { + self.inner.set_prevrandao(prevrandao); + } + + fn set_blob_excess_gas_and_price( + &mut self, + _excess_blob_gas: u64, + _base_fee_update_fraction: u64, + ) { + } + + fn timestamp_millis_part(&self) -> u64 { + self.timestamp_millis_part + } + + fn set_timestamp_millis_part(&mut self, millis: u64) { + self.timestamp_millis_part = millis; + } +} diff --git a/crates/evm/core/src/env/cfg.rs b/crates/evm/core/src/env/cfg.rs new file mode 100644 index 0000000000000..694de65c391c0 --- /dev/null +++ b/crates/evm/core/src/env/cfg.rs @@ -0,0 +1,80 @@ +//! Configuration access and hardfork gas parameters. + +use revm::{ + context::{Cfg, CfgEnv}, + primitives::hardfork::SpecId, +}; +use std::fmt::Debug; + +/// Access to a configuration's underlying environment and hardfork updates. +pub trait FoundryCfg: + Cfg + Copy + Debug> + Clone + From> + Into> +{ + /// Reference to the underlying configuration. + fn cfg_env(&self) -> &CfgEnv; + + /// Mutable reference to the underlying configuration. + fn cfg_env_mut(&mut self) -> &mut CfgEnv; + + /// Updates the hardfork and its gas parameters. + fn set_spec_and_gas_params(&mut self, spec: Self::Spec) { + self.cfg_env_mut().set_spec_and_mainnet_gas_params(spec); + } +} + +impl + Copy + Debug> FoundryCfg for CfgEnv { + fn cfg_env(&self) -> &Self { + self + } + + fn cfg_env_mut(&mut self) -> &mut Self { + self + } +} + +#[cfg(feature = "monad")] +impl FoundryCfg for monad_revm::MonadCfgEnv { + fn cfg_env(&self) -> &CfgEnv { + self.inner() + } + + fn cfg_env_mut(&mut self) -> &mut CfgEnv { + self.inner_mut() + } + + fn set_spec_and_gas_params(&mut self, spec: Self::Spec) { + self.inner_mut().spec = spec; + self.inner_mut().set_gas_params(monad_revm::instructions::monad_gas_params(spec)); + } +} + +#[cfg(all(test, feature = "monad"))] +mod tests { + use super::*; + use crate::env::{EvmEnv, FoundryContextExt}; + use alloy_evm::EvmFactory; + use monad_revm::{MonadHardfork, cfg::MONAD_MEMORY_LIMIT, instructions::monad_gas_params}; + use revm::{context::BlockEnv, context_interface::ContextTr, database::EmptyDB}; + + #[test] + fn monad_memory_limit_follows_hardfork_transitions() { + const FOUNDRY_MEMORY_LIMIT: u64 = 128 * 1024 * 1024; + + let mut cfg = CfgEnv::new_with_spec(MonadHardfork::MonadEight); + cfg.memory_limit = FOUNDRY_MEMORY_LIMIT; + let mut evm = alloy_monad_evm::MonadEvmFactory::default() + .create_evm(EmptyDB::default(), EvmEnv::new(cfg, BlockEnv::default())); + + assert_eq!(evm.ctx().cfg().memory_limit(), FOUNDRY_MEMORY_LIMIT); + + for (spec, memory_limit) in [ + (MonadHardfork::MonadNine, MONAD_MEMORY_LIMIT), + (MonadHardfork::MonadEight, FOUNDRY_MEMORY_LIMIT), + ] { + evm.ctx_mut().set_spec_and_gas_params(spec); + assert_eq!(evm.ctx().cfg().inner().memory_limit, FOUNDRY_MEMORY_LIMIT); + assert_eq!(evm.ctx().cfg().memory_limit(), memory_limit); + assert_eq!(evm.ctx().cfg().inner().gas_params, monad_gas_params(spec)); + } + } +} diff --git a/crates/evm/core/src/env/chain.rs b/crates/evm/core/src/env/chain.rs new file mode 100644 index 0000000000000..91512f8544812 --- /dev/null +++ b/crates/evm/core/src/env/chain.rs @@ -0,0 +1,35 @@ +//! Chain context construction and lifecycle operations. + +use std::fmt::Debug; + +#[cfg(feature = "monad")] +use super::FoundryJournal; + +/// Foundry extension for chain context type +/// +/// Every family that doesn't need chain metadata uses `()`. +pub trait FoundryChain: Clone + Debug + Default + Send + Sync { + /// Builds chain context for a standalone synthetic transaction. + fn for_transaction(_tx: &Tx) -> Self { + Self::default() + } + + /// Builds chain context for a transaction at an exact block position. + fn for_block( + _grandparent: &[Tx], + _parent: &[Tx], + _current: &[Tx], + _current_tx_index: usize, + ) -> Self { + Self::default() + } + + /// Refreshes journal state derived from the active chain position. + #[cfg(feature = "monad")] + fn refresh_journal(&self, _journal: &mut J) {} + + /// Clears cached protocol fees after a synthetic transaction restores chain context. + fn clear_transaction_fee_cache(&mut self) {} +} + +impl FoundryChain for () {} diff --git a/crates/evm/core/src/env/context.rs b/crates/evm/core/src/env/context.rs new file mode 100644 index 0000000000000..68a2c951566c2 --- /dev/null +++ b/crates/evm/core/src/env/context.rs @@ -0,0 +1,217 @@ +//! Mutable execution context access. + +use super::{EvmEnv, FoundryBlock, FoundryCfg, FoundryChain, FoundryJournal, FoundryTransaction}; +use crate::backend::JournaledState; +use revm::{ + Context, Database, + context::{Cfg, CfgEnv}, + context_interface::ContextTr, + primitives::hardfork::SpecId, +}; +use std::fmt::Debug; + +/// Extension trait providing mutable field access to block, tx, and cfg environments. +/// +/// [`ContextTr`] only exposes immutable references for block, tx, and cfg. +/// Cheatcodes like `vm.warp()`, `vm.roll()`, `vm.chainId()` need to mutate these fields. +pub trait FoundryContextExt: + ContextTr< + Block: FoundryBlock + Clone, + Tx: FoundryTransaction + Clone, + Cfg: FoundryCfg, + Journal: FoundryJournal, + Chain: FoundryChain, + > +{ + /// Specification id type + /// + /// Bubbled-up from `ContextTr::Cfg` for convenience and simplified bounds. + type Spec: Into + Copy + Debug; + + /// Mutable reference to the block environment. + fn block_mut(&mut self) -> &mut Self::Block; + + /// Mutable reference to the transaction environment. + fn tx_mut(&mut self) -> &mut Self::Tx; + + /// Mutable reference to the configuration environment. + fn cfg_mut(&mut self) -> &mut Self::Cfg; + + /// Reference to the underlying [`CfgEnv`]. + fn cfg_env(&self) -> &CfgEnv { + self.cfg().cfg_env() + } + + /// Mutable reference to the underlying [`CfgEnv`]. + fn cfg_env_mut(&mut self) -> &mut CfgEnv { + self.cfg_mut().cfg_env_mut() + } + + /// Mutable reference to the db and the journal inner. + fn db_journal_inner_mut(&mut self) -> (&mut Self::Db, &mut JournaledState) { + self.journal_mut().db_journal_inner_mut() + } + + /// Reference to the journal inner. + fn journal_inner(&self) -> &JournaledState { + self.journal().journal_inner() + } + + /// Sets the spec and refreshes gas params for the concrete EVM family. + fn set_spec_and_gas_params(&mut self, spec: Self::Spec) { + self.cfg_mut().set_spec_and_gas_params(spec); + } + + /// Sets block environment. + fn set_block(&mut self, block: Self::Block) { + *self.block_mut() = block; + } + + /// Sets transaction environment. + fn set_tx(&mut self, tx: Self::Tx) { + *self.tx_mut() = tx; + } + + /// Sets configuration environment. + fn set_cfg(&mut self, cfg: Self::Cfg) { + *self.cfg_mut() = cfg; + } + + /// Sets journal inner. + fn set_journal_inner(&mut self, journal_inner: JournaledState) { + *self.db_journal_inner_mut().1 = journal_inner; + } + + /// Sets EVM environment. + fn set_evm(&mut self, evm_env: EvmEnv) { + *self.cfg_mut() = evm_env.cfg_env.into(); + *self.block_mut() = evm_env.block_env; + } + + /// Cloned transaction environment. + fn tx_clone(&self) -> Self::Tx { + self.tx().clone() + } + + /// Cloned EVM environment (Cfg + Block). + fn evm_clone(&self) -> EvmEnv { + EvmEnv::new(self.cfg().clone().into(), self.block().clone()) + } +} + +impl< + BLOCK: FoundryBlock + Clone, + TX: FoundryTransaction + Clone, + CFG: FoundryCfg, + DB: Database, + J: FoundryJournal, + C: FoundryChain, +> FoundryContextExt for Context +{ + type Spec = ::Spec; + + fn block_mut(&mut self) -> &mut Self::Block { + &mut self.block + } + + fn tx_mut(&mut self) -> &mut Self::Tx { + &mut self.tx + } + + fn cfg_mut(&mut self) -> &mut Self::Cfg { + &mut self.cfg + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_evm::{EthEvmFactory, EvmFactory}; + use alloy_primitives::U256; + use foundry_evm_hardforks::TempoHardfork; + use revm::{ + context::{Block, Transaction}, + database::EmptyDB, + }; + use tempo_evm::TempoEvmFactory; + + #[cfg(feature = "base")] + use base_common_evm::{BaseEvmFactory, BaseSpecId, BaseUpgrade}; + + #[test] + fn eth_evm_foundry_context_ext_implementation() { + let mut evm = EthEvmFactory::default().create_evm(EmptyDB::default(), EvmEnv::default()); + assert_context_mutation(evm.ctx_mut(), SpecId::AMSTERDAM); + } + + #[cfg(feature = "base")] + #[test] + fn base_evm_foundry_context_ext_implementation() { + let mut evm = BaseEvmFactory::default().create_evm(EmptyDB::default(), EvmEnv::default()); + assert_context_mutation(evm.ctx_mut(), BaseSpecId::new(BaseUpgrade::Beryl)); + } + + #[cfg(feature = "monad")] + #[test] + fn monad_evm_foundry_context_ext_implementation() { + let mut evm = alloy_monad_evm::MonadEvmFactory::default().create_evm( + EmptyDB::default(), + EvmEnv::new( + CfgEnv::new_with_spec(monad_revm::MonadHardfork::MonadNine), + Default::default(), + ), + ); + assert_context_mutation(evm.ctx_mut(), monad_revm::MonadHardfork::MonadEight); + evm.ctx_mut().journal_mut().set_preserve_reserve_balance(true); + let mut inner = evm.ctx().journal_inner().clone(); + inner.depth = 2; + evm.ctx_mut().set_journal_inner(inner); + assert_eq!(evm.ctx().journal_inner().depth, 2); + assert!(evm.ctx().journal().preserves_reserve_balance()); + } + + #[test] + fn tempo_evm_foundry_context_ext_implementation() { + let mut evm = TempoEvmFactory::default().create_evm(EmptyDB::default(), EvmEnv::default()); + assert_context_mutation(evm.ctx_mut(), TempoHardfork::Genesis); + } + + #[cfg(feature = "optimism")] + mod optimism { + use super::*; + use alloy_op_evm::{OpEvmFactory, OpTx}; + use op_revm::OpSpecId; + + #[test] + fn op_evm_foundry_context_ext_implementation() { + let mut evm = + OpEvmFactory::::default().create_evm(EmptyDB::default(), EvmEnv::default()); + assert_context_mutation(evm.ctx_mut(), OpSpecId::JOVIAN); + } + } + + fn assert_context_mutation(ctx: &mut CTX, spec: CTX::Spec) + where + CTX::Spec: PartialEq, + { + ctx.block_mut().set_number(U256::from(123)); + assert_eq!(ctx.block().number(), U256::from(123)); + ctx.tx_mut().set_nonce(99); + assert_eq!(ctx.tx().nonce(), 99); + ctx.cfg_env_mut().spec = spec; + assert_eq!(ctx.cfg().spec(), spec); + + let tx = ctx.tx_clone(); + ctx.tx_mut().set_nonce(0); + ctx.set_tx(tx); + assert_eq!(ctx.tx().nonce(), 99); + ctx.cfg_env_mut().chain_id = 42; + let env = ctx.evm_clone(); + ctx.block_mut().set_number(U256::ZERO); + ctx.cfg_env_mut().chain_id = 43; + ctx.set_evm(env); + assert_eq!(ctx.block().number(), U256::from(123)); + assert_eq!(ctx.cfg().spec(), spec); + assert_eq!(ctx.cfg().chain_id(), 42); + } +} diff --git a/crates/evm/core/src/env/journal.rs b/crates/evm/core/src/env/journal.rs new file mode 100644 index 0000000000000..baf345183e707 --- /dev/null +++ b/crates/evm/core/src/env/journal.rs @@ -0,0 +1,83 @@ +//! Access to execution journals and their native state. + +use crate::backend::JournaledState; +use revm::{Database, Journal, inspector::JournalExt}; + +/// Foundry extension for Journal type +pub trait FoundryJournal: JournalExt { + /// Mutable access to the database and journal inner. + fn db_journal_inner_mut(&mut self) -> (&mut Self::Database, &mut JournaledState); + + /// Reference to the journal inner. + fn journal_inner(&self) -> &JournaledState; + + /// Captures Monad's reserve-balance tracker for the active transaction. + #[cfg(feature = "monad")] + fn capture_reserve_balance( + &self, + ) -> monad_revm::reserve_balance::tracker::ReserveBalanceTracker { + monad_revm::reserve_balance::tracker::ReserveBalanceTracker::default() + } + + /// Restores Monad's reserve-balance tracker for the active transaction. + #[cfg(feature = "monad")] + fn restore_reserve_balance( + &mut self, + _tracker: monad_revm::reserve_balance::tracker::ReserveBalanceTracker, + ) { + } + + /// Whether transaction boundaries currently preserve the reserve-balance tracker, e.g. for + /// an isolated call that models an inner call of the enclosing transaction rather than a + /// new one. + #[cfg(feature = "monad")] + fn preserves_reserve_balance(&self) -> bool { + false + } + + /// Sets whether transaction boundaries preserve the reserve-balance tracker. + #[cfg(feature = "monad")] + fn set_preserve_reserve_balance(&mut self, _preserve: bool) {} +} + +impl FoundryJournal for Journal { + fn db_journal_inner_mut(&mut self) -> (&mut DB, &mut JournaledState) { + (&mut self.database, &mut self.inner) + } + + fn journal_inner(&self) -> &JournaledState { + &self.inner + } +} + +#[cfg(feature = "monad")] +impl FoundryJournal for monad_revm::MonadJournal { + fn db_journal_inner_mut(&mut self) -> (&mut DB, &mut JournaledState) { + Journal::db_journal_inner_mut(self) + } + + fn journal_inner(&self) -> &JournaledState { + Journal::journal_inner(self) + } + + fn capture_reserve_balance( + &self, + ) -> monad_revm::reserve_balance::tracker::ReserveBalanceTracker { + monad_revm::MonadJournalTr::reserve_balance(self).clone() + } + + fn restore_reserve_balance( + &mut self, + tracker: monad_revm::reserve_balance::tracker::ReserveBalanceTracker, + ) { + *monad_revm::MonadJournalTr::reserve_balance_mut(self) = tracker; + } + + fn preserves_reserve_balance(&self) -> bool { + monad_revm::MonadJournalTr::preserves_reserve_balance_tracker(self) + } + + fn set_preserve_reserve_balance(&mut self, preserve: bool) { + monad_revm::MonadJournalTr::set_preserve_reserve_balance_tracker(self, preserve); + } +} diff --git a/crates/evm/core/src/env/mod.rs b/crates/evm/core/src/env/mod.rs new file mode 100644 index 0000000000000..8e31dad0f5024 --- /dev/null +++ b/crates/evm/core/src/env/mod.rs @@ -0,0 +1,24 @@ +//! Execution environment mutation, context access, and RPC transaction conversion. + +pub use alloy_evm::EvmEnv; + +mod block; +pub use block::FoundryBlock; + +mod transaction; +pub use transaction::FoundryTransaction; + +mod cfg; +pub use cfg::FoundryCfg; + +mod chain; +pub use chain::FoundryChain; + +mod journal; +pub use journal::FoundryJournal; + +mod context; +pub use context::FoundryContextExt; + +mod rpc; +pub use rpc::FromAnyRpcTransaction; diff --git a/crates/evm/core/src/env/rpc.rs b/crates/evm/core/src/env/rpc.rs new file mode 100644 index 0000000000000..df1d65af489f6 --- /dev/null +++ b/crates/evm/core/src/env/rpc.rs @@ -0,0 +1,628 @@ +//! RPC transaction conversion into concrete execution environments. + +use alloy_chains::NamedChain; +use alloy_consensus::{Transaction as _, Typed2718}; +use alloy_evm::FromRecoveredTx; +use alloy_network::{AnyRpcTransaction, AnyTxEnvelope, TransactionResponse}; +use foundry_evm_networks::celo::CELO_DYNAMIC_FEE_TX_TYPE; +use revm::context::TxEnv; +use tempo_alloy::primitives::{TEMPO_TX_TYPE_ID, TempoTxEnvelope}; +use tempo_revm::TempoTxEnv; + +/// Trait for converting an [`AnyRpcTransaction`] into a specific `TxEnv`. +/// +/// Ethereum envelopes delegate to [`FromRecoveredTx`]. Implementations may also explicitly +/// project compatible network-specific envelopes into their execution environment. +pub trait FromAnyRpcTransaction: Sized { + /// Tries to convert an [`AnyRpcTransaction`] into `Self`. + fn from_any_rpc_transaction(tx: &AnyRpcTransaction) -> eyre::Result; +} + +/// Returns the error for a transaction whose envelope `target` cannot represent. +fn unknown_transaction_type(tx: &AnyRpcTransaction, target: &str) -> eyre::Report { + eyre::eyre!("cannot convert unknown transaction type {:#04x} to {target}", tx.inner.inner.ty()) +} + +impl FromAnyRpcTransaction for TxEnv { + fn from_any_rpc_transaction(tx: &AnyRpcTransaction) -> eyre::Result { + if let Some(envelope) = tx.as_envelope() { + return Ok(Self::from_recovered_tx(envelope, tx.from())); + } + + // CIP-64 transactions have EIP-1559 execution fields plus a Celo-specific fee currency. + // Foundry does not model fee payment in TxEnv, but can replay their EVM payload. Preserve + // the custom type so revm does not compare the fee-currency price with the native-CELO + // base fee. Keep this projection restricted to active Celo chains so an unrelated network + // cannot silently acquire semantics for its own type 0x7b envelope. + if let AnyTxEnvelope::Unknown(unknown) = &*tx.inner.inner + && unknown.ty() == CELO_DYNAMIC_FEE_TX_TYPE + && matches!( + unknown.chain_id().and_then(NamedChain::from_chain_id), + Some(NamedChain::Celo | NamedChain::CeloSepolia) + ) + { + return Ok(Self { + tx_type: CELO_DYNAMIC_FEE_TX_TYPE, + caller: tx.from(), + gas_limit: unknown.gas_limit(), + gas_price: unknown.max_fee_per_gas(), + gas_priority_fee: unknown.max_priority_fee_per_gas(), + kind: unknown.kind(), + value: unknown.value(), + data: unknown.input().clone(), + nonce: unknown.nonce(), + chain_id: unknown.chain_id(), + access_list: unknown.access_list().cloned().unwrap_or_default(), + ..Default::default() + }); + } + + Err(unknown_transaction_type(tx, "TxEnv")) + } +} + +impl FromAnyRpcTransaction for TempoTxEnv { + fn from_any_rpc_transaction(tx: &AnyRpcTransaction) -> eyre::Result { + // Rebuild the signed Tempo envelope so Tempo's own conversion populates the transaction + // hash and sender-scoped identifier for every type, and the batch calls, nonce key, + // validity window, authorizations and fee payer for AA transactions. + let envelope = match &*tx.inner.inner { + AnyTxEnvelope::Ethereum(envelope) => TempoTxEnvelope::try_from(envelope.clone()) + .map_err(|_| unknown_transaction_type(tx, "TempoTxEnv"))?, + AnyTxEnvelope::Unknown(unknown) if unknown.ty() == TEMPO_TX_TYPE_ID => { + serde_json::from_value::>( + serde_json::to_value(tx)?, + ) + .map_err(|err| eyre::eyre!("cannot decode Tempo AA transaction: {err}"))? + .inner + .into_inner() + } + AnyTxEnvelope::Unknown(_) => return Err(unknown_transaction_type(tx, "TempoTxEnv")), + }; + Ok(Self::from_recovered_tx(&envelope, tx.from())) + } +} + +#[cfg(feature = "base")] +mod base { + use super::*; + use base_common_consensus::BaseTxEnvelope; + use base_common_evm::{BaseTransaction, EIP8130_TRANSACTION_TYPE}; + use base_common_rpc_types::Transaction as BaseRpcTransaction; + + impl FromAnyRpcTransaction for BaseTransaction { + fn from_any_rpc_transaction(tx: &AnyRpcTransaction) -> eyre::Result { + let envelope = match BaseTxEnvelope::try_from(tx.clone()) { + Ok(envelope) => envelope, + Err(_) if tx.ty() == EIP8130_TRANSACTION_TYPE => { + let rpc_tx = + serde_json::from_value::(serde_json::to_value(tx)?) + .map_err(|err| { + eyre::eyre!( + "cannot convert RPC transaction to Base envelope: {err}" + ) + })?; + rpc_tx.inner.into_inner() + } + Err(_) => eyre::bail!("cannot convert transaction to BaseTxEnvelope"), + }; + Ok(Self::from_recovered_tx(&envelope, tx.from())) + } + } +} + +#[cfg(feature = "optimism")] +mod optimism { + use super::*; + use alloy_eips::eip2718::Encodable2718; + use alloy_op_evm::OpTx; + use op_alloy_consensus::{DEPOSIT_TX_TYPE_ID, TxDeposit}; + use op_revm::OpTransaction; + + impl FromAnyRpcTransaction for OpTx { + fn from_any_rpc_transaction(tx: &AnyRpcTransaction) -> eyre::Result { + if let Some(envelope) = tx.as_envelope() { + return Ok(Self(OpTransaction:: { + base: TxEnv::from_recovered_tx(envelope, tx.from()), + // The L1 data fee is charged off these bytes, and op-revm rejects a + // non-deposit transaction that arrives without them. + enveloped_tx: Some(envelope.encoded_2718().into()), + deposit: Default::default(), + })); + } + + // Handle OP deposit transactions from `Unknown` envelope variant. + if let AnyTxEnvelope::Unknown(unknown) = &*tx.inner.inner + && unknown.ty() == DEPOSIT_TX_TYPE_ID + { + let mut fields = unknown.inner.fields.clone(); + fields.insert("from".to_string(), serde_json::to_value(tx.from())?); + let deposit_tx: TxDeposit = fields + .deserialize_into() + .map_err(|e| eyre::eyre!("failed to deserialize deposit tx: {e}"))?; + return Ok(Self::from_recovered_tx(&deposit_tx, deposit_tx.from)); + } + + Err(unknown_transaction_type(tx, "OpTransaction")) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_consensus::{Signed, TxEip1559, transaction::Recovered}; + use alloy_network::{AnyTxType, UnknownTxEnvelope, UnknownTypedTransaction}; + use alloy_primitives::{Address, B256, Bytes, Signature, U256, address, b256, bytes}; + use alloy_rpc_types::{Transaction as RpcTransaction, TransactionInfo}; + use alloy_serde::WithOtherFields; + use alloy_signer::SignerSync; + use alloy_signer_local::PrivateKeySigner; + use revm::primitives::TxKind; + use std::num::NonZeroU64; + use tempo_alloy::primitives::{TempoSignature, TempoTransaction, transaction::Call}; + use tempo_revm::ExecutionContext; + + #[cfg(feature = "base")] + use base_common_evm::BaseTransaction; + + fn make_signed_eip1559() -> Signed { + Signed::new_unchecked( + TxEip1559 { + chain_id: 1, + nonce: 42, + gas_limit: 21001, + to: TxKind::Call(Address::with_last_byte(0xBB)), + value: U256::from(101), + ..Default::default() + }, + Signature::new(U256::ZERO, U256::ZERO, false), + B256::ZERO, + ) + } + + #[test] + fn from_any_rpc_transaction_for_eth() { + let from = Address::random(); + let any_tx = eth_rpc_transaction(from); + let tx_env = TxEnv::from_any_rpc_transaction(&any_tx).unwrap(); + + assert_eq!(tx_env.caller, from); + assert_eq!(tx_env.nonce, 42); + assert_eq!(tx_env.gas_limit, 21001); + assert_eq!(tx_env.value, U256::from(101)); + assert_eq!(tx_env.kind, TxKind::Call(Address::with_last_byte(0xBB))); + } + + #[cfg(feature = "base")] + #[test] + fn from_any_rpc_transaction_for_base_eth_envelope() { + let from = Address::random(); + let any_tx = eth_rpc_transaction(from); + + let tx_env = BaseTransaction::::from_any_rpc_transaction(&any_tx).unwrap(); + assert_eq!(tx_env.base.caller, from); + assert_eq!(tx_env.base.nonce, 42); + assert_eq!(tx_env.base.gas_limit, 21001); + assert_eq!(tx_env.base.value, U256::from(101)); + assert!(tx_env.enveloped_tx.is_some()); + } + + #[test] + fn from_any_rpc_transaction_unknown_envelope_errors() { + let unknown = AnyTxEnvelope::Unknown(UnknownTxEnvelope { + hash: B256::ZERO, + inner: UnknownTypedTransaction { + ty: AnyTxType(0xFF), + fields: Default::default(), + memo: Default::default(), + }, + }); + let from = Address::random(); + let any_tx = AnyRpcTransaction::new(WithOtherFields::new(RpcTransaction { + inner: Recovered::new_unchecked(unknown, from), + block_hash: None, + block_number: None, + transaction_index: None, + effective_gas_price: None, + block_timestamp: None, + })); + + let result = TxEnv::from_any_rpc_transaction(&any_tx).unwrap_err(); + assert_eq!(result.to_string(), "cannot convert unknown transaction type 0xff to TxEnv"); + } + + #[test] + fn from_any_rpc_transaction_for_celo_dynamic_fee() { + let from = Address::with_last_byte(0xAA); + let to = Address::with_last_byte(0xBB); + let fee_currency = Address::with_last_byte(0xCC); + let json = serde_json::json!({ + "accessList": [], + "blockHash": B256::ZERO, + "blockNumber": "0x1", + "chainId": "0xa4ec", + "feeCurrency": fee_currency, + "from": from, + "gas": "0x5208", + "gasPrice": "0x3", + "hash": B256::ZERO, + "input": "0x1234", + "maxFeePerGas": "0x3", + "maxPriorityFeePerGas": "0x1", + "nonce": "0x2a", + "r": B256::ZERO, + "s": B256::ZERO, + "to": to, + "transactionIndex": "0x0", + "type": "0x7b", + "v": "0x0", + "value": "0x65", + "yParity": "0x0" + }); + let mut non_celo_json = json.clone(); + non_celo_json["chainId"] = serde_json::json!("0x1"); + let non_celo_tx: AnyRpcTransaction = serde_json::from_value(non_celo_json).unwrap(); + assert!(TxEnv::from_any_rpc_transaction(&non_celo_tx).is_err()); + + let any_tx: AnyRpcTransaction = serde_json::from_value(json).unwrap(); + + let tx_env = TxEnv::from_any_rpc_transaction(&any_tx).unwrap(); + + assert_eq!(tx_env.tx_type, CELO_DYNAMIC_FEE_TX_TYPE); + assert_eq!(tx_env.caller, from); + assert_eq!(tx_env.nonce, 42); + assert_eq!(tx_env.gas_limit, 21000); + assert_eq!(tx_env.gas_price, 3); + assert_eq!(tx_env.gas_priority_fee, Some(1)); + assert_eq!(tx_env.kind, TxKind::Call(to)); + assert_eq!(tx_env.value, U256::from(101)); + assert_eq!(tx_env.data, Bytes::from_static(&[0x12, 0x34])); + assert_eq!(tx_env.chain_id, Some(42_220)); + } + + #[test] + fn from_any_rpc_transaction_for_tempo_eth_envelope() { + let from = Address::random(); + let signed_tx = make_signed_eip1559(); + let tempo_envelope = TempoTxEnvelope::Eip1559(signed_tx.clone()); + let any_tx = eth_rpc_transaction(from); + + let tx_env = TempoTxEnv::from_any_rpc_transaction(&any_tx).unwrap(); + assert_eq!(tx_env.inner.caller, from); + assert_eq!(tx_env.inner.nonce, 42); + assert_eq!(tx_env.inner.gas_limit, 21001); + assert_eq!(tx_env.inner.value, U256::from(101)); + assert_eq!(tx_env.fee_token, None); + assert_eq!( + tx_env.execution_context(), + ExecutionContext::Transaction { tx_hash: *signed_tx.hash() } + ); + assert_eq!(tx_env.unique_tx_identifier(), Some(tempo_envelope.unique_tx_identifier(from))); + assert!(tx_env.tempo_tx_env.is_none()); + } + + #[test] + fn from_any_rpc_transaction_for_tempo_aa() { + let sender = PrivateKeySigner::random(); + let sponsor = PrivateKeySigner::random(); + let fee_token = Some(Address::random()); + let calls = vec![ + Call { + to: TxKind::Call(Address::with_last_byte(0x11)), + value: U256::ZERO, + input: Bytes::from_static(&[0xaa, 0xbb]), + }, + Call { + to: TxKind::Call(Address::with_last_byte(0x22)), + value: U256::ZERO, + input: Bytes::from_static(&[0xcc, 0xdd]), + }, + ]; + let mut tempo_tx = TempoTransaction { + chain_id: 42431, + nonce: 42, + gas_limit: 424242, + fee_token, + nonce_key: U256::from(4242), + valid_after: NonZeroU64::new(1800000000), + valid_before: NonZeroU64::new(1900000000), + calls: calls.clone(), + ..Default::default() + }; + tempo_tx.fee_payer_signature = Some( + sponsor.sign_hash_sync(&tempo_tx.fee_payer_signature_hash(sender.address())).unwrap(), + ); + let signature = sender.sign_hash_sync(&tempo_tx.signature_hash()).unwrap(); + let aa_signed = tempo_tx.into_signed(signature.into()); + let tx_hash = *aa_signed.hash(); + let unique_tx_identifier = aa_signed.expiring_nonce_hash(sender.address()); + + // Round-trip a Tempo RPC transaction through JSON, as `AnyNetwork` providers receive it. + let rpc_tx = RpcTransaction::from_transaction( + Recovered::new_unchecked(TempoTxEnvelope::AA(aa_signed), sender.address()), + TransactionInfo::default(), + ); + let any_tx: AnyRpcTransaction = + serde_json::from_value(serde_json::to_value(&rpc_tx).unwrap()).unwrap(); + assert!((*any_tx.inner.inner).is_unknown()); + + let tx_env = TempoTxEnv::from_any_rpc_transaction(&any_tx).unwrap(); + assert_eq!(tx_env.inner.tx_type, TEMPO_TX_TYPE_ID); + assert_eq!(tx_env.inner.caller, sender.address()); + assert_eq!(tx_env.inner.nonce, 42); + assert_eq!(tx_env.inner.gas_limit, 424242); + assert_eq!(tx_env.inner.chain_id, Some(42431)); + assert_eq!(tx_env.fee_token, fee_token); + assert_eq!(tx_env.fee_payer, Some(Some(sponsor.address()))); + assert_eq!(tx_env.execution_context(), ExecutionContext::Transaction { tx_hash }); + assert_eq!(tx_env.unique_tx_identifier(), Some(unique_tx_identifier)); + + let aa = tx_env.tempo_tx_env.as_deref().unwrap(); + assert_eq!(aa.aa_calls, calls); + assert_eq!(aa.nonce_key, U256::from(4242)); + assert_eq!(aa.valid_after, Some(1800000000)); + assert_eq!(aa.valid_before, Some(1900000000)); + assert_eq!(aa.tx_hash, tx_hash); + } + + /// Decodes a captured Tempo `eth_getTransactionByHash` result. + fn captured_tempo_aa(json: &str) -> TempoTxEnv { + let any_tx: AnyRpcTransaction = serde_json::from_str(json).unwrap(); + let tx_env = TempoTxEnv::from_any_rpc_transaction(&any_tx).unwrap(); + let aa = tx_env.tempo_tx_env.as_deref().unwrap(); + // Recovery over the recomputed signing hash only succeeds if every signed field decoded. + assert_eq!(aa.signature.recover_signer(&aa.signature_hash).unwrap(), tx_env.inner.caller); + assert_eq!(tx_env.inner.tx_type, TEMPO_TX_TYPE_ID); + tx_env + } + + #[test] + fn from_any_rpc_transaction_for_captured_sponsored_tempo_aa_batch() { + let tx_env = + captured_tempo_aa(include_str!("../../test-data/tempo-aa-sponsored-batch.json")); + assert_eq!(tx_env.inner.caller, address!("0x0a0d9bc4dda3a659699ce05153ffe9298f99ce89")); + assert_eq!(tx_env.inner.nonce, 0); + assert_eq!(tx_env.inner.gas_limit, 1403789); + assert_eq!(tx_env.inner.chain_id, Some(4217)); + assert_eq!(tx_env.fee_token, Some(address!("0x20c0000000000000000000006a37da5c996874be"))); + assert_eq!( + tx_env.fee_payer, + Some(Some(address!("0x58aa7ce42e1d13b2919e2ac7e006c4fbc171442c"))) + ); + assert_eq!( + tx_env.execution_context(), + ExecutionContext::Transaction { + tx_hash: b256!( + "0xbb23551c35bc2c2539ac3637c7464a22b5fb38bbb9cf39fa1f533ca6c2c981a3" + ) + } + ); + // Expiring nonces are replay-protected by this sender-scoped identifier. + assert_eq!( + tx_env.channel_open_context_hash(), + Some(b256!("0xadb35ee9830a691a8dbd8f42208a1553d9a242b8750a218d841cc78a0dea20ca")) + ); + + let aa = tx_env.tempo_tx_env.as_deref().unwrap(); + assert_eq!( + aa.aa_calls, + [ + Call { + to: TxKind::Call(address!("0x20c0000000000000000000000000000000000000")), + value: U256::ZERO, + input: bytes!( + "0x095ea7b300000000000000000000000083a1491f3e7f8daab8f787a631334b9ca7a870230000000000000000000000000000000000000000000000000000000077359400" + ), + }, + Call { + to: TxKind::Call(address!("0x83a1491f3e7f8daab8f787a631334b9ca7a87023")), + value: U256::ZERO, + input: bytes!( + "0x6e553f6500000000000000000000000000000000000000000000000000000000773594000000000000000000000000000a0d9bc4dda3a659699ce05153ffe9298f99ce89" + ), + }, + ] + ); + assert_eq!(aa.nonce_key, U256::MAX); + assert_eq!(aa.valid_after, Some(542204929)); + assert_eq!(aa.valid_before, Some(1790813692)); + } + + #[test] + fn from_any_rpc_transaction_for_captured_nonce_lane_tempo_aa() { + let tx_env = captured_tempo_aa(include_str!("../../test-data/tempo-aa-nonce-lane.json")); + assert_eq!(tx_env.inner.caller, address!("0xfdd1c606b498f5fcaaf27bd318b14caf52e8f6c2")); + assert_eq!(tx_env.inner.nonce, 22496); + assert_eq!(tx_env.inner.gas_limit, 99408); + assert_eq!(tx_env.inner.chain_id, Some(42431)); + assert_eq!(tx_env.fee_token, None); + assert_eq!(tx_env.fee_payer, None); + assert_eq!( + tx_env.execution_context(), + ExecutionContext::Transaction { + tx_hash: b256!( + "0x4df0a9b9e859be6c91c00edf8478939d0b91f6f9eb39dd9c8a93946ac70ff740" + ) + } + ); + + let aa = tx_env.tempo_tx_env.as_deref().unwrap(); + let [call] = aa.aa_calls.as_slice() else { panic!("expected one call") }; + assert_eq!(call.to, TxKind::Call(address!("0x5ad0000000000000000000000000000000000003"))); + assert_eq!(call.value, U256::ZERO); + assert_eq!(call.input.len(), 868); + assert_eq!(aa.nonce_key, U256::ONE); + assert_eq!(aa.valid_after, None); + assert_eq!(aa.valid_before, None); + } + + #[test] + fn from_any_rpc_transaction_for_captured_keychain_tempo_aa() { + let tx_env = captured_tempo_aa(include_str!("../../test-data/tempo-aa-keychain.json")); + let sender = address!("0x5f704c6c7075acd14ee36527f03b5b5dcb4a966f"); + let key_id = address!("0x240a31713e851acdc0c590fe45ef72baeff55f91"); + assert_eq!(tx_env.inner.caller, sender); + assert_eq!(tx_env.inner.nonce, 0); + assert_eq!(tx_env.inner.gas_limit, 322821); + assert_eq!(tx_env.inner.chain_id, Some(42431)); + assert_eq!(tx_env.fee_token, None); + assert_eq!( + tx_env.fee_payer, + Some(Some(address!("0x133d7736f290fa2758cf0d1e0862f5a93ae1dcbf"))) + ); + assert_eq!( + tx_env.execution_context(), + ExecutionContext::Transaction { + tx_hash: b256!( + "0xd4d372ad5cfa4cdf476aeb25d4a76fb80fe2d6169eb9354c544500c0496a19d6" + ) + } + ); + // Expiring nonces are replay-protected by this sender-scoped identifier. + assert_eq!( + tx_env.channel_open_context_hash(), + Some(b256!("0xdb2722c83d8893c1637bbabfe2ad924bcc044653f6c9fa91d472d70ddfa97de3")) + ); + + let aa = tx_env.tempo_tx_env.as_deref().unwrap(); + assert_eq!( + aa.aa_calls, + [Call { to: TxKind::Call(Address::ZERO), value: U256::ZERO, input: Bytes::new() }] + ); + assert_eq!(aa.nonce_key, U256::MAX); + assert_eq!(aa.valid_after, Some(949691873)); + assert_eq!(aa.valid_before, Some(1790811579)); + let TempoSignature::Keychain(keychain) = &aa.signature else { panic!("expected keychain") }; + assert_eq!(keychain.user_address, sender); + assert_eq!(keychain.key_id(&aa.signature_hash).unwrap(), key_id); + let key_authorization = aa.key_authorization.as_ref().unwrap(); + assert_eq!(key_authorization.key_id, key_id); + assert_eq!(key_authorization.recover_signer().unwrap(), sender); + } + + #[test] + fn from_any_rpc_transaction_for_captured_authorization_list_tempo_aa() { + let tx_env = + captured_tempo_aa(include_str!("../../test-data/tempo-aa-authorization-list.json")); + assert_eq!(tx_env.inner.caller, address!("0xd410bcea80de9214e0e49df7d19c4c5e3db60f2a")); + assert_eq!(tx_env.inner.nonce, 0); + assert_eq!(tx_env.inner.gas_limit, 2000000); + assert_eq!(tx_env.inner.chain_id, Some(42431)); + assert_eq!(tx_env.fee_token, Some(address!("0x20c0000000000000000000000000000000000000"))); + assert_eq!(tx_env.fee_payer, None); + assert_eq!( + tx_env.execution_context(), + ExecutionContext::Transaction { + tx_hash: b256!( + "0xfff5a6fa344fa9d6d7410374f40b6f711735500892cccdf480670fd7c5adcf93" + ) + } + ); + + let aa = tx_env.tempo_tx_env.as_deref().unwrap(); + let call = Call { + to: TxKind::Call(address!("0xa98d41b22c17ae4f4a94420a29a3095464c037e3")), + value: U256::ZERO, + input: Bytes::new(), + }; + assert_eq!(aa.aa_calls, [call.clone(), call]); + assert_eq!(aa.nonce_key, U256::ZERO); + // Secp256k1, P256 and WebAuthn authorizations, in that order. + let delegate = address!("0xaaaaaaaa00000000000000000000000000000000"); + assert_eq!( + aa.tempo_authorization_list + .iter() + .map(|auth| (auth.address, auth.authority())) + .collect::>(), + [ + (delegate, Some(address!("0xd9aa283bc5643587f9623a0e9683df78588404a8"))), + (delegate, Some(address!("0x883b644ccaa219845187fd0932a8ef6e7d484bd6"))), + (delegate, Some(address!("0x732c32ec8e029d8989b9d1db9914376b17e20676"))), + ] + ); + } + + #[test] + fn from_any_rpc_transaction_for_tempo_aa_without_signature_errors() { + let mut json: serde_json::Value = + serde_json::from_str(include_str!("../../test-data/tempo-aa-sponsored-batch.json")) + .unwrap(); + json.as_object_mut().unwrap().remove("signature"); + let any_tx: AnyRpcTransaction = serde_json::from_value(json).unwrap(); + + let err = TempoTxEnv::from_any_rpc_transaction(&any_tx).unwrap_err(); + assert_eq!( + err.to_string(), + "cannot decode Tempo AA transaction: missing field `signature`" + ); + } + + #[cfg(feature = "optimism")] + mod optimism { + use super::*; + use alloy_consensus::Sealed; + use alloy_eips::eip2718::Encodable2718; + use alloy_op_evm::OpTx; + use op_alloy_consensus::{OpTxEnvelope, TxDeposit, transaction::OpTransactionInfo}; + use op_alloy_rpc_types::Transaction as OpRpcTransaction; + + #[test] + fn from_any_rpc_transaction_for_op() { + let from = Address::random(); + let any_tx = eth_rpc_transaction(from); + let expected_base = TxEnv::from_any_rpc_transaction(&any_tx).unwrap(); + + let op_tx_env = OpTx::from_any_rpc_transaction(&any_tx).unwrap(); + assert_eq!(op_tx_env.base, expected_base); + // op-revm charges the L1 data fee off these bytes and rejects a non-deposit + // transaction that arrives without them. + assert_eq!( + op_tx_env.enveloped_tx, + Some(any_tx.as_envelope().unwrap().encoded_2718().into()) + ); + } + + #[test] + fn from_any_rpc_transaction_for_op_deposit() { + let from = Address::random(); + let source_hash = B256::random(); + let deposit = TxDeposit { + source_hash, + from, + to: TxKind::Call(Address::with_last_byte(0xCC)), + mint: 1111, + value: U256::from(200), + gas_limit: 21000, + is_system_transaction: true, + input: Default::default(), + }; + + // Build a concrete OpRpcTransaction, serialize to JSON, deserialize as + // AnyRpcTransaction. + let op_rpc_tx = OpRpcTransaction::from_transaction( + Recovered::new_unchecked(OpTxEnvelope::Deposit(Sealed::new(deposit)), from), + OpTransactionInfo::default(), + ); + let json = serde_json::to_value(&op_rpc_tx).unwrap(); + let any_tx: AnyRpcTransaction = serde_json::from_value(json).unwrap(); + + let op_tx_env = OpTx::from_any_rpc_transaction(&any_tx).unwrap(); + assert_eq!(op_tx_env.base.caller, from); + assert_eq!(op_tx_env.base.kind, TxKind::Call(Address::with_last_byte(0xCC))); + assert_eq!(op_tx_env.base.value, U256::from(200)); + assert_eq!(op_tx_env.base.gas_limit, 21000); + assert_eq!(op_tx_env.deposit.source_hash, source_hash); + assert_eq!(op_tx_env.deposit.mint, Some(1111)); + assert!(op_tx_env.deposit.is_system_transaction); + } + } + + fn eth_rpc_transaction(from: Address) -> AnyRpcTransaction { + RpcTransaction::from_transaction( + Recovered::new_unchecked(make_signed_eip1559().into(), from), + TransactionInfo::default(), + ) + .into() + } +} diff --git a/crates/evm/core/src/env/transaction.rs b/crates/evm/core/src/env/transaction.rs new file mode 100644 index 0000000000000..b20d2cc204d9c --- /dev/null +++ b/crates/evm/core/src/env/transaction.rs @@ -0,0 +1,625 @@ +//! Mutable transaction environments and execution-family adapters. + +use alloy_primitives::{Address, B256, Bytes, U256}; +use revm::{ + context::{Transaction, TxEnv}, + context_interface::{ + either::Either, + transaction::{AccessList, RecoveredAuthorization, SignedAuthorization}, + }, + primitives::TxKind, +}; +use tempo_revm::TempoTxEnv; + +#[cfg(feature = "optimism")] +use op_revm::transaction::deposit::DEPOSIT_TRANSACTION_TYPE; + +/// Extension of [`Transaction`] with mutable setters, allowing EVM-agnostic mutation of transaction +/// fields. +pub trait FoundryTransaction: Transaction { + /// Sets the transaction type. + fn set_tx_type(&mut self, tx_type: u8); + + /// Sets the caller (sender) address. + fn set_caller(&mut self, caller: Address); + + /// Sets the gas limit. + fn set_gas_limit(&mut self, gas_limit: u64); + + /// Sets the gas price (or max fee per gas for EIP-1559). + fn set_gas_price(&mut self, gas_price: u128); + + /// Sets the transaction kind (call or create). + fn set_kind(&mut self, kind: TxKind); + + /// Sets the value sent with the transaction. + fn set_value(&mut self, value: U256); + + /// Sets the transaction input data. + fn set_data(&mut self, data: Bytes); + + /// Sets the nonce. + fn set_nonce(&mut self, nonce: u64); + + /// Sets the chain ID. + fn set_chain_id(&mut self, chain_id: Option); + + /// Sets the access list. + fn set_access_list(&mut self, access_list: AccessList); + + /// Returns a mutable reference to the EIP-7702 authorization list. + fn authorization_list_mut( + &mut self, + ) -> &mut Vec>; + + /// Sets the max priority fee per gas. + fn set_gas_priority_fee(&mut self, gas_priority_fee: Option); + + /// Sets the blob versioned hashes. + fn set_blob_hashes(&mut self, blob_hashes: Vec); + + /// Sets the max fee per blob gas. + fn set_max_fee_per_blob_gas(&mut self, max_fee_per_blob_gas: u128); + + /// Sets the EIP-7702 signed authorization list. + fn set_signed_authorization(&mut self, auth: Vec) { + *self.authorization_list_mut() = auth.into_iter().map(Either::Left).collect(); + } + + // `OpTransaction` methods + + /// Enveloped transaction bytes. + fn enveloped_tx(&self) -> Option<&Bytes> { + None + } + + /// Set Enveloped transaction bytes. + fn set_enveloped_tx(&mut self, _bytes: Bytes) {} + + /// Source hash of the deposit transaction. + fn source_hash(&self) -> Option { + None + } + + /// Sets source hash of the deposit transaction. + fn set_source_hash(&mut self, _source_hash: B256) {} + + /// Mint of the deposit transaction + fn mint(&self) -> Option { + None + } + + /// Sets mint of the deposit transaction. + fn set_mint(&mut self, _mint: u128) {} + + /// Whether the transaction is a system transaction + fn is_system_transaction(&self) -> bool { + false + } + + /// Sets whether the transaction is a system transaction + fn set_system_transaction(&mut self, _is_system_transaction: bool) {} + + /// Returns `true` if transaction is an Optimism deposit transaction. + fn is_deposit(&self) -> bool { + #[cfg(feature = "optimism")] + { + self.tx_type() == DEPOSIT_TRANSACTION_TYPE + } + #[cfg(not(feature = "optimism"))] + { + false + } + } + + // Tempo methods + + /// Returns the fee token address for this transaction. + fn fee_token(&self) -> Option
{ + None + } + + /// Sets the fee token address for this transaction. + fn set_fee_token(&mut self, _token: Option
) {} + + /// Returns the fee payer for this transaction. + fn fee_payer(&self) -> Option> { + None + } + + /// Sets the fee payer for this transaction. + fn set_fee_payer(&mut self, _payer: Option>) {} + + /// Returns whether this transaction executes a single contract creation. + fn is_single_create(&self) -> bool { + self.kind().is_create() + } +} + +impl FoundryTransaction for TxEnv { + fn set_tx_type(&mut self, tx_type: u8) { + self.tx_type = tx_type; + } + + fn set_caller(&mut self, caller: Address) { + self.caller = caller; + } + + fn set_gas_limit(&mut self, gas_limit: u64) { + self.gas_limit = gas_limit; + } + + fn set_gas_price(&mut self, gas_price: u128) { + self.gas_price = gas_price; + } + + fn set_kind(&mut self, kind: TxKind) { + self.kind = kind; + } + + fn set_value(&mut self, value: U256) { + self.value = value; + } + + fn set_data(&mut self, data: Bytes) { + self.data = data; + } + + fn set_nonce(&mut self, nonce: u64) { + self.nonce = nonce; + } + + fn set_chain_id(&mut self, chain_id: Option) { + self.chain_id = chain_id; + } + + fn set_access_list(&mut self, access_list: AccessList) { + self.access_list = access_list; + } + + fn authorization_list_mut( + &mut self, + ) -> &mut Vec> { + &mut self.authorization_list + } + + fn set_gas_priority_fee(&mut self, gas_priority_fee: Option) { + self.gas_priority_fee = gas_priority_fee; + } + + fn set_blob_hashes(&mut self, blob_hashes: Vec) { + self.blob_hashes = blob_hashes; + } + + fn set_max_fee_per_blob_gas(&mut self, max_fee_per_blob_gas: u128) { + self.max_fee_per_blob_gas = max_fee_per_blob_gas; + } +} + +impl FoundryTransaction for TempoTxEnv { + fn set_tx_type(&mut self, tx_type: u8) { + self.inner.set_tx_type(tx_type); + } + + fn set_caller(&mut self, caller: Address) { + self.inner.set_caller(caller); + } + + fn set_gas_limit(&mut self, gas_limit: u64) { + self.inner.set_gas_limit(gas_limit); + } + + fn set_gas_price(&mut self, gas_price: u128) { + self.inner.set_gas_price(gas_price); + } + + fn set_kind(&mut self, kind: TxKind) { + self.inner.set_kind(kind); + if let Some(call) = + self.tempo_tx_env.as_deref_mut().and_then(|env| env.aa_calls.first_mut()) + { + call.to = kind; + } + } + + fn set_value(&mut self, value: U256) { + self.inner.set_value(value); + if let Some(call) = + self.tempo_tx_env.as_deref_mut().and_then(|env| env.aa_calls.first_mut()) + { + call.value = value; + } + } + + fn set_data(&mut self, data: Bytes) { + self.inner.set_data(data.clone()); + if let Some(call) = + self.tempo_tx_env.as_deref_mut().and_then(|env| env.aa_calls.first_mut()) + { + call.input = data; + } + } + + fn set_nonce(&mut self, nonce: u64) { + self.inner.set_nonce(nonce); + } + + fn set_chain_id(&mut self, chain_id: Option) { + self.inner.set_chain_id(chain_id); + } + + fn set_access_list(&mut self, access_list: AccessList) { + self.inner.set_access_list(access_list); + } + + fn authorization_list_mut( + &mut self, + ) -> &mut Vec> { + self.inner.authorization_list_mut() + } + + fn set_gas_priority_fee(&mut self, gas_priority_fee: Option) { + self.inner.set_gas_priority_fee(gas_priority_fee); + } + + fn set_blob_hashes(&mut self, _blob_hashes: Vec) {} + + fn set_max_fee_per_blob_gas(&mut self, _max_fee_per_blob_gas: u128) {} + + fn fee_token(&self) -> Option
{ + self.fee_token + } + + fn set_fee_token(&mut self, token: Option
) { + self.fee_token = token; + } + + fn fee_payer(&self) -> Option> { + self.fee_payer + } + + fn set_fee_payer(&mut self, payer: Option>) { + self.fee_payer = payer; + } + + fn is_single_create(&self) -> bool { + self.kind().is_create() + && self.tempo_tx_env.as_ref().is_none_or(|env| env.aa_calls.len() == 1) + } +} + +#[cfg(feature = "base")] +mod base { + use super::*; + use base_common_evm::{BaseTransaction, BaseTxTr, DEPOSIT_TRANSACTION_TYPE}; + + impl FoundryTransaction for BaseTransaction { + fn set_tx_type(&mut self, tx_type: u8) { + self.base.set_tx_type(tx_type); + } + + fn set_caller(&mut self, caller: Address) { + self.base.set_caller(caller); + } + + fn set_gas_limit(&mut self, gas_limit: u64) { + self.base.set_gas_limit(gas_limit); + } + + fn set_gas_price(&mut self, gas_price: u128) { + self.base.set_gas_price(gas_price); + } + + fn set_kind(&mut self, kind: TxKind) { + self.base.set_kind(kind); + } + + fn set_value(&mut self, value: U256) { + self.base.set_value(value); + } + + fn set_data(&mut self, data: Bytes) { + self.base.set_data(data); + } + + fn set_nonce(&mut self, nonce: u64) { + self.base.set_nonce(nonce); + } + + fn set_chain_id(&mut self, chain_id: Option) { + self.base.set_chain_id(chain_id); + } + + fn set_access_list(&mut self, access_list: AccessList) { + self.base.set_access_list(access_list); + } + + fn authorization_list_mut( + &mut self, + ) -> &mut Vec> { + self.base.authorization_list_mut() + } + + fn set_gas_priority_fee(&mut self, gas_priority_fee: Option) { + self.base.set_gas_priority_fee(gas_priority_fee); + } + + fn set_blob_hashes(&mut self, blob_hashes: Vec) { + self.base.set_blob_hashes(blob_hashes); + } + + fn set_max_fee_per_blob_gas(&mut self, max_fee_per_blob_gas: u128) { + self.base.set_max_fee_per_blob_gas(max_fee_per_blob_gas); + } + + fn enveloped_tx(&self) -> Option<&Bytes> { + BaseTxTr::enveloped_tx(self) + } + + fn set_enveloped_tx(&mut self, bytes: Bytes) { + self.enveloped_tx = Some(bytes); + } + + fn source_hash(&self) -> Option { + BaseTxTr::source_hash(self) + } + + fn set_source_hash(&mut self, source_hash: B256) { + self.deposit.source_hash = source_hash; + } + + fn mint(&self) -> Option { + BaseTxTr::mint(self) + } + + fn set_mint(&mut self, mint: u128) { + self.deposit.mint = Some(mint); + } + + fn is_system_transaction(&self) -> bool { + BaseTxTr::is_system_transaction(self) + } + + fn set_system_transaction(&mut self, is_system_transaction: bool) { + self.deposit.is_system_transaction = is_system_transaction; + } + + fn is_deposit(&self) -> bool { + self.tx_type() == DEPOSIT_TRANSACTION_TYPE + } + } +} + +#[cfg(feature = "optimism")] +mod optimism { + use super::*; + use alloy_op_evm::OpTx; + use op_revm::{OpTransaction, transaction::OpTxTr}; + + impl FoundryTransaction for OpTransaction { + fn set_tx_type(&mut self, tx_type: u8) { + self.base.set_tx_type(tx_type); + } + + fn set_caller(&mut self, caller: Address) { + self.base.set_caller(caller); + } + + fn set_gas_limit(&mut self, gas_limit: u64) { + self.base.set_gas_limit(gas_limit); + } + + fn set_gas_price(&mut self, gas_price: u128) { + self.base.set_gas_price(gas_price); + } + + fn set_kind(&mut self, kind: TxKind) { + self.base.set_kind(kind); + } + + fn set_value(&mut self, value: U256) { + self.base.set_value(value); + } + + fn set_data(&mut self, data: Bytes) { + self.base.set_data(data); + } + + fn set_nonce(&mut self, nonce: u64) { + self.base.set_nonce(nonce); + } + + fn set_chain_id(&mut self, chain_id: Option) { + self.base.set_chain_id(chain_id); + } + + fn set_access_list(&mut self, access_list: AccessList) { + self.base.set_access_list(access_list); + } + + fn authorization_list_mut( + &mut self, + ) -> &mut Vec> { + self.base.authorization_list_mut() + } + + fn set_gas_priority_fee(&mut self, gas_priority_fee: Option) { + self.base.set_gas_priority_fee(gas_priority_fee); + } + + fn set_blob_hashes(&mut self, _blob_hashes: Vec) {} + + fn set_max_fee_per_blob_gas(&mut self, _max_fee_per_blob_gas: u128) {} + + fn enveloped_tx(&self) -> Option<&Bytes> { + OpTxTr::enveloped_tx(self) + } + + fn set_enveloped_tx(&mut self, bytes: Bytes) { + self.enveloped_tx = Some(bytes); + } + + fn source_hash(&self) -> Option { + OpTxTr::source_hash(self) + } + + fn set_source_hash(&mut self, source_hash: B256) { + if self.tx_type() == DEPOSIT_TRANSACTION_TYPE { + self.deposit.source_hash = source_hash; + } + } + + fn mint(&self) -> Option { + OpTxTr::mint(self) + } + + fn set_mint(&mut self, mint: u128) { + if self.tx_type() == DEPOSIT_TRANSACTION_TYPE { + self.deposit.mint = Some(mint); + } + } + + fn is_system_transaction(&self) -> bool { + OpTxTr::is_system_transaction(self) + } + + fn set_system_transaction(&mut self, is_system_transaction: bool) { + if self.tx_type() == DEPOSIT_TRANSACTION_TYPE { + self.deposit.is_system_transaction = is_system_transaction; + } + } + } + + impl FoundryTransaction for OpTx { + fn set_tx_type(&mut self, tx_type: u8) { + self.0.set_tx_type(tx_type); + } + + fn set_caller(&mut self, caller: Address) { + self.0.set_caller(caller); + } + + fn set_gas_limit(&mut self, gas_limit: u64) { + self.0.set_gas_limit(gas_limit); + } + + fn set_gas_price(&mut self, gas_price: u128) { + self.0.set_gas_price(gas_price); + } + + fn set_kind(&mut self, kind: TxKind) { + self.0.set_kind(kind); + } + + fn set_value(&mut self, value: U256) { + self.0.set_value(value); + } + + fn set_data(&mut self, data: Bytes) { + self.0.set_data(data); + } + + fn set_nonce(&mut self, nonce: u64) { + self.0.set_nonce(nonce); + } + + fn set_chain_id(&mut self, chain_id: Option) { + self.0.set_chain_id(chain_id); + } + + fn set_access_list(&mut self, access_list: AccessList) { + self.0.set_access_list(access_list); + } + + fn authorization_list_mut( + &mut self, + ) -> &mut Vec> { + self.0.authorization_list_mut() + } + + fn set_gas_priority_fee(&mut self, gas_priority_fee: Option) { + self.0.set_gas_priority_fee(gas_priority_fee); + } + + fn set_blob_hashes(&mut self, _blob_hashes: Vec) {} + + fn set_max_fee_per_blob_gas(&mut self, _max_fee_per_blob_gas: u128) {} + + fn enveloped_tx(&self) -> Option<&Bytes> { + FoundryTransaction::enveloped_tx(&self.0) + } + + fn set_enveloped_tx(&mut self, bytes: Bytes) { + self.0.set_enveloped_tx(bytes); + } + + fn source_hash(&self) -> Option { + FoundryTransaction::source_hash(&self.0) + } + + fn set_source_hash(&mut self, source_hash: B256) { + self.0.set_source_hash(source_hash); + } + + fn mint(&self) -> Option { + FoundryTransaction::mint(&self.0) + } + + fn set_mint(&mut self, mint: u128) { + self.0.set_mint(mint); + } + + fn is_system_transaction(&self) -> bool { + FoundryTransaction::is_system_transaction(&self.0) + } + + fn set_system_transaction(&mut self, is_system_transaction: bool) { + self.0.set_system_transaction(is_system_transaction); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempo_alloy::primitives::transaction::Call; + + #[test] + fn tempo_tx_env_setters_update_aa_call_payload() { + let old_to = TxKind::Call(Address::with_last_byte(0xAA)); + let new_to = TxKind::Create; + let new_value = U256::from(123); + let new_input = Bytes::from_static(b"local bytecode"); + + let mut tx_env = TempoTxEnv { + inner: TxEnv { + kind: old_to, + value: U256::ONE, + data: Bytes::from_static(b"original bytecode"), + ..Default::default() + }, + tempo_tx_env: Some(Box::new(tempo_revm::TempoBatchCallEnv { + aa_calls: vec![Call { + to: old_to, + value: U256::ONE, + input: Bytes::from_static(b"original bytecode"), + }], + ..Default::default() + })), + ..Default::default() + }; + + tx_env.set_kind(new_to); + tx_env.set_value(new_value); + tx_env.set_data(new_input.clone()); + + assert_eq!(tx_env.inner.kind, new_to); + assert_eq!(tx_env.inner.value, new_value); + assert_eq!(tx_env.inner.data, new_input); + + let call = &tx_env.tempo_tx_env.as_ref().unwrap().aa_calls[0]; + assert_eq!(call.to, new_to); + assert_eq!(call.value, new_value); + assert_eq!(call.input, new_input); + } +} diff --git a/crates/evm/core/src/evm.rs b/crates/evm/core/src/evm.rs deleted file mode 100644 index a40bcd44d442f..0000000000000 --- a/crates/evm/core/src/evm.rs +++ /dev/null @@ -1,412 +0,0 @@ -use std::{ - marker::PhantomData, - ops::{Deref, DerefMut}, -}; - -use crate::{ - Env, InspectorExt, backend::DatabaseExt, constants::DEFAULT_CREATE2_DEPLOYER_CODEHASH, -}; -use alloy_consensus::constants::KECCAK_EMPTY; -use alloy_evm::{ - Evm, EvmEnv, - eth::EthEvmContext, - precompiles::{DynPrecompile, PrecompileInput, PrecompilesMap}, -}; -use alloy_primitives::{Address, Bytes, U256}; -use foundry_fork_db::DatabaseError; -use revm::{ - Context, Journal, - context::{ - BlockEnv, CfgEnv, ContextTr, CreateScheme, Evm as RevmEvm, JournalTr, LocalContext, - LocalContextTr, TxEnv, - result::{EVMError, ExecResultAndState, ExecutionResult, HaltReason, ResultAndState}, - }, - handler::{ - EthFrame, EthPrecompiles, EvmTr, FrameResult, FrameTr, Handler, ItemOrResult, - instructions::EthInstructions, - }, - inspector::{InspectorEvmTr, InspectorHandler}, - interpreter::{ - CallInput, CallInputs, CallOutcome, CallScheme, CallValue, CreateInputs, CreateOutcome, - FrameInput, Gas, InstructionResult, InterpreterResult, SharedMemory, - interpreter::EthInterpreter, interpreter_action::FrameInit, return_ok, - }, - precompile::{ - PrecompileSpecId, Precompiles, - secp256r1::{P256VERIFY, P256VERIFY_BASE_GAS_FEE}, - }, - primitives::hardfork::SpecId, -}; - -pub fn new_evm_with_inspector<'i, 'db, I: InspectorExt + ?Sized>( - db: &'db mut dyn DatabaseExt, - env: Env, - inspector: &'i mut I, -) -> FoundryEvm<'db, &'i mut I> { - let mut ctx = EthEvmContext { - journaled_state: { - let mut journal = Journal::new(db); - journal.set_spec_id(env.evm_env.cfg_env.spec); - journal - }, - block: env.evm_env.block_env, - cfg: env.evm_env.cfg_env, - tx: env.tx, - chain: (), - local: LocalContext::default(), - error: Ok(()), - }; - ctx.cfg.tx_chain_id_check = true; - let spec = ctx.cfg.spec; - - let mut evm = FoundryEvm { - inner: RevmEvm::new_with_inspector( - ctx, - inspector, - EthInstructions::default(), - get_precompiles(spec), - ), - }; - - inject_precompiles(&mut evm); - - evm -} - -pub fn new_evm_with_existing_context<'a>( - ctx: EthEvmContext<&'a mut dyn DatabaseExt>, - inspector: &'a mut dyn InspectorExt, -) -> FoundryEvm<'a, &'a mut dyn InspectorExt> { - let spec = ctx.cfg.spec; - - let mut evm = FoundryEvm { - inner: RevmEvm::new_with_inspector( - ctx, - inspector, - EthInstructions::default(), - get_precompiles(spec), - ), - }; - - inject_precompiles(&mut evm); - - evm -} - -/// Conditionally inject additional precompiles into the EVM context. -fn inject_precompiles(evm: &mut FoundryEvm<'_, impl InspectorExt>) { - if evm.inspector().is_odyssey() { - evm.precompiles_mut().apply_precompile(P256VERIFY.address(), |_| { - // Create a wrapper function that adapts the new API - let precompile_fn = |input: PrecompileInput<'_>| -> Result<_, _> { - P256VERIFY.precompile()(input.data, P256VERIFY_BASE_GAS_FEE) - }; - Some(DynPrecompile::from(precompile_fn)) - }); - } -} - -/// Get the precompiles for the given spec. -fn get_precompiles(spec: SpecId) -> PrecompilesMap { - PrecompilesMap::from_static( - EthPrecompiles { - precompiles: Precompiles::new(PrecompileSpecId::from_spec_id(spec)), - spec, - } - .precompiles, - ) -} - -/// Get the call inputs for the CREATE2 factory. -fn get_create2_factory_call_inputs( - salt: U256, - inputs: &CreateInputs, - deployer: Address, -) -> CallInputs { - let calldata = [&salt.to_be_bytes::<32>()[..], &inputs.init_code[..]].concat(); - CallInputs { - caller: inputs.caller, - bytecode_address: deployer, - target_address: deployer, - scheme: CallScheme::Call, - value: CallValue::Transfer(inputs.value), - input: CallInput::Bytes(calldata.into()), - gas_limit: inputs.gas_limit, - is_static: false, - return_memory_offset: 0..0, - } -} - -pub struct FoundryEvm<'db, I: InspectorExt> { - #[allow(clippy::type_complexity)] - pub inner: RevmEvm< - EthEvmContext<&'db mut dyn DatabaseExt>, - I, - EthInstructions>, - PrecompilesMap, - EthFrame, - >, -} -impl FoundryEvm<'_, I> { - pub fn run_execution( - &mut self, - frame: FrameInput, - ) -> Result> { - let mut handler = FoundryHandler::::default(); - - // Create first frame - let memory = - SharedMemory::new_with_buffer(self.inner.ctx().local().shared_memory_buffer().clone()); - let first_frame_input = FrameInit { depth: 0, memory, frame_input: frame }; - - // Run execution loop - let mut frame_result = handler.inspect_run_exec_loop(&mut self.inner, first_frame_input)?; - - // Handle last frame result - handler.last_frame_result(&mut self.inner, &mut frame_result)?; - - Ok(frame_result) - } -} - -impl<'db, I: InspectorExt> Evm for FoundryEvm<'db, I> { - type Precompiles = PrecompilesMap; - type Inspector = I; - type DB = &'db mut dyn DatabaseExt; - type Error = EVMError; - type HaltReason = HaltReason; - type Spec = SpecId; - type Tx = TxEnv; - - fn chain_id(&self) -> u64 { - self.inner.ctx.cfg.chain_id - } - - fn block(&self) -> &BlockEnv { - &self.inner.block - } - - fn db_mut(&mut self) -> &mut Self::DB { - &mut self.inner.ctx.journaled_state.database - } - - fn precompiles(&self) -> &Self::Precompiles { - &self.inner.precompiles - } - - fn precompiles_mut(&mut self) -> &mut Self::Precompiles { - &mut self.inner.precompiles - } - - fn inspector(&self) -> &Self::Inspector { - &self.inner.inspector - } - - fn inspector_mut(&mut self) -> &mut Self::Inspector { - &mut self.inner.inspector - } - - fn set_inspector_enabled(&mut self, _enabled: bool) { - unimplemented!("FoundryEvm is always inspecting") - } - - fn transact_raw( - &mut self, - tx: Self::Tx, - ) -> Result, Self::Error> { - self.inner.ctx.tx = tx; - - let mut handler = FoundryHandler::::default(); - let result = handler.inspect_run(&mut self.inner)?; - - Ok(ResultAndState::new(result, self.inner.ctx.journaled_state.inner.state.clone())) - } - - fn transact_system_call( - &mut self, - _caller: Address, - _contract: Address, - _data: Bytes, - ) -> Result, Self::Error> { - unimplemented!() - } - - fn finish(self) -> (Self::DB, EvmEnv) - where - Self: Sized, - { - let Context { block: block_env, cfg: cfg_env, journaled_state, .. } = self.inner.ctx; - - (journaled_state.database, EvmEnv { block_env, cfg_env }) - } -} - -impl<'db, I: InspectorExt> Deref for FoundryEvm<'db, I> { - type Target = Context; - - fn deref(&self) -> &Self::Target { - &self.inner.ctx - } -} - -impl DerefMut for FoundryEvm<'_, I> { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.inner.ctx - } -} - -pub struct FoundryHandler<'db, I: InspectorExt> { - create2_overrides: Vec<(usize, CallInputs)>, - _phantom: PhantomData<(&'db mut dyn DatabaseExt, I)>, -} - -impl Default for FoundryHandler<'_, I> { - fn default() -> Self { - Self { create2_overrides: Vec::new(), _phantom: PhantomData } - } -} - -// Blanket Handler implementation for FoundryHandler, needed for implementing the InspectorHandler -// trait. -impl<'db, I: InspectorExt> Handler for FoundryHandler<'db, I> { - type Evm = RevmEvm< - EthEvmContext<&'db mut dyn DatabaseExt>, - I, - EthInstructions>, - PrecompilesMap, - EthFrame, - >; - type Error = EVMError; - type HaltReason = HaltReason; -} - -impl<'db, I: InspectorExt> FoundryHandler<'db, I> { - /// Handles CREATE2 frame initialization, potentially transforming it to use the CREATE2 - /// factory. - fn handle_create_frame( - &mut self, - evm: &mut ::Evm, - init: &mut FrameInit, - ) -> Result, ::Error> { - if let FrameInput::Create(inputs) = &init.frame_input - && let CreateScheme::Create2 { salt } = inputs.scheme - { - let (ctx, inspector) = evm.ctx_inspector(); - - if inspector.should_use_create2_factory(ctx, inputs) { - let gas_limit = inputs.gas_limit; - - // Get CREATE2 deployer. - let create2_deployer = evm.inspector().create2_deployer(); - - // Generate call inputs for CREATE2 factory. - let call_inputs = get_create2_factory_call_inputs(salt, inputs, create2_deployer); - - // Push data about current override to the stack. - self.create2_overrides.push((evm.journal().depth(), call_inputs.clone())); - - // Sanity check that CREATE2 deployer exists. - let code_hash = evm.journal_mut().load_account(create2_deployer)?.info.code_hash; - if code_hash == KECCAK_EMPTY { - return Ok(Some(FrameResult::Call(CallOutcome { - result: InterpreterResult { - result: InstructionResult::Revert, - output: Bytes::copy_from_slice( - format!("missing CREATE2 deployer: {create2_deployer}").as_bytes(), - ), - gas: Gas::new(gas_limit), - }, - memory_offset: 0..0, - }))); - } else if code_hash != DEFAULT_CREATE2_DEPLOYER_CODEHASH { - return Ok(Some(FrameResult::Call(CallOutcome { - result: InterpreterResult { - result: InstructionResult::Revert, - output: "invalid CREATE2 deployer bytecode".into(), - gas: Gas::new(gas_limit), - }, - memory_offset: 0..0, - }))); - } - - // Rewrite the frame init - init.frame_input = FrameInput::Call(Box::new(call_inputs)); - } - } - Ok(None) - } - - /// Transforms CREATE2 factory call results back into CREATE outcomes. - fn handle_create2_override( - &mut self, - evm: &mut ::Evm, - result: FrameResult, - ) -> FrameResult { - if self.create2_overrides.last().is_some_and(|(depth, _)| *depth == evm.journal().depth()) { - let (_, call_inputs) = self.create2_overrides.pop().unwrap(); - let FrameResult::Call(mut call) = result else { - unreachable!("create2 override should be a call frame"); - }; - - // Decode address from output. - let address = match call.instruction_result() { - return_ok!() => Address::try_from(call.output().as_ref()) - .map_err(|_| { - call.result = InterpreterResult { - result: InstructionResult::Revert, - output: "invalid CREATE2 factory output".into(), - gas: Gas::new(call_inputs.gas_limit), - }; - }) - .ok(), - _ => None, - }; - - FrameResult::Create(CreateOutcome { result: call.result, address }) - } else { - result - } - } -} - -impl InspectorHandler for FoundryHandler<'_, I> { - type IT = EthInterpreter; - - fn inspect_run_exec_loop( - &mut self, - evm: &mut Self::Evm, - first_frame_input: <::Frame as FrameTr>::FrameInit, - ) -> Result { - let res = evm.inspect_frame_init(first_frame_input)?; - - if let ItemOrResult::Result(frame_result) = res { - return Ok(frame_result); - } - - loop { - let call_or_result = evm.inspect_frame_run()?; - - let result = match call_or_result { - ItemOrResult::Item(mut init) => { - // Handle CREATE/CREATE2 frame initialization - if let Some(frame_result) = self.handle_create_frame(evm, &mut init)? { - return Ok(frame_result); - } - - match evm.inspect_frame_init(init)? { - ItemOrResult::Item(_) => continue, - ItemOrResult::Result(result) => result, - } - } - ItemOrResult::Result(result) => result, - }; - - // Handle CREATE2 override transformation if needed - let result = self.handle_create2_override(evm, result); - - if let Some(result) = evm.frame_return_result(result)? { - return Ok(result); - } - } - } -} diff --git a/crates/evm/core/src/evm/base.rs b/crates/evm/core/src/evm/base.rs new file mode 100644 index 0000000000000..3ee952c81f8b3 --- /dev/null +++ b/crates/evm/core/src/evm/base.rs @@ -0,0 +1,386 @@ +use crate::{ + FoundryChain, FoundryContextExt, FoundryInspectorExt, FoundryTransaction, + backend::{DatabaseExt, JournaledState}, + constants::SYSTEM_PRECOMPILE_STUB, + evm::{ + FoundryEvmFactory, FoundryEvmNetwork, IntoInstructionResult, NestedEvm, NestedEvmFor, + run_inspected_frame, + }, +}; +use alloy_evm::{Evm, EvmEnv, EvmFactory, precompiles::PrecompilesMap}; +use alloy_primitives::{Address, Bytes}; +use base_common_chains::ChainConfig; +use base_common_evm::{ + BaseContext, BaseEvm, BaseEvmFactory, BaseHaltReason, BaseHandler, BaseSpecId, BaseTransaction, + BaseTransactionError, BaseUpgrade, L1BlockInfo, +}; +use base_common_network::Base; +// Only the tests below need this crate, but Cargo forbids optional dev-dependencies, so it is an +// optional regular dependency that the `base` feature turns on. +use base_common_precompiles as _; +use foundry_evm_networks::{BASE_CODE_SENTINEL_ADDRESSES, is_base_precompile_active_at}; +use foundry_fork_db::DatabaseError; +use revm::{ + context::{ + BlockEnv, ContextTr, Journal, JournalTr, TxEnv, + result::{EVMError, HaltReason, ResultAndState}, + }, + handler::{EthFrame, EvmTr, FrameResult}, + interpreter::{FrameInput, InstructionResult, interpreter::EthInterpreter}, + state::Bytecode, +}; + +/// Base EVM network. +#[derive(Clone, Copy, Debug, Default)] +pub struct BaseEvmNetwork; + +impl FoundryEvmNetwork for BaseEvmNetwork { + type Network = Base; + type EvmFactory = BaseEvmFactory; +} + +impl IntoInstructionResult for BaseHaltReason { + fn into_instruction_result(self) -> InstructionResult { + match self { + Self::Base(eth) => eth.into(), + Self::FailedDeposit => InstructionResult::Stop, + } + } +} + +pub type BaseRevmEvm<'db, I> = BaseEvm<&'db mut dyn DatabaseExt, I, PrecompilesMap>; + +type BaseEvmHandler<'db, I> = BaseHandler< + BaseRevmEvm<'db, I>, + EVMError, + EthFrame, +>; + +/// Base precompiles installed at `upgrade` that hold state but carry no bytecode. +/// +/// Solidity emits an `extcodesize` check for high-level calls to functions without return data, +/// so a code-less precompile makes the *caller* revert before the precompile ever runs. Base +/// mainnet plants a one-byte sentinel on exactly these accounts, so mirroring it keeps local +/// execution faithful to the chain. +pub fn base_code_sentinel_addresses(upgrade: BaseUpgrade) -> impl Iterator { + BASE_CODE_SENTINEL_ADDRESSES + .iter() + .copied() + .filter(move |address| is_base_precompile_active_at(*address, upgrade)) +} + +impl FoundryChain> for L1BlockInfo { + fn clear_transaction_fee_cache(&mut self) { + self.clear_tx_l1_cost(); + } +} + +impl FoundryEvmFactory for BaseEvmFactory { + type Chain = L1BlockInfo; + type FoundryContext<'db> = BaseContext<&'db mut dyn DatabaseExt>; + + type FoundryEvm<'db, I: FoundryInspectorExt>> = BaseRevmEvm<'db, I>; + + fn create_foundry_evm_with_inspector<'db, I: FoundryInspectorExt>>( + &self, + db: &'db mut dyn DatabaseExt, + evm_env: EvmEnv, + inspector: I, + ) -> Self::FoundryEvm<'db, I> { + let upgrade = evm_env.cfg_env.spec.upgrade(); + // Nested EVMs use the same fork database, so execution chain-ID overrides cannot + // change the source-chain registry configuration when rebuilding their precompiles. + let activation_admin = self.activation_admin_address().or_else(|| { + ChainConfig::activation_admin_address_for_upgrade_by_chain_id( + db.active_fork_source_chain_id().unwrap_or(evm_env.cfg_env.chain_id), + upgrade, + ) + }); + let factory = self.with_activation_admin_address(activation_admin); + let mut base_evm = factory.create_evm_with_inspector(db, evm_env, inspector); + base_evm.ctx_mut().cfg.tx_chain_id_check = true; + // Preserve existing code, including sentinels already present on forks. + let sentinel = Bytecode::new_legacy(Bytes::from_static(SYSTEM_PRECOMPILE_STUB)); + let sentinel_hash = sentinel.hash_slow(); + let journal = base_evm.ctx_mut().journal_mut(); + for address in base_code_sentinel_addresses(upgrade) { + if let Ok(account) = journal.load_account_with_code(address) + && account.info.code.as_ref().is_none_or(|code| code.is_empty()) + { + journal.set_code_with_hash(address, sentinel.clone(), sentinel_hash); + } + } + base_evm + } + + fn create_nested_evm_with_inspector<'db, I>( + &self, + db: &'db mut dyn DatabaseExt, + evm_env: EvmEnv, + inspector: I, + ) -> NestedEvmFor<'db, Self> + where + I: FoundryInspectorExt> + 'db, + { + Box::new(self.create_foundry_evm_with_inspector(db, evm_env, inspector)) + } +} + +fn map_base_error(error: EVMError) -> EVMError { + match error { + EVMError::Database(db) => EVMError::Database(db), + EVMError::Header(header) => EVMError::Header(header), + EVMError::Custom(message) => EVMError::Custom(message), + EVMError::Transaction(transaction) => { + EVMError::Custom(format!("base transaction error: {transaction}")) + } + EVMError::CustomAny(error) => EVMError::CustomAny(error), + } +} + +impl<'db, I: FoundryInspectorExt>>> NestedEvm + for BaseRevmEvm<'db, I> +{ + type Spec = BaseSpecId; + type Block = BlockEnv; + type Tx = BaseTransaction; + type Chain = L1BlockInfo; + type Journal = Journal<&'db mut dyn DatabaseExt>; + + fn journal_inner_mut(&mut self) -> &mut JournaledState { + &mut self.ctx_mut().journaled_state.inner + } + + fn tx_mut(&mut self) -> &mut Self::Tx { + self.ctx_mut().tx_mut() + } + + fn chain_mut(&mut self) -> &mut Self::Chain { + &mut self.ctx_mut().chain + } + + fn journal_mut(&mut self) -> &mut Self::Journal { + &mut self.ctx_mut().journaled_state + } + + fn precompiles_mut(&mut self) -> &mut PrecompilesMap { + Evm::precompiles_mut(self) + } + + fn run_execution(&mut self, frame: FrameInput) -> Result> { + run_inspected_frame(self, BaseEvmHandler::::new(), frame).map_err(map_base_error) + } + + fn transact_raw(&mut self, tx: Self::Tx) -> eyre::Result> { + if self.ctx().cfg().disable_fee_charge + && tx.enveloped_tx().is_some_and(|enveloped| enveloped.is_empty()) + { + self.ctx_mut().chain_mut().clear_transaction_fee_cache(); + } + let ResultAndState { result, state } = + Evm::transact_raw(self, tx).map_err(map_base_error)?; + let result = result.map_haltreason(|halt| match halt { + BaseHaltReason::Base(eth) => eth, + BaseHaltReason::FailedDeposit => HaltReason::PrecompileError, + }); + Ok(ResultAndState::new(result, state)) + } + + fn to_evm_env(&self) -> EvmEnv { + self.ctx_ref().evm_clone() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + backend::{Backend, CowBackend}, + evm::with_inherited_evm, + opts::EvmOpts, + }; + use alloy_sol_types::SolCall; + use base_common_precompiles::{ + ActivationRegistryStorage, B20FactoryStorage, IActivationRegistry, NonceManagerStorage, + PolicyRegistryStorage, TxContextStorage, + }; + use foundry_config::Config; + use foundry_evm_networks::NetworkConfigs; + use revm::{ + ExecuteEvm, context::CfgEnv, inspector::NoOpInspector, primitives::TxKind, + state::AccountInfo, + }; + + fn base_env(chain_id: u64, upgrade: BaseUpgrade) -> EvmEnv { + let mut cfg = CfgEnv::new_with_spec(BaseSpecId::new(upgrade)); + cfg.chain_id = chain_id; + EvmEnv::new(cfg, BlockEnv::default()) + } + + #[test] + fn failed_deposit_maps_to_stop() { + assert_eq!( + BaseHaltReason::FailedDeposit.into_instruction_result(), + InstructionResult::Stop + ); + } + + #[test] + fn constructor_resolves_activation_admin_and_preserves_override() { + let chain_admin = + ChainConfig::activation_admin_address_for_upgrade_by_chain_id(8453, BaseUpgrade::Beryl) + .unwrap(); + let custom_admin = Address::repeat_byte(0xaa); + for (override_admin, expected_admin) in + [(None, chain_admin), (Some(custom_admin), custom_admin)] + { + let mut db = Backend::::spawn(None).unwrap(); + let mut evm = BaseEvmFactory::new(override_admin).create_foundry_evm_with_inspector( + &mut db, + base_env(8453, BaseUpgrade::Beryl), + NoOpInspector, + ); + let tx = BaseTransaction::builder() + .base( + TxEnv::builder() + .chain_id(Some(8453)) + .kind(TxKind::Call(ActivationRegistryStorage::ADDRESS)) + .data(Bytes::from(IActivationRegistry::adminCall {}.abi_encode())) + .gas_limit(100_000), + ) + .build_fill(); + let result = evm.transact_one(tx).unwrap(); + let admin = + IActivationRegistry::adminCall::abi_decode_returns(result.output().unwrap()) + .unwrap(); + assert_eq!(admin, expected_admin); + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn fork_activation_admin_uses_source_chain_in_nested_deployment() { + let (_api, handle) = + anvil::spawn(anvil::NodeConfig::test().with_chain_id(Some(8453u64))).await; + let caller = handle.genesis_accounts().next().unwrap(); + let expected = + ChainConfig::activation_admin_address_for_upgrade_by_chain_id(8453, BaseUpgrade::Beryl) + .unwrap(); + for chain_id in [31337, 84532] { + let mut opts = EvmOpts { + fork_url: Some(handle.http_endpoint()), + networks: NetworkConfigs::with_base(), + ..Default::default() + }; + opts.env.chain_id = Some(chain_id); + let fork = opts.get_fork(&Config::default(), 8453, Some(0)).unwrap(); + let backend = Backend::::spawn(Some(fork)).unwrap(); + assert_eq!(backend.active_fork_source_chain_id(), Some(8453)); + let mut db = CowBackend::new_borrowed(&backend); + let env = base_env(chain_id, BaseUpgrade::Beryl); + let mut evm = BaseEvmFactory::default().create_foundry_evm_with_inspector( + &mut db, + env, + NoOpInspector, + ); + let tx = BaseTransaction::builder() + .base( + TxEnv::builder() + .caller(caller) + .chain_id(Some(chain_id)) + .kind(TxKind::Call(ActivationRegistryStorage::ADDRESS)) + .data(Bytes::from(IActivationRegistry::adminCall {}.abi_encode())) + .gas_limit(100_000), + ) + .build_fill(); + let result = evm.transact_one(tx).unwrap(); + assert_eq!( + IActivationRegistry::adminCall::abi_decode_returns(result.output().unwrap()) + .unwrap(), + expected + ); + // Deploy initcode that calls admin() and returns it as the deployed code. + let mut initcode = vec![0x63]; + initcode.extend(IActivationRegistry::adminCall::SELECTOR); + initcode + .extend([0x60, 0xe0, 0x1b, 0x5f, 0x52, 0x60, 0x20, 0x5f, 0x60, 0x04, 0x5f, 0x73]); + initcode.extend(ActivationRegistryStorage::ADDRESS.as_slice()); + initcode.extend([0x5a, 0xfa, 0x50, 0x60, 0x20, 0x5f, 0xf3]); + with_inherited_evm::(evm.ctx_mut(), NoOpInspector, &mut |nested| { + let result = nested + .transact_raw( + BaseTransaction::builder() + .base( + TxEnv::builder() + .caller(caller) + .chain_id(Some(chain_id)) + .nonce(1) + .kind(TxKind::Create) + .data(Bytes::from(initcode.clone())) + .gas_limit(200_000), + ) + .build_fill(), + ) + .unwrap(); + assert!(result.result.is_success(), "{:?}", result.result); + assert_eq!( + IActivationRegistry::adminCall::abi_decode_returns( + result.result.output().unwrap() + ) + .unwrap(), + expected + ); + Ok(()) + }) + .unwrap(); + } + } + + #[test] + fn constructor_plants_only_active_registry_sentinels() { + for upgrade in [BaseUpgrade::Azul, BaseUpgrade::Beryl] { + let mut db = Backend::::spawn(None).unwrap(); + let mut evm = BaseEvmFactory::default().create_foundry_evm_with_inspector( + &mut db, + base_env(8453, upgrade), + NoOpInspector, + ); + for (address, registry) in [ + (ActivationRegistryStorage::ADDRESS, true), + (PolicyRegistryStorage::ADDRESS, true), + (B20FactoryStorage::ADDRESS, false), + (NonceManagerStorage::ADDRESS, false), + (TxContextStorage::ADDRESS, false), + ] { + let expected = if upgrade == BaseUpgrade::Beryl && registry { + Bytecode::new_legacy(Bytes::from_static(SYSTEM_PRECOMPILE_STUB)) + } else { + Bytecode::default() + }; + let account = evm.ctx_mut().journal_mut().load_account_with_code(address).unwrap(); + assert_eq!( + account.info.code.as_ref().unwrap(), + &expected, + "{upgrade:?}: {address}" + ); + assert_eq!(account.info.code_hash(), expected.hash_slow()); + } + } + } + + #[test] + fn constructor_preserves_existing_registry_code() { + let address = ActivationRegistryStorage::ADDRESS; + let code = Bytecode::new_legacy(Bytes::from_static(&[0x60, 0x00, 0x00])); + let code_hash = code.hash_slow(); + let mut db = Backend::::spawn(None).unwrap(); + db.insert_account_info(address, AccountInfo::default().with_code(code.clone())); + let mut evm = BaseEvmFactory::default().create_foundry_evm_with_inspector( + &mut db, + base_env(8453, BaseUpgrade::Beryl), + NoOpInspector, + ); + let account = evm.ctx_mut().journal_mut().load_account_with_code(address).unwrap(); + assert_eq!(account.info.code.as_ref().unwrap(), &code); + assert_eq!(account.info.code_hash(), code_hash); + } +} diff --git a/crates/evm/core/src/evm/eth.rs b/crates/evm/core/src/evm/eth.rs new file mode 100644 index 0000000000000..6968b07f43a85 --- /dev/null +++ b/crates/evm/core/src/evm/eth.rs @@ -0,0 +1,130 @@ +use alloy_evm::{ + EthEvm, EthEvmFactory, Evm, EvmEnv, EvmFactory, eth::EthEvmContext, precompiles::PrecompilesMap, +}; +use alloy_network::Ethereum; +use foundry_evm_networks::apply_bsc_p256_precompile; +use foundry_fork_db::DatabaseError; +use revm::{ + context::{ + BlockEnv, Evm as RevmEvm, Journal, TxEnv, + result::{EVMError, HaltReason, ResultAndState}, + }, + handler::{EthFrame, EvmTr, FrameResult, MainnetHandler, instructions::EthInstructions}, + inspector::InspectorHandler, + interpreter::{FrameInput, InstructionResult, interpreter::EthInterpreter}, + primitives::hardfork::SpecId, +}; + +use crate::{ + FoundryContextExt, FoundryInspectorExt, + backend::{DatabaseExt, JournaledState}, + evm::{ + FoundryEvmFactory, FoundryEvmNetwork, IntoInstructionResult, NestedEvm, NestedEvmFor, + run_inspected_frame, + }, +}; + +#[derive(Clone, Copy, Debug, Default)] +pub struct EthEvmNetwork; +impl FoundryEvmNetwork for EthEvmNetwork { + type Network = Ethereum; + type EvmFactory = EthEvmFactory; +} + +type EthEvmHandler<'db, I> = MainnetHandler, EVMError, EthFrame>; + +pub type EthRevmEvm<'db, I> = RevmEvm< + EthEvmContext<&'db mut dyn DatabaseExt>, + I, + EthInstructions>>, + PrecompilesMap, + EthFrame, +>; + +impl IntoInstructionResult for HaltReason { + fn into_instruction_result(self) -> InstructionResult { + self.into() + } +} + +impl FoundryEvmFactory for EthEvmFactory { + type Chain = (); + type FoundryContext<'db> = EthEvmContext<&'db mut dyn DatabaseExt>; + + type FoundryEvm<'db, I: FoundryInspectorExt>> = + EthEvm<&'db mut dyn DatabaseExt, I, Self::Precompiles>; + + fn create_foundry_evm_with_inspector<'db, I: FoundryInspectorExt>>( + &self, + db: &'db mut dyn DatabaseExt, + evm_env: EvmEnv, + inspector: I, + ) -> Self::FoundryEvm<'db, I> { + let chain_id = evm_env.cfg_env.chain_id; + let timestamp = evm_env.block_env.timestamp.saturating_to(); + let mut eth_evm = Self::default().create_evm_with_inspector(db, evm_env, inspector); + eth_evm.cfg.tx_chain_id_check = true; + let networks = eth_evm.inspector().get_networks(); + networks.inject_precompiles(eth_evm.precompiles_mut()); + apply_bsc_p256_precompile(eth_evm.precompiles_mut(), chain_id, timestamp); + eth_evm + } + + fn create_nested_evm_with_inspector<'db, I>( + &self, + db: &'db mut dyn DatabaseExt, + evm_env: EvmEnv, + inspector: I, + ) -> NestedEvmFor<'db, Self> + where + I: FoundryInspectorExt> + 'db, + { + Box::new(self.create_foundry_evm_with_inspector(db, evm_env, inspector).into_inner()) + } +} + +impl<'db, I: FoundryInspectorExt>>> NestedEvm + for EthRevmEvm<'db, I> +{ + type Spec = SpecId; + type Block = BlockEnv; + type Tx = TxEnv; + type Chain = (); + type Journal = Journal<&'db mut dyn DatabaseExt>; + + fn tx_mut(&mut self) -> &mut Self::Tx { + self.ctx_mut().tx_mut() + } + + fn journal_inner_mut(&mut self) -> &mut JournaledState { + &mut self.ctx_mut().journaled_state.inner + } + + fn chain_mut(&mut self) -> &mut Self::Chain { + &mut self.ctx_mut().chain + } + + fn precompiles_mut(&mut self) -> &mut alloy_evm::precompiles::PrecompilesMap { + &mut self.precompiles + } + + fn journal_mut(&mut self) -> &mut Self::Journal { + &mut self.ctx_mut().journaled_state + } + + fn run_execution(&mut self, frame: FrameInput) -> Result> { + run_inspected_frame(self, EthEvmHandler::::default(), frame) + } + + fn transact_raw(&mut self, tx: Self::Tx) -> eyre::Result { + self.set_tx(tx); + + let result = EthEvmHandler::::default().inspect_run(self)?; + + Ok(ResultAndState::new(result, self.ctx.journaled_state.inner.state.clone())) + } + + fn to_evm_env(&self) -> EvmEnv { + self.ctx_ref().evm_clone() + } +} diff --git a/crates/evm/core/src/evm/mod.rs b/crates/evm/core/src/evm/mod.rs new file mode 100644 index 0000000000000..4ac360a811ac3 --- /dev/null +++ b/crates/evm/core/src/evm/mod.rs @@ -0,0 +1,626 @@ +//! Shared EVM traits, associated types, and execution helpers. +//! +//! Each network module owns its network marker and concrete EVM implementations. + +use crate::{ + FoundryBlock, FoundryChain, FoundryContextExt, FoundryInspectorExt, FoundryJournal, + FoundryTransaction, FromAnyRpcTransaction, + backend::{DatabaseExt, JournaledState}, +}; +use alloy_consensus::{SignableTransaction, Signed, transaction::SignerRecoverable}; +use alloy_evm::{Evm, EvmEnv, EvmFactory, FromRecoveredTx, precompiles::PrecompilesMap}; +use alloy_network::Network; +use alloy_primitives::{Address, Signature, U256}; +use alloy_rlp::Decodable; +use foundry_common::{FoundryReceiptResponse, FoundryTransactionBuilder, fmt::UIfmt}; +use foundry_config::ExecutionSpec; +use foundry_fork_db::{DatabaseError, ForkBlockEnv}; +use revm::{ + Database, + context::{ + ContextTr, JournalTr, LocalContextTr, + result::{EVMError, HaltReason, ResultAndState}, + }, + handler::{EvmTr, FrameResult}, + inspector::{InspectorEvmTr, InspectorHandler, NoOpInspector}, + interpreter::{ + CallInput, CallInputs, CallScheme, CallValue, CreateInputs, FrameInput, GasTracker, + InstructionResult, SharedMemory, interpreter::EthInterpreter, + interpreter_action::FrameInit, + }, + primitives::hardfork::SpecId, + state::{AccountStatus, EvmState}, +}; +use serde::{Deserialize, Serialize}; +use std::{fmt::Debug, ops::DerefMut}; + +#[cfg(feature = "base")] +pub mod base; +pub mod eth; +#[cfg(feature = "monad")] +pub mod monad; +#[cfg(feature = "optimism")] +pub mod op; +pub mod tempo; + +pub use eth::*; +pub use tempo::*; + +#[cfg(feature = "base")] +pub use base::*; + +#[cfg(feature = "monad")] +pub use monad::*; + +#[cfg(feature = "optimism")] +pub use op::*; + +/// Foundry's compatibility trait associating a [`Network`] with a [`FoundryEvmFactory`]. +pub trait FoundryEvmNetwork: Copy + Debug + Default + 'static { + type Network: Network< + TxEnvelope: Decodable + + SignerRecoverable + + From::UnsignedTx>> + + for<'d> Deserialize<'d> + + Serialize + + UIfmt, + UnsignedTx: SignableTransaction, + TransactionRequest: FoundryTransactionBuilder + + for<'d> Deserialize<'d> + + Serialize, + ReceiptResponse: FoundryReceiptResponse, + >; + type EvmFactory: FoundryEvmFactory::TxEnvelope>>; +} + +pub trait FoundryEvmFactory: + EvmFactory< + Spec: Into + ExecutionSpec + Default + Copy + Unpin + Send + 'static, + BlockEnv: FoundryBlock + ForkBlockEnv + Default + Unpin, + Tx: Clone + Debug + FoundryTransaction + FromAnyRpcTransaction + Default + Send + Sync, + HaltReason: IntoInstructionResult, + Precompiles = PrecompilesMap, + > + Clone + + Debug + + Default + + 'static +{ + /// Chain type for EVM's context created by this factory. + type Chain: FoundryChain; + + /// Foundry Context abstraction + type FoundryContext<'db>: FoundryContextExt< + Block = Self::BlockEnv, + Tx = Self::Tx, + Spec = Self::Spec, + Chain = Self::Chain, + Journal: FoundryJournal, + Db: DatabaseExt, + > + where + Self: 'db; + + /// The Foundry-wrapped EVM type produced by this factory. + type FoundryEvm<'db, I: FoundryInspectorExt>>: Evm< + DB = &'db mut dyn DatabaseExt, + Tx = Self::Tx, + BlockEnv = Self::BlockEnv, + Spec = Self::Spec, + HaltReason = Self::HaltReason, + Precompiles = PrecompilesMap, + > + DerefMut> + where + Self: 'db; + + /// Creates a Foundry-wrapped EVM with the given inspector. + /// + /// Callers carrying execution context must install it through the returned context's + /// `chain_mut` before executing. This also preserves OP's block-derived L1 fee information. + fn create_foundry_evm_with_inspector<'db, I: FoundryInspectorExt>>( + &self, + db: &'db mut dyn DatabaseExt, + evm_env: EvmEnv, + inspector: I, + ) -> Self::FoundryEvm<'db, I>; + + /// Creates a Foundry-wrapped nested EVM without an inspector. + fn create_nested_evm<'db>( + &self, + db: &'db mut dyn DatabaseExt, + evm_env: EvmEnv, + ) -> NestedEvmFor<'db, Self> { + self.create_nested_evm_with_inspector(db, evm_env, NoOpInspector) + } + + /// Creates a Foundry-wrapped nested EVM with the given inspector. + /// Install inherited chain state with [`NestedEvm::chain_mut`] before executing or restoring + /// journal-derived state. + fn create_nested_evm_with_inspector<'db, I>( + &self, + db: &'db mut dyn DatabaseExt, + evm_env: EvmEnv, + inspector: I, + ) -> NestedEvmFor<'db, Self> + where + I: FoundryInspectorExt> + 'db; + + /// Updates the execution spec and gas parameters using this family's configuration. + /// This does not reconstruct the instruction table or precompile registry. + fn set_execution_spec(context: &mut Self::FoundryContext<'_>, spec: Self::Spec) { + context.set_spec_and_gas_params(spec); + } +} + +/// Object-safe EVM operations used by nested execution and fork replay. +/// +/// This abstracts over the concrete EVM type (`FoundryEvm`, future `TempoEvm`, etc.) +/// so that cheatcode impls can build and run nested EVMs without knowing the concrete type. +pub trait NestedEvm { + /// The spec type. + type Spec; + /// The block environment type. + type Block; + /// The transaction environment type. + type Tx: FoundryTransaction; + /// Chain context identifying the active transaction position. + type Chain: FoundryChain; + /// The Journal type, which may own Monad's reserve-balance-tracker state. + type Journal: FoundryJournal; + /// Returns a mutable reference to the journal inner state (`JournaledState`). + fn journal_inner_mut(&mut self) -> &mut JournaledState; + + /// Returns a mutable reference to the transaction environment. + fn tx_mut(&mut self) -> &mut Self::Tx; + + /// Returns a mutable reference to the chain-position context. + fn chain_mut(&mut self) -> &mut Self::Chain; + + /// Returns the precompile map. + fn precompiles_mut(&mut self) -> &mut PrecompilesMap; + + /// Returns a mutable reference to the Journal. + fn journal_mut(&mut self) -> &mut Self::Journal; + + /// Runs a single execution frame (create or call) through the EVM handler loop. + fn run_execution(&mut self, frame: FrameInput) -> Result>; + + /// Executes a full transaction with the given tx env. + fn transact_raw(&mut self, tx: Self::Tx) -> eyre::Result>; + + /// Replays a transaction, skipping unsupported system envelopes. + /// + /// `is_system` preserves the RPC envelope classification that conversion to `Self::Tx` may + /// discard. Returning `None` must not mutate the EVM, database, or inspector. + fn transact_replay( + &mut self, + tx: Self::Tx, + is_system: bool, + ) -> eyre::Result>> { + if is_system && !tx.is_deposit() { + return Ok(None); + } + self.transact_raw(tx).map(Some) + } + + fn to_evm_env(&self) -> EvmEnv; +} + +/// Converts a network-specific halt reason into an [`InstructionResult`]. +pub trait IntoInstructionResult { + fn into_instruction_result(self) -> InstructionResult; +} + +/// Convenience type aliases for accessing associated types through [`FoundryEvmNetwork`]. +pub type EvmFactoryFor = ::EvmFactory; +pub type FoundryContextFor<'db, FEN> = + as FoundryEvmFactory>::FoundryContext<'db>; +pub type TxEnvFor = as EvmFactory>::Tx; +pub type HaltReasonFor = as EvmFactory>::HaltReason; +pub type SpecFor = as EvmFactory>::Spec; +pub type BlockEnvFor = as EvmFactory>::BlockEnv; +pub type PrecompilesFor = as EvmFactory>::Precompiles; +pub type EvmEnvFor = EvmEnv, BlockEnvFor>; +pub type NetworkFor = ::Network; +pub type TxEnvelopeFor = as Network>::TxEnvelope; +pub type TransactionRequestFor = as Network>::TransactionRequest; +pub type TransactionResponseFor = as Network>::TransactionResponse; +pub type BlockResponseFor = as Network>::BlockResponse; + +pub type ChainFor = as FoundryEvmFactory>::Chain; + +/// Boxed nested EVM produced by a Foundry EVM factory. +pub type NestedEvmFor<'db, F> = Box< + dyn NestedEvm< + Spec = ::Spec, + Block = ::BlockEnv, + Tx = ::Tx, + Chain = ::Chain, + Journal = <::FoundryContext<'db> as ContextTr>::Journal, + > + 'db, +>; + +/// Closure type used by `CheatcodesExecutor` methods that run nested EVM operations. +pub type NestedEvmClosure<'a, F> = &'a mut dyn for<'j> FnMut( + &mut dyn NestedEvm< + Spec = ::Spec, + Block = ::BlockEnv, + Tx = ::Tx, + Chain = ::Chain, + Journal = <::FoundryContext<'j> as ContextTr>::Journal, + >, +) + -> Result<(), EVMError>; + +/// Nested EVM closure for a Foundry EVM network. +pub type NestedEvmClosureFor<'a, FEN> = NestedEvmClosure<'a, EvmFactoryFor>; + +/// Runs a child operation with the parent's environment, journal, and native chain state. +/// +/// Publishes child environment, journal, and chain changes only when the operation returns `Ok`. +/// This does not roll back database or inspector effects: callers retain their existing ownership +/// of those effects. The outer transaction environment is not replaced. +/// +/// Both inspector adapters use this operation so inheritance and write-back remain paired. The +/// existing Monad journal bridge is retained here until native journal lifecycle ownership +/// migrates. +pub fn with_inherited_evm( + ecx: &mut F::FoundryContext<'_>, + inspector: I, + f: NestedEvmClosure<'_, F>, +) -> Result<(), EVMError> +where + F: FoundryEvmFactory, + I: for<'db> FoundryInspectorExt>, +{ + let evm_env = ecx.evm_clone(); + let chain_context = ecx.chain().clone(); + #[cfg(feature = "monad")] + let mut reserve_balance = FoundryJournal::capture_reserve_balance(ecx.journal()); + let (evm_env, journaled_state, chain_context) = { + let (db, journaled_state) = ecx.db_journal_inner_mut(); + let journaled_state = journaled_state.clone(); + let mut evm = F::default().create_nested_evm_with_inspector(db, evm_env, inspector); + *evm.chain_mut() = chain_context; + *evm.journal_inner_mut() = journaled_state; + #[cfg(feature = "monad")] + { + FoundryJournal::restore_reserve_balance(evm.journal_mut(), reserve_balance); + refresh_nested_chain_journal(&mut *evm); + } + f(&mut *evm)?; + #[cfg(feature = "monad")] + { + reserve_balance = FoundryJournal::capture_reserve_balance(evm.journal_mut()); + } + (evm.to_evm_env(), evm.journal_inner_mut().clone(), evm.chain_mut().clone()) + }; + ecx.set_journal_inner(journaled_state); + ecx.set_evm(evm_env); + *ecx.chain_mut() = chain_context; + #[cfg(feature = "monad")] + { + FoundryJournal::restore_reserve_balance(ecx.journal_mut(), reserve_balance); + refresh_chain_journal(ecx); + } + Ok(()) +} + +/// Prepares account state for execution across a synthetic transaction boundary. +/// +/// Preserve account flags, including local creation, while making accounts outside the protocol +/// warm-address set cold. All storage starts cold with its current value as the child's original +/// value. The parent's state is unchanged. +pub fn prepare_child_state(journal: &JournaledState) -> EvmState { + let mut state = journal.state.clone(); + for (address, account) in &mut state { + if journal.warm_addresses.is_cold(address) { + account.mark_cold(); + } + for slot in account.storage.values_mut() { + slot.is_cold = true; + slot.original_value = slot.present_value(); + } + } + state +} + +/// Merges a child's returned account state into its suspended parent. +/// +/// Preserve parent warmth and original storage values, import child account flags and current +/// values, and optionally remove parent accounts and slots absent from the child. Newly loaded +/// accounts and slots keep their child metadata. This operates on the EVM's returned state, not on +/// an unfiltered write set; the caller retains responsibility for execution errors and +/// family-specific reconciliation. +pub fn merge_child_state(parent: &mut EvmState, child: EvmState, remove_absent: bool) { + if remove_absent { + parent.retain(|address, parent_account| { + let Some(child_account) = child.get(address) else { return false }; + parent_account.storage.retain(|key, _| child_account.storage.contains_key(key)); + true + }); + } + + for (address, mut account) in child { + let Some(parent_account) = parent.get_mut(&address) else { + parent.insert(address, account); + continue; + }; + if account.status.contains(AccountStatus::Cold) + && !parent_account.status.contains(AccountStatus::Cold) + { + account.status -= AccountStatus::Cold; + } + parent_account.info = account.info; + parent_account.status |= account.status; + for (key, slot) in account.storage { + let Some(parent_slot) = parent_account.storage.get_mut(&key) else { + parent_account.storage.insert(key, slot); + continue; + }; + parent_slot.present_value = slot.present_value(); + parent_slot.is_cold &= slot.is_cold; + } + } +} + +/// Runs a nested frame with inspection and settles its gas into the parent frame. +pub(crate) fn run_inspected_frame( + evm: &mut H::Evm, + mut handler: H, + frame_input: FrameInput, +) -> Result +where + H: InspectorHandler, + H::Evm: InspectorEvmTr, +{ + let memory = + SharedMemory::new_with_buffer(evm.ctx_ref().local().shared_memory_buffer().clone()); + let first_frame_input = FrameInit { depth: 0, memory, frame_input }; + let mut frame_result = handler.inspect_run_exec_loop(evm, first_frame_input)?; + let mut parent_gas = GasTracker::new( + frame_result.gas().limit(), + frame_result.gas().remaining(), + frame_result.gas().reservoir(), + ); + handler.last_frame_result(evm, &mut frame_result, &mut parent_gas)?; + Ok(frame_result) +} + +/// Get the call inputs for the CREATE2 factory. +pub fn get_create2_factory_call_inputs( + salt: U256, + inputs: &CreateInputs, + deployer: Address, + journal: &mut T, +) -> Result::Error> { + let calldata = [&salt.to_be_bytes::<32>()[..], &inputs.init_code()[..]].concat(); + let account = journal.load_account_with_code(deployer)?; + Ok(CallInputs { + caller: inputs.caller(), + bytecode_address: deployer, + known_bytecode: (account.info.code_hash(), account.info.code.clone().unwrap_or_default()), + target_address: deployer, + scheme: CallScheme::Call, + value: CallValue::Transfer(inputs.value()), + input: CallInput::Bytes(calldata.into()), + gas_limit: inputs.gas_limit(), + reservoir: inputs.reservoir(), + is_static: false, + return_memory_offset: 0..0, + charged_new_account_state_gas: false, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backend::Backend; + use alloy_evm::EthEvmFactory; + use revm::{ + context::Transaction, + state::{Account, AccountInfo, EvmStorageSlot, TransactionId}, + }; + + #[cfg(feature = "monad")] + use alloy_monad_evm::MonadEvmFactory; + #[cfg(feature = "monad")] + use monad_revm::{MonadHardfork, MonadJournalTr, reserve_balance::tracker::ReserveBalanceInit}; + #[cfg(feature = "monad")] + use revm::context::{BlockEnv, CfgEnv}; + + #[test] + fn inherited_journal_publishes_only_after_success() { + let address = Address::with_last_byte(0x42); + for succeeds in [false, true] { + let mut db = Backend::::spawn(None).unwrap(); + let mut parent = EthEvmFactory::default().create_foundry_evm_with_inspector( + &mut db, + EvmEnv::default(), + NoOpInspector, + ); + parent.journaled_state.inner.depth = 3; + parent + .journaled_state + .inner + .state + .insert(address, Account::from(AccountInfo::from_balance(U256::from(7)))); + let caller = parent.tx().caller(); + let result = + with_inherited_evm::(&mut parent, NoOpInspector, &mut |child| { + assert_eq!(child.journal_inner_mut().depth, 3); + assert_eq!( + child.journal_inner_mut().state[&address].info.balance, + U256::from(7) + ); + child.journal_inner_mut().depth = 4; + child.journal_inner_mut().state.get_mut(&address).unwrap().info.balance = + U256::from(9); + child.tx_mut().caller = address; + if succeeds { Ok(()) } else { Err(EVMError::Custom("abort child".into())) } + }); + assert_eq!(result.is_ok(), succeeds); + assert_eq!(parent.journaled_state.inner.depth, if succeeds { 4 } else { 3 }); + assert_eq!( + parent.journaled_state.inner.state[&address].info.balance, + U256::from(if succeeds { 9 } else { 7 }) + ); + assert_eq!(parent.tx().caller(), caller); + } + } + + #[cfg(feature = "monad")] + #[test] + fn inherited_monad_tracker_and_chain_publish_together() { + let sender = Address::with_last_byte(0x42); + for succeeds in [false, true] { + let mut db = Backend::::spawn(None).unwrap(); + let mut parent = MonadEvmFactory::default().create_foundry_evm_with_inspector( + &mut db, + EvmEnv::new(CfgEnv::new_with_spec(MonadHardfork::MonadNine), BlockEnv::default()), + NoOpInspector, + ); + let account = Account::from(AccountInfo::from_balance(U256::from(12))); + let chain = parent.chain().clone(); + parent.journaled_state.reserve_balance_mut().init(ReserveBalanceInit { + chain: &chain, + spec: MonadHardfork::MonadNine, + sender, + effective_gas_price: 0, + gas_limit: 0, + sender_is_delegated: false, + sender_account: Some(&account), + }); + parent.journaled_state.inner.state.insert(sender, account); + let tracker = parent.journaled_state.reserve_balance().clone(); + let result = with_inherited_evm::( + &mut parent, + NoOpInspector, + &mut |child| { + assert_eq!(child.journal_mut().reserve_balance(), &tracker); + child.chain_mut().parent_senders_and_authorities.insert(sender); + child.journal_inner_mut().state.get_mut(&sender).unwrap().info.balance = + U256::from(9); + if succeeds { Ok(()) } else { Err(EVMError::Custom("abort child".into())) } + }, + ); + assert_eq!(result.is_ok(), succeeds); + if succeeds { + assert!(parent.chain().parent_senders_and_authorities.contains(&sender)); + assert!(parent.journaled_state.reserve_balance().has_violation()); + } else { + assert_eq!(parent.chain(), &chain); + assert_eq!(parent.journaled_state.reserve_balance(), &tracker); + assert_eq!( + parent.journaled_state.inner.state[&sender].info.balance, + U256::from(12) + ); + } + } + } + + #[test] + fn preparation_preserves_creation_and_protocol_warmth() { + let address = Address::with_last_byte(0x42); + let protocol_address = Address::with_last_byte(0x43); + let key = U256::ONE; + let mut account = Account::from(AccountInfo::default()); + account.mark_created_locally(); + account.mark_touch(); + account.storage.insert( + key, + EvmStorageSlot::new_changed(U256::from(3), U256::from(7), TransactionId::ZERO), + ); + let mut journal = JournaledState::default(); + journal.state.insert(address, account.clone()); + journal.state.insert(protocol_address, account); + journal.warm_addresses.set_coinbase(protocol_address); + let before = journal.state.clone(); + + let child = prepare_child_state(&journal); + + assert_eq!(journal.state, before); + assert!(child[&address].is_created_locally()); + assert!(child[&address].is_touched()); + assert!(child[&address].status.contains(AccountStatus::Cold)); + assert!(!child[&protocol_address].status.contains(AccountStatus::Cold)); + for account in child.values() { + assert_eq!(account.storage[&key].original_value, U256::from(7)); + assert_eq!(account.storage[&key].present_value, U256::from(7)); + assert!(account.storage[&key].is_cold); + } + } + + #[test] + fn settlement_preserves_parent_original_values_and_combines_warmth() { + let address = Address::with_last_byte(0x42); + let key = U256::ONE; + for parent_cold in [false, true] { + for child_cold in [false, true] { + let mut account = Account::from(AccountInfo::default()); + account.status.set(AccountStatus::Cold, parent_cold); + account.mark_created_locally(); + let mut slot = + EvmStorageSlot::new_changed(U256::from(3), U256::from(7), TransactionId::ZERO); + slot.is_cold = parent_cold; + account.storage.insert(key, slot); + let mut parent = EvmState::from_iter([(address, account)]); + let mut account = Account::from(AccountInfo::from_balance(U256::from(9))); + account.status.set(AccountStatus::Cold, child_cold); + account.mark_touch(); + let mut slot = + EvmStorageSlot::new_changed(U256::from(7), U256::from(11), TransactionId::ZERO); + slot.is_cold = child_cold; + account.storage.insert(key, slot); + + merge_child_state(&mut parent, EvmState::from_iter([(address, account)]), false); + + let account = &parent[&address]; + assert!(account.is_created_locally()); + assert!(account.is_touched()); + assert_eq!(account.info.balance, U256::from(9)); + // Account flags are unioned; a cold parent retains its flag until journal access. + assert_eq!(account.status.contains(AccountStatus::Cold), parent_cold); + assert_eq!(account.storage[&key].original_value, U256::from(3)); + assert_eq!(account.storage[&key].present_value, U256::from(11)); + assert_eq!(account.storage[&key].is_cold, parent_cold && child_cold); + } + } + } + + #[test] + fn settlement_only_removes_state_absent_from_child_when_requested() { + let retained_address = Address::with_last_byte(0x42); + let removed_address = Address::with_last_byte(0x43); + let retained_key = U256::ONE; + let removed_key = U256::from(2); + let mut retained_account = Account::from(AccountInfo::default()); + retained_account + .storage + .insert(retained_key, EvmStorageSlot::new(U256::ONE, TransactionId::ZERO)); + retained_account + .storage + .insert(removed_key, EvmStorageSlot::new(U256::from(2), TransactionId::ZERO)); + let mut parent = EvmState::from_iter([ + (retained_address, retained_account), + (removed_address, Account::from(AccountInfo::default())), + ]); + let mut child_account = Account::from(AccountInfo::default()); + child_account + .storage + .insert(retained_key, EvmStorageSlot::new(U256::ONE, TransactionId::ZERO)); + + let child = EvmState::from_iter([(retained_address, child_account)]); + + let mut retained = parent.clone(); + merge_child_state(&mut retained, child.clone(), false); + + assert!(retained.contains_key(&removed_address)); + assert!(retained[&retained_address].storage.contains_key(&removed_key)); + + merge_child_state(&mut parent, child, true); + + assert!(!parent.contains_key(&removed_address)); + assert!(parent[&retained_address].storage.contains_key(&retained_key)); + assert!(!parent[&retained_address].storage.contains_key(&removed_key)); + } +} diff --git a/crates/evm/core/src/evm/monad.rs b/crates/evm/core/src/evm/monad.rs new file mode 100644 index 0000000000000..437a097f11ee2 --- /dev/null +++ b/crates/evm/core/src/evm/monad.rs @@ -0,0 +1,1329 @@ +use alloy_consensus::BlockHeader; +use alloy_evm::{Evm, EvmEnv, EvmFactory, FromRecoveredTx}; +use alloy_monad_evm::{MonadEvm, MonadEvmFactory, MonadPrecompilesMap}; +use alloy_network::{BlockResponse, Ethereum, TransactionResponse}; +use alloy_provider::Provider; +use alloy_rpc_types::BlockTransactions; +use alloy_sol_types::SolCall; +use eyre::WrapErr; +use foundry_fork_db::DatabaseError; +use monad_revm::{ + MonadBuilder, MonadCfgEnv, MonadChainContext, MonadContext, MonadEvm as RevmMonadEvm, + MonadHardfork, MonadJournal, MonadJournalTr, + api::block::{ + syscall_on_epoch_change_calldata, syscall_reward_calldata, syscall_snapshot_calldata, + }, + handler::MonadHandler, + instructions::MonadInstructions, + monad_context_with_db, + staking::{ + STAKING_ADDRESS, + constants::SYSTEM_ADDRESS, + interface::IMonadStaking::{ + syscallOnEpochChangeCall, syscallRewardCall, syscallSnapshotCall, + }, + }, +}; +use revm::{ + context::{ + BlockEnv, ContextTr, Transaction, TransactionType, TxEnv, + journaled_state::account::JournaledAccountTr, + result::{EVMError, ResultAndState}, + }, + context_interface::{Cfg, ContextSetters, transaction::AuthorizationTr}, + handler::{EthFrame, EvmTr, FrameResult}, + inspector::{InspectSystemCallEvm, Inspector, InspectorHandler}, + interpreter::FrameInput, + primitives::{Address, Bytes, HashSet, U256}, +}; + +use crate::{ + FoundryChain, FoundryContextExt, FoundryInspectorExt, FoundryJournal, + backend::{DatabaseExt, JournaledState}, + evm::{ + BlockResponseFor, ChainFor, FoundryEvmFactory, FoundryEvmNetwork, NestedEvm, NestedEvmFor, + TxEnvFor, run_inspected_frame, + }, +}; + +#[derive(Clone, Copy, Debug, Default)] +pub struct MonadEvmNetwork; +impl FoundryEvmNetwork for MonadEvmNetwork { + type Network = Ethereum; + type EvmFactory = MonadEvmFactory; +} + +type MonadEvmHandler<'db, I> = + MonadHandler, EVMError, EthFrame>; + +pub type MonadRevmEvm<'db, I> = RevmMonadEvm< + MonadContext<&'db mut dyn DatabaseExt>, + I, + MonadInstructions>>, + MonadPrecompilesMap, +>; + +impl FoundryChain for MonadChainContext { + fn for_transaction(tx: &TxEnv) -> Self { + monad_context_from_participants( + Default::default(), + Default::default(), + std::slice::from_ref(tx), + 0, + ) + } + + fn for_block( + grandparent: &[TxEnv], + parent: &[TxEnv], + current: &[TxEnv], + current_tx_index: usize, + ) -> Self { + monad_context_from_participants( + monad_block_participants(grandparent), + monad_block_participants(parent), + current, + current_tx_index, + ) + } + + fn refresh_journal(&self, journal: &mut J) { + let mut tracker = journal.capture_reserve_balance(); + tracker.rebase(self, journal.evm_state()); + journal.restore_reserve_balance(tracker); + } +} + +impl FoundryEvmFactory for MonadEvmFactory { + type Chain = MonadChainContext; + + type FoundryContext<'db> = MonadContext<&'db mut dyn DatabaseExt>; + + type FoundryEvm<'db, I: FoundryInspectorExt>> = + MonadEvm<&'db mut dyn DatabaseExt, I>; + + fn create_foundry_evm_with_inspector<'db, I: FoundryInspectorExt>>( + &self, + db: &'db mut dyn DatabaseExt, + evm_env: EvmEnv, + inspector: I, + ) -> Self::FoundryEvm<'db, I> { + let mut monad_evm = self.create_evm_with_inspector(db, evm_env, inspector); + monad_evm.cfg.tx_chain_id_check = true; + monad_evm + } + + fn create_nested_evm_with_inspector<'db, I>( + &self, + db: &'db mut dyn DatabaseExt, + evm_env: EvmEnv, + inspector: I, + ) -> NestedEvmFor<'db, Self> + where + I: FoundryInspectorExt> + 'db, + { + let spec = evm_env.cfg_env.spec; + let monad_cfg = MonadCfgEnv::from(evm_env.cfg_env); + let mut evm = monad_context_with_db(db) + .with_block(evm_env.block_env) + .with_cfg(monad_cfg) + .build_monad_with_inspector(inspector) + .with_precompiles(MonadPrecompilesMap::new_with_spec(spec)); + + evm.0.ctx.cfg.tx_chain_id_check = true; + Box::new(evm) + } +} + +impl<'db, I: FoundryInspectorExt>>> NestedEvm + for MonadRevmEvm<'db, I> +{ + type Spec = MonadHardfork; + type Block = BlockEnv; + type Tx = TxEnv; + type Chain = MonadChainContext; + type Journal = MonadJournal<&'db mut dyn DatabaseExt>; + + fn tx_mut(&mut self) -> &mut Self::Tx { + self.ctx_mut().tx_mut() + } + + fn journal_inner_mut(&mut self) -> &mut JournaledState { + &mut self.ctx_mut().journaled_state.inner + } + + fn chain_mut(&mut self) -> &mut Self::Chain { + &mut self.ctx_mut().chain + } + + fn precompiles_mut(&mut self) -> &mut alloy_evm::precompiles::PrecompilesMap { + &mut self.0.precompiles + } + + fn journal_mut(&mut self) -> &mut Self::Journal { + &mut self.ctx_mut().journaled_state + } + + fn run_execution(&mut self, frame: FrameInput) -> Result> { + run_inspected_frame(self, MonadEvmHandler::::new(), frame) + } + + fn transact_raw(&mut self, tx: Self::Tx) -> eyre::Result { + let Some(system_call) = protocol_system_call(&tx)? else { + ContextSetters::set_tx(&mut self.0.ctx, tx); + + let mut handler = MonadEvmHandler::::new(); + let result = handler.inspect_run(self)?; + + return Ok(ResultAndState::new( + result, + self.ctx_ref().journaled_state.inner.state.clone(), + )); + }; + + system_call.validate_chain_id(self.ctx_ref().cfg().chain_id())?; + let journal = self.ctx_ref().journal_inner().clone(); + let chain = self.ctx_ref().chain.clone(); + let reserve_balance = self.ctx_ref().journaled_state.reserve_balance().clone(); + let result = (|| { + let (db, journal) = self.0.ctx.db_journal_inner_mut(); + system_call.apply_prestate(db, journal)?; + let result = self + .inspect_system_call_with_caller( + system_call.caller, + system_call.contract, + system_call.data, + ) + .wrap_err("failed to execute protocol system transaction")?; + finish_protocol_system_call(result) + })(); + // Restore EVM-owned protocol state. Callers that require atomic inspector state isolate + // the inspector and database, as the Executor/Cow replay path does. + if result.is_err() { + self.ctx_mut().set_journal_inner(journal); + self.ctx_mut().chain = chain; + *self.ctx_mut().journaled_state.reserve_balance_mut() = reserve_balance; + } + result + } + + fn transact_replay( + &mut self, + tx: Self::Tx, + is_system: bool, + ) -> eyre::Result> { + if is_system && protocol_system_call(&tx)?.is_none() { + return Ok(None); + } + self.transact_raw(tx).map(Some) + } + + fn to_evm_env(&self) -> EvmEnv { + self.ctx_ref().evm_clone() + } +} + +/// Transaction metadata for an exact block and its two ancestors. +#[derive(Clone, Debug)] +pub struct BlockContext { + grandparent: Vec>, + parent: Vec>, + current: Vec>, +} + +impl BlockContext { + /// Creates block context from grandparent, parent, and current block transactions. + pub const fn new( + grandparent: Vec>, + parent: Vec>, + current: Vec>, + ) -> Self { + Self { grandparent, parent, current } + } + + /// Fetches all transaction bodies needed to replay transactions in `block` exactly. + pub async fn fetch>( + provider: &P, + block: &BlockResponseFor, + ) -> eyre::Result { + let current = transaction_envs::(block)?; + let parent = fetch_parent::(provider, block).await?; + let grandparent = if let Some(parent) = &parent { + fetch_parent::(provider, parent).await? + } else { + None + }; + + Ok(Self::new( + grandparent.as_ref().map(transaction_envs::).transpose()?.unwrap_or_default(), + parent.as_ref().map(transaction_envs::).transpose()?.unwrap_or_default(), + current, + )) + } + + /// Builds context for the transaction at `index` in the current block. + pub fn transaction(&self, index: usize) -> ChainFor { + ChainFor::::for_block(&self.grandparent, &self.parent, &self.current, index) + } + + /// Returns a cursor positioned immediately before `index` in the current block. + pub fn before_transaction(mut self, index: usize) -> eyre::Result { + if index > self.current.len() { + eyre::bail!( + "transaction index {index} exceeds block transaction count {}", + self.current.len() + ); + } + self.current.truncate(index); + Ok(self) + } + + /// Returns a cursor positioned at the start of a child block. + pub fn into_child(mut self) -> Self { + self.grandparent = std::mem::take(&mut self.parent); + self.parent = std::mem::take(&mut self.current); + self + } + + /// Builds context for the next transaction at the cursor's current block position. + pub fn next_transaction(&self, tx: &TxEnvFor) -> ChainFor { + let mut current = self.current.clone(); + let index = current.len(); + current.push(tx.clone()); + ChainFor::::for_block(&self.grandparent, &self.parent, ¤t, index) + } + + /// Records a committed transaction at the cursor's current block position. + pub fn record_transaction(&mut self, tx: TxEnvFor) { + self.current.push(tx); + } + + /// Advances the cursor to the start of the next block. + pub fn advance_block(&mut self) { + self.grandparent = std::mem::take(&mut self.parent); + self.parent = std::mem::take(&mut self.current); + } +} + +async fn fetch_parent( + provider: &P, + block: &BlockResponseFor, +) -> eyre::Result>> +where + FEN: FoundryEvmNetwork, + P: Provider, +{ + let parent_hash = block.header().parent_hash(); + if parent_hash.is_zero() { + return Ok(None); + } + + provider + .get_block_by_hash(parent_hash) + .full() + .await + .wrap_err_with(|| format!("failed to fetch ancestor block {parent_hash}"))? + .map(Some) + .ok_or_else(|| eyre::eyre!("ancestor block {parent_hash} not found")) +} + +fn transaction_envs( + block: &BlockResponseFor, +) -> eyre::Result>> { + let BlockTransactions::Full(transactions) = block.transactions() else { + eyre::bail!("block {} does not contain full transactions", block.header().number()); + }; + Ok(transactions + .iter() + .map(|tx| TxEnvFor::::from_recovered_tx(tx.as_ref(), tx.from())) + .collect()) +} + +/// Refreshes journal state derived from a context's active Monad chain position. +pub fn refresh_chain_journal(context: &mut CTX) { + let chain = context.chain().clone(); + chain.refresh_journal(context.journal_mut()); +} + +/// Refreshes journal state derived from a nested EVM's active Monad chain position. +pub fn refresh_nested_chain_journal(evm: &mut E) { + let chain = evm.chain_mut().clone(); + chain.refresh_journal(evm.journal_mut()); +} + +/// Senders and EIP-7702 authorities that participated in one Monad block. +pub type MonadBlockParticipants = HashSet
; + +/// Collects all senders and EIP-7702 authorities from a block's transactions. +pub fn monad_block_participants(transactions: &[TxEnv]) -> MonadBlockParticipants { + transactions + .iter() + .flat_map(|tx| { + std::iter::once(tx.caller()) + .chain(tx.authorization_list().filter_map(|auth| auth.authority())) + }) + .collect() +} + +/// Builds Monad context from cached ancestor participants and the current block transactions. +pub fn monad_context_from_participants( + grandparent_senders_and_authorities: MonadBlockParticipants, + parent_senders_and_authorities: MonadBlockParticipants, + current: &[TxEnv], + current_tx_index: usize, +) -> MonadChainContext { + MonadChainContext { + grandparent_senders_and_authorities, + parent_senders_and_authorities, + current_block_senders: current.iter().map(Transaction::caller).collect(), + current_block_authorities: current + .iter() + .map(|tx| tx.authorization_list().filter_map(|auth| auth.authority()).collect()) + .collect(), + current_tx_index, + ..Default::default() + } +} + +/// A canonical Monad protocol system transaction. +#[derive(Clone, Debug)] +pub struct ProtocolSystemCall { + /// Reserved caller used by the protocol. + pub caller: Address, + /// Native system contract or precompile being called. + pub contract: Address, + /// Calldata passed to the dedicated system-call entry point. + pub data: Bytes, + /// Sender nonce encoded by the canonical envelope. + pub nonce: u64, + /// Optional EIP-155 chain ID encoded by the canonical envelope. + pub chain_id: Option, + /// Optional protocol mint applied before system-call execution. + pub balance_increment: Option<(Address, U256)>, +} + +impl ProtocolSystemCall { + fn validate_chain_id(&self, chain_id: u64) -> eyre::Result<()> { + if let Some(envelope_chain_id) = self.chain_id + && envelope_chain_id != chain_id + { + eyre::bail!( + "protocol system transaction chain ID mismatch: envelope {envelope_chain_id}, \ + environment {chain_id}" + ); + } + Ok(()) + } + + fn apply_prestate( + &self, + db: &mut DB, + journal: &mut JournaledState, + ) -> eyre::Result<()> { + let next_nonce = self + .nonce + .checked_add(1) + .ok_or_else(|| eyre::eyre!("protocol system transaction nonce overflow"))?; + let caller_nonce = journal.load_account(db, self.caller)?.data.info.nonce; + if caller_nonce != self.nonce { + eyre::bail!( + "protocol system transaction nonce mismatch: envelope {}, state {}", + self.nonce, + caller_nonce + ); + } + + let balance = if let Some((address, amount)) = self.balance_increment { + let balance = journal + .load_account(db, address)? + .data + .info + .balance + .checked_add(amount) + .ok_or_else(|| eyre::eyre!("protocol system transaction balance overflow"))?; + Some((address, balance)) + } else { + None + }; + + journal.load_account_mut(db, self.caller)?.data.set_nonce(next_nonce); + if let Some((address, balance)) = balance { + journal.load_account_mut(db, address)?.data.set_balance(balance); + } + + Ok(()) + } +} + +/// Converts a canonical Monad envelope into its dedicated system call. +/// +/// Returns an error when the transaction uses Monad's reserved protocol sender but does not +/// satisfy the canonical envelope rules. +pub fn protocol_system_call(tx: &T) -> eyre::Result> { + if tx.caller() != SYSTEM_ADDRESS { + return Ok(None); + } + + eyre::ensure!( + tx.tx_type() == TransactionType::Legacy as u8, + "invalid Monad protocol system transaction: transaction type must be legacy" + ); + eyre::ensure!( + tx.kind() == revm::primitives::TxKind::Call(STAKING_ADDRESS), + "invalid Monad protocol system transaction: target must be the staking contract" + ); + eyre::ensure!( + tx.gas_limit() == 0, + "invalid Monad protocol system transaction: gas limit must be zero" + ); + eyre::ensure!( + tx.gas_price() == 0, + "invalid Monad protocol system transaction: gas price must be zero" + ); + eyre::ensure!( + tx.max_priority_fee_per_gas().is_none(), + "invalid Monad protocol system transaction: priority fee must be absent" + ); + eyre::ensure!( + tx.access_list().is_none_or(|mut list| list.next().is_none()), + "invalid Monad protocol system transaction: access list must be empty" + ); + eyre::ensure!( + tx.blob_versioned_hashes().is_empty(), + "invalid Monad protocol system transaction: blob hashes must be empty" + ); + eyre::ensure!( + tx.max_fee_per_blob_gas() == 0, + "invalid Monad protocol system transaction: blob gas fee must be zero" + ); + eyre::ensure!( + tx.authorization_list_len() == 0, + "invalid Monad protocol system transaction: authorization list must be empty" + ); + + let selector: [u8; 4] = tx + .input() + .get(..4) + .ok_or_else(|| { + eyre::eyre!( + "invalid Monad protocol system transaction: calldata is shorter than a selector" + ) + })? + .try_into() + .expect("slice has exactly four bytes"); + let (data, balance_increment) = match selector { + syscallRewardCall::SELECTOR => { + eyre::ensure!( + tx.input().len() == 36, + "invalid Monad protocol system transaction: reward calldata must be 36 bytes" + ); + let call = syscallRewardCall::abi_decode_raw(&tx.input()[4..]) + .wrap_err("invalid Monad protocol system reward calldata")?; + eyre::ensure!( + call.abi_encode().as_slice() == tx.input(), + "invalid Monad protocol system reward calldata" + ); + ( + syscall_reward_calldata(call.blockAuthor, tx.value()), + Some((STAKING_ADDRESS, tx.value())), + ) + } + syscallSnapshotCall::SELECTOR => { + eyre::ensure!( + tx.input().len() == 4, + "invalid Monad protocol system transaction: snapshot calldata must be 4 bytes" + ); + eyre::ensure!( + tx.value().is_zero(), + "invalid Monad protocol system transaction: snapshot value must be zero" + ); + syscallSnapshotCall::abi_decode_raw(&tx.input()[4..]) + .wrap_err("invalid Monad protocol system snapshot calldata")?; + (syscall_snapshot_calldata(), None) + } + syscallOnEpochChangeCall::SELECTOR => { + eyre::ensure!( + tx.input().len() == 36, + "invalid Monad protocol system transaction: epoch calldata must be 36 bytes" + ); + eyre::ensure!( + tx.value().is_zero(), + "invalid Monad protocol system transaction: epoch value must be zero" + ); + let call = syscallOnEpochChangeCall::abi_decode_raw(&tx.input()[4..]) + .wrap_err("invalid Monad protocol system epoch calldata")?; + eyre::ensure!( + call.abi_encode().as_slice() == tx.input(), + "invalid Monad protocol system epoch calldata" + ); + (syscall_on_epoch_change_calldata(call.epoch), None) + } + _ => { + return Err(eyre::eyre!( + "invalid Monad protocol system transaction: unknown staking syscall selector" + )); + } + }; + + Ok(Some(ProtocolSystemCall { + caller: SYSTEM_ADDRESS, + contract: STAKING_ADDRESS, + data, + nonce: tx.nonce(), + chain_id: tx.chain_id(), + balance_increment, + })) +} + +fn finish_protocol_system_call( + mut result: ResultAndState, +) -> eyre::Result> { + if !result.result.is_success() { + eyre::bail!("protocol system transaction reverted or halted"); + } + + if let revm::context_interface::result::ExecutionResult::Success { gas, .. } = + &mut result.result + { + *gas = Default::default(); + } + + Ok(result) +} + +/// Tries to execute a canonical Monad system transaction on an existing Monad EVM. +pub fn try_transact_monad_system_replay( + evm: &mut MonadEvm, + tx: &TxEnv, +) -> eyre::Result> +where + DB: alloy_evm::Database, + I: Inspector>, +{ + let Some(system_call) = protocol_system_call(tx)? else { + return Ok(None); + }; + + system_call.validate_chain_id(evm.chain_id())?; + let journal = evm.ctx().journal_inner().clone(); + let chain = evm.ctx().chain.clone(); + let reserve_balance = evm.ctx().journaled_state.reserve_balance().clone(); + let result = (|| { + let (db, journal) = evm.ctx_mut().db_journal_inner_mut(); + system_call.apply_prestate(db, journal)?; + let result = evm + .transact_system_call(system_call.caller, system_call.contract, system_call.data) + .wrap_err("failed to execute protocol system transaction")?; + finish_protocol_system_call(result) + })(); + // Restore EVM-owned protocol state. Callers that require atomic inspector state isolate the + // inspector and database, as the Executor/Cow replay path does. + if result.is_err() { + evm.ctx_mut().set_journal_inner(journal); + evm.ctx_mut().chain = chain; + *evm.ctx_mut().journaled_state.reserve_balance_mut() = reserve_balance; + } + result.map(Some) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{backend::Backend, evm::EthEvmNetwork}; + use alloy_evm::EthEvmFactory; + use alloy_sol_types::SolEvent; + use monad_revm::{ + reserve_balance::tracker::ReserveBalanceInit, + staking::{ + constants::MON, + interface::IMonadStaking::ValidatorRewarded, + storage::{ + consensus_view_key, global_slots, val_id_secp_key, validator_key, validator_offsets, + }, + }, + }; + use revm::{ + Database, DatabaseCommit, + context::CfgEnv, + context_interface::{ + either::Either, + transaction::{ + AccessListItem, Authorization, RecoveredAuthority, RecoveredAuthorization, + }, + }, + database::InMemoryDB, + interpreter::{CallInputs, CallOutcome}, + primitives::{B256, TxKind, address}, + state::{Account, AccountInfo, EvmState}, + }; + + #[test] + fn ethereum_replay_skips_monad_system_envelopes() { + let tx = system_transaction(syscallSnapshotCall {}.abi_encode(), U256::ZERO); + let factory = EthEvmFactory::default(); + let evm_env = EvmEnv::default(); + let mut db = Backend::::spawn(None).unwrap(); + db.set_networks(foundry_evm_networks::NetworkConfigs::with_monad()); + let mut nested = factory.create_nested_evm(&mut db, evm_env); + assert!(nested.transact_replay(tx.clone(), true).unwrap().is_none()); + assert!(nested.journal_inner_mut().state.is_empty()); + let error = nested.transact_raw(tx).unwrap_err(); + assert!(format!("{error:?}").contains("gas"), "{error:?}"); + } + + #[test] + fn nested_replay_executes_monad_envelopes_once_and_skips_foreign_systems() { + let factory = MonadEvmFactory::default(); + let evm_env = + EvmEnv::new(CfgEnv::new_with_spec(MonadHardfork::MonadNine), BlockEnv::default()); + let mut db = Backend::::spawn(None).unwrap(); + db.insert_account_info(SYSTEM_ADDRESS, AccountInfo { nonce: 3, ..Default::default() }); + let mut evm = factory.create_nested_evm(&mut db, evm_env.clone()); + + // The RPC system classification must survive decoding into an ordinary TxEnv. + let foreign = TxEnv { caller: Address::with_last_byte(42), ..Default::default() }; + assert!(evm.transact_replay(foreign, true).unwrap().is_none()); + assert!(evm.journal_inner_mut().state.is_empty()); + + let tx = system_transaction(syscallSnapshotCall {}.abi_encode(), U256::ZERO); + let result = evm.transact_replay(tx.clone(), true).unwrap().unwrap(); + assert!(result.result.is_success()); + assert_eq!(result.result.tx_gas_used(), 0); + drop(evm); + db.commit(result.state); + assert_eq!(db.basic(SYSTEM_ADDRESS).unwrap().unwrap().nonce, 4); + + let mut evm = factory.create_nested_evm(&mut db, evm_env); + assert!(evm.transact_replay(tx, true).unwrap_err().to_string().contains("nonce")); + assert!(evm.journal_inner_mut().state.is_empty()); + drop(evm); + assert_eq!(db.basic(SYSTEM_ADDRESS).unwrap().unwrap().nonce, 4); + } + + #[test] + fn nested_replay_failure_restores_monad_prestate() { + let initial_balance = U256::from(3) * MON; + let mut db = Backend::::spawn(None).unwrap(); + db.insert_account_info(SYSTEM_ADDRESS, AccountInfo { nonce: 3, ..Default::default() }); + db.insert_account_info( + STAKING_ADDRESS, + AccountInfo { balance: initial_balance, ..Default::default() }, + ); + let tx = system_transaction( + syscallRewardCall { blockAuthor: Address::with_last_byte(42) }.abi_encode(), + U256::from(25) * MON, + ); + let factory = MonadEvmFactory::default(); + let evm_env = + EvmEnv::new(CfgEnv::new_with_spec(MonadHardfork::MonadNine), BlockEnv::default()); + let mut evm = factory.create_nested_evm(&mut db, evm_env); + let journal_before = evm.journal_inner_mut().clone(); + let chain_before = evm.chain_mut().clone(); + let tracker_before = evm.journal_mut().reserve_balance().clone(); + + let error = evm.transact_replay(tx, true).unwrap_err(); + assert!(error.to_string().contains("reverted or halted"), "{error:?}"); + assert_eq!(evm.journal_inner_mut().state, journal_before.state); + assert_eq!(evm.chain_mut(), &chain_before); + assert_eq!(evm.journal_mut().reserve_balance(), &tracker_before); + drop(evm); + assert_eq!(db.basic(SYSTEM_ADDRESS).unwrap().unwrap().nonce, 3); + assert_eq!(db.basic(STAKING_ADDRESS).unwrap().unwrap().balance, initial_balance); + assert_eq!(db.storage(STAKING_ADDRESS, global_slots::PROPOSER_VAL_ID).unwrap(), U256::ZERO); + } + + #[derive(Default)] + struct ProtocolPrestateInspector { + call_count: usize, + staking_balance: Option, + } + + impl Inspector> for ProtocolPrestateInspector { + fn call( + &mut self, + context: &mut MonadContext, + _inputs: &mut CallInputs, + ) -> Option { + self.call_count += 1; + self.staking_balance = context + .journaled_state + .inner + .state + .get(&STAKING_ADDRESS) + .map(|account| account.info.balance); + None + } + } + + fn transaction(caller: Address, authority: Address) -> TxEnv { + let authorization = RecoveredAuthorization::new_unchecked( + Authorization { chain_id: U256::ONE, address: Address::ZERO, nonce: 0 }, + RecoveredAuthority::Valid(authority), + ); + TxEnv { + caller, + authorization_list: vec![Either::Right(authorization)], + ..Default::default() + } + } + + fn system_transaction(data: Vec, value: U256) -> TxEnv { + TxEnv { + tx_type: TransactionType::Legacy as u8, + caller: SYSTEM_ADDRESS, + gas_limit: 0, + kind: revm::primitives::TxKind::Call(STAKING_ADDRESS), + data: data.into(), + value, + nonce: 3, + chain_id: None, + ..Default::default() + } + } + + fn assert_invalid_system_transaction(tx: TxEnv, expected: &str) { + let err = protocol_system_call(&tx).unwrap_err(); + assert!(err.to_string().contains(expected), "expected {expected:?} in error, got {err:?}"); + } + + #[test] + fn monad_evm_factory_implements_foundry_evm_factory() { + fn assert_foundry_factory() {} + + assert_foundry_factory::(); + } + + #[test] + fn monad_context_transition_rebases_live_tracker() { + let sender = Address::with_last_byte(1); + let old_chain = MonadChainContext::default(); + let new_chain = MonadChainContext { + parent_senders_and_authorities: [sender].into_iter().collect(), + ..Default::default() + }; + let mut account = + Account::from(AccountInfo { balance: U256::from(12), ..Default::default() }); + account.info.balance = U256::from(9); + + let factory = MonadEvmFactory::default(); + let mut evm = factory.create_evm( + revm::database::EmptyDB::default(), + EvmEnv::new( + revm::context::CfgEnv::new_with_spec(MonadHardfork::MonadNine), + BlockEnv::default(), + ), + ); + evm.ctx_mut().chain = old_chain.clone(); + evm.ctx_mut().journaled_state.reserve_balance_mut().init(ReserveBalanceInit { + chain: &old_chain, + spec: MonadHardfork::MonadNine, + sender, + effective_gas_price: 0, + gas_limit: 0, + sender_is_delegated: false, + sender_account: Some(&account), + }); + assert!(!evm.ctx().journaled_state.reserve_balance().has_violation()); + + evm.ctx_mut().chain = new_chain.clone(); + evm.ctx_mut().journaled_state.inner.state = EvmState::from_iter([(sender, account)]); + refresh_chain_journal(evm.ctx_mut()); + + assert_eq!(evm.ctx().chain, new_chain); + assert!(evm.ctx().journaled_state.reserve_balance().has_violation()); + } + + #[test] + fn monad_factory_classifies_canonical_system_envelopes() { + let reward = U256::from(25); + let reward_tx = system_transaction( + syscallRewardCall { blockAuthor: Address::with_last_byte(1) }.abi_encode(), + reward, + ); + let reward_call = protocol_system_call(&reward_tx).unwrap().unwrap(); + assert_eq!(reward_call.data.len(), 68); + assert_eq!(reward_call.balance_increment, Some((STAKING_ADDRESS, reward))); + + let snapshot_tx = system_transaction(syscallSnapshotCall {}.abi_encode(), U256::ZERO); + assert!(protocol_system_call(&snapshot_tx).unwrap().is_some()); + + let epoch_tx = + system_transaction(syscallOnEpochChangeCall { epoch: 9 }.abi_encode(), U256::ZERO); + assert!(protocol_system_call(&epoch_tx).unwrap().is_some()); + + let mut unrelated = snapshot_tx; + unrelated.caller = Address::with_last_byte(2); + unrelated.tx_type = TransactionType::Eip1559 as u8; + assert!(protocol_system_call(&unrelated).unwrap().is_none()); + } + + #[test] + fn monad_replay_decline_leaves_evm_untouched() { + let tx = TxEnv { + caller: foundry_common::OPTIMISM_SYSTEM_ADDRESS, + kind: TxKind::Call(Address::with_last_byte(1)), + ..Default::default() + }; + let factory = MonadEvmFactory::default(); + let evm_env = + EvmEnv::new(CfgEnv::new_with_spec(MonadHardfork::MonadNine), BlockEnv::default()); + let mut evm = factory.create_evm(InMemoryDB::default(), evm_env); + let tx_before = evm.tx().clone(); + let journal_before = evm.ctx().journal_inner().clone(); + let chain_before = evm.ctx().chain.clone(); + let tracker_before = evm.ctx().journaled_state.reserve_balance().clone(); + + assert!(try_transact_monad_system_replay(&mut evm, &tx).unwrap().is_none()); + assert_eq!(evm.tx(), &tx_before); + assert_eq!(evm.ctx().journal_inner().state, journal_before.state); + assert_eq!(evm.ctx().chain, chain_before); + assert_eq!(evm.ctx().journaled_state.reserve_balance(), &tracker_before); + } + + #[test] + fn monad_factory_rejects_noncanonical_system_envelope_fields() { + let canonical = system_transaction(syscallSnapshotCall {}.abi_encode(), U256::ZERO); + + let mut tx = canonical.clone(); + tx.tx_type = TransactionType::Eip1559 as u8; + assert_invalid_system_transaction(tx, "transaction type must be legacy"); + + let mut tx = canonical.clone(); + tx.kind = revm::primitives::TxKind::Call(Address::ZERO); + assert_invalid_system_transaction(tx, "target must be the staking contract"); + + let mut tx = canonical.clone(); + tx.gas_limit = 1; + assert_invalid_system_transaction(tx, "gas limit must be zero"); + + let mut tx = canonical.clone(); + tx.gas_price = 1; + assert_invalid_system_transaction(tx, "gas price must be zero"); + + let mut tx = canonical.clone(); + tx.gas_priority_fee = Some(0); + assert_invalid_system_transaction(tx, "priority fee must be absent"); + + let mut tx = canonical.clone(); + tx.access_list.0.push(AccessListItem::default()); + assert_invalid_system_transaction(tx, "access list must be empty"); + + let mut tx = canonical.clone(); + tx.blob_hashes.push(B256::ZERO); + assert_invalid_system_transaction(tx, "blob hashes must be empty"); + + let mut tx = canonical.clone(); + tx.max_fee_per_blob_gas = 1; + assert_invalid_system_transaction(tx, "blob gas fee must be zero"); + + let mut tx = canonical; + tx.authorization_list = + transaction(Address::ZERO, Address::with_last_byte(1)).authorization_list; + assert_invalid_system_transaction(tx, "authorization list must be empty"); + } + + #[test] + fn monad_factory_rejects_noncanonical_system_call_data_and_value() { + assert_invalid_system_transaction( + system_transaction(Vec::new(), U256::ZERO), + "calldata is shorter than a selector", + ); + assert_invalid_system_transaction( + system_transaction(vec![0xff; 4], U256::ZERO), + "unknown staking syscall selector", + ); + + let mut reward = syscallRewardCall { blockAuthor: Address::with_last_byte(1) }.abi_encode(); + reward.push(0); + assert_invalid_system_transaction( + system_transaction(reward, U256::ZERO), + "reward calldata must be 36 bytes", + ); + + let mut malformed_reward = + syscallRewardCall { blockAuthor: Address::with_last_byte(1) }.abi_encode(); + malformed_reward[4] = 1; + assert_invalid_system_transaction( + system_transaction(malformed_reward, U256::ZERO), + "invalid Monad protocol system reward calldata", + ); + + let mut snapshot = syscallSnapshotCall {}.abi_encode(); + snapshot.push(0); + assert_invalid_system_transaction( + system_transaction(snapshot, U256::ZERO), + "snapshot calldata must be 4 bytes", + ); + assert_invalid_system_transaction( + system_transaction(syscallSnapshotCall {}.abi_encode(), U256::ONE), + "snapshot value must be zero", + ); + + let mut epoch = syscallOnEpochChangeCall { epoch: 9 }.abi_encode(); + epoch.push(0); + assert_invalid_system_transaction( + system_transaction(epoch, U256::ZERO), + "epoch calldata must be 36 bytes", + ); + let mut malformed_epoch = syscallOnEpochChangeCall { epoch: 9 }.abi_encode(); + malformed_epoch[4] = 1; + assert_invalid_system_transaction( + system_transaction(malformed_epoch, U256::ZERO), + "invalid Monad protocol system epoch calldata", + ); + assert_invalid_system_transaction( + system_transaction(syscallOnEpochChangeCall { epoch: 9 }.abi_encode(), U256::ONE), + "epoch value must be zero", + ); + } + + #[test] + fn monad_factory_validates_system_envelope_chain_id_at_execution() { + let mut tx = system_transaction(syscallSnapshotCall {}.abi_encode(), U256::ZERO); + tx.chain_id = Some(143); + let system_call = protocol_system_call(&tx).unwrap().unwrap(); + + system_call.validate_chain_id(143).unwrap(); + assert!( + system_call.validate_chain_id(1).unwrap_err().to_string().contains("chain ID mismatch") + ); + } + + #[test] + fn protocol_prestate_updates_nonce_and_balance() { + let caller = Address::with_last_byte(0xfe); + let recipient = address!("0000000000000000000000000000000000001000"); + let mut db = InMemoryDB::default(); + db.insert_account_info(caller, AccountInfo { nonce: 7, ..Default::default() }); + db.insert_account_info( + recipient, + AccountInfo { balance: U256::from(10), ..Default::default() }, + ); + let call = ProtocolSystemCall { + caller, + contract: recipient, + data: Bytes::new(), + nonce: 7, + chain_id: None, + balance_increment: Some((recipient, U256::from(25))), + }; + let mut journal = JournaledState::default(); + + call.apply_prestate(&mut db, &mut journal).unwrap(); + + assert_eq!(journal.state[&caller].info.nonce, 8); + assert_eq!(journal.state[&recipient].info.balance, U256::from(35)); + assert_eq!(db.basic(caller).unwrap().unwrap().nonce, 7); + assert_eq!(db.basic(recipient).unwrap().unwrap().balance, U256::from(10)); + } + + #[test] + fn protocol_prestate_rejects_nonce_mismatch() { + let caller = Address::with_last_byte(0xfe); + let mut db = InMemoryDB::default(); + db.insert_account_info(caller, AccountInfo { nonce: 3, ..Default::default() }); + let call = ProtocolSystemCall { + caller, + contract: Address::ZERO, + data: Bytes::new(), + nonce: 4, + chain_id: None, + balance_increment: None, + }; + let mut journal = JournaledState::default(); + + let err = call.apply_prestate(&mut db, &mut journal).unwrap_err(); + + assert!(err.to_string().contains("nonce mismatch")); + assert_eq!(db.basic(caller).unwrap().unwrap().nonce, 3); + } + + #[test] + fn protocol_prestate_rejects_nonce_overflow() { + let caller = Address::with_last_byte(0xfe); + let mut db = InMemoryDB::default(); + db.insert_account_info(caller, AccountInfo { nonce: u64::MAX, ..Default::default() }); + let call = ProtocolSystemCall { + caller, + contract: Address::ZERO, + data: Bytes::new(), + nonce: u64::MAX, + chain_id: None, + balance_increment: None, + }; + let mut journal = JournaledState::default(); + + let err = call.apply_prestate(&mut db, &mut journal).unwrap_err(); + + assert!(err.to_string().contains("nonce overflow")); + assert_eq!(db.basic(caller).unwrap().unwrap().nonce, u64::MAX); + } + + #[test] + fn reward_envelope_replays_mint_nonce_storage_and_log() { + let block_author = Address::repeat_byte(0x11); + let validator_auth = Address::repeat_byte(0x22); + let validator_id = 7; + let reward = U256::from(25) * MON; + let initial_staking_balance = U256::from(3) * MON; + // Monad stores validator IDs and packed address/flags values left-aligned. + let validator_id_slot = U256::from(validator_id) << 192; + let address_flags_slot = U256::from_be_slice(validator_auth.as_slice()) << 96; + let mut db = InMemoryDB::default(); + db.insert_account_info(SYSTEM_ADDRESS, AccountInfo { nonce: 11, ..Default::default() }); + db.insert_account_info( + STAKING_ADDRESS, + AccountInfo { balance: initial_staking_balance, ..Default::default() }, + ); + db.insert_account_storage( + STAKING_ADDRESS, + val_id_secp_key(&block_author), + validator_id_slot, + ) + .unwrap(); + db.insert_account_storage( + STAKING_ADDRESS, + consensus_view_key(validator_id, 0), + U256::from(100) * MON, + ) + .unwrap(); + db.insert_account_storage(STAKING_ADDRESS, consensus_view_key(validator_id, 1), U256::ZERO) + .unwrap(); + db.insert_account_storage( + STAKING_ADDRESS, + validator_key(validator_id, validator_offsets::ADDRESS_FLAGS), + address_flags_slot, + ) + .unwrap(); + + let tx = TxEnv { + tx_type: 0, + caller: SYSTEM_ADDRESS, + gas_limit: 0, + kind: TxKind::Call(STAKING_ADDRESS), + value: reward, + data: syscallRewardCall { blockAuthor: block_author }.abi_encode().into(), + nonce: 11, + ..Default::default() + }; + let factory = MonadEvmFactory::default(); + let evm_env = + EvmEnv::new(CfgEnv::new_with_spec(MonadHardfork::MonadNine), BlockEnv::default()); + let mut evm = + factory.create_evm_with_inspector(db, evm_env, ProtocolPrestateInspector::default()); + + let result = try_transact_monad_system_replay(&mut evm, &tx).unwrap().unwrap(); + + assert!(result.result.is_success()); + assert_eq!(result.result.tx_gas_used(), 0); + assert!(evm.inspector().call_count > 0); + assert_eq!(evm.inspector().staking_balance, Some(initial_staking_balance + reward)); + assert_eq!(result.result.logs().len(), 1); + assert_eq!(result.result.logs()[0].address, STAKING_ADDRESS); + assert_eq!(result.result.logs()[0].topics()[0], ValidatorRewarded::SIGNATURE_HASH); + evm.db_mut().commit(result.state); + let mut db = evm.into_db(); + assert_eq!(db.basic(SYSTEM_ADDRESS).unwrap().unwrap().nonce, 12); + assert_eq!( + db.basic(STAKING_ADDRESS).unwrap().unwrap().balance, + initial_staking_balance + reward + ); + assert_eq!( + db.storage(STAKING_ADDRESS, global_slots::PROPOSER_VAL_ID).unwrap(), + validator_id_slot + ); + assert_eq!( + db.storage( + STAKING_ADDRESS, + validator_key(validator_id, validator_offsets::UNCLAIMED_REWARDS), + ) + .unwrap(), + reward + ); + } + + #[test] + fn failed_reward_envelope_does_not_commit_prestate() { + let unknown_author = Address::repeat_byte(0x11); + let reward = U256::from(25) * MON; + let initial_staking_balance = U256::from(3) * MON; + let mut db = InMemoryDB::default(); + db.insert_account_info(SYSTEM_ADDRESS, AccountInfo { nonce: 11, ..Default::default() }); + db.insert_account_info( + STAKING_ADDRESS, + AccountInfo { balance: initial_staking_balance, ..Default::default() }, + ); + let tx = TxEnv { + tx_type: 0, + caller: SYSTEM_ADDRESS, + gas_limit: 0, + kind: TxKind::Call(STAKING_ADDRESS), + value: reward, + data: syscallRewardCall { blockAuthor: unknown_author }.abi_encode().into(), + nonce: 11, + ..Default::default() + }; + let factory = MonadEvmFactory::default(); + let evm_env = + EvmEnv::new(CfgEnv::new_with_spec(MonadHardfork::MonadNine), BlockEnv::default()); + let mut evm = + factory.create_evm_with_inspector(db, evm_env, ProtocolPrestateInspector::default()); + let journal_before = evm.ctx().journal_inner().clone(); + let chain_before = evm.ctx().chain.clone(); + let tracker_before = evm.ctx().journaled_state.reserve_balance().clone(); + + let error = try_transact_monad_system_replay(&mut evm, &tx).unwrap_err(); + + assert!(error.to_string().contains("reverted or halted")); + assert!(evm.inspector().call_count > 0); + assert_eq!(evm.inspector().staking_balance, Some(initial_staking_balance + reward)); + assert_eq!(evm.ctx().journal_inner().state, journal_before.state); + assert_eq!(evm.ctx().chain, chain_before); + assert_eq!(evm.ctx().journaled_state.reserve_balance(), &tracker_before); + assert_eq!(evm.db_mut().basic(SYSTEM_ADDRESS).unwrap().unwrap().nonce, 11); + assert_eq!( + evm.db_mut().basic(STAKING_ADDRESS).unwrap().unwrap().balance, + initial_staking_balance + ); + assert_eq!( + evm.db_mut().storage(STAKING_ADDRESS, global_slots::PROPOSER_VAL_ID).unwrap(), + U256::ZERO + ); + } + + #[test] + fn monad_context_tracks_senders_authorities_and_current_index() { + let grandparent_sender = Address::repeat_byte(1); + let grandparent_authority = Address::repeat_byte(2); + let parent_sender = Address::repeat_byte(3); + let parent_authority = Address::repeat_byte(4); + let current_sender = Address::repeat_byte(5); + let current_authority = Address::repeat_byte(6); + let next_sender = Address::repeat_byte(7); + let next_authority = Address::repeat_byte(8); + + let grandparent = [transaction(grandparent_sender, grandparent_authority)]; + let parent = [transaction(parent_sender, parent_authority)]; + let current = [ + transaction(current_sender, current_authority), + transaction(next_sender, next_authority), + ]; + + let context = monad_context_from_participants( + monad_block_participants(&grandparent), + monad_block_participants(&parent), + ¤t, + 1, + ); + + assert_eq!(context.current_tx_index, 1); + assert_eq!(context.grandparent_senders_and_authorities.len(), 2); + assert!(context.grandparent_senders_and_authorities.contains(&grandparent_sender)); + assert!(context.grandparent_senders_and_authorities.contains(&grandparent_authority)); + assert_eq!(context.parent_senders_and_authorities.len(), 2); + assert!(context.parent_senders_and_authorities.contains(&parent_sender)); + assert!(context.parent_senders_and_authorities.contains(&parent_authority)); + assert_eq!(context.current_block_senders, vec![current_sender, next_sender]); + assert_eq!(context.current_block_authorities.len(), 2); + assert!(context.current_block_authorities[0].contains(¤t_authority)); + assert!(context.current_block_authorities[1].contains(&next_authority)); + } + + #[test] + fn child_context_advances_fork_ancestry() { + let parent_sender = Address::repeat_byte(1); + let parent_authority = Address::repeat_byte(2); + let current_sender = Address::repeat_byte(3); + let current_authority = Address::repeat_byte(4); + let child_sender = Address::repeat_byte(5); + let child_authority = Address::repeat_byte(6); + + let context = BlockContext::::new( + Vec::new(), + vec![transaction(parent_sender, parent_authority)], + vec![transaction(current_sender, current_authority)], + ) + .into_child() + .next_transaction(&transaction(child_sender, child_authority)); + + assert_eq!(context.current_tx_index, 0); + assert_eq!(context.grandparent_senders_and_authorities.len(), 2); + assert!(context.grandparent_senders_and_authorities.contains(&parent_sender)); + assert!(context.grandparent_senders_and_authorities.contains(&parent_authority)); + assert_eq!(context.parent_senders_and_authorities.len(), 2); + assert!(context.parent_senders_and_authorities.contains(¤t_sender)); + assert!(context.parent_senders_and_authorities.contains(¤t_authority)); + assert_eq!(context.current_block_senders, vec![child_sender]); + assert!(context.current_block_authorities[0].contains(&child_authority)); + } + + #[test] + fn transaction_cursor_replaces_target_and_excludes_future_transactions() { + let preceding_sender = Address::repeat_byte(1); + let target_sender = Address::repeat_byte(2); + let future_sender = Address::repeat_byte(3); + let synthetic_sender = Address::repeat_byte(4); + + let cursor = BlockContext::::new( + Vec::new(), + Vec::new(), + vec![ + transaction(preceding_sender, Address::ZERO), + transaction(target_sender, Address::ZERO), + transaction(future_sender, Address::ZERO), + ], + ) + .before_transaction(1) + .unwrap(); + let context = cursor.next_transaction(&transaction(synthetic_sender, Address::ZERO)); + + assert_eq!(context.current_tx_index, 1); + assert_eq!(context.current_block_senders, vec![preceding_sender, synthetic_sender]); + assert!(!context.current_block_senders.contains(&target_sender)); + assert!(!context.current_block_senders.contains(&future_sender)); + } + + #[test] + fn transaction_cursor_accumulates_same_block_transactions() { + let fork_sender = Address::repeat_byte(1); + let first_sender = Address::repeat_byte(2); + let second_sender = Address::repeat_byte(3); + let mut cursor = BlockContext::::new( + Vec::new(), + Vec::new(), + vec![transaction(fork_sender, Address::ZERO)], + ) + .into_child(); + + cursor.record_transaction(transaction(first_sender, Address::ZERO)); + let context = cursor.next_transaction(&transaction(second_sender, Address::ZERO)); + + assert_eq!(context.current_tx_index, 1); + assert_eq!(context.current_block_senders, vec![first_sender, second_sender]); + assert!(context.parent_senders_and_authorities.contains(&fork_sender)); + } + + #[test] + fn transaction_cursor_rotates_separate_blocks() { + let fork_parent_sender = Address::repeat_byte(1); + let fork_sender = Address::repeat_byte(2); + let first_sender = Address::repeat_byte(3); + let second_sender = Address::repeat_byte(4); + let mut cursor = BlockContext::::new( + Vec::new(), + vec![transaction(fork_parent_sender, Address::ZERO)], + vec![transaction(fork_sender, Address::ZERO)], + ) + .into_child(); + + cursor.record_transaction(transaction(first_sender, Address::ZERO)); + cursor.advance_block(); + let context = cursor.next_transaction(&transaction(second_sender, Address::ZERO)); + + assert_eq!(context.current_tx_index, 0); + assert_eq!(context.current_block_senders, vec![second_sender]); + assert!(context.parent_senders_and_authorities.contains(&first_sender)); + assert!(context.grandparent_senders_and_authorities.contains(&fork_sender)); + assert!(!context.grandparent_senders_and_authorities.contains(&fork_parent_sender)); + } +} diff --git a/crates/evm/core/src/evm/op.rs b/crates/evm/core/src/evm/op.rs new file mode 100644 index 0000000000000..285f0925d21f5 --- /dev/null +++ b/crates/evm/core/src/evm/op.rs @@ -0,0 +1,265 @@ +use alloy_evm::{EvmEnv, EvmFactory, precompiles::PrecompilesMap}; +use alloy_op_evm::{OpEvm, OpEvmContext, OpEvmFactory, OpTx}; +use foundry_fork_db::DatabaseError; +use op_alloy_network::Optimism; +use op_revm::{ + L1BlockInfo, OpEvm as RevmEvm, OpHaltReason, OpSpecId, OpTransactionError, handler::OpHandler, +}; +use revm::{ + context::{ + BlockEnv, ContextTr, Journal, + result::{EVMError, HaltReason, ResultAndState}, + }, + handler::{EthFrame, EvmTr, FrameResult, instructions::EthInstructions}, + inspector::InspectorHandler, + interpreter::{FrameInput, InstructionResult, interpreter::EthInterpreter}, +}; + +use crate::{ + FoundryChain, FoundryContextExt, FoundryInspectorExt, FoundryTransaction, + backend::{DatabaseExt, JournaledState}, + evm::{ + FoundryEvmFactory, FoundryEvmNetwork, IntoInstructionResult, NestedEvm, NestedEvmFor, + run_inspected_frame, + }, +}; + +#[derive(Clone, Copy, Debug, Default)] +pub struct OpEvmNetwork; +impl FoundryEvmNetwork for OpEvmNetwork { + type Network = Optimism; + type EvmFactory = OpEvmFactory; +} + +type OpEvmHandler<'db, I> = + OpHandler, EVMError, EthFrame>; + +pub type OpRevmEvm<'db, I> = RevmEvm< + OpEvmContext<&'db mut dyn DatabaseExt>, + I, + EthInstructions>>, + PrecompilesMap, +>; + +impl FoundryChain for L1BlockInfo { + fn clear_transaction_fee_cache(&mut self) { + self.clear_tx_l1_cost(); + } +} + +impl IntoInstructionResult for OpHaltReason { + fn into_instruction_result(self) -> InstructionResult { + match self { + Self::Base(eth) => eth.into(), + Self::FailedDeposit => InstructionResult::Stop, + } + } +} + +impl FoundryEvmFactory for OpEvmFactory { + type Chain = L1BlockInfo; + type FoundryContext<'db> = OpEvmContext<&'db mut dyn DatabaseExt>; + + type FoundryEvm<'db, I: FoundryInspectorExt>> = + OpEvm<&'db mut dyn DatabaseExt, I, Self::Precompiles>; + + fn create_foundry_evm_with_inspector<'db, I: FoundryInspectorExt>>( + &self, + db: &'db mut dyn DatabaseExt, + evm_env: EvmEnv, + inspector: I, + ) -> Self::FoundryEvm<'db, I> { + let mut op_evm = Self::default().create_evm_with_inspector(db, evm_env, inspector); + op_evm.cfg.tx_chain_id_check = true; + op_evm + } + + fn create_nested_evm_with_inspector<'db, I>( + &self, + db: &'db mut dyn DatabaseExt, + evm_env: EvmEnv, + inspector: I, + ) -> NestedEvmFor<'db, Self> + where + I: FoundryInspectorExt> + 'db, + { + Box::new(self.create_foundry_evm_with_inspector(db, evm_env, inspector).into_inner()) + } +} + +impl<'db, I: FoundryInspectorExt>>> NestedEvm + for OpRevmEvm<'db, I> +{ + type Spec = OpSpecId; + type Block = BlockEnv; + type Tx = OpTx; + type Chain = L1BlockInfo; + type Journal = Journal<&'db mut dyn DatabaseExt>; + + fn tx_mut(&mut self) -> &mut Self::Tx { + self.ctx_mut().tx_mut() + } + + fn journal_inner_mut(&mut self) -> &mut JournaledState { + &mut self.ctx().journaled_state.inner + } + + fn chain_mut(&mut self) -> &mut Self::Chain { + &mut self.ctx_mut().chain + } + + fn precompiles_mut(&mut self) -> &mut alloy_evm::precompiles::PrecompilesMap { + &mut self.0.precompiles + } + + fn journal_mut(&mut self) -> &mut Self::Journal { + &mut self.ctx_mut().journaled_state + } + + fn run_execution(&mut self, frame: FrameInput) -> Result> { + run_inspected_frame(self, OpEvmHandler::::new(), frame).map_err(map_op_error) + } + + fn transact_raw(&mut self, tx: Self::Tx) -> eyre::Result> { + if self.ctx().cfg().disable_fee_charge + && tx.enveloped_tx().is_some_and(|enveloped| enveloped.is_empty()) + { + self.ctx_mut().chain_mut().clear_transaction_fee_cache(); + } + self.ctx().set_tx(tx); + + let mut handler = OpEvmHandler::::new(); + let result = handler.inspect_run(self).map_err(map_op_error)?; + + let result = result.map_haltreason(|h| match h { + OpHaltReason::Base(eth) => eth, + _ => HaltReason::PrecompileError, + }); + + Ok(ResultAndState::new(result, self.ctx_ref().journaled_state.inner.state.clone())) + } + + fn to_evm_env(&self) -> EvmEnv { + self.ctx_ref().evm_clone() + } +} + +/// Maps an OP [`EVMError`] to the common `EVMError` used by [`NestedEvm`]. +fn map_op_error(e: EVMError) -> EVMError { + match e { + EVMError::Database(db) => EVMError::Database(db), + EVMError::Header(h) => EVMError::Header(h), + EVMError::Custom(s) => EVMError::Custom(s), + EVMError::Transaction(t) => EVMError::Custom(format!("op transaction error: {t}")), + EVMError::CustomAny(custom_any_error) => EVMError::CustomAny(custom_any_error), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backend::Backend; + use alloy_primitives::{Address, Bytes, TxKind, U256}; + use op_revm::constants::L1_FEE_RECIPIENT; + use revm::{context::TxEnv, inspector::NoOpInspector, state::AccountInfo}; + + #[test] + fn constructors_allow_restoring_l1_block_context() { + let factory = OpEvmFactory::default(); + let mut db = Backend::::spawn(None).unwrap(); + let chain = L1BlockInfo { + l2_block: Some(U256::from(42)), + l1_base_fee: U256::from(123), + tx_l1_cost: Some(U256::from(456)), + ..Default::default() + }; + { + let mut evm = factory.create_foundry_evm_with_inspector( + &mut db, + EvmEnv::::default(), + NoOpInspector, + ); + *evm.chain_mut() = chain.clone(); + assert_eq!(evm.chain().l2_block, chain.l2_block); + assert_eq!(evm.chain().l1_base_fee, chain.l1_base_fee); + assert_eq!(evm.chain().tx_l1_cost, chain.tx_l1_cost); + } + let mut evm = factory.create_nested_evm_with_inspector( + &mut db, + EvmEnv::::default(), + NoOpInspector, + ); + *evm.chain_mut() = chain.clone(); + assert_eq!(evm.chain_mut().l2_block, chain.l2_block); + assert_eq!(evm.chain_mut().l1_base_fee, chain.l1_base_fee); + assert_eq!(evm.chain_mut().tx_l1_cost, chain.tx_l1_cost); + } + + #[test] + fn nested_execution_uses_restored_l1_block_context() { + let caller = Address::with_last_byte(0x42); + let recipient = Address::with_last_byte(0x43); + let l1_cost = U256::from(456); + let mut db = Backend::::spawn(None).unwrap(); + db.insert_account_info(caller, AccountInfo { balance: U256::MAX, ..Default::default() }); + + let mut evm_env = EvmEnv::::default(); + evm_env.cfg_env.spec = OpSpecId::REGOLITH; + evm_env.cfg_env.disable_fee_charge = false; + let mut evm = OpEvmFactory::default().create_nested_evm(&mut db, evm_env); + *evm.chain_mut() = L1BlockInfo { + l2_block: Some(U256::ZERO), + tx_l1_cost: Some(l1_cost), + ..Default::default() + }; + let tx = OpTx(op_revm::OpTransaction { + base: TxEnv { + caller, + gas_limit: 21_000, + kind: TxKind::Call(recipient), + ..Default::default() + }, + enveloped_tx: Some(Default::default()), + deposit: Default::default(), + }); + + let result = evm.transact_raw(tx).unwrap(); + + assert!(result.result.is_success()); + assert_eq!(result.state[&L1_FEE_RECIPIENT].info.balance, l1_cost); + } + + #[test] + fn nested_execution_skips_cached_l1_cost_when_fees_disabled() { + let caller = Address::with_last_byte(0x42); + let recipient = Address::with_last_byte(0x43); + let mut db = Backend::::spawn(None).unwrap(); + db.insert_account_info(caller, AccountInfo { balance: U256::MAX, ..Default::default() }); + + let mut evm_env = EvmEnv::::default(); + evm_env.cfg_env.spec = OpSpecId::REGOLITH; + evm_env.cfg_env.disable_fee_charge = true; + let mut evm = OpEvmFactory::default().create_nested_evm(&mut db, evm_env); + *evm.chain_mut() = L1BlockInfo { + l2_block: Some(U256::ZERO), + tx_l1_cost: Some(U256::from(456)), + ..Default::default() + }; + let tx = OpTx(op_revm::OpTransaction { + base: TxEnv { + caller, + gas_limit: 21_000, + kind: TxKind::Call(recipient), + ..Default::default() + }, + enveloped_tx: Some(Bytes::new()), + deposit: Default::default(), + }); + + let result = evm.transact_raw(tx).unwrap(); + + assert!(result.result.is_success()); + assert_eq!(result.state[&caller].info.balance, U256::MAX); + assert_eq!(result.state[&L1_FEE_RECIPIENT].info.balance, U256::ZERO); + } +} diff --git a/crates/evm/core/src/evm/tempo.rs b/crates/evm/core/src/evm/tempo.rs new file mode 100644 index 0000000000000..ea94868b35649 --- /dev/null +++ b/crates/evm/core/src/evm/tempo.rs @@ -0,0 +1,236 @@ +use alloy_evm::{Evm, EvmEnv, EvmFactory}; +use alloy_primitives::Bytes; +use foundry_evm_hardforks::TempoHardfork; +use foundry_fork_db::DatabaseError; +use revm::{ + context::{ + Journal, + result::{EVMError, ResultAndState}, + }, + handler::{EvmTr, FrameResult}, + inspector::InspectorHandler, + interpreter::FrameInput, + state::Bytecode, +}; +use tempo_alloy::TempoNetwork; +use tempo_evm::{TempoBlockEnv, TempoEvmFactory, evm::TempoEvm}; +use tempo_precompiles::{ + extend_tempo_precompiles, + storage::{StorageActions, StorageCtx}, +}; +use tempo_revm::{ + TempoInvalidTransaction, TempoTxEnv, evm::TempoContext, gas_params::tempo_gas_params, + handler::TempoEvmHandler, +}; + +use crate::{ + FoundryContextExt, FoundryInspectorExt, + backend::{DatabaseExt, JournaledState}, + constants::{CALLER, SYSTEM_PRECOMPILE_STUB, TEST_CONTRACT_ADDRESS}, + evm::{FoundryEvmFactory, FoundryEvmNetwork, NestedEvm, NestedEvmFor, run_inspected_frame}, + tempo::{TEMPO_PRECOMPILE_ADDRESSES, TEMPO_TIP20_TOKENS, initialize_tempo_test_genesis_inner}, +}; + +#[derive(Clone, Copy, Debug, Default)] +pub struct TempoEvmNetwork; +impl FoundryEvmNetwork for TempoEvmNetwork { + type Network = TempoNetwork; + type EvmFactory = TempoEvmFactory; +} + +// Will be removed when the next revm release includes bluealloy/revm#3518. +pub type TempoRevmEvm<'db, I> = tempo_revm::TempoEvm<&'db mut dyn DatabaseExt, I>; + +impl FoundryEvmFactory for TempoEvmFactory { + type Chain = (); + type FoundryContext<'db> = TempoContext<&'db mut dyn DatabaseExt>; + + type FoundryEvm<'db, I: FoundryInspectorExt>> = + TempoEvm<&'db mut dyn DatabaseExt, I>; + + fn create_foundry_evm_with_inspector<'db, I: FoundryInspectorExt>>( + &self, + db: &'db mut dyn DatabaseExt, + evm_env: EvmEnv, + inspector: I, + ) -> Self::FoundryEvm<'db, I> { + let is_forked = db.is_forked_mode(); + let spec = *evm_env.spec_id(); + let mut tempo_evm = Self::default().create_evm_with_inspector(db, evm_env, inspector); + Self::set_execution_spec(tempo_evm.ctx_mut(), spec); + tempo_evm.cfg.tx_chain_id_check = true; + if tempo_evm.cfg.tx_gas_limit_cap.is_none() { + tempo_evm.cfg.tx_gas_limit_cap = spec.tx_gas_limit_cap(); + } + + // Re-extend Tempo precompiles, preserving shared non-creditable slots. + let cfg = tempo_evm.cfg.clone(); + let non_creditable_slots = tempo_evm.non_creditable_slots(); + extend_tempo_precompiles( + tempo_evm.precompiles_mut(), + &cfg, + StorageActions::disabled(), + non_creditable_slots, + ); + + initialize_tempo_evm(&mut tempo_evm, is_forked); + tempo_evm + } + + fn create_nested_evm_with_inspector<'db, I>( + &self, + db: &'db mut dyn DatabaseExt, + evm_env: EvmEnv, + inspector: I, + ) -> NestedEvmFor<'db, Self> + where + I: FoundryInspectorExt> + 'db, + { + Box::new(self.create_foundry_evm_with_inspector(db, evm_env, inspector).into_inner()) + } + + fn set_execution_spec(context: &mut Self::FoundryContext<'_>, spec: Self::Spec) { + context.cfg.spec = spec; + context.cfg.set_gas_params(tempo_gas_params(spec)); + } +} + +impl<'db, I: FoundryInspectorExt>>> NestedEvm + for TempoRevmEvm<'db, I> +{ + type Spec = TempoHardfork; + type Block = TempoBlockEnv; + type Tx = TempoTxEnv; + type Chain = (); + type Journal = Journal<&'db mut dyn DatabaseExt>; + + fn tx_mut(&mut self) -> &mut Self::Tx { + self.ctx_mut().tx_mut() + } + + fn journal_inner_mut(&mut self) -> &mut JournaledState { + &mut self.ctx_mut().journaled_state.inner + } + + fn chain_mut(&mut self) -> &mut Self::Chain { + &mut self.ctx_mut().chain + } + + fn precompiles_mut(&mut self) -> &mut alloy_evm::precompiles::PrecompilesMap { + &mut self.precompiles + } + + fn journal_mut(&mut self) -> &mut Self::Journal { + &mut self.ctx_mut().journaled_state + } + + fn run_execution(&mut self, frame: FrameInput) -> Result> { + run_inspected_frame(self, TempoEvmHandler::new(), frame).map_err(map_tempo_error) + } + + fn transact_raw(&mut self, tx: Self::Tx) -> eyre::Result { + // The backend seeds nested transactions at outer depth + 1. Tempo AA's batch checkpoint + // supplies that increment before inspector callbacks, so avoid counting it twice. + if tx.tempo_tx_env.is_some() && self.ctx.journaled_state.inner.depth > 0 { + self.ctx.journaled_state.inner.depth -= 1; + } + self.set_tx(tx); + + let mut handler = TempoEvmHandler::new(); + let result = handler.inspect_run(self).map_err(map_tempo_error)?; + + Ok(ResultAndState::new(result, self.ctx.journaled_state.inner.state.clone())) + } + + fn to_evm_env(&self) -> EvmEnv { + self.ctx_ref().evm_clone() + } +} + +/// Maps a Tempo [`EVMError`] to the common `EVMError` used by [`NestedEvm`]. +/// +/// This exists because [`NestedEvm`] currently uses Eth-typed errors. When `NestedEvm` gains +/// an associated `Error` type, this mapping can be removed. +pub(crate) fn map_tempo_error( + e: EVMError, +) -> EVMError { + match e { + EVMError::Database(db) => EVMError::Database(db), + EVMError::Header(h) => EVMError::Header(h), + EVMError::Custom(s) => EVMError::Custom(s), + EVMError::CustomAny(custom_any_error) => EVMError::CustomAny(custom_any_error), + EVMError::Transaction(t) => match t { + TempoInvalidTransaction::EthInvalidTransaction(eth) => EVMError::Transaction(eth), + t => EVMError::Custom(format!("tempo transaction error: {t}")), + }, + } +} + +/// Initialize Tempo precompiles and contracts for a newly created EVM. +/// +/// In non-fork mode, runs full genesis initialization (precompile sentinel bytecode, +/// TIP20 fee tokens, standard contracts) via [`StorageCtx::enter_evm`]. +/// +/// In fork mode, warms up precompile and TIP20 token addresses with sentinel bytecode +/// to prevent repeated RPC round-trips for addresses that are Rust-native precompiles +/// on Tempo nodes (no real EVM bytecode on-chain). +pub(crate) fn initialize_tempo_evm< + 'db, + I: FoundryInspectorExt>>, +>( + evm: &mut TempoEvm<&'db mut dyn DatabaseExt, I>, + is_forked: bool, +) { + let ctx = evm.ctx_mut(); + StorageCtx::enter_evm( + &mut ctx.journaled_state, + &ctx.block, + &ctx.cfg, + &ctx.tx, + StorageActions::disabled(), + || { + if is_forked { + // In fork mode, warm up precompile accounts to avoid repeated RPC fetches. + let mut sctx = StorageCtx; + let sentinel = Bytecode::new_legacy(Bytes::from_static(SYSTEM_PRECOMPILE_STUB)); + for addr in TEMPO_PRECOMPILE_ADDRESSES + .iter() + .copied() + .chain(TEMPO_TIP20_TOKENS.iter().copied()) + { + sctx.set_code(addr, sentinel.clone()) + .expect("failed to warm tempo precompile address"); + } + } else { + // In non-fork mode, run full genesis initialization. + initialize_tempo_test_genesis_inner(TEST_CONTRACT_ADDRESS, CALLER) + .expect("tempo genesis initialization failed"); + } + }, + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backend::Backend; + use revm::{ + context_interface::cfg::GasParams, inspector::NoOpInspector, primitives::hardfork::SpecId, + }; + + #[test] + fn tempo_execution_spec_preserves_network_gas() { + let mut db = Backend::::spawn(None).unwrap(); + let mut evm = TempoEvmFactory::default().create_foundry_evm_with_inspector( + &mut db, + EvmEnv::default(), + NoOpInspector, + ); + for spec in [TempoHardfork::T3, TempoHardfork::T7, TempoHardfork::T14] { + TempoEvmFactory::set_execution_spec(evm.ctx_mut(), spec); + assert_eq!(evm.cfg.spec, spec); + assert_eq!(evm.cfg.gas_params, tempo_gas_params(spec)); + assert_ne!(evm.cfg.gas_params, GasParams::new_spec(SpecId::OSAKA)); + } + } +} diff --git a/crates/evm/core/src/fork/bal.rs b/crates/evm/core/src/fork/bal.rs new file mode 100644 index 0000000000000..7eefb43ac4db9 --- /dev/null +++ b/crates/evm/core/src/fork/bal.rs @@ -0,0 +1,168 @@ +//! Validates and caches BAL post-state, and prepares transaction forks' parent-block BALs. + +use super::Fork; +use crate::opts::ForkContext; +use alloy_chains::{Chain, NamedChain}; +use alloy_consensus::BlockHeader; +use alloy_eips::{ + BlockId, + eip7928::{BlockAccessList, compute_block_access_list_hash, validate_block_access_list}, +}; +use alloy_hardforks::EthereumHardfork; +use alloy_network::{AnyNetwork, AnyRpcBlock}; +use alloy_primitives::{ + B256, U256, + map::{AddressHashMap, U256Map}, +}; +use alloy_provider::Provider; +use eyre::{Result, WrapErr}; +use foundry_common::provider::is_rpc_method_not_found; +use foundry_evm_networks::NetworkConfigs; +use foundry_fork_db::cache::MemDb; +use revm::state::{AccountInfo, Bytecode}; +use std::time::Duration; + +/// Fetches and validates a parent BAL without mutating a database or propagating BAL failures. +pub(super) async fn prepare>( + provider: &P, + resolved: &Fork, + block: &AnyRpcBlock, +) -> Option { + if !eligible_source(resolved.context()) + || block.header.hash != resolved.hash() + || block.header.number() != resolved.number() + || !EthereumHardfork::from_chain_and_timestamp( + Chain::from_id(resolved.context().source_chain_id), + block.header.timestamp(), + ) + .is_some_and(|hardfork| hardfork >= EthereumHardfork::Cancun) + { + return None; + } + + let prepare = async { + // An inconclusive discovery probe is not proof of an immutable source. + if !immutable_source(provider).await { + return None; + } + let bal = provider.get_block_access_list(BlockId::hash(resolved.hash())).await.ok()??; + if let Err(err) = + validate_bal(&bal, block.transactions.len(), block.header.block_access_list_hash()) + { + debug!(target: "backend::fork", block_hash = %resolved.hash(), %err, "ignoring invalid fork BAL"); + return None; + } + // A local node can change state without changing its block hash. + immutable_source(provider).await.then_some(bal) + }; + + // Reuse the validated block; optional BAL and source probes share one budget, including + // retries. + let bal = tokio::time::timeout(Duration::from_millis(500), prepare).await.ok().flatten(); + if bal.is_none() { + debug!(target: "backend::fork", block_hash = %resolved.hash(), "fork BAL unavailable or ineligible"); + } + bal +} + +fn eligible_source(context: ForkContext) -> bool { + context.network.is_ethereum() + && context.network_profile.canonical_execution_profile() == NetworkConfigs::default() + && context.hardfork.is_none() + && context.instance_id.is_none() + && context.source_fork_block_number.is_none() + && context.source_fork_block_hash.is_none() + && matches!( + NamedChain::try_from(context.source_chain_id), + Ok(NamedChain::Mainnet | NamedChain::Sepolia | NamedChain::Holesky | NamedChain::Hoodi) + ) +} + +async fn immutable_source>(provider: &P) -> bool { + matches!( + provider.raw_request::<_, serde_json::Value>("anvil_nodeInfo".into(), ()).await, + Err(error) if is_rpc_method_not_found(&error) + ) +} + +/// Validates the entire BAL before any values can enter the cache. +/// +/// Callers must separately establish that the BAL and cache belong to the same immutable block. +pub fn validate_bal( + bal: &BlockAccessList, + transaction_count: usize, + expected_hash: Option, +) -> Result<()> { + validate_block_access_list(bal, transaction_count).wrap_err("invalid BAL structure")?; + if let Some(expected_hash) = expected_hash { + eyre::ensure!(compute_block_access_list_hash(bal) == expected_hash, "BAL hash mismatch"); + } + for account in bal { + // Reject invalid code even in earlier changes or incomplete accounts. + for change in &account.code_changes { + Bytecode::new_raw_checked(change.new_code.clone()).wrap_err("invalid BAL code")?; + } + } + Ok(()) +} + +/// Inserts a validated BAL's post-state into its selected remote cache, retaining existing values. +/// +/// The BAL must pass [`validate_bal`] before this call, and the cache must belong to its immutable +/// source block. Account and storage locks are acquired separately; insertion is not atomic across +/// the two maps. +pub fn cache_bal(db: &MemDb, bal: BlockAccessList) { + let mut accounts = db.accounts.write(); + let inserted_accounts = cache_bal_accounts(&mut accounts, &bal); + drop(accounts); + + let mut storage = db.storage.write(); + let inserted_slots = cache_bal_storage(&mut storage, &bal); + drop(storage); + debug!(target: "backend::fork", inserted_accounts, inserted_slots, "prefilled fork cache from BAL"); +} + +/// Inserts complete account post-states, retaining existing accounts, and returns the added count. +fn cache_bal_accounts(accounts: &mut AddressHashMap, bal: &BlockAccessList) -> usize { + let accounts_before = accounts.len(); + for account in bal { + if let (Some(balance), Some(nonce), Some(code)) = + (account.balance_post_state(), account.nonce_post_state(), account.code_changes.last()) + { + accounts.entry(account.address()).or_insert_with(|| { + let code = Bytecode::new_raw(code.new_code.clone()); + AccountInfo { + balance, + nonce, + code_hash: code.hash_slow(), + code: Some(code), + account_id: None, + } + }); + } + } + accounts.len() - accounts_before +} + +/// Inserts final slot writes, retaining cached values and leaving read-only slots unknown. +/// +/// Returns the number of added slots. +fn cache_bal_storage(storage: &mut AddressHashMap>, bal: &BlockAccessList) -> usize { + let mut inserted_slots = 0; + for account in bal { + if !account.storage_changes.is_empty() { + let cached_slots = storage.entry(account.address()).or_insert_with(|| { + U256Map::with_capacity_and_hasher(account.storage_changes.len(), Default::default()) + }); + let slots_before = cached_slots.len(); + for (slot, value) in account.storage_post_states() { + cached_slots.entry(slot).or_insert(value); + } + inserted_slots += cached_slots.len() - slots_before; + } + } + inserted_slots +} + +#[cfg(test)] +mod tests; diff --git a/crates/evm/core/src/fork/bal/tests.rs b/crates/evm/core/src/fork/bal/tests.rs new file mode 100644 index 0000000000000..2526b178a556d --- /dev/null +++ b/crates/evm/core/src/fork/bal/tests.rs @@ -0,0 +1,402 @@ +use super::*; +use alloy_eips::{ + BlockNumHash, + eip7928::{ + AccountChanges, BalanceChange, BlockAccessIndex, CodeChange, NonceChange, SlotChanges, + StorageChange, + }, +}; +use alloy_network::{AnyHeader, AnyRpcHeader}; +use alloy_primitives::{Address, Bytes, bytes}; +use alloy_provider::{ProviderBuilder, mock::Asserter}; +use alloy_rpc_types::{Block, BlockTransactions}; +use foundry_config::FoundryHardfork; +use foundry_evm_networks::NetworkVariant; + +fn context() -> ForkContext { + ForkContext { + execution_chain_id: 1, + source_chain_id: 1, + network: NetworkVariant::Ethereum, + network_profile: NetworkConfigs::default(), + block_number: 20_000_000, + hardfork: None, + instance_id: None, + source_fork_block_number: None, + source_fork_block_hash: None, + } +} + +fn resolved(context: ForkContext) -> Fork { + Fork::test( + "http://localhost:8545", + None, + None, + Some(context.block_number), + BlockNumHash::new(context.block_number, B256::repeat_byte(1)), + context, + ) +} + +fn block(resolved: &Fork, transactions: usize) -> AnyRpcBlock { + let header = + AnyHeader { number: resolved.number(), timestamp: 1_710_338_135, ..Default::default() }; + AnyRpcBlock::new( + Block::new( + AnyRpcHeader::from_sealed(header.seal(resolved.hash())), + BlockTransactions::Hashes(vec![B256::ZERO; transactions]), + ) + .into(), + ) +} + +fn index(index: u64) -> BlockAccessIndex { + BlockAccessIndex::new(index) +} + +fn complete_account(address: Address, code: Bytes) -> AccountChanges { + AccountChanges::new(address) + .with_balance_change(BalanceChange::new(index(1), U256::from(42))) + .with_nonce_change(NonceChange::new(index(1), 3)) + .with_code_change(CodeChange::new(index(1), code)) +} + +#[test] +fn fork_bal_cache_keeps_final_zero_and_system_writes() { + let address = Address::repeat_byte(1); + let slot = U256::ONE; + let system_slot = U256::from(2); + let account = AccountChanges::new(address) + .with_storage_change(SlotChanges::new( + slot, + vec![ + StorageChange::new(index(0), U256::from(3)), + StorageChange::new(index(1), U256::from(4)), + StorageChange::new(index(3), U256::ZERO), + ], + )) + .with_storage_change(SlotChanges::new( + system_slot, + vec![StorageChange::new(index(0), U256::from(5))], + )); + let db = MemDb::default(); + + let bal = vec![account]; + validate_bal(&bal, 2, None).unwrap(); + cache_bal(&db, bal); + + let storage = db.storage.read(); + assert_eq!(storage[&address][&slot], U256::ZERO); + assert_eq!(storage[&address][&system_slot], U256::from(5)); + assert!(db.accounts.read().is_empty()); +} + +#[test] +fn fork_bal_cache_leaves_partial_accounts_and_reads_unknown() { + let address = Address::repeat_byte(1); + let account = AccountChanges::new(address) + .with_balance_change(BalanceChange::new(index(1), U256::from(42))) + .with_storage_read(U256::from(2)); + let db = MemDb::default(); + + let bal = vec![account]; + validate_bal(&bal, 1, None).unwrap(); + cache_bal(&db, bal); + + assert!(db.accounts.read().is_empty()); + assert!(db.storage.read().is_empty()); +} + +#[test] +fn fork_bal_cache_preserves_cached_values_and_merges_slots() { + let address = Address::repeat_byte(1); + let account = complete_account(address, Bytes::new()) + .with_storage_change(SlotChanges::new( + U256::ONE, + vec![StorageChange::new(index(1), U256::from(11))], + )) + .with_storage_change(SlotChanges::new( + U256::from(2), + vec![StorageChange::new(index(1), U256::from(22))], + )); + let db = MemDb::default(); + let cached_account = AccountInfo { balance: U256::from(99), ..Default::default() }; + db.accounts.write().insert(address, cached_account.clone()); + db.storage.write().insert( + address, + [(U256::ONE, U256::from(101)), (U256::from(3), U256::from(303))].into_iter().collect(), + ); + + let bal = vec![account]; + validate_bal(&bal, 1, None).unwrap(); + for _ in 0..2 { + cache_bal(&db, bal.clone()); + + assert_eq!(db.accounts.read()[&address], cached_account); + assert_eq!( + db.storage.read()[&address], + [ + (U256::ONE, U256::from(101)), + (U256::from(2), U256::from(22)), + (U256::from(3), U256::from(303)), + ] + .into_iter() + .collect() + ); + } +} + +#[test] +fn fork_bal_cache_preserves_delegation_code_and_final_clearing() { + let authority = Address::repeat_byte(1); + let cleared = Address::repeat_byte(2); + let delegation = bytes!("ef01000000000000000000000000000000000000000042"); + let cleared_account = complete_account(cleared, delegation.clone()) + .with_balance_change(BalanceChange::new(index(2), U256::ZERO)) + .with_nonce_change(NonceChange::new(index(2), 0)) + .with_code_change(CodeChange::new(index(2), Bytes::new())); + let db = MemDb::default(); + + let bal = vec![complete_account(authority, delegation.clone()), cleared_account]; + validate_bal(&bal, 2, None).unwrap(); + cache_bal(&db, bal); + + let accounts = db.accounts.read(); + let account = &accounts[&authority]; + assert_eq!(account.balance, U256::from(42)); + assert_eq!(account.nonce, 3); + assert_eq!(account.code_hash(), alloy_primitives::keccak256(&delegation)); + assert_eq!(account.code.as_ref().unwrap().original_bytes(), delegation); + assert!(account.code.as_ref().unwrap().is_eip7702()); + let account = &accounts[&cleared]; + assert_eq!(account.balance, U256::ZERO); + assert_eq!(account.nonce, 0); + assert_eq!(account.code_hash(), alloy_primitives::KECCAK256_EMPTY); + assert!(account.code.as_ref().unwrap().is_empty()); +} + +#[test] +fn fork_bal_validation_rejects_invalid_structure_and_hash() { + let address = Address::repeat_byte(1); + let valid = vec![complete_account(address, Bytes::new())]; + let commitment = compute_block_access_list_hash(&valid); + assert!(validate_bal(&valid, 1, Some(commitment)).is_ok()); + assert!(validate_bal(&valid, 1, Some(B256::ZERO)).is_err()); + + let duplicate_accounts = vec![valid[0].clone(), valid[0].clone()]; + assert!(validate_bal(&duplicate_accounts, 1, None).is_err()); + + let invalid_index = + AccountChanges::new(address).with_balance_change(BalanceChange::new(index(3), U256::ZERO)); + assert!(validate_bal(&vec![invalid_index], 1, None).is_err()); +} + +#[tokio::test] +async fn fork_bal_prepare_skips_mutable_and_custom_sources_without_requests() { + let ordinary = context(); + let contexts = [ + ForkContext { source_chain_id: 31337, ..ordinary }, + ForkContext { network: NetworkVariant::Tempo, ..ordinary }, + ForkContext { network_profile: NetworkConfigs::with_celo(), ..ordinary }, + ForkContext { network_profile: NetworkConfigs::with_tempo(), ..ordinary }, + ForkContext { + hardfork: Some(FoundryHardfork::Ethereum(EthereumHardfork::Cancun)), + ..ordinary + }, + ForkContext { instance_id: Some(B256::ZERO), ..ordinary }, + ForkContext { source_fork_block_number: Some(1), ..ordinary }, + ForkContext { source_fork_block_hash: Some(B256::ZERO), ..ordinary }, + ]; + for context in contexts { + let resolved = resolved(context); + let asserter = Asserter::new(); + asserter.push_success(&BlockAccessList::new()); + let provider = + ProviderBuilder::<_, _, AnyNetwork>::default().connect_mocked_client(asserter.clone()); + assert!(prepare(&provider, &resolved, &block(&resolved, 0)).await.is_none()); + assert_eq!(asserter.read_q().len(), 1); + } +} + +#[tokio::test] +async fn fork_bal_prepare_checks_parent_identity_and_source_timestamp_before_bal() { + let resolved = resolved(context()); + let valid = block(&resolved, 1); + for field in ["hash", "number", "timestamp"] { + let mut block = valid.clone(); + match field { + "hash" => block.header.hash = B256::repeat_byte(2), + "number" => block.header.number += 1, + "timestamp" => block.header.timestamp -= 1, + _ => unreachable!(), + } + let asserter = Asserter::new(); + asserter.push_success(&BlockAccessList::new()); + let provider = + ProviderBuilder::<_, _, AnyNetwork>::default().connect_mocked_client(asserter.clone()); + + assert!(prepare(&provider, &resolved, &block).await.is_none()); + assert_eq!(asserter.read_q().len(), 1); + } +} + +#[tokio::test] +async fn fork_bal_prepare_reuses_block_with_execution_chain_override() { + for chain in [NamedChain::Mainnet, NamedChain::Sepolia, NamedChain::Holesky, NamedChain::Hoodi] + { + let resolved = resolved(ForkContext { + source_chain_id: chain as u64, + execution_chain_id: 31337, + ..context() + }); + let asserter = Asserter::new(); + rpc_error(&asserter, -32601); + asserter.push_success(&BlockAccessList::new()); + rpc_error(&asserter, -32601); + let provider = + ProviderBuilder::<_, _, AnyNetwork>::default().connect_mocked_client(asserter.clone()); + + assert!(prepare(&provider, &resolved, &block(&resolved, 0)).await.is_some()); + assert!(asserter.read_q().is_empty()); + } +} + +fn rpc_error(asserter: &Asserter, code: i64) { + asserter.push_failure( + serde_json::from_value(serde_json::json!({"code": code, "message": "unavailable"})) + .unwrap(), + ); +} + +#[tokio::test] +async fn fork_bal_prepare_requires_immutable_source_before_and_after_bal() { + let resolved = resolved(context()); + let block = block(&resolved, 0); + for final_probe in [false, true] { + for error in [None, Some(-32603)] { + let asserter = Asserter::new(); + if final_probe { + rpc_error(&asserter, -32601); + asserter.push_success(&BlockAccessList::new()); + } + if let Some(code) = error { + rpc_error(&asserter, code); + } else { + // Even an unrecognized successful response identifies a potentially mutable node. + asserter.push_success(&serde_json::json!({})); + } + asserter.push_success(&BlockAccessList::new()); + let provider = ProviderBuilder::<_, _, AnyNetwork>::default() + .connect_mocked_client(asserter.clone()); + + assert!(prepare(&provider, &resolved, &block).await.is_none()); + assert_eq!(asserter.read_q().len(), 1, "requests continued after an uncertain source"); + } + } +} + +#[tokio::test] +async fn fork_bal_prepare_ends_on_bal_errors() { + let resolved = resolved(context()); + let block = block(&resolved, 0); + for code in [-32601, -32603] { + let asserter = Asserter::new(); + rpc_error(&asserter, -32601); + rpc_error(&asserter, code); + asserter.push_success(&BlockAccessList::new()); + let provider = + ProviderBuilder::<_, _, AnyNetwork>::default().connect_mocked_client(asserter.clone()); + + assert!(prepare(&provider, &resolved, &block).await.is_none()); + assert_eq!(asserter.read_q().len(), 1, "code: {code}"); + } +} + +#[test] +fn fork_bal_validation_rejects_invalid_earlier_code() { + let address = Address::repeat_byte(1); + for complete in [false, true] { + let invalid_code = bytes!("ef0100"); + let account = if complete { + complete_account(address, invalid_code) + } else { + AccountChanges::new(address).with_code_change(CodeChange::new(index(1), invalid_code)) + } + .with_code_change(CodeChange::new(index(2), Bytes::new())); + let bal = vec![account]; + + validate_block_access_list(&bal, 2).unwrap(); + assert!(validate_bal(&bal, 2, None).is_err(), "complete account: {complete}"); + } +} + +#[test] +fn fork_bal_cache_keeps_empty_block_post_execution_writes() { + let address = Address::repeat_byte(1); + let account = AccountChanges::new(address).with_storage_change(SlotChanges::new( + U256::ONE, + vec![StorageChange::new(index(1), U256::from(42))], + )); + let bal = vec![account]; + let db = MemDb::default(); + + validate_bal(&bal, 0, None).unwrap(); + cache_bal(&db, bal); + + assert_eq!(db.storage.read()[&address][&U256::ONE], U256::from(42)); + assert!(db.accounts.read().is_empty()); + + let invalid = AccountChanges::new(address).with_storage_change(SlotChanges::new( + U256::ONE, + vec![StorageChange::new(index(2), U256::from(42))], + )); + assert!(validate_bal(&vec![invalid], 0, None).is_err()); +} + +#[test] +fn fork_bal_cache_counts_only_new_entries() { + let cached_address = Address::repeat_byte(1); + let new_address = Address::repeat_byte(2); + let partial_address = Address::repeat_byte(3); + let bal = vec![ + complete_account(cached_address, Bytes::new()) + .with_storage_change(SlotChanges::new( + U256::ONE, + vec![StorageChange::new(index(1), U256::from(11))], + )) + .with_storage_change(SlotChanges::new( + U256::from(2), + vec![StorageChange::new(index(1), U256::from(22))], + )), + complete_account(new_address, Bytes::new()).with_storage_change(SlotChanges::new( + U256::ONE, + vec![StorageChange::new(index(1), U256::ZERO)], + )), + AccountChanges::new(partial_address) + .with_balance_change(BalanceChange::new(index(1), U256::from(42))) + .with_storage_read(U256::ONE), + ]; + let db = MemDb::default(); + let cached_account = AccountInfo { balance: U256::from(99), ..Default::default() }; + db.accounts.write().insert(cached_address, cached_account.clone()); + db.storage.write().entry(cached_address).or_default().insert(U256::ONE, U256::from(101)); + + validate_bal(&bal, 1, None).unwrap(); + assert_eq!(cache_bal_accounts(&mut db.accounts.write(), &bal), 1); + assert_eq!(cache_bal_storage(&mut db.storage.write(), &bal), 2); + assert_eq!(cache_bal_accounts(&mut db.accounts.write(), &bal), 0); + assert_eq!(cache_bal_storage(&mut db.storage.write(), &bal), 0); + + let accounts = db.accounts.read(); + assert_eq!(accounts.len(), 2); + assert_eq!(accounts[&cached_address], cached_account); + assert_eq!(accounts[&new_address].balance, U256::from(42)); + drop(accounts); + let storage = db.storage.read(); + assert_eq!(storage.len(), 2); + assert_eq!( + storage[&cached_address], + [(U256::ONE, U256::from(101)), (U256::from(2), U256::from(22))].into_iter().collect() + ); + assert_eq!(storage[&new_address], [(U256::ONE, U256::ZERO)].into_iter().collect()); +} diff --git a/crates/evm/core/src/fork/database.rs b/crates/evm/core/src/fork/database.rs index 6a53a19843b1b..04509e1da8eb5 100644 --- a/crates/evm/core/src/fork/database.rs +++ b/crates/evm/core/src/fork/database.rs @@ -4,14 +4,17 @@ use crate::{ backend::{RevertStateSnapshotAction, StateSnapshot}, state_snapshot::StateSnapshots, }; -use alloy_primitives::{Address, B256, U256, map::HashMap}; +use alloy_network::Network; +use alloy_primitives::{Address, B256, U256}; use alloy_rpc_types::BlockId; -use foundry_fork_db::{BlockchainDb, DatabaseError, SharedBackend}; +use foundry_fork_db::{BlockchainDb, DatabaseError, ForkBlockEnv, SharedBackend}; use parking_lot::Mutex; use revm::{ Database, DatabaseCommit, bytecode::Bytecode, - database::{CacheDB, DatabaseRef}, + context::BlockEnv, + database::{AccountState, CacheDB, DatabaseRef}, + primitives::AddressMap, state::{Account, AccountInfo}, }; use std::sync::Arc; @@ -23,28 +26,28 @@ use std::sync::Arc; /// This database uses the `backend` for read and the `db` for write operations. But note the /// `backend` will also write (missing) data to the `db` in the background #[derive(Clone, Debug)] -pub struct ForkedDatabase { +pub struct ForkedDatabase { /// Responsible for fetching missing data. /// /// This is responsible for getting data. - backend: SharedBackend, + backend: SharedBackend, /// Cached Database layer, ensures that changes are not written to the database that /// exclusively stores the state of the remote client. /// /// This separates Read/Write operations /// - reads from the `SharedBackend as DatabaseRef` writes to the internal cache storage. - cache_db: CacheDB, + cache_db: CacheDB>, /// Contains all the data already fetched. /// /// This exclusively stores the _unchanged_ remote client state. - db: BlockchainDb, + db: BlockchainDb, /// Holds the state snapshots of a blockchain. - state_snapshots: Arc>>, + state_snapshots: Arc>>>, } -impl ForkedDatabase { +impl ForkedDatabase { /// Creates a new instance of this DB - pub fn new(backend: SharedBackend, db: BlockchainDb) -> Self { + pub fn new(backend: SharedBackend, db: BlockchainDb) -> Self { Self { cache_db: CacheDB::new(backend.clone()), backend, @@ -53,22 +56,22 @@ impl ForkedDatabase { } } - pub fn database(&self) -> &CacheDB { + pub const fn database(&self) -> &CacheDB> { &self.cache_db } - pub fn database_mut(&mut self) -> &mut CacheDB { + pub const fn database_mut(&mut self) -> &mut CacheDB> { &mut self.cache_db } - pub fn state_snapshots(&self) -> &Arc>> { + pub const fn state_snapshots(&self) -> &Arc>>> { &self.state_snapshots } /// Reset the fork to a fresh forked state, and optionally update the fork config pub fn reset( &mut self, - _url: Option, + _urls: Vec, block_number: impl Into, ) -> Result<(), String> { self.backend.set_pinned_block(block_number).map_err(|err| err.to_string())?; @@ -89,11 +92,11 @@ impl ForkedDatabase { } /// Returns the database that holds the remote state - pub fn inner(&self) -> &BlockchainDb { + pub const fn inner(&self) -> &BlockchainDb { &self.db } - pub fn create_state_snapshot(&self) -> ForkDbStateSnapshot { + pub fn create_state_snapshot(&self) -> ForkDbStateSnapshot { let db = self.db.db(); let state_snapshot = StateSnapshot { accounts: db.accounts.read().clone(), @@ -113,7 +116,7 @@ impl ForkedDatabase { /// Removes the snapshot from the tracked snapshot and sets it as the current state pub fn revert_state_snapshot(&mut self, id: U256, action: RevertStateSnapshotAction) -> bool { - let state_snapshot = { self.state_snapshots().lock().remove_at(id) }; + let state_snapshot = { self.state_snapshots().lock().remove(id) }; if let Some(state_snapshot) = state_snapshot { if action.is_keep() { self.state_snapshots().lock().insert_at(state_snapshot.clone(), id); @@ -150,7 +153,7 @@ impl ForkedDatabase { } } -impl Database for ForkedDatabase { +impl Database for ForkedDatabase { type Error = DatabaseError; fn basic(&mut self, address: Address) -> Result, Self::Error> { @@ -173,7 +176,7 @@ impl Database for ForkedDatabase { } } -impl DatabaseRef for ForkedDatabase { +impl DatabaseRef for ForkedDatabase { type Error = DatabaseError; fn basic_ref(&self, address: Address) -> Result, Self::Error> { @@ -193,8 +196,8 @@ impl DatabaseRef for ForkedDatabase { } } -impl DatabaseCommit for ForkedDatabase { - fn commit(&mut self, changes: HashMap) { +impl DatabaseCommit for ForkedDatabase { + fn commit(&mut self, changes: AddressMap) { self.database_mut().commit(changes) } } @@ -203,26 +206,31 @@ impl DatabaseCommit for ForkedDatabase { /// /// This mimics `revm::CacheDB` #[derive(Clone, Debug)] -pub struct ForkDbStateSnapshot { - pub local: CacheDB, +pub struct ForkDbStateSnapshot { + pub local: CacheDB>, pub state_snapshot: StateSnapshot, } -impl ForkDbStateSnapshot { - fn get_storage(&self, address: Address, index: U256) -> Option { - self.local - .cache - .accounts - .get(&address) - .and_then(|account| account.storage.get(&index)) - .copied() +impl ForkDbStateSnapshot { + /// Lookup storage in `state_snapshot`, then fall back to the backend (remote RPC). + fn storage_from_snapshot_or_backend( + &self, + address: Address, + index: U256, + ) -> Result { + // Check state_snapshot.storage first (data fetched by SharedBackend / disk cache). + if let Some(val) = self.state_snapshot.storage.get(&address).and_then(|s| s.get(&index)) { + return Ok(*val); + } + // Fall back to the underlying backend (SharedBackend → remote RPC). + DatabaseRef::storage_ref(&self.local, address, index) } } // This `DatabaseRef` implementation works similar to `CacheDB` which prioritizes modified elements, // and uses another db as fallback // We prioritize stored changed accounts/storage -impl DatabaseRef for ForkDbStateSnapshot { +impl DatabaseRef for ForkDbStateSnapshot { type Error = DatabaseError; fn basic_ref(&self, address: Address) -> Result, Self::Error> { @@ -247,15 +255,16 @@ impl DatabaseRef for ForkDbStateSnapshot { match self.local.cache.accounts.get(&address) { Some(account) => match account.storage.get(&index) { Some(entry) => Ok(*entry), - None => match self.get_storage(address, index) { - None => DatabaseRef::storage_ref(&self.local, address, index), - Some(storage) => Ok(storage), - }, - }, - None => match self.get_storage(address, index) { - None => DatabaseRef::storage_ref(&self.local, address, index), - Some(storage) => Ok(storage), + None if matches!( + account.account_state, + AccountState::NotExisting | AccountState::StorageCleared + ) => + { + Ok(U256::ZERO) + } + None => self.storage_from_snapshot_or_backend(address, index), }, + None => self.storage_from_snapshot_or_backend(address, index), } } @@ -271,7 +280,54 @@ impl DatabaseRef for ForkDbStateSnapshot { mod tests { use super::*; use crate::backend::BlockchainDbMeta; + use alloy_network::AnyNetwork; + use alloy_provider::{ProviderBuilder, mock::Asserter}; use foundry_common::provider::get_http_provider; + use revm::database::DbAccount; + + fn fork_snapshot_with_closed_backend() -> ForkDbStateSnapshot { + let provider = + ProviderBuilder::<_, _, AnyNetwork>::default().connect_mocked_client(Asserter::new()); + let db = BlockchainDb::new( + BlockchainDbMeta::new(BlockEnv::default(), "http://localhost".to_string()), + None, + ); + let (backend, handler) = SharedBackend::new(provider, db, None); + drop(handler); + ForkDbStateSnapshot { + local: CacheDB::new(backend), + state_snapshot: StateSnapshot::default(), + } + } + + #[test] + fn fork_db_state_snapshot_respects_local_storage_clearing() { + let address = Address::random(); + let slot = U256::from(42); + for account_state in [AccountState::NotExisting, AccountState::StorageCleared] { + let mut snapshot = fork_snapshot_with_closed_backend(); + snapshot.state_snapshot.storage.entry(address).or_default().insert(slot, U256::from(7)); + snapshot.local.cache.accounts.insert( + address, + DbAccount { account_state: account_state.clone(), ..Default::default() }, + ); + + assert_eq!(snapshot.storage_ref(address, slot).unwrap(), U256::ZERO); + assert_eq!(snapshot.storage_ref(address, slot + U256::ONE).unwrap(), U256::ZERO); + + if account_state.is_storage_cleared() { + snapshot + .local + .cache + .accounts + .get_mut(&address) + .unwrap() + .storage + .insert(slot, U256::from(9)); + assert_eq!(snapshot.storage_ref(address, slot).unwrap(), U256::from(9)); + } + } + } /// Demonstrates that `Database::basic` for `ForkedDatabase` will always return the /// `AccountInfo` @@ -279,7 +335,7 @@ mod tests { async fn fork_db_insert_basic_default() { let rpc = foundry_test_utils::rpc::next_http_rpc_endpoint(); let provider = get_http_provider(rpc.clone()); - let meta = BlockchainDbMeta::new(Default::default(), rpc); + let meta = BlockchainDbMeta::new(BlockEnv::default(), rpc); let db = BlockchainDb::new(meta, None); @@ -300,4 +356,36 @@ mod tests { assert!(loaded.is_some()); assert_eq!(loaded.unwrap(), info); } + + /// Reads cached remote storage without consulting the backend, including when the local + /// account is present but does not override the slot. + #[test] + fn fork_db_state_snapshot_reads_storage_from_snapshot() { + let mut snapshot = fork_snapshot_with_closed_backend(); + let address = Address::random(); + let slot = U256::from(42u64); + let expected = U256::from(0xdeadbeefu64); + + snapshot.state_snapshot.storage.entry(address).or_default().insert(slot, expected); + + assert_eq!(snapshot.storage_ref(address, slot).unwrap(), expected); + snapshot.local.cache.accounts.insert(address, DbAccount::default()); + assert_eq!(snapshot.storage_ref(address, slot).unwrap(), expected); + } + + #[tokio::test(flavor = "multi_thread")] + async fn fork_db_revert_invalidates_newer_snapshots() { + let rpc = foundry_test_utils::rpc::next_http_rpc_endpoint(); + let provider = get_http_provider(rpc.clone()); + let meta = BlockchainDbMeta::new(BlockEnv::default(), rpc); + let db = BlockchainDb::new(meta, None); + let backend = SharedBackend::spawn_backend(Arc::new(provider), db.clone(), None).await; + let mut db = ForkedDatabase::new(backend, db); + + let first = db.insert_state_snapshot(); + let second = db.insert_state_snapshot(); + + assert!(db.revert_state_snapshot(first, RevertStateSnapshotAction::RevertRemove)); + assert!(!db.revert_state_snapshot(second, RevertStateSnapshotAction::RevertRemove)); + } } diff --git a/crates/evm/core/src/fork/init.rs b/crates/evm/core/src/fork/init.rs deleted file mode 100644 index 4742d46fcaad2..0000000000000 --- a/crates/evm/core/src/fork/init.rs +++ /dev/null @@ -1,96 +0,0 @@ -use crate::{AsEnvMut, Env, EvmEnv, utils::apply_chain_and_block_specific_env_changes}; -use alloy_consensus::BlockHeader; -use alloy_primitives::{Address, U256}; -use alloy_provider::{Network, Provider, network::BlockResponse}; -use alloy_rpc_types::BlockNumberOrTag; -use eyre::WrapErr; -use foundry_common::NON_ARCHIVE_NODE_WARNING; -use revm::context::{BlockEnv, CfgEnv, TxEnv}; - -/// Initializes a REVM block environment based on a forked -/// ethereum provider. -pub async fn environment>( - provider: &P, - memory_limit: u64, - gas_price: Option, - override_chain_id: Option, - pin_block: Option, - origin: Address, - disable_block_gas_limit: bool, -) -> eyre::Result<(Env, N::BlockResponse)> { - let block_number = if let Some(pin_block) = pin_block { - pin_block - } else { - provider.get_block_number().await.wrap_err("failed to get latest block number")? - }; - let (fork_gas_price, rpc_chain_id, block) = tokio::try_join!( - provider.get_gas_price(), - provider.get_chain_id(), - provider.get_block_by_number(BlockNumberOrTag::Number(block_number)) - )?; - let block = if let Some(block) = block { - block - } else { - if let Ok(latest_block) = provider.get_block_number().await { - // If the `eth_getBlockByNumber` call succeeds, but returns null instead of - // the block, and the block number is less than equal the latest block, then - // the user is forking from a non-archive node with an older block number. - if block_number <= latest_block { - error!("{NON_ARCHIVE_NODE_WARNING}"); - } - eyre::bail!( - "failed to get block for block number: {block_number}; \ - latest block number: {latest_block}" - ); - } - eyre::bail!("failed to get block for block number: {block_number}") - }; - - let cfg = configure_env( - override_chain_id.unwrap_or(rpc_chain_id), - memory_limit, - disable_block_gas_limit, - ); - - let mut env = Env { - evm_env: EvmEnv { - cfg_env: cfg, - block_env: BlockEnv { - number: U256::from(block.header().number()), - timestamp: U256::from(block.header().timestamp()), - beneficiary: block.header().beneficiary(), - difficulty: block.header().difficulty(), - prevrandao: block.header().mix_hash(), - basefee: block.header().base_fee_per_gas().unwrap_or_default(), - gas_limit: block.header().gas_limit(), - ..Default::default() - }, - }, - tx: TxEnv { - caller: origin, - gas_price: gas_price.unwrap_or(fork_gas_price), - chain_id: Some(override_chain_id.unwrap_or(rpc_chain_id)), - gas_limit: block.header().gas_limit() as u64, - ..Default::default() - }, - }; - - apply_chain_and_block_specific_env_changes::(env.as_env_mut(), &block); - - Ok((env, block)) -} - -/// Configures the environment for the given chain id and memory limit. -pub fn configure_env(chain_id: u64, memory_limit: u64, disable_block_gas_limit: bool) -> CfgEnv { - let mut cfg = CfgEnv::default(); - cfg.chain_id = chain_id; - cfg.memory_limit = memory_limit; - cfg.limit_contract_code_size = Some(usize::MAX); - // EIP-3607 rejects transactions from senders with deployed code. - // If EIP-3607 is enabled it can cause issues during fuzz/invariant tests if the caller - // is a contract. So we disable the check by default. - cfg.disable_eip3607 = true; - cfg.disable_block_gas_limit = disable_block_gas_limit; - cfg.disable_nonce_check = true; - cfg -} diff --git a/crates/evm/core/src/fork/mod.rs b/crates/evm/core/src/fork/mod.rs index 3049f9f957a63..a890a9775b089 100644 --- a/crates/evm/core/src/fork/mod.rs +++ b/crates/evm/core/src/fork/mod.rs @@ -1,13 +1,24 @@ -use super::opts::EvmOpts; -use crate::Env; - -mod init; -pub use init::{configure_env, environment}; +use super::opts::{EvmOpts, ForkContext}; +use alloy_consensus::BlockHeader; +use alloy_eips::{BlockId, BlockNumHash}; +use alloy_network::{AnyNetwork, AnyRpcBlock, Network}; +use alloy_primitives::{B256, BlockNumber, keccak256}; +use alloy_provider::{Provider, RootProvider}; +use alloy_rpc_client::RpcClient; +use eyre::OptionExt; +use std::{ + fmt, + hash::{Hash, Hasher}, + sync::Arc, +}; pub mod database; mod multi; -pub use multi::{ForkId, MultiFork, MultiForkHandler}; +pub use multi::{ForkId, ForkResult, MultiFork, MultiForkHandler}; + +mod bal; +pub use bal::{cache_bal, validate_bal}; /// Represents a _fork_ of a remote chain whose data is available only via the `url` endpoint. #[derive(Clone, Debug)] @@ -16,8 +27,345 @@ pub struct CreateFork { pub enable_caching: bool, /// The URL to a node for fetching remote state pub url: String, - /// The env to create this fork, main purpose is to provide some metadata for the fork - pub env: Env, /// All env settings as configured by the user pub evm_opts: EvmOpts, } + +/// A prepared remote fork. The RPC client and block are shared by preflight and execution. +/// +/// The configured selector remains separate from the observed block. Clones share the provider +/// and header; changing the requested source or rolling a fork prepares a new snapshot. The +/// optional number-based state mode retains this anchor, but state reads may follow a replacement +/// block after a reorganization. +#[derive(Clone)] +pub struct Fork { + source_id: B256, + client: RpcClient, + selector: Option, + pub(crate) block: Arc, + context: ForkContext, + /// Whether state reads use the RPC block number instead of the exact hash. + pub(crate) state_by_number: bool, +} + +impl Fork { + pub(crate) fn new( + opts: &EvmOpts, + client: RpcClient, + block: AnyRpcBlock, + mut context: ForkContext, + ) -> Self { + debug_assert_eq!(block.header.number(), context.block_number); + context.network_profile = context.network_profile.canonical_execution_profile(); + Self { + source_id: source_id( + opts.fork_url.as_deref().unwrap_or_default(), + opts.fork_source_headers(), + opts.rpc_jwt.as_deref(), + ), + client, + selector: opts.fork_block_number, + block: Arc::new(block), + context, + state_by_number: opts.fork_state_by_number, + } + } + + /// Whether this backend's fork was created from the requested source, selector, and state mode. + pub fn matches_request(&self, opts: &EvmOpts) -> bool { + opts.fork_url.as_deref().is_some_and(|url| { + self.selector == opts.fork_block_number + && self.state_by_number == opts.fork_state_by_number + && self.matches_source(url, opts.fork_source_headers(), opts.rpc_jwt.as_deref()) + }) + } + + pub(crate) fn matches_source( + &self, + url: &str, + headers: Option<&[String]>, + jwt: Option<&str>, + ) -> bool { + self.source_id == source_id(url, headers, jwt) + } + + /// Returns the RPC block number, which may differ from the EVM-visible number on L2s. + pub fn number(&self) -> BlockNumber { + self.block.header.number() + } + + /// Returns the fork block hash. + pub fn hash(&self) -> B256 { + self.block.header.hash + } + + /// Returns the endpoint and execution context observed when the fork was prepared. + pub const fn context(&self) -> ForkContext { + self.context + } + + /// Returns an exact state selector that remains valid for a retained noncanonical block. + pub fn exact_block_id(&self) -> BlockId { + BlockId::from((self.hash(), Some(false))) + } + + /// Returns the state selector, honoring the RPC compatibility opt-in. + pub(crate) fn state_block_id(&self) -> BlockId { + if self.state_by_number { BlockId::number(self.number()) } else { self.exact_block_id() } + } + + pub(crate) fn block(&self) -> BlockNumHash { + BlockNumHash::new(self.number(), self.hash()) + } + + /// Reuses the configured RPC client with the response types required by the caller. + pub(crate) fn provider(&self) -> RootProvider { + RootProvider::new(self.client.clone()) + } + + pub(crate) async fn at_block(&self, block: BlockNumHash) -> eyre::Result { + let provider = self.provider::(); + let response = provider + .get_block_by_hash(block.hash) + .await? + .ok_or_eyre("exact fork block is unavailable")?; + eyre::ensure!( + response.header.number() == block.number && response.header.hash == block.hash, + "exact fork block does not match the requested number and hash" + ); + let mut fork = self.clone(); + fork.selector = Some(block.number); + fork.block = Arc::new(response); + fork.state_by_number = false; + fork.context.block_number = block.number; + Ok(fork) + } + + pub(crate) const fn source_id(&self) -> B256 { + self.source_id + } + + /// Returns the stable, redacted identity used by persisted execution caches. + pub fn fingerprint(&self) -> B256 { + let mut encoded = serde_json::to_vec(&( + "foundry-resolved-fork-v1", + self.source_id, + self.block(), + self.context, + )) + .expect("fork identity is serializable"); + // Distinguish execution policies without invalidating existing hash-based disk caches. + if self.state_by_number { + encoded.extend_from_slice(b"state-by-number"); + } + keccak256(encoded) + } +} + +impl PartialEq for Fork { + fn eq(&self, other: &Self) -> bool { + (self.source_id, self.selector, self.block(), self.context, self.state_by_number) + == ( + other.source_id, + other.selector, + other.block(), + other.context, + other.state_by_number, + ) + } +} + +impl Eq for Fork {} + +impl Hash for Fork { + fn hash(&self, state: &mut H) { + (self.source_id, self.selector, self.block(), self.context, self.state_by_number) + .hash(state); + } +} + +impl fmt::Debug for Fork { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut debug = f.debug_struct("Fork"); + debug.field("source", &""); + if let Some(number) = self.selector { + debug.field("selector", &number); + } else { + debug.field("selector", &"latest"); + } + debug.field("block", &self.block()).finish() + } +} + +fn source_id(url: &str, headers: Option<&[String]>, jwt: Option<&str>) -> B256 { + let mut encoded = Vec::from(b"foundry-resolved-fork-source-v1".as_slice()); + encode_source_part(&mut encoded, url.as_bytes()); + let headers = headers.unwrap_or_default(); + encoded.extend_from_slice( + &u64::try_from(headers.len()).expect("fork header count exceeds u64").to_be_bytes(), + ); + for header in headers { + encode_source_part(&mut encoded, header.as_bytes()); + } + if let Some(jwt) = jwt { + encoded.push(1); + encode_source_part(&mut encoded, jwt.as_bytes()); + } else { + encoded.push(0); + } + keccak256(encoded) +} + +fn encode_source_part(encoded: &mut Vec, part: &[u8]) { + encoded.extend_from_slice( + &u64::try_from(part.len()).expect("source identity part length exceeds u64").to_be_bytes(), + ); + encoded.extend_from_slice(part); +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_network::{AnyHeader, AnyRpcHeader}; + use alloy_rpc_types::Block; + use foundry_evm_networks::{NetworkConfigs, NetworkVariant}; + use serde_json::json; + use std::collections::HashSet; + + impl Fork { + pub(crate) fn test( + url: &str, + headers: Option<&[String]>, + jwt: Option<&str>, + selector: Option, + block: BlockNumHash, + context: ForkContext, + ) -> Self { + let opts = EvmOpts { + fork_url: Some(url.to_string()), + fork_headers: headers.map(<[String]>::to_vec), + rpc_jwt: jwt.map(str::to_string), + fork_block_number: selector, + ..Default::default() + }; + let header = AnyHeader { number: block.number, ..Default::default() }; + let block = AnyRpcBlock::new( + Block::new(AnyRpcHeader::from_sealed(header.seal(block.hash)), Default::default()) + .into(), + ); + Self::new(&opts, opts.fork_rpc_client(url).unwrap(), block, context) + } + } + + fn context(block_number: BlockNumber) -> ForkContext { + ForkContext { + execution_chain_id: 1, + source_chain_id: 1, + network: NetworkVariant::Ethereum, + network_profile: NetworkConfigs::default(), + block_number, + hardfork: None, + instance_id: None, + source_fork_block_number: None, + source_fork_block_hash: None, + } + } + + #[test] + fn exact_block_id_serializes_as_eip_1898_object() { + let hash = B256::with_last_byte(1); + let fork = Fork::test( + "http://localhost:8545", + None, + None, + None, + BlockNumHash::new(1, hash), + context(1), + ); + + assert_eq!( + serde_json::to_value(fork.exact_block_id()).unwrap(), + json!({ + "blockHash": hash, + "requireCanonical": false, + }) + ); + } + + #[test] + fn fork_state_by_number_preserves_anchor_and_separates_identity() { + let block = BlockNumHash::new(42, B256::with_last_byte(1)); + let exact = Fork::test("http://localhost:8545", None, None, None, block, context(42)); + let mut numbered = exact.clone(); + numbered.state_by_number = true; + assert_eq!(serde_json::to_value(numbered.state_block_id()).unwrap(), json!("0x2a")); + assert_eq!(exact.state_block_id(), exact.exact_block_id()); + assert_eq!(numbered.exact_block_id(), exact.exact_block_id()); + assert_ne!(numbered, exact); + assert_eq!(numbered.source_id(), exact.source_id()); + assert_ne!(numbered.fingerprint(), exact.fingerprint()); + } + + #[test] + fn endpoint_identity_participates_in_equality_and_hashing() { + let block = BlockNumHash::new(1, B256::with_last_byte(1)); + let first = Fork::test("http://localhost:8545", None, None, None, block, context(1)); + for changed_context in [ + ForkContext { instance_id: Some(B256::with_last_byte(2)), ..context(1) }, + ForkContext { network_profile: NetworkConfigs::with_celo(), ..context(1) }, + ForkContext { + network: NetworkVariant::Tempo, + network_profile: NetworkConfigs::with_tempo(), + ..context(1) + }, + ] { + let second = + Fork::test("http://localhost:8545", None, None, None, block, changed_context); + + assert_ne!(first, second); + assert_ne!(first.fingerprint(), second.fingerprint()); + assert_eq!(HashSet::from([first.clone(), second]).len(), 2); + } + } + + #[test] + fn configured_source_identity_is_unambiguous() { + let block = BlockNumHash::new(1, B256::with_last_byte(1)); + let context = context(1); + let plain = Fork::test("http://localhost:8545", None, None, None, block, context); + let header = Fork::test( + "http://localhost:8545", + Some(&["secret".to_string()]), + None, + None, + block, + context, + ); + let jwt = Fork::test("http://localhost:8545", None, Some("secret"), None, block, context); + + assert_ne!(plain.source_id, header.source_id); + assert_ne!(plain.source_id, jwt.source_id); + assert_ne!(header.source_id, jwt.source_id); + assert_ne!(plain.fingerprint(), header.fingerprint()); + assert_ne!(plain.fingerprint(), jwt.fingerprint()); + } + + #[test] + fn resolved_fork_canonicalizes_equivalent_ethereum_profiles() { + let url = "http://localhost:8545"; + let block = BlockNumHash::new(1, B256::with_last_byte(1)); + let implicit = Fork::test(url, None, None, Some(1), block, context(1)); + let explicit = Fork::test( + url, + None, + None, + Some(1), + block, + ForkContext { network_profile: NetworkConfigs::with_ethereum(), ..context(1) }, + ); + + assert_eq!(implicit.context(), explicit.context()); + assert_eq!(implicit.fingerprint(), explicit.fingerprint()); + assert_eq!(HashSet::from([implicit, explicit]).len(), 1); + } +} diff --git a/crates/evm/core/src/fork/multi.rs b/crates/evm/core/src/fork/multi.rs index d33fcf837105c..e8f738aa95ab4 100644 --- a/crates/evm/core/src/fork/multi.rs +++ b/crates/evm/core/src/fork/multi.rs @@ -3,27 +3,29 @@ //! The design is similar to the single `SharedBackend`, `BackendHandler` but supports multiple //! concurrently active pairs at once. -use super::CreateFork; -use crate::Env; -use alloy_consensus::BlockHeader; -use alloy_primitives::{U256, map::HashMap}; -use alloy_provider::network::BlockResponse; -use foundry_common::provider::{ProviderBuilder, RetryProvider}; +use super::{CreateFork, Fork, bal}; +use crate::{FoundryBlock, opts::ForkContext}; +use alloy_eips::{BlockNumHash, eip7928::BlockAccessList}; +use alloy_evm::EvmEnv; +use alloy_network::{AnyNetwork, Network}; +use alloy_primitives::map::HashMap; use foundry_config::Config; -use foundry_fork_db::{BackendHandler, BlockchainDb, SharedBackend, cache::BlockchainDbMeta}; +use foundry_fork_db::{ + BackendHandler, BlockchainDb, ForkBlock, ForkBlockEnv, SharedBackend, cache::BlockchainDbMeta, +}; use futures::{ FutureExt, StreamExt, channel::mpsc::{Receiver, Sender, channel}, - stream::{Fuse, Stream}, + stream::Fuse, task::{Context, Poll}, }; -use revm::context::BlockEnv; +use revm::primitives::hardfork::SpecId; use std::{ fmt::{self, Write}, pin::Pin, sync::{ Arc, - atomic::AtomicUsize, + atomic::{AtomicBool, AtomicUsize, Ordering}, mpsc::{Sender as OneshotSender, channel as oneshot_channel}, }, time::Duration, @@ -37,7 +39,30 @@ pub struct ForkId(pub String); impl ForkId { /// Returns the identifier for a Fork from a URL and block number. pub fn new(url: &str, num: Option) -> Self { + Self::new_with_context(url, num, None) + } + + fn new_with_context( + url: &str, + num: Option, + context: Option<&crate::opts::ForkContext>, + ) -> Self { let mut id = url.to_string(); + if let Some(context) = context { + write!( + id, + "#{}:{}:{}:{}:{:?}:{:?}:{:?}:{:?}", + context.execution_chain_id, + context.source_chain_id, + context.network, + context.network_profile.execution_profile_name(), + context.hardfork, + context.instance_id, + context.source_fork_block_number, + context.source_fork_block_hash + ) + .unwrap(); + } id.push('@'); match num { Some(n) => write!(id, "{n:#x}").unwrap(), @@ -46,6 +71,22 @@ impl ForkId { Self(id) } + /// Returns the identifier for an exactly resolved fork. + fn resolved(url: &str, fork: &Fork) -> Self { + let mut id = Self::exact(url, fork, fork.block()); + if fork.state_by_number { + id.0.push_str("#state-by-number"); + } + id + } + + /// Exact rolls always read state by hash, regardless of the parent fork's state mode. + fn exact(url: &str, fork: &Fork, block: BlockNumHash) -> Self { + let mut id = Self::new_with_context(url, Some(block.number), Some(&fork.context())).0; + write!(id, "#{}:{}", block.hash, fork.source_id()).unwrap(); + Self(id) + } + /// Returns the identifier of the fork. pub fn as_str(&self) -> &str { &self.0 @@ -64,23 +105,58 @@ impl> From for ForkId { } } +/// Backend, environment, and identity returned after creating or rolling a fork. +pub struct ForkResult { + /// Identifier assigned to the fork. + pub id: ForkId, + /// Backend pinned to the resolved fork block. + pub backend: SharedBackend, + /// EVM environment reconstructed from the resolved fork block. + pub env: EvmEnv, + /// Exact source and block identity used to construct the backend. + pub fork: Fork, +} + /// The Sender half of multi fork pair. /// Can send requests to the `MultiForkHandler` to create forks. #[derive(Clone, Debug)] #[must_use] -pub struct MultiFork { +pub struct MultiFork { /// Channel to send `Request`s to the handler. - handler: Sender, + handler: Sender>, /// Ensures that all rpc resources get flushed properly. - _shutdown: Arc, + _shutdown: Arc>, } -impl MultiFork { +impl< + N: Network, + SPEC: Into + Default + Copy + Unpin + Send + 'static, + BLOCK: FoundryBlock + ForkBlockEnv + Default + Unpin, +> MultiFork +{ /// Creates a new pair and spawns the `MultiForkHandler` on a background thread. pub fn spawn() -> Self { - trace!(target: "fork::multi", "spawning multifork"); + Self::spawn_with_forks(HashMap::default(), None) + } + + /// Starts an independent registry sharing the existing remote caches. + pub(crate) fn scoped(&self) -> eyre::Result { + let (sender, rx) = oneshot_channel(); + self.handler + .clone() + .try_send(Request::CloneForks(sender)) + .map_err(|e| eyre::eyre!("{e:?}"))?; + Ok(Self::spawn_with_forks(rx.recv()?, Some(self._shutdown.clone()))) + } - let (fork, mut handler) = Self::new(); + fn spawn_with_forks( + forks: HashMap>, + parent: Option>>, + ) -> Self { + trace!(target: "fork::multi", "spawning multifork"); + let (mut fork, mut handler) = Self::new(); + handler.forks = forks; + Arc::get_mut(&mut fork._shutdown).unwrap()._parent = parent; // Spawn a light-weight thread just for sending and receiving data from the remote // client(s). @@ -95,6 +171,7 @@ impl MultiFork { match tokio::runtime::Handle::try_current() { Ok(rt) => _ = rt.spawn(fut), Err(_) => { + trace!(target: "fork::multi", "spawning multifork backend thread"); _ = std::thread::Builder::new() .name("multi-fork-backend".into()) .spawn(move || { @@ -116,16 +193,17 @@ impl MultiFork { /// /// Use [`spawn`](Self::spawn) instead. #[doc(hidden)] - pub fn new() -> (Self, MultiForkHandler) { + pub fn new() -> (Self, MultiForkHandler) { let (handler, handler_rx) = channel(1); - let _shutdown = Arc::new(ShutDownMultiFork { handler: Some(handler.clone()) }); + let _shutdown = + Arc::new(ShutDownMultiFork { handler: Some(handler.clone()), _parent: None }); (Self { handler, _shutdown }, MultiForkHandler::new(handler_rx)) } /// Returns a fork backend. /// /// If no matching fork backend exists it will be created. - pub fn create_fork(&self, fork: CreateFork) -> eyre::Result<(ForkId, SharedBackend, Env)> { + pub fn create_fork(&self, fork: CreateFork) -> eyre::Result> { trace!("Creating new fork, url={}, block={:?}", fork.url, fork.evm_opts.fork_block_number); let (sender, rx) = oneshot_channel(); let req = Request::CreateFork(Box::new(fork), sender); @@ -136,11 +214,7 @@ impl MultiFork { /// Rolls the block of the fork. /// /// If no matching fork backend exists it will be created. - pub fn roll_fork( - &self, - fork: ForkId, - block: u64, - ) -> eyre::Result<(ForkId, SharedBackend, Env)> { + pub fn roll_fork(&self, fork: ForkId, block: u64) -> eyre::Result> { trace!(?fork, ?block, "rolling fork"); let (sender, rx) = oneshot_channel(); let req = Request::RollFork(fork, block, sender); @@ -148,40 +222,42 @@ impl MultiFork { rx.recv()? } - /// Returns the `Env` of the given fork, if any. - pub fn get_env(&self, fork: ForkId) -> eyre::Result> { - trace!(?fork, "getting env config"); - let (sender, rx) = oneshot_channel(); - let req = Request::GetEnv(fork, sender); - self.handler.clone().try_send(req).map_err(|e| eyre::eyre!("{:?}", e))?; - Ok(rx.recv()?) + /// Rolls a fork to an already resolved exact block. + pub fn roll_fork_exact( + &self, + fork: ForkId, + block: BlockNumHash, + ) -> eyre::Result> { + self.roll_fork_exact_with_bal(fork, block, false) } - /// Updates block number and timestamp of given fork with new values. - pub fn update_block(&self, fork: ForkId, number: U256, timestamp: U256) -> eyre::Result<()> { - trace!(?fork, ?number, ?timestamp, "update fork block"); - self.handler - .clone() - .try_send(Request::UpdateBlock(fork, number, timestamp)) - .map_err(|e| eyre::eyre!("{:?}", e)) + /// Rolls to an exact parent block, optionally warming its cache before transaction replay. + pub(crate) fn roll_fork_exact_with_bal( + &self, + fork: ForkId, + block: BlockNumHash, + prewarm_bal: bool, + ) -> eyre::Result> { + trace!(?fork, ?block, "rolling fork to exact block"); + let (sender, rx) = oneshot_channel(); + let req = Request::RollForkExact(fork, block, prewarm_bal, sender); + self.handler.clone().try_send(req).map_err(|e| eyre::eyre!("{:?}", e))?; + rx.recv()? } - /// Updates the fork's entire env - /// - /// This is required for tx level forking where we need to fork off the `block - 1` state but - /// still need use env settings for `env`. - pub fn update_block_env(&self, fork: ForkId, env: BlockEnv) -> eyre::Result<()> { - trace!(?fork, ?env, "update fork block"); - self.handler - .clone() - .try_send(Request::UpdateEnv(fork, env)) - .map_err(|e| eyre::eyre!("{:?}", e)) + /// Returns the `EvmEnv` of the given fork, if any. + pub fn get_evm_env(&self, fork: ForkId) -> eyre::Result>> { + trace!(?fork, "getting env config"); + let (sender, rx) = oneshot_channel(); + let req = Request::GetEvmEnv(fork, sender); + self.handler.clone().try_send(req).map_err(|e| eyre::eyre!("{:?}", e))?; + Ok(rx.recv()?) } /// Returns the corresponding fork if it exists. /// /// Returns `None` if no matching fork backend is available. - pub fn get_fork(&self, id: impl Into) -> eyre::Result> { + pub fn get_fork(&self, id: impl Into) -> eyre::Result>> { let id = id.into(); trace!(?id, "get fork backend"); let (sender, rx) = oneshot_channel(); @@ -190,6 +266,16 @@ impl MultiFork { Ok(rx.recv()?) } + /// Returns the immutable remote fork owned by the selected backend. + pub fn get_fork_info(&self, id: impl Into) -> eyre::Result> { + let (sender, rx) = oneshot_channel(); + self.handler + .clone() + .try_send(Request::GetForkInfo(id.into(), sender)) + .map_err(|e| eyre::eyre!("{e:?}"))?; + Ok(rx.recv()?) + } + /// Returns the corresponding fork url if it exists. /// /// Returns `None` if no matching fork is available. @@ -199,67 +285,100 @@ impl MultiFork { self.handler.clone().try_send(req).map_err(|e| eyre::eyre!("{:?}", e))?; Ok(rx.recv()?) } -} -type Handler = BackendHandler>; + /// Returns the options used to create the corresponding fork if it exists. + /// + /// Returns `None` if no matching fork is available. + pub fn get_fork_options(&self, id: impl Into) -> eyre::Result> { + let (sender, rx) = oneshot_channel(); + let req = Request::GetForkOptions(id.into(), sender); + self.handler.clone().try_send(req).map_err(|e| eyre::eyre!("{:?}", e))?; + Ok(rx.recv()?) + } +} -type CreateFuture = - Pin> + Send>>; -type CreateSender = OneshotSender>; -type GetEnvSender = OneshotSender>; +type CreateFuture = Pin< + Box< + dyn Future< + Output = eyre::Result<( + ForkId, + CreateFork, + Fork, + EvmEnv, + Option, + )>, + > + Send, + >, +>; +type CreateSender = OneshotSender>>; +type GetEvmEnvSender = OneshotSender>>; /// Request that's send to the handler. #[derive(Debug)] -enum Request { +enum Request { + CloneForks(OneshotSender>>), /// Creates a new ForkBackend. - CreateFork(Box, CreateSender), + CreateFork(Box, CreateSender), /// Returns the Fork backend for the `ForkId` if it exists. - GetFork(ForkId, OneshotSender>), + GetFork(ForkId, OneshotSender>>), /// Adjusts the block that's being forked, by creating a new fork at the new block. - RollFork(ForkId, u64, CreateSender), + RollFork(ForkId, u64, CreateSender), + /// Adjusts the fork to an already resolved exact block. + RollForkExact(ForkId, BlockNumHash, bool, CreateSender), /// Returns the environment of the fork. - GetEnv(ForkId, GetEnvSender), - /// Updates the block number and timestamp of the fork. - UpdateBlock(ForkId, U256, U256), - /// Updates the block the entire block env, - UpdateEnv(ForkId, BlockEnv), + GetEvmEnv(ForkId, GetEvmEnvSender), /// Shutdowns the entire `MultiForkHandler`, see `ShutDownMultiFork` ShutDown(OneshotSender<()>), /// Returns the Fork Url for the `ForkId` if it exists. GetForkUrl(ForkId, OneshotSender>), + GetForkInfo(ForkId, OneshotSender>), + /// Returns the options used to create the `ForkId` if it exists. + GetForkOptions(ForkId, OneshotSender>), } -enum ForkTask { +enum ForkTask { /// Contains the future that will establish a new fork. - Create(CreateFuture, ForkId, CreateSender, Vec), + Create { + future: CreateFuture, + id: ForkId, + prewarm_bal: bool, + no_fork_bal: bool, + sender: CreateSender, + additional_senders: Vec>, + }, } /// The type that manages connections in the background. #[must_use = "futures do nothing unless polled"] -pub struct MultiForkHandler { +pub struct MultiForkHandler { /// Incoming requests from the `MultiFork`. - incoming: Fuse>, + incoming: Fuse>>, /// All active handlers. /// /// It's expected that this list will be rather small (<10). - handlers: Vec<(ForkId, Handler)>, + handlers: Vec<(ForkId, BackendHandler)>, // tasks currently in progress - pending_tasks: Vec, + pending_tasks: Vec>, /// All _unique_ forkids mapped to their corresponding backend. /// /// Note: The backend can be shared by multiple ForkIds if the target the same provider and /// block number. - forks: HashMap, + forks: HashMap>, /// Optional periodic interval to flush rpc cache. flush_cache_interval: Option, } -impl MultiForkHandler { - fn new(incoming: Receiver) -> Self { +impl< + N: Network, + SPEC: Into + Default + Copy + Send + 'static, + BLOCK: FoundryBlock + ForkBlockEnv + Default, +> MultiForkHandler +{ + fn new(incoming: Receiver>) -> Self { Self { incoming: incoming.fuse(), handlers: Default::default(), @@ -277,68 +396,111 @@ impl MultiForkHandler { } /// Returns the list of additional senders of a matching task for the given id, if any. - #[expect(irrefutable_let_patterns)] - fn find_in_progress_task(&mut self, id: &ForkId) -> Option<&mut Vec> { - for task in &mut self.pending_tasks { - if let ForkTask::Create(_, in_progress, _, additional) = task - && in_progress == id + fn find_in_progress_task( + &mut self, + id: &ForkId, + prewarm_bal: bool, + no_fork_bal: bool, + ) -> Option<&mut Vec>> { + for ForkTask::Create { + id: in_progress, + prewarm_bal: pending_prewarm, + no_fork_bal: pending_opt_out, + additional_senders, + .. + } in &mut self.pending_tasks + { + if in_progress == id + && *pending_prewarm == prewarm_bal + && *pending_opt_out == no_fork_bal { - return Some(additional); + return Some(additional_senders); } } None } - fn create_fork(&mut self, fork: CreateFork, sender: CreateSender) { - let fork_id = ForkId::new(&fork.url, fork.evm_opts.fork_block_number); - trace!(?fork_id, "created new forkId"); + fn create_fork(&mut self, fork: CreateFork, sender: CreateSender) { + self.create_fork_with_identity(fork, None, false, None, sender); + } - // There could already be a task for the requested fork in progress. - if let Some(in_progress) = self.find_in_progress_task(&fork_id) { + fn create_fork_with_identity( + &mut self, + fork: CreateFork, + expected_identity: Option, + prewarm_bal: bool, + exact_block: Option<(Fork, BlockNumHash)>, + sender: CreateSender, + ) { + let no_fork_bal = fork.evm_opts.no_fork_bal; + let prewarm_bal = prewarm_bal && !no_fork_bal; + let resolved_id = + exact_block.as_ref().map(|(parent, block)| ForkId::exact(&fork.url, parent, *block)); + trace!(?resolved_id, "creating fork"); + + // Only deduplicate requests that already carry an exact identity. Unresolved requests at + // the same URL and height can resolve to different blocks across a reorganization. + if let Some(fork_id) = &resolved_id + && let Some(in_progress) = self.find_in_progress_task(fork_id, prewarm_bal, no_fork_bal) + { in_progress.push(sender); return; } + let already_prewarmed = resolved_id + .as_ref() + .and_then(|id| self.forks.get(id)) + .is_some_and(|fork| fork.bal_prewarmed.load(Ordering::Relaxed)); + // Need to create a new fork. - let task = Box::pin(create_fork(fork)); - self.pending_tasks.push(ForkTask::Create(task, fork_id, sender, Vec::new())); + let task_id = + resolved_id.unwrap_or_else(|| ForkId::new(&fork.url, fork.evm_opts.fork_block_number)); + let needs_bal = prewarm_bal && !already_prewarmed; + let future = Box::pin(create_fork(fork, expected_identity, needs_bal, exact_block)); + self.pending_tasks.push(ForkTask::Create { + future, + id: task_id, + prewarm_bal, + no_fork_bal, + sender, + additional_senders: Vec::new(), + }); } fn insert_new_fork( &mut self, fork_id: ForkId, - fork: CreatedFork, - sender: CreateSender, - additional_senders: Vec, + fork: CreatedFork, + sender: CreateSender, + additional_senders: Vec>, ) { self.forks.insert(fork_id.clone(), fork.clone()); - let _ = sender.send(Ok((fork_id.clone(), fork.backend.clone(), fork.opts.env.clone()))); + let resolved = fork.fork.clone(); + let _ = sender.send(Ok(ForkResult { + id: fork_id.clone(), + backend: fork.backend.clone(), + env: fork.evm_env.clone(), + fork: resolved.clone(), + })); // Notify all additional senders and track unique forkIds. for sender in additional_senders { let next_fork_id = fork.inc_senders(fork_id.clone()); self.forks.insert(next_fork_id.clone(), fork.clone()); - let _ = sender.send(Ok((next_fork_id, fork.backend.clone(), fork.opts.env.clone()))); + let _ = sender.send(Ok(ForkResult { + id: next_fork_id, + backend: fork.backend.clone(), + env: fork.evm_env.clone(), + fork: resolved.clone(), + })); } } - /// Update the fork's block entire env - fn update_env(&mut self, fork_id: ForkId, env: BlockEnv) { - if let Some(fork) = self.forks.get_mut(&fork_id) { - fork.opts.env.evm_env.block_env = env; - } - } - /// Update fork block number and timestamp. Used to preserve values set by `roll` and `warp` - /// cheatcodes when new fork selected. - fn update_block(&mut self, fork_id: ForkId, block_number: U256, block_timestamp: U256) { - if let Some(fork) = self.forks.get_mut(&fork_id) { - fork.opts.env.evm_env.block_env.number = block_number; - fork.opts.env.evm_env.block_env.timestamp = block_timestamp; - } - } - - fn on_request(&mut self, req: Request) { + fn on_request(&mut self, req: Request) { match req { + Request::CloneForks(sender) => { + let _ = sender.send(self.forks.clone()); + } Request::CreateFork(fork, sender) => self.create_fork(*fork, sender), Request::GetFork(fork_id, sender) => { let fork = self.forks.get(&fork_id).map(|f| f.backend.clone()); @@ -347,21 +509,37 @@ impl MultiForkHandler { Request::RollFork(fork_id, block, sender) => { if let Some(fork) = self.forks.get(&fork_id) { trace!(target: "fork::multi", "rolling {} to {}", fork_id, block); + let expected_identity = Some(fork.fork.context()); let mut opts = fork.opts.clone(); opts.evm_opts.fork_block_number = Some(block); - self.create_fork(opts, sender) + opts.evm_opts.fork_block_number_is_inferred = false; + self.create_fork_with_identity(opts, expected_identity, false, None, sender) } else { - let _ = sender.send(Err(eyre::eyre!("No matching fork exits for {}", fork_id))); + let _ = + sender.send(Err(eyre::eyre!("No matching fork exists for {}", fork_id))); } } - Request::GetEnv(fork_id, sender) => { - let _ = sender.send(self.forks.get(&fork_id).map(|fork| fork.opts.env.clone())); - } - Request::UpdateBlock(fork_id, block_number, block_timestamp) => { - self.update_block(fork_id, block_number, block_timestamp); + Request::RollForkExact(fork_id, block, prewarm_bal, sender) => { + if let Some(fork) = self.forks.get(&fork_id) { + trace!(target: "fork::multi", "rolling {} to exact block {:?}", fork_id, block); + let mut opts = fork.opts.clone(); + opts.evm_opts.fork_block_number = Some(block.number); + opts.evm_opts.fork_state_by_number = false; + opts.evm_opts.fork_block_number_is_inferred = false; + self.create_fork_with_identity( + opts, + None, + prewarm_bal, + Some((fork.fork.clone(), block)), + sender, + ) + } else { + let _ = + sender.send(Err(eyre::eyre!("No matching fork exists for {}", fork_id))); + } } - Request::UpdateEnv(fork_id, block_env) => { - self.update_env(fork_id, block_env); + Request::GetEvmEnv(fork_id, sender) => { + let _ = sender.send(self.forks.get(&fork_id).map(|fork| fork.evm_env.clone())); } Request::ShutDown(sender) => { trace!(target: "fork::multi", "received shutdown signal"); @@ -370,28 +548,38 @@ impl MultiForkHandler { self.handlers.clear(); let _ = sender.send(()); } + Request::GetForkInfo(fork_id, sender) => { + let _ = sender.send(self.forks.get(&fork_id).map(|fork| fork.fork.clone())); + } Request::GetForkUrl(fork_id, sender) => { let fork = self.forks.get(&fork_id).map(|f| f.opts.url.clone()); let _ = sender.send(fork); } + Request::GetForkOptions(fork_id, sender) => { + let fork = self.forks.get(&fork_id).map(|f| f.opts.clone()); + let _ = sender.send(fork); + } } } } // Drives all handler to completion. // This future will finish once all underlying BackendHandler are completed. -impl Future for MultiForkHandler { +impl< + N: Network, + SPEC: Into + Default + Copy + Unpin + Send + 'static, + BLOCK: FoundryBlock + ForkBlockEnv + Default + Unpin, +> Future for MultiForkHandler +{ type Output = (); fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - let pin = self.get_mut(); + let this = self.get_mut(); // Receive new requests. loop { - match Pin::new(&mut pin.incoming).poll_next(cx) { - Poll::Ready(Some(req)) => { - pin.on_request(req); - } + match this.incoming.poll_next_unpin(cx) { + Poll::Ready(Some(req)) => this.on_request(req), Poll::Ready(None) => { // Channel closed, but we still need to drive the fork handlers to completion. trace!(target: "fork::multi", "request channel closed"); @@ -402,24 +590,58 @@ impl Future for MultiForkHandler { } // Advance all tasks. - for n in (0..pin.pending_tasks.len()).rev() { - let task = pin.pending_tasks.swap_remove(n); + for n in (0..this.pending_tasks.len()).rev() { + let task = this.pending_tasks.swap_remove(n); match task { - ForkTask::Create(mut fut, id, sender, additional_senders) => { - if let Poll::Ready(resp) = fut.poll_unpin(cx) { + ForkTask::Create { + mut future, + id, + prewarm_bal, + no_fork_bal, + sender, + additional_senders, + } => { + if let Poll::Ready(resp) = future.poll_unpin(cx) { + let resp = resp.and_then(|(fork_id, opts, resolved, evm_env, mut bal)| { + let (fork_id, fork) = if let Some(mut cached) = + this.forks.get(&fork_id).cloned() + { + if bal.is_some() + && resolved.fingerprint() != cached.fork.fingerprint() + { + debug!(target: "backend::fork", "ignoring fork BAL for a different cache identity"); + bal = None; + } + // Share the remote cache, RPC client, and block while retaining + // this consumer's selector, execution environment, and BAL + // policy. The fork ID includes the block hash, so the cached + // block is the same block. + cached.opts = opts; + cached.fork = Fork { + client: cached.fork.client, + block: cached.fork.block, + ..resolved + }; + cached.evm_env = evm_env; + (cached.inc_senders(fork_id), cached) + } else { + // Only a new backend loads the disk cache. + let (backend, handler) = + create_backend(&opts, &resolved, &evm_env.block_env)?; + this.handlers.push((fork_id.clone(), handler)); + (fork_id, CreatedFork::new(opts, resolved, evm_env, backend)) + }; + Ok((fork_id, fork, bal)) + }); match resp { - Ok((fork_id, fork, handler)) => { - if let Some(fork) = pin.forks.get(&fork_id).cloned() { - pin.insert_new_fork( - fork.inc_senders(fork_id), - fork, - sender, - additional_senders, - ); - } else { - pin.handlers.push((fork_id.clone(), handler)); - pin.insert_new_fork(fork_id, fork, sender, additional_senders); + Ok((fork_id, fork, bal)) => { + // Apply only after choosing the backend, including an existing + // cache. + if let Some(bal) = bal { + bal::cache_bal(&fork.backend.data(), bal); + fork.bal_prewarmed.store(true, Ordering::Relaxed); } + this.insert_new_fork(fork_id, fork, sender, additional_senders); } Err(err) => { let _ = sender.send(Err(eyre::eyre!("{err}"))); @@ -429,50 +651,60 @@ impl Future for MultiForkHandler { } } } else { - pin.pending_tasks.push(ForkTask::Create( - fut, + this.pending_tasks.push(ForkTask::Create { + future, id, + prewarm_bal, + no_fork_bal, sender, additional_senders, - )); + }); } } } } // Advance all handlers. - for n in (0..pin.handlers.len()).rev() { - let (id, mut handler) = pin.handlers.swap_remove(n); + for n in (0..this.handlers.len()).rev() { + let (id, mut handler) = this.handlers.swap_remove(n); match handler.poll_unpin(cx) { Poll::Ready(_) => { trace!(target: "fork::multi", "fork {:?} completed", id); } Poll::Pending => { - pin.handlers.push((id, handler)); + this.handlers.push((id, handler)); } } } - if pin.handlers.is_empty() && pin.incoming.is_done() { + if this.handlers.is_empty() && this.incoming.is_done() { trace!(target: "fork::multi", "completed"); return Poll::Ready(()); } // Periodically flush cached RPC state. - if pin + if this .flush_cache_interval .as_mut() .map(|interval| interval.poll_tick(cx).is_ready()) .unwrap_or_default() - && !pin.forks.is_empty() + && !this.forks.is_empty() { trace!(target: "fork::multi", "tick flushing caches"); - let forks = pin.forks.values().map(|f| f.backend.clone()).collect::>(); + // Only the registry owning a handler flushes its cache, once per backend. + let forks = this + .handlers + .iter() + .filter_map(|(id, _)| this.forks.get(id)) + .map(|fork| fork.backend.clone()) + .collect::>(); // Flush this on new thread to not block here. std::thread::Builder::new() .name("flusher".into()) .spawn(move || { - forks.into_iter().for_each(|fork| fork.flush_cache()); + for fork in forks { + fork.flush_cache(); + } }) .expect("failed to spawn thread"); } @@ -483,19 +715,37 @@ impl Future for MultiForkHandler { /// Tracks the created Fork #[derive(Debug, Clone)] -struct CreatedFork { +struct CreatedFork { /// How the fork was initially created. opts: CreateFork, + /// The immutable remote fork owned by this backend. + fork: Fork, + /// The resolved EVM environment (fetched from the provider). + evm_env: EvmEnv, /// Copy of the sender. - backend: SharedBackend, + backend: SharedBackend, /// How many consumers there are, since a `SharedBacked` can be used by multiple /// consumers. num_senders: Arc, + /// Whether this exact shared backend has successfully received a validated parent BAL. + bal_prewarmed: Arc, } -impl CreatedFork { - pub fn new(opts: CreateFork, backend: SharedBackend) -> Self { - Self { opts, backend, num_senders: Arc::new(AtomicUsize::new(1)) } +impl CreatedFork { + pub fn new( + opts: CreateFork, + fork: Fork, + evm_env: EvmEnv, + backend: SharedBackend, + ) -> Self { + Self { + opts, + fork, + evm_env, + backend, + num_senders: Arc::new(AtomicUsize::new(1)), + bal_prewarmed: Arc::default(), + } } /// Increment senders and return unique identifier of the fork. @@ -516,11 +766,13 @@ impl CreatedFork { /// This type intentionally does not implement `Clone` since it's intended that there's only once /// instance. #[derive(Debug)] -struct ShutDownMultiFork { - handler: Option>, +struct ShutDownMultiFork { + handler: Option>>, + // Keep shared backend handlers alive until this registry has shut down. + _parent: Option>, } -impl Drop for ShutDownMultiFork { +impl Drop for ShutDownMultiFork { fn drop(&mut self) { trace!(target: "fork::multi", "initiating shutdown"); let (sender, rx) = oneshot_channel(); @@ -536,37 +788,654 @@ impl Drop for ShutDownMultiFork { /// Creates a new fork. /// -/// This will establish a new `Provider` to the endpoint and return the Fork Backend. -async fn create_fork(mut fork: CreateFork) -> eyre::Result<(ForkId, CreatedFork, Handler)> { - let provider = Arc::new( - ProviderBuilder::new(fork.url.as_str()) - .maybe_max_retry(fork.evm_opts.fork_retries) - .maybe_initial_backoff(fork.evm_opts.fork_retry_backoff) - .maybe_headers(fork.evm_opts.fork_headers.clone()) - .compute_units_per_second(fork.evm_opts.get_compute_units_per_second()) - .build()?, - ); +/// This resolves the fork block and environment through the endpoint. The handler creates the +/// backend with [`create_backend`] only if no backend exists for the fork ID. +async fn create_fork< + SPEC: Into + Default + Copy + Send, + BLOCK: FoundryBlock + ForkBlockEnv + Default, +>( + mut fork: CreateFork, + expected_identity: Option, + prewarm_bal: bool, + exact_block: Option<(Fork, BlockNumHash)>, +) -> eyre::Result<(ForkId, CreateFork, Fork, EvmEnv, Option)> { + // Ensure evm_opts reflects the fork URL (may differ from the resolved CreateFork url when + // created via cheatcodes, where evm_opts is cloned from the base config). + let execution_networks = fork.evm_opts.networks; + let require_endpoint_family_match = + fork.evm_opts.fork_network_is_inferred || !execution_networks.has_network_selection(); + let targets_new_endpoint = + fork.evm_opts.fork_url.as_ref().is_some_and(|endpoint| endpoint != &fork.url) + || fork + .evm_opts + .fork_endpoint + .as_ref() + .is_some_and(|identity| identity.endpoint != fork.url); + if targets_new_endpoint { + // The EVM implementation is already fixed, so use its family as the fallback for a custom + // endpoint without metadata. Clear identity and chain values inferred from the old URL; + // authoritative metadata from the new endpoint is still checked below. + fork.evm_opts.fork_endpoint = None; + fork.evm_opts.expected_fork_endpoint = None; + fork.evm_opts.fork_network_is_inferred = false; + if fork.evm_opts.fork_chain_id_is_inferred { + fork.evm_opts.env.chain_id = None; + fork.evm_opts.fork_chain_id_is_inferred = false; + } + if fork.evm_opts.fork_block_number_is_inferred { + fork.evm_opts.fork_block_number = None; + fork.evm_opts.fork_block_number_is_inferred = false; + } + } + fork.evm_opts.fork_url = Some(fork.url.clone()); // Initialise the fork environment. - let (env, block) = fork.evm_opts.fork_evm_env(&fork.url).await?; - fork.env = env; - let meta = BlockchainDbMeta::new(fork.env.evm_env.block_env.clone(), fork.url.clone()); + // Here we use [`AnyNetwork`] to maximize compatibility with custom chains, aligned with + // `EvmOpts::env` impl. + let resolved = if let Some((parent, block)) = exact_block { + let provider = parent.provider::(); + fork.evm_opts.check_fork_endpoint(&provider, &parent).await?; + let fork_state = parent.at_block(block).await?; + fork.evm_opts.check_fork_endpoint(&provider, &fork_state).await?; + fork_state + } else { + fork.evm_opts.prepare_fork().await?.expect("fork URL is configured") + }; + let any_provider = resolved.provider::(); + let fork_context = resolved.context(); + let evm_env = fork.evm_opts.fork_env_from_block::( + fork.evm_opts.chain_id_override().unwrap_or(fork_context.execution_chain_id), + fork_context.source_chain_id, + &resolved.block, + ); + if require_endpoint_family_match + && !execution_networks.supports_fork_source(&fork_context.network_profile) + { + eyre::bail!( + "cannot create a `{}` fork with an EVM instantiated for `{}`; run the script with --rpc-url pointing to the forked chain to select its EVM", + fork_context.network, + execution_networks.execution_network() + ); + } + if let Some(expected) = expected_identity { + eyre::ensure!( + fork_context.has_same_endpoint_identity(expected), + "fork endpoint identity changed while the fork was being rolled" + ); + } + let bal = if prewarm_bal { + bal::prepare(&any_provider, &resolved, &resolved.block).await + } else { + None + }; + let fork_id = ForkId::resolved(&fork.url, &resolved); - // We need to use the block number from the block because the env's number can be different on - // some L2s (e.g. Arbitrum). - let number = block.header().number(); + Ok((fork_id, fork, resolved, evm_env, bal)) +} + +/// Creates the backend of a resolved fork. +/// +/// This loads the disk cache of the fork block, so it only runs for a new fork ID. +fn create_backend( + fork: &CreateFork, + resolved: &Fork, + block_env: &BLOCK, +) -> eyre::Result<(SharedBackend, BackendHandler)> { + let fork_context = resolved.context(); + let number = resolved.number(); + let account_fetch_policy = crate::backend::account_fetch_policy_for_source( + fork_context.source_chain_id, + fork_context.network_profile, + ); + let meta = BlockchainDbMeta::new(block_env.clone(), fork.url.clone()) + .with_fork_identity(resolved.hash(), resolved.source_id()) + .with_account_fetch_policy(account_fetch_policy); // Determine the cache path if caching is enabled. - let cache_path = if fork.enable_caching { - Config::foundry_block_cache_dir(fork.env.evm_env.cfg_env.chain_id, number) + // Number-addressed state can follow a replacement block, so keep it in memory only. + let cache_path = if fork.enable_caching && !resolved.state_by_number { + Config::foundry_block_cache_dir(fork_context.source_chain_id, number) } else { None }; + let provider = resolved.provider::(); let db = BlockchainDb::new(meta, cache_path); - let (backend, handler) = SharedBackend::new(provider, db, Some(number.into())); - let fork = CreatedFork::new(fork, backend); - let fork_id = ForkId::new(&fork.opts.url, Some(number)); + let anchor = ForkBlock::with_rpc_number( + block_env.number().saturating_to(), + resolved.number(), + resolved.hash(), + ); + if resolved.state_by_number { + SharedBackend::new_with_anchor_by_number(provider, db, anchor) + } else { + SharedBackend::new_with_anchor(provider, db, anchor) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::opts::EvmOpts; + use alloy_chains::NamedChain; + use alloy_eips::eip7928::{AccountChanges, BlockAccessIndex, SlotChanges, StorageChange}; + use alloy_network::TransactionBuilder; + use alloy_primitives::{Address, B256, U256, bytes}; + use alloy_provider::{Provider, ProviderBuilder, mock::Asserter}; + use alloy_rpc_types::TransactionRequest; + use alloy_serde::WithOtherFields; + use foundry_evm_networks::{NetworkConfigs, NetworkVariant}; + use foundry_fork_db::AccountFetchPolicy; + use foundry_test_utils::rpc::{ + spawn_rpc_proxy_method_not_found_before, spawn_rpc_proxy_recording_method, + }; + use futures::{channel::oneshot, task::noop_waker_ref}; + use revm::context::{BlockEnv, TxEnv}; + use std::sync::mpsc::{Receiver as OneshotReceiver, TryRecvError}; - Ok((fork_id, fork, handler)) + fn context(block_number: u64) -> ForkContext { + ForkContext { + execution_chain_id: 1, + source_chain_id: 1, + network: NetworkVariant::Ethereum, + network_profile: NetworkConfigs::default(), + block_number, + hardfork: None, + instance_id: None, + source_fork_block_number: None, + source_fork_block_hash: None, + } + } + + #[test] + fn resolved_fork_ids_include_hash_and_source_identity() { + let url = "http://localhost:8545"; + let first = Fork::test( + url, + None, + None, + Some(1), + BlockNumHash::new(1, B256::with_last_byte(1)), + context(1), + ); + let replacement = Fork::test( + url, + None, + None, + Some(1), + BlockNumHash::new(1, B256::with_last_byte(2)), + context(1), + ); + let authenticated = Fork::test( + url, + Some(&["Authorization: secret".to_string()]), + None, + Some(1), + BlockNumHash::new(1, B256::with_last_byte(1)), + context(1), + ); + + assert_ne!(ForkId::resolved(url, &first), ForkId::resolved(url, &replacement)); + assert_ne!(ForkId::resolved(url, &first), ForkId::resolved(url, &authenticated)); + let mut numbered = first.clone(); + numbered.state_by_number = true; + assert_ne!(ForkId::resolved(url, &first), ForkId::resolved(url, &numbered)); + assert_eq!(ForkId::resolved(url, &first), ForkId::exact(url, &numbered, first.block())); + } + + #[tokio::test(flavor = "multi_thread")] + async fn fork_state_by_number_does_not_load_or_overwrite_hash_cache() { + let chain_id = u64::from_be_bytes(B256::random()[..8].try_into().unwrap()); + let (_api, handle) = anvil::spawn( + anvil::NodeConfig::test().with_chain_id(Some(chain_id)).with_no_mining(true), + ) + .await; + let opts = EvmOpts { + fork_url: Some(handle.http_endpoint()), + fork_block_number: Some(0), + ..Default::default() + }; + let resolved = opts.prepare_fork().await.unwrap().unwrap(); + let (env, _) = opts.env_at_fork::(Some(&resolved)).await.unwrap(); + let path = Config::foundry_block_cache_dir(chain_id, resolved.number()).unwrap(); + assert!(!path.exists()); + let meta = BlockchainDbMeta::new(env.block_env, handle.http_endpoint()) + .with_fork_identity(resolved.hash(), resolved.source_id()); + let cached = BlockchainDb::new(meta.clone(), Some(path.clone())); + let address = Address::with_last_byte(0x42); + cached.storage().write().entry(address).or_default().insert(U256::ZERO, U256::from(42)); + cached.cache().flush(); + assert!(path.exists()); + + for state_by_number in [true, false] { + let fork = CreateFork { + url: handle.http_endpoint(), + enable_caching: true, + evm_opts: EvmOpts { fork_state_by_number: state_by_number, ..opts.clone() }, + }; + let (_, fork, fork_state, evm_env, _) = + create_fork::(fork, None, false, None).await.unwrap(); + let (backend, handler) = + create_backend::(&fork, &fork_state, &evm_env.block_env).unwrap(); + assert_eq!(backend.data().storage.read().contains_key(&address), !state_by_number); + if state_by_number { + backend + .data() + .storage + .write() + .entry(address) + .or_default() + .insert(U256::ZERO, U256::from(99)); + } + backend.flush_cache(); + drop(backend); + drop(handler); + let reloaded = BlockchainDb::new(meta.clone(), Some(path.clone())); + assert_eq!(reloaded.storage().read()[&address][&U256::ZERO], U256::from(42)); + } + std::fs::remove_file(&path).unwrap(); + std::fs::remove_dir(path.parent().unwrap()).unwrap(); + } + + #[test] + fn account_fetch_policy_follows_source_identity() { + assert_eq!( + crate::backend::account_fetch_policy_for_source( + NamedChain::Tempo as u64, + NetworkConfigs::with_ethereum(), + ), + AccountFetchPolicy::RequireAccountInfo, + ); + assert_eq!( + crate::backend::account_fetch_policy_for_source( + NamedChain::Mainnet as u64, + NetworkConfigs::with_tempo(), + ), + AccountFetchPolicy::Auto, + ); + assert_eq!( + crate::backend::account_fetch_policy_for_source(123_456, NetworkConfigs::with_tempo(),), + AccountFetchPolicy::RequireAccountInfo, + ); + } + + #[test] + fn fork_bal_pending_requests_preserve_opt_out() { + let url = "http://localhost:8545"; + let resolved = Fork::test( + url, + None, + None, + Some(1), + BlockNumHash::new(1, B256::with_last_byte(1)), + context(1), + ); + let (_, receiver) = channel(1); + let mut handler = + MultiForkHandler::::new(receiver); + let mut fork = CreateFork { + enable_caching: false, + url: url.to_string(), + evm_opts: Default::default(), + }; + let (sender, _) = oneshot_channel(); + handler.create_fork_with_identity( + fork.clone(), + None, + false, + Some((resolved.clone(), resolved.block())), + sender, + ); + fork.evm_opts.no_fork_bal = true; + let (sender, _) = oneshot_channel(); + handler.create_fork_with_identity( + fork.clone(), + None, + false, + Some((resolved.clone(), resolved.block())), + sender, + ); + assert_eq!(handler.pending_tasks.len(), 2, "each fork must retain its opt-out policy"); + let (sender, _) = oneshot_channel(); + handler.create_fork_with_identity( + fork.clone(), + None, + false, + Some((resolved.clone(), resolved.block())), + sender, + ); + assert_eq!(handler.pending_tasks.len(), 2, "identical policies can share creation"); + fork.evm_opts.no_fork_bal = false; + let (sender, _) = oneshot_channel(); + handler.create_fork_with_identity( + fork.clone(), + None, + true, + Some((resolved.clone(), resolved.block())), + sender, + ); + assert_eq!(handler.pending_tasks.len(), 3, "prewarming must not join ordinary creation"); + let (sender, _) = oneshot_channel(); + handler.create_fork_with_identity( + fork, + None, + true, + Some((resolved.clone(), resolved.block())), + sender, + ); + assert_eq!(handler.pending_tasks.len(), 3, "matching prewarm requests can share creation"); + } + + #[test] + fn fork_bal_fills_selected_cache_for_equivalent_profiles() { + let url = "http://localhost:8545"; + let block = BlockNumHash::new(1, B256::with_last_byte(1)); + let address = Address::with_last_byte(1); + let create = |resolved: Fork| { + let provider = ProviderBuilder::<_, _, AnyNetwork>::default() + .connect_mocked_client(Asserter::new()); + let env = EvmEnv::::default(); + let meta = BlockchainDbMeta::new(env.block_env.clone(), url.to_string()) + .with_fork_identity(resolved.hash(), resolved.source_id()); + let (backend, handler) = SharedBackend::new_with_anchor( + provider, + BlockchainDb::new(meta, None), + ForkBlock::with_rpc_number(1, 1, resolved.hash()), + ) + .unwrap(); + let opts = CreateFork { + enable_caching: false, + url: url.to_string(), + evm_opts: Default::default(), + }; + (CreatedFork::new(opts, resolved, env, backend), handler) + }; + + for profile in [NetworkConfigs::default(), NetworkConfigs::with_ethereum()] { + let cached = Fork::test(url, None, None, None, block, context(1)); + let candidate = Fork::test( + url, + None, + None, + Some(1), + block, + ForkContext { network_profile: profile, ..context(1) }, + ); + let id = ForkId::resolved(url, &candidate); + assert_eq!(id, ForkId::resolved(url, &cached)); + assert_eq!(candidate.fingerprint(), cached.fingerprint()); + let (cached, cached_handler) = create(cached); + let cached_db = cached.backend.data(); + let bal = vec![AccountChanges::new(address).with_storage_change(SlotChanges::new( + U256::ONE, + vec![StorageChange::new(BlockAccessIndex::new(1), U256::from(42))], + ))]; + let future = futures::future::ready(Ok(( + id.clone(), + cached.opts.clone(), + candidate, + cached.evm_env.clone(), + Some(bal), + ))); + let (_incoming, receiver) = channel(1); + let mut manager = MultiForkHandler::::new(receiver); + manager.forks.insert(id.clone(), cached); + manager.handlers.push((id.clone(), cached_handler)); + let (sender, receiver) = oneshot_channel(); + manager.pending_tasks.push(ForkTask::Create { + future: Box::pin(future), + id: id.clone(), + prewarm_bal: true, + no_fork_bal: false, + sender, + additional_senders: Vec::new(), + }); + + assert!(manager.poll_unpin(&mut Context::from_waker(noop_waker_ref())).is_pending()); + let result = receiver.try_recv().unwrap().unwrap(); + assert!(Arc::ptr_eq(&result.backend.data(), &cached_db)); + // Reusing the cached backend creates no second backend. + assert_eq!(manager.handlers.len(), 1); + assert_eq!( + cached_db + .storage + .read() + .get(&address) + .and_then(|slots| slots.get(&U256::ONE)) + .copied(), + Some(U256::from(42)), + ); + assert!(manager.forks[&id].bal_prewarmed.load(Ordering::Relaxed)); + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn fork_bal_reuses_prewarmed_cache_only_for_equivalent_identities() { + let (api, handle) = anvil::spawn( + anvil::NodeConfig::test() + .with_chain_id(Some(1u64)) + .with_hardfork(Some(anvil::EthereumHardfork::Amsterdam.into())) + .with_genesis_timestamp(Some(1_800_000_000u64)) + .with_no_mining(true), + ) + .await; + let address = Address::with_last_byte(0x42); + api.anvil_set_code(address, bytes!("602a60015500")).await.unwrap(); + api.send_transaction(WithOtherFields::new( + TransactionRequest::default() + .with_from(handle.dev_accounts().next().unwrap()) + .with_to(address) + .with_nonce(0) + // Enough for the EIP-8037 state gas of creating slot one. + .with_gas_limit(1_000_000) + .with_gas_price(2_000_000_000), + )) + .await + .unwrap(); + api.mine_one().await.unwrap(); + let block = handle.http_provider().get_block_by_number(1.into()).await.unwrap().unwrap(); + let block = BlockNumHash::new(1, block.header.hash); + // Expose native BALs through an endpoint without mutable Anvil identity. + let endpoint = spawn_rpc_proxy_method_not_found_before( + handle.http_endpoint(), + "anvil_nodeInfo", + usize::MAX, + ) + .await; + let (endpoint, bal_requests) = + spawn_rpc_proxy_recording_method(endpoint, "eth_getBlockAccessList").await; + let (endpoint, probes) = spawn_rpc_proxy_recording_method(endpoint, "anvil_nodeInfo").await; + let (_incoming, receiver) = channel(1); + let mut manager = MultiForkHandler::::new(receiver); + let mut first = None; + let mut probe_counts = Vec::new(); + let other_headers = ["X-Test-Source: other".to_string()]; + + for (profile, headers) in [ + (NetworkConfigs::default(), None), + (NetworkConfigs::with_ethereum(), None), + (NetworkConfigs::default(), None), + (NetworkConfigs::default(), Some(other_headers.as_slice())), + ] { + let evm_opts = EvmOpts { + fork_url: Some(endpoint.clone()), + fork_block_number: Some(1), + fork_headers: headers.map(<[_]>::to_vec), + networks: profile, + ..Default::default() + }; + let resolved = evm_opts.prepare_fork().await.unwrap().unwrap(); + assert_eq!(resolved.block(), block); + let fingerprint = resolved.fingerprint(); + let fork = CreateFork { enable_caching: false, url: endpoint.clone(), evm_opts }; + let before = probes.lock().unwrap().len(); + let (sender, receiver) = oneshot_channel(); + manager.create_fork_with_identity( + fork, + None, + true, + Some((resolved.clone(), resolved.block())), + sender, + ); + let result = tokio::time::timeout( + Duration::from_secs(10), + futures::future::poll_fn(|cx| { + assert!(manager.poll_unpin(cx).is_pending()); + match receiver.try_recv() { + Ok(result) => Poll::Ready(result), + Err(std::sync::mpsc::TryRecvError::Empty) => Poll::Pending, + Err(error) => panic!("fork response channel closed: {error}"), + } + }), + ) + .await + .unwrap() + .unwrap(); + + let db = result.backend.data(); + assert_eq!( + Arc::ptr_eq(first.get_or_insert_with(|| db.clone()), &db), + headers.is_none(), + ); + assert_eq!(result.fork.fingerprint(), fingerprint); + assert_eq!(db.storage.read()[&address][&U256::ONE], U256::from(42)); + assert!(manager.forks[&result.id].bal_prewarmed.load(Ordering::Relaxed)); + assert_eq!(bal_requests.lock().unwrap().len(), if headers.is_some() { 2 } else { 1 }); + probe_counts.push(probes.lock().unwrap().len() - before); + } + + // Only cold caches need the two source probes surrounding BAL preparation. + assert_eq!(probe_counts[0], probe_counts[1] + 2); + assert_eq!(probe_counts[1], probe_counts[2]); + assert_eq!(probe_counts[0], probe_counts[3]); + } + + #[tokio::test(flavor = "multi_thread")] + async fn fork_bal_shared_backend_preserves_opposite_policies_on_roll() { + let (api, handle) = anvil::spawn( + anvil::NodeConfig::test() + .with_chain_id(Some(1u64)) + .with_hardfork(Some(anvil::EthereumHardfork::Amsterdam.into())) + .with_genesis_timestamp(Some(1_800_000_000u64)) + .with_no_mining(true), + ) + .await; + let mut blocks = Vec::new(); + for number in 1..=3 { + api.mine_one().await.unwrap(); + let block = + handle.http_provider().get_block_by_number(number.into()).await.unwrap().unwrap(); + blocks.push(BlockNumHash::new(number, block.header.hash)); + } + // Expose native BALs through an endpoint without mutable Anvil identity. + let endpoint = spawn_rpc_proxy_method_not_found_before( + handle.http_endpoint(), + "anvil_nodeInfo", + usize::MAX, + ) + .await; + let (endpoint, bal_requests) = + spawn_rpc_proxy_recording_method(endpoint, "eth_getBlockAccessList").await; + let resolved = EvmOpts { + fork_url: Some(endpoint.clone()), + fork_block_number: Some(1), + ..Default::default() + } + .prepare_fork() + .await + .unwrap() + .unwrap(); + assert_eq!(resolved.block(), blocks[0]); + + for disabled_first in [false, true] { + let (_incoming, receiver) = channel(1); + let mut manager = MultiForkHandler::::new(receiver); + bal_requests.lock().unwrap().clear(); + let mut requests = [false, true].map(|no_fork_bal| { + let fork = CreateFork { + enable_caching: false, + url: endpoint.clone(), + evm_opts: EvmOpts { + fork_url: Some(endpoint.clone()), + fork_block_number: Some(1), + no_fork_bal, + ..Default::default() + }, + }; + let (sender, receiver) = oneshot_channel(); + manager.create_fork(fork, sender); + let ForkTask::Create { future, .. } = manager.pending_tasks.last_mut().unwrap(); + let original = std::mem::replace(future, Box::pin(futures::future::pending())); + let (release, gate) = oneshot::channel(); + // Run real creation, but explicitly control which result reaches backend reuse. + *future = Box::pin(async move { + let result = original.await; + gate.await.unwrap(); + result + }); + (no_fork_bal, release, receiver) + }); + assert_eq!(manager.pending_tasks.len(), 2); + if disabled_first { + requests.reverse(); + } + let [ + (first_policy, first_gate, first_receiver), + (second_policy, second_gate, second_receiver), + ] = requests; + first_gate.send(()).unwrap(); + let first = complete_fork(&mut manager, first_receiver).await; + assert!(matches!(second_receiver.try_recv(), Err(TryRecvError::Empty))); + assert_eq!(manager.pending_tasks.len(), 1); + second_gate.send(()).unwrap(); + let second = complete_fork(&mut manager, second_receiver).await; + + assert_ne!(first.id, second.id); + assert!(Arc::ptr_eq(&first.backend.data(), &second.backend.data())); + assert!(Arc::ptr_eq(first.fork.client.inner(), second.fork.client.inner())); + assert!(Arc::ptr_eq(&first.fork.block, &second.fork.block)); + assert_eq!(manager.forks[&first.id].opts.evm_opts.no_fork_bal, first_policy); + assert_eq!(manager.forks[&second.id].opts.evm_opts.no_fork_bal, second_policy); + assert!(bal_requests.lock().unwrap().is_empty()); + + // Each roll targets a cold block so shared prewarming cannot mask a lost policy. + for (no_fork_bal, fork, block) in + [(first_policy, first, blocks[1]), (second_policy, second, blocks[2])] + { + bal_requests.lock().unwrap().clear(); + let (sender, receiver) = oneshot_channel(); + manager.on_request(Request::RollForkExact(fork.id, block, true, sender)); + let rolled = complete_fork(&mut manager, receiver).await; + assert_eq!(rolled.fork.block(), block); + assert_eq!(manager.forks[&rolled.id].opts.evm_opts.no_fork_bal, no_fork_bal); + assert_eq!( + manager.forks[&rolled.id].bal_prewarmed.load(Ordering::Relaxed), + !no_fork_bal, + ); + let expected = + if no_fork_bal { vec![] } else { vec![serde_json::json!([block.hash])] }; + assert_eq!(*bal_requests.lock().unwrap(), expected); + } + } + } + + async fn complete_fork( + manager: &mut MultiForkHandler, + receiver: OneshotReceiver>>, + ) -> ForkResult { + tokio::time::timeout( + Duration::from_secs(10), + futures::future::poll_fn(|cx| { + assert!(manager.poll_unpin(cx).is_pending()); + match receiver.try_recv() { + Ok(result) => Poll::Ready(result), + Err(TryRecvError::Empty) => Poll::Pending, + Err(error) => panic!("fork response channel closed: {error}"), + } + }), + ) + .await + .unwrap() + .unwrap() + } } diff --git a/crates/evm/core/src/hardfork.rs b/crates/evm/core/src/hardfork.rs new file mode 100644 index 0000000000000..858624dec5c77 --- /dev/null +++ b/crates/evm/core/src/hardfork.rs @@ -0,0 +1 @@ +pub use foundry_evm_hardforks::*; diff --git a/crates/evm/core/src/ic.rs b/crates/evm/core/src/ic.rs index 7fc64791ace06..0f0a7719fc6bb 100644 --- a/crates/evm/core/src/ic.rs +++ b/crates/evm/core/src/ic.rs @@ -1,6 +1,5 @@ +use crate::bytecode::InstIter; use alloy_primitives::map::rustc_hash::FxHashMap; -use eyre::Result; -use revm::bytecode::opcode::{OpCode, PUSH0, PUSH1, PUSH32}; use serde::Serialize; /// Maps from program counter to instruction counter. @@ -9,7 +8,7 @@ use serde::Serialize; #[derive(Debug, Clone, Serialize)] #[serde(transparent)] pub struct PcIcMap { - pub inner: FxHashMap, + inner: FxHashMap, } impl PcIcMap { @@ -38,7 +37,7 @@ impl PcIcMap { /// /// Inverse of [`PcIcMap`]. pub struct IcPcMap { - pub inner: FxHashMap, + inner: FxHashMap, } impl IcPcMap { @@ -61,123 +60,23 @@ impl IcPcMap { pub fn get(&self, ic: u32) -> Option { self.inner.get(&ic).copied() } + + /// Iterate over the IC-PC pairs. + pub fn iter(&self) -> impl Iterator { + self.inner.iter() + } } fn make_map(code: &[u8]) -> FxHashMap { assert!(code.len() <= u32::MAX as usize, "bytecode is too big"); - let mut map = FxHashMap::with_capacity_and_hasher(code.len(), Default::default()); - - let mut pc = 0usize; - let mut cumulative_push_size = 0usize; - while pc < code.len() { - let ic = pc - cumulative_push_size; + for (ic, (pc, _)) in InstIter::new(code).with_pc().enumerate() { if PC_FIRST { map.insert(pc as u32, ic as u32); } else { map.insert(ic as u32, pc as u32); } - - if (PUSH1..=PUSH32).contains(&code[pc]) { - // Skip the push bytes. - let push_size = (code[pc] - PUSH0) as usize; - pc += push_size; - cumulative_push_size += push_size; - } - - pc += 1; } - map.shrink_to_fit(); - map } - -/// Represents a single instruction consisting of the opcode and its immediate data. -pub struct Instruction { - /// OpCode, if it could be decoded. - pub op: Option, - /// Immediate data following the opcode. - pub immediate: Box<[u8]>, - /// Program counter of the opcode. - pub pc: u32, -} - -/// Decodes raw opcode bytes into [`Instruction`]s. -pub fn decode_instructions(code: &[u8]) -> Result> { - assert!(code.len() <= u32::MAX as usize, "bytecode is too big"); - - let mut pc = 0usize; - let mut steps = Vec::new(); - - while pc < code.len() { - let op = OpCode::new(code[pc]); - let next_pc = pc + 1; - let immediate_size = op.map(|op| op.info().immediate_size()).unwrap_or(0) as usize; - let is_normal_push = op.map(|op| op.is_push()).unwrap_or(false); - - if !is_normal_push && next_pc + immediate_size > code.len() { - eyre::bail!("incomplete sequence of bytecode"); - } - - // Ensure immediate is padded if needed. - let immediate_end = (next_pc + immediate_size).min(code.len()); - let mut immediate = vec![0u8; immediate_size]; - let immediate_part = &code[next_pc..immediate_end]; - immediate[..immediate_part.len()].copy_from_slice(immediate_part); - - steps.push(Instruction { op, pc: pc as u32, immediate: immediate.into_boxed_slice() }); - - pc = next_pc + immediate_size; - } - - Ok(steps) -} - -#[cfg(test)] -pub mod tests { - use super::*; - - #[test] - fn decode_push2_and_stop() -> Result<()> { - // 0x61 0xAA 0xBB = PUSH2 0xAABB - // 0x00 = STOP - let code = vec![0x61, 0xAA, 0xBB, 0x00]; - let insns = decode_instructions(&code)?; - - // PUSH2 then STOP - assert_eq!(insns.len(), 2); - - // PUSH2 at pc = 0 - let i0 = &insns[0]; - assert_eq!(i0.pc, 0); - assert_eq!(i0.op, Some(OpCode::PUSH2)); - assert_eq!(i0.immediate.as_ref(), &[0xAA, 0xBB]); - - // STOP at pc = 3 - let i1 = &insns[1]; - assert_eq!(i1.pc, 3); - assert_eq!(i1.op, Some(OpCode::STOP)); - assert!(i1.immediate.is_empty()); - - Ok(()) - } - - #[test] - fn decode_arithmetic_ops() -> Result<()> { - // 0x01 = ADD, 0x02 = MUL, 0x03 = SUB, 0x04 = DIV - let code = vec![0x01, 0x02, 0x03, 0x04]; - let insns = decode_instructions(&code)?; - - assert_eq!(insns.len(), 4); - - let expected = [(0, OpCode::ADD), (1, OpCode::MUL), (2, OpCode::SUB), (3, OpCode::DIV)]; - for ((pc, want_op), insn) in expected.iter().zip(insns.iter()) { - assert_eq!(insn.pc, *pc); - assert_eq!(insn.op, Some(*want_op)); - assert!(insn.immediate.is_empty()); - } - - Ok(()) - } -} diff --git a/crates/evm/core/src/lib.rs b/crates/evm/core/src/lib.rs index 3f2ec812bb67d..8b7810dddf764 100644 --- a/crates/evm/core/src/lib.rs +++ b/crates/evm/core/src/lib.rs @@ -1,18 +1,28 @@ //! # foundry-evm-core //! -//! Core EVM abstractions. +//! Generic execution, environment, fork, backend, and state abstractions shared by Foundry tools. +//! +//! [`evm::FoundryEvmNetwork`] binds an Alloy network to an [`evm::FoundryEvmFactory`]. The factory +//! owns the concrete execution types and constructs a Foundry-compatible EVM context, while +//! `foundry-evm-networks` owns runtime family selection. Keeping those responsibilities separate +//! allows one compiled binary to dispatch to different execution families at runtime. #![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] +#![cfg_attr(docsrs, feature(doc_cfg))] use crate::constants::DEFAULT_CREATE2_DEPLOYER; -use alloy_evm::eth::EthEvmContext; -use alloy_primitives::Address; +use alloy_primitives::{Address, map::HashMap}; use auto_impl::auto_impl; -use backend::DatabaseExt; +use foundry_evm_networks::NetworkConfigs; use revm::{Inspector, inspector::NoOpInspector, interpreter::CreateInputs}; use revm_inspectors::access_list::AccessListInspector; +#[cfg(feature = "optimism")] +use op_alloy_rpc_types as _; + +/// Map keyed by breakpoints char to their location (contract address, pc) +pub type Breakpoints = HashMap; + #[macro_use] extern crate tracing; @@ -23,32 +33,35 @@ pub mod abi { pub mod env; pub use env::*; + pub mod backend; pub mod buffer; +pub mod bytecode; pub mod constants; pub mod decode; -pub mod either_evm; +pub mod eip2935; pub mod evm; pub mod fork; +pub mod hardfork; pub mod ic; pub mod opts; pub mod precompiles; pub mod state_snapshot; +pub mod tempo; pub mod utils; -/// An extension trait that allows us to add additional hooks to Inspector for later use in -/// handlers. +/// Foundry-specific inspector methods, decoupled from any particular EVM context type. +/// +/// This trait holds Foundry-specific extensions (create2 factory, console logging, temporary Celo +/// configuration, deployer address). It has no `Inspector` supertrait so it can +/// be used in generic code with `I: FoundryInspectorExt + Inspector`. #[auto_impl(&mut, Box)] -pub trait InspectorExt: for<'a> Inspector> { +pub trait InspectorExt { /// Determines whether the `DEFAULT_CREATE2_DEPLOYER` should be used for a CREATE2 frame. /// /// If this function returns true, we'll replace CREATE2 frame with a CALL frame to CREATE2 /// factory. - fn should_use_create2_factory( - &mut self, - _context: &mut EthEvmContext<&mut dyn DatabaseExt>, - _inputs: &CreateInputs, - ) -> bool { + fn should_use_create2_factory(&mut self, _depth: usize, _inputs: &CreateInputs) -> bool { false } @@ -57,9 +70,9 @@ pub trait InspectorExt: for<'a> Inspector let _ = msg; } - /// Returns `true` if the current network is Odyssey. - fn is_odyssey(&self) -> bool { - false + /// Returns configuration retained for Celo precompile support. + fn get_networks(&self) -> NetworkConfigs { + NetworkConfigs::default() } /// Returns the CREATE2 deployer address. @@ -68,6 +81,14 @@ pub trait InspectorExt: for<'a> Inspector } } +/// A combined inspector trait that integrates revm's [`Inspector`] with Foundry-specific +/// extensions. Automatically implemented for any type that implements both [`Inspector`] +/// and [`InspectorExt`]. +pub trait FoundryInspectorExt: Inspector + InspectorExt {} + +impl FoundryInspectorExt for T where T: Inspector + InspectorExt +{} + impl InspectorExt for NoOpInspector {} impl InspectorExt for AccessListInspector {} diff --git a/crates/evm/core/src/opts.rs b/crates/evm/core/src/opts.rs index 5cf7321647acb..24fc700164160 100644 --- a/crates/evm/core/src/opts.rs +++ b/crates/evm/core/src/opts.rs @@ -1,19 +1,45 @@ -use super::fork::environment; use crate::{ - EvmEnv, + EvmEnv, FoundryBlock, FoundryTransaction, constants::DEFAULT_CREATE2_DEPLOYER, - fork::{CreateFork, configure_env}, + fork::{CreateFork, Fork}, + utils::{apply_chain_and_block_specific_env_changes_for_chain, block_env_from_header}, }; -use alloy_primitives::{Address, B256, U256}; -use alloy_provider::{Provider, network::AnyRpcBlock}; -use eyre::WrapErr; -use foundry_common::{ALCHEMY_FREE_TIER_CUPS, provider::ProviderBuilder}; -use foundry_config::{Chain, Config, GasLimit}; -use revm::context::{BlockEnv, TxEnv}; +use alloy_consensus::BlockHeader; +use alloy_eips::BlockNumHash; +use alloy_network::{AnyNetwork, BlockResponse, Network, primitives::HeaderResponse}; +use alloy_primitives::{Address, B256, BlockNumber, ChainId, U256}; +use alloy_provider::{Provider, RootProvider}; +use alloy_rpc_client::RpcClient; +use alloy_rpc_types::{ + BlockNumberOrTag, + anvil::{Metadata, NodeInfo}, +}; +use eyre::{OptionExt, WrapErr}; +use foundry_common::{ + ALCHEMY_FREE_TIER_CUPS, NON_ARCHIVE_NODE_WARNING, + provider::{ProviderBuilder, is_rpc_method_not_found}, +}; +use foundry_compilers::artifacts::EvmVersion; +use foundry_config::{Chain, Config, ExecutionSpec, FoundryHardfork, GasLimit, evm_spec_id}; +use foundry_evm_networks::{NetworkConfigs, NetworkVariant}; +use revm::{context::CfgEnv, primitives::hardfork::SpecId}; use serde::{Deserialize, Serialize}; use std::fmt::Write; use url::Url; +/// EVM execution options, including the configured remote fork. +/// +/// Fork handling separates two responsibilities. Endpoint discovery identifies and caches the +/// execution family without selecting a block. Exact resolution binds the configured source and +/// selector to a block number, hash, and endpoint context. Forge normally performs discovery +/// before resolution, while callers that already know the network can resolve directly. +/// +/// Resolution revalidates the endpoint identity around its remote reads. A cached discovery that +/// found no Anvil identity has nothing to revalidate and is reused instead, so the chain ID and +/// the `anvil_nodeInfo` probe of such an endpoint are requested once, by discovery. +/// +/// An implicit `latest` selector remains unchanged in these options; only the returned +/// [`Fork`] is pinned to the exact block observed during resolution. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct EvmOpts { /// The EVM environment configuration. @@ -27,6 +53,10 @@ pub struct EvmOpts { /// Pins the block number for the state fork. pub fork_block_number: Option, + /// Fetch fork state by block number for RPCs that cannot serve it by hash. + #[serde(default)] + pub fork_state_by_number: bool, + /// The number of retries. pub fork_retries: Option, @@ -36,6 +66,26 @@ pub struct EvmOpts { /// Headers to use with `fork_url` pub fork_headers: Option>, + /// JWT secret to use with the configured RPC endpoint. + #[serde(rename = "eth_rpc_jwt")] + pub rpc_jwt: Option, + + /// Headers to use with the configured RPC endpoint. + #[serde(rename = "eth_rpc_headers")] + pub rpc_headers: Option>, + + /// Request timeout to use with the configured RPC endpoint. + #[serde(rename = "eth_rpc_timeout")] + pub rpc_timeout: Option, + + /// Whether to accept invalid certificates from the configured RPC endpoint. + #[serde(default, rename = "eth_rpc_accept_invalid_certs")] + pub rpc_accept_invalid_certs: bool, + + /// Whether to disable automatic proxy detection for the configured RPC endpoint. + #[serde(default, rename = "eth_rpc_no_proxy")] + pub rpc_no_proxy: bool, + /// The available compute units per second. /// /// See also @@ -47,6 +97,10 @@ pub struct EvmOpts { /// Disables storage caching entirely. pub no_storage_caching: bool, + /// Disables parent-block BAL cache prewarming for transaction-hash forks. + #[serde(default)] + pub no_fork_bal: bool, + /// The initial balance of each deployed test contract. pub initial_balance: U256, @@ -72,11 +126,185 @@ pub struct EvmOpts { /// Whether to disable block gas limit checks. pub disable_block_gas_limit: bool, - /// whether to enable Odyssey features. - pub odyssey: bool, + /// Whether to enable tx gas limit checks as imposed by Osaka (EIP-7825). + pub enable_tx_gas_limit: bool, + + #[serde(flatten)] + /// Networks with enabled features. + pub networks: NetworkConfigs, /// The CREATE2 deployer's address. pub create2_deployer: Address, + + /// Most recently discovered endpoint identity, cached for network dispatch and revalidation. + /// + /// An identity without Anvil metadata also stands in for later identity snapshots of the same + /// endpoint. + #[serde(skip)] + pub fork_endpoint: Option, + + /// Endpoint identity promoted to an invariant that later discovery must match. + #[serde(skip)] + pub expected_fork_endpoint: Option, + + /// Whether the active network selection was inferred from the fork endpoint. + #[serde(skip)] + pub fork_network_is_inferred: bool, + + /// Whether `env.chain_id` was inferred from the fork endpoint. + #[serde(skip)] + pub fork_chain_id_is_inferred: bool, + + /// Whether `fork_block_number` was pinned from the fork endpoint's latest block. + #[serde(skip)] + pub fork_block_number_is_inferred: bool, +} + +/// A snapshot of the execution and upstream-source identity exposed by an RPC endpoint. +/// +/// This identity is independent of the block selector Forge resolves. In particular, +/// `source_fork_block_*` describes the upstream block from which an Anvil endpoint was itself +/// forked; the target block selected for local execution belongs to [`Fork`]. Equality +/// detects observable endpoint identity changes, including Anvil instance resets, and +/// execution-profile changes between related RPC reads. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ForkEndpointIdentity { + /// RPC URL from which this identity was discovered. + /// + /// Request headers and JWT credentials are bound separately by [`Fork`]. + pub endpoint: String, + /// Chain ID exposed through `eth_chainId`. + pub execution_chain_id: ChainId, + /// Underlying source chain ID, when the endpoint is itself a fork. + pub source_chain_id: ChainId, + /// EVM family exposed by the endpoint. + pub network: NetworkVariant, + /// Complete execution profile exposed by the endpoint. + pub network_profile: NetworkConfigs, + /// Raw hardfork name reported by an Anvil endpoint. + pub reported_hardfork: Option, + /// Parsed hardfork exposed by an Anvil endpoint, when recognized. + pub hardfork: Option, + /// Anvil instance identifier used to detect resets during multi-call discovery. + pub instance_id: Option, + /// Block number from which an Anvil endpoint was forked. + pub source_fork_block_number: Option, + /// Block hash from which an Anvil endpoint was forked. + pub source_fork_block_hash: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum EndpointHardforkPolicy { + /// Permit an unrecognized hardfork during early network-family discovery. + Optional, + /// Require a recognized hardfork before constructing an execution environment. + Required, +} + +/// Best-effort Anvil detection that becomes strict after positive identification. +/// +/// Before the first successful `anvil_nodeInfo` response, any probe failure means that the +/// optional capability is unavailable. Mandatory standard RPC reads still expose endpoint-wide +/// failures. Once a response or cached endpoint identity identifies Anvil, every later probe +/// failure is returned so it cannot hide an endpoint reset or execution-profile change. +/// +/// The probe is not repeated for an endpoint whose cached discovery found no Anvil identity, see +/// [`EvmOpts::discovered_non_anvil_chain_id`]. +#[derive(Clone, Copy, Debug, Default)] +struct AnvilNodeInfoProbe { + identified: bool, +} + +impl AnvilNodeInfoProbe { + const fn new(identified: bool) -> Self { + Self { identified } + } + + async fn request>( + &mut self, + provider: &P, + ) -> eyre::Result> { + match provider.raw_request::<_, NodeInfo>("anvil_nodeInfo".into(), ()).await { + Ok(node_info) => { + self.identified = true; + Ok(Some(node_info)) + } + Err(_) if !self.identified => Ok(None), + Err(error) => Err(error).wrap_err("failed to determine network family from endpoint"), + } + } +} + +fn endpoint_hardfork( + network: NetworkVariant, + hardfork: &str, + policy: EndpointHardforkPolicy, +) -> eyre::Result> { + match network.parse_hardfork(hardfork) { + Ok(hardfork) => Ok(Some(hardfork)), + Err(_) if policy == EndpointHardforkPolicy::Optional => Ok(None), + Err(error) => Err(eyre::Report::msg(error)).wrap_err_with(|| { + format!("unsupported hardfork `{hardfork}` reported for `{network}`") + }), + } +} + +/// Endpoint identity paired with the remote block number backing a fork. +/// +/// [`Fork`] adds the exact block hash and configured RPC source. The remote +/// `block_number` may differ from a remapped EVM block number on L2s, and the source chain ID +/// remains distinct from a configured `CHAINID` opcode override. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize)] +pub struct ForkContext { + /// Chain ID exposed through `eth_chainId`. + pub execution_chain_id: ChainId, + /// Underlying source chain ID. + pub source_chain_id: ChainId, + /// Execution network exposed by the endpoint. + pub network: NetworkVariant, + /// Complete execution profile exposed by the endpoint. + pub network_profile: NetworkConfigs, + /// Actual block number fetched from the fork endpoint. + pub block_number: BlockNumber, + /// Exact hardfork reported by the fork endpoint, when available. + pub hardfork: Option, + /// Anvil instance identifier, when exposed by the endpoint. + pub instance_id: Option, + /// Block number from which the endpoint itself was forked. + pub source_fork_block_number: Option, + /// Block hash from which the endpoint itself was forked. + pub source_fork_block_hash: Option, +} + +impl ForkContext { + /// Returns whether this block context belongs to `identity`. + pub fn matches_identity(self, identity: &ForkEndpointIdentity) -> bool { + self.execution_chain_id == identity.execution_chain_id + && self.source_chain_id == identity.source_chain_id + && self.network == identity.network + && self.network_profile == identity.network_profile + && self.hardfork == identity.hardfork + && self.instance_id == identity.instance_id + && self.source_fork_block_number == identity.source_fork_block_number + && self.source_fork_block_hash == identity.source_fork_block_hash + } + + /// Returns whether two block contexts belong to the same endpoint identity. + pub(crate) fn has_same_endpoint_identity(self, other: Self) -> bool { + self.execution_chain_id == other.execution_chain_id + && self.source_chain_id == other.source_chain_id + && self.network == other.network + && self.network_profile == other.network_profile + && self.hardfork == other.hardfork + && self.instance_id == other.instance_id + && self.source_fork_block_number == other.source_fork_block_number + && self.source_fork_block_hash == other.source_fork_block_hash + } + + /// Returns whether two contexts can reuse a backend pinned by block number. + pub fn has_same_backend_target(self, other: Self) -> bool { + self.has_same_endpoint_identity(other) && self.block_number == other.block_number + } } impl Default for EvmOpts { @@ -85,12 +313,19 @@ impl Default for EvmOpts { env: Env::default(), fork_url: None, fork_block_number: None, + fork_state_by_number: false, fork_retries: None, fork_retry_backoff: None, fork_headers: None, + rpc_jwt: None, + rpc_headers: None, + rpc_timeout: None, + rpc_accept_invalid_certs: false, + rpc_no_proxy: false, compute_units_per_second: None, no_rpc_rate_limit: false, no_storage_caching: false, + no_fork_bal: false, initial_balance: U256::default(), sender: Address::default(), ffi: false, @@ -99,81 +334,847 @@ impl Default for EvmOpts { memory_limit: 0, isolate: false, disable_block_gas_limit: false, - odyssey: false, + enable_tx_gas_limit: false, + networks: NetworkConfigs::default(), create2_deployer: DEFAULT_CREATE2_DEPLOYER, + fork_endpoint: None, + expected_fork_endpoint: None, + fork_network_is_inferred: false, + fork_chain_id_is_inferred: false, + fork_block_number_is_inferred: false, } } } impl EvmOpts { - /// Configures a new `revm::Env` + /// Clears state derived from a fork endpoint while preserving explicit configuration. + fn clear_endpoint_derived_fork_state(&mut self) { + if self.fork_block_number_is_inferred { + self.fork_block_number = None; + self.fork_block_number_is_inferred = false; + } + self.fork_endpoint = None; + self.expected_fork_endpoint = None; + if self.fork_network_is_inferred { + self.networks = self.networks.with_rpc_profile(NetworkConfigs::default()); + self.fork_network_is_inferred = false; + } + if self.fork_chain_id_is_inferred { + self.env.chain_id = None; + self.fork_chain_id_is_inferred = false; + } + } + + /// Selects a new fork URL and clears endpoint-derived state from the previous source. + pub fn set_fork_url(&mut self, fork_url: String) { + if self.fork_url.as_ref() == Some(&fork_url) { + return; + } + self.fork_url = Some(fork_url); + self.clear_endpoint_derived_fork_state(); + } + + /// Invalidates endpoint-derived state when the effective RPC source changed since `fork` was + /// resolved. /// - /// If a `fork_url` is set, it gets configured with settings fetched from the endpoint (chain - /// id, ) - pub async fn evm_env(&self) -> eyre::Result { - if let Some(ref fork_url) = self.fork_url { - Ok(self.fork_evm_env(fork_url).await?.0) + /// The effective source consists of the URL, active headers, and JWT. Explicit block, network, + /// and chain-ID selections are retained so the new source is resolved under the same user + /// configuration. + pub fn invalidate_fork_endpoint_if_source_changed(&mut self, fork: &Fork) -> bool { + let matches = self.fork_url.as_deref().is_some_and(|fork_url| { + fork.matches_source(fork_url, self.fork_source_headers(), self.rpc_jwt.as_deref()) + }); + if matches { + return false; + } + self.clear_endpoint_derived_fork_state(); + true + } + + /// Applies an explicit network override, replacing any endpoint-inferred selection. + pub fn set_explicit_network(&mut self, network: NetworkVariant) { + self.networks = network.into(); + self.fork_network_is_inferred = false; + } + + /// Requires subsequent local fork construction to use this exact endpoint identity. + pub fn expect_fork_endpoint( + &mut self, + identity: ForkEndpointIdentity, + network_is_inferred: bool, + ) { + self.fork_endpoint = Some(identity.clone()); + self.expected_fork_endpoint = Some(identity); + self.fork_network_is_inferred = network_is_inferred; + } + + fn ensure_expected_fork_endpoint(&self, identity: &ForkEndpointIdentity) -> eyre::Result<()> { + if let Some(expected) = &self.expected_fork_endpoint + && expected != identity + { + eyre::bail!( + "fork endpoint {} changed after its execution context was selected", + fork_endpoint_description(&identity.endpoint) + ); + } + Ok(()) + } + + /// Rejects an inferred network whose endpoint now reports a different execution profile. + fn ensure_inferred_execution_profile( + &self, + identity: &ForkEndpointIdentity, + ) -> eyre::Result<()> { + eyre::ensure!( + !self.fork_network_is_inferred + || self.networks.has_same_execution_profile(&identity.network_profile), + "fork endpoint {} changed execution profile from `{}` to `{}`; rebuild the EVM for \ + the new network", + fork_endpoint_description(&identity.endpoint), + self.networks.execution_profile_name(), + identity.network_profile.execution_profile_name() + ); + Ok(()) + } + + pub(crate) fn chain_id_override(&self) -> Option { + (!self.fork_chain_id_is_inferred).then_some(self.env.chain_id).flatten() + } + + fn endpoint_network_fallback(&self) -> NetworkConfigs { + if self.networks.has_network_selection() && !self.fork_network_is_inferred { + self.networks } else { - Ok(self.local_evm_env()) + NetworkConfigs::default() } } - /// Returns the `revm::Env` that is configured with settings retrieved from the endpoint. - /// And the block that was used to configure the environment. - pub async fn fork_evm_env(&self, fork_url: &str) -> eyre::Result<(crate::Env, AnyRpcBlock)> { - let provider = ProviderBuilder::new(fork_url) - .compute_units_per_second(self.get_compute_units_per_second()) - .build()?; - environment( - &provider, - self.memory_limit, - self.env.gas_price.map(|v| v as u128), - self.env.chain_id, + fn anvil_node_info_probe(&self) -> AnvilNodeInfoProbe { + let endpoint = self.fork_url.as_deref(); + let identified = [self.fork_endpoint.as_ref(), self.expected_fork_endpoint.as_ref()] + .into_iter() + .flatten() + .any(|identity| { + Some(identity.endpoint.as_str()) == endpoint && identity.reported_hardfork.is_some() + }); + AnvilNodeInfoProbe::new(identified) + } + + /// Returns the chain ID of the configured endpoint if its cached discovery found no Anvil + /// identity. + /// + /// Without Anvil node info the identity is derived from the chain ID alone: there is no + /// instance, hardfork, or upstream fork that a reset could change between two reads. Later + /// snapshots of the same endpoint therefore reuse the discovered chain ID instead of + /// requesting `eth_chainId` and probing `anvil_nodeInfo` again. An endpoint that + /// `node_info_probe` or a cached identity identifies as Anvil is never reused and keeps its + /// strict revalidation. + fn discovered_non_anvil_chain_id( + &self, + node_info_probe: AnvilNodeInfoProbe, + ) -> Option { + if node_info_probe.identified { + return None; + } + let endpoint = self.fork_url.as_deref()?; + self.fork_endpoint + .as_ref() + .filter(|identity| identity.endpoint == endpoint) + .map(|identity| identity.execution_chain_id) + } + + pub(crate) fn fork_source_headers(&self) -> Option<&[String]> { + self.fork_headers.as_deref().or(self.rpc_headers.as_deref()) + } + + #[cfg(test)] + fn test_fork(&self, fork_url: &str, block: BlockNumHash, context: ForkContext) -> Fork { + let mut fork = Fork::test( + fork_url, + self.fork_source_headers(), + self.rpc_jwt.as_deref(), self.fork_block_number, - self.sender, - self.disable_block_gas_limit, + block, + context, + ); + fork.state_by_number = self.fork_state_by_number; + fork + } + + /// Converts an implicit `latest` selector into a block-number selector in place. + /// + /// This only reads the endpoint's current block number; it does not resolve a block hash or + /// endpoint identity and is therefore not a reorg-safe fork snapshot. Create a + /// [`Backend`](crate::backend::Backend) when preflight and execution must share an exact + /// identity. + pub async fn pin_fork_block(&mut self) -> eyre::Result> { + if self.fork_block_number.is_none() + && let Some(fork_url) = &self.fork_url + { + self.fork_block_number = Some( + self.fork_provider_with_url::(fork_url)?.get_block_number().await?, + ); + self.fork_block_number_is_inferred = true; + } + Ok(self.fork_block_number) + } + + /// Resolves the configured fork source and selector to an exact snapshot without changing + /// `self`. + /// + /// Endpoint identity is read before and after the target block and the operation is retried if + /// it changes. The result binds the configured RPC source, configured selector, exact block + /// number and hash, and [`ForkContext`]. When the selector is implicit `latest`, only the + /// returned [`Fork`] is pinned. + pub(crate) async fn prepare_fork(&self) -> eyre::Result> { + let Some(fork_url) = &self.fork_url else { return Ok(None) }; + let client = self.fork_rpc_client(fork_url)?; + let provider = RootProvider::::new(client.clone()); + let target = match self.fork_block_number { + Some(block_number) => BlockNumberOrTag::Number(block_number), + None => BlockNumberOrTag::Latest, + }; + let mut node_info_probe = self.anvil_node_info_probe(); + let (block, _, context) = + self.resolve_fork_block_with_context(&provider, target, &mut node_info_probe).await?; + Ok(Some(Fork::new(self, client, block, context))) + } + + /// Returns whether the configured CREATE2 deployer existed at the resolved fork block. + pub(crate) async fn can_use_create2_deployer_at( + &self, + fork: Option<&Fork>, + ) -> eyre::Result { + let Some(_) = &self.fork_url else { + eyre::ensure!(fork.is_none(), "resolved fork provided without a configured fork"); + return Ok(self.create2_deployer == DEFAULT_CREATE2_DEPLOYER); + }; + let fork = fork.ok_or_else(|| eyre::eyre!("fork must be resolved"))?; + let provider = self.provider_for_fork::(fork)?; + self.check_fork_endpoint(&provider, fork).await?; + let available = !provider + .get_code_at(self.create2_deployer) + .block_id(fork.state_block_id()) + .await? + .is_empty(); + self.check_fork_endpoint(&provider, fork).await?; + Ok(available) + } + + /// Returns a provider configured for an already resolved fork. + pub(crate) fn provider_for_fork( + &self, + fork: &Fork, + ) -> eyre::Result> { + eyre::ensure!( + fork.matches_request(self), + "resolved fork does not match the configured source and selector" + ); + Ok(fork.provider()) + } + + pub(crate) async fn check_fork_endpoint>( + &self, + provider: &P, + fork: &Fork, + ) -> eyre::Result<()> { + let mut node_info_probe = self.anvil_node_info_probe(); + node_info_probe.identified |= fork.context().hardfork.is_some(); + let identity = self + .resolve_fork_endpoint_once( + provider, + &mut node_info_probe, + EndpointHardforkPolicy::Required, + ) + .await?; + self.ensure_expected_fork_endpoint(&identity)?; + self.ensure_inferred_execution_profile(&identity)?; + eyre::ensure!( + fork.context().matches_identity(&identity), + "fork endpoint {} changed after its block and execution context were resolved", + fork_endpoint_description(self.fork_url.as_deref().unwrap_or_default()) + ); + Ok(()) + } + + /// Returns an account nonce at the exact block and endpoint identity of `fork`. + pub(crate) async fn transaction_count_at_fork( + &self, + account: Address, + fork: &Fork, + ) -> eyre::Result { + let provider = self.provider_for_fork::(fork)?; + self.check_fork_endpoint(&provider, fork).await?; + let nonce = provider.get_transaction_count(account).block_id(fork.state_block_id()).await?; + self.check_fork_endpoint(&provider, fork).await?; + Ok(nonce) + } + + /// Returns a `RootProvider` for the given fork URL configured with options in `self` and + /// annotated `Network` type. + pub fn fork_provider_with_url( + &self, + fork_url: &str, + ) -> eyre::Result> { + Ok(RootProvider::new(self.fork_rpc_client(fork_url)?)) + } + + pub(crate) fn fork_rpc_client(&self, fork_url: &str) -> eyre::Result { + let mut builder = ProviderBuilder::::new(fork_url) + .maybe_max_retry(self.fork_retries) + .maybe_initial_backoff(self.fork_retry_backoff) + .maybe_headers(self.fork_headers.clone().or_else(|| self.rpc_headers.clone())) + .compute_units_per_second(self.get_compute_units_per_second()) + .accept_invalid_certs(self.rpc_accept_invalid_certs) + .no_proxy(self.rpc_no_proxy); + if let Some(jwt) = &self.rpc_jwt { + builder = builder.jwt(jwt); + } + if let Some(timeout) = self.rpc_timeout { + builder = builder.timeout(std::time::Duration::from_secs(timeout)); + } + builder.build_client() + } + + /// Discovers and caches the network configuration and endpoint identity for a fork. + /// + /// This is the dispatch phase of fork setup: explicit network selections are preserved, while + /// inferred chain and network settings are applied when no override exists. The method does not + /// resolve or pin a fork block; block resolution happens during backend creation or through + /// [`Self::env_with_fork_context`]. Repeated calls only inspect the endpoint again as + /// described by [`Self::fork_endpoint_identity`]. + pub async fn infer_network_from_fork(&mut self) -> eyre::Result<()> { + let previous_identity = self.fork_endpoint.clone(); + let Some(fork_url) = self.fork_url.clone() else { + self.fork_endpoint = None; + self.expected_fork_endpoint = None; + if self.fork_network_is_inferred { + self.networks = self.networks.with_rpc_profile(NetworkConfigs::default()); + self.fork_network_is_inferred = false; + } + if self.fork_chain_id_is_inferred { + self.env.chain_id = None; + self.fork_chain_id_is_inferred = false; + } + if self.fork_block_number_is_inferred { + self.fork_block_number = None; + self.fork_block_number_is_inferred = false; + } + return Ok(()); + }; + let explicit_network = + self.networks.has_network_selection() && !self.fork_network_is_inferred; + let identity = self.fork_endpoint_identity().await?; + self.ensure_expected_fork_endpoint(&identity)?; + if self.fork_network_is_inferred + && previous_identity.as_ref().is_some_and(|previous| { + previous.endpoint == fork_url + && previous.network_profile != identity.network_profile + }) + { + eyre::bail!( + "fork endpoint {} changed execution profile from `{}` to `{}`; rebuild \ + the EVM for the new network", + fork_endpoint_description(&fork_url), + self.networks.execution_profile_name(), + identity.network_profile.execution_profile_name() + ); + } + + if self.env.chain_id.is_none() || self.fork_chain_id_is_inferred { + self.env.chain_id = Some(identity.execution_chain_id); + self.fork_chain_id_is_inferred = true; + } + if !explicit_network { + self.networks = self.networks.with_rpc_profile(identity.network_profile); + self.fork_network_is_inferred = true; + } + debug_assert_eq!(identity.endpoint, fork_url); + self.fork_endpoint = Some(identity); + Ok(()) + } + + /// Discovers a stable identity snapshot without selecting a fork block. + /// + /// Each attempt reads `eth_chainId` and the optional Anvil identity methods before and after, + /// and returns only when both snapshots agree. Before Anvil is positively identified, a failed + /// `anvil_nodeInfo` probe is treated as absence of optional Anvil identity information: the + /// identity then depends on the chain ID alone and is returned from the first snapshot without + /// a confirming one. Later probe failures are strict. This method does not mutate or cache the + /// returned identity in `self`. + pub async fn discover_fork_endpoint(&self) -> eyre::Result { + let fork_url = self.fork_url.as_deref().ok_or_eyre("fork URL is not configured")?; + let provider = self.fork_provider_with_url::(fork_url)?; + let unknown_fallback = self.endpoint_network_fallback(); + let mut node_info_probe = self.anvil_node_info_probe(); + for _ in 0..3 { + let before_chain_id = provider + .get_chain_id() + .await + .wrap_err("failed to retrieve chain ID from fork endpoint")?; + let Some(before_node_info) = node_info_probe.request(&provider).await? else { + return Self::non_anvil_fork_endpoint_identity( + fork_url, + before_chain_id, + Some(unknown_fallback), + ); + }; + let before = Self::resolve_fork_endpoint_identity( + &provider, + fork_url, + before_chain_id, + Some(before_node_info), + Some(unknown_fallback), + EndpointHardforkPolicy::Optional, + ) + .await?; + let after_chain_id = provider + .get_chain_id() + .await + .wrap_err("failed to confirm chain ID from fork endpoint")?; + let after_node_info = node_info_probe.request(&provider).await?; + let after = Self::resolve_fork_endpoint_identity( + &provider, + fork_url, + after_chain_id, + after_node_info, + Some(unknown_fallback), + EndpointHardforkPolicy::Optional, + ) + .await?; + if before_chain_id == after_chain_id && before == after { + return Ok(before); + } + } + eyre::bail!( + "fork endpoint {} changed while its identity was being resolved", + fork_endpoint_description(fork_url) + ); + } + + /// Returns the identity of the configured fork endpoint, inspecting it only when necessary. + /// + /// A cached discovery that found no Anvil identity is returned without new requests. Anvil + /// endpoints and endpoints without a cached discovery are inspected through + /// [`Self::discover_fork_endpoint`], so a reset or execution-profile change of an Anvil + /// endpoint is still observed. + pub async fn fork_endpoint_identity(&self) -> eyre::Result { + let fork_url = self.fork_url.as_deref().ok_or_eyre("fork URL is not configured")?; + if let Some(execution_chain_id) = + self.discovered_non_anvil_chain_id(self.anvil_node_info_probe()) + { + return Self::non_anvil_fork_endpoint_identity( + fork_url, + execution_chain_id, + Some(self.endpoint_network_fallback()), + ); + } + self.discover_fork_endpoint().await + } + + /// Reads one endpoint identity snapshot without performing a stability check. + /// + /// The shared node-info probe starts permissive for endpoints that do not expose Anvil methods + /// and becomes strict after Anvil is identified. Callers either bracket mutable remote reads + /// with two snapshots or use this helper to revalidate an already resolved fork. No request is + /// sent for an endpoint whose cached discovery found no Anvil identity. + async fn resolve_fork_endpoint_once>( + &self, + provider: &P, + node_info_probe: &mut AnvilNodeInfoProbe, + hardfork_policy: EndpointHardforkPolicy, + ) -> eyre::Result { + let fork_url = self.fork_url.as_deref().ok_or_eyre("fork URL is not configured")?; + if let Some(execution_chain_id) = self.discovered_non_anvil_chain_id(*node_info_probe) { + return Self::non_anvil_fork_endpoint_identity( + fork_url, + execution_chain_id, + Some(self.endpoint_network_fallback()), + ); + } + let cached_anvil = self.fork_endpoint.as_ref().is_some_and(|identity| { + identity.endpoint == fork_url && identity.reported_hardfork.is_some() + }); + let node_info = node_info_probe.request(provider).await?; + let execution_chain_id = if cached_anvil && let Some(node_info) = &node_info { + node_info.environment.chain_id + } else { + provider + .get_chain_id() + .await + .wrap_err("failed to retrieve chain ID from fork endpoint")? + }; + Self::resolve_fork_endpoint_identity( + provider, + fork_url, + execution_chain_id, + node_info, + Some(self.endpoint_network_fallback()), + hardfork_policy, ) .await - .wrap_err_with(|| { - let mut msg = "could not instantiate forked environment".to_string(); - if let Ok(url) = Url::parse(fork_url) - && let Some(provider) = url.host() - { - write!(msg, " with provider {provider}").unwrap(); + } + + /// Builds an identity from one observed chain ID and optional Anvil node-info response. + /// + /// Anvil metadata is consulted only after node info has positively identified the endpoint. + async fn resolve_fork_endpoint_identity>( + provider: &P, + endpoint: &str, + execution_chain_id: ChainId, + node_info: Option, + unknown_fallback: Option, + hardfork_policy: EndpointHardforkPolicy, + ) -> eyre::Result { + match node_info { + Some(node_info) => { + let ( + source_chain_id, + instance_id, + source_fork_block_number, + source_fork_block_hash, + ) = match provider.raw_request::<_, Metadata>("anvil_metadata".into(), ()).await { + Ok(metadata) => { + let forked_network = metadata.forked_network; + ( + forked_network.map(|fork| fork.chain_id).unwrap_or(execution_chain_id), + Some(metadata.instance_id), + forked_network.map(|fork| fork.fork_block_number), + forked_network.map(|fork| fork.fork_block_hash), + ) + } + Err(error) if is_rpc_method_not_found(&error) => { + (execution_chain_id, None, None, None) + } + Err(error) => { + return Err(error) + .wrap_err("failed to retrieve Anvil fork source identity"); + } + }; + let identity_chain_id = + if node_info.network.is_some() { execution_chain_id } else { source_chain_id }; + let network_profile = NetworkConfigs::from_rpc_identity_profile_with_fallback( + identity_chain_id, + Some(node_info.network.as_deref()), + unknown_fallback, + ) + .map_err(eyre::Report::msg)? + .ok_or_else(|| { + eyre::eyre!("Anvil metadata did not identify an execution profile") + })?; + let network = network_profile.execution_network(); + let hardfork = endpoint_hardfork(network, &node_info.hard_fork, hardfork_policy)?; + + Ok(ForkEndpointIdentity { + endpoint: endpoint.to_string(), + execution_chain_id, + source_chain_id, + network, + network_profile, + reported_hardfork: Some(node_info.hard_fork), + hardfork, + instance_id, + source_fork_block_number, + source_fork_block_hash, + }) } - msg + None => Self::non_anvil_fork_endpoint_identity( + endpoint, + execution_chain_id, + unknown_fallback, + ), + } + } + + /// Builds the identity of an endpoint that exposes no Anvil node info from its chain ID. + fn non_anvil_fork_endpoint_identity( + endpoint: &str, + execution_chain_id: ChainId, + unknown_fallback: Option, + ) -> eyre::Result { + let network_profile = NetworkConfigs::from_rpc_identity_profile_with_fallback( + execution_chain_id, + None, + unknown_fallback, + ) + .map_err(eyre::Report::msg)? + .ok_or_else(|| { + eyre::eyre!( + "cannot determine network family for unknown chain ID \ + {execution_chain_id}: the fork endpoint does not expose `anvil_nodeInfo`" + ) + })?; + Ok(ForkEndpointIdentity { + endpoint: endpoint.to_string(), + execution_chain_id, + source_chain_id: execution_chain_id, + network: network_profile.execution_network(), + network_profile, + reported_hardfork: None, + hardfork: None, + instance_id: None, + source_fork_block_number: None, + source_fork_block_hash: None, }) } - /// Returns the `revm::Env` configured with only local settings - pub fn local_evm_env(&self) -> crate::Env { - let cfg = configure_env( - self.env.chain_id.unwrap_or(foundry_common::DEV_CHAIN_ID), - self.memory_limit, - self.disable_block_gas_limit, - ); - - crate::Env { - evm_env: EvmEnv { - cfg_env: cfg, - block_env: BlockEnv { - number: self.env.block_number, - beneficiary: self.env.block_coinbase, - timestamp: self.env.block_timestamp, - difficulty: U256::from(self.env.block_difficulty), - prevrandao: Some(self.env.block_prevrandao), - basefee: self.env.block_base_fee_per_gas, - gas_limit: self.gas_limit(), - ..Default::default() - }, - }, - tx: TxEnv { - gas_price: self.env.gas_price.unwrap_or_default().into(), - gas_limit: self.gas_limit(), - caller: self.sender, - ..Default::default() - }, + /// Returns standalone execution environments and the remote source context, if any. + /// + /// Execution workflows with a backend use its selected block through `Backend::env`. + pub async fn env_with_fork_context< + SPEC: Into + Default + Copy, + BLOCK: FoundryBlock + Default, + TX: FoundryTransaction + Default, + >( + &self, + ) -> eyre::Result<(EvmEnv, TX, Option)> { + let Some(fork_url) = &self.fork_url else { + return Ok((self.local_evm_env(), self.local_tx_env(), None)); + }; + let provider = self.fork_provider_with_url::(fork_url)?; + let target = self.fork_block_number.map_or(BlockNumberOrTag::Latest, Into::into); + let mut node_info_probe = self.anvil_node_info_probe(); + for _ in 0..3 { + let (block, _, context) = self + .resolve_fork_block_with_context(&provider, target, &mut node_info_probe) + .await?; + let gas_price = + option_try_or_else(self.env.gas_price.map(|value| value as u128), async || { + provider.get_gas_price().await + }) + .await?; + let identity = self + .resolve_fork_endpoint_once( + &provider, + &mut node_info_probe, + EndpointHardforkPolicy::Required, + ) + .await?; + self.ensure_expected_fork_endpoint(&identity)?; + if context.matches_identity(&identity) { + let chain_id = self.chain_id_override().unwrap_or(context.execution_chain_id); + let env = self.fork_env_from_block::( + chain_id, + context.source_chain_id, + &block, + ); + let tx = self.fork_tx_env(gas_price, chain_id); + return Ok((env, tx, Some(context))); + } } + eyre::bail!( + "fork endpoint {} changed while its EVM and transaction environments were being \ + resolved", + fork_endpoint_description(fork_url) + ); + } + + /// Returns the EVM and transaction environments at an already resolved fork. + pub(crate) async fn env_at_fork< + SPEC: Into + Default + Copy, + BLOCK: FoundryBlock + Default, + TX: FoundryTransaction + Default, + >( + &self, + fork: Option<&Fork>, + ) -> eyre::Result<(EvmEnv, TX)> { + let Some(_) = &self.fork_url else { + eyre::ensure!(fork.is_none(), "resolved fork provided without a configured fork"); + return Ok((self.local_evm_env(), self.local_tx_env())); + }; + let fork = fork.ok_or_else(|| eyre::eyre!("fork must be resolved"))?; + let provider = self.provider_for_fork::(fork)?; + self.check_fork_endpoint(&provider, fork).await?; + let context = fork.context(); + let chain_id = self.chain_id_override().unwrap_or(context.execution_chain_id); + let env = self.fork_env_from_block::( + chain_id, + context.source_chain_id, + &fork.block, + ); + let gas_price = + option_try_or_else(self.env.gas_price.map(|value| value as u128), async || { + provider.get_gas_price().await + }) + .await?; + self.check_fork_endpoint(&provider, fork).await?; + Ok((env, self.fork_tx_env(gas_price, chain_id))) + } + + fn fork_env_error_context(&self) -> String { + let mut message = "could not instantiate forked environment".to_string(); + if let Some(fork_url) = self.fork_url.as_deref() + && let Ok(url) = Url::parse(fork_url) + && let Some(host) = url.host() + { + write!(message, " with provider {host}").unwrap(); + } + message + } + + async fn resolve_fork_block_with_context>( + &self, + provider: &P, + target: BlockNumberOrTag, + node_info_probe: &mut AnvilNodeInfoProbe, + ) -> eyre::Result<(N::BlockResponse, BlockNumHash, ForkContext)> { + let endpoint = self.fork_url.as_deref().unwrap_or_default(); + let mut stable = None; + for _ in 0..3 { + let before = self + .resolve_fork_endpoint_once( + provider, + node_info_probe, + EndpointHardforkPolicy::Required, + ) + .await + .wrap_err_with(|| self.fork_env_error_context())?; + let block = provider + .get_block_by_number(target) + .await + .wrap_err_with(|| self.fork_env_error_context())?; + let after = self + .resolve_fork_endpoint_once( + provider, + node_info_probe, + EndpointHardforkPolicy::Required, + ) + .await + .wrap_err_with(|| self.fork_env_error_context())?; + if before == after { + stable = Some((before, block)); + break; + } + } + let (identity, block) = stable.ok_or_else(|| { + eyre::eyre!( + "fork endpoint {} changed while its block and execution context were being \ + resolved", + fork_endpoint_description(endpoint) + ) + })?; + self.ensure_expected_fork_endpoint(&identity)?; + self.ensure_inferred_execution_profile(&identity)?; + + let block = if let Some(block) = block { + block + } else { + let block_number_message = match target { + BlockNumberOrTag::Number(block_number) => { + format!("block number: {block_number}") + } + block_number => format!("{block_number} block"), + }; + let latest_message = if let Ok(latest_block) = provider.get_block_number().await { + if let Some(block_number) = self.fork_block_number + && block_number <= latest_block + { + error!("{NON_ARCHIVE_NODE_WARNING}"); + } + format!("; latest block number: {latest_block}") + } else { + Default::default() + }; + eyre::bail!("failed to get {block_number_message}{latest_message}"); + }; + let actual = BlockNumHash::new(block.header().number(), block.header().hash()); + + let context = ForkContext { + execution_chain_id: identity.execution_chain_id, + source_chain_id: identity.source_chain_id, + network: identity.network, + network_profile: identity.network_profile, + block_number: actual.number, + hardfork: identity.hardfork, + instance_id: identity.instance_id, + source_fork_block_number: identity.source_fork_block_number, + source_fork_block_hash: identity.source_fork_block_hash, + }; + Ok((block, actual, context)) + } + + pub(crate) fn fork_env_from_block< + SPEC: Into + Default + Copy, + BLOCK: FoundryBlock + Default, + N: Network, + >( + &self, + chain_id: ChainId, + source_chain_id: ChainId, + block: &N::BlockResponse, + ) -> EvmEnv { + let mut evm_env = EvmEnv { + cfg_env: self.cfg_env(chain_id), + block_env: block_env_from_header(block.header()), + }; + + apply_chain_and_block_specific_env_changes_for_chain::( + &mut evm_env, + block, + source_chain_id, + self.networks, + ); + + evm_env + } + + /// Returns the [`EvmEnv`] configured with only local settings. + fn local_evm_env + Default + Clone, BLOCK: FoundryBlock + Default>( + &self, + ) -> EvmEnv { + let cfg_env = self.cfg_env(self.env.chain_id.unwrap_or(foundry_common::DEV_CHAIN_ID)); + let mut block_env = BLOCK::default(); + block_env.set_number(self.env.block_number); + block_env.set_beneficiary(self.env.block_coinbase); + block_env.set_timestamp(self.env.block_timestamp); + block_env.set_difficulty(U256::from(self.env.block_difficulty)); + block_env.set_prevrandao(Some(self.env.block_prevrandao)); + block_env.set_basefee(self.env.block_base_fee_per_gas); + block_env.set_gas_limit(self.gas_limit()); + EvmEnv::new(cfg_env, block_env) + } + + /// Returns the `TxEnv` with gas price and chain ID from a stable fork snapshot. + fn fork_tx_env( + &self, + gas_price: u128, + chain_id: ChainId, + ) -> TX { + let mut tx_env = TX::default(); + tx_env.set_caller(self.sender); + tx_env.set_chain_id(Some(chain_id)); + tx_env.set_gas_price(gas_price); + tx_env.set_gas_limit(self.gas_limit()); + tx_env + } + + /// Returns the `TxEnv` configured from local settings only. + fn local_tx_env(&self) -> TX { + let mut tx_env = TX::default(); + tx_env.set_caller(self.sender); + tx_env.set_gas_price(self.env.gas_price.unwrap_or_default().into()); + tx_env.set_gas_limit(self.gas_limit()); + tx_env + } + + /// Builds a [`CfgEnv`] from the options, using the provided [`ChainId`]. + fn cfg_env + Default + Clone>(&self, chain_id: ChainId) -> CfgEnv { + let mut cfg = CfgEnv::default(); + cfg.chain_id = chain_id; + cfg.memory_limit = self.memory_limit; + cfg.limit_contract_code_size = self.env.code_size_limit.or(Some(usize::MAX)); + // EIP-3607 rejects transactions from senders with deployed code. + // If EIP-3607 is enabled it can cause issues during fuzz/invariant tests if the caller + // is a contract. So we disable the check by default. + cfg.disable_eip3607 = true; + cfg.disable_block_gas_limit = self.disable_block_gas_limit; + cfg.disable_nonce_check = true; + // By default do not enforce transaction gas limits imposed by Osaka (EIP-7825). + // Users can opt-in to enable these limits by setting `enable_tx_gas_limit` to true. + if !self.enable_tx_gas_limit { + cfg.tx_gas_limit_cap = Some(u64::MAX); + } + cfg } /// Helper function that returns the [CreateFork] to use, if any. @@ -181,7 +1182,7 @@ impl EvmOpts { /// storage caching for the [CreateFork] will be enabled if /// - `fork_url` is present /// - `fork_block_number` is present - /// - `StorageCachingConfig` allows the `fork_url` + chain ID pair + /// - `StorageCachingConfig` allows the `fork_url` + source chain ID pair /// - storage is allowed (`no_storage_caching = false`) /// /// If all these criteria are met, then storage caching is enabled and storage info will be @@ -189,10 +1190,33 @@ impl EvmOpts { /// /// for `mainnet` and `--fork-block-number 14435000` on mac the corresponding storage cache will /// be at `~/.foundry/cache/mainnet/14435000/storage.json`. - pub fn get_fork(&self, config: &Config, env: crate::Env) -> Option { + /// `fork_block_number` is the actual block number to pin the fork to. This must be the + /// real chain block number, not a remapped value. On some L2s (e.g., Arbitrum) + /// `block_env.number` is remapped to the L1 block number, so callers must pass the + /// block number in the context returned by [`EvmOpts::env_with_fork_context`] instead. + /// + /// RPC storage caching uses the source chain ID of the discovered fork endpoint. Discover the + /// endpoint first, for example with [`EvmOpts::infer_network_from_fork`]. Without it, + /// `chain_id` decides caching. + pub fn get_fork( + &self, + config: &Config, + chain_id: u64, + fork_block_number: Option, + ) -> Option { let url = self.fork_url.clone()?; - let enable_caching = config.enable_caching(&url, env.evm_env.cfg_env.chain_id); - Some(CreateFork { url, enable_caching, env, evm_opts: self.clone() }) + let source_chain_id = + self.fork_endpoint.as_ref().map_or(chain_id, |identity| identity.source_chain_id); + let enable_caching = config.enable_caching(&url, source_chain_id); + + // A supplied RPC block number is part of this creation request. + let mut evm_opts = self.clone(); + if evm_opts.fork_block_number.is_none() { + evm_opts.fork_block_number = fork_block_number; + evm_opts.fork_block_number_is_inferred = fork_block_number.is_some(); + } + + Some(CreateFork { url, enable_caching, evm_opts }) } /// Returns the gas limit to use @@ -200,23 +1224,11 @@ impl EvmOpts { self.env.block_gas_limit.unwrap_or(self.env.gas_limit).0 } - /// Returns the configured chain id, which will be - /// - the value of `chain_id` if set - /// - mainnet if `fork_url` contains "mainnet" - /// - the chain if `fork_url` is set and the endpoints returned its chain id successfully - /// - mainnet otherwise - pub async fn get_chain_id(&self) -> u64 { - if let Some(id) = self.env.chain_id { - return id; - } - self.get_remote_chain_id().await.unwrap_or(Chain::mainnet()).id() - } - /// Returns the available compute units per second, which will be /// - u64::MAX, if `no_rpc_rate_limit` if set (as rate limiting is disabled) /// - the assigned compute units, if `compute_units_per_second` is set /// - ALCHEMY_FREE_TIER_CUPS (330) otherwise - pub fn get_compute_units_per_second(&self) -> u64 { + const fn get_compute_units_per_second(&self) -> u64 { if self.no_rpc_rate_limit { u64::MAX } else if let Some(cups) = self.compute_units_per_second { @@ -228,31 +1240,119 @@ impl EvmOpts { /// Returns the chain ID from the RPC, if any. pub async fn get_remote_chain_id(&self) -> Option { - if let Some(ref url) = self.fork_url { + if let Some(url) = &self.fork_url + && let Ok(provider) = self.fork_provider_with_url::(url) + { trace!(?url, "retrieving chain via eth_chainId"); - let provider = ProviderBuilder::new(url.as_str()) - .compute_units_per_second(self.get_compute_units_per_second()) - .build() - .ok() - .unwrap_or_else(|| panic!("Failed to establish provider to {url}")); if let Ok(id) = provider.get_chain_id().await { return Some(Chain::from(id)); } - - // Provider URLs could be of the format `{CHAIN_IDENTIFIER}-mainnet` - // (e.g. Alchemy `opt-mainnet`, `arb-mainnet`), fallback to this method only - // if we're not able to retrieve chain id from `RetryProvider`. - if url.contains("mainnet") { - trace!(?url, "auto detected mainnet chain"); - return Some(Chain::mainnet()); - } } None } } +/// Describes how an execution spec should use a source chain's hardfork schedule. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ExecutionSpecContext { + /// Local execution without a fork. + Local, + /// Local execution backed by a fork. + Fork { + /// Underlying source chain ID. + source_chain_id: ChainId, + /// Exact hardfork reported by the endpoint. + endpoint_hardfork: Option, + }, + /// Historical execution whose semantics must match the source block. + Historical { + /// Underlying source chain ID. + source_chain_id: ChainId, + /// Exact hardfork reported by the endpoint. + endpoint_hardfork: Option, + }, +} + +impl ExecutionSpecContext { + /// Returns a local fork execution context. + pub const fn fork( + source_chain_id: ChainId, + endpoint_hardfork: Option, + ) -> Self { + Self::Fork { source_chain_id, endpoint_hardfork } + } + + /// Returns the context for an optional local fork source. + pub const fn local_or_fork( + source_chain_id: Option, + endpoint_hardfork: Option, + ) -> Self { + if let Some(source_chain_id) = source_chain_id { + Self::Fork { source_chain_id, endpoint_hardfork } + } else { + Self::Local + } + } + + /// Returns a historical execution context. + pub const fn historical( + source_chain_id: ChainId, + endpoint_hardfork: Option, + ) -> Self { + Self::Historical { source_chain_id, endpoint_hardfork } + } + + fn hardfork(self, timestamp: u64) -> Option { + match self { + Self::Local => None, + Self::Fork { source_chain_id, endpoint_hardfork } => { + SPEC::fork_hardfork(source_chain_id, timestamp, endpoint_hardfork) + } + Self::Historical { source_chain_id, endpoint_hardfork } => endpoint_hardfork + .filter(|&hardfork| SPEC::from_foundry_hardfork(hardfork).is_some()) + .or_else(|| SPEC::historical_hardfork(source_chain_id, timestamp)), + } + } +} + +/// Resolves and applies the execution spec for an EVM environment. +/// +/// A direct caller override takes precedence over a configured namespaced hardfork, followed by +/// exact endpoint metadata and the hardfork selected from the source schedule. Local Ethereum +/// forks retain Foundry's configured EVM version, namespaced local networks follow their source +/// identity, and historical execution always follows the source block's identity. +/// +/// Returns the exact namespaced hardfork, when applicable, so execution and trace decoding can use +/// the same hardfork. +pub fn resolve_execution_spec( + evm_version: EvmVersion, + configured_hardfork: Option, + evm_env: &mut EvmEnv, + context: ExecutionSpecContext, + explicit_spec: Option, +) -> Option +where + SPEC: ExecutionSpec + Into + Copy, + BLOCK: FoundryBlock, +{ + let (spec, hardfork) = if let Some(spec) = explicit_spec { + (spec, spec.reported_hardfork()) + } else if let Some((hardfork, spec)) = configured_hardfork + .filter(|&hardfork| SPEC::from_foundry_hardfork(hardfork).is_some()) + .or_else(|| context.hardfork::(evm_env.block_env.timestamp().saturating_to())) + .and_then(|hardfork| SPEC::from_foundry_hardfork(hardfork).map(|spec| (hardfork, spec))) + { + (spec, Some(hardfork)) + } else { + let spec = evm_spec_id::(evm_version); + (spec, spec.reported_hardfork()) + }; + evm_env.cfg_env.set_spec_and_mainnet_gas_params(spec); + hardfork +} + #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct Env { /// The block gas limit. @@ -305,3 +1405,1485 @@ pub struct Env { #[serde(default, skip_serializing_if = "Option::is_none")] pub code_size_limit: Option, } + +fn fork_endpoint_description(endpoint: &str) -> String { + Url::parse(endpoint) + .ok() + .and_then(|url| url.host_str().map(|host| format!("provider {host}"))) + .unwrap_or_else(|| "configured provider".to_string()) +} + +async fn option_try_or_else( + option: Option, + f: impl AsyncFnOnce() -> Result, +) -> Result { + if let Some(value) = option { Ok(value) } else { f().await } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_chains::NamedChain; + use alloy_network::TransactionBuilder; + use alloy_primitives::bytes; + use alloy_rpc_types::TransactionRequest; + use alloy_serde::WithOtherFields; + use foundry_test_utils::rpc::{ + spawn_rpc_proxy_internal_error_after, spawn_rpc_proxy_method_not_found_before, + spawn_rpc_proxy_recording_method, spawn_rpc_proxy_rejecting_method_after, + }; + use revm::context::{BlockEnv, TxEnv}; + + #[cfg(feature = "base")] + use foundry_evm_hardforks::BaseUpgrade; + + #[cfg(feature = "optimism")] + use op_revm::OpSpecId; + + fn resolved_context(block_number: BlockNumber) -> ForkContext { + ForkContext { + execution_chain_id: 1, + source_chain_id: 1, + network: NetworkVariant::Ethereum, + network_profile: NetworkConfigs::default(), + block_number, + hardfork: None, + instance_id: None, + source_fork_block_number: None, + source_fork_block_hash: None, + } + } + + #[test] + fn fork_endpoint_description_redacts_credentials_and_paths() { + let endpoint = "https://user:secret@example.com/v2/private-api-key?token=another-secret"; + let description = fork_endpoint_description(endpoint); + + assert_eq!(description, "provider example.com"); + assert!(!description.contains("secret")); + assert!(!description.contains("private-api-key")); + } + + #[test] + fn endpoint_network_fallback_preserves_explicit_selection() { + let default = EvmOpts::default().endpoint_network_fallback(); + assert!(!default.has_network_selection()); + + let explicit = EvmOpts { networks: NetworkConfigs::with_ethereum(), ..Default::default() } + .endpoint_network_fallback(); + assert!(explicit.has_network_selection()); + assert_eq!(explicit.execution_network(), NetworkVariant::Ethereum); + } + + #[test] + fn backend_target_matches_number_pinned_fork_identity() { + let context = ForkContext { + execution_chain_id: 1, + source_chain_id: 1, + network: NetworkVariant::Ethereum, + network_profile: NetworkConfigs::default(), + block_number: 10, + hardfork: None, + instance_id: None, + source_fork_block_number: None, + source_fork_block_hash: None, + }; + + let mut different_number = context; + different_number.block_number += 1; + assert!(!context.has_same_backend_target(different_number)); + + let mut different_profile = context; + different_profile.network_profile = NetworkConfigs::with_celo(); + assert!(!context.has_same_backend_target(different_profile)); + + let mut different_hardfork = context; + different_hardfork.hardfork = + Some(FoundryHardfork::Ethereum(foundry_evm_hardforks::EthereumHardfork::Prague)); + assert!(!context.has_same_backend_target(different_hardfork)); + + let mut different_instance = context; + different_instance.instance_id = Some(B256::with_last_byte(3)); + assert!(!context.has_same_backend_target(different_instance)); + } + + #[tokio::test] + async fn expected_fork_endpoint_requires_exact_identity() { + let identity = ForkEndpointIdentity { + endpoint: "http://localhost:8545".to_string(), + execution_chain_id: 1, + source_chain_id: 1, + network: NetworkVariant::Ethereum, + network_profile: NetworkConfigs::default(), + reported_hardfork: Some("Prague".to_string()), + hardfork: Some(FoundryHardfork::Ethereum( + foundry_evm_hardforks::EthereumHardfork::Prague, + )), + instance_id: Some(B256::with_last_byte(1)), + source_fork_block_number: None, + source_fork_block_hash: None, + }; + let mut evm_opts = EvmOpts::default(); + evm_opts.expect_fork_endpoint(identity.clone(), true); + + assert!(evm_opts.ensure_expected_fork_endpoint(&identity).is_ok()); + assert_eq!(evm_opts.fork_endpoint, Some(identity.clone())); + assert_eq!(evm_opts.expected_fork_endpoint, Some(identity.clone())); + assert!(evm_opts.fork_network_is_inferred); + + let mut changed_instance = identity.clone(); + changed_instance.instance_id = Some(B256::with_last_byte(2)); + assert!(evm_opts.ensure_expected_fork_endpoint(&changed_instance).is_err()); + + let mut changed_unknown_hardfork = identity; + changed_unknown_hardfork.reported_hardfork = Some("Future".to_string()); + changed_unknown_hardfork.hardfork = None; + assert!(evm_opts.ensure_expected_fork_endpoint(&changed_unknown_hardfork).is_err()); + + evm_opts.infer_network_from_fork().await.unwrap(); + assert_eq!(evm_opts.fork_endpoint, None); + assert_eq!(evm_opts.expected_fork_endpoint, None); + } + + #[test] + fn fork_expected_anvil_identity_seeds_strict_node_info_probe() { + let authoritative = ForkEndpointIdentity { + endpoint: "http://localhost:8545".to_string(), + execution_chain_id: 1, + source_chain_id: 1, + network: NetworkVariant::Ethereum, + network_profile: NetworkConfigs::default(), + reported_hardfork: Some("Prague".to_string()), + hardfork: Some(FoundryHardfork::Ethereum( + foundry_evm_hardforks::EthereumHardfork::Prague, + )), + instance_id: Some(B256::with_last_byte(1)), + source_fork_block_number: None, + source_fork_block_hash: None, + }; + let mut anonymous = authoritative.clone(); + anonymous.reported_hardfork = None; + anonymous.hardfork = None; + anonymous.instance_id = None; + let mut evm_opts = EvmOpts { + fork_url: Some(authoritative.endpoint.clone()), + fork_endpoint: Some(anonymous), + expected_fork_endpoint: Some(authoritative), + ..Default::default() + }; + + assert!(evm_opts.anvil_node_info_probe().identified); + + evm_opts.fork_url = Some("http://localhost:9545".to_string()); + assert!(!evm_opts.anvil_node_info_probe().identified); + } + + #[test] + fn selecting_new_fork_url_clears_only_endpoint_derived_state() { + let identity = ForkEndpointIdentity { + endpoint: "http://localhost:8545".to_string(), + execution_chain_id: 42, + source_chain_id: 42, + network: NetworkVariant::Ethereum, + network_profile: NetworkConfigs::with_celo(), + reported_hardfork: None, + hardfork: None, + instance_id: None, + source_fork_block_number: None, + source_fork_block_hash: None, + }; + let mut inferred = EvmOpts { + fork_url: Some(identity.endpoint.clone()), + fork_block_number: Some(123), + fork_endpoint: Some(identity.clone()), + expected_fork_endpoint: Some(identity.clone()), + networks: identity.network_profile, + fork_network_is_inferred: true, + fork_chain_id_is_inferred: true, + fork_block_number_is_inferred: true, + ..Default::default() + }; + inferred.env.chain_id = Some(identity.execution_chain_id); + + inferred.set_fork_url("http://localhost:9545".to_string()); + + assert_eq!(inferred.fork_url.as_deref(), Some("http://localhost:9545")); + assert_eq!(inferred.fork_block_number, None); + assert_eq!(inferred.fork_endpoint, None); + assert_eq!(inferred.expected_fork_endpoint, None); + assert_eq!(inferred.networks, NetworkConfigs::default()); + assert_eq!(inferred.env.chain_id, None); + assert!(!inferred.fork_network_is_inferred); + assert!(!inferred.fork_chain_id_is_inferred); + assert!(!inferred.fork_block_number_is_inferred); + + let mut explicit = EvmOpts { + fork_url: Some(identity.endpoint.clone()), + fork_block_number: Some(123), + fork_endpoint: Some(identity.clone()), + expected_fork_endpoint: Some(identity), + networks: NetworkConfigs::with_celo(), + ..Default::default() + }; + explicit.env.chain_id = Some(42); + + explicit.set_fork_url("http://localhost:9545".to_string()); + + assert_eq!(explicit.networks, NetworkConfigs::with_celo()); + assert_eq!(explicit.env.chain_id, Some(42)); + assert_eq!(explicit.fork_block_number, Some(123)); + assert_eq!(explicit.fork_endpoint, None); + assert_eq!(explicit.expected_fork_endpoint, None); + assert!(!explicit.fork_block_number_is_inferred); + } + + #[test] + fn changed_fork_auth_clears_only_endpoint_derived_state() { + let endpoint = "http://localhost:8545"; + let identity = ForkEndpointIdentity { + endpoint: endpoint.to_string(), + execution_chain_id: 42, + source_chain_id: 42, + network: NetworkVariant::Ethereum, + network_profile: NetworkConfigs::with_celo(), + reported_hardfork: None, + hardfork: None, + instance_id: Some(B256::with_last_byte(1)), + source_fork_block_number: None, + source_fork_block_hash: None, + }; + let context = ForkContext { + execution_chain_id: identity.execution_chain_id, + source_chain_id: identity.source_chain_id, + network: identity.network, + network_profile: identity.network_profile, + block_number: 123, + hardfork: identity.hardfork, + instance_id: identity.instance_id, + source_fork_block_number: identity.source_fork_block_number, + source_fork_block_hash: identity.source_fork_block_hash, + }; + let mut evm_opts = EvmOpts { + fork_url: Some(endpoint.to_string()), + fork_block_number: Some(123), + fork_headers: Some(vec!["Authorization: first".to_string()]), + rpc_jwt: Some("first-jwt".to_string()), + fork_endpoint: Some(identity.clone()), + expected_fork_endpoint: Some(identity), + networks: NetworkConfigs::with_celo(), + ..Default::default() + }; + evm_opts.env.chain_id = Some(42); + let fork = evm_opts.test_fork( + endpoint, + BlockNumHash::new(context.block_number, B256::with_last_byte(2)), + context, + ); + + evm_opts.fork_headers = Some(vec!["Authorization: second".to_string()]); + evm_opts.rpc_jwt = Some("second-jwt".to_string()); + + assert!(evm_opts.invalidate_fork_endpoint_if_source_changed(&fork)); + assert_eq!(evm_opts.fork_endpoint, None); + assert_eq!(evm_opts.expected_fork_endpoint, None); + assert_eq!(evm_opts.fork_block_number, Some(123)); + assert_eq!(evm_opts.networks, NetworkConfigs::with_celo()); + assert_eq!(evm_opts.env.chain_id, Some(42)); + } + + #[test] + fn changed_fork_selector_preserves_endpoint_identity() { + let endpoint = "http://localhost:8545"; + let identity = ForkEndpointIdentity { + endpoint: endpoint.to_string(), + execution_chain_id: 1, + source_chain_id: 1, + network: NetworkVariant::Ethereum, + network_profile: NetworkConfigs::default(), + reported_hardfork: None, + hardfork: None, + instance_id: Some(B256::with_last_byte(1)), + source_fork_block_number: None, + source_fork_block_hash: None, + }; + let mut evm_opts = EvmOpts { + fork_url: Some(endpoint.to_string()), + fork_headers: Some(vec!["x-source: stable".to_string()]), + rpc_jwt: Some("stable-jwt".to_string()), + fork_endpoint: Some(identity.clone()), + expected_fork_endpoint: Some(identity.clone()), + ..Default::default() + }; + let fork = evm_opts.test_fork( + endpoint, + BlockNumHash::new(1, B256::with_last_byte(2)), + resolved_context(1), + ); + + evm_opts.fork_block_number = Some(1); + + assert!(!evm_opts.invalidate_fork_endpoint_if_source_changed(&fork)); + assert_eq!(evm_opts.fork_endpoint, Some(identity.clone())); + assert_eq!(evm_opts.expected_fork_endpoint, Some(identity)); + } + + #[test] + fn selecting_same_fork_url_preserves_pinned_state() { + let mut evm_opts = EvmOpts { + fork_url: Some("http://localhost:8545".to_string()), + fork_block_number: Some(123), + networks: NetworkConfigs::with_celo(), + fork_network_is_inferred: true, + fork_chain_id_is_inferred: true, + fork_block_number_is_inferred: true, + ..Default::default() + }; + evm_opts.env.chain_id = Some(42); + + evm_opts.set_fork_url("http://localhost:8545".to_string()); + + assert_eq!(evm_opts.fork_block_number, Some(123)); + assert_eq!(evm_opts.networks, NetworkConfigs::with_celo()); + assert_eq!(evm_opts.env.chain_id, Some(42)); + assert!(evm_opts.fork_network_is_inferred); + assert!(evm_opts.fork_chain_id_is_inferred); + assert!(evm_opts.fork_block_number_is_inferred); + } + + #[cfg(feature = "monad")] + fn monad_env(timestamp: u64) -> EvmEnv { + let mut block = BlockEnv::default(); + block.set_timestamp(U256::from(timestamp)); + let mut cfg = CfgEnv::new_with_spec(foundry_evm_hardforks::MonadHardfork::default()); + cfg.chain_id = NamedChain::Monad as u64; + EvmEnv::new(cfg, block) + } + + #[test] + #[cfg(feature = "monad")] + fn resolve_execution_spec_uses_monad_ten_activation_timestamp() { + let config = Config::default(); + let activation = + foundry_evm_hardforks::MonadHardfork::MonadTen.mainnet_activation_timestamp().unwrap(); + + let mut before = monad_env(activation - 1); + assert_eq!( + resolve_execution_spec( + config.evm_version, + config.hardfork, + &mut before, + ExecutionSpecContext::fork(NamedChain::Monad as u64, None), + None, + ), + Some(FoundryHardfork::Monad(foundry_evm_hardforks::MonadHardfork::MonadNine)) + ); + assert_eq!(before.cfg_env.spec, foundry_evm_hardforks::MonadHardfork::MonadNine); + + let mut after = monad_env(activation); + assert_eq!( + resolve_execution_spec( + config.evm_version, + config.hardfork, + &mut after, + ExecutionSpecContext::fork(NamedChain::Monad as u64, None), + None, + ), + Some(FoundryHardfork::Monad(foundry_evm_hardforks::MonadHardfork::MonadTen)) + ); + assert_eq!(after.cfg_env.spec, foundry_evm_hardforks::MonadHardfork::MonadTen); + } + + #[test] + #[cfg(feature = "monad")] + fn resolve_execution_spec_prefers_exact_endpoint_hardfork() { + let config = Config::default(); + let activation = + foundry_evm_hardforks::MonadHardfork::MonadNine.mainnet_activation_timestamp().unwrap(); + let mut env = monad_env(activation); + let endpoint_hardfork = + FoundryHardfork::Monad(foundry_evm_hardforks::MonadHardfork::MonadEight); + + assert_eq!( + resolve_execution_spec( + config.evm_version, + config.hardfork, + &mut env, + ExecutionSpecContext::fork(NamedChain::Monad as u64, Some(endpoint_hardfork),), + None, + ), + Some(endpoint_hardfork) + ); + assert_eq!(env.cfg_env.spec, foundry_evm_hardforks::MonadHardfork::MonadEight); + } + + #[test] + #[cfg(feature = "monad")] + fn resolve_execution_spec_ignores_schedule_for_local_env() { + let config = Config::default(); + let activation = + foundry_evm_hardforks::MonadHardfork::MonadTen.mainnet_activation_timestamp().unwrap(); + let mut env = monad_env(activation - 1); + + assert_eq!( + resolve_execution_spec( + config.evm_version, + config.hardfork, + &mut env, + ExecutionSpecContext::Local, + None, + ), + Some(FoundryHardfork::Monad(foundry_evm_hardforks::MonadHardfork::MonadTen)) + ); + assert_eq!(env.cfg_env.spec, foundry_evm_hardforks::MonadHardfork::MonadTen); + } + + #[test] + fn resolve_execution_spec_preserves_ethereum_config_for_local_forks() { + let config = Config::default(); + let mut block = BlockEnv::default(); + block.set_timestamp(U256::from(1_500_000_000u64)); + let mut env = EvmEnv::new(CfgEnv::new_with_spec(SpecId::LONDON), block); + + assert_eq!( + resolve_execution_spec( + config.evm_version, + config.hardfork, + &mut env, + ExecutionSpecContext::fork(NamedChain::Mainnet as u64, None), + None, + ), + None + ); + assert_eq!(env.cfg_env.spec, config.evm_spec_id::()); + + let endpoint_hardfork = + FoundryHardfork::Ethereum(foundry_evm_hardforks::EthereumHardfork::Frontier); + assert_eq!( + resolve_execution_spec( + config.evm_version, + config.hardfork, + &mut env, + ExecutionSpecContext::fork(NamedChain::Mainnet as u64, Some(endpoint_hardfork),), + None, + ), + None + ); + assert_eq!(env.cfg_env.spec, config.evm_spec_id::()); + } + + #[test] + fn resolve_execution_spec_uses_ethereum_schedule_for_historical_execution() { + let config = Config::default(); + let timestamp = 1_500_000_000u64; + let expected = + FoundryHardfork::from_chain_and_timestamp(NamedChain::Mainnet as u64, timestamp) + .unwrap(); + let mut block = BlockEnv::default(); + block.set_timestamp(U256::from(timestamp)); + let mut env = EvmEnv::new(CfgEnv::new_with_spec(SpecId::LONDON), block); + + assert_eq!( + resolve_execution_spec( + config.evm_version, + config.hardfork, + &mut env, + ExecutionSpecContext::historical(NamedChain::Mainnet as u64, None), + None, + ), + Some(expected) + ); + assert_eq!(env.cfg_env.spec, SpecId::from(expected)); + } + + #[test] + #[cfg(feature = "optimism")] + fn resolve_execution_spec_uses_optimism_schedule_for_local_forks() { + let config = + Config { hardfork: Some("ethereum:london".parse().unwrap()), ..Default::default() }; + let chain_id = NamedChain::Optimism as u64; + let timestamp = u64::MAX; + let expected = FoundryHardfork::from_chain_and_timestamp(chain_id, timestamp).unwrap(); + let mut block = BlockEnv::default(); + block.set_timestamp(U256::from(timestamp)); + let mut env = EvmEnv::new(CfgEnv::new_with_spec(OpSpecId::default()), block); + + assert_eq!( + resolve_execution_spec( + config.evm_version, + config.hardfork, + &mut env, + ExecutionSpecContext::fork(chain_id, Some("tempo:T3".parse().unwrap())), + None, + ), + Some(expected) + ); + assert_eq!(env.cfg_env.spec, OpSpecId::from_foundry_hardfork(expected).unwrap()); + } + + #[test] + #[cfg(all(feature = "optimism", not(feature = "base")))] + fn resolve_execution_spec_preserves_config_for_base_op_fallback() { + let config = Config { evm_version: EvmVersion::Osaka, ..Default::default() }; + let mut block = BlockEnv::default(); + block.set_timestamp(U256::from(u64::MAX)); + let mut env = EvmEnv::new(CfgEnv::new_with_spec(OpSpecId::default()), block); + + assert_eq!( + resolve_execution_spec( + config.evm_version, + config.hardfork, + &mut env, + ExecutionSpecContext::fork(NamedChain::Base as u64, None), + None, + ), + None + ); + assert_eq!(env.cfg_env.spec, OpSpecId::KARST); + } + + #[test] + #[cfg(feature = "monad")] + fn resolve_execution_spec_honors_explicit_precedence() { + let activation = + foundry_evm_hardforks::MonadHardfork::MonadNine.mainnet_activation_timestamp().unwrap(); + let mut configured = Config { + hardfork: Some(FoundryHardfork::Monad(foundry_evm_hardforks::MonadHardfork::MonadNine)), + ..Default::default() + }; + let mut env = monad_env(activation - 1); + + assert_eq!( + resolve_execution_spec( + configured.evm_version, + configured.hardfork, + &mut env, + ExecutionSpecContext::fork(NamedChain::Monad as u64, None), + None, + ), + configured.hardfork + ); + assert_eq!(env.cfg_env.spec, foundry_evm_hardforks::MonadHardfork::MonadNine); + + configured.hardfork = None; + assert_eq!( + resolve_execution_spec( + configured.evm_version, + configured.hardfork, + &mut env, + ExecutionSpecContext::fork(NamedChain::Monad as u64, None), + Some(foundry_evm_hardforks::MonadHardfork::MonadEight), + ), + Some(FoundryHardfork::Monad(foundry_evm_hardforks::MonadHardfork::MonadEight)) + ); + assert_eq!(env.cfg_env.spec, foundry_evm_hardforks::MonadHardfork::MonadEight); + } + + #[tokio::test(flavor = "multi_thread")] + #[cfg(feature = "monad")] + async fn fork_context_preserves_source_chain_with_execution_override() { + let activation = + foundry_evm_hardforks::MonadHardfork::MonadNine.mainnet_activation_timestamp().unwrap(); + let (_api, handle) = anvil::spawn( + anvil::NodeConfig::test() + .with_networks(NetworkConfigs::with_ethereum()) + .with_chain_id(Some(NamedChain::Monad as u64)) + .with_genesis_timestamp(Some(activation - 1)), + ) + .await; + let mut inferred = EvmOpts { fork_url: Some(handle.http_endpoint()), ..Default::default() }; + inferred.infer_network_from_fork().await.unwrap(); + assert_eq!(inferred.networks, NetworkConfigs::default()); + + let mut evm_opts = EvmOpts { fork_url: Some(handle.http_endpoint()), ..Default::default() }; + evm_opts.env.chain_id = Some(NamedChain::Mainnet as u64); + evm_opts.networks = NetworkConfigs::with_monad(); + + let (mut evm_env, tx_env, fork) = evm_opts + .env_with_fork_context::() + .await + .unwrap(); + let fork_context = fork.unwrap(); + + assert_eq!(fork_context.source_chain_id, NamedChain::Monad as u64); + assert_eq!(fork_context.network, NetworkVariant::Ethereum); + assert!(matches!(fork_context.hardfork, Some(FoundryHardfork::Ethereum(_)))); + assert_eq!(evm_env.cfg_env.chain_id, NamedChain::Mainnet as u64); + assert_eq!(tx_env.chain_id, Some(NamedChain::Mainnet as u64)); + assert_eq!( + resolve_execution_spec( + Config::default().evm_version, + Config::default().hardfork, + &mut evm_env, + ExecutionSpecContext::fork(fork_context.source_chain_id, fork_context.hardfork,), + None, + ), + Some(FoundryHardfork::Monad(foundry_evm_hardforks::MonadHardfork::MonadEight)) + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn infer_network_default_and_custom_anvil_selects_ethereum() { + for chain_id in [NamedChain::Mainnet as u64, NamedChain::AnvilHardhat as u64, 98_765_432] { + let (_api, handle) = + anvil::spawn(anvil::NodeConfig::test().with_chain_id(Some(chain_id))).await; + + let config = Config::figment(); + let mut evm_opts = config.extract::().unwrap(); + evm_opts.fork_url = Some(handle.http_endpoint()); + assert_eq!(evm_opts.networks, NetworkConfigs::default()); + + evm_opts.infer_network_from_fork().await.unwrap(); + + assert_eq!(evm_opts.env.chain_id, Some(chain_id)); + assert!(!evm_opts.networks.is_tempo()); + #[cfg(feature = "optimism")] + assert!(!evm_opts.networks.is_optimism()); + #[cfg(feature = "base")] + assert!(!evm_opts.networks.is_base()); + assert!(!evm_opts.networks.is_celo()); + assert_eq!(evm_opts.networks, NetworkConfigs::default()); + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn infer_network_preserves_explicit_ethereum() { + let networks = NetworkConfigs::with_ethereum(); + let mut evm_opts = EvmOpts { + fork_url: Some("http://127.0.0.1:1".to_string()), + networks, + ..Default::default() + }; + + let error = evm_opts.infer_network_from_fork().await.unwrap_err(); + + assert!(error.to_string().contains("failed to retrieve chain ID")); + assert_eq!(evm_opts.networks, networks); + assert_eq!(evm_opts.env.chain_id, None); + } + + #[tokio::test] + async fn clearing_fork_restores_inferred_network_defaults() { + let mut profiles = vec![NetworkConfigs::with_celo(), NetworkConfigs::with_tempo()]; + #[cfg(feature = "optimism")] + profiles.push(NetworkConfigs::with_optimism()); + #[cfg(feature = "monad")] + profiles.push(NetworkConfigs::with_monad()); + #[cfg(feature = "base")] + profiles.push(NetworkConfigs::with_base()); + + for networks in profiles { + let mut evm_opts = EvmOpts { + networks, + fork_endpoint: Some(ForkEndpointIdentity { + endpoint: "http://localhost:8545".to_string(), + execution_chain_id: 1, + source_chain_id: 1, + network: networks.execution_network(), + network_profile: networks, + reported_hardfork: None, + hardfork: None, + instance_id: None, + source_fork_block_number: None, + source_fork_block_hash: None, + }), + fork_block_number: Some(123), + fork_network_is_inferred: true, + fork_chain_id_is_inferred: true, + fork_block_number_is_inferred: true, + ..Default::default() + }; + evm_opts.env.chain_id = Some(1); + + evm_opts.infer_network_from_fork().await.unwrap(); + + assert_eq!(evm_opts.networks, NetworkConfigs::default()); + assert_eq!(evm_opts.fork_endpoint, None); + assert!(!evm_opts.fork_network_is_inferred); + assert_eq!(evm_opts.env.chain_id, None); + assert!(!evm_opts.fork_chain_id_is_inferred); + assert_eq!(evm_opts.fork_block_number, None); + assert!(!evm_opts.fork_block_number_is_inferred); + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn fork_network_detects_ethereum_anvil() { + let (_api, handle) = anvil::spawn(anvil::NodeConfig::test()).await; + let evm_opts = EvmOpts { fork_url: Some(handle.http_endpoint()), ..Default::default() }; + + let identity = evm_opts.discover_fork_endpoint().await.unwrap(); + assert_eq!( + (identity.execution_chain_id, identity.network), + (NamedChain::AnvilHardhat as u64, NetworkVariant::Ethereum) + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn fork_network_uses_anvil_metadata_for_unknown_chain() { + let chain_id = 98_765_432; + let (_api, handle) = + anvil::spawn(anvil::NodeConfig::test().with_chain_id(Some(chain_id))).await; + let evm_opts = EvmOpts { fork_url: Some(handle.http_endpoint()), ..Default::default() }; + + let identity = evm_opts.discover_fork_endpoint().await.unwrap(); + assert_eq!( + (identity.execution_chain_id, identity.network), + (chain_id, NetworkVariant::Ethereum) + ); + } + + #[tokio::test] + #[cfg(feature = "base")] + async fn base_endpoint_identity_uses_generic_chain_profile() { + let endpoint = "http://127.0.0.1:1"; + let provider = EvmOpts::default().fork_provider_with_url::(endpoint).unwrap(); + + let identity = EvmOpts::resolve_fork_endpoint_identity( + &provider, + endpoint, + NamedChain::Base as u64, + None, + None, + EndpointHardforkPolicy::Optional, + ) + .await + .unwrap(); + + assert_eq!(identity.execution_chain_id, NamedChain::Base as u64); + assert_eq!(identity.source_chain_id, NamedChain::Base as u64); + assert_eq!(identity.network, NetworkVariant::Base); + assert!(identity.network_profile.is_base()); + assert_eq!(identity.reported_hardfork, None); + assert_eq!(identity.hardfork, None); + } + + #[tokio::test(flavor = "multi_thread")] + async fn fork_non_anvil_node_info_rpc_error_is_optional() { + let (_api, handle) = + anvil::spawn(anvil::NodeConfig::test().with_chain_id(Some(NamedChain::Mainnet as u64))) + .await; + let fork_url = + spawn_rpc_proxy_internal_error_after(handle.http_endpoint(), "anvil_nodeInfo", 0).await; + let mut evm_opts = EvmOpts { fork_url: Some(fork_url), ..Default::default() }; + + evm_opts.infer_network_from_fork().await.unwrap(); + + assert_eq!(evm_opts.networks, NetworkConfigs::default()); + assert_eq!(evm_opts.env.chain_id, Some(NamedChain::Mainnet as u64)); + } + + #[tokio::test(flavor = "multi_thread")] + async fn fork_node_info_failure_is_strict_after_anvil_is_identified() { + let (_api, handle) = anvil::spawn(anvil::NodeConfig::test()).await; + let fork_url = + spawn_rpc_proxy_rejecting_method_after(handle.http_endpoint(), "anvil_nodeInfo", 1) + .await; + let evm_opts = EvmOpts { fork_url: Some(fork_url), ..Default::default() }; + + let error = evm_opts.discover_fork_endpoint().await.unwrap_err(); + + assert!( + error.to_string().contains("failed to determine network family from endpoint"), + "{error}" + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn fork_node_info_probe_becomes_strict_after_initial_failure() { + let (_api, handle) = anvil::spawn(anvil::NodeConfig::test()).await; + let fork_url = + spawn_rpc_proxy_method_not_found_before(handle.http_endpoint(), "anvil_nodeInfo", 1) + .await; + let evm_opts = EvmOpts { fork_url: Some(fork_url), ..Default::default() }; + + let fork = evm_opts.prepare_fork().await.unwrap().unwrap(); + + assert!(fork.context().hardfork.is_some()); + assert!(fork.context().instance_id.is_some()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn fork_cached_anvil_identity_keeps_node_info_probe_strict() { + let (_api, handle) = anvil::spawn(anvil::NodeConfig::test()).await; + let fork_url = + spawn_rpc_proxy_rejecting_method_after(handle.http_endpoint(), "anvil_nodeInfo", 2) + .await; + let mut evm_opts = EvmOpts { fork_url: Some(fork_url), ..Default::default() }; + evm_opts.infer_network_from_fork().await.unwrap(); + assert!(evm_opts.fork_endpoint.as_ref().unwrap().reported_hardfork.is_some()); + + let error = evm_opts.discover_fork_endpoint().await.unwrap_err(); + + assert!( + error.to_string().contains("failed to determine network family from endpoint"), + "{error}" + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn fork_non_anvil_discovery_is_reused_by_resolution() { + let (_api, handle) = + anvil::spawn(anvil::NodeConfig::test().with_chain_id(Some(NamedChain::Mainnet as u64))) + .await; + let endpoint = spawn_rpc_proxy_method_not_found_before( + handle.http_endpoint(), + "anvil_nodeInfo", + usize::MAX, + ) + .await; + let (endpoint, chain_ids) = spawn_rpc_proxy_recording_method(endpoint, "eth_chainId").await; + let (endpoint, node_infos) = + spawn_rpc_proxy_recording_method(endpoint, "anvil_nodeInfo").await; + let (endpoint, blocks) = + spawn_rpc_proxy_recording_method(endpoint, "eth_getBlockByNumber").await; + let (endpoint, hashes) = + spawn_rpc_proxy_recording_method(endpoint, "eth_getBlockByHash").await; + let probes = || (chain_ids.lock().unwrap().len(), node_infos.lock().unwrap().len()); + let mut evm_opts = EvmOpts { fork_url: Some(endpoint), ..Default::default() }; + + evm_opts.infer_network_from_fork().await.unwrap(); + assert_eq!(probes(), (1, 1)); + assert_eq!(evm_opts.fork_endpoint.as_ref().unwrap().reported_hardfork, None); + + evm_opts.infer_network_from_fork().await.unwrap(); + let backend = crate::backend::Backend::::spawn( + evm_opts.get_fork(&Config::default(), NamedChain::Mainnet as u64, None), + ) + .unwrap(); + backend.env(&evm_opts).await.unwrap(); + backend.env(&evm_opts).await.unwrap(); + backend.can_use_create2_deployer(evm_opts.create2_deployer).await.unwrap(); + backend.transaction_count(evm_opts.sender).await.unwrap(); + assert_eq!(probes(), (1, 1)); + assert_eq!(blocks.lock().unwrap().len(), 1); + assert!(hashes.lock().unwrap().is_empty()); + assert_eq!(evm_opts.fork_block_number, None); + + // Explicit discovery always inspects the endpoint. + evm_opts.discover_fork_endpoint().await.unwrap(); + assert_eq!(probes(), (2, 2)); + } + + #[tokio::test] + async fn fork_remote_chain_id_does_not_guess_from_url() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + drop(listener); + let evm_opts = EvmOpts { + fork_url: Some(format!("http://{address}/arb-mainnet")), + fork_retries: Some(0), + ..Default::default() + }; + assert_eq!(evm_opts.get_remote_chain_id().await, None); + } + + #[test] + fn known_network_variant_does_not_guess_unknown_chain() { + assert_eq!(NetworkVariant::from_known_chain_id(98_765_432).unwrap(), None); + } + + #[test] + #[cfg(feature = "monad")] + fn known_network_variant_classifies_monad() { + assert_eq!( + NetworkVariant::from_known_chain_id(NamedChain::Monad as u64).unwrap(), + Some(NetworkVariant::Monad) + ); + } + + #[test] + #[cfg(not(feature = "monad"))] + fn known_network_variant_rejects_disabled_monad() { + assert_eq!( + NetworkVariant::from_known_chain_id(NamedChain::Monad as u64).unwrap_err(), + "network family `monad` is not enabled in this build" + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn infer_network_tempo_anvil_via_node_info() { + let (_api, handle) = anvil::spawn(anvil::NodeConfig::test_tempo()).await; + + let config = Config::figment(); + let mut evm_opts = config.extract::().unwrap(); + evm_opts.fork_url = Some(handle.http_endpoint()); + // Networks not set -> should query anvil_nodeInfo to discover tempo. + assert_eq!(evm_opts.networks, NetworkConfigs::default()); + + evm_opts.infer_network_from_fork().await.unwrap(); + + assert!(evm_opts.networks.is_tempo(), "should detect tempo via anvil_nodeInfo"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn infer_network_tempo_propagates_unavailable_rpc() { + let config = Config::figment(); + let mut evm_opts = config.extract::().unwrap(); + evm_opts.fork_url = Some("http://127.0.0.1:1".to_string()); + evm_opts.networks = NetworkConfigs::with_tempo(); + + let error = evm_opts.infer_network_from_fork().await.unwrap_err(); + + assert!(error.to_string().contains("failed to retrieve chain ID")); + assert!(evm_opts.networks.is_tempo()); + } + + #[tokio::test] + async fn create2_deployer_availability_requires_resolved_fork_block() { + let evm_opts = + EvmOpts { fork_url: Some("http://127.0.0.1:1".to_string()), ..Default::default() }; + + let err = evm_opts.can_use_create2_deployer_at(None).await.unwrap_err(); + assert!(err.to_string().contains("fork must be resolved")); + } + + #[test] + fn resolved_fork_matches_source_headers_and_selector() { + let headers = vec!["Authorization: one".to_string()]; + let fork = Fork::test( + "http://127.0.0.1:1", + Some(&headers), + None, + None, + BlockNumHash::new(1, B256::ZERO), + resolved_context(1), + ); + let mut evm_opts = EvmOpts { + fork_url: Some("http://127.0.0.1:1".to_string()), + fork_headers: Some(headers), + rpc_headers: Some(vec!["x-fallback: ignored".to_string()]), + ..Default::default() + }; + + assert!(fork.matches_request(&evm_opts)); + let debug = format!("{fork:?}"); + assert!(!debug.contains("127.0.0.1")); + assert!(!debug.contains("Authorization")); + assert!(!debug.contains("one")); + + evm_opts.fork_headers = Some(vec!["Authorization: two".to_string()]); + assert!(!fork.matches_request(&evm_opts)); + + evm_opts.fork_headers = Some(vec!["Authorization: one".to_string()]); + evm_opts.fork_block_number = Some(1); + assert!(!fork.matches_request(&evm_opts)); + + evm_opts.fork_block_number = None; + evm_opts.fork_url = Some("http://127.0.0.1:2".to_string()); + assert!(!fork.matches_request(&evm_opts)); + + let fork = Fork::test( + "http://127.0.0.1:1", + None, + None, + None, + BlockNumHash::new(1, B256::ZERO), + resolved_context(1), + ); + evm_opts.fork_url = Some("http://127.0.0.1:1".to_string()); + evm_opts.fork_headers = Some(Vec::new()); + evm_opts.rpc_headers = None; + assert!(fork.matches_request(&evm_opts)); + + let rpc_headers = vec!["Authorization: fallback".to_string()]; + let fork = Fork::test( + "http://127.0.0.1:1", + Some(&rpc_headers), + Some("secret-jwt"), + None, + BlockNumHash::new(1, B256::ZERO), + resolved_context(1), + ); + evm_opts.fork_headers = None; + evm_opts.rpc_headers = Some(rpc_headers); + evm_opts.rpc_jwt = Some("secret-jwt".to_string()); + assert!(fork.matches_request(&evm_opts)); + assert!(!format!("{fork:?}").contains("secret-jwt")); + + evm_opts.rpc_jwt = Some("different-jwt".to_string()); + assert!(!fork.matches_request(&evm_opts)); + } + + #[tokio::test(flavor = "multi_thread")] + async fn create2_deployer_preflight_uses_exact_fork_hash() { + let (_api, handle) = anvil::spawn(anvil::NodeConfig::test()).await; + let provider = handle.http_provider(); + let sender = handle.dev_accounts().next().unwrap(); + + let init_code = bytes!("6001600c60003960016000f300"); + let receipt = provider + .send_transaction(WithOtherFields::new( + TransactionRequest::default().from(sender).with_deploy_code(init_code), + )) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + let deployer = receipt.contract_address.unwrap(); + let block_number = receipt.block_number.unwrap(); + + let evm_opts = EvmOpts { + fork_url: Some(handle.http_endpoint()), + fork_block_number: Some(block_number), + create2_deployer: deployer, + ..Default::default() + }; + let fork = evm_opts.prepare_fork().await.unwrap(); + let fork = fork.unwrap(); + assert!(evm_opts.can_use_create2_deployer_at(Some(&fork)).await.unwrap()); + + provider + .raw_request::<_, ()>("anvil_reorg".into(), (1_u64, Vec::::new())) + .await + .unwrap(); + let replacement = provider.get_block_by_number(block_number.into()).await.unwrap().unwrap(); + assert_ne!(replacement.header().hash(), fork.hash()); + assert!( + provider.get_code_at(deployer).number(block_number).await.unwrap().is_empty(), + "the replacement block must not contain the deployer" + ); + + assert!( + !matches!(evm_opts.can_use_create2_deployer_at(Some(&fork)).await, Ok(false)), + "the exact lookup fell back to the replacement block" + ); + } + + #[tokio::test(flavor = "multi_thread")] + #[cfg(feature = "monad")] + async fn infer_network_monad_anvil_via_node_info() { + for chain_id in [NamedChain::Mainnet as u64, NamedChain::AnvilHardhat as u64, 98_765_432] { + let (_api, handle) = + anvil::spawn(anvil::NodeConfig::test_monad().with_chain_id(Some(chain_id))).await; + + let config = Config::figment(); + let mut evm_opts = config.extract::().unwrap(); + evm_opts.fork_url = Some(handle.http_endpoint()); + assert_eq!(evm_opts.networks, NetworkConfigs::default()); + + evm_opts.infer_network_from_fork().await.unwrap(); + + assert_eq!(evm_opts.env.chain_id, Some(chain_id)); + assert!( + evm_opts.networks.is_monad(), + "should detect Monad via anvil_nodeInfo for chain {chain_id}" + ); + } + } + + #[tokio::test(flavor = "multi_thread")] + #[cfg(feature = "monad")] + async fn fork_context_carries_exact_monad_anvil_hardfork() { + let activation = + foundry_evm_hardforks::MonadHardfork::MonadNine.mainnet_activation_timestamp().unwrap(); + let (_api, handle) = anvil::spawn( + anvil::NodeConfig::test_monad() + .with_chain_id(Some(NamedChain::Monad as u64)) + .with_genesis_timestamp(Some(activation)) + .with_hardfork(Some(foundry_evm_hardforks::MonadHardfork::MonadEight.into())), + ) + .await; + let mut evm_opts = EvmOpts { fork_url: Some(handle.http_endpoint()), ..Default::default() }; + + evm_opts.infer_network_from_fork().await.unwrap(); + let (mut evm_env, _, fork) = evm_opts + .env_with_fork_context::() + .await + .unwrap(); + let context = fork.unwrap(); + + assert_eq!( + context.hardfork, + Some(FoundryHardfork::Monad(foundry_evm_hardforks::MonadHardfork::MonadEight)) + ); + assert_eq!( + resolve_execution_spec( + Config::default().evm_version, + Config::default().hardfork, + &mut evm_env, + ExecutionSpecContext::fork(context.source_chain_id, context.hardfork), + None, + ), + context.hardfork + ); + assert_eq!(evm_env.cfg_env.spec, foundry_evm_hardforks::MonadHardfork::MonadEight); + } + + #[tokio::test(flavor = "multi_thread")] + #[cfg(feature = "monad")] + async fn fork_context_refreshes_identity_after_same_url_reset() { + let (_api, monad_eight) = anvil::spawn( + anvil::NodeConfig::test_monad() + .with_chain_id(Some(NamedChain::MonadTestnet as u64)) + .with_hardfork(Some(foundry_evm_hardforks::MonadHardfork::MonadEight.into())), + ) + .await; + let (_api, monad_nine) = anvil::spawn( + anvil::NodeConfig::test_monad() + .with_chain_id(Some(NamedChain::Monad as u64)) + .with_hardfork(Some(foundry_evm_hardforks::MonadHardfork::MonadNine.into())), + ) + .await; + let (fork_api, fork_handle) = anvil::spawn( + anvil::NodeConfig::test() + .with_chain_id(Some(NamedChain::Mainnet as u64)) + .with_no_storage_caching(true) + .with_eth_rpc_url(Some(monad_eight.http_endpoint())) + .with_fork_block_number(Some(0u64)), + ) + .await; + + let mut evm_opts = + EvmOpts { fork_url: Some(fork_handle.http_endpoint()), ..Default::default() }; + evm_opts.infer_network_from_fork().await.unwrap(); + let cached = evm_opts.fork_endpoint.as_ref().unwrap(); + assert_eq!(cached.source_chain_id, NamedChain::MonadTestnet as u64); + assert_eq!( + cached.hardfork, + Some(FoundryHardfork::Monad(foundry_evm_hardforks::MonadHardfork::MonadEight)) + ); + + let (_, _, first) = evm_opts + .env_with_fork_context::() + .await + .unwrap(); + assert_eq!(first.unwrap().source_chain_id, NamedChain::MonadTestnet as u64); + + fork_api + .anvil_reset(Some(alloy_rpc_types::anvil::Forking { + json_rpc_url: Some(monad_nine.http_endpoint()), + block_number: Some(0), + })) + .await + .unwrap(); + + let (_, _, second) = evm_opts + .env_with_fork_context::() + .await + .unwrap(); + let second = second.unwrap(); + assert_eq!(second.source_chain_id, NamedChain::Monad as u64); + assert_eq!( + second.hardfork, + Some(FoundryHardfork::Monad(foundry_evm_hardforks::MonadHardfork::MonadNine)) + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn expected_fork_endpoint_detects_same_url_reset() { + let (api, handle) = anvil::spawn(anvil::NodeConfig::test()).await; + let mut evm_opts = EvmOpts { fork_url: Some(handle.http_endpoint()), ..Default::default() }; + evm_opts.infer_network_from_fork().await.unwrap(); + let identity = evm_opts.fork_endpoint.clone().unwrap(); + let original_instance = identity.instance_id; + let network_is_inferred = evm_opts.fork_network_is_inferred; + evm_opts.expect_fork_endpoint(identity, network_is_inferred); + + api.anvil_reset(None).await.unwrap(); + + assert_ne!(api.instance_id(), original_instance.unwrap()); + let error = evm_opts.env_with_fork_context::().await.unwrap_err(); + assert!( + error.to_string().contains("changed after its execution context was selected"), + "{error}" + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn prepared_fork_preserves_inferred_execution_profile_guard() { + let (_api, handle) = anvil::spawn(anvil::NodeConfig::test()).await; + let mut opts = EvmOpts { fork_url: Some(handle.http_endpoint()), ..Default::default() }; + let fork = opts.prepare_fork().await.unwrap().unwrap(); + opts.networks = NetworkConfigs::with_tempo(); + opts.fork_network_is_inferred = true; + let error = opts.env_at_fork::(Some(&fork)).await.unwrap_err(); + assert!(error.to_string().contains("changed execution profile"), "{error}"); + opts.fork_network_is_inferred = false; + opts.env_at_fork::(Some(&fork)).await.unwrap(); + } + + #[tokio::test(flavor = "multi_thread")] + async fn prepared_fork_reconnects_after_runtime_shutdown() { + let (_api, handle) = anvil::spawn(anvil::NodeConfig::test()).await; + for endpoint in [handle.http_endpoint(), handle.ws_endpoint()] { + tokio::task::spawn_blocking(move || { + let opts = EvmOpts { fork_url: Some(endpoint), ..Default::default() }; + let runtime = + || tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap(); + let fork = runtime().block_on(opts.prepare_fork()).unwrap().unwrap(); + let (env, _) = runtime() + .block_on(opts.env_at_fork::(Some(&fork))) + .unwrap(); + assert_eq!(env.block_env.number, U256::from(fork.number())); + }) + .await + .unwrap(); + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn resolved_fork_environment_retains_exact_block() { + let (api, handle) = anvil::spawn(anvil::NodeConfig::test()).await; + let evm_opts = EvmOpts { fork_url: Some(handle.http_endpoint()), ..Default::default() }; + let fork = evm_opts.prepare_fork().await.unwrap().unwrap(); + let provider = handle.http_provider(); + + api.anvil_mine(Some(U256::ONE), None).await.unwrap(); + assert!(provider.get_block_number().await.unwrap() > fork.number()); + + let (evm_env, _) = + evm_opts.env_at_fork::(Some(&fork)).await.unwrap(); + let block = &fork.block; + + assert_eq!(block.header().hash(), fork.hash()); + assert_eq!(block.header().number(), fork.number()); + assert_eq!(evm_env.block_env.number, U256::from(fork.number())); + assert_eq!(evm_env.block_env.timestamp, U256::from(block.header().timestamp())); + } + + #[tokio::test(flavor = "multi_thread")] + async fn resolved_fork_detects_same_url_reset_without_cached_expectation() { + let (api, handle) = anvil::spawn(anvil::NodeConfig::test()).await; + let evm_opts = EvmOpts { fork_url: Some(handle.http_endpoint()), ..Default::default() }; + let fork = evm_opts.prepare_fork().await.unwrap().unwrap(); + let original_instance = fork.context().instance_id; + + api.anvil_reset(None).await.unwrap(); + + assert_ne!(api.instance_id(), original_instance.unwrap()); + let error = evm_opts.env_at_fork::(Some(&fork)).await.unwrap_err(); + assert!( + error + .to_string() + .contains("changed after its block and execution context were resolved"), + "{error}" + ); + } + + #[test] + #[cfg(feature = "monad")] + fn unknown_endpoint_hardfork_is_optional_only_for_remote_execution() { + assert_eq!(NetworkVariant::from_node_info_name("monad").unwrap(), NetworkVariant::Monad); + assert_eq!( + endpoint_hardfork( + NetworkVariant::Monad, + "MonadFuture", + EndpointHardforkPolicy::Optional + ) + .unwrap(), + None + ); + + let error = endpoint_hardfork( + NetworkVariant::Monad, + "MonadFuture", + EndpointHardforkPolicy::Required, + ) + .unwrap_err(); + assert!( + error.to_string().contains("unsupported hardfork `MonadFuture` reported for `monad`") + ); + assert_eq!( + endpoint_hardfork(NetworkVariant::Monad, "MonadNine", EndpointHardforkPolicy::Required) + .unwrap(), + Some(FoundryHardfork::Monad(foundry_evm_hardforks::MonadHardfork::MonadNine)) + ); + } + + #[test] + #[cfg(feature = "base")] + fn unknown_base_endpoint_hardfork_is_optional_only_for_remote_execution() { + assert_eq!(NetworkVariant::from_node_info_name("base").unwrap(), NetworkVariant::Base); + assert_eq!( + endpoint_hardfork(NetworkVariant::Base, "BaseFuture", EndpointHardforkPolicy::Optional) + .unwrap(), + None + ); + + let error = + endpoint_hardfork(NetworkVariant::Base, "BaseFuture", EndpointHardforkPolicy::Required) + .unwrap_err(); + assert!( + error.to_string().contains("unsupported hardfork `BaseFuture` reported for `base`") + ); + assert_eq!( + endpoint_hardfork(NetworkVariant::Base, "Beryl", EndpointHardforkPolicy::Required) + .unwrap(), + Some(FoundryHardfork::Base(BaseUpgrade::Beryl)) + ); + } + + #[tokio::test(flavor = "multi_thread")] + #[cfg(feature = "monad")] + async fn fork_network_detects_monad_anvil() { + let (_api, handle) = anvil::spawn(anvil::NodeConfig::test_monad()).await; + let evm_opts = EvmOpts { fork_url: Some(handle.http_endpoint()), ..Default::default() }; + + let identity = evm_opts.discover_fork_endpoint().await.unwrap(); + assert_eq!( + (identity.execution_chain_id, identity.network), + (NamedChain::AnvilHardhat as u64, NetworkVariant::Monad) + ); + } + + #[tokio::test(flavor = "multi_thread")] + #[cfg(feature = "monad")] + async fn infer_network_monad_propagates_unavailable_rpc() { + let config = Config::figment(); + let mut evm_opts = config.extract::().unwrap(); + evm_opts.fork_url = Some("http://127.0.0.1:1".to_string()); + evm_opts.networks = NetworkConfigs::with_monad(); + + let error = evm_opts.infer_network_from_fork().await.unwrap_err(); + + assert!(error.to_string().contains("failed to retrieve chain ID")); + assert!(evm_opts.networks.is_monad()); + } + + #[tokio::test(flavor = "multi_thread")] + #[cfg(feature = "base")] + async fn infer_network_base_propagates_unavailable_rpc() { + let mut evm_opts = EvmOpts { + fork_url: Some("http://127.0.0.1:1".to_string()), + networks: NetworkConfigs::with_base(), + ..Default::default() + }; + + let error = evm_opts.infer_network_from_fork().await.unwrap_err(); + + assert!(error.to_string().contains("failed to retrieve chain ID")); + assert!(evm_opts.networks.is_base()); + assert_eq!(evm_opts.fork_endpoint, None); + } + + #[tokio::test(flavor = "multi_thread")] + async fn flaky_infer_network_tempo_moderato_rpc() { + let config = Config::figment(); + let mut evm_opts = config.extract::().unwrap(); + evm_opts.fork_url = Some("https://rpc.moderato.tempo.xyz".to_string()); + assert_eq!(evm_opts.networks, NetworkConfigs::default()); + + evm_opts.infer_network_from_fork().await.unwrap(); + + // Tempo Moderato has a known Tempo chain ID -> should be inferred via with_chain_id. + assert!(evm_opts.networks.is_tempo(), "should detect tempo from Moderato chain ID"); + } + + #[tokio::test(flavor = "multi_thread")] + async fn get_fork_pins_block_number_from_env() { + let endpoint = foundry_test_utils::rpc::next_http_rpc_endpoint(); + + let config = Config::figment(); + let mut evm_opts = config.extract::().unwrap(); + evm_opts.fork_url = Some(endpoint.clone()); + // Explicitly leave fork_block_number as None to simulate --fork-url without --block-number + assert!(evm_opts.fork_block_number.is_none()); + + // Fetch the environment (this resolves "latest" to an actual block number) + let (evm_env, _, fork) = + evm_opts.env_with_fork_context::().await.unwrap(); + let fork_block = fork.as_ref().map(|context| context.block_number); + assert!(fork_block.is_some(), "should have resolved a fork block number"); + let resolved_block = fork_block.unwrap(); + assert!(resolved_block > 0, "should have resolved to a real block number"); + + // Create the fork - this should pin the block number + let fork = + evm_opts.get_fork(&Config::default(), evm_env.cfg_env.chain_id, fork_block).unwrap(); + + // The fork's evm_opts should now have fork_block_number set to the resolved block + assert_eq!( + fork.evm_opts.fork_block_number, + Some(resolved_block), + "get_fork should pin fork_block_number to the block from env" + ); + assert!(fork.evm_opts.fork_block_number_is_inferred); + } + + #[tokio::test(flavor = "multi_thread")] + async fn fork_backend_rejects_changed_selected_endpoint() { + let (api, handle) = anvil::spawn(anvil::NodeConfig::test()).await; + let mut opts = EvmOpts { fork_url: Some(handle.http_endpoint()), ..Default::default() }; + opts.infer_network_from_fork().await.unwrap(); + opts.expect_fork_endpoint( + opts.fork_endpoint.clone().unwrap(), + opts.fork_network_is_inferred, + ); + let request = opts.get_fork(&Config::default(), 31337, None); + api.anvil_reset(None).await.unwrap(); + let error = + crate::backend::Backend::::spawn(request).unwrap_err(); + assert!( + error.to_string().contains("changed after its execution context was selected"), + "{error}" + ); + } + + // Regression test for https://github.com/foundry-rs/foundry/issues/13576 + // On Arbitrum, `block_env.number` is remapped to the L1 block number by + // `apply_chain_and_block_specific_env_changes`. The fork block number returned + // by `env_with_fork_context()` must be the actual L2 block number, not the remapped L1 value. + #[tokio::test(flavor = "multi_thread")] + async fn flaky_get_fork_uses_l2_block_number_on_arbitrum() { + let endpoint = + foundry_test_utils::rpc::next_rpc_endpoint(foundry_config::NamedChain::Arbitrum); + + let config = Config::figment(); + let mut evm_opts = config.extract::().unwrap(); + evm_opts.fork_url = Some(endpoint.clone()); + assert!(evm_opts.fork_block_number.is_none()); + + let (evm_env, _, fork) = + evm_opts.env_with_fork_context::().await.unwrap(); + let fork_block = fork + .as_ref() + .map(|context| context.block_number) + .expect("should have resolved a fork block number"); + + // On Arbitrum, block_env.number is the L1 block number (much smaller). + // The fork_block should be the actual L2 block number (much larger). + let block_env_number: u64 = evm_env.block_env.number.to(); + assert!( + fork_block > block_env_number, + "fork_block ({fork_block}) should be the L2 block, which is larger than \ + block_env.number ({block_env_number}) which is the L1 block on Arbitrum" + ); + + // Verify get_fork pins to the correct L2 block number + let fork = evm_opts + .get_fork(&Config::default(), evm_env.cfg_env.chain_id, Some(fork_block)) + .unwrap(); + assert_eq!( + fork.evm_opts.fork_block_number, + Some(fork_block), + "get_fork should pin to the L2 block number, not the L1 block number" + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn get_fork_preserves_explicit_block_number() { + let endpoint = foundry_test_utils::rpc::next_http_rpc_endpoint(); + + let config = Config::figment(); + let mut evm_opts = config.extract::().unwrap(); + evm_opts.fork_url = Some(endpoint.clone()); + // Set an explicit block number + evm_opts.fork_block_number = Some(12345678); + + let (evm_env, _, fork) = + evm_opts.env_with_fork_context::().await.unwrap(); + let fork_block = fork.as_ref().map(|context| context.block_number); + + let fork = + evm_opts.get_fork(&Config::default(), evm_env.cfg_env.chain_id, fork_block).unwrap(); + + // Should preserve the explicit block number, not override it + assert_eq!( + fork.evm_opts.fork_block_number, + Some(12345678), + "get_fork should preserve explicitly set fork_block_number" + ); + assert!(!fork.evm_opts.fork_block_number_is_inferred); + } +} diff --git a/crates/evm/core/src/precompiles.rs b/crates/evm/core/src/precompiles.rs index 62f7397a12bf9..51cc23ac4a99f 100644 --- a/crates/evm/core/src/precompiles.rs +++ b/crates/evm/core/src/precompiles.rs @@ -1,34 +1,63 @@ use alloy_primitives::{Address, address}; /// The ECRecover precompile address. -pub const EC_RECOVER: Address = address!("0x0000000000000000000000000000000000000001"); +pub const EC_RECOVER: Address = Address::with_last_byte(1); /// The SHA-256 precompile address. -pub const SHA_256: Address = address!("0x0000000000000000000000000000000000000002"); +pub const SHA_256: Address = Address::with_last_byte(2); /// The RIPEMD-160 precompile address. -pub const RIPEMD_160: Address = address!("0x0000000000000000000000000000000000000003"); +pub const RIPEMD_160: Address = Address::with_last_byte(3); /// The Identity precompile address. -pub const IDENTITY: Address = address!("0x0000000000000000000000000000000000000004"); +pub const IDENTITY: Address = Address::with_last_byte(4); /// The ModExp precompile address. -pub const MOD_EXP: Address = address!("0x0000000000000000000000000000000000000005"); +pub const MOD_EXP: Address = Address::with_last_byte(5); /// The ECAdd precompile address. -pub const EC_ADD: Address = address!("0x0000000000000000000000000000000000000006"); +pub const EC_ADD: Address = Address::with_last_byte(6); /// The ECMul precompile address. -pub const EC_MUL: Address = address!("0x0000000000000000000000000000000000000007"); +pub const EC_MUL: Address = Address::with_last_byte(7); /// The ECPairing precompile address. -pub const EC_PAIRING: Address = address!("0x0000000000000000000000000000000000000008"); +pub const EC_PAIRING: Address = Address::with_last_byte(8); /// The Blake2F precompile address. -pub const BLAKE_2F: Address = address!("0x0000000000000000000000000000000000000009"); +pub const BLAKE_2F: Address = Address::with_last_byte(9); /// The PointEvaluation precompile address. -pub const POINT_EVALUATION: Address = address!("0x000000000000000000000000000000000000000a"); +pub const POINT_EVALUATION: Address = Address::with_last_byte(0x0a); + +/// The BLS12-381 G1ADD precompile address. +pub const BLS12_G1ADD: Address = Address::with_last_byte(0x0b); + +/// The BLS12-381 G1MSM precompile address. +pub const BLS12_G1MSM: Address = Address::with_last_byte(0x0c); + +/// The BLS12-381 G2ADD precompile address. +pub const BLS12_G2ADD: Address = Address::with_last_byte(0x0d); + +/// The BLS12-381 G2MSM precompile address. +pub const BLS12_G2MSM: Address = Address::with_last_byte(0x0e); + +/// The BLS12-381 pairing check precompile address. +pub const BLS12_PAIRING_CHECK: Address = Address::with_last_byte(0x0f); + +/// The BLS12-381 map Fp to G1 precompile address. +pub const BLS12_MAP_FP_TO_G1: Address = Address::with_last_byte(0x10); + +/// The BLS12-381 map Fp2 to G2 precompile address. +pub const BLS12_MAP_FP2_TO_G2: Address = Address::with_last_byte(0x11); + +/// The P256VERIFY precompile address. +pub const P256_VERIFY: Address = address!("0x0000000000000000000000000000000000000100"); + +/// The Celo transfer precompile address. +/// +/// See +pub const CELO_TRANSFER: Address = Address::with_last_byte(0xfd); /// Precompile addresses. pub const PRECOMPILES: &[Address] = &[ @@ -42,4 +71,12 @@ pub const PRECOMPILES: &[Address] = &[ EC_PAIRING, BLAKE_2F, POINT_EVALUATION, + BLS12_G1ADD, + BLS12_G1MSM, + BLS12_G2ADD, + BLS12_G2MSM, + BLS12_PAIRING_CHECK, + BLS12_MAP_FP_TO_G1, + BLS12_MAP_FP2_TO_G2, + P256_VERIFY, ]; diff --git a/crates/evm/core/src/state_snapshot.rs b/crates/evm/core/src/state_snapshot.rs index 9c06b3fbf49dc..05854a03d6c2a 100644 --- a/crates/evm/core/src/state_snapshot.rs +++ b/crates/evm/core/src/state_snapshot.rs @@ -1,7 +1,6 @@ //! Support for snapshotting different states use alloy_primitives::{U256, map::HashMap}; -use std::ops::Add; /// Represents all state snapshots #[derive(Clone, Debug)] @@ -11,9 +10,9 @@ pub struct StateSnapshots { } impl StateSnapshots { - fn next_id(&mut self) -> U256 { + const fn next_id(&mut self) -> U256 { let id = self.id; - self.id = id.saturating_add(U256::from(1)); + self.id = id.saturating_add(U256::ONE); id } @@ -22,6 +21,11 @@ impl StateSnapshots { self.state_snapshots.get(&id) } + /// Returns `true` if there are no state snapshots. + pub fn is_empty(&self) -> bool { + self.state_snapshots.is_empty() + } + /// Removes the state snapshot with the given `id`. /// /// This will also remove any state snapshots taken after the state snapshot with the `id`. @@ -30,10 +34,10 @@ impl StateSnapshots { let snapshot_state = self.state_snapshots.remove(&id); // Revert all state snapshots taken after the state snapshot with the `id` - let mut to_revert = id.add(U256::from(1)); + let mut to_revert = id + U256::ONE; while to_revert < self.id { self.state_snapshots.remove(&to_revert); - to_revert += U256::from(1); + to_revert += U256::ONE; } snapshot_state @@ -61,9 +65,8 @@ impl StateSnapshots { /// Inserts the new state snapshot at the given `id`. /// /// Does not auto-increment the next `id`. - pub fn insert_at(&mut self, state_snapshot: T, id: U256) -> U256 { + pub fn insert_at(&mut self, state_snapshot: T, id: U256) { self.state_snapshots.insert(id, state_snapshot); - id } } diff --git a/crates/evm/core/src/tempo.rs b/crates/evm/core/src/tempo.rs new file mode 100644 index 0000000000000..154ce5eab6233 --- /dev/null +++ b/crates/evm/core/src/tempo.rs @@ -0,0 +1,216 @@ +//! Tempo precompile and contract initialization for Foundry. +//! +//! This module provides the core initialization logic for Tempo-specific precompiles, +//! fee tokens (PathUSD, AlphaUSD, BetaUSD, ThetaUSD), and standard contracts. +//! +//! It includes the shared genesis initialization function used by both anvil and forge. + +use alloy_primitives::{Address, Bytes, U256}; +use revm::state::Bytecode; +use tempo_contracts::{ + ARACHNID_CREATE2_FACTORY_ADDRESS, CREATEX_ADDRESS, CreateX, MULTICALL3_ADDRESS, Multicall3, + PERMIT2_ADDRESS, Permit2, SAFE_DEPLOYER_ADDRESS, SafeDeployer, + contracts::ARACHNID_CREATE2_FACTORY_BYTECODE, precompiles::VALIDATOR_CONFIG_ADDRESS, +}; +use tempo_hardfork::TempoHardfork; +use tempo_precompiles::{ + error::TempoPrecompileError, + storage::{PrecompileStorageProvider, StorageCtx}, + tip20::{ITIP20, TIP20Token}, + tip20_factory::TIP20Factory, + validator_config, +}; + +use crate::constants::SYSTEM_PRECOMPILE_STUB; + +pub use foundry_common::tempo::{ + ALPHA_USD_ADDRESS, BETA_USD_ADDRESS, PATH_USD_ADDRESS, THETA_USD_ADDRESS, +}; +pub use foundry_evm_networks::{ + TEMPO_PRECOMPILE_ADDRESSES, active_tempo_precompile_addresses, is_tempo_precompile_active_at, +}; +pub use tempo_contracts::precompiles::{ + ACCOUNT_KEYCHAIN_ADDRESS, ADDRESS_REGISTRY_ADDRESS, IAccountKeychain, IAddressRegistry, + IFeeManager, IReceivePolicyGuard, ISignatureVerifier, IStablecoinDEX, ITIP20ChannelReserve, + ITIP403Registry, RECEIVE_POLICY_GUARD_ADDRESS, SIGNATURE_VERIFIER_ADDRESS, + STABLECOIN_DEX_ADDRESS, TIP_FEE_MANAGER_ADDRESS, TIP20_CHANNEL_RESERVE_ADDRESS, + TIP20_FACTORY_ADDRESS, TIP403_REGISTRY_ADDRESS, +}; +pub use tempo_precompiles::{ + address_registry::{AddressRegistry, IMPLICIT_APPROVAL_LIST, is_implicitly_approved}, + signature_verifier::SignatureVerifier, + stablecoin_dex::StablecoinDEX, + tip_fee_manager::TipFeeManager, + tip20::is_tip20_prefix, + tip20_channel_reserve::TIP20ChannelReserve, +}; + +/// All well-known TIP20 fee token addresses on Tempo networks. +pub const TEMPO_TIP20_TOKENS: &[Address] = &[PATH_USD_ADDRESS]; + +/// Initialize Tempo precompiles and contracts for a specific active hardfork. +pub fn initialize_tempo_genesis_at_hardfork( + storage: &mut impl PrecompileStorageProvider, + admin: Address, + recipient: Address, + hardfork: TempoHardfork, +) -> Result<(), TempoPrecompileError> { + StorageCtx::enter(storage, || initialize_tempo_genesis_inner(admin, recipient, hardfork)) +} + +/// Inner genesis initialization logic. Must be called within a [`StorageCtx`] scope +/// (either via [`StorageCtx::enter`] or [`StorageCtx::enter_evm`]). +pub fn initialize_tempo_genesis_inner( + admin: Address, + recipient: Address, + hardfork: TempoHardfork, +) -> Result<(), TempoPrecompileError> { + initialize_tempo_genesis_inner_with_precompiles( + admin, + recipient, + active_tempo_precompile_addresses(hardfork), + ) +} + +/// Inner genesis initialization for Forge's local test EVM. +/// +/// Forge tests use sentinel bytecode to identify well-known Tempo precompile accounts, even when +/// the current setup spec is earlier than the precompile's activation hardfork. This does not +/// affect hardfork-aware execution, which remains handled by the precompile lookup. +pub fn initialize_tempo_test_genesis_inner( + admin: Address, + recipient: Address, +) -> Result<(), TempoPrecompileError> { + initialize_tempo_genesis_inner_with_precompiles( + admin, + recipient, + TEMPO_PRECOMPILE_ADDRESSES.iter().copied(), + ) +} + +fn initialize_tempo_genesis_inner_with_precompiles( + admin: Address, + recipient: Address, + precompiles: impl IntoIterator, +) -> Result<(), TempoPrecompileError> { + // Idempotent: PATH_USD is the first token created during genesis; if it already exists, skip. + if TIP20Factory::new().is_tip20(PATH_USD_ADDRESS)? { + return Ok(()); + } + + let mut ctx = StorageCtx; + + // Set sentinel bytecode for precompile addresses + let sentinel = Bytecode::new_legacy(Bytes::from_static(SYSTEM_PRECOMPILE_STUB)); + for precompile in precompiles { + ctx.set_code(precompile, sentinel.clone())?; + } + + // Create PathUSD token: 0x20C0000000000000000000000000000000000000 + let path_usd_token_address = create_and_mint_token( + PATH_USD_ADDRESS, + "PathUSD", + "PathUSD", + "USD", + Address::ZERO, + admin, + recipient, + U256::from(u64::MAX), + )?; + + // Create AlphaUSD token: 0x20C0000000000000000000000000000000000001 + let _alpha_usd_token_address = create_and_mint_token( + ALPHA_USD_ADDRESS, + "AlphaUSD", + "AlphaUSD", + "USD", + path_usd_token_address, + admin, + recipient, + U256::from(u64::MAX), + )?; + + // Create BetaUSD token: 0x20C0000000000000000000000000000000000002 + let _beta_usd_token_address = create_and_mint_token( + BETA_USD_ADDRESS, + "BetaUSD", + "BetaUSD", + "USD", + path_usd_token_address, + admin, + recipient, + U256::from(u64::MAX), + )?; + + // Create ThetaUSD token: 0x20C0000000000000000000000000000000000003 + let _theta_usd_token_address = create_and_mint_token( + THETA_USD_ADDRESS, + "ThetaUSD", + "ThetaUSD", + "USD", + path_usd_token_address, + admin, + recipient, + U256::from(u64::MAX), + )?; + + // Initialize ValidatorConfig with admin as owner + ctx.sstore(VALIDATOR_CONFIG_ADDRESS, validator_config::slots::OWNER, admin.into_word().into())?; + + // Set bytecode for standard contracts + ctx.set_code( + MULTICALL3_ADDRESS, + Bytecode::new_legacy(Bytes::from_static(&Multicall3::DEPLOYED_BYTECODE)), + )?; + ctx.set_code( + CREATEX_ADDRESS, + Bytecode::new_legacy(Bytes::from_static(&CreateX::DEPLOYED_BYTECODE)), + )?; + ctx.set_code( + SAFE_DEPLOYER_ADDRESS, + Bytecode::new_legacy(Bytes::from_static(&SafeDeployer::DEPLOYED_BYTECODE)), + )?; + ctx.set_code( + PERMIT2_ADDRESS, + Bytecode::new_legacy(Bytes::from_static(&Permit2::DEPLOYED_BYTECODE)), + )?; + ctx.set_code( + ARACHNID_CREATE2_FACTORY_ADDRESS, + Bytecode::new_legacy(ARACHNID_CREATE2_FACTORY_BYTECODE), + )?; + + Ok(()) +} + +/// Helper function to create and mint a TIP20 token. +#[allow(clippy::too_many_arguments)] +fn create_and_mint_token( + address: Address, + symbol: &str, + name: &str, + currency: &str, + quote_token: Address, + admin: Address, + recipient: Address, + mint_amount: U256, +) -> Result { + let mut tip20_factory = TIP20Factory::new(); + + let token_address = tip20_factory.create_token_reserved_address( + address, + name, + symbol, + currency, + quote_token, + admin, + )?; + + let mut token = TIP20Token::from_address(token_address)?; + token.grant_role_internal(admin, TIP20Token::issuer_role())?; + token.mint(admin, ITIP20::mintCall { to: recipient, amount: mint_amount })?; + if admin != recipient { + token.mint(admin, ITIP20::mintCall { to: admin, amount: mint_amount })?; + } + + Ok(token_address) +} diff --git a/crates/evm/core/src/utils.rs b/crates/evm/core/src/utils.rs index 778802f395875..669fe07acabc3 100644 --- a/crates/evm/core/src/utils.rs +++ b/crates/evm/core/src/utils.rs @@ -1,59 +1,121 @@ -use crate::EnvMut; +use crate::{EvmEnv, FoundryBlock, hardfork::FoundryHardfork}; use alloy_chains::Chain; -use alloy_consensus::BlockHeader; +use alloy_consensus::{BlockHeader, private::alloy_eips::eip7840::BlobParams}; use alloy_hardforks::EthereumHardfork; use alloy_json_abi::{Function, JsonAbi}; -use alloy_network::{AnyTxEnvelope, TransactionResponse}; -use alloy_primitives::{Address, B256, ChainId, Selector, TxKind, U256}; +use alloy_primitives::{ChainId, Selector, U256}; use alloy_provider::{Network, network::BlockResponse}; -use alloy_rpc_types::{Transaction, TransactionRequest}; -use foundry_config::NamedChain; -use revm::primitives::{ - eip4844::{BLOB_BASE_FEE_UPDATE_FRACTION_CANCUN, BLOB_BASE_FEE_UPDATE_FRACTION_PRAGUE}, - hardfork::SpecId, +use foundry_config::NamedChain::{ + self, Avalanche, AvalancheFuji, BinanceSmartChain, BinanceSmartChainTestnet, Polygon, + PolygonAmoy, }; +use foundry_evm_networks::NetworkConfigs; +use revm::primitives::hardfork::SpecId; pub use revm::state::EvmState as StateChangeset; +/// Hints to the compiler that this is a cold path, i.e. unlikely to be taken. +#[cold] +#[inline(always)] +pub const fn cold_path() { + // TODO: remove `#[cold]` and call `std::hint::cold_path` once stable. +} + +/// Constructs a generic [`FoundryBlock`] from a block header. +pub fn block_env_from_header(header: &impl BlockHeader) -> BLOCK { + let mut block = BLOCK::default(); + block.set_number(U256::from(header.number())); + block.set_slot_num(header.slot_number().unwrap_or_default()); + block.set_beneficiary(header.beneficiary()); + block.set_timestamp(U256::from(header.timestamp())); + block.set_difficulty(header.difficulty()); + block.set_prevrandao(header.mix_hash()); + block.set_basefee(header.base_fee_per_gas().unwrap_or_default()); + block.set_gas_limit(header.gas_limit()); + block +} + +/// Applies chain-specific changes required to replay transactions accepted on-chain. +pub fn apply_chain_specific_tx_replay_env_changes(evm_env: &mut EvmEnv) { + let chain_id = evm_env.cfg_env.chain_id; + apply_chain_specific_tx_replay_env_changes_for_chain(evm_env, chain_id); +} + +/// Applies replay normalization for the provided source chain. +/// +/// This keeps fork-specific transaction validation independent from an execution `CHAINID` +/// override. +pub fn apply_chain_specific_tx_replay_env_changes_for_chain( + evm_env: &mut EvmEnv, + source_chain_id: ChainId, +) { + if NamedChain::try_from(source_chain_id).is_ok_and(|chain| chain.is_arbitrum()) { + // Arbitrum does not enforce the EIP-1559 priority fee ordering constraint. + evm_env.cfg_env.disable_priority_fee_check = true; + } +} + /// Depending on the configured chain id and block number this should apply any specific changes /// /// - checks for prevrandao mixhash after merge /// - applies chain specifics: on Arbitrum `block.number` is the L1 block /// -/// Should be called with proper chain id (retrieved from provider if not provided). -pub fn apply_chain_and_block_specific_env_changes( - env: EnvMut<'_>, +/// Should be called with proper chain id (retrieved from provider if not provided), works with any +/// [`FoundryBlock`] type. +pub fn apply_chain_and_block_specific_env_changes< + N: Network, + SPEC: Into + Copy, + BLOCK: FoundryBlock, +>( + evm_env: &mut EvmEnv, block: &N::BlockResponse, + configs: NetworkConfigs, ) { - use NamedChain::*; + let chain_id = evm_env.cfg_env.chain_id; + apply_chain_and_block_specific_env_changes_for_chain::( + evm_env, block, chain_id, configs, + ); +} - if let Ok(chain) = NamedChain::try_from(env.cfg.chain_id) { - let block_number = block.header().number(); +/// Applies block normalization for the provided source chain. +/// +/// This keeps fork-specific header handling independent from an execution `CHAINID` override. +pub fn apply_chain_and_block_specific_env_changes_for_chain< + N: Network, + SPEC: Into + Copy, + BLOCK: FoundryBlock, +>( + evm_env: &mut EvmEnv, + block: &N::BlockResponse, + source_chain_id: ChainId, + configs: NetworkConfigs, +) { + // The blob fee market is priced from the header's excess blob gas and the source chain's + // blob schedule at the block timestamp. Headers without the field (pre-Cancun blocks and + // chains without EIP-4844) keep the default blob environment. + if let Some(excess_blob_gas) = block.header().excess_blob_gas() { + evm_env.block_env.set_blob_excess_gas_and_price( + excess_blob_gas, + get_blob_base_fee_update_fraction(source_chain_id, block.header().timestamp()), + ); + } + if let Ok(chain) = NamedChain::try_from(source_chain_id) { match chain { - Mainnet => { - // after merge difficulty is supplanted with prevrandao EIP-4399 - if block_number >= 15_537_351u64 { - env.block.difficulty = env.block.prevrandao.unwrap_or_default().into(); - } - - return; - } - BinanceSmartChain | BinanceSmartChainTestnet => { + BinanceSmartChain + | BinanceSmartChainTestnet + | Polygon + | PolygonAmoy + | Avalanche + | AvalancheFuji => { // https://github.com/foundry-rs/foundry/issues/9942 // As far as observed from the source code of bnb-chain/bsc, the `difficulty` field // is still in use and returned by the corresponding opcode but `prevrandao` // (`mixHash`) is always zero, even though bsc adopts the newer EVM // specification. This will confuse revm and causes emulation - // failure. - env.block.prevrandao = Some(env.block.difficulty.into()); + // failure. Polygon and Avalanche behave the same way. + evm_env.block_env.set_prevrandao(Some(evm_env.block_env.difficulty().into())); return; } - Moonbeam | Moonbase | Moonriver | MoonbeamDev | Rsk | RskTestnet => { - if env.block.prevrandao.is_none() { - // - env.block.prevrandao = Some(B256::random()); - } - } c if c.is_arbitrum() => { // on arbitrum `block.number` is the L1 block which is included in the // `l1BlockNumber` field @@ -64,39 +126,93 @@ pub fn apply_chain_and_block_specific_env_changes( serde_json::from_value::(l1_block_number).ok() }) { - env.block.number = l1_block_number.to(); + evm_env.block_env.set_number(l1_block_number); } + + // `mixHash` carries L1 metadata rather than randomness here, while the + // `PREVRANDAO` opcode returns `difficulty` like it does on the chains above. + evm_env.block_env.set_prevrandao(Some(evm_env.block_env.difficulty().into())); } _ => {} } } + if configs.bypass_prevrandao(source_chain_id) && evm_env.block_env.prevrandao().is_none() { + // Chains without randomness keep the historical DIFFICULTY opcode semantics. + // + evm_env.block_env.set_prevrandao(Some(evm_env.block_env.difficulty().into())); + } + // if difficulty is `0` we assume it's past merge if block.header().difficulty().is_zero() { - env.block.difficulty = env.block.prevrandao.unwrap_or_default().into(); + evm_env.block_env.set_difficulty(evm_env.block_env.prevrandao().unwrap_or_default().into()); + } +} + +/// Derives the active [`BlobParams`] based on the given timestamp. +/// +/// This falls back to regular ethereum blob params if no hardforks for the given chain id are +/// detected. +pub fn get_blob_params(chain_id: ChainId, timestamp: u64) -> BlobParams { + let hardfork = EthereumHardfork::from_chain_and_timestamp(Chain::from_id(chain_id), timestamp) + .unwrap_or_default(); + + match hardfork { + EthereumHardfork::Prague => BlobParams::prague(), + EthereumHardfork::Osaka => BlobParams::osaka(), + EthereumHardfork::Bpo1 => BlobParams::bpo1(), + EthereumHardfork::Bpo2 => BlobParams::bpo2(), + + // future hardforks/unknown settings: update once decided + EthereumHardfork::Bpo3 => BlobParams::bpo2(), + EthereumHardfork::Bpo4 => BlobParams::bpo2(), + EthereumHardfork::Bpo5 => BlobParams::bpo2(), + EthereumHardfork::Amsterdam => BlobParams::bpo2(), + + // fallback + _ => BlobParams::cancun(), } } /// Derive the blob base fee update fraction based on the chain and timestamp by checking the /// hardfork. pub fn get_blob_base_fee_update_fraction(chain_id: ChainId, timestamp: u64) -> u64 { - let hardfork = EthereumHardfork::from_chain_and_timestamp(Chain::from_id(chain_id), timestamp) - .unwrap_or_default(); + get_blob_params(chain_id, timestamp).update_fraction as u64 +} - if hardfork >= EthereumHardfork::Prague { - BLOB_BASE_FEE_UPDATE_FRACTION_PRAGUE +/// Returns the blob params based on the spec id. +pub fn get_blob_params_by_spec_id(spec: SpecId) -> BlobParams { + if spec >= SpecId::AMSTERDAM { + BlobParams::bpo2() + } else if spec >= SpecId::OSAKA { + BlobParams::osaka() + } else if spec >= SpecId::PRAGUE { + BlobParams::prague() } else { - BLOB_BASE_FEE_UPDATE_FRACTION_CANCUN + BlobParams::cancun() + } +} + +/// Returns the blob parameters selected by an explicit Foundry hardfork. +pub fn get_blob_params_by_hardfork(hardfork: FoundryHardfork) -> BlobParams { + match hardfork { + FoundryHardfork::Ethereum(EthereumHardfork::Prague) => BlobParams::prague(), + FoundryHardfork::Ethereum(EthereumHardfork::Osaka) => BlobParams::osaka(), + FoundryHardfork::Ethereum(EthereumHardfork::Bpo1) => BlobParams::bpo1(), + FoundryHardfork::Ethereum(EthereumHardfork::Bpo2) => BlobParams::bpo2(), + FoundryHardfork::Ethereum( + EthereumHardfork::Bpo3 + | EthereumHardfork::Bpo4 + | EthereumHardfork::Bpo5 + | EthereumHardfork::Amsterdam, + ) => BlobParams::bpo2(), + _ => get_blob_params_by_spec_id(hardfork.into()), } } /// Returns the blob base fee update fraction based on the spec id. pub fn get_blob_base_fee_update_fraction_by_spec_id(spec: SpecId) -> u64 { - if spec >= SpecId::PRAGUE { - BLOB_BASE_FEE_UPDATE_FRACTION_PRAGUE - } else { - BLOB_BASE_FEE_UPDATE_FRACTION_CANCUN - } + get_blob_params_by_spec_id(spec).update_fraction as u64 } /// Given an ABI and selector, it tries to find the respective function. @@ -110,71 +226,313 @@ pub fn get_function<'a>( .ok_or_else(|| eyre::eyre!("{contract_name} does not have the selector {selector}")) } -/// Configures the env for the given RPC transaction. -/// Accounts for an impersonated transaction by resetting the `env.tx.caller` field to `tx.from`. -pub fn configure_tx_env(env: &mut EnvMut<'_>, tx: &Transaction) { - let from = tx.from(); - if let AnyTxEnvelope::Ethereum(tx) = &tx.inner.inner() { - configure_tx_req_env(env, &tx.clone().into(), Some(from)).expect("cannot fail"); +#[cfg(test)] +mod tests { + use super::*; + use alloy_network::{AnyHeader, AnyNetwork, AnyRpcBlock, AnyRpcHeader}; + use alloy_primitives::B256; + use alloy_rpc_types::{Block, BlockTransactions}; + use revm::context::{BlockEnv, CfgEnv}; + + #[test] + fn block_env_preserves_slot_number() { + for slot_number in [None, Some(0), Some(42), Some(u64::MAX)] { + let header = AnyHeader { slot_number, ..Default::default() }; + let block = block_env_from_header::(&header); + assert_eq!(block.slot_num, slot_number.unwrap_or_default()); + } + } + + #[test] + fn block_normalization_uses_source_chain() { + let header = AnyHeader { number: 500, ..Default::default() }; + let mut block = AnyRpcBlock::new( + Block::new( + AnyRpcHeader::from_sealed(header.seal(B256::ZERO)), + BlockTransactions::Full(Vec::new()), + ) + .into(), + ); + block.other.insert("l1BlockNumber".to_string(), serde_json::json!("0x64")); + + let mut cfg_env = CfgEnv::::default(); + cfg_env.chain_id = NamedChain::Mainnet as u64; + let mut evm_env = EvmEnv { + cfg_env, + block_env: BlockEnv { number: U256::from(500), ..Default::default() }, + }; + + apply_chain_and_block_specific_env_changes_for_chain::( + &mut evm_env, + &block, + NamedChain::Arbitrum as u64, + NetworkConfigs::default(), + ); + + assert_eq!(evm_env.cfg_env.chain_id, NamedChain::Mainnet as u64); + assert_eq!(evm_env.block_env.number, U256::from(100)); + } + + #[test] + fn block_normalization_sets_blob_excess_gas_from_header() { + // Mainnet block 22_000_000 (Cancun): 22_151_168 excess blob gas prices blobs at 761 wei. + let header = AnyHeader { + timestamp: 1_741_410_875, + excess_blob_gas: Some(22_151_168), + ..Default::default() + }; + let block = AnyRpcBlock::new( + Block::new( + AnyRpcHeader::from_sealed(header.seal(B256::ZERO)), + BlockTransactions::Full(Vec::new()), + ) + .into(), + ); + let mut evm_env = EvmEnv::new(CfgEnv::::default(), BlockEnv::default()); + // The execution chain id can be overridden; the blob schedule follows the source chain. + evm_env.cfg_env.chain_id = 1337; + + apply_chain_and_block_specific_env_changes_for_chain::( + &mut evm_env, + &block, + NamedChain::Mainnet as u64, + NetworkConfigs::default(), + ); + + let blob = evm_env.block_env.blob_excess_gas_and_price.unwrap(); + assert_eq!(blob.excess_blob_gas, 22_151_168); + assert_eq!(blob.blob_gasprice, 761); + } + + #[test] + fn block_normalization_keeps_default_blob_env_without_header_field() { + let header = AnyHeader { excess_blob_gas: None, ..Default::default() }; + let block = AnyRpcBlock::new( + Block::new( + AnyRpcHeader::from_sealed(header.seal(B256::ZERO)), + BlockTransactions::Full(Vec::new()), + ) + .into(), + ); + let mut evm_env = EvmEnv::new(CfgEnv::::default(), BlockEnv::default()); + + apply_chain_and_block_specific_env_changes_for_chain::( + &mut evm_env, + &block, + NamedChain::Mainnet as u64, + NetworkConfigs::default(), + ); + + assert_eq!( + evm_env.block_env.blob_excess_gas_and_price, + BlockEnv::default().blob_excess_gas_and_price + ); + } + + #[test] + fn block_normalization_handles_missing_prevrandao_deterministically() { + for (chain, configs, bypass) in [ + (NamedChain::Moonbeam as u64, NetworkConfigs::default(), true), + (NamedChain::Gnosis as u64, NetworkConfigs::default(), true), + (NamedChain::Rsk as u64, NetworkConfigs::default(), true), + (98_765_432, NetworkConfigs::default(), false), + ( + 98_765_432, + serde_json::from_str(r#"{"bypass_prevrandao":true,"celo":false}"#).unwrap(), + true, + ), + ] { + for difficulty in [U256::ZERO, U256::from(42)] { + let header = AnyHeader { difficulty, mix_hash: None, ..Default::default() }; + let block = AnyRpcBlock::new( + Block::new( + AnyRpcHeader::from_sealed(header.seal(B256::ZERO)), + BlockTransactions::Full(Vec::new()), + ) + .into(), + ); + let mut previous = None; + for _ in 0..2 { + let mut evm_env = EvmEnv::new( + CfgEnv::::default(), + block_env_from_header::(block.header()), + ); + apply_chain_and_block_specific_env_changes_for_chain::( + &mut evm_env, + &block, + chain, + configs, + ); + assert_eq!(evm_env.block_env.prevrandao, bypass.then(|| difficulty.into())); + assert_eq!(evm_env.block_env.difficulty, difficulty); + if let Some(previous) = &previous { + assert_eq!(&evm_env.block_env, previous); + } + previous = Some(evm_env.block_env); + } + } + } + } + + #[test] + fn block_normalization_preserves_header_prevrandao() { + let randao = B256::repeat_byte(0xab); + let header = AnyHeader { mix_hash: Some(randao), ..Default::default() }; + let block = AnyRpcBlock::new( + Block::new( + AnyRpcHeader::from_sealed(header.seal(B256::ZERO)), + BlockTransactions::Full(Vec::new()), + ) + .into(), + ); + let mut evm_env = EvmEnv::new( + CfgEnv::::default(), + block_env_from_header::(block.header()), + ); + apply_chain_and_block_specific_env_changes_for_chain::( + &mut evm_env, + &block, + NamedChain::Gnosis as u64, + NetworkConfigs::default(), + ); + assert_eq!(evm_env.block_env.prevrandao, Some(randao)); } -} -/// Configures the env for the given RPC transaction request. -/// `impersonated_from` is the address of the impersonated account. This helps account for an -/// impersonated transaction by resetting the `env.tx.caller` field to `impersonated_from`. -pub fn configure_tx_req_env( - env: &mut EnvMut<'_>, - tx: &TransactionRequest, - impersonated_from: Option
, -) -> eyre::Result<()> { - // If no transaction type is provided, we need to infer it from the other fields. - let tx_type = tx.transaction_type.unwrap_or_else(|| tx.minimal_tx_type() as u8); - env.tx.tx_type = tx_type; - - let TransactionRequest { - nonce, - from, - to, - value, - gas_price, - gas, - max_fee_per_gas, - max_priority_fee_per_gas, - max_fee_per_blob_gas, - ref input, - chain_id, - ref blob_versioned_hashes, - ref access_list, - ref authorization_list, - transaction_type: _, - sidecar: _, - } = *tx; - - // If no `to` field then set create kind: https://eips.ethereum.org/EIPS/eip-2470#deployment-transaction - env.tx.kind = to.unwrap_or(TxKind::Create); - // If the transaction is impersonated, we need to set the caller to the from - // address Ref: https://github.com/foundry-rs/foundry/issues/9541 - env.tx.caller = - impersonated_from.unwrap_or(from.ok_or_else(|| eyre::eyre!("missing `from` field"))?); - env.tx.gas_limit = gas.ok_or_else(|| eyre::eyre!("missing `gas` field"))?; - env.tx.nonce = nonce.unwrap_or_default(); - env.tx.value = value.unwrap_or_default(); - env.tx.data = input.input().cloned().unwrap_or_default(); - env.tx.chain_id = chain_id; - - // Type 1, EIP-2930 - env.tx.access_list = access_list.clone().unwrap_or_default(); - - // Type 2, EIP-1559 - env.tx.gas_price = gas_price.or(max_fee_per_gas).unwrap_or_default(); - env.tx.gas_priority_fee = max_priority_fee_per_gas; - - // Type 3, EIP-4844 - env.tx.blob_hashes = blob_versioned_hashes.clone().unwrap_or_default(); - env.tx.max_fee_per_blob_gas = max_fee_per_blob_gas.unwrap_or_default(); - - // Type 4, EIP-7702 - env.tx.set_signed_authorization(authorization_list.clone().unwrap_or_default()); - - Ok(()) + #[test] + fn block_normalization_preserves_mainnet_merge_boundary() { + let randao = B256::repeat_byte(0xab); + for number in [15_537_350, 15_537_351, 15_537_393, 15_537_394] { + let difficulty = if number < 15_537_394 { U256::from(42) } else { U256::ZERO }; + let header = + AnyHeader { number, difficulty, mix_hash: Some(randao), ..Default::default() }; + let block = AnyRpcBlock::new( + Block::new( + AnyRpcHeader::from_sealed(header.seal(B256::ZERO)), + BlockTransactions::Full(Vec::new()), + ) + .into(), + ); + let mut evm_env = EvmEnv::new( + CfgEnv::::default(), + block_env_from_header::(block.header()), + ); + apply_chain_and_block_specific_env_changes_for_chain::( + &mut evm_env, + &block, + NamedChain::Mainnet as u64, + NetworkConfigs::default(), + ); + let expected = if difficulty.is_zero() { randao.into() } else { difficulty }; + assert_eq!(evm_env.block_env.difficulty, expected, "block {number}"); + assert_eq!(evm_env.block_env.prevrandao, Some(randao)); + } + } + + #[test] + fn block_normalization_uses_difficulty_as_prevrandao() { + // These chains keep using `difficulty` and return it from `PREVRANDAO`, so a header + // `mixHash` of zero (or, on Arbitrum, packed L1 metadata) must not reach the block env. + for (chain, mix_hash) in [ + (NamedChain::BinanceSmartChain, B256::ZERO), + (NamedChain::Polygon, B256::ZERO), + (NamedChain::PolygonAmoy, B256::ZERO), + (NamedChain::Avalanche, B256::ZERO), + (NamedChain::AvalancheFuji, B256::ZERO), + (NamedChain::Arbitrum, B256::repeat_byte(0xab)), + (NamedChain::ArbitrumNova, B256::repeat_byte(0xab)), + (NamedChain::ArbitrumSepolia, B256::repeat_byte(0xab)), + ] { + let header = + AnyHeader { difficulty: U256::ONE, mix_hash: Some(mix_hash), ..Default::default() }; + let block = AnyRpcBlock::new( + Block::new( + AnyRpcHeader::from_sealed(header.seal(B256::ZERO)), + BlockTransactions::Full(Vec::new()), + ) + .into(), + ); + let mut evm_env = EvmEnv::new( + CfgEnv::::default(), + BlockEnv { + difficulty: U256::ONE, + prevrandao: Some(mix_hash), + ..Default::default() + }, + ); + + apply_chain_and_block_specific_env_changes_for_chain::( + &mut evm_env, + &block, + chain as u64, + NetworkConfigs::default(), + ); + + assert_eq!( + evm_env.block_env.prevrandao, + Some(B256::with_last_byte(1)), + "{chain:?} should expose `difficulty` as `PREVRANDAO`" + ); + } + } + + #[test] + fn tx_replay_env_changes_disable_priority_fee_check_only_for_arbitrum() { + let mut evm_env = EvmEnv::new( + revm::context::CfgEnv::::default(), + revm::context::BlockEnv::default(), + ); + evm_env.cfg_env.chain_id = NamedChain::Arbitrum as u64; + + apply_chain_specific_tx_replay_env_changes(&mut evm_env); + assert!(evm_env.cfg_env.disable_priority_fee_check); + + evm_env.cfg_env.chain_id = NamedChain::Mainnet as u64; + evm_env.cfg_env.disable_priority_fee_check = false; + + apply_chain_specific_tx_replay_env_changes(&mut evm_env); + assert!(!evm_env.cfg_env.disable_priority_fee_check); + } + + #[test] + fn tx_replay_env_changes_use_source_chain() { + let mut evm_env = EvmEnv::new( + revm::context::CfgEnv::::default(), + revm::context::BlockEnv::default(), + ); + evm_env.cfg_env.chain_id = NamedChain::Mainnet as u64; + + apply_chain_specific_tx_replay_env_changes_for_chain( + &mut evm_env, + NamedChain::Arbitrum as u64, + ); + + assert_eq!(evm_env.cfg_env.chain_id, NamedChain::Mainnet as u64); + assert!(evm_env.cfg_env.disable_priority_fee_check); + } + + #[test] + fn blob_params_by_spec_id_tracks_latest_known_blob_schedule() { + assert_eq!(get_blob_params_by_spec_id(SpecId::CANCUN), BlobParams::cancun()); + assert_eq!(get_blob_params_by_spec_id(SpecId::PRAGUE), BlobParams::prague()); + assert_eq!(get_blob_params_by_spec_id(SpecId::OSAKA), BlobParams::osaka()); + assert_eq!(get_blob_params_by_spec_id(SpecId::AMSTERDAM), BlobParams::bpo2()); + assert_eq!( + get_blob_base_fee_update_fraction_by_spec_id(SpecId::AMSTERDAM), + BlobParams::bpo2().update_fraction as u64 + ); + } + + #[test] + fn blob_params_by_explicit_hardfork() { + for (hardfork, expected) in [ + (EthereumHardfork::Cancun, BlobParams::cancun()), + (EthereumHardfork::Prague, BlobParams::prague()), + (EthereumHardfork::Osaka, BlobParams::osaka()), + (EthereumHardfork::Bpo1, BlobParams::bpo1()), + (EthereumHardfork::Bpo2, BlobParams::bpo2()), + (EthereumHardfork::Amsterdam, BlobParams::bpo2()), + ] { + assert_eq!(get_blob_params_by_hardfork(hardfork.into()), expected); + } + } } diff --git a/crates/evm/core/test-data/tempo-aa-authorization-list.json b/crates/evm/core/test-data/tempo-aa-authorization-list.json new file mode 100644 index 0000000000000..bacb238f49dff --- /dev/null +++ b/crates/evm/core/test-data/tempo-aa-authorization-list.json @@ -0,0 +1,83 @@ +{ + "type": "0x76", + "chainId": "0xa5bf", + "feeToken": "0x20c0000000000000000000000000000000000000", + "maxPriorityFeePerGas": "0x4a817c800", + "maxFeePerGas": "0x4a817c800", + "gas": "0x1e8480", + "calls": [ + { + "to": "0xa98d41b22c17ae4f4a94420a29a3095464c037e3", + "value": "0x0", + "input": "0x", + "data": null + }, + { + "to": "0xa98d41b22c17ae4f4a94420a29a3095464c037e3", + "value": "0x0", + "input": "0x", + "data": null + } + ], + "accessList": [], + "nonceKey": "0x0", + "nonce": "0x0", + "feePayerSignature": null, + "validBefore": null, + "validAfter": null, + "keyAuthorization": null, + "aaAuthorizationList": [ + { + "chainId": "0xa5bf", + "address": "0xaaaaaaaa00000000000000000000000000000000", + "nonce": "0x0", + "signature": { + "type": "secp256k1", + "r": "0x815456e1d5bbef2a8b8aec3198a16862aaa7e6e6d840ebf038e6a9935c1b01bd", + "s": "0x3b1ed1a1e4950bf4a3060427808658414f067ac92364770f0f5aae26cb2e522a", + "yParity": "0x0", + "v": "0x0" + } + }, + { + "chainId": "0xa5bf", + "address": "0xaaaaaaaa00000000000000000000000000000000", + "nonce": "0x0", + "signature": { + "type": "p256", + "r": "0x1ee7a33aac8f04f5ca81cce2d5a0186bf30a329685845328481560c7e76c48b7", + "s": "0x353cb7093466e6ed4975a1dc5bc01684ee76dc76ca92f137a5324c5a8dad15ea", + "pubKeyX": "0x228ab707cb6251aea5f7f698b08e605432e1f0f153ad0e28920e8152209cf7a1", + "pubKeyY": "0x7d208dcf2cc10fcf49a56fd9ebf48c216dbe2c3a0c10e5d037b8a12881e95556", + "preHash": true + } + }, + { + "chainId": "0xa5bf", + "address": "0xaaaaaaaa00000000000000000000000000000000", + "nonce": "0x0", + "signature": { + "type": "webAuthn", + "r": "0x41586c5805ef938001260596aabb378110cfb827ae09fd1cb371ef705d3deffd", + "s": "0x24e4c741dc9063d0f011352cef35a4bd73c385f86ee493f7fc7dbb557daf7dea", + "pubKeyX": "0xa818c5868176c58b1b21d6343edafb5a95dd8538fbe75b43d26c01f658505e85", + "pubKeyY": "0xcab60c6fe071869447e4ee1ba7dbb7b340bda33c0efa4126abc8007cf6b449f8", + "webauthnData": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa01000000007b2274797065223a22776562617574686e2e676574222c226368616c6c656e6765223a22465f7274624a525335576b704a7442376b2d5532725765594c56775476556f6d38637164423873535f6267222c226f726967696e223a2268747470733a2f2f6578616d706c652e636f6d222c2263726f73734f726967696e223a66616c73657d" + } + } + ], + "signature": { + "type": "secp256k1", + "r": "0x14d2be61cd10b19ea2057c18288389e67c84c286358a9d6e3a3dc56ac0ef1118", + "s": "0x74d8eed27a25071266dbd6fd4281c474e2d0d63f49d7714aaca5512f07d77d7", + "yParity": "0x1", + "v": "0x1" + }, + "hash": "0xfff5a6fa344fa9d6d7410374f40b6f711735500892cccdf480670fd7c5adcf93", + "blockHash": "0x1545dbb05d4f58e77b82b26c5bc5d875b76f745306abeec617ef9c25840ca102", + "blockNumber": "0x23df1a9", + "transactionIndex": "0x0", + "from": "0xd410bcea80de9214e0e49df7d19c4c5e3db60f2a", + "gasPrice": "0x4a817c800", + "blockTimestamp": "0x6abd8288" +} diff --git a/crates/evm/core/test-data/tempo-aa-keychain.json b/crates/evm/core/test-data/tempo-aa-keychain.json new file mode 100644 index 0000000000000..1fc4274a34e82 --- /dev/null +++ b/crates/evm/core/test-data/tempo-aa-keychain.json @@ -0,0 +1,67 @@ +{ + "type": "0x76", + "chainId": "0xa5bf", + "feeToken": null, + "maxPriorityFeePerGas": "0x30a32c", + "maxFeePerGas": "0x560f0568", + "gas": "0x4ed05", + "calls": [ + { + "to": "0x0000000000000000000000000000000000000000", + "value": "0x0", + "input": "0x", + "data": null + } + ], + "accessList": [], + "nonceKey": "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "nonce": "0x0", + "feePayerSignature": { + "r": "0xb8f7e9c00477083beb8867ccc3f83ba1c62eb1e78de166b1b83d6a5bcbbb0389", + "s": "0x192d1fecea3c628c20b0c0c05c9c809a7c5fa60c19c8087d9de0e02e36d52035", + "yParity": "0x1", + "v": "0x1" + }, + "validBefore": "0x6abd9dbb", + "validAfter": "0x389b25e1", + "keyAuthorization": { + "chainId": "0xa5bf", + "keyType": "p256", + "keyId": "0x240a31713e851acdc0c590fe45ef72baeff55f91", + "expiry": null, + "limits": null, + "allowedCalls": null, + "witness": null, + "isAdmin": false, + "account": null, + "signature": { + "type": "p256", + "r": "0x9fcb18e7fcf24ab6b0dfc52ec350d7545c9998c05310b0982399035f33f61cf5", + "s": "0x19aa5d3a6a5200a2e35c718db03fa4d168b528edc74dfe2843e24d537097d931", + "pubKeyX": "0xc6774268fd7a385cf84921db76caac4701f007cc9b78cf330d56e846ada93e07", + "pubKeyY": "0x2ff5860a6692c4af45833230f8d11abd5d185aca1c12d7348c9b4ec314c974a3", + "preHash": false + } + }, + "aaAuthorizationList": [], + "signature": { + "userAddress": "0x5f704c6c7075acd14ee36527f03b5b5dcb4a966f", + "signature": { + "type": "p256", + "r": "0x0858c1fe383caf0d2c518fdb0783065a00ea37d5322140fbc0e058b1398432ab", + "s": "0x37c9ca2725858d9433390ba017e9bfd804d5b32a15bed21742756f5e13159fe3", + "pubKeyX": "0x01c70db647bf022c5112d515b61d6f242ebe7ee85fbb4c40f27fec94684f2455", + "pubKeyY": "0xc52642f443bc839c1afa85374f3f5a82a7199b650238e72bb2d903be877b118e", + "preHash": false + }, + "version": "v2", + "keyId": "0x240a31713e851acdc0c590fe45ef72baeff55f91" + }, + "hash": "0xd4d372ad5cfa4cdf476aeb25d4a76fb80fe2d6169eb9354c544500c0496a19d6", + "blockHash": "0xdb7ad242e9d214f9055a1320e309a00f7bf6e3db899ad0f8503be993646c0a62", + "blockNumber": "0x23e1ee8", + "transactionIndex": "0x7", + "from": "0x5f704c6c7075acd14ee36527f03b5b5dcb4a966f", + "gasPrice": "0x23f3e92c", + "blockTimestamp": "0x6abd9da2" +} diff --git a/crates/evm/core/test-data/tempo-aa-nonce-lane.json b/crates/evm/core/test-data/tempo-aa-nonce-lane.json new file mode 100644 index 0000000000000..5115114e5d965 --- /dev/null +++ b/crates/evm/core/test-data/tempo-aa-nonce-lane.json @@ -0,0 +1,38 @@ +{ + "type": "0x76", + "chainId": "0xa5bf", + "feeToken": null, + "maxPriorityFeePerGas": "0x0", + "maxFeePerGas": "0x4a817c800", + "gas": "0x18450", + "calls": [ + { + "to": "0x5ad0000000000000000000000000000000000003", + "value": "0x0", + "input": "0x78fb159b00000000000000000000000000000000000000000000000000000000023e27d00000000000000000000000000000000000000000000000000000000000000000542d1e4033aa1703078137d5c8a385d8d8b1787aee76689c723c54dd3228debe8d7cb1ff3816b35cfbea26f29de79c9c2953d2ff45ce98cd33f4bcb9c51438c70e534b30d87fc2a71f40e429ba68ed6dc90d39c4bd73861c68f78e7388a64c310e534b30d87fc2a71f40e429ba68ed6dc90d39c4bd73861c68f78e7388a64c31000000000000000000000000000000000000000000000000000000000003843d000000000000000000000000000000000000000000000000000000000003843d000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001a000000000000000000000000000000000000000000000000000000000000001e000000000000000000000000000000000000000000000000000000000005a069000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000001020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000041d1d513f793a0380d512e780374cb687a60b16384d8521f7516fe107e54ee7ed4509191f07aecc2a0eb540471829dfb5a3269acc7b59d815ed760dcf3db1145181b000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000041badf80060ed9d8563d60480c7cdd697f4c6c668abe878d6ca22c3afd6fa5b04f67090ac38925bf2afab23b8076d0391b475aca7caff07ee73f13c201e76a59cb1c00000000000000000000000000000000000000000000000000000000000000", + "data": null + } + ], + "accessList": [], + "nonceKey": "0x1", + "nonce": "0x57e0", + "feePayerSignature": null, + "validBefore": null, + "validAfter": null, + "keyAuthorization": null, + "aaAuthorizationList": [], + "signature": { + "type": "secp256k1", + "r": "0x44af835b9b8cf401ef98577fcc22d47ae24fa8048df4b18bbf3bd84fce4cfee5", + "s": "0x3aa4eb185c0ed6646daa1293a0fec5557b25db05e8fe2e17ccc51d0d51e4ae10", + "yParity": "0x0", + "v": "0x0" + }, + "hash": "0x4df0a9b9e859be6c91c00edf8478939d0b91f6f9eb39dd9c8a93946ac70ff740", + "blockHash": "0x7ef21f5a07849fced21b4af00e4cbcc84191da814e7001cde6d2b80ab0ceaffa", + "blockNumber": "0x23e27d2", + "transactionIndex": "0x0", + "from": "0xfdd1c606b498f5fcaaf27bd318b14caf52e8f6c2", + "gasPrice": "0x23c34600", + "blockTimestamp": "0x6abda2f6" +} diff --git a/crates/evm/core/test-data/tempo-aa-sponsored-batch.json b/crates/evm/core/test-data/tempo-aa-sponsored-batch.json new file mode 100644 index 0000000000000..1e2c574638576 --- /dev/null +++ b/crates/evm/core/test-data/tempo-aa-sponsored-batch.json @@ -0,0 +1,49 @@ +{ + "type": "0x76", + "chainId": "0x1079", + "feeToken": "0x20c0000000000000000000006a37da5c996874be", + "maxPriorityFeePerGas": "0x0", + "maxFeePerGas": "0x47868c00", + "gas": "0x156b8d", + "calls": [ + { + "to": "0x20c0000000000000000000000000000000000000", + "value": "0x0", + "input": "0x095ea7b300000000000000000000000083a1491f3e7f8daab8f787a631334b9ca7a870230000000000000000000000000000000000000000000000000000000077359400", + "data": null + }, + { + "to": "0x83a1491f3e7f8daab8f787a631334b9ca7a87023", + "value": "0x0", + "input": "0x6e553f6500000000000000000000000000000000000000000000000000000000773594000000000000000000000000000a0d9bc4dda3a659699ce05153ffe9298f99ce89", + "data": null + } + ], + "accessList": [], + "nonceKey": "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "nonce": "0x0", + "feePayerSignature": { + "r": "0x1fdf1ae1bfcf8c8208e88fcee0019b0667990b130e43a2dcb1a34233311c3463", + "s": "0x17c33b0318b5e77b0c6853f6d78d5f5d49f648aab69bf7eab50c0a2d5c76a3e1", + "yParity": "0x1", + "v": "0x1" + }, + "validBefore": "0x6abda5fc", + "validAfter": "0x20516401", + "keyAuthorization": null, + "aaAuthorizationList": [], + "signature": { + "type": "secp256k1", + "r": "0xb9ae21d92c6567b1f5f81eac709b2ee71006b0ad157b63ae79ac65a2e0888b22", + "s": "0x4a00798f9022dc83ce42ebc7c88dd4fc8e90e9b520efb4b10d5451e1f4879edb", + "yParity": "0x1", + "v": "0x1" + }, + "hash": "0xbb23551c35bc2c2539ac3637c7464a22b5fb38bbb9cf39fa1f533ca6c2c981a3", + "blockHash": "0x2434d5c902a0d85ddd11b80d0005105a492883a6db160729aa6ff563fa6be653", + "blockNumber": "0x2813a40", + "transactionIndex": "0x0", + "from": "0x0a0d9bc4dda3a659699ce05153ffe9298f99ce89", + "gasPrice": "0x23c34600", + "blockTimestamp": "0x6abda5e4" +} diff --git a/crates/evm/core/tests/base_replay.rs b/crates/evm/core/tests/base_replay.rs new file mode 100644 index 0000000000000..80e4bb2cbb390 --- /dev/null +++ b/crates/evm/core/tests/base_replay.rs @@ -0,0 +1,467 @@ +use alloy_consensus::{ + Sealable, + transaction::{Recovered, SignerRecoverable}, +}; +use alloy_eips::eip2718::{Decodable2718, Encodable2718}; +use alloy_evm::{Evm, EvmEnv, FromRecoveredTx}; +use alloy_network::{AnyRpcTransaction, AnyTxEnvelope, UnknownTxEnvelope}; +use alloy_primitives::{Address, B256, Bytes, TxKind, U256, hex}; +use alloy_rpc_types::Transaction as RpcTransaction; +use alloy_serde::WithOtherFields; +use alloy_signer::SignerSync; +use alloy_signer_local::PrivateKeySigner; +use base_common_consensus::{ + BaseTransactionInfo, BaseTxEnvelope, Eip8130Signed, Predeploys, TxDeposit, TxEip8130, +}; +use base_common_evm::{ + BaseEvmFactory, BaseHaltReason, BaseSpecId, BaseTransaction, BaseUpgrade, Eip8130ExecutionMode, + L1BlockInfo, +}; +use base_common_rpc_types::Transaction; +use foundry_evm_core::{ + FoundryBlock, FoundryTransaction, FromAnyRpcTransaction, + backend::Backend, + evm::{BaseEvmNetwork, FoundryEvmFactory}, + fork::MultiFork, + utils::get_blob_base_fee_update_fraction, +}; +use revm::{ + Database, + context::{BlockEnv, CfgEnv, TxEnv}, + inspector::NoOpInspector, + state::{AccountInfo, Bytecode}, +}; +use serde::Deserialize; +use std::str::FromStr; + +const AZUL_FIXTURE: &str = include_str!("fixtures/base/azul-transfer.json"); +const BERYL_FIXTURE: &str = include_str!("fixtures/base/beryl-transfer.json"); +const JOVIAN_FIXTURE: &str = include_str!("fixtures/base/jovian-transfer.json"); + +#[derive(Debug, Deserialize)] +struct ReplayFixture { + chain_id: String, + upgrade: String, + block: FixtureBlock, + transaction: FixtureTransaction, + parent_state: FixtureParentState, + expected: FixtureExpected, +} + +#[derive(Debug, Deserialize)] +struct FixtureBlock { + number: String, + timestamp: String, + gas_limit: String, + base_fee_per_gas: String, + beneficiary: String, + prevrandao: String, + excess_blob_gas: String, + receipts_root: String, + state_root: String, +} + +#[derive(Debug, Deserialize)] +struct FixtureTransaction { + hash: String, + raw: String, + from: String, + to: String, + value: String, +} + +#[derive(Debug, Deserialize)] +struct FixtureParentState { + caller_balance: String, + caller_nonce: String, + recipient_balance: String, + recipient_code: String, + l1_block_info_storage: Vec<(String, String)>, +} + +#[derive(Debug, Deserialize)] +struct FixtureExpected { + status: bool, + gas_used: String, + effective_gas_price: String, + l1_gas_used: String, + l1_fee: String, + log_count: usize, +} + +fn fixture(data: &str) -> ReplayFixture { + serde_json::from_str(data).expect("valid Base replay fixture") +} + +fn u256(value: &str) -> U256 { + U256::from_str(value).expect("valid U256 fixture value") +} + +fn u64_hex(value: &str) -> u64 { + u64::from_str_radix(value.trim_start_matches("0x"), 16).expect("valid u64 fixture value") +} + +fn bytes(value: &str) -> Bytes { + hex::decode(value.trim_start_matches("0x")).expect("valid byte fixture").into() +} + +fn in_memory_base_backend() -> Backend { + let (forks, _fork_handler) = MultiFork::new(); + Backend::new(forks, None).expect("in-memory Base backend") +} + +fn seed_fixture_backend(fixture: &ReplayFixture) -> Backend { + let mut db = in_memory_base_backend(); + let caller = fixture.transaction.from.parse().unwrap(); + let recipient = fixture.transaction.to.parse().unwrap(); + + db.insert_account_info( + caller, + AccountInfo { + balance: u256(&fixture.parent_state.caller_balance), + nonce: u64_hex(&fixture.parent_state.caller_nonce), + ..Default::default() + }, + ); + assert_eq!(fixture.parent_state.recipient_code, "0x"); + db.insert_account_info( + recipient, + AccountInfo { + balance: u256(&fixture.parent_state.recipient_balance), + ..Default::default() + }, + ); + db.insert_account_info(Predeploys::L1_BLOCK_INFO, AccountInfo::default()); + for (slot, value) in &fixture.parent_state.l1_block_info_storage { + db.insert_account_storage(Predeploys::L1_BLOCK_INFO, u256(slot), u256(value)) + .expect("insert L1BlockInfo storage"); + } + db +} + +fn fixture_env(fixture: &ReplayFixture, upgrade: BaseUpgrade) -> EvmEnv { + let chain_id = u64_hex(&fixture.chain_id); + let timestamp = u64_hex(&fixture.block.timestamp); + let mut cfg = CfgEnv::new_with_spec(BaseSpecId::new(upgrade)); + cfg.chain_id = chain_id; + + let mut block = BlockEnv::default(); + block.set_number(u256(&fixture.block.number)); + block.set_timestamp(U256::from(timestamp)); + block.set_gas_limit(u64_hex(&fixture.block.gas_limit)); + block.set_basefee(u64_hex(&fixture.block.base_fee_per_gas)); + block.set_beneficiary(fixture.block.beneficiary.parse().unwrap()); + block.set_prevrandao(Some(fixture.block.prevrandao.parse().unwrap())); + block.set_blob_excess_gas_and_price( + u64_hex(&fixture.block.excess_blob_gas), + get_blob_base_fee_update_fraction(chain_id, timestamp), + ); + EvmEnv::new(cfg, block) +} + +fn decode_fixture_transaction( + fixture: &ReplayFixture, +) -> (Bytes, BaseTxEnvelope, BaseTransaction) { + let raw = bytes(&fixture.transaction.raw); + let envelope = + BaseTxEnvelope::decode_2718(&mut raw.as_ref()).expect("decode Base transaction envelope"); + assert_eq!(envelope.encoded_2718(), raw); + assert_eq!(*envelope.tx_hash(), fixture.transaction.hash.parse::().unwrap()); + + let signer = envelope.recover_signer().expect("recover Base transaction signer"); + assert_eq!(signer, fixture.transaction.from.parse::
().unwrap()); + let tx = BaseTransaction::from_recovered_tx(&envelope, signer); + assert_eq!(tx.enveloped_tx(), Some(&raw)); + (raw, envelope, tx) +} + +fn balance(db: &mut Backend, address: Address) -> U256 { + db.basic(address).unwrap().unwrap_or_default().balance +} + +fn nonce(db: &mut Backend, address: Address) -> u64 { + db.basic(address).unwrap().unwrap_or_default().nonce +} + +fn replay_transfer_fixture(fixture: ReplayFixture) { + let upgrade = BaseUpgrade::from_str(&fixture.upgrade).expect("valid Base fixture upgrade"); + assert_ne!(fixture.block.receipts_root.parse::().unwrap(), B256::ZERO); + assert_ne!(fixture.block.state_root.parse::().unwrap(), B256::ZERO); + let (raw, _envelope, tx) = decode_fixture_transaction(&fixture); + let mut db = seed_fixture_backend(&fixture); + let spec = BaseSpecId::new(upgrade); + let block_number = u256(&fixture.block.number); + + let mut l1_block_info = + L1BlockInfo::try_fetch(&mut db, block_number, spec).expect("load L1BlockInfo"); + assert_eq!(l1_block_info.data_gas(&raw, spec), u256(&fixture.expected.l1_gas_used)); + assert_eq!(l1_block_info.calculate_tx_l1_cost(&raw, spec), u256(&fixture.expected.l1_fee)); + + let mut evm = BaseEvmFactory::default().create_foundry_evm_with_inspector( + &mut db, + fixture_env(&fixture, upgrade), + NoOpInspector, + ); + let result = evm.transact_commit(tx).expect("replay Base transfer"); + assert_eq!(result.is_success(), fixture.expected.status); + assert_eq!(result.tx_gas_used(), u64_hex(&fixture.expected.gas_used)); + assert_eq!(result.logs().len(), fixture.expected.log_count); + drop(evm); + + let caller = fixture.transaction.from.parse().unwrap(); + let recipient = fixture.transaction.to.parse().unwrap(); + let gas_fee = u256(&fixture.expected.effective_gas_price) * U256::from(result.tx_gas_used()); + let expected_caller = u256(&fixture.parent_state.caller_balance) + - u256(&fixture.transaction.value) + - gas_fee + - u256(&fixture.expected.l1_fee); + assert_eq!(balance(&mut db, caller), expected_caller); + assert_eq!( + balance(&mut db, recipient), + u256(&fixture.parent_state.recipient_balance) + u256(&fixture.transaction.value) + ); + assert_eq!(nonce(&mut db, caller), u64_hex(&fixture.parent_state.caller_nonce) + 1); + assert_eq!(balance(&mut db, Predeploys::L1_FEE_VAULT), u256(&fixture.expected.l1_fee)); + assert_eq!( + balance(&mut db, Predeploys::BASE_FEE_VAULT), + u256(&fixture.block.base_fee_per_gas) * U256::from(result.tx_gas_used()) + ); + assert_eq!(balance(&mut db, Predeploys::OPERATOR_FEE_VAULT), U256::ZERO); +} + +#[test] +fn replays_azul_transfer_with_wire_fees_and_state_deltas() { + replay_transfer_fixture(fixture(AZUL_FIXTURE)); +} + +#[test] +fn replays_jovian_transfer_with_wire_fees_and_state_deltas() { + replay_transfer_fixture(fixture(JOVIAN_FIXTURE)); +} + +#[test] +fn replays_beryl_transfer_with_wire_fees_and_state_deltas() { + replay_transfer_fixture(fixture(BERYL_FIXTURE)); +} + +#[test] +fn replays_beryl_operator_fee_charge_and_refund() { + let fixture = fixture(BERYL_FIXTURE); + let (_raw, _envelope, tx) = decode_fixture_transaction(&fixture); + let mut db = seed_fixture_backend(&fixture); + let mut evm = BaseEvmFactory::default().create_foundry_evm_with_inspector( + &mut db, + fixture_env(&fixture, BaseUpgrade::Beryl), + NoOpInspector, + ); + evm.ctx_mut().chain = L1BlockInfo { + l2_block: Some(u256(&fixture.block.number)), + operator_fee_scalar: Some(U256::from(2)), + operator_fee_constant: Some(U256::from(5)), + ..Default::default() + }; + + let result = evm.transact_commit(tx).expect("replay Base transfer with operator fee"); + assert!(result.is_success()); + let operator_fee = U256::from(result.tx_gas_used()) * U256::from(200) + U256::from(5); + drop(evm); + + let caller = fixture.transaction.from.parse().unwrap(); + let expected_caller = u256(&fixture.parent_state.caller_balance) + - u256(&fixture.transaction.value) + - u256(&fixture.expected.effective_gas_price) * U256::from(result.tx_gas_used()) + - operator_fee; + assert_eq!(balance(&mut db, caller), expected_caller); + assert_eq!(balance(&mut db, Predeploys::OPERATOR_FEE_VAULT), operator_fee); + assert_eq!(balance(&mut db, Predeploys::L1_FEE_VAULT), U256::ZERO); +} + +fn deposit_envelope(to: Address) -> BaseTxEnvelope { + BaseTxEnvelope::Deposit( + TxDeposit { + source_hash: B256::repeat_byte(0x11), + from: Address::repeat_byte(0xaa), + to: TxKind::Call(to), + mint: 1_000, + value: U256::from(600), + gas_limit: 100_000, + is_system_transaction: false, + input: Bytes::new(), + } + .seal_slow(), + ) +} + +fn simple_base_env(upgrade: BaseUpgrade) -> EvmEnv { + let mut cfg = CfgEnv::new_with_spec(BaseSpecId::new(upgrade)); + cfg.chain_id = 8453; + let mut block = BlockEnv::default(); + block.set_number(U256::ONE); + block.set_timestamp(U256::ONE); + block.set_gas_limit(30_000_000); + block.set_basefee(1_000_000); + EvmEnv::new(cfg, block) +} + +#[test] +fn replays_successful_and_failed_base_deposits() { + let sender = Address::repeat_byte(0xaa); + let recipient = Address::repeat_byte(0xbb); + let envelope = deposit_envelope(recipient); + let raw: Bytes = envelope.encoded_2718().into(); + assert_eq!(raw[0], 0x7e); + assert_eq!( + L1BlockInfo::default().calculate_tx_l1_cost(&raw, BaseSpecId::new(BaseUpgrade::Beryl)), + U256::ZERO + ); + + let mut db = in_memory_base_backend(); + let tx = BaseTransaction::from_recovered_tx(&envelope, sender); + assert_eq!(tx.enveloped_tx(), Some(&raw)); + let mut evm = BaseEvmFactory::default().create_foundry_evm_with_inspector( + &mut db, + simple_base_env(BaseUpgrade::Beryl), + NoOpInspector, + ); + evm.ctx_mut().chain.l2_block = Some(U256::ONE); + let result = evm.transact_commit(tx).expect("execute Base deposit"); + assert!(result.is_success()); + assert_eq!(result.tx_gas_used(), 21_000); + drop(evm); + assert_eq!(balance(&mut db, sender), U256::from(400)); + assert_eq!(balance(&mut db, recipient), U256::from(600)); + assert_eq!(balance(&mut db, Predeploys::L1_FEE_VAULT), U256::ZERO); + + let reverter = Address::repeat_byte(0xcc); + let mut db = in_memory_base_backend(); + db.insert_account_info( + reverter, + AccountInfo::default().with_code(Bytecode::new_raw(Bytes::from_static(&[0xfe]))), + ); + let envelope = deposit_envelope(reverter); + let tx = BaseTransaction::from_recovered_tx(&envelope, sender); + let mut evm = BaseEvmFactory::default().create_foundry_evm_with_inspector( + &mut db, + simple_base_env(BaseUpgrade::Beryl), + NoOpInspector, + ); + evm.ctx_mut().chain.l2_block = Some(U256::ONE); + let result = evm.transact_commit(tx).expect("execute failed Base deposit"); + assert!(matches!( + result, + revm::context::result::ExecutionResult::Halt { reason: BaseHaltReason::FailedDeposit, .. } + )); + assert_eq!(result.tx_gas_used(), 100_000); + drop(evm); + assert_eq!(balance(&mut db, sender), U256::from(1_000)); + assert_eq!(balance(&mut db, reverter), U256::ZERO); + assert_eq!(nonce(&mut db, sender), 1); +} + +fn eip8130_transaction(signer: &PrivateKeySigner) -> (BaseTxEnvelope, BaseTransaction) { + let tx = TxEip8130 { + chain_id: 8453, + sender: None, + nonce_key: U256::ZERO, + nonce_sequence: 0, + valid_after: 0, + valid_before: 0, + max_priority_fee_per_gas: 0, + max_fee_per_gas: 1_000_000_000, + gas_limit: 200_000, + account_changes: Vec::new(), + calls: Vec::new(), + metadata: Bytes::new(), + payer: None, + }; + let signature = signer.sign_hash_sync(&tx.sender_signature_hash()).unwrap(); + let signed = Eip8130Signed::new(tx, signature.as_bytes().into(), Bytes::new()); + let envelope = BaseTxEnvelope::Eip8130(signed); + let base_tx = BaseTransaction::from_recovered_tx(&envelope, signer.address()); + (envelope, base_tx) +} + +fn eip8130_backend(sender: Address) -> Backend { + let mut db = in_memory_base_backend(); + db.insert_account_info( + sender, + AccountInfo { balance: U256::from(10u64).pow(U256::from(18)), ..Default::default() }, + ); + db +} + +#[test] +fn simulates_and_commits_eip8130_without_placeholder_txenv() { + let signer = PrivateKeySigner::from_bytes(&B256::with_last_byte(1)).unwrap(); + let sender = signer.address(); + let (envelope, tx) = eip8130_transaction(&signer); + let raw: Bytes = envelope.encoded_2718().into(); + assert_eq!(raw[0], 0x79); + assert_eq!(tx.enveloped_tx(), Some(&raw)); + assert!(tx.eip8130.is_some()); + + let mut simulation_tx = tx.clone(); + simulation_tx.eip8130.as_mut().unwrap().mode = Eip8130ExecutionMode::Simulate; + let mut simulation_db = eip8130_backend(sender); + let initial_balance = balance(&mut simulation_db, sender); + let mut simulation_evm = BaseEvmFactory::default().create_foundry_evm_with_inspector( + &mut simulation_db, + simple_base_env(BaseUpgrade::Zenith), + NoOpInspector, + ); + simulation_evm.ctx_mut().chain.l2_block = Some(U256::ONE); + let simulation_result = + simulation_evm.transact_commit(simulation_tx).expect("simulate EIP-8130"); + assert!(simulation_result.is_success()); + assert!(simulation_result.tx_gas_used() > 0); + drop(simulation_evm); + assert_eq!(balance(&mut simulation_db, sender), initial_balance); + assert_eq!(nonce(&mut simulation_db, sender), 0); + + let mut db = eip8130_backend(sender); + let mut evm = BaseEvmFactory::default().create_foundry_evm_with_inspector( + &mut db, + simple_base_env(BaseUpgrade::Zenith), + NoOpInspector, + ); + evm.ctx_mut().chain.l2_block = Some(U256::ONE); + let result = evm.transact_commit(tx.clone()).expect("commit EIP-8130"); + assert!(result.is_success()); + assert!(result.tx_gas_used() > 0); + drop(evm); + assert!(balance(&mut db, sender) < initial_balance); + + let mut replay_evm = BaseEvmFactory::default().create_foundry_evm_with_inspector( + &mut db, + simple_base_env(BaseUpgrade::Zenith), + NoOpInspector, + ); + replay_evm.ctx_mut().chain.l2_block = Some(U256::ONE); + assert!(replay_evm.transact_commit(tx).is_err(), "protocol nonce replay must fail"); +} + +#[test] +fn converts_eip8130_from_any_rpc_transaction() { + let signer = PrivateKeySigner::from_bytes(&B256::with_last_byte(2)).unwrap(); + let (envelope, expected) = eip8130_transaction(&signer); + let hash = *envelope.tx_hash(); + let rpc_tx = Transaction::from_transaction( + Recovered::new_unchecked(envelope, signer.address()), + BaseTransactionInfo::default(), + ); + let mut rpc_value = serde_json::to_value(rpc_tx).unwrap(); + rpc_value + .as_object_mut() + .unwrap() + .insert("hash".to_string(), serde_json::to_value(hash).unwrap()); + let unknown = serde_json::from_value::(rpc_value).unwrap(); + let any_tx = RpcTransaction::from_transaction( + Recovered::new_unchecked(AnyTxEnvelope::Unknown(unknown), signer.address()), + Default::default(), + ); + let any = AnyRpcTransaction::new(WithOtherFields::new(any_tx)); + + let converted = BaseTransaction::::from_any_rpc_transaction(&any).unwrap(); + assert_eq!(converted.enveloped_tx(), expected.enveloped_tx()); + assert_eq!(converted.eip8130, expected.eip8130); +} diff --git a/crates/evm/core/tests/fixtures/base/README.md b/crates/evm/core/tests/fixtures/base/README.md new file mode 100644 index 0000000000000..cac7ef95ef868 --- /dev/null +++ b/crates/evm/core/tests/fixtures/base/README.md @@ -0,0 +1,15 @@ +# Base replay fixtures + +These fixtures contain the minimum parent state and wire transaction bytes needed for deterministic +Base EVM replay. They are captured once from public Base RPC data and do not contact RPC endpoints +during tests. + +`jovian-transfer.json`, `azul-transfer.json`, and `beryl-transfer.json` were captured from Base +mainnet blocks `0x2bf82ef`, `0x2cec52f`, and `0x2f4a86c` on 2026-08-05. Raw transactions came from +`debug_getRawTransaction`; account balances, nonces, and L1BlockInfo storage were read at each +parent block; expected execution fields came from `eth_getTransactionReceipt`. + +Fixtures intentionally include sampled parent state rather than a full state trie. Tests may assert +the transaction result, gas, logs, fee-vault credits, and touched-account deltas, but must not claim +the canonical block state or receipt root unless the complete block state and transaction list are +included. diff --git a/crates/evm/core/tests/fixtures/base/azul-transfer.json b/crates/evm/core/tests/fixtures/base/azul-transfer.json new file mode 100644 index 0000000000000..22d79fa3f9525 --- /dev/null +++ b/crates/evm/core/tests/fixtures/base/azul-transfer.json @@ -0,0 +1,43 @@ +{ + "chain_id": "0x2105", + "upgrade": "Azul", + "block": { + "number": "0x2cec52f", + "timestamp": "0x6a27e741", + "gas_limit": "0x17d78400", + "base_fee_per_gas": "0x4c4b40", + "beneficiary": "0x4200000000000000000000000000000000000011", + "prevrandao": "0x04fbdf553b3ad1934509230ac0e921f9be5d37234b37f4ec2b58910f235bbd63", + "excess_blob_gas": "0x0", + "parent_beacon_block_root": "0xcd3ba43fbc5bf8ce774016b7d46375b5e00b4990beea138a8152f5c8dc292006", + "receipts_root": "0x803abb6d7b0bea097ee972ec819a378f6f534606d5d276f6e6bf98d9e5b6461e", + "state_root": "0x5f25c26c6cb53289a050f880fbf48f668ce944bbe4592917dcf2c17859c1c450" + }, + "transaction": { + "hash": "0x1304308f42386396b1a2607e22616eabc82506d5db3c81dc0d112e57ca3e3b33", + "raw": "0x02f87382210582028e835b8d8083c28cb0825208942d32b281144421abcd351a767269a9cbdc16fa6f8720fca886bd500080c080a0232c1d3e199f83911b457d97b3ce8be5b69050d097720f604eb93c7d762395faa06b5c2136bd6edab1c049ac187b9907ee000ccdbb4033f4c56a4a516638e03612", + "from": "0x77dfd3391d071e0d42cfb6682512bfb65735cae1", + "to": "0x2d32b281144421abcd351a767269a9cbdc16fa6f", + "value": "0x20fca886bd5000" + }, + "parent_state": { + "caller_balance": "0x21ac628ea7b40c", + "caller_nonce": "0x28e", + "recipient_balance": "0x144200844c29", + "recipient_code": "0x", + "l1_block_info_storage": [ + ["0x1", "0x000000000000000000000000000000000000000000000000000000000754caea"], + ["0x3", "0x00000000000000000000000000000000000008dd00101c120000000000000000"], + ["0x7", "0x00000000000000000000000000000000000000000000000000000000006b3409"], + ["0x8", "0x0000000000000000000000000000000000000094000000000000000000000000"] + ] + }, + "expected": { + "status": true, + "gas_used": "0x5208", + "effective_gas_price": "0xa7d8c0", + "l1_gas_used": "0x640", + "l1_fee": "0x46d39efc", + "log_count": 0 + } +} diff --git a/crates/evm/core/tests/fixtures/base/beryl-transfer.json b/crates/evm/core/tests/fixtures/base/beryl-transfer.json new file mode 100644 index 0000000000000..a042e0113e7fc --- /dev/null +++ b/crates/evm/core/tests/fixtures/base/beryl-transfer.json @@ -0,0 +1,43 @@ +{ + "chain_id": "0x2105", + "upgrade": "Beryl", + "block": { + "number": "0x2f4a86c", + "timestamp": "0x6a73adbb", + "gas_limit": "0x17d78400", + "base_fee_per_gas": "0x4c4b40", + "beneficiary": "0x4200000000000000000000000000000000000011", + "prevrandao": "0x0cda1d64ddf6cc46d3fac7eeb694170cbc526ab6d7f19413e24a72371f000373", + "excess_blob_gas": "0x0", + "parent_beacon_block_root": "0x7f1d71c6435bca667b35230362349a86d9c944c8c66ae1c73e991e0b6788ad7a", + "receipts_root": "0x43c0efac7d601d05e6c5aeb019396fe41915a9ea430c20141f3bfb36f6306c40", + "state_root": "0xd1751057c179aedf082a270a2f630930c624ed1d787246235b07363170118af3" + }, + "transaction": { + "hash": "0x6d05122b7b96dd136db5fbd32d06f43d419e370eb7b937da8490f42ee53f6b8e", + "raw": "0x02f872822105823492830f424083a7d8c0825208944142fae9016da79039766351c67013544c9604a4860fd1f76b6e1680c001a0df62c066f583599b69dd50cecf0f8bb65f584c2d26b8b4a7a1a7d1d3e833f953a03b2db2297738bd498cab80e66b5beddea7fde1978679740beae23dbb1ac25fa1", + "from": "0x3f4e979d1b11f4a2e9c52b83e7e0f8f2fb8e9ec3", + "to": "0x4142fae9016da79039766351c67013544c9604a4", + "value": "0xfd1f76b6e16" + }, + "parent_state": { + "caller_balance": "0x8e1b517c7801", + "caller_nonce": "0x3492", + "recipient_balance": "0x20a70521fa8e", + "recipient_code": "0x", + "l1_block_info_storage": [ + ["0x1", "0x0000000000000000000000000000000000000000000000000000000007fc9e4b"], + ["0x3", "0x00000000000000000000000000000000000008dd00101c120000000000000002"], + ["0x7", "0x0000000000000000000000000000000000000000000000000000000000784987"], + ["0x8", "0x0000000000000000000000000000000000000094000000000000000000000000"] + ] + }, + "expected": { + "status": true, + "gas_used": "0x5208", + "effective_gas_price": "0x5b8d80", + "l1_gas_used": "0x640", + "l1_fee": "0x4e9a3fe4", + "log_count": 0 + } +} diff --git a/crates/evm/core/tests/fixtures/base/jovian-transfer.json b/crates/evm/core/tests/fixtures/base/jovian-transfer.json new file mode 100644 index 0000000000000..85ad8c07f35d3 --- /dev/null +++ b/crates/evm/core/tests/fixtures/base/jovian-transfer.json @@ -0,0 +1,43 @@ +{ + "chain_id": "0x2105", + "upgrade": "Jovian", + "block": { + "number": "0x2bf82ef", + "timestamp": "0x6a0962c1", + "gas_limit": "0x17d78400", + "base_fee_per_gas": "0x4c4b40", + "beneficiary": "0x4200000000000000000000000000000000000011", + "prevrandao": "0x9219110ff1635fa6bc52a073ed7ddd139eb2768ad02585acabb3204ca7a8ee77", + "excess_blob_gas": "0x0", + "parent_beacon_block_root": "0x4fe0ecb40aefa1691fb36b87dda0cee747b27030465c28ff8f5ee51796dacc85", + "receipts_root": "0xd451d2a3af6cfc6985d2919d6dae871790420642201e91ec20ccf7f07f9de7ba", + "state_root": "0x289628c5fa503e60838930880925807314bdb08e49dab41d2cb0035fd89a85b3" + }, + "transaction": { + "hash": "0xae293444da78703ad63bf2517f8f083770fff67e79dcce72279e0a503748c86c", + "raw": "0x02f86e8221055e830f4240838b04eb826aa494a2baf0592072bd884d0b2ece27f88c29ec18dd1b843b9aca0080c001a0e046d4b6295ce98ad0bcd8ece87deb34c49db3b34e38359b5095252000b99329a03b6bcd33274e14607f3362ccc1cb9d0ea10d5de2ec2a7e468adc0cf873e18cb4", + "from": "0x89583a150b8c7f6b3405e3aa35a03dd77bd806b4", + "to": "0xa2baf0592072bd884d0b2ece27f88c29ec18dd1b", + "value": "0x3b9aca00" + }, + "parent_state": { + "caller_balance": "0x1fc9c3b78abf", + "caller_nonce": "0x5e", + "recipient_balance": "0x301f3fc354906b1", + "recipient_code": "0x", + "l1_block_info_storage": [ + ["0x1", "0x0000000000000000000000000000000000000000000000000000000006b6114e"], + ["0x3", "0x00000000000000000000000000000000000008dd00101c120000000000000000"], + ["0x7", "0x000000000000000000000000000000000000000000000000000000000052ca80"], + ["0x8", "0x0000000000000000000000000000000000000094000000000000000000000000"] + ] + }, + "expected": { + "status": true, + "gas_used": "0x5208", + "effective_gas_price": "0x5b8d80", + "l1_gas_used": "0x640", + "l1_fee": "0x3a82088e", + "log_count": 0 + } +} diff --git a/crates/evm/core/tests/tempo.rs b/crates/evm/core/tests/tempo.rs new file mode 100644 index 0000000000000..842652869904b --- /dev/null +++ b/crates/evm/core/tests/tempo.rs @@ -0,0 +1,233 @@ +use alloy_evm::{Evm, EvmEnv, FromRecoveredTx}; +use alloy_primitives::{Address, Bytes, TxKind, U256}; +use alloy_signer::Signer; +use alloy_signer_local::PrivateKeySigner; +use alloy_sol_types::SolCall; +use foundry_evm_core::{ + backend::Backend, + evm::{FoundryEvmFactory, TempoEvmNetwork}, + fork::MultiFork, +}; +use revm::{ + Inspector, + context::result::{ExecutionResult, HaltReason}, + inspector::NoOpInspector, + state::{AccountInfo, Bytecode}, +}; +use tempo_alloy::primitives::TempoTxEnvelope; +use tempo_evm::{TempoBlockEnv, TempoEvmFactory}; +use tempo_hardfork::TempoHardfork; +use tempo_precompiles::{ + ACCOUNT_KEYCHAIN_ADDRESS, DEFAULT_FEE_TOKEN, + account_keychain::{ + KeyRestrictions, SignatureType as KeychainSignatureType, TokenLimit as KeychainTokenLimit, + authorizeKeyCall, + }, + storage::{StorageActions, StorageCtx}, + storage_credits::StorageCredits, + tip20::{ITIP20, TIP20Token}, +}; +use tempo_primitives::{ + TempoSignature, TempoTransaction, + transaction::{Call, KeychainSignature, PrimitiveSignature, calc_gas_balance_spending}, +}; +use tempo_revm::{TempoTxEnv, gas_params::tempo_gas_params}; + +const GAS_LIMIT: u64 = 500_000; +const GAS_PRICE: u128 = 1_000_000_000_000; +const TRANSFER_AMOUNT: u64 = 1_234; + +#[tokio::test] +async fn nested_tempo_execution_preserves_halt_reason() { + let account = PrivateKeySigner::random(); + let target = Address::repeat_byte(0xee); + + for (opcode, expected) in + [(0x0c, HaltReason::OpcodeNotFound), (0x50, HaltReason::StackUnderflow)] + { + let mut db = in_memory_tempo_backend(); + seed_fee_token_balances(&mut db, account.address(), target); + db.insert_account_info( + target, + AccountInfo::default().with_code(Bytecode::new_raw(Bytes::from(vec![opcode]))), + ); + let evm_env = EvmEnv::new( + revm::context::CfgEnv::::default() + .with_spec_and_gas_params(TempoHardfork::T11, tempo_gas_params(TempoHardfork::T11)), + TempoBlockEnv::default(), + ); + let mut evm = TempoEvmFactory::default().create_nested_evm(&mut db, evm_env); + let tx = primitive_tx( + &account, + TempoTransaction { + chain_id: 1, + fee_token: Some(DEFAULT_FEE_TOKEN), + gas_limit: GAS_LIMIT, + calls: vec![Call { + to: TxKind::Call(target), + value: U256::ZERO, + input: Bytes::new(), + }], + ..Default::default() + }, + ) + .await; + + let result = evm.transact_raw(tx).expect("nested transaction executes"); + assert!( + matches!(result.result, ExecutionResult::Halt { reason, .. } if reason == expected) + ); + } +} + +fn in_memory_tempo_backend() -> Backend { + let (forks, _fork_handler) = MultiFork::new(); + Backend::new(forks, None).unwrap() +} + +fn seed_fee_token_balances( + db: &mut Backend, + account: Address, + recipient: Address, +) { + db.insert_account_info( + account, + AccountInfo { balance: U256::from(1_000_000_000_000_000_000u128), ..Default::default() }, + ); + let initial_balance = + calc_gas_balance_spending(GAS_LIMIT, GAS_PRICE) + U256::from(TRANSFER_AMOUNT); + let token = TIP20Token::from_address_unchecked(DEFAULT_FEE_TOKEN); + db.insert_account_storage(DEFAULT_FEE_TOKEN, token.balances[account].slot(), initial_balance) + .unwrap(); + db.insert_account_storage(DEFAULT_FEE_TOKEN, token.balances[recipient].slot(), U256::ONE) + .unwrap(); +} + +fn storage_credit_balance(evm: &mut tempo_evm::evm::TempoEvm, owner: Address) -> u64 +where + DB: alloy_evm::Database, + I: Inspector>, +{ + let ctx = evm.ctx_mut(); + StorageCtx::enter_evm( + &mut ctx.journaled_state, + &ctx.block, + &ctx.cfg, + &ctx.tx, + StorageActions::disabled(), + || StorageCredits::new().balance_of(owner).unwrap(), + ) +} + +async fn primitive_tx(account: &PrivateKeySigner, tx: TempoTransaction) -> TempoTxEnv { + let signature = account.sign_hash(&tx.signature_hash()).await.unwrap(); + let envelope = TempoTxEnvelope::AA(tx.into_signed(signature.into())); + TempoTxEnv::from_recovered_tx(&envelope, account.address()) +} + +async fn keychain_spend_setup() -> (Address, Address, TempoTxEnv, TempoTxEnv) { + let root = PrivateKeySigner::random(); + let access_key = PrivateKeySigner::random(); + let recipient = Address::repeat_byte(0xee); + let spending_limit = + calc_gas_balance_spending(GAS_LIMIT, GAS_PRICE) + U256::from(TRANSFER_AMOUNT); + + let authorize = authorizeKeyCall { + keyId: access_key.address(), + signatureType: KeychainSignatureType::Secp256k1, + config: KeyRestrictions { + expiry: u64::MAX, + enforceLimits: true, + limits: vec![KeychainTokenLimit { + token: DEFAULT_FEE_TOKEN, + amount: spending_limit, + period: 0, + }], + allowAnyCalls: true, + allowedCalls: vec![], + }, + }; + let auth_tx = primitive_tx( + &root, + TempoTransaction { + chain_id: 1, + fee_token: Some(DEFAULT_FEE_TOKEN), + max_priority_fee_per_gas: 0, + max_fee_per_gas: 0, + gas_limit: 2_000_000, + calls: vec![Call { + to: TxKind::Call(ACCOUNT_KEYCHAIN_ADDRESS), + value: U256::ZERO, + input: authorize.abi_encode().into(), + }], + access_list: Default::default(), + nonce_key: U256::ZERO, + nonce: 0, + fee_payer_signature: None, + valid_before: None, + valid_after: None, + key_authorization: None, + tempo_authorization_list: vec![], + }, + ) + .await; + + let spend_tx = TempoTransaction { + chain_id: 1, + fee_token: Some(DEFAULT_FEE_TOKEN), + max_priority_fee_per_gas: GAS_PRICE, + max_fee_per_gas: GAS_PRICE, + gas_limit: GAS_LIMIT, + calls: vec![Call { + to: TxKind::Call(DEFAULT_FEE_TOKEN), + value: U256::ZERO, + input: ITIP20::transferCall { to: recipient, amount: U256::from(TRANSFER_AMOUNT) } + .abi_encode() + .into(), + }], + access_list: Default::default(), + nonce_key: U256::ZERO, + nonce: 1, + fee_payer_signature: None, + valid_before: None, + valid_after: None, + key_authorization: None, + tempo_authorization_list: vec![], + }; + let keychain_hash = KeychainSignature::signing_hash(spend_tx.signature_hash(), root.address()); + let access_signature = access_key.sign_hash(&keychain_hash).await.unwrap(); + let envelope = TempoTxEnvelope::AA(spend_tx.into_signed(TempoSignature::Keychain( + KeychainSignature::new(root.address(), PrimitiveSignature::Secp256k1(access_signature)), + ))); + let spend_env = TempoTxEnv::from_recovered_tx(&envelope, root.address()); + (spend_env.inner.caller, recipient, auth_tx, spend_env) +} + +#[tokio::test] +async fn foundry_factory_keychain_limit_refund_does_not_leak_storage_credit() { + let (account, recipient, auth_tx, spend_tx) = keychain_spend_setup().await; + let mut db = in_memory_tempo_backend(); + seed_fee_token_balances(&mut db, account, recipient); + + let evm_env = EvmEnv::new( + revm::context::CfgEnv::::default() + .with_spec_and_gas_params(TempoHardfork::T7, tempo_gas_params(TempoHardfork::T7)), + TempoBlockEnv::default(), + ); + let mut evm = TempoEvmFactory::default().create_foundry_evm_with_inspector( + &mut db, + evm_env, + NoOpInspector, + ); + + let auth_result = evm.transact_commit(auth_tx).expect("auth transaction executes"); + assert!(auth_result.is_success(), "auth transaction should succeed: {auth_result:?}"); + + let spend_result = evm.transact_commit(spend_tx).expect("spend transaction executes"); + assert!(spend_result.is_success(), "keychain spend should succeed: {spend_result:?}"); + assert_eq!( + storage_credit_balance(&mut evm, ACCOUNT_KEYCHAIN_ADDRESS), + 0, + "post-tx fee refund recreates the keychain spending-limit slot, so the same-tx clear credit must be canceled", + ); +} diff --git a/crates/evm/coverage/Cargo.toml b/crates/evm/coverage/Cargo.toml index e38d33e2a5907..27ceb809381a6 100644 --- a/crates/evm/coverage/Cargo.toml +++ b/crates/evm/coverage/Cargo.toml @@ -24,3 +24,10 @@ revm.workspace = true semver.workspace = true tracing.workspace = true rayon.workspace = true +solar.workspace = true + +[features] +default = ["optimism"] +monad = ["foundry-evm-core/monad"] +base = ["foundry-common/base", "foundry-evm-core/base"] +optimism = ["foundry-common/optimism", "foundry-evm-core/optimism"] diff --git a/crates/evm/coverage/src/analysis.rs b/crates/evm/coverage/src/analysis.rs index 4709a49cfbacd..563c898488726 100644 --- a/crates/evm/coverage/src/analysis.rs +++ b/crates/evm/coverage/src/analysis.rs @@ -1,550 +1,574 @@ use super::{CoverageItem, CoverageItemKind, SourceLocation}; -use alloy_primitives::map::HashMap; +use alloy_primitives::map::{HashMap, HashSet}; use foundry_common::TestFunctionExt; -use foundry_compilers::artifacts::{ - Source, - ast::{self, Ast, Node, NodeType}, -}; +use foundry_compilers::ProjectCompileOutput; use rayon::prelude::*; -use std::sync::Arc; +use solar::{ + ast::{self, ExprKind, ItemKind, StmtKind, yul}, + data_structures::{Never, map::FxHashMap}, + interface::{BytePos, Span}, + sema::{Gcx, hir}, +}; +use std::{ + ops::{ControlFlow, Range}, + path::PathBuf, + sync::Arc, +}; /// A visitor that walks the AST of a single contract and finds coverage items. -#[derive(Clone, Debug)] -pub struct ContractVisitor<'a> { +#[derive(Clone)] +struct SourceVisitor<'gcx> { /// The source ID of the contract. source_id: u32, - /// The source code that contains the AST being walked. - source: &'a str, + /// The solar session for span resolution. + gcx: Gcx<'gcx>, /// The name of the contract being walked. - contract_name: &'a Arc, + contract_name: Arc, /// The current branch ID branch_id: u32, - /// Stores the last line we put in the items collection to ensure we don't push duplicate lines - last_line: u32, /// Coverage items - pub items: Vec, + items: Vec, + + all_lines: Vec, + /// Branch IDs whose jumps must match the exact ternary expression span. + ternary_branches: Vec, + /// Deferred function-call spans, each paired with the contract scope active where the call + /// was collected, so scope travels with the span to the delayed HIR resolution pass. + function_calls: Vec<(Span, Arc)>, + function_call_scopes: FxHashMap>, +} + +struct SourceVisitorCheckpoint { + items: usize, + all_lines: usize, + function_calls: usize, + ternary_branches: usize, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum EmptySpecialFunctionKind { + Constructor, + Receive, + Fallback, +} + +struct ResolvedEmptySpecialFunction { + contract_source_id: u32, + contract_name: Arc, + function_source_id: u32, + function_contract_name: Arc, + function_bytes: Range, + kind: EmptySpecialFunctionKind, } -impl<'a> ContractVisitor<'a> { - pub fn new(source_id: usize, source: &'a str, contract_name: &'a Arc) -> Self { +fn resolve_empty_special_functions( + gcx: Gcx<'_>, + data: &SourceFiles, +) -> Vec { + let hir_source_ids = data + .sources + .iter() + .map(|(&source_id, path)| { + let (hir_source_id, _) = gcx.get_hir_source(path).unwrap(); + (hir_source_id, source_id) + }) + .collect::>(); + let mut resolved = Vec::new(); + for contract_id in gcx.hir.contract_ids() { + let contract = gcx.hir.contract(contract_id); + let Some(&contract_source_id) = hir_source_ids.get(&contract.source) else { continue }; + let constructors = contract.linearized_bases.iter().filter_map(|&base_id| { + gcx.hir + .contract(base_id) + .ctor + .map(|function_id| (function_id, EmptySpecialFunctionKind::Constructor)) + }); + let runtime_functions = [ + (contract.receive, EmptySpecialFunctionKind::Receive), + (contract.fallback, EmptySpecialFunctionKind::Fallback), + ] + .into_iter() + .filter_map(|(function_id, kind)| function_id.map(|function_id| (function_id, kind))); + for (function_id, kind) in constructors.chain(runtime_functions) { + let function = gcx.hir.function(function_id); + if !function.body.is_some_and(|body| body.is_empty()) { + continue; + } + let Some(&function_source_id) = hir_source_ids.get(&function.source) else { continue }; + let Some(function_contract_id) = function.contract else { continue }; + let function_contract = gcx.hir.contract(function_contract_id); + let function_bytes = gcx.sess.source_map().span_to_source(function.span).unwrap().data; + resolved.push(ResolvedEmptySpecialFunction { + contract_source_id, + contract_name: contract.name.as_str().into(), + function_source_id, + function_contract_name: function_contract.name.as_str().into(), + function_bytes: function_bytes.start as u32..function_bytes.end as u32, + kind, + }); + } + } + resolved +} + +impl<'gcx> SourceVisitor<'gcx> { + fn new(source_id: u32, gcx: Gcx<'gcx>) -> Self { Self { - source_id: source_id.try_into().expect("too many sources"), - source, - contract_name, + source_id, + gcx, + contract_name: Arc::default(), branch_id: 0, - last_line: 0, - items: Vec::new(), + ternary_branches: Default::default(), + all_lines: Default::default(), + function_calls: Default::default(), + function_call_scopes: Default::default(), + items: Default::default(), } } - pub fn visit_contract(&mut self, node: &Node) -> eyre::Result<()> { - // Find all functions and walk their AST - for node in &node.nodes { - match node.node_type { - NodeType::FunctionDefinition => { - self.visit_function_definition(node)?; - } - NodeType::ModifierDefinition => { - self.visit_modifier_or_yul_fn_definition(node)?; + const fn checkpoint(&self) -> SourceVisitorCheckpoint { + SourceVisitorCheckpoint { + items: self.items.len(), + all_lines: self.all_lines.len(), + function_calls: self.function_calls.len(), + ternary_branches: self.ternary_branches.len(), + } + } + + fn restore_checkpoint(&mut self, checkpoint: SourceVisitorCheckpoint) { + let SourceVisitorCheckpoint { items, all_lines, function_calls, ternary_branches } = + checkpoint; + self.items.truncate(items); + self.all_lines.truncate(all_lines); + self.function_calls.truncate(function_calls); + self.ternary_branches.truncate(ternary_branches); + } + + fn visit_contract<'ast>(&mut self, contract: &'ast ast::ItemContract<'ast>) { + let _ = ast::Visit::visit_item_contract(self, contract); + } + + /// Returns `true` if the contract has any test functions. + fn has_tests(&self, checkpoint: &SourceVisitorCheckpoint) -> bool { + self.items[checkpoint.items..].iter().any(|item| { + if let CoverageItemKind::Function { name } = &item.kind { + name.is_any_test() + } else { + false + } + }) + } + + /// Disambiguate overloaded functions that share a name within the same scope (a contract, or + /// the file level for free functions). Keyed by scope so a contract method and a same-named + /// free function are not treated as duplicates of each other. + fn disambiguate_functions(&mut self) { + let mut dups = HashMap::<_, Vec>::default(); + for (i, item) in self.items.iter().enumerate() { + if let CoverageItemKind::Function { name } = &item.kind { + dups.entry((item.loc.contract_name.clone(), name.clone())).or_default().push(i); + } + } + for dups in dups.values() { + if dups.len() > 1 { + for (i, &dup) in dups.iter().enumerate() { + let item = &mut self.items[dup]; + if let CoverageItemKind::Function { name } = &item.kind { + item.kind = + CoverageItemKind::Function { name: format!("{name}.{i}").into() }; + } } - _ => {} } } - Ok(()) } - fn visit_function_definition(&mut self, node: &Node) -> eyre::Result<()> { - let Some(body) = &node.body else { return Ok(()) }; + fn resolve_function_calls(&mut self, hir_source_id: hir::SourceId) { + self.function_call_scopes = self.function_calls.iter().cloned().collect(); + let _ = hir::Visit::visit_nested_source(self, hir_source_id); + } - let name: String = - node.attribute("name").ok_or_else(|| eyre::eyre!("Function has no name"))?; - let kind: String = - node.attribute("kind").ok_or_else(|| eyre::eyre!("Function has no kind"))?; + fn sort(&mut self) { + self.items.sort(); + } - // TODO: We currently can only detect empty bodies in normal functions, not any of the other - // kinds: https://github.com/foundry-rs/foundry/issues/9458 - if kind != "function" && !has_statements(body) { - return Ok(()); + fn push_lines(&mut self) { + self.all_lines.sort_unstable(); + self.all_lines.dedup(); + let mut lines = Vec::with_capacity(self.all_lines.len()); + // Items are already ordered by line, so advance through them only once. + let mut items = self.items.iter().peekable(); + for &line in &self.all_lines { + while items.peek().is_some_and(|item| item.loc.lines.start < line) { + items.next(); + } + if let Some(mut reference_item) = items.next_if(|item| item.loc.lines.start == line) { + // Anchor the line to its earliest source item, not the shortest span or a + // statement in a conditional body that may never execute. + while let Some(item) = items.next_if(|item| item.loc.lines.start == line) { + if item.loc.bytes.start < reference_item.loc.bytes.start { + reference_item = item; + } + } + lines.push(CoverageItem { + kind: CoverageItemKind::Line, + loc: reference_item.loc.clone(), + anchor_loc: None, + hits: 0, + }); + } } + self.items.extend(lines); + } - // `fallback`, `receive`, and `constructor` functions have an empty `name`. - // Use the `kind` itself as the name. - let name = if name.is_empty() { kind } else { name }; + fn push_stmt(&mut self, span: Span) { + self.push_item_kind(CoverageItemKind::Statement, span); + } - self.push_item_kind(CoverageItemKind::Function { name }, &node.src); - self.visit_block(body) + /// Creates a coverage item for a given kind and source location. Pushes item to the internal + /// collection (plus additional coverage line if item is a statement). + fn push_item_kind(&mut self, kind: CoverageItemKind, span: Span) -> &mut CoverageItem { + let item = + CoverageItem { kind, loc: self.source_location_for(span), anchor_loc: None, hits: 0 }; + + debug_assert!(!matches!(item.kind, CoverageItemKind::Line)); + self.all_lines.push(item.loc.lines.start); + + self.items.push(item); + self.items.last_mut().unwrap() } - fn visit_modifier_or_yul_fn_definition(&mut self, node: &Node) -> eyre::Result<()> { - let name: String = - node.attribute("name").ok_or_else(|| eyre::eyre!("Modifier has no name"))?; + fn source_location_for(&self, mut span: Span) -> SourceLocation { + // Statements' ranges in the solc source map do not include the semicolon. + if let Ok(snippet) = self.gcx.sess.source_map().span_to_snippet(span) + && let Some(stripped) = snippet.strip_suffix(';') + { + let stripped = stripped.trim_end(); + let skipped = snippet.len() - stripped.len(); + span = span.with_hi(span.hi() - BytePos::from_usize(skipped)); + } - match &node.body { - Some(body) => { - self.push_item_kind(CoverageItemKind::Function { name }, &node.src); - self.visit_block(body) - } - _ => Ok(()), + SourceLocation { + source_id: self.source_id as usize, + contract_name: self.contract_name.clone(), + bytes: self.byte_range(span), + lines: self.line_range(span), } } - fn visit_block(&mut self, node: &Node) -> eyre::Result<()> { - let statements: Vec = node.attribute("statements").unwrap_or_default(); + fn byte_range(&self, span: Span) -> Range { + let bytes_usize = self.gcx.sess.source_map().span_to_source(span).unwrap().data; + bytes_usize.start as u32..bytes_usize.end as u32 + } - for statement in &statements { - self.visit_statement(statement)?; - } + fn line_range(&self, span: Span) -> Range { + let lines = self.gcx.sess.source_map().span_to_lines(span).unwrap().data; + assert!(!lines.is_empty()); + let first = lines.first().unwrap(); + let last = lines.last().unwrap(); + first.line_index as u32 + 1..last.line_index as u32 + 2 + } - Ok(()) + const fn next_branch_id(&mut self) -> u32 { + let id = self.branch_id; + self.branch_id = id + 1; + id } +} - fn visit_statement(&mut self, node: &Node) -> eyre::Result<()> { - match node.node_type { - // Blocks - NodeType::Block | NodeType::UncheckedBlock | NodeType::YulBlock => { - self.visit_block(node) - } - // Inline assembly block - NodeType::InlineAssembly => self.visit_block( - &node - .attribute("AST") - .ok_or_else(|| eyre::eyre!("inline assembly block with no AST attribute"))?, - ), - // Simple statements - NodeType::Break - | NodeType::Continue - | NodeType::EmitStatement - | NodeType::RevertStatement - | NodeType::YulAssignment - | NodeType::YulBreak - | NodeType::YulContinue - | NodeType::YulLeave - | NodeType::YulVariableDeclaration => { - self.push_item_kind(CoverageItemKind::Statement, &node.src); - Ok(()) - } - // Skip placeholder statements as they are never referenced in source maps. - NodeType::PlaceholderStatement => Ok(()), - // Return with eventual subcall - NodeType::Return => { - self.push_item_kind(CoverageItemKind::Statement, &node.src); - if let Some(expr) = node.attribute("expression") { - self.visit_expression(&expr)?; - } - Ok(()) - } - // Variable declaration - NodeType::VariableDeclarationStatement => { - self.push_item_kind(CoverageItemKind::Statement, &node.src); - if let Some(expr) = node.attribute("initialValue") { - self.visit_expression(&expr)?; - } - Ok(()) - } - // While loops - NodeType::DoWhileStatement | NodeType::WhileStatement => { - self.visit_expression( - &node - .attribute("condition") - .ok_or_else(|| eyre::eyre!("while statement had no condition"))?, - )?; - - let body = node - .body - .as_deref() - .ok_or_else(|| eyre::eyre!("while statement had no body node"))?; - self.visit_block_or_statement(body) - } - // For loops - NodeType::ForStatement => { - if let Some(stmt) = node.attribute("initializationExpression") { - self.visit_statement(&stmt)?; - } - if let Some(expr) = node.attribute("condition") { - self.visit_expression(&expr)?; - } - if let Some(stmt) = node.attribute("loopExpression") { - self.visit_statement(&stmt)?; +impl<'ast> ast::Visit<'ast> for SourceVisitor<'_> { + type BreakValue = Never; + + fn visit_item_contract( + &mut self, + contract: &'ast ast::ItemContract<'ast>, + ) -> ControlFlow { + self.contract_name = contract.name.as_str().into(); + self.walk_item_contract(contract) + } + + #[expect(clippy::single_match)] + fn visit_item(&mut self, item: &'ast ast::Item<'ast>) -> ControlFlow { + match &item.kind { + ItemKind::Function(func) => { + // Empty modifiers have no bytecode of their own to anchor. Empty constructors, + // receive functions, and fallbacks are handled separately. + if func.kind == ast::FunctionKind::Modifier && !has_statements(func.body.as_ref()) { + return ControlFlow::Continue(()); } - let body = node - .body - .as_deref() - .ok_or_else(|| eyre::eyre!("for statement had no body node"))?; - self.visit_block_or_statement(body) - } - // Expression statement - NodeType::ExpressionStatement | NodeType::YulExpressionStatement => self - .visit_expression( - &node - .attribute("expression") - .ok_or_else(|| eyre::eyre!("expression statement had no expression"))?, - ), - // If statement - NodeType::IfStatement => { - self.visit_expression( - &node - .attribute("condition") - .ok_or_else(|| eyre::eyre!("if statement had no condition"))?, - )?; - - let true_body: Node = node - .attribute("trueBody") - .ok_or_else(|| eyre::eyre!("if statement had no true body"))?; - - // We need to store the current branch ID here since visiting the body of either of - // the if blocks may increase `self.branch_id` in the case of nested if statements. - let branch_id = self.branch_id; - - // We increase the branch ID here such that nested branches do not use the same - // branch ID as we do. - self.branch_id += 1; - - match node.attribute::("falseBody") { - // Both if/else statements. - Some(false_body) => { - // Add branch coverage items only if one of true/branch bodies contains - // statements. - if has_statements(&true_body) || has_statements(&false_body) { - // The branch instruction is mapped to the first opcode within the true - // body source range. - self.push_item_kind( - CoverageItemKind::Branch { - branch_id, - path_id: 0, - is_first_opcode: true, - }, - &true_body.src, - ); - // Add the coverage item for branch 1 (false body). - // The relevant source range for the false branch is the `else` - // statement itself and the false body of the else statement. - self.push_item_kind( - CoverageItemKind::Branch { - branch_id, - path_id: 1, - is_first_opcode: false, - }, - &ast::LowFidelitySourceLocation { - start: node.src.start, - length: false_body.src.length.map(|length| { - false_body.src.start - true_body.src.start + length - }), - index: node.src.index, - }, - ); - - // Process the true body. - self.visit_block_or_statement(&true_body)?; - // Process the false body. - self.visit_block_or_statement(&false_body)?; - } - } - None => { - // Add single branch coverage only if it contains statements. - if has_statements(&true_body) { - // Add the coverage item for branch 0 (true body). - self.push_item_kind( - CoverageItemKind::Branch { - branch_id, - path_id: 0, - is_first_opcode: true, - }, - &true_body.src, - ); - // Process the true body. - self.visit_block_or_statement(&true_body)?; - } + let name = func.header.name.as_ref().map(|n| n.as_str()).unwrap_or_else(|| { + match func.kind { + ast::FunctionKind::Constructor => "constructor", + ast::FunctionKind::Receive => "receive", + ast::FunctionKind::Fallback => "fallback", + ast::FunctionKind::Function | ast::FunctionKind::Modifier => unreachable!(), } + }); + + // Exclude function from coverage report if it is virtual without implementation. + let exclude_func = func.header.virtual_() && !func.is_implemented(); + if !exclude_func { + self.push_item_kind( + CoverageItemKind::Function { name: name.into() }, + item.span, + ); } - Ok(()) + // Discover decisions without changing the ordinary statement/call traversal. + TernaryVisitor(self).walk_item(item)?; + self.walk_item(item)?; } - NodeType::YulIf => { - self.visit_expression( - &node - .attribute("condition") - .ok_or_else(|| eyre::eyre!("yul if statement had no condition"))?, - )?; - let body = node - .body - .as_deref() - .ok_or_else(|| eyre::eyre!("yul if statement had no body"))?; - - // We need to store the current branch ID here since visiting the body of either of - // the if blocks may increase `self.branch_id` in the case of nested if statements. - let branch_id = self.branch_id; - - // We increase the branch ID here such that nested branches do not use the same - // branch ID as we do - self.branch_id += 1; - - self.push_item_kind( - CoverageItemKind::Branch { branch_id, path_id: 0, is_first_opcode: false }, - &node.src, - ); - self.visit_block(body)?; + _ => {} + } + // Only walk functions. + ControlFlow::Continue(()) + } - Ok(()) + fn visit_stmt(&mut self, stmt: &'ast ast::Stmt<'ast>) -> ControlFlow { + match &stmt.kind { + StmtKind::Break | StmtKind::Continue | StmtKind::Emit(..) | StmtKind::Revert(..) => { + self.push_stmt(stmt.span); + // TODO(dani): these probably shouldn't be excluded. + return ControlFlow::Continue(()); } - // Try-catch statement. Coverage is reported as branches for catch clauses with - // statements. - NodeType::TryStatement => { - self.visit_expression( - &node - .attribute("externalCall") - .ok_or_else(|| eyre::eyre!("try statement had no call"))?, - )?; - - let branch_id = self.branch_id; - self.branch_id += 1; - - let mut clauses = node - .attribute::>("clauses") - .ok_or_else(|| eyre::eyre!("try statement had no clauses"))?; - - let try_block = clauses - .remove(0) - .attribute::("block") - .ok_or_else(|| eyre::eyre!("try statement had no block"))?; - // Add branch with path id 0 for try (first clause). - self.push_item_kind( - CoverageItemKind::Branch { branch_id, path_id: 0, is_first_opcode: true }, - &ast::LowFidelitySourceLocation { - start: node.src.start, - length: try_block - .src - .length - .map(|length| try_block.src.start + length - node.src.start), - index: node.src.index, - }, - ); - self.visit_block(&try_block)?; - - let mut path_id = 1; - for clause in clauses { - if let Some(catch_block) = clause.attribute::("block") { - if has_statements(&catch_block) { - // Add catch branch if it has statements. - self.push_item_kind( - CoverageItemKind::Branch { - branch_id, - path_id, - is_first_opcode: true, - }, - &catch_block.src, - ); - self.visit_block(&catch_block)?; - // Increment path id for next branch. - path_id += 1; - } else if clause.attribute::("parameters").is_some() { - // Add coverage for clause with parameters and empty statements. - // (`catch (bytes memory reason) {}`). - // Catch all clause without statements is ignored (`catch {}`). - self.push_item_kind(CoverageItemKind::Statement, &clause.src); - self.visit_statement(&clause)?; - } - } - } - - Ok(()) + StmtKind::Return(_) | StmtKind::DeclSingle(_) | StmtKind::DeclMulti(..) => { + self.push_stmt(stmt.span); } - NodeType::YulSwitch => { - // Add coverage for each case statement amd their bodies. - for case in node - .attribute::>("cases") - .ok_or_else(|| eyre::eyre!("yul switch had no case"))? - { - self.push_item_kind(CoverageItemKind::Statement, &case.src); - self.visit_statement(&case)?; - if let Some(body) = case.body { - self.push_item_kind(CoverageItemKind::Statement, &body.src); - self.visit_block(&body)? + StmtKind::If(_cond, then_stmt, else_stmt) => { + let branch_id = self.next_branch_id(); + + // Add branch coverage items only if one of true/branch bodies contains statements. + if stmt_has_statements(then_stmt) + || else_stmt.as_ref().is_some_and(|s| stmt_has_statements(s)) + { + // Report the branch at the condition, but count hits in the true body. + // Line coverage uses the reported span so it also sees false conditions. + let anchor_loc = self.source_location_for(then_stmt.span); + self.push_item_kind( + CoverageItemKind::Branch { branch_id, path_id: 0, is_first_opcode: true }, + stmt.span, + ) + .anchor_loc = Some(anchor_loc); + if let Some(else_stmt) = else_stmt { + let is_first_opcode = stmt_has_statements(else_stmt); + let anchor_loc = + is_first_opcode.then(|| self.source_location_for(else_stmt.span)); + self.push_item_kind( + CoverageItemKind::Branch { branch_id, path_id: 1, is_first_opcode }, + stmt.span, + ) + .anchor_loc = anchor_loc; } } - Ok(()) } - NodeType::YulForLoop => { - if let Some(condition) = node.attribute("condition") { - self.visit_expression(&condition)?; - } - if let Some(pre) = node.attribute::("pre") { - self.visit_block(&pre)? - } - if let Some(post) = node.attribute::("post") { - self.visit_block(&post)? - } - if let Some(body) = &node.body { - self.push_item_kind(CoverageItemKind::Statement, &body.src); - self.visit_block(body)? + StmtKind::Try(ast::StmtTry { expr: _, clauses }) => { + let branch_id = self.next_branch_id(); + + let mut path_id = 0; + for catch in clauses.iter() { + let ast::TryCatchClause { span, name: _, args, block } = catch; + let span = if path_id == 0 { stmt.span.to(*span) } else { *span }; + if path_id == 0 || has_statements(Some(block)) { + self.push_item_kind( + CoverageItemKind::Branch { branch_id, path_id, is_first_opcode: true }, + span, + ); + path_id += 1; + } else if !args.is_empty() { + // Add coverage for clause with parameters and empty statements. + // (`catch (bytes memory reason) {}`). + // Catch all clause without statements is ignored (`catch {}`). + self.push_stmt(span); + } } - Ok(()) - } - NodeType::YulFunctionDefinition => self.visit_modifier_or_yul_fn_definition(node), - _ => { - warn!("unexpected node type, expected a statement: {:?}", node.node_type); - Ok(()) } + + // Skip placeholder statements as they are never referenced in source maps. + StmtKind::Assembly(_) + | StmtKind::Block(_) + | StmtKind::UncheckedBlock(_) + | StmtKind::Placeholder + | StmtKind::Expr(_) + | StmtKind::While(..) + | StmtKind::DoWhile(..) + | StmtKind::For { .. } => {} } + self.walk_stmt(stmt) } - fn visit_expression(&mut self, node: &Node) -> eyre::Result<()> { - match node.node_type { - NodeType::Assignment - | NodeType::UnaryOperation - | NodeType::Conditional - | NodeType::YulFunctionCall => { - self.push_item_kind(CoverageItemKind::Statement, &node.src); - Ok(()) + fn visit_expr(&mut self, expr: &'ast ast::Expr<'ast>) -> ControlFlow { + match &expr.kind { + ExprKind::Assign(..) + | ExprKind::Unary(..) + | ExprKind::Binary(..) + | ExprKind::Ternary(..) => { + self.push_stmt(expr.span); + if matches!(expr.kind, ExprKind::Binary(..)) { + return self.walk_expr(expr); + } } - NodeType::FunctionCall => { - // Do not count other kinds of calls towards coverage (like `typeConversion` - // and `structConstructorCall`). - let kind: Option = node.attribute("kind"); - if let Some("functionCall") = kind.as_deref() { - self.push_item_kind(CoverageItemKind::Statement, &node.src); - - let expr: Option = node.attribute("expression"); - if let Some(NodeType::Identifier) = expr.as_ref().map(|expr| &expr.node_type) { - // Might be a require call, add branch coverage. - // Asserts should not be considered branches: . - let name: Option = expr.and_then(|expr| expr.attribute("name")); - if let Some("require") = name.as_deref() { - let branch_id = self.branch_id; - self.branch_id += 1; - self.push_item_kind( - CoverageItemKind::Branch { - branch_id, - path_id: 0, - is_first_opcode: false, - }, - &node.src, - ); - self.push_item_kind( - CoverageItemKind::Branch { - branch_id, - path_id: 1, - is_first_opcode: false, - }, - &node.src, - ); - } + ExprKind::Call(callee, _args) => { + // Resolve later. Capture the current contract scope so it travels with the span. + self.function_calls.push((expr.span, self.contract_name.clone())); + + if let ExprKind::Ident(ident) = &callee.kind { + // Might be a require call, add branch coverage. + // Asserts should not be considered branches: . + if ident.as_str() == "require" { + let branch_id = self.next_branch_id(); + self.push_item_kind( + CoverageItemKind::Branch { + branch_id, + path_id: 0, + is_first_opcode: false, + }, + expr.span, + ); + self.push_item_kind( + CoverageItemKind::Branch { + branch_id, + path_id: 1, + is_first_opcode: false, + }, + expr.span, + ); } } + } + _ => {} + } + // Intentionally do not walk all expressions. + ControlFlow::Continue(()) + } - Ok(()) + fn visit_yul_stmt(&mut self, stmt: &'ast yul::Stmt<'ast>) -> ControlFlow { + use yul::StmtKind; + match &stmt.kind { + StmtKind::VarDecl(..) + | StmtKind::AssignSingle(..) + | StmtKind::AssignMulti(..) + | StmtKind::Leave + | StmtKind::Break + | StmtKind::Continue => { + self.push_stmt(stmt.span); + // Don't walk assignments. + return ControlFlow::Continue(()); } - NodeType::BinaryOperation => { - self.push_item_kind(CoverageItemKind::Statement, &node.src); - - // visit left and right expressions - // There could possibly a function call in the left or right expression - // e.g: callFunc(a) + callFunc(b) - if let Some(expr) = node.attribute("leftExpression") { - self.visit_expression(&expr)?; + StmtKind::If(..) => { + let branch_id = self.next_branch_id(); + // Track both outcomes, including the implicit path that skips the body. + for path_id in 0..2 { + self.push_item_kind( + CoverageItemKind::Branch { branch_id, path_id, is_first_opcode: false }, + stmt.span, + ); } - - if let Some(expr) = node.attribute("rightExpression") { - self.visit_expression(&expr)?; + } + StmtKind::For(yul::StmtFor { body, .. }) => { + self.push_stmt(body.span); + } + StmtKind::Switch(switch) => { + for case in switch.cases.iter() { + self.push_stmt(case.span); + self.push_stmt(case.body.span); } - - Ok(()) } - // Does not count towards coverage - NodeType::FunctionCallOptions - | NodeType::Identifier - | NodeType::IndexAccess - | NodeType::IndexRangeAccess - | NodeType::Literal - | NodeType::YulLiteralValue - | NodeType::YulIdentifier => Ok(()), - _ => { - warn!("unexpected node type, expected an expression: {:?}", node.node_type); - Ok(()) + StmtKind::FunctionDef(func) => { + let name = func.name.as_str(); + self.push_item_kind(CoverageItemKind::Function { name: name.into() }, stmt.span); } + // TODO(dani): merge with Block below on next solar release: https://github.com/paradigmxyz/solar/pull/496 + StmtKind::Expr(_) => { + self.push_stmt(stmt.span); + return ControlFlow::Continue(()); + } + StmtKind::Block(_) => {} } + self.walk_yul_stmt(stmt) } - fn visit_block_or_statement(&mut self, node: &Node) -> eyre::Result<()> { - match node.node_type { - NodeType::Block => self.visit_block(node), - NodeType::Break - | NodeType::Continue - | NodeType::DoWhileStatement - | NodeType::EmitStatement - | NodeType::ExpressionStatement - | NodeType::ForStatement - | NodeType::IfStatement - | NodeType::InlineAssembly - | NodeType::Return - | NodeType::RevertStatement - | NodeType::TryStatement - | NodeType::VariableDeclarationStatement - | NodeType::YulVariableDeclaration - | NodeType::WhileStatement => self.visit_statement(node), - // Skip placeholder statements as they are never referenced in source maps. - NodeType::PlaceholderStatement => Ok(()), - _ => { - warn!("unexpected node type, expected block or statement: {:?}", node.node_type); - Ok(()) - } + fn visit_yul_expr(&mut self, expr: &'ast yul::Expr<'ast>) -> ControlFlow { + use yul::ExprKind; + match &expr.kind { + ExprKind::Path(_) | ExprKind::Lit(_) => {} + ExprKind::Call(_) => self.push_stmt(expr.span), } + // Intentionally do not walk all expressions. + ControlFlow::Continue(()) } +} - /// Creates a coverage item for a given kind and source location. Pushes item to the internal - /// collection (plus additional coverage line if item is a statement). - fn push_item_kind(&mut self, kind: CoverageItemKind, src: &ast::LowFidelitySourceLocation) { - let item = CoverageItem { kind, loc: self.source_location_for(src), hits: 0 }; - - // Push a line item if we haven't already. - debug_assert!(!matches!(item.kind, CoverageItemKind::Line)); - if self.last_line < item.loc.lines.start { - self.items.push(CoverageItem { - kind: CoverageItemKind::Line, - loc: item.loc.clone(), - hits: 0, - }); - self.last_line = item.loc.lines.start; +/// Walks all expression shapes, adding only ternary decisions. Statement coverage remains owned +/// by the enclosing statement or the ordinary expression visitor. +struct TernaryVisitor<'a, 'gcx>(&'a mut SourceVisitor<'gcx>); + +impl<'ast> ast::Visit<'ast> for TernaryVisitor<'_, '_> { + type BreakValue = Never; + + fn visit_expr(&mut self, expr: &'ast ast::Expr<'ast>) -> ControlFlow { + if matches!(expr.kind, ExprKind::Ternary(..)) { + let branch_id = self.0.next_branch_id(); + self.0.ternary_branches.push(branch_id); + // Ternary path 0 is the false arm (fallthrough); path 1 is the true arm (jump target). + for path_id in 0..2 { + self.0.push_item_kind( + CoverageItemKind::Branch { branch_id, path_id, is_first_opcode: false }, + expr.span, + ); + } } - - self.items.push(item); + self.walk_expr(expr) } +} - fn source_location_for(&self, loc: &ast::LowFidelitySourceLocation) -> SourceLocation { - let bytes_start = loc.start as u32; - let bytes_end = (loc.start + loc.length.unwrap_or(0)) as u32; - let bytes = bytes_start..bytes_end; +impl<'gcx> hir::Visit<'gcx> for SourceVisitor<'gcx> { + type BreakValue = Never; - let start_line = self.source[..bytes.start as usize].lines().count() as u32; - let n_lines = self.source[bytes.start as usize..bytes.end as usize].lines().count() as u32; - let lines = start_line..start_line + n_lines; - SourceLocation { - source_id: self.source_id as usize, - contract_name: self.contract_name.clone(), - bytes, - lines, + fn hir(&self) -> &'gcx hir::Hir<'gcx> { + &self.gcx.hir + } + + fn visit_expr(&mut self, expr: &'gcx hir::Expr<'gcx>) -> ControlFlow { + if let hir::ExprKind::Call(lhs, ..) = &expr.kind + && is_regular_call(lhs) + && let Some(scope) = self.function_call_scopes.get(&expr.span).cloned() + { + // Attribute the call with the scope captured where it was collected, not the + // visitor's final scope (which leaks across free functions and contracts). + let prev = std::mem::replace(&mut self.contract_name, scope); + self.push_stmt(expr.span); + self.contract_name = prev; } + self.walk_expr(expr) } } -/// Helper function to check if a given node is or contains any statement. -fn has_statements(node: &Node) -> bool { - match node.node_type { - NodeType::DoWhileStatement - | NodeType::EmitStatement - | NodeType::ExpressionStatement - | NodeType::ForStatement - | NodeType::IfStatement - | NodeType::RevertStatement - | NodeType::TryStatement - | NodeType::VariableDeclarationStatement - | NodeType::WhileStatement => true, - _ => node.attribute::>("statements").is_some_and(|s| !s.is_empty()), +// https://github.com/argotorg/solidity/blob/965166317bbc2b02067eb87f222a2dce9d24e289/libsolidity/ast/ASTAnnotations.h#L336-L341 +// https://github.com/argotorg/solidity/blob/965166317bbc2b02067eb87f222a2dce9d24e289/libsolidity/analysis/TypeChecker.cpp#L2720 +fn is_regular_call(lhs: &hir::Expr<'_>) -> bool { + match lhs.peel_parens().kind { + // StructConstructorCall + hir::ExprKind::Ident([hir::Res::Item(hir::ItemId::Struct(_))]) => false, + // TypeConversion + hir::ExprKind::Type(_) => false, + _ => true, + } +} + +fn has_statements(block: Option<&ast::Block<'_>>) -> bool { + block.is_some_and(|block| !block.is_empty()) +} + +fn stmt_has_statements(stmt: &ast::Stmt<'_>) -> bool { + match &stmt.kind { + StmtKind::Assembly(a) => !a.block.is_empty(), + StmtKind::Block(b) | StmtKind::UncheckedBlock(b) => has_statements(Some(b)), + _ => true, } } @@ -553,75 +577,101 @@ fn has_statements(node: &Node) -> bool { pub struct SourceAnalysis { /// All the coverage items. all_items: Vec, + /// Source and branch IDs requiring exact decision-node source-map matching. + ternary_branches: HashSet<(u32, u32)>, /// Source ID to `(offset, len)` into `all_items`. map: Vec<(u32, u32)>, + /// Empty receive and fallback items keyed by coverage item ID. + empty_special_functions: HashMap, + /// Empty receive and fallback item IDs resolved for each contract, including inheritance. + contract_empty_special_functions: HashMap, Vec>>, } impl SourceAnalysis { - /// Analyzes contracts in the sources held by the source analyzer. + /// Analyzes the sources held by the source analyzer. /// /// Coverage items are found by: /// - Walking the AST of each contract (except interfaces) - /// - Recording the items of each contract + /// - Walking file-level (free) functions + /// - Recording the items found /// /// Each coverage item contains relevant information to find opcodes corresponding to them: the /// source ID the item is in, the source code range of the item, and the contract name the item /// is in. /// - /// Note: Source IDs are only unique per compilation job; that is, a code base compiled with - /// two different solc versions will produce overlapping source IDs if the compiler version is - /// not taken into account. - pub fn new(data: &SourceFiles<'_>) -> eyre::Result { - let mut sourced_items = data - .sources - .par_iter() - .flat_map_iter(|(&source_id, SourceFile { source, ast })| { - let items = ast.nodes.iter().map(move |node| { - if !matches!(node.node_type, NodeType::ContractDefinition) { - return Ok(vec![]); + /// Note: Source IDs are only unique per compilation job, so report-level source identity must + /// also include the compiler build ID. + #[instrument(name = "SourceAnalysis::new", skip_all)] + pub fn new(data: &SourceFiles, output: &ProjectCompileOutput) -> eyre::Result { + let mut resolved_empty_special_functions = Vec::new(); + let mut sourced_items = output.parser().solc().compiler().enter(|compiler| { + resolved_empty_special_functions = + resolve_empty_special_functions(compiler.gcx(), data); + data.sources + .par_iter() + .map(|(&source_id, path)| { + let _guard = debug_span!("SourceAnalysis::new::visit", ?path).entered(); + + let (_, source) = compiler.gcx().get_ast_source(path).unwrap(); + let ast = source.ast.as_ref().unwrap(); + let (hir_source_id, _) = compiler.gcx().get_hir_source(path).unwrap(); + + let mut visitor = SourceVisitor::new(source_id, compiler.gcx()); + for item in ast.items.iter() { + match &item.kind { + // Contracts: walk their functions, dropping test contracts. + ItemKind::Contract(contract) => { + // Skip interfaces which have no function implementations. + if contract.kind.is_interface() { + continue; + } + + let checkpoint = visitor.checkpoint(); + visitor.visit_contract(contract); + if visitor.has_tests(&checkpoint) { + visitor.restore_checkpoint(checkpoint); + } + } + // File-level (free) functions are covered too, not only functions + // defined inside a contract. Without this, a file of only free + // functions gets no coverage record at all. + ItemKind::Function(_) => { + // A free function is not scoped to any contract. Clear any scope + // left by a previously visited contract so it is attributed at + // file level, not as `Contract.freeFn`. + visitor.contract_name = Arc::default(); + let _ = ast::Visit::visit_item(&mut visitor, item); + } + _ => {} + } } - // Skip interfaces which have no function implementations. - let contract_kind: String = node - .attribute("contractKind") - .ok_or_else(|| eyre::eyre!("Contract has no kind"))?; - if contract_kind == "interface" { - return Ok(vec![]); + if !visitor.function_calls.is_empty() { + visitor.resolve_function_calls(hir_source_id); } - let name = node - .attribute("name") - .ok_or_else(|| eyre::eyre!("Contract has no name"))?; - - let mut visitor = ContractVisitor::new(source_id, &source.content, &name); - visitor.visit_contract(node)?; - let mut items = visitor.items; - - let is_test = items.iter().any(|item| { - if let CoverageItemKind::Function { name } = &item.kind { - name.is_any_test() - } else { - false - } - }); - if is_test { - items.clear(); + if !visitor.items.is_empty() { + visitor.disambiguate_functions(); + visitor.sort(); + visitor.push_lines(); + visitor.sort(); } - - Ok(items) - }); - items.map(move |items| items.map(|items| (source_id, items))) - }) - .collect::)>>>()?; + (source_id, visitor.items, visitor.ternary_branches) + }) + .collect::>() + }); // Create mapping and merge items. - sourced_items.sort_by_key(|(id, items)| (*id, items.first().map(|i| i.loc.bytes.start))); - let Some(&(max_idx, _)) = sourced_items.last() else { return Ok(Self::default()) }; + sourced_items.sort_by_key(|(id, items, _)| (*id, items.first().map(|i| i.loc.bytes.start))); + let Some(&(max_idx, _, _)) = sourced_items.last() else { return Ok(Self::default()) }; let len = max_idx + 1; let mut all_items = Vec::new(); - let mut map = vec![(u32::MAX, 0); len]; - for (idx, items) in sourced_items { + let mut map = vec![(u32::MAX, 0); len as usize]; + let mut ternary_branches = HashSet::default(); + for (idx, items, branches) in sourced_items { + ternary_branches.extend(branches.into_iter().map(|branch| (idx, branch))); // Assumes that all `idx` items are consecutive, guaranteed by the sort above. + let idx = idx as usize; if map[idx].0 == u32::MAX { map[idx].0 = all_items.len() as u32; } @@ -629,7 +679,41 @@ impl SourceAnalysis { all_items.extend(items); } - Ok(Self { all_items, map }) + let mut empty_special_functions = HashMap::default(); + let mut contract_empty_special_functions = + HashMap::, Vec>>::default(); + for resolved in resolved_empty_special_functions { + let item_ids = all_items + .iter() + .enumerate() + .filter(|(_, item)| { + item.loc.source_id == resolved.function_source_id as usize + && item.loc.contract_name == resolved.function_contract_name + && item.loc.bytes == resolved.function_bytes + }) + .map(|(item_id, _)| item_id as u32) + .collect::>(); + empty_special_functions + .extend(item_ids.iter().map(|&item_id| (item_id, resolved.kind))); + contract_empty_special_functions + .entry(resolved.contract_source_id) + .or_default() + .entry(resolved.contract_name) + .or_default() + .extend(item_ids); + } + + Ok(Self { + all_items, + map, + ternary_branches, + empty_special_functions, + contract_empty_special_functions, + }) + } + + pub(crate) fn is_ternary_branch(&self, source_id: u32, branch_id: u32) -> bool { + self.ternary_branches.contains(&(source_id, branch_id)) } /// Returns all the coverage items. @@ -638,7 +722,7 @@ impl SourceAnalysis { } /// Returns all the mutable coverage items. - pub fn all_items_mut(&mut self) -> &mut Vec { + pub const fn all_items_mut(&mut self) -> &mut Vec { &mut self.all_items } @@ -665,20 +749,31 @@ impl SourceAnalysis { pub fn get(&self, item_id: u32) -> Option<&CoverageItem> { self.all_items.get(item_id as usize) } -} -/// A list of versioned sources and their ASTs. -#[derive(Debug, Default)] -pub struct SourceFiles<'a> { - /// The versioned sources. - pub sources: HashMap>, + pub(crate) fn empty_special_function_kind( + &self, + item_id: u32, + ) -> Option { + self.empty_special_functions.get(&item_id).copied() + } + + pub(crate) fn empty_special_function_ids( + &self, + source_id: u32, + contract_name: &str, + ) -> impl Iterator + '_ { + self.contract_empty_special_functions + .get(&source_id) + .and_then(|contracts| contracts.get(contract_name)) + .into_iter() + .flatten() + .copied() + } } -/// The source code and AST of a file. -#[derive(Debug)] -pub struct SourceFile<'a> { - /// The source code. - pub source: Source, - /// The AST of the source code. - pub ast: &'a Ast, +/// A list of sources from one compiler build. +#[derive(Default)] +pub struct SourceFiles { + /// The sources keyed by their IDs within the compiler build. + pub sources: HashMap, } diff --git a/crates/evm/coverage/src/anchors.rs b/crates/evm/coverage/src/anchors.rs index 3c92121296653..48dfc27661be7 100644 --- a/crates/evm/coverage/src/anchors.rs +++ b/crates/evm/coverage/src/anchors.rs @@ -1,9 +1,11 @@ -use super::{CoverageItemKind, ItemAnchor, SourceLocation}; -use crate::analysis::SourceAnalysis; +use super::{ + CoverageItemKind, ExecutionAnchor, ExecutionAnchorKind, ItemAnchor, ItemAnchors, SourceLocation, +}; +use crate::analysis::{EmptySpecialFunctionKind, SourceAnalysis}; use alloy_primitives::map::rustc_hash::FxHashSet; use eyre::ensure; use foundry_compilers::artifacts::sourcemap::{SourceElement, SourceMap}; -use foundry_evm_core::ic::IcPcMap; +use foundry_evm_core::{bytecode::InstIter, ic::IcPcMap}; use revm::bytecode::opcode; /// Attempts to find anchors for the given items using the given source map and bytecode. @@ -12,7 +14,13 @@ pub fn find_anchors( source_map: &SourceMap, ic_pc_map: &IcPcMap, analysis: &SourceAnalysis, -) -> Vec { +) -> ItemAnchors { + let mut anchors = ItemAnchors::default(); + let select_branch = |(fallthrough, taken): (ItemAnchor, ItemAnchor), path_id| match path_id { + 0 => (fallthrough, None), + 1 => (taken, Some(fallthrough.instruction - 1)), + _ => panic!("too many path IDs for branch"), + }; let mut seen_sources = FxHashSet::default(); source_map .iter() @@ -20,21 +28,60 @@ pub fn find_anchors( .filter(|&source| seen_sources.insert(source)) .flat_map(|source| analysis.items_for_source_enumerated(source)) .filter_map(|(item_id, item)| { + if analysis.empty_special_function_kind(item_id).is_some() { + return None; + } + let anchor_loc = item.anchor_loc.as_ref().unwrap_or(&item.loc); match item.kind { - CoverageItemKind::Branch { path_id, is_first_opcode: false, .. } => { - find_anchor_branch(bytecode, source_map, item_id, &item.loc).map(|anchors| { - match path_id { - 0 => anchors.0, - 1 => anchors.1, - _ => panic!("too many path IDs for branch"), - } - }) + CoverageItemKind::Branch { path_id: 1, is_first_opcode: true, .. } + if item.anchor_loc.is_some() => + { + find_anchor_simple(source_map, ic_pc_map, item_id, anchor_loc) + .map(|anchor| (anchor, None)) + .or_else(|_| { + find_anchor_branch(bytecode, source_map, item_id, &item.loc) + .map(|anchors| select_branch(anchors, 1)) + }) } - _ => find_anchor_simple(source_map, ic_pc_map, item_id, &item.loc), + CoverageItemKind::Branch { branch_id, path_id, is_first_opcode: false } => { + let exact = analysis.is_ternary_branch(item.loc.source_id as u32, branch_id); + find_anchor_branch_inner(bytecode, source_map, item_id, anchor_loc, exact) + .map(|anchors| select_branch(anchors, path_id)) + } + _ => find_anchor_simple(source_map, ic_pc_map, item_id, anchor_loc) + .map(|anchor| (anchor, None)), } .inspect_err(|err| warn!(%item, %err, "could not find anchor")) .ok() }) + .for_each(|(anchor, jump)| { + if let Some(jump) = jump { + anchors.jumps.insert(anchors.anchors.len(), jump); + } + anchors.anchors.push(anchor); + }); + anchors +} + +/// Finds execution-based anchors for empty constructors, receive functions, and fallbacks in a +/// contract. +pub fn find_execution_anchors( + source_id: u32, + contract_name: &str, + analysis: &SourceAnalysis, +) -> Vec { + analysis + .empty_special_function_ids(source_id, contract_name) + .filter_map(|item_id| { + analysis.empty_special_function_kind(item_id).map(|kind| ExecutionAnchor { + item_id, + kind: match kind { + EmptySpecialFunctionKind::Constructor => ExecutionAnchorKind::Constructor, + EmptySpecialFunctionKind::Receive => ExecutionAnchorKind::Receive, + EmptySpecialFunctionKind::Fallback => ExecutionAnchorKind::Fallback, + }, + }) + }) .collect() } @@ -92,55 +139,67 @@ pub fn find_anchor_branch( item_id: u32, loc: &SourceLocation, ) -> eyre::Result<(ItemAnchor, ItemAnchor)> { - let mut anchors: Option<(ItemAnchor, ItemAnchor)> = None; - let mut pc = 0; - let mut cumulative_push_size = 0; - while pc < bytecode.len() { - let op = bytecode[pc]; + find_anchor_branch_inner(bytecode, source_map, item_id, loc, false) +} +/// Matches exact ternary spans to exclude nested decisions. +fn find_anchor_branch_inner( + bytecode: &[u8], + source_map: &SourceMap, + item_id: u32, + loc: &SourceLocation, + exact: bool, +) -> eyre::Result<(ItemAnchor, ItemAnchor)> { + let mut anchors: Option<(ItemAnchor, ItemAnchor)> = None; + for (ic, (pc, inst)) in InstIter::new(bytecode).with_pc().enumerate() { // We found a push, so we do some PC -> IC translation accounting, but we also check if // this push is coupled with the JUMPI we are interested in. // Check if Opcode is PUSH - if (opcode::PUSH1..=opcode::PUSH32).contains(&op) { - let element = if let Some(element) = source_map.get(pc - cumulative_push_size) { - element - } else { + if (opcode::PUSH1..=opcode::PUSH32).contains(&inst.opcode.get()) { + let Some(element) = source_map.get(ic) else { // NOTE(onbjerg): For some reason the last few bytes of the bytecode do not have // a source map associated, so at that point we just stop searching break; }; - // Do push byte accounting - let push_size = (op - opcode::PUSH1 + 1) as usize; - pc += push_size; - cumulative_push_size += push_size; - // Check if we are in the source range we are interested in, and if the next opcode // is a JUMPI - if is_in_source_range(element, loc) && bytecode[pc + 1] == opcode::JUMPI { - // We do not support program counters bigger than usize. This is also an - // assumption in REVM, so this is just a sanity check. - ensure!(push_size <= 8, "jump destination overflow"); + let next_pc = pc + inst.immediate.len() + 1; + let push_size = inst.immediate.len(); + if bytecode.get(next_pc).copied() == Some(opcode::JUMPI) + && if exact { + source_map.get(ic + 1).is_some_and(|jump| { + jump.index() == Some(loc.source_id as u32) + && jump.offset() == loc.bytes.start + && jump.length() == loc.len() + }) + } else { + is_in_source_range(element, loc) + } + { + // We do not support program counters bigger than u32. + ensure!(push_size <= 4, "jump destination overflow"); - // Convert the push bytes for the second branch's PC to a usize - let push_bytes_start = pc - push_size + 1; - let push_bytes = &bytecode[push_bytes_start..push_bytes_start + push_size]; - let mut pc_bytes = [0u8; 8]; - pc_bytes[8 - push_size..].copy_from_slice(push_bytes); - let pc_jump = u64::from_be_bytes(pc_bytes); - let pc_jump = u32::try_from(pc_jump).expect("PC is too big"); - anchors = Some(( + // Convert the push bytes for the second branch's PC to a u32. + let mut pc_bytes = [0u8; 4]; + pc_bytes[4 - push_size..].copy_from_slice(inst.immediate); + let pc_jump = u32::from_be_bytes(pc_bytes); + let found = ( ItemAnchor { item_id, // The first branch is the opcode directly after JUMPI - instruction: (pc + 2) as u32, + instruction: (next_pc + 1).try_into()?, }, ItemAnchor { item_id, instruction: pc_jump }, - )); + ); + if exact { + // Generated branch code can contain later jumps mapped to the ternary span. + return Ok(found); + } + anchors = Some(found); } } - pc += 1; } anchors.ok_or_else(|| eyre::eyre!("Could not detect branches in source: {}", loc)) @@ -163,10 +222,21 @@ fn is_in_source_range(element: &SourceElement, location: &SourceLocation) -> boo let start_of_ranges = location.bytes.start.max(element.offset()); let end_of_ranges = (location.bytes.start + location.len()).min(element.offset() + element.length()); - let within_ranges = start_of_ranges <= end_of_ranges; - if !within_ranges { - return false; - } + start_of_ranges <= end_of_ranges +} + +#[cfg(test)] +mod tests { + use super::*; + use foundry_compilers::artifacts::sourcemap; - true + #[test] + fn ternary_anchor_rejects_missing_node_mapping() { + let loc = + SourceLocation { source_id: 0, contract_name: "T".into(), bytes: 10..30, lines: 1..2 }; + let bytecode = [opcode::PUSH1, 6, opcode::JUMPI, opcode::PUSH1, 7, opcode::JUMPI]; + // A contained inner span must never substitute for the missing outer decision. + let inner_only = sourcemap::parse("15:5:0;;;").unwrap(); + assert!(find_anchor_branch_inner(&bytecode, &inner_only, 0, &loc, true).is_err()); + } } diff --git a/crates/evm/coverage/src/inspector.rs b/crates/evm/coverage/src/inspector.rs index 1361ca26799e4..8cffacb74d757 100644 --- a/crates/evm/coverage/src/inspector.rs +++ b/crates/evm/coverage/src/inspector.rs @@ -1,10 +1,8 @@ -use crate::{HitMap, HitMaps}; +use crate::{CallData, HitMap, HitMaps}; use alloy_primitives::B256; use revm::{ Inspector, - context::ContextTr, - inspector::JournalExt, - interpreter::{Interpreter, interpreter_types::Jumps}, + interpreter::{CreateInputs, CreateOutcome, Interpreter, interpreter_types::Jumps}, }; use std::ptr::NonNull; @@ -34,20 +32,42 @@ impl Default for LineCoverageCollector { } } -impl Inspector for LineCoverageCollector -where - CTX: ContextTr, -{ +impl Inspector for LineCoverageCollector { fn initialize_interp(&mut self, interpreter: &mut Interpreter, _context: &mut CTX) { - get_or_insert_contract_hash(interpreter); - self.insert_map(interpreter); + let call = interpreter.input.bytecode_address.is_some().then(|| { + let calldata = if interpreter.input.input.is_empty() { + CallData::Empty + } else { + let input = interpreter.input.input.as_bytes_memory(&interpreter.memory); + CallData::new(&input) + }; + (calldata, !interpreter.input.call_value.is_zero()) + }); + let map = self.get_or_insert_map(interpreter); + if let Some((call, with_value)) = call { + map.call(call, with_value); + } + // Reserve some space early to avoid reallocating too often. + map.reserve(8192.min(interpreter.bytecode.len())); } - #[inline] fn step(&mut self, interpreter: &mut Interpreter, _context: &mut CTX) { let map = self.get_or_insert_map(interpreter); map.hit(interpreter.bytecode.pc() as u32); } + + fn create_end( + &mut self, + _context: &mut CTX, + inputs: &CreateInputs, + outcome: &mut CreateOutcome, + ) { + if outcome.result.result.is_ok() + && let Some(map) = self.maps.get_mut(&inputs.init_code_hash()) + { + map.creation(); + } + } } impl LineCoverageCollector { @@ -62,7 +82,7 @@ impl LineCoverageCollector { /// See comments on `current_map` for more details. #[inline] fn get_or_insert_map(&mut self, interpreter: &mut Interpreter) -> &mut HitMap { - let hash = get_or_insert_contract_hash(interpreter); + let hash = interpreter.bytecode.get_or_calculate_hash(); if self.current_hash != *hash { self.insert_map(interpreter); } @@ -73,7 +93,7 @@ impl LineCoverageCollector { #[cold] #[inline(never)] fn insert_map(&mut self, interpreter: &mut Interpreter) { - let hash = interpreter.bytecode.hash().unwrap_or_else(|| eof_panic()); + let hash = interpreter.bytecode.hash().unwrap(); self.current_hash = hash; // Converts the mutable reference to a `NonNull` pointer. self.current_map = self @@ -83,21 +103,3 @@ impl LineCoverageCollector { .into(); } } - -/// Helper function for extracting contract hash used to record coverage hit map. -/// -/// If the contract hash is zero (contract not yet created but it's going to be created in current -/// tx) then the hash is calculated from the bytecode. -#[inline] -fn get_or_insert_contract_hash(interpreter: &mut Interpreter) -> B256 { - if interpreter.bytecode.hash().is_none_or(|h| h.is_zero()) { - interpreter.bytecode.regenerate_hash(); - } - interpreter.bytecode.hash().unwrap_or_else(|| eof_panic()) -} - -#[cold] -#[inline(never)] -fn eof_panic() -> ! { - panic!("coverage does not support EOF"); -} diff --git a/crates/evm/coverage/src/lib.rs b/crates/evm/coverage/src/lib.rs index 8e6f889f309cd..a24cd582b563c 100644 --- a/crates/evm/coverage/src/lib.rs +++ b/crates/evm/coverage/src/lib.rs @@ -3,14 +3,17 @@ //! EVM bytecode coverage analysis. #![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] +#![cfg_attr(docsrs, feature(doc_cfg))] #[macro_use] extern crate tracing; use alloy_primitives::{ Bytes, - map::{B256HashMap, HashMap}, + map::{ + B256HashMap, HashMap, + rustc_hash::{FxHashMap, FxHashSet}, + }, }; use analysis::SourceAnalysis; use eyre::Result; @@ -18,7 +21,7 @@ use foundry_compilers::artifacts::sourcemap::SourceMap; use semver::Version; use std::{ collections::BTreeMap, - fmt::Display, + fmt, num::NonZeroU32, ops::{Deref, DerefMut, Range}, path::{Path, PathBuf}, @@ -37,14 +40,18 @@ pub use inspector::LineCoverageCollector; /// "anchors"). A single coverage item may be referred to by multiple anchors. #[derive(Clone, Debug, Default)] pub struct CoverageReport { - /// A map of source IDs to the source path. - pub source_paths: HashMap<(Version, usize), PathBuf>, - /// A map of source paths to source IDs. - pub source_paths_to_ids: HashMap<(Version, PathBuf), usize>, - /// All coverage items for the codebase, keyed by the compiler version. - pub analyses: HashMap, + /// A map of compiler build IDs and source IDs to source paths. + pub source_paths: HashMap>, + /// A map of compiler build IDs and source paths to source IDs. + pub source_paths_to_ids: HashMap>, + /// All coverage items for the codebase, keyed by the compiler build ID. + pub analyses: HashMap, /// All item anchors for the codebase, keyed by their contract ID. - pub anchors: HashMap, Vec)>, + /// + /// `(id, (creation, runtime))` + pub anchors: HashMap, + /// Execution-based anchors for coverage items without source-mapped bytecode. + execution_anchors: HashMap, /// All the bytecode hits for the codebase. pub bytecode_hits: HashMap, /// The bytecode -> source mappings. @@ -53,14 +60,19 @@ pub struct CoverageReport { impl CoverageReport { /// Add a source file path. - pub fn add_source(&mut self, version: Version, source_id: usize, path: PathBuf) { - self.source_paths.insert((version.clone(), source_id), path.clone()); - self.source_paths_to_ids.insert((version, path), source_id); + pub fn add_source(&mut self, build_id: String, source_id: usize, path: PathBuf) { + self.source_paths.entry(build_id.clone()).or_default().insert(source_id, path.clone()); + self.source_paths_to_ids.entry(build_id).or_default().insert(path, source_id); } /// Get the source ID for a specific source file path. - pub fn get_source_id(&self, version: Version, path: PathBuf) -> Option { - self.source_paths_to_ids.get(&(version, path)).copied() + pub fn get_source_id(&self, build_id: &str, path: &Path) -> Option { + self.source_paths_to_ids.get(build_id)?.get(path).copied() + } + + /// Get the source path for a source ID in a compiler build. + pub fn get_source_path(&self, build_id: &str, source_id: usize) -> Option<&Path> { + self.source_paths.get(build_id)?.get(&source_id).map(PathBuf::as_path) } /// Add the source maps. @@ -72,41 +84,68 @@ impl CoverageReport { } /// Add a [`SourceAnalysis`] to this report. - pub fn add_analysis(&mut self, version: Version, analysis: SourceAnalysis) { - self.analyses.insert(version, analysis); + pub fn add_analysis(&mut self, build_id: String, analysis: SourceAnalysis) { + self.analyses.insert(build_id, analysis); } /// Add anchors to this report. + /// + /// `(id, (creation, runtime))` pub fn add_anchors( &mut self, - anchors: impl IntoIterator, Vec))>, + anchors: impl IntoIterator, ) { self.anchors.extend(anchors); } - /// Returns an iterator over coverage summaries by source file path. - pub fn summary_by_file(&self) -> impl Iterator { - self.by_file(|summary: &mut CoverageSummary, item| summary.add_item(item)) + /// Adds execution-based anchors for a contract. + pub fn add_execution_anchors( + &mut self, + contract_id: ContractId, + anchors: Vec, + function_selectors: impl IntoIterator, + has_receive: bool, + fallback_payable: bool, + ) { + if anchors.is_empty() { + return; + } + self.execution_anchors.insert( + contract_id, + ContractExecutionAnchors { + anchors, + function_selectors: function_selectors.into_iter().collect(), + has_receive, + fallback_payable, + }, + ); } - /// Returns an iterator over coverage items by source file path. - pub fn items_by_file(&self) -> impl Iterator)> { - self.by_file(|list: &mut Vec<_>, item| list.push(item)) + /// Returns an iterator over coverage summaries by source file path. + pub fn summary_by_file(&self) -> impl Iterator { + self.items_by_file().map(|(path, items)| { + let summary = CoverageSummary::from_items(&items); + (path, summary) + }) } - fn by_file<'a, T: Default>( - &'a self, - mut f: impl FnMut(&mut T, &'a CoverageItem), - ) -> impl Iterator { - let mut by_file: BTreeMap<&Path, T> = BTreeMap::new(); - for (version, items) in &self.analyses { + /// Returns coverage items by source file path, merging duplicate items from compiler builds. + pub fn items_by_file(&self) -> impl Iterator)> { + let mut by_file = BTreeMap::<&Path, BTreeMap, CoverageItem>>::new(); + for (build_id, items) in &self.analyses { for item in items.all_items() { - let key = (version.clone(), item.loc.source_id); - let Some(path) = self.source_paths.get(&key) else { continue }; - f(by_file.entry(path).or_default(), item); + let Some(path) = self.get_source_path(build_id, item.loc.source_id) else { + continue; + }; + by_file + .entry(path) + .or_default() + .entry(CoverageItemKey::new(item)) + .and_modify(|merged| merged.hits = merged.hits.saturating_add(item.hits)) + .or_insert_with(|| item.clone()); } } - by_file.into_iter() + by_file.into_iter().map(|(path, items)| (path, items.into_values().collect())) } /// Processes data from a [`HitMap`] and sets hit counts for coverage items in this coverage @@ -129,33 +168,79 @@ impl CoverageReport { // Add source level hits. if let Some(anchors) = self.anchors.get(contract_id) { let anchors = if is_deployed_code { &anchors.1 } else { &anchors.0 }; - for anchor in anchors { - if let Some(hits) = hit_map.get(anchor.instruction) { - self.analyses - .get_mut(&contract_id.version) - .and_then(|items| items.all_items_mut().get_mut(anchor.item_id as usize)) - .expect("Anchor refers to non-existent coverage item") - .hits += hits.get(); - } + for (anchor, hits) in anchors.hits(hit_map) { + self.analyses + .get_mut(&contract_id.build_id) + .and_then(|items| items.all_items_mut().get_mut(anchor.item_id as usize)) + .expect("Anchor refers to non-existent coverage item") + .hits += hits.get(); + } + } + if let Some(anchors) = self.execution_anchors.get(contract_id) { + for anchor in &anchors.anchors { + let hits = anchors.hits(hit_map, anchor.kind, is_deployed_code); + self.analyses + .get_mut(&contract_id.build_id) + .and_then(|items| items.all_items_mut().get_mut(anchor.item_id as usize)) + .expect("Anchor refers to non-existent coverage item") + .hits += hits; } } Ok(()) } - /// Retains all the coverage items specified by `predicate`. + /// Returns the coverage items hit by a [`HitMap`] without mutating this report. + pub fn hit_items_for_hit_map<'a>( + &'a self, + contract_id: &ContractId, + hit_map: &HitMap, + is_deployed_code: bool, + ) -> Vec<(&'a CoverageItem, u32)> { + let Some(anchors) = self.anchors.get(contract_id) else { return Vec::new() }; + let anchors = if is_deployed_code { &anchors.1 } else { &anchors.0 }; + + let mut hits_by_item = BTreeMap::::new(); + for (anchor, hits) in anchors.hits(hit_map) { + *hits_by_item.entry(anchor.item_id).or_default() += hits.get(); + } + if let Some(anchors) = self.execution_anchors.get(contract_id) { + for anchor in &anchors.anchors { + let hits = anchors.hits(hit_map, anchor.kind, is_deployed_code); + if hits > 0 { + *hits_by_item.entry(anchor.item_id).or_default() += hits; + } + } + } + + let Some(items) = self.analyses.get(&contract_id.build_id) else { + return Vec::new(); + }; + hits_by_item + .into_iter() + .filter_map(|(item_id, hits)| { + let item = items.get(item_id)?; + Some((item, hits)) + }) + .collect() + } + + /// Retains all the sources specified by `predicate`. /// /// This function should only be called after all the sources were used, otherwise, the output /// will be missing the ones that are dependent on them. pub fn retain_sources(&mut self, mut predicate: impl FnMut(&Path) -> bool) { - self.analyses.retain(|version, analysis| { - analysis.all_items_mut().retain(|item| { - self.source_paths - .get(&(version.clone(), item.loc.source_id)) - .map(|path| predicate(path)) - .unwrap_or(false) + self.source_paths.retain(|_, paths| { + paths.retain(|_, path| predicate(path)); + !paths.is_empty() + }); + + let source_paths = &self.source_paths; + self.source_paths_to_ids.retain(|build_id, paths| { + paths.retain(|_, source_id| { + source_paths.get(build_id).is_some_and(|paths| paths.contains_key(source_id)) }); - !analysis.all_items().is_empty() + !paths.is_empty() }); } } @@ -203,25 +288,80 @@ impl DerefMut for HitMaps { } } +#[derive(Clone, Copy, Debug)] +enum CallData { + Empty, + Short, + Selector([u8; 4]), +} + +impl CallData { + fn new(input: &[u8]) -> Self { + if input.is_empty() { + Self::Empty + } else if let Some(selector) = input.get(..4) { + Self::Selector(selector.try_into().unwrap()) + } else { + Self::Short + } + } +} + +#[derive(Clone, Copy, Debug, Default)] +struct CallHits { + without_value: u32, + with_value: u32, +} + +impl CallHits { + const fn hit(&mut self, with_value: bool) { + if with_value { + self.with_value += 1; + } else { + self.without_value += 1; + } + } + + const fn merge(&mut self, other: Self) { + self.without_value += other.without_value; + self.with_value += other.with_value; + } + + const fn total(self, payable: bool) -> u32 { + self.without_value + if payable { self.with_value } else { 0 } + } +} + /// Hit data for an address. /// /// Contains low-level data about hit counters for the instructions in the bytecode of a contract. #[derive(Clone, Debug)] pub struct HitMap { + hits: FxHashMap, bytecode: Bytes, - hits: HashMap, + creations: u32, + empty_calls: CallHits, + short_calls: CallHits, + selector_calls: FxHashMap<[u8; 4], CallHits>, } impl HitMap { /// Create a new hitmap with the given bytecode. #[inline] pub fn new(bytecode: Bytes) -> Self { - Self { bytecode, hits: HashMap::with_capacity_and_hasher(1024, Default::default()) } + Self { + bytecode, + hits: HashMap::with_capacity_and_hasher(1024, Default::default()), + creations: 0, + empty_calls: Default::default(), + short_calls: Default::default(), + selector_calls: Default::default(), + } } /// Returns the bytecode. #[inline] - pub fn bytecode(&self) -> &Bytes { + pub const fn bytecode(&self) -> &Bytes { &self.bytecode } @@ -243,12 +383,37 @@ impl HitMap { *self.hits.entry(pc).or_default() += hits; } + fn call(&mut self, call: CallData, with_value: bool) { + let hits = match call { + CallData::Empty => &mut self.empty_calls, + CallData::Short => &mut self.short_calls, + CallData::Selector(selector) => self.selector_calls.entry(selector).or_default(), + }; + hits.hit(with_value); + } + + const fn creation(&mut self) { + self.creations += 1; + } + + /// Reserve space for additional hits. + #[inline] + pub fn reserve(&mut self, additional: usize) { + self.hits.reserve(additional); + } + /// Merge another hitmap into this, assuming the bytecode is consistent pub fn merge(&mut self, other: &Self) { - self.hits.reserve(other.len()); + self.reserve(other.len()); for (pc, hits) in other.iter() { self.hits(pc, hits); } + self.creations += other.creations; + self.empty_calls.merge(other.empty_calls); + self.short_calls.merge(other.short_calls); + for (&selector, &hits) in &other.selector_calls { + self.selector_calls.entry(selector).or_default().merge(hits); + } } /// Returns an iterator over all the program counters and their hit counts. @@ -270,16 +435,17 @@ impl HitMap { } } -/// A unique identifier for a contract +/// A unique identifier for a contract. #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct ContractId { pub version: Version, + pub build_id: String, pub source_id: usize, pub contract_name: Arc, } -impl Display for ContractId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl fmt::Display for ContractId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, "Contract \"{}\" (solc {}, source ID {})", @@ -297,12 +463,98 @@ pub struct ItemAnchor { pub item_id: u32, } -impl Display for ItemAnchor { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +const _: () = assert!(std::mem::size_of::() == 8); + +/// Item anchors for one contract's creation or runtime bytecode. +#[derive(Clone, Debug, Default)] +pub struct ItemAnchors { + /// Anchors into the source analysis. + pub anchors: Vec, + /// Sparse map from anchor indices to the conditional jumps whose taken edges they represent. + /// Indices distinguish anchors even when they share an item or destination. + pub jumps: FxHashMap, +} + +impl ItemAnchors { + fn hits<'a>( + &'a self, + hit_map: &'a HitMap, + ) -> impl Iterator { + self.anchors.iter().enumerate().filter_map(|(index, anchor)| { + let hits = if let Some(&jump) = self.jumps.get(&index) { + // A destination can also be reached through the other branch. Count executions + // of the jump minus its fall-through instruction instead of destination hits. + let hits = hit_map.get(jump)?.get(); + NonZeroU32::new( + hits.saturating_sub(hit_map.get(jump + 1).map_or(0, NonZeroU32::get)), + )? + } else { + hit_map.get(anchor.instruction)? + }; + Some((anchor, hits)) + }) + } +} + +impl fmt::Display for ItemAnchor { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "IC {} -> Item {}", self.instruction, self.item_id) } } +/// An execution-based anchor for a coverage item without source-mapped bytecode. +#[derive(Clone, Copy, Debug)] +pub struct ExecutionAnchor { + /// The item ID this anchor points to. + pub item_id: u32, + /// The execution path that marks the item as covered. + pub kind: ExecutionAnchorKind, +} + +/// The execution path associated with an execution-based anchor. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ExecutionAnchorKind { + /// A successful contract creation. + Constructor, + /// An empty calldata call routed to `receive`. + Receive, + /// A call routed to `fallback`. + Fallback, +} + +#[derive(Clone, Debug)] +struct ContractExecutionAnchors { + anchors: Vec, + function_selectors: FxHashSet<[u8; 4]>, + has_receive: bool, + fallback_payable: bool, +} + +impl ContractExecutionAnchors { + fn hits(&self, hit_map: &HitMap, kind: ExecutionAnchorKind, is_deployed_code: bool) -> u32 { + match (kind, is_deployed_code) { + (ExecutionAnchorKind::Constructor, false) => hit_map.creations, + (ExecutionAnchorKind::Receive, true) => hit_map.empty_calls.total(true), + (ExecutionAnchorKind::Fallback, true) => { + let empty_calls = if self.has_receive { + 0 + } else { + hit_map.empty_calls.total(self.fallback_payable) + }; + empty_calls + + hit_map.short_calls.total(self.fallback_payable) + + hit_map + .selector_calls + .iter() + .filter(|(selector, _)| !self.function_selectors.contains(*selector)) + .map(|(_, hits)| hits.total(self.fallback_payable)) + .sum::() + } + _ => 0, + } + } +} + #[derive(Clone, Debug)] pub enum CoverageItemKind { /// An executable line in the code. @@ -326,37 +578,172 @@ pub enum CoverageItemKind { /// A function in the code. Function { /// The name of the function. - name: String, + name: Box, }, } +impl PartialEq for CoverageItemKind { + fn eq(&self, other: &Self) -> bool { + self.ord_key() == other.ord_key() + } +} + +impl Eq for CoverageItemKind {} + +impl PartialOrd for CoverageItemKind { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for CoverageItemKind { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.ord_key().cmp(&other.ord_key()) + } +} + +impl CoverageItemKind { + fn ord_key(&self) -> impl Ord + use<> { + match *self { + Self::Line => 0, + Self::Statement => 1, + Self::Branch { .. } => 2, + Self::Function { .. } => 3, + } + } +} + #[derive(Clone, Debug)] pub struct CoverageItem { /// The coverage item kind. pub kind: CoverageItemKind, /// The location of the item in the source code. pub loc: SourceLocation, + /// An alternative source location used only to find the item's bytecode anchor. + pub anchor_loc: Option, /// The number of times this item was hit. pub hits: u32, } -impl Display for CoverageItem { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match &self.kind { - CoverageItemKind::Line => { - write!(f, "Line")?; - } - CoverageItemKind::Statement => { - write!(f, "Statement")?; - } +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum CoverageItemKindKey<'a> { + Line, + Statement, + Branch { branch_id: u32, path_id: u32 }, + Function(&'a str), +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +struct CoverageItemKey<'a> { + line_start: u32, + line_end: u32, + kind_order: u8, + byte_start: u32, + byte_end: u32, + contract_name: &'a str, + kind: CoverageItemKindKey<'a>, +} + +impl<'a> CoverageItemKey<'a> { + fn new(item: &'a CoverageItem) -> Self { + let (kind_order, kind) = match &item.kind { + CoverageItemKind::Line => (0, CoverageItemKindKey::Line), + CoverageItemKind::Statement => (1, CoverageItemKindKey::Statement), CoverageItemKind::Branch { branch_id, path_id, .. } => { - write!(f, "Branch (branch: {branch_id}, path: {path_id})")?; + (2, CoverageItemKindKey::Branch { branch_id: *branch_id, path_id: *path_id }) } CoverageItemKind::Function { name } => { - write!(f, r#"Function "{name}""#)?; + (3, CoverageItemKindKey::Function(name.as_ref())) } + }; + + Self { + line_start: item.loc.lines.start, + line_end: item.loc.lines.end, + kind_order, + byte_start: item.loc.bytes.start, + byte_end: item.loc.bytes.end, + contract_name: item.loc.contract_name.as_ref(), + kind, } - write!(f, " (location: {}, hits: {})", self.loc, self.hits) + } +} + +impl PartialEq for CoverageItem { + fn eq(&self, other: &Self) -> bool { + self.ord_key() == other.ord_key() + } +} + +impl Eq for CoverageItem {} + +impl PartialOrd for CoverageItem { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for CoverageItem { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.ord_key().cmp(&other.ord_key()) + } +} + +impl fmt::Display for CoverageItem { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.fmt_with_source(None).fmt(f) + } +} + +impl CoverageItem { + fn ord_key(&self) -> impl Ord + use<> { + ( + self.loc.source_id, + self.loc.lines.start, + self.loc.lines.end, + self.kind.ord_key(), + self.loc.bytes.start, + self.loc.bytes.end, + ) + } + + pub fn fmt_with_source(&self, src: Option<&str>) -> impl fmt::Display { + solar::data_structures::fmt::from_fn(move |f| { + match &self.kind { + CoverageItemKind::Line => { + write!(f, "Line")?; + } + CoverageItemKind::Statement => { + write!(f, "Statement")?; + } + CoverageItemKind::Branch { branch_id, path_id, .. } => { + write!(f, "Branch (branch: {branch_id}, path: {path_id})")?; + } + CoverageItemKind::Function { name } => { + write!(f, r#"Function "{name}""#)?; + } + } + write!(f, " (location: ({}), hits: {})", self.loc, self.hits)?; + + if let Some(src) = src + && let Some(src) = src.get(self.loc.bytes()) + { + write!(f, " -> ")?; + + let max_len = 64; + let max_half = max_len / 2; + + if src.len() > max_len { + write!(f, "\"{}", src[..max_half].escape_debug())?; + write!(f, "...")?; + write!(f, "{}\"", src[src.len() - max_half..].escape_debug())?; + } else { + write!(f, "{src:?}")?; + } + } + + Ok(()) + }) } } @@ -373,13 +760,18 @@ pub struct SourceLocation { pub lines: Range, } -impl Display for SourceLocation { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "source ID {}, lines {:?}, bytes {:?}", self.source_id, self.lines, self.bytes) +impl fmt::Display for SourceLocation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "source ID: {}, lines: {:?}, bytes: {:?}", self.source_id, self.lines, self.bytes) } } impl SourceLocation { + /// Returns the byte range as usize. + pub const fn bytes(&self) -> Range { + self.bytes.start as usize..self.bytes.end as usize + } + /// Returns the length of the byte range. pub fn len(&self) -> u32 { self.bytes.len() as u32 @@ -420,13 +812,13 @@ impl CoverageSummary { /// Creates a coverage summary from a collection of coverage items. pub fn from_items<'a>(items: impl IntoIterator) -> Self { - let mut summary = Self::default(); + let mut summary = Self::new(); summary.add_items(items); summary } /// Adds another coverage summary to this one. - pub fn merge(&mut self, other: &Self) { + pub const fn merge(&mut self, other: &Self) { let Self { line_count, line_hits, @@ -448,7 +840,7 @@ impl CoverageSummary { } /// Adds a coverage item to this summary. - pub fn add_item(&mut self, item: &CoverageItem) { + pub const fn add_item(&mut self, item: &CoverageItem) { match item.kind { CoverageItemKind::Line => { self.line_count += 1; @@ -484,3 +876,47 @@ impl CoverageSummary { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sparse_jump_hits_distinguish_shared_items_and_destinations() { + let anchors = ItemAnchors { + anchors: vec![ + ItemAnchor { instruction: 20, item_id: 0 }, + ItemAnchor { instruction: 20, item_id: 0 }, + ItemAnchor { instruction: 20, item_id: 0 }, + ], + jumps: [(1, 4), (2, 10)].into_iter().collect(), + }; + for (jump_hits, fallthrough_hits, taken_hits) in + [(3, 2, 1), (3, 0, 3), (3, 3, 0), (0, 0, 0)] + { + let mut hit_map = HitMap::new(Bytes::new()); + hit_map.hits(20, 5); + hit_map.hits(4, jump_hits); + hit_map.hits(5, fallthrough_hits); + hit_map.hits(10, 2); + hit_map.hits(11, 2); + + let hits = anchors.hits(&hit_map).map(|(_, hits)| hits.get()).collect::>(); + let expected = if taken_hits == 0 { vec![5] } else { vec![5, taken_hits] }; + assert_eq!(hits, expected); + } + } + + #[test] + fn ordinary_anchors_do_not_allocate_jump_metadata() { + let anchors = ItemAnchors { + anchors: vec![ItemAnchor { instruction: 20, item_id: 0 }], + ..Default::default() + }; + assert_eq!(anchors.jumps.capacity(), 0); + let mut hit_map = HitMap::new(Bytes::new()); + assert_eq!(anchors.hits(&hit_map).count(), 0); + hit_map.hits(20, 3); + assert_eq!(anchors.hits(&hit_map).next().unwrap().1.get(), 3); + } +} diff --git a/crates/evm/evm/Cargo.toml b/crates/evm/evm/Cargo.toml index 7124c9d0bb0ac..723c030f12482 100644 --- a/crates/evm/evm/Cargo.toml +++ b/crates/evm/evm/Cargo.toml @@ -21,9 +21,13 @@ foundry-config.workspace = true foundry-evm-core.workspace = true foundry-evm-coverage.workspace = true foundry-evm-fuzz.workspace = true +foundry-evm-sancov.workspace = true +foundry-evm-hardforks.workspace = true +foundry-evm-networks.workspace = true foundry-evm-traces.workspace = true alloy-dyn-abi = { workspace = true, features = ["arbitrary", "eip712"] } +alloy-eips.workspace = true alloy-evm.workspace = true alloy-json-abi.workspace = true alloy-primitives = { workspace = true, features = [ @@ -32,26 +36,74 @@ alloy-primitives = { workspace = true, features = [ "arbitrary", "rlp", ] } -alloy-rpc-types.workspace = true +alloy-rpc-types = { workspace = true, features = ["trace"] } alloy-sol-types.workspace = true revm = { workspace = true, default-features = false, features = [ "std", "serde", "memory_limit", "optional_eip3607", + "optional_eip7623", "optional_block_gas_limit", "optional_no_base_fee", "arbitrary", "c-kzg", ] } revm-inspectors.workspace = true +tempo-precompiles.workspace = true +tempo-primitives.workspace = true eyre.workspace = true parking_lot.workspace = true proptest.workspace = true +rand.workspace = true thiserror.workspace = true tracing.workspace = true indicatif.workspace = true serde_json.workspace = true serde.workspace = true uuid.workspace = true +rayon.workspace = true +tokio.workspace = true + +# monad +alloy-monad-evm = { workspace = true, optional = true } +monad-revm = { workspace = true, optional = true } + +[dev-dependencies] +tempo-revm.workspace = true + +[features] +default = ["optimism"] +monad = [ + "dep:monad-revm", + "dep:alloy-monad-evm", + "foundry-evm-core/monad", + "foundry-evm-hardforks/monad", + "foundry-evm-networks/monad", + "foundry-cheatcodes/monad", + "foundry-evm-coverage/monad", + "foundry-evm-fuzz/monad", + "foundry-evm-traces/monad", +] +base = [ + "foundry-cheatcodes/base", + "foundry-common/base", + "foundry-config/base", + "foundry-evm-core/base", + "foundry-evm-coverage/base", + "foundry-evm-fuzz/base", + "foundry-evm-hardforks/base", + "foundry-evm-networks/base", + "foundry-evm-traces/base", +] +optimism = [ + "foundry-evm-core/optimism", + "foundry-evm-hardforks/optimism", + "foundry-evm-networks/optimism", + "foundry-common/optimism", + "foundry-cheatcodes/optimism", + "foundry-evm-coverage/optimism", + "foundry-evm-fuzz/optimism", + "foundry-evm-traces/optimism", +] diff --git a/crates/evm/evm/src/executors/builder.rs b/crates/evm/evm/src/executors/builder.rs index 8407f9aa09ff9..65d42d80470f1 100644 --- a/crates/evm/evm/src/executors/builder.rs +++ b/crates/evm/evm/src/executors/builder.rs @@ -1,50 +1,74 @@ -use crate::{executors::Executor, inspectors::InspectorStackBuilder}; -use foundry_evm_core::{Env, backend::Backend}; -use revm::primitives::hardfork::SpecId; +use crate::{ + executors::Executor, + inspectors::{InspectorStackBuilder, TempoLabels}, +}; +use alloy_primitives::Address; +use foundry_evm_core::{ + backend::Backend, + evm::{ + BlockEnvFor, EthEvmNetwork, EvmEnvFor, FoundryEvmNetwork, SpecFor, TempoEvmNetwork, + TxEnvFor, + }, +}; +use foundry_evm_networks::NetworkConfigs; +use revm::context::{Block, Transaction}; + +#[cfg(feature = "base")] +use foundry_evm_core::evm::BaseEvmNetwork; + +#[cfg(feature = "monad")] +use foundry_evm_core::{constants::MONAD_CHEATCODE_ADDRESS, evm::MonadEvmNetwork}; + +#[cfg(feature = "optimism")] +use foundry_evm_core::evm::OpEvmNetwork; /// The builder that allows to configure an evm [`Executor`] which a stack of optional /// [`revm::Inspector`]s, such as [`Cheatcodes`]. /// -/// By default, the [`Executor`] will be configured with an empty [`InspectorStack`]. +/// By default, the [`Executor`] will be configured with an empty [`InspectorStack`] and no +/// network-specific tooling. Command dispatch should use the concrete FEN's inherent `new` +/// constructor so any required tooling is selected there. /// /// [`Cheatcodes`]: super::Cheatcodes /// [`InspectorStack`]: super::InspectorStack -#[derive(Clone, Debug)] +#[derive(Debug, Clone)] #[must_use = "builders do nothing unless you call `build` on them"] -pub struct ExecutorBuilder { +pub struct ExecutorBuilder { /// The configuration used to build an `InspectorStack`. - stack: InspectorStackBuilder, + stack: InspectorStackBuilder>, /// The gas limit. gas_limit: Option, - /// The spec ID. - spec_id: SpecId, + /// The spec override. When `None`, the spec from `EvmEnv::cfg_env` is preserved. + spec: Option>, legacy_assertions: bool, } -impl Default for ExecutorBuilder { +impl Default for ExecutorBuilder { #[inline] fn default() -> Self { Self { - stack: InspectorStackBuilder::new(), + stack: InspectorStackBuilder::new().extra_cheatcode_addresses(&[]), gas_limit: None, - spec_id: SpecId::default(), + spec: None, legacy_assertions: false, } } } -impl ExecutorBuilder { - /// Create a new executor builder. +impl ExecutorBuilder { + /// Returns additional cheatcode addresses selected for this executor. #[inline] - pub fn new() -> Self { - Self::default() + pub const fn extra_cheatcode_addresses(&self) -> &'static [Address] { + self.stack.extra_cheatcode_addresses } /// Modify the inspector stack. #[inline] pub fn inspectors( mut self, - f: impl FnOnce(InspectorStackBuilder) -> InspectorStackBuilder, + f: impl FnOnce( + InspectorStackBuilder>, + ) -> InspectorStackBuilder>, ) -> Self { self.stack = f(self.stack); self @@ -52,42 +76,98 @@ impl ExecutorBuilder { /// Sets the EVM spec to use. #[inline] - pub fn spec_id(mut self, spec: SpecId) -> Self { - self.spec_id = spec; + pub const fn spec_id(mut self, spec: SpecFor) -> Self { + self.spec = Some(spec); self } + /// Optionally sets the EVM spec. When `None`, the spec from `EvmEnv::cfg_env` is preserved. + #[inline] + pub const fn spec_id_opt(self, spec: Option>) -> Self { + if let Some(spec) = spec { self.spec_id(spec) } else { self } + } + /// Sets the executor gas limit. #[inline] - pub fn gas_limit(mut self, gas_limit: u64) -> Self { + pub const fn gas_limit(mut self, gas_limit: u64) -> Self { self.gas_limit = Some(gas_limit); self } /// Sets the `legacy_assertions` flag. #[inline] - pub fn legacy_assertions(mut self, legacy_assertions: bool) -> Self { + pub const fn legacy_assertions(mut self, legacy_assertions: bool) -> Self { self.legacy_assertions = legacy_assertions; self } /// Builds the executor as configured. #[inline] - pub fn build(self, env: Env, db: Backend) -> Executor { - let Self { mut stack, gas_limit, spec_id, legacy_assertions } = self; + pub fn build( + self, + mut evm_env: EvmEnvFor, + tx_env: TxEnvFor, + db: Backend, + // TODO(monad-fen-lifecycle): Remove this argument after the backend's Monad fork-position + // migration and the inspector's separate Celo configuration cleanup are complete. + networks: NetworkConfigs, + ) -> Executor { + let Self { mut stack, gas_limit, spec, legacy_assertions, .. } = self; + stack.networks = networks; if stack.block.is_none() { - stack.block = Some(env.evm_env.block_env.clone()); + stack.block = Some(evm_env.block_env.clone()); } if stack.gas_price.is_none() { - stack.gas_price = Some(env.tx.gas_price); + stack.gas_price = Some(tx_env.gas_price()); } - let gas_limit = gas_limit.unwrap_or(env.evm_env.block_env.gas_limit); - let env = Env::new_with_spec_id( - env.evm_env.cfg_env.clone(), - env.evm_env.block_env.clone(), - env.tx, - spec_id, - ); - Executor::new(db, env, stack.build(), gas_limit, legacy_assertions) + let gas_limit = gas_limit.unwrap_or(evm_env.block_env.gas_limit()); + if let Some(spec) = spec { + evm_env.cfg_env.set_spec_and_mainnet_gas_params(spec); + } + Executor::new(db, evm_env, tx_env, stack.build(), networks, gas_limit, legacy_assertions) + } +} + +impl ExecutorBuilder { + /// Creates the default Ethereum executor builder. + #[inline] + pub fn new() -> Self { + Self::default() + } +} + +#[cfg(feature = "base")] +impl ExecutorBuilder { + /// Creates the default Base executor builder. + #[inline] + pub fn new() -> Self { + Self::default() + } +} + +#[cfg(feature = "optimism")] +impl ExecutorBuilder { + /// Creates the default OP executor builder. + #[inline] + pub fn new() -> Self { + Self::default() + } +} + +impl ExecutorBuilder { + /// Creates a Tempo executor builder with its native label inspector. + #[inline] + pub fn new() -> Self { + Self::default().inspectors(|stack| stack.tempo_labels(TempoLabels::default())) + } +} + +#[cfg(feature = "monad")] +impl ExecutorBuilder { + /// Creates a Monad executor builder with MonadVM cheatcode support. + #[inline] + pub fn new() -> Self { + Self::default() + .inspectors(|stack| stack.extra_cheatcode_addresses(&[MONAD_CHEATCODE_ADDRESS])) } } diff --git a/crates/evm/evm/src/executors/campaign.rs b/crates/evm/evm/src/executors/campaign.rs new file mode 100644 index 0000000000000..586902d86cae6 --- /dev/null +++ b/crates/evm/evm/src/executors/campaign.rs @@ -0,0 +1,258 @@ +//! Worker-local call execution policy shared by stateless and invariant fuzz campaigns. + +use super::{Executor, RawCallResult}; +use alloy_primitives::U256; +use eyre::{Result, eyre}; +use foundry_evm_core::{ + FoundryBlock, + constants::MAGIC_ASSUME, + evm::{BlockEnvFor, FoundryEvmNetwork}, +}; +use foundry_evm_fuzz::BasicTxDetails; +use revm::context::Block; + +/// The small set of execution policies which differ between fuzzing modes. +#[derive(Clone, Copy, Debug)] +pub(crate) enum FuzzCampaignMode { + /// Stateless calls never mutate the worker executor. + Stateless, + /// Accepted invariant calls mutate it and predicates run at the configured cadence. + Invariant { check_interval: u32, optimization: bool }, +} + +/// Classification produced by the common call loop. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CampaignCallKind { + Accepted, + AssumptionRejected, +} + +pub(crate) enum CampaignEvent<'a, FEN: FoundryEvmNetwork> { + Feedback(&'a mut RawCallResult), + Check { result: &'a mut Option>, kind: CampaignCallKind, should_check: bool }, + Advance, + Next { discarded: bool, depth: u32 }, + PostCheck, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CampaignControl { + Continue, + Stop, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CampaignSequenceOutcome { + Complete, + Cancelled, + Stopped, +} + +/// Concrete worker-local campaign execution policy. +pub(crate) struct FuzzCampaign { + mode: FuzzCampaignMode, +} + +impl FuzzCampaign { + pub(crate) const fn new(mode: FuzzCampaignMode) -> Self { + Self { mode } + } + + /// Drives one concrete sequence, including execution, classification, and lifecycle events. + pub(crate) fn run_sequence( + &self, + state: &mut S, + run_depth: u32, + mut parts: Parts, + mut depth: Depth, + mut should_stop: Stop, + mut on_event: Event, + ) -> Result + where + FEN: FoundryEvmNetwork, + Parts: for<'a> FnMut(&'a mut S) -> (&'a mut Executor, &'a mut BasicTxDetails), + Depth: FnMut(&S) -> u32, + Stop: FnMut(&S) -> bool, + Event: for<'a> FnMut(&mut S, CampaignEvent<'a, FEN>) -> Result, + { + let outcome = loop { + if depth(state) >= run_depth { + break CampaignSequenceOutcome::Complete; + } + if should_stop(state) { + return Ok(CampaignSequenceOutcome::Cancelled); + } + let current_depth = depth(state); + let last_call = current_depth == run_depth - 1; + let (mut result, block_snapshot) = { + let (executor, tx) = parts(state); + let snapshot = matches!(self.mode, FuzzCampaignMode::Invariant { .. }) + .then(|| BlockSnapshot::for_delayed_call(executor, tx)) + .flatten(); + let result = match self.mode { + FuzzCampaignMode::Stateless => executor.call_raw( + tx.sender, + tx.call_details.target, + tx.call_details.calldata.clone(), + tx.call_details.value.unwrap_or_default(), + )?, + FuzzCampaignMode::Invariant { .. } => execute_invariant_tx(executor, tx)?, + }; + (result, snapshot) + }; + if result.execution_cancelled { + if let Some(snapshot) = block_snapshot { + let (executor, _) = parts(state); + snapshot.restore(executor); + } + return Ok(CampaignSequenceOutcome::Cancelled); + } + on_event(state, CampaignEvent::Feedback(&mut result))?; + let kind = match self.mode { + FuzzCampaignMode::Invariant { .. } => { + let (executor, _) = parts(state); + transition_invariant_call(executor, &mut result, block_snapshot) + } + FuzzCampaignMode::Stateless => { + if result.result.as_ref() == MAGIC_ASSUME { + CampaignCallKind::AssumptionRejected + } else { + CampaignCallKind::Accepted + } + } + }; + let discarded = kind == CampaignCallKind::AssumptionRejected; + let should_check = match self.mode { + FuzzCampaignMode::Stateless => true, + FuzzCampaignMode::Invariant { optimization: true, .. } => true, + FuzzCampaignMode::Invariant { check_interval: 0, .. } => last_call, + FuzzCampaignMode::Invariant { check_interval, .. } => { + check_interval == 1 + || (current_depth + 1).is_multiple_of(check_interval) + || last_call + } + }; + let mut result = Some(result); + if on_event(state, CampaignEvent::Check { result: &mut result, kind, should_check })? + == CampaignControl::Stop + { + break CampaignSequenceOutcome::Stopped; + } + if !discarded { + on_event(state, CampaignEvent::Advance)?; + } + let next_depth = depth(state); + on_event(state, CampaignEvent::Next { discarded, depth: next_depth })?; + }; + on_event(state, CampaignEvent::PostCheck)?; + Ok(outcome) + } +} + +struct BlockSnapshot { + env: BlockEnvFor, + cheatcode: Option>, +} + +impl BlockSnapshot { + fn for_delayed_call(executor: &Executor, tx: &BasicTxDetails) -> Option { + let has_delay = tx.warp.is_some_and(|delay| !delay.is_zero()) + || tx.roll.is_some_and(|delay| !delay.is_zero()); + has_delay.then(|| Self::new(executor)) + } + + fn new(executor: &Executor) -> Self { + Self { + env: executor.evm_env().block_env.clone(), + cheatcode: executor.inspector().cheatcodes.as_ref().and_then(|c| c.block.clone()), + } + } + + fn restore(self, executor: &mut Executor) { + executor.evm_env_mut().block_env = self.env; + if let Some(cheatcodes) = executor.inspector_mut().cheatcodes.as_mut() { + cheatcodes.block = self.cheatcode; + } + } +} + +fn transition_invariant_call( + executor: &mut Executor, + result: &mut RawCallResult, + block_snapshot: Option>, +) -> CampaignCallKind { + if result.result.as_ref() == MAGIC_ASSUME { + if let Some(snapshot) = block_snapshot { + snapshot.restore(executor); + } + CampaignCallKind::AssumptionRejected + } else { + executor.commit(result); + CampaignCallKind::Accepted + } +} + +/// Executes and commits one invariant replay call with live campaign semantics. +pub(super) fn execute_invariant_replay_tx( + executor: &mut Executor, + tx: &BasicTxDetails, +) -> Result<(CampaignCallKind, RawCallResult)> { + let block_snapshot = BlockSnapshot::for_delayed_call(executor, tx); + let mut result = execute_invariant_tx(executor, &mut tx.clone())?; + if result.execution_cancelled { + if let Some(snapshot) = block_snapshot { + snapshot.restore(executor); + } + return Err(eyre!("invariant replay call was cancelled")); + } + let kind = transition_invariant_call(executor, &mut result, block_snapshot); + Ok((kind, result)) +} + +pub(super) fn execute_invariant_tx( + executor: &mut Executor, + tx: &mut BasicTxDetails, +) -> Result> { + apply_block_delay(executor, tx.warp.unwrap_or_default(), tx.roll.unwrap_or_default()); + let value = match tx.call_details.value { + Some(requested) if !requested.is_zero() => requested.min(executor.get_balance(tx.sender)?), + _ => U256::ZERO, + }; + // Persist exactly what was sent so replay, shrinking, and corpus entries do not claim an + // unavailable value was executed. + tx.call_details.value = (!value.is_zero()).then_some(value); + executor + .call_raw(tx.sender, tx.call_details.target, tx.call_details.calldata.clone(), value) + .map_err(|error| eyre!("Could not make raw evm call: {error}")) +} + +/// Advances the execution block environment and the cheatcode block override by an invariant +/// call's warp and roll delays. +pub(super) fn apply_block_delay( + executor: &mut Executor, + warp: U256, + roll: U256, +) { + if warp.is_zero() && roll.is_zero() { + return; + } + let needs_cheatcode_block = executor + .inspector() + .cheatcodes + .as_ref() + .is_some_and(|cheatcodes| cheatcodes.block.is_none()); + let block_env = { + let block_env = &mut executor.evm_env_mut().block_env; + block_env.set_timestamp(block_env.timestamp() + warp); + block_env.set_number(block_env.number() + roll); + needs_cheatcode_block.then(|| block_env.clone()) + }; + if let Some(cheatcodes) = executor.inspector_mut().cheatcodes.as_mut() { + if let Some(block) = cheatcodes.block.as_mut() { + block.set_timestamp(block.timestamp() + warp); + block.set_number(block.number() + roll); + } else { + cheatcodes.block = Some(block_env.unwrap()); + } + } +} diff --git a/crates/evm/evm/src/executors/corpus.rs b/crates/evm/evm/src/executors/corpus.rs new file mode 100644 index 0000000000000..22b467c9110b9 --- /dev/null +++ b/crates/evm/evm/src/executors/corpus.rs @@ -0,0 +1,3511 @@ +//! Corpus management for parallel fuzzing with coverage-guided mutation. +//! +//! This module implements a corpus-based fuzzing system that stores, mutates, and shares +//! transaction sequences across multiple fuzzing workers. Each corpus entry represents a +//! sequence of transactions that has produced interesting coverage, and can be mutated to +//! discover new execution paths. +//! +//! ## File System Structure +//! +//! The corpus is organized on disk as follows: +//! +//! ```text +//! / +//! ├── worker0/ # Master (worker 0) directory +//! │ ├── corpus/ # Master's corpus entries +//! │ │ ├── -.json # Corpus entry (if small) +//! │ │ ├── -.json.gz # Corpus entry (if large, compressed) +//! │ └── sync/ # Directory where other workers export new findings +//! │ └── -.json # New entries from other workers +//! └── workerN/ # Worker N's directory +//! ├── corpus/ # Worker N's local corpus +//! │ └── ... +//! └── sync/ # Worker 2's sync directory +//! └── ... +//! ``` +//! +//! ## Workflow +//! +//! - Each worker maintains its own local corpus with entries stored as JSON files +//! - Workers export new interesting entries to the master's sync directory via hard links +//! - The master (worker0) imports new entries from its sync directory and exports them to all the +//! other workers +//! - Workers sync with the master to receive new corpus entries from other workers +//! - This all happens periodically, there is no clear order in which workers export or import +//! entries since it doesn't matter as long as the corpus eventually syncs across all workers + +use super::corpus_io::{ + CorpusDirEntry, canonical_replay_dirs, read_corpus_dir, read_corpus_dir_strict, +}; +use crate::{ + executors::{ + Executor, RawCallResult, + campaign::{CampaignCallKind, execute_invariant_replay_tx}, + invariant::execute_tx, + }, + inspectors::{CmpOperands, EdgeIndexMap, MAX_EDGE_COUNT}, +}; +use alloy_dyn_abi::JsonAbiExt; +use alloy_json_abi::Function; +use alloy_primitives::{Address, Bytes, I256, U256}; +use alloy_sol_types::SolCall; +use eyre::{Result, eyre}; +use foundry_cheatcodes::Vm::{ + revertToAndDeleteCall, revertToCall, revertToStateAndDeleteCall, revertToStateCall, rollCall, + setEvmVersionCall, warpCall, +}; +use foundry_common::{ContractsByAddress, ContractsByArtifact, TestFunctionExt, sh_warn}; +use foundry_config::FuzzCorpusConfig; +use foundry_evm_core::{ + constants::{CALLER, CHEATCODE_ADDRESS}, + evm::FoundryEvmNetwork, + utils::StateChangeset, +}; +use foundry_evm_fuzz::{ + BasicTxDetails, CallDetails, ObservedCall, + invariant::{ + ArtifactFilters, FuzzRunIdentifiedContracts, InvariantContract, SenderFilters, + TargetedContracts, + }, + sequence::{ComparisonHint, CorpusEntryView, SequenceGenerator, SequencePlan}, +}; +use proptest::test_runner::TestRunner; +use revm::{context::Block, primitives::hardfork::SpecId}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashSet, + fmt, + path::{Path, PathBuf}, + sync::{ + Arc, + atomic::{AtomicBool, AtomicUsize, Ordering}, + }, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; +use uuid::Uuid; + +const WORKER: &str = "worker"; +const CORPUS_DIR: &str = "corpus"; +const SYNC_DIR: &str = "sync"; +const OPTIMIZATION_BEST_FILE: &str = "optimization_best.json"; + +const FAVORABILITY_THRESHOLD: f64 = 0.3; + +/// Threshold for compressing corpus entries. +/// 4KiB is usually the minimum file size on popular file systems. +const GZIP_THRESHOLD: usize = 4 * 1024; + +/// Persisted optimization state: the best value found and the sequence that produced it. +#[derive(Clone, Serialize, Deserialize)] +struct OptimizationState { + best_value: I256, + best_sequence: Vec, +} + +/// Holds Corpus information. +#[derive(Clone, Serialize)] +struct CorpusEntry { + // Unique corpus identifier. + uuid: Uuid, + // Total mutations of corpus as primary source. + total_mutations: usize, + // New coverage found as a result of mutating this corpus. + new_finds_produced: usize, + // Corpus call sequence. + #[serde(skip_serializing)] + tx_seq: Vec, + // Per-call EVM comparison operands observed while executing this corpus entry. + // Parallel to `tx_seq`. Empty inner vec means "no cmp data for this call". + #[serde(skip_serializing)] + cmp_seq: Vec>, + // Whether this corpus is favored, i.e. producing new finds more often than + // `FAVORABILITY_THRESHOLD`. + is_favored: bool, + /// Timestamp of when this entry was written to disk in seconds. + #[serde(skip_serializing)] + timestamp: u64, + /// Original filename for an entry imported from another worker. + #[serde(skip_serializing)] + persisted_file_name: Option, +} + +impl CorpusEntry { + /// Creates a corpus entry with a new UUID. + pub fn new(tx_seq: Vec) -> Self { + Self::new_with_cmp(tx_seq, Vec::new(), Uuid::new_v4()) + } + + /// Creates a corpus entry with the given UUID and per-call cmp operand log. + pub fn new_with_cmp( + tx_seq: Vec, + cmp_seq: Vec>, + uuid: Uuid, + ) -> Self { + Self { + uuid, + total_mutations: 0, + new_finds_produced: 0, + tx_seq, + cmp_seq, + is_favored: false, + timestamp: SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time went backwards") + .as_secs(), + persisted_file_name: None, + } + } + + fn write_to_disk_in(&self, dir: &Path, can_gzip: bool) -> foundry_common::fs::Result { + let should_gzip = self.should_gzip(can_gzip); + let file_name = self.file_name(should_gzip); + let path = dir.join(&file_name); + let temp_path = dir.join(format!(".{file_name}.{}.tmp", Uuid::new_v4())); + + let write_result = if should_gzip { + foundry_common::fs::write_json_gzip_file(&temp_path, &self.tx_seq) + } else { + foundry_common::fs::write_json_file(&temp_path, &self.tx_seq) + }; + if let Err(err) = write_result { + let _ = foundry_common::fs::remove_file(&temp_path); + return Err(err); + } + + if let Err(err) = std::fs::rename(&temp_path, &path) { + let _ = foundry_common::fs::remove_file(&temp_path); + return Err(foundry_common::errors::FsPathError::write(err, &path)); + } + + Ok(path) + } + + fn file_name(&self, gzip: bool) -> String { + if let Some(name) = &self.persisted_file_name { + return name.clone(); + } + let ext = if gzip { ".json.gz" } else { ".json" }; + format!("{}-{}{ext}", self.uuid, self.timestamp) + } + + fn should_gzip(&self, can_gzip: bool) -> bool { + if !can_gzip { + return false; + } + let size: usize = self.tx_seq.iter().map(|tx| tx.estimate_serialized_size()).sum(); + size > GZIP_THRESHOLD + } +} + +/// Persists one call sequence as a corpus seed in the canonical worker0 corpus directory. +pub fn persist_corpus_seed( + config: &FuzzCorpusConfig, + tx_seq: Vec, +) -> foundry_common::fs::Result> { + let Some(root) = &config.corpus_dir else { + return Ok(None); + }; + for dir in canonical_replay_dirs(root) { + for entry in read_corpus_dir(&dir) { + match entry.read_tx_seq() { + Ok(existing) if same_tx_sequence(&existing, &tx_seq) => { + return Ok(Some(entry.path)); + } + Ok(_) => {} + Err(err) => debug!(%err, path = ?entry.path, "failed to read corpus seed"), + } + } + } + let corpus_dir = root.join(format!("{WORKER}0")).join(CORPUS_DIR); + foundry_common::fs::create_dir_all(&corpus_dir)?; + CorpusEntry::new(tx_seq).write_to_disk_in(&corpus_dir, config.corpus_gzip).map(Some) +} + +fn same_tx_sequence(left: &[BasicTxDetails], right: &[BasicTxDetails]) -> bool { + left.len() == right.len() + && left.iter().zip(right).all(|(left, right)| { + left.warp == right.warp + && left.roll == right.roll + && left.sender == right.sender + && left.call_details.target == right.call_details.target + && left.call_details.calldata == right.call_details.calldata + && left.call_details.value == right.call_details.value + }) +} + +fn link_corpus_file(from: &Path, to: &Path) -> bool { + match std::fs::hard_link(from, to) { + Ok(()) => true, + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { + if !std::fs::symlink_metadata(to).is_ok_and(|metadata| metadata.file_type().is_file()) { + debug!(target: "corpus", from=?from, to=?to, "corpus destination is not a regular file"); + return false; + } + let entry = |path: &Path| CorpusDirEntry { + path: path.to_path_buf(), + uuid: Uuid::nil(), + timestamp: 0, + }; + let matches = entry(from) + .read_tx_seq() + .ok() + .zip(entry(to).read_tx_seq().ok()) + .is_some_and(|(from, to)| same_tx_sequence(&from, &to)); + if !matches { + debug!(target: "corpus", from=?from, to=?to, "conflicting corpus file already exists"); + } + matches + } + Err(err) => { + debug!(target: "corpus", %err, from=?from, to=?to, "failed to link corpus file"); + false + } + } +} + +fn accept_synced_corpus_file( + entry: &CorpusDirEntry, + tx_seq: &[BasicTxDetails], + corpus_path: &Path, +) -> bool { + if corpus_path.is_file() { + let existing = CorpusDirEntry { + path: corpus_path.to_path_buf(), + uuid: entry.uuid, + timestamp: entry.timestamp, + }; + if existing.read_tx_seq().is_ok_and(|existing| same_tx_sequence(&existing, tx_seq)) { + if let Err(err) = std::fs::remove_file(&entry.path) { + debug!(target: "corpus", %err, "failed to remove synced corpus link {}", entry.path.display()); + return false; + } + return true; + } + + warn!(target: "corpus", "not overwriting conflicting corpus file {}", corpus_path.display()); + let quarantine_path = + entry.path.with_file_name(format!("{}.{}.invalid", entry.name(), Uuid::new_v4())); + if let Err(err) = std::fs::rename(&entry.path, &quarantine_path) { + debug!(target: "corpus", %err, "failed to quarantine conflicting corpus file {}", entry.path.display()); + } + return false; + } + + if let Err(err) = std::fs::rename(&entry.path, corpus_path) { + debug!(target: "corpus", %err, "failed to move synced corpus from {:?} to {corpus_path:?} dir", entry.path); + return false; + } + true +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CorpusInsertionMode { + Live, + MemoryOnly, +} + +struct ReplayOutcome { + keep_entry: bool, + new_coverage: bool, + /// Whether replay hit a first-time edge (advances the per-worker "time since new edge" timer). + new_edge: bool, + cmp_seq: Vec>, + failed_replays: usize, +} + +#[derive(Clone, Copy)] +pub struct StatelessReplayTarget<'a> { + pub function: &'a Function, + pub address: Address, +} + +impl StatelessReplayTarget<'_> { + fn can_replay(self, tx: &BasicTxDetails) -> bool { + tx.call_details.target == self.address + && tx + .call_details + .calldata + .get(..4) + .is_some_and(|selector| self.function.selector() == selector) + } +} + +#[derive(Clone, Copy)] +pub(crate) struct ReplayTarget<'a> { + pub(crate) stateless: Option>, + pub(crate) fuzzed_contracts: Option<&'a FuzzRunIdentifiedContracts>, + pub(crate) dynamic: Option<&'a DynamicTargetCtx<'a>>, + /// Current invariant sender policy; persisted transactions from other senders are rejected. + pub(crate) senders: Option<&'a SenderFilters>, +} + +struct ReplayCoverage<'a> { + history_map: &'a mut Vec, + edge_indices: &'a mut EdgeIndexMap, + sancov_history_map: &'a mut Vec, + metrics: Option<&'a mut CorpusMetrics>, +} + +/// Campaign-level corpus state produced by replaying persisted corpus entries once. +/// +/// Parallel invariant workers clone this seed so every worker starts with the same warmed corpus +/// and coverage maps. That avoids each worker rediscovering persisted coverage relative to an empty +/// local map. +#[derive(Clone, Default)] +pub(crate) struct WorkerCorpusSeed { + in_memory_corpus: Vec, + history_map: Vec, + edge_indices: EdgeIndexMap, + sancov_history_map: Vec, + metrics: CorpusMetrics, + replay_dirs: Option>, + failed_replays: usize, + pub(crate) optimization_best_value: Option, + pub(crate) optimization_best_sequence: Vec, + /// Set if persisted-corpus replay hit a first-time edge, so the timer starts at the baseline + /// load instead of reading "never" while `cumulative_edges_seen` is non-zero. + last_new_edge_at: Option, +} + +impl WorkerCorpusSeed { + fn empty(config: &FuzzCorpusConfig) -> Self { + // Hash mode always merges a fixed `MAX_EDGE_COUNT` bitmap, so preallocate to avoid moving + // the one-time 64 KiB resize into the first merge. Collision-free and sancov maps grow on + // demand and start empty. + let history_map = + if config.collect_evm_edge_coverage() && !config.evm_edge_coverage_collision_free() { + vec![0u8; MAX_EDGE_COUNT] + } else { + Vec::new() + }; + Self { history_map, ..Default::default() } + } + + fn with_optimization_state(mut self, config: &FuzzCorpusConfig) -> Self { + if let Some((value, sequence)) = load_optimization_state(config) { + self.optimization_best_value = Some(value); + self.optimization_best_sequence = sequence; + } + self + } + + pub(crate) fn clone_for_worker( + &self, + worker_id: usize, + worker_count: usize, + include_cmp_seq: bool, + ) -> Self { + let in_memory_corpus = self + .in_memory_corpus + .iter() + .enumerate() + .filter(|(idx, _)| idx % worker_count == worker_id) + .map(|(_, entry)| { + let mut entry = entry.clone(); + if !include_cmp_seq { + entry.cmp_seq.clear(); + } + entry + }) + .collect::>(); + + let mut metrics = self.metrics.clone(); + metrics.corpus_count = in_memory_corpus.len(); + metrics.favored_items = in_memory_corpus.iter().filter(|entry| entry.is_favored).count(); + + Self { + in_memory_corpus, + history_map: self.history_map.clone(), + edge_indices: self.edge_indices.clone(), + sancov_history_map: self.sancov_history_map.clone(), + metrics, + replay_dirs: self.replay_dirs.clone(), + failed_replays: self.failed_replays, + optimization_best_value: self.optimization_best_value, + optimization_best_sequence: self.optimization_best_sequence.clone(), + last_new_edge_at: self.last_new_edge_at, + } + } + + pub(crate) fn retain_replayable(&mut self, targeted_contracts: &TargetedContracts) { + let is_replayable = + |tx_seq: &[BasicTxDetails]| tx_seq.iter().all(|tx| targeted_contracts.can_replay(tx)); + self.in_memory_corpus.retain(|entry| is_replayable(&entry.tx_seq)); + self.metrics.corpus_count = self.in_memory_corpus.len(); + self.metrics.favored_items = + self.in_memory_corpus.iter().filter(|entry| entry.is_favored).count(); + + if !self.optimization_best_sequence.is_empty() + && !is_replayable(&self.optimization_best_sequence) + { + self.optimization_best_value = None; + self.optimization_best_sequence.clear(); + } + } + + pub(crate) fn load_from_disk( + config: &FuzzCorpusConfig, + replay_root: Option<&Path>, + executor: Option<&Executor>, + target: ReplayTarget<'_>, + ) -> Result { + let mut seed = Self::empty(config).with_optimization_state(config); + let Some(corpus_dir) = &config.corpus_dir else { + return Ok(seed); + }; + let replay_dirs = canonical_replay_dirs(replay_root.unwrap_or(corpus_dir)); + seed.replay_dirs = Some(replay_dirs.clone()); + + // Drop a persisted optimization seed with a sender the current policy no longer allows. + if let Some(senders) = target.senders + && !seed.optimization_best_sequence.iter().all(|tx| senders.allows(tx.sender)) + { + seed.optimization_best_value = None; + seed.optimization_best_sequence.clear(); + } + + // Seed in-memory corpus with the persisted optimization best sequence so the mutation + // engine can build on it in future runs. + if !seed.optimization_best_sequence.is_empty() { + seed.in_memory_corpus.push(CorpusEntry::new(seed.optimization_best_sequence.clone())); + seed.metrics.corpus_count += 1; + } + + if target.fuzzed_contracts.is_some() && has_legacy_invariant_corpus_dirs(corpus_dir) { + let _ = sh_warn!( + "Ignoring legacy invariant corpus directories under {}; new corpus entries are persisted under the contract-level corpus directory.", + corpus_dir.display(), + ); + } + + let Some(executor) = executor else { + return Ok(seed); + }; + let mut seen_entries = + seed.in_memory_corpus.iter().map(|entry| entry.uuid).collect::>(); + for entry in unique_corpus_entries(&replay_dirs, &mut seen_entries) { + // A corrupt or truncated corpus file must not abort the whole campaign startup: skip + // it and keep loading the rest of the corpus. Canonical entries are atomically + // published, but malformed files may come from older versions or manual edits. + let tx_seq = match entry.read_tx_seq() { + Ok(tx_seq) => tx_seq, + Err(err) => { + let _ = + sh_warn!("Skipping unreadable corpus file {}: {err}", entry.path.display()); + continue; + } + }; + if tx_seq.is_empty() { + continue; + } + + let coverage = ReplayCoverage { + history_map: &mut seed.history_map, + edge_indices: &mut seed.edge_indices, + sancov_history_map: &mut seed.sancov_history_map, + metrics: Some(&mut seed.metrics), + }; + let ReplayOutcome { keep_entry, new_edge, cmp_seq, failed_replays, .. } = + replay_corpus_sequence(&tx_seq, executor, target, coverage)?; + seed.failed_replays += failed_replays; + // Start the timer at the baseline load if replay hit a first-time edge. + if new_edge { + seed.last_new_edge_at = Some(Instant::now()); + } + if !keep_entry { + continue; + } + + seed.metrics.corpus_count += 1; + debug!( + target: "corpus", + "load sequence with len {} from corpus file {}", + tx_seq.len(), + entry.path.display() + ); + seed.in_memory_corpus.push(CorpusEntry::new_with_cmp(tx_seq, cmp_seq, entry.uuid)); + } + + Ok(seed) + } +} + +#[derive(Default)] +pub(crate) struct GlobalCorpusMetrics { + // Number of edges seen during the invariant run. + cumulative_edges_seen: AtomicUsize, + // Number of features (new hitcount bin of previously hit edge) seen during the invariant run. + cumulative_features_seen: AtomicUsize, + // Number of corpus entries. + corpus_count: AtomicUsize, + // Number of corpus entries that are favored. + favored_items: AtomicUsize, +} + +pub(crate) struct CorpusSyncCoordinator { + workers: usize, + arrived: AtomicUsize, + phase: AtomicUsize, + aborted: AtomicBool, +} + +impl CorpusSyncCoordinator { + pub(crate) const fn new(workers: usize) -> Self { + Self { + workers, + arrived: AtomicUsize::new(0), + phase: AtomicUsize::new(0), + aborted: AtomicBool::new(false), + } + } + + pub(crate) fn abort(&self) { + self.aborted.store(true, Ordering::Release); + } + + fn wait(&self) -> bool { + if self.aborted.load(Ordering::Acquire) { + return false; + } + + let phase = self.phase.load(Ordering::Acquire); + if self.arrived.fetch_add(1, Ordering::AcqRel) + 1 == self.workers { + self.arrived.store(0, Ordering::Release); + self.phase.fetch_add(1, Ordering::AcqRel); + return true; + } + + while self.phase.load(Ordering::Acquire) == phase && !self.aborted.load(Ordering::Acquire) { + rayon::yield_now(); + } + !self.aborted.load(Ordering::Acquire) + } +} + +impl fmt::Display for GlobalCorpusMetrics { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.load().fmt(f) + } +} + +impl GlobalCorpusMetrics { + pub(crate) fn load(&self) -> CorpusMetrics { + CorpusMetrics { + cumulative_edges_seen: self.cumulative_edges_seen.load(Ordering::Relaxed), + cumulative_features_seen: self.cumulative_features_seen.load(Ordering::Relaxed), + corpus_count: self.corpus_count.load(Ordering::Relaxed), + favored_items: self.favored_items.load(Ordering::Relaxed), + } + } +} + +#[derive(Serialize, Default, Clone)] +pub(crate) struct CorpusMetrics { + // Number of edges seen during the invariant run. + cumulative_edges_seen: usize, + // Number of features (new hitcount bin of previously hit edge) seen during the invariant run. + cumulative_features_seen: usize, + // Number of corpus entries. + corpus_count: usize, + // Number of corpus entries that are favored. + favored_items: usize, +} + +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)?; + write!(f, " - favored items: {}", self.favored_items)?; + Ok(()) + } +} + +impl CorpusMetrics { + /// Records number of new edges or features explored during the campaign. + pub const fn update_seen(&mut self, is_edge: bool) { + if is_edge { + self.cumulative_edges_seen += 1; + } else { + self.cumulative_features_seen += 1; + } + } + + /// Updates campaign favored items. + pub const fn update_favored(&mut self, is_favored: bool, corpus_favored: bool) { + if is_favored && !corpus_favored { + self.favored_items += 1; + } else if !is_favored && corpus_favored { + self.favored_items -= 1; + } + } +} + +/// Per-worker corpus manager. +pub struct WorkerCorpus { + /// Worker Id + id: usize, + /// In-memory corpus entries populated from the persisted files and + /// runs administered by this worker. + in_memory_corpus: Vec, + /// History of binned hitcount of edges seen during fuzzing + history_map: Vec, + /// Stable dense EVM edge IDs for this worker's history map. + edge_indices: EdgeIndexMap, + /// History of binned hitcount of sancov (native Rust) edges seen during fuzzing + sancov_history_map: Vec, + /// Number of failed replays from initial corpus + pub(crate) failed_replays: usize, + /// Worker Metrics + pub(crate) metrics: CorpusMetrics, + /// Shared transaction-sequence generator. + sequence_generator: SequenceGenerator, + /// Identifier of current mutated entry for this worker. + current_mutated_index: Option, + /// Config + config: Arc, + /// Whether this corpus participates in stateless worker synchronization. + worker_sync_enabled: bool, + /// Sorted indices of new entries added to [`WorkerCorpus::in_memory_corpus`] since last sync. + new_entry_indices: Vec, + /// Corpus directories the master loaded at startup and still needs to distribute. + initial_export_dirs: Option>, + /// Worker Dir + /// corpus_dir/worker1/ + worker_dir: Option, + /// Whether this worker already warned that a live corpus entry could not be persisted. + warned_persistence_failure: bool, + /// Metrics at last sync - used to calculate deltas while syncing with global metrics + last_sync_metrics: CorpusMetrics, + /// Optimization mode: the best value found so far (loaded from disk or discovered in-run). + optimization_best_value: Option, + /// Optimization mode: the call sequence that produced the best value. + optimization_best_sequence: Vec, + /// Monotonic time the worker's local map last gained a first-time edge; `None` until then. + /// + /// Updated wherever the map grows: live fuzzing, startup replay, and cross-worker sync. Tracks + /// *local* discovery (an edge new to this worker), not globally unique discovery. Kept out of + /// [`CorpusMetrics`] since a timestamp is neither additive across workers nor serializable. + last_new_edge_at: Option, +} + +/// Refs used during corpus replay to register contracts deployed mid-sequence as fuzz targets, +/// mirroring the campaign loop so follow-up calls into them aren't dropped by `can_replay_tx`. +#[derive(Clone, Copy)] +pub struct DynamicTargetCtx<'a> { + pub project_contracts: &'a ContractsByArtifact, + pub setup_contracts: &'a ContractsByAddress, + pub artifact_filters: &'a ArtifactFilters, +} + +/// Registers contracts created by the last tx so subsequent txs in the same replayed sequence +/// can target them. +pub(crate) fn register_replay_created( + state_changeset: &StateChangeset, + dynamic: Option<&DynamicTargetCtx<'_>>, + fuzzed_contracts: Option<&FuzzRunIdentifiedContracts>, + created: &mut Vec
, +) { + let (Some(dynamic), Some(fuzzed_contracts)) = (dynamic, fuzzed_contracts) else { + return; + }; + if let Err(error) = fuzzed_contracts.collect_created_contracts( + state_changeset, + dynamic.project_contracts, + dynamic.setup_contracts, + dynamic.artifact_filters, + created, + ) { + warn!(target: "corpus", "{error}"); + } +} + +/// Clears dynamic targets added during a replayed entry so they don't leak into the next one. +pub(crate) fn rollback_replay_created( + fuzzed_contracts: Option<&FuzzRunIdentifiedContracts>, + created: Vec
, +) { + if !created.is_empty() + && let Some(fuzzed_contracts) = fuzzed_contracts + { + fuzzed_contracts.clear_created_contracts(created); + } +} + +fn load_optimization_state(config: &FuzzCorpusConfig) -> Option<(I256, Vec)> { + let corpus_dir = config.corpus_dir.as_ref()?; + let opt_path = corpus_dir.join(OPTIMIZATION_BEST_FILE); + if !opt_path.is_file() { + return None; + } + + match foundry_common::fs::read_json_file::(&opt_path) { + Ok(state) => { + debug!( + target: "corpus", + "loaded optimization best value {} with sequence len {}", + state.best_value, + state.best_sequence.len() + ); + Some((state.best_value, state.best_sequence)) + } + Err(err) => { + let _ = sh_warn!( + "failed to load optimization state from {}: {err}; starting without persisted optimization seed", + opt_path.display() + ); + None + } + } +} + +fn replay_corpus_sequence( + tx_seq: &[BasicTxDetails], + executor: &Executor, + target: ReplayTarget<'_>, + coverage: ReplayCoverage<'_>, +) -> Result { + let mut executor = executor.clone(); + replay_corpus_sequence_with_executor(tx_seq, &mut executor, target, coverage, false, true) +} + +fn replay_corpus_sequence_with_executor( + tx_seq: &[BasicTxDetails], + executor: &mut Executor, + target: ReplayTarget<'_>, + mut coverage: ReplayCoverage<'_>, + trace_sync: bool, + reject_unmatched_function: bool, +) -> Result { + let mut cmp_seq = Vec::with_capacity(tx_seq.len()); + let mut failed_replays = 0; + let mut new_coverage_for_entry = false; + let mut new_edge_for_entry = false; + let mut created: Vec
= Vec::new(); + + for tx in tx_seq { + if WorkerCorpus::can_replay_tx( + tx, + target.stateless, + target.fuzzed_contracts, + target.senders, + ) { + let (kind, mut call_result) = if target.fuzzed_contracts.is_some() { + execute_invariant_replay_tx(executor, tx)? + } else { + (CampaignCallKind::Accepted, execute_tx(executor, tx)?) + }; + cmp_seq.push(ComparisonHint::applicable( + &tx.call_details.calldata, + call_result + .evm_cmp_values + .take() + .unwrap_or_default() + .into_iter() + .map(|cmp| ComparisonHint { lhs: cmp.op1, rhs: cmp.op2 }), + )); + let (new_coverage, is_edge) = call_result.merge_all_coverage( + coverage.history_map, + coverage.edge_indices, + coverage.sancov_history_map, + ); + if new_coverage { + new_coverage_for_entry = true; + new_edge_for_entry |= is_edge; + if let Some(metrics) = coverage.metrics.as_deref_mut() { + metrics.update_seen(is_edge); + } + } + + if kind == CampaignCallKind::Accepted { + register_replay_created( + &call_result.state_changeset, + target.dynamic, + target.fuzzed_contracts, + &mut created, + ); + } + + if trace_sync { + trace!( + target: "corpus", + %new_coverage, + ?tx, + "replayed tx for syncing", + ); + } + } else { + cmp_seq.push(Vec::new()); + failed_replays += 1; + + // The campaign executes loaded sequences verbatim, so a persisted sequence with any + // transaction outside the current targets, selectors, or senders must be dropped. + if reject_unmatched_function { + rollback_replay_created(target.fuzzed_contracts, created); + return Ok(ReplayOutcome { + keep_entry: false, + new_coverage: new_coverage_for_entry, + new_edge: new_edge_for_entry, + cmp_seq, + failed_replays, + }); + } + } + } + rollback_replay_created(target.fuzzed_contracts, created); + + Ok(ReplayOutcome { + keep_entry: true, + new_coverage: new_coverage_for_entry, + new_edge: new_edge_for_entry, + cmp_seq, + failed_replays, + }) +} + +impl WorkerCorpus { + /// Produces the next sequence for either execution mode. + pub fn new_sequence(&mut self, test_runner: &mut TestRunner) -> Result { + if self.config.is_coverage_guided() && !self.in_memory_corpus.is_empty() { + self.evict_oldest_corpus()?; + } + let corpus_len = self.in_memory_corpus.len(); + let plan = self.sequence_generator.start( + test_runner, + corpus_len, + |index| { + let entry = self + .in_memory_corpus + .get(index) + .ok_or_else(|| eyre::eyre!("corpus index {index} is out of bounds"))?; + CorpusEntryView::new(&entry.tx_seq, &entry.cmp_seq) + }, + self.config.is_coverage_guided(), + )?; + self.current_mutated_index = plan.source(); + Ok(plan) + } + + pub fn new( + id: usize, + config: FuzzCorpusConfig, + sequence_generator: SequenceGenerator, + replay_root: Option<&Path>, + // Only required by master worker (id = 0) to replay existing corpus. + executor: Option<&Executor>, + target: ReplayTarget<'_>, + ) -> Result { + let seed = if id == 0 { + WorkerCorpusSeed::load_from_disk(&config, replay_root, executor, target)? + } else { + WorkerCorpusSeed::empty(&config).with_optimization_state(&config) + }; + let mut corpus = Self::from_seed(id, config, sequence_generator, seed)?; + corpus.worker_sync_enabled = true; + Ok(corpus) + } + + pub(crate) fn from_seed( + id: usize, + config: FuzzCorpusConfig, + sequence_generator: SequenceGenerator, + mut seed: WorkerCorpusSeed, + ) -> Result { + let initial_export_dirs = if id == 0 { seed.replay_dirs.take() } else { None }; + let worker_dir = if let Some(corpus_dir) = &config.corpus_dir { + let worker_dir = corpus_dir.join(format!("{WORKER}{id}")); + let worker_corpus = worker_dir.join(CORPUS_DIR); + let sync_dir = worker_dir.join(SYNC_DIR); + + // Create the necessary directories for the worker. + foundry_common::fs::create_dir_all(&worker_corpus)?; + foundry_common::fs::create_dir_all(&sync_dir)?; + + Some(worker_dir) + } else { + None + }; + + Ok(Self { + id, + in_memory_corpus: seed.in_memory_corpus, + history_map: seed.history_map, + edge_indices: seed.edge_indices, + sancov_history_map: seed.sancov_history_map, + failed_replays: seed.failed_replays, + metrics: seed.metrics, + sequence_generator, + current_mutated_index: None, + config: config.into(), + worker_sync_enabled: false, + new_entry_indices: Default::default(), + initial_export_dirs, + worker_dir, + warned_persistence_failure: false, + last_sync_metrics: Default::default(), + optimization_best_value: seed.optimization_best_value, + optimization_best_sequence: seed.optimization_best_sequence, + last_new_edge_at: seed.last_new_edge_at, + }) + } + + /// Updates stats for the given call sequence, if new coverage produced. + /// Persists the call sequence (if corpus directory is configured and new coverage or + /// improved optimization value) and updates in-memory corpus. + #[instrument(skip_all)] + pub fn process_inputs( + &mut self, + inputs: &[BasicTxDetails], + cmp_seq: &[Vec], + new_coverage: bool, + optimization: Option<(I256, Vec)>, + ) { + self.process_inputs_inner( + inputs, + cmp_seq, + new_coverage, + optimization, + CorpusInsertionMode::Live, + true, + ); + } + + /// Updates worker-local corpus state and persists interesting inputs immediately, while + /// leaving campaign-wide optimization persistence to the coordinator. Entries are retained + /// per worker rather than globally filtered so abrupt termination cannot discard discoveries + /// that have not yet reached the coordinator. + #[instrument(skip_all)] + pub fn process_inputs_for_campaign( + &mut self, + inputs: &[BasicTxDetails], + cmp_seq: &[Vec], + new_coverage: bool, + optimization: Option<(I256, Vec)>, + ) { + self.process_inputs_inner( + inputs, + cmp_seq, + new_coverage, + optimization, + CorpusInsertionMode::Live, + false, + ); + } + + fn process_inputs_inner( + &mut self, + inputs: &[BasicTxDetails], + cmp_seq: &[Vec], + new_coverage: bool, + optimization: Option<(I256, Vec)>, + insertion_mode: CorpusInsertionMode, + persist_optimization: bool, + ) { + // Check if this run improved the optimization value. + let improved_optimization = optimization.as_ref().is_some_and(|(value, _)| { + self.optimization_best_value.is_none_or(|best| *value > best) + }); + + // Update stats of current mutated primary corpus. + if let Some(index) = self.current_mutated_index.take() { + let should_credit = new_coverage || improved_optimization; + if let Some(corpus) = self.in_memory_corpus.get_mut(index) { + corpus.total_mutations += 1; + if should_credit { + corpus.new_finds_produced += 1 + } + let is_favored = (corpus.new_finds_produced as f64 / corpus.total_mutations as f64) + > FAVORABILITY_THRESHOLD; + self.metrics.update_favored(is_favored, corpus.is_favored); + corpus.is_favored = is_favored; + + trace!( + target: "corpus", + "updated corpus {}, total mutations: {}, new finds: {}", + corpus.uuid, corpus.total_mutations, corpus.new_finds_produced + ); + } + } + if let Some((value, best_seq)) = optimization + && improved_optimization + { + self.optimization_best_value = Some(value); + self.optimization_best_sequence = best_seq; + if persist_optimization { + self.persist_optimization_state(); + } + } + + if !self.config.is_coverage_guided() { + return; + } + + // Collect inputs if current run produced new coverage or improved optimization. + if !new_coverage && !improved_optimization { + return; + } + + // When the run is interesting only because of optimization (no new coverage), + // add the best prefix to the corpus instead of the full run — the prefix is + // the sequence that actually achieved the best value. + // + // `inputs` can be empty when every call was discarded/popped but new coverage was + // still recorded; there's nothing to persist, so skip without inserting an entry. + let corpus_inputs = if improved_optimization && (!new_coverage || inputs.is_empty()) { + self.optimization_best_sequence.clone() + } else { + inputs.to_vec() + }; + if corpus_inputs.is_empty() { + return; + } + let corpus_cmp_seq = cmp_seq + .iter() + .zip(&corpus_inputs) + .map(|(values, tx)| { + ComparisonHint::applicable( + &tx.call_details.calldata, + values.iter().map(|cmp| ComparisonHint { lhs: cmp.op1, rhs: cmp.op2 }), + ) + }) + .collect(); + let corpus = CorpusEntry::new_with_cmp(corpus_inputs, corpus_cmp_seq, Uuid::new_v4()); + + self.insert_corpus_entry(corpus, insertion_mode) + } + + fn insert_corpus_entry(&mut self, corpus: CorpusEntry, insertion_mode: CorpusInsertionMode) { + if matches!(insertion_mode, CorpusInsertionMode::Live) + && let Some(worker_dir) = &self.worker_dir + { + let worker_corpus = worker_dir.join(CORPUS_DIR); + let write_result = corpus.write_to_disk_in(&worker_corpus, self.config.corpus_gzip); + if let Err(err) = write_result { + if !self.warned_persistence_failure { + let _ = sh_warn!( + "Failed to persist coverage corpus entries for worker {} in {}: {err}", + self.id, + worker_corpus.display() + ); + self.warned_persistence_failure = true; + } + debug!(target: "corpus", %err, "failed to record call sequence {:?}", corpus.tx_seq); + } else { + trace!( + target: "corpus", + "persisted {} inputs for new coverage for {} corpus", + corpus.tx_seq.len(), + corpus.uuid, + ); + } + } + + self.push_corpus_entry(corpus); + } + + fn push_corpus_entry(&mut self, corpus: CorpusEntry) { + let new_index = self.in_memory_corpus.len(); + if self.worker_sync_enabled { + self.new_entry_indices.push(new_index); + } + self.metrics.corpus_count += 1; + self.in_memory_corpus.push(corpus); + } + + /// Returns the previously persisted optimization best value and sequence (if any). + pub fn optimization_initial_state(&self) -> (Option, Vec) { + (self.optimization_best_value, self.optimization_best_sequence.clone()) + } + + /// Persists the current optimization best value and sequence to disk. + fn persist_optimization_state(&self) { + let optimization_best = self + .optimization_best_value + .map(|value| (value, self.optimization_best_sequence.as_slice())); + persist_optimization_output(&self.config, optimization_best); + } + + /// Collects EVM and sancov coverage from call result and updates metrics. + pub fn merge_edge_coverage( + &mut self, + call_result: &mut RawCallResult, + ) -> bool { + if !self.config.collect_edge_coverage() { + return false; + } + + let (new_coverage, is_edge) = call_result.merge_all_coverage( + &mut self.history_map, + &mut self.edge_indices, + &mut self.sancov_history_map, + ); + if new_coverage { + self.metrics.update_seen(is_edge); + // Only a first-time edge (not a new hitcount bucket, i.e. a "feature") resets the + // timer. + if is_edge { + self.last_new_edge_at = Some(Instant::now()); + } + } + new_coverage + } + + /// Time since this worker last gained a first-time edge; `None` until it has seen one. See + /// [`WorkerCorpus::last_new_edge_at`] for the local-vs-global caveat. + pub(crate) fn time_since_new_edge(&self) -> Option { + self.last_new_edge_at.map(|at| at.elapsed()) + } + /// Converts replayable observed sub-calls into one normal multi-transaction corpus entry. + /// + /// This captures calls shaped by a handler or another target call and lets the existing corpus + /// machinery mutate, evict, sync, and persist them like any other interesting sequence. + pub fn hoist_observed_calls( + &mut self, + observed: &[ObservedCall], + parent_tx: &BasicTxDetails, + targeted_contracts: &FuzzRunIdentifiedContracts, + senders: &SenderFilters, + insertion_mode: CorpusInsertionMode, + ) { + if !self.config.is_coverage_guided() || observed.is_empty() { + return; + } + + let tx_seq = { + let targets = targeted_contracts.targets(); + sequence_from_observed( + observed, + &targets, + senders, + Some((parent_tx.warp, parent_tx.roll)), + ) + }; + + self.push_observed_sequence(tx_seq, insertion_mode) + } + + /// Seeds the corpus from sibling zero-input unit tests by replaying them on a clone of the + /// post-setUp executor and keeping the direct replayable calls made by each test. + /// + /// Returns the number of test-derived corpus entries added. + pub fn seed_from_test_traces( + &mut self, + invariant_contract: &InvariantContract<'_>, + targeted_contracts: &FuzzRunIdentifiedContracts, + senders: &SenderFilters, + executor: &Executor, + ) -> Result { + if !self.config.is_coverage_guided() { + return Ok(0); + } + + let mut added = 0; + + for func in invariant_contract.abi.functions() { + if !func.is_unit_test() { + continue; + } + if invariant_contract + .invariant_fns + .iter() + .any(|(invariant_fn, _)| func.selector() == invariant_fn.selector()) + { + continue; + } + + let calldata = match func.abi_encode_input(&[]) { + Ok(calldata) => Bytes::from(calldata), + Err(_) => continue, + }; + + let exec = executor.clone(); + + let raw = match exec.call_raw(CALLER, invariant_contract.address, calldata, U256::ZERO) + { + Ok(raw) => raw, + Err(_) => continue, + }; + if raw.reverted { + continue; + } + + let observed = raw.observed_calls; + if observed.is_empty() { + continue; + } + + let Some(seq) = ({ + let targets = targeted_contracts.targets(); + let block = executor + .inspector() + .cheatcodes + .as_ref() + .and_then(|cheatcodes| cheatcodes.block.as_ref()) + .unwrap_or(&executor.evm_env().block_env); + let spec_id = executor + .inspector() + .cheatcodes + .as_ref() + .and_then(|cheatcodes| cheatcodes.execution_evm_version) + .unwrap_or_else(|| executor.spec_id()); + sequence_from_test_trace( + &observed, + &targets, + senders, + block.timestamp(), + block.number(), + spec_id.into(), + ) + }) else { + continue; + }; + + let insertion_mode = if self.id == 0 { + CorpusInsertionMode::Live + } else { + CorpusInsertionMode::MemoryOnly + }; + let len_before = self.in_memory_corpus.len(); + self.push_observed_sequence(seq, insertion_mode); + if self.in_memory_corpus.len() > len_before { + debug!(target: "corpus", test = %func.name, "seeded corpus sequence from test trace"); + added += 1; + } + } + + Ok(added) + } + + fn push_observed_sequence( + &mut self, + tx_seq: Vec, + insertion_mode: CorpusInsertionMode, + ) { + if !self.config.is_coverage_guided() || tx_seq.is_empty() { + return; + } + + let corpus = CorpusEntry::new(tx_seq); + + self.insert_corpus_entry(corpus, insertion_mode) + } + /// Flush the oldest corpus mutated more than configured max mutations unless it is favored + /// or pending synchronization. + fn evict_oldest_corpus(&mut self) -> Result<()> { + if self.in_memory_corpus.len() > self.config.corpus_min_size.max(1) + && let Some(index) = + self.in_memory_corpus.iter().enumerate().position(|(index, corpus)| { + self.new_entry_indices.binary_search(&index).is_err() + && corpus.total_mutations > self.config.corpus_min_mutations + && !corpus.is_favored + }) + { + let corpus = &self.in_memory_corpus[index]; + + trace!(target: "corpus", corpus=%serde_json::to_string(&corpus).unwrap(), "evict corpus"); + + // Remove corpus from memory. + self.in_memory_corpus.remove(index); + + // Adjust the tracked indices. + self.new_entry_indices.retain_mut(|i| { + if *i > index { + *i -= 1; // Shift indices down. + true // Keep this index. + } else { + *i != index // Remove if it's the deleted index, keep otherwise. + } + }); + } + Ok(()) + } + // Sync Methods. + + /// Imports the new corpus entries from the `sync` directory. + /// These contain tx sequences which are replayed and used to update the history map. + fn load_sync_corpus(&self, strict: bool) -> Result)>> { + let Some(worker_dir) = &self.worker_dir else { + return Ok(vec![]); + }; + + let sync_dir = worker_dir.join(SYNC_DIR); + if !strict && !sync_dir.is_dir() { + return Ok(vec![]); + } + + let mut imports = vec![]; + let entries = if strict { + read_corpus_dir_strict(&sync_dir)? + } else { + read_corpus_dir(&sync_dir).collect() + }; + for entry in entries { + // A corrupt or truncated sync file must not abort the whole sync pass: skip it. + let tx_seq = match entry.read_tx_seq() { + Ok(tx_seq) => tx_seq, + Err(err) if strict => { + return Err(eyre!( + "failed to read final corpus entry {}: {err}", + entry.path.display() + )); + } + Err(err) => { + warn!(target: "corpus", "skipping unreadable corpus file {}: {err}", entry.path.display()); + let quarantine_path = entry.path.with_file_name(format!( + "{}.{}.invalid", + entry.name(), + Uuid::new_v4() + )); + if let Err(err) = std::fs::rename(&entry.path, &quarantine_path) { + debug!(target: "corpus", %err, "failed to quarantine unreadable corpus file {}", entry.path.display()); + } + continue; + } + }; + if tx_seq.is_empty() { + warn!(target: "corpus", "skipping empty corpus entry: {}", entry.path.display()); + if let Err(err) = std::fs::remove_file(&entry.path) { + if strict { + return Err(err.into()); + } + debug!(target: "corpus", %err, "failed to remove empty corpus file {}", entry.path.display()); + } + continue; + } + imports.push((entry, tx_seq)); + } + + if !imports.is_empty() { + debug!(target: "corpus", "imported {} new corpus entries", imports.len()); + } + + Ok(imports) + } + + /// Adds a calibrated sync entry to the local corpus and queues it for fan-out on the master. + fn push_synced_corpus_entry( + &mut self, + mut corpus: CorpusEntry, + timestamp: u64, + file_name: String, + ) { + corpus.timestamp = timestamp; + corpus.persisted_file_name = Some(file_name); + if self.worker_sync_enabled && self.id == 0 { + self.new_entry_indices.push(self.in_memory_corpus.len()); + } + self.in_memory_corpus.push(corpus); + } + + /// Syncs and calibrates the in memory corpus and updates the history_map if new coverage is + /// found from the corpus findings of other workers. + #[instrument(skip_all)] + fn calibrate( + &mut self, + executor: &Executor, + target: ReplayTarget<'_>, + strict: bool, + ) -> Result<()> { + let Some(worker_dir) = &self.worker_dir else { + return Ok(()); + }; + let corpus_dir = worker_dir.join(CORPUS_DIR); + + for (entry, tx_seq) in self.load_sync_corpus(strict)? { + let mut history_map = self.history_map.clone(); + let mut edge_indices = self.edge_indices.clone(); + let mut sancov_history_map = self.sancov_history_map.clone(); + let mut metrics = self.metrics.clone(); + let coverage = ReplayCoverage { + history_map: &mut history_map, + edge_indices: &mut edge_indices, + sancov_history_map: &mut sancov_history_map, + metrics: Some(&mut metrics), + }; + let mut replay_executor = executor.clone(); + let ReplayOutcome { keep_entry, new_coverage, new_edge, cmp_seq, .. } = + replay_corpus_sequence_with_executor( + &tx_seq, + &mut replay_executor, + target, + coverage, + true, + false, + )?; + + let sync_path = &entry.path; + if keep_entry && new_coverage { + // Move file from sync/ to corpus/ directory. + let corpus_path = corpus_dir.join(sync_path.components().next_back().unwrap()); + if !accept_synced_corpus_file(&entry, &tx_seq, &corpus_path) { + if strict { + return Err(eyre!("failed to accept final corpus entry {}", entry.name())); + } + continue; + } + + self.history_map = history_map; + self.edge_indices = edge_indices; + self.sancov_history_map = sancov_history_map; + self.metrics = metrics; + // A synced edge is new to this worker's local map, so it advances the timer. + if new_edge { + self.last_new_edge_at = Some(Instant::now()); + } + + debug!( + target: "corpus", + name=%entry.name(), + "moved synced corpus to corpus dir", + ); + + let corpus_entry = CorpusEntry::new_with_cmp(tx_seq.clone(), cmp_seq, entry.uuid); + self.push_synced_corpus_entry( + corpus_entry, + entry.timestamp, + entry.name().to_owned(), + ); + } else { + // Remove the file as it did not generate new coverage. + if let Err(err) = std::fs::remove_file(&entry.path) { + if strict { + return Err(err.into()); + } + debug!(target: "corpus", %err, "failed to remove synced corpus from {sync_path:?}"); + continue; + } + trace!(target: "corpus", "removed synced corpus from {sync_path:?}"); + } + } + + Ok(()) + } + + /// Exports the new corpus entries to the master worker's sync dir. + #[instrument(skip_all)] + fn export_to_master(&mut self) -> Result<()> { + // Master doesn't export (it only receives from others). + assert_ne!(self.id, 0, "non-master only"); + + // Early return if no new entries or corpus dir not configured. + if self.new_entry_indices.is_empty() || self.worker_dir.is_none() { + return Ok(()); + } + + let worker_dir = self.worker_dir.as_ref().unwrap(); + let Some(master_sync_dir) = self + .config + .corpus_dir + .as_ref() + .map(|dir| dir.join(format!("{WORKER}0")).join(SYNC_DIR)) + else { + return Ok(()); + }; + + let mut exported = 0; + let corpus_dir = worker_dir.join(CORPUS_DIR); + let mut delivered = HashSet::new(); + + for &index in &self.new_entry_indices { + let Some(corpus) = self.in_memory_corpus.get(index) else { + delivered.insert(index); + continue; + }; + let file_name = corpus.file_name(corpus.should_gzip(self.config.corpus_gzip)); + let file_path = corpus_dir.join(&file_name); + if !file_path.is_file() + && let Err(err) = corpus.write_to_disk_in(&corpus_dir, self.config.corpus_gzip) + { + debug!(target: "corpus", %err, "failed to persist corpus {} for export", corpus.uuid); + continue; + } + let sync_path = master_sync_dir.join(&file_name); + if !link_corpus_file(&file_path, &sync_path) { + continue; + } + exported += 1; + delivered.insert(index); + } + self.new_entry_indices.retain(|index| !delivered.contains(index)); + + debug!(target: "corpus", "exported {exported} new corpus entries"); + + Ok(()) + } + + /// Exports the global corpus to the `sync/` directories of all the non-master workers. + #[instrument(skip_all)] + fn export_to_workers(&mut self, num_workers: usize) -> Result<()> { + assert_eq!(self.id, 0, "master worker only"); + if self.worker_dir.is_none() { + return Ok(()); + } + + let worker_dir = self.worker_dir.as_ref().unwrap(); + let master_corpus_dir = worker_dir.join(CORPUS_DIR); + let startup_entries = if let Some(replay_dirs) = &self.initial_export_dirs { + let mut seen_entries = HashSet::new(); + unique_corpus_entries(replay_dirs, &mut seen_entries) + .map(|entry| entry.path) + .collect::>() + } else { + Vec::new() + }; + let mut pending_entries = Vec::new(); + let mut delivered = HashSet::new(); + for &index in &self.new_entry_indices { + let Some(corpus) = self.in_memory_corpus.get(index) else { + delivered.insert(index); + continue; + }; + let path = master_corpus_dir + .join(corpus.file_name(corpus.should_gzip(self.config.corpus_gzip))); + if !path.is_file() + && let Err(err) = + corpus.write_to_disk_in(&master_corpus_dir, self.config.corpus_gzip) + { + debug!(target: "corpus", %err, "failed to persist corpus {} for fan-out", corpus.uuid); + continue; + } + pending_entries.push((index, path)); + } + + let mut target_dirs = Vec::new(); + for target_worker in 1..num_workers { + let target_dir = self + .config + .corpus_dir + .as_ref() + .unwrap() + .join(format!("{WORKER}{target_worker}")) + .join(SYNC_DIR); + if !target_dir.is_dir() { + foundry_common::fs::create_dir_all(&target_dir)?; + } + target_dirs.push(target_dir); + } + + let mut any_distributed = false; + let mut startup_delivered = true; + for path in &startup_entries { + let Some(name) = path.file_name() else { + startup_delivered = false; + continue; + }; + let mut delivered_to_all = true; + for target_dir in &target_dirs { + let sync_path = target_dir.join(name); + if link_corpus_file(path, &sync_path) { + any_distributed = true; + trace!(target: "corpus", name=%name.to_string_lossy(), ?target_dir, "distributed corpus"); + } else { + delivered_to_all = false; + } + } + startup_delivered &= delivered_to_all; + } + + for (index, path) in pending_entries { + let Some(name) = path.file_name() else { continue }; + let mut delivered_to_all = true; + for target_dir in &target_dirs { + let sync_path = target_dir.join(name); + if link_corpus_file(&path, &sync_path) { + any_distributed = true; + trace!(target: "corpus", name=%name.to_string_lossy(), ?target_dir, "distributed corpus"); + } else { + delivered_to_all = false; + } + } + if delivered_to_all { + delivered.insert(index); + } + } + + self.new_entry_indices.retain(|index| !delivered.contains(index)); + if startup_delivered { + self.initial_export_dirs = None; + } + debug!(target: "corpus", %any_distributed, "distributed master corpus to all workers"); + + Ok(()) + } + + // TODO(dani): currently only master syncs metrics? + /// Syncs local metrics with global corpus metrics by calculating and applying deltas. + pub(crate) fn sync_metrics(&mut self, global_corpus_metrics: &GlobalCorpusMetrics) { + // Calculate delta metrics since last sync. + let edges_delta = self + .metrics + .cumulative_edges_seen + .saturating_sub(self.last_sync_metrics.cumulative_edges_seen); + let features_delta = self + .metrics + .cumulative_features_seen + .saturating_sub(self.last_sync_metrics.cumulative_features_seen); + // For corpus count and favored items, calculate deltas. + let corpus_count_delta = + self.metrics.corpus_count as isize - self.last_sync_metrics.corpus_count as isize; + let favored_delta = + self.metrics.favored_items as isize - self.last_sync_metrics.favored_items as isize; + + // Add delta values to global metrics. + + if edges_delta > 0 { + global_corpus_metrics.cumulative_edges_seen.fetch_add(edges_delta, Ordering::Relaxed); + } + if features_delta > 0 { + global_corpus_metrics + .cumulative_features_seen + .fetch_add(features_delta, Ordering::Relaxed); + } + + if corpus_count_delta > 0 { + global_corpus_metrics + .corpus_count + .fetch_add(corpus_count_delta as usize, Ordering::Relaxed); + } else if corpus_count_delta < 0 { + global_corpus_metrics + .corpus_count + .fetch_sub((-corpus_count_delta) as usize, Ordering::Relaxed); + } + + if favored_delta > 0 { + global_corpus_metrics + .favored_items + .fetch_add(favored_delta as usize, Ordering::Relaxed); + } else if favored_delta < 0 { + global_corpus_metrics + .favored_items + .fetch_sub((-favored_delta) as usize, Ordering::Relaxed); + } + + // Store current metrics as last sync metrics for next delta calculation. + self.last_sync_metrics = self.metrics.clone(); + } + + /// Syncs the workers in_memory_corpus and history_map with the findings from other workers. + #[instrument(skip_all)] + pub fn sync( + &mut self, + num_workers: usize, + executor: &Executor, + target: ReplayTarget<'_>, + global_corpus_metrics: &GlobalCorpusMetrics, + ) -> Result<()> { + trace!(target: "corpus", "syncing"); + + self.sync_metrics(global_corpus_metrics); + + self.calibrate(executor, target, false)?; + if self.id == 0 { + self.export_to_workers(num_workers)?; + } else { + self.export_to_master()?; + } + + debug!(target: "corpus", "synced"); + + Ok(()) + } + + /// Performs the ordered final synchronization once every worker has stopped fuzzing. + pub(crate) fn finalize_sync( + &mut self, + executor: &Executor, + target: ReplayTarget<'_>, + coordinator: &CorpusSyncCoordinator, + ) -> Result<()> { + if self.id != 0 { + self.export_to_master()?; + if self.worker_dir.is_some() && !self.new_entry_indices.is_empty() { + return Err(eyre!("worker {} failed to complete final corpus export", self.id)); + } + } + if !coordinator.wait() { + return Ok(()); + } + + if self.id == 0 { + self.calibrate(executor, target, true)?; + self.export_to_workers(coordinator.workers)?; + if self.worker_dir.is_some() + && (!self.new_entry_indices.is_empty() || self.initial_export_dirs.is_some()) + { + return Err(eyre!("master failed to complete final corpus fan-out")); + } + } + if !coordinator.wait() { + return Ok(()); + } + + if self.id != 0 { + self.calibrate(executor, target, true)?; + } + if !coordinator.wait() { + return Ok(()); + } + + Ok(()) + } + + /// Helper to check if a tx can be replayed. + pub(crate) fn can_replay_tx( + tx: &BasicTxDetails, + stateless: Option>, + fuzzed_contracts: Option<&FuzzRunIdentifiedContracts>, + senders: Option<&SenderFilters>, + ) -> bool { + senders.is_none_or(|senders| senders.allows(tx.sender)) + && (fuzzed_contracts.is_some_and(|contracts| contracts.targets().can_replay(tx)) + || stateless.is_some_and(|target| target.can_replay(tx))) + } +} + +fn sequence_from_observed( + observed: &[ObservedCall], + targets: &TargetedContracts, + senders: &SenderFilters, + first_delay: Option<(Option, Option)>, +) -> Vec { + let mut first_delay = first_delay; + observed + .iter() + .filter_map(|call| { + let mut tx = BasicTxDetails { + warp: None, + roll: None, + sender: call.caller, + call_details: CallDetails { + target: call.target, + calldata: call.calldata.clone(), + value: call.value, + }, + }; + (targets.can_replay(&tx) && senders.allows(tx.sender)).then(|| { + let (warp, roll) = first_delay.take().unwrap_or((None, None)); + tx.warp = warp; + tx.roll = roll; + tx + }) + }) + .collect() +} + +fn sequence_from_test_trace( + observed: &[ObservedCall], + targets: &TargetedContracts, + senders: &SenderFilters, + mut timestamp: U256, + mut block_number: U256, + spec_id: SpecId, +) -> Option> { + let mut next_timestamp = timestamp; + let mut next_block_number = block_number; + let mut sequence = Vec::new(); + + for call in observed { + if call.target == CHEATCODE_ADDRESS { + match <[u8; 4]>::try_from(call.calldata.get(..4)?).ok()? { + warpCall::SELECTOR => { + if call.depth != 1 { + return None; + } + next_timestamp = warpCall::abi_decode(&call.calldata).ok()?.newTimestamp; + } + rollCall::SELECTOR => { + if call.depth != 1 { + return None; + } + let new_height = rollCall::abi_decode(&call.calldata).ok()?.newHeight; + // Prague rolls also write EIP-2935 history, which replaying a block-number + // delta cannot reproduce. Reject the entire seed, including earlier calls. + if spec_id >= SpecId::PRAGUE && new_height > next_block_number { + return None; + } + next_block_number = new_height; + } + // Snapshot restoration can undo target calls and environment changes, but the + // observed trace does not retain enough snapshot state to reproduce that exactly. + revertToCall::SELECTOR + | revertToStateCall::SELECTOR + | revertToAndDeleteCall::SELECTOR + | revertToStateAndDeleteCall::SELECTOR + | setEvmVersionCall::SELECTOR => return None, + _ => {} + } + continue; + } + if call.depth != 1 { + continue; + } + + if next_timestamp < timestamp || next_block_number < block_number { + return None; + } + let tx = BasicTxDetails { + warp: next_timestamp.checked_sub(timestamp).filter(|delay| !delay.is_zero()), + roll: next_block_number.checked_sub(block_number).filter(|delay| !delay.is_zero()), + sender: call.caller, + call_details: CallDetails { + target: call.target, + calldata: call.calldata.clone(), + value: call.value, + }, + }; + if targets.can_replay(&tx) && senders.allows(tx.sender) { + timestamp = next_timestamp; + block_number = next_block_number; + sequence.push(tx); + } + } + + Some(sequence) +} + +fn persist_optimization_output( + config: &FuzzCorpusConfig, + optimization_best: Option<(I256, &[BasicTxDetails])>, +) { + let Some(root) = &config.corpus_dir else { + return; + }; + let Some((value, sequence)) = optimization_best else { + return; + }; + let state = OptimizationState { best_value: value, best_sequence: sequence.to_vec() }; + let path = root.join(OPTIMIZATION_BEST_FILE); + if let Err(err) = foundry_common::fs::write_json_file(&path, &state) { + debug!(target: "corpus", %err, "failed to persist optimization state"); + } else { + trace!( + target: "corpus", + "persisted optimization best value {} with sequence len {}", + value, + sequence.len() + ); + } +} + +pub(crate) fn persist_campaign_optimization( + config: &FuzzCorpusConfig, + value: Option, + sequence: &[BasicTxDetails], +) { + persist_optimization_output(config, value.map(|value| (value, sequence))); +} + +fn has_legacy_invariant_corpus_dirs(path: &Path) -> bool { + std::fs::read_dir(path).is_ok_and(|entries| { + entries.flatten().any(|entry| { + let path = entry.path(); + path.is_dir() + && entry.file_name().to_str().is_some_and(|name| !name.starts_with(WORKER)) + && !path.join(OPTIMIZATION_BEST_FILE).is_file() + }) + }) +} + +fn unique_corpus_entries<'a>( + replay_dirs: &'a [PathBuf], + seen_entries: &'a mut HashSet, +) -> impl Iterator + 'a { + replay_dirs.iter().flat_map(|replay_dir| read_corpus_dir(replay_dir)).filter(|entry| { + let is_new = seen_entries.insert(entry.uuid); + if !is_new { + trace!(target: "corpus", "skipping duplicate corpus entry {}", entry.uuid); + } + is_new + }) +} + +#[cfg(test)] +mod tests { + + use super::*; + use crate::{ + executors::ExecutorBuilder, + inspectors::{EdgeCovHit, EdgeCoverage, EdgeKey}, + }; + use alloy_dyn_abi::DynSolValue; + use alloy_evm::EvmEnv; + use foundry_cheatcodes::{Cheatcodes, CheatsConfig}; + use foundry_config::FuzzDictionaryConfig; + use foundry_evm_core::{backend::Backend, constants::MAGIC_ASSUME, evm::EthEvmNetwork}; + use foundry_evm_fuzz::strategies::{EvmFuzzState, TxGenerator}; + use proptest::prelude::{Just, Strategy}; + use rayon::prelude::*; + use revm::{DatabaseRef, bytecode::Bytecode, context::TxEnv, database::InMemoryDB}; + use std::fs; + + fn basic_tx() -> BasicTxDetails { + BasicTxDetails { + warp: None, + roll: None, + sender: Address::ZERO, + call_details: foundry_evm_fuzz::CallDetails { + target: Address::ZERO, + calldata: Bytes::new(), + value: None, + }, + } + } + + fn basic_tx_with_calldata(calldata: impl Into) -> BasicTxDetails { + let mut tx = basic_tx(); + tx.call_details.calldata = calldata.into(); + tx + } + + fn tx_for_function( + target: Address, + function: &Function, + args: &[DynSolValue], + ) -> BasicTxDetails { + BasicTxDetails { + warp: None, + roll: None, + sender: Address::ZERO, + call_details: foundry_evm_fuzz::CallDetails { + target, + calldata: Bytes::from(function.abi_encode_input(args).unwrap()), + value: None, + }, + } + } + + fn empty_fuzz_state() -> EvmFuzzState { + EvmFuzzState::new(&[], &InMemoryDB::default(), FuzzDictionaryConfig::default(), None) + } + + fn temp_corpus_dir() -> PathBuf { + let dir = std::env::temp_dir().join(format!("foundry-corpus-tests-{}", Uuid::new_v4())); + let _ = fs::create_dir_all(&dir); + dir + } + + fn corpus_config(corpus_dir: PathBuf) -> FuzzCorpusConfig { + FuzzCorpusConfig { + corpus_dir: Some(corpus_dir), + corpus_gzip: false, + corpus_min_mutations: 0, + corpus_min_size: 0, + ..Default::default() + } + } + + fn test_sequence(config: &FuzzCorpusConfig, tx: TxGenerator) -> SequenceGenerator { + SequenceGenerator::stateless( + tx, + empty_fuzz_state().stateless_worker(), + Function::parse("test(uint256)").unwrap(), + config, + ) + .unwrap() + } + + fn worker_corpus(id: usize, corpus_root: PathBuf, seed: WorkerCorpusSeed) -> WorkerCorpus { + let config = corpus_config(corpus_root); + let generator = + test_sequence(&config, TxGenerator::from_strategy(Just(basic_tx()).boxed())); + let mut corpus = WorkerCorpus::from_seed(id, config, generator, seed).unwrap(); + corpus.worker_sync_enabled = true; + corpus + } + + fn empty_worker_corpus(id: usize, corpus_root: PathBuf) -> WorkerCorpus { + worker_corpus(id, corpus_root, WorkerCorpusSeed::default()) + } + + #[test] + fn worker_initialization_fails_when_corpus_directories_cannot_be_created() { + let corpus_root = temp_corpus_dir().join("not-a-directory"); + fs::write(&corpus_root, b"blocked").unwrap(); + let config = corpus_config(corpus_root); + let generator = + test_sequence(&config, TxGenerator::from_strategy(Just(basic_tx()).boxed())); + + assert!( + WorkerCorpus::from_seed(0, config, generator, WorkerCorpusSeed::default()).is_err() + ); + } + + fn sync_test_executor(corpus_root: PathBuf, target: Address) -> Executor { + let mut executor = ExecutorBuilder::::default().gas_limit(1 << 24).build( + EvmEnv::default(), + TxEnv::default(), + Backend::spawn(None).unwrap(), + Default::default(), + ); + executor.inspector_mut().collect_edge_coverage_with_config(&corpus_config(corpus_root)); + // CALLDATALOAD(4); PUSH1 8; JUMPI; STOP; JUMPDEST; STOP. + executor + .set_code( + target, + Bytecode::new_raw(Bytes::from_static(&[ + 0x60, 0x04, 0x35, 0x60, 0x08, 0x57, 0x00, 0x00, 0x5b, 0x00, + ])), + ) + .unwrap(); + executor + } + + fn finalize_test_worker( + worker: &mut WorkerCorpus, + corpus_root: PathBuf, + target_address: Address, + coordinator: &CorpusSyncCoordinator, + ) { + let function = Function::parse("test(uint256)").unwrap(); + let executor = sync_test_executor(corpus_root, target_address); + worker + .finalize_sync( + &executor, + ReplayTarget { + stateless: Some(StatelessReplayTarget { + function: &function, + address: target_address, + }), + fuzzed_contracts: None, + dynamic: None, + senders: None, + }, + coordinator, + ) + .unwrap(); + } + + fn seeded_worker_corpus( + id: usize, + corpus_root: PathBuf, + entries: Vec, + ) -> WorkerCorpus { + worker_corpus( + id, + corpus_root, + WorkerCorpusSeed { in_memory_corpus: entries, ..Default::default() }, + ) + } + fn new_manager_with_single_corpus() -> (WorkerCorpus, Uuid) { + let corpus = CorpusEntry::new(vec![basic_tx()]); + let seed_uuid = corpus.uuid; + let mut manager = seeded_worker_corpus(0, temp_corpus_dir(), vec![corpus]); + manager.current_mutated_index = Some(0); + + (manager, seed_uuid) + } + + fn targeted_contracts_with_selective_functions( + target: Address, + functions: Vec, + targeted_selectors: impl IntoIterator, + ) -> FuzzRunIdentifiedContracts { + use alloy_json_abi::JsonAbi; + use foundry_evm_fuzz::invariant::TargetedContract; + + let mut abi = JsonAbi::new(); + for function in functions { + abi.functions.entry(function.name.clone()).or_default().push(function); + } + + let mut contract = TargetedContract::new("Target".to_string(), abi); + contract.add_selectors(targeted_selectors, false).unwrap(); + + let mut targets = TargetedContracts::new(); + targets.inner.insert(target, contract); + FuzzRunIdentifiedContracts::new(targets, false) + } + + #[test] + fn stateful_replay_restores_assumption_state_and_delays() { + let target = Address::repeat_byte(0x11); + let function = Function::parse("test(uint256)").unwrap(); + let contracts = targeted_contracts_with_selective_functions( + target, + vec![function.clone()], + [function.selector()], + ); + let mut rejected = + tx_for_function(target, &function, &[DynSolValue::Uint(U256::ZERO, 256)]); + rejected.warp = Some(U256::from(10)); + rejected.roll = Some(U256::from(5)); + let mut accepted = tx_for_function(target, &function, &[DynSolValue::Uint(U256::ONE, 256)]); + accepted.warp = Some(U256::from(3)); + accepted.roll = Some(U256::from(2)); + + // Branch on the argument. The rejected branch writes state and returns MAGIC_ASSUME; + // the accepted branch writes the current timestamp. Both branches compare the argument + // with 42, so both record comparisons that cmp mutations can apply to the calldata. + let mut code = vec![0x60, 0x04, 0x35, 0x60, 0x00, 0x57]; + code.extend_from_slice(&[0x60, 0x2a, 0x60, 0x04, 0x35, 0x10, 0x50, 0x60, 0x63, 0x5f, 0x55]); + code.push(0x6e); // PUSH15. + code.extend_from_slice(MAGIC_ASSUME); + code.extend_from_slice(&[0x5f, 0x52, 0x60, 0x0f, 0x60, 0x11, 0xf3]); + code[4] = code.len().try_into().unwrap(); + code.extend_from_slice(&[ + 0x5b, 0x60, 0x2a, 0x60, 0x04, 0x35, 0x10, 0x50, 0x42, 0x5f, 0x55, 0x00, + ]); + + for accept_last in [false, true] { + let mut executor = sync_test_executor(temp_corpus_dir(), target); + executor.inspector_mut().cheatcodes = + Some(Box::new(Cheatcodes::new(Arc::new(CheatsConfig::default())))); + executor.inspector_mut().collect_evm_cmp_log(true); + executor.set_code(target, Bytecode::new_raw(code.clone().into())).unwrap(); + let initial_block = executor.evm_env().block_env.clone(); + let initial_cheatcode_block = + executor.inspector().cheatcodes.as_ref().unwrap().block.clone(); + let mut history_map = Vec::new(); + let mut edge_indices = EdgeIndexMap::default(); + let mut sancov_history_map = Vec::new(); + let sequence = + [rejected.clone(), if accept_last { accepted.clone() } else { rejected.clone() }]; + let outcome = replay_corpus_sequence_with_executor( + &sequence, + &mut executor, + ReplayTarget { + stateless: None, + fuzzed_contracts: Some(&contracts), + dynamic: None, + senders: None, + }, + ReplayCoverage { + history_map: &mut history_map, + edge_indices: &mut edge_indices, + sancov_history_map: &mut sancov_history_map, + metrics: None, + }, + false, + true, + ) + .unwrap(); + assert!(outcome.keep_entry); + assert_eq!(outcome.failed_replays, 0); + assert_eq!(outcome.cmp_seq.len(), sequence.len()); + // Rejected inputs remain useful mutation seeds; this fix preserves their feedback. + assert!(outcome.cmp_seq.iter().all(|hints| !hints.is_empty())); + assert!(outcome.new_coverage); + assert!(history_map.iter().any(|&hit| hit != 0)); + if accept_last { + let timestamp = initial_block.timestamp() + U256::from(3); + assert_eq!(executor.evm_env().block_env.timestamp(), timestamp); + assert_eq!( + executor.evm_env().block_env.number(), + initial_block.number() + U256::from(2) + ); + assert_eq!(executor.backend().storage_ref(target, U256::ZERO).unwrap(), timestamp); + } else { + assert_eq!(executor.evm_env().block_env, initial_block); + assert_eq!( + executor.inspector().cheatcodes.as_ref().unwrap().block, + initial_cheatcode_block + ); + assert_eq!(executor.backend().storage_ref(target, U256::ZERO).unwrap(), U256::ZERO); + } + } + } + + // A corrupt/truncated corpus file (valid name, unparsable content) must surface as a per-entry + // read error rather than break directory scanning, so the load/sync loops can skip malformed + // files from older versions or manual edits instead of aborting the whole campaign. + #[test] + fn corrupt_corpus_file_surfaces_as_error_for_load_to_skip() { + let dir = temp_corpus_dir(); + + // A valid entry round-trips through the on-disk format. + let valid = CorpusEntry::new(vec![basic_tx()]); + valid.write_to_disk_in(&dir, false).unwrap(); + + // A file with a valid corpus name but garbage content. + let corrupt_path = dir.join(format!("{}-123.json", Uuid::new_v4())); + fs::write(&corrupt_path, b"{ not valid json").unwrap(); + + let entries = read_corpus_dir(&dir).collect::>(); + assert_eq!(entries.len(), 2, "directory scan should surface both files"); + + let (mut ok, mut err) = (0u32, 0u32); + for entry in &entries { + match entry.read_tx_seq() { + Ok(seq) => { + ok += 1; + assert_eq!(seq.len(), 1); + } + Err(_) => err += 1, + } + } + assert_eq!((ok, err), (1, 1), "the corrupt file must read as Err, the valid one as Ok"); + } + + #[test] + fn sync_inbox_loads_entries_regardless_of_timestamp() { + let corpus_root = temp_corpus_dir(); + let manager = empty_worker_corpus(0, corpus_root.clone()); + let sync_dir = corpus_root.join("worker0").join(SYNC_DIR); + let mut corpus = CorpusEntry::new(vec![basic_tx()]); + corpus.timestamp = 0; + corpus.write_to_disk_in(&sync_dir, false).unwrap(); + let mut empty = CorpusEntry::new(vec![]); + empty.timestamp = 0; + let empty_path = empty.write_to_disk_in(&sync_dir, false).unwrap(); + let corrupt_path = sync_dir.join(format!("{}-0.json", Uuid::new_v4())); + fs::write(&corrupt_path, b"{ not valid json").unwrap(); + + let imports = manager.load_sync_corpus(false).unwrap(); + + assert_eq!(imports.len(), 1); + assert_eq!(imports[0].0.timestamp, 0); + assert!(!empty_path.exists()); + assert!(!corrupt_path.exists()); + assert!(fs::read_dir(&sync_dir).unwrap().flatten().any(|entry| { + entry.file_name().to_string_lossy().ends_with(".invalid") && entry.path().is_file() + })); + } + + #[test] + fn synced_entries_are_queued_for_fanout_only_on_master() { + let corpus_root = temp_corpus_dir(); + let mut master = empty_worker_corpus(0, corpus_root.clone()); + let mut worker = empty_worker_corpus(1, corpus_root); + let master_entry = CorpusEntry::new(vec![basic_tx()]); + let worker_entry = CorpusEntry::new(vec![basic_tx()]); + + master.push_synced_corpus_entry(master_entry, 1, "master-1.json".to_string()); + worker.push_synced_corpus_entry(worker_entry, 2, "worker-2.json".to_string()); + + assert_eq!(master.new_entry_indices, [0]); + assert!(worker.new_entry_indices.is_empty()); + assert_eq!(master.in_memory_corpus[0].timestamp, 1); + assert_eq!(worker.in_memory_corpus[0].timestamp, 2); + assert_eq!(master.metrics.corpus_count, 0); + assert_eq!(worker.metrics.corpus_count, 0); + } + + #[test] + fn master_distributes_old_synced_entries() { + let corpus_root = temp_corpus_dir(); + let mut master = empty_worker_corpus(0, corpus_root.clone()); + master.initial_export_dirs = None; + let mut corpus = + CorpusEntry::new(vec![basic_tx_with_calldata(vec![0; GZIP_THRESHOLD * 2])]); + corpus.timestamp = 0; + let path = + corpus.write_to_disk_in(&corpus_root.join("worker0").join(CORPUS_DIR), true).unwrap(); + let name = path.file_name().unwrap().to_str().unwrap().to_owned(); + assert!(name.ends_with(".json.gz")); + master.push_synced_corpus_entry(corpus, 0, name.clone()); + + master.export_to_workers(3).unwrap(); + + assert!(corpus_root.join("worker1").join(SYNC_DIR).join(&name).is_file()); + assert!(corpus_root.join("worker2").join(SYNC_DIR).join(name).is_file()); + assert!(master.new_entry_indices.is_empty()); + } + + #[test] + fn worker_retries_failed_export() { + let corpus_root = temp_corpus_dir(); + let mut worker = empty_worker_corpus(1, corpus_root.clone()); + let corpus = CorpusEntry::new(vec![basic_tx_with_calldata([1])]); + let name = corpus.file_name(false); + worker.push_corpus_entry(corpus); + + let master_sync = corpus_root.join("worker0").join(SYNC_DIR); + fs::create_dir_all(&master_sync).unwrap(); + let destination = master_sync.join(&name); + foundry_common::fs::write_json_file(&destination, &vec![basic_tx_with_calldata([2])]) + .unwrap(); + + worker.export_to_master().unwrap(); + assert_eq!(worker.new_entry_indices, [0]); + assert!(corpus_root.join("worker1").join(CORPUS_DIR).join(&name).is_file()); + + fs::remove_file(&destination).unwrap(); + worker.export_to_master().unwrap(); + + assert!(worker.new_entry_indices.is_empty()); + let exported = read_corpus_dir(&master_sync).next().unwrap().read_tx_seq().unwrap(); + assert!(same_tx_sequence(&exported, &[basic_tx_with_calldata([1])])); + } + + #[test] + fn master_retries_partial_fanout() { + let corpus_root = temp_corpus_dir(); + let mut master = empty_worker_corpus(0, corpus_root.clone()); + master.initial_export_dirs = None; + let corpus = CorpusEntry::new(vec![basic_tx_with_calldata([1])]); + let name = corpus.file_name(false); + corpus.write_to_disk_in(&corpus_root.join("worker0").join(CORPUS_DIR), false).unwrap(); + master.push_corpus_entry(corpus); + + let worker2_sync = corpus_root.join("worker2").join(SYNC_DIR); + fs::create_dir_all(&worker2_sync).unwrap(); + let worker2_destination = worker2_sync.join(&name); + foundry_common::fs::write_json_file( + &worker2_destination, + &vec![basic_tx_with_calldata([2])], + ) + .unwrap(); + + master.export_to_workers(3).unwrap(); + assert_eq!(master.new_entry_indices, [0]); + assert!(corpus_root.join("worker1").join(SYNC_DIR).join(&name).is_file()); + + fs::remove_file(&worker2_destination).unwrap(); + master.export_to_workers(3).unwrap(); + + assert!(master.new_entry_indices.is_empty()); + assert!(worker2_destination.is_file()); + } + + #[test] + fn final_sync_coordinator_yields_to_nested_rayon_workers() { + rayon::ThreadPoolBuilder::new().num_threads(2).build().unwrap().install(|| { + (0..2usize).into_par_iter().for_each(|_| { + let coordinator = CorpusSyncCoordinator::new(2); + (0..2usize).into_par_iter().for_each(|_| assert!(coordinator.wait())); + }); + }); + } + + #[test] + fn final_calibration_rejects_non_file_corpus_entry() { + let corpus_root = temp_corpus_dir(); + let mut worker = empty_worker_corpus(1, corpus_root.clone()); + let sync_dir = corpus_root.join("worker1").join(SYNC_DIR); + fs::create_dir(sync_dir.join("00000000-0000-0000-0000-000000000001-1.json")).unwrap(); + let target = Address::repeat_byte(0x11); + let executor = sync_test_executor(corpus_root, target); + let function = Function::parse("test(uint256)").unwrap(); + + let err = worker + .calibrate( + &executor, + ReplayTarget { + stateless: Some(StatelessReplayTarget { function: &function, address: target }), + fuzzed_contracts: None, + dynamic: None, + senders: None, + }, + true, + ) + .unwrap_err() + .to_string(); + + assert!(err.contains("not a regular file"), "{err}"); + } + + #[test] + fn final_sync_completes_interleaved_corpus_lifecycle() { + let corpus_root = temp_corpus_dir(); + let function = Function::parse("test(uint256)").unwrap(); + let target_address = Address::repeat_byte(0x11); + let startup = CorpusEntry::new(vec![tx_for_function( + target_address, + &function, + &[DynSolValue::Uint(U256::ZERO, 256)], + )]); + let startup_name = startup.file_name(false); + fs::create_dir_all(corpus_root.join("worker1").join(CORPUS_DIR)).unwrap(); + startup.write_to_disk_in(&corpus_root.join("worker1").join(CORPUS_DIR), false).unwrap(); + let coordinator = Arc::new(CorpusSyncCoordinator::new(3)); + let setup = Arc::new(std::sync::Barrier::new(3)); + + std::thread::scope(|scope| { + let root = corpus_root.clone(); + let master_startup_name = startup_name.clone(); + let master_coordinator = coordinator.clone(); + let master_setup = setup.clone(); + scope.spawn(move || { + let function = Function::parse("test(uint256)").unwrap(); + let seed = WorkerCorpusSeed { + replay_dirs: Some(canonical_replay_dirs(&root)), + ..Default::default() + }; + let mut master = worker_corpus(0, root.clone(), seed); + let periodic = CorpusEntry::new(vec![tx_for_function( + target_address, + &function, + &[DynSolValue::Uint(U256::ZERO, 256)], + )]); + let periodic_name = periodic.file_name(false); + periodic.write_to_disk_in(&root.join("worker0").join(CORPUS_DIR), false).unwrap(); + master.push_corpus_entry(periodic); + + // The periodic fan-out reaches worker 1 but remains pending for worker 2. + let blocked = root.join("worker2").join(SYNC_DIR).join(&periodic_name); + fs::create_dir_all(&blocked).unwrap(); + master.export_to_workers(3).unwrap(); + assert_eq!(master.new_entry_indices, [0]); + assert!(root.join("worker2").join(SYNC_DIR).join(master_startup_name).is_file()); + fs::remove_dir(blocked).unwrap(); + master_setup.wait(); + + finalize_test_worker(&mut master, root, target_address, &master_coordinator); + }); + + for id in 1..=2 { + let root = corpus_root.clone(); + let coordinator = coordinator.clone(); + let setup = setup.clone(); + scope.spawn(move || { + let mut worker = empty_worker_corpus(id, root.clone()); + setup.wait(); + + if id == 1 { + // This old-timestamp finding appears after the last periodic sync. + let function = Function::parse("test(uint256)").unwrap(); + let mut late = CorpusEntry::new(vec![tx_for_function( + target_address, + &function, + &[DynSolValue::Uint(U256::ONE, 256)], + )]); + late.timestamp = 0; + worker.push_corpus_entry(late); + } + + finalize_test_worker(&mut worker, root, target_address, &coordinator); + }); + } + }); + + assert_eq!(read_corpus_dir(&corpus_root.join("worker2").join(CORPUS_DIR)).count(), 2); + assert!(read_corpus_dir(&corpus_root.join("worker2").join(SYNC_DIR)).next().is_none()); + } + + #[test] + fn master_distributes_startup_corpus_only_once() { + let corpus_root = temp_corpus_dir(); + let corpus = CorpusEntry::new(vec![basic_tx()]); + let name = corpus.file_name(false); + let non_master_corpus = corpus_root.join("worker1").join(CORPUS_DIR); + fs::create_dir_all(&non_master_corpus).unwrap(); + corpus.write_to_disk_in(&non_master_corpus, false).unwrap(); + let seed = WorkerCorpusSeed { + replay_dirs: Some(canonical_replay_dirs(&corpus_root)), + ..Default::default() + }; + let mut master = worker_corpus(0, corpus_root.clone(), seed); + + let other_worker_sync_dir = corpus_root.join("worker2").join(SYNC_DIR); + fs::create_dir_all(&other_worker_sync_dir).unwrap(); + let other_worker_sync = other_worker_sync_dir.join(&name); + foundry_common::fs::write_json_file(&other_worker_sync, &vec![basic_tx_with_calldata([1])]) + .unwrap(); + + master.export_to_workers(3).unwrap(); + let source_worker_sync = corpus_root.join("worker1").join(SYNC_DIR).join(&name); + assert!(source_worker_sync.is_file()); + assert!(other_worker_sync.is_file()); + assert!(master.initial_export_dirs.is_some()); + + fs::remove_file(&other_worker_sync).unwrap(); + master.export_to_workers(3).unwrap(); + assert!(other_worker_sync.is_file()); + assert!(master.initial_export_dirs.is_none()); + + let source_entry = read_corpus_dir(source_worker_sync.parent().unwrap()).next().unwrap(); + assert!(accept_synced_corpus_file( + &source_entry, + &source_entry.read_tx_seq().unwrap(), + &non_master_corpus.join(&name), + )); + assert!(!source_worker_sync.exists()); + assert!(non_master_corpus.join(&name).is_file()); + + fs::remove_file(&other_worker_sync).unwrap(); + master.export_to_workers(3).unwrap(); + assert!(!other_worker_sync.exists()); + + let flat_root = temp_corpus_dir(); + let flat_corpus = CorpusEntry::new(vec![basic_tx()]); + let flat_name = flat_corpus.file_name(false); + flat_corpus.write_to_disk_in(&flat_root, false).unwrap(); + let config = corpus_config(flat_root.clone()); + let seed = WorkerCorpusSeed::load_from_disk::( + &config, + None, + None, + ReplayTarget { stateless: None, fuzzed_contracts: None, dynamic: None, senders: None }, + ) + .unwrap(); + fs::create_dir_all(flat_root.join("worker1").join(CORPUS_DIR)).unwrap(); + let generator = + test_sequence(&config, TxGenerator::from_strategy(Just(basic_tx()).boxed())); + let mut flat_master = WorkerCorpus::from_seed(0, config, generator, seed).unwrap(); + flat_master.worker_sync_enabled = true; + let pending = CorpusEntry::new(vec![basic_tx_with_calldata([1])]); + let pending_name = pending.file_name(false); + pending.write_to_disk_in(&flat_root.join("worker0").join(CORPUS_DIR), false).unwrap(); + flat_master.push_corpus_entry(pending); + + flat_master.export_to_workers(2).unwrap(); + + assert!(flat_root.join("worker1").join(SYNC_DIR).join(flat_name).is_file()); + assert!(flat_root.join("worker1").join(SYNC_DIR).join(pending_name).is_file()); + assert!(flat_master.new_entry_indices.is_empty()); + } + + #[test] + fn pending_master_fanout_entries_are_not_evicted() { + let corpus_root = temp_corpus_dir(); + let mut master = empty_worker_corpus(0, corpus_root.clone()); + master.initial_export_dirs = None; + let mut pending = CorpusEntry::new(vec![basic_tx_with_calldata([1])]); + pending.total_mutations = 1; + let retained = CorpusEntry::new(vec![basic_tx_with_calldata([2])]); + let pending_name = pending.file_name(false); + let retained_name = retained.file_name(false); + let pending_timestamp = pending.timestamp; + let retained_timestamp = retained.timestamp; + let master_corpus = corpus_root.join("worker0").join(CORPUS_DIR); + pending.write_to_disk_in(&master_corpus, false).unwrap(); + retained.write_to_disk_in(&master_corpus, false).unwrap(); + master.push_synced_corpus_entry(pending, pending_timestamp, pending_name.clone()); + master.push_synced_corpus_entry(retained, retained_timestamp, retained_name.clone()); + + master.evict_oldest_corpus().unwrap(); + master.export_to_workers(2).unwrap(); + + let worker_sync = corpus_root.join("worker1").join(SYNC_DIR); + assert!(worker_sync.join(pending_name).is_file()); + assert!(worker_sync.join(retained_name).is_file()); + } + + #[test] + fn campaign_processing_writes_worker_file_immediately() { + let corpus_root = temp_corpus_dir(); + let worker_subdir = corpus_root.join("worker1"); + let mut manager = empty_worker_corpus(1, corpus_root); + + manager.process_inputs_for_campaign(&[basic_tx()], &[], true, None); + + assert_eq!(manager.in_memory_corpus.len(), 1); + assert_eq!(manager.metrics.corpus_count, 1); + assert_eq!(read_corpus_dir(&worker_subdir.join(CORPUS_DIR)).count(), 1); + } + + /// `RawCallResult` carrying a single edge hit, to drive `merge_edge_coverage` without the EVM. + fn edge_call(edge: EdgeKey, count: u8) -> RawCallResult { + RawCallResult { + edge_coverage: Some(EdgeCoverage::CollisionFree(vec![EdgeCovHit { edge, count }])), + ..Default::default() + } + } + + #[test] + fn merge_edge_coverage_advances_timer_only_for_new_edges() { + let corpus_root = temp_corpus_dir(); + let mut manager = empty_worker_corpus(1, corpus_root); + + // No edge seen yet. + assert!(manager.time_since_new_edge().is_none()); + assert_eq!(manager.metrics.cumulative_edges_seen, 0); + + let edge = + EdgeKey { address: Address::ZERO, depth: None, pc: 0, jump_dest: U256::from(10) }; + + // First-time edge starts the timer. + assert!(manager.merge_edge_coverage(&mut edge_call(edge, 1))); + let first = manager.last_new_edge_at.expect("timer set after first new edge"); + assert_eq!(manager.metrics.cumulative_edges_seen, 1); + + // Same edge, higher bucket = a feature, not an edge: timer must not advance. + assert!(manager.merge_edge_coverage(&mut edge_call(edge, 8))); + assert_eq!(manager.last_new_edge_at, Some(first)); + assert_eq!(manager.metrics.cumulative_edges_seen, 1); + assert_eq!(manager.metrics.cumulative_features_seen, 1); + + // A distinct edge advances the timer. + let other = + EdgeKey { address: Address::ZERO, depth: None, pc: 1, jump_dest: U256::from(20) }; + assert!(manager.merge_edge_coverage(&mut edge_call(other, 1))); + let second = manager.last_new_edge_at.expect("timer present"); + assert!(second >= first); + assert_eq!(manager.metrics.cumulative_edges_seen, 2); + assert!(manager.time_since_new_edge().is_some()); + } + + #[test] + fn empty_input_sequence_with_new_coverage_does_not_panic_or_insert() { + // A run where every executed call was discarded (magic assume) or popped (reverts + // without `fail_on_revert`, handler assertions) leaves no surviving inputs, yet + // `new_coverage` can still be true because edge coverage is collected before the + // input is popped. Processing must not panic and must not persist an entry. + let corpus_root = temp_corpus_dir(); + let worker_subdir = corpus_root.join("worker1"); + let mut manager = empty_worker_corpus(1, corpus_root); + + manager.process_inputs_for_campaign(&[], &[], true, None); + + assert_eq!(manager.in_memory_corpus.len(), 0); + assert_eq!(manager.metrics.corpus_count, 0); + assert_eq!(read_corpus_dir(&worker_subdir.join(CORPUS_DIR)).count(), 0); + + // Live processing path must also tolerate the empty sequence. + manager.process_inputs(&[], &[], true, None); + assert_eq!(manager.in_memory_corpus.len(), 0); + assert_eq!(read_corpus_dir(&worker_subdir.join(CORPUS_DIR)).count(), 0); + } + + #[test] + fn campaign_processing_defers_only_optimization_persistence() { + let corpus_root = temp_corpus_dir(); + let mut manager = empty_worker_corpus(1, corpus_root.clone()); + let sequence = vec![basic_tx()]; + manager.process_inputs_for_campaign( + &sequence, + &[], + false, + Some((I256::try_from(7).unwrap(), sequence.clone())), + ); + + let worker_corpus_dir = corpus_root.join("worker1").join(CORPUS_DIR); + let entries = read_corpus_dir(&worker_corpus_dir).collect::>(); + assert_eq!(entries.len(), 1); + let persisted_sequence = entries[0].read_tx_seq().unwrap(); + assert_eq!(persisted_sequence.len(), sequence.len()); + assert_eq!(persisted_sequence[0].sender, sequence[0].sender); + assert_eq!(persisted_sequence[0].call_details.target, sequence[0].call_details.target); + assert_eq!(persisted_sequence[0].call_details.calldata, sequence[0].call_details.calldata); + assert!(!corpus_root.join(OPTIMIZATION_BEST_FILE).exists()); + + persist_campaign_optimization( + &corpus_config(corpus_root.clone()), + Some(I256::try_from(7).unwrap()), + &sequence, + ); + + let state: OptimizationState = + foundry_common::fs::read_json_file(&corpus_root.join(OPTIMIZATION_BEST_FILE)).unwrap(); + assert_eq!(state.best_value, I256::try_from(7).unwrap()); + assert_eq!(state.best_sequence.len(), sequence.len()); + assert_eq!(state.best_sequence[0].sender, sequence[0].sender); + assert_eq!(state.best_sequence[0].call_details.target, sequence[0].call_details.target); + assert_eq!(state.best_sequence[0].call_details.calldata, sequence[0].call_details.calldata); + } + + #[test] + fn persisted_worker_corpus_entries_are_deduped_by_uuid() { + let corpus_root = temp_corpus_dir(); + let corpus = CorpusEntry::new(vec![basic_tx()]); + let duplicate = corpus.clone(); + + let worker0_corpus = corpus_root.join("worker0").join(CORPUS_DIR); + let worker1_corpus = corpus_root.join("worker1").join(CORPUS_DIR); + fs::create_dir_all(&worker0_corpus).unwrap(); + fs::create_dir_all(&worker1_corpus).unwrap(); + corpus.write_to_disk_in(&worker0_corpus, false).unwrap(); + duplicate.write_to_disk_in(&worker1_corpus, false).unwrap(); + + let mut seen = HashSet::new(); + let entries = unique_corpus_entries(&canonical_replay_dirs(&corpus_root), &mut seen) + .collect::>(); + + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].uuid, corpus.uuid); + } + + #[test] + fn corpus_entry_write_uses_unparsable_temp_file() { + let corpus_dir = temp_corpus_dir(); + let corpus = CorpusEntry::new(vec![basic_tx()]); + let temp_path = + corpus_dir.join(format!(".{}.{}.tmp", corpus.file_name(false), Uuid::new_v4())); + fs::write(&temp_path, b"{").unwrap(); + + let path = corpus.write_to_disk_in(&corpus_dir, false).unwrap(); + let entries = read_corpus_dir(&corpus_dir).collect::>(); + + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].path, path); + assert!(temp_path.exists()); + } + + #[test] + fn persist_corpus_seed_skips_duplicate_sequence() { + let corpus_root = temp_corpus_dir(); + let config = corpus_config(corpus_root.clone()); + let sequence = vec![basic_tx_with_calldata(vec![0x12, 0x34])]; + + let first = persist_corpus_seed(&config, sequence.clone()).unwrap().unwrap(); + let second = persist_corpus_seed(&config, sequence).unwrap().unwrap(); + let entries = + read_corpus_dir(&corpus_root.join("worker0").join(CORPUS_DIR)).collect::>(); + + assert_eq!(first, second); + assert_eq!(entries.len(), 1); + } + + #[test] + fn non_master_campaign_worker_uses_persisted_optimization_baseline() { + let corpus_root = temp_corpus_dir(); + let persisted_sequence = vec![basic_tx()]; + let persisted_state = OptimizationState { + best_value: I256::try_from(100).unwrap(), + best_sequence: persisted_sequence, + }; + foundry_common::fs::write_json_file( + &corpus_root.join(OPTIMIZATION_BEST_FILE), + &persisted_state, + ) + .unwrap(); + let config = corpus_config(corpus_root); + let generator = + test_sequence(&config, TxGenerator::from_strategy(Just(basic_tx()).boxed())); + let mut manager = WorkerCorpus::new::( + 1, + config, + generator, + None, + None, + ReplayTarget { stateless: None, fuzzed_contracts: None, dynamic: None, senders: None }, + ) + .unwrap(); + + let worse_sequence = vec![basic_tx()]; + manager.process_inputs_for_campaign( + &worse_sequence, + &[], + false, + Some((I256::try_from(50).unwrap(), worse_sequence.clone())), + ); + + let better_sequence = vec![basic_tx()]; + manager.process_inputs_for_campaign( + &better_sequence, + &[], + false, + Some((I256::try_from(150).unwrap(), better_sequence.clone())), + ); + assert_eq!(manager.optimization_best_value, Some(I256::try_from(150).unwrap())); + } + + #[test] + fn worker_can_initialize_from_warmed_seed() { + let corpus_root = temp_corpus_dir(); + let tx_seq = vec![basic_tx()]; + let seed = WorkerCorpusSeed { + in_memory_corpus: vec![CorpusEntry::new(tx_seq.clone())], + history_map: vec![1, 2, 3], + edge_indices: EdgeIndexMap::default(), + sancov_history_map: vec![4, 5], + metrics: CorpusMetrics { + cumulative_edges_seen: 7, + cumulative_features_seen: 11, + corpus_count: 1, + favored_items: 0, + }, + replay_dirs: None, + failed_replays: 13, + optimization_best_value: Some(I256::try_from(17).unwrap()), + optimization_best_sequence: tx_seq, + last_new_edge_at: None, + }; + + let config = corpus_config(corpus_root); + let generator = + test_sequence(&config, TxGenerator::from_strategy(Just(basic_tx()).boxed())); + let manager = WorkerCorpus::from_seed(1, config, generator, seed).unwrap(); + + assert_eq!(manager.in_memory_corpus.len(), 1); + assert_eq!(manager.history_map, vec![1, 2, 3]); + assert_eq!(manager.sancov_history_map, vec![4, 5]); + assert_eq!(manager.metrics.cumulative_edges_seen, 7); + assert_eq!(manager.metrics.cumulative_features_seen, 11); + assert_eq!(manager.metrics.corpus_count, 1); + assert_eq!(manager.failed_replays, 13); + let (value, sequence) = manager.optimization_initial_state(); + assert_eq!(value, Some(I256::try_from(17).unwrap())); + assert_eq!(sequence.len(), 1); + } + + #[test] + fn clone_for_worker_shards_warmed_corpus_and_recomputes_metrics() { + let entries = (0..10) + .map(|idx| { + let mut entry = CorpusEntry::new(vec![basic_tx()]); + entry.is_favored = idx % 2 == 0; + entry + }) + .collect::>(); + let entry_ids = entries.iter().map(|entry| entry.uuid).collect::>(); + let seed = WorkerCorpusSeed { + in_memory_corpus: entries, + history_map: vec![1, 2, 3], + edge_indices: EdgeIndexMap::default(), + sancov_history_map: vec![4, 5], + metrics: CorpusMetrics { + cumulative_edges_seen: 7, + cumulative_features_seen: 11, + corpus_count: 10, + favored_items: 5, + }, + replay_dirs: None, + failed_replays: 13, + optimization_best_value: Some(I256::try_from(17).unwrap()), + optimization_best_sequence: vec![basic_tx()], + last_new_edge_at: None, + }; + + let worker_count = 3; + let shards = (0..worker_count) + .map(|worker_id| seed.clone_for_worker(worker_id, worker_count, true)) + .collect::>(); + let mut sharded_ids = shards + .iter() + .flat_map(|shard| shard.in_memory_corpus.iter().map(|entry| entry.uuid)) + .collect::>(); + let mut expected_ids = entry_ids.clone(); + sharded_ids.sort_unstable(); + expected_ids.sort_unstable(); + + assert_eq!(sharded_ids, expected_ids); + assert_eq!( + shards[0].in_memory_corpus.iter().map(|entry| entry.uuid).collect::>(), + [entry_ids[0], entry_ids[3], entry_ids[6], entry_ids[9]] + ); + assert_eq!( + shards[1].in_memory_corpus.iter().map(|entry| entry.uuid).collect::>(), + [entry_ids[1], entry_ids[4], entry_ids[7]] + ); + assert_eq!( + shards[2].in_memory_corpus.iter().map(|entry| entry.uuid).collect::>(), + [entry_ids[2], entry_ids[5], entry_ids[8]] + ); + assert_eq!( + shards.iter().map(|shard| shard.in_memory_corpus.len()).collect::>(), + [4, 3, 3] + ); + assert_eq!( + shards.iter().map(|shard| shard.metrics.corpus_count).collect::>(), + [4, 3, 3] + ); + assert_eq!( + shards.iter().map(|shard| shard.metrics.favored_items).collect::>(), + [2, 1, 2] + ); + assert!(shards.iter().all(|shard| shard.history_map == seed.history_map)); + assert!(shards.iter().all(|shard| shard.sancov_history_map == seed.sancov_history_map)); + assert!(shards.iter().all(|shard| shard.metrics.cumulative_edges_seen == 7)); + assert!(shards.iter().all(|shard| shard.metrics.cumulative_features_seen == 11)); + } + + #[test] + fn clone_for_worker_can_strip_cmp_sequences() { + let cmp = CmpOperands { + op1: U256::ONE, + op2: U256::from(2), + pc: 3, + address: Address::ZERO, + opcode: 0, + }; + let entries = (0..2) + .map(|_| { + CorpusEntry::new_with_cmp( + vec![basic_tx()], + vec![vec![ComparisonHint { lhs: cmp.op1, rhs: cmp.op2 }]], + Uuid::new_v4(), + ) + }) + .collect::>(); + let seed = WorkerCorpusSeed { in_memory_corpus: entries, ..Default::default() }; + + let with_cmp = seed.clone_for_worker(0, 1, true); + let without_cmp = seed.clone_for_worker(0, 1, false); + + assert!(with_cmp.in_memory_corpus.iter().all(|entry| !entry.cmp_seq[0].is_empty())); + assert!(without_cmp.in_memory_corpus.iter().all(|entry| entry.cmp_seq.is_empty())); + } + + #[test] + fn retain_replayable_removes_off_target_corpus_entries() { + let target = Address::repeat_byte(0x11); + let foo = Function::parse("foo()").unwrap(); + let bar = Function::parse("bar()").unwrap(); + let foo_selector = foo.selector(); + let foo_tx = tx_for_function(target, &foo, &[]); + let bar_tx = tx_for_function(target, &bar, &[]); + let mut foo_entry = CorpusEntry::new(vec![foo_tx.clone()]); + foo_entry.is_favored = true; + let mut bar_entry = CorpusEntry::new(vec![bar_tx.clone()]); + bar_entry.is_favored = true; + let mut seed = WorkerCorpusSeed { + in_memory_corpus: vec![foo_entry, bar_entry], + metrics: CorpusMetrics { corpus_count: 2, favored_items: 2, ..Default::default() }, + optimization_best_value: Some(I256::try_from(17).unwrap()), + optimization_best_sequence: vec![bar_tx], + ..Default::default() + }; + let targeted_contracts = + targeted_contracts_with_selective_functions(target, vec![foo, bar], [foo_selector]); + let targets = targeted_contracts.targets(); + + seed.retain_replayable(&targets); + + assert_eq!(seed.in_memory_corpus.len(), 1); + assert_eq!(seed.in_memory_corpus[0].tx_seq.len(), 1); + assert_eq!( + seed.in_memory_corpus[0].tx_seq[0].call_details.target, + foo_tx.call_details.target + ); + assert_eq!( + seed.in_memory_corpus[0].tx_seq[0].call_details.calldata, + foo_tx.call_details.calldata + ); + assert_eq!(seed.metrics.corpus_count, 1); + assert_eq!(seed.metrics.favored_items, 1); + assert!(seed.optimization_best_value.is_none()); + assert!(seed.optimization_best_sequence.is_empty()); + } + + #[test] + fn hoist_observed_calls_bundles_replayable_subcalls_into_one_corpus_entry() { + let target = Address::repeat_byte(0x42); + let other = Address::repeat_byte(0x43); + let sender = Address::repeat_byte(0xaa); + let observed_caller = Address::repeat_byte(0xbb); + let foo = Function::parse("foo(uint256)").unwrap(); + let bar = Function::parse("bar()").unwrap(); + let foo_selector = foo.selector(); + let bar_selector = bar.selector(); + let targeted_contracts = targeted_contracts_with_selective_functions( + target, + vec![foo, bar], + [foo_selector, bar_selector], + ); + + let mut foo_calldata = vec![0u8; 36]; + foo_calldata[..4].copy_from_slice(&foo_selector[..]); + let bar_calldata = bar_selector.to_vec(); + let mut unknown_selector = vec![0u8; 36]; + unknown_selector[..4].copy_from_slice(&[0xde, 0xad, 0xbe, 0xef]); + let value = U256::ONE; + + let observed = vec![ + ObservedCall { + depth: 1, + caller: observed_caller, + target: other, + calldata: Bytes::from(foo_calldata.clone()), + value: Some(value), + }, + ObservedCall { + depth: 1, + caller: observed_caller, + target, + calldata: Bytes::from(foo_calldata), + value: None, + }, + ObservedCall { + depth: 2, + caller: observed_caller, + target, + calldata: Bytes::from(bar_calldata), + value: None, + }, + ObservedCall { + depth: 1, + caller: observed_caller, + target, + calldata: Bytes::from(unknown_selector), + value: None, + }, + ObservedCall { + depth: 1, + caller: observed_caller, + target, + calldata: Bytes::from(vec![0u8; 3]), + value: None, + }, + ]; + let parent_tx = BasicTxDetails { + warp: Some(U256::from(123)), + roll: Some(U256::from(456)), + sender, + call_details: CallDetails { + target: Address::repeat_byte(0x99), + calldata: Bytes::new(), + value: None, + }, + }; + let mut manager = empty_worker_corpus(0, temp_corpus_dir()); + + manager.hoist_observed_calls( + &observed, + &parent_tx, + &targeted_contracts, + &SenderFilters::default(), + CorpusInsertionMode::Live, + ); + + assert_eq!(manager.in_memory_corpus.len(), 1); + assert_eq!(manager.metrics.corpus_count, 1); + + let entry = manager.in_memory_corpus.last().unwrap(); + assert_eq!(entry.tx_seq.len(), 2); + let tx = &entry.tx_seq[0]; + assert_eq!(tx.warp, parent_tx.warp); + assert_eq!(tx.roll, parent_tx.roll); + assert_eq!(tx.sender, observed_caller); + assert_eq!(tx.call_details.target, target); + assert_eq!(&tx.call_details.calldata[..4], &foo_selector[..]); + assert_eq!(tx.call_details.value, None); + + let tx = &entry.tx_seq[1]; + assert_eq!(tx.warp, None); + assert_eq!(tx.roll, None); + assert_eq!(tx.sender, observed_caller); + assert_eq!(tx.call_details.target, target); + assert_eq!(&tx.call_details.calldata[..4], &bar_selector[..]); + assert_eq!(tx.call_details.value, None); + } + + #[test] + fn hoist_observed_calls_persists_immediately() { + let target = Address::repeat_byte(0x42); + let foo = Function::parse("foo()").unwrap(); + let selector = foo.selector(); + let targeted_contracts = targeted_contracts_with_selective_functions(target, vec![foo], []); + let observed = vec![ObservedCall { + depth: 1, + caller: Address::repeat_byte(0xaa), + target, + calldata: selector.into(), + value: None, + }]; + let corpus_root = temp_corpus_dir(); + let worker_corpus_dir = corpus_root.join("worker1").join(CORPUS_DIR); + let mut manager = empty_worker_corpus(1, corpus_root); + + manager.hoist_observed_calls( + &observed, + &basic_tx(), + &targeted_contracts, + &SenderFilters::default(), + CorpusInsertionMode::Live, + ); + + assert_eq!(manager.in_memory_corpus.len(), 1); + assert_eq!(read_corpus_dir(&worker_corpus_dir).count(), 1); + } + + #[test] + fn hoist_observed_calls_skips_empty_or_non_coverage_guided_inputs() { + let target = Address::repeat_byte(0x42); + let foo = Function::parse("foo()").unwrap(); + let selector = foo.selector(); + let targeted_contracts = targeted_contracts_with_selective_functions(target, vec![foo], []); + let observed = vec![ObservedCall { + depth: 1, + caller: Address::repeat_byte(0xaa), + target, + calldata: selector.into(), + value: None, + }]; + + let mut no_corpus_config = corpus_config(temp_corpus_dir()); + no_corpus_config.corpus_dir = None; + let generator = + test_sequence(&no_corpus_config, TxGenerator::from_strategy(Just(basic_tx()).boxed())); + let mut manager = + WorkerCorpus::from_seed(0, no_corpus_config, generator, WorkerCorpusSeed::default()) + .unwrap(); + manager.hoist_observed_calls( + &observed, + &basic_tx(), + &targeted_contracts, + &SenderFilters::default(), + CorpusInsertionMode::Live, + ); + assert!(manager.in_memory_corpus.is_empty()); + + let mut manager = empty_worker_corpus(0, temp_corpus_dir()); + manager.hoist_observed_calls( + &[], + &basic_tx(), + &targeted_contracts, + &SenderFilters::default(), + CorpusInsertionMode::Live, + ); + assert!(manager.in_memory_corpus.is_empty()); + } + + #[test] + fn sequence_from_test_trace_preserves_forward_environment_and_rejects_restores() { + let target = Address::repeat_byte(0x42); + let sender = Address::repeat_byte(0xaa); + let foo = Function::parse("foo()").unwrap(); + let foo_selector = foo.selector(); + let targeted_contracts = + targeted_contracts_with_selective_functions(target, vec![foo], [foo_selector]); + let targets = targeted_contracts.targets(); + let target_call = || ObservedCall { + depth: 1, + caller: sender, + target, + calldata: foo_selector.into(), + value: Some(U256::from(7)), + }; + let cheatcode_call = |calldata| ObservedCall { + depth: 1, + caller: sender, + target: CHEATCODE_ADDRESS, + calldata: Bytes::from(calldata), + value: None, + }; + let observed = vec![ + cheatcode_call(warpCall { newTimestamp: U256::from(105) }.abi_encode()), + target_call(), + cheatcode_call(rollCall { newHeight: U256::from(12) }.abi_encode()), + cheatcode_call(warpCall { newTimestamp: U256::from(110) }.abi_encode()), + target_call(), + ]; + + let sequence = sequence_from_test_trace( + &observed, + &targets, + &SenderFilters::default(), + U256::from(100), + U256::from(10), + SpecId::CANCUN, + ) + .unwrap(); + + assert_eq!(sequence.len(), 2); + assert_eq!(sequence[0].warp, Some(U256::from(5))); + assert_eq!(sequence[0].roll, None); + assert_eq!(sequence[1].warp, Some(U256::from(5))); + assert_eq!(sequence[1].roll, Some(U256::from(2))); + assert!(sequence.iter().all(|tx| { + tx.sender == sender + && tx.call_details.target == target + && tx.call_details.value == Some(U256::from(7)) + })); + + for spec_id in [SpecId::PRAGUE, SpecId::OSAKA] { + assert!( + sequence_from_test_trace( + &observed, + &targets, + &SenderFilters::default(), + U256::from(100), + U256::from(10), + spec_id, + ) + .is_none() + ); + + let unchanged_block = [ + cheatcode_call(warpCall { newTimestamp: U256::from(105) }.abi_encode()), + cheatcode_call(rollCall { newHeight: U256::from(10) }.abi_encode()), + target_call(), + ]; + let sequence = sequence_from_test_trace( + &unchanged_block, + &targets, + &SenderFilters::default(), + U256::from(100), + U256::from(10), + spec_id, + ) + .unwrap(); + assert_eq!(sequence.len(), 1); + assert_eq!(sequence[0].warp, Some(U256::from(5))); + assert_eq!(sequence[0].roll, None); + } + + let backwards = + [cheatcode_call(warpCall { newTimestamp: U256::from(99) }.abi_encode()), target_call()]; + assert!( + sequence_from_test_trace( + &backwards, + &targets, + &SenderFilters::default(), + U256::from(100), + U256::from(10), + SpecId::CANCUN + ) + .is_none() + ); + + for calldata in [ + revertToCall { snapshotId: U256::ONE }.abi_encode(), + revertToStateCall { snapshotId: U256::ONE }.abi_encode(), + revertToAndDeleteCall { snapshotId: U256::ONE }.abi_encode(), + revertToStateAndDeleteCall { snapshotId: U256::ONE }.abi_encode(), + setEvmVersionCall { evm: "prague".to_string() }.abi_encode(), + ] { + let restored = [ + cheatcode_call(warpCall { newTimestamp: U256::from(105) }.abi_encode()), + cheatcode_call(rollCall { newHeight: U256::from(12) }.abi_encode()), + cheatcode_call(calldata), + target_call(), + ]; + assert!( + sequence_from_test_trace( + &restored, + &targets, + &SenderFilters::default(), + U256::from(100), + U256::from(10), + SpecId::CANCUN + ) + .is_none() + ); + } + } + + #[test] + fn push_observed_sequence_live_persists_and_memory_only_does_not() { + let corpus_root = temp_corpus_dir(); + let worker0_corpus_dir = corpus_root.join("worker0").join(CORPUS_DIR); + let mut manager = empty_worker_corpus(0, corpus_root.clone()); + + manager.push_observed_sequence(vec![basic_tx()], CorpusInsertionMode::Live); + assert_eq!(manager.in_memory_corpus.len(), 1); + assert_eq!(read_corpus_dir(&worker0_corpus_dir).count(), 1); + + let mut manager = empty_worker_corpus(1, corpus_root.clone()); + let worker1_corpus_dir = corpus_root.join("worker1").join(CORPUS_DIR); + manager.push_observed_sequence(vec![basic_tx()], CorpusInsertionMode::MemoryOnly); + assert_eq!(manager.in_memory_corpus.len(), 1); + assert_eq!(read_corpus_dir(&worker1_corpus_dir).count(), 0); + } + + #[test] + fn detects_legacy_invariant_corpus_dirs_without_matching_worker_dirs() { + let corpus_root = temp_corpus_dir(); + fs::create_dir_all(corpus_root.join("worker0")).unwrap(); + assert!(!has_legacy_invariant_corpus_dirs(&corpus_root)); + + fs::create_dir_all(corpus_root.join("invariant_a")).unwrap(); + assert!(has_legacy_invariant_corpus_dirs(&corpus_root)); + } + + #[test] + fn ignores_optimization_invariant_corpus_dirs_when_detecting_legacy_dirs() { + let corpus_root = temp_corpus_dir(); + fs::create_dir_all(corpus_root.join("worker0")).unwrap(); + let optimization_dir = corpus_root.join("invariant_optimize"); + fs::create_dir_all(optimization_dir.join("worker0")).unwrap(); + fs::write(optimization_dir.join(OPTIMIZATION_BEST_FILE), "{}").unwrap(); + + assert!(!has_legacy_invariant_corpus_dirs(&corpus_root)); + + fs::create_dir_all(corpus_root.join("invariant_legacy").join("worker0")).unwrap(); + assert!(has_legacy_invariant_corpus_dirs(&corpus_root)); + } + + #[test] + fn favored_sets_true_and_metrics_increment_when_ratio_gt_threshold() { + let (mut manager, uuid) = new_manager_with_single_corpus(); + let corpus = manager.in_memory_corpus.iter_mut().find(|c| c.uuid == uuid).unwrap(); + corpus.total_mutations = 4; + corpus.new_finds_produced = 2; // ratio currently 0.5 if both increment → 3/5 = 0.6 > 0.3. + corpus.is_favored = false; + + // Ensure metrics start at 0. + assert_eq!(manager.metrics.favored_items, 0); + + // Mark this as the currently mutated corpus and process a run with new coverage. + manager.current_mutated_index = Some(0); + manager.process_inputs(&[basic_tx()], &[], true, None); + + let corpus = manager.in_memory_corpus.iter().find(|c| c.uuid == uuid).unwrap(); + assert!(corpus.is_favored, "expected favored to be true when ratio > threshold"); + assert_eq!( + manager.metrics.favored_items, 1, + "favored_items should increment on false→true" + ); + } + + #[test] + fn favored_sets_false_and_metrics_decrement_when_ratio_lt_threshold() { + let (mut manager, uuid) = new_manager_with_single_corpus(); + let corpus = manager.in_memory_corpus.iter_mut().find(|c| c.uuid == uuid).unwrap(); + corpus.total_mutations = 9; + corpus.new_finds_produced = 3; // 3/9 = 0.333.. > 0.3; after +1: 3/10 = 0.3 => not favored. + corpus.is_favored = true; // Start as favored. + + manager.metrics.favored_items = 1; + + // Next run does NOT produce coverage → only total_mutations increments, ratio drops. + manager.current_mutated_index = Some(0); + manager.process_inputs(&[basic_tx()], &[], false, None); + + let corpus = manager.in_memory_corpus.iter().find(|c| c.uuid == uuid).unwrap(); + assert!(!corpus.is_favored, "expected favored to be false when ratio < threshold"); + assert_eq!( + manager.metrics.favored_items, 0, + "favored_items should decrement on true→false" + ); + } + + #[test] + fn favored_is_false_on_ratio_equal_threshold() { + let (mut manager, uuid) = new_manager_with_single_corpus(); + let corpus = manager.in_memory_corpus.iter_mut().find(|c| c.uuid == uuid).unwrap(); + // After this call with new_coverage=true, totals become 10 and 3 → 0.3. + corpus.total_mutations = 9; + corpus.new_finds_produced = 2; + corpus.is_favored = false; + + manager.current_mutated_index = Some(0); + manager.process_inputs(&[basic_tx()], &[], true, None); + + let corpus = manager.in_memory_corpus.iter().find(|c| c.uuid == uuid).unwrap(); + assert!( + !(corpus.is_favored), + "with strict '>' comparison, favored must be false when ratio == threshold" + ); + } + + #[test] + fn eviction_skips_favored_and_evicts_non_favored() { + // Manager with two corpora. + let mut favored = CorpusEntry::new(vec![basic_tx()]); + favored.total_mutations = 2; + favored.is_favored = true; + + let mut non_favored = CorpusEntry::new(vec![basic_tx()]); + non_favored.total_mutations = 2; + non_favored.is_favored = false; + let non_favored_uuid = non_favored.uuid; + + let mut manager = seeded_worker_corpus(0, temp_corpus_dir(), vec![favored, non_favored]); + + // First eviction should remove the non-favored one. + manager.evict_oldest_corpus().unwrap(); + assert_eq!(manager.in_memory_corpus.len(), 1); + assert!(manager.in_memory_corpus.iter().all(|c| c.is_favored)); + + // Attempt eviction again: only favored remains → should not remove. + manager.evict_oldest_corpus().unwrap(); + assert_eq!(manager.in_memory_corpus.len(), 1, "favored corpus must not be evicted"); + + // Ensure the evicted one was the non-favored uuid. + assert!(manager.in_memory_corpus.iter().all(|c| c.uuid != non_favored_uuid)); + } + + #[test] + fn non_synchronizing_entries_remain_evictable() { + let corpus_root = temp_corpus_dir(); + let config = corpus_config(corpus_root); + let generator = + test_sequence(&config, TxGenerator::from_strategy(Just(basic_tx()).boxed())); + let mut manager = + WorkerCorpus::from_seed(0, config, generator, WorkerCorpusSeed::default()).unwrap(); + let mut evictable = CorpusEntry::new(vec![basic_tx_with_calldata([1])]); + evictable.total_mutations = 1; + let evictable_uuid = evictable.uuid; + manager.push_corpus_entry(evictable); + manager.push_corpus_entry(CorpusEntry::new(vec![basic_tx_with_calldata([2])])); + + manager.evict_oldest_corpus().unwrap(); + + assert_eq!(manager.in_memory_corpus.len(), 1); + assert!(manager.in_memory_corpus.iter().all(|entry| entry.uuid != evictable_uuid)); + assert!(manager.new_entry_indices.is_empty()); + } + + #[test] + fn invariant_load_drops_entries_outside_current_targets_or_senders() { + let corpus_root = temp_corpus_dir(); + let target = Address::repeat_byte(0x11); + let allowed = Address::repeat_byte(0xaa); + let excluded = Address::repeat_byte(0xbb); + let foo = Function::parse("foo()").unwrap(); + let bar = Function::parse("bar()").unwrap(); + let foo_selector = foo.selector(); + let tx = |sender, function: &Function| BasicTxDetails { + sender, + ..tx_for_function(target, function, &[]) + }; + + let kept = CorpusEntry::new(vec![tx(allowed, &foo)]); + let kept_uuid = kept.uuid; + for entry in [ + kept, + CorpusEntry::new(vec![tx(excluded, &foo)]), + CorpusEntry::new(vec![tx(allowed, &bar)]), + CorpusEntry::new(vec![tx(allowed, &foo), tx(excluded, &foo)]), + ] { + entry.write_to_disk_in(&corpus_root, false).unwrap(); + } + foundry_common::fs::write_json_file( + &corpus_root.join(OPTIMIZATION_BEST_FILE), + &OptimizationState { + best_value: I256::try_from(100).unwrap(), + best_sequence: vec![tx(excluded, &foo)], + }, + ) + .unwrap(); + + let targeted_contracts = + targeted_contracts_with_selective_functions(target, vec![foo, bar], [foo_selector]); + let senders = SenderFilters::new(vec![allowed], vec![]); + let executor = sync_test_executor(corpus_root.clone(), target); + let seed = WorkerCorpusSeed::load_from_disk( + &corpus_config(corpus_root), + None, + Some(&executor), + ReplayTarget { + stateless: None, + fuzzed_contracts: Some(&targeted_contracts), + dynamic: None, + senders: Some(&senders), + }, + ) + .unwrap(); + + assert_eq!( + seed.in_memory_corpus.iter().map(|entry| entry.uuid).collect::>(), + [kept_uuid] + ); + assert_eq!(seed.metrics.corpus_count, 1); + assert!(seed.optimization_best_value.is_none()); + assert!(seed.optimization_best_sequence.is_empty()); + } + + #[test] + fn observed_sequences_skip_disallowed_senders() { + let target = Address::repeat_byte(0x42); + let allowed = Address::repeat_byte(0xaa); + let handler = Address::repeat_byte(0xbb); + let foo = Function::parse("foo()").unwrap(); + let foo_selector = foo.selector(); + let targeted_contracts = + targeted_contracts_with_selective_functions(target, vec![foo], [foo_selector]); + let targets = targeted_contracts.targets(); + let call = |caller| ObservedCall { + depth: 1, + caller, + target, + calldata: foo_selector.into(), + value: None, + }; + let observed = [call(handler), call(allowed)]; + + // Cover both `targetSenders` and `excludeSenders` policies. + for senders in + [SenderFilters::new(vec![allowed], vec![]), SenderFilters::new(vec![], vec![handler])] + { + for sequence in [ + sequence_from_observed(&observed, &targets, &senders, None), + sequence_from_test_trace( + &observed, + &targets, + &senders, + U256::ZERO, + U256::ZERO, + SpecId::CANCUN, + ) + .unwrap(), + ] { + assert_eq!(sequence.iter().map(|tx| tx.sender).collect::>(), [allowed]); + } + } + } +} diff --git a/crates/evm/evm/src/executors/corpus_io.rs b/crates/evm/evm/src/executors/corpus_io.rs new file mode 100644 index 0000000000000..494ef54def869 --- /dev/null +++ b/crates/evm/evm/src/executors/corpus_io.rs @@ -0,0 +1,352 @@ +//! Shared helpers for reading on-disk corpus directories. + +use eyre::{Result, eyre}; +use foundry_evm_fuzz::BasicTxDetails; +use std::{ + collections::HashSet, + path::{Path, PathBuf}, +}; +use uuid::Uuid; + +const WORKER_DIR_PREFIX: &str = "worker"; +const CORPUS_SUBDIR: &str = "corpus"; +const MAX_CORPUS_TREE_DEPTH: usize = 64; +const MAX_CORPUS_TREE_DIRS: usize = 10_000; +const MAX_CORPUS_ENTRIES: usize = 1_000_000; + +/// A single corpus file on disk. +pub struct CorpusDirEntry { + pub path: PathBuf, + pub uuid: Uuid, + pub timestamp: u64, +} + +impl CorpusDirEntry { + pub fn name(&self) -> &str { + self.path.file_name().unwrap().to_str().unwrap() + } + + pub fn read_tx_seq(&self) -> foundry_common::fs::Result> { + if self + .path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("gz")) + { + foundry_common::fs::read_json_gzip_file(&self.path) + } else { + foundry_common::fs::read_json_file(&self.path) + } + } +} + +/// Iterate corpus files in `path`, ignoring entries with unparsable names. +pub fn read_corpus_dir(path: &Path) -> impl Iterator { + let dir = match std::fs::read_dir(path) { + Ok(dir) => dir, + Err(err) => { + debug!(%err, ?path, "failed to read corpus directory"); + return vec![].into_iter(); + } + }; + + dir.filter_map(|res| { + let entry = + res.inspect_err(|err| debug!(%err, "failed to read corpus directory entry")).ok()?; + let path = entry.path(); + if !entry.file_type().ok()?.is_file() { + return None; + } + let name = path.file_name()?.to_str()?; + match parse_corpus_filename(name) { + Ok((uuid, timestamp)) => Some(CorpusDirEntry { path, uuid, timestamp }), + Err(_) => { + debug!(target: "corpus", ?path, "failed to parse corpus filename"); + None + } + } + }) + .collect::>() + .into_iter() +} + +/// Reads every parseable corpus entry while surfacing directory and file-type failures. +pub(crate) fn read_corpus_dir_strict(path: &Path) -> Result> { + let mut entries = Vec::new(); + for entry in std::fs::read_dir(path)? { + let entry = entry?; + let path = entry.path(); + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + let Ok((uuid, timestamp)) = parse_corpus_filename(name) else { + continue; + }; + if !entry.file_type()?.is_file() { + return Err(eyre!("corpus entry is not a regular file: {}", path.display())); + } + entries.push(CorpusDirEntry { path, uuid, timestamp }); + } + Ok(entries) +} + +/// Reads corpus files from a file, corpus directory, worker corpus directory, or generated corpus +/// root such as `///worker0/corpus`. +pub fn read_corpus_tree(path: &Path) -> Result> { + let metadata = std::fs::symlink_metadata(path) + .map_err(|_| eyre!("corpus path does not exist or is not readable: {}", path.display()))?; + let file_type = metadata.file_type(); + if file_type.is_symlink() { + return Err(eyre!("corpus path must not be a symlink: {}", path.display())); + } + if file_type.is_file() { + let name = path.file_name().and_then(|name| name.to_str()).unwrap_or_default(); + let (uuid, timestamp) = parse_corpus_filename(name).unwrap_or((Uuid::nil(), 0)); + return Ok(vec![CorpusDirEntry { path: path.to_path_buf(), uuid, timestamp }]); + } + + if !file_type.is_dir() { + return Err(eyre!("corpus path does not exist or is not readable: {}", path.display())); + } + + let mut seen_entries = HashSet::new(); + let mut visited_dirs = HashSet::new(); + let mut entries = Vec::new(); + let mut stack = vec![(path.to_path_buf(), 0usize)]; + while let Some((dir, depth)) = stack.pop() { + let canonical = match dir.canonicalize() { + Ok(canonical) => canonical, + Err(err) => { + debug!(%err, ?dir, "failed to canonicalize corpus tree directory"); + continue; + } + }; + if !visited_dirs.insert(canonical) { + continue; + } + if visited_dirs.len() > MAX_CORPUS_TREE_DIRS { + return Err(eyre!( + "corpus tree exceeds directory limit of {MAX_CORPUS_TREE_DIRS}: {}", + path.display() + )); + } + + for replay_dir in canonical_replay_dirs(&dir) { + for entry in read_corpus_dir(&replay_dir) { + if seen_entries.insert((entry.uuid, entry.timestamp)) { + entries.push(entry); + if entries.len() > MAX_CORPUS_ENTRIES { + return Err(eyre!( + "corpus tree exceeds entry limit of {MAX_CORPUS_ENTRIES}: {}", + path.display() + )); + } + } + } + } + + if depth >= MAX_CORPUS_TREE_DEPTH { + debug!(?dir, "skipping corpus tree directory beyond depth limit"); + continue; + } + let children = match std::fs::read_dir(&dir) { + Ok(children) => children, + Err(err) => { + debug!(%err, ?dir, "failed to read corpus tree directory"); + continue; + } + }; + for child in children { + let Ok(child) = + child.inspect_err(|err| debug!(%err, "failed to read corpus tree entry")) + else { + continue; + }; + let child_path = child.path(); + let Ok(child_type) = child + .file_type() + .inspect_err(|err| debug!(%err, "failed to read corpus tree entry type")) + else { + continue; + }; + if child_type.is_dir() { + stack.push((child_path, depth + 1)); + } + } + } + + entries.sort_by(|a, b| a.path.cmp(&b.path)); + Ok(entries) +} + +fn is_dir_no_symlink(path: &Path) -> bool { + std::fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_dir()) +} + +/// Strips a trailing `suffix` from `name`, comparing case-insensitively. +fn strip_suffix_ci<'a>(name: &'a str, suffix: &str) -> Option<&'a str> { + let split = name.len().checked_sub(suffix.len())?; + name.is_char_boundary(split) + .then(|| name.split_at(split)) + .filter(|(_, tail)| tail.eq_ignore_ascii_case(suffix)) + .map(|(head, _)| head) +} + +/// Parses a corpus filename of the form `-.json[.gz]`. +/// +/// The `.json` / `.gz` extensions are matched case-insensitively so corpus files +/// written with upper-case extensions are still discovered. +pub fn parse_corpus_filename(name: &str) -> Result<(Uuid, u64)> { + let name = strip_suffix_ci(name, ".gz").unwrap_or(name); + let name = strip_suffix_ci(name, ".json").unwrap_or(name); + let (uuid_str, timestamp_str) = + name.rsplit_once('-').ok_or_else(|| eyre!("invalid corpus filename format: {name}"))?; + Ok((Uuid::parse_str(uuid_str)?, timestamp_str.parse()?)) +} + +/// Returns every `worker*/corpus/` under `root`, or `[root]` if none exist. +pub fn canonical_replay_dirs(root: &Path) -> Vec { + let mut dirs: Vec = std::fs::read_dir(root) + .into_iter() + .flatten() + .flatten() + .filter_map(|e| { + let p = e.path(); + let name = p.file_name()?.to_str()?; + (e.file_type().ok()?.is_dir() && name.starts_with(WORKER_DIR_PREFIX)) + .then(|| p.join(CORPUS_SUBDIR)) + .filter(|d| is_dir_no_symlink(d)) + }) + .collect(); + dirs.sort(); + if dirs.is_empty() { + dirs.push(root.to_path_buf()); + } + dirs +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_dir() -> PathBuf { + let dir = std::env::temp_dir().join(format!("foundry-corpus-io-{}", Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn read_corpus_tree_finds_generated_layout() { + let dir = temp_dir(); + let corpus = dir.join("ExampleTest").join("testFuzz_value").join("worker0").join("corpus"); + std::fs::create_dir_all(&corpus).unwrap(); + let entry = corpus.join("00000000-0000-0000-0000-000000000001-1.json"); + std::fs::write(&entry, "[]").unwrap(); + + let entries = read_corpus_tree(&dir).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].path, entry); + } + + #[test] + fn read_corpus_tree_dedups_worker_entries_by_uuid() { + let dir = temp_dir(); + let name = "00000000-0000-0000-0000-000000000001-1.json"; + for worker in ["worker0", "worker1"] { + let corpus = dir.join("ExampleTest").join("testFuzz_value").join(worker).join("corpus"); + std::fs::create_dir_all(&corpus).unwrap(); + std::fs::write(corpus.join(name), "[]").unwrap(); + } + + let entries = read_corpus_tree(&dir).unwrap(); + assert_eq!(entries.len(), 1); + } + + #[test] + fn read_corpus_tree_keeps_same_uuid_with_different_timestamps() { + let dir = temp_dir(); + for timestamp in [1, 2] { + let name = format!("00000000-0000-0000-0000-000000000001-{timestamp}.json"); + std::fs::write(dir.join(name), "[]").unwrap(); + } + + let entries = read_corpus_tree(&dir).unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].timestamp, 1); + assert_eq!(entries[1].timestamp, 2); + } + + #[cfg(unix)] + #[test] + fn read_corpus_tree_rejects_top_level_symlink() { + let dir = temp_dir(); + let corpus = dir.join("corpus"); + let link = dir.join("link"); + std::fs::create_dir_all(&corpus).unwrap(); + std::os::unix::fs::symlink(&corpus, &link).unwrap(); + + let err = match read_corpus_tree(&link) { + Ok(_) => panic!("top-level symlink should be rejected"), + Err(err) => err.to_string(), + }; + assert!(err.contains("must not be a symlink"), "{err}"); + } + + #[cfg(unix)] + #[test] + fn read_corpus_tree_skips_symlinked_directories() { + let dir = temp_dir(); + let corpus = dir.join("corpus"); + let outside = dir.join("outside"); + std::fs::create_dir_all(&corpus).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + let outside_entry = outside.join("00000000-0000-0000-0000-000000000001-1.json"); + std::fs::write(&outside_entry, "[]").unwrap(); + std::os::unix::fs::symlink(&outside, corpus.join("link")).unwrap(); + + let entries = read_corpus_tree(&corpus).unwrap(); + assert!( + entries.is_empty(), + "{:?}", + entries.iter().map(|entry| &entry.path).collect::>() + ); + } + + #[test] + fn parse_corpus_filename_is_case_insensitive_for_extensions() { + let uuid = "00000000-0000-0000-0000-000000000001"; + let (parsed_uuid, ts) = parse_corpus_filename(&format!("{uuid}-7.JSON.GZ")).unwrap(); + assert_eq!(parsed_uuid, Uuid::parse_str(uuid).unwrap()); + assert_eq!(ts, 7); + + let (parsed_uuid, ts) = parse_corpus_filename(&format!("{uuid}-9.Json")).unwrap(); + assert_eq!(parsed_uuid, Uuid::parse_str(uuid).unwrap()); + assert_eq!(ts, 9); + } + + #[test] + fn read_corpus_tree_discovers_uppercase_extensions() { + let dir = temp_dir(); + let corpus = dir.join("ExampleTest").join("testFuzz_value").join("worker0").join("corpus"); + std::fs::create_dir_all(&corpus).unwrap(); + let entry = corpus.join("00000000-0000-0000-0000-000000000001-1.JSON.GZ"); + std::fs::write(&entry, "[]").unwrap(); + + let entries = read_corpus_tree(&dir).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].path, entry); + } + + #[test] + fn read_corpus_tree_accepts_explicit_single_file_with_arbitrary_name() { + let dir = temp_dir(); + let entry = dir.join("min.json"); + std::fs::write(&entry, "[]").unwrap(); + + let entries = read_corpus_tree(&entry).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].path, entry); + assert_eq!(entries[0].uuid, Uuid::nil()); + assert_eq!(entries[0].timestamp, 0); + } +} diff --git a/crates/evm/evm/src/executors/fuzz/frontier.rs b/crates/evm/evm/src/executors/fuzz/frontier.rs new file mode 100644 index 0000000000000..0f573ebe980d4 --- /dev/null +++ b/crates/evm/evm/src/executors/fuzz/frontier.rs @@ -0,0 +1,710 @@ +use crate::inspectors::CmpOperands; +use alloy_json_abi::Function; +use alloy_primitives::{ + Address, I256, U256, + map::{Entry, HashMap}, +}; +use foundry_common::fs; +use foundry_evm_fuzz::{BasicTxDetails, FuzzRunMetadata}; +use revm::bytecode::opcode; +use serde::{Serialize, Serializer}; +use std::{ + path::Path, + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, +}; + +const FRONTIER_SCHEMA: &str = "foundry:fuzz.branch-frontiers@v1"; +const STATEFUL_FRONTIER_SCHEMA: &str = "foundry:fuzz.branch-frontiers@v2"; +pub(super) const FRONTIER_FILE: &str = "branch-frontiers.json"; + +#[derive(Debug, Serialize)] +pub(super) struct FuzzBranchFrontierArtifact { + /// Stable artifact schema identifier for downstream symbolic consumers. + schema: &'static str, + /// Schema version for consumers that prefer numeric dispatch. + version: u32, + /// Unix timestamp, in seconds, when the artifact was written. + generated_at: u64, + /// Fuzz test signature that produced the frontier records. + test: String, + /// Configured maximum number of records retained for this test. + limit: usize, + /// Captured comparison frontiers. + frontiers: Vec, +} + +impl FuzzBranchFrontierArtifact { + pub(super) fn new( + func: &Function, + limit: usize, + mut frontiers: Vec, + ) -> Self { + for (id, frontier) in frontiers.iter_mut().enumerate() { + frontier.id = id as u64; + } + + Self { + schema: FRONTIER_SCHEMA, + version: 1, + generated_at: SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time went backwards") + .as_secs(), + test: func.signature(), + limit, + frontiers, + } + } +} + +#[derive(Debug, Serialize)] +pub(in crate::executors) struct StatefulFuzzBranchFrontierArtifact { + /// Stable artifact schema identifier for downstream symbolic consumers. + schema: &'static str, + /// Schema version for consumers that prefer numeric dispatch. + version: u32, + /// Unix timestamp, in seconds, when the artifact was written. + generated_at: u64, + /// Invariant campaign anchor that produced the frontier records. + test: String, + /// Configured maximum number of records retained for this campaign. + limit: usize, + /// Deduplicated concrete transaction sequences referenced by frontier records. + #[serde(serialize_with = "serialize_sequences")] + sequences: Vec, + /// Captured comparison frontiers. + frontiers: Vec, +} + +impl StatefulFuzzBranchFrontierArtifact { + pub(in crate::executors) fn new( + func: &Function, + limit: usize, + frontiers: Vec, + ) -> Self { + let mut sequence_indexes = HashMap::<*const BasicTxDetails, usize>::default(); + let mut sequences = Vec::::new(); + let mut records = Vec::with_capacity(frontiers.len()); + for (id, frontier) in frontiers.into_iter().enumerate() { + let sequence_key = frontier.sequence.as_ptr(); + let required_len = frontier.call_index + 1; + let sequence_index = match sequence_indexes.entry(sequence_key) { + Entry::Occupied(entry) => { + let index = *entry.get(); + sequences[index].len = sequences[index].len.max(required_len); + index + } + Entry::Vacant(entry) => { + let index = sequences.len(); + entry.insert(index); + sequences.push(StatefulFuzzSequence { + calls: Arc::clone(&frontier.sequence), + len: required_len, + }); + index + } + }; + records.push(StatefulFuzzBranchFrontier { + id: id as u64, + call_index: frontier.call_index, + sequence_index, + site: frontier.site, + operands: frontier.operands, + }); + } + + Self { + schema: STATEFUL_FRONTIER_SCHEMA, + version: 2, + generated_at: SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time went backwards") + .as_secs(), + test: func.signature(), + limit, + sequences, + frontiers: records, + } + } +} + +#[derive(Debug)] +struct StatefulFuzzSequence { + calls: Arc<[BasicTxDetails]>, + len: usize, +} + +#[derive(Debug, Serialize)] +pub(in crate::executors) struct FuzzBranchFrontier { + /// Unique record identifier. + id: u64, + /// Reproducible fuzz seed, if configured. + #[serde(skip_serializing_if = "Option::is_none")] + seed: Option, + /// 1-based fuzz run number, if known. + #[serde(skip_serializing_if = "Option::is_none")] + run: Option, + /// Fuzz worker that produced the record, if known. + #[serde(skip_serializing_if = "Option::is_none")] + worker: Option, + /// Whether this call also expanded coverage from the worker's current map. Only present when + /// edge coverage is collected (corpus, edge-coverage metrics, or sancov); omitted otherwise. + #[serde(skip_serializing_if = "Option::is_none")] + new_coverage: Option, + /// Index of the call in the recorded sequence. Stateless fuzzing records one call. + call_index: usize, + /// Concrete call sequence that reached the frontier. + #[serde(serialize_with = "serialize_sequence")] + sequence: Arc<[BasicTxDetails]>, + /// EVM comparison site to target symbolically. + site: FuzzBranchFrontierSite, + /// Concrete operands observed at the site. + operands: FuzzBranchFrontierOperands, +} + +#[derive(Debug, Serialize)] +struct StatefulFuzzBranchFrontier { + id: u64, + call_index: usize, + sequence_index: usize, + site: FuzzBranchFrontierSite, + operands: FuzzBranchFrontierOperands, +} + +#[derive(Clone, Copy, Debug, Serialize)] +struct FuzzBranchFrontierSite { + address: Address, + pc: usize, + opcode: u8, + opcode_name: &'static str, +} + +#[derive(Clone, Copy, Debug, Serialize)] +struct FuzzBranchFrontierOperands { + lhs: U256, + rhs: U256, + /// Result of evaluating the captured comparison with these concrete operands. + result: bool, + /// Absolute operand delta interpreted according to the comparison opcode's signedness. + operand_delta: U256, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +struct FuzzBranchFrontierKey { + address: Address, + pc: usize, + opcode: u8, + result: bool, +} + +impl FuzzBranchFrontierKey { + const fn new(cmp: &CmpOperands, result: bool) -> Self { + Self { address: cmp.address, pc: cmp.pc, opcode: cmp.opcode, result } + } +} + +#[derive(Debug, Default)] +pub(in crate::executors) struct FuzzFrontierRecorder { + limit: usize, + frontiers: Vec, + indexes: HashMap, +} + +impl FuzzFrontierRecorder { + pub(in crate::executors) fn new(limit: usize) -> Self { + Self { limit, frontiers: Vec::with_capacity(limit.min(32)), indexes: HashMap::default() } + } + + pub(super) fn capture_call( + &mut self, + run: Option<&FuzzRunMetadata>, + sequence: &[BasicTxDetails], + call_index: usize, + cmp_values: &[CmpOperands], + new_coverage: Option, + ) { + if self.limit == 0 || cmp_values.is_empty() { + return; + } + debug_assert!(call_index < sequence.len()); + + let mut recorded_sequence = None; + self.capture_comparisons( + run, + sequence, + call_index, + cmp_values, + new_coverage, + &mut recorded_sequence, + ); + } + + pub(in crate::executors) fn capture_sequence( + &mut self, + sequence: &[BasicTxDetails], + cmp_sequence: &[Vec], + ) { + if self.limit == 0 || cmp_sequence.is_empty() { + return; + } + debug_assert!(sequence.len() >= cmp_sequence.len()); + let sequence = &sequence[..cmp_sequence.len()]; + + let mut recorded_sequence = None; + for (call_index, cmp_values) in cmp_sequence.iter().enumerate() { + self.capture_comparisons( + None, + sequence, + call_index, + cmp_values, + None, + &mut recorded_sequence, + ); + } + } + + fn capture_comparisons( + &mut self, + run: Option<&FuzzRunMetadata>, + sequence: &[BasicTxDetails], + call_index: usize, + cmp_values: &[CmpOperands], + new_coverage: Option, + recorded_sequence: &mut Option>, + ) { + if cmp_values.is_empty() { + return; + } + + let mut new_frontier = |cmp: &CmpOperands, result, operand_delta| { + let sequence = recorded_sequence.get_or_insert_with(|| Arc::from(sequence)); + FuzzBranchFrontier::new( + run, + Arc::clone(sequence), + *cmp, + result, + operand_delta, + new_coverage, + call_index, + ) + }; + + for cmp in cmp_values { + let result = comparison_result(cmp); + let key = FuzzBranchFrontierKey::new(cmp, result); + let operand_delta = operand_delta(cmp); + + match self.indexes.entry(key) { + Entry::Occupied(entry) => { + let index = *entry.get(); + let previous = &self.frontiers[index]; + if operand_delta < previous.operands.operand_delta + || (operand_delta == previous.operands.operand_delta + && call_index < previous.call_index) + { + self.frontiers[index] = new_frontier(cmp, result, operand_delta); + } + } + Entry::Vacant(entry) => { + if self.frontiers.len() < self.limit { + entry.insert(self.frontiers.len()); + let frontier = new_frontier(cmp, result, operand_delta); + self.frontiers.push(frontier); + } + } + } + } + } + + pub(in crate::executors) fn into_frontiers(self) -> Vec { + self.frontiers + } +} + +impl FuzzBranchFrontier { + const fn key(&self) -> FuzzBranchFrontierKey { + FuzzBranchFrontierKey { + address: self.site.address, + pc: self.site.pc, + opcode: self.site.opcode, + result: self.operands.result, + } + } + + fn new( + run: Option<&FuzzRunMetadata>, + sequence: Arc<[BasicTxDetails]>, + cmp: CmpOperands, + result: bool, + operand_delta: U256, + new_coverage: Option, + call_index: usize, + ) -> Self { + Self { + id: 0, + seed: run.and_then(|run| run.seed), + run: run.and_then(|run| run.run), + worker: run.and_then(|run| run.worker), + new_coverage, + call_index, + sequence, + site: FuzzBranchFrontierSite { + address: cmp.address, + pc: cmp.pc, + opcode: cmp.opcode, + opcode_name: opcode_name(cmp.opcode), + }, + operands: FuzzBranchFrontierOperands { + lhs: cmp.op1, + rhs: cmp.op2, + result, + operand_delta, + }, + } + } +} + +/// Merges per-worker frontier records into a single bounded, globally deduplicated set. +/// +/// Each worker deduplicates its own records by comparison site key while keeping the smallest +/// `operand_delta`, but workers run independently, so the same site can appear in several workers' +/// records with different observed deltas. This applies the same key dedup and smallest-delta +/// policy across all workers so the artifact keeps one globally closest record per site and does +/// not spend `limit` on duplicates. Iteration continues after `limit` is reached because a later +/// record may be a smaller-delta duplicate of an already retained key. +pub(in crate::executors) fn merge_frontiers( + limit: usize, + frontiers: impl IntoIterator, +) -> Vec { + if limit == 0 { + return Vec::new(); + } + + let mut merged = Vec::::with_capacity(limit.min(32)); + let mut indexes = HashMap::::default(); + for frontier in frontiers { + match indexes.entry(frontier.key()) { + Entry::Occupied(entry) => { + let index = *entry.get(); + let previous = &merged[index]; + if frontier.operands.operand_delta < previous.operands.operand_delta + || (frontier.operands.operand_delta == previous.operands.operand_delta + && frontier.call_index < previous.call_index) + { + merged[index] = frontier; + } + } + Entry::Vacant(entry) => { + if merged.len() < limit { + entry.insert(merged.len()); + merged.push(frontier); + } + } + } + } + + merged +} + +pub(in crate::executors) fn write_frontier_artifact( + dir: &Path, + artifact: &impl Serialize, +) -> fs::Result<()> { + fs::create_dir_all(dir)?; + fs::write_json_file(&dir.join(FRONTIER_FILE), artifact) +} + +fn serialize_sequence(sequence: &Arc<[BasicTxDetails]>, serializer: S) -> Result +where + S: Serializer, +{ + Serialize::serialize(sequence.as_ref(), serializer) +} + +fn serialize_sequences( + sequences: &[StatefulFuzzSequence], + serializer: S, +) -> Result +where + S: Serializer, +{ + serializer.collect_seq(sequences.iter().map(|sequence| &sequence.calls[..sequence.len])) +} + +fn comparison_result(cmp: &CmpOperands) -> bool { + match cmp.opcode { + opcode::EQ => cmp.op1 == cmp.op2, + opcode::LT => cmp.op1 < cmp.op2, + opcode::GT => cmp.op1 > cmp.op2, + opcode::SLT => I256::from_raw(cmp.op1) < I256::from_raw(cmp.op2), + opcode::SGT => I256::from_raw(cmp.op1) > I256::from_raw(cmp.op2), + opcode::ISZERO => cmp.op1.is_zero(), + _ => false, + } +} + +fn operand_delta(cmp: &CmpOperands) -> U256 { + match cmp.opcode { + opcode::SLT | opcode::SGT => signed_operand_delta(cmp.op1, cmp.op2), + _ => unsigned_operand_delta(cmp.op1, cmp.op2), + } +} + +fn unsigned_operand_delta(left: U256, right: U256) -> U256 { + if left >= right { left - right } else { right - left } +} + +fn signed_operand_delta(left: U256, right: U256) -> U256 { + let (left_negative, left_magnitude) = signed_magnitude(left); + let (right_negative, right_magnitude) = signed_magnitude(right); + + if left_negative == right_negative { + unsigned_operand_delta(left_magnitude, right_magnitude) + } else { + left_magnitude + right_magnitude + } +} + +fn signed_magnitude(value: U256) -> (bool, U256) { + let negative = I256::from_raw(value) < I256::ZERO; + let magnitude = if negative { U256::ZERO.wrapping_sub(value) } else { value }; + (negative, magnitude) +} + +const fn opcode_name(op: u8) -> &'static str { + match op { + opcode::EQ => "EQ", + opcode::LT => "LT", + opcode::GT => "GT", + opcode::SLT => "SLT", + opcode::SGT => "SGT", + opcode::ISZERO => "ISZERO", + _ => "UNKNOWN", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::Bytes; + use foundry_evm_fuzz::CallDetails; + + #[test] + fn stateless_frontier_preserves_concrete_transaction() { + let tx = BasicTxDetails { + warp: None, + roll: None, + sender: Address::with_last_byte(1), + call_details: CallDetails { + target: Address::with_last_byte(2), + calldata: Bytes::from_static(&[1, 2, 3, 4]), + value: Some(U256::from(7)), + }, + }; + let cmp = CmpOperands { + op1: U256::ZERO, + op2: U256::ONE, + pc: 1, + address: tx.call_details.target, + opcode: opcode::LT, + }; + let mut recorder = FuzzFrontierRecorder::new(1); + + recorder.capture_call(None, std::slice::from_ref(&tx), 0, &[cmp], Some(true)); + + let frontier = recorder.into_frontiers().pop().unwrap(); + assert_eq!(frontier.sequence.len(), 1); + assert_eq!(frontier.call_index, 0); + let recorded = &frontier.sequence[0]; + assert_eq!(recorded.sender, tx.sender); + assert_eq!(recorded.call_details.target, tx.call_details.target); + assert_eq!(recorded.call_details.calldata, tx.call_details.calldata); + assert_eq!(recorded.call_details.value, tx.call_details.value); + } + + #[test] + fn operand_delta_uses_signed_distance_for_signed_comparisons() { + let unsigned = CmpOperands { + op1: U256::MAX, + op2: U256::ONE, + pc: 0, + address: Address::ZERO, + opcode: opcode::LT, + }; + assert_eq!(operand_delta(&unsigned), U256::MAX - U256::ONE); + + let signed = CmpOperands { opcode: opcode::SLT, ..unsigned }; + assert_eq!(operand_delta(&signed), U256::from(2)); + } + + #[test] + fn signed_operand_delta_handles_full_int256_range() { + let min = I256::MIN.into_raw(); + let max = I256::MAX.into_raw(); + + assert_eq!(signed_operand_delta(min, max), U256::MAX); + } + + fn frontier(pc: usize, result: bool, operand_delta: u64) -> FuzzBranchFrontier { + frontier_at(Address::ZERO, pc, result, operand_delta) + } + + fn frontier_at( + address: Address, + pc: usize, + result: bool, + operand_delta: u64, + ) -> FuzzBranchFrontier { + let cmp = CmpOperands { op1: U256::ZERO, op2: U256::ZERO, pc, address, opcode: opcode::LT }; + FuzzBranchFrontier::new( + None, + Arc::from(Vec::::new().into_boxed_slice()), + cmp, + result, + U256::from(operand_delta), + None, + 0, + ) + } + + #[test] + fn merge_frontiers_dedupes_across_workers_keeping_smallest_delta() { + let merged = merge_frontiers( + 8, + [frontier(1, false, 30), frontier(1, false, 10), frontier(1, false, 20)], + ); + + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].operands.operand_delta, U256::from(10)); + } + + #[test] + fn frontier_keeps_shortest_stateful_sequence_for_equal_delta() { + let tx = BasicTxDetails { + warp: None, + roll: None, + sender: Address::with_last_byte(1), + call_details: CallDetails { + target: Address::with_last_byte(2), + calldata: Bytes::from_static(&[1, 2, 3, 4]), + value: None, + }, + }; + let cmp = CmpOperands { + op1: U256::ONE, + op2: U256::from(2), + pc: 1, + address: tx.call_details.target, + opcode: opcode::LT, + }; + let mut recorder = FuzzFrontierRecorder::new(1); + + recorder.capture_call(None, &[tx.clone(), tx.clone()], 1, &[cmp], None); + recorder.capture_call(None, std::slice::from_ref(&tx), 0, &[cmp], None); + + let frontier = recorder.into_frontiers().pop().unwrap(); + assert_eq!(frontier.sequence.len(), 1); + assert_eq!(frontier.call_index, 0); + } + + #[test] + fn stateful_artifact_stores_shared_sequence_once() { + let tx = BasicTxDetails { + warp: None, + roll: None, + sender: Address::with_last_byte(1), + call_details: CallDetails { + target: Address::with_last_byte(2), + calldata: Bytes::from_static(&[1, 2, 3, 4]), + value: None, + }, + }; + let first = CmpOperands { + op1: U256::ZERO, + op2: U256::ONE, + pc: 1, + address: tx.call_details.target, + opcode: opcode::LT, + }; + let second = CmpOperands { pc: 2, ..first }; + let mut recorder = FuzzFrontierRecorder::new(2); + recorder.capture_sequence(&[tx.clone(), tx], &[vec![first], vec![second]]); + let func: Function = serde_json::from_str( + r#"{"type":"function","name":"invariant_ok","inputs":[],"outputs":[],"stateMutability":"pure"}"#, + ) + .unwrap(); + + let artifact = StatefulFuzzBranchFrontierArtifact::new(&func, 2, recorder.into_frontiers()); + + assert_eq!(artifact.sequences.len(), 1); + assert_eq!(artifact.sequences[0].len, 2); + assert_eq!(artifact.frontiers.len(), 2); + assert_eq!(artifact.frontiers[0].sequence_index, 0); + assert_eq!(artifact.frontiers[1].sequence_index, 0); + assert_eq!(artifact.frontiers[0].call_index, 0); + assert_eq!(artifact.frontiers[1].call_index, 1); + } + + #[test] + fn merge_frontiers_keeps_records_with_distinct_result_keys() { + // Same site but different comparison result is a distinct key. + let merged = merge_frontiers(8, [frontier(1, false, 5), frontier(1, true, 5)]); + + assert_eq!(merged.len(), 2); + } + + #[test] + fn merge_frontiers_replaces_retained_key_after_limit_is_full() { + // The first two unique keys fill the limit; a later new key is dropped, but a later + // smaller-delta duplicate of an already retained key still replaces it. + let merged = merge_frontiers( + 2, + [ + frontier(1, false, 30), + frontier(2, false, 30), + frontier(3, false, 5), + frontier(1, false, 10), + ], + ); + + assert_eq!(merged.len(), 2); + let retained = merged.iter().find(|f| f.site.pc == 1).unwrap(); + assert_eq!(retained.operands.operand_delta, U256::from(10)); + assert!(merged.iter().all(|f| f.site.pc != 3)); + } + + #[test] + fn merge_frontiers_does_not_spend_limit_on_duplicates() { + // A duplicate of a retained key must not count against the limit, so a later distinct key + // still fits. This guards against counting processed records instead of unique keys. + let merged = merge_frontiers( + 2, + [frontier(1, false, 30), frontier(1, false, 10), frontier(2, false, 5)], + ); + + assert_eq!(merged.len(), 2); + assert_eq!( + merged.iter().find(|f| f.site.pc == 1).unwrap().operands.operand_delta, + U256::from(10) + ); + assert!(merged.iter().any(|f| f.site.pc == 2)); + } + + #[test] + fn merge_frontiers_distinguishes_by_address() { + // Same pc/opcode/result at different addresses are distinct sites. + let other = Address::with_last_byte(1); + let merged = merge_frontiers( + 8, + [frontier_at(Address::ZERO, 1, false, 5), frontier_at(other, 1, false, 5)], + ); + + assert_eq!(merged.len(), 2); + } + + #[test] + fn merge_frontiers_with_zero_limit_is_empty() { + assert!(merge_frontiers(0, [frontier(1, false, 1)]).is_empty()); + } +} diff --git a/crates/evm/evm/src/executors/fuzz/mod.rs b/crates/evm/evm/src/executors/fuzz/mod.rs index cdd638ff4d183..4e780b04231e0 100644 --- a/crates/evm/evm/src/executors/fuzz/mod.rs +++ b/crates/evm/evm/src/executors/fuzz/mod.rs @@ -1,48 +1,207 @@ -use crate::executors::{Executor, FuzzTestTimer, RawCallResult}; +use crate::executors::{ + DURATION_BETWEEN_METRICS_REPORT, EarlyExit, Executor, FuzzTestTimer, RawCallResult, + campaign::{CampaignCallKind, CampaignControl, CampaignEvent, FuzzCampaign, FuzzCampaignMode}, + corpus::{ + CorpusSyncCoordinator, GlobalCorpusMetrics, ReplayTarget, StatelessReplayTarget, + WorkerCorpus, + }, + should_ignore_revert, +}; use alloy_dyn_abi::JsonAbiExt; use alloy_json_abi::Function; -use alloy_primitives::{Address, Bytes, Log, U256, map::HashMap}; +use alloy_primitives::{ + Address, Bytes, Log, U256, keccak256, + map::{AddressHashMap, HashMap}, +}; use eyre::Result; -use foundry_common::evm::Breakpoints; +use foundry_common::sh_println; use foundry_config::FuzzConfig; use foundry_evm_core::{ - constants::{CHEATCODE_ADDRESS, MAGIC_ASSUME, TEST_TIMEOUT}, + Breakpoints, + constants::MAGIC_ASSUME, decode::{RevertDecoder, SkipReason}, + evm::FoundryEvmNetwork, }; use foundry_evm_coverage::HitMaps; use foundry_evm_fuzz::{ - BaseCounterExample, CounterExample, FuzzCase, FuzzError, FuzzFixtures, FuzzTestResult, - strategies::{EvmFuzzState, fuzz_calldata, fuzz_calldata_from_state}, + BaseCounterExample, BasicTxDetails, CallDetails, CounterExample, FuzzCase, FuzzError, + FuzzFixtures, FuzzRunMetadata, FuzzTestResult, + strategies::{EvmFuzzState, TxGenerator}, }; use foundry_evm_traces::SparsedTraceArena; use indicatif::ProgressBar; -use proptest::test_runner::{TestCaseError, TestError, TestRunner}; -use std::{cell::RefCell, collections::BTreeMap}; +use proptest::test_runner::{RngAlgorithm, TestCaseError, TestRng, TestRunner}; +use rayon::iter::{IntoParallelIterator, ParallelIterator}; +use serde_json::json; +use std::{ + path::PathBuf, + sync::{ + Arc, OnceLock, + atomic::{AtomicBool, AtomicU32, Ordering}, + }, + time::{Instant, SystemTime, UNIX_EPOCH}, +}; +mod frontier; mod types; +use frontier::FuzzBranchFrontierArtifact; +pub(super) use frontier::{ + FuzzBranchFrontier, FuzzFrontierRecorder, StatefulFuzzBranchFrontierArtifact, merge_frontiers, + write_frontier_artifact, +}; pub use types::{CaseOutcome, CounterExampleOutcome, FuzzOutcome}; -/// Contains data collected during fuzz test runs. -#[derive(Default)] -pub struct FuzzTestData { - // Stores the first fuzz case. - pub first_case: Option, - // Stored gas usage per fuzz case. - pub gas_by_case: Vec<(u64, u64)>, - // Stores the result and calldata of the last failed call, if any. - pub counterexample: (Bytes, RawCallResult), - // Stores up to `max_traces_to_collect` traces. - pub traces: Vec, - // Stores breakpoints for the last fuzz case. - pub breakpoints: Option, - // Stores coverage information for all fuzz cases. - pub coverage: Option, - // Stores logs for all fuzz cases - pub logs: Vec, - // Stores gas snapshots for all fuzz cases - pub gas_snapshots: BTreeMap>, - // Deprecated cheatcodes mapped to their replacements. - pub deprecated_cheatcodes: HashMap<&'static str, Option<&'static str>>, +/// Corpus syncs across workers every `SYNC_INTERVAL` runs. +const SYNC_INTERVAL: u32 = 1000; + +/// Minimum number of runs per worker. +/// This is mainly to reduce the overall number of rayon jobs. +const MIN_RUNS_PER_WORKER: u32 = 64; + +struct WorkerState { + /// Worker identifier + id: usize, + /// First fuzz case this worker encountered (with global run number) + first_case: Option<(u32, FuzzCase)>, + /// Gas usage for all cases this worker ran + gas_by_case: Vec<(u64, u64)>, + /// Counterexample if this worker found one + counterexample: Option<(BasicTxDetails, RawCallResult)>, + /// Traces collected by this worker + /// + /// Stores up to `max_traces_to_collect` which is `config.gas_report_samples / num_workers` + traces: Vec, + /// Runtime bytecodes for the last collected trace. + debug_bytecodes: AddressHashMap, + /// Last breakpoints from this worker + breakpoints: Option, + /// Coverage collected by this worker + coverage: Option, + /// Logs from all cases this worker ran + logs: Vec, + /// Deprecated cheatcodes seen by this worker + deprecated_cheatcodes: HashMap<&'static str, Option<&'static str>>, + /// Number of runs this worker completed + runs: u32, + /// Failure reason if this worker failed + failure: Option, + /// Fuzz run metadata that produced the failure. + failure_run: Option, + /// Last run timestamp in milliseconds + /// + /// Used to identify which worker ran last and collect its traces and call breakpoints + last_run_timestamp: u128, + /// Failed corpus replays + failed_corpus_replays: usize, + /// Branch frontiers captured for symbolic follow-up. + frontiers: Vec, +} + +impl WorkerState { + fn new(worker_id: usize) -> Self { + Self { + id: worker_id, + first_case: None, + gas_by_case: Vec::new(), + counterexample: None, + traces: Vec::new(), + debug_bytecodes: HashMap::default(), + breakpoints: None, + coverage: None, + logs: Vec::new(), + deprecated_cheatcodes: HashMap::default(), + runs: 0, + failure: None, + failure_run: None, + last_run_timestamp: 0, + failed_corpus_replays: 0, + frontiers: Vec::new(), + } + } +} + +/// Shared state for coordinating parallel fuzz workers +struct SharedFuzzState { + state: EvmFuzzState, + /// Total runs across workers + total_runs: Arc, + /// Found failure + /// + /// The worker that found the failure sets it's ID. + /// + /// This ID is then used to correctly extract the failure reason and counterexample. + failed_worker_id: OnceLock, + /// Total rejects across workers + total_rejects: Arc, + /// Fuzz timer + timer: FuzzTestTimer, + /// Whether a worker stopped because the timer expired. + timed_out: AtomicBool, + /// Global corpus metrics + global_corpus_metrics: GlobalCorpusMetrics, + + /// Global test suite early exit. + global_early_exit: EarlyExit, + /// Local fuzz early exit. + local_early_exit: EarlyExit, +} + +impl SharedFuzzState { + fn new(state: EvmFuzzState, timeout: Option, early_exit: EarlyExit) -> Self { + Self { + state, + total_runs: Arc::new(AtomicU32::new(0)), + failed_worker_id: OnceLock::new(), + total_rejects: Arc::new(AtomicU32::new(0)), + timer: FuzzTestTimer::new(timeout), + timed_out: AtomicBool::new(false), + global_corpus_metrics: GlobalCorpusMetrics::default(), + global_early_exit: early_exit, + local_early_exit: EarlyExit::new(true), + } + } + + /// Increments the number of runs and returns the new value. + fn increment_runs(&self) -> u32 { + self.total_runs.fetch_add(1, Ordering::Relaxed) + 1 + } + + /// Increments and returns the new value of the number of rejected tests. + fn increment_rejects(&self) -> u32 { + self.total_rejects.fetch_add(1, Ordering::Relaxed) + 1 + } + + /// Returns `true` if the worker should continue running. + fn should_continue(&self) -> bool { + if self.global_early_exit.should_stop() || self.local_early_exit.should_stop() { + return false; + } + if self.timer.is_timed_out() { + self.timed_out.store(true, Ordering::Relaxed); + return false; + } + true + } + + /// Returns `true` if fail-fast or Ctrl-C stopped the campaign before its timeout or + /// `planned_runs`. + fn interrupted(&self, planned_runs: u32) -> bool { + self.global_early_exit.should_stop() + && !self.timed_out.load(Ordering::Relaxed) + && self.total_runs.load(Ordering::Relaxed) < planned_runs + } + + /// Returns true if the worker was able to claim the failure, false if failure was set by + /// another worker + fn try_claim_failure(&self, worker_id: usize) -> bool { + let mut claimed = false; + let _ = self.failed_worker_id.get_or_init(|| { + claimed = true; + self.local_early_exit.record_failure(); + worker_id + }); + claimed + } } /// Wrapper around an [`Executor`] which provides fuzzing support using [`proptest`]. @@ -50,173 +209,448 @@ pub struct FuzzTestData { /// After instantiation, calling `fuzz` will proceed to hammer the deployed smart contract with /// inputs, until it finds a counterexample. The provided [`TestRunner`] contains all the /// configuration which can be overridden via [environment variables](proptest::test_runner::Config) -pub struct FuzzedExecutor { - /// The EVM executor - executor: Executor, +pub struct FuzzedExecutor { + /// The EVM executor. + executor_f: Executor, /// The fuzzer runner: TestRunner, - /// The account that calls tests + /// The account that calls tests. sender: Address, - /// The fuzz configuration + /// The fuzz configuration. config: FuzzConfig, + /// The persisted counterexample to be replayed, if any. + persisted_failure: Option, + /// An existing corpus to replay before persisting into the configured corpus directory. + corpus_replay_dir: Option, + /// The number of parallel workers. + num_workers: usize, } -impl FuzzedExecutor { +impl FuzzedExecutor { /// Instantiates a fuzzed executor given a testrunner pub fn new( - executor: Executor, + executor: Executor, runner: TestRunner, sender: Address, config: FuzzConfig, + persisted_failure: Option, + corpus_replay_dir: Option, ) -> Self { - Self { executor, runner, sender, config } + let run_limit = if config.run.is_some() { 1 } else { config.runs }; + let max_workers = if run_limit == 0 { + 0 + } else if config.run.is_some() { + 1 + } else { + Ord::max(1, run_limit / MIN_RUNS_PER_WORKER) + }; + let num_workers = Ord::min(rayon::current_num_threads(), max_workers as usize); + Self { + executor_f: executor, + runner, + sender, + config, + persisted_failure, + corpus_replay_dir, + num_workers, + } } /// Fuzzes the provided function, assuming it is available at the contract at `address` - /// If `should_fail` is set to `true`, then it will stop only when there's a success - /// test case. /// - /// Returns a list of all the consumed gas and calldata of every fuzz case + /// Returns a list of all the consumed gas and calldata of every fuzz case. + #[allow(clippy::too_many_arguments)] pub fn fuzz( - &self, + &mut self, func: &Function, fuzz_fixtures: &FuzzFixtures, - deployed_libs: &[Address], + state: EvmFuzzState, address: Address, rd: &RevertDecoder, progress: Option<&ProgressBar>, - ) -> FuzzTestResult { - // Stores the fuzz test execution data. - let execution_data = RefCell::new(FuzzTestData::default()); - let state = self.build_fuzz_state(deployed_libs); - let dictionary_weight = self.config.dictionary.dictionary_weight.min(100); - let strategy = proptest::prop_oneof![ - 100 - dictionary_weight => fuzz_calldata(func.clone(), fuzz_fixtures), - dictionary_weight => fuzz_calldata_from_state(func.clone(), &state), - ]; - // We want to collect at least one trace which will be displayed to user. - let max_traces_to_collect = std::cmp::max(1, self.config.gas_report_samples) as usize; - let show_logs = self.config.show_logs; + early_exit: &EarlyExit, + tokio_handle: &tokio::runtime::Handle, + ) -> Result { + let shared_state = SharedFuzzState::new(state, self.config.timeout, early_exit.clone()); - // Start timer for this fuzz test. - let timer = FuzzTestTimer::new(self.config.timeout); + let worker_ids = self.worker_ids(); + debug!(n = worker_ids.len(), "spawning workers"); + let final_sync = (worker_ids.len() > 1 && self.config.corpus.corpus_dir.is_some()) + .then(|| Arc::new(CorpusSyncCoordinator::new(worker_ids.len()))); + let workers = worker_ids + .into_par_iter() + .map(|worker_id| { + let _guard = tokio_handle.enter(); + let _guard = info_span!("fuzz_worker", id = worker_id).entered(); + let timer = Instant::now(); + let r = self.run_worker( + worker_id, + func, + fuzz_fixtures, + address, + rd, + &shared_state, + progress, + final_sync.as_deref(), + ); + if r.is_err() + && let Some(final_sync) = &final_sync + { + final_sync.abort(); + } + debug!("finished in {:?}", timer.elapsed()); + r + }) + .collect::>>()?; - let run_result = self.runner.clone().run(&strategy, |calldata| { - // Check if the timeout has been reached. - if timer.is_timed_out() { - return Err(TestCaseError::fail(TEST_TIMEOUT)); - } + Ok(self.aggregate_results(workers, func, &shared_state)) + } - let fuzz_res = self.single_fuzz(address, calldata)?; + /// Replays the persisted single-call counterexample exactly once. + /// + /// Unlike [`Self::fuzz`], this never falls through to generated inputs if the persisted + /// calldata now succeeds or is rejected by `vm.assume`. + pub fn replay_persisted_failure( + &mut self, + func: &Function, + address: Address, + rd: &RevertDecoder, + ) -> Result { + let Some(failure) = self.persisted_failure.as_ref() else { + return Ok(FuzzTestResult { + skipped: true, + reason: Some("no persisted fuzz failure to replay".to_string()), + ..Default::default() + }); + }; - // If running with progress then increment current run. - if let Some(progress) = progress { - progress.inc(1); - }; + let seed = failure.fuzz.seed.or(self.config.seed); + if let Some(cheats) = self.executor_f.inspector_mut().cheatcodes.as_mut() + && let Some(seed) = seed + { + let run = failure.fuzz.run.unwrap_or(1); + let worker = failure.fuzz.worker.unwrap_or(0) as usize; + cheats.set_seed(Self::fuzz_run_seed(seed, worker, run)); + } - match fuzz_res { - FuzzOutcome::Case(case) => { - let mut data = execution_data.borrow_mut(); - data.gas_by_case.push((case.case.gas, case.case.stipend)); + let mut tx = BasicTxDetails { + warp: None, + roll: None, + sender: self.sender, + call_details: CallDetails { + target: address, + calldata: failure.calldata.clone(), + value: failure.value, + }, + }; + self.resolve_stateless_tx_with_executor(&self.executor_f, &mut tx)?; + let mut call = self.executor_f.call_raw( + tx.sender, + tx.call_details.target, + tx.call_details.calldata.clone(), + tx.call_details.value.unwrap_or_default(), + )?; + if call.result.as_ref() == MAGIC_ASSUME { + return Ok(FuzzTestResult { + skipped: true, + reason: Some("persisted fuzz failure rejected by `vm.assume`".to_string()), + ..Default::default() + }); + } + if let Some(reason) = call.skip_reason() { + return Ok(FuzzTestResult { skipped: true, reason: reason.0, ..Default::default() }); + } - if data.first_case.is_none() { - data.first_case.replace(case.case); - } + let (breakpoints, deprecated_cheatcodes) = + call.cheatcodes.as_ref().map_or_else(Default::default, |cheats| { + (cheats.breakpoints.clone(), cheats.deprecated.clone()) + }); + let success = should_ignore_revert( + self.config.fail_on_revert, + address, + call.reverter, + self.executor_f.inspector().extra_cheatcode_addresses(), + ) || self.executor_f.is_raw_call_mut_success(address, &mut call); - if let Some(call_traces) = case.traces { - if data.traces.len() == max_traces_to_collect { - data.traces.pop(); - } - data.traces.push(call_traces); - data.breakpoints.replace(case.breakpoints); - } + let mut result = FuzzTestResult { + success, + labels: call.labels.clone(), + traces: call.traces.clone(), + debug_bytecodes: call.debug_bytecodes.clone(), + breakpoints: Some(breakpoints), + deprecated_cheatcodes, + fork_block_number: call.fork_block_number, + ..Default::default() + }; - if show_logs { - data.logs.extend(case.logs); + if success { + result.first_case = FuzzCase { gas: call.gas_used, stipend: call.stipend }; + result.gas_by_case.push((call.gas_used, call.stipend)); + result.line_coverage = call.line_coverage; + result.logs = call.logs; + result.gas_report_traces.extend(call.traces.into_iter().map(|trace| trace.arena)); + } else { + let reason = call + .skip_reason() + .map(|reason| reason.to_string()) + .or_else(|| rd.maybe_decode(&call.result, call.exit_reason)); + result.reason = reason; + let args = tx + .call_details + .calldata + .get(4..) + .map_or_else(Vec::new, |data| func.abi_decode_input(data).unwrap_or_default()); + result.counterexample = Some(CounterExample::Single( + BaseCounterExample::from_fuzz_tx(&tx, args, call.traces).with_fuzz_metadata( + FuzzRunMetadata::new( + seed, + failure.fuzz.run, + Some(failure.fuzz.worker.unwrap_or(0)), + ), + ), + )); + result.logs = call.logs; + } + + Ok(result) + } + + /// Granular and single-step function that runs only one fuzz and returns either a `CaseOutcome` + /// or a `CounterExampleOutcome` + fn single_fuzz( + &self, + executor: &mut Executor, + address: Address, + mut tx: BasicTxDetails, + coverage_metrics: &mut WorkerCorpus, + frontier_recorder: &mut FuzzFrontierRecorder, + fuzz_run: Option<&FuzzRunMetadata>, + ) -> Result, TestCaseError> { + tx.sender = self.sender; + tx.call_details.target = address; + tx.warp = None; + tx.roll = None; + self.resolve_stateless_tx_with_executor(executor, &mut tx) + .map_err(|e| TestCaseError::fail(e.to_string()))?; + let campaign = FuzzCampaign::new(FuzzCampaignMode::Stateless); + let mut state = (executor, tx); + let mut cmp_values = Vec::new(); + let mut checked = None; + campaign + .run_sequence( + &mut state, + 1, + |state| (state.0, &mut state.1), + |_| 0, + |_| false, + |_, event| { + match event { + CampaignEvent::Feedback(call) => { + cmp_values = call.evm_cmp_values.take().unwrap_or_default(); + } + CampaignEvent::Check { result, kind, .. } => { + checked = Some(( + result.take().expect("campaign check result is available"), + kind, + )); + return Ok(CampaignControl::Stop); + } + CampaignEvent::Advance | CampaignEvent::Next { .. } => { + unreachable!("depth-one campaigns cannot advance") + } + CampaignEvent::PostCheck => {} } + Ok(CampaignControl::Continue) + }, + ) + .map_err(|e| TestCaseError::fail(e.to_string()))?; + let (mut call, kind) = checked.expect("depth-one campaign emits a check event"); + let tx = state.1.clone(); - HitMaps::merge_opt(&mut data.coverage, case.coverage); + // Handle `vm.assume` before recording coverage or persisting the input. + if kind == CampaignCallKind::AssumptionRejected { + // Account for the attempted corpus mutation without retaining or crediting the input. + coverage_metrics.process_inputs(&[], &[], false, None); + return Err(TestCaseError::reject(FuzzError::AssumeReject)); + } - data.deprecated_cheatcodes = case.deprecated_cheatcodes; + if call.skip_reason().is_some() { + // Account for the attempted corpus mutation without retaining or crediting the input. + coverage_metrics.process_inputs(&[], &[], false, None); + } else { + let new_coverage = coverage_metrics.merge_edge_coverage(&mut call); + // `new_coverage` is only meaningful when edge coverage is collected; otherwise + // `merge_edge_coverage` always returns `false`, so record it as unknown for frontiers. + let frontier_new_coverage = + self.config.corpus.collect_edge_coverage().then_some(new_coverage); + frontier_recorder.capture_call( + fuzz_run, + std::slice::from_ref(&tx), + 0, + &cmp_values, + frontier_new_coverage, + ); + coverage_metrics.process_inputs( + std::slice::from_ref(&tx), + &[cmp_values], + new_coverage, + None, + ); + } - Ok(()) - } - FuzzOutcome::CounterExample(CounterExampleOutcome { - exit_reason: status, - counterexample: outcome, - .. - }) => { - // We cannot use the calldata returned by the test runner in `TestError::Fail`, - // since that input represents the last run case, which may not correspond with - // our failure - when a fuzz case fails, proptest will try to run at least one - // more case to find a minimal failure case. - let reason = rd.maybe_decode(&outcome.1.result, status); - execution_data.borrow_mut().logs.extend(outcome.1.logs.clone()); - execution_data.borrow_mut().counterexample = outcome; - // HACK: we have to use an empty string here to denote `None`. - Err(TestCaseError::fail(reason.unwrap_or_default())) - } - } - }); + let (breakpoints, deprecated_cheatcodes) = + call.cheatcodes.as_ref().map_or_else(Default::default, |cheats| { + (cheats.breakpoints.clone(), cheats.deprecated.clone()) + }); - let fuzz_result = execution_data.into_inner(); - let (calldata, call) = fuzz_result.counterexample; + // Consider call success if test should not fail on reverts and reverter is not the test + // address or one of the network's cheatcode contracts. + let success = should_ignore_revert( + self.config.fail_on_revert, + address, + call.reverter, + state.0.inspector().extra_cheatcode_addresses(), + ) || state.0.is_raw_call_mut_success(address, &mut call); - let mut traces = fuzz_result.traces; - let (last_run_traces, last_run_breakpoints) = if run_result.is_ok() { - (traces.pop(), fuzz_result.breakpoints) + if success { + Ok(FuzzOutcome::Case(CaseOutcome { + case: FuzzCase { gas: call.gas_used, stipend: call.stipend }, + traces: call.traces, + debug_bytecodes: call.debug_bytecodes, + coverage: call.line_coverage, + breakpoints, + logs: call.logs, + deprecated_cheatcodes, + })) } else { - (call.traces.clone(), call.cheatcodes.map(|c| c.breakpoints)) - }; + Ok(FuzzOutcome::CounterExample(CounterExampleOutcome { + exit_reason: call.exit_reason, + counterexample: (tx, call), + breakpoints, + })) + } + } - let mut result = FuzzTestResult { - first_case: fuzz_result.first_case.unwrap_or_default(), - gas_by_case: fuzz_result.gas_by_case, - success: run_result.is_ok(), - skipped: false, - reason: None, - counterexample: None, - logs: fuzz_result.logs, - labeled_addresses: call.labels, - traces: last_run_traces, - breakpoints: last_run_breakpoints, - gas_report_traces: traces.into_iter().map(|a| a.arena).collect(), - line_coverage: fuzz_result.coverage, - deprecated_cheatcodes: fuzz_result.deprecated_cheatcodes, + fn resolve_stateless_tx_with_executor( + &self, + executor: &Executor, + tx: &mut BasicTxDetails, + ) -> Result<()> { + tx.call_details.value = match tx.call_details.value { + Some(requested) if !requested.is_zero() => { + let value = requested.min(executor.get_balance(tx.sender)?); + (!value.is_zero()).then_some(value) + } + _ => None, }; + Ok(()) + } - match run_result { - Ok(()) => {} - Err(TestError::Abort(reason)) => { - let msg = reason.message(); - // Currently the only operation that can trigger proptest global rejects is the - // `vm.assume` cheatcode, thus we surface this info to the user when the fuzz test - // aborts due to too many global rejects, making the error message more actionable. - result.reason = if msg == "Too many global rejects" { - let error = FuzzError::TooManyRejects(self.runner.config().max_global_rejects); - Some(error.to_string()) - } else { - Some(msg.to_string()) - }; + /// Aggregates the results from all workers + fn aggregate_results( + &self, + mut workers: Vec>, + func: &Function, + shared_state: &SharedFuzzState, + ) -> FuzzTestResult { + self.write_branch_frontiers(&mut workers, func); + + let mut result = FuzzTestResult::default(); + if workers.is_empty() { + result.success = true; + return result; + } + + // Find first case and last run worker. Set `failed_corpus_replays`. + let mut first_case_candidate = None; + let mut last_run_worker = None; + for (i, worker) in workers.iter().enumerate() { + if let Some((run, ref case)) = worker.first_case + && first_case_candidate.as_ref().is_none_or(|&(r, _)| run < r) + { + first_case_candidate = Some((run, case.clone())); + } + + if last_run_worker.is_none_or(|(t, _)| worker.last_run_timestamp > t) { + last_run_worker = Some((worker.last_run_timestamp, i)); + } + + // Only set replays from master which is responsible for replaying persisted corpus. + if worker.id == 0 { + result.failed_corpus_replays = worker.failed_corpus_replays; } - Err(TestError::Fail(reason, _)) => { - let reason = reason.to_string(); - if reason == TEST_TIMEOUT { - // If the reason is a timeout, we consider the fuzz test successful. - result.success = true; - } else { + } + result.first_case = first_case_candidate.map(|(_, case)| case).unwrap_or_default(); + let (_, last_run_worker_idx) = last_run_worker.expect("at least one worker"); + let mut output_worker_idx = last_run_worker_idx; + + if let Some(&failed_worker_id) = shared_state.failed_worker_id.get() { + result.success = false; + + let failed_worker_idx = workers.iter().position(|w| w.id == failed_worker_id).unwrap(); + output_worker_idx = failed_worker_idx; + let failed_worker = &mut workers[failed_worker_idx]; + + let counterexample = failed_worker.counterexample.take(); + if let Some((_, call)) = &counterexample { + result.labels.clone_from(&call.labels); + result.traces.clone_from(&call.traces); + result.debug_bytecodes.clone_from(&call.debug_bytecodes); + result.breakpoints = call.cheatcodes.as_ref().map(|c| c.breakpoints.clone()); + result.fork_block_number = call.fork_block_number; + } + + match &failed_worker.failure { + Some(TestCaseError::Fail(reason)) => { + let reason = reason.to_string(); result.reason = (!reason.is_empty()).then_some(reason); - let args = if let Some(data) = calldata.get(4..) { - func.abi_decode_input(data).unwrap_or_default() - } else { - vec![] - }; - - result.counterexample = Some(CounterExample::Single( - BaseCounterExample::from_fuzz_call(calldata, args, call.traces), - )); + if let Some((tx, call)) = counterexample { + let args = + tx.call_details.calldata.get(4..).map_or_else(Vec::new, |data| { + func.abi_decode_input(data).unwrap_or_default() + }); + let fuzz = failed_worker.failure_run.unwrap_or_default(); + result.counterexample = Some(CounterExample::Single( + BaseCounterExample::from_fuzz_tx(&tx, args, call.traces) + .with_fuzz_metadata(FuzzRunMetadata::new( + fuzz.seed.or(self.config.seed), + fuzz.run, + fuzz.worker, + )), + )); + } } + Some(TestCaseError::Reject(reason)) => { + let reason = reason.to_string(); + result.reason = (!reason.is_empty()).then_some(reason); + } + None => {} + } + } else { + let last_run_worker = &workers[last_run_worker_idx]; + result.success = true; + let planned_runs = if self.config.run.is_some() { 1 } else { self.config.runs }; + if shared_state.interrupted(planned_runs) { + result.skipped = true; + result.reason = Some("interrupted".to_string()); + } + result.traces = last_run_worker.traces.last().cloned(); + result.debug_bytecodes.clone_from(&last_run_worker.debug_bytecodes); + result.breakpoints = last_run_worker.breakpoints.clone(); + } + + if !self.config.show_logs { + result.logs = workers[output_worker_idx].logs.clone(); + } + + for mut worker in workers { + result.gas_by_case.append(&mut worker.gas_by_case); + if self.config.show_logs { + result.logs.append(&mut worker.logs); } + result.gas_report_traces.extend(worker.traces.into_iter().map(|t| t.arena)); + HitMaps::merge_opt(&mut result.line_coverage, worker.coverage); + result.deprecated_cheatcodes.extend(worker.deprecated_cheatcodes); } if let Some(reason) = &result.reason @@ -226,73 +660,401 @@ impl FuzzedExecutor { result.reason = reason.0; } - state.log_stats(); - result } - /// Granular and single-step function that runs only one fuzz and returns either a `CaseOutcome` - /// or a `CounterExampleOutcome` - pub fn single_fuzz( + fn write_branch_frontiers(&self, workers: &mut [WorkerState], func: &Function) { + let Some(frontier_dir) = &self.config.corpus.frontier_dir else { + return; + }; + let limit = self.config.corpus.frontier_limit; + if limit == 0 { + return; + } + + let frontiers = frontier::merge_frontiers( + limit, + workers.iter_mut().flat_map(|worker| worker.frontiers.drain(..)), + ); + if frontiers.is_empty() { + return; + } + + let artifact = FuzzBranchFrontierArtifact::new(func, limit, frontiers); + if let Err(err) = frontier::write_frontier_artifact(frontier_dir, &artifact) { + warn!(%err, path = ?frontier_dir, "failed to write fuzz branch frontier artifact"); + } + } + + /// Runs a single fuzz worker + #[allow(clippy::too_many_arguments)] + fn run_worker( &self, + worker_id: usize, + func: &Function, + fuzz_fixtures: &FuzzFixtures, address: Address, - calldata: alloy_primitives::Bytes, - ) -> Result { - let mut call = self - .executor - .call_raw(self.sender, address, calldata.clone(), U256::ZERO) - .map_err(|e| TestCaseError::fail(e.to_string()))?; + rd: &RevertDecoder, + shared_state: &SharedFuzzState, + progress: Option<&ProgressBar>, + final_sync: Option<&CorpusSyncCoordinator>, + ) -> Result> { + // Prepare + let fuzz_seed = shared_state.state.fork(); + let generator = TxGenerator::stateless( + fuzz_seed.clone(), + fuzz_fixtures.clone(), + address, + self.sender, + func.clone(), + self.config.dictionary.dictionary_weight, + self.config.corpus.payable_value_weight, + ); + let fuzz_state = fuzz_seed.stateless_worker(); + let generator = foundry_evm_fuzz::sequence::SequenceGenerator::stateless_with_fixtures( + generator, + fuzz_state.clone(), + fuzz_fixtures.clone(), + func.clone(), + &self.config.corpus, + )?; - // Handle `vm.assume`. - if call.result.as_ref() == MAGIC_ASSUME { - return Err(TestCaseError::reject(FuzzError::AssumeReject)); - } + let replay_target = ReplayTarget { + stateless: Some(StatelessReplayTarget { function: func, address }), + fuzzed_contracts: None, + dynamic: None, + senders: None, + }; + let mut corpus = WorkerCorpus::new( + worker_id, + self.config.corpus.clone(), + generator, + self.corpus_replay_dir.as_deref(), + // Master worker replays the persisted corpus using the executor + (worker_id == 0).then_some(&self.executor_f), + replay_target, + )?; + let mut executor = self.executor_f.clone(); + let frontier_limit = if self.config.corpus.capture_branch_frontiers() { + self.config.corpus.frontier_limit + } else { + 0 + }; + let mut frontier_recorder = FuzzFrontierRecorder::new(frontier_limit); - let (breakpoints, deprecated_cheatcodes) = - call.cheatcodes.as_ref().map_or_else(Default::default, |cheats| { - (cheats.breakpoints.clone(), cheats.deprecated.clone()) - }); + let mut worker = WorkerState::new(worker_id); + // We want to collect at least one trace which will be displayed to user. + let max_traces_to_collect = + std::cmp::max(1, self.config.gas_report_samples / self.num_workers as u32); - // Consider call success if test should not fail on reverts and reverter is not the - // cheatcode or test address. - let success = if !self.config.fail_on_revert - && call - .reverter - .is_some_and(|reverter| reverter != address && reverter != CHEATCODE_ADDRESS) - { - true + let worker_runs = self.runs_per_worker(worker_id); + debug!(worker_runs); + + let mut runner_config = self.runner.config().clone(); + runner_config.cases = worker_runs; + + let mut runner = if let Some(seed) = self.config.seed { + let worker_seed = Self::fuzz_worker_seed(seed, worker_id); + trace!(target: "forge::test", ?worker_seed, "deterministic seed for worker {worker_id}"); + let rng = TestRng::from_seed(RngAlgorithm::ChaCha, &worker_seed.to_be_bytes::<32>()); + TestRunner::new_with_rng(runner_config, rng) } else { - self.executor.is_raw_call_mut_success(address, &mut call, false) + TestRunner::new(runner_config) }; - if success { - Ok(FuzzOutcome::Case(CaseOutcome { - case: FuzzCase { calldata, gas: call.gas_used, stipend: call.stipend }, - traces: call.traces, - coverage: call.line_coverage, - breakpoints, - logs: call.logs, - deprecated_cheatcodes, - })) + if let Some(target_run) = self.config.run { + for _ in 1..target_run { + if let Err(err) = corpus.new_sequence(&mut runner) { + worker.failure = Some(TestCaseError::fail(format!( + "failed to generate fuzzed input in worker {}: {err}", + worker.id + ))); + shared_state.try_claim_failure(worker_id); + return Ok(worker); + } + } + } + let mut generated_inputs = 0; + + let mut persisted_failure = + self.persisted_failure.as_ref().filter(|_| worker_id == 0 && self.config.run.is_none()); + + // Offset to stagger corpus syncs across workers; so that workers don't sync at the same + // time. + let sync_offset = (worker_id as u32).saturating_mul(100); + let sync_threshold = SYNC_INTERVAL + sync_offset; + let mut runs_since_sync = sync_threshold; // Always sync at the start. + let mut last_metrics_report = Instant::now(); + // Continue while: + // 1. Global state allows (not timed out, not at global limit, no failure found) + // 2. Worker hasn't reached its specific run limit + 'stop: while shared_state.should_continue() && worker.runs < worker_runs { + // If counterexample recorded, replay it first, without incrementing runs. + let (input, fuzz_run, is_persisted_replay) = if worker_id == 0 + && let Some(failure) = persisted_failure.take() + && failure.calldata.get(..4).is_some_and(|selector| func.selector() == selector) + { + let seed = failure.fuzz.seed.or(self.config.seed); + if let Some(cheats) = executor.inspector_mut().cheatcodes.as_mut() + && let Some(seed) = seed + { + let run = failure.fuzz.run.unwrap_or(1); + let worker = failure.fuzz.worker.unwrap_or(worker_id as u32) as usize; + cheats.set_seed(Self::fuzz_run_seed(seed, worker, run)); + } + + ( + BasicTxDetails { + warp: None, + roll: None, + sender: self.sender, + call_details: CallDetails { + target: address, + calldata: failure.calldata.clone(), + value: failure.value, + }, + }, + Some(FuzzRunMetadata::new( + seed, + failure.fuzz.run, + Some(failure.fuzz.worker.unwrap_or(worker_id as u32)), + )), + true, + ) + } else { + runs_since_sync += 1; + if runs_since_sync >= sync_threshold { + let timer = Instant::now(); + corpus.sync( + self.num_workers, + &executor, + replay_target, + &shared_state.global_corpus_metrics, + )?; + trace!("finished corpus sync in {:?}", timer.elapsed()); + runs_since_sync = 0; + } + + let fuzz_run = self.config.run.unwrap_or_else(|| { + generated_inputs += 1; + generated_inputs + }); + if let Some(cheats) = executor.inspector_mut().cheatcodes.as_mut() + && let Some(seed) = self.config.seed + { + cheats.set_seed(Self::fuzz_run_seed(seed, worker_id, fuzz_run)); + } + + let input = match corpus.new_sequence(&mut runner) { + Ok(plan) => plan.into_first(), + Err(err) => { + worker.failure = Some(TestCaseError::fail(format!( + "failed to generate fuzzed input in worker {}: {err}", + worker.id + ))); + shared_state.try_claim_failure(worker_id); + break 'stop; + } + }; + + ( + input, + Some(FuzzRunMetadata::new( + self.config.seed, + Some(fuzz_run), + Some(worker_id as u32), + )), + false, + ) + }; + + let mut inc_runs = || { + let total_runs = shared_state.increment_runs(); + debug_assert!( + shared_state.timer.is_enabled() + || total_runs + <= if self.config.run.is_some() { 1 } else { self.config.runs }, + "worker runs were not distributed correctly" + ); + worker.runs += 1; + if let Some(progress) = progress { + progress.inc(1); + } + total_runs + }; + + worker.last_run_timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis(); + match self.single_fuzz( + &mut executor, + address, + input, + &mut corpus, + &mut frontier_recorder, + fuzz_run.as_ref(), + ) { + Ok(fuzz_outcome) => match fuzz_outcome { + FuzzOutcome::Case(case) => { + if is_persisted_replay { + continue 'stop; + } + let total_runs = inc_runs(); + + if worker_id == 0 && self.config.corpus.collect_edge_coverage() { + if let Some(progress) = progress { + corpus.sync_metrics(&shared_state.global_corpus_metrics); + progress + .set_message(format!("{}", shared_state.global_corpus_metrics)); + } else if last_metrics_report.elapsed() + > DURATION_BETWEEN_METRICS_REPORT + { + corpus.sync_metrics(&shared_state.global_corpus_metrics); + // Display metrics inline. + let metrics = json!({ + "timestamp": SystemTime::now() + .duration_since(UNIX_EPOCH)? + .as_secs(), + "test": func.name, + "metrics": shared_state.global_corpus_metrics.load(), + }); + let _ = sh_println!("{metrics}"); + last_metrics_report = Instant::now(); + } + } + + worker.gas_by_case.push((case.case.gas, case.case.stipend)); + + if worker.first_case.is_none() { + worker.first_case = Some((total_runs, case.case)); + } + + if let Some(call_traces) = case.traces { + if worker.traces.len() == max_traces_to_collect as usize { + worker.traces.pop(); + } + worker.traces.push(call_traces); + worker.debug_bytecodes = case.debug_bytecodes; + worker.breakpoints = Some(case.breakpoints); + } + + // Always store logs from the last run in test_data.logs for display at + // verbosity >= 2. When show_logs is true, + // accumulate all logs. When false, only keep the last run's logs. + if self.config.show_logs { + worker.logs.extend(case.logs); + } else { + worker.logs = case.logs; + } + + HitMaps::merge_opt(&mut worker.coverage, case.coverage); + worker.deprecated_cheatcodes = case.deprecated_cheatcodes; + } + FuzzOutcome::CounterExample(CounterExampleOutcome { + exit_reason: status, + counterexample: outcome, + .. + }) => { + if !is_persisted_replay { + inc_runs(); + } + worker.failure_run = fuzz_run; + + // Only classify magic skip payloads minted by the skip cheatcode. + let reason = outcome + .1 + .skip_reason() + .map(|reason| reason.to_string()) + .or_else(|| rd.maybe_decode(&outcome.1.result, status)); + if self.config.show_logs { + worker.logs.extend(outcome.1.logs.clone()); + } else { + worker.logs.clone_from(&outcome.1.logs); + } + worker.counterexample = Some(outcome); + worker.failure = Some(TestCaseError::fail(reason.unwrap_or_default())); + shared_state.try_claim_failure(worker_id); + break 'stop; + } + }, + Err(err) => match err { + TestCaseError::Fail(_) => { + worker.failure = Some(err); + shared_state.try_claim_failure(worker_id); + break 'stop; + } + TestCaseError::Reject(_) => { + let max = self.config.max_test_rejects; + + let total = shared_state.increment_rejects(); + + // Update progress bar to reflect rejected runs. + // TODO(dani): (pre-existing) conflicts with corpus metrics `set_message` + if !self.config.corpus.collect_edge_coverage() + && let Some(progress) = progress + { + progress.set_message(format!("([{total}] rejected)")); + } + + if max > 0 && total > max { + worker.failure = + Some(TestCaseError::reject(FuzzError::TooManyRejects(max))); + shared_state.try_claim_failure(worker_id); + break 'stop; + } + } + }, + } + } + + if let Some(final_sync) = final_sync { + corpus.finalize_sync(&executor, replay_target, final_sync)?; + } + + if worker_id == 0 { + worker.failed_corpus_replays = corpus.failed_replays; + } + worker.frontiers = frontier_recorder.into_frontiers(); + + // Logs stats + trace!("worker {worker_id} fuzz stats"); + fuzz_state.log_stats(); + + Ok(worker) + } + + /// Determines the number of runs per worker. + const fn runs_per_worker(&self, worker_id: usize) -> u32 { + let worker_id = worker_id as u32; + let total_runs = if self.config.run.is_some() { 1 } else { self.config.runs }; + let n = self.num_workers as u32; + let runs = total_runs / n; + let remainder = total_runs % n; + // Distribute the remainder evenly among the first `remainder` workers, + // assuming `worker_id` is in `0..n`. + if worker_id < remainder { runs + 1 } else { runs } + } + + /// Returns the worker IDs to execute. + fn worker_ids(&self) -> Vec { + if self.config.run.is_some() { + vec![self.config.worker.unwrap_or(0) as usize] } else { - Ok(FuzzOutcome::CounterExample(CounterExampleOutcome { - exit_reason: call.exit_reason, - counterexample: (calldata, call), - breakpoints, - })) + (0..self.num_workers).collect() } } - /// Stores fuzz state for use with [fuzz_calldata_from_state] - pub fn build_fuzz_state(&self, deployed_libs: &[Address]) -> EvmFuzzState { - if let Some(fork_db) = self.executor.backend().active_fork_db() { - EvmFuzzState::new(fork_db, self.config.dictionary, deployed_libs) + /// Derives the deterministic RNG seed for a fuzz worker. + fn fuzz_worker_seed(seed: U256, worker_id: usize) -> U256 { + if worker_id == 0 { + seed } else { - EvmFuzzState::new( - self.executor.backend().mem_db(), - self.config.dictionary, - deployed_libs, - ) + let worker_id = worker_id as u32; + let seed_data = [&seed.to_be_bytes::<32>()[..], &worker_id.to_be_bytes()[..]].concat(); + keccak256(seed_data).into() } } + + /// Derives the deterministic RNG seed for cheatcode randomness in a worker-local run. + fn fuzz_run_seed(seed: U256, worker_id: usize, run: u32) -> U256 { + Self::fuzz_worker_seed(seed, worker_id).wrapping_add(U256::from(run.saturating_sub(1))) + } } diff --git a/crates/evm/evm/src/executors/fuzz/types.rs b/crates/evm/evm/src/executors/fuzz/types.rs index 11f658f4bed8b..5516283fc89ba 100644 --- a/crates/evm/evm/src/executors/fuzz/types.rs +++ b/crates/evm/evm/src/executors/fuzz/types.rs @@ -1,8 +1,11 @@ use crate::executors::RawCallResult; -use alloy_primitives::{Bytes, Log, map::HashMap}; -use foundry_common::evm::Breakpoints; +use alloy_primitives::{ + Bytes, Log, + map::{AddressHashMap, HashMap}, +}; +use foundry_evm_core::{Breakpoints, evm::FoundryEvmNetwork}; use foundry_evm_coverage::HitMaps; -use foundry_evm_fuzz::FuzzCase; +use foundry_evm_fuzz::{BasicTxDetails, FuzzCase}; use foundry_evm_traces::SparsedTraceArena; use revm::interpreter::InstructionResult; @@ -13,6 +16,8 @@ pub struct CaseOutcome { pub case: FuzzCase, /// The traces of the call. pub traces: Option, + /// Runtime bytecodes for contracts seen in the trace. + pub debug_bytecodes: AddressHashMap, /// The coverage info collected during the call. pub coverage: Option, /// Breakpoints char pc map. @@ -25,9 +30,9 @@ pub struct CaseOutcome { /// Returned by a single fuzz when a counterexample has been discovered #[derive(Debug)] -pub struct CounterExampleOutcome { +pub struct CounterExampleOutcome { /// Minimal reproduction test case for failing test. - pub counterexample: (Bytes, RawCallResult), + pub counterexample: (BasicTxDetails, RawCallResult), /// The status of the call. pub exit_reason: Option, /// Breakpoints char pc map. @@ -37,7 +42,7 @@ pub struct CounterExampleOutcome { /// Outcome of a single fuzz #[derive(Debug)] #[expect(clippy::large_enum_variant)] -pub enum FuzzOutcome { +pub enum FuzzOutcome { Case(CaseOutcome), - CounterExample(CounterExampleOutcome), + CounterExample(CounterExampleOutcome), } diff --git a/crates/evm/evm/src/executors/invariant/campaign.rs b/crates/evm/evm/src/executors/invariant/campaign.rs new file mode 100644 index 0000000000000..2290a3d78863b --- /dev/null +++ b/crates/evm/evm/src/executors/invariant/campaign.rs @@ -0,0 +1,1106 @@ +use super::{ + FailureKey, InvariantFailureMetrics, InvariantFailures, InvariantFuzzError, + InvariantFuzzTestResult, InvariantMetrics, +}; +use crate::executors::{EarlyExit, EvmExecutionCancellation}; +use alloy_primitives::{Address, I256, Selector}; +use eyre::{Result, ensure}; +use foundry_evm_coverage::HitMaps; +use foundry_evm_fuzz::BasicTxDetails; +use std::{ + collections::{HashMap, HashSet}, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}, + }, + time::{Duration, Instant}, +}; + +/// Immutable plan-level description for an invariant campaign. +/// +/// This is only a planning contract for splitting one logical campaign into worker ranges. It does +/// not start workers, choose worker counts, or decide corpus/failure persistence. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct InvariantCampaignSpec { + /// Total logical runs configured for the campaign. + pub total_runs: u32, +} + +impl InvariantCampaignSpec { + pub const fn new(total_runs: u32) -> Self { + Self { total_runs } + } + + /// Partitions the logical campaign into contiguous worker run ranges. + /// + /// This only describes work assignment. It does not start worker execution and does not + /// attribute failures to worker/run origins. + pub fn worker_plans(self, workers: usize) -> Result> { + ensure!(workers > 0, "invariant campaign requires at least one worker"); + + if self.total_runs == 0 { + return Ok(vec![InvariantWorkerPlan { worker_id: 0, first_global_run: 0, runs: 0 }]); + } + + let worker_count = workers.min(self.total_runs as usize) as u32; + let base_runs = self.total_runs / worker_count; + let extra_runs = self.total_runs % worker_count; + + let mut first_global_run = 0; + let mut plans = Vec::with_capacity(worker_count as usize); + for worker_id in 0..worker_count { + let runs = base_runs + u32::from(worker_id < extra_runs); + plans.push(InvariantWorkerPlan { worker_id, first_global_run, runs }); + first_global_run += runs; + } + + debug_assert_eq!(first_global_run, self.total_runs); + Ok(plans) + } +} + +/// Static assignment of a contiguous logical run range to one worker. +/// +/// The assigned range is `[first_global_run, first_global_run + runs)`. +/// Worker `0` is the master worker for master-only artifacts such as persisted corpus replay +/// counts. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct InvariantWorkerPlan { + pub worker_id: u32, + pub first_global_run: u32, + pub runs: u32, +} + +/// Shared state used only to coordinate invariant worker execution. +pub struct InvariantCampaignState { + started_at: Instant, + timed: bool, + total_runs: AtomicU32, + total_txs: AtomicU64, + total_gas: AtomicU64, + cancellation: EvmExecutionCancellation, + last_metrics_report: Mutex, + failure_metrics: Mutex, +} + +#[derive(Default)] +struct CampaignFailureMetrics { + metrics: InvariantFailureMetrics, + handler_sites: HashSet<(Address, Selector)>, +} + +impl InvariantCampaignState { + pub fn new(early_exit: EarlyExit, timeout: Option) -> Self { + let started_at = Instant::now(); + let deadline = timeout + .map(|timeout| Duration::from_secs(timeout.into())) + .and_then(|timeout| started_at.checked_add(timeout)); + Self { + started_at, + timed: timeout.is_some(), + total_runs: AtomicU32::new(0), + total_txs: AtomicU64::new(0), + total_gas: AtomicU64::new(0), + cancellation: EvmExecutionCancellation::campaign( + early_exit, + Arc::new(AtomicBool::new(false)), + deadline, + ), + last_metrics_report: Mutex::new(started_at), + failure_metrics: Mutex::new(CampaignFailureMetrics::default()), + } + } + + pub fn increment_runs(&self) -> u32 { + self.total_runs.fetch_add(1, Ordering::Relaxed) + 1 + } + + #[cfg(test)] + pub fn total_runs(&self) -> u32 { + self.total_runs.load(Ordering::Relaxed) + } + + pub fn record_call(&self, gas_used: u64) { + self.total_txs.fetch_add(1, Ordering::Relaxed); + self.total_gas.fetch_add(gas_used, Ordering::Relaxed); + } + + pub fn throughput_totals(&self) -> (u64, u64) { + (self.total_txs.load(Ordering::Relaxed), self.total_gas.load(Ordering::Relaxed)) + } + + pub fn elapsed(&self) -> Duration { + self.started_at.elapsed() + } + + pub const fn is_timed_campaign(&self) -> bool { + self.timed + } + + pub fn should_stop(&self) -> bool { + self.cancellation.should_stop(true) + } + + pub fn request_terminal_stop(&self) { + self.cancellation.request_stop(); + } + + /// Returns whether fail-fast or Ctrl-C stopped the campaign before its timeout or + /// `planned_runs`. + /// + /// The deadline and terminal failures latch the campaign stop flag, while early exit does not, + /// so a timed campaign without a latched stop was interrupted. + pub fn interrupted(&self, runs: usize, planned_runs: u32) -> bool { + self.early_exit().should_stop() + && if self.timed { + !self.cancellation.stop_requested() + } else { + runs < planned_runs as usize + } + } + + pub fn should_emit_metrics_report(&self, interval: Duration) -> bool { + let mut last_report = + self.last_metrics_report.lock().expect("metrics report lock poisoned"); + if last_report.elapsed() <= interval { + return false; + } + + *last_report = Instant::now(); + true + } + + pub(super) fn record_invariant_failure( + &self, + invariant_name: &str, + target: &str, + reason: &str, + ) { + let mut failure_metrics = + self.failure_metrics.lock().expect("failure metrics lock poisoned"); + if !failure_metrics.metrics.unique_failures.contains(invariant_name) { + failure_metrics.metrics.record_failure(invariant_name, target, reason); + } + } + + pub(super) fn sync_handler_failures(&self, failures: &InvariantFailures) { + let mut failure_metrics = + self.failure_metrics.lock().expect("failure metrics lock poisoned"); + for (key, error) in &failures.failures { + let FailureKey::Handler(addr, selector) = key else { continue }; + if failure_metrics.handler_sites.insert((*addr, *selector)) { + let reason = error.revert_reason().unwrap_or_default(); + failure_metrics.metrics.record_handler_failure(*addr, *selector, &reason); + } + } + debug_assert_eq!( + failure_metrics.metrics.broken_handlers, + failure_metrics.handler_sites.len() + ); + } + + pub(super) fn failure_metrics(&self) -> InvariantFailureMetrics { + self.failure_metrics.lock().expect("failure metrics lock poisoned").metrics.clone() + } + + pub const fn early_exit(&self) -> &EarlyExit { + self.cancellation.early_exit_ref() + } + + pub const fn cancellation(&self) -> &EvmExecutionCancellation { + &self.cancellation + } +} + +/// Output produced by one invariant worker. +/// +/// This is a data envelope for aggregation only. It does not imply that this module executed the +/// worker, shrank failures, or wrote any persisted corpus/failure files. +#[derive(Debug)] +pub struct InvariantWorkerOutput { + pub plan: InvariantWorkerPlan, + pub result: InvariantFuzzTestResult, +} + +impl InvariantWorkerOutput { + #[cfg(test)] + pub const fn new(plan: InvariantWorkerPlan, result: InvariantFuzzTestResult) -> Self { + Self { plan, result } + } +} + +/// Merges worker outputs back into one logical invariant campaign result. +/// +/// Merge policy: +/// - outputs are folded in `first_global_run` order; +/// - predicate failures keep the first failure in logical run order; +/// - handler assertion failures keep the shorter reproducer, with equal lengths preserving the +/// earlier logical worker; +/// - optimization mode keeps the maximum value, with ties preserving the earlier logical worker; +/// - `failed_corpus_replays` is a master-worker-only value from worker `0`; +/// - run/call counts, reverts, gas traces, selector metrics, and line coverage accumulate into the +/// logical campaign result. +#[derive(Debug)] +pub struct InvariantCampaignAggregator { + spec: InvariantCampaignSpec, + outputs: Vec, +} + +impl InvariantCampaignAggregator { + pub const fn new(spec: InvariantCampaignSpec) -> Self { + Self { spec, outputs: Vec::new() } + } + + pub fn push(&mut self, output: InvariantWorkerOutput) { + self.outputs.push(output); + } + + /// Validates the collected worker ranges and folds them into one logical campaign result. + #[cfg(test)] + pub fn finish(self) -> Result { + self.finish_campaign() + } + + pub fn finish_campaign(mut self) -> Result { + ensure!(!self.outputs.is_empty(), "missing invariant worker output"); + + self.outputs.sort_by_key(|output| output.plan.first_global_run); + ensure_outputs_cover_campaign(self.spec, &self.outputs)?; + fold_outputs(self.outputs) + } + + /// Folds timeout worker outputs without requiring full logical campaign coverage. + /// + /// Timeout campaigns share a wall-clock deadline across workers. When the deadline hits, any + /// worker may have completed fewer than its assigned runs, so the original static ranges can + /// contain gaps. The merge still validates worker identity and preserves deterministic worker + /// order, but final run count is derived from the completed worker counters. + pub fn finish_partial(mut self) -> Result { + ensure!(!self.outputs.is_empty(), "missing invariant worker output"); + + self.outputs.sort_by_key(|output| output.plan.first_global_run); + ensure_worker_ids_are_valid(&self.outputs)?; + fold_outputs(self.outputs) + } +} + +fn fold_outputs(outputs: Vec) -> Result { + let workers = outputs.len(); + let mut errors = HashMap::default(); + let mut handler_errors = HashMap::default(); + let mut runs = 0; + let mut calls = 0; + let mut reverts = 0; + let mut last_run_inputs = Vec::new(); + let mut gas_report_traces = Vec::new(); + let mut line_coverage = None; + let mut metrics = HashMap::default(); + let mut failed_corpus_replays = 0; + let mut optimization_best = None; + + for InvariantWorkerOutput { plan, result } in outputs { + if plan.worker_id == 0 { + failed_corpus_replays = result.failed_corpus_replays; + } + for (invariant, error) in result.errors { + errors.entry(invariant).or_insert(error); + } + merge_handler_errors(&mut handler_errors, result.handler_errors); + runs += result.runs; + calls += result.calls; + reverts += result.reverts; + if !result.last_run_inputs.is_empty() { + last_run_inputs = result.last_run_inputs; + } + gas_report_traces.extend(result.gas_report_traces); + HitMaps::merge_opt(&mut line_coverage, result.line_coverage); + merge_metrics(&mut metrics, result.metrics); + merge_optimization( + &mut optimization_best, + result.optimization_best_value, + result.optimization_best_sequence, + ); + } + let (optimization_best_value, optimization_best_sequence) = + optimization_best.map(|(value, sequence)| (Some(value), sequence)).unwrap_or_default(); + Ok(InvariantFuzzTestResult::new( + errors, + handler_errors, + runs, + calls, + reverts, + last_run_inputs, + gas_report_traces, + line_coverage, + metrics, + failed_corpus_replays, + workers, + optimization_best_value, + optimization_best_sequence, + )) +} + +fn ensure_outputs_cover_campaign( + spec: InvariantCampaignSpec, + outputs: &[InvariantWorkerOutput], +) -> Result<()> { + ensure_worker_ids_are_valid(outputs)?; + + if spec.total_runs == 0 { + ensure!( + outputs.len() == 1 + && outputs[0].plan.first_global_run == 0 + && outputs[0].plan.runs == 0, + "invariant worker outputs do not cover the logical campaign" + ); + return Ok(()); + } + + let mut next_global_run = 0; + for output in outputs { + ensure!(output.plan.runs > 0, "invariant worker outputs do not cover the logical campaign"); + ensure!( + output.plan.first_global_run == next_global_run, + "invariant worker outputs do not cover the logical campaign" + ); + next_global_run = next_global_run + .checked_add(output.plan.runs) + .ok_or_else(|| eyre::eyre!("invariant worker output range overflows"))?; + } + + ensure!( + next_global_run == spec.total_runs, + "invariant worker outputs do not cover the logical campaign" + ); + Ok(()) +} + +fn ensure_worker_ids_are_valid(outputs: &[InvariantWorkerOutput]) -> Result<()> { + let mut seen = HashSet::with_capacity(outputs.len()); + for output in outputs { + ensure!( + seen.insert(output.plan.worker_id), + "duplicate invariant worker output for worker {}", + output.plan.worker_id + ); + } + + ensure!(seen.contains(&0), "missing invariant master worker output"); + Ok(()) +} + +/// Deduplicates handler assertion failures by site, keeping the shorter reproducer. +/// Equal-length reproducers keep the one already inserted, which is the earlier logical worker +/// because the caller folds worker outputs in `first_global_run` order. +fn merge_handler_errors( + merged: &mut HashMap<(Address, Selector), InvariantFuzzError>, + worker_errors: HashMap<(Address, Selector), InvariantFuzzError>, +) { + for (site, error) in worker_errors { + let candidate_len = handler_error_sequence_len(&error); + if merged + .get(&site) + .is_none_or(|existing| handler_error_sequence_len(existing) > candidate_len) + { + merged.insert(site, error); + } + } +} + +/// Adds worker-local selector metrics into the logical campaign totals. +fn merge_metrics( + merged: &mut HashMap, + worker_metrics: HashMap, +) { + for (selector, metrics) in worker_metrics { + let entry = merged.entry(selector).or_default(); + entry.calls += metrics.calls; + entry.reverts += metrics.reverts; + entry.discards += metrics.discards; + } +} + +/// Keeps the best optimization value, using logical run order to break ties. +fn merge_optimization( + best: &mut Option<(I256, Vec)>, + candidate_value: Option, + candidate_sequence: Vec, +) { + let Some(candidate_value) = candidate_value else { + return; + }; + + if best.as_ref().is_none_or(|(best, _)| candidate_value > *best) { + *best = Some((candidate_value, candidate_sequence)); + } +} + +fn handler_error_sequence_len(error: &InvariantFuzzError) -> usize { + error.as_handler_assertion().map_or(usize::MAX, |failure| failure.call_sequence.len()) +} + +#[cfg(test)] +mod tests { + use super::{ + super::error::{FailedInvariantCaseData, HandlerAssertionFailure}, + *, + }; + use alloy_primitives::{B256, Bytes}; + use foundry_evm_coverage::HitMap; + use foundry_evm_fuzz::CallDetails; + use foundry_evm_traces::CallTraceArena; + use proptest::test_runner::TestError; + + fn empty_result(reverts: usize, failed_corpus_replays: usize) -> InvariantFuzzTestResult { + InvariantFuzzTestResult::new( + HashMap::default(), + HashMap::default(), + 0, + 0, + reverts, + Vec::new(), + Vec::new(), + None, + HashMap::default(), + failed_corpus_replays, + 1, + None, + Vec::new(), + ) + } + + fn basic_tx(sender: u8) -> BasicTxDetails { + BasicTxDetails { + warp: None, + roll: None, + sender: Address::repeat_byte(sender), + call_details: CallDetails { + target: Address::repeat_byte(sender.wrapping_add(1)), + calldata: Bytes::from(vec![0, 0, 0, sender]), + value: None, + }, + } + } + + fn hit_maps(pc: u32, hits: u32) -> HitMaps { + let mut hit_map = HitMap::new(Bytes::from_static(&[0])); + hit_map.hits(pc, hits); + + let mut maps = HitMaps::default(); + maps.insert(B256::ZERO, hit_map); + maps + } + + /// Builds a worker-local result fixture with the fields merged by the aggregator. + fn worker_result( + reverts: usize, + last_input_sender: u8, + metric_name: &str, + metrics: InvariantMetrics, + coverage_hits: u32, + failed_corpus_replays: usize, + ) -> InvariantFuzzTestResult { + let mut result = empty_result(reverts, failed_corpus_replays); + result.runs = 1; + result.calls = metrics.calls; + result.last_run_inputs = vec![basic_tx(last_input_sender)]; + result.gas_report_traces.push(vec![CallTraceArena::default()]); + result.line_coverage = Some(hit_maps(7, coverage_hits)); + result.metrics.insert(metric_name.to_string(), metrics); + result + } + + fn sequence(len: usize, first_sender: u8) -> Vec { + (0..len).map(|idx| basic_tx(first_sender.wrapping_add(idx as u8))).collect() + } + + /// Builds a predicate failure fixture with a reproducible call sequence. + fn predicate_error(reason: &str, sequence_len: usize) -> InvariantFuzzError { + InvariantFuzzError::BrokenInvariant(FailedInvariantCaseData { + test_error: TestError::Fail(reason.to_string().into(), sequence(sequence_len, 0x80)), + return_reason: reason.to_string().into(), + revert_reason: reason.to_string(), + addr: Address::repeat_byte(0x70), + calldata: Bytes::new(), + inner_sequence: Vec::new(), + shrink_run_limit: 0, + fail_on_revert: false, + assertion_failure: false, + fork_block_number: None, + }) + } + + /// Builds a handler assertion fixture with a reproducible call sequence. + fn handler_error( + reverter: Address, + selector: Selector, + sequence_len: usize, + reason: &str, + ) -> InvariantFuzzError { + InvariantFuzzError::HandlerAssertion(HandlerAssertionFailure { + reverter, + selector, + call_sequence: sequence(sequence_len, 0x90), + original_sequence_len: sequence_len, + revert_reason: reason.to_string(), + fork_block_number: None, + edge_fingerprint: B256::ZERO, + }) + } + + fn one_worker_plan(total_runs: u32) -> InvariantWorkerPlan { + let mut plans = InvariantCampaignSpec::new(total_runs).worker_plans(1).unwrap(); + assert_eq!(plans.len(), 1); + plans.pop().unwrap() + } + + #[test] + fn worker_plans_cover_logical_campaign_with_one_worker() { + let plan = one_worker_plan(3); + + assert_eq!(plan.worker_id, 0); + assert_eq!(plan.first_global_run, 0); + assert_eq!(plan.runs, 3); + } + + #[test] + fn worker_plans_split_runs_evenly() { + let plans = InvariantCampaignSpec::new(100).worker_plans(4).unwrap(); + + assert_eq!( + plans, + vec![ + InvariantWorkerPlan { worker_id: 0, first_global_run: 0, runs: 25 }, + InvariantWorkerPlan { worker_id: 1, first_global_run: 25, runs: 25 }, + InvariantWorkerPlan { worker_id: 2, first_global_run: 50, runs: 25 }, + InvariantWorkerPlan { worker_id: 3, first_global_run: 75, runs: 25 }, + ] + ); + } + + #[test] + fn worker_plans_distribute_remainder_to_earlier_workers() { + let plans = InvariantCampaignSpec::new(10).worker_plans(3).unwrap(); + + assert_eq!( + plans, + vec![ + InvariantWorkerPlan { worker_id: 0, first_global_run: 0, runs: 4 }, + InvariantWorkerPlan { worker_id: 1, first_global_run: 4, runs: 3 }, + InvariantWorkerPlan { worker_id: 2, first_global_run: 7, runs: 3 }, + ] + ); + } + + #[test] + fn worker_plans_do_not_create_empty_workers_when_runs_are_available() { + let plans = InvariantCampaignSpec::new(2).worker_plans(8).unwrap(); + + assert_eq!( + plans, + vec![ + InvariantWorkerPlan { worker_id: 0, first_global_run: 0, runs: 1 }, + InvariantWorkerPlan { worker_id: 1, first_global_run: 1, runs: 1 }, + ] + ); + } + + #[test] + fn worker_plans_keep_zero_run_campaign_as_single_empty_plan() { + let plans = InvariantCampaignSpec::new(0).worker_plans(4).unwrap(); + + assert_eq!(plans, vec![InvariantWorkerPlan { worker_id: 0, first_global_run: 0, runs: 0 }]); + } + + #[test] + fn worker_plans_reject_zero_workers() { + let err = InvariantCampaignSpec::new(1).worker_plans(0).unwrap_err(); + assert!(err.to_string().contains("requires at least one worker")); + + let err = InvariantCampaignSpec::new(0).worker_plans(0).unwrap_err(); + assert!(err.to_string().contains("requires at least one worker")); + } + + #[test] + fn campaign_state_stops_after_terminal_request() { + let state = InvariantCampaignState::new(EarlyExit::new(false), None); + assert!(!state.should_stop()); + + state.request_terminal_stop(); + + assert!(state.should_stop()); + } + + #[test] + fn campaign_state_uses_shared_timeout_and_global_throughput() { + let state = InvariantCampaignState::new(EarlyExit::new(false), Some(0)); + std::thread::sleep(Duration::from_millis(1)); + + assert!(state.is_timed_campaign()); + assert!(state.should_stop()); + + state.record_call(20); + state.record_call(30); + assert_eq!(state.throughput_totals(), (2, 50)); + assert_eq!(state.increment_runs(), 1); + assert_eq!(state.total_runs(), 1); + } + + #[test] + fn campaign_state_reports_early_exit_before_completion_as_interrupted() { + let early_exit = EarlyExit::new(true); + let untimed = InvariantCampaignState::new(early_exit.clone(), None); + let timed = InvariantCampaignState::new(early_exit.clone(), Some(3600)); + let expired = InvariantCampaignState::new(early_exit.clone(), Some(0)); + std::thread::sleep(Duration::from_millis(1)); + assert!(expired.should_stop()); + assert!(!untimed.interrupted(4, 100)); + assert!(!timed.interrupted(4, 100)); + + early_exit.record_failure(); + assert!(untimed.interrupted(4, 100)); + assert!(!untimed.interrupted(100, 100)); + assert!(timed.interrupted(100, 100)); + assert!(!expired.interrupted(4, 100)); + } + + #[test] + fn campaign_state_deduplicates_handler_failure_events_across_workers() { + let state = InvariantCampaignState::new(EarlyExit::new(false), None); + let target = Address::repeat_byte(0x11); + let selector = Selector::from([0xde, 0xad, 0xbe, 0xef]); + let mut first_worker = InvariantFailures::new(); + first_worker.seed_handler_failure( + target, + selector, + handler_error(target, selector, 2, "assertion failed"), + ); + let mut second_worker = InvariantFailures::new(); + second_worker.seed_handler_failure( + target, + selector, + handler_error(target, selector, 1, "assertion failed"), + ); + + state.sync_handler_failures(&first_worker); + state.sync_handler_failures(&second_worker); + + assert_eq!(state.failure_metrics().broken_handlers, 1); + } + + #[test] + fn aggregator_returns_single_worker_result_without_rewriting() { + let spec = InvariantCampaignSpec::new(1); + let worker = InvariantWorkerOutput::new(one_worker_plan(1), empty_result(2, 3)); + + let mut aggregator = InvariantCampaignAggregator::new(spec); + aggregator.push(worker); + let result = aggregator.finish().unwrap(); + + assert_eq!(result.reverts, 2); + assert_eq!(result.failed_corpus_replays, 3); + } + + #[test] + fn aggregator_accepts_single_worker_output_for_zero_run_campaign() { + let spec = InvariantCampaignSpec::new(0); + let worker = InvariantWorkerOutput::new( + InvariantWorkerPlan { worker_id: 0, first_global_run: 0, runs: 0 }, + empty_result(0, 0), + ); + + let mut aggregator = InvariantCampaignAggregator::new(spec); + aggregator.push(worker); + let result = aggregator.finish().unwrap(); + + assert_eq!(result.reverts, 0); + } + + #[test] + fn aggregator_merges_multiple_worker_outputs_in_logical_run_order() { + let spec = InvariantCampaignSpec::new(3); + let plans = [ + InvariantWorkerPlan { worker_id: 0, first_global_run: 0, runs: 1 }, + InvariantWorkerPlan { worker_id: 1, first_global_run: 1, runs: 1 }, + InvariantWorkerPlan { worker_id: 2, first_global_run: 2, runs: 1 }, + ]; + + let mut aggregator = InvariantCampaignAggregator::new(spec); + aggregator.push(InvariantWorkerOutput::new( + plans[2], + worker_result( + 3, + 0x30, + "transfer(address)", + InvariantMetrics { calls: 3, reverts: 1, discards: 0 }, + 3, + 0, + ), + )); + aggregator.push(InvariantWorkerOutput::new( + plans[0], + worker_result( + 1, + 0x10, + "transfer(address)", + InvariantMetrics { calls: 1, reverts: 0, discards: 2 }, + 1, + 4, + ), + )); + aggregator.push(InvariantWorkerOutput::new( + plans[1], + worker_result( + 2, + 0x20, + "approve(address)", + InvariantMetrics { calls: 2, reverts: 1, discards: 1 }, + 2, + 0, + ), + )); + + let result = aggregator.finish().unwrap(); + + assert_eq!(result.runs, 3); + assert_eq!(result.calls, 6); + assert_eq!(result.reverts, 6); + assert_eq!(result.gas_report_traces.len(), 3); + assert_eq!(result.last_run_inputs[0].sender, Address::repeat_byte(0x30)); + + let transfer_metrics = result.metrics.get("transfer(address)").unwrap(); + assert_eq!(transfer_metrics, &InvariantMetrics { calls: 4, reverts: 1, discards: 2 }); + let approve_metrics = result.metrics.get("approve(address)").unwrap(); + assert_eq!(approve_metrics, &InvariantMetrics { calls: 2, reverts: 1, discards: 1 }); + + let coverage = result.line_coverage.unwrap(); + assert_eq!(coverage.get(&B256::ZERO).unwrap().get(7).unwrap().get(), 6); + assert_eq!(result.failed_corpus_replays, 4); + } + + #[test] + fn aggregator_preserves_run_and_call_counts() { + let spec = InvariantCampaignSpec::new(3); + let plans = [ + InvariantWorkerPlan { worker_id: 0, first_global_run: 0, runs: 1 }, + InvariantWorkerPlan { worker_id: 1, first_global_run: 1, runs: 2 }, + ]; + let mut first = empty_result(0, 0); + first.runs = 1; + first.calls = 1000; + let mut second = empty_result(0, 0); + second.runs = 2; + second.calls = 2000; + + let mut aggregator = InvariantCampaignAggregator::new(spec); + aggregator.push(InvariantWorkerOutput::new(plans[1], second)); + aggregator.push(InvariantWorkerOutput::new(plans[0], first)); + let result = aggregator.finish().unwrap(); + + assert_eq!(result.runs, 3); + assert_eq!(result.calls, 3000); + } + + #[test] + fn timeout_aggregator_accepts_partial_outputs_with_range_gaps() { + fn result_with_counts( + runs: usize, + calls: usize, + has_last_run: bool, + failed_corpus_replays: usize, + ) -> InvariantFuzzTestResult { + let mut result = empty_result(0, failed_corpus_replays); + result.runs = runs; + result.calls = calls; + result.last_run_inputs = if has_last_run { vec![basic_tx(0x44)] } else { Vec::new() }; + result + } + + let spec = InvariantCampaignSpec::new(10); + let outputs = [ + InvariantWorkerOutput::new( + InvariantWorkerPlan { worker_id: 0, first_global_run: 0, runs: 2 }, + result_with_counts(2, 20, true, 5), + ), + InvariantWorkerOutput::new( + InvariantWorkerPlan { worker_id: 1, first_global_run: 4, runs: 0 }, + result_with_counts(0, 0, false, 0), + ), + InvariantWorkerOutput::new( + InvariantWorkerPlan { worker_id: 2, first_global_run: 7, runs: 1 }, + result_with_counts(1, 10, true, 0), + ), + ]; + + let mut strict = InvariantCampaignAggregator::new(spec); + for output in outputs { + strict.push(output); + } + let err = strict.finish().unwrap_err(); + assert!(err.to_string().contains("do not cover the logical campaign")); + + let mut partial = InvariantCampaignAggregator::new(spec); + partial.push(InvariantWorkerOutput::new( + InvariantWorkerPlan { worker_id: 0, first_global_run: 0, runs: 2 }, + result_with_counts(2, 20, true, 5), + )); + partial.push(InvariantWorkerOutput::new( + InvariantWorkerPlan { worker_id: 1, first_global_run: 4, runs: 0 }, + result_with_counts(0, 0, false, 0), + )); + partial.push(InvariantWorkerOutput::new( + InvariantWorkerPlan { worker_id: 2, first_global_run: 7, runs: 1 }, + result_with_counts(1, 10, true, 0), + )); + + let result = partial.finish_partial().unwrap(); + + assert_eq!(result.runs, 3); + assert_eq!(result.calls, 30); + assert_eq!(result.failed_corpus_replays, 5); + } + + #[test] + fn aggregator_keeps_earlier_predicate_failure_for_each_invariant() { + let spec = InvariantCampaignSpec::new(2); + let plans = [ + InvariantWorkerPlan { worker_id: 0, first_global_run: 0, runs: 1 }, + InvariantWorkerPlan { worker_id: 1, first_global_run: 1, runs: 1 }, + ]; + let mut earlier = empty_result(0, 0); + earlier.errors.insert("invariant_balance".to_string(), predicate_error("earlier", 3)); + let mut later = empty_result(0, 0); + later.errors.insert("invariant_balance".to_string(), predicate_error("later", 1)); + + let mut aggregator = InvariantCampaignAggregator::new(spec); + aggregator.push(InvariantWorkerOutput::new(plans[1], later)); + aggregator.push(InvariantWorkerOutput::new(plans[0], earlier)); + let result = aggregator.finish().unwrap(); + + assert_eq!(result.errors.len(), 1); + assert_eq!(result.errors["invariant_balance"].revert_reason().as_deref(), Some("earlier")); + } + + #[test] + fn aggregator_dedupes_handler_assertions_by_site_and_keeps_shorter_sequence() { + let spec = InvariantCampaignSpec::new(2); + let plans = [ + InvariantWorkerPlan { worker_id: 0, first_global_run: 0, runs: 1 }, + InvariantWorkerPlan { worker_id: 1, first_global_run: 1, runs: 1 }, + ]; + let site = (Address::repeat_byte(0xaa), Selector::from([1, 2, 3, 4])); + let mut longer = empty_result(0, 0); + longer.handler_errors.insert(site, handler_error(site.0, site.1, 4, "longer")); + let mut shorter = empty_result(0, 0); + shorter.handler_errors.insert(site, handler_error(site.0, site.1, 2, "shorter")); + + let mut aggregator = InvariantCampaignAggregator::new(spec); + aggregator.push(InvariantWorkerOutput::new(plans[1], shorter)); + aggregator.push(InvariantWorkerOutput::new(plans[0], longer)); + let result = aggregator.finish().unwrap(); + + let failure = result.handler_errors[&site].as_handler_assertion().unwrap(); + assert_eq!(result.handler_errors.len(), 1); + assert_eq!(failure.call_sequence.len(), 2); + assert_eq!(failure.revert_reason, "shorter"); + } + + #[test] + fn aggregator_keeps_earlier_handler_assertion_when_lengths_tie() { + let spec = InvariantCampaignSpec::new(2); + let plans = [ + InvariantWorkerPlan { worker_id: 0, first_global_run: 0, runs: 1 }, + InvariantWorkerPlan { worker_id: 1, first_global_run: 1, runs: 1 }, + ]; + let site = (Address::repeat_byte(0xaa), Selector::from([1, 2, 3, 4])); + let mut earlier = empty_result(0, 0); + earlier.handler_errors.insert(site, handler_error(site.0, site.1, 2, "earlier")); + let mut later = empty_result(0, 0); + later.handler_errors.insert(site, handler_error(site.0, site.1, 2, "later")); + + let mut aggregator = InvariantCampaignAggregator::new(spec); + aggregator.push(InvariantWorkerOutput::new(plans[1], later)); + aggregator.push(InvariantWorkerOutput::new(plans[0], earlier)); + let result = aggregator.finish().unwrap(); + + let failure = result.handler_errors[&site].as_handler_assertion().unwrap(); + assert_eq!(result.handler_errors.len(), 1); + assert_eq!(failure.call_sequence.len(), 2); + assert_eq!(failure.revert_reason, "earlier"); + } + + #[test] + fn aggregator_keeps_distinct_predicate_failures() { + let spec = InvariantCampaignSpec::new(2); + let plans = [ + InvariantWorkerPlan { worker_id: 0, first_global_run: 0, runs: 1 }, + InvariantWorkerPlan { worker_id: 1, first_global_run: 1, runs: 1 }, + ]; + let mut earlier = empty_result(0, 0); + earlier.errors.insert("invariant_a".to_string(), predicate_error("a", 3)); + let mut later = empty_result(0, 0); + later.errors.insert("invariant_b".to_string(), predicate_error("b", 2)); + + let mut aggregator = InvariantCampaignAggregator::new(spec); + aggregator.push(InvariantWorkerOutput::new(plans[1], later)); + aggregator.push(InvariantWorkerOutput::new(plans[0], earlier)); + let result = aggregator.finish().unwrap(); + + assert_eq!(result.errors.len(), 2); + assert_eq!(result.errors["invariant_a"].revert_reason().as_deref(), Some("a")); + assert_eq!(result.errors["invariant_b"].revert_reason().as_deref(), Some("b")); + } + + #[test] + fn aggregator_keeps_first_max_optimization_value_on_tie() { + let spec = InvariantCampaignSpec::new(3); + let plans = [ + InvariantWorkerPlan { worker_id: 0, first_global_run: 0, runs: 1 }, + InvariantWorkerPlan { worker_id: 1, first_global_run: 1, runs: 1 }, + InvariantWorkerPlan { worker_id: 2, first_global_run: 2, runs: 1 }, + ]; + let mut first = empty_result(0, 0); + first.optimization_best_value = Some(I256::try_from(7).unwrap()); + first.optimization_best_sequence = sequence(1, 0x10); + let mut earlier_best = empty_result(0, 0); + earlier_best.optimization_best_value = Some(I256::try_from(9).unwrap()); + earlier_best.optimization_best_sequence = sequence(1, 0x20); + let mut later_tie = empty_result(0, 0); + later_tie.optimization_best_value = Some(I256::try_from(9).unwrap()); + later_tie.optimization_best_sequence = sequence(1, 0x30); + + let mut aggregator = InvariantCampaignAggregator::new(spec); + aggregator.push(InvariantWorkerOutput::new(plans[2], later_tie)); + aggregator.push(InvariantWorkerOutput::new(plans[0], first)); + aggregator.push(InvariantWorkerOutput::new(plans[1], earlier_best)); + let result = aggregator.finish().unwrap(); + + assert_eq!(result.optimization_best_value, Some(I256::try_from(9).unwrap())); + assert_eq!(result.optimization_best_sequence[0].sender, Address::repeat_byte(0x20)); + } + + #[test] + fn aggregator_rejects_overlapping_outputs() { + let spec = InvariantCampaignSpec::new(1); + let mut aggregator = InvariantCampaignAggregator::new(spec); + + aggregator.push(InvariantWorkerOutput::new( + InvariantWorkerPlan { worker_id: 0, first_global_run: 0, runs: 1 }, + empty_result(0, 0), + )); + aggregator.push(InvariantWorkerOutput::new( + InvariantWorkerPlan { worker_id: 1, first_global_run: 0, runs: 1 }, + empty_result(0, 0), + )); + let err = aggregator.finish().unwrap_err(); + + assert!(err.to_string().contains("do not cover the logical campaign")); + } + + #[test] + fn aggregator_rejects_duplicate_worker_ids() { + let spec = InvariantCampaignSpec::new(2); + let mut aggregator = InvariantCampaignAggregator::new(spec); + + aggregator.push(InvariantWorkerOutput::new( + InvariantWorkerPlan { worker_id: 0, first_global_run: 0, runs: 1 }, + empty_result(0, 0), + )); + aggregator.push(InvariantWorkerOutput::new( + InvariantWorkerPlan { worker_id: 0, first_global_run: 1, runs: 1 }, + empty_result(0, 0), + )); + let err = aggregator.finish().unwrap_err(); + + assert!(err.to_string().contains("duplicate invariant worker output")); + } + + #[test] + fn aggregator_allows_non_dense_worker_ids_with_contiguous_ranges() { + let spec = InvariantCampaignSpec::new(2); + let mut aggregator = InvariantCampaignAggregator::new(spec); + + aggregator.push(InvariantWorkerOutput::new( + InvariantWorkerPlan { worker_id: 0, first_global_run: 0, runs: 1 }, + empty_result(0, 0), + )); + aggregator.push(InvariantWorkerOutput::new( + InvariantWorkerPlan { worker_id: 2, first_global_run: 1, runs: 1 }, + empty_result(2, 0), + )); + let result = aggregator.finish().unwrap(); + + assert_eq!(result.reverts, 2); + } + + #[test] + fn aggregator_rejects_missing_master_worker() { + let spec = InvariantCampaignSpec::new(2); + let mut aggregator = InvariantCampaignAggregator::new(spec); + + aggregator.push(InvariantWorkerOutput::new( + InvariantWorkerPlan { worker_id: 1, first_global_run: 0, runs: 1 }, + empty_result(0, 0), + )); + aggregator.push(InvariantWorkerOutput::new( + InvariantWorkerPlan { worker_id: 2, first_global_run: 1, runs: 1 }, + empty_result(0, 0), + )); + let err = aggregator.finish().unwrap_err(); + + assert!(err.to_string().contains("missing invariant master worker output")); + } + + #[test] + fn aggregator_uses_master_failed_corpus_replays() { + let spec = InvariantCampaignSpec::new(2); + let plans = [ + InvariantWorkerPlan { worker_id: 0, first_global_run: 0, runs: 1 }, + InvariantWorkerPlan { worker_id: 1, first_global_run: 1, runs: 1 }, + ]; + + let mut aggregator = InvariantCampaignAggregator::new(spec); + aggregator.push(InvariantWorkerOutput::new(plans[0], empty_result(0, 7))); + aggregator.push(InvariantWorkerOutput::new(plans[1], empty_result(0, 1))); + let result = aggregator.finish().unwrap(); + + assert_eq!(result.failed_corpus_replays, 7); + } + + #[test] + fn aggregator_uses_master_failed_corpus_replays_independent_of_output_order() { + let spec = InvariantCampaignSpec::new(2); + let plans = [ + InvariantWorkerPlan { worker_id: 1, first_global_run: 0, runs: 1 }, + InvariantWorkerPlan { worker_id: 0, first_global_run: 1, runs: 1 }, + ]; + + let mut aggregator = InvariantCampaignAggregator::new(spec); + aggregator.push(InvariantWorkerOutput::new(plans[0], empty_result(0, 0))); + aggregator.push(InvariantWorkerOutput::new(plans[1], empty_result(0, 7))); + let result = aggregator.finish().unwrap(); + + assert_eq!(result.failed_corpus_replays, 7); + } + + #[test] + fn aggregator_rejects_plan_that_does_not_cover_campaign() { + let spec = InvariantCampaignSpec::new(2); + let plan = InvariantWorkerPlan { worker_id: 0, first_global_run: 0, runs: 1 }; + let worker = InvariantWorkerOutput::new(plan, empty_result(0, 0)); + + let mut aggregator = InvariantCampaignAggregator::new(spec); + aggregator.push(worker); + let err = aggregator.finish().unwrap_err(); + + assert!(err.to_string().contains("do not cover the logical campaign")); + } + + #[test] + fn aggregator_rejects_missing_output() { + let aggregator = InvariantCampaignAggregator::new(InvariantCampaignSpec::new(1)); + let err = aggregator.finish().unwrap_err(); + + assert!(err.to_string().contains("missing invariant worker output")); + } +} diff --git a/crates/evm/evm/src/executors/invariant/corpus.rs b/crates/evm/evm/src/executors/invariant/corpus.rs deleted file mode 100644 index 2614f8cb3da5b..0000000000000 --- a/crates/evm/evm/src/executors/invariant/corpus.rs +++ /dev/null @@ -1,587 +0,0 @@ -use crate::executors::{ - Executor, - invariant::{InvariantTest, InvariantTestRun}, -}; -use alloy_dyn_abi::JsonAbiExt; -use alloy_primitives::U256; -use eyre::eyre; -use foundry_config::InvariantConfig; -use foundry_evm_fuzz::{ - invariant::{BasicTxDetails, FuzzRunIdentifiedContracts}, - strategies::fuzz_param_from_state, -}; -use proptest::{ - prelude::{Just, Rng, Strategy}, - prop_oneof, - strategy::{BoxedStrategy, ValueTree}, - test_runner::TestRunner, -}; -use serde::Serialize; -use std::{ - fmt, - path::PathBuf, - time::{SystemTime, UNIX_EPOCH}, -}; -use uuid::Uuid; - -const METADATA_SUFFIX: &str = "metadata.json"; -const JSON_EXTENSION: &str = ".json"; -const FAVORABILITY_THRESHOLD: f64 = 0.3; - -/// Possible mutation strategies to apply on a call sequence. -#[derive(Debug, Clone)] -enum MutationType { - /// Splice original call sequence. - Splice, - /// Repeat selected call several times. - Repeat, - /// Interleave calls from two random call sequences. - Interleave, - /// Replace prefix of the original call sequence with new calls. - Prefix, - /// Replace suffix of the original call sequence with new calls. - Suffix, - /// ABI mutate random args of selected call in sequence. - Abi, -} - -/// Holds Corpus information. -#[derive(Serialize)] -struct CorpusEntry { - // Unique corpus identifier. - uuid: Uuid, - // Total mutations of corpus as primary source. - total_mutations: usize, - // New coverage found as a result of mutating this corpus. - new_finds_produced: usize, - // Corpus call sequence. - #[serde(skip_serializing)] - tx_seq: Vec, - // Whether this corpus is favored, i.e. producing new finds more often than - // `FAVORABILITY_THRESHOLD`. - is_favored: bool, -} - -impl CorpusEntry { - /// New corpus from given call sequence and corpus path to read uuid. - pub fn new(tx_seq: Vec, path: PathBuf) -> eyre::Result { - let uuid = if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { - Uuid::try_from(stem.strip_suffix(JSON_EXTENSION).unwrap_or(stem).to_string())? - } else { - Uuid::new_v4() - }; - Ok(Self { uuid, total_mutations: 0, new_finds_produced: 0, tx_seq, is_favored: false }) - } - - /// New corpus with given call sequence and new uuid. - pub fn from_tx_seq(tx_seq: Vec) -> Self { - Self { - uuid: Uuid::new_v4(), - total_mutations: 0, - new_finds_produced: 0, - tx_seq, - is_favored: false, - } - } -} - -#[derive(Serialize, Default)] -pub(crate) struct CorpusMetrics { - // Number of edges seen during the invariant run. - cumulative_edges_seen: usize, - // Number of features (new hitcount bin of previously hit edge) seen during the invariant run. - cumulative_features_seen: usize, - // Number of corpus entries. - corpus_count: usize, - // Number of corpus entries that are favored. - favored_items: usize, -} - -impl fmt::Display for CorpusMetrics { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - writeln!(f)?; - 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)?; - write!(f, " - favored items: {}", self.favored_items)?; - Ok(()) - } -} - -impl CorpusMetrics { - /// Records number of new edges or features explored during the campaign. - pub fn update_seen(&mut self, is_edge: bool) { - if is_edge { - self.cumulative_edges_seen += 1; - } else { - self.cumulative_features_seen += 1; - } - } - - /// Updates campaign favored items. - pub fn update_favored(&mut self, is_favored: bool, corpus_favored: bool) { - if is_favored && !corpus_favored { - self.favored_items += 1; - } else if !is_favored && corpus_favored { - self.favored_items -= 1; - } - } -} - -/// Invariant corpus manager. -pub struct TxCorpusManager { - // Fuzzed calls generator. - tx_generator: BoxedStrategy, - // Call sequence mutation strategy type generator. - mutation_generator: BoxedStrategy, - // Path to invariant corpus directory. If None, sequences with new coverage are not persisted. - corpus_dir: Option, // TODO consolidate into config - // Whether corpus to use gzip file compression and decompression. - corpus_gzip: bool, - // Number of mutations until entry marked as eligible to be flushed from in-memory corpus. - // Mutations will be performed at least `corpus_min_mutations` times. - corpus_min_mutations: usize, - // Number of corpus that won't be evicted from memory. - corpus_min_size: usize, - // In-memory corpus, populated from persisted files and current runs. - // Mutation is performed on these. - in_memory_corpus: Vec, - // Identifier of current mutated entry. - current_mutated: Option, - // Number of failed replays from persisted corpus. - failed_replays: usize, - // Corpus metrics. - pub(crate) metrics: CorpusMetrics, -} - -impl TxCorpusManager { - pub fn new( - invariant_config: &InvariantConfig, - test_name: &String, - fuzzed_contracts: &FuzzRunIdentifiedContracts, - tx_generator: BoxedStrategy, - executor: &Executor, - history_map: &mut [u8], - ) -> eyre::Result { - let mutation_generator = prop_oneof![ - Just(MutationType::Splice), - Just(MutationType::Repeat), - Just(MutationType::Interleave), - Just(MutationType::Prefix), - Just(MutationType::Suffix), - Just(MutationType::Abi), - ] - .boxed(); - let mut in_memory_corpus = vec![]; - let corpus_gzip = invariant_config.corpus_gzip; - let corpus_min_mutations = invariant_config.corpus_min_mutations; - let corpus_min_size = invariant_config.corpus_min_size; - let mut failed_replays = 0; - - // Early return if corpus dir / coverage guided fuzzing not configured. - let Some(corpus_dir) = &invariant_config.corpus_dir else { - return Ok(Self { - tx_generator, - mutation_generator, - corpus_dir: None, - corpus_gzip, - corpus_min_mutations, - corpus_min_size, - in_memory_corpus, - current_mutated: None, - failed_replays, - metrics: CorpusMetrics::default(), - }); - }; - - // Ensure corpus dir for invariant function is created. - let corpus_dir = corpus_dir.join(test_name); - if !corpus_dir.is_dir() { - foundry_common::fs::create_dir_all(&corpus_dir)?; - } - - let fuzzed_contracts = fuzzed_contracts.targets.lock(); - let mut metrics = CorpusMetrics::default(); - - for entry in std::fs::read_dir(&corpus_dir)? { - let path = entry?.path(); - if path.is_file() - && let Some(name) = path.file_name().and_then(|s| s.to_str()) - { - // Ignore metadata files - if name.contains(METADATA_SUFFIX) { - continue; - } - } - metrics.corpus_count += 1; - - let read_corpus_result = match path.extension().and_then(|ext| ext.to_str()) { - Some("gz") => foundry_common::fs::read_json_gzip_file::>(&path), - _ => foundry_common::fs::read_json_file::>(&path), - }; - - let Ok(tx_seq) = read_corpus_result else { - trace!(target: "corpus", "failed to load corpus from {}", path.display()); - continue; - }; - - if !tx_seq.is_empty() { - // Warm up history map from loaded sequences. - let mut executor = executor.clone(); - for tx in &tx_seq { - let mut call_result = executor - .call_raw( - tx.sender, - tx.call_details.target, - tx.call_details.calldata.clone(), - U256::ZERO, - ) - .map_err(|e| eyre!(format!("Could not make raw evm call: {e}")))?; - - if fuzzed_contracts.can_replay(tx) { - let (new_coverage, is_edge) = call_result.merge_edge_coverage(history_map); - if new_coverage { - metrics.update_seen(is_edge); - } - - executor.commit(&mut call_result); - } else { - failed_replays += 1; - } - } - - trace!( - target: "corpus", - "load sequence with len {} from corpus file {}", - tx_seq.len(), - path.display() - ); - - // Populate in memory corpus with sequence from corpus file. - in_memory_corpus.push(CorpusEntry::new(tx_seq, path)?); - } - } - - Ok(Self { - tx_generator, - mutation_generator, - corpus_dir: Some(corpus_dir), - corpus_gzip, - corpus_min_mutations, - corpus_min_size, - in_memory_corpus, - current_mutated: None, - failed_replays, - metrics, - }) - } - - /// Collects inputs from given invariant run, if new coverage produced. - /// Persists call sequence (if corpus directory is configured) and updates in-memory corpus. - pub fn collect_inputs(&mut self, test_run: &InvariantTestRun) { - // Early return if corpus dir / coverage guided fuzzing is not configured. - let Some(corpus_dir) = &self.corpus_dir else { - return; - }; - - // Update stats of current mutated primary corpus. - if let Some(uuid) = &self.current_mutated { - if let Some(corpus) = - self.in_memory_corpus.iter_mut().find(|corpus| corpus.uuid.eq(uuid)) - { - corpus.total_mutations += 1; - if test_run.new_coverage { - corpus.new_finds_produced += 1 - } - let is_favored = (corpus.new_finds_produced as f64 / corpus.total_mutations as f64) - < FAVORABILITY_THRESHOLD; - self.metrics.update_favored(is_favored, corpus.is_favored); - corpus.is_favored = is_favored; - - trace!( - target: "corpus", - "updated corpus {}, total mutations: {}, new finds: {}", - corpus.uuid, corpus.total_mutations, corpus.new_finds_produced - ); - } - - self.current_mutated = None; - } - - // Collect inputs only if current run produced new coverage. - if !test_run.new_coverage { - return; - } - - let corpus = CorpusEntry::from_tx_seq(test_run.inputs.clone()); - let corpus_uuid = corpus.uuid; - - // Persist to disk if corpus dir is configured. - let write_result = if self.corpus_gzip { - foundry_common::fs::write_json_gzip_file( - corpus_dir.join(format!("{corpus_uuid}{JSON_EXTENSION}.gz")).as_path(), - &corpus.tx_seq, - ) - } else { - foundry_common::fs::write_json_file( - corpus_dir.join(format!("{corpus_uuid}{JSON_EXTENSION}")).as_path(), - &corpus.tx_seq, - ) - }; - - if let Err(err) = write_result { - debug!(target: "corpus", %err, "Failed to record call sequence {:?}", &corpus.tx_seq); - } else { - trace!( - target: "corpus", - "persisted {} inputs for new coverage in {corpus_uuid} corpus", - &corpus.tx_seq.len() - ); - } - - // This includes reverting txs in the corpus and `can_continue` removes - // them. We want this as it is new coverage and may help reach the other branch. - self.metrics.corpus_count += 1; - self.in_memory_corpus.push(corpus); - } - - /// Generates new call sequence from in memory corpus. Evicts oldest corpus mutated more than - /// configured max mutations value. - pub fn new_sequence(&mut self, test: &InvariantTest) -> eyre::Result> { - let mut new_seq = vec![]; - let test_runner = &mut test.execution_data.borrow_mut().branch_runner; - - // Early return with first_input only if corpus dir / coverage guided fuzzing not - // configured. - let Some(corpus_dir) = &self.corpus_dir else { - new_seq.push(self.new_tx(test_runner)?); - return Ok(new_seq); - }; - - if !self.in_memory_corpus.is_empty() { - // Flush oldest corpus mutated more than configured max mutations unless they are - // favored. - let should_evict = self.in_memory_corpus.len() > self.corpus_min_size.max(1); - if should_evict - && let Some(index) = self.in_memory_corpus.iter().position(|corpus| { - corpus.total_mutations > self.corpus_min_mutations && !corpus.is_favored - }) - { - let corpus = self.in_memory_corpus.get(index).unwrap(); - - let uuid = corpus.uuid; - debug!(target: "corpus", "evict corpus {uuid}"); - - // Flush to disk the seed metadata at the time of eviction. - let eviction_time = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards") - .as_secs(); - foundry_common::fs::write_json_file( - corpus_dir.join(format!("{uuid}-{eviction_time}-{METADATA_SUFFIX}")).as_path(), - &corpus, - )?; - - // Remove corpus from memory. - self.in_memory_corpus.remove(index); - } - - let mutation_type = self - .mutation_generator - .new_tree(test_runner) - .expect("Could not generate mutation type") - .current(); - let rng = test_runner.rng(); - let corpus_len = self.in_memory_corpus.len(); - let primary = &self.in_memory_corpus[rng.random_range(0..corpus_len)]; - let secondary = &self.in_memory_corpus[rng.random_range(0..corpus_len)]; - - match mutation_type { - MutationType::Splice => { - trace!(target: "corpus", "splice {} and {}", primary.uuid, secondary.uuid); - - self.current_mutated = Some(primary.uuid); - - let start1 = rng.random_range(0..primary.tx_seq.len()); - let end1 = rng.random_range(start1..primary.tx_seq.len()); - - let start2 = rng.random_range(0..secondary.tx_seq.len()); - let end2 = rng.random_range(start2..secondary.tx_seq.len()); - - for tx in primary.tx_seq.iter().take(end1).skip(start1) { - new_seq.push(tx.clone()); - } - for tx in secondary.tx_seq.iter().take(end2).skip(start2) { - new_seq.push(tx.clone()); - } - } - MutationType::Repeat => { - let corpus = if rng.random::() { primary } else { secondary }; - trace!(target: "corpus", "repeat {}", corpus.uuid); - - self.current_mutated = Some(corpus.uuid); - - new_seq = corpus.tx_seq.clone(); - let start = rng.random_range(0..corpus.tx_seq.len()); - let end = rng.random_range(start..corpus.tx_seq.len()); - let item_idx = rng.random_range(0..corpus.tx_seq.len()); - let repeated = vec![new_seq[item_idx].clone(); end - start]; - new_seq.splice(start..end, repeated); - } - MutationType::Interleave => { - trace!(target: "corpus", "interleave {} with {}", primary.uuid, secondary.uuid); - - self.current_mutated = Some(primary.uuid); - - for (tx1, tx2) in primary.tx_seq.iter().zip(secondary.tx_seq.iter()) { - // chunks? - let tx = if rng.random::() { tx1.clone() } else { tx2.clone() }; - new_seq.push(tx); - } - } - MutationType::Prefix => { - let corpus = if rng.random::() { primary } else { secondary }; - trace!(target: "corpus", "overwrite prefix of {}", corpus.uuid); - - self.current_mutated = Some(corpus.uuid); - - new_seq = corpus.tx_seq.clone(); - for i in 0..rng.random_range(0..=new_seq.len()) { - new_seq[i] = self.new_tx(test_runner)?; - } - } - MutationType::Suffix => { - let corpus = if rng.random::() { primary } else { secondary }; - trace!(target: "corpus", "overwrite suffix of {}", corpus.uuid); - - self.current_mutated = Some(corpus.uuid); - - new_seq = corpus.tx_seq.clone(); - for i in new_seq.len() - rng.random_range(0..new_seq.len())..corpus.tx_seq.len() - { - new_seq[i] = self.new_tx(test_runner)?; - } - } - MutationType::Abi => { - let targets = test.targeted_contracts.targets.lock(); - let corpus = if rng.random::() { primary } else { secondary }; - trace!(target: "corpus", "ABI mutate args of {}", corpus.uuid); - - self.current_mutated = Some(corpus.uuid); - - new_seq = corpus.tx_seq.clone(); - - let idx = rng.random_range(0..new_seq.len()); - let tx = new_seq.get_mut(idx).unwrap(); - if let (_, Some(function)) = targets.fuzzed_artifacts(tx) { - // TODO add call_value to call details and mutate it as well as sender some - // of the time - if !function.inputs.is_empty() { - let mut new_function = function.clone(); - let mut arg_mutation_rounds = - rng.random_range(0..=function.inputs.len()).max(1); - let round_arg_idx: Vec = if function.inputs.len() <= 1 { - vec![0] - } else { - (0..arg_mutation_rounds) - .map(|_| { - test_runner.rng().random_range(0..function.inputs.len()) - }) - .collect() - }; - // TODO mutation strategy for individual ABI types - let mut prev_inputs = function - .abi_decode_input(&tx.call_details.calldata[4..]) - .expect("fuzzed_artifacts returned wrong sig"); - // For now, only new inputs are generated, no existing inputs are - // mutated. - let mut gen_input = |input: &alloy_json_abi::Param| { - fuzz_param_from_state( - &input.selector_type().parse().unwrap(), - &test.fuzz_state, - ) - .new_tree(test_runner) - .expect("Could not generate case") - .current() - }; - - while arg_mutation_rounds > 0 { - let idx = round_arg_idx[arg_mutation_rounds - 1]; - let input = new_function - .inputs - .get_mut(idx) - .expect("Could not get input to mutate"); - let new_input = gen_input(input); - prev_inputs[idx] = new_input; - arg_mutation_rounds -= 1; - } - - tx.call_details.calldata = new_function - .abi_encode_input(&prev_inputs) - .map_err(|e| eyre!(e.to_string()))? - .into(); - } - } - } - } - } - - // Make sure sequence contains at least one tx to start fuzzing from. - if new_seq.is_empty() { - new_seq.push(self.new_tx(test_runner)?); - } - trace!(target: "corpus", "new sequence of {} calls generated", new_seq.len()); - - Ok(new_seq) - } - - /// Returns the next call to be used in call sequence. - /// If coverage guided fuzzing is not configured or if previous input was discarded then this is - /// a new tx from strategy. - /// If running with coverage guided fuzzing it returns a new call only when sequence - /// does not have enough entries, or randomly. Otherwise, returns the next call from initial - /// sequence. - pub fn generate_next_input( - &mut self, - test: &InvariantTest, - sequence: &[BasicTxDetails], - discarded: bool, - depth: usize, - ) -> eyre::Result { - let test_runner = &mut test.execution_data.borrow_mut().branch_runner; - - // Early return with new input if corpus dir / coverage guided fuzzing not configured or if - // call was discarded. - if self.corpus_dir.is_none() || discarded { - return self.new_tx(test_runner); - } - - // When running with coverage guided fuzzing enabled then generate new sequence if initial - // sequence's length is less than depth or randomly, to occasionally intermix new txs. - if depth > sequence.len().saturating_sub(1) || test_runner.rng().random_ratio(1, 10) { - return self.new_tx(test_runner); - } - - // Continue with the next call initial sequence - Ok(sequence[depth].clone()) - } - - /// Generates single call from invariant strategy. - pub fn new_tx(&mut self, test_runner: &mut TestRunner) -> eyre::Result { - Ok(self - .tx_generator - .new_tree(test_runner) - .map_err(|_| eyre!("Could not generate case"))? - .current()) - } - - /// Returns campaign failed replays. - pub fn failed_replays(self) -> usize { - self.failed_replays - } - - /// Updates seen edges or features metrics. - pub fn update_seen_metrics(&mut self, is_edge: bool) { - self.metrics.update_seen(is_edge); - } -} diff --git a/crates/evm/evm/src/executors/invariant/error.rs b/crates/evm/evm/src/executors/invariant/error.rs index 061d9ed8d0ddc..dab35e878828d 100644 --- a/crates/evm/evm/src/executors/invariant/error.rs +++ b/crates/evm/evm/src/executors/invariant/error.rs @@ -1,20 +1,275 @@ -use super::{BasicTxDetails, InvariantContract}; -use crate::executors::RawCallResult; -use alloy_primitives::{Address, Bytes}; +use super::InvariantContract; +use crate::{ + executors::RawCallResult, + inspectors::{EdgeCovHit, EdgeCoverage}, +}; +use alloy_json_abi::Function; +use alloy_primitives::{Address, B256, Bytes, Selector, keccak256}; use foundry_config::InvariantConfig; -use foundry_evm_core::decode::RevertDecoder; -use foundry_evm_fuzz::{Reason, invariant::FuzzRunIdentifiedContracts}; +use foundry_evm_core::{ + decode::{ASSERTION_FAILED_PREFIX, EMPTY_REVERT_DATA, RevertDecoder}, + evm::FoundryEvmNetwork, +}; +use foundry_evm_fuzz::{BasicTxDetails, Reason, invariant::FuzzRunIdentifiedContracts}; use proptest::test_runner::TestError; +use std::{collections::HashMap, fmt}; -/// Stores information about failures and reverts of the invariant tests. +/// A handler-side assertion bug: a `require`/`assert` inside a fuzzed handler that the +/// campaign reached. Deduped by `(reverter, selector)` site (Echidna/Medusa semantics), +/// shortest sequence wins on collision. +#[derive(Clone, Debug)] +pub struct HandlerAssertionFailure { + /// Handler contract whose call asserted. + pub reverter: Address, + /// 4-byte selector of the failing function. + pub selector: Selector, + /// Call sequence including the failing call (post-shrink: minimal prefix). + pub call_sequence: Vec, + /// Pre-shrink length, for the `(original: N, shrunk: M)` renderer. + pub original_sequence_len: usize, + /// Decoded revert/assert reason. + pub revert_reason: String, + /// Active fork block when the handler assertion failed, if any. + pub fork_block_number: Option, + /// Stable hash of edge coverage at the asserting call (falls back to `(reverter, + /// selector)`). Used by the shrinker to preserve path identity, not for dedup. + pub edge_fingerprint: B256, +} + +impl HandlerAssertionFailure { + /// Builds a failure from a replayed sequence whose last call asserted. + pub const fn from_replayed_sequence( + call_sequence: Vec, + reverter: Address, + selector: Selector, + edge_fingerprint: B256, + revert_reason: String, + ) -> Self { + let original_sequence_len = call_sequence.len(); + Self { + reverter, + selector, + call_sequence, + original_sequence_len, + revert_reason, + fork_block_number: None, + edge_fingerprint, + } + } +} + +/// Run-scoped references shared by failure-recording paths in an invariant run. +pub struct InvariantRunCtx<'a> { + /// The invariant test contract. + pub contract: &'a InvariantContract<'a>, + /// Active invariant configuration. + pub config: &'a InvariantConfig, + /// Fuzz targets discovered for this run. + pub targeted_contracts: &'a FuzzRunIdentifiedContracts, + /// Inputs of the current run, used as the failing call sequence. + pub calldata: &'a [BasicTxDetails], +} + +impl<'a> InvariantRunCtx<'a> { + /// Builds a [`FailedInvariantCaseData`] attributed to `broken_fn`. `fail_on_revert` is + /// passed in because `assert_invariants` overrides it with the per-invariant flag. + /// `assertion_failure=true` normalizes empty revert data so output is not blank. + pub fn failed_case( + &self, + broken_fn: &Function, + fail_on_revert: bool, + assertion_failure: bool, + call_result: RawCallResult, + inner_sequence: &[Option], + ) -> FailedInvariantCaseData { + let revert_reason = self.decode_revert_reason(&call_result, assertion_failure); + let origin = broken_fn.name.as_str(); + FailedInvariantCaseData { + test_error: TestError::Fail( + format!("{origin}, reason: {revert_reason}").into(), + self.calldata.to_vec(), + ), + return_reason: "".into(), + revert_reason, + addr: self.contract.address, + calldata: broken_fn.selector().into(), + inner_sequence: inner_sequence.to_vec(), + shrink_run_limit: self.config.shrink_run_limit, + fail_on_revert, + assertion_failure, + fork_block_number: call_result.fork_block_number, + } + } + + /// Decodes the revert/assert reason without allocating a full [`FailedInvariantCaseData`]. + /// Used by callers that only need the reason (e.g. handler-bug recording). + pub fn decode_revert_reason( + &self, + call_result: &RawCallResult, + assertion_failure: bool, + ) -> String { + let revert_reason = RevertDecoder::new() + .with_abis(self.targeted_contracts.targets().values().map(|c| &c.abi)) + .with_abi(self.contract.abi) + .decode(call_result.result.as_ref(), call_result.exit_reason); + // Non-reverting assertion failures surface through Foundry's failure flags, not + // revert data — fall back so invariant output is not blank. + let needs_fallback = matches!(revert_reason.as_str(), "" | EMPTY_REVERT_DATA); + if needs_fallback && (!call_result.reverted || assertion_failure) { + ASSERTION_FAILED_PREFIX.to_string() + } else { + revert_reason + } + } +} + +/// Edge-coverage fingerprint for a handler-side assertion call. Prefers a pre-merge +/// edges hash; falls back to `keccak(target || selector)` when edge coverage is disabled. +pub fn handler_edge_fingerprint( + pre_merge_edges_hash: Option, + target: Address, + selector: Selector, +) -> B256 { + if let Some(hash) = pre_merge_edges_hash { + return hash; + } + let mut buf = [0u8; 24]; + buf[..20].copy_from_slice(target.as_slice()); + buf[20..].copy_from_slice(selector.as_slice()); + keccak256(buf) +} + +/// Records a handler-side assertion bug (if strictly shorter than the existing repro for +/// this site) and pops the just-asserted reverted input from `inputs`. Shared by the +/// periodic-check path and the inline check-skipped path. +#[expect(clippy::too_many_arguments)] +pub(crate) fn record_handler_assertion_bug( + invariant_contract: &InvariantContract<'_>, + config: &InvariantConfig, + targeted_contracts: &FuzzRunIdentifiedContracts, + failures: &mut InvariantFailures, + inputs: &mut Vec, + handler_target: Address, + handler_selector: Selector, + pre_merge_edges_hash: Option, + call_result: RawCallResult, + call_reverted: bool, + is_optimization: bool, +) { + let fingerprint = + handler_edge_fingerprint(pre_merge_edges_hash, handler_target, handler_selector); + + if !handler_site_already_minimal( + &failures.failures, + (handler_target, handler_selector), + inputs.len(), + ) { + // Handler bugs go through `FailureKey::Handler`; we only need the reason. + let revert_reason = InvariantRunCtx { + contract: invariant_contract, + config, + targeted_contracts, + calldata: inputs, + } + .decode_revert_reason(&call_result, true); + let call_sequence = inputs.clone(); + let original_sequence_len = call_sequence.len(); + failures.record_handler_failure(HandlerAssertionFailure { + reverter: handler_target, + selector: handler_selector, + call_sequence, + original_sequence_len, + revert_reason, + fork_block_number: call_result.fork_block_number, + edge_fingerprint: fingerprint, + }); + } + + // Standard reverted-input pop. Delay-enabled campaigns keep reverted calls so their warp/roll + // contribution can be replayed. + if call_reverted && !is_optimization && !config.has_delay() { + inputs.pop(); + } +} + +/// True iff there is already a [`HandlerAssertionFailure`] for `site` no longer than +/// `candidate_len`. Used to skip inserting a not-strictly-shorter repro. +pub fn handler_site_already_minimal( + failures: &HashMap, + site: (Address, Selector), + candidate_len: usize, +) -> bool { + failures + .get(&FailureKey::Handler(site.0, site.1)) + .and_then(InvariantFuzzError::as_handler_assertion) + .is_some_and(|existing| existing.call_sequence.len() <= candidate_len) +} + +/// Stable hash of the call's edge coverage, taken *before* `merge_edge_coverage` +/// zeroes the buffer. Returns `None` when edge coverage is disabled. +pub fn snapshot_edge_fingerprint( + call_result: &RawCallResult, +) -> Option { + let edges = call_result.edge_coverage.as_ref()?; + if edges.is_empty() { + return None; + } + match edges { + EdgeCoverage::Hash(edges) => Some(keccak256(edges)), + EdgeCoverage::CollisionFree(hits) => { + // `From` does not sort on the per-call drain path, + // so sort here for a deterministic fingerprint across runs regardless + // of HashMap iteration order. Cold path — only invoked on handler + // assertion failure. + let mut sorted: Vec<&EdgeCovHit> = hits.iter().collect(); + sorted.sort_unstable_by_key(|hit| hit.edge); + + // address(20) + pc(8) + jump_dest(32) + depth_tag(1) + depth(8) + count(1) + let mut bytes = Vec::with_capacity(sorted.len() * (20 + 8 + 32 + 1 + 8 + 1)); + for hit in sorted { + bytes.extend_from_slice(hit.edge.address.as_slice()); + bytes.extend_from_slice(&hit.edge.pc.to_le_bytes()); + bytes.extend_from_slice(&hit.edge.jump_dest.to_be_bytes::<32>()); + // Tag byte disambiguates `None` from `Some(0)` so configs with + // `include_call_depth` toggled don't collide in the fingerprint. + bytes.push(u8::from(hit.edge.depth.is_some())); + bytes.extend_from_slice(&hit.edge.depth.unwrap_or(0).to_le_bytes()); + bytes.push(hit.count); + } + Some(keccak256(bytes)) + } + } +} + +/// Identifies a single entry in the [`InvariantFailures`] map. Invariant predicate +/// failures and handler-side assertion bugs share one map keyed by this enum. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub enum FailureKey { + /// Keyed by invariant function name. + Invariant(String), + /// Keyed by handler `(reverter, selector)` site (Echidna/Medusa semantics: one bug + /// per handler function regardless of code path). + Handler(Address, Selector), +} + +/// Stores invariant test failures and revert counts. +/// +/// TODO: dedup multiple distinct `assert(...)` within the same `(reverter, selector)` +/// handler if callers ever need finer attribution (e.g. per-assertion-label). #[derive(Clone, Default)] pub struct InvariantFailures { /// Total number of reverts. pub reverts: usize, - /// The latest revert reason of a run. - pub revert_reason: Option, - /// Maps a broken invariant to its specific error. - pub error: Option, + /// Invariant predicate failures and handler-side assertion bugs share one map. + /// Mutate only via `record_failure` / `record_handler_failure` / `seed_handler_failure` + /// so the cached counters stay in sync. + pub(crate) failures: HashMap, + /// Cached `FailureKey::Invariant` count, kept O(1) on the hot path. + invariant_count: usize, + /// Cached `FailureKey::Handler` count, read on progress/metrics ticks. + handler_count: usize, + /// Increments whenever a failure or its reproducer is inserted or replaced. + revision: usize, } impl InvariantFailures { @@ -22,29 +277,205 @@ impl InvariantFailures { Self::default() } - pub fn into_inner(self) -> (usize, Option) { - (self.reverts, self.error) + /// Splits `self.failures` into the legacy `(invariant_errors, handler_errors)` pair. + pub fn partition( + self, + ) -> (HashMap, HashMap<(Address, Selector), InvariantFuzzError>) + { + let mut invariant_errors = HashMap::new(); + let mut handler_errors = HashMap::new(); + for (key, err) in self.failures { + match key { + FailureKey::Invariant(name) => { + invariant_errors.insert(name, err); + } + FailureKey::Handler(addr, sel) => { + handler_errors.insert((addr, sel), err); + } + } + } + (invariant_errors, handler_errors) + } + + pub fn record_failure(&mut self, invariant: &Function, failure: InvariantFuzzError) { + let prev = self.failures.insert(FailureKey::Invariant(invariant.name.clone()), failure); + self.revision = self.revision.wrapping_add(1); + if prev.is_none() { + self.invariant_count += 1; + } + } + + pub fn has_failure(&self, invariant: &Function) -> bool { + self.failures.contains_key(&FailureKey::Invariant(invariant.name.clone())) + } + + pub fn get_failure(&self, invariant: &Function) -> Option<&InvariantFuzzError> { + self.failures.get(&FailureKey::Invariant(invariant.name.clone())) + } + + /// Recorded revert reason for `invariant`, or empty when none. Used by failure events + /// so the metrics payload mirrors the persisted failure. + pub fn broken_reason(&self, invariant: &Function) -> String { + self.get_failure(invariant).and_then(|e| e.revert_reason()).unwrap_or_default() + } + + pub const fn can_continue(&self, invariants: usize) -> bool { + self.invariant_count() < invariants + } + + /// Number of unique broken invariant predicates (O(1), cached). + pub const fn invariant_count(&self) -> usize { + self.invariant_count + } + + /// Number of unique handler-side assertion bugs (O(1), cached). + pub const fn handler_count(&self) -> usize { + self.handler_count + } + + /// Revision of the failure map, including replacements with shorter reproducers. + pub const fn revision(&self) -> usize { + self.revision + } + + pub fn handler_failures_mut(&mut self) -> impl Iterator { + self.failures.iter_mut().filter_map(|(key, error)| match key { + FailureKey::Handler(_, _) => Some(error), + FailureKey::Invariant(_) => None, + }) + } + + /// Records a handler-side assertion bug. Deduped by `(reverter, selector)` site; + /// shortest sequence wins on collision. + pub fn record_handler_failure(&mut self, failure: HandlerAssertionFailure) { + let site = (failure.reverter, failure.selector); + if !handler_site_already_minimal(&self.failures, site, failure.call_sequence.len()) { + let prev = self.failures.insert( + FailureKey::Handler(site.0, site.1), + InvariantFuzzError::HandlerAssertion(failure), + ); + self.revision = self.revision.wrapping_add(1); + if prev.is_none() { + self.handler_count += 1; + } + } + } + + /// Inserts a persisted-replay handler bug. Skips dedup (caller seeds an empty map) + /// but bumps `handler_count` so the live counter is correct from the first tick. + pub fn seed_handler_failure( + &mut self, + target: Address, + selector: Selector, + err: InvariantFuzzError, + ) { + let prev = self.failures.insert(FailureKey::Handler(target, selector), err); + self.revision = self.revision.wrapping_add(1); + if prev.is_none() { + self.handler_count += 1; + } + } + + /// Returns true if a handler bug has already been recorded for the given site. + pub fn has_handler_failure(&self, target: Address, selector: Selector) -> bool { + self.failures.contains_key(&FailureKey::Handler(target, selector)) + } +} + +impl fmt::Display for InvariantFailures { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f)?; + writeln!(f, " ❌ Failures: {}", self.invariant_count())?; + Ok(()) } } #[derive(Clone, Debug)] pub enum InvariantFuzzError { + /// A handler call reverted under `fail_on_revert = true`. Revert(FailedInvariantCaseData), + /// An `invariant_*` predicate returned `false` (or asserted). BrokenInvariant(FailedInvariantCaseData), + /// A handler-side `assert(...)` / `vm.assert*` failed (bug inside a handler, not in + /// an `invariant_*` predicate). Recorded per `(reverter, selector)` site. + HandlerAssertion(HandlerAssertionFailure), + /// `vm.assume` rejected more inputs than allowed. MaxAssumeRejects(u32), } impl InvariantFuzzError { + /// Reconstructs a predicate failure from a persisted sequence that still reproduces. + #[expect(clippy::too_many_arguments)] + pub fn from_replayed_invariant( + invariant_address: Address, + invariant: &Function, + call_sequence: Vec, + reason: Option, + config: &InvariantConfig, + fail_on_revert: bool, + assertion_failure: bool, + is_revert: bool, + ) -> Self { + let revert_reason = reason.unwrap_or_default(); + let origin = invariant.name.as_str(); + let failure = FailedInvariantCaseData { + test_error: TestError::Fail( + format!("{origin}, reason: {revert_reason}").into(), + call_sequence, + ), + return_reason: "".into(), + revert_reason, + addr: invariant_address, + calldata: invariant.selector().into(), + inner_sequence: Vec::new(), + shrink_run_limit: config.shrink_run_limit, + fail_on_revert, + assertion_failure, + fork_block_number: None, + }; + if is_revert { Self::Revert(failure) } else { Self::BrokenInvariant(failure) } + } + + /// Active fork block when this invariant failure was observed, if any. + pub const fn fork_block_number(&self) -> Option { + match self { + Self::BrokenInvariant(case_data) | Self::Revert(case_data) => { + case_data.fork_block_number + } + Self::HandlerAssertion(failure) => failure.fork_block_number, + Self::MaxAssumeRejects(_) => None, + } + } + pub fn revert_reason(&self) -> Option { match self { Self::BrokenInvariant(case_data) | Self::Revert(case_data) => { (!case_data.revert_reason.is_empty()).then(|| case_data.revert_reason.clone()) } + Self::HandlerAssertion(failure) => { + (!failure.revert_reason.is_empty()).then(|| failure.revert_reason.clone()) + } Self::MaxAssumeRejects(allowed) => { Some(format!("`vm.assume` rejected too many inputs ({allowed} allowed)")) } } } + + /// Wrapped `HandlerAssertionFailure` if this is the [`Self::HandlerAssertion`] variant. + pub const fn as_handler_assertion(&self) -> Option<&HandlerAssertionFailure> { + match self { + Self::HandlerAssertion(failure) => Some(failure), + _ => None, + } + } + + /// Mutable counterpart of [`Self::as_handler_assertion`]. Used by post-campaign shrinking. + pub const fn as_handler_assertion_mut(&mut self) -> Option<&mut HandlerAssertionFailure> { + match self { + Self::HandlerAssertion(failure) => Some(failure), + _ => None, + } + } } #[derive(Clone, Debug)] @@ -65,38 +496,53 @@ pub struct FailedInvariantCaseData { pub shrink_run_limit: u32, /// Fail on revert, used to check sequence when shrinking. pub fail_on_revert: bool, + /// Whether this failure originated from a handler assertion. + pub assertion_failure: bool, + /// Active fork block when the failure was observed, if any. + pub fork_block_number: Option, } -impl FailedInvariantCaseData { - pub fn new( - invariant_contract: &InvariantContract<'_>, - invariant_config: &InvariantConfig, - targeted_contracts: &FuzzRunIdentifiedContracts, - calldata: &[BasicTxDetails], - call_result: RawCallResult, - inner_sequence: &[Option], - ) -> Self { - // Collect abis of fuzzed and invariant contracts to decode custom error. - let revert_reason = RevertDecoder::new() - .with_abis(targeted_contracts.targets.lock().values().map(|c| &c.abi)) - .with_abi(invariant_contract.abi) - .decode(call_result.result.as_ref(), call_result.exit_reason); +#[cfg(test)] +mod tests { + use super::*; + use foundry_evm_fuzz::CallDetails; - let func = invariant_contract.invariant_function; - debug_assert!(func.inputs.is_empty()); - let origin = func.name.as_str(); - Self { - test_error: TestError::Fail( - format!("{origin}, reason: {revert_reason}").into(), - calldata.to_vec(), - ), - return_reason: "".into(), - revert_reason, - addr: invariant_contract.address, - calldata: func.selector().to_vec().into(), - inner_sequence: inner_sequence.to_vec(), - shrink_run_limit: invariant_config.shrink_run_limit, - fail_on_revert: invariant_config.fail_on_revert, - } + fn handler_failure(sequence_len: usize) -> HandlerAssertionFailure { + let tx = BasicTxDetails { + warp: None, + roll: None, + sender: Address::ZERO, + call_details: CallDetails { + target: Address::ZERO, + calldata: Bytes::new(), + value: None, + }, + }; + HandlerAssertionFailure::from_replayed_sequence( + vec![tx; sequence_len], + Address::ZERO, + Selector::ZERO, + B256::ZERO, + "assertion failed".to_string(), + ) + } + + #[test] + fn failure_revision_tracks_shorter_handler_reproducer() { + let mut failures = InvariantFailures::new(); + failures.record_handler_failure(handler_failure(2)); + let checkpoint = failures.revision(); + + failures.record_handler_failure(handler_failure(2)); + assert_eq!(failures.revision(), checkpoint); + + failures.record_handler_failure(handler_failure(1)); + assert_ne!(failures.revision(), checkpoint); + let failure = failures + .failures + .get(&FailureKey::Handler(Address::ZERO, Selector::ZERO)) + .and_then(InvariantFuzzError::as_handler_assertion) + .unwrap(); + assert_eq!(failure.call_sequence.len(), 1); } } diff --git a/crates/evm/evm/src/executors/invariant/mod.rs b/crates/evm/evm/src/executors/invariant/mod.rs index b84afccac63e9..2d0c9b87c5f4b 100644 --- a/crates/evm/evm/src/executors/invariant/mod.rs +++ b/crates/evm/evm/src/executors/invariant/mod.rs @@ -1,58 +1,105 @@ use crate::{ - executors::{Executor, RawCallResult}, + executors::{ + DURATION_BETWEEN_METRICS_REPORT, EarlyExit, EvmError, Executor, RawCallResult, + campaign::{ + CampaignCallKind, CampaignControl, CampaignEvent, CampaignSequenceOutcome, + FuzzCampaign, FuzzCampaignMode, + }, + corpus::{ + CorpusInsertionMode, DynamicTargetCtx, ReplayTarget, WorkerCorpus, WorkerCorpusSeed, + persist_campaign_optimization, + }, + fuzz::{ + FuzzBranchFrontier, FuzzFrontierRecorder, StatefulFuzzBranchFrontierArtifact, + merge_frontiers, write_frontier_artifact, + }, + }, inspectors::Fuzzer, }; -use alloy_primitives::{Address, Bytes, FixedBytes, Selector, U256, map::HashMap}; +use alloy_json_abi::Function; +use alloy_primitives::{ + Address, Bytes, FixedBytes, I256, Selector, U256, keccak256, + map::{AddressMap, AddressSet, HashMap, hash_map::Entry as AddressMapEntry}, +}; use alloy_sol_types::{SolCall, sol}; +use campaign::{ + InvariantCampaignAggregator, InvariantCampaignSpec, InvariantCampaignState, + InvariantWorkerOutput, InvariantWorkerPlan, +}; use eyre::{ContextCompat, Result, eyre}; -use foundry_common::contracts::{ContractsByAddress, ContractsByArtifact}; -use foundry_config::InvariantConfig; +use foundry_common::{ + TestFunctionExt, + contracts::{ContractsByAddress, ContractsByArtifact}, + sh_eprintln, sh_println, +}; +use foundry_config::{FuzzCorpusConfig, InvariantConfig, InvariantDepthMode, InvariantWorkers}; use foundry_evm_core::{ constants::{ CALLER, CHEATCODE_ADDRESS, DEFAULT_CREATE2_DEPLOYER, HARDHAT_CONSOLE_ADDRESS, MAGIC_ASSUME, }, + evm::FoundryEvmNetwork, precompiles::PRECOMPILES, }; +use foundry_evm_coverage::HitMaps; use foundry_evm_fuzz::{ - FuzzCase, FuzzFixtures, FuzzedCases, + BasicTxDetails, FuzzCase, FuzzFixtures, ObservedCall, invariant::{ - ArtifactFilters, BasicTxDetails, FuzzRunIdentifiedContracts, InvariantContract, - RandomCallGenerator, SenderFilters, TargetedContract, TargetedContracts, + ArtifactFilters, FuzzRunIdentifiedContracts, InvariantContract, RandomCallGenerator, + SenderFilters, TargetedContract, TargetedContracts, }, - strategies::{EvmFuzzState, invariant_strat, override_call_strat}, + strategies::{EvmFuzzState, FuzzState, TxGenerator, override_call_strat}, }; 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}; +use proptest::{ + prelude::Rng, + test_runner::{RngAlgorithm, TestRng, TestRunner}, +}; +use rayon::iter::{IntoParallelIterator, ParallelIterator}; +use result::{assert_after_invariant, can_continue, invariant_preflight_check}; use revm::state::Account; -use shrink::shrink_sequence; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; use std::{ - cell::RefCell, - collections::{HashMap as Map, btree_map::Entry}, + collections::{HashMap as Map, HashSet, btree_map::Entry}, sync::Arc, time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; mod error; -pub use error::{InvariantFailures, InvariantFuzzError}; -use foundry_evm_coverage::HitMaps; +pub(crate) use error::snapshot_edge_fingerprint; +pub use error::{ + FailureKey, HandlerAssertionFailure, InvariantFailures, InvariantFuzzError, + handler_edge_fingerprint, handler_site_already_minimal, +}; +mod campaign; mod replay; -pub use replay::{replay_error, replay_run}; +pub use replay::{ReplayErrorResult, replay_error, replay_run}; mod result; -use foundry_common::{TestFunctionExt, sh_println}; -pub use result::InvariantFuzzTestResult; -use serde::{Deserialize, Serialize}; -use serde_json::json; - -mod corpus; +pub use result::{InvariantFuzzTestResult, did_fail_on_assert}; mod shrink; -use crate::executors::{EvmError, FuzzTestTimer, invariant::corpus::TxCorpusManager}; -pub use shrink::check_sequence; +pub use shrink::{ + CheckSequenceFailureSite, CheckSequenceOptions, CheckSequenceOutcome, HandlerReplayOutcome, + SequenceShrink, ShrinkCandidateKeys, ShrinkRun, ShrinkRunStats, check_sequence, + check_sequence_value, replay_handler_failure_sequence, shrink_sequence_by_removing, +}; + +/// Minimum number of logical runs assigned to each auto invariant worker at the default invariant +/// depth. +/// +/// Keeps short campaigns single-threaded and avoids producing many small rayon jobs. +const MIN_RUNS_PER_INVARIANT_WORKER: u32 = 10_000; +/// Baseline depth used to preserve the previous default-depth worker heuristic. +const DEFAULT_DEPTH_FOR_INVARIANT_WORKER_CAP: u32 = 500; +/// Minimum estimated handler calls assigned to each auto invariant worker. +const MIN_ESTIMATED_CALLS_PER_INVARIANT_WORKER: u64 = + MIN_RUNS_PER_INVARIANT_WORKER as u64 * DEFAULT_DEPTH_FOR_INVARIANT_WORKER_CAP as u64; +/// Share of parallel workers reserved for selector focus mode. +const INVARIANT_FOCUS_WORKER_DIVISOR: usize = 8; sol! { interface IInvariantTest { @@ -108,8 +155,6 @@ sol! { } } -const DURATION_BETWEEN_METRICS_REPORT: Duration = Duration::from_secs(5); - /// Contains invariant metrics for a single fuzzed selector. #[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] pub struct InvariantMetrics { @@ -121,167 +166,653 @@ pub struct InvariantMetrics { pub discards: usize, } +impl InvariantMetrics { + const fn record_call(&mut self, reverted: bool, discarded: bool) { + self.calls += 1; + if discarded { + self.discards += 1; + } else if reverted { + self.reverts += 1; + } + } +} + +/// Campaign-level throughput metrics for invariant progress reporting. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +struct InvariantThroughputMetrics { + total_txs: u64, + total_gas: u64, +} + +impl InvariantThroughputMetrics { + fn tps(self, elapsed: Duration) -> f64 { + round_rate_for_progress(rate_per_sec(self.total_txs as f64, elapsed)) + } + + fn gps(self, elapsed: Duration) -> f64 { + round_rate_for_progress(rate_per_sec(self.total_gas as f64, elapsed)) + } +} + +fn max_invariant_workers_for_campaign(runs: u32, depth: u32) -> usize { + let estimated_calls = u64::from(runs) * u64::from(depth.max(1)); + usize::try_from((estimated_calls / MIN_ESTIMATED_CALLS_PER_INVARIANT_WORKER).max(1)) + .unwrap_or(usize::MAX) +} + +fn invariant_run_depth(config: &InvariantConfig, runner: &mut TestRunner) -> u32 { + match config.depth_mode { + InvariantDepthMode::Fixed => config.depth, + InvariantDepthMode::Random => { + let min_depth = config.min_depth.max(1); + if config.depth <= min_depth { + config.depth.max(1) + } else { + runner.rng().random_range(min_depth..=config.depth) + } + } + } +} + +fn auto_invariant_worker_count( + available_threads: usize, + invariant_campaign_anchors: usize, +) -> usize { + (available_threads.max(1) / invariant_campaign_anchors.max(1)).max(1) +} + +fn invariant_worker_count_with_threads( + config: &InvariantConfig, + available_threads: usize, + invariant_campaign_anchors: usize, +) -> usize { + match config.workers { + InvariantWorkers::Fixed(workers) => workers.get(), + InvariantWorkers::Auto => { + let requested = + auto_invariant_worker_count(available_threads, invariant_campaign_anchors); + if config.timeout.is_some() { + requested + } else { + requested.min(max_invariant_workers_for_campaign(config.runs, config.depth)) + } + } + } +} + +const fn invariant_worker_config( + mut config: InvariantConfig, + worker_id: u32, + worker_count: usize, +) -> InvariantConfig { + // Keep one- and two-worker campaigns on the stable default, but let one extra worker explore + // the broader fresh-input setting once the user has enough workers to keep the exploratory + // share below half of the campaign. The last worker does not receive remainder runs from + // campaign sharding, so this keeps exposure bounded to one worker shard. + let exploratory_worker_id = worker_count.saturating_sub(1) as u32; + if worker_count > 2 + && worker_id == exploratory_worker_id + && !config.corpus_random_sequence_weight_configured + && config.corpus.corpus_random_sequence_weight + == FuzzCorpusConfig::DEFAULT_CORPUS_RANDOM_SEQUENCE_WEIGHT + { + config.corpus.corpus_random_sequence_weight = + FuzzCorpusConfig::ENSEMBLE_CORPUS_RANDOM_SEQUENCE_WEIGHT; + } + config +} + +fn gas_report_samples_for_worker(total_samples: u32, worker_id: u32, worker_count: usize) -> usize { + let total_samples = total_samples as usize; + let worker_count = worker_count.max(1); + total_samples / worker_count + usize::from((worker_id as usize) < total_samples % worker_count) +} + +fn invariant_worker_collects_evm_cmp_log( + config: &InvariantConfig, + worker_id: u32, + worker_count: usize, +) -> bool { + config.corpus.collect_evm_cmp_log() && (worker_count <= 1 || worker_id == 0) +} + +fn invariant_focus_worker_count(worker_count: usize) -> usize { + if worker_count <= 1 { + 0 + } else { + let max_focus_workers = if worker_count > 2 { worker_count - 2 } else { worker_count - 1 }; + (worker_count / INVARIANT_FOCUS_WORKER_DIVISOR).max(1).min(max_focus_workers) + } +} + +fn invariant_focus_worker_index(worker_id: u32, worker_count: usize) -> Option { + let worker_id = worker_id as usize; + let focus_workers = invariant_focus_worker_count(worker_count); + if focus_workers == 0 || worker_id >= worker_count { + return None; + } + + let focus_worker_end = if worker_count > 2 { worker_count - 1 } else { worker_count }; + let first_focus_worker = focus_worker_end - focus_workers; + (worker_id >= first_focus_worker && worker_id < focus_worker_end) + .then(|| worker_id - first_focus_worker) +} + +#[cfg(test)] +fn campaign_seed_for_worker( + campaign_seed: &InvariantCampaignSeed, + plan: InvariantWorkerPlan, + worker_count: usize, +) -> InvariantCampaignSeed { + focused_campaign_seed_for_worker(campaign_seed, plan, worker_count, None) + .unwrap_or_else(|| campaign_seed.clone()) +} + +fn focused_campaign_seed_for_worker( + campaign_seed: &InvariantCampaignSeed, + plan: InvariantWorkerPlan, + worker_count: usize, + focus_seed: Option, +) -> Option { + let focus_index = invariant_focus_worker_index(plan.worker_id, worker_count)?; + let targeted_contracts = + focused_targeted_contracts(&campaign_seed.targeted_contracts, focus_index, focus_seed)?; + + Some(InvariantCampaignSeed { + targeted_contracts, + targets_are_updatable: false, + ..campaign_seed.clone() + }) +} + +fn campaign_seed_and_corpus_seed_for_worker( + campaign_seed: &InvariantCampaignSeed, + corpus_seed: &WorkerCorpusSeed, + plan: InvariantWorkerPlan, + worker_count: usize, + include_cmp_seq: bool, + focus_seed: Option, +) -> (InvariantCampaignSeed, WorkerCorpusSeed) { + let worker_corpus_seed = + corpus_seed.clone_for_worker(plan.worker_id as usize, worker_count, include_cmp_seq); + let Some(worker_campaign_seed) = + focused_campaign_seed_for_worker(campaign_seed, plan, worker_count, focus_seed) + else { + return (campaign_seed.clone(), worker_corpus_seed); + }; + + let mut worker_corpus_seed = worker_corpus_seed; + worker_corpus_seed.retain_replayable(&worker_campaign_seed.targeted_contracts); + (worker_campaign_seed, worker_corpus_seed) +} + +fn focused_targeted_contracts( + targeted_contracts: &TargetedContracts, + focus_index: usize, + focus_seed: Option, +) -> Option { + let mut seen = HashSet::new(); + let mut candidates = Vec::new(); + for (address, contract) in targeted_contracts.iter() { + // Build from the effective selector set so user target/exclude config stays authoritative. + for function in contract.abi_fuzzed_functions() { + if seen.insert((*address, function.selector())) { + candidates.push((*address, function.clone())); + } + } + } + if candidates.len() <= 1 { + return None; + } + + let seed_offset = focus_seed + .map(|seed| (seed % U256::from(candidates.len())).to::()) + .unwrap_or_default(); + let candidate_index = (focus_index % candidates.len() + seed_offset) % candidates.len(); + let (address, function) = candidates[candidate_index].clone(); + let mut contract = targeted_contracts.get(&address)?.clone(); + contract.targeted_functions = vec![function]; + + let mut focused = TargetedContracts::new(); + focused.insert(address, contract); + Some(focused) +} + +fn invariant_worker_seed(seed: U256, worker_id: u32) -> U256 { + if worker_id == 0 { + seed + } else { + let seed_data = [&seed.to_be_bytes::<32>()[..], &worker_id.to_be_bytes()[..]].concat(); + keccak256(seed_data).into() + } +} + +fn should_continue_invariant_worker( + campaign_state: &InvariantCampaignState, + runs: u32, + plan: InvariantWorkerPlan, +) -> bool { + if campaign_state.should_stop() { + return false; + } + + campaign_state.is_timed_campaign() || runs < plan.runs +} + +fn invariant_worker_runner( + runner: &mut TestRunner, + worker_id: u32, + seed: Option, +) -> TestRunner { + if let Some(seed) = seed { + let worker_seed = invariant_worker_seed(seed, worker_id); + trace!(target: "forge::test", ?worker_seed, "deterministic seed for invariant worker {worker_id}"); + let rng = TestRng::from_seed(RngAlgorithm::ChaCha, &worker_seed.to_be_bytes::<32>()); + TestRunner::new_with_rng(runner.config().clone(), rng) + } else if worker_id == 0 { + runner.clone() + } else { + TestRunner::new_with_rng(runner.config().clone(), runner.new_rng()) + } +} + +fn invariant_focus_seed( + runner: &mut TestRunner, + configured_seed: Option, + worker_count: usize, +) -> Option { + if invariant_focus_worker_count(worker_count) == 0 { + return None; + } + configured_seed.or_else(|| { + let mut rng = runner.new_rng(); + Some(U256::from_be_bytes(rng.random::<[u8; 32]>())) + }) +} + +/// Converts a cumulative campaign total into an average per-second rate. +/// +/// Returns `0.0` during the initial zero-elapsed startup window to avoid +/// dividing by zero while progress reporting is warming up. +fn rate_per_sec(total: f64, elapsed: Duration) -> f64 { + let elapsed_secs = elapsed.as_secs_f64(); + if elapsed_secs > 0.0 { total / elapsed_secs } else { 0.0 } +} + +fn round_rate_for_progress(rate: f64) -> f64 { + (rate * 100.0).round() / 100.0 +} + +/// Tracks invariant failure counts during a campaign. +#[derive(Clone, Debug, Default)] +struct InvariantFailureMetrics { + failures: u64, + unique_failures: HashSet, + /// Unique handler-side assertion bugs found so far. + broken_handlers: usize, +} + +impl InvariantFailureMetrics { + /// Records a failure and emits a structured JSON `"failure"` event. + fn record_failure(&mut self, invariant_name: &str, target: &str, reason: &str) { + self.failures += 1; + self.unique_failures.insert(invariant_name.to_string()); + + let timestamp = + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + let event = json!({ + "timestamp": timestamp, + "event": "failure", + "invariant": invariant_name, + "target": target, + "reason": reason, + }); + let _ = sh_eprintln!("{}", serde_json::to_string(&event).unwrap_or_default()); + } + + /// Records a handler assertion and emits a structured JSON `"failure"` event. + fn record_handler_failure(&mut self, target: Address, selector: Selector, reason: &str) { + self.broken_handlers += 1; + + let timestamp = + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + let event = build_handler_failure_event(timestamp, target, selector, reason); + let _ = sh_eprintln!("{}", serde_json::to_string(&event).unwrap_or_default()); + } +} + +fn build_handler_failure_event( + timestamp_secs: u64, + target: Address, + selector: Selector, + reason: &str, +) -> Value { + json!({ + "timestamp": timestamp_secs, + "event": "failure", + "failure_type": "handler_assertion", + "target": target, + "selector": selector, + "reason": reason, + }) +} + +/// Bridges newly-recorded invariant breaks from `failures.errors` into the pulse +/// `failure_metrics` so the live progress stream reflects breaks as they happen. +/// Iterates in declaration order so the emitted "failure" events are deterministic. +fn record_new_invariant_failures( + campaign_state: &InvariantCampaignState, + invariant_contract: &InvariantContract<'_>, + failures: &InvariantFailures, +) { + for (f, _) in &invariant_contract.invariant_fns { + if let Some(failure) = failures.get_failure(f) { + let reason = failure.revert_reason().unwrap_or_default(); + campaign_state.record_invariant_failure(&f.name, invariant_contract.name, &reason); + } + } +} + +struct InvariantProgressContext<'a> { + timestamp_secs: u64, + contract_name: &'a str, + optimization_best: Option, + throughput: InvariantThroughputMetrics, + elapsed: Duration, + worker_id: u32, + worker_count: usize, + /// Time since this worker last saw a new edge, or `None` if it has not seen one yet. + time_since_new_edge: Option, +} + +/// Builds the machine-readable invariant progress payload emitted during a +/// campaign. +/// +/// This keeps the existing corpus progress metrics together with cumulative and +/// derived throughput fields so downstream benchmark tooling can consume a +/// single JSON event shape. +fn build_invariant_progress_json( + context: InvariantProgressContext<'_>, + corpus_metrics: &M, + failure_metrics: &InvariantFailureMetrics, +) -> serde_json::Value { + let mut metrics = serde_json::to_value(corpus_metrics).unwrap_or_default(); + if let Some(obj) = metrics.as_object_mut() { + obj.insert("broken_invariants".to_string(), json!(failure_metrics.unique_failures.len())); + obj.insert("broken_assertions".to_string(), json!(failure_metrics.broken_handlers)); + } + + let mut payload = json!({ + "timestamp": context.timestamp_secs, + "event": "pulse", + "contract": context.contract_name, + "metrics": metrics, + "total_txs": context.throughput.total_txs, + "total_gas": context.throughput.total_gas, + "tps": context.throughput.tps(context.elapsed), + "gps": context.throughput.gps(context.elapsed), + "worker": { + "id": context.worker_id, + "count": context.worker_count, + // `null` until this worker sees its first edge. + "secs_since_new_edge": context + .time_since_new_edge + .map(|d| d.as_secs_f64()), + }, + }); + + if let Some(best) = context.optimization_best { + payload["optimization_best"] = json!(best.to_string()); + } + + payload +} + /// Contains data collected during invariant test runs. -pub struct InvariantTestData { - // Consumed gas and calldata of every successful fuzz call. - pub fuzz_cases: Vec, +struct InvariantTestData { + // Number of completed invariant runs. + runs: usize, + // Number of completed fuzzed calls across all invariant runs. + calls: usize, // Data related to reverts or failed assertions of the test. - pub failures: InvariantFailures, + failures: InvariantFailures, // Calldata in the last invariant run. - pub last_run_inputs: Vec, + last_run_inputs: Vec, // Additional traces for gas report. - pub gas_report_traces: Vec>, - // Last call results of the invariant test. - pub last_call_results: Option, + gas_report_traces: Vec>, // Line coverage information collected from all fuzzed calls. - pub line_coverage: Option, + line_coverage: Option, // Metrics for each fuzzed selector. - pub metrics: Map, + metrics: HashMap, + // Cache from fuzzed (target, selector) to its metric key. Only resolved keys are cached and + // they are invalidated when targets change (see `invalidate_metric_key_cache`). + metric_key_cache: HashMap<(Address, Selector), String>, // Proptest runner to query for random values. // The strategy only comes with the first `input`. We fill the rest of the `inputs` // until the desired `depth` so we can use the evolving fuzz dictionary // during the run. - pub branch_runner: TestRunner, + branch_runner: TestRunner, + + // Optimization mode state: tracks the best (maximum) value and the sequence that produced it. + // Only used when invariant function returns int256. + optimization_best_value: Option, + optimization_best_sequence: Vec, } /// Contains invariant test data. -pub struct InvariantTest { +struct InvariantTest { // Fuzz state of invariant test. - pub fuzz_state: EvmFuzzState, + fuzz_state: FuzzState, // Contracts fuzzed by the invariant test. - pub targeted_contracts: FuzzRunIdentifiedContracts, + targeted_contracts: FuzzRunIdentifiedContracts, // Data collected during invariant runs. - pub execution_data: RefCell, + test_data: InvariantTestData, } impl InvariantTest { /// Instantiates an invariant test. - pub fn new( - fuzz_state: EvmFuzzState, + fn new( + fuzz_state: FuzzState, targeted_contracts: FuzzRunIdentifiedContracts, failures: InvariantFailures, - last_call_results: Option, branch_runner: TestRunner, ) -> Self { - let mut fuzz_cases = vec![]; - if last_call_results.is_none() { - fuzz_cases.push(FuzzedCases::new(vec![])); - } - let execution_data = RefCell::new(InvariantTestData { - fuzz_cases, + let test_data = InvariantTestData { + runs: 0, + calls: 0, failures, last_run_inputs: vec![], gas_report_traces: vec![], - last_call_results, line_coverage: None, - metrics: Map::default(), + metrics: HashMap::default(), + metric_key_cache: HashMap::default(), branch_runner, - }); - Self { fuzz_state, targeted_contracts, execution_data } + optimization_best_value: None, + optimization_best_sequence: vec![], + }; + Self { fuzz_state, targeted_contracts, test_data } } /// Returns number of invariant test reverts. - pub fn reverts(&self) -> usize { - self.execution_data.borrow().failures.reverts - } - - /// Whether invariant test has errors or not. - pub fn has_errors(&self) -> bool { - self.execution_data.borrow().failures.error.is_some() + const fn reverts(&self) -> usize { + self.test_data.failures.reverts } /// Set invariant test error. - pub fn set_error(&self, error: InvariantFuzzError) { - self.execution_data.borrow_mut().failures.error = Some(error); - } - - /// Set last invariant test call results. - pub fn set_last_call_results(&self, call_result: Option) { - self.execution_data.borrow_mut().last_call_results = call_result; + fn set_error(&mut self, invariant: &Function, error: InvariantFuzzError) { + self.test_data.failures.record_failure(invariant, error); } /// Set last invariant run call sequence. - pub fn set_last_run_inputs(&self, inputs: &Vec) { - self.execution_data.borrow_mut().last_run_inputs.clone_from(inputs); + fn set_last_run_inputs(&mut self, inputs: &Vec) { + self.test_data.last_run_inputs.clone_from(inputs); } /// Merge current collected line coverage with the new coverage from last fuzzed call. - pub fn merge_coverage(&self, new_coverage: Option) { - HitMaps::merge_opt(&mut self.execution_data.borrow_mut().line_coverage, new_coverage); + fn merge_line_coverage(&mut self, new_coverage: Option) { + HitMaps::merge_opt(&mut self.test_data.line_coverage, new_coverage); } /// Update metrics for a fuzzed selector, extracted from tx details. /// Always increments number of calls; discarded runs (through assume cheatcodes) are tracked /// separated from reverts. - pub fn record_metrics(&self, tx_details: &BasicTxDetails, reverted: bool, discarded: bool) { - if let Some(metric_key) = - self.targeted_contracts.targets.lock().fuzzed_metric_key(tx_details) - { - let test_metrics = &mut self.execution_data.borrow_mut().metrics; - let invariant_metrics = test_metrics.entry(metric_key).or_default(); - invariant_metrics.calls += 1; - if discarded { - invariant_metrics.discards += 1; - } else if reverted { - invariant_metrics.reverts += 1; + fn record_metrics(&mut self, tx_details: &BasicTxDetails, reverted: bool, discarded: bool) { + let Some(selector) = tx_details + .call_details + .calldata + .get(..4) + .and_then(|selector| <[u8; 4]>::try_from(selector).ok()) + .map(Selector::from) + else { + return; + }; + let cache_key = (tx_details.call_details.target, selector); + + if let Some(metric_key) = self.test_data.metric_key_cache.get(&cache_key) { + if let Some(invariant_metrics) = self.test_data.metrics.get_mut(metric_key) { + invariant_metrics.record_call(reverted, discarded); + } else { + self.test_data + .metrics + .entry(metric_key.to_owned()) + .or_default() + .record_call(reverted, discarded); } + return; + } + + // Not cached: resolve from the current target set. Unresolved keys aren't cached so a tx + // whose target isn't known yet is re-resolved once that target is added. + let Some(metric_key) = self + .targeted_contracts + .targets() + .fuzzed_metric_key_for_selector(tx_details.call_details.target, selector) + else { + return; + }; + self.test_data.metric_key_cache.insert(cache_key, metric_key.clone()); + self.test_data.metrics.entry(metric_key).or_default().record_call(reverted, discarded); + } + + /// Drops cached metric keys for the given addresses, keeping the cache coherent when targets + /// are added or removed (an address can be reused for a different artifact across runs). + fn invalidate_metric_key_cache(&mut self, addresses: &[Address]) { + if addresses.is_empty() { + return; } + self.test_data.metric_key_cache.retain(|(addr, _), _| !addresses.contains(addr)); } /// End invariant test run by collecting results, cleaning collected artifacts and reverting /// created fuzz state. - pub fn end_run(&self, run: InvariantTestRun, gas_samples: usize) { - // We clear all the targeted contracts created during this run. + fn end_run(&mut self, run: InvariantTestRun, gas_samples: usize) { + // Clear contracts created during this run, dropping their cached metric keys so a reused + // address can't resolve to a stale contract in a later run. + self.invalidate_metric_key_cache(&run.created_contracts); self.targeted_contracts.clear_created_contracts(run.created_contracts); - let mut invariant_data = self.execution_data.borrow_mut(); - if invariant_data.gas_report_traces.len() < gas_samples { - invariant_data + if self.test_data.gas_report_traces.len() < gas_samples { + self.test_data .gas_report_traces .push(run.run_traces.into_iter().map(|arena| arena.arena).collect()); } - invariant_data.fuzz_cases.push(FuzzedCases::new(run.fuzz_runs)); + self.test_data.runs += 1; + self.test_data.calls += run.fuzz_runs.len(); // Revert state to not persist values between runs. self.fuzz_state.revert(); } + + /// Updates the optimization state if the new value is better (higher) than the current best. + fn update_optimization_value(&mut self, value: I256, sequence: &[BasicTxDetails]) { + if self.test_data.optimization_best_value.is_none_or(|best| value > best) { + self.test_data.optimization_best_value = Some(value); + self.test_data.optimization_best_sequence = sequence.to_vec(); + } + } } /// Contains data for an invariant test run. -pub struct InvariantTestRun { +struct InvariantTestRun { // Invariant run call sequence. - pub inputs: Vec, + inputs: Vec, + // Per-call EVM comparison operands (parallel to `inputs`), captured for I2S corpus mutation. + cmp_seq: Vec>, // Current invariant run executor. - pub executor: Executor, + executor: Executor, // Invariant run stat reports (eg. gas usage). - pub fuzz_runs: Vec, + fuzz_runs: Vec, // Contracts created during current invariant run. - pub created_contracts: Vec
, + created_contracts: Vec
, // Traces of each call of the invariant run call sequence. - pub run_traces: Vec, + run_traces: Vec, // Current depth of invariant run. - pub depth: u32, + depth: u32, // Current assume rejects of the invariant run. - pub assume_rejects_counter: u32, + rejects: u32, // Whether new coverage was discovered during this run. - pub new_coverage: bool, + new_coverage: bool, + // Line coverage staged until the run is accepted. + line_coverage: Option, + // Whether this run's inputs should become the reported last run. + save_last_run_inputs: bool, + // For optimization mode: the best value found during this run (if any). + optimization_value: Option, + // For optimization mode: the length of the input prefix that produced the best value. + optimization_prefix_len: usize, +} + +/// Recorded call sequence used by corpus feedback and frontier recording. +struct RecordedCallSequence { + inputs: Vec, + cmp_seq: Vec>, } -impl InvariantTestRun { +/// Immutable state selected once for a logical invariant campaign and cloned into each worker. +#[derive(Clone)] +struct InvariantCampaignSeed { + artifact_filters: ArtifactFilters, + sender_filters: SenderFilters, + targeted_contracts: TargetedContracts, + targets_are_updatable: bool, + initial_handler_failures: Map<(Address, Selector), InvariantFuzzError>, +} + +impl InvariantTestRun { /// Instantiates an invariant test run. - pub fn new(first_input: BasicTxDetails, executor: Executor, depth: usize) -> Self { + fn new(first_input: BasicTxDetails, executor: Executor, depth: usize) -> Self { + let mut inputs = Vec::with_capacity(depth.saturating_add(1)); + inputs.push(first_input); Self { - inputs: vec![first_input], + inputs, + cmp_seq: Vec::with_capacity(depth), executor, fuzz_runs: Vec::with_capacity(depth), created_contracts: vec![], run_traces: vec![], depth: 0, - assume_rejects_counter: 0, + rejects: 0, new_coverage: false, + line_coverage: None, + save_last_run_inputs: false, + optimization_value: None, + optimization_prefix_len: 0, } } + + /// Releases per-run corpus payloads once the worker corpus manager has consumed them. + /// + /// Successful runs only need `fuzz_runs`, traces, and created-contract bookkeeping for final + /// reporting. Counterexample inputs are copied into `InvariantTestData::last_run_inputs` + /// before this point, so retaining the full per-run input/cmp buffers until `end_run` only + /// extends peak memory in long invariant campaigns. + fn drop_corpus_payloads(&mut self) { + self.inputs.clear(); + self.inputs.shrink_to_fit(); + self.cmp_seq.clear(); + self.cmp_seq.shrink_to_fit(); + } } /// Wrapper around any [`Executor`] implementer which provides fuzzing support using [`proptest`]. @@ -290,10 +821,12 @@ impl InvariantTestRun { /// contracts with inputs, until it finds a counterexample sequence. The provided [`TestRunner`] /// contains all the configuration which can be overridden via [environment /// variables](proptest::test_runner::Config) -pub struct InvariantExecutor<'a> { - pub executor: Executor, +pub struct InvariantExecutor<'a, FEN: FoundryEvmNetwork> { + pub executor: Executor, /// Proptest runner. runner: TestRunner, + /// Configured fuzz seed used to derive deterministic invariant worker runners. + fuzz_seed: Option, /// The invariant configuration config: InvariantConfig, /// Contracts deployed with `setUp()` @@ -303,347 +836,1086 @@ pub struct InvariantExecutor<'a> { project_contracts: &'a ContractsByArtifact, /// Filters contracts to be fuzzed through their artifact identifiers. artifact_filters: ArtifactFilters, - /// History of binned hitcount of edges seen during fuzzing. - history_map: Vec, + /// Number of matching invariant campaign anchors in the current test pass. + invariant_campaign_anchors: usize, } -const COVERAGE_MAP_SIZE: usize = 65536; -impl<'a> InvariantExecutor<'a> { - /// Instantiates a fuzzed executor EVM given a testrunner - pub fn new( - executor: Executor, +impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { + /// Instantiates an invariant executor with the configured fuzz seed for deterministic worker + /// runner derivation. + pub fn new_with_fuzz_seed( + executor: Executor, runner: TestRunner, + fuzz_seed: Option, config: InvariantConfig, setup_contracts: &'a ContractsByAddress, project_contracts: &'a ContractsByArtifact, + invariant_campaign_anchors: usize, ) -> Self { Self { executor, runner, + fuzz_seed, config, setup_contracts, project_contracts, artifact_filters: ArtifactFilters::default(), - history_map: vec![0u8; COVERAGE_MAP_SIZE], + invariant_campaign_anchors, + } + } + + pub fn config(&self) -> InvariantConfig { + self.config.clone() + } + + /// Retains corpus replay and result aggregation without starting fresh invariant runs. + pub const fn skip_fresh_runs(&mut self) { + self.config.runs = 0; + self.config.timeout = None; + } + + /// Refs for tracking contracts deployed mid-sequence during corpus replay. + pub const fn dynamic_target_ctx(&self) -> DynamicTargetCtx<'_> { + DynamicTargetCtx { + project_contracts: self.project_contracts, + setup_contracts: self.setup_contracts, + artifact_filters: &self.artifact_filters, } } /// Fuzzes any deployed contract and checks any broken invariant at `invariant_address`. + /// + /// `initial_handler_failures` pre-seeds the campaign's `broken_handlers` map with bugs + /// recovered from disk by the runner's persisted-failure replay step, so the live + /// progress bar and JSON pulse stream surface them from the first emission instead of + /// jumping at the final report. pub fn invariant_fuzz( &mut self, invariant_contract: InvariantContract<'_>, fuzz_fixtures: &FuzzFixtures, - deployed_libs: &[Address], + fuzz_state: EvmFuzzState, progress: Option<&ProgressBar>, + early_exit: &EarlyExit, + initial_handler_failures: std::collections::HashMap< + (Address, Selector), + InvariantFuzzError, + >, ) -> Result { - // Throw an error to abort test run if the invariant function accepts input params - if !invariant_contract.invariant_function.inputs.is_empty() { - return Err(eyre!("Invariant test function should have no inputs")); + let campaign_spec = InvariantCampaignSpec::new(self.config.runs); + let worker_plans = campaign_spec.worker_plans(invariant_worker_count_with_threads( + &self.config, + rayon::current_num_threads(), + self.invariant_campaign_anchors, + ))?; + let actual_worker_count = worker_plans.len(); + let campaign_seed = + self.prepare_campaign_seed(&invariant_contract, initial_handler_failures)?; + let replay_targets = FuzzRunIdentifiedContracts::new( + campaign_seed.targeted_contracts.clone(), + campaign_seed.targets_are_updatable, + ); + let mut corpus_replay_executor = self.executor.clone(); + corpus_replay_executor.inspector_mut().collect_evm_cmp_log( + invariant_worker_collects_evm_cmp_log(&self.config, 0, actual_worker_count), + ); + let dynamic = self.dynamic_target_ctx(); + let mut corpus_seed = WorkerCorpusSeed::load_from_disk( + &self.config.corpus, + None, + Some(&corpus_replay_executor), + ReplayTarget { + stateless: None, + fuzzed_contracts: Some(&replay_targets), + dynamic: Some(&dynamic), + senders: Some(&campaign_seed.sender_filters), + }, + )?; + if invariant_contract.is_optimization() + && let Some(best_value) = corpus_seed.optimization_best_value + { + let sequence = (0..corpus_seed.optimization_best_sequence.len()).collect::>(); + let replayed = check_sequence_value( + self.executor.clone(), + &corpus_seed.optimization_best_sequence, + &sequence, + invariant_contract.address, + invariant_contract.anchor_calldata(), + ); + if !matches!(replayed, Ok(Some(value)) if value == best_value) { + // Keep the sequence in the mutation corpus, but do not report its stale value. + corpus_seed.optimization_best_value = None; + corpus_seed.optimization_best_sequence.clear(); + } } + let mut runner = self.runner.clone(); + let config = self.config.clone(); + let setup_contracts = self.setup_contracts; + let project_contracts = self.project_contracts; + let base_executor = self.executor.clone(); + let focus_seed = invariant_focus_seed(&mut runner, self.fuzz_seed, actual_worker_count); + let campaign_state = + Arc::new(InvariantCampaignState::new(early_exit.clone(), self.config.timeout)); + + let frontier_test = self + .config + .corpus + .capture_branch_frontiers() + .then(|| invariant_contract.anchor().clone()); + let mut worker_outputs = if actual_worker_count > 1 { + let worker_jobs = worker_plans + .into_iter() + .map(|worker_plan| { + let worker_runner = + invariant_worker_runner(&mut runner, worker_plan.worker_id, self.fuzz_seed); + let gas_report_samples = gas_report_samples_for_worker( + config.gas_report_samples, + worker_plan.worker_id, + actual_worker_count, + ); + let collect_cmp_log = invariant_worker_collects_evm_cmp_log( + &config, + worker_plan.worker_id, + actual_worker_count, + ); + (worker_plan, worker_runner, gas_report_samples, collect_cmp_log) + }) + .collect::>(); + worker_jobs + .into_par_iter() + .map(|(worker_plan, worker_runner, gas_report_samples, collect_cmp_log)| { + let _guard = + info_span!("invariant_worker", id = worker_plan.worker_id).entered(); + let timer = Instant::now(); + let (worker_campaign_seed, worker_corpus_seed) = + campaign_seed_and_corpus_seed_for_worker( + &campaign_seed, + &corpus_seed, + worker_plan, + actual_worker_count, + collect_cmp_log, + focus_seed, + ); + let output = Self::run_invariant_worker( + base_executor.clone(), + worker_runner, + config.clone(), + setup_contracts, + project_contracts, + worker_plan, + invariant_contract.clone(), + fuzz_fixtures, + fuzz_state.fork(), + progress, + &campaign_state, + worker_campaign_seed, + worker_corpus_seed, + actual_worker_count, + gas_report_samples, + ); + if output.is_err() { + campaign_state.request_terminal_stop(); + } + debug!("finished in {:?}", timer.elapsed()); + output + }) + .collect::>>()? + } else { + let worker_plan = worker_plans[0]; + let runner = + invariant_worker_runner(&mut runner, worker_plan.worker_id, self.fuzz_seed); + let gas_report_samples = config.gas_report_samples as usize; + let collect_cmp_log = invariant_worker_collects_evm_cmp_log( + &config, + worker_plan.worker_id, + actual_worker_count, + ); + let (worker_campaign_seed, worker_corpus_seed) = + campaign_seed_and_corpus_seed_for_worker( + &campaign_seed, + &corpus_seed, + worker_plan, + actual_worker_count, + collect_cmp_log, + focus_seed, + ); + vec![Self::run_invariant_worker( + base_executor, + runner, + config, + setup_contracts, + project_contracts, + worker_plan, + invariant_contract, + fuzz_fixtures, + fuzz_state, + progress, + &campaign_state, + worker_campaign_seed, + worker_corpus_seed, + actual_worker_count, + gas_report_samples, + )?] + }; - let (invariant_test, mut corpus_manager) = - self.prepare_test(&invariant_contract, fuzz_fixtures, deployed_libs)?; + let frontier_limit = self.config.corpus.frontier_limit; + if let (Some(frontier_dir), Some(frontier_test)) = + (&self.config.corpus.frontier_dir, frontier_test.as_ref()) + { + let frontiers = merge_frontiers( + frontier_limit, + worker_outputs.iter_mut().flat_map(|(_, frontiers)| frontiers.drain(..)), + ); + if !frontiers.is_empty() { + let artifact = StatefulFuzzBranchFrontierArtifact::new( + frontier_test, + frontier_limit, + frontiers, + ); + if let Err(err) = write_frontier_artifact(frontier_dir, &artifact) { + warn!(%err, path = ?frontier_dir, "failed to write fuzz branch frontier artifact"); + } + } + } - // Start timer for this invariant test. - let mut runs = 0; - let timer = FuzzTestTimer::new(self.config.timeout); - let mut last_metrics_report = Instant::now(); - let continue_campaign = |runs: u32| { - // If timeout is configured, then perform invariant runs until expires. - if self.config.timeout.is_some() { - return !timer.is_timed_out(); - } - // If no timeout configured then loop until configured runs. - runs < self.config.runs + let mut aggregator = InvariantCampaignAggregator::new(campaign_spec); + for (worker_output, _) in worker_outputs { + aggregator.push(worker_output); + } + let mut result = if campaign_state.is_timed_campaign() { + aggregator.finish_partial()? + } else { + aggregator.finish_campaign()? }; + result.interrupted = campaign_state.interrupted(result.runs, self.config.runs); + persist_campaign_optimization( + &self.config.corpus, + result.optimization_best_value, + &result.optimization_best_sequence, + ); + Ok(result) + } - 'stop: while continue_campaign(runs) { - let initial_seq = corpus_manager.new_sequence(&invariant_test)?; + /// Runs one worker-local slice of an invariant campaign. + #[allow(clippy::too_many_arguments)] + fn run_invariant_worker( + mut executor: Executor, + runner: TestRunner, + config: InvariantConfig, + setup_contracts: &'a ContractsByAddress, + project_contracts: &'a ContractsByArtifact, + plan: InvariantWorkerPlan, + invariant_contract: InvariantContract<'_>, + fuzz_fixtures: &FuzzFixtures, + fuzz_state: EvmFuzzState, + progress: Option<&ProgressBar>, + campaign_state: &InvariantCampaignState, + campaign_seed: InvariantCampaignSeed, + corpus_seed: WorkerCorpusSeed, + worker_count: usize, + gas_report_samples: usize, + ) -> Result<(InvariantWorkerOutput, Vec)> { + // Note: invariant function signatures (no inputs) are validated upstream in the + // suite runner so parameterized `invariant_*` functions are rejected with a per-test + // failure entry before any campaign runs. + let config = invariant_worker_config(config, plan.worker_id, worker_count); + let frontier_limit = if config.corpus.capture_branch_frontiers() + && invariant_worker_collects_evm_cmp_log(&config, plan.worker_id, worker_count) + { + config.corpus.frontier_limit + } else { + 0 + }; + let mut frontier_recorder = FuzzFrontierRecorder::new(frontier_limit); + executor.inspector_mut().set_execution_cancellation(campaign_state.cancellation().clone()); + + let (mut invariant_test, mut corpus_manager) = Self::prepare_worker( + &mut executor, + plan, + worker_count, + &invariant_contract, + fuzz_fixtures, + fuzz_state, + &runner, + &config, + &campaign_seed, + corpus_seed, + )?; + let mut runs = 0; + campaign_state.sync_handler_failures(&invariant_test.test_data.failures); + + // Invariant runs with edge coverage if corpus dir is set or showing edge coverage. + let edge_coverage_enabled = config.corpus.collect_edge_coverage(); + + 'stop: while should_continue_invariant_worker(campaign_state, runs, plan) { + // Per-run failure count snapshot used to gate `afterInvariant` below. + let failures_before_run = invariant_test.test_data.failures.invariant_count(); + let failures_checkpoint = invariant_test.test_data.failures.clone(); + let failures_revision = failures_checkpoint.revision(); + let mut stop_after_run = false; + let mut run_cancelled = false; + let mut observed_call_entries = Vec::<(Vec, BasicTxDetails)>::new(); + + let call_campaign = FuzzCampaign::new(FuzzCampaignMode::Invariant { + check_interval: config.check_interval, + optimization: invariant_contract.is_optimization(), + }); + + let sequence_plan = + corpus_manager.new_sequence(&mut invariant_test.test_data.branch_runner)?; + let initial_seq = sequence_plan.initial(); + + let run_depth = + invariant_run_depth(&config, &mut invariant_test.test_data.branch_runner); + + let mut corpus_run = Option::::None; + let mut frontier_run = (frontier_limit > 0).then(|| RecordedCallSequence { + inputs: Vec::with_capacity(run_depth as usize), + cmp_seq: Vec::with_capacity(run_depth as usize), + }); // Create current invariant run data. let mut current_run = InvariantTestRun::new( initial_seq[0].clone(), // Before each run, we must reset the backend state. - self.executor.clone(), - self.config.depth as usize, + executor.clone(), + run_depth as usize, ); // We stop the run immediately if we have reverted, and `fail_on_revert` is set. - if self.config.fail_on_revert && invariant_test.reverts() > 0 { + if config.fail_on_revert && invariant_test.reverts() > 0 { + campaign_state.request_terminal_stop(); return Err(eyre!("call reverted")); } - while current_run.depth < self.config.depth { - // Check if the timeout has been reached. - if timer.is_timed_out() { - // Since we never record a revert here the test is still considered - // successful even though it timed out. We *want* - // this behavior for now, so that's ok, but - // future developers should be aware of this. - break 'stop; - } - - let tx = current_run - .inputs - .last() - .ok_or_else(|| eyre!("no input generated to call fuzzed target."))?; - - // Execute call from the randomly generated sequence without committing state. - // State is committed only if call is not a magic assume. - let mut call_result = current_run - .executor - .call_raw( - tx.sender, - tx.call_details.target, - tx.call_details.calldata.clone(), - U256::ZERO, - ) - .map_err(|e| eyre!(format!("Could not make raw evm call: {e}")))?; - - let discarded = call_result.result.as_ref() == MAGIC_ASSUME; - if self.config.show_metrics { - invariant_test.record_metrics(tx, call_result.reverted, discarded); - } - - // Collect line coverage from last fuzzed call. - invariant_test.merge_coverage(call_result.line_coverage.clone()); - // If coverage guided fuzzing is enabled then merge edge count with current history - // map and set new coverage in current run. - if self.config.corpus_dir.is_some() { - let (new_coverage, is_edge) = - call_result.merge_edge_coverage(&mut self.history_map); - if new_coverage { - current_run.new_coverage = true; - corpus_manager.update_seen_metrics(is_edge); + let mut call_cmp_values = Vec::new(); + let mut call_new_coverage = false; + let mut coverage_prefix_len = 0; + let mut assertion_failure = false; + let mut pre_merge_edges_hash = None; + let mut handler = None; + let mut abort_campaign = false; + let sequence_outcome = call_campaign.run_sequence( + &mut current_run, + run_depth, + |run| { + let tx = run.inputs.last_mut().expect("campaign always has a current input"); + (&mut run.executor, tx) + }, + |run| run.depth, + |_| campaign_state.should_stop(), + |current_run, event| { + match event { + CampaignEvent::Feedback(call_result) => { + let current_tx = current_run.inputs.last().ok_or_else(|| { + eyre!("no input generated to call fuzzed target.") + })?; + let sel_bytes: [u8; 4] = current_tx + .call_details + .calldata + .get(..4) + .and_then(|selector| selector.try_into().ok()) + .unwrap_or_default(); + handler = + Some((current_tx.call_details.target, Selector::from(sel_bytes))); + if let Some(fuzzer) = + current_run.executor.inspector_mut().fuzzer.as_mut() + { + invariant_test.fuzz_state.collect_fuzzer_values(fuzzer); + } + call_cmp_values = call_result.evm_cmp_values.take().unwrap_or_default(); + let discarded = call_result.result.as_ref() == MAGIC_ASSUME; + if config.show_metrics { + invariant_test.record_metrics( + current_tx, + call_result.reverted, + discarded, + ); + } + HitMaps::merge_opt( + &mut current_run.line_coverage, + call_result.line_coverage.take(), + ); + assertion_failure = !discarded + && did_fail_on_assert(call_result, &call_result.state_changeset); + pre_merge_edges_hash = assertion_failure + .then(|| error::snapshot_edge_fingerprint(call_result)) + .flatten(); + call_new_coverage = corpus_manager.merge_edge_coverage(call_result); + if call_new_coverage { + current_run.new_coverage = true; + } + let observed_calls = std::mem::take(&mut call_result.observed_calls); + if call_new_coverage && !observed_calls.is_empty() { + observed_call_entries.push((observed_calls, current_tx.clone())); + } + } + CampaignEvent::Check { result, kind, should_check } => { + let mut result = + result.take().expect("campaign check result is available"); + if kind == CampaignCallKind::AssumptionRejected { + current_run.inputs.pop(); + current_run.rejects += 1; + if current_run.rejects > config.max_assume_rejects { + invariant_test.set_error( + invariant_contract.anchor(), + InvariantFuzzError::MaxAssumeRejects( + config.max_assume_rejects, + ), + ); + campaign_state.request_terminal_stop(); + abort_campaign = true; + return Ok(CampaignControl::Stop); + } + return Ok(CampaignControl::Continue); + } + debug_assert_eq!(kind, CampaignCallKind::Accepted); + if let Some(frontier_run) = &mut frontier_run { + frontier_run.inputs.push( + current_run + .inputs + .last() + .expect("accepted call has a campaign input") + .clone(), + ); + frontier_run.cmp_seq.push(call_cmp_values.clone()); + } + if config.corpus.is_coverage_guided() { + let preserve_revert = + invariant_contract.is_optimization() || config.has_delay(); + let retain_for_corpus = + !result.reverted || preserve_revert || call_new_coverage; + if let Some(corpus_run) = &mut corpus_run { + if retain_for_corpus { + corpus_run.inputs.push( + current_run + .inputs + .last() + .expect("accepted call has a campaign input") + .clone(), + ); + corpus_run + .cmp_seq + .push(std::mem::take(&mut call_cmp_values)); + } + } else if result.reverted && !preserve_revert && call_new_coverage { + let mut cmp_seq = std::mem::take(&mut current_run.cmp_seq); + cmp_seq.push(std::mem::take(&mut call_cmp_values)); + corpus_run = Some(RecordedCallSequence { + inputs: current_run.inputs.clone(), + cmp_seq, + }); + } else if !result.reverted || preserve_revert { + current_run.cmp_seq.push(std::mem::take(&mut call_cmp_values)); + } + if call_new_coverage { + coverage_prefix_len = corpus_run + .as_ref() + .map_or(current_run.inputs.len(), |history| { + history.inputs.len() + }); + } + } + let (handler_target, handler_selector) = + handler.take().expect("feedback precedes campaign checks"); + let mut state_changeset = std::mem::take(&mut result.state_changeset); + if !result.reverted { + let mapping_slots = current_run + .executor + .inspector() + .fuzzer + .as_ref() + .and_then(|fuzzer| fuzzer.mapping_slots.as_ref()); + collect_data( + &invariant_test, + &mut state_changeset, + current_run.inputs.last().expect("checked above"), + &result, + run_depth, + mapping_slots, + ); + } + + let created_before = current_run.created_contracts.len(); + if let Err(error) = + &invariant_test.targeted_contracts.collect_created_contracts( + &state_changeset, + project_contracts, + setup_contracts, + &campaign_seed.artifact_filters, + &mut current_run.created_contracts, + ) + { + warn!(target: "forge::test", "{error}"); + } + invariant_test.invalidate_metric_key_cache( + ¤t_run.created_contracts[created_before..], + ); + current_run + .fuzz_runs + .push(FuzzCase { gas: result.gas_used, stipend: result.stipend }); + + let continues = if should_check { + let outcome = can_continue( + &invariant_contract, + &mut invariant_test, + current_run, + &config, + result, + &state_changeset, + handler_target, + handler_selector, + assertion_failure, + pre_merge_edges_hash, + ) + .map_err(|error| eyre!(error.to_string()))?; + run_cancelled = outcome.cancelled; + outcome.continues + } else { + if result.reverted { + invariant_test.test_data.failures.reverts += 1; + } + if assertion_failure { + let call_reverted = result.reverted; + error::record_handler_assertion_bug( + &invariant_contract, + &config, + &invariant_test.targeted_contracts, + &mut invariant_test.test_data.failures, + &mut current_run.inputs, + handler_target, + handler_selector, + pre_merge_edges_hash, + result, + call_reverted, + invariant_contract.is_optimization(), + ); + true + } else if result.reverted && config.fail_on_revert { + let anchor = invariant_contract.anchor(); + let case_data = error::InvariantRunCtx { + contract: &invariant_contract, + config: &config, + targeted_contracts: &invariant_test.targeted_contracts, + calldata: ¤t_run.inputs, + } + .failed_case(anchor, config.fail_on_revert, false, result, &[]); + invariant_test.test_data.failures.record_failure( + anchor, + InvariantFuzzError::Revert(case_data), + ); + false + } else { + if result.reverted + && !invariant_contract.is_optimization() + && !config.has_delay() + { + current_run.inputs.pop(); + } + true + } + }; + + if run_cancelled { + return Ok(CampaignControl::Stop); + } + if !continues || current_run.depth == run_depth - 1 { + current_run.save_last_run_inputs = true; + } + if !continues { + if invariant_contract.invariant_fns.len() == 1 + || config.fail_on_revert + { + campaign_state.request_terminal_stop(); + stop_after_run = true; + } + return Ok(CampaignControl::Stop); + } + } + CampaignEvent::Advance => current_run.depth += 1, + CampaignEvent::Next { discarded, depth } => { + current_run.inputs.push(sequence_plan.next( + &mut invariant_test.test_data.branch_runner, + discarded, + depth as usize, + )?); + } + CampaignEvent::PostCheck => { + // Multi-predicate campaigns keep running after earlier failures, but + // the hook must still execute on subsequent clean runs. + if !abort_campaign + && !run_cancelled + && invariant_contract.call_after_invariant + && invariant_test.test_data.failures.invariant_count() + == failures_before_run + { + let (broken, hook_cancelled) = assert_after_invariant( + &invariant_contract, + &mut invariant_test, + current_run, + &config, + ) + .map_err(|_| eyre!("Failed to call afterInvariant"))?; + if hook_cancelled { + run_cancelled = true; + } else if broken.is_some() { + current_run.save_last_run_inputs = true; + } + } + } } - } + Ok(CampaignControl::Continue) + }, + )?; + if sequence_outcome == CampaignSequenceOutcome::Cancelled { + // A timed-out partial run remains successful, matching the previous worker + // behavior, but it must not be persisted or counted. + run_cancelled = true; + } + if abort_campaign { + break 'stop; + } - if discarded { - current_run.inputs.pop(); - current_run.assume_rejects_counter += 1; - if current_run.assume_rejects_counter > self.config.max_assume_rejects { - invariant_test.set_error(InvariantFuzzError::MaxAssumeRejects( - self.config.max_assume_rejects, - )); - break 'stop; - } - } else { - // Commit executed call result. - current_run.executor.commit(&mut call_result); - - // Collect data for fuzzing from the state changeset. - // This step updates the state dictionary and therefore invalidates the - // ValueTree in use by the current run. This manifestsitself in proptest - // observing a different input case than what it was called with, and creates - // inconsistencies whenever proptest tries to use the input case after test - // execution. - // See . - let mut state_changeset = call_result.state_changeset.clone(); - if !call_result.reverted { - collect_data( - &invariant_test, - &mut state_changeset, - tx, - &call_result, - self.config.depth, - ); - } + // The worker which requested a terminal stop for its own failure still owns a + // complete failing run. All other campaign stops discard the partial run before any + // corpus persistence or accounting. + if campaign_state.should_stop() && !stop_after_run { + run_cancelled = true; + } - // Collect created contracts and add to fuzz targets only if targeted contracts - // are updatable. - if let Err(error) = - &invariant_test.targeted_contracts.collect_created_contracts( - &state_changeset, - self.project_contracts, - self.setup_contracts, - &self.artifact_filters, - &mut current_run.created_contracts, - ) - { - warn!(target: "forge::test", "{error}"); - } - current_run.fuzz_runs.push(FuzzCase { - calldata: tx.call_details.calldata.clone(), - gas: call_result.gas_used, - stipend: call_result.stipend, - }); - - // Determine if test can continue or should exit. - let result = can_continue( + if run_cancelled { + let completed_finding = + invariant_test.test_data.failures.revision() != failures_revision; + if completed_finding { + record_new_invariant_failures( + campaign_state, &invariant_contract, - &invariant_test, - &mut current_run, - &self.config, - call_result, - &state_changeset, - ) - .map_err(|e| eyre!(e.to_string()))?; - if !result.can_continue || 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 { - break 'stop; - } - - invariant_test.set_last_call_results(result.call_result); - current_run.depth += 1; + &invariant_test.test_data.failures, + ); + campaign_state.sync_handler_failures(&invariant_test.test_data.failures); + } else { + invariant_test.test_data.failures.clone_from(&failures_checkpoint); } + break 'stop; + } - current_run.inputs.push(corpus_manager.generate_next_input( - &invariant_test, - &initial_seq, - discarded, - current_run.depth as usize, - )?); + if invariant_test.test_data.failures.invariant_count() > failures_before_run { + record_new_invariant_failures( + campaign_state, + &invariant_contract, + &invariant_test.test_data.failures, + ); + } + if invariant_test.test_data.failures.handler_count() + > failures_checkpoint.handler_count() + { + campaign_state.sync_handler_failures(&invariant_test.test_data.failures); } - // Extend corpus with current run data. - corpus_manager.collect_inputs(¤t_run); + for (observed_calls, parent_tx) in observed_call_entries { + corpus_manager.hoist_observed_calls( + &observed_calls, + &parent_tx, + &invariant_test.targeted_contracts, + &campaign_seed.sender_filters, + CorpusInsertionMode::Live, + ); + } - // Call `afterInvariant` only if it is declared and test didn't fail already. - if invariant_contract.call_after_invariant && !invariant_test.has_errors() { - assert_after_invariant( - &invariant_contract, - &invariant_test, - ¤t_run, - &self.config, - ) - .map_err(|_| eyre!("Failed to call afterInvariant"))?; + // Extend corpus only after the run and its optional hook have completed. + if let Some(frontier_run) = &frontier_run { + frontier_recorder.capture_sequence(&frontier_run.inputs, &frontier_run.cmp_seq); + } + let optimization = current_run.optimization_value.map(|v| { + let prefix = current_run.inputs[..current_run.optimization_prefix_len].to_vec(); + (v, prefix) + }); + let mut corpus_inputs = corpus_run + .as_ref() + .map_or(current_run.inputs.as_slice(), |history| history.inputs.as_slice()); + let mut corpus_cmp_seq = corpus_run + .as_ref() + .map_or(current_run.cmp_seq.as_slice(), |history| history.cmp_seq.as_slice()); + if current_run.new_coverage && !invariant_contract.is_optimization() { + corpus_inputs = &corpus_inputs[..coverage_prefix_len]; + corpus_cmp_seq = &corpus_cmp_seq[..coverage_prefix_len]; + } + if worker_count > 1 { + corpus_manager.process_inputs_for_campaign( + corpus_inputs, + corpus_cmp_seq, + current_run.new_coverage, + optimization, + ); + } else { + corpus_manager.process_inputs( + corpus_inputs, + corpus_cmp_seq, + current_run.new_coverage, + optimization, + ); } // End current invariant test run. - invariant_test.end_run(current_run, self.config.gas_report_samples as usize); - + if current_run.save_last_run_inputs { + invariant_test.set_last_run_inputs(¤t_run.inputs); + } + if let Some(value) = current_run.optimization_value { + invariant_test.update_optimization_value( + value, + ¤t_run.inputs[..current_run.optimization_prefix_len], + ); + } + invariant_test.merge_line_coverage(current_run.line_coverage.take()); + for fuzz_run in ¤t_run.fuzz_runs { + campaign_state.record_call(fuzz_run.gas); + } + current_run.drop_corpus_payloads(); + invariant_test.end_run(current_run, gas_report_samples); + runs += 1; + let total_runs = campaign_state.increment_runs(); + debug_assert!( + campaign_state.is_timed_campaign() || total_runs <= config.runs, + "worker runs were not distributed correctly" + ); if let Some(progress) = progress { - // If running with progress then increment completed runs. progress.inc(1); - // Display metrics in progress bar. - if self.config.corpus_dir.is_some() { - progress.set_message(format!("{}", &corpus_manager.metrics)); + campaign_state.sync_handler_failures(&invariant_test.test_data.failures); + // Display current best value, corpus metrics, and failure counts. + let best = invariant_test.test_data.optimization_best_value; + let failure_metrics = campaign_state.failure_metrics(); + let broken = failure_metrics.unique_failures.len(); + let handler_bugs = failure_metrics.broken_handlers; + let total_invariants = invariant_contract.invariant_fns.len(); + if edge_coverage_enabled || best.is_some() || broken > 0 || handler_bugs > 0 { + let mut msg = String::new(); + if let Some(best) = best { + msg.push_str(&format!("best: {best}")); + } + if edge_coverage_enabled { + if !msg.is_empty() { + msg.push_str(", "); + } + msg.push_str(&format!("{}", corpus_manager.metrics)); + match corpus_manager.time_since_new_edge() { + Some(elapsed) => msg.push_str(&format!( + "\n - time since new edge: {:.1}s", + elapsed.as_secs_f64() + )), + None => msg.push_str("\n - time since new edge: never"), + } + } + if broken > 0 { + if !msg.is_empty() { + msg.push_str(", "); + } + msg.push_str(&format!("❌ {broken}/{total_invariants} broken")); + } + if handler_bugs > 0 { + if !msg.is_empty() { + msg.push_str(", "); + } + msg.push_str(&format!("⚠ {handler_bugs} handler bug(s)")); + } + let msg = + if worker_count > 1 { format!("[w{}] {msg}", plan.worker_id) } else { msg }; + progress.set_message(msg); } - } else if self.config.corpus_dir.is_some() - && last_metrics_report.elapsed() > DURATION_BETWEEN_METRICS_REPORT + } else if edge_coverage_enabled + && campaign_state.should_emit_metrics_report(DURATION_BETWEEN_METRICS_REPORT) { - // Display metrics inline if corpus dir set. - let metrics = json!({ - "timestamp": SystemTime::now() - .duration_since(UNIX_EPOCH)? - .as_secs(), - "invariant": invariant_contract.invariant_function.name, - "metrics": &corpus_manager.metrics, - }); + campaign_state.sync_handler_failures(&invariant_test.test_data.failures); + let failure_metrics = campaign_state.failure_metrics(); + let (total_txs, total_gas) = campaign_state.throughput_totals(); + let throughput = InvariantThroughputMetrics { total_txs, total_gas }; + // Display corpus metrics inline as JSON. + let metrics = build_invariant_progress_json( + InvariantProgressContext { + timestamp_secs: SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(), + contract_name: invariant_contract.name, + optimization_best: invariant_test.test_data.optimization_best_value, + throughput, + elapsed: campaign_state.elapsed(), + worker_id: plan.worker_id, + worker_count, + time_since_new_edge: corpus_manager.time_since_new_edge(), + }, + &corpus_manager.metrics, + &failure_metrics, + ); let _ = sh_println!("{}", serde_json::to_string(&metrics)?); - last_metrics_report = Instant::now(); } - runs += 1; + if stop_after_run { + break 'stop; + } } trace!(?fuzz_fixtures); invariant_test.fuzz_state.log_stats(); - let result = invariant_test.execution_data.into_inner(); - Ok(InvariantFuzzTestResult { - error: result.failures.error, - cases: result.fuzz_cases, - reverts: result.failures.reverts, - last_run_inputs: result.last_run_inputs, - gas_report_traces: result.gas_report_traces, - line_coverage: result.line_coverage, - metrics: result.metrics, - failed_corpus_replays: corpus_manager.failed_replays(), - }) + // Campaign-local terminal stops and deadlines must not suppress post-campaign shrinking. + executor.inspector_mut().set_early_exit(campaign_state.early_exit().clone()); + Self::shrink_handler_failures( + &config, + &executor, + &mut invariant_test.test_data, + progress, + campaign_state.early_exit(), + ); + + // Move out the final test data and drop worker-local fuzz state before returning this + // worker's aggregate output. Long invariant campaigns can leave large dictionaries and + // target state behind; once shrinking is complete, only `test_data` is needed. + let InvariantTest { fuzz_state: _, targeted_contracts: _, test_data: result } = + invariant_test; + let reverts = result.failures.reverts; + let (errors, handler_errors) = result.failures.partition(); + let worker_result = InvariantFuzzTestResult::new( + errors, + handler_errors, + result.runs, + result.calls, + reverts, + result.last_run_inputs, + result.gas_report_traces, + result.line_coverage, + result.metrics.into_iter().collect(), + if plan.worker_id == 0 { corpus_manager.failed_replays } else { 0 }, + 1, + result.optimization_best_value, + result.optimization_best_sequence, + ); + drop(corpus_manager); + let reported_plan = if campaign_state.is_timed_campaign() { + InvariantWorkerPlan { runs, ..plan } + } else { + // Sharded campaigns must report the original assigned range. Early worker exit changes + // the number of executed runs, but it must not shrink `plan.runs`: following workers' + // `first_global_run` offsets were computed from the original partition. + plan + }; + Ok(( + InvariantWorkerOutput { plan: reported_plan, result: worker_result }, + frontier_recorder.into_frontiers(), + )) } - /// Prepares certain structures to execute the invariant tests: - /// * Invariant Fuzz Test. - /// * Invariant Corpus Manager. - fn prepare_test( + fn shrink_handler_failures( + config: &InvariantConfig, + executor: &Executor, + result: &mut InvariantTestData, + progress: Option<&ProgressBar>, + early_exit: &EarlyExit, + ) { + let total = result.failures.handler_count(); + if total == 0 { + return; + } + + for (idx, error) in result.failures.handler_failures_mut().enumerate() { + if early_exit.should_stop() { + break; + } + let Some(failure) = error.as_handler_assertion_mut() else { + continue; + }; + let shrink_progress = shrink::ShrinkProgress::new( + config, + progress, + &format!("handler {:#x}::{}", failure.reverter, failure.selector), + Some((idx + 1, total)), + None, + false, + ); + match shrink::shrink_handler_sequence( + config, + &failure.call_sequence, + failure.edge_fingerprint, + executor, + &shrink_progress, + early_exit, + ) { + Ok(shrunk) if !shrunk.is_empty() => { + failure.call_sequence = shrunk; + } + Ok(_) => {} + Err(e) => trace!(target: "forge::test", "handler shrink failed: {e}"), + } + } + } + + fn prepare_campaign_seed( &mut self, invariant_contract: &InvariantContract<'_>, - fuzz_fixtures: &FuzzFixtures, - deployed_libs: &[Address], - ) -> Result<(InvariantTest, TxCorpusManager)> { - // Finds out the chosen deployed contracts and/or senders. + initial_handler_failures: std::collections::HashMap< + (Address, Selector), + InvariantFuzzError, + >, + ) -> Result { self.select_contract_artifacts(invariant_contract.address)?; - let (targeted_senders, targeted_contracts) = + let (sender_filters, targeted_contracts) = self.select_contracts_and_senders(invariant_contract.address)?; + let targets_are_updatable = targeted_contracts.is_updatable; + let targeted_contracts = targeted_contracts.targets().clone(); + + Ok(InvariantCampaignSeed { + artifact_filters: self.artifact_filters.clone(), + sender_filters, + targeted_contracts, + targets_are_updatable, + initial_handler_failures, + }) + } - // Stores fuzz state for use with [fuzz_calldata_from_state]. - let fuzz_state = EvmFuzzState::new( - self.executor.backend().mem_db(), - self.config.dictionary, - deployed_libs, + /// Prepares worker-local structures to execute an invariant campaign slice. + #[allow(clippy::too_many_arguments)] + fn prepare_worker( + executor: &mut Executor, + plan: InvariantWorkerPlan, + worker_count: usize, + invariant_contract: &InvariantContract<'_>, + fuzz_fixtures: &FuzzFixtures, + fuzz_state: EvmFuzzState, + runner: &TestRunner, + config: &InvariantConfig, + campaign_seed: &InvariantCampaignSeed, + corpus_seed: WorkerCorpusSeed, + ) -> Result<(InvariantTest, WorkerCorpus)> { + let fuzz_state = fuzz_state.into_invariant(); + let targeted_contracts = FuzzRunIdentifiedContracts::new( + campaign_seed.targeted_contracts.clone(), + campaign_seed.targets_are_updatable, ); + executor.inspector_mut().collect_evm_cmp_log(invariant_worker_collects_evm_cmp_log( + config, + plan.worker_id, + worker_count, + )); // Creates the invariant strategy. - let strategy = invariant_strat( + let generator = TxGenerator::invariant( fuzz_state.clone(), - targeted_senders, - targeted_contracts.clone(), - self.config.dictionary.dictionary_weight, - fuzz_fixtures.clone(), - ) - .no_shrink(); - - // Allows `override_call_strat` to use the address given by the Fuzzer inspector during - // EVM execution. - let mut call_generator = None; - if self.config.call_override { - let target_contract_ref = Arc::new(RwLock::new(Address::ZERO)); - - call_generator = Some(RandomCallGenerator::new( - invariant_contract.address, - self.runner.clone(), - override_call_strat( - fuzz_state.clone(), - targeted_contracts.clone(), - target_contract_ref.clone(), - fuzz_fixtures.clone(), - ), - target_contract_ref, - )); - } + campaign_seed.sender_filters.clone(), + targeted_contracts.clone(), + config.clone(), + fuzz_fixtures.clone(), + ); - self.executor.inspector_mut().fuzzer = - Some(Fuzzer { call_generator, fuzz_state: fuzz_state.clone(), collect: true }); + // If any of the targeted contracts have the storage layout enabled then we can sample + // mapping values. To accomplish, we need to record the mapping storage slots and keys. + let mapping_slots = targeted_contracts + .targets() + .iter() + .any(|(_, t)| t.storage_layout.is_some()) + .then(AddressMap::default); + + // Set up fuzzer WITHOUT call_generator initially. + // We defer call_override until after the initial invariant check to avoid + // injecting random calls during setup which would break the invariant assertion. + let extra_cheatcode_addresses = executor.inspector().extra_cheatcode_addresses(); + executor.inspector_mut().set_fuzzer( + Fuzzer::new(config.dictionary.max_fuzz_dictionary_values, mapping_slots) + .with_extra_cheatcode_addresses(extra_cheatcode_addresses) + .with_call_recording(config.corpus.is_coverage_guided()), + ); // Let's make sure the invariant is sound before actually starting the run: // We'll assert the invariant in its initial state, and if it fails, we'll // already know if we can early exit the invariant run. // 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( + // Seed disk-recovered handler bugs so live counters reflect them from tick 0. + for (&(addr, sel), err) in &campaign_seed.initial_handler_failures { + failures.seed_handler_failure(addr, sel, err.clone()); + } + invariant_preflight_check( invariant_contract, - &self.config, + config, &targeted_contracts, - &self.executor, + executor, &[], &mut failures, )?; - if let Some(error) = failures.error { - return Err(eyre!(error.revert_reason().unwrap_or_default())); + if let Some(fuzzer) = executor.inspector_mut().fuzzer.as_mut() { + fuzz_state.collect_fuzzer_values(fuzzer); + let _ = fuzzer.take_observed_calls(); } + let generator = foundry_evm_fuzz::sequence::SequenceGenerator::invariant_with_fixtures( + generator, + fuzz_state.clone(), + fuzz_fixtures.clone(), + targeted_contracts.clone(), + &config.corpus, + )?; + let mut worker = WorkerCorpus::from_seed( + plan.worker_id as usize, + config.corpus.clone(), + generator, + corpus_seed, + )?; - let corpus_manager = TxCorpusManager::new( - &self.config, - &invariant_contract.invariant_function.name, + if let Err(err) = worker.seed_from_test_traces( + invariant_contract, &targeted_contracts, - strategy.boxed(), - &self.executor, - &mut self.history_map, - )?; + &campaign_seed.sender_filters, + executor, + ) { + debug!(target: "corpus", %err, "failed to seed corpus from test traces"); + } - let invariant_test = InvariantTest::new( - fuzz_state, - targeted_contracts, - failures, - last_call_results, - self.runner.clone(), - ); + // NOW enable call_override after the initial invariant check and corpus trace seeding have + // passed. This allows `override_call_strat` to inject calls during actual fuzz runs for + // reentrancy vulnerability detection. + if config.call_override { + let target_contract_ref = Arc::new(RwLock::new(Address::ZERO)); + + // Collect handler addresses - these are the contracts we want to inject + // reentrancy into (simulating malicious receive() functions). + let handler_addresses: AddressSet = + targeted_contracts.targets().keys().copied().collect(); + let override_targets = targeted_contracts + .targets() + .iter() + .filter_map(|(address, contract)| { + let functions = contract.abi_fuzzed_functions().cloned().collect::>(); + (!functions.is_empty()).then_some((*address, functions)) + }) + .collect::>(); + + let call_generator = RandomCallGenerator::new( + invariant_contract.address, + handler_addresses, + runner.clone(), + override_call_strat( + fuzz_state.snapshot(), + override_targets, + target_contract_ref.clone(), + fuzz_fixtures.clone(), + config.dictionary.dictionary_weight, + config.corpus.payable_value_weight, + ), + target_contract_ref, + ); + + if let Some(fuzzer) = executor.inspector_mut().fuzzer.as_mut() { + fuzzer.call_generator = Some(call_generator); + } + } + + let mut invariant_test = + InvariantTest::new(fuzz_state, targeted_contracts, failures, runner.clone()); + + // Seed invariant test with previously persisted optimization state, + // but only if the current invariant is in optimization mode. Persisted optimization state + // is a master-worker artifact loaded with the initial corpus. + if invariant_contract.is_optimization() { + let (opt_best_value, opt_best_sequence) = worker.optimization_initial_state(); + if let Some(value) = opt_best_value { + invariant_test.update_optimization_value(value, &opt_best_sequence); + } + } - Ok((invariant_test, corpus_manager)) + Ok((invariant_test, worker)) } /// Fills the `InvariantExecutor` with the artifact identifier filters (in `path:name` string @@ -784,7 +2056,11 @@ impl<'a> InvariantExecutor<'a> { && self.artifact_filters.matches(identifier) }) .map(|(addr, (identifier, abi))| { - (*addr, TargetedContract::new(identifier.clone(), abi.clone())) + ( + *addr, + TargetedContract::new(identifier.clone(), abi.clone()) + .with_project_contracts(self.project_contracts), + ) }) .collect(); let mut contracts = TargetedContracts { inner: contracts }; @@ -792,15 +2068,52 @@ impl<'a> InvariantExecutor<'a> { self.target_interfaces(to, &mut contracts)?; self.select_selectors(to, &mut contracts)?; + self.exclude_default_storage_hook_callbacks(&mut contracts)?; // There should be at least one contract identified as target for fuzz runs. if contracts.is_empty() { eyre::bail!("No contracts to fuzz."); } + if contracts.fuzzed_functions().next().is_none() { + eyre::bail!("No functions to fuzz."); + } Ok((sender_filters, FuzzRunIdentifiedContracts::new(contracts, selected.is_empty()))) } + /// Excludes registered storage-hook callbacks from implicit invariant targets. + /// + /// Explicit selector filters take precedence, so users can still target a callback + /// intentionally. + fn exclude_default_storage_hook_callbacks( + &self, + targeted_contracts: &mut TargetedContracts, + ) -> Result<()> { + let Some(cheatcodes) = self.executor.inspector().cheatcodes.as_deref() else { + return Ok(()); + }; + let callbacks = + cheatcodes + .storage_load_hooks() + .chain(cheatcodes.storage_store_hooks()) + .map(|(_, hook)| (hook.callback_target, Selector::from(hook.callback_selector))) + .chain(cheatcodes.mapping_storage_store_hooks().map(|(_, _, hook)| { + (hook.callback_target, Selector::from(hook.callback_selector)) + })) + .collect::>(); + + for (target, selector) in callbacks { + let Some(contract) = targeted_contracts.get_mut(&target) else { continue }; + if !contract.targeted_functions.is_empty() + || contract.function_by_selector(selector).is_none() + { + continue; + } + contract.add_selectors([selector], true)?; + } + Ok(()) + } + /// Extends the contracts and selectors to fuzz with the addresses and ABIs specified in /// `targetInterfaces() -> (address, string[])[]`. Enables targeting of addresses that are /// not deployed during `setUp` such as when fuzzing in a forked environment. Also enables @@ -827,8 +2140,12 @@ impl<'a> InvariantExecutor<'a> { // Identifiers are specified as an array, so we loop through them. for identifier in artifacts { // Try to find the contract by name or identifier in the project's contracts. - if let Some(abi) = self.project_contracts.find_abi_by_name_or_identifier(identifier) + if let Some((_, contract_data)) = + self.project_contracts.iter().find(|(artifact, _)| { + &artifact.name == identifier || &artifact.identifier() == identifier + }) { + let abi = &contract_data.abi; combined // Check if there's an entry for the given key in the 'combined' map. .entry(*addr) @@ -836,9 +2153,16 @@ impl<'a> InvariantExecutor<'a> { .and_modify(|entry| { // Extend the ABI's function list with the new functions. entry.abi.functions.extend(abi.functions.clone()); + entry.rebuild_function_lookups(); }) // Otherwise insert it into the map. - .or_insert_with(|| TargetedContract::new(identifier.to_string(), abi)); + .or_insert_with(|| { + let mut contract = + TargetedContract::new(identifier.clone(), abi.clone()); + contract.storage_layout = + contract_data.storage_layout.as_ref().map(Arc::clone); + contract + }); } } } @@ -938,7 +2262,10 @@ impl<'a> InvariantExecutor<'a> { address ) })?; - entry.insert(TargetedContract::new(identifier.clone(), abi.clone())) + entry.insert( + TargetedContract::new(identifier.clone(), abi.clone()) + .with_project_contracts(self.project_contracts), + ) } }; contract.add_selectors(selectors.iter().copied(), should_exclude)?; @@ -949,26 +2276,25 @@ impl<'a> InvariantExecutor<'a> { /// Collects data from call for fuzzing. However, it first verifies that the sender is not an EOA /// before inserting it into the dictionary. Otherwise, we flood the dictionary with /// randomly generated addresses. -fn collect_data( +fn collect_data( invariant_test: &InvariantTest, - state_changeset: &mut HashMap, + state_changeset: &mut AddressMap, tx: &BasicTxDetails, - call_result: &RawCallResult, + call_result: &RawCallResult, run_depth: u32, + mapping_slots: Option<&AddressMap>, ) { - // Verify it has no code. - let mut has_code = false; - if let Some(Some(code)) = - state_changeset.get(&tx.sender).map(|account| account.info.code.as_ref()) - { - has_code = !code.is_empty(); - } - // We keep the nonce changes to apply later. - let mut sender_changeset = None; - if !has_code { - sender_changeset = state_changeset.remove(&tx.sender); - } + let sender_changeset = match state_changeset.entry(tx.sender) { + AddressMapEntry::Occupied(entry) => entry + .get() + .info + .code + .as_ref() + .is_none_or(|code| code.is_empty()) + .then(|| entry.remove()), + AddressMapEntry::Vacant(_) => None, + }; // Collect values from fuzzed call result and add them to fuzz dictionary. invariant_test.fuzz_state.collect_values_from_call( @@ -978,8 +2304,15 @@ fn collect_data( &call_result.logs, &*state_changeset, run_depth, + mapping_slots, ); + // Inject typed sancov trace-cmp operands into the fuzz dictionary. + if let Some(cmp_values) = &call_result.sancov_cmp_values { + invariant_test.fuzz_state.collect_typed_cmp_values( + cmp_values.iter().map(|s| (s.width, alloy_primitives::B256::from(s.value))), + ); + } // Re-add changes if let Some(changed) = sender_changeset { state_changeset.insert(tx.sender, changed); @@ -989,23 +2322,978 @@ fn collect_data( /// Calls the `afterInvariant()` function on a contract. /// Returns call result and if call succeeded. /// The state after the call is not persisted. -pub(crate) fn call_after_invariant_function( - executor: &Executor, +/// +/// Uses the handler-gate success check so a stale committed `GLOBAL_FAIL_SLOT` from a +/// previously-recorded handler bug doesn't false-positive this call (the slot is `1` from +/// the prior bug, but `afterInvariant` itself didn't write it in this changeset). +pub(crate) fn call_after_invariant_function( + executor: &Executor, to: Address, -) -> Result<(RawCallResult, bool), EvmError> { +) -> Result<(RawCallResult, bool), EvmError> { let calldata = Bytes::from_static(&IInvariantTest::afterInvariantCall::SELECTOR); let mut call_result = executor.call_raw(CALLER, to, calldata, U256::ZERO)?; - let success = executor.is_raw_call_mut_success(to, &mut call_result, false); + let success = executor.is_raw_call_mut_success_handler_gate(to, &mut call_result); Ok((call_result, success)) } /// Calls the invariant function and returns call result and if succeeded. -pub(crate) fn call_invariant_function( - executor: &Executor, +/// +/// Uses the handler-gate success check (same rationale as `call_after_invariant_function`): +/// the predicate is broken iff this call's own changeset writes `GLOBAL_FAIL_SLOT` (via `t()` / +/// `vm.assert*`) or the call reverts; a stale committed slot from a prior handler bug must not +/// poison every later predicate evaluation in the run. +pub(crate) fn call_invariant_function( + executor: &Executor, address: Address, calldata: Bytes, -) -> Result<(RawCallResult, bool)> { +) -> Result<(RawCallResult, bool)> { let mut call_result = executor.call_raw(CALLER, address, calldata, U256::ZERO)?; - let success = executor.is_raw_call_mut_success(address, &mut call_result, false); + let success = executor.is_raw_call_mut_success_handler_gate(address, &mut call_result); Ok((call_result, success)) } + +/// Executes an invariant replay fuzz call and returns the result. +/// +/// This applies invariant replay semantics: warp/roll deltas are applied before the call and the +/// requested value is clamped to the sender balance. It is intended for invariant sequence replay, +/// shrinking, and artifact validation rather than as a general raw-call helper. +/// +/// Applies any block timestamp (warp) and block number (roll) adjustments before the call. +pub fn execute_tx( + executor: &mut Executor, + tx: &BasicTxDetails, +) -> Result> { + super::campaign::execute_invariant_tx(executor, &mut tx.clone()) +} + +/// Executes an invariant replay call on a validation executor and registers created targets. +/// +/// This uses live campaign acceptance and commit behavior while allowing callers to update +/// updatable target sets before validating later calls in the same artifact. +pub fn execute_tx_and_register_created( + executor: &mut Executor, + tx: &BasicTxDetails, + targeted_contracts: &FuzzRunIdentifiedContracts, + dynamic_target_ctx: &DynamicTargetCtx<'_>, + created_contracts: &mut Vec
, +) -> Result<()> { + let (kind, call_result) = super::campaign::execute_invariant_replay_tx(executor, tx)?; + if kind == CampaignCallKind::AssumptionRejected { + return Err(eyre!("invariant replay prefix rejected by vm.assume")); + } + targeted_contracts.collect_created_contracts( + &call_result.state_changeset, + dynamic_target_ctx.project_contracts, + dynamic_target_ctx.setup_contracts, + dynamic_target_ctx.artifact_filters, + created_contracts, + )?; + Ok(()) +} + +#[cfg(test)] +mod tests { + + use super::*; + use crate::executors::ExecutorBuilder; + use alloy_evm::EvmEnv; + use foundry_cheatcodes::CheatsConfig; + use foundry_config::FuzzDictionaryConfig; + use foundry_evm_core::{backend::Backend, evm::EthEvmNetwork}; + use foundry_evm_fuzz::CallDetails; + use proptest::{ + prelude::any, + strategy::{Strategy, ValueTree}, + test_runner::Config, + }; + use revm::{ + bytecode::Bytecode, + context::{Block, TxEnv}, + database::InMemoryDB, + }; + use serde_json::json; + use std::{sync::mpsc, thread}; + + fn first_generated_u64(runner: &mut TestRunner) -> u64 { + any::().new_tree(runner).unwrap().current() + } + + fn test_runner() -> TestRunner { + TestRunner::new(Config { failure_persistence: None, ..Default::default() }) + } + + fn seeded_test_runner(seed: U256) -> TestRunner { + let config = Config { failure_persistence: None, ..Default::default() }; + let rng = TestRng::from_seed(RngAlgorithm::ChaCha, &seed.to_be_bytes::<32>()); + TestRunner::new_with_rng(config, rng) + } + + #[test] + fn assumption_rejection_restores_delayed_block_environment() { + let backend = Backend::::spawn(None).unwrap(); + let mut executor = ExecutorBuilder::default() + .inspectors(|stack| stack.cheatcodes(Arc::new(CheatsConfig::default()))) + .gas_limit(1 << 24) + .build(EvmEnv::default(), TxEnv::default(), backend, Default::default()); + let target = Address::repeat_byte(0x11); + let mut code = vec![0x6e]; // PUSH15. + code.extend_from_slice(MAGIC_ASSUME); + code.extend_from_slice(&[0x60, 0x00, 0x52, 0x60, 0x0f, 0x60, 0x11, 0xf3]); + executor.set_code(target, Bytecode::new_raw(Bytes::from(code))).unwrap(); + + let initial_block = executor.evm_env().block_env.clone(); + let initial_cheatcode_block = + executor.inspector().cheatcodes.as_ref().unwrap().block.clone(); + let expected_timestamp = initial_block.timestamp() + U256::from(10); + let expected_number = initial_block.number() + U256::from(5); + let tx = BasicTxDetails { + warp: Some(U256::from(10)), + roll: Some(U256::from(5)), + sender: Address::ZERO, + call_details: CallDetails { target, calldata: Bytes::new(), value: None }, + }; + let mut state = (executor, tx); + let campaign = FuzzCampaign::new(FuzzCampaignMode::Invariant { + check_interval: 1, + optimization: false, + }); + + let outcome = campaign + .run_sequence( + &mut state, + 1, + |state| (&mut state.0, &mut state.1), + |_| 0, + |_| false, + |state, event| { + match event { + CampaignEvent::Feedback(_) => { + let block = &state.0.evm_env().block_env; + assert_eq!(block.timestamp(), expected_timestamp); + assert_eq!(block.number(), expected_number); + let block = state + .0 + .inspector() + .cheatcodes + .as_ref() + .unwrap() + .block + .as_ref() + .unwrap(); + assert_eq!(block.timestamp(), expected_timestamp); + assert_eq!(block.number(), expected_number); + } + CampaignEvent::Check { kind, .. } => { + assert_eq!(kind, CampaignCallKind::AssumptionRejected); + return Ok(CampaignControl::Stop); + } + _ => {} + } + Ok(CampaignControl::Continue) + }, + ) + .unwrap(); + + assert_eq!(outcome, CampaignSequenceOutcome::Stopped); + assert_eq!(state.0.evm_env().block_env, initial_block); + assert_eq!(state.0.inspector().cheatcodes.as_ref().unwrap().block, initial_cheatcode_block); + + let (kind, _) = + crate::executors::campaign::execute_invariant_replay_tx(&mut state.0, &state.1) + .unwrap(); + assert_eq!(kind, CampaignCallKind::AssumptionRejected); + assert_eq!(state.0.evm_env().block_env, initial_block); + assert_eq!(state.0.inspector().cheatcodes.as_ref().unwrap().block, initial_cheatcode_block); + } + + #[test] + fn invariant_worker_seed_preserves_master_seed_and_derives_workers() { + let seed = U256::from(0x1234); + + assert_eq!(invariant_worker_seed(seed, 0), seed); + assert_ne!(invariant_worker_seed(seed, 1), seed); + assert_ne!(invariant_worker_seed(seed, 1), invariant_worker_seed(seed, 2)); + assert_ne!(invariant_worker_seed(seed, 1), invariant_worker_seed(U256::from(0x5678), 1)); + } + + #[test] + fn invariant_worker_runner_preserves_seed_for_master_worker() { + let seed = U256::from(0x1234); + let mut seeded_runner = seeded_test_runner(seed); + let mut parent = test_runner(); + let mut worker = invariant_worker_runner(&mut parent, 0, Some(seed)); + + assert_eq!(first_generated_u64(&mut worker), first_generated_u64(&mut seeded_runner)); + } + + #[test] + fn invariant_worker_runner_uses_seed_independent_of_parent_rng_state() { + let seed = U256::from(0x1234); + let mut parent = test_runner(); + let mut advanced_parent = test_runner(); + let _ = first_generated_u64(&mut advanced_parent); + + let mut worker = invariant_worker_runner(&mut parent, 1, Some(seed)); + let mut worker_from_advanced_parent = + invariant_worker_runner(&mut advanced_parent, 1, Some(seed)); + + assert_eq!( + first_generated_u64(&mut worker), + first_generated_u64(&mut worker_from_advanced_parent) + ); + } + + #[test] + fn invariant_focus_seed_preserves_configured_seed() { + let configured_seed = U256::from(0x1234); + let mut parent = test_runner(); + + assert_eq!( + invariant_focus_seed(&mut parent, Some(configured_seed), 2), + Some(configured_seed) + ); + assert_eq!(invariant_focus_seed(&mut parent, Some(configured_seed), 1), None); + } + + #[test] + fn invariant_focus_seed_uses_parent_rng_when_unconfigured() { + let mut parent = seeded_test_runner(U256::ONE); + let mut matching_parent = seeded_test_runner(U256::ONE); + let mut different_parent = seeded_test_runner(U256::from(2)); + + let focus_seed = invariant_focus_seed(&mut parent, None, 2).unwrap(); + + assert_eq!(focus_seed, invariant_focus_seed(&mut matching_parent, None, 2).unwrap()); + assert_ne!(focus_seed, invariant_focus_seed(&mut different_parent, None, 2).unwrap()); + assert_eq!(invariant_focus_seed(&mut parent, None, 1), None); + } + + #[test] + fn invariant_progress_json_includes_throughput_fields() { + let throughput = InvariantThroughputMetrics { total_txs: 2, total_gas: 50 }; + + let payload = build_invariant_progress_json( + InvariantProgressContext { + timestamp_secs: 123, + contract_name: "InvariantContract", + optimization_best: Some(I256::try_from(42).unwrap()), + throughput, + elapsed: Duration::from_secs(10), + worker_id: 1, + worker_count: 4, + time_since_new_edge: Some(Duration::from_secs(3)), + }, + &json!({ "corpus_count": 7 }), + &InvariantFailureMetrics::default(), + ); + + assert_eq!(payload["timestamp"], json!(123)); + assert_eq!(payload["contract"], json!("InvariantContract")); + assert!(payload.get("invariant").is_none()); + assert_eq!(payload["metrics"]["corpus_count"], json!(7)); + assert_eq!(payload["metrics"]["broken_assertions"], json!(0)); + assert!(payload["metrics"].get("broken_handlers").is_none()); + assert_eq!(payload["total_txs"], json!(2)); + assert_eq!(payload["total_gas"], json!(50)); + assert_eq!(payload["tps"], json!(0.2)); + assert_eq!(payload["gps"], json!(5.0)); + assert!(payload.get("tx_per_sec").is_none()); + assert!(payload.get("gas_per_sec").is_none()); + assert_eq!(payload["worker"]["id"], json!(1)); + assert_eq!(payload["worker"]["count"], json!(4)); + assert_eq!(payload["optimization_best"], json!("42")); + } + + #[test] + fn invariant_worker_count_keeps_short_campaigns_single_worker() { + assert_eq!( + max_invariant_workers_for_campaign(0, DEFAULT_DEPTH_FOR_INVARIANT_WORKER_CAP), + 1 + ); + assert_eq!( + max_invariant_workers_for_campaign( + MIN_RUNS_PER_INVARIANT_WORKER - 1, + DEFAULT_DEPTH_FOR_INVARIANT_WORKER_CAP + ), + 1 + ); + assert_eq!( + max_invariant_workers_for_campaign( + MIN_RUNS_PER_INVARIANT_WORKER, + DEFAULT_DEPTH_FOR_INVARIANT_WORKER_CAP + ), + 1 + ); + assert_eq!( + max_invariant_workers_for_campaign( + MIN_RUNS_PER_INVARIANT_WORKER * 2, + DEFAULT_DEPTH_FOR_INVARIANT_WORKER_CAP + ), + 2 + ); + assert_eq!(max_invariant_workers_for_campaign(256, 100_000), 5); + } + + #[test] + fn invariant_run_depth_random_min_depth_zero_never_returns_zero() { + let mut runner = test_runner(); + let config = InvariantConfig { + depth: 8, + min_depth: 0, + depth_mode: InvariantDepthMode::Random, + ..Default::default() + }; + + for _ in 0..128 { + assert!((1..=8).contains(&invariant_run_depth(&config, &mut runner))); + } + } + + #[test] + fn invariant_run_depth_fixed_zero_preserves_zero() { + let mut runner = test_runner(); + let config = InvariantConfig { + depth: 0, + depth_mode: InvariantDepthMode::Fixed, + ..Default::default() + }; + + assert_eq!(invariant_run_depth(&config, &mut runner), 0); + } + + #[test] + fn invariant_worker_config_keeps_single_worker_default() { + let config = InvariantConfig::default(); + + assert_eq!( + invariant_worker_config(config, 0, 1).corpus.corpus_random_sequence_weight, + FuzzCorpusConfig::DEFAULT_CORPUS_RANDOM_SEQUENCE_WEIGHT + ); + } + + #[test] + fn invariant_worker_config_uses_one_exploratory_worker_with_default_config() { + let config = InvariantConfig::default(); + + assert_eq!( + invariant_worker_config(config.clone(), 0, 4).corpus.corpus_random_sequence_weight, + FuzzCorpusConfig::DEFAULT_CORPUS_RANDOM_SEQUENCE_WEIGHT + ); + assert_eq!( + invariant_worker_config(config.clone(), 1, 4).corpus.corpus_random_sequence_weight, + FuzzCorpusConfig::DEFAULT_CORPUS_RANDOM_SEQUENCE_WEIGHT + ); + assert_eq!( + invariant_worker_config(config.clone(), 2, 4).corpus.corpus_random_sequence_weight, + FuzzCorpusConfig::DEFAULT_CORPUS_RANDOM_SEQUENCE_WEIGHT + ); + assert_eq!( + invariant_worker_config(config, 3, 4).corpus.corpus_random_sequence_weight, + FuzzCorpusConfig::ENSEMBLE_CORPUS_RANDOM_SEQUENCE_WEIGHT + ); + } + + #[test] + fn invariant_worker_config_keeps_two_worker_campaign_on_default() { + let config = InvariantConfig::default(); + + assert_eq!( + invariant_worker_config(config.clone(), 0, 2).corpus.corpus_random_sequence_weight, + FuzzCorpusConfig::DEFAULT_CORPUS_RANDOM_SEQUENCE_WEIGHT + ); + assert_eq!( + invariant_worker_config(config, 1, 2).corpus.corpus_random_sequence_weight, + FuzzCorpusConfig::DEFAULT_CORPUS_RANDOM_SEQUENCE_WEIGHT + ); + } + + #[test] + fn invariant_worker_config_uses_last_worker_for_three_worker_campaign() { + let config = InvariantConfig::default(); + + assert_eq!( + invariant_worker_config(config.clone(), 0, 3).corpus.corpus_random_sequence_weight, + FuzzCorpusConfig::DEFAULT_CORPUS_RANDOM_SEQUENCE_WEIGHT + ); + assert_eq!( + invariant_worker_config(config.clone(), 1, 3).corpus.corpus_random_sequence_weight, + FuzzCorpusConfig::DEFAULT_CORPUS_RANDOM_SEQUENCE_WEIGHT + ); + assert_eq!( + invariant_worker_config(config, 2, 3).corpus.corpus_random_sequence_weight, + FuzzCorpusConfig::ENSEMBLE_CORPUS_RANDOM_SEQUENCE_WEIGHT + ); + } + + #[test] + fn invariant_worker_config_preserves_explicit_corpus_random_sequence_weight() { + let config = InvariantConfig { + corpus: FuzzCorpusConfig { + corpus_random_sequence_weight: 25, + ..FuzzCorpusConfig::default() + }, + corpus_random_sequence_weight_configured: true, + ..InvariantConfig::default() + }; + + assert_eq!( + invariant_worker_config(config.clone(), 1, 4).corpus.corpus_random_sequence_weight, + 25 + ); + assert_eq!(invariant_worker_config(config, 0, 1).corpus.corpus_random_sequence_weight, 25); + + let config = InvariantConfig { + corpus_random_sequence_weight_configured: true, + ..InvariantConfig::default() + }; + + assert_eq!( + invariant_worker_config(config, 1, 4).corpus.corpus_random_sequence_weight, + FuzzCorpusConfig::DEFAULT_CORPUS_RANDOM_SEQUENCE_WEIGHT + ); + } + + #[test] + fn invariant_worker_count_preserves_fixed_workers() { + let mut config = InvariantConfig { + runs: MIN_RUNS_PER_INVARIANT_WORKER * 4, + workers: foundry_config::InvariantWorkers::Fixed( + std::num::NonZeroUsize::new(4).unwrap(), + ), + ..Default::default() + }; + assert_eq!(invariant_worker_count_with_threads(&config, 8, 1), 4); + + config.corpus.show_edge_coverage = true; + assert_eq!(invariant_worker_count_with_threads(&config, 8, 1), 4); + + config.corpus.show_edge_coverage = false; + config.corpus.corpus_dir = Some(std::path::PathBuf::from("corpus")); + assert_eq!(invariant_worker_count_with_threads(&config, 8, 1), 4); + + config.runs = MIN_RUNS_PER_INVARIANT_WORKER - 1; + config.timeout = None; + assert_eq!(invariant_worker_count_with_threads(&config, 8, 1), 4); + + config.timeout = Some(1); + assert_eq!(invariant_worker_count_with_threads(&config, 8, 4), 4); + } + + #[test] + fn invariant_worker_count_does_not_cap_configured_workers_by_available_threads() { + let config = InvariantConfig { + runs: MIN_RUNS_PER_INVARIANT_WORKER * 8, + workers: foundry_config::InvariantWorkers::Fixed( + std::num::NonZeroUsize::new(8).unwrap(), + ), + ..Default::default() + }; + + assert_eq!(invariant_worker_count_with_threads(&config, 4, 1), 8); + } + + #[test] + fn invariant_worker_count_splits_available_threads_for_auto_workers() { + let mut config = InvariantConfig { + runs: MIN_RUNS_PER_INVARIANT_WORKER * 4, + depth: DEFAULT_DEPTH_FOR_INVARIANT_WORKER_CAP, + workers: foundry_config::InvariantWorkers::Auto, + ..Default::default() + }; + + assert_eq!(invariant_worker_count_with_threads(&config, 4, 1), 4); + assert_eq!(invariant_worker_count_with_threads(&config, 8, 2), 4); + assert_eq!(invariant_worker_count_with_threads(&config, 8, 3), 2); + assert_eq!(invariant_worker_count_with_threads(&config, 3, 8), 1); + assert_eq!(invariant_worker_count_with_threads(&config, 0, 0), 1); + + config.runs = MIN_RUNS_PER_INVARIANT_WORKER - 1; + assert_eq!(invariant_worker_count_with_threads(&config, 8, 2), 1); + + config.depth = 100_000; + assert_eq!(invariant_worker_count_with_threads(&config, 8, 2), 4); + + config.timeout = Some(1); + assert_eq!(invariant_worker_count_with_threads(&config, 8, 2), 4); + } + + fn function(signature: &str) -> Function { + Function::parse(signature).unwrap() + } + + fn targeted_contract(identifier: &str, functions: Vec) -> TargetedContract { + let mut abi = alloy_json_abi::JsonAbi::new(); + for function in functions { + abi.functions.entry(function.name.clone()).or_default().push(function); + } + TargetedContract::new(identifier.to_string(), abi) + } + + #[test] + fn campaign_terminal_stop_interrupts_handler_without_accepting_run() { + const GAS_LIMIT: u64 = 1 << 24; + let invariant_address = Address::repeat_byte(0x11); + let handler_address = Address::repeat_byte(0x22); + let backend = Backend::::spawn(None).unwrap(); + let mut executor = ExecutorBuilder::default().gas_limit(GAS_LIMIT).build( + EvmEnv::default(), + TxEnv::default(), + backend, + Default::default(), + ); + // Return ABI-encoded `true` for the invariant predicate. + executor + .set_code( + invariant_address, + Bytecode::new_raw(Bytes::from_static(&[ + 0x60, 0x01, 0x60, 0x00, 0x52, 0x60, 0x20, 0x60, 0x00, 0xf3, + ])), + ) + .unwrap(); + // JUMPDEST; PUSH1 0; JUMP loops until the campaign stop reaches the inspector. + executor + .set_code( + handler_address, + Bytecode::new_raw(Bytes::from_static(&[0x5b, 0x60, 0x00, 0x56])), + ) + .unwrap(); + + let (handler_entered_tx, handler_entered_rx) = mpsc::channel(); + let (handler_release_tx, handler_release_rx) = mpsc::channel(); + executor.inspector_mut().set_early_exit_test_gate( + handler_entered_tx, + handler_release_rx, + 0, + ); + + let invariant = function("invariant_ok() view returns (bool)"); + let mut invariant_abi = alloy_json_abi::JsonAbi::new(); + invariant_abi.functions.entry(invariant.name.clone()).or_default().push(invariant.clone()); + let invariant_contract = InvariantContract::new( + invariant_address, + "InvariantTest", + vec![(&invariant, false)], + 0, + false, + &invariant_abi, + ); + + let handler = function("loopForever()"); + let mut handler_contract = targeted_contract("Handler", vec![handler.clone()]); + handler_contract.targeted_functions = vec![handler]; + let mut targeted_contracts = TargetedContracts::new(); + targeted_contracts.insert(handler_address, handler_contract); + let campaign_seed = InvariantCampaignSeed { + artifact_filters: ArtifactFilters::default(), + sender_filters: SenderFilters::new(vec![CALLER], Vec::new()), + targeted_contracts, + targets_are_updatable: false, + initial_handler_failures: Map::default(), + }; + + let config = + InvariantConfig { runs: 1, depth: 1, show_metrics: false, ..Default::default() }; + let campaign_state = InvariantCampaignState::new(EarlyExit::new(false), None); + let fuzz_state = + EvmFuzzState::new(&[], &InMemoryDB::default(), FuzzDictionaryConfig::default(), None); + let setup_contracts = ContractsByAddress::default(); + let project_contracts = ContractsByArtifact::default(); + let (result_tx, result_rx) = mpsc::channel(); + + let (handler_entered, result) = thread::scope(|scope| { + let handle = scope.spawn(|| { + let result = InvariantExecutor::::run_invariant_worker( + executor, + test_runner(), + config, + &setup_contracts, + &project_contracts, + InvariantWorkerPlan { worker_id: 0, first_global_run: 0, runs: 1 }, + invariant_contract, + &FuzzFixtures::default(), + fuzz_state, + None, + &campaign_state, + campaign_seed, + WorkerCorpusSeed::default(), + 1, + 1, + ); + let _ = result_tx.send(result); + }); + + let handler_entered = handler_entered_rx.recv_timeout(Duration::from_secs(1)).is_ok(); + campaign_state.request_terminal_stop(); + let _ = handler_release_tx.send(()); + + let result = result_rx.recv_timeout(Duration::from_secs(1)); + handle.join().unwrap(); + (handler_entered, result) + }); + assert!(handler_entered, "invariant handler did not begin EVM execution"); + let (output, _) = result.expect("invariant campaign did not observe early exit").unwrap(); + assert_eq!(output.result.runs, 0); + assert_eq!(output.result.calls, 0); + assert_eq!(campaign_state.total_runs(), 0); + assert_eq!(campaign_state.throughput_totals(), (0, 0)); + assert!(output.result.errors.is_empty()); + assert!(output.result.handler_errors.is_empty()); + assert!(output.result.last_run_inputs.is_empty()); + assert!(output.result.line_coverage.is_none()); + assert!(output.result.metrics.is_empty()); + assert!(output.result.optimization_best_value.is_none()); + } + + #[test] + fn invariant_focus_workers_stay_before_exploratory_tail_worker() { + assert_eq!(invariant_focus_worker_count(1), 0); + assert_eq!(invariant_focus_worker_count(2), 1); + assert_eq!(invariant_focus_worker_count(4), 1); + assert_eq!(invariant_focus_worker_count(8), 1); + assert_eq!(invariant_focus_worker_count(16), 2); + + assert_eq!(invariant_focus_worker_index(0, 1), None); + assert_eq!(invariant_focus_worker_index(1, 2), Some(0)); + assert_eq!(invariant_focus_worker_index(0, 4), None); + assert_eq!(invariant_focus_worker_index(2, 4), Some(0)); + assert_eq!(invariant_focus_worker_index(3, 4), None); + assert_eq!(invariant_focus_worker_index(13, 16), Some(0)); + assert_eq!(invariant_focus_worker_index(14, 16), Some(1)); + assert_eq!(invariant_focus_worker_index(15, 16), None); + assert_eq!(invariant_focus_worker_index(16, 16), None); + } + + #[test] + fn invariant_focus_narrows_to_one_effective_selector() { + let target = Address::repeat_byte(0x11); + let first = function("first(uint256)"); + let second = function("second(uint256)"); + let second_selector = second.selector(); + let mut contract = targeted_contract("Target", vec![first.clone(), second.clone()]); + contract.targeted_functions = vec![first, second]; + + let mut targets = TargetedContracts::new(); + targets.insert(target, contract); + + let focused = focused_targeted_contracts(&targets, 1, None).unwrap(); + let focused_functions = focused[&target].abi_fuzzed_functions().collect::>(); + + assert_eq!(focused.len(), 1); + assert_eq!(focused_functions.len(), 1); + assert_eq!(focused_functions[0].selector(), second_selector); + } + + #[test] + fn invariant_focus_seed_rotates_effective_selector() { + let target = Address::repeat_byte(0x55); + let first = function("first(uint256)"); + let second = function("second(uint256)"); + let third = function("third(uint256)"); + let third_selector = third.selector(); + let mut contract = + targeted_contract("Target", vec![first.clone(), second.clone(), third.clone()]); + contract.targeted_functions = vec![first, second, third]; + + let mut targets = TargetedContracts::new(); + targets.insert(target, contract); + + let focused = focused_targeted_contracts(&targets, 0, Some(U256::from(2))).unwrap(); + let focused_functions = focused[&target].abi_fuzzed_functions().collect::>(); + + assert_eq!(focused_functions.len(), 1); + assert_eq!(focused_functions[0].selector(), third_selector); + } + + #[test] + fn invariant_focus_freezes_dynamic_target_updates() { + let target = Address::repeat_byte(0x44); + let first = function("first(uint256)"); + let second = function("second(uint256)"); + let mut contract = targeted_contract("Target", vec![first.clone(), second.clone()]); + contract.targeted_functions = vec![first, second]; + + let mut targeted_contracts = TargetedContracts::new(); + targeted_contracts.insert(target, contract); + let campaign_seed = InvariantCampaignSeed { + artifact_filters: ArtifactFilters::default(), + sender_filters: SenderFilters::default(), + targeted_contracts, + targets_are_updatable: true, + initial_handler_failures: Map::default(), + }; + + let normal_worker = campaign_seed_for_worker( + &campaign_seed, + InvariantWorkerPlan { worker_id: 0, first_global_run: 0, runs: 1 }, + 2, + ); + let focus_worker = campaign_seed_for_worker( + &campaign_seed, + InvariantWorkerPlan { worker_id: 1, first_global_run: 1, runs: 1 }, + 2, + ); + + assert!(normal_worker.targets_are_updatable); + assert!(!focus_worker.targets_are_updatable); + } + + #[test] + fn invariant_focus_does_not_widen_target_selectors() { + let target = Address::repeat_byte(0x22); + let allowed = function("allowed(uint256)"); + let hidden = function("hidden(uint256)"); + let mut contract = targeted_contract("Target", vec![allowed.clone(), hidden]); + contract.targeted_functions = vec![allowed]; + + let mut targets = TargetedContracts::new(); + targets.insert(target, contract); + + assert!(focused_targeted_contracts(&targets, 0, None).is_none()); + } + + #[test] + fn invariant_focus_skips_excluded_selectors() { + let target = Address::repeat_byte(0x33); + let first = function("aaa(uint256)"); + let second = function("bbb(uint256)"); + let excluded = function("ccc(uint256)"); + let excluded_selector = excluded.selector(); + let mut contract = targeted_contract("Target", vec![first, second, excluded.clone()]); + contract.excluded_functions = vec![excluded]; + + let mut targets = TargetedContracts::new(); + targets.insert(target, contract); + + let focused = focused_targeted_contracts(&targets, 3, None).unwrap(); + let focused_functions = focused[&target].abi_fuzzed_functions().collect::>(); + + assert_eq!(focused_functions.len(), 1); + assert_ne!(focused_functions[0].selector(), excluded_selector); + } + + #[test] + fn invariant_focus_skips_excluded_targeted_selectors() { + let target = Address::repeat_byte(0x66); + let first = function("aaa(uint256)"); + let second = function("bbb(uint256)"); + let excluded = function("ccc(uint256)"); + let excluded_selector = excluded.selector(); + let mut contract = + targeted_contract("Target", vec![first.clone(), second.clone(), excluded.clone()]); + contract.targeted_functions = vec![first, second, excluded.clone()]; + contract.excluded_functions = vec![excluded]; + + let mut targets = TargetedContracts::new(); + targets.insert(target, contract); + + let focused = focused_targeted_contracts(&targets, 2, None).unwrap(); + let focused_functions = focused[&target].abi_fuzzed_functions().collect::>(); + + assert_eq!(focused_functions.len(), 1); + assert_ne!(focused_functions[0].selector(), excluded_selector); + } + + #[test] + fn invariant_worker_cmp_log_selection_uses_one_worker_per_campaign() { + use foundry_config::FuzzCorpusMutationWeights; + + let mut config = InvariantConfig::default(); + assert!(!invariant_worker_collects_evm_cmp_log(&config, 0, 1)); + + config.corpus.corpus_dir = Some("corpus".into()); + assert!(invariant_worker_collects_evm_cmp_log(&config, 0, 1)); + assert!(invariant_worker_collects_evm_cmp_log(&config, 0, 4)); + assert!(!invariant_worker_collects_evm_cmp_log(&config, 1, 4)); + assert!(!invariant_worker_collects_evm_cmp_log(&config, 3, 4)); + + config.corpus.mutation_weights = FuzzCorpusMutationWeights { + mutation_weight_splice: 1, + mutation_weight_repeat: 1, + mutation_weight_interleave: 1, + mutation_weight_prefix: 1, + mutation_weight_suffix: 1, + mutation_weight_abi: 1, + mutation_weight_cmp: 0, + }; + assert!(!invariant_worker_collects_evm_cmp_log(&config, 0, 1)); + + // All-zero configured weights resolve to the default mutation distribution. + config.corpus.mutation_weights = FuzzCorpusMutationWeights { + mutation_weight_splice: 0, + mutation_weight_repeat: 0, + mutation_weight_interleave: 0, + mutation_weight_prefix: 0, + mutation_weight_suffix: 0, + mutation_weight_abi: 0, + mutation_weight_cmp: 0, + }; + assert!(invariant_worker_collects_evm_cmp_log(&config, 0, 1)); + + config.corpus.sancov_edges = true; + assert!(!invariant_worker_collects_evm_cmp_log(&config, 0, 1)); + assert!(!invariant_worker_collects_evm_cmp_log(&config, 0, 4)); + } + + #[test] + fn timed_invariant_workers_are_not_bounded_by_assigned_runs() { + let plan = InvariantWorkerPlan { worker_id: 0, first_global_run: 0, runs: 1 }; + + let untimed = InvariantCampaignState::new(EarlyExit::new(false), None); + assert!(should_continue_invariant_worker(&untimed, 0, plan)); + assert!(!should_continue_invariant_worker(&untimed, 1, plan)); + + let timed = InvariantCampaignState::new(EarlyExit::new(false), Some(60)); + assert!(should_continue_invariant_worker(&timed, 0, plan)); + assert!(should_continue_invariant_worker(&timed, 1, plan)); + assert!(should_continue_invariant_worker(&timed, 10_000, plan)); + } + + #[test] + fn gas_report_samples_are_split_across_workers() { + assert_eq!(gas_report_samples_for_worker(0, 0, 4), 0); + assert_eq!(gas_report_samples_for_worker(8, 0, 4), 2); + assert_eq!(gas_report_samples_for_worker(8, 3, 4), 2); + assert_eq!(gas_report_samples_for_worker(10, 0, 4), 3); + assert_eq!(gas_report_samples_for_worker(10, 1, 4), 3); + assert_eq!(gas_report_samples_for_worker(10, 2, 4), 2); + assert_eq!(gas_report_samples_for_worker(10, 3, 4), 2); + assert_eq!(gas_report_samples_for_worker(3, 3, 4), 0); + } + + #[test] + fn invariant_progress_json_zero_elapsed_reports_zero_rates() { + let throughput = InvariantThroughputMetrics { total_txs: 1, total_gas: 21_000 }; + + let payload = build_invariant_progress_json( + InvariantProgressContext { + timestamp_secs: 456, + contract_name: "invariant_zero_elapsed", + optimization_best: None, + throughput, + elapsed: Duration::ZERO, + worker_id: 0, + worker_count: 1, + time_since_new_edge: None, + }, + &json!({ "corpus_count": 1 }), + &InvariantFailureMetrics::default(), + ); + + assert_eq!(payload["tps"], json!(0.0)); + assert_eq!(payload["gps"], json!(0.0)); + assert!(payload.get("optimization_best").is_none()); + // No edge seen yet -> `null`. + assert_eq!(payload["worker"]["secs_since_new_edge"], json!(null)); + } + + #[test] + fn invariant_progress_json_reports_secs_since_new_edge() { + let payload = build_invariant_progress_json( + InvariantProgressContext { + timestamp_secs: 1, + contract_name: "TestContract", + optimization_best: None, + throughput: InvariantThroughputMetrics::default(), + elapsed: Duration::from_secs(1), + worker_id: 2, + worker_count: 4, + time_since_new_edge: Some(Duration::from_millis(1500)), + }, + &json!({ "corpus_count": 1 }), + &InvariantFailureMetrics::default(), + ); + + assert_eq!(payload["worker"]["id"], json!(2)); + assert_eq!(payload["worker"]["secs_since_new_edge"], json!(1.5)); + } + + #[test] + fn invariant_progress_json_rounds_fractional_rates() { + let payload = build_invariant_progress_json( + InvariantProgressContext { + timestamp_secs: 456, + contract_name: "TestContract", + optimization_best: None, + throughput: InvariantThroughputMetrics { total_txs: 1, total_gas: 1 }, + elapsed: Duration::from_secs(3), + worker_id: 0, + worker_count: 1, + time_since_new_edge: None, + }, + &json!({ "corpus_count": 1 }), + &InvariantFailureMetrics::default(), + ); + + assert_eq!(payload["tps"], json!(0.33)); + assert_eq!(payload["gps"], json!(0.33)); + } + + #[test] + fn invariant_progress_json_includes_broken_counts() { + let mut failure_metrics = InvariantFailureMetrics::default(); + failure_metrics.record_failure("invariant_a", "TestContract", "revert"); + failure_metrics.record_failure("invariant_a", "TestContract", "revert"); + failure_metrics.record_failure("invariant_b", "TestContract", "assertion failed"); + failure_metrics.broken_handlers = 7; + + let payload = build_invariant_progress_json( + InvariantProgressContext { + timestamp_secs: 789, + contract_name: "TestContract", + optimization_best: None, + throughput: InvariantThroughputMetrics::default(), + elapsed: Duration::from_secs(1), + worker_id: 0, + worker_count: 1, + time_since_new_edge: None, + }, + &json!({ "corpus_count": 5 }), + &failure_metrics, + ); + + assert!(payload["metrics"].get("failures").is_none()); + assert!(payload["metrics"].get("unique_failures").is_none()); + assert_eq!(payload["metrics"]["broken_invariants"], json!(2)); + assert_eq!(payload["metrics"]["broken_assertions"], json!(7)); + assert!(payload["metrics"].get("broken_handlers").is_none()); + } + + #[test] + fn handler_assertion_failure_event_includes_site_and_reason() { + let target = Address::repeat_byte(0x11); + let selector = Selector::from([0xde, 0xad, 0xbe, 0xef]); + + assert_eq!( + build_handler_failure_event(123, target, selector, "assertion failed"), + json!({ + "timestamp": 123, + "event": "failure", + "failure_type": "handler_assertion", + "target": target, + "selector": "0xdeadbeef", + "reason": "assertion failed", + }) + ); + } + + #[test] + fn failure_metrics_tracks_total_and_unique_failures() { + let mut metrics = InvariantFailureMetrics::default(); + metrics.record_failure("invariant_a", "TestContract", "revert"); + metrics.record_failure("invariant_a", "TestContract", "revert"); + metrics.record_failure("invariant_b", "TestContract", "assertion failed"); + + assert_eq!(metrics.failures, 3); + assert_eq!(metrics.unique_failures.len(), 2); + assert!(metrics.unique_failures.contains("invariant_a")); + assert!(metrics.unique_failures.contains("invariant_b")); + } + + #[test] + fn failure_metrics_default_is_zero() { + let metrics = InvariantFailureMetrics::default(); + assert_eq!(metrics.failures, 0); + assert!(metrics.unique_failures.is_empty()); + assert_eq!(metrics.broken_handlers, 0); + } +} diff --git a/crates/evm/evm/src/executors/invariant/replay.rs b/crates/evm/evm/src/executors/invariant/replay.rs index 5aabfc6e16e19..5d38fb4268846 100644 --- a/crates/evm/evm/src/executors/invariant/replay.rs +++ b/crates/evm/evm/src/executors/invariant/replay.rs @@ -1,57 +1,67 @@ -use super::{ - call_after_invariant_function, call_invariant_function, error::FailedInvariantCaseData, - shrink_sequence, +use super::{call_after_invariant_function, call_invariant_function, execute_tx}; +use crate::executors::{ + EarlyExit, Executor, + invariant::shrink::{ + CheckSequenceOutcome, ShrinkProgress, shrink_sequence, shrink_sequence_value, + }, }; -use crate::executors::Executor; use alloy_dyn_abi::JsonAbiExt; -use alloy_primitives::{Log, U256, map::HashMap}; +use alloy_json_abi::Function; +use alloy_primitives::{ + Bytes, I256, Log, + map::{AddressHashMap, HashMap}, +}; use eyre::Result; use foundry_common::{ContractsByAddress, ContractsByArtifact}; +use foundry_config::InvariantConfig; +use foundry_evm_core::{decode::RevertDecoder, evm::FoundryEvmNetwork}; use foundry_evm_coverage::HitMaps; -use foundry_evm_fuzz::{ - BaseCounterExample, - invariant::{BasicTxDetails, InvariantContract}, -}; -use foundry_evm_traces::{TraceKind, TraceMode, Traces, load_contracts}; +use foundry_evm_fuzz::{BaseCounterExample, BasicTxDetails, invariant::InvariantContract}; +use foundry_evm_traces::{TraceKind, TraceRequirements, Traces, load_contracts}; use indicatif::ProgressBar; use parking_lot::RwLock; -use proptest::test_runner::TestError; use std::sync::Arc; +pub struct ReplayErrorResult { + pub counterexample_sequence: Vec, + pub check_result: Option, + pub fork_block_number: Option, +} + /// Replays a call sequence for collecting logs and traces. /// Returns counterexample to be used when the call sequence is a failed scenario. #[expect(clippy::too_many_arguments)] -pub fn replay_run( +pub fn replay_run( invariant_contract: &InvariantContract<'_>, - mut executor: Executor, + target_invariant: &Function, + mut executor: Executor, known_contracts: &ContractsByArtifact, mut ided_contracts: ContractsByAddress, logs: &mut Vec, traces: &mut Traces, + debug_bytecodes: &mut AddressHashMap, line_coverage: &mut Option, deprecated_cheatcodes: &mut HashMap<&'static str, Option<&'static str>>, inputs: &[BasicTxDetails], show_solidity: bool, -) -> Result> { +) -> Result { // We want traces for a failed case. if executor.inspector().tracer.is_none() { - executor.set_tracing(TraceMode::Call); + executor.set_trace_requirements(TraceRequirements::none().with_calls(true)); } let mut counterexample_sequence = vec![]; // Replay each call from the sequence, collect logs, traces and coverage. for tx in inputs { - let call_result = executor.transact_raw( - tx.sender, - tx.call_details.target, - tx.call_details.calldata.clone(), - U256::ZERO, - )?; - - logs.extend(call_result.logs); + let mut call_result = execute_tx(&mut executor, tx)?; + logs.append(&mut call_result.logs); + debug_bytecodes.extend(std::mem::take(&mut call_result.debug_bytecodes)); traces.push((TraceKind::Execution, call_result.traces.clone().unwrap())); - HitMaps::merge_opt(line_coverage, call_result.line_coverage); + HitMaps::merge_opt(line_coverage, call_result.line_coverage.take()); + + // Commit state changes to persist across calls in the sequence. + executor.commit(&mut call_result); // Identify newly generated contracts, if they exist. ided_contracts @@ -59,9 +69,7 @@ pub fn replay_run( // Create counter example to be used in failed case. counterexample_sequence.push(BaseCounterExample::from_invariant_call( - tx.sender, - tx.call_details.target, - &tx.call_details.calldata, + tx, &ided_contracts, call_result.traces, show_solidity, @@ -76,8 +84,10 @@ pub fn replay_run( let (invariant_result, invariant_success) = call_invariant_function( &executor, invariant_contract.address, - invariant_contract.invariant_function.abi_encode_input(&[])?.into(), + target_invariant.abi_encode_input(&[])?.into(), )?; + let fork_block_number = invariant_result.fork_block_number; + debug_bytecodes.extend(invariant_result.debug_bytecodes); traces.push((TraceKind::Execution, invariant_result.traces.clone().unwrap())); logs.extend(invariant_result.logs); deprecated_cheatcodes.extend( @@ -91,62 +101,108 @@ pub fn replay_run( if invariant_contract.call_after_invariant && invariant_success { let (after_invariant_result, _) = call_after_invariant_function(&executor, invariant_contract.address)?; + debug_bytecodes.extend(after_invariant_result.debug_bytecodes); traces.push((TraceKind::Execution, after_invariant_result.traces.clone().unwrap())); logs.extend(after_invariant_result.logs); } - Ok(counterexample_sequence) + Ok(ReplayErrorResult { counterexample_sequence, check_result: None, fork_block_number }) } -/// Replays the error case, shrinks the failing sequence and collects all necessary traces. +/// Replays and shrinks a call sequence, collecting logs and traces. +/// +/// For check mode (target_value=None): shrinks to find shortest failing sequence. +/// For optimization mode (target_value=Some): shrinks to find shortest sequence producing target. #[expect(clippy::too_many_arguments)] -pub fn replay_error( - failed_case: &FailedInvariantCaseData, +pub fn replay_error( + config: InvariantConfig, + mut executor: Executor, + calls: &[BasicTxDetails], + inner_sequence: Option>>, + expect_assertion_failure: bool, + rd: Option<&RevertDecoder>, + target_value: Option, invariant_contract: &InvariantContract<'_>, - mut executor: Executor, + target_invariant: &Function, known_contracts: &ContractsByArtifact, ided_contracts: ContractsByAddress, logs: &mut Vec, traces: &mut Traces, + debug_bytecodes: &mut AddressHashMap, line_coverage: &mut Option, deprecated_cheatcodes: &mut HashMap<&'static str, Option<&'static str>>, progress: Option<&ProgressBar>, - show_solidity: bool, -) -> Result> { - match failed_case.test_error { - // Don't use at the moment. - TestError::Abort(_) => Ok(vec![]), - TestError::Fail(_, ref calls) => { - // Shrink sequence of failed calls. - let calls = shrink_sequence( - failed_case, + early_exit: &EarlyExit, + position: Option<(usize, usize)>, +) -> Result { + // Multi-invariant runs include `[i/N]` in the shrink progress message so users see how many + // shrinkers are queued behind the current one. + let shrink_progress = ShrinkProgress::new( + &config, + progress, + &target_invariant.name, + position, + Some(&ided_contracts), + config.show_solidity, + ); + + let (calls, check_result) = if let Some(target) = target_value { + ( + shrink_sequence_value( + &config, + invariant_contract, + target_invariant, calls, &executor, - invariant_contract.call_after_invariant, - progress, - )?; - - set_up_inner_replay(&mut executor, &failed_case.inner_sequence); + target, + &shrink_progress, + early_exit, + )?, + None, + ) + } else { + let shrunk = shrink_sequence( + &config, + invariant_contract, + target_invariant, + calls, + expect_assertion_failure, + &executor, + rd, + &shrink_progress, + early_exit, + )?; + (shrunk.calls, shrunk.result) + }; - // Replay calls to get the counterexample and to collect logs, traces and coverage. - replay_run( - invariant_contract, - executor, - known_contracts, - ided_contracts, - logs, - traces, - line_coverage, - deprecated_cheatcodes, - &calls, - show_solidity, - ) - } + if let Some(sequence) = inner_sequence { + set_up_inner_replay(&mut executor, &sequence); } + + let mut replay = replay_run( + invariant_contract, + target_invariant, + executor, + known_contracts, + ided_contracts, + logs, + traces, + debug_bytecodes, + line_coverage, + deprecated_cheatcodes, + &calls, + config.show_solidity, + )?; + + replay.check_result = check_result; + Ok(replay) } /// Sets up the calls generated by the internal fuzzer, if they exist. -fn set_up_inner_replay(executor: &mut Executor, inner_sequence: &[Option]) { +fn set_up_inner_replay( + executor: &mut Executor, + inner_sequence: &[Option], +) { if let Some(fuzzer) = &mut executor.inspector_mut().fuzzer && let Some(call_generator) = &mut fuzzer.call_generator { diff --git a/crates/evm/evm/src/executors/invariant/result.rs b/crates/evm/evm/src/executors/invariant/result.rs index f1cd9a2174fb3..9f456cbb10c82 100644 --- a/crates/evm/evm/src/executors/invariant/result.rs +++ b/crates/evm/evm/src/executors/invariant/result.rs @@ -1,26 +1,43 @@ use super::{ InvariantFailures, InvariantFuzzError, InvariantMetrics, InvariantTest, InvariantTestRun, - call_after_invariant_function, call_invariant_function, error::FailedInvariantCaseData, + call_after_invariant_function, call_invariant_function, + error::{InvariantRunCtx, record_handler_assertion_bug}, }; use crate::executors::{Executor, RawCallResult}; -use alloy_dyn_abi::JsonAbiExt; +use alloy_json_abi::Function; +use alloy_primitives::{Address, B256, I256, Selector}; +use alloy_sol_types::{Panic, PanicKind, Revert, SolError, SolInterface}; use eyre::Result; use foundry_config::InvariantConfig; -use foundry_evm_core::utils::StateChangeset; +use foundry_evm_core::{ + abi::Vm, + constants::CHEATCODE_ADDRESS, + decode::{ASSERTION_FAILED_PREFIX, decode_console_log}, + evm::FoundryEvmNetwork, + utils::StateChangeset, +}; use foundry_evm_coverage::HitMaps; use foundry_evm_fuzz::{ - FuzzedCases, - invariant::{BasicTxDetails, FuzzRunIdentifiedContracts, InvariantContract}, + BasicTxDetails, + invariant::{FuzzRunIdentifiedContracts, InvariantContract}, }; -use revm_inspectors::tracing::CallTraceArena; +use foundry_evm_traces::CallTraceArena; +use proptest::test_runner::TestError; +use revm::interpreter::InstructionResult; use std::{borrow::Cow, collections::HashMap}; /// The outcome of an invariant fuzz test -#[derive(Debug)] +#[derive(Debug, Default)] pub struct InvariantFuzzTestResult { - pub error: Option, - /// Every successful fuzz test case - pub cases: Vec, + /// Errors recorded per invariant. + pub errors: HashMap, + /// Handler-side assertion bugs, keyed by `(reverter, selector)` site (deduped per + /// handler function). Each entry is [`InvariantFuzzError::HandlerAssertion`]. + pub handler_errors: HashMap<(Address, Selector), InvariantFuzzError>, + /// Number of completed invariant runs. + pub runs: usize, + /// Number of completed fuzzed calls across all invariant runs. + pub calls: usize, /// Number of reverted fuzz calls pub reverts: usize, /// The entire inputs of the last run of the invariant campaign, used for @@ -32,155 +49,586 @@ pub struct InvariantFuzzTestResult { pub line_coverage: Option, /// Fuzzed selectors metrics collected during the invariant test runs. pub metrics: HashMap, - /// NUmber of failed replays from persisted corpus. + /// Number of failed replays from persisted corpus. pub failed_corpus_replays: usize, + /// Actual number of workers used for this logical campaign. + pub workers: usize, + /// Common fork block for all recorded failures, if they agree on one. + pub fork_block_number: Option, + /// For optimization mode (int256 return): the best (maximum) value achieved. + /// None means standard invariant check mode. + pub optimization_best_value: Option, + /// For optimization mode: the call sequence that produced the best value. + pub optimization_best_sequence: Vec, + /// Whether fail-fast or Ctrl-C stopped the campaign before its timeout or planned runs. + pub interrupted: bool, } -/// 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 InvariantFuzzTestResult { + #[expect(clippy::too_many_arguments)] + pub(crate) fn new( + errors: HashMap, + handler_errors: HashMap<(Address, Selector), InvariantFuzzError>, + runs: usize, + calls: usize, + reverts: usize, + last_run_inputs: Vec, + gas_report_traces: Vec>, + line_coverage: Option, + metrics: HashMap, + failed_corpus_replays: usize, + workers: usize, + optimization_best_value: Option, + optimization_best_sequence: Vec, + ) -> Self { + let mut failure_blocks = errors + .values() + .chain(handler_errors.values()) + .map(InvariantFuzzError::fork_block_number); + let fork_block_number = failure_blocks + .next() + .flatten() + .filter(|first| failure_blocks.all(|block| block == Some(*first))); -impl RichInvariantResults { - fn new(can_continue: bool, call_result: Option) -> Self { - Self { can_continue, call_result } + Self { + errors, + handler_errors, + runs, + calls, + reverts, + last_run_inputs, + gas_report_traces, + line_coverage, + metrics, + failed_corpus_replays, + workers, + fork_block_number, + optimization_best_value, + optimization_best_sequence, + interrupted: false, + } } } /// Given the executor state, asserts that no invariant has been broken. Otherwise, it fills the /// external `invariant_failures.failed_invariant` map and returns a generic error. /// Either returns the call result if successful, or nothing if there was an error. -pub(crate) fn assert_invariants( +pub(crate) fn invariant_preflight_check( invariant_contract: &InvariantContract<'_>, invariant_config: &InvariantConfig, targeted_contracts: &FuzzRunIdentifiedContracts, - executor: &Executor, + executor: &Executor, calldata: &[BasicTxDetails], invariant_failures: &mut InvariantFailures, -) -> Result> { - let mut inner_sequence = vec![]; +) -> Result<()> { + assert_invariants( + invariant_contract, + invariant_config, + targeted_contracts, + executor, + calldata, + invariant_failures, + )?; + Ok(()) +} - if let Some(fuzzer) = &executor.inspector().fuzzer - && let Some(call_generator) = &fuzzer.call_generator - { - inner_sequence.extend(call_generator.last_sequence.read().iter().cloned()); +/// Returns true if this call failed due to a Solidity assertion: +/// - `Panic(0x01)`, or +/// - legacy invalid opcode assert behavior. +pub(crate) fn is_assertion_failure( + call_result: &RawCallResult, +) -> bool { + if !call_result.reverted { + return false; } - let (call_result, success) = call_invariant_function( - executor, - invariant_contract.address, - invariant_contract.invariant_function.abi_encode_input(&[])?.into(), - )?; - if !success { + is_assert_panic(call_result.result.as_ref()) + || matches!(call_result.exit_reason, Some(InstructionResult::InvalidFEOpcode)) + || is_revert_assertion_failure(call_result.result.as_ref()) + || is_cheatcode_assert_revert(call_result) +} + +fn is_assert_panic(data: &[u8]) -> bool { + Panic::abi_decode(data).is_ok_and(|panic| panic == PanicKind::Assert.into()) +} + +fn is_revert_assertion_failure(data: &[u8]) -> bool { + Revert::abi_decode(data).is_ok_and(|revert| revert.reason.contains(ASSERTION_FAILED_PREFIX)) +} + +fn is_cheatcode_assert_revert(call_result: &RawCallResult) -> bool { + call_result.reverter == Some(CHEATCODE_ADDRESS) + && Vm::VmErrors::abi_decode(call_result.result.as_ref()) + .ok() + .map(|error| error.to_string()) + .is_some_and(|message| message.starts_with(ASSERTION_FAILED_PREFIX)) +} + +fn logged_assertion_failure(call_result: &RawCallResult) -> bool { + call_result + .logs + .iter() + .filter_map(decode_console_log) + .any(|msg| msg.starts_with(ASSERTION_FAILED_PREFIX)) +} + +/// Returns whether the current fuzz call should be treated as an assertion failure. +/// +/// This covers Solidity `assert`, legacy invalid-opcode assertions, `vm.assert*` reverts, and the +/// non-reverting `GLOBAL_FAIL_SLOT` path used when `assertions_revert = false`. +pub fn did_fail_on_assert( + call_result: &RawCallResult, + state_changeset: &StateChangeset, +) -> bool { + is_assertion_failure(call_result) + || call_result.has_state_snapshot_failure + || Executor::::has_pending_global_failure(state_changeset) + || logged_assertion_failure(call_result) +} + +/// Given the executor state, asserts that no invariant has been broken. Otherwise, it fills the +/// external `invariant_failures.failed_invariant` map. +/// +/// Returns the first newly-broken invariant in declaration order (if any), so callers can +/// attribute the failure event without re-scanning `invariant_failures.errors` afterwards. +pub(crate) fn assert_invariants<'a, FEN: FoundryEvmNetwork>( + invariant_contract: &InvariantContract<'a>, + invariant_config: &InvariantConfig, + targeted_contracts: &FuzzRunIdentifiedContracts, + executor: &Executor, + calldata: &[BasicTxDetails], + invariant_failures: &mut InvariantFailures, +) -> Result<(Option<&'a Function>, bool)> { + let mut inner_sequence = None; + let mut first_broken: Option<&'a Function> = None; + let ctx = InvariantRunCtx { + contract: invariant_contract, + config: invariant_config, + targeted_contracts, + calldata, + }; + + for (idx, (invariant, fail_on_revert)) in invariant_contract.invariant_fns.iter().enumerate() { // We only care about invariants which we haven't broken yet. - if invariant_failures.error.is_none() { - let case_data = FailedInvariantCaseData::new( - invariant_contract, - invariant_config, - targeted_contracts, - calldata, - call_result, - &inner_sequence, - ); - invariant_failures.error = Some(InvariantFuzzError::BrokenInvariant(case_data)); - return Ok(None); + if invariant_failures.has_failure(invariant) { + continue; + } + + let (call_result, success) = call_invariant_function( + executor, + invariant_contract.address, + invariant_contract.invariant_calldata(idx), + )?; + if call_result.execution_cancelled { + return Ok((first_broken, true)); } + if !success { + let inner_sequence = + inner_sequence.get_or_insert_with(|| invariant_inner_sequence(executor)); + let case = + ctx.failed_case(invariant, *fail_on_revert, false, call_result, inner_sequence); + invariant_failures.record_failure(invariant, InvariantFuzzError::BrokenInvariant(case)); + if first_broken.is_none() { + first_broken = Some(*invariant); + } + } + } + + Ok((first_broken, false)) +} + +/// 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 +} - Ok(Some(call_result)) +/// Outcome of a per-call invariant check. +#[derive(Debug)] +pub(crate) struct ContinueOutcome { + /// Whether the invariant campaign should keep running after this call. + pub continues: bool, + /// Whether an invariant or optimization call was halted by cancellation. + pub cancelled: bool, } /// Returns if invariant test can continue and last successful call result of the invariant test /// function (if it can continue). -pub(crate) fn can_continue( - invariant_contract: &InvariantContract<'_>, - invariant_test: &InvariantTest, - invariant_run: &mut InvariantTestRun, +/// +/// For optimization mode (int256 return), tracks the max value but never fails on invariant. +/// For check mode, asserts the invariant and fails if broken. +/// +/// `handler_target` / `handler_selector` identify the just-executed call, used to +/// attribute handler-side assertion failures. +#[allow(clippy::too_many_arguments)] +pub(crate) fn can_continue<'a, FEN: FoundryEvmNetwork>( + invariant_contract: &InvariantContract<'a>, + invariant_test: &mut InvariantTest, + invariant_run: &mut InvariantTestRun, invariant_config: &InvariantConfig, - call_result: RawCallResult, + call_result: RawCallResult, state_changeset: &StateChangeset, -) -> Result { - let mut call_results = None; + handler_target: Address, + handler_selector: Selector, + assertion_failure: bool, + pre_merge_edges_hash: Option, +) -> Result { + let is_optimization = invariant_contract.is_optimization(); + // Use the handler-gate variant so a stale committed `GLOBAL_FAIL_SLOT` from a + // previously-recorded handler bug doesn't poison this gate (which would otherwise silently + // skip every subsequent `assert_invariants` evaluation under `assertions_revert = false`). + // Handler bugs are tracked separately in `failures.broken_handlers`. let handlers_succeeded = || { - invariant_test.targeted_contracts.targets.lock().keys().all(|address| { - invariant_run.executor.is_success( - *address, + if !invariant_run.executor.legacy_assertions() { + return invariant_run.executor.is_success_handler_gate( + invariant_contract.address, false, Cow::Borrowed(state_changeset), + ); + } + + invariant_test.targeted_contracts.targets().keys().all(|address| { + invariant_run.executor.is_success_handler_gate( + *address, false, + Cow::Borrowed(state_changeset), ) }) }; - // Assert invariants if the call did not revert and the handlers did not fail. if !call_result.reverted && handlers_succeeded() { if let Some(traces) = call_result.traces { invariant_run.run_traces.push(traces); } - call_results = assert_invariants( - invariant_contract, - invariant_config, - &invariant_test.targeted_contracts, - &invariant_run.executor, - &invariant_run.inputs, - &mut invariant_test.execution_data.borrow_mut().failures, - )?; - if call_results.is_none() { - return Ok(RichInvariantResults::new(false, None)); + if is_optimization { + // Optimization mode: call invariant and track max value, never fail. + let (inv_result, success) = call_invariant_function( + &invariant_run.executor, + invariant_contract.address, + invariant_contract.anchor_calldata(), + )?; + if inv_result.execution_cancelled { + return Ok(ContinueOutcome { continues: true, cancelled: true }); + } + if success + && inv_result.result.len() >= 32 + && let Some(value) = I256::try_from_be_slice(&inv_result.result[..32]) + { + // Track the best value and its prefix length for this run + // (used for corpus persistence — materialized once at run end). + if invariant_run.optimization_value.is_none_or(|prev| value > prev) { + invariant_run.optimization_value = Some(value); + invariant_run.optimization_prefix_len = invariant_run.inputs.len(); + } + } + } else { + // Check mode: assert invariants and fail if broken. + let (_, cancelled) = assert_invariants( + invariant_contract, + invariant_config, + &invariant_test.targeted_contracts, + &invariant_run.executor, + &invariant_run.inputs, + &mut invariant_test.test_data.failures, + )?; + if cancelled { + return Ok(ContinueOutcome { continues: true, cancelled: true }); + } } } else { - // Increase the amount of reverts. - let mut invariant_data = invariant_test.execution_data.borrow_mut(); - invariant_data.failures.reverts += 1; - // If fail on revert is set, we must return immediately. - if invariant_config.fail_on_revert { - let case_data = FailedInvariantCaseData::new( + let is_assert_failure = assertion_failure; + let reverted = call_result.reverted; + + if reverted { + invariant_test.test_data.failures.reverts += 1; + } + + if is_assert_failure { + // Handler-side assertion: deduped by `(reverter, selector)` site, shortest + // sequence wins on collision. + record_handler_assertion_bug( invariant_contract, invariant_config, &invariant_test.targeted_contracts, - &invariant_run.inputs, + &mut invariant_test.test_data.failures, + &mut invariant_run.inputs, + handler_target, + handler_selector, + pre_merge_edges_hash, + call_result, + reverted, + is_optimization, + ); + + // No invariant predicate broke; `broken = None`. + let continues = invariant_test + .test_data + .failures + .can_continue(invariant_contract.invariant_fns.len()); + return Ok(ContinueOutcome { continues, cancelled: false }); + } + + // Non-assertion revert: per-invariant `fail_on_revert` still marks affected + // invariants as broken. + let failing_invariants: Vec<_> = invariant_contract + .invariant_fns + .iter() + .filter(|(invariant, fail_on_revert)| { + *fail_on_revert && !invariant_test.test_data.failures.has_failure(invariant) + }) + .collect(); + + if let Some((first_invariant, _)) = failing_invariants.first() { + // Build a base case_data attributed to the first failing invariant; clone it for + // each subsequent broken invariant, retagging name/selector/`fail_on_revert` so + // every recorded failure points at its own invariant body. + let base = InvariantRunCtx { + contract: invariant_contract, + config: invariant_config, + targeted_contracts: &invariant_test.targeted_contracts, + calldata: &invariant_run.inputs, + } + .failed_case( + first_invariant, + invariant_config.fail_on_revert, + is_assert_failure, call_result, &[], ); - invariant_data.failures.revert_reason = Some(case_data.revert_reason.clone()); - invariant_data.failures.error = Some(InvariantFuzzError::Revert(case_data)); - return Ok(RichInvariantResults::new(false, None)); - } else if call_result.reverted { - // If we don't fail test on revert then remove last reverted call from inputs. - // This improves shrinking performance as irrelevant calls won't be checked again. + for (invariant, fail_on_revert) in failing_invariants { + let mut data = base.clone(); + data.fail_on_revert = *fail_on_revert; + data.calldata = invariant.selector().into(); + data.test_error = TestError::Fail( + format!("{}, reason: {}", invariant.name, data.revert_reason).into(), + invariant_run.inputs.clone(), + ); + // Handler asserts go to `broken_handlers` above; `BrokenInvariant` arm kept + // for non-handler-routed assertion paths. + invariant_test.test_data.failures.record_failure( + invariant, + if is_assert_failure { + InvariantFuzzError::BrokenInvariant(data) + } else { + InvariantFuzzError::Revert(data) + }, + ); + } + } + + if reverted && !is_optimization && !invariant_config.has_delay() { + // If we don't fail the test on revert, remove the reverted call from inputs. + // Delay-enabled campaigns keep reverted calls so their warp/roll contribution can be + // replayed. invariant_run.inputs.pop(); } } - Ok(RichInvariantResults::new(true, call_results)) + + let continues = + invariant_test.test_data.failures.can_continue(invariant_contract.invariant_fns.len()); + Ok(ContinueOutcome { continues, cancelled: false }) } /// 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: &InvariantTest, - invariant_run: &InvariantTestRun, +/// +/// Returns `Some(anchor)` if the hook failed (so the caller can record the failure event +/// without re-scanning the failures map), or `None` if the hook succeeded. +pub(crate) fn assert_after_invariant<'a, FEN: FoundryEvmNetwork>( + invariant_contract: &InvariantContract<'a>, + invariant_test: &mut InvariantTest, + invariant_run: &InvariantTestRun, invariant_config: &InvariantConfig, -) -> Result { +) -> Result<(Option<&'a Function>, bool)> { let (call_result, success) = call_after_invariant_function(&invariant_run.executor, invariant_contract.address)?; + if call_result.execution_cancelled { + return Ok((None, true)); + } // Fail the test case if `afterInvariant` doesn't succeed. - if !success { - let case_data = FailedInvariantCaseData::new( - invariant_contract, - invariant_config, - &invariant_test.targeted_contracts, - &invariant_run.inputs, - call_result, - &[], + if success { + return Ok((None, false)); + } + // `afterInvariant` failures are contract-wide (no specific invariant body executed), + // so attribute to the campaign anchor. + let anchor = invariant_contract.anchor(); + let case_data = InvariantRunCtx { + contract: invariant_contract, + config: invariant_config, + targeted_contracts: &invariant_test.targeted_contracts, + calldata: &invariant_run.inputs, + } + .failed_case(anchor, invariant_config.fail_on_revert, false, call_result, &[]); + invariant_test + .test_data + .failures + .record_failure(anchor, InvariantFuzzError::BrokenInvariant(case_data)); + Ok((Some(anchor), false)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::executors::{EarlyExit, ExecutorBuilder}; + use alloy_dyn_abi::JsonAbiExt; + use alloy_evm::EvmEnv; + use alloy_primitives::{Bytes, U256}; + use alloy_sol_types::SolCall; + use foundry_cheatcodes::{CheatsConfig, Vm::expectRevert_0Call}; + use foundry_config::Config; + use foundry_evm_core::{ + backend::Backend, constants::CALLER, evm::EthEvmNetwork, opts::EvmOpts, + }; + use foundry_evm_fuzz::invariant::TargetedContracts; + use revm::{bytecode::Bytecode, context::TxEnv}; + use std::sync::Arc; + + fn panic_payload(code: u8) -> Bytes { + let mut payload = vec![0_u8; 36]; + payload[..4].copy_from_slice(&[0x4e, 0x48, 0x7b, 0x71]); + payload[35] = code; + payload.into() + } + + #[test] + fn cancellation_does_not_record_call_end_rewrite_as_invariant_failure() { + let cheats_config = + Arc::new(CheatsConfig::new(&Config::default(), EvmOpts::default(), None, None, false)); + let backend = Backend::::spawn(None).unwrap(); + let mut executor = ExecutorBuilder::default() + .inspectors(|stack| stack.cheatcodes(cheats_config)) + .gas_limit(1 << 24) + .build(EvmEnv::default(), TxEnv::default(), backend, Default::default()); + let invariant_address = Address::repeat_byte(0x11); + executor + .set_code( + invariant_address, + Bytecode::new_raw(Bytes::from_static(&[0x5b, 0x60, 0x00, 0x56])), + ) + .unwrap(); + let expect_result = executor + .transact_raw( + CALLER, + CHEATCODE_ADDRESS, + expectRevert_0Call {}.abi_encode().into(), + U256::ZERO, + ) + .unwrap(); + assert!(!expect_result.reverted); + + let early_exit = EarlyExit::new(false); + executor.inspector_mut().set_early_exit(early_exit.clone()); + early_exit.record_ctrl_c(); + + let invariant = Function::parse("invariant_ok() view returns (bool)").unwrap(); + let mut abi = alloy_json_abi::JsonAbi::new(); + abi.functions.entry(invariant.name.clone()).or_default().push(invariant.clone()); + let invariant_contract = InvariantContract::new( + invariant_address, + "InvariantTest", + vec![(&invariant, false)], + 0, + false, + &abi, ); - invariant_test.set_error(InvariantFuzzError::BrokenInvariant(case_data)); + + let (_, success) = call_invariant_function( + &executor.clone(), + invariant_address, + invariant.abi_encode_input(&[]).unwrap().into(), + ) + .unwrap(); + assert!(!success, "pending expectRevert should rewrite the interrupted call"); + + let targets = FuzzRunIdentifiedContracts::new(TargetedContracts::new(), false); + let mut failures = InvariantFailures::new(); + let broken = assert_invariants( + &invariant_contract, + &InvariantConfig::default(), + &targets, + &executor, + &[], + &mut failures, + ) + .unwrap(); + + assert!(broken.0.is_none()); + assert!(broken.1); + assert_eq!(failures.invariant_count(), 0); + } + + #[test] + fn detects_assert_panic_code() { + let call_result = RawCallResult:: { + reverted: true, + result: panic_payload(0x01), + ..Default::default() + }; + assert!(is_assertion_failure(&call_result)); + } + + #[test] + fn ignores_non_assert_panic_code() { + let call_result = RawCallResult:: { + reverted: true, + result: panic_payload(0x11), + ..Default::default() + }; + assert!(!is_assertion_failure(&call_result)); + } + + #[test] + fn detects_legacy_invalid_opcode_assert() { + let call_result = RawCallResult:: { + reverted: true, + exit_reason: Some(InstructionResult::InvalidFEOpcode), + ..Default::default() + }; + assert!(is_assertion_failure(&call_result)); + } + + #[test] + fn detects_vm_assert_revert() { + let call_result = RawCallResult:: { + reverted: true, + result: Vm::CheatcodeError { message: format!("{ASSERTION_FAILED_PREFIX}: 1 != 2") } + .abi_encode() + .into(), + reverter: Some(CHEATCODE_ADDRESS), + ..Default::default() + }; + assert!(is_assertion_failure(&call_result)); + } + + #[test] + fn detects_assertion_failure_revert_reason() { + let call_result = RawCallResult:: { + reverted: true, + result: Revert { reason: format!("{ASSERTION_FAILED_PREFIX}: expected") } + .abi_encode() + .into(), + ..Default::default() + }; + assert!(is_assertion_failure(&call_result)); + } + + #[test] + fn ignores_empty_cheatcode_revert() { + let call_result = RawCallResult:: { + reverted: true, + result: Bytes::new(), + reverter: Some(CHEATCODE_ADDRESS), + ..Default::default() + }; + assert!(!is_assertion_failure(&call_result)); } - Ok(success) } diff --git a/crates/evm/evm/src/executors/invariant/shrink.rs b/crates/evm/evm/src/executors/invariant/shrink.rs index aa193d0baa3d9..91064541d2dd8 100644 --- a/crates/evm/evm/src/executors/invariant/shrink.rs +++ b/crates/evm/evm/src/executors/invariant/shrink.rs @@ -1,140 +1,641 @@ use crate::executors::{ - Executor, + EarlyExit, EvmError, Executor, RawCallResult, + campaign::{apply_block_delay, execute_invariant_replay_tx}, invariant::{ - call_after_invariant_function, call_invariant_function, error::FailedInvariantCaseData, + IInvariantTest, call_after_invariant_function, call_invariant_function, + error::{handler_edge_fingerprint, snapshot_edge_fingerprint}, + result::did_fail_on_assert, }, }; -use alloy_primitives::{Address, Bytes, U256}; -use foundry_evm_core::constants::MAGIC_ASSUME; -use foundry_evm_fuzz::invariant::BasicTxDetails; +use alloy_json_abi::Function; +use alloy_primitives::{Address, B256, Bytes, I256, Selector, U256, map::HashSet}; +use alloy_sol_types::SolCall; +use foundry_common::ContractsByAddress; +use foundry_config::InvariantConfig; +use foundry_evm_core::{constants::MAGIC_ASSUME, decode::RevertDecoder, evm::FoundryEvmNetwork}; +use foundry_evm_fuzz::{BaseCounterExample, BasicTxDetails, invariant::InvariantContract}; use indicatif::ProgressBar; use proptest::bits::{BitSetLike, VarBitSet}; -use std::cmp::min; +use std::{cell::Cell, fmt::Write, hash::Hash}; -#[derive(Clone, Copy, Debug)] -struct Shrink { - call_index: usize, -} +const LIVE_SHRINK_SEQUENCE_EDGE_CALLS: usize = 16; /// Shrinker for a call sequence failure. /// Iterates sequence call sequence top down and removes calls one by one. /// If the failure is still reproducible with removed call then moves to the next one. /// If the failure is not reproducible then restore removed call and moves to next one. #[derive(Debug)] -struct CallSequenceShrinker { +pub struct SequenceShrink { /// Length of call sequence to be shrunk. call_sequence_len: usize, /// Call ids contained in current shrunk sequence. included_calls: VarBitSet, - /// Current shrunk call id. - shrink: Shrink, - /// Previous shrunk call id. - prev_shrink: Option, } -impl CallSequenceShrinker { - fn new(call_sequence_len: usize) -> Self { - Self { - call_sequence_len, - included_calls: VarBitSet::saturated(call_sequence_len), - shrink: Shrink { call_index: 0 }, - prev_shrink: None, - } +impl SequenceShrink { + pub fn new(call_sequence_len: usize) -> Self { + Self { call_sequence_len, included_calls: VarBitSet::saturated(call_sequence_len) } } /// Return candidate shrink sequence to be tested, by removing ids from original sequence. - fn current(&self) -> impl Iterator + '_ { + pub fn current(&self) -> impl Iterator + '_ { (0..self.call_sequence_len).filter(|&call_id| self.included_calls.test(call_id)) } - /// Removes next call from sequence. - fn simplify(&mut self) -> bool { - if self.shrink.call_index >= self.call_sequence_len { - // We reached the end of call sequence, nothing left to simplify. - false - } else { - // Remove current call. - self.included_calls.clear(self.shrink.call_index); - // Record current call as previous call. - self.prev_shrink = Some(self.shrink); - // Remove next call index - self.shrink = Shrink { call_index: self.shrink.call_index + 1 }; - true - } + pub fn contains(&self, call_idx: usize) -> bool { + self.included_calls.test(call_idx) + } + + pub fn included_count(&self) -> usize { + self.included_calls.count() + } + + pub fn apply(&self, calls: &[T]) -> Vec { + self.current().map(|idx| calls[idx].clone()).collect() } - /// Reverts removed call from sequence and tries to simplify next call. - fn complicate(&mut self) -> bool { - match self.prev_shrink { - Some(shrink) => { - // Undo the last call removed. - self.included_calls.set(shrink.call_index); - self.prev_shrink = None; - // Try to simplify next call. - self.simplify() + pub fn apply_with_accumulated_delay( + &self, + calls: &[T], + mut delay: D, + mut apply_delay: A, + ) -> Vec + where + T: Clone, + D: FnMut(&T) -> (Option, Option), + A: FnMut(T, U256, U256) -> T, + { + let mut result = Vec::new(); + let mut accumulated_warp = U256::ZERO; + let mut accumulated_roll = U256::ZERO; + + for (idx, call) in calls.iter().enumerate() { + let (warp, roll) = delay(call); + accumulated_warp += warp.unwrap_or(U256::ZERO); + accumulated_roll += roll.unwrap_or(U256::ZERO); + + if self.contains(idx) { + result.push(apply_delay(call.clone(), accumulated_warp, accumulated_roll)); + accumulated_warp = U256::ZERO; + accumulated_roll = U256::ZERO; } - None => false, } + + result + } + + fn remove(&mut self, call_idx: usize) { + self.included_calls.clear(call_idx); + } + + fn restore(&mut self, call_idx: usize) { + self.included_calls.set(call_idx); + } + + /// Advance to the next call index, wrapping around to 0 at the end. + const fn next_index(&self, call_idx: usize) -> usize { + if call_idx + 1 == self.call_sequence_len { 0 } else { call_idx + 1 } } } -/// Shrinks the failure case to its smallest sequence of calls. -/// -/// Maximal shrinkage is guaranteed if the shrink_run_limit is not set to a value lower than the -/// length of failed call sequence. +/// How `run_shrink_loop` handles a predicate error. +#[derive(Clone, Copy)] +enum ShrinkErrorPolicy { + /// "Bug still present" — keep the call removed (legacy `shrink_sequence` behavior). + KeepRemoved, + /// "Bug gone" — restore the call. Used by handler shrink so a replay error never + /// produces a sequence that no longer reproduces the anchor. + RestoreRemoved, +} + +/// Attempt counters collected while trying shrink candidates. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ShrinkRunStats { + pub attempts: usize, + pub accepted: usize, +} + +/// Shared shrink attempt driver. /// -/// The shrunk call sequence always respect the order failure is reproduced as it is tested -/// top-down. -pub(crate) fn shrink_sequence( - failed_case: &FailedInvariantCaseData, - calls: &[BasicTxDetails], - executor: &Executor, - call_after_invariant: bool, - progress: Option<&ProgressBar>, -) -> eyre::Result> { - trace!(target: "forge::test", "Shrinking sequence of {} calls.", calls.len()); +/// Candidate generation stays with each shrinker; this type only centralizes limit enforcement +/// and the "accept when the candidate still reproduces the bug" accounting. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ShrinkRun { + max_attempts: usize, + stats: ShrinkRunStats, +} + +impl ShrinkRun { + pub const fn new(max_attempts: usize) -> Self { + Self { max_attempts, stats: ShrinkRunStats { attempts: 0, accepted: 0 } } + } + + pub const fn can_try(&self) -> bool { + self.stats.attempts < self.max_attempts + } + + pub const fn remaining_attempts(&self) -> usize { + self.max_attempts - self.stats.attempts + } + + pub const fn finish(self) -> ShrinkRunStats { + self.stats + } + + pub fn try_candidate(&mut self, still_fails: impl FnOnce() -> bool) -> bool { + self.try_candidate_decision(|| Some(still_fails())).unwrap_or(false) + } + + fn try_candidate_decision(&mut self, decide: impl FnOnce() -> Option) -> Option { + if !self.can_try() { + return None; + } + + let accepted = decide()?; + self.stats.attempts += 1; + if accepted { + self.stats.accepted += 1; + } + Some(accepted) + } +} + +/// Shared key set for shrinkers that need to skip duplicate concrete replays. +#[derive(Clone, Debug)] +pub struct ShrinkCandidateKeys { + seen: HashSet, +} + +impl ShrinkCandidateKeys { + pub fn new(initial: K) -> Self { + Self { seen: HashSet::<_>::from_iter([initial]) } + } + + pub fn insert(&mut self, key: K) -> bool { + self.seen.insert(key) + } +} + +/// Per-call decision returned by callbacks driving `replay_sequence`. +enum ReplayDecision { + Stop(T), + Continue, +} - // Reset run count and display shrinking message. +/// Options controlling how `check_sequence` evaluates a candidate call sequence. +pub struct CheckSequenceOptions<'a> { + pub accumulate_warp_roll: bool, + pub fail_on_revert: bool, + pub expect_assertion_failure: bool, + pub call_after_invariant: bool, + pub rd: Option<&'a RevertDecoder>, +} + +/// Concrete failure site observed while replaying a sequence through [`check_sequence`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CheckSequenceFailureSite { + SequenceCall { target: Address, selector: Selector, fingerprint: B256 }, + Invariant { target: Address, selector: Selector, fingerprint: B256 }, + AfterInvariant { target: Address, selector: Selector, fingerprint: B256 }, +} + +/// Outcome from replaying an invariant call sequence through [`check_sequence`]. +#[derive(Clone, Debug)] +pub struct CheckSequenceOutcome { + pub success: bool, + pub replayed_entirely: bool, + pub reason: Option, + pub calls_count: usize, + pub reverts: usize, + pub failure_site: Option, + /// Whether replay stopped on an assertion in a sequence call rather than a plain revert or + /// terminal invariant check. + pub sequence_assertion_failure: bool, + /// Innermost reverter for a sequence assertion, used to recognize legacy handler identities. + pub sequence_reverter: Option
, +} + +pub struct ShrunkSequence { + pub calls: Vec, + pub result: Option, +} + +/// Result of a strict handler-bug replay: anchor asserts, no earlier call asserts, and the +/// recomputed edge fingerprint identifies which path the assertion took. +#[derive(Debug)] +pub struct HandlerReplayOutcome { + pub anchor_asserted: bool, + /// Top-level fuzzed handler target used as the canonical failure identity. + pub handler_target: Address, + /// Innermost reverting frame, retained to recognize legacy persisted identities. + pub reverter: Address, + pub selector: Selector, + pub revert_reason: Option, + /// Normalized via `handler_edge_fingerprint` so callers can compare directly. + pub anchor_fingerprint: B256, +} + +/// Resets the progress bar before each shrink. `position = Some((i, N))` renders +/// `[i/N] Shrink:
, to: Address, rd: Option<&RevertDecoder>, - ) -> Result { + ) -> Result, EvmError> { trace!(?from, ?to, "setting up contract"); let from = from.unwrap_or(CALLER); @@ -386,12 +689,11 @@ impl Executor { res = res.into_result(rd)?; // record any changes made to the block's environment during setup - self.env_mut().evm_env.block_env = res.env.evm_env.block_env.clone(); + self.evm_env_mut().block_env = res.evm_env.block_env.clone(); // and also the chainid, which can be set manually - self.env_mut().evm_env.cfg_env.chain_id = res.env.evm_env.cfg_env.chain_id; + self.evm_env_mut().cfg_env.chain_id = res.evm_env.cfg_env.chain_id; - let success = - self.is_raw_call_success(to, Cow::Borrowed(&res.state_changeset), &res, false); + let success = self.is_raw_call_success(to, Cow::Borrowed(&res.state_changeset), &res); if !success { return Err(res.into_execution_error("execution error".to_string()).into()); } @@ -408,7 +710,7 @@ impl Executor { args: &[DynSolValue], value: U256, rd: Option<&RevertDecoder>, - ) -> Result { + ) -> Result, EvmError> { let calldata = Bytes::from(func.abi_encode_input(args)?); let result = self.call_raw(from, to, calldata, value)?; result.into_decoded_result(func, rd) @@ -422,7 +724,7 @@ impl Executor { args: &C, value: U256, rd: Option<&RevertDecoder>, - ) -> Result, EvmError> { + ) -> Result, EvmError> { let calldata = Bytes::from(args.abi_encode()); let mut raw = self.call_raw(from, to, calldata, value)?; raw = raw.into_result(rd)?; @@ -438,7 +740,7 @@ impl Executor { args: &[DynSolValue], value: U256, rd: Option<&RevertDecoder>, - ) -> Result { + ) -> Result, EvmError> { let calldata = Bytes::from(func.abi_encode_input(args)?); let result = self.transact_raw(from, to, calldata, value)?; result.into_decoded_result(func, rd) @@ -451,9 +753,9 @@ impl Executor { to: Address, calldata: Bytes, value: U256, - ) -> eyre::Result { - let env = self.build_test_env(from, TxKind::Call(to), calldata, value); - self.call_with_env(env) + ) -> eyre::Result> { + let (evm_env, tx_env) = self.prepare_call_env(from, TxKind::Call(to), calldata, value); + self.call_with_env(evm_env, tx_env) } /// Performs a raw call to an account on the current state of the VM with an EIP-7702 @@ -465,11 +767,11 @@ impl Executor { calldata: Bytes, value: U256, authorization_list: Vec, - ) -> eyre::Result { - let mut env = self.build_test_env(from, to.into(), calldata, value); - env.tx.set_signed_authorization(authorization_list); - env.tx.tx_type = 4; - self.call_with_env(env) + ) -> eyre::Result> { + let (evm_env, mut tx_env) = self.prepare_call_env(from, to.into(), calldata, value); + tx_env.set_signed_authorization(authorization_list); + tx_env.set_tx_type(4); + self.call_with_env(evm_env, tx_env) } /// Performs a raw call to an account on the current state of the VM. @@ -479,30 +781,242 @@ impl Executor { to: Address, calldata: Bytes, value: U256, - ) -> eyre::Result { - let env = self.build_test_env(from, TxKind::Call(to), calldata, value); - self.transact_with_env(env) + ) -> eyre::Result> { + let (evm_env, tx_env) = self.prepare_call_env(from, TxKind::Call(to), calldata, value); + self.transact_with_env(evm_env, tx_env) + } + + /// Performs a raw call to an account on the current state of the VM with an EIP-7702 + /// authorization last. + pub fn transact_raw_with_authorization( + &mut self, + from: Address, + to: Address, + calldata: Bytes, + value: U256, + authorization_list: Vec, + ) -> eyre::Result> { + let (evm_env, mut tx_env) = self.prepare_call_env(from, TxKind::Call(to), calldata, value); + tx_env.set_signed_authorization(authorization_list); + tx_env.set_tx_type(4); + self.transact_with_env(evm_env, tx_env) + } + + /// Applies the EIP-4788 beacon roots system call (Cancun+). + /// + pub fn apply_beacon_root( + &mut self, + parent_beacon_block_root: alloy_primitives::B256, + ) -> eyre::Result<()> { + let calldata = Bytes::from(parent_beacon_block_root); + let mut evm_env = self.evm_env.clone(); + let inspector = self.inspector().clone(); + let mut state = { + let mut backend = CowBackend::new_borrowed(self.backend()); + let mut evm = FEN::EvmFactory::default().create_foundry_evm_with_inspector( + &mut backend, + evm_env.clone(), + inspector, + ); + *evm.chain_mut() = ChainFor::::for_transaction(&TxEnvFor::::default()); + let result = + evm.transact_system_call(SYSTEM_ADDRESS, BEACON_ROOTS_ADDRESS, calldata)?; + evm_env = evm.finish().1; + result.state + }; + state.retain(|address, _| *address == BEACON_ROOTS_ADDRESS); + + self.backend_mut().commit(state); + self.inspector_mut().set_block(evm_env.block_env); + + Ok(()) } - /// Execute the transaction configured in `env.tx`. + /// Execute the transaction configured in `tx_env`. /// /// The state after the call is **not** persisted. #[instrument(name = "call", level = "debug", skip_all)] - pub fn call_with_env(&self, mut env: Env) -> eyre::Result { - let mut inspector = self.inspector().clone(); + pub fn call_with_env( + &self, + evm_env: EvmEnvFor, + tx_env: TxEnvFor, + ) -> eyre::Result> { + let chain_context = self.chain_context_for_synthetic_transaction(&tx_env)?; + self.call_with_env_and_context(evm_env, tx_env, chain_context) + } + + /// Executes the transaction with explicit network-specific context without committing state. + #[instrument(name = "call", level = "debug", skip_all)] + pub fn call_with_env_and_context( + &self, + mut evm_env: EvmEnvFor, + mut tx_env: TxEnvFor, + chain_context: ChainFor, + ) -> eyre::Result> { + let mut stack = self.inspector().clone(); + let sancov_edges = stack.inner.sancov_edges; + let sancov_trace_cmp = stack.inner.sancov_trace_cmp; + let sancov_active = sancov_edges || sancov_trace_cmp; let mut backend = CowBackend::new_borrowed(self.backend()); - let result = backend.inspect(&mut env, &mut inspector)?; - convert_executed_result(env, inspector, result, backend.has_state_snapshot_failure()) + let result = { + let _guard = sancov_active.then(|| SancovGuard::new(sancov_edges, sancov_trace_cmp)); + backend.inspect_with_context(&mut evm_env, &mut tx_env, chain_context, &mut stack)? + }; + let has_state_snapshot_failure = backend.has_state_snapshot_failure(); + let fork_block_number = backend.active_fork_block_number(); + let mut result = convert_executed_result( + evm_env, + tx_env, + stack, + result, + &backend, + has_state_snapshot_failure, + fork_block_number, + )?; + if sancov_edges { + SancovGuard::append_edges_into(&mut result); + } + if sancov_trace_cmp { + SancovGuard::drain_cmp_into(&mut result); + } + Ok(result) } - /// Execute the transaction configured in `env.tx`. + /// Execute the transaction configured in `tx_env`. #[instrument(name = "transact", level = "debug", skip_all)] - pub fn transact_with_env(&mut self, mut env: Env) -> eyre::Result { - let mut inspector = self.inspector().clone(); + pub fn transact_with_env( + &mut self, + evm_env: EvmEnvFor, + tx_env: TxEnvFor, + ) -> eyre::Result> { + let chain_context = self.chain_context_for_synthetic_transaction(&tx_env)?; + self.transact_with_env_and_context(evm_env, tx_env, chain_context) + } + + /// Executes and commits the transaction with explicit network-specific context. + #[instrument(name = "transact", level = "debug", skip_all)] + pub fn transact_with_env_and_context( + &mut self, + mut evm_env: EvmEnvFor, + mut tx_env: TxEnvFor, + chain_context: ChainFor, + ) -> eyre::Result> { + let mut stack = self.inspector().clone(); + let sancov_edges = stack.inner.sancov_edges; + let sancov_trace_cmp = stack.inner.sancov_trace_cmp; + let sancov_active = sancov_edges || sancov_trace_cmp; + let backend = self.backend_mut(); + let result = { + let _guard = sancov_active.then(|| SancovGuard::new(sancov_edges, sancov_trace_cmp)); + backend.inspect_with_context(&mut evm_env, &mut tx_env, chain_context, &mut stack)? + }; + let has_state_snapshot_failure = backend.has_state_snapshot_failure(); + let fork_block_number = backend.active_fork_block_number(); + let mut result = convert_executed_result( + evm_env, + tx_env, + stack, + result, + &*backend, + has_state_snapshot_failure, + fork_block_number, + )?; + if sancov_edges { + SancovGuard::append_edges_into(&mut result); + } + if sancov_trace_cmp { + SancovGuard::drain_cmp_into(&mut result); + } + self.commit(&mut result); + Ok(result) + } + + /// Replays ordinary transactions and executes the target against one EVM instance. + #[instrument(name = "transact_block_replay", level = "debug", skip_all)] + pub fn transact_with_ordinary_block_replay( + &mut self, + mut evm_env: EvmEnvFor, + target_tx_env: TxEnvFor, + replay: Vec<(B256, TxEnvFor)>, + ) -> eyre::Result> { + // EIP-8130 requires phase-aware execution and has no ordinary call/create kind. + // Check the entire prefix before executing anything or accessing ordinary tx fields. + eyre::ensure!( + target_tx_env.tx_type() != 0x79, + "EIP-8130 target transactions are not supported by tooling replay" + ); + for (hash, tx) in &replay { + eyre::ensure!( + tx.tx_type() != 0x79, + "EIP-8130 prefix transaction {hash} is not supported by tooling replay" + ); + } + let block_number = evm_env.block_env.number(); + let mut stack = self.inspector().clone(); + let sancov_edges = stack.inner.sancov_edges; + let sancov_trace_cmp = stack.inner.sancov_trace_cmp; + let sancov_active = sancov_edges || sancov_trace_cmp; let backend = self.backend_mut(); - let result = backend.inspect(&mut env, &mut inspector)?; - let mut result = - convert_executed_result(env, inspector, result, backend.has_state_snapshot_failure())?; + + let (result, evm_env, tx_env) = { + let caller = target_tx_env.caller(); + backend.set_caller(caller).set_spec_id(evm_env.cfg_env.spec); + let target_contract = match target_tx_env.kind() { + TxKind::Call(to) => to, + // The prefix has not run yet, so use the canonical target nonce rather than the + // current database nonce. + TxKind::Create => caller.create(target_tx_env.nonce()), + }; + backend.set_test_contract(target_contract); + let target_chain_context = ChainFor::::for_transaction(&target_tx_env); + if !replay.is_empty() { + evm_env.cfg_env.disable_balance_check = true; + } + let mut evm = FEN::EvmFactory::default() + .create_foundry_evm_with_inspector(backend, evm_env, &mut stack); + *evm.chain_mut() = target_chain_context; + evm.disable_inspector(); + for (tx_hash, tx_env) in replay { + let created = match tx_env.kind() { + TxKind::Create => Some(tx_env.caller().create(tx_env.nonce())), + TxKind::Call(_) => None, + }; + let result = evm.transact(tx_env).wrap_err_with(|| { + format!("Failed to execute transaction: {tx_hash:?} in block {block_number}") + })?; + if result.result.is_success() + && let Some(address) = created + { + evm.db_mut().add_persistent_account(address); + } + evm.db_mut().commit(result.state); + } + + evm.enable_inspector(); + let _guard = sancov_active.then(|| SancovGuard::new(sancov_edges, sancov_trace_cmp)); + let result = evm.transact(target_tx_env).wrap_err("EVM error")?; + let tx_env = evm.tx().clone(); + let evm_env = evm.finish().1; + (result, evm_env, tx_env) + }; + + let has_state_snapshot_failure = backend.has_state_snapshot_failure(); + let fork_block_number = backend.active_fork_block_number(); + let mut result = convert_executed_result( + evm_env, + tx_env, + stack, + result, + &*backend, + has_state_snapshot_failure, + fork_block_number, + )?; + if sancov_edges { + SancovGuard::append_edges_into(&mut result); + } + if sancov_trace_cmp { + SancovGuard::drain_cmp_into(&mut result); + } self.commit(&mut result); Ok(result) } @@ -512,7 +1026,7 @@ impl Executor { /// /// This should not be exposed to the user, as it should be called only by `transact*`. #[instrument(name = "commit", level = "debug", skip_all)] - fn commit(&mut self, result: &mut RawCallResult) { + fn commit(&mut self, result: &mut RawCallResult) { // Persist changes to db. self.backend_mut().commit(result.state_changeset.clone()); @@ -522,7 +1036,6 @@ impl Executor { // Clear broadcastable transactions cheats.broadcastable_transactions.clear(); cheats.ignored_traces.ignored.clear(); - // if tracing was paused but never unpaused, we should begin next frame with tracing // still paused if let Some(last_pause_call) = cheats.ignored_traces.last_pause_call.as_mut() { @@ -531,7 +1044,8 @@ impl Executor { } // Persist the changed environment. - self.inspector_mut().set_env(&result.env); + self.inspector_mut().set_block(result.evm_env.block_env.clone()); + self.inspector_mut().set_gas_price(result.tx_env.gas_price()); } /// Returns `true` if a test can be considered successful. @@ -541,14 +1055,12 @@ impl Executor { pub fn is_raw_call_mut_success( &self, address: Address, - call_result: &mut RawCallResult, - should_fail: bool, + call_result: &mut RawCallResult, ) -> bool { self.is_raw_call_success( address, Cow::Owned(std::mem::take(&mut call_result.state_changeset)), call_result, - should_fail, ) } @@ -559,14 +1071,28 @@ impl Executor { &self, address: Address, state_changeset: Cow<'_, StateChangeset>, - call_result: &RawCallResult, - should_fail: bool, + call_result: &RawCallResult, ) -> bool { if call_result.has_state_snapshot_failure { // a failure occurred in a reverted snapshot, which is considered a failed test - return should_fail; + return false; + } + self.is_success(address, call_result.reverted, state_changeset) + } + + /// Like [`Self::is_raw_call_mut_success`] but uses [`Self::is_success_handler_gate`] under + /// the hood. Intended for invariant view-call success checks during a campaign where the + /// committed `GLOBAL_FAIL_SLOT` may be stale poison from a previously-recorded handler bug. + pub fn is_raw_call_mut_success_handler_gate( + &self, + address: Address, + call_result: &mut RawCallResult, + ) -> bool { + if call_result.has_state_snapshot_failure { + return false; } - self.is_success(address, call_result.reverted, state_changeset, should_fail) + let state_changeset = std::mem::take(&mut call_result.state_changeset); + self.is_success_handler_gate(address, call_result.reverted, Cow::Owned(state_changeset)) } /// Returns `true` if a test can be considered successful. @@ -595,10 +1121,22 @@ impl Executor { address: Address, reverted: bool, state_changeset: Cow<'_, StateChangeset>, - should_fail: bool, ) -> bool { - let success = self.is_success_raw(address, reverted, state_changeset); - should_fail ^ success + self.is_success_raw(address, reverted, state_changeset, false) + } + + /// Like [`Self::is_success`] but ignores the *committed* `GLOBAL_FAIL_SLOT` and only treats + /// the slot as failed when this call's in-flight changeset writes it. Used by the invariant + /// runner's per-call handler-success gate, where a `1` already in committed storage is just + /// stale poison from a previously-recorded handler bug (separately tracked) and must not + /// suppress later `assert_invariants` / `afterInvariant` evaluations. + pub fn is_success_handler_gate( + &self, + address: Address, + reverted: bool, + state_changeset: Cow<'_, StateChangeset>, + ) -> bool { + self.is_success_raw(address, reverted, state_changeset, true) } #[instrument(name = "is_success", level = "debug", skip_all)] @@ -607,6 +1145,7 @@ impl Executor { address: Address, reverted: bool, state_changeset: Cow<'_, StateChangeset>, + pending_global_failure_only: bool, ) -> bool { // The call reverted. if reverted { @@ -618,16 +1157,16 @@ impl Executor { return false; } - // Check the global failure slot. - if let Some(acc) = state_changeset.get(&CHEATCODE_ADDRESS) - && let Some(failed_slot) = acc.storage.get(&GLOBAL_FAIL_SLOT) - && !failed_slot.present_value().is_zero() - { - return false; - } - if let Ok(failed_slot) = self.backend().storage_ref(CHEATCODE_ADDRESS, GLOBAL_FAIL_SLOT) - && !failed_slot.is_zero() - { + // Check the global failure slot. Callers that already track recorded handler bugs + // out-of-band can pass `pending_global_failure_only = true` to ignore the committed + // slot (which would otherwise stay `1` for the rest of the run after a non-reverting + // `vm.assert*` under `assertions_revert = false`). + let global_failed = if pending_global_failure_only { + Self::has_pending_global_failure(&state_changeset) + } else { + self.has_global_failure(&state_changeset) + }; + if global_failed { return false; } @@ -669,40 +1208,64 @@ impl Executor { } } + /// Returns whether the in-flight state changeset for the current call sets the global + /// assertion failure flag. + pub fn has_pending_global_failure(state_changeset: &StateChangeset) -> bool { + if let Some(acc) = state_changeset.get(&CHEATCODE_ADDRESS) + && let Some(failed_slot) = acc.storage.get(&GLOBAL_FAIL_SLOT) + && !failed_slot.present_value().is_zero() + { + return true; + } + + false + } + + /// Returns whether the global assertion failure flag is set either in the in-flight state + /// changeset or in the committed backend state. + pub fn has_global_failure(&self, state_changeset: &StateChangeset) -> bool { + if Self::has_pending_global_failure(state_changeset) { + return true; + } + + self.backend() + .storage_ref(CHEATCODE_ADDRESS, GLOBAL_FAIL_SLOT) + .is_ok_and(|failed_slot| !failed_slot.is_zero()) + } + /// Creates the environment to use when executing a transaction in a test context /// /// If using a backend with cheatcodes, `tx.gas_price` and `block.number` will be overwritten by /// the cheatcode state in between calls. - fn build_test_env(&self, caller: Address, kind: TxKind, data: Bytes, value: U256) -> Env { - Env { - evm_env: EvmEnv { - cfg_env: { - let mut cfg = self.env().evm_env.cfg_env.clone(); - cfg.spec = self.spec_id(); - cfg - }, - // We always set the gas price to 0 so we can execute the transaction regardless of - // network conditions - the actual gas price is kept in `self.block` and is applied - // by the cheatcode handler if it is enabled - block_env: BlockEnv { - basefee: 0, - gas_limit: self.gas_limit, - ..self.env().evm_env.block_env.clone() - }, - }, - tx: TxEnv { - caller, - kind, - data, - value, - // As above, we set the gas price to 0. - gas_price: 0, - gas_priority_fee: None, - gas_limit: self.gas_limit, - chain_id: Some(self.env().evm_env.cfg_env.chain_id), - ..self.env().tx.clone() - }, - } + pub fn prepare_call_env( + &self, + caller: Address, + kind: TxKind, + data: Bytes, + value: U256, + ) -> (EvmEnvFor, TxEnvFor) { + let mut cfg_env = self.evm_env.cfg_env.clone(); + cfg_env.spec = self.spec_id(); + + // We always set the gas price to 0 so we can execute the transaction regardless of + // network conditions - the actual gas price is kept in `self.block` and is applied + // by the cheatcode handler if it is enabled + let mut block_env = self.evm_env.block_env.clone(); + block_env.set_basefee(0); + block_env.set_gas_limit(self.gas_limit); + + let mut tx_env = self.tx_env.clone(); + tx_env.set_caller(caller); + tx_env.set_kind(kind); + tx_env.set_data(data); + tx_env.set_value(value); + // As above, we set the gas price to 0. + tx_env.set_gas_price(0); + tx_env.set_gas_priority_fee(None); + tx_env.set_gas_limit(self.gas_limit); + tx_env.set_chain_id(Some(self.evm_env.cfg_env.chain_id)); + + (EvmEnv { cfg_env, block_env }, tx_env) } pub fn call_sol_default(&self, to: Address, args: &C) -> C::Return @@ -719,15 +1282,15 @@ impl Executor { /// Represents the context after an execution error occurred. #[derive(Debug, thiserror::Error)] #[error("execution reverted: {reason} (gas: {})", raw.gas_used)] -pub struct ExecutionErr { +pub struct ExecutionErr { /// The raw result of the call. - pub raw: RawCallResult, + pub raw: RawCallResult, /// The revert reason. pub reason: String, } -impl std::ops::Deref for ExecutionErr { - type Target = RawCallResult; +impl std::ops::Deref for ExecutionErr { + type Target = RawCallResult; #[inline] fn deref(&self) -> &Self::Target { @@ -735,7 +1298,7 @@ impl std::ops::Deref for ExecutionErr { } } -impl std::ops::DerefMut for ExecutionErr { +impl std::ops::DerefMut for ExecutionErr { #[inline] fn deref_mut(&mut self) -> &mut Self::Target { &mut self.raw @@ -743,10 +1306,10 @@ impl std::ops::DerefMut for ExecutionErr { } #[derive(Debug, thiserror::Error)] -pub enum EvmError { +pub enum EvmError { /// Error which occurred during execution of a transaction. #[error(transparent)] - Execution(#[from] Box), + Execution(Box>), /// Error which occurred during ABI encoding/decoding. #[error(transparent)] Abi(#[from] alloy_dyn_abi::Error), @@ -762,13 +1325,13 @@ pub enum EvmError { ), } -impl From for EvmError { - fn from(err: ExecutionErr) -> Self { +impl From> for EvmError { + fn from(err: ExecutionErr) -> Self { Self::Execution(Box::new(err)) } } -impl From for EvmError { +impl From for EvmError { fn from(err: alloy_sol_types::Error) -> Self { Self::Abi(err.into()) } @@ -776,15 +1339,15 @@ impl From for EvmError { /// The result of a deployment. #[derive(Debug)] -pub struct DeployResult { +pub struct DeployResult { /// The raw result of the deployment. - pub raw: RawCallResult, + pub raw: RawCallResult, /// The address of the deployed contract pub address: Address, } -impl std::ops::Deref for DeployResult { - type Target = RawCallResult; +impl std::ops::Deref for DeployResult { + type Target = RawCallResult; #[inline] fn deref(&self) -> &Self::Target { @@ -792,24 +1355,26 @@ impl std::ops::Deref for DeployResult { } } -impl std::ops::DerefMut for DeployResult { +impl std::ops::DerefMut for DeployResult { #[inline] fn deref_mut(&mut self) -> &mut Self::Target { &mut self.raw } } -impl From for RawCallResult { - fn from(d: DeployResult) -> Self { +impl From> for RawCallResult { + fn from(d: DeployResult) -> Self { d.raw } } /// The result of a raw call. #[derive(Debug)] -pub struct RawCallResult { +pub struct RawCallResult { /// The status of the call pub exit_reason: Option, + /// Whether the call was halted by the execution cancellation inspector. + pub execution_cancelled: bool, /// Whether the call reverted or not pub reverted: bool, /// Whether the call includes a snapshot failure @@ -831,29 +1396,50 @@ pub struct RawCallResult { pub labels: AddressHashMap, /// The traces of the call pub traces: Option, + /// Runtime bytecodes for contracts seen in the trace, used by debug source mapping. + pub debug_bytecodes: AddressHashMap, /// The line coverage info collected during the call pub line_coverage: Option, /// The edge coverage info collected during the call - pub edge_coverage: Option>, + pub edge_coverage: Option, + /// EVM comparison operands collected during the call. + pub evm_cmp_values: Option>, + /// Observed sub-calls collected during the call. + pub observed_calls: Vec, + /// Sancov edge coverage from instrumented native Rust crates (e.g. precompiles). + /// Tracked separately from EVM edge coverage to avoid ID-space collisions. + pub sancov_coverage: Option>, + /// Comparison operands captured via sancov trace-cmp callbacks. + pub sancov_cmp_values: Option>, /// Scripted transactions generated from this call - pub transactions: Option, + pub transactions: Option>, /// The changeset of the state. pub state_changeset: StateChangeset, - /// The `revm::Env` after the call - pub env: Env, + /// The `EvmEnv` after the call + pub evm_env: EvmEnvFor, + /// The `TxEnv` after the call + pub tx_env: TxEnvFor, /// The cheatcode states after execution - pub cheatcodes: Option, + pub cheatcodes: Option>>, /// The raw output of the execution pub out: Option, + /// The active fork's block number after execution, if any. + pub fork_block_number: Option, /// The chisel state - pub chisel_state: Option<(Vec, Vec, Option)>, + pub chisel_state: Option<(Vec, Vec)>, pub reverter: Option
, + /// Revert payloads minted by the `skip` cheatcode during this call. + /// + /// Moved out of the cheatcode state on conversion since `commit` moves that state back into + /// the executor before results are classified. + pub skip_payloads: Vec, } -impl Default for RawCallResult { +impl Default for RawCallResult { fn default() -> Self { Self { exit_reason: None, + execution_cancelled: false, reverted: false, has_state_snapshot_failure: false, result: Bytes::new(), @@ -863,22 +1449,30 @@ impl Default for RawCallResult { logs: Vec::new(), labels: HashMap::default(), traces: None, + debug_bytecodes: HashMap::default(), line_coverage: None, edge_coverage: None, + evm_cmp_values: None, + observed_calls: Vec::new(), + sancov_coverage: None, + sancov_cmp_values: None, transactions: None, state_changeset: HashMap::default(), - env: Env::default(), + evm_env: EvmEnv::default(), + tx_env: TxEnvFor::::default(), cheatcodes: Default::default(), out: None, + fork_block_number: None, chisel_state: None, reverter: None, + skip_payloads: Vec::new(), } } } -impl RawCallResult { +impl RawCallResult { /// Unpacks an EVM result. - pub fn from_evm_result(r: Result) -> eyre::Result<(Self, Option)> { + pub fn from_evm_result(r: Result>) -> eyre::Result<(Self, Option)> { match r { Ok(r) => Ok((r, None)), Err(EvmError::Execution(e)) => Ok((e.raw, Some(e.reason))), @@ -886,17 +1480,20 @@ impl RawCallResult { } } - /// Unpacks an execution result. - pub fn from_execution_result(r: Result) -> (Self, Option) { - match r { - Ok(r) => (r, None), - Err(e) => (e.raw, Some(e.reason)), + /// Returns the skip reason if this call reverted with a genuine `vm.skip` payload. + /// + /// The revert data must byte-equal a payload recorded by the skip cheatcode during this call; + /// a matching `FOUNDRY::SKIP` prefix alone (user-crafted revert data) does not count. + pub fn skip_reason(&self) -> Option { + if !self.reverted || !self.skip_payloads.contains(&self.result) { + return None; } + SkipReason::decode(&self.result) } /// Converts the result of the call into an `EvmError`. - pub fn into_evm_error(self, rd: Option<&RevertDecoder>) -> EvmError { - if let Some(reason) = SkipReason::decode(&self.result) { + pub fn into_evm_error(self, rd: Option<&RevertDecoder>) -> EvmError { + if let Some(reason) = self.skip_reason() { return EvmError::Skip(reason); } let reason = rd.unwrap_or_default().decode(&self.result, self.exit_reason); @@ -904,12 +1501,12 @@ impl RawCallResult { } /// Converts the result of the call into an `ExecutionErr`. - pub fn into_execution_error(self, reason: String) -> ExecutionErr { + pub const fn into_execution_error(self, reason: String) -> ExecutionErr { ExecutionErr { raw: self, reason } } /// Returns an `EvmError` if the call failed, otherwise returns `self`. - pub fn into_result(self, rd: Option<&RevertDecoder>) -> Result { + pub fn into_result(self, rd: Option<&RevertDecoder>) -> Result> { if let Some(reason) = self.exit_reason && reason.is_ok() { @@ -924,76 +1521,149 @@ impl RawCallResult { mut self, func: &Function, rd: Option<&RevertDecoder>, - ) -> Result { + ) -> Result, EvmError> { self = self.into_result(rd)?; let mut result = func.abi_decode_output(&self.result)?; - let decoded_result = if result.len() == 1 { - result.pop().unwrap() - } else { - // combine results into a tuple - DynSolValue::Tuple(result) - }; + let decoded_result = + if result.len() == 1 { result.pop().unwrap() } else { DynSolValue::Tuple(result) }; Ok(CallResult { raw: self, decoded_result }) } /// Returns the transactions generated from this call. - pub fn transactions(&self) -> Option<&BroadcastableTransactions> { + pub fn transactions(&self) -> Option<&BroadcastableTransactions> { self.cheatcodes.as_ref().map(|c| &c.broadcastable_transactions) } /// Update provided history map with edge coverage info collected during this call. - /// Uses AFL binning algo - pub fn merge_edge_coverage(&mut self, history_map: &mut [u8]) -> (bool, bool) { + pub fn merge_edge_coverage( + &mut self, + history_map: &mut Vec, + edge_indices: &mut EdgeIndexMap, + ) -> (bool, bool) { let mut new_coverage = false; let mut is_edge = false; if let Some(x) = &mut self.edge_coverage { - // Iterate over the current map and the history map together and update - // the history map, if we discover some new coverage, report true - for (curr, hist) in std::iter::zip(x, history_map) { - // If we got a hitcount of at least 1 - if *curr > 0 { - // Convert hitcount into bucket count - let bucket = match *curr { - 0 => 0, - 1 => 1, - 2 => 2, - 3 => 4, - 4..=7 => 8, - 8..=15 => 16, - 16..=31 => 32, - 32..=127 => 64, - 128..=255 => 128, - }; + match x { + EdgeCoverage::Hash(x) => { + if history_map.len() < x.len() { + history_map.resize(x.len(), 0); + } + // Iterate over the current map and the history map together and update + // the history map, if we discover some new coverage, report true + for (curr, hist) in std::iter::zip(x.iter_mut(), history_map.iter_mut()) { + Self::merge_edge_count(*curr, hist, &mut new_coverage, &mut is_edge); - // If the old record for this edge pair is lower, update - if *hist < bucket { + // Hash reuses its map; collision-free drains hits. + *curr = 0; + } + } + EdgeCoverage::CollisionFree(hits) => { + for hit in hits.drain(..) { + let edge_index = edge_indices.edge_index(hit.edge); + if history_map.len() <= edge_index { + history_map.resize(edge_index + 1, 0); + } + Self::merge_edge_count( + hit.count, + &mut history_map[edge_index], + &mut new_coverage, + &mut is_edge, + ); + } + } + } + } + (new_coverage, is_edge) + } + + const fn merge_edge_count( + curr: u8, + hist: &mut u8, + new_coverage: &mut bool, + is_edge: &mut bool, + ) { + let Some(bucket) = Self::bin_count(curr) else { + return; + }; + + // If the old record for this edge pair is lower, update + if *hist < bucket { + if *hist == 0 { + // Counts as an edge the first time we see it, otherwise it's a feature. + *is_edge = true; + } + *hist = bucket; + *new_coverage = true; + } + } + + /// Convert a hitcount into an AFL-style bucket. + /// + const fn bin_count(count: u8) -> Option { + match count { + 0 => None, + 1 => Some(1), + 2 => Some(2), + 3 => Some(4), + 4..=7 => Some(8), + 8..=15 => Some(16), + 16..=31 => Some(32), + 32..=127 => Some(64), + 128..=255 => Some(128), + } + } + + /// Update provided history map with sancov coverage info collected during this call. + /// Uses AFL-style hitcount binning. + pub fn merge_sancov_coverage(&mut self, history_map: &mut Vec) -> (bool, bool) { + let mut new_coverage = false; + let mut is_edge = false; + if let Some(x) = &mut self.sancov_coverage { + if history_map.len() < x.len() { + history_map.resize(x.len(), 0); + } + for (curr, hist) in std::iter::zip(x.iter_mut(), history_map.iter_mut()) { + if *curr > 0 { + if let Some(bucket) = Self::bin_count(*curr) + && *hist < bucket + { if *hist == 0 { - // Counts as an edge the first time we see it, otherwise it's a feature. is_edge = true; } *hist = bucket; new_coverage = true; } - - // Zero out the current map for next iteration. *curr = 0; } } } (new_coverage, is_edge) } + + /// Merge both EVM and sancov coverage into their respective history maps. + /// Returns `(new_coverage, is_edge)` — true if either domain produced new coverage. + pub fn merge_all_coverage( + &mut self, + evm_history: &mut Vec, + evm_edge_indices: &mut EdgeIndexMap, + sancov_history: &mut Vec, + ) -> (bool, bool) { + let (new_evm, edge_evm) = self.merge_edge_coverage(evm_history, evm_edge_indices); + let (new_san, edge_san) = self.merge_sancov_coverage(sancov_history); + (new_evm || new_san, edge_evm || edge_san) + } } /// The result of a call. -pub struct CallResult { +pub struct CallResult { /// The raw result of the call. - pub raw: RawCallResult, + pub raw: RawCallResult, /// The decoded result of the call. pub decoded_result: T, } -impl std::ops::Deref for CallResult { - type Target = RawCallResult; +impl std::ops::Deref for CallResult { + type Target = RawCallResult; #[inline] fn deref(&self) -> &Self::Target { @@ -1001,45 +1671,63 @@ impl std::ops::Deref for CallResult { } } -impl std::ops::DerefMut for CallResult { +impl std::ops::DerefMut for CallResult { #[inline] fn deref_mut(&mut self) -> &mut Self::Target { &mut self.raw } } -/// Converts the data aggregated in the `inspector` and `call` to a `RawCallResult` -fn convert_executed_result( - env: Env, - inspector: InspectorStack, - ResultAndState { result, state: state_changeset }: ResultAndState, +pub(crate) fn calculate_stipend(tx_env: &impl Transaction, cfg: &impl Cfg) -> u64 { + calculate_initial_gas(tx_env, cfg).initial_total_gas() +} + +/// Returns the intrinsic gas and EIP-7623 calldata floor of a transaction. +pub(crate) fn calculate_initial_gas( + tx_env: &impl Transaction, + cfg: &impl Cfg, +) -> InitialAndFloorGas { + let eip2780 = cfg.is_amsterdam_eip2780_enabled().then(|| Eip2780TxInfo { + value: tx_env.value(), + is_self_transfer: matches!(tx_env.kind(), TxKind::Call(to) if to == tx_env.caller()), + }); + revm::interpreter::gas::calculate_initial_tx_gas_for_tx(tx_env, cfg.spec().into(), eip2780) +} + +/// Converts the data aggregated in the `inspector` and `call` to a `RawCallResult`. +fn convert_executed_result( + evm_env: EvmEnvFor, + tx_env: TxEnvFor, + mut inspector: InspectorStack, + ResultAndState { result, state: state_changeset }: ResultAndState, + db: &dyn DatabaseRef, has_state_snapshot_failure: bool, -) -> eyre::Result { + fork_block_number: Option, +) -> eyre::Result> { + let execution_cancelled = inspector.execution_cancelled(); let (exit_reason, gas_refunded, gas_used, out, exec_logs) = match result { - ExecutionResult::Success { reason, gas_used, gas_refunded, output, logs, .. } => { - (reason.into(), gas_refunded, gas_used, Some(output), logs) + ExecutionResult::Success { reason, gas, output, logs } => { + (reason.into(), gas.final_refunded(), gas.tx_gas_used(), Some(output), logs) } - ExecutionResult::Revert { gas_used, output } => { - // Need to fetch the unused gas - (InstructionResult::Revert, 0_u64, gas_used, Some(Output::Call(output)), vec![]) + ExecutionResult::Revert { gas, output, logs } => { + (InstructionResult::Revert, 0_u64, gas.tx_gas_used(), Some(Output::Call(output)), logs) } - ExecutionResult::Halt { reason, gas_used } => { - (reason.into(), 0_u64, gas_used, None, vec![]) + ExecutionResult::Halt { reason, gas, logs } => { + (reason.into_instruction_result(), 0_u64, gas.tx_gas_used(), None, logs) } }; - let gas = revm::interpreter::gas::calculate_initial_tx_gas( - env.evm_env.cfg_env.spec, - &env.tx.data, - env.tx.kind.is_create(), - env.tx.access_list.len().try_into()?, - 0, - 0, - ); + let stipend = calculate_stipend(&tx_env, evm_env.cfg_env()); let result = match &out { Some(Output::Call(data)) => data.clone(), _ => Bytes::new(), }; + let observed_calls = inspector + .inner + .fuzzer + .as_mut() + .map(|fuzzer| fuzzer.take_observed_calls()) + .unwrap_or_default(); let InspectorData { mut logs, @@ -1047,10 +1735,16 @@ fn convert_executed_result( traces, line_coverage, edge_coverage, - cheatcodes, + evm_cmp_values, + mut cheatcodes, chisel_state, reverter, } = inspector.collect(); + let fork_block_number = cheatcodes + .as_ref() + .and_then(|cheats| cheats.fork_block_number_override) + .or(fork_block_number); + let debug_bytecodes = collect_debug_bytecodes(traces.as_ref(), db); if logs.is_empty() { logs = exec_logs; @@ -1060,30 +1754,67 @@ fn convert_executed_result( .as_ref() .map(|c| c.broadcastable_transactions.clone()) .filter(|txs| !txs.is_empty()); + let skip_payloads = + cheatcodes.as_mut().map(|c| std::mem::take(&mut c.skip_payloads)).unwrap_or_default(); Ok(RawCallResult { exit_reason: Some(exit_reason), - reverted: !matches!(exit_reason, return_ok!()), + execution_cancelled, + reverted: !exit_reason.is_ok(), has_state_snapshot_failure, result, gas_used, gas_refunded, - stipend: gas.initial_gas, + stipend, logs, labels, traces, + debug_bytecodes, line_coverage, edge_coverage, + evm_cmp_values, + observed_calls, + sancov_coverage: None, + sancov_cmp_values: None, transactions, state_changeset, - env, + evm_env, + tx_env, cheatcodes, out, + fork_block_number, chisel_state, reverter, + skip_payloads, }) } +fn collect_debug_bytecodes( + traces: Option<&SparsedTraceArena>, + db: &dyn DatabaseRef, +) -> AddressHashMap { + let mut bytecodes = HashMap::default(); + let Some(traces) = traces else { return bytecodes }; + + for node in traces.arena.nodes() { + let address = node.trace.address; + if bytecodes.contains_key(&address) { + continue; + } + + let Ok(Some(account)) = db.basic_ref(address) else { continue }; + let code: Option = + account.code.or_else(|| db.code_by_hash_ref(account.code_hash).ok()); + let code: Bytes = code.map(|code| code.original_bytes()).unwrap_or_default(); + + if !code.is_empty() { + bytecodes.insert(address, code); + } + } + + bytecodes +} + /// Timer for a fuzz test. pub struct FuzzTestTimer { /// Inner fuzz test timer - (test start time, test duration). @@ -1095,8 +1826,996 @@ impl FuzzTestTimer { Self { inner: timeout.map(|timeout| (Instant::now(), Duration::from_secs(timeout.into()))) } } + /// Whether the fuzz test timer is enabled. + pub const fn is_enabled(&self) -> bool { + self.inner.is_some() + } + /// Whether the current fuzz test timed out and should be stopped. pub fn is_timed_out(&self) -> bool { self.inner.is_some_and(|(start, duration)| start.elapsed() > duration) } } + +/// Helper struct to enable early exit behavior: when one test fails or run is interrupted, +/// all other tests stop early. +#[derive(Clone, Debug)] +pub struct EarlyExit { + /// Shared atomic flag set to `true` when a failure occurs or ctrl-c received. + inner: Arc, + /// Whether to exit early on test failure (fail-fast mode). + fail_fast: bool, +} + +impl EarlyExit { + pub fn new(fail_fast: bool) -> Self { + Self { inner: Arc::new(AtomicBool::new(false)), fail_fast } + } + + /// Records a test failure. Only triggers early exit if fail-fast mode is enabled. + pub fn record_failure(&self) { + if self.fail_fast { + self.inner.store(true, Ordering::Relaxed); + } + } + + /// Records a Ctrl-C interrupt. Always triggers early exit. + pub fn record_ctrl_c(&self) { + self.inner.store(true, Ordering::Relaxed); + } + + /// Whether tests should stop and exit early. + pub fn should_stop(&self) -> bool { + self.inner.load(Ordering::Relaxed) + } +} + +/// Shared cancellation state for an active EVM execution. +#[derive(Clone, Debug)] +pub(crate) enum EvmExecutionCancellation { + /// Cancellation driven only by the process-wide early-exit signal. + EarlyExit(EarlyExit), + /// Cancellation driven by the complete invariant campaign stop condition. + Campaign { early_exit: EarlyExit, stop: Arc, deadline: Option }, +} + +impl EvmExecutionCancellation { + pub(crate) const fn early_exit(early_exit: EarlyExit) -> Self { + Self::EarlyExit(early_exit) + } + + pub(crate) const fn campaign( + early_exit: EarlyExit, + stop: Arc, + deadline: Option, + ) -> Self { + Self::Campaign { early_exit, stop, deadline } + } + + /// Returns whether execution should stop, optionally polling a campaign deadline. + pub(crate) fn should_stop(&self, poll_deadline: bool) -> bool { + match self { + Self::EarlyExit(early_exit) => early_exit.should_stop(), + Self::Campaign { early_exit, stop, deadline } => { + if early_exit.should_stop() || stop.load(Ordering::Relaxed) { + return true; + } + if poll_deadline && deadline.is_some_and(|deadline| Instant::now() > deadline) { + stop.store(true, Ordering::Relaxed); + return true; + } + false + } + } + } + + pub(crate) fn request_stop(&self) { + if let Self::Campaign { stop, .. } = self { + stop.store(true, Ordering::Relaxed); + } + } + + /// Returns whether a campaign stop was requested or its deadline was observed. + pub(crate) fn stop_requested(&self) -> bool { + matches!(self, Self::Campaign { stop, .. } if stop.load(Ordering::Relaxed)) + } + + pub(crate) const fn early_exit_ref(&self) -> &EarlyExit { + match self { + Self::EarlyExit(early_exit) | Self::Campaign { early_exit, .. } => early_exit, + } + } +} + +/// Returns whether a nested revert can be ignored when fail-on-revert is disabled. +#[inline] +pub fn should_ignore_revert( + fail_on_revert: bool, + target: Address, + reverter: Option
, + extra_cheatcode_addresses: &[Address], +) -> bool { + !fail_on_revert + && reverter.is_some_and(|reverter| { + reverter != target + && reverter != CHEATCODE_ADDRESS + && !extra_cheatcode_addresses.contains(&reverter) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::inspectors::{EdgeCovHit, EdgeKey}; + use foundry_cheatcodes::{ + CheatsConfig, + Vm::{blobhashesCall, mockCallRevert_1Call, revertToStateCall, snapshotStateCall}, + }; + use foundry_config::Config; + use foundry_evm_core::{constants::MAGIC_SKIP, evm::TempoEvmNetwork, opts::EvmOpts}; + use foundry_evm_hardforks::TempoHardfork; + use foundry_evm_traces::InternalTraceMode; + use revm::context::CfgEnv; + use std::{sync::mpsc, thread}; + use tempo_revm::{TempoBlockEnv, TempoTxEnv}; + + #[cfg(not(feature = "monad"))] + use revm::context::TxEnv; + + #[cfg(feature = "base")] + use foundry_evm_core::evm::BaseEvmNetwork; + #[cfg(feature = "base")] + use foundry_evm_hardforks::BaseSpecId; + + #[cfg(feature = "monad")] + use foundry_evm_core::constants::MONAD_CHEATCODE_ADDRESS; + + fn dense_call(edge: EdgeKey) -> RawCallResult { + RawCallResult { + edge_coverage: Some(EdgeCoverage::CollisionFree(vec![EdgeCovHit { edge, count: 1 }])), + ..Default::default() + } + } + + #[test] + fn nested_revert_is_ignored_only_when_allowed() { + let target = Address::repeat_byte(0x11); + let nested = Address::repeat_byte(0x22); + + assert!(should_ignore_revert(false, target, Some(nested), &[])); + assert!(!should_ignore_revert(true, target, Some(nested), &[])); + assert!(!should_ignore_revert(false, target, Some(target), &[])); + assert!(!should_ignore_revert(false, target, Some(CHEATCODE_ADDRESS), &[])); + assert!(!should_ignore_revert(false, target, None, &[])); + } + + #[cfg(feature = "monad")] + #[test] + fn network_cheatcode_revert_handling_is_monad_specific() { + let target = Address::repeat_byte(0x11); + + assert!(should_ignore_revert(false, target, Some(MONAD_CHEATCODE_ADDRESS), &[])); + assert!(!should_ignore_revert( + false, + target, + Some(MONAD_CHEATCODE_ADDRESS), + &[MONAD_CHEATCODE_ADDRESS], + )); + } + + #[cfg(feature = "monad")] + #[test] + fn executor_tooling_follows_concrete_builder() { + let ethereum = ExecutorBuilder::::new().build( + EvmEnv::default(), + TxEnv::default(), + Backend::spawn(None).unwrap(), + NetworkConfigs::with_monad(), + ); + assert!(ethereum.backend().networks().is_monad()); + assert!(!ethereum.backend().is_persistent(&MONAD_CHEATCODE_ADDRESS)); + + let monad = ExecutorBuilder::::new().build( + EvmEnv::::default(), + TxEnv::default(), + Backend::spawn(None).unwrap(), + NetworkConfigs::with_monad(), + ); + assert!(monad.inspector().networks.is_monad()); + assert!(monad.backend().networks().is_monad()); + assert!(monad.backend().is_persistent(&MONAD_CHEATCODE_ADDRESS)); + } + + #[test] + fn tempo_labels_follow_concrete_builder() { + let ethereum = ExecutorBuilder::::new().build( + EvmEnv::default(), + TxEnv::default(), + Backend::spawn(None).unwrap(), + NetworkConfigs::with_tempo(), + ); + assert!(ethereum.inspector().tempo_labels.is_none()); + + let tempo = ExecutorBuilder::::new().build( + EvmEnv::::default(), + TempoTxEnv::default(), + Backend::spawn(None).unwrap(), + NetworkConfigs::default(), + ); + assert!(tempo.inspector().tempo_labels.is_some()); + } + + #[test] + fn collision_free_edge_merge_uses_stable_indices() { + let first = + EdgeKey { address: Address::ZERO, depth: None, pc: 0, jump_dest: U256::from(10) }; + let second = + EdgeKey { address: Address::ZERO, depth: None, pc: 0, jump_dest: U256::from(20) }; + let mut history = Vec::new(); + let mut edge_indices = EdgeIndexMap::default(); + + assert_eq!( + dense_call(first).merge_edge_coverage(&mut history, &mut edge_indices), + (true, true) + ); + assert_eq!(history, [1]); + + assert_eq!( + dense_call(second).merge_edge_coverage(&mut history, &mut edge_indices), + (true, true) + ); + assert_eq!(history, [1, 1]); + + assert_eq!( + dense_call(first).merge_edge_coverage(&mut history, &mut edge_indices), + (false, false) + ); + assert_eq!(history, [1, 1]); + } + + #[test] + fn collision_free_edge_merge_handles_sparse_observation_indices() { + let first = + EdgeKey { address: Address::ZERO, depth: None, pc: 0, jump_dest: U256::from(10) }; + let second = + EdgeKey { address: Address::ZERO, depth: None, pc: 0, jump_dest: U256::from(20) }; + let mut edge_indices = EdgeIndexMap::default(); + edge_indices.edge_index(first); + edge_indices.edge_index(second); + let mut history = Vec::new(); + + assert_eq!( + dense_call(second).merge_edge_coverage(&mut history, &mut edge_indices), + (true, true) + ); + assert_eq!(history, [0, 1]); + } + + #[test] + fn cheatcode_skip_payload_is_classified_as_skip() { + let raw = RawCallResult:: { + reverted: true, + result: Bytes::from_static(b"FOUNDRY::SKIPwith reason"), + skip_payloads: vec![Bytes::from_static(b"FOUNDRY::SKIPwith reason")], + ..Default::default() + }; + + let err = raw.into_evm_error(None); + assert!(matches!(err, EvmError::Skip(_))); + } + + #[test] + fn forged_skip_payload_is_execution_error() { + let raw = RawCallResult:: { + reverted: true, + result: Bytes::from_static(MAGIC_SKIP), + reverter: Some(CHEATCODE_ADDRESS), + ..Default::default() + }; + + let err = raw.into_evm_error(None); + assert!(matches!(err, EvmError::Execution(_))); + } + + #[cfg(feature = "base")] + #[test] + fn base_block_replay_rejects_eip8130_before_execution() { + for target_is_eip8130 in [true, false] { + let mut executor = ExecutorBuilder::::new().build( + EvmEnv::::default(), + TxEnvFor::::default(), + Backend::spawn(None).unwrap(), + NetworkConfigs::with_base(), + ); + let unsupported = TxEnvFor:: { + eip8130: Some( + serde_json::from_value(serde_json::json!({ + "signed": { + "tx": { + "chainId": 8453, "sender": null, "nonceKey": "0x0", + "nonceSequence": 0, "validAfter": 0, "validBefore": 0, + "maxPriorityFeePerGas": "0x0", "maxFeePerGas": "0x0", + "gasLimit": 100000, "accountChanges": [], "calls": [], + "metadata": "0x", "payer": null + }, + "senderAuth": "0x", "payerAuth": "0x" + }, + "mode": "Verified", "simulation_sender_actor_id": null + })) + .unwrap(), + ), + ..Default::default() + }; + let ordinary = TxEnvFor::::default(); + let hash = B256::repeat_byte(1); + let (target, prefix, expected) = if target_is_eip8130 { + ( + unsupported, + vec![(hash, ordinary)], + "EIP-8130 target transactions are not supported by tooling replay".to_string(), + ) + } else { + ( + ordinary.clone(), + vec![(B256::ZERO, ordinary), (hash, unsupported)], + format!( + "EIP-8130 prefix transaction {hash} is not supported by tooling replay" + ), + ) + }; + let error = executor + .transact_with_ordinary_block_replay( + EvmEnv::::default(), + target, + prefix, + ) + .unwrap_err(); + assert_eq!(error.to_string(), expected); + assert_eq!(executor.get_nonce(Address::ZERO).unwrap(), 0); + } + } + + #[test] + fn block_replay_commits_prefix_and_traces_only_target() { + let backend = Backend::::spawn(None).unwrap(); + let mut executor = ExecutorBuilder::default().gas_limit(1 << 20).build( + EvmEnv::default(), + TxEnv::default(), + backend, + NetworkConfigs::default(), + ); + executor.set_balance(CALLER, U256::MAX).unwrap(); + executor.set_trace_requirements(TraceRequirements::none().with_calls(true)); + + let address = Address::repeat_byte(0x11); + // Increment slot zero and return its new value. + executor + .set_code( + address, + Bytecode::new_raw(Bytes::from_static(&[ + 0x60, 0x00, 0x54, 0x60, 0x01, 0x01, 0x80, 0x60, 0x00, 0x55, 0x60, 0x00, 0x52, + 0x60, 0x20, 0x60, 0x00, 0xf3, + ])), + ) + .unwrap(); + let prefix = TxEnv { + caller: CALLER, + gas_limit: 100_000, + kind: TxKind::Call(address), + ..Default::default() + }; + let reverted_create = TxEnv { + nonce: 1, + kind: TxKind::Create, + data: Bytes::from_static(&[0x5f, 0x5f, 0xfd]), + ..prefix.clone() + }; + let target = TxEnv { nonce: 2, ..prefix.clone() }; + + let result = executor + .transact_with_ordinary_block_replay( + EvmEnv::default(), + target, + vec![(B256::repeat_byte(1), prefix), (B256::repeat_byte(2), reverted_create)], + ) + .unwrap(); + + assert_eq!(result.result, Bytes::from(B256::with_last_byte(2).0)); + assert_eq!(result.tx_env.nonce, 2); + assert_eq!(executor.get_nonce(CALLER).unwrap(), 3); + assert_eq!(executor.backend().storage_ref(address, U256::ZERO).unwrap(), U256::from(2)); + assert_eq!(result.traces.unwrap().arena.nodes().len(), 1); + } + + #[test] + fn block_replay_initializes_create_target_from_canonical_nonce() { + let backend = Backend::::spawn(None).unwrap(); + let mut executor = ExecutorBuilder::default().gas_limit(1 << 20).build( + EvmEnv::default(), + TxEnv::default(), + backend, + NetworkConfigs::default(), + ); + executor.set_balance(CALLER, U256::MAX).unwrap(); + + let prefix = TxEnv { + caller: CALLER, + gas_limit: 100_000, + kind: TxKind::Call(Address::repeat_byte(0x11)), + ..Default::default() + }; + let target = TxEnv { + nonce: 1, + kind: TxKind::Create, + data: Bytes::from_static(&[0x00]), + ..prefix.clone() + }; + let expected = CALLER.create(1); + + let result = executor + .transact_with_ordinary_block_replay( + EvmEnv::default(), + target, + vec![(B256::repeat_byte(1), prefix)], + ) + .unwrap(); + + assert!( + matches!(result.out, Some(Output::Create(_, Some(address))) if address == expected) + ); + assert!(executor.backend().is_persistent(&expected)); + assert!(executor.backend().has_cheatcode_access(&expected)); + } + + #[test] + fn block_replay_preserves_successful_prefix_deployment() { + let backend = Backend::::spawn(None).unwrap(); + let mut executor = ExecutorBuilder::default().gas_limit(1 << 20).build( + EvmEnv::default(), + TxEnv::default(), + backend, + NetworkConfigs::default(), + ); + executor.set_balance(CALLER, U256::MAX).unwrap(); + + let deployed = CALLER.create(0); + let prefix = TxEnv { + caller: CALLER, + gas_limit: 100_000, + kind: TxKind::Create, + data: Bytes::from_static(&[0x00]), + ..Default::default() + }; + let target = TxEnv { nonce: 1, kind: TxKind::Call(deployed), ..prefix.clone() }; + + executor + .transact_with_ordinary_block_replay( + EvmEnv::default(), + target, + vec![(B256::repeat_byte(1), prefix)], + ) + .unwrap(); + + assert!(executor.backend().is_persistent(&deployed)); + } + + #[cfg(feature = "monad")] + #[test] + fn block_replay_executes_monad_system_prefix() { + use foundry_evm_core::evm::MonadEvmNetwork; + + let backend = Backend::::spawn(None).unwrap(); + let mut executor = ExecutorBuilder::::default().gas_limit(1 << 20).build( + EvmEnv::::default(), + TxEnv::default(), + backend, + NetworkConfigs::with_monad(), + ); + executor.set_balance(CALLER, U256::MAX).unwrap(); + + let system_address = alloy_primitives::address!("6f49a8f621353f12378d0046e7d7e4b9b249dc9e"); + let staking_address = + alloy_primitives::address!("0000000000000000000000000000000000001000"); + let selector = keccak256("syscallSnapshot()"); + let system = TxEnv { + caller: system_address, + gas_limit: 0, + kind: TxKind::Call(staking_address), + data: Bytes::copy_from_slice(&selector[..4]), + chain_id: None, + ..Default::default() + }; + let target = TxEnv { + caller: CALLER, + gas_limit: 100_000, + kind: TxKind::Call(Address::repeat_byte(0x11)), + ..Default::default() + }; + let system_chain = MonadChainContext::for_transaction(&system); + let target_chain = MonadChainContext::for_transaction(&target); + + let (result, used_system_replay) = executor + .transact_with_monad_block_replay( + EvmEnv::::default(), + target, + target_chain, + vec![(B256::repeat_byte(1), system, system_chain)], + false, + ) + .unwrap() + .unwrap(); + + assert!(!used_system_replay); + assert!(!result.reverted); + assert_eq!(executor.get_nonce(system_address).unwrap(), 1); + } + + #[cfg(feature = "monad")] + #[test] + fn block_replay_executes_monad_system_target() { + use foundry_evm_core::evm::MonadEvmNetwork; + + let backend = Backend::::spawn(None).unwrap(); + let mut executor = ExecutorBuilder::::default().gas_limit(1 << 20).build( + EvmEnv::::default(), + TxEnv::default(), + backend, + NetworkConfigs::with_monad(), + ); + + let system_address = alloy_primitives::address!("6f49a8f621353f12378d0046e7d7e4b9b249dc9e"); + let staking_address = + alloy_primitives::address!("0000000000000000000000000000000000001000"); + let selector = keccak256("syscallSnapshot()"); + let target = TxEnv { + caller: system_address, + gas_limit: 0, + kind: TxKind::Call(staking_address), + data: Bytes::copy_from_slice(&selector[..4]), + chain_id: None, + ..Default::default() + }; + let target_chain = MonadChainContext::for_transaction(&target); + + let (result, used_system_replay) = executor + .transact_with_monad_block_replay( + EvmEnv::::default(), + target, + target_chain, + Vec::new(), + false, + ) + .unwrap() + .unwrap(); + + assert!(used_system_replay); + assert!(!result.reverted); + assert_eq!(executor.get_nonce(system_address).unwrap(), 1); + } + + #[test] + fn mismatched_skip_payload_is_execution_error() { + let raw = RawCallResult:: { + reverted: true, + result: Bytes::from_static(b"FOUNDRY::SKIPforged"), + skip_payloads: vec![Bytes::from_static(b"FOUNDRY::SKIPgenuine")], + ..Default::default() + }; + + let err = raw.into_evm_error(None); + assert!(matches!(err, EvmError::Execution(_))); + } + + #[test] + fn set_spec_id_updates_spec_dependent_cfg_state() { + let backend = Backend::::spawn(None).unwrap(); + let mut executor = ExecutorBuilder::default().build( + EvmEnv::default(), + TxEnv::default(), + backend, + NetworkConfigs::default(), + ); + + executor.evm_env_mut().cfg_env.set_spec_and_mainnet_gas_params(SpecId::HOMESTEAD); + assert_eq!( + executor.evm_env().cfg_env.gas_params(), + &revm::context_interface::cfg::GasParams::new_spec(SpecId::HOMESTEAD), + ); + assert!(!executor.evm_env().cfg_env.is_amsterdam_eip8037_enabled()); + + executor.set_spec_id(SpecId::AMSTERDAM); + + assert_eq!(executor.spec_id(), SpecId::AMSTERDAM); + assert_eq!( + executor.evm_env().cfg_env.gas_params(), + &revm::context_interface::cfg::GasParams::new_spec(SpecId::AMSTERDAM), + ); + assert!(executor.evm_env().cfg_env.is_amsterdam_eip8037_enabled()); + } + + #[test] + fn calculate_stipend_uses_eip2780_transaction_context() { + let caller = Address::repeat_byte(0x11); + let recipient = Address::repeat_byte(0x22); + let mut tx = TxEnv { caller, kind: TxKind::Call(recipient), ..Default::default() }; + let cfg = CfgEnv::new_with_spec(SpecId::AMSTERDAM); + + assert_eq!( + calculate_stipend(&tx, &cfg), + revm::primitives::eip2780::TX_BASE_COST + + revm::primitives::eip8038::COLD_ACCOUNT_ACCESS + ); + let cfg = cfg.with_enable_amsterdam_eip2780(false); + assert_eq!( + calculate_stipend(&tx, &cfg), + revm::context_interface::cfg::GasParams::new_spec(SpecId::AMSTERDAM).tx_base_stipend() + ); + + tx.kind = TxKind::Call(caller); + let cfg = cfg.with_enable_amsterdam_eip2780(true); + assert_eq!(calculate_stipend(&tx, &cfg), revm::primitives::eip2780::TX_BASE_COST); + } + + #[test] + fn amsterdam_intercepted_create_refunds_state_gas() { + let cheats_config = + Arc::new(CheatsConfig::new(&Config::default(), EvmOpts::default(), None, None, false)); + let backend = Backend::::spawn(None).unwrap(); + let mut executor = ExecutorBuilder::default() + .inspectors(|stack| stack.cheatcodes(cheats_config)) + .spec_id(SpecId::AMSTERDAM) + .gas_limit(1_000_000) + .build(EvmEnv::default(), TxEnv::default(), backend, NetworkConfigs::default()); + + let target = Address::repeat_byte(0x11); + // PUSH0; PUSH0; PUSH0; CREATE; POP; STOP. + executor + .set_code( + target, + Bytecode::new_raw(Bytes::from_static(&[0x5f, 0x5f, 0x5f, 0xf0, 0x50, 0x00])), + ) + .unwrap(); + executor.inspector_mut().cheatcodes.as_mut().unwrap().intercept_next_create_call = true; + + let result = executor.transact_raw(CALLER, target, Bytes::new(), U256::ZERO).unwrap(); + + assert!(!result.reverted); + assert!( + result.gas_used + < revm::context_interface::cfg::GasParams::new_spec(SpecId::AMSTERDAM) + .create_state_gas(), + "failed CREATE retained its conditional state-gas charge" + ); + } + + #[test] + fn amsterdam_mocked_call_revert_refunds_state_gas() { + let cheats_config = + Arc::new(CheatsConfig::new(&Config::default(), EvmOpts::default(), None, None, false)); + let backend = Backend::::spawn(None).unwrap(); + let mut executor = ExecutorBuilder::default() + .inspectors(|stack| stack.cheatcodes(cheats_config)) + .spec_id(SpecId::AMSTERDAM) + .gas_limit(1_000_000) + .build(EvmEnv::default(), TxEnv::default(), backend, NetworkConfigs::default()); + + let target = Address::repeat_byte(0x11); + let mocked = Address::repeat_byte(0x22); + executor + .transact_raw( + CALLER, + CHEATCODE_ADDRESS, + mockCallRevert_1Call { + callee: mocked, + msgValue: U256::ONE, + data: Bytes::new(), + revertData: Bytes::new(), + } + .abi_encode() + .into(), + U256::ZERO, + ) + .unwrap(); + executor.set_code(mocked, Bytecode::default()).unwrap(); + executor.set_balance(target, U256::ONE).unwrap(); + + // PUSH0 x4; PUSH1 1; PUSH20 ; GAS; CALL; POP; STOP. + let mut code = vec![0x5f, 0x5f, 0x5f, 0x5f, 0x60, 0x01, 0x73]; + code.extend_from_slice(mocked.as_slice()); + code.extend_from_slice(&[0x5a, 0xf1, 0x50, 0x00]); + executor.set_code(target, Bytecode::new_raw(code.into())).unwrap(); + + let result = executor.transact_raw(CALLER, target, Bytes::new(), U256::ZERO).unwrap(); + + assert!(!result.reverted); + assert!( + result.gas_used + < revm::context_interface::cfg::GasParams::new_spec(SpecId::AMSTERDAM) + .new_account_state_gas(), + "reverted mocked CALL retained its conditional state-gas charge" + ); + } + + #[test] + fn set_trace_requirements_replaces_trace_mode_between_transactions() { + let backend = Backend::::spawn(None).unwrap(); + let mut executor = ExecutorBuilder::default().gas_limit(1 << 20).build( + EvmEnv::default(), + TxEnv::default(), + backend, + NetworkConfigs::default(), + ); + executor.evm_env_mut().cfg_env.disable_nonce_check = true; + let target = Address::repeat_byte(0x11); + // PUSH1 4; JUMP; STOP; JUMPDEST; PUSH1 1; PUSH1 0; SSTORE; STOP. + executor + .set_code( + target, + Bytecode::new_raw(Bytes::from_static(&[ + 0x60, 0x04, 0x56, 0x00, 0x5b, 0x60, 0x01, 0x60, 0x00, 0x55, 0x00, + ])), + ) + .unwrap(); + + let untraced = executor.transact_raw(CALLER, target, Bytes::new(), U256::ZERO).unwrap(); + assert!(untraced.traces.is_none()); + + executor.set_trace_requirements(TraceRequirements::none().with_debug(true)); + let debug = executor.transact_raw(CALLER, target, Bytes::new(), U256::ZERO).unwrap(); + let debug_steps = &debug.traces.as_ref().unwrap().nodes()[0].trace.steps; + assert_eq!(debug_steps.len(), 7); + assert!(debug_steps.iter().all(|step| step.stack.is_some() && step.memory.is_some())); + + executor.set_trace_requirements( + TraceRequirements::none().with_decode_internal(InternalTraceMode::Full), + ); + let internal = executor.transact_raw(CALLER, target, Bytes::new(), U256::ZERO).unwrap(); + let internal_steps = &internal.traces.as_ref().unwrap().nodes()[0].trace.steps; + assert_eq!(internal_steps.len(), 2); + assert!(internal_steps.iter().all(|step| step.stack.is_some() && step.memory.is_some())); + + executor.set_trace_requirements(TraceRequirements::none()); + let untraced = executor.transact_raw(CALLER, target, Bytes::new(), U256::ZERO).unwrap(); + assert!(untraced.traces.is_none()); + } + + #[test] + fn early_exit_interrupts_active_evm_execution() { + const GAS_LIMIT: u64 = 1 << 24; + let backend = Backend::::spawn(None).unwrap(); + let mut executor = ExecutorBuilder::default().gas_limit(GAS_LIMIT).build( + EvmEnv::default(), + TxEnv::default(), + backend, + NetworkConfigs::default(), + ); + let early_exit = EarlyExit::new(false); + executor.inspector_mut().set_early_exit(early_exit.clone()); + + let target = Address::repeat_byte(0x11); + // JUMPDEST; PUSH1 0; JUMP loops until the inspector observes the interrupt. + executor + .set_code(target, Bytecode::new_raw(Bytes::from_static(&[0x5b, 0x60, 0x00, 0x56]))) + .unwrap(); + + let (started_tx, started_rx) = mpsc::channel(); + let (result_tx, result_rx) = mpsc::channel(); + let handle = thread::spawn(move || { + started_tx.send(()).unwrap(); + let result = executor.transact_raw(CALLER, target, Bytes::new(), U256::ZERO); + let _ = result_tx.send(result); + }); + + started_rx.recv().unwrap(); + thread::sleep(Duration::from_millis(1)); + early_exit.record_ctrl_c(); + + let result = result_rx.recv_timeout(Duration::from_secs(1)); + handle.join().unwrap(); + let result = result.expect("active EVM execution did not observe early exit").unwrap(); + assert!(result.execution_cancelled); + assert!(!result.reverted); + assert_eq!(result.exit_reason, Some(InstructionResult::Stop)); + assert!(result.gas_used > 21_000, "interrupt fired before EVM execution started"); + assert!(result.gas_used < GAS_LIMIT, "execution ran out of gas instead of exiting"); + } + + #[test] + fn completed_execution_is_not_retroactively_cancelled() { + let backend = Backend::::spawn(None).unwrap(); + let mut executor = ExecutorBuilder::default().gas_limit(1 << 24).build( + EvmEnv::default(), + TxEnv::default(), + backend, + NetworkConfigs::default(), + ); + let early_exit = EarlyExit::new(false); + executor.inspector_mut().set_early_exit(early_exit.clone()); + + let target = Address::repeat_byte(0x11); + executor.set_code(target, Bytecode::new_raw(Bytes::from_static(&[0x00]))).unwrap(); + let result = executor.transact_raw(CALLER, target, Bytes::new(), U256::ZERO).unwrap(); + early_exit.record_ctrl_c(); + + assert!(!result.execution_cancelled); + assert!(!result.reverted); + } + + #[test] + fn campaign_deadline_interrupts_active_evm_execution() { + const GAS_LIMIT: u64 = 1 << 24; + let backend = Backend::::spawn(None).unwrap(); + let mut executor = ExecutorBuilder::default().gas_limit(GAS_LIMIT).build( + EvmEnv::default(), + TxEnv::default(), + backend, + NetworkConfigs::default(), + ); + let cancellation = EvmExecutionCancellation::campaign( + EarlyExit::new(false), + Arc::new(AtomicBool::new(false)), + Some(Instant::now()), + ); + executor.inspector_mut().set_execution_cancellation(cancellation); + + let target = Address::repeat_byte(0x11); + executor + .set_code(target, Bytecode::new_raw(Bytes::from_static(&[0x5b, 0x60, 0x00, 0x56]))) + .unwrap(); + + let result = executor.transact_raw(CALLER, target, Bytes::new(), U256::ZERO).unwrap(); + assert!(result.execution_cancelled); + assert!(!result.reverted); + assert_eq!(result.exit_reason, Some(InstructionResult::Stop)); + assert!(result.gas_used < GAS_LIMIT, "execution ran out of gas instead of timing out"); + } + + #[test] + fn beacon_root_system_call_does_not_persist_system_address() { + let backend = Backend::::spawn(None).unwrap(); + let mut executor = ExecutorBuilder::default().spec_id(SpecId::CANCUN).build( + EvmEnv::default(), + TxEnv::default(), + backend, + NetworkConfigs::default(), + ); + let before = executor.backend().basic_ref(SYSTEM_ADDRESS).unwrap(); + + executor.apply_beacon_root(B256::repeat_byte(0x11)).unwrap(); + + assert_eq!( + executor.backend().basic_ref(SYSTEM_ADDRESS).unwrap(), + before, + "EIP-4788 system calls must not persist the system caller account", + ); + } + + /// Regression test for `pre_override_blob_hashes` restoration. + /// + /// Exercises the `None` arm of `sync_tx_after_env_override_restore` with + /// *non-empty* native blob hashes, the case that cannot be reached from + /// Solidity because no cheatcode sets `tx.blob_hashes` without also setting + /// `env_overrides.blob_hashes`. + /// + /// Steps: + /// 1. Seed `tx.blob_hashes = original` directly (no cheatcode -> override stays `None`). + /// 2. `vm.snapshotState()` -> `inner_snapshot_state` captures `pre_override_blob_hashes = + /// Some(original)`. + /// 3. `vm.blobhashes(new)` -> sets override (`Some`) AND real tx hashes. + /// 4. `vm.revertToState(id)` -> restores override to `None`, + /// `sync_tx_after_env_override_restore` must restore `tx.blob_hashes = original`. + #[test] + fn pre_override_blob_hashes_restored_on_revert_to_state() { + let cheats_config = + Arc::new(CheatsConfig::new(&Config::default(), EvmOpts::default(), None, None, false)); + + let backend = Backend::::spawn(None).unwrap(); + let mut executor = ExecutorBuilder::default() + .inspectors(|stack| stack.cheatcodes(cheats_config)) + .spec_id(SpecId::CANCUN) + .build(EvmEnv::default(), TxEnv::default(), backend, NetworkConfigs::default()); + + let original: Vec = vec![B256::repeat_byte(0x11), B256::repeat_byte(0x22)]; + executor.tx_env_mut().set_blob_hashes(original.clone()); + + let snap_result = executor + .transact_raw( + CALLER, + CHEATCODE_ADDRESS, + snapshotStateCall {}.abi_encode().into(), + U256::ZERO, + ) + .expect("snapshotState failed"); + assert!(!snap_result.reverted, "snapshotState reverted unexpectedly"); + let snapshot_id = U256::from_be_slice(&snap_result.result[..32]); + + let new_hashes = vec![B256::repeat_byte(0x33)]; + let blob_result = executor + .transact_raw( + CALLER, + CHEATCODE_ADDRESS, + blobhashesCall { hashes: new_hashes }.abi_encode().into(), + U256::ZERO, + ) + .expect("blobhashes failed"); + assert!(!blob_result.reverted, "blobhashes reverted unexpectedly"); + + let revert_result = executor + .transact_raw( + CALLER, + CHEATCODE_ADDRESS, + revertToStateCall { snapshotId: snapshot_id }.abi_encode().into(), + U256::ZERO, + ) + .expect("revertToState failed"); + assert!(!revert_result.reverted, "revertToState reverted unexpectedly"); + + assert_eq!( + revert_result.tx_env.blob_hashes, original, + "pre_override_blob_hashes must be restored to original non-empty hashes, not []", + ); + assert!( + executor.inspector().cheatcodes.as_ref().unwrap().env_overrides.is_empty(), + "inactive env overrides must be removed after restoring their metadata", + ); + } + #[cfg(feature = "monad")] + #[test] + fn concrete_system_replay_preserves_envelope_and_rejects_without_commit() { + let mut executor = ExecutorBuilder::::new().gas_limit(1 << 20).build( + EvmEnv::::default(), + TxEnv::default(), + Backend::spawn(None).unwrap(), + NetworkConfigs::with_monad(), + ); + let caller = alloy_primitives::address!("6f49a8f621353f12378d0046e7d7e4b9b249dc9e"); + let selector = keccak256("syscallSnapshot()"); + let system = TxEnv { + caller, + gas_limit: 0, + kind: TxKind::Call(alloy_primitives::address!( + "0000000000000000000000000000000000001000" + )), + data: Bytes::copy_from_slice(&selector[..4]), + chain_id: None, + ..Default::default() + }; + let result = executor + .try_transact_system_replay_with_env_and_context( + EvmEnv::::default(), + system.clone(), + MonadChainContext::for_transaction(&system), + ) + .unwrap() + .unwrap(); + assert!(!result.reverted); + assert_eq!(result.tx_env, system); + assert_eq!(executor.get_nonce(caller).unwrap(), 1); + + // Replaying the same canonical nonce must fail without committing another increment. + assert!( + executor + .try_transact_system_replay_with_env_and_context( + EvmEnv::::default(), + system.clone(), + MonadChainContext::for_transaction(&system), + ) + .is_err() + ); + assert_eq!(executor.get_nonce(caller).unwrap(), 1); + + let ordinary = TxEnv { caller: CALLER, ..Default::default() }; + let nonce = executor.get_nonce(CALLER).unwrap(); + assert!( + executor + .try_transact_system_replay_with_env_and_context( + EvmEnv::::default(), + ordinary.clone(), + MonadChainContext::for_transaction(&ordinary), + ) + .unwrap() + .is_none() + ); + assert_eq!(executor.get_nonce(CALLER).unwrap(), nonce); + } +} diff --git a/crates/evm/evm/src/executors/sancov.rs b/crates/evm/evm/src/executors/sancov.rs new file mode 100644 index 0000000000000..660069b5563d6 --- /dev/null +++ b/crates/evm/evm/src/executors/sancov.rs @@ -0,0 +1,70 @@ +use super::RawCallResult; +use foundry_evm_core::evm::FoundryEvmNetwork; + +const SANCOV_BUFFER_CAPACITY: usize = 65536; + +/// RAII guard that activates sancov coverage collection for the duration of an EVM call. +/// +/// Allocates a thread-local scratch buffer for sancov hits and sets it as the active coverage map. +/// After execution, sancov hits are appended to the call result separately from EVM edge coverage. +pub(super) struct SancovGuard { + collect_edges: bool, +} + +thread_local! { + static SANCOV_BUFFER: std::cell::RefCell> = + std::cell::RefCell::new(vec![0u8; SANCOV_BUFFER_CAPACITY]); +} + +impl SancovGuard { + pub(super) fn new(collect_edges: bool, collect_trace_cmp: bool) -> Self { + if collect_edges { + SANCOV_BUFFER.with(|buf| { + let mut buf = buf.borrow_mut(); + buf.fill(0); + let ptr = buf.as_mut_ptr(); + let len = buf.len(); + foundry_evm_sancov::set_coverage_map(ptr, len); + }); + } + if collect_trace_cmp { + foundry_evm_sancov::clear_cmp_operands(); + } + Self { collect_edges } + } + + /// Populate the result's sancov coverage buffer with edge hits. + pub(super) fn append_edges_into(result: &mut RawCallResult) { + let sancov_used = foundry_evm_sancov::sancov_edge_count(); + if sancov_used == 0 { + return; + } + + SANCOV_BUFFER.with(|buf| { + let buf = buf.borrow(); + let sancov_slice = &buf[..sancov_used.min(buf.len())]; + + if !sancov_slice.iter().any(|&b| b > 0) { + return; + } + + result.sancov_coverage = Some(sancov_slice.to_vec()); + }); + } + + /// Drain captured comparison operands and attach them to the result for dictionary injection. + pub(super) fn drain_cmp_into(result: &mut RawCallResult) { + let cmp_values = foundry_evm_sancov::drain_cmp_operands(); + if !cmp_values.is_empty() { + result.sancov_cmp_values = Some(cmp_values); + } + } +} + +impl Drop for SancovGuard { + fn drop(&mut self) { + if self.collect_edges { + foundry_evm_sancov::clear_coverage_map(); + } + } +} diff --git a/crates/evm/evm/src/executors/showmap.rs b/crates/evm/evm/src/executors/showmap.rs new file mode 100644 index 0000000000000..8dcaab4d23e07 --- /dev/null +++ b/crates/evm/evm/src/executors/showmap.rs @@ -0,0 +1,1167 @@ +//! AFL-`afl-showmap`-style corpus replay. +//! +//! Replays a persisted corpus through a fresh executor and emits one text file +//! per trial (or per corpus entry). Each line has the form `:`: +//! +//! - EVM IDs use the *deterministic* `(bytecode_hash, pc)` derived from the line-coverage `HitMap` +//! so that IDs are stable across `forge` invocations and meaningful for cross-approach analysis. +//! Format: `evm__`. +//! - Sancov IDs use the deterministic guard index from the sancov bitmap: `sancov_0x`. +//! +//! Counts are raw saturating-summed hitcounts across the replayed corpus. +//! +//! Output is consumable by tools like `riesentoaster/differential-coverage`. + +use crate::{ + executors::{ + Executor, + corpus::{ + DynamicTargetCtx, StatelessReplayTarget, WorkerCorpus, register_replay_created, + rollback_replay_created, + }, + corpus_io::read_corpus_tree, + invariant::{ + call_after_invariant_function, call_invariant_function, did_fail_on_assert, execute_tx, + snapshot_edge_fingerprint, + }, + should_ignore_revert, + }, + inspectors::EdgeIndexMap, +}; +use alloy_dyn_abi::JsonAbiExt; +use alloy_json_abi::Function; +use alloy_primitives::{Address, B256, Selector, hex, keccak256, map::HashMap}; +use eyre::Result; +use foundry_config::FuzzCorpusConfig; +use foundry_evm_core::{constants::MAGIC_ASSUME, evm::FoundryEvmNetwork}; +use foundry_evm_coverage::HitMaps; +use foundry_evm_fuzz::{BasicTxDetails, invariant::FuzzRunIdentifiedContracts}; +use std::{ + borrow::Cow, + cmp::Ordering, + collections::BTreeSet, + fmt, + fs::File, + io::{BufWriter, Write}, + path::{Path, PathBuf}, +}; + +type EvmShowmap = HashMap<(B256, u32), u64>; +const MAX_REPORTED_REPLAY_FAILURES: usize = 20; + +/// Which coverage bitmap(s) to dump. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub enum ShowmapDomain { + #[default] + Evm, + Sancov, + Both, +} + +impl ShowmapDomain { + pub const fn includes_evm(self) -> bool { + matches!(self, Self::Evm | Self::Both) + } + pub const fn includes_sancov(self) -> bool { + matches!(self, Self::Sancov | Self::Both) + } +} + +impl fmt::Display for ShowmapDomain { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Evm => f.write_str("evm"), + Self::Sancov => f.write_str("sancov"), + Self::Both => f.write_str("both"), + } + } +} + +/// Per-replay options. +#[derive(Clone, Debug)] +pub struct ShowmapOpts { + /// Output root directory; emitted files live under `//`. + pub out_dir: PathBuf, + /// Approach directory name; test identity is folded in here so each + /// `/` contains trials of one test (matches `differential-coverage`). + pub approach: String, + /// Rerun identifier used as the filename so multiple trials accumulate side-by-side. + pub trial: String, + /// Whether to emit one file per corpus entry or one aggregated file. + pub per_input: bool, + /// Which bitmap(s) to dump. + pub domain: ShowmapDomain, + /// Whether to write showmap files. Disabled by `forge fuzz replay`. + pub emit_files: bool, +} + +/// Stats returned from a single trial replay. +#[derive(Clone, Debug, Default)] +pub struct ShowmapStats { + /// Number of corpus entries successfully replayed. + pub corpus_entries: usize, + /// Number of files written to disk. + pub showmap_files: usize, + /// Number of corpus entries skipped because they couldn't be replayed + /// against the current target (e.g. selector mismatch). + pub skipped_entries: usize, + /// Number of corpus entries skipped because they could not be read. + pub unreadable_entries: usize, + /// True if sancov coverage was requested. Lets the caller distinguish + /// "sancov not asked for" from "sancov asked for but produced nothing". + pub sancov_requested: bool, + /// True if any non-zero sancov hits were observed across the replay. + pub sancov_observed: bool, +} + +/// Test target metadata needed to replay corpus entries. +pub struct ShowmapReplayTarget<'a> { + pub stateless: Option>, + pub fuzz_fail_on_revert: bool, + pub fuzzed_contracts: Option<&'a FuzzRunIdentifiedContracts>, + pub invariant_address: Option
, + pub invariant_fns: &'a [(&'a Function, bool)], + pub invariant_replay: InvariantReplayOptions, + pub dynamic: Option<&'a DynamicTargetCtx<'a>>, +} + +/// Invariant replay settings that affect when terminal checks run. +#[derive(Clone, Copy, Debug, Default)] +pub struct InvariantReplayOptions { + pub check_interval: u32, + pub call_after_invariant: bool, + pub is_optimization: bool, +} + +/// A structured identity for a failure observed during corpus replay. +#[derive(Clone, Debug)] +pub enum ReplayFailure { + /// A stateless fuzz test call failed. Keyed by selector, code-path fingerprint, and output. + Fuzz { selector: Selector, fingerprint: Option, output: B256 }, + /// An invariant handler call hit an assertion. + /// Keyed by `(target, selector)`. The fingerprint is retained as replay metadata but does not + /// distinguish failure identities. + Handler { target: Address, selector: Selector, fingerprint: Option }, + /// A handler reverted for an invariant configured with `fail_on_revert`. + /// Keyed by invariant name; the handler site is retained for diagnostics. + HandlerRevert { name: String, target: Address, selector: Selector }, + /// A broken invariant predicate. Keyed by the invariant function name. + Invariant { name: String }, + /// The `afterInvariant` hook reverted. + AfterInvariant, +} + +impl PartialEq for ReplayFailure { + fn eq(&self, other: &Self) -> bool { + self.cmp(other) == Ordering::Equal + } +} + +impl Eq for ReplayFailure {} + +impl PartialOrd for ReplayFailure { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for ReplayFailure { + fn cmp(&self, other: &Self) -> Ordering { + match (self, other) { + ( + Self::Fuzz { selector, fingerprint, output }, + Self::Fuzz { + selector: other_selector, + fingerprint: other_fingerprint, + output: other_output, + }, + ) => (selector, fingerprint, output).cmp(&( + other_selector, + other_fingerprint, + other_output, + )), + ( + Self::Handler { target, selector, .. }, + Self::Handler { target: other_target, selector: other_selector, .. }, + ) => (target, selector).cmp(&(other_target, other_selector)), + (Self::HandlerRevert { name, .. }, Self::HandlerRevert { name: other_name, .. }) => { + name.cmp(other_name) + } + (Self::Invariant { name }, Self::Invariant { name: other_name }) => { + name.cmp(other_name) + } + (Self::AfterInvariant, Self::AfterInvariant) => Ordering::Equal, + _ => replay_failure_rank(self).cmp(&replay_failure_rank(other)), + } + } +} + +const fn replay_failure_rank(failure: &ReplayFailure) -> u8 { + match failure { + ReplayFailure::Fuzz { .. } => 0, + ReplayFailure::Handler { .. } => 1, + ReplayFailure::HandlerRevert { .. } => 2, + ReplayFailure::Invariant { .. } => 3, + ReplayFailure::AfterInvariant => 4, + } +} + +impl fmt::Display for ReplayFailure { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Fuzz { selector, .. } => write!(f, "fuzz call {selector:?} failed"), + Self::Handler { target, selector, .. } => { + write!(f, "handler {selector:?} on {target:?} failed") + } + Self::HandlerRevert { name, target, selector } => { + write!(f, "invariant `{name}` failed on handler {selector:?} on {target:?}") + } + Self::Invariant { name } => write!(f, "invariant `{name}` broken"), + Self::AfterInvariant => f.write_str("afterInvariant broken"), + } + } +} + +impl ReplayFailure { + /// Whether this failure comes from an invariant predicate or `afterInvariant` hook. + const fn is_predicate(&self) -> bool { + matches!(self, Self::Invariant { .. } | Self::AfterInvariant) + } +} + +/// Facts observed while replaying one candidate for corpus minimization. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ReplayObservation { + /// AFL-bucketed EVM edge coverage for the candidate. + pub evm_edges: Vec, + /// AFL-bucketed native sancov edge coverage for the candidate. + pub sancov_edges: Vec, + /// All unique failure identities observed while replaying the candidate. + pub failures: BTreeSet, + /// Number of replayable transactions executed. + pub replayed: usize, + /// Number of transactions that do not target this fuzz/invariant context. + pub unmatched: usize, + /// Number of transactions rejected via `vm.assume`/`vm.skip`. + pub skipped: usize, +} + +impl ReplayObservation { + /// Whether replay observed a stateless fuzz or invariant handler failure. + pub fn has_non_predicate_failure(&self) -> bool { + self.failures.iter().any(|failure| !failure.is_predicate()) + } + + fn has_invariant_failure(&self) -> bool { + self.failures.iter().any(|failure| { + matches!(failure, ReplayFailure::HandlerRevert { .. } | ReplayFailure::Invariant { .. }) + }) + } +} + +/// Replay every corpus entry under `corpus_dir` and emit showmap files. +/// +/// `stateless` is set for stateless fuzz tests; `fuzzed_contracts` is set for +/// invariant tests (txs are committed between calls in that case). +/// `dynamic` lets invariant replay register contracts deployed mid-sequence so +/// follow-up calls into them aren't dropped. +pub fn replay_corpus_to_showmap( + executor: &Executor, + corpus_dir: &Path, + target: ShowmapReplayTarget<'_>, + opts: &ShowmapOpts, +) -> Result { + let entries = read_corpus_tree(corpus_dir)?; + if opts.emit_files && entries.is_empty() { + return Err(eyre::eyre!("corpus directory not found: {}", corpus_dir.display())); + } + + let approach_dir = opts.out_dir.join(&opts.approach); + if opts.emit_files { + foundry_common::fs::create_dir_all(&approach_dir)?; + } + + let mut stats = + ShowmapStats { sancov_requested: opts.domain.includes_sancov(), ..Default::default() }; + let mut replay_failures = Vec::new(); + let mut failed_entries = 0usize; + // Reused per call. In aggregate mode it accumulates across all entries; in per-input mode it + // is cleared after each entry's file is written. + let mut evm_buf = EvmShowmap::default(); + let mut san_buf: Vec = Vec::new(); + + for entry in entries { + let tx_seq = match entry.read_tx_seq() { + Ok(seq) if !seq.is_empty() => seq, + Ok(_) => continue, + Err(err) => { + debug!(target: "showmap", %err, ?entry.path, "failed to read corpus entry"); + stats.unreadable_entries += 1; + stats.skipped_entries += 1; + continue; + } + }; + + let mut had_accepted = false; + let mut executor = executor.clone(); + // Targets deployed during this entry, cleared after the entry. + let mut created: Vec
= Vec::new(); + // Number of committed (non-`vm.assume`) calls, used to gate invariant checks. + let mut accepted = 0usize; + let mut last_accepted_checked_invariant = false; + let mut entry_failure: Option = None; + for tx in &tx_seq { + if !WorkerCorpus::can_replay_tx(tx, target.stateless, target.fuzzed_contracts, None) { + continue; + } + + let mut call_result = execute_tx(&mut executor, tx)?; + // Snapshot the edge fingerprint before any coverage merge zeroes the buffer. + let fingerprint = snapshot_edge_fingerprint(&call_result); + // `vm.assume` rejects and cheatcode `vm.skip` are discarded by the campaign: the call + // is not committed, checked, or counted toward coverage. + if call_result.result.as_ref() == MAGIC_ASSUME || call_result.skip_reason().is_some() { + continue; + } + // Coverage-collection asymmetry across calls within a stateful sequence: + // - line_coverage is per-call: `Executor::call_raw` returns a fresh HitMap each time, + // so we can simply accumulate it. + // - sancov_coverage is the inspector's shared `Vec` buffer that keeps growing + // across calls, so after consuming it we zero it out to avoid double-counting on the + // next iteration. + if opts.domain.includes_evm() { + accumulate_evm(&mut evm_buf, call_result.line_coverage.as_ref()); + } + if opts.domain.includes_sancov() { + accumulate_sancov(&mut san_buf, call_result.sancov_coverage.as_deref()); + if let Some(buf) = call_result.sancov_coverage.as_mut() { + buf.fill(0); + } + } + + had_accepted = true; + + register_replay_created( + &call_result.state_changeset, + target.dynamic, + target.fuzzed_contracts, + &mut created, + ); + + let target_addr = tx.call_details.target; + let selector = + tx.call_details.calldata.get(..4).map(Selector::from_slice).unwrap_or_default(); + + // Stateful tests need the tx committed so subsequent calls see its effects. + if target.fuzzed_contracts.is_some() { + accepted += 1; + last_accepted_checked_invariant = false; + if !opts.emit_files + && let Some(failure) = invariant_replay_failures( + target_addr, + selector, + did_fail_on_assert(&call_result, &call_result.state_changeset), + target.invariant_fns, + &call_result, + fingerprint, + ) + .into_iter() + .next() + { + entry_failure = Some(failure); + break; + } + executor.commit(&mut call_result); + if !opts.emit_files + && should_check_invariant( + accepted, + target.invariant_replay.check_interval, + target.invariant_replay.is_optimization, + ) + { + last_accepted_checked_invariant = true; + if !target.invariant_replay.is_optimization + && let Some(address) = target.invariant_address + && let Some(failure) = + first_broken_invariant(&executor, address, target.invariant_fns)? + { + entry_failure = Some(failure); + break; + } + } + } else if !opts.emit_files + && !fuzz_replay_call_succeeded( + &executor, + target_addr, + &mut call_result, + target.fuzz_fail_on_revert, + ) + { + entry_failure = Some(ReplayFailure::Fuzz { + selector, + fingerprint, + output: keccak256(call_result.result.as_ref()), + }); + break; + } + } + // Final invariant + afterInvariant checks (replay mode only): mirror the + // campaign's "always check on the last call", and run afterInvariant unless a + // predicate already broke. + if !opts.emit_files + && entry_failure.is_none() + && accepted > 0 + && target.fuzzed_contracts.is_some() + && let Some(address) = target.invariant_address + { + if !target.invariant_replay.is_optimization + && !last_accepted_checked_invariant + && let Some(failure) = + first_broken_invariant(&executor, address, target.invariant_fns)? + { + entry_failure = Some(failure); + } else if target.invariant_replay.call_after_invariant + && let Some(failure) = broken_after_invariant(&executor, address)? + { + entry_failure = Some(failure); + } + } + if let Some(failure) = entry_failure { + rollback_replay_created(target.fuzzed_contracts, created); + failed_entries += 1; + if replay_failures.len() < MAX_REPORTED_REPLAY_FAILURES { + replay_failures.push(format!( + "corpus entry {} failed during replay: {failure}", + entry.path.display() + )); + } + continue; + } + rollback_replay_created(target.fuzzed_contracts, created); + + if !had_accepted { + stats.skipped_entries += 1; + continue; + } + stats.corpus_entries += 1; + if !stats.sancov_observed && san_buf.iter().any(|&x| x != 0) { + stats.sancov_observed = true; + } + + if opts.emit_files && opts.per_input { + // __-.txt + let stem = format!("{}__{}-{}", opts.trial, entry.uuid, entry.timestamp); + stats.showmap_files += + write_showmap_file(&approach_dir.join(format!("{stem}.txt")), &evm_buf, &san_buf)?; + // Reset for the next entry; preserves capacity so we don't reallocate. + evm_buf.clear(); + san_buf.fill(0); + } + } + + if opts.emit_files && !opts.per_input { + // .txt + stats.showmap_files += write_showmap_file( + &approach_dir.join(format!("{}.txt", opts.trial)), + &evm_buf, + &san_buf, + )?; + } + + if failed_entries > 0 { + return Err(eyre::eyre!( + "corpus replay failed:\n{}", + replay_failure_report(&replay_failures, failed_entries) + )); + } + + Ok(stats) +} + +pub struct MinimizationReplayInput<'a> { + pub sequence: &'a [BasicTxDetails], + pub evm_edge_indices: &'a mut EdgeIndexMap, + pub corpus: &'a FuzzCorpusConfig, + /// Whether to stop once every selected invariant has failed, mirroring a tmin campaign. + pub stop_at_campaign_end: bool, +} + +/// Replays one candidate input and returns coverage/failure facts for minimizers. +pub fn replay_sequence_for_minimization( + executor: &Executor, + input: MinimizationReplayInput<'_>, + target: ShowmapReplayTarget<'_>, +) -> Result { + let mut observation = ReplayObservation::default(); + let mut executor = executor.clone(); + executor.inspector_mut().collect_edge_coverage_with_config(input.corpus); + executor.inspector_mut().collect_sancov_edges(input.corpus.collect_sancov_edges()); + executor.inspector_mut().collect_sancov_trace_cmp(input.corpus.collect_sancov_trace_cmp()); + + let mut created = Vec::new(); + let mut accepted = 0usize; + let mut last_accepted_checked_invariant = false; + let mut last_accepted_handlers_succeeded = false; + for tx in input.sequence { + if !WorkerCorpus::can_replay_tx(tx, target.stateless, target.fuzzed_contracts, None) { + observation.unmatched += 1; + continue; + } + + let mut call_result = execute_tx(&mut executor, tx)?; + let target_addr = tx.call_details.target; + let selector = + tx.call_details.calldata.get(..4).map(Selector::from_slice).unwrap_or_default(); + let fingerprint = snapshot_edge_fingerprint(&call_result); + + if call_result.result.as_ref() == MAGIC_ASSUME || call_result.skip_reason().is_some() { + observation.skipped += 1; + continue; + } + + call_result.merge_all_coverage( + &mut observation.evm_edges, + input.evm_edge_indices, + &mut observation.sancov_edges, + ); + observation.replayed += 1; + + register_replay_created( + &call_result.state_changeset, + target.dynamic, + target.fuzzed_contracts, + &mut created, + ); + + if target.fuzzed_contracts.is_some() { + accepted += 1; + last_accepted_checked_invariant = false; + last_accepted_handlers_succeeded = + invariant_handlers_succeeded(&executor, &target, &call_result); + for failure in invariant_replay_failures( + target_addr, + selector, + did_fail_on_assert(&call_result, &call_result.state_changeset), + target.invariant_fns, + &call_result, + fingerprint, + ) { + observation.failures.insert(failure); + } + executor.commit(&mut call_result); + if input.stop_at_campaign_end + && all_invariants_failed(&observation.failures, target.invariant_fns) + { + break; + } + if should_check_invariant( + accepted, + target.invariant_replay.check_interval, + target.invariant_replay.is_optimization, + ) && last_accepted_handlers_succeeded + { + last_accepted_checked_invariant = true; + if !target.invariant_replay.is_optimization + && let Some(address) = target.invariant_address + { + for failure in newly_broken_invariants( + &executor, + address, + target.invariant_fns, + &observation.failures, + )? { + observation.failures.insert(failure); + } + if input.stop_at_campaign_end + && all_invariants_failed(&observation.failures, target.invariant_fns) + { + break; + } + } + } + } else if !fuzz_replay_call_succeeded( + &executor, + target_addr, + &mut call_result, + target.fuzz_fail_on_revert, + ) { + observation.failures.insert(ReplayFailure::Fuzz { + selector, + fingerprint, + output: keccak256(call_result.result.as_ref()), + }); + break; + } + } + + if !all_invariants_failed(&observation.failures, target.invariant_fns) + && accepted > 0 + && target.fuzzed_contracts.is_some() + && let Some(address) = target.invariant_address + { + if !target.invariant_replay.is_optimization + && last_accepted_handlers_succeeded + && !last_accepted_checked_invariant + { + for failure in newly_broken_invariants( + &executor, + address, + target.invariant_fns, + &observation.failures, + )? { + observation.failures.insert(failure); + } + } + if target.invariant_replay.call_after_invariant + && !observation.has_invariant_failure() + && let Some(failure) = broken_after_invariant(&executor, address)? + { + observation.failures.insert(failure); + } + } + + rollback_replay_created(target.fuzzed_contracts, created); + Ok(observation) +} + +/// Whether the just-executed handler call passed the campaign's success gate. +fn invariant_handlers_succeeded( + executor: &Executor, + target: &ShowmapReplayTarget<'_>, + call_result: &crate::executors::RawCallResult, +) -> bool { + if call_result.reverted { + return false; + } + + if !executor.legacy_assertions() { + return target.invariant_address.is_some_and(|address| { + executor.is_success_handler_gate( + address, + false, + Cow::Borrowed(&call_result.state_changeset), + ) + }); + } + + target.fuzzed_contracts.is_some_and(|contracts| { + contracts.targets().keys().all(|address| { + executor.is_success_handler_gate( + *address, + false, + Cow::Borrowed(&call_result.state_changeset), + ) + }) + }) +} + +fn replay_failure_report(replay_failures: &[String], failed_entries: usize) -> String { + let mut report = replay_failures.join("\n"); + let unreported = failed_entries.saturating_sub(replay_failures.len()); + if unreported > 0 { + if !report.is_empty() { + report.push('\n'); + } + report.push_str(&format!("... and {unreported} more failed corpus entries")); + } + report +} + +/// Returns replay failures produced directly by a handler call, mirroring the campaign: +/// assertions are keyed by handler site, while a non-assertion revert breaks every invariant +/// configured with `fail_on_revert`. +fn invariant_replay_failures( + target: Address, + selector: Selector, + assertion_failure: bool, + invariant_fns: &[(&Function, bool)], + call_result: &crate::executors::RawCallResult, + fingerprint: Option, +) -> Vec { + if assertion_failure { + return vec![ReplayFailure::Handler { target, selector, fingerprint }]; + } + if !call_result.reverted || call_result.result.as_ref() == MAGIC_ASSUME { + return Vec::new(); + } + invariant_fns + .iter() + .filter(|(_, fail_on_revert)| *fail_on_revert) + .map(|(invariant, _)| ReplayFailure::HandlerRevert { + name: invariant.name.clone(), + target, + selector, + }) + .collect() +} + +fn fuzz_replay_call_succeeded( + executor: &Executor, + target_addr: Address, + call_result: &mut crate::executors::RawCallResult, + fail_on_revert: bool, +) -> bool { + should_ignore_revert( + fail_on_revert, + target_addr, + call_result.reverter, + executor.inspector().extra_cheatcode_addresses(), + ) || executor.is_raw_call_mut_success(target_addr, call_result) +} + +fn newly_broken_invariants( + executor: &Executor, + invariant_address: Address, + invariant_fns: &[(&Function, bool)], + observed: &BTreeSet, +) -> Result> { + let mut failures = Vec::new(); + for (invariant, _) in invariant_fns { + if has_replay_invariant_failure(observed, &invariant.name) { + continue; + } + let (_, success) = call_invariant_function( + executor, + invariant_address, + invariant.abi_encode_input(&[])?.into(), + )?; + if !success { + failures.push(ReplayFailure::Invariant { name: invariant.name.clone() }); + } + } + Ok(failures) +} + +fn first_broken_invariant( + executor: &Executor, + invariant_address: Address, + invariant_fns: &[(&Function, bool)], +) -> Result> { + for (invariant, _) in invariant_fns { + let (_, success) = call_invariant_function( + executor, + invariant_address, + invariant.abi_encode_input(&[])?.into(), + )?; + if !success { + return Ok(Some(ReplayFailure::Invariant { name: invariant.name.clone() })); + } + } + Ok(None) +} + +fn has_replay_invariant_failure(failures: &BTreeSet, name: &str) -> bool { + failures.iter().any(|failure| { + matches!( + failure, + ReplayFailure::HandlerRevert { name: observed, .. } + | ReplayFailure::Invariant { name: observed } + if observed == name + ) + }) +} + +fn all_invariants_failed( + failures: &BTreeSet, + invariant_fns: &[(&Function, bool)], +) -> bool { + invariant_fns + .iter() + .all(|(invariant, _)| has_replay_invariant_failure(failures, &invariant.name)) +} + +fn broken_after_invariant( + executor: &Executor, + invariant_address: Address, +) -> Result> { + let (_, success) = call_after_invariant_function(executor, invariant_address)?; + Ok((!success).then_some(ReplayFailure::AfterInvariant)) +} + +/// Whether the invariant predicate should be evaluated after the `accepted`-th +/// committed (non-`vm.assume`) call. +/// +/// Mirrors the campaign: with `check_interval == 0` only the final call is checked +/// (callers additionally perform a final check after the sequence ends); with +/// `check_interval == 1` every call is checked; otherwise every N-th call. +fn should_check_invariant(accepted: usize, check_interval: u32, is_optimization: bool) -> bool { + debug_assert!(accepted > 0); + is_optimization + || check_interval == 1 + || (check_interval > 1 && accepted.is_multiple_of(check_interval as usize)) +} + +/// Saturating-add per-(bytecode, pc) hits from a `HitMaps` snapshot into `dst`. +fn accumulate_evm(dst: &mut EvmShowmap, src: Option<&HitMaps>) { + let Some(maps) = src else { return }; + for (hash, hitmap) in maps.iter() { + for (pc, hits) in hitmap.iter() { + let slot = dst.entry((*hash, pc)).or_default(); + *slot = slot.saturating_add(hits as u64); + } + } +} + +/// Saturating-add `src` (u8 raw counts) into `dst` (u64 aggregated counts). +fn accumulate_sancov(dst: &mut Vec, src: Option<&[u8]>) { + let Some(src) = src else { return }; + if dst.len() < src.len() { + dst.resize(src.len(), 0); + } + for (d, &s) in dst.iter_mut().zip(src) { + if s != 0 { + *d = d.saturating_add(s as u64); + } + } +} + +/// Write a single showmap file. Returns 1 if a file was written, 0 if skipped +/// (no nonzero entries). +fn write_showmap_file(path: &Path, evm: &EvmShowmap, san: &[u64]) -> Result { + // Pre-check so we don't create empty files. + let has_evm = evm.values().any(|&c| c != 0); + let has_san = san.iter().any(|&c| c != 0); + if !has_evm && !has_san { + return Ok(0); + } + let mut w = BufWriter::new(File::create_new(path).map_err(|err| { + eyre::eyre!( + "failed to create showmap file {}: {err}; pick a different --showmap-trial or remove \ + the existing file", + path.display() + ) + })?); + write_evm(&mut w, evm)?; + write_sancov(&mut w, san)?; + w.flush()?; + Ok(1) +} + +/// Each EVM ID is `evm__`. The 16-hex prefix +/// (64 bits) of the keccak256 bytecode hash makes IDs deterministic across +/// processes while keeping line lengths short. +fn write_evm(out: &mut W, evm: &EvmShowmap) -> std::io::Result<()> { + let mut entries = evm.iter().filter(|(_, count)| **count != 0).collect::>(); + entries.sort_unstable_by_key(|((hash, pc), _)| (*hash, *pc)); + + for ((hash, pc), count) in entries { + let h = hex::encode(&hash.as_slice()[..8]); + writeln!(out, "evm_{h}_{pc:04x}:{count}")?; + } + Ok(()) +} + +fn write_sancov(out: &mut W, bitmap: &[u64]) -> std::io::Result<()> { + for (idx, &count) in bitmap.iter().enumerate() { + if count != 0 { + // Underscore (not `:`) between prefix and id keeps the showmap + // `:` parser unambiguous. + writeln!(out, "sancov_0x{idx:04x}:{count}")?; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::executors::{RawCallResult, corpus_io::canonical_replay_dirs}; + use foundry_evm_core::evm::EthEvmNetwork; + use revm::interpreter::InstructionResult; + use uuid::Uuid; + + fn temp_dir() -> PathBuf { + let dir = std::env::temp_dir().join(format!("foundry-showmap-{}", Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn accumulate_sancov_resizes_and_saturating_adds() { + let mut dst: Vec = vec![10]; + accumulate_sancov(&mut dst, Some(&[1u8, 2, 3])); + assert_eq!(dst, vec![11, 2, 3]); + } + + #[test] + fn write_evm_emits_only_nonzero_deterministic_ids() { + let mut buf: Vec = Vec::new(); + let h = B256::with_last_byte(0xab); + let mut evm = EvmShowmap::default(); + evm.insert((h, 1u32), 0u64); // skipped (count=0) + evm.insert((h, 0x2au32), 3u64); + write_evm(&mut buf, &evm).unwrap(); + let h_hex = hex::encode(&h.as_slice()[..8]); + assert_eq!(String::from_utf8(buf).unwrap(), format!("evm_{h_hex}_002a:3\n")); + } + + #[test] + fn write_sancov_emits_only_nonzero_hex_ids() { + let mut buf: Vec = Vec::new(); + write_sancov(&mut buf, &[0, 3, 0, 1]).unwrap(); + assert_eq!(String::from_utf8(buf).unwrap(), "sancov_0x0001:3\nsancov_0x0003:1\n"); + } + + #[test] + fn write_showmap_file_skips_when_empty() { + let dir = temp_dir(); + let path = dir.join("trial.txt"); + let written = write_showmap_file(&path, &EvmShowmap::default(), &[]).unwrap(); + assert_eq!(written, 0); + assert!(!path.exists()); + } + + #[test] + fn write_showmap_file_writes_combined_domains() { + let dir = temp_dir(); + let path = dir.join("trial.txt"); + let h = B256::with_last_byte(0xff); + let mut evm = EvmShowmap::default(); + evm.insert((h, 7u32), 5u64); + let written = write_showmap_file(&path, &evm, &[2]).unwrap(); + assert_eq!(written, 1); + let body = std::fs::read_to_string(&path).unwrap(); + let h_hex = hex::encode(&h.as_slice()[..8]); + assert_eq!(body, format!("evm_{h_hex}_0007:5\nsancov_0x0000:2\n")); + } + + #[test] + fn write_showmap_file_does_not_overwrite_existing_file() { + let dir = temp_dir(); + let path = dir.join("trial.txt"); + std::fs::write(&path, "keep me").unwrap(); + let h = B256::with_last_byte(0xff); + let mut evm = EvmShowmap::default(); + evm.insert((h, 7u32), 5u64); + let err = write_showmap_file(&path, &evm, &[]).unwrap_err(); + assert!(err.to_string().contains("pick a different --showmap-trial"), "{err:?}"); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "keep me"); + } + + #[test] + fn replay_failure_report_caps_details() { + let failures = (0..MAX_REPORTED_REPLAY_FAILURES) + .map(|idx| format!("corpus entry {idx} failed during replay: fuzz call failed")) + .collect::>(); + let report = replay_failure_report(&failures, MAX_REPORTED_REPLAY_FAILURES + 3); + + assert!(report.contains("corpus entry 0 failed during replay"), "{report}"); + assert!(report.contains("corpus entry 19 failed during replay"), "{report}"); + assert!(report.contains("... and 3 more failed corpus entries"), "{report}"); + assert!(!report.contains("corpus entry 20 failed during replay"), "{report}"); + } + + #[test] + fn replay_observation_derives_failure_classification() { + let handler = ReplayFailure::Handler { + target: Address::with_last_byte(1), + selector: Selector::from([0xaa, 0xbb, 0xcc, 0xdd]), + fingerprint: None, + }; + let mut observation = ReplayObservation::default(); + + assert!(!observation.has_non_predicate_failure()); + assert!(!observation.has_invariant_failure()); + + observation + .failures + .insert(ReplayFailure::Invariant { name: "invariant_first".to_string() }); + assert!(!observation.has_non_predicate_failure()); + assert!(observation.has_invariant_failure()); + + observation.failures.insert(handler); + assert!(observation.has_non_predicate_failure()); + + observation.failures.insert(ReplayFailure::Fuzz { + selector: Selector::ZERO, + fingerprint: None, + output: B256::ZERO, + }); + assert!(observation.has_non_predicate_failure()); + } + + #[test] + fn handler_failure_identity_ignores_path_fingerprint() { + let target = Address::with_last_byte(1); + let selector = Selector::from([0xaa, 0xbb, 0xcc, 0xdd]); + let failures = BTreeSet::from([ + ReplayFailure::Handler { target, selector, fingerprint: Some(B256::with_last_byte(1)) }, + ReplayFailure::Handler { target, selector, fingerprint: Some(B256::with_last_byte(2)) }, + ]); + + let observation = ReplayObservation { failures, ..ReplayObservation::default() }; + assert_eq!(observation.failures.len(), 1); + assert!(observation.has_non_predicate_failure()); + } + + #[test] + fn handler_revert_identity_ignores_handler_site() { + let failures = BTreeSet::from([ + ReplayFailure::HandlerRevert { + name: "invariant_ok".to_string(), + target: Address::with_last_byte(1), + selector: Selector::from([0xaa, 0xbb, 0xcc, 0xdd]), + }, + ReplayFailure::HandlerRevert { + name: "invariant_ok".to_string(), + target: Address::with_last_byte(2), + selector: Selector::from([0x11, 0x22, 0x33, 0x44]), + }, + ]); + + let observation = ReplayObservation { failures, ..ReplayObservation::default() }; + assert_eq!(observation.failures.len(), 1); + assert!(observation.has_non_predicate_failure()); + assert!(observation.has_invariant_failure()); + } + + #[test] + fn has_replay_invariant_failure_matches_only_observed_predicate() { + let failures = BTreeSet::from([ + ReplayFailure::HandlerRevert { + name: "invariant_revert".to_string(), + target: Address::with_last_byte(1), + selector: Selector::ZERO, + }, + ReplayFailure::Invariant { name: "invariant_first".to_string() }, + ReplayFailure::AfterInvariant, + ]); + + assert!(has_replay_invariant_failure(&failures, "invariant_revert")); + assert!(has_replay_invariant_failure(&failures, "invariant_first")); + assert!(!has_replay_invariant_failure(&failures, "invariant_second")); + } + + #[test] + fn should_check_invariant_matches_campaign_intervals() { + // check_interval == 1: every accepted call. + assert!(should_check_invariant(1, 1, false)); + assert!(should_check_invariant(2, 1, false)); + // check_interval == 0: never inline (callers do a final check instead). + assert!(!should_check_invariant(1, 0, false)); + assert!(!should_check_invariant(5, 0, false)); + // check_interval == N: every N-th accepted call. + assert!(!should_check_invariant(1, 3, false)); + assert!(!should_check_invariant(2, 3, false)); + assert!(should_check_invariant(3, 3, false)); + assert!(should_check_invariant(6, 3, false)); + // Optimization mode evaluates every prefix to track the best value. + assert!(should_check_invariant(1, 0, true)); + } + + #[test] + fn invariant_replay_failures_ignore_plain_revert() { + let call_result = RawCallResult:: { + reverted: true, + exit_reason: Some(InstructionResult::Revert), + ..Default::default() + }; + let failures = invariant_replay_failures( + Address::with_last_byte(1), + Selector::from([0xaa, 0xbb, 0xcc, 0xdd]), + did_fail_on_assert(&call_result, &call_result.state_changeset), + &[], + &call_result, + None, + ); + assert!(failures.is_empty()); + } + + #[test] + fn invariant_replay_failures_report_every_fail_on_revert_invariant() { + let call_result = RawCallResult:: { + reverted: true, + exit_reason: Some(InstructionResult::Revert), + ..Default::default() + }; + let first = serde_json::from_value::(serde_json::json!({ + "type": "function", + "name": "invariant_first", + "inputs": [], + "outputs": [], + "stateMutability": "view" + })) + .unwrap(); + let second = serde_json::from_value::(serde_json::json!({ + "type": "function", + "name": "invariant_second", + "inputs": [], + "outputs": [], + "stateMutability": "view" + })) + .unwrap(); + let ignored = serde_json::from_value::(serde_json::json!({ + "type": "function", + "name": "invariant_ignored", + "inputs": [], + "outputs": [], + "stateMutability": "view" + })) + .unwrap(); + let failures = invariant_replay_failures( + Address::with_last_byte(1), + Selector::from([0xaa, 0xbb, 0xcc, 0xdd]), + did_fail_on_assert(&call_result, &call_result.state_changeset), + &[(&first, true), (&second, true), (&ignored, false)], + &call_result, + None, + ); + assert_eq!( + failures, + [ + ReplayFailure::HandlerRevert { + name: "invariant_first".to_string(), + target: Address::with_last_byte(1), + selector: Selector::from([0xaa, 0xbb, 0xcc, 0xdd]), + }, + ReplayFailure::HandlerRevert { + name: "invariant_second".to_string(), + target: Address::with_last_byte(1), + selector: Selector::from([0xaa, 0xbb, 0xcc, 0xdd]), + }, + ] + ); + } + + #[test] + fn invariant_replay_failures_prefer_assertion_over_fail_on_revert() { + let call_result = RawCallResult:: { + reverted: true, + exit_reason: Some(InstructionResult::Revert), + ..Default::default() + }; + let invariant = serde_json::from_value::(serde_json::json!({ + "type": "function", + "name": "invariant_ok", + "inputs": [], + "outputs": [], + "stateMutability": "view" + })) + .unwrap(); + let failures = invariant_replay_failures( + Address::with_last_byte(1), + Selector::from([0xaa, 0xbb, 0xcc, 0xdd]), + true, + &[(&invariant, true)], + &call_result, + None, + ); + assert!(matches!(failures.as_slice(), [ReplayFailure::Handler { .. }])); + } + + #[test] + fn canonical_replay_dirs_collects_all_workers() { + let dir = temp_dir(); + let w0 = dir.join("worker0").join("corpus"); + let w1 = dir.join("worker1").join("corpus"); + std::fs::create_dir_all(&w0).unwrap(); + std::fs::create_dir_all(&w1).unwrap(); + assert_eq!(canonical_replay_dirs(&dir), vec![w0, w1]); + } + + #[test] + fn canonical_replay_dirs_falls_back_when_no_workers() { + let dir = temp_dir(); + assert_eq!(canonical_replay_dirs(&dir), vec![dir]); + } +} diff --git a/crates/evm/evm/src/executors/trace.rs b/crates/evm/evm/src/executors/trace.rs index ff99ba5009bc8..6906e3739c987 100644 --- a/crates/evm/evm/src/executors/trace.rs +++ b/crates/evm/evm/src/executors/trace.rs @@ -1,105 +1,363 @@ -use crate::{ - Env, - executors::{Executor, ExecutorBuilder}, -}; -use alloy_primitives::{Address, U256, map::HashMap}; +use crate::executors::{Executor, ExecutorBuilder}; +use alloy_primitives::{Address, ChainId, U256, map::HashMap}; use alloy_rpc_types::state::StateOverride; -use eyre::Context; +use eyre::{Context, ContextCompat}; use foundry_compilers::artifacts::EvmVersion; -use foundry_config::{Chain, Config, utils::evm_spec_id}; -use foundry_evm_core::{backend::Backend, fork::CreateFork, opts::EvmOpts}; -use foundry_evm_traces::TraceMode; -use revm::{primitives::hardfork::SpecId, state::Bytecode}; +use foundry_config::{Chain, Config, evm_spec_id}; +use foundry_evm_core::{ + backend::Backend, + evm::{EvmEnvFor, FoundryEvmNetwork, SpecFor, TxEnvFor}, + opts::{EvmOpts, ExecutionSpecContext, resolve_execution_spec}, +}; +use foundry_evm_hardforks::FoundryHardfork; +use foundry_evm_networks::{ + NetworkConfigs, + celo::transfer::{CELO_TRANSFER_ADDRESS, CELO_TRANSFER_LABEL}, + resolved_precompile_labels, +}; +use foundry_evm_traces::{TraceContext, TraceRequirements}; +use revm::state::Bytecode; use std::ops::{Deref, DerefMut}; /// A default executor with tracing enabled -pub struct TracingExecutor { - executor: Executor, +pub struct TracingExecutor { + executor: Executor, } -impl TracingExecutor { +/// Fork state and network context used to construct a tracing executor. +pub struct TracingFork { + pub evm_env: EvmEnvFor, + pub tx_env: TxEnvFor, + fork: Backend, + context: TraceContext, +} + +impl TracingFork { + pub const fn context(&self) -> TraceContext { + self.context + } + + /// Resolves the execution spec and carries it into the trace decoding context. + pub fn resolve_spec(&mut self, config: &Config, evm_version: Option) { + let hardfork = TracingExecutor::::resolve_spec_for_chain( + config, + self.context.chain().id(), + self.context.hardfork(), + &mut self.evm_env, + evm_version, + ); + self.context = self.context.with_hardfork(hardfork); + } + + /// Adds labels for precompiles active in this trace context. + pub fn extend_precompile_labels(&self, config: &mut Config) { + TracingExecutor::::extend_precompile_labels( + config, + self.context.networks(), + self.context.hardfork(), + ); + } + + /// Builds a tracing executor from this resolved fork. + pub fn into_executor( + self, + builder: ExecutorBuilder, + trace_requirements: TraceRequirements, + create2_deployer: Address, + state_overrides: Option, + ) -> eyre::Result> { + TracingExecutor::new( + builder, + (self.evm_env, self.tx_env), + self.fork, + None, + trace_requirements, + self.context.networks(), + create2_deployer, + state_overrides, + ) + } + + fn into_parts( + self, + ) -> (EvmEnvFor, TxEnvFor, Backend, Chain, NetworkConfigs, Option) + { + ( + self.evm_env, + self.tx_env, + self.fork, + self.context.chain(), + self.context.networks(), + self.context.hardfork(), + ) + } +} + +impl TracingExecutor { + /// Creates a tracing executor from tooling resolved by concrete network dispatch. + #[allow(clippy::too_many_arguments)] pub fn new( - env: Env, - fork: Option, + builder: ExecutorBuilder, + env: (EvmEnvFor, TxEnvFor), + fork: Backend, version: Option, - trace_mode: TraceMode, - odyssey: bool, + trace_requirements: TraceRequirements, + networks: NetworkConfigs, create2_deployer: Address, state_overrides: Option, ) -> eyre::Result { - let db = Backend::spawn(fork)?; - // configures a bare version of the evm executor: no cheatcode inspector is enabled, - // tracing will be enabled only for the targeted transaction - let mut executor = ExecutorBuilder::new() + let db = fork; + // configures a bare version of the evm executor: no cheatcode and log_collector inspector + // is enabled, tracing will be enabled only for the targeted transaction + let mut executor = builder .inspectors(|stack| { - stack.trace_mode(trace_mode).odyssey(odyssey).create2_deployer(create2_deployer) + stack.trace_requirements(trace_requirements).create2_deployer(create2_deployer) }) - .spec_id(evm_spec_id(version.unwrap_or_default(), odyssey)) - .build(env, db); + .spec_id_opt(version.map(evm_spec_id::>)) + .build(env.0, env.1, db, networks); - // Apply the state overrides. if let Some(state_overrides) = state_overrides { - for (address, overrides) in state_overrides { - if let Some(balance) = overrides.balance { - executor.set_balance(address, balance)?; - } - if let Some(nonce) = overrides.nonce { - executor.set_nonce(address, nonce)?; - } - if let Some(code) = overrides.code { - let bytecode = Bytecode::new_raw_checked(code) - .wrap_err("invalid bytecode in state override")?; - executor.set_code(address, bytecode)?; - } - if let Some(state) = overrides.state { - let state: HashMap = state - .into_iter() - .map(|(slot, value)| (slot.into(), value.into())) - .collect(); - executor.set_storage(address, state)?; - } - if let Some(state_diff) = overrides.state_diff { - for (slot, value) in state_diff { - executor.set_storage_slot(address, slot.into(), value.into())?; - } - } - } + apply_state_overrides(&mut executor, state_overrides)?; } Ok(Self { executor }) } /// Returns the spec id of the executor - pub fn spec_id(&self) -> SpecId { + pub const fn spec_id(&self) -> SpecFor { self.executor.spec_id() } - /// uses the fork block number from the config - pub async fn get_fork_material( + /// Resolves and applies the execution spec using the source chain's hardfork schedule. + pub fn resolve_spec_for_chain( config: &Config, + source_chain_id: ChainId, + endpoint_hardfork: Option, + evm_env: &mut EvmEnvFor, + evm_version: Option, + ) -> Option { + resolve_execution_spec( + config.evm_version, + config.hardfork, + evm_env, + ExecutionSpecContext::historical(source_chain_id, endpoint_hardfork), + evm_version.map(evm_spec_id::>), + ) + } + + /// Extends trace labels with the precompiles active at the resolved execution hardfork. + pub fn extend_precompile_labels( + config: &mut Config, + networks: NetworkConfigs, + resolved_hardfork: Option, + ) { + config.labels.extend(resolved_precompile_labels(resolved_hardfork)); + // Celo shares the Ethereum spec and remains a separate inspector configuration. + if networks.is_celo() { + config.labels.insert(CELO_TRANSFER_ADDRESS, CELO_TRANSFER_LABEL.to_string()); + } + } + + /// Resolves the fork state and trace context using the fork block number from the config. + pub async fn get_fork( + config: &mut Config, mut evm_opts: EvmOpts, - ) -> eyre::Result<(Env, Option, Option, bool)> { + ) -> eyre::Result> { evm_opts.fork_url = Some(config.get_rpc_url_or_localhost_http()?.into_owned()); evm_opts.fork_block_number = config.fork_block_number; + // Tracing replays a specific chain history and must keep hash-addressed state. + evm_opts.fork_state_by_number = false; + evm_opts.infer_network_from_fork().await?; + let networks = evm_opts.networks; + let fork = Backend::::spawn(evm_opts.get_fork( + config, + evm_opts.env.chain_id.unwrap_or_default(), + None, + ))?; + let (evm_env, tx_env) = fork.env(&evm_opts).await?; + let fork_context = + fork.fork()?.context("fork context is missing for tracing executor")?.context(); - let env = evm_opts.evm_env().await?; + let chain = fork_context.source_chain_id.into(); + Ok(TracingFork { + evm_env, + tx_env, + fork, + context: TraceContext::new(chain, networks, fork_context.hardfork), + }) + } - let fork = evm_opts.get_fork(config, env.clone()); + /// Returns the tracing fork as its legacy tuple representation. + pub async fn get_fork_material( + config: &mut Config, + evm_opts: EvmOpts, + ) -> eyre::Result<( + EvmEnvFor, + TxEnvFor, + Backend, + Chain, + NetworkConfigs, + Option, + )> { + Ok(Self::get_fork(config, evm_opts).await?.into_parts()) + } +} - Ok((env, fork, evm_opts.get_remote_chain_id().await, evm_opts.odyssey)) +fn apply_state_overrides( + executor: &mut Executor, + state_overrides: StateOverride, +) -> eyre::Result<()> { + for (address, overrides) in state_overrides { + if let Some(balance) = overrides.balance { + executor.set_balance(address, balance)?; + } + if let Some(nonce) = overrides.nonce { + executor.set_account_nonce(address, nonce)?; + } + if let Some(code) = overrides.code { + let bytecode = + Bytecode::new_raw_checked(code).wrap_err("invalid bytecode in state override")?; + executor.set_code(address, bytecode)?; + } + if let Some(state) = overrides.state { + let state: HashMap = + state.into_iter().map(|(slot, value)| (slot.into(), value.into())).collect(); + executor.set_storage(address, state)?; + } + if let Some(state_diff) = overrides.state_diff { + for (slot, value) in state_diff { + executor.set_storage_slot(address, slot.into(), value.into())?; + } + } } + Ok(()) } -impl Deref for TracingExecutor { - type Target = Executor; +impl Deref for TracingExecutor { + type Target = Executor; fn deref(&self) -> &Self::Target { &self.executor } } -impl DerefMut for TracingExecutor { +impl DerefMut for TracingExecutor { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.executor } } + +#[cfg(test)] +mod tests { + use super::*; + use alloy_evm::EvmEnv; + use alloy_rpc_types::state::AccountOverride; + use foundry_evm_core::{FoundryTransaction, evm::EthEvmNetwork}; + use revm::context::{Transaction, TxEnv}; + + #[cfg(feature = "base")] + use foundry_evm_core::evm::BaseEvmNetwork; + #[cfg(feature = "base")] + use foundry_evm_hardforks::{BaseSpecId, BaseUpgrade}; + + fn assert_trace_spec_authority( + networks: NetworkConfigs, + configured: FoundryHardfork, + evm_version: EvmVersion, + expected_spec: SpecFor, + expected_hardfork: Option, + ) where + FEN: FoundryEvmNetwork, + SpecFor: std::fmt::Debug + PartialEq, + { + let mut config = Config { networks, hardfork: Some(configured), ..Default::default() }; + let mut env = EvmEnvFor::::default(); + env.cfg_env.chain_id = 999_999; + let hardfork = TracingExecutor::::resolve_spec_for_chain( + &config, + 1, + Some(configured), + &mut env, + Some(evm_version), + ); + assert_eq!(env.cfg_env.spec, expected_spec); + assert_eq!(hardfork, expected_hardfork); + assert_eq!(env.cfg_env.chain_id, 999_999); + + TracingExecutor::::extend_precompile_labels(&mut config, networks, hardfork); + assert_eq!(config.labels, resolved_precompile_labels(expected_hardfork)); + let context = TraceContext::new(Chain::from_id(1), networks, hardfork); + let decoder = foundry_evm_traces::CallTraceDecoderBuilder::new() + .with_networks(context.networks()) + .with_hardfork(context.hardfork()) + .build(); + assert_eq!(decoder.hardfork(), expected_hardfork); + } + + #[test] + fn trace_spec_ethereum_override_preserves_absent_metadata() { + assert_trace_spec_authority::( + NetworkConfigs::default(), + "ethereum:shanghai".parse().unwrap(), + EvmVersion::Cancun, + revm::primitives::hardfork::SpecId::CANCUN, + None, + ); + } + + #[test] + fn trace_spec_tempo_override_reports_executed_hardfork() { + let spec = evm_spec_id::(EvmVersion::Cancun); + assert_trace_spec_authority::( + NetworkConfigs::with_tempo(), + "tempo:T3".parse().unwrap(), + EvmVersion::Cancun, + spec, + Some(spec.into()), + ); + } + + #[cfg(feature = "base")] + #[test] + fn trace_spec_base_override_reports_executed_hardfork() { + assert_trace_spec_authority::( + NetworkConfigs::with_base(), + FoundryHardfork::Base(BaseUpgrade::Cobalt), + EvmVersion::Cancun, + BaseSpecId::new(BaseUpgrade::Ecotone), + Some(FoundryHardfork::Base(BaseUpgrade::Ecotone)), + ); + } + + #[test] + fn state_override_nonce_does_not_modify_transaction_nonce() { + let sender = Address::repeat_byte(0x11); + let mut tx_env = TxEnv::default(); + tx_env.set_caller(sender); + tx_env.set_nonce(7); + let backend = Backend::::spawn(None).unwrap(); + let mut evm_env = EvmEnv::default(); + evm_env.cfg_env.disable_nonce_check = true; + let mut executor = + ExecutorBuilder::default().build(evm_env, tx_env, backend, NetworkConfigs::default()); + executor.set_gas_limit(1_000_000); + executor.set_account_nonce(sender, 7).unwrap(); + + let overridden = Address::repeat_byte(0x42); + let mut state_overrides = StateOverride::default(); + state_overrides.insert(sender, AccountOverride { nonce: Some(100), ..Default::default() }); + state_overrides + .insert(overridden, AccountOverride { nonce: Some(200), ..Default::default() }); + + apply_state_overrides(&mut executor, state_overrides).unwrap(); + + assert_eq!(executor.get_nonce(sender).unwrap(), 100); + assert_eq!(executor.get_nonce(overridden).unwrap(), 200); + assert_eq!(executor.tx_env().caller(), sender); + assert_eq!(executor.tx_env().nonce(), 7); + + let result = + executor.transact_raw(sender, overridden, Default::default(), U256::ZERO).unwrap(); + assert_eq!(result.tx_env.nonce(), 7); + } +} diff --git a/crates/evm/evm/src/inspectors/chisel_state.rs b/crates/evm/evm/src/inspectors/chisel_state.rs index f99ae4116fd32..1388f3c18140f 100644 --- a/crates/evm/evm/src/inspectors/chisel_state.rs +++ b/crates/evm/evm/src/inspectors/chisel_state.rs @@ -1,14 +1,7 @@ use alloy_primitives::U256; -use foundry_evm_core::backend::DatabaseError; use revm::{ - Database, Inspector, - context::ContextTr, - inspector::JournalExt, - interpreter::{ - InstructionResult, Interpreter, - interpreter::EthInterpreter, - interpreter_types::{Jumps, LoopControl}, - }, + Inspector, + interpreter::{Interpreter, interpreter_types::Jumps}, }; /// An inspector for Chisel @@ -17,23 +10,18 @@ pub struct ChiselState { /// The PC of the final instruction pub final_pc: usize, /// The final state of the REPL contract call - pub state: Option<(Vec, Vec, Option)>, + pub state: Option<(Vec, Vec)>, } impl ChiselState { /// Create a new Chisel state inspector. #[inline] - pub fn new(final_pc: usize) -> Self { + pub const fn new(final_pc: usize) -> Self { Self { final_pc, state: None } } } -impl Inspector for ChiselState -where - D: Database, - CTX: ContextTr, - CTX::Journal: JournalExt, -{ +impl Inspector for ChiselState { #[cold] fn step_end(&mut self, interpreter: &mut Interpreter, _context: &mut CTX) { // If we are at the final pc of the REPL contract execution, set the state. @@ -42,7 +30,6 @@ where self.state = Some(( interpreter.stack.data().clone(), interpreter.memory.context_memory().to_vec(), - interpreter.bytecode.instruction_result(), )) } } diff --git a/crates/evm/evm/src/inspectors/custom_printer.rs b/crates/evm/evm/src/inspectors/custom_printer.rs index 2d39f33b955b3..5ed4f4cb2e49e 100644 --- a/crates/evm/evm/src/inspectors/custom_printer.rs +++ b/crates/evm/evm/src/inspectors/custom_printer.rs @@ -2,15 +2,13 @@ //! It is a great tool if some debugging is needed. use foundry_common::sh_println; -use foundry_evm_core::backend::DatabaseError; use revm::{ - Database, Inspector, + Inspector, bytecode::opcode::OpCode, context::{ContextTr, JournalTr}, - inspector::{JournalExt, inspectors::GasInspector}, + inspector::inspectors::GasInspector, interpreter::{ CallInputs, CallOutcome, CreateInputs, CreateOutcome, Interpreter, - interpreter::EthInterpreter, interpreter_types::{Jumps, MemoryTr}, }, primitives::{Address, U256}, @@ -24,12 +22,7 @@ pub struct CustomPrintTracer { gas_inspector: GasInspector, } -impl Inspector for CustomPrintTracer -where - D: Database, - CTX: ContextTr, - CTX::Journal: JournalExt, -{ +impl Inspector for CustomPrintTracer { fn initialize_interp(&mut self, interp: &mut Interpreter, _context: &mut CTX) { self.gas_inspector.initialize_interp(&interp.gas); } @@ -62,7 +55,7 @@ where } fn step_end(&mut self, interpreter: &mut Interpreter, _context: &mut CTX) { - self.gas_inspector.step_end(&mut interpreter.gas); + self.gas_inspector.step_end(&interpreter.gas); } fn call_end(&mut self, _context: &mut CTX, _inputs: &CallInputs, outcome: &mut CallOutcome) { @@ -82,8 +75,8 @@ where let _ = sh_println!( "SM Address: {:?}, caller:{:?},target:{:?} is_static:{:?}, transfer:{:?}, input_size:{:?}", inputs.bytecode_address, - inputs.caller, - inputs.target_address, + inputs.transfer_from(), + inputs.transfer_to(), inputs.is_static, inputs.value, inputs.input.len(), @@ -94,11 +87,11 @@ where fn create(&mut self, _context: &mut CTX, inputs: &mut CreateInputs) -> Option { let _ = sh_println!( "CREATE CALL: caller:{:?}, scheme:{:?}, value:{:?}, init_code:{:?}, gas:{:?}", - inputs.caller, - inputs.scheme, - inputs.value, - inputs.init_code, - inputs.gas_limit + inputs.caller(), + inputs.scheme(), + inputs.value(), + inputs.init_code(), + inputs.gas_limit() ); None } diff --git a/crates/evm/evm/src/inspectors/edge_cov.rs b/crates/evm/evm/src/inspectors/edge_cov.rs new file mode 100644 index 0000000000000..3471cc613a251 --- /dev/null +++ b/crates/evm/evm/src/inspectors/edge_cov.rs @@ -0,0 +1,695 @@ +use alloy_primitives::{ + Address, U256, + map::{Entry, HashMap}, +}; +use core::fmt; +use revm::{ + Inspector, + bytecode::opcode, + context::{ContextTr, JournalTr}, + interpreter::{ + Interpreter, + interpreter_types::{InputsTr, Jumps}, + }, +}; + +// Default capacity for the hitcount buffer. +pub(crate) const MAX_EDGE_COUNT: usize = 65536; + +// Maximum number of unique comparison sites to track for CmpLog-style feedback. +const MAX_CMP_LOG_SITES: usize = 1024; + +// Maximum number of comparison operand pairs to track per site. This matches the downstream loop +// detection threshold so a hot loop can be classified without filling the whole log. +const MAX_CMP_OBSERVATIONS_PER_SITE: u8 = 8; + +/// Edge coverage collection kind. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum EdgeCovKind { + /// Assign dense monotonically-increasing indices to unique edges. + #[default] + CollisionFree, + /// Preserve the legacy fixed-size hash ID calculation. + Hash, +} + +/// Configuration for [`EdgeCovInspector`]. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct EdgeCovConfig { + /// Which edge coverage representation should be collected. + pub kind: EdgeCovKind, + /// Whether call-frame depth should be included in the edge identity. + pub include_call_depth: bool, +} + +impl EdgeCovConfig { + /// Creates a new edge coverage configuration. + pub const fn new(kind: EdgeCovKind, include_call_depth: bool) -> Self { + Self { kind, include_call_depth } + } + + /// Legacy fixed-size hash ID configuration. + pub const fn legacy_hash_ids() -> Self { + Self::new(EdgeCovKind::Hash, false) + } +} + +impl Default for EdgeCovConfig { + fn default() -> Self { + Self::new(EdgeCovKind::CollisionFree, false) + } +} + +impl From<&foundry_config::FuzzCorpusConfig> for EdgeCovConfig { + fn from(corpus: &foundry_config::FuzzCorpusConfig) -> Self { + let kind = if corpus.evm_edge_coverage_collision_free() { + EdgeCovKind::CollisionFree + } else { + EdgeCovKind::Hash + }; + Self::new(kind, corpus.evm_edge_coverage_include_call_depth()) + } +} + +/// A comparison operand pair captured during execution. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct CmpOperands { + /// First operand of the comparison. + pub op1: U256, + /// Second operand of the comparison. + pub op2: U256, + /// Program counter where the comparison occurred. + pub pc: usize, + /// Contract address where the comparison occurred. + pub address: Address, + /// EVM opcode that performed the comparison. + pub opcode: u8, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct EdgeKey { + pub address: Address, + pub depth: Option, + pub pc: usize, + pub jump_dest: U256, +} + +impl EdgeKey { + fn new( + address: Address, + depth: usize, + pc: usize, + jump_dest: U256, + include_depth: bool, + ) -> Self { + Self { address, depth: include_depth.then_some(depth), pc, jump_dest } + } +} + +#[derive(Clone, Debug, Default)] +pub struct EdgeIndexMap { + edge_indices: HashMap, + next_index: usize, +} + +impl EdgeIndexMap { + #[inline] + pub fn edge_index(&mut self, edge: EdgeKey) -> usize { + match self.edge_indices.entry(edge) { + Entry::Occupied(entry) => *entry.get(), + Entry::Vacant(entry) => { + let index = self.next_index; + self.next_index += 1; + entry.insert(index); + index + } + } + } + + pub const fn edge_count(&self) -> usize { + self.next_index + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct EdgeCovHit { + pub edge: EdgeKey, + pub count: u8, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum EdgeCoverage { + Hash(Vec), + CollisionFree(Vec), +} + +impl EdgeCoverage { + pub fn is_empty(&self) -> bool { + match self { + Self::Hash(hitcount) => hitcount.iter().all(|&count| count == 0), + Self::CollisionFree(hits) => hits.is_empty(), + } + } +} + +/// An `Inspector` that tracks [edge coverage](https://clang.llvm.org/docs/SanitizerCoverage.html#edge-coverage). +/// Covered edges will not wrap to zero e.g. a loop edge hit more than 255 will still be retained. +/// +/// Also tracks comparison operands for CmpLog-style guided fuzzing. +// see https://github.com/AFLplusplus/AFLplusplus/blob/5777ceaf23f48ae4ceae60e4f3a79263802633c6/instrumentation/afl-llvm-pass.so.cc#L810-L829 +#[derive(Clone)] +pub struct EdgeCovInspector { + /// Map of hitcounts that can be diffed against to determine if new coverage was reached. + hitcount: Vec, + /// Configuration for edge ID generation. + config: EdgeCovConfig, + /// Whether to collect edge hits. Comparison-only consumers keep this off to avoid affecting + /// the active coverage guidance source. + collect_edges: bool, + /// Per-execution dense edge hitcounts. Stable IDs are assigned by the corpus history owner. + dense_hitcount: HashMap, + /// Comparison operand log for CmpLog-style guided fuzzing. + cmp_log: Option>, + cmp_site_counts: HashMap, +} + +impl fmt::Debug for EdgeCovInspector { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("EdgeCovInspector") + .field("capacity", &self.hitcount.len()) + .field("edges", &self.edge_count()) + .field("config", &self.config) + .field("collect_edges", &self.collect_edges) + .finish() + } +} + +impl EdgeCovInspector { + /// Create a new `EdgeCovInspector` with default configuration and capacity. + pub fn new() -> Self { + Self::with_config(EdgeCovConfig::default()) + } + + /// Create a new `EdgeCovInspector` with comparison operand logging enabled. + pub fn with_cmp_log() -> Self { + let mut inspector = Self::new(); + inspector.enable_cmp_log(true); + inspector + } + + /// Create a comparison-operand inspector without collecting EVM edge hits. + pub fn with_cmp_log_only() -> Self { + let mut inspector = Self::new(); + inspector.collect_edges = false; + inspector.enable_cmp_log(true); + inspector + } + + /// Create a new `EdgeCovInspector` with the given configuration. + /// + /// [`EdgeCovKind::Hash`] preallocates a fixed-size bitmap; + /// [`EdgeCovKind::CollisionFree`] grows its dense map on demand. + pub fn with_config(config: EdgeCovConfig) -> Self { + let hitcount = match config.kind { + EdgeCovKind::Hash => vec![0; MAX_EDGE_COUNT], + EdgeCovKind::CollisionFree => Vec::new(), + }; + Self { + hitcount, + config, + collect_edges: true, + dense_hitcount: HashMap::default(), + cmp_log: None, + cmp_site_counts: HashMap::default(), + } + } + + /// Set whether to collect comparison operand logs. + pub fn enable_cmp_log(&mut self, yes: bool) { + if yes { + self.cmp_log.get_or_insert_with(|| Vec::with_capacity(MAX_CMP_LOG_SITES)); + } else { + self.cmp_log = None; + self.cmp_site_counts.clear(); + } + } + + /// Reset the hitcount to zero and clear the comparison log. + pub fn reset(&mut self) { + match self.config.kind { + EdgeCovKind::CollisionFree => self.dense_hitcount.clear(), + EdgeCovKind::Hash => self.hitcount.fill(0), + } + if let Some(cmp_log) = &mut self.cmp_log { + cmp_log.clear(); + } + self.cmp_site_counts.clear(); + } + + /// Get an immutable reference to the comparison operand log. + pub const fn get_cmp_log(&self) -> &[CmpOperands] { + match &self.cmp_log { + Some(cmp_log) => cmp_log.as_slice(), + None => &[], + } + } + + /// Consume the inspector and take ownership of both the hitcount and comparison log. + pub fn into_parts(mut self) -> (EdgeCoverage, Vec) { + let cmp_log = self.cmp_log.take().unwrap_or_default(); + (self.into(), cmp_log) + } + + /// Number of unique collision-free edges discovered so far. + pub fn edge_count(&self) -> usize { + self.dense_hitcount.len() + } + + /// Mark the edge `(address, depth, pc, jump_dest)` as hit. + fn store_hit(&mut self, address: Address, depth: usize, pc: usize, jump_dest: U256) { + if !self.collect_edges { + return; + } + + let edge_id = match self.config.kind { + EdgeCovKind::CollisionFree => { + self.store_dense_hit(address, depth, pc, jump_dest); + return; + } + EdgeCovKind::Hash => self.hash_edge_id(address, depth, pc, jump_dest), + }; + self.hitcount[edge_id] = self.hitcount[edge_id].wrapping_add(1).max(1); + } + + fn store_dense_hit(&mut self, address: Address, depth: usize, pc: usize, jump_dest: U256) { + let key = EdgeKey::new(address, depth, pc, jump_dest, self.config.include_call_depth); + let count = self.dense_hitcount.entry(key).or_default(); + *count = count.wrapping_add(1).max(1); + } + + fn hash_edge_id( + &mut self, + address: Address, + depth: usize, + pc: usize, + jump_dest: U256, + ) -> usize { + let mut hash = FNV_OFFSET_BASIS; + hash_bytes(&mut hash, address.as_slice()); + if self.config.include_call_depth { + hash_bytes(&mut hash, &depth.to_le_bytes()); + } + hash_bytes(&mut hash, &pc.to_le_bytes()); + hash_bytes(&mut hash, &jump_dest.to_be_bytes::<32>()); + // The hash is used to index into the hitcount array, + // so it must be modulo the map size. + (hash % self.hitcount.len() as u64) as usize + } + + #[cfg(test)] + fn dense_hits(&self) -> Vec { + let mut hits = self + .dense_hitcount + .iter() + .map(|(&edge, &count)| EdgeCovHit { edge, count }) + .collect::>(); + hits.sort_by_key(|hit| hit.edge); + hits + } + + /// Store comparison operands for CmpLog-style guided fuzzing. + fn store_cmp(&mut self, cmp: CmpOperands) { + let Some(cmp_log) = &mut self.cmp_log else { + return; + }; + + let site = CmpSiteKey::new(&cmp); + if let Some(count) = self.cmp_site_counts.get_mut(&site) { + if *count >= MAX_CMP_OBSERVATIONS_PER_SITE { + return; + } + *count += 1; + cmp_log.push(cmp); + } else if self.cmp_site_counts.len() < MAX_CMP_LOG_SITES { + self.cmp_site_counts.insert(site, 1); + cmp_log.push(cmp); + } + } + + #[cold] + fn do_step(&mut self, interp: &mut Interpreter, context: &mut CTX) + where + CTX: ContextTr, + { + let address = interp.input.target_address(); + let depth = context.journal_ref().depth(); + let current_pc = interp.bytecode.pc(); + + match interp.bytecode.opcode() { + opcode::JUMP => { + // unconditional jump + if let Ok(jump_dest) = interp.stack.peek(0) { + self.store_hit(address, depth, current_pc, jump_dest); + } + } + opcode::JUMPI => { + if let Ok(stack_value) = interp.stack.peek(1) { + let jump_dest = if stack_value.is_zero() { + // fall through + Ok(U256::from(current_pc + 1)) + } else { + // branch taken + interp.stack.peek(0) + }; + + if let Ok(jump_dest) = jump_dest { + self.store_hit(address, depth, current_pc, jump_dest); + } + } + } + _ => { + // no-op + } + } + } + + #[cold] + fn do_cmp_step(&mut self, interp: &mut Interpreter) { + if self.cmp_log.is_none() { + return; + } + + let address = interp.input.target_address(); + let current_pc = interp.bytecode.pc(); + + match interp.bytecode.opcode() { + op @ (opcode::EQ | opcode::LT | opcode::SLT | opcode::GT | opcode::SGT) => { + if let (Ok(op1), Ok(op2)) = (interp.stack.peek(0), interp.stack.peek(1)) { + self.store_cmp(CmpOperands { op1, op2, pc: current_pc, address, opcode: op }); + } + } + op @ opcode::ISZERO => { + if let Ok(op1) = interp.stack.peek(0) { + self.store_cmp(CmpOperands { + op1, + op2: U256::ZERO, + pc: current_pc, + address, + opcode: op, + }); + } + } + _ => {} + } + } +} + +impl Default for EdgeCovInspector { + fn default() -> Self { + Self::new() + } +} + +impl From for EdgeCoverage { + fn from(inspector: EdgeCovInspector) -> Self { + let EdgeCovInspector { hitcount, config, dense_hitcount, .. } = inspector; + match config.kind { + // Hits are deliberately not sorted here — this is the per-call drain + // path and `merge_edge_coverage` doesn't care about order. Consumers + // that need a deterministic order (e.g. `snapshot_edge_fingerprint`) + // sort locally. + EdgeCovKind::CollisionFree => Self::CollisionFree( + dense_hitcount + .into_iter() + .map(|(edge, count)| EdgeCovHit { edge, count }) + .collect(), + ), + EdgeCovKind::Hash => Self::Hash(hitcount), + } + } +} + +impl Inspector for EdgeCovInspector +where + CTX: ContextTr, +{ + #[inline] + fn step(&mut self, interp: &mut Interpreter, context: &mut CTX) { + let op = interp.bytecode.opcode(); + if self.collect_edges && matches!(op, opcode::JUMP | opcode::JUMPI) { + self.do_step(interp, context); + } + if self.cmp_log.is_some() + && matches!( + op, + opcode::EQ | opcode::LT | opcode::GT | opcode::SLT | opcode::SGT | opcode::ISZERO + ) + { + self.do_cmp_step(interp); + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +struct CmpSiteKey { + address: Address, + pc: usize, + opcode: u8, +} + +impl CmpSiteKey { + const fn new(cmp: &CmpOperands) -> Self { + Self { address: cmp.address, pc: cmp.pc, opcode: cmp.opcode } + } +} + +const FNV_OFFSET_BASIS: u64 = 0xcbf29ce484222325; +const FNV_PRIME: u64 = 0x100000001b3; + +fn hash_bytes(hash: &mut u64, bytes: &[u8]) { + for byte in bytes { + *hash ^= u64::from(*byte); + *hash = hash.wrapping_mul(FNV_PRIME); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dense_counts(inspector: &EdgeCovInspector) -> Vec { + inspector.dense_hits().into_iter().map(|hit| hit.count).collect() + } + + #[test] + fn cmp_operands_defaults_and_clones() { + let cmp = CmpOperands { + op1: U256::from(123), + op2: U256::from(456), + pc: 42, + address: Address::repeat_byte(0xaa), + opcode: opcode::EQ, + }; + + assert_eq!(cmp.op1, U256::from(123)); + assert_eq!(cmp.op2, U256::from(456)); + assert_eq!(cmp.pc, 42); + + assert_eq!(CmpOperands::default(), CmpOperands::default()); + let cloned = cmp; + assert_eq!(cloned, cmp); + } + + #[test] + fn cmp_log_starts_empty_and_is_returned_by_into_parts() { + let inspector = EdgeCovInspector::new(); + + assert!(inspector.get_cmp_log().is_empty()); + + let (coverage, cmp_log) = inspector.into_parts(); + assert_eq!(coverage, EdgeCoverage::CollisionFree(Vec::new())); + assert!(cmp_log.is_empty()); + } + + #[test] + fn cmp_log_only_does_not_collect_edges() { + let mut inspector = EdgeCovInspector::with_cmp_log_only(); + let addr = Address::ZERO; + + inspector.store_hit(addr, 0, 0, U256::ONE); + inspector.store_cmp(CmpOperands { + op1: U256::from(123), + op2: U256::from(456), + pc: 42, + address: addr, + opcode: opcode::EQ, + }); + + let (coverage, cmp_log) = inspector.into_parts(); + assert_eq!(coverage, EdgeCoverage::CollisionFree(Vec::new())); + assert_eq!(cmp_log.len(), 1); + } + + #[test] + fn collision_free_ids() { + let mut inspector = EdgeCovInspector::new(); + let addr = Address::ZERO; + + inspector.store_hit(addr, 0, 0, U256::from(10)); + inspector.store_hit(addr, 0, 0, U256::from(20)); + inspector.store_hit(addr, 0, 1, U256::from(10)); + + assert_eq!(inspector.edge_count(), 3); + assert_eq!(dense_counts(&inspector), [1, 1, 1]); + } + + #[test] + fn same_edge_increments_same_dense_slot() { + let mut inspector = EdgeCovInspector::new(); + let addr = Address::ZERO; + + for _ in 0..5 { + inspector.store_hit(addr, 0, 42, U256::from(100)); + } + + assert_eq!(inspector.edge_count(), 1); + assert_eq!(dense_counts(&inspector), [5]); + } + + #[test] + fn edge_index_map_keeps_indices_stable() { + let addr = Address::ZERO; + let mut indices = EdgeIndexMap::default(); + let first = EdgeKey::new(addr, 0, 0, U256::from(10), false); + let second = EdgeKey::new(addr, 0, 0, U256::from(20), false); + + assert_eq!(indices.edge_index(first), 0); + assert_eq!(indices.edge_index(second), 1); + assert_eq!(indices.edge_index(first), 0); + assert_eq!(indices.edge_count(), 2); + } + + #[test] + fn hitcount_neverzero_on_wrap() { + let mut inspector = EdgeCovInspector::new(); + let addr = Address::ZERO; + + for _ in 0..256 { + inspector.store_hit(addr, 0, 0, U256::ONE); + } + + assert_eq!(inspector.edge_count(), 1); + assert_eq!(dense_counts(&inspector), [1]); + } + + #[test] + fn reset_clears_dense_hitcounts() { + let mut inspector = EdgeCovInspector::new(); + let addr = Address::ZERO; + + inspector.store_hit(addr, 0, 0, U256::ONE); + inspector.store_hit(addr, 0, 0, U256::from(2)); + assert_eq!(inspector.edge_count(), 2); + assert_eq!(dense_counts(&inspector), [1, 1]); + + inspector.reset(); + assert_eq!(inspector.edge_count(), 0); + assert!(inspector.dense_hits().is_empty()); + + inspector.store_hit(addr, 0, 0, U256::ONE); + assert_eq!(inspector.edge_count(), 1); + assert_eq!(dense_counts(&inspector), [1]); + } + + #[test] + fn hash_ids_use_stable_encoding() { + let mut inspector = EdgeCovInspector::with_config(EdgeCovConfig::legacy_hash_ids()); + let addr = Address::ZERO; + let pc = 42; + let jump_dest = U256::from(100); + + inspector.store_hit(addr, 0, pc, jump_dest); + + assert_eq!(inspector.hitcount[65235], 1); + assert_eq!(inspector.hitcount.iter().filter(|&&count| count != 0).count(), 1); + } + + #[test] + fn call_depth_option_delineates_same_edge() { + let addr = Address::ZERO; + + let mut without_depth = EdgeCovInspector::new(); + without_depth.store_hit(addr, 0, 0, U256::ONE); + without_depth.store_hit(addr, 1, 0, U256::ONE); + assert_eq!(without_depth.edge_count(), 1); + assert_eq!(dense_counts(&without_depth), [2]); + + let mut with_depth = + EdgeCovInspector::with_config(EdgeCovConfig::new(EdgeCovKind::CollisionFree, true)); + with_depth.store_hit(addr, 0, 0, U256::ONE); + with_depth.store_hit(addr, 1, 0, U256::ONE); + assert_eq!(with_depth.edge_count(), 2); + assert_eq!(dense_counts(&with_depth), [1, 1]); + } + + #[test] + fn reset_clears_hitcount_and_cmp_log() { + let mut inspector = EdgeCovInspector::with_cmp_log(); + + inspector.store_hit(Address::ZERO, 0, 0, U256::ONE); + inspector.store_cmp(CmpOperands { + op1: U256::from(123), + op2: U256::from(456), + pc: 42, + address: Address::ZERO, + opcode: opcode::EQ, + }); + + inspector.reset(); + + assert!(inspector.dense_hits().is_empty()); + assert!(inspector.get_cmp_log().is_empty()); + } + + #[test] + fn cmp_log_is_capped_per_site() { + let mut inspector = EdgeCovInspector::with_cmp_log(); + + for i in 0..usize::from(MAX_CMP_OBSERVATIONS_PER_SITE) + 1 { + inspector.store_cmp(CmpOperands { + op1: U256::from(i), + op2: U256::from(i + 1), + pc: 42, + address: Address::ZERO, + opcode: opcode::EQ, + }); + } + + assert_eq!(inspector.get_cmp_log().len(), usize::from(MAX_CMP_OBSERVATIONS_PER_SITE)); + } + + #[test] + fn cmp_log_caps_sites_without_starving_later_observations() { + let mut inspector = EdgeCovInspector::with_cmp_log(); + + for i in 0..usize::from(MAX_CMP_OBSERVATIONS_PER_SITE) * 2 { + inspector.store_cmp(CmpOperands { + op1: U256::from(i), + op2: U256::from(i + 1), + pc: 1, + address: Address::ZERO, + opcode: opcode::EQ, + }); + } + inspector.store_cmp(CmpOperands { + op1: U256::from(123), + op2: U256::from(456), + pc: 2, + address: Address::ZERO, + opcode: opcode::EQ, + }); + + assert_eq!(inspector.get_cmp_log().len(), usize::from(MAX_CMP_OBSERVATIONS_PER_SITE) + 1); + assert_eq!(inspector.get_cmp_log().last().unwrap().pc, 2); + } +} diff --git a/crates/evm/evm/src/inspectors/logs.rs b/crates/evm/evm/src/inspectors/logs.rs index 4b7bee7ce11a7..8edbc6ae4df1b 100644 --- a/crates/evm/evm/src/inspectors/logs.rs +++ b/crates/evm/evm/src/inspectors/logs.rs @@ -1,37 +1,49 @@ use alloy_primitives::Log; use alloy_sol_types::{SolEvent, SolInterface, SolValue}; -use foundry_common::{ErrorExt, fmt::ConsoleFmt}; -use foundry_evm_core::{InspectorExt, abi::console, constants::HARDHAT_CONSOLE_ADDRESS}; +use foundry_common::{ErrorExt, fmt::ConsoleFmt, sh_println}; +use foundry_evm_core::{ + InspectorExt, abi::console, constants::HARDHAT_CONSOLE_ADDRESS, decode::decode_console_log, +}; use revm::{ Inspector, context::ContextTr, - interpreter::{ - CallInputs, CallOutcome, Gas, InstructionResult, Interpreter, InterpreterResult, - interpreter::EthInterpreter, - }, + interpreter::{CallInputs, CallOutcome, Gas, InstructionResult, InterpreterResult}, }; /// An inspector that collects logs during execution. /// /// The inspector collects logs from the `LOG` opcodes as well as Hardhat-style `console.sol` logs. -#[derive(Clone, Debug, Default)] -pub struct LogCollector { +#[derive(Clone, Debug)] +pub enum LogCollector { /// The collected logs. Includes both `LOG` opcodes and Hardhat-style `console.sol` logs. - pub logs: Vec, + Capture { logs: Vec }, + /// Print logs directly to stdout. + LiveLogs, } impl LogCollector { + pub fn into_captured_logs(self) -> Option> { + match self { + Self::Capture { logs } => Some(logs), + Self::LiveLogs => None, + } + } + #[cold] - fn do_hardhat_log(&mut self, context: &mut CTX, inputs: &CallInputs) -> Option - where - CTX: ContextTr, - { + fn do_hardhat_log( + &mut self, + context: &mut CTX, + inputs: &CallInputs, + ) -> Option { if let Err(err) = self.hardhat_log(&inputs.input.bytes(context)) { let result = InstructionResult::Revert; let output = err.abi_encode_revert(); return Some(CallOutcome { result: InterpreterResult { result, output, gas: Gas::new(inputs.gas_limit) }, memory_offset: inputs.return_memory_offset.clone(), + was_precompile_called: true, + precompile_call_logs: vec![], + charged_new_account_state_gas: inputs.charged_new_account_state_gas, }); } None @@ -39,21 +51,43 @@ impl LogCollector { fn hardhat_log(&mut self, data: &[u8]) -> alloy_sol_types::Result<()> { let decoded = console::hh::ConsoleCalls::abi_decode(data)?; - self.logs.push(hh_to_ds(&decoded)); + for line in decoded.fmt(Default::default()).lines() { + self.push_msg(line); + } Ok(()) } + + fn push_raw_log(&mut self, log: Log) { + match self { + Self::Capture { logs } => logs.push(log), + Self::LiveLogs => { + if let Some(msg) = decode_console_log(&log) { + sh_println!("{msg}").expect("fail printing to stdout"); + } else { + // This case should not happen if the users call through forge-std. + // We print the log data for the user nonetheless. + sh_println!("console.log({:?}, {})", log.data.topics(), log.data.data) + .expect("fail printing to stdout"); + } + } + } + } + + fn push_msg(&mut self, msg: &str) { + match self { + Self::Capture { logs } => logs.push(new_console_log(msg)), + Self::LiveLogs => sh_println!("{msg}").expect("fail printing to stdout"), + } + } } -impl Inspector for LogCollector -where - CTX: ContextTr, -{ - fn log(&mut self, _interp: &mut Interpreter, _context: &mut CTX, log: Log) { - self.logs.push(log); +impl Inspector for LogCollector { + fn log(&mut self, _context: &mut CTX, log: Log) { + self.push_raw_log(log); } fn call(&mut self, context: &mut CTX, inputs: &mut CallInputs) -> Option { - if inputs.target_address == HARDHAT_CONSOLE_ADDRESS { + if inputs.transfer_to() == HARDHAT_CONSOLE_ADDRESS { return self.do_hardhat_log(context, inputs); } None @@ -62,17 +96,10 @@ where impl InspectorExt for LogCollector { fn console_log(&mut self, msg: &str) { - self.logs.push(new_console_log(msg)); + self.push_msg(msg); } } -/// Converts a Hardhat `console.log` call to a DSTest `log(string)` event. -fn hh_to_ds(call: &console::hh::ConsoleCalls) -> Log { - // Convert the parameters of the call to their string representation using `ConsoleFmt`. - let msg = call.fmt(Default::default()); - new_console_log(&msg) -} - /// Creates a `console.log(string)` event. fn new_console_log(msg: &str) -> Log { Log::new_unchecked( diff --git a/crates/evm/evm/src/inspectors/mod.rs b/crates/evm/evm/src/inspectors/mod.rs index 31ff3f2603605..63a2a00aa724c 100644 --- a/crates/evm/evm/src/inspectors/mod.rs +++ b/crates/evm/evm/src/inspectors/mod.rs @@ -22,5 +22,15 @@ pub use script::ScriptExecutionInspector; mod stack; pub use stack::{InspectorData, InspectorStack, InspectorStackBuilder}; +mod edge_cov; +pub(crate) use edge_cov::MAX_EDGE_COUNT; +pub use edge_cov::{ + CmpOperands, EdgeCovConfig, EdgeCovHit, EdgeCovInspector, EdgeCovKind, EdgeCoverage, + EdgeIndexMap, EdgeKey, +}; + mod revert_diagnostic; pub use revert_diagnostic::RevertDiagnostic; + +mod tempo_labels; +pub(crate) use tempo_labels::TempoLabels; diff --git a/crates/evm/evm/src/inspectors/revert_diagnostic.rs b/crates/evm/evm/src/inspectors/revert_diagnostic.rs index 79c9b1754d511..a312026f7f083 100644 --- a/crates/evm/evm/src/inspectors/revert_diagnostic.rs +++ b/crates/evm/evm/src/inspectors/revert_diagnostic.rs @@ -1,49 +1,15 @@ -use alloy_primitives::{Address, U256}; -use alloy_sol_types::SolValue; -use foundry_evm_core::{ - backend::DatabaseError, - constants::{CHEATCODE_ADDRESS, HARDHAT_CONSOLE_ADDRESS}, -}; +use alloy_primitives::{Address, map::HashMap}; +use foundry_evm_core::constants::{CHEATCODE_ADDRESS, HARDHAT_CONSOLE_ADDRESS}; +use foundry_evm_traces::RevertDiagnostic as DetailedRevertReason; use revm::{ - Database, Inspector, + Inspector, bytecode::opcode, context::{ContextTr, JournalTr}, - inspector::JournalExt, - interpreter::{ - CallInputs, CallOutcome, CallScheme, InstructionResult, Interpreter, InterpreterAction, - interpreter::EthInterpreter, - interpreter_types::{Jumps, LoopControl}, - }, + interpreter::{CallInputs, CallOutcome, CallScheme, Interpreter, interpreter_types::Jumps}, }; -use std::fmt; const IGNORE: [Address; 2] = [HARDHAT_CONSOLE_ADDRESS, CHEATCODE_ADDRESS]; -/// Checks if the call scheme corresponds to any sort of delegate call -pub fn is_delegatecall(scheme: CallScheme) -> bool { - matches!(scheme, CallScheme::DelegateCall | CallScheme::CallCode) -} - -#[derive(Debug, Clone, Copy)] -pub enum DetailedRevertReason { - CallToNonContract(Address), - DelegateCallToNonContract(Address), -} - -impl fmt::Display for DetailedRevertReason { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::CallToNonContract(addr) => { - write!(f, "call to non-contract address {addr}") - } - Self::DelegateCallToNonContract(addr) => write!( - f, - "delegatecall to non-contract address {addr} (usually an unliked library)" - ), - } - } -} - /// An inspector that tracks call context to enhances revert diagnostics. /// Useful for understanding reverts that are not linked to custom errors or revert strings. /// @@ -63,22 +29,22 @@ impl fmt::Display for DetailedRevertReason { #[derive(Clone, Debug, Default)] pub struct RevertDiagnostic { /// Tracks calls with calldata that target an address without executable code. - pub non_contract_call: Option<(Address, CallScheme, usize)>, + non_contract_call: Option<(Address, CallScheme, usize)>, /// Tracks EXTCODESIZE checks that target an address without executable code. - pub non_contract_size_check: Option<(Address, usize)>, + non_contract_size_check: Option<(Address, usize)>, /// Whether the step opcode is EXTCODESIZE or not. - pub is_extcodesize_step: bool, + is_extcodesize_step: bool, + /// Diagnostic detected for the currently executing frame. + pending: Option, + /// Trace nodes for active frames. `None` means the tracer did not create a node. + active_trace_nodes: Vec>, + /// Presentation-only revert diagnostics keyed by trace node. + diagnostics: HashMap, } impl RevertDiagnostic { - /// Returns the effective target address whose code would be executed. - /// For delegate calls, this is the `bytecode_address`. Otherwise, it's the `target_address`. - fn code_target_address(&self, inputs: &mut CallInputs) -> Address { - if is_delegatecall(inputs.scheme) { inputs.bytecode_address } else { inputs.target_address } - } - /// Derives the revert reason based on the cached data. Should only be called after a revert. - fn reason(&self) -> Option { + const fn reason(&self) -> Option { if let Some((addr, scheme, _)) = self.non_contract_call { let reason = if is_delegatecall(scheme) { DetailedRevertReason::DelegateCallToNonContract(addr) @@ -97,29 +63,43 @@ impl RevertDiagnostic { None } - /// Injects the revert diagnostic into the debug traces. Should only be called after a revert. - fn broadcast_diagnostic(&self, interpreter: &mut Interpreter) { - if let Some(reason) = self.reason() { - interpreter.bytecode.set_action(InterpreterAction::new_return( - InstructionResult::Revert, - reason.to_string().abi_encode().into(), - interpreter.gas, - )); + /// Starts tracking a frame before its inspectors can short-circuit execution. + pub fn frame_start(&mut self) { + self.active_trace_nodes.push(None); + } + + /// Associates the active frame with the node created by the tracer. + pub fn set_trace_node(&mut self, trace_node: usize) { + let frame = self.active_trace_nodes.last_mut(); + debug_assert!(frame.is_some(), "missing active revert diagnostic frame"); + if let Some(frame) = frame { + *frame = Some(trace_node); } } - /// When a `REVERT` opcode with zero data size occurs: - /// - if `non_contract_call` was set at the current depth, `broadcast_diagnostic` is called. - /// Otherwise, it is cleared. - /// - if `non_contract_size_check` was set at the current depth, `broadcast_diagnostic` is - /// called. Otherwise, it is cleared. + /// Finishes tracking a frame and associates any pending diagnostic with its trace node. + pub fn frame_end(&mut self) { + let frame = self.active_trace_nodes.pop(); + debug_assert!(frame.is_some(), "revert diagnostic frame stack underflow"); + let diagnostic = self.pending.take(); + if let Some(node_idx) = frame.flatten() + && let Some(diagnostic) = diagnostic + { + self.diagnostics.insert(node_idx, diagnostic); + } + } + + /// Consumes the inspector and returns its presentation-only diagnostics. + pub fn into_diagnostics(self) -> HashMap { + debug_assert!(self.active_trace_nodes.is_empty(), "unclosed revert diagnostic frames"); + self.diagnostics + } + + /// When a `REVERT` opcode with zero data size occurs, records a diagnostic if a matching + /// non-contract call or size check was observed at the current depth. Stale observations from + /// other depths are cleared. #[cold] - fn handle_revert(&mut self, interp: &mut Interpreter, ctx: &mut CTX) - where - D: Database, - CTX: ContextTr, - CTX::Journal: JournalExt, - { + fn handle_revert(&mut self, interp: &mut Interpreter, ctx: &mut CTX) { // REVERT (offset, size) if let Ok(size) = interp.stack.peek(1) && size.is_zero() @@ -127,7 +107,7 @@ impl RevertDiagnostic { // Check empty revert with same depth as a non-contract call if let Some((_, _, depth)) = self.non_contract_call { if ctx.journal_ref().depth() == depth { - self.broadcast_diagnostic(interp); + self.pending = self.reason(); } else { self.non_contract_call = None; } @@ -137,7 +117,7 @@ impl RevertDiagnostic { // Check empty revert with same depth as a non-contract size check if let Some((_, depth)) = self.non_contract_size_check { if depth == ctx.journal_ref().depth() { - self.broadcast_diagnostic(interp); + self.pending = self.reason(); } else { self.non_contract_size_check = None; } @@ -149,12 +129,7 @@ impl RevertDiagnostic { /// - Optimistically caches the target address and current depth in `non_contract_size_check`, /// pending later validation. #[cold] - fn handle_extcodesize(&mut self, interp: &mut Interpreter, ctx: &mut CTX) - where - D: Database, - CTX: ContextTr, - CTX::Journal: JournalExt, - { + fn handle_extcodesize(&mut self, interp: &mut Interpreter, ctx: &mut CTX) { // EXTCODESIZE (address) if let Ok(word) = interp.stack.peek(0) { let addr = Address::from_word(word.into()); @@ -173,7 +148,7 @@ impl RevertDiagnostic { #[cold] fn handle_extcodesize_output(&mut self, interp: &mut Interpreter) { if let Ok(size) = interp.stack.peek(0) - && size != U256::ZERO + && !size.is_zero() { self.non_contract_size_check = None; } @@ -182,16 +157,20 @@ impl RevertDiagnostic { } } -impl Inspector for RevertDiagnostic -where - D: Database, - CTX: ContextTr, - CTX::Journal: JournalExt, -{ +impl Inspector for RevertDiagnostic { /// Tracks the first call with non-zero calldata that targets a non-contract address. Excludes /// precompiles and test addresses. fn call(&mut self, ctx: &mut CTX, inputs: &mut CallInputs) -> Option { - let target = self.code_target_address(inputs); + if inputs.input.is_empty() { + return None; + } + + // Delegate calls execute the callee's code in the caller's storage context. + let target = if is_delegatecall(inputs.scheme) { + inputs.bytecode_address + } else { + inputs.transfer_to() + }; if IGNORE.contains(&target) || ctx.journal_ref().precompile_addresses().contains(&target) { return None; @@ -199,7 +178,6 @@ where if let Ok(state) = ctx.journal_mut().code(target) && state.is_empty() - && !inputs.input.is_empty() { self.non_contract_call = Some((target, inputs.scheme, ctx.journal_ref().depth())); } @@ -208,11 +186,6 @@ where /// Handles `REVERT` and `EXTCODESIZE` opcodes for diagnostics. fn step(&mut self, interp: &mut Interpreter, ctx: &mut CTX) { - // Check if an action has already been set (which would null the instruction pointer) - if interp.bytecode.action.is_some() { - return; - } - match interp.bytecode.opcode() { opcode::REVERT => self.handle_revert(interp, ctx), opcode::EXTCODESIZE => self.handle_extcodesize(interp, ctx), @@ -226,3 +199,8 @@ where } } } + +/// Checks if the call scheme corresponds to any sort of delegate call +pub const fn is_delegatecall(scheme: CallScheme) -> bool { + matches!(scheme, CallScheme::DelegateCall | CallScheme::CallCode) +} diff --git a/crates/evm/evm/src/inspectors/script.rs b/crates/evm/evm/src/inspectors/script.rs index a7703fa32cd1d..7e9ad84d2ffb3 100644 --- a/crates/evm/evm/src/inspectors/script.rs +++ b/crates/evm/evm/src/inspectors/script.rs @@ -1,14 +1,9 @@ -use alloy_evm::Database; use alloy_primitives::{Address, Bytes}; -use foundry_evm_core::backend::DatabaseError; use revm::{ Inspector, bytecode::opcode::ADDRESS, - context::ContextTr, - inspector::JournalExt, interpreter::{ InstructionResult, Interpreter, InterpreterAction, - interpreter::EthInterpreter, interpreter_types::{Jumps, LoopControl}, }, }; @@ -22,13 +17,7 @@ pub struct ScriptExecutionInspector { pub script_address: Address, } -impl Inspector for ScriptExecutionInspector -where - D: Database, - CTX: ContextTr, - CTX::Journal: JournalExt, -{ - #[inline] +impl Inspector for ScriptExecutionInspector { fn step(&mut self, interpreter: &mut Interpreter, _ecx: &mut CTX) { // Check if both target and bytecode address are the same as script contract address // (allow calling external libraries when bytecode address is different). @@ -42,8 +31,5 @@ where interpreter.gas, )); } - // Note: We don't return anything here as step returns void. - // The original check returned InstructionResult::Continue, but that's the default - // behavior. } } diff --git a/crates/evm/evm/src/inspectors/stack.rs b/crates/evm/evm/src/inspectors/stack.rs index 4d4c39c7c360d..22fe0fc7fa11b 100644 --- a/crates/evm/evm/src/inspectors/stack.rs +++ b/crates/evm/evm/src/inspectors/stack.rs @@ -1,47 +1,69 @@ use super::{ - Cheatcodes, CheatsConfig, ChiselState, CustomPrintTracer, Fuzzer, LineCoverageCollector, - LogCollector, RevertDiagnostic, ScriptExecutionInspector, TracingInspector, + Cheatcodes, CheatsConfig, ChiselState, CmpOperands, CustomPrintTracer, EdgeCovConfig, + EdgeCovInspector, EdgeCoverage, Fuzzer, LineCoverageCollector, LogCollector, RevertDiagnostic, + ScriptExecutionInspector, TempoLabels, TracingInspector, }; -use alloy_evm::{Evm, eth::EthEvmContext}; use alloy_primitives::{ - Address, Bytes, Log, TxKind, U256, - map::{AddressHashMap, HashMap}, + Address, B256, Bytes, Log, TxKind, U256, keccak256, + map::{AddressHashMap, AddressHashSet, AddressMap}, }; -use foundry_cheatcodes::{CheatcodesExecutor, Wallets}; + +use foundry_cheatcodes::{CheatcodeAnalysis, CheatcodesExecutor, NestedEvmClosureFor, Wallets}; +use foundry_common::{compile::Analysis, sh_warn}; +use foundry_config::FuzzCorpusConfig; use foundry_evm_core::{ - ContextExt, Env, InspectorExt, - backend::{DatabaseExt, JournaledState}, - evm::new_evm_with_inspector, + FoundryBlock, FoundryTransaction, InspectorExt, + backend::{ContextUpdateFor, DatabaseError, DatabaseExt, JournaledState}, + constants::DEFAULT_CREATE2_DEPLOYER_CODEHASH, + env::FoundryContextExt, + evm::{ + BlockEnvFor, ChainFor, EthEvmNetwork, EvmEnvFor, EvmFactoryFor, FoundryContextFor, + FoundryEvmFactory, FoundryEvmNetwork, TxEnvFor, get_create2_factory_call_inputs, + merge_child_state, prepare_child_state, with_inherited_evm, + }, + precompiles::P256_VERIFY, }; use foundry_evm_coverage::HitMaps; -use foundry_evm_traces::{SparsedTraceArena, TraceMode}; +use foundry_evm_networks::{NetworkConfigs, arbitrum}; +use foundry_evm_traces::{SparsedTraceArena, TraceRequirements}; use revm::{ - Inspector, + Inspector, JournalEntry, context::{ - BlockEnv, - result::{ExecutionResult, Output}, + Block, Cfg, ContextTr, JournalTr, Transaction, TransactionType, + result::{EVMError, ExecutionResult, Output}, }, - context_interface::CreateScheme, + context_interface::{CreateScheme, journaled_state::JournalCheckpoint}, + handler::FrameResult, interpreter::{ - CallInputs, CallOutcome, CallScheme, CreateInputs, CreateOutcome, Gas, InstructionResult, - Interpreter, InterpreterResult, + CallInputs, CallOutcome, CallScheme, CreateInputs, CreateOutcome, FrameInput, Gas, + InstructionResult, Interpreter, InterpreterResult, + bytecode::opcode as op, + interpreter_types::{InputsTr, Jumps}, + return_ok, }, - state::{Account, AccountStatus}, + primitives::KECCAK_EMPTY, + state::Account, }; -use revm_inspectors::edge_cov::EdgeCovInspector; use std::{ ops::{Deref, DerefMut}, sync::Arc, }; -#[derive(Clone, Debug, Default)] +use crate::executors::{EarlyExit, EvmExecutionCancellation, calculate_initial_gas}; + +#[cfg(feature = "monad")] +use foundry_evm_core::evm::refresh_chain_journal; + +#[derive(Clone, Debug)] #[must_use = "builders do nothing unless you call `build` on them"] -pub struct InspectorStackBuilder { +pub struct InspectorStackBuilder { + /// Solar compiler instance, to grant syntactic and semantic analysis capabilities. + pub analysis: Option, /// The block environment. /// /// Used in the cheatcode handler to overwrite the block environment separately from the /// execution block environment. - pub block: Option, + pub block: Option, /// The gas price. /// /// Used in the cheatcode handler to overwrite the gas price separately from the gas price @@ -52,8 +74,11 @@ pub struct InspectorStackBuilder { /// The fuzzer inspector and its state, if it exists. pub fuzzer: Option, /// Whether to enable tracing and revert diagnostics. - pub trace_mode: TraceMode, + pub trace_requirements: TraceRequirements, /// Whether logs should be collected. + /// - None for no log collection. + /// - Some(true) for realtime console.log-ing. + /// - Some(false) for log collection. pub logs: Option, /// Whether line coverage info should be collected. pub line_coverage: Option, @@ -65,31 +90,67 @@ pub struct InspectorStackBuilder { /// In isolation mode all top-level calls are executed as a separate transaction in a separate /// EVM context, enabling more precise gas accounting and transaction state changes. pub enable_isolation: bool, - /// Whether to enable Odyssey features. - pub odyssey: bool, + /// Configuration retained for Celo precompile support. + // TODO(celo-execution-owner): Replace this residual with concrete Celo precompile + // configuration. This is independent of the Monad lifecycle migration. + pub networks: NetworkConfigs, + /// Concrete Tempo label inspector selected by the Tempo executor builder. + tempo_labels: Option>, + /// Explicitly resolved additional cheatcode addresses. + pub extra_cheatcode_addresses: &'static [Address], /// The wallets to set in the cheatcodes context. pub wallets: Option, /// The CREATE2 deployer address. pub create2_deployer: Address, } -impl InspectorStackBuilder { +impl Default for InspectorStackBuilder { + fn default() -> Self { + Self { + analysis: None, + block: None, + gas_price: None, + cheatcodes: None, + fuzzer: None, + trace_requirements: TraceRequirements::none(), + logs: None, + line_coverage: None, + print: None, + chisel_state: None, + enable_isolation: false, + networks: NetworkConfigs::default(), + tempo_labels: None, + extra_cheatcode_addresses: &[], + wallets: None, + create2_deployer: Default::default(), + } + } +} + +impl InspectorStackBuilder { /// Create a new inspector stack builder. #[inline] pub fn new() -> Self { Self::default() } + /// Set the solar compiler instance that grants syntactic and semantic analysis capabilities + #[inline] + pub fn set_analysis(mut self, analysis: Analysis) -> Self { + self.analysis = Some(analysis); + self + } + /// Set the block environment. #[inline] - pub fn block(mut self, block: BlockEnv) -> Self { + pub fn block(mut self, block: BLOCK) -> Self { self.block = Some(block); self } /// Set the gas price. #[inline] - pub fn gas_price(mut self, gas_price: u128) -> Self { + pub const fn gas_price(mut self, gas_price: u128) -> Self { self.gas_price = Some(gas_price); self } @@ -117,86 +178,109 @@ impl InspectorStackBuilder { /// Set the Chisel inspector. #[inline] - pub fn chisel_state(mut self, final_pc: usize) -> Self { + pub const fn chisel_state(mut self, final_pc: usize) -> Self { self.chisel_state = Some(final_pc); self } - /// Set whether to collect logs. + /// Set the log collector, and whether to print the logs directly to stdout. #[inline] - pub fn logs(mut self, yes: bool) -> Self { - self.logs = Some(yes); + pub const fn logs(mut self, live_logs: bool) -> Self { + self.logs = Some(live_logs); self } /// Set whether to collect line coverage information. #[inline] - pub fn line_coverage(mut self, yes: bool) -> Self { + pub const fn line_coverage(mut self, yes: bool) -> Self { self.line_coverage = Some(yes); self } /// Set whether to enable the trace printer. #[inline] - pub fn print(mut self, yes: bool) -> Self { + pub const fn print(mut self, yes: bool) -> Self { self.print = Some(yes); self } - /// Set whether to enable the tracer. - /// Revert diagnostic inspector is activated when `mode != TraceMode::None` + /// Set trace data requirements. #[inline] - pub fn trace_mode(mut self, mode: TraceMode) -> Self { - if self.trace_mode < mode { - self.trace_mode = mode - } + pub const fn trace_requirements(mut self, requirements: TraceRequirements) -> Self { + self.trace_requirements = self.trace_requirements.merge(requirements); self } /// Set whether to enable the call isolation. /// For description of call isolation, see [`InspectorStack::enable_isolation`]. #[inline] - pub fn enable_isolation(mut self, yes: bool) -> Self { + pub const fn enable_isolation(mut self, yes: bool) -> Self { self.enable_isolation = yes; self } - /// Set whether to enable Odyssey features. - /// For description of call isolation, see [`InspectorStack::enable_isolation`]. + /// Sets networks when building an inspector stack directly. + /// + /// [`ExecutorBuilder::build`](crate::executors::ExecutorBuilder::build) overrides this with its + /// explicit network configuration so the executor, backend, and inspector remain in sync. + #[inline] + pub const fn networks(mut self, networks: NetworkConfigs) -> Self { + self.networks = networks; + self + } + + /// Installs the Tempo label inspector. + #[inline] + pub(crate) fn tempo_labels(mut self, inspector: TempoLabels) -> Self { + self.tempo_labels = Some(Box::new(inspector)); + self + } + + /// Sets explicitly resolved additional cheatcode addresses. #[inline] - pub fn odyssey(mut self, yes: bool) -> Self { - self.odyssey = yes; + pub const fn extra_cheatcode_addresses(mut self, addresses: &'static [Address]) -> Self { + self.extra_cheatcode_addresses = addresses; self } #[inline] - pub fn create2_deployer(mut self, create2_deployer: Address) -> Self { + pub const fn create2_deployer(mut self, create2_deployer: Address) -> Self { self.create2_deployer = create2_deployer; self } /// Builds the stack of inspectors to use when transacting/committing on the EVM. - pub fn build(self) -> InspectorStack { + pub fn build>>( + self, + ) -> InspectorStack { let Self { + analysis, block, gas_price, cheatcodes, fuzzer, - trace_mode, + trace_requirements, logs, line_coverage, print, chisel_state, enable_isolation, - odyssey, + networks, + tempo_labels, + extra_cheatcode_addresses, wallets, create2_deployer, } = self; let mut stack = InspectorStack::new(); - // inspectors if let Some(config) = cheatcodes { let mut cheatcodes = Cheatcodes::new(config); + cheatcodes.set_extra_cheatcode_addresses(extra_cheatcode_addresses); + // Set analysis capabilities if they are provided + if let Some(analysis) = analysis { + stack.set_analysis(analysis.clone()); + cheatcodes.set_analysis(CheatcodeAnalysis::new(analysis)); + } // Set wallets if they are provided if let Some(wallets) = wallets { cheatcodes.set_wallets(wallets); @@ -211,18 +295,19 @@ impl InspectorStackBuilder { stack.set_chisel(chisel_state); } stack.collect_line_coverage(line_coverage.unwrap_or(false)); - stack.collect_edge_coverage(true); - stack.collect_logs(logs.unwrap_or(true)); + stack.collect_logs(logs); stack.print(print.unwrap_or(false)); - stack.tracing(trace_mode); + stack.tracing_requirements(trace_requirements); stack.enable_isolation(enable_isolation); - stack.odyssey(odyssey); + stack.networks(networks); + stack.inner.tempo_labels = tempo_labels; + stack.set_extra_cheatcode_addresses(extra_cheatcode_addresses); stack.set_create2_deployer(create2_deployer); // environment, must come after all of the inspectors if let Some(block) = block { - stack.set_block(&block); + stack.set_block(block); } if let Some(gas_price) = gas_price { stack.set_gas_price(gas_price); @@ -236,17 +321,19 @@ impl InspectorStackBuilder { /// dispatch. #[macro_export] macro_rules! call_inspectors { - ([$($inspector:expr),+ $(,)?], |$id:ident $(,)?| $call:expr $(,)?) => { + ([$($inspector:expr),+ $(,)?], |$id:ident $(,)?| $body:expr $(,)?) => { $( if let Some($id) = $inspector { - ({ #[inline(always)] #[cold] || $call })(); + $crate::utils::cold_path(); + $body; } )+ }; - (#[ret] [$($inspector:expr),+ $(,)?], |$id:ident $(,)?| $call:expr $(,)?) => {{ + (#[ret] [$($inspector:expr),+ $(,)?], |$id:ident $(,)?| $body:expr $(,)?) => {{ $( if let Some($id) = $inspector { - if let Some(result) = ({ #[inline(always)] #[cold] || $call })() { + $crate::utils::cold_path(); + if let Some(result) = $body { return result; } } @@ -255,14 +342,15 @@ macro_rules! call_inspectors { } /// The collected results of [`InspectorStack`]. -pub struct InspectorData { +pub struct InspectorData { pub logs: Vec, pub labels: AddressHashMap, pub traces: Option, pub line_coverage: Option, - pub edge_coverage: Option>, - pub cheatcodes: Option, - pub chisel_state: Option<(Vec, Vec, Option)>, + pub edge_coverage: Option, + pub evm_cmp_values: Option>, + pub cheatcodes: Option>>, + pub chisel_state: Option<(Vec, Vec)>, pub reverter: Option
, } @@ -275,6 +363,28 @@ pub struct InspectorData { pub struct InnerContextData { /// Origin of the transaction in the outer EVM context. original_origin: Address, + /// Accounts that were created locally before entering the nested EVM context. + locally_created_accounts: AddressHashSet, + /// Depth of the isolated root frame in the surrounding trace. + root_depth: usize, +} + +/// Gas accounting carried across an isolated frame's synthetic transaction boundary. +struct IsolatedGas { + /// Regular execution gas available to the isolated frame. + regular_limit: u64, + /// State gas reservoir available to the isolated frame. + reservoir: u64, + /// State gas already charged by the outer opcode. + precharged_state: Option, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum OpcodeStepDispatch { + #[default] + None, + FuzzerOnly, + General, } /// An inspector that calls multiple inspectors in sequence. @@ -287,57 +397,259 @@ pub struct InnerContextData { /// us ability to create and execute separate EVM frames from inside cheatcodes while still having /// access to entire stack of inspectors and correctly handling traces, logs, debugging info /// collection, etc. -#[derive(Clone, Debug, Default)] -pub struct InspectorStack { - pub cheatcodes: Option, +#[derive(Clone, Debug)] +pub struct InspectorStack { + #[allow(clippy::type_complexity)] + pub cheatcodes: Option>>, pub inner: InspectorStackInner, } -/// All used inpectors besides [Cheatcodes]. +#[cfg(test)] +#[derive(Clone, Debug)] +struct EarlyExitTestGate { + entered: std::sync::mpsc::Sender<()>, + release: Arc>>, + target_pc: usize, + notified: Arc, +} + +#[derive(Clone, Copy, Debug)] +struct PendingCreate2Redirect { + depth: usize, + charged_create_state_gas: bool, +} + +#[derive(Clone, Copy, Debug)] +struct PendingCallTrace { + trace_idx: usize, + executed_address: Option
, +} + +#[derive(Clone, Copy, Debug)] +struct IsolatedFrameCheckpoint { + checkpoint: JournalCheckpoint, + restore_len: usize, + return_depth: usize, + is_create: bool, + failed: bool, +} + +/// All used inspectors besides [Cheatcodes]. /// /// See [`InspectorStack`]. #[derive(Default, Clone, Debug)] pub struct InspectorStackInner { - pub chisel_state: Option, - pub line_coverage: Option, - pub edge_coverage: Option, - pub fuzzer: Option, - pub log_collector: Option, - pub printer: Option, - pub tracer: Option, - pub script_execution_inspector: Option, + /// Solar compiler instance, to grant syntactic and semantic analysis capabilities. + pub analysis: Option, + + // Inspectors. + // These are boxed to reduce the size of the struct and slightly improve performance of the + // `if let Some` checks. + pub chisel_state: Option>, + pub edge_coverage: Option>, + pub fuzzer: Option>, + pub line_coverage: Option>, + pub log_collector: Option>, + pub printer: Option>, + pub revert_diag: Option>, + pub script_execution_inspector: Option>, + pub tempo_labels: Option>, + pub tracer: Option>, + + // FoundryInspectorExt and other internal data. + /// Whether to collect sancov edge coverage from instrumented native crates. + pub sancov_edges: bool, + /// Whether to capture sancov trace-cmp operands for dictionary injection. + pub sancov_trace_cmp: bool, pub enable_isolation: bool, - pub odyssey: bool, + pub networks: NetworkConfigs, + /// Additional addresses installed and recognized as cheatcode contracts. + pub extra_cheatcode_addresses: &'static [Address], pub create2_deployer: Address, - pub revert_diag: Option, - /// Flag marking if we are in the inner EVM context. pub in_inner_context: bool, pub inner_context_data: Option, - pub top_frame_journal: HashMap, + /// Accounts that should retain the per-transaction creation marker in the current context. + pub locally_created_accounts: AddressHashSet, + pub top_frame_journal: AddressMap, + /// Whether the top-level frame failed before inspector result rewriting. + top_level_frame_failed_before_rewrite: bool, + /// Fee policy to restore when an isolated call explicitly executes another transaction. + outer_disable_fee_charge: Option, + /// Whether the root call of the active isolated transaction executed as a precompile. + isolated_call_was_precompile: Option, + /// Synthetic CREATE depth corresponding to an outer top-level deployment. + synthetic_create_depth: Option, /// Address that reverted the call, if any. pub reverter: Option
, + /// LIFO stack tracking CREATE2 frames that were redirected to the CREATE2 factory. + pending_create2_redirects: Vec, + /// LIFO stack tracking the effective address of traced calls delegated to the EVM provider. + pending_call_traces: Vec, + /// LIFO stack used to unwind snapshot restoration across reverted frames of the tracked + /// transaction: an isolated call, or the top-level transaction when isolation is disabled. + isolated_frame_checkpoints: Vec, + /// Pending CREATE2 deployer validation error, deferred from `frame_start` to `create` so + /// it goes through the normal inspector lifecycle (tracing, etc.). + pub pending_create2_error: Option, + /// Counter for CREATE2 salt in `--batch` CREATE rewrites. + pub batch_create_counter: u64, + /// Whether the one-shot `--batch` rewrite warning has already been emitted. + pub batch_rewrite_warned: bool, + /// Per-inspector random seed mixed into `--batch` CREATE2 salts, ensuring re-runs + /// at identical on-chain state still produce distinct salts. Lazily initialized. + pub batch_rewrite_process_salt: Option, + /// Shared cancellation state for interruptible EVM execution. + execution_cancellation: Option, + /// Amortizes deadline checks in the opcode hot path. + cancellation_poll_counter: u8, + /// Whether this inspector halted the current execution due to cancellation. + execution_cancelled: bool, + #[cfg(test)] + early_exit_test_gate: Option, + static_step_dispatch: OpcodeStepDispatch, + has_static_step_end_inspectors: bool, } /// Struct keeping mutable references to both parts of [InspectorStack] and implementing -/// [revm::Inspector]. This struct can be obtained via [InspectorStack::as_mut] or via -/// [CheatcodesExecutor::get_inspector] method implemented for [InspectorStackInner]. -pub struct InspectorStackRefMut<'a> { - pub cheatcodes: Option<&'a mut Cheatcodes>, +/// [revm::Inspector]. This struct can be obtained via [InspectorStack::as_mut]. +pub struct InspectorStackRefMut<'a, FEN: FoundryEvmNetwork = EthEvmNetwork> { + pub cheatcodes: Option<&'a mut Cheatcodes>, pub inner: &'a mut InspectorStackInner, } -impl CheatcodesExecutor for InspectorStackInner { - fn get_inspector<'a>(&'a mut self, cheats: &'a mut Cheatcodes) -> Box { - Box::new(InspectorStackRefMut { cheatcodes: Some(cheats), inner: self }) +impl CheatcodesExecutor for InspectorStackInner { + fn with_nested_evm( + &mut self, + cheats: &mut Cheatcodes, + ecx: &mut FoundryContextFor<'_, FEN>, + f: NestedEvmClosureFor<'_, FEN>, + ) -> Result<(), EVMError> { + let previous = self.synthetic_create_depth; + let create2_redirects = self.pending_create2_redirects.len(); + let snapshot_checkpoints = self.isolated_frame_checkpoints.len(); + let snapshot_restores = cheats.isolated_snapshot_restores.len(); + let pending_snapshot_journal = cheats + .track_isolated_snapshots + .then(|| cheats.pending_isolated_snapshot_journal.clone()); + let capture_snapshot_restore = cheats.capture_isolated_snapshot_restore; + self.synthetic_create_depth = (ecx.journal().depth() == 1).then_some(2); + let mut inspector = InspectorStackRefMut { cheatcodes: Some(cheats), inner: self }; + let result = with_inherited_evm::(ecx, &mut inspector, f); + self.synthetic_create_depth = previous; + // Drop redirects left behind if nested execution aborted before their frames ended. + self.pending_create2_redirects.truncate(create2_redirects); + if result.is_err() + && let Some(pending_snapshot_journal) = pending_snapshot_journal + { + self.isolated_frame_checkpoints.truncate(snapshot_checkpoints); + cheats.isolated_snapshot_restores.truncate(snapshot_restores); + cheats.pending_isolated_snapshot_journal = pending_snapshot_journal; + cheats.capture_isolated_snapshot_restore = capture_snapshot_restore; + } + result } - fn tracing_inspector(&mut self) -> Option<&mut Option> { - Some(&mut self.tracer) + fn with_fresh_nested_evm( + &mut self, + cheats: &mut Cheatcodes, + ecx: &mut FoundryContextFor<'_, FEN>, + chain_context: ChainFor, + f: NestedEvmClosureFor<'_, FEN>, + ) -> Result, EVMError> { + self.with_snapshot_tracking(cheats, |inner, cheats| { + let depth = ecx.journal().depth(); + let previous_inner_context = inner.in_inner_context; + let previous_inner_context_data = inner.inner_context_data.replace(InnerContextData { + original_origin: ecx.tx().caller(), + locally_created_accounts: AddressHashSet::default(), + root_depth: depth, + }); + let previous_precompile = inner.isolated_call_was_precompile.take(); + inner.in_inner_context = true; + let mut evm_env = ecx.evm_clone(); + let (db, _) = ecx.db_journal_inner_mut(); + let inherited_disable_fee_charge = evm_env.cfg_env.disable_fee_charge; + if let Some(disable_fee_charge) = inner.outer_disable_fee_charge { + evm_env.cfg_env.disable_fee_charge = disable_fee_charge; + } + let mut inspector = InspectorStackRefMut { cheatcodes: Some(cheats), inner }; + let mut evm = FEN::EvmFactory::default().create_nested_evm_with_inspector( + db, + evm_env, + &mut inspector, + ); + *evm.chain_mut() = chain_context; + // The fresh root duplicates the suspended frame; its children share the existing + // tracer. + evm.journal_inner_mut().depth = depth; + let result = f(&mut *evm).map(|()| { + let mut evm_env = evm.to_evm_env(); + evm_env.cfg_env.disable_fee_charge = inherited_disable_fee_charge; + evm_env + }); + drop(evm); + inner.in_inner_context = previous_inner_context; + inner.inner_context_data = previous_inner_context_data; + inner.isolated_call_was_precompile = previous_precompile; + result + }) + } + + fn transact_on_db( + &mut self, + cheats: &mut Cheatcodes, + ecx: &mut FoundryContextFor<'_, FEN>, + fork_id: Option, + transaction: B256, + ) -> eyre::Result>> { + self.with_snapshot_tracking(cheats, |inner, cheats| { + let mut evm_env = ecx.evm_clone(); + if let Some(disable_fee_charge) = inner.outer_disable_fee_charge { + evm_env.cfg_env.disable_fee_charge = disable_fee_charge; + } + let outer_tx_env = ecx.tx_clone(); + let mut inspector = InspectorStackRefMut { cheatcodes: Some(cheats), inner }; + let (db, inner) = ecx.db_journal_inner_mut(); + db.transact(fork_id, transaction, evm_env, &outer_tx_env, inner, &mut inspector) + }) + } + + fn transact_from_tx_on_db( + &mut self, + cheats: &mut Cheatcodes, + ecx: &mut FoundryContextFor<'_, FEN>, + tx_env: TxEnvFor, + ) -> eyre::Result<()> { + self.with_snapshot_tracking(cheats, |inner, cheats| { + let mut evm_env = ecx.evm_clone(); + if let Some(disable_fee_charge) = inner.outer_disable_fee_charge { + evm_env.cfg_env.disable_fee_charge = disable_fee_charge; + } + let mut inspector = InspectorStackRefMut { cheatcodes: Some(cheats), inner }; + let (db, inner) = ecx.db_journal_inner_mut(); + db.transact_from_tx(tx_env, evm_env, inner, &mut inspector) + }) + } + + fn console_log(&mut self, msg: &str) { + if let Some(ref mut collector) = self.log_collector { + InspectorExt::console_log(&mut **collector, msg); + } + } + + fn tracing_inspector(&mut self) -> Option<&mut TracingInspector> { + self.tracer.as_deref_mut() } } -impl InspectorStack { +impl Default for InspectorStack { + fn default() -> Self { + Self::new() + } +} + +impl InspectorStack { /// Creates a new inspector stack. /// /// Note that the stack is empty by default, and you must add inspectors to it. @@ -345,42 +657,53 @@ impl InspectorStack { /// with [`InspectorStack`]. #[inline] pub fn new() -> Self { - Self::default() + Self { cheatcodes: None, inner: InspectorStackInner::default() } } - /// Logs the status of the inspectors. - pub fn log_status(&self) { - trace!(enabled=%{ - let mut enabled = Vec::with_capacity(16); - macro_rules! push { - ($($id:ident),* $(,)?) => { - $( - if self.$id.is_some() { - enabled.push(stringify!($id)); - } - )* - }; - } - push!(cheatcodes, chisel_state, line_coverage, fuzzer, log_collector, printer, tracer); - if self.enable_isolation { - enabled.push("isolation"); - } - format!("[{}]", enabled.join(", ")) - }); + /// Set the solar compiler instance. + #[inline] + pub fn set_analysis(&mut self, analysis: Analysis) { + self.analysis = Some(analysis); + } + + /// Set the cancellation state checked during EVM execution. + #[inline] + pub(crate) fn set_early_exit(&mut self, early_exit: EarlyExit) { + self.set_execution_cancellation(EvmExecutionCancellation::early_exit(early_exit)); } - /// Set variables from an environment for the relevant inspectors. + /// Set the complete cancellation state checked during EVM execution. #[inline] - pub fn set_env(&mut self, env: &Env) { - self.set_block(&env.evm_env.block_env); - self.set_gas_price(env.tx.gas_price); + pub(crate) fn set_execution_cancellation(&mut self, cancellation: EvmExecutionCancellation) { + self.execution_cancellation = Some(cancellation); + } + + /// Returns whether this inspector halted execution due to cancellation. + #[inline] + pub(crate) const fn execution_cancelled(&self) -> bool { + self.inner.execution_cancelled + } + + #[cfg(test)] + pub(crate) fn set_early_exit_test_gate( + &mut self, + entered: std::sync::mpsc::Sender<()>, + release: std::sync::mpsc::Receiver<()>, + target_pc: usize, + ) { + self.early_exit_test_gate = Some(EarlyExitTestGate { + entered, + release: Arc::new(std::sync::Mutex::new(release)), + target_pc, + notified: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }); } /// Sets the block for the relevant inspectors. #[inline] - pub fn set_block(&mut self, block: &BlockEnv) { + pub fn set_block(&mut self, block: BlockEnvFor) { if let Some(cheatcodes) = &mut self.cheatcodes { - cheatcodes.block = Some(block.clone()); + cheatcodes.block = Some(block); } } @@ -394,44 +717,103 @@ impl InspectorStack { /// Set the cheatcodes inspector. #[inline] - pub fn set_cheatcodes(&mut self, cheatcodes: Cheatcodes) { - self.cheatcodes = Some(cheatcodes); + pub fn set_cheatcodes(&mut self, cheatcodes: Cheatcodes) { + self.cheatcodes = Some(cheatcodes.into()); } /// Set the fuzzer inspector. #[inline] pub fn set_fuzzer(&mut self, fuzzer: Fuzzer) { - self.fuzzer = Some(fuzzer); + self.fuzzer = Some(fuzzer.into()); + self.refresh_static_step_dispatch(); } /// Set the Chisel inspector. #[inline] pub fn set_chisel(&mut self, final_pc: usize) { - self.chisel_state = Some(ChiselState::new(final_pc)); + self.chisel_state = Some(ChiselState::new(final_pc).into()); + self.refresh_static_step_end_dispatch(); } /// Set whether to enable the line coverage collector. #[inline] pub fn collect_line_coverage(&mut self, yes: bool) { self.line_coverage = yes.then(Default::default); + self.refresh_static_step_dispatch(); } - /// Set whether to enable the edge coverage collector. + /// Set whether to enable the edge coverage collector with default config. #[inline] pub fn collect_edge_coverage(&mut self, yes: bool) { - self.edge_coverage = yes.then(EdgeCovInspector::new); // TODO configurable edge size? + self.edge_coverage = yes.then(|| EdgeCovInspector::default().into()); + self.refresh_static_step_dispatch(); } - /// Set whether to enable call isolation. + /// Enable edge coverage with an explicit configuration. #[inline] - pub fn enable_isolation(&mut self, yes: bool) { - self.enable_isolation = yes; + pub fn collect_edge_coverage_with_edge_config(&mut self, config: EdgeCovConfig) { + self.edge_coverage = Some(EdgeCovInspector::with_config(config).into()); + self.refresh_static_step_dispatch(); + } + + /// Configure the edge coverage collector from a [`FuzzCorpusConfig`]. + /// + /// Derives both the on/off gate and [`EdgeCovConfig`] from `corpus`. + #[inline] + pub fn collect_edge_coverage_with_config(&mut self, corpus: &FuzzCorpusConfig) { + self.edge_coverage = corpus + .collect_evm_edge_coverage() + .then(|| EdgeCovInspector::with_config(corpus.into()).into()); + self.refresh_static_step_dispatch(); + } + + /// Set whether to collect EVM comparison operands. + #[inline] + pub fn collect_evm_cmp_log(&mut self, yes: bool) { + if yes { + self.edge_coverage + .get_or_insert_with(|| EdgeCovInspector::with_cmp_log_only().into()) + .enable_cmp_log(true); + } else if let Some(edge_coverage) = &mut self.edge_coverage { + edge_coverage.enable_cmp_log(false); + } + self.refresh_static_step_dispatch(); + } + + /// Set whether to collect sancov edge coverage from instrumented native crates. + #[inline] + pub const fn collect_sancov_edges(&mut self, yes: bool) { + self.inner.sancov_edges = yes; + } + + /// Set whether to capture sancov trace-cmp operands for dictionary injection. + #[inline] + pub const fn collect_sancov_trace_cmp(&mut self, yes: bool) { + self.inner.sancov_trace_cmp = yes; } /// Set whether to enable call isolation. #[inline] - pub fn odyssey(&mut self, yes: bool) { - self.odyssey = yes; + pub const fn enable_isolation(&mut self, yes: bool) { + self.inner.enable_isolation = yes; + } + + /// Set networks with enabled features. + #[inline] + pub const fn networks(&mut self, networks: NetworkConfigs) { + self.inner.networks = networks; + } + + /// Returns additional addresses installed and recognized as cheatcode contracts. + #[inline] + pub const fn extra_cheatcode_addresses(&self) -> &'static [Address] { + self.inner.extra_cheatcode_addresses + } + + /// Sets additional addresses installed and recognized as cheatcode contracts. + #[inline] + pub const fn set_extra_cheatcode_addresses(&mut self, addresses: &'static [Address]) { + self.inner.extra_cheatcode_addresses = addresses; } /// Set the CREATE2 deployer address. @@ -441,32 +823,39 @@ impl InspectorStack { } /// Set whether to enable the log collector. + /// - None for no log collection. + /// - Some(true) for realtime console.log-ing. + /// - Some(false) for log collection. #[inline] - pub fn collect_logs(&mut self, yes: bool) { - self.log_collector = yes.then(Default::default); + pub fn collect_logs(&mut self, live_logs: Option) { + self.log_collector = live_logs.map(|live_logs| { + Box::new(if live_logs { + LogCollector::LiveLogs + } else { + LogCollector::Capture { logs: Vec::new() } + }) + }); } /// Set whether to enable the trace printer. #[inline] pub fn print(&mut self, yes: bool) { self.printer = yes.then(Default::default); + self.refresh_static_opcode_dispatch(); } - /// Set whether to enable the tracer. - /// Revert diagnostic inspector is activated when `mode != TraceMode::None` + /// Set trace data requirements. #[inline] - pub fn tracing(&mut self, mode: TraceMode) { - if mode.is_none() { - self.revert_diag = None; - } else { - self.revert_diag = Some(RevertDiagnostic::default()); - } + pub fn tracing_requirements(&mut self, requirements: TraceRequirements) { + let config = requirements.into_config(); + self.revert_diag = config.is_some().then(RevertDiagnostic::default).map(Into::into); - if let Some(config) = mode.into_config() { + if let Some(config) = config { *self.tracer.get_or_insert_with(Default::default).config_mut() = config; } else { self.tracer = None; } + self.refresh_static_opcode_dispatch(); } /// Set whether to enable script execution inspector. @@ -474,11 +863,19 @@ impl InspectorStack { pub fn script(&mut self, script_address: Address) { self.script_execution_inspector.get_or_insert_with(Default::default).script_address = script_address; + if let Some(cheatcodes) = &mut self.cheatcodes { + cheatcodes.script_address = Some(script_address); + } + self.refresh_static_step_dispatch(); + } + + #[inline(always)] + fn as_mut(&mut self) -> InspectorStackRefMut<'_, FEN> { + InspectorStackRefMut { cheatcodes: self.cheatcodes.as_deref_mut(), inner: &mut self.inner } } /// Collects all the data gathered during inspection into a single struct. - #[inline] - pub fn collect(self) -> InspectorData { + pub fn collect(self) -> InspectorData { let Self { mut cheatcodes, inner: @@ -487,12 +884,17 @@ impl InspectorStack { line_coverage, edge_coverage, log_collector, + tempo_labels, tracer, + revert_diag, reverter, .. }, } = self; + let trace_diagnostics = + revert_diag.map(|revert_diag| revert_diag.into_diagnostics()).unwrap_or_default(); + let traces = tracer.map(|tracer| tracer.into_traces()).map(|arena| { let ignored = cheatcodes .as_mut() @@ -508,293 +910,652 @@ impl InspectorStack { }) .unwrap_or_default(); - SparsedTraceArena { arena, ignored } + SparsedTraceArena { arena, ignored, diagnostics: trace_diagnostics } }); + let (edge_coverage, evm_cmp_values) = edge_coverage + .map(|edge_coverage| { + let (hitcount, cmp_values) = edge_coverage.into_parts(); + (Some(hitcount), (!cmp_values.is_empty()).then_some(cmp_values)) + }) + .unwrap_or_default(); + InspectorData { - logs: log_collector.map(|logs| logs.logs).unwrap_or_default(), - labels: cheatcodes - .as_ref() - .map(|cheatcodes| cheatcodes.labels.clone()) - .unwrap_or_default(), + logs: log_collector.and_then(|logs| logs.into_captured_logs()).unwrap_or_default(), + labels: { + let mut labels = cheatcodes.as_ref().map(|c| c.labels.clone()).unwrap_or_default(); + if let Some(tempo_labels) = tempo_labels { + labels.extend(tempo_labels.labels); + } + labels + }, traces, line_coverage: line_coverage.map(|line_coverage| line_coverage.finish()), - edge_coverage: edge_coverage.map(|edge_coverage| edge_coverage.into_hitcount()), + edge_coverage, + evm_cmp_values, cheatcodes, chisel_state: chisel_state.and_then(|state| state.state), reverter, } } +} - #[inline(always)] - fn as_mut(&mut self) -> InspectorStackRefMut<'_> { - InspectorStackRefMut { cheatcodes: self.cheatcodes.as_mut(), inner: &mut self.inner } +impl InspectorStackRefMut<'_, FEN> { + fn finish_create2_redirect(&mut self, depth: usize, frame_result: &mut FrameResult) { + let Some(redirect) = self + .inner + .pending_create2_redirects + .last() + .copied() + .filter(|redirect| redirect.depth == depth) + else { + return; + }; + self.inner.pending_create2_redirects.pop(); + + let FrameResult::Call(call) = frame_result else { + debug_assert!(false, "pending CREATE2 redirect ended with non-call result"); + return; + }; + + let address = match call.instruction_result() { + return_ok!() => Address::try_from(call.output().as_ref()) + .map_err(|_| { + call.result = InterpreterResult { + result: InstructionResult::Revert, + output: "invalid CREATE2 factory output".into(), + gas: Gas::new(call.result.gas.limit()), + }; + }) + .ok(), + _ => None, + }; + + *frame_result = FrameResult::Create(CreateOutcome { + result: call.result.clone(), + address, + charged_create_state_gas: redirect.charged_create_state_gas, + }); } -} -impl InspectorStackRefMut<'_> { /// Adjusts the EVM data for the inner EVM context. /// Should be called on the top-level call of inner context (depth == 0 && /// self.in_inner_context) Decreases sender nonce for CALLs to keep backwards compatibility /// Updates tx.origin to the value before entering inner context - fn adjust_evm_data_for_inner_context(&mut self, ecx: &mut EthEvmContext<&mut dyn DatabaseExt>) { + fn adjust_evm_data_for_inner_context(&mut self, ecx: &mut CTX) { let inner_context_data = self.inner_context_data.as_ref().expect("should be called in inner context"); - ecx.tx.caller = inner_context_data.original_origin; + ecx.tx_mut().set_caller(inner_context_data.original_origin); } fn do_call_end( &mut self, - ecx: &mut EthEvmContext<&mut dyn DatabaseExt>, + ecx: &mut FoundryContextFor<'_, FEN>, inputs: &CallInputs, outcome: &mut CallOutcome, - ) -> CallOutcome { + ) { + let storage_hook_active = + self.cheatcodes.as_deref().is_some_and(Cheatcodes::is_storage_hook_active); + if !storage_hook_active && let Some(fuzzer) = &mut self.fuzzer { + fuzzer.call_end(ecx, inputs, outcome); + } + let result = outcome.result.result; call_inspectors!( #[ret] - [ - &mut self.fuzzer, - &mut self.tracer, - &mut self.cheatcodes, - &mut self.printer, - &mut self.revert_diag - ], + [&mut self.tracer, &mut self.cheatcodes, &mut self.printer, &mut self.revert_diag], |inspector| { - let previous_outcome = outcome.clone(); + let previous_output = outcome.output().clone(); inspector.call_end(ecx, inputs, outcome); // If the inspector returns a different status or a revert with a non-empty message, // we assume it wants to tell us something let different = outcome.result.result != result || (outcome.result.result == InstructionResult::Revert - && outcome.output() != previous_outcome.output()); - different.then_some(outcome.clone()) + && outcome.output() != &previous_output); + different.then_some(()) }, ); // Record first address that reverted the call. if result.is_revert() && self.reverter.is_none() { - self.reverter = Some(inputs.target_address); + self.reverter = Some(inputs.transfer_to()); } - - outcome.clone() } fn do_create_end( &mut self, - ecx: &mut EthEvmContext<&mut dyn DatabaseExt>, + ecx: &mut FoundryContextFor<'_, FEN>, call: &CreateInputs, outcome: &mut CreateOutcome, - ) -> CreateOutcome { + ) { let result = outcome.result.result; call_inspectors!( #[ret] - [&mut self.tracer, &mut self.cheatcodes, &mut self.printer], + [&mut self.line_coverage, &mut self.tracer, &mut self.cheatcodes, &mut self.printer], |inspector| { - let previous_outcome = outcome.clone(); + let previous_output = outcome.output().clone(); inspector.create_end(ecx, call, outcome); // If the inspector returns a different status or a revert with a non-empty message, // we assume it wants to tell us something let different = outcome.result.result != result || (outcome.result.result == InstructionResult::Revert - && outcome.output() != previous_outcome.output()); - different.then_some(outcome.clone()) + && outcome.output() != &previous_output); + different.then_some(()) }, ); - - outcome.clone() } fn transact_inner( &mut self, - ecx: &mut EthEvmContext<&mut dyn DatabaseExt>, + ecx: &mut FoundryContextFor<'_, FEN>, kind: TxKind, caller: Address, input: Bytes, - gas_limit: u64, + gas: IsolatedGas, value: U256, - ) -> (InterpreterResult, Option
) { - let cached_env = Env::from(ecx.cfg.clone(), ecx.block.clone(), ecx.tx.clone()); - - ecx.block.basefee = 0; - ecx.tx.chain_id = Some(ecx.cfg.chain_id); - ecx.tx.caller = caller; - ecx.tx.kind = kind; - ecx.tx.data = input; - ecx.tx.value = value; - // Add 21000 to the gas limit to account for the base cost of transaction. - ecx.tx.gas_limit = gas_limit + 21000; + ) -> (InterpreterResult, Option
, bool) { + let IsolatedGas { regular_limit, reservoir, precharged_state } = gas; + let source_fork_id = ecx.db().active_fork_id(); + let cached_evm_env = ecx.evm_clone(); + let cached_tx_env = ecx.tx_clone(); + self.isolated_call_was_precompile = None; + + ecx.block_mut().set_basefee(0); + ecx.cfg_env_mut().disable_fee_charge = true; + + let chain_id = ecx.cfg().chain_id(); + ecx.tx_mut().set_chain_id(Some(chain_id)); + ecx.tx_mut().set_caller(caller); + ecx.tx_mut().set_kind(kind); + ecx.tx_mut().set_data(input); + ecx.tx_mut().set_enveloped_tx(Bytes::new()); + ecx.tx_mut().set_value(value); + let initial_gas = calculate_initial_gas(ecx.tx(), ecx.cfg()); + // Preserve the frame's regular gas and reservoir across the synthetic transaction + // boundary. The extra state gas offsets the account-creation charge already paid by the + // outer opcode. + let regular_gas_limit = regular_limit.saturating_add(initial_gas.initial_total_gas()); + ecx.cfg_env_mut().tx_gas_limit_cap = Some(regular_gas_limit); + // revm rejects transactions whose gas limit is below the EIP-7623 calldata floor. Raising + // the limit would give the frame more gas than it was forwarded, so skip that check and + // apply the floor to the transaction result instead. + let floor_gas = + (initial_gas.floor_gas() > regular_gas_limit).then_some(initial_gas.floor_gas()); + if floor_gas.is_some() { + ecx.cfg_env_mut().disable_eip7623 = true; + } + let mut tx_gas_limit = regular_gas_limit.saturating_add(reservoir); + if let Some(precharged_state) = precharged_state { + tx_gas_limit = tx_gas_limit.saturating_add(precharged_state); + } + ecx.tx_mut().set_gas_limit(tx_gas_limit); // If we haven't disabled gas limit checks, ensure that transaction gas limit will not // exceed block gas limit. - if !ecx.cfg.disable_block_gas_limit { - ecx.tx.gas_limit = std::cmp::min(ecx.tx.gas_limit, ecx.block.gas_limit); + if !ecx.cfg().is_block_gas_limit_disabled() { + let gas_limit = std::cmp::min(ecx.tx().gas_limit(), ecx.block().gas_limit()); + ecx.tx_mut().set_gas_limit(gas_limit); + } + ecx.tx_mut().set_gas_price(0); + // If the cached tx is EIP-4844 (e.g. set by `vm.blobhashes`), downgrade + // it to EIP-1559 and drop the blob hashes so revm doesn't reject the + // synthetic inner tx because `gas_price = 0` plus blob fields fail + // 4844 validation. The contract-visible `BLOBHASH` opcode is restored + // via `EnvOverrides`. Other types (incl. EIP-2930 access lists) are + // intentionally left intact, and the full original tx is restored + // from `cached_tx_env` after the inner call. + if ecx.tx().tx_type() == TransactionType::Eip4844 as u8 { + ecx.tx_mut().set_tx_type(TransactionType::Eip1559 as u8); + ecx.tx_mut().set_blob_hashes(Vec::new()); } - ecx.tx.gas_price = 0; - self.inner_context_data = Some(InnerContextData { original_origin: cached_env.tx.caller }); + let locally_created_accounts = ecx + .journal() + .evm_state() + .iter() + .filter_map(|(addr, acc)| acc.is_created_locally().then_some(*addr)) + .collect(); + let root_depth = ecx.journal().depth(); + self.inner_context_data = Some(InnerContextData { + original_origin: cached_tx_env.caller(), + locally_created_accounts, + root_depth, + }); + self.outer_disable_fee_charge = Some(cached_evm_env.cfg_env.disable_fee_charge); self.in_inner_context = true; - let res = self.with_stack(|inspector| { - let (db, journal, env) = ecx.as_db_env_and_journal(); - let mut evm = new_evm_with_inspector(db, env.to_owned(), inspector); - - evm.journaled_state.state = { - let mut state = journal.state.clone(); - - for (addr, acc_mut) in &mut state { - // mark all accounts cold, besides preloaded addresses - if !journal.warm_preloaded_addresses.contains(addr) { - acc_mut.mark_cold(); - } - - // mark all slots cold - for slot_mut in acc_mut.storage.values_mut() { - slot_mut.is_cold = true; - slot_mut.original_value = slot_mut.present_value; - } + // Tell cheatcodes we're entering the synthetic inner transaction so + // env-mutating cheatcodes route through `env_overrides` instead of + // fighting with the fee-accounting zeroing above. See `EnvOverrides`. + if let Some(cheats) = self.cheatcodes.as_deref_mut() { + cheats.pending_isolated_snapshot_journal = None; + cheats.track_isolated_snapshots = true; + cheats.isolated_snapshot_restores.clear(); + cheats.in_isolation_context = true; + } + self.inner.isolated_frame_checkpoints.clear(); + + let evm_env = ecx.evm_clone(); + let tx_env = ecx.tx_clone(); + let factory = FEN::EvmFactory::default(); + let chain_context = ecx.chain().clone(); + + let isolated_state = prepare_child_state(ecx.journal_inner()); + + #[cfg(feature = "monad")] + let state = foundry_evm_core::FoundryJournal::capture_reserve_balance(ecx.journal()); + #[cfg(feature = "monad")] + let mut reserve_balance = None; + let mut nested_chain_context = None; + let res = self.with_inspector(|mut inspector| { + let (res, nested_env) = { + let (db, _) = ecx.db_journal_inner_mut(); + let mut evm = factory.create_nested_evm_with_inspector(db, evm_env, &mut inspector); + *evm.chain_mut() = chain_context; + evm.journal_inner_mut().state = isolated_state; + #[cfg(feature = "monad")] + { + foundry_evm_core::FoundryJournal::restore_reserve_balance( + evm.journal_mut(), + state, + ); + foundry_evm_core::evm::refresh_nested_chain_journal(&mut *evm); + foundry_evm_core::FoundryJournal::set_preserve_reserve_balance( + evm.journal_mut(), + true, + ); } - - state + // Preserve the surrounding trace depth, including a synthetic CREATE frame. + evm.journal_inner_mut().depth = root_depth; + let res = evm.transact_raw(tx_env); + nested_chain_context = Some(evm.chain_mut().clone()); + #[cfg(feature = "monad")] + { + reserve_balance = + Some(foundry_evm_core::FoundryJournal::capture_reserve_balance( + evm.journal_mut(), + )); + } + (res, evm.to_evm_env()) }; - // set depth to 1 to make sure traces are collected correctly - evm.journaled_state.depth = 1; - - let res = evm.transact(env.tx.clone()); - - // need to reset the env in case it was modified via cheatcodes during execution - *env.cfg = evm.cfg.clone(); - *env.block = evm.block.clone(); - - *env.tx = cached_env.tx; - env.block.basefee = cached_env.evm_env.block_env.basefee; + // Restore env, preserving cheatcode cfg/block changes from the nested EVM but restoring + // the original tx and temporary fee overrides used for the nested call. + let mut restored_evm_env = nested_env; + restored_evm_env.block_env.set_basefee(cached_evm_env.block_env.basefee()); + restored_evm_env.cfg_env.disable_fee_charge = cached_evm_env.cfg_env.disable_fee_charge; + restored_evm_env.cfg_env.tx_gas_limit_cap = cached_evm_env.cfg_env.tx_gas_limit_cap; + restored_evm_env.cfg_env.disable_eip7623 = cached_evm_env.cfg_env.disable_eip7623; + ecx.set_evm(restored_evm_env); + ecx.set_tx(cached_tx_env); res }); + *ecx.chain_mut() = nested_chain_context.expect("nested EVM chain context was captured"); self.in_inner_context = false; self.inner_context_data = None; + self.outer_disable_fee_charge = None; + + // Reset the cheatcodes isolation flag now that the synthetic inner + // transaction has finished. + if let Some(cheats) = self.cheatcodes.as_deref_mut() { + cheats.in_isolation_context = false; + cheats.track_isolated_snapshots = false; + } - let mut gas = Gas::new(gas_limit); + let mut gas = Gas::new_with_regular_gas_and_reservoir(regular_limit, reservoir); + let was_precompile_called = self.isolated_call_was_precompile.take().unwrap_or(false); let Ok(res) = res else { + if let Some(cheats) = self.cheatcodes.as_deref_mut() { + cheats.pending_isolated_snapshot_journal = None; + cheats.isolated_snapshot_restores.clear(); + } + self.inner.isolated_frame_checkpoints.clear(); + #[cfg(feature = "monad")] + foundry_evm_core::FoundryJournal::restore_reserve_balance( + ecx.journal_mut(), + reserve_balance.expect("isolated transaction state was captured"), + ); + #[cfg(feature = "monad")] + refresh_chain_journal(ecx); // Should we match, encode and propagate error as a revert reason? let result = InterpreterResult { result: InstructionResult::Revert, output: Bytes::new(), gas }; - return (result, None); + return (result, None, was_precompile_called); }; - for (addr, mut acc) in res.state { - let Some(acc_mut) = ecx.journaled_state.state.get_mut(&addr) else { - ecx.journaled_state.state.insert(addr, acc); - continue; - }; + let mut transaction_gas = *res.result.gas(); + if let Some(floor_gas) = floor_gas { + transaction_gas = transaction_gas.with_floor_gas(floor_gas); + } + let tx_state_gas_used = transaction_gas.block_state_gas_used(); + // A failed frame refunds the account-creation charge to the outer opcode, and its + // transaction does not charge that state gas either. + let precharged_state = precharged_state.map_or(0, |gas| gas.min(tx_state_gas_used)); + let state_gas_used = tx_state_gas_used - precharged_state; + if let Some(cheats) = self.cheatcodes.as_deref_mut() { + cheats + .gas_metering + .set_isolated_snapshot_gas_used(transaction_gas.tx_gas_used(), precharged_state); + } + // The transaction also pays its intrinsic gas and calldata floor, which can exceed the + // gas forwarded to the frame. The frame then consumes its whole regular budget, as on an + // exceptional halt. + if !(gas.record_state_cost(state_gas_used) + && gas.record_regular_cost(transaction_gas.block_regular_gas_used())) + { + gas.spend_all(); + } - // make sure accounts that were warmed earlier do not become cold - if acc.status.contains(AccountStatus::Cold) - && !acc_mut.status.contains(AccountStatus::Cold) - { - acc.status -= AccountStatus::Cold; - } - acc_mut.info = acc.info; - acc_mut.status |= acc.status; + let rolled_back = !res.result.is_success(); - for (key, val) in acc.storage { - let Some(slot_mut) = acc_mut.storage.get_mut(&key) else { - acc_mut.storage.insert(key, val); - continue; - }; - slot_mut.present_value = val.present_value; - slot_mut.is_cold &= val.is_cold; - } + let restored_journal = self + .cheatcodes + .as_deref_mut() + .and_then(|cheats| cheats.pending_isolated_snapshot_journal.take()); + if let Some(cheats) = self.cheatcodes.as_deref_mut() { + cheats.isolated_snapshot_restores.clear(); + } + self.inner.isolated_frame_checkpoints.clear(); + let restored_snapshot = if !rolled_back && let Some(restored_journal) = restored_journal { + let (_, journaled_state) = ecx.db_journal_inner_mut(); + journaled_state.journal = restored_journal; + true + } else { + false + }; + if source_fork_id == ecx.db().active_fork_id() { + merge_child_state(ecx.journal_mut().evm_state_mut(), res.state, restored_snapshot); + } else { + *ecx.journal_mut().evm_state_mut() = res.state; } + #[cfg(feature = "monad")] + foundry_evm_core::FoundryJournal::restore_reserve_balance( + ecx.journal_mut(), + reserve_balance.expect("isolated transaction state was captured"), + ); + #[cfg(feature = "monad")] + refresh_chain_journal(ecx); let (result, address, output) = match res.result { - ExecutionResult::Success { reason, gas_used, gas_refunded, logs: _, output } => { - gas.set_refund(gas_refunded as i64); - let _ = gas.record_cost(gas_used); + ExecutionResult::Success { reason, gas: _, logs: _, output } => { + gas.set_refund(transaction_gas.final_refunded() as i64); let address = match output { Output::Create(_, address) => address, Output::Call(_) => None, }; (reason.into(), address, output.into_data()) } - ExecutionResult::Halt { reason, gas_used } => { - let _ = gas.record_cost(gas_used); - (reason.into(), None, Bytes::new()) + ExecutionResult::Halt { reason, .. } => { + (InstructionResult::from(reason), None, Bytes::new()) } - ExecutionResult::Revert { gas_used, output } => { - let _ = gas.record_cost(gas_used); - (InstructionResult::Revert, None, output) - } - }; - (InterpreterResult { result, output, gas }, address) - } - - /// Moves out of references, constructs an [`InspectorStack`] and runs the given closure with - /// it. - fn with_stack(&mut self, f: impl FnOnce(&mut InspectorStack) -> O) -> O { - let mut stack = InspectorStack { - cheatcodes: self - .cheatcodes - .as_deref_mut() - .map(|cheats| core::mem::replace(cheats, Cheatcodes::new(cheats.config.clone()))), - inner: std::mem::take(self.inner), + ExecutionResult::Revert { output, .. } => (InstructionResult::Revert, None, output), }; + #[cfg(feature = "monad")] + if rolled_back { + refresh_chain_journal(ecx); + } + (InterpreterResult { result, output, gas }, address, was_precompile_called) + } - let out = f(&mut stack); + /// Moves out of references, constructs a new [`InspectorStackRefMut`] and runs the given + /// closure with it. + fn with_inspector(&mut self, f: impl FnOnce(InspectorStackRefMut<'_, FEN>) -> O) -> O { + let mut cheatcodes = self + .cheatcodes + .as_deref_mut() + .map(|cheats| core::mem::replace(cheats, Cheatcodes::new(cheats.config.clone()))); + let mut inner = std::mem::take(self.inner); + + // Save pending CREATE2 redirects so frame_end in the nested EVM doesn't consume them. + // These belong to the outer EVM's frame lifecycle and must be restored after. + let saved_create2_redirects = std::mem::take(&mut inner.pending_create2_redirects); + + let out = f(InspectorStackRefMut { cheatcodes: cheatcodes.as_mut(), inner: &mut inner }); if let Some(cheats) = self.cheatcodes.as_deref_mut() { - *cheats = stack.cheatcodes.take().unwrap(); + *cheats = cheatcodes.unwrap(); } - *self.inner = stack.inner; + inner.pending_create2_redirects = saved_create2_redirects; + *self.inner = inner; out } /// Invoked at the beginning of a new top-level (0 depth) frame. - fn top_level_frame_start(&mut self, ecx: &mut EthEvmContext<&mut dyn DatabaseExt>) { + fn top_level_frame_start(&mut self, ecx: &mut FoundryContextFor<'_, FEN>) { + self.locally_created_accounts.clear(); + self.top_level_frame_failed_before_rewrite = false; + warm_arbitrum_system_contract::(ecx); + if let Some(cheatcodes) = &mut self.cheatcodes { + cheatcodes.clear_storage_hook_mapping_slots(); + } + if self.enable_isolation { // If we're in isolation mode, we need to keep track of the state at the beginning of // the frame to be able to roll back on revert - self.top_frame_journal = ecx.journaled_state.state.clone(); + self.top_frame_journal.clone_from(ecx.journal().evm_state()); } } /// Invoked at the end of root frame. - fn top_level_frame_end( - &mut self, - ecx: &mut EthEvmContext<&mut dyn DatabaseExt>, - result: InstructionResult, - ) { - if !result.is_revert() { + fn top_level_frame_end(&mut self, ecx: &mut FoundryContextFor<'_, FEN>, failed: bool) { + if let Some(cheatcodes) = &mut self.cheatcodes { + cheatcodes.clear_storage_hook_mapping_slots(); + } + if !failed { return; } - // Encountered a revert, since cheatcodes may have altered the evm state in such a way - // that violates some constraints, e.g. `deal`, we need to manually roll back on revert - // before revm reverts the state itself + // The frame was rolled back. Since cheatcodes may have altered the EVM state in a way + // that violates some constraints, e.g. `deal`, restore those changes explicitly. if let Some(cheats) = self.cheatcodes.as_mut() { cheats.on_revert(ecx); } - // If we're in isolation mode, we need to rollback to state before the root frame was - // created We can't rely on revm's journal because it doesn't account for changes - // made by isolated calls + // In isolation mode, restore the state from before the root frame. We cannot rely on + // revm's journal because it does not account for changes made by isolated calls. if self.enable_isolation { - ecx.journaled_state.state = std::mem::take(&mut self.top_frame_journal); + *ecx.journal_mut().evm_state_mut() = std::mem::take(&mut self.top_frame_journal); + } + + #[cfg(feature = "monad")] + refresh_chain_journal(ecx); + } + + // We take extra care in optimizing `step` and `step_end`, as they're are likely the most + // hot functions in all of Foundry. + // We want to `#[inline(always)]` these functions so that `InspectorStack` does not + // delegate to `InspectorStackRefMut` in this case. + + #[inline(always)] + fn step_inlined( + &mut self, + interpreter: &mut Interpreter, + ecx: &mut FoundryContextFor<'_, FEN>, + ) { + let storage_hook_active = if let Some(cheats) = self.cheatcodes.as_mut() { + if cheats.has_storage_hooks() && cheats.finish_storage_hook_callback(interpreter, ecx) { + return; + } + cheats.pc = interpreter.bytecode.pc(); + cheats.is_storage_hook_active() + } else { + false + }; + + #[cfg(test)] + if interpreter.bytecode.opcode() == op::JUMPDEST + && let Some(gate) = &self.inner.early_exit_test_gate + && interpreter.bytecode.pc() == gate.target_pc + && !gate.notified.swap(true, std::sync::atomic::Ordering::Relaxed) + { + let _ = gate.entered.send(()); + let _ = gate + .release + .lock() + .expect("early-exit test gate lock poisoned") + .recv_timeout(std::time::Duration::from_secs(1)); + } + + if let Some(cancellation) = &self.inner.execution_cancellation { + let poll_deadline = self.inner.cancellation_poll_counter == 0; + self.inner.cancellation_poll_counter = + self.inner.cancellation_poll_counter.wrapping_add(1); + if cancellation.should_stop(poll_deadline) { + self.inner.execution_cancelled = true; + interpreter.halt(InstructionResult::Stop); + return; + } + } + + match self.static_step_dispatch { + OpcodeStepDispatch::None => {} + OpcodeStepDispatch::FuzzerOnly => { + if !storage_hook_active && let Some(inspector) = &mut self.fuzzer { + inspector.step(interpreter, ecx); + } + } + OpcodeStepDispatch::General => { + if !storage_hook_active { + call_inspectors!( + [ + // These are sorted in definition order. + &mut self.edge_coverage, + &mut self.fuzzer, + &mut self.line_coverage, + ], + |inspector| (**inspector).step(interpreter, ecx), + ); + } + call_inspectors!( + [ + // These are sorted in definition order. + &mut self.printer, + &mut self.revert_diag, + &mut self.script_execution_inspector, + &mut self.tracer, + ], + |inspector| (**inspector).step(interpreter, ecx), + ); + } + } + + if let Some(cheats) = self.cheatcodes.as_mut() + && cheats.has_step_hooks() + { + let opcode = interpreter.bytecode.opcode(); + if !cheats.has_recording_accesses_only_step_hook() + || matches!(opcode, op::SLOAD | op::SSTORE) + { + crate::utils::cold_path(); + cheats.step(interpreter, ecx); + } + } + } + + #[inline(always)] + fn step_end_inlined( + &mut self, + interpreter: &mut Interpreter, + ecx: &mut FoundryContextFor<'_, FEN>, + ) { + if self.has_static_step_end_inspectors { + call_inspectors!( + [ + // These are sorted in definition order. + &mut self.chisel_state, + &mut self.printer, + &mut self.revert_diag, + &mut self.tracer, + ], + |inspector| (**inspector).step_end(interpreter, ecx), + ); + } + + if let Some(fuzzer) = &mut self.fuzzer + && fuzzer.mapping_slots.is_some() + { + fuzzer.step_end(interpreter, ecx); + } + + if let Some(cheats) = self.cheatcodes.as_mut() + && cheats.has_step_end_hooks() + { + crate::utils::cold_path(); + cheats.step_end(interpreter, ecx); + } + } + + fn finish_isolated_snapshot_frame(&mut self, ecx: &mut FoundryContextFor<'_, FEN>) { + let Some(frame) = self.inner.isolated_frame_checkpoints.pop() else { return }; + + if frame.failed + && let Some(cheats) = self.cheatcodes.as_deref_mut() + && cheats.isolated_snapshot_restores.len() > frame.restore_len + { + cheats.isolated_snapshot_restores.truncate(frame.restore_len + 1); + let mut journal = cheats.isolated_snapshot_restores.pop().unwrap(); + journal.checkpoint_revert(frame.checkpoint); + journal.depth = frame.return_depth; + if cheats.isolated_snapshot_restores.is_empty() { + cheats.pending_isolated_snapshot_journal = None; + } + ecx.set_journal_inner(journal); + } + + // The caller only needs the first restoration made since it started, so drop the ones a + // finished call captured after it. This bounds their count by call depth; each retained + // journal can still contain all loaded account state. + if let Some(caller) = self.inner.isolated_frame_checkpoints.last() + && let Some(cheats) = self.cheatcodes.as_deref_mut() + { + cheats.isolated_snapshot_restores.truncate(caller.restore_len + 1); + } + + if let Some(cheats) = self.cheatcodes.as_deref_mut() + && cheats.pending_isolated_snapshot_journal.is_some() + { + cheats.pending_isolated_snapshot_journal = Some(ecx.journal_inner().journal.clone()); } } } -impl Inspector> for InspectorStackRefMut<'_> { +impl Inspector> + for InspectorStackRefMut<'_, FEN> +{ fn initialize_interp( &mut self, interpreter: &mut Interpreter, - ecx: &mut EthEvmContext<&mut dyn DatabaseExt>, + ecx: &mut FoundryContextFor<'_, FEN>, ) { + if self.cheatcodes.as_deref().is_some_and(|cheats| cheats.track_isolated_snapshots) + && let Some(frame) = self.inner.isolated_frame_checkpoints.last_mut() + && frame.is_create + && let Some(offset) = ecx.journal_inner().journal[frame.checkpoint.journal_i..] + .iter() + .position(|entry| matches!(entry, JournalEntry::AccountCreated { .. })) + { + frame.checkpoint.journal_i += offset; + } + + let address = interpreter.input.target_address(); + let should_mark_created_locally = self.locally_created_accounts.contains(&address) + || self + .inner_context_data + .as_ref() + .is_some_and(|ctx| ctx.locally_created_accounts.contains(&address)); + if should_mark_created_locally + && let Some(account) = ecx.journal_mut().evm_state_mut().get_mut(&address) + && account.is_created() + { + account.mark_created_locally(); + } + call_inspectors!( [ &mut self.line_coverage, @@ -807,126 +1568,358 @@ impl Inspector> for InspectorStackRefMut<'_> ); } - fn step( + fn step(&mut self, interpreter: &mut Interpreter, ecx: &mut FoundryContextFor<'_, FEN>) { + self.step_inlined(interpreter, ecx); + } + + fn step_end(&mut self, interpreter: &mut Interpreter, ecx: &mut FoundryContextFor<'_, FEN>) { + self.step_end_inlined(interpreter, ecx); + } + + #[allow(clippy::redundant_clone)] + fn log(&mut self, ecx: &mut FoundryContextFor<'_, FEN>, log: Log) { + call_inspectors!([&mut self.tracer, &mut self.log_collector], |inspector| { + inspector.log(ecx, log.clone()) + }); + if let Some(inspector) = &mut self.cheatcodes + && inspector.has_log_hooks() + { + crate::utils::cold_path(); + inspector.log(ecx, log.clone()); + } + call_inspectors!([&mut self.printer], |inspector| { inspector.log(ecx, log.clone()) }); + } + + #[allow(clippy::redundant_clone)] + fn log_full( &mut self, interpreter: &mut Interpreter, - ecx: &mut EthEvmContext<&mut dyn DatabaseExt>, + ecx: &mut FoundryContextFor<'_, FEN>, + log: Log, ) { - call_inspectors!( - [ - &mut self.fuzzer, - &mut self.tracer, - &mut self.line_coverage, - &mut self.edge_coverage, - &mut self.cheatcodes, - &mut self.script_execution_inspector, - &mut self.printer, - &mut self.revert_diag - ], - |inspector| inspector.step(interpreter, ecx), - ); + call_inspectors!([&mut self.tracer, &mut self.log_collector], |inspector| { + inspector.log_full(interpreter, ecx, log.clone()) + }); + if let Some(inspector) = &mut self.cheatcodes + && inspector.has_log_hooks() + { + crate::utils::cold_path(); + inspector.log_full(interpreter, ecx, log.clone()); + } + call_inspectors!([&mut self.printer], |inspector| { + inspector.log_full(interpreter, ecx, log.clone()) + }); } - fn step_end( + fn frame_start( &mut self, - interpreter: &mut Interpreter, - ecx: &mut EthEvmContext<&mut dyn DatabaseExt>, - ) { - call_inspectors!( - [ - &mut self.tracer, - &mut self.cheatcodes, - &mut self.chisel_state, - &mut self.printer, - &mut self.revert_diag - ], - |inspector| inspector.step_end(interpreter, ecx), - ); + ecx: &mut FoundryContextFor<'_, FEN>, + frame_input: &mut FrameInput, + ) -> Option { + // A non-isolated top-level transaction needs the same restore tracking as an isolated call. + if !self.enable_isolation + && !self.in_inner_context + && ecx.journal().depth() == 0 + && let Some(cheats) = self.cheatcodes.as_deref_mut() + { + cheats.pending_isolated_snapshot_journal = None; + cheats.track_isolated_snapshots = true; + cheats.isolated_snapshot_restores.clear(); + self.inner.isolated_frame_checkpoints.clear(); + } + if let Some(cheats) = self.cheatcodes.as_deref_mut() + && cheats.track_isolated_snapshots + { + let journal = ecx.journal_inner(); + let restore_len = cheats.isolated_snapshot_restores.len(); + // Unwinding a frame only needs the first restoration made inside it, so a restoration + // requested by this call is captured only if none was captured since its caller + // started. + cheats.capture_isolated_snapshot_restore = self + .inner + .isolated_frame_checkpoints + .last() + .is_none_or(|caller| caller.restore_len == restore_len); + self.inner.isolated_frame_checkpoints.push(IsolatedFrameCheckpoint { + checkpoint: JournalCheckpoint { + log_i: journal.logs.len(), + journal_i: journal.journal.len(), + selfdestructed_i: journal.selfdestructed_addresses.len(), + }, + restore_len, + return_depth: journal.depth, + is_create: matches!(frame_input, FrameInput::Create(_)), + failed: false, + }); + } + + if let FrameInput::Create(inputs) = frame_input + && self.should_use_create2_factory(ecx.journal().depth(), inputs) + { + // `get_create2_factory_call_inputs` forwards `inputs.value()` to the factory + // as `CallValue::Transfer`, and the default factory runtime forwards CALLVALUE + // into CREATE2, so payable deploys are supported on both the explicit + // `new C{salt, value}` path and the `--batch` rewrite path. + let salt = match inputs.scheme() { + CreateScheme::Create2 { salt } => salt, + // --batch: process_salt (random per run) + counter make each deploy unique. + // The nonce below is from the EVM frame caller (script contract), not the EOA. + CreateScheme::Create => { + if !self.inner.batch_rewrite_warned { + let _ = sh_warn!( + "--batch rewrites CREATE → CREATE2 via the Arachnid factory; \ + deployed addresses follow the CREATE2 formula and constructor \ + msg.sender is the factory, not the EOA." + ); + self.inner.batch_rewrite_warned = true; + } + let chain_id = ecx.cfg().chain_id(); + let nonce = ecx.journal_mut().load_account(inputs.caller()).ok()?.info.nonce; + self.inner.next_batch_create_salt(chain_id, nonce) + } + _ => return None, + }; + + let gas_limit = inputs.gas_limit(); + let create2_deployer = self.create2_deployer(); + + // Validate deployer before rewriting. + let code_hash = ecx.journal_mut().load_account(create2_deployer).ok()?.info.code_hash; + if code_hash == KECCAK_EMPTY { + // Store the revert so `create` can return it inside the normal inspector + // lifecycle (avoids tracing mismatch from short-circuiting in frame_start). + self.inner.pending_create2_error = Some(CreateOutcome { + result: InterpreterResult { + result: InstructionResult::Revert, + output: Bytes::from( + format!("missing CREATE2 deployer: {create2_deployer}").into_bytes(), + ), + gas: Gas::new(gas_limit), + }, + address: None, + charged_create_state_gas: inputs.charged_create_state_gas(), + }); + return None; + } else if code_hash != DEFAULT_CREATE2_DEPLOYER_CODEHASH { + self.inner.pending_create2_error = Some(CreateOutcome { + result: InterpreterResult { + result: InstructionResult::Revert, + output: "invalid CREATE2 deployer bytecode".into(), + gas: Gas::new(gas_limit), + }, + address: None, + charged_create_state_gas: inputs.charged_create_state_gas(), + }); + return None; + } + + let call_inputs = + get_create2_factory_call_inputs(salt, inputs, create2_deployer, ecx.journal_mut()) + .ok()?; + + // Record the redirect depth *after* validation succeeds. + self.inner.pending_create2_redirects.push(PendingCreate2Redirect { + depth: ecx.journal().depth(), + charged_create_state_gas: inputs.charged_create_state_gas(), + }); + + // Rewrite the frame input from Create to Call. + *frame_input = FrameInput::Call(Box::new(call_inputs)); + } + + None } - fn log( + fn frame_end( &mut self, - interpreter: &mut Interpreter, - ecx: &mut EthEvmContext<&mut dyn DatabaseExt>, - log: Log, + ecx: &mut FoundryContextFor<'_, FEN>, + _frame_input: &FrameInput, + frame_result: &mut FrameResult, ) { - call_inspectors!( - [&mut self.tracer, &mut self.log_collector, &mut self.cheatcodes, &mut self.printer], - |inspector| inspector.log(interpreter, ecx, log.clone()), - ); + let depth = ecx.journal().depth(); + self.finish_create2_redirect(depth, frame_result); + if self.cheatcodes.as_deref().is_some_and(|cheats| cheats.track_isolated_snapshots) { + self.finish_isolated_snapshot_frame(ecx); + } + + let result = frame_result.instruction_result(); + if !self.in_inner_context && depth == 0 { + let failed = std::mem::take(&mut self.inner.top_level_frame_failed_before_rewrite) + || !result.is_ok(); + self.top_level_frame_end(ecx, failed); + if !self.enable_isolation { + if let Some(cheats) = self.cheatcodes.as_deref_mut() { + cheats.track_isolated_snapshots = false; + cheats.isolated_snapshot_restores.clear(); + } + self.inner.isolated_frame_checkpoints.clear(); + } + } } fn call( &mut self, - ecx: &mut EthEvmContext<&mut dyn DatabaseExt>, + ecx: &mut FoundryContextFor<'_, FEN>, call: &mut CallInputs, ) -> Option { - if self.in_inner_context && ecx.journaled_state.depth == 1 { + if self.is_inner_context_root(ecx.journal().depth()) { self.adjust_evm_data_for_inner_context(ecx); + warm_arbitrum_system_contract::(ecx); + // Transaction call inputs use the original target, so resolve function mocks here too. + if let Some(cheatcodes) = self.cheatcodes.as_deref() { + apply_mocked_function(cheatcodes, ecx, call); + } return None; } - if ecx.journaled_state.depth == 0 { + if ecx.journal().depth() == 0 { self.top_level_frame_start(ecx); } + if let Some(revert_diag) = self.revert_diag.as_deref_mut() { + revert_diag.frame_start(); + } + + let storage_hook_callback = self + .cheatcodes + .as_deref() + .is_some_and(|cheatcodes| cheatcodes.is_storage_hook_callback(ecx, call)); + let storage_hook_active = + self.cheatcodes.as_deref().is_some_and(Cheatcodes::is_storage_hook_active); + + if !storage_hook_active { + call_inspectors!( + #[ret] + [&mut self.fuzzer], + |inspector| { + let mut out = None; + if let Some(output) = inspector.call(ecx, call) { + out = Some(Some(output)); + } + out + } + ); + } + + if self.tracer.is_some() { + crate::utils::cold_path(); + let (output, trace_idx) = { + let tracer = self.tracer.as_deref_mut().unwrap(); + let output = tracer.call(ecx, call); + (output, tracer.traces().nodes().len() - 1) + }; + self.pending_call_traces.push(PendingCallTrace { trace_idx, executed_address: None }); + if let Some(revert_diag) = self.revert_diag.as_deref_mut() { + revert_diag.set_trace_node(trace_idx); + } + if output.is_some() { + return output; + } + } + call_inspectors!( #[ret] [ - &mut self.fuzzer, - &mut self.tracer, &mut self.log_collector, &mut self.printer, - &mut self.revert_diag + &mut self.revert_diag, + &mut self.tempo_labels ], - |inspector| { - let mut out = None; - if let Some(output) = inspector.call(ecx, call) { - out = Some(Some(output)); - } - out - }, + |inspector| inspector.call(ecx, call).map(Some), ); - if let Some(cheatcodes) = self.cheatcodes.as_deref_mut() { - // Handle mocked functions, replace bytecode address with mock if matched. - if let Some(mocks) = cheatcodes.mocked_functions.get(&call.target_address) { - // Check if any mock function set for call data or if catch-all mock function set - // for selector. - if let Some(target) = mocks.get(&call.input.bytes(ecx)).or_else(|| { - call.input.bytes(ecx).get(..4).and_then(|selector| mocks.get(selector)) - }) { - call.bytecode_address = *target; - } + // Storage hook callbacks are instrumentation frames, not user calls. Let revm execute the + // callback after tracing it, but do not apply mocks, pranks, broadcasts, or isolation. + if storage_hook_callback { + if let Some(pending) = self.pending_call_traces.last_mut() { + pending.executed_address = Some(call.bytecode_address); } + return None; + } - if let Some(output) = cheatcodes.call_with_executor(ecx, call, self.inner) { - return Some(output); + // The tracer records call inputs before cheatcodes apply caller overrides such as pranks + // and broadcasts. Keep the trace lifecycle ordering, but remember the node so its caller + // can be synchronized with the inputs that are actually executed. + let trace_idx = self.tracer.as_ref().map(|tracer| tracer.traces().nodes().len() - 1); + // Also isolate a synthetic `deployCode` create that was rewritten to a CREATE2 factory + // call. + let isolate = self.enable_isolation + && !self.in_inner_context + && (ecx.journal().depth() == 1 + || self.inner.synthetic_create_depth == Some(ecx.journal().depth())); + let mut cheatcode_outcome = None; + if let Some(cheatcodes) = self.cheatcodes.as_deref_mut() { + apply_mocked_function(cheatcodes, ecx, call); + + let execution_disable_fee_charge = ecx.cfg_env().disable_fee_charge; + if let Some(disable_fee_charge) = self.inner.outer_disable_fee_charge { + ecx.cfg_env_mut().disable_fee_charge = disable_fee_charge; } + cheatcode_outcome = cheatcodes.call_with_executor( + ecx, + call, + self.inner, + isolate && call.scheme.is_call(), + ); + ecx.cfg_env_mut().disable_fee_charge = execution_disable_fee_charge; + } + + if let Some(trace_idx) = trace_idx + && let Some(tracer) = self.tracer.as_deref_mut() + { + let caller = match call.scheme { + CallScheme::DelegateCall | CallScheme::CallCode => call.transfer_to(), + CallScheme::Call | CallScheme::StaticCall => call.transfer_from(), + }; + let node = &mut tracer.traces_mut().nodes_mut()[trace_idx]; + debug_assert_eq!(node.trace.depth, ecx.journal().depth()); + node.trace.caller = caller; + } + + if let Some(output) = cheatcode_outcome { + return Some(output); + } + + if let Some(outcome) = handle_arbitrum_system_call::(ecx, call) { + return Some(outcome); + } + + if let Some(pending) = self.pending_call_traces.last_mut() { + pending.executed_address = Some(call.bytecode_address); } - if self.enable_isolation && !self.in_inner_context && ecx.journaled_state.depth == 1 { + if isolate { match call.scheme { // Isolate CALLs CallScheme::Call => { let input = call.input.bytes(ecx); - let (result, _) = self.transact_inner( + let precharged_state = call + .charged_new_account_state_gas + .then_some(ecx.cfg().gas_params().new_account_state_gas()); + let (result, _, was_precompile_called) = self.transact_inner( ecx, - TxKind::Call(call.target_address), - call.caller, + TxKind::Call(call.transfer_to()), + call.transfer_from(), input, - call.gas_limit, + IsolatedGas { + regular_limit: call.gas_limit, + reservoir: call.reservoir, + precharged_state, + }, call.value.get(), ); return Some(CallOutcome { result, memory_offset: call.return_memory_offset.clone(), + was_precompile_called, + precompile_call_logs: vec![], + charged_new_account_state_gas: call.charged_new_account_state_gas, }); } // Mark accounts and storage cold before STATICCALLs CallScheme::StaticCall => { - let JournaledState { state, warm_preloaded_addresses, .. } = - &mut ecx.journaled_state.inner; + let (_, journal_inner) = ecx.db_journal_inner_mut(); + let JournaledState { state, warm_addresses, .. } = journal_inner; for (addr, acc_mut) in state { // Do not mark accounts and storage cold accounts with arbitrary storage. if let Some(cheatcodes) = &self.cheatcodes @@ -935,7 +1928,7 @@ impl Inspector> for InspectorStackRefMut<'_> continue; } - if !warm_preloaded_addresses.contains(addr) { + if warm_addresses.is_cold(addr) { acc_mut.mark_cold(); } @@ -954,57 +1947,146 @@ impl Inspector> for InspectorStackRefMut<'_> fn call_end( &mut self, - ecx: &mut EthEvmContext<&mut dyn DatabaseExt>, + ecx: &mut FoundryContextFor<'_, FEN>, inputs: &CallInputs, outcome: &mut CallOutcome, ) { + if self.cheatcodes.as_deref().is_some_and(|cheats| cheats.track_isolated_snapshots) + && let Some(frame) = self.inner.isolated_frame_checkpoints.last_mut() + { + frame.failed |= !outcome.result.result.is_ok(); + } + // We are processing inner context outputs in the outer context, so need to avoid processing // twice. - if self.in_inner_context && ecx.journaled_state.depth == 1 { + if self.is_inner_context_root(ecx.journal().depth()) { + self.isolated_call_was_precompile = Some(outcome.was_precompile_called); return; } + if ecx.journal().depth() == 0 { + self.inner.top_level_frame_failed_before_rewrite |= !outcome.result.result.is_ok(); + } + + if let Some(pending) = self.pending_call_traces.pop() + && let Some(tracer) = self.tracer.as_deref_mut() + { + let trace = &mut tracer.traces_mut().nodes_mut()[pending.trace_idx].trace; + if trace.address == P256_VERIFY { + trace.maybe_precompile = Some( + pending.executed_address == Some(P256_VERIFY) && outcome.was_precompile_called, + ); + } + } + self.do_call_end(ecx, inputs, outcome); - if ecx.journaled_state.depth == 0 { - self.top_level_frame_end(ecx, outcome.result.result); + if let Some(revert_diag) = self.revert_diag.as_deref_mut() { + revert_diag.frame_end(); } } fn create( &mut self, - ecx: &mut EthEvmContext<&mut dyn DatabaseExt>, + ecx: &mut FoundryContextFor<'_, FEN>, create: &mut CreateInputs, ) -> Option { - if self.in_inner_context && ecx.journaled_state.depth == 1 { + if self.is_inner_context_root(ecx.journal().depth()) { self.adjust_evm_data_for_inner_context(ecx); + warm_arbitrum_system_contract::(ecx); return None; } - if ecx.journaled_state.depth == 0 { + if ecx.journal().depth() == 0 { self.top_level_frame_start(ecx); } + if let Some(revert_diag) = self.revert_diag.as_deref_mut() { + revert_diag.frame_start(); + } + + if self.tracer.is_some() { + crate::utils::cold_path(); + let (output, trace_idx) = { + let tracer = self.tracer.as_deref_mut().unwrap(); + let output = tracer.create(ecx, create); + (output, tracer.traces().nodes().len() - 1) + }; + if let Some(revert_diag) = self.revert_diag.as_deref_mut() { + revert_diag.set_trace_node(trace_idx); + } + if output.is_some() { + return output; + } + } + call_inspectors!( #[ret] - [&mut self.tracer, &mut self.line_coverage, &mut self.cheatcodes], + [&mut self.line_coverage], |inspector| inspector.create(ecx, create).map(Some), ); - if !matches!(create.scheme, CreateScheme::Create2 { .. }) + let trace_idx = self.tracer.as_ref().map(|tracer| tracer.traces().nodes().len() - 1); + let mut cheatcode_outcome = None; + if let Some(cheatcodes) = self.cheatcodes.as_deref_mut() { + cheatcode_outcome = cheatcodes.create(ecx, create); + } + + if let Some(trace_idx) = trace_idx + && let Some(tracer) = self.tracer.as_deref_mut() + { + let node = &mut tracer.traces_mut().nodes_mut()[trace_idx]; + debug_assert_eq!(node.trace.depth, ecx.journal().depth()); + node.trace.caller = create.caller(); + } + + if let Some(output) = cheatcode_outcome { + return Some(output); + } + + // If frame_start detected an invalid CREATE2 deployer, return the error here + // (after sub-inspectors have been notified) so tracing stays balanced. + if let Some(error) = self.inner.pending_create2_error.take() { + return Some(error); + } + + if !matches!(create.scheme(), CreateScheme::Create2 { .. }) && self.enable_isolation && !self.in_inner_context - && ecx.journaled_state.depth == 1 + && (ecx.journal().depth() == 1 + || self.synthetic_create_depth == Some(ecx.journal().depth())) { - let (result, address) = self.transact_inner( + // In isolation mode, transact_inner returns None for the address on revert; pre-compute + // the would-be deployed address so create_end can enforce expected_revert reverter + // checks. + let precomputed_address = ecx + .journal() + .evm_state() + .get(&create.caller()) + .map(|acc| create.caller().create(acc.info.nonce)); + let precharged_state = create + .charged_create_state_gas() + .then_some(ecx.cfg().gas_params().create_state_gas()); + + let (result, address, _) = self.transact_inner( ecx, TxKind::Create, - create.caller, - create.init_code.clone(), - create.gas_limit, - create.value, + create.caller(), + create.init_code().clone(), + IsolatedGas { + regular_limit: create.gas_limit(), + reservoir: create.reservoir(), + precharged_state, + }, + create.value(), ); - return Some(CreateOutcome { result, address }); + let address = + address.or_else(|| if result.is_revert() { precomputed_address } else { None }); + return Some(CreateOutcome { + result, + address, + charged_create_state_gas: create.charged_create_state_gas(), + }); } None @@ -1012,34 +2094,129 @@ impl Inspector> for InspectorStackRefMut<'_> fn create_end( &mut self, - ecx: &mut EthEvmContext<&mut dyn DatabaseExt>, + ecx: &mut FoundryContextFor<'_, FEN>, call: &CreateInputs, outcome: &mut CreateOutcome, ) { + if self.cheatcodes.as_deref().is_some_and(|cheats| cheats.track_isolated_snapshots) + && let Some(frame) = self.inner.isolated_frame_checkpoints.last_mut() + { + frame.failed |= !outcome.result.result.is_ok(); + } + + if outcome.result.result.is_ok() + && let Some(address) = outcome.address + { + self.locally_created_accounts.insert(address); + + if self.in_inner_context + && let Some(inner_context) = &mut self.inner_context_data + { + inner_context.locally_created_accounts.insert(address); + } + } + // We are processing inner context outputs in the outer context, so need to avoid processing // twice. - if self.in_inner_context && ecx.journaled_state.depth == 1 { + if self.is_inner_context_root(ecx.journal().depth()) { return; } + if ecx.journal().depth() == 0 { + self.inner.top_level_frame_failed_before_rewrite |= !outcome.result.result.is_ok(); + } + self.do_create_end(ecx, call, outcome); - if ecx.journaled_state.depth == 0 { - self.top_level_frame_end(ecx, outcome.result.result); + if let Some(revert_diag) = self.revert_diag.as_deref_mut() { + revert_diag.frame_end(); } } + + fn selfdestruct(&mut self, contract: Address, target: Address, value: U256) { + call_inspectors!([&mut self.printer], |inspector| { + Inspector::>::selfdestruct( + inspector, contract, target, value, + ) + }); + } } -impl InspectorExt for InspectorStackRefMut<'_> { - fn should_use_create2_factory( - &mut self, - ecx: &mut EthEvmContext<&mut dyn DatabaseExt>, - inputs: &CreateInputs, - ) -> bool { +fn warm_arbitrum_system_contract(ecx: &mut FoundryContextFor<'_, FEN>) { + if !arbitrum::is_arbitrum_chain(ecx.cfg().chain_id()) { + return; + } + + // Nitro warms ArbSys like a precompile. Use the access list so other selectors can still + // execute code at the address. + let mut access_list = ecx.journal_inner().warm_addresses.access_list().clone(); + access_list.entry(arbitrum::ARB_SYS_ADDRESS).or_default(); + ecx.journal_mut().warm_access_list(access_list); +} + +fn handle_arbitrum_system_call( + ecx: &mut FoundryContextFor<'_, FEN>, + call: &CallInputs, +) -> Option { + if call.transfer_to() != arbitrum::ARB_SYS_ADDRESS + || call.bytecode_address != arbitrum::ARB_SYS_ADDRESS + || !arbitrum::is_arbitrum_chain(ecx.cfg().chain_id()) + { + return None; + } + + let input = call.input.bytes(ecx); + if input.get(..4) != Some(&arbitrum::ARB_BLOCK_NUMBER_SELECTOR) { + return None; + } + + // Forks report their L2 block, which the block env lacks on Arbitrum because `NUMBER` returns + // the L1 block there. Without a fork the block env is read here to follow `vm.roll`. + let block_number = match ecx.db().active_fork_block_number() { + Some(block_number) => block_number, + None => ecx.block().number().saturating_to(), + }; + let Some((gas_cost, output)) = arbitrum::arb_block_number_call(call.gas_limit, block_number) + else { + return Some(arbitrum_call_outcome( + call, + InstructionResult::PrecompileOOG, + 0, + Bytes::new(), + )); + }; + + Some(arbitrum_call_outcome(call, InstructionResult::Return, gas_cost, output)) +} + +fn arbitrum_call_outcome( + call: &CallInputs, + result: InstructionResult, + gas_used: u64, + output: Bytes, +) -> CallOutcome { + let mut gas = Gas::new(call.gas_limit); + if result.is_ok() { + let _ = gas.record_regular_cost(gas_used); + } else { + gas.spend_all(); + } + + CallOutcome { + result: InterpreterResult { result, output, gas }, + memory_offset: call.return_memory_offset.clone(), + was_precompile_called: true, + precompile_call_logs: vec![], + charged_new_account_state_gas: call.charged_new_account_state_gas, + } +} + +impl InspectorExt for InspectorStackRefMut<'_, FEN> { + fn should_use_create2_factory(&mut self, depth: usize, inputs: &CreateInputs) -> bool { call_inspectors!( #[ret] [&mut self.cheatcodes], - |inspector| { inspector.should_use_create2_factory(ecx, inputs).then_some(true) }, + |inspector| { inspector.should_use_create2_factory(depth, inputs).then_some(true) }, ); false @@ -1051,8 +2228,8 @@ impl InspectorExt for InspectorStackRefMut<'_> { )); } - fn is_odyssey(&self) -> bool { - self.inner.odyssey + fn get_networks(&self) -> NetworkConfigs { + self.inner.networks } fn create2_deployer(&self) -> Address { @@ -1060,28 +2237,20 @@ impl InspectorExt for InspectorStackRefMut<'_> { } } -impl Inspector> for InspectorStack { - #[inline] - fn step( - &mut self, - interpreter: &mut Interpreter, - ecx: &mut EthEvmContext<&mut dyn DatabaseExt>, - ) { - self.as_mut().step(interpreter, ecx) +impl Inspector> for InspectorStack { + #[inline(always)] + fn step(&mut self, interpreter: &mut Interpreter, ecx: &mut FoundryContextFor<'_, FEN>) { + self.as_mut().step_inlined(interpreter, ecx) } - #[inline] - fn step_end( - &mut self, - interpreter: &mut Interpreter, - ecx: &mut EthEvmContext<&mut dyn DatabaseExt>, - ) { - self.as_mut().step_end(interpreter, ecx) + #[inline(always)] + fn step_end(&mut self, interpreter: &mut Interpreter, ecx: &mut FoundryContextFor<'_, FEN>) { + self.as_mut().step_end_inlined(interpreter, ecx) } fn call( &mut self, - context: &mut EthEvmContext<&mut dyn DatabaseExt>, + context: &mut FoundryContextFor<'_, FEN>, inputs: &mut CallInputs, ) -> Option { self.as_mut().call(context, inputs) @@ -1089,7 +2258,7 @@ impl Inspector> for InspectorStack { fn call_end( &mut self, - context: &mut EthEvmContext<&mut dyn DatabaseExt>, + context: &mut FoundryContextFor<'_, FEN>, inputs: &CallInputs, outcome: &mut CallOutcome, ) { @@ -1098,7 +2267,7 @@ impl Inspector> for InspectorStack { fn create( &mut self, - context: &mut EthEvmContext<&mut dyn DatabaseExt>, + context: &mut FoundryContextFor<'_, FEN>, create: &mut CreateInputs, ) -> Option { self.as_mut().create(context, create) @@ -1106,7 +2275,7 @@ impl Inspector> for InspectorStack { fn create_end( &mut self, - context: &mut EthEvmContext<&mut dyn DatabaseExt>, + context: &mut FoundryContextFor<'_, FEN>, call: &CreateInputs, outcome: &mut CreateOutcome, ) { @@ -1116,36 +2285,57 @@ impl Inspector> for InspectorStack { fn initialize_interp( &mut self, interpreter: &mut Interpreter, - ecx: &mut EthEvmContext<&mut dyn DatabaseExt>, + ecx: &mut FoundryContextFor<'_, FEN>, ) { self.as_mut().initialize_interp(interpreter, ecx) } - fn log( + fn log(&mut self, ecx: &mut FoundryContextFor<'_, FEN>, log: Log) { + self.as_mut().log(ecx, log) + } + + fn log_full( &mut self, interpreter: &mut Interpreter, - ecx: &mut EthEvmContext<&mut dyn DatabaseExt>, + ecx: &mut FoundryContextFor<'_, FEN>, log: Log, ) { - self.as_mut().log(interpreter, ecx, log) + self.as_mut().log_full(interpreter, ecx, log) + } + + fn frame_start( + &mut self, + context: &mut FoundryContextFor<'_, FEN>, + frame_input: &mut FrameInput, + ) -> Option { + self.as_mut().frame_start(context, frame_input) + } + + fn frame_end( + &mut self, + context: &mut FoundryContextFor<'_, FEN>, + frame_input: &FrameInput, + frame_result: &mut FrameResult, + ) { + self.as_mut().frame_end(context, frame_input, frame_result) } fn selfdestruct(&mut self, contract: Address, target: Address, value: U256) { - self.as_mut().selfdestruct(contract, target, value); + call_inspectors!([&mut self.inner.printer], |inspector| { + Inspector::>::selfdestruct( + inspector, contract, target, value, + ) + }); } } -impl InspectorExt for InspectorStack { - fn should_use_create2_factory( - &mut self, - ecx: &mut EthEvmContext<&mut dyn DatabaseExt>, - inputs: &CreateInputs, - ) -> bool { - self.as_mut().should_use_create2_factory(ecx, inputs) +impl InspectorExt for InspectorStack { + fn should_use_create2_factory(&mut self, depth: usize, inputs: &CreateInputs) -> bool { + self.as_mut().should_use_create2_factory(depth, inputs) } - fn is_odyssey(&self) -> bool { - self.odyssey + fn get_networks(&self) -> NetworkConfigs { + self.networks } fn create2_deployer(&self) -> Address { @@ -1153,7 +2343,7 @@ impl InspectorExt for InspectorStack { } } -impl<'a> Deref for InspectorStackRefMut<'a> { +impl<'a, FEN: FoundryEvmNetwork> Deref for InspectorStackRefMut<'a, FEN> { type Target = &'a mut InspectorStackInner; fn deref(&self) -> &Self::Target { @@ -1161,13 +2351,13 @@ impl<'a> Deref for InspectorStackRefMut<'a> { } } -impl DerefMut for InspectorStackRefMut<'_> { +impl DerefMut for InspectorStackRefMut<'_, FEN> { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.inner } } -impl Deref for InspectorStack { +impl Deref for InspectorStack { type Target = InspectorStackInner; fn deref(&self) -> &Self::Target { @@ -1175,8 +2365,245 @@ impl Deref for InspectorStack { } } -impl DerefMut for InspectorStack { +impl DerefMut for InspectorStack { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.inner } } + +impl InspectorStackInner { + #[inline] + const fn refresh_static_opcode_dispatch(&mut self) { + self.refresh_static_step_dispatch(); + self.refresh_static_step_end_dispatch(); + } + + #[inline] + const fn refresh_static_step_dispatch(&mut self) { + self.static_step_dispatch = if self.edge_coverage.is_none() + && self.line_coverage.is_none() + && self.printer.is_none() + && self.revert_diag.is_none() + && self.script_execution_inspector.is_none() + && self.tracer.is_none() + { + if self.fuzzer.is_some() { + OpcodeStepDispatch::FuzzerOnly + } else { + OpcodeStepDispatch::None + } + } else { + OpcodeStepDispatch::General + }; + } + + #[inline] + const fn refresh_static_step_end_dispatch(&mut self) { + self.has_static_step_end_inspectors = self.chisel_state.is_some() + || self.printer.is_some() + || self.revert_diag.is_some() + || self.tracer.is_some(); + } + + /// Derive the next `--batch` CREATE2 salt and advance the per-batch counter. + /// The per-inspector random seed is lazily initialized on first use. + fn next_batch_create_salt(&mut self, chain_id: u64, nonce: u64) -> U256 { + let process_salt = *self.batch_rewrite_process_salt.get_or_insert_with(rand::random); + let counter = self.batch_create_counter; + self.batch_create_counter = counter.wrapping_add(1); + compute_batch_create_salt(process_salt, chain_id, nonce, counter) + } + + /// Whether this frame duplicates the outer frame that entered an isolated transaction. + fn is_inner_context_root(&self, depth: usize) -> bool { + self.in_inner_context + && self.inner_context_data.as_ref().is_some_and(|inner| depth == inner.root_depth) + } + + /// Gives a fresh transaction its own restore tracking, independent of the suspended parent. + fn with_snapshot_tracking( + &mut self, + cheats: &mut Cheatcodes, + f: impl FnOnce(&mut Self, &mut Cheatcodes) -> R, + ) -> R { + let tracking = std::mem::replace(&mut cheats.track_isolated_snapshots, true); + let isolation = std::mem::replace(&mut cheats.in_isolation_context, false); + let capture = std::mem::replace(&mut cheats.capture_isolated_snapshot_restore, true); + let restores = std::mem::take(&mut cheats.isolated_snapshot_restores); + let pending = cheats.pending_isolated_snapshot_journal.take(); + let frames = std::mem::take(&mut self.isolated_frame_checkpoints); + let result = f(self, cheats); + self.isolated_frame_checkpoints = frames; + cheats.pending_isolated_snapshot_journal = pending; + cheats.isolated_snapshot_restores = restores; + cheats.capture_isolated_snapshot_restore = capture; + cheats.in_isolation_context = isolation; + cheats.track_isolated_snapshots = tracking; + result + } +} + +/// Derive the CREATE2 salt used by `--batch` CREATE rewrites. +/// +/// Mixes a per-inspector random seed, the chain id, the caller nonce, and a per-batch counter +/// so that two simulations launched at the same on-chain state produce distinct salts and +/// do not collide at the Arachnid factory. +fn compute_batch_create_salt(process_salt: u64, chain_id: u64, nonce: u64, counter: u64) -> U256 { + let mut buf = [0u8; 32]; + buf[0..8].copy_from_slice(&process_salt.to_be_bytes()); + buf[8..16].copy_from_slice(&chain_id.to_be_bytes()); + buf[16..24].copy_from_slice(&nonce.to_be_bytes()); + buf[24..32].copy_from_slice(&counter.to_be_bytes()); + keccak256(buf).into() +} + +/// Redirects `call` to the implementation set with `vm.mockFunction`, if one matches. +fn apply_mocked_function( + cheatcodes: &Cheatcodes, + ecx: &mut FoundryContextFor<'_, FEN>, + call: &mut CallInputs, +) { + if let Some(mocks) = cheatcodes.mocked_functions.get(&call.bytecode_address) { + let input_bytes = call.input.bytes(ecx); + // Check if any mock function set for call data or if catch-all mock function set + // for selector. + if let Some(target) = mocks + .get(&input_bytes) + .or_else(|| input_bytes.get(..4).and_then(|selector| mocks.get(selector))) + { + call.bytecode_address = *target; + + let target = + ecx.journal_mut().load_account_with_code(*target).expect("failed to load account"); + call.known_bytecode = + (target.info.code_hash(), target.info.code.clone().unwrap_or_default()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn opcode_dispatch_defaults_to_no_static_inspectors() { + let stack = InspectorStackInner::default(); + + assert_eq!(stack.static_step_dispatch, OpcodeStepDispatch::None); + assert!(!stack.has_static_step_end_inspectors); + } + + #[test] + fn opcode_dispatch_uses_fuzzer_fast_path_when_fuzzer_is_only_static_step_inspector() { + let mut stack = InspectorStack::::new(); + stack.set_fuzzer(Fuzzer::new(16, None)); + + assert_eq!(stack.inner.static_step_dispatch, OpcodeStepDispatch::FuzzerOnly); + assert!(!stack.inner.has_static_step_end_inspectors); + + stack.collect_line_coverage(true); + assert_eq!(stack.inner.static_step_dispatch, OpcodeStepDispatch::General); + + stack.collect_line_coverage(false); + assert_eq!(stack.inner.static_step_dispatch, OpcodeStepDispatch::FuzzerOnly); + } + + #[test] + fn opcode_dispatch_tracks_general_step_and_step_end_inspectors() { + let mut stack = InspectorStack::::new(); + + stack.print(true); + assert_eq!(stack.inner.static_step_dispatch, OpcodeStepDispatch::General); + assert!(stack.inner.has_static_step_end_inspectors); + + stack.print(false); + assert_eq!(stack.inner.static_step_dispatch, OpcodeStepDispatch::None); + assert!(!stack.inner.has_static_step_end_inspectors); + + stack.tracing_requirements(TraceRequirements::none().with_calls(true)); + assert_eq!(stack.inner.static_step_dispatch, OpcodeStepDispatch::General); + assert!(stack.inner.has_static_step_end_inspectors); + + stack.tracing_requirements(TraceRequirements::none()); + assert_eq!(stack.inner.static_step_dispatch, OpcodeStepDispatch::None); + assert!(!stack.inner.has_static_step_end_inspectors); + + stack.set_chisel(0); + assert_eq!(stack.inner.static_step_dispatch, OpcodeStepDispatch::None); + assert!(stack.inner.has_static_step_end_inspectors); + } + + #[test] + fn opcode_dispatch_tracks_script_and_edge_coverage_inspectors() { + let mut stack = InspectorStack::::new(); + + stack.script(Address::with_last_byte(1)); + assert_eq!(stack.inner.static_step_dispatch, OpcodeStepDispatch::General); + assert!(!stack.inner.has_static_step_end_inspectors); + + let mut stack = InspectorStack::::new(); + stack.collect_edge_coverage(true); + assert_eq!(stack.inner.static_step_dispatch, OpcodeStepDispatch::General); + stack.collect_edge_coverage(false); + assert_eq!(stack.inner.static_step_dispatch, OpcodeStepDispatch::None); + } + + #[test] + fn revert_diagnostic_frames_remain_balanced() { + let mut inspector = RevertDiagnostic::default(); + inspector.frame_start(); + inspector.set_trace_node(0); + inspector.frame_start(); + inspector.set_trace_node(1); + inspector.frame_end(); + inspector.frame_end(); + + assert!(inspector.into_diagnostics().is_empty()); + } + + #[test] + fn distinct_salts_across_simulations_at_same_nonce() { + // Two "simulations" with different per-inspector seeds but identical on-chain state. + let a = compute_batch_create_salt(0xabcd_ef01_2345_6789, 1, 5, 0); + let b = compute_batch_create_salt(0x1122_3344_5566_7788, 1, 5, 0); + assert_ne!(a, b); + } + + #[test] + fn counter_changes_salt() { + let a = compute_batch_create_salt(1, 1, 5, 0); + let b = compute_batch_create_salt(1, 1, 5, 1); + assert_ne!(a, b); + } + + #[test] + fn chain_id_changes_salt() { + let a = compute_batch_create_salt(1, 1, 5, 0); + let b = compute_batch_create_salt(1, 2, 5, 0); + assert_ne!(a, b); + } + + #[test] + fn deterministic_for_same_inputs() { + let a = compute_batch_create_salt(42, 1, 5, 7); + let b = compute_batch_create_salt(42, 1, 5, 7); + assert_eq!(a, b); + } + + #[test] + fn distinct_create2_addresses_across_inspector_instances_at_same_onchain_state() { + // Two fresh inspectors fed identical (chain_id, nonce) must produce CREATE2 addresses + // that differ when routed through the same factory with the same init code. + let factory = Address::with_last_byte(0x42); + let init_code = b"\x60\x80\x60\x40".as_slice(); + + let mut a = InspectorStackInner::default(); + let mut b = InspectorStackInner::default(); + let salt_a = a.next_batch_create_salt(1, 5).to_be_bytes::<32>(); + let salt_b = b.next_batch_create_salt(1, 5).to_be_bytes::<32>(); + + let addr_a = factory.create2_from_code(salt_a, init_code); + let addr_b = factory.create2_from_code(salt_b, init_code); + assert_ne!(addr_a, addr_b); + } +} diff --git a/crates/evm/evm/src/inspectors/tempo_labels.rs b/crates/evm/evm/src/inspectors/tempo_labels.rs new file mode 100644 index 0000000000000..a16a6c4cc70ec --- /dev/null +++ b/crates/evm/evm/src/inspectors/tempo_labels.rs @@ -0,0 +1,68 @@ +use alloy_primitives::{U256, keccak256, map::AddressMap}; +use foundry_evm_core::backend::DatabaseError; +use revm::{ + Database, Inspector, + context::ContextTr, + inspector::JournalExt, + interpreter::{CallInputs, CallOutcome, interpreter::EthInterpreter}, +}; +use tempo_primitives::TempoAddressExt; + +// Limit labels to 256 bytes so long names require at most eight additional storage reads. +const MAX_NAME_BYTES: usize = 256; + +/// Inspector that labels TIP20 token precompile addresses with their on-chain names. +/// +/// During execution, when a call targets a TIP20 address, this inspector reads the token's +/// name from storage and records the `address -> name` mapping. These labels are later merged +/// into trace output for better readability. +#[derive(Default, Clone, Debug)] +pub struct TempoLabels { + pub(crate) labels: AddressMap, +} + +impl Inspector for TempoLabels +where + D: Database, + CTX: ContextTr, + CTX::Journal: JournalExt, +{ + fn call(&mut self, ctx: &mut CTX, inputs: &mut CallInputs) -> Option { + if inputs.transfer_to().is_tip20() && !self.labels.contains_key(&inputs.transfer_to()) { + let name = 'decode: { + let db = ctx.db_mut(); + let address = inputs.transfer_to(); + let slot = tempo_precompiles::tip20::slots::NAME; + let Ok(value) = db.storage(address, slot) else { break 'decode None }; + let bytes = value.to_be_bytes::<32>(); + if bytes[31] & 1 == 0 { + let len = usize::from(bytes[31] / 2); + break 'decode (1..=31) + .contains(&len) + .then(|| String::from_utf8_lossy(&bytes[..len]).into_owned()); + } + + // Long strings store length * 2 + 1 in the base slot and data at keccak256(slot). + let len = value >> 1usize; + if len < U256::from(32) || len > U256::from(MAX_NAME_BYTES) { + break 'decode None; + } + let len = len.to::(); + let start = Into::::into(keccak256(slot.to_be_bytes::<32>())); + let mut data = Vec::with_capacity(len); + for i in 0..len.div_ceil(32) { + let Ok(chunk) = db.storage(address, start + U256::from(i)) else { + break 'decode None; + }; + let chunk = chunk.to_be_bytes::<32>(); + data.extend_from_slice(&chunk[..(len - data.len()).min(32)]); + } + Some(String::from_utf8_lossy(&data).into_owned()) + } + .unwrap_or_else(|| "TIP20".to_string()); + self.labels.insert(inputs.transfer_to(), name); + } + + None + } +} diff --git a/crates/evm/evm/src/lib.rs b/crates/evm/evm/src/lib.rs index bdca9d2eaa6ce..660b10ea41d4b 100644 --- a/crates/evm/evm/src/lib.rs +++ b/crates/evm/evm/src/lib.rs @@ -3,7 +3,7 @@ //! Main Foundry EVM backend abstractions. #![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] +#![cfg_attr(docsrs, feature(doc_cfg))] #[macro_use] extern crate tracing; @@ -11,11 +11,14 @@ extern crate tracing; pub mod executors; pub mod inspectors; +pub use foundry_evm_core as core; pub use foundry_evm_core::{ - Env, EnvMut, EvmEnv, InspectorExt, backend, constants, decode, fork, opts, utils, + EvmEnv, FoundryInspectorExt, InspectorExt, backend, constants, decode, fork, hardfork, opts, + utils, }; pub use foundry_evm_coverage as coverage; pub use foundry_evm_fuzz as fuzz; +pub use foundry_evm_hardforks as hardforks; pub use foundry_evm_traces as traces; // TODO: We should probably remove these, but it's a pretty big breaking change. diff --git a/crates/evm/fuzz/Cargo.toml b/crates/evm/fuzz/Cargo.toml index 452eb49790f9e..22befe022eea2 100644 --- a/crates/evm/fuzz/Cargo.toml +++ b/crates/evm/fuzz/Cargo.toml @@ -21,6 +21,8 @@ foundry-evm-core.workspace = true foundry-evm-coverage.workspace = true foundry-evm-traces.workspace = true +solar.workspace = true + alloy-dyn-abi = { workspace = true, features = ["arbitrary", "eip712"] } alloy-json-abi.workspace = true alloy-primitives = { workspace = true, features = [ @@ -48,3 +50,19 @@ rand.workspace = true serde.workspace = true thiserror.workspace = true tracing.workspace = true + +[features] +default = ["optimism"] +monad = ["foundry-evm-core/monad"] +base = [ + "foundry-common/base", + "foundry-evm-core/base", + "foundry-evm-coverage/base", + "foundry-evm-traces/base", +] +optimism = [ + "foundry-common/optimism", + "foundry-evm-core/optimism", + "foundry-evm-coverage/optimism", + "foundry-evm-traces/optimism", +] diff --git a/crates/evm/fuzz/src/inspector.rs b/crates/evm/fuzz/src/inspector.rs index 86b096c4f8fb7..5ffc0aa06d591 100644 --- a/crates/evm/fuzz/src/inspector.rs +++ b/crates/evm/fuzz/src/inspector.rs @@ -1,40 +1,85 @@ -use crate::{invariant::RandomCallGenerator, strategies::EvmFuzzState}; +use crate::invariant::RandomCallGenerator; +use alloy_primitives::{Address, B256, Bytes, U256, map::AddressMap}; +use foundry_common::mapping_slots::{ + MappingSlots, PendingMappingHash, capture_hash as capture_mapping_hash, + record_hash as record_mapping_hash, step as mapping_step, +}; +use foundry_evm_core::constants::CHEATCODE_ADDRESS; use revm::{ Inspector, - context::{ContextTr, Transaction}, - inspector::JournalExt, - interpreter::{CallInput, CallInputs, CallOutcome, CallScheme, Interpreter}, + context::{ContextTr, JournalTr, Transaction}, + interpreter::{CallInput, CallInputs, CallOutcome, CallScheme, CallValue, Interpreter}, }; +/// A sub-call observed by the [`Fuzzer`] inspector. +/// +/// `depth` is 1-indexed relative to the top-level call: depth 1 is a direct call +/// from the top-level callee, depth 2 is a sub-call of that call, and so on. The +/// top-level call itself is never recorded. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ObservedCall { + pub depth: u32, + pub caller: Address, + pub target: Address, + pub calldata: Bytes, + pub value: Option, +} + /// An inspector that can fuzz and collect data for that effect. #[derive(Clone, Debug)] pub struct Fuzzer { - /// Given a strategy, it generates a random call. - pub call_generator: Option, /// If set, it collects `stack` and `memory` values for fuzzing purposes. pub collect: bool, - /// If `collect` is set, we store the collected values in this fuzz dictionary. - pub fuzz_state: EvmFuzzState, + /// Given a strategy, it generates a random call. + pub call_generator: Option, + /// If `collect` is set, we store collected values until the invariant worker drains them. + pub collected_values: Vec, + /// Maximum number of stack words staged before the invariant worker drains them. + pub max_collected_values: usize, + /// Mapping accesses observed during execution, used for storage slot sampling. + pub mapping_slots: Option>, + /// A 64-byte Keccak operation waiting to be recorded after execution. + pending_mapping_hash: Option, + /// Whether sub-calls should be buffered for later corpus seeding. + record_calls: bool, + /// Sub-calls observed since the last drain. + observed_calls: Vec, + /// Current EVM call depth. 0 means no active call, 1 means top-level call. + call_depth: u32, + /// Additional network-specific cheatcode addresses that must not be overridden. + extra_cheatcode_addresses: &'static [Address], } -impl Inspector for Fuzzer -where - CTX: ContextTr, -{ +impl Inspector for Fuzzer { #[inline] fn step(&mut self, interp: &mut Interpreter, _context: &mut CTX) { + self.capture_mapping_hash(interp); // We only collect `stack` and `memory` data before and after calls. if self.collect { self.collect_data(interp); - self.collect = false; } } #[inline] + fn step_end(&mut self, interp: &mut Interpreter, _context: &mut CTX) { + self.record_mapping_hash(interp); + } + fn call(&mut self, ecx: &mut CTX, inputs: &mut CallInputs) -> Option { // We don't want to override the very first call made to the test contract. - if self.call_generator.is_some() && ecx.tx().caller() != inputs.caller { - self.override_call(inputs); + if self.call_generator.is_some() && ecx.tx().caller() != inputs.transfer_from() { + self.override_call(ecx, inputs); + } + + self.call_depth = self.call_depth.saturating_add(1); + if self.should_record_observed_call(inputs.scheme) { + self.observed_calls.push(ObservedCall { + depth: self.call_depth - 1, + caller: inputs.transfer_from(), + target: inputs.transfer_to(), + calldata: inputs.input.bytes(ecx), + value: inputs.transfer_value().filter(|value| !value.is_zero()), + }); } // We only collect `stack` and `memory` data before and after calls. @@ -44,22 +89,116 @@ where None } - #[inline] fn call_end(&mut self, _context: &mut CTX, _inputs: &CallInputs, _outcome: &mut CallOutcome) { if let Some(ref mut call_generator) = self.call_generator { - call_generator.used = false; + // Decrement depth when any call ends while inside an override + if call_generator.override_depth > 0 { + call_generator.override_depth -= 1; + } } // We only collect `stack` and `memory` data before and after calls. // this will be turned off on the next `step` self.collect = true; + + self.call_depth = self.call_depth.saturating_sub(1); } } impl Fuzzer { + fn capture_mapping_hash(&mut self, interpreter: &Interpreter) { + if let Some(mapping_slots) = &mut self.mapping_slots { + mapping_step(mapping_slots, interpreter); + self.pending_mapping_hash = capture_mapping_hash(interpreter); + } + } + + fn record_mapping_hash(&mut self, interpreter: &Interpreter) { + if let Some(pending) = self.pending_mapping_hash.take() + && interpreter.bytecode.action.is_none() + && let Some(mapping_slots) = &mut self.mapping_slots + { + record_mapping_hash(mapping_slots, interpreter, pending); + } + } + + /// Constructs a new `Fuzzer` inspector. + pub const fn new( + max_collected_values: usize, + mapping_slots: Option>, + ) -> Self { + Self { + collect: true, + call_generator: None, + collected_values: Vec::new(), + max_collected_values, + mapping_slots, + pending_mapping_hash: None, + record_calls: false, + observed_calls: Vec::new(), + call_depth: 0, + extra_cheatcode_addresses: &[], + } + } + + /// Sets additional network-specific cheatcode addresses that must not be overridden. + pub const fn with_extra_cheatcode_addresses(mut self, addresses: &'static [Address]) -> Self { + self.extra_cheatcode_addresses = addresses; + self + } + + /// Enables or disables sub-call buffering. + pub const fn with_call_recording(mut self, record_calls: bool) -> Self { + self.record_calls = record_calls; + self + } + + /// Enables or disables sub-call buffering on an existing inspector. + pub const fn set_call_recording(&mut self, record_calls: bool) { + self.record_calls = record_calls; + } + + /// Returns the buffered sub-calls observed since the last drain. + pub fn take_observed_calls(&mut self) -> Vec { + std::mem::take(&mut self.observed_calls) + } + + #[cfg(test)] + fn record_observed_call( + &mut self, + caller: Address, + target: Address, + calldata: Bytes, + value: Option, + scheme: CallScheme, + ) { + if self.should_record_observed_call(scheme) { + self.observed_calls.push(ObservedCall { + depth: self.call_depth - 1, + caller, + target, + calldata, + value, + }); + } + } + + #[inline] + const fn should_record_observed_call(&self, scheme: CallScheme) -> bool { + self.record_calls && self.call_depth > 1 && scheme.is_call() + } + + #[inline] + fn is_cheatcode_address(&self, address: Address) -> bool { + address == CHEATCODE_ADDRESS || self.extra_cheatcode_addresses.contains(&address) + } + /// Collects `stack` and `memory` values into the fuzz dictionary. + #[cold] fn collect_data(&mut self, interpreter: &Interpreter) { - self.fuzz_state.collect_values(interpreter.stack.data().iter().copied().map(Into::into)); + let remaining = self.max_collected_values.saturating_sub(self.collected_values.len()); + self.collected_values + .extend(interpreter.stack.data().iter().take(remaining).copied().map(B256::from)); // TODO: disabled for now since it's flooding the dictionary // for index in 0..interpreter.shared_memory.len() / 32 { @@ -68,27 +207,218 @@ impl Fuzzer { // state.insert(slot); // } + + self.collect = false; } - /// Overrides an external call and tries to call any method of msg.sender. - fn override_call(&mut self, call: &mut CallInputs) { - if let Some(ref mut call_generator) = self.call_generator { - // We only override external calls which are not coming from the test contract. - if call.caller != call_generator.test_address - && call.scheme == CallScheme::Call - && !call_generator.used + /// Overrides an external call to simulate reentrancy attacks. + /// + /// This function detects reentrancy vulnerabilities by replacing external calls + /// with callbacks that reenter the caller contract. + /// + /// For calls with value (ETH transfers): + /// 1. Performs the ETH transfer via the journal first + /// 2. Replaces the call with a reentrant callback (value = 0) + /// + /// For calls without value: + /// - Replaces the call entirely with a reentrant callback + /// + /// This simulates malicious contracts that immediately reenter when called. + fn override_call(&mut self, ecx: &mut CTX, call: &mut CallInputs) { + let target_is_cheatcode = self.is_cheatcode_address(call.transfer_to()); + let Some(ref mut call_generator) = self.call_generator else { + return; + }; + + // Skip if: + // - Caller is test contract (don't override the initial calls from the test) + // - Not a CALL scheme (only override CALLs, not STATICCALLs, DELEGATECALLs, etc.) + // - Inside an override (prevent recursive overrides) + // - Target is cheatcode address + // - Neither caller nor target is a handler contract + // + // We override calls when either the caller OR target is a handler. This covers: + // 1. EtherStore pattern: handler sends ETH out, attacker reenters handler + // 2. Rari pattern: external protocol sends ETH to handler, handler reenters protocol + if call.transfer_from() == call_generator.test_address + || !call.scheme.is_call() + || call_generator.override_depth > 0 + || target_is_cheatcode + { + return; + } + { + let handlers = call_generator.handler_addresses.read(); + if !handlers.contains(&call.transfer_from()) && !handlers.contains(&call.transfer_to()) { - // There's only a 30% chance that an override happens. - if let Some(tx) = call_generator.next(call.caller, call.target_address) { - call.input = CallInput::Bytes(tx.call_details.calldata.0.into()); - call.caller = tx.sender; - call.target_address = tx.call_details.target; - - // TODO: in what scenarios can the following be problematic - call.bytecode_address = tx.call_details.target; - call_generator.used = true; - } + return; } } + + // There's only a ~27% chance that an override happens (90% * 30% from strategy). + let Some(tx) = call_generator.next(call.transfer_from(), call.transfer_to()) else { + return; + }; + + // For value transfers, perform the ETH transfer before injecting the callback. + // This simulates a malicious receive() that gets the ETH and then reenters. + let value = call.transfer_value().unwrap_or_default(); + let has_value = !value.is_zero() && call.gas_limit > 2300; + if has_value + && ecx.journal_mut().transfer(call.transfer_from(), call.transfer_to(), value).is_err() + { + return; + } + + // Replace the call with a reentrant callback + call.input = CallInput::Bytes(tx.call_details.calldata); + call.caller = tx.sender; + call.target_address = tx.call_details.target; + call.bytecode_address = tx.call_details.target; + let target = ecx + .journal_mut() + .load_account_with_code(tx.call_details.target) + .expect("failed to load account"); + // Clear known_bytecode to force REVM to load bytecode from the new target. + // Without this, REVM uses cached bytecode from the original target (e.g., empty + // bytecode for EOA), causing the call to short-circuit before executing any code. + call.known_bytecode = + (target.info.code_hash(), target.info.code.clone().unwrap_or_default()); + // Clear value since ETH was already transferred above + call.value = CallValue::Transfer(alloy_primitives::U256::ZERO); + + // Track that we're inside an overridden call to avoid recursive overrides + call_generator.override_depth = 1; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::keccak256; + use foundry_evm_core::constants::MONAD_CHEATCODE_ADDRESS; + use revm::bytecode::Bytecode; + + fn fuzzer(record_calls: bool) -> Fuzzer { + Fuzzer::new(16, None).with_call_recording(record_calls) + } + + #[test] + fn network_cheatcode_addresses_are_opt_in() { + let ethereum = Fuzzer::new(16, None); + assert!(!ethereum.is_cheatcode_address(MONAD_CHEATCODE_ADDRESS)); + + let monad = + Fuzzer::new(16, None).with_extra_cheatcode_addresses(&[MONAD_CHEATCODE_ADDRESS]); + assert!(monad.is_cheatcode_address(MONAD_CHEATCODE_ADDRESS)); + } + + #[test] + fn mapping_hashes_are_recorded_outside_dictionary_collection() { + let key = B256::with_last_byte(1); + let parent = B256::with_last_byte(2); + let preimage = [key.as_slice(), parent.as_slice()].concat(); + let result = keccak256(&preimage); + let mut interpreter = + Interpreter::default().with_bytecode(Bytecode::new_raw(Bytes::from_static(&[ + revm::bytecode::opcode::KECCAK256, + ]))); + interpreter.memory.resize(64); + interpreter.memory.set(0, &preimage); + assert!(interpreter.stack.push(U256::from(64))); + assert!(interpreter.stack.push(U256::ZERO)); + + let mut fuzzer = Fuzzer::new(16, Some(AddressMap::default())); + fuzzer.collect = false; + fuzzer.capture_mapping_hash(&interpreter); + + interpreter.stack.pop().unwrap(); + interpreter.stack.pop().unwrap(); + assert!(interpreter.stack.push(result.into())); + fuzzer.record_mapping_hash(&interpreter); + + let slots = fuzzer.mapping_slots.unwrap(); + assert_eq!(slots[&Address::ZERO].seen_sha3.get(&result), Some(&(key, parent))); + } + + #[test] + fn observed_calls_are_disabled_by_default() { + let mut fuzzer = Fuzzer::new(16, None); + fuzzer.call_depth = 2; + + fuzzer.record_observed_call( + Address::repeat_byte(0xaa), + Address::repeat_byte(0x11), + Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef]), + Some(U256::ONE), + CallScheme::Call, + ); + + assert!(fuzzer.take_observed_calls().is_empty()); + } + + #[test] + fn observed_calls_skip_top_level_call() { + let mut fuzzer = fuzzer(true); + fuzzer.call_depth = 1; + + fuzzer.record_observed_call( + Address::repeat_byte(0xaa), + Address::repeat_byte(0x11), + Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef]), + None, + CallScheme::Call, + ); + + assert!(fuzzer.take_observed_calls().is_empty()); + } + + #[test] + fn observed_calls_record_subcall_depth_target_calldata_and_value() { + let mut fuzzer = fuzzer(true); + let caller = Address::repeat_byte(0x11); + let target = Address::repeat_byte(0x22); + let calldata = Bytes::from_static(&[0xca, 0xfe, 0xba, 0xbe]); + let value = Some(U256::from(7)); + fuzzer.call_depth = 3; + + fuzzer.record_observed_call(caller, target, calldata.clone(), value, CallScheme::Call); + + assert_eq!( + fuzzer.take_observed_calls(), + vec![ObservedCall { depth: 2, caller, target, calldata, value }] + ); + } + + #[test] + fn observed_calls_skip_non_call_schemes() { + let mut fuzzer = fuzzer(true); + fuzzer.call_depth = 2; + + fuzzer.record_observed_call( + Address::repeat_byte(0x11), + Address::repeat_byte(0x22), + Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef]), + None, + CallScheme::DelegateCall, + ); + + assert!(fuzzer.take_observed_calls().is_empty()); + } + + #[test] + fn take_observed_calls_drains_buffer() { + let mut fuzzer = fuzzer(true); + fuzzer.call_depth = 2; + fuzzer.record_observed_call( + Address::repeat_byte(0xaa), + Address::repeat_byte(0x33), + Bytes::from_static(&[0x12, 0x34, 0x56, 0x78]), + None, + CallScheme::Call, + ); + + assert_eq!(fuzzer.take_observed_calls().len(), 1); + assert!(fuzzer.take_observed_calls().is_empty()); } } diff --git a/crates/evm/fuzz/src/invariant/call_override.rs b/crates/evm/fuzz/src/invariant/call_override.rs index dddf591526f08..cd803c7ac9780 100644 --- a/crates/evm/fuzz/src/invariant/call_override.rs +++ b/crates/evm/fuzz/src/invariant/call_override.rs @@ -1,5 +1,5 @@ -use super::{BasicTxDetails, CallDetails}; -use alloy_primitives::Address; +use crate::{BasicTxDetails, CallDetails}; +use alloy_primitives::{Address, map::AddressSet}; use parking_lot::{Mutex, RwLock}; use proptest::{ option::weighted, @@ -9,19 +9,27 @@ use proptest::{ use std::sync::Arc; /// Given a TestRunner and a strategy, it generates calls. Used inside the Fuzzer inspector to -/// override external calls to test for potential reentrancy vulnerabilities.. +/// override external calls to test for potential reentrancy vulnerabilities. +/// +/// The key insight is that we only override calls TO handler contracts (targeted contracts). +/// This simulates a malicious contract that reenters when receiving ETH via its receive() function. #[derive(Clone, Debug)] pub struct RandomCallGenerator { /// Address of the test contract. pub test_address: Address, + /// Addresses of handler contracts that can be reentered. + /// We only inject callbacks when the call target is one of these. + pub handler_addresses: Arc>, /// Runner that will generate the call from the strategy. pub runner: Arc>, /// Strategy to be used to generate calls from `target_reference`. pub strategy: SBoxedStrategy>, /// Reference to which contract we want a fuzzed calldata from. pub target_reference: Arc>, - /// Flag to know if a call has been overridden. Don't allow nesting for now. - pub used: bool, + /// Tracks the call depth when an override is active. When > 0, we're inside an overridden + /// call and should not override nested calls. Incremented when we override a call, + /// decremented when any call ends while inside an override. + pub override_depth: usize, /// If set to `true`, consumes the next call from `last_sequence`, otherwise queries it from /// the strategy. pub replay: bool, @@ -32,21 +40,28 @@ pub struct RandomCallGenerator { impl RandomCallGenerator { pub fn new( test_address: Address, + handler_addresses: AddressSet, runner: TestRunner, strategy: impl Strategy + Send + Sync + 'static, target_reference: Arc>, ) -> Self { Self { test_address, + handler_addresses: Arc::new(RwLock::new(handler_addresses)), runner: Arc::new(Mutex::new(runner)), strategy: weighted(0.9, strategy).sboxed(), target_reference, last_sequence: Arc::default(), replay: false, - used: false, + override_depth: 0, } } + /// Check if the given address is a handler that can be reentered. + pub fn is_handler(&self, address: Address) -> bool { + self.handler_addresses.read().contains(&address) + } + /// All `self.next()` calls will now pop `self.last_sequence`. Used to replay an invariant /// failure. pub fn set_replay(&mut self, status: bool) { @@ -75,12 +90,9 @@ impl RandomCallGenerator { *self.target_reference.write() = original_caller; // `original_caller` has a 80% chance of being the `new_target`. - let choice = self - .strategy - .new_tree(&mut self.runner.lock()) - .unwrap() - .current() - .map(|call_details| BasicTxDetails { sender, call_details }); + let choice = self.strategy.new_tree(&mut self.runner.lock()).unwrap().current().map( + |call_details| BasicTxDetails { warp: None, roll: None, sender, call_details }, + ); self.last_sequence.write().push(choice.clone()); choice diff --git a/crates/evm/fuzz/src/invariant/filters.rs b/crates/evm/fuzz/src/invariant/filters.rs index f9f068161f5e6..425405e46c12d 100644 --- a/crates/evm/fuzz/src/invariant/filters.rs +++ b/crates/evm/fuzz/src/invariant/filters.rs @@ -6,7 +6,7 @@ use std::collections::BTreeMap; /// Contains which contracts are to be targeted or excluded on an invariant test through their /// artifact identifiers. -#[derive(Default)] +#[derive(Clone, Default)] pub struct ArtifactFilters { /// List of `contract_path:contract_name` along with selectors, which are to be targeted. If /// list of functions is not empty, target only those. @@ -55,7 +55,7 @@ impl ArtifactFilters { /// clashing. /// /// `address(0)` is excluded by default. -#[derive(Default)] +#[derive(Clone, Default)] pub struct SenderFilters { pub targeted: Vec
, pub excluded: Vec
, @@ -70,4 +70,10 @@ impl SenderFilters { targeted.retain(|addr| !excluded.contains(addr)); Self { targeted, excluded } } + + /// Returns whether `sender` is permitted by the configured target and exclusion filters. + pub fn allows(&self, sender: Address) -> bool { + (self.targeted.is_empty() || self.targeted.contains(&sender)) + && !self.excluded.contains(&sender) + } } diff --git a/crates/evm/fuzz/src/invariant/mod.rs b/crates/evm/fuzz/src/invariant/mod.rs index cb419f301a95c..2d23757d4fac9 100644 --- a/crates/evm/fuzz/src/invariant/mod.rs +++ b/crates/evm/fuzz/src/invariant/mod.rs @@ -1,17 +1,29 @@ -use alloy_json_abi::{Function, JsonAbi}; -use alloy_primitives::{Address, Bytes, Selector}; +use alloy_json_abi::{Event, Function, JsonAbi}; +use alloy_primitives::{Address, B256, Bytes, Selector, map::HashMap}; +use foundry_compilers::artifacts::StorageLayout; use itertools::Either; -use parking_lot::Mutex; use serde::{Deserialize, Serialize}; -use std::{collections::BTreeMap, sync::Arc}; +use std::{ + cell::{Cell, Ref, RefCell}, + collections::BTreeMap, + fmt, + rc::Rc, + sync::Arc, +}; mod call_override; pub use call_override::RandomCallGenerator; mod filters; +use crate::BasicTxDetails; pub use filters::{ArtifactFilters, SenderFilters}; use foundry_common::{ContractsByAddress, ContractsByArtifact}; -use foundry_evm_core::utils::{StateChangeset, get_function}; +use foundry_evm_core::utils::StateChangeset; + +type DynamicTargetArtifactMatchCache = + Rc>>>; +type FuzzedFunction = (Address, Function); +type FunctionLookup = HashMap; /// Contracts identified as targets during a fuzz run. /// @@ -20,15 +32,55 @@ use foundry_evm_core::utils::{StateChangeset, get_function}; #[derive(Clone, Debug)] pub struct FuzzRunIdentifiedContracts { /// Contracts identified as targets during a fuzz run. - pub targets: Arc>, + targets: Rc>, + /// Flat cache of all currently fuzzable target functions. + fuzzed_functions: Rc>>, + /// Generation counter for cached fuzzed functions. + fuzzed_functions_generation: Rc>, /// Whether target contracts are updatable or not. pub is_updatable: bool, + artifact_matches: DynamicTargetArtifactMatchCache, } impl FuzzRunIdentifiedContracts { /// Creates a new `FuzzRunIdentifiedContracts` instance. pub fn new(targets: TargetedContracts, is_updatable: bool) -> Self { - Self { targets: Arc::new(Mutex::new(targets)), is_updatable } + let fuzzed_functions = Self::flatten_fuzzed_functions(&targets); + Self { + targets: Rc::new(RefCell::new(targets)), + fuzzed_functions: Rc::new(RefCell::new(fuzzed_functions)), + fuzzed_functions_generation: Rc::new(Cell::new(0)), + is_updatable, + artifact_matches: Rc::new(RefCell::new(HashMap::default())), + } + } + + /// Borrows the current targeted contracts. + pub fn targets(&self) -> Ref<'_, TargetedContracts> { + self.targets.borrow() + } + + /// Borrows the current flat list of fuzzed target functions. + pub fn fuzzed_functions(&self) -> Ref<'_, [FuzzedFunction]> { + Ref::map(self.fuzzed_functions.borrow(), Vec::as_slice) + } + + /// Returns the current fuzzed-functions generation. + pub fn fuzzed_functions_generation(&self) -> u64 { + self.fuzzed_functions_generation.get() + } + + fn refresh_fuzzed_functions(&self) { + let fuzzed_functions = { + let targets = self.targets.borrow(); + Self::flatten_fuzzed_functions(&targets) + }; + *self.fuzzed_functions.borrow_mut() = fuzzed_functions; + self.fuzzed_functions_generation.set(self.fuzzed_functions_generation.get() + 1); + } + + fn flatten_fuzzed_functions(targets: &TargetedContracts) -> Vec { + targets.fuzzed_functions().map(|(address, function)| (*address, function.clone())).collect() } /// If targets are updatable, collect all contracts created during an invariant run (which @@ -45,7 +97,7 @@ impl FuzzRunIdentifiedContracts { return Ok(()); } - let mut targets = self.targets.lock(); + let mut targets_changed = false; for (address, account) in state_changeset { if setup_contracts.contains_key(address) { continue; @@ -59,36 +111,94 @@ impl FuzzRunIdentifiedContracts { if code.is_empty() { continue; } - let Some((artifact, contract)) = - project_contracts.find_by_deployed_code(code.original_byte_slice()) - else { - continue; - }; - let Some(functions) = - artifact_filters.get_targeted_functions(artifact, &contract.abi)? + let code_hash = code.hash_slow(); + let code = code.original_byte_slice(); + let Some(contract) = self.target_contract_for_code( + *address, + code_hash, + code, + project_contracts, + artifact_filters, + )? else { continue; }; created_contracts.push(*address); - let contract = TargetedContract { - identifier: artifact.name.clone(), - abi: contract.abi.clone(), - targeted_functions: functions, - excluded_functions: Vec::new(), - }; - targets.insert(*address, contract); + self.targets.borrow_mut().insert(*address, contract.into_targeted_contract()); + targets_changed = true; + } + if targets_changed { + self.refresh_fuzzed_functions(); } Ok(()) } + fn target_contract_for_code( + &self, + address: Address, + code_hash: B256, + code: &[u8], + project_contracts: &ContractsByArtifact, + artifact_filters: &ArtifactFilters, + ) -> eyre::Result> { + let cache_key = (address, code_hash); + if let Some(cached_match) = self.artifact_matches.borrow().get(&cache_key) { + return Ok(cached_match.clone()); + } + + let cached_match = if let Some((artifact, contract_data)) = + project_contracts.find_by_deployed_code(code) + { + artifact_filters.get_targeted_functions(artifact, &contract_data.abi)?.map( + |targeted_functions| CachedTargetContract { + identifier: artifact.name.clone(), + abi: contract_data.abi.clone(), + targeted_functions, + storage_layout: contract_data.storage_layout.as_ref().map(Arc::clone), + event_lookup: Arc::new(TargetedContractEvents::new(&contract_data.abi)), + }, + ) + } else { + None + }; + self.artifact_matches.borrow_mut().insert(cache_key, cached_match.clone()); + Ok(cached_match) + } + /// Clears targeted contracts created during an invariant run. pub fn clear_created_contracts(&self, created_contracts: Vec
) { + let mut targets_changed = false; if !created_contracts.is_empty() { - let mut targets = self.targets.lock(); + let mut targets = self.targets.borrow_mut(); for addr in &created_contracts { - targets.remove(addr); + targets_changed |= targets.remove(addr).is_some(); } } + if targets_changed { + self.refresh_fuzzed_functions(); + } + } +} + +#[derive(Clone, Debug)] +struct CachedTargetContract { + identifier: String, + abi: JsonAbi, + targeted_functions: Vec, + storage_layout: Option>, + event_lookup: Arc, +} + +impl CachedTargetContract { + fn into_targeted_contract(self) -> TargetedContract { + TargetedContract::from_parts( + self.identifier, + self.abi, + self.targeted_functions, + Vec::new(), + self.storage_layout, + self.event_lookup, + ) } } @@ -105,15 +215,24 @@ impl TargetedContracts { Self::default() } - /// Returns fuzzed contract abi and fuzzed function from address and provided calldata. + /// Returns fuzzed contract and fuzzed function from address and provided calldata. /// /// Used to decode return values and logs in order to add values into fuzz dictionary. - pub fn fuzzed_artifacts(&self, tx: &BasicTxDetails) -> (Option<&JsonAbi>, Option<&Function>) { + pub fn fuzzed_artifacts( + &self, + tx: &BasicTxDetails, + ) -> (Option<&TargetedContract>, Option<&Function>) { match self.inner.get(&tx.call_details.target) { - Some(c) => ( - Some(&c.abi), - c.abi.functions().find(|f| f.selector() == tx.call_details.calldata[..4]), - ), + Some(c) => { + let function = tx + .call_details + .calldata + .get(..4) + .and_then(|selector| <[u8; 4]>::try_from(selector).ok()) + .map(Selector::from) + .and_then(|selector| c.function_by_selector(selector)); + (Some(c), function) + } None => (None, None), } } @@ -130,7 +249,13 @@ impl TargetedContracts { /// Returns whether the given transaction can be replayed or not with known contracts. pub fn can_replay(&self, tx: &BasicTxDetails) -> bool { match self.inner.get(&tx.call_details.target) { - Some(c) => c.abi.functions().any(|f| f.selector() == tx.call_details.calldata[..4]), + Some(c) => tx + .call_details + .calldata + .get(..4) + .and_then(|selector| <[u8; 4]>::try_from(selector).ok()) + .map(Selector::from) + .is_some_and(|selector| c.fuzzed_function_by_selector(selector).is_some()), None => false, } } @@ -138,12 +263,27 @@ impl TargetedContracts { /// Identifies fuzzed contract and function based on given tx details and returns unique metric /// key composed from contract identifier and function name. pub fn fuzzed_metric_key(&self, tx: &BasicTxDetails) -> Option { - self.inner.get(&tx.call_details.target).and_then(|contract| { + tx.call_details + .calldata + .get(..4) + .and_then(|selector| <[u8; 4]>::try_from(selector).ok()) + .map(Selector::from) + .and_then(|selector| { + self.fuzzed_metric_key_for_selector(tx.call_details.target, selector) + }) + } + + /// Identifies fuzzed contract and function from target and selector and returns unique metric + /// key composed from contract identifier and function name. + pub fn fuzzed_metric_key_for_selector( + &self, + target: Address, + selector: Selector, + ) -> Option { + self.inner.get(&target).and_then(|contract| { contract - .abi - .functions() - .find(|f| f.selector() == tx.call_details.calldata[..4]) - .map(|function| format!("{}.{}", contract.identifier.clone(), function.name)) + .function_by_selector(selector) + .map(|function| format!("{}.{}", contract.identifier.as_str(), function.name)) }) } } @@ -173,33 +313,107 @@ pub struct TargetedContract { pub targeted_functions: Vec, /// The excluded functions of the contract. pub excluded_functions: Vec, + /// The contract's storage layout, if available. + pub storage_layout: Option>, + /// Contract events indexed by topic0 and indexed-topic count for log dictionary decoding. + pub event_lookup: Arc, + functions_by_selector: FunctionLookup, + fuzzed_functions_by_selector: FunctionLookup, } impl TargetedContract { /// Returns a new `TargetedContract` instance. pub fn new(identifier: String, abi: JsonAbi) -> Self { - Self { identifier, abi, targeted_functions: Vec::new(), excluded_functions: Vec::new() } + let event_lookup = Arc::new(TargetedContractEvents::new(&abi)); + Self::from_parts(identifier, abi, Vec::new(), Vec::new(), None, event_lookup) + } + + fn from_parts( + identifier: String, + abi: JsonAbi, + targeted_functions: Vec, + excluded_functions: Vec, + storage_layout: Option>, + event_lookup: Arc, + ) -> Self { + let mut contract = Self { + identifier, + abi, + targeted_functions, + excluded_functions, + storage_layout, + event_lookup, + functions_by_selector: FunctionLookup::default(), + fuzzed_functions_by_selector: FunctionLookup::default(), + }; + contract.rebuild_function_lookups(); + contract + } + + /// Determines contract storage layout from project contracts. Needs `storageLayout` to be + /// enabled as extra output in project configuration. + pub fn with_project_contracts(mut self, project_contracts: &ContractsByArtifact) -> Self { + if let Some((src, name)) = self.identifier.split_once(':') + && let Some((_, contract_data)) = project_contracts.iter().find(|(artifact, _)| { + artifact.name == name && artifact.source.as_path().ends_with(src) + }) + { + self.storage_layout = contract_data.storage_layout.as_ref().map(Arc::clone); + } + self } /// Helper to retrieve functions to fuzz for specified abi. - /// Returns specified targeted functions if any, else mutable abi functions that are not - /// marked as excluded. + /// Returns specified targeted functions if any, else mutable abi functions, always skipping + /// functions marked as excluded. pub fn abi_fuzzed_functions(&self) -> impl Iterator { - if !self.targeted_functions.is_empty() { - Either::Left(self.targeted_functions.iter()) - } else { + if self.targeted_functions.is_empty() { Either::Right(self.abi.functions().filter(|&func| { !matches!( func.state_mutability, alloy_json_abi::StateMutability::Pure | alloy_json_abi::StateMutability::View ) && !self.excluded_functions.contains(func) })) + } else { + Either::Left( + self.targeted_functions + .iter() + .filter(|func| !self.excluded_functions.contains(func)), + ) } } + pub fn rebuild_function_lookups(&mut self) { + let functions_by_selector = + self.abi.functions().fold(FunctionLookup::default(), |mut functions, function| { + functions.entry(function.selector()).or_insert_with(|| function.clone()); + functions + }); + let fuzzed_functions_by_selector = self.abi_fuzzed_functions().fold( + FunctionLookup::default(), + |mut functions, function| { + functions.entry(function.selector()).or_insert_with(|| function.clone()); + functions + }, + ); + self.functions_by_selector = functions_by_selector; + self.fuzzed_functions_by_selector = fuzzed_functions_by_selector; + } + + /// Returns any ABI function for the given selector. + pub fn function_by_selector(&self, selector: Selector) -> Option<&Function> { + self.functions_by_selector.get(&selector) + } + + /// Returns a fuzzable function for the given selector. + pub fn fuzzed_function_by_selector(&self, selector: Selector) -> Option<&Function> { + self.fuzzed_functions_by_selector.get(&selector) + } + /// Returns the function for the given selector. pub fn get_function(&self, selector: Selector) -> eyre::Result<&Function> { - get_function(&self.identifier, selector, &self.abi) + self.function_by_selector(selector) + .ok_or_else(|| eyre::eyre!("{} does not have the selector {selector}", self.identifier)) } /// Adds the specified selectors to the targeted functions. @@ -215,26 +429,65 @@ impl TargetedContract { self.targeted_functions.push(self.get_function(selector)?.clone()); } } + self.rebuild_function_lookups(); Ok(()) } } -/// Details of a transaction generated by invariant strategy for fuzzing a target. -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct BasicTxDetails { - // Transaction sender address. - pub sender: Address, - // Transaction call details. - pub call_details: CallDetails, +/// Events for a targeted contract, pre-indexed for log dictionary decoding. +#[derive(Clone, Debug, Default)] +pub struct TargetedContractEvents { + by_topic: HashMap<(B256, usize), Vec>, + anonymous: Vec, +} + +impl TargetedContractEvents { + fn new(abi: &JsonAbi) -> Self { + let mut events = Self::default(); + for (order, event) in abi.events().enumerate() { + let event = TargetedContractEvent { order, event: event.clone() }; + if event.event.anonymous { + events.anonymous.push(event); + } else { + let indexed_count = event.event.inputs.iter().filter(|input| input.indexed).count(); + events + .by_topic + .entry((event.event.selector(), indexed_count)) + .or_default() + .push(event); + } + } + events + } + + pub fn by_topic( + &self, + selector: &B256, + indexed_count: usize, + ) -> Option<&[TargetedContractEvent]> { + self.by_topic.get(&(*selector, indexed_count)).map(Vec::as_slice) + } + + pub fn anonymous(&self) -> &[TargetedContractEvent] { + &self.anonymous + } +} + +/// Event with its flattened ABI order for preserving log decode priority. +#[derive(Clone, Debug)] +pub struct TargetedContractEvent { + event: Event, + order: usize, } -/// Call details of a transaction generated to fuzz invariant target. -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct CallDetails { - // Address of target contract. - pub target: Address, - // The data of the transaction. - pub calldata: Bytes, +impl TargetedContractEvent { + pub const fn order(&self) -> usize { + self.order + } + + pub const fn event(&self) -> &Event { + &self.event + } } /// Test contract which is testing its invariants. @@ -242,10 +495,474 @@ pub struct CallDetails { pub struct InvariantContract<'a> { /// Address of the test contract. pub address: Address, - /// Invariant function present in the test contract. - pub invariant_function: &'a Function, + /// Name of the test contract. + pub name: &'a str, + /// Invariant functions to assert against, paired with their `fail_on_revert` config. + /// Stored in **source declaration order** so failure-event attribution and report + /// rendering match user expectations. + pub invariant_fns: Vec<(&'a Function, bool)>, + /// Index into [`Self::invariant_fns`] of the stable campaign anchor. Boolean invariant + /// suites use a deterministic contract-local anchor so test filters do not affect + /// corpus/failure namespaces. + pub anchor_idx: usize, /// If true, `afterInvariant` function is called after each invariant run. pub call_after_invariant: bool, /// ABI of the test contract. pub abi: &'a JsonAbi, + /// ABI calldata for each invariant function. + pub invariant_calldata: Vec, +} + +impl<'a> InvariantContract<'a> { + /// Creates a new invariant contract. + /// + /// Caller must ensure `invariant_fns` is non-empty and `anchor_idx < invariant_fns.len()`. + pub fn new( + address: Address, + name: &'a str, + invariant_fns: Vec<(&'a Function, bool)>, + anchor_idx: usize, + call_after_invariant: bool, + abi: &'a JsonAbi, + ) -> Self { + let invariant_calldata = + invariant_fns.iter().map(|(func, _)| func.selector().into()).collect(); + Self { + address, + name, + invariant_fns, + anchor_idx, + call_after_invariant, + abi, + invariant_calldata, + } + } + + /// Returns the stable campaign anchor. + pub fn anchor(&self) -> &'a Function { + self.invariant_fns[self.anchor_idx].0 + } + + /// Returns cached calldata for the invariant at `idx`. + pub fn invariant_calldata(&self, idx: usize) -> Bytes { + self.invariant_calldata[idx].clone() + } + + /// Returns cached calldata for the stable campaign anchor. + pub fn anchor_calldata(&self) -> Bytes { + self.invariant_calldata(self.anchor_idx) + } + + /// Returns true if this is an optimization mode invariant (returns int256). + pub fn is_optimization(&self) -> bool { + is_optimization_invariant(self.anchor()) + } +} + +/// Settings that determine the validity of a persisted invariant counterexample. +/// +/// When a counterexample is replayed, it's only valid if the same contracts, selectors, +/// senders, and fail_on_revert settings are used. Changes to unrelated code (e.g., adding +/// a log statement) should not invalidate the counterexample. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct InvariantSettings { + /// Target contracts with their addresses and identifiers. + pub target_contracts: BTreeMap, + /// Target selectors per contract address. + pub target_selectors: BTreeMap>, + /// Target senders for the invariant test. + pub target_senders: Vec
, + /// Excluded senders for the invariant test. + pub excluded_senders: Vec
, + /// Whether the test should fail on any revert. + pub fail_on_revert: bool, +} + +impl InvariantSettings { + /// Creates new invariant settings from the given components. + pub fn new( + targeted_contracts: &TargetedContracts, + sender_filters: &SenderFilters, + fail_on_revert: bool, + ) -> Self { + let mut target_contracts = BTreeMap::new(); + let mut target_selectors = BTreeMap::new(); + for (addr, contract) in &targeted_contracts.inner { + target_contracts.insert(*addr, contract.identifier.clone()); + target_selectors + .insert(*addr, contract.abi_fuzzed_functions().map(|f| f.selector()).collect()); + } + + let mut target_senders = sender_filters.targeted.clone(); + target_senders.sort_unstable(); + + let mut excluded_senders = sender_filters.excluded.clone(); + excluded_senders.sort_unstable(); + + Self { + target_contracts, + target_selectors, + target_senders, + excluded_senders, + fail_on_revert, + } + } + + /// Compares these settings with another and returns a description of what changed. + /// Returns `None` if the settings are equivalent. + pub fn diff(&self, other: &Self) -> Option { + let mut changes = Vec::new(); + + if self.target_contracts != other.target_contracts { + let added: Vec<_> = other + .target_contracts + .iter() + .filter(|(addr, _)| !self.target_contracts.contains_key(*addr)) + .map(|(_, name)| name.as_str()) + .collect(); + let removed: Vec<_> = self + .target_contracts + .iter() + .filter(|(addr, _)| !other.target_contracts.contains_key(*addr)) + .map(|(_, name)| name.as_str()) + .collect(); + + if !added.is_empty() { + changes.push(format!("added target contracts: {}", added.join(", "))); + } + if !removed.is_empty() { + changes.push(format!("removed target contracts: {}", removed.join(", "))); + } + } + + if self.target_selectors != other.target_selectors { + changes.push("target selectors changed".to_string()); + } + + if self.target_senders != other.target_senders { + changes.push("target senders changed".to_string()); + } + + if self.excluded_senders != other.excluded_senders { + changes.push("excluded senders changed".to_string()); + } + + if self.fail_on_revert != other.fail_on_revert { + changes.push(format!( + "fail_on_revert changed from {} to {}", + self.fail_on_revert, other.fail_on_revert + )); + } + + if changes.is_empty() { None } else { Some(changes.join(", ")) } + } +} + +impl fmt::Display for InvariantSettings { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "targets: {}, selectors: {}, senders: {}, excluded: {}, fail_on_revert: {}", + self.target_contracts.len(), + self.target_selectors.values().map(|v| v.len()).sum::(), + self.target_senders.len(), + self.excluded_senders.len(), + self.fail_on_revert, + ) + } +} + +/// Returns true if the function returns `int256`, indicating optimization mode. +/// In optimization mode, the fuzzer maximizes the return value instead of checking invariants. +pub fn is_optimization_invariant(func: &Function) -> bool { + func.outputs.len() == 1 && func.outputs[0].ty == "int256" +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::CallDetails; + use alloy_primitives::U256; + use foundry_compilers::{ + ArtifactId, + artifacts::{ + BytecodeObject, CompactBytecode, CompactContractBytecode, CompactDeployedBytecode, + }, + }; + use revm::{bytecode::Bytecode, state::Account}; + + fn abi_with_functions(functions: &[&str]) -> JsonAbi { + let mut abi = JsonAbi::new(); + for function in functions { + let function = Function::parse(function).unwrap(); + abi.functions.entry(function.name.clone()).or_default().push(function); + } + abi + } + + fn targeted_contracts_with_functions(target: Address, functions: &[&str]) -> TargetedContracts { + let mut targets = TargetedContracts::new(); + targets.inner.insert( + target, + TargetedContract::new("Target".to_string(), abi_with_functions(functions)), + ); + targets + } + + fn targeted_contracts_with_function(target: Address, function: Function) -> TargetedContracts { + let mut abi = JsonAbi::new(); + abi.functions.entry(function.name.clone()).or_default().push(function); + let mut targets = TargetedContracts::new(); + targets.inner.insert(target, TargetedContract::new("Target".to_string(), abi)); + targets + } + + fn tx(target: Address, calldata: impl Into) -> BasicTxDetails { + BasicTxDetails { + warp: None, + roll: None, + sender: Address::ZERO, + call_details: CallDetails { target, calldata: calldata.into(), value: None }, + } + } + + fn artifact_id(name: &str) -> ArtifactId { + ArtifactId { + path: format!("{name}.json").into(), + name: name.to_string(), + source: format!("{name}.sol").into(), + version: "0.8.30".parse().unwrap(), + build_id: "test".to_string(), + profile: "test".to_string(), + } + } + + fn project_contracts_with_runtime_code_and_abi( + name: &str, + code: Bytes, + abi: JsonAbi, + ) -> ContractsByArtifact { + let deployed_bytecode = CompactDeployedBytecode { + bytecode: Some(CompactBytecode { + object: BytecodeObject::Bytecode(code), + source_map: None, + link_references: Default::default(), + }), + immutable_references: Default::default(), + }; + let artifact = CompactContractBytecode { + abi: Some(abi), + bytecode: None, + deployed_bytecode: Some(deployed_bytecode), + }; + ContractsByArtifact::new([(artifact_id(name), artifact)]) + } + + fn touched_account_with_code(code: Bytes) -> Account { + let mut account = Account::default(); + account.info.balance = U256::ZERO; + account.info.code = Some(Bytecode::new_raw(code)); + account.mark_touch(); + account + } + + #[test] + fn targeted_contracts_short_calldata_is_not_replayable_or_decodable() { + let target = Address::repeat_byte(0x42); + let targets = targeted_contracts_with_function(target, Function::parse("foo()").unwrap()); + let tx = tx(target, vec![0xde, 0xad, 0xbe]); + + assert!(!targets.can_replay(&tx)); + assert!(targets.fuzzed_artifacts(&tx).1.is_none()); + assert!(targets.fuzzed_metric_key(&tx).is_none()); + } + + #[test] + fn abi_fuzzed_functions_filters_excluded_targeted_functions() { + let allowed = Function::parse("allowed()").unwrap(); + let excluded = Function::parse("excluded()").unwrap(); + let mut contract = TargetedContract::new("Target".to_string(), JsonAbi::new()); + contract.targeted_functions = vec![allowed.clone(), excluded.clone()]; + contract.excluded_functions = vec![excluded]; + + let selectors = contract.abi_fuzzed_functions().map(Function::selector).collect::>(); + + assert_eq!(selectors, vec![allowed.selector()]); + } + + #[test] + fn targeted_contracts_refresh_selector_lookup_after_filters() { + let target = Address::repeat_byte(0x42); + let foo = Function::parse("foo()").unwrap(); + let bar = Function::parse("bar()").unwrap(); + + let mut excluded = targeted_contracts_with_functions(target, &["foo()", "bar()"]); + excluded.inner.get_mut(&target).unwrap().add_selectors([foo.selector()], true).unwrap(); + assert!(!excluded.can_replay(&tx(target, foo.selector().to_vec()))); + assert!(excluded.can_replay(&tx(target, bar.selector().to_vec()))); + assert_eq!( + excluded.fuzzed_artifacts(&tx(target, foo.selector().to_vec())).1.unwrap().name, + "foo" + ); + + let mut targeted = targeted_contracts_with_functions(target, &["foo()", "bar()"]); + targeted.inner.get_mut(&target).unwrap().add_selectors([foo.selector()], false).unwrap(); + assert!(targeted.can_replay(&tx(target, foo.selector().to_vec()))); + assert!(!targeted.can_replay(&tx(target, bar.selector().to_vec()))); + assert_eq!( + targeted.fuzzed_metric_key_for_selector(target, bar.selector()).unwrap(), + "Target.bar" + ); + } + + #[test] + fn fuzz_run_identified_contracts_cache_fuzzed_functions_in_target_order() { + let first = Address::repeat_byte(0x01); + let second = Address::repeat_byte(0x02); + let mut targets = targeted_contracts_with_functions(second, &["bar()", "baz(uint256)"]); + targets.inner.insert( + first, + TargetedContract::new( + "First".to_string(), + abi_with_functions(&["foo()", "qux(address)"]), + ), + ); + let expected = targets + .fuzzed_functions() + .map(|(address, function)| (*address, function.selector())) + .collect::>(); + + let identified = FuzzRunIdentifiedContracts::new(targets, true); + let actual = identified + .fuzzed_functions() + .iter() + .map(|(address, function)| (*address, function.selector())) + .collect::>(); + + assert_eq!(actual, expected); + } + + #[test] + fn collect_created_contracts_caches_deployed_code_matches() { + let existing = Address::repeat_byte(0x42); + let created = Address::repeat_byte(0x43); + let setup = Address::repeat_byte(0x44); + let untouched = Address::repeat_byte(0x45); + let runtime_code = Bytes::from_static(&[0x60, 0x00, 0x56]); + let project_contracts = project_contracts_with_runtime_code_and_abi( + "DynamicTarget", + runtime_code.clone(), + JsonAbi::new(), + ); + let mut targets = TargetedContracts::new(); + for address in [existing, setup, untouched] { + targets.inner.insert( + address, + TargetedContract::new("AlreadyTargeted".to_string(), JsonAbi::new()), + ); + } + let identified = FuzzRunIdentifiedContracts::new(targets, true); + + let mut state_changeset = StateChangeset::default(); + state_changeset.insert(existing, touched_account_with_code(runtime_code.clone())); + state_changeset.insert(setup, touched_account_with_code(runtime_code.clone())); + state_changeset.insert(untouched, Account::default()); + state_changeset.insert(created, touched_account_with_code(runtime_code)); + let mut created_contracts = Vec::new(); + let setup_contracts = + ContractsByAddress::from([(setup, ("Setup".to_string(), JsonAbi::new()))]); + + identified + .collect_created_contracts( + &state_changeset, + &project_contracts, + &setup_contracts, + &ArtifactFilters::default(), + &mut created_contracts, + ) + .unwrap(); + + created_contracts.sort_unstable(); + assert_eq!(created_contracts, vec![existing, created]); + let targets = identified.targets(); + assert_eq!(targets[&existing].identifier, "DynamicTarget"); + assert_eq!(targets[&created].identifier, "DynamicTarget"); + assert_eq!(targets[&setup].identifier, "AlreadyTargeted"); + assert_eq!(targets[&untouched].identifier, "AlreadyTargeted"); + drop(targets); + + identified + .collect_created_contracts( + &state_changeset, + &Default::default(), + &setup_contracts, + &ArtifactFilters::default(), + &mut created_contracts, + ) + .unwrap(); + + created_contracts.sort_unstable(); + assert_eq!(created_contracts, vec![existing, existing, created, created]); + } + + #[test] + fn collect_and_clear_created_contracts_refresh_fuzzed_function_cache() { + let existing = Address::repeat_byte(0x42); + let created = Address::repeat_byte(0x43); + let runtime_code = Bytes::from_static(&[0x60, 0x00, 0x56]); + let project_contracts = project_contracts_with_runtime_code_and_abi( + "DynamicTarget", + runtime_code.clone(), + abi_with_functions(&["dynamic(uint256)"]), + ); + let identified = FuzzRunIdentifiedContracts::new( + targeted_contracts_with_functions(existing, &["existing()"]), + true, + ); + + let initial = identified + .fuzzed_functions() + .iter() + .map(|(address, function)| (*address, function.selector())) + .collect::>(); + assert_eq!(initial, vec![(existing, Function::parse("existing()").unwrap().selector())]); + assert_eq!(identified.fuzzed_functions_generation(), 0); + + let mut state_changeset = StateChangeset::default(); + state_changeset.insert(created, touched_account_with_code(runtime_code)); + let mut created_contracts = Vec::new(); + + identified + .collect_created_contracts( + &state_changeset, + &project_contracts, + &ContractsByAddress::default(), + &ArtifactFilters::default(), + &mut created_contracts, + ) + .unwrap(); + + let with_created = identified + .fuzzed_functions() + .iter() + .map(|(address, function)| (*address, function.selector())) + .collect::>(); + assert_eq!(identified.fuzzed_functions_generation(), 1); + assert_eq!( + with_created, + vec![ + (existing, Function::parse("existing()").unwrap().selector()), + (created, Function::parse("dynamic(uint256)").unwrap().selector()), + ] + ); + + identified.clear_created_contracts(created_contracts); + let cleared = identified + .fuzzed_functions() + .iter() + .map(|(address, function)| (*address, function.selector())) + .collect::>(); + assert_eq!(cleared, initial); + assert_eq!(identified.fuzzed_functions_generation(), 2); + } } diff --git a/crates/evm/fuzz/src/lib.rs b/crates/evm/fuzz/src/lib.rs index 7067c1f6f54cd..c41bf74a30b32 100644 --- a/crates/evm/fuzz/src/lib.rs +++ b/crates/evm/fuzz/src/lib.rs @@ -3,17 +3,18 @@ //! EVM fuzzing implementation using [`proptest`]. #![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] +#![cfg_attr(docsrs, feature(doc_cfg))] #[macro_use] extern crate tracing; use alloy_dyn_abi::{DynSolValue, JsonAbiExt}; use alloy_primitives::{ - Address, Bytes, Log, + Address, Bytes, Log, U256, map::{AddressHashMap, HashMap}, }; -use foundry_common::{calc, contracts::ContractsByAddress, evm::Breakpoints}; +use foundry_common::{calc, contracts::ContractsByAddress}; +use foundry_evm_core::Breakpoints; use foundry_evm_coverage::HitMaps; use foundry_evm_traces::{CallTraceArena, SparsedTraceArena}; use itertools::Itertools; @@ -26,10 +27,69 @@ mod error; pub use error::FuzzError; pub mod invariant; +pub mod sequence; pub mod strategies; +pub use strategies::LiteralMaps; mod inspector; -pub use inspector::Fuzzer; +pub use inspector::{Fuzzer, ObservedCall}; + +/// Metadata needed to reproduce a fuzz run. +#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)] +pub struct FuzzRunMetadata { + /// Seed used for the worker's input stream. + #[serde(default, rename = "fuzz_seed", skip_serializing_if = "Option::is_none")] + pub seed: Option, + /// 1-based run inside the worker's input stream. + #[serde(default, rename = "fuzz_run", skip_serializing_if = "Option::is_none")] + pub run: Option, + /// Worker that generated the input stream. + #[serde(default, rename = "fuzz_worker", skip_serializing_if = "Option::is_none")] + pub worker: Option, +} + +impl FuzzRunMetadata { + /// Creates metadata for reproducing a fuzz run. + pub const fn new(seed: Option, run: Option, worker: Option) -> Self { + Self { seed, run, worker } + } +} + +/// Details of a transaction generated by fuzz strategy for fuzzing a target. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BasicTxDetails { + /// Time (in seconds) to increase block timestamp before executing the tx. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub warp: Option, + /// Number to increase block number before executing the tx. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub roll: Option, + /// Transaction sender address. + pub sender: Address, + /// Transaction call details. + #[serde(flatten)] + pub call_details: CallDetails, +} + +/// Call details of a transaction generated to fuzz. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CallDetails { + /// Address of target contract. + pub target: Address, + /// The data of the transaction. + pub calldata: Bytes, + /// Ether value to send with the transaction. + /// Uses `#[serde(default)]` for backwards compatibility with existing corpus files. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub value: Option, +} + +impl BasicTxDetails { + /// Returns an estimate of the serialized (JSON) size in bytes. + pub fn estimate_serialized_size(&self) -> usize { + size_of::() + self.call_details.calldata.len() * 2 + } +} #[derive(Clone, Debug, Serialize, Deserialize)] #[expect(clippy::large_enum_variant)] @@ -42,12 +102,19 @@ pub enum CounterExample { #[derive(Clone, Debug, Serialize, Deserialize)] pub struct BaseCounterExample { + // Amount to increase block timestamp. + pub warp: Option, + // Amount to increase block number. + pub roll: Option, /// Address which makes the call. pub sender: Option
, /// Address to which to call to. pub addr: Option
, /// The data to provide. pub calldata: Bytes, + /// Ether value sent with the call. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub value: Option, /// Contract name if it exists. pub contract_name: Option, /// Function name if it exists. @@ -64,27 +131,37 @@ pub struct BaseCounterExample { /// Whether to display sequence as solidity. #[serde(skip)] pub show_solidity: bool, + /// Fuzz metadata needed to reproduce this counterexample. + #[serde(flatten)] + pub fuzz: FuzzRunMetadata, } impl BaseCounterExample { /// Creates counter example representing a step from invariant call sequence. pub fn from_invariant_call( - sender: Address, - addr: Address, - bytes: &Bytes, + tx: &BasicTxDetails, contracts: &ContractsByAddress, traces: Option, show_solidity: bool, ) -> Self { - if let Some((name, abi)) = &contracts.get(&addr) + let sender = tx.sender; + let target = tx.call_details.target; + let bytes = &tx.call_details.calldata; + let value = tx.call_details.value; + let warp = tx.warp; + let roll = tx.roll; + if let Some((name, abi)) = &contracts.get(&target) && let Some(func) = abi.functions().find(|f| f.selector() == bytes[..4]) { // skip the function selector when decoding if let Ok(args) = func.abi_decode_input(&bytes[4..]) { return Self { + warp, + roll, sender: Some(sender), - addr: Some(addr), + addr: Some(target), calldata: bytes.clone(), + value, contract_name: Some(name.clone()), func_name: Some(func.name.clone()), signature: Some(func.signature()), @@ -94,14 +171,18 @@ impl BaseCounterExample { ), traces, show_solidity, + fuzz: FuzzRunMetadata::default(), }; } } Self { + warp, + roll, sender: Some(sender), - addr: Some(addr), + addr: Some(target), calldata: bytes.clone(), + value, contract_name: None, func_name: None, signature: None, @@ -109,6 +190,7 @@ impl BaseCounterExample { raw_args: None, traces, show_solidity: false, + fuzz: FuzzRunMetadata::default(), } } @@ -119,9 +201,36 @@ impl BaseCounterExample { traces: Option, ) -> Self { Self { + warp: None, + roll: None, sender: None, addr: None, calldata: bytes, + value: None, + contract_name: None, + func_name: None, + signature: None, + args: Some(foundry_common::fmt::format_tokens(&args).format(", ").to_string()), + raw_args: Some(foundry_common::fmt::format_tokens_raw(&args).format(", ").to_string()), + traces, + show_solidity: false, + fuzz: FuzzRunMetadata::default(), + } + } + + /// Creates counter example for a fuzz test failure from the concrete executed transaction. + pub fn from_fuzz_tx( + tx: &BasicTxDetails, + args: Vec, + traces: Option, + ) -> Self { + Self { + warp: tx.warp, + roll: tx.roll, + sender: Some(tx.sender), + addr: Some(tx.call_details.target), + calldata: tx.call_details.calldata.clone(), + value: tx.call_details.value, contract_name: None, func_name: None, signature: None, @@ -129,8 +238,15 @@ impl BaseCounterExample { raw_args: Some(foundry_common::fmt::format_tokens_raw(&args).format(", ").to_string()), traces, show_solidity: false, + fuzz: FuzzRunMetadata::default(), } } + + /// Sets fuzz metadata for reproducing this counterexample. + pub const fn with_fuzz_metadata(mut self, fuzz: FuzzRunMetadata) -> Self { + self.fuzz = fuzz; + self + } } impl fmt::Display for BaseCounterExample { @@ -140,7 +256,27 @@ impl fmt::Display for BaseCounterExample { && let (Some(sender), Some(contract), Some(address), Some(func_name), Some(args)) = (&self.sender, &self.contract_name, &self.addr, &self.func_name, &self.raw_args) { + if let Some(warp) = &self.warp { + writeln!(f, "\t\tvm.warp(block.timestamp + {warp});")?; + } + if let Some(roll) = &self.roll { + writeln!(f, "\t\tvm.roll(block.number + {roll});")?; + } writeln!(f, "\t\tvm.prank({sender});")?; + // Use value syntax for payable calls. + if let Some(value) = &self.value + && !value.is_zero() + { + write!( + f, + "\t\t{}({}).{}{{value: {value}}}({});", + contract.split_once(':').map_or(contract.as_str(), |(_, contract)| contract), + address, + func_name, + args + )?; + return Ok(()); + } write!( f, "\t\t{}({}).{}({});", @@ -154,22 +290,42 @@ impl fmt::Display for BaseCounterExample { } // Regular counterexample display. - if let Some(sender) = self.sender { - write!(f, "\t\tsender={sender} addr=")? + // Stateless fuzz targets and senders are fixed by the test configuration, so preserve the + // compact historical display unless value makes the transaction context relevant. + let show_tx_context = + self.fuzz.worker.is_none() || self.value.as_ref().is_some_and(|value| !value.is_zero()); + if show_tx_context { + if let Some(sender) = self.sender { + write!(f, "\t\tsender={sender} addr=")? + } + + if let Some(name) = &self.contract_name { + write!(f, "[{name}]")? + } + + if let Some(addr) = &self.addr { + write!(f, "{addr} ")? + } } - if let Some(name) = &self.contract_name { - write!(f, "[{name}]")? + if let Some(warp) = &self.warp { + write!(f, "warp={warp} ")?; + } + if let Some(roll) = &self.roll { + write!(f, "roll={roll} ")?; } - if let Some(addr) = &self.addr { - write!(f, "{addr} ")? + // Display value if non-zero (for payable calls). + if let Some(value) = &self.value + && !value.is_zero() + { + write!(f, "value={value} ")?; } if let Some(sig) = &self.signature { write!(f, "calldata={sig}")? } else { - write!(f, "calldata={}", &self.calldata)? + write!(f, "calldata={}", self.calldata)? } if let Some(args) = &self.args { @@ -181,21 +337,19 @@ impl fmt::Display for BaseCounterExample { } /// The outcome of a fuzz test -#[derive(Debug)] +#[derive(Debug, Default)] pub struct FuzzTestResult { /// we keep this for the debugger pub first_case: FuzzCase, /// Gas usage (gas_used, call_stipend) per cases pub gas_by_case: Vec<(u64, u64)>, /// Whether the test case was successful. This means that the transaction executed - /// properly, or that there was a revert and that the test was expected to fail - /// (prefixed with `testFail`) + /// properly. pub success: bool, /// Whether the test case was skipped. `reason` will contain the skip reason, if any. pub skipped: bool, - /// If there was a revert, this field will be populated. Note that the test can - /// still be successful (i.e self.success == true) when it's expected to fail. + /// If there was a revert, this field will be populated. pub reason: Option, /// Minimal reproduction test case for failing fuzz tests @@ -206,7 +360,7 @@ pub struct FuzzTestResult { pub logs: Vec, /// Labeled addresses - pub labeled_addresses: AddressHashMap, + pub labels: AddressHashMap, /// Exemplary traces for a fuzz run of the test function /// @@ -224,8 +378,17 @@ pub struct FuzzTestResult { /// Breakpoints for debugger. Correspond to the same fuzz case as `traces`. pub breakpoints: Option, + /// Runtime bytecodes for contracts seen in the debug trace. + pub debug_bytecodes: AddressHashMap, + // Deprecated cheatcodes mapped to their replacements. pub deprecated_cheatcodes: HashMap<&'static str, Option<&'static str>>, + + /// Number of failed replays from persisted corpus. + pub failed_corpus_replays: usize, + + /// The active fork's block number for the failing case, if any. + pub fork_block_number: Option, } impl FuzzTestResult { @@ -254,8 +417,6 @@ impl FuzzTestResult { /// Data of a single fuzz test case #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct FuzzCase { - /// The calldata used for this fuzz test - pub calldata: Bytes, /// Consumed gas pub gas: u64, /// The initial gas stipend for the transaction @@ -270,30 +431,25 @@ pub struct FuzzedCases { } impl FuzzedCases { - #[inline] pub fn new(mut cases: Vec) -> Self { cases.sort_by_key(|c| c.gas); Self { cases } } - #[inline] pub fn cases(&self) -> &[FuzzCase] { &self.cases } - #[inline] pub fn into_cases(self) -> Vec { self.cases } /// Get the last [FuzzCase] - #[inline] pub fn last(&self) -> Option<&FuzzCase> { self.cases.last() } /// Returns the median gas of all test cases - #[inline] pub fn median_gas(&self, with_stipend: bool) -> u64 { let mut values = self.gas_values(with_stipend); values.sort_unstable(); @@ -301,14 +457,12 @@ impl FuzzedCases { } /// Returns the average gas use of all test cases - #[inline] pub fn mean_gas(&self, with_stipend: bool) -> u64 { let mut values = self.gas_values(with_stipend); values.sort_unstable(); calc::mean(&values) } - #[inline] fn gas_values(&self, with_stipend: bool) -> Vec { self.cases .iter() @@ -317,19 +471,16 @@ impl FuzzedCases { } /// Returns the case with the highest gas usage - #[inline] pub fn highest(&self) -> Option<&FuzzCase> { self.cases.last() } /// Returns the case with the lowest gas usage - #[inline] pub fn lowest(&self) -> Option<&FuzzCase> { self.cases.first() } /// Returns the highest amount of gas spent on a fuzz case - #[inline] pub fn highest_gas(&self, with_stipend: bool) -> u64 { self.highest() .map(|c| if with_stipend { c.gas } else { c.gas - c.stipend }) @@ -337,7 +488,6 @@ impl FuzzedCases { } /// Returns the lowest amount of gas spent on a fuzz case - #[inline] pub fn lowest_gas(&self) -> u64 { self.lowest().map(|c| c.gas).unwrap_or_default() } @@ -352,11 +502,19 @@ impl FuzzedCases { #[derive(Clone, Default, Debug)] pub struct FuzzFixtures { inner: Arc>, + /// Variant counts for project enums, used to constrain fuzzed enum inputs. + enum_bounds: strategies::EnumBounds, } impl FuzzFixtures { pub fn new(fixtures: HashMap) -> Self { - Self { inner: Arc::new(fixtures) } + Self { inner: Arc::new(fixtures), enum_bounds: strategies::EnumBounds::default() } + } + + /// Attaches collected enum variant counts. + pub fn with_enum_bounds(mut self, enum_bounds: strategies::EnumBounds) -> Self { + self.enum_bounds = enum_bounds; + self } /// Returns configured fixtures for `param_name` fuzzed parameter. @@ -367,6 +525,12 @@ impl FuzzFixtures { None } } + + /// Returns the variant count for an enum identified by an optional contract qualifier and name + /// (as found in an ABI `internalType`), or `None` if unknown. + pub fn enum_variant_count(&self, contract: Option<&str>, name: &str) -> Option { + self.enum_bounds.variant_count(contract, name) + } } /// Extracts fixture name from a function name. diff --git a/crates/evm/fuzz/src/sequence.rs b/crates/evm/fuzz/src/sequence.rs new file mode 100644 index 0000000000000..7a807959e8248 --- /dev/null +++ b/crates/evm/fuzz/src/sequence.rs @@ -0,0 +1,1115 @@ +//! Pure transaction-sequence generation and mutation primitives. + +use crate::{ + BasicTxDetails, FuzzFixtures, + invariant::FuzzRunIdentifiedContracts, + strategies::{ + FuzzState, TxGenerator, constrain_enum_value, generate_msg_value, mutate_param_value, + }, +}; +use alloy_dyn_abi::JsonAbiExt; +use alloy_json_abi::Function; +use alloy_primitives::{U256, map::HashSet}; +use eyre::{Result, eyre}; +use foundry_config::{FuzzCorpusConfig, FuzzCorpusMutationWeights}; +use proptest::test_runner::TestRunner; +use rand::{ + Rng, + distr::{Distribution, weighted::WeightedIndex}, +}; + +/// A neutral borrowed view of one corpus entry. +#[derive(Clone, Copy)] +pub struct CorpusEntryView<'a> { + transactions: &'a [BasicTxDetails], + comparisons: &'a [Vec], +} + +impl<'a> CorpusEntryView<'a> { + pub fn new( + transactions: &'a [BasicTxDetails], + comparisons: &'a [Vec], + ) -> Result { + if transactions.is_empty() { + return Err(eyre!("corpus entry has no transactions")); + } + if comparisons.len() > transactions.len() { + return Err(eyre!("corpus entry has more comparison sets than transactions")); + } + Ok(Self { transactions, comparisons }) + } +} + +#[derive(Clone)] +enum SequenceMode { + Stateless(Function), + Invariant(FuzzRunIdentifiedContracts), +} + +/// Generates initial sequences and their lazy continuations. +#[derive(Clone)] +pub struct SequenceGenerator { + tx: TxGenerator, + state: FuzzState, + fixtures: FuzzFixtures, + mode: SequenceMode, + weights: FuzzCorpusMutationWeights, + mutations: WeightedIndex, + arg_mutations: Option>, + fresh_weight: u32, + payable_weight: u32, + has_corpus_dir: bool, +} + +/// An initial sequence and the generator used to lazily continue it. +pub struct SequencePlan { + initial: InitialSequence, + tx: TxGenerator, + fresh_weight: u32, + has_corpus_dir: bool, + stateless: bool, + source: Option, +} + +enum InitialSequence { + Single(BasicTxDetails), + Multiple(Vec), +} + +impl InitialSequence { + pub fn into_first(self) -> BasicTxDetails { + match self { + Self::Single(tx) => tx, + Self::Multiple(mut txs) => txs.remove(0), + } + } + + pub fn as_slice(&self) -> &[BasicTxDetails] { + match self { + Self::Single(tx) => std::slice::from_ref(tx), + Self::Multiple(txs) => txs, + } + } +} + +impl SequencePlan { + pub fn initial(&self) -> &[BasicTxDetails] { + self.initial.as_slice() + } + pub fn into_first(self) -> BasicTxDetails { + self.initial.into_first() + } + pub const fn source(&self) -> Option { + self.source + } + pub fn next( + &self, + runner: &mut TestRunner, + discarded: bool, + depth: usize, + ) -> Result { + if self.stateless { + return Err(eyre!("stateless sequence is limited to one transaction")); + } + if !self.has_corpus_dir || discarded { + return self.tx.next_tx(runner); + } + let fresh = self.fresh_weight > 0 && runner.rng().random_ratio(self.fresh_weight, 100); + if depth >= self.initial.as_slice().len() || fresh { + self.tx.next_tx(runner) + } else { + Ok(self.initial.as_slice()[depth].clone()) + } + } +} + +#[derive(Clone, Copy)] +enum MutationType { + Splice, + Repeat, + Interleave, + Prefix, + Suffix, + Abi, + Cmp, +} + +impl SequenceGenerator { + pub fn stateless( + tx: TxGenerator, + state: FuzzState, + function: Function, + config: &FuzzCorpusConfig, + ) -> Result { + Self::stateless_with_fixtures(tx, state, FuzzFixtures::default(), function, config) + } + pub fn stateless_with_fixtures( + tx: TxGenerator, + state: FuzzState, + fixtures: FuzzFixtures, + function: Function, + config: &FuzzCorpusConfig, + ) -> Result { + Self::new(tx, state, fixtures, SequenceMode::Stateless(function), config) + } + pub fn invariant( + tx: TxGenerator, + state: FuzzState, + targets: FuzzRunIdentifiedContracts, + config: &FuzzCorpusConfig, + ) -> Result { + Self::invariant_with_fixtures(tx, state, FuzzFixtures::default(), targets, config) + } + pub fn invariant_with_fixtures( + tx: TxGenerator, + state: FuzzState, + fixtures: FuzzFixtures, + targets: FuzzRunIdentifiedContracts, + config: &FuzzCorpusConfig, + ) -> Result { + Self::new(tx, state, fixtures, SequenceMode::Invariant(targets), config) + } + fn new( + tx: TxGenerator, + state: FuzzState, + fixtures: FuzzFixtures, + mode: SequenceMode, + config: &FuzzCorpusConfig, + ) -> Result { + let weights = config.mutation_weights.effective(); + if weights.total() > u64::from(u32::MAX) { + return Err(eyre!( + "effective mutation weights sum to {}, which exceeds the maximum supported total {}", + weights.total(), + u32::MAX + )); + } + let all = [ + weights.mutation_weight_splice, + weights.mutation_weight_repeat, + weights.mutation_weight_interleave, + weights.mutation_weight_prefix, + weights.mutation_weight_suffix, + weights.mutation_weight_abi, + weights.mutation_weight_cmp, + ]; + let mutations = + WeightedIndex::new(all).map_err(|e| eyre!("invalid corpus mutation weights: {e}"))?; + let arg_mutations = if weights.mutation_weight_abi == 0 && weights.mutation_weight_cmp == 0 + { + None + } else { + Some( + WeightedIndex::new([weights.mutation_weight_abi, weights.mutation_weight_cmp]) + .map_err(|e| eyre!("invalid argument mutation weights: {e}"))?, + ) + }; + Ok(Self { + tx, + state, + fixtures, + mode, + weights, + mutations, + arg_mutations, + fresh_weight: config.corpus_random_sequence_weight.min(100), + payable_weight: config.payable_value_weight, + has_corpus_dir: config.corpus_dir.is_some(), + }) + } + + pub fn start<'a, F>( + &self, + runner: &mut TestRunner, + corpus_len: usize, + mut entry_at: F, + coverage: bool, + ) -> Result + where + F: FnMut(usize) -> Result>, + { + let (initial, source) = match &self.mode { + SequenceMode::Stateless(function) => { + self.start_stateless(runner, corpus_len, &mut entry_at, coverage, function)? + } + SequenceMode::Invariant(targets) => { + self.start_invariant(runner, corpus_len, &mut entry_at, coverage, targets)? + } + }; + Ok(SequencePlan { + initial, + tx: self.tx.clone(), + fresh_weight: self.fresh_weight, + has_corpus_dir: self.has_corpus_dir, + stateless: matches!(self.mode, SequenceMode::Stateless(_)), + source, + }) + } + + fn start_stateless<'a>( + &self, + runner: &mut TestRunner, + corpus_len: usize, + entry_at: &mut impl FnMut(usize) -> Result>, + coverage: bool, + function: &Function, + ) -> Result<(InitialSequence, Option)> { + if !coverage + || corpus_len == 0 + || (self.fresh_weight > 0 && runner.rng().random_ratio(self.fresh_weight, 100)) + { + return Ok((InitialSequence::Single(self.tx.next_tx(runner)?), None)); + } + let index = runner.rng().random_range(0..corpus_len); + let entry = entry_at(index)?; + let mut tx = entry.transactions[0].clone(); + let hints = entry.comparisons.first().map_or(&[][..], Vec::as_slice); + match self.arg_mutations.as_ref().map(|d| d.sample(runner.rng()) == 1) { + Some(true) + if !SequenceMutator::cmp_mutate( + &mut tx, + function, + hints, + runner, + &self.fixtures, + )? && self.weights.mutation_weight_abi > 0 + && !function.inputs.is_empty() => + { + SequenceMutator::abi_mutate( + &mut tx, + function, + runner, + &self.state, + &self.fixtures, + self.payable_weight, + )? + } + Some(true) => {} + Some(false) if self.weights.mutation_weight_abi > 0 && !function.inputs.is_empty() => { + SequenceMutator::abi_mutate( + &mut tx, + function, + runner, + &self.state, + &self.fixtures, + self.payable_weight, + )? + } + Some(false) if self.weights.mutation_weight_cmp > 0 => { + let _ = + SequenceMutator::cmp_mutate(&mut tx, function, hints, runner, &self.fixtures)?; + } + None => return Ok((InitialSequence::Single(self.tx.next_tx(runner)?), None)), + _ => {} + } + Ok((InitialSequence::Single(tx), Some(index))) + } + + fn start_invariant<'a>( + &self, + runner: &mut TestRunner, + corpus_len: usize, + entry_at: &mut impl FnMut(usize) -> Result>, + coverage: bool, + targets: &FuzzRunIdentifiedContracts, + ) -> Result<(InitialSequence, Option)> { + if !coverage || corpus_len == 0 { + return Ok((InitialSequence::Multiple(vec![self.tx.next_tx(runner)?]), None)); + } + let kind = match self.mutations.sample(runner.rng()) { + 0 => MutationType::Splice, + 1 => MutationType::Repeat, + 2 => MutationType::Interleave, + 3 => MutationType::Prefix, + 4 => MutationType::Suffix, + 5 => MutationType::Abi, + _ => MutationType::Cmp, + }; + let a = runner.rng().random_range(0..corpus_len); + let b = runner.rng().random_range(0..corpus_len); + let primary = entry_at(a)?; + let secondary = entry_at(b)?; + let (mut seq, source) = match kind { + MutationType::Splice => { + (SequenceMutator::splice(primary.transactions, secondary.transactions, runner), a) + } + MutationType::Interleave => ( + SequenceMutator::interleave(primary.transactions, secondary.transactions, runner), + a, + ), + MutationType::Repeat => { + let i = if runner.rng().random() { a } else { b }; + let entry = if i == a { primary } else { secondary }; + (SequenceMutator::repeat(entry.transactions, runner), i) + } + MutationType::Prefix | MutationType::Suffix => { + let i = if runner.rng().random() { a } else { b }; + let base = if i == a { primary.transactions } else { secondary.transactions }; + let len = if matches!(kind, MutationType::Prefix) { + runner.rng().random_range(0..=base.len()) + } else { + runner.rng().random_range(0..base.len()) + }; + let mut r = Vec::with_capacity(len); + for _ in 0..len { + r.push(self.tx.next_tx(runner)?) + } + ( + if matches!(kind, MutationType::Prefix) { + SequenceMutator::prefix(base, r) + } else { + SequenceMutator::suffix(base, r) + }, + i, + ) + } + MutationType::Abi | MutationType::Cmp => { + let i = if runner.rng().random() { a } else { b }; + let entry = if i == a { primary } else { secondary }; + let mut seq = entry.transactions.to_vec(); + let fallback = runner.rng().random_range(0..seq.len()); + if matches!(kind, MutationType::Abi) { + let tx = &mut seq[fallback]; + if let (_, Some(f)) = targets.targets().fuzzed_artifacts(tx) + && !f.inputs.is_empty() + { + SequenceMutator::abi_mutate( + tx, + f, + runner, + &self.state, + &self.fixtures, + self.payable_weight, + )?; + } + } else { + let candidates = + entry.comparisons.iter().enumerate().filter(|(_, h)| !h.is_empty()); + let count = candidates.clone().count(); + let mut mutated = false; + if count > 0 { + let start = runner.rng().random_range(0..count); + for (idx, h) in candidates.cycle().skip(start).take(count) { + let tx = &mut seq[idx]; + if let (_, Some(f)) = targets.targets().fuzzed_artifacts(tx) { + mutated = + SequenceMutator::cmp_mutate(tx, f, h, runner, &self.fixtures)?; + if mutated { + break; + } + } + } + } + if !mutated && self.weights.mutation_weight_abi > 0 { + let tx = &mut seq[fallback]; + if let (_, Some(f)) = targets.targets().fuzzed_artifacts(tx) + && !f.inputs.is_empty() + { + SequenceMutator::abi_mutate( + tx, + f, + runner, + &self.state, + &self.fixtures, + self.payable_weight, + )? + } + } + } + (seq, i) + } + }; + if seq.is_empty() { + seq.push(self.tx.next_tx(runner)?) + } + Ok((InitialSequence::Multiple(seq), Some(source))) + } +} + +/// An EVM comparison observed while executing an input. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ComparisonHint { + pub lhs: U256, + pub rhs: U256, +} + +impl ComparisonHint { + /// Collects the distinct `hints` that a cmp mutation can apply to `calldata`, in first-seen + /// order. + /// + /// Cmp mutations only rewrite the calldata stored with the hints, so other hints can never + /// produce a mutation. + pub fn applicable(calldata: &[u8], hints: impl IntoIterator) -> Vec { + let mut seen = HashSet::::default(); + hints.into_iter().filter(|hint| seen.insert(*hint) && hint.applies_to(calldata)).collect() + } + + /// Returns whether a cmp mutation can replace one of this comparison's operands in + /// `calldata`. + pub fn applies_to(&self, calldata: &[u8]) -> bool { + let lhs = self.lhs.to_be_bytes::<32>(); + let rhs = self.rhs.to_be_bytes::<32>(); + CMP_WIDTHS.iter().any(|&width| { + let lhs = &lhs[32 - width..]; + let rhs = &rhs[32 - width..]; + lhs != rhs && (contains_operand(calldata, lhs) || contains_operand(calldata, rhs)) + }) + } +} + +/// Pure mutations shared by stateless and invariant sequence producers. +struct SequenceMutator; + +impl SequenceMutator { + fn splice( + first: &[BasicTxDetails], + second: &[BasicTxDetails], + runner: &mut TestRunner, + ) -> Vec { + let rng = runner.rng(); + let start1 = rng.random_range(0..first.len()); + let end1 = rng.random_range(start1..first.len()); + let start2 = rng.random_range(0..second.len()); + let end2 = rng.random_range(start2..second.len()); + first[start1..=end1].iter().chain(&second[start2..=end2]).cloned().collect() + } + + fn repeat(sequence: &[BasicTxDetails], runner: &mut TestRunner) -> Vec { + let rng = runner.rng(); + let start = rng.random_range(0..sequence.len()); + let end = rng.random_range(start..=sequence.len()); + let repeated = sequence[rng.random_range(0..sequence.len())].clone(); + let mut result = Vec::with_capacity(sequence.len()); + result.extend_from_slice(&sequence[..start]); + result.extend((start..end).map(|_| repeated.clone())); + result.extend_from_slice(&sequence[end..]); + result + } + + fn interleave( + first: &[BasicTxDetails], + second: &[BasicTxDetails], + runner: &mut TestRunner, + ) -> Vec { + first + .iter() + .zip(second) + .map( + |(first, second)| { + if runner.rng().random() { first.clone() } else { second.clone() } + }, + ) + .collect() + } + + fn prefix( + sequence: &[BasicTxDetails], + mut replacements: Vec, + ) -> Vec { + replacements.truncate(sequence.len()); + let mut result = sequence.to_vec(); + result[..replacements.len()].clone_from_slice(&replacements); + result + } + + fn suffix( + sequence: &[BasicTxDetails], + mut replacements: Vec, + ) -> Vec { + replacements.truncate(sequence.len()); + let mut result = sequence.to_vec(); + let retained = result.len() - replacements.len(); + result[retained..].clone_from_slice(&replacements); + result + } + + /// Mutates ABI arguments while retaining transaction metadata and optionally changing value. + fn abi_mutate( + tx: &mut BasicTxDetails, + function: &Function, + runner: &mut TestRunner, + state: &FuzzState, + fixtures: &FuzzFixtures, + payable_value_weight: u32, + ) -> Result<()> { + if function.inputs.is_empty() || tx.call_details.calldata.len() < 4 { + return Ok(()); + } + if function.state_mutability == alloy_json_abi::StateMutability::Payable + && runner.rng().random_ratio(payable_value_weight.min(100), 100) + { + tx.call_details.value = Some(generate_msg_value(runner)); + } + let mut rounds = runner.rng().random_range(0..=function.inputs.len()).max(1); + let indices = if function.inputs.len() <= 1 { + vec![0] + } else { + (0..rounds).map(|_| runner.rng().random_range(0..function.inputs.len())).collect() + }; + let mut inputs = function + .abi_decode_input(&tx.call_details.calldata[4..]) + .map_err(|err| eyre!("failed to load previous inputs: {err}"))?; + while rounds > 0 { + let index = indices[rounds - 1]; + inputs[index] = mutate_param_value( + &function.inputs[index].selector_type().parse()?, + inputs[index].clone(), + runner, + state, + ); + rounds -= 1; + } + let inputs = inputs + .into_iter() + .zip(&function.inputs) + .map(|(value, input)| constrain_enum_value(value, input, fixtures)) + .collect::>(); + tx.call_details.calldata = + function.abi_encode_input(&inputs).map_err(|err| eyre!(err.to_string()))?.into(); + Ok(()) + } + + fn cmp_mutate( + tx: &mut BasicTxDetails, + function: &Function, + hints: &[ComparisonHint], + runner: &mut TestRunner, + fixtures: &FuzzFixtures, + ) -> Result { + if hints.is_empty() || tx.call_details.calldata.len() <= 4 { + return Ok(false); + } + let start = runner.rng().random_range(0..hints.len()); + for offset in 0..hints.len() { + if let Some(calldata) = cmp_mutated_calldata( + tx.call_details.calldata.as_ref(), + hints[(start + offset) % hints.len()], + runner, + ) && let Ok(inputs) = function.abi_decode_input(&calldata[4..]) + && inputs + .iter() + .cloned() + .zip(&function.inputs) + .map(|(value, input)| constrain_enum_value(value, input, fixtures)) + .zip(&inputs) + .all(|(constrained, input)| constrained == *input) + { + tx.call_details.calldata = calldata.into(); + return Ok(true); + } + } + Ok(false) + } +} + +/// Operand widths, in bytes, that cmp mutations try to replace. +const CMP_WIDTHS: [usize; 6] = [32, 16, 8, 4, 2, 1]; + +fn cmp_mutated_calldata( + calldata: &[u8], + hint: ComparisonHint, + runner: &mut TestRunner, +) -> Option> { + let lhs = hint.lhs.to_be_bytes::<32>(); + let rhs = hint.rhs.to_be_bytes::<32>(); + let start = runner.rng().random_range(0..CMP_WIDTHS.len()); + for offset in 0..CMP_WIDTHS.len() { + let width = CMP_WIDTHS[(start + offset) % CMP_WIDTHS.len()]; + let lhs = &lhs[32 - width..]; + let rhs = &rhs[32 - width..]; + if lhs == rhs { + continue; + } + let pairs = + if runner.rng().random() { [(lhs, rhs), (rhs, lhs)] } else { [(rhs, lhs), (lhs, rhs)] }; + for (pattern, replacement) in pairs { + if let Some(mutated) = replace_operand(calldata, pattern, replacement, runner) { + return Some(mutated); + } + } + } + None +} + +/// Length of the function selector that cmp mutations never rewrite. +const SELECTOR_LEN: usize = 4; + +/// Returns whether `pattern` can be searched for in `calldata` arguments. +fn is_replaceable_operand(calldata: &[u8], pattern: &[u8]) -> bool { + !pattern.is_empty() + && calldata.len() >= SELECTOR_LEN + pattern.len() + && (pattern.len() == 32 || pattern.iter().any(|byte| *byte != 0)) +} + +/// Returns whether `pattern` occurs in `calldata` arguments. +fn contains_operand(calldata: &[u8], pattern: &[u8]) -> bool { + is_replaceable_operand(calldata, pattern) + && calldata[SELECTOR_LEN..].windows(pattern.len()).any(|window| window == pattern) +} + +fn replace_operand( + calldata: &[u8], + pattern: &[u8], + replacement: &[u8], + runner: &mut TestRunner, +) -> Option> { + if pattern.len() != replacement.len() || !is_replaceable_operand(calldata, pattern) { + return None; + } + let search_len = calldata.len() - SELECTOR_LEN - pattern.len() + 1; + let start = runner.rng().random_range(0..search_len); + for offset in 0..search_len { + let index = SELECTOR_LEN + ((start + offset) % search_len); + if &calldata[index..index + pattern.len()] == pattern { + let mut mutated = calldata.to_vec(); + mutated[index..index + replacement.len()].copy_from_slice(replacement); + return Some(mutated); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + CallDetails, + invariant::{TargetedContract, TargetedContracts}, + strategies::EvmFuzzState, + }; + use alloy_dyn_abi::DynSolValue; + use alloy_json_abi::JsonAbi; + use alloy_primitives::{Address, Bytes}; + use foundry_config::FuzzDictionaryConfig; + use proptest::{prelude::Just, strategy::Strategy}; + use revm::database::InMemoryDB; + use std::path::PathBuf; + + fn sentinel(runner: &mut TestRunner) -> u64 { + runner.rng().random() + } + + fn forced_weights(kind: usize) -> FuzzCorpusMutationWeights { + let mut weights = FuzzCorpusMutationWeights { + mutation_weight_splice: 0, + mutation_weight_repeat: 0, + mutation_weight_interleave: 0, + mutation_weight_prefix: 0, + mutation_weight_suffix: 0, + mutation_weight_abi: 0, + mutation_weight_cmp: 0, + }; + match kind { + 0 => weights.mutation_weight_splice = 1, + 1 => weights.mutation_weight_repeat = 1, + 2 => weights.mutation_weight_interleave = 1, + 3 => weights.mutation_weight_prefix = 1, + 4 => weights.mutation_weight_suffix = 1, + 5 => weights.mutation_weight_abi = 1, + _ => weights.mutation_weight_cmp = 1, + } + weights + } + + fn tx(sender: u8) -> BasicTxDetails { + BasicTxDetails { + warp: Some(U256::from(sender)), + roll: Some(U256::from(sender + 1)), + sender: Address::with_last_byte(sender), + call_details: CallDetails { + target: Address::with_last_byte(10), + calldata: Bytes::from(vec![sender]), + value: Some(U256::from(sender)), + }, + } + } + + fn state() -> FuzzState { + EvmFuzzState::new(&[], &InMemoryDB::default(), FuzzDictionaryConfig::default(), None) + .stateless_worker() + } + + fn generator_tx(sender: u8) -> TxGenerator { + TxGenerator::from_strategy(Just(tx(sender)).boxed()) + } + + fn config() -> FuzzCorpusConfig { + FuzzCorpusConfig { corpus_dir: Some(PathBuf::from("corpus")), ..Default::default() } + } + + #[test] + fn prefix_and_suffix_handle_sequence_edges() { + let sequence = vec![tx(1), tx(2), tx(3)]; + assert_eq!(SequenceMutator::prefix(&sequence, Vec::new())[0].sender, sequence[0].sender); + assert_eq!(SequenceMutator::suffix(&sequence, Vec::new())[2].sender, sequence[2].sender); + assert_eq!( + SequenceMutator::prefix(&sequence, vec![tx(9), tx(8), tx(7)])[2].sender, + tx(7).sender + ); + assert_eq!(SequenceMutator::suffix(&sequence, vec![tx(9)])[0].warp, sequence[0].warp); + } + + #[test] + fn splice_retains_singleton_entries() { + let mut runner = TestRunner::deterministic(); + let result = SequenceMutator::splice(&[tx(1)], &[tx(2)], &mut runner); + + assert_eq!( + result.iter().map(|tx| tx.sender).collect::>(), + [tx(1).sender, tx(2).sender] + ); + } + + #[test] + fn splice_can_select_final_transactions() { + let first = [tx(1), tx(2)]; + let second = [tx(3), tx(4)]; + let mut runner = TestRunner::deterministic(); + let mut selected_first_final = false; + let mut selected_second_final = false; + + for _ in 0..1000 { + let result = SequenceMutator::splice(&first, &second, &mut runner); + selected_first_final |= result.iter().any(|tx| tx.sender == first[1].sender); + selected_second_final |= result.iter().any(|tx| tx.sender == second[1].sender); + } + + assert!(selected_first_final, "splice never selected the first entry's final transaction"); + assert!( + selected_second_final, + "splice never selected the second entry's final transaction" + ); + } + + #[test] + fn repeat_can_replace_final_transaction() { + let sequence = [tx(1), tx(2)]; + let mut runner = TestRunner::deterministic(); + let mut replaced_final = false; + + for _ in 0..1000 { + let result = SequenceMutator::repeat(&sequence, &mut runner); + replaced_final |= result[1].sender != sequence[1].sender; + } + + assert!(replaced_final, "repeat never replaced the final transaction"); + } + + #[test] + fn cmp_mutation_replaces_operand_and_retains_metadata() { + let function = Function::parse("testCmp(uint256)").unwrap(); + let mut input = tx(7); + input.call_details.calldata = + function.abi_encode_input(&[DynSolValue::Uint(U256::from(7), 256)]).unwrap().into(); + let metadata = (input.warp, input.roll, input.sender, input.call_details.value); + let mut runner = TestRunner::default(); + + assert!( + SequenceMutator::cmp_mutate( + &mut input, + &function, + &[ComparisonHint { lhs: U256::from(7), rhs: U256::from(42) }], + &mut runner, + &FuzzFixtures::default(), + ) + .unwrap() + ); + let decoded = function.abi_decode_input(&input.call_details.calldata[4..]).unwrap(); + assert_eq!(decoded[0].as_uint().unwrap().0, U256::from(42)); + assert_eq!((input.warp, input.roll, input.sender, input.call_details.value), metadata); + } + + #[test] + fn stateless_fresh_and_disabled_mutators_generate_one_transaction() { + let function = Function::parse("test(uint256)").unwrap(); + let corpus_tx = tx(1); + let corpus = [CorpusEntryView::new(std::slice::from_ref(&corpus_tx), &[]).unwrap()]; + for disable_mutators in [false, true] { + let mut config = config(); + config.corpus_random_sequence_weight = if disable_mutators { 0 } else { 100 }; + if disable_mutators { + config.mutation_weights.mutation_weight_abi = 0; + config.mutation_weights.mutation_weight_cmp = 0; + } + let generator = + SequenceGenerator::stateless(generator_tx(9), state(), function.clone(), &config) + .unwrap(); + let plan = generator + .start(&mut TestRunner::default(), corpus.len(), |i| Ok(corpus[i]), true) + .unwrap(); + assert_eq!(plan.initial().len(), 1); + assert_eq!(plan.initial()[0].sender, tx(9).sender); + assert_eq!(plan.initial()[0].call_details.value, tx(9).call_details.value); + assert_eq!(plan.source(), None); + } + } + + #[test] + fn sequence_plan_continues_corpus_or_generates_when_unavailable() { + let mut config = config(); + config.corpus_random_sequence_weight = 0; + config.mutation_weights.mutation_weight_splice = 1; + config.mutation_weights.mutation_weight_repeat = 0; + config.mutation_weights.mutation_weight_interleave = 0; + config.mutation_weights.mutation_weight_prefix = 0; + config.mutation_weights.mutation_weight_suffix = 0; + config.mutation_weights.mutation_weight_abi = 0; + config.mutation_weights.mutation_weight_cmp = 0; + let targets = FuzzRunIdentifiedContracts::new(TargetedContracts::new(), false); + let generator = + SequenceGenerator::invariant(generator_tx(9), state(), targets, &config).unwrap(); + let mut runner = TestRunner::default(); + let plan = SequencePlan { + initial: InitialSequence::Multiple(vec![tx(1), tx(2)]), + tx: generator.tx, + fresh_weight: generator.fresh_weight, + has_corpus_dir: generator.has_corpus_dir, + stateless: false, + source: Some(0), + }; + + assert_eq!(plan.next(&mut runner, false, 1).unwrap().sender, tx(2).sender); + assert_eq!(plan.next(&mut runner, true, 1).unwrap().sender, tx(9).sender); + assert_eq!(plan.next(&mut runner, false, 2).unwrap().sender, tx(9).sender); + } + + #[test] + fn continuation_preserves_gate_draw_order() { + for (fresh_weight, discarded, depth, expected_sender, draws_gate) in [ + (0, false, 1, 2, false), + (100, false, 1, 9, true), + (50, false, 2, 9, true), + (50, true, 1, 9, false), + ] { + let plan = SequencePlan { + initial: InitialSequence::Multiple(vec![tx(1), tx(2)]), + tx: generator_tx(9), + fresh_weight, + has_corpus_dir: true, + stateless: false, + source: Some(0), + }; + let mut actual = TestRunner::deterministic(); + let mut reference = TestRunner::deterministic(); + if draws_gate { + let _ = reference.rng().random_ratio(fresh_weight, 100); + } + assert_eq!( + plan.next(&mut actual, discarded, depth).unwrap().sender, + tx(expected_sender).sender + ); + assert_eq!(sentinel(&mut actual), sentinel(&mut reference)); + } + } + + #[test] + fn forced_invariant_mutations_preserve_old_selection_draws() { + let sequences = [vec![tx(1), tx(2), tx(3)], vec![tx(4), tx(5), tx(6)]]; + let entries = sequences + .iter() + .map(|sequence| CorpusEntryView::new(sequence, &[]).unwrap()) + .collect::>(); + for kind in 0..7 { + let mut config = config(); + config.mutation_weights = forced_weights(kind); + let generator = SequenceGenerator::invariant( + generator_tx(9), + state(), + FuzzRunIdentifiedContracts::new(TargetedContracts::new(), false), + &config, + ) + .unwrap(); + let mut actual = TestRunner::deterministic(); + let mut reference = TestRunner::deterministic(); + + // The legacy producer selected the mutation, both corpus entries, and then the source + // before making operation-specific draws. Keep these draws explicit: this test is + // intended to catch seemingly harmless reordering during further extraction work. + let distribution = WeightedIndex::new( + [kind == 0, kind == 1, kind == 2, kind == 3, kind == 4, kind == 5, kind == 6] + .map(u32::from), + ) + .unwrap(); + assert_eq!(distribution.sample(reference.rng()), kind); + let a = reference.rng().random_range(0..entries.len()); + let b = reference.rng().random_range(0..entries.len()); + let source = match kind { + 0 | 2 => a, + _ => { + if reference.rng().random() { + a + } else { + b + } + } + }; + // ABI/CMP always selected a fallback transaction after choosing the source. + if kind >= 5 { + let _ = reference.rng().random_range(0..entries[source].transactions.len()); + } + + let plan = + generator.start(&mut actual, entries.len(), |i| Ok(entries[i]), true).unwrap(); + assert_eq!(plan.source(), Some(source), "mutation family {kind}"); + // For argument mutation there are no matching artifacts, so the fallback index is the + // final draw. Structural mutations make additional operation-specific draws and are + // covered independently by the mutator unit tests. + if kind >= 5 { + assert_eq!( + sentinel(&mut actual), + sentinel(&mut reference), + "mutation family {kind}" + ); + } + } + } + + #[test] + fn stateless_plan_never_exposes_continuation() { + let generator = SequenceGenerator::stateless( + generator_tx(9), + state(), + Function::parse("test()").unwrap(), + &config(), + ) + .unwrap(); + let plan = generator + .start(&mut TestRunner::deterministic(), 0, |_| unreachable!(), false) + .unwrap(); + assert!(plan.next(&mut TestRunner::deterministic(), false, 0).is_err()); + } + + #[test] + fn overflowing_effective_mutation_weight_is_rejected() { + let mut config = config(); + config.mutation_weights.mutation_weight_splice = u32::MAX; + config.mutation_weights.mutation_weight_repeat = 1; + assert!( + SequenceGenerator::stateless( + generator_tx(1), + state(), + Function::parse("test()").unwrap(), + &config, + ) + .is_err() + ); + } + + #[test] + fn invariant_cmp_only_does_not_fallback_to_abi() { + let target = Address::with_last_byte(42); + let function = Function::parse("test(uint256)").unwrap(); + let mut abi = JsonAbi::new(); + abi.functions.entry(function.name.clone()).or_default().push(function.clone()); + let mut contracts = TargetedContracts::new(); + contracts.insert(target, TargetedContract::new("Target".into(), abi)); + let mut original = tx(1); + original.call_details.target = target; + original.call_details.calldata = + function.abi_encode_input(&[DynSolValue::Uint(U256::from(7), 256)]).unwrap().into(); + let sequence = [original.clone()]; + let corpus = [CorpusEntryView::new(&sequence, &[]).unwrap()]; + let mut config = config(); + config.mutation_weights = FuzzCorpusMutationWeights { + mutation_weight_splice: 0, + mutation_weight_repeat: 0, + mutation_weight_interleave: 0, + mutation_weight_prefix: 0, + mutation_weight_suffix: 0, + mutation_weight_abi: 0, + mutation_weight_cmp: 1, + }; + let generator = SequenceGenerator::invariant( + generator_tx(9), + state(), + FuzzRunIdentifiedContracts::new(contracts, false), + &config, + ) + .unwrap(); + let plan = generator + .start(&mut TestRunner::default(), corpus.len(), |i| Ok(corpus[i]), true) + .unwrap(); + assert_eq!(plan.initial()[0].call_details.calldata, original.call_details.calldata); + } + + #[test] + fn malformed_corpus_views_are_rejected_without_panicking() { + assert!(CorpusEntryView::new(&[], &[]).is_err()); + let sequence = [tx(1)]; + let comparisons = [Vec::new(), Vec::new()]; + assert!(CorpusEntryView::new(&sequence, &comparisons).is_err()); + + let generator = SequenceGenerator::stateless( + generator_tx(9), + state(), + Function::parse("test(uint256)").unwrap(), + &config(), + ) + .unwrap(); + let result = generator.start( + &mut TestRunner::deterministic(), + 1, + |_| CorpusEntryView::new(&[], &[]), + true, + ); + assert!(result.is_err()); + } + + #[test] + fn start_accesses_at_most_two_corpus_entries() { + let mut config = config(); + config.mutation_weights.mutation_weight_splice = 1; + config.mutation_weights.mutation_weight_repeat = 0; + config.mutation_weights.mutation_weight_interleave = 0; + config.mutation_weights.mutation_weight_prefix = 0; + config.mutation_weights.mutation_weight_suffix = 0; + config.mutation_weights.mutation_weight_abi = 0; + config.mutation_weights.mutation_weight_cmp = 0; + let generator = SequenceGenerator::invariant( + generator_tx(9), + state(), + FuzzRunIdentifiedContracts::new(TargetedContracts::new(), false), + &config, + ) + .unwrap(); + let sequence = [tx(1)]; + let mut accesses = 0; + generator + .start( + &mut TestRunner::deterministic(), + 10_000, + |_| { + accesses += 1; + CorpusEntryView::new(&sequence, &[]) + }, + true, + ) + .unwrap(); + assert_eq!(accesses, 2); + } + + #[test] + fn applicable_hints_dedup_and_drop_operands_missing_from_calldata() { + let function = Function::parse("testCmp(uint256)").unwrap(); + let calldata = function.abi_encode_input(&[DynSolValue::Uint(U256::from(7), 256)]).unwrap(); + let hint = + |lhs: u64, rhs: u64| ComparisonHint { lhs: U256::from(lhs), rhs: U256::from(rhs) }; + + let hints = [hint(7, 42), hint(1000, 2000), hint(7, 42), hint(5, 5), hint(42, 7)]; + let applicable = ComparisonHint::applicable(&calldata, hints); + assert_eq!(applicable, [hint(7, 42), hint(42, 7)]); + for hint in hints { + let mut input = tx(7); + input.call_details.calldata = calldata.clone().into(); + let mutated = SequenceMutator::cmp_mutate( + &mut input, + &function, + &[hint], + &mut TestRunner::default(), + &FuzzFixtures::default(), + ) + .unwrap(); + assert_eq!(mutated, applicable.contains(&hint)); + } + } +} diff --git a/crates/evm/fuzz/src/strategies/calldata.rs b/crates/evm/fuzz/src/strategies/calldata.rs index c792af57f5b4e..90ec263baddd7 100644 --- a/crates/evm/fuzz/src/strategies/calldata.rs +++ b/crates/evm/fuzz/src/strategies/calldata.rs @@ -1,11 +1,157 @@ use crate::{ FuzzFixtures, - strategies::{EvmFuzzState, fuzz_param_from_state, fuzz_param_with_fixtures}, + strategies::{DictionaryRead, fuzz_param_from_state, fuzz_param_with_fixtures}, }; -use alloy_dyn_abi::JsonAbiExt; -use alloy_json_abi::Function; -use alloy_primitives::Bytes; -use proptest::prelude::Strategy; +use alloy_dyn_abi::{DynSolCall, DynSolReturns, DynSolType, DynSolValue}; +use alloy_json_abi::{Function, Param}; +use alloy_primitives::{Bytes, U256}; +use proptest::{prelude::Strategy, strategy::BoxedStrategy}; + +#[derive(Clone)] +struct CalldataEncoder { + call: DynSolCall, + name: String, + inputs: Vec, +} + +impl CalldataEncoder { + fn new(func: Function, input_types: Vec) -> Self { + let selector = func.selector(); + let name = func.name; + let inputs = func.inputs; + let call = DynSolCall::new(selector, input_types, None, DynSolReturns::new(Vec::new())); + Self { call, name, inputs } + } + + fn encode(&self, values: &[DynSolValue]) -> Bytes { + self.call + .abi_encode_input(values) + .unwrap_or_else(|_| { + panic!( + "Fuzzer generated invalid arguments for function `{}` with inputs {:?}: {:?}", + self.name, self.inputs, values + ) + }) + .into() + } +} + +fn parse_input_types(func: &Function) -> Vec { + func.inputs.iter().map(|input| input.selector_type().parse().unwrap()).collect() +} + +/// Plan for constraining the enum leaves of a fuzzed parameter into their valid `0..variant_count` +/// range. Solidity enums are ABI-encoded as `uint8`, so without this the fuzzer can generate +/// out-of-range values that the contract rejects with `Panic(0x21)` when decoding them. +#[derive(Clone, Debug)] +enum EnumClamp { + /// An enum (possibly nested in arrays); reduce every `uint8` leaf modulo the variant count. + Leaf(U256), + /// A tuple/struct; apply the per-component plans (`None` = no clamping needed). + Tuple(Vec>), +} + +impl EnumClamp { + /// Builds a clamp plan for `input`, returning `None` if it contains no enums to constrain. + fn for_param(input: &Param, fuzz_fixtures: &FuzzFixtures) -> Option { + // Direct enum, e.g. `EnumVal` or `EnumVal[2][]`; strip any array suffix. + if let Some((contract, ty)) = input.internal_type.as_ref().and_then(|it| it.as_enum()) { + let base = ty.split('[').next().unwrap_or(ty); + if let Some(count) = fuzz_fixtures.enum_variant_count(contract, base) + && count > 0 + { + return Some(Self::Leaf(U256::from(count))); + } + } + + // Struct/tuple (or `Struct[]`): recurse into components, keeping the plan only if any field + // needs clamping. + if input.components.is_empty() { + return None; + } + let fields = input + .components + .iter() + .map(|component| Self::for_param(component, fuzz_fixtures)) + .collect::>(); + fields.iter().any(Option::is_some).then_some(Self::Tuple(fields)) + } + + /// Applies the plan to a generated value, reducing every enum leaf into its valid range. + fn apply(&self, value: DynSolValue) -> DynSolValue { + match self { + Self::Leaf(count) => clamp_enum_leaf(value, *count), + Self::Tuple(fields) => match value { + DynSolValue::Tuple(values) => DynSolValue::Tuple(self.apply_fields(fields, values)), + DynSolValue::CustomStruct { name, prop_names, tuple } => { + DynSolValue::CustomStruct { + name, + prop_names, + tuple: self.apply_fields(fields, tuple), + } + } + // `Struct[]`/`Struct[N]`: apply the field plan to each element. + DynSolValue::Array(values) => { + DynSolValue::Array(values.into_iter().map(|v| self.apply(v)).collect()) + } + DynSolValue::FixedArray(values) => { + DynSolValue::FixedArray(values.into_iter().map(|v| self.apply(v)).collect()) + } + other => other, + }, + } + } + + fn apply_fields(&self, fields: &[Option], values: Vec) -> Vec { + values + .into_iter() + .enumerate() + .map(|(i, value)| match fields.get(i) { + Some(Some(plan)) => plan.apply(value), + _ => value, + }) + .collect() + } +} + +/// Constrains enum leaves in a decoded value to the range declared by its Solidity type. +pub(crate) fn constrain_enum_value( + value: DynSolValue, + input: &Param, + fuzz_fixtures: &FuzzFixtures, +) -> DynSolValue { + match EnumClamp::for_param(input, fuzz_fixtures) { + Some(plan) => plan.apply(value), + None => value, + } +} + +/// Wraps `strat` to constrain any enum leaves in `input` to their valid range; a no-op otherwise. +fn bound_enum( + strat: BoxedStrategy, + input: &Param, + fuzz_fixtures: &FuzzFixtures, +) -> BoxedStrategy { + match EnumClamp::for_param(input, fuzz_fixtures) { + Some(plan) => strat.prop_map(move |value| plan.apply(value)).boxed(), + None => strat, + } +} + +/// Recursively reduces every `uint8` leaf in an enum value (possibly nested in arrays) modulo +/// `count`. +fn clamp_enum_leaf(value: DynSolValue, count: U256) -> DynSolValue { + match value { + DynSolValue::Uint(v, 8) => DynSolValue::Uint(v % count, 8), + DynSolValue::Array(values) => { + DynSolValue::Array(values.into_iter().map(|v| clamp_enum_leaf(v, count)).collect()) + } + DynSolValue::FixedArray(values) => { + DynSolValue::FixedArray(values.into_iter().map(|v| clamp_enum_leaf(v, count)).collect()) + } + other => other, + } +} /// Given a function, it returns a strategy which generates valid calldata /// for that function's input types, following declared test fixtures. @@ -13,54 +159,45 @@ pub fn fuzz_calldata( func: Function, fuzz_fixtures: &FuzzFixtures, ) -> impl Strategy + use<> { + let input_types = parse_input_types(&func); // We need to compose all the strategies generated for each parameter in all // possible combinations, accounting any parameter declared fixture let strats = func .inputs .iter() - .map(|input| { - fuzz_param_with_fixtures( - &input.selector_type().parse().unwrap(), + .zip(&input_types) + .map(|(input, input_type)| { + let strat = fuzz_param_with_fixtures( + input_type, fuzz_fixtures.param_fixtures(&input.name), &input.name, - ) + ); + bound_enum(strat, input, fuzz_fixtures) }) .collect::>(); - strats.prop_map(move |values| { - func.abi_encode_input(&values) - .unwrap_or_else(|_| { - panic!( - "Fuzzer generated invalid arguments for function `{}` with inputs {:?}: {:?}", - func.name, func.inputs, values - ) - }) - .into() - }) + let encoder = CalldataEncoder::new(func, input_types); + strats.prop_map(move |values| encoder.encode(&values)) } /// Given a function and some state, it returns a strategy which generated valid calldata for the /// given function's input types, based on state taken from the EVM. -pub fn fuzz_calldata_from_state( +pub(crate) fn fuzz_calldata_from_state( func: Function, - state: &EvmFuzzState, -) -> impl Strategy + use<> { + state: &S, + fuzz_fixtures: &FuzzFixtures, +) -> impl Strategy + use { + let input_types = parse_input_types(&func); let strats = func .inputs .iter() - .map(|input| fuzz_param_from_state(&input.selector_type().parse().unwrap(), state)) - .collect::>(); - strats - .prop_map(move |values| { - func.abi_encode_input(&values) - .unwrap_or_else(|_| { - panic!( - "Fuzzer generated invalid arguments for function `{}` with inputs {:?}: {:?}", - func.name, func.inputs, values - ) - }) - .into() + .zip(&input_types) + .map(|(input, input_type)| { + let strat = fuzz_param_from_state(input_type, state); + bound_enum(strat, input, fuzz_fixtures) }) - .no_shrink() + .collect::>(); + let encoder = CalldataEncoder::new(func, input_types); + strats.prop_map(move |values| encoder.encode(&values)).no_shrink() } #[cfg(test)] @@ -68,7 +205,7 @@ mod tests { use crate::{FuzzFixtures, strategies::fuzz_calldata}; use alloy_dyn_abi::{DynSolValue, JsonAbiExt}; use alloy_json_abi::Function; - use alloy_primitives::{Address, map::HashMap}; + use alloy_primitives::{Address, U256, map::HashMap}; use proptest::prelude::Strategy; #[test] @@ -88,4 +225,33 @@ mod tests { assert_eq!(expected, fuzzed); }); } + + #[test] + fn calldata_encoder_matches_json_abi() { + let function = Function::parse("test_values(uint256,string,bytes,uint64[2])").unwrap(); + let values = vec![ + DynSolValue::Uint(U256::from(42), 256), + DynSolValue::String("hello".to_string()), + DynSolValue::Bytes(vec![0xaa, 0xbb, 0xcc]), + DynSolValue::FixedArray(vec![ + DynSolValue::Uint(U256::ONE, 64), + DynSolValue::Uint(U256::from(2), 64), + ]), + ]; + let expected = function.abi_encode_input(&values).unwrap(); + let encoder = + super::CalldataEncoder::new(function.clone(), super::parse_input_types(&function)); + + assert_eq!(expected, encoder.encode(&values)); + } + + #[test] + fn calldata_encoder_matches_json_abi_for_empty_inputs() { + let function = Function::parse("test_no_args()").unwrap(); + let expected = function.abi_encode_input(&[]).unwrap(); + let encoder = + super::CalldataEncoder::new(function.clone(), super::parse_input_types(&function)); + + assert_eq!(expected, encoder.encode(&[])); + } } diff --git a/crates/evm/fuzz/src/strategies/int.rs b/crates/evm/fuzz/src/strategies/int.rs index 91197a4f6e731..e0cf056994c9e 100644 --- a/crates/evm/fuzz/src/strategies/int.rs +++ b/crates/evm/fuzz/src/strategies/int.rs @@ -23,7 +23,7 @@ impl IntValueTree { /// # Arguments /// * `start` - Starting value for the tree /// * `fixed` - If `true` the tree would only contain one element and won't be simplified. - fn new(start: I256, fixed: bool) -> Self { + const fn new(start: I256, fixed: bool) -> Self { Self { lo: I256::ZERO, curr: start, hi: start, fixed } } @@ -124,13 +124,11 @@ impl IntStrategy { let rng = runner.rng(); let offset = I256::from_raw(U256::from(rng.random_range(0..4))); - let umax: U256 = (U256::from(1) << (self.bits - 1)) - U256::from(1); + let umax: U256 = (U256::ONE << (self.bits - 1)) - U256::ONE; // Choose if we want values around min, -0, +0, or max let kind = rng.random_range(0..4); let start = match kind { - 0 => { - I256::overflowing_from_sign_and_abs(Sign::Negative, umax + U256::from(1)).0 + offset - } + 0 => I256::overflowing_from_sign_and_abs(Sign::Negative, umax + U256::ONE).0 + offset, 1 => -offset - I256::ONE, 2 => offset, 3 => I256::overflowing_from_sign_and_abs(Sign::Positive, umax).0 - offset, @@ -183,17 +181,12 @@ impl IntStrategy { }; // init I256 from 2 randoms - let mut inner: [u64; 4] = [0; 4]; - let mask64 = (1 << 65) - 1; - inner[0] = (lower & mask64) as u64; - inner[1] = (lower >> 64) as u64; - inner[2] = (higher & mask64) as u64; - inner[3] = (higher >> 64) as u64; + let magnitude = (U256::from(higher) << 128) | U256::from(lower); // we have a small bias here, i.e. intN::min will never be generated // but it's ok since it's generated in `fn generate_edge_tree(...)` let sign = if rng.random::() { Sign::Positive } else { Sign::Negative }; - let (start, _) = I256::overflowing_from_sign_and_abs(sign, U256::from_limbs(inner)); + let (start, _) = I256::overflowing_from_sign_and_abs(sign, magnitude); Ok(IntValueTree::new(start, false)) } diff --git a/crates/evm/fuzz/src/strategies/invariants.rs b/crates/evm/fuzz/src/strategies/invariants.rs index f739b4f86dac1..d4d3c48ec6137 100644 --- a/crates/evm/fuzz/src/strategies/invariants.rs +++ b/crates/evm/fuzz/src/strategies/invariants.rs @@ -1,126 +1,75 @@ -use super::{fuzz_calldata, fuzz_param_from_state}; -use crate::{ - FuzzFixtures, - invariant::{BasicTxDetails, CallDetails, FuzzRunIdentifiedContracts, SenderFilters}, - strategies::{EvmFuzzState, fuzz_calldata_from_state, fuzz_param}, -}; +use super::TxGenerator; +use crate::{CallDetails, FuzzFixtures, strategies::EvmFuzzState}; use alloy_json_abi::Function; use alloy_primitives::Address; use parking_lot::RwLock; use proptest::prelude::*; use rand::seq::IteratorRandom; -use std::{rc::Rc, sync::Arc}; +use std::sync::Arc; /// Given a target address, we generate random calldata. pub fn override_call_strat( fuzz_state: EvmFuzzState, - contracts: FuzzRunIdentifiedContracts, + contracts: Vec<(Address, Vec)>, target: Arc>, fuzz_fixtures: FuzzFixtures, + dictionary_weight: u32, + payable_value_weight: u32, ) -> impl Strategy + Send + Sync + 'static { - let contracts_ref = contracts.targets.clone(); + // Each generated call owns its function-selection strategy. Share the functions so + // constructing that strategy does not clone the entire target ABI on every call. + let contracts = Arc::new( + contracts + .into_iter() + .map(|(address, functions)| (address, Arc::new(functions))) + .collect::>(), + ); + let contracts_ref = contracts.clone(); proptest::prop_oneof![ 80 => proptest::strategy::LazyJust::new(move || *target.read()), 20 => any::() - .prop_map(move |selector| *selector.select(contracts_ref.lock().keys())), + .prop_map(move |selector| { + let (target, _) = selector.select(contracts_ref.iter()); + *target + }), ] .prop_flat_map(move |target_address| { let fuzz_state = fuzz_state.clone(); let fuzz_fixtures = fuzz_fixtures.clone(); + let contracts = contracts.clone(); - let func = { - let contracts = contracts.targets.lock(); - let contract = contracts.get(&target_address).unwrap_or_else(|| { - // Choose a random contract if target selected by lazy strategy is not in fuzz run - // identified contracts. This can happen when contract is created in `setUp` call - // but is not included in targetContracts. - contracts.values().choose(&mut rand::rng()).unwrap() - }); - let fuzzed_functions: Vec<_> = contract.abi_fuzzed_functions().cloned().collect(); - any::().prop_map(move |index| index.get(&fuzzed_functions).clone()) + let (actual_target, func) = { + // If the target address is in the contracts map, use it directly. + // Otherwise, fall back to a random contract from the targeted contracts. + // This can happen when call_override sets target_reference to a contract + // that is not in targetContracts (e.g., the protocol contract during reentrancy). + let (actual_target, fuzzed_functions) = contracts + .iter() + .find(|(address, _)| *address == target_address) + .map(|(address, functions)| (*address, functions.clone())) + .unwrap_or_else(|| { + let (address, functions) = contracts + .iter() + .choose(&mut rand::rng()) + .expect("at least one target contract"); + (*address, functions.clone()) + }); + ( + actual_target, + any::() + .prop_map(move |index| index.get(&fuzzed_functions).clone()), + ) }; func.prop_flat_map(move |func| { - fuzz_contract_with_calldata(&fuzz_state, &fuzz_fixtures, target_address, func) - }) - }) -} - -/// Creates the invariant strategy. -/// -/// Given the known and future contracts, it generates the next call by fuzzing the `caller`, -/// `calldata` and `target`. The generated data is evaluated lazily for every single call to fully -/// leverage the evolving fuzz dictionary. -/// -/// The fuzzed parameters can be filtered through different methods implemented in the test -/// contract: -/// -/// `targetContracts()`, `targetSenders()`, `excludeContracts()`, `targetSelectors()` -pub fn invariant_strat( - fuzz_state: EvmFuzzState, - senders: SenderFilters, - contracts: FuzzRunIdentifiedContracts, - dictionary_weight: u32, - fuzz_fixtures: FuzzFixtures, -) -> impl Strategy { - let senders = Rc::new(senders); - any::() - .prop_flat_map(move |selector| { - let contracts = contracts.targets.lock(); - let functions = contracts.fuzzed_functions(); - let (target_address, target_function) = selector.select(functions); - let sender = select_random_sender(&fuzz_state, senders.clone(), dictionary_weight); - let call_details = fuzz_contract_with_calldata( + TxGenerator::call_strategy( &fuzz_state, &fuzz_fixtures, - *target_address, - target_function.clone(), - ); - (sender, call_details) + actual_target, + func, + dictionary_weight, + payable_value_weight, + ) }) - .prop_map(|(sender, call_details)| BasicTxDetails { sender, call_details }) -} - -/// Strategy to select a sender address: -/// * If `senders` is empty, then it's either a random address (10%) or from the dictionary (90%). -/// * If `senders` is not empty, a random address is chosen from the list of senders. -fn select_random_sender( - fuzz_state: &EvmFuzzState, - senders: Rc, - dictionary_weight: u32, -) -> impl Strategy + use<> { - if !senders.targeted.is_empty() { - any::().prop_map(move |index| *index.get(&senders.targeted)).boxed() - } else { - assert!(dictionary_weight <= 100, "dictionary_weight must be <= 100"); - proptest::prop_oneof![ - 100 - dictionary_weight => fuzz_param(&alloy_dyn_abi::DynSolType::Address), - dictionary_weight => fuzz_param_from_state(&alloy_dyn_abi::DynSolType::Address, fuzz_state), - ] - .prop_map(move |addr| addr.as_address().unwrap()) - // Too many exclusions can slow down testing. - .prop_filter("excluded sender", move |addr| !senders.excluded.contains(addr)) - .boxed() - } -} - -/// Given a function, it returns a proptest strategy which generates valid abi-encoded calldata -/// for that function's input types. -pub fn fuzz_contract_with_calldata( - fuzz_state: &EvmFuzzState, - fuzz_fixtures: &FuzzFixtures, - target: Address, - func: Function, -) -> impl Strategy + use<> { - // We need to compose all the strategies generated for each parameter in all possible - // combinations. - // `prop_oneof!` / `TupleUnion` `Arc`s for cheap cloning. - prop_oneof![ - 60 => fuzz_calldata(func.clone(), fuzz_fixtures), - 40 => fuzz_calldata_from_state(func, fuzz_state), - ] - .prop_map(move |calldata| { - trace!(input=?calldata); - CallDetails { target, calldata } }) } diff --git a/crates/evm/fuzz/src/strategies/literals.rs b/crates/evm/fuzz/src/strategies/literals.rs new file mode 100644 index 0000000000000..b791f376d219e --- /dev/null +++ b/crates/evm/fuzz/src/strategies/literals.rs @@ -0,0 +1,1171 @@ +use alloy_dyn_abi::DynSolType; +use alloy_primitives::{ + B256, Bytes, I256, U256, keccak256, + map::{B256IndexSet, HashMap, IndexSet}, +}; +use foundry_common::Analysis; +use foundry_compilers::ProjectPathsConfig; +use solar::{ + ast::{ + self, + BinOpKind::{Add, BitAnd, BitOr, BitXor, Div, Mul, Pow, Rem, Shl, Shr, Sub}, + Visit, + }, + interface::{Span, source_map::FileName}, +}; +use std::{ + cell::RefCell, + ops::ControlFlow, + sync::{Arc, OnceLock}, +}; + +/// Maximum nesting depth [`LiteralsCollector::eval`] recurses into, to bound stack usage. +const MAX_FOLD_DEPTH: usize = 128; + +#[derive(Clone, Debug)] +pub struct LiteralsDictionary { + maps: Arc>, +} + +impl Default for LiteralsDictionary { + fn default() -> Self { + Self::new(None, None, usize::MAX) + } +} + +impl LiteralsDictionary { + pub fn new( + analysis: Option, + paths_config: Option, + max_values: usize, + ) -> Self { + let maps = Arc::new(OnceLock::::new()); + if let Some(analysis) = analysis + && max_values > 0 + { + let maps = maps.clone(); + // This can't be done in a rayon task (including inside of `get`) because it can cause a + // deadlock, since internally `solar` also uses rayon. + let _ = std::thread::Builder::new().name("literal-collector".into()).spawn(move || { + let _ = maps.get_or_init(|| { + let literals = + LiteralsCollector::process(&analysis, paths_config.as_ref(), max_values); + debug!( + words = literals.words.values().map(|set| set.len()).sum::(), + strings = literals.strings.len(), + bytes = literals.bytes.len(), + "collected source code literals for fuzz dictionary" + ); + literals + }); + }); + } else { + maps.set(Default::default()).unwrap(); + } + Self { maps } + } + + /// Returns a reference to the `LiteralMaps`. + pub fn get(&self) -> &LiteralMaps { + self.maps.wait() + } + + /// Test-only helper to seed the dictionary with literal values. + #[cfg(test)] + pub(crate) fn set(&mut self, map: super::LiteralMaps) { + self.maps = Arc::new(OnceLock::new()); + self.maps.set(map).unwrap(); + } +} + +#[derive(Debug, Default)] +pub struct LiteralMaps { + pub words: HashMap, + pub strings: IndexSet, + pub bytes: IndexSet, +} + +/// Maps Solidity enum definitions to their variant counts, used to constrain fuzzed enum inputs +/// to valid values (the ABI encodes enums as `uint8` without carrying the variant count). +/// +/// Keys are `"."` for enums declared inside a contract/library and the bare +/// `""` for file-level enums, mirroring the qualifier in an ABI `internalType`. +#[derive(Clone, Debug, Default)] +pub struct EnumBounds { + inner: Arc>, +} + +impl EnumBounds { + /// Records the variant count of every enum declaration across all parsed sources (libraries + /// and dependencies included, as their enums may be used as test parameters). Keys with + /// conflicting counts are dropped, leaving ambiguous enums unbounded rather than bounded wrong. + pub fn collect(analysis: &Analysis) -> Self { + let bounds = analysis.enter(|compiler| { + let mut collector = EnumBoundsCollector::default(); + for source in compiler.sources().iter() { + if let Some(ast) = &source.ast { + let _ = collector.visit_source_unit(ast); + } + } + // Keep only unambiguous entries; ambiguous keys (`None`) are discarded. + collector + .bounds + .into_iter() + .filter_map(|(key, count)| count.map(|count| (key, count))) + .collect() + }); + Self { inner: Arc::new(bounds) } + } + + /// Returns the number of variants for an enum identified by an optional contract qualifier and + /// name, or `None` if it is unknown. + pub fn variant_count(&self, contract: Option<&str>, name: &str) -> Option { + let key = match contract { + Some(contract) => format!("{contract}.{name}"), + None => name.to_string(), + }; + self.inner.get(&key).copied() + } +} + +/// AST visitor that records enum variant counts, tracking the enclosing contract to build +/// fully-qualified keys. +#[derive(Default)] +struct EnumBoundsCollector { + /// Name of the contract/library currently being visited, if any. + current_contract: Option, + /// Collected `enum key -> variant count` entries. A value of `None` marks a key seen with + /// conflicting counts (ambiguous), which is later discarded. + bounds: HashMap>, +} + +impl<'ast> ast::Visit<'ast> for EnumBoundsCollector { + type BreakValue = (); + + fn visit_item_contract(&mut self, contract: &'ast ast::ItemContract<'ast>) -> ControlFlow<()> { + let prev = self.current_contract.replace(contract.name.as_str().to_string()); + let r = self.walk_item_contract(contract); + self.current_contract = prev; + r + } + + fn visit_item_enum(&mut self, enum_: &'ast ast::ItemEnum<'ast>) -> ControlFlow<()> { + let name = enum_.name.as_str(); + let count = enum_.variants.len(); + let key = match &self.current_contract { + Some(contract) => format!("{contract}.{name}"), + None => name.to_string(), + }; + self.bounds + .entry(key) + // Same key seen with a different count is ambiguous; mark it for removal. + .and_modify(|existing| { + if *existing != Some(count) { + *existing = None; + } + }) + .or_insert(Some(count)); + self.walk_item_enum(enum_) + } +} + +#[derive(Debug, Default)] +pub struct LiteralsCollector { + max_values: usize, + total_values: usize, + output: LiteralMaps, + /// Memoizes [`Self::eval`] results by expression span so overlapping subtrees are folded once. + eval_cache: RefCell>>, +} + +impl LiteralsCollector { + fn new(max_values: usize) -> Self { + Self { max_values, ..Default::default() } + } + + pub fn process( + analysis: &Analysis, + paths_config: Option<&ProjectPathsConfig>, + max_values: usize, + ) -> LiteralMaps { + analysis.enter(|compiler| { + let mut literals_collector = Self::new(max_values); + for source in compiler.sources().iter() { + // Ignore scripts, and libs + if let Some(paths) = paths_config + && let FileName::Real(source_path) = &source.file.name + && !(source_path.starts_with(&paths.sources) || paths.is_test(source_path)) + { + continue; + } + + if let Some(ast) = &source.ast + && literals_collector.visit_source_unit(ast).is_break() + { + break; + } + } + + literals_collector.output + }) + } + + /// Inserts a single word value under the given type, respecting the value limit. + fn insert_word(&mut self, ty: DynSolType, word: B256) { + if self.total_values < self.max_values + && self.output.words.entry(ty).or_default().insert(word) + { + self.total_values += 1; + } + } + + /// Inserts a string value, respecting the value limit. + fn insert_string(&mut self, s: String) { + if self.total_values < self.max_values && self.output.strings.insert(s) { + self.total_values += 1; + } + } + + /// Inserts a raw bytes value, respecting the value limit. + fn insert_bytes(&mut self, bytes: Bytes) { + if self.total_values < self.max_values && self.output.bytes.insert(bytes) { + self.total_values += 1; + } + } + + /// Seeds an unsigned value under all `uintN` sizes that can represent it. + fn seed_uint(&mut self, value: U256) { + let word = B256::from(value); + for bits in [8, 16, 32, 64, 128, 256] { + if can_fit_uint(value, bits) { + self.insert_word(DynSolType::Uint(bits), word); + } + } + self.insert_word(DynSolType::FixedBytes(32), word); + } + + /// Seeds a signed value under all `intN` sizes that can represent it. + fn seed_int(&mut self, value: I256) { + let word = B256::from(value.into_raw()); + for bits in [8, 16, 32, 64, 128, 256] { + if can_fit_int(value, bits) { + self.insert_word(DynSolType::Int(bits), word); + } + } + } + + /// Seeds a folded value: under its exact type when it carries a width, and (for integers) under + /// every smaller size that can represent it. + fn seed_num(&mut self, value: Num) { + match value { + Num::Int { raw, signed: false, width } => { + if let Some(bits) = width { + self.insert_word(DynSolType::Uint(bits), B256::from(raw)); + } + self.seed_uint(raw); + } + Num::Int { signed: true, width, .. } => { + let i = value.to_i256().expect("signed values always convert to I256"); + if let Some(bits) = width { + self.insert_word(DynSolType::Int(bits), B256::from(i.into_raw())); + } + self.seed_int(i); + } + // `bytesN` is left-aligned in the word, while integers are right-aligned. + Num::Bytes { raw, n } => { + let word = if n >= 32 { raw } else { raw.wrapping_shl((32 - n) * 8) }; + self.insert_word(DynSolType::FixedBytes(n), B256::from(word)); + } + } + } + + /// Attempts to constant-fold a compound expression and seed the resulting value(s). + /// + /// This walks recognized casts (`uintN`/`intN`/`bytesN`/`address`), `keccak256` of literal + /// arguments, `type(T).min`/`max`, and arithmetic/bitwise expressions over numeric literals. + fn fold_and_seed(&mut self, expr: &ast::Expr<'_>) { + if let ast::ExprKind::Call(callee, args) = &expr.kind + && let Some(arg) = single_arg(args) + { + match &callee.peel_parens().kind { + // A top-level `keccak256()` seeds a `bytes32` hash; nested, it is folded + // to its numeric value by `eval` instead. + ast::ExprKind::Ident(id) if id.as_str() == "keccak256" => { + if let Some(bytes) = lit_bytes(arg) { + self.insert_word(DynSolType::FixedBytes(32), keccak256(bytes)); + } + return; + } + // A top-level `address(...)` cast seeds under `address`; nested, it folds to a + // 160-bit integer via `cast_num`. + ast::ExprKind::Type(ty) + if matches!( + &ty.kind, + ast::TypeKind::Elementary(ast::ElementaryType::Address(_)) + ) => + { + if let Some(value) = self.eval(arg) { + self.insert_word( + DynSolType::Address, + B256::from(low_bits(value.full_raw(), 160)), + ); + } + return; + } + _ => {} + } + } + + if let Some(value) = self.eval(expr) { + self.seed_num(value); + } + } + + /// Recursively evaluates a constant expression to a numeric value, if possible. Results are + /// memoized by span and recursion is depth-bounded. + fn eval(&self, expr: &ast::Expr<'_>) -> Option { + self.eval_depth(expr, 0) + } + + fn eval_depth(&self, expr: &ast::Expr<'_>, depth: usize) -> Option { + if depth > MAX_FOLD_DEPTH { + return None; + } + let expr = expr.peel_parens(); + if let Some(cached) = self.eval_cache.borrow().get(&expr.span) { + return *cached; + } + let result = self.eval_kind(expr, depth); + // Only memoize successful folds, so a depth-limited `None` doesn't poison a subtree that is + // foldable when later visited as a shallower root. + if result.is_some() { + self.eval_cache.borrow_mut().insert(expr.span, result); + } + result + } + + fn eval_kind(&self, expr: &ast::Expr<'_>, depth: usize) -> Option { + match &expr.kind { + ast::ExprKind::Lit(lit, _) => match &lit.kind { + // Sub-denominations (e.g. `ether`, `days`) are already folded into the value. + ast::LitKind::Number(n) => Some(Num::untyped(*n)), + _ => None, + }, + ast::ExprKind::Unary(op, inner) => { + let value = self.eval_depth(inner, depth + 1)?; + match op.kind { + ast::UnOpKind::Neg => value.neg(), + // Bitwise-not complements the raw bits, preserving signedness and width. + ast::UnOpKind::BitNot => match value { + Num::Int { raw, signed, width } => Some(Num::int(!raw, signed, width)), + Num::Bytes { .. } => None, + }, + _ => None, + } + } + ast::ExprKind::Binary(lhs, op, rhs) => { + let a = self.eval_depth(lhs, depth + 1)?; + let b = self.eval_depth(rhs, depth + 1)?; + apply_bin(op.kind, a, b) + } + ast::ExprKind::Call(callee, args) => { + let arg = single_arg(args)?; + match &callee.peel_parens().kind { + ast::ExprKind::Type(ty) => { + let ast::TypeKind::Elementary(et) = &ty.kind else { return None }; + let value = self.eval_depth(arg, depth + 1)?; + cast_num(*et, value) + } + ast::ExprKind::Ident(id) if id.as_str() == "keccak256" => { + let bytes = lit_bytes(arg)?; + Some(Num::untyped(keccak256(bytes).into())) + } + _ => None, + } + } + // `type(uintN).max`, `type(intN).min`, `type(intN).max`. + ast::ExprKind::Member(inner, member) => { + let ast::ExprKind::TypeCall(ty) = &inner.peel_parens().kind else { return None }; + let ast::TypeKind::Elementary(et) = &ty.kind else { return None }; + type_min_max(*et, member.as_str()) + } + _ => None, + } + } +} + +impl<'ast> ast::Visit<'ast> for LiteralsCollector { + type BreakValue = (); + + fn visit_expr(&mut self, expr: &'ast ast::Expr<'ast>) -> ControlFlow<()> { + // Stop early if we've hit the limit + if self.total_values >= self.max_values { + return ControlFlow::Break(()); + } + + match &expr.kind { + // Handle plain literals. + ast::ExprKind::Lit(lit, _) => match &lit.kind { + ast::LitKind::Number(n) => self.seed_uint(*n), + ast::LitKind::Address(addr) => { + self.insert_word(DynSolType::Address, addr.into_word()) + } + ast::LitKind::Str(ast::StrKind::Hex, sym, _) => { + self.insert_bytes(Bytes::copy_from_slice(sym.as_byte_str())); + } + ast::LitKind::Str(_, sym, _) => { + let s = String::from_utf8_lossy(sym.as_byte_str()).into_owned(); + // For strings, also store the hashed version. + self.insert_word(DynSolType::FixedBytes(32), keccak256(s.as_bytes())); + // And the right-padded version if it fits. + if s.len() <= 32 { + self.insert_word( + DynSolType::FixedBytes(32), + B256::right_padding_from(s.as_bytes()), + ); + } + self.insert_string(s); + } + ast::LitKind::Bool(..) | ast::LitKind::Rational(..) | ast::LitKind::Err(..) => { + // ignore + } + }, + // Attempt to constant-fold compound expressions (casts, hashes, arithmetic). + _ => self.fold_and_seed(expr), + } + + self.walk_expr(expr) + } +} + +/// A folded constant value. Opportunistic 256-bit heuristic, not a Solidity-correct folder. +#[derive(Clone, Copy, Debug)] +enum Num { + /// An integer: low-`width` two's-complement bits (right-aligned), signedness, and the bit width + /// it was cast to (`None` = untyped literal, treated as 256-bit). + Int { raw: U256, signed: bool, width: Option }, + /// A `bytesN` value: the `n` significant bytes stored right-aligned. + Bytes { raw: U256, n: usize }, +} + +impl Num { + /// Creates an untyped (256-bit) unsigned integer from a literal. + const fn untyped(raw: U256) -> Self { + Self::Int { raw, signed: false, width: None } + } + + /// Creates an integer, normalizing `raw` to its width's low bits. + fn int(raw: U256, signed: bool, width: Option) -> Self { + let raw = match width { + Some(bits) => low_bits(raw, bits), + None => raw, + }; + Self::Int { raw, signed, width } + } + + /// Returns the in-width raw bits of the value (right-aligned, not sign-extended). + const fn as_u256(self) -> U256 { + match self { + Self::Int { raw, .. } | Self::Bytes { raw, .. } => raw, + } + } + + /// Returns the full 256-bit value, sign-extended from its width in a signed context. Used when + /// widening through a cast so the sign is preserved. + fn full_raw(self) -> U256 { + match self { + Self::Int { raw, signed: true, width } => sign_extend(raw, width), + _ => self.as_u256(), + } + } + + /// Returns the value as an [`I256`], sign-extended from its width, if it represents a signed + /// integer (in a signed context, or an unsigned value that fits the positive range). + fn to_i256(self) -> Option { + match self { + Self::Int { raw, signed: true, width } => Some(I256::from_raw(sign_extend(raw, width))), + Self::Int { raw, signed: false, .. } => I256::try_from(raw).ok(), + Self::Bytes { .. } => None, + } + } + + /// Returns `true` if the value is in a signed context. + const fn is_signed(self) -> bool { + matches!(self, Self::Int { signed: true, .. }) + } + + /// Returns the bit width carried by the value, if any. + const fn width(self) -> Option { + match self { + Self::Int { width, .. } => width, + Self::Bytes { .. } => None, + } + } + + /// Negates the value, keeping its width and switching to a signed context. Returns `None` if + /// the result doesn't fit [`I256`] or, for a width-carrying value, its `intN` type (negating + /// `type(intN).min` reverts under checked arithmetic). + fn neg(self) -> Option { + match self { + // `-x` fits in `I256` iff `x <= 2**255` (`|I256::MIN|`). + Self::Int { raw, signed: false, width } => { + (raw <= I256::MIN.into_raw()).then(|| Self::int(raw.wrapping_neg(), true, width)) + } + Self::Int { signed: true, width, .. } => { + let r = self.to_i256()?.checked_neg()?; + if let Some(bits) = width + && !can_fit_int(r, bits) + { + return None; + } + Some(Self::int(r.into_raw(), true, width)) + } + Self::Bytes { .. } => None, + } + } +} + +/// Applies a binary operator to two folded numeric values, carrying the result width so narrow +/// operands produce in-range results. +fn apply_bin(op: ast::BinOpKind, a: Num, b: Num) -> Option { + // `bytesN` operands aren't folded as integers. + if matches!(a, Num::Bytes { .. }) || matches!(b, Num::Bytes { .. }) { + return None; + } + + let signed = a.is_signed() || b.is_signed(); + // The result type of a shift is the left operand's type, and of `**` the base's; other + // operators take the wider operand. + let width = + if matches!(op, Shl | Shr | Pow) { a.width() } else { combine_width(a.width(), b.width()) }; + + // Bitwise AND/OR/XOR and left-shift operate purely on the (in-width) raw bits and are + // independent of signedness, so they fold in any context. + if matches!(op, BitAnd | BitOr | BitXor | Shl) { + let (x, y) = (a.as_u256(), b.as_u256()); + let raw = match op { + BitAnd => x & y, + BitOr => x | y, + BitXor => x ^ y, + Shl => shift_amount(y).map_or(U256::ZERO, |s| x.wrapping_shl(s)), + _ => unreachable!(), + }; + return Some(Num::int(raw, signed, width)); + } + + // Use signed arithmetic if either operand is in a signed context. Signed `>>` (arithmetic) is + // not folded here. + if signed { + let (x, y) = (a.to_i256()?, b.to_i256()?); + if op == Pow { + return signed_pow(x, y, width); + } + // Mirror the unsigned path: typed signed operands use checked arithmetic (an overflow of + // their `intN` type reverts at runtime), untyped literals keep 256-bit wrapping. + let r = match width { + Some(bits) => { + let r = match op { + Add => x.checked_add(y)?, + Sub => x.checked_sub(y)?, + Mul => x.checked_mul(y)?, + Div => x.checked_div(y)?, + Rem => x.checked_rem(y)?, + _ => return None, + }; + if !can_fit_int(r, bits) { + return None; + } + r + } + None => match op { + Add => x.wrapping_add(y), + Sub => x.wrapping_sub(y), + Mul => x.wrapping_mul(y), + Div => x.checked_div(y)?, + Rem => x.checked_rem(y)?, + _ => return None, + }, + }; + return Some(Num::int(r.into_raw(), true, width)); + } + + let (x, y) = (a.as_u256(), b.as_u256()); + let r = match op { + // Default-checked arithmetic: a width-carrying op overflowing its type reverts at runtime, + // so bail (`None`) instead of seeding the wrapped value. Untyped literals keep 256-bit + // wrapping. + Add => checked_arith(x.checked_add(y), x.wrapping_add(y), width)?, + Sub => checked_arith(x.checked_sub(y), x.wrapping_sub(y), width)?, + Mul => checked_arith(x.checked_mul(y), x.wrapping_mul(y), width)?, + Div => x.checked_div(y)?, + Rem => x.checked_rem(y)?, + // `pow` overflowing `U256` means an out-of-range constant, so bail rather than seed a + // wrapped value. (`2 ** 256 - 1` therefore doesn't fold; use `type(uint256).max`.) A + // width-carrying result must also fit its type, else checked arithmetic would revert. + Pow => { + let r = x.checked_pow(y)?; + checked_arith(Some(r), r, width)? + } + // Unsigned `>>` is a logical shift. + Shr => shift_amount(y).map_or(U256::ZERO, |s| x.wrapping_shr(s)), + // Comparisons and logical operators are not folded into values. + _ => return None, + }; + Some(Num::int(r, false, width)) +} + +/// Resolves a default-checked unsigned arithmetic result. +/// +/// For a width-carrying operand the result type is checked: it must neither overflow 256 bits +/// (`checked` is `None`) nor exceed its `uintN` width, otherwise the expression reverts at runtime +/// and we return `None` instead of seeding an unreachable value. Untyped literal operands keep the +/// 256-bit wrapping value. +fn checked_arith(checked: Option, wrapping: U256, width: Option) -> Option { + match width { + Some(bits) => checked.filter(|r| can_fit_uint(*r, bits)), + None => Some(wrapping), + } +} + +/// Folds a signed `base ** exp`, returning `None` on a negative exponent or a magnitude that +/// doesn't fit the signed range (overflow ~ compile error). +fn signed_pow(base: I256, exp: I256, width: Option) -> Option { + if exp.is_negative() { + return None; + } + let magnitude = base.unsigned_abs().checked_pow(exp.into_raw())?; + let negative = base.is_negative() && exp.into_raw().bit(0); + let value = if negative { + // Negative results must fit `[-2**255, 0)`, i.e. magnitude <= |I256::MIN|. + (magnitude <= I256::MIN.into_raw()).then(|| I256::from_raw(magnitude.wrapping_neg()))? + } else { + // Positive results must fit `[0, 2**255)`. + (magnitude < I256::MIN.into_raw()).then(|| I256::from_raw(magnitude))? + }; + // A width-carrying result that overflows its `intN` type reverts under checked arithmetic, so + // don't seed a value the expression can never produce. + if let Some(bits) = width + && !can_fit_int(value, bits) + { + return None; + } + Some(Num::int(value.into_raw(), true, width)) +} + +/// Combines the widths of two operands: an untyped operand (`None`) inherits the other's width; +/// two explicit widths pick the wider. +fn combine_width(a: Option, b: Option) -> Option { + match (a, b) { + (None, None) => None, + (Some(a), Some(b)) => Some(a.max(b)), + (Some(w), None) | (None, Some(w)) => Some(w), + } +} + +/// Folds `type(uintN).max`, `type(intN).min`, and `type(intN).max` (and `type(uintN).min` == 0). +fn type_min_max(ty: ast::ElementaryType, member: &str) -> Option { + match (ty, member) { + (ast::ElementaryType::UInt(size), "max") => { + let bits = size.bits() as usize; + Some(Num::int(low_bits(U256::MAX, bits), false, Some(bits))) + } + (ast::ElementaryType::UInt(size), "min") => { + Some(Num::int(U256::ZERO, false, Some(size.bits() as usize))) + } + // `intN` max is `2**(N-1) - 1`; min is `-2**(N-1)`, whose two's-complement is just the + // sign bit set within the width. + (ast::ElementaryType::Int(size), "max") => { + let bits = size.bits() as usize; + Some(Num::int(low_bits(U256::MAX, bits - 1), true, Some(bits))) + } + (ast::ElementaryType::Int(size), "min") => { + let bits = size.bits() as usize; + Some(Num::int(U256::ONE.wrapping_shl(bits - 1), true, Some(bits))) + } + _ => None, + } +} + +/// Returns the shift amount as a `usize`, or `None` if it is `>= 256` (which shifts out all bits). +fn shift_amount(y: U256) -> Option { + (y < U256::from(256u64)).then(|| y.as_limbs()[0] as usize) +} + +/// Reinterprets a folded value for an elementary type cast used inside a larger expression, +/// applying the target width so truncation and sign-extension are correct (e.g. +/// `int256(uint256(type(uint256).max))` -> `-1`). +fn cast_num(ty: ast::ElementaryType, value: Num) -> Option { + match ty { + ast::ElementaryType::UInt(size) => { + Some(Num::int(value.full_raw(), false, Some(size.bits() as usize))) + } + ast::ElementaryType::Int(size) => { + Some(Num::int(value.full_raw(), true, Some(size.bits() as usize))) + } + ast::ElementaryType::Address(_) => Some(Num::int(value.full_raw(), false, Some(160))), + ast::ElementaryType::FixedBytes(size) => Some(cast_to_bytes(value, size.bytes() as usize)), + _ => None, + } +} + +/// Casts a folded value to `bytesN`, keeping the value right-aligned in `Num::Bytes`. +/// +/// `bytesM(bytesN)` keeps the leftmost `min(M, N)` bytes (and pads on the right when widening); +/// casting an integer keeps its low `N` bytes. +fn cast_to_bytes(value: Num, n: usize) -> Num { + let raw = match value { + Num::Bytes { raw, n: m } if n <= m => raw.wrapping_shr((m - n) * 8), + Num::Bytes { raw, n: m } => raw.wrapping_shl((n - m) * 8), + _ => low_bits(value.full_raw(), n * 8), + }; + Num::Bytes { raw, n } +} + +/// Returns the low `bits` of `value`, zeroing everything above. +fn low_bits(value: U256, bits: usize) -> U256 { + if bits >= 256 { value } else { value & (U256::ONE.wrapping_shl(bits) - U256::ONE) } +} + +/// Sign-extends the low `width` bits of `raw` to a full 256-bit two's-complement value. +fn sign_extend(raw: U256, width: Option) -> U256 { + match width { + Some(bits) if bits < 256 && raw.bit(bits - 1) => raw | U256::MAX.wrapping_shl(bits), + _ => raw, + } +} + +/// Returns the single positional argument of a call, if it has exactly one. +fn single_arg<'a, 'ast>(args: &'a ast::CallArgs<'ast>) -> Option<&'a ast::Expr<'ast>> { + let mut exprs = args.exprs(); + (exprs.len() == 1).then(|| exprs.next()).flatten() +} + +/// Extracts the raw bytes of a string, unicode, or hex string literal argument, borrowing them +/// directly from the interner to avoid allocating (and re-hashing) on every fold. +fn lit_bytes<'a>(expr: &'a ast::Expr<'_>) -> Option<&'a [u8]> { + if let ast::ExprKind::Lit(lit, _) = &expr.peel_parens().kind + && let ast::LitKind::Str(_, sym, _) = &lit.kind + { + return Some(sym.as_byte_str()); + } + None +} + +/// Checks if a signed integer value can fit in intN type. +fn can_fit_int(value: I256, bits: usize) -> bool { + // Calculate the maximum positive value for intN: 2^(N-1) - 1 + let max_val = I256::try_from((U256::ONE << (bits - 1)) - U256::ONE) + .expect("max value should fit in I256"); + // Calculate the minimum negative value for intN: -2^(N-1) + let min_val = -max_val - I256::ONE; + + value >= min_val && value <= max_val +} + +/// Checks if an unsigned integer value can fit in uintN type. +fn can_fit_uint(value: U256, bits: usize) -> bool { + if bits == 256 { + return true; + } + // Calculate the maximum value for uintN: 2^N - 1 + let max_val = (U256::ONE << bits) - U256::ONE; + value <= max_val +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::address; + use solar::interface::{Session, source_map}; + + const SOURCE: &str = r#" + contract Magic { + // plain literals + address constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F; + uint64 constant MAGIC_NUMBER = 1122334455; + int32 constant MAGIC_INT = -777; + bytes32 constant MAGIC_WORD = "abcd1234"; + bytes constant MAGIC_BYTES = hex"deadbeef"; + string constant MAGIC_STRING = "xyzzy"; + + // constant exprs with folding + uint256 constant NEG_FOLDING = uint(-2); + uint256 constant BIN_FOLDING = 2 * 2 ether; + bytes32 constant IMPLEMENTATION_SLOT = bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1); + }"#; + + #[test] + fn test_literals_collector_coverage() { + let map = process_source_literals(SOURCE); + + // Expected values from the SOURCE contract + let addr = address!("0x6B175474E89094C44Da98b954EedeAC495271d0F").into_word(); + let num = B256::from(U256::from(1122334455u64)); + let int = B256::from(I256::try_from(-777i32).unwrap().into_raw()); + let word = B256::right_padding_from(b"abcd1234"); + let dyn_bytes = Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef]); + + assert_word(&map, DynSolType::Address, addr, "Expected DAI in address set"); + assert_word(&map, DynSolType::Uint(64), num, "Expected MAGIC_NUMBER in uint64 set"); + assert_word(&map, DynSolType::FixedBytes(32), num, "Expected MAGIC_NUMBER in bytes32 set"); + assert_word(&map, DynSolType::Int(32), int, "Expected MAGIC_INT in int32 set"); + assert_word(&map, DynSolType::FixedBytes(32), word, "Expected MAGIC_WORD in bytes32 set"); + assert!(map.strings.contains("xyzzy"), "Expected MAGIC_STRING to be collected"); + assert!( + map.strings.contains("eip1967.proxy.implementation"), + "Expected IMPLEMENTATION_SLOT in string set" + ); + assert!(map.bytes.contains(&dyn_bytes), "Expected MAGIC_BYTES in bytes set"); + + // -- folded constant expressions -- + + // `uint(-2)` folds to `2**256 - 2`. + let neg_cast = B256::from(U256::MAX - U256::ONE); + assert_word(&map, DynSolType::Uint(256), neg_cast, "Expected uint(-2) to be folded"); + + // `2 * 2 ether` folds to `4e18`. + let bin = B256::from(U256::from(4_000_000_000_000_000_000u64)); + assert_word(&map, DynSolType::Uint(64), bin, "Expected `2 * 2 ether` to be folded"); + + // `bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1)` folds to the + // well-known EIP-1967 implementation slot. + let slot = + B256::from(Into::::into(keccak256("eip1967.proxy.implementation")) - U256::ONE); + assert_word( + &map, + DynSolType::FixedBytes(32), + slot, + "Expected IMPLEMENTATION_SLOT expression to be folded", + ); + } + + #[test] + fn test_literals_collector_size() { + let literals = process_source_literals(SOURCE); + + // Helper to get count for a type, returns 0 if not present + let count = |ty: DynSolType| literals.words.get(&ty).map_or(0, |set| set.len()); + + assert_eq!(count(DynSolType::Address), 1, "Address literal count mismatch"); + assert_eq!(literals.strings.len(), 3, "String literals count mismatch"); + assert_eq!(literals.bytes.len(), 1, "Byte literals count mismatch"); + + // Unsigned integers. Bare literals {1, 2, 777, 1122334455, 2e18} are seeded under every + // `uintN` that fits, plus the folded values `4e18` (`2 * 2 ether`) and, for `uint256`, + // `2**256 - 2` (`uint(-2)`), `K` and `K - 1` where `K = keccak256("eip1967...")`. + assert_eq!(count(DynSolType::Uint(8)), 2, "Uint(8) count mismatch"); + assert_eq!(count(DynSolType::Uint(16)), 3, "Uint(16) count mismatch"); + assert_eq!(count(DynSolType::Uint(32)), 4, "Uint(32) count mismatch"); + assert_eq!(count(DynSolType::Uint(64)), 6, "Uint(64) count mismatch"); + assert_eq!(count(DynSolType::Uint(128)), 6, "Uint(128) count mismatch"); + assert_eq!(count(DynSolType::Uint(256)), 9, "Uint(256) count mismatch"); + + // Signed integers - MAGIC_INT (-777) and the folded `-2` appear in multiple sizes; only + // `-2` fits `int8` (-777 is out of range). + assert_eq!(count(DynSolType::Int(8)), 1, "Int(8) count mismatch"); + assert_eq!(count(DynSolType::Int(16)), 2, "Int(16) count mismatch"); + assert_eq!(count(DynSolType::Int(32)), 2, "Int(32) count mismatch"); + assert_eq!(count(DynSolType::Int(64)), 2, "Int(64) count mismatch"); + assert_eq!(count(DynSolType::Int(128)), 2, "Int(128) count mismatch"); + assert_eq!(count(DynSolType::Int(256)), 2, "Int(256) count mismatch"); + + // FixedBytes(32) includes: + // - MAGIC_WORD + // - String literals (hashed and right-padded versions) + // - The folded EIP-1967 slot `K - 1` (`K` itself dedups with the hashed string literal) + // - Unsigned numeric values, which use the same ABI word representation + assert_eq!(count(DynSolType::FixedBytes(32)), 14, "FixedBytes(32) count mismatch"); + + // Total count check + assert_eq!( + literals.words.values().map(|set| set.len()).sum::(), + 56, + "Total word values count mismatch" + ); + } + + #[test] + fn test_width_aware_casts() { + // Casts truncate/sign-extend to the target width, widening respects the source's + // signedness, and `~`/signed `**` keep the signed context. + let source = r#" + contract C { + uint8 constant A = uint8(-2); // 254 + uint8 constant B = uint8(257); // 1 + int8 constant D = int8(255); // -1 + int256 constant E = int256(1) - 2; // -1 + int256 constant F = ~int256(0); // -1 (not 2**256 - 1) + int16 constant G = int16(int8(-1)); // -1 (sign-extended) + int16 constant H = int16(uint8(255)); // 255 (unsigned source) + uint16 constant I = uint16(int8(-1)); // 65535 + int256 constant J = -2 ** 255; // int256 min + bytes4 constant K = bytes4(uint32(0x12345678)); // left-aligned + }"#; + let map = process_source_literals(source); + + let neg_one = B256::from(I256::try_from(-1).unwrap().into_raw()); + assert_word(&map, DynSolType::Uint(8), B256::with_last_byte(254), "uint8(-2) -> 254"); + assert_word(&map, DynSolType::Uint(8), B256::with_last_byte(1), "uint8(257) -> 1"); + assert_word(&map, DynSolType::Int(8), neg_one, "int8(255) -> -1"); + assert_word(&map, DynSolType::Int(256), neg_one, "int256(1) - 2 -> -1"); + assert_word(&map, DynSolType::Int(256), neg_one, "~int256(0) -> -1"); + assert_word(&map, DynSolType::Int(16), neg_one, "int16(int8(-1)) -> -1"); + assert_word( + &map, + DynSolType::Int(16), + B256::with_last_byte(255), + "int16(uint8(255)) -> 255", + ); + assert_word( + &map, + DynSolType::Uint(16), + B256::from(U256::from(65535)), + "uint16(int8(-1)) -> 65535", + ); + assert_word( + &map, + DynSolType::Int(256), + B256::from(I256::MIN.into_raw()), + "-2 ** 255 -> int256 min", + ); + + let left_aligned = B256::right_padding_from(&[0x12, 0x34, 0x56, 0x78]); + assert_word(&map, DynSolType::FixedBytes(4), left_aligned, "bytes4 is left-aligned"); + } + + #[test] + fn test_width_dependent_ops_stay_in_width() { + // `~`, shifts, and arithmetic on a narrow operand stay within its width instead of leaking + // 256-bit results; an untyped literal inherits the typed operand's width. + let source = r#" + contract C { + uint8 constant A = ~uint8(0); // 255 + uint8 constant B = uint8(1) << 8; // 0 + int8 constant D = int8(1) << 7; // -128 + uint8 constant E = uint8(255) << 256; // 0 (shift amount >= 256) + uint8 constant F = uint8(250) + 5; // 255 (in-range, typed + untyped literal) + uint8 constant G = uint8(10) ** uint256(2); // 100 (in-range, result type is base uint8) + uint8 constant H = uint8(0x80) >> uint256(0); // 128 (result type is left uint8) + }"#; + let map = process_source_literals(source); + + assert_word(&map, DynSolType::Uint(8), B256::with_last_byte(255), "~uint8(0) -> 255"); + assert_word(&map, DynSolType::Uint(8), B256::ZERO, "uint8(_) << {8,256} -> 0"); + assert_word(&map, DynSolType::Uint(8), B256::with_last_byte(255), "uint8(250) + 5 -> 255"); + assert_word(&map, DynSolType::Uint(8), B256::with_last_byte(100), "uint8(10) ** 2 -> 100"); + assert_word( + &map, + DynSolType::Uint(8), + B256::with_last_byte(128), + "uint8(0x80) >> 0 -> 128", + ); + let neg_128 = B256::from(I256::try_from(-128).unwrap().into_raw()); + assert_word(&map, DynSolType::Int(8), neg_128, "int8(1) << 7 -> -128"); + + // The in-width `~` result must not leak its 256-bit form (`uint256::MAX`) into a larger + // bucket. (In-range arithmetic results like `255` legitimately seed every width that fits.) + assert!( + !map.words + .get(&DynSolType::Uint(256)) + .is_some_and(|s| s.contains(&B256::from(U256::MAX))), + "~uint8(0) must not seed a uint256 max" + ); + } + + #[test] + fn test_checked_overflow_does_not_seed() { + // Solidity arithmetic is checked by default: a width-carrying `+`/`-`/`*`/`**` that + // overflows its type reverts at runtime, so the folder must not seed the (wrapped) value + // that can never occur. Operands and in-range siblings still fold normally. + let source = r#" + contract C { + uint8 constant A = uint8(250) + 10; // 260 -> reverts (panic 0x11), not 4 + uint8 constant B = uint8(200) * 2; // 400 -> reverts, not 144 + uint8 constant C2 = uint8(1) - 2; // underflow -> reverts, not 255 + uint8 constant D = uint8(10) ** 3; // 1000 -> reverts, not 232 + int8 constant E = int8(100) + 100; // 200 -> reverts, not -56 + int8 constant F = int8(64) * 2; // 128 -> reverts, not -128 + int8 constant G = int8(5) ** 3; // 125 -> in range, folds + }"#; + let map = process_source_literals(source); + + // None of the wrapped values may be seeded under any width. + let seeded = + |ty, raw: U256| map.words.get(&ty).is_some_and(|s| s.contains(&B256::from(raw))); + for bits in [8usize, 16, 32, 64, 128, 256] { + assert!( + !seeded(DynSolType::Uint(bits), U256::from(4)), + "uint8(250)+10 must not seed 4" + ); + assert!( + !seeded(DynSolType::Uint(bits), U256::from(144)), + "uint8(200)*2 must not seed 144" + ); + assert!( + !seeded(DynSolType::Uint(bits), U256::from(255)), + "uint8(1)-2 must not seed 255" + ); + assert!( + !seeded(DynSolType::Uint(bits), U256::from(232)), + "uint8(10)**3 must not seed 232" + ); + } + let neg_56 = I256::try_from(-56).unwrap().into_raw(); + let neg_128 = I256::try_from(-128).unwrap().into_raw(); + for bits in [8usize, 16, 32, 64, 128, 256] { + assert!(!seeded(DynSolType::Int(bits), neg_56), "int8(100)+100 must not seed -56"); + assert!(!seeded(DynSolType::Int(bits), neg_128), "int8(64)*2 must not seed -128"); + } + + // The in-range expression and the cast operands themselves still fold. + assert_word(&map, DynSolType::Int(8), B256::with_last_byte(125), "int8(5) ** 3 -> 125"); + assert_word(&map, DynSolType::Uint(8), B256::with_last_byte(250), "operand uint8(250)"); + } + + #[test] + fn test_checked_negation_overflow_does_not_seed() { + // Negating `type(intN).min` overflows the `intN` type and reverts under checked arithmetic, + // so the (wrapped) min value must not be re-seeded; in-range negations still fold. + let source = r#" + contract C { + int8 constant A = -type(int8).min + 1; // -(-128) reverts, must not seed -127 + int8 constant B = -int8(1); // -1, folds normally + }"#; + let map = process_source_literals(source); + + let neg_127 = I256::try_from(-127).unwrap().into_raw(); + let seeded = + |ty, raw: U256| map.words.get(&ty).is_some_and(|s| s.contains(&B256::from(raw))); + for bits in [8usize, 16, 32, 64, 128, 256] { + assert!( + !seeded(DynSolType::Int(bits), neg_127), + "-type(int8).min + 1 must not seed -127" + ); + } + let neg_one = B256::from(I256::try_from(-1).unwrap().into_raw()); + assert_word(&map, DynSolType::Int(8), neg_one, "-int8(1) -> -1"); + } + + #[test] + fn test_fixed_bytes_folding() { + // `bytesN` is left-aligned; truncation keeps the leftmost bytes (and must not zero the + // word), widening pads on the right. + let source = r#" + contract C { + bytes4 constant A = bytes4(uint256(0xdeadbeef12345678)); // low 4 bytes, not zero + bytes2 constant B = bytes2(bytes4(uint32(0x12345678))); // keep left -> 0x1234 + bytes4 constant D = bytes4(bytes2(uint16(0x1234))); // pad right -> 0x12340000 + }"#; + let map = process_source_literals(source); + + let low4 = B256::right_padding_from(&[0x12, 0x34, 0x56, 0x78]); + assert_word(&map, DynSolType::FixedBytes(4), low4, "bytes4 must not fold to zero"); + let left2 = B256::right_padding_from(&[0x12, 0x34]); + assert_word(&map, DynSolType::FixedBytes(2), left2, "bytes2(bytes4(..)) keeps left bytes"); + let padded = B256::right_padding_from(&[0x12, 0x34, 0x00, 0x00]); + assert_word(&map, DynSolType::FixedBytes(4), padded, "bytes4(bytes2(..)) pads right"); + } + + #[test] + fn test_type_min_max_folding() { + let source = r#" + contract C { + uint256 constant A = type(uint256).max; + uint8 constant B = type(uint8).max; // 255 + int256 constant D = type(int256).min; + int256 constant E = type(int256).max; + uint256 constant F = type(uint256).max - 1; + int8 constant G = type(int8).min; // -128 + int24 constant H = type(int24).min; // -2**23 + }"#; + let map = process_source_literals(source); + + assert_word(&map, DynSolType::Uint(256), B256::from(U256::MAX), "type(uint256).max"); + assert_word(&map, DynSolType::Uint(8), B256::with_last_byte(255), "type(uint8).max"); + assert_word( + &map, + DynSolType::Int(256), + B256::from(I256::MIN.into_raw()), + "type(int256).min", + ); + assert_word( + &map, + DynSolType::Int(256), + B256::from(I256::MAX.into_raw()), + "type(int256).max", + ); + let max_minus_one = B256::from(U256::MAX - U256::ONE); + assert_word(&map, DynSolType::Uint(256), max_minus_one, "type(uint256).max - 1"); + let min8 = B256::from(I256::try_from(-128).unwrap().into_raw()); + assert_word(&map, DynSolType::Int(8), min8, "type(int8).min -> -128"); + let min24 = B256::from(I256::try_from(-(1i64 << 23)).unwrap().into_raw()); + assert_word(&map, DynSolType::Int(24), min24, "type(int24).min -> -2**23"); + } + + #[test] + fn test_address_cast_seeds_address_type() { + // A folded `address(...)` cast must be seeded under `address`, not leak into a `uint160` + // bucket. + let source = r#" + contract C { + address constant A = address(4660); + }"#; + let map = process_source_literals(source); + + assert_word( + &map, + DynSolType::Address, + B256::from(low_bits(U256::from(4660), 160)), + "address(4660) -> address", + ); + assert_eq!(map.words.get(&DynSolType::Uint(160)), None, "must not seed a uint160 bucket"); + } + + #[test] + fn test_max_values_is_respected() { + // Each string literal seeds up to 3 values (hash, padded, and the string itself), so a + // limit of 2 must stop collection mid-string rather than overrun. + let source = r#" + contract C { + string constant A = "aaa"; + string constant B = "bbb"; + string constant D = "ccc"; + }"#; + let map = process_source_literals_with_max(source, 2); + + let total = map.words.values().map(|set| set.len()).sum::() + + map.strings.len() + + map.bytes.len(); + assert!(total <= 2, "max_values not respected: collected {total} values"); + } + + // -- TEST HELPERS --------------------------------------------------------- + + fn process_source_literals(source: &str) -> LiteralMaps { + process_source_literals_with_max(source, usize::MAX) + } + + fn process_source_literals_with_max(source: &str, max_values: usize) -> LiteralMaps { + let mut compiler = + solar::sema::Compiler::new(Session::builder().with_stderr_emitter().build()); + compiler + .enter_mut(|c| -> std::io::Result<()> { + let mut pcx = c.parse(); + pcx.set_resolve_imports(false); + + pcx.add_file( + c.sess().source_map().new_source_file(source_map::FileName::Stdin, source)?, + ); + pcx.parse(); + let _ = c.lower_asts(); + Ok(()) + }) + .expect("Failed to compile test source"); + + LiteralsCollector::process(&std::sync::Arc::new(compiler), None, max_values) + } + + fn assert_word(literals: &LiteralMaps, ty: DynSolType, value: B256, msg: &str) { + assert!(literals.words.get(&ty).is_some_and(|set| set.contains(&value)), "{}", msg); + } +} diff --git a/crates/evm/fuzz/src/strategies/mod.rs b/crates/evm/fuzz/src/strategies/mod.rs index 1d8e647a52f3f..ab4f5954fbba2 100644 --- a/crates/evm/fuzz/src/strategies/mod.rs +++ b/crates/evm/fuzz/src/strategies/mod.rs @@ -5,13 +5,25 @@ mod uint; pub use uint::UintStrategy; mod param; -pub use param::{fuzz_param, fuzz_param_from_state, fuzz_param_with_fixtures}; +pub use param::{fuzz_msg_value, fuzz_param, fuzz_param_with_fixtures, generate_msg_value}; +pub(crate) use param::{fuzz_param_from_state, mutate_param_value}; mod calldata; -pub use calldata::{fuzz_calldata, fuzz_calldata_from_state}; +pub use calldata::fuzz_calldata; +pub(crate) use calldata::{constrain_enum_value, fuzz_calldata_from_state}; mod state; -pub use state::EvmFuzzState; +pub(crate) use state::DictionaryRead; +pub use state::{EvmFuzzState, FuzzState}; mod invariants; -pub use invariants::{fuzz_contract_with_calldata, invariant_strat, override_call_strat}; +pub use invariants::override_call_strat; + +mod tx; +pub use tx::TxGenerator; + +mod mutators; +pub use mutators::BoundMutator; + +mod literals; +pub use literals::{EnumBounds, LiteralMaps, LiteralsCollector, LiteralsDictionary}; diff --git a/crates/evm/fuzz/src/strategies/mutators.rs b/crates/evm/fuzz/src/strategies/mutators.rs new file mode 100644 index 0000000000000..dd5265a91d826 --- /dev/null +++ b/crates/evm/fuzz/src/strategies/mutators.rs @@ -0,0 +1,760 @@ +use alloy_dyn_abi::Word; +use alloy_primitives::{Address, I256, Sign, U256}; +use proptest::{prelude::*, test_runner::TestRunner}; +use rand::seq::IndexedRandom; +use std::fmt::Debug; + +// Interesting 8-bit values to inject. +static INTERESTING_8: &[i8] = &[-128, -1, 0, 1, 16, 32, 64, 100, 127]; + +/// Interesting 16-bit values to inject. +static INTERESTING_16: &[i16] = &[ + -128, -1, 0, 1, 16, 32, 64, 100, 127, -32768, -129, 128, 255, 256, 512, 1000, 1024, 4096, 32767, +]; + +/// Interesting 32-bit values to inject. +static INTERESTING_32: &[i32] = &[ + -128, + -1, + 0, + 1, + 16, + 32, + 64, + 100, + 127, + -32768, + -129, + 128, + 255, + 256, + 512, + 1000, + 1024, + 4096, + 32767, + -2147483648, + -100663046, + -32769, + 32768, + 65535, + 65536, + 100663045, + 2147483647, +]; + +/// Multipliers used to define the 3 standard deviation range of a Gaussian-like curve. +/// For example, a multiplier of 0.25 means the +/-3 standard deviation bounds are +/-25% of the +/// original value. +static THREE_SIGMA_MULTIPLIERS: &[f64] = &[0.1, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0]; + +/// Mutator that randomly increments or decrements an uint or int. +pub(crate) trait IncrementDecrementMutator: Sized + Copy + Debug { + fn validate(old: Self, new: Self, size: usize) -> Option; + + #[instrument( + name = "mutator::increment_decrement", + level = "trace", + skip(size, test_runner), + ret + )] + fn increment_decrement(self, size: usize, test_runner: &mut TestRunner) -> Option { + let mutated = if test_runner.rng().random::() { + self.wrapping_add(Self::ONE) + } else { + self.wrapping_sub(Self::ONE) + }; + Self::validate(self, mutated, size) + } + + fn wrapping_add(self, rhs: Self) -> Self; + fn wrapping_sub(self, rhs: Self) -> Self; + const ONE: Self; +} + +macro_rules! impl_increment_decrement_mutator { + ($ty:ty, $validate_fn:path) => { + impl IncrementDecrementMutator for $ty { + fn validate(old: Self, new: Self, size: usize) -> Option { + $validate_fn(old, new, size) + } + + fn wrapping_add(self, rhs: Self) -> Self { + Self::wrapping_add(self, rhs) + } + + fn wrapping_sub(self, rhs: Self) -> Self { + Self::wrapping_sub(self, rhs) + } + + const ONE: Self = Self::ONE; + } + }; +} + +impl_increment_decrement_mutator!(U256, validate_uint_mutation); +impl_increment_decrement_mutator!(I256, validate_int_mutation); + +/// Mutator that changes the current value of an uint or int by applying gaussian noise. +pub(crate) trait GaussianNoiseMutator: Sized + Copy + Debug { + fn mutate_with_gaussian_noise(self, size: usize, test_runner: &mut TestRunner) -> Option; +} + +impl GaussianNoiseMutator for U256 { + #[instrument( + name = "U256::mutate_with_gaussian_noise", + level = "trace", + skip(size, test_runner), + ret + )] + fn mutate_with_gaussian_noise(self, size: usize, test_runner: &mut TestRunner) -> Option { + let scale_factor = sample_gaussian_scale(&mut test_runner.rng())?; + let mut bytes: [u8; 32] = self.to_be_bytes(); + apply_scale_to_bytes(&mut bytes[32 - size / 8..], scale_factor)?; + validate_uint_mutation(self, Self::from_be_bytes(bytes), size) + } +} + +impl GaussianNoiseMutator for I256 { + #[instrument( + name = "I256::mutate_with_gaussian_noise", + level = "trace", + skip(size, test_runner), + ret + )] + fn mutate_with_gaussian_noise(self, size: usize, test_runner: &mut TestRunner) -> Option { + let scale_factor = sample_gaussian_scale(&mut test_runner.rng())?; + let mut bytes: [u8; 32] = self.to_be_bytes(); + apply_scale_to_bytes(&mut bytes[32 - size / 8..], scale_factor)?; + validate_int_mutation(self, Self::from_be_bytes(bytes), size) + } +} + +/// Mutator that bounds the current value of an uint or int in the given range. +/// The mutated value is always different from the current value. +pub trait BoundMutator: Sized + Copy + Debug { + fn bound(self, min: Self, max: Self, test_runner: &mut TestRunner) -> Option; +} + +impl BoundMutator for U256 { + #[instrument(name = "U256::bound", level = "trace", skip(test_runner), ret)] + fn bound(self, min: Self, max: Self, test_runner: &mut TestRunner) -> Option { + if min > max || self < min || self > max || min == max { + return None; + } + + let rng = test_runner.rng(); + + loop { + let bits = rng.random_range(8..=256); + let mask = (Self::ONE << bits) - Self::ONE; + let candidate = Self::from(rng.random::()) & mask; + + // Map to range. + let candidate = min + (candidate % ((max - min).saturating_add(Self::ONE))); + + if candidate != self { + return Some(candidate); + } + } + } +} + +impl BoundMutator for I256 { + #[instrument(name = "I256::bound", level = "trace", skip(test_runner), ret)] + fn bound(self, min: Self, max: Self, test_runner: &mut TestRunner) -> Option { + if min > max || self < min || self > max || min == max { + return None; + } + + let rng = test_runner.rng(); + + loop { + let bits = rng.random_range(8..=255); + let mask = (U256::ONE << bits) - U256::ONE; + let rand_u = U256::from(rng.next_u64()) | (U256::from(rng.next_u64()) << 64); + let unsigned_candidate = rand_u & mask; + + let signed_candidate = { + let midpoint = U256::ONE << (bits - 1); + if unsigned_candidate < midpoint { + Self::from_raw(unsigned_candidate) + } else { + Self::from_raw(unsigned_candidate) - Self::from_raw(U256::ONE << bits) + } + }; + + // Map to range. + let range = max.saturating_sub(min).saturating_add(Self::ONE).unsigned_abs(); + let wrapped = Self::from_raw(signed_candidate.unsigned_abs() % range); + let candidate = + if signed_candidate.is_negative() { max - wrapped } else { min + wrapped }; + + if candidate != self { + return Some(candidate); + } + } + } +} + +/// Mutator that changes the current value by flipping a random bit. +pub(crate) trait BitMutator: Sized + Copy + Debug { + fn flip_random_bit(self, size: usize, test_runner: &mut TestRunner) -> Option; +} + +impl BitMutator for U256 { + #[instrument(name = "U256::flip_random_bit", level = "trace", skip(size, test_runner), ret)] + fn flip_random_bit(self, size: usize, test_runner: &mut TestRunner) -> Option { + let mut bytes: [u8; 32] = self.to_be_bytes(); + flip_random_bit_in_slice(&mut bytes[32 - size / 8..], test_runner)?; + validate_uint_mutation(self, Self::from_be_bytes(bytes), size) + } +} + +impl BitMutator for I256 { + #[instrument(name = "I256::flip_random_bit", level = "trace", skip(size, test_runner), ret)] + fn flip_random_bit(self, size: usize, test_runner: &mut TestRunner) -> Option { + let mut bytes: [u8; 32] = self.to_be_bytes(); + flip_random_bit_in_slice(&mut bytes[32 - size / 8..], test_runner)?; + validate_int_mutation(self, Self::from_be_bytes(bytes), size) + } +} + +impl BitMutator for Address { + #[instrument(name = "Address::flip_random_bit", level = "trace", skip(_size, test_runner), ret)] + fn flip_random_bit(self, _size: usize, test_runner: &mut TestRunner) -> Option { + let mut mutated = self; + flip_random_bit_in_slice(mutated.as_mut_slice(), test_runner)?; + (self != mutated).then_some(mutated) + } +} + +impl BitMutator for Word { + #[instrument(name = "Word::flip_random_bit", level = "trace", skip(size, test_runner), ret)] + fn flip_random_bit(self, size: usize, test_runner: &mut TestRunner) -> Option { + let mut bytes = self; + let slice = &mut bytes[..size]; + flip_random_bit_in_slice(slice, test_runner)?; + (self != bytes).then_some(bytes) + } +} + +/// Mutator that changes the current value by randomly injecting interesting words (for uint, int, +/// address and fixed bytes) - see . +pub(crate) trait InterestingWordMutator: Sized + Copy + Debug { + fn mutate_interesting_byte(self, size: usize, test_runner: &mut TestRunner) -> Option; + fn mutate_interesting_word(self, size: usize, test_runner: &mut TestRunner) -> Option; + fn mutate_interesting_dword(self, size: usize, test_runner: &mut TestRunner) -> Option; +} + +impl InterestingWordMutator for U256 { + #[instrument( + name = "U256::mutate_interesting_byte", + level = "trace", + skip(size, test_runner), + ret + )] + fn mutate_interesting_byte(self, size: usize, test_runner: &mut TestRunner) -> Option { + let mut bytes: [u8; 32] = self.to_be_bytes(); + mutate_interesting_byte_slice(&mut bytes[32 - size / 8..], test_runner)?; + validate_uint_mutation(self, Self::from_be_bytes(bytes), size) + } + + #[instrument( + name = "U256::mutate_interesting_word", + level = "trace", + skip(size, test_runner), + ret + )] + fn mutate_interesting_word(self, size: usize, test_runner: &mut TestRunner) -> Option { + let mut bytes: [u8; 32] = self.to_be_bytes(); + mutate_interesting_word_slice(&mut bytes[32 - size / 8..], test_runner)?; + validate_uint_mutation(self, Self::from_be_bytes(bytes), size) + } + + #[instrument( + name = "U256::mutate_interesting_dword", + level = "trace", + skip(size, test_runner), + ret + )] + fn mutate_interesting_dword(self, size: usize, test_runner: &mut TestRunner) -> Option { + let mut bytes: [u8; 32] = self.to_be_bytes(); + mutate_interesting_dword_slice(&mut bytes[32 - size / 8..], test_runner)?; + validate_uint_mutation(self, Self::from_be_bytes(bytes), size) + } +} + +impl InterestingWordMutator for I256 { + #[instrument( + name = "I256::mutate_interesting_byte", + level = "trace", + skip(size, test_runner), + ret + )] + fn mutate_interesting_byte(self, size: usize, test_runner: &mut TestRunner) -> Option { + let mut bytes: [u8; 32] = self.to_be_bytes(); + mutate_interesting_byte_slice(&mut bytes[32 - size / 8..], test_runner)?; + validate_int_mutation(self, Self::from_be_bytes(bytes), size) + } + + #[instrument( + name = "I256::mutate_interesting_word", + level = "trace", + skip(size, test_runner), + ret + )] + fn mutate_interesting_word(self, size: usize, test_runner: &mut TestRunner) -> Option { + let mut bytes: [u8; 32] = self.to_be_bytes(); + mutate_interesting_word_slice(&mut bytes[32 - size / 8..], test_runner)?; + validate_int_mutation(self, Self::from_be_bytes(bytes), size) + } + + #[instrument( + name = "I256::mutate_interesting_dword", + level = "trace", + skip(size, test_runner), + ret + )] + fn mutate_interesting_dword(self, size: usize, test_runner: &mut TestRunner) -> Option { + let mut bytes: [u8; 32] = self.to_be_bytes(); + mutate_interesting_dword_slice(&mut bytes[32 - size / 8..], test_runner)?; + validate_int_mutation(self, Self::from_be_bytes(bytes), size) + } +} + +impl InterestingWordMutator for Address { + #[instrument( + name = "Address::mutate_interesting_byte", + level = "trace", + skip(_size, test_runner), + ret + )] + fn mutate_interesting_byte(self, _size: usize, test_runner: &mut TestRunner) -> Option { + let mut mutated = self; + mutate_interesting_byte_slice(mutated.as_mut_slice(), test_runner)?; + (self != mutated).then_some(mutated) + } + + #[instrument( + name = "Address::mutate_interesting_word", + level = "trace", + skip(_size, test_runner), + ret + )] + fn mutate_interesting_word(self, _size: usize, test_runner: &mut TestRunner) -> Option { + let mut mutated = self; + mutate_interesting_word_slice(mutated.as_mut_slice(), test_runner)?; + (self != mutated).then_some(mutated) + } + + #[instrument( + name = "Address::mutate_interesting_dword", + level = "trace", + skip(_size, test_runner), + ret + )] + fn mutate_interesting_dword(self, _size: usize, test_runner: &mut TestRunner) -> Option { + let mut mutated = self; + mutate_interesting_dword_slice(mutated.as_mut_slice(), test_runner)?; + (self != mutated).then_some(mutated) + } +} + +impl InterestingWordMutator for Word { + #[instrument( + name = "Word::mutate_interesting_byte", + level = "trace", + skip(size, test_runner), + ret + )] + fn mutate_interesting_byte(self, size: usize, test_runner: &mut TestRunner) -> Option { + let mut bytes = self; + let slice = &mut bytes[..size]; + mutate_interesting_byte_slice(slice, test_runner)?; + (self != bytes).then_some(bytes) + } + + #[instrument( + name = "Word::mutate_interesting_word", + level = "trace", + skip(size, test_runner), + ret + )] + fn mutate_interesting_word(self, size: usize, test_runner: &mut TestRunner) -> Option { + let mut bytes = self; + let slice = &mut bytes[..size]; + mutate_interesting_word_slice(slice, test_runner)?; + (self != bytes).then_some(bytes) + } + + #[instrument( + name = "Word::mutate_interesting_dword", + level = "trace", + skip(size, test_runner), + ret + )] + fn mutate_interesting_dword(self, size: usize, test_runner: &mut TestRunner) -> Option { + let mut bytes = self; + let slice = &mut bytes[..size]; + mutate_interesting_dword_slice(slice, test_runner)?; + (self != bytes).then_some(bytes) + } +} + +/// Flips a random bit in the given mutable byte slice. +fn flip_random_bit_in_slice(bytes: &mut [u8], test_runner: &mut TestRunner) -> Option<()> { + if bytes.is_empty() { + return None; + } + let bit_index = test_runner.rng().random_range(0..(bytes.len() * 8)); + bytes[bit_index / 8] ^= 1 << (bit_index % 8); + Some(()) +} + +/// Mutates a random byte in the given byte slice by replacing it with a randomly chosen +/// interesting 8-bit value. +fn mutate_interesting_byte_slice(bytes: &mut [u8], test_runner: &mut TestRunner) -> Option<()> { + let index = test_runner.rng().random_range(0..bytes.len()); + let val = *INTERESTING_8.choose(&mut test_runner.rng())? as u8; + bytes[index] = val; + Some(()) +} + +/// Mutates a random 2-byte (16-bit) region in the byte slice with a randomly chosen interesting +/// 16-bit value. +fn mutate_interesting_word_slice(bytes: &mut [u8], test_runner: &mut TestRunner) -> Option<()> { + if bytes.len() < 2 { + return None; + } + let index = test_runner.rng().random_range(0..=bytes.len() - 2); + let val = *INTERESTING_16.choose(&mut test_runner.rng())? as u16; + bytes[index..index + 2].copy_from_slice(&val.to_be_bytes()); + Some(()) +} + +/// Mutates a random 4-byte (32-bit) region in the byte slice with a randomly chosen interesting +/// 32-bit value. +fn mutate_interesting_dword_slice(bytes: &mut [u8], test_runner: &mut TestRunner) -> Option<()> { + if bytes.len() < 4 { + return None; + } + let index = test_runner.rng().random_range(0..=bytes.len() - 4); + let val = *INTERESTING_32.choose(&mut test_runner.rng())? as u32; + bytes[index..index + 4].copy_from_slice(&val.to_be_bytes()); + Some(()) +} + +/// Samples a scale factor from a pseudo-Gaussian distribution centered around 1.0. +/// +/// - Select a random standard deviation multiplier from a predefined set. +/// - Approximates a standard normal distribution using the Irwin-Hall method (sum of uniform +/// samples). +/// - Scales the normal value by the chosen standard deviation multiplier, divided by 3 to get +/// standard deviation. +/// - Adds 1.0 to center the scale factor around 1.0 (no mutation). +/// +/// Returns a scale factor that, when applied to a number, mimics Gaussian noise. +fn sample_gaussian_scale(rng: &mut R) -> Option { + let num_samples = 8; + let chosen_3rd_sigma = *THREE_SIGMA_MULTIPLIERS.choose(rng).unwrap_or(&1.0); + + let mut sum = 0.0; + for _ in 0..num_samples { + sum += rng.random::(); + } + + let standard_normal = sum - (num_samples as f64 / 2.0); + let mut scale_factor = (chosen_3rd_sigma / 3.0) * standard_normal; + scale_factor += 1.0; + + if scale_factor < 0.0 || (scale_factor - 1.0).abs() < f64::EPSILON { + None + } else { + Some(scale_factor) + } +} + +/// Applies a floating-point scale factor to a byte slice representing an unsigned or signed +/// integer. +fn apply_scale_to_bytes(bytes: &mut [u8], scale_factor: f64) -> Option<()> { + let mut carry_down = 0.0; + + for i in (0..bytes.len()).rev() { + let byte_val = bytes[i] as f64; + let scaled = f64::mul_add(carry_down, 256.0, byte_val) * scale_factor; + + if i == 0 && scaled >= 256.0 { + for b in bytes.iter_mut() { + *b = 0xFF; + } + return Some(()); + } + + bytes[i] = (scaled % 256.0).floor() as u8; + + let mut carry_up = (scaled / 256.0).floor(); + carry_down = (scaled % 1.0) / scale_factor; + + let mut j = i; + // Propagate carry_up until it is zero or no more bytes left + while carry_up > 0.0 && j > 0 { + j -= 1; + let new_val = bytes[j] as f64 + carry_up; + if j == 0 && new_val >= 256.0 { + for b in bytes.iter_mut() { + *b = 0xFF; + } + return Some(()); + } + bytes[j] = (new_val % 256.0).floor() as u8; + carry_up = (new_val / 256.0).floor(); + } + } + + Some(()) +} + +/// Returns mutated uint value if different from the original value and if it fits in the given +/// size, otherwise None. +fn validate_uint_mutation(original: U256, mutated: U256, size: usize) -> Option { + // Early return if mutated value is the same as original value. + if mutated == original { + return None; + } + + // Check if mutated value fits the given size. + let max = if size < 256 { (U256::ONE << size) - U256::ONE } else { U256::MAX }; + (mutated <= max).then_some(mutated) +} + +/// Returns mutated int value if different from the original value and if it fits in the given size, +/// otherwise None. +fn validate_int_mutation(original: I256, mutated: I256, size: usize) -> Option { + // Early return if mutated value is the same as original value. + if mutated == original { + return None; + } + + // Check if mutated value fits the given size. + // Valid signed range for `size` bits is `[-2^(size-1), 2^(size-1) - 1]`. + let max = + I256::overflowing_from_sign_and_abs(Sign::Positive, (U256::ONE << (size - 1)) - U256::ONE) + .0; + let min = I256::overflowing_from_sign_and_abs(Sign::Negative, U256::ONE << (size - 1)).0; + match mutated.sign() { + Sign::Positive => mutated <= max, + Sign::Negative => mutated >= min, + } + .then_some(mutated) +} + +#[cfg(test)] +mod tests { + use super::*; + use proptest::test_runner::Config; + + #[test] + fn validate_uint_mutation_accepts_inclusive_max() { + // For width `size`, valid unsigned values are 0..=max where max = 2^size - 1. + // Regression: `< max` incorrectly rejected `mutated == max`; must use `<= max`. + for size in [8usize, 16, 32, 64, 128] { + let max = (U256::ONE << size) - U256::ONE; + assert_eq!( + super::validate_uint_mutation(U256::ZERO, max, size), + Some(max), + "size={size}" + ); + } + let original = U256::ONE; + assert_eq!(super::validate_uint_mutation(original, U256::MAX, 256), Some(U256::MAX)); + } + + #[test] + fn validate_int_mutation_accepts_inclusive_bounds() { + // For width `size`, valid signed values are [-2^(size-1), 2^(size-1) - 1]. + // Regression: strict comparisons rejected the inclusive max and min bounds. + for size in [8usize, 16, 32, 64, 128] { + let max = I256::overflowing_from_sign_and_abs( + Sign::Positive, + (U256::ONE << (size - 1)) - U256::ONE, + ) + .0; + let min = + I256::overflowing_from_sign_and_abs(Sign::Negative, U256::ONE << (size - 1)).0; + assert_eq!( + super::validate_int_mutation(I256::ZERO, max, size), + Some(max), + "size={size} max" + ); + assert_eq!( + super::validate_int_mutation(I256::ZERO, min, size), + Some(min), + "size={size} min" + ); + } + assert_eq!(super::validate_int_mutation(I256::ZERO, I256::MAX, 256), Some(I256::MAX)); + assert_eq!(super::validate_int_mutation(I256::ZERO, I256::MIN, 256), Some(I256::MIN)); + } + + #[test] + fn test_mutate_uint() { + let mut runner = TestRunner::new(Config::default()); + let size = 32; + + let test_values = + vec![U256::ZERO, U256::ONE, U256::from(12345u64), U256::from(255), U256::MAX]; + + #[track_caller] + fn validate_mutation(value: U256, mutated: Option) { + assert!( + mutated.is_none() || mutated.is_some_and(|m| m != value), + "Mutation failed: value = {value:?}, mutated = {mutated:?}" + ); + } + + for value in test_values { + for _ in 0..100 { + validate_mutation(value, U256::increment_decrement(value, size, &mut runner)); + validate_mutation(value, U256::flip_random_bit(value, size, &mut runner)); + validate_mutation(value, U256::mutate_interesting_byte(value, size, &mut runner)); + validate_mutation(value, U256::mutate_interesting_word(value, size, &mut runner)); + validate_mutation(value, U256::mutate_interesting_dword(value, size, &mut runner)); + } + } + } + + #[test] + fn test_mutate_int() { + let mut runner = TestRunner::new(Config::default()); + let size = 32; + + let test_values = vec![ + I256::ZERO, + I256::ONE, + I256::MINUS_ONE, + I256::from_dec_str("12345").unwrap(), + I256::from_dec_str("-54321").unwrap(), + I256::from_dec_str("340282366920938463463374607431768211455").unwrap(), + I256::from_dec_str("-340282366920938463463374607431768211455").unwrap(), + ]; + + #[track_caller] + fn validate_mutation(value: I256, mutated: Option) { + assert!( + mutated.is_none() || mutated.is_some_and(|m| m != value), + "Mutation failed: value = {value:?}, mutated = {mutated:?}" + ); + } + + for value in test_values { + for _ in 0..100 { + validate_mutation(value, I256::increment_decrement(value, size, &mut runner)); + validate_mutation(value, I256::flip_random_bit(value, size, &mut runner)); + validate_mutation(value, I256::mutate_interesting_byte(value, size, &mut runner)); + validate_mutation(value, I256::mutate_interesting_word(value, size, &mut runner)); + validate_mutation(value, I256::mutate_interesting_dword(value, size, &mut runner)); + } + } + } + + #[test] + fn test_mutate_address() { + let mut runner = TestRunner::new(Config::default()); + let value = Address::random(); + + #[track_caller] + fn validate_mutation(value: Address, mutated: Option
) { + assert!( + mutated.is_none() || mutated.is_some_and(|mutated| mutated != value), + "Mutation failed for value: {value:?}, result: {mutated:?}" + ); + } + + for _ in 0..100 { + validate_mutation(value, Address::flip_random_bit(value, 20, &mut runner)); + validate_mutation(value, Address::mutate_interesting_byte(value, 20, &mut runner)); + validate_mutation(value, Address::mutate_interesting_word(value, 20, &mut runner)); + validate_mutation(value, Address::mutate_interesting_dword(value, 20, &mut runner)); + } + } + + #[test] + fn test_mutate_word() { + let mut runner = TestRunner::new(Config::default()); + let value = Word::random(); + + #[track_caller] + fn validate_mutation(value: Word, mutated: Option) { + assert!( + mutated.is_none() || mutated.is_some_and(|mutated| mutated != value), + "Mutation failed for value: {value:?}, result: {mutated:?}" + ); + } + + for _ in 0..100 { + validate_mutation(value, Word::flip_random_bit(value, 32, &mut runner)); + validate_mutation(value, Word::mutate_interesting_byte(value, 32, &mut runner)); + validate_mutation(value, Word::mutate_interesting_word(value, 32, &mut runner)); + validate_mutation(value, Word::mutate_interesting_dword(value, 32, &mut runner)); + } + } + + #[test] + fn test_mutate_interesting_word_too_small_returns_none() { + let mut runner = TestRunner::new(Config::default()); + let value = U256::from(123); + assert!(U256::mutate_interesting_word(value, 8, &mut runner).is_none()); + } + + #[test] + fn test_mutate_interesting_dword_too_small_returns_none() { + let mut runner = TestRunner::new(Config::default()); + let value = I256::from_dec_str("123").unwrap(); + assert!(I256::mutate_interesting_dword(value, 16, &mut runner).is_none()); + } + + #[test] + fn test_u256_bound() { + let mut runner = TestRunner::new(Config::default()); + let min = U256::ZERO; + let max = U256::from(200u64); + let original = U256::from(100u64); + + for _ in 0..50 { + let result = original.bound(min, max, &mut runner); + assert!(result.is_some(), "Mutation should occur"); + + let mutated = result.unwrap(); + assert!(mutated >= min, "Mutated value >= min"); + assert!(mutated <= max, "Mutated value <= max"); + assert_ne!(mutated, original, "mutated value should differ from original"); + } + + // Test bound in [min, max] range. + let result = original.bound(U256::MIN, U256::MAX, &mut runner); + assert!(result.is_some(), "Mutation should occur"); + } + + #[test] + fn test_i256_bound() { + let mut runner = TestRunner::new(Config::default()); + let min = I256::from_dec_str("-100").unwrap(); + let max = I256::from_dec_str("100").unwrap(); + let original = I256::from_dec_str("10").unwrap(); + + for _ in 0..50 { + let result = original.bound(min, max, &mut runner); + assert!(result.is_some(), "Mutation should occur"); + + let mutated = result.unwrap(); + assert!(mutated >= min, "Mutated value >= min"); + assert!(mutated <= max, "Mutated value <= max"); + assert_ne!(mutated, original, "Mutated value should not equal current"); + } + + // Test bound in [min, max] range. + let result = original.bound(I256::MIN, I256::MAX, &mut runner); + assert!(result.is_some(), "Mutation should occur"); + } +} diff --git a/crates/evm/fuzz/src/strategies/param.rs b/crates/evm/fuzz/src/strategies/param.rs index 37bc521a0637c..6c674e1ae4b9d 100644 --- a/crates/evm/fuzz/src/strategies/param.rs +++ b/crates/evm/fuzz/src/strategies/param.rs @@ -1,8 +1,15 @@ -use super::state::EvmFuzzState; -use alloy_dyn_abi::{DynSolType, DynSolValue}; +use super::{UintStrategy, state::DictionaryRead}; +use crate::{ + invariant::SenderFilters, + strategies::mutators::{ + BitMutator, GaussianNoiseMutator, IncrementDecrementMutator, InterestingWordMutator, + }, +}; +use alloy_dyn_abi::{DynSolType, DynSolValue, Word}; use alloy_primitives::{Address, B256, I256, U256}; -use proptest::prelude::*; -use rand::{SeedableRng, rngs::StdRng}; +use proptest::{prelude::*, strategy::ValueTree, test_runner::TestRunner}; +use rand::{SeedableRng, prelude::IndexedMutRandom, rngs::StdRng}; +use std::mem::replace; /// The max length of arrays we fuzz for is 256. const MAX_ARRAY_LEN: usize = 256; @@ -50,7 +57,7 @@ fn fuzz_param_inner( let fuzz_fixtures = fuzz_fixtures.map(|(f, _)| f); let value = || { - let default_strategy = DynSolValue::type_strategy(param); + let default_strategy = param.value_strategy(); if let Some(fixtures) = fuzz_fixtures { proptest::prop_oneof![ 50 => { @@ -74,16 +81,27 @@ fn fuzz_param_inner( DynSolType::Uint(n @ 8..=256) => super::UintStrategy::new(n, fuzz_fixtures) .prop_map(move |x| DynSolValue::Uint(x, n)) .boxed(), - DynSolType::Function | DynSolType::Bool => DynSolValue::type_strategy(param).boxed(), + DynSolType::Function | DynSolType::Bool => param.value_strategy().boxed(), DynSolType::Bytes => value(), DynSolType::FixedBytes(_size @ 1..=32) => value(), - DynSolType::String => value() - .prop_map(move |value| { + DynSolType::String => { + let default_strategy = param.value_strategy().prop_map(move |value| { DynSolValue::String( value.as_str().unwrap().trim().trim_end_matches('\0').to_string(), ) - }) - .boxed(), + }); + if let Some(fixtures) = fuzz_fixtures { + let fixtures = fixtures.to_vec(); + proptest::prop_oneof![ + 50 => any::() + .prop_map(move |index| index.get(&fixtures).clone()), + 50 => default_strategy, + ] + .boxed() + } else { + default_strategy.boxed() + } + } DynSolType::Tuple(ref params) => params .iter() .map(|param| fuzz_param_inner(param, None)) @@ -108,9 +126,9 @@ fn fuzz_param_inner( /// fuzz state. /// /// Works with ABI Encoder v2 tuples. -pub fn fuzz_param_from_state( +pub(crate) fn fuzz_param_from_state( param: &DynSolType, - state: &EvmFuzzState, + state: &impl DictionaryRead, ) -> BoxedStrategy { // Value strategy that uses the state. let value = || { @@ -120,18 +138,19 @@ pub fn fuzz_param_from_state( // Use `Index` instead of `Selector` when selecting a value to avoid iterating over the // entire dictionary. any::<(bool, prop::sample::Index)>().prop_map(move |(bias, index)| { - let state = state.dictionary_read(); - let values = if bias { state.samples(¶m) } else { None } - .unwrap_or_else(|| state.values()) - .as_slice(); - values[index.index(values.len())] + state.with_dictionary(|dict| { + let values = if bias { dict.samples(¶m) } else { None } + .unwrap_or_else(|| dict.values()) + .as_slice(); + values[index.index(values.len())] + }) }) }; // Convert the value based on the parameter type match *param { DynSolType::Address => { - let deployed_libs = state.deployed_libs.clone(); + let deployed_libs = state.deployed_libs().to_vec(); value() .prop_map(move |value| { let mut fuzzed_addr = Address::from_word(value); @@ -165,16 +184,67 @@ pub fn fuzz_param_from_state( DynSolValue::FixedBytes(B256::from(v), size) }) .boxed(), - DynSolType::Bool => DynSolValue::type_strategy(param).boxed(), - DynSolType::String => DynSolValue::type_strategy(param) - .prop_map(move |value| { - DynSolValue::String( - value.as_str().unwrap().trim().trim_end_matches('\0').to_string(), - ) - }) - .boxed(), + DynSolType::Bool => param.value_strategy().boxed(), + DynSolType::String => { + let state = state.clone(); + (proptest::bool::weighted(0.3), any::()) + .prop_flat_map(move |(use_ast, select_index)| { + if let Some(value) = state.with_dictionary(|dict| { + // AST string literals available: 30% probability + let ast_strings = dict.ast_strings(); + if use_ast && !ast_strings.is_empty() { + let s = &ast_strings.as_slice()[select_index.index(ast_strings.len())]; + return Some(DynSolValue::String(s.clone())); + } + None + }) { + return Just(value).boxed(); + } + + // Fallback to random string generation + DynSolType::String + .value_strategy() + .prop_map(|value| { + DynSolValue::String( + value.as_str().unwrap().trim().trim_end_matches('\0').to_string(), + ) + }) + .boxed() + }) + .boxed() + } DynSolType::Bytes => { - value().prop_map(move |value| DynSolValue::Bytes(value.0.into())).boxed() + let state_clone = state.clone(); + ( + value(), + proptest::bool::weighted(0.1), + proptest::bool::weighted(0.2), + any::(), + ) + .prop_map(move |(word, use_ast_string, use_ast_bytes, select_index)| { + if let Some(value) = state_clone.with_dictionary(|dict| { + // Try string literals as bytes: 10% chance + let ast_strings = dict.ast_strings(); + if use_ast_string && !ast_strings.is_empty() { + let s = &ast_strings.as_slice()[select_index.index(ast_strings.len())]; + return Some(DynSolValue::Bytes(s.as_bytes().to_vec())); + } + + // Try hex literals: 20% chance + let ast_bytes = dict.ast_bytes(); + if use_ast_bytes && !ast_bytes.is_empty() { + let bytes = &ast_bytes.as_slice()[select_index.index(ast_bytes.len())]; + return Some(DynSolValue::Bytes(bytes.to_vec())); + } + None + }) { + return value; + } + + // Fallback to the generated word from the dictionary: 70% chance + DynSolValue::Bytes(word.0.into()) + }) + .boxed() } DynSolType::Int(n @ 8..=256) => match n / 8 { 32 => value() @@ -182,23 +252,29 @@ pub fn fuzz_param_from_state( .boxed(), 1..=31 => value() .prop_map(move |value| { - // Generate a uintN in the correct range, then shift it to the range of intN - // by subtracting 2^(N-1) - let uint = U256::from_be_bytes(value.0) % U256::from(1).wrapping_shl(n); - let max_int_plus1 = U256::from(1).wrapping_shl(n - 1); - let num = I256::from_raw(uint.wrapping_sub(max_int_plus1)); + // Extract lower N bits + let uint_n = Into::::into(value) % U256::ONE.wrapping_shl(n); + // Interpret as signed int (two's complement) --> check sign bit (bit N-1). + let sign_bit = U256::ONE << (n - 1); + let num = if uint_n >= sign_bit { + // Negative number in two's complement + let modulus = U256::ONE << n; + I256::from_raw(uint_n.wrapping_sub(modulus)) + } else { + // Positive number + I256::from_raw(uint_n) + }; + DynSolValue::Int(num, n) }) .boxed(), _ => unreachable!(), }, DynSolType::Uint(n @ 8..=256) => match n / 8 { - 32 => value() - .prop_map(move |value| DynSolValue::Uint(U256::from_be_bytes(value.0), 256)) - .boxed(), + 32 => value().prop_map(move |value| DynSolValue::Uint(value.into(), 256)).boxed(), 1..=31 => value() .prop_map(move |value| { - let uint = U256::from_be_bytes(value.0) % U256::from(1).wrapping_shl(n); + let uint = Into::::into(value) % U256::ONE.wrapping_shl(n); DynSolValue::Uint(uint, n) }) .boxed(), @@ -224,28 +300,532 @@ pub fn fuzz_param_from_state( } } +/// Selects a random address for mutation, respecting sender filters if provided. +/// +/// Priority: +/// 1. If `senders` has targeted addresses, pick randomly from those +/// 2. Otherwise, pick from the dictionary state values (excluding any in `senders.excluded`) +/// 3. Returns `None` if no suitable address is found or if the selected address equals `current` +fn select_random_address( + current: Address, + test_runner: &mut TestRunner, + state: &impl DictionaryRead, + senders: Option<&SenderFilters>, +) -> Option
{ + if let Some(senders) = senders { + if !senders.targeted.is_empty() { + // Pick from targeted senders + let index = test_runner.rng().random_range(0..senders.targeted.len()); + let addr = senders.targeted[index]; + return (addr != current).then_some(addr); + } + + // Pick from dictionary state values, excluding addresses in the exclusion list + state.with_dictionary(|dict| { + let values = dict.values(); + if values.is_empty() { + return None; + } + + // Try a few times to find a non-excluded address + for _ in 0..10 { + let index = test_runner.rng().random_range(0..values.len()); + let addr = Address::from_word(values[index]); + if addr != current && !senders.excluded.contains(&addr) { + return Some(addr); + } + } + None + }) + } else { + // No sender filters, just pick from dictionary state values + state.with_dictionary(|dict| { + let values = dict.values(); + if values.is_empty() { + None + } else { + let index = test_runner.rng().random_range(0..values.len()); + let addr = Address::from_word(values[index]); + (addr != current).then_some(addr) + } + }) + } +} + +/// Mutates the current value of the given parameter type and value. +pub(crate) fn mutate_param_value( + param: &DynSolType, + value: DynSolValue, + test_runner: &mut TestRunner, + state: &impl DictionaryRead, +) -> DynSolValue { + mutate_param_value_inner(param, value, test_runner, state, None) +} + +fn mutate_param_value_inner( + param: &DynSolType, + value: DynSolValue, + test_runner: &mut TestRunner, + state: &impl DictionaryRead, + senders: Option<&SenderFilters>, +) -> DynSolValue { + let new_value = |param: &DynSolType, test_runner: &mut TestRunner| { + fuzz_param_from_state(param, state) + .new_tree(test_runner) + .expect("Could not generate case") + .current() + }; + + match value { + DynSolValue::Bool(val) => { + // flip boolean value + trace!(target: "mutator", "Bool flip {val}"); + Some(DynSolValue::Bool(!val)) + } + DynSolValue::Uint(val, size) => match test_runner.rng().random_range(0..=6) { + 0 => U256::increment_decrement(val, size, test_runner), + 1 => U256::flip_random_bit(val, size, test_runner), + 2 => U256::mutate_interesting_byte(val, size, test_runner), + 3 => U256::mutate_interesting_word(val, size, test_runner), + 4 => U256::mutate_interesting_dword(val, size, test_runner), + 5 => U256::mutate_with_gaussian_noise(val, size, test_runner), + 6 => None, + _ => unreachable!(), + } + .map(|v| DynSolValue::Uint(v, size)), + DynSolValue::Int(val, size) => match test_runner.rng().random_range(0..=6) { + 0 => I256::increment_decrement(val, size, test_runner), + 1 => I256::flip_random_bit(val, size, test_runner), + 2 => I256::mutate_interesting_byte(val, size, test_runner), + 3 => I256::mutate_interesting_word(val, size, test_runner), + 4 => I256::mutate_interesting_dword(val, size, test_runner), + 5 => I256::mutate_with_gaussian_noise(val, size, test_runner), + 6 => None, + _ => unreachable!(), + } + .map(|v| DynSolValue::Int(v, size)), + DynSolValue::Address(val) => match test_runner.rng().random_range(0..=5) { + 0 => Address::flip_random_bit(val, 20, test_runner), + 1 => Address::mutate_interesting_byte(val, 20, test_runner), + 2 => Address::mutate_interesting_word(val, 20, test_runner), + 3 => Address::mutate_interesting_dword(val, 20, test_runner), + // Replace with a random address from targeted senders or dictionary. + 4 => select_random_address(val, test_runner, state, senders), + 5 => None, + _ => unreachable!(), + } + .map(DynSolValue::Address), + DynSolValue::Array(mut values) => { + if let DynSolType::Array(param_type) = param + && !values.is_empty() + { + match test_runner.rng().random_range(0..=2) { + // Decrease array size by removing a random element. + 0 => { + values.remove(test_runner.rng().random_range(0..values.len())); + } + // Increase array size. + 1 => values.push(new_value(param_type, test_runner)), + // Mutate random array element. + 2 => mutate_random_array_value( + &mut values, + param_type, + test_runner, + state, + senders, + ), + _ => unreachable!(), + } + Some(DynSolValue::Array(values)) + } else { + None + } + } + DynSolValue::FixedArray(mut values) => { + if let DynSolType::FixedArray(param_type, _size) = param + && !values.is_empty() + { + mutate_random_array_value(&mut values, param_type, test_runner, state, senders); + Some(DynSolValue::FixedArray(values)) + } else { + None + } + } + DynSolValue::FixedBytes(word, size) => match test_runner.rng().random_range(0..=4) { + 0 => Word::flip_random_bit(word, size, test_runner), + 1 => Word::mutate_interesting_byte(word, size, test_runner), + 2 => Word::mutate_interesting_word(word, size, test_runner), + 3 => Word::mutate_interesting_dword(word, size, test_runner), + 4 => None, + _ => unreachable!(), + } + .map(|word| DynSolValue::FixedBytes(word, size)), + DynSolValue::CustomStruct { name, prop_names, tuple: mut values } => { + if let DynSolType::CustomStruct { name: _, prop_names: _, tuple: tuple_types } + | DynSolType::Tuple(tuple_types) = param + && !values.is_empty() + { + // Mutate random struct element. + mutate_random_tuple_value(&mut values, tuple_types, test_runner, state, senders); + Some(DynSolValue::CustomStruct { name, prop_names, tuple: values }) + } else { + None + } + } + DynSolValue::Tuple(mut values) => { + if let DynSolType::Tuple(tuple_types) = param + && !values.is_empty() + { + // Mutate random tuple element. + mutate_random_tuple_value(&mut values, tuple_types, test_runner, state, senders); + Some(DynSolValue::Tuple(values)) + } else { + None + } + } + _ => None, + } + .unwrap_or_else(|| new_value(param, test_runner)) +} + +/// Mutates random value from given tuples. +fn mutate_random_tuple_value( + tuple_values: &mut [DynSolValue], + tuple_types: &[DynSolType], + test_runner: &mut TestRunner, + state: &impl DictionaryRead, + senders: Option<&SenderFilters>, +) { + let id = test_runner.rng().random_range(0..tuple_values.len()); + let param_type = &tuple_types[id]; + let old_val = replace(&mut tuple_values[id], DynSolValue::Bool(false)); + let new_val = mutate_param_value_inner(param_type, old_val, test_runner, state, senders); + tuple_values[id] = new_val; +} + +/// Mutates random value from given array. +fn mutate_random_array_value( + array_values: &mut [DynSolValue], + element_type: &DynSolType, + test_runner: &mut TestRunner, + state: &impl DictionaryRead, + senders: Option<&SenderFilters>, +) { + let elem = array_values.choose_mut(&mut test_runner.rng()).unwrap(); + let old_val = replace(elem, DynSolValue::Bool(false)); + let new_val = mutate_param_value_inner(element_type, old_val, test_runner, state, senders); + *elem = new_val; +} + +/// Returns a proptest strategy for generating random msg.value for payable functions. +/// +/// Most calls carry no value. The configured non-zero percent delegates to [`UintStrategy`], +/// which biases toward edge cases (around 0 / max) and dictionary fixtures, with +/// random fallback. Over-budget values are clamped to sender balance at execute time. +pub fn fuzz_msg_value(payable_value_weight: u32) -> BoxedStrategy> { + match payable_value_weight.min(100) { + 0 => proptest::strategy::Just(None).boxed(), + 100 => UintStrategy::new(256, None).prop_map(Some).boxed(), + payable_value_weight => proptest::prop_oneof![ + 100 - payable_value_weight => proptest::strategy::Just(None), + payable_value_weight => UintStrategy::new(256, None).prop_map(Some), + ] + .boxed(), + } +} + +/// Generates a msg.value for payable functions using `TestRunner`'s RNG (corpus mutation path). +/// +/// Mirrors [`fuzz_msg_value`] by sampling from [`UintStrategy`]. The configured mutation gate is +/// applied at the call site in `corpus.rs`. Over-budget values are clamped to sender +/// balance at execute time. +pub fn generate_msg_value(test_runner: &mut TestRunner) -> U256 { + UintStrategy::new(256, None) + .new_tree(test_runner) + .expect("UintStrategy::new_tree is infallible") + .current() +} + #[cfg(test)] mod tests { use crate::{ FuzzFixtures, strategies::{EvmFuzzState, fuzz_calldata, fuzz_calldata_from_state}, }; + use alloy_dyn_abi::{DynSolType, DynSolValue}; + use alloy_primitives::{B256, U256}; use foundry_common::abi::get_func; use foundry_config::FuzzDictionaryConfig; - use revm::database::{CacheDB, EmptyDB}; + use proptest::{ + strategy::{Strategy, ValueTree}, + test_runner::TestRunner, + }; + use revm::database::InMemoryDB; + use std::collections::HashSet; + + #[test] + fn payable_value_weight_controls_non_zero_msg_value() { + use super::fuzz_msg_value; + + let cfg = proptest::test_runner::Config { failure_persistence: None, ..Default::default() }; + let mut runner = proptest::test_runner::TestRunner::new(cfg); + + for _ in 0..32 { + assert!(fuzz_msg_value(0).new_tree(&mut runner).unwrap().current().is_none()); + assert!(fuzz_msg_value(100).new_tree(&mut runner).unwrap().current().is_some()); + assert!(fuzz_msg_value(250).new_tree(&mut runner).unwrap().current().is_some()); + } + } #[test] fn can_fuzz_array() { let f = "testArray(uint64[2] calldata values)"; let func = get_func(f).unwrap(); - let db = CacheDB::new(EmptyDB::default()); - let state = EvmFuzzState::new(&db, FuzzDictionaryConfig::default(), &[]); + let state = EvmFuzzState::test(); let strategy = proptest::prop_oneof![ 60 => fuzz_calldata(func.clone(), &FuzzFixtures::default()), - 40 => fuzz_calldata_from_state(func, &state), + 40 => fuzz_calldata_from_state(func, &state, &FuzzFixtures::default()), ]; let cfg = proptest::test_runner::Config { failure_persistence: None, ..Default::default() }; let mut runner = proptest::test_runner::TestRunner::new(cfg); let _ = runner.run(&strategy, |_| Ok(())); } + + #[test] + fn can_fuzz_from_zero_capacity_dictionary() { + let state = EvmFuzzState::new( + &[], + &InMemoryDB::default(), + FuzzDictionaryConfig { max_fuzz_dictionary_values: 0, ..Default::default() }, + None, + ); + let strategy = super::fuzz_param_from_state(&DynSolType::Uint(256), &state); + let mut runner = TestRunner::default(); + + assert_eq!( + strategy.new_tree(&mut runner).unwrap().current(), + DynSolValue::Uint(U256::ZERO, 256) + ); + } + + #[test] + fn string_fixtures_are_emitted_verbatim() { + let fixture = DynSolValue::String(" padded fixture \0".to_string()); + let strategy = super::fuzz_param_with_fixtures( + &DynSolType::String, + Some(std::slice::from_ref(&fixture)), + "value", + ); + let mut runner = TestRunner::deterministic(); + + let emitted = + (0..1000).any(|_| strategy.new_tree(&mut runner).unwrap().current() == fixture); + + assert!(emitted, "string fixture was never emitted verbatim"); + } + + #[test] + fn can_fuzz_string_and_bytes_with_ast_literals_and_hashes() { + use super::fuzz_param_from_state; + use crate::strategies::LiteralMaps; + use alloy_dyn_abi::DynSolType; + use alloy_primitives::keccak256; + use proptest::strategy::Strategy; + + // Seed dict with string values and their hashes --> mimic `CheatcodeAnalysis` behavior. + let mut literals = LiteralMaps::default(); + literals.strings.insert("hello".to_string()); + literals.strings.insert("world".to_string()); + literals.words.entry(DynSolType::FixedBytes(32)).or_default().insert(keccak256("hello")); + literals.words.entry(DynSolType::FixedBytes(32)).or_default().insert(keccak256("world")); + + let mut state = EvmFuzzState::test(); + state.seed_literals(literals); + + let cfg = proptest::test_runner::Config { failure_persistence: None, ..Default::default() }; + let mut runner = proptest::test_runner::TestRunner::new(cfg); + + // Verify strategies generates the seeded AST literals + let mut generated_bytes = HashSet::new(); + let mut generated_hashes = HashSet::new(); + let mut generated_strings = HashSet::new(); + let bytes_strategy = fuzz_param_from_state(&DynSolType::Bytes, &state); + let string_strategy = fuzz_param_from_state(&DynSolType::String, &state); + let bytes32_strategy = fuzz_param_from_state(&DynSolType::FixedBytes(32), &state); + + for _ in 0..256 { + let tree = bytes_strategy.new_tree(&mut runner).unwrap(); + if let Some(bytes) = tree.current().as_bytes() + && let Ok(s) = std::str::from_utf8(bytes) + { + generated_bytes.insert(s.to_string()); + } + + let tree = string_strategy.new_tree(&mut runner).unwrap(); + if let Some(s) = tree.current().as_str() { + generated_strings.insert(s.to_string()); + } + + let tree = bytes32_strategy.new_tree(&mut runner).unwrap(); + if let Some((bytes, size)) = tree.current().as_fixed_bytes() + && size == 32 + { + generated_hashes.insert(B256::from_slice(bytes)); + } + } + + assert!(generated_bytes.contains("hello")); + assert!(generated_bytes.contains("world")); + assert!(generated_strings.contains("hello")); + assert!(generated_strings.contains("world")); + assert!(generated_hashes.contains(&keccak256("hello"))); + assert!(generated_hashes.contains(&keccak256("world"))); + } + + #[test] + fn mutate_address_can_select_from_dictionary() { + use super::mutate_param_value; + use alloy_dyn_abi::{DynSolType, DynSolValue}; + use alloy_primitives::Address; + + let mut state = EvmFuzzState::test(); + + // Add addresses to dictionary via state values. + let addr1 = Address::repeat_byte(0x11); + let addr2 = Address::repeat_byte(0x22); + let addr3 = Address::repeat_byte(0x33); + state.collect_values([addr1.into_word(), addr2.into_word(), addr3.into_word()]); + + let cfg = proptest::test_runner::Config { failure_persistence: None, ..Default::default() }; + let mut runner = proptest::test_runner::TestRunner::new(cfg); + + // Mutate an address many times and verify we can get addresses from the dictionary. + let original = Address::repeat_byte(0xff); + let mut got_addr1 = false; + let mut got_addr2 = false; + let mut got_addr3 = false; + + for _ in 0..1000 { + let mutated = mutate_param_value( + &DynSolType::Address, + DynSolValue::Address(original), + &mut runner, + &state, + ); + if let DynSolValue::Address(addr) = mutated { + if addr == addr1 { + got_addr1 = true; + } + if addr == addr2 { + got_addr2 = true; + } + if addr == addr3 { + got_addr3 = true; + } + } + if got_addr1 && got_addr2 && got_addr3 { + break; + } + } + + // We should have seen at least one dictionary address in 1000 iterations. + assert!( + got_addr1 || got_addr2 || got_addr3, + "Address mutation should select addresses from dictionary" + ); + } + + #[test] + fn mutate_address_prefers_targeted_senders() { + use super::select_random_address; + use crate::invariant::SenderFilters; + use alloy_primitives::Address; + + let mut state = EvmFuzzState::test(); + + // Add addresses to dictionary (these should NOT be selected when targeted is set). + let dict_addr = Address::repeat_byte(0xdd); + state.collect_values([dict_addr.into_word()]); + + // Set up targeted senders. + let targeted1 = Address::repeat_byte(0x11); + let targeted2 = Address::repeat_byte(0x22); + let senders = SenderFilters::new(vec![targeted1, targeted2], vec![]); + + let cfg = proptest::test_runner::Config { failure_persistence: None, ..Default::default() }; + let mut runner = proptest::test_runner::TestRunner::new(cfg); + + // Call select_random_address directly to verify it uses targeted senders. + let original = Address::repeat_byte(0xff); + let mut got_targeted1 = false; + let mut got_targeted2 = false; + let mut got_dict = false; + + for _ in 0..100 { + if let Some(addr) = select_random_address(original, &mut runner, &state, Some(&senders)) + { + if addr == targeted1 { + got_targeted1 = true; + } + if addr == targeted2 { + got_targeted2 = true; + } + if addr == dict_addr { + got_dict = true; + } + } + } + + // Should see targeted addresses, never dictionary address. + assert!( + got_targeted1 || got_targeted2, + "select_random_address should select from targeted senders" + ); + assert!( + !got_dict, + "select_random_address should not select from dictionary when targeted senders are set" + ); + } + + #[test] + fn mutate_address_respects_excluded_senders() { + use super::select_random_address; + use crate::invariant::SenderFilters; + use alloy_primitives::Address; + + let mut state = EvmFuzzState::test(); + + // Add addresses to dictionary. + let addr1 = Address::repeat_byte(0x11); + let addr2 = Address::repeat_byte(0x22); + let excluded_addr = Address::repeat_byte(0xee); + state.collect_values([addr1.into_word(), addr2.into_word(), excluded_addr.into_word()]); + + // Exclude one address. + let senders = SenderFilters::new(vec![], vec![excluded_addr]); + + let cfg = proptest::test_runner::Config { failure_persistence: None, ..Default::default() }; + let mut runner = proptest::test_runner::TestRunner::new(cfg); + + // Call select_random_address directly to verify it respects excluded senders. + let original = Address::repeat_byte(0xff); + let mut got_excluded = false; + let mut got_valid = false; + + for _ in 0..100 { + if let Some(addr) = select_random_address(original, &mut runner, &state, Some(&senders)) + { + if addr == excluded_addr { + got_excluded = true; + break; + } + if addr == addr1 || addr == addr2 { + got_valid = true; + } + } + } + + assert!(!got_excluded, "select_random_address should not select excluded addresses"); + assert!(got_valid, "select_random_address should select valid (non-excluded) addresses"); + } } diff --git a/crates/evm/fuzz/src/strategies/state.rs b/crates/evm/fuzz/src/strategies/state.rs index bb166e53177f9..5706db77233c9 100644 --- a/crates/evm/fuzz/src/strategies/state.rs +++ b/crates/evm/fuzz/src/strategies/state.rs @@ -1,20 +1,33 @@ -use crate::invariant::{BasicTxDetails, FuzzRunIdentifiedContracts}; +use crate::{ + BasicTxDetails, Fuzzer, + invariant::{ + FuzzRunIdentifiedContracts, TargetedContract, TargetedContractEvent, TargetedContracts, + }, + strategies::literals::LiteralsDictionary, +}; use alloy_dyn_abi::{DynSolType, DynSolValue, EventExt, FunctionExt}; -use alloy_json_abi::{Function, JsonAbi}; +use alloy_json_abi::Function; use alloy_primitives::{ Address, B256, Bytes, Log, U256, - map::{AddressIndexSet, B256IndexSet, HashMap}, + map::{AddressIndexSet, AddressMap, B256IndexSet, HashMap, HashSet, IndexSet}, +}; +use foundry_common::{ + ignore_metadata_hash, + mapping_slots::MappingSlots, + slot_identifier::{SlotIdentifier, SlotInfo}, }; -use foundry_common::ignore_metadata_hash; use foundry_config::FuzzDictionaryConfig; -use foundry_evm_core::utils::StateChangeset; -use parking_lot::{RawRwLock, RwLock, lock_api::RwLockReadGuard}; +use foundry_evm_core::{ + bytecode::InstIter, eip2935::is_history_storage_address, utils::StateChangeset, +}; use revm::{ - bytecode::opcode, database::{CacheDB, DatabaseRef, DbAccount}, state::AccountInfo, }; -use std::{collections::BTreeMap, fmt, sync::Arc}; +use std::{cell::RefCell, fmt, rc::Rc, sync::Arc}; + +#[cfg(test)] +use revm::database::InMemoryDB; /// The maximum number of bytes we will look at in bytecodes to find push bytes (24 KiB). /// @@ -22,21 +35,48 @@ use std::{collections::BTreeMap, fmt, sync::Arc}; /// bytecode (as is the case with Solmate). const PUSH_BYTE_ANALYSIS_LIMIT: usize = 24 * 1024; -/// A set of arbitrary 32 byte data from the VM used to generate values for the strategy. -/// -/// Wrapped in a shareable container. +/// Immutable fuzz dictionary seed used by parallel stateless fuzz workers. #[derive(Clone, Debug)] pub struct EvmFuzzState { - inner: Arc>, + inner: Arc, /// Addresses of external libraries deployed in test setup, excluded from fuzz test inputs. pub deployed_libs: Vec
, } +/// Worker-local fuzz state. +/// +/// Stateless workers share the immutable campaign seed, while invariant workers own an isolated +/// mutable dictionary. The latter deliberately uses `Rc>`: worker state is not shared +/// across threads. +#[derive(Clone, Debug)] +pub struct FuzzState { + inner: FuzzStateInner, + /// Addresses of external libraries deployed in test setup, excluded from fuzz test inputs. + pub deployed_libs: Vec
, +} + +#[derive(Clone, Debug)] +enum FuzzStateInner { + Stateless(Arc), + Invariant(Rc>), +} + +pub(crate) trait DictionaryRead: Clone + 'static { + fn deployed_libs(&self) -> &[Address]; + fn with_dictionary(&self, f: impl FnOnce(&FuzzDictionary) -> R) -> R; +} + impl EvmFuzzState { + #[cfg(test)] + pub(crate) fn test() -> Self { + Self::new(&[], &InMemoryDB::default(), FuzzDictionaryConfig::default(), None) + } + pub fn new( + deployed_libs: &[Address], db: &CacheDB, config: FuzzDictionaryConfig, - deployed_libs: &[Address], + literals: Option<&LiteralsDictionary>, ) -> Self { // Sort accounts to ensure deterministic dictionary generation from the same setUp state. let mut accs = db.cache.accounts.iter().collect::>(); @@ -45,18 +85,81 @@ impl EvmFuzzState { // Create fuzz dictionary and insert values from db state. let mut dictionary = FuzzDictionary::new(config); dictionary.insert_db_values(accs); - Self { inner: Arc::new(RwLock::new(dictionary)), deployed_libs: deployed_libs.to_vec() } + if let Some(literals) = literals { + dictionary.literal_values = literals.clone(); + } + + Self { inner: Arc::new(dictionary), deployed_libs: deployed_libs.to_vec() } + } + + pub fn stateless_worker(&self) -> FuzzState { + FuzzState { + inner: FuzzStateInner::Stateless(Arc::clone(&self.inner)), + deployed_libs: self.deployed_libs.clone(), + } + } + + pub fn into_invariant(self) -> FuzzState { + FuzzState { + inner: FuzzStateInner::Invariant(Rc::new(RefCell::new((*self.inner).clone()))), + deployed_libs: self.deployed_libs, + } + } + + pub fn fork(&self) -> Self { + Self { inner: Arc::clone(&self.inner), deployed_libs: self.deployed_libs.clone() } + } + + pub fn collect_values(&mut self, values: impl IntoIterator) { + let dict = Arc::make_mut(&mut self.inner); + for value in values { + dict.insert_value(value); + } + } + + /// Logs stats about the current state. + pub fn log_stats(&self) { + self.inner.log_stats(); + } + + /// Test-only helper to seed the dictionary with literal values. + #[cfg(test)] + pub(crate) fn seed_literals(&mut self, map: super::LiteralMaps) { + Arc::make_mut(&mut self.inner).seed_literals(map); + } +} + +impl FuzzState { + pub fn snapshot(&self) -> EvmFuzzState { + EvmFuzzState { + inner: Arc::new(self.with_dictionary(Clone::clone)), + deployed_libs: self.deployed_libs.clone(), + } } pub fn collect_values(&self, values: impl IntoIterator) { - let mut dict = self.inner.write(); + let FuzzStateInner::Invariant(inner) = &self.inner else { return }; + let mut dict = inner.borrow_mut(); for value in values { dict.insert_value(value); } } - /// Collects state changes from a [StateChangeset] and logs into an [EvmFuzzState] according to - /// the given [FuzzDictionaryConfig]. + pub fn collect_fuzzer_values(&self, fuzzer: &mut Fuzzer) { + if fuzzer.collected_values.is_empty() { + return; + } + + let FuzzStateInner::Invariant(inner) = &self.inner else { return }; + let mut dict = inner.borrow_mut(); + for value in fuzzer.collected_values.drain(..) { + dict.insert_value(value); + } + } + + /// Collects state changes from a [StateChangeset] and logs into a worker state + /// according to the given [FuzzDictionaryConfig]. + #[allow(clippy::too_many_arguments)] pub fn collect_values_from_call( &self, fuzzed_contracts: &FuzzRunIdentifiedContracts, @@ -65,15 +168,39 @@ impl EvmFuzzState { logs: &[Log], state_changeset: &StateChangeset, run_depth: u32, + mapping_slots: Option<&AddressMap>, ) { - let mut dict = self.inner.write(); - { - let targets = fuzzed_contracts.targets.lock(); - let (target_abi, target_function) = targets.fuzzed_artifacts(tx); - dict.insert_logs_values(target_abi, logs, run_depth); + if logs.is_empty() && result.is_empty() && state_changeset.is_empty() { + return; + } + + let FuzzStateInner::Invariant(inner) = &self.inner else { return }; + let mut dict = inner.borrow_mut(); + let targets = fuzzed_contracts.targets(); + let (target_contract, target_function) = if logs.is_empty() && result.is_empty() { + (None, None) + } else { + targets.fuzzed_artifacts(tx) + }; + if !logs.is_empty() { + dict.insert_logs_values(target_contract, logs, run_depth); + } + if !result.is_empty() { dict.insert_result_values(target_function, result, run_depth); } - dict.insert_new_state_values(state_changeset); + dict.insert_new_state_values(state_changeset, &targets, mapping_slots); + } + + /// Collects typed trace-cmp operands from sancov-instrumented code. + /// Values are inserted into both persistent state values (survive reverts) and typed + /// sample buckets (for ABI-aware mutation). + pub fn collect_typed_cmp_values(&self, values: impl IntoIterator) { + let FuzzStateInner::Invariant(inner) = &self.inner else { return }; + let mut dict = inner.borrow_mut(); + for (width, value) in values { + dict.insert_persistent_value(value); + dict.insert_typed_cmp_value(width, value); + } } /// Removes all newly added entries from the dictionary. @@ -81,27 +208,69 @@ impl EvmFuzzState { /// Should be called between fuzz/invariant runs to avoid accumulating data derived from fuzz /// inputs. pub fn revert(&self) { - self.inner.write().revert(); - } - - pub fn dictionary_read(&self) -> RwLockReadGuard<'_, RawRwLock, FuzzDictionary> { - self.inner.read() + if let FuzzStateInner::Invariant(inner) = &self.inner { + inner.borrow_mut().revert(); + } } /// Logs stats about the current state. pub fn log_stats(&self) { - self.inner.read().log_stats(); + self.with_dictionary(FuzzDictionary::log_stats); + } + + pub fn deployed_libs(&self) -> &[Address] { + &self.deployed_libs + } + + pub fn with_dictionary(&self, f: impl FnOnce(&FuzzDictionary) -> R) -> R { + match &self.inner { + FuzzStateInner::Stateless(inner) => f(inner), + FuzzStateInner::Invariant(inner) => f(&inner.borrow()), + } + } +} + +impl DictionaryRead for FuzzState { + fn deployed_libs(&self) -> &[Address] { + self.deployed_libs() + } + + fn with_dictionary(&self, f: impl FnOnce(&FuzzDictionary) -> R) -> R { + self.with_dictionary(f) + } +} + +impl DictionaryRead for EvmFuzzState { + fn deployed_libs(&self) -> &[Address] { + &self.deployed_libs + } + + fn with_dictionary(&self, f: impl FnOnce(&FuzzDictionary) -> R) -> R { + f(&self.inner) + } +} + +impl From for FuzzState { + fn from(state: EvmFuzzState) -> Self { + state.into_invariant() } } // We're using `IndexSet` to have a stable element order when restoring persisted state, as well as // for performance when iterating over the sets. -#[derive(Default)] +/// Maximum number of persistent values from sancov trace-cmp. +const MAX_PERSISTENT_VALUES: usize = 2048; +/// Maximum cached storage slot layout lookups per fuzz dictionary. +const MAX_SLOT_INFO_CACHE_ENTRIES: usize = 4096; + +#[derive(Clone)] pub struct FuzzDictionary { /// Collected state values. state_values: B256IndexSet, /// Addresses that already had their PUSH bytes collected. addresses: AddressIndexSet, + /// Code hashes that already had their PUSH bytes collected. + push_bytecode_hashes: B256IndexSet, /// Configuration for the dictionary. config: FuzzDictionaryConfig, /// Number of state values initially collected from db. @@ -110,8 +279,25 @@ pub struct FuzzDictionary { /// Number of address values initially collected from db. /// Used to revert new collected addresses at the end of each run. db_addresses: usize, - /// Sample typed values that are collected from call result and used across invariant runs. + /// Number of bytecode hashes initially collected from db. + /// Used to revert new collected bytecode hashes at the end of each run. + db_push_bytecode_hashes: usize, + /// Typed runtime sample values persisted across invariant runs. + /// Initially seeded with literal values collected from the source code. sample_values: HashMap, + /// Lazily initialized dictionary of literal values collected from the source code. + literal_values: LiteralsDictionary, + /// Tracks whether literals from `literal_values` have been merged into `sample_values`. + /// + /// Set to `true` on first call to `seed_samples()`. Before seeding, `samples()` checks both + /// maps separately. After seeding, literals are merged in, so only `sample_values` is checked. + samples_seeded: bool, + /// Persistent values from sancov trace-cmp that survive `revert()` across runs. + persistent_values: B256IndexSet, + /// Parsed storage layout identifiers keyed by the layout allocation. + slot_identifiers: HashMap, + /// Cached non-mapping storage slot identification keyed by layout allocation and slot. + slot_info_cache: HashMap<(usize, B256), Option>, misses: usize, hits: usize, @@ -122,26 +308,61 @@ impl fmt::Debug for FuzzDictionary { f.debug_struct("FuzzDictionary") .field("state_values", &self.state_values.len()) .field("addresses", &self.addresses) + .field("persistent_values", &self.persistent_values.len()) .finish() } } +impl Default for FuzzDictionary { + fn default() -> Self { + Self::new(Default::default()) + } +} + impl FuzzDictionary { - pub fn new(config: FuzzDictionaryConfig) -> Self { - let mut dictionary = Self { config, ..Default::default() }; - dictionary.prefill(); + pub fn new(mut config: FuzzDictionaryConfig) -> Self { + config.max_fuzz_dictionary_values = config.max_fuzz_dictionary_values.max(1); + let mut dictionary = Self { + config, + samples_seeded: false, + + state_values: Default::default(), + addresses: Default::default(), + push_bytecode_hashes: Default::default(), + db_state_values: Default::default(), + db_addresses: Default::default(), + db_push_bytecode_hashes: Default::default(), + sample_values: Default::default(), + literal_values: Default::default(), + persistent_values: Default::default(), + slot_identifiers: Default::default(), + slot_info_cache: Default::default(), + misses: Default::default(), + hits: Default::default(), + }; + // Zero is a useful default seed even before state or literals populate the dictionary. + dictionary.insert_value(B256::ZERO); dictionary } - /// Insert common values into the dictionary at initialization. - fn prefill(&mut self) { - self.insert_value(B256::ZERO); + /// Seeds `sample_values` with all words from the [`LiteralsDictionary`]. + /// Should only be called once per dictionary lifetime. + #[cold] + fn seed_samples(&mut self) { + trace!("seeding `sample_values` from literal dictionary"); + self.sample_values + .extend(self.literal_values.get().words.iter().map(|(k, v)| (k.clone(), v.clone()))); + self.samples_seeded = true; } /// Insert values from initial db state into fuzz dictionary. /// These values are persisted across invariant runs. fn insert_db_values(&mut self, db_state: Vec<(&Address, &DbAccount)>) { for (address, account) in db_state { + if is_history_storage_address(address) { + continue; + } + // Insert basic account information self.insert_value(address.into_word()); // Insert push bytes @@ -149,9 +370,10 @@ impl FuzzDictionary { // Insert storage values. if self.config.include_storage { // Sort storage values before inserting to ensure deterministic dictionary. - let values = account.storage.iter().collect::>(); + let mut values = account.storage.iter().collect::>(); + values.sort_unstable_by_key(|(slot, _)| **slot); for (slot, value) in values { - self.insert_storage_value(slot, value); + self.insert_storage_value(slot, value, None); } } } @@ -167,6 +389,7 @@ impl FuzzDictionary { // end of each run. self.db_state_values = self.state_values.len(); self.db_addresses = self.addresses.len(); + self.db_push_bytecode_hashes = self.push_bytecode_hashes.len(); } /// Insert values collected from call result into fuzz dictionary. @@ -187,32 +410,42 @@ impl FuzzDictionary { } /// Insert values from call log topics and data into fuzz dictionary. - fn insert_logs_values(&mut self, abi: Option<&JsonAbi>, logs: &[Log], run_depth: u32) { + fn insert_logs_values( + &mut self, + target_contract: Option<&TargetedContract>, + logs: &[Log], + run_depth: u32, + ) { let mut samples = Vec::new(); // Decode logs with known events and collect samples from indexed fields and event body. for log in logs { - let mut log_decoded = false; // Try to decode log with events from contract abi. - if let Some(abi) = abi { - for event in abi.events() { - if let Ok(decoded_event) = event.decode_log(log) { - samples.extend(decoded_event.indexed); - samples.extend(decoded_event.body); - log_decoded = true; - break; - } - } - } + let log_decoded = if let Some(contract) = target_contract { + let matched_events = log + .topics() + .first() + .and_then(|selector| { + contract.event_lookup.by_topic(selector, log.topics().len() - 1) + }) + .unwrap_or(&[]); + Self::decode_log_events( + matched_events, + contract.event_lookup.anonymous(), + log, + &mut samples, + ) + } else { + false + }; // If we weren't able to decode event then we insert raw data in fuzz dictionary. if !log_decoded { for &topic in log.topics() { self.insert_value(topic); } - let chunks = log.data.data.chunks_exact(32); - let rem = chunks.remainder(); + let (chunks, rem) = log.data.data.as_chunks::<32>(); for chunk in chunks { - self.insert_value(chunk.try_into().unwrap()); + self.insert_value((*chunk).into()); } if !rem.is_empty() { self.insert_value(B256::right_padding_from(rem)); @@ -221,86 +454,171 @@ impl FuzzDictionary { } // Insert samples collected from current call in fuzz dictionary. - self.insert_sample_values(samples, run_depth); + if !samples.is_empty() { + self.insert_sample_values(samples, run_depth); + } + } + + fn decode_log_events( + matched_events: &[TargetedContractEvent], + anonymous_events: &[TargetedContractEvent], + log: &Log, + samples: &mut Vec, + ) -> bool { + let mut matched = matched_events.iter().peekable(); + let mut anonymous = anonymous_events.iter().peekable(); + while matched.peek().is_some() || anonymous.peek().is_some() { + let event = match (matched.peek(), anonymous.peek()) { + (Some(matched_event), Some(anonymous_event)) => { + if matched_event.order() < anonymous_event.order() { + matched.next().unwrap() + } else { + anonymous.next().unwrap() + } + } + (Some(_), None) => matched.next().unwrap(), + (None, Some(_)) => anonymous.next().unwrap(), + (None, None) => unreachable!(), + }; + if let Ok(decoded_event) = event.event().decode_log(log) { + samples.extend(decoded_event.indexed); + samples.extend(decoded_event.body); + return true; + } + } + false } /// Insert values from call state changeset into fuzz dictionary. /// These values are removed at the end of current run. - fn insert_new_state_values(&mut self, state_changeset: &StateChangeset) { + fn insert_new_state_values( + &mut self, + state_changeset: &StateChangeset, + targets: &TargetedContracts, + mapping_slots: Option<&AddressMap>, + ) { for (address, account) in state_changeset { + if is_history_storage_address(address) { + continue; + } + // Insert basic account information. self.insert_value(address.into_word()); // Insert push bytes. self.insert_push_bytes_values(address, &account.info); // Insert storage values. - if self.config.include_storage { + if self.config.include_storage && !account.storage.is_empty() { + let slot_identifier_key = targets.get(address).and_then(|contract| { + contract.storage_layout.as_ref().map(|layout| { + let key = Arc::as_ptr(layout) as usize; + self.slot_identifiers + .entry(key) + .or_insert_with(|| SlotIdentifier::new(Arc::clone(layout))); + key + }) + }); + trace!( + "{address:?} has mapping_slots {}", + mapping_slots.is_some_and(|m| m.contains_key(address)) + ); + let mapping_slots = mapping_slots.and_then(|m| m.get(address)); for (slot, value) in &account.storage { - self.insert_storage_value(slot, &value.present_value); + let slot_info = slot_identifier_key.and_then(|key| { + let slot = B256::from(*slot); + let value_word = B256::from(value.present_value()); + self.identify_storage_slot(key, slot, mapping_slots) + .filter(|slot_info| slot_info.decode(value_word).is_some()) + }); + self.insert_storage_value(slot, &value.present_value(), slot_info); } } } } + fn identify_storage_slot( + &mut self, + key: usize, + slot: B256, + mapping_slots: Option<&MappingSlots>, + ) -> Option { + if mapping_slots.is_some() { + return self + .slot_identifiers + .get(&key) + .and_then(|identifier| identifier.identify(&slot, mapping_slots)); + } + + let cache_key = (key, slot); + if let Some(slot_info) = self.slot_info_cache.get(&cache_key) { + return slot_info.clone(); + } + + let slot_info = + self.slot_identifiers.get(&key).and_then(|identifier| identifier.identify(&slot, None)); + if self.slot_info_cache.len() < MAX_SLOT_INFO_CACHE_ENTRIES { + self.slot_info_cache.insert(cache_key, slot_info.clone()); + } + slot_info + } + /// Insert values from push bytes into fuzz dictionary. - /// Values are collected only once for a given address. + /// Values are collected only once for a given bytecode. /// If values are newly collected then they are removed at the end of current run. fn insert_push_bytes_values(&mut self, address: &Address, account_info: &AccountInfo) { - if self.config.include_push_bytes && !self.addresses.contains(address) { - // Insert push bytes - if let Some(code) = &account_info.code { - self.insert_address(*address); - self.collect_push_bytes(ignore_metadata_hash(code.original_byte_slice())); - } + if !self.config.include_push_bytes { + return; + } + + let Some(code) = &account_info.code else { + return; + }; + self.insert_address(*address); + if self.values_full() { + return; + } + if self.push_bytecode_hashes.insert(account_info.code_hash()) { + self.collect_push_bytes(ignore_metadata_hash(code.original_byte_slice())); } } fn collect_push_bytes(&mut self, code: &[u8]) { - let mut i = 0; let len = code.len().min(PUSH_BYTE_ANALYSIS_LIMIT); - while i < len { - let op = code[i]; - if (opcode::PUSH1..=opcode::PUSH32).contains(&op) { - let push_size = (op - opcode::PUSH1 + 1) as usize; - let push_start = i + 1; - let push_end = push_start + push_size; - // As a precaution, if a fuzz test deploys malformed bytecode (such as using - // `CREATE2`) this will terminate the loop early. - if push_start > code.len() || push_end > code.len() { - break; - } - - let push_value = U256::try_from_be_slice(&code[push_start..push_end]).unwrap(); - if push_value != U256::ZERO { - // Never add 0 to the dictionary as it's always present. - self.insert_value(push_value.into()); - - // Also add the value below and above the push value to the dictionary. - self.insert_value((push_value - U256::from(1)).into()); - - if push_value != U256::MAX { - self.insert_value((push_value + U256::from(1)).into()); - } - } - - i += push_size; + let code = &code[..len]; + let mut seen = HashSet::default(); + for inst in InstIter::new(code) { + if self.values_full() { + break; + } + // Don't add 0 to the dictionary as it's already present. + if !inst.immediate.is_empty() + && let Some(push_value) = U256::try_from_be_slice(inst.immediate) + && !push_value.is_zero() + { + self.insert_push_value_u256(push_value, &mut seen); } - i += 1; } } /// Insert values from single storage slot and storage value into fuzz dictionary. - /// If storage values are newly collected then they are removed at the end of current run. - fn insert_storage_value(&mut self, storage_slot: &U256, storage_value: &U256) { - self.insert_value(B256::from(*storage_slot)); - self.insert_value(B256::from(*storage_value)); - // also add the value below and above the storage value to the dictionary. - if *storage_value != U256::ZERO { - let below_value = storage_value - U256::from(1); - self.insert_value(B256::from(below_value)); - } - if *storage_value != U256::MAX { - let above_value = storage_value + U256::from(1); - self.insert_value(B256::from(above_value)); + /// Uses [`SlotIdentifier`] to identify storage slots types. + fn insert_storage_value(&mut self, slot: &U256, value: &U256, slot_info: Option) { + let slot = B256::from(*slot); + let value_word = B256::from(*value); + + // Always insert the slot itself + self.insert_value(slot); + + if let Some(slot_info) = slot_info { + trace!(?slot_info, "inserting typed storage value"); + if !self.samples_seeded { + self.seed_samples(); + } + self.sample_values + .entry(slot_info.slot_type.dyn_sol_type) + .or_default() + .insert(value_word); + } else { + self.insert_value_u256(*value); } } @@ -313,13 +631,111 @@ impl FuzzDictionary { } /// Insert raw value into fuzz dictionary. + /// /// If value is newly collected then it is removed by index at the end of current run. - fn insert_value(&mut self, value: B256) { - if self.state_values.len() < self.config.max_fuzz_dictionary_values { + /// + /// Returns true if the value was inserted. + fn insert_value(&mut self, value: B256) -> bool { + let insert = !self.values_full(); + if insert { let new_value = self.state_values.insert(value); let counter = if new_value { &mut self.misses } else { &mut self.hits }; *counter += 1; } + insert + } + + /// Insert a persistent value that survives `revert()` across invariant runs. + /// Used for trace-cmp operands that should compound over time. + fn insert_persistent_value(&mut self, value: B256) { + if self.persistent_values.len() >= MAX_PERSISTENT_VALUES { + return; + } + if !self.persistent_values.insert(value) { + return; + } + // `revert()` truncates `state_values` down to the first `db_state_values` entries, so the + // value must be placed inside that prefix; a plain `insert` appends past it and would be + // truncated at the end of the run. + match self.state_values.get_index_of(&value) { + // Already inside the persisted prefix. + Some(index) if index < self.db_state_values => {} + // Collected as an ephemeral value earlier in this run: move it into the prefix. + Some(index) => { + self.state_values.move_index(index, self.db_state_values); + self.db_state_values += 1; + } + None => { + self.state_values.shift_insert(self.db_state_values, value); + self.db_state_values += 1; + } + } + } + + /// Insert a typed trace-cmp value into the `sample_values` map. + /// Maps sancov width to `DynSolType` buckets and promotes to larger types. + fn insert_typed_cmp_value(&mut self, width: u8, value: B256) { + if !self.samples_seeded { + self.seed_samples(); + } + + const MAX_TYPED_CMP_PER_BUCKET: usize = 1024; + + let native_type = match width { + 8 => DynSolType::Uint(8), + 16 => DynSolType::Uint(16), + 32 => DynSolType::Uint(32), + 64 => DynSolType::Uint(64), + _ => DynSolType::Uint(256), + }; + + let insert = |map: &mut HashMap, ty: DynSolType, val: B256| { + let bucket = map.entry(ty).or_default(); + if bucket.len() < MAX_TYPED_CMP_PER_BUCKET { + bucket.insert(val); + } + }; + + insert(&mut self.sample_values, native_type, value); + + if width <= 64 { + insert(&mut self.sample_values, DynSolType::Uint(128), value); + insert(&mut self.sample_values, DynSolType::Uint(256), value); + insert(&mut self.sample_values, DynSolType::Int(256), value); + } + } + + fn insert_value_u256(&mut self, value: U256) -> bool { + // Also add the value below and above the push value to the dictionary. + let one = U256::ONE; + let mut inserted = self.insert_value(value.into()); + if !self.values_full() { + inserted |= self.insert_value((value.wrapping_sub(one)).into()); + } + if !self.values_full() { + inserted |= self.insert_value((value.wrapping_add(one)).into()); + } + inserted + } + + fn insert_push_value_u256(&mut self, value: U256, seen: &mut HashSet) -> bool { + // Also add the value below and above the push value to the dictionary. + let one = U256::ONE; + let mut inserted = false; + for value in [value, value.wrapping_sub(one), value.wrapping_add(one)] { + if self.values_full() { + break; + } + let value = value.into(); + if seen.insert(value) { + inserted |= self.insert_value(value); + } + } + inserted + } + + fn values_full(&self) -> bool { + self.state_values.len() >= self.config.max_fuzz_dictionary_values } /// Insert sample values that are reused across multiple runs. @@ -330,6 +746,9 @@ impl FuzzDictionary { sample_values: impl IntoIterator, limit: u32, ) { + if !self.samples_seeded { + self.seed_samples(); + } for sample in sample_values { if let (Some(sample_type), Some(sample_value)) = (sample.as_type(), sample.as_word()) { if let Some(values) = self.sample_values.get_mut(&sample_type) { @@ -346,7 +765,7 @@ impl FuzzDictionary { } } - pub fn values(&self) -> &B256IndexSet { + pub const fn values(&self) -> &B256IndexSet { &self.state_values } @@ -358,13 +777,34 @@ impl FuzzDictionary { self.state_values.is_empty() } + /// Returns sample values for a given type, checking both runtime samples and literals. + /// + /// Before `seed_samples()` is called, checks both `literal_values` and `sample_values` + /// separately. After seeding, all literal values are merged into `sample_values`. #[inline] pub fn samples(&self, param_type: &DynSolType) -> Option<&B256IndexSet> { + // If not seeded yet, return literals + if !self.samples_seeded { + return self.literal_values.get().words.get(param_type); + } + self.sample_values.get(param_type) } + /// Returns the collected literal strings, triggering initialization if needed. + #[inline] + pub fn ast_strings(&self) -> &IndexSet { + &self.literal_values.get().strings + } + + /// Returns the collected literal bytes (hex strings), triggering initialization if needed. #[inline] - pub fn addresses(&self) -> &AddressIndexSet { + pub fn ast_bytes(&self) -> &IndexSet { + &self.literal_values.get().bytes + } + + #[inline] + pub const fn addresses(&self) -> &AddressIndexSet { &self.addresses } @@ -372,6 +812,7 @@ impl FuzzDictionary { pub fn revert(&mut self) { self.state_values.truncate(self.db_state_values); self.addresses.truncate(self.db_addresses); + self.push_bytecode_hashes.truncate(self.db_push_bytecode_hashes); } pub fn log_stats(&self) { @@ -384,4 +825,243 @@ impl FuzzDictionary { "FuzzDictionary stats", ); } + + #[cfg(test)] + /// Test-only helper to seed the dictionary with literal values. + pub(crate) fn seed_literals(&mut self, map: super::LiteralMaps) { + self.literal_values.set(map); + } +} + +#[cfg(test)] +mod tests { + + use super::*; + use alloy_json_abi::{Event, JsonAbi}; + use foundry_evm_core::eip2935::HISTORY_STORAGE_ADDRESS; + use revm::bytecode::Bytecode; + + fn account_with_code(raw: &'static [u8]) -> AccountInfo { + AccountInfo::default().with_code(Bytecode::new_raw(Bytes::from_static(raw))) + } + + #[test] + fn log_decoding_preserves_anonymous_event_priority() { + let mut abi = JsonAbi::new(); + let anonymous_event = + Event::parse("event AEvent(bytes32 indexed topic, uint256 value) anonymous").unwrap(); + let matched_event = Event::parse("event ZEvent(uint256 value)").unwrap(); + let selector = matched_event.selector(); + abi.events.entry(anonymous_event.name.clone()).or_default().push(anonymous_event); + abi.events.entry(matched_event.name.clone()).or_default().push(matched_event); + let contract = TargetedContract::new("Target".to_string(), abi); + let matched_events = contract.event_lookup.by_topic(&selector, 0).unwrap(); + let word: B256 = U256::from(42).into(); + let log = Log::new_unchecked(Address::ZERO, vec![selector], Bytes::from(word)); + let mut samples = Vec::new(); + + assert!(FuzzDictionary::decode_log_events( + matched_events, + contract.event_lookup.anonymous(), + &log, + &mut samples, + )); + + assert_eq!(samples.len(), 2); + assert_eq!(samples[0], DynSolValue::FixedBytes(selector, 32)); + assert_eq!(samples[1], DynSolValue::Uint(U256::from(42), 256)); + } + + #[test] + fn push_byte_collection_stops_when_dictionary_is_full() { + let mut dictionary = FuzzDictionary::new(FuzzDictionaryConfig { + max_fuzz_dictionary_values: 3, + ..Default::default() + }); + + dictionary.collect_push_bytes(&[0x60, 0x01, 0x60, 0x03]); + + assert_eq!(dictionary.len(), 3); + assert!(dictionary.state_values.contains(&B256::ZERO)); + assert!(dictionary.state_values.contains(&B256::with_last_byte(1))); + assert!(dictionary.state_values.contains(&B256::with_last_byte(2))); + assert!(!dictionary.state_values.contains(&B256::with_last_byte(3))); + } + + #[test] + fn zero_value_capacity_keeps_only_required_seed() { + let mut dictionary = FuzzDictionary::new(FuzzDictionaryConfig { + max_fuzz_dictionary_values: 0, + ..Default::default() + }); + + assert_eq!(dictionary.config.max_fuzz_dictionary_values, 1); + assert_eq!(dictionary.state_values.as_slice(), &[B256::ZERO]); + assert!(!dictionary.insert_value(B256::with_last_byte(1))); + assert_eq!(dictionary.state_values.as_slice(), &[B256::ZERO]); + } + + #[test] + fn duplicate_push_values_in_same_bytecode_are_collected_once() { + let mut dictionary = FuzzDictionary::default(); + + dictionary.collect_push_bytes(&[0x60, 0x01, 0x60, 0x01]); + + assert!(dictionary.state_values.contains(&B256::ZERO)); + assert!(dictionary.state_values.contains(&B256::with_last_byte(1))); + assert!(dictionary.state_values.contains(&B256::with_last_byte(2))); + assert_eq!(dictionary.hits, 1); + } + + #[test] + fn duplicate_bytecode_push_bytes_are_collected_once() { + let mut dictionary = FuzzDictionary::default(); + let account = account_with_code(&[0x60, 0x01]); + + dictionary.insert_push_bytes_values(&Address::repeat_byte(0x11), &account); + let hits_after_first_scan = dictionary.hits; + + dictionary.insert_push_bytes_values(&Address::repeat_byte(0x22), &account); + + assert_eq!(dictionary.push_bytecode_hashes.len(), 1); + assert_eq!(dictionary.addresses.len(), 2); + assert_eq!(dictionary.hits, hits_after_first_scan); + } + + #[test] + fn same_address_with_new_bytecode_is_scanned_again() { + let mut dictionary = FuzzDictionary::default(); + let address = Address::repeat_byte(0x22); + + dictionary.insert_push_bytes_values(&address, &account_with_code(&[0x60, 0x01])); + dictionary.insert_push_bytes_values(&address, &account_with_code(&[0x60, 0x04])); + + assert_eq!(dictionary.addresses.len(), 1); + assert_eq!(dictionary.push_bytecode_hashes.len(), 2); + assert!(dictionary.state_values.contains(&B256::with_last_byte(1))); + assert!(dictionary.state_values.contains(&B256::with_last_byte(4))); + } + + #[test] + fn no_code_account_does_not_block_later_push_byte_scan() { + let mut dictionary = FuzzDictionary::default(); + let address = Address::repeat_byte(0x33); + let account_without_code = AccountInfo { code: None, ..Default::default() }; + + dictionary.insert_push_bytes_values(&address, &account_without_code); + + assert!(!dictionary.addresses.contains(&address)); + assert_eq!(dictionary.push_bytecode_hashes.len(), 0); + + dictionary.insert_push_bytes_values(&address, &account_with_code(&[0x60, 0x04])); + + assert!(dictionary.addresses.contains(&address)); + assert_eq!(dictionary.push_bytecode_hashes.len(), 1); + assert!(dictionary.state_values.contains(&B256::with_last_byte(4))); + } + + #[test] + fn revert_removes_runtime_bytecode_scan_cache() { + let mut dictionary = FuzzDictionary::default(); + dictionary.db_state_values = dictionary.state_values.len(); + dictionary.db_addresses = dictionary.addresses.len(); + dictionary.db_push_bytecode_hashes = dictionary.push_bytecode_hashes.len(); + + let account = account_with_code(&[0x60, 0x01]); + dictionary.insert_push_bytes_values(&Address::repeat_byte(0x11), &account); + assert_eq!(dictionary.push_bytecode_hashes.len(), 1); + + dictionary.revert(); + assert_eq!(dictionary.push_bytecode_hashes.len(), 0); + + dictionary.insert_push_bytes_values(&Address::repeat_byte(0x22), &account); + assert_eq!(dictionary.push_bytecode_hashes.len(), 1); + assert!(dictionary.state_values.contains(&B256::with_last_byte(1))); + } + + #[test] + fn persistent_value_survives_revert() { + let mut dictionary = FuzzDictionary::default(); + dictionary.db_state_values = dictionary.state_values.len(); + + let ephemeral = B256::from(U256::from(0xbeef_u64)); + let persistent = B256::from(U256::from(0xcafe_u64)); + dictionary.insert_value(ephemeral); + dictionary.insert_persistent_value(persistent); + + dictionary.revert(); + + assert!(dictionary.state_values.contains(&persistent)); + assert!(!dictionary.state_values.contains(&ephemeral)); + assert!(dictionary.db_state_values <= dictionary.state_values.len()); + + // Values already inside the persisted prefix are left untouched. + let watermark = dictionary.db_state_values; + let len = dictionary.state_values.len(); + dictionary.insert_persistent_value(B256::ZERO); + assert_eq!(dictionary.db_state_values, watermark); + assert_eq!(dictionary.state_values.len(), len); + } + + #[test] + fn persistent_value_promotes_existing_ephemeral() { + let mut dictionary = FuzzDictionary::default(); + dictionary.db_state_values = dictionary.state_values.len(); + + let ephemeral = B256::from(U256::from(0xbeef_u64)); + let value = B256::from(U256::from(0xdead_u64)); + dictionary.insert_value(ephemeral); + dictionary.insert_value(value); + dictionary.insert_persistent_value(value); + + dictionary.revert(); + + assert!(dictionary.state_values.contains(&value)); + assert!(!dictionary.state_values.contains(&ephemeral)); + assert!(dictionary.db_state_values <= dictionary.state_values.len()); + } + + #[test] + fn history_storage_account_is_excluded_from_initial_dictionary() { + let mut db = InMemoryDB::default(); + let code = Bytecode::new_raw(Bytes::from_static(&[0x61, 0x01, 0x23, 0x00])); + db.insert_account_info(HISTORY_STORAGE_ADDRESS, AccountInfo::default().with_code(code)); + db.insert_account_storage(HISTORY_STORAGE_ADDRESS, U256::from(7), U256::from(0xdead_u64)) + .unwrap(); + + let state = EvmFuzzState::new(&[], &db, FuzzDictionaryConfig::default(), None); + + state.with_dictionary(|dict| { + assert!(!dict.values().contains(&HISTORY_STORAGE_ADDRESS.into_word())); + assert!(!dict.values().contains(&B256::from(U256::from(0x123)))); + assert!(!dict.values().contains(&B256::with_last_byte(7))); + assert!(!dict.values().contains(&B256::from(U256::from(0xdead_u64)))); + }); + } + + #[test] + fn worker_modes_share_seed_but_isolate_feedback() { + let seed = EvmFuzzState::test(); + let readonly = seed.stateless_worker(); + let first = seed.clone().into_invariant(); + let second = seed.into_invariant(); + let transient = B256::from(U256::from(0xdead_u64)); + let persistent = B256::from(U256::from(0xbeef_u64)); + + readonly.collect_values([transient]); + assert!(!readonly.with_dictionary(|dict| dict.state_values.contains(&transient))); + + first.collect_values([transient]); + first.collect_typed_cmp_values([(8, persistent)]); + assert!(first.with_dictionary(|dict| dict.state_values.contains(&transient))); + assert!(!second.with_dictionary(|dict| dict.state_values.contains(&transient))); + + first.revert(); + assert!(!first.with_dictionary(|dict| dict.state_values.contains(&transient))); + first.with_dictionary(|dict| { + assert!(dict.state_values.contains(&persistent)); + assert!(dict.persistent_values.contains(&persistent)); + assert!(dict.sample_values[&DynSolType::Uint(8)].contains(&persistent)); + }); + } } diff --git a/crates/evm/fuzz/src/strategies/tx.rs b/crates/evm/fuzz/src/strategies/tx.rs new file mode 100644 index 0000000000000..2a4831d9864b3 --- /dev/null +++ b/crates/evm/fuzz/src/strategies/tx.rs @@ -0,0 +1,246 @@ +use super::{ + DictionaryRead, EvmFuzzState, FuzzState, fuzz_calldata, fuzz_calldata_from_state, + fuzz_msg_value, fuzz_param, fuzz_param_from_state, +}; +use crate::{ + BasicTxDetails, CallDetails, FuzzFixtures, + invariant::{FuzzRunIdentifiedContracts, SenderFilters}, +}; +use alloy_dyn_abi::DynSolType; +use alloy_json_abi::Function; +use alloy_primitives::{Address, U256}; +use eyre::{Result, eyre}; +use foundry_config::InvariantConfig; +use proptest::{prelude::*, test_runner::TestRunner}; +use std::{cell::RefCell, rc::Rc}; + +#[derive(Default)] +struct PlannedCalls { + generation: u64, + calls: Vec>, +} + +/// Concrete generator for stateless and invariant transactions. +#[derive(Clone)] +pub struct TxGenerator { + strategy: BoxedStrategy, +} + +impl TxGenerator { + /// Wraps a prebuilt strategy, primarily for deterministic tests. + pub const fn from_strategy(strategy: BoxedStrategy) -> Self { + Self { strategy } + } + /// Creates a fixed-target, fixed-sender stateless generator. + pub fn stateless( + state: EvmFuzzState, + fixtures: FuzzFixtures, + target: Address, + sender: Address, + function: Function, + dictionary_weight: u32, + payable_value_weight: u32, + ) -> Self { + let call = Self::call_strategy( + &state, + &fixtures, + target, + function, + dictionary_weight, + payable_value_weight, + ); + Self { + strategy: call + .prop_map(move |call_details| BasicTxDetails { + warp: None, + roll: None, + sender, + call_details, + }) + .boxed(), + } + } + + /// Creates a lazy invariant generator whose target list follows deployed contracts. + pub fn invariant( + state: FuzzState, + senders: SenderFilters, + contracts: FuzzRunIdentifiedContracts, + config: InvariantConfig, + fixtures: FuzzFixtures, + ) -> Self { + let senders = Rc::new(senders); + let dictionary_weight = config.dictionary.dictionary_weight; + let payable_value_weight = config.corpus.payable_value_weight; + let planned = Rc::new(RefCell::new(PlannedCalls::default())); + let strategy = any::() + .prop_flat_map(move |selector| { + let sender = select_sender(&state, senders.clone(), dictionary_weight); + let call = { + let generation = contracts.fuzzed_functions_generation(); + let mut planned = planned.borrow_mut(); + if planned.generation != generation || planned.calls.is_empty() { + planned.calls = contracts + .fuzzed_functions() + .iter() + .map(|(target, function)| { + Self::call_strategy( + &state, + &fixtures, + *target, + function.clone(), + dictionary_weight, + payable_value_weight, + ) + }) + .collect(); + planned.generation = generation; + } + selector.select(planned.calls.iter()).clone() + }; + let warp = optional_delay(config.max_time_delay); + let roll = optional_delay(config.max_block_delay); + (warp, roll, sender, call) + }) + .prop_map(move |(warp, roll, sender, call_details)| BasicTxDetails { + warp, + roll, + sender, + call_details, + }) + .boxed(); + Self { strategy } + } + + /// Draws the next transaction from this generator. + pub fn next_tx(&self, runner: &mut TestRunner) -> Result { + Ok(self.strategy.new_tree(runner).map_err(|_| eyre!("Could not generate case"))?.current()) + } + + /// Generates calldata and payable value for one contract call. + pub(crate) fn call_strategy( + state: &S, + fixtures: &FuzzFixtures, + target: Address, + function: Function, + dictionary_weight: u32, + payable_value_weight: u32, + ) -> BoxedStrategy { + let payable = function.state_mutability == alloy_json_abi::StateMutability::Payable; + let dictionary_weight = dictionary_weight.min(100); + let calldata = prop_oneof![ + 100 - dictionary_weight => fuzz_calldata(function.clone(), fixtures), + dictionary_weight => fuzz_calldata_from_state(function, state, fixtures), + ]; + let value = + if payable { fuzz_msg_value(payable_value_weight).boxed() } else { Just(None).boxed() }; + (calldata, value) + .prop_map(move |(calldata, value)| CallDetails { target, calldata, value }) + .boxed() + } +} + +fn optional_delay(max: Option) -> BoxedStrategy> { + if let Some(max) = max.filter(|max| *max > 0) { + any::().prop_map(move |value| Some(value % U256::from(max))).boxed() + } else { + Just(None).boxed() + } +} + +fn select_sender( + state: &FuzzState, + senders: Rc, + dictionary_weight: u32, +) -> BoxedStrategy
{ + if senders.targeted.is_empty() { + let dictionary_weight = dictionary_weight.min(100); + prop_oneof![ + 100 - dictionary_weight => fuzz_param(&DynSolType::Address), + dictionary_weight => fuzz_param_from_state(&DynSolType::Address, state), + ] + .prop_map(move |value| { + let mut sender = value.as_address().unwrap(); + while senders.excluded.contains(&sender) { + sender = Address::random(); + } + sender + }) + .boxed() + } else { + any::().prop_map(move |index| *index.get(&senders.targeted)).boxed() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::invariant::{TargetedContract, TargetedContracts}; + use alloy_json_abi::JsonAbi; + use foundry_config::FuzzDictionaryConfig; + use revm::database::InMemoryDB; + + #[test] + fn zero_delay_is_disabled() { + let mut runner = TestRunner::deterministic(); + assert_eq!(optional_delay(Some(0)).new_tree(&mut runner).unwrap().current(), None); + assert_eq!( + optional_delay(Some(1)).new_tree(&mut runner).unwrap().current(), + Some(U256::ZERO) + ); + } + + #[test] + fn stateless_generator_has_fixed_metadata() { + let target = Address::with_last_byte(1); + let sender = Address::with_last_byte(2); + let function = Function::parse("fuzz(uint256)").unwrap(); + let generator = TxGenerator::stateless( + EvmFuzzState::test(), + FuzzFixtures::default(), + target, + sender, + function, + 40, + 10, + ); + let mut runner = TestRunner::deterministic(); + let tx = generator.next_tx(&mut runner).unwrap(); + assert_eq!(tx.sender, sender); + assert_eq!(tx.call_details.target, target); + assert_eq!(tx.warp, None); + assert_eq!(tx.roll, None); + } + + #[test] + fn invariant_generator_refreshes_removed_targets_lazily() { + let retained = Address::with_last_byte(1); + let removed = Address::with_last_byte(2); + let function = Function::parse("fuzz(uint256)").unwrap(); + let mut abi = JsonAbi::new(); + abi.functions.entry(function.name.clone()).or_default().push(function); + let mut targets = TargetedContracts::new(); + targets.insert(retained, TargetedContract::new("Retained".into(), abi.clone())); + targets.insert(removed, TargetedContract::new("Removed".into(), abi)); + let identified = FuzzRunIdentifiedContracts::new(targets, false); + let state = + EvmFuzzState::new(&[], &InMemoryDB::default(), FuzzDictionaryConfig::default(), None) + .into_invariant(); + let generator = TxGenerator::invariant( + state, + SenderFilters::default(), + identified.clone(), + InvariantConfig::default(), + FuzzFixtures::default(), + ); + let mut runner = TestRunner::deterministic(); + + // Populate the lazy cache while both calls are available, then invalidate it solely via + // the public lifecycle API used after invariant runs. + let _ = generator.next_tx(&mut runner).unwrap(); + identified.clear_created_contracts(vec![removed]); + for _ in 0..32 { + assert_eq!(generator.next_tx(&mut runner).unwrap().call_details.target, retained); + } + } +} diff --git a/crates/evm/fuzz/src/strategies/uint.rs b/crates/evm/fuzz/src/strategies/uint.rs index b31da09803f98..9afc25e14fd65 100644 --- a/crates/evm/fuzz/src/strategies/uint.rs +++ b/crates/evm/fuzz/src/strategies/uint.rs @@ -23,7 +23,7 @@ impl UintValueTree { /// # Arguments /// * `start` - Starting value for the tree /// * `fixed` - If `true` the tree would only contain one element and won't be simplified. - fn new(start: U256, fixed: bool) -> Self { + const fn new(start: U256, fixed: bool) -> Self { Self { lo: U256::ZERO, curr: start, hi: start, fixed } } @@ -60,7 +60,7 @@ impl ValueTree for UintValueTree { return false; } - self.lo = self.curr + U256::from(1); + self.lo = self.curr + U256::ONE; self.reposition() } } @@ -157,19 +157,13 @@ impl UintStrategy { }; // init U256 from 2 randoms - let mut inner: [u64; 4] = [0; 4]; - let mask64 = (1 << 65) - 1; - inner[0] = (lower & mask64) as u64; - inner[1] = (lower >> 64) as u64; - inner[2] = (higher & mask64) as u64; - inner[3] = (higher >> 64) as u64; - let start: U256 = U256::from_limbs(inner); + let start = (U256::from(higher) << 128) | U256::from(lower); Ok(UintValueTree::new(start, false)) } fn type_max(&self) -> U256 { - if self.bits < 256 { (U256::from(1) << self.bits) - U256::from(1) } else { U256::MAX } + if self.bits < 256 { (U256::ONE << self.bits) - U256::ONE } else { U256::MAX } } } diff --git a/crates/evm/hardforks/Cargo.toml b/crates/evm/hardforks/Cargo.toml new file mode 100644 index 0000000000000..88d2c9f0cb5ee --- /dev/null +++ b/crates/evm/hardforks/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "foundry-evm-hardforks" +description = "EVM hardfork definitions for Foundry" + +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +alloy-chains.workspace = true +alloy-hardforks = { workspace = true, features = ["serde"] } +alloy-op-hardforks = { workspace = true, features = ["serde"], optional = true } +alloy-rpc-types.workspace = true +monad-revm = { workspace = true, optional = true } +base-common-genesis = { workspace = true, optional = true } +base-common-evm = { workspace = true, optional = true } +op-revm = { workspace = true, optional = true } +revm.workspace = true +serde = { workspace = true, features = ["derive"] } +tempo-hardfork.workspace = true +foundry-compilers.workspace = true + +[features] +default = ["optimism"] +monad = ["dep:monad-revm"] +base = ["dep:base-common-genesis", "dep:base-common-evm"] +optimism = ["dep:alloy-op-hardforks", "dep:op-revm"] diff --git a/crates/evm/hardforks/src/lib.rs b/crates/evm/hardforks/src/lib.rs new file mode 100644 index 0000000000000..69b1e32a58774 --- /dev/null +++ b/crates/evm/hardforks/src/lib.rs @@ -0,0 +1,1205 @@ +//! EVM hardfork definitions for Foundry. +//! +//! Provides [`FoundryHardfork`], a unified enum over Ethereum, Optimism, Tempo, and Monad hardforks +//! with `FromStr`/`Serialize`/`Deserialize` support for CLI and config usage. + +use alloy_chains::Chain; +use alloy_rpc_types::BlockNumberOrTag; +use foundry_compilers::artifacts::EvmVersion; +use revm::primitives::hardfork::SpecId; +use serde::{Deserialize, Serialize}; +use std::{ + str::FromStr, + time::{SystemTime, UNIX_EPOCH}, +}; + +#[cfg(all(feature = "optimism", not(feature = "base")))] +use alloy_chains::NamedChain; +#[cfg(feature = "optimism")] +use op_revm::OpSpecId; + +pub use alloy_hardforks::EthereumHardfork; +pub use tempo_hardfork::TempoHardfork; + +#[cfg(feature = "base")] +pub use base_common_evm::BaseSpecId; +#[cfg(feature = "base")] +pub use base_common_genesis::BaseUpgrade; + +#[cfg(feature = "monad")] +pub use monad_revm::MonadHardfork; + +#[cfg(feature = "optimism")] +pub use alloy_op_hardforks::OpHardfork; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] +#[serde(into = "String")] +pub enum FoundryHardfork { + Ethereum(EthereumHardfork), + #[cfg(feature = "optimism")] + Optimism(OpHardfork), + #[cfg(feature = "base")] + Base(BaseUpgrade), + Tempo(TempoHardfork), + #[cfg(feature = "monad")] + Monad(MonadHardfork), +} + +impl From for String { + fn from(fork: FoundryHardfork) -> Self { + match fork { + FoundryHardfork::Ethereum(h) => format!("{h}"), + #[cfg(feature = "optimism")] + FoundryHardfork::Optimism(h) => format!("optimism:{h}"), + #[cfg(feature = "base")] + FoundryHardfork::Base(h) => format!("base:{h}"), + FoundryHardfork::Tempo(h) => format!("tempo:{h}"), + #[cfg(feature = "monad")] + FoundryHardfork::Monad(h) => format!("monad:{h}"), + } + } +} + +impl<'de> Deserialize<'de> for FoundryHardfork { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Self::from_str(&s).map_err(serde::de::Error::custom) + } +} + +impl FromStr for FoundryHardfork { + type Err = String; + + fn from_str(s: &str) -> Result { + let raw = s.trim(); + + let Some((ns, fork_raw)) = raw.split_once(':') else { + return EthereumHardfork::from_str(raw) + .map(Self::Ethereum) + .map_err(|_| format!("unknown ethereum hardfork '{raw}'")); + }; + + let ns = ns.trim().to_ascii_lowercase(); + let fork = fork_raw.trim().to_ascii_lowercase().replace(['-', ' '], "_"); + + match ns.as_str() { + "eth" | "ethereum" => EthereumHardfork::from_str(&fork) + .map(Self::Ethereum) + .map_err(|_| format!("unknown ethereum hardfork '{fork_raw}'")), + + #[cfg(feature = "optimism")] + "op" | "optimism" => OpHardfork::from_str(&fork) + .map(Self::Optimism) + .map_err(|_| format!("unknown optimism hardfork '{fork_raw}'")), + #[cfg(not(feature = "optimism"))] + "op" | "optimism" => { + Err(format!("hardfork namespace '{ns}' requires the `optimism` feature")) + } + + #[cfg(feature = "base")] + "base" => BaseUpgrade::from_str(&fork) + .map(Self::Base) + .map_err(|_| format!("unknown base hardfork '{fork_raw}'")), + #[cfg(not(feature = "base"))] + "base" => Err(format!("hardfork namespace '{ns}' requires the `base` feature")), + + "t" | "tempo" => TempoHardfork::from_str(&fork) + .map(Self::Tempo) + .map_err(|_| format!("unknown tempo hardfork '{fork_raw}'")), + + #[cfg(feature = "monad")] + "m" | "monad" => MonadHardfork::from_str(&fork) + .map(Self::Monad) + .map_err(|_| format!("unknown monad hardfork '{fork_raw}'")), + #[cfg(not(feature = "monad"))] + "m" | "monad" => Err(format!("hardfork namespace '{ns}' requires the `monad` feature")), + + _ => Err(format!("unknown hardfork namespace '{ns}'")), + } + } +} + +impl FoundryHardfork { + pub const fn ethereum(h: EthereumHardfork) -> Self { + Self::Ethereum(h) + } + + #[cfg(feature = "optimism")] + pub const fn optimism(h: OpHardfork) -> Self { + Self::Optimism(h) + } + + #[cfg(feature = "base")] + pub const fn base(h: BaseUpgrade) -> Self { + Self::Base(h) + } + + pub const fn tempo(h: TempoHardfork) -> Self { + Self::Tempo(h) + } + + #[cfg(feature = "monad")] + pub const fn monad(h: MonadHardfork) -> Self { + Self::Monad(h) + } + + /// Returns the hardfork name without a network namespace prefix. + pub fn name(&self) -> String { + match self { + Self::Ethereum(h) => format!("{h}"), + #[cfg(feature = "optimism")] + Self::Optimism(h) => format!("{h}"), + #[cfg(feature = "base")] + Self::Base(h) => format!("{h}"), + Self::Tempo(h) => format!("{h}"), + #[cfg(feature = "monad")] + Self::Monad(h) => format!("{h}"), + } + } + + /// Returns the network namespace for this hardfork, or `None` for plain Ethereum. + /// + /// Mirrors the namespace prefix used in the `"network:hardfork"` serialization format. + pub const fn namespace(&self) -> Option<&'static str> { + match self { + Self::Ethereum(_) => None, + #[cfg(feature = "optimism")] + Self::Optimism(_) => Some("optimism"), + #[cfg(feature = "base")] + Self::Base(_) => Some("base"), + Self::Tempo(_) => Some("tempo"), + #[cfg(feature = "monad")] + Self::Monad(_) => Some("monad"), + } + } + + /// Auto-detect the active hardfork for a given chain at a specific timestamp. + /// + /// Tries Ethereum, then Optimism, Tempo, and Monad. Returns `None` for unknown chains. + pub fn from_chain_and_timestamp(chain_id: u64, timestamp: u64) -> Option { + let chain = Chain::from_id(chain_id); + if let Some(fork) = EthereumHardfork::from_chain_and_timestamp(chain, timestamp) { + return Some(Self::Ethereum(fork)); + } + #[cfg(feature = "base")] + if let Some(fork) = BaseUpgrade::from_chain_and_timestamp(chain_id, timestamp) { + return Some(Self::Base(fork)); + } + #[cfg(feature = "optimism")] + if let Some(fork) = OpHardfork::from_chain_and_timestamp(chain, timestamp) { + return Some(Self::Optimism(fork)); + } + if let Some(fork) = TempoHardfork::from_chain_and_timestamp(chain_id, timestamp) { + return Some(Self::Tempo(fork)); + } + #[cfg(feature = "monad")] + if let Some(fork) = MonadHardfork::from_chain_and_timestamp(chain_id, timestamp) { + return Some(Self::Monad(fork)); + } + None + } +} + +impl From for FoundryHardfork { + fn from(value: EthereumHardfork) -> Self { + Self::Ethereum(value) + } +} + +impl From for EthereumHardfork { + fn from(fork: FoundryHardfork) -> Self { + match fork { + FoundryHardfork::Ethereum(hardfork) => hardfork, + _ => Self::default(), + } + } +} + +#[cfg(feature = "optimism")] +impl From for FoundryHardfork { + fn from(value: OpHardfork) -> Self { + Self::Optimism(value) + } +} + +#[cfg(feature = "optimism")] +impl From for OpHardfork { + fn from(fork: FoundryHardfork) -> Self { + match fork { + FoundryHardfork::Optimism(hardfork) => hardfork, + _ => Self::default(), + } + } +} + +#[cfg(feature = "base")] +impl From for FoundryHardfork { + fn from(value: BaseUpgrade) -> Self { + Self::Base(value) + } +} + +#[cfg(feature = "base")] +impl From for BaseUpgrade { + fn from(fork: FoundryHardfork) -> Self { + match fork { + FoundryHardfork::Base(upgrade) => upgrade, + _ => Self::default(), + } + } +} + +#[cfg(feature = "base")] +impl From for BaseSpecId { + fn from(fork: FoundryHardfork) -> Self { + match fork { + FoundryHardfork::Base(upgrade) => Self::new(upgrade), + _ => Self::default(), + } + } +} + +impl From for FoundryHardfork { + fn from(value: TempoHardfork) -> Self { + Self::Tempo(value) + } +} + +impl From for TempoHardfork { + fn from(fork: FoundryHardfork) -> Self { + match fork { + FoundryHardfork::Tempo(hardfork) => hardfork, + _ => Self::default(), + } + } +} + +#[cfg(feature = "monad")] +impl From for FoundryHardfork { + fn from(value: MonadHardfork) -> Self { + Self::Monad(value) + } +} + +#[cfg(feature = "monad")] +impl From for MonadHardfork { + fn from(fork: FoundryHardfork) -> Self { + match fork { + FoundryHardfork::Monad(hardfork) => hardfork, + _ => Self::default(), + } + } +} + +impl From for SpecId { + fn from(fork: FoundryHardfork) -> Self { + match fork { + FoundryHardfork::Ethereum(hardfork) => spec_id_from_ethereum_hardfork(hardfork), + #[cfg(feature = "optimism")] + FoundryHardfork::Optimism(hardfork) => eth_spec_id_from_optimism_hardfork(hardfork), + #[cfg(feature = "base")] + FoundryHardfork::Base(hardfork) => BaseSpecId::new(hardfork).into_eth_spec(), + FoundryHardfork::Tempo(hardfork) => spec_id_from_tempo_hardfork(hardfork), + #[cfg(feature = "monad")] + FoundryHardfork::Monad(hardfork) => hardfork.into(), + } + } +} + +#[cfg(feature = "optimism")] +impl From for OpSpecId { + fn from(fork: FoundryHardfork) -> Self { + match fork { + FoundryHardfork::Optimism(hardfork) => spec_id_from_optimism_hardfork(hardfork), + _ => Self::default(), + } + } +} + +/// Map an `EthereumHardfork` enum into its corresponding `SpecId`. +pub fn spec_id_from_ethereum_hardfork(hardfork: EthereumHardfork) -> SpecId { + match hardfork { + EthereumHardfork::Frontier => SpecId::FRONTIER, + EthereumHardfork::Homestead => SpecId::HOMESTEAD, + EthereumHardfork::Dao => SpecId::HOMESTEAD, + EthereumHardfork::Tangerine => SpecId::TANGERINE, + EthereumHardfork::SpuriousDragon => SpecId::SPURIOUS_DRAGON, + EthereumHardfork::Byzantium => SpecId::BYZANTIUM, + EthereumHardfork::Constantinople => SpecId::PETERSBURG, + EthereumHardfork::Petersburg => SpecId::PETERSBURG, + EthereumHardfork::Istanbul => SpecId::ISTANBUL, + EthereumHardfork::MuirGlacier => SpecId::ISTANBUL, + EthereumHardfork::Berlin => SpecId::BERLIN, + EthereumHardfork::London => SpecId::LONDON, + EthereumHardfork::ArrowGlacier => SpecId::LONDON, + EthereumHardfork::GrayGlacier => SpecId::LONDON, + EthereumHardfork::Paris => SpecId::MERGE, + EthereumHardfork::Shanghai => SpecId::SHANGHAI, + EthereumHardfork::Cancun => SpecId::CANCUN, + EthereumHardfork::Prague => SpecId::PRAGUE, + EthereumHardfork::Osaka => SpecId::OSAKA, + EthereumHardfork::Bpo1 + | EthereumHardfork::Bpo2 + | EthereumHardfork::Bpo3 + | EthereumHardfork::Bpo4 + | EthereumHardfork::Bpo5 => SpecId::OSAKA, + EthereumHardfork::Amsterdam => SpecId::AMSTERDAM, + f => unreachable!("unimplemented {}", f), + } +} + +/// Map an `OptimismHardfork` enum into its corresponding `OpSpecId`. +#[cfg(feature = "optimism")] +pub fn spec_id_from_optimism_hardfork(hardfork: OpHardfork) -> OpSpecId { + match hardfork { + OpHardfork::Bedrock => OpSpecId::BEDROCK, + OpHardfork::Regolith => OpSpecId::REGOLITH, + OpHardfork::Canyon => OpSpecId::CANYON, + OpHardfork::Ecotone => OpSpecId::ECOTONE, + OpHardfork::Fjord => OpSpecId::FJORD, + OpHardfork::Granite => OpSpecId::GRANITE, + OpHardfork::Holocene => OpSpecId::HOLOCENE, + OpHardfork::Isthmus => OpSpecId::ISTHMUS, + OpHardfork::Jovian => OpSpecId::JOVIAN, + OpHardfork::Karst => OpSpecId::KARST, + OpHardfork::Lagoon => OpSpecId::LAGOON, + f => unreachable!("unimplemented {}", f), + } +} + +/// Map an `OptimismHardfork` enum into its corresponding Ethereum `SpecId`. +#[cfg(feature = "optimism")] +pub fn eth_spec_id_from_optimism_hardfork(hardfork: OpHardfork) -> SpecId { + match hardfork { + OpHardfork::Bedrock | OpHardfork::Regolith => SpecId::MERGE, + OpHardfork::Canyon => SpecId::SHANGHAI, + OpHardfork::Ecotone | OpHardfork::Fjord | OpHardfork::Granite | OpHardfork::Holocene => { + SpecId::CANCUN + } + OpHardfork::Isthmus | OpHardfork::Jovian => SpecId::PRAGUE, + OpHardfork::Karst | OpHardfork::Lagoon => SpecId::OSAKA, + f => unreachable!("unimplemented {}", f), + } +} + +/// Map a `TempoHardfork` enum into its corresponding Ethereum `SpecId`. +pub const fn spec_id_from_tempo_hardfork(_: TempoHardfork) -> SpecId { + SpecId::OSAKA +} + +/// Trait for converting an [`EvmVersion`] into a network-specific spec type. +pub trait FromEvmVersion: From { + fn from_evm_version(version: EvmVersion) -> Self; +} + +/// Conversion, hardfork scheduling, and trace metadata for a concrete execution spec. +pub trait ExecutionSpec: FromEvmVersion { + // Returns the user-facing name for the active execution spec. + fn evm_version_name(&self) -> String; + + // Parses an unnamespaced hardfork name for the active network. + fn from_network_hardfork(_: &str) -> Option { + None + } + + // Converts a namespaced Foundry hardfork if it belongs to this spec family. + fn from_foundry_hardfork(hardfork: FoundryHardfork) -> Option; + + /// Returns this family's hardfork at a historical source-chain timestamp. + /// Unknown source chains return `None`; this never discovers another execution family. + fn historical_hardfork(chain_id: u64, timestamp: u64) -> Option { + FoundryHardfork::from_chain_and_timestamp(chain_id, timestamp) + .filter(|&hardfork| Self::from_foundry_hardfork(hardfork).is_some()) + } + + /// Resolves a local fork's hardfork from compatible endpoint metadata or its source schedule. + /// Ethereum overrides this to retain the configured EVM version for local forks. + fn fork_hardfork( + chain_id: u64, + timestamp: u64, + endpoint_hardfork: Option, + ) -> Option { + endpoint_hardfork + .filter(|&hardfork| Self::from_foundry_hardfork(hardfork).is_some()) + .or_else(|| Self::historical_hardfork(chain_id, timestamp)) + } + + /// Returns exact metadata for a directly selected spec, when the family requires it. + /// Ethereum and Optimism retain their existing absence of metadata for direct spec overrides. + fn reported_hardfork(self) -> Option { + None + } +} + +impl FromEvmVersion for SpecId { + fn from_evm_version(version: EvmVersion) -> Self { + match version { + EvmVersion::Homestead => Self::HOMESTEAD, + EvmVersion::TangerineWhistle => Self::TANGERINE, + EvmVersion::SpuriousDragon => Self::SPURIOUS_DRAGON, + EvmVersion::Byzantium => Self::BYZANTIUM, + EvmVersion::Constantinople => Self::PETERSBURG, + EvmVersion::Petersburg => Self::PETERSBURG, + EvmVersion::Istanbul => Self::ISTANBUL, + EvmVersion::Berlin => Self::BERLIN, + EvmVersion::London => Self::LONDON, + EvmVersion::Paris => Self::MERGE, + EvmVersion::Shanghai => Self::SHANGHAI, + EvmVersion::Cancun => Self::CANCUN, + EvmVersion::Prague => Self::PRAGUE, + EvmVersion::Osaka => Self::OSAKA, + EvmVersion::Amsterdam => Self::AMSTERDAM, + } + } +} + +impl ExecutionSpec for SpecId { + // Returns the user-facing name for the active execution spec. + fn evm_version_name(&self) -> String { + self.to_string() + } + + // Parses an unnamespaced Ethereum hardfork name. + fn from_network_hardfork(hardfork: &str) -> Option { + EthereumHardfork::from_str(hardfork).ok().map(spec_id_from_ethereum_hardfork) + } + + // Converts only Ethereum namespaced hardforks to an Ethereum spec. + fn from_foundry_hardfork(hardfork: FoundryHardfork) -> Option { + match hardfork { + FoundryHardfork::Ethereum(hardfork) => Some(spec_id_from_ethereum_hardfork(hardfork)), + _ => None, + } + } + + fn fork_hardfork( + _chain_id: u64, + _timestamp: u64, + _endpoint_hardfork: Option, + ) -> Option { + None + } +} + +#[cfg(feature = "optimism")] +impl FromEvmVersion for OpSpecId { + fn from_evm_version(version: EvmVersion) -> Self { + match version { + EvmVersion::Homestead + | EvmVersion::TangerineWhistle + | EvmVersion::SpuriousDragon + | EvmVersion::Byzantium + | EvmVersion::Constantinople + | EvmVersion::Petersburg + | EvmVersion::Istanbul + | EvmVersion::Berlin + | EvmVersion::London + | EvmVersion::Paris => Self::BEDROCK, + EvmVersion::Shanghai => Self::CANYON, + EvmVersion::Cancun => Self::ECOTONE, + EvmVersion::Prague => Self::ISTHMUS, + EvmVersion::Osaka | EvmVersion::Amsterdam => Self::KARST, + } + } +} + +#[cfg(feature = "optimism")] +impl ExecutionSpec for OpSpecId { + // Returns the user-facing name for the active execution spec. + fn evm_version_name(&self) -> String { + let name: &'static str = (*self).into(); + name.to_string() + } + + // Parses an unnamespaced Optimism hardfork name. + fn from_network_hardfork(hardfork: &str) -> Option { + OpHardfork::from_str(hardfork).ok().map(spec_id_from_optimism_hardfork) + } + + // Converts only Optimism namespaced hardforks to an Optimism spec. + fn from_foundry_hardfork(hardfork: FoundryHardfork) -> Option { + match hardfork { + FoundryHardfork::Optimism(hardfork) => Some(spec_id_from_optimism_hardfork(hardfork)), + _ => None, + } + } + + fn fork_hardfork( + chain_id: u64, + timestamp: u64, + endpoint_hardfork: Option, + ) -> Option { + let endpoint_hardfork = + endpoint_hardfork.filter(|&hardfork| Self::from_foundry_hardfork(hardfork).is_some()); + if endpoint_hardfork.is_some() { + return endpoint_hardfork; + } + + // Base uses its own upgrade schedule. Without native Base support, retain the configured + // EVM version instead of applying the incompatible OP schedule. + #[cfg(not(feature = "base"))] + if matches!( + Chain::from_id(chain_id).named(), + Some(NamedChain::Base | NamedChain::BaseSepolia) + ) { + return None; + } + + Self::historical_hardfork(chain_id, timestamp) + } + + fn historical_hardfork(chain_id: u64, timestamp: u64) -> Option { + OpHardfork::from_chain_and_timestamp(Chain::from_id(chain_id), timestamp) + .map(FoundryHardfork::Optimism) + } +} + +impl FromEvmVersion for TempoHardfork { + fn from_evm_version(_: EvmVersion) -> Self { + latest_active_tempo_hardfork() + } +} + +impl ExecutionSpec for TempoHardfork { + // Returns the user-facing name for the active execution spec. + fn evm_version_name(&self) -> String { + self.to_string() + } + + // Parses an unnamespaced Tempo hardfork name. + fn from_network_hardfork(hardfork: &str) -> Option { + Self::from_str(hardfork).ok() + } + + // Converts only Tempo namespaced hardforks to a Tempo spec. + fn from_foundry_hardfork(hardfork: FoundryHardfork) -> Option { + match hardfork { + FoundryHardfork::Tempo(hardfork) => Some(hardfork), + _ => None, + } + } + + fn reported_hardfork(self) -> Option { + Some(self.into()) + } +} + +#[cfg(feature = "monad")] +impl FromEvmVersion for MonadHardfork { + fn from_evm_version(_: EvmVersion) -> Self { + Self::default() + } +} + +#[cfg(feature = "monad")] +impl ExecutionSpec for MonadHardfork { + // Returns the user-facing name for the active execution spec. + fn evm_version_name(&self) -> String { + self.to_string() + } + + // Parses an unnamespaced Monad hardfork name. + fn from_network_hardfork(hardfork: &str) -> Option { + Self::from_str(hardfork).ok() + } + + // Converts only Monad namespaced hardforks to a Monad spec. + fn from_foundry_hardfork(hardfork: FoundryHardfork) -> Option { + match hardfork { + #[cfg(feature = "monad")] + FoundryHardfork::Monad(hardfork) => Some(hardfork), + _ => None, + } + } + + fn reported_hardfork(self) -> Option { + Some(self.into()) + } +} + +#[cfg(feature = "base")] +impl FromEvmVersion for BaseSpecId { + fn from_evm_version(version: EvmVersion) -> Self { + let upgrade = match version { + EvmVersion::Homestead + | EvmVersion::TangerineWhistle + | EvmVersion::SpuriousDragon + | EvmVersion::Byzantium + | EvmVersion::Constantinople + | EvmVersion::Petersburg + | EvmVersion::Istanbul + | EvmVersion::Berlin + | EvmVersion::London + | EvmVersion::Paris => BaseUpgrade::Bedrock, + EvmVersion::Shanghai => BaseUpgrade::Canyon, + EvmVersion::Cancun => BaseUpgrade::Ecotone, + EvmVersion::Prague => BaseUpgrade::Isthmus, + EvmVersion::Osaka | EvmVersion::Amsterdam => BaseUpgrade::Azul, + }; + Self::new(upgrade) + } +} + +#[cfg(feature = "base")] +impl ExecutionSpec for BaseSpecId { + // Returns the user-facing name for the active execution spec. + fn evm_version_name(&self) -> String { + self.to_string() + } + + // Parses an unnamespaced Base hardfork name. + fn from_network_hardfork(hardfork: &str) -> Option { + Self::from_str(hardfork).ok() + } + + // Converts only Base namespaced hardforks to a Base spec. + fn from_foundry_hardfork(hardfork: FoundryHardfork) -> Option { + match hardfork { + FoundryHardfork::Base(hardfork) => Some(Self::new(hardfork)), + _ => None, + } + } + + fn reported_hardfork(self) -> Option { + Some(FoundryHardfork::Base(self.upgrade())) + } +} + +/// Returns the spec id derived from [`EvmVersion`] for a given spec type. +pub fn evm_spec_id(evm_version: EvmVersion) -> SPEC { + SPEC::from_evm_version(evm_version) +} + +/// Returns the latest Tempo hardfork that has an activation on a known Tempo network. +pub fn latest_active_tempo_hardfork() -> TempoHardfork { + // Tempo currently publishes activation timestamps through chain-aware hardfork resolution. + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(u64::MAX); + TempoHardfork::from_chain_and_timestamp(4217, now) + .or_else(|| TempoHardfork::from_chain_and_timestamp(42431, now)) + .unwrap_or_default() +} + +// Parses an EVM version or network-specific hardfork into the given spec type. +pub fn evm_spec_id_from_str(evm_version: &str) -> Option { + let evm_version = evm_version.trim(); + + if let Ok(version) = EvmVersion::from_str(evm_version) { + return Some(evm_spec_id(version)); + } + + if let Some(spec) = SPEC::from_network_hardfork(evm_version) { + return Some(spec); + } + + FoundryHardfork::from_str(evm_version).ok().and_then(SPEC::from_foundry_hardfork) +} + +/// Convert a `BlockNumberOrTag` into an `EthereumHardfork`. +pub fn ethereum_hardfork_from_block_tag(block: impl Into) -> EthereumHardfork { + let num = match block.into() { + BlockNumberOrTag::Earliest => 0, + BlockNumberOrTag::Number(num) => num, + _ => u64::MAX, + }; + + EthereumHardfork::from_mainnet_block_number(num) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_hardforks::ethereum::mainnet::*; + use tempo_hardfork::constants::{mainnet::*, moderato::*}; + + #[test] + fn test_ethereum_spec_id_mapping() { + for (hardfork, expected) in [ + (EthereumHardfork::Frontier, SpecId::FRONTIER), + (EthereumHardfork::Homestead, SpecId::HOMESTEAD), + (EthereumHardfork::Dao, SpecId::HOMESTEAD), + (EthereumHardfork::Tangerine, SpecId::TANGERINE), + (EthereumHardfork::SpuriousDragon, SpecId::SPURIOUS_DRAGON), + (EthereumHardfork::Byzantium, SpecId::BYZANTIUM), + (EthereumHardfork::Constantinople, SpecId::PETERSBURG), + (EthereumHardfork::Petersburg, SpecId::PETERSBURG), + (EthereumHardfork::Istanbul, SpecId::ISTANBUL), + (EthereumHardfork::MuirGlacier, SpecId::ISTANBUL), + (EthereumHardfork::Berlin, SpecId::BERLIN), + (EthereumHardfork::London, SpecId::LONDON), + (EthereumHardfork::ArrowGlacier, SpecId::LONDON), + (EthereumHardfork::GrayGlacier, SpecId::LONDON), + (EthereumHardfork::Paris, SpecId::MERGE), + (EthereumHardfork::Shanghai, SpecId::SHANGHAI), + (EthereumHardfork::Cancun, SpecId::CANCUN), + (EthereumHardfork::Prague, SpecId::PRAGUE), + (EthereumHardfork::Osaka, SpecId::OSAKA), + (EthereumHardfork::Bpo1, SpecId::OSAKA), + (EthereumHardfork::Bpo2, SpecId::OSAKA), + (EthereumHardfork::Bpo3, SpecId::OSAKA), + (EthereumHardfork::Bpo4, SpecId::OSAKA), + (EthereumHardfork::Bpo5, SpecId::OSAKA), + (EthereumHardfork::Amsterdam, SpecId::AMSTERDAM), + ] { + assert_eq!(spec_id_from_ethereum_hardfork(hardfork), expected, "{hardfork}"); + } + } + + #[test] + fn test_tempo_spec_id_mapping() { + assert_eq!(spec_id_from_tempo_hardfork(TempoHardfork::Genesis), SpecId::OSAKA); + assert_eq!(spec_id_from_tempo_hardfork(TempoHardfork::T8), SpecId::OSAKA); + } + + #[test] + fn test_tempo_evm_version_defaults_to_latest_active_hardfork() { + let latest = latest_active_tempo_hardfork(); + assert_eq!(evm_spec_id::(EvmVersion::Osaka), latest); + } + + #[test] + #[cfg(feature = "monad")] + fn test_monad_hardfork_parsing() { + assert_eq!( + "monad:MonadNine".parse::().unwrap(), + FoundryHardfork::Monad(MonadHardfork::MonadNine) + ); + assert_eq!( + "monad:MonadTen".parse::().unwrap(), + FoundryHardfork::Monad(MonadHardfork::MonadTen) + ); + assert_eq!( + "m:MonadNext".parse::().unwrap(), + FoundryHardfork::Monad(MonadHardfork::MonadNext) + ); + } + + #[test] + fn test_hardfork_namespace_parsing() { + let cases = [ + ("prague", Ok(FoundryHardfork::Ethereum(EthereumHardfork::Prague))), + ("eth:prague", Ok(FoundryHardfork::Ethereum(EthereumHardfork::Prague))), + ("ethereum:Osaka", Ok(FoundryHardfork::Ethereum(EthereumHardfork::Osaka))), + (" ETH : Prague ", Ok(FoundryHardfork::Ethereum(EthereumHardfork::Prague))), + ("t:T5", Ok(FoundryHardfork::Tempo(TempoHardfork::T5))), + ("tempo:t5", Ok(FoundryHardfork::Tempo(TempoHardfork::T5))), + #[cfg(feature = "optimism")] + ("op:Isthmus", Ok(FoundryHardfork::Optimism(OpHardfork::Isthmus))), + #[cfg(feature = "optimism")] + ("optimism:jovian", Ok(FoundryHardfork::Optimism(OpHardfork::Jovian))), + #[cfg(not(feature = "optimism"))] + ("op:prague", Err("hardfork namespace 'op' requires the `optimism` feature")), + #[cfg(feature = "base")] + ("base:azul", Ok(FoundryHardfork::Base(BaseUpgrade::Azul))), + #[cfg(not(feature = "base"))] + ("base:prague", Err("hardfork namespace 'base' requires the `base` feature")), + #[cfg(feature = "monad")] + ("m:MonadTen", Ok(FoundryHardfork::Monad(MonadHardfork::MonadTen))), + #[cfg(feature = "monad")] + ("monad:monadnine", Ok(FoundryHardfork::Monad(MonadHardfork::MonadNine))), + #[cfg(not(feature = "monad"))] + ("monad:prague", Err("hardfork namespace 'monad' requires the `monad` feature")), + ("foo:prague", Err("unknown hardfork namespace 'foo'")), + ("Foo:Prague", Err("unknown hardfork namespace 'foo'")), + ]; + + for (input, expected) in cases { + assert_eq!( + input.parse::(), + expected.map_err(str::to_string), + "{input}" + ); + } + } + + #[test] + #[cfg(feature = "monad")] + fn test_monad_hardfork_serialization() { + assert_eq!( + String::from(FoundryHardfork::Monad(MonadHardfork::MonadEight)), + "monad:MonadEight" + ); + assert_eq!(FoundryHardfork::Monad(MonadHardfork::MonadEight).namespace(), Some("monad")); + assert_eq!(FoundryHardfork::Monad(MonadHardfork::MonadEight).name(), "MonadEight"); + } + + #[test] + #[cfg(feature = "monad")] + fn test_monad_hardfork_spec_id_mapping() { + assert_eq!(SpecId::from(FoundryHardfork::Monad(MonadHardfork::MonadEight)), SpecId::PRAGUE); + assert_eq!(SpecId::from(FoundryHardfork::Monad(MonadHardfork::MonadNine)), SpecId::OSAKA); + assert_eq!(SpecId::from(FoundryHardfork::Monad(MonadHardfork::MonadTen)), SpecId::OSAKA); + assert_eq!( + MonadHardfork::from(FoundryHardfork::Monad(MonadHardfork::MonadNext)), + MonadHardfork::MonadNext + ); + } + + #[test] + fn test_tempo_hardfork_from_chain_and_timestamp() { + assert_eq!( + FoundryHardfork::from_chain_and_timestamp(4217, u64::MAX), + Some(FoundryHardfork::Tempo(TempoHardfork::T12)) + ); + assert_eq!( + FoundryHardfork::from_chain_and_timestamp(42431, u64::MAX), + Some(FoundryHardfork::Tempo(TempoHardfork::T12)) + ); + + assert_eq!( + FoundryHardfork::from_chain_and_timestamp(MAINNET_CHAIN_ID, MAINNET_T6_TIMESTAMP - 1), + Some(FoundryHardfork::Tempo(TempoHardfork::T5)) + ); + assert_eq!( + FoundryHardfork::from_chain_and_timestamp(MAINNET_CHAIN_ID, MAINNET_T6_TIMESTAMP), + Some(FoundryHardfork::Tempo(TempoHardfork::T6)) + ); + assert_eq!( + FoundryHardfork::from_chain_and_timestamp(MAINNET_CHAIN_ID, MAINNET_T7_TIMESTAMP - 1), + Some(FoundryHardfork::Tempo(TempoHardfork::T6)) + ); + assert_eq!( + FoundryHardfork::from_chain_and_timestamp(MAINNET_CHAIN_ID, MAINNET_T7_TIMESTAMP), + Some(FoundryHardfork::Tempo(TempoHardfork::T7)) + ); + assert_eq!( + FoundryHardfork::from_chain_and_timestamp(MAINNET_CHAIN_ID, MAINNET_T8_TIMESTAMP - 1), + Some(FoundryHardfork::Tempo(TempoHardfork::T7)) + ); + assert_eq!( + FoundryHardfork::from_chain_and_timestamp(MAINNET_CHAIN_ID, MAINNET_T8_TIMESTAMP), + Some(FoundryHardfork::Tempo(TempoHardfork::T8)) + ); + assert_eq!( + FoundryHardfork::from_chain_and_timestamp(MAINNET_CHAIN_ID, MAINNET_T9_TIMESTAMP - 1), + Some(FoundryHardfork::Tempo(TempoHardfork::T8)) + ); + assert_eq!( + FoundryHardfork::from_chain_and_timestamp(MAINNET_CHAIN_ID, MAINNET_T9_TIMESTAMP), + Some(FoundryHardfork::Tempo(TempoHardfork::T9)) + ); + assert_eq!( + FoundryHardfork::from_chain_and_timestamp(MODERATO_CHAIN_ID, MODERATO_T6_TIMESTAMP - 1), + Some(FoundryHardfork::Tempo(TempoHardfork::T5)) + ); + assert_eq!( + FoundryHardfork::from_chain_and_timestamp(MODERATO_CHAIN_ID, MODERATO_T6_TIMESTAMP), + Some(FoundryHardfork::Tempo(TempoHardfork::T6)) + ); + assert_eq!( + FoundryHardfork::from_chain_and_timestamp(MODERATO_CHAIN_ID, MODERATO_T7_TIMESTAMP - 1), + Some(FoundryHardfork::Tempo(TempoHardfork::T6)) + ); + assert_eq!( + FoundryHardfork::from_chain_and_timestamp(MODERATO_CHAIN_ID, MODERATO_T7_TIMESTAMP), + Some(FoundryHardfork::Tempo(TempoHardfork::T7)) + ); + assert_eq!( + FoundryHardfork::from_chain_and_timestamp(MODERATO_CHAIN_ID, MODERATO_T8_TIMESTAMP - 1), + Some(FoundryHardfork::Tempo(TempoHardfork::T7)) + ); + assert_eq!( + FoundryHardfork::from_chain_and_timestamp(MODERATO_CHAIN_ID, MODERATO_T8_TIMESTAMP), + Some(FoundryHardfork::Tempo(TempoHardfork::T8)) + ); + assert_eq!( + FoundryHardfork::from_chain_and_timestamp(MODERATO_CHAIN_ID, MODERATO_T9_TIMESTAMP - 1), + Some(FoundryHardfork::Tempo(TempoHardfork::T8)) + ); + assert_eq!( + FoundryHardfork::from_chain_and_timestamp(MODERATO_CHAIN_ID, MODERATO_T9_TIMESTAMP), + Some(FoundryHardfork::Tempo(TempoHardfork::T9)) + ); + } + + #[test] + #[cfg(feature = "monad")] + fn test_monad_hardfork_from_chain_and_timestamp() { + let mainnet_ten = MonadHardfork::MonadTen.mainnet_activation_timestamp().unwrap(); + let testnet_ten = MonadHardfork::MonadTen.testnet_activation_timestamp().unwrap(); + + assert_eq!( + FoundryHardfork::from_chain_and_timestamp( + monad_revm::MONAD_MAINNET_CHAIN_ID, + 1_763_648_999, + ), + Some(FoundryHardfork::Monad(MonadHardfork::MonadEight)) + ); + assert_eq!( + FoundryHardfork::from_chain_and_timestamp( + monad_revm::MONAD_MAINNET_CHAIN_ID, + 1_773_930_600, + ), + Some(FoundryHardfork::Monad(MonadHardfork::MonadNine)) + ); + assert_eq!( + FoundryHardfork::from_chain_and_timestamp( + monad_revm::MONAD_MAINNET_CHAIN_ID, + mainnet_ten - 1, + ), + Some(FoundryHardfork::Monad(MonadHardfork::MonadNine)) + ); + assert_eq!( + FoundryHardfork::from_chain_and_timestamp( + monad_revm::MONAD_MAINNET_CHAIN_ID, + mainnet_ten, + ), + Some(FoundryHardfork::Monad(MonadHardfork::MonadTen)) + ); + assert_eq!( + FoundryHardfork::from_chain_and_timestamp( + monad_revm::MONAD_TESTNET_CHAIN_ID, + 1_773_152_999, + ), + Some(FoundryHardfork::Monad(MonadHardfork::MonadEight)) + ); + assert_eq!( + FoundryHardfork::from_chain_and_timestamp( + monad_revm::MONAD_TESTNET_CHAIN_ID, + 1_773_153_000, + ), + Some(FoundryHardfork::Monad(MonadHardfork::MonadNine)) + ); + assert_eq!( + FoundryHardfork::from_chain_and_timestamp( + monad_revm::MONAD_TESTNET_CHAIN_ID, + testnet_ten - 1, + ), + Some(FoundryHardfork::Monad(MonadHardfork::MonadNine)) + ); + assert_eq!( + FoundryHardfork::from_chain_and_timestamp( + monad_revm::MONAD_TESTNET_CHAIN_ID, + testnet_ten, + ), + Some(FoundryHardfork::Monad(MonadHardfork::MonadTen)) + ); + } + + #[test] + fn test_evm_spec_id_from_str_parses_network_hardforks() { + assert_eq!(evm_spec_id_from_str::("T3"), Some(TempoHardfork::T3)); + assert_eq!(evm_spec_id_from_str::("tempo:T2"), Some(TempoHardfork::T2)); + assert_eq!(evm_spec_id_from_str::("tempo:T7"), Some(TempoHardfork::T7)); + assert_eq!(evm_spec_id_from_str::("tempo:T8"), Some(TempoHardfork::T8)); + assert_eq!(evm_spec_id_from_str::("tempo:T13"), Some(TempoHardfork::T13)); + assert_eq!(evm_spec_id_from_str::("T13"), Some(TempoHardfork::T13)); + assert_eq!(evm_spec_id_from_str::("tempo:T14"), Some(TempoHardfork::T14)); + assert_eq!(evm_spec_id_from_str::("T14"), Some(TempoHardfork::T14)); + assert_eq!(evm_spec_id_from_str::("ethereum:prague"), None); + + #[cfg(feature = "monad")] + { + assert_eq!( + evm_spec_id_from_str::("MonadNine"), + Some(MonadHardfork::MonadNine) + ); + assert_eq!( + evm_spec_id_from_str::("MonadTen"), + Some(MonadHardfork::MonadTen) + ); + assert_eq!( + evm_spec_id_from_str::("monad:MonadEight"), + Some(MonadHardfork::MonadEight) + ); + assert_eq!(evm_spec_id_from_str::("tempo:T3"), None); + } + } + + #[test] + fn test_hardfork_from_block_tag_numbers() { + assert_eq!( + ethereum_hardfork_from_block_tag(MAINNET_HOMESTEAD_BLOCK - 1), + EthereumHardfork::Frontier + ); + assert_eq!( + ethereum_hardfork_from_block_tag(MAINNET_LONDON_BLOCK + 1), + EthereumHardfork::London + ); + } + + #[test] + fn test_from_chain_and_timestamp_ethereum_mainnet() { + assert_eq!( + FoundryHardfork::from_chain_and_timestamp(1, 0), + Some(FoundryHardfork::Ethereum(EthereumHardfork::Frontier)) + ); + // Shanghai activated at timestamp 1681338455 on mainnet + assert_eq!( + FoundryHardfork::from_chain_and_timestamp(1, 1_681_338_455), + Some(FoundryHardfork::Ethereum(EthereumHardfork::Shanghai)) + ); + } + + #[test] + fn test_from_chain_and_timestamp_sepolia() { + let sepolia_chain_id = 11155111; + assert!(FoundryHardfork::from_chain_and_timestamp(sepolia_chain_id, u64::MAX).is_some()); + } + + #[test] + fn test_from_chain_and_timestamp_unknown_chain() { + assert_eq!(FoundryHardfork::from_chain_and_timestamp(999999, 0), None); + } + + #[cfg(feature = "base")] + mod base { + use super::*; + use base_common_genesis::UpgradeConfig; + + #[test] + fn test_base_hardfork_serialization() { + assert_eq!(String::from(FoundryHardfork::Base(BaseUpgrade::Azul)), "base:Azul"); + assert_eq!(FoundryHardfork::Base(BaseUpgrade::Azul).namespace(), Some("base")); + assert_eq!(FoundryHardfork::Base(BaseUpgrade::Azul).name(), "Azul"); + } + + #[test] + fn test_base_hardfork_spec_id_mapping() { + assert_eq!(SpecId::from(FoundryHardfork::Base(BaseUpgrade::Azul)), SpecId::OSAKA); + assert_eq!(SpecId::from(FoundryHardfork::Base(BaseUpgrade::Jovian)), SpecId::PRAGUE); + assert_eq!( + BaseSpecId::from(FoundryHardfork::Base(BaseUpgrade::Azul)), + BaseSpecId::new(BaseUpgrade::Azul) + ); + } + + #[test] + fn test_base_hardfork_parsing() { + assert_eq!( + "base:Azul".parse::().unwrap(), + FoundryHardfork::Base(BaseUpgrade::Azul) + ); + assert_eq!( + "base:Beryl".parse::().unwrap(), + FoundryHardfork::Base(BaseUpgrade::Beryl) + ); + } + + #[test] + fn test_base_hardfork_from_chain_and_timestamp() { + let mainnet_config = UpgradeConfig::BASE_MAINNET; + let sepolia_config = UpgradeConfig::BASE_SEPOLIA; + assert_eq!( + FoundryHardfork::from_chain_and_timestamp(8453, u64::MAX), + Some(FoundryHardfork::Base(BaseUpgrade::Beryl)) + ); + assert_eq!( + FoundryHardfork::from_chain_and_timestamp(84532, u64::MAX), + Some(FoundryHardfork::Base(BaseUpgrade::Beryl)) + ); + assert_eq!( + FoundryHardfork::from_chain_and_timestamp(8453, mainnet_config.base.azul.unwrap()), + Some(FoundryHardfork::Base(BaseUpgrade::Azul)) + ); + assert_eq!( + FoundryHardfork::from_chain_and_timestamp(84532, sepolia_config.base.azul.unwrap()), + Some(FoundryHardfork::Base(BaseUpgrade::Azul)) + ); + assert_eq!( + FoundryHardfork::from_chain_and_timestamp( + 8453, + mainnet_config.base.azul.unwrap() - 1 + ), + Some(FoundryHardfork::Base(BaseUpgrade::Jovian)) + ); + assert_eq!( + FoundryHardfork::from_chain_and_timestamp( + 84532, + sepolia_config.base.azul.unwrap() - 1 + ), + Some(FoundryHardfork::Base(BaseUpgrade::Jovian)) + ); + } + + #[test] + fn test_evm_spec_id_from_str_parses_base_upgrades() { + assert_eq!( + evm_spec_id_from_str::("Azul"), + Some(BaseSpecId::new(BaseUpgrade::Azul)) + ); + assert_eq!( + evm_spec_id_from_str::("base:Beryl"), + Some(BaseSpecId::new(BaseUpgrade::Beryl)) + ); + assert_eq!(evm_spec_id_from_str::("tempo:T3"), None); + } + } + + #[cfg(feature = "optimism")] + mod optimism { + use super::*; + + #[cfg(feature = "base")] + use base_common_genesis::UpgradeConfig; + + #[test] + fn test_optimism_spec_id_mapping() { + assert_eq!(spec_id_from_optimism_hardfork(OpHardfork::Bedrock), OpSpecId::BEDROCK); + assert_eq!(spec_id_from_optimism_hardfork(OpHardfork::Regolith), OpSpecId::REGOLITH); + + // Test latest hardforks + assert_eq!(spec_id_from_optimism_hardfork(OpHardfork::Holocene), OpSpecId::HOLOCENE); + assert_eq!(spec_id_from_optimism_hardfork(OpHardfork::Karst), OpSpecId::KARST); + assert_eq!(spec_id_from_optimism_hardfork(OpHardfork::Lagoon), OpSpecId::LAGOON); + assert_eq!(eth_spec_id_from_optimism_hardfork(OpHardfork::Jovian), SpecId::PRAGUE); + assert_eq!(eth_spec_id_from_optimism_hardfork(OpHardfork::Karst), SpecId::OSAKA); + assert_eq!(eth_spec_id_from_optimism_hardfork(OpHardfork::Lagoon), SpecId::OSAKA); + assert_eq!(evm_spec_id::(EvmVersion::Osaka), OpSpecId::KARST); + } + + #[test] + fn test_from_chain_and_timestamp_op_mainnet() { + let op_chain_id = 10; + assert!(matches!( + FoundryHardfork::from_chain_and_timestamp(op_chain_id, u64::MAX), + Some(FoundryHardfork::Optimism(_)) + )); + } + + #[test] + #[cfg(feature = "base")] + fn test_base_chain_historical_hardfork_is_execution_family_specific() { + let chains = [ + (8453, UpgradeConfig::BASE_MAINNET.ecotone_time.unwrap()), + (84532, UpgradeConfig::BASE_SEPOLIA.ecotone_time.unwrap()), + ]; + + for (chain_id, timestamp) in chains { + assert_eq!( + FoundryHardfork::from_chain_and_timestamp(chain_id, timestamp), + Some(FoundryHardfork::Base(BaseUpgrade::Ecotone)) + ); + assert_eq!( + BaseSpecId::historical_hardfork(chain_id, timestamp), + Some(FoundryHardfork::Base(BaseUpgrade::Ecotone)) + ); + assert_eq!( + OpSpecId::historical_hardfork(chain_id, timestamp), + Some(FoundryHardfork::Optimism(OpHardfork::Ecotone)) + ); + assert_eq!( + OpSpecId::fork_hardfork(chain_id, timestamp, None), + Some(FoundryHardfork::Optimism(OpHardfork::Ecotone)) + ); + } + } + + /// Base is an OP-stack chain, so without the `base` feature its chain IDs must still map + /// to an Optimism hardfork rather than resolving to nothing. + #[test] + #[cfg(not(feature = "base"))] + fn test_from_chain_and_timestamp_base() { + let base_chain_id = 8453; + assert!(matches!( + FoundryHardfork::from_chain_and_timestamp(base_chain_id, u64::MAX), + Some(FoundryHardfork::Optimism(_)) + )); + } + } +} diff --git a/crates/evm/networks/Cargo.toml b/crates/evm/networks/Cargo.toml new file mode 100644 index 0000000000000..43391de561b2e --- /dev/null +++ b/crates/evm/networks/Cargo.toml @@ -0,0 +1,57 @@ +[package] +name = "foundry-evm-networks" +description = "Custom network features, like precompiles and custom tx types" + +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +foundry-evm-hardforks.workspace = true + +alloy-chains.workspace = true +alloy-eips.workspace = true +alloy-evm.workspace = true +alloy-op-hardforks = { workspace = true, optional = true } +alloy-primitives = { workspace = true, features = [ + "serde", + "getrandom", + "arbitrary", + "rlp", +] } + +# Base +base-common-precompiles = { workspace = true, optional = true, features = ["std"] } + +revm = { workspace = true, features = [ + "std", + "serde", + "memory_limit", + "optional_eip3607", + "optional_block_gas_limit", + "optional_no_base_fee", + "arbitrary", + "c-kzg", + "blst", +] } +monad-revm = { workspace = true, optional = true } + +clap = { version = "4", features = ["derive", "env", "unicode", "wrap_help"] } +serde.workspace = true +tempo-contracts.workspace = true + +[dev-dependencies] +serde_json.workspace = true + +[features] +default = ["optimism"] +monad = ["dep:monad-revm", "foundry-evm-hardforks/monad"] +optimism = ["dep:alloy-op-hardforks", "foundry-evm-hardforks/optimism"] +base = ["dep:base-common-precompiles", "foundry-evm-hardforks/base"] diff --git a/crates/evm/networks/README.md b/crates/evm/networks/README.md new file mode 100644 index 0000000000000..37056679f5e3c --- /dev/null +++ b/crates/evm/networks/README.md @@ -0,0 +1,12 @@ +# Foundry EVM networks + +This crate owns Foundry's runtime network-family selection and the network features that can be +shared across Forge, Cast, Anvil, Chisel, and the EVM backend. `NetworkConfigs` represents the active +execution profile; optional Cargo features only make additional profiles available to a binary. + +The crate does not instantiate an EVM. Concrete Alloy network and EVM factory types are associated +through `FoundryEvmNetwork` in `foundry-evm-core`. + +See the [custom EVM integration guide](../../../docs/dev/networks.md) for ownership boundaries, +state-lifecycle requirements, tool coverage, and CI expectations. API details are published in the +[workspace Rustdoc](https://foundry-rs.github.io/foundry/foundry_evm_networks/). diff --git a/crates/evm/networks/src/arbitrum.rs b/crates/evm/networks/src/arbitrum.rs new file mode 100644 index 0000000000000..ebc7f0e609157 --- /dev/null +++ b/crates/evm/networks/src/arbitrum.rs @@ -0,0 +1,59 @@ +//! Minimal Arbitrum system contract compatibility helpers. + +use std::borrow::Cow; + +use alloy_chains::Chain; +use alloy_evm::precompiles::{DynPrecompile, PrecompileInput}; +use alloy_primitives::{Address, Bytes, U256, hex}; +use revm::precompile::{PrecompileHalt, PrecompileId, PrecompileOutput, PrecompileResult}; + +/// ArbSys system contract address. +pub const ARB_SYS_ADDRESS: Address = Address::with_last_byte(0x64); + +/// `ArbSys.arbBlockNumber()` selector. +pub const ARB_BLOCK_NUMBER_SELECTOR: [u8; 4] = hex!("a3b1b31d"); + +/// Gas Nitro charges inside `ArbSys.arbBlockNumber()`: opening the ArbOS state reads its version +/// from storage (800, the EIP-2200 `SLOAD` cost), and copying the 32-byte result costs 3. +pub const ARB_BLOCK_NUMBER_GAS_COST: u64 = 803; + +/// ID for the ArbSys precompile. +pub static PRECOMPILE_ID_ARB_SYS: PrecompileId = PrecompileId::Custom(Cow::Borrowed("ArbSys")); + +/// Returns whether `chain_id` is an Arbitrum chain. +pub fn is_arbitrum_chain(chain_id: u64) -> bool { + Chain::from_id(chain_id).is_arbitrum() +} + +/// Returns the ABI-encoded result for `ArbSys.arbBlockNumber()`. +pub fn arb_block_number_output(block_number: u64) -> Bytes { + U256::from(block_number).to_be_bytes::<32>().into() +} + +/// Returns the gas cost and ABI-encoded result for `ArbSys.arbBlockNumber()`. +pub fn arb_block_number_call(gas_limit: u64, block_number: u64) -> Option<(u64, Bytes)> { + (gas_limit >= ARB_BLOCK_NUMBER_GAS_COST) + .then(|| (ARB_BLOCK_NUMBER_GAS_COST, arb_block_number_output(block_number))) +} + +/// Returns an ArbSys precompile for the provided L2 block number. +pub fn arb_sys_precompile(block_number: u64) -> DynPrecompile { + DynPrecompile::new_stateful(PRECOMPILE_ID_ARB_SYS.clone(), move |input| { + arb_sys_precompile_call(input, block_number) + }) +} + +fn arb_sys_precompile_call(input: PrecompileInput<'_>, block_number: u64) -> PrecompileResult { + if input.data.get(..4) != Some(&ARB_BLOCK_NUMBER_SELECTOR) { + return Ok(PrecompileOutput::halt( + PrecompileHalt::Other("unsupported ArbSys selector".into()), + input.reservoir, + )); + } + + let Some((gas_cost, output)) = arb_block_number_call(input.gas(), block_number) else { + return Ok(PrecompileOutput::halt(PrecompileHalt::OutOfGas, input.reservoir)); + }; + + Ok(PrecompileOutput::new(gas_cost, output, input.reservoir)) +} diff --git a/crates/evm/networks/src/celo/mod.rs b/crates/evm/networks/src/celo/mod.rs new file mode 100644 index 0000000000000..0f2a3c1a3ede0 --- /dev/null +++ b/crates/evm/networks/src/celo/mod.rs @@ -0,0 +1,6 @@ +//! Celo-specific execution support. + +pub mod transfer; + +/// Celo dynamic fee transaction type introduced by CIP-64. +pub const CELO_DYNAMIC_FEE_TX_TYPE: u8 = 0x7b; diff --git a/crates/evm/networks/src/celo/transfer.rs b/crates/evm/networks/src/celo/transfer.rs new file mode 100644 index 0000000000000..600c28da076e5 --- /dev/null +++ b/crates/evm/networks/src/celo/transfer.rs @@ -0,0 +1,124 @@ +//! Celo precompile implementation for token transfers. +//! +//! This module implements the Celo transfer precompile that enables native token transfers from an +//! EVM contract. The precompile is part of Celo's token duality system, allowing transfer of +//! native tokens via ERC20. +//! +//! For more details, see: +//! +//! The transfer precompile is deployed at address 0xfd and accepts 96 bytes of input: +//! - from address (32 bytes, left-padded) +//! - to address (32 bytes, left-padded) +//! - value (32 bytes, big-endian U256) + +use std::borrow::Cow; + +use alloy_evm::precompiles::{DynPrecompile, PrecompileInput}; +use alloy_primitives::{Address, U256}; +use revm::precompile::{ + PrecompileError, PrecompileHalt, PrecompileId, PrecompileOutput, PrecompileResult, +}; + +/// Label of the Celo transfer precompile to display in traces. +pub const CELO_TRANSFER_LABEL: &str = "CELO_TRANSFER_PRECOMPILE"; + +/// Address of the Celo transfer precompile. +pub const CELO_TRANSFER_ADDRESS: Address = Address::with_last_byte(0xfd); + +/// ID for the [Celo transfer precompile](CELO_TRANSFER_ADDRESS). +pub static PRECOMPILE_ID_CELO_TRANSFER: PrecompileId = + PrecompileId::Custom(Cow::Borrowed("celo transfer")); + +/// Gas cost for Celo transfer precompile. +const CELO_TRANSFER_GAS_COST: u64 = 9000; + +/// Returns the Celo native transfer. +pub fn precompile() -> DynPrecompile { + DynPrecompile::new_stateful(PRECOMPILE_ID_CELO_TRANSFER.clone(), celo_transfer_precompile) +} + +/// Celo transfer precompile implementation. +/// +/// Uses load_account to modify balances directly, making it compatible with PrecompilesMap. +pub fn celo_transfer_precompile(mut input: PrecompileInput<'_>) -> PrecompileResult { + // Check minimum gas requirement + if input.gas() < CELO_TRANSFER_GAS_COST { + return Ok(PrecompileOutput::halt(PrecompileHalt::OutOfGas, input.reservoir)); + } + + // Validate input length (must be exactly 96 bytes: 32 + 32 + 32) + if input.data.len() != 96 { + return Ok(PrecompileOutput::halt( + PrecompileHalt::Other( + format!( + "Invalid input length for Celo transfer precompile: expected 96 bytes, got {}", + input.data.len() + ) + .into(), + ), + input.reservoir, + )); + } + + // Parse input: from (bytes 12-32), to (bytes 44-64), value (bytes 64-96) + let from_bytes = &input.data[12..32]; + let to_bytes = &input.data[44..64]; + let value_bytes = &input.data[64..96]; + + let from_address = Address::from_slice(from_bytes); + let to_address = Address::from_slice(to_bytes); + let value = U256::from_be_slice(value_bytes); + + // Perform the transfer using load_account to modify balances directly + let internals = input.internals_mut(); + + // Load and check the from account balance first + + let from_account = match internals.load_account(from_address) { + Ok(account) => account, + Err(e) => { + return Ok(PrecompileOutput::halt( + PrecompileHalt::Other(format!("Failed to load sender account: {e:?}").into()), + input.reservoir, + )); + } + }; + + // Check if from account has sufficient balance + if from_account.data.info.balance < value { + return Ok(PrecompileOutput::halt( + PrecompileHalt::Other("Insufficient balance".into()), + input.reservoir, + )); + } + + let to_account = match internals.load_account(to_address) { + Ok(account) => account, + Err(e) => { + return Ok(PrecompileOutput::halt( + PrecompileHalt::Other(format!("Failed to load recipient account: {e:?}").into()), + input.reservoir, + )); + } + }; + + // Check for overflow in to account + if to_account.data.info.balance.checked_add(value).is_none() { + return Ok(PrecompileOutput::halt( + PrecompileHalt::Other("Balance overflow in to account".into()), + input.reservoir, + )); + } + + // Transfer the value between accounts + internals + .transfer(from_address, to_address, value) + .map_err(|e| PrecompileError::Fatal(format!("Failed to perform transfer: {e:?}")))?; + + // No output data for successful transfer + Ok(PrecompileOutput::new( + CELO_TRANSFER_GAS_COST, + alloy_primitives::Bytes::new(), + input.reservoir, + )) +} diff --git a/crates/evm/networks/src/lib.rs b/crates/evm/networks/src/lib.rs new file mode 100644 index 0000000000000..38dbe24124170 --- /dev/null +++ b/crates/evm/networks/src/lib.rs @@ -0,0 +1,2126 @@ +//! # foundry-evm-networks +//! +//! Runtime selection and shared configuration for Foundry's EVM network families. +//! +//! [`NetworkConfigs`] describes the active execution profile selected by configuration, CLI flags, +//! hardforks, or fork endpoint discovery. Cargo features only determine which optional +//! [`NetworkVariant`] values are compiled into a binary; they do not select a network at runtime. +//! +//! Concrete Alloy network and EVM factory types are associated by `FoundryEvmNetwork` in +//! `foundry-evm-core`. See the [custom EVM integration guide] for the cross-crate ownership and +//! state-lifecycle contract. +//! +//! [custom EVM integration guide]: https://github.com/foundry-rs/foundry/blob/master/docs/dev/networks.md + +use crate::celo::transfer::{CELO_TRANSFER_ADDRESS, PRECOMPILE_ID_CELO_TRANSFER}; +use alloy_chains::{ + Chain, NamedChain, + NamedChain::{Chiado, Gnosis, Moonbase, Moonbeam, MoonbeamDev, Moonriver, Rsk, RskTestnet}, +}; +use alloy_eips::{eip1559::BaseFeeParams, eip7840::BlobParams}; +use alloy_evm::precompiles::{DynPrecompile, PrecompilesMap}; +use alloy_primitives::{Address, ChainId, address, map::AddressHashMap}; +use clap::Parser; +use foundry_evm_hardforks::{ + EthereumHardfork, ExecutionSpec, FoundryHardfork, TempoHardfork, latest_active_tempo_hardfork, +}; +use revm::precompile::{ + Precompile as RevmPrecompile, + secp256r1::{P256VERIFY, P256VERIFY_OSAKA}, +}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use tempo_contracts::precompiles::{ + ACCOUNT_KEYCHAIN_ADDRESS, ADDRESS_REGISTRY_ADDRESS, CURRENT_COMMITTEE_ADDRESS, + NONCE_PRECOMPILE_ADDRESS, RECEIVE_POLICY_GUARD_ADDRESS, SIGNATURE_VERIFIER_ADDRESS, + STABLECOIN_DEX_ADDRESS, STORAGE_CREDITS_ADDRESS, TIP_FEE_MANAGER_ADDRESS, + TIP20_CHANNEL_RESERVE_ADDRESS, TIP20_FACTORY_ADDRESS, TIP403_REGISTRY_ADDRESS, + VALIDATOR_CONFIG_ADDRESS, VALIDATOR_CONFIG_V2_ADDRESS, +}; + +#[cfg(feature = "base")] +use base_common_precompiles::{ + ActivationRegistryStorage, B20FactoryStorage, NonceManagerStorage, PolicyRegistryStorage, + TxContextStorage, +}; +#[cfg(feature = "base")] +use foundry_evm_hardforks::BaseUpgrade; + +#[cfg(feature = "optimism")] +use foundry_evm_hardforks::OpHardfork; + +#[cfg(feature = "monad")] +type MonadHardfork = foundry_evm_hardforks::MonadHardfork; + +/// The Monad cheatcode handler address. +pub const MONAD_CHEATCODE_ADDRESS: Address = address!("0xc0FFeeCD43A10e1C2b0De63c6CDCFe5B7d0e0CEA"); + +pub mod arbitrum; +pub mod celo; + +#[cfg(feature = "optimism")] +mod optimism; + +/// Labels of all well-known Tempo precompiles, keyed by address. +pub const TEMPO_PRECOMPILES: &[(&str, Address)] = &[ + ("Nonce", NONCE_PRECOMPILE_ADDRESS), + ("StablecoinDex", STABLECOIN_DEX_ADDRESS), + ("TIP20Factory", TIP20_FACTORY_ADDRESS), + ("TIP403Registry", TIP403_REGISTRY_ADDRESS), + ("FeeManager", TIP_FEE_MANAGER_ADDRESS), + ("ValidatorConfig", VALIDATOR_CONFIG_ADDRESS), + ("ValidatorConfigV2", VALIDATOR_CONFIG_V2_ADDRESS), + ("AccountKeychain", ACCOUNT_KEYCHAIN_ADDRESS), + ("SignatureVerifier", SIGNATURE_VERIFIER_ADDRESS), + ("AddressRegistry", ADDRESS_REGISTRY_ADDRESS), + ("TIP20ChannelReserve", TIP20_CHANNEL_RESERVE_ADDRESS), + ("ReceivePolicyGuard", RECEIVE_POLICY_GUARD_ADDRESS), + ("StorageCredits", STORAGE_CREDITS_ADDRESS), + ("CurrentCommittee", CURRENT_COMMITTEE_ADDRESS), +]; + +#[cfg(feature = "monad")] +const MONAD_PRECOMPILE_LABELS: &[(&str, Address)] = &[ + ("Staking", monad_revm::staking::STAKING_ADDRESS), + ("ReserveBalance", monad_revm::reserve_balance::abi::RESERVE_BALANCE_ADDRESS), +]; + +#[cfg(feature = "monad")] +const MONAD_PRECOMPILES: &[(&str, Address)] = &[ + ("MonadStaking", monad_revm::staking::STAKING_ADDRESS), + ("MonadReserveBalance", monad_revm::reserve_balance::abi::RESERVE_BALANCE_ADDRESS), +]; + +#[cfg(feature = "base")] +const BASE_PRECOMPILES: &[(&str, Address)] = &[ + ("B20Factory", B20FactoryStorage::ADDRESS), + ("ActivationRegistry", ActivationRegistryStorage::ADDRESS), + ("PolicyRegistry", PolicyRegistryStorage::ADDRESS), + ("TxContext", TxContextStorage::ADDRESS), + ("NonceManager", NonceManagerStorage::ADDRESS), +]; + +/// Fixed Base precompiles that expose at least one function returning no data. +/// +/// Solidity guards high-level calls to such functions with an `extcodesize` check, which a +/// code-less account fails in the caller, so these must carry code. Base mainnet plants a one-byte +/// sentinel on exactly these two. The factory, nonce manager, and transaction context return data +/// from every function and are code-less on chain, so stubbing them would diverge — a contract +/// guarding calls with an `isContract` probe would pass locally and revert on Base. +/// +/// The nonce manager separately receives a stub at Zenith from Base's own +/// `ensure_eip8130_system_accounts` transition, for EIP-161 state clearing rather than for +/// `extcodesize`. That transition owns it; this list must not. +#[cfg(feature = "base")] +pub const BASE_CODE_SENTINEL_ADDRESSES: &[Address] = + &[ActivationRegistryStorage::ADDRESS, PolicyRegistryStorage::ADDRESS]; + +/// BSC secp256r1 precompile address introduced by the Haber hardfork. +const BSC_P256_ADDRESS: Address = address!("0000000000000000000000000000000000000100"); + +const BSC_MAINNET_CHAIN_ID: u64 = 56; +const BSC_TESTNET_CHAIN_ID: u64 = 97; +const BSC_MAINNET_HABER_TIMESTAMP: u64 = 1_718_863_500; +const BSC_TESTNET_HABER_TIMESTAMP: u64 = 1_716_962_820; +const BSC_MAINNET_OSAKA_TIMESTAMP: u64 = 1_777_343_400; +const BSC_TESTNET_OSAKA_TIMESTAMP: u64 = 1_774_319_400; + +/// All well-known Tempo precompile addresses. +pub const TEMPO_PRECOMPILE_ADDRESSES: &[Address] = &{ + let mut addresses = [Address::ZERO; TEMPO_PRECOMPILES.len()]; + let mut i = 0; + while i < addresses.len() { + addresses[i] = TEMPO_PRECOMPILES[i].1; + i += 1; + } + addresses +}; + +#[derive( + Clone, + Copy, + Debug, + Default, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Serialize, + Deserialize, + clap::ValueEnum, +)] +#[serde(rename_all = "lowercase")] +#[clap(rename_all = "lowercase")] +pub enum NetworkVariant { + #[default] + Ethereum, + #[cfg(feature = "base")] + Base, + #[cfg(feature = "optimism")] + Optimism, + Tempo, + #[cfg(feature = "monad")] + Monad, +} + +/// Runtime and initcode byte-size limits for a configured network. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct NetworkContractSizeLimits { + /// Maximum deployed runtime bytecode size. + pub runtime: usize, + /// Maximum initcode bytecode size. + pub initcode: usize, +} + +impl std::str::FromStr for NetworkVariant { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "ethereum" => Ok(Self::Ethereum), + #[cfg(feature = "base")] + "base" => Ok(Self::Base), + #[cfg(feature = "optimism")] + "optimism" => Ok(Self::Optimism), + "tempo" => Ok(Self::Tempo), + #[cfg(feature = "monad")] + "monad" => Ok(Self::Monad), + _ => Err(format!("unknown network variant: {s}")), + } + } +} + +impl NetworkVariant { + /// Returns the network family identified by a known chain ID. + /// + /// Unknown chain IDs return `None` so callers can consult endpoint metadata instead of + /// assuming Ethereum. If a known chain belongs to a feature-gated family that is not enabled, + /// this returns an error rather than silently selecting a different EVM. + pub fn from_known_chain_id(chain_id: ChainId) -> Result, String> { + let chain = Chain::from_id(chain_id); + if chain.is_tempo() { + return Ok(Some(Self::Tempo)); + } + if matches!(chain.named(), Some(NamedChain::Celo | NamedChain::CeloSepolia)) { + return Ok(Some(Self::Ethereum)); + } + // Only claim Base when the feature is on. `is_optimism()` below already covers Base chain + // IDs, and that is what shipped binaries resolve them to today, so erroring here would + // regress builds that never asked for Base. Monad errors instead because no shipped EVM + // approximates it. + #[cfg(feature = "base")] + if matches!(chain.named(), Some(NamedChain::Base | NamedChain::BaseSepolia)) { + return Ok(Some(Self::Base)); + } + if matches!(chain.named(), Some(NamedChain::Monad | NamedChain::MonadTestnet)) { + #[cfg(feature = "monad")] + return Ok(Some(Self::Monad)); + #[cfg(not(feature = "monad"))] + return Err("network family `monad` is not enabled in this build".to_string()); + } + if chain.is_optimism() { + #[cfg(feature = "optimism")] + return Ok(Some(Self::Optimism)); + #[cfg(not(feature = "optimism"))] + return Err("network family `optimism` is not enabled in this build".to_string()); + } + Ok(chain.named().map(|_| Self::Ethereum)) + } + + /// Parses an explicit network family reported by `anvil_nodeInfo`. + pub fn from_node_info_name(network: &str) -> Result { + match network { + "ethereum" => Ok(Self::Ethereum), + #[cfg(feature = "base")] + "base" => Ok(Self::Base), + #[cfg(not(feature = "base"))] + "base" => Err("network family `base` is not enabled in this build".to_string()), + #[cfg(feature = "optimism")] + "optimism" => Ok(Self::Optimism), + #[cfg(not(feature = "optimism"))] + "optimism" => Err("network family `optimism` is not enabled in this build".to_string()), + "tempo" => Ok(Self::Tempo), + #[cfg(feature = "monad")] + "monad" => Ok(Self::Monad), + #[cfg(not(feature = "monad"))] + "monad" => Err("network family `monad` is not enabled in this build".to_string()), + network => { + Err(format!("unsupported network family `{network}` reported by fork endpoint")) + } + } + } + + /// Parses a hardfork name reported by an RPC endpoint in this network's namespace. + pub fn parse_hardfork(self, hardfork: &str) -> Result { + format!("{}:{hardfork}", self.name()).parse() + } + + /// Returns this network's hardfork at a historical source-chain timestamp. + /// + /// Unknown source chains return `None`; the selected network owns the lookup so a chain ID + /// shared by multiple execution families cannot select another family's hardfork. + pub fn historical_hardfork(self, chain_id: ChainId, timestamp: u64) -> Option { + match self { + Self::Ethereum => { + EthereumHardfork::from_chain_and_timestamp(Chain::from_id(chain_id), timestamp) + .map(Into::into) + } + Self::Tempo => { + TempoHardfork::from_chain_and_timestamp(chain_id, timestamp).map(Into::into) + } + #[cfg(feature = "optimism")] + Self::Optimism => { + OpHardfork::from_chain_and_timestamp(Chain::from_id(chain_id), timestamp) + .map(Into::into) + } + #[cfg(feature = "monad")] + Self::Monad => { + MonadHardfork::from_chain_and_timestamp(chain_id, timestamp).map(Into::into) + } + #[cfg(feature = "base")] + Self::Base => { + BaseUpgrade::from_chain_and_timestamp(chain_id, timestamp).map(Into::into) + } + } + } + + /// Returns the active hardfork for this network at the given chain and timestamp. + /// + /// Unknown chain IDs fall back to the network's default hardfork. The selected network owns + /// the lookup so an explicit network choice is not overridden by the chain ID's family. + pub fn hardfork_at(self, chain_id: ChainId, timestamp: u64) -> FoundryHardfork { + self.historical_hardfork(chain_id, timestamp).unwrap_or_else(|| match self { + Self::Ethereum => EthereumHardfork::default().into(), + Self::Tempo => latest_active_tempo_hardfork().into(), + #[cfg(feature = "optimism")] + Self::Optimism => OpHardfork::default().into(), + #[cfg(feature = "monad")] + Self::Monad => MonadHardfork::default().into(), + #[cfg(feature = "base")] + Self::Base => BaseUpgrade::default().into(), + }) + } + + /// Returns `true` if this is the Ethereum network variant. + pub const fn is_ethereum(&self) -> bool { + matches!(self, Self::Ethereum) + } + + /// Returns `true` if this is the Base network variant. + #[cfg(feature = "base")] + pub const fn is_base(&self) -> bool { + matches!(self, Self::Base) + } + + /// Returns `true` if this is the Optimism network variant. + pub const fn is_optimism(&self) -> bool { + #[cfg(feature = "optimism")] + { + matches!(self, Self::Optimism) + } + #[cfg(not(feature = "optimism"))] + { + false + } + } + + /// Returns `true` if this is the Tempo network variant. + pub const fn is_tempo(&self) -> bool { + matches!(self, Self::Tempo) + } + + /// Returns `true` if this is the Monad network variant. + #[cfg(feature = "monad")] + pub const fn is_monad(&self) -> bool { + matches!(self, Self::Monad) + } + + /// Returns `false` when Monad support is not compiled in. + #[cfg(not(feature = "monad"))] + pub const fn is_monad(&self) -> bool { + false + } + + /// Returns the network variant name. + pub const fn name(&self) -> &'static str { + match self { + Self::Ethereum => "ethereum", + #[cfg(feature = "base")] + Self::Base => "base", + #[cfg(feature = "optimism")] + Self::Optimism => "optimism", + Self::Tempo => "tempo", + #[cfg(feature = "monad")] + Self::Monad => "monad", + } + } + + /// Returns the hardfork namespace used by this network family. + pub const fn hardfork_namespace(&self) -> Option<&'static str> { + match self { + Self::Ethereum => None, + #[cfg(feature = "base")] + Self::Base => Some("base"), + #[cfg(feature = "optimism")] + Self::Optimism => Some("optimism"), + Self::Tempo => Some("tempo"), + #[cfg(feature = "monad")] + Self::Monad => Some("monad"), + } + } +} + +impl std::fmt::Display for NetworkVariant { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.name()) + } +} + +impl From for NetworkVariant { + fn from(chain_id: ChainId) -> Self { + Self::from_known_chain_id(chain_id).ok().flatten().unwrap_or(Self::Ethereum) + } +} + +#[derive(Clone, Debug, Default, Parser, Deserialize, Copy, PartialEq, Eq, Hash)] +pub struct NetworkConfigs { + /// Enable a specific network family. + #[arg(help_heading = "Networks", long, short, num_args = 1, value_name = "NETWORK", value_enum, conflicts_with_all = ["celo", "tempo"])] + #[cfg_attr(feature = "optimism", arg(conflicts_with = "optimism"))] + #[cfg_attr(feature = "monad", arg(conflicts_with = "monad"))] + #[serde(default)] + pub(crate) network: Option, + /// Enable Celo network features. + #[arg(help_heading = "Networks", long, conflicts_with_all = ["network", "tempo"])] + #[cfg_attr(feature = "optimism", arg(conflicts_with = "optimism"))] + #[cfg_attr(feature = "monad", arg(conflicts_with = "monad"))] + celo: bool, + /// Enable Optimism network features (deprecated: use --network optimism). + #[cfg(feature = "optimism")] + #[arg(long, hide = true, conflicts_with_all = ["network", "celo", "tempo"])] + #[cfg_attr(feature = "monad", arg(conflicts_with = "monad"))] + // Deserialize-only legacy alias: accepted in foundry.toml but never serialized — the + // canonical form is `network = "optimism"`. + #[serde(default)] + pub(crate) optimism: bool, + /// Enable Tempo network features (deprecated: use --network tempo). + #[arg(long, hide = true, conflicts_with_all = ["network", "celo"])] + #[cfg_attr(feature = "optimism", arg(conflicts_with = "optimism"))] + #[cfg_attr(feature = "monad", arg(conflicts_with = "monad"))] + // Deserialize-only legacy alias: accepted in foundry.toml but never serialized — the + // canonical form is `network = "tempo"`. + #[serde(default)] + tempo: bool, + /// Enable Monad network features (deprecated: use --network monad). + #[cfg(feature = "monad")] + #[arg(long, hide = true, conflicts_with_all = ["network", "celo", "tempo"])] + #[cfg_attr(feature = "optimism", arg(conflicts_with = "optimism"))] + // Deserialize-only legacy alias: accepted in foundry.toml but never serialized - the + // canonical form is `network = "monad"`. + #[serde(default)] + monad: bool, + /// Whether to bypass prevrandao. + #[arg(skip)] + #[serde(default)] + bypass_prevrandao: bool, +} + +// Custom `Serialize` impl: always emits the *resolved* network as the canonical +// `network = "..."` field, and never emits legacy network aliases. This avoids contradictory +// canonical and legacy selectors, and ensures old foundry.toml keys round-trip canonically. +impl Serialize for NetworkConfigs { + fn serialize(&self, serializer: S) -> Result { + use serde::ser::SerializeStruct; + let mut s = serializer.serialize_struct("NetworkConfigs", 3)?; + s.serialize_field("network", &self.resolved_network())?; + s.serialize_field("celo", &self.celo)?; + s.serialize_field("bypass_prevrandao", &self.bypass_prevrandao)?; + s.end() + } +} + +impl NetworkConfigs { + /// Validates that all configured network selectors resolve to the same execution profile. + /// + /// Canonical and legacy selectors for the same family remain compatible. Selectors for + /// different families are rejected instead of being silently resolved by field priority. + pub fn validate(&self) -> Result<(), String> { + let mut selectors = Vec::new(); + if let Some(network) = self.network { + selectors.push((network.name(), format!("network = \"{}\"", network.name()))); + } + if self.celo { + selectors.push(("celo", "celo = true".to_string())); + } + #[cfg(feature = "optimism")] + if self.optimism { + selectors.push(("optimism", "optimism = true".to_string())); + } + if self.tempo { + selectors.push(("tempo", "tempo = true".to_string())); + } + #[cfg(feature = "monad")] + if self.monad { + selectors.push(("monad", "monad = true".to_string())); + } + + if let Some((family, selector)) = selectors.first() + && let Some((_, conflicting)) = + selectors.iter().find(|(candidate, _)| candidate != family) + { + return Err(format!( + "network selectors `{selector}` and `{conflicting}` conflict; select only one \ + network" + )); + } + + Ok(()) + } + + pub fn with_ethereum() -> Self { + Self { network: Some(NetworkVariant::Ethereum), ..Default::default() } + } + + pub fn with_celo() -> Self { + Self { celo: true, ..Default::default() } + } + + pub fn with_tempo() -> Self { + Self { network: Some(NetworkVariant::Tempo), tempo: true, ..Default::default() } + } + + #[cfg(feature = "monad")] + pub fn with_monad() -> Self { + Self { network: Some(NetworkVariant::Monad), monad: true, ..Default::default() } + } + + #[cfg(feature = "base")] + pub fn with_base() -> Self { + Self { network: Some(NetworkVariant::Base), ..Default::default() } + } + + pub const fn is_tempo(&self) -> bool { + if let Some(network) = self.resolved_network() { network.is_tempo() } else { false } + } + + /// Returns whether Optimism network features are enabled. + /// + /// Always returns `false` when built without the `optimism` feature. + pub const fn is_optimism(&self) -> bool { + if let Some(network) = self.resolved_network() { network.is_optimism() } else { false } + } + + #[cfg(feature = "monad")] + pub const fn is_monad(&self) -> bool { + if let Some(network) = self.resolved_network() { network.is_monad() } else { false } + } + + #[cfg(not(feature = "monad"))] + pub const fn is_monad(&self) -> bool { + false + } + + #[cfg(feature = "base")] + pub const fn is_base(&self) -> bool { + matches!(self.resolved_network(), Some(NetworkVariant::Base)) + } + + /// Coerces `hardfork` into this network's family. + /// + /// Execution and fee rules apply the same lossy `From` conversions, so a cross-namespace + /// override such as `--hardfork prague` on a Tempo node runs as a Tempo hardfork. Callers that + /// need to describe what actually executed, like trace decoding, go through here rather than + /// carrying the configured value. + pub fn executed_hardfork(&self, hardfork: FoundryHardfork) -> FoundryHardfork { + if self.is_tempo() { + return TempoHardfork::from(hardfork).into(); + } + #[cfg(feature = "monad")] + if self.is_monad() { + return MonadHardfork::from(hardfork).into(); + } + #[cfg(feature = "base")] + if self.is_base() { + return BaseUpgrade::from(hardfork).into(); + } + hardfork + } + + pub const fn is_celo(&self) -> bool { + self.celo + } + + /// Returns the resolved network variant, folding legacy flags. + pub const fn resolved_network(&self) -> Option { + if let Some(n) = self.network { + return Some(n); + } + #[cfg(feature = "optimism")] + if self.optimism { + return Some(NetworkVariant::Optimism); + } + if self.tempo { + return Some(NetworkVariant::Tempo); + } + #[cfg(feature = "monad")] + if self.monad { + return Some(NetworkVariant::Monad); + } + None + } + + /// Returns whether a network family was selected in this configuration. + pub const fn has_network_selection(&self) -> bool { + self.celo || self.resolved_network().is_some() + } + + /// Returns the execution family represented by this configuration. + pub const fn execution_family_name(&self) -> &'static str { + self.execution_network().name() + } + + /// Returns a label for the complete execution configuration. + pub const fn execution_profile_name(&self) -> &'static str { + if self.celo { "celo" } else { self.execution_family_name() } + } + + /// Returns the concrete execution network, treating an unresolved configuration as Ethereum. + pub const fn execution_network(&self) -> NetworkVariant { + if let Some(network) = self.resolved_network() { network } else { NetworkVariant::Ethereum } + } + + /// Returns whether both configurations can use the same instantiated EVM backend. + pub fn has_same_execution_profile(&self, other: &Self) -> bool { + self.celo == other.celo + && match (self.resolved_network(), other.resolved_network()) { + (Some(left), Some(right)) => left == right, + (Some(left), None) => left.is_ethereum(), + (None, Some(right)) => right.is_ethereum(), + (None, None) => true, + } + } + + /// Returns whether this execution configuration can use `source` as a fork state source + /// without rebuilding the instantiated EVM. + /// + /// Monad uses a distinct EVM factory and instruction provider, so forks cannot cross the + /// Monad boundary. Base execution requires a Base source. Optimism execution requires an OP + /// Stack source for its fee accounting. Ethereum and Tempo can use OP Stack state sources + /// without switching engines. + pub const fn supports_fork_source(&self, source: &Self) -> bool { + match self.execution_network() { + #[cfg(feature = "base")] + NetworkVariant::Base => source.is_base(), + #[cfg(feature = "optimism")] + NetworkVariant::Optimism => match source.execution_network() { + NetworkVariant::Optimism => true, + #[cfg(feature = "base")] + NetworkVariant::Base => true, + _ => false, + }, + #[cfg(feature = "monad")] + NetworkVariant::Monad => source.is_monad(), + _ => !source.is_monad(), + } + } + + /// Returns the name of the currently active non-Ethereum network, or `None` for plain Ethereum. + pub fn active_network_name(&self) -> Option<&'static str> { + self.resolved_network().and_then(|network| network.hardfork_namespace()) + } + + /// Returns the base fee parameters for the configured network. + /// + /// For OP Stack networks, returns Canyon parameters if the Canyon hardfork is active at the + /// given timestamp, otherwise returns pre-Canyon parameters. + pub fn base_fee_params(&self, timestamp: u64) -> BaseFeeParams { + #[cfg(feature = "base")] + if self.is_base() { + let canyon_active = + BaseUpgrade::from_chain_and_timestamp(NamedChain::Base as u64, timestamp) + .is_some_and(|upgrade| upgrade >= BaseUpgrade::Canyon); + return if canyon_active { + BaseFeeParams::new(250, 6) + } else { + BaseFeeParams::new(50, 6) + }; + } + #[cfg(feature = "optimism")] + if self.is_optimism() { + return self.op_base_fee_params(timestamp); + } + BaseFeeParams::ethereum() + } + + /// Calculates the blob excess gas inherited by the next block. + /// + /// OP Stack headers use the blob fields for protocol metadata rather than EIP-4844 blobs, so + /// their excess blob gas remains zero. Other execution profiles use the configured Ethereum + /// blob schedule. + pub fn next_block_blob_excess_gas( + &self, + blob_params: BlobParams, + parent_excess_blob_gas: u64, + parent_blob_gas_used: u64, + parent_base_fee: u64, + ) -> u64 { + #[cfg(feature = "base")] + if self.is_base() { + return 0; + } + if self.is_optimism() { + return 0; + } + blob_params.next_block_excess_blob_gas_osaka( + parent_excess_blob_gas, + parent_blob_gas_used, + parent_base_fee, + ) + } + + /// Returns contract size limits for networks that override Ethereum defaults. + #[cfg(feature = "monad")] + pub fn contract_size_limits(&self) -> Option { + self.is_monad().then_some(NetworkContractSizeLimits { + runtime: monad_revm::MONAD_MAX_CODE_SIZE, + initcode: monad_revm::MONAD_MAX_INITCODE_SIZE, + }) + } + + /// Returns contract size limits for networks that override Ethereum defaults. + #[cfg(not(feature = "monad"))] + pub const fn contract_size_limits(&self) -> Option { + None + } + + pub fn bypass_prevrandao(&self, chain_id: u64) -> bool { + if let Ok( + Moonbeam | Moonbase | Moonriver | MoonbeamDev | Rsk | RskTestnet | Gnosis | Chiado, + ) = NamedChain::try_from(chain_id) + { + return true; + } + self.bypass_prevrandao + } + + /// Infers an execution profile from `chain_id` unless one was selected explicitly. + /// + /// Unknown chain IDs preserve the unresolved configuration so endpoint metadata can still + /// identify their execution profile. A known chain whose feature is unavailable is rejected + /// instead of silently falling back to Ethereum semantics. + pub fn try_with_chain_id(self, chain_id: u64) -> Result { + if self.has_network_selection() { + return Ok(self); + } + + match NetworkVariant::from_known_chain_id(chain_id).map_err(|error| { + format!("cannot infer execution network from chain ID {chain_id}: {error}") + })? { + Some(network) => Ok(self.with_rpc_identity(network, chain_id)), + None => Ok(self), + } + } + + /// Best-effort execution-profile inference for trusted, programmatic chain IDs. + /// + /// User-provided chain IDs must be handled with [`Self::try_with_chain_id`] so an unavailable + /// feature cannot silently select Ethereum semantics. + pub fn with_chain_id(self, chain_id: u64) -> Self { + self.try_with_chain_id(chain_id).unwrap_or(self) + } + + /// Applies an RPC endpoint's resolved EVM family to this configuration. + /// + /// Successful endpoint metadata is authoritative, including when a local Anvil uses a + /// well-known chain ID as an execution override. Orthogonal settings are preserved. + pub fn with_rpc_network(self, network: NetworkVariant) -> Self { + let mut resolved = if network.is_ethereum() { Self::default() } else { network.into() }; + resolved.bypass_prevrandao = self.bypass_prevrandao; + resolved + } + + /// Applies an RPC endpoint's execution family and chain-specific Ethereum configuration. + /// + /// The reported family is authoritative. A known Celo chain ID additionally enables Celo's + /// precompiles because Celo shares the Ethereum EVM factory rather than having its own + /// [`NetworkVariant`]. + pub fn with_rpc_identity(self, network: NetworkVariant, chain_id: ChainId) -> Self { + let mut resolved = self.with_rpc_network(network); + if network.is_ethereum() + && matches!( + Chain::from_id(chain_id).named(), + Some(NamedChain::Celo | NamedChain::CeloSepolia) + ) + { + resolved.celo = true; + } + resolved + } + + /// Returns the canonical endpoint-visible execution profile. + pub fn canonical_execution_profile(self) -> Self { + if self.is_celo() { + Self::with_celo() + } else if let Some(network) = self.resolved_network() + && !network.is_ethereum() + { + network.into() + } else { + Self::default() + } + } + + /// Applies an authoritative execution profile while preserving orthogonal settings. + pub fn with_execution_profile(self, profile: Self) -> Self { + let mut resolved = if profile.is_celo() { + Self::with_celo() + } else { + profile.resolved_network().map(Into::into).unwrap_or_default() + }; + resolved.bypass_prevrandao = self.bypass_prevrandao; + resolved + } + + /// Applies an endpoint-reported execution profile while preserving orthogonal settings. + pub fn with_rpc_profile(self, profile: Self) -> Self { + self.with_execution_profile(profile.canonical_execution_profile()) + } + + /// Parses the execution profile reported by `anvil_nodeInfo`. + pub fn from_node_info_profile(profile: &str) -> Result { + if profile == "celo" { + return Ok(Self::with_celo()); + } + let network = NetworkVariant::from_node_info_name(profile)?; + Ok(if network.is_ethereum() { Self::default() } else { network.into() }) + } + + /// Resolves an RPC endpoint's complete execution profile. + /// + /// Explicit metadata is authoritative. When metadata is absent or omits the profile, a + /// caller-supplied explicit profile takes precedence over chain-ID inference. Otherwise, + /// legacy Anvil responses and ordinary RPC endpoints recover Celo from a canonical Celo chain + /// ID and preserve the historical behavior for custom chain IDs. + pub fn from_rpc_identity_profile_with_fallback( + chain_id: ChainId, + node_info_profile: Option>, + unknown_fallback: Option, + ) -> Result, String> { + let known_profile = || { + NetworkVariant::from_known_chain_id(chain_id).map(|network| { + network.map(|network| Self::default().with_rpc_identity(network, chain_id)) + }) + }; + let fallback_is_explicit = + unknown_fallback.is_some_and(|fallback| fallback.has_network_selection()); + let fallback = unknown_fallback.map(Self::canonical_execution_profile); + if let Some(Some(profile)) = node_info_profile { + return Self::from_node_info_profile(profile).map(Some); + } + if fallback_is_explicit && let Some(fallback) = fallback { + return Ok(Some(fallback)); + } + match node_info_profile { + Some(None) => Ok(known_profile()?.or(fallback).or(Some(Self::default()))), + None => Ok(known_profile()?.or(fallback)), + Some(Some(_)) => unreachable!(), + } + } + + /// Validates `hardfork` against the current `NetworkConfigs` and, if consistent, returns an + /// updated instance with the network implied by the enabled hardfork. + /// + /// Returns `Err` when the hardfork's network family conflicts with the configured one. + pub fn normalize_for_hardfork(self, hardfork: FoundryHardfork) -> Result { + if self.has_network_selection() { + let configured = self.execution_network(); + if configured.hardfork_namespace() != hardfork.namespace() { + return Err(format!( + "hardfork `{}` conflicts with network config `{}`", + String::from(hardfork), + self.execution_profile_name(), + )); + } + } + + let network = match hardfork { + FoundryHardfork::Ethereum(_) => self, + FoundryHardfork::Tempo(_) => Self::with_tempo(), + #[cfg(feature = "base")] + FoundryHardfork::Base(_) => Self::with_base(), + #[cfg(feature = "optimism")] + FoundryHardfork::Optimism(_) => Self::with_optimism(), + #[cfg(feature = "monad")] + FoundryHardfork::Monad(_) => Self::with_monad(), + }; + + Ok(network) + } + + /// Inject precompiles for configured networks. + pub fn inject_precompiles(self, precompiles: &mut PrecompilesMap) { + if self.is_celo() { + precompiles.apply_precompile(&CELO_TRANSFER_ADDRESS, move |_| { + Some(celo::transfer::precompile()) + }); + } + } + + /// Returns precompiles for configured networks at the given hardfork. + pub fn precompiles(self, hardfork: Option) -> BTreeMap { + let mut precompiles = BTreeMap::new(); + if self.is_celo() { + precompiles + .insert(PRECOMPILE_ID_CELO_TRANSFER.name().to_string(), CELO_TRANSFER_ADDRESS); + } + if self.is_tempo() { + let tempo_hardfork = hardfork.and_then(TempoHardfork::from_foundry_hardfork); + precompiles.extend( + TEMPO_PRECOMPILES + .iter() + .copied() + .filter(|(_, address)| { + tempo_hardfork.is_none_or(|hardfork| { + is_tempo_precompile_active_at(*address, hardfork) + }) + }) + .map(|(label, address)| (label.to_string(), address)), + ); + } + #[cfg(feature = "monad")] + if self.is_monad() { + let monad_hardfork = hardfork.and_then(MonadHardfork::from_foundry_hardfork); + precompiles.extend( + MONAD_PRECOMPILES + .iter() + .copied() + .filter(|(_, address)| { + monad_hardfork.is_none_or(|hardfork| { + is_monad_precompile_active_at(*address, hardfork) + }) + }) + .map(|(label, address)| (label.to_string(), address)), + ); + } + #[cfg(feature = "base")] + if self.is_base() { + let base_upgrade = hardfork.and_then(|hardfork| match hardfork { + FoundryHardfork::Base(upgrade) => Some(upgrade), + _ => None, + }); + precompiles.extend( + BASE_PRECOMPILES + .iter() + .copied() + .filter(|(_, address)| { + base_upgrade + .is_none_or(|upgrade| is_base_precompile_active_at(*address, upgrade)) + }) + .map(|(label, address)| (label.to_string(), address)), + ); + } + precompiles + } +} + +/// Applies the BSC P256 precompile active at the given timestamp. +pub fn apply_bsc_p256_precompile( + precompiles: &mut PrecompilesMap, + chain_id: ChainId, + timestamp: u64, +) { + let Some(p256verify) = bsc_p256_precompile(chain_id, timestamp) else { return }; + precompiles.apply_precompile(&BSC_P256_ADDRESS, move |_| { + p256verify.map(|p256verify| { + DynPrecompile::new(p256verify.id().clone(), move |input| { + p256verify.execute(input.data, input.gas(), input.reservoir) + }) + }) + }); +} + +impl From for NetworkConfigs { + fn from(network: NetworkVariant) -> Self { + match network { + NetworkVariant::Ethereum => Self::with_ethereum(), + NetworkVariant::Tempo => { + Self { network: Some(network), tempo: true, ..Default::default() } + } + #[cfg(feature = "monad")] + NetworkVariant::Monad => { + Self { network: Some(network), monad: true, ..Default::default() } + } + #[cfg(feature = "base")] + NetworkVariant::Base => Self { network: Some(network), ..Default::default() }, + #[cfg(feature = "optimism")] + NetworkVariant::Optimism => { + Self { network: Some(network), optimism: true, ..Default::default() } + } + } + } +} + +/// Returns the BSC P256 precompile for the given timestamp. The outer option distinguishes BSC +/// chains from unrelated chains, while the inner option disables P256 before Haber. +const fn bsc_p256_precompile(chain_id: ChainId, timestamp: u64) -> Option> { + let (haber_timestamp, osaka_timestamp) = match chain_id { + BSC_MAINNET_CHAIN_ID => (BSC_MAINNET_HABER_TIMESTAMP, BSC_MAINNET_OSAKA_TIMESTAMP), + BSC_TESTNET_CHAIN_ID => (BSC_TESTNET_HABER_TIMESTAMP, BSC_TESTNET_OSAKA_TIMESTAMP), + _ => return None, + }; + + if timestamp < haber_timestamp { + Some(None) + } else if timestamp < osaka_timestamp { + Some(Some(P256VERIFY)) + } else { + Some(Some(P256VERIFY_OSAKA)) + } +} + +/// Returns custom precompile labels for an already resolved execution hardfork. +/// This metadata lookup does not discover or select an execution network. +pub fn resolved_precompile_labels(hardfork: Option) -> AddressHashMap { + match hardfork { + Some(FoundryHardfork::Tempo(hardfork)) => TEMPO_PRECOMPILES + .iter() + .filter(|(_, address)| is_tempo_precompile_active_at(*address, hardfork)) + .map(|(label, address)| (*address, (*label).to_string())) + .collect(), + #[cfg(feature = "monad")] + Some(FoundryHardfork::Monad(hardfork)) => MONAD_PRECOMPILE_LABELS + .iter() + .filter(|(_, address)| is_monad_precompile_active_at(*address, hardfork)) + .map(|(label, address)| (*address, (*label).to_string())) + .collect(), + #[cfg(feature = "base")] + Some(FoundryHardfork::Base(upgrade)) => BASE_PRECOMPILES + .iter() + .filter(|(_, address)| is_base_precompile_active_at(*address, upgrade)) + .map(|(label, address)| (*address, (*label).to_string())) + .collect(), + _ => AddressHashMap::default(), + } +} + +/// Returns whether a well-known Tempo precompile address is active at `hardfork`. +pub fn is_tempo_precompile_active_at(address: Address, hardfork: TempoHardfork) -> bool { + if address == CURRENT_COMMITTEE_ADDRESS { + hardfork.is_t8() + } else if address == TIP20_CHANNEL_RESERVE_ADDRESS { + hardfork.is_t5() + } else if address == RECEIVE_POLICY_GUARD_ADDRESS { + hardfork.is_t6() + } else if address == STORAGE_CREDITS_ADDRESS { + hardfork.is_t7() + } else if address == ADDRESS_REGISTRY_ADDRESS || address == SIGNATURE_VERIFIER_ADDRESS { + hardfork.is_t3() + } else { + true + } +} + +/// Returns the well-known Tempo precompile addresses active at `hardfork`. +pub fn active_tempo_precompile_addresses(hardfork: TempoHardfork) -> impl Iterator { + TEMPO_PRECOMPILE_ADDRESSES + .iter() + .copied() + .filter(move |&address| is_tempo_precompile_active_at(address, hardfork)) +} + +/// Returns whether a well-known Monad precompile address is active at `hardfork`. +#[cfg(feature = "monad")] +pub fn is_monad_precompile_active_at(address: Address, hardfork: MonadHardfork) -> bool { + address == monad_revm::staking::STAKING_ADDRESS + || (address == monad_revm::reserve_balance::abi::RESERVE_BALANCE_ADDRESS + && MonadHardfork::MonadNine.is_enabled_in(hardfork)) +} + +/// Returns whether a fixed Base precompile is active at `upgrade`. +#[cfg(feature = "base")] +pub fn is_base_precompile_active_at(address: Address, upgrade: BaseUpgrade) -> bool { + if matches!(address, TxContextStorage::ADDRESS | NonceManagerStorage::ADDRESS) { + upgrade >= BaseUpgrade::Cobalt + } else if matches!( + address, + B20FactoryStorage::ADDRESS + | ActivationRegistryStorage::ADDRESS + | PolicyRegistryStorage::ADDRESS + ) { + upgrade >= BaseUpgrade::Beryl + } else { + false + } +} + +/// Returns the fixed Base precompiles active at `upgrade`. +#[cfg(feature = "base")] +pub fn active_base_precompiles( + upgrade: BaseUpgrade, +) -> impl Iterator { + BASE_PRECOMPILES + .iter() + .copied() + .filter(move |(_, address)| is_base_precompile_active_at(*address, upgrade)) +} + +#[cfg(test)] +mod tests { + use super::*; + use revm::precompile::{ + Precompiles, + secp256r1::{P256VERIFY_BASE_GAS_FEE, P256VERIFY_BASE_GAS_FEE_OSAKA}, + }; + + // --- Equivalence: new flag == legacy flag --- + + #[test] + fn network_variant_predicates() { + assert!(NetworkVariant::Ethereum.is_ethereum()); + assert!(!NetworkVariant::Ethereum.is_optimism()); + assert!(!NetworkVariant::Ethereum.is_tempo()); + assert!(NetworkVariant::Tempo.is_tempo()); + assert!(!NetworkVariant::Tempo.is_ethereum()); + assert!(!NetworkVariant::Tempo.is_optimism()); + + #[cfg(feature = "monad")] + { + assert!(!NetworkVariant::Ethereum.is_monad()); + assert!(!NetworkVariant::Tempo.is_monad()); + assert!(NetworkVariant::Monad.is_monad()); + assert!(!NetworkVariant::Monad.is_ethereum()); + assert!(!NetworkVariant::Monad.is_optimism()); + assert!(!NetworkVariant::Monad.is_tempo()); + } + + #[cfg(feature = "optimism")] + { + assert!(NetworkVariant::Optimism.is_optimism()); + assert!(!NetworkVariant::Optimism.is_ethereum()); + assert!(!NetworkVariant::Optimism.is_tempo()); + + #[cfg(feature = "monad")] + assert!(!NetworkVariant::Optimism.is_monad()); + } + + #[cfg(feature = "base")] + { + assert!(NetworkVariant::Base.is_base()); + assert!(!NetworkVariant::Base.is_ethereum()); + assert!(!NetworkVariant::Base.is_optimism()); + assert!(!NetworkVariant::Base.is_tempo()); + } + } + + #[test] + #[cfg(all(feature = "base", feature = "optimism"))] + fn historical_hardfork_uses_selected_execution_family() { + let timestamp = 1_710_374_401; + + assert_eq!( + NetworkVariant::Base.historical_hardfork(NamedChain::Base as u64, timestamp), + Some(BaseUpgrade::Ecotone.into()) + ); + assert_eq!( + NetworkVariant::Optimism.historical_hardfork(NamedChain::Base as u64, timestamp), + Some(OpHardfork::Ecotone.into()) + ); + assert_eq!( + NetworkVariant::Ethereum.historical_hardfork(NamedChain::Base as u64, timestamp), + None + ); + assert_eq!( + NetworkVariant::Tempo.historical_hardfork(NamedChain::Base as u64, timestamp), + None + ); + } + + #[test] + #[cfg(feature = "monad")] + fn fork_sources_only_isolate_monad() { + let non_monad = vec![ + NetworkConfigs::default(), + NetworkConfigs::with_ethereum(), + NetworkConfigs::with_celo(), + NetworkConfigs::with_tempo(), + ]; + + for execution in &non_monad { + for source in &non_monad { + assert!(execution.supports_fork_source(source)); + } + assert!(!execution.supports_fork_source(&NetworkConfigs::with_monad())); + assert!(!NetworkConfigs::with_monad().supports_fork_source(execution)); + } + assert!(NetworkConfigs::with_monad().supports_fork_source(&NetworkConfigs::with_monad())); + } + + #[test] + #[cfg(feature = "optimism")] + fn optimism_fork_sources_require_op_stack_state() { + let optimism = NetworkConfigs::with_optimism(); + for source in [ + NetworkConfigs::default(), + NetworkConfigs::with_ethereum(), + NetworkConfigs::with_celo(), + NetworkConfigs::with_tempo(), + ] { + assert!(!optimism.supports_fork_source(&source)); + assert!(source.supports_fork_source(&optimism)); + } + assert!(optimism.supports_fork_source(&optimism)); + #[cfg(feature = "monad")] + assert!(!optimism.supports_fork_source(&NetworkConfigs::with_monad())); + } + + #[test] + #[cfg(feature = "base")] + fn fork_sources_preserve_base_state_source_compatibility() { + #[cfg_attr(not(feature = "optimism"), allow(unused_mut))] + let mut non_base = vec![ + NetworkConfigs::default(), + NetworkConfigs::with_ethereum(), + NetworkConfigs::with_celo(), + NetworkConfigs::with_tempo(), + ]; + #[cfg(feature = "optimism")] + non_base.push(NetworkConfigs::with_optimism()); + for execution in &non_base { + assert!(execution.supports_fork_source(&NetworkConfigs::with_base())); + assert!(!NetworkConfigs::with_base().supports_fork_source(execution)); + } + assert!(NetworkConfigs::with_base().supports_fork_source(&NetworkConfigs::with_base())); + + #[cfg(feature = "monad")] + { + assert!( + !NetworkConfigs::with_monad().supports_fork_source(&NetworkConfigs::with_base()) + ); + assert!( + !NetworkConfigs::with_base().supports_fork_source(&NetworkConfigs::with_monad()) + ); + } + } + + #[test] + fn known_chain_identity_does_not_guess_unknown_networks() { + assert_eq!(NetworkVariant::from_known_chain_id(98_765_432).unwrap(), None); + assert_eq!( + NetworkVariant::from_known_chain_id(NamedChain::Mainnet as u64).unwrap(), + Some(NetworkVariant::Ethereum) + ); + assert_eq!(NetworkVariant::from(98_765_432), NetworkVariant::Ethereum); + } + + #[test] + fn fallible_chain_id_inference_preserves_unknown_chains() { + let networks = NetworkConfigs { bypass_prevrandao: true, ..Default::default() }; + assert_eq!(networks.try_with_chain_id(98_765_432).unwrap(), networks); + } + + #[test] + #[cfg(not(feature = "monad"))] + fn fallible_chain_id_inference_rejects_disabled_monad() { + for chain_id in [NamedChain::Monad as u64, NamedChain::MonadTestnet as u64] { + let unavailable = "network family `monad` is not enabled in this build"; + let expected = + format!("cannot infer execution network from chain ID {chain_id}: {unavailable}"); + assert_eq!( + NetworkConfigs::default().try_with_chain_id(chain_id).unwrap_err(), + expected + ); + } + } + + #[test] + #[cfg(not(feature = "optimism"))] + fn fallible_chain_id_inference_rejects_disabled_optimism() { + let chain_id = NamedChain::Optimism as u64; + assert_eq!( + NetworkConfigs::default().try_with_chain_id(chain_id).unwrap_err(), + format!( + "cannot infer execution network from chain ID {chain_id}: network family \ + `optimism` is not enabled in this build" + ) + ); + } + + #[test] + fn explicit_ethereum_overrides_known_chain_inference() { + let ethereum = NetworkConfigs::with_ethereum(); + for chain_id in [NamedChain::Monad as u64, NamedChain::Optimism as u64] { + assert_eq!(ethereum.try_with_chain_id(chain_id).unwrap(), ethereum); + } + } + + #[test] + fn fallible_chain_id_inference_preserves_orthogonal_configuration() { + let networks = NetworkConfigs { bypass_prevrandao: true, ..Default::default() }; + let inferred = networks.try_with_chain_id(NamedChain::Tempo as u64).unwrap(); + + assert!(inferred.is_tempo()); + assert!(inferred.bypass_prevrandao(NamedChain::Mainnet as u64)); + } + + #[test] + #[cfg(feature = "optimism")] + fn fallible_chain_id_inference_detects_optimism() { + let chain_id = NamedChain::Optimism as u64; + assert_eq!(NetworkVariant::from(chain_id), NetworkVariant::Optimism); + assert!(NetworkConfigs::default().try_with_chain_id(chain_id).unwrap().is_optimism()); + } + + #[test] + fn rpc_identity_preserves_orthogonal_configuration() { + let base = NetworkConfigs { bypass_prevrandao: true, ..NetworkConfigs::default() }; + let ethereum = base.with_rpc_network(NetworkVariant::Ethereum); + assert!(ethereum.bypass_prevrandao(NamedChain::Mainnet as u64)); + + let ethereum = NetworkConfigs::default().with_rpc_network(NetworkVariant::Ethereum); + assert_eq!(ethereum, NetworkConfigs::default()); + + assert!( + NetworkConfigs::default() + .with_rpc_identity(NetworkVariant::Ethereum, NamedChain::Celo as u64) + .is_celo() + ); + assert!( + !NetworkConfigs::default() + .with_rpc_identity(NetworkVariant::Ethereum, NamedChain::Monad as u64) + .is_monad() + ); + } + + #[test] + fn rpc_metadata_overrides_known_execution_chain_identity() { + assert_eq!( + NetworkConfigs::from_rpc_identity_profile_with_fallback( + NamedChain::Mainnet as u64, + Some(Some("tempo")), + None, + ) + .unwrap(), + Some(NetworkConfigs::with_tempo()) + ); + assert_eq!( + NetworkConfigs::from_rpc_identity_profile_with_fallback( + NamedChain::Tempo as u64, + Some(None), + None, + ) + .unwrap(), + Some(NetworkConfigs::with_tempo()) + ); + assert_eq!( + NetworkConfigs::from_rpc_identity_profile_with_fallback(98_765_432, None, None) + .unwrap(), + None + ); + assert_eq!( + NetworkConfigs::from_rpc_identity_profile_with_fallback(98_765_432, Some(None), None) + .unwrap(), + Some(NetworkConfigs::default()) + ); + #[cfg(feature = "optimism")] + assert_eq!( + NetworkConfigs::from_rpc_identity_profile_with_fallback( + NamedChain::Optimism as u64, + Some(None), + None, + ) + .unwrap(), + Some(NetworkConfigs::from(NetworkVariant::Optimism)) + ); + } + + #[test] + fn rpc_profile_distinguishes_celo_from_ethereum_factory() { + let custom_chain_id = 98_765_432; + assert!( + NetworkConfigs::from_rpc_identity_profile_with_fallback( + custom_chain_id, + Some(Some("celo")), + None, + ) + .unwrap() + .unwrap() + .is_celo() + ); + assert!( + !NetworkConfigs::from_rpc_identity_profile_with_fallback( + NamedChain::Celo as u64, + Some(Some("ethereum")), + None, + ) + .unwrap() + .unwrap() + .is_celo() + ); + assert!( + NetworkConfigs::from_rpc_identity_profile_with_fallback( + NamedChain::Celo as u64, + Some(None), + None, + ) + .unwrap() + .unwrap() + .is_celo() + ); + assert!( + NetworkConfigs::from_rpc_identity_profile_with_fallback( + custom_chain_id, + None, + Some(NetworkConfigs::with_celo()), + ) + .unwrap() + .unwrap() + .is_celo() + ); + } + + #[test] + fn rpc_profile_keeps_moonbeam_on_default_ethereum() { + let profile = NetworkConfigs::from_rpc_identity_profile_with_fallback( + NamedChain::Moonbeam as u64, + None, + None, + ) + .unwrap() + .unwrap(); + + assert_eq!(profile, NetworkConfigs::default()); + assert!(profile.bypass_prevrandao(NamedChain::Moonbeam as u64)); + } + + #[test] + fn default_fallback_still_infers_known_execution_profile() { + for node_info in [None, Some(None)] { + assert_eq!( + NetworkConfigs::from_rpc_identity_profile_with_fallback( + NamedChain::Tempo as u64, + node_info, + Some(NetworkConfigs::default()), + ) + .unwrap(), + Some(NetworkConfigs::with_tempo()) + ); + } + } + + #[test] + fn explicit_ethereum_overrides_known_execution_profile() { + for node_info in [None, Some(None)] { + assert_eq!( + NetworkConfigs::from_rpc_identity_profile_with_fallback( + NamedChain::Tempo as u64, + node_info, + Some(NetworkConfigs::with_ethereum()), + ) + .unwrap(), + Some(NetworkConfigs::default()) + ); + } + } + + #[test] + #[cfg(not(feature = "monad"))] + fn explicit_ethereum_overrides_disabled_monad_without_node_info() { + assert_eq!( + NetworkConfigs::from_rpc_identity_profile_with_fallback( + NamedChain::Monad as u64, + None, + Some(NetworkConfigs::with_ethereum()), + ) + .unwrap(), + Some(NetworkConfigs::default()) + ); + } + + #[test] + #[cfg(not(feature = "monad"))] + fn explicit_ethereum_overrides_disabled_monad_with_legacy_node_info() { + assert_eq!( + NetworkConfigs::from_rpc_identity_profile_with_fallback( + NamedChain::Monad as u64, + Some(None), + Some(NetworkConfigs::with_ethereum()), + ) + .unwrap(), + Some(NetworkConfigs::default()) + ); + } + + #[test] + #[cfg(not(feature = "monad"))] + fn disabled_monad_without_explicit_profile_still_errors() { + for node_info in [None, Some(None)] { + assert_eq!( + NetworkConfigs::from_rpc_identity_profile_with_fallback( + NamedChain::Monad as u64, + node_info, + None, + ) + .unwrap_err(), + "network family `monad` is not enabled in this build" + ); + } + } + + #[test] + fn network_selection_distinguishes_default_and_explicit_ethereum() { + assert!(!NetworkConfigs::default().has_network_selection()); + assert!(NetworkConfigs::with_ethereum().has_network_selection()); + assert!(NetworkConfigs::with_celo().has_network_selection()); + } + + #[test] + fn celo_uses_ethereum_factory_with_distinct_precompiles() { + assert!( + !NetworkConfigs::with_celo().has_same_execution_profile(&NetworkConfigs::default()) + ); + assert!( + NetworkConfigs::with_ethereum().has_same_execution_profile(&NetworkConfigs::default()) + ); + assert_eq!(NetworkConfigs::with_celo().execution_family_name(), "ethereum"); + assert_eq!(NetworkConfigs::with_celo().execution_profile_name(), "celo"); + } + + #[test] + fn authoritative_execution_profile_preserves_orthogonal_settings() { + let inline = NetworkConfigs { bypass_prevrandao: true, ..NetworkConfigs::with_tempo() }; + + #[cfg_attr(not(any(feature = "optimism", feature = "monad")), allow(unused_mut))] + let mut profiles = vec![ + NetworkConfigs::with_ethereum(), + NetworkConfigs::with_tempo(), + NetworkConfigs::with_celo(), + ]; + #[cfg(feature = "optimism")] + profiles.push(NetworkVariant::Optimism.into()); + #[cfg(feature = "monad")] + profiles.push(NetworkConfigs::with_monad()); + + for profile in profiles { + let resolved = inline.with_execution_profile(profile); + assert!(resolved.has_same_execution_profile(&profile)); + assert!(resolved.has_network_selection()); + assert!(resolved.bypass_prevrandao(NamedChain::Mainnet as u64)); + } + + let ethereum = inline.with_execution_profile(NetworkConfigs::with_ethereum()); + assert_eq!( + ethereum.try_with_chain_id(NamedChain::Tempo as u64).unwrap(), + ethereum, + "an authoritative Ethereum profile must prevent later endpoint inference", + ); + + let rpc_ethereum = inline.with_rpc_profile(NetworkConfigs::with_ethereum()); + assert!(!rpc_ethereum.has_network_selection()); + } + + #[test] + fn chain_id_inference_preserves_explicit_networks() { + assert!( + NetworkConfigs::default().try_with_chain_id(NamedChain::Celo as u64).unwrap().is_celo() + ); + let celo = NetworkConfigs { bypass_prevrandao: true, ..Default::default() } + .try_with_chain_id(NamedChain::Celo as u64) + .unwrap(); + assert!(celo.is_celo()); + assert!(celo.bypass_prevrandao(NamedChain::Mainnet as u64)); + + let explicit = [ + NetworkConfigs::with_ethereum(), + NetworkConfigs::with_tempo(), + NetworkConfigs::with_celo(), + ]; + for networks in explicit { + assert_eq!(networks.try_with_chain_id(NamedChain::Celo as u64).unwrap(), networks); + } + + #[cfg(feature = "monad")] + { + let monad = NetworkConfigs::with_monad(); + assert_eq!(monad.try_with_chain_id(NamedChain::Celo as u64).unwrap(), monad); + } + } + + #[test] + #[cfg(feature = "monad")] + fn rpc_metadata_identifies_custom_monad_networks() { + assert_eq!(NetworkVariant::from_node_info_name("monad").unwrap(), NetworkVariant::Monad); + assert!(NetworkConfigs::default().with_rpc_network(NetworkVariant::Monad).is_monad()); + } + + #[test] + #[cfg(feature = "monad")] + fn parses_endpoint_hardfork_in_network_namespace() { + assert_eq!( + NetworkVariant::Monad.parse_hardfork("MonadEight").unwrap(), + FoundryHardfork::Monad(MonadHardfork::MonadEight) + ); + } + + #[test] + fn rpc_metadata_rejects_unknown_network_family() { + assert_eq!( + NetworkVariant::from_node_info_name("unknown").unwrap_err(), + "unsupported network family `unknown` reported by fork endpoint" + ); + } + + #[test] + fn explicit_ethereum_families_reject_namespaced_hardforks() { + #[cfg_attr(not(feature = "monad"), allow(unused_mut))] + let mut incompatible = vec![FoundryHardfork::Tempo(TempoHardfork::T0)]; + #[cfg(feature = "monad")] + incompatible.push(FoundryHardfork::Monad(MonadHardfork::MonadEight)); + + for networks in [NetworkConfigs::with_ethereum(), NetworkConfigs::with_celo()] { + for hardfork in &incompatible { + assert_eq!( + networks.normalize_for_hardfork(*hardfork).unwrap_err(), + format!( + "hardfork `{}` conflicts with network config `{}`", + String::from(*hardfork), + networks.execution_profile_name() + ) + ); + } + } + + let celo = NetworkConfigs::with_celo(); + assert_eq!( + celo.normalize_for_hardfork(FoundryHardfork::Ethereum( + foundry_evm_hardforks::EthereumHardfork::Prague + )) + .unwrap(), + celo + ); + } + + #[cfg(feature = "base")] + #[test] + fn base_precompile_labels_follow_upgrade_boundaries() { + let config = NetworkConfigs::with_base(); + + assert!(resolved_precompile_labels(Some(BaseUpgrade::Azul.into())).is_empty()); + + let beryl = resolved_precompile_labels(Some(BaseUpgrade::Beryl.into())); + assert_eq!(beryl.get(&B20FactoryStorage::ADDRESS), Some(&"B20Factory".to_string())); + assert_eq!( + beryl.get(&ActivationRegistryStorage::ADDRESS), + Some(&"ActivationRegistry".to_string()) + ); + assert_eq!(beryl.get(&PolicyRegistryStorage::ADDRESS), Some(&"PolicyRegistry".to_string())); + assert!(!beryl.contains_key(&TxContextStorage::ADDRESS)); + assert!(!beryl.contains_key(&NonceManagerStorage::ADDRESS)); + + let cobalt = resolved_precompile_labels(Some(BaseUpgrade::Cobalt.into())); + assert_eq!(cobalt.len(), BASE_PRECOMPILES.len()); + + // The name-keyed precompile map must honor the same upgrade boundaries. + assert!(config.precompiles(Some(BaseUpgrade::Azul.into())).is_empty()); + let beryl = config.precompiles(Some(BaseUpgrade::Beryl.into())); + assert_eq!(beryl.get("B20Factory"), Some(&B20FactoryStorage::ADDRESS)); + assert!(!beryl.contains_key("NonceManager")); + assert_eq!( + config.precompiles(Some(BaseUpgrade::Cobalt.into())).get("NonceManager"), + Some(&NonceManagerStorage::ADDRESS) + ); + } + + #[test] + fn new_tempo_flag_equivalent_to_legacy() { + let via_new = NetworkConfigs { network: Some(NetworkVariant::Tempo), ..Default::default() }; + let via_old = NetworkConfigs { tempo: true, ..Default::default() }; + assert_eq!(via_new.is_tempo(), via_old.is_tempo()); + assert_eq!(via_new.active_network_name(), via_old.active_network_name()); + assert_eq!(via_new.precompiles(None), via_old.precompiles(None)); + } + + fn bsc_p256_gas_used(chain_id: ChainId, timestamp: u64) -> Option { + bsc_p256_precompile(chain_id, timestamp) + .flatten() + .map(|precompile| precompile.execute(&[], u64::MAX, 0).unwrap().gas_used) + } + + fn assert_bsc_p256_boundaries(chain_id: ChainId, haber_timestamp: u64, osaka_timestamp: u64) { + assert!(matches!(bsc_p256_precompile(chain_id, haber_timestamp - 1), Some(None))); + assert_eq!(bsc_p256_gas_used(chain_id, haber_timestamp), Some(P256VERIFY_BASE_GAS_FEE)); + assert_eq!(bsc_p256_gas_used(chain_id, osaka_timestamp - 1), Some(P256VERIFY_BASE_GAS_FEE)); + assert_eq!( + bsc_p256_gas_used(chain_id, osaka_timestamp), + Some(P256VERIFY_BASE_GAS_FEE_OSAKA) + ); + } + + #[test] + fn selects_bsc_p256_at_mainnet_boundaries() { + assert_bsc_p256_boundaries( + BSC_MAINNET_CHAIN_ID, + BSC_MAINNET_HABER_TIMESTAMP, + BSC_MAINNET_OSAKA_TIMESTAMP, + ); + } + + #[test] + fn selects_bsc_p256_at_testnet_boundaries() { + assert_bsc_p256_boundaries( + BSC_TESTNET_CHAIN_ID, + BSC_TESTNET_HABER_TIMESTAMP, + BSC_TESTNET_OSAKA_TIMESTAMP, + ); + } + + #[test] + fn removes_bsc_p256_before_haber() { + let mut precompiles = PrecompilesMap::from_static(Precompiles::osaka()); + assert!(precompiles.get(&BSC_P256_ADDRESS).is_some()); + apply_bsc_p256_precompile( + &mut precompiles, + BSC_MAINNET_CHAIN_ID, + BSC_MAINNET_HABER_TIMESTAMP - 1, + ); + assert!(precompiles.get(&BSC_P256_ADDRESS).is_none()); + } + + #[test] + fn canonical_tempo_network_reports_precompiles() { + let cfg = NetworkConfigs { network: Some(NetworkVariant::Tempo), ..Default::default() }; + + assert_eq!( + cfg.precompiles(None).get("TIP20ChannelReserve"), + Some(&TIP20_CHANNEL_RESERVE_ADDRESS) + ); + assert!( + !cfg.precompiles(Some(TempoHardfork::T4.into())).contains_key("TIP20ChannelReserve") + ); + assert!( + !cfg.precompiles(Some(TempoHardfork::T4.into())).contains_key("ReceivePolicyGuard") + ); + assert!(!cfg.precompiles(Some(TempoHardfork::T2.into())).contains_key("AddressRegistry")); + assert!(!cfg.precompiles(Some(TempoHardfork::T2.into())).contains_key("SignatureVerifier")); + assert_eq!( + cfg.precompiles(Some(TempoHardfork::T3.into())).get("AddressRegistry"), + Some(&ADDRESS_REGISTRY_ADDRESS) + ); + assert_eq!( + cfg.precompiles(Some(TempoHardfork::T3.into())).get("SignatureVerifier"), + Some(&SIGNATURE_VERIFIER_ADDRESS) + ); + assert_eq!( + resolved_precompile_labels(Some(TempoHardfork::T5.into())) + .get(&TIP20_CHANNEL_RESERVE_ADDRESS), + Some(&"TIP20ChannelReserve".to_string()) + ); + assert!( + !resolved_precompile_labels(Some(TempoHardfork::T5.into())) + .contains_key(&RECEIVE_POLICY_GUARD_ADDRESS) + ); + assert!( + resolved_precompile_labels(Some(TempoHardfork::T6.into())) + .contains_key(&RECEIVE_POLICY_GUARD_ADDRESS) + ); + } + + #[test] + #[cfg(feature = "monad")] + fn canonical_monad_network_reports_hardfork_gated_precompiles() { + let cfg = NetworkConfigs { network: Some(NetworkVariant::Monad), ..Default::default() }; + + assert_eq!( + cfg.precompiles(Some(MonadHardfork::MonadEight.into())).get("MonadStaking"), + Some(&monad_revm::staking::STAKING_ADDRESS) + ); + assert!( + !cfg.precompiles(Some(MonadHardfork::MonadEight.into())) + .contains_key("MonadReserveBalance") + ); + assert_eq!( + cfg.precompiles(Some(MonadHardfork::MonadNine.into())).get("MonadReserveBalance"), + Some(&monad_revm::reserve_balance::abi::RESERVE_BALANCE_ADDRESS) + ); + assert_eq!( + resolved_precompile_labels(Some(MonadHardfork::MonadNine.into())) + .get(&monad_revm::reserve_balance::abi::RESERVE_BALANCE_ADDRESS), + Some(&"ReserveBalance".to_string()) + ); + assert!( + !resolved_precompile_labels(Some(MonadHardfork::MonadEight.into())) + .contains_key(&monad_revm::reserve_balance::abi::RESERVE_BALANCE_ADDRESS) + ); + } + + #[test] + fn storage_credits_precompile_activates_at_t7() { + assert!(!is_tempo_precompile_active_at(STORAGE_CREDITS_ADDRESS, TempoHardfork::T6)); + assert!(is_tempo_precompile_active_at(STORAGE_CREDITS_ADDRESS, TempoHardfork::T7)); + assert!(TEMPO_PRECOMPILE_ADDRESSES.contains(&STORAGE_CREDITS_ADDRESS)); + + // The hardfork-filtered precompile map must honor the same T7 activation. + let cfg = NetworkConfigs { network: Some(NetworkVariant::Tempo), ..Default::default() }; + assert!(!cfg.precompiles(Some(TempoHardfork::T6.into())).contains_key("StorageCredits")); + assert!(cfg.precompiles(Some(TempoHardfork::T7.into())).contains_key("StorageCredits")); + } + + #[test] + fn current_committee_precompile_activates_at_t8() { + assert!(!is_tempo_precompile_active_at(CURRENT_COMMITTEE_ADDRESS, TempoHardfork::T7)); + assert!(is_tempo_precompile_active_at(CURRENT_COMMITTEE_ADDRESS, TempoHardfork::T8)); + assert!(TEMPO_PRECOMPILE_ADDRESSES.contains(&CURRENT_COMMITTEE_ADDRESS)); + + let cfg = NetworkConfigs { network: Some(NetworkVariant::Tempo), ..Default::default() }; + assert!(!cfg.precompiles(Some(TempoHardfork::T7.into())).contains_key("CurrentCommittee")); + assert!(cfg.precompiles(Some(TempoHardfork::T8.into())).contains_key("CurrentCommittee")); + } + + // --- resolved() / active_network_name --- + + #[test] + fn active_network_name_tempo() { + let cfg = NetworkConfigs::with_tempo(); + assert_eq!(cfg.active_network_name(), Some("tempo")); + } + + #[test] + #[cfg(feature = "monad")] + fn active_network_name_monad() { + let cfg = NetworkConfigs::with_monad(); + assert_eq!(cfg.active_network_name(), Some("monad")); + assert!(cfg.is_monad()); + } + + #[test] + #[cfg(feature = "monad")] + fn contract_size_limits_monad() { + let limits = NetworkConfigs::with_monad().contract_size_limits().unwrap(); + assert_eq!(limits.runtime, monad_revm::MONAD_MAX_CODE_SIZE); + assert_eq!(limits.initcode, monad_revm::MONAD_MAX_INITCODE_SIZE); + assert!(NetworkConfigs::default().contract_size_limits().is_none()); + } + + #[test] + fn active_network_name_default_is_none() { + let cfg = NetworkConfigs::default(); + assert_eq!(cfg.active_network_name(), None); + assert!(!cfg.is_optimism()); + } + + /// Base chain IDs resolved to Optimism before Base support existed, and `is_optimism()` still + /// covers them, so a build without the `base` feature must keep resolving them rather than + /// erroring. Shipped release binaries are exactly that build. + #[test] + #[cfg(all(not(feature = "base"), feature = "optimism"))] + fn chain_id_inference_falls_back_to_optimism_without_base() { + for chain_id in [NamedChain::Base as u64, NamedChain::BaseSepolia as u64] { + let configs = NetworkConfigs::default() + .try_with_chain_id(chain_id) + .unwrap_or_else(|error| panic!("chain ID {chain_id} must still resolve: {error}")); + assert!(configs.is_optimism(), "chain ID {chain_id} must resolve to Optimism"); + } + } + + #[test] + #[cfg(not(feature = "base"))] + fn node_info_rejects_disabled_base() { + assert_eq!( + NetworkVariant::from_node_info_name("base").unwrap_err(), + "network family `base` is not enabled in this build" + ); + } + + // --- Serde round-trip --- + + #[test] + fn serde_roundtrip_tempo() { + let original = NetworkConfigs::with_tempo(); + let json = serde_json::to_string(&original).unwrap(); + let restored: NetworkConfigs = serde_json::from_str(&json).unwrap(); + assert!(restored.is_tempo()); + } + + #[test] + fn serde_legacy_tempo_bool_deserialized() { + // Old foundry.toml format: `tempo = true` + let json = r#"{"tempo": true, "celo": false, "bypass_prevrandao": false}"#; + let cfg: NetworkConfigs = serde_json::from_str(json).unwrap(); + assert!(cfg.is_tempo()); + } + + #[test] + #[cfg(feature = "monad")] + fn serde_roundtrip_monad() { + let original = NetworkConfigs::with_monad(); + let json = serde_json::to_string(&original).unwrap(); + let restored: NetworkConfigs = serde_json::from_str(&json).unwrap(); + assert!(restored.is_monad()); + assert!(!restored.is_tempo()); + } + + #[test] + #[cfg(feature = "monad")] + fn serde_legacy_monad_bool_deserialized() { + let json = r#"{"monad": true, "celo": false, "bypass_prevrandao": false}"#; + let cfg: NetworkConfigs = serde_json::from_str(json).unwrap(); + assert!(cfg.is_monad()); + } + + #[test] + fn serde_serializes_legacy_alias_as_canonical_network() { + #[cfg_attr(not(feature = "monad"), allow(unused_mut))] + let mut cases = vec![(NetworkConfigs { tempo: true, ..Default::default() }, "tempo")]; + #[cfg(feature = "monad")] + cases.push((NetworkConfigs { monad: true, ..Default::default() }, "monad")); + + for (cfg, expected) in cases { + let json = serde_json::to_value(cfg).unwrap(); + assert_eq!(json["network"], serde_json::json!(expected)); + assert!(json.get("tempo").is_none(), "legacy `tempo` key should not be serialized"); + assert!(json.get("monad").is_none(), "legacy `monad` key should not be serialized"); + } + } + + #[test] + fn serde_new_network_field_deserialized() { + let json_tempo = r#"{"network": "tempo", "celo": false, "bypass_prevrandao": false}"#; + let cfg_tempo: NetworkConfigs = serde_json::from_str(json_tempo).unwrap(); + assert!(cfg_tempo.is_tempo()); + + #[cfg(feature = "monad")] + { + let json_monad = r#"{"network": "monad", "celo": false, "bypass_prevrandao": false}"#; + let cfg_monad: NetworkConfigs = serde_json::from_str(json_monad).unwrap(); + assert!(cfg_monad.is_monad()); + } + } + + #[test] + fn validates_flattened_network_selectors() { + let valid = [ + NetworkConfigs::default(), + NetworkConfigs::with_ethereum(), + NetworkConfigs::with_celo(), + NetworkConfigs::with_tempo(), + NetworkConfigs { + network: Some(NetworkVariant::Tempo), + tempo: true, + ..Default::default() + }, + ]; + for networks in valid { + networks.validate().unwrap(); + } + + let conflicts = [ + ( + NetworkConfigs { + network: Some(NetworkVariant::Tempo), + celo: true, + ..Default::default() + }, + "network selectors `network = \"tempo\"` and `celo = true` conflict", + ), + ( + NetworkConfigs { celo: true, tempo: true, ..Default::default() }, + "network selectors `celo = true` and `tempo = true` conflict", + ), + ]; + for (networks, expected) in conflicts { + assert!(networks.validate().unwrap_err().contains(expected)); + } + } + + #[test] + #[cfg(feature = "monad")] + fn validates_flattened_monad_network_selectors() { + for networks in [ + NetworkConfigs::with_monad(), + NetworkConfigs { + network: Some(NetworkVariant::Monad), + monad: true, + ..Default::default() + }, + ] { + networks.validate().unwrap(); + } + + let conflicts = [ + ( + NetworkConfigs { + network: Some(NetworkVariant::Monad), + celo: true, + ..Default::default() + }, + "network selectors `network = \"monad\"` and `celo = true` conflict", + ), + ( + NetworkConfigs { + network: Some(NetworkVariant::Monad), + tempo: true, + ..Default::default() + }, + "network selectors `network = \"monad\"` and `tempo = true` conflict", + ), + ( + NetworkConfigs { celo: true, monad: true, ..Default::default() }, + "network selectors `celo = true` and `monad = true` conflict", + ), + ( + NetworkConfigs { tempo: true, monad: true, ..Default::default() }, + "network selectors `tempo = true` and `monad = true` conflict", + ), + ]; + for (networks, expected) in conflicts { + assert!(networks.validate().unwrap_err().contains(expected)); + } + } + + #[test] + #[cfg(feature = "monad")] + fn chain_id_detects_monad_network() { + assert_eq!(NetworkVariant::from(143), NetworkVariant::Monad); + assert_eq!(NetworkVariant::from(10143), NetworkVariant::Monad); + + assert!(NetworkConfigs::default().try_with_chain_id(143).unwrap().is_monad()); + } + + #[cfg(feature = "base")] + mod base { + use super::*; + + #[test] + fn active_network_name_base() { + let cfg = NetworkConfigs::with_base(); + assert_eq!(cfg.active_network_name(), Some("base")); + } + + #[test] + fn base_fee_params_follow_canyon_boundary() { + const CANYON_TIMESTAMP: u64 = 1_704_992_401; + + let config = NetworkConfigs::with_base(); + assert_eq!(config.base_fee_params(CANYON_TIMESTAMP - 1), BaseFeeParams::new(50, 6)); + assert_eq!(config.base_fee_params(CANYON_TIMESTAMP), BaseFeeParams::new(250, 6)); + } + + #[test] + fn base_does_not_inherit_ethereum_blob_excess_gas() { + let params = BlobParams::prague(); + assert_eq!( + NetworkConfigs::with_base().next_block_blob_excess_gas( + params, + 0, + params.target_blob_gas_per_block() + 1, + 1, + ), + 0 + ); + } + + #[test] + fn serde_roundtrip_base() { + let original = NetworkConfigs::with_base(); + let json = serde_json::to_string(&original).unwrap(); + let restored: NetworkConfigs = serde_json::from_str(&json).unwrap(); + assert!(restored.is_base()); + assert!(!restored.is_tempo()); + } + + #[test] + fn serde_base_field_deserialized() { + let json_base = r#"{"network": "base", "celo": false, "bypass_prevrandao": false}"#; + let cfg_base: NetworkConfigs = serde_json::from_str(json_base).unwrap(); + assert!(cfg_base.is_base()); + } + + #[test] + fn chain_id_detects_base_networks() { + assert_eq!(NetworkVariant::from(8453), NetworkVariant::Base); + assert_eq!(NetworkVariant::from(84532), NetworkVariant::Base); + assert!(NetworkConfigs::default().try_with_chain_id(8453).unwrap().is_base()); + assert_eq!(NetworkVariant::from_node_info_name("base").unwrap(), NetworkVariant::Base); + } + + #[test] + fn hardfork_infers_base_network() { + assert_eq!( + NetworkConfigs::default() + .normalize_for_hardfork(FoundryHardfork::Base(BaseUpgrade::Beryl)) + .unwrap() + .resolved_network(), + Some(NetworkVariant::Base) + ); + } + } + + #[cfg(feature = "optimism")] + mod optimism { + use super::*; + + #[test] + fn new_optimism_flag_equivalent_to_legacy() { + let via_new = + NetworkConfigs { network: Some(NetworkVariant::Optimism), ..Default::default() }; + let via_old = NetworkConfigs { optimism: true, ..Default::default() }; + assert_eq!(via_new.is_optimism(), via_old.is_optimism()); + assert_eq!(via_new.is_tempo(), via_old.is_tempo()); + assert_eq!(via_new.active_network_name(), via_old.active_network_name()); + } + + #[test] + fn matching_optimism_selectors_are_valid() { + NetworkConfigs::with_optimism().validate().unwrap(); + } + + #[test] + fn active_network_name_optimism() { + let cfg = NetworkConfigs::with_optimism(); + assert_eq!(cfg.active_network_name(), Some("optimism")); + } + + #[test] + fn conflicting_optimism_and_tempo_selectors_are_rejected() { + let cfg = NetworkConfigs { + network: Some(NetworkVariant::Optimism), + tempo: true, + ..Default::default() + }; + assert_eq!( + cfg.validate().unwrap_err(), + "network selectors `network = \"optimism\"` and `tempo = true` conflict; select \ + only one network" + ); + } + + #[test] + fn serde_roundtrip_optimism() { + let original = NetworkConfigs::with_optimism(); + let json = serde_json::to_string(&original).unwrap(); + let restored: NetworkConfigs = serde_json::from_str(&json).unwrap(); + assert!(restored.is_optimism()); + assert!(!restored.is_tempo()); + } + + #[test] + fn serde_optimism_field_deserialized() { + let json_optimism = + r#"{"network": "optimism", "celo": false, "bypass_prevrandao": false}"#; + let cfg_optimism: NetworkConfigs = serde_json::from_str(json_optimism).unwrap(); + assert!(cfg_optimism.is_optimism()); + } + } + + #[test] + fn executed_hardfork_follows_the_network_family() { + // A cross-namespace override runs as the configured network's hardfork, so the value used + // to describe execution has to be coerced the same way. + let prague = FoundryHardfork::Ethereum(EthereumHardfork::Prague); + assert_eq!(NetworkConfigs::default().executed_hardfork(prague), prague); + assert_eq!( + NetworkConfigs::with_tempo().executed_hardfork(prague), + FoundryHardfork::Tempo(TempoHardfork::from(prague)) + ); + } +} diff --git a/crates/evm/networks/src/optimism.rs b/crates/evm/networks/src/optimism.rs new file mode 100644 index 0000000000000..4f88557d72a75 --- /dev/null +++ b/crates/evm/networks/src/optimism.rs @@ -0,0 +1,21 @@ +//! Optimism-specific extensions for [`NetworkConfigs`] and related helpers. + +use crate::{NetworkConfigs, NetworkVariant}; +use alloy_eips::eip1559::BaseFeeParams; +use alloy_op_hardforks::{OpChainHardforks, OpHardforks}; + +impl NetworkConfigs { + pub fn with_optimism() -> Self { + Self { network: Some(NetworkVariant::Optimism), optimism: true, ..Default::default() } + } + + /// Optimism-specific base fee parameters, picking Canyon vs pre-Canyon based on `timestamp`. + pub(crate) fn op_base_fee_params(&self, timestamp: u64) -> BaseFeeParams { + let op_hardforks = OpChainHardforks::op_mainnet(); + if op_hardforks.is_canyon_active_at_timestamp(timestamp) { + BaseFeeParams::optimism_canyon() + } else { + BaseFeeParams::optimism() + } + } +} diff --git a/crates/evm/sancov/Cargo.toml b/crates/evm/sancov/Cargo.toml new file mode 100644 index 0000000000000..4d65a79b6429f --- /dev/null +++ b/crates/evm/sancov/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "foundry-evm-sancov" +description = "SanitizerCoverage callbacks for coverage-guided fuzzing of native Rust code" + +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true + +[lints] +workspace = true diff --git a/crates/evm/sancov/README.md b/crates/evm/sancov/README.md new file mode 100644 index 0000000000000..78d56f83f6436 --- /dev/null +++ b/crates/evm/sancov/README.md @@ -0,0 +1,54 @@ +# foundry-evm-sancov + +SanitizerCoverage callbacks for coverage-guided fuzzing of native Rust code (precompiles, revm internals, etc.). + +When forge is built with a `RUSTC_WRAPPER` that injects sancov flags for target crates, the fuzzer uses native code edge coverage to guide mutation. Comparison operands from instrumented code are also captured and injected into the fuzz dictionary. + +## Config + +```toml +[invariant] +sancov_edges = true +sancov_trace_cmp = true +corpus_dir = "corpus/invariant" +``` + +When `sancov_edges` is enabled, the EVM `EdgeCovInspector` is automatically disabled — sancov replaces EVM bytecode coverage as the guidance signal. +When `corpus_dir` is set and `sancov_edges` is disabled, EVM edge coverage and EVM comparison operands are collected automatically for coverage-guided fuzzing. +`sancov_trace_cmp` is independent and only adds comparison operands from sancov-instrumented native code. + +## Replaying a corpus to AFL-`afl-showmap` files + +To export coverage from a persisted corpus for cross-fuzzer comparison, use +`forge test --showmap-out `. See [docs/dev/showmap.md](../../../docs/dev/showmap.md). + +## Build + +Create a `RUSTC_WRAPPER` that injects sancov flags for the crate(s) you want to instrument: + +```bash +#!/usr/bin/env bash +RUSTC="$1"; shift +CRATE_NAME="" +PREV="" +for arg in "$@"; do + [ "$PREV" = "--crate-name" ] && CRATE_NAME="$arg" && break + PREV="$arg" +done + +if [ "$CRATE_NAME" = "your_target_crate" ]; then + exec "$RUSTC" "$@" \ + -Cpasses=sancov-module \ + -Cllvm-args=-sanitizer-coverage-level=3 \ + -Cllvm-args=-sanitizer-coverage-trace-pc-guard \ + -Cllvm-args=-sanitizer-coverage-trace-compares +else + exec "$RUSTC" "$@" +fi +``` + +Then build: + +```bash +RUSTC_WRAPPER=./sancov-wrapper.sh cargo build --profile fuzz --bin forge +``` diff --git a/crates/evm/sancov/src/lib.rs b/crates/evm/sancov/src/lib.rs new file mode 100644 index 0000000000000..b192cf8f5d2bb --- /dev/null +++ b/crates/evm/sancov/src/lib.rs @@ -0,0 +1,258 @@ +//! SanitizerCoverage callbacks for coverage-guided fuzzing of native Rust code. +//! +//! Provides LLVM SanitizerCoverage callbacks and a coverage map that can be set +//! by the fuzzing executor to collect edge coverage from instrumented Rust +//! crates (e.g. precompile implementations compiled with `-Cpasses=sancov-module`). +//! +//! Additionally provides trace-cmp callbacks that capture comparison operands +//! and surface them to the fuzzer's dictionary, enabling it to solve comparison +//! guards (balance checks, overflow guards, etc.). +//! +//! Only crates compiled with sancov instrumentation (via a `RUSTC_WRAPPER`) +//! will trigger these callbacks — no runtime filtering needed. + +use std::sync::atomic::{AtomicPtr, AtomicU32, AtomicUsize, Ordering}; + +static COVERAGE_MAP_PTR: AtomicPtr = AtomicPtr::new(std::ptr::null_mut()); +static COVERAGE_MAP_LEN: AtomicUsize = AtomicUsize::new(0); + +/// Point the coverage map at the given buffer. Subsequent `__sanitizer_cov_trace_pc_guard` +/// calls will record hits into this buffer. +pub fn set_coverage_map(ptr: *mut u8, len: usize) { + COVERAGE_MAP_PTR.store(ptr, Ordering::Release); + COVERAGE_MAP_LEN.store(len, Ordering::Release); +} + +/// Deactivate the coverage map. +pub fn clear_coverage_map() { + COVERAGE_MAP_PTR.store(std::ptr::null_mut(), Ordering::Release); + COVERAGE_MAP_LEN.store(0, Ordering::Release); +} + +/// Whether a coverage map is currently active. +pub fn is_active() -> bool { + !COVERAGE_MAP_PTR.load(Ordering::Relaxed).is_null() +} + +static NEXT_SANCOV_IDX: AtomicUsize = AtomicUsize::new(0); + +static GUARD_LOOKUP: std::sync::RwLock> = std::sync::RwLock::new(Vec::new()); + +const UNASSIGNED: usize = usize::MAX; + +/// Record a hit for the given guard ID into the active coverage map. +#[inline(always)] +pub fn record_hit(guard_id: u32) { + let ptr = COVERAGE_MAP_PTR.load(Ordering::Relaxed); + if ptr.is_null() { + return; + } + let len = COVERAGE_MAP_LEN.load(Ordering::Relaxed); + if len == 0 { + return; + } + + let gid = guard_id as usize; + + // Fast path: read lock, check if already assigned. + let idx = { + let lookup = GUARD_LOOKUP.read().unwrap(); + (gid < lookup.len() && lookup[gid] != UNASSIGNED).then(|| lookup[gid]) + }; + + let idx = idx.unwrap_or_else(|| { + // Slow path: write lock, assign new index (double-check after acquiring). + let mut lookup = GUARD_LOOKUP.write().unwrap(); + if gid >= lookup.len() { + lookup.resize(gid + 1, UNASSIGNED); + } + if lookup[gid] == UNASSIGNED { + lookup[gid] = NEXT_SANCOV_IDX.fetch_add(1, Ordering::Relaxed); + } + lookup[gid] + }); + + if idx >= len { + return; + } + unsafe { + let slot = ptr.add(idx); + *slot = (*slot).wrapping_add(1); + } +} + +/// Number of unique sancov edges discovered so far. +pub fn sancov_edge_count() -> usize { + NEXT_SANCOV_IDX.load(Ordering::Relaxed) +} + +static GUARD_COUNTER: AtomicU32 = AtomicU32::new(1); + +/// # Safety +/// +/// Called by the LLVM SanitizerCoverage runtime at startup. `[start, stop)` must be a valid +/// range of mutable `u32` guard slots allocated by the compiler for the current DSO. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn __sanitizer_cov_trace_pc_guard_init(mut start: *mut u32, stop: *mut u32) { + while start < stop { + let id = GUARD_COUNTER.fetch_add(1, Ordering::Relaxed); + unsafe { + *start = id; + start = start.add(1); + } + } +} + +/// # Safety +/// +/// Called by the LLVM SanitizerCoverage runtime at every instrumented CFG edge. +/// `guard` must point to a valid `u32` guard slot initialized by +/// `__sanitizer_cov_trace_pc_guard_init`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn __sanitizer_cov_trace_pc_guard(guard: *mut u32) { + let id = unsafe { *guard }; + if id == 0 { + return; + } + record_hit(id); +} + +// --------------------------------------------------------------------------- +// Trace-cmp: capture comparison operands from instrumented code +// --------------------------------------------------------------------------- + +const MAX_CMP_OPERANDS: usize = 512; + +/// A single comparison operand captured by a trace-cmp callback. +#[derive(Clone, Copy, Debug)] +pub struct CmpSample { + /// Bit-width of the original comparison (8, 16, 32, or 64). + pub width: u8, + /// The operand value, right-aligned in a 32-byte buffer. + pub value: [u8; 32], +} + +thread_local! { + static CMP_OPERANDS: std::cell::RefCell> = + const { std::cell::RefCell::new(Vec::new()) }; +} + +#[inline(always)] +fn record_cmp(width: u8, arg1: u64, arg2: u64) { + if !is_active() { + return; + } + if arg1 == 0 && arg2 == 0 { + return; + } + CMP_OPERANDS.with(|ops| { + let mut ops = ops.borrow_mut(); + if ops.len() >= MAX_CMP_OPERANDS { + return; + } + if arg1 != 0 { + let mut buf = [0u8; 32]; + buf[24..].copy_from_slice(&arg1.to_be_bytes()); + ops.push(CmpSample { width, value: buf }); + } + if arg2 != 0 && arg2 != arg1 { + let mut buf = [0u8; 32]; + buf[24..].copy_from_slice(&arg2.to_be_bytes()); + ops.push(CmpSample { width, value: buf }); + } + }); +} + +/// Drain all captured comparison operands from the current thread. +pub fn drain_cmp_operands() -> Vec { + CMP_OPERANDS.with(|ops| { + let mut ops = ops.borrow_mut(); + std::mem::take(&mut *ops) + }) +} + +/// Clear all captured comparison operands on the current thread. +pub fn clear_cmp_operands() { + CMP_OPERANDS.with(|ops| ops.borrow_mut().clear()); +} + +/// # Safety +/// +/// Called by LLVM SanitizerCoverage at 1-byte comparison instructions. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn __sanitizer_cov_trace_cmp1(arg1: u8, arg2: u8) { + record_cmp(8, arg1 as u64, arg2 as u64); +} + +/// # Safety +/// +/// Called by LLVM SanitizerCoverage at 2-byte comparison instructions. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn __sanitizer_cov_trace_cmp2(arg1: u16, arg2: u16) { + record_cmp(16, arg1 as u64, arg2 as u64); +} + +/// # Safety +/// +/// Called by LLVM SanitizerCoverage at 4-byte comparison instructions. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn __sanitizer_cov_trace_cmp4(arg1: u32, arg2: u32) { + record_cmp(32, arg1 as u64, arg2 as u64); +} + +/// # Safety +/// +/// Called by LLVM SanitizerCoverage at 8-byte comparison instructions. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn __sanitizer_cov_trace_cmp8(arg1: u64, arg2: u64) { + record_cmp(64, arg1, arg2); +} + +/// # Safety +/// +/// Called by LLVM SanitizerCoverage at 1-byte constant comparison instructions. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn __sanitizer_cov_trace_const_cmp1(arg1: u8, arg2: u8) { + record_cmp(8, arg1 as u64, arg2 as u64); +} + +/// # Safety +/// +/// Called by LLVM SanitizerCoverage at 2-byte constant comparison instructions. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn __sanitizer_cov_trace_const_cmp2(arg1: u16, arg2: u16) { + record_cmp(16, arg1 as u64, arg2 as u64); +} + +/// # Safety +/// +/// Called by LLVM SanitizerCoverage at 4-byte constant comparison instructions. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn __sanitizer_cov_trace_const_cmp4(arg1: u32, arg2: u32) { + record_cmp(32, arg1 as u64, arg2 as u64); +} + +/// # Safety +/// +/// Called by LLVM SanitizerCoverage at 8-byte constant comparison instructions. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn __sanitizer_cov_trace_const_cmp8(arg1: u64, arg2: u64) { + record_cmp(64, arg1, arg2); +} + +/// # Safety +/// +/// Called by LLVM SanitizerCoverage before switch statements. +/// `cases[0]` is the number of cases, `cases[1]` is bit-width of `val`, +/// `cases[2..]` are the case constants. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn __sanitizer_cov_trace_switch(val: u64, cases: *const u64) { + if !is_active() || cases.is_null() { + return; + } + let n = unsafe { *cases } as usize; + for i in 0..n.min(16) { + let case_val = unsafe { *cases.add(2 + i) }; + record_cmp(64, val, case_val); + } +} diff --git a/crates/evm/symbolic/AGENTS.md b/crates/evm/symbolic/AGENTS.md new file mode 100644 index 0000000000000..7e6f2066ae583 --- /dev/null +++ b/crates/evm/symbolic/AGENTS.md @@ -0,0 +1,74 @@ +# AGENTS.md + +Guidance for AI coding agents working in `crates/evm/symbolic`. + +## First Read + +Read `crates/evm/symbolic/README.md` before changing user-facing symbolic +execution behavior. It defines the external semantics: symbolic tests are +`check*`, `prove*`, `invariant*`, and `statefulFuzz*` entrypoints; results are +`Safe`, replay-confirmed `Counterexample`, or `Incomplete`; and `PASS` is only +scoped to the modeled EVM surface and configured bounds. + +## Internal Structure + +- `src/lib.rs`: crate boundary and public symbolic run inputs/results. +- `src/abi.rs`: ABI-to-symbolic calldata/value construction and replay value + extraction. +- `src/executor/`: bytecode execution, calls/creates, cheatcodes, branch + constraints, and bounded invariant sequences. +- `src/runtime/`: symbolic runtime data structures: path state, world overlay, + memory, bytes, calldata, expressions, solver integration, precompiles, and + EVM helpers. +- `src/runtime/expr/`: symbolic word/bool expressions, hash-consing, symbol + interning, simplification, canonicalization, SMT emission, traversal, and + folding. +- `src/runtime/solver/`: solver orchestration, query scheduling, caches, budgets, + and model validation. Its `normalize/`, `reasoning/`, `fallback/`, and `smt/` + folders own normalization, lightweight proof passes, bounded witness fallback, + and query-level SMT emission respectively. + +## Expression Invariants + +- `SymCx` owns expression storage and symbol interning. +- `Symbol` is an id-only handle. Do not store variable names in symbols or + expression leaves; keep names in the interner. +- Use `SymExpr::var(cx, name)` when creating a named variable from a string. + Use `SymExpr::get_var(cx, symbol)` when you already have a `Symbol`. +- `HashConsed` equality is pointer equality. Structural equality is enforced + when values are hash-consed. +- `SymExpr::binop` must accept both EVM operand orders because simplification + happens before commutative canonicalization. +- Normalized commutative word ops put simpler operands on the RHS; constants end + up on the RHS. +- Once matching an already-normalized commutative expression, do not duplicate + impossible constant-left cases such as `, X` and `X, `. + Keep both directions only at construction boundaries or for non-commutative + operations. +- `Eq` comparisons are commutative and ordered through the same expression + ordering helper. Unsigned/signed order comparisons are not commutative; do not + remove left-constant comparison cases unless the code has already normalized + that exact operator. + +## Solver And Normalization Notes + +- Keep solver rewrites semantics-preserving under EVM bit-vector behavior. +- Preserve normalization/cache behavior when changing expression ordering or + boolean simplification. +- Solver models are not user-visible counterexamples until replay confirms + them. + +## Testing + +Cover symbolic behavior with end-to-end Forge CLI tests under +`crates/forge/tests/cli/test_cmd/symbolic*.rs`; do not add crate-level unit +tests or standalone test harnesses. Run: + +```bash +cargo nextest run -p forge --test cli test_cmd::symbolic +SYMBOLIC_CONFORMANCE=1 cargo nextest run -p forge --test cli symbolic_conformance +SYMBOLIC_LIMITS=1 cargo nextest run -p forge --test cli symbolic_limits +``` + +The conformance and limits suites require a local solver and are intentionally +broader/slower. PR CI skips them; the nightly `test-flaky` workflow runs them. diff --git a/crates/evm/symbolic/Cargo.toml b/crates/evm/symbolic/Cargo.toml new file mode 100644 index 0000000000000..43921312f1e05 --- /dev/null +++ b/crates/evm/symbolic/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "foundry-evm-symbolic" +description = "Foundry symbolic EVM executor" + +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +foundry-common.workspace = true +foundry-config.workspace = true +foundry-cheatcodes-spec.workspace = true +foundry-evm.workspace = true + +alloy-dyn-abi.workspace = true +alloy-json-abi.workspace = true +alloy-primitives = { workspace = true, features = ["serde"] } +alloy-sol-types.workspace = true +alloy-signer.workspace = true +alloy-signer-local = { workspace = true, features = ["mnemonic"] } + +base64.workspace = true +hashbrown.workspace = true +inturn.workspace = true +serde.workspace = true +serde_json.workspace = true +shlex.workspace = true +thiserror.workspace = true +tracing.workspace = true +wait-timeout.workspace = true + +[features] +default = ["optimism"] +base = ["foundry-evm/base"] +optimism = ["foundry-evm/optimism"] diff --git a/crates/evm/symbolic/README.md b/crates/evm/symbolic/README.md new file mode 100644 index 0000000000000..8417aacd06f9b --- /dev/null +++ b/crates/evm/symbolic/README.md @@ -0,0 +1,908 @@ +# foundry-evm-symbolic + +`foundry-evm-symbolic` is Foundry's native symbolic EVM executor. It powers +`forge test --symbolic` and is intended to make symbolic tests feel like normal +Forge tests: write Solidity, run Forge, get either a proof result or a concrete +counterexample that is replayed through the normal Foundry executor before it is +reported. + +Most users should interact with this crate through Forge. The Rust crate is the +engine that Forge calls after it has compiled contracts, run `setUp`, selected +tests, and prepared the concrete executor backend. + +## Quick Start + +Symbolic tests are Solidity functions named `check*` or `prove*`. + +```solidity +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; + +contract MathSymbolicTest is Test { + function check_average(uint256 a, uint256 b) external pure { + uint256 average = (a + b) / 2; + + // Forge should find an overflow counterexample. + assertGe(average, a <= b ? a : b); + } +} +``` + +Run it with: + +```sh +forge test --symbolic --match-test check_average +``` + +Requirements: + +- The configured solver must be available. The default solver command is `z3`. + Install it locally with your package manager, for example `brew install z3` + on macOS or `sudo apt-get install z3` on Ubuntu. Foundry avoids launching it + when bounded local model search can validate a satisfiable path directly. +- `check*` and `prove*` tests are only selected when `--symbolic` is enabled + and the contract is in a source path Forge compiles for the current project. +- A reported counterexample must replay concretely before Forge prints it as a + failure. + +`--match-test` filters function names or signatures. To filter by contract, use +`--match-contract`: + +```sh +forge test --symbolic --match-test check_average +forge test --symbolic --match-contract MathSymbolicTest +``` + +## Result Semantics + +Native symbolic testing is a preview feature. Results are scoped to the +executor's current EVM model and the configured exploration bounds. + +Symbolic testing works best for Solidity-level properties that fit the modeled +EVM surface: arithmetic, storage, calldata, common call/reentrancy flows, +selected cheatcodes, and bounded stateful sequences. It finds and replays +concrete witnesses when it can. When a test depends on unsupported or unmodeled +behavior, Forge reports the run as incomplete instead of treating the property +as proven. It does not model full revm behavior, arbitrary unknown fork +accounts, or cryptographic preimage/collision search. + +Forge reports symbolic test outcomes as: + +- `PASS`: every explored path finished without a feasible failure under the + currently modeled semantics and configured bounds. +- `FAIL` with a counterexample: the solver found a failing model and Forge + replayed that concrete input or invariant sequence through the normal + executor. +- `FAIL: incomplete symbolic execution (...)`: Forge could not complete the + search or validate a counterexample for this run. Treat this outcome as "not + established". + +When `--json` is enabled, each symbolic test result includes a stable +`symbolic` object in addition to the legacy test fields. The schema lives at +`crates/evm/symbolic/assets/symbolic-result.schema.json` and records the +normalized status (`pass`, `fail_counterexample`, or `incomplete`), incomplete +reason kind, effective bounds, solver identity and counters, explicit +assumptions, call-trace location metadata, replay status, and counterexample +payload when one exists. + +When Forge materializes a replay candidate, `symbolic.artifact` points to a +durable replay artifact written under the configured cache path. The artifact +schema lives at +`crates/evm/symbolic/assets/symbolic-counterexample.schema.json` and records +the replay status, bounds, assumptions, solver metadata, optional trace +reference, and concrete call data needed by downstream minimizers and exporters. + +Symbolic execution can also seed coverage-guided fuzzing by concretizing +non-failing fuzz-test inputs into the configured `fuzz.corpus_dir`: + +```sh +forge test --symbolic-seed-corpus --fuzz-corpus-dir fuzz_corpus +``` + +Forge symbolically executes matching fuzz tests, reuses their normal corpus +layout, and writes a successful concrete input as a seed for later fuzz runs. + +Symbolic execution can import the same Foundry fuzz corpus as path-priority +hints for fuzz tests: + +```sh +forge test --symbolic-use-fuzz-corpus --fuzz-corpus-dir fuzz_corpus +``` + +Imported corpus entries are bounded by `symbolic.corpus_seed_limit` and only +guide branch order; they do not prune feasible symbolic paths. JSON output +records the per-test corpus directory, import counts, and seed files that +matched a symbolic calldata variant under `symbolic.corpus_seeds.used`. + +Fuzzing can also record branch frontier artifacts for later targeted symbolic +follow-up: + +```sh +forge test --match-test test_hard_branch --fuzz-frontier-dir fuzz_frontiers +``` + +Invariant campaigns can capture the same EVM-level comparisons together with +the concrete transaction prefix that reached each stateful site: + +```sh +forge fuzz run --match-test invariant_ \ + --runs 1000 \ + --depth 500 \ + --frontier-dir fuzz_frontiers +``` + +For example, a fuzz run may pass after reaching `feeMultiplier == 100` at a +`feeMultiplier < 100` guard; the frontier gives symbolic execution the replay +calldata and comparison site needed to solve the adjacent missed branch. + +Forge writes one bounded artifact per stateless fuzz test at +`///branch-frontiers.json`. Stateful artifacts +use a collision-free v2 namespace under +`/v2/-//`; +merged boolean campaigns add `shared/branch-frontiers.json`, while isolated +boolean and optimization campaigns add `isolated//branch-frontiers.json`. +Stateless artifacts use schema `foundry:fuzz.branch-frontiers@v1`. Invariant +artifacts use `foundry:fuzz.branch-frontiers@v2`, storing concrete sequences +once in a top-level `sequences` array and referencing them by `sequence_index` +to avoid repeating long transaction prefixes. Both record the test signature, +configured record limit, and a `frontiers` array. Each frontier contains: + +- a stable record index (`id`) within the artifact +- the concrete call sequence, directly or by `sequence_index`, and the index of + the call whose prefix reached the site +- the EVM comparison site (`address`, `pc`, `opcode`, `opcode_name`) +- concrete operands (`lhs`, `rhs`), the comparison result, and an + `operand_delta` priority score interpreted according to opcode signedness + +Stateless records additionally contain fuzz replay metadata (`seed`, `run`, +`worker`) when available and `new_coverage` when edge coverage is collected. + +Frontier capture is opt-in and bounded by `fuzz.frontier_limit` or +`invariant.frontier_limit` (both default to 256). It reuses the fuzzer's +comparison-operand inspector and does not store traces. Parallel invariant +campaigns capture on worker 0 only. The recorder keeps the first distinct +site/result pairs up to the record limit, replaces them with closer operands, +and prefers the shorter sequence when distances tie. The limit bounds records, +not bytes; retained sequence storage can scale with the limit and campaign +depth. Campaigns using `invariant.call_override` do not write frontier artifacts +because the artifact does not encode randomized inner calls. + +Symbolic execution can consume those artifacts to solve the opposite side of +captured comparisons and write replay-confirmed inputs into the fuzz corpus: + +```sh +forge test --match-test test_hard_branch \ + --fuzz-frontier-dir fuzz_frontiers \ + --fuzz-corpus-dir fuzz_corpus \ + --symbolic-use-fuzz-frontiers +``` + +Forge imports up to `symbolic.frontier_limit` records (default 256), replays the +recorded one-call seed as a path-priority hint, constrains symbolic execution to +flip the captured comparison result, and persists only candidates that replay +with the expected concrete outcome. +For an invariant campaign, point the follow-up run at both the captured +frontiers and an invariant corpus directory: + +```sh +forge test --match-test invariant_ \ + --invariant-frontier-dir fuzz_frontiers \ + --invariant-corpus-dir fuzz_corpus \ + --symbolic-use-fuzz-frontiers \ + --symbolic-timeout 1 +``` + +Forge replays each recorded prefix into a fresh EVM, symbolically solves only +the call that reached the retained comparison, and writes a branch candidate +only when concrete replay observes the opposite result at that exact comparison +site. Every accepted input produced by that solve is also replayed against each +remaining invariant predicate and the suite hook, and exact failures are +reported only at checkpoints where the concrete campaign would evaluate them; +other candidates remain corpus seeds. `afterInvariant` failures are reported +only at a terminal depth allowed by the configured depth mode. This uses the +existing solver results; it does not issue additional symbolic queries. +Reverting candidates are retained only when they contain an assertion failure +or the invariant suite enables `fail_on_revert`. Target calls carrying nonzero +value are currently skipped because symbolic root calls do not yet apply the +corresponding balance transfer. Replay the resulting corpus to check every +persisted sequence deterministically: + +```sh +forge fuzz replay --match-test invariant_ --corpus-dir fuzz_corpus +``` + +Pass `--symbolic-check-invariant-frontiers` to enable invariant frontier seeding +and first check whether one symbolic invocation of each selected target can +break a suite predicate from its replayed +concrete prefix. Without `afterInvariant`, each invariant function is checked +independently from the same post-call symbolic state. Forge uses the concrete +campaign semantics: assertions and reverts indicate failure, while a Solidity +`bool` return value is ignored. With `afterInvariant`, the property attempt uses +the current campaign anchor and its hook. + +Property checking catches correlated inputs that violate an invariant even when +flipping the recorded comparison is harmless. Forge persists only a one-call +symbolic suffix with no symbolic initial-storage assignments that replays +concretely through the full prefix and fails the exact predicate. Unsupported or +bounded predicates do not discard candidates already found, but an empty search +does not prove the suite safe. Forge performs comparison flipping after the +property search regardless of whether that search found a candidate, and does +not symbolize or repair earlier calls in the recorded prefix. +Configured symbolic limits apply separately to the property attempt and +comparison-flipping pass for each imported frontier. + +This is an explicit follow-up to a concrete campaign, not automatic symbolic +work in every fuzz run. A short solver timeout keeps iteration bounded; increase +it for frontiers that report incomplete. Use frontier IDs, PCs, selectors, or a +lower `symbolic.frontier_limit` when only selected sites should consume solver +time. Frontier capture currently covers direct EVM comparison opcodes; branch +conditions compiled into arithmetic followed by `JUMPI` are not targeted yet. + +To focus solver time on specific captured sites, pass frontier artifact IDs, +comparison PCs, or calldata selectors: + +```sh +forge test --match-test test_hard_branch \ + --fuzz-frontier-dir fuzz_frontiers \ + --fuzz-corpus-dir fuzz_corpus \ + --symbolic-use-fuzz-frontiers \ + --symbolic-frontier-ids 3,7 \ + --symbolic-frontier-pcs 128,412 \ + --symbolic-frontier-selectors 0x12345678 +``` + +`symbolic.frontier_ids`, `symbolic.frontier_pcs`, and +`symbolic.frontier_selectors` default to `[]`, meaning any value for that +dimension. Non-empty filters compose conjunctively, so the example imports only +records matching one of the requested IDs, one of the requested PCs, and one of +the requested selectors. Stateless imports keep artifact order. Automatic +stateful imports represent distinct recorded sequence/call contexts within each +priority tier before revisiting other comparisons from the same contexts, while +sampling across call depths. Records at sites whose two outcomes are both +retained in the bounded artifact remain lower priority rather than being +discarded; one deep such record is reserved when possible, and they fill +otherwise unused solver budget. Explicit frontier filters use the matching +records directly. Forge imports up to `symbolic.frontier_limit` matching records +and warns if a requested stateless target cannot be imported. + +> **Hash-model caveat:** `PASS` also assumes collision and preimage resistance +> for symbolic `KECCAK256` and hash-like precompile terms. The executor may use +> equal symbolic hashes to infer equal symbolic preimages or lengths in modeled +> cases, and it does not search for Keccak collisions or adversarial preimages. +> Concrete counterexamples are still replayed before they are reported. + +Symbolic exploration is bounded by configuration, including +`symbolic.max_depth`, `symbolic.max_paths`, `symbolic.max_solver_queries`, +dynamic calldata length settings, and `symbolic.invariant_depth`. + +`Incomplete` can occur when exploration reaches a configured bound, the solver +times out or returns `unknown`, a test uses unsupported EVM or cheatcode +semantics, a backend error occurs, or a solver model does not replay concretely. +When a solver candidate does not replay, it can still be shown as a diagnostic +legacy top-level `counterexample`; treat it as confirmed only when +`symbolic.status` is `fail_counterexample` and `symbolic.replay.status` is +`confirmed`. + +Current modeling notes: + +- Unsupported opcodes, world-state behavior, or cheatcode forms are reported as + incomplete results with an explanatory reason. +- Symbolic `KECCAK256` supports common Solidity storage patterns; arbitrary + symbolic hashing may require heuristics and can make a run incomplete. +- `SELFDESTRUCT` follows the active fork. Before Cancun it deletes the account; + from Cancun onward it only deletes contracts created in the same top-level + symbolic transaction, otherwise it transfers balance and halts while + preserving code and storage. Cancun beneficiaries must resolve to concrete + addresses; unresolved symbolic beneficiaries report incomplete. +- Counterexamples are shown only after successful concrete replay. + +## Writing Symbolic Tests + +Stateless symbolic tests use ordinary ABI parameters. The executor creates +symbolic calldata from the function ABI and explores feasible EVM paths. + +Storage hooks can maintain revert-aware ghost state for raw storage accesses. +Register a callback during `setUp` for each target and access kind: + +```solidity +function setUp() public { + vm.registerSloadHook(address(vault), this.onLoad.selector); + vm.registerSstoreHook(address(vault), this.onStore.selector); +} + +function onLoad(address account, bytes32 slot, bytes32 value) external { + require(msg.sender == address(vm), "only storage hook"); + // Update ghost state from the observed load. +} + +function onStore( + address account, + bytes32 slot, + bytes32 oldValue, + bytes32 newValue +) external { + require(msg.sender == address(vm), "only storage hook"); + // Update ghost state from the observed write. +} +``` + +Concrete and symbolic execution use the same callback contract. Re-registering +an access kind replaces its callback for that target. Callback reverts propagate +through the post-operation callback. Registration survives EVM reverts, while +callback state follows the enclosing EVM context and rolls back with it. Hooks +use the effective storage account, so a `delegatecall` is attributed to the +caller's storage context. Hooks are suppressed throughout the callback's call +subtree. Callback execution is hidden from mocks, expectations, recorded logs, +and storage-access recording, while its EVM state writes and hook registrations +are retained. Callbacks do not inherit staticness, so they can update ghost state +for storage reads beneath `STATICCALL`; the observed target remains subject to +normal static-call restrictions. The slot is the computed effective slot; +mapping roots and decoded keys are not included. Callbacks are ordinary external +functions, so they must authenticate `msg.sender` as `address(vm)` before +updating ghost state. Registered callback selectors are excluded from default +invariant targets, but explicit selector configuration can still target them. +The callback runs as an ordinary call frame, so it consumes one of the 1024 +protocol call-depth slots; a storage access at the maximum legal call depth +that would otherwise succeed can have its callback rejected as too deep, which +propagates as a failure of the storage operation. + +Aggregate ghosts such as `ghost = ghost - oldValue + newValue` require an +initialization model consistent with the ghost's starting value. Use +`symbolic.storage_layout = "zero_init"` when the ghost starts from zero and +unwritten symbolic mapping entries should be zero. With the default `solidity` +layout, a previously unwritten symbolic key has an unknown base value, so the +ghost must already account for that value. Known nonzero setup state must +likewise be included in the initial ghost. These raw hooks cannot distinguish +one mapping family from another without mapping-root provenance. + +### Mapping SSTORE hooks and ERC20 accounting + +`registerMappingSstoreHook` can maintain a ghost aggregate for one mapping root without reacting +to unrelated storage. For an ERC20 whose `balances` mapping is at slot zero: + +```solidity +function setUp() public { + token = new Token(); + vm.registerMappingSstoreHook(address(token), bytes32(0), this.onBalanceWrite.selector); +} + +function onBalanceWrite( + address account, + bytes32, + bytes32 root, + bytes32[] calldata keys, + bytes32 oldValue, + bytes32 newValue +) external { + require(msg.sender == address(vm) && account == address(token)); + require(root == bytes32(0) && keys.length == 1); + ghostSupply = ghostSupply - uint256(oldValue) + uint256(newValue); +} +``` + +With `symbolic.storage_layout = "zero_init"`, initialize `ghostSupply` to zero and assert it equals +`totalSupply` after symbolic mint, transfer, and burn operations. Allowance writes use a different +root and do not affect the ghost. Both target writes and callback ghost updates roll back when the +enclosing operation reverts. Mapping hooks require complete, exact 64-byte Keccak chains computed +after the latest mapping-hook registration for that target in the current top-level execution. +Resolution follows the complete chain to its terminal root instead of stopping at a registered +intermediate hash; offsets, incomplete provenance, hashes computed before registration, and hashes +from earlier top-level executions do not match. Hashes computed after registration remain usable by +later calls in the same top-level execution. The contract that calls `registerMappingSstoreHook` +receives the callback. + +```solidity +contract RiddleTest is Test { + function check_riddle(uint256 x) external pure { + uint256 sender = uint160(0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38); + + unchecked { + require(x * x < sender); + } + + require(x > sender); + require(x & 0x800 != 0); + require(x & 0x10000 == 0); + + assert(false); + } +} +``` + +In this style: + +- `require(...)` prunes paths when the condition is false. +- `vm.assume(...)` also prunes paths. If assumptions reject every path, Forge + reports an incomplete result instead of a proof. +- `assert`, forge-std assertions, and DSTest failure signals are treated as + properties to disprove. +- User reverts terminate the current path. If every path reverts, Forge reports + a revert-all result instead of a proof. + +Dynamic ABI inputs are bounded. Use `forge-config:` inline annotations or +`foundry.toml` to choose lengths. + +```solidity +contract BytesSymbolicTest is Test { + /// forge-config: default.symbolic.array_lengths = [3] + function check_bytes(bytes memory data) external pure { + require(data.length == 3); + if (data[0] == 0xaa && data[1] == 0xbb && data[2] == 0xcc) { + assert(false); + } + } +} +``` + +Dynamic leaves are traversed in deterministic ABI pre-order. Lengths resolve in +this order: + +1. `symbolic.dynamic_lengths`, keyed by ABI argument name or generated symbolic + name such as `calldata_0`. +2. `symbolic.default_array_lengths` for arrays, or + `symbolic.default_bytes_lengths` for `bytes` and `string`. +3. The legacy positional `symbolic.array_lengths`, applied to the next dynamic + leaf that was not matched by a named or type-specific default. +4. `symbolic.default_dynamic_length`. + +Length-set config fields accept Halmos-style arrays and expand into separate +symbolic calldata shapes. For nested dynamic values, Foundry explores the cross +product implied by the selected outer lengths. Eager calldata expansion is capped +by the effective symbolic path width (`symbolic.width` / `symbolic.max_paths`). +Extra positional `array_lengths` entries are rejected as config errors. + +Pending symbolic paths are explored in breadth-first order by default. Set +`symbolic.exploration_order = "dfs"` to use depth-first ordering instead. This +only changes which queued path is explored next; it does not change path limits, +solver query limits, or solver portfolio scheduling. + +Supported ABI shapes include: + +- integers, booleans, addresses, fixed bytes, and dynamic bytes +- strings, constrained to printable ASCII for model extraction +- fixed and dynamic arrays +- tuples and structs, represented through ABI tuples + +## Stateful Symbolic Invariants + +When `--symbolic` is enabled, `invariant*` and `statefulFuzz*` functions use a +bounded symbolic call sequence instead of the normal invariant fuzzer. + +```solidity +contract CounterInvariant is Test { + Counter counter; + + function setUp() public { + counter = new Counter(); + targetContract(address(counter)); + } + + /// forge-config: default.symbolic.invariant_depth = 4 + function invariant_counterNeverFive() public view { + assertTrue(counter.value() != 5); + } +} +``` + +Forge reuses its invariant target discovery for target contracts, selectors, and +senders. The symbolic executor chooses a bounded sequence from that discovered +set, generates symbolic arguments with the same ABI model used for stateless +tests, preserves symbolic world state between calls, and replays a concrete +sequence before reporting a counterexample. + +Within an invariant predicate or `afterInvariant`, including nested calls, +`vm.assume` is supported only when the current path constraints imply its +condition. If the assumption could reject a reachable state, symbolic execution +reports incomplete instead of restricting the property to the accepted states. +Assumptions in target handler calls continue to discard inputs normally. + +Some invariant harnesses deploy dependency contracts in `setUp`, then rely on +those dependencies having satisfiable environment state during the campaign. For +example, a lending invariant may call an ERC20 mock for balances and allowances, +or an oracle mock for a price, without exposing target functions that write +those values first. In that case the dependency storage remains the concrete +state produced by `setUp`, and symbolic execution can only explore paths +reachable from that concrete dependency state. + +Use Foundry's existing `vm.setArbitraryStorage(address)` cheatcode to mark those +environment dependency contracts as symbolic storage targets: + +```solidity +function setUp() public { + loanToken = new ERC20Mock(); + collateralToken = new ERC20Mock(); + oracle = new OracleMock(); + + vm.setArbitraryStorage(address(loanToken)); + vm.setArbitraryStorage(address(collateralToken)); + vm.setArbitraryStorage(address(oracle)); + + targetContract(address(this)); +} +``` + +If the harness imports a smaller Hevm interface that does not expose this +cheatcode, declare a local interface with `setArbitraryStorage(address)` and +cast it to `address(vm)`. + +Keep this scoped to external dependencies that model the environment, such as +token balances, token allowances, and oracle prices. Do not blanket-mark the +invariant harness or protocol state as arbitrary; that can create unreachable +states and counterexamples that concrete replay rejects. + +Replay currently materializes only concrete storage slots observed during +symbolic execution. Dependency storage keyed by symbolic calldata is still +explored symbolically, but it can replay-filter to `Incomplete` or `mismatch` +instead of a confirmed counterexample if the required slot is not concrete. + +## Configuration + +The primary configuration path is native Foundry config. + +```toml +[profile.default.symbolic] +solver = "z3" +# Optional exact command. When set, this overrides `solver`. +# solver_command = "z3 -in -smt2" +# Optional solver names or commands to race in parallel. Ignored when +# `solver_command` is set. Entries with spaces/quotes/backslashes are parsed as +# argv strings, not shell snippets. +# solver_portfolio = ["yices", "z3"] +# Per SMT query and, for stateless tests, the deferred hard-arithmetic phase. +# This is the overall wall-clock bound for symbolic invariant exploration. +timeout = 30 +max_depth = 10000 +max_paths = 1024 +exploration_order = "bfs" +max_solver_queries = 10000 +default_dynamic_length = 2 +max_dynamic_length = 256 +array_lengths = [] +dynamic_lengths = {} +default_array_lengths = [] +default_bytes_lengths = [] +max_calldata_bytes = 4096 +invariant_depth = 10 +use_fuzz_frontiers = false +check_invariant_frontiers = false +frontier_limit = 256 +frontier_ids = [] +frontier_pcs = [] +frontier_selectors = [] +symbolic_call_targets = false +dump_smt = false +storage_layout = "solidity" +``` + +The same values can be set inline with NatSpec: + +```solidity +/// forge-config: default.symbolic.timeout = 120 +/// forge-config: default.symbolic.array_lengths = [2, 4] +/// forge-config: default.symbolic.dynamic_lengths = { data = [3] } +/// forge-config: default.symbolic.default_bytes_lengths = [8] +/// forge-config: default.symbolic.exploration_order = "dfs" +/// forge-config: default.symbolic.invariant_depth = 6 +function check_with_bounds(bytes memory data, uint256[] memory b) external { + // ... +} +``` + +Common CLI and environment overrides: + +```sh +forge test --symbolic +forge test --symbolic --symbolic-solver yices +forge test --symbolic --symbolic-solver cvc5 +forge test --symbolic --symbolic-solver bitwuzla +forge test --symbolic --symbolic-solver-command "z3 -in -smt2" +forge test --symbolic --symbolic-solver-portfolio yices,z3 +forge test --symbolic --symbolic-timeout 120 +forge test --symbolic --symbolic-array-lengths 2,4 +forge test --symbolic --symbolic-invariant-depth 6 +forge test --symbolic --symbolic-call-targets +forge test --symbolic --symbolic-dump-smt + +FOUNDRY_SYMBOLIC=true forge test +FOUNDRY_SYMBOLIC_SOLVER=z3 forge test --symbolic +FOUNDRY_SYMBOLIC_SOLVER_COMMAND="z3 -in -smt2" forge test --symbolic +FOUNDRY_SYMBOLIC_SOLVER_PORTFOLIO="yices,z3" forge test --symbolic +FOUNDRY_SYMBOLIC_TIMEOUT=120 forge test --symbolic +``` + +Known solver names are `z3`, `yices`, `cvc5`, `cvc5-int`, `bitwuzla`, and +`bitwuzla-abs`. Unknown `symbolic.solver` values are treated as z3-compatible +executables and are invoked with `-in -smt2` to preserve the old +`symbolic.solver = "/path/to/z3"` behavior. Use `symbolic.solver_command` for +non-z3-compatible command lines or wrapper tools. + +`symbolic.solver_portfolio` runs solvers in configured order with staged starts: +the first entry starts immediately, the second starts shortly after if the query +is still unresolved, and later entries are treated as rescue solvers. If a solver +finishes without a decisive result and no other solver is running, the next +pending entry starts immediately. The first `sat` response wins after its model +is validated for model-producing queries. `unsat` responses are used only after +all configured solvers that were needed to rule out `sat` have finished, and +`unknown` results only win if no configured solver returns a decisive response. +A nonempty `symbolic.solver_command` overrides both +`symbolic.solver_portfolio` and `symbolic.solver`; otherwise a nonempty +portfolio overrides `symbolic.solver`. Portfolio entries without whitespace, +quotes, or backslashes are resolved like `symbolic.solver` values. Entries with +whitespace, quotes, or backslashes are split into argv parts like +`symbolic.solver_command`; they are not executed through a shell. +For latency-sensitive local runs, start with a small portfolio such as +`["yices", "z3"]`. Broader portfolios can help on solver-diverse workloads but +can still use more CPU and be slower when one fast solver already handles most +queries. `--symbolic-dump-smt` prints each solver's configured order and launch +delay with the per-query portfolio outcomes so solver mixes can be compared +without changing execution semantics. + +Security note: `symbolic.solver_command`, custom `symbolic.solver` values, and +custom or command-like `symbolic.solver_portfolio` entries execute local programs +when symbolic tests run. This also applies when these values come from inline +`forge-config:` or translated legacy `@custom:halmos` annotations. Review solver +settings before running symbolic tests from untrusted projects. +Timeouts and portfolio cancellation terminate only the direct solver child +process. Wrapper commands should forward termination to any subprocesses they +spawn so descendant solvers do not outlive the cancelled query. + +Halmos-style annotations are accepted as compatibility input and translated into +the same internal config: + +```solidity +/// @custom:halmos --array-lengths 2,4 --width 32 --depth 256 +function check_legacy(bytes memory a, bytes memory b) external { + // ... +} +``` + +Supported compatibility flags are: + +- `--array-lengths` +- `--loop` +- `--width` +- `--depth` +- `--invariant-depth` +- `--solver-timeout` +- `--solver-timeout-branching` +- `--solver-timeout-assertion` +- `--solver` +- `--solver-command` + +Native `forge-config:` values win when both native and compatibility annotations +set the same symbolic option. + +## SVM Compatibility Helpers + +The executor recognizes a Halmos-style symbolic VM helper address: + +```solidity +address constant SVM_ADDRESS = address(0xF3993A62377BCd56AE39D773740A5390411E8BC9); + +interface Svm { + function createUint256(string calldata name) external returns (uint256); + function createInt256(string calldata name) external returns (int256); + function createBytes32(string calldata name) external returns (bytes32); + function createAddress(string calldata name) external returns (address); + function createBool(string calldata name) external returns (bool); + function createBytes(string calldata name) external returns (bytes memory); + function createBytes(uint256 length, string calldata name) external returns (bytes memory); + function createString(string calldata name) external returns (string memory); + function createString(uint256 length, string calldata name) external returns (string memory); + function createBytes4(string calldata name) external returns (bytes4); + function createCalldata(string calldata name) external returns (bytes memory); + function enableSymbolicStorage(address target) external; + function setArbitraryStorage(address target) external; + function snapshotStorage(address target) external returns (uint256); + function snapshotState() external returns (uint256); +} +``` + +Forge also treats several `vm.random*` cheatcodes as symbolic value creators when +running symbolically. Dynamic byte and string creators use the same dynamic ABI +bounds as function arguments. + +## How It Works + +Forge drives the symbolic executor in these stages: + +1. Compile and deploy contracts with the normal Foundry flow. +2. Execute `setUp` concretely, including fork-backed setup when the Forge + executor is forked. +3. Classify `check*` and `prove*` functions as stateless symbolic tests, and + `invariant*` or `statefulFuzz*` functions as symbolic invariant tests when + `--symbolic` is enabled. +4. Build symbolic calldata from the Alloy ABI type model. +5. Execute bytecode in a standalone symbolic EVM, reading concrete code and + setup state from the Foundry executor backend. +6. Store mutations in a symbolic world overlay instead of mutating the concrete + executor. +7. Query the solver for branch feasibility and model extraction. +8. Replay any candidate counterexample through the concrete Foundry executor + before reporting it. + +The symbolic EVM is intentionally separate from revm's concrete interpreter. It +uses Foundry and revm data structures for bytecode, accounts, environment, and +backend reads, but symbolic execution needs its own expression values, memory, +storage, call frames, path constraints, and solver integration. + +Important internal pieces: + +- `SymbolicExecutor` owns configuration and the solver backend. +- `SymbolicRunInput` describes one deployed test contract function to explore. +- `SymbolicInvariantRunInput` describes one bounded invariant sequence run. +- `SymbolicRunResult` and `SymbolicInvariantRunResult` return safe, + counterexample, or incomplete outcomes. +- `SymbolicWorld` overlays balances, nonce, code, persistent storage, transient + storage, logs, returndata, snapshots, and account lifecycle changes. +- `CallFrame` tracks address, code address, storage address, caller, call value, + static context, calldata, stack, memory, and returndata. +- `SmtLibSubprocessSolver` resolves named solvers (z3, cvc5, yices, bitwuzla, + etc.) into solver-specific argv via `solver_commands_for_config`. + +## EVM And World Semantics + +The executor models byte-precise calldata, memory, returndata, logs, storage, +and symbolic storage keys. Supported behavior includes: + +- arithmetic and bitwise operations, including symbolic-safe `EXP`, + `SIGNEXTEND`, `BYTE`, shifts, and checked wrapping behavior +- `CALLDATALOAD`, `CALLDATACOPY`, `CODECOPY`, `EXTCODECOPY`, + `RETURNDATACOPY`, `MCOPY`, `MLOAD`, `MSTORE`, and `MSTORE8` +- `CALL`, `STATICCALL`, `DELEGATECALL`, `CALLCODE`, `CREATE`, and `CREATE2` +- concrete and bounded symbolic call targets over known deployed contracts +- symbolic calldata and returndata across call frames +- `SLOAD`, `SSTORE`, `TLOAD`, and `TSTORE` with concrete or symbolic keys +- symbolic `KECCAK256` terms for Solidity mapping and dynamic-array storage + patterns +- concrete code and storage read from the normal Foundry setup backend, + including fork-backed setup state +- balances, value transfer, nonce lifecycle, code hash, account existence, and + pre-Cancun `SELFDESTRUCT` +- common concrete-input precompile cases +- block and transaction environment reads and supported environment cheatcodes + +Unsupported symbolic constructs return an incomplete result with a `Stuck` +reason instead of silently proving the property. + +## Known limitations + +Unsupported constructs report `Incomplete` rather than a proof. Some supported +semantics are bounded or approximate; in those cases, `PASS` only applies to the +modeled semantics and configured bounds. + +Known incomplete, bounded, or approximate surfaces include: + +| Area | Current behavior | +|---|---| +| Gas-dependent behavior | The engine does not use gas to prove properties. A raw `GAS` / `gasleft()` value is tolerated only as the direct gas operand to a CALL-family opcode and is not used to model gas availability. Explicit or computed CALL-family gas caps report incomplete, including the caps emitted by Solidity `send` and `transfer`. Branches on observed gas may be explored through local solver witnesses, but they never produce counterexamples: arithmetic, call targets/values, calldata/returndata, memory/log offsets or sizes, `expectCall` gas matching, or counterexample models derived from observed gas report incomplete. Non-observable gas metering helpers are accepted as no-ops; observable gas read/snapshot helpers such as `lastCallGas`, `lastFrameGas`, `snapshotGasLastCall`, `snapshotGasLastFrame`, and `stopSnapshotGas` report incomplete and should not be used as symbolic properties. | +| `SELFDESTRUCT` | Pre-Cancun deletion is modeled. Cancun/EIP-6780 is modeled for concrete beneficiaries: contracts created in the current top-level symbolic transaction are deleted, while existing contracts transfer balance and halt without deleting code or storage. Unresolved symbolic Cancun beneficiaries report incomplete. | +| Symbolic account/code queries | `BALANCE`, `EXTCODESIZE`, `EXTCODEHASH`, and `EXTCODECOPY` on symbolic addresses are scoped to the engine's known symbolic/overlay/code-cache candidates plus the documented empty-account fallback. They do not prove quantified properties over every possible fork/backend account. | +| Symbolic CALL targets | Concrete targets and symbolic targets constrained to known deployed-contract/precompile candidates are supported. By default, a feasible symbolic target outside the known candidate set reports incomplete. With `symbolic_call_targets = true`, the outside-candidate branch is modeled as an empty-account/no-code successful call, including value transfer for `CALL`; it does not model arbitrary unknown external code or custom/future precompiles. Symbolic cheatcode addresses/selectors still report incomplete. | +| Symbolic CREATE / CREATE2 inputs | Concrete initcode and common bounded symbolic CREATE2 address expressions are supported. Symbolic runtime sizes and unsupported symbolic initcode shapes report incomplete. | +| ABI and calldata shape limits | Primitive ABI types, arrays, tuples, structs, bytes, and strings are supported within configured dynamic length and calldata byte limits. Unsupported ABI types, invalid ABI shapes, or calldata exceeding configured budgets report incomplete or config errors. | +| Dynamic memory and copy bounds | Many symbolic memory, calldata, returndata, and `MCOPY`/`RETURNDATACOPY` sizes are supported when bounded by configuration or solver-proved limits. Unbounded or out-of-bounds symbolic reads/copies report incomplete. | +| Opcode activation | Implemented opcodes follow the active EVM version. An opcode used before its activation fork causes an exceptional halt, matching concrete execution. | +| Concrete-required operands and bytecode | Symbolic data can flow through calldata, memory, storage, logs, and returndata, but some control/metadata values must resolve to concrete or solver-constrained values: `JUMP`/`JUMPI` destinations, `BLOBHASH` indices, cheatcode selectors, many cheatcode ABI decodes, fork IDs/block numbers, nonces, and created runtime bytecode opcodes. Symbolic bytecode opcodes, symbolic runtime sizes, or unconstrained control operands report incomplete. | +| Symbolic hashing and `KECCAK256` | Concrete hashes are computed exactly. Symbolic `KECCAK256` is represented by deterministic opaque terms plus Solidity-storage-layout heuristics for common mapping and dynamic-array keys. Proof obligations that depend on cryptographic facts such as non-zero hashes, collision resistance, or preimage resistance are not proof-grade and may report incomplete or produce replay-filtered candidates. | +| Symbolic storage base values | Writes and later reads through symbolic keys are modeled, with Solidity-layout heuristics for common mapping/dynamic-array keys. Reads of previously-unwritten symbolic keys are abstract storage variables by default, or zero under the zero-init storage layout; the engine does not enumerate arbitrary concrete backend storage slots for a symbolic key. Proofs involving unknown existing storage are scoped to the selected `symbolic.storage_layout`. | +| Precompiles | Canonical precompiles are recognized according to the active EVM version; KZG `0x0a` is Cancun+ only and falls back to normal empty-account behavior on earlier hardforks. Precompile addresses use normal account emptiness checks for `EXTCODEHASH`. Concrete inputs for modeled precompiles execute the corresponding revm precompile with effectively unlimited gas. Symbolic identity is byte-precise; symbolic hash/modexp outputs are deterministic opaque terms or fixed-length symbolic outputs, not full cryptographic/algebraic models. Symbolic ecrecover returns empty data for invalid recovery IDs and models both empty and 32-byte returndata when recovery is uncertain; the recovered address remains an opaque term. Symbolic BN254 inputs and symbolic BLAKE2f final flags report incomplete because precompile success depends on validity checks the symbolic model does not prove. KZG `0x0a` concrete inputs execute the revm KZG precompile exactly. Symbolic KZG calls model broad exact failures such as invalid input length and version/hash mismatches where known, plus selected replayable success/failure witnesses. Any remaining feasible symbolic KZG space reports incomplete rather than being treated as proved safe. Symbolic length headers, symbolic modexp output lengths, out-of-bounds symbolic inputs, future/custom precompiles, and precompile gas/OOG behavior are not fully modeled. | +| Hard arithmetic | Bit-vector arithmetic is modeled through SMT. Forge canonicalizes small polynomial equalities over the exact 256-bit EVM word ring, including identities that wrap, and proves unsigned monotonic product and same-divisor quotient comparisons when path bounds show that every product fits in 256 bits. It first explores branches decided by those local checks and bounded concrete witnesses, then sends the remaining hard-arithmetic branches to the configured SMT solver. Expansion and fallback time are deliberately bounded; division, unsupported `EXP` base/exponent shapes, larger polynomials, and other solver-intractable nonlinear expressions can report incomplete or timeout. | +| Cheatcode surface | The common testing cheatcodes listed below are modeled for safe concrete/symbolic forms. Unsupported Foundry/VM compatibility cheatcodes, value-bearing cheatcode calls, delegatecall prank forms, symbolic `expectCall` gas, unsupported symbolic `vm.bound` ranges, and unsupported symbolic `assumeNoRevert` decodes/overlaps report incomplete. | +| Approximate/no-op cheatcodes | Some recognized Foundry helpers are accepted but not semantically checked under symbolic execution, including non-observable gas metering helpers, access-list/warm/cool helpers, `allowCheatcodes`, `sleep`, and breakpoints. Observable EVM-version helpers, gas snapshot/read helpers, and safe-memory expectation helpers report incomplete instead of fabricating results or silently accepting assertions. | +| Fork mutation during symbolic execution | Fork-backed setup is allowed before symbolic execution. Creating forks, selecting a different fork, or rolling/mutating fork blocks during symbolic execution is restricted and reports incomplete unless it stays on the already active fork in the supported form. | +| Filesystem, JSON/TOML, and prompt-shaped inputs | Environment reads and `ffi` are supported for concrete values and commands, with `ffi` gated by Forge's existing `--ffi` setting. Missing or unparsable env values, disabled or failing FFI, non-UTF8 stdout, artifact lookup failures, filesystem access, JSON/TOML parsing or serialization, and interactive prompt cheatcodes report incomplete. | +| Resource and scope bounds | `max_paths` / width, execution depth, calldata variant budget, solver query budget, and solver timeout can stop a run as incomplete. Dynamic ABI length settings, `invariant_depth`, and `symbolic.loop` define the explored input/sequence/loop scope; a `PASS` is only within those configured bounds, and skipped larger shapes, deeper sequences, or more loop iterations are not necessarily reported as incomplete. | + +Exact failure messages are preserved in the test output, for example +`unsupported symbolic execution feature: GAS/gasleft() not modeled`. + +For real-world bug-shaped examples that exercise the current modeled surface, +see the community-maintained +[`symbolic-bug-suite`](https://github.com/grandizzy/symbolic-bug-suite). Those +examples are written so a successful symbolic run reports a concrete +counterexample. + +## Cheatcodes + +Symbolic tests run through a symbolic cheatcode handler for the subset that can +be modeled safely. The supported surface includes: + +- `vm.assume`, `vm.bound`, `vm.skip`, `vm.assumeNoRevert` +- forge-std and DSTest assertion/failure signals +- `warp`, `roll`, `fee`, `chainId`, `difficulty`, `coinbase`, blockhash helpers +- `deal`, `store`, `load`, `etch`, `getCode`, `getDeployedCode` +- `prank`, `startPrank`, `stopPrank` +- `addr`, `sign`, `deriveKey`, `rememberKey`, `rememberKeys` +- `env*`, `envOr*`, `envExists` +- `ffi`, gated by Forge's existing `--ffi` setting +- console calls as no-ops +- logs and expectations such as `recordLogs`, `getRecordedLogs`, `expectEmit` +- call expectations and mocks for supported concrete and symbolic forms +- snapshots and state/storage helper calls used by symbolic tests + +If a cheatcode is not modeled, the executor reports an incomplete symbolic run +with a clear unsupported-feature reason. + +## Troubleshooting + +### `No tests found` for a `check*` or `prove*` function + +`check*` and `prove*` are symbolic entrypoints, not normal Forge tests. They are +discovered only when symbolic mode is enabled: + +```sh +forge test --symbolic --match-test check_my_property +``` + +If Forge still prints `No tests found in project! Forge looks for functions that +start with test`, check the following: + +1. The binary was built from a revision that includes native symbolic test + discovery. `forge test --help` should list `--symbolic` options, and the + source should include the `check*` / `prove*` symbolic entrypoint path. +2. The file is under the current project's configured test or source paths and + is compiled by this `forge test` invocation. A file outside the project, or + outside the active profile's `src` / `test` paths, can compile in another + project but will not be discovered here. +3. `--match-test` filters function names/signatures. Use `--match-contract` for + contract names: + + ```sh + forge test --symbolic --match-contract MySymbolicTest + ``` + +### `PASS` is surprising + +First check whether the property depends on one of the known limitations above. +A `PASS` is scoped to the current symbolic model and configured bounds; it does +not cover skipped dynamic lengths, deeper invariant sequences, larger loop +bounds, unmodeled gas behavior, arbitrary unknown external code, or +cryptographic preimage/collision properties. If the property should have a +counterexample within the modeled surface, reduce the example and try raising +`symbolic.max_paths`, `symbolic.max_depth`, `symbolic.max_solver_queries`, or +the relevant dynamic length / invariant depth settings. + +### `Incomplete` is not a proof + +An incomplete run means the executor stopped before establishing safety or +replaying a counterexample. To continue, adjust bounds, simplify the property, +avoid the unsupported construct, or file a minimal repro for a missing model. + +## Results + +At the crate boundary, symbolic execution returns: + +- `Safe`: all explored paths completed without a feasible failure. +- `Counterexample`: the solver found a model for a failing path. Forge must + replay this before reporting it to the user. +- `Incomplete`: execution stopped before a proof or replayed counterexample. + +Incomplete runs carry a stop reason: + +- `Stuck`: unsupported symbolic construct or configured width/depth/query limit. +- `RevertAll`: every explored path ended in an ordinary revert. +- `Timeout`: solver timeout or solver `unknown`. +- `Error`: backend, ABI, bytecode, or solver process failure. + +## Development Checks + +Useful checks while changing this crate: + +```sh +cargo fmt --check +cargo check -p foundry-evm-symbolic +cargo test -p foundry-evm-symbolic +cargo check -p forge +cargo test -p forge --test cli test_cmd::symbolic -- --nocapture +SYMBOLIC_CONFORMANCE=1 cargo test -p forge --test cli symbolic_conformance -- --nocapture +SYMBOLIC_LIMITS=1 cargo test -p forge --test cli symbolic_limits -- --nocapture +``` + +The conformance and limits suites are gated because they require Z3 and exercise +broader, slower symbolic behavior; PR CI skips them and the nightly `test-flaky` +workflow runs them with both variables set. The limits suite checks resource +boundaries such as execution depth, calldata budgets and invariant sequence +depth. diff --git a/crates/evm/symbolic/assets/symbolic-counterexample.schema.json b/crates/evm/symbolic/assets/symbolic-counterexample.schema.json new file mode 100644 index 0000000000000..13bd6adce7dea --- /dev/null +++ b/crates/evm/symbolic/assets/symbolic-counterexample.schema.json @@ -0,0 +1,221 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://foundry-rs.github.io/schemas/symbolic-counterexample.v1.schema.json", + "title": "Foundry symbolic counterexample artifact", + "description": "Stable replayable counterexample artifact emitted for forge symbolic test results.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "schema", + "kind", + "test", + "replay", + "replay_semantics", + "bounds", + "solver", + "assumptions", + "call_trace", + "calls" + ], + "properties": { + "schema_version": { + "type": "integer", + "const": 1 + }, + "schema": { + "type": "string", + "const": "foundry:symbolic.counterexample@v1" + }, + "kind": { + "type": "string", + "enum": ["single_call", "sequence"] + }, + "test": { + "type": "object", + "additionalProperties": false, + "required": ["contract", "test"], + "properties": { + "contract": { "type": "string" }, + "test": { "type": "string" } + } + }, + "replay": { + "$ref": "https://foundry-rs.github.io/schemas/symbolic-result.v1.schema.json#/$defs/replay" + }, + "replay_semantics": { + "type": "object", + "additionalProperties": false, + "required": ["fail_on_revert"], + "properties": { + "fail_on_revert": { + "type": "boolean", + "description": "Whether invariant sequence replay treats target-call reverts as failures." + } + } + }, + "bounds": { + "$ref": "https://foundry-rs.github.io/schemas/symbolic-result.v1.schema.json#/$defs/bounds" + }, + "solver": { + "$ref": "https://foundry-rs.github.io/schemas/symbolic-result.v1.schema.json#/$defs/solver" + }, + "assumptions": { + "type": "array", + "items": { + "$ref": "https://foundry-rs.github.io/schemas/symbolic-result.v1.schema.json#/$defs/assumption" + } + }, + "call_trace": { + "$ref": "https://foundry-rs.github.io/schemas/symbolic-result.v1.schema.json#/$defs/call_trace" + }, + "storage": { + "type": "array", + "items": { "$ref": "#/$defs/storage_assignment" } + }, + "invariant_failure": { + "$ref": "#/$defs/invariant_failure" + }, + "calls": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/call" } + } + }, + "$defs": { + "address": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]{40}$" + }, + "b256": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]{64}$" + }, + "quantity": { + "type": "string", + "pattern": "^0x(0|[1-9a-fA-F][0-9a-fA-F]*)$" + }, + "selector": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]{8}$" + }, + "storage_assignment": { + "type": "object", + "additionalProperties": false, + "required": ["address", "slot", "value"], + "properties": { + "address": { "$ref": "#/$defs/address" }, + "slot": { "$ref": "#/$defs/quantity" }, + "value": { "$ref": "#/$defs/quantity" } + } + }, + "invariant_failure": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "name"], + "properties": { + "kind": { + "type": "string", + "const": "predicate" + }, + "name": { + "type": "string" + }, + "site": { + "$ref": "#/$defs/invariant_failure_site" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["kind", "reverter", "selector", "fingerprint"], + "properties": { + "kind": { + "type": "string", + "const": "handler" + }, + "name": { + "type": "string" + }, + "reverter": { "$ref": "#/$defs/address" }, + "selector": { "$ref": "#/$defs/selector" }, + "fingerprint": { "$ref": "#/$defs/b256" } + } + } + ] + }, + "invariant_failure_site": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "target", "selector", "fingerprint"], + "properties": { + "kind": { + "type": "string", + "enum": ["sequence_call", "invariant", "after_invariant"] + }, + "target": { "$ref": "#/$defs/address" }, + "selector": { "$ref": "#/$defs/selector" }, + "fingerprint": { "$ref": "#/$defs/b256" } + } + }, + "call": { + "type": "object", + "additionalProperties": false, + "required": [ + "warp", + "roll", + "sender", + "target", + "calldata", + "value", + "contract_name", + "function_name", + "signature", + "args", + "raw_args" + ], + "properties": { + "warp": { + "type": ["string", "null"], + "pattern": "^0x(0|[1-9a-fA-F][0-9a-fA-F]*)$" + }, + "roll": { + "type": ["string", "null"], + "pattern": "^0x(0|[1-9a-fA-F][0-9a-fA-F]*)$" + }, + "sender": { + "$ref": "#/$defs/address" + }, + "target": { + "$ref": "#/$defs/address" + }, + "calldata": { + "type": "string", + "pattern": "^0x([0-9a-fA-F]{2})*$" + }, + "value": { + "type": ["string", "null"], + "pattern": "^0x(0|[1-9a-fA-F][0-9a-fA-F]*)$" + }, + "contract_name": { + "type": ["string", "null"] + }, + "function_name": { + "type": ["string", "null"] + }, + "signature": { + "type": ["string", "null"] + }, + "args": { + "type": ["string", "null"] + }, + "raw_args": { + "type": ["string", "null"] + } + } + } + } +} diff --git a/crates/evm/symbolic/assets/symbolic-result.schema.json b/crates/evm/symbolic/assets/symbolic-result.schema.json new file mode 100644 index 0000000000000..476e8c9fa4185 --- /dev/null +++ b/crates/evm/symbolic/assets/symbolic-result.schema.json @@ -0,0 +1,568 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://foundry-rs.github.io/schemas/symbolic-result.v1.schema.json", + "title": "Foundry symbolic test result", + "description": "Stable machine-readable result object emitted at test_results[*].symbolic for forge test --symbolic --json.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "status", + "incomplete", + "bounds", + "solver", + "assumptions", + "call_trace", + "replay", + "counterexample" + ], + "properties": { + "schema_version": { + "type": "integer", + "const": 1 + }, + "status": { + "type": "string", + "enum": ["pass", "fail_counterexample", "incomplete"] + }, + "incomplete": { + "oneOf": [ + { "type": "null" }, + { "$ref": "#/$defs/incomplete" } + ] + }, + "bounds": { + "$ref": "#/$defs/bounds" + }, + "solver": { + "$ref": "#/$defs/solver" + }, + "assumptions": { + "type": "array", + "items": { "$ref": "#/$defs/assumption" } + }, + "call_trace": { + "$ref": "#/$defs/call_trace" + }, + "replay": { + "$ref": "#/$defs/replay" + }, + "counterexample": { + "oneOf": [ + { "type": "null" }, + { "$ref": "#/$defs/counterexample" } + ] + }, + "corpus_seeds": { + "$ref": "#/$defs/corpus_seeds" + }, + "artifact": { + "$ref": "#/$defs/artifact_ref" + }, + "minimization": { + "$ref": "#/$defs/minimization" + } + }, + "allOf": [ + { + "if": { + "required": ["status"], + "properties": { + "status": { "const": "pass" } + } + }, + "then": { + "properties": { + "incomplete": { "type": "null" }, + "counterexample": { "type": "null" }, + "replay": { + "properties": { + "required": { "const": false }, + "status": { "const": "not_required" }, + "reason": { "type": "null" } + } + } + } + } + }, + { + "if": { + "required": ["status"], + "properties": { + "status": { "const": "fail_counterexample" } + } + }, + "then": { + "properties": { + "incomplete": { "type": "null" }, + "counterexample": { "$ref": "#/$defs/counterexample" }, + "replay": { + "properties": { + "required": { "const": true }, + "status": { "const": "confirmed" }, + "reason": { "type": "null" } + } + } + } + } + }, + { + "if": { + "required": ["status"], + "properties": { + "status": { "const": "incomplete" } + } + }, + "then": { + "properties": { + "incomplete": { "$ref": "#/$defs/incomplete" }, + "replay": { + "properties": { + "status": { + "enum": ["not_required", "mismatch", "error", "skipped"] + } + } + } + } + } + }, + { + "if": { + "required": ["replay"], + "properties": { + "replay": { + "required": ["required"], + "properties": { + "required": { "const": false } + } + } + } + }, + "then": { + "properties": { + "replay": { + "properties": { + "status": { "const": "not_required" }, + "reason": { "type": "null" } + } + } + } + } + }, + { + "if": { + "required": ["replay"], + "properties": { + "replay": { + "required": ["required"], + "properties": { + "required": { "const": true } + } + } + } + }, + "then": { + "properties": { + "replay": { + "properties": { + "status": { + "enum": ["confirmed", "mismatch", "error", "skipped"] + } + } + } + } + } + }, + { + "if": { + "required": ["replay"], + "properties": { + "replay": { + "required": ["status"], + "properties": { + "status": { "const": "confirmed" } + } + } + } + }, + "then": { + "properties": { + "replay": { + "properties": { + "required": { "const": true }, + "reason": { "type": "null" } + } + } + } + } + }, + { + "if": { + "required": ["call_trace"], + "properties": { + "call_trace": { + "required": ["available"], + "properties": { + "available": { "const": false } + } + } + } + }, + "then": { + "properties": { + "call_trace": { + "properties": { + "source": { "type": "null" }, + "format": { "type": "null" } + } + } + } + } + }, + { + "if": { + "required": ["call_trace"], + "properties": { + "call_trace": { + "required": ["available"], + "properties": { + "available": { "const": true } + } + } + } + }, + "then": { + "properties": { + "call_trace": { + "properties": { + "source": { "type": "string" }, + "format": { "type": "string" } + } + } + } + } + } + ], + "$defs": { + "incomplete": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "reason"], + "properties": { + "kind": { + "type": "string", + "enum": ["stuck", "revert_all", "timeout", "error"] + }, + "reason": { + "type": "string" + } + } + }, + "bounds": { + "type": "object", + "additionalProperties": false, + "required": [ + "timeout_seconds", + "loop_bound", + "max_depth", + "max_paths", + "invariant_depth", + "exploration_order", + "max_solver_queries", + "default_dynamic_length", + "max_dynamic_length", + "array_lengths", + "dynamic_lengths", + "default_array_lengths", + "default_bytes_lengths", + "max_calldata_bytes", + "symbolic_call_targets", + "storage_layout" + ], + "properties": { + "timeout_seconds": { + "type": ["integer", "null"], + "minimum": 0 + }, + "loop_bound": { + "type": ["integer", "null"], + "minimum": 0 + }, + "max_depth": { + "type": "integer", + "minimum": 0 + }, + "max_paths": { + "type": "integer", + "minimum": 0 + }, + "invariant_depth": { + "type": "integer", + "minimum": 0 + }, + "exploration_order": { + "type": "string", + "enum": ["bfs", "dfs"] + }, + "max_solver_queries": { + "type": "integer", + "minimum": 0 + }, + "default_dynamic_length": { + "type": "integer", + "minimum": 0 + }, + "max_dynamic_length": { + "type": "integer", + "minimum": 0 + }, + "array_lengths": { + "type": "array", + "items": { "type": "integer", "minimum": 0 } + }, + "dynamic_lengths": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { "type": "integer", "minimum": 0 } + } + }, + "default_array_lengths": { + "type": "array", + "items": { "type": "integer", "minimum": 0 } + }, + "default_bytes_lengths": { + "type": "array", + "items": { "type": "integer", "minimum": 0 } + }, + "max_calldata_bytes": { + "type": "integer", + "minimum": 0 + }, + "symbolic_call_targets": { + "type": "boolean" + }, + "storage_layout": { + "type": "string", + "enum": ["solidity", "generic", "zero_init"] + } + } + }, + "solver": { + "type": "object", + "additionalProperties": false, + "required": ["name", "command", "portfolio", "stats"], + "properties": { + "name": { + "type": "string" + }, + "command": { + "type": ["string", "null"] + }, + "portfolio": { + "type": "array", + "items": { "type": "string" } + }, + "stats": { + "$ref": "#/$defs/solver_stats" + } + } + }, + "solver_stats": { + "type": "object", + "additionalProperties": false, + "required": [ + "paths", + "solver_queries", + "smt_queries", + "sat_queries", + "model_queries", + "sat_cache_hits", + "model_cache_hits", + "heuristic_witnesses", + "solver_time_ms" + ], + "properties": { + "paths": { "type": "integer", "minimum": 0 }, + "solver_queries": { "type": "integer", "minimum": 0 }, + "smt_queries": { "type": "integer", "minimum": 0 }, + "sat_queries": { "type": "integer", "minimum": 0 }, + "model_queries": { "type": "integer", "minimum": 0 }, + "sat_cache_hits": { "type": "integer", "minimum": 0 }, + "model_cache_hits": { "type": "integer", "minimum": 0 }, + "heuristic_witnesses": { "type": "integer", "minimum": 0 }, + "solver_time_ms": { "type": "integer", "minimum": 0 }, + "smt_input_bytes": { "type": "integer", "minimum": 0 }, + "smt_max_query_bytes": { "type": "integer", "minimum": 0 }, + "smt_build_time_ms": { "type": "integer", "minimum": 0 }, + "smt_max_query_time_ms": { "type": "integer", "minimum": 0 } + } + }, + "assumption": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "description"], + "properties": { + "kind": { + "type": "string" + }, + "description": { + "type": "string" + } + } + }, + "call_trace": { + "type": "object", + "additionalProperties": false, + "required": ["available", "source", "format"], + "properties": { + "available": { + "type": "boolean" + }, + "source": { + "type": ["string", "null"] + }, + "format": { + "type": ["string", "null"] + } + } + }, + "replay": { + "type": "object", + "additionalProperties": false, + "required": ["required", "status", "reason"], + "properties": { + "required": { + "type": "boolean" + }, + "status": { + "type": "string", + "enum": ["not_required", "confirmed", "mismatch", "error", "skipped"] + }, + "reason": { + "type": ["string", "null"] + } + } + }, + "counterexample": { + "type": "object", + "additionalProperties": false, + "required": ["calldata", "args", "raw_args", "value"], + "properties": { + "calldata": { + "type": "string", + "pattern": "^0x([0-9a-fA-F]{2})*$" + }, + "args": { + "type": ["string", "null"] + }, + "raw_args": { + "type": ["string", "null"] + }, + "value": { + "type": ["string", "null"] + } + } + }, + "corpus_seeds": { + "type": "object", + "additionalProperties": false, + "required": ["corpus_dir", "limit", "loaded", "skipped", "used"], + "properties": { + "corpus_dir": { + "type": ["string", "null"] + }, + "limit": { + "type": "integer", + "minimum": 0 + }, + "loaded": { + "type": "integer", + "minimum": 0 + }, + "skipped": { + "type": "integer", + "minimum": 0 + }, + "used": { + "type": "array", + "items": { "$ref": "#/$defs/corpus_seed" } + } + } + }, + "corpus_seed": { + "type": "object", + "additionalProperties": false, + "required": ["path", "calldata"], + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "calldata": { + "type": "string", + "pattern": "^0x([0-9a-fA-F]{2})*$" + } + } + }, + "artifact_ref": { + "type": "object", + "additionalProperties": false, + "required": ["schema", "path"], + "properties": { + "schema": { + "type": "string", + "const": "foundry:symbolic.counterexample@v1" + }, + "path": { + "type": "string", + "minLength": 1 + } + } + }, + "minimization": { + "type": "object", + "additionalProperties": false, + "required": [ + "original", + "minimized", + "attempts", + "accepted", + "original_calldata_bytes", + "minimized_calldata_bytes" + ], + "properties": { + "original": { + "$ref": "#/$defs/artifact_ref" + }, + "minimized": { + "$ref": "#/$defs/artifact_ref" + }, + "attempts": { + "type": "integer", + "minimum": 0 + }, + "accepted": { + "type": "integer", + "minimum": 0 + }, + "original_calldata_bytes": { + "type": "integer", + "minimum": 0 + }, + "minimized_calldata_bytes": { + "type": "integer", + "minimum": 0 + }, + "original_sequence_len": { + "type": "integer", + "minimum": 0 + }, + "minimized_sequence_len": { + "type": "integer", + "minimum": 0 + } + } + } + } +} diff --git a/crates/evm/symbolic/src/abi.rs b/crates/evm/symbolic/src/abi.rs new file mode 100644 index 0000000000000..944e441e189c4 --- /dev/null +++ b/crates/evm/symbolic/src/abi.rs @@ -0,0 +1,976 @@ +use super::{runtime::*, *}; + +#[derive(Clone, Debug)] +pub(super) struct SymbolicCalldata { + bytes: SymBytes, + inputs: Vec, + constraints: Vec, +} + +impl SymbolicCalldata { + pub(super) fn variants( + function: &Function, + config: &SymbolicConfig, + cx: &mut SymCx, + ) -> Result, SymbolicError> { + Self::variants_with_prefix(function, config, cx, "calldata") + } + + pub(super) fn selector_only( + cx: &mut SymCx, + function: &Function, + ) -> Result { + if !function.inputs.is_empty() { + return Err(SymbolicError::UnsupportedAbi(format!( + "symbolic invariant `{}` must take no parameters", + function.name + ))); + } + Ok(Self { + bytes: SymBytes::concrete(cx, function.selector().to_vec()), + inputs: Vec::new(), + constraints: Vec::new(), + }) + } + + pub(super) fn variants_with_prefix( + function: &Function, + config: &SymbolicConfig, + cx: &mut SymCx, + prefix: &str, + ) -> Result, SymbolicError> { + let variant_limit = calldata_variant_limit(config); + let mut builder = SymbolicAbiBuilder { config, cx }; + let mut variants = vec![(SymbolicAbiState::default(), Vec::new())]; + for (idx, input) in function.inputs.iter().enumerate() { + let ty = input.selector_type(); + let mut next_variants = Vec::new(); + for (state, inputs) in variants { + for (state, input) in SymbolicInput::variants( + &mut builder, + state, + prefix, + idx, + Some(input.name.as_str()), + ty.as_ref(), + )? { + let mut inputs = inputs.clone(); + inputs.push(input); + push_variant(&mut next_variants, (state, inputs), variant_limit)?; + } + } + variants = next_variants; + } + + validate_positional_dynamic_lengths( + config, + variants.iter().map(|(state, _)| state.positional_dynamic_index).max().unwrap_or(0), + )?; + + // Explore equal-address cases as variants sharing a representative account. + let mut partitioned = Vec::new(); + for (state, inputs) in variants { + let address_indices = inputs + .iter() + .enumerate() + .filter_map(|(idx, input)| match input.value { + SymbolicAbiValue::Address { .. } => Some(idx), + _ => None, + }) + .collect::>(); + for partition in set_partitions(address_indices.len(), variant_limit)? { + let mut state = state.clone(); + let mut inputs = inputs.clone(); + let mut representatives = Vec::with_capacity(partition.len()); + for block in partition { + let representative = match &inputs[address_indices[block[0]]].value { + SymbolicAbiValue::Address { word } => word.clone(), + _ => unreachable!("address index must refer to an address input"), + }; + for member in block { + let replaced = match &mut inputs[address_indices[member]].value { + SymbolicAbiValue::Address { word } => { + let replaced = word.clone(); + *word = representative.clone(); + replaced + } + _ => unreachable!("address index must refer to an address input"), + }; + if replaced != representative { + for constraint in &mut state.constraints { + *constraint = constraint.fold_exprs(builder.cx, &mut |_, expr| { + if expr == replaced { representative.clone() } else { expr } + }); + } + } + } + representatives.push(representative); + } + for (idx, representative) in representatives.iter().enumerate() { + for other in &representatives[idx + 1..] { + let eq = SymBoolExpr::eq(builder.cx, representative.clone(), other.clone()); + state.constraints.push(eq.not(builder.cx)); + } + } + push_variant(&mut partitioned, (state, inputs), variant_limit)?; + } + } + + let mut out = Vec::with_capacity(partitioned.len()); + for (state, inputs) in partitioned { + let selector = SymBytes::concrete(builder.cx, function.selector().to_vec()); + let encoded = builder.encode_sequence(inputs.iter().map(|input| &input.value)); + let bytes = SymBytes::concat(builder.cx, [selector, encoded]); + if bytes.len() > config.max_calldata_bytes as usize { + return Err(SymbolicError::Unsupported( + "symbolic calldata size exceeds configured max", + )); + } + + out.push(Self { bytes, inputs, constraints: state.constraints }); + } + Ok(out) + } + + pub(super) fn call_data(&self, cx: &mut SymCx) -> SymCalldata { + SymCalldata::from_bytes(cx, self.bytes.clone()) + } + + /// Returns symbolic calldata constraints. + pub(super) fn constraints(&self) -> &[SymBoolExpr] { + &self.constraints + } + + /// Consumes this symbolic calldata into its constraints. + pub(super) fn into_constraints(self) -> Vec { + self.constraints + } + + pub(super) fn model_to_args( + &self, + cx: &mut SymCx, + model: &(impl SymbolicModelLookup + ?Sized), + ) -> Result, SymbolicError> { + self.inputs.iter().map(|input| input.value.model_value(cx, model)).collect() + } + + pub(super) fn seed_model( + &self, + cx: &mut SymCx, + seed: &SymbolicConcreteInput, + ) -> Option { + if seed.args.len() != self.inputs.len() { + return None; + } + + let mut model = SymbolicModel::default(); + for (input, arg) in self.inputs.iter().zip(&seed.args) { + if !input.value.seed_model_value(cx, &mut model, arg) { + return None; + } + } + + for constraint in &self.constraints { + if constraint.eval_model_if_complete(&model).ok().flatten() != Some(true) { + return None; + } + } + + let calldata = self.bytes.eval_model(cx, &model).ok()?; + (calldata.as_slice() == seed.calldata.as_ref()).then_some(model) + } +} + +#[derive(Clone, Debug)] +pub(super) struct SymbolicInput { + value: SymbolicAbiValue, +} + +impl SymbolicInput { + pub(super) fn variants<'a, 'cx>( + builder: &mut SymbolicAbiBuilder<'a, 'cx>, + state: SymbolicAbiState, + prefix: &str, + idx: usize, + abi_name: Option<&str>, + ty: &str, + ) -> Result, SymbolicError> { + let ty = + DynSolType::parse(ty).map_err(|_| SymbolicError::UnsupportedAbi(ty.to_string()))?; + let name = format!("{prefix}_{idx}"); + let aliases = + abi_name.filter(|name| !name.is_empty()).map(str::to_string).into_iter().collect(); + builder.value_variants(state, name, aliases, &ty).map(|variants| { + variants.into_iter().map(|(state, value)| (state, Self { value })).collect() + }) + } +} + +#[derive(Clone, Debug, Default)] +pub(super) struct SymbolicAbiState { + constraints: Vec, + positional_dynamic_index: usize, +} + +#[derive(Debug)] +pub(super) struct SymbolicAbiBuilder<'a, 'cx> { + config: &'a SymbolicConfig, + cx: &'cx mut SymCx, +} + +impl<'a, 'cx> SymbolicAbiBuilder<'a, 'cx> { + pub(super) fn value( + &mut self, + state: &mut SymbolicAbiState, + name: String, + aliases: Vec, + ty: &DynSolType, + ) -> Result { + Ok(match ty { + DynSolType::Bool => { + let word = self.fresh_word(&name); + state.constraints.push(SymBoolExpr::cmp_word_const( + self.cx, + SymCmpOp::Ult, + &word, + U256::from(2), + )); + SymbolicAbiValue::Bool { word } + } + DynSolType::Uint(bits) => { + let word = self.fresh_word(&name); + self.constrain_uint(state, &word, *bits); + SymbolicAbiValue::Uint { bits: *bits, word } + } + DynSolType::Int(bits) => { + let word = self.fresh_word(&name); + self.constrain_int(state, &word, *bits); + SymbolicAbiValue::Int { bits: *bits, word } + } + DynSolType::FixedBytes(size) => { + let bytes = (0..*size) + .map(|idx| self.fresh_byte(state, &format!("{name}_{idx}"), false)) + .collect(); + SymbolicAbiValue::FixedBytes { bytes: SymBytes::exprs(self.cx, bytes), size: *size } + } + DynSolType::Address => { + let word = self.fresh_word(&name); + self.constrain_uint(state, &word, 160); + SymbolicAbiValue::Address { word } + } + DynSolType::Function => { + return Err(SymbolicError::UnsupportedAbi("function".to_string())); + } + DynSolType::Bytes => { + let len = self.next_dynamic_length(state, &name, &aliases, DynamicKind::Bytes)?; + let bytes = (0..len) + .map(|idx| self.fresh_byte(state, &format!("{name}_{idx}"), false)) + .collect(); + SymbolicAbiValue::Bytes { + len: SymExpr::constant(self.cx, U256::from(len)), + bytes: SymBytes::exprs(self.cx, bytes), + } + } + DynSolType::String => { + let len = self.next_dynamic_length(state, &name, &aliases, DynamicKind::String)?; + let bytes = (0..len) + .map(|idx| self.fresh_byte(state, &format!("{name}_{idx}"), true)) + .collect(); + SymbolicAbiValue::String { bytes: SymBytes::exprs(self.cx, bytes) } + } + DynSolType::Array(inner) => { + let len = self.next_dynamic_length(state, &name, &aliases, DynamicKind::Array)?; + SymbolicAbiValue::Array { + elements: (0..len) + .map(|idx| { + self.value( + state, + format!("{name}_{idx}"), + child_aliases(&aliases, idx), + inner, + ) + }) + .collect::, _>>()?, + } + } + DynSolType::FixedArray(inner, len) => SymbolicAbiValue::FixedArray { + elements: (0..*len) + .map(|idx| { + self.value( + state, + format!("{name}_{idx}"), + child_aliases(&aliases, idx), + inner, + ) + }) + .collect::, _>>()?, + }, + DynSolType::Tuple(types) => SymbolicAbiValue::Tuple { + elements: types + .iter() + .enumerate() + .map(|(idx, ty)| { + self.value(state, format!("{name}_{idx}"), child_aliases(&aliases, idx), ty) + }) + .collect::, _>>()?, + }, + DynSolType::CustomStruct { tuple, .. } => SymbolicAbiValue::Tuple { + elements: tuple + .iter() + .enumerate() + .map(|(idx, ty)| { + self.value(state, format!("{name}_{idx}"), child_aliases(&aliases, idx), ty) + }) + .collect::, _>>()?, + }, + }) + } + + pub(super) fn value_variants( + &mut self, + state: SymbolicAbiState, + name: String, + aliases: Vec, + ty: &DynSolType, + ) -> Result, SymbolicError> { + Ok(match ty { + DynSolType::Bytes => { + let mut state = state; + let lengths = self.next_dynamic_length_options( + &mut state, + &name, + &aliases, + DynamicKind::Bytes, + )?; + let limit = calldata_variant_limit(self.config); + let mut variants = Vec::new(); + for len in lengths { + let mut state = state.clone(); + let bytes = (0..len as usize) + .map(|idx| self.fresh_byte(&mut state, &format!("{name}_{idx}"), false)) + .collect(); + let value = SymbolicAbiValue::Bytes { + len: SymExpr::constant(self.cx, U256::from(len)), + bytes: SymBytes::exprs(self.cx, bytes), + }; + push_variant(&mut variants, (state, value), limit)?; + } + variants + } + DynSolType::String => { + let mut state = state; + let lengths = self.next_dynamic_length_options( + &mut state, + &name, + &aliases, + DynamicKind::String, + )?; + let limit = calldata_variant_limit(self.config); + let mut variants = Vec::new(); + for len in lengths { + let mut state = state.clone(); + let bytes = (0..len as usize) + .map(|idx| self.fresh_byte(&mut state, &format!("{name}_{idx}"), true)) + .collect(); + let value = SymbolicAbiValue::String { bytes: SymBytes::exprs(self.cx, bytes) }; + push_variant(&mut variants, (state, value), limit)?; + } + variants + } + DynSolType::Array(inner) => { + let mut state = state; + let lengths = self.next_dynamic_length_options( + &mut state, + &name, + &aliases, + DynamicKind::Array, + )?; + let limit = calldata_variant_limit(self.config); + let mut variants = Vec::new(); + for len in lengths { + for (state, elements) in self.array_elements_variants( + state.clone(), + &name, + &aliases, + inner, + len as usize, + )? { + push_variant( + &mut variants, + (state, SymbolicAbiValue::Array { elements }), + limit, + )?; + } + } + variants + } + DynSolType::FixedArray(inner, len) => self + .array_elements_variants(state, &name, &aliases, inner, *len) + .map(|variants| { + variants + .into_iter() + .map(|(state, elements)| (state, SymbolicAbiValue::FixedArray { elements })) + .collect() + })?, + DynSolType::Tuple(types) => self + .tuple_elements_variants(state, &name, &aliases, types)? + .into_iter() + .map(|(state, elements)| (state, SymbolicAbiValue::Tuple { elements })) + .collect(), + DynSolType::CustomStruct { tuple, .. } => self + .tuple_elements_variants(state, &name, &aliases, tuple)? + .into_iter() + .map(|(state, elements)| (state, SymbolicAbiValue::Tuple { elements })) + .collect(), + _ => { + let mut state = state; + let value = self.value(&mut state, name, aliases, ty)?; + vec![(state, value)] + } + }) + } + + pub(super) fn array_elements_variants( + &mut self, + state: SymbolicAbiState, + name: &str, + aliases: &[String], + inner: &DynSolType, + len: usize, + ) -> Result)>, SymbolicError> { + let limit = calldata_variant_limit(self.config); + let mut variants = vec![(state, Vec::with_capacity(len))]; + for idx in 0..len { + let mut next_variants = Vec::new(); + for (state, elements) in variants { + for (state, value) in self.value_variants( + state, + format!("{name}_{idx}"), + child_aliases(aliases, idx), + inner, + )? { + let mut elements = elements.clone(); + elements.push(value); + push_variant(&mut next_variants, (state, elements), limit)?; + } + } + variants = next_variants; + } + Ok(variants) + } + + pub(super) fn tuple_elements_variants( + &mut self, + state: SymbolicAbiState, + name: &str, + aliases: &[String], + types: &[DynSolType], + ) -> Result)>, SymbolicError> { + let limit = calldata_variant_limit(self.config); + let mut variants = vec![(state, Vec::with_capacity(types.len()))]; + for (idx, ty) in types.iter().enumerate() { + let mut next_variants = Vec::new(); + for (state, elements) in variants { + for (state, value) in self.value_variants( + state, + format!("{name}_{idx}"), + child_aliases(aliases, idx), + ty, + )? { + let mut elements = elements.clone(); + elements.push(value); + push_variant(&mut next_variants, (state, elements), limit)?; + } + } + variants = next_variants; + } + Ok(variants) + } + + pub(super) fn fresh_word(&mut self, name: &str) -> SymExpr { + let symbol = self.cx.intern(name); + self.cx.mark_replayable_input(symbol); + SymExpr::get_var(self.cx, symbol) + } + + pub(super) fn fresh_byte( + &mut self, + state: &mut SymbolicAbiState, + name: &str, + printable: bool, + ) -> SymExpr { + let word = self.fresh_word(name); + state.constraints.push(SymBoolExpr::cmp_word_const( + self.cx, + SymCmpOp::Ult, + &word, + U256::from(256), + )); + if printable { + state.constraints.push(SymBoolExpr::cmp_word_const( + self.cx, + SymCmpOp::Uge, + &word, + U256::from(0x20), + )); + state.constraints.push(SymBoolExpr::cmp_word_const( + self.cx, + SymCmpOp::Ule, + &word, + U256::from(0x7e), + )); + } + word + } + + pub(super) fn next_dynamic_length( + &self, + state: &mut SymbolicAbiState, + name: &str, + aliases: &[String], + kind: DynamicKind, + ) -> Result { + Ok(first_dynamic_length( + &self.next_dynamic_length_options(state, name, aliases, kind)?, + "symbolic dynamic length", + )? as usize) + } + + pub(super) fn next_dynamic_length_options( + &self, + state: &mut SymbolicAbiState, + name: &str, + aliases: &[String], + kind: DynamicKind, + ) -> Result, SymbolicError> { + let named_lengths = std::iter::once(name) + .chain(aliases.iter().map(String::as_str)) + .find_map(|name| self.config.dynamic_lengths.get(name)); + + let lengths = if let Some(lengths) = named_lengths { + lengths.clone() + } else if let Some(lengths) = kind.default_lengths(self.config) { + lengths.to_vec() + } else if let Some(len) = + self.config.array_lengths.get(state.positional_dynamic_index).copied() + { + state.positional_dynamic_index += 1; + vec![len] + } else { + vec![self.config.default_dynamic_length] + }; + + if lengths.is_empty() { + return Err(SymbolicError::UnsupportedAbi( + "symbolic dynamic length set must not be empty".to_string(), + )); + } + for len in &lengths { + if *len > self.config.max_dynamic_length { + return Err(SymbolicError::UnsupportedAbi(format!( + "symbolic {} length {len} exceeds max_dynamic_length {}", + kind.name(), + self.config.max_dynamic_length + ))); + } + } + Ok(lengths) + } + + pub(super) fn constrain_uint( + &mut self, + state: &mut SymbolicAbiState, + word: &SymExpr, + bits: usize, + ) { + if bits < 256 { + state.constraints.push(SymBoolExpr::cmp_word_const( + self.cx, + SymCmpOp::Ult, + word, + U256::ONE << bits, + )); + } + } + + pub(super) fn constrain_int( + &mut self, + state: &mut SymbolicAbiState, + word: &SymExpr, + bits: usize, + ) { + if bits < 256 { + let byte_index = U256::from(bits / 8 - 1); + let signextended = signextend_word(self.cx, byte_index, word.clone()); + state.constraints.push(SymBoolExpr::eq(self.cx, word.clone(), signextended)); + } + } + + pub(super) fn encode_sequence<'v>( + &mut self, + values: impl IntoIterator, + ) -> SymBytes { + encode_sequence(self.cx, values) + } +} + +/// Validates that positional ABI length config can be consumed by at least one expanded variant. +fn validate_positional_dynamic_lengths( + config: &SymbolicConfig, + max_positional_dynamic_index: usize, +) -> Result<(), SymbolicError> { + if config.array_lengths.len() > max_positional_dynamic_index { + return Err(SymbolicError::UnsupportedAbi(format!( + "symbolic.array_lengths has {} entries but ABI used at most {} positional dynamic leaves", + config.array_lengths.len(), + max_positional_dynamic_index + ))); + } + Ok(()) +} + +/// Enumerates address partitions within the variant budget, all-distinct first. +fn set_partitions(len: usize, limit: usize) -> Result>>, SymbolicError> { + let mut out = Vec::new(); + let mut blocks = Vec::>::new(); + fn go( + idx: usize, + len: usize, + blocks: &mut Vec>, + out: &mut Vec>>, + limit: usize, + ) -> Result<(), SymbolicError> { + if idx == len { + return push_variant(out, blocks.clone(), limit); + } + blocks.push(vec![idx]); + go(idx + 1, len, blocks, out, limit)?; + blocks.pop(); + for block in 0..blocks.len() { + blocks[block].push(idx); + go(idx + 1, len, blocks, out, limit)?; + blocks[block].pop(); + } + Ok(()) + } + go(0, len, &mut blocks, &mut out, limit)?; + Ok(out) +} + +/// Returns the maximum number of calldata variants allowed during ABI expansion. +fn calldata_variant_limit(config: &SymbolicConfig) -> usize { + config.path_width().max(1) as usize +} + +/// Adds one expansion variant while enforcing the configured symbolic path-width budget. +fn push_variant(variants: &mut Vec, variant: T, limit: usize) -> Result<(), SymbolicError> { + if variants.len() >= limit { + return Err(SymbolicError::CalldataVariantLimit(limit)); + } + variants.push(variant); + Ok(()) +} + +#[derive(Clone, Copy)] +pub(super) enum DynamicKind { + Array, + Bytes, + String, +} + +impl DynamicKind { + pub(super) const fn name(self) -> &'static str { + match self { + Self::Array => "array", + Self::Bytes => "bytes", + Self::String => "string", + } + } + + pub(super) fn default_lengths(self, config: &SymbolicConfig) -> Option<&[u32]> { + match self { + Self::Array if !config.default_array_lengths.is_empty() => { + Some(&config.default_array_lengths) + } + Self::Bytes | Self::String if !config.default_bytes_lengths.is_empty() => { + Some(&config.default_bytes_lengths) + } + _ => None, + } + } +} + +pub(super) fn first_dynamic_length(lengths: &[u32], field: &str) -> Result { + lengths + .first() + .copied() + .ok_or_else(|| SymbolicError::UnsupportedAbi(format!("{field} must not be empty"))) +} + +pub(super) fn child_aliases(aliases: &[String], idx: usize) -> Vec { + aliases.iter().map(|alias| format!("{alias}_{idx}")).collect() +} + +#[derive(Clone, Debug)] +pub(super) enum SymbolicAbiValue { + Bool { word: SymExpr }, + Uint { bits: usize, word: SymExpr }, + Int { bits: usize, word: SymExpr }, + FixedBytes { bytes: SymBytes, size: usize }, + Address { word: SymExpr }, + Bytes { len: SymExpr, bytes: SymBytes }, + String { bytes: SymBytes }, + Array { elements: Vec }, + FixedArray { elements: Vec }, + Tuple { elements: Vec }, +} + +impl SymbolicAbiValue { + /// Returns whether `is_dynamic` holds. + pub(super) fn is_dynamic(&self) -> bool { + match self { + Self::Bool { .. } + | Self::Uint { .. } + | Self::Int { .. } + | Self::FixedBytes { .. } + | Self::Address { .. } => false, + Self::Bytes { .. } | Self::String { .. } | Self::Array { .. } => true, + Self::FixedArray { elements } | Self::Tuple { elements } => { + elements.iter().any(Self::is_dynamic) + } + } + } + + pub(super) fn head_size(&self) -> usize { + if self.is_dynamic() { + 32 + } else { + match self { + Self::Bool { .. } + | Self::Uint { .. } + | Self::Int { .. } + | Self::FixedBytes { .. } + | Self::Address { .. } => 32, + Self::FixedArray { elements } | Self::Tuple { elements } => { + elements.iter().map(Self::head_size).sum() + } + Self::Bytes { .. } | Self::String { .. } | Self::Array { .. } => 32, + } + } + } + + pub(super) fn model_value( + &self, + cx: &mut SymCx, + model: &(impl SymbolicModelLookup + ?Sized), + ) -> Result { + Ok(match self { + Self::Bool { word } => DynSolValue::Bool(!word.eval_model(model)?.is_zero()), + Self::Uint { bits, word } => { + DynSolValue::Uint(mask_bits(word.eval_model(model)?, *bits), *bits) + } + Self::Int { bits, word } => { + DynSolValue::Int(I256::from_raw(word.eval_model(model)?), *bits) + } + Self::FixedBytes { bytes, size } => { + let mut word = [0u8; 32]; + for (idx, out) in word.iter_mut().enumerate().take(bytes.len()) { + *out = bytes.byte(cx, idx).eval_model(model)?.to::(); + } + DynSolValue::FixedBytes(B256::from(word), *size) + } + Self::Address { word } => { + DynSolValue::Address(word_to_address(word.eval_model(model)?)) + } + Self::Bytes { len, bytes } => { + let len = len.eval_model(model)?; + let len = usize::try_from(len) + .ok() + .filter(|len| *len <= bytes.len()) + .ok_or_else(|| SymbolicError::Solver("invalid symbolic bytes length".into()))?; + let mut bytes = bytes.eval_model(cx, model)?; + bytes.truncate(len); + DynSolValue::Bytes(bytes) + } + Self::String { bytes } => { + let bytes = bytes.eval_model(cx, model)?; + let value = String::from_utf8(bytes).map_err(|err| { + SymbolicError::Solver(format!("invalid symbolic string model: {err}")) + })?; + DynSolValue::String(value) + } + Self::Array { elements } => DynSolValue::Array( + elements + .iter() + .map(|value| value.model_value(cx, model)) + .collect::, _>>()?, + ), + Self::FixedArray { elements } => DynSolValue::FixedArray( + elements + .iter() + .map(|value| value.model_value(cx, model)) + .collect::, _>>()?, + ), + Self::Tuple { elements } => DynSolValue::Tuple( + elements + .iter() + .map(|value| value.model_value(cx, model)) + .collect::, _>>()?, + ), + }) + } + + pub(super) fn seed_model_value( + &self, + cx: &mut SymCx, + model: &mut SymbolicModel, + value: &DynSolValue, + ) -> bool { + match (self, value) { + (Self::Bool { word }, DynSolValue::Bool(value)) => { + word.assign_model_value(model, U256::from(*value as u8)) + } + (Self::Uint { bits, word }, DynSolValue::Uint(value, value_bits)) + if bits == value_bits => + { + word.assign_model_value(model, *value) + } + (Self::Int { bits, word }, DynSolValue::Int(value, value_bits)) + if bits == value_bits => + { + word.assign_model_value(model, value.into_raw()) + } + (Self::FixedBytes { bytes, size }, DynSolValue::FixedBytes(value, value_size)) + if size == value_size => + { + seed_model_bytes(cx, model, bytes, &value.as_slice()[..*size]) + } + (Self::Address { word }, DynSolValue::Address(value)) => { + word.assign_model_value(model, address_word(*value)) + } + (Self::Bytes { len, bytes }, DynSolValue::Bytes(value)) => { + len.assign_model_value(model, U256::from(value.len())) + && seed_model_bytes(cx, model, bytes, value) + } + (Self::String { bytes }, DynSolValue::String(value)) => { + seed_model_bytes(cx, model, bytes, value.as_bytes()) + } + (Self::Array { elements }, DynSolValue::Array(values)) + | (Self::FixedArray { elements }, DynSolValue::FixedArray(values)) + | (Self::Tuple { elements }, DynSolValue::Tuple(values)) => { + seed_model_elements(cx, model, elements, values) + } + (Self::Tuple { elements }, DynSolValue::CustomStruct { tuple, .. }) => { + seed_model_elements(cx, model, elements, tuple) + } + _ => false, + } + } +} + +fn seed_model_elements( + cx: &mut SymCx, + model: &mut SymbolicModel, + elements: &[SymbolicAbiValue], + values: &[DynSolValue], +) -> bool { + elements.len() == values.len() + && elements + .iter() + .zip(values) + .all(|(element, value)| element.seed_model_value(cx, model, value)) +} + +fn seed_model_bytes( + cx: &mut SymCx, + model: &mut SymbolicModel, + bytes: &SymBytes, + value: &[u8], +) -> bool { + bytes.len() == value.len() + && value + .iter() + .enumerate() + .all(|(idx, byte)| bytes.byte(cx, idx).assign_model_value(model, U256::from(*byte))) +} + +pub(super) fn encode_sequence<'a>( + cx: &mut SymCx, + values: impl IntoIterator, +) -> SymBytes { + let values = values.into_iter().collect::>(); + let head_size = values.iter().map(|value| value.head_size()).sum::(); + let mut head = Vec::with_capacity(values.len()); + let mut tail = Vec::new(); + let mut tail_len = 0usize; + + for value in values { + if value.is_dynamic() { + let offset = SymExpr::constant(cx, U256::from(head_size + tail_len)); + head.push(offset.into_bytes(cx)); + let body = encode_dynamic_body(cx, value); + tail_len += body.len(); + tail.push(body); + } else { + head.push(encode_static(cx, value)); + } + } + + SymBytes::concat(cx, head.into_iter().chain(tail)) +} + +fn encode_static(cx: &mut SymCx, value: &SymbolicAbiValue) -> SymBytes { + match value { + SymbolicAbiValue::Bool { word } + | SymbolicAbiValue::Uint { word, .. } + | SymbolicAbiValue::Int { word, .. } + | SymbolicAbiValue::Address { word } => word.clone().into_bytes(cx), + SymbolicAbiValue::FixedBytes { bytes, .. } => { + let padding = SymBytes::concrete(cx, vec![0; 32usize.saturating_sub(bytes.len())]); + SymBytes::concat(cx, [bytes.clone(), padding]) + } + SymbolicAbiValue::FixedArray { elements } | SymbolicAbiValue::Tuple { elements } => { + encode_sequence(cx, elements.iter()) + } + SymbolicAbiValue::Bytes { .. } + | SymbolicAbiValue::String { .. } + | SymbolicAbiValue::Array { .. } => unreachable!("dynamic ABI value encoded as static"), + } +} + +fn encode_dynamic_body(cx: &mut SymCx, value: &SymbolicAbiValue) -> SymBytes { + match value { + SymbolicAbiValue::Bytes { len, bytes } => { + encode_packed_bytes_with_len(cx, len.clone(), bytes) + } + SymbolicAbiValue::String { bytes } => { + let len = SymExpr::constant(cx, U256::from(bytes.len())); + encode_packed_bytes_with_len(cx, len, bytes) + } + SymbolicAbiValue::Array { elements } => { + let len = SymExpr::constant(cx, U256::from(elements.len())); + let len = len.into_bytes(cx); + let elements = encode_sequence(cx, elements.iter()); + SymBytes::concat(cx, [len, elements]) + } + SymbolicAbiValue::FixedArray { elements } | SymbolicAbiValue::Tuple { elements } => { + encode_sequence(cx, elements.iter()) + } + SymbolicAbiValue::Bool { .. } + | SymbolicAbiValue::Uint { .. } + | SymbolicAbiValue::Int { .. } + | SymbolicAbiValue::FixedBytes { .. } + | SymbolicAbiValue::Address { .. } => unreachable!("static ABI value encoded as dynamic"), + } +} + +pub(super) fn encode_packed_bytes_with_len( + cx: &mut SymCx, + len: SymExpr, + bytes: &SymBytes, +) -> SymBytes { + let padded_len = bytes.len().next_multiple_of(32); + let len = len.into_bytes(cx); + let padding = SymBytes::concrete(cx, vec![0; padded_len - bytes.len()]); + SymBytes::concat(cx, [len, bytes.clone(), padding]) +} diff --git a/crates/evm/symbolic/src/consts.rs b/crates/evm/symbolic/src/consts.rs new file mode 100644 index 0000000000000..c75c9a3ad60a0 --- /dev/null +++ b/crates/evm/symbolic/src/consts.rs @@ -0,0 +1,43 @@ +use alloy_primitives::{Address, address}; +use std::time::Duration; + +// HD wallet key derivation +pub(crate) const DEFAULT_DERIVATION_PATH_PREFIX: &str = "m/44'/60'/0'/0/"; +pub(crate) const MAX_REMEMBER_KEYS: u32 = 64; +pub(crate) const SYMBOLIC_VM_COMPAT_ADDRESS: Address = + address!("0xF3993A62377BCd56AE39D773740A5390411E8BC9"); + +// Symbolic exponentiation limits +pub(crate) const SYMBOLIC_EXP_CONCRETE_EXPONENT_LIMIT: u64 = 32; +pub(crate) const CONCRETE_BASE_SYMBOLIC_EXPONENT_LIMIT: u64 = 256; + +// ABI encoding lengths +pub(crate) const ERROR_DATA_MIN_LEN: usize = 68; // selector (4) + offset (32) + length (32) + +// Precompile address layout +pub(crate) const PRECOMPILE_ADDRESS_LEADING_ZEROS: usize = 19; + +// Solver subprocess supervision. +pub(crate) const SOLVER_CANCEL_CHECK_INTERVAL: Duration = Duration::from_millis(50); + +// Portfolio scheduler launch delays. +pub(crate) const SECOND_PORTFOLIO_SOLVER_DELAY: Duration = Duration::from_millis(100); +pub(crate) const RESCUE_PORTFOLIO_SOLVER_DELAY: Duration = Duration::from_millis(500); + +// Portfolio scheduler tuning +pub(crate) const PORTFOLIO_SCHEDULER_HISTORY: usize = 8; +pub(crate) const PORTFOLIO_SCHEDULER_MIN_RECENCY_WEIGHT: i64 = 1; +pub(crate) const PORTFOLIO_SCHEDULER_SPEED_BONUS_CAP_MS: u128 = 100; +pub(crate) const PORTFOLIO_SCHEDULER_MAX_SPEED_BONUS: i64 = 100; + +// Solver query cache limits +pub(crate) const SYMBOLIC_SOLVER_SAT_CACHE_MAX_ENTRIES: usize = 4096; +pub(crate) const SYMBOLIC_SOLVER_MODEL_CACHE_MAX_ENTRIES: usize = 512; + +// Bounded witness search limits. +pub(crate) const HARD_ARITH_FALLBACK_MAX_VARS: usize = 4; +pub(crate) const HARD_ARITH_FALLBACK_MAX_ASSIGNMENTS: usize = 50_000; +pub(crate) const FALLBACK_MODEL_MAX_VARS: usize = 5; +pub(crate) const FALLBACK_MODEL_MAX_CANDIDATES_PER_VAR: usize = 24; +pub(crate) const FALLBACK_MODEL_MAX_ASSIGNMENTS: usize = + FALLBACK_MODEL_MAX_CANDIDATES_PER_VAR * FALLBACK_MODEL_MAX_CANDIDATES_PER_VAR; diff --git a/crates/evm/symbolic/src/executor/calls.rs b/crates/evm/symbolic/src/executor/calls.rs new file mode 100644 index 0000000000000..25208a85704a0 --- /dev/null +++ b/crates/evm/symbolic/src/executor/calls.rs @@ -0,0 +1,1906 @@ +use super::*; +use foundry_evm::revm::precompile::u64_to_address; + +impl SymbolicExecutor { + pub(super) fn call( + &mut self, + executor: &Executor, + state: &mut PathState, + worklist: &mut VecDeque, + completed_paths: &mut usize, + kind: CallKind, + ) -> Result { + let pre_call_state = (!state.function_mocks.is_empty() + || !state.expected_calls.is_empty() + || !state.call_mocks.is_empty() + || (state.is_static && matches!(kind, CallKind::Call))) + .then(|| state.clone()); + let call_pc = state.pc.saturating_sub(1); + + let has_value = matches!(kind, CallKind::Call | CallKind::CallCode); + let in_offset_idx = if has_value { 3 } else { 2 }; + let in_offset = state.stack.peek(in_offset_idx)?.clone(); + let in_size = state.stack.peek(in_offset_idx + 1)?.clone(); + let out_offset = state.stack.peek(in_offset_idx + 2)?.clone(); + let out_size = state.stack.peek(in_offset_idx + 3)?.clone(); + if let Some(outcome) = + self.guard_memory_range(executor, state, worklist, &in_offset, &in_size)? + { + return Ok(outcome); + } + if let Some(outcome) = + self.guard_memory_range(executor, state, worklist, &out_offset, &out_size)? + { + return Ok(outcome); + } + + let gas = state.stack.pop()?; + if !gas.is_raw_gasleft() { + return Err(SymbolicError::Unsupported("explicit CALL gas limit not modeled")); + } + let target = state.stack.pop()?; + ensure_expr_not_gasleft(&target)?; + let target_address = state.world.resolve_address(&target); + let value = match (kind, target_address) { + (CallKind::Call, Some(to)) + if to == CHEATCODE_ADDRESS || to == SYMBOLIC_VM_COMPAT_ADDRESS => + { + let value = state.stack.pop()?; + let value = + state.expect_constrained_word(&mut self.cx, value, "symbolic CALL value")?; + SymExpr::constant(&mut self.cx, value) + } + (CallKind::Call, _) => state.stack.pop()?, + (CallKind::CallCode, _) => state.stack.pop()?, + (CallKind::StaticCall | CallKind::DelegateCall, _) => SymExpr::zero(&mut self.cx), + }; + ensure_expr_not_gasleft(&value)?; + let in_offset = state.stack.pop()?; + ensure_expr_not_gasleft(&in_offset)?; + let in_size = state.stack.pop()?; + ensure_expr_not_gasleft(&in_size)?; + let in_size = match state.constrained_usize_checked(&mut self.cx, &in_size) { + Some(Ok(size)) => BoundedCopySize::Concrete(size), + Some(Err(_)) => { + return Ok(StepOutcome::Revert); + } + None => { + let max_limit = self.config.max_calldata_bytes as usize; + let max_size = self.solver_upper_bound_usize( + state, + &in_size, + max_limit, + "symbolic CALL input size", + )?; + BoundedCopySize::Symbolic { size: in_size, max_size } + } + }; + let out_offset = state.stack.pop()?; + ensure_expr_not_gasleft(&out_offset)?; + let out_size = state.stack.pop()?; + ensure_expr_not_gasleft(&out_size)?; + let out_size = match state.constrained_usize_checked(&mut self.cx, &out_size) { + Some(Ok(size)) => BoundedCopySize::Concrete(size), + Some(Err(_)) => { + return Ok(StepOutcome::Revert); + } + None => { + let max_limit = self.config.max_calldata_bytes as usize; + let max_size = self.solver_upper_bound_usize( + state, + &out_size, + max_limit, + "symbolic CALL output size", + )?; + BoundedCopySize::Symbolic { size: out_size, max_size } + } + }; + + in_size.expand_memory(&mut self.cx, &mut state.memory, in_offset.clone()); + out_size.expand_memory(&mut self.cx, &mut state.memory, out_offset.clone()); + + if state.is_static && matches!(kind, CallKind::Call) { + match state.constrained_word(&mut self.cx, &value) { + Some(value) if value.is_zero() => {} + Some(_) => { + state.return_data = SymReturnData::empty(&mut self.cx); + return Ok(StepOutcome::ExceptionalHalt); + } + None => { + let zero = SymBoolExpr::eq_word_const(&mut self.cx, &value, U256::ZERO); + let (zero_constraints, zero_sat) = + self.constraints_with_condition(state, zero.clone())?; + let nonzero = zero.not(&mut self.cx); + let (nonzero_constraints, nonzero_sat) = + self.constraints_with_condition(state, nonzero)?; + match (zero_sat, nonzero_sat) { + (true, true) => { + let mut zero_state = pre_call_state + .as_ref() + .expect("static calls preserve pre-call state") + .clone(); + zero_state.pc = call_pc; + zero_state.constraints = zero_constraints; + worklist.push_back(zero_state); + state.constraints = nonzero_constraints; + state.return_data = SymReturnData::empty(&mut self.cx); + return Ok(StepOutcome::ExceptionalHalt); + } + (true, false) => state.constraints = zero_constraints, + (false, true) => { + state.constraints = nonzero_constraints; + state.return_data = SymReturnData::empty(&mut self.cx); + return Ok(StepOutcome::ExceptionalHalt); + } + (false, false) => return Ok(StepOutcome::AssumeRejected), + } + } + } + } + + let call_input = in_size.read_from_memory(&mut self.cx, &state.memory, in_offset.clone()); + // Gas is not modeled, so calldata derived from `GAS` / `gasleft()` must fail closed instead + // of handing the callee a fabricated gas value. + if call_input.contains_gasleft(&mut self.cx) { + return Err(SymbolicError::Unsupported("GAS/gasleft() not modeled")); + } + + if let Some(to) = target_address { + if !state.function_mocks.is_empty() { + let pre_call_state = + pre_call_state.as_ref().expect("function mocks require pre-call state"); + if self.branch_symbolic_function_mock_if_needed( + state, + worklist, + pre_call_state, + call_pc, + to, + &call_input, + )? { + return Ok(StepOutcome::Forked); + } + } + let code_address = if state.function_mocks.is_empty() { + to + } else { + self.function_mock_target(state, to, &call_input)?.unwrap_or(to) + }; + if !state.expected_calls.is_empty() || !state.call_mocks.is_empty() { + let pre_call_state = + pre_call_state.as_ref().expect("call mocks require pre-call state"); + if self.branch_symbolic_call_value_if_needed( + state, + worklist, + pre_call_state, + call_pc, + code_address, + &value, + &gas, + &call_input, + )? { + return Ok(StepOutcome::Forked); + } + } + let concrete_value = state.constrained_word(&mut self.cx, &value); + if !state.expected_calls.is_empty() || !state.call_mocks.is_empty() { + let pre_call_state = + pre_call_state.as_ref().expect("call mocks require pre-call state"); + if self.branch_symbolic_call_match_if_needed( + state, + worklist, + pre_call_state, + call_pc, + code_address, + concrete_value, + &gas, + &call_input, + )? { + return Ok(StepOutcome::Forked); + } + } + return self.call_concrete_target( + executor, + state, + worklist, + completed_paths, + kind, + to, + Some(target), + value, + gas, + in_offset, + in_size, + out_offset, + out_size, + ); + } + + self.call_symbolic_target( + executor, + state, + worklist, + completed_paths, + kind, + target, + value, + gas, + in_offset, + in_size, + out_offset, + out_size, + ) + } + + #[expect(clippy::too_many_arguments)] + pub(super) fn branch_symbolic_call_value_if_needed( + &mut self, + state: &mut PathState, + worklist: &mut VecDeque, + pre_call_state: &PathState, + call_pc: usize, + code_address: Address, + value: &SymExpr, + gas: &SymExpr, + call_input: &SymBytes, + ) -> Result { + if state.constrained_word(&mut self.cx, value).is_some() { + return Ok(false); + } + + for candidate in self.call_value_candidates(state, code_address, gas, call_input)? { + let eq = SymBoolExpr::eq_word_const(&mut self.cx, value, candidate); + let (eq_constraints, eq_sat) = self.constraints_with_condition(state, eq.clone())?; + let eq_not = eq.not(&mut self.cx); + let (neq_constraints, neq_sat) = self.constraints_with_condition(state, eq_not)?; + + match (eq_sat, neq_sat) { + (true, true) => { + let mut eq_state = pre_call_state.clone(); + eq_state.pc = call_pc; + eq_state.constraints = eq_constraints; + worklist.push_back(eq_state); + + let mut neq_state = pre_call_state.clone(); + neq_state.pc = call_pc; + neq_state.constraints = neq_constraints; + worklist.push_back(neq_state); + return Ok(true); + } + (true, false) => { + state.constraints = eq_constraints; + return Ok(false); + } + (false, true) => { + state.constraints = neq_constraints; + } + (false, false) => return Ok(false), + } + } + + Ok(false) + } + + pub(super) fn branch_symbolic_function_mock_if_needed( + &mut self, + state: &mut PathState, + worklist: &mut VecDeque, + pre_call_state: &PathState, + call_pc: usize, + callee: Address, + calldata: &SymBytes, + ) -> Result { + for condition in self.function_mock_conditions(state, callee, calldata) { + if self.branch_symbolic_match_condition_if_needed( + state, + worklist, + pre_call_state, + call_pc, + condition, + )? { + return Ok(true); + } + } + + Ok(false) + } + + pub(super) fn observe_expected_call( + &mut self, + state: &mut PathState, + callee: Address, + value: Option, + gas: &SymExpr, + calldata: &SymBytes, + ) -> Result { + if state.expected_calls.is_empty() { + return Ok(true); + } + for idx in 0..state.expected_calls.len() { + if let Some(constraints) = self.expected_call_match_constraints( + state, + &state.expected_calls[idx], + callee, + value, + gas, + calldata, + )? { + state.constraints = constraints; + return Ok(state.expected_calls[idx].observe()); + } + } + Ok(true) + } + + #[expect(clippy::too_many_arguments)] + pub(super) fn branch_symbolic_call_match_if_needed( + &mut self, + state: &mut PathState, + worklist: &mut VecDeque, + pre_call_state: &PathState, + call_pc: usize, + code_address: Address, + value: Option, + gas: &SymExpr, + calldata: &SymBytes, + ) -> Result { + for condition in self.call_match_conditions(state, code_address, value, gas, calldata)? { + if self.branch_symbolic_match_condition_if_needed( + state, + worklist, + pre_call_state, + call_pc, + condition, + )? { + return Ok(true); + } + } + + Ok(false) + } + + pub(super) fn take_call_mock( + &mut self, + state: &mut PathState, + callee: Address, + value: Option, + calldata: &SymBytes, + ) -> Result, SymbolicError> { + if state.call_mocks.is_empty() { + return Ok(None); + } + let mut best = None; + for idx in 0..state.call_mocks.len() { + let Some(constraints) = self.call_mock_match_constraints( + state, + &state.call_mocks[idx], + callee, + value, + calldata, + )? + else { + continue; + }; + let specificity = state.call_mocks[idx].specificity(); + if best.as_ref().is_none_or( + |(_, best_specificity, _): &(usize, (usize, bool), Vec)| { + specificity > *best_specificity + }, + ) { + best = Some((idx, specificity, constraints)); + } + } + let Some((idx, _, constraints)) = best else { + return Ok(None); + }; + state.constraints = constraints; + Ok(Some(state.call_mocks[idx].next_outcome(&mut self.cx))) + } + + pub(super) fn branch_symbolic_match_condition_if_needed( + &mut self, + state: &mut PathState, + worklist: &mut VecDeque, + pre_call_state: &PathState, + call_pc: usize, + condition: SymBoolExpr, + ) -> Result { + let (match_constraints, match_sat) = + self.constraints_with_condition(state, condition.clone())?; + let mismatch_condition = condition.not(&mut self.cx); + let (mismatch_constraints, mismatch_sat) = + self.constraints_with_condition(state, mismatch_condition)?; + + match (match_sat, mismatch_sat) { + (true, true) => { + let mut match_state = pre_call_state.clone(); + match_state.pc = call_pc; + match_state.constraints = match_constraints; + worklist.push_back(match_state); + + let mut mismatch_state = pre_call_state.clone(); + mismatch_state.pc = call_pc; + mismatch_state.constraints = mismatch_constraints; + worklist.push_back(mismatch_state); + Ok(true) + } + (true, false) => { + state.constraints = match_constraints; + Ok(false) + } + (false, true) => { + state.constraints = mismatch_constraints; + Ok(false) + } + (false, false) => Ok(false), + } + } + + pub(super) fn function_mock_target( + &mut self, + state: &mut PathState, + callee: Address, + calldata: &SymBytes, + ) -> Result, SymbolicError> { + for idx in (0..state.function_mocks.len()).rev() { + if state.function_mocks[idx].calldata_len() != calldata.len() { + continue; + } + let Some(condition) = + state.function_mocks[idx].match_condition(&mut self.cx, callee, calldata) + else { + continue; + }; + if let Some(constraints) = self.constraints_for_condition(state, condition)? { + state.constraints = constraints; + return Ok(Some(state.function_mocks[idx].target())); + } + } + for idx in (0..state.function_mocks.len()).rev() { + if state.function_mocks[idx].calldata_len() != 4 { + continue; + } + let Some(condition) = + state.function_mocks[idx].match_condition(&mut self.cx, callee, calldata) + else { + continue; + }; + if let Some(constraints) = self.constraints_for_condition(state, condition)? { + state.constraints = constraints; + return Ok(Some(state.function_mocks[idx].target())); + } + } + Ok(None) + } + + pub(super) fn expected_call_match_constraints( + &mut self, + state: &PathState, + expected: &ExpectedCall, + callee: Address, + value: Option, + gas: &SymExpr, + calldata: &SymBytes, + ) -> Result>, SymbolicError> { + let Some(condition) = + expected.match_condition(&mut self.cx, callee, value, gas, calldata)? + else { + return Ok(None); + }; + self.constraints_for_condition(state, condition) + } + + pub(super) fn call_mock_match_constraints( + &mut self, + state: &PathState, + mock: &CallMock, + callee: Address, + value: Option, + calldata: &SymBytes, + ) -> Result>, SymbolicError> { + let Some(condition) = mock.match_condition(&mut self.cx, callee, value, calldata) else { + return Ok(None); + }; + self.constraints_for_condition(state, condition) + } + + /// Returns whether `expected_revert_matches` holds. + pub(super) fn expected_revert_matches( + &mut self, + state: &mut PathState, + expected: &ExpectedRevert, + reverter: Address, + return_data: &SymReturnData, + ) -> Result { + let Some(condition) = expected.match_condition(&mut self.cx, reverter, return_data) else { + return Ok(false); + }; + + let (match_constraints, match_sat) = + self.constraints_with_condition(state, condition.clone())?; + if !match_sat { + return Ok(false); + } + + let mismatch_condition = condition.not(&mut self.cx); + let (mismatch_constraints, mismatch_sat) = + self.constraints_with_condition(state, mismatch_condition)?; + if mismatch_sat { + state.constraints = mismatch_constraints; + return Ok(false); + } + + state.constraints = match_constraints; + Ok(true) + } + + pub(super) fn assume_no_revert_rejects( + &mut self, + state: &mut PathState, + assumption: &AssumeNoRevert, + reverter: Address, + return_data: &SymReturnData, + ) -> Result { + let AssumeNoRevert::Filtered(filters) = assumption else { + return Ok(true); + }; + + let conditions = filters + .iter() + .filter_map(|filter| filter.match_condition(&mut self.cx, reverter, return_data)) + .collect::>(); + if conditions.is_empty() { + return Ok(false); + } + + let condition = SymBoolExpr::or(&mut self.cx, conditions); + let (_match_constraints, match_sat) = + self.constraints_with_condition(state, condition.clone())?; + if !match_sat { + return Ok(false); + } + + let mismatch_condition = condition.not(&mut self.cx); + let (mismatch_constraints, mismatch_sat) = + self.constraints_with_condition(state, mismatch_condition)?; + if mismatch_sat { + state.constraints = mismatch_constraints; + return Ok(false); + } + + Ok(true) + } + + pub(super) fn constraints_for_condition( + &mut self, + state: &PathState, + condition: SymBoolExpr, + ) -> Result>, SymbolicError> { + let (constraints, sat) = self.constraints_with_condition(state, condition)?; + Ok(sat.then_some(constraints)) + } + + pub(super) fn constraints_with_condition( + &mut self, + state: &PathState, + condition: SymBoolExpr, + ) -> Result<(Vec, bool), SymbolicError> { + match condition.as_const() { + Some(true) => Ok((state.constraints.clone(), true)), + Some(false) => Ok((state.constraints.clone(), false)), + None => { + let mut constraints = state.constraints.clone(); + constraints.push(condition); + let sat = self.is_sat_with_state(state, &constraints)?; + Ok((constraints, sat)) + } + } + } + + pub(super) fn take_loop_jump( + &self, + state: &mut PathState, + source_pc: usize, + dest: usize, + ) -> bool { + let Some(bound) = self.config.loop_bound else { + return true; + }; + if dest >= source_pc { + return true; + } + let count = state.loop_jumps.entry(dest).or_default(); + if *count >= bound { + return false; + } + *count += 1; + true + } + + pub(super) fn handle_log( + &mut self, + state: &mut PathState, + log: SymbolicLog, + ) -> Result { + let Some(mut expected) = state.expected_emit.take() else { + state.record_log(log); + return Ok(StepOutcome::Continue); + }; + + if let Some(template) = expected.template().cloned() { + if !self.expected_emit_matches(state, &expected, &template, &log)? { + state.expected_emit = Some(expected); + state.record_log(log); + return Ok(StepOutcome::Failure); + } + expected.consume_one(); + if !expected.is_satisfied() { + state.expected_emit = Some(expected); + } + } else { + expected.set_template(log.clone()); + state.expected_emit = Some(expected); + } + + state.record_log(log); + Ok(StepOutcome::Continue) + } + + /// Returns whether `expected_emit_matches` holds. + pub(super) fn expected_emit_matches( + &mut self, + state: &mut PathState, + expected: &ExpectedEmit, + template: &SymbolicLog, + actual: &SymbolicLog, + ) -> Result { + let Some(condition) = expected.match_condition(&mut self.cx, template, actual) else { + return Ok(false); + }; + let (match_constraints, match_sat) = + self.constraints_with_condition(state, condition.clone())?; + if !match_sat { + return Ok(false); + } + + let mismatch_condition = condition.not(&mut self.cx); + let (mismatch_constraints, mismatch_sat) = + self.constraints_with_condition(state, mismatch_condition)?; + if mismatch_sat { + state.constraints = mismatch_constraints; + return Ok(false); + } + + state.constraints = match_constraints; + Ok(true) + } + + #[expect(clippy::too_many_arguments)] + pub(super) fn call_concrete_target( + &mut self, + executor: &Executor, + state: &mut PathState, + worklist: &mut VecDeque, + completed_paths: &mut usize, + kind: CallKind, + to: Address, + target_word: Option, + value: SymExpr, + gas: SymExpr, + in_offset: SymExpr, + in_size: BoundedCopySize, + out_offset: SymExpr, + out_size: BoundedCopySize, + ) -> Result { + if to == CHEATCODE_ADDRESS || to == SYMBOLIC_VM_COMPAT_ADDRESS { + if !state.constrained_word(&mut self.cx, &value).is_some_and(|value| value.is_zero()) { + return Err(SymbolicError::Unsupported("value-bearing cheatcode CALL")); + } + let (in_size_word, in_size, has_symbolic_in_size) = in_size.parts(&mut self.cx); + if in_size < 4 { + return Err(SymbolicError::Unsupported("short cheatcode CALL")); + } + + let has_symbolic_input_offset = in_offset.as_const().is_none(); + let concrete_in_offset = if has_symbolic_input_offset { + None + } else { + Some(in_offset.as_usize_or("symbolic cheatcode CALL input offset")?) + }; + let selector = if has_symbolic_input_offset { + let minimum_offset = state.lower_bound_usize(&in_offset); + let maximum_offset = state.upper_bound_usize(&mut self.cx, &in_offset); + let selector = state + .memory + .read_bytes_offset_with_bounds( + &mut self.cx, + in_offset.clone(), + 4, + minimum_offset, + maximum_offset, + ) + .right_aligned_word(&mut self.cx, 0, 4); + self.constrained_word_with_solver(state, &selector)? + .map(|selector| selector.to_be_bytes::<32>()[28..].try_into().unwrap()) + .ok_or(SymbolicError::Unsupported("symbolic cheatcode selector"))? + } else { + state + .memory + .read_concrete( + &mut self.cx, + concrete_in_offset.expect("ordinary cheatcode input offset is concrete"), + 4, + )? + .try_into() + .map_err(|_| SymbolicError::Unsupported("symbolic cheatcode selector"))? + }; + let full_word_array_assertion = + to == CHEATCODE_ADDRESS && is_full_word_array_assertion(selector); + if has_symbolic_input_offset && !full_word_array_assertion { + return Err(SymbolicError::Unsupported("symbolic cheatcode CALL input offset")); + } + if has_symbolic_in_size { + let min_size = if to == CHEATCODE_ADDRESS { + foundry_cheatcode_min_input_size(selector) + } else if to == SYMBOLIC_VM_COMPAT_ADDRESS { + symbolic_vm_cheatcode_min_input_size(selector) + } else { + None + } + .ok_or(SymbolicError::Unsupported("symbolic cheatcode CALL input size"))?; + if min_size > in_size { + return Err(SymbolicError::Unsupported("symbolic cheatcode CALL input size")); + } + if !full_word_array_assertion + && state.lower_bound_usize(&in_size_word) < min_size + && !self.assume_expr_at_least(state, &in_size_word, min_size)? + { + return Ok(StepOutcome::AssumeRejected); + } + } + + if to == CHEATCODE_ADDRESS + && let Some(concrete_in_offset) = concrete_in_offset + && let Some(outcome) = self.branch_accesses_cheatcode_if_needed( + state, + worklist, + selector, + concrete_in_offset, + out_offset.clone(), + &out_size, + )? + { + return Ok(outcome); + } + + if to == CHEATCODE_ADDRESS + && let Some(concrete_in_offset) = concrete_in_offset + && let Some(outcome) = self.deploy_code_cheatcode_if_needed( + executor, + state, + worklist, + completed_paths, + selector, + concrete_in_offset, + out_offset.clone(), + &out_size, + )? + { + return Ok(outcome); + } + + let return_data = if to == CHEATCODE_ADDRESS { + let outcome = self.handle_foundry_cheatcode( + executor, + state, + selector, + &in_offset, + &in_size_word, + in_size, + )?; + match outcome { + CheatcodeOutcome::Continue(ret) => SymReturnData::from_words(&mut self.cx, ret), + CheatcodeOutcome::ContinueData(ret) => ret, + CheatcodeOutcome::Revert(ret) => { + state.return_data = ret; + state.copy_call_output_offset(&mut self.cx, out_offset, &out_size)?; + state.stack.push(SymExpr::zero(&mut self.cx))?; + return Ok(StepOutcome::Continue); + } + CheatcodeOutcome::AssumeRejected => return Ok(StepOutcome::AssumeRejected), + CheatcodeOutcome::Failure => return Ok(StepOutcome::Failure), + } + } else if to == SYMBOLIC_VM_COMPAT_ADDRESS { + self.handle_symbolic_vm_cheatcode( + state, + selector, + concrete_in_offset.expect("symbolic vm input offset is concrete"), + )? + } else { + return Err(SymbolicError::Unsupported("symbolic cheatcode address")); + }; + + state.return_data = return_data; + state.copy_call_output_offset(&mut self.cx, out_offset, &out_size)?; + state.stack.push(SymExpr::one(&mut self.cx))?; + return Ok(StepOutcome::Continue); + } + + if to == HARDHAT_CONSOLE_ADDRESS { + state.return_data = SymReturnData::empty(&mut self.cx); + state.copy_call_output_offset(&mut self.cx, out_offset, &out_size)?; + state.stack.push(SymExpr::one(&mut self.cx))?; + return Ok(StepOutcome::Continue); + } + + let call_input = in_size.read_from_memory(&mut self.cx, &state.memory, in_offset.clone()); + // `vm.mockFunction` swaps the code that runs, and the concrete inspector matches + // `vm.expectCall` against that address, so resolve the redirect before observing. + let code_address = self.function_mock_target(state, to, &call_input)?.unwrap_or(to); + if !state.expected_calls.is_empty() { + let concrete_value = state.constrained_word(&mut self.cx, &value); + if !self.observe_expected_call( + state, + code_address, + concrete_value, + &gas, + &call_input, + )? { + return Ok(StepOutcome::Failure); + } + } + let call_context = + (!matches!(kind, CallKind::DelegateCall)).then(|| state.prank_for_next_call()); + let transfer_to = if matches!(kind, CallKind::Call) { to } else { state.address }; + if matches!(kind, CallKind::Call | CallKind::CallCode) { + let call_caller = call_context.as_ref().expect("value calls have a call context").0; + if !self.prepare_value_transfer( + executor, + state, + worklist, + call_caller, + transfer_to, + value.clone(), + out_offset.clone(), + &out_size, + )? { + return Ok(StepOutcome::Continue); + } + } + if !state.call_mocks.is_empty() { + let concrete_value = state.constrained_word(&mut self.cx, &value); + if let Some(mock) = + self.take_call_mock(state, code_address, concrete_value, &call_input)? + { + let (return_data, reverts) = mock.into_parts(); + state.return_data = return_data; + if !reverts && let Some((call_caller, _, _)) = call_context { + self.apply_call_value_transfer(executor, state, kind, to, call_caller, value); + } + state.copy_call_output_offset(&mut self.cx, out_offset, &out_size)?; + let success = SymExpr::constant(&mut self.cx, U256::from(!reverts)); + state.stack.push(success)?; + return Ok(StepOutcome::Continue); + } + } + if matches!(kind, CallKind::DelegateCall) && state.prank.has_active() { + return Err(SymbolicError::Unsupported("symbolic prank delegatecall")); + } + let (call_caller, call_caller_word, pranked_origin) = + call_context.unwrap_or_else(|| state.prank_for_next_call()); + + let spec_id: SpecId = executor.spec_id().into(); + if precompile_number_for_spec(code_address, spec_id).is_some() { + let input_len = in_size.size_word(&mut self.cx); + let input = in_size.read_from_memory(&mut self.cx, &state.memory, in_offset); + if precompile_number_for_spec(code_address, spec_id) == Some(10) { + let input_bytes = input.materialize(&mut self.cx); + return self.execute_kzg_precompile_call( + executor, + state, + worklist, + kind, + to, + call_caller, + value, + out_offset, + &out_size, + input_bytes, + input_len, + ); + } + match execute_symbolic_precompile( + &mut self.cx, + code_address, + input, + input_len, + spec_id, + )? { + Some(return_data) => { + state.return_data = return_data; + self.apply_call_value_transfer(executor, state, kind, to, call_caller, value); + state.copy_call_output_offset(&mut self.cx, out_offset, &out_size)?; + state.stack.push(SymExpr::one(&mut self.cx))?; + } + None => { + state.return_data = SymReturnData::empty(&mut self.cx); + state.copy_call_output_offset(&mut self.cx, out_offset, &out_size)?; + state.stack.push(SymExpr::zero(&mut self.cx))?; + } + } + return Ok(StepOutcome::Continue); + } + + let child_code = state.world.extcode(&mut self.cx, executor, code_address)?; + if child_code.is_empty() { + self.apply_call_value_transfer(executor, state, kind, to, call_caller, value); + state.return_data = SymReturnData::empty(&mut self.cx); + state.copy_call_output_offset(&mut self.cx, out_offset, &out_size)?; + state.stack.push(SymExpr::one(&mut self.cx))?; + return Ok(StepOutcome::Continue); + } + + let calldata = in_size.calldata(&mut self.cx, call_input); + let callee_address_word = state + .world + .symbolic_word_for_address(to) + .or_else(|| { + target_word + .as_ref() + .filter(|expr| state.world.resolve_address(expr) == Some(to)) + .cloned() + }) + .unwrap_or_else(|| SymExpr::constant(&mut self.cx, address_word(to))); + let frame = match kind { + CallKind::Call => { + let mut frame = CallFrame::new( + &mut self.cx, + to, + to, + call_caller, + value.clone(), + state.is_static, + calldata, + ); + frame.address_word = callee_address_word; + frame.caller_word = call_caller_word; + frame + } + CallKind::StaticCall => { + let value = SymExpr::zero(&mut self.cx); + let mut frame = + CallFrame::new(&mut self.cx, to, to, call_caller, value, true, calldata); + frame.address_word = callee_address_word; + frame.caller_word = call_caller_word; + frame + } + CallKind::DelegateCall => { + let mut frame = CallFrame::new( + &mut self.cx, + state.address, + state.storage_address, + state.caller, + state.callvalue.clone(), + state.is_static, + calldata, + ); + frame.address_word = state.address_word.clone(); + frame.caller_word = state.caller_word.clone(); + frame + } + CallKind::CallCode => { + let mut frame = CallFrame::new( + &mut self.cx, + state.address, + state.storage_address, + call_caller, + value.clone(), + state.is_static, + calldata, + ); + frame.address_word = state.address_word.clone(); + frame.caller_word = call_caller_word; + frame + } + }; + + let original_world = state.world.clone(); + let mut child = state.child(frame); + if let Some((origin, origin_word)) = pranked_origin { + child.origin = origin; + child.origin_word = origin_word; + } + self.apply_call_value_transfer(executor, &mut child, kind, to, call_caller, value); + let outcomes = self.execute_external_call(executor, child, &child_code, completed_paths)?; + if outcomes.is_empty() { + return Ok(StepOutcome::AssumeRejected); + } + + let mut parents = VecDeque::with_capacity(outcomes.len()); + for outcome in outcomes { + match self.join_call_outcome(state, outcome, to)? { + JoinedCallOutcome::Rejected => {} + JoinedCallOutcome::Failure(parent) => { + *state = parent; + return Ok(StepOutcome::Failure); + } + JoinedCallOutcome::ExceptionalHalt(mut parent) => { + parent.world = original_world.clone(); + parent.return_data = SymReturnData::empty(&mut self.cx); + parent.copy_call_output_offset(&mut self.cx, out_offset.clone(), &out_size)?; + parent.stack.push(SymExpr::zero(&mut self.cx))?; + parents.push_back(parent); + } + JoinedCallOutcome::ExpectedRevert { mut parent, child } => { + parent.expected_creates = child.expected_creates; + parent.world = original_world.clone(); + parent.return_data = SymReturnData::empty(&mut self.cx); + parent.copy_call_output_offset(&mut self.cx, out_offset.clone(), &out_size)?; + parent.stack.push(SymExpr::one(&mut self.cx))?; + parents.push_back(parent); + } + JoinedCallOutcome::Success { mut parent, child } => { + parent.world = child.world; + parent.expected_emit = child.expected_emit; + parent.expected_creates = child.expected_creates; + parent.return_data = child.frame.return_data; + parent.copy_call_output_offset(&mut self.cx, out_offset.clone(), &out_size)?; + parent.stack.push(SymExpr::one(&mut self.cx))?; + parents.push_back(parent); + } + JoinedCallOutcome::Revert { mut parent, child } => { + parent.expected_creates = child.expected_creates; + parent.world = original_world.clone(); + parent.return_data = child.frame.return_data; + parent.copy_call_output_offset(&mut self.cx, out_offset.clone(), &out_size)?; + parent.stack.push(SymExpr::zero(&mut self.cx))?; + parents.push_back(parent); + } + } + } + + Ok(self.resume_parent_paths(state, worklist, parents)) + } + + #[expect(clippy::too_many_arguments)] + fn execute_kzg_precompile_call( + &mut self, + executor: &Executor, + state: &mut PathState, + worklist: &mut VecDeque, + kind: CallKind, + to: Address, + call_caller: Address, + value: SymExpr, + out_offset: SymExpr, + out_size: &BoundedCopySize, + input: Vec, + input_len: SymExpr, + ) -> Result { + if let Some(outcome) = kzg_constrained_outcome(&mut self.cx, state, &input, &input_len)? { + self.apply_precompile_outcome( + executor, + state, + kind, + to, + call_caller, + value, + out_offset, + out_size, + outcome, + )?; + return Ok(StepOutcome::Continue); + } + + let success_condition = kzg_success_witness_condition(&mut self.cx, &input, &input_len); + let failure_condition = + kzg_failure_witness_condition(&mut self.cx, state, &input, &input_len); + let modeled_condition = SymBoolExpr::or( + &mut self.cx, + vec![success_condition.clone(), failure_condition.clone()], + ); + let modeled_condition = modeled_condition.not(&mut self.cx); + let (_, residual_sat) = self.constraints_with_condition(state, modeled_condition)?; + if residual_sat { + self.defer_incomplete(KZG_RESIDUAL_REASON); + } + + let (success_constraints, success_sat) = + self.constraints_with_condition(state, success_condition)?; + + let (failure_constraints, failure_sat) = + self.constraints_with_condition(state, failure_condition)?; + + match (success_sat, failure_sat) { + (true, true) => { + let mut failure = state.clone(); + failure.constraints = failure_constraints; + self.apply_precompile_outcome( + executor, + &mut failure, + kind, + to, + call_caller, + value.clone(), + out_offset.clone(), + out_size, + None, + )?; + worklist.push_back(failure); + + state.constraints = success_constraints; + let return_data = kzg_success_return_data(&mut self.cx); + self.apply_precompile_outcome( + executor, + state, + kind, + to, + call_caller, + value, + out_offset, + out_size, + Some(return_data), + )?; + Ok(StepOutcome::Continue) + } + (true, false) => { + state.constraints = success_constraints; + let return_data = kzg_success_return_data(&mut self.cx); + self.apply_precompile_outcome( + executor, + state, + kind, + to, + call_caller, + value, + out_offset, + out_size, + Some(return_data), + )?; + Ok(StepOutcome::Continue) + } + (false, true) => { + state.constraints = failure_constraints; + self.apply_precompile_outcome( + executor, + state, + kind, + to, + call_caller, + value, + out_offset, + out_size, + None, + )?; + Ok(StepOutcome::Continue) + } + (false, false) => Err(SymbolicError::Unsupported(KZG_RESIDUAL_REASON)), + } + } + + #[expect(clippy::too_many_arguments)] + /// Applies a precompile call result to the current symbolic state. + fn apply_precompile_outcome( + &mut self, + executor: &Executor, + state: &mut PathState, + kind: CallKind, + to: Address, + call_caller: Address, + value: SymExpr, + out_offset: SymExpr, + out_size: &BoundedCopySize, + outcome: Option, + ) -> Result<(), SymbolicError> { + match outcome { + Some(return_data) => { + state.return_data = return_data; + self.apply_call_value_transfer(executor, state, kind, to, call_caller, value); + state.copy_call_output_offset(&mut self.cx, out_offset, out_size)?; + state.stack.push(SymExpr::one(&mut self.cx))?; + } + None => { + state.return_data = SymReturnData::empty(&mut self.cx); + state.copy_call_output_offset(&mut self.cx, out_offset, out_size)?; + state.stack.push(SymExpr::zero(&mut self.cx))?; + } + } + Ok(()) + } + + fn apply_call_value_transfer( + &mut self, + executor: &Executor, + state: &mut PathState, + kind: CallKind, + to: Address, + from: Address, + value: SymExpr, + ) { + let to = match kind { + CallKind::Call => to, + CallKind::CallCode => state.address, + CallKind::DelegateCall | CallKind::StaticCall => return, + }; + state.world.transfer(&mut self.cx, executor, from, to, value); + } + + #[expect(clippy::too_many_arguments)] + pub(super) fn prepare_value_transfer( + &mut self, + executor: &Executor, + state: &mut PathState, + worklist: &mut VecDeque, + from: Address, + to: Address, + value: SymExpr, + out_offset: SymExpr, + out_size: &BoundedCopySize, + ) -> Result { + if state.constrained_word(&mut self.cx, &value).is_some_and(|value| value.is_zero()) { + return Ok(true); + } + + let balance = state.balance(&mut self.cx, executor, from); + let can_pay = SymBoolExpr::cmp(&mut self.cx, SymCmpOp::Uge, balance, value.clone()); + let can_transfer = if from == to { + can_pay + } else { + let balance = state.balance(&mut self.cx, executor, to); + let sum = SymExpr::binop(&mut self.cx, SymBinOp::Add, balance.clone(), value); + let no_overflow = SymBoolExpr::cmp(&mut self.cx, SymCmpOp::Uge, sum, balance); + SymBoolExpr::and(&mut self.cx, vec![can_pay, no_overflow]) + }; + match can_transfer.as_const() { + Some(true) => Ok(true), + Some(false) => { + state.return_data = SymReturnData::empty(&mut self.cx); + state.copy_call_output_offset(&mut self.cx, out_offset, out_size)?; + state.stack.push(SymExpr::zero(&mut self.cx))?; + Ok(false) + } + None => { + let mut success_constraints = state.constraints.clone(); + success_constraints.push(can_transfer.clone()); + let success_sat = self.is_sat_with_state(state, &success_constraints)?; + + let mut failure_constraints = state.constraints.clone(); + failure_constraints.push(can_transfer.not(&mut self.cx)); + let failure_sat = self.is_sat_with_state(state, &failure_constraints)?; + + match (success_sat, failure_sat) { + (true, true) => { + let mut failure = state.clone(); + failure.constraints = failure_constraints; + failure.return_data = SymReturnData::empty(&mut self.cx); + failure.copy_call_output_offset(&mut self.cx, out_offset, out_size)?; + failure.stack.push(SymExpr::zero(&mut self.cx))?; + worklist.push_back(failure); + + state.constraints = success_constraints; + Ok(true) + } + (true, false) => { + state.constraints = success_constraints; + Ok(true) + } + (false, true) => { + state.constraints = failure_constraints; + state.return_data = SymReturnData::empty(&mut self.cx); + state.copy_call_output_offset(&mut self.cx, out_offset, out_size)?; + state.stack.push(SymExpr::zero(&mut self.cx))?; + Ok(false) + } + (false, false) => Ok(false), + } + } + } + } + + pub(super) fn prepare_create_value_transfer( + &mut self, + executor: &Executor, + state: &mut PathState, + worklist: &mut VecDeque, + value: SymExpr, + ) -> Result { + if state.constrained_word(&mut self.cx, &value).is_some_and(|value| value.is_zero()) { + return Ok(true); + } + + let balance = state.balance(&mut self.cx, executor, state.address); + let can_pay = SymBoolExpr::cmp(&mut self.cx, SymCmpOp::Uge, balance, value); + match can_pay.as_const() { + Some(true) => Ok(true), + Some(false) => { + state.return_data = SymReturnData::empty(&mut self.cx); + state.stack.push(SymExpr::zero(&mut self.cx))?; + Ok(false) + } + None => { + let mut success_constraints = state.constraints.clone(); + success_constraints.push(can_pay.clone()); + let success_sat = self.is_sat_with_state(state, &success_constraints)?; + + let mut failure_constraints = state.constraints.clone(); + failure_constraints.push(can_pay.not(&mut self.cx)); + let failure_sat = self.is_sat_with_state(state, &failure_constraints)?; + + match (success_sat, failure_sat) { + (true, true) => { + let mut failure = state.clone(); + failure.constraints = failure_constraints; + failure.return_data = SymReturnData::empty(&mut self.cx); + failure.stack.push(SymExpr::zero(&mut self.cx))?; + worklist.push_back(failure); + + state.constraints = success_constraints; + Ok(true) + } + (true, false) => { + state.constraints = success_constraints; + Ok(true) + } + (false, true) => { + state.constraints = failure_constraints; + state.return_data = SymReturnData::empty(&mut self.cx); + state.stack.push(SymExpr::zero(&mut self.cx))?; + Ok(false) + } + (false, false) => Ok(false), + } + } + } + } + + #[expect(clippy::too_many_arguments)] + pub(super) fn call_symbolic_target( + &mut self, + executor: &Executor, + state: &mut PathState, + worklist: &mut VecDeque, + completed_paths: &mut usize, + kind: CallKind, + target: SymExpr, + value: SymExpr, + gas: SymExpr, + in_offset: SymExpr, + in_size: BoundedCopySize, + out_offset: SymExpr, + out_size: BoundedCopySize, + ) -> Result { + let mut candidates = state.world.symbolic_call_targets(&mut self.cx, executor)?; + candidates.extend((1..=10).map(u64_to_address)); + candidates.sort(); + candidates.dedup(); + if candidates.is_empty() { + return Err(SymbolicError::Unsupported( + "symbolic CALL target has no known contract candidates", + )); + } + + let candidate_constraints = candidates + .iter() + .map(|address| { + let address = SymExpr::constant(&mut self.cx, address_word(*address)); + SymBoolExpr::eq(&mut self.cx, target.clone(), address) + }) + .collect::>(); + let mut outside_constraints = state.constraints.clone(); + outside_constraints.extend( + candidate_constraints.iter().cloned().map(|condition| condition.not(&mut self.cx)), + ); + let outside_sat = self.is_sat_with_state(state, &outside_constraints)?; + + if !self.config.symbolic_call_targets && outside_sat { + return Err(SymbolicError::Unsupported("symbolic CALL target")); + } + + let mut parents = VecDeque::new(); + if outside_sat { + let mut branch = state.clone(); + branch.constraints = outside_constraints; + + if matches!(kind, CallKind::DelegateCall) && branch.prank.has_active() { + return Err(SymbolicError::Unsupported("symbolic prank delegatecall")); + } + let (call_caller, _, _) = branch.prank_for_next_call(); + if matches!(kind, CallKind::Call | CallKind::CallCode) { + let transfer_to = if matches!(kind, CallKind::Call) { + branch.world.symbolic_address_slot(target) + } else { + branch.address + }; + if self.prepare_value_transfer( + executor, + &mut branch, + &mut parents, + call_caller, + transfer_to, + value.clone(), + out_offset.clone(), + &out_size, + )? { + branch.world.transfer( + &mut self.cx, + executor, + call_caller, + transfer_to, + value.clone(), + ); + branch.return_data = SymReturnData::empty(&mut self.cx); + branch.copy_call_output_offset(&mut self.cx, out_offset.clone(), &out_size)?; + branch.stack.push(SymExpr::one(&mut self.cx))?; + } + } else { + branch.return_data = SymReturnData::empty(&mut self.cx); + branch.copy_call_output_offset(&mut self.cx, out_offset.clone(), &out_size)?; + branch.stack.push(SymExpr::one(&mut self.cx))?; + } + parents.push_back(branch); + } + + let call_input = in_size.read_from_memory(&mut self.cx, &state.memory, in_offset.clone()); + for (to, constraint) in candidates.into_iter().zip(candidate_constraints) { + let mut branch = state.clone(); + branch.constraints.push(constraint); + if !self.is_sat_with_state(&branch, &branch.constraints)? { + continue; + } + + // Decide mock matches before executing either the mocked or real call. + for mut branch in + self.split_symbolic_target_candidate(branch, to, &value, &gas, &call_input)? + { + let mut branch_worklist = VecDeque::new(); + match self.call_concrete_target( + executor, + &mut branch, + &mut branch_worklist, + completed_paths, + kind, + to, + None, + value.clone(), + gas.clone(), + in_offset.clone(), + in_size.clone(), + out_offset.clone(), + out_size.clone(), + )? { + StepOutcome::Continue => { + parents.push_back(branch); + parents.extend(branch_worklist); + } + StepOutcome::AssumeRejected => {} + outcome => return Ok(outcome), + } + } + } + + let Some(first) = self.pop_next_path(&mut parents) else { + return Ok(StepOutcome::AssumeRejected); + }; + *state = first; + worklist.extend(parents); + Ok(StepOutcome::Continue) + } + + /// Decides mock and expectation matches before executing a symbolic-target candidate. + fn split_symbolic_target_candidate( + &mut self, + branch: PathState, + to: Address, + value: &SymExpr, + gas: &SymExpr, + call_input: &SymBytes, + ) -> Result, SymbolicError> { + let mut branches = vec![branch]; + for condition in self.function_mock_conditions(&branches[0], to, call_input) { + branches = self.split_branches_on(branches, condition)?; + } + + let mut out = Vec::new(); + for mut branch in branches { + let code_address = if branch.function_mocks.is_empty() { + to + } else { + self.function_mock_target(&mut branch, to, call_input)?.unwrap_or(to) + }; + + let mut value_branches = vec![branch]; + if value_branches[0].constrained_word(&mut self.cx, value).is_none() { + let candidates = + self.call_value_candidates(&value_branches[0], code_address, gas, call_input)?; + for candidate in candidates { + let eq = SymBoolExpr::eq_word_const(&mut self.cx, value, candidate); + value_branches = self.split_branches_on(value_branches, eq)?; + } + } + + for branch in value_branches { + let concrete_value = branch.constrained_word(&mut self.cx, value); + let mut match_branches = vec![branch]; + let conditions = self.call_match_conditions( + &match_branches[0], + code_address, + concrete_value, + gas, + call_input, + )?; + for condition in conditions { + match_branches = self.split_branches_on(match_branches, condition)?; + } + if out.len() + match_branches.len() > self.config.path_width() as usize { + return Err(SymbolicError::Unsupported("symbolic path limit exceeded")); + } + out.extend(match_branches); + } + } + Ok(out) + } + + /// Splits branches on a match condition, retaining feasible outcomes. + fn split_branches_on( + &mut self, + branches: Vec, + condition: SymBoolExpr, + ) -> Result, SymbolicError> { + let mismatch_condition = condition.clone().not(&mut self.cx); + let mut next = Vec::with_capacity(branches.len()); + for branch in branches { + let (match_constraints, match_sat) = + self.constraints_with_condition(&branch, condition.clone())?; + let (mismatch_constraints, mismatch_sat) = + self.constraints_with_condition(&branch, mismatch_condition.clone())?; + match (match_sat, mismatch_sat) { + (true, true) => { + let mut match_branch = branch.clone(); + match_branch.constraints = match_constraints; + next.push(match_branch); + let mut mismatch_branch = branch; + mismatch_branch.constraints = mismatch_constraints; + next.push(mismatch_branch); + } + (true, false) => { + let mut branch = branch; + branch.constraints = match_constraints; + next.push(branch); + } + (false, true) => { + let mut branch = branch; + branch.constraints = mismatch_constraints; + next.push(branch); + } + (false, false) => {} + } + if next.len() > self.config.path_width() as usize { + return Err(SymbolicError::Unsupported("symbolic path limit exceeded")); + } + } + Ok(next) + } + + /// Function mock conditions in match-precedence order. + fn function_mock_conditions( + &mut self, + state: &PathState, + callee: Address, + calldata: &SymBytes, + ) -> Vec { + let mut conditions = Vec::new(); + for calldata_len in [calldata.len(), 4] { + for idx in (0..state.function_mocks.len()).rev() { + if state.function_mocks[idx].calldata_len() != calldata_len { + continue; + } + if let Some(condition) = + state.function_mocks[idx].match_condition(&mut self.cx, callee, calldata) + { + conditions.push(condition); + } + } + } + conditions + } + + /// Values that can satisfy a call expectation or mock. + fn call_value_candidates( + &mut self, + state: &PathState, + code_address: Address, + gas: &SymExpr, + call_input: &SymBytes, + ) -> Result, SymbolicError> { + let mut candidates = HashSet::::default(); + for expected in &state.expected_calls { + let Some(expected_value) = expected.value() else { continue }; + if self + .expected_call_match_constraints( + state, + expected, + code_address, + Some(expected_value), + gas, + call_input, + )? + .is_some() + { + candidates.insert(expected_value); + } + } + for mock in &state.call_mocks { + let Some(mock_value) = mock.value() else { continue }; + if self + .call_mock_match_constraints( + state, + mock, + code_address, + Some(mock_value), + call_input, + )? + .is_some() + { + candidates.insert(mock_value); + } + } + let mut candidates = candidates.into_iter().collect::>(); + candidates.sort_unstable(); + Ok(candidates) + } + + /// Expected call and call mock conditions in match-precedence order. + fn call_match_conditions( + &mut self, + state: &PathState, + code_address: Address, + value: Option, + gas: &SymExpr, + calldata: &SymBytes, + ) -> Result, SymbolicError> { + let mut conditions = Vec::new(); + for expected in &state.expected_calls { + if let Some(condition) = + expected.match_condition(&mut self.cx, code_address, value, gas, calldata)? + { + conditions.push(condition); + } + } + let mut mocks = (0..state.call_mocks.len()).collect::>(); + mocks.sort_by_key(|idx| { + let (len, has_value) = state.call_mocks[*idx].specificity(); + (std::cmp::Reverse(len), std::cmp::Reverse(has_value), *idx) + }); + for idx in mocks { + if let Some(condition) = + state.call_mocks[idx].match_condition(&mut self.cx, code_address, value, calldata) + { + conditions.push(condition); + } + } + Ok(conditions) + } +} + +const KZG_POINT_EVALUATION_INPUT_LEN: usize = 192; +const KZG_VERSIONED_HASH_OFFSET: usize = 0; +const KZG_Z_OFFSET: usize = 32; +const KZG_Y_OFFSET: usize = 64; +const KZG_COMMITMENT_OFFSET: usize = 96; +const KZG_PROOF_OFFSET: usize = 144; + +const KZG_BLS_MODULUS: [u8; 32] = + hex!("73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000001"); + +const KZG_SUCCESS_INPUT: [u8; KZG_POINT_EVALUATION_INPUT_LEN] = hex!( + "01e798154708fe7789429634053cbf9f99b619f9f084048927333fce637f549b" + "73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000000" + "1522a4a7f34e1ea350ae07c29c96c7e79655aa926122e95fe69fcbd932ca49e9" + "8f59a8d2a1a625a17f3fea0fe5eb8c896db3764f3185481bc22f91b4aaffcca25f26936857bc3a7c2539ea8ec3a952b7" + "a62ad71d14c5719385c0686f1871430475bf3a00f0aa3f7b8dd99a9abc2160744faf0070725e00b60ad9a026a15b1a8c" +); + +const KZG_INVALID_PROOF: [u8; 48] = [0xff; 48]; +const KZG_ZERO_COMMITMENT: [u8; 48] = [0x00; 48]; +const KZG_ONE_COMMITMENT: [u8; 48] = [0x01; 48]; +const KZG_RESIDUAL_REASON: &str = "symbolic KZG point-evaluation precompile residual not modeled"; + +fn kzg_success_return_data(cx: &mut SymCx) -> SymReturnData { + SymReturnData::from_concrete_bytes(cx, kzg_point_evaluation::RETURN_VALUE.to_vec()) +} + +fn kzg_constrained_outcome( + cx: &mut SymCx, + state: &PathState, + input: &[SymExpr], + input_len: &SymExpr, +) -> Result>, SymbolicError> { + let Some(input_len) = state.constrained_usize(cx, input_len) else { + return Ok(None); + }; + if input_len != KZG_POINT_EVALUATION_INPUT_LEN { + return Ok(Some(None)); + } + if input_len > input.len() { + return Err(SymbolicError::Unsupported("out-of-bounds symbolic precompile input")); + } + + if let Some(input) = constrained_bytes_at(cx, state, input, 0, input_len) { + return execute_precompile(cx, kzg_point_evaluation::ADDRESS, &input, SpecId::CANCUN) + .map(Some); + } + + if constrained_byte(cx, state, &input[0]) + .is_some_and(|version| version != kzg_point_evaluation::VERSIONED_HASH_VERSION_KZG) + { + return Ok(Some(None)); + } + + if constrained_bytes_at(cx, state, input, KZG_Z_OFFSET, KZG_BLS_MODULUS.len()) + .is_some_and(|z| z == KZG_BLS_MODULUS) + || constrained_bytes_at(cx, state, input, KZG_Y_OFFSET, KZG_BLS_MODULUS.len()) + .is_some_and(|y| y == KZG_BLS_MODULUS) + || constrained_bytes_at(cx, state, input, KZG_PROOF_OFFSET, KZG_INVALID_PROOF.len()) + .is_some_and(|proof| proof == KZG_INVALID_PROOF) + { + return Ok(Some(None)); + } + + if let Some(commitment) = constrained_bytes_at(cx, state, input, KZG_COMMITMENT_OFFSET, 48) { + let expected_hash = kzg_point_evaluation::kzg_to_versioned_hash(&commitment); + for (idx, expected) in expected_hash.into_iter().enumerate() { + if constrained_byte(cx, state, &input[idx]).is_some_and(|actual| actual != expected) { + return Ok(Some(None)); + } + } + } + + Ok(None) +} + +fn kzg_success_witness_condition( + cx: &mut SymCx, + input: &[SymExpr], + input_len: &SymExpr, +) -> SymBoolExpr { + let len = expr_eq_condition(cx, input_len, KZG_POINT_EVALUATION_INPUT_LEN); + let bytes = bytes_eq_condition(cx, input, KZG_VERSIONED_HASH_OFFSET, &KZG_SUCCESS_INPUT); + SymBoolExpr::and(cx, vec![len, bytes]) +} + +fn kzg_failure_witness_condition( + cx: &mut SymCx, + state: &PathState, + input: &[SymExpr], + input_len: &SymExpr, +) -> SymBoolExpr { + let len_192 = expr_eq_condition(cx, input_len, KZG_POINT_EVALUATION_INPUT_LEN); + let len_ne_192 = expr_ne_condition(cx, input_len, KZG_POINT_EVALUATION_INPUT_LEN); + let bad_version = + byte_ne_condition(cx, input, 0, kzg_point_evaluation::VERSIONED_HASH_VERSION_KZG); + let bad_z = bytes_eq_condition(cx, input, KZG_Z_OFFSET, &KZG_BLS_MODULUS); + let bad_y = bytes_eq_condition(cx, input, KZG_Y_OFFSET, &KZG_BLS_MODULUS); + let bad_proof = bytes_eq_condition(cx, input, KZG_PROOF_OFFSET, &KZG_INVALID_PROOF); + let mut conditions = vec![ + len_ne_192, + SymBoolExpr::and(cx, vec![len_192.clone(), bad_version]), + SymBoolExpr::and(cx, vec![len_192.clone(), bad_z]), + SymBoolExpr::and(cx, vec![len_192.clone(), bad_y]), + SymBoolExpr::and(cx, vec![len_192.clone(), bad_proof]), + ]; + + if let Some(commitment) = constrained_bytes_at(cx, state, input, KZG_COMMITMENT_OFFSET, 48) { + let expected_hash = kzg_point_evaluation::kzg_to_versioned_hash(&commitment); + let mismatch = kzg_versioned_hash_mismatch_condition(cx, input, &expected_hash); + conditions.push(SymBoolExpr::and(cx, vec![len_192.clone(), mismatch])); + } + + let expected_hash = &KZG_SUCCESS_INPUT[KZG_VERSIONED_HASH_OFFSET..KZG_Z_OFFSET]; + let commitment = &KZG_SUCCESS_INPUT[KZG_COMMITMENT_OFFSET..KZG_PROOF_OFFSET]; + let commitment_eq = bytes_eq_condition(cx, input, KZG_COMMITMENT_OFFSET, commitment); + let hash_byte_mismatch = byte_eq_condition(cx, input, 1, expected_hash[1] ^ 1); + conditions.push(SymBoolExpr::and(cx, vec![len_192.clone(), commitment_eq, hash_byte_mismatch])); + + for commitment in [&KZG_ZERO_COMMITMENT, &KZG_ONE_COMMITMENT] { + let expected_hash = kzg_point_evaluation::kzg_to_versioned_hash(commitment); + let commitment_eq = bytes_eq_condition(cx, input, KZG_COMMITMENT_OFFSET, commitment); + let mismatch = kzg_versioned_hash_mismatch_condition(cx, input, &expected_hash); + conditions.push(SymBoolExpr::and(cx, vec![len_192.clone(), commitment_eq, mismatch])); + } + + SymBoolExpr::or(cx, conditions) +} + +fn kzg_versioned_hash_mismatch_condition( + cx: &mut SymCx, + input: &[SymExpr], + expected_hash: &[u8; 32], +) -> SymBoolExpr { + bytes_ne_condition(cx, input, KZG_VERSIONED_HASH_OFFSET, expected_hash) +} + +fn expr_eq_condition(cx: &mut SymCx, expr: &SymExpr, value: usize) -> SymBoolExpr { + SymBoolExpr::eq_word_const(cx, expr, U256::from(value)) +} + +fn expr_ne_condition(cx: &mut SymCx, expr: &SymExpr, value: usize) -> SymBoolExpr { + let condition = expr_eq_condition(cx, expr, value); + condition.not(cx) +} + +fn byte_eq_condition(cx: &mut SymCx, input: &[SymExpr], offset: usize, value: u8) -> SymBoolExpr { + match input.get(offset) { + Some(expr) => expr_eq_condition(cx, expr, value as usize), + None => SymBoolExpr::constant(cx, false), + } +} + +fn byte_ne_condition(cx: &mut SymCx, input: &[SymExpr], offset: usize, value: u8) -> SymBoolExpr { + match input.get(offset) { + Some(expr) => expr_ne_condition(cx, expr, value as usize), + None => SymBoolExpr::constant(cx, false), + } +} + +fn bytes_eq_condition( + cx: &mut SymCx, + input: &[SymExpr], + offset: usize, + bytes: &[u8], +) -> SymBoolExpr { + let Some(end) = offset.checked_add(bytes.len()) else { + return SymBoolExpr::constant(cx, false); + }; + if end > input.len() { + return SymBoolExpr::constant(cx, false); + } + let conditions = input[offset..end] + .iter() + .zip(bytes) + .map(|(expr, byte)| expr_eq_condition(cx, expr, *byte as usize)) + .collect(); + SymBoolExpr::and(cx, conditions) +} + +fn bytes_ne_condition( + cx: &mut SymCx, + input: &[SymExpr], + offset: usize, + bytes: &[u8], +) -> SymBoolExpr { + let Some(end) = offset.checked_add(bytes.len()) else { + return SymBoolExpr::constant(cx, false); + }; + if end > input.len() { + return SymBoolExpr::constant(cx, false); + } + let conditions = input[offset..end] + .iter() + .zip(bytes) + .map(|(expr, byte)| expr_ne_condition(cx, expr, *byte as usize)) + .collect(); + SymBoolExpr::or(cx, conditions) +} + +fn constrained_bytes_at( + cx: &mut SymCx, + state: &PathState, + input: &[SymExpr], + offset: usize, + len: usize, +) -> Option> { + let end = offset.checked_add(len)?; + let bytes = input.get(offset..end)?; + bytes.iter().map(|byte| constrained_byte(cx, state, byte)).collect() +} + +fn constrained_byte(cx: &mut SymCx, state: &PathState, byte: &SymExpr) -> Option { + state.constrained_word(cx, byte).and_then(|byte| u8::try_from(byte).ok()) +} + +fn ensure_expr_not_gasleft(expr: &SymExpr) -> Result<(), SymbolicError> { + if expr.contains_gasleft() { + Err(SymbolicError::Unsupported("GAS/gasleft() not modeled")) + } else { + Ok(()) + } +} diff --git a/crates/evm/symbolic/src/executor/cheatcodes.rs b/crates/evm/symbolic/src/executor/cheatcodes.rs new file mode 100644 index 0000000000000..b029a4c1f8ebe --- /dev/null +++ b/crates/evm/symbolic/src/executor/cheatcodes.rs @@ -0,0 +1,2414 @@ +use foundry_cheatcodes_spec::Vm::*; +use foundry_evm::{ + core::backend::GLOBAL_FAIL_SLOT, inspectors::cheatcodes::current_execution_context, +}; + +use super::*; + +impl SymbolicExecutor { + pub(super) fn handle_assertion( + &mut self, + state: &mut PathState, + pass: SymBoolExpr, + ) -> Result { + let fail = pass.clone().not(&mut self.cx); + match fail.as_const() { + Some(true) => return Ok(CheatcodeOutcome::Failure), + Some(false) => return Ok(CheatcodeOutcome::Continue(Vec::new())), + None => {} + } + + let mut fail_constraints = state.constraints.clone(); + fail_constraints.push(fail); + if self.is_sat_with_state(state, &fail_constraints)? { + state.constraints = fail_constraints; + return Ok(CheatcodeOutcome::Failure); + } + + state.constraints.push(pass); + Ok(CheatcodeOutcome::Continue(Vec::new())) + } + + pub(super) fn handle_full_word_array_assertion( + &mut self, + state: &mut PathState, + selector: [u8; 4], + input_offset: &SymExpr, + input_size: &SymExpr, + maximum_input_size: usize, + ) -> Result { + const HEAD_SIZE: usize = 4 + 2 * 32; + if maximum_input_size < HEAD_SIZE { + return Err(SymbolicError::Unsupported("short symbolic array assertion CALL")); + } + + let minimum_offset = state.lower_bound_usize(input_offset); + let maximum_offset = state.upper_bound_usize(&mut self.cx, input_offset); + let mut required_size = HEAD_SIZE; + let mut layouts = [(0usize, 0usize); 2]; + + for (index, layout) in layouts.iter_mut().enumerate() { + let head_offset = 4 + index * 32; + let offset = state.memory.load_word_offset_with_bounds( + &mut self.cx, + input_offset, + head_offset, + minimum_offset, + maximum_offset, + ); + let offset = self + .constrained_word_with_solver(state, &offset)? + .and_then(|offset| usize::try_from(offset).ok()) + .ok_or(SymbolicError::Unsupported("symbolic array assertion offset"))?; + + let length_offset = 4usize + .checked_add(offset) + .ok_or(SymbolicError::Unsupported("symbolic array assertion ABI decode"))?; + let elements_offset = length_offset + .checked_add(32) + .ok_or(SymbolicError::Unsupported("symbolic array assertion ABI decode"))?; + if elements_offset > maximum_input_size { + return Err(SymbolicError::Unsupported("short symbolic array assertion CALL")); + } + let length = state.memory.load_word_offset_with_bounds( + &mut self.cx, + input_offset, + length_offset, + minimum_offset, + maximum_offset, + ); + let length = self + .constrained_word_with_solver(state, &length)? + .and_then(|length| usize::try_from(length).ok()) + .ok_or(SymbolicError::Unsupported("symbolic array assertion length"))?; + let byte_length = length + .checked_mul(32) + .ok_or(SymbolicError::Unsupported("symbolic array assertion ABI decode"))?; + let end = elements_offset + .checked_add(byte_length) + .ok_or(SymbolicError::Unsupported("symbolic array assertion ABI decode"))?; + if end > maximum_input_size { + return Err(SymbolicError::Unsupported("short symbolic array assertion CALL")); + } + required_size = required_size.max(end); + *layout = (elements_offset, length); + } + + if !self.proves_expr_at_least(state, input_size, required_size)? { + return Err(SymbolicError::Unsupported("symbolic array assertion CALL input size")); + } + + let [(left_offset, left_len), (right_offset, right_len)] = layouts; + let mut condition = if left_len == right_len { + let mut equal = Vec::with_capacity(left_len); + for element in 0..left_len { + let offset = element + .checked_mul(32) + .ok_or(SymbolicError::Unsupported("symbolic array assertion ABI decode"))?; + let left = state.memory.load_word_offset_with_bounds( + &mut self.cx, + input_offset, + left_offset + .checked_add(offset) + .ok_or(SymbolicError::Unsupported("symbolic array assertion ABI decode"))?, + minimum_offset, + maximum_offset, + ); + let right = state.memory.load_word_offset_with_bounds( + &mut self.cx, + input_offset, + right_offset + .checked_add(offset) + .ok_or(SymbolicError::Unsupported("symbolic array assertion ABI decode"))?, + minimum_offset, + maximum_offset, + ); + equal.push(SymBoolExpr::eq(&mut self.cx, left, right)); + } + SymBoolExpr::and(&mut self.cx, equal) + } else { + SymBoolExpr::constant(&mut self.cx, false) + }; + if matches!( + selector, + assertNotEq_16Call::SELECTOR + | assertNotEq_18Call::SELECTOR + | assertNotEq_22Call::SELECTOR + ) { + condition = condition.not(&mut self.cx); + } + self.handle_assertion(state, condition) + } + + pub(super) fn set_expected_revert( + &mut self, + state: &mut PathState, + data: ExpectedRevertData, + reverter: Option, + remaining: u64, + ) -> CheatcodeOutcome { + if state.expected_revert.is_some() { + return CheatcodeOutcome::Revert(error_string_return_data( + &mut self.cx, + "you must call another function prior to expecting a second revert", + )); + } + state.expected_revert = Some(ExpectedRevert::new(data, reverter, remaining)); + CheatcodeOutcome::Continue(Vec::new()) + } + + fn expect_emit_from_args( + &mut self, + state: &mut PathState, + args_offset: usize, + checks: ExpectedEmitChecks, + emitter_arg: Option, + count_arg: Option, + ) -> Result { + let emitter = emitter_arg + .map(|index| read_abi_word_arg(&mut self.cx, &state.memory, args_offset, index)) + .transpose()?; + let remaining = count_arg + .map(|index| { + read_abi_u64_arg( + &mut self.cx, + &state.memory, + args_offset, + index, + "symbolic vm.expectEmit", + ) + }) + .transpose()? + .unwrap_or(1); + state.expected_emit = Some(ExpectedEmit::new(checks, emitter, remaining)); + Ok(CheatcodeOutcome::Continue(Vec::new())) + } + + #[expect(clippy::too_many_arguments)] + pub(super) fn set_expected_call( + &mut self, + state: &mut PathState, + callee: SymExpr, + value: Option, + gas: Option, + min_gas: Option, + data: SymBytes, + count: Option, + ) -> CheatcodeOutcome { + let expected = ExpectedCall::new(callee, value, gas, min_gas, data, count); + match register_expected_call(&mut state.expected_calls, &mut self.cx, expected) { + Ok(()) => CheatcodeOutcome::Continue(Vec::new()), + Err(message) => { + CheatcodeOutcome::Revert(error_string_return_data(&mut self.cx, message)) + } + } + } + + pub(super) fn set_expected_create( + &mut self, + state: &mut PathState, + bytecode: Vec, + deployer: SymExpr, + kind: CreateKind, + ) -> CheatcodeOutcome { + state.expected_creates.push(ExpectedCreate::new(bytecode, deployer, kind)); + CheatcodeOutcome::Continue(Vec::new()) + } + + #[expect(clippy::too_many_arguments)] + pub(super) fn deploy_code_cheatcode_if_needed( + &mut self, + executor: &Executor, + state: &mut PathState, + worklist: &mut VecDeque, + completed_paths: &mut usize, + selector: [u8; 4], + in_offset: usize, + out_offset: SymExpr, + out_size: &BoundedCopySize, + ) -> Result, SymbolicError> { + let args_offset = in_offset + 4; + let (artifact, constructor_args) = if selector == deployCode_0Call::SELECTOR { + let artifact = read_abi_string_arg( + &mut self.cx, + &state.memory, + args_offset, + 0, + "symbolic vm.deployCode", + )?; + (artifact, Vec::new()) + } else if selector == deployCode_1Call::SELECTOR { + let artifact = read_abi_string_arg( + &mut self.cx, + &state.memory, + args_offset, + 0, + "symbolic vm.deployCode", + )?; + let args = read_abi_dynamic_bytes_arg( + &mut self.cx, + &state.memory, + args_offset, + 1, + "symbolic vm.deployCode args", + )?; + (artifact, args) + } else { + return Ok(None); + }; + + self.deploy_code_cheatcode_call( + executor, + state, + worklist, + completed_paths, + artifact, + constructor_args, + out_offset, + out_size, + ) + .map(Some) + } + + #[expect(clippy::too_many_arguments)] + pub(super) fn deploy_code_cheatcode_call( + &mut self, + executor: &Executor, + state: &mut PathState, + worklist: &mut VecDeque, + completed_paths: &mut usize, + artifact: String, + constructor_args: Vec, + out_offset: SymExpr, + out_size: &BoundedCopySize, + ) -> Result { + if state.is_static { + state.return_data = SymReturnData::empty(&mut self.cx); + return Ok(StepOutcome::Revert); + } + + self.stateless_retry_safe = false; + let mut initcode = artifact_code(&artifact, false)?; + initcode.extend_from_slice(&constructor_args); + let initcode = SymCode::concrete(&mut self.cx, initcode); + + let nonce = state.world.nonce(executor, state.address)?; + let created = state.address.create(nonce); + let created_word = SymExpr::constant(&mut self.cx, address_word(created)); + + let mut failure_world = state.world.clone(); + failure_world.increment_nonce(executor, state.address)?; + if failure_world.has_code_or_nonce(&mut self.cx, executor, created)? { + state.world = failure_world; + let zero = SymExpr::zero(&mut self.cx); + let return_data = SymReturnData::from_words(&mut self.cx, vec![zero]); + complete_cheatcode_call(&mut self.cx, state, out_offset, out_size, return_data)?; + return Ok(StepOutcome::Continue); + } + + let zero = SymExpr::zero(&mut self.cx); + let calldata = SymBytes::empty(&mut self.cx); + let calldata = SymCalldata::from_bytes(&mut self.cx, calldata); + let mut frame = + CallFrame::new(&mut self.cx, created, created, state.address, zero, false, calldata); + frame.address_word = created_word.clone(); + frame.caller_word = state.address_word.clone(); + let mut child = state.child(frame); + let pending_expected_creates = std::mem::take(&mut child.expected_creates); + child.world = failure_world.clone(); + child.world.mark_current_transaction_created(created); + child.world.set_nonce(created, 1); + + let outcomes = self.execute_external_call(executor, child, &initcode, completed_paths)?; + if outcomes.is_empty() { + return Ok(StepOutcome::AssumeRejected); + } + + let mut parents = VecDeque::with_capacity(outcomes.len()); + for outcome in outcomes { + match self.join_call_outcome(state, outcome, created)? { + JoinedCallOutcome::Rejected => {} + JoinedCallOutcome::Failure(parent) => { + *state = parent; + return Ok(StepOutcome::Failure); + } + JoinedCallOutcome::ExceptionalHalt(mut parent) => { + parent.world = failure_world.clone(); + parent.return_data = SymReturnData::empty(&mut self.cx); + parent.copy_call_output_offset(&mut self.cx, out_offset.clone(), out_size)?; + parent.stack.push(SymExpr::zero(&mut self.cx))?; + parents.push_back(parent); + } + JoinedCallOutcome::ExpectedRevert { mut parent, .. } => { + parent.expected_creates = pending_expected_creates.clone(); + parent.world = failure_world.clone(); + let zero = SymExpr::zero(&mut self.cx); + let return_data = SymReturnData::from_words(&mut self.cx, vec![zero]); + complete_cheatcode_call( + &mut self.cx, + &mut parent, + out_offset.clone(), + out_size, + return_data, + )?; + parents.push_back(parent); + } + JoinedCallOutcome::Success { mut parent, child } => { + parent.world = child.world; + parent.expected_emit = child.expected_emit; + parent.expected_creates = pending_expected_creates.clone(); + self.observe_expected_create( + &mut parent, + state.address, + CreateKind::Create, + &child.frame.return_data, + )?; + if !parent.world.is_destroyed(created) { + parent + .world + .install_code(created, child.frame.return_data.to_code(&mut self.cx)?); + parent.world.set_nonce(created, 1); + } + let return_data = + SymReturnData::from_words(&mut self.cx, vec![created_word.clone()]); + complete_cheatcode_call( + &mut self.cx, + &mut parent, + out_offset.clone(), + out_size, + return_data, + )?; + parents.push_back(parent); + } + JoinedCallOutcome::Revert { mut parent, child } => { + parent.world = failure_world.clone(); + parent.return_data = child.frame.return_data; + parent.copy_call_output_offset(&mut self.cx, out_offset.clone(), out_size)?; + parent.stack.push(SymExpr::zero(&mut self.cx))?; + parents.push_back(parent); + } + } + } + + Ok(self.resume_parent_paths(state, worklist, parents)) + } + + pub(super) fn observe_expected_create( + &mut self, + state: &mut PathState, + deployer: Address, + kind: CreateKind, + runtime: &SymReturnData, + ) -> Result<(), SymbolicError> { + if state.expected_creates.is_empty() { + return Ok(()); + } + let bytecode = runtime.read_concrete(&mut self.cx, "symbolic expected create bytecode")?; + let mut mismatch_constraints = None; + for idx in 0..state.expected_creates.len() { + let Some(condition) = state.expected_creates[idx].match_condition( + &mut self.cx, + deployer, + kind, + &bytecode, + ) else { + continue; + }; + let (match_constraints, match_sat) = + self.constraints_with_condition(state, condition.clone())?; + let mismatch_condition = condition.not(&mut self.cx); + let (candidate_mismatch_constraints, mismatch_sat) = + self.constraints_with_condition(state, mismatch_condition)?; + + if match_sat && !mismatch_sat { + state.constraints = match_constraints; + state.expected_creates.swap_remove(idx); + return Ok(()); + } + + if mismatch_sat { + mismatch_constraints.get_or_insert(candidate_mismatch_constraints); + } + } + + if let Some(constraints) = mismatch_constraints { + state.constraints = constraints; + } + Ok(()) + } + + pub(super) fn branch_accesses_cheatcode_if_needed( + &mut self, + state: &mut PathState, + worklist: &mut VecDeque, + selector: [u8; 4], + in_offset: usize, + out_offset: SymExpr, + out_size: &BoundedCopySize, + ) -> Result, SymbolicError> { + if selector != accessesCall::SELECTOR { + return Ok(None); + } + + let Some(record) = state.access_record.clone() else { + return Ok(None); + }; + let target = read_abi_word_arg(&mut self.cx, &state.memory, in_offset + 4, 0)?; + if target.as_const().is_some() { + return Ok(None); + } + + let addresses = record.addresses(); + if addresses.is_empty() { + return Ok(None); + } + + let mut branches = VecDeque::new(); + let mut matched_conditions = Vec::new(); + for address in addresses { + let condition = target.address_match_condition(&mut self.cx, address); + matched_conditions.push(condition.clone()); + if let Some(constraints) = self.constraints_for_condition(state, condition)? { + let mut branch = state.clone(); + branch.constraints = constraints; + let return_data = accesses_return_data(&mut self.cx, Some(&record), address); + complete_cheatcode_call( + &mut self.cx, + &mut branch, + out_offset.clone(), + out_size, + return_data, + )?; + branches.push_back(branch); + } + } + + let unmatched_conditions = + matched_conditions.into_iter().map(|condition| condition.not(&mut self.cx)).collect(); + let unmatched_condition = SymBoolExpr::and(&mut self.cx, unmatched_conditions); + if let Some(constraints) = self.constraints_for_condition(state, unmatched_condition)? { + let mut branch = state.clone(); + branch.constraints = constraints; + let return_data = accesses_return_data(&mut self.cx, Some(&record), Address::ZERO); + complete_cheatcode_call(&mut self.cx, &mut branch, out_offset, out_size, return_data)?; + branches.push_back(branch); + } + + let Some(first_branch) = self.pop_next_path(&mut branches) else { + return Ok(Some(StepOutcome::AssumeRejected)); + }; + *state = first_branch; + worklist.extend(branches); + Ok(Some(StepOutcome::Continue)) + } + + pub(super) fn accesses_return_data_for_target( + &mut self, + state: &mut PathState, + target: SymExpr, + ) -> Result { + let Some(record) = state.access_record.clone() else { + return Ok(accesses_return_data(&mut self.cx, None, Address::ZERO)); + }; + + if let Some(target) = target.as_const() { + return Ok(accesses_return_data(&mut self.cx, Some(&record), word_to_address(target))); + } + + let addresses = record.addresses(); + if addresses.is_empty() { + return Ok(accesses_return_data(&mut self.cx, Some(&record), Address::ZERO)); + } + + for address in addresses { + let condition = target.address_match_condition(&mut self.cx, address); + let (match_constraints, match_sat) = + self.constraints_with_condition(state, condition.clone())?; + let mismatch_condition = condition.not(&mut self.cx); + let (_, mismatch_sat) = self.constraints_with_condition(state, mismatch_condition)?; + + match (match_sat, mismatch_sat) { + (true, false) => { + state.constraints = match_constraints; + return Ok(accesses_return_data(&mut self.cx, Some(&record), address)); + } + (true, true) => { + return Err(SymbolicError::Unsupported("symbolic vm.accesses address")); + } + (false, _) => {} + } + } + + Ok(accesses_return_data(&mut self.cx, Some(&record), Address::ZERO)) + } + + pub(super) fn add_call_mock( + &mut self, + state: &mut PathState, + callee: SymExpr, + value: Option, + data: SymBytes, + returns: Vec, + reverts: bool, + ) -> CheatcodeOutcome { + // Replace identical definitions in place to preserve mock precedence. + if let Some(existing) = state.call_mocks.iter_mut().find(|mock| { + mock.callee == callee + && mock.value() == value + && mock.data.same_bytes(&mut self.cx, &data) + }) { + *existing = CallMock::new(callee, value, data, returns, reverts); + } else { + state.call_mocks.push(CallMock::new(callee, value, data, returns, reverts)); + } + CheatcodeOutcome::Continue(Vec::new()) + } + + pub(super) fn set_function_mock( + &mut self, + state: &mut PathState, + callee: SymExpr, + target: Address, + data: SymBytes, + ) -> CheatcodeOutcome { + if let Some(mock) = state + .function_mocks + .iter_mut() + .find(|mock| mock.matches_definition(&mut self.cx, &callee, &data)) + { + mock.set_target(target); + } else { + state.function_mocks.push(FunctionMock::new(callee, target, data)); + } + CheatcodeOutcome::Continue(Vec::new()) + } + + pub(super) fn handle_foundry_cheatcode( + &mut self, + executor: &Executor, + state: &mut PathState, + selector: [u8; 4], + in_offset: &SymExpr, + input_size: &SymExpr, + in_size: usize, + ) -> Result { + if is_full_word_array_assertion(selector) { + return self + .handle_full_word_array_assertion(state, selector, in_offset, input_size, in_size); + } + let in_offset = in_offset.as_usize_or("symbolic cheatcode CALL input offset")?; + let args_offset = in_offset + 4; + match selector { + assumeCall::SELECTOR => self.handle_assume(state, in_offset + 4), + assumeNoRevert_0Call::SELECTOR + | assumeNoRevert_1Call::SELECTOR + | assumeNoRevert_2Call::SELECTOR => { + if state.assume_no_revert_next_call.is_some() { + return Err(SymbolicError::Unsupported("symbolic vm.assumeNoRevert overlap")); + } + let filter = if selector == assumeNoRevert_0Call::SELECTOR { + AssumeNoRevert::Any + } else { + let single = selector == assumeNoRevert_1Call::SELECTOR; + let mut values = + decode_cheatcode_args(&mut self.cx, state, selector, in_offset, in_size)?; + let value = values + .pop() + .ok_or(SymbolicError::Unsupported("symbolic vm.assumeNoRevert decode"))?; + AssumeNoRevert::Filtered(if single { + vec![dyn_potential_revert(&mut self.cx, &value)?] + } else { + dyn_potential_reverts(&mut self.cx, &value)? + }) + }; + state.assume_no_revert_next_call = Some(filter); + Ok(CheatcodeOutcome::Continue(Vec::new())) + } + skip_0Call::SELECTOR | skip_1Call::SELECTOR => self.handle_skip(state, in_offset + 4), + recordLogsCall::SELECTOR => { + state.recorded_logs = Some(Vec::new()); + Ok(CheatcodeOutcome::Continue(Vec::new())) + } + recordCall::SELECTOR => { + state.access_record = Some(AccessRecord::default()); + Ok(CheatcodeOutcome::Continue(Vec::new())) + } + stopRecordCall::SELECTOR => { + state.access_record = None; + Ok(CheatcodeOutcome::Continue(Vec::new())) + } + accessesCall::SELECTOR => { + let target = read_abi_word_arg(&mut self.cx, &state.memory, args_offset, 0)?; + Ok(CheatcodeOutcome::ContinueData( + self.accesses_return_data_for_target(state, target)?, + )) + } + registerSloadHookCall::SELECTOR | registerSstoreHookCall::SELECTOR => { + let target = read_abi_address_arg( + &mut self.cx, + &state.memory, + args_offset, + 0, + "symbolic storage hook target", + )?; + let callback: [u8; 4] = state + .memory + .read_concrete(&mut self.cx, args_offset + 32, 4)? + .try_into() + .map_err(|_| SymbolicError::Unsupported("symbolic storage hook callback"))?; + let hook = SymbolicStorageHook { + callback_target: state.address, + callback_selector: callback, + }; + if selector == registerSloadHookCall::SELECTOR { + state.storage_load_hooks.insert(target, hook); + } else { + if state + .mapping_storage_store_hooks + .keys() + .any(|(address, _)| *address == target) + { + return Ok(CheatcodeOutcome::Revert(error_string_return_data( + &mut self.cx, + "cannot register raw SSTORE hook: mapping SSTORE hooks already exist for target", + ))); + } + state.storage_store_hooks.insert(target, hook); + } + Ok(CheatcodeOutcome::Continue(Vec::new())) + } + registerMappingSstoreHookCall::SELECTOR => { + let target = read_abi_address_arg( + &mut self.cx, + &state.memory, + args_offset, + 0, + "symbolic mapping storage hook target", + )?; + let root = read_abi_concrete_word_arg( + &mut self.cx, + &state.memory, + args_offset, + 1, + "symbolic mapping storage hook root", + )?; + let callback: [u8; 4] = state + .memory + .read_concrete(&mut self.cx, args_offset + 64, 4)? + .try_into() + .map_err(|_| { + SymbolicError::Unsupported("symbolic mapping storage hook callback") + })?; + let hook = SymbolicStorageHook { + callback_target: state.address, + callback_selector: callback, + }; + if state.storage_store_hooks.contains_key(&target) { + return Ok(CheatcodeOutcome::Revert(error_string_return_data( + &mut self.cx, + "cannot register mapping SSTORE hook: raw SSTORE hook already exists for target", + ))); + } + state.mapping_hook_keccak_preimages.retain(|(account, _), _| *account != target); + state.mapping_storage_store_hooks.insert((target, root), hook); + Ok(CheatcodeOutcome::Continue(Vec::new())) + } + getRecordedLogsCall::SELECTOR => { + let logs = state.recorded_logs.replace(Vec::new()).unwrap_or_default(); + Ok(CheatcodeOutcome::ContinueData(recorded_logs_return_data(&mut self.cx, logs))) + } + getRecordedLogsJsonCall::SELECTOR => { + let logs = state.recorded_logs.replace(Vec::new()).unwrap_or_default(); + Ok(CheatcodeOutcome::ContinueData(recorded_logs_json_return_data( + &mut self.cx, + logs, + )?)) + } + expectRevert_0Call::SELECTOR + | expectRevert_1Call::SELECTOR + | expectRevert_2Call::SELECTOR + | expectRevert_3Call::SELECTOR + | expectRevert_4Call::SELECTOR + | expectRevert_5Call::SELECTOR + | expectRevert_6Call::SELECTOR + | expectRevert_7Call::SELECTOR + | expectRevert_8Call::SELECTOR + | expectRevert_9Call::SELECTOR + | expectRevert_10Call::SELECTOR + | expectRevert_11Call::SELECTOR + | expectPartialRevert_0Call::SELECTOR + | expectPartialRevert_1Call::SELECTOR => { + let mut data = ExpectedRevertData::Any; + let mut reverter = None; + let mut count = 1; + for (index, param) in vm_params(selector).into_iter().enumerate() { + match param { + DynSolType::FixedBytes(4) => { + let selector = read_abi_bytes4_words_arg( + &mut self.cx, + &state.memory, + args_offset, + index, + ); + data = + ExpectedRevertData::Prefix(SymBytes::exprs(&mut self.cx, selector)); + } + DynSolType::Bytes => { + let bytes = read_abi_symbolic_dynamic_byte_exprs_arg( + &mut self.cx, + state, + args_offset, + index, + self.config.max_calldata_bytes as usize, + "symbolic vm.expectRevert", + )?; + data = ExpectedRevertData::Exact(SymBytes::exprs(&mut self.cx, bytes)); + } + DynSolType::Address => { + reverter = Some(read_abi_word_arg( + &mut self.cx, + &state.memory, + args_offset, + index, + )?); + } + _ => { + count = read_abi_u64_arg( + &mut self.cx, + &state.memory, + args_offset, + index, + "symbolic vm.expectRevert", + )?; + } + } + } + Ok(self.set_expected_revert(state, data, reverter, count)) + } + expectEmitAnonymous_0Call::SELECTOR + | expectEmitAnonymous_1Call::SELECTOR + | expectEmitAnonymous_2Call::SELECTOR + | expectEmitAnonymous_3Call::SELECTOR + | expectEmit_0Call::SELECTOR + | expectEmit_1Call::SELECTOR + | expectEmit_2Call::SELECTOR + | expectEmit_3Call::SELECTOR + | expectEmit_4Call::SELECTOR + | expectEmit_5Call::SELECTOR + | expectEmit_6Call::SELECTOR + | expectEmit_7Call::SELECTOR => { + let params = vm_params(selector); + let checks = match params.iter().filter(|param| **param == DynSolType::Bool).count() + { + 0 => ExpectedEmitChecks::default(), + 4 => ExpectedEmitChecks::from_non_anonymous_args( + &mut self.cx, + &state.memory, + args_offset, + )?, + _ => ExpectedEmitChecks::from_anonymous_args( + &mut self.cx, + &state.memory, + args_offset, + )?, + }; + let emitter = params.iter().position(|param| *param == DynSolType::Address); + let count = params.iter().position(|param| *param == DynSolType::Uint(64)); + self.expect_emit_from_args(state, args_offset, checks, emitter, count) + } + expectCall_0Call::SELECTOR + | expectCall_1Call::SELECTOR + | expectCall_2Call::SELECTOR + | expectCall_3Call::SELECTOR + | expectCall_4Call::SELECTOR + | expectCall_5Call::SELECTOR + | expectCallMinGas_0Call::SELECTOR + | expectCallMinGas_1Call::SELECTOR => { + let callee = read_abi_word_arg(&mut self.cx, &state.memory, args_offset, 0)?; + let mut value = None; + let mut gas = None; + let mut data = None; + let mut count = None; + for (index, param) in vm_params(selector).into_iter().enumerate().skip(1) { + match param { + DynSolType::Uint(256) => { + value = Some(read_abi_concrete_word_arg( + &mut self.cx, + &state.memory, + args_offset, + index, + "symbolic vm.expectCall", + )?); + } + DynSolType::Bytes => { + data = Some(read_abi_symbolic_dynamic_byte_exprs_arg( + &mut self.cx, + state, + args_offset, + index, + self.config.max_calldata_bytes as usize, + "symbolic vm.expectCall", + )?); + } + _ => { + let arg = read_abi_u64_arg( + &mut self.cx, + &state.memory, + args_offset, + index, + "symbolic vm.expectCall", + )?; + if data.is_some() { count = Some(arg) } else { gas = Some(arg) } + } + } + } + let data = SymBytes::exprs(&mut self.cx, data.unwrap_or_default()); + let (gas, min_gas) = if matches!( + selector, + expectCallMinGas_0Call::SELECTOR | expectCallMinGas_1Call::SELECTOR + ) { + (None, gas) + } else { + (gas, None) + }; + Ok(self.set_expected_call(state, callee, value, gas, min_gas, data, count)) + } + expectCreateCall::SELECTOR | expectCreate2Call::SELECTOR => { + let bytecode = read_abi_dynamic_bytes_arg( + &mut self.cx, + &state.memory, + args_offset, + 0, + "symbolic vm.expectCreate bytecode", + )?; + let deployer = read_abi_word_arg(&mut self.cx, &state.memory, args_offset, 1)?; + let kind = if selector == expectCreateCall::SELECTOR { + CreateKind::Create + } else { + CreateKind::Create2 + }; + Ok(self.set_expected_create(state, bytecode, deployer, kind)) + } + clearMockedCallsCall::SELECTOR => { + state.call_mocks.clear(); + Ok(CheatcodeOutcome::Continue(Vec::new())) + } + mockCall_0Call::SELECTOR + | mockCall_1Call::SELECTOR + | mockCall_2Call::SELECTOR + | mockCall_3Call::SELECTOR + | mockCallRevert_0Call::SELECTOR + | mockCallRevert_1Call::SELECTOR + | mockCallRevert_2Call::SELECTOR + | mockCallRevert_3Call::SELECTOR => { + let revert = matches!( + selector, + mockCallRevert_0Call::SELECTOR + | mockCallRevert_1Call::SELECTOR + | mockCallRevert_2Call::SELECTOR + | mockCallRevert_3Call::SELECTOR + ); + let message = + if revert { "symbolic vm.mockCallRevert" } else { "symbolic vm.mockCall" }; + let params = vm_params(selector); + let callee = read_abi_word_arg(&mut self.cx, &state.memory, args_offset, 0)?; + let value = (params[1] == DynSolType::Uint(256)) + .then(|| { + read_abi_concrete_word_arg( + &mut self.cx, + &state.memory, + args_offset, + 1, + message, + ) + }) + .transpose()?; + let data_index = params.len() - 2; + let data = if params[data_index] == DynSolType::FixedBytes(4) { + read_abi_bytes4_words_arg(&mut self.cx, &state.memory, args_offset, data_index) + } else { + read_abi_symbolic_dynamic_byte_exprs_arg( + &mut self.cx, + state, + args_offset, + data_index, + self.config.max_calldata_bytes as usize, + message, + )? + }; + let ret = read_abi_dynamic_return_data_arg( + &mut self.cx, + state, + args_offset, + data_index + 1, + self.config.max_calldata_bytes as usize, + message, + )?; + let data = SymBytes::exprs(&mut self.cx, data); + Ok(self.add_call_mock(state, callee, value, data, vec![ret], revert)) + } + mockCalls_0Call::SELECTOR | mockCalls_1Call::SELECTOR => { + let has_value = selector == mockCalls_1Call::SELECTOR; + let (value, data_idx, ret_idx) = if has_value { + let value = read_abi_concrete_word_arg( + &mut self.cx, + &state.memory, + args_offset, + 1, + "symbolic vm.mockCalls", + )?; + (Some(value), 2, 3) + } else { + (None, 1, 2) + }; + let callee = read_abi_word_arg(&mut self.cx, &state.memory, args_offset, 0)?; + let data = read_abi_symbolic_dynamic_byte_exprs_arg( + &mut self.cx, + state, + args_offset, + data_idx, + self.config.max_calldata_bytes as usize, + "symbolic vm.mockCalls data", + )?; + let returns = read_abi_symbolic_dynamic_bytes_array_arg( + &mut self.cx, + state, + args_offset, + ret_idx, + self.config.max_dynamic_length as usize, + self.config.max_calldata_bytes as usize, + )?; + let data = SymBytes::exprs(&mut self.cx, data); + Ok(self.add_call_mock(state, callee, value, data, returns, false)) + } + mockFunctionCall::SELECTOR => { + let callee = read_abi_word_arg(&mut self.cx, &state.memory, args_offset, 0)?; + let target = read_abi_address_arg( + &mut self.cx, + &state.memory, + args_offset, + 1, + "symbolic vm.mockFunction", + )?; + let data = read_abi_symbolic_dynamic_byte_exprs_arg( + &mut self.cx, + state, + args_offset, + 2, + self.config.max_calldata_bytes as usize, + "symbolic vm.mockFunction", + )?; + let data = SymBytes::exprs(&mut self.cx, data); + Ok(self.set_function_mock(state, callee, target, data)) + } + prank_0Call::SELECTOR + | prank_1Call::SELECTOR + | prank_2Call::SELECTOR + | prank_3Call::SELECTOR + | startPrank_0Call::SELECTOR + | startPrank_1Call::SELECTOR + | startPrank_2Call::SELECTOR + | startPrank_3Call::SELECTOR => { + let persistent = matches!( + selector, + startPrank_0Call::SELECTOR + | startPrank_1Call::SELECTOR + | startPrank_2Call::SELECTOR + | startPrank_3Call::SELECTOR + ); + let (message, delegate_message) = if persistent { + ("symbolic vm.startPrank", "symbolic vm.startPrank delegatecall") + } else { + ("symbolic vm.prank", "symbolic vm.prank delegatecall") + }; + let params = vm_params(selector); + if params.last() == Some(&DynSolType::Bool) + && read_abi_bool_arg( + &mut self.cx, + &state.memory, + args_offset, + params.len() - 1, + message, + )? + { + return Err(SymbolicError::Unsupported(delegate_message)); + } + let caller = read_abi_address_word_or_symbolic_slot_arg( + &mut self.cx, + state, + args_offset, + 0, + )?; + let origin = (params.get(1) == Some(&DynSolType::Address)) + .then(|| { + read_abi_address_word_or_symbolic_slot_arg( + &mut self.cx, + state, + args_offset, + 1, + ) + }) + .transpose()?; + if persistent { + state.prank.set_persistent(caller, origin); + } else { + state.prank.set_next(caller, origin); + } + Ok(CheatcodeOutcome::Continue(Vec::new())) + } + stopPrankCall::SELECTOR => { + state.prank = SymbolicPrank::default(); + Ok(CheatcodeOutcome::Continue(Vec::new())) + } + readCallersCall::SELECTOR => { + Ok(CheatcodeOutcome::Continue(state.read_callers_words(&mut self.cx))) + } + addrCall::SELECTOR => { + let private_key = read_abi_constrained_word_arg( + &mut self.cx, + state, + args_offset, + 0, + "symbolic vm.addr", + )?; + let address = private_key_address(private_key)?; + let address = SymExpr::constant(&mut self.cx, address_word(address)); + Ok(CheatcodeOutcome::Continue(vec![address])) + } + sign_1Call::SELECTOR | signCompact_1Call::SELECTOR => { + let compact = selector == signCompact_1Call::SELECTOR; + let message = if compact { "symbolic vm.signCompact" } else { "symbolic vm.sign" }; + let private_key = + read_abi_constrained_word_arg(&mut self.cx, state, args_offset, 0, message)?; + let digest = + read_abi_constrained_word_arg(&mut self.cx, state, args_offset, 1, message)?; + Ok(CheatcodeOutcome::Continue(if compact { + sign_compact_hash_words(&mut self.cx, private_key, digest)? + } else { + sign_hash_words(&mut self.cx, private_key, digest)? + })) + } + deriveKey_0Call::SELECTOR + | deriveKey_1Call::SELECTOR + | deriveKey_2Call::SELECTOR + | deriveKey_3Call::SELECTOR => { + let params = vm_params(selector); + let mnemonic = read_abi_string_arg( + &mut self.cx, + &state.memory, + args_offset, + 0, + "symbolic vm.deriveKey", + )?; + let index_arg = if params[1] == DynSolType::String { 2 } else { 1 }; + let path = if index_arg == 2 { + read_abi_string_arg( + &mut self.cx, + &state.memory, + args_offset, + 1, + "symbolic vm.deriveKey", + )? + } else { + DEFAULT_DERIVATION_PATH_PREFIX.to_string() + }; + let index = read_abi_u32_arg( + &mut self.cx, + &state.memory, + args_offset, + index_arg, + "symbolic vm.deriveKey", + )?; + let private_key = if params.len() > index_arg + 1 { + let language = read_abi_string_arg( + &mut self.cx, + &state.memory, + args_offset, + index_arg + 1, + "symbolic vm.deriveKey", + )?; + derive_private_key_with_language(&mnemonic, &path, index, &language)? + } else { + derive_private_key::(&mnemonic, &path, index)? + }; + let private_key = SymExpr::constant(&mut self.cx, private_key); + Ok(CheatcodeOutcome::Continue(vec![private_key])) + } + rememberKeyCall::SELECTOR => { + let private_key = read_abi_constrained_word_arg( + &mut self.cx, + state, + args_offset, + 0, + "symbolic vm.rememberKey", + )?; + let address = private_key_address(private_key)?; + state.wallets.insert(address); + let address = SymExpr::constant(&mut self.cx, address_word(address)); + Ok(CheatcodeOutcome::Continue(vec![address])) + } + rememberKeys_0Call::SELECTOR | rememberKeys_1Call::SELECTOR => { + let mnemonic = read_abi_string_arg( + &mut self.cx, + &state.memory, + args_offset, + 0, + "symbolic vm.rememberKeys", + )?; + let path = read_abi_string_arg( + &mut self.cx, + &state.memory, + args_offset, + 1, + "symbolic vm.rememberKeys", + )?; + let (language, count_index) = if selector == rememberKeys_1Call::SELECTOR { + ( + Some(read_abi_string_arg( + &mut self.cx, + &state.memory, + args_offset, + 2, + "symbolic vm.rememberKeys", + )?), + 3, + ) + } else { + (None, 2) + }; + let count = read_abi_u32_arg( + &mut self.cx, + &state.memory, + args_offset, + count_index, + "symbolic vm.rememberKeys", + )?; + if count > MAX_REMEMBER_KEYS { + return Err(SymbolicError::Unsupported("symbolic vm.rememberKeys count")); + } + let mut addresses = Vec::with_capacity(count as usize); + for index in 0..count { + let private_key = if let Some(language) = &language { + derive_private_key_with_language(&mnemonic, &path, index, language)? + } else { + derive_private_key::(&mnemonic, &path, index)? + }; + let address = private_key_address(private_key)?; + state.wallets.insert(address); + addresses.push(DynSolValue::Address(address)); + } + Ok(CheatcodeOutcome::ContinueData(abi_concrete_value_return( + &mut self.cx, + DynSolValue::Array(addresses), + ))) + } + getWalletsCall::SELECTOR => { + let wallets = DynSolValue::Array( + state.wallets.iter().copied().map(DynSolValue::Address).collect(), + ); + Ok(CheatcodeOutcome::ContinueData(abi_concrete_value_return(&mut self.cx, wallets))) + } + storeCall::SELECTOR => { + let target = + read_abi_address_or_symbolic_slot_arg(&mut self.cx, state, args_offset, 0)?; + let slot = state.memory.load_word(&mut self.cx, in_offset + 36)?; + let value = state.memory.load_word(&mut self.cx, in_offset + 68)?; + let failed_slot = SymExpr::constant(&mut self.cx, GLOBAL_FAIL_SLOT); + let one = SymExpr::one(&mut self.cx); + if target == CHEATCODE_ADDRESS && slot == failed_slot && value == one { + return Ok(CheatcodeOutcome::Failure); + } + state.world.sstore(target, slot, value); + Ok(CheatcodeOutcome::Continue(Vec::new())) + } + loadCall::SELECTOR => { + let target = + read_abi_address_or_symbolic_slot_arg(&mut self.cx, state, args_offset, 0)?; + let slot = state.memory.load_word(&mut self.cx, in_offset + 36)?; + let concrete_slot = state.constrained_word(&mut self.cx, &slot); + let value = + state.world.sload(&mut self.cx, executor, target, slot, concrete_slot)?; + Ok(CheatcodeOutcome::Continue(vec![value])) + } + getNonce_0Call::SELECTOR => { + let target = + read_abi_address_or_symbolic_slot_arg(&mut self.cx, state, args_offset, 0)?; + let nonce = state.world.nonce(executor, target)?; + let nonce = SymExpr::constant(&mut self.cx, U256::from(nonce)); + Ok(CheatcodeOutcome::Continue(vec![nonce])) + } + computeCreateAddressCall::SELECTOR => { + let deployer = read_abi_word_arg(&mut self.cx, &state.memory, args_offset, 0)?; + let nonce = read_abi_word_arg(&mut self.cx, &state.memory, args_offset, 1)?; + let address = compute_create_address_word(&mut self.cx, state, deployer, nonce)?; + Ok(CheatcodeOutcome::Continue(vec![address])) + } + computeCreate2Address_0Call::SELECTOR | computeCreate2Address_1Call::SELECTOR => { + let salt = read_abi_word_arg(&mut self.cx, &state.memory, args_offset, 0)?; + let init_code_hash = + read_abi_word_arg(&mut self.cx, &state.memory, args_offset, 1)?; + let deployer = if selector == computeCreate2Address_0Call::SELECTOR { + read_abi_word_arg(&mut self.cx, &state.memory, args_offset, 2)? + } else { + SymExpr::constant(&mut self.cx, address_word(DEFAULT_CREATE2_DEPLOYER)) + }; + let address = compute_create2_address_word( + &mut self.cx, + state, + deployer, + salt, + init_code_hash, + )?; + Ok(CheatcodeOutcome::Continue(vec![address])) + } + etchCall::SELECTOR => { + let target = + read_abi_address_or_symbolic_slot_arg(&mut self.cx, state, args_offset, 0)?; + let code = read_abi_symbolic_dynamic_byte_exprs_arg( + &mut self.cx, + state, + args_offset, + 1, + self.config.max_dynamic_length as usize, + "symbolic vm.etch", + )?; + let code = SymCode::from_byte_exprs(&mut self.cx, code); + state.world.install_code(target, code); + Ok(CheatcodeOutcome::Continue(Vec::new())) + } + getCodeCall::SELECTOR | getDeployedCodeCall::SELECTOR => { + let artifact = read_abi_string_arg( + &mut self.cx, + &state.memory, + args_offset, + 0, + "symbolic vm.getCode", + )?; + self.stateless_retry_safe = false; + let code = artifact_code(&artifact, selector == getDeployedCodeCall::SELECTOR)?; + Ok(CheatcodeOutcome::ContinueData(abi_concrete_bytes_return(&mut self.cx, &code))) + } + dealCall::SELECTOR => { + let target = + read_abi_address_or_symbolic_slot_arg(&mut self.cx, state, args_offset, 0)?; + let value = read_abi_word_arg(&mut self.cx, &state.memory, args_offset, 1)?; + if value.contains_gasleft() { + return Err(SymbolicError::Unsupported("GAS/gasleft() not modeled")); + } + let value = state + .constrained_word(&mut self.cx, &value) + .map(|value| SymExpr::constant(&mut self.cx, value)) + .unwrap_or(value); + state.world.set_balance_word(target, value); + Ok(CheatcodeOutcome::Continue(Vec::new())) + } + setNonceCall::SELECTOR | setNonceUnsafeCall::SELECTOR => { + let target = + read_abi_address_or_symbolic_slot_arg(&mut self.cx, state, args_offset, 0)?; + let nonce = read_abi_constrained_word_arg( + &mut self.cx, + state, + args_offset, + 1, + "symbolic vm.setNonce", + )?; + let Ok(nonce) = u64::try_from(nonce) else { + return Err(SymbolicError::Unsupported("symbolic vm.setNonce nonce")); + }; + if selector == setNonceCall::SELECTOR + && nonce < state.world.nonce(executor, target)? + { + return Ok(CheatcodeOutcome::Failure); + } + state.world.set_nonce(target, nonce); + Ok(CheatcodeOutcome::Continue(Vec::new())) + } + resetNonceCall::SELECTOR => { + let target = + read_abi_address_or_symbolic_slot_arg(&mut self.cx, state, args_offset, 0)?; + let nonce = if state.world.extcode(&mut self.cx, executor, target)?.is_empty() { + 0 + } else { + 1 + }; + state.world.set_nonce(target, nonce); + Ok(CheatcodeOutcome::Continue(Vec::new())) + } + allowCheatcodesCall::SELECTOR => Ok(CheatcodeOutcome::Continue(Vec::new())), + makePersistent_0Call::SELECTOR + | makePersistent_1Call::SELECTOR + | makePersistent_2Call::SELECTOR => { + for index in 0..vm_params(selector).len() { + let account = read_abi_address_or_symbolic_slot_arg( + &mut self.cx, + state, + args_offset, + index, + )?; + state.persistent_accounts.insert(account); + } + Ok(CheatcodeOutcome::Continue(Vec::new())) + } + makePersistent_3Call::SELECTOR => { + let values = + decode_cheatcode_args(&mut self.cx, state, selector, in_offset, in_size)?; + for account in dyn_address_array(&values[0])? { + state.persistent_accounts.insert(account); + } + Ok(CheatcodeOutcome::Continue(Vec::new())) + } + revokePersistent_0Call::SELECTOR => { + let account = + read_abi_address_or_symbolic_slot_arg(&mut self.cx, state, args_offset, 0)?; + state.persistent_accounts.remove(&account); + Ok(CheatcodeOutcome::Continue(Vec::new())) + } + revokePersistent_1Call::SELECTOR => { + let values = + decode_cheatcode_args(&mut self.cx, state, selector, in_offset, in_size)?; + for account in dyn_address_array(&values[0])? { + state.persistent_accounts.remove(&account); + } + Ok(CheatcodeOutcome::Continue(Vec::new())) + } + isPersistentCall::SELECTOR => { + let account = + read_abi_address_or_symbolic_slot_arg(&mut self.cx, state, args_offset, 0)?; + let exists = SymExpr::constant( + &mut self.cx, + U256::from(state.persistent_accounts.contains(&account)), + ); + Ok(CheatcodeOutcome::Continue(vec![exists])) + } + activeForkCall::SELECTOR => { + let id = executor.backend().active_fork_id().ok_or(SymbolicError::Unsupported( + "symbolic vm.activeFork requires an active forked executor", + ))?; + let id = SymExpr::constant(&mut self.cx, id); + Ok(CheatcodeOutcome::Continue(vec![id])) + } + selectForkCall::SELECTOR => { + let id = read_abi_constrained_word_arg( + &mut self.cx, + state, + args_offset, + 0, + "symbolic vm.selectFork id", + )?; + if executor.backend().is_active_fork(id) { + return Ok(CheatcodeOutcome::Continue(Vec::new())); + } + Err(SymbolicError::Unsupported( + "symbolic vm.selectFork can only select the already active fork", + )) + } + rollFork_0Call::SELECTOR | rollFork_2Call::SELECTOR => { + let with_id = selector == rollFork_2Call::SELECTOR; + let active_fork = !with_id + || executor.backend().is_active_fork(read_abi_constrained_word_arg( + &mut self.cx, + state, + args_offset, + 0, + "symbolic vm.rollFork id", + )?); + let block_number = read_abi_constrained_word_arg( + &mut self.cx, + state, + args_offset, + usize::from(with_id), + "symbolic vm.rollFork block number", + )?; + let current = + state.block.number.as_const_or("symbolic vm.rollFork current block")?; + if active_fork && block_number == current { + return Ok(CheatcodeOutcome::Continue(Vec::new())); + } + Err(SymbolicError::Unsupported( + "symbolic vm.rollFork cannot change the active fork block during symbolic execution", + )) + } + createFork_0Call::SELECTOR + | createFork_1Call::SELECTOR + | createFork_2Call::SELECTOR + | createSelectFork_0Call::SELECTOR + | createSelectFork_1Call::SELECTOR + | createSelectFork_2Call::SELECTOR + | rollFork_1Call::SELECTOR + | rollFork_3Call::SELECTOR => Err(SymbolicError::Unsupported( + "symbolic fork creation and fork block mutation must happen before symbolic execution", + )), + snapshotCall::SELECTOR | snapshotStateCall::SELECTOR => { + let id = state.world.snapshot_state(); + let id = SymExpr::constant(&mut self.cx, id); + Ok(CheatcodeOutcome::Continue(vec![id])) + } + revertToCall::SELECTOR + | revertToStateCall::SELECTOR + | revertToAndDeleteCall::SELECTOR + | revertToStateAndDeleteCall::SELECTOR => { + let id = read_abi_constrained_word_arg( + &mut self.cx, + state, + args_offset, + 0, + "symbolic vm.revertToState snapshot", + )?; + let success = state.world.restore_snapshot(id); + if success + && (selector == revertToAndDeleteCall::SELECTOR + || selector == revertToStateAndDeleteCall::SELECTOR) + { + state.world.delete_snapshot(id); + } + let success = SymExpr::constant(&mut self.cx, U256::from(success)); + Ok(CheatcodeOutcome::Continue(vec![success])) + } + deleteSnapshotCall::SELECTOR | deleteStateSnapshotCall::SELECTOR => { + let id = read_abi_constrained_word_arg( + &mut self.cx, + state, + args_offset, + 0, + "symbolic vm.deleteStateSnapshot snapshot", + )?; + let success = state.world.delete_snapshot(id); + let success = SymExpr::constant(&mut self.cx, U256::from(success)); + Ok(CheatcodeOutcome::Continue(vec![success])) + } + deleteSnapshotsCall::SELECTOR | deleteStateSnapshotsCall::SELECTOR => { + state.world.delete_snapshots(); + Ok(CheatcodeOutcome::Continue(Vec::new())) + } + warpCall::SELECTOR => { + state.block.timestamp = state.memory.load_word(&mut self.cx, in_offset + 4)?; + Ok(CheatcodeOutcome::Continue(Vec::new())) + } + rollCall::SELECTOR => { + state.block.number = state.memory.load_word(&mut self.cx, in_offset + 4)?; + Ok(CheatcodeOutcome::Continue(Vec::new())) + } + setBlockhashCall::SELECTOR => { + let block_number = read_abi_constrained_word_arg( + &mut self.cx, + state, + args_offset, + 0, + "symbolic vm.setBlockhash block number", + )?; + let block_hash = state.memory.load_word(&mut self.cx, in_offset + 36)?; + state.block.set_block_hash(block_number, block_hash)?; + Ok(CheatcodeOutcome::Continue(Vec::new())) + } + prevrandao_0Call::SELECTOR | prevrandao_1Call::SELECTOR => { + state.block.difficulty = state.memory.load_word(&mut self.cx, in_offset + 4)?; + Ok(CheatcodeOutcome::Continue(Vec::new())) + } + blobhashesCall::SELECTOR => { + let values = + decode_cheatcode_args(&mut self.cx, state, selector, in_offset, in_size)?; + state.block.blob_hashes = dyn_bytes32_array(&values[0])?; + Ok(CheatcodeOutcome::Continue(Vec::new())) + } + getBlobhashesCall::SELECTOR => { + let value = DynSolValue::Array( + state + .block + .blob_hashes + .iter() + .copied() + .map(|hash| DynSolValue::FixedBytes(hash, 32)) + .collect(), + ); + Ok(CheatcodeOutcome::ContinueData(abi_concrete_value_return(&mut self.cx, value))) + } + feeCall::SELECTOR => { + state.block.basefee = state.memory.load_word(&mut self.cx, in_offset + 4)?; + Ok(CheatcodeOutcome::Continue(Vec::new())) + } + blobBaseFeeCall::SELECTOR => { + state.block.blob_basefee = state.memory.load_word(&mut self.cx, in_offset + 4)?; + Ok(CheatcodeOutcome::Continue(Vec::new())) + } + getBlobBaseFeeCall::SELECTOR => { + Ok(CheatcodeOutcome::Continue(vec![state.block.blob_basefee.clone()])) + } + chainIdCall::SELECTOR => { + state.block.chain_id = state.memory.load_word(&mut self.cx, in_offset + 4)?; + Ok(CheatcodeOutcome::Continue(Vec::new())) + } + getChainIdCall::SELECTOR => { + Ok(CheatcodeOutcome::Continue(vec![state.block.chain_id.clone()])) + } + difficultyCall::SELECTOR => { + state.block.difficulty = state.memory.load_word(&mut self.cx, in_offset + 4)?; + Ok(CheatcodeOutcome::Continue(Vec::new())) + } + coinbaseCall::SELECTOR => { + let coinbase = read_abi_constrained_address_arg( + &mut self.cx, + state, + args_offset, + 0, + "symbolic vm.coinbase value", + )?; + state.block.coinbase = coinbase; + Ok(CheatcodeOutcome::Continue(Vec::new())) + } + getBlockNumberCall::SELECTOR => { + Ok(CheatcodeOutcome::Continue(vec![state.block.number.clone()])) + } + txGasPriceCall::SELECTOR => { + state.gas_price = state.memory.load_word(&mut self.cx, in_offset + 4)?; + Ok(CheatcodeOutcome::Continue(Vec::new())) + } + getBlockTimestampCall::SELECTOR => { + Ok(CheatcodeOutcome::Continue(vec![state.block.timestamp.clone()])) + } + labelCall::SELECTOR => { + let values = + decode_cheatcode_args(&mut self.cx, state, selector, in_offset, in_size)?; + let account = dyn_address(&values[0])?; + let label = dyn_string(&values[1])?; + state.labels.insert(account, label); + Ok(CheatcodeOutcome::Continue(Vec::new())) + } + getLabelCall::SELECTOR => { + let account = read_abi_address_arg( + &mut self.cx, + &state.memory, + args_offset, + 0, + "symbolic vm.getLabel", + )?; + let label = state + .labels + .get(&account) + .cloned() + .unwrap_or_else(|| format!("unlabeled:{account}")); + Ok(CheatcodeOutcome::ContinueData(abi_concrete_bytes_return( + &mut self.cx, + label.as_bytes(), + ))) + } + expectSafeMemoryCall::SELECTOR => { + Err(SymbolicError::Unsupported("symbolic vm.expectSafeMemory not modeled")) + } + expectSafeMemoryCallCall::SELECTOR => { + Err(SymbolicError::Unsupported("symbolic vm.expectSafeMemoryCall not modeled")) + } + stopExpectSafeMemoryCall::SELECTOR => { + Err(SymbolicError::Unsupported("symbolic vm.stopExpectSafeMemory not modeled")) + } + lastCallGasCall::SELECTOR => { + Err(SymbolicError::Unsupported("symbolic vm.lastCallGas not modeled")) + } + lastFrameGasCall::SELECTOR => { + Err(SymbolicError::Unsupported("symbolic vm.lastFrameGas not modeled")) + } + snapshotGasLastCall_0Call::SELECTOR | snapshotGasLastCall_1Call::SELECTOR => { + Err(SymbolicError::Unsupported("symbolic vm.snapshotGasLastCall not modeled")) + } + snapshotGasLastFrame_0Call::SELECTOR | snapshotGasLastFrame_1Call::SELECTOR => { + Err(SymbolicError::Unsupported("symbolic vm.snapshotGasLastFrame not modeled")) + } + stopSnapshotGas_0Call::SELECTOR + | stopSnapshotGas_1Call::SELECTOR + | stopSnapshotGas_2Call::SELECTOR => { + Err(SymbolicError::Unsupported("symbolic vm.stopSnapshotGas not modeled")) + } + pauseGasMeteringCall::SELECTOR + | resumeGasMeteringCall::SELECTOR + | resetGasMeteringCall::SELECTOR + | breakpoint_0Call::SELECTOR + | breakpoint_1Call::SELECTOR + | snapshotValue_0Call::SELECTOR + | snapshotValue_1Call::SELECTOR + | startSnapshotGas_0Call::SELECTOR + | startSnapshotGas_1Call::SELECTOR + | sleepCall::SELECTOR + | coolCall::SELECTOR + | accessListCall::SELECTOR + | warmSlotCall::SELECTOR + | coolSlotCall::SELECTOR + | noAccessListCall::SELECTOR => Ok(CheatcodeOutcome::Continue(Vec::new())), + setEvmVersionCall::SELECTOR => { + Err(SymbolicError::Unsupported("symbolic vm.setEvmVersion not modeled")) + } + getEvmVersionCall::SELECTOR => { + Err(SymbolicError::Unsupported("symbolic vm.getEvmVersion not modeled")) + } + getFoundryVersionCall::SELECTOR => Ok(CheatcodeOutcome::ContinueData( + abi_concrete_bytes_return(&mut self.cx, env!("CARGO_PKG_VERSION").as_bytes()), + )), + projectRootCall::SELECTOR => { + self.stateless_retry_safe = false; + let root = std::env::current_dir() + .map_err(|_| SymbolicError::Unsupported("symbolic vm.projectRoot"))?; + Ok(CheatcodeOutcome::ContinueData(abi_concrete_bytes_return( + &mut self.cx, + root.display().to_string().as_bytes(), + ))) + } + unixTimeCall::SELECTOR => { + self.stateless_retry_safe = false; + let milliseconds = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| SymbolicError::Unsupported("symbolic vm.unixTime"))? + .as_millis(); + let value = U256::try_from(milliseconds) + .map_err(|_| SymbolicError::Unsupported("symbolic vm.unixTime"))?; + let value = SymExpr::constant(&mut self.cx, value); + Ok(CheatcodeOutcome::Continue(vec![value])) + } + isIsolateModeCall::SELECTOR => { + let isolate = executor + .inspector() + .cheatcodes + .as_ref() + .is_some_and(|cheats| cheats.config.isolate); + let isolate = SymExpr::constant(&mut self.cx, U256::from(isolate)); + Ok(CheatcodeOutcome::Continue(vec![isolate])) + } + isContextCall::SELECTOR => { + let context = read_abi_concrete_word_arg( + &mut self.cx, + &state.memory, + args_offset, + 0, + "symbolic vm.isContext", + )?; + let context = u8::try_from(context) + .ok() + .and_then(|context| ForgeContext::try_from(context).ok()) + .ok_or(SymbolicError::Unsupported("symbolic vm.isContext invalid context"))?; + Ok(CheatcodeOutcome::Continue(vec![SymExpr::constant( + &mut self.cx, + U256::from(current_execution_context() == Some(context)), + )])) + } + toString_0Call::SELECTOR + | toString_1Call::SELECTOR + | toString_2Call::SELECTOR + | toString_3Call::SELECTOR + | toString_4Call::SELECTOR + | toString_5Call::SELECTOR => { + let output = match selector { + toString_0Call::SELECTOR => format!( + "{:?}", + read_abi_address_arg( + &mut self.cx, + &state.memory, + args_offset, + 0, + "symbolic vm.toString" + )? + ), + toString_1Call::SELECTOR => hex::encode_prefixed(read_abi_dynamic_bytes_arg( + &mut self.cx, + &state.memory, + args_offset, + 0, + "symbolic vm.toString", + )?), + toString_3Call::SELECTOR => read_abi_bool_arg( + &mut self.cx, + &state.memory, + args_offset, + 0, + "symbolic vm.toString", + )? + .to_string(), + _ => { + let value = read_abi_concrete_word_arg( + &mut self.cx, + &state.memory, + args_offset, + 0, + "symbolic vm.toString", + )?; + match selector { + toString_2Call::SELECTOR => { + hex::encode_prefixed(value.to_be_bytes::<32>()) + } + toString_4Call::SELECTOR => value.to_string(), + _ => I256::from_raw(value).to_string(), + } + } + }; + Ok(CheatcodeOutcome::ContinueData(abi_concrete_bytes_return( + &mut self.cx, + output.as_bytes(), + ))) + } + envBool_0Call::SELECTOR + | envUint_0Call::SELECTOR + | envInt_0Call::SELECTOR + | envAddress_0Call::SELECTOR + | envBytes32_0Call::SELECTOR + | envString_0Call::SELECTOR + | envBytes_0Call::SELECTOR + | parseBoolCall::SELECTOR + | parseUintCall::SELECTOR + | parseIntCall::SELECTOR + | parseAddressCall::SELECTOR + | parseBytes32Call::SELECTOR + | parseBytesCall::SELECTOR => { + let (ty, message, from_env) = match selector { + envBool_0Call::SELECTOR => (DynSolType::Bool, "symbolic vm.envBool", true), + envUint_0Call::SELECTOR => (DynSolType::Uint(256), "symbolic vm.envUint", true), + envInt_0Call::SELECTOR => (DynSolType::Int(256), "symbolic vm.envInt", true), + envAddress_0Call::SELECTOR => { + (DynSolType::Address, "symbolic vm.envAddress", true) + } + envBytes32_0Call::SELECTOR => { + (DynSolType::FixedBytes(32), "symbolic vm.envBytes32", true) + } + envString_0Call::SELECTOR => { + (DynSolType::String, "symbolic vm.envString", true) + } + envBytes_0Call::SELECTOR => (DynSolType::Bytes, "symbolic vm.envBytes", true), + parseBoolCall::SELECTOR => (DynSolType::Bool, "symbolic vm.parseBool", false), + parseUintCall::SELECTOR => { + (DynSolType::Uint(256), "symbolic vm.parseUint", false) + } + parseIntCall::SELECTOR => (DynSolType::Int(256), "symbolic vm.parseInt", false), + parseAddressCall::SELECTOR => { + (DynSolType::Address, "symbolic vm.parseAddress", false) + } + parseBytes32Call::SELECTOR => { + (DynSolType::FixedBytes(32), "symbolic vm.parseBytes32", false) + } + _ => (DynSolType::Bytes, "symbolic vm.parseBytes", false), + }; + let mut value = + read_abi_string_arg(&mut self.cx, &state.memory, args_offset, 0, message)?; + if from_env { + self.stateless_retry_safe = false; + value = std::env::var(value) + .map_err(|_| SymbolicError::Unsupported("symbolic env var missing"))?; + } + let value = parse_env_value(&value, &ty)?; + Ok(match value.as_word() { + Some(word) => CheatcodeOutcome::Continue(vec![SymExpr::constant( + &mut self.cx, + word.into(), + )]), + None => CheatcodeOutcome::ContinueData(abi_concrete_bytes_return( + &mut self.cx, + &value.abi_encode_packed(), + )), + }) + } + toLowercaseCall::SELECTOR | toUppercaseCall::SELECTOR | trimCall::SELECTOR => { + let value = read_abi_string_arg( + &mut self.cx, + &state.memory, + args_offset, + 0, + "symbolic vm.string", + )?; + let output = if selector == toLowercaseCall::SELECTOR { + value.to_lowercase() + } else if selector == toUppercaseCall::SELECTOR { + value.to_uppercase() + } else { + value.trim().to_string() + }; + Ok(CheatcodeOutcome::ContinueData(abi_concrete_bytes_return( + &mut self.cx, + output.as_bytes(), + ))) + } + replaceCall::SELECTOR => { + let values = + decode_cheatcode_args(&mut self.cx, state, selector, in_offset, in_size)?; + let output = dyn_string(&values[0])? + .replace(&dyn_string(&values[1])?, &dyn_string(&values[2])?); + Ok(CheatcodeOutcome::ContinueData(abi_concrete_bytes_return( + &mut self.cx, + output.as_bytes(), + ))) + } + splitCall::SELECTOR => { + let values = + decode_cheatcode_args(&mut self.cx, state, selector, in_offset, in_size)?; + let input = dyn_string(&values[0])?; + let delimiter = dyn_string(&values[1])?; + let parts = if delimiter.is_empty() { + input.chars().map(|ch| DynSolValue::String(ch.to_string())).collect() + } else { + input + .split(&delimiter) + .map(|part| DynSolValue::String(part.to_string())) + .collect() + }; + Ok(CheatcodeOutcome::ContinueData(abi_concrete_value_return( + &mut self.cx, + DynSolValue::Array(parts), + ))) + } + indexOfCall::SELECTOR => { + let values = + decode_cheatcode_args(&mut self.cx, state, selector, in_offset, in_size)?; + let input = dyn_string(&values[0])?; + let needle = dyn_string(&values[1])?; + let index = input.find(&needle).map(U256::from).unwrap_or(U256::MAX); + Ok(CheatcodeOutcome::Continue(vec![SymExpr::constant(&mut self.cx, index)])) + } + containsCall::SELECTOR => { + let values = + decode_cheatcode_args(&mut self.cx, state, selector, in_offset, in_size)?; + let contains = dyn_string(&values[0])?.contains(&dyn_string(&values[1])?); + Ok(CheatcodeOutcome::Continue(vec![SymExpr::constant( + &mut self.cx, + U256::from(contains), + )])) + } + toBase64_0Call::SELECTOR + | toBase64_1Call::SELECTOR + | toBase64URL_0Call::SELECTOR + | toBase64URL_1Call::SELECTOR => { + let data = read_abi_dynamic_bytes_arg( + &mut self.cx, + &state.memory, + args_offset, + 0, + "symbolic vm.toBase64", + )?; + let encoded = if selector == toBase64URL_0Call::SELECTOR + || selector == toBase64URL_1Call::SELECTOR + { + BASE64_URL_SAFE.encode(data) + } else { + BASE64_STANDARD.encode(data) + }; + Ok(CheatcodeOutcome::ContinueData(abi_concrete_bytes_return( + &mut self.cx, + encoded.as_bytes(), + ))) + } + bound_0Call::SELECTOR | bound_1Call::SELECTOR => { + self.handle_bound(state, args_offset, selector == bound_1Call::SELECTOR) + } + envExistsCall::SELECTOR => { + let name = read_abi_string_arg( + &mut self.cx, + &state.memory, + args_offset, + 0, + "symbolic vm.envExists", + )?; + self.stateless_retry_safe = false; + Ok(CheatcodeOutcome::Continue(vec![SymExpr::constant( + &mut self.cx, + U256::from(std::env::var_os(name).is_some()), + )])) + } + envBool_1Call::SELECTOR + | envUint_1Call::SELECTOR + | envInt_1Call::SELECTOR + | envAddress_1Call::SELECTOR + | envBytes32_1Call::SELECTOR + | envString_1Call::SELECTOR + | envBytes_1Call::SELECTOR => { + let name = VmCalls::name_by_selector(selector).unwrap_or_default(); + let element_ty = DynSolType::parse(&name["env".len()..].to_ascii_lowercase()) + .map_err(|_| SymbolicError::Unsupported("symbolic env type"))?; + let values = + decode_cheatcode_args(&mut self.cx, state, selector, in_offset, in_size)?; + let name = dyn_string(&values[0])?; + let delimiter = dyn_string(&values[1])?; + self.stateless_retry_safe = false; + let value = std::env::var(name) + .map_err(|_| SymbolicError::Unsupported("symbolic env var missing"))?; + let value = parse_env_array(&value, &delimiter, &element_ty)?; + Ok(CheatcodeOutcome::ContinueData(abi_concrete_value_return(&mut self.cx, value))) + } + envOr_0Call::SELECTOR => { + let name = read_abi_string_arg( + &mut self.cx, + &state.memory, + args_offset, + 0, + "symbolic vm.envOr", + )?; + self.stateless_retry_safe = false; + let value = match std::env::var(name) { + Ok(value) => U256::from(parse_env_bool(&value)?), + Err(_) => read_abi_concrete_word_arg( + &mut self.cx, + &state.memory, + args_offset, + 1, + "symbolic vm.envOr", + )?, + }; + Ok(CheatcodeOutcome::Continue(vec![SymExpr::constant(&mut self.cx, value)])) + } + envOr_1Call::SELECTOR + | envOr_2Call::SELECTOR + | envOr_3Call::SELECTOR + | envOr_4Call::SELECTOR => { + let name = read_abi_string_arg( + &mut self.cx, + &state.memory, + args_offset, + 0, + "symbolic vm.envOr", + )?; + let default = read_abi_concrete_word_arg( + &mut self.cx, + &state.memory, + args_offset, + 1, + "symbolic vm.envOr", + )?; + self.stateless_retry_safe = false; + let value = match std::env::var(name) { + Ok(value) if selector == envOr_1Call::SELECTOR => parse_env_uint(&value)?, + Ok(value) if selector == envOr_2Call::SELECTOR => parse_env_int(&value)?, + Ok(value) if selector == envOr_3Call::SELECTOR => { + address_word(parse_env_address(&value)?) + } + Ok(value) => parse_env_bytes32(&value)?, + Err(_) => default, + }; + Ok(CheatcodeOutcome::Continue(vec![SymExpr::constant(&mut self.cx, value)])) + } + envOr_5Call::SELECTOR => { + let values = + decode_cheatcode_args(&mut self.cx, state, selector, in_offset, in_size)?; + let name = dyn_string(&values[0])?; + self.stateless_retry_safe = false; + let value = std::env::var(name).unwrap_or(dyn_string(&values[1])?); + Ok(CheatcodeOutcome::ContinueData(abi_concrete_bytes_return( + &mut self.cx, + value.as_bytes(), + ))) + } + envOr_6Call::SELECTOR => { + let values = + decode_cheatcode_args(&mut self.cx, state, selector, in_offset, in_size)?; + let name = dyn_string(&values[0])?; + self.stateless_retry_safe = false; + let value = match std::env::var(name) { + Ok(value) => parse_env_bytes(&value)?, + Err(_) => dyn_bytes(&values[1])?, + }; + Ok(CheatcodeOutcome::ContinueData(abi_concrete_bytes_return(&mut self.cx, &value))) + } + envOr_7Call::SELECTOR + | envOr_8Call::SELECTOR + | envOr_9Call::SELECTOR + | envOr_10Call::SELECTOR + | envOr_11Call::SELECTOR + | envOr_12Call::SELECTOR + | envOr_13Call::SELECTOR => { + let params = vm_params(selector); + let Some(DynSolType::Array(element_ty)) = params.last().cloned() else { + return Err(SymbolicError::Unsupported("symbolic env type")); + }; + let values = + decode_cheatcode_args(&mut self.cx, state, selector, in_offset, in_size)?; + let name = dyn_string(&values[0])?; + let delimiter = dyn_string(&values[1])?; + self.stateless_retry_safe = false; + let value = match std::env::var(name) { + Ok(value) => parse_env_array(&value, &delimiter, &element_ty)?, + Err(_) => values[2].clone(), + }; + Ok(CheatcodeOutcome::ContinueData(abi_concrete_value_return(&mut self.cx, value))) + } + ffiCall::SELECTOR => { + if !state.ffi_enabled { + return Err(SymbolicError::Unsupported("symbolic ffi disabled")); + } + let values = + decode_cheatcode_args(&mut self.cx, state, selector, in_offset, in_size)?; + let args = dyn_string_array(&values[0])?; + if args.is_empty() || args[0].is_empty() { + return Err(SymbolicError::Unsupported("symbolic ffi empty command")); + } + self.stateless_retry_safe = false; + let output = Command::new(&args[0]) + .args(&args[1..]) + .output() + .map_err(|_| SymbolicError::Unsupported("symbolic ffi command"))?; + if !output.status.success() { + return Err(SymbolicError::Unsupported("symbolic ffi command failed")); + } + let stdout = String::from_utf8(output.stdout) + .map_err(|_| SymbolicError::Unsupported("symbolic ffi stdout"))?; + let trimmed = stdout.trim(); + let bytes = hex::decode(trimmed).unwrap_or_else(|_| trimmed.as_bytes().to_vec()); + Ok(CheatcodeOutcome::ContinueData(abi_concrete_bytes_return(&mut self.cx, &bytes))) + } + assertTrue_0Call::SELECTOR | assertTrue_1Call::SELECTOR => { + let word = read_abi_word_arg(&mut self.cx, &state.memory, args_offset, 0)?; + let condition = word.nonzero_bool(&mut self.cx); + self.handle_assertion(state, condition) + } + assertFalse_0Call::SELECTOR | assertFalse_1Call::SELECTOR => { + let word = read_abi_word_arg(&mut self.cx, &state.memory, args_offset, 0)?; + let condition = word.into_zero_bool(&mut self.cx); + self.handle_assertion(state, condition) + } + assertEq_2Call::SELECTOR + | assertEq_3Call::SELECTOR + | assertEq_4Call::SELECTOR + | assertEq_5Call::SELECTOR + | assertEq_6Call::SELECTOR + | assertEq_7Call::SELECTOR + | assertEq_8Call::SELECTOR + | assertEq_9Call::SELECTOR + | assertEq_0Call::SELECTOR + | assertEq_1Call::SELECTOR => { + let left = read_abi_word_arg(&mut self.cx, &state.memory, args_offset, 0)?; + let right = read_abi_word_arg(&mut self.cx, &state.memory, args_offset, 1)?; + let condition = SymBoolExpr::eq(&mut self.cx, left, right); + self.handle_assertion(state, condition) + } + assertEqDecimal_0Call::SELECTOR + | assertEqDecimal_1Call::SELECTOR + | assertEqDecimal_2Call::SELECTOR + | assertEqDecimal_3Call::SELECTOR => { + let left = read_abi_word_arg(&mut self.cx, &state.memory, args_offset, 0)?; + let right = read_abi_word_arg(&mut self.cx, &state.memory, args_offset, 1)?; + let condition = SymBoolExpr::eq(&mut self.cx, left, right); + self.handle_assertion(state, condition) + } + assertNotEq_2Call::SELECTOR + | assertNotEq_3Call::SELECTOR + | assertNotEq_4Call::SELECTOR + | assertNotEq_5Call::SELECTOR + | assertNotEq_6Call::SELECTOR + | assertNotEq_7Call::SELECTOR + | assertNotEq_8Call::SELECTOR + | assertNotEq_9Call::SELECTOR + | assertNotEq_0Call::SELECTOR + | assertNotEq_1Call::SELECTOR => { + let left = read_abi_word_arg(&mut self.cx, &state.memory, args_offset, 0)?; + let right = read_abi_word_arg(&mut self.cx, &state.memory, args_offset, 1)?; + let condition = SymBoolExpr::eq(&mut self.cx, left, right); + let condition = condition.not(&mut self.cx); + self.handle_assertion(state, condition) + } + assertEq_10Call::SELECTOR + | assertEq_11Call::SELECTOR + | assertEq_12Call::SELECTOR + | assertEq_13Call::SELECTOR + | assertEq_14Call::SELECTOR + | assertEq_15Call::SELECTOR + | assertEq_16Call::SELECTOR + | assertEq_17Call::SELECTOR + | assertEq_18Call::SELECTOR + | assertEq_19Call::SELECTOR + | assertEq_20Call::SELECTOR + | assertEq_21Call::SELECTOR + | assertEq_22Call::SELECTOR + | assertEq_23Call::SELECTOR + | assertEq_24Call::SELECTOR + | assertEq_25Call::SELECTOR + | assertEq_26Call::SELECTOR + | assertEq_27Call::SELECTOR + | assertNotEq_10Call::SELECTOR + | assertNotEq_11Call::SELECTOR + | assertNotEq_12Call::SELECTOR + | assertNotEq_13Call::SELECTOR + | assertNotEq_14Call::SELECTOR + | assertNotEq_15Call::SELECTOR + | assertNotEq_16Call::SELECTOR + | assertNotEq_17Call::SELECTOR + | assertNotEq_18Call::SELECTOR + | assertNotEq_19Call::SELECTOR + | assertNotEq_20Call::SELECTOR + | assertNotEq_21Call::SELECTOR + | assertNotEq_22Call::SELECTOR + | assertNotEq_23Call::SELECTOR + | assertNotEq_24Call::SELECTOR + | assertNotEq_25Call::SELECTOR + | assertNotEq_26Call::SELECTOR + | assertNotEq_27Call::SELECTOR => { + let values = + decode_cheatcode_args(&mut self.cx, state, selector, in_offset, in_size)?; + let expect_equal = VmCalls::name_by_selector(selector) == Some("assertEq"); + let condition = + SymBoolExpr::constant(&mut self.cx, (values[0] == values[1]) == expect_equal); + self.handle_assertion(state, condition) + } + assertLt_0Call::SELECTOR | assertLt_1Call::SELECTOR => { + let left = read_abi_word_arg(&mut self.cx, &state.memory, args_offset, 0)?; + let right = read_abi_word_arg(&mut self.cx, &state.memory, args_offset, 1)?; + let condition = SymBoolExpr::cmp(&mut self.cx, SymCmpOp::Ult, left, right); + self.handle_assertion(state, condition) + } + assertLe_0Call::SELECTOR | assertLe_1Call::SELECTOR => { + let left = read_abi_word_arg(&mut self.cx, &state.memory, args_offset, 0)?; + let right = read_abi_word_arg(&mut self.cx, &state.memory, args_offset, 1)?; + let condition = SymBoolExpr::cmp(&mut self.cx, SymCmpOp::Ule, left, right); + self.handle_assertion(state, condition) + } + assertGt_0Call::SELECTOR | assertGt_1Call::SELECTOR => { + let left = read_abi_word_arg(&mut self.cx, &state.memory, args_offset, 0)?; + let right = read_abi_word_arg(&mut self.cx, &state.memory, args_offset, 1)?; + let condition = SymBoolExpr::cmp(&mut self.cx, SymCmpOp::Ugt, left, right); + self.handle_assertion(state, condition) + } + assertGe_0Call::SELECTOR | assertGe_1Call::SELECTOR => { + let left = read_abi_word_arg(&mut self.cx, &state.memory, args_offset, 0)?; + let right = read_abi_word_arg(&mut self.cx, &state.memory, args_offset, 1)?; + let condition = SymBoolExpr::cmp(&mut self.cx, SymCmpOp::Uge, left, right); + self.handle_assertion(state, condition) + } + assertLt_2Call::SELECTOR | assertLt_3Call::SELECTOR => { + let left = read_abi_word_arg(&mut self.cx, &state.memory, args_offset, 0)?; + let right = read_abi_word_arg(&mut self.cx, &state.memory, args_offset, 1)?; + let condition = SymBoolExpr::cmp(&mut self.cx, SymCmpOp::Slt, left, right); + self.handle_assertion(state, condition) + } + assertGt_2Call::SELECTOR | assertGt_3Call::SELECTOR => { + let left = read_abi_word_arg(&mut self.cx, &state.memory, args_offset, 0)?; + let right = read_abi_word_arg(&mut self.cx, &state.memory, args_offset, 1)?; + let condition = SymBoolExpr::cmp(&mut self.cx, SymCmpOp::Sgt, left, right); + self.handle_assertion(state, condition) + } + assertLe_2Call::SELECTOR | assertLe_3Call::SELECTOR => { + let left = read_abi_word_arg(&mut self.cx, &state.memory, args_offset, 0)?; + let right = read_abi_word_arg(&mut self.cx, &state.memory, args_offset, 1)?; + let condition = SymBoolExpr::cmp(&mut self.cx, SymCmpOp::Sgt, left, right); + let condition = condition.not(&mut self.cx); + self.handle_assertion(state, condition) + } + assertGe_2Call::SELECTOR | assertGe_3Call::SELECTOR => { + let left = read_abi_word_arg(&mut self.cx, &state.memory, args_offset, 0)?; + let right = read_abi_word_arg(&mut self.cx, &state.memory, args_offset, 1)?; + let condition = SymBoolExpr::cmp(&mut self.cx, SymCmpOp::Slt, left, right); + let condition = condition.not(&mut self.cx); + self.handle_assertion(state, condition) + } + randomUint_0Call::SELECTOR => { + Ok(CheatcodeOutcome::Continue(vec![state.fresh_word(&mut self.cx, "vmRandomUint")])) + } + randomUint_2Call::SELECTOR => { + let bits = read_abi_constrained_word_arg( + &mut self.cx, + state, + args_offset, + 0, + "symbolic randomUint bits", + )?; + Self::validate_symbolic_integer_bits(bits, "symbolic randomUint bits")?; + Ok(CheatcodeOutcome::Continue(vec![state.fresh_bounded_uint(&mut self.cx, bits)])) + } + randomUint_1Call::SELECTOR => { + let min = state.memory.load_word(&mut self.cx, in_offset + 4)?; + let max = state.memory.load_word(&mut self.cx, in_offset + 36)?; + let value = state.fresh_word(&mut self.cx, "vmRandomUintRange"); + state.constraints.push(SymBoolExpr::cmp_word_expr( + &mut self.cx, + SymCmpOp::Uge, + &value, + min, + )); + state.constraints.push(SymBoolExpr::cmp_word_expr( + &mut self.cx, + SymCmpOp::Ule, + &value, + max, + )); + Ok(CheatcodeOutcome::Continue(vec![value])) + } + randomInt_0Call::SELECTOR => { + Ok(CheatcodeOutcome::Continue(vec![state.fresh_word(&mut self.cx, "vmRandomInt")])) + } + randomInt_1Call::SELECTOR => { + let bits = read_abi_constrained_word_arg( + &mut self.cx, + state, + args_offset, + 0, + "symbolic randomInt bits", + )?; + Self::validate_symbolic_integer_bits(bits, "symbolic randomInt bits")?; + Ok(CheatcodeOutcome::Continue(vec![state.fresh_bounded_int(&mut self.cx, bits)])) + } + randomAddressCall::SELECTOR => { + let value = state.fresh_bounded_uint(&mut self.cx, U256::from(160)); + Ok(CheatcodeOutcome::Continue(vec![value])) + } + randomBoolCall::SELECTOR => { + let value = state.fresh_bounded_uint(&mut self.cx, U256::ONE); + Ok(CheatcodeOutcome::Continue(vec![value])) + } + randomBytesCall::SELECTOR => { + let len = read_abi_word_arg(&mut self.cx, &state.memory, args_offset, 0)?; + let max_limit = self.config.max_dynamic_length as usize; + let max_len = self.solver_upper_bound_usize( + state, + &len, + max_limit, + "symbolic randomBytes length", + )?; + let bytes = state.fresh_bytes(&mut self.cx, max_len); + Ok(CheatcodeOutcome::ContinueData(abi_bytes_return_with_len( + &mut self.cx, + len, + bytes, + ))) + } + randomBytes4Call::SELECTOR => { + let value = state.fresh_bounded_uint(&mut self.cx, U256::from(32)); + Ok(CheatcodeOutcome::Continue(vec![shift_left(&mut self.cx, value, 224)])) + } + randomBytes8Call::SELECTOR => { + let value = state.fresh_bounded_uint(&mut self.cx, U256::from(64)); + Ok(CheatcodeOutcome::Continue(vec![shift_left(&mut self.cx, value, 192)])) + } + + _ => Err(SymbolicError::Unsupported("symbolic Foundry cheatcode")), + } + } + + pub(super) fn handle_symbolic_vm_cheatcode( + &mut self, + state: &mut PathState, + selector: [u8; 4], + in_offset: usize, + ) -> Result { + let Some(cheatcode) = SymbolicVmCheatcode::from_selector(selector) else { + return Err(SymbolicError::Unsupported("symbolic VM compatibility cheatcode")); + }; + let args_offset = in_offset + 4; + + match cheatcode { + SymbolicVmCheatcode::CreateUintBits(bits) => { + let value = if bits == 256 { + state.fresh_word(&mut self.cx, "svm") + } else { + state.fresh_bounded_uint(&mut self.cx, U256::from(bits)) + }; + Ok(SymReturnData::from_words(&mut self.cx, vec![value])) + } + SymbolicVmCheatcode::CreateIntBits(bits) => { + let value = if bits == 256 { + state.fresh_word(&mut self.cx, "svm") + } else { + state.fresh_bounded_int(&mut self.cx, U256::from(bits)) + }; + Ok(SymReturnData::from_words(&mut self.cx, vec![value])) + } + SymbolicVmCheatcode::CreateBytesFixed(bytes) => { + let value = if bytes == 32 { + state.fresh_word(&mut self.cx, "svm") + } else { + let value = state.fresh_bounded_uint(&mut self.cx, U256::from(bytes * 8)); + shift_left(&mut self.cx, value, (32 - bytes) * 8) + }; + Ok(SymReturnData::from_words(&mut self.cx, vec![value])) + } + SymbolicVmCheatcode::CreateUint => { + let bits = read_abi_constrained_word_arg( + &mut self.cx, + state, + args_offset, + 0, + "symbolic svm.create integer bits", + )?; + Self::validate_symbolic_integer_bits(bits, "symbolic svm.create integer bits")?; + let value = state.fresh_bounded_uint(&mut self.cx, bits); + Ok(SymReturnData::from_words(&mut self.cx, vec![value])) + } + SymbolicVmCheatcode::CreateInt => { + let bits = read_abi_constrained_word_arg( + &mut self.cx, + state, + args_offset, + 0, + "symbolic svm.create integer bits", + )?; + Self::validate_symbolic_integer_bits(bits, "symbolic svm.create integer bits")?; + let value = state.fresh_bounded_int(&mut self.cx, bits); + Ok(SymReturnData::from_words(&mut self.cx, vec![value])) + } + SymbolicVmCheatcode::CreateAddress => { + let value = state.fresh_bounded_uint(&mut self.cx, U256::from(160)); + Ok(SymReturnData::from_words(&mut self.cx, vec![value])) + } + SymbolicVmCheatcode::CreateBool => { + let value = state.fresh_bounded_uint(&mut self.cx, U256::ONE); + Ok(SymReturnData::from_words(&mut self.cx, vec![value])) + } + SymbolicVmCheatcode::CreateBytes => { + let bytes = + state.fresh_bytes(&mut self.cx, self.config.default_dynamic_length as usize); + Ok(abi_bytes_return(&mut self.cx, bytes)) + } + SymbolicVmCheatcode::CreateBytesSized => { + let len = read_abi_constrained_word_arg( + &mut self.cx, + state, + args_offset, + 0, + "symbolic svm.createBytes length", + )?; + let len = usize::try_from(len) + .ok() + .filter(|len| *len <= self.config.max_calldata_bytes as usize) + .ok_or(SymbolicError::Unsupported("symbolic svm.createBytes length"))?; + let bytes = state.fresh_bytes(&mut self.cx, len); + Ok(abi_bytes_return(&mut self.cx, bytes)) + } + SymbolicVmCheatcode::CreateString => { + let bytes = state.fresh_printable_ascii_bytes( + &mut self.cx, + self.config.default_dynamic_length as usize, + ); + Ok(abi_bytes_return(&mut self.cx, bytes)) + } + SymbolicVmCheatcode::CreateStringSized => { + let len = read_abi_constrained_word_arg( + &mut self.cx, + state, + args_offset, + 0, + "symbolic svm.createString length", + )?; + let len = usize::try_from(len) + .ok() + .filter(|len| *len <= self.config.max_calldata_bytes as usize) + .ok_or(SymbolicError::Unsupported("symbolic svm.createString length"))?; + let bytes = state.fresh_printable_ascii_bytes(&mut self.cx, len); + Ok(abi_bytes_return(&mut self.cx, bytes)) + } + SymbolicVmCheatcode::CreateCalldata => { + let max = self.config.max_calldata_bytes as usize; + let len = if max < 4 { + max + } else { + (self.config.default_dynamic_length as usize).max(4).min(max) + }; + let bytes = state.fresh_bytes(&mut self.cx, len); + Ok(abi_bytes_return(&mut self.cx, bytes)) + } + SymbolicVmCheatcode::EnableSymbolicStorage => { + let target = + read_abi_address_or_symbolic_slot_arg(&mut self.cx, state, args_offset, 0)?; + state.world.enable_arbitrary_storage(target, false); + Ok(SymReturnData::empty(&mut self.cx)) + } + SymbolicVmCheatcode::SnapshotStorage => { + let _target = + read_abi_address_or_symbolic_slot_arg(&mut self.cx, state, args_offset, 0)?; + let id = state.world.snapshot_state(); + let id = SymExpr::constant(&mut self.cx, id); + Ok(SymReturnData::from_words(&mut self.cx, vec![id])) + } + SymbolicVmCheatcode::SnapshotState => { + let id = state.world.snapshot_state(); + let id = SymExpr::constant(&mut self.cx, id); + Ok(SymReturnData::from_words(&mut self.cx, vec![id])) + } + } + } +} diff --git a/crates/evm/symbolic/src/executor/constraints.rs b/crates/evm/symbolic/src/executor/constraints.rs new file mode 100644 index 0000000000000..5ef93dfd31530 --- /dev/null +++ b/crates/evm/symbolic/src/executor/constraints.rs @@ -0,0 +1,250 @@ +use super::*; +use foundry_evm::revm::interpreter::instructions::i256::i256_cmp; + +impl SymbolicExecutor { + pub(super) fn handle_assume( + &mut self, + state: &mut PathState, + condition_offset: usize, + ) -> Result { + let cond = state.memory.load_word(&mut self.cx, condition_offset)?; + let cond = cond.nonzero_bool(&mut self.cx); + if state.invariant_predicate && cond.as_const() != Some(true) { + // A predicate must cover every reachable state. Restricting its input domain would + // hide rejected states, including when accepted values remain on this same path. + let rejected = cond.not(&mut self.cx); + let (_, rejected_sat) = self.constraints_with_condition(state, rejected)?; + if rejected_sat { + return Err(SymbolicError::Unsupported( + "vm.assume may reject an invariant predicate", + )); + } + return Ok(CheatcodeOutcome::Continue(Vec::new())); + } + self.assume_condition(state, cond) + } + + pub(super) fn handle_skip( + &mut self, + state: &mut PathState, + condition_offset: usize, + ) -> Result { + let cond = state.memory.load_word(&mut self.cx, condition_offset)?; + let cond = cond.nonzero_bool(&mut self.cx).not(&mut self.cx); + self.assume_condition(state, cond) + } + + pub(super) fn assume_condition( + &mut self, + state: &mut PathState, + condition: SymBoolExpr, + ) -> Result { + match condition.as_const() { + Some(true) => Ok(CheatcodeOutcome::Continue(Vec::new())), + Some(false) => Ok(CheatcodeOutcome::AssumeRejected), + None => { + state.constraints.push(condition); + if self.is_sat_with_state(state, &state.constraints)? { + Ok(CheatcodeOutcome::Continue(Vec::new())) + } else { + Ok(CheatcodeOutcome::AssumeRejected) + } + } + } + } + + pub(super) fn solver_upper_bound_usize( + &mut self, + state: &PathState, + expr: &SymExpr, + max: usize, + reason: &'static str, + ) -> Result { + if let Some(bound) = + state.upper_bound_usize(&mut self.cx, expr).filter(|bound| *bound <= max) + { + return Ok(bound); + } + let mut above_max = state.constraints.clone(); + above_max.push(SymBoolExpr::cmp_word_const( + &mut self.cx, + SymCmpOp::Ugt, + expr, + U256::from(max), + )); + if self.is_sat_with_state(state, &above_max)? { + return Err(SymbolicError::Unsupported(reason)); + } + + let mut low = 0usize; + let mut high = max; + while low < high { + let mid = low + (high - low) / 2; + let mut above_mid = state.constraints.clone(); + above_mid.push(SymBoolExpr::cmp_word_const( + &mut self.cx, + SymCmpOp::Ugt, + expr, + U256::from(mid), + )); + if self.is_sat_with_state(state, &above_mid)? { + low = mid + 1; + } else { + high = mid; + } + } + Ok(low) + } + + pub(super) fn assume_expr_at_least( + &mut self, + state: &mut PathState, + expr: &SymExpr, + min: usize, + ) -> Result { + let condition = + SymBoolExpr::cmp_word_const(&mut self.cx, SymCmpOp::Uge, expr, U256::from(min)); + match condition.as_const() { + Some(value) => Ok(value), + None => { + let mut constraints = state.constraints.clone(); + constraints.push(condition); + if self.is_sat_with_state(state, &constraints)? { + state.constraints = constraints; + Ok(true) + } else { + Ok(false) + } + } + } + } + + /// Proves that every feasible value is at least `min` without restricting the path. + pub(super) fn proves_expr_at_least( + &mut self, + state: &PathState, + expr: &SymExpr, + min: usize, + ) -> Result { + if state.lower_bound_usize(expr) >= min { + return Ok(true); + } + + let mut below_min = state.constraints.clone(); + below_min.push(SymBoolExpr::cmp_word_const( + &mut self.cx, + SymCmpOp::Ult, + expr, + U256::from(min), + )); + Ok(!self.is_sat_with_state(state, &below_min)?) + } + + /// Resolves a path-constant word and proves that no alternate value is feasible. + pub(super) fn constrained_word_with_solver( + &mut self, + state: &PathState, + expr: &SymExpr, + ) -> Result, SymbolicError> { + if let Some(value) = state.constrained_word(&mut self.cx, expr) { + return Ok(Some(value)); + } + if expr.contains_gasleft() { + return Err(SymbolicError::Unsupported("GAS/gasleft() not modeled")); + } + + let replayable_storage = state.world.replay_storage_symbols(); + let model = self.solver.model_with_replayable_storage( + &mut self.cx, + &state.constraints, + &replayable_storage, + )?; + let value = expr.eval_model(&model)?; + let differs = SymBoolExpr::eq_word_const(&mut self.cx, expr, value).not(&mut self.cx); + let mut constraints = state.constraints.clone(); + constraints.push(differs); + if self.is_sat_with_state(state, &constraints)? { Ok(None) } else { Ok(Some(value)) } + } + + /// Rejects symbolic integer bit widths outside the EVM word size. + pub(super) fn validate_symbolic_integer_bits( + bits: U256, + context: &'static str, + ) -> Result<(), SymbolicError> { + if bits <= U256::from(256) { Ok(()) } else { Err(SymbolicError::Unsupported(context)) } + } + + /// Handles `vm.bound` for unsigned or signed (`int256`) ranges. + pub(super) fn handle_bound( + &mut self, + state: &mut PathState, + args_offset: usize, + signed: bool, + ) -> Result { + let value = read_abi_word_arg(&mut self.cx, &state.memory, args_offset, 0)?; + let min = read_abi_word_arg(&mut self.cx, &state.memory, args_offset, 1)?; + let max = read_abi_word_arg(&mut self.cx, &state.memory, args_offset, 2)?; + let order = |a: &U256, b: &U256| if signed { i256_cmp(a, b) } else { a.cmp(b) }; + + if let (Some(value), Some(min), Some(max)) = + (value.as_const(), min.as_const(), max.as_const()) + { + if !order(&min, &max).is_lt() + || order(&value, &min).is_lt() + || order(&value, &max).is_gt() + { + return Ok(CheatcodeOutcome::Failure); + } + let bounded = if value == min { max } else { min }; + return Ok(CheatcodeOutcome::Continue(vec![SymExpr::constant(&mut self.cx, bounded)])); + } + + if let (Some(min), Some(max)) = (min.as_const(), max.as_const()) + && !order(&min, &max).is_lt() + { + return Ok(CheatcodeOutcome::Failure); + } + let (Some(min_word), Some(max_word)) = (min.as_const(), max.as_const()) else { + return Err(SymbolicError::Unsupported("symbolic vm.bound range")); + }; + + // Signed bounds are expressed as `!(x < min) && !(x > max)`. + let range_conditions = |cx: &mut SymCx, word: &SymExpr, as_consts: bool| { + let cmp = |cx: &mut SymCx, op, bound| { + if as_consts { + SymBoolExpr::cmp_word_const(cx, op, word, bound) + } else { + let bound = SymExpr::constant(cx, bound); + SymBoolExpr::cmp_word_expr(cx, op, word, bound) + } + }; + if signed { + let below_min = cmp(cx, SymCmpOp::Slt, min_word).not(cx); + let above_max = cmp(cx, SymCmpOp::Sgt, max_word).not(cx); + [below_min, above_max] + } else { + [cmp(cx, SymCmpOp::Uge, min_word), cmp(cx, SymCmpOp::Ule, max_word)] + } + }; + let in_range = range_conditions(&mut self.cx, &value, false); + let in_range = SymBoolExpr::and(&mut self.cx, in_range.into()); + let (_, in_range_sat) = self.constraints_with_condition(state, in_range.clone())?; + if !in_range_sat { + return Ok(CheatcodeOutcome::Failure); + } + let out_of_range = in_range.not(&mut self.cx); + let (out_of_range_constraints, out_of_range_sat) = + self.constraints_with_condition(state, out_of_range)?; + if out_of_range_sat { + state.constraints = out_of_range_constraints; + return Ok(CheatcodeOutcome::Failure); + } + + let bounded = + state.fresh_word(&mut self.cx, if signed { "vmBoundInt" } else { "vmBoundUint" }); + state.constraints.extend(range_conditions(&mut self.cx, &bounded, true)); + let same_value = SymBoolExpr::eq(&mut self.cx, bounded.clone(), value); + state.constraints.push(same_value.not(&mut self.cx)); + Ok(CheatcodeOutcome::Continue(vec![bounded])) + } +} diff --git a/crates/evm/symbolic/src/executor/create.rs b/crates/evm/symbolic/src/executor/create.rs new file mode 100644 index 0000000000000..2fb5779930657 --- /dev/null +++ b/crates/evm/symbolic/src/executor/create.rs @@ -0,0 +1,225 @@ +use super::*; + +impl SymbolicExecutor { + pub(super) fn create( + &mut self, + executor: &Executor, + state: &mut PathState, + worklist: &mut VecDeque, + completed_paths: &mut usize, + kind: CreateKind, + ) -> Result { + if state.is_static { + state.return_data = SymReturnData::empty(&mut self.cx); + return Ok(StepOutcome::ExceptionalHalt); + } + + let offset = state.stack.peek(1)?.clone(); + let size = state.stack.peek(2)?.clone(); + if let Some(outcome) = self.guard_memory_range(executor, state, worklist, &offset, &size)? { + return Ok(outcome); + } + + let value = state.stack.pop()?; + let offset = state.stack.pop()?; + let size = state.stack.pop()?; + let size = match state.constrained_usize_checked(&mut self.cx, &size) { + Some(Ok(size)) => BoundedCopySize::Concrete(size), + Some(Err(_)) => { + state.return_data = SymReturnData::empty(&mut self.cx); + return Ok(StepOutcome::Revert); + } + None => { + let max_limit = self.config.max_calldata_bytes as usize; + let max_size = self.solver_upper_bound_usize( + state, + &size, + max_limit, + "symbolic CREATE initcode size", + )?; + BoundedCopySize::Symbolic { size, max_size } + } + }; + let salt = + if matches!(kind, CreateKind::Create2) { Some(state.stack.pop()?) } else { None }; + + size.expand_memory(&mut self.cx, &mut state.memory, offset.clone()); + + let initcode = match &size { + BoundedCopySize::Concrete(size) => { + if let Some(offset) = state.constrained_usize(&mut self.cx, &offset) { + let bytes = state.memory.read_bytes(&mut self.cx, offset, *size); + SymCode::from_bytes(&mut self.cx, bytes) + } else { + SymCode::from_memory_offset(&mut self.cx, &state.memory, offset, *size) + } + } + BoundedCopySize::Symbolic { size, max_size } => SymCode::from_memory_symbolic_size( + &mut self.cx, + &state.memory, + offset, + size.clone(), + *max_size, + ), + }; + let (created_word, created) = match kind { + CreateKind::Create => { + let nonce = state.world.nonce(executor, state.address)?; + let address = state.address.create(nonce); + (SymExpr::constant(&mut self.cx, address_word(address)), address) + } + CreateKind::Create2 => create2_address_word( + &mut self.cx, + state, + state.address, + salt.expect("CREATE2 salt exists"), + &initcode, + )?, + }; + + if !self.prepare_create_value_transfer(executor, state, worklist, value.clone())? { + return Ok(StepOutcome::Continue); + } + + let mut failure_world = state.world.clone(); + failure_world.increment_nonce(executor, state.address)?; + + if failure_world.has_code_or_nonce(&mut self.cx, executor, created)? { + state.world = failure_world; + state.return_data = SymReturnData::empty(&mut self.cx); + state.stack.push(SymExpr::zero(&mut self.cx))?; + return Ok(StepOutcome::Continue); + } + + let calldata = SymBytes::empty(&mut self.cx); + let calldata = SymCalldata::from_bytes(&mut self.cx, calldata); + let mut frame = CallFrame::new( + &mut self.cx, + created, + created, + state.address, + value.clone(), + false, + calldata, + ); + frame.address_word = created_word.clone(); + frame.caller_word = state.address_word.clone(); + let mut child = state.child(frame); + let pending_expected_creates = std::mem::take(&mut child.expected_creates); + child.world = failure_world.clone(); + child.world.mark_current_transaction_created(created); + child.world.set_nonce(created, 1); + child.world.transfer(&mut self.cx, executor, state.address, created, value); + + let outcomes = self.execute_external_call(executor, child, &initcode, completed_paths)?; + if outcomes.is_empty() { + return Ok(StepOutcome::AssumeRejected); + } + + let mut parents = VecDeque::with_capacity(outcomes.len()); + for mut outcome in outcomes { + let runtime = &outcome.state.frame.return_data; + let spec_id: SpecId = executor.spec_id().into(); + let mut rejected_runtime = false; + if matches!(outcome.status, CallStatus::Success) { + if runtime_exceeds_code_size_limit(&executor.evm_env().cfg_env, spec_id, runtime) { + outcome.status = CallStatus::Revert; + outcome.state.frame.return_data = SymReturnData::empty(&mut self.cx); + } else if runtime_has_rejected_prefix(&mut self.cx, spec_id, runtime)? { + outcome.status = CallStatus::Revert; + rejected_runtime = true; + } + } + match self.join_call_outcome(state, outcome, created)? { + JoinedCallOutcome::Rejected => {} + JoinedCallOutcome::Failure(mut parent) => { + parent.return_data = SymReturnData::empty(&mut self.cx); + *state = parent; + return Ok(StepOutcome::Failure); + } + JoinedCallOutcome::ExceptionalHalt(mut parent) => { + parent.return_data = SymReturnData::empty(&mut self.cx); + parent.world = failure_world.clone(); + parent.stack.push(SymExpr::zero(&mut self.cx))?; + parents.push_back(parent); + } + JoinedCallOutcome::ExpectedRevert { mut parent, .. } => { + parent.return_data = SymReturnData::empty(&mut self.cx); + parent.expected_creates = pending_expected_creates.clone(); + parent.world = failure_world.clone(); + parent.stack.push(created_word.clone())?; + parents.push_back(parent); + } + JoinedCallOutcome::Success { mut parent, child } => { + parent.return_data = SymReturnData::empty(&mut self.cx); + let runtime = &child.frame.return_data; + parent.world = child.world; + parent.expected_emit = child.expected_emit; + parent.expected_creates = pending_expected_creates.clone(); + self.observe_expected_create(&mut parent, state.address, kind, runtime)?; + if !parent.world.is_destroyed(created) { + parent.world.install_code(created, runtime.to_code(&mut self.cx)?); + parent.world.set_nonce(created, 1); + } + parent.stack.push(created_word.clone())?; + parents.push_back(parent); + } + JoinedCallOutcome::Revert { mut parent, child } => { + parent.return_data = SymReturnData::empty(&mut self.cx); + parent.world = failure_world.clone(); + if !rejected_runtime { + parent.return_data = child.frame.return_data; + } + parent.stack.push(SymExpr::zero(&mut self.cx))?; + parents.push_back(parent); + } + } + } + + Ok(self.resume_parent_paths(state, worklist, parents)) + } + + pub(super) fn execute_external_call( + &mut self, + executor: &Executor, + initial: PathState, + code: &SymCode, + completed_paths: &mut usize, + ) -> Result, SymbolicError> { + let mut worklist = VecDeque::from([initial]); + let mut deferred_worklist = VecDeque::new(); + self.execute_call_path_batch( + executor, + code, + &mut worklist, + &mut deferred_worklist, + completed_paths, + CallPathKind::External, + ) + } +} + +fn runtime_exceeds_code_size_limit( + cfg: &impl Cfg, + spec_id: SpecId, + runtime: &SymReturnData, +) -> bool { + spec_id >= SpecId::SPURIOUS_DRAGON + && runtime.len_word.as_const().is_some() + && runtime.len() > cfg.max_code_size() +} + +fn runtime_has_rejected_prefix( + cx: &mut SymCx, + spec_id: SpecId, + runtime: &SymReturnData, +) -> Result { + if spec_id < SpecId::LONDON || runtime.len() == 0 { + return Ok(false); + } + let first_byte = runtime.byte(cx, 0); + let Some(first_byte) = first_byte.as_const() else { + return Err(SymbolicError::Unsupported("CREATE with symbolic runtime prefix not modeled")); + }; + Ok(first_byte == U256::from(0xef)) +} diff --git a/crates/evm/symbolic/src/executor/invariant.rs b/crates/evm/symbolic/src/executor/invariant.rs new file mode 100644 index 0000000000000..17b232767544c --- /dev/null +++ b/crates/evm/symbolic/src/executor/invariant.rs @@ -0,0 +1,360 @@ +use super::*; + +fn record_candidate_limitation( + limitation: &mut Option, + error: SymbolicError, +) -> bool { + let search_exhausted = matches!( + error, + SymbolicError::Timeout(_) | SymbolicError::Solver(_) | SymbolicError::SolverQueryLimit(_) + ); + if search_exhausted { + *limitation = Some(error.into()); + } else { + limitation.get_or_insert_with(|| error.into()); + } + search_exhausted +} + +impl SymbolicExecutor { + #[expect(clippy::too_many_arguments)] + pub(super) fn execute_invariant_check( + &mut self, + executor: &Executor, + state: PathState, + invariant_address: Address, + sender: Address, + invariant: &Function, + after_invariant: Option<&Function>, + completed_paths: &mut usize, + ) -> Result, SymbolicError> { + let mut call = + self.prepare_invariant_call(executor, state, invariant_address, sender, invariant)?; + + let mut checked = Vec::new(); + while let Some(outcome) = + self.execute_sequence_call_next(executor, &mut call, completed_paths)? + { + if !matches!(outcome.status, CallStatus::Success) { + return Ok(vec![InvariantCheckOutcome { failed: true, state: outcome.state }]); + } + + let Some(after_invariant) = after_invariant else { + checked.push(InvariantCheckOutcome { failed: false, state: outcome.state }); + continue; + }; + + let mut after_call = self.prepare_invariant_call( + executor, + outcome.state, + invariant_address, + sender, + after_invariant, + )?; + while let Some(after_outcome) = + self.execute_sequence_call_next(executor, &mut after_call, completed_paths)? + { + let failed = !matches!(after_outcome.status, CallStatus::Success); + let checked_outcome = InvariantCheckOutcome { failed, state: after_outcome.state }; + if failed { + return Ok(vec![checked_outcome]); + } + checked.push(checked_outcome); + } + } + Ok(checked) + } + + fn prepare_invariant_call( + &mut self, + executor: &Executor, + mut state: PathState, + invariant_address: Address, + sender: Address, + invariant: &Function, + ) -> Result { + state.invariant_predicate = true; + let calldata = SymbolicCalldata::selector_only(&mut self.cx, invariant)?; + let call_data = calldata.call_data(&mut self.cx); + let constraints = calldata.into_constraints(); + self.prepare_sequence_call( + executor, + state, + invariant_address, + sender, + invariant, + call_data, + constraints, + ) + } + + pub(super) fn search_invariant_candidates_inner( + &mut self, + input: &SymbolicInvariantCandidateInput<'_, FEN>, + candidates: &mut Vec, + limitation: &mut Option, + ) -> Result<(), SymbolicError> { + if input.invariants.is_empty() { + return Err(SymbolicError::Unsupported("symbolic invariant has no predicates")); + } + let mut completed_paths = 0; + + let mut initial_state = PathState::empty( + &mut self.cx, + input.invariant_address, + input.handler_sender, + input.ffi_enabled, + ); + initial_state.apply_executor_env(&mut self.cx, input.executor); + initial_state.world.set_storage_layout(self.config.storage_layout); + + let calldatas = SymbolicCalldata::variants_with_prefix( + &input.target.function, + &self.config, + &mut self.cx, + "frontier_handler", + )?; + 'variants: for calldata in calldatas { + self.check_timeout()?; + let step = SequenceStepTemplate { + sender: input.handler_sender, + address: input.target.address, + contract_name: input.target.contract_name.clone(), + function: input.target.function.clone(), + calldata, + }; + let call_data = step.calldata.call_data(&mut self.cx); + let constraints = step.calldata.constraints().to_vec(); + let mut handler = match self.prepare_sequence_call( + input.executor, + initial_state.clone(), + input.target.address, + input.handler_sender, + &input.target.function, + call_data, + constraints, + ) { + Ok(call) => call, + Err(error) => { + if record_candidate_limitation(limitation, error) { + break; + } + continue; + } + }; + let mut stop_after_handler = false; + loop { + let outcome = match self.execute_sequence_call_next( + input.executor, + &mut handler, + &mut completed_paths, + ) { + Ok(Some(outcome)) => outcome, + Ok(None) => break, + Err(error) => { + stop_after_handler = record_candidate_limitation(limitation, error); + break; + } + }; + if !matches!(outcome.status, CallStatus::Success) { + continue; + } + let handler_state = outcome.state; + for (invariant_idx, invariant) in input.invariants.iter().enumerate() { + self.check_timeout()?; + let mut predicate = match self.prepare_invariant_call( + input.executor, + handler_state.clone(), + input.invariant_address, + CALLER, + invariant, + ) { + Ok(call) => call, + Err(error) => { + if record_candidate_limitation(limitation, error) { + break 'variants; + } + continue; + } + }; + let mut stop_after_predicate = false; + let mut candidate_states = Vec::new(); + loop { + let predicate_outcome = match self.execute_sequence_call_next( + input.executor, + &mut predicate, + &mut completed_paths, + ) { + Ok(Some(outcome)) => outcome, + Ok(None) => break, + Err(error) => { + stop_after_predicate = + record_candidate_limitation(limitation, error); + break; + } + }; + if !matches!(predicate_outcome.status, CallStatus::Success) { + candidate_states.push(predicate_outcome.state); + continue; + } + let Some(after_invariant) = input.after_invariant else { + continue; + }; + + // Concrete invariant checks do not commit predicate state before invoking + // `afterInvariant`. Retain its path constraints while restoring the + // unchanged post-handler world. + let mut after_state = handler_state.clone(); + after_state.constraints = predicate_outcome.state.constraints; + let mut after = match self.prepare_invariant_call( + input.executor, + after_state, + input.invariant_address, + CALLER, + after_invariant, + ) { + Ok(call) => call, + Err(error) => { + if record_candidate_limitation(limitation, error) { + stop_after_predicate = true; + break; + } + continue; + } + }; + loop { + match self.execute_sequence_call_next( + input.executor, + &mut after, + &mut completed_paths, + ) { + Ok(Some(outcome)) => { + if !matches!(outcome.status, CallStatus::Success) { + candidate_states.push(outcome.state); + } + } + Ok(None) => break, + Err(error) => { + if record_candidate_limitation(limitation, error) { + stop_after_predicate = true; + } + break; + } + } + } + if stop_after_predicate { + break; + } + } + + for state in candidate_states { + match self.materialize_sequence(std::slice::from_ref(&step), &state) { + Ok((mut sequence, storage)) => { + let step = + sequence.pop().expect("one handler template produces one step"); + candidates.push(SymbolicInvariantCandidate { + invariant_idx, + step, + storage, + }); + } + Err(error) => { + if record_candidate_limitation(limitation, error) { + break 'variants; + } + } + } + } + if stop_after_predicate { + break 'variants; + } + } + } + if stop_after_handler { + break; + } + } + + Ok(()) + } + + #[expect(clippy::too_many_arguments)] + pub(super) fn prepare_sequence_call( + &mut self, + executor: &Executor, + mut state: PathState, + target: Address, + sender: Address, + _function: &Function, + calldata: SymCalldata, + constraints: Vec, + ) -> Result { + state.world.clear_transaction_scoped_state(); + state.mapping_hook_keccak_preimages.clear(); + let code = state.world.extcode(&mut self.cx, executor, target)?; + state.call_depth = 0; + state.origin = sender; + state.origin_word = SymExpr::constant(&mut self.cx, address_word(sender)); + let callvalue = SymExpr::zero(&mut self.cx); + state.frame = + CallFrame::new(&mut self.cx, target, target, sender, callvalue, false, calldata); + state.constraints.extend(constraints); + Ok(SequenceCall { + code, + worklist: VecDeque::from([state]), + deferred_worklist: VecDeque::new(), + }) + } + + pub(super) fn execute_sequence_call_next( + &mut self, + executor: &Executor, + call: &mut SequenceCall, + completed_paths: &mut usize, + ) -> Result, SymbolicError> { + if call.worklist.is_empty() && call.deferred_worklist.is_empty() { + return Ok(None); + } + let mut outcomes = self.execute_call_path_batch( + executor, + &call.code, + &mut call.worklist, + &mut call.deferred_worklist, + completed_paths, + CallPathKind::Sequence, + )?; + debug_assert!(outcomes.len() <= 1); + Ok(outcomes.pop()) + } + + pub(super) fn materialize_sequence( + &mut self, + steps: &[SequenceStepTemplate], + state: &PathState, + ) -> Result<(Vec, Vec), SymbolicError> { + let replayable_storage = state.world.replay_storage_symbols(); + let model = self.solver.model_with_replayable_storage( + &mut self.cx, + &state.constraints, + &replayable_storage, + )?; + let sequence = steps + .iter() + .map(|step| { + let args = step.calldata.model_to_args(&mut self.cx, &model)?; + let calldata = Bytes::from(step.function.abi_encode_input(&args)?); + Ok(SymbolicInvariantStep { + sender: step.sender, + address: step.address, + contract_name: step.contract_name.clone(), + function_name: step.function.name.clone(), + signature: step.function.signature(), + args, + calldata, + }) + }) + .collect::, SymbolicError>>()?; + let storage = state.world.replay_storage_assignments(&model)?; + Ok((sequence, storage)) + } +} diff --git a/crates/evm/symbolic/src/executor/mod.rs b/crates/evm/symbolic/src/executor/mod.rs new file mode 100644 index 0000000000000..bec3e01357bec --- /dev/null +++ b/crates/evm/symbolic/src/executor/mod.rs @@ -0,0 +1,389 @@ +use super::{abi::*, runtime::*, *}; + +mod calls; +mod cheatcodes; +mod constraints; +mod create; +mod invariant; +mod opcodes; +mod run; + +#[derive(Debug)] +struct CallOutcome { + status: CallStatus, + state: PathState, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum CallStatus { + Success, + Revert, + ExceptionalHalt, + Failure, +} + +enum JoinedCallOutcome { + Rejected, + ExpectedRevert { parent: PathState, child: PathState }, + Success { parent: PathState, child: PathState }, + Revert { parent: PathState, child: PathState }, + ExceptionalHalt(PathState), + Failure(PathState), +} + +#[derive(Clone, Copy)] +enum CallPathKind { + External, + Sequence, +} + +enum CallPathOpcode { + Execute(u8), + Halt, + Discard, +} + +pub(super) struct FeasiblePath { + state: PathState, + from_deferred: bool, +} + +#[derive(Debug)] +struct SequencePath { + state: PathState, + steps: Vec, +} + +#[derive(Debug)] +struct SequenceCall { + code: SymCode, + worklist: VecDeque, + deferred_worklist: VecDeque, +} + +#[derive(Clone, Debug)] +struct SequenceStepTemplate { + sender: Address, + address: Address, + contract_name: Option, + function: Function, + calldata: SymbolicCalldata, +} + +#[derive(Debug)] +struct InvariantCheckOutcome { + failed: bool, + state: PathState, +} + +impl SymbolicExecutor { + pub(super) fn pop_next_path(&self, paths: &mut VecDeque) -> Option { + match self.config.exploration_order { + SymbolicExplorationOrder::Bfs => paths.pop_front(), + SymbolicExplorationOrder::Dfs => paths.pop_back(), + } + } + + pub(super) fn pop_next_feasible_path( + &mut self, + paths: &mut VecDeque, + deferred_paths: &mut VecDeque, + deferred_mode: DeferredPathMode, + ) -> Result, SymbolicError> { + loop { + while let Some(mut state) = self.pop_next_path(paths) { + if state.take_deferred_feasibility_check() { + let replayable_storage = state.world.replay_storage_symbols(); + match self.solver.branch_feasibility_with_replayable_storage( + &mut self.cx, + &state.constraints, + &replayable_storage, + ) { + Ok(BranchFeasibility::Sat) => {} + Ok(BranchFeasibility::Unsat) => continue, + Ok(BranchFeasibility::NeedsSolver) => { + if matches!(deferred_mode, DeferredPathMode::Skip) { + self.defer_hard_arithmetic(); + continue; + } + trace!("queued hard arithmetic branch for deferred SMT solving"); + deferred_paths.push_back(state); + continue; + } + Err(SymbolicError::SolverUnknown) => { + self.defer_solver_unknown(); + continue; + } + Err(err) => return Err(err), + } + } + return Ok(Some(FeasiblePath { state, from_deferred: false })); + } + + let Some(state) = self.pop_next_path(deferred_paths) else { + return Ok(None); + }; + if self.deadline.is_none() { + self.deadline = self + .config + .timeout + .filter(|seconds| *seconds > 0) + .map(|seconds| Instant::now() + Duration::from_secs(seconds.into())); + } + self.check_timeout()?; + trace!("escalating deferred hard arithmetic branch to SMT solver"); + match self.is_sat_with_state(&state, &state.constraints) { + Ok(true) => return Ok(Some(FeasiblePath { state, from_deferred: true })), + Ok(false) => {} + Err(SymbolicError::SolverUnknown) => self.defer_solver_unknown(), + Err(err) => return Err(err), + } + } + } + + fn join_call_outcome( + &mut self, + state: &PathState, + mut outcome: CallOutcome, + reverter: Address, + ) -> Result { + let mut parent = state.clone(); + parent.take_call_outcome_state(&mut outcome.state); + + if let Some(assumption) = parent.assume_no_revert_next_call.take() + && matches!(outcome.status, CallStatus::Revert) + && self.assume_no_revert_rejects( + &mut parent, + &assumption, + reverter, + &outcome.state.frame.return_data, + )? + { + return Ok(JoinedCallOutcome::Rejected); + } + + if let Some(mut expected) = parent.expected_revert.clone() { + match outcome.status { + CallStatus::Success => return Ok(JoinedCallOutcome::Failure(parent)), + CallStatus::Revert | CallStatus::ExceptionalHalt | CallStatus::Failure => { + if !self.expected_revert_matches( + &mut parent, + &expected, + reverter, + &outcome.state.frame.return_data, + )? { + return Ok(JoinedCallOutcome::Failure(parent)); + } + if expected.consume_one() { + parent.expected_revert = None; + } else { + parent.expected_revert = Some(expected); + } + return Ok(JoinedCallOutcome::ExpectedRevert { parent, child: outcome.state }); + } + } + } + + Ok(match outcome.status { + CallStatus::Success => JoinedCallOutcome::Success { parent, child: outcome.state }, + CallStatus::Revert => JoinedCallOutcome::Revert { parent, child: outcome.state }, + CallStatus::ExceptionalHalt => JoinedCallOutcome::ExceptionalHalt(parent), + CallStatus::Failure => JoinedCallOutcome::Failure(parent), + }) + } + + fn resume_parent_paths( + &self, + state: &mut PathState, + worklist: &mut VecDeque, + mut parents: VecDeque, + ) -> StepOutcome { + let Some(first) = self.pop_next_path(&mut parents) else { + return StepOutcome::AssumeRejected; + }; + *state = first; + worklist.extend(parents); + StepOutcome::Continue + } + + fn execute_call_path_batch( + &mut self, + executor: &Executor, + code: &SymCode, + worklist: &mut VecDeque, + deferred_worklist: &mut VecDeque, + completed_paths: &mut usize, + kind: CallPathKind, + ) -> Result, SymbolicError> { + let mut outcomes = Vec::new(); + let mut outcomes_before_deferred = None; + let path_limit = self.config.path_width() as usize; + let depth_limit = self.config.execution_depth() as usize; + + loop { + // Let callers inspect a completed outcome before spending the remaining budget on a + // deferred sibling. A later proof pass reconstructs and drains all nested paths. + if matches!(kind, CallPathKind::Sequence) && !outcomes.is_empty() { + break; + } + if matches!(kind, CallPathKind::External) + && matches!(self.nested_deferred_mode, DeferredPathMode::Yield) + && outcomes_before_deferred.is_some_and(|count| outcomes.len() > count) + { + if !worklist.is_empty() || !deferred_worklist.is_empty() { + self.defer_hard_arithmetic(); + } + break; + } + let deferred_mode = match kind { + CallPathKind::Sequence => DeferredPathMode::Drain, + CallPathKind::External => self.nested_deferred_mode, + }; + let Some(next) = + self.pop_next_feasible_path(worklist, deferred_worklist, deferred_mode)? + else { + break; + }; + if next.from_deferred + && matches!(kind, CallPathKind::External) + && matches!(deferred_mode, DeferredPathMode::Yield) + { + outcomes_before_deferred = Some(outcomes.len()); + } + let mut state = next.state; + if *completed_paths >= path_limit { + return Err(SymbolicError::Unsupported("symbolic path limit exceeded")); + } + if std::mem::take(&mut state.pending_storage_hook_revert) { + *completed_paths += 1; + outcomes.push(CallOutcome { status: CallStatus::Revert, state }); + continue; + } + let _path_span = matches!(kind, CallPathKind::Sequence).then(|| { + trace_span!("symbolic_path", completed_paths, worklist_size = worklist.len()) + .entered() + }); + if matches!(kind, CallPathKind::Sequence) { + trace!(completed_paths, worklist_size = worklist.len(), "exploring symbolic path"); + } + + loop { + self.check_timeout()?; + if state.depth >= depth_limit { + return Err(SymbolicError::Unsupported("symbolic depth limit exceeded")); + } + state.depth += 1; + + let op = match kind { + CallPathKind::Sequence => match code.opcode(&mut self.cx, state.pc)? { + Some(op) => CallPathOpcode::Execute(op), + None => CallPathOpcode::Halt, + }, + CallPathKind::External => match code.guarded_opcode(&mut self.cx, state.pc)? { + GuardedOpcode::End => CallPathOpcode::Halt, + GuardedOpcode::Concrete(op) => CallPathOpcode::Execute(op), + GuardedOpcode::SymbolicSize { condition, opcode } => { + let mut in_bounds_constraints = state.constraints.clone(); + in_bounds_constraints.push(condition.clone()); + let in_bounds_sat = + self.is_sat_with_state(&state, &in_bounds_constraints)?; + + let mut out_of_bounds_constraints = state.constraints.clone(); + out_of_bounds_constraints.push(condition.not(&mut self.cx)); + if self.is_sat_with_state(&state, &out_of_bounds_constraints)? { + if *completed_paths >= path_limit { + return Err(SymbolicError::Unsupported( + "symbolic path limit exceeded", + )); + } + let mut halted = state.clone(); + halted.constraints = out_of_bounds_constraints; + *completed_paths += 1; + let status = self.successful_call_status(kind, &halted); + outcomes.push(CallOutcome { status, state: halted }); + } + + if in_bounds_sat { + state.constraints = in_bounds_constraints; + CallPathOpcode::Execute(opcode) + } else { + CallPathOpcode::Discard + } + } + }, + }; + let op = match op { + CallPathOpcode::Execute(op) => op, + CallPathOpcode::Halt => { + if *completed_paths >= path_limit { + return Err(SymbolicError::Unsupported("symbolic path limit exceeded")); + } + *completed_paths += 1; + let status = self.successful_call_status(kind, &state); + outcomes.push(CallOutcome { status, state }); + break; + } + CallPathOpcode::Discard => break, + }; + + let _step_span = matches!(kind, CallPathKind::Sequence) + .then(|| trace_span!("symbolic_step", pc = state.pc, op).entered()); + match self.step( + executor, + code, + code.jump_table(), + &mut state, + &mut *worklist, + completed_paths, + op, + )? { + StepOutcome::Continue => {} + StepOutcome::Halt => { + if *completed_paths >= path_limit { + return Err(SymbolicError::Unsupported("symbolic path limit exceeded")); + } + *completed_paths += 1; + let status = self.successful_call_status(kind, &state); + outcomes.push(CallOutcome { status, state }); + break; + } + StepOutcome::Revert => { + if *completed_paths >= path_limit { + return Err(SymbolicError::Unsupported("symbolic path limit exceeded")); + } + *completed_paths += 1; + outcomes.push(CallOutcome { status: CallStatus::Revert, state }); + break; + } + StepOutcome::ExceptionalHalt => { + if *completed_paths >= path_limit { + return Err(SymbolicError::Unsupported("symbolic path limit exceeded")); + } + *completed_paths += 1; + outcomes.push(CallOutcome { status: CallStatus::ExceptionalHalt, state }); + break; + } + StepOutcome::Failure => { + if *completed_paths >= path_limit { + return Err(SymbolicError::Unsupported("symbolic path limit exceeded")); + } + *completed_paths += 1; + outcomes.push(CallOutcome { status: CallStatus::Failure, state }); + break; + } + StepOutcome::AssumeRejected | StepOutcome::Forked => break, + } + } + } + + Ok(outcomes) + } + + fn successful_call_status(&self, kind: CallPathKind, state: &PathState) -> CallStatus { + if matches!(kind, CallPathKind::External) || state.expectations_satisfied() { + CallStatus::Success + } else { + CallStatus::Failure + } + } +} diff --git a/crates/evm/symbolic/src/executor/opcodes.rs b/crates/evm/symbolic/src/executor/opcodes.rs new file mode 100644 index 0000000000000..0507f332aad9f --- /dev/null +++ b/crates/evm/symbolic/src/executor/opcodes.rs @@ -0,0 +1,1525 @@ +use super::*; + +enum MappingStorageProvenance { + None, + Exact(SymbolicMappingProvenance), + Fork { equality: Vec, inequality: Vec }, +} + +impl SymbolicExecutor { + fn classify_mapping_match( + &mut self, + state: &PathState, + matches: SymBoolExpr, + provenance: SymbolicMappingProvenance, + ) -> Result, SymbolicError> { + let does_not_match = matches.clone().not(&mut self.cx); + let (inequality, inequality_is_sat) = + self.constraints_with_condition(state, does_not_match)?; + if !inequality_is_sat { + return Ok(Some(MappingStorageProvenance::Exact(provenance))); + } + let (equality, equality_is_sat) = self.constraints_with_condition(state, matches)?; + Ok(equality_is_sat.then_some(MappingStorageProvenance::Fork { equality, inequality })) + } + + fn observed_mapping_chain( + &mut self, + state: &PathState, + hash: &SymExpr, + ) -> Option<(SymExpr, Vec)> { + let account = state.storage_address; + let mut current = hash.clone(); + let mut keys = Vec::new(); + let mut visited = Vec::new(); + loop { + if visited.contains(¤t) { + return None; + } + visited.push(current.clone()); + let Some(bytes) = + state.mapping_hook_keccak_preimages.get(&(account, current.clone())).cloned() + else { + keys.reverse(); + return Some((current, keys)); + }; + if bytes.len() != 64 { + return None; + } + keys.push(SymExpr::from_bytes(&mut self.cx, bytes[..32].iter().cloned())); + current = SymExpr::from_bytes(&mut self.cx, bytes[32..64].iter().cloned()); + } + } + + fn mapping_storage_provenance( + &mut self, + state: &PathState, + key: &SymExpr, + ) -> Result { + let account = state.storage_address; + let observed = |hash: &SymExpr| { + state.mapping_hook_keccak_preimages.get(&(account, hash.clone())).cloned() + }; + if let Some(provenance) = + key.storage_mapping_provenance_observed_with(&mut self.cx, observed) + { + return Ok(MappingStorageProvenance::Exact(provenance)); + } + let mut hashes = state + .mapping_hook_keccak_preimages + .keys() + .filter(|(address, _)| *address == account) + .map(|(_, hash)| hash.clone()) + .collect::>(); + let contains_observed_hash = + hashes.iter().any(|hash| key.visit_bool(|candidate| candidate == hash)); + let key_is_const = key.as_const().is_some(); + let key_contains_keccak = key.contains_keccak(); + let key_is_storage_mapping_key = key.storage_mapping_key(&mut self.cx).is_some(); + let roots = state + .mapping_storage_store_hooks + .keys() + .filter(|(address, _)| *address == account) + .map(|(_, root)| *root) + .collect::>(); + hashes.sort_by_key(|hash| hash != key); + for hash in hashes { + if contains_observed_hash && !key.visit_bool(|candidate| candidate == &hash) { + continue; + } + let use_legacy_match = contains_observed_hash || !key_contains_keccak; + if use_legacy_match + && let Some(provenance) = + hash.storage_mapping_provenance_observed_with(&mut self.cx, |candidate| { + state + .mapping_hook_keccak_preimages + .get(&(account, candidate.clone())) + .cloned() + }) + { + if !state.mapping_storage_store_hooks.contains_key(&(account, provenance.root_slot)) + { + continue; + } + let equality = SymBoolExpr::eq(&mut self.cx, key.clone(), hash.clone()); + if key_is_const { + let inequality = equality.not(&mut self.cx); + let (_, inequality_is_sat) = + self.constraints_with_condition(state, inequality)?; + if !inequality_is_sat { + return Ok(MappingStorageProvenance::Exact(provenance)); + } + continue; + } + if let Some(provenance) = + self.classify_mapping_match(state, equality, provenance)? + { + return Ok(provenance); + } + continue; + } + if (key_is_const || key_contains_keccak) && !key_is_storage_mapping_key { + continue; + } + let Some((root, keys)) = self.observed_mapping_chain(state, &hash) else { + continue; + }; + for &root_slot in &roots { + let slot_matches = if key_is_const || key.visit_bool(|candidate| candidate == &hash) + { + SymBoolExpr::eq(&mut self.cx, key.clone(), hash.clone()) + } else { + key.storage_key_eq(&mut self.cx, &hash) + }; + let matches = if let Some(constrained_root) = + state.constrained_word(&mut self.cx, &root) + { + if constrained_root != root_slot { + continue; + } + slot_matches + } else { + let root_slot_expr = SymExpr::constant(&mut self.cx, root_slot); + let root_matches = SymBoolExpr::eq(&mut self.cx, root.clone(), root_slot_expr); + SymBoolExpr::and(&mut self.cx, vec![slot_matches, root_matches]) + }; + let provenance = SymbolicMappingProvenance { root_slot, keys: keys.clone() }; + if let Some(provenance) = self.classify_mapping_match(state, matches, provenance)? { + return Ok(provenance); + } + } + } + Ok(MappingStorageProvenance::None) + } + + fn storage_hook_calldata( + &mut self, + selector: [u8; 4], + words: impl IntoIterator, + ) -> SymCalldata { + let selector = SymBytes::concrete(&mut self.cx, selector.to_vec()); + let words = words.into_iter().map(|word| word.into_bytes(&mut self.cx)).collect::>(); + let bytes = SymBytes::concat(&mut self.cx, std::iter::once(selector).chain(words)); + SymCalldata::from_bytes(&mut self.cx, bytes) + } + + fn mapping_storage_hook_calldata( + &mut self, + selector: [u8; 4], + [account, computed_slot, root_slot, old_value, new_value]: [SymExpr; 5], + keys: Vec, + ) -> SymCalldata { + let selector = SymBytes::concrete(&mut self.cx, selector.to_vec()); + let keys_offset = SymExpr::constant(&mut self.cx, U256::from(6 * 32)); + let mut words = vec![account, computed_slot, root_slot, keys_offset, old_value, new_value]; + words.push(SymExpr::constant(&mut self.cx, U256::from(keys.len()))); + words.extend(keys); + let words = words.into_iter().map(|word| word.into_bytes(&mut self.cx)).collect::>(); + let bytes = SymBytes::concat(&mut self.cx, std::iter::once(selector).chain(words)); + SymCalldata::from_bytes(&mut self.cx, bytes) + } + + fn invoke_storage_hook( + &mut self, + executor: &Executor, + state: &mut PathState, + worklist: &mut VecDeque, + completed_paths: &mut usize, + hook: SymbolicStorageHook, + calldata: SymCalldata, + ) -> Result { + let code = state.world.extcode(&mut self.cx, executor, hook.callback_target)?; + if code.is_empty() { + return Ok(StepOutcome::Continue); + } + + let callvalue = SymExpr::zero(&mut self.cx); + let frame = CallFrame::new( + &mut self.cx, + hook.callback_target, + hook.callback_target, + CHEATCODE_ADDRESS, + callvalue, + false, + calldata, + ); + let child = state.storage_hook_child(frame); + let outcomes = self.execute_external_call(executor, child, &code, completed_paths)?; + if outcomes.is_empty() { + return Ok(StepOutcome::AssumeRejected); + } + + let mut parents = VecDeque::with_capacity(outcomes.len()); + for mut outcome in outcomes { + let mut parent = state.clone(); + parent.constraints = std::mem::take(&mut outcome.state.constraints); + parent.next_symbol = outcome.state.next_symbol; + parent.storage_load_hooks = std::mem::take(&mut outcome.state.storage_load_hooks); + parent.storage_store_hooks = std::mem::take(&mut outcome.state.storage_store_hooks); + parent.mapping_storage_store_hooks = + std::mem::take(&mut outcome.state.mapping_storage_store_hooks); + parent.mapping_hook_keccak_preimages = + std::mem::take(&mut outcome.state.mapping_hook_keccak_preimages); + parent.storage_hook_active = false; + + match outcome.status { + CallStatus::Success => { + parent.world = outcome.state.world; + parent.block = outcome.state.block; + } + CallStatus::Revert | CallStatus::ExceptionalHalt | CallStatus::Failure => { + parent.return_data = outcome.state.frame.return_data; + parent.pending_storage_hook_revert = true; + } + } + parents.push_back(parent); + } + + let Some(first) = self.pop_next_path(&mut parents) else { + return Ok(StepOutcome::AssumeRejected); + }; + *state = first; + worklist.extend(parents); + Ok(if std::mem::take(&mut state.pending_storage_hook_revert) { + StepOutcome::Revert + } else { + StepOutcome::Continue + }) + } + + fn push_comparison_result( + &mut self, + state: &mut PathState, + op_pc: usize, + opcode: u8, + condition: SymBoolExpr, + ) -> Result { + if !self.apply_branch_target_constraint(state, op_pc, opcode, &condition)? { + return Ok(StepOutcome::AssumeRejected); + } + let value = SymExpr::bool_word(&mut self.cx, condition); + state.stack.push(value)?; + Ok(StepOutcome::Continue) + } + + fn apply_branch_target_constraint( + &mut self, + state: &mut PathState, + op_pc: usize, + opcode: u8, + condition: &SymBoolExpr, + ) -> Result { + let Some(target) = state.branch_target() else { + return Ok(true); + }; + if state.satisfies_branch_target() { + return Ok(true); + } + if !target.matches(state.address, op_pc, opcode) { + return Ok(true); + } + + let desired = + if target.result() { condition.clone().not(&mut self.cx) } else { condition.clone() }; + let mut constraints = state.constraints.clone(); + constraints.push(desired); + if !self.branch_is_sat_or_defer(state, &constraints)? { + return Ok(false); + } + state.constraints = constraints; + state.mark_branch_target_reached(); + Ok(true) + } + + fn guard_fixed_memory_access( + &mut self, + executor: &Executor, + state: &mut PathState, + worklist: &mut VecDeque, + offset: &SymExpr, + size: usize, + ) -> Result, SymbolicError> { + let memory_limit = executor.evm_env().cfg_env.memory_limit(); + let host_max_offset = (usize::MAX & !31usize).checked_sub(size); + let constrained_offset = state.constrained_usize_checked(&mut self.cx, offset); + if constrained_offset.as_ref().is_some_and(|offset| match offset { + Ok(offset) => host_max_offset.is_none_or(|max| *offset > max), + Err(_) => true, + }) { + state.return_data = SymReturnData::empty(&mut self.cx); + return Ok(Some(StepOutcome::Revert)); + } + + let expanded_size_bound = state + .upper_bound_usize(&mut self.cx, offset) + .and_then(|offset| offset.checked_add(size)) + .and_then(|end| end.checked_add(31)) + .and_then(|end| u64::try_from(end & !31usize).ok()); + if expanded_size_bound.is_some_and(|size| size <= memory_limit) { + return Ok(None); + } + + let representable = if let Some(host_max_offset) = host_max_offset { + let host_max_offset = SymExpr::constant(&mut self.cx, U256::from(host_max_offset)); + SymBoolExpr::cmp_word_expr(&mut self.cx, SymCmpOp::Ule, offset, host_max_offset) + } else { + SymBoolExpr::constant(&mut self.cx, false) + }; + let size = SymExpr::constant(&mut self.cx, U256::from(size)); + let local_size = + state.memory.size_after_range_expansion_word(&mut self.cx, offset.clone(), size); + if let Some(local_size) = local_size.as_const() { + if local_size <= U256::from(memory_limit) { + return Ok(None); + } + state.return_data = SymReturnData::empty(&mut self.cx); + return Ok(Some(StepOutcome::Revert)); + } + let memory_limit = SymExpr::constant(&mut self.cx, U256::from(memory_limit)); + let within_limit = SymBoolExpr::cmp(&mut self.cx, SymCmpOp::Ule, local_size, memory_limit); + let valid_access = SymBoolExpr::and(&mut self.cx, vec![representable, within_limit]); + self.apply_memory_access_guard(state, worklist, valid_access) + } + + fn apply_memory_access_guard( + &mut self, + state: &mut PathState, + worklist: &mut VecDeque, + valid_access: SymBoolExpr, + ) -> Result, SymbolicError> { + let (valid_constraints, valid_sat) = + self.constraints_with_condition(state, valid_access.clone())?; + let invalid = valid_access.clone().not(&mut self.cx); + let (invalid_constraints, invalid_sat) = self.constraints_with_condition(state, invalid)?; + match (valid_sat, invalid_sat) { + (true, true) => { + let (valid_seed_models, invalid_seed_models) = + state.split_corpus_seed_models(&valid_access); + let mut valid = state.clone(); + valid.pc = valid.pc.saturating_sub(1); + valid.depth = valid.depth.saturating_sub(1); + valid.constraints = valid_constraints; + valid.set_corpus_seed_models(valid_seed_models); + worklist.push_back(valid); + state.constraints = invalid_constraints; + state.set_corpus_seed_models(invalid_seed_models); + state.return_data = SymReturnData::empty(&mut self.cx); + Ok(Some(StepOutcome::Revert)) + } + (true, false) => { + state.constraints = valid_constraints; + Ok(None) + } + (false, true) => { + state.constraints = invalid_constraints; + state.return_data = SymReturnData::empty(&mut self.cx); + Ok(Some(StepOutcome::Revert)) + } + (false, false) => Ok(Some(StepOutcome::AssumeRejected)), + } + } + + pub(super) fn guard_memory_range( + &mut self, + executor: &Executor, + state: &mut PathState, + worklist: &mut VecDeque, + offset: &SymExpr, + size: &SymExpr, + ) -> Result, SymbolicError> { + let memory_limit = executor.evm_env().cfg_env.memory_limit(); + if let (Some(offset_value), Some(size_value)) = (offset.as_const(), size.as_const()) { + let valid = size_value.is_zero() + || usize::try_from(offset_value) + .ok() + .zip(usize::try_from(size_value).ok()) + .and_then(|(offset, size)| offset.checked_add(size)) + .and_then(|end| end.checked_add(31)) + .and_then(|end| u64::try_from(end & !31usize).ok()) + .is_some_and(|end| end <= memory_limit); + if !valid { + state.return_data = SymReturnData::empty(&mut self.cx); + return Ok(Some(StepOutcome::Revert)); + } + state.memory.expand_range(&mut self.cx, offset.clone(), size.clone()); + return Ok(None); + } + + let offset_bound = state.upper_bound_usize(&mut self.cx, offset); + let size_bound = state.upper_bound_usize(&mut self.cx, size); + if offset_bound + .zip(size_bound) + .and_then(|(offset, size)| offset.checked_add(size)) + .and_then(|end| end.checked_add(31)) + .and_then(|end| u64::try_from(end & !31usize).ok()) + .is_some_and(|end| end <= memory_limit) + { + state.memory.expand_range(&mut self.cx, offset.clone(), size.clone()); + return Ok(None); + } + + let zero_size = SymBoolExpr::eq_word_const(&mut self.cx, size, U256::ZERO); + let host_max = SymExpr::constant(&mut self.cx, U256::from(usize::MAX & !31usize)); + let size_fits = + SymBoolExpr::cmp_word_expr(&mut self.cx, SymCmpOp::Ule, size, host_max.clone()); + let max_offset = SymExpr::binop(&mut self.cx, SymBinOp::Sub, host_max, size.clone()); + let offset_fits = + SymBoolExpr::cmp_word_expr(&mut self.cx, SymCmpOp::Ule, offset, max_offset); + + let local_size = state.memory.size_after_range_expansion_word( + &mut self.cx, + offset.clone(), + size.clone(), + ); + let memory_limit = SymExpr::constant(&mut self.cx, U256::from(memory_limit)); + let local_fits = SymBoolExpr::cmp(&mut self.cx, SymCmpOp::Ule, local_size, memory_limit); + let nonzero_valid = + SymBoolExpr::and(&mut self.cx, vec![size_fits, offset_fits, local_fits]); + let valid_access = SymBoolExpr::or(&mut self.cx, vec![zero_size, nonzero_valid]); + + let outcome = self.apply_memory_access_guard(state, worklist, valid_access)?; + if outcome.is_none() { + state.memory.expand_range(&mut self.cx, offset.clone(), size.clone()); + } + Ok(outcome) + } + + #[expect(clippy::too_many_arguments)] + pub(super) fn step( + &mut self, + executor: &Executor, + code: &SymCode, + jumpdests: &JumpTable, + state: &mut PathState, + worklist: &mut VecDeque, + completed_paths: &mut usize, + op: u8, + ) -> Result { + state.pc += 1; + + if Into::::into(executor.spec_id()) < opcode_activation(op) { + state.return_data = SymReturnData::empty(&mut self.cx); + return Ok(StepOutcome::ExceptionalHalt); + } + + match op { + opcode::PUSH0 => { + state.stack.push(SymExpr::zero(&mut self.cx))?; + } + opcode::PUSH1..=opcode::PUSH32 => { + let n = (op - opcode::PUSH1 + 1) as usize; + let end = state.pc.saturating_add(n); + if end > code.len() { + return Err(SymbolicError::InvalidBytecode("truncated PUSH data")); + } + let value = code.push_data_word(&mut self.cx, state.pc, n); + state.pc = end; + state.stack.push(value)?; + } + opcode::DUP1..=opcode::DUP16 => { + let n = (op - opcode::DUP1 + 1) as usize; + let value = state.stack.peek(n - 1)?.clone(); + state.stack.push(value)?; + } + opcode::SWAP1..=opcode::SWAP16 => { + let n = (op - opcode::SWAP1 + 1) as usize; + state.stack.swap(n)?; + } + opcode::STOP => return Ok(StepOutcome::Halt), + opcode::ADD | opcode::SUB | opcode::MUL | opcode::AND | opcode::OR | opcode::XOR => { + let bin_op = match op { + opcode::ADD => SymBinOp::Add, + opcode::SUB => SymBinOp::Sub, + opcode::MUL => SymBinOp::Mul, + opcode::AND => SymBinOp::And, + opcode::OR => SymBinOp::Or, + _ => SymBinOp::Xor, + }; + state.bin_word(&mut self.cx, bin_op)? + } + opcode::EXP => state.exp_word(&mut self.cx)?, + opcode::DIV | opcode::SDIV | opcode::MOD | opcode::SMOD => { + let bin_op = match op { + opcode::DIV => SymBinOp::UDiv, + opcode::SDIV => SymBinOp::SDiv, + opcode::MOD => SymBinOp::URem, + _ => SymBinOp::SRem, + }; + state.bin_word_div_zero_guard(&mut self.cx, bin_op)? + } + opcode::ADDMOD | opcode::MULMOD => { + let a = state.stack.pop()?; + let b = state.stack.pop()?; + let n = state.stack.pop()?; + let tern_op = + if op == opcode::ADDMOD { SymTernOp::AddMod } else { SymTernOp::MulMod }; + state.stack.push(SymExpr::ternop(&mut self.cx, tern_op, a, b, n))?; + } + opcode::LT | opcode::GT | opcode::SLT | opcode::SGT => { + let op_pc = state.pc - 1; + let cmp_op = match op { + opcode::LT => SymCmpOp::Ult, + opcode::GT => SymCmpOp::Ugt, + opcode::SLT => SymCmpOp::Slt, + _ => SymCmpOp::Sgt, + }; + let condition = state.cmp_word_condition(&mut self.cx, cmp_op)?; + return self.push_comparison_result(state, op_pc, op, condition); + } + opcode::EQ => { + let op_pc = state.pc - 1; + let a = state.stack.pop()?; + let b = state.stack.pop()?; + let condition = SymBoolExpr::eq(&mut self.cx, b, a); + return self.push_comparison_result(state, op_pc, op, condition); + } + opcode::ISZERO => { + let op_pc = state.pc - 1; + let value = state.stack.pop()?; + let value = value.into_zero_bool(&mut self.cx); + return self.push_comparison_result(state, op_pc, op, value); + } + opcode::NOT => { + let value = state.stack.pop()?; + state.stack.push(SymExpr::not(&mut self.cx, value))?; + } + opcode::SIGNEXTEND => { + let byte_index = state.stack.pop()?; + let value = state.stack.pop()?; + state.stack.push(signextend_word_dynamic(&mut self.cx, byte_index, value))?; + } + opcode::BYTE => { + let index = state.stack.pop()?; + let word = state.stack.pop()?; + state.stack.push(byte_word_dynamic(&mut self.cx, index, word))?; + } + opcode::SHL => state.shift_word(&mut self.cx, SymBinOp::Shl)?, + opcode::SHR => state.shift_word(&mut self.cx, SymBinOp::Shr)?, + opcode::SAR => state.shift_word(&mut self.cx, SymBinOp::Sar)?, + opcode::KECCAK256 => { + let offset = state.stack.peek(0)?.clone(); + let size = state.stack.peek(1)?.clone(); + if let Some(outcome) = + self.guard_memory_range(executor, state, worklist, &offset, &size)? + { + return Ok(outcome); + } + let offset = state.stack.pop()?; + let size = state.stack.pop()?; + match state.constrained_usize_checked(&mut self.cx, &size) { + Some(Ok(size)) => { + let bytes = state.memory.read_byte_exprs_offset(&mut self.cx, offset, size); + let hash = keccak_word(&mut self.cx, bytes.clone()); + let has_mapping_hook = state + .mapping_storage_store_hooks + .keys() + .any(|(address, _)| *address == state.storage_address); + if has_mapping_hook && !state.storage_hook_active && size == 64 { + state + .mapping_hook_keccak_preimages + .entry((state.storage_address, hash.clone())) + .or_insert_with(|| bytes.into()); + } + state.stack.push(hash)?; + } + Some(Err(_)) => { + return Ok(StepOutcome::Revert); + } + None => { + let has_mapping_hook = state + .mapping_storage_store_hooks + .keys() + .any(|(address, _)| *address == state.storage_address); + if has_mapping_hook && !state.storage_hook_active { + let mapping_size = SymExpr::constant(&mut self.cx, U256::from(64)); + let mapping_size_feasible = + SymBoolExpr::eq(&mut self.cx, size.clone(), mapping_size); + let (_, mapping_size_feasible) = + self.constraints_with_condition(state, mapping_size_feasible)?; + if mapping_size_feasible { + self.defer_incomplete( + "symbolic KECCAK256 size may conceal mapping provenance", + ); + } + } + let max_limit = self.config.max_calldata_bytes as usize; + let max_size = self.solver_upper_bound_usize( + state, + &size, + max_limit, + "symbolic SHA3 size", + )?; + let bytes = state.memory.read_byte_exprs_symbolic_size( + &mut self.cx, + offset, + size.clone(), + max_size, + ); + state.stack.push(keccak_word_with_len(&mut self.cx, bytes, size))?; + } + } + } + opcode::ADDRESS | opcode::CALLER | opcode::ORIGIN | opcode::CALLVALUE => { + let value = match op { + opcode::ADDRESS => state.address_word.clone(), + opcode::CALLER => state.caller_word.clone(), + opcode::ORIGIN => state.origin_word.clone(), + _ => state.callvalue.clone(), + }; + state.stack.push(value)?; + } + opcode::BLOCKHASH => { + let number = state.stack.pop()?; + let hash = state.block.block_hash_word(&mut self.cx, executor, number)?; + state.stack.push(hash)?; + } + opcode::BALANCE => { + let target = state.stack.pop()?; + let balance = state.balance_word(&mut self.cx, executor, target)?; + state.stack.push(balance)?; + } + opcode::SELFBALANCE => { + let balance = state.balance(&mut self.cx, executor, state.address); + state.stack.push(balance)?; + } + opcode::EXTCODESIZE => { + let target = state.stack.pop()?; + let size = state.extcode_size_word(&mut self.cx, executor, target)?; + state.stack.push(size)?; + } + opcode::EXTCODEHASH => { + let target = state.stack.pop()?; + let hash = state.extcode_hash_word(&mut self.cx, executor, target)?; + state.stack.push(hash)?; + } + opcode::EXTCODECOPY => { + let dest = state.stack.peek(1)?.clone(); + let size = state.stack.peek(3)?.clone(); + if let Some(outcome) = + self.guard_memory_range(executor, state, worklist, &dest, &size)? + { + return Ok(outcome); + } + let target = state.stack.pop()?; + let dest = state.stack.pop()?; + let offset = state.stack.pop()?; + let size = state.stack.pop()?; + match state.constrained_usize_checked(&mut self.cx, &size) { + Some(Ok(size)) => { + let bytes = state.extcode_bytes_word( + &mut self.cx, + executor, + target, + offset, + size, + )?; + state.memory.store_bytes_offset(&mut self.cx, dest, bytes); + } + Some(Err(_)) => { + return Ok(StepOutcome::Revert); + } + None => { + let max_limit = self.config.max_calldata_bytes as usize; + let max_size = self.solver_upper_bound_usize( + state, + &size, + max_limit, + "symbolic EXTCODECOPY size", + )?; + if max_size != 0 { + let bytes = state.extcode_bytes_word( + &mut self.cx, + executor, + target, + offset, + max_size, + )?; + state.memory.copy_bytes_size_offset(&mut self.cx, dest, size, bytes)?; + } + } + } + } + opcode::CALLDATALOAD => { + let offset = state.stack.pop()?; + let value = state.calldata.load_word(&mut self.cx, offset); + state.stack.push(value)?; + } + opcode::CALLDATASIZE => { + let size = state.calldata.size_word(); + state.stack.push(size)?; + } + opcode::CALLDATACOPY => { + let dest = state.stack.peek(0)?.clone(); + let size = state.stack.peek(2)?.clone(); + if let Some(outcome) = + self.guard_memory_range(executor, state, worklist, &dest, &size)? + { + return Ok(outcome); + } + let dest = state.stack.pop()?; + let offset = state.stack.pop()?; + let size = state.stack.pop()?; + match state.constrained_usize_checked(&mut self.cx, &size) { + Some(Ok(size)) => { + if size != 0 { + let CallFrame { memory, calldata, .. } = &mut state.frame; + memory.copy_calldata_to_offset( + &mut self.cx, + dest, + offset, + size, + calldata, + ); + } + } + Some(Err(_)) => { + return Ok(StepOutcome::Revert); + } + None => { + let max_limit = self.config.max_calldata_bytes as usize; + let max_size = self.solver_upper_bound_usize( + state, + &size, + max_limit, + "symbolic CALLDATACOPY size", + )?; + if max_size != 0 { + let CallFrame { memory, calldata, .. } = &mut state.frame; + memory.copy_calldata_symbolic_size( + &mut self.cx, + dest, + offset, + size, + max_size, + calldata, + )?; + } + } + } + } + opcode::CODESIZE => { + let value = SymExpr::constant(&mut self.cx, U256::from(code.len())); + state.stack.push(value)?; + } + opcode::CODECOPY => { + let dest = state.stack.peek(0)?.clone(); + let size = state.stack.peek(2)?.clone(); + if let Some(outcome) = + self.guard_memory_range(executor, state, worklist, &dest, &size)? + { + return Ok(outcome); + } + let dest = state.stack.pop()?; + let offset = state.stack.pop()?; + let size = state.stack.pop()?; + match state.constrained_usize_checked(&mut self.cx, &size) { + Some(Ok(size)) => { + let bytes = code.read_bytes_offset(&mut self.cx, offset, size); + state.memory.store_bytes_offset(&mut self.cx, dest, bytes); + } + Some(Err(_)) => { + return Ok(StepOutcome::Revert); + } + None => { + let max_limit = self.config.max_calldata_bytes as usize; + let max_size = self.solver_upper_bound_usize( + state, + &size, + max_limit, + "symbolic CODECOPY size", + )?; + if max_size != 0 { + let bytes = code.read_bytes_offset(&mut self.cx, offset, max_size); + state.memory.copy_bytes_size_offset(&mut self.cx, dest, size, bytes)?; + } + } + } + } + opcode::RETURNDATASIZE => { + let size = state.return_data.len_word.clone(); + state.stack.push(size)?; + } + opcode::RETURNDATACOPY => { + let dest = state.stack.peek(0)?.clone(); + let offset = state.stack.peek(1)?.clone(); + let size = state.stack.peek(2)?.clone(); + if let Some(outcome) = + self.guard_memory_range(executor, state, worklist, &dest, &size)? + { + return Ok(outcome); + } + if let Some(outcome) = + self.guard_returndata_copy_range(state, worklist, &offset, &size)? + { + return Ok(outcome); + } + let dest = state.stack.pop()?; + let offset = state.stack.pop()?; + let size = state.stack.pop()?; + match state.constrained_usize_checked(&mut self.cx, &size) { + Some(Ok(size)) => { + let CallFrame { memory, return_data, .. } = &mut state.frame; + memory.copy_return_data_to_offset( + &mut self.cx, + dest, + offset, + size, + return_data, + )?; + } + Some(Err(_)) => { + return Ok(StepOutcome::Revert); + } + None => { + let available = state + .constrained_usize(&mut self.cx, &offset) + .map(|offset| state.return_data.len().saturating_sub(offset)) + .unwrap_or(state.return_data.len()); + let max_limit = available.min(self.config.max_calldata_bytes as usize); + let max_size = self.solver_upper_bound_usize( + state, + &size, + max_limit, + "symbolic RETURNDATACOPY size", + )?; + let CallFrame { memory, return_data, .. } = &mut state.frame; + memory.copy_return_data_symbolic_size( + &mut self.cx, + dest, + offset, + size, + max_size, + return_data, + )?; + } + } + } + opcode::POP => { + state.stack.pop()?; + } + opcode::MLOAD => { + let offset = state.stack.peek(0)?.clone(); + if let Some(outcome) = + self.guard_fixed_memory_access(executor, state, worklist, &offset, 32)? + { + return Ok(outcome); + } + let offset = state.stack.pop()?; + let value = state.memory.load_word_offset(&mut self.cx, offset)?; + state.stack.push(value)?; + } + opcode::MSTORE => { + let offset = state.stack.peek(0)?.clone(); + if let Some(outcome) = + self.guard_fixed_memory_access(executor, state, worklist, &offset, 32)? + { + return Ok(outcome); + } + let offset = state.stack.pop()?; + let value = state.stack.pop()?; + let minimum_offset = state.lower_bound_usize(&offset); + state.memory.store_word_offset(&mut self.cx, offset, value, minimum_offset); + } + opcode::MSTORE8 => { + let offset = state.stack.peek(0)?.clone(); + if let Some(outcome) = + self.guard_fixed_memory_access(executor, state, worklist, &offset, 1)? + { + return Ok(outcome); + } + let offset = state.stack.pop()?; + let value = state.stack.pop()?; + let minimum_offset = state.lower_bound_usize(&offset); + state.memory.store_byte_offset(&mut self.cx, offset, value, minimum_offset); + } + opcode::SLOAD => { + let key = state.stack.pop()?; + state.record_sload(state.storage_address, key.clone()); + let concrete_key = state.constrained_word(&mut self.cx, &key); + let value = state.world.sload( + &mut self.cx, + executor, + state.storage_address, + key.clone(), + concrete_key, + )?; + state.stack.push(value.clone())?; + if !state.storage_hook_active + && let Some(hook) = + state.storage_load_hooks.get(&state.storage_address).copied() + { + let account = + SymExpr::constant(&mut self.cx, address_word(state.storage_address)); + let calldata = + self.storage_hook_calldata(hook.callback_selector, [account, key, value]); + return self.invoke_storage_hook( + executor, + state, + worklist, + completed_paths, + hook, + calldata, + ); + } + } + opcode::SSTORE => { + if state.is_static { + state.return_data = SymReturnData::empty(&mut self.cx); + return Ok(StepOutcome::ExceptionalHalt); + } + let key = state.stack.peek(0)?.clone(); + state.stack.peek(1)?; + let hook = (!state.storage_hook_active) + .then(|| state.storage_store_hooks.get(&state.storage_address).copied()) + .flatten(); + let mapping = if state.storage_hook_active { + MappingStorageProvenance::None + } else { + self.mapping_storage_provenance(state, &key)? + }; + let mapping = match mapping { + MappingStorageProvenance::None => None, + MappingStorageProvenance::Exact(provenance) => state + .mapping_storage_store_hooks + .get(&(state.storage_address, provenance.root_slot)) + .copied() + .map(|hook| (hook, provenance)), + MappingStorageProvenance::Fork { equality, inequality } => { + let store_pc = state.pc - 1; + let mut equality_state = state.clone(); + equality_state.pc = store_pc; + equality_state.depth = equality_state.depth.saturating_sub(1); + equality_state.constraints = equality; + let mut inequality_state = state.clone(); + inequality_state.pc = store_pc; + inequality_state.depth = inequality_state.depth.saturating_sub(1); + inequality_state.constraints = inequality; + worklist.push_back(equality_state); + worklist.push_back(inequality_state); + return Ok(StepOutcome::Forked); + } + }; + state.stack.pop()?; + let value = state.stack.pop()?; + state.record_sstore(state.storage_address, key.clone()); + let old_value = if hook.is_some() || mapping.is_some() { + let concrete_key = state.constrained_word(&mut self.cx, &key); + Some(state.world.sload( + &mut self.cx, + executor, + state.storage_address, + key.clone(), + concrete_key, + )?) + } else { + None + }; + state.world.sstore(state.storage_address, key.clone(), value.clone()); + if let Some(hook) = hook { + let account = + SymExpr::constant(&mut self.cx, address_word(state.storage_address)); + let calldata = self.storage_hook_calldata( + hook.callback_selector, + [ + account, + key, + old_value.expect("old value loaded for storage hook"), + value, + ], + ); + return self.invoke_storage_hook( + executor, + state, + worklist, + completed_paths, + hook, + calldata, + ); + } else if let Some((hook, provenance)) = mapping { + let account = + SymExpr::constant(&mut self.cx, address_word(state.storage_address)); + let root = SymExpr::constant(&mut self.cx, provenance.root_slot); + let calldata = self.mapping_storage_hook_calldata( + hook.callback_selector, + [ + account, + key, + root, + old_value.expect("old value loaded for mapping storage hook"), + value, + ], + provenance.keys, + ); + return self.invoke_storage_hook( + executor, + state, + worklist, + completed_paths, + hook, + calldata, + ); + } + } + opcode::TLOAD => { + let key = state.stack.pop()?; + let value = state.world.tload(&mut self.cx, state.storage_address, key); + state.stack.push(value)?; + } + opcode::TSTORE => { + if state.is_static { + state.return_data = SymReturnData::empty(&mut self.cx); + return Ok(StepOutcome::ExceptionalHalt); + } + let key = state.stack.pop()?; + let value = state.stack.pop()?; + state.world.tstore(state.storage_address, key, value); + } + opcode::JUMP => { + let dest = state.stack.pop()?; + let Some(dest) = self.resolve_jump_destination( + state, + jumpdests, + dest, + "symbolic JUMP destination", + )? + else { + state.return_data = SymReturnData::empty(&mut self.cx); + return Ok(StepOutcome::ExceptionalHalt); + }; + if !self.take_loop_jump(state, state.pc, dest) { + return Ok(StepOutcome::AssumeRejected); + } + state.pc = dest; + } + opcode::JUMPI => { + let dest = state.stack.pop()?; + let cond = state.stack.pop()?; + match cond.as_const().map(|value| !value.is_zero()) { + Some(true) => { + let Some(dest) = self.resolve_jump_destination( + state, + jumpdests, + dest, + "symbolic JUMPI destination", + )? + else { + state.return_data = SymReturnData::empty(&mut self.cx); + return Ok(StepOutcome::ExceptionalHalt); + }; + if !self.take_loop_jump(state, state.pc, dest) { + return Ok(StepOutcome::AssumeRejected); + } + state.pc = dest; + } + Some(false) => {} + None => { + let true_cond = cond.nonzero_bool(&mut self.cx); + let dest = match self.resolve_jump_destination( + state, + jumpdests, + dest, + "symbolic JUMPI destination", + ) { + Ok(Some(dest)) => dest, + Ok(None) => { + return self.branch_invalid_jumpi(state, worklist, true_cond); + } + Err(err) => { + let (_, taken_sat) = + self.constraints_with_condition(state, true_cond.clone())?; + if taken_sat { + return Err(err); + } + let (_, not_taken_seed_models) = + state.split_corpus_seed_models(&true_cond); + state.constraints.push(true_cond.not(&mut self.cx)); + state.set_corpus_seed_models(not_taken_seed_models); + return Ok(StepOutcome::Continue); + } + }; + let op_pc = state.pc.saturating_sub(1); + let _branch_span = trace_span!("jumpi_branch", pc = op_pc, dest).entered(); + let false_cond = true_cond.clone().not(&mut self.cx); + let fallthrough = state.pc; + let (true_seed_models, false_seed_models) = + state.split_corpus_seed_models(&true_cond); + let mut true_state = state.clone(); + true_state.constraints.push(true_cond); + true_state.set_corpus_seed_models(true_seed_models); + true_state.pc = dest; + let mut false_state = state.clone(); + false_state.constraints.push(false_cond); + false_state.set_corpus_seed_models(false_seed_models); + false_state.pc = fallthrough; + + let true_pending = self.take_loop_jump(&mut true_state, fallthrough, dest); + if true_pending { + true_state.defer_feasibility_check(); + } + false_state.defer_feasibility_check(); + trace!(true_pending, false_pending = true, "JUMPI symbolic branch"); + if true_pending { + let true_seed_count = true_state.corpus_seed_model_count(); + let false_seed_count = false_state.corpus_seed_model_count(); + match ( + false_seed_count.cmp(&true_seed_count), + self.config.exploration_order, + ) { + (std::cmp::Ordering::Greater, SymbolicExplorationOrder::Bfs) + | (std::cmp::Ordering::Less, SymbolicExplorationOrder::Dfs) => { + worklist.push_back(false_state); + worklist.push_back(true_state); + } + (std::cmp::Ordering::Greater, SymbolicExplorationOrder::Dfs) + | (std::cmp::Ordering::Less, SymbolicExplorationOrder::Bfs) + | (std::cmp::Ordering::Equal, _) => { + worklist.push_back(true_state); + worklist.push_back(false_state); + } + } + } else { + worklist.push_back(false_state); + } + return Ok(StepOutcome::Forked); + } + } + } + opcode::PC => { + let pc = state.pc - 1; + let pc = SymExpr::constant(&mut self.cx, U256::from(pc)); + state.stack.push(pc)?; + } + opcode::MSIZE => { + let size = state.memory.size_word(&mut self.cx); + state.stack.push(size)?; + } + opcode::GAS => { + let gas = state.fresh_gasleft(&mut self.cx); + state.stack.push(gas)?; + } + opcode::JUMPDEST => {} + opcode::MCOPY => { + let dest = state.stack.peek(0)?.clone(); + let src = state.stack.peek(1)?.clone(); + let size = state.stack.peek(2)?.clone(); + if let Some(outcome) = + self.guard_memory_range(executor, state, worklist, &dest, &size)? + { + return Ok(outcome); + } + if let Some(outcome) = + self.guard_memory_range(executor, state, worklist, &src, &size)? + { + return Ok(outcome); + } + let dest = state.stack.pop()?; + let src = state.stack.pop()?; + let size = state.stack.pop()?; + match state.constrained_usize_checked(&mut self.cx, &size) { + Some(Ok(size)) => { + state.memory.copy_memory_to_offset(&mut self.cx, dest, src, size)?; + } + Some(Err(_)) => { + return Ok(StepOutcome::Revert); + } + None => { + let max_limit = self.config.max_calldata_bytes as usize; + let max_size = self.solver_upper_bound_usize( + state, + &size, + max_limit, + "symbolic MCOPY size", + )?; + if max_size != 0 { + state.memory.copy_memory_symbolic_size( + &mut self.cx, + dest, + src, + size, + max_size, + )?; + } + } + } + } + opcode::RETURN | opcode::REVERT => { + let offset = state.stack.peek(0)?.clone(); + let size = state.stack.peek(1)?.clone(); + if let Some(outcome) = + self.guard_memory_range(executor, state, worklist, &offset, &size)? + { + return Ok(outcome); + } + return self.return_or_revert(state, op == opcode::REVERT); + } + opcode::INVALID => return Ok(StepOutcome::ExceptionalHalt), + opcode::CALL => { + return self.call(executor, state, worklist, completed_paths, CallKind::Call); + } + opcode::CALLCODE => { + return self.call(executor, state, worklist, completed_paths, CallKind::CallCode); + } + opcode::DELEGATECALL => { + return self.call( + executor, + state, + worklist, + completed_paths, + CallKind::DelegateCall, + ); + } + opcode::STATICCALL => { + return self.call(executor, state, worklist, completed_paths, CallKind::StaticCall); + } + opcode::CREATE => { + return self.create(executor, state, worklist, completed_paths, CreateKind::Create); + } + opcode::CREATE2 => { + return self.create( + executor, + state, + worklist, + completed_paths, + CreateKind::Create2, + ); + } + opcode::SELFDESTRUCT => { + if state.is_static { + state.return_data = SymReturnData::empty(&mut self.cx); + return Ok(StepOutcome::ExceptionalHalt); + } + let spec_id: SpecId = executor.spec_id().into(); + let (beneficiary_word, beneficiary) = + state.pop_address_word_or_symbolic_slot(&mut self.cx)?; + if spec_id < SpecId::CANCUN + || state.world.was_created_in_current_transaction(state.address) + { + state.world.selfdestruct_legacy( + &mut self.cx, + executor, + state.address, + beneficiary, + )?; + } else { + if state.constrained_word(&mut self.cx, &beneficiary_word).is_none() { + return Err(SymbolicError::Unsupported( + "symbolic SELFDESTRUCT beneficiary", + )); + } + state.world.selfdestruct_cancun_existing( + &mut self.cx, + executor, + state.address, + beneficiary, + ); + } + state.return_data = SymReturnData::empty(&mut self.cx); + return Ok(StepOutcome::Halt); + } + opcode::CHAINID => { + let value = state.block.chain_id.clone(); + state.stack.push(value)?; + } + opcode::BASEFEE => { + let value = state.block.basefee.clone(); + state.stack.push(value)?; + } + opcode::GASPRICE => { + let gas_price = state.gas_price.clone(); + state.stack.push(gas_price)?; + } + opcode::BLOBHASH => { + let index = state.stack.pop()?; + let index = state.expect_constrained_usize( + &mut self.cx, + index, + "symbolic BLOBHASH index", + )?; + let hash = state.block.blob_hashes.get(index).copied().unwrap_or_default(); + let hash = SymExpr::constant(&mut self.cx, U256::from_be_slice(hash.as_slice())); + state.stack.push(hash)?; + } + opcode::COINBASE => { + let coinbase = state.block.coinbase; + let coinbase = SymExpr::constant(&mut self.cx, address_word(coinbase)); + state.stack.push(coinbase)?; + } + opcode::TIMESTAMP => { + let value = state.block.timestamp.clone(); + state.stack.push(value)?; + } + opcode::NUMBER => { + let value = state.block.number.clone(); + state.stack.push(value)?; + } + opcode::DIFFICULTY => { + let value = state.block.difficulty.clone(); + state.stack.push(value)?; + } + opcode::GASLIMIT => { + let value = state.block.gaslimit.clone(); + state.stack.push(value)?; + } + opcode::BLOBBASEFEE => { + let value = state.block.blob_basefee.clone(); + state.stack.push(value)?; + } + opcode::LOG0 | opcode::LOG1 | opcode::LOG2 | opcode::LOG3 | opcode::LOG4 => { + if state.is_static { + state.return_data = SymReturnData::empty(&mut self.cx); + return Ok(StepOutcome::ExceptionalHalt); + } + let topics = (op - opcode::LOG0) as usize; + let offset = state.stack.peek(0)?.clone(); + let size = state.stack.peek(1)?.clone(); + if let Some(outcome) = + self.guard_memory_range(executor, state, worklist, &offset, &size)? + { + return Ok(outcome); + } + let offset = state.stack.pop()?; + if offset.contains_gasleft() { + return Err(SymbolicError::Unsupported("GAS/gasleft() not modeled")); + } + let size = state.stack.pop()?; + if size.contains_gasleft() { + return Err(SymbolicError::Unsupported("GAS/gasleft() not modeled")); + } + let (data_len, data) = match state.constrained_usize_checked(&mut self.cx, &size) { + Some(Ok(size)) => ( + SymExpr::constant(&mut self.cx, U256::from(size)), + state.memory.read_bytes_offset(&mut self.cx, offset, size), + ), + Some(Err(_)) => { + return Ok(StepOutcome::Revert); + } + None => { + let max_limit = self.config.max_calldata_bytes as usize; + let max_size = self.solver_upper_bound_usize( + state, + &size, + max_limit, + "symbolic LOG size", + )?; + let data = state.memory.read_bytes_symbolic_size( + &mut self.cx, + offset, + size.clone(), + max_size, + ); + (size, data) + } + }; + let mut log_topics = Vec::with_capacity(topics); + for _ in 0..topics { + let topic = state.stack.pop()?; + if topic.contains_gasleft() { + return Err(SymbolicError::Unsupported("GAS/gasleft() not modeled")); + } + log_topics.push(topic); + } + return self.handle_log( + state, + SymbolicLog::new(log_topics, data_len, data, state.address), + ); + } + _ => return Err(SymbolicError::UnsupportedOpcode(op)), + }; + + Ok(StepOutcome::Continue) + } + + fn resolve_jump_destination( + &mut self, + state: &PathState, + jumpdests: &JumpTable, + dest: SymExpr, + unsupported: &'static str, + ) -> Result, SymbolicError> { + let dest = state.expect_constrained_word(&mut self.cx, dest, unsupported)?; + let Ok(dest) = usize::try_from(dest) else { return Ok(None) }; + Ok(jumpdests.is_valid(dest).then_some(dest)) + } + + fn branch_invalid_jumpi( + &mut self, + state: &mut PathState, + worklist: &mut VecDeque, + taken: SymBoolExpr, + ) -> Result { + let (taken_constraints, taken_sat) = + self.constraints_with_condition(state, taken.clone())?; + let not_taken = taken.clone().not(&mut self.cx); + let (taken_seed_models, not_taken_seed_models) = state.split_corpus_seed_models(&taken); + if !taken_sat { + state.constraints.push(not_taken); + state.set_corpus_seed_models(not_taken_seed_models); + return Ok(StepOutcome::Continue); + } + + let (not_taken_constraints, not_taken_sat) = + self.constraints_with_condition(state, not_taken)?; + if not_taken_sat { + let mut fallthrough = state.clone(); + fallthrough.constraints = not_taken_constraints; + fallthrough.set_corpus_seed_models(not_taken_seed_models); + worklist.push_back(fallthrough); + } + state.constraints = taken_constraints; + state.set_corpus_seed_models(taken_seed_models); + state.return_data = SymReturnData::empty(&mut self.cx); + Ok(StepOutcome::ExceptionalHalt) + } + + fn guard_returndata_copy_range( + &mut self, + state: &mut PathState, + worklist: &mut VecDeque, + offset: &SymExpr, + size: &SymExpr, + ) -> Result, SymbolicError> { + if offset.contains_gasleft() || size.contains_gasleft() { + return Err(SymbolicError::Unsupported("GAS/gasleft() not modeled")); + } + let return_data_len = state.return_data.len_word.clone(); + let offset_in_bounds = SymBoolExpr::cmp_word_expr( + &mut self.cx, + SymCmpOp::Ule, + offset, + return_data_len.clone(), + ); + let remaining = + SymExpr::binop(&mut self.cx, SymBinOp::Sub, return_data_len, offset.clone()); + let size_in_bounds = + SymBoolExpr::cmp_word_expr(&mut self.cx, SymCmpOp::Ule, size, remaining); + let valid_access = SymBoolExpr::and(&mut self.cx, vec![offset_in_bounds, size_in_bounds]); + self.apply_memory_access_guard(state, worklist, valid_access) + } + + pub(super) fn return_or_revert( + &mut self, + state: &mut PathState, + is_revert: bool, + ) -> Result { + let offset = state.stack.pop()?; + let size = state.stack.pop()?; + match state.constrained_usize_checked(&mut self.cx, &size) { + Some(Ok(size)) => { + state.return_data = state.memory.return_data(&mut self.cx, offset.clone(), size)?; + if is_revert { + Ok(self.classify_revert(state, offset, size)) + } else { + Ok(StepOutcome::Halt) + } + } + Some(Err(_)) => Ok(StepOutcome::Revert), + None => { + let max_limit = self.config.max_calldata_bytes as usize; + let reason = + if is_revert { "symbolic REVERT size" } else { "symbolic RETURN size" }; + let max_size = self.solver_upper_bound_usize(state, &size, max_limit, reason)?; + state.return_data = + state.memory.return_data_symbolic_size(&mut self.cx, offset, size, max_size)?; + Ok(if is_revert { StepOutcome::Revert } else { StepOutcome::Halt }) + } + } + } + + pub(super) fn classify_revert( + &mut self, + state: &PathState, + offset: SymExpr, + size: usize, + ) -> StepOutcome { + if state.call_depth == 0 + && let Some(offset) = offset.as_const() + && let Ok(offset) = usize::try_from(offset) + && let Ok(data) = state.memory.read_concrete(&mut self.cx, offset, size) + && is_assertion_revert(&data) + { + StepOutcome::Failure + } else { + StepOutcome::Revert + } + } +} + +/// Returns the activation fork for opcodes implemented by the symbolic executor. +const fn opcode_activation(op: u8) -> SpecId { + match op { + opcode::DELEGATECALL => SpecId::HOMESTEAD, + opcode::RETURNDATASIZE | opcode::RETURNDATACOPY | opcode::STATICCALL | opcode::REVERT => { + SpecId::BYZANTIUM + } + opcode::SHL | opcode::SHR | opcode::SAR | opcode::EXTCODEHASH | opcode::CREATE2 => { + SpecId::PETERSBURG + } + opcode::CHAINID | opcode::SELFBALANCE => SpecId::ISTANBUL, + opcode::BASEFEE => SpecId::LONDON, + opcode::PUSH0 => SpecId::SHANGHAI, + opcode::TLOAD | opcode::TSTORE | opcode::MCOPY | opcode::BLOBHASH | opcode::BLOBBASEFEE => { + SpecId::CANCUN + } + _ => SpecId::FRONTIER, + } +} diff --git a/crates/evm/symbolic/src/executor/run.rs b/crates/evm/symbolic/src/executor/run.rs new file mode 100644 index 0000000000000..2e12bbf1aa699 --- /dev/null +++ b/crates/evm/symbolic/src/executor/run.rs @@ -0,0 +1,832 @@ +use super::*; +use std::cmp::Reverse; + +impl SymbolicExecutor { + /// Creates a symbolic executor from Foundry's symbolic configuration. + /// + /// The configured solver command is not executed here. Solver availability is + /// checked by [`Self::run`] so construction remains cheap and side-effect free. + /// + /// The executor owns an isolated solver backend and symbolic world overlay. Create + /// a fresh executor when a caller needs independent solver query accounting. + pub fn new(config: SymbolicConfig) -> Self { + let solver = SmtLibSubprocessSolver::from_config(&config); + Self { + config, + cx: SymCx::new(), + solver, + deferred_incomplete: None, + deadline: None, + nested_deferred_mode: DeferredPathMode::Skip, + stateless_retry_safe: true, + } + } + + fn reset_run_state(&mut self, use_wall_clock_deadline: bool) { + self.deferred_incomplete = None; + self.nested_deferred_mode = DeferredPathMode::Skip; + self.stateless_retry_safe = true; + self.deadline = if use_wall_clock_deadline { + self.config + .timeout + .filter(|seconds| *seconds > 0) + .map(|seconds| Instant::now() + Duration::from_secs(seconds.into())) + } else { + None + }; + } + + pub(super) fn check_timeout(&self) -> Result<(), SymbolicError> { + if let Some(deadline) = self.deadline + && Instant::now() >= deadline + { + return Err(SymbolicError::Timeout(self.config.timeout.unwrap_or_default())); + } + Ok(()) + } + + /// Defers an incomplete result until all counterexample-producing modeled paths are explored. + pub(super) fn defer_incomplete(&mut self, reason: &'static str) { + if self + .deferred_incomplete + .is_none_or(|reason| reason == DeferredIncomplete::HardArithmetic) + { + self.deferred_incomplete = Some(DeferredIncomplete::Unsupported(reason)); + } + } + + /// Defers a solver-unknown result while continuing with decidable sibling paths. + pub(super) fn defer_solver_unknown(&mut self) { + if self + .deferred_incomplete + .is_none_or(|reason| reason == DeferredIncomplete::HardArithmetic) + { + self.deferred_incomplete = Some(DeferredIncomplete::SolverUnknown); + } + } + + /// Defers an incomplete result for a hard-arithmetic branch skipped by nested execution. + pub(super) fn defer_hard_arithmetic(&mut self) { + self.deferred_incomplete.get_or_insert(DeferredIncomplete::HardArithmetic); + } + + pub(super) fn is_sat_with_state( + &mut self, + state: &PathState, + constraints: &[SymBoolExpr], + ) -> Result { + let replayable_storage = state.world.replay_storage_symbols(); + self.solver.is_sat_with_replayable_storage(&mut self.cx, constraints, &replayable_storage) + } + + /// Checks branch feasibility, recording solver-unknown as an incomplete proof path. + pub(super) fn branch_is_sat_or_defer( + &mut self, + state: &PathState, + constraints: &[SymBoolExpr], + ) -> Result { + match self.is_sat_with_state(state, constraints) { + Ok(feasible) => Ok(feasible), + Err(SymbolicError::SolverUnknown) => { + self.defer_solver_unknown(); + Ok(false) + } + Err(err) => Err(err), + } + } + + /// Returns any deferred incomplete reason. + fn deferred_incomplete(&self) -> Option<(SymbolicStopReason, String)> { + match self.deferred_incomplete? { + DeferredIncomplete::Unsupported(reason) => Some(( + SymbolicStopReason::Stuck, + format!("unsupported symbolic execution feature: {reason}"), + )), + DeferredIncomplete::SolverUnknown => { + Some((SymbolicStopReason::Timeout, "solver returned unknown".to_string())) + } + DeferredIncomplete::HardArithmetic => Some(( + SymbolicStopReason::Timeout, + "nested hard arithmetic branch requires deferred SMT solving".to_string(), + )), + } + } + + /// Executes one function symbolically against an already-deployed test contract. + /// + /// The input executor supplies the deployed bytecode, storage backend, caller, and + /// target address established by the normal forge test setup flow. This method + /// does not mutate the concrete executor and does not replay failures itself; when + /// it returns [`SymbolicRunResult::Counterexample`], callers should replay the + /// returned arguments through the concrete executor before reporting the failure. + /// + /// Unsupported opcodes, unsupported ABI types, missing solver support, and resource + /// limit exhaustion are reported as [`SymbolicRunResult::Incomplete`]. + /// + /// Ordinary Solidity `require` reverts prune the current path. Assertion failures, + /// forge-std assertion reverts, and DSTest failure signals are reported as + /// counterexample candidates when the failing path is satisfiable. + pub fn run( + &mut self, + input: SymbolicRunInput<'_, FEN>, + ) -> SymbolicRunResult { + self.execute_run(input, None) + } + + /// Searches for concrete inputs that complete after reaching a requested branch outcome. + /// + /// Unlike [`Self::run`], this retains replay candidates independently of the proof result, so a + /// later unsupported path or resource limit does not discard an input found on an earlier + /// completed path. The caller must concretely replay every candidate before using it. + pub fn search_branch_target( + &mut self, + input: SymbolicRunInput<'_, FEN>, + ) -> SymbolicBranchTargetSearchResult { + let mut candidates = Vec::new(); + if input.branch_target.is_none() { + return SymbolicBranchTargetSearchResult { + candidates, + execution: SymbolicRunResult::Incomplete { + kind: SymbolicStopReason::Error, + reason: "branch target search requires a branch target".to_string(), + stats: SymbolicStats::default(), + }, + }; + } + + let execution = self.execute_run(input, Some(&mut candidates)); + if let SymbolicRunResult::Counterexample { args, calldata, .. } = &execution { + candidates.insert( + 0, + SymbolicConcreteInput { args: args.clone(), calldata: calldata.clone() }, + ); + } + SymbolicBranchTargetSearchResult { candidates, execution } + } + + fn execute_run( + &mut self, + input: SymbolicRunInput<'_, FEN>, + mut branch_candidates: Option<&mut Vec>, + ) -> SymbolicRunResult { + self.reset_run_state(false); + self.solver.clear_context_caches(); + self.cx = SymCx::new(); + if let Err(err) = self.solver.check_available() { + return SymbolicRunResult::Incomplete { + kind: err.stop_reason(), + reason: err.to_string(), + stats: SymbolicStats::default(), + }; + } + + let mut completed_paths = 0; + let first = + match self.run_inner(&input, branch_candidates.as_deref_mut(), &mut completed_paths) { + Ok(result) => result, + Err(err) => { + return SymbolicRunResult::Incomplete { + kind: err.stop_reason(), + reason: err.to_string(), + stats: self.stats_with_paths(completed_paths), + }; + } + }; + + // Preserve the cheap-path priority of the first pass. Only a deterministic stateless + // proof retries after nested hard arithmetic was its remaining limitation. + let retry_nested_deferred = branch_candidates.is_none() + && self.stateless_retry_safe + && matches!(self.deferred_incomplete, Some(DeferredIncomplete::HardArithmetic)) + && matches!( + &first, + SymbolicRunResult::Incomplete { + kind: SymbolicStopReason::RevertAll | SymbolicStopReason::Timeout, + .. + } + ); + if !retry_nested_deferred { + return first; + } + + if let Err(err) = self.check_timeout() { + return SymbolicRunResult::Incomplete { + kind: err.stop_reason(), + reason: err.to_string(), + stats: self.stats_with_paths(completed_paths), + }; + } + + self.deferred_incomplete = None; + self.nested_deferred_mode = DeferredPathMode::Yield; + let prioritized = match self.run_inner(&input, None, &mut completed_paths) { + Ok(result) => result, + Err(err) => { + return SymbolicRunResult::Incomplete { + kind: err.stop_reason(), + reason: err.to_string(), + stats: self.stats_with_paths(completed_paths), + }; + } + }; + let drain_nested_deferred = self.stateless_retry_safe + && matches!(self.deferred_incomplete, Some(DeferredIncomplete::HardArithmetic)) + && matches!( + &prioritized, + SymbolicRunResult::Incomplete { + kind: SymbolicStopReason::RevertAll | SymbolicStopReason::Timeout, + .. + } + ); + if !drain_nested_deferred { + return prioritized; + } + + if let Err(err) = self.check_timeout() { + return SymbolicRunResult::Incomplete { + kind: err.stop_reason(), + reason: err.to_string(), + stats: self.stats_with_paths(completed_paths), + }; + } + + self.deferred_incomplete = None; + self.nested_deferred_mode = DeferredPathMode::Drain; + match self.run_inner(&input, None, &mut completed_paths) { + Ok(result) => result, + Err(err) => SymbolicRunResult::Incomplete { + kind: err.stop_reason(), + reason: err.to_string(), + stats: self.stats_with_paths(completed_paths), + }, + } + } + + /// Returns corpus seed indexes that can be modeled by at least one symbolic calldata variant. + pub fn modeled_corpus_seed_indexes( + config: &SymbolicConfig, + function: &Function, + corpus_seeds: &[SymbolicConcreteInput], + ) -> Result, SymbolicError> { + let mut cx = SymCx::new(); + let variants = SymbolicCalldata::variants(function, config, &mut cx)?; + let mut modeled = vec![false; corpus_seeds.len()]; + for calldata in &variants { + for (idx, seed) in corpus_seeds.iter().enumerate() { + if !modeled[idx] && calldata.seed_model(&mut cx, seed).is_some() { + modeled[idx] = true; + } + } + } + Ok(modeled + .into_iter() + .enumerate() + .filter_map(|(idx, modeled)| modeled.then_some(idx)) + .collect()) + } + + /// Executes a bounded symbolic invariant call sequence. + /// + /// Each sequence step chooses from the concrete target functions and senders supplied by + /// Foundry's invariant target discovery. Arguments are generated through the same symbolic ABI + /// model used by stateless symbolic tests, and the symbolic world state is preserved between + /// steps. Returned counterexamples must still be replayed by the caller before reporting. + /// + /// The configured invariant depth limits the number of target calls explored before the + /// invariant is checked. A depth of zero checks only the invariant against setup state. + pub fn run_invariant( + &mut self, + input: SymbolicInvariantRunInput<'_, FEN>, + ) -> SymbolicInvariantRunResult { + self.reset_run_state(true); + self.solver.clear_context_caches(); + self.cx = SymCx::new(); + if let Err(err) = self.solver.check_available() { + return SymbolicInvariantRunResult::Incomplete { + kind: err.stop_reason(), + reason: err.to_string(), + stats: SymbolicStats::default(), + }; + } + + match self.run_invariant_inner(input) { + Ok(result) => result, + Err(err) => SymbolicInvariantRunResult::Incomplete { + kind: err.stop_reason(), + reason: err.to_string(), + stats: self.solver.stats(), + }, + } + } + + /// Searches for invariant-breaking inputs after one symbolic handler call. + /// + /// This is a best-effort candidate search from a concrete state. Returned candidates are + /// unconfirmed until the caller replays them concretely, and an empty result does not prove + /// any invariant. + pub fn search_invariant_candidates( + &mut self, + input: SymbolicInvariantCandidateInput<'_, FEN>, + ) -> SymbolicInvariantCandidateSearchResult { + self.reset_run_state(true); + self.solver.clear_context_caches(); + self.cx = SymCx::new(); + if let Err(error) = self.solver.check_available() { + return SymbolicInvariantCandidateSearchResult { + candidates: Vec::new(), + limitation: Some(error.into()), + }; + } + + let mut candidates = Vec::new(); + let mut limitation = None; + if let Err(error) = + self.search_invariant_candidates_inner(&input, &mut candidates, &mut limitation) + { + limitation = Some(error.into()); + } + // Deferred hard-arithmetic branches are now sent to SMT before candidate search finishes. + // Only branches that nested execution could not escalate remain incomplete. + if limitation.is_none() + && let Some((kind, reason)) = self.deferred_incomplete() + { + limitation = Some(SymbolicInvariantSearchLimitation { kind, reason }); + } + + SymbolicInvariantCandidateSearchResult { candidates, limitation } + } + + pub(super) fn run_inner( + &mut self, + input: &SymbolicRunInput<'_, FEN>, + mut branch_candidates: Option<&mut Vec>, + completed_paths: &mut usize, + ) -> Result { + let account = input + .executor + .backend() + .basic_ref(input.target) + .map_err(|err| SymbolicError::Backend(err.to_string()))? + .ok_or(SymbolicError::MissingAccount(input.target))?; + let bytecode = account.code.ok_or(SymbolicError::MissingCode(input.target))?; + let code = SymCode::from_bytecode(&mut self.cx, &bytecode); + let mut roots = Vec::new(); + for calldata in SymbolicCalldata::variants(input.function, &self.config, &mut self.cx)? { + let corpus_seed_models = input + .corpus_seeds + .iter() + .filter_map(|seed| calldata.seed_model(&mut self.cx, seed).map(Arc::new)) + .collect(); + let mut root = PathState::new( + &mut self.cx, + input.target, + input.sender, + input.value, + calldata, + input.ffi_enabled, + ); + root.set_corpus_seed_models(corpus_seed_models); + root.set_branch_target(input.branch_target); + root.apply_executor_env(&mut self.cx, input.executor); + root.world.set_storage_layout(self.config.storage_layout); + root.world.clear_transaction_scoped_state(); + roots.push(root); + } + order_roots_by_corpus_seed_count(&mut roots, self.config.exploration_order); + let mut worklist = roots.into_iter().collect::>(); + let mut deferred_worklist = VecDeque::new(); + let mut reverted_paths = 0usize; + let mut normal_paths = 0usize; + let mut success_input = None; + let path_limit = self.config.path_width() as usize; + let depth_limit = self.config.execution_depth() as usize; + + while let Some(next) = self.pop_next_feasible_path( + &mut worklist, + &mut deferred_worklist, + DeferredPathMode::Drain, + )? { + let mut state = next.state; + if *completed_paths >= path_limit { + debug!( + completed_paths = *completed_paths, + path_limit, "symbolic path limit reached" + ); + return Ok(SymbolicRunResult::Incomplete { + kind: SymbolicStopReason::Stuck, + reason: format!("symbolic path limit exceeded ({path_limit})"), + stats: self.stats_with_paths(*completed_paths), + }); + } + if std::mem::take(&mut state.pending_storage_hook_revert) { + self.collect_branch_candidate( + branch_candidates.as_deref_mut(), + input.function, + &state, + )?; + *completed_paths += 1; + reverted_paths += 1; + continue; + } + let _path_span = trace_span!( + "symbolic_path", + completed_paths = *completed_paths, + worklist_size = worklist.len() + ) + .entered(); + trace!( + completed_paths = *completed_paths, + worklist_size = worklist.len(), + "exploring symbolic path" + ); + + loop { + self.check_timeout()?; + if state.depth >= depth_limit { + debug!(depth = state.depth, depth_limit, "symbolic depth limit reached"); + return Ok(SymbolicRunResult::Incomplete { + kind: SymbolicStopReason::Stuck, + reason: format!("symbolic depth limit exceeded ({depth_limit})"), + stats: self.stats_with_paths(*completed_paths), + }); + } + state.depth += 1; + + let outcome = match code.opcode(&mut self.cx, state.pc)? { + Some(op) => { + let _step_span = trace_span!("symbolic_step", pc = state.pc, op).entered(); + self.step( + input.executor, + &code, + code.jump_table(), + &mut state, + &mut worklist, + completed_paths, + op, + )? + } + None => StepOutcome::Halt, + }; + match outcome { + StepOutcome::Continue => {} + StepOutcome::AssumeRejected | StepOutcome::Forked => break, + StepOutcome::Revert => { + self.collect_branch_candidate( + branch_candidates.as_deref_mut(), + input.function, + &state, + )?; + *completed_paths += 1; + reverted_paths += 1; + break; + } + StepOutcome::Halt if state.expectations_satisfied() => { + let candidate = self.collect_branch_candidate( + branch_candidates.as_deref_mut(), + input.function, + &state, + )?; + if input.collect_success_input + && state.satisfies_branch_target() + && state.can_materialize_seed() + && success_input.as_ref().is_none_or(|(depth, _)| state.depth > *depth) + { + let input = match candidate { + Some(input) => input, + None => self.materialize_root_input(input.function, &state)?, + }; + success_input = Some((state.depth, input)); + } + *completed_paths += 1; + normal_paths += 1; + break; + } + StepOutcome::Halt | StepOutcome::ExceptionalHalt | StepOutcome::Failure => { + if !state.satisfies_branch_target() { + *completed_paths += 1; + break; + } + debug!( + constraint_count = state.constraints.len(), + "materializing counterexample from solver model" + ); + let SymbolicConcreteInput { args, calldata } = + self.materialize_root_input(input.function, &state)?; + return Ok(SymbolicRunResult::Counterexample { + args, + calldata, + stats: self.stats_with_paths(*completed_paths + 1), + }); + } + } + } + } + + if normal_paths == 0 && reverted_paths > 0 { + debug!(completed_paths = *completed_paths, "all symbolic paths reverted"); + return Ok(SymbolicRunResult::Incomplete { + kind: SymbolicStopReason::RevertAll, + reason: "all symbolic paths reverted".to_string(), + stats: self.stats_with_paths(*completed_paths), + }); + } + + if let Some((kind, reason)) = self.deferred_incomplete() { + return Ok(SymbolicRunResult::Incomplete { + kind, + reason, + stats: self.stats_with_paths(*completed_paths), + }); + } + + if normal_paths == 0 { + return Ok(SymbolicRunResult::Incomplete { + kind: SymbolicStopReason::Stuck, + reason: "no successful symbolic paths".to_string(), + stats: self.stats_with_paths(*completed_paths), + }); + } + + debug!(completed_paths = *completed_paths, "symbolic execution safe"); + Ok(SymbolicRunResult::Safe { + stats: self.stats_with_paths(*completed_paths), + success_input: success_input.map(|(_, input)| input), + }) + } + + fn collect_branch_candidate( + &mut self, + candidates: Option<&mut Vec>, + function: &Function, + state: &PathState, + ) -> Result, SymbolicError> { + let Some(candidates) = candidates else { + return Ok(None); + }; + if !state.satisfies_branch_target() || !state.can_materialize_seed() { + return Ok(None); + } + + let input = self.materialize_root_input(function, state)?; + candidates.push(input.clone()); + Ok(Some(input)) + } + + /// Materializes the root call input for `state` from a solver model. + fn materialize_root_input( + &mut self, + function: &Function, + state: &PathState, + ) -> Result { + let calldata = state + .root_calldata + .as_ref() + .ok_or(SymbolicError::Unsupported("missing root symbolic calldata"))?; + let replayable_storage = state.world.replay_storage_symbols(); + let model = self.solver.model_with_replayable_storage( + &mut self.cx, + &state.constraints, + &replayable_storage, + )?; + let args = calldata.model_to_args(&mut self.cx, &model)?; + let calldata_bytes = Bytes::from(function.abi_encode_input(&args)?); + Ok(SymbolicConcreteInput { args, calldata: calldata_bytes }) + } + + pub(super) fn run_invariant_inner( + &mut self, + input: SymbolicInvariantRunInput<'_, FEN>, + ) -> Result { + if input.targets.is_empty() { + return Err(SymbolicError::Unsupported("symbolic invariant has no targets")); + } + + let mut senders = + if input.senders.is_empty() { vec![input.sender] } else { input.senders.clone() }; + senders.retain(|sender| !input.excluded_senders.contains(sender)); + if senders.is_empty() { + return Err(SymbolicError::Unsupported("symbolic invariant senders are excluded")); + } + let after_invariant_for = |steps_len: usize| { + (steps_len == input.depth).then_some(input.after_invariant).flatten() + }; + let mut completed_paths = 0usize; + let mut initial_state = PathState::empty( + &mut self.cx, + input.invariant_address, + input.sender, + input.ffi_enabled, + ); + initial_state.apply_executor_env(&mut self.cx, input.executor); + initial_state.world.set_storage_layout(self.config.storage_layout); + let initial = SequencePath { state: initial_state, steps: Vec::new() }; + + if symbolic_invariant_should_check(0, input.depth, input.check_interval) { + for outcome in self.execute_invariant_check( + input.executor, + initial.state.clone(), + input.invariant_address, + input.sender, + input.invariant, + after_invariant_for(0), + &mut completed_paths, + )? { + if outcome.failed { + let (sequence, storage) = + self.materialize_sequence(&initial.steps, &outcome.state)?; + return Ok(SymbolicInvariantRunResult::Counterexample { + kind: SymbolicInvariantCounterexampleKind::Predicate, + sequence, + storage, + stats: self.stats_with_paths(completed_paths), + }); + } + } + } + + let path_limit = self.config.path_width() as usize; + let mut frontier = vec![initial]; + for depth in 0..input.depth { + self.check_timeout()?; + let mut next_frontier = Vec::new(); + for sequence in frontier { + self.check_timeout()?; + for (target_idx, target) in input.targets.iter().enumerate() { + for (sender_idx, sender) in senders.iter().copied().enumerate() { + self.check_timeout()?; + let prefix = format!("sequence_{depth}_{target_idx}_{sender_idx}"); + let calldatas = SymbolicCalldata::variants_with_prefix( + &target.function, + &self.config, + &mut self.cx, + &prefix, + )?; + for calldata in calldatas { + let step = SequenceStepTemplate { + sender, + address: target.address, + contract_name: target.contract_name.clone(), + function: target.function.clone(), + calldata, + }; + let calldata = step.calldata.call_data(&mut self.cx); + let constraints = step.calldata.constraints().to_vec(); + let mut call = self.prepare_sequence_call( + input.executor, + sequence.state.clone(), + target.address, + sender, + &target.function, + calldata, + constraints, + )?; + + while let Some(outcome) = self.execute_sequence_call_next( + input.executor, + &mut call, + &mut completed_paths, + )? { + let mut steps = sequence.steps.clone(); + steps.push(step.clone()); + + let post_state = match outcome.status { + CallStatus::Failure => { + let (sequence, storage) = + self.materialize_sequence(&steps, &outcome.state)?; + return Ok(SymbolicInvariantRunResult::Counterexample { + kind: SymbolicInvariantCounterexampleKind::Handler, + sequence, + storage, + stats: self.stats_with_paths(completed_paths), + }); + } + CallStatus::Revert | CallStatus::ExceptionalHalt => { + if input.fail_on_revert { + let (sequence, storage) = + self.materialize_sequence(&steps, &outcome.state)?; + return Ok( + SymbolicInvariantRunResult::Counterexample { + kind: SymbolicInvariantCounterexampleKind::Predicate, + sequence, + storage, + stats: self.stats_with_paths(completed_paths), + }, + ); + } + // A reverted top-level call cannot change persistent + // state, but it still consumes one invariant sequence + // step. Preserve the pre-call world together with the + // reverted branch constraints so end-only and periodic + // invariant checks observe the same call schedule as the + // concrete campaign. + let mut reverted_state = sequence.state.clone(); + reverted_state + .take_reverted_top_level_effects(outcome.state); + reverted_state + } + CallStatus::Success => outcome.state, + }; + if symbolic_invariant_should_check( + steps.len(), + input.depth, + input.check_interval, + ) { + for mut invariant_outcome in self.execute_invariant_check( + input.executor, + post_state.clone(), + input.invariant_address, + input.sender, + input.invariant, + after_invariant_for(steps.len()), + &mut completed_paths, + )? { + if invariant_outcome.failed { + let (sequence, storage) = self.materialize_sequence( + &steps, + &invariant_outcome.state, + )?; + return Ok(SymbolicInvariantRunResult::Counterexample { + kind: SymbolicInvariantCounterexampleKind::Predicate, + sequence, + storage, + stats: self.stats_with_paths(completed_paths), + }); + } + let mut state = post_state.clone(); + state.take_noncommitting_check_state( + &mut invariant_outcome.state, + ); + next_frontier + .push(SequencePath { state, steps: steps.clone() }); + } + } else { + next_frontier.push(SequencePath { state: post_state, steps }); + } + + if completed_paths >= path_limit { + return Ok(SymbolicInvariantRunResult::Incomplete { + kind: SymbolicStopReason::Stuck, + reason: format!( + "symbolic path limit exceeded ({path_limit})" + ), + stats: self.stats_with_paths(completed_paths), + }); + } + } + } + } + } + } + + if next_frontier.is_empty() { + break; + } + frontier = next_frontier; + } + + if let Some((kind, reason)) = self.deferred_incomplete() { + return Ok(SymbolicInvariantRunResult::Incomplete { + kind, + reason, + stats: self.stats_with_paths(completed_paths), + }); + } + + Ok(SymbolicInvariantRunResult::Safe(self.stats_with_paths(completed_paths))) + } + + pub(super) fn stats_with_paths(&self, paths: usize) -> SymbolicStats { + let mut stats = self.solver.stats(); + stats.paths = paths; + stats + } +} + +fn order_roots_by_corpus_seed_count(roots: &mut [PathState], order: SymbolicExplorationOrder) { + let Some((first, rest)) = roots.split_first() else { + return; + }; + if rest.iter().all(|root| root.corpus_seed_model_count() == first.corpus_seed_model_count()) { + return; + } + + match order { + SymbolicExplorationOrder::Bfs => { + roots.sort_by_key(|root| Reverse(root.corpus_seed_model_count())); + } + SymbolicExplorationOrder::Dfs => { + roots.sort_by_key(PathState::corpus_seed_model_count); + } + } +} + +const fn symbolic_invariant_should_check( + sequence_len: usize, + depth: usize, + check_interval: u32, +) -> bool { + sequence_len == depth + || (check_interval != 0 + && sequence_len != 0 + && sequence_len.is_multiple_of(check_interval as usize)) +} diff --git a/crates/evm/symbolic/src/lib.rs b/crates/evm/symbolic/src/lib.rs new file mode 100644 index 0000000000000..dbda4c6b37945 --- /dev/null +++ b/crates/evm/symbolic/src/lib.rs @@ -0,0 +1,425 @@ +//! Foundry's symbolic EVM executor. + +#![warn(unused_crate_dependencies)] + +use alloy_dyn_abi::{DynSolType, DynSolValue, JsonAbiExt}; +use alloy_json_abi::Function; +use alloy_primitives::{ + Address, B256, Bytes, I256, Keccak256, U256, hex, keccak256, + map::{HashMap, HashSet, IndexSet}, +}; +use alloy_signer::SignerSync; +use alloy_signer_local::{ + PrivateKeySigner, + coins_bip39::{English, Wordlist}, +}; +use alloy_sol_types::SolCall; +use base64::prelude::*; +use foundry_cheatcodes_spec::{SymbolicVm, Vm}; +use foundry_config::{SymbolicConfig, SymbolicExplorationOrder, SymbolicStorageLayout}; +use foundry_evm::{ + constants::{CALLER, CHEATCODE_ADDRESS, DEFAULT_CREATE2_DEPLOYER, HARDHAT_CONSOLE_ADDRESS}, + core::{backend::DatabaseExt, evm::FoundryEvmNetwork}, + executors::Executor, + revm::{ + bytecode::{Bytecode, JumpTable, opcode}, + context::{Block, Cfg, Transaction}, + database::DatabaseRef, + precompile::{blake2, bn254, hash, identity, kzg_point_evaluation, modexp, secp256k1}, + primitives::hardfork::SpecId, + }, +}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::VecDeque, + fmt::{self, Write as _}, + io::Write, + ops::{ControlFlow, Deref, DerefMut}, + path::{Path, PathBuf}, + process::{Command, Stdio}, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + mpsc, + }, + thread, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; +use thiserror::Error; +use tracing::{debug, trace, trace_span, warn}; + +mod abi; +mod consts; +mod executor; +mod runtime; + +pub(crate) use consts::*; +pub use runtime::{SymbolicBranchTarget, SymbolicError, SymbolicRunInput}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SymbolicVmCheatcode { + CreateAddress, + CreateBool, + CreateBytes, + CreateBytesSized, + CreateBytesFixed(usize), + CreateCalldata, + CreateInt, + CreateIntBits(usize), + CreateString, + CreateStringSized, + CreateUint, + CreateUintBits(usize), + EnableSymbolicStorage, + SnapshotStorage, + SnapshotState, +} + +impl SymbolicVmCheatcode { + fn from_selector(selector: [u8; 4]) -> Option { + match selector { + SymbolicVm::createAddressCall::SELECTOR => Some(Self::CreateAddress), + SymbolicVm::createBoolCall::SELECTOR => Some(Self::CreateBool), + SymbolicVm::createBytes_0Call::SELECTOR => Some(Self::CreateBytes), + SymbolicVm::createBytes_1Call::SELECTOR => Some(Self::CreateBytesSized), + SymbolicVm::createCalldataCall::SELECTOR => Some(Self::CreateCalldata), + SymbolicVm::createIntCall::SELECTOR => Some(Self::CreateInt), + SymbolicVm::createString_0Call::SELECTOR => Some(Self::CreateString), + SymbolicVm::createString_1Call::SELECTOR => Some(Self::CreateStringSized), + SymbolicVm::createUintCall::SELECTOR => Some(Self::CreateUint), + SymbolicVm::enableSymbolicStorageCall::SELECTOR + | Vm::setArbitraryStorage_0Call::SELECTOR => Some(Self::EnableSymbolicStorage), + SymbolicVm::snapshotStorageCall::SELECTOR => Some(Self::SnapshotStorage), + Vm::snapshotStateCall::SELECTOR => Some(Self::SnapshotState), + _ => { + let name = SymbolicVm::SymbolicVmCalls::name_by_selector(selector)?; + if let Some(bits) = name.strip_prefix("createUint") { + bits.parse().ok().map(Self::CreateUintBits) + } else if let Some(bits) = name.strip_prefix("createInt") { + bits.parse().ok().map(Self::CreateIntBits) + } else if let Some(bytes) = name.strip_prefix("createBytes") { + bytes.parse().ok().map(Self::CreateBytesFixed) + } else { + None + } + } + } + } + + const fn min_input_words(self) -> usize { + match self { + Self::CreateUint + | Self::CreateInt + | Self::CreateBytesSized + | Self::CreateStringSized + | Self::EnableSymbolicStorage + | Self::SnapshotStorage => 1, + Self::CreateAddress + | Self::CreateBool + | Self::CreateBytes + | Self::CreateBytesFixed(_) + | Self::CreateCalldata + | Self::CreateIntBits(_) + | Self::CreateString + | Self::CreateUintBits(_) + | Self::SnapshotState => 0, + } + } +} + +/// Outcome of a symbolic test execution. +/// +/// The forge runner treats `Safe` as a passing symbolic test, `Counterexample` as a +/// candidate failure that must be replayed concretely, and `Incomplete` as a failing +/// test because the symbolic engine could not prove the property with the supported +/// semantics and configured resource limits. +#[derive(Clone, Debug)] +pub enum SymbolicRunResult { + /// All explored paths completed without a feasible failure. + Safe { + /// Execution counters collected during the run. + stats: SymbolicStats, + /// One concrete successful input, when requested by the caller. + success_input: Option, + }, + /// A feasible failure was found. + Counterexample { + /// ABI-typed argument values extracted from the solver model. + args: Vec, + /// ABI-encoded calldata for the failing invocation. + calldata: Bytes, + /// Execution counters collected before the counterexample was returned. + stats: SymbolicStats, + }, + /// Execution was intentionally stopped because V1 semantics were insufficient. + Incomplete { + /// Category describing why symbolic execution stopped before proving the test. + kind: SymbolicStopReason, + /// Human-readable explanation of the unsupported construct or exhausted limit. + reason: String, + /// Execution counters collected before execution stopped. + stats: SymbolicStats, + }, +} + +/// One concrete symbolic input materialized from a solver model. +#[derive(Clone, Debug)] +pub struct SymbolicConcreteInput { + /// ABI-typed argument values extracted from the solver model. + pub args: Vec, + /// ABI-encoded calldata for replay. + pub calldata: Bytes, +} + +/// Result of best-effort symbolic exploration toward one branch target. +#[derive(Debug)] +pub struct SymbolicBranchTargetSearchResult { + /// Concrete inputs whose completed root path reached the requested branch outcome. + pub candidates: Vec, + /// Underlying execution result, retained so callers can report incomplete exploration. + pub execution: SymbolicRunResult, +} + +/// A concrete invariant target selected from Foundry's invariant discovery. +#[derive(Clone, Debug)] +pub struct SymbolicInvariantTarget { + /// Address that receives the sequence call. + pub address: Address, + /// Human-readable contract identifier used in counterexample rendering. + pub contract_name: Option, + /// ABI function invoked with symbolic arguments. + pub function: Function, +} + +/// Input for best-effort invariant candidate search after one symbolic handler call. +pub struct SymbolicInvariantCandidateInput<'a, FEN: FoundryEvmNetwork> { + /// Concrete Foundry executor containing the replayed invariant frontier prefix. + pub executor: &'a Executor, + /// Address of the deployed invariant test contract. + pub invariant_address: Address, + /// Invariant functions checked independently after the handler call. + pub invariants: &'a [&'a Function], + /// Optional campaign hook checked from the unchanged post-handler state. + pub after_invariant: Option<&'a Function>, + /// Concrete handler target selected from the captured frontier. + pub target: &'a SymbolicInvariantTarget, + /// Sender of the captured handler call. + pub handler_sender: Address, + /// Whether symbolic `vm.ffi` calls are allowed to execute subprocesses. + pub ffi_enabled: bool, +} + +/// One unconfirmed symbolic input produced by invariant candidate search. +#[derive(Clone, Debug)] +pub struct SymbolicInvariantCandidate { + /// Index within [`SymbolicInvariantCandidateInput::invariants`] predicted to fail. + pub invariant_idx: usize, + /// Concrete handler call extracted from the solver model. + pub step: SymbolicInvariantStep, + /// Concrete setup-storage values needed to replay the candidate. + pub storage: Vec, +} + +/// An execution or solver limitation encountered during best-effort candidate search. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SymbolicInvariantSearchLimitation { + /// Category describing why part of the search could not complete. + pub kind: SymbolicStopReason, + /// Human-readable description of the limitation. + pub reason: String, +} + +impl From for SymbolicInvariantSearchLimitation { + fn from(error: SymbolicError) -> Self { + Self { kind: error.stop_reason(), reason: error.to_string() } + } +} + +/// Result of best-effort invariant candidate search after one symbolic handler call. +#[derive(Clone, Debug)] +pub struct SymbolicInvariantCandidateSearchResult { + /// Unconfirmed candidates that must be replayed concretely by the caller. + pub candidates: Vec, + /// First encountered search limitation, unless a later error exhausts the search. + /// `None` is not a proof of safety. + pub limitation: Option, +} + +/// Input for bounded symbolic invariant execution. +pub struct SymbolicInvariantRunInput<'a, FEN: FoundryEvmNetwork> { + /// Concrete Foundry executor used as the source of deployed bytecode and backend state. + pub executor: &'a Executor, + /// Address of the deployed invariant test contract. + pub invariant_address: Address, + /// Default sender used when invariant targeting does not configure senders. + pub sender: Address, + /// Invariant function checked after each symbolic sequence step. + pub invariant: &'a Function, + /// Optional `afterInvariant` hook to execute after a passing invariant check. + pub after_invariant: Option<&'a Function>, + /// Concrete target/selector set discovered by Foundry invariant targeting. + pub targets: Vec, + /// Concrete sender set discovered by Foundry invariant targeting. + pub senders: Vec
, + /// Sender addresses excluded by Foundry invariant targeting. + pub excluded_senders: Vec
, + /// Maximum number of sequence calls to execute. + pub depth: usize, + /// Concrete invariant check interval. `0` means only check at sequence end. + pub check_interval: u32, + /// Whether ordinary target-call reverts should be reported as failures. + pub fail_on_revert: bool, + /// Whether symbolic `vm.ffi` calls are allowed to execute subprocesses. + pub ffi_enabled: bool, +} + +/// One concrete storage value required to replay a symbolic invariant candidate. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SymbolicStorageAssignment { + /// Account whose storage slot should be initialized. + pub address: Address, + /// Concrete storage slot. + pub slot: U256, + /// Concrete value extracted from the solver model. + pub value: U256, +} + +/// Outcome of bounded symbolic invariant execution. +#[derive(Clone, Debug)] +pub enum SymbolicInvariantRunResult { + /// No feasible invariant failure was found within the configured sequence depth. + Safe(SymbolicStats), + /// A feasible invariant or handler failure was found. + Counterexample { + /// Which part of the invariant run produced the failure. + kind: SymbolicInvariantCounterexampleKind, + /// Concrete sequence extracted from the solver model. + sequence: Vec, + /// Concrete setup-storage values needed for replay. + storage: Vec, + /// Execution counters collected before the counterexample was returned. + stats: SymbolicStats, + }, + /// Execution stopped before proving the invariant. + Incomplete { + /// Category describing why symbolic execution stopped. + kind: SymbolicStopReason, + /// Human-readable explanation of the unsupported construct or exhausted limit. + reason: String, + /// Execution counters collected before execution stopped. + stats: SymbolicStats, + }, +} + +/// Part of a symbolic invariant run that produced a replayable counterexample. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SymbolicInvariantCounterexampleKind { + /// An `invariant_*` or `afterInvariant` check failed. + Predicate, + /// A fuzzed target/handler call failed with an assertion. + Handler, +} + +/// One concrete step in a symbolic invariant counterexample sequence. +#[derive(Clone, Debug)] +pub struct SymbolicInvariantStep { + /// Sender used for the call. + pub sender: Address, + /// Target address called by the sequence step. + pub address: Address, + /// Human-readable contract identifier, when known. + pub contract_name: Option, + /// ABI function name. + pub function_name: String, + /// ABI function signature. + pub signature: String, + /// ABI-typed arguments extracted from the solver model. + pub args: Vec, + /// ABI-encoded calldata for replay. + pub calldata: Bytes, +} + +/// High-level reason a symbolic run stopped without a proof or replayed counterexample. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum SymbolicStopReason { + /// The executor reached a supported-but-incomplete semantic boundary. + Stuck, + /// Every explored execution path ended in an ordinary revert. + RevertAll, + /// The solver timed out or returned `unknown`. + Timeout, + /// An internal engine, backend, or solver process error occurred. + Error, +} + +/// Symbolic execution counters. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct SymbolicStats { + /// Number of explored symbolic paths. + pub paths: usize, + /// Number of normalized solver queries issued during the run. + pub solver_queries: usize, + /// Number of queries sent to the SMT backend after local fast paths. + #[serde(default)] + pub smt_queries: usize, + /// Number of satisfiability checks requested by the executor. + #[serde(default)] + pub sat_queries: usize, + /// Number of concrete model requests requested by the executor. + #[serde(default)] + pub model_queries: usize, + /// Number of satisfiability checks served from the normalized cache. + #[serde(default)] + pub sat_cache_hits: usize, + /// Number of model requests served from the normalized model cache. + #[serde(default)] + pub model_cache_hits: usize, + /// Number of satisfiable witnesses produced by local hard-arithmetic search. + #[serde(default)] + pub heuristic_witnesses: usize, + /// Wall-clock time spent waiting on backend solver subprocesses, in milliseconds. + #[serde(default)] + pub solver_time_ms: u64, + /// Total SMT-LIB input bytes sent to backend solver subprocesses. + #[serde(default)] + pub smt_input_bytes: u64, + /// Largest single SMT-LIB query input sent to a backend solver subprocess, in bytes. + #[serde(default)] + pub smt_max_query_bytes: u64, + /// Wall-clock time spent building SMT-LIB query strings, in milliseconds. + #[serde(default)] + pub smt_build_time_ms: u64, + /// Longest single backend solver subprocess query, in milliseconds. + #[serde(default)] + pub smt_max_query_time_ms: u64, +} + +/// SMT-LIB-backed symbolic executor. +/// +/// This executor is intentionally separate from the concrete revm executor used by +/// Foundry. It consumes bytecode and state from an existing [`Executor`], explores +/// symbolic branches, and returns either a proof result, a counterexample candidate, +/// or an incomplete result. +pub struct SymbolicExecutor { + config: SymbolicConfig, + cx: runtime::SymCx, + solver: runtime::SmtLibSubprocessSolver, + deferred_incomplete: Option, + deadline: Option, + nested_deferred_mode: DeferredPathMode, + stateless_retry_safe: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DeferredPathMode { + Skip, + Yield, + Drain, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DeferredIncomplete { + Unsupported(&'static str), + SolverUnknown, + HardArithmetic, +} diff --git a/crates/evm/symbolic/src/runtime.rs b/crates/evm/symbolic/src/runtime.rs new file mode 100644 index 0000000000000..3d5390412c631 --- /dev/null +++ b/crates/evm/symbolic/src/runtime.rs @@ -0,0 +1,168 @@ +use super::{abi::*, *}; + +mod address; +mod bytes; +mod calldata; +mod cheatcodes; +mod control; +mod evm; +mod expr; +mod memory; +mod precompiles; +mod solver; +mod state; +mod symbols; + +pub(crate) use address::*; +pub(crate) use bytes::*; +pub(crate) use calldata::*; +pub(crate) use cheatcodes::*; +pub(crate) use control::*; +pub(crate) use evm::*; +pub(crate) use expr::*; +pub(crate) use memory::*; +pub(crate) use precompiles::*; +pub(crate) use solver::{BranchFeasibility, SmtLibSubprocessSolver}; +pub(crate) use state::*; +pub(crate) use symbols::*; + +/// One comparison site from a fuzz branch frontier to target during symbolic execution. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SymbolicBranchTarget { + /// Contract address where the comparison executed. + address: Address, + /// Program counter of the comparison opcode. + pc: usize, + /// Comparison opcode. + opcode: u8, + /// Concrete result observed by fuzzing. Symbolic execution targets the opposite result. + result: bool, +} + +impl SymbolicBranchTarget { + pub const fn new(address: Address, pc: usize, opcode: u8, result: bool) -> Self { + Self { address, pc, opcode, result } + } + + pub(crate) const fn result(self) -> bool { + self.result + } + + pub(crate) fn matches(self, address: Address, pc: usize, opcode: u8) -> bool { + self.address == address && self.pc == pc && self.opcode == opcode + } +} + +pub struct SymbolicRunInput<'a, FEN: FoundryEvmNetwork> { + /// Concrete Foundry executor used as the source of deployed bytecode and backend state. + pub executor: &'a Executor, + /// Address of the deployed test contract whose runtime bytecode will be explored. + pub target: Address, + /// Sender used for symbolic execution environment opcodes such as `CALLER` and `ORIGIN`. + pub sender: Address, + /// ABI function to invoke symbolically. + pub function: &'a Function, + /// Call value exposed to the symbolic execution through `CALLVALUE`. + pub value: U256, + /// Whether symbolic `vm.ffi` calls are allowed to execute subprocesses. + pub ffi_enabled: bool, + /// Whether to return one successful concrete input when execution is safe. + pub collect_success_input: bool, + /// Concrete fuzz corpus entries used as path-priority hints. + pub corpus_seeds: Vec, + /// Optional comparison site whose opposite branch should be solved. + pub branch_target: Option, +} + +/// Error returned by the internal symbolic executor. +/// +/// Public callers normally receive these errors as [`SymbolicRunResult::Incomplete`] +/// through [`SymbolicExecutor::run`]. The enum is public so integration code and tests +/// can inspect exact failure causes when using lower-level helpers in this crate. +#[derive(Debug, Error)] +pub enum SymbolicError { + /// The target account was not present in the executor backend. + #[error("missing account {0}")] + MissingAccount(Address), + /// The target account had no runtime bytecode. + #[error("missing code for account {0}")] + MissingCode(Address), + /// The concrete backend returned an error while reading account state. + #[error("backend error: {0}")] + Backend(String), + /// The function ABI contains a type that is not supported by the V1 symbolic calldata model. + #[error("unsupported ABI type for symbolic execution: {0}")] + UnsupportedAbi(String), + /// Symbolic calldata variant expansion exceeded the configured path-width budget. + #[error("symbolic calldata variant limit exceeded ({0})")] + CalldataVariantLimit(usize), + /// Symbolic execution reached a feature that is not implemented yet. + #[error("unsupported symbolic execution feature: {0}")] + Unsupported(&'static str), + /// Symbolic execution reached an opcode that is not implemented yet. + #[error("unsupported opcode 0x{0:02x}")] + UnsupportedOpcode(u8), + /// Runtime bytecode was malformed in a way that prevents symbolic execution. + #[error("invalid bytecode: {0}")] + InvalidBytecode(&'static str), + /// A jump targeted a byte offset that is not a valid `JUMPDEST`. + #[error("invalid jump destination {0}")] + InvalidJump(usize), + /// The symbolic stack was popped without enough values. + #[error("stack underflow")] + StackUnderflow, + /// The symbolic stack exceeded the EVM stack limit. + #[error("stack overflow")] + StackOverflow, + /// The solver process failed, timed out, or returned an unexpected response. + #[error("solver error: {0}")] + Solver(String), + /// The solver returned `unknown`. + #[error("solver returned unknown")] + SolverUnknown, + /// The configured symbolic execution timeout was exceeded. + #[error("symbolic execution timeout exceeded ({0}s)")] + Timeout(u32), + /// The configured maximum number of solver queries was reached. + #[error("symbolic solver query limit exceeded ({0})")] + SolverQueryLimit(usize), + /// ABI encoding failed while constructing a concrete counterexample call. + #[error(transparent)] + Abi(#[from] alloy_dyn_abi::Error), +} + +impl SymbolicError { + pub(super) const fn stop_reason(&self) -> SymbolicStopReason { + match self { + Self::Unsupported(_) + | Self::CalldataVariantLimit(_) + | Self::UnsupportedOpcode(_) + | Self::SolverQueryLimit(_) => SymbolicStopReason::Stuck, + Self::SolverUnknown | Self::Timeout(_) => SymbolicStopReason::Timeout, + Self::Solver(_) + | Self::MissingAccount(_) + | Self::MissingCode(_) + | Self::Backend(_) + | Self::UnsupportedAbi(_) + | Self::InvalidBytecode(_) + | Self::InvalidJump(_) + | Self::StackUnderflow + | Self::StackOverflow + | Self::Abi(_) => SymbolicStopReason::Error, + } + } +} + +impl fmt::Display for SymbolicRunResult { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Safe { stats, .. } => write!(f, "safe after {} paths", stats.paths), + Self::Counterexample { stats, .. } => { + write!(f, "counterexample after {} paths", stats.paths) + } + Self::Incomplete { kind, reason, .. } => { + write!(f, "incomplete symbolic execution ({kind:?}): {reason}") + } + } + } +} diff --git a/crates/evm/symbolic/src/runtime/address.rs b/crates/evm/symbolic/src/runtime/address.rs new file mode 100644 index 0000000000000..8ae9ee1420485 --- /dev/null +++ b/crates/evm/symbolic/src/runtime/address.rs @@ -0,0 +1,146 @@ +use super::*; + +impl SymExpr { + pub(crate) fn representative_symbolic_address(&self) -> Address { + let digest = keccak256(self.symbolic_address_key()); + let mut bytes = [0u8; 20]; + bytes[0] = 0xfe; + bytes[1..].copy_from_slice(&digest[..19]); + Address::from(bytes) + } + + pub(crate) fn symbolic_address_key(&self) -> String { + if let Some(value) = self.as_const() { + format!("concrete-address:{:?}", word_to_address(value)) + } else { + let expr = self.symbolic_address_canonical(); + let identity = expr.address_byte_terms_for_equivalence().map_or_else( + || ExpressionDigests::identity([expr]), + |bytes| ExpressionDigests::identity(&bytes), + ); + let bytes = format!("{identity:?}"); + format!("symbolic-address:{bytes}") + } + } + + pub(crate) fn address_match_condition(&self, cx: &mut SymCx, address: Address) -> SymBoolExpr { + if let Some(word) = self.as_const() { + return SymBoolExpr::constant(cx, word == address_word(address)); + } + let Some(terms) = + (12..32).map(|index| self.byte_term(cx, index)).collect::>>() + else { + let address = Self::constant(cx, address_word(address)); + return SymBoolExpr::eq(cx, self.clone(), address); + }; + let bytes = address.as_slice(); + let conditions = terms + .into_iter() + .enumerate() + .map(|(index, term)| { + let byte = Self::constant(cx, U256::from(bytes[index])); + SymBoolExpr::eq(cx, term, byte) + }) + .collect(); + SymBoolExpr::and(cx, conditions) + } + + pub(crate) fn symbolic_address_equivalent(&self, alias: &Self) -> bool { + match (self.as_const(), alias.as_const()) { + (Some(left), Some(right)) => word_to_address(left) == word_to_address(right), + (None, None) => self.address_expr_equivalent(alias), + _ => false, + } + } + + fn address_expr_equivalent(&self, alias: &Self) -> bool { + let this = self.symbolic_address_canonical(); + let alias = alias.symbolic_address_canonical(); + + if this == alias { + return true; + } + + if let (Some(candidate), Some(alias)) = + (this.address_byte_terms_for_equivalence(), alias.address_byte_terms_for_equivalence()) + { + return candidate == alias; + } + + match this.kind() { + SymExprKind::BinOp(SymBinOp::And, left, right) if right.is_address_mask() => { + left.address_expr_equivalent(alias) + } + SymExprKind::BinOp(SymBinOp::Shr, value, shift) if shift.is_shift_96() => { + match value.kind() { + SymExprKind::BinOp(SymBinOp::Shl, inner, inner_shift) + if inner_shift.is_shift_96() => + { + inner.address_expr_equivalent(alias) + } + _ => false, + } + } + _ => false, + } + } + + fn symbolic_address_canonical(&self) -> &Self { + match self.kind() { + SymExprKind::BinOp(SymBinOp::And, left, right) if right.is_address_mask() => { + left.symbolic_address_canonical() + } + SymExprKind::BinOp(SymBinOp::Shr, value, shift) if shift.is_shift_96() => { + match value.kind() { + SymExprKind::BinOp(SymBinOp::Shl, inner, inner_shift) + if inner_shift.is_shift_96() => + { + inner.symbolic_address_canonical() + } + _ => self, + } + } + _ => self, + } + } + + fn address_byte_terms_for_equivalence(&self) -> Option> { + (12..32).map(|index| self.extracted_byte_source(index)).collect() + } + + fn is_address_mask(&self) -> bool { + self.as_const() == Some((U256::ONE << 160) - U256::ONE) + } + + fn is_shift_96(&self) -> bool { + self.as_const() == Some(U256::from(96)) + } +} + +pub(crate) fn mask_bits(value: U256, bits: usize) -> U256 { + if bits >= 256 { + value + } else { + let mask = (U256::ONE << bits) - U256::ONE; + value & mask + } +} + +pub(crate) fn address_word(address: Address) -> U256 { + address.into_word().into() +} + +pub(crate) fn word_to_address(value: U256) -> Address { + Address::from_word(value.into()) +} + +pub(crate) fn stable_symbol(cx: &mut SymCx, prefix: &'static str, input: &[u8]) -> Symbol { + let digest = keccak256(input); + let mut symbol = String::with_capacity(prefix.len() + 17); + symbol.push_str(prefix); + symbol.push('_'); + for byte in &digest[..8] { + let _ = write!(symbol, "{byte:02x}"); + } + cx.intern(&symbol) +} diff --git a/crates/evm/symbolic/src/runtime/bytes.rs b/crates/evm/symbolic/src/runtime/bytes.rs new file mode 100644 index 0000000000000..73cf2a20dba22 --- /dev/null +++ b/crates/evm/symbolic/src/runtime/bytes.rs @@ -0,0 +1,614 @@ +use super::{expr::hashcons::HashConsed, *}; + +#[derive(Clone, PartialEq, Eq, Hash)] +pub(crate) struct SymBytes { + pub(in crate::runtime) kind: HashConsed, +} + +impl fmt::Debug for SymBytes { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.kind().fmt(f) + } +} + +#[derive(Debug, PartialEq, Eq, Hash)] +pub(in crate::runtime) enum SymBytesKind { + Concrete(Vec), + Exprs(Vec), + Word(SymExpr), + Concat(Vec), + Slice { bytes: SymBytes, offset: SymExpr, len: usize }, + Sized { bytes: SymBytes, size: SymExpr, max_size: usize }, +} + +impl SymBytes { + fn from_kind(cx: &mut SymCx, kind: SymBytesKind) -> Self { + cx.mk_bytes_kind(kind) + } + + fn kind(&self) -> &SymBytesKind { + self.kind.value() + } + + pub(crate) fn empty(cx: &mut SymCx) -> Self { + Self::concrete(cx, Vec::new()) + } + + pub(crate) fn concrete(cx: &mut SymCx, bytes: Vec) -> Self { + Self::from_kind(cx, SymBytesKind::Concrete(bytes)) + } + + pub(crate) fn as_concrete_slice(&self) -> Option<&[u8]> { + match self.kind() { + SymBytesKind::Concrete(bytes) => Some(bytes), + _ => None, + } + } + + pub(crate) fn exprs(cx: &mut SymCx, bytes: Vec) -> Self { + if let Ok(concrete) = concrete_expr_bytes(&bytes, "symbolic bytes") { + Self::concrete(cx, concrete) + } else { + Self::from_kind(cx, SymBytesKind::Exprs(bytes)) + } + } + + pub(crate) fn word(cx: &mut SymCx, word: SymExpr) -> Self { + if let Some(word) = word.as_const() { + Self::concrete(cx, word.to_be_bytes::<32>().to_vec()) + } else { + Self::from_kind(cx, SymBytesKind::Word(word)) + } + } + + pub(crate) fn concat(cx: &mut SymCx, bytes: impl IntoIterator) -> Self { + let mut out = Vec::new(); + for bytes in bytes { + match bytes.kind() { + SymBytesKind::Concrete(values) if values.is_empty() => {} + SymBytesKind::Concat(values) => out.extend(values.iter().cloned()), + _ => out.push(bytes), + } + } + match out.len() { + 0 => Self::empty(cx), + 1 => out.pop().expect("single item exists"), + _ => Self::from_kind(cx, SymBytesKind::Concat(out)), + } + } + + pub(crate) fn slice(cx: &mut SymCx, bytes: Self, offset: SymExpr, len: usize) -> Self { + if len == 0 { + return Self::empty(cx); + } + if let Some(offset) = offset.eval() { + let Ok(offset) = usize::try_from(offset) else { + return Self::concrete(cx, vec![0; len]); + }; + return bytes.slice_concrete(cx, offset, len); + } + Self::slice_node(cx, bytes, offset, len) + } + + fn slice_node(cx: &mut SymCx, bytes: Self, offset: SymExpr, len: usize) -> Self { + Self::from_kind(cx, SymBytesKind::Slice { bytes, offset, len }) + } + + pub(crate) fn sized(cx: &mut SymCx, bytes: Self, size: SymExpr, max_size: usize) -> Self { + if max_size == 0 { + return Self::empty(cx); + } + if let Some(size) = size.eval() { + let size = usize::try_from(size).map_or(max_size, |size| size.min(max_size)); + let bytes = bytes.slice_concrete(cx, 0, size); + let padding = Self::concrete(cx, vec![0; max_size - size]); + return Self::concat(cx, [bytes, padding]); + } + Self::from_kind(cx, SymBytesKind::Sized { bytes, size, max_size }) + } + + pub(crate) fn len(&self) -> usize { + match self.kind() { + SymBytesKind::Concrete(bytes) => bytes.len(), + SymBytesKind::Exprs(bytes) => bytes.len(), + SymBytesKind::Word(_) => 32, + SymBytesKind::Concat(values) => { + values.iter().fold(0usize, |len, bytes| len.saturating_add(bytes.len())) + } + SymBytesKind::Slice { len, .. } => *len, + SymBytesKind::Sized { max_size, .. } => *max_size, + } + } + + pub(crate) fn is_empty(&self) -> bool { + self.len() == 0 + } + + pub(crate) fn byte(&self, cx: &mut SymCx, offset: usize) -> SymExpr { + match self.kind() { + SymBytesKind::Concrete(bytes) => bytes + .get(offset) + .copied() + .map(|byte| SymExpr::constant(cx, U256::from(byte))) + .unwrap_or_else(|| SymExpr::zero(cx)), + SymBytesKind::Exprs(bytes) => { + bytes.get(offset).cloned().unwrap_or_else(|| SymExpr::zero(cx)) + } + SymBytesKind::Word(word) => { + if offset >= 32 { + SymExpr::zero(cx) + } else if let Some(byte) = word.known_byte(offset) { + SymExpr::constant(cx, U256::from(byte)) + } else { + word.extracted_byte(cx, offset) + } + } + SymBytesKind::Concat(values) => { + let mut offset = offset; + for bytes in values { + if offset < bytes.len() { + return bytes.byte(cx, offset); + } + offset = offset.saturating_sub(bytes.len()); + } + SymExpr::zero(cx) + } + SymBytesKind::Slice { bytes, offset: base_offset, len } => { + if offset >= *len { + SymExpr::zero(cx) + } else if let Some(base_offset) = base_offset.eval() { + let Ok(base_offset) = usize::try_from(base_offset) else { + return SymExpr::zero(cx); + }; + let Some(offset) = base_offset.checked_add(offset) else { + return SymExpr::zero(cx); + }; + bytes.byte(cx, offset) + } else { + bytes.byte_dynamic_with_delta(cx, base_offset, offset) + } + } + SymBytesKind::Sized { bytes, size, max_size } => { + if offset >= *max_size { + return SymExpr::zero(cx); + } + let source = bytes.byte(cx, offset); + let offset = SymExpr::constant(cx, U256::from(offset)); + let condition = SymBoolExpr::cmp(cx, SymCmpOp::Ult, offset, size.clone()); + let zero = SymExpr::zero(cx); + SymExpr::ite(cx, condition, source, zero) + } + } + } + + pub(crate) fn byte_dynamic_with_delta( + &self, + cx: &mut SymCx, + offset: &SymExpr, + delta: usize, + ) -> SymExpr { + let mut result = SymExpr::zero(cx); + for candidate in (delta..self.len()).rev() { + let candidate_offset = candidate - delta; + let candidate_expr = SymExpr::constant(cx, U256::from(candidate_offset)); + let condition = SymBoolExpr::eq(cx, offset.clone(), candidate_expr); + let byte = self.byte(cx, candidate); + result = SymExpr::ite(cx, condition, byte, result); + } + result + } + + pub(crate) fn slice_concrete(&self, cx: &mut SymCx, offset: usize, len: usize) -> Self { + if len == 0 { + return Self::empty(cx); + } + if offset == 0 && len == self.len() { + return self.clone(); + } + match self.kind() { + SymBytesKind::Concrete(bytes) => { + let out = if offset.checked_add(len).is_some_and(|end| end <= bytes.len()) { + bytes[offset..offset + len].to_vec() + } else { + (0..len) + .map(|idx| bytes.get(offset + idx).copied().unwrap_or_default()) + .collect() + }; + Self::concrete(cx, out) + } + SymBytesKind::Exprs(bytes) => { + let bytes = (0..len) + .map(|idx| { + bytes.get(offset + idx).cloned().unwrap_or_else(|| SymExpr::zero(cx)) + }) + .collect(); + Self::exprs(cx, bytes) + } + SymBytesKind::Word(word) if offset == 0 && len == 32 => Self::word(cx, word.clone()), + SymBytesKind::Word(_) | SymBytesKind::Sized { .. } => { + let offset = SymExpr::constant(cx, U256::from(offset)); + Self::slice_node(cx, self.clone(), offset, len) + } + SymBytesKind::Slice { bytes, offset: base_offset, .. } => { + if let Some(base_offset) = base_offset.eval() + && let Ok(base_offset) = usize::try_from(base_offset) + && let Some(offset) = base_offset.checked_add(offset) + { + bytes.slice_concrete(cx, offset, len) + } else { + let offset = SymExpr::constant(cx, U256::from(offset)); + Self::slice_node(cx, self.clone(), offset, len) + } + } + SymBytesKind::Concat(values) => { + let mut offset = offset; + let mut len = len; + let mut out = Vec::new(); + for bytes in values { + if len == 0 { + break; + } + + let bytes_len = bytes.len(); + if offset >= bytes_len { + offset -= bytes_len; + continue; + } + + let take = (bytes_len - offset).min(len); + if offset == 0 && take == bytes_len { + out.push(bytes.clone()); + } else { + out.push(bytes.slice_concrete(cx, offset, take)); + } + len -= take; + offset = 0; + } + + if len != 0 { + out.push(Self::concrete(cx, vec![0; len])); + } + Self::concat(cx, out) + } + } + } + + pub(crate) fn read_offset(&self, cx: &mut SymCx, offset: SymExpr, len: usize) -> Self { + Self::slice(cx, self.clone(), offset, len) + } + + pub(crate) fn word_at(&self, cx: &mut SymCx, offset: usize) -> SymExpr { + if let Some(word) = self.word_fragment_at(cx, offset, 32, 0) { + return word; + } + + match self.kind() { + SymBytesKind::Concrete(bytes) => { + let mut word = [0u8; 32]; + if offset < bytes.len() { + let take = (bytes.len() - offset).min(32); + word[..take].copy_from_slice(&bytes[offset..offset + take]); + } + SymExpr::constant(cx, U256::from_be_bytes(word)) + } + SymBytesKind::Exprs(bytes) => { + let bytes = (0..32) + .map(|idx| { + bytes.get(offset + idx).cloned().unwrap_or_else(|| SymExpr::zero(cx)) + }) + .collect::>(); + SymExpr::from_bytes(cx, bytes) + } + SymBytesKind::Word(word) if offset == 0 => word.clone(), + SymBytesKind::Word(_) if offset >= 32 => SymExpr::zero(cx), + SymBytesKind::Slice { bytes, offset: base_offset, len } => { + if offset.checked_add(32).is_some_and(|end| end <= *len) + && let Some(base_offset) = base_offset.eval() + && let Ok(base_offset) = usize::try_from(base_offset) + && let Some(base_offset) = base_offset.checked_add(offset) + { + return bytes.word_at(cx, base_offset); + } + let bytes = (0..32).map(|idx| self.byte(cx, offset + idx)).collect::>(); + SymExpr::from_bytes(cx, bytes) + } + _ => { + let bytes = (0..32).map(|idx| self.byte(cx, offset + idx)).collect::>(); + SymExpr::from_bytes(cx, bytes) + } + } + } + + pub(crate) fn right_aligned_word(&self, cx: &mut SymCx, offset: usize, len: usize) -> SymExpr { + debug_assert!(len <= 32); + let len = len.min(32); + if let Some(word) = self.word_fragment_at(cx, offset, len, 32 - len) { + return word; + } + + let mut bytes = Vec::with_capacity(32); + for _ in 0..32 - len { + bytes.push(SymExpr::zero(cx)); + } + bytes.extend((0..len).map(|idx| self.byte(cx, offset + idx))); + SymExpr::from_bytes(cx, bytes) + } + + fn word_fragment_at( + &self, + cx: &mut SymCx, + offset: usize, + len: usize, + out_offset: usize, + ) -> Option { + debug_assert!(len <= 32); + debug_assert!(out_offset <= 32); + debug_assert!(out_offset + len <= 32); + + if len == 0 { + return Some(SymExpr::zero(cx)); + } + + match self.kind() { + SymBytesKind::Concrete(bytes) => { + let mut word = [0u8; 32]; + if offset < bytes.len() { + let take = (bytes.len() - offset).min(len); + word[out_offset..out_offset + take] + .copy_from_slice(&bytes[offset..offset + take]); + } + Some(SymExpr::constant(cx, U256::from_be_bytes(word))) + } + SymBytesKind::Word(word) => { + Self::word_expr_fragment(cx, word.clone(), offset, len, out_offset) + } + SymBytesKind::Slice { bytes, offset: base_offset, len: slice_len } => { + let available = slice_len.saturating_sub(offset).min(len); + if available == 0 { + return Some(SymExpr::zero(cx)); + } + let base_offset = + base_offset.eval().and_then(|offset| usize::try_from(offset).ok())?; + bytes.word_fragment_at(cx, base_offset.checked_add(offset)?, available, out_offset) + } + SymBytesKind::Concat(values) => { + let mut offset = offset; + let mut out_offset = out_offset; + let mut remaining = len; + let mut out = SymExpr::zero(cx); + + for bytes in values { + if remaining == 0 { + break; + } + + let bytes_len = bytes.len(); + if offset >= bytes_len { + offset -= bytes_len; + continue; + } + + let take = (bytes_len - offset).min(remaining); + let fragment = bytes.word_fragment_at(cx, offset, take, out_offset)?; + out = SymExpr::binop(cx, SymBinOp::Or, out, fragment); + out_offset += take; + remaining -= take; + offset = 0; + } + + Some(out) + } + SymBytesKind::Exprs(_) | SymBytesKind::Sized { .. } => None, + } + } + + fn word_expr_fragment( + cx: &mut SymCx, + word: SymExpr, + offset: usize, + len: usize, + out_offset: usize, + ) -> Option { + let len = 32usize.checked_sub(offset)?.min(len); + if len == 0 { + return Some(SymExpr::zero(cx)); + } + if offset == 0 && len == 32 && out_offset == 0 { + return Some(word); + } + + let src_trailing_bits = (32 - (offset + len)) * 8; + let dst_trailing_bits = (32 - (out_offset + len)) * 8; + let mask = mask_bits(U256::MAX, len * 8); + + let src_trailing_bits = SymExpr::constant(cx, U256::from(src_trailing_bits)); + let expr = SymExpr::binop(cx, SymBinOp::Shr, word, src_trailing_bits); + let mask = SymExpr::constant(cx, mask); + let expr = SymExpr::binop(cx, SymBinOp::And, expr, mask); + let dst_trailing_bits = SymExpr::constant(cx, U256::from(dst_trailing_bits)); + Some(SymExpr::binop(cx, SymBinOp::Shl, expr, dst_trailing_bits)) + } + + /// Returns `true` if the observable bytes depend on an unresolved `GAS` / `gasleft()` value. + /// + /// Only the represented byte range is inspected: bytes of a backing value that a slice or a + /// `max_size` bound excludes are never reachable and do not taint the result. A gas-dependent + /// slice offset or dynamic size always taints, because the byte layout itself would then + /// depend on gas. + pub(crate) fn contains_gasleft(&self, cx: &mut SymCx) -> bool { + if self.shape_depends_on_gasleft() { + return true; + } + // Fast structural pre-check: without a gas-dependent backing value nothing can be tainted. + if !self.backing_contains_gasleft() { + return false; + } + (0..self.len()).any(|offset| self.byte(cx, offset).contains_gasleft()) + } + + /// Returns `true` if a slice offset or dynamic size anywhere in the tree mentions `gasleft()`. + fn shape_depends_on_gasleft(&self) -> bool { + match self.kind() { + SymBytesKind::Concrete(_) | SymBytesKind::Exprs(_) | SymBytesKind::Word(_) => false, + SymBytesKind::Concat(parts) => parts.iter().any(Self::shape_depends_on_gasleft), + SymBytesKind::Slice { bytes, offset, .. } => { + offset.contains_gasleft() || bytes.shape_depends_on_gasleft() + } + SymBytesKind::Sized { bytes, size, .. } => { + size.contains_gasleft() || bytes.shape_depends_on_gasleft() + } + } + } + + /// Returns `true` if any backing value, observable or not, mentions `gasleft()`. + fn backing_contains_gasleft(&self) -> bool { + match self.kind() { + SymBytesKind::Concrete(_) => false, + SymBytesKind::Exprs(bytes) => bytes.iter().any(SymExpr::contains_gasleft), + SymBytesKind::Word(word) => word.contains_gasleft(), + SymBytesKind::Concat(parts) => parts.iter().any(Self::backing_contains_gasleft), + SymBytesKind::Slice { bytes, .. } | SymBytesKind::Sized { bytes, .. } => { + bytes.backing_contains_gasleft() + } + } + } + + pub(crate) fn materialize(&self, cx: &mut SymCx) -> Vec { + (0..self.len()).map(|idx| self.byte(cx, idx)).collect() + } + + pub(crate) fn same_bytes(&self, cx: &mut SymCx, other: &Self) -> bool { + self.len() == other.len() + && (0..self.len()).all(|idx| self.byte(cx, idx) == other.byte(cx, idx)) + } + + pub(crate) fn prefix_condition(&self, cx: &mut SymCx, prefix: &Self) -> Option { + if prefix.len() > self.len() { + return None; + } + let mut conditions = Vec::new(); + for idx in 0..prefix.len() { + let actual = self.byte(cx, idx); + let expected = prefix.byte(cx, idx); + if actual == expected { + continue; + } + match (actual.as_const(), expected.as_const()) { + (Some(actual), Some(expected)) if actual.to::() == expected.to::() => {} + (Some(_), Some(_)) => return None, + _ => conditions.push(SymBoolExpr::eq(cx, actual, expected)), + } + } + Some(SymBoolExpr::and(cx, conditions)) + } + + pub(crate) fn eval_model( + &self, + cx: &mut SymCx, + model: &M, + ) -> Result, SymbolicError> { + match self.kind() { + SymBytesKind::Concrete(bytes) => Ok(bytes.clone()), + _ => (0..self.len()) + .map(|idx| Ok(self.byte(cx, idx).eval_model(model)?.to::())) + .collect(), + } + } + + pub(crate) fn concrete_bytes( + &self, + cx: &mut SymCx, + reason: &'static str, + ) -> Result, SymbolicError> { + let mut out = Vec::with_capacity(self.len()); + self.append_concrete_range(cx, 0, self.len(), &mut out, reason)?; + Ok(out) + } + + fn append_concrete_range( + &self, + cx: &mut SymCx, + mut offset: usize, + mut len: usize, + out: &mut Vec, + reason: &'static str, + ) -> Result<(), SymbolicError> { + if len == 0 { + return Ok(()); + } + + match self.kind() { + SymBytesKind::Concrete(bytes) => { + if offset >= bytes.len() { + out.resize(out.len() + len, 0); + return Ok(()); + } + + let take = (bytes.len() - offset).min(len); + out.extend_from_slice(&bytes[offset..offset + take]); + if len > take { + out.resize(out.len() + len - take, 0); + } + } + SymBytesKind::Exprs(bytes) => { + for idx in 0..len { + let Some(byte) = offset.checked_add(idx).and_then(|idx| bytes.get(idx)) else { + out.push(0); + continue; + }; + let Some(byte) = byte.as_const() else { + return Err(SymbolicError::Unsupported(reason)); + }; + out.push(byte.to::()); + } + } + SymBytesKind::Concat(values) => { + for bytes in values { + if len == 0 { + break; + } + + let bytes_len = bytes.len(); + if offset >= bytes_len { + offset -= bytes_len; + continue; + } + + let take = (bytes_len - offset).min(len); + bytes.append_concrete_range(cx, offset, take, out, reason)?; + len -= take; + offset = 0; + } + + if len != 0 { + out.resize(out.len() + len, 0); + } + } + SymBytesKind::Slice { bytes, offset: base_offset, len: slice_len } => { + if offset >= *slice_len { + out.resize(out.len() + len, 0); + return Ok(()); + } + + let take = (*slice_len - offset).min(len); + if let Some(base_offset) = base_offset.eval() + && let Ok(base_offset) = usize::try_from(base_offset) + && let Some(offset) = base_offset.checked_add(offset) + { + bytes.append_concrete_range(cx, offset, take, out, reason)?; + if len > take { + out.resize(out.len() + len - take, 0); + } + } else { + let bytes = self.slice_concrete(cx, offset, len).materialize(cx); + out.extend(concrete_expr_bytes(&bytes, reason)?); + } + } + _ => { + let bytes = self.slice_concrete(cx, offset, len).materialize(cx); + out.extend(concrete_expr_bytes(&bytes, reason)?); + } + } + Ok(()) + } +} diff --git a/crates/evm/symbolic/src/runtime/calldata.rs b/crates/evm/symbolic/src/runtime/calldata.rs new file mode 100644 index 0000000000000..f13cd7fc8b1fa --- /dev/null +++ b/crates/evm/symbolic/src/runtime/calldata.rs @@ -0,0 +1,94 @@ +use super::*; + +#[derive(Clone, Debug)] +pub(crate) struct SymCalldata { + size: usize, + size_word: SymExpr, + bytes: SymBytes, +} + +impl SymCalldata { + pub(crate) fn from_bytes(cx: &mut SymCx, bytes: SymBytes) -> Self { + let size = bytes.len(); + Self { size_word: SymExpr::constant(cx, U256::from(size)), size, bytes } + } + + pub(crate) fn size_word(&self) -> SymExpr { + self.size_word.clone() + } + + pub(crate) fn load_word(&self, cx: &mut SymCx, offset: SymExpr) -> SymExpr { + if let Some(offset) = offset.as_const() { + let Ok(offset) = usize::try_from(offset) else { + return SymExpr::zero(cx); + }; + self.bytes.word_at(cx, offset) + } else { + self.load_dynamic(cx, &offset) + } + } + + fn load_dynamic(&self, cx: &mut SymCx, offset: &SymExpr) -> SymExpr { + let mut result = SymExpr::zero(cx); + for candidate in (0..self.size).rev() { + let candidate_expr = SymExpr::constant(cx, U256::from(candidate)); + let condition = SymBoolExpr::eq(cx, offset.clone(), candidate_expr); + let word = self.bytes.word_at(cx, candidate); + result = SymExpr::ite(cx, condition, word, result); + } + result + } + + pub(crate) fn read_bytes_offset( + &self, + cx: &mut SymCx, + offset: SymExpr, + size: usize, + ) -> SymBytes { + self.bytes.read_offset(cx, offset, size) + } +} + +impl BoundedCopySize { + pub(crate) fn expand_memory(&self, cx: &mut SymCx, memory: &mut SymMemory, offset: SymExpr) { + let size = self.size_word(cx); + memory.expand_range(cx, offset, size); + } + + pub(crate) fn read_from_memory( + &self, + cx: &mut SymCx, + memory: &SymMemory, + offset: SymExpr, + ) -> SymBytes { + match self { + Self::Concrete(size) => memory.read_bytes_offset(cx, offset, *size), + Self::Symbolic { size, max_size } => { + memory.read_bytes_symbolic_size(cx, offset, size.clone(), *max_size) + } + } + } + + pub(crate) fn size_word(&self, cx: &mut SymCx) -> SymExpr { + match self { + Self::Concrete(size) => SymExpr::constant(cx, U256::from(*size)), + Self::Symbolic { size, .. } => size.clone(), + } + } + + pub(crate) fn parts(&self, cx: &mut SymCx) -> (SymExpr, usize, bool) { + match self { + Self::Concrete(size) => (SymExpr::constant(cx, U256::from(*size)), *size, false), + Self::Symbolic { size, max_size } => (size.clone(), *max_size, true), + } + } + + pub(crate) fn calldata(&self, cx: &mut SymCx, input: SymBytes) -> SymCalldata { + match self { + Self::Concrete(_) => SymCalldata::from_bytes(cx, input), + Self::Symbolic { size, .. } => { + SymCalldata { size: input.len(), size_word: size.clone(), bytes: input } + } + } + } +} diff --git a/crates/evm/symbolic/src/runtime/cheatcodes.rs b/crates/evm/symbolic/src/runtime/cheatcodes.rs new file mode 100644 index 0000000000000..3748b1699fd4e --- /dev/null +++ b/crates/evm/symbolic/src/runtime/cheatcodes.rs @@ -0,0 +1,1028 @@ +use alloy_sol_types::{SolError, SolType, sol_data}; +use foundry_cheatcodes_spec::Vm::{self, *}; +use foundry_common::wallet::private_key_from_u256; + +use super::*; + +pub(crate) const fn foundry_cheatcode_min_input_size(selector: [u8; 4]) -> Option { + match selector { + recordLogsCall::SELECTOR + | recordCall::SELECTOR + | stopRecordCall::SELECTOR + | assumeNoRevert_0Call::SELECTOR + | getRecordedLogsCall::SELECTOR + | getRecordedLogsJsonCall::SELECTOR + | expectRevert_0Call::SELECTOR + | expectEmit_2Call::SELECTOR + | expectEmitAnonymous_2Call::SELECTOR + | clearMockedCallsCall::SELECTOR + | stopPrankCall::SELECTOR + | readCallersCall::SELECTOR + | getWalletsCall::SELECTOR + | snapshotCall::SELECTOR + | snapshotStateCall::SELECTOR + | deleteSnapshotsCall::SELECTOR + | deleteStateSnapshotsCall::SELECTOR + | activeForkCall::SELECTOR + | getChainIdCall::SELECTOR + | getBlobhashesCall::SELECTOR + | getBlobBaseFeeCall::SELECTOR + | getBlockNumberCall::SELECTOR + | getBlockTimestampCall::SELECTOR + | pauseGasMeteringCall::SELECTOR + | resumeGasMeteringCall::SELECTOR + | resetGasMeteringCall::SELECTOR + | isIsolateModeCall::SELECTOR + | lastCallGasCall::SELECTOR + | lastFrameGasCall::SELECTOR + | stopExpectSafeMemoryCall::SELECTOR + | stopSnapshotGas_0Call::SELECTOR + | getEvmVersionCall::SELECTOR + | getFoundryVersionCall::SELECTOR + | projectRootCall::SELECTOR + | unixTimeCall::SELECTOR + | noAccessListCall::SELECTOR + | randomUint_0Call::SELECTOR + | randomInt_0Call::SELECTOR + | randomAddressCall::SELECTOR + | randomBoolCall::SELECTOR + | randomBytes4Call::SELECTOR + | randomBytes8Call::SELECTOR => Some(abi_static_input_size(0)), + assumeCall::SELECTOR + | skip_0Call::SELECTOR + | skip_1Call::SELECTOR + | assumeNoRevert_1Call::SELECTOR + | assumeNoRevert_2Call::SELECTOR + | accessesCall::SELECTOR + | expectRevert_1Call::SELECTOR + | expectRevert_3Call::SELECTOR + | expectRevert_6Call::SELECTOR + | expectPartialRevert_0Call::SELECTOR + | expectEmit_3Call::SELECTOR + | expectEmit_6Call::SELECTOR + | expectEmitAnonymous_3Call::SELECTOR + | prank_0Call::SELECTOR + | startPrank_0Call::SELECTOR + | addrCall::SELECTOR + | rememberKeyCall::SELECTOR + | toString_0Call::SELECTOR + | toString_1Call::SELECTOR + | toString_2Call::SELECTOR + | toString_3Call::SELECTOR + | toString_4Call::SELECTOR + | toString_5Call::SELECTOR + | parseBytesCall::SELECTOR + | parseAddressCall::SELECTOR + | parseUintCall::SELECTOR + | parseIntCall::SELECTOR + | parseBytes32Call::SELECTOR + | parseBoolCall::SELECTOR + | toLowercaseCall::SELECTOR + | toUppercaseCall::SELECTOR + | trimCall::SELECTOR + | toBase64_0Call::SELECTOR + | toBase64_1Call::SELECTOR + | toBase64URL_0Call::SELECTOR + | toBase64URL_1Call::SELECTOR + | breakpoint_0Call::SELECTOR + | setEvmVersionCall::SELECTOR + | sleepCall::SELECTOR + | accessListCall::SELECTOR + | getNonce_0Call::SELECTOR + | resetNonceCall::SELECTOR + | allowCheatcodesCall::SELECTOR + | makePersistent_0Call::SELECTOR + | makePersistent_3Call::SELECTOR + | revokePersistent_0Call::SELECTOR + | revokePersistent_1Call::SELECTOR + | isPersistentCall::SELECTOR + | selectForkCall::SELECTOR + | createFork_0Call::SELECTOR + | createSelectFork_0Call::SELECTOR + | rollFork_0Call::SELECTOR + | rollFork_1Call::SELECTOR + | revertToCall::SELECTOR + | revertToStateCall::SELECTOR + | revertToAndDeleteCall::SELECTOR + | revertToStateAndDeleteCall::SELECTOR + | deleteSnapshotCall::SELECTOR + | deleteStateSnapshotCall::SELECTOR + | warpCall::SELECTOR + | rollCall::SELECTOR + | prevrandao_0Call::SELECTOR + | prevrandao_1Call::SELECTOR + | feeCall::SELECTOR + | blobBaseFeeCall::SELECTOR + | chainIdCall::SELECTOR + | difficultyCall::SELECTOR + | coinbaseCall::SELECTOR + | txGasPriceCall::SELECTOR + | getLabelCall::SELECTOR + | snapshotGasLastCall_0Call::SELECTOR + | snapshotGasLastFrame_0Call::SELECTOR + | startSnapshotGas_0Call::SELECTOR + | stopSnapshotGas_1Call::SELECTOR + | coolCall::SELECTOR + | isContextCall::SELECTOR + | assertTrue_0Call::SELECTOR + | assertTrue_1Call::SELECTOR + | assertFalse_0Call::SELECTOR + | assertFalse_1Call::SELECTOR + | randomUint_2Call::SELECTOR + | randomInt_1Call::SELECTOR + | randomBytesCall::SELECTOR => Some(abi_static_input_size(1)), + expectRevert_4Call::SELECTOR + | expectRevert_7Call::SELECTOR + | expectRevert_9Call::SELECTOR + | expectPartialRevert_1Call::SELECTOR + | expectEmit_7Call::SELECTOR + | prank_1Call::SELECTOR + | prank_2Call::SELECTOR + | startPrank_1Call::SELECTOR + | startPrank_2Call::SELECTOR + | sign_1Call::SELECTOR + | signCompact_1Call::SELECTOR + | deriveKey_0Call::SELECTOR + | splitCall::SELECTOR + | indexOfCall::SELECTOR + | containsCall::SELECTOR + | breakpoint_1Call::SELECTOR + | expectSafeMemoryCall::SELECTOR + | expectSafeMemoryCallCall::SELECTOR + | loadCall::SELECTOR + | makePersistent_1Call::SELECTOR + | computeCreateAddressCall::SELECTOR + | computeCreate2Address_1Call::SELECTOR + | setBlockhashCall::SELECTOR + | dealCall::SELECTOR + | setNonceCall::SELECTOR + | setNonceUnsafeCall::SELECTOR + | createFork_1Call::SELECTOR + | createFork_2Call::SELECTOR + | createSelectFork_1Call::SELECTOR + | createSelectFork_2Call::SELECTOR + | rollFork_2Call::SELECTOR + | rollFork_3Call::SELECTOR + | labelCall::SELECTOR + | snapshotValue_0Call::SELECTOR + | snapshotGasLastCall_1Call::SELECTOR + | snapshotGasLastFrame_1Call::SELECTOR + | startSnapshotGas_1Call::SELECTOR + | stopSnapshotGas_2Call::SELECTOR + | warmSlotCall::SELECTOR + | coolSlotCall::SELECTOR + | assertEq_2Call::SELECTOR + | assertEq_3Call::SELECTOR + | assertEq_4Call::SELECTOR + | assertEq_5Call::SELECTOR + | assertEq_6Call::SELECTOR + | assertEq_7Call::SELECTOR + | assertEq_8Call::SELECTOR + | assertEq_9Call::SELECTOR + | assertEq_0Call::SELECTOR + | assertEq_1Call::SELECTOR + | assertNotEq_2Call::SELECTOR + | assertNotEq_3Call::SELECTOR + | assertNotEq_4Call::SELECTOR + | assertNotEq_5Call::SELECTOR + | assertNotEq_6Call::SELECTOR + | assertNotEq_7Call::SELECTOR + | assertNotEq_8Call::SELECTOR + | assertNotEq_9Call::SELECTOR + | assertNotEq_0Call::SELECTOR + | assertNotEq_1Call::SELECTOR + | assertLt_0Call::SELECTOR + | assertLt_1Call::SELECTOR + | assertLe_0Call::SELECTOR + | assertLe_1Call::SELECTOR + | assertGt_0Call::SELECTOR + | assertGt_1Call::SELECTOR + | assertGe_0Call::SELECTOR + | assertGe_1Call::SELECTOR + | assertLt_2Call::SELECTOR + | assertLt_3Call::SELECTOR + | assertGt_2Call::SELECTOR + | assertGt_3Call::SELECTOR + | assertLe_2Call::SELECTOR + | assertLe_3Call::SELECTOR + | assertGe_2Call::SELECTOR + | assertGe_3Call::SELECTOR + | assertEq_14Call::SELECTOR + | assertEq_15Call::SELECTOR + | assertEq_16Call::SELECTOR + | assertEq_17Call::SELECTOR + | assertEq_18Call::SELECTOR + | assertEq_19Call::SELECTOR + | assertEq_20Call::SELECTOR + | assertEq_21Call::SELECTOR + | assertEq_22Call::SELECTOR + | assertEq_23Call::SELECTOR + | assertEq_24Call::SELECTOR + | assertEq_25Call::SELECTOR + | assertEq_26Call::SELECTOR + | assertEq_27Call::SELECTOR + | assertNotEq_14Call::SELECTOR + | assertNotEq_15Call::SELECTOR + | assertNotEq_16Call::SELECTOR + | assertNotEq_17Call::SELECTOR + | assertNotEq_18Call::SELECTOR + | assertNotEq_19Call::SELECTOR + | assertNotEq_20Call::SELECTOR + | assertNotEq_21Call::SELECTOR + | assertNotEq_22Call::SELECTOR + | assertNotEq_23Call::SELECTOR + | assertNotEq_24Call::SELECTOR + | assertNotEq_25Call::SELECTOR + | assertNotEq_26Call::SELECTOR + | assertNotEq_27Call::SELECTOR + | randomUint_1Call::SELECTOR + | registerSloadHookCall::SELECTOR + | registerSstoreHookCall::SELECTOR => Some(abi_static_input_size(2)), + expectRevert_10Call::SELECTOR + | deriveKey_1Call::SELECTOR + | deriveKey_2Call::SELECTOR + | rememberKeys_0Call::SELECTOR + | storeCall::SELECTOR + | makePersistent_2Call::SELECTOR + | snapshotValue_1Call::SELECTOR + | replaceCall::SELECTOR + | assertEqDecimal_0Call::SELECTOR + | assertEqDecimal_2Call::SELECTOR + | computeCreate2Address_0Call::SELECTOR + | bound_0Call::SELECTOR + | bound_1Call::SELECTOR + | registerMappingSstoreHookCall::SELECTOR => Some(abi_static_input_size(3)), + expectEmit_0Call::SELECTOR + | deriveKey_3Call::SELECTOR + | rememberKeys_1Call::SELECTOR + | assertEqDecimal_1Call::SELECTOR + | assertEqDecimal_3Call::SELECTOR => Some(abi_static_input_size(4)), + expectEmitAnonymous_0Call::SELECTOR + | expectEmit_1Call::SELECTOR + | expectEmit_4Call::SELECTOR => Some(abi_static_input_size(5)), + expectEmit_5Call::SELECTOR | expectEmitAnonymous_1Call::SELECTOR => { + Some(abi_static_input_size(6)) + } + _ => None, + } +} + +pub(crate) fn symbolic_vm_cheatcode_min_input_size(selector: [u8; 4]) -> Option { + SymbolicVmCheatcode::from_selector(selector) + .map(|cheatcode| abi_static_input_size(cheatcode.min_input_words())) +} + +pub(crate) const fn abi_static_input_size(words: usize) -> usize { + 4 + words * 32 +} + +pub(crate) fn abi_bytes_return(cx: &mut SymCx, bytes: Vec) -> SymReturnData { + let len = SymExpr::constant(cx, U256::from(bytes.len())); + abi_bytes_return_with_len(cx, len, bytes) +} + +pub(crate) fn abi_bytes_return_with_len( + cx: &mut SymCx, + len: SymExpr, + bytes: Vec, +) -> SymReturnData { + let mut out = SymExpr::constant(cx, U256::from(32)).into_byte_exprs(cx); + out.extend(len.into_byte_exprs(cx)); + out.extend(bytes.iter().cloned()); + out.resize_with(64 + bytes.len().next_multiple_of(32), || SymExpr::zero(cx)); + SymReturnData::from_byte_exprs(cx, out) +} + +pub(crate) fn abi_concrete_bytes_return(cx: &mut SymCx, bytes: &[u8]) -> SymReturnData { + SymReturnData::from_concrete_bytes(cx, sol_data::Bytes::abi_encode(bytes)) +} + +pub(crate) fn abi_concrete_value_return(cx: &mut SymCx, value: DynSolValue) -> SymReturnData { + SymReturnData::from_concrete_bytes(cx, value.abi_encode()) +} + +pub(crate) fn error_string_return_data(cx: &mut SymCx, reason: &str) -> SymReturnData { + let bytes = Vm::CheatcodeError { message: reason.to_string() }.abi_encode(); + SymReturnData::from_concrete_bytes(cx, bytes) +} + +pub(crate) fn recorded_logs_return_data(cx: &mut SymCx, logs: Vec) -> SymReturnData { + let value = SymbolicAbiValue::Array { + elements: logs + .into_iter() + .map(|log| { + let (topics, data_len, data, emitter) = log.into_parts(); + let topics = topics + .iter() + .cloned() + .map(|topic| SymbolicAbiValue::FixedBytes { + bytes: topic.into_bytes(cx), + size: 32, + }) + .collect(); + SymbolicAbiValue::Tuple { + elements: vec![ + SymbolicAbiValue::Array { elements: topics }, + SymbolicAbiValue::Bytes { len: data_len, bytes: data }, + SymbolicAbiValue::Address { + word: SymExpr::constant(cx, address_word(emitter)), + }, + ], + } + }) + .collect(), + }; + let bytes = encode_sequence(cx, std::iter::once(&value)); + SymReturnData::from_bytes(cx, bytes) +} + +pub(crate) fn recorded_logs_json_return_data( + cx: &mut SymCx, + logs: Vec, +) -> Result { + let mut bytes = Vec::new(); + push_ascii(cx, &mut bytes, "["); + for (log_idx, log) in logs.into_iter().enumerate() { + let (topics, data_len, data, emitter) = log.into_parts(); + if log_idx > 0 { + push_ascii(cx, &mut bytes, ","); + } + push_ascii(cx, &mut bytes, "{\"topics\":["); + for (topic_idx, topic) in topics.iter().cloned().enumerate() { + if topic_idx > 0 { + push_ascii(cx, &mut bytes, ","); + } + push_ascii(cx, &mut bytes, "\"0x"); + push_hex_word(cx, &mut bytes, topic); + push_ascii(cx, &mut bytes, "\""); + } + push_ascii(cx, &mut bytes, "],\"data\":\"0x"); + + let len = data_len.as_const_or("symbolic vm.getRecordedLogsJson data length").and_then( + |len| { + usize::try_from(len).map_err(|_| { + SymbolicError::Unsupported("symbolic vm.getRecordedLogsJson data length") + }) + }, + )?; + if len > data.len() { + return Err(SymbolicError::Unsupported("symbolic vm.getRecordedLogsJson data length")); + } + for idx in 0..len { + let byte = data.byte(cx, idx); + push_hex_byte(cx, &mut bytes, byte); + } + + push_ascii(cx, &mut bytes, "\",\"emitter\":\""); + push_ascii(cx, &mut bytes, &format!("{emitter}")); + push_ascii(cx, &mut bytes, "\"}"); + } + push_ascii(cx, &mut bytes, "]"); + Ok(abi_bytes_return(cx, bytes)) +} + +pub(crate) fn accesses_return_data( + cx: &mut SymCx, + record: Option<&AccessRecord>, + target: Address, +) -> SymReturnData { + let reads = record.map(|record| record.read_slots(target)).unwrap_or_default(); + let writes = record.map(|record| record.write_slots(target)).unwrap_or_default(); + let values = [storage_slots_abi_array(cx, reads), storage_slots_abi_array(cx, writes)]; + let bytes = encode_sequence(cx, values.iter()); + SymReturnData::from_bytes(cx, bytes) +} + +pub(crate) fn complete_cheatcode_call( + cx: &mut SymCx, + state: &mut PathState, + out_offset: SymExpr, + out_size: &BoundedCopySize, + return_data: SymReturnData, +) -> Result<(), SymbolicError> { + state.return_data = return_data; + state.copy_call_output_offset(cx, out_offset, out_size)?; + state.stack.push(SymExpr::one(cx))?; + Ok(()) +} + +pub(crate) fn storage_slots_abi_array(cx: &mut SymCx, slots: Vec) -> SymbolicAbiValue { + SymbolicAbiValue::Array { + elements: slots + .into_iter() + .map(|slot| SymbolicAbiValue::FixedBytes { bytes: slot.into_bytes(cx), size: 32 }) + .collect(), + } +} + +pub(crate) fn push_ascii(cx: &mut SymCx, out: &mut Vec, value: &str) { + out.extend(value.bytes().map(|byte| SymExpr::constant(cx, U256::from(byte)))); +} + +pub(crate) fn push_hex_word(cx: &mut SymCx, out: &mut Vec, word: SymExpr) { + for byte in word.into_byte_exprs(cx) { + push_hex_byte(cx, out, byte); + } +} + +pub(crate) fn push_hex_byte(cx: &mut SymCx, out: &mut Vec, byte: SymExpr) { + let byte = byte.low_byte(cx); + let (high, low) = if let Some(value) = byte.as_const() { + ( + SymExpr::constant(cx, U256::from(value.to::() >> 4)), + SymExpr::constant(cx, U256::from(value.to::() & 0x0f)), + ) + } else { + let expr = byte; + let shift = SymExpr::constant(cx, U256::from(4)); + let mask = SymExpr::constant(cx, U256::from(0x0f)); + ( + SymExpr::binop(cx, SymBinOp::Shr, expr.clone(), shift), + SymExpr::binop(cx, SymBinOp::And, expr, mask), + ) + }; + out.push(hex_nibble_ascii(cx, high)); + out.push(hex_nibble_ascii(cx, low)); +} + +pub(crate) fn hex_nibble_ascii(cx: &mut SymCx, nibble: SymExpr) -> SymExpr { + let nibble = nibble.low_byte(cx); + if let Some(value) = nibble.as_const() { + let nibble = value.to::() & 0x0f; + let byte = if nibble < 10 { b'0' + nibble } else { b'a' + (nibble - 10) }; + SymExpr::constant(cx, U256::from(byte)) + } else { + let ten = SymExpr::constant(cx, U256::from(10)); + let condition = SymBoolExpr::cmp_word_expr(cx, SymCmpOp::Ult, &nibble, ten); + let zero = SymExpr::constant(cx, U256::from(b'0')); + let digit = SymExpr::binop(cx, SymBinOp::Add, nibble.clone(), zero); + let alpha_base = SymExpr::constant(cx, U256::from(b'a' - 10)); + let alpha = SymExpr::binop(cx, SymBinOp::Add, nibble, alpha_base); + SymExpr::ite(cx, condition, digit, alpha) + } +} + +pub(crate) fn read_abi_word_arg( + cx: &mut SymCx, + memory: &SymMemory, + args_offset: usize, + index: usize, +) -> Result { + memory.load_word(cx, args_offset + index * 32) +} + +pub(crate) fn read_abi_concrete_word_arg( + cx: &mut SymCx, + memory: &SymMemory, + args_offset: usize, + index: usize, + reason: &'static str, +) -> Result { + read_abi_word_arg(cx, memory, args_offset, index)?.as_const_or(reason) +} + +pub(crate) fn read_abi_constrained_word_arg( + cx: &mut SymCx, + state: &PathState, + args_offset: usize, + index: usize, + reason: &'static str, +) -> Result { + let word = read_abi_word_arg(cx, &state.memory, args_offset, index)?; + state.expect_constrained_word(cx, word, reason) +} + +pub(crate) fn read_abi_constrained_address_arg( + cx: &mut SymCx, + state: &PathState, + args_offset: usize, + index: usize, + reason: &'static str, +) -> Result { + Ok(word_to_address(read_abi_constrained_word_arg(cx, state, args_offset, index, reason)?)) +} + +pub(crate) fn read_abi_address_or_symbolic_slot_arg( + cx: &mut SymCx, + state: &mut PathState, + args_offset: usize, + index: usize, +) -> Result { + let word = read_abi_word_arg(cx, &state.memory, args_offset, index)?; + Ok(state.address_or_symbolic_slot(cx, word)) +} + +pub(crate) fn read_abi_address_word_or_symbolic_slot_arg( + cx: &mut SymCx, + state: &mut PathState, + args_offset: usize, + index: usize, +) -> Result<(Address, SymExpr), SymbolicError> { + let word = read_abi_word_arg(cx, &state.memory, args_offset, index)?; + let address = state.address_or_symbolic_slot(cx, word.clone()); + Ok((address, word)) +} + +pub(crate) fn read_abi_address_arg( + cx: &mut SymCx, + memory: &SymMemory, + args_offset: usize, + index: usize, + reason: &'static str, +) -> Result { + Ok(word_to_address(read_abi_concrete_word_arg(cx, memory, args_offset, index, reason)?)) +} + +pub(crate) fn read_abi_bool_arg( + cx: &mut SymCx, + memory: &SymMemory, + args_offset: usize, + index: usize, + reason: &'static str, +) -> Result { + Ok(!read_abi_concrete_word_arg(cx, memory, args_offset, index, reason)?.is_zero()) +} + +pub(crate) fn read_abi_u64_arg( + cx: &mut SymCx, + memory: &SymMemory, + args_offset: usize, + index: usize, + reason: &'static str, +) -> Result { + let value = read_abi_concrete_word_arg(cx, memory, args_offset, index, reason)?; + u64::try_from(value).map_err(|_| SymbolicError::Unsupported(reason)) +} + +pub(crate) fn read_abi_u32_arg( + cx: &mut SymCx, + memory: &SymMemory, + args_offset: usize, + index: usize, + reason: &'static str, +) -> Result { + let value = read_abi_concrete_word_arg(cx, memory, args_offset, index, reason)?; + u32::try_from(value).map_err(|_| SymbolicError::Unsupported(reason)) +} + +pub(crate) fn read_abi_bytes4_words_arg( + cx: &mut SymCx, + memory: &SymMemory, + args_offset: usize, + index: usize, +) -> Vec { + memory.read_byte_exprs(cx, args_offset + index * 32, 4) +} + +pub(crate) fn read_abi_dynamic_bytes_arg( + cx: &mut SymCx, + memory: &SymMemory, + args_offset: usize, + index: usize, + reason: &'static str, +) -> Result, SymbolicError> { + let offset = + usize::try_from(read_abi_concrete_word_arg(cx, memory, args_offset, index, reason)?) + .map_err(|_| SymbolicError::Unsupported(reason))?; + let len_offset = args_offset.checked_add(offset).ok_or(SymbolicError::Unsupported(reason))?; + let data_offset = len_offset.checked_add(32).ok_or(SymbolicError::Unsupported(reason))?; + let len = memory.load_word(cx, len_offset)?.as_usize_or(reason)?; + memory.read_concrete(cx, data_offset, len) +} + +pub(crate) fn read_abi_symbolic_dynamic_bytes_arg( + cx: &mut SymCx, + state: &PathState, + args_offset: usize, + index: usize, + max_len: usize, + reason: &'static str, +) -> Result { + let offset = read_abi_word_arg(cx, &state.memory, args_offset, index)?; + let offset = state.expect_constrained_usize(cx, offset, reason)?; + let len_offset = args_offset.checked_add(offset).ok_or(SymbolicError::Unsupported(reason))?; + let len = state.memory.load_word(cx, len_offset)?; + let len = state.expect_constrained_usize(cx, len, reason)?; + if len > max_len { + return Err(SymbolicError::Unsupported(reason)); + } + let data_offset = len_offset.checked_add(32).ok_or(SymbolicError::Unsupported(reason))?; + Ok(state.memory.read_bytes(cx, data_offset, len)) +} + +pub(crate) fn read_abi_symbolic_dynamic_byte_exprs_arg( + cx: &mut SymCx, + state: &PathState, + args_offset: usize, + index: usize, + max_len: usize, + reason: &'static str, +) -> Result, SymbolicError> { + Ok(read_abi_symbolic_dynamic_bytes_arg(cx, state, args_offset, index, max_len, reason)? + .materialize(cx)) +} + +pub(crate) fn read_abi_dynamic_return_data_arg( + cx: &mut SymCx, + state: &PathState, + args_offset: usize, + index: usize, + max_len: usize, + reason: &'static str, +) -> Result { + let bytes = + read_abi_symbolic_dynamic_bytes_arg(cx, state, args_offset, index, max_len, reason)?; + Ok(SymReturnData::from_bytes(cx, bytes)) +} + +pub(crate) fn read_abi_symbolic_dynamic_bytes_array_arg( + cx: &mut SymCx, + state: &PathState, + args_offset: usize, + index: usize, + max_array_len: usize, + max_bytes_len: usize, +) -> Result, SymbolicError> { + let offset = read_abi_word_arg(cx, &state.memory, args_offset, index)?; + let offset = + state.expect_constrained_usize(cx, offset, "symbolic vm.mockCalls returns offset")?; + let array_offset = args_offset + .checked_add(offset) + .ok_or(SymbolicError::Unsupported("symbolic vm.mockCalls returns offset"))?; + let array_data_offset = array_offset + .checked_add(32) + .ok_or(SymbolicError::Unsupported("symbolic vm.mockCalls returns offset"))?; + let len = state.memory.load_word(cx, array_offset)?; + let len = state.expect_constrained_usize(cx, len, "symbolic vm.mockCalls returns length")?; + if len > max_array_len { + return Err(SymbolicError::Unsupported("symbolic vm.mockCalls returns length")); + } + + let mut values = Vec::with_capacity(len); + for value_idx in 0..len { + let head_offset = array_offset + .checked_add(32) + .and_then(|offset| offset.checked_add(value_idx.saturating_mul(32))) + .ok_or(SymbolicError::Unsupported("symbolic vm.mockCalls returns element offset"))?; + let value_offset = state.memory.load_word(cx, head_offset)?; + let value_offset = state.expect_constrained_usize( + cx, + value_offset, + "symbolic vm.mockCalls returns element offset", + )?; + let len_offset = array_data_offset + .checked_add(value_offset) + .ok_or(SymbolicError::Unsupported("symbolic vm.mockCalls returns element offset"))?; + let value_len = state.memory.load_word(cx, len_offset)?; + let value_len = state.expect_constrained_usize( + cx, + value_len, + "symbolic vm.mockCalls returns element length", + )?; + if value_len > max_bytes_len { + return Err(SymbolicError::Unsupported("symbolic vm.mockCalls returns element length")); + } + let data_offset = len_offset + .checked_add(32) + .ok_or(SymbolicError::Unsupported("symbolic vm.mockCalls returns element offset"))?; + let bytes = state.memory.read_bytes(cx, data_offset, value_len); + values.push(SymReturnData::from_bytes(cx, bytes)); + } + + Ok(values) +} + +pub(crate) fn read_abi_string_arg( + cx: &mut SymCx, + memory: &SymMemory, + args_offset: usize, + index: usize, + reason: &'static str, +) -> Result { + String::from_utf8(read_abi_dynamic_bytes_arg(cx, memory, args_offset, index, reason)?) + .map_err(|_| SymbolicError::Unsupported(reason)) +} + +pub(crate) fn decode_cheatcode_args( + cx: &mut SymCx, + state: &PathState, + selector: [u8; 4], + in_offset: usize, + in_size: usize, +) -> Result, SymbolicError> { + let data = state.memory.read_concrete(cx, in_offset + 4, in_size.saturating_sub(4))?; + let value = DynSolType::Tuple(vm_params(selector)) + .abi_decode_sequence(&data) + .map_err(|_| SymbolicError::Unsupported("symbolic cheatcode ABI decode"))?; + let DynSolValue::Tuple(values) = value else { + return Err(SymbolicError::Unsupported("symbolic cheatcode ABI decode")); + }; + Ok(values) +} + +/// Returns the parameter types of a `Vm` cheatcode from its generated ABI signature. +pub(crate) fn vm_params(selector: [u8; 4]) -> Vec { + let signature = VmCalls::signature_by_selector(selector).unwrap_or_default(); + let params = signature.find('(').map_or("()", |start| &signature[start..]); + match DynSolType::parse(params) { + Ok(DynSolType::Tuple(params)) => params, + _ => Vec::new(), + } +} + +pub(crate) const fn is_full_word_array_assertion(selector: [u8; 4]) -> bool { + matches!( + selector, + assertEq_16Call::SELECTOR + | assertEq_18Call::SELECTOR + | assertEq_22Call::SELECTOR + | assertNotEq_16Call::SELECTOR + | assertNotEq_18Call::SELECTOR + | assertNotEq_22Call::SELECTOR + ) +} + +pub(crate) fn dyn_string(value: &DynSolValue) -> Result { + match value { + DynSolValue::String(value) => Ok(value.clone()), + _ => Err(SymbolicError::Unsupported("symbolic cheatcode ABI decode")), + } +} + +pub(crate) fn dyn_bytes(value: &DynSolValue) -> Result, SymbolicError> { + match value { + DynSolValue::Bytes(value) => Ok(value.clone()), + _ => Err(SymbolicError::Unsupported("symbolic cheatcode ABI decode")), + } +} + +pub(crate) const fn dyn_bool(value: &DynSolValue) -> Result { + match value { + DynSolValue::Bool(value) => Ok(*value), + _ => Err(SymbolicError::Unsupported("symbolic cheatcode ABI decode")), + } +} + +pub(crate) const fn dyn_address(value: &DynSolValue) -> Result { + match value { + DynSolValue::Address(value) => Ok(*value), + _ => Err(SymbolicError::Unsupported("symbolic cheatcode ABI decode")), + } +} + +pub(crate) fn dyn_potential_revert( + cx: &mut SymCx, + value: &DynSolValue, +) -> Result { + let DynSolValue::Tuple(values) = value else { + return Err(SymbolicError::Unsupported("symbolic vm.assumeNoRevert decode")); + }; + let [reverter, partial_match, revert_data] = values.as_slice() else { + return Err(SymbolicError::Unsupported("symbolic vm.assumeNoRevert decode")); + }; + + let reverter = dyn_address(reverter)?; + let reverter = (!reverter.is_zero()).then(|| SymExpr::constant(cx, address_word(reverter))); + let revert_data = SymBytes::concrete(cx, dyn_bytes(revert_data)?); + let data = if dyn_bool(partial_match)? { + ExpectedRevertData::Prefix(revert_data) + } else { + ExpectedRevertData::Exact(revert_data) + }; + Ok(ExpectedRevert::new(data, reverter, 1)) +} + +pub(crate) fn dyn_potential_reverts( + cx: &mut SymCx, + value: &DynSolValue, +) -> Result, SymbolicError> { + let DynSolValue::Array(values) = value else { + return Err(SymbolicError::Unsupported("symbolic vm.assumeNoRevert decode")); + }; + values.iter().map(|value| dyn_potential_revert(cx, value)).collect() +} + +pub(crate) fn dyn_address_array(value: &DynSolValue) -> Result, SymbolicError> { + let DynSolValue::Array(values) = value else { + return Err(SymbolicError::Unsupported("symbolic cheatcode ABI decode")); + }; + values.iter().map(dyn_address).collect() +} + +pub(crate) fn dyn_bytes32_array(value: &DynSolValue) -> Result, SymbolicError> { + let DynSolValue::Array(values) = value else { + return Err(SymbolicError::Unsupported("symbolic cheatcode ABI decode")); + }; + values + .iter() + .map(|value| match value { + DynSolValue::FixedBytes(bytes, 32) => Ok(*bytes), + _ => Err(SymbolicError::Unsupported("symbolic cheatcode ABI decode")), + }) + .collect() +} + +pub(crate) fn dyn_string_array(value: &DynSolValue) -> Result, SymbolicError> { + let DynSolValue::Array(values) = value else { + return Err(SymbolicError::Unsupported("symbolic cheatcode ABI decode")); + }; + values.iter().map(dyn_string).collect() +} + +pub(crate) fn parse_env_array( + value: &str, + delimiter: &str, + ty: &DynSolType, +) -> Result { + if delimiter.is_empty() { + return Err(SymbolicError::Unsupported("symbolic env delimiter")); + } + value + .split(delimiter) + .map(|value| parse_env_value(value, ty)) + .collect::, _>>() + .map(DynSolValue::Array) +} + +pub(crate) fn parse_env_value(value: &str, ty: &DynSolType) -> Result { + match ty { + DynSolType::Bool => Ok(DynSolValue::Bool(parse_env_bool(value)?)), + DynSolType::Uint(256) => Ok(DynSolValue::Uint(parse_env_uint(value)?, 256)), + DynSolType::Int(256) => Ok(DynSolValue::Int(I256::from_raw(parse_env_int(value)?), 256)), + DynSolType::Address => Ok(DynSolValue::Address(parse_env_address(value)?)), + DynSolType::FixedBytes(32) => { + Ok(DynSolValue::FixedBytes(B256::from(parse_env_bytes32(value)?), 32)) + } + DynSolType::String => Ok(DynSolValue::String(value.to_string())), + DynSolType::Bytes => Ok(DynSolValue::Bytes(parse_env_bytes(value)?)), + _ => Err(SymbolicError::Unsupported("symbolic env type")), + } +} + +pub(crate) fn parse_env_uint(value: &str) -> Result { + value.parse::().map_err(|_| SymbolicError::Unsupported("symbolic env uint parse")) +} + +pub(crate) fn parse_env_int(value: &str) -> Result { + if let Some(value) = value.strip_prefix('-') { + let magnitude = parse_env_uint(value)?; + Ok(U256::ZERO.wrapping_sub(magnitude)) + } else { + parse_env_uint(value) + } +} + +pub(crate) fn parse_env_bool(value: &str) -> Result { + match value { + "true" | "1" | "TRUE" | "True" => Ok(true), + "false" | "0" | "FALSE" | "False" => Ok(false), + _ => Err(SymbolicError::Unsupported("symbolic env bool parse")), + } +} + +pub(crate) fn parse_env_bytes(value: &str) -> Result, SymbolicError> { + let value = value.strip_prefix("0x").unwrap_or(value); + hex::decode(value).map_err(|_| SymbolicError::Unsupported("symbolic env bytes parse")) +} + +pub(crate) fn parse_env_bytes32(value: &str) -> Result { + let bytes = parse_env_bytes(value)?; + if bytes.len() != 32 { + return Err(SymbolicError::Unsupported("symbolic env bytes32 parse")); + } + Ok(U256::from_be_slice(&bytes)) +} + +pub(crate) fn parse_env_address(value: &str) -> Result { + value.parse::
().map_err(|_| SymbolicError::Unsupported("symbolic env address parse")) +} + +pub(crate) fn private_key_signer(private_key: U256) -> Result { + private_key_from_u256(private_key) + .map_err(|_| SymbolicError::Unsupported("symbolic private key parse")) +} + +pub(crate) fn private_key_address(private_key: U256) -> Result { + Ok(private_key_signer(private_key)?.address()) +} + +pub(crate) fn sign_hash_words( + cx: &mut SymCx, + private_key: U256, + digest: U256, +) -> Result, SymbolicError> { + let signer = private_key_signer(private_key)?; + let digest = B256::from(digest); + let sig = signer + .sign_hash_sync(&digest) + .map_err(|_| SymbolicError::Unsupported("symbolic vm.sign"))?; + Ok(vec![ + SymExpr::constant(cx, U256::from(sig.v_byte())), + SymExpr::constant(cx, sig.r()), + SymExpr::constant(cx, sig.s()), + ]) +} + +pub(crate) fn sign_compact_hash_words( + cx: &mut SymCx, + private_key: U256, + digest: U256, +) -> Result, SymbolicError> { + let signer = private_key_signer(private_key)?; + let digest = B256::from(digest); + let sig = signer + .sign_hash_sync(&digest) + .map_err(|_| SymbolicError::Unsupported("symbolic vm.signCompact"))?; + Ok(sig + .as_erc2098() + .as_chunks::<32>() + .0 + .iter() + .map(|word| SymExpr::constant(cx, U256::from_be_bytes(*word))) + .collect()) +} + +pub(crate) fn derive_private_key( + mnemonic: &str, + path: &str, + index: u32, +) -> Result { + foundry_common::wallet::derive_private_key::(mnemonic, path, index) + .map_err(|_| SymbolicError::Unsupported("symbolic vm.deriveKey")) +} + +pub(crate) fn derive_private_key_with_language( + mnemonic: &str, + path: &str, + index: u32, + language: &str, +) -> Result { + foundry_common::wallet::derive_private_key_with_language(mnemonic, path, index, language) + .map_err(|_| SymbolicError::Unsupported("symbolic vm.deriveKey language")) +} + +pub(crate) fn artifact_json_path(path: &str) -> PathBuf { + if path.ends_with(".json") { + return PathBuf::from(path); + } + + let mut parts = path.split(':'); + let first = parts.next().unwrap_or_default(); + let second = parts.next(); + + if first.contains('.') { + let file = Path::new(first); + let contract = second + .map(str::to_string) + .or_else(|| file.file_stem().map(|stem| stem.to_string_lossy().to_string())) + .unwrap_or_else(|| first.to_string()); + PathBuf::from("out").join(first).join(format!("{contract}.json")) + } else { + let contract = first; + PathBuf::from("out").join(format!("{contract}.sol")).join(format!("{contract}.json")) + } +} + +/// Returns fallback paths for symbolic artifact lookup. +pub(crate) fn artifact_json_fallback_paths(path: &str) -> Vec { + if path.ends_with(".json") { + return Vec::new(); + } + + let mut parts = path.split(':'); + let first = parts.next().unwrap_or_default(); + let second = parts.next(); + let Some(file_name) = first.rsplit(['/', '\\']).find(|part| !part.is_empty()) else { + return Vec::new(); + }; + + if !first.contains('/') && !first.contains('\\') { + return Vec::new(); + } + + let contract = second.map(str::to_string).unwrap_or_else(|| { + file_name.rsplit_once('.').map(|(stem, _)| stem).unwrap_or(file_name).to_string() + }); + vec![PathBuf::from("out").join(file_name).join(format!("{contract}.json"))] +} + +pub(crate) fn artifact_code(path: &str, deployed: bool) -> Result, SymbolicError> { + let mut data = None; + for path in std::iter::once(artifact_json_path(path)).chain(artifact_json_fallback_paths(path)) + { + if let Ok(contents) = std::fs::read_to_string(path) { + data = Some(contents); + break; + } + } + let data = data.ok_or(SymbolicError::Unsupported("symbolic vm.getCode artifact"))?; + let artifact: serde_json::Value = serde_json::from_str(&data) + .map_err(|_| SymbolicError::Unsupported("symbolic vm.getCode artifact"))?; + let key = if deployed { "deployedBytecode" } else { "bytecode" }; + let object = artifact + .get(key) + .and_then(|value| value.get("object").or(Some(value))) + .and_then(serde_json::Value::as_str) + .ok_or(SymbolicError::Unsupported("symbolic vm.getCode artifact"))?; + hex::decode(object).map_err(|_| SymbolicError::Unsupported("symbolic vm.getCode artifact")) +} diff --git a/crates/evm/symbolic/src/runtime/control.rs b/crates/evm/symbolic/src/runtime/control.rs new file mode 100644 index 0000000000000..6de6e9273b770 --- /dev/null +++ b/crates/evm/symbolic/src/runtime/control.rs @@ -0,0 +1,34 @@ +use super::*; + +#[derive(Clone, Copy, Debug)] +pub(crate) enum CallKind { + Call, + CallCode, + DelegateCall, + StaticCall, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CreateKind { + Create, + Create2, +} + +#[derive(Clone, Copy, Debug)] +pub(crate) enum StepOutcome { + Continue, + Forked, + Halt, + Revert, + ExceptionalHalt, + Failure, + AssumeRejected, +} + +pub(crate) enum CheatcodeOutcome { + Continue(Vec), + ContinueData(SymReturnData), + Revert(SymReturnData), + AssumeRejected, + Failure, +} diff --git a/crates/evm/symbolic/src/runtime/evm.rs b/crates/evm/symbolic/src/runtime/evm.rs new file mode 100644 index 0000000000000..375577a0900c4 --- /dev/null +++ b/crates/evm/symbolic/src/runtime/evm.rs @@ -0,0 +1,148 @@ +use super::*; +use alloy_sol_types::{ + Panic, PanicKind, Revert, SolError, + abi::{AbiDecoderConfig, decode_with_config, token::PackedSeqToken}, +}; +use foundry_evm::core::decode::ASSERTION_FAILED_PREFIX; + +pub(crate) fn exp_expr_for_concrete_exponent( + cx: &mut SymCx, + base: SymExpr, + exponent: usize, +) -> SymExpr { + if exponent == 0 { + return SymExpr::one(cx); + } + if let Some(base) = base.as_const() { + return SymExpr::constant(cx, base.wrapping_pow(U256::from(exponent))); + } + + let mut expr = base.clone(); + for _ in 1..exponent { + expr = SymExpr::binop(cx, SymBinOp::Mul, expr, base.clone()); + } + expr +} + +pub(crate) fn signextend(byte_index: U256, value: U256) -> U256 { + if byte_index >= U256::from(32) { + return value; + } + let bit_index = usize::try_from(byte_index).expect("checked byte index") * 8 + 7; + let sign_bit = U256::ONE << bit_index; + let mask = sign_bit - U256::ONE; + if (value & sign_bit).is_zero() { value & mask } else { value | !mask } +} + +pub(crate) fn signextend_word(cx: &mut SymCx, byte_index: U256, value: SymExpr) -> SymExpr { + if byte_index >= U256::from(32) { + return value; + } + if let Some(value) = value.as_const() { + return SymExpr::constant(cx, signextend(byte_index, value)); + } + let bit_index = usize::try_from(byte_index).expect("checked byte index") * 8 + 7; + let sign_bit = U256::ONE << bit_index; + let mask_value = sign_bit - U256::ONE; + let sign_bit = SymExpr::constant(cx, sign_bit); + let masked_sign = SymExpr::binop(cx, SymBinOp::And, value.clone(), sign_bit); + let zero = SymExpr::zero(cx); + let condition = SymBoolExpr::eq(cx, masked_sign, zero); + let inverse_mask = SymExpr::constant(cx, !mask_value); + let mask = SymExpr::constant(cx, mask_value); + let masked = SymExpr::binop(cx, SymBinOp::And, value.clone(), mask); + let extended = SymExpr::binop(cx, SymBinOp::Or, value, inverse_mask); + SymExpr::ite(cx, condition, masked, extended) +} + +pub(crate) fn signextend_word_dynamic( + cx: &mut SymCx, + byte_index: SymExpr, + value: SymExpr, +) -> SymExpr { + if let Some(byte_index) = byte_index.as_const() { + return signextend_word(cx, byte_index, value); + } + + let mut result = value.clone(); + for idx in (0..31).rev() { + let idx_expr = SymExpr::constant(cx, U256::from(idx)); + let condition = SymBoolExpr::eq(cx, byte_index.clone(), idx_expr); + let value = signextend_word(cx, U256::from(idx), value.clone()); + result = SymExpr::ite(cx, condition, value, result); + } + result +} + +pub(crate) fn byte_word(cx: &mut SymCx, index: U256, word: SymExpr) -> SymExpr { + if index >= U256::from(32) { + return SymExpr::zero(cx); + } + let index = usize::try_from(index).expect("checked byte index"); + if let Some(word) = word.as_const() { + SymExpr::constant(cx, U256::from(word.to_be_bytes::<32>()[index])) + } else { + byte_expr(cx, index, &word) + } +} + +pub(crate) fn byte_word_dynamic(cx: &mut SymCx, index: SymExpr, word: SymExpr) -> SymExpr { + if let Some(index) = index.as_const() { + return byte_word(cx, index, word); + } + + let mut result = SymExpr::zero(cx); + if let Some(word) = word.as_const() { + let bytes = word.to_be_bytes::<32>(); + for idx in (0..32).rev() { + let idx_expr = SymExpr::constant(cx, U256::from(idx)); + let condition = SymBoolExpr::eq(cx, index.clone(), idx_expr); + let byte = SymExpr::constant(cx, U256::from(bytes[idx])); + result = SymExpr::ite(cx, condition, byte, result); + } + } else { + for idx in (0..32).rev() { + let idx_expr = SymExpr::constant(cx, U256::from(idx)); + let condition = SymBoolExpr::eq(cx, index.clone(), idx_expr); + let byte = byte_expr(cx, idx, &word); + result = SymExpr::ite(cx, condition, byte, result); + } + } + result +} + +/// Returns the byte extraction expression for a symbolic word. +pub(crate) fn byte_expr(cx: &mut SymCx, index: usize, expr: &SymExpr) -> SymExpr { + debug_assert!(index < 32); + if let Some(byte) = expr.known_byte(index) { + return SymExpr::constant(cx, U256::from(byte)); + } + expr.extracted_byte(cx, index) +} + +pub(crate) fn shift_left(cx: &mut SymCx, value: SymExpr, bits: usize) -> SymExpr { + if let Some(value) = value.as_const() { + SymExpr::constant(cx, value << bits) + } else { + let bits = SymExpr::constant(cx, U256::from(bits)); + SymExpr::binop(cx, SymBinOp::Shl, value, bits) + } +} + +pub(crate) fn is_assertion_revert(data: &[u8]) -> bool { + Panic::abi_decode(data).is_ok_and(|panic| panic.kind() == Some(PanicKind::Assert)) + || is_revert_assertion_failure(data) +} + +pub(crate) fn is_revert_assertion_failure(data: &[u8]) -> bool { + if data.len() < ERROR_DATA_MIN_LEN { + return false; + } + let Some(data) = data.strip_prefix(&Revert::SELECTOR) else { return false }; + // Decode a borrowed token to preserve strict UTF-8 checks without allocating a String. + let config = AbiDecoderConfig::new().memory_limit(data.len()); + decode_with_config::>(data, config) + .ok() + .and_then(|message| std::str::from_utf8(message.0).ok()) + .is_some_and(|message| message.contains(ASSERTION_FAILED_PREFIX)) +} diff --git a/crates/evm/symbolic/src/runtime/expr/bool.rs b/crates/evm/symbolic/src/runtime/expr/bool.rs new file mode 100644 index 0000000000000..b8033ba12fe60 --- /dev/null +++ b/crates/evm/symbolic/src/runtime/expr/bool.rs @@ -0,0 +1,916 @@ +use super::{hashcons::HashConsed, *}; +use foundry_evm::revm::interpreter::instructions::i256::i256_cmp; + +/// Bounds both the number of distinct word nodes inspected by constant-ITE equality expansion and +/// the unfolded size of the Boolean expression it could produce. +const MAX_CONSTANT_ITE_EQ_NODES: usize = 128; +const MAX_CONSTANT_ITE_EQ_UNFOLDED_NODES: usize = 8 * 1024; + +#[derive(Clone, PartialEq, Eq, Hash)] +pub(crate) struct SymBoolExpr { + pub(in crate::runtime::expr) kind: HashConsed, +} + +impl fmt::Debug for SymBoolExpr { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.kind().fmt(f) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(in crate::runtime) enum SymBoolExprKind { + Const(bool), + Not(SymBoolExpr), + And(Arc<[SymBoolExpr]>), + Cmp(SymCmpOp, SymExpr, SymExpr), +} + +impl SymBoolExpr { + #[inline] + pub(in crate::runtime) fn stable_hash_cmp(&self, other: &Self) -> std::cmp::Ordering { + self.kind.stable_hash_cmp(&other.kind) + } + + pub(in crate::runtime) fn kind(&self) -> &SymBoolExprKind { + self.kind.value() + } + + pub(in crate::runtime) fn from_kind(cx: &mut SymCx, kind: SymBoolExprKind) -> Self { + cx.mk_bool_kind(kind) + } + + pub(crate) fn constant(cx: &mut SymCx, value: bool) -> Self { + cx.cached_bool(value) + } + + pub(crate) fn cmp_word_const( + cx: &mut SymCx, + op: SymCmpOp, + word: &SymExpr, + value: U256, + ) -> Self { + if let Some(word) = word.as_const() { + Self::constant(cx, op.eval(word, value)) + } else { + let value = SymExpr::constant(cx, value); + Self::cmp_word_expr(cx, op, word, value) + } + } + + pub(crate) fn eq_word_const(cx: &mut SymCx, word: &SymExpr, value: U256) -> Self { + if let Some(word) = word.as_const() { + Self::constant(cx, word == value) + } else { + let value = SymExpr::constant(cx, value); + Self::eq(cx, word.clone(), value) + } + } + + pub(crate) fn eq(cx: &mut SymCx, left: SymExpr, right: SymExpr) -> Self { + Self::cmp(cx, SymCmpOp::Eq, left, right) + } + + pub(crate) fn cmp(cx: &mut SymCx, op: SymCmpOp, left: SymExpr, right: SymExpr) -> Self { + if let ( + SymExprKind::Ite(left_condition, left_then, left_else), + SymExprKind::Ite(right_condition, right_then, right_else), + ) = (left.kind(), right.kind()) + && left_condition == right_condition + && let (Some(left_then), Some(left_else), Some(right_then), Some(right_else)) = ( + left_then.as_const(), + left_else.as_const(), + right_then.as_const(), + right_else.as_const(), + ) + { + // Compare aligned constant-arm ITEs pointwise without expanding either expression. + return match (op.eval(left_then, right_then), op.eval(left_else, right_else)) { + (true, true) => Self::constant(cx, true), + (false, false) => Self::constant(cx, false), + (true, false) => left_condition.clone(), + (false, true) => Self::not_bool(cx, left_condition.clone()), + }; + } + + match op { + SymCmpOp::Eq => { + if let Some(condition) = Self::ite_eq_arm(cx, &left, &right) + .or_else(|| Self::ite_eq_arm(cx, &right, &left)) + { + return condition; + } + match (left.kind(), right.kind()) { + // `a == a => true`. + _ if left == right => Self::constant(cx, true), + (SymExprKind::Const(left), SymExprKind::Const(right)) => { + // `const == const => const`. + Self::constant(cx, left == right) + } + (_, SymExprKind::Const(right_value)) => { + if let Some(condition) = Self::bool_word_eq_const(cx, &left, *right_value) { + return condition; + } + if let Some(left_value) = left.known_word() { + // `known(a) == const => const`. + return Self::constant(cx, left_value == *right_value); + } + // `a == b => ordered(a, b)`. + let (left, right) = SymExpr::ordered_commutative_operands(left, right); + Self::from_kind(cx, SymBoolExprKind::Cmp(SymCmpOp::Eq, left, right)) + } + (SymExprKind::Const(left_value), _) => { + if let Some(condition) = Self::bool_word_eq_const(cx, &right, *left_value) { + return condition; + } + if let Some(right_value) = right.known_word() { + // `const == known(a) => const`. + return Self::constant(cx, *left_value == right_value); + } + // `a == b => ordered(a, b)`. + let (left, right) = SymExpr::ordered_commutative_operands(left, right); + Self::from_kind(cx, SymBoolExprKind::Cmp(SymCmpOp::Eq, left, right)) + } + ( + SymExprKind::Keccak { len: left_len, bytes: left_bytes, .. }, + SymExprKind::Keccak { len: right_len, bytes: right_bytes, .. }, + ) if left_bytes.len() == right_bytes.len() => { + // `keccak(a) == keccak(b) => len(a) == len(b) && bytes(a) == bytes(b)`. + let mut conditions = + vec![Self::eq(cx, left_len.clone(), right_len.clone())]; + conditions.extend( + left_bytes + .iter() + .cloned() + .zip(right_bytes.iter().cloned()) + .map(|(left, right)| Self::eq(cx, left, right)), + ); + Self::and(cx, conditions) + } + ( + SymExprKind::Hash { algorithm: left_algorithm, bytes: left_bytes, .. }, + SymExprKind::Hash { + algorithm: right_algorithm, bytes: right_bytes, .. + }, + ) if left_algorithm == right_algorithm + && left_bytes.len() == right_bytes.len() => + { + // `hash(a) == hash(b) => bytes(a) == bytes(b)`. + let conditions = left_bytes + .iter() + .cloned() + .zip(right_bytes.iter().cloned()) + .map(|(left, right)| Self::eq(cx, left, right)) + .collect(); + Self::and(cx, conditions) + } + _ => { + // `a == b => ordered(a, b)`. + let (left, right) = SymExpr::ordered_commutative_operands(left, right); + Self::from_kind(cx, SymBoolExprKind::Cmp(SymCmpOp::Eq, left, right)) + } + } + } + SymCmpOp::Ult => match (left.kind(), right.kind()) { + // `a < a => false`. + _ if left == right => Self::constant(cx, false), + (SymExprKind::Const(left), SymExprKind::Const(right)) => { + // `const < const => const`. + Self::constant(cx, op.eval(*left, *right)) + } + // `a < 0 => false`. + (_, SymExprKind::Const(value)) if value.is_zero() => Self::constant(cx, false), + // `MAX < a => false`. + (SymExprKind::Const(value), _) if *value == U256::MAX => Self::constant(cx, false), + // `a < a & low_mask => false`. + _ if low_masked_source_any(&right) == Some(&left) => Self::constant(cx, false), + _ => Self::from_kind(cx, SymBoolExprKind::Cmp(op, left, right)), + }, + SymCmpOp::Ugt => match (left.kind(), right.kind()) { + // `a > a => false`. + _ if left == right => Self::constant(cx, false), + (SymExprKind::Const(left), SymExprKind::Const(right)) => { + // `const > const => const`. + Self::constant(cx, op.eval(*left, *right)) + } + // `0 > a => false`. + (SymExprKind::Const(value), _) if value.is_zero() => Self::constant(cx, false), + // `a > MAX => false`. + (_, SymExprKind::Const(value)) if *value == U256::MAX => Self::constant(cx, false), + // `a & low_mask > a => false`. + _ if low_masked_source_any(&left) == Some(&right) => Self::constant(cx, false), + _ => Self::from_kind(cx, SymBoolExprKind::Cmp(op, left, right)), + }, + SymCmpOp::Ule => match (left.kind(), right.kind()) { + // `a <= a => true`. + _ if left == right => Self::constant(cx, true), + (SymExprKind::Const(left), SymExprKind::Const(right)) => { + // `const <= const => const`. + Self::constant(cx, op.eval(*left, *right)) + } + // `0 <= a => true`. + (SymExprKind::Const(value), _) if value.is_zero() => Self::constant(cx, true), + // `a <= MAX => true`. + (_, SymExprKind::Const(value)) if *value == U256::MAX => Self::constant(cx, true), + // `a & low_mask <= a => true`. + _ if low_masked_source_any(&left) == Some(&right) => Self::constant(cx, true), + _ => Self::from_kind(cx, SymBoolExprKind::Cmp(op, left, right)), + }, + SymCmpOp::Uge => match (left.kind(), right.kind()) { + // `a >= a => true`. + _ if left == right => Self::constant(cx, true), + (SymExprKind::Const(left), SymExprKind::Const(right)) => { + // `const >= const => const`. + Self::constant(cx, op.eval(*left, *right)) + } + // `a >= 0 => true`. + (_, SymExprKind::Const(value)) if value.is_zero() => Self::constant(cx, true), + // `MAX >= a => true`. + (SymExprKind::Const(value), _) if *value == U256::MAX => Self::constant(cx, true), + // `a >= a & low_mask => true`. + _ if low_masked_source_any(&right) == Some(&left) => Self::constant(cx, true), + _ => Self::from_kind(cx, SymBoolExprKind::Cmp(op, left, right)), + }, + SymCmpOp::Slt | SymCmpOp::Sgt => match (left.kind(), right.kind()) { + // `a false`, `a >s a => false`. + _ if left == right => Self::constant(cx, false), + (SymExprKind::Const(left), SymExprKind::Const(right)) => { + // `const const`. + Self::constant(cx, op.eval(*left, *right)) + } + _ => Self::from_kind(cx, SymBoolExprKind::Cmp(op, left, right)), + }, + } + } + + pub(crate) fn and(cx: &mut SymCx, values: Vec) -> Self { + let mut out = Vec::new(); + for value in values { + match value.kind() { + // `true && a => a`. + SymBoolExprKind::Const(true) => {} + // `false && a => false`. + SymBoolExprKind::Const(false) => return Self::constant(cx, false), + // `(a && b) && c => a && b && c`. + SymBoolExprKind::And(values) => out.extend(values.iter().cloned()), + _ => out.push(value), + } + } + if out.is_empty() { + // `and() => true`. + Self::constant(cx, true) + } else if out.len() == 1 { + // `and(a) => a`. + out.pop().expect("single item exists") + } else { + Self::from_kind(cx, SymBoolExprKind::And(out.into())) + } + } + + pub(crate) fn or(cx: &mut SymCx, values: Vec) -> Self { + let mut out = Vec::new(); + for value in values { + match value.kind() { + // `false || a => a`. + SymBoolExprKind::Const(false) => {} + // `true || a => true`. + SymBoolExprKind::Const(true) => return Self::constant(cx, true), + _ => out.push(value), + } + } + if out.is_empty() { + // `or() => false`. + Self::constant(cx, false) + } else if out.len() == 1 { + // `or(a) => a`. + out.pop().expect("single item exists") + } else { + // `a || b => !(!a && !b)`. + let values = out.into_iter().map(|value| Self::not_bool(cx, value)).collect(); + let and = Self::and(cx, values); + Self::not_bool(cx, and) + } + } + + pub(crate) fn not_bool(cx: &mut SymCx, value: Self) -> Self { + match value.kind() { + // `!const => const`. + SymBoolExprKind::Const(value) => Self::constant(cx, !*value), + // `!!a => a`. + SymBoolExprKind::Not(value) => value.clone(), + _ => Self::from_kind(cx, SymBoolExprKind::Not(value)), + } + } + + fn bool_word_eq_const(cx: &mut SymCx, word: &SymExpr, value: U256) -> Option { + let SymExprKind::Ite(condition, then_expr, else_expr) = word.kind() else { return None }; + match (then_expr.as_const(), else_expr.as_const()) { + (Some(then_value), Some(else_value)) + if then_value == U256::ONE && else_value.is_zero() => + { + Some(if value.is_zero() { + Self::not_bool(cx, condition.clone()) + } else if value == U256::ONE { + condition.clone() + } else { + Self::constant(cx, false) + }) + } + (Some(then_value), Some(else_value)) + if then_value.is_zero() && else_value == U256::ONE => + { + Some(if value.is_zero() { + condition.clone() + } else if value == U256::ONE { + Self::not_bool(cx, condition.clone()) + } else { + Self::constant(cx, false) + }) + } + _ => None, + } + } + + fn ite_eq_arm(cx: &mut SymCx, conditional: &SymExpr, expected: &SymExpr) -> Option { + let SymExprKind::Ite(condition, then_expr, else_expr) = conditional.kind() else { + return None; + }; + if then_expr == expected { + let else_matches = Self::eq(cx, else_expr.clone(), expected.clone()); + return Some(Self::or(cx, vec![condition.clone(), else_matches])); + } + if else_expr == expected { + let then_matches = Self::eq(cx, then_expr.clone(), expected.clone()); + let condition = Self::not_bool(cx, condition.clone()); + return Some(Self::or(cx, vec![condition, then_matches])); + } + if let Some(expected_value) = expected.as_const() { + let mut cost_cache = HashMap::default(); + let mut remaining = MAX_CONSTANT_ITE_EQ_NODES; + if Self::constant_ite_eq_unfolded_nodes(conditional, &mut cost_cache, &mut remaining) + .is_none() + { + let (left, right) = + SymExpr::ordered_commutative_operands(conditional.clone(), expected.clone()); + return Some(Self::from_kind(cx, SymBoolExprKind::Cmp(SymCmpOp::Eq, left, right))); + } + + let mut cache = HashMap::default(); + let mut remaining = MAX_CONSTANT_ITE_EQ_NODES; + return Self::constant_ite_eq( + cx, + conditional, + expected_value, + &mut cache, + &mut remaining, + ); + } + None + } + + /// Computes a conservative upper bound for the unfolded Boolean result without interning it. + /// + /// Cached child costs are deliberately added again at each use. This keeps construction of a + /// small shared word DAG from creating a Boolean DAG that becomes exponential when a later + /// consumer traverses occurrences instead of identities. + fn constant_ite_eq_unfolded_nodes( + expr: &SymExpr, + cache: &mut HashMap>, + remaining: &mut usize, + ) -> Option { + if let Some(cached) = cache.get(expr) { + return *cached; + } + if *remaining == 0 { + cache.insert(expr.clone(), None); + return None; + } + *remaining -= 1; + + let result = match expr.kind() { + SymExprKind::Const(_) => Some(1), + SymExprKind::Ite(condition, then_expr, else_expr) => { + let then_nodes = Self::constant_ite_eq_unfolded_nodes(then_expr, cache, remaining)?; + let else_nodes = Self::constant_ite_eq_unfolded_nodes(else_expr, cache, remaining)?; + + let mut pending = vec![condition]; + let mut condition_nodes = 0usize; + while let Some(condition) = pending.pop() { + condition_nodes = condition_nodes.checked_add(1)?; + if condition_nodes > MAX_CONSTANT_ITE_EQ_UNFOLDED_NODES { + return None; + } + match condition.kind() { + SymBoolExprKind::Not(value) => pending.push(value), + SymBoolExprKind::And(values) => pending.extend(values.iter()), + SymBoolExprKind::Const(_) | SymBoolExprKind::Cmp(_, _, _) => {} + } + } + + // Two selected branches and the `or` encoding add at most seven Boolean wrapper + // occurrences around both children and two occurrences of the condition. + then_nodes + .checked_add(else_nodes) + .and_then(|nodes| nodes.checked_add(2 * condition_nodes)) + .and_then(|nodes| nodes.checked_add(7)) + .filter(|nodes| *nodes <= MAX_CONSTANT_ITE_EQ_UNFOLDED_NODES) + } + _ => None, + }; + cache.insert(expr.clone(), result); + result + } + + fn constant_ite_eq( + cx: &mut SymCx, + expr: &SymExpr, + expected: U256, + cache: &mut HashMap>, + remaining: &mut usize, + ) -> Option { + if let Some(cached) = cache.get(expr) { + return cached.clone(); + } + if *remaining == 0 { + cache.insert(expr.clone(), None); + return None; + } + *remaining -= 1; + + let result = match expr.kind() { + SymExprKind::Const(value) => Some(Self::constant(cx, *value == expected)), + SymExprKind::Ite(condition, then_expr, else_expr) => { + let condition = condition.clone(); + let then_matches = Self::constant_ite_eq(cx, then_expr, expected, cache, remaining); + let else_matches = Self::constant_ite_eq(cx, else_expr, expected, cache, remaining); + match (then_matches, else_matches) { + (Some(then_matches), Some(else_matches)) => { + let then_selected = + Self::and_ite_branch(cx, condition.clone(), then_matches); + let condition = Self::not_bool(cx, condition); + let else_selected = Self::and_ite_branch(cx, condition, else_matches); + Some(Self::or(cx, vec![then_selected, else_selected])) + } + _ => None, + } + } + _ => None, + }; + cache.insert(expr.clone(), result.clone()); + result + } + + /// Builds one selected ITE branch without flattening a cached child conjunction. + /// + /// Flattening here makes a linear ITE chain retain conjunctions of lengths `1..n` in the + /// per-call cache. Keeping this pair binary preserves the shared DAG and leaves flattening to + /// solver constraint normalization, where only the final expression is expanded. + fn and_ite_branch(cx: &mut SymCx, condition: Self, branch: Self) -> Self { + match (condition.as_const(), branch.as_const()) { + (Some(false), _) | (_, Some(false)) => Self::constant(cx, false), + (Some(true), _) => branch, + (_, Some(true)) => condition, + _ if condition == branch => condition, + _ => Self::from_kind(cx, SymBoolExprKind::And(vec![condition, branch].into())), + } + } + + pub(crate) fn as_const(&self) -> Option { + match self.kind() { + SymBoolExprKind::Const(value) => Some(*value), + _ => None, + } + } + + pub(in crate::runtime) fn zero_check_operand(&self) -> Option<&SymExpr> { + match self.kind() { + SymBoolExprKind::Cmp(SymCmpOp::Eq, left, right) + if right.as_const().is_some_and(|value| value.is_zero()) => + { + Some(left) + } + _ => None, + } + } + + pub(crate) fn contains_keccak(&self) -> bool { + self.visit_bool(|expr| matches!(expr.kind(), SymExprKind::Keccak { .. })) + } + + pub(crate) fn contains_gasleft(&self) -> bool { + self.visit_bool(|expr| expr.is_raw_gasleft()) + } + + pub(crate) fn contains_udiv(&self) -> bool { + self.visit_bool(|expr| expr.contains_udiv()) + } + + pub(crate) fn implies_unsigned_less_or_equal( + &self, + expected: bool, + left: &SymExpr, + right: &SymExpr, + remaining: &mut usize, + ) -> bool { + if left == right { + return true; + } + let Some(next) = remaining.checked_sub(1) else { return false }; + *remaining = next; + + match self.kind() { + SymBoolExprKind::Not(value) => { + value.implies_unsigned_less_or_equal(!expected, left, right, remaining) + } + SymBoolExprKind::And(values) if expected => values + .iter() + .any(|value| value.implies_unsigned_less_or_equal(true, left, right, remaining)), + SymBoolExprKind::Cmp(op, fact_left, fact_right) => match (*op, expected) { + (SymCmpOp::Ult | SymCmpOp::Ule, true) => fact_left == left && fact_right == right, + (SymCmpOp::Uge | SymCmpOp::Ugt, true) => fact_right == left && fact_left == right, + (SymCmpOp::Ult | SymCmpOp::Ule, false) => fact_right == left && fact_left == right, + (SymCmpOp::Uge | SymCmpOp::Ugt, false) => fact_left == left && fact_right == right, + (SymCmpOp::Eq, true) => { + (fact_left == left && fact_right == right) + || (fact_right == left && fact_left == right) + } + (SymCmpOp::Eq | SymCmpOp::Slt | SymCmpOp::Sgt, _) => false, + }, + SymBoolExprKind::Const(_) | SymBoolExprKind::And(_) => false, + } + } + + pub(crate) fn forces_expr_const_with_context( + &self, + expr: &SymExpr, + context: &[Self], + ) -> Option { + match self.kind() { + SymBoolExprKind::Cmp(SymCmpOp::Eq, left, right) => match right.kind() { + SymExprKind::Const(value) => left.equality_forces_const(*value, expr, context), + _ => None, + }, + SymBoolExprKind::Not(value) => match value.kind() { + SymBoolExprKind::Cmp(SymCmpOp::Eq, left, right) => match right.kind() { + SymExprKind::Const(value) if value.is_zero() => { + left.nonzero_forces_const(expr, context) + } + _ => None, + }, + SymBoolExprKind::Not(value) => value.forces_expr_const_with_context(expr, context), + _ => None, + }, + SymBoolExprKind::And(values) => { + values.iter().find_map(|value| value.forces_expr_const_with_context(expr, context)) + } + _ => None, + } + } + + pub(crate) fn upper_bound_usize(&self, expr: &SymExpr) -> Option { + match self.kind() { + SymBoolExprKind::Const(_) | SymBoolExprKind::Not(_) => None, + SymBoolExprKind::And(values) => { + let mut bound: Option = None; + for value in values.iter() { + if let Some(candidate) = value.upper_bound_usize(expr) { + bound = Some(bound.map_or(candidate, |bound| bound.min(candidate))); + } + } + bound + } + SymBoolExprKind::Cmp(op, left, right) => { + if *op == SymCmpOp::Eq { + return match (left == expr, right == expr) { + (true, _) => right.as_const().and_then(|value| usize::try_from(value).ok()), + (_, true) => left.as_const().and_then(|value| usize::try_from(value).ok()), + _ => None, + }; + } + if left == expr { + match *op { + SymCmpOp::Ult => right + .as_const() + .and_then(|bound| (!bound.is_zero()).then(|| bound - U256::ONE)) + .and_then(|value| usize::try_from(value).ok()), + SymCmpOp::Ule => { + right.as_const().and_then(|value| usize::try_from(value).ok()) + } + _ => None, + } + } else if right == expr { + match *op { + SymCmpOp::Ugt => left + .as_const() + .and_then(|bound| (!bound.is_zero()).then(|| bound - U256::ONE)) + .and_then(|value| usize::try_from(value).ok()), + SymCmpOp::Uge => { + left.as_const().and_then(|value| usize::try_from(value).ok()) + } + _ => None, + } + } else { + None + } + } + } + } + + pub(crate) fn eval_model( + &self, + model: &M, + ) -> Result { + ModelEvaluator::new(model).eval_bool(self) + } + + pub(crate) fn eval_model_if_complete( + &self, + model: &M, + ) -> Result, SymbolicError> { + let mut vars = SymbolicVars::default(); + self.collect_eval_vars(&mut vars); + if vars.iter().copied().all(|var| model.contains_name(var)) { + self.eval_model(model).map(Some) + } else { + Ok(None) + } + } + + /// Visits all word expressions contained in this boolean expression. + pub(crate) fn visit_exprs( + &self, + visitor: &mut impl FnMut(&SymExpr) -> ControlFlow, + ) -> ControlFlow { + match self.kind() { + SymBoolExprKind::Const(_) => {} + SymBoolExprKind::Not(value) => value.visit_exprs(visitor)?, + SymBoolExprKind::And(values) => { + for value in values.iter() { + value.visit_exprs(visitor)?; + } + } + SymBoolExprKind::Cmp(_, left, right) => { + left.visit(visitor)?; + right.visit(visitor)?; + } + } + ControlFlow::Continue(()) + } + + /// Returns whether any word node satisfies `visitor`, visiting each distinct node once. + pub(crate) fn visit_bool(&self, visitor: impl FnMut(&SymExpr) -> bool) -> bool { + visit_unique(vec![self.clone()], Vec::new(), visitor) + } + + /// Rewrites each distinct Boolean node once in bottom-up order. + /// + /// The folder must return the same result for every occurrence of one hash-consed node. + pub(crate) fn fold( + &self, + cx: &mut SymCx, + folder: &mut impl FnMut(&mut SymCx, Self) -> Self, + ) -> Self { + let mut folded = HashMap::default(); + self.fold_cached(cx, folder, &mut folded) + } + + fn fold_cached<'a>( + &'a self, + cx: &mut SymCx, + folder: &mut impl FnMut(&mut SymCx, Self) -> Self, + folded: &mut HashMap<&'a Self, Self>, + ) -> Self { + if let Some(expr) = folded.get(self) { + return expr.clone(); + } + + let expr = match self.kind() { + SymBoolExprKind::Const(_) => self.clone(), + SymBoolExprKind::Not(value) => { + let value = value.fold_cached(cx, folder, folded); + Self::not_bool(cx, value) + } + SymBoolExprKind::And(values) => { + let values = + values.iter().map(|value| value.fold_cached(cx, folder, folded)).collect(); + Self::and(cx, values) + } + SymBoolExprKind::Cmp(op, left, right) => { + Self::cmp_word_expr(cx, *op, left, right.clone()) + } + }; + let expr = folder(cx, expr); + folded.insert(self, expr.clone()); + expr + } + + /// Rewrites each distinct word node once while rebuilding this Boolean expression. + /// + /// The folder must return the same result for every occurrence of one hash-consed node. + pub(crate) fn fold_exprs( + &self, + cx: &mut SymCx, + folder: &mut impl FnMut(&mut SymCx, SymExpr) -> SymExpr, + ) -> Self { + let mut folded = ExpressionFoldCache::default(); + self.fold_exprs_cached(cx, folder, &mut folded) + } + + pub(in crate::runtime::expr) fn fold_exprs_cached<'a>( + &'a self, + cx: &mut SymCx, + folder: &mut impl FnMut(&mut SymCx, SymExpr) -> SymExpr, + folded: &mut ExpressionFoldCache<'a>, + ) -> Self { + if let Some(expr) = folded.bools.get(self) { + return expr.clone(); + } + + let expr = match self.kind() { + SymBoolExprKind::Const(_) => self.clone(), + SymBoolExprKind::Not(value) => { + let value = value.fold_exprs_cached(cx, folder, folded); + Self::not_bool(cx, value) + } + SymBoolExprKind::And(values) => { + let values = values + .iter() + .map(|value| value.fold_exprs_cached(cx, folder, folded)) + .collect(); + Self::and(cx, values) + } + SymBoolExprKind::Cmp(op, left, right) => { + let left = left.fold_cached(cx, folder, folded); + let right = right.fold_cached(cx, folder, folded); + Self::cmp(cx, *op, left, right) + } + }; + folded.bools.insert(self, expr.clone()); + expr + } + + pub(crate) fn cmp_word_expr( + cx: &mut SymCx, + op: SymCmpOp, + word: &SymExpr, + expr: SymExpr, + ) -> Self { + Self::cmp(cx, op, word.clone(), expr) + } + + pub(crate) fn not(self, cx: &mut SymCx) -> Self { + Self::not_bool(cx, self) + } + + pub(crate) fn collect_vars(&self, vars: &mut SymbolicVars) { + let _ = self.visit_exprs(&mut |expr| { + if let Some(var) = expr.kind().get_var() { + vars.insert(var); + } + ControlFlow::<()>::Continue(()) + }); + } + + pub(crate) fn collect_eval_vars(&self, vars: &mut SymbolicVars) { + let _ = self.visit_exprs(&mut |expr| { + if let Some(var) = expr.kind().get_eval_var() { + vars.insert(var); + } + ControlFlow::<()>::Continue(()) + }); + } + + pub(crate) fn smt(&self, cx: &SymCx) -> String { + let mut smt = String::new(); + self.write_smt(cx, &mut smt); + smt + } + + pub(in crate::runtime::expr) fn write_smt(&self, cx: &SymCx, out: &mut String) { + match self.kind() { + SymBoolExprKind::Const(value) => out.push_str(if *value { "true" } else { "false" }), + SymBoolExprKind::Not(value) => { + out.push_str("(not "); + value.write_smt(cx, out); + out.push(')'); + } + SymBoolExprKind::And(values) => { + out.push_str("(and"); + for value in values.iter() { + out.push(' '); + value.write_smt(cx, out); + } + out.push(')'); + } + SymBoolExprKind::Cmp(op, left, right) => { + let _ = write!(out, "({} ", op.smt()); + left.write_smt(cx, out); + out.push(' '); + right.write_smt(cx, out); + out.push(')'); + } + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) enum SymCmpOp { + Eq, + Ult, + Ugt, + Ule, + Uge, + Slt, + Sgt, +} + +impl SymCmpOp { + pub(crate) const fn smt(self) -> &'static str { + match self { + Self::Eq => "=", + Self::Ult => "bvult", + Self::Ugt => "bvugt", + Self::Ule => "bvule", + Self::Uge => "bvuge", + Self::Slt => "bvslt", + Self::Sgt => "bvsgt", + } + } + + pub(crate) fn eval(self, left: U256, right: U256) -> bool { + match self { + Self::Eq => left == right, + Self::Ult => left < right, + Self::Ugt => left > right, + Self::Ule => left <= right, + Self::Uge => left >= right, + Self::Slt => i256_cmp(&left, &right).is_lt(), + Self::Sgt => i256_cmp(&left, &right).is_gt(), + } + } +} + +/// Returns whether any word node reachable from the pending expressions satisfies `visitor`. +/// +/// Expressions are hash-consed DAGs, so each distinct node is visited once. A tree walk would +/// revisit shared subexpressions, which grows exponentially along chains such as nested hashes. +pub(crate) fn visit_unique( + mut pending_bools: Vec, + mut pending_words: Vec, + mut visitor: impl FnMut(&SymExpr) -> bool, +) -> bool { + let mut visited_bools = HashSet::::default(); + let mut visited_words = HashSet::::default(); + + loop { + if let Some(expr) = pending_bools.pop() { + if !visited_bools.insert(expr.clone()) { + continue; + } + match expr.kind() { + SymBoolExprKind::Const(_) => {} + SymBoolExprKind::Not(value) => pending_bools.push(value.clone()), + SymBoolExprKind::And(values) => { + pending_bools.extend(values.iter().cloned()); + } + SymBoolExprKind::Cmp(_, left, right) => { + pending_words.push(left.clone()); + pending_words.push(right.clone()); + } + } + continue; + } + + let Some(expr) = pending_words.pop() else { return false }; + if !visited_words.insert(expr.clone()) { + continue; + } + if visitor(&expr) { + return true; + } + match expr.kind() { + SymExprKind::Const(_) | SymExprKind::Var(_) | SymExprKind::GasLeft(_) => {} + SymExprKind::Keccak { len, bytes, .. } => { + pending_words.push(len.clone()); + pending_words.extend(bytes.iter().cloned()); + } + SymExprKind::Hash { bytes, .. } => { + pending_words.extend(bytes.iter().cloned()); + } + SymExprKind::Not(value) => pending_words.push(value.clone()), + SymExprKind::BinOp(_, left, right) => { + pending_words.push(left.clone()); + pending_words.push(right.clone()); + } + SymExprKind::TernOp(_, left, right, modulus) => { + pending_words.push(left.clone()); + pending_words.push(right.clone()); + pending_words.push(modulus.clone()); + } + SymExprKind::Ite(condition, left, right) => { + pending_bools.push(condition.clone()); + pending_words.push(left.clone()); + pending_words.push(right.clone()); + } + } + } +} diff --git a/crates/evm/symbolic/src/runtime/expr/cx.rs b/crates/evm/symbolic/src/runtime/expr/cx.rs new file mode 100644 index 0000000000000..2dd056c1040a7 --- /dev/null +++ b/crates/evm/symbolic/src/runtime/expr/cx.rs @@ -0,0 +1,116 @@ +use super::{hashcons::HashCons, *}; +use alloy_primitives::map::DefaultHashBuilder; +use inturn::unsync::Interner; + +pub(crate) struct SymCx { + words: HashCons, + bools: HashCons, + bytes: HashCons, + symbols: Interner, + replayable_inputs: SymbolicVars, + concrete_keccak_preimages: HashMap>, + cache: SymCxCache, +} + +struct SymCxCache { + zero: SymExpr, + one: SymExpr, + bool_true: SymBoolExpr, + bool_false: SymBoolExpr, + bytes_empty: SymBytes, +} + +impl SymCx { + pub(crate) fn new() -> Self { + let mut words = HashCons::new(); + let zero = SymExpr { kind: words.make(SymExprKind::Const(U256::ZERO)) }; + let one = SymExpr { kind: words.make(SymExprKind::Const(U256::ONE)) }; + + let mut bools = HashCons::new(); + let bool_true = SymBoolExpr { kind: bools.make(SymBoolExprKind::Const(true)) }; + let bool_false = SymBoolExpr { kind: bools.make(SymBoolExprKind::Const(false)) }; + + let mut bytes = HashCons::new(); + let bytes_empty = SymBytes { kind: bytes.make(SymBytesKind::Concrete(Vec::new())) }; + + Self { + words, + bools, + bytes, + symbols: Interner::with_hasher(DefaultHashBuilder::default()), + replayable_inputs: SymbolicVars::default(), + concrete_keccak_preimages: HashMap::default(), + cache: SymCxCache { zero, one, bool_true, bool_false, bytes_empty }, + } + } + + pub(in crate::runtime) fn mk_expr_kind(&mut self, expr: SymExprKind) -> SymExpr { + SymExpr { kind: self.words.make(expr) } + } + + pub(in crate::runtime) fn mk_bool_kind(&mut self, expr: SymBoolExprKind) -> SymBoolExpr { + SymBoolExpr { kind: self.bools.make(expr) } + } + + pub(in crate::runtime) fn mk_bytes_kind(&mut self, bytes: SymBytesKind) -> SymBytes { + if matches!(&bytes, SymBytesKind::Concrete(bytes) if bytes.is_empty()) { + return self.cache.bytes_empty.clone(); + } + SymBytes { kind: self.bytes.make(bytes) } + } + + pub(in crate::runtime::expr) fn cached_zero(&self) -> SymExpr { + self.cache.zero.clone() + } + + pub(in crate::runtime::expr) fn cached_one(&self) -> SymExpr { + self.cache.one.clone() + } + + pub(in crate::runtime::expr) fn cached_bool(&self, value: bool) -> SymBoolExpr { + if value { self.cache.bool_true.clone() } else { self.cache.bool_false.clone() } + } + + pub(crate) fn intern(&mut self, name: &str) -> Symbol { + self.symbols.intern_mut(name) + } + + pub(crate) fn symbol_name(&self, symbol: Symbol) -> &str { + self.symbols.resolve(symbol) + } + + pub(crate) fn mark_replayable_input(&mut self, symbol: Symbol) { + self.replayable_inputs.insert(symbol); + } + + pub(crate) fn is_replayable_input(&self, symbol: Symbol) -> bool { + self.replayable_inputs.contains(&symbol) + } + + pub(in crate::runtime::expr) fn record_concrete_keccak_preimage( + &mut self, + hash: U256, + bytes: Arc<[SymExpr]>, + ) { + self.concrete_keccak_preimages.entry(hash).or_insert(bytes); + } + + pub(in crate::runtime::expr) fn concrete_keccak_preimage( + &self, + hash: U256, + ) -> Option> { + self.concrete_keccak_preimages.get(&hash).cloned() + } +} + +impl fmt::Debug for SymCx { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SymCx").finish_non_exhaustive() + } +} + +impl Default for SymCx { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/evm/symbolic/src/runtime/expr/expr.rs b/crates/evm/symbolic/src/runtime/expr/expr.rs new file mode 100644 index 0000000000000..435ed0109800e --- /dev/null +++ b/crates/evm/symbolic/src/runtime/expr/expr.rs @@ -0,0 +1,2476 @@ +use super::{hashcons::HashConsed, *}; +use foundry_evm::revm::interpreter::instructions::i256::{i256_div, i256_mod}; + +// Boolean selector recovery is an optional expression rewrite. Bound it to one word's worth of +// unique nodes so adversarial expression trees cannot make construction unbounded. +const MAX_BITWISE_BOOL_WORD_VISITS: usize = 256; +const MAX_CONSTANT_DIFFERENCE_VISITS: usize = 256; + +impl SymExpr { + pub(crate) fn select_storage_write( + self, + cx: &mut SymCx, + write_key: Self, + write_value: Self, + base: Self, + ) -> Self { + if write_value == base { + return base; + } + let condition = self.storage_key_eq(cx, &write_key); + match condition.as_const() { + Some(true) => write_value, + Some(false) => base, + None => Self::ite(cx, condition, write_value, base), + } + } + + pub(crate) fn storage_key_eq(&self, cx: &mut SymCx, write_key: &Self) -> SymBoolExpr { + if let (Some(read_root), Some(write_root)) = + (self.storage_mapping_root_slot(cx), write_key.storage_mapping_root_slot(cx)) + && read_root != write_root + { + return SymBoolExpr::constant(cx, false); + } + match (self.storage_layout_key(cx), write_key.storage_layout_key(cx)) { + (Some((read_base, read_offset)), Some((write_base, write_offset))) => { + let read_base = read_base + .storage_base_eq(cx, &write_base) + .unwrap_or_else(|| SymBoolExpr::eq(cx, read_base, write_base)); + let read_offset = SymBoolExpr::eq(cx, read_offset, write_offset); + SymBoolExpr::and(cx, vec![read_base, read_offset]) + } + (Some(_), None) if write_key.as_const().is_some() => SymBoolExpr::constant(cx, false), + (None, Some(_)) if self.as_const().is_some() => SymBoolExpr::constant(cx, false), + _ => SymBoolExpr::eq(cx, self.clone(), write_key.clone()), + } + } + + fn storage_base_eq(&self, cx: &mut SymCx, other: &Self) -> Option { + let read = self.storage_mapping_key(cx)?; + let write = other.storage_mapping_key(cx)?; + + let key_eq = storage_mapping_key_eq(cx, &read, &write); + let slot_eq = read + .slot + .storage_base_eq(cx, &write.slot) + .unwrap_or_else(|| SymBoolExpr::eq(cx, read.slot, write.slot)); + Some(SymBoolExpr::and(cx, vec![key_eq, slot_eq])) + } + + pub(crate) fn storage_mapping_key(&self, cx: &mut SymCx) -> Option { + let bytes = self.storage_mapping_key_bytes(cx)?; + let key_bytes = &bytes[..32]; + let preserve_key_bytes = (!key_bytes.iter().all(|byte| byte.as_const().is_some()) + && word_from_extracted_bytes(key_bytes).is_none()) + .then(|| key_bytes.to_vec()); + let key = Self::from_bytes(cx, key_bytes.iter().cloned()); + let slot = Self::from_bytes(cx, bytes[32..64].iter().cloned()); + Some(StorageMappingKey { key, key_bytes: preserve_key_bytes, slot }) + } + + pub(crate) fn storage_mapping_provenance_observed_with( + &self, + cx: &mut SymCx, + mut observed_preimage: impl FnMut(&Self) -> Option>, + ) -> Option { + let mut current = self.clone(); + let mut keys = Vec::new(); + let mut visited = Vec::new(); + loop { + if visited.contains(¤t) { + return None; + } + visited.push(current.clone()); + let bytes = observed_preimage(¤t)?; + if bytes.len() != 64 { + return None; + } + let key = Self::from_bytes(cx, bytes[..32].iter().cloned()); + keys.push(key); + current = Self::from_bytes(cx, bytes[32..64].iter().cloned()); + match current.kind() { + SymExprKind::Const(root_slot) if observed_preimage(¤t).is_none() => { + keys.reverse(); + return Some(SymbolicMappingProvenance { root_slot: *root_slot, keys }); + } + SymExprKind::Const(_) | SymExprKind::Keccak { .. } => {} + _ => return None, + } + } + } + + fn storage_mapping_root_slot(&self, cx: &mut SymCx) -> Option { + let bytes = self.storage_mapping_key_bytes(cx)?; + let slot = Self::from_bytes(cx, bytes[32..64].iter().cloned()); + match slot.kind() { + SymExprKind::Const(value) if cx.concrete_keccak_preimage(*value).is_some() => { + slot.storage_mapping_root_slot(cx) + } + SymExprKind::Const(slot) => Some(*slot), + SymExprKind::Keccak { .. } => slot.storage_mapping_root_slot(cx), + _ => None, + } + } + + fn storage_mapping_key_bytes(&self, cx: &SymCx) -> Option> { + match self.kind() { + SymExprKind::Keccak { len, bytes, .. } + if len.as_const() == Some(U256::from(64)) && bytes.len() >= 64 => + { + Some(bytes.clone()) + } + SymExprKind::Const(hash) => cx.concrete_keccak_preimage(*hash), + _ => None, + } + } + + fn storage_layout_key(&self, cx: &mut SymCx) -> Option<(Self, Self)> { + match self.kind() { + SymExprKind::Keccak { .. } => Some((self.clone(), Self::zero(cx))), + SymExprKind::Const(hash) if cx.concrete_keccak_preimage(*hash).is_some() => { + Some((self.clone(), Self::zero(cx))) + } + SymExprKind::BinOp(SymBinOp::Add, left, right) => { + if let Some((base, offset)) = left.storage_layout_key(cx) + && !right.contains_keccak() + { + let offset = Self::binop(cx, SymBinOp::Add, offset, right.clone()); + return Some((base, offset)); + } + if let Some((base, offset)) = right.storage_layout_key(cx) + && !left.contains_keccak() + { + let offset = Self::binop(cx, SymBinOp::Add, offset, left.clone()); + return Some((base, offset)); + } + None + } + _ => None, + } + } +} + +pub(crate) struct StorageMappingKey { + key: SymExpr, + key_bytes: Option>, + slot: SymExpr, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct SymbolicMappingProvenance { + pub(crate) root_slot: U256, + pub(crate) keys: Vec, +} + +fn storage_mapping_key_eq( + cx: &mut SymCx, + read: &StorageMappingKey, + write: &StorageMappingKey, +) -> SymBoolExpr { + if read.key_bytes.is_some() || write.key_bytes.is_some() { + let read_owned; + let read_bytes = if let Some(bytes) = read.key_bytes.as_deref() { + bytes + } else { + read_owned = read.key.clone().into_byte_exprs(cx); + &read_owned + }; + let write_owned; + let write_bytes = if let Some(bytes) = write.key_bytes.as_deref() { + bytes + } else { + write_owned = write.key.clone().into_byte_exprs(cx); + &write_owned + }; + let byte_equalities = read_bytes + .iter() + .zip(write_bytes) + .map(|(read, write)| { + let read = read.byte_term(cx, 31).unwrap_or_else(|| read.clone().low_byte(cx)); + let write = write.byte_term(cx, 31).unwrap_or_else(|| write.clone().low_byte(cx)); + SymBoolExpr::eq(cx, read, write) + }) + .collect(); + SymBoolExpr::and(cx, byte_equalities) + } else { + SymBoolExpr::eq(cx, read.key.clone(), write.key.clone()) + } +} + +fn masked_expr_matches(candidate: &SymExprKind, target: &SymExpr) -> Option { + match candidate { + SymExprKind::BinOp(SymBinOp::And, left, right) if left == target => right.eval(), + SymExprKind::BinOp(SymBinOp::And, left, right) if right == target => left.eval(), + _ => None, + } +} + +fn context_forces_masked_expr(context: &[SymBoolExpr], target: &SymExpr, mask: U256) -> bool { + context.iter().any(|condition| match condition.kind() { + SymBoolExprKind::Cmp(SymCmpOp::Eq, left, right) => { + (left == target && masked_expr_matches(right.kind(), target) == Some(mask)) + || (right == target && masked_expr_matches(left.kind(), target) == Some(mask)) + } + SymBoolExprKind::And(values) => context_forces_masked_expr(values, target, mask), + _ => false, + }) +} + +pub(crate) fn concrete_expr_bytes( + bytes: &[SymExpr], + reason: &'static str, +) -> Result, SymbolicError> { + bytes + .iter() + .map(|byte| match byte.as_const() { + Some(value) => Ok(value.to::()), + None => Err(SymbolicError::Unsupported(reason)), + }) + .collect() +} + +pub(crate) fn mask_low_bits(mask: U256) -> Option { + let bits = mask.bit_len(); + (mask == mask_bits(U256::MAX, bits)).then_some(bits) +} + +fn power_of_two_shift(value: U256) -> Option { + if value <= U256::ONE || !value.is_power_of_two() { + return None; + } + Some(value.bit_len() - 1) +} + +pub(in crate::runtime::expr) fn low_masked_source(expr: &SymExpr, bits: usize) -> Option<&SymExpr> { + match expr.kind() { + // `a & low_mask => a`. + SymExprKind::BinOp(SymBinOp::And, left, right) + if right.as_const().and_then(mask_low_bits) == Some(bits) => + { + Some(left) + } + _ => None, + } +} + +pub(in crate::runtime::expr) fn low_masked_source_any(expr: &SymExpr) -> Option<&SymExpr> { + match expr.kind() { + // `a & low_mask => a`. + SymExprKind::BinOp(SymBinOp::And, left, right) + if right.as_const().and_then(mask_low_bits).is_some() => + { + Some(left) + } + _ => None, + } +} + +fn word_from_extracted_bytes(bytes: &[SymExpr]) -> Option { + if bytes.len() < 32 { + return None; + } + + let source = bytes + .iter() + .take(32) + .enumerate() + .find_map(|(idx, byte)| byte.extracted_byte_source(idx))?; + + for (idx, byte) in bytes.iter().take(32).enumerate() { + if let Some(byte_source) = byte.extracted_byte_source(idx) { + if byte_source != source { + return None; + } + continue; + } + + let byte = byte.as_const()?; + if source.known_byte(idx) != Some(byte.to::()) { + return None; + } + } + Some(source) +} + +#[derive(Clone, PartialEq, Eq, Hash)] +pub(crate) struct SymExpr { + pub(in crate::runtime::expr) kind: HashConsed, +} + +impl fmt::Debug for SymExpr { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.kind().fmt(f) + } +} + +#[derive(Clone, PartialEq, Eq, Hash)] +pub(in crate::runtime) enum SymExprKind { + Const(U256), + Var(Symbol), + GasLeft(Symbol), + Keccak { name: Symbol, len: SymExpr, bytes: Arc<[SymExpr]> }, + Hash { name: Symbol, algorithm: &'static str, bytes: Arc<[SymExpr]> }, + Not(SymExpr), + BinOp(SymBinOp, SymExpr, SymExpr), + TernOp(SymTernOp, SymExpr, SymExpr, SymExpr), + Ite(SymBoolExpr, SymExpr, SymExpr), +} + +/// Formats an expression as a tree. +/// +/// A hash node prints only its name, because the name is a digest of the preimage. Printing the +/// preimage again would repeat every nested preimage, so a chain of hashes over the previous hash +/// would grow exponentially. +impl fmt::Debug for SymExprKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Const(value) => f.debug_tuple("Const").field(value).finish(), + Self::Var(symbol) => f.debug_tuple("Var").field(symbol).finish(), + Self::GasLeft(symbol) => f.debug_tuple("GasLeft").field(symbol).finish(), + Self::Keccak { name, len, .. } => f + .debug_struct("Keccak") + .field("name", name) + .field("len", len) + .finish_non_exhaustive(), + Self::Hash { name, algorithm, .. } => f + .debug_struct("Hash") + .field("name", name) + .field("algorithm", algorithm) + .finish_non_exhaustive(), + Self::Not(expr) => f.debug_tuple("Not").field(expr).finish(), + Self::BinOp(op, left, right) => { + f.debug_tuple("BinOp").field(op).field(left).field(right).finish() + } + Self::TernOp(op, first, second, third) => { + f.debug_tuple("TernOp").field(op).field(first).field(second).field(third).finish() + } + Self::Ite(condition, then, otherwise) => { + f.debug_tuple("Ite").field(condition).field(then).field(otherwise).finish() + } + } + } +} + +impl SymExprKind { + pub(in crate::runtime) const fn get_var(&self) -> Option { + match self { + Self::Var(symbol) + | Self::GasLeft(symbol) + | Self::Keccak { name: symbol, .. } + | Self::Hash { name: symbol, .. } => Some(*symbol), + _ => None, + } + } + + pub(in crate::runtime) const fn get_eval_var(&self) -> Option { + match self { + Self::Var(symbol) | Self::GasLeft(symbol) | Self::Hash { name: symbol, .. } => { + Some(*symbol) + } + _ => None, + } + } +} + +impl SymExpr { + pub(in crate::runtime) fn kind(&self) -> &SymExprKind { + self.kind.value() + } + + pub(in crate::runtime) fn from_kind(cx: &mut SymCx, kind: SymExprKind) -> Self { + cx.mk_expr_kind(kind) + } + + pub(crate) fn zero(cx: &mut SymCx) -> Self { + Self::constant(cx, U256::ZERO) + } + + pub(crate) fn one(cx: &mut SymCx) -> Self { + Self::constant(cx, U256::ONE) + } + + pub(crate) fn constant(cx: &mut SymCx, value: U256) -> Self { + if value.is_zero() { + return cx.cached_zero(); + } + if value == U256::ONE { + return cx.cached_one(); + } + Self::from_kind(cx, SymExprKind::Const(value)) + } + + pub(crate) fn var(cx: &mut SymCx, name: &str) -> Self { + let symbol = cx.intern(name); + Self::get_var(cx, symbol) + } + + pub(crate) fn get_var(cx: &mut SymCx, symbol: Symbol) -> Self { + Self::from_kind(cx, SymExprKind::Var(symbol)) + } + + pub(crate) fn gas_left(cx: &mut SymCx, id: usize) -> Self { + let symbol = cx.intern(&format!("gasleft_{id}")); + Self::from_kind(cx, SymExprKind::GasLeft(symbol)) + } + + pub(crate) fn not(cx: &mut SymCx, value: Self) -> Self { + match value.kind() { + SymExprKind::Const(value) => Self::constant(cx, !*value), + SymExprKind::Not(value) => value.clone(), + _ => Self::from_kind(cx, SymExprKind::Not(value)), + } + } + + pub(crate) fn binop(cx: &mut SymCx, binop: SymBinOp, left: Self, right: Self) -> Self { + match binop { + SymBinOp::Add => match (left.kind(), right.kind()) { + (SymExprKind::Const(left_value), SymExprKind::Const(right_value)) => { + // `const + const => const`. + Self::constant(cx, binop.eval(*left_value, *right_value)) + } + // `0 + a => a`. + (SymExprKind::Const(value), _) if value.is_zero() => right, + // `a + 0 => a`. + (_, SymExprKind::Const(value)) if value.is_zero() => left, + // `bool_word(c) + MAX => ite(c, 0, MAX)`. + (SymExprKind::Const(value), _) | (_, SymExprKind::Const(value)) + if *value == U256::MAX + && let Some(condition) = if left.as_const() == Some(U256::MAX) { + right.bitwise_bool_word_condition(cx) + } else { + left.bitwise_bool_word_condition(cx) + } => + { + let zero = Self::zero(cx); + let max = Self::constant(cx, U256::MAX); + Self::ite(cx, condition, zero, max) + } + // `one_bit_word(c) + k => ite(c, k + 1, k)`. + (SymExprKind::Const(value), _) + if let Some(condition) = right.bitwise_bool_word_condition(cx) => + { + let incremented = Self::constant(cx, value.wrapping_add(U256::ONE)); + let value = Self::constant(cx, *value); + Self::ite(cx, condition, incremented, value) + } + (_, SymExprKind::Const(value)) + if let Some(condition) = left.bitwise_bool_word_condition(cx) => + { + let incremented = Self::constant(cx, value.wrapping_add(U256::ONE)); + let value = Self::constant(cx, *value); + Self::ite(cx, condition, incremented, value) + } + _ => { + let (left, right) = Self::ordered_commutative_operands(left, right); + if let Some(value) = Self::add_with_const_ite(cx, &left, &right) { + value + } else if let Some(value) = Self::add_with_const_ite(cx, &right, &left) { + value + } else { + Self::from_kind(cx, SymExprKind::BinOp(binop, left, right)) + } + } + }, + SymBinOp::Sub => match (left.kind(), right.kind()) { + (SymExprKind::Const(left_value), SymExprKind::Const(right_value)) => { + // `const - const => const`. + Self::constant(cx, binop.eval(*left_value, *right_value)) + } + // `a - 0 => a`. + (_, SymExprKind::Const(value)) if value.is_zero() => left, + // `a - a => 0`. + _ if left == right => Self::zero(cx), + // `bool_word(c) - 1 => ite(c, 0, MAX)`. + (_, SymExprKind::Const(value)) + if *value == U256::ONE + && let Some(condition) = left.bitwise_bool_word_condition(cx) => + { + let zero = Self::zero(cx); + let max = Self::constant(cx, U256::MAX); + Self::ite(cx, condition, zero, max) + } + // `k - bool_word(c) => ite(c, k - 1, k)`. + (SymExprKind::Const(value), _) + if let Some(condition) = right.bitwise_bool_word_condition(cx) => + { + let decremented = Self::constant(cx, value.wrapping_sub(U256::ONE)); + let value = Self::constant(cx, *value); + Self::ite(cx, condition, decremented, value) + } + _ => Self::from_kind(cx, SymExprKind::BinOp(binop, left, right)), + }, + SymBinOp::Mul => match (left.kind(), right.kind()) { + (SymExprKind::Const(left_value), SymExprKind::Const(right_value)) => { + // `const * const => const`. + Self::constant(cx, binop.eval(*left_value, *right_value)) + } + // `0 * a => 0`. + (SymExprKind::Const(value), _) | (_, SymExprKind::Const(value)) + if value.is_zero() => + { + Self::zero(cx) + } + // `1 * a => a`. + (SymExprKind::Const(value), _) if *value == U256::ONE => right, + // `a * 1 => a`. + (_, SymExprKind::Const(value)) if *value == U256::ONE => left, + _ => { + let (left, right) = Self::ordered_commutative_operands(left, right); + if let Some(condition) = left.direct_bool_word_condition(cx) { + // `bool_word(c) * a => ite(c, a, 0)`. + let zero = Self::zero(cx); + Self::ite(cx, condition, right, zero) + } else if let Some(condition) = right.direct_bool_word_condition(cx) { + // `a * bool_word(c) => ite(c, a, 0)`. + let zero = Self::zero(cx); + Self::ite(cx, condition, left, zero) + } else if let Some(shift) = right.as_const().and_then(power_of_two_shift) { + // `a * 2**n => a << n`. + let shift = Self::constant(cx, U256::from(shift)); + Self::binop(cx, SymBinOp::Shl, left, shift) + } else { + Self::from_kind(cx, SymExprKind::BinOp(binop, left, right)) + } + } + }, + SymBinOp::UDiv | SymBinOp::SDiv => match (left.kind(), right.kind()) { + (SymExprKind::Const(left_value), SymExprKind::Const(right_value)) => { + // `const / const => const`. + Self::constant(cx, binop.eval(*left_value, *right_value)) + } + // `a / 0 => 0`. + (_, SymExprKind::Const(value)) if value.is_zero() => Self::zero(cx), + // `a / 1 => a`. + (_, SymExprKind::Const(value)) if *value == U256::ONE => left, + // `(a - (a & (2**n - 1))) u/ 2**n => a >> n`. + ( + SymExprKind::BinOp(SymBinOp::Sub, value, low_bits), + SymExprKind::Const(divisor), + ) if binop == SymBinOp::UDiv + && let Some(shift) = power_of_two_shift(*divisor) + && low_masked_source(low_bits, shift) == Some(value) => + { + let shift = Self::constant(cx, U256::from(shift)); + Self::binop(cx, SymBinOp::Shr, value.clone(), shift) + } + // `a u/ 2**n => a >> n`. + (_, SymExprKind::Const(divisor)) + if binop == SymBinOp::UDiv + && let Some(shift) = power_of_two_shift(*divisor) => + { + let shift = Self::constant(cx, U256::from(shift)); + Self::binop(cx, SymBinOp::Shr, left, shift) + } + _ => Self::from_kind(cx, SymExprKind::BinOp(binop, left, right)), + }, + SymBinOp::URem | SymBinOp::SRem => match (left.kind(), right.kind()) { + (SymExprKind::Const(left_value), SymExprKind::Const(right_value)) => { + // `const % const => const`. + Self::constant(cx, binop.eval(*left_value, *right_value)) + } + // `a % 0 => 0`. + (_, SymExprKind::Const(value)) if value.is_zero() => Self::zero(cx), + // `a % 1 => 0`. + (_, SymExprKind::Const(value)) if *value == U256::ONE => Self::zero(cx), + // `a u% 2**n => a & (2**n - 1)`. + (_, SymExprKind::Const(divisor)) + if binop == SymBinOp::URem + && let Some(bits) = power_of_two_shift(*divisor) => + { + Self::and_const(cx, left, mask_bits(U256::MAX, bits)) + } + _ => Self::from_kind(cx, SymExprKind::BinOp(binop, left, right)), + }, + SymBinOp::And => match (left.kind(), right.kind()) { + (SymExprKind::Const(left_value), SymExprKind::Const(right_value)) => { + // `const & const => const`. + Self::constant(cx, binop.eval(*left_value, *right_value)) + } + // `0 & a => 0`. + (SymExprKind::Const(value), _) | (_, SymExprKind::Const(value)) + if value.is_zero() => + { + Self::zero(cx) + } + // `MAX & a => a`. + (SymExprKind::Const(value), _) if *value == U256::MAX => right, + // `a & MAX => a`. + (_, SymExprKind::Const(value)) if *value == U256::MAX => left, + // `a & a => a`. + _ if left == right => left, + (SymExprKind::Const(mask), _) => Self::and_const(cx, right, *mask), + (_, SymExprKind::Const(mask)) => Self::and_const(cx, left, *mask), + _ => Self::commutative_binop(cx, binop, left, right), + }, + SymBinOp::Or => match (left.kind(), right.kind()) { + (SymExprKind::Const(left_value), SymExprKind::Const(right_value)) => { + // `const | const => const`. + Self::constant(cx, binop.eval(*left_value, *right_value)) + } + // `0 | a => a`. + (SymExprKind::Const(value), _) if value.is_zero() => right, + // `a | 0 => a`. + (_, SymExprKind::Const(value)) if value.is_zero() => left, + // `a | a => a`. + _ if left == right => left, + _ if let Some(value) = Self::or_with_absorbing_ite(cx, &left, right.clone()) => { + value + } + _ if let Some(value) = Self::or_with_absorbing_ite(cx, &right, left.clone()) => { + value + } + _ => Self::or(cx, left, right), + }, + SymBinOp::Xor => match (left.kind(), right.kind()) { + (SymExprKind::Const(left_value), SymExprKind::Const(right_value)) => { + // `const ^ const => const`. + Self::constant(cx, binop.eval(*left_value, *right_value)) + } + // `0 ^ a => a`. + (SymExprKind::Const(value), _) if value.is_zero() => right, + // `a ^ 0 => a`. + (_, SymExprKind::Const(value)) if value.is_zero() => left, + // `a ^ a => 0`. + _ if left == right => Self::zero(cx), + _ => { + let (left, right) = Self::ordered_commutative_operands(left, right); + // `a ^ (a ^ b) => b`. + if let Some(value) = Self::xor_with_shared_operand(&left, &right) + .or_else(|| Self::xor_with_shared_operand(&right, &left)) + { + value + // `a ^ ((a ^ b) * bool_word(c)) => ite(c, b, a)`. + } else if let Some(value) = Self::xor_with_bool_select(cx, &left, &right) { + value + } else if let Some(value) = Self::xor_with_bool_select(cx, &right, &left) { + value + // `a ^ ite(c, b, 0) => ite(c, a ^ b, a)`. + } else if let Some(value) = Self::xor_with_zero_ite(cx, &left, &right) { + value + } else if let Some(value) = Self::xor_with_zero_ite(cx, &right, &left) { + value + } else { + Self::from_kind(cx, SymExprKind::BinOp(binop, left, right)) + } + } + }, + SymBinOp::Shl => match (left.kind(), right.kind()) { + (SymExprKind::Const(left_value), SymExprKind::Const(right_value)) => { + // `const << const => const`. + Self::constant(cx, binop.eval(*left_value, *right_value)) + } + // `a << 0 => a`. + (_, SymExprKind::Const(value)) if value.is_zero() => left, + // `0 << a => 0`. + (SymExprKind::Const(value), _) if value.is_zero() => Self::zero(cx), + // `a << 256 => 0`. + (_, SymExprKind::Const(value)) if *value >= U256::from(256) => Self::zero(cx), + _ => Self::from_kind(cx, SymExprKind::BinOp(binop, left, right)), + }, + SymBinOp::Shr => match (left.kind(), right.kind()) { + (SymExprKind::Const(left_value), SymExprKind::Const(right_value)) => { + // `const >> const => const`. + Self::constant(cx, binop.eval(*left_value, *right_value)) + } + // `a >> 0 => a`. + (_, SymExprKind::Const(value)) if value.is_zero() => left, + // `0 >> a => 0`. + (SymExprKind::Const(value), _) if value.is_zero() => Self::zero(cx), + (_, SymExprKind::Const(value)) => Self::shr_const(cx, left, *value), + _ => Self::from_kind(cx, SymExprKind::BinOp(binop, left, right)), + }, + SymBinOp::Sar => match (left.kind(), right.kind()) { + (SymExprKind::Const(left_value), SymExprKind::Const(right_value)) => { + // `const >>s const => const`. + Self::constant(cx, binop.eval(*left_value, *right_value)) + } + // `a >>s 0 => a`. + (_, SymExprKind::Const(value)) if value.is_zero() => left, + _ => Self::from_kind(cx, SymExprKind::BinOp(binop, left, right)), + }, + } + } + + pub(crate) fn ternop( + cx: &mut SymCx, + ternop: SymTernOp, + left: Self, + right: Self, + modulus: Self, + ) -> Self { + match (left.kind(), right.kind(), modulus.kind()) { + (_, _, SymExprKind::Const(modulus)) if modulus.is_zero() || *modulus == U256::ONE => { + // `addmod/mulmod(a, b, 0) => 0`. + Self::zero(cx) + } + (SymExprKind::Const(left), SymExprKind::Const(right), SymExprKind::Const(modulus)) => { + // `addmod/mulmod(const, const, const) => const`. + Self::constant(cx, ternop.eval(*left, *right, *modulus)) + } + // `addmod/mulmod(a, b, 2**n) => op(a, b) & (2**n - 1)`. + (_, _, SymExprKind::Const(modulus)) + if let Some(bits) = power_of_two_shift(*modulus) => + { + let binop = match ternop { + SymTernOp::AddMod => SymBinOp::Add, + SymTernOp::MulMod => SymBinOp::Mul, + }; + let value = Self::binop(cx, binop, left, right); + Self::and_const(cx, value, mask_bits(U256::MAX, bits)) + } + _ => { + // `addmod/mulmod(a, b, m) => addmod/mulmod(ordered(a, b), m)`. + let (left, right) = Self::ordered_commutative_operands(left, right); + Self::from_kind(cx, SymExprKind::TernOp(ternop, left, right, modulus)) + } + } + } + + pub(crate) fn ite( + cx: &mut SymCx, + condition: SymBoolExpr, + then_expr: Self, + else_expr: Self, + ) -> Self { + match condition.as_const() { + // `ite(true, a, b) => a`. + Some(true) => then_expr, + // `ite(false, a, b) => b`. + Some(false) => else_expr, + // `ite(c, a, a) => a`. + None if then_expr == else_expr => then_expr, + // `ite(a == 0, 0, a / a) => a != 0`. + None if then_expr.as_const().is_some_and(|value| value.is_zero()) + && Self::self_div_expr_matches_zero_check(&condition, &else_expr) => + { + let condition = condition.not(cx); + Self::bool_word(cx, condition) + } + // `ite(c, 1, bool_word(c)) => bool_word(c)`. + None if then_expr.as_const() == Some(U256::ONE) + && else_expr.bool_word_condition().as_ref() == Some(&condition) => + { + else_expr + } + // `ite(c, bool_word(c), 0) => bool_word(c)`. + None if else_expr.as_const().is_some_and(|value| value.is_zero()) + && then_expr.bool_word_condition().as_ref() == Some(&condition) => + { + then_expr + } + None => Self::from_kind(cx, SymExprKind::Ite(condition, then_expr, else_expr)), + } + } + + pub(crate) fn bool_word(cx: &mut SymCx, value: SymBoolExpr) -> Self { + let one = Self::one(cx); + let zero = Self::zero(cx); + Self::ite(cx, value, one, zero) + } + + fn self_div_expr_matches_zero_check(cond: &SymBoolExpr, expr: &Self) -> bool { + let Some(zero_operand) = cond.zero_check_operand() else { return false }; + let Some((numerator, denominator)) = expr.udiv_operands() else { return false }; + numerator == zero_operand && denominator == zero_operand + } + + pub(crate) fn keccak_symbol(cx: &mut SymCx, name: Symbol, len: Self, bytes: Vec) -> Self { + Self::from_kind(cx, SymExprKind::Keccak { name, len, bytes: bytes.into() }) + } + + pub(crate) fn hash_symbol( + cx: &mut SymCx, + name: Symbol, + algorithm: &'static str, + bytes: Vec, + ) -> Self { + Self::from_kind(cx, SymExprKind::Hash { name, algorithm, bytes: bytes.into() }) + } + + fn or(cx: &mut SymCx, left: Self, right: Self) -> Self { + if let Some(rebuilt) = Self::rebuild_from_or_terms(&left, &right) { + // `byte_parts(a) | byte_parts(a) => a`. + return rebuilt; + } + Self::commutative_binop(cx, SymBinOp::Or, left, right) + } + + fn or_with_absorbing_ite(cx: &mut SymCx, conditional: &Self, other: Self) -> Option { + let SymExprKind::Ite(condition, then_expr, else_expr) = conditional.kind() else { + return None; + }; + if then_expr.as_const().is_some_and(|value| value.is_zero()) + && else_expr.as_const() == Some(U256::MAX) + { + return Some(Self::ite(cx, condition.clone(), other, else_expr.clone())); + } + if then_expr.as_const() == Some(U256::MAX) + && else_expr.as_const().is_some_and(|value| value.is_zero()) + { + return Some(Self::ite(cx, condition.clone(), then_expr.clone(), other)); + } + None + } + + fn add_with_const_ite(cx: &mut SymCx, other: &Self, conditional: &Self) -> Option { + if other.as_const().is_some() { + return None; + } + let SymExprKind::Ite(condition, then_expr, else_expr) = conditional.kind() else { + return None; + }; + if then_expr.as_const().is_none() || else_expr.as_const().is_none() { + return None; + } + if !Self::duplicating_branchless_rewrite_fits(other, conditional) { + return None; + } + let then_expr = Self::binop(cx, SymBinOp::Add, other.clone(), then_expr.clone()); + let else_expr = Self::binop(cx, SymBinOp::Add, other.clone(), else_expr.clone()); + Some(Self::ite(cx, condition.clone(), then_expr, else_expr)) + } + + fn xor_with_bool_select(cx: &mut SymCx, base: &Self, selector: &Self) -> Option { + let SymExprKind::BinOp(SymBinOp::Mul, left, right) = selector.kind() else { return None }; + let (condition_word, selected) = match left.kind() { + SymExprKind::BinOp(SymBinOp::Xor, delta_left, delta_right) if delta_left == base => { + (right, delta_right.clone()) + } + SymExprKind::BinOp(SymBinOp::Xor, delta_left, delta_right) if delta_right == base => { + (right, delta_left.clone()) + } + _ => match right.kind() { + SymExprKind::BinOp(SymBinOp::Xor, delta_left, delta_right) + if delta_left == base => + { + (left, delta_right.clone()) + } + SymExprKind::BinOp(SymBinOp::Xor, delta_left, delta_right) + if delta_right == base => + { + (left, delta_left.clone()) + } + _ => return None, + }, + }; + let condition = condition_word.bitwise_bool_word_condition(cx)?; + Some(Self::ite(cx, condition, selected, base.clone())) + } + + fn xor_with_shared_operand(base: &Self, nested: &Self) -> Option { + let SymExprKind::BinOp(SymBinOp::Xor, left, right) = nested.kind() else { return None }; + if left == base { + Some(right.clone()) + } else if right == base { + Some(left.clone()) + } else { + None + } + } + + fn xor_with_zero_ite(cx: &mut SymCx, base: &Self, conditional: &Self) -> Option { + let SymExprKind::Ite(condition, then_expr, else_expr) = conditional.kind() else { + return None; + }; + if then_expr.as_const().is_some_and(|value| value.is_zero()) { + if !Self::duplicating_branchless_rewrite_fits(base, conditional) { + return None; + } + let selected = Self::binop(cx, SymBinOp::Xor, base.clone(), else_expr.clone()); + return Some(Self::ite(cx, condition.clone(), base.clone(), selected)); + } + if else_expr.as_const().is_some_and(|value| value.is_zero()) { + if !Self::duplicating_branchless_rewrite_fits(base, conditional) { + return None; + } + let selected = Self::binop(cx, SymBinOp::Xor, base.clone(), then_expr.clone()); + return Some(Self::ite(cx, condition.clone(), selected, base.clone())); + } + None + } + + /// Checks the occurrence cost of a rewrite that places one operand in both ITE arms. + /// + /// Hash-consing keeps the stored DAG compact, but the rewrite still places a shared operand in + /// both arms for downstream consumers. Count that unfolded result before constructing it so a + /// linear series of branchless operations cannot become exponential. + fn duplicating_branchless_rewrite_fits(operand: &Self, conditional: &Self) -> bool { + let mut counter = UnfoldedNodeCounter::new(); + let Some(operand_nodes) = counter.expr_nodes(operand) else { + return false; + }; + let Some(conditional_nodes) = counter.expr_nodes(conditional) else { + return false; + }; + + operand_nodes + .checked_mul(2) + .and_then(|duplicated| conditional_nodes.checked_add(duplicated)) + // The rewritten ITE adds at most two operation wrappers around the original arms. + .and_then(|nodes| nodes.checked_add(2)) + .is_some_and(|nodes| nodes <= MAX_BRANCHLESS_REWRITE_UNFOLDED_NODES) + } + + fn commutative_binop(cx: &mut SymCx, op: SymBinOp, left: Self, right: Self) -> Self { + // `a + b => b + a`. + let (left, right) = Self::ordered_commutative_operands(left, right); + Self::from_kind(cx, SymExprKind::BinOp(op, left, right)) + } + + pub(in crate::runtime::expr) fn ordered_commutative_operands( + left: Self, + right: Self, + ) -> (Self, Self) { + match left.complexity().cmp(&right.complexity()) { + // Put less complex operands, like constants, on the RHS. + std::cmp::Ordering::Less => (right, left), + std::cmp::Ordering::Greater => (left, right), + std::cmp::Ordering::Equal if right.kind.stable_hash_cmp(&left.kind).is_lt() => { + (right, left) + } + std::cmp::Ordering::Equal => (left, right), + } + } + + /// Canonically orders factors that belong to the same symbolic context. + /// + /// Polynomial normalization only needs a stable order within one context, where hash-consed + /// identity is both total and substantially cheaper than rendering structural string keys. + pub(in crate::runtime) fn sort_interned_factors(factors: &mut [Self]) { + factors.sort_unstable_by(|left, right| left.kind.identity_cmp(&right.kind)); + } + + fn complexity(&self) -> usize { + match self.kind() { + SymExprKind::Const(_) => 0, + SymExprKind::Not(_) => 1, + SymExprKind::BinOp(..) => 2, + SymExprKind::TernOp(..) => 3, + _ => 4, + } + } + + fn and_const(cx: &mut SymCx, expr: Self, mask: U256) -> Self { + if mask.is_zero() { + // `a & 0 => 0`. + return Self::zero(cx); + } + if mask == U256::MAX { + // `a & MAX => a`. + return expr; + } + + match expr.kind() { + // `const & mask => const`. + SymExprKind::Const(value) => Self::constant(cx, *value & mask), + SymExprKind::BinOp(SymBinOp::Or, left, right) => { + // `(a | b) & mask => (a & mask) | (b & mask)`. + let left = Self::and_const(cx, left.clone(), mask); + let right = Self::and_const(cx, right.clone(), mask); + Self::binop(cx, SymBinOp::Or, left, right) + } + SymExprKind::BinOp(SymBinOp::Shl, _, shift) + if mask_low_bits(mask).is_some_and(|bits| { + shift + .as_const() + .and_then(|shift| usize::try_from(shift).ok()) + .is_some_and(|shift| bits <= shift) + }) => + { + // `(a << n) & low_mask(n) => 0`. + Self::zero(cx) + } + SymExprKind::BinOp(SymBinOp::And, left, right) => { + if right.as_const() == Some(mask) { + // `(a & mask) & mask => a & mask`. + Self::and_const(cx, left.clone(), mask) + } else if left == right { + // `(a & a) & mask => a & mask`. + Self::and_const(cx, left.clone(), mask) + } else { + let mask = Self::constant(cx, mask); + Self::from_kind(cx, SymExprKind::BinOp(SymBinOp::And, expr, mask)) + } + } + _ => { + let mask = Self::constant(cx, mask); + Self::from_kind(cx, SymExprKind::BinOp(SymBinOp::And, expr, mask)) + } + } + } + + fn shr_const(cx: &mut SymCx, expr: Self, shift: U256) -> Self { + if shift.is_zero() { + // `a >> 0 => a`. + return expr; + } + if shift >= U256::from(256) { + // `a >> 256 => 0`. + return Self::zero(cx); + } + + let shift = usize::try_from(shift).expect("shift is less than 256"); + if expr.unsigned_bits() <= shift { + // `small(a) >> bits(a) => 0`. + return Self::zero(cx); + } + + if let SymExprKind::BinOp(SymBinOp::Shl, inner, left_shift) = expr.kind() + && left_shift.as_const() == Some(U256::from(shift)) + && inner.unsigned_bits() <= 256 - shift + { + // `(a << n) >> n => a`. + return inner.clone(); + } + + if let SymExprKind::BinOp(SymBinOp::Or, left, right) = expr.kind() { + // Distribute only when it collapses part of the OR; expanding broad + // bit-smearing chains eagerly makes SMT CSE much larger. + let left = Self::shr_const(cx, left.clone(), U256::from(shift)); + let right = Self::shr_const(cx, right.clone(), U256::from(shift)); + if left.as_const().is_some_and(|value| value.is_zero()) { + return right; + } + if right.as_const().is_some_and(|value| value.is_zero()) { + return left; + } + } + + let shift = Self::constant(cx, U256::from(shift)); + Self::from_kind(cx, SymExprKind::BinOp(SymBinOp::Shr, expr, shift)) + } + + fn rebuild_from_or_terms(left: &Self, right: &Self) -> Option { + let mut terms = Vec::new(); + left.push_or_terms(&mut terms); + right.push_or_terms(&mut terms); + Self::rebuild_from_extracted_byte_terms(&terms) + .or_else(|| Self::rebuild_from_shifted_word_fragments(&terms)) + } + + pub(in crate::runtime) fn push_or_terms<'a>(&'a self, terms: &mut Vec<&'a Self>) { + match self.kind() { + SymExprKind::BinOp(SymBinOp::Or, left, right) => { + left.push_or_terms(terms); + right.push_or_terms(terms); + } + _ => terms.push(self), + } + } + + fn rebuild_from_extracted_byte_terms(terms: &[&Self]) -> Option { + if terms.len() <= 1 { + return None; + } + + let mut source = None; + let mut seen = [false; 32]; + for term in terms { + if term.as_const().is_some_and(|value| value.is_zero()) { + continue; + } + let (term_source, index) = term.extracted_shifted_byte_term()?; + match &source { + Some(source) if source != &term_source => return None, + Some(_) => {} + None => source = Some(term_source), + } + seen[index] = true; + } + + let source = source?; + for (index, seen) in seen.into_iter().enumerate() { + if !seen && source.known_byte(index) != Some(0) { + return None; + } + } + Some(source) + } + + fn extracted_shifted_byte_term(&self) -> Option<(Self, usize)> { + match self.kind() { + SymExprKind::BinOp(SymBinOp::Shl, byte, shift) => { + let shift = shift.as_const()?; + let Ok(shift) = usize::try_from(shift) else { return None }; + if shift % 8 != 0 || shift > 248 { + return None; + } + let index = 31 - shift / 8; + let source = byte.extracted_unshifted_byte_source(index)?; + Some((source, index)) + } + _ => self.extracted_unshifted_byte_source(31).map(|source| (source, 31)), + } + } + + fn extracted_unshifted_byte_source(&self, index: usize) -> Option { + let expr = self.strip_low_byte_mask(); + if index == 31 { + return Some(expr.clone()); + } + let SymExprKind::BinOp(SymBinOp::Shr, source, shift) = expr.kind() else { return None }; + let shift = shift.as_const()?; + (shift == U256::from((31 - index) * 8)).then(|| source.clone()) + } + + fn rebuild_from_shifted_word_fragments(terms: &[&Self]) -> Option { + if terms.len() != 2 { + return None; + } + + let left_low = terms[0].low_word_fragment(); + let right_low = terms[1].low_word_fragment(); + let left_high = terms[0].shifted_high_word_fragment(); + let right_high = terms[1].shifted_high_word_fragment(); + match (left_low, right_low, left_high, right_high) { + (Some((low_source, low_bits)), None, None, Some((high_source, high_bits))) + | (None, Some((low_source, low_bits)), Some((high_source, high_bits)), None) + if low_source == high_source && low_bits == high_bits => + { + Some(low_source) + } + _ => None, + } + } + + fn low_word_fragment(&self) -> Option<(Self, usize)> { + let SymExprKind::BinOp(SymBinOp::And, left, right) = self.kind() else { return None }; + let mask = right.as_const()?; + mask_low_bits(mask).map(|bits| (left.clone(), bits)) + } + + fn shifted_high_word_fragment(&self) -> Option<(Self, usize)> { + let SymExprKind::BinOp(SymBinOp::Shl, value, shift) = self.kind() else { return None }; + let bits = shift.as_const().and_then(|shift| usize::try_from(shift).ok())?; + if bits == 0 || bits >= 256 { + return None; + } + + let (source, source_shift, width) = value.shifted_low_fragment_source()?; + (source_shift == bits && width == 256 - bits).then_some((source, bits)) + } + + fn shifted_low_fragment_source(&self) -> Option<(Self, usize, usize)> { + let SymExprKind::BinOp(SymBinOp::And, left, right) = self.kind() else { return None }; + let mask = right.as_const()?; + Self::shifted_low_fragment_source_with_mask(left, mask) + } + + fn shifted_low_fragment_source_with_mask( + value: &Self, + mask: U256, + ) -> Option<(Self, usize, usize)> { + let width = mask_low_bits(mask)?; + match value.kind() { + SymExprKind::BinOp(SymBinOp::Shr, source, shift) => { + let shift = shift.as_const().and_then(|shift| usize::try_from(shift).ok())?; + Some((source.clone(), shift, width)) + } + _ => Some((value.clone(), 0, width)), + } + } + + pub(crate) fn low_byte(self, cx: &mut SymCx) -> Self { + if let Some(word) = self.as_const() { + return Self::constant(cx, U256::from(word.to::())); + } + let mask = Self::constant(cx, U256::from(0xff)); + Self::binop(cx, SymBinOp::And, self, mask) + } + + pub(crate) fn into_byte_exprs(self, cx: &mut SymCx) -> Vec { + SymBytes::word(cx, self).materialize(cx) + } + + pub(crate) fn into_bytes(self, cx: &mut SymCx) -> SymBytes { + SymBytes::word(cx, self) + } + + pub(crate) fn from_bytes(cx: &mut SymCx, bytes: impl IntoIterator) -> Self { + let bytes = bytes.into_iter().collect::>(); + if let Ok(concrete) = concrete_expr_bytes(&bytes, "symbolic word bytes") { + let mut word = [0u8; 32]; + for (idx, byte) in concrete.into_iter().take(32).enumerate() { + word[idx] = byte; + } + return Self::constant(cx, U256::from_be_bytes(word)); + } + + if let Some(expr) = word_from_extracted_bytes(&bytes) { + return expr; + } + + let mut expr = Self::zero(cx); + for (idx, byte) in bytes.into_iter().take(32).enumerate() { + let shift = (31 - idx) * 8; + let byte = byte.low_byte(cx); + let byte = if shift == 0 { + byte + } else { + let shift = Self::constant(cx, U256::from(shift)); + Self::binop(cx, SymBinOp::Shl, byte, shift) + }; + expr = Self::binop(cx, SymBinOp::Or, expr, byte); + } + expr + } + + pub(crate) fn as_const(&self) -> Option { + match self.kind() { + SymExprKind::Const(value) => Some(*value), + _ => None, + } + } + + pub(crate) fn eval(&self) -> Option { + self.eval_model_if_complete(&NoopModel).ok().flatten() + } + + pub(crate) fn eval_model( + &self, + model: &M, + ) -> Result { + ModelEvaluator::new(model).eval_word(self) + } + + pub(crate) fn eval_model_if_complete( + &self, + model: &M, + ) -> Result, SymbolicError> { + let mut vars = SymbolicVars::default(); + self.collect_eval_vars(&mut vars); + if vars.iter().copied().all(|var| model.contains_name(var)) { + self.eval_model(model).map(Some) + } else { + Ok(None) + } + } + + pub(crate) fn assign_model_value(&self, model: &mut SymbolicModel, value: U256) -> bool { + match self.kind() { + SymExprKind::Const(existing) => *existing == value, + SymExprKind::Var(var) => { + if let Some(existing) = model.get(var) { + *existing == value + } else { + model.insert(*var, value); + true + } + } + SymExprKind::GasLeft(symbol) => { + if let Some(existing) = model.get(symbol) { + *existing == value + } else { + model.insert(*symbol, value); + true + } + } + _ => false, + } + } + + pub(crate) fn bool_word_condition(&self) -> Option { + let SymExprKind::Ite(condition, then_expr, else_expr) = self.kind() else { + return None; + }; + Self::bool_word_condition_from_parts(condition, then_expr, else_expr) + } + + pub(in crate::runtime) fn bitwise_bool_word_condition( + &self, + cx: &mut SymCx, + ) -> Option { + let mut pending = vec![self.clone()]; + let mut seen_words = HashSet::::default(); + let mut leaf_conditions = IndexSet::::default(); + let mut bit_widths = HashMap::default(); + let mut remaining = MAX_BITWISE_BOOL_WORD_VISITS; + while let Some(word) = pending.pop() { + if !seen_words.insert(word.clone()) { + continue; + } + if remaining == 0 { + return None; + } + remaining -= 1; + + if let Some(condition) = word.direct_bool_word_condition(cx) { + leaf_conditions.insert(condition); + continue; + } + if let SymExprKind::BinOp(SymBinOp::Or, left, right) = word.kind() { + pending.push(right.clone()); + pending.push(left.clone()); + continue; + } + + if word.unsigned_bits_cached(&mut bit_widths, &mut remaining) == Some(1) { + let zero = Self::zero(cx); + let (word, zero) = Self::ordered_commutative_operands(word, zero); + let zero_check = + SymBoolExpr::from_kind(cx, SymBoolExprKind::Cmp(SymCmpOp::Eq, word, zero)); + leaf_conditions.insert(zero_check.not(cx)); + continue; + } + return None; + } + + Some(SymBoolExpr::or(cx, leaf_conditions.into_iter().collect())) + } + + /// Returns the condition represented by a direct `0`/`1` ITE, preserving its polarity. + /// + /// Unlike [`Self::bitwise_bool_word_condition`], this deliberately does not infer a boolean + /// word from arbitrary one-bit expressions. Callers that run during expression construction + /// use this bounded structural check to avoid recursively sizing a shared expression DAG. + fn direct_bool_word_condition(&self, cx: &mut SymCx) -> Option { + let SymExprKind::Ite(condition, then_expr, else_expr) = self.kind() else { + return None; + }; + match (then_expr.as_const(), else_expr.as_const()) { + (Some(then_value), Some(else_value)) + if then_value == U256::ONE && else_value.is_zero() => + { + Some(condition.clone()) + } + (Some(then_value), Some(else_value)) + if then_value.is_zero() && else_value == U256::ONE => + { + Some(condition.clone().not(cx)) + } + _ => None, + } + } + + fn bool_word_condition_from_parts( + condition: &SymBoolExpr, + then_expr: &Self, + else_expr: &Self, + ) -> Option { + match (then_expr.as_const(), else_expr.as_const()) { + (Some(then_value), Some(else_value)) + if then_value == U256::ONE && else_value.is_zero() => + { + Some(condition.clone()) + } + (Some(then_value), Some(else_value)) + if then_value.is_zero() && else_value == U256::ONE => + { + None + } + _ => None, + } + } + + pub(crate) fn into_zero_bool(self, cx: &mut SymCx) -> SymBoolExpr { + match self.kind() { + SymExprKind::Const(value) => SymBoolExpr::constant(cx, value.is_zero()), + SymExprKind::Ite(condition, then_expr, else_expr) => { + match Self::bool_word_condition_from_parts(condition, then_expr, else_expr) { + Some(condition) => SymBoolExpr::not_bool(cx, condition), + None => { + let zero = Self::zero(cx); + SymBoolExpr::eq(cx, self, zero) + } + } + } + _ => { + let zero = Self::zero(cx); + SymBoolExpr::eq(cx, self, zero) + } + } + } + + pub(crate) fn nonzero_bool(self, cx: &mut SymCx) -> SymBoolExpr { + let zero = self.into_zero_bool(cx); + SymBoolExpr::not_bool(cx, zero) + } + + pub(crate) fn as_const_or(&self, reason: &'static str) -> Result { + self.as_const().ok_or(SymbolicError::Unsupported(reason)) + } + + pub(crate) fn as_usize_or(&self, reason: &'static str) -> Result { + let value = self.as_const_or(reason)?; + usize::try_from(value).map_err(|_| SymbolicError::Unsupported(reason)) + } + + pub(crate) fn contains_keccak(&self) -> bool { + self.visit_bool(|expr| matches!(expr.kind(), SymExprKind::Keccak { .. })) + } + + pub(crate) fn contains_gasleft(&self) -> bool { + self.visit_bool(|expr| expr.is_raw_gasleft()) + } + + pub(crate) fn contains_udiv(&self) -> bool { + self.visit_bool(|expr| matches!(expr.kind(), SymExprKind::BinOp(SymBinOp::UDiv, _, _))) + } + + pub(crate) fn contains_ite(&self) -> bool { + let mut visited = HashSet::<&Self>::default(); + self.contains_ite_cached(&mut visited, false) + } + + fn contains_ite_cached<'a>(&'a self, visited: &mut HashSet<&'a Self>, memoize: bool) -> bool { + match self.kind() { + SymExprKind::Const(_) | SymExprKind::Var(_) | SymExprKind::GasLeft(_) => return false, + SymExprKind::Ite(_, _, _) => return true, + _ => {} + } + if memoize && !visited.insert(self) { + return false; + } + + match self.kind() { + SymExprKind::Keccak { len, bytes, .. } => { + len.contains_ite_cached(visited, true) + || bytes.iter().any(|byte| byte.contains_ite_cached(visited, true)) + } + SymExprKind::Hash { bytes, .. } => { + bytes.iter().any(|byte| byte.contains_ite_cached(visited, true)) + } + SymExprKind::Not(value) => value.contains_ite_cached(visited, true), + SymExprKind::BinOp(_, left, right) => { + left.contains_ite_cached(visited, true) || right.contains_ite_cached(visited, true) + } + SymExprKind::TernOp(_, left, right, modulus) => { + left.contains_ite_cached(visited, true) + || right.contains_ite_cached(visited, true) + || modulus.contains_ite_cached(visited, true) + } + SymExprKind::Const(_) + | SymExprKind::Var(_) + | SymExprKind::GasLeft(_) + | SymExprKind::Ite(_, _, _) => unreachable!("leaf expression handled before descent"), + } + } + + pub(in crate::runtime) fn udiv_operands(&self) -> Option<(&Self, &Self)> { + match self.kind() { + SymExprKind::BinOp(SymBinOp::UDiv, numerator, denominator) => { + Some((numerator, denominator)) + } + _ => None, + } + } + + pub(crate) fn collect_eval_vars(&self, vars: &mut SymbolicVars) { + let _ = self.visit(&mut |expr| { + if let Some(var) = expr.kind().get_eval_var() { + vars.insert(var); + } + ControlFlow::<()>::Continue(()) + }); + } + + pub(crate) fn known_byte(&self, index: usize) -> Option { + debug_assert!(index < 32); + match self.kind() { + SymExprKind::Const(value) => Some(value.to_be_bytes::<32>()[index]), + SymExprKind::Var(_) + | SymExprKind::GasLeft(_) + | SymExprKind::Keccak { .. } + | SymExprKind::Hash { .. } => None, + SymExprKind::Not(value) => value.known_byte(index).map(|byte| !byte), + SymExprKind::Ite(_, then_expr, else_expr) => { + let then_byte = then_expr.known_byte(index)?; + let else_byte = else_expr.known_byte(index)?; + (then_byte == else_byte).then_some(then_byte) + } + SymExprKind::BinOp(op, left, right) => match op { + SymBinOp::And => match (left.known_byte(index), right.known_byte(index)) { + (Some(left), Some(right)) => Some(left & right), + (Some(0), _) | (_, Some(0)) => Some(0), + _ => None, + }, + SymBinOp::Or => Some(left.known_byte(index)? | right.known_byte(index)?), + SymBinOp::Xor => Some(left.known_byte(index)? ^ right.known_byte(index)?), + SymBinOp::Shl => { + let shift = right.as_const()?; + if shift >= U256::from(256) { + return Some(0); + } + let shift = usize::try_from(shift).expect("checked byte shift"); + if shift % 8 != 0 { + return None; + } + let source_index = index + shift / 8; + if source_index >= 32 { Some(0) } else { left.known_byte(source_index) } + } + SymBinOp::Shr => { + let shift = right.as_const()?; + if shift >= U256::from(256) { + return Some(0); + } + let shift = usize::try_from(shift).expect("checked byte shift"); + if shift % 8 != 0 { + return None; + } + let byte_shift = shift / 8; + if index < byte_shift { Some(0) } else { left.known_byte(index - byte_shift) } + } + SymBinOp::Add + | SymBinOp::Sub + | SymBinOp::Mul + | SymBinOp::UDiv + | SymBinOp::URem + | SymBinOp::SDiv + | SymBinOp::SRem + | SymBinOp::Sar => None, + }, + SymExprKind::TernOp(_, _, _, _) => None, + } + } + + pub(crate) fn known_word(&self) -> Option { + let mut word = [0u8; 32]; + for (idx, byte) in word.iter_mut().enumerate() { + *byte = self.known_byte(idx)?; + } + Some(U256::from_be_bytes(word)) + } + + pub(crate) fn unsigned_bits(&self) -> usize { + let mut bit_widths = HashMap::default(); + let mut remaining = usize::MAX; + self.unsigned_bits_cached(&mut bit_widths, &mut remaining).unwrap_or(256) + } + + fn unsigned_bits_cached( + &self, + bit_widths: &mut HashMap, + remaining: &mut usize, + ) -> Option { + if let Some(bits) = bit_widths.get(self) { + return Some(*bits); + } + + let mut pending = vec![(self.clone(), false)]; + while let Some((expr, children_visited)) = pending.pop() { + if bit_widths.contains_key(&expr) { + continue; + } + if !children_visited { + if *remaining == 0 { + return None; + } + *remaining -= 1; + pending.push((expr.clone(), true)); + match expr.kind() { + SymExprKind::BinOp(SymBinOp::And, left, right) + if right.as_const().is_some() => + { + pending.push((left.clone(), false)); + } + SymExprKind::BinOp(SymBinOp::Add | SymBinOp::Mul, left, right) + | SymExprKind::Ite(_, left, right) => { + pending.push((right.clone(), false)); + pending.push((left.clone(), false)); + } + SymExprKind::BinOp(SymBinOp::Shl | SymBinOp::Shr, left, right) + if right + .as_const() + .and_then(|shift| usize::try_from(shift).ok()) + .is_some() => + { + pending.push((left.clone(), false)); + } + SymExprKind::BinOp(SymBinOp::UDiv, left, _) => { + pending.push((left.clone(), false)); + } + SymExprKind::TernOp(_, _, _, modulus) => { + pending.push((modulus.clone(), false)); + } + _ => {} + } + continue; + } + + let bits = match expr.kind() { + SymExprKind::Const(value) => value.bit_len().max(1), + SymExprKind::BinOp(SymBinOp::And, left, right) => { + if let Some(mask) = right.as_const() { + bit_widths[left].min(mask.bit_len()) + } else { + 256 + } + } + SymExprKind::BinOp(SymBinOp::Add, left, right) => { + bit_widths[left].max(bit_widths[right]).saturating_add(1).min(256) + } + SymExprKind::BinOp(SymBinOp::Mul, left, right) => { + bit_widths[left].saturating_add(bit_widths[right]).min(256) + } + SymExprKind::BinOp(SymBinOp::Shl, left, right) => right + .as_const() + .and_then(|shift| usize::try_from(shift).ok()) + .map_or(256, |shift| bit_widths[left].saturating_add(shift).min(256)), + SymExprKind::BinOp(SymBinOp::Shr, left, right) => right + .as_const() + .and_then(|shift| usize::try_from(shift).ok()) + .map_or(256, |shift| bit_widths[left].saturating_sub(shift).max(1)), + SymExprKind::BinOp(SymBinOp::UDiv, left, _) => bit_widths[left], + SymExprKind::TernOp(_, _, _, modulus) => bit_widths[modulus], + SymExprKind::Ite(_, left, right) => bit_widths[left].max(bit_widths[right]), + _ => 256, + }; + bit_widths.insert(expr, bits); + } + bit_widths.get(self).copied() + } + + pub(crate) fn extracted_byte(&self, cx: &mut SymCx, index: usize) -> Self { + debug_assert!(index < 32); + let shift = Self::constant(cx, U256::from((31 - index) * 8)); + let shifted = Self::binop(cx, SymBinOp::Shr, self.clone(), shift); + let mask = Self::constant(cx, U256::from(0xff)); + Self::binop(cx, SymBinOp::And, shifted, mask) + } + + pub(crate) fn extracted_byte_source(&self, index: usize) -> Option { + let expr = self.strip_low_byte_mask(); + if index == 31 { + return Some(expr.clone()); + } + let SymExprKind::BinOp(SymBinOp::Shr, source, shift) = expr.kind() else { return None }; + let shift = shift.as_const()?; + (shift == U256::from((31 - index) * 8)).then(|| source.clone()) + } + + pub(crate) fn strip_low_byte_mask(&self) -> &Self { + match self.kind() { + SymExprKind::BinOp(SymBinOp::And, left, right) + if right.as_const() == Some(U256::from(0xff)) => + { + left.strip_low_byte_mask() + } + _ => self, + } + } + + pub(crate) fn byte_term(&self, cx: &mut SymCx, index: usize) -> Option { + debug_assert!(index < 32); + + match self.kind() { + SymExprKind::Const(value) => { + Some(Self::constant(cx, U256::from(value.to_be_bytes::<32>()[index]))) + } + SymExprKind::Var(_) + | SymExprKind::GasLeft(_) + | SymExprKind::Keccak { .. } + | SymExprKind::Hash { .. } => Some(self.extracted_byte(cx, index)), + SymExprKind::Not(value) => { + let value = value.byte_term(cx, index)?; + Some(Self::not(cx, value)) + } + SymExprKind::Ite(cond, then_expr, else_expr) => { + let then_expr = then_expr.byte_term(cx, index)?; + let else_expr = else_expr.byte_term(cx, index)?; + Some(Self::ite(cx, cond.clone(), then_expr, else_expr)) + } + SymExprKind::BinOp(op, left, right) => match op { + SymBinOp::And => Self::binary_byte_term( + cx, + left, + right, + index, + SymBinOp::And, + |byte| byte == 0xff, + |byte| byte == 0, + ), + SymBinOp::Or => Self::binary_byte_term( + cx, + left, + right, + index, + SymBinOp::Or, + |byte| byte == 0, + |_| false, + ), + SymBinOp::Xor => Self::binary_byte_term( + cx, + left, + right, + index, + SymBinOp::Xor, + |byte| byte == 0, + |_| false, + ), + SymBinOp::Shl => { + let shift = right.eval()?; + if shift >= U256::from(256) { + return Some(Self::zero(cx)); + } + let shift = usize::try_from(shift).expect("checked byte shift"); + if shift % 8 != 0 { + return None; + } + let source_index = index + shift / 8; + if source_index >= 32 { + Some(Self::zero(cx)) + } else { + left.byte_term(cx, source_index) + } + } + SymBinOp::Shr => { + let shift = right.eval()?; + if shift >= U256::from(256) { + return Some(Self::zero(cx)); + } + let shift = usize::try_from(shift).expect("checked byte shift"); + if shift % 8 != 0 { + return None; + } + let byte_shift = shift / 8; + if index < byte_shift { + Some(Self::zero(cx)) + } else { + left.byte_term(cx, index - byte_shift) + } + } + SymBinOp::Add + | SymBinOp::Sub + | SymBinOp::Mul + | SymBinOp::UDiv + | SymBinOp::URem + | SymBinOp::SDiv + | SymBinOp::SRem + | SymBinOp::Sar => None, + }, + SymExprKind::TernOp(_, _, _, _) => None, + } + } + + fn binary_byte_term( + cx: &mut SymCx, + left: &Self, + right: &Self, + index: usize, + op: SymBinOp, + identity: impl Fn(u8) -> bool, + absorbing: impl Fn(u8) -> bool, + ) -> Option { + let left = left.byte_term(cx, index)?; + let right = right.byte_term(cx, index)?; + match ( + left.as_const().map(|value| value.to::()), + right.as_const().map(|value| value.to::()), + ) { + (Some(left), _) if absorbing(left) => Some(Self::constant(cx, U256::from(left))), + (_, Some(right)) if absorbing(right) => Some(Self::constant(cx, U256::from(right))), + (Some(left), _) if identity(left) => Some(right), + (_, Some(right)) if identity(right) => Some(left), + _ => Some(Self::binop(cx, op, left, right)), + } + } + + pub(crate) fn equality_forces_const( + &self, + value: U256, + expr: &Self, + context: &[SymBoolExpr], + ) -> Option { + if self == expr { + return Some(value); + } + self.equality_forces_const_inner(value, expr, context) + } + + fn equality_forces_const_inner( + &self, + value: U256, + expr: &Self, + context: &[SymBoolExpr], + ) -> Option { + let mask = masked_expr_matches(self.kind(), expr)?; + if !(value & !mask).is_zero() || !context_forces_masked_expr(context, expr, mask) { + return None; + } + Some(value) + } + + pub(crate) fn nonzero_forces_const( + &self, + target: &Self, + context: &[SymBoolExpr], + ) -> Option { + match self.kind() { + SymExprKind::Const(_) + | SymExprKind::Var(_) + | SymExprKind::GasLeft(_) + | SymExprKind::Keccak { .. } + | SymExprKind::Hash { .. } + | SymExprKind::Not(_) => None, + SymExprKind::Ite(cond, then_expr, else_expr) => { + if then_expr.eval().is_some_and(|value| !value.is_zero()) + && else_expr.eval().is_some_and(|value| value.is_zero()) + { + cond.forces_expr_const_with_context(target, context) + } else { + None + } + } + SymExprKind::BinOp(SymBinOp::Or, left, right) => { + if left.eval().is_some_and(|value| value.is_zero()) { + return right.nonzero_forces_const(target, context); + } + if right.eval().is_some_and(|value| value.is_zero()) { + return left.nonzero_forces_const(target, context); + } + None + } + SymExprKind::BinOp(SymBinOp::And, left, right) => { + if left.eval().is_some_and(|value| !value.is_zero()) { + return right.nonzero_forces_const(target, context); + } + if right.eval().is_some_and(|value| !value.is_zero()) { + return left.nonzero_forces_const(target, context); + } + None + } + SymExprKind::BinOp(SymBinOp::Shl | SymBinOp::Shr, value, shift) + if shift.eval().is_some_and(|shift| shift.is_zero()) => + { + value.nonzero_forces_const(target, context) + } + SymExprKind::TernOp(_, _, _, _) => None, + SymExprKind::BinOp(_, _, _) => None, + } + } + + pub(crate) fn is_raw_gasleft(&self) -> bool { + matches!(self.kind(), SymExprKind::GasLeft(_)) + } + + pub(crate) fn add_const(cx: &mut SymCx, expr: Self, value: U256) -> Self { + if value.is_zero() { + return expr; + } + match expr.kind() { + SymExprKind::Const(expr) => Self::constant(cx, expr.wrapping_add(value)), + _ => { + let value = Self::constant(cx, value); + Self::binop(cx, SymBinOp::Add, expr, value) + } + } + } + + /// Returns a constant `self - other` modulo the EVM word size when it follows from the + /// expression structure on every branch. + pub(crate) fn constant_difference(&self, other: &Self) -> Option { + let mut differences = HashMap::default(); + let mut remaining = MAX_CONSTANT_DIFFERENCE_VISITS; + self.constant_difference_cached(other, &mut differences, &mut remaining) + } + + fn constant_difference_cached( + &self, + other: &Self, + differences: &mut HashMap<(Self, Self), Option>, + remaining: &mut usize, + ) -> Option { + if self == other { + return Some(U256::ZERO); + } + let key = (self.clone(), other.clone()); + if let Some(difference) = differences.get(&key) { + return *difference; + } + *remaining = remaining.checked_sub(1)?; + + let difference = match (self.kind(), other.kind()) { + (SymExprKind::Const(left), SymExprKind::Const(right)) => { + Some(left.wrapping_sub(*right)) + } + ( + SymExprKind::Ite(left_condition, left_then, left_else), + SymExprKind::Ite(right_condition, right_then, right_else), + ) if left_condition == right_condition => { + let then_difference = + left_then.constant_difference_cached(right_then, differences, remaining)?; + let else_difference = + left_else.constant_difference_cached(right_else, differences, remaining)?; + (then_difference == else_difference).then_some(then_difference) + } + (SymExprKind::BinOp(SymBinOp::Add, value, constant), _) + if let Some(constant) = constant.as_const() => + { + value + .constant_difference_cached(other, differences, remaining) + .map(|difference| difference.wrapping_add(constant)) + } + (SymExprKind::BinOp(SymBinOp::Sub, value, constant), _) + if let Some(constant) = constant.as_const() => + { + value + .constant_difference_cached(other, differences, remaining) + .map(|difference| difference.wrapping_sub(constant)) + } + (_, SymExprKind::BinOp(SymBinOp::Add, value, constant)) + if let Some(constant) = constant.as_const() => + { + self.constant_difference_cached(value, differences, remaining) + .map(|difference| difference.wrapping_sub(constant)) + } + (_, SymExprKind::BinOp(SymBinOp::Sub, value, constant)) + if let Some(constant) = constant.as_const() => + { + self.constant_difference_cached(value, differences, remaining) + .map(|difference| difference.wrapping_add(constant)) + } + _ => None, + }; + differences.insert(key, difference); + difference + } + + /// Visits this expression and all child expressions. + pub(crate) fn visit( + &self, + visitor: &mut impl FnMut(&Self) -> ControlFlow, + ) -> ControlFlow { + visitor(self)?; + match self.kind() { + SymExprKind::Const(_) | SymExprKind::Var(_) | SymExprKind::GasLeft(_) => {} + SymExprKind::Keccak { len, bytes, .. } => { + len.visit(visitor)?; + for byte in bytes.iter() { + byte.visit(visitor)?; + } + } + SymExprKind::Hash { bytes, .. } => { + for byte in bytes.iter() { + byte.visit(visitor)?; + } + } + SymExprKind::Not(value) => value.visit(visitor)?, + SymExprKind::BinOp(_, left, right) => { + left.visit(visitor)?; + right.visit(visitor)?; + } + SymExprKind::TernOp(_, left, right, modulus) => { + left.visit(visitor)?; + right.visit(visitor)?; + modulus.visit(visitor)?; + } + SymExprKind::Ite(cond, left, right) => { + cond.visit_exprs(visitor)?; + left.visit(visitor)?; + right.visit(visitor)?; + } + } + ControlFlow::Continue(()) + } + + /// Returns whether any node satisfies `visitor`, visiting each distinct node once. + pub(crate) fn visit_bool(&self, visitor: impl FnMut(&Self) -> bool) -> bool { + visit_unique(Vec::new(), vec![self.clone()], visitor) + } + + /// Rewrites each distinct word or nested Boolean node once in bottom-up order. + /// + /// The folder must return the same result for every occurrence of one hash-consed node. + pub(crate) fn fold( + &self, + cx: &mut SymCx, + folder: &mut impl FnMut(&mut SymCx, Self) -> Self, + ) -> Self { + let mut folded = ExpressionFoldCache::default(); + self.fold_cached(cx, folder, &mut folded) + } + + pub(in crate::runtime::expr) fn fold_cached<'a>( + &'a self, + cx: &mut SymCx, + folder: &mut impl FnMut(&mut SymCx, Self) -> Self, + folded: &mut ExpressionFoldCache<'a>, + ) -> Self { + if let Some(expr) = folded.words.get(self) { + return expr.clone(); + } + + let expr = match self.kind() { + SymExprKind::Const(_) | SymExprKind::Var(_) | SymExprKind::GasLeft(_) => self.clone(), + SymExprKind::Keccak { name, len, bytes } => { + let len = len.fold_cached(cx, folder, folded); + let bytes = bytes.iter().map(|byte| byte.fold_cached(cx, folder, folded)).collect(); + Self::keccak_symbol(cx, *name, len, bytes) + } + SymExprKind::Hash { name, algorithm, bytes } => { + let bytes = bytes.iter().map(|byte| byte.fold_cached(cx, folder, folded)).collect(); + Self::hash_symbol(cx, *name, algorithm, bytes) + } + SymExprKind::Not(value) => { + let value = value.fold_cached(cx, folder, folded); + Self::not(cx, value) + } + SymExprKind::BinOp(op, left, right) => { + let left = left.fold_cached(cx, folder, folded); + let right = right.fold_cached(cx, folder, folded); + Self::binop(cx, *op, left, right) + } + SymExprKind::TernOp(op, left, right, modulus) => { + let left = left.fold_cached(cx, folder, folded); + let right = right.fold_cached(cx, folder, folded); + let modulus = modulus.fold_cached(cx, folder, folded); + Self::ternop(cx, *op, left, right, modulus) + } + SymExprKind::Ite(condition, then_expr, else_expr) => { + let condition = condition.fold_exprs_cached(cx, folder, folded); + let then_expr = then_expr.fold_cached(cx, folder, folded); + let else_expr = else_expr.fold_cached(cx, folder, folded); + Self::ite(cx, condition, then_expr, else_expr) + } + }; + let expr = folder(cx, expr); + folded.words.insert(self, expr.clone()); + expr + } + + pub(in crate::runtime::expr) fn write_smt(&self, cx: &SymCx, out: &mut String) { + match self.kind() { + SymExprKind::Const(value) => { + let _ = write!(out, "(_ bv{value} 256)"); + } + SymExprKind::Var(symbol) + | SymExprKind::GasLeft(symbol) + | SymExprKind::Keccak { name: symbol, .. } + | SymExprKind::Hash { name: symbol, .. } => out.push_str(cx.symbol_name(*symbol)), + SymExprKind::Not(value) => { + out.push_str("(bvnot "); + value.write_smt(cx, out); + out.push(')'); + } + SymExprKind::BinOp(op, left, right) => { + let _ = write!(out, "({} ", op.smt()); + left.write_smt(cx, out); + out.push(' '); + right.write_smt(cx, out); + out.push(')'); + } + SymExprKind::TernOp(op, left, right, modulus) => { + write_smt_wide_modular_arithmetic(cx, out, op.smt(), left, right, modulus); + } + SymExprKind::Ite(cond, left, right) => { + out.push_str("(ite "); + cond.write_smt(cx, out); + out.push(' '); + left.write_smt(cx, out); + out.push(' '); + right.write_smt(cx, out); + out.push(')'); + } + } + } +} + +// Branchless expression rewrites are optional. Bound both the distinct DAG nodes inspected while +// deciding whether to rewrite and the unfolded size of the expression they could produce. +const MAX_BRANCHLESS_REWRITE_NODES: usize = 256; +const MAX_BRANCHLESS_REWRITE_UNFOLDED_NODES: usize = 8 * 1024; + +struct UnfoldedNodeCounter { + expr_nodes: HashMap, + bool_nodes: HashMap, + remaining_unique_nodes: usize, +} + +impl UnfoldedNodeCounter { + fn new() -> Self { + Self { + expr_nodes: HashMap::default(), + bool_nodes: HashMap::default(), + remaining_unique_nodes: MAX_BRANCHLESS_REWRITE_NODES, + } + } + + /// Counts unfolded occurrences while memoizing the cost of each distinct expression node. + /// Reusing a cached cost still adds every occurrence, which exposes exponential shared DAGs. + fn expr_nodes(&mut self, expr: &SymExpr) -> Option { + if let Some(nodes) = self.expr_nodes.get(expr) { + return Some(*nodes); + } + if self.remaining_unique_nodes == 0 { + return None; + } + self.remaining_unique_nodes -= 1; + + let nodes = match expr.kind() { + SymExprKind::Const(_) | SymExprKind::Var(_) | SymExprKind::GasLeft(_) => 1, + SymExprKind::Keccak { len, bytes, .. } => { + let mut nodes = 1usize.checked_add(self.expr_nodes(len)?)?; + for byte in bytes.iter() { + nodes = nodes.checked_add(self.expr_nodes(byte)?)?; + } + nodes + } + SymExprKind::Hash { bytes, .. } => { + let mut nodes = 1usize; + for byte in bytes.iter() { + nodes = nodes.checked_add(self.expr_nodes(byte)?)?; + } + nodes + } + SymExprKind::Not(value) => 1usize.checked_add(self.expr_nodes(value)?)?, + SymExprKind::BinOp(_, left, right) => { + 1usize.checked_add(self.expr_nodes(left)?)?.checked_add(self.expr_nodes(right)?)? + } + SymExprKind::TernOp(_, left, right, modulus) => 1usize + .checked_add(self.expr_nodes(left)?)? + .checked_add(self.expr_nodes(right)?)? + .checked_add(self.expr_nodes(modulus)?)?, + SymExprKind::Ite(condition, then_expr, else_expr) => 1usize + .checked_add(self.bool_nodes(condition)?)? + .checked_add(self.expr_nodes(then_expr)?)? + .checked_add(self.expr_nodes(else_expr)?)?, + }; + if nodes > MAX_BRANCHLESS_REWRITE_UNFOLDED_NODES { + return None; + } + self.expr_nodes.insert(expr.clone(), nodes); + Some(nodes) + } + + fn bool_nodes(&mut self, expr: &SymBoolExpr) -> Option { + if let Some(nodes) = self.bool_nodes.get(expr) { + return Some(*nodes); + } + if self.remaining_unique_nodes == 0 { + return None; + } + self.remaining_unique_nodes -= 1; + + let nodes = match expr.kind() { + SymBoolExprKind::Const(_) => 1, + SymBoolExprKind::Not(value) => 1usize.checked_add(self.bool_nodes(value)?)?, + SymBoolExprKind::And(values) => { + let mut nodes = 1usize; + for value in values.iter() { + nodes = nodes.checked_add(self.bool_nodes(value)?)?; + } + nodes + } + SymBoolExprKind::Cmp(_, left, right) => { + 1usize.checked_add(self.expr_nodes(left)?)?.checked_add(self.expr_nodes(right)?)? + } + }; + if nodes > MAX_BRANCHLESS_REWRITE_UNFOLDED_NODES { + return None; + } + self.bool_nodes.insert(expr.clone(), nodes); + Some(nodes) + } +} + +fn write_smt_wide_modular_arithmetic( + cx: &SymCx, + out: &mut String, + op: &'static str, + left: &SymExpr, + right: &SymExpr, + modulus: &SymExpr, +) { + // if modulus == 0: + // 0 + // else: + // low_256((zext(left) op zext(right)) urem zext(modulus)) + out.push_str("(ite (= "); + modulus.write_smt(cx, out); + out.push_str(" (_ bv0 256)) (_ bv0 256) ((_ extract 255 0) (bvurem ("); + out.push_str(op); + out.push_str(" ((_ zero_extend 256) "); + left.write_smt(cx, out); + out.push_str(") ((_ zero_extend 256) "); + right.write_smt(cx, out); + out.push_str(")) ((_ zero_extend 256) "); + modulus.write_smt(cx, out); + out.push_str("))))"); +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) enum SymTernOp { + AddMod, + MulMod, +} + +impl SymTernOp { + pub(crate) const fn smt(self) -> &'static str { + match self { + Self::AddMod => "bvadd", + Self::MulMod => "bvmul", + } + } + + pub(crate) fn eval(self, left: U256, right: U256, modulus: U256) -> U256 { + if modulus.is_zero() { + return U256::ZERO; + } + match self { + Self::AddMod => left.add_mod(right, modulus), + Self::MulMod => left.mul_mod(right, modulus), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) enum SymBinOp { + Add, + Sub, + Mul, + UDiv, + URem, + SDiv, + SRem, + And, + Or, + Xor, + Shl, + Shr, + Sar, +} + +impl SymBinOp { + pub(crate) const fn smt(self) -> &'static str { + match self { + Self::Add => "bvadd", + Self::Sub => "bvsub", + Self::Mul => "bvmul", + Self::UDiv => "bvudiv", + Self::URem => "bvurem", + Self::SDiv => "bvsdiv", + Self::SRem => "bvsrem", + Self::And => "bvand", + Self::Or => "bvor", + Self::Xor => "bvxor", + Self::Shl => "bvshl", + Self::Shr => "bvlshr", + Self::Sar => "bvashr", + } + } + + pub(crate) fn eval(self, left: U256, right: U256) -> U256 { + match self { + Self::Add => left.wrapping_add(right), + Self::Sub => left.wrapping_sub(right), + Self::Mul => left.wrapping_mul(right), + Self::UDiv => { + if right.is_zero() { + U256::ZERO + } else { + left / right + } + } + Self::URem => { + if right.is_zero() { + U256::ZERO + } else { + left % right + } + } + Self::SDiv => i256_div(left, right), + Self::SRem => i256_mod(left, right), + Self::And => left & right, + Self::Or => left | right, + Self::Xor => left ^ right, + Self::Shl => { + if right >= U256::from(256) { + U256::ZERO + } else { + left << usize::try_from(right).expect("checked word shift") + } + } + Self::Shr => { + if right >= U256::from(256) { + U256::ZERO + } else { + left >> usize::try_from(right).expect("checked word shift") + } + } + Self::Sar => { + if right >= U256::from(256) { + left.arithmetic_shr(256) + } else { + left.arithmetic_shr(usize::try_from(right).expect("checked word shift")) + } + } + } + } +} + +pub(crate) fn keccak_word(cx: &mut SymCx, bytes: Vec) -> SymExpr { + let len = bytes.len(); + let len = SymExpr::constant(cx, U256::from(len)); + keccak_word_with_len(cx, bytes, len) +} + +pub(crate) fn keccak_word_with_len(cx: &mut SymCx, bytes: Vec, len: SymExpr) -> SymExpr { + if let Some(len) = len.as_const() + && let Ok(len) = usize::try_from(len) + && len <= bytes.len() + && let Ok(concrete) = concrete_expr_bytes(&bytes[..len], "symbolic keccak input") + { + let hash = Into::::into(keccak256(concrete)); + if len == 64 { + cx.record_concrete_keccak_preimage(hash, bytes[..len].to_vec().into()); + } + return SymExpr::constant(cx, hash); + } + + let exprs = bytes; + let identity = ExpressionDigests::identity(std::iter::once(&len).chain(&exprs)); + let name = stable_symbol(cx, "keccak", identity.as_slice()); + SymExpr::keccak_symbol(cx, name, len, exprs) +} + +pub(crate) fn symbolic_hash_word_with_len( + cx: &mut SymCx, + algorithm: &'static str, + bytes: Vec, + len: SymExpr, +) -> SymExpr { + let exprs = bytes; + let identity = ExpressionDigests::identity(std::iter::once(&len).chain(&exprs)); + let name = stable_symbol(cx, algorithm, identity.as_slice()); + let mut identity = Vec::with_capacity(exprs.len() + 1); + identity.push(len); + identity.extend(exprs); + SymExpr::hash_symbol(cx, name, algorithm, identity) +} + +pub(crate) fn create2_address_word( + cx: &mut SymCx, + state: &mut PathState, + creator: Address, + salt: SymExpr, + initcode: &SymCode, +) -> Result<(SymExpr, Address), SymbolicError> { + match (salt.as_const(), initcode.concrete_bytes(cx, "symbolic CREATE2 initcode")) { + (Some(salt), Ok(initcode)) => { + let address = creator.create2_from_code(salt.to_be_bytes::<32>(), &initcode); + Ok((SymExpr::constant(cx, address_word(address)), address)) + } + (None, Ok(initcode)) => { + let initcode_hash = keccak256(&initcode); + let word = symbolic_create2_address_word( + cx, + state, + format!("address:{creator:?}"), + salt, + format!("hash:{initcode_hash:?}"), + ); + let address = state.world.symbolic_address_slot(word.clone()); + Ok((word, address)) + } + (_, Err(SymbolicError::Unsupported("symbolic CREATE2 initcode"))) => { + let initcode_bytes = initcode.read_byte_exprs(cx, 0, initcode.len()); + let word = symbolic_create2_address_word( + cx, + state, + format!("address:{creator:?}"), + salt, + format!("initcode:{:?}", ExpressionDigests::identity(&initcode_bytes)), + ); + let address = state.world.symbolic_address_slot(word.clone()); + Ok((word, address)) + } + (_, Err(err)) => Err(err), + } +} + +pub(crate) fn compute_create2_address_word( + cx: &mut SymCx, + state: &mut PathState, + deployer: SymExpr, + salt: SymExpr, + init_code_hash: SymExpr, +) -> Result { + let deployer_concrete = state.constrained_word(cx, &deployer).map(word_to_address); + let salt_concrete = state.constrained_word(cx, &salt); + let init_code_hash_concrete = state.constrained_word(cx, &init_code_hash); + + if let (Some(deployer), Some(salt), Some(init_code_hash)) = + (deployer_concrete, salt_concrete, init_code_hash_concrete) + { + let init_code_hash = B256::from(init_code_hash); + let address = deployer.create2(B256::from(salt), init_code_hash); + return Ok(SymExpr::constant(cx, address_word(address))); + } + + let deployer_identity = deployer_identity(deployer_concrete, &deployer); + let init_code_hash_identity = init_code_hash_concrete + .map(|init_code_hash| { + let init_code_hash = B256::from(init_code_hash); + format!("hash:{init_code_hash:?}") + }) + .unwrap_or_else(|| { + format!("hash_expr:{:?}", ExpressionDigests::identity([&init_code_hash])) + }); + + Ok(symbolic_create2_address_word(cx, state, deployer_identity, salt, init_code_hash_identity)) +} + +pub(crate) fn compute_create_address_word( + cx: &mut SymCx, + state: &mut PathState, + deployer: SymExpr, + nonce: SymExpr, +) -> Result { + let deployer_concrete = state.constrained_word(cx, &deployer).map(word_to_address); + let nonce_concrete = state.constrained_word(cx, &nonce); + + if let (Some(deployer), Some(nonce)) = (deployer_concrete, nonce_concrete) { + let Ok(nonce) = u64::try_from(nonce) else { + return Err(SymbolicError::Unsupported("symbolic vm.computeCreateAddress nonce")); + }; + return Ok(SymExpr::constant(cx, address_word(deployer.create(nonce)))); + } + + let deployer_identity = deployer_identity(deployer_concrete, &deployer); + Ok(symbolic_create_address_word(cx, state, deployer_identity, nonce)) +} + +pub(crate) fn symbolic_create_address_word( + cx: &mut SymCx, + state: &mut PathState, + creator_identity: String, + nonce: SymExpr, +) -> SymExpr { + let nonce = ExpressionDigests::identity([&nonce]); + let name = + stable_symbol(cx, "create_address", format!("{creator_identity}:{nonce:?}").as_bytes()); + let word = SymExpr::get_var(cx, name); + state.constraints.push(SymBoolExpr::cmp_word_const(cx, SymCmpOp::Ult, &word, U256::ONE << 160)); + word +} + +pub(crate) fn symbolic_create2_address_word( + cx: &mut SymCx, + state: &mut PathState, + creator_identity: String, + salt: SymExpr, + initcode_identity: String, +) -> SymExpr { + let name = stable_symbol( + cx, + "create2_address", + format!( + "{creator_identity}:{:?}:{initcode_identity}", + ExpressionDigests::identity([&salt]) + ) + .as_bytes(), + ); + let word = SymExpr::get_var(cx, name); + state.constraints.push(SymBoolExpr::cmp_word_const(cx, SymCmpOp::Ult, &word, U256::ONE << 160)); + word +} + +/// Identifies a deployer in a stable address symbol name. +/// +/// The prefixes keep a concrete address apart from the digest of a symbolic deployer. +fn deployer_identity(concrete: Option
, deployer: &SymExpr) -> String { + concrete + .map(|deployer| format!("address:{deployer:?}")) + .unwrap_or_else(|| format!("address_expr:{:?}", ExpressionDigests::identity([deployer]))) +} diff --git a/crates/evm/symbolic/src/runtime/expr/hashcons.rs b/crates/evm/symbolic/src/runtime/expr/hashcons.rs new file mode 100644 index 0000000000000..37f8081db6f6e --- /dev/null +++ b/crates/evm/symbolic/src/runtime/expr/hashcons.rs @@ -0,0 +1,177 @@ +use alloy_primitives::map::foldhash::fast::FixedState; +use hashbrown::{HashTable, hash_table::Entry}; +use std::{ + cmp::Ordering, + fmt, + hash::{BuildHasher, Hash, Hasher}, + sync::{Arc, Weak}, +}; + +/// Shared handle for a hash-consed value. +/// +/// Equality is pointer equality only. Hashing writes the cached structural hash +/// instead of walking the value. +pub(in crate::runtime) struct HashConsed { + inner: Arc>, +} + +struct HashConsedInner { + hash: u64, + value: T, +} + +impl HashConsed { + #[inline] + pub(in crate::runtime::expr) fn stable_hash_cmp(&self, other: &Self) -> Ordering { + self.inner.hash.cmp(&other.inner.hash) + } + + /// Orders nodes within one hash-consing context without inspecting or rendering their value. + /// + /// The cached structural hash handles the common case. Pointer identity is only a tie-breaker + /// for distinct nodes with the same hash; structurally equal values share one node. + #[inline] + pub(in crate::runtime::expr) fn identity_cmp(&self, other: &Self) -> Ordering { + self.stable_hash_cmp(other).then_with(|| { + let left = Arc::as_ptr(&self.inner); + let right = Arc::as_ptr(&other.inner); + left.cmp(&right) + }) + } + + #[inline] + pub(in crate::runtime) fn value(&self) -> &T { + &self.inner.value + } +} + +impl Clone for HashConsed { + #[inline] + fn clone(&self) -> Self { + Self { inner: self.inner.clone() } + } +} + +impl PartialEq for HashConsed { + #[inline] + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.inner, &other.inner) + } +} + +impl Eq for HashConsed {} + +impl Hash for HashConsed { + #[inline] + fn hash(&self, state: &mut H) { + self.inner.hash.hash(state); + } +} + +impl PartialOrd for HashConsed { + #[inline] + fn partial_cmp(&self, other: &Self) -> Option { + self.value().partial_cmp(other.value()) + } +} + +impl Ord for HashConsed { + #[inline] + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.value().cmp(other.value()) + } +} + +impl fmt::Debug for HashConsed { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.value().fmt(f) + } +} + +type HashConsHasher = FixedState; +const MIN_GC_THRESHOLD: usize = 1024; + +/// Hash-consing table for sharing structurally equal immutable values. +/// +/// The table stores weak references so interned values disappear when the rest of +/// the symbolic state stops using them. `make` removes dead entries encountered +/// during lookup and periodically sweeps distinct dead values before they can +/// grow the table without bound. +pub(in crate::runtime) struct HashCons { + table: HashTable>, + hash_builder: HashConsHasher, + gc_threshold: usize, +} + +struct HashConsEntry { + hash: u64, + value: Weak>, +} + +impl HashConsEntry { + const fn hash(&self) -> u64 { + self.hash + } +} + +impl HashCons { + pub(in crate::runtime) fn new() -> Self { + Self { + table: HashTable::new(), + hash_builder: HashConsHasher::default(), + gc_threshold: MIN_GC_THRESHOLD, + } + } + + fn hash(&self, value: &Q) -> u64 { + self.hash_builder.hash_one(value) + } +} + +impl HashCons { + pub(in crate::runtime) fn make(&mut self, value: T) -> HashConsed { + if self.table.len() >= self.gc_threshold { + self.table.retain(|entry| entry.value.strong_count() != 0); + self.gc_threshold = self.table.len().saturating_mul(2).max(MIN_GC_THRESHOLD); + } + + let hash = self.hash(&value); + loop { + let mut found = None; + let mut matched_dead_entry = false; + match self.table.entry( + hash, + |entry| { + if entry.hash() != hash { + return false; + } + match entry.value.upgrade() { + Some(existing) if existing.value == value => { + found = Some(existing); + true + } + None => { + matched_dead_entry = true; + true + } + Some(_) => false, + } + }, + HashConsEntry::hash, + ) { + Entry::Occupied(entry) => { + if let Some(inner) = found { + return HashConsed { inner }; + } + debug_assert!(matched_dead_entry); + let _ = entry.remove(); + } + Entry::Vacant(entry) => { + let inner = Arc::new(HashConsedInner { hash, value }); + entry.insert(HashConsEntry { hash, value: Arc::downgrade(&inner) }); + return HashConsed { inner }; + } + } + } + } +} diff --git a/crates/evm/symbolic/src/runtime/expr/mod.rs b/crates/evm/symbolic/src/runtime/expr/mod.rs new file mode 100644 index 0000000000000..7ed178eef18a3 --- /dev/null +++ b/crates/evm/symbolic/src/runtime/expr/mod.rs @@ -0,0 +1,272 @@ +use super::*; + +mod bool; +mod cx; +pub(super) mod hashcons; +#[path = "expr.rs"] +mod word; + +pub(crate) use bool::*; +pub(crate) use cx::*; +pub(crate) use word::*; + +struct NoopModel; + +impl SymbolicModelLookup for NoopModel { + fn value(&self, _name: Symbol) -> Option { + None + } +} + +/// Results from one deterministic bottom-up expression fold. +/// +/// Keys borrow the original DAG so the memo table does not add strong references to source nodes. +#[derive(Default)] +struct ExpressionFoldCache<'a> { + words: HashMap<&'a SymExpr, SymExpr>, + bools: HashMap<&'a SymBoolExpr, SymBoolExpr>, +} + +/// Structural digests that identify expressions in stable symbol names. +/// +/// Each distinct hash-consed node is digested once. Formatting a node with `Debug` instead +/// prints the DAG as a tree, which grows exponentially when subexpressions are shared, as in a +/// chain of hashes over the previous hash. +#[derive(Default)] +pub(crate) struct ExpressionDigests<'a> { + words: HashMap<&'a SymExpr, B256>, + bools: HashMap<&'a SymBoolExpr, B256>, +} + +impl<'a> ExpressionDigests<'a> { + /// Returns one digest that identifies a sequence of expressions. + pub(crate) fn identity(exprs: impl IntoIterator) -> B256 { + let mut digests = Self::default(); + let mut hasher = Keccak256::new(); + for expr in exprs { + hasher.update(digests.digest(DigestNode::Word(expr))); + } + hasher.finalize() + } + + /// Digests a node in post-order with an explicit stack, because expressions can be deeper + /// than the thread stack allows for recursion. + fn digest(&mut self, root: DigestNode<'a>) -> B256 { + let mut stack = vec![(root, false)]; + while let Some((node, children_done)) = stack.pop() { + if self.get(node).is_some() { + continue; + } + if !children_done { + stack.push((node, true)); + stack.extend(node.children().into_iter().map(|child| (child, false))); + continue; + } + let mut hasher = Keccak256::new(); + match node { + DigestNode::Word(expr) => { + match expr.kind() { + SymExprKind::Const(value) => { + hasher.update([0]); + hasher.update(value.to_be_bytes::<32>()); + } + SymExprKind::Var(symbol) => { + hasher.update([1]); + hasher.update(symbol.id().get().to_be_bytes()); + } + SymExprKind::GasLeft(symbol) => { + hasher.update([2]); + hasher.update(symbol.id().get().to_be_bytes()); + } + // A hash name is already a digest of its preimage. + SymExprKind::Keccak { name, .. } => { + hasher.update([3]); + hasher.update(name.id().get().to_be_bytes()); + } + SymExprKind::Hash { name, algorithm, .. } => { + hasher.update([4]); + hasher.update(algorithm.as_bytes()); + hasher.update(name.id().get().to_be_bytes()); + } + SymExprKind::Not(_) => hasher.update([5]), + SymExprKind::BinOp(op, _, _) => hasher.update([6, *op as u8]), + SymExprKind::TernOp(op, _, _, _) => hasher.update([7, *op as u8]), + SymExprKind::Ite(_, _, _) => hasher.update([8]), + } + } + DigestNode::Bool(expr) => match expr.kind() { + SymBoolExprKind::Const(value) => hasher.update([9, u8::from(*value)]), + SymBoolExprKind::Not(_) => hasher.update([10]), + SymBoolExprKind::And(_) => hasher.update([11]), + SymBoolExprKind::Cmp(op, _, _) => hasher.update([12, *op as u8]), + }, + } + for child in node.children() { + hasher.update(self.get(child).expect("children are digested first")); + } + let digest = hasher.finalize(); + match node { + DigestNode::Word(expr) => self.words.insert(expr, digest), + DigestNode::Bool(expr) => self.bools.insert(expr, digest), + }; + } + self.get(root).expect("root is digested") + } + + fn get(&self, node: DigestNode<'a>) -> Option { + match node { + DigestNode::Word(expr) => self.words.get(expr).copied(), + DigestNode::Bool(expr) => self.bools.get(expr).copied(), + } + } +} + +#[derive(Clone, Copy)] +enum DigestNode<'a> { + Word(&'a SymExpr), + Bool(&'a SymBoolExpr), +} + +impl<'a> DigestNode<'a> { + /// Returns the operands that contribute to the digest. Hash preimages are left out, because + /// the hash name already commits to them. + fn children(self) -> Vec { + match self { + Self::Word(expr) => match expr.kind() { + SymExprKind::Const(_) + | SymExprKind::Var(_) + | SymExprKind::GasLeft(_) + | SymExprKind::Keccak { .. } + | SymExprKind::Hash { .. } => vec![], + SymExprKind::Not(value) => vec![Self::Word(value)], + SymExprKind::BinOp(_, left, right) => vec![Self::Word(left), Self::Word(right)], + SymExprKind::TernOp(_, first, second, third) => { + vec![Self::Word(first), Self::Word(second), Self::Word(third)] + } + SymExprKind::Ite(condition, then, otherwise) => { + vec![Self::Bool(condition), Self::Word(then), Self::Word(otherwise)] + } + }, + Self::Bool(expr) => match expr.kind() { + SymBoolExprKind::Const(_) => vec![], + SymBoolExprKind::Not(value) => vec![Self::Bool(value)], + SymBoolExprKind::And(values) => values.iter().map(Self::Bool).collect(), + SymBoolExprKind::Cmp(_, left, right) => vec![Self::Word(left), Self::Word(right)], + }, + } + } +} + +/// Evaluates hash-consed expressions once per model. +/// +/// Symbolic expressions form a DAG, so recursively evaluating both operands without caching can +/// revisit the same node exponentially many times. +struct ModelEvaluator<'a, M: ?Sized> { + model: &'a M, + words: HashMap, + bools: HashMap, +} + +impl<'a, M: SymbolicModelLookup + ?Sized> ModelEvaluator<'a, M> { + fn new(model: &'a M) -> Self { + Self { model, words: HashMap::default(), bools: HashMap::default() } + } + + fn eval_word(&mut self, expr: &SymExpr) -> Result { + let kind = expr.kind(); + if let Some(var) = kind.get_eval_var() { + return Ok(self.model.value(var).unwrap_or_default()); + } + if let SymExprKind::Const(value) = kind { + return Ok(*value); + } + if let Some(value) = self.words.get(expr) { + return Ok(*value); + } + + let value = match kind { + SymExprKind::Const(_) + | SymExprKind::Var(_) + | SymExprKind::GasLeft(_) + | SymExprKind::Hash { .. } => unreachable!("symbolic eval leaf handled above"), + SymExprKind::Keccak { len, bytes, .. } => { + let len = self.eval_word(len)?; + let Ok(len) = usize::try_from(len) else { + return Err(SymbolicError::Solver( + "solver model uses an invalid keccak length".to_string(), + )); + }; + if len > bytes.len() { + return Err(SymbolicError::Solver( + "solver model uses an invalid keccak length".to_string(), + )); + } + + let mut input = Vec::with_capacity(len); + for byte in bytes.iter().take(len) { + input.push((self.eval_word(byte)? & U256::from(0xff)).to::()); + } + keccak256(input).into() + } + SymExprKind::Not(value) => !self.eval_word(value)?, + SymExprKind::BinOp(op, left, right) => { + op.eval(self.eval_word(left)?, self.eval_word(right)?) + } + SymExprKind::TernOp(op, left, right, modulus) => { + op.eval(self.eval_word(left)?, self.eval_word(right)?, self.eval_word(modulus)?) + } + SymExprKind::Ite(condition, then_expr, else_expr) => { + if self.eval_bool(condition)? { + self.eval_word(then_expr)? + } else { + self.eval_word(else_expr)? + } + } + }; + self.words.insert(expr.clone(), value); + Ok(value) + } + + fn eval_bool(&mut self, expr: &SymBoolExpr) -> Result { + let kind = expr.kind(); + if let SymBoolExprKind::Const(value) = kind { + return Ok(*value); + } + // `Not` and `Cmp` cheaply recombine child results, so memoizing them adds one-use entries + // for ordinary path constraints. Conjunctions can share additional Boolean work. + let cache_result = matches!(kind, SymBoolExprKind::And(_)); + if cache_result && let Some(value) = self.bools.get(expr) { + return Ok(*value); + } + + let value = match kind { + SymBoolExprKind::Const(_) => unreachable!("symbolic eval leaf handled above"), + SymBoolExprKind::Not(value) => !self.eval_bool(value)?, + SymBoolExprKind::And(values) => { + let mut result = true; + for value in values.iter() { + if !self.eval_bool(value)? { + result = false; + break; + } + } + result + } + SymBoolExprKind::Cmp(op, left, right) => { + op.eval(self.eval_word(left)?, self.eval_word(right)?) + } + }; + if cache_result { + self.bools.insert(expr.clone(), value); + } + Ok(value) + } +} + +pub(crate) fn eval_model_constraints( + constraints: &[SymBoolExpr], + model: &M, +) -> bool { + let mut evaluator = ModelEvaluator::new(model); + constraints.iter().all(|constraint| evaluator.eval_bool(constraint).unwrap_or(false)) +} diff --git a/crates/evm/symbolic/src/runtime/memory.rs b/crates/evm/symbolic/src/runtime/memory.rs new file mode 100644 index 0000000000000..18fd58c0731c0 --- /dev/null +++ b/crates/evm/symbolic/src/runtime/memory.rs @@ -0,0 +1,1154 @@ +use super::*; +use foundry_evm::revm::interpreter::STACK_LIMIT; + +#[derive(Clone, Debug, Default)] +pub(crate) struct SymStack(Vec); + +impl SymStack { + pub(crate) fn push(&mut self, value: SymExpr) -> Result<(), SymbolicError> { + if self.0.len() >= STACK_LIMIT { + return Err(SymbolicError::StackOverflow); + } + self.0.push(value); + Ok(()) + } + + pub(crate) fn pop(&mut self) -> Result { + self.0.pop().ok_or(SymbolicError::StackUnderflow) + } + + pub(crate) fn peek(&self, index_from_top: usize) -> Result<&SymExpr, SymbolicError> { + self.0 + .get( + self.0 + .len() + .checked_sub(index_from_top + 1) + .ok_or(SymbolicError::StackUnderflow)?, + ) + .ok_or(SymbolicError::StackUnderflow) + } + + pub(crate) fn swap(&mut self, index_from_top: usize) -> Result<(), SymbolicError> { + let len = self.0.len(); + let other = len.checked_sub(index_from_top + 1).ok_or(SymbolicError::StackUnderflow)?; + self.0.swap(len - 1, other); + Ok(()) + } +} + +#[derive(Clone, Debug)] +pub(crate) enum BoundedCopySize { + Concrete(usize), + Symbolic { size: SymExpr, max_size: usize }, +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct SymMemory { + symbolic_writes: Vec, + materialized_size: usize, + logical_size: Option, +} + +#[derive(Clone, Debug)] +struct SymbolicMemoryWrite { + offset: SymExpr, + bytes: SymBytes, + /// Proven lower bound for the start of a guarded, non-wrapping write. + minimum_offset: usize, +} + +impl SymbolicMemoryWrite { + fn concrete_offset(&self) -> Option { + self.offset.eval().and_then(|offset| usize::try_from(offset).ok()) + } + + fn concrete_byte_index(&self, offset: usize) -> Option { + let write_offset = self.concrete_offset()?; + let idx = offset.checked_sub(write_offset)?; + (idx < self.bytes.len()).then_some(idx) + } + + fn concrete_byte(&self, cx: &mut SymCx, offset: usize) -> Option { + self.concrete_byte_index(offset).map(|idx| self.bytes.byte(cx, idx)) + } +} + +impl SymMemory { + fn saturating_add_word(cx: &mut SymCx, left: SymExpr, right: SymExpr) -> SymExpr { + let sum = SymExpr::binop(cx, SymBinOp::Add, left.clone(), right); + let overflow = SymBoolExpr::cmp_word_expr(cx, SymCmpOp::Ult, &sum, left); + let max = SymExpr::constant(cx, U256::MAX); + SymExpr::ite(cx, overflow, max, sum) + } + + pub(crate) fn size_after_access_word(cx: &mut SymCx, offset: SymExpr, len: usize) -> SymExpr { + let size = SymExpr::constant(cx, U256::from(len)); + Self::size_after_range_word(cx, offset, size) + } + + fn size_after_range_word(cx: &mut SymCx, offset: SymExpr, size: SymExpr) -> SymExpr { + let end = Self::saturating_add_word(cx, offset, size.clone()); + let round = SymExpr::constant(cx, U256::from(31)); + let rounded = Self::saturating_add_word(cx, end, round); + let mask = SymExpr::constant(cx, !U256::from(31)); + let rounded = SymExpr::binop(cx, SymBinOp::And, rounded, mask); + let is_empty = SymBoolExpr::eq_word_const(cx, &size, U256::ZERO); + let zero = SymExpr::zero(cx); + SymExpr::ite(cx, is_empty, zero, rounded) + } + + fn size_after_access(offset: usize, len: usize) -> usize { + let Some(end) = offset.checked_add(len) else { + return usize::MAX & !31usize; + }; + end.checked_add(31).map(|size| size & !31usize).unwrap_or(usize::MAX & !31usize) + } + + fn max_size_word(cx: &mut SymCx, left: SymExpr, right: SymExpr) -> SymExpr { + if let (Some(left_value), Some(right_value)) = (left.as_const(), right.as_const()) { + return SymExpr::constant(cx, left_value.max(right_value)); + } + if left == right { + left + } else { + let condition = SymBoolExpr::cmp_word_expr(cx, SymCmpOp::Ult, &left, right.clone()); + SymExpr::ite(cx, condition, right, left) + } + } + + fn expand_to(&mut self, cx: &mut SymCx, size: SymExpr) { + self.logical_size = Some(match self.logical_size.take() { + Some(current) => Self::max_size_word(cx, current, size), + None => size, + }); + } + + pub(crate) fn store_word(&mut self, cx: &mut SymCx, offset: usize, value: SymExpr) { + let bytes = value.into_bytes(cx); + self.store_bytes(cx, offset, bytes); + } + + pub(crate) fn store_word_offset( + &mut self, + cx: &mut SymCx, + offset: SymExpr, + value: SymExpr, + minimum_offset: usize, + ) { + if let Some(offset) = offset.as_const() { + if let Ok(offset) = usize::try_from(offset) { + self.store_word(cx, offset, value); + } + } else { + let bytes = value.into_bytes(cx); + self.store_symbolic_bytes(cx, offset, bytes, minimum_offset); + } + } + + pub(crate) fn store_byte(&mut self, cx: &mut SymCx, offset: usize, value: SymExpr) { + let byte = value.low_byte(cx); + let bytes = SymBytes::exprs(cx, vec![byte]); + self.store_bytes(cx, offset, bytes); + } + + pub(crate) fn store_byte_offset( + &mut self, + cx: &mut SymCx, + offset: SymExpr, + value: SymExpr, + minimum_offset: usize, + ) { + if let Some(offset) = offset.as_const() { + if let Ok(offset) = usize::try_from(offset) { + self.store_byte(cx, offset, value); + } + } else { + let byte = value.low_byte(cx); + let bytes = SymBytes::exprs(cx, vec![byte]); + self.store_symbolic_bytes(cx, offset, bytes, minimum_offset); + } + } + + pub(crate) fn store_bytes(&mut self, cx: &mut SymCx, offset: usize, bytes: SymBytes) { + if bytes.is_empty() { + return; + } + let size = Self::size_after_access(offset, bytes.len()); + self.materialized_size = self.materialized_size.max(size); + let size = SymExpr::constant(cx, U256::from(size)); + self.expand_to(cx, size); + let minimum_offset = offset; + let offset = SymExpr::constant(cx, U256::from(offset)); + self.symbolic_writes.push(SymbolicMemoryWrite { offset, bytes, minimum_offset }); + } + + fn store_symbolic_bytes( + &mut self, + cx: &mut SymCx, + offset: SymExpr, + bytes: SymBytes, + minimum_offset: usize, + ) { + if bytes.is_empty() { + return; + } + let size = Self::size_after_access_word(cx, offset.clone(), bytes.len()); + self.expand_to(cx, size); + self.symbolic_writes.push(SymbolicMemoryWrite { offset, bytes, minimum_offset }); + } + + fn store_symbolic_sized_bytes( + &mut self, + cx: &mut SymCx, + offset: SymExpr, + bytes: SymBytes, + access_size: SymExpr, + ) { + if !bytes.is_empty() + && let Some(offset) = offset.eval().and_then(|offset| usize::try_from(offset).ok()) + { + let size = Self::size_after_access(offset, bytes.len()); + self.materialized_size = self.materialized_size.max(size); + } + if !bytes.is_empty() { + self.symbolic_writes.push(SymbolicMemoryWrite { + offset: offset.clone(), + bytes, + minimum_offset: 0, + }); + } + let size = Self::size_after_range_word(cx, offset, access_size); + self.expand_to(cx, size); + } + + pub(crate) fn store_bytes_offset(&mut self, cx: &mut SymCx, offset: SymExpr, bytes: SymBytes) { + if let Some(offset) = offset.as_const() { + if let Ok(offset) = usize::try_from(offset) { + self.store_bytes(cx, offset, bytes); + } + } else { + self.store_symbolic_bytes(cx, offset, bytes, 0); + } + } + + pub(crate) fn load_word( + &self, + cx: &mut SymCx, + offset: usize, + ) -> Result { + let offset = SymExpr::constant(cx, U256::from(offset)); + Ok(self.read_bytes_offset(cx, offset, 32).word_at(cx, 0)) + } + + pub(crate) fn load_word_offset( + &mut self, + cx: &mut SymCx, + offset: SymExpr, + ) -> Result { + if let Some(offset) = offset.as_const() { + let Ok(offset) = usize::try_from(offset) else { return Ok(SymExpr::zero(cx)) }; + let size = Self::size_after_access(offset, 32); + let size = SymExpr::constant(cx, U256::from(size)); + self.expand_to(cx, size); + self.load_word(cx, offset) + } else { + let size = Self::size_after_access_word(cx, offset.clone(), 32); + self.expand_to(cx, size); + // Share the byte-read path so symbolic writes are included. + Ok(self.read_bytes_offset(cx, offset, 32).word_at(cx, 0)) + } + } + + pub(crate) fn read_concrete( + &self, + cx: &mut SymCx, + offset: usize, + size: usize, + ) -> Result, SymbolicError> { + if let Some(bytes) = self.read_stored_bytes(cx, offset, size) { + return bytes.concrete_bytes(cx, "symbolic memory read"); + } + + let mut out = vec![0u8; size]; + for (idx, byte) in out.iter_mut().enumerate() { + if let Some(value) = self.byte(cx, offset + idx).as_const() { + *byte = value.to::(); + } else { + return Err(SymbolicError::Unsupported("symbolic memory read")); + } + } + Ok(out) + } + + pub(crate) fn read_byte_exprs( + &self, + cx: &mut SymCx, + offset: usize, + size: usize, + ) -> Vec { + self.read_bytes(cx, offset, size).materialize(cx) + } + + pub(crate) fn read_byte_exprs_offset( + &self, + cx: &mut SymCx, + offset: SymExpr, + size: usize, + ) -> Vec { + self.read_bytes_offset(cx, offset, size).materialize(cx) + } + + pub(crate) fn read_bytes(&self, cx: &mut SymCx, offset: usize, size: usize) -> SymBytes { + let offset = SymExpr::constant(cx, U256::from(offset)); + self.read_bytes_offset(cx, offset, size) + } + + pub(crate) fn read_bytes_offset( + &self, + cx: &mut SymCx, + offset: SymExpr, + size: usize, + ) -> SymBytes { + self.read_bytes_offset_with_bounds(cx, offset, size, 0, None) + } + + pub(crate) fn read_bytes_offset_with_bounds( + &self, + cx: &mut SymCx, + offset: SymExpr, + size: usize, + minimum_offset: usize, + maximum_offset: Option, + ) -> SymBytes { + if let Some(offset) = offset.as_const() { + let Ok(offset) = usize::try_from(offset) else { + return SymBytes::concrete(cx, vec![0; size]); + }; + if let Some(bytes) = self.read_stored_bytes(cx, offset, size) { + return bytes; + } + let bytes = (0..size).map(|idx| self.byte(cx, offset + idx)).collect(); + SymBytes::exprs(cx, bytes) + } else { + let bytes = (0..size) + .map(|idx| { + self.byte_dynamic_with_delta_and_bounds( + cx, + &offset, + idx, + minimum_offset, + maximum_offset, + ) + }) + .collect(); + SymBytes::exprs(cx, bytes) + } + } + + pub(crate) fn load_word_offset_with_bounds( + &self, + cx: &mut SymCx, + base: &SymExpr, + relative_offset: usize, + minimum_base: usize, + maximum_base: Option, + ) -> SymExpr { + let offset = SymExpr::add_const(cx, base.clone(), U256::from(relative_offset)); + let maximum_offset = maximum_base.and_then(|offset| offset.checked_add(relative_offset)); + let minimum_offset = if relative_offset == 0 || maximum_offset.is_some() { + minimum_base.checked_add(relative_offset).unwrap_or_default() + } else { + 0 + }; + self.read_bytes_offset_with_bounds(cx, offset, 32, minimum_offset, maximum_offset) + .word_at(cx, 0) + } + + fn read_stored_bytes(&self, cx: &mut SymCx, offset: usize, size: usize) -> Option { + if size == 0 { + return Some(SymBytes::empty(cx)); + } + let end = offset.checked_add(size)?; + + let mut unresolved = vec![(offset, end)]; + let mut pieces = Vec::new(); + + for write in self.symbolic_writes.iter().rev() { + if unresolved.is_empty() { + break; + } + + let write_offset = write.concrete_offset()?; + let write_end = write_offset.checked_add(write.bytes.len())?; + + if write_end <= offset || end <= write_offset { + continue; + } + + let mut next_unresolved = Vec::new(); + for (start, end) in unresolved { + let overlap_start = start.max(write_offset); + let overlap_end = end.min(write_end); + + if overlap_start >= overlap_end { + next_unresolved.push((start, end)); + continue; + } + + if start < overlap_start { + next_unresolved.push((start, overlap_start)); + } + + pieces.push(( + overlap_start - offset, + write.bytes.slice_concrete( + cx, + overlap_start - write_offset, + overlap_end - overlap_start, + ), + )); + + if overlap_end < end { + next_unresolved.push((overlap_end, end)); + } + } + unresolved = next_unresolved; + } + + pieces.extend( + unresolved + .into_iter() + .map(|(start, end)| (start - offset, SymBytes::concrete(cx, vec![0; end - start]))), + ); + pieces.sort_by_key(|(offset, _)| *offset); + + Some(SymBytes::concat(cx, pieces.into_iter().map(|(_, bytes)| bytes))) + } + + pub(crate) fn read_byte_exprs_symbolic_size( + &self, + cx: &mut SymCx, + offset: SymExpr, + size: SymExpr, + max_size: usize, + ) -> Vec { + self.read_bytes_symbolic_size(cx, offset, size, max_size).materialize(cx) + } + + pub(crate) fn read_bytes_symbolic_size( + &self, + cx: &mut SymCx, + offset: SymExpr, + size: SymExpr, + max_size: usize, + ) -> SymBytes { + if let Some(size) = size.eval() { + let size = usize::try_from(size).map_or(max_size, |size| size.min(max_size)); + let bytes = self.read_bytes_offset(cx, offset, size); + let padding = SymBytes::concrete(cx, vec![0; max_size - size]); + return SymBytes::concat(cx, [bytes, padding]); + } + + let bytes = self.read_bytes_offset(cx, offset, max_size); + SymBytes::sized(cx, bytes, size, max_size) + } + + pub(crate) fn byte(&self, cx: &mut SymCx, offset: usize) -> SymExpr { + let mut writes = self.symbolic_writes.as_slice(); + let mut result = if let Some(base_idx) = + writes.iter().rposition(|write| write.concrete_byte_index(offset).is_some()) + { + let write = &writes[base_idx]; + let byte = write.concrete_byte(cx, offset).expect("concrete byte index is present"); + writes = &writes[base_idx + 1..]; + byte + } else { + SymExpr::zero(cx) + }; + + for write in writes { + if let Some(byte) = write.concrete_byte(cx, offset) { + result = byte; + continue; + } + if write.concrete_offset().is_some() { + continue; + } + if write.minimum_offset > offset { + continue; + } + for idx in 0..write.bytes.len() { + let write_offset = SymExpr::add_const(cx, write.offset.clone(), U256::from(idx)); + let offset = SymExpr::constant(cx, U256::from(offset)); + let condition = SymBoolExpr::eq(cx, write_offset, offset); + let byte = write.bytes.byte(cx, idx); + result = SymExpr::ite(cx, condition, byte, result); + } + } + result + } + + /// Reads the byte at `offset + delta`. + /// + /// Symbolic stores need not extend `materialized_size`, even when their offsets + /// are const-evaluable. Enumerate that region only if it contains every write; + /// otherwise fold all writes in insertion order so later writes win. + pub(crate) fn byte_dynamic_with_delta( + &self, + cx: &mut SymCx, + offset: &SymExpr, + delta: usize, + ) -> SymExpr { + self.byte_dynamic_with_delta_and_bounds(cx, offset, delta, 0, None) + } + + fn byte_dynamic_with_delta_and_bounds( + &self, + cx: &mut SymCx, + offset: &SymExpr, + delta: usize, + minimum_offset: usize, + maximum_offset: Option, + ) -> SymExpr { + let materialized_size = self.materialized_size; + let all_writes_bounded = self.symbolic_writes.iter().all(|write| { + write + .concrete_offset() + .and_then(|write_offset| write_offset.checked_add(write.bytes.len())) + .is_some_and(|end| end <= materialized_size) + }); + let maximum_target = maximum_offset.and_then(|offset| offset.checked_add(delta)); + let target_non_wrapping = delta == 0 || maximum_target.is_some(); + + if all_writes_bounded && target_non_wrapping { + let mut result = SymExpr::zero(cx); + for candidate in (delta..self.materialized_size).rev() { + let candidate_expr = SymExpr::constant(cx, U256::from(candidate - delta)); + let condition = SymBoolExpr::eq(cx, offset.clone(), candidate_expr); + let byte = self.byte(cx, candidate); + result = SymExpr::ite(cx, condition, byte, result); + } + return result; + } + + let target = SymExpr::add_const(cx, offset.clone(), U256::from(delta)); + let minimum_target = if target_non_wrapping { + minimum_offset.checked_add(delta).unwrap_or_default() + } else { + 0 + }; + let gas_dependent_offset = offset.contains_gasleft(); + let mut result = SymExpr::zero(cx); + for write in &self.symbolic_writes { + if write + .concrete_offset() + .and_then(|offset| offset.checked_add(write.bytes.len())) + .is_some_and(|end| end <= minimum_target) + || maximum_target.is_some_and(|target| write.minimum_offset > target) + { + continue; + } + if !gas_dependent_offset + && !write.offset.contains_gasleft() + && let Some(index) = target.constant_difference(&write.offset) + { + if let Ok(index) = usize::try_from(index) + && index < write.bytes.len() + { + result = write.bytes.byte(cx, index); + } + continue; + } + for idx in 0..write.bytes.len() { + let write_offset = SymExpr::add_const(cx, write.offset.clone(), U256::from(idx)); + let condition = SymBoolExpr::eq(cx, write_offset, target.clone()); + let byte = write.bytes.byte(cx, idx); + result = SymExpr::ite(cx, condition, byte, result); + } + } + result + } + + pub(crate) fn size_word(&self, cx: &mut SymCx) -> SymExpr { + self.logical_size.clone().unwrap_or_else(|| SymExpr::zero(cx)) + } + + pub(crate) fn size_after_range_expansion_word( + &self, + cx: &mut SymCx, + offset: SymExpr, + size: SymExpr, + ) -> SymExpr { + let current = self.size_word(cx); + let expanded = Self::size_after_range_word(cx, offset, size); + Self::max_size_word(cx, current, expanded) + } + + pub(crate) fn expand_range(&mut self, cx: &mut SymCx, offset: SymExpr, size: SymExpr) { + if let (Some(offset), Some(size)) = (offset.as_const(), size.as_const()) + && let (Ok(offset), Ok(size)) = (usize::try_from(offset), usize::try_from(size)) + { + if size != 0 { + let size = Self::size_after_access(offset, size); + let size = SymExpr::constant(cx, U256::from(size)); + self.expand_to(cx, size); + } + return; + } + let size = Self::size_after_range_word(cx, offset, size); + self.expand_to(cx, size); + } + + pub(crate) fn copy_bytes_size_offset( + &mut self, + cx: &mut SymCx, + dest: SymExpr, + size: SymExpr, + src: SymBytes, + ) -> Result<(), SymbolicError> { + if src.is_empty() { + return Ok(()); + } + if let Some(size) = size.eval() { + let size = usize::try_from(size).map_or(src.len(), |size| size.min(src.len())); + if size != 0 { + let src = src.slice_concrete(cx, 0, size); + self.store_bytes_offset(cx, dest, src); + } + return Ok(()); + } + + if let Some(dest) = dest.as_const() { + if let Ok(dest) = usize::try_from(dest) { + let bytes = (0..src.len()) + .map(|idx| { + let source = src.byte(cx, idx); + self.copy_size_byte_at(cx, dest + idx, idx, &size, source) + }) + .collect::>(); + let bytes = SymBytes::exprs(cx, bytes); + let dest = SymExpr::constant(cx, U256::from(dest)); + self.store_symbolic_sized_bytes(cx, dest, bytes, size); + } + } else { + let bytes = (0..src.len()) + .map(|idx| { + let existing = self.byte_dynamic_with_delta(cx, &dest, idx); + let source = src.byte(cx, idx); + Self::copy_size_byte(cx, idx, &size, source, existing) + }) + .collect(); + let bytes = SymBytes::exprs(cx, bytes); + self.store_symbolic_sized_bytes(cx, dest, bytes, size); + } + Ok(()) + } + + pub(crate) fn copy_calldata_to_offset( + &mut self, + cx: &mut SymCx, + dest: SymExpr, + offset: SymExpr, + size: usize, + calldata: &SymCalldata, + ) { + let bytes = if offset.as_const().is_some_and(|offset| usize::try_from(offset).is_err()) { + SymBytes::concrete(cx, vec![0; size]) + } else { + calldata.read_bytes_offset(cx, offset, size) + }; + self.store_bytes_offset(cx, dest, bytes); + } + + pub(crate) fn copy_calldata_symbolic_size( + &mut self, + cx: &mut SymCx, + dest: SymExpr, + offset: SymExpr, + size: SymExpr, + max_size: usize, + calldata: &SymCalldata, + ) -> Result<(), SymbolicError> { + let bytes = calldata.read_bytes_offset(cx, offset, max_size); + self.copy_bytes_size_offset(cx, dest, size, bytes) + } + + fn copy_size_byte_at( + &self, + cx: &mut SymCx, + dest: usize, + idx: usize, + size: &SymExpr, + source: SymExpr, + ) -> SymExpr { + let existing = self.byte(cx, dest); + Self::copy_size_byte(cx, idx, size, source, existing) + } + + fn copy_size_byte( + cx: &mut SymCx, + idx: usize, + size: &SymExpr, + source: SymExpr, + existing: SymExpr, + ) -> SymExpr { + let idx = SymExpr::constant(cx, U256::from(idx)); + let condition = SymBoolExpr::cmp(cx, SymCmpOp::Ult, idx, size.clone()); + SymExpr::ite(cx, condition, source, existing) + } + + pub(crate) fn copy_return_data_to_offset( + &mut self, + cx: &mut SymCx, + dest: SymExpr, + offset: SymExpr, + size: usize, + return_data: &SymReturnData, + ) -> Result<(), SymbolicError> { + if size == 0 { + return Ok(()); + } + if let Some(offset) = offset.as_const() { + let Ok(offset) = usize::try_from(offset) else { + return Err(SymbolicError::Unsupported("out-of-bounds symbolic RETURNDATACOPY")); + }; + if offset.saturating_add(size) > return_data.len() { + return Err(SymbolicError::Unsupported("out-of-bounds symbolic RETURNDATACOPY")); + } + } + let bytes = return_data.read_bytes_offset(cx, offset, size); + self.store_bytes_offset(cx, dest, bytes); + Ok(()) + } + + pub(crate) fn copy_return_data_symbolic_size( + &mut self, + cx: &mut SymCx, + dest: SymExpr, + offset: SymExpr, + size: SymExpr, + max_size: usize, + return_data: &SymReturnData, + ) -> Result<(), SymbolicError> { + if max_size == 0 { + return Ok(()); + } + if let Some(offset) = offset.as_const() { + let Ok(offset) = usize::try_from(offset) else { + return Err(SymbolicError::Unsupported("out-of-bounds symbolic RETURNDATACOPY")); + }; + if offset.saturating_add(max_size) > return_data.len() { + return Err(SymbolicError::Unsupported("out-of-bounds symbolic RETURNDATACOPY")); + } + } + let bytes = return_data.read_bytes_offset(cx, offset, max_size); + self.copy_bytes_size_offset(cx, dest, size, bytes) + } + + pub(crate) fn copy_call_output_offset( + &mut self, + cx: &mut SymCx, + dest: SymExpr, + size: &BoundedCopySize, + return_data: &SymReturnData, + ) -> Result<(), SymbolicError> { + match size { + BoundedCopySize::Concrete(size) => { + if *size != 0 { + let copy_size = (*size).min(return_data.len()); + let bytes = if return_data.len_word.as_const().is_none() { + let bytes = (0..copy_size) + .map(|idx| self.call_output_byte(cx, &dest, idx, None, return_data)) + .collect::>(); + SymBytes::exprs(cx, bytes) + } else { + let offset = SymExpr::zero(cx); + return_data.read_bytes_offset(cx, offset, copy_size) + }; + let size = SymExpr::constant(cx, U256::from(*size)); + self.store_symbolic_sized_bytes(cx, dest, bytes, size); + } + } + BoundedCopySize::Symbolic { size, max_size } => { + let output_size = size.clone(); + if *max_size != 0 { + let copy_size = (*max_size).min(return_data.len()); + let bytes = (0..copy_size) + .map(|idx| { + self.call_output_byte(cx, &dest, idx, Some(&output_size), return_data) + }) + .collect::>(); + let bytes = SymBytes::exprs(cx, bytes); + self.store_symbolic_sized_bytes(cx, dest, bytes, output_size); + } + } + } + Ok(()) + } + + pub(crate) fn call_output_byte( + &self, + cx: &mut SymCx, + dest: &SymExpr, + idx: usize, + output_size: Option<&SymExpr>, + return_data: &SymReturnData, + ) -> SymExpr { + let mut guards = Vec::new(); + if let Some(output_size) = output_size { + let idx_expr = SymExpr::constant(cx, U256::from(idx)); + guards.push(SymBoolExpr::cmp(cx, SymCmpOp::Ult, idx_expr, output_size.clone())); + } + if return_data.len_word.as_const().is_none() { + let idx_expr = SymExpr::constant(cx, U256::from(idx)); + guards.push(SymBoolExpr::cmp( + cx, + SymCmpOp::Ult, + idx_expr, + return_data.len_word.clone(), + )); + } + let guard = SymBoolExpr::and(cx, guards); + match guard.as_const() { + Some(true) => return_data.byte(cx, idx), + Some(false) => self.call_output_existing_byte(cx, dest, idx), + None => { + let byte = return_data.byte(cx, idx); + let existing = self.call_output_existing_byte(cx, dest, idx); + SymExpr::ite(cx, guard, byte, existing) + } + } + } + + pub(crate) fn call_output_existing_byte( + &self, + cx: &mut SymCx, + dest: &SymExpr, + idx: usize, + ) -> SymExpr { + if let Some(dest) = dest.as_const() { + match usize::try_from(dest) { + Ok(dest) => self.byte(cx, dest + idx), + Err(_) => SymExpr::zero(cx), + } + } else { + self.byte_dynamic_with_delta(cx, dest, idx) + } + } + + pub(crate) fn copy_memory_to_offset( + &mut self, + cx: &mut SymCx, + dest: SymExpr, + src: SymExpr, + size: usize, + ) -> Result<(), SymbolicError> { + if size == 0 { + return Ok(()); + } + let bytes = self.read_bytes_offset(cx, src, size); + self.store_bytes_offset(cx, dest, bytes); + Ok(()) + } + + pub(crate) fn copy_memory_symbolic_size( + &mut self, + cx: &mut SymCx, + dest: SymExpr, + src: SymExpr, + size: SymExpr, + max_size: usize, + ) -> Result<(), SymbolicError> { + if max_size == 0 { + return Ok(()); + } + let source = self.read_bytes_offset(cx, src, max_size); + self.copy_bytes_size_offset(cx, dest, size, source) + } + + pub(crate) fn return_data( + &self, + cx: &mut SymCx, + offset: SymExpr, + size: usize, + ) -> Result { + let bytes = self.read_bytes_offset(cx, offset, size); + Ok(SymReturnData::from_bytes(cx, bytes)) + } + + pub(crate) fn return_data_symbolic_size( + &self, + cx: &mut SymCx, + offset: SymExpr, + size: SymExpr, + max_size: usize, + ) -> Result { + Ok(SymReturnData { + bytes: self.read_bytes_symbolic_size(cx, offset, size.clone(), max_size), + len_word: size, + }) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct SymCode { + bytes: SymBytes, + jump_table: JumpTable, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum GuardedOpcode { + End, + Concrete(u8), + SymbolicSize { condition: SymBoolExpr, opcode: u8 }, +} + +impl SymCode { + pub(crate) fn empty(cx: &mut SymCx) -> Self { + Self { bytes: SymBytes::empty(cx), jump_table: JumpTable::default() } + } + + pub(crate) fn from_byte_exprs(cx: &mut SymCx, bytes: Vec) -> Self { + let bytes = SymBytes::exprs(cx, bytes); + Self::from_bytes(cx, bytes) + } + + pub(crate) fn from_bytes(cx: &mut SymCx, bytes: SymBytes) -> Self { + let analysis = if let Some(bytes) = bytes.as_concrete_slice() { + bytes.to_vec() + } else { + (0..bytes.len()) + .map(|idx| { + bytes.byte(cx, idx).as_const().map_or(opcode::STOP, |value| value.to::()) + }) + .collect::>() + }; + let analyzed = Bytecode::new_legacy(Bytes::from(analysis)); + let jump_table = analyzed.legacy_jump_table().cloned().unwrap_or_default(); + Self { bytes, jump_table } + } + + pub(crate) fn concrete(cx: &mut SymCx, bytes: Vec) -> Self { + Self::from_bytecode(cx, &Bytecode::new_legacy(Bytes::from(bytes))) + } + + pub(crate) fn from_bytecode(cx: &mut SymCx, bytecode: &Bytecode) -> Self { + let bytes = SymBytes::concrete(cx, bytecode.original_byte_slice().to_vec()); + let jump_table = bytecode.legacy_jump_table().cloned().unwrap_or_default(); + Self { bytes, jump_table } + } + + pub(crate) fn from_memory_offset( + cx: &mut SymCx, + memory: &SymMemory, + offset: SymExpr, + size: usize, + ) -> Self { + let bytes = memory.read_bytes_offset(cx, offset, size); + Self::from_bytes(cx, bytes) + } + + pub(crate) fn from_memory_symbolic_size( + cx: &mut SymCx, + memory: &SymMemory, + offset: SymExpr, + size: SymExpr, + max_size: usize, + ) -> Self { + let bytes = memory.read_bytes_symbolic_size(cx, offset, size, max_size); + Self::from_bytes(cx, bytes) + } + + pub(crate) fn len(&self) -> usize { + self.bytes.len() + } + + pub(crate) fn is_empty(&self) -> bool { + self.bytes.is_empty() + } + + pub(crate) const fn jump_table(&self) -> &JumpTable { + &self.jump_table + } + + pub(crate) fn opcode(&self, cx: &mut SymCx, pc: usize) -> Result, SymbolicError> { + if pc >= self.len() { + return Ok(None); + } + match self.bytes.byte(cx, pc).as_const() { + Some(value) => Ok(Some(value.to::())), + None => Err(SymbolicError::Unsupported("symbolic bytecode opcode")), + } + } + + pub(crate) fn guarded_opcode( + &self, + cx: &mut SymCx, + pc: usize, + ) -> Result { + if pc >= self.len() { + return Ok(GuardedOpcode::End); + } + let byte = self.bytes.byte(cx, pc); + match byte.as_const() { + Some(value) => Ok(GuardedOpcode::Concrete(value.to::())), + None => { + if let SymExprKind::Ite(condition, then_expr, else_expr) = byte.kind() + && else_expr.as_const().is_some_and(|value| value.is_zero()) + { + match then_expr.as_const() { + Some(value) if value.is_zero() => Ok(GuardedOpcode::Concrete(0)), + Some(value) => Ok(GuardedOpcode::SymbolicSize { + condition: condition.clone(), + opcode: value.to::(), + }), + None => Err(SymbolicError::Unsupported("symbolic bytecode opcode")), + } + } else { + Err(SymbolicError::Unsupported("symbolic bytecode opcode")) + } + } + } + } + + pub(crate) fn concrete_range( + &self, + cx: &mut SymCx, + offset: usize, + size: usize, + reason: &'static str, + ) -> Result, SymbolicError> { + if let Some(bytes) = self.bytes.as_concrete_slice() { + let mut out = Vec::with_capacity(size); + let end = offset.saturating_add(size).min(bytes.len()); + if offset < end { + out.extend_from_slice(&bytes[offset..end]); + } + out.resize(size, 0); + return Ok(out); + } + + let mut out = Vec::with_capacity(size); + for idx in 0..size { + if offset + idx >= self.len() { + out.push(0); + continue; + } + match self.bytes.byte(cx, offset + idx).as_const() { + Some(value) => out.push(value.to::()), + None => return Err(SymbolicError::Unsupported(reason)), + } + } + Ok(out) + } + + pub(crate) fn read_byte_exprs( + &self, + cx: &mut SymCx, + offset: usize, + size: usize, + ) -> Vec { + self.read_bytes(cx, offset, size).materialize(cx) + } + + pub(crate) fn read_byte_exprs_offset( + &self, + cx: &mut SymCx, + offset: SymExpr, + size: usize, + ) -> Vec { + self.read_bytes_offset(cx, offset, size).materialize(cx) + } + + pub(crate) fn read_bytes(&self, cx: &mut SymCx, offset: usize, size: usize) -> SymBytes { + self.bytes.slice_concrete(cx, offset, size) + } + + pub(crate) fn read_bytes_offset( + &self, + cx: &mut SymCx, + offset: SymExpr, + size: usize, + ) -> SymBytes { + self.bytes.read_offset(cx, offset, size) + } + + pub(crate) fn push_data_word(&self, cx: &mut SymCx, offset: usize, len: usize) -> SymExpr { + self.bytes.right_aligned_word(cx, offset, len) + } + + pub(crate) fn concrete_bytes( + &self, + cx: &mut SymCx, + reason: &'static str, + ) -> Result, SymbolicError> { + self.concrete_range(cx, 0, self.len(), reason) + } +} + +#[derive(Clone, Debug)] +pub(crate) struct SymReturnData { + pub(crate) len_word: SymExpr, + pub(crate) bytes: SymBytes, +} + +impl SymReturnData { + pub(crate) fn empty(cx: &mut SymCx) -> Self { + Self { len_word: SymExpr::zero(cx), bytes: SymBytes::empty(cx) } + } + + pub(crate) fn from_words(cx: &mut SymCx, words: Vec) -> Self { + let bytes = words.into_iter().map(|word| word.into_bytes(cx)).collect::>(); + let bytes = SymBytes::concat(cx, bytes); + Self::from_bytes(cx, bytes) + } + + pub(crate) fn from_concrete_bytes(cx: &mut SymCx, bytes: Vec) -> Self { + let bytes = SymBytes::concrete(cx, bytes); + Self::from_bytes(cx, bytes) + } + + pub(crate) fn from_byte_exprs(cx: &mut SymCx, bytes: Vec) -> Self { + let bytes = SymBytes::exprs(cx, bytes); + Self::from_bytes(cx, bytes) + } + + pub(crate) fn from_bytes(cx: &mut SymCx, bytes: SymBytes) -> Self { + let len = bytes.len(); + Self { len_word: SymExpr::constant(cx, U256::from(len)), bytes } + } + + pub(crate) fn len(&self) -> usize { + self.bytes.len() + } + + pub(crate) fn byte(&self, cx: &mut SymCx, offset: usize) -> SymExpr { + self.bytes.byte(cx, offset) + } + + pub(crate) fn read_bytes_offset( + &self, + cx: &mut SymCx, + offset: SymExpr, + size: usize, + ) -> SymBytes { + self.bytes.read_offset(cx, offset, size) + } + + pub(crate) fn read_concrete( + &self, + cx: &mut SymCx, + reason: &'static str, + ) -> Result, SymbolicError> { + self.bytes.concrete_bytes(cx, reason) + } + + pub(crate) fn to_code(&self, cx: &mut SymCx) -> Result { + if self.len_word.as_const().is_none() { + return Err(SymbolicError::Unsupported( + "CREATE with symbolic runtime size not modeled", + )); + } + Ok(SymCode::from_bytes(cx, self.bytes.clone())) + } +} diff --git a/crates/evm/symbolic/src/runtime/precompiles.rs b/crates/evm/symbolic/src/runtime/precompiles.rs new file mode 100644 index 0000000000000..33d1379c2394a --- /dev/null +++ b/crates/evm/symbolic/src/runtime/precompiles.rs @@ -0,0 +1,248 @@ +use super::*; + +pub(crate) fn precompile_number(address: Address) -> Option { + let bytes = address.as_slice(); + if bytes[..PRECOMPILE_ADDRESS_LEADING_ZEROS].iter().any(|byte| *byte != 0) { + return None; + } + match bytes[PRECOMPILE_ADDRESS_LEADING_ZEROS] { + 1..=10 => Some(bytes[PRECOMPILE_ADDRESS_LEADING_ZEROS]), + _ => None, + } +} + +pub(crate) fn precompile_number_for_spec(address: Address, spec_id: SpecId) -> Option { + match precompile_number(address)? { + 5..=8 if spec_id < SpecId::BYZANTIUM => None, + 9 if spec_id < SpecId::ISTANBUL => None, + 10 if spec_id < SpecId::CANCUN => None, + number => Some(number), + } +} + +pub(crate) fn execute_precompile( + cx: &mut SymCx, + address: Address, + input: &[u8], + spec_id: SpecId, +) -> Result, SymbolicError> { + let output = match precompile_number_for_spec(address, spec_id) { + Some(1) => secp256k1::ec_recover_run(input, u64::MAX), + Some(2) => hash::sha256_run(input, u64::MAX), + Some(3) => hash::ripemd160_run(input, u64::MAX), + Some(4) => identity::identity_run(input, u64::MAX), + Some(5) => modexp::berlin_run(input, u64::MAX), + Some(6) => bn254::run_add(input, bn254::add::ISTANBUL_ADD_GAS_COST, u64::MAX), + Some(7) => bn254::run_mul(input, bn254::mul::ISTANBUL_MUL_GAS_COST, u64::MAX), + Some(8) => bn254::run_pair( + input, + bn254::pair::ISTANBUL_PAIR_PER_POINT, + bn254::pair::ISTANBUL_PAIR_BASE, + u64::MAX, + ), + Some(9) => blake2::run(input, u64::MAX), + Some(10) => kzg_point_evaluation::run(input, u64::MAX), + _ => return Err(SymbolicError::Unsupported("unsupported precompile")), + }; + + match output { + Ok(output) => Ok(Some(SymReturnData::from_concrete_bytes(cx, output.bytes.to_vec()))), + Err(_) => Ok(None), + } +} + +pub(crate) fn execute_symbolic_precompile( + cx: &mut SymCx, + address: Address, + input: SymBytes, + input_len: SymExpr, + spec_id: SpecId, +) -> Result, SymbolicError> { + if let Some(input_len) = input_len.as_const() + && let Ok(input_len) = usize::try_from(input_len) + && input_len <= input.len() + && let Ok(input) = + input.slice_concrete(cx, 0, input_len).concrete_bytes(cx, "symbolic precompile input") + { + return execute_precompile(cx, address, &input, spec_id); + } + + match precompile_number_for_spec(address, spec_id) { + Some(1) => { + // ECRECOVER ignores trailing bytes and pads short input with zeros. + let input = SymBytes::sized(cx, input, input_len, 128); + if let Ok(input) = input.concrete_bytes(cx, "symbolic ecrecover input") { + return execute_precompile(cx, address, &input, spec_id); + } + let v = input.word_at(cx, 32); + let v27 = SymBoolExpr::eq_word_const(cx, &v, U256::from(27)); + let v28 = SymBoolExpr::eq_word_const(cx, &v, U256::from(28)); + let valid_v = SymBoolExpr::or(cx, vec![v27, v28]); + if valid_v.as_const() == Some(false) { + return Ok(Some(SymReturnData::empty(cx))); + } + + let input = input.materialize(cx); + let input_len = SymExpr::constant(cx, U256::from(128)); + let word = symbolic_hash_word_with_len(cx, "ecrecover", input, input_len); + // Recovery may fail even with a valid v. Use an otherwise discarded byte of the + // opaque word so this choice is independent of the low 160-bit recovered address. + let recovered = byte_word(cx, U256::ZERO, word.clone()).nonzero_bool(cx); + let recovered = SymBoolExpr::and(cx, vec![valid_v, recovered]); + let full_len = SymExpr::constant(cx, U256::from(32)); + let empty_len = SymExpr::zero(cx); + let len = SymExpr::ite(cx, recovered, full_len, empty_len); + let mut bytes = vec![SymExpr::zero(cx); 12]; + bytes.extend((12..32).map(|idx| byte_word(cx, U256::from(idx), word.clone()))); + let bytes = SymBytes::exprs(cx, bytes); + Ok(Some(SymReturnData { len_word: len, bytes })) + } + Some(2) => { + let input = input.materialize(cx); + let word = symbolic_hash_word_with_len(cx, "sha256", input, input_len); + let bytes = word.into_byte_exprs(cx); + Ok(Some(SymReturnData::from_byte_exprs(cx, bytes))) + } + Some(3) => { + let input = input.materialize(cx); + let word = symbolic_hash_word_with_len(cx, "ripemd160", input, input_len); + let mut bytes = vec![SymExpr::zero(cx); 12]; + bytes.extend((12..32).map(|idx| byte_word(cx, U256::from(idx), word.clone()))); + Ok(Some(SymReturnData::from_byte_exprs(cx, bytes))) + } + Some(4) => Ok(Some(SymReturnData { len_word: input_len, bytes: input })), + Some(5) => symbolic_modexp_precompile(cx, &input, input_len), + Some(6) => { + let input_len = input_len.as_usize_or("symbolic precompile input")?; + if input_len > input.len() { + return Err(SymbolicError::Unsupported("out-of-bounds symbolic precompile input")); + } + if (0..input_len).any(|idx| input.byte(cx, idx).as_const().is_none()) { + return Err(SymbolicError::Unsupported( + "symbolic bn254 precompile validity not modeled", + )); + } + Ok(Some(symbolic_fixed_len_precompile_output(cx, "bn254_add", &input, input_len, 64))) + } + Some(7) => { + let input_len = input_len.as_usize_or("symbolic precompile input")?; + if input_len > input.len() { + return Err(SymbolicError::Unsupported("out-of-bounds symbolic precompile input")); + } + if (0..input_len).any(|idx| input.byte(cx, idx).as_const().is_none()) { + return Err(SymbolicError::Unsupported( + "symbolic bn254 precompile validity not modeled", + )); + } + Ok(Some(symbolic_fixed_len_precompile_output(cx, "bn254_mul", &input, input_len, 64))) + } + Some(8) => { + let input_len = input_len.as_usize_or("symbolic precompile input")?; + if input_len % 192 != 0 { + return Ok(None); + } + if input_len > input.len() { + return Err(SymbolicError::Unsupported("out-of-bounds symbolic precompile input")); + } + if (0..input_len).any(|idx| input.byte(cx, idx).as_const().is_none()) { + return Err(SymbolicError::Unsupported( + "symbolic bn254 precompile validity not modeled", + )); + } + Ok(Some(symbolic_fixed_len_precompile_output( + cx, + "bn254_pairing", + &input, + input_len, + 32, + ))) + } + Some(9) => { + let input_len = input_len.as_usize_or("symbolic precompile input")?; + if input_len != 213 { + return Ok(None); + } + if input_len > input.len() { + return Err(SymbolicError::Unsupported("out-of-bounds symbolic precompile input")); + } + let flag = input.byte(cx, 212); + match flag.as_const() { + Some(flag) if flag.is_zero() || flag == U256::ONE => {} + Some(_) => return Ok(None), + None => { + return Err(SymbolicError::Unsupported( + "symbolic blake2f precompile final flag not modeled", + )); + } + } + Ok(Some(symbolic_fixed_len_precompile_output(cx, "blake2f", &input, input_len, 64))) + } + Some(10) => Err(SymbolicError::Unsupported("KZG handled by execute_kzg_precompile_call")), + _ => { + let input_len = input_len.as_usize_or("symbolic precompile input")?; + if input_len > input.len() { + return Err(SymbolicError::Unsupported("out-of-bounds symbolic precompile input")); + } + let input = input + .slice_concrete(cx, 0, input_len) + .concrete_bytes(cx, "symbolic precompile input")?; + execute_precompile(cx, address, &input, spec_id) + } + } +} + +pub(crate) fn symbolic_modexp_precompile( + cx: &mut SymCx, + input: &SymBytes, + input_len: SymExpr, +) -> Result, SymbolicError> { + let input_len = input_len.as_usize_or("symbolic precompile input")?; + if input_len > input.len() { + return Err(SymbolicError::Unsupported("out-of-bounds symbolic precompile input")); + } + + let modulus_len = concrete_precompile_word_at(cx, input, 64)?; + let modulus_len = usize::try_from(modulus_len) + .ok() + .ok_or(SymbolicError::Unsupported("symbolic modexp output length"))?; + if modulus_len > 4096 { + return Err(SymbolicError::Unsupported("symbolic modexp output length")); + } + Ok(Some(symbolic_fixed_len_precompile_output(cx, "modexp", input, input_len, modulus_len))) +} + +pub(crate) fn concrete_precompile_word_at( + cx: &mut SymCx, + input: &SymBytes, + offset: usize, +) -> Result { + let mut bytes = [0u8; 32]; + for (idx, byte) in bytes.iter_mut().enumerate() { + let word = input.byte(cx, offset + idx); + *byte = word.as_const_or("symbolic precompile length header")?.to::(); + } + Ok(U256::from_be_bytes(bytes)) +} + +pub(crate) fn symbolic_fixed_len_precompile_output( + cx: &mut SymCx, + algorithm: &'static str, + input: &SymBytes, + input_len: usize, + output_len: usize, +) -> SymReturnData { + let input_len_word = SymExpr::constant(cx, U256::from(input_len)); + let input = input.materialize(cx); + let mut bytes = Vec::with_capacity(output_len); + for chunk in 0..output_len.div_ceil(32) { + let mut chunk_input = Vec::with_capacity(input.len() + 1); + chunk_input.push(SymExpr::constant(cx, U256::from(chunk))); + chunk_input.extend(input.iter().cloned()); + bytes.extend( + symbolic_hash_word_with_len(cx, algorithm, chunk_input, input_len_word.clone()) + .into_byte_exprs(cx), + ); + } + bytes.truncate(output_len); + SymReturnData::from_byte_exprs(cx, bytes) +} diff --git a/crates/evm/symbolic/src/runtime/solver/fallback/mod.rs b/crates/evm/symbolic/src/runtime/solver/fallback/mod.rs new file mode 100644 index 0000000000000..b40956980eecb --- /dev/null +++ b/crates/evm/symbolic/src/runtime/solver/fallback/mod.rs @@ -0,0 +1,1110 @@ +//! Bounded fallback model generation for hard arithmetic constraints. + +use super::*; + +impl SymBoolExpr { + pub(crate) fn contains_hard_arith(&self) -> bool { + self.visit_bool(is_hard_arith_node) + } + + fn contains_symbolic_hash(&self) -> bool { + self.visit_bool(|expr| matches!(expr.kind(), SymExprKind::Hash { .. })) + } +} + +impl SymExpr { + fn contains_var(&self) -> bool { + self.visit_bool(|expr| { + matches!( + expr.kind(), + SymExprKind::Var(_) | SymExprKind::Keccak { .. } | SymExprKind::Hash { .. } + ) + }) + } +} + +fn is_hard_arith_node(expr: &SymExpr) -> bool { + match expr.kind() { + SymExprKind::BinOp(SymBinOp::Mul, left, right) => { + left.contains_var() && right.contains_var() + } + SymExprKind::BinOp( + SymBinOp::UDiv | SymBinOp::URem | SymBinOp::SDiv | SymBinOp::SRem, + left, + right, + ) => left.contains_var() || right.contains_var(), + SymExprKind::TernOp(_, left, right, modulus) => { + left.contains_var() || right.contains_var() || modulus.contains_var() + } + _ => false, + } +} + +/// Returns whether local hard-arithmetic search should run before asking the solver. +pub(crate) fn constraints_prefer_hard_arith_fallback_first( + cx: &SymCx, + constraints: &[SymBoolExpr], +) -> bool { + if !constraints.iter().any(SymBoolExpr::contains_hard_arith) + || constraints.iter().any(SymBoolExpr::contains_symbolic_hash) + { + return false; + } + + let mut vars = SymbolicVars::default(); + for constraint in constraints { + collect_bool_fallback_vars(constraint, &mut vars); + } + let vars = fallback_search_vars(cx, vars, constraints); + !vars.is_empty() && vars.len() <= HARD_ARITH_FALLBACK_MAX_VARS +} + +pub(crate) fn hard_arith_fallback_model( + cx: &SymCx, + constraints: &[SymBoolExpr], +) -> Option { + if !constraints.iter().any(SymBoolExpr::contains_hard_arith) + || constraints.iter().any(SymBoolExpr::contains_symbolic_hash) + { + return None; + } + + let mut vars = SymbolicVars::default(); + let mut constants = HashSet::::default(); + for constraint in constraints { + collect_bool_fallback_vars(constraint, &mut vars); + collect_bool_constants(constraint, &mut constants); + } + let mut constants = constants.into_iter().collect::>(); + constants.sort_unstable(); + let vars = fallback_search_vars(cx, vars, constraints); + if vars.is_empty() || vars.len() > HARD_ARITH_FALLBACK_MAX_VARS { + return None; + } + + let candidates = vars + .iter() + .map(|var| fallback_candidates_for_var(var, constraints, &constants)) + .collect::>>()?; + let searched_vars = vars.iter().copied().collect::(); + let constraint_vars = constraints + .iter() + .map(|constraint| { + let mut vars = SymbolicVars::default(); + constraint.collect_vars(&mut vars); + vars + }) + .collect::>(); + let mut model = SymbolicModel::default(); + let mut assignments = 0usize; + let search = FallbackSearch { + constraints, + constraint_vars: &constraint_vars, + searched_vars: &searched_vars, + vars: &vars, + candidates: &candidates, + max_assignments: HARD_ARITH_FALLBACK_MAX_ASSIGNMENTS, + }; + search.model(0, &mut model, &mut assignments) +} + +// Constructive checked-multiply modeling is optional. Bound repeated support scans to keep a miss +// from consuming more work than the solver fallback it is intended to avoid. +const MAX_CHECKED_MUL_SUPPORT_VISITS: usize = 256; + +/// Constructs and validates a concrete model for a checked-multiply guard branch. +/// +/// Solidity's guard is `x == 0 || (x * y) / x == y`. The assignments below represent its +/// semantic cases directly: the zero disjunct, a nonzero exact product, and wrapping products in +/// either operand order. Simple support constraints are completed first so an exact operand value +/// from the path is preserved instead of being overwritten by the semantic default. This does not +/// perform the generic bounded candidate search, and a model is returned only when it satisfies +/// every original constraint. +pub(super) fn checked_mul_guard_branch_model( + cx: &SymCx, + constraints: &[SymBoolExpr], + original_constraints: &[SymBoolExpr], + replayable_storage: &SymbolicVars, +) -> Option { + let mut eval_vars = SymbolicVars::default(); + for constraint in original_constraints { + constraint.collect_eval_vars(&mut eval_vars); + } + if eval_vars + .iter() + .any(|var| !cx.is_replayable_input(*var) && !replayable_storage.contains(var)) + { + return None; + } + + let mut remaining_support_visits = MAX_CHECKED_MUL_SUPPORT_VISITS; + let mut candidates = Vec::new(); + let mut seen = HashSet::<&SymBoolExpr>::default(); + let mut pending = Vec::new(); + for constraint in constraints { + if !seen.insert(constraint) { + continue; + } + if remaining_support_visits == 0 { + return None; + } + remaining_support_visits -= 1; + if let Some(candidate) = checked_mul_guard_branch(constraint) { + candidates.push(candidate); + } + match constraint.kind() { + SymBoolExprKind::Not(inner) => pending.push(inner), + SymBoolExprKind::And(values) => pending.extend(values.iter()), + SymBoolExprKind::Const(_) | SymBoolExprKind::Cmp(_, _, _) => {} + } + } + + let mut nested = Vec::new(); + while let Some(constraint) = pending.pop() { + if !seen.insert(constraint) { + continue; + } + if remaining_support_visits == 0 { + return None; + } + remaining_support_visits -= 1; + nested.push(constraint); + match constraint.kind() { + SymBoolExprKind::Not(inner) => pending.push(inner), + SymBoolExprKind::And(values) => pending.extend(values.iter()), + SymBoolExprKind::Const(_) | SymBoolExprKind::Cmp(_, _, _) => {} + } + } + for constraint in nested.into_iter().rev() { + if let Some(candidate) = checked_mul_guard_branch(constraint) { + candidates.push(candidate); + } + } + + for (zero_operand, expected, guard_is_true) in candidates { + let assignments = if guard_is_true { + [(U256::ZERO, U256::ZERO), (U256::ONE, U256::ONE)] + } else { + [(U256::MAX, U256::from(2)), (U256::from(2), U256::MAX)] + }; + for (zero_default, expected_default) in assignments { + let seed_orders = [ + [(&zero_operand, zero_default), (&expected, expected_default)], + [(&expected, expected_default), (&zero_operand, zero_default)], + ]; + for seeds in seed_orders { + let mut model = SymbolicModel::default(); + if !propagate_fallback_support_constraints( + constraints, + &mut model, + &mut remaining_support_visits, + ) { + if remaining_support_visits == 0 { + return None; + } + continue; + } + let mut valid = true; + for (operand, default) in seeds { + let assigned = match operand.eval_model_if_complete(&model) { + Ok(Some(_)) => true, + Ok(None) => operand.assign_model_value(&mut model, default), + Err(_) => false, + }; + if !assigned { + valid = false; + break; + } + if !propagate_fallback_support_constraints( + constraints, + &mut model, + &mut remaining_support_visits, + ) { + if remaining_support_visits == 0 { + return None; + } + valid = false; + break; + } + } + if valid { + if eval_vars.iter().all(|var| model.contains_name(*var)) { + let valid = original_constraints.iter().all(|constraint| { + charge_support_constraint(constraint, &mut remaining_support_visits) + && constraint.eval_model(&model).unwrap_or(false) + }); + if valid { + return Some(model); + } + if remaining_support_visits == 0 { + return None; + } + continue; + } + if complete_fallback_support_model( + constraints, + &mut model, + &mut remaining_support_visits, + ) && complete_model_with_zeroes( + original_constraints, + &mut model, + &mut remaining_support_visits, + ) { + let valid = original_constraints.iter().all(|constraint| { + charge_support_constraint(constraint, &mut remaining_support_visits) + && constraint.eval_model(&model).unwrap_or(false) + }); + if valid { + return Some(model); + } + } + if remaining_support_visits == 0 { + return None; + } + } + } + } + } + None +} + +fn checked_mul_guard_branch(constraint: &SymBoolExpr) -> Option<(SymExpr, SymExpr, bool)> { + match constraint.kind() { + SymBoolExprKind::Cmp(SymCmpOp::Eq, left, right) => { + checked_mul_guard_word_comparison(left, right) + .map(|(zero_operand, expected)| (zero_operand, expected, false)) + } + SymBoolExprKind::Not(inner) => match inner.kind() { + SymBoolExprKind::Cmp(SymCmpOp::Eq, left, right) => { + checked_mul_guard_word_comparison(left, right) + .map(|(zero_operand, expected)| (zero_operand, expected, true)) + } + SymBoolExprKind::And(values) => checked_mul_guard_conjunction(values) + .map(|(zero_operand, expected)| (zero_operand, expected, true)), + _ => None, + }, + SymBoolExprKind::And(values) => checked_mul_guard_conjunction(values) + .map(|(zero_operand, expected)| (zero_operand, expected, false)), + _ => None, + } +} + +fn checked_mul_guard_word_comparison( + left: &SymExpr, + right: &SymExpr, +) -> Option<(SymExpr, SymExpr)> { + let guard_word = if right.as_const().is_some_and(|value| value.is_zero()) { + left + } else if left.as_const().is_some_and(|value| value.is_zero()) { + right + } else { + return None; + }; + let SymExprKind::BinOp(SymBinOp::Or, left, right) = guard_word.kind() else { + return None; + }; + + for (quotient_word, zero_word) in [(left, right), (right, left)] { + let Some(quotient_matches) = quotient_word.bool_word_condition() else { + continue; + }; + let Some(zero_condition) = zero_word.bool_word_condition() else { + continue; + }; + let Some((zero_operand, expected, quotient_zero_condition)) = + checked_mul_guard_operands("ient_matches) + else { + continue; + }; + if zero_condition == quotient_zero_condition { + return Some((zero_operand, expected)); + } + } + None +} + +fn checked_mul_guard_conjunction(values: &[SymBoolExpr]) -> Option<(SymExpr, SymExpr)> { + for value in values { + let SymBoolExprKind::Not(quotient_matches) = value.kind() else { + continue; + }; + let Some((zero_operand, expected, zero_condition)) = + checked_mul_guard_operands(quotient_matches) + else { + continue; + }; + let contains_negated_zero_condition = values.iter().any( + |value| matches!(value.kind(), SymBoolExprKind::Not(inner) if inner == &zero_condition), + ); + if contains_negated_zero_condition { + return Some((zero_operand, expected)); + } + } + None +} + +fn checked_mul_guard_operands(condition: &SymBoolExpr) -> Option<(SymExpr, SymExpr, SymBoolExpr)> { + let SymBoolExprKind::Cmp(SymCmpOp::Eq, left, right) = condition.kind() else { + return None; + }; + for (guarded_quotient, expected) in [(left, right), (right, left)] { + let SymExprKind::Ite(zero_condition, zero, quotient) = guarded_quotient.kind() else { + continue; + }; + if !zero.as_const().is_some_and(|value| value.is_zero()) { + continue; + } + let Some(zero_operand) = zero_condition.zero_check_operand() else { + continue; + }; + let Some((numerator, denominator)) = quotient.udiv_operands() else { + continue; + }; + if denominator != zero_operand { + continue; + } + let SymExprKind::BinOp(SymBinOp::Mul, product_left, product_right) = numerator.kind() + else { + continue; + }; + if (product_left == denominator && product_right == expected) + || (product_right == denominator && product_left == expected) + { + return Some((zero_operand.clone(), expected.clone(), zero_condition.clone())); + } + } + None +} + +fn fallback_search_vars( + cx: &SymCx, + vars: SymbolicVars, + constraints: &[SymBoolExpr], +) -> Vec { + if vars.len() <= HARD_ARITH_FALLBACK_MAX_VARS { + return vars.into_iter().collect(); + } + + let hard_arith_vars = hard_arith_fallback_vars(constraints); + if !hard_arith_vars.is_empty() && hard_arith_vars.len() <= HARD_ARITH_FALLBACK_MAX_VARS { + let mut vars = hard_arith_vars; + add_zero_invalid_support_vars(&mut vars, constraints); + return vars.into_iter().collect(); + } + + vars.into_iter() + .filter(|var| { + let var = cx.symbol_name(*var); + var.starts_with("calldata") + || var.starts_with("sequence") + || var.starts_with("create_address") + || var.starts_with("create2_address") + || !var.contains('_') + }) + .collect() +} + +fn hard_arith_fallback_vars(constraints: &[SymBoolExpr]) -> SymbolicVars { + let mut vars = SymbolicVars::default(); + for constraint in constraints { + collect_bool_hard_arith_vars(constraint, &mut vars); + } + vars +} + +fn add_zero_invalid_support_vars(vars: &mut SymbolicVars, constraints: &[SymBoolExpr]) { + let zero_model = SymbolicModel::default(); + for constraint in constraints { + if constraint.eval_model(&zero_model).unwrap_or(false) { + continue; + } + // Scalar bounds can be completed constructively. Spending a search slot on them can + // miss a valid endpoint (e.g. int256::MAX + 1) that is outside the candidate budget. + let (inner, inverted) = match constraint.kind() { + SymBoolExprKind::Not(inner) => (inner, true), + _ => (constraint, false), + }; + if let SymBoolExprKind::Cmp(op, left, right) = inner.kind() + && support_cmp_op(*op, inverted) + .is_some_and(|op| !matches!(op, SymCmpOp::Slt | SymCmpOp::Sgt)) + && [(left, right), (right, left)].into_iter().any(|(variable, bound)| { + if !matches!(variable.kind(), SymExprKind::Var(_)) { + return false; + } + let mut dependencies = SymbolicVars::default(); + bound.collect_eval_vars(&mut dependencies); + dependencies.is_subset(vars) + }) + { + continue; + } + + let mut constraint_vars = SymbolicVars::default(); + constraint.collect_vars(&mut constraint_vars); + let missing = + constraint_vars.iter().filter(|var| !vars.contains(*var)).copied().collect::>(); + if vars.len() + missing.len() > HARD_ARITH_FALLBACK_MAX_VARS { + continue; + } + vars.extend(missing); + } +} + +fn fallback_candidates_for_var( + var: &Symbol, + constraints: &[SymBoolExpr], + constants: &[U256], +) -> Option> { + let hints = MaskHints::for_var(var, constraints); + if !(hints.one & hints.zero).is_zero() { + return None; + } + + let mut candidates = HashSet::::default(); + for candidate in [ + U256::ZERO, + U256::ONE, + U256::from(2), + U256::from(3), + U256::MAX, + U256::MAX - U256::ONE, + U256::MAX - U256::from(2), + ] { + push_fallback_candidate(&mut candidates, candidate, hints); + } + + for constant in constants.iter().copied() { + push_fallback_candidate(&mut candidates, constant, hints); + push_fallback_candidate(&mut candidates, constant.wrapping_add(U256::ONE), hints); + push_fallback_candidate(&mut candidates, constant.wrapping_sub(U256::ONE), hints); + if candidates.len() >= FALLBACK_MODEL_MAX_CANDIDATES_PER_VAR { + break; + } + } + + for bit in 0..256 { + let power = U256::ONE << bit; + push_fallback_candidate(&mut candidates, power, hints); + if candidates.len() >= FALLBACK_MODEL_MAX_CANDIDATES_PER_VAR { + break; + } + } + + let mut candidates = candidates.into_iter().collect::>(); + candidates.sort_unstable(); + candidates.truncate(FALLBACK_MODEL_MAX_CANDIDATES_PER_VAR); + Some(candidates) +} + +struct FallbackSearch<'a> { + constraints: &'a [SymBoolExpr], + constraint_vars: &'a [SymbolicVars], + searched_vars: &'a SymbolicVars, + vars: &'a [Symbol], + candidates: &'a [Vec], + max_assignments: usize, +} + +impl FallbackSearch<'_> { + fn model( + &self, + index: usize, + model: &mut SymbolicModel, + assignments: &mut usize, + ) -> Option { + if index == self.vars.len() { + let mut completed = model.clone(); + let mut remaining_support_visits = usize::MAX; + if complete_fallback_support_model( + self.constraints, + &mut completed, + &mut remaining_support_visits, + ) { + return Some(completed); + } + // Greedy completion can choose one endpoint before seeing a tighter bound. Retry + // from the search assignment after intersecting all currently evaluable bounds. + let mut completed = model.clone(); + return (seed_bounded_support_vars(self.constraints, &mut completed) + && complete_fallback_support_model( + self.constraints, + &mut completed, + &mut remaining_support_visits, + )) + .then_some(completed); + } + + for candidate in &self.candidates[index] { + if *assignments >= self.max_assignments { + return None; + } + *assignments += 1; + model.insert(self.vars[index], *candidate); + if fallback_partial_model_satisfies_known_constraints( + self.constraints, + self.constraint_vars, + self.searched_vars, + model, + ) && let Some(model) = self.model(index + 1, model, assignments) + { + return Some(model); + } + } + model.remove(&self.vars[index]); + None + } +} + +/// Seeds only unassigned scalar variables; every resulting witness is still fully validated. +fn seed_bounded_support_vars(constraints: &[SymBoolExpr], model: &mut SymbolicModel) -> bool { + let mut bounds: HashMap = HashMap::default(); + for constraint in constraints { + let (constraint, inverted) = match constraint.kind() { + SymBoolExprKind::Not(inner) => (inner, true), + _ => (constraint, false), + }; + let SymBoolExprKind::Cmp(op, left, right) = constraint.kind() else { continue }; + let Some(mut op) = support_cmp_op(*op, inverted) else { continue }; + if matches!(op, SymCmpOp::Slt | SymCmpOp::Sgt) { + continue; + } + let (var, known) = if let SymExprKind::Var(var) = left.kind() + && !model.contains_name(*var) + && let Ok(Some(value)) = right.eval_model_if_complete(model) + { + (*var, value) + } else if let SymExprKind::Var(var) = right.kind() + && !model.contains_name(*var) + && let Ok(Some(value)) = left.eval_model_if_complete(model) + { + op = match op { + SymCmpOp::Ult => SymCmpOp::Ugt, + SymCmpOp::Ule => SymCmpOp::Uge, + SymCmpOp::Ugt => SymCmpOp::Ult, + SymCmpOp::Uge => SymCmpOp::Ule, + other => other, + }; + (*var, value) + } else { + continue; + }; + let Some(value) = support_target_for_known_right(op, known) else { return false }; + let (lower, upper) = bounds.entry(var).or_insert((U256::ZERO, U256::MAX)); + match op { + SymCmpOp::Eq => { + *lower = (*lower).max(value); + *upper = (*upper).min(value); + } + SymCmpOp::Ule | SymCmpOp::Ult => *upper = (*upper).min(value), + SymCmpOp::Uge | SymCmpOp::Ugt => *lower = (*lower).max(value), + SymCmpOp::Slt | SymCmpOp::Sgt => continue, + } + if lower > upper { + return false; + } + } + if bounds.is_empty() { + return false; + } + for (var, (lower, _)) in bounds { + model.insert(var, lower); + } + true +} + +fn complete_fallback_support_model( + constraints: &[SymBoolExpr], + model: &mut SymbolicModel, + remaining_support_visits: &mut usize, +) -> bool { + for _ in 0..constraints.len() { + let Some(mut changed) = + complete_support_constraints_once(constraints, model, remaining_support_visits) + else { + return false; + }; + if changed { + continue; + } + // Default checked-add bases to zero only after exact/lower-bound completions had a chance + // to assign a stronger value required by another constraint. + for constraint in constraints { + if !charge_support_constraint(constraint, remaining_support_visits) { + return false; + } + match constraint.eval_model_if_complete(model) { + Ok(Some(true)) => {} + Ok(Some(false)) | Err(_) => return false, + Ok(None) => { + changed |= complete_default_support_constraint(constraint, model); + } + } + } + if !changed { + break; + } + } + constraints.iter().all(|constraint| { + charge_support_constraint(constraint, remaining_support_visits) + && constraint.eval_model(model).unwrap_or(false) + }) +} + +fn propagate_fallback_support_constraints( + constraints: &[SymBoolExpr], + model: &mut SymbolicModel, + remaining_support_visits: &mut usize, +) -> bool { + for _ in 0..constraints.len() { + match complete_support_constraints_once(constraints, model, remaining_support_visits) { + Some(true) => {} + Some(false) => return true, + None => return false, + } + } + true +} + +fn complete_support_constraints_once( + constraints: &[SymBoolExpr], + model: &mut SymbolicModel, + remaining_support_visits: &mut usize, +) -> Option { + let mut changed = false; + for constraint in constraints { + if !charge_support_constraint(constraint, remaining_support_visits) { + return None; + } + match constraint.eval_model_if_complete(model) { + Ok(Some(true)) => {} + Ok(Some(false)) | Err(_) => return None, + Ok(None) => changed |= complete_support_bool(constraint, model, false, false), + } + } + Some(changed) +} + +fn charge_support_constraint( + constraint: &SymBoolExpr, + remaining_support_visits: &mut usize, +) -> bool { + if *remaining_support_visits == 0 { + return false; + } + *remaining_support_visits -= 1; + !constraint + .visit_exprs(&mut |_| { + if *remaining_support_visits == 0 { + return ControlFlow::Break(()); + } + *remaining_support_visits -= 1; + ControlFlow::Continue(()) + }) + .is_break() +} + +fn complete_model_with_zeroes( + constraints: &[SymBoolExpr], + model: &mut SymbolicModel, + remaining_support_visits: &mut usize, +) -> bool { + let mut vars = SymbolicVars::default(); + for constraint in constraints { + if !charge_support_constraint(constraint, remaining_support_visits) { + return false; + } + constraint.collect_eval_vars(&mut vars); + } + for var in vars { + model.entry(var).or_default(); + } + true +} + +fn complete_default_support_constraint( + constraint: &SymBoolExpr, + model: &mut SymbolicModel, +) -> bool { + complete_support_bool(constraint, model, false, true) +} + +fn complete_support_bool( + constraint: &SymBoolExpr, + model: &mut SymbolicModel, + inverted: bool, + defaults_only: bool, +) -> bool { + match constraint.kind() { + SymBoolExprKind::Const(_) => false, + SymBoolExprKind::Not(value) => { + complete_support_bool(value, model, !inverted, defaults_only) + } + SymBoolExprKind::And(values) if !inverted => { + let mut changed = false; + for value in values.iter() { + changed |= complete_support_bool(value, model, false, defaults_only); + } + changed + } + SymBoolExprKind::Cmp(op, left, right) => { + let Some(op) = support_cmp_op(*op, inverted) else { + return false; + }; + if defaults_only { + complete_default_support_comparison(op, left, right, model) + } else { + complete_support_comparison(op, left, right, model) + } + } + SymBoolExprKind::And(_) => false, + } +} + +const fn support_cmp_op(op: SymCmpOp, inverted: bool) -> Option { + if !inverted { + return Some(op); + } + + match op { + SymCmpOp::Ult => Some(SymCmpOp::Uge), + SymCmpOp::Ugt => Some(SymCmpOp::Ule), + SymCmpOp::Ule => Some(SymCmpOp::Ugt), + SymCmpOp::Uge => Some(SymCmpOp::Ult), + SymCmpOp::Eq | SymCmpOp::Slt | SymCmpOp::Sgt => None, + } +} + +fn complete_support_comparison( + op: SymCmpOp, + left: &SymExpr, + right: &SymExpr, + model: &mut SymbolicModel, +) -> bool { + if complete_checked_sub_guard(op, left, right, model) { + return true; + } + if let Ok(Some(value)) = left.eval_model_if_complete(model) + && let Some(target) = support_target_for_known_left(op, value) + { + return right.assign_model_value(model, target); + } + if let Ok(Some(value)) = right.eval_model_if_complete(model) + && let Some(target) = support_target_for_known_right(op, value) + { + return left.assign_model_value(model, target); + } + false +} + +fn complete_default_support_comparison( + op: SymCmpOp, + left: &SymExpr, + right: &SymExpr, + model: &mut SymbolicModel, +) -> bool { + complete_checked_add_guard(op, left, right, model) +} + +fn complete_checked_sub_guard( + op: SymCmpOp, + left: &SymExpr, + right: &SymExpr, + model: &mut SymbolicModel, +) -> bool { + match op { + SymCmpOp::Uge => assign_checked_sub_minuend(left, right, model), + SymCmpOp::Ule => assign_checked_sub_minuend(right, left, model), + _ => false, + } +} + +fn assign_checked_sub_minuend( + minuend: &SymExpr, + sub_expr: &SymExpr, + model: &mut SymbolicModel, +) -> bool { + let SymExprKind::BinOp(SymBinOp::Sub, sub_minuend, amount) = sub_expr.kind() else { + return false; + }; + if sub_minuend != minuend { + return false; + } + let Ok(Some(amount)) = amount.eval_model_if_complete(model) else { + return false; + }; + minuend.assign_model_value(model, amount) +} + +fn complete_checked_add_guard( + op: SymCmpOp, + left: &SymExpr, + right: &SymExpr, + model: &mut SymbolicModel, +) -> bool { + match op { + SymCmpOp::Uge => assign_checked_add_base(left, right, model), + SymCmpOp::Ule => assign_checked_add_base(right, left, model), + _ => false, + } +} + +fn assign_checked_add_base(sum: &SymExpr, base: &SymExpr, model: &mut SymbolicModel) -> bool { + let SymExprKind::BinOp(SymBinOp::Add, left, right) = sum.kind() else { + return false; + }; + if left == base && right.eval_model_if_complete(model).ok().flatten().is_some() { + return base.assign_model_value(model, U256::ZERO); + } + if right == base && left.eval_model_if_complete(model).ok().flatten().is_some() { + return base.assign_model_value(model, U256::ZERO); + } + false +} + +const fn support_target_for_known_left(op: SymCmpOp, value: U256) -> Option { + match op { + SymCmpOp::Eq | SymCmpOp::Ule | SymCmpOp::Uge => Some(value), + SymCmpOp::Ult => value.checked_add(U256::ONE), + SymCmpOp::Ugt => value.checked_sub(U256::ONE), + SymCmpOp::Slt | SymCmpOp::Sgt => None, + } +} + +const fn support_target_for_known_right(op: SymCmpOp, value: U256) -> Option { + match op { + SymCmpOp::Eq | SymCmpOp::Ule | SymCmpOp::Uge => Some(value), + SymCmpOp::Ult => value.checked_sub(U256::ONE), + SymCmpOp::Ugt => value.checked_add(U256::ONE), + SymCmpOp::Slt | SymCmpOp::Sgt => None, + } +} + +fn fallback_partial_model_satisfies_known_constraints( + constraints: &[SymBoolExpr], + constraint_vars: &[SymbolicVars], + searched_vars: &SymbolicVars, + model: &SymbolicModel, +) -> bool { + constraints.iter().zip(constraint_vars).all(|(constraint, vars)| { + !vars.is_subset(searched_vars) + || !vars.iter().all(|var| model.contains_name(*var)) + || constraint.eval_model(model).unwrap_or(false) + }) +} + +fn collect_bool_fallback_vars(expr: &SymBoolExpr, vars: &mut SymbolicVars) { + let _ = expr.visit_exprs(&mut |expr| { + if let Some(var) = expr.kind().get_eval_var() { + vars.insert(var); + } + ControlFlow::<()>::Continue(()) + }); +} + +fn collect_bool_hard_arith_vars(expr: &SymBoolExpr, vars: &mut SymbolicVars) { + let _ = expr.visit_exprs(&mut |expr| { + if is_hard_arith_node(expr) { + expr.collect_eval_vars(vars); + } + ControlFlow::<()>::Continue(()) + }); +} + +pub(crate) fn fallback_single_var_model(constraints: &[SymBoolExpr]) -> Option { + let mut vars = SymbolicVars::default(); + let mut constants = HashSet::::default(); + for constraint in constraints { + constraint.collect_vars(&mut vars); + collect_bool_constants(constraint, &mut constants); + } + let mut constants = constants.into_iter().collect::>(); + constants.sort_unstable(); + + let var = if vars.len() == 1 { *vars.iter().next()? } else { return None }; + let hints = MaskHints::for_var(&var, constraints); + if !(hints.one & hints.zero).is_zero() { + return None; + } + + let mut model = SymbolicModel::default(); + let mut remaining_support_visits = usize::MAX; + if complete_fallback_support_model(constraints, &mut model, &mut remaining_support_visits) + && model.len() == 1 + && model.contains_key(&var) + { + return Some(model); + } + + for candidate in [ + U256::ZERO, + U256::ONE, + U256::from(2), + U256::MAX, + U256::MAX - U256::ONE, + U256::MAX - U256::from(2), + ] { + let mut model = SymbolicModel::default(); + model.insert(var, (candidate | hints.one) & !hints.zero); + if eval_model_constraints(constraints, &model) { + return Some(model); + } + } + + let mut candidates = HashSet::::default(); + for constant in constants.iter().copied() { + push_fallback_candidate(&mut candidates, constant, hints); + push_fallback_candidate(&mut candidates, constant.wrapping_add(U256::ONE), hints); + push_fallback_candidate(&mut candidates, constant.wrapping_sub(U256::ONE), hints); + } + + for bit in 0..256 { + let power = U256::ONE << bit; + push_fallback_candidate(&mut candidates, power, hints); + for constant in constants.iter().copied().take(64) { + push_fallback_candidate(&mut candidates, power | constant, hints); + push_fallback_candidate(&mut candidates, power.wrapping_add(constant), hints); + } + } + + let mut candidates = candidates.into_iter().collect::>(); + candidates.sort_unstable(); + for candidate in candidates { + let mut model = SymbolicModel::default(); + model.insert(var, candidate); + if eval_model_constraints(constraints, &model) { + return Some(model); + } + } + + None +} + +/// Searches a bounded Cartesian portfolio and returns only an evaluator-validated SAT witness. +/// +/// Unsupported expressions and exhausted search return `None`, leaving the external solver as the +/// authoritative fallback. +pub(crate) fn fallback_bounded_model(constraints: &[SymBoolExpr]) -> Option { + if constraints.iter().any(SymBoolExpr::contains_hard_arith) { + return None; + } + + let mut vars = SymbolicVars::default(); + for constraint in constraints { + collect_bool_fallback_vars(constraint, &mut vars); + if vars.len() > FALLBACK_MODEL_MAX_VARS { + return None; + } + } + if vars.len() < 2 { + return None; + } + if constraints.iter().any(SymBoolExpr::contains_symbolic_hash) + || constraints.iter().any(SymBoolExpr::contains_gasleft) + { + return None; + } + let mut constants = HashSet::::default(); + for constraint in constraints { + collect_bool_constants(constraint, &mut constants); + } + let mut constants = constants.into_iter().collect::>(); + constants.sort_unstable(); + let vars = vars.into_iter().collect::>(); + let candidates = vars + .iter() + .map(|var| fallback_candidates_for_var(var, constraints, &constants)) + .collect::>>()?; + let searched_vars = vars.iter().copied().collect::(); + let constraint_vars = constraints + .iter() + .map(|constraint| { + let mut vars = SymbolicVars::default(); + constraint.collect_vars(&mut vars); + vars + }) + .collect::>(); + let search = FallbackSearch { + constraints, + constraint_vars: &constraint_vars, + searched_vars: &searched_vars, + vars: &vars, + candidates: &candidates, + max_assignments: FALLBACK_MODEL_MAX_ASSIGNMENTS, + }; + let mut model = SymbolicModel::default(); + let mut assignments = 0usize; + search.model(0, &mut model, &mut assignments) +} + +fn push_fallback_candidate(candidates: &mut HashSet, candidate: U256, hints: MaskHints) { + candidates.insert((candidate | hints.one) & !hints.zero); +} + +fn collect_bool_constants(expr: &SymBoolExpr, constants: &mut HashSet) { + let _ = expr.visit_exprs(&mut |expr| { + if let SymExprKind::Const(value) = expr.kind() { + constants.insert(*value); + } + ControlFlow::<()>::Continue(()) + }); +} + +#[derive(Clone, Copy, Debug, Default)] +struct MaskHints { + one: U256, + zero: U256, +} + +impl MaskHints { + fn for_var(var: &Symbol, constraints: &[SymBoolExpr]) -> Self { + let mut hints = Self::default(); + for constraint in constraints { + hints.apply_bool(var, constraint, false); + } + hints + } + + fn apply_bool(&mut self, var: &Symbol, expr: &SymBoolExpr, inverted: bool) { + match expr.kind() { + SymBoolExprKind::Const(_) => {} + SymBoolExprKind::Not(value) => self.apply_bool(var, value, !inverted), + SymBoolExprKind::And(values) if !inverted => { + for value in values.iter() { + self.apply_bool(var, value, false); + } + } + SymBoolExprKind::Cmp(SymCmpOp::Eq, left, right) => { + self.apply_equality(var, left, right, inverted) + } + SymBoolExprKind::Cmp(_, _, _) | SymBoolExprKind::And(_) => {} + } + } + + fn apply_equality(&mut self, var: &Symbol, left: &SymExpr, right: &SymExpr, inverted: bool) { + if let Some(mask) = + zero_mask_equality(var, left, right).or_else(|| zero_mask_equality(var, right, left)) + { + if inverted { + if mask.is_power_of_two() { + self.one |= mask; + } + } else { + self.zero |= mask; + } + } + } +} + +fn zero_mask_equality(var: &Symbol, masked: &SymExpr, zero: &SymExpr) -> Option { + if !zero.as_const().is_some_and(|value| value.is_zero()) { + return None; + } + match masked.kind() { + SymExprKind::BinOp(SymBinOp::And, left, right) + if left.kind().get_var().is_some_and(|name| &name == var) => + { + right.as_const() + } + _ => None, + } +} diff --git a/crates/evm/symbolic/src/runtime/solver/mod.rs b/crates/evm/symbolic/src/runtime/solver/mod.rs new file mode 100644 index 0000000000000..eb2b9e6ec8be5 --- /dev/null +++ b/crates/evm/symbolic/src/runtime/solver/mod.rs @@ -0,0 +1,1739 @@ +//! Solver orchestration, query scheduling, caching, and model validation. + +use super::*; +use std::{ + io::{BufRead, BufReader, Read}, + process::{Child, ChildStdin, Output}, + sync::mpsc::{Receiver, RecvTimeoutError}, + thread::JoinHandle, +}; +use wait_timeout::ChildExt; + +mod fallback; +mod normalize; +mod reasoning; +mod smt; + +use fallback::{checked_mul_guard_branch_model, constraints_prefer_hard_arith_fallback_first}; +use normalize::{ + constraints_are_directly_unsat, normalize_constraints_for_solver_cached, + sorted_bool_exprs_are_subset, +}; +use reasoning::{product_monotonic_unsat_normalized, remove_implied_monotonic_constraints}; +use smt::write_smt_assertions; + +pub(crate) use fallback::{ + fallback_bounded_model, fallback_single_var_model, hard_arith_fallback_model, +}; + +const Z3_QUERY_END: &str = "foundry-query-complete"; + +/// Errors that arise when parsing or constructing solver commands from configuration. +#[derive(Debug, thiserror::Error)] +pub(crate) enum SolverConfigError { + /// The command string parsed to an empty argv. + #[error("symbolic solver command is empty")] + EmptyCommand, + /// The command string contains invalid shell quoting. + #[error("invalid shell quoting in symbolic solver command")] + InvalidShellQuoting, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub(crate) enum SolverOutcome { + Cancelled, + Error, + NotStarted, + SatAfterWinner, + SatInvalid, + SatValid, + TimeoutOrUnknown, + Unknown, + UnknownAfterWinner, + Unsat, + UnsatAfterWinner, + Unexpected, +} + +impl fmt::Display for SolverOutcome { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Cancelled => "cancelled", + Self::Error => "error", + Self::NotStarted => "not-started", + Self::SatAfterWinner => "sat-after-winner", + Self::SatInvalid => "sat-invalid", + Self::SatValid => "sat-valid", + Self::TimeoutOrUnknown => "timeout-or-unknown", + Self::Unknown => "unknown", + Self::UnknownAfterWinner => "unknown-after-winner", + Self::Unsat => "unsat", + Self::UnsatAfterWinner => "unsat-after-winner", + Self::Unexpected => "unexpected", + }) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum BranchFeasibility { + Sat, + Unsat, + NeedsSolver, +} + +impl BranchFeasibility { + const fn into_result(self) -> Result { + match self { + Self::Sat => Ok(true), + Self::Unsat => Ok(false), + Self::NeedsSolver => Err(SymbolicError::SolverUnknown), + } + } +} +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct SolverCommand { + program: String, + args: Vec, + display: String, + smt_timeout: bool, +} + +impl SolverCommand { + /// Constructs a solver command from a program plus arguments. + pub(crate) fn new(parts: Vec, smt_timeout: bool) -> Result { + let mut parts = parts.into_iter(); + let Some(program) = parts.next().filter(|part| !part.is_empty()) else { + return Err(SolverConfigError::EmptyCommand); + }; + let args = parts.collect::>(); + let display = std::iter::once(program.as_str()) + .chain(args.iter().map(String::as_str)) + .collect::>() + .join(" "); + Ok(Self { program, args, display, smt_timeout }) + } +} + +pub(crate) struct SmtLibSubprocessSolver { + commands: Result, SolverConfigError>, + timeout: Option, + max_queries: usize, + queries: usize, + dump_smt: bool, + portfolio_scheduler: PortfolioScheduler, + heuristic_witnesses: usize, + replayable_storage: SymbolicVars, + normalization_cache: HashMap, + sat_cache: HashMap, bool>, + model_cache: HashMap, SymbolicModel>, + sat_queries: usize, + model_queries: usize, + sat_cache_hits: usize, + model_cache_hits: usize, + smt_queries: usize, + solver_time: Duration, + smt_input_bytes: u64, + smt_max_query_bytes: u64, + smt_build_time: Duration, + smt_max_query_time: Duration, + z3_session: Option, +} + +impl SmtLibSubprocessSolver { + /// Constructs a subprocess solver from Foundry symbolic config. + pub(crate) fn from_config(config: &SymbolicConfig) -> Self { + Self { + commands: solver_commands_for_config(config), + timeout: config.timeout, + max_queries: config.max_solver_queries as usize, + queries: 0, + dump_smt: config.dump_smt, + portfolio_scheduler: PortfolioScheduler::default(), + heuristic_witnesses: 0, + replayable_storage: SymbolicVars::default(), + normalization_cache: HashMap::default(), + sat_cache: HashMap::default(), + model_cache: HashMap::default(), + sat_queries: 0, + model_queries: 0, + sat_cache_hits: 0, + model_cache_hits: 0, + smt_queries: 0, + solver_time: Duration::ZERO, + smt_input_bytes: 0, + smt_max_query_bytes: 0, + smt_build_time: Duration::ZERO, + smt_max_query_time: Duration::ZERO, + z3_session: None, + } + } + + /// Returns solver counters collected by this backend. + pub(crate) fn stats(&self) -> SymbolicStats { + SymbolicStats { + paths: 0, + solver_queries: self.queries, + smt_queries: self.smt_queries, + sat_queries: self.sat_queries, + model_queries: self.model_queries, + sat_cache_hits: self.sat_cache_hits, + model_cache_hits: self.model_cache_hits, + heuristic_witnesses: self.heuristic_witnesses, + solver_time_ms: self.solver_time.as_millis().try_into().unwrap_or(u64::MAX), + smt_input_bytes: self.smt_input_bytes, + smt_max_query_bytes: self.smt_max_query_bytes, + smt_build_time_ms: self.smt_build_time.as_millis().try_into().unwrap_or(u64::MAX), + smt_max_query_time_ms: self + .smt_max_query_time + .as_millis() + .try_into() + .unwrap_or(u64::MAX), + } + } + + /// Clears cached expression keys tied to a previous symbolic context. + pub(crate) fn clear_context_caches(&mut self) { + self.normalization_cache.clear(); + self.sat_cache.clear(); + self.model_cache.clear(); + } + + /// Verifies that a configured solver can be invoked before exploration starts. + pub(crate) fn check_available(&self) -> Result<(), SymbolicError> { + let commands = self.commands()?; + let mut errors = Vec::new(); + for command in commands { + let output = match Command::new(&command.program).arg("--version").output() { + Ok(output) => output, + Err(err) => { + errors.push(format!("failed to execute `{}`: {err}", command.program)); + continue; + } + }; + if output.status.success() { + return Ok(()); + } + errors.push(format!("`{}` is not a usable SMT solver executable", command.program)); + } + Err(SymbolicError::Solver(errors.join("; "))) + } + + /// Returns satisfiability with path-local storage symbols that concrete replay can set. + pub(crate) fn is_sat_with_replayable_storage( + &mut self, + cx: &mut SymCx, + constraints: &[SymBoolExpr], + replayable_storage: &SymbolicVars, + ) -> Result { + self.with_replayable_storage(replayable_storage, |solver| { + solver.is_sat_inner(cx, constraints, false).and_then(BranchFeasibility::into_result) + }) + } + + /// Returns branch feasibility with path-local storage symbols concrete replay can set. + pub(crate) fn branch_feasibility_with_replayable_storage( + &mut self, + cx: &mut SymCx, + constraints: &[SymBoolExpr], + replayable_storage: &SymbolicVars, + ) -> Result { + self.with_replayable_storage(replayable_storage, |solver| { + solver.is_sat_inner(cx, constraints, true) + }) + } + + /// Returns a model with path-local storage symbols that concrete replay can set. + pub(crate) fn model_with_replayable_storage( + &mut self, + cx: &mut SymCx, + constraints: &[SymBoolExpr], + replayable_storage: &SymbolicVars, + ) -> Result { + self.with_replayable_storage(replayable_storage, |solver| solver.model(cx, constraints)) + } + + fn with_replayable_storage( + &mut self, + replayable_storage: &SymbolicVars, + operation: impl FnOnce(&mut Self) -> T, + ) -> T { + let previous = std::mem::replace(&mut self.replayable_storage, replayable_storage.clone()); + let result = operation(self); + self.replayable_storage = previous; + result + } + + /// Returns variable assignments used to materialize inputs for concrete replay. + pub(crate) fn model( + &mut self, + cx: &mut SymCx, + constraints: &[SymBoolExpr], + ) -> Result { + // Local witnesses may decide the feasibility of gas-dependent branches, but a model assigns + // `gasleft()` a concrete value the engine cannot replay faithfully, so fail closed here. + if constraints.iter().any(SymBoolExpr::contains_gasleft) { + return Err(SymbolicError::Unsupported("GAS/gasleft() not modeled")); + } + self.model_queries += 1; + let smt_constraints = + normalize_constraints_for_solver_cached(cx, constraints, &mut self.normalization_cache); + let cache_key = smt_constraints.clone(); + + if self.sat_cache.get(&cache_key) == Some(&false) { + self.model_cache.remove(&cache_key); + trace!("model: normalized sat cache says unsat"); + return Err(SymbolicError::Solver("counterexample path became unsat".to_string())); + } + if self.has_cached_unsat_subset(&cache_key) { + self.cache_sat_result(cache_key.clone(), false); + self.model_cache.remove(&cache_key); + trace!("model: normalized unsat subset cache hit"); + return Err(SymbolicError::Solver("counterexample path became unsat".to_string())); + } + + if let Some(model) = self.model_cache.get(&cache_key) { + if eval_model_constraints(constraints, model) { + let model = model.clone(); + self.model_cache_hits += 1; + trace!("model: normalized cache hit"); + self.cache_sat_result(cache_key.clone(), true); + return Ok(model); + } + trace!("model: normalized cache hit failed validation"); + } + if self.model_cache.remove(&cache_key).is_some() { + self.sat_cache.remove(&cache_key); + } + + self.reserve_query()?; + self.queries += 1; + let _span = trace_span!( + "solver_query", + query_id = self.queries, + constraint_count = constraints.len(), + kind = "model" + ) + .entered(); + trace!(query_id = self.queries, constraint_count = constraints.len(), "solver model"); + if let Some(model) = fallback_single_var_model(&smt_constraints) + && eval_model_constraints(constraints, &model) + { + self.cache_sat_result(cache_key.clone(), true); + self.cache_model_result(cache_key, model.clone()); + return Ok(model); + } + if let Some(model) = fallback_bounded_model(&smt_constraints) + && eval_model_constraints(constraints, &model) + { + self.cache_sat_result(cache_key.clone(), true); + self.cache_model_result(cache_key, model.clone()); + return Ok(model); + } + if let Some(model) = checked_mul_guard_branch_model( + cx, + &smt_constraints, + constraints, + &self.replayable_storage, + ) { + trace!("model: validated constructive checked-multiply guard model"); + self.cache_sat_result(cache_key.clone(), true); + return Ok(model); + } + if constraints_prefer_hard_arith_fallback_first(cx, &smt_constraints) + && let Some(model) = + validated_hard_arith_fallback_model(cx, &smt_constraints, constraints) + { + self.heuristic_witnesses += 1; + trace!("model: validated hard arithmetic fallback model before solver"); + self.cache_sat_result(cache_key.clone(), true); + self.cache_model_result(cache_key, model.clone()); + return Ok(model); + } + let output = match self.query_normalized(cx, &smt_constraints, true, constraints) { + Ok(output) => output, + Err(SymbolicError::SolverUnknown) => { + if let Some(model) = + validated_hard_arith_fallback_model(cx, &smt_constraints, constraints) + { + self.heuristic_witnesses += 1; + trace!("model: validated hard arithmetic fallback model after solver unknown"); + self.cache_sat_result(cache_key.clone(), true); + self.cache_model_result(cache_key, model.clone()); + return Ok(model); + } + return Err(SymbolicError::SolverUnknown); + } + Err(err) => return Err(err), + }; + let mut lines = output.lines(); + match lines.next().unwrap_or_default().trim() { + "sat" => { + let model = parse_and_validate_model(cx, &output, constraints)?; + self.cache_sat_result(cache_key.clone(), true); + self.cache_model_result(cache_key, model.clone()); + Ok(model) + } + "unsat" => { + self.model_cache.remove(&cache_key); + self.cache_sat_result(cache_key, false); + Err(SymbolicError::Solver("counterexample path became unsat".to_string())) + } + "unknown" => { + if let Some(model) = + validated_hard_arith_fallback_model(cx, &smt_constraints, constraints) + { + self.heuristic_witnesses += 1; + self.cache_sat_result(cache_key.clone(), true); + self.cache_model_result(cache_key, model.clone()); + Ok(model) + } else { + Err(SymbolicError::SolverUnknown) + } + } + other => Err(SymbolicError::Solver(format!("unexpected solver response `{other}`"))), + } + } + + fn is_sat_inner( + &mut self, + cx: &mut SymCx, + constraints: &[SymBoolExpr], + defer_hard_arith_without_witness: bool, + ) -> Result { + self.sat_queries += 1; + let smt_constraints = + normalize_sat_constraints(cx, constraints, &mut self.normalization_cache); + let cache_key = smt_constraints.clone(); + if let Some(result) = self.sat_cache.get(&cache_key) { + self.sat_cache_hits += 1; + trace!(result, "is_sat: normalized cache hit"); + return Ok(if *result { BranchFeasibility::Sat } else { BranchFeasibility::Unsat }); + } + if self.has_cached_unsat_subset(&cache_key) { + self.sat_cache_hits += 1; + trace!("is_sat: normalized unsat subset cache hit"); + self.cache_sat_result(cache_key, false); + return Ok(BranchFeasibility::Unsat); + } + if defer_hard_arith_without_witness + && let Some((condition, base)) = constraints.split_last() + && { + let normalized_base = + normalize_sat_constraints(cx, base, &mut self.normalization_cache); + self.sat_cache.get(&normalized_base) == Some(&true) + } + && { + let mut complement = Vec::with_capacity(constraints.len()); + complement.extend(base.iter().cloned()); + complement.push(condition.clone().not(cx)); + let normalized_complement = + normalize_sat_constraints(cx, &complement, &mut self.normalization_cache); + self.has_cached_unsat_subset(&normalized_complement) + } + { + self.sat_cache_hits += 1; + trace!("is_sat: branch complement unsat cache hit"); + self.cache_sat_result(cache_key, true); + return Ok(BranchFeasibility::Sat); + } + + self.reserve_query()?; + self.queries += 1; + let _span = trace_span!( + "solver_query", + query_id = self.queries, + constraint_count = constraints.len(), + kind = "is_sat" + ) + .entered(); + trace!(query_id = self.queries, constraint_count = constraints.len(), "solver is_sat"); + if constraints_are_directly_unsat(cx, &smt_constraints) { + trace!("is_sat: direct contradiction"); + self.cache_sat_result(cache_key, false); + return Ok(BranchFeasibility::Unsat); + } + if product_monotonic_unsat_normalized(&smt_constraints) { + trace!("is_sat: monotonic product contradiction"); + self.cache_sat_result(cache_key, false); + return Ok(BranchFeasibility::Unsat); + } + if !constraints.is_empty() + && eval_model_constraints(constraints, &SymbolicModel::default()) + && !constraints.iter().any(SymBoolExpr::contains_gasleft) + { + self.cache_sat_result(cache_key, true); + return Ok(BranchFeasibility::Sat); + } + if let Some(model) = fallback_single_var_model(&smt_constraints) + && eval_model_constraints(constraints, &model) + { + self.cache_sat_result(cache_key, true); + return Ok(BranchFeasibility::Sat); + } + if let Some(model) = fallback_bounded_model(&smt_constraints) + && eval_model_constraints(constraints, &model) + { + self.cache_sat_result(cache_key, true); + return Ok(BranchFeasibility::Sat); + } + if checked_mul_guard_branch_model( + cx, + &smt_constraints, + constraints, + &self.replayable_storage, + ) + .is_some() + { + trace!("is_sat: validated constructive checked-multiply guard model"); + self.cache_sat_result(cache_key, true); + return Ok(BranchFeasibility::Sat); + } + if constraints_prefer_hard_arith_fallback_first(cx, &smt_constraints) { + if validated_hard_arith_fallback_model(cx, &smt_constraints, constraints).is_some() { + self.heuristic_witnesses += 1; + trace!("is_sat: validated hard arithmetic fallback model before solver"); + self.cache_sat_result(cache_key, true); + return Ok(BranchFeasibility::Sat); + } + if defer_hard_arith_without_witness { + trace!("is_sat: deferring hard arithmetic branch without local witness"); + return Ok(BranchFeasibility::NeedsSolver); + } + } + let output = match self.query_normalized(cx, &smt_constraints, false, constraints) { + Ok(output) => output, + Err(SymbolicError::SolverUnknown) => { + if validated_hard_arith_fallback_model(cx, &smt_constraints, constraints).is_some() + { + self.heuristic_witnesses += 1; + trace!("is_sat: validated hard arithmetic fallback model after solver unknown"); + self.cache_sat_result(cache_key, true); + return Ok(BranchFeasibility::Sat); + } + return Err(SymbolicError::SolverUnknown); + } + Err(err) => return Err(err), + }; + match output.lines().next().unwrap_or_default().trim() { + "sat" => { + self.cache_sat_result(cache_key, true); + Ok(BranchFeasibility::Sat) + } + "unsat" => { + self.cache_sat_result(cache_key, false); + Ok(BranchFeasibility::Unsat) + } + "unknown" => { + if validated_hard_arith_fallback_model(cx, &smt_constraints, constraints).is_some() + { + self.heuristic_witnesses += 1; + self.cache_sat_result(cache_key, true); + Ok(BranchFeasibility::Sat) + } else { + Err(SymbolicError::SolverUnknown) + } + } + other => Err(SymbolicError::Solver(format!("unexpected solver response `{other}`"))), + } + } + /// Returns the resolved commands or the stored config error. + pub(crate) fn commands(&self) -> Result<&[SolverCommand], SymbolicError> { + self.commands + .as_ref() + .map(Vec::as_slice) + .map_err(|err| SymbolicError::Solver(err.to_string())) + } + + pub(crate) const fn reserve_query(&self) -> Result<(), SymbolicError> { + if self.queries >= self.max_queries { + return Err(SymbolicError::SolverQueryLimit(self.max_queries)); + } + Ok(()) + } + + fn cache_sat_result(&mut self, key: Vec, result: bool) { + cache_result(&mut self.sat_cache, key, result, SYMBOLIC_SOLVER_SAT_CACHE_MAX_ENTRIES); + } + + fn cache_model_result(&mut self, key: Vec, model: SymbolicModel) { + cache_result(&mut self.model_cache, key, model, SYMBOLIC_SOLVER_MODEL_CACHE_MAX_ENTRIES); + } + + /// Returns whether an already-proved unsat constraint set is a subset of `key`. + fn has_cached_unsat_subset(&self, key: &[SymBoolExpr]) -> bool { + self.sat_cache + .iter() + .any(|(cached_key, result)| !*result && sorted_bool_exprs_are_subset(cached_key, key)) + } + + /// Sends already-normalized constraints to the configured solver portfolio. + pub(crate) fn query_normalized( + &mut self, + cx: &SymCx, + smt_constraints: &[SymBoolExpr], + model: bool, + model_constraints: &[SymBoolExpr], + ) -> Result { + self.smt_queries += 1; + let build_started = Instant::now(); + let mut vars = SymbolicVars::default(); + for constraint in smt_constraints { + constraint.collect_vars(&mut vars); + } + + let configured_commands = self.commands()?.to_vec(); + let ordered_commands = self.portfolio_scheduler.ordered_commands(&configured_commands); + let commands = + ordered_commands.iter().map(|(_, command)| command.clone()).collect::>(); + + let mut smt = String::with_capacity(256 + smt_constraints.len().saturating_mul(192)); + smt.push_str("(set-logic QF_BV)\n"); + if commands.iter().all(|command| command.smt_timeout) + && let Some(timeout) = self.timeout.filter(|timeout| *timeout > 0) + { + let _ = writeln!(smt, "(set-option :timeout {})", timeout.saturating_mul(1000)); + } + for var in vars { + let name = cx.symbol_name(var); + let _ = writeln!(smt, "(declare-fun {name} () (_ BitVec 256))"); + } + write_smt_assertions(cx, &mut smt, smt_constraints)?; + smt.push_str("(check-sat)\n"); + if model { + smt.push_str("(get-model)\n"); + } + let smt_bytes = smt.len().try_into().unwrap_or(u64::MAX); + self.smt_input_bytes = self.smt_input_bytes.saturating_add(smt_bytes); + self.smt_max_query_bytes = self.smt_max_query_bytes.max(smt_bytes); + self.smt_build_time += build_started.elapsed(); + if self.dump_smt { + let query = self.queries; + let _ = writeln!(std::io::stderr(), "--- symbolic SMT query {query} ---\n{smt}"); + } + + let started = Instant::now(); + let result = if let [command] = commands.as_slice() + && command.smt_timeout + && command.program == "z3" + && command.args == ["-in", "-smt2"] + { + let output = self.query_z3(command, &smt).into_result(); + SolverCommandRun { output, summaries: Vec::new() } + } else { + run_solver_commands( + cx, + &commands, + &smt, + self.timeout, + model.then_some(model_constraints), + ) + }; + let query_time = started.elapsed(); + self.solver_time += query_time; + self.smt_max_query_time = self.smt_max_query_time.max(query_time); + self.portfolio_scheduler.record(&ordered_commands, &result.summaries); + if self.dump_smt && !result.summaries.is_empty() { + let _ = write!( + std::io::stderr(), + "{}", + format_solver_portfolio_summaries(&result.summaries) + ); + } + result.output + } + + fn query_z3(&mut self, command: &SolverCommand, smt: &str) -> SolverProcessOutcome { + let mut session = match self.z3_session.take() { + Some(session) => session, + None => match Z3Session::spawn(command) { + Ok(session) => session, + Err(err) => return SolverProcessOutcome::Error(err), + }, + }; + let outcome = session.query(command, smt, self.timeout); + match outcome { + output @ SolverProcessOutcome::Output(_) => { + self.z3_session = Some(session); + output + } + SolverProcessOutcome::Error(_) => { + drop(session); + run_solver_process(command, smt, self.timeout, &AtomicBool::new(false)) + } + other => other, + } + } +} + +fn cache_result(cache: &mut HashMap, key: K, value: V, max_entries: usize) +where + K: Eq + std::hash::Hash, +{ + let has_capacity = cache.len() < max_entries; + match cache.entry(key) { + alloy_primitives::map::Entry::Occupied(mut entry) => { + entry.insert(value); + } + alloy_primitives::map::Entry::Vacant(entry) if has_capacity => { + entry.insert(value); + } + alloy_primitives::map::Entry::Vacant(_) => {} + } +} + +/// Normalizes satisfiability constraints and removes soundly redundant constraints. +fn normalize_sat_constraints( + cx: &mut SymCx, + constraints: &[SymBoolExpr], + normalization_cache: &mut HashMap, +) -> Vec { + let constraints = remove_implied_monotonic_constraints( + normalize_constraints_for_solver_cached(cx, constraints, normalization_cache), + ); + remove_witnessed_isolated_hash_constraints(cx, constraints) +} + +/// Removes independently satisfiable constraints over one opaque hash symbol. +/// +/// SMT treats symbolic hashes as free bit-vector symbols. If a constraint's only SMT symbol is a +/// hash unused by other constraints, a concrete witness proves that it cannot affect conjunction +/// satisfiability. The witness is discarded because hash values are not replayable inputs. +fn remove_witnessed_isolated_hash_constraints( + cx: &mut SymCx, + constraints: Vec, +) -> Vec { + if !constraints.iter().any(|constraint| { + constraint.visit_bool(|expr| { + matches!(expr.kind(), SymExprKind::Keccak { .. } | SymExprKind::Hash { .. }) + }) + }) { + return constraints; + } + + let mut symbol_constraint_counts = HashMap::::default(); + let hash_candidates = constraints + .iter() + .map(|constraint| { + let mut symbols = SymbolicVars::default(); + let contains_hash = collect_solver_vars(constraint, &mut symbols); + for symbol in &symbols { + *symbol_constraint_counts.entry(*symbol).or_default() += 1; + } + if contains_hash && symbols.len() == 1 { symbols.first().copied() } else { None } + }) + .collect::>(); + + constraints + .into_iter() + .zip(hash_candidates) + .filter_map(|(constraint, candidate)| { + let Some(symbol) = + candidate.filter(|symbol| symbol_constraint_counts.get(symbol) == Some(&1)) + else { + return Some(constraint); + }; + let abstracted = constraint.fold_exprs(cx, &mut |cx, expr| match expr.kind() { + SymExprKind::Keccak { name, .. } | SymExprKind::Hash { name, .. } + if *name == symbol => + { + SymExpr::get_var(cx, symbol) + } + _ => expr, + }); + let removable = fallback_single_var_model(std::slice::from_ref(&abstracted)).is_some(); + (!removable).then_some(constraint) + }) + .collect() +} + +/// Collects variables as the SMT writer sees them, stopping at opaque hash leaves. +fn collect_solver_vars(constraint: &SymBoolExpr, vars: &mut SymbolicVars) -> bool { + fn visit_bool(expr: &SymBoolExpr, vars: &mut SymbolicVars) -> bool { + match expr.kind() { + SymBoolExprKind::Const(_) => false, + SymBoolExprKind::Not(expr) => visit_bool(expr, vars), + SymBoolExprKind::And(exprs) => { + let mut contains_hash = false; + for expr in exprs.iter() { + contains_hash |= visit_bool(expr, vars); + } + contains_hash + } + SymBoolExprKind::Cmp(_, left, right) => { + visit_word(left, vars) | visit_word(right, vars) + } + } + } + + fn visit_word(expr: &SymExpr, vars: &mut SymbolicVars) -> bool { + match expr.kind() { + SymExprKind::Const(_) => false, + SymExprKind::Var(symbol) | SymExprKind::GasLeft(symbol) => { + vars.insert(*symbol); + false + } + SymExprKind::Keccak { name, .. } | SymExprKind::Hash { name, .. } => { + vars.insert(*name); + true + } + SymExprKind::Not(expr) => visit_word(expr, vars), + SymExprKind::BinOp(_, left, right) => visit_word(left, vars) | visit_word(right, vars), + SymExprKind::TernOp(_, left, right, modulus) => { + visit_word(left, vars) | visit_word(right, vars) | visit_word(modulus, vars) + } + SymExprKind::Ite(condition, then_expr, else_expr) => { + visit_bool(condition, vars) + | visit_word(then_expr, vars) + | visit_word(else_expr, vars) + } + } + } + + visit_bool(constraint, vars) +} + +/// Returns a hard-arithmetic fallback model only after validating it against original constraints. +fn validated_hard_arith_fallback_model( + cx: &SymCx, + normalized_constraints: &[SymBoolExpr], + original_constraints: &[SymBoolExpr], +) -> Option { + let model = hard_arith_fallback_model(cx, normalized_constraints)?; + eval_model_constraints(original_constraints, &model).then_some(model) +} + +#[derive(Clone, Debug, Default)] +struct PortfolioScheduler { + history: Vec>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PortfolioSchedulerSignal { + Winner { speed_bonus: i64 }, + InvalidModel, + Error, + Unknown, + Neutral, +} + +impl PortfolioSchedulerSignal { + /// Returns the scheduler signal represented by one solver run summary. + fn from_summary(summary: &SolverRunSummary) -> Self { + let speed_bonus = PORTFOLIO_SCHEDULER_MAX_SPEED_BONUS.saturating_sub( + summary.elapsed.as_millis().min(PORTFOLIO_SCHEDULER_SPEED_BONUS_CAP_MS) as i64, + ); + match (summary.winner, summary.outcome) { + (true, SolverOutcome::SatValid | SolverOutcome::Unsat) => Self::Winner { speed_bonus }, + (_, SolverOutcome::SatInvalid) => Self::InvalidModel, + (_, SolverOutcome::Error | SolverOutcome::Unexpected) => Self::Error, + (_, SolverOutcome::Unknown | SolverOutcome::TimeoutOrUnknown) => Self::Unknown, + _ => Self::Neutral, + } + } + + /// Returns the numeric score contribution for adaptive portfolio ordering. + const fn score(self) -> i64 { + match self { + Self::Winner { speed_bonus } => 1_000 + speed_bonus, + Self::InvalidModel => -1_000, + Self::Error => -750, + Self::Unknown => -250, + Self::Neutral => 0, + } + } +} + +impl PortfolioScheduler { + /// Returns configured commands ordered by recent portfolio performance. + fn ordered_commands(&mut self, commands: &[SolverCommand]) -> Vec<(usize, SolverCommand)> { + self.history.resize_with(commands.len(), VecDeque::new); + let mut ordered = commands.iter().cloned().enumerate().collect::>(); + ordered.sort_by(|(left_index, _), (right_index, _)| { + self.score(*right_index) + .cmp(&self.score(*left_index)) + .then_with(|| left_index.cmp(right_index)) + }); + ordered + } + + /// Records one query's portfolio summaries against original configured solver indexes. + fn record( + &mut self, + ordered_commands: &[(usize, SolverCommand)], + summaries: &[SolverRunSummary], + ) { + for summary in summaries { + let Some(run_index) = summary.index else { continue }; + let Some((configured_index, _)) = ordered_commands.get(run_index) else { continue }; + let Some(history) = self.history.get_mut(*configured_index) else { continue }; + let signal = PortfolioSchedulerSignal::from_summary(summary); + if matches!(signal, PortfolioSchedulerSignal::Neutral) { + continue; + } + history.push_back(signal); + if history.len() > PORTFOLIO_SCHEDULER_HISTORY { + history.pop_front(); + } + } + } + + /// Returns the recent-performance score for one configured solver index. + fn score(&self, index: usize) -> i64 { + self.history + .get(index) + .into_iter() + .flatten() + .rev() + .enumerate() + .map(|(age, signal)| { + let recency = PORTFOLIO_SCHEDULER_HISTORY + .saturating_sub(age) + .max(PORTFOLIO_SCHEDULER_MIN_RECENCY_WEIGHT as usize) + as i64; + recency * signal.score() + }) + .sum() + } +} + +/// Returns the subprocess commands for the configured SMT solver setup. +pub(crate) fn solver_commands_for_config( + config: &SymbolicConfig, +) -> Result, SolverConfigError> { + if let Some(command) = config.solver_command.as_deref().filter(|command| !command.is_empty()) { + return Ok(vec![SolverCommand::new(split_solver_command(command)?, false)?]); + } + + let portfolio = config + .solver_portfolio + .iter() + .map(|entry| entry.trim()) + .filter(|entry| !entry.is_empty()) + .collect::>(); + if !portfolio.is_empty() { + return portfolio.into_iter().map(solver_command_for_portfolio_entry).collect(); + } + + Ok(vec![named_solver_command(&config.solver)?]) +} + +/// Returns the default command for a known solver name. +pub(crate) fn named_solver_command(solver: &str) -> Result { + let (parts, smt_timeout) = match solver { + "z3" => (vec!["z3", "-in", "-smt2"], true), + "yices" => (vec!["yices-smt2", "--bvconst-in-decimal"], false), + "cvc5" => ( + vec![ + "cvc5", + "--produce-models", + "--lang", + "smt2", + "--bv-print-consts-as-indexed-symbols", + ], + false, + ), + "cvc5-int" => ( + vec![ + "cvc5", + "--produce-models", + "--lang", + "smt2", + "--bv-print-consts-as-indexed-symbols", + "--solve-bv-as-int=iand", + "--iand-mode=bitwise", + ], + false, + ), + "bitwuzla" => (vec!["bitwuzla", "--produce-models"], false), + "bitwuzla-abs" => (vec!["bitwuzla", "--produce-models", "--abstraction"], false), + // Preserve existing behavior for custom z3-compatible executable names/paths. + custom => (vec![custom, "-in", "-smt2"], true), + }; + let parts = parts.into_iter().map(str::to_string).collect::>(); + SolverCommand::new(parts, smt_timeout) +} + +/// Returns the command for one configured portfolio entry. +pub(crate) fn solver_command_for_portfolio_entry( + entry: &str, +) -> Result { + if entry.chars().any(|ch| ch.is_whitespace() || matches!(ch, '"' | '\'' | '\\')) { + SolverCommand::new(split_solver_command(entry)?, false) + } else { + named_solver_command(entry) + } +} + +/// Splits a shell-like solver command into argv parts. +pub(crate) fn split_solver_command(command: &str) -> Result, SolverConfigError> { + let parts = shlex::split(command).ok_or(SolverConfigError::InvalidShellQuoting)?; + if parts.is_empty() { + return Err(SolverConfigError::EmptyCommand); + } + + Ok(parts) +} + +#[derive(Debug)] +enum SolverProcessOutcome { + Output(String), + Unknown, + Cancelled, + Error(String), +} + +impl SolverProcessOutcome { + fn into_result(self) -> Result { + match self { + Self::Output(output) => Ok(output), + Self::Unknown => Err(SymbolicError::SolverUnknown), + Self::Cancelled => { + warn!("solver query was cancelled"); + Err(SymbolicError::Solver("solver query was cancelled".to_string())) + } + Self::Error(err) => Err(SymbolicError::Solver(err)), + } + } +} + +#[derive(Debug)] +struct SolverProcessResult { + index: usize, + display: String, + scheduled_after: Duration, + started_after: Duration, + elapsed: Duration, + outcome: SolverProcessOutcome, +} + +#[derive(Debug)] +struct ScheduledSolver { + index: usize, + command: SolverCommand, + launch_after: Duration, +} + +#[derive(Debug)] +struct SolverCommandRun { + output: Result, + summaries: Vec, +} + +#[derive(Debug)] +pub(crate) struct SolverRunSummary { + index: Option, + display: String, + scheduled_after: Option, + started_after: Option, + elapsed: Duration, + outcome: SolverOutcome, + detail: Option, + winner: bool, +} + +impl SolverRunSummary { + /// Builds a portfolio run summary with no detail or winner marker. + pub(crate) const fn new(display: String, elapsed: Duration, outcome: SolverOutcome) -> Self { + Self { + index: None, + display, + scheduled_after: None, + started_after: None, + elapsed, + outcome, + detail: None, + winner: false, + } + } + + /// Attaches the configured portfolio order and launch delay to this summary. + pub(crate) const fn with_schedule( + mut self, + index: usize, + scheduled_after: Duration, + started_after: Option, + ) -> Self { + self.index = Some(index); + self.scheduled_after = Some(scheduled_after); + self.started_after = started_after; + self + } + + fn with_detail(mut self, detail: String) -> Self { + self.detail = Some(detail); + self + } + + /// Marks this solver run as the portfolio result winner. + pub(crate) const fn winner(mut self) -> Self { + self.winner = true; + self + } +} + +/// Runs one or more solver commands and returns the first decisive SMT-LIB response. +fn run_solver_commands( + cx: &SymCx, + commands: &[SolverCommand], + smt: &str, + timeout: Option, + model_constraints: Option<&[SymBoolExpr]>, +) -> SolverCommandRun { + if commands.is_empty() { + return SolverCommandRun { + output: Err(SymbolicError::Solver("symbolic solver portfolio is empty".to_string())), + summaries: Vec::new(), + }; + } + if commands.len() == 1 { + let output = + run_solver_process(&commands[0], smt, timeout, &AtomicBool::new(false)).into_result(); + return SolverCommandRun { output, summaries: Vec::new() }; + } + + let cancel = Arc::new(AtomicBool::new(false)); + let (tx, rx) = mpsc::channel(); + thread::scope(|scope| { + let started_at = Instant::now(); + let mut pending = scheduled_portfolio(commands); + let mut running = 0usize; + + let mut saw_unknown = false; + let mut saw_unsat = false; + let mut saw_invalid_sat_model = false; + let mut errors = Vec::new(); + let mut decisive = None; + let mut summaries = Vec::new(); + + while running > 0 || !pending.is_empty() { + if decisive.is_none() { + let now = started_at.elapsed(); + let mut launched = false; + while pending + .front() + .is_some_and(|solver| solver.launch_after <= now || (running == 0 && !launched)) + { + let solver = pending.pop_front().expect("pending solver exists"); + let tx = tx.clone(); + let cancel = Arc::clone(&cancel); + let started_after = started_at.elapsed(); + running += 1; + launched = true; + scope.spawn(move || { + let start = Instant::now(); + let outcome = run_solver_process(&solver.command, smt, timeout, &cancel); + let _ = tx.send(SolverProcessResult { + index: solver.index, + display: solver.command.display, + scheduled_after: solver.launch_after, + started_after, + elapsed: start.elapsed(), + outcome, + }); + }); + } + } + + if running == 0 { + continue; + } + + let result = if decisive.is_none() { + next_portfolio_launch_wait(started_at, &pending) + .map_or_else(|| rx.recv().ok(), |wait| rx.recv_timeout(wait).ok()) + } else { + rx.recv().ok() + }; + let Some(result) = result else { + continue; + }; + running = running.saturating_sub(1); + let SolverProcessResult { + index, + display, + scheduled_after, + started_after, + elapsed, + outcome, + } = result; + if decisive.is_some() { + summaries.push(summary_for_cancelled_solver_result( + index, + display, + scheduled_after, + started_after, + elapsed, + outcome, + )); + continue; + } + match outcome { + SolverProcessOutcome::Output(output) + if output.lines().next().unwrap_or_default().trim() == "sat" => + { + if let Some(constraints) = model_constraints + && let Err(err) = validate_solver_model_output(cx, &output, constraints) + { + summaries.push( + SolverRunSummary::new( + display.clone(), + elapsed, + SolverOutcome::SatInvalid, + ) + .with_schedule(index, scheduled_after, Some(started_after)) + .with_detail(err.to_string()), + ); + saw_invalid_sat_model = true; + errors.push(format!("{display}: {err}")); + continue; + } + summaries.push( + SolverRunSummary::new(display, elapsed, SolverOutcome::SatValid) + .with_schedule(index, scheduled_after, Some(started_after)) + .winner(), + ); + decisive = Some(output); + cancel.store(true, Ordering::SeqCst); + while let Some(solver) = pending.pop_front() { + summaries.push(summary_for_unstarted_solver(solver)); + } + } + SolverProcessOutcome::Output(output) + if output.lines().next().unwrap_or_default().trim() == "unsat" => + { + summaries.push( + SolverRunSummary::new(display, elapsed, SolverOutcome::Unsat) + .with_schedule(index, scheduled_after, Some(started_after)), + ); + saw_unsat = true; + } + SolverProcessOutcome::Output(output) + if output.lines().next().unwrap_or_default().trim() == "unknown" => + { + summaries.push( + SolverRunSummary::new(display, elapsed, SolverOutcome::Unknown) + .with_schedule(index, scheduled_after, Some(started_after)), + ); + saw_unknown = true; + } + SolverProcessOutcome::Output(output) => { + let first_line = output.lines().next().unwrap_or_default().trim().to_string(); + summaries.push( + SolverRunSummary::new(display.clone(), elapsed, SolverOutcome::Unexpected) + .with_schedule(index, scheduled_after, Some(started_after)) + .with_detail(first_line.clone()), + ); + errors.push(format!("{display}: unexpected solver response `{first_line}`")); + } + SolverProcessOutcome::Unknown => { + summaries.push( + SolverRunSummary::new(display, elapsed, SolverOutcome::TimeoutOrUnknown) + .with_schedule(index, scheduled_after, Some(started_after)), + ); + saw_unknown = true; + } + SolverProcessOutcome::Cancelled => { + summaries.push( + SolverRunSummary::new(display, elapsed, SolverOutcome::Cancelled) + .with_schedule(index, scheduled_after, Some(started_after)), + ); + } + SolverProcessOutcome::Error(err) => { + summaries.push( + SolverRunSummary::new(display.clone(), elapsed, SolverOutcome::Error) + .with_schedule(index, scheduled_after, Some(started_after)) + .with_detail(err.clone()), + ); + errors.push(format!("{display}: {err}")); + } + } + } + + if decisive.is_none() + && saw_unsat + && let Some(summary) = + summaries.iter_mut().find(|summary| summary.outcome == SolverOutcome::Unsat) + { + summary.winner = true; + } + + let output = if let Some(output) = decisive { + Ok(output) + } else if saw_invalid_sat_model { + Err(SymbolicError::Solver(errors.join("; "))) + } else if saw_unsat { + Ok("unsat\n".to_string()) + } else if saw_unknown { + Err(SymbolicError::SolverUnknown) + } else { + Err(SymbolicError::Solver(errors.join("; "))) + }; + + SolverCommandRun { output, summaries } + }) +} + +/// Returns the staged launch plan for a configured portfolio. +fn scheduled_portfolio(commands: &[SolverCommand]) -> VecDeque { + commands + .iter() + .cloned() + .enumerate() + .map(|(index, command)| ScheduledSolver { + index, + command, + launch_after: portfolio_launch_delay(index), + }) + .collect() +} + +/// Returns when the solver at `index` should be started, relative to query start. +const fn portfolio_launch_delay(index: usize) -> Duration { + match index { + 0 => Duration::ZERO, + 1 => SECOND_PORTFOLIO_SOLVER_DELAY, + index => RESCUE_PORTFOLIO_SOLVER_DELAY.saturating_mul(index.saturating_sub(1) as u32), + } +} + +/// Returns how long the supervisor can wait before the next pending solver is due. +fn next_portfolio_launch_wait( + started_at: Instant, + pending: &VecDeque, +) -> Option { + pending.front().map(|solver| { + solver.launch_after.checked_sub(started_at.elapsed()).unwrap_or(Duration::ZERO) + }) +} + +/// Summarizes a solver that was never launched because the portfolio already won. +fn summary_for_unstarted_solver(solver: ScheduledSolver) -> SolverRunSummary { + SolverRunSummary::new(solver.command.display, Duration::ZERO, SolverOutcome::NotStarted) + .with_schedule(solver.index, solver.launch_after, None) +} + +/// Summarizes a solver result received after a portfolio winner was chosen. +fn summary_for_cancelled_solver_result( + index: usize, + display: String, + scheduled_after: Duration, + started_after: Duration, + elapsed: Duration, + outcome: SolverProcessOutcome, +) -> SolverRunSummary { + let summary = match outcome { + SolverProcessOutcome::Output(output) + if output.lines().next().unwrap_or_default().trim() == "sat" => + { + SolverRunSummary::new(display, elapsed, SolverOutcome::SatAfterWinner) + } + SolverProcessOutcome::Output(output) + if output.lines().next().unwrap_or_default().trim() == "unsat" => + { + SolverRunSummary::new(display, elapsed, SolverOutcome::UnsatAfterWinner) + } + SolverProcessOutcome::Output(output) + if output.lines().next().unwrap_or_default().trim() == "unknown" => + { + SolverRunSummary::new(display, elapsed, SolverOutcome::UnknownAfterWinner) + } + SolverProcessOutcome::Output(output) => { + SolverRunSummary::new(display, elapsed, SolverOutcome::Unexpected) + .with_detail(output.lines().next().unwrap_or_default().trim().to_string()) + } + SolverProcessOutcome::Unknown => { + SolverRunSummary::new(display, elapsed, SolverOutcome::TimeoutOrUnknown) + } + SolverProcessOutcome::Cancelled => { + SolverRunSummary::new(display, elapsed, SolverOutcome::Cancelled) + } + SolverProcessOutcome::Error(err) => { + SolverRunSummary::new(display, elapsed, SolverOutcome::Error).with_detail(err) + } + }; + summary.with_schedule(index, scheduled_after, Some(started_after)) +} + +/// Formats solver portfolio outcome diagnostics. +fn format_solver_portfolio_summaries(summaries: &[SolverRunSummary]) -> String { + let mut output = String::new(); + let _ = writeln!(output, "--- symbolic solver portfolio outcomes ---"); + for summary in summaries { + let marker = if summary.winner { " winner" } else { "" }; + let schedule = summary.index.zip(summary.scheduled_after).map(|(index, delay)| { + let started = summary + .started_after + .map(|started| format!(" started +{started:.3?}")) + .unwrap_or_default(); + format!("#{} scheduled +{delay:.3?}{started} ", index + 1) + }); + let _ = write!( + output, + "{}{}: {} in {:.3?}{}", + schedule.as_deref().unwrap_or_default(), + summary.display, + summary.outcome, + summary.elapsed, + marker + ); + if let Some(detail) = summary.detail.as_deref().filter(|detail| !detail.is_empty()) { + let _ = write!(output, " ({detail})"); + } + let _ = writeln!(output); + } + output +} + +struct Z3Session { + child: SolverChild, + stdin: ChildStdin, + stdout: Receiver>, + stderr: Receiver, + stdout_thread: Option>, + stderr_thread: Option>, +} + +impl Z3Session { + fn spawn(command: &SolverCommand) -> Result { + let mut child = spawn_solver_process(command)?; + let stdin = child.child_mut().stdin.take().expect("piped solver stdin is available"); + let stdout = child.child_mut().stdout.take().expect("piped solver stdout is available"); + let stderr = child.child_mut().stderr.take().expect("piped solver stderr is available"); + + let (stdout_tx, stdout_rx) = mpsc::channel(); + let stdout_thread = thread::spawn(move || { + for line in BufReader::new(stdout).lines() { + let line = line.map_err(|err| format!("failed to read solver output: {err}")); + let failed = line.is_err(); + if stdout_tx.send(line).is_err() || failed { + break; + } + } + }); + let (stderr_tx, stderr_rx) = mpsc::channel(); + let stderr_thread = thread::spawn(move || { + let mut stderr = BufReader::new(stderr); + let mut output = String::new(); + let _ = stderr.read_to_string(&mut output); + let _ = stderr_tx.send(output); + }); + + Ok(Self { + child, + stdin, + stdout: stdout_rx, + stderr: stderr_rx, + stdout_thread: Some(stdout_thread), + stderr_thread: Some(stderr_thread), + }) + } + + fn query( + &mut self, + command: &SolverCommand, + smt: &str, + timeout: Option, + ) -> SolverProcessOutcome { + if let Err(err) = self + .stdin + .write_all(b"(reset)\n") + .and_then(|_| self.stdin.write_all(smt.as_bytes())) + .and_then(|_| writeln!(self.stdin, "(echo \"{Z3_QUERY_END}\")")) + .and_then(|_| self.stdin.flush()) + { + return SolverProcessOutcome::Error(format!("failed to write solver query: {err}")); + } + + let started_at = Instant::now(); + let timeout = timeout + .filter(|seconds| *seconds > 0) + .map(|seconds| Duration::from_secs(seconds.into())); + let mut output = String::new(); + loop { + let Some(wait) = solver_wait_duration(started_at.elapsed(), timeout) else { + return SolverProcessOutcome::Unknown; + }; + match self.stdout.recv_timeout(wait) { + Ok(Ok(line)) if line == Z3_QUERY_END => { + return SolverProcessOutcome::Output(output); + } + Ok(Ok(line)) => { + output.push_str(&line); + output.push('\n'); + } + Ok(Err(err)) => return SolverProcessOutcome::Error(err), + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => { + let stderr = + self.stderr.recv_timeout(SOLVER_CANCEL_CHECK_INTERVAL).unwrap_or_default(); + return match self.child.child_mut().try_wait() { + Ok(Some(status)) => SolverProcessOutcome::Error(solver_exit_error( + command, status, &output, &stderr, + )), + Ok(None) => SolverProcessOutcome::Error( + "solver stdout closed before the query completed".to_string(), + ), + Err(err) => SolverProcessOutcome::Error(format!( + "failed to query solver process status: {err}" + )), + }; + } + } + } + } +} + +impl Drop for Z3Session { + fn drop(&mut self) { + self.child.terminate(); + if let Some(thread) = self.stdout_thread.take() { + let _ = thread.join(); + } + if let Some(thread) = self.stderr_thread.take() { + let _ = thread.join(); + } + } +} + +fn spawn_solver_process(command: &SolverCommand) -> Result { + Command::new(&command.program) + .args(&command.args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map(SolverChild::new) + .map_err(|err| format!("failed to spawn `{}`: {err}", command.display)) +} + +/// Runs one solver process to completion, timeout, or cooperative cancellation. +fn run_solver_process( + command: &SolverCommand, + smt: &str, + timeout: Option, + cancel: &AtomicBool, +) -> SolverProcessOutcome { + let mut child = match spawn_solver_process(command) { + Ok(child) => child, + Err(err) => return SolverProcessOutcome::Error(err), + }; + + if let Some(mut stdin) = child.child_mut().stdin.take() + && let Err(err) = stdin.write_all(smt.as_bytes()) + { + return SolverProcessOutcome::Error(format!("failed to write solver query: {err}")); + } + + let started_at = Instant::now(); + let timeout = + timeout.filter(|seconds| *seconds > 0).map(|seconds| Duration::from_secs(seconds.into())); + loop { + if cancel.load(Ordering::SeqCst) { + return SolverProcessOutcome::Cancelled; + } + + let Some(wait) = solver_wait_duration(started_at.elapsed(), timeout) else { + return SolverProcessOutcome::Unknown; + }; + + match child.child_mut().wait_timeout(wait) { + Ok(Some(_)) => break, + Ok(None) => {} + Err(err) => { + return SolverProcessOutcome::Error(format!( + "failed to wait for solver process: {err}" + )); + } + } + } + + let output = match child.wait_with_output() { + Ok(output) => output, + Err(err) => { + return SolverProcessOutcome::Error(format!("failed to read solver output: {err}")); + } + }; + let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + return SolverProcessOutcome::Error(solver_exit_error( + command, + output.status, + &stdout, + &stderr, + )); + } + SolverProcessOutcome::Output(stdout) +} + +fn solver_wait_duration(elapsed: Duration, timeout: Option) -> Option { + let Some(timeout) = timeout else { + return Some(SOLVER_CANCEL_CHECK_INTERVAL); + }; + let remaining = timeout.checked_sub(elapsed)?; + if remaining.is_zero() { None } else { Some(remaining.min(SOLVER_CANCEL_CHECK_INTERVAL)) } +} + +struct SolverChild { + child: Option, +} + +impl SolverChild { + const fn new(child: Child) -> Self { + Self { child: Some(child) } + } + + const fn child_mut(&mut self) -> &mut Child { + self.child.as_mut().expect("solver child exists") + } + + fn wait_with_output(mut self) -> std::io::Result { + self.child.take().expect("solver child exists").wait_with_output() + } + + fn terminate(&mut self) { + if let Some(mut child) = self.child.take() { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +impl Drop for SolverChild { + fn drop(&mut self) { + self.terminate(); + } +} + +fn solver_exit_error( + command: &SolverCommand, + status: std::process::ExitStatus, + stdout: &str, + stderr: &str, +) -> String { + let mut message = format!("`{}` exited with {status}", command.display); + if !stderr.trim().is_empty() { + message.push_str(": "); + message.push_str(stderr.trim()); + } + if !stdout.trim().is_empty() { + message.push_str("; stdout: "); + message.push_str(stdout.trim()); + } + message +} + +pub(crate) fn parse_and_validate_model( + cx: &SymCx, + output: &str, + constraints: &[SymBoolExpr], +) -> Result { + let symbols = model_symbols_for_constraints(cx, constraints); + let model = parse_model_with_symbols(output, &symbols)?; + if eval_model_constraints(constraints, &model) { + Ok(model) + } else { + let reason = if constraints.iter().any(SymBoolExpr::contains_keccak) { + "solver model does not satisfy path constraints involving symbolic Keccak heuristic" + } else { + "solver model does not satisfy path constraints" + }; + debug!( + constraint_count = constraints.len(), + reason, "solver model does not satisfy path constraints" + ); + Err(SymbolicError::Solver(reason.to_string())) + } +} + +pub(crate) fn validate_solver_model_output( + cx: &SymCx, + output: &str, + constraints: &[SymBoolExpr], +) -> Result<(), SymbolicError> { + parse_and_validate_model(cx, output, constraints).map(|_| ()) +} + +fn parse_model_with_symbols( + output: &str, + symbols: &HashMap, +) -> Result { + parse_model_with_symbol(output, |name| symbols.get(name).copied()) +} + +fn parse_model_with_symbol( + output: &str, + mut symbol_for: impl FnMut(&str) -> Option, +) -> Result { + let mut values = SymbolicModel::default(); + parse_model_values(output, |name, value| { + if let Some(symbol) = symbol_for(name) { + values.insert(symbol, value); + } + })?; + Ok(values) +} + +fn parse_model_values( + output: &str, + mut insert_value: impl FnMut(&str, U256), +) -> Result<(), SymbolicError> { + let mut tokens = output + .split(|c: char| c.is_whitespace() || matches!(c, '(' | ')')) + .filter(|token| !token.is_empty()); + while let Some(token) = tokens.next() { + if token == "define-fun" { + let Some(name) = tokens.next() else { continue }; + while let Some(value) = tokens.next() { + if let Some(hex) = value.strip_prefix("#x") { + if hex.len() > 64 { + return Err(SymbolicError::Solver( + "solver hex model value exceeds 256 bits".to_string(), + )); + } + let mut bytes = [0u8; 32]; + let decoded = alloy_primitives::hex::decode(hex).map_err(|err| { + SymbolicError::Solver(format!("invalid solver hex model value: {err}")) + })?; + let start = 32usize.saturating_sub(decoded.len()); + bytes[start..start + decoded.len()].copy_from_slice(&decoded); + insert_value(name, U256::from_be_bytes(bytes)); + break; + } + if let Some(binary) = value.strip_prefix("#b") { + if binary.len() > 256 { + return Err(SymbolicError::Solver( + "solver binary model value exceeds 256 bits".to_string(), + )); + } + let parsed = U256::from_str_radix(binary, 2).map_err(|err| { + SymbolicError::Solver(format!("invalid solver binary model value: {err}")) + })?; + insert_value(name, parsed); + break; + } + if value == "_" + && let Some(bv) = tokens.next().and_then(|v| v.strip_prefix("bv")) + { + let parsed = U256::from_str_radix(bv, 10).map_err(|err| { + SymbolicError::Solver(format!("invalid solver decimal model value: {err}")) + })?; + insert_value(name, parsed); + break; + } + } + } + } + Ok(()) +} + +fn model_symbols_for_constraints( + cx: &SymCx, + constraints: &[SymBoolExpr], +) -> HashMap { + let mut vars = SymbolicVars::default(); + for constraint in constraints { + constraint.collect_vars(&mut vars); + } + vars.into_iter().map(|symbol| (cx.symbol_name(symbol).to_owned(), symbol)).collect() +} diff --git a/crates/evm/symbolic/src/runtime/solver/normalize/mod.rs b/crates/evm/symbolic/src/runtime/solver/normalize/mod.rs new file mode 100644 index 0000000000000..0ae620588bbef --- /dev/null +++ b/crates/evm/symbolic/src/runtime/solver/normalize/mod.rs @@ -0,0 +1,2087 @@ +//! Constraint and expression normalization for solver queries. + +use super::*; + +mod polynomial; +mod rounding; + +use polynomial::polynomial_identity; + +/// Reuses context-free normalization results while retaining per-query contextual rewrites. +pub(super) fn normalize_constraints_for_solver_cached( + cx: &mut SymCx, + constraints: &[SymBoolExpr], + normalization_cache: &mut HashMap, +) -> Vec { + normalize_constraints_for_solver_with(cx, constraints, |cx, constraint| { + if let Some(normalized) = normalization_cache.get(constraint) { + return normalized.clone(); + } + let normalized = normalize_bool_for_solver(cx, constraint.clone()); + // These are strong hash-consed handles, so bound their lifetime like the SAT cache. + if normalization_cache.len() < SYMBOLIC_SOLVER_SAT_CACHE_MAX_ENTRIES { + normalization_cache.insert(constraint.clone(), normalized.clone()); + } + normalized + }) +} + +fn normalize_constraints_for_solver_with( + cx: &mut SymCx, + constraints: &[SymBoolExpr], + mut normalize: impl FnMut(&mut SymCx, &SymBoolExpr) -> SymBoolExpr, +) -> Vec { + let mut changed_conjuncts = HashSet::default(); + let normalized = normalize_constraint_batch( + constraints.iter().map(|constraint| { + let normalized = normalize(cx, constraint); + if normalized != *constraint { + mark_conjuncts(&normalized, &mut changed_conjuncts); + } + normalized + }), + constraints.len(), + ); + if matches!(normalized.as_slice(), [expr] if expr.as_const() == Some(false)) { + return normalized; + } + + // Context-dependent rewrites must not contribute facts to the context that proves them. Mark + // candidates by syntax rather than by whether the full context happens to prove a rewrite: + // contradictory bounds can make an interval unavailable until another candidate is removed. + let retained_count = normalized + .iter() + .filter(|constraint| !ConstraintContext::requires_independent_context(constraint)) + .count(); + let retained = normalized + .iter() + .filter(|constraint| !ConstraintContext::requires_independent_context(constraint)); + let context = + ConstraintContext::from_constraints_with_lower_bounds(retained, retained_count, false); + let normalized_len = normalized.len(); + let normalized = normalize_constraint_batch( + normalized.into_iter().map(|constraint| { + let changed = changed_conjuncts.contains(&constraint); + context.normalize_bool(cx, constraint, changed) + }), + normalized_len, + ); + normalize_bounded_comparisons(cx, normalized) +} + +/// Simplifies predicates using only the other, still-retained conjuncts. +fn normalize_bounded_comparisons( + cx: &mut SymCx, + mut constraints: Vec, +) -> Vec { + // Later predicates can expose guards needed by earlier ones. Revisit the retained + // conjunction, but bound the work; unfinished simplification is still sound SMT input. + for _ in 0..MAX_CONTEXTUAL_PASSES { + let previous = constraints.clone(); + let mut index = 0; + while index < constraints.len() { + let context = ConstraintContext::for_rewrite(cx, &constraints, index); + constraints[index] = context.normalize_bool(cx, constraints[index].clone(), false); + // Revisit each newly exposed conjunct with the retained supporting facts. Otherwise a + // division rewrite can leave a simple contradiction hidden until the SMT fallback. + if let SymBoolExprKind::And(terms) = constraints[index].kind() { + let terms = terms.to_vec(); + constraints.splice(index..=index, terms); + continue; + } + match context.bounded_bool_value(&constraints[index]) { + Some(false) => return vec![SymBoolExpr::constant(cx, false)], + Some(true) => { + constraints.remove(index); + } + None => index += 1, + } + } + // Contextual rewrites may change sort order or expose a conjunction. + let count = constraints.len(); + constraints = normalize_constraint_batch(constraints, count); + if constraints == previous || constraints.iter().any(|c| c.as_const() == Some(false)) { + break; + } + } + constraints +} + +fn mark_conjuncts(expr: &SymBoolExpr, out: &mut HashSet) { + let mut pending = vec![expr.clone()]; + while let Some(expr) = pending.pop() { + if !out.insert(expr.clone()) { + continue; + } + if let SymBoolExprKind::And(values) = expr.kind() { + pending.extend(values.iter().cloned()); + } + } +} + +fn normalize_constraint_batch( + constraints: impl IntoIterator, + capacity: usize, +) -> Vec { + let mut normalized = Vec::with_capacity(capacity); + for constraint in constraints { + if constraint.as_const() == Some(false) { + return vec![constraint]; + } + constraint.push_normalized_conjuncts(&mut normalized); + } + sort_dedup_bool_exprs(&mut normalized); + normalized +} + +fn sort_dedup_bool_exprs(exprs: &mut Vec) { + // Hash-consing already caches deterministic structural hashes. Only render full structural + // keys for the exceedingly rare case where two distinct expressions collide. + exprs.sort_unstable_by(bool_expr_cmp); + exprs.dedup(); +} + +fn bool_expr_cmp(left: &SymBoolExpr, right: &SymBoolExpr) -> std::cmp::Ordering { + if left == right { + return std::cmp::Ordering::Equal; + } + left.stable_hash_cmp(right) + .then_with(|| bool_structural_key(left).cmp(&bool_structural_key(right))) +} + +fn bool_structural_key(expr: &SymBoolExpr) -> String { + let mut key = String::new(); + write_bool_structural_key(&mut key, expr); + key +} + +fn write_bool_structural_key(out: &mut String, expr: &SymBoolExpr) { + match expr.kind() { + SymBoolExprKind::Const(value) => { + let _ = write!(out, "0:{value}"); + } + SymBoolExprKind::Not(value) => { + out.push_str("1:"); + write_bool_structural_key(out, value); + } + SymBoolExprKind::And(values) => { + let _ = write!(out, "2:{}:", values.len()); + for value in values.iter() { + write_bool_structural_key(out, value); + out.push(';'); + } + } + SymBoolExprKind::Cmp(op, left, right) => { + let _ = write!(out, "3:{}:", cmp_op_key(*op)); + write_expr_structural_key(out, left); + out.push(':'); + write_expr_structural_key(out, right); + } + } +} + +fn write_expr_structural_key(out: &mut String, expr: &SymExpr) { + match expr.kind() { + SymExprKind::Const(value) => { + let _ = write!(out, "0:{value:064x}"); + } + SymExprKind::Var(name) => { + let _ = write!(out, "1:{}", name.id()); + } + SymExprKind::GasLeft(symbol) => { + let _ = write!(out, "2:{}", symbol.id()); + } + SymExprKind::Keccak { name, len, bytes } => { + let _ = write!(out, "3:{}:", name.id()); + write_expr_structural_key(out, len); + write_exprs_structural_key(out, bytes); + } + SymExprKind::Hash { name, algorithm, bytes } => { + let _ = write!(out, "4:{}:{algorithm}:", name.id()); + write_exprs_structural_key(out, bytes); + } + SymExprKind::Not(value) => { + out.push_str("5:"); + write_expr_structural_key(out, value); + } + SymExprKind::BinOp(op, left, right) => { + let _ = write!(out, "6:{}:", expr_binop_key(*op)); + write_expr_structural_key(out, left); + out.push(':'); + write_expr_structural_key(out, right); + } + SymExprKind::TernOp(op, left, right, modulus) => { + let _ = write!(out, "7:{}:", expr_ternop_key(*op)); + write_expr_structural_key(out, left); + out.push(':'); + write_expr_structural_key(out, right); + out.push(':'); + write_expr_structural_key(out, modulus); + } + SymExprKind::Ite(condition, then_expr, else_expr) => { + out.push_str("9:"); + write_bool_structural_key(out, condition); + out.push(':'); + write_expr_structural_key(out, then_expr); + out.push(':'); + write_expr_structural_key(out, else_expr); + } + } +} + +fn write_exprs_structural_key(out: &mut String, exprs: &[SymExpr]) { + let _ = write!(out, "{}:", exprs.len()); + for expr in exprs { + write_expr_structural_key(out, expr); + out.push(';'); + } +} + +const fn cmp_op_key(op: SymCmpOp) -> u8 { + match op { + SymCmpOp::Eq => 0, + SymCmpOp::Ult => 1, + SymCmpOp::Ugt => 2, + SymCmpOp::Ule => 3, + SymCmpOp::Uge => 4, + SymCmpOp::Slt => 5, + SymCmpOp::Sgt => 6, + } +} + +const fn expr_binop_key(op: SymBinOp) -> u8 { + match op { + SymBinOp::Add => 0, + SymBinOp::Sub => 1, + SymBinOp::Mul => 2, + SymBinOp::UDiv => 3, + SymBinOp::URem => 4, + SymBinOp::SDiv => 5, + SymBinOp::SRem => 6, + SymBinOp::And => 7, + SymBinOp::Or => 8, + SymBinOp::Xor => 9, + SymBinOp::Shl => 10, + SymBinOp::Shr => 11, + SymBinOp::Sar => 12, + } +} + +const fn expr_ternop_key(op: SymTernOp) -> u8 { + match op { + SymTernOp::AddMod => 0, + SymTernOp::MulMod => 1, + } +} + +/// Returns whether canonically ordered normalized constraints contain a direct contradiction. +pub(super) fn constraints_are_directly_unsat(cx: &mut SymCx, constraints: &[SymBoolExpr]) -> bool { + let mut derived = Vec::new(); + for constraint in constraints { + let Some(fact) = bitwise_bool_word_fact(cx, constraint) else { + continue; + }; + if let SymBoolExprKind::And(values) = fact.kind() { + // A positive conjunction implies each member independently. Retain the aggregate for + // exact matches, but expose its members to the direct contradiction check as well. + derived.extend(values.iter().cloned()); + } + derived.push(fact); + } + let contains = |expected: &SymBoolExpr| { + constraints.binary_search_by(|candidate| bool_expr_cmp(candidate, expected)).is_ok() + || derived.contains(expected) + }; + constraints.iter().chain(&derived).any(|constraint| match constraint.kind() { + SymBoolExprKind::Const(false) => true, + SymBoolExprKind::Not(inner) + if let SymBoolExprKind::And(values) = inner.kind() + && values.iter().all(&contains) => + { + true + } + SymBoolExprKind::Not(inner) => contains(inner), + _ => { + let negated = constraint.clone().not(cx); + contains(&negated) + } + }) +} + +fn bitwise_bool_word_fact(cx: &mut SymCx, constraint: &SymBoolExpr) -> Option { + match constraint.kind() { + SymBoolExprKind::Cmp(SymCmpOp::Eq, left, right) + if right.as_const().is_some_and(|value| value.is_zero()) => + { + left.bitwise_bool_word_condition(cx).map(|condition| condition.not(cx)) + } + SymBoolExprKind::Not(inner) => { + let SymBoolExprKind::Cmp(SymCmpOp::Eq, left, right) = inner.kind() else { + return None; + }; + if !right.as_const().is_some_and(|value| value.is_zero()) { + return None; + } + left.bitwise_bool_word_condition(cx) + } + _ => None, + } +} + +/// Returns whether every expression in `subset` appears in `superset`. +pub(super) fn sorted_bool_exprs_are_subset( + subset: &[SymBoolExpr], + superset: &[SymBoolExpr], +) -> bool { + if subset.len() > superset.len() { + return false; + } + + let superset: HashSet<_> = superset.iter().collect(); + subset.iter().all(|expected| superset.contains(expected)) +} + +/// Normalizes one boolean expression into an equivalent, solver-friendlier form. +pub(crate) fn normalize_bool_for_solver(cx: &mut SymCx, expr: SymBoolExpr) -> SymBoolExpr { + expr.fold(cx, &mut normalize_bool_node_for_solver) +} + +impl SymBoolExpr { + fn push_normalized_conjuncts(self, out: &mut Vec) { + match self.kind() { + SymBoolExprKind::Const(true) => {} + SymBoolExprKind::And(values) => { + for value in values.iter().cloned() { + value.push_normalized_conjuncts(out); + } + } + _ => out.push(self), + } + } +} + +fn normalize_bool_node_for_solver(cx: &mut SymCx, expr: SymBoolExpr) -> SymBoolExpr { + if let Some(normalized) = expr.normalize_udiv_for_solver(cx) { + return normalized; + } + + match expr.kind() { + SymBoolExprKind::Not(value) => match value.kind() { + SymBoolExprKind::Cmp(SymCmpOp::Ult, left, right) + if matches!(left.kind(), SymExprKind::Not(_)) => + { + normalize_cmp_for_solver(cx, SymCmpOp::Ule, right.clone(), left.clone()) + } + _ => expr, + }, + SymBoolExprKind::Cmp(op, left, right) => { + let left = normalize_expr_for_solver(cx, left.clone()); + let right = normalize_expr_for_solver(cx, right.clone()); + if *op == SymCmpOp::Eq && polynomial_identity(&left, &right) { + return SymBoolExpr::constant(cx, true); + } + let normalized = normalize_cmp_for_solver(cx, *op, left, right); + normalized.normalize_udiv_for_solver(cx).unwrap_or(normalized) + } + _ => expr, + } +} + +fn normalize_cmp_for_solver( + cx: &mut SymCx, + op: SymCmpOp, + left: SymExpr, + right: SymExpr, +) -> SymBoolExpr { + if op == SymCmpOp::Eq { + for (quotient, expected) in [(&left, &right), (&right, &left)] { + if let Some((denominator, value)) = + ConstraintContext::mul_div_identity_operands(quotient, expected) + && let Some(factor) = denominator.as_const().filter(|value| !value.is_zero()) + { + // For constant k > 0, (x * k mod 2^256) / k == x iff x <= MAX / k. + // The quotient cannot exceed MAX / k; conversely this bound prevents wrapping. + // Retain that exact bound instead of asking SMT to solve the overflow check. + // `SymExpr::binop` folds a zero factor away, so the non-zero filter is only a + // defensive guard against `MAX / 0` should that folding ever change. + return SymBoolExpr::cmp_word_const(cx, SymCmpOp::Ule, value, U256::MAX / factor); + } + } + if right.as_const().is_some_and(|value| value.is_zero()) + && let SymExprKind::BinOp(SymBinOp::Sub, minuend, subtrahend) = left.kind() + { + // Word subtraction is zero exactly when both operands are equal, including at the + // modular boundary. Solc commonly lowers optimized equality checks to this shape. + return SymBoolExpr::eq(cx, minuend.clone(), subtrahend.clone()); + } + if left.as_const().is_some_and(|value| value.is_zero()) + && let SymExprKind::BinOp(SymBinOp::Sub, minuend, subtrahend) = right.kind() + { + return SymBoolExpr::eq(cx, minuend.clone(), subtrahend.clone()); + } + } + + let (left, right) = + if matches!(op, SymCmpOp::Ult | SymCmpOp::Ule | SymCmpOp::Ugt | SymCmpOp::Uge) { + // Complement reverses unsigned order: ~x = MAX - x. Move it onto + // the constant so interval analysis can see Solidity's addition guard. + match (left.kind(), right.kind()) { + (SymExprKind::Not(value), SymExprKind::Const(limit)) => { + (SymExpr::constant(cx, !*limit), value.clone()) + } + (SymExprKind::Const(limit), SymExprKind::Not(value)) => { + (value.clone(), SymExpr::constant(cx, !*limit)) + } + _ => (left, right), + } + } else { + (left, right) + }; + + match op { + // `a > b => b < a`. + SymCmpOp::Ugt => SymBoolExpr::cmp(cx, SymCmpOp::Ult, right, left), + // `a >= b => b <= a`. + SymCmpOp::Uge => SymBoolExpr::cmp(cx, SymCmpOp::Ule, right, left), + // `a >s b => b SymBoolExpr::cmp(cx, SymCmpOp::Slt, right, left), + SymCmpOp::Eq | SymCmpOp::Ult | SymCmpOp::Ule | SymCmpOp::Slt => { + SymBoolExpr::cmp(cx, op, left, right) + } + } +} + +/// Simple facts learned from the normalized conjunction currently being queried. +#[derive(Default)] +pub(super) struct ConstraintContext { + upper_bounds: HashMap, + lower_bounds: HashMap, + unsigned_lower_bounds: HashMap, + exact_values: HashMap, + conflicting_exact_values: HashSet, + non_wrapping_products: HashSet<(SymExpr, SymExpr)>, +} + +#[derive(Clone, Copy)] +struct WordInterval { + min: U256, + max: U256, +} + +// These analyses are solver optimizations, so exceeding their local work budget must only make +// them decline a rewrite. Keeping the bound shared and private prevents deeply nested bytecode +// expressions from turning a proof shortcut into unbounded Rust recursion. +const MAX_LOCAL_ANALYSIS_NODES: usize = 256; +const MAX_CONTEXTUAL_PASSES: usize = 4; + +impl WordInterval { + fn new(min: U256, max: U256) -> Option { + (min <= max).then_some(Self { min, max }) + } + + const fn exact(value: U256) -> Self { + Self { min: value, max: value } + } + + fn with_bounds(self, lower: Option, upper: Option) -> Option { + Self::new( + self.min.max(lower.unwrap_or(U256::ZERO)), + self.max.min(upper.unwrap_or(U256::MAX)), + ) + } +} + +impl ConstraintContext { + pub(super) fn new(constraints: &[SymBoolExpr]) -> Self { + Self::from_constraints(constraints.iter(), constraints.len()) + } + + fn from_constraints<'a>( + constraints: impl Clone + Iterator, + constraint_count: usize, + ) -> Self { + Self::from_constraints_with_lower_bounds(constraints, constraint_count, true) + } + + /// Builds a rewrite context from the other retained conjuncts, never the predicate itself. + fn for_rewrite(cx: &mut SymCx, constraints: &[SymBoolExpr], index: usize) -> Self { + let supporting = constraints + .iter() + .enumerate() + .filter_map(|(i, constraint)| (i != index).then_some(constraint)); + let mut context = Self::from_constraints(supporting.clone(), constraints.len() - 1); + // One successful product guard may bound an operand used in another guard. + for _ in 0..MAX_CONTEXTUAL_PASSES { + let mut changed = false; + for constraint in supporting.clone() { + changed |= context.record_non_wrapping_product(cx, constraint); + } + if !changed { + break; + } + } + // Product bounds can then turn scaled zero checks into exact operand facts. + for constraint in supporting { + context.record_scaled_zero_fact(cx, constraint); + } + context + } + + fn from_constraints_with_lower_bounds<'a>( + constraints: impl Clone + Iterator, + constraint_count: usize, + promote_unsigned_bounds: bool, + ) -> Self { + let mut context = Self::default(); + for constraint in constraints.clone() { + context.record_exact_value_constraint(constraint); + context.record_upper_bound_constraint(constraint); + context.record_lower_bound_constraint(constraint); + context.record_unsigned_lower_bound_constraint(constraint, promote_unsigned_bounds); + } + // A bounded number of rounds closes ordinary order chains. Relational propagation keeps + // strict comparisons weak (`a < b` propagates only `a <= upper(b)`), so inconsistent + // cycles cannot tighten a bound one integer at a time across the uint256 domain. + for _ in 0..constraint_count { + let mut changed = false; + for constraint in constraints.clone() { + changed |= context.propagate_order_bounds(constraint); + } + if !changed { + break; + } + } + context + } + + /// Conservatively identifies every conjunct that path facts may rewrite. + fn requires_independent_context(expr: &SymBoolExpr) -> bool { + let root_candidate = match expr.kind() { + SymBoolExprKind::Cmp(op, left, right) => match op { + SymCmpOp::Eq => { + Self::mul_div_identity_operands(left, right).is_some() + || Self::mul_div_identity_operands(right, left).is_some() + || Self::masked_word_side_eq_self_shape(left, right).is_some() + || Self::masked_word_side_eq_self_shape(right, left).is_some() + } + SymCmpOp::Ult | SymCmpOp::Ule => { + Self::udiv_comparison_operands(*op, left, right).is_some() + } + SymCmpOp::Slt | SymCmpOp::Sgt => true, + SymCmpOp::Ugt | SymCmpOp::Uge => false, + }, + SymBoolExprKind::Not(value) => match value.kind() { + SymBoolExprKind::Cmp(op, left, right) + if Self::udiv_comparison_operands(*op, left, right).is_some() => + { + true + } + SymBoolExprKind::Cmp(SymCmpOp::Slt | SymCmpOp::Sgt, _, _) => true, + _ => value.zero_check_operand().is_some_and(|word| { + matches!(word.kind(), SymExprKind::BinOp(SymBinOp::Or, _, _)) + }), + }, + SymBoolExprKind::Const(_) | SymBoolExprKind::And(_) => false, + }; + root_candidate + || expr.contains_udiv() + || expr.visit_bool(|word| matches!(word.kind(), SymExprKind::Ite(_, _, _))) + } + + fn normalize_bool( + &self, + cx: &mut SymCx, + expr: SymBoolExpr, + context_free_changed: bool, + ) -> SymBoolExpr { + let may_normalize_word = !self.is_exact_value_constraint(&expr) + && expr.visit_bool(|word| self.may_normalize_word(word)); + let expr = if may_normalize_word { + let expr = expr.fold_exprs(cx, &mut |cx, expr| self.normalize_word(cx, expr)); + normalize_bool_for_solver(cx, expr) + } else if context_free_changed { + // The first pass can create new Boolean predicates, such as an overflow comparison + // while eliminating a division. Normalize those predicates before applying facts. + normalize_bool_for_solver(cx, expr) + } else { + expr + }; + if let Some(normalized) = self.normalize_signed_add_comparison(cx, &expr) { + return normalized; + } + if let Some(value) = self.rounding_comparison_value(&expr) { + return SymBoolExpr::constant(cx, value); + } + if let SymBoolExprKind::Not(value) = expr.kind() + && let Some(normalized) = self.normalize_signed_add_comparison(cx, value) + { + return normalized.not(cx); + } + if let SymBoolExprKind::Cmp(op, left, right) = expr.kind() + && let Some(normalized) = self.normalize_udiv_comparison(cx, *op, left, right) + { + return normalized; + } + if let SymBoolExprKind::Not(value) = expr.kind() + && let SymBoolExprKind::Cmp(op, left, right) = value.kind() + && let Some(normalized) = self.normalize_udiv_comparison(cx, *op, left, right) + { + return normalized.not(cx); + } + + match expr.kind() { + SymBoolExprKind::Not(value) if self.unsigned_bool_always_true(value) => { + SymBoolExpr::constant(cx, false) + } + SymBoolExprKind::Cmp(SymCmpOp::Eq, left, right) + if self.mul_div_identity(left, right) || self.mul_div_identity(right, left) => + { + SymBoolExpr::constant(cx, true) + } + SymBoolExprKind::Not(value) + if matches!( + value.kind(), + SymBoolExprKind::Cmp(SymCmpOp::Eq, left, right) + if self.mul_div_identity(left, right) + || self.mul_div_identity(right, left) + ) => + { + SymBoolExpr::constant(cx, false) + } + SymBoolExprKind::Cmp(SymCmpOp::Eq, left, right) + if self.masked_word_eq_self(left, right) => + { + // `x & mask == x => true` when the current context proves `x <= mask`. + SymBoolExpr::constant(cx, true) + } + SymBoolExprKind::Not(value) if self.masked_eq_self_condition(value) => { + // `x & mask != x => false` when the current context proves `x <= mask`. + SymBoolExpr::constant(cx, false) + } + _ if expr + .zero_check_operand() + .is_some_and(|left| self.word_bool_always_true(cx, left)) => + { + // `always_true_word == 0 => false`. + SymBoolExpr::constant(cx, false) + } + SymBoolExprKind::Not(value) + if value + .zero_check_operand() + .is_some_and(|left| self.word_bool_always_true(cx, left)) => + { + // `always_true_word != 0 => true`. + SymBoolExpr::constant(cx, true) + } + _ => expr, + } + } + + fn record_exact_value_constraint(&mut self, constraint: &SymBoolExpr) { + let SymBoolExprKind::Cmp(SymCmpOp::Eq, left, right) = constraint.kind() else { + return; + }; + let Some((expr, value)) = const_side_bound(left, right) else { + return; + }; + if !matches!(expr.kind(), SymExprKind::Var(_)) + || self.conflicting_exact_values.contains(expr) + { + return; + } + if self.exact_values.get(expr).is_some_and(|current| *current != value) { + self.exact_values.remove(expr); + self.conflicting_exact_values.insert(expr.clone()); + } else { + self.exact_values.insert(expr.clone(), value); + } + } + + fn may_normalize_word(&self, expr: &SymExpr) -> bool { + if self.exact_values.contains_key(expr) + || Self::mul_div_operands(expr).is_some() + || Self::ceil_div_product(expr).is_some() + { + return true; + } + match expr.kind() { + SymExprKind::Ite(_, _, _) => true, + SymExprKind::BinOp(SymBinOp::Or, left, right) => { + left.as_const() == Some(U256::ONE) || right.as_const() == Some(U256::ONE) + } + SymExprKind::BinOp(SymBinOp::Mul, _, _) => Self::constant_mul_operands(expr) + .is_some_and(|(value, _)| Self::constant_mul_operands(value).is_some()), + SymExprKind::BinOp(SymBinOp::UDiv, numerator, denominator) => { + Self::rounded_product_operands(numerator).is_some() + || (denominator.as_const().is_some_and(|value| !value.is_zero()) + && Self::constant_mul_operands(numerator).is_some()) + } + _ => false, + } + } + + fn normalize_word(&self, cx: &mut SymCx, expr: SymExpr) -> SymExpr { + if let Some(value) = self.exact_values.get(&expr).copied() { + return SymExpr::constant(cx, value); + } + if let SymExprKind::Ite(condition, then_value, else_value) = expr.kind() { + if let Some(value) = self.bounded_bool_value(condition) { + return if value { then_value.clone() } else { else_value.clone() }; + } + if let Some(condition) = self.normalize_signed_add_comparison(cx, condition) { + return SymExpr::ite(cx, condition, then_value.clone(), else_value.clone()); + } + } + if let Some(value) = self.quotient_of_rounded_product(&expr) { + return value.clone(); + } + if let SymExprKind::BinOp(SymBinOp::And, value, mask) = expr.kind() + && mask.as_const() == Some(U256::ONE) + && value.normalized_bool_word_condition(cx).is_some() + { + return value.clone(); + } + if let SymExprKind::BinOp(SymBinOp::Or, left, right) = expr.kind() + && ((left.as_const() == Some(U256::ONE) + && right.normalized_bool_word_condition(cx).is_some()) + || (right.as_const() == Some(U256::ONE) + && left.normalized_bool_word_condition(cx).is_some())) + { + return SymExpr::one(cx); + } + if let Some((value, outer_factor)) = Self::constant_mul_operands(&expr) + && let Some((value, inner_factor)) = Self::constant_mul_operands(value) + { + let factor = SymExpr::constant(cx, inner_factor.wrapping_mul(outer_factor)); + return SymExpr::binop(cx, SymBinOp::Mul, value.clone(), factor); + } + if let Some((value, factor)) = + self.exact_ceil_div_factor(&expr).or_else(|| self.exact_scaled_div_factor(&expr)) + { + let factor = SymExpr::constant(cx, factor); + return SymExpr::binop(cx, SymBinOp::Mul, value.clone(), factor); + } + if let Some((denominator, other)) = Self::mul_div_operands(&expr) + && self.interval(denominator).is_some_and(|interval| !interval.min.is_zero()) + && self.mul_cannot_overflow_256(denominator, other) + { + return other.clone(); + } + expr + } + + fn bounded_bool_value(&self, expr: &SymBoolExpr) -> Option { + match expr.kind() { + SymBoolExprKind::Const(value) => Some(*value), + SymBoolExprKind::Not(value) => self.bounded_bool_value(value).map(|value| !value), + SymBoolExprKind::Cmp(op, left, right) => { + let left = self.interval(left)?; + let right = self.interval(right)?; + if *op == SymCmpOp::Eq { + return if left.max < right.min || right.max < left.min { + Some(false) + } else if left.min == left.max && right.min == right.max { + Some(left.min == right.min) + } else { + None + }; + } + if matches!(op, SymCmpOp::Slt | SymCmpOp::Sgt) + && (left.min.bit(255) != left.max.bit(255) + || right.min.bit(255) != right.max.bit(255)) + { + return None; + } + let (always, possible) = + if matches!(op, SymCmpOp::Ult | SymCmpOp::Ule | SymCmpOp::Slt) { + (op.eval(left.max, right.min), op.eval(left.min, right.max)) + } else { + (op.eval(left.min, right.max), op.eval(left.max, right.min)) + }; + if always { + Some(true) + } else if !possible { + Some(false) + } else { + None + } + } + SymBoolExprKind::And(_) => None, + } + } + + /// Simplifies signed addition guards once the signs of the summands are established. + fn normalize_signed_add_comparison( + &self, + cx: &mut SymCx, + expr: &SymBoolExpr, + ) -> Option { + let SymBoolExprKind::Cmp(op, left, right) = expr.kind() else { return None }; + let (sum, base) = match op { + SymCmpOp::Slt => (left, right), + SymCmpOp::Sgt => (right, left), + _ => return None, + }; + let signed_max = U256::MAX >> 1; + if let Some((_, increment)) = sum.add_with_operand(base) { + let base_range = self.interval(base)?; + let increment_range = self.interval(increment)?; + // Opposite-sign addition cannot overflow: adding a nonnegative value cannot make + // a negative summand smaller, and adding a negative value makes a nonnegative one + // smaller. + if base_range.min > signed_max && increment_range.max <= signed_max { + return Some(SymBoolExpr::constant(cx, false)); + } + if base_range.max <= signed_max && increment_range.min > signed_max { + return Some(SymBoolExpr::constant(cx, true)); + } + } else if base.as_const() != Some(U256::ZERO) { + return None; + } + if base.as_const() == Some(U256::ZERO) + && let SymExprKind::BinOp(SymBinOp::Add, left, right) = sum.kind() + { + for (positive, negative) in [(left, right), (right, left)] { + if let SymExprKind::BinOp(SymBinOp::Sub, zero, amount) = negative.kind() + && zero.as_const() == Some(U256::ZERO) + && self.interval(positive).is_some_and(|range| range.max <= signed_max) + && self.interval(amount).is_some_and(|range| range.max <= signed_max) + { + // For a,b in [0, int256::MAX], signed(a + (-b)) < 0 iff a < b. + return Some(SymBoolExpr::cmp_word_expr( + cx, + SymCmpOp::Ult, + positive, + amount.clone(), + )); + } + } + } + // Use the sum's canonical operand order for both the overflow guard and a subsequent + // signed-to-unsigned cast. Their conditions must normalize to the same predicate. + let SymExprKind::BinOp(SymBinOp::Add, increment, base) = sum.kind() else { + return None; + }; + if self.interval(base)?.max > signed_max || self.interval(increment)?.max > signed_max { + return None; + } + // With nonnegative summands their unsigned sum cannot wrap. It is signed-less than a + // summand exactly when it crosses the signed maximum; equality (zero increment) is safe. + let limit = SymExpr::constant(cx, signed_max); + let remaining = SymExpr::binop(cx, SymBinOp::Sub, limit, base.clone()); + Some(SymBoolExpr::cmp(cx, SymCmpOp::Ult, remaining, increment.clone())) + } + + fn exact_ceil_div_factor<'a>(&self, expr: &'a SymExpr) -> Option<(&'a SymExpr, U256)> { + let (product, denominator) = Self::ceil_div_product(expr)?; + let (value, multiplier) = Self::constant_mul_operands(product)?; + self.max_scaled_product(value, multiplier)?.checked_add(denominator)?; + let factor = multiplier.checked_div(denominator)?; + (multiplier % denominator).is_zero().then_some((value, factor)) + } + + /// Recognizes `(product + scale - 1) / scale` without assuming the arithmetic cannot wrap. + fn ceil_div_product(expr: &SymExpr) -> Option<(&SymExpr, U256)> { + let (numerator, denominator) = expr.udiv_operands()?; + let scale = denominator.as_const().filter(|value| !value.is_zero())?; + if let SymExprKind::BinOp(SymBinOp::Sub, sum, one) = numerator.kind() + && one.as_const() == Some(U256::ONE) + && let SymExprKind::BinOp(SymBinOp::Add, product, rounding) = sum.kind() + && rounding.as_const() == Some(scale) + && matches!(product.kind(), SymExprKind::BinOp(SymBinOp::Mul, _, _)) + { + Some((product, scale)) + } else { + None + } + } + + fn exact_scaled_div_factor<'a>(&self, expr: &'a SymExpr) -> Option<(&'a SymExpr, U256)> { + let (numerator, denominator) = expr.udiv_operands()?; + let denominator = denominator.as_const().filter(|value| !value.is_zero())?; + let (value, multiplier) = Self::constant_mul_operands(numerator)?; + if !(multiplier % denominator).is_zero() + || self.max_scaled_product(value, multiplier).is_none() + { + return None; + } + Some((value, multiplier / denominator)) + } + + fn max_scaled_product(&self, value: &SymExpr, multiplier: U256) -> Option { + self.interval(value)?.max.checked_mul(multiplier) + } + + fn constant_mul_operands(expr: &SymExpr) -> Option<(&SymExpr, U256)> { + let SymExprKind::BinOp(SymBinOp::Mul, left, right) = expr.kind() else { + return None; + }; + const_side_bound(left, right) + } + + fn is_exact_value_constraint(&self, constraint: &SymBoolExpr) -> bool { + let SymBoolExprKind::Cmp(SymCmpOp::Eq, left, right) = constraint.kind() else { + return false; + }; + const_side_bound(left, right) + .is_some_and(|(expr, value)| self.exact_values.get(expr).copied() == Some(value)) + } + + fn masked_eq_self_condition(&self, expr: &SymBoolExpr) -> bool { + match expr.kind() { + SymBoolExprKind::Cmp(SymCmpOp::Eq, left, right) => { + self.masked_word_eq_self(left, right) + } + _ => false, + } + } + + fn masked_word_eq_self(&self, left: &SymExpr, right: &SymExpr) -> bool { + self.masked_word_side_eq_self(left, right) || self.masked_word_side_eq_self(right, left) + } + + fn masked_word_side_eq_self(&self, masked: &SymExpr, value: &SymExpr) -> bool { + Self::masked_word_side_eq_self_shape(masked, value) + .is_some_and(|bits| self.unsigned_bits(value) <= bits) + } + + fn masked_word_side_eq_self_shape(masked: &SymExpr, value: &SymExpr) -> Option { + let SymExprKind::BinOp(SymBinOp::And, left, right) = masked.kind() else { + return None; + }; + let (source, mask) = const_side_bound(left, right)?; + let bits = mask_low_bits(mask)?; + (source == value).then_some(bits) + } + + fn record_upper_bound_constraint(&mut self, constraint: &SymBoolExpr) { + if let Some((expr, bound)) = self.upper_bound_constraint(constraint) { + self.record_upper_bound(expr.clone(), bound); + } + } + + fn record_upper_bound(&mut self, expr: SymExpr, bound: U256) -> bool { + match self.upper_bounds.entry(expr) { + alloy_primitives::map::Entry::Occupied(mut entry) if bound < *entry.get() => { + entry.insert(bound); + true + } + alloy_primitives::map::Entry::Vacant(entry) => { + entry.insert(bound); + true + } + alloy_primitives::map::Entry::Occupied(_) => false, + } + } + + fn record_lower_bound_constraint(&mut self, constraint: &SymBoolExpr) { + if let Some((expr, bound)) = self.lower_bound_constraint(constraint) { + self.record_lower_bound(expr.clone(), bound); + } + } + + fn record_unsigned_lower_bound_constraint( + &mut self, + constraint: &SymBoolExpr, + promote_to_interval: bool, + ) { + if let Some((expr, bound)) = self.unsigned_lower_bound_constraint(constraint) { + let entry = self.unsigned_lower_bounds.entry(expr.clone()).or_default(); + *entry = (*entry).max(bound); + // Batch normalization keeps theorem bounds separate from general intervals. + // A rewrite supported only by other retained conjuncts may also use these bounds + // for interval deductions. + if promote_to_interval { + self.record_lower_bound(expr.clone(), bound); + } + } + } + + fn unsigned_lower_bound_constraint<'a>( + &self, + constraint: &'a SymBoolExpr, + ) -> Option<(&'a SymExpr, U256)> { + match constraint.kind() { + SymBoolExprKind::Cmp(op, left, right) => match *op { + SymCmpOp::Eq => const_side_bound(left, right), + SymCmpOp::Ult => { + left.as_const()?.checked_add(U256::ONE).map(|bound| (right, bound)) + } + SymCmpOp::Ule => left.as_const().map(|bound| (right, bound)), + SymCmpOp::Ugt => { + right.as_const()?.checked_add(U256::ONE).map(|bound| (left, bound)) + } + SymCmpOp::Uge => right.as_const().map(|bound| (left, bound)), + SymCmpOp::Slt | SymCmpOp::Sgt => None, + }, + SymBoolExprKind::Not(value) => match value.kind() { + SymBoolExprKind::Cmp(SymCmpOp::Ult, left, right) => { + right.as_const().map(|bound| (left, bound)) + } + SymBoolExprKind::Cmp(SymCmpOp::Ule, left, right) => { + right.as_const()?.checked_add(U256::ONE).map(|bound| (left, bound)) + } + SymBoolExprKind::Cmp(SymCmpOp::Ugt, left, right) => { + left.as_const().map(|bound| (right, bound)) + } + SymBoolExprKind::Cmp(SymCmpOp::Uge, left, right) => { + left.as_const()?.checked_add(U256::ONE).map(|bound| (right, bound)) + } + _ => None, + }, + _ => None, + } + } + + fn record_lower_bound(&mut self, expr: SymExpr, bound: U256) -> bool { + match self.lower_bounds.entry(expr) { + alloy_primitives::map::Entry::Occupied(mut entry) if bound > *entry.get() => { + entry.insert(bound); + true + } + alloy_primitives::map::Entry::Vacant(entry) => { + entry.insert(bound); + true + } + alloy_primitives::map::Entry::Occupied(_) => false, + } + } + + fn propagate_order_bounds(&mut self, constraint: &SymBoolExpr) -> bool { + match constraint.kind() { + SymBoolExprKind::Cmp(op, left, right) => match op { + SymCmpOp::Ult | SymCmpOp::Ule => self.propagate_less_or_equal_bounds(left, right), + SymCmpOp::Ugt | SymCmpOp::Uge => self.propagate_less_or_equal_bounds(right, left), + SymCmpOp::Eq => { + let changed = self.propagate_less_or_equal_bounds(left, right); + self.propagate_less_or_equal_bounds(right, left) || changed + } + SymCmpOp::Slt | SymCmpOp::Sgt => false, + }, + SymBoolExprKind::Not(value) => match value.kind() { + SymBoolExprKind::Cmp(op, left, right) => match op { + SymCmpOp::Ult | SymCmpOp::Ule => { + self.propagate_less_or_equal_bounds(right, left) + } + SymCmpOp::Ugt | SymCmpOp::Uge => { + self.propagate_less_or_equal_bounds(left, right) + } + SymCmpOp::Eq | SymCmpOp::Slt | SymCmpOp::Sgt => false, + }, + _ => false, + }, + SymBoolExprKind::Const(_) | SymBoolExprKind::And(_) => false, + } + } + + /// Propagates interval bounds through the known unsigned relation `left <= right`. + fn propagate_less_or_equal_bounds(&mut self, left: &SymExpr, right: &SymExpr) -> bool { + let upper = self.upper_bounds.get(right).copied(); + let lower = self.lower_bounds.get(left).copied(); + let upper_changed = upper.is_some_and(|bound| self.record_upper_bound(left.clone(), bound)); + let lower_changed = + lower.is_some_and(|bound| self.record_lower_bound(right.clone(), bound)); + upper_changed || lower_changed + } + + fn upper_bound_constraint<'a>( + &self, + constraint: &'a SymBoolExpr, + ) -> Option<(&'a SymExpr, U256)> { + match constraint.kind() { + SymBoolExprKind::Cmp(op, left, right) => match *op { + SymCmpOp::Eq => const_side_bound(left, right), + SymCmpOp::Ult => match (left.as_const(), right.as_const()) { + (_, Some(bound)) => (!bound.is_zero()).then(|| (left, bound - U256::ONE)), + _ => None, + }, + SymCmpOp::Ule => match (left.as_const(), right.as_const()) { + (_, Some(bound)) => Some((left, bound)), + _ => None, + }, + SymCmpOp::Ugt => match (left.as_const(), right.as_const()) { + (Some(bound), _) => (!bound.is_zero()).then(|| (right, bound - U256::ONE)), + _ => None, + }, + SymCmpOp::Uge => match (left.as_const(), right.as_const()) { + (Some(bound), _) => Some((right, bound)), + _ => None, + }, + SymCmpOp::Slt | SymCmpOp::Sgt => None, + }, + SymBoolExprKind::Not(value) => match value.kind() { + SymBoolExprKind::Cmp(op, left, right) => match *op { + SymCmpOp::Ugt => match (left.as_const(), right.as_const()) { + (_, Some(bound)) => Some((left, bound)), + _ => None, + }, + SymCmpOp::Uge => match (left.as_const(), right.as_const()) { + (_, Some(bound)) => (!bound.is_zero()).then(|| (left, bound - U256::ONE)), + _ => None, + }, + SymCmpOp::Ult => match (left.as_const(), right.as_const()) { + (Some(bound), _) => Some((right, bound)), + _ => None, + }, + SymCmpOp::Ule => match (left.as_const(), right.as_const()) { + (Some(bound), _) => (!bound.is_zero()).then(|| (right, bound - U256::ONE)), + _ => None, + }, + SymCmpOp::Eq | SymCmpOp::Slt | SymCmpOp::Sgt => None, + }, + _ => None, + }, + SymBoolExprKind::Const(_) | SymBoolExprKind::And(_) => None, + } + } + + fn lower_bound_constraint<'a>( + &self, + constraint: &'a SymBoolExpr, + ) -> Option<(&'a SymExpr, U256)> { + match constraint.kind() { + SymBoolExprKind::Cmp(SymCmpOp::Eq, left, right) => const_side_bound(left, right), + SymBoolExprKind::Not(value) => match value.kind() { + SymBoolExprKind::Cmp(SymCmpOp::Eq, left, right) => { + if right.as_const().is_some_and(|value| value.is_zero()) { + Some((left, U256::ONE)) + } else if left.as_const().is_some_and(|value| value.is_zero()) { + Some((right, U256::ONE)) + } else { + None + } + } + _ => None, + }, + _ => None, + } + } + + fn unsigned_bool_always_true(&self, expr: &SymBoolExpr) -> bool { + match expr.kind() { + SymBoolExprKind::Cmp(op, left, right) => { + self.unsigned_cmp_always_true(*op, left, right) + } + _ => false, + } + } + + fn unsigned_cmp_always_true(&self, op: SymCmpOp, left: &SymExpr, right: &SymExpr) -> bool { + if op == SymCmpOp::Eq + && (self.mul_div_identity(left, right) || self.mul_div_identity(right, left)) + { + return true; + } + let Some(left) = self.interval(left) else { + return false; + }; + let Some(right) = self.interval(right) else { + return false; + }; + match op { + SymCmpOp::Ult => left.max < right.min, + SymCmpOp::Ule => left.max <= right.min, + SymCmpOp::Ugt => left.min > right.max, + SymCmpOp::Uge => left.min >= right.max, + SymCmpOp::Eq | SymCmpOp::Slt | SymCmpOp::Sgt => false, + } + } + + fn mul_div_identity(&self, quotient: &SymExpr, expected: &SymExpr) -> bool { + let Some((denominator, other)) = Self::mul_div_identity_operands(quotient, expected) else { + return false; + }; + + self.interval(denominator).is_some_and(|interval| !interval.min.is_zero()) + && self.mul_cannot_overflow_256(denominator, other) + } + + fn mul_div_identity_operands<'a>( + quotient: &'a SymExpr, + expected: &SymExpr, + ) -> Option<(&'a SymExpr, &'a SymExpr)> { + let (denominator, other) = Self::mul_div_operands(quotient)?; + (other == expected).then_some((denominator, other)) + } + + fn mul_div_operands(quotient: &SymExpr) -> Option<(&SymExpr, &SymExpr)> { + let (numerator, denominator) = quotient.udiv_operands()?; + let SymExprKind::BinOp(SymBinOp::Mul, left, right) = numerator.kind() else { + return None; + }; + let other = if left == denominator { + right + } else if right == denominator { + left + } else { + return None; + }; + Some((denominator, other)) + } + + fn udiv_comparison_operands<'a>( + op: SymCmpOp, + left: &'a SymExpr, + right: &'a SymExpr, + ) -> Option<(&'a SymExpr, &'a SymExpr, &'a SymExpr, bool)> { + if !matches!(op, SymCmpOp::Ult | SymCmpOp::Ule) { + return None; + } + if let Some((numerator, denominator)) = left.udiv_operands() + && denominator.as_const().is_some_and(|value| !value.is_zero()) + && !right.contains_udiv() + { + return Some((numerator, denominator, right, true)); + } + if let Some((numerator, denominator)) = right.udiv_operands() + && denominator.as_const().is_some_and(|value| !value.is_zero()) + && !left.contains_udiv() + { + return Some((numerator, denominator, left, false)); + } + None + } + + fn normalize_udiv_comparison( + &self, + cx: &mut SymCx, + op: SymCmpOp, + left: &SymExpr, + right: &SymExpr, + ) -> Option { + let (numerator, denominator, threshold, quotient_on_left) = + Self::udiv_comparison_operands(op, left, right)?; + let increment_threshold = + matches!((op, quotient_on_left), (SymCmpOp::Ule, true) | (SymCmpOp::Ult, false)); + let threshold = if increment_threshold { + // Prove the successor cannot wrap before constructing the word addition. + self.interval(threshold)?.max.checked_add(U256::ONE)?; + let one = SymExpr::one(cx); + SymExpr::binop(cx, SymBinOp::Add, threshold.clone(), one) + } else { + threshold.clone() + }; + if !self.mul_cannot_overflow_256(&threshold, denominator) { + return None; + } + + let scaled_threshold = SymExpr::binop(cx, SymBinOp::Mul, threshold, denominator.clone()); + Some(if quotient_on_left { + // `n / d < k => n < k * d`; `n / d <= k => n < (k + 1) * d`. + SymBoolExpr::cmp_word_expr(cx, SymCmpOp::Ult, numerator, scaled_threshold) + } else { + // `k <= n / d => k * d <= n`; `k < n / d => (k + 1) * d <= n`. + SymBoolExpr::cmp(cx, SymCmpOp::Ule, scaled_threshold, numerator.clone()) + }) + } + + fn interval(&self, expr: &SymExpr) -> Option { + let mut intervals = HashMap::default(); + let mut remaining = MAX_LOCAL_ANALYSIS_NODES; + self.interval_cached(expr, &mut intervals, &mut remaining) + } + + fn interval_cached( + &self, + expr: &SymExpr, + intervals: &mut HashMap>, + remaining: &mut usize, + ) -> Option { + if let Some(interval) = intervals.get(expr) { + return *interval; + } + + let lower = self.lower_bounds.get(expr).copied(); + let upper = self.upper_bounds.get(expr).copied(); + let explicit_bounds = || { + if lower.is_none() && upper.is_none() { + return None; + } + WordInterval::new(lower.unwrap_or(U256::ZERO), upper.unwrap_or(U256::MAX)) + }; + if *remaining == 0 { + let interval = explicit_bounds(); + intervals.insert(expr.clone(), interval); + return interval; + } + *remaining -= 1; + + let interval = + self.structural_interval(expr, intervals, remaining).or_else(explicit_bounds); + let interval = interval.and_then(|interval| interval.with_bounds(lower, upper)); + intervals.insert(expr.clone(), interval); + interval + } + + fn structural_interval( + &self, + expr: &SymExpr, + intervals: &mut HashMap>, + remaining: &mut usize, + ) -> Option { + match expr.kind() { + SymExprKind::Const(value) => Some(WordInterval::exact(*value)), + SymExprKind::BinOp(SymBinOp::And, left, right) => { + let mask = left.as_const().or_else(|| right.as_const())?; + Some(WordInterval { min: U256::ZERO, max: mask }) + } + SymExprKind::BinOp(SymBinOp::Add, left, right) => { + let left = self.interval_cached(left, intervals, remaining)?; + let right = self.interval_cached(right, intervals, remaining)?; + Some(WordInterval { + min: left.min.checked_add(right.min)?, + max: left.max.checked_add(right.max)?, + }) + } + SymExprKind::BinOp(SymBinOp::Sub, left, right) => { + if let Some(interval) = + self.rounding_error_interval(left, right, intervals, remaining) + { + return Some(interval); + } + let left = self.interval_cached(left, intervals, remaining)?; + let right = self.interval_cached(right, intervals, remaining)?; + if left.max < right.min { + // Every subtraction wraps exactly once, so the unsigned image is contiguous. + return Some(WordInterval { + min: left.min.wrapping_sub(right.max), + max: left.max.wrapping_sub(right.min), + }); + } + if left.min < right.max { + return None; + } + Some(WordInterval { + min: left.min.checked_sub(right.max)?, + max: left.max.checked_sub(right.min)?, + }) + } + SymExprKind::BinOp(SymBinOp::Mul, left, right) => { + let guarded = self.has_non_wrapping_product(left, right); + let left = self.interval_cached(left, intervals, remaining)?; + let right = self.interval_cached(right, intervals, remaining)?; + Some(WordInterval { + min: left.min.checked_mul(right.min)?, + // A retained overflow guard correlates the factors. Their independent + // maxima can overflow even though every feasible product fits. + max: left + .max + .checked_mul(right.max) + .or_else(|| guarded.then_some(U256::MAX))?, + }) + } + SymExprKind::BinOp(SymBinOp::UDiv, numerator, denominator) => { + // Even an otherwise unbounded numerator is a uint256 word. Division by a + // positive denominator bounds the quotient regardless of numerator wrapping. + let numerator = self + .interval_cached(numerator, intervals, remaining) + .unwrap_or(WordInterval { min: U256::ZERO, max: U256::MAX }); + let denominator = self.interval_cached(denominator, intervals, remaining)?; + if denominator.min.is_zero() { + return None; + } + Some(WordInterval { + min: numerator.min / denominator.max, + max: numerator.max / denominator.min, + }) + } + SymExprKind::BinOp(SymBinOp::Shr, value, shift) => { + let shift = shift.as_const()?; + if shift >= U256::from(256) { + return Some(WordInterval::exact(U256::ZERO)); + } + let value = self.interval_cached(value, intervals, remaining)?; + let shift = shift.to::(); + Some(WordInterval { min: value.min >> shift, max: value.max >> shift }) + } + SymExprKind::Ite(_, left, right) => { + let left = self.interval_cached(left, intervals, remaining)?; + let right = self.interval_cached(right, intervals, remaining)?; + Some(WordInterval { min: left.min.min(right.min), max: left.max.max(right.max) }) + } + _ => None, + } + } +} + +fn const_side_bound<'a>(left: &'a SymExpr, right: &'a SymExpr) -> Option<(&'a SymExpr, U256)> { + right + .as_const() + .map(|value| (left, value)) + .or_else(|| left.as_const().map(|value| (right, value))) +} + +/// Normalizes one word expression into an equivalent, solver-friendlier form. +pub(crate) fn normalize_expr_for_solver(cx: &mut SymCx, expr: SymExpr) -> SymExpr { + if expr.contains_ite() { expr.fold(cx, &mut normalize_expr_node_for_solver) } else { expr } +} + +fn normalize_expr_node_for_solver(cx: &mut SymCx, expr: SymExpr) -> SymExpr { + match expr.kind() { + SymExprKind::Ite(cond, left, right) => { + normalize_ite_expr_for_solver(cx, cond.clone(), left.clone(), right.clone()) + } + _ => expr, + } +} + +fn normalize_ite_expr_for_solver( + cx: &mut SymCx, + cond: SymBoolExpr, + left: SymExpr, + right: SymExpr, +) -> SymExpr { + let cond = normalize_bool_for_solver(cx, cond); + if left == right { + // `ite(c, a, a) => a`. + return left; + } + if left.as_const() == Some(U256::ONE) + && right.normalized_bool_word_condition(cx).as_ref() == Some(&cond) + { + // `ite(c, 1, bool_word(c)) => bool_word(c)`. + return right; + } + if right.as_const().is_some_and(|value| value.is_zero()) + && left.normalized_bool_word_condition(cx).as_ref() == Some(&cond) + { + // `ite(c, bool_word(c), 0) => bool_word(c)`. + return left; + } + SymExpr::ite(cx, cond, left, right) +} + +impl SymExpr { + fn word_bool_always_true(&self, cx: &mut SymCx) -> bool { + ConstraintContext::default().word_bool_always_true(cx, self) + } +} + +impl SymBoolExpr { + fn normalize_udiv_for_solver(&self, cx: &mut SymCx) -> Option { + if let SymBoolExprKind::Cmp(op, left, right) = self.kind() + && let Some(normalized) = Self::normalize_const_over_self_udiv_cmp(cx, *op, left, right) + { + return Some(normalized); + } + if let SymBoolExprKind::Not(value) = self.kind() + && let SymBoolExprKind::Cmp(op, left, right) = value.kind() + && let Some(normalized) = Self::normalize_const_over_self_udiv_cmp(cx, *op, left, right) + { + return Some(normalized.not(cx)); + } + + match self.kind() { + SymBoolExprKind::Cmp(SymCmpOp::Eq, left, right) + if right.as_const().is_some_and(|value| value.is_zero()) => + { + left.normalized_bool_word_condition(cx).map(|value| value.not(cx)).or_else(|| { + if left.word_bool_always_true(cx) { + // `always_true_word == 0 => false`. + Some(Self::constant(cx, false)) + } else { + let zero = SymExpr::zero(cx); + Self::normalize_udiv_eq_zero(cx, left, &zero) + } + }) + } + SymBoolExprKind::Cmp(SymCmpOp::Eq, left, right) + if right.as_const() == Some(U256::ONE) => + { + // `bool_word(c) == 1 => c`. + left.normalized_bool_word_condition(cx) + } + SymBoolExprKind::Not(value) => match value.kind() { + SymBoolExprKind::Cmp(SymCmpOp::Eq, left, right) + if right.as_const().is_some_and(|value| value.is_zero()) => + { + if left.word_bool_always_true(cx) { + // `always_true_word != 0 => true`. + Some(Self::constant(cx, true)) + } else { + let zero = SymExpr::zero(cx); + Self::normalize_udiv_eq_zero(cx, left, &zero).map(|value| value.not(cx)) + } + } + SymBoolExprKind::Cmp(SymCmpOp::Eq, left, right) => { + Self::normalize_udiv_eq_zero(cx, left, right).map(|value| value.not(cx)) + } + SymBoolExprKind::Cmp(op, left, right) => { + Self::normalize_add_overflow_cmp(cx, *op, left, right) + .map(|value| value.not(cx)) + .or_else(|| { + Self::normalize_udiv_cmp(cx, *op, left, right) + .map(|value| value.not(cx)) + }) + } + _ => None, + }, + SymBoolExprKind::Cmp(SymCmpOp::Eq, left, right) => { + Self::normalize_udiv_eq_zero(cx, left, right) + } + SymBoolExprKind::Cmp(op, left, right) => { + Self::normalize_add_overflow_cmp(cx, *op, left, right) + .or_else(|| Self::normalize_udiv_cmp(cx, *op, left, right)) + } + SymBoolExprKind::Const(_) | SymBoolExprKind::And(_) => None, + } + } + + fn normalize_add_overflow_cmp( + cx: &mut SymCx, + op: SymCmpOp, + left: &SymExpr, + right: &SymExpr, + ) -> Option { + if let Some(normalized) = Self::normalize_sub_underflow_cmp(cx, op, left, right) { + return Some(normalized); + } + // Strict forms test overflow and non-strict forms its complement; addition wraps iff the + // increment exceeds `~base`. + let (base, increment, overflow) = match op { + SymCmpOp::Ugt => { + right.add_with_operand(left).map(|(_, increment)| (left, increment, true)) + } + SymCmpOp::Ult => { + left.add_with_operand(right).map(|(_, increment)| (right, increment, true)) + } + SymCmpOp::Uge => { + left.add_with_operand(right).map(|(_, increment)| (right, increment, false)) + } + SymCmpOp::Ule => { + right.add_with_operand(left).map(|(_, increment)| (left, increment, false)) + } + SymCmpOp::Eq | SymCmpOp::Slt | SymCmpOp::Sgt => None, + }?; + if base.unsigned_bits().max(increment.unsigned_bits()).saturating_add(1) <= 256 { + return Some(Self::constant(cx, !overflow)); + } + + let limit = match base.kind() { + SymExprKind::BinOp(SymBinOp::Sub, max, value) if max.as_const() == Some(U256::MAX) => { + value.clone() + } + _ => SymExpr::not(cx, base.clone()), + }; + Some(if overflow { + Self::cmp(cx, SymCmpOp::Ult, limit, increment.clone()) + } else { + Self::cmp_word_expr(cx, SymCmpOp::Ule, increment, limit) + }) + } + + fn normalize_sub_underflow_cmp( + cx: &mut SymCx, + op: SymCmpOp, + left: &SymExpr, + right: &SymExpr, + ) -> Option { + let (base, difference, underflow) = match op { + SymCmpOp::Ult => (left, right, true), + SymCmpOp::Ugt => (right, left, true), + SymCmpOp::Ule => (right, left, false), + SymCmpOp::Uge => (left, right, false), + _ => return None, + }; + let SymExprKind::BinOp(SymBinOp::Sub, minuend, subtrahend) = difference.kind() else { + return None; + }; + if minuend != base { + return None; + } + // Unsigned modular subtraction wraps exactly when the subtrahend exceeds the minuend. + Some(if underflow { + Self::cmp_word_expr(cx, SymCmpOp::Ult, base, subtrahend.clone()) + } else { + Self::cmp_word_expr(cx, SymCmpOp::Ule, subtrahend, base.clone()) + }) + } + + fn normalize_udiv_eq_zero(cx: &mut SymCx, left: &SymExpr, right: &SymExpr) -> Option { + if right.as_const().is_some_and(|value| value.is_zero()) + && let Some(condition) = left.normalize_eq_zero_for_solver(cx) + { + // `word_bool(c) == 0 => !c`. + return Some(condition); + } + None + } + + fn normalize_udiv_cmp( + cx: &mut SymCx, + op: SymCmpOp, + left: &SymExpr, + right: &SymExpr, + ) -> Option { + match op { + SymCmpOp::Ugt => match (left.as_const(), right.as_const()) { + // `a > 0 => a != 0`. + (_, Some(value)) if value.is_zero() => left + .normalize_ne_zero_for_solver(cx) + .or_else(|| Some(Self::eq_zero(cx, left).not(cx))), + // `1 > a => a == 0`. + (Some(value), _) if value == U256::ONE => right + .normalize_eq_zero_for_solver(cx) + .or_else(|| Some(Self::eq_zero(cx, right))), + _ => None, + }, + SymCmpOp::Uge => match (left.as_const(), right.as_const()) { + // `a >= 1 => a != 0`. + (_, Some(value)) if value == U256::ONE => left + .normalize_ne_zero_for_solver(cx) + .or_else(|| Some(Self::eq_zero(cx, left).not(cx))), + // `0 >= a => a == 0`. + (Some(value), _) if value.is_zero() => right + .normalize_eq_zero_for_solver(cx) + .or_else(|| Some(Self::eq_zero(cx, right))), + _ => None, + }, + SymCmpOp::Ule => match (left.as_const(), right.as_const()) { + // `a <= 0 => a == 0`. + (_, Some(value)) if value.is_zero() => { + left.normalize_eq_zero_for_solver(cx).or_else(|| Some(Self::eq_zero(cx, left))) + } + // `1 <= a => a != 0`. + (Some(value), _) if value == U256::ONE => right + .normalize_ne_zero_for_solver(cx) + .or_else(|| Some(Self::eq_zero(cx, right).not(cx))), + _ => None, + }, + SymCmpOp::Ult => match (left.as_const(), right.as_const()) { + // `a < 1 => a == 0`. + (_, Some(value)) if value == U256::ONE => { + left.normalize_eq_zero_for_solver(cx).or_else(|| Some(Self::eq_zero(cx, left))) + } + // `0 < a => a != 0`. + (Some(value), _) if value.is_zero() => right + .normalize_ne_zero_for_solver(cx) + .or_else(|| Some(Self::eq_zero(cx, right).not(cx))), + _ => None, + }, + SymCmpOp::Eq | SymCmpOp::Slt | SymCmpOp::Sgt => None, + } + } + + fn normalize_const_over_self_udiv_cmp( + cx: &mut SymCx, + op: SymCmpOp, + left: &SymExpr, + right: &SymExpr, + ) -> Option { + let (value, quotient, complement) = match op { + // `a <= c / a`. + SymCmpOp::Ule => (left, right, false), + // `c / a < a`, the complement of `a <= c / a`. + SymCmpOp::Ult => (right, left, true), + SymCmpOp::Eq | SymCmpOp::Ugt | SymCmpOp::Uge | SymCmpOp::Slt | SymCmpOp::Sgt => { + return None; + } + }; + let (numerator, denominator) = match quotient.kind() { + SymExprKind::BinOp(SymBinOp::UDiv, numerator, denominator) => (numerator, denominator), + SymExprKind::Ite(condition, zero, division) + if zero.as_const().is_some_and(|value| value.is_zero()) => + { + let (numerator, denominator) = division.udiv_operands()?; + if condition.zero_check_operand() != Some(denominator) { + return None; + } + (numerator, denominator) + } + _ => return None, + }; + if denominator != value { + return None; + } + + let threshold = numerator.as_const()?.root(2); + let threshold = SymExpr::constant(cx, threshold); + Some(if complement { + Self::cmp(cx, SymCmpOp::Ult, threshold, value.clone()) + } else { + Self::cmp_word_expr(cx, SymCmpOp::Ule, value, threshold) + }) + } + + fn eq_zero(cx: &mut SymCx, expr: &SymExpr) -> Self { + let zero = SymExpr::zero(cx); + Self::eq(cx, expr.clone(), zero) + } +} + +impl SymExpr { + fn normalized_bool_word_condition(&self, cx: &mut SymCx) -> Option { + self.strip_low_byte_mask() + .bool_word_condition() + .map(|condition| normalize_bool_for_solver(cx, condition)) + } + + fn add_with_operand<'a>(&'a self, operand: &Self) -> Option<(&'a Self, &'a Self)> { + let SymExprKind::BinOp(SymBinOp::Add, left, right) = self.kind() else { + return None; + }; + if left == operand { + Some((left, right)) + } else if right == operand { + Some((right, left)) + } else { + None + } + } + + fn normalize_eq_zero_for_solver(&self, cx: &mut SymCx) -> Option { + if let Some((numerator, denominator)) = self.udiv_operands() { + // `a / b == 0 => b == 0 || a < b`. + return Some(Self::udiv_zero_condition(cx, numerator, denominator)); + } + if let SymExprKind::Ite(condition, then_expr, else_expr) = self.kind() { + let then_zero = match then_expr.normalize_eq_zero_for_solver(cx) { + Some(condition) => condition, + None => { + let then_expr = normalize_expr_for_solver(cx, then_expr.clone()); + let zero = Self::zero(cx); + SymBoolExpr::eq(cx, then_expr, zero) + } + }; + let else_zero = match else_expr.normalize_eq_zero_for_solver(cx) { + Some(condition) => condition, + None => { + let else_expr = normalize_expr_for_solver(cx, else_expr.clone()); + let zero = Self::zero(cx); + SymBoolExpr::eq(cx, else_expr, zero) + } + }; + if then_zero.contains_udiv() || else_zero.contains_udiv() { + return None; + } + // `ite(c, a, b) == 0 => (c && a == 0) || (!c && b == 0)`. + let condition = normalize_bool_for_solver(cx, condition.clone()); + let then_condition = SymBoolExpr::and(cx, vec![condition.clone(), then_zero]); + let not_condition = condition.not(cx); + let else_condition = SymBoolExpr::and(cx, vec![not_condition, else_zero]); + return Some(SymBoolExpr::or(cx, vec![then_condition, else_condition])); + } + None + } + + fn normalize_ne_zero_for_solver(&self, cx: &mut SymCx) -> Option { + if let Some((numerator, denominator)) = self.udiv_operands() { + // `a / b != 0 => b != 0 && a >= b`. + return Some(Self::udiv_nonzero_condition(cx, numerator, denominator)); + } + if let SymExprKind::Ite(condition, then_expr, else_expr) = self.kind() { + let then_nonzero = match then_expr.normalize_ne_zero_for_solver(cx) { + Some(condition) => condition, + None => { + let then_expr = normalize_expr_for_solver(cx, then_expr.clone()); + let zero = Self::zero(cx); + SymBoolExpr::eq(cx, then_expr, zero).not(cx) + } + }; + let else_nonzero = match else_expr.normalize_ne_zero_for_solver(cx) { + Some(condition) => condition, + None => { + let else_expr = normalize_expr_for_solver(cx, else_expr.clone()); + let zero = Self::zero(cx); + SymBoolExpr::eq(cx, else_expr, zero).not(cx) + } + }; + if then_nonzero.contains_udiv() || else_nonzero.contains_udiv() { + return None; + } + // `ite(c, a, b) != 0 => (c && a != 0) || (!c && b != 0)`. + let condition = normalize_bool_for_solver(cx, condition.clone()); + let then_condition = SymBoolExpr::and(cx, vec![condition.clone(), then_nonzero]); + let not_condition = condition.not(cx); + let else_condition = SymBoolExpr::and(cx, vec![not_condition, else_nonzero]); + return Some(SymBoolExpr::or(cx, vec![then_condition, else_condition])); + } + None + } + + fn udiv_zero_condition(cx: &mut SymCx, numerator: &Self, denominator: &Self) -> SymBoolExpr { + let numerator = normalize_expr_for_solver(cx, numerator.clone()); + let denominator = normalize_expr_for_solver(cx, denominator.clone()); + let zero = Self::zero(cx); + let denominator_zero = SymBoolExpr::eq(cx, denominator.clone(), zero); + let below_denominator = SymBoolExpr::cmp(cx, SymCmpOp::Ult, numerator, denominator); + SymBoolExpr::or(cx, vec![denominator_zero, below_denominator]) + } + + fn udiv_nonzero_condition(cx: &mut SymCx, numerator: &Self, denominator: &Self) -> SymBoolExpr { + let numerator = normalize_expr_for_solver(cx, numerator.clone()); + let denominator = normalize_expr_for_solver(cx, denominator.clone()); + let zero = Self::zero(cx); + let denominator_nonzero = SymBoolExpr::eq(cx, denominator.clone(), zero).not(cx); + let at_least_denominator = SymBoolExpr::cmp(cx, SymCmpOp::Uge, numerator, denominator); + SymBoolExpr::and(cx, vec![denominator_nonzero, at_least_denominator]) + } +} + +impl ConstraintContext { + fn word_bool_always_true(&self, cx: &mut SymCx, expr: &SymExpr) -> bool { + let mut terms = Vec::new(); + expr.push_or_terms(&mut terms); + if terms.len() <= 1 { + return false; + } + + let bool_terms = terms + .iter() + .filter_map(|term| term.normalized_bool_word_condition(cx)) + .collect::>(); + if bool_terms.iter().any(|term| { + let negated = term.clone().not(cx); + bool_terms.contains(&negated) + }) { + // `c || !c => true`. + return true; + } + for zero_term in &bool_terms { + if bool_terms + .iter() + .any(|term| self.checked_mul_guard_for_zero_condition(term, zero_term)) + { + // `a == 0 || guarded_mul_div(a) => true`. + return true; + } + } + false + } + + /// Records operand facts from independently justified, non-wrapping scaled zero checks. + fn record_scaled_zero_fact(&mut self, cx: &mut SymCx, constraint: &SymBoolExpr) { + let (condition, nonzero) = match constraint.kind() { + SymBoolExprKind::Not(inner) => (inner, true), + _ => (constraint, false), + }; + if matches!(condition.kind(), SymBoolExprKind::Cmp(SymCmpOp::Ult, _, _)) + && let Some(value) = self.bounded_zero_check_operand(condition).cloned() + { + if nonzero { + self.record_lower_bound(value, U256::ONE); + } else { + self.record_upper_bound(value.clone(), U256::ZERO); + let zero = SymExpr::zero(cx); + let exact = SymBoolExpr::eq(cx, value, zero); + self.record_exact_value_constraint(&exact); + } + } + } + + /// Recognizes zero checks exposed by normalizing a scaled balance division. + fn bounded_zero_check_operand<'a>(&self, expr: &'a SymBoolExpr) -> Option<&'a SymExpr> { + if let Some(value) = expr.zero_check_operand() { + return Some(value); + } + let SymBoolExprKind::Cmp(SymCmpOp::Ult, product, limit) = expr.kind() else { + return None; + }; + let (value, scale) = Self::constant_mul_operands(product)?; + // x * scale < scale iff x == 0, but only for a positive scale and no wrap. + if scale.is_zero() || limit.as_const() != Some(scale) { + return None; + } + self.max_scaled_product(value, scale)?; + Some(value) + } + + /// Checks a zero predicate against the actual divisor of a multiplication guard. + fn zero_check_for_operand(&self, condition: &SymBoolExpr, operand: &SymExpr) -> bool { + if self.bounded_zero_check_operand(condition) == Some(operand) { + return true; + } + // For a positive constant d, n / d == 0 iff n < d. Normalization + // exposes this comparison before the Solidity multiplication guard. + if let Some((numerator, denominator)) = operand.udiv_operands() + && denominator.as_const().is_some_and(|d| !d.is_zero()) + && let SymBoolExprKind::Cmp(SymCmpOp::Ult, left, right) = condition.kind() + { + return left == numerator && right == denominator; + } + false + } + + fn checked_mul_guard_for_zero_condition( + &self, + expr: &SymBoolExpr, + zero_condition: &SymBoolExpr, + ) -> bool { + let SymBoolExprKind::Cmp(SymCmpOp::Eq, left, right) = expr.kind() else { + return false; + }; + [(left, right), (right, left)].into_iter().any(|(quotient, expected)| { + matches!(quotient.kind(), SymExprKind::Ite(_, _, _)) + && self + .checked_quotient_factors(quotient, expected, Some(zero_condition)) + .is_some_and(|(left, right)| self.mul_cannot_overflow_256(left, right)) + }) + } + + /// Matches the quotient side shared by multiplication guard proofs and retained guard facts. + fn checked_quotient_factors<'a>( + &self, + quotient: &'a SymExpr, + expected: &SymExpr, + zero_condition: Option<&SymBoolExpr>, + ) -> Option<(&'a SymExpr, &'a SymExpr)> { + let (quotient, branch_condition) = + if let SymExprKind::Ite(condition, zero, quotient) = quotient.kind() { + if zero.as_const() != Some(U256::ZERO) || zero_condition.is_none() { + return None; + } + (quotient, Some(condition)) + } else { + (quotient, None) + }; + let (divisor, other) = Self::mul_div_identity_operands(quotient, expected)?; + (zero_condition.is_none_or(|condition| self.zero_check_for_operand(condition, divisor)) + && branch_condition + .is_none_or(|condition| self.zero_check_for_operand(condition, divisor))) + .then_some((divisor, other)) + } + + /// Learns multiplication safety from a retained successful Solidity overflow check. + fn record_non_wrapping_product(&mut self, cx: &mut SymCx, constraint: &SymBoolExpr) -> bool { + let fact = bitwise_bool_word_fact(cx, constraint).unwrap_or_else(|| constraint.clone()); + let factors = if let Some(factors) = self.checked_product_factors(&fact, None) { + Some(factors) + } else if let SymBoolExprKind::Not(inner) = fact.kind() + && let SymBoolExprKind::And(terms) = inner.kind() + && terms.len() == 2 + { + // Only the exact two-way disjunction is a multiplication guard. An additional + // alternative would let this predicate hold even when the product wraps. + let first = terms[0].clone().not(cx); + let second = terms[1].clone().not(cx); + self.checked_product_factors(&second, Some(&first)) + .or_else(|| self.checked_product_factors(&first, Some(&second))) + } else { + None + }; + if let Some((left, right)) = factors { + // A successful product fits in one word. A positive lower bound on either + // factor therefore bounds the other, even if it started as a full-width word. + // The supporting guard remains in the conjunction; it cannot prove itself. + let mut changed = false; + for (value, factor) in [(&left, &right), (&right, &left)] { + if let Some(range) = self.interval(factor) + && !range.min.is_zero() + { + changed |= self.record_upper_bound(value.clone(), U256::MAX / range.min); + } + } + self.non_wrapping_products.insert((left, right)) || changed + } else { + false + } + } + + fn checked_product_factors( + &self, + predicate: &SymBoolExpr, + zero_condition: Option<&SymBoolExpr>, + ) -> Option<(SymExpr, SymExpr)> { + let SymBoolExprKind::Cmp(SymCmpOp::Eq, left, right) = predicate.kind() else { + return None; + }; + for (quotient, expected) in [(left, right), (right, left)] { + if let Some((divisor, other)) = + self.checked_quotient_factors(quotient, expected, zero_condition) + { + // For divisor > 0, (divisor * other mod 2^256) / divisor == other + // implies the true product fits. For divisor == 0 the product is zero. + return Some((divisor.clone(), other.clone())); + } + } + None + } + + fn has_non_wrapping_product(&self, left: &SymExpr, right: &SymExpr) -> bool { + self.non_wrapping_products.contains(&(left.clone(), right.clone())) + || self.non_wrapping_products.contains(&(right.clone(), left.clone())) + } + + pub(super) fn mul_cannot_overflow_256(&self, left: &SymExpr, right: &SymExpr) -> bool { + if self.has_non_wrapping_product(left, right) { + return true; + } + let mut intervals = HashMap::default(); + let mut remaining = MAX_LOCAL_ANALYSIS_NODES; + if self + .interval_cached(left, &mut intervals, &mut remaining) + .zip(self.interval_cached(right, &mut intervals, &mut remaining)) + .is_some_and(|(left, right)| left.max.checked_mul(right.max).is_some()) + { + return true; + } + + let mut bit_widths = HashMap::default(); + let mut remaining = MAX_LOCAL_ANALYSIS_NODES; + self.unsigned_bits_cached(left, &mut bit_widths, &mut remaining) + .zip(self.unsigned_bits_cached(right, &mut bit_widths, &mut remaining)) + .is_some_and(|(left, right)| left.saturating_add(right) <= 256) + } + + pub(super) fn unsigned_bits(&self, expr: &SymExpr) -> usize { + let mut bit_widths = HashMap::default(); + let mut remaining = MAX_LOCAL_ANALYSIS_NODES; + self.unsigned_bits_cached(expr, &mut bit_widths, &mut remaining).unwrap_or(256) + } + + fn unsigned_bits_cached( + &self, + expr: &SymExpr, + bit_widths: &mut HashMap, + remaining: &mut usize, + ) -> Option { + if let Some(bits) = bit_widths.get(expr) { + return Some(*bits); + } + if *remaining == 0 { + return None; + } + *remaining -= 1; + + let bits = match expr.kind() { + SymExprKind::Const(value) => value.bit_len().max(1), + SymExprKind::Var(_) + | SymExprKind::GasLeft(_) + | SymExprKind::Keccak { .. } + | SymExprKind::Hash { .. } + | SymExprKind::Not(_) => 256, + SymExprKind::BinOp(SymBinOp::And, left, right) => { + if let Some(mask) = right.as_const() { + self.unsigned_bits_cached(left, bit_widths, remaining)?.min(mask.bit_len()) + } else { + 256 + } + } + SymExprKind::BinOp(SymBinOp::Add, left, right) => self + .unsigned_bits_cached(left, bit_widths, remaining)? + .max(self.unsigned_bits_cached(right, bit_widths, remaining)?) + .saturating_add(1) + .min(256), + SymExprKind::BinOp(SymBinOp::Mul, left, right) => self + .unsigned_bits_cached(left, bit_widths, remaining)? + .saturating_add(self.unsigned_bits_cached(right, bit_widths, remaining)?) + .min(256), + SymExprKind::BinOp(SymBinOp::UDiv, left, _) => { + self.unsigned_bits_cached(left, bit_widths, remaining)? + } + SymExprKind::Ite(_, left, right) => self + .unsigned_bits_cached(left, bit_widths, remaining)? + .max(self.unsigned_bits_cached(right, bit_widths, remaining)?), + _ => 256, + }; + + let bits = self + .upper_bounds + .get(expr) + .copied() + .map(|bound| bits.min(bound.bit_len().max(1))) + .unwrap_or(bits); + bit_widths.insert(expr.clone(), bits); + Some(bits) + } +} diff --git a/crates/evm/symbolic/src/runtime/solver/normalize/polynomial.rs b/crates/evm/symbolic/src/runtime/solver/normalize/polynomial.rs new file mode 100644 index 0000000000000..014918b02c050 --- /dev/null +++ b/crates/evm/symbolic/src/runtime/solver/normalize/polynomial.rs @@ -0,0 +1,223 @@ +//! Bounded sparse-polynomial identity reasoning over EVM words. + +use super::*; + +pub(super) fn polynomial_identity(left: &SymExpr, right: &SymExpr) -> bool { + if !polynomial_normalization_can_help(left) && !polynomial_normalization_can_help(right) { + return false; + } + matches!( + (Polynomial::from_expr(left), Polynomial::from_expr(right)), + (Some(left), Some(right)) if left == right + ) +} + +fn polynomial_normalization_can_help(expr: &SymExpr) -> bool { + let crosses_sum_product_boundary = match expr.kind() { + SymExprKind::BinOp(SymBinOp::Mul, left, right) => { + matches!(left.kind(), SymExprKind::BinOp(SymBinOp::Add | SymBinOp::Sub, ..)) + || matches!(right.kind(), SymExprKind::BinOp(SymBinOp::Add | SymBinOp::Sub, ..)) + } + SymExprKind::BinOp(SymBinOp::Add | SymBinOp::Sub, left, right) => { + matches!(left.kind(), SymExprKind::BinOp(SymBinOp::Mul, ..)) + || matches!(right.kind(), SymExprKind::BinOp(SymBinOp::Mul, ..)) + || matches!( + left.kind(), + SymExprKind::BinOp(SymBinOp::Shl, _, shift) + if shift.as_const().is_some_and(|shift| shift < U256::from(256)) + ) + || matches!( + right.kind(), + SymExprKind::BinOp(SymBinOp::Shl, _, shift) + if shift.as_const().is_some_and(|shift| shift < U256::from(256)) + ) + } + _ => false, + }; + if !crosses_sum_product_boundary { + return false; + } + + fn ring_shape( + expr: &SymExpr, + shapes: &mut HashMap>, + remaining: &mut usize, + ) -> Option<(usize, usize)> { + if let Some(shape) = shapes.get(expr) { + return *shape; + } + if *remaining == 0 { + return None; + } + *remaining -= 1; + let shape = (|| match expr.kind() { + SymExprKind::Const(_) | SymExprKind::Var(_) => Some((0, 0)), + SymExprKind::BinOp( + op @ (SymBinOp::Add | SymBinOp::Sub | SymBinOp::Mul), + left, + right, + ) => { + let left = ring_shape(left, shapes, remaining)?; + let right = ring_shape(right, shapes, remaining)?; + let operations = left.0.saturating_add(right.0).saturating_add(1); + let multiplications = left + .1 + .saturating_add(right.1) + .saturating_add(usize::from(*op == SymBinOp::Mul)); + Some((operations, multiplications)) + } + SymExprKind::BinOp(SymBinOp::Shl, value, shift) + if shift.as_const().is_some_and(|shift| shift < U256::from(256)) => + { + let shape = ring_shape(value, shapes, remaining)?; + Some((shape.0.saturating_add(1), shape.1.saturating_add(1))) + } + _ => None, + })(); + shapes.insert(expr.clone(), shape); + shape + } + + let mut shapes = HashMap::default(); + let mut remaining = MAX_LOCAL_ANALYSIS_NODES; + ring_shape(expr, &mut shapes, &mut remaining) + .is_some_and(|(operations, multiplications)| operations > 1 && multiplications > 0) +} + +// Keep distributive expansion predictably bounded. The motivating accounting identity needs two +// terms with two factors; these limits leave ample room for ordinary identities without allowing +// adversarial expressions to explode. +const MAX_POLYNOMIAL_TERMS: usize = 32; +const MAX_MONOMIAL_FACTORS: usize = 8; +const MAX_POLYNOMIAL_PRODUCTS: usize = 256; + +type Monomial = Vec; + +/// A sparse polynomial over the EVM word ring Z/(2^256). +/// +/// Addition, subtraction, and multiplication of EVM words obey the ring laws even when they +/// wrap. Canonicalizing small expressions here lets the solver recognize nonlinear algebraic +/// identities without replacing bit-vector semantics with unbounded integer arithmetic. +#[derive(Clone, PartialEq, Eq)] +struct Polynomial { + terms: HashMap, +} + +impl Polynomial { + fn from_expr(expr: &SymExpr) -> Option { + let mut remaining = MAX_LOCAL_ANALYSIS_NODES; + Self::from_expr_cached(expr, &mut HashMap::default(), &mut remaining) + } + + fn from_expr_cached( + expr: &SymExpr, + polynomials: &mut HashMap>, + remaining: &mut usize, + ) -> Option { + if let Some(polynomial) = polynomials.get(expr) { + return polynomial.clone(); + } + if *remaining == 0 { + polynomials.insert(expr.clone(), None); + return None; + } + *remaining -= 1; + let polynomial = + (|| match expr.kind() { + SymExprKind::Const(value) => Some(Self::constant(*value)), + SymExprKind::BinOp(SymBinOp::Add, left, right) => { + Self::from_expr_cached(left, polynomials, remaining)? + .add(Self::from_expr_cached(right, polynomials, remaining)?) + } + SymExprKind::BinOp(SymBinOp::Sub, left, right) => { + Self::from_expr_cached(left, polynomials, remaining)? + .sub(Self::from_expr_cached(right, polynomials, remaining)?) + } + SymExprKind::BinOp(SymBinOp::Mul, left, right) => { + Self::from_expr_cached(left, polynomials, remaining)? + .mul(Self::from_expr_cached(right, polynomials, remaining)?) + } + SymExprKind::BinOp(SymBinOp::Shl, value, shift) + if let Some(shift) = shift.as_const() + && shift < U256::from(256) => + { + let coefficient = U256::ONE << usize::try_from(shift).ok()?; + Self::from_expr_cached(value, polynomials, remaining)? + .mul(Self::constant(coefficient)) + } + _ => { + let terms = HashMap::from_iter([(vec![expr.clone()], U256::ONE)]); + Some(Self { terms }) + } + })(); + polynomials.insert(expr.clone(), polynomial.clone()); + polynomial + } + + fn constant(value: U256) -> Self { + let mut terms = HashMap::default(); + if !value.is_zero() { + terms.insert(Vec::new(), value); + } + Self { terms } + } + + fn add(mut self, right: Self) -> Option { + for (monomial, coefficient) in right.terms { + self.add_term(monomial, coefficient); + if self.terms.len() > MAX_POLYNOMIAL_TERMS { + return None; + } + } + Some(self) + } + + fn sub(mut self, right: Self) -> Option { + for (monomial, coefficient) in right.terms { + self.add_term(monomial, U256::ZERO.wrapping_sub(coefficient)); + if self.terms.len() > MAX_POLYNOMIAL_TERMS { + return None; + } + } + Some(self) + } + + fn mul(self, right: Self) -> Option { + let products = self.terms.len().checked_mul(right.terms.len())?; + if products > MAX_POLYNOMIAL_PRODUCTS { + return None; + } + + let mut out = Self { terms: HashMap::default() }; + for (left_monomial, left_coefficient) in &self.terms { + for (right_monomial, right_coefficient) in &right.terms { + let factor_count = left_monomial.len().checked_add(right_monomial.len())?; + if factor_count > MAX_MONOMIAL_FACTORS { + return None; + } + let mut monomial = Vec::with_capacity(factor_count); + monomial.extend(left_monomial.iter().cloned()); + monomial.extend(right_monomial.iter().cloned()); + SymExpr::sort_interned_factors(&mut monomial); + out.add_term(monomial, left_coefficient.wrapping_mul(*right_coefficient)); + if out.terms.len() > MAX_POLYNOMIAL_TERMS { + return None; + } + } + } + Some(out) + } + + fn add_term(&mut self, monomial: Monomial, coefficient: U256) { + if coefficient.is_zero() { + return; + } + let coefficient = + self.terms.get(&monomial).copied().unwrap_or_default().wrapping_add(coefficient); + if coefficient.is_zero() { + self.terms.remove(&monomial); + } else { + self.terms.insert(monomial, coefficient); + } + } +} diff --git a/crates/evm/symbolic/src/runtime/solver/normalize/rounding.rs b/crates/evm/symbolic/src/runtime/solver/normalize/rounding.rs new file mode 100644 index 0000000000000..add22a8276078 --- /dev/null +++ b/crates/evm/symbolic/src/runtime/solver/normalize/rounding.rs @@ -0,0 +1,163 @@ +//! Rounding relations between a rounded multiple and its original dividend. +//! +//! These bounds describe mathematical differences, not wrapping EVM subtractions. +//! They are consumed only where the sign or a non-wrapping product is established. + +use super::{ + ConstraintContext, HashMap, MAX_LOCAL_ANALYSIS_NODES, SymBinOp, SymBoolExpr, SymBoolExprKind, + SymCmpOp, SymExpr, SymExprKind, U256, WordInterval, +}; + +/// `anchor - below <= rounded <= anchor + above` over mathematical integers. +struct RoundingBounds<'a> { + anchor: &'a SymExpr, + below: U256, + above: U256, +} + +impl ConstraintContext { + /// Matches `(dividend / d) * d` for a positive constant `d`. + pub(super) fn rounded_product_operands(expr: &SymExpr) -> Option<(&SymExpr, U256)> { + let (quotient, factor) = Self::constant_mul_operands(expr)?; + let (dividend, divisor) = quotient.udiv_operands()?; + (divisor.as_const() == Some(factor) && !factor.is_zero()).then_some((dividend, factor)) + } + + // Requesting an anchor preserves the raw dividend relation even when a safe + // offset also exposes a relation to the pre-offset value. With no requested + // anchor, prefer the shifted relation for quotient cancellation. + fn rounding_bounds<'a>( + &self, + expr: &'a SymExpr, + anchor: Option<&SymExpr>, + ) -> Option> { + let mut remaining = MAX_LOCAL_ANALYSIS_NODES; + self.rounding_bounds_cached(expr, anchor, &mut HashMap::default(), &mut remaining) + } + + fn rounding_bounds_cached<'a>( + &self, + expr: &'a SymExpr, + anchor: Option<&SymExpr>, + intervals: &mut HashMap>, + remaining: &mut usize, + ) -> Option> { + let (dividend, divisor) = Self::rounded_product_operands(expr)?; + // Euclidean division: q*d = dividend - remainder, 0 <= remainder < d. + // In particular, q*d <= dividend <= MAX, so the rescaling cannot wrap. + let width = divisor - U256::ONE; + let raw = RoundingBounds { anchor: dividend, below: width, above: U256::ZERO }; + if anchor == Some(dividend) { + return Some(raw); + } + let offset = match dividend.kind() { + SymExprKind::BinOp(SymBinOp::Add, anchor, bias) + if let Some(bias) = bias.as_const() + && bias <= width => + { + Some((anchor, bias, bias)) + } + SymExprKind::BinOp(SymBinOp::Sub, sum, one) + if one.as_const() == Some(U256::ONE) + && let SymExprKind::BinOp(SymBinOp::Add, anchor, bias) = sum.kind() + && bias.as_const() == Some(divisor) => + { + // Preserve the intermediate addition in `(anchor + d) - 1`. + Some((anchor, width, divisor)) + } + _ => None, + }; + if let Some((anchor, bias, addition)) = offset + && self + .interval_cached(anchor, intervals, remaining) + .is_some_and(|range| range.max.checked_add(addition).is_some()) + { + // For dividend = anchor + bias, the error is bias - remainder. + return Some(RoundingBounds { anchor, below: width - bias, above: bias }); + } + // An unproved offset remains opaque. A bound on a wrapped sum cannot + // justify removing that sum from the relation. + Some(raw) + } + + /// A nonnegative rounding error smaller than a divisor preserves its quotient. + pub(super) fn quotient_of_rounded_product<'a>(&self, expr: &'a SymExpr) -> Option<&'a SymExpr> { + let (numerator, divisor) = expr.udiv_operands()?; + let bounds = self.rounding_bounds(numerator, None)?; + let minimum = + divisor.as_const().or_else(|| self.unsigned_lower_bounds.get(divisor).copied())?; + if !bounds.below.is_zero() || bounds.above >= minimum { + return None; + } + let SymExprKind::BinOp(SymBinOp::Mul, left, right) = bounds.anchor.kind() else { + return None; + }; + let value = if left == divisor { + right + } else if right == divisor { + left + } else { + return None; + }; + // The anchor is an EVM word. Cancelling a factor requires independent + // evidence that its mathematical product did not wrap. + self.mul_cannot_overflow_256(value, divisor).then_some(value) + } + + pub(super) fn rounding_comparison_value(&self, expr: &SymBoolExpr) -> Option { + if let SymBoolExprKind::Not(inner) = expr.kind() { + return self.rounding_comparison_value(inner).map(|value| !value); + } + let SymBoolExprKind::Cmp(op, left, right) = expr.kind() else { return None }; + for (rounded, anchor, op) in [ + (left, right, *op), + ( + right, + left, + match op { + SymCmpOp::Ult => SymCmpOp::Ugt, + SymCmpOp::Ule => SymCmpOp::Uge, + SymCmpOp::Ugt => SymCmpOp::Ult, + SymCmpOp::Uge => SymCmpOp::Ule, + other => *other, + }, + ), + ] { + if let Some(bounds) = self.rounding_bounds(rounded, Some(anchor)) + && bounds.anchor == anchor + { + match op { + SymCmpOp::Uge if bounds.below.is_zero() => return Some(true), + SymCmpOp::Ult if bounds.below.is_zero() => return Some(false), + SymCmpOp::Ule if bounds.above.is_zero() => return Some(true), + SymCmpOp::Ugt if bounds.above.is_zero() => return Some(false), + _ => {} + } + } + } + None + } + + /// Bounds a subtraction only when the relation proves it cannot underflow. + pub(super) fn rounding_error_interval( + &self, + left: &SymExpr, + right: &SymExpr, + intervals: &mut HashMap>, + remaining: &mut usize, + ) -> Option { + if let Some(bounds) = self.rounding_bounds_cached(left, Some(right), intervals, remaining) + && bounds.anchor == right + && bounds.below.is_zero() + { + return Some(WordInterval { min: U256::ZERO, max: bounds.above }); + } + if let Some(bounds) = self.rounding_bounds_cached(right, Some(left), intervals, remaining) + && bounds.anchor == left + && bounds.above.is_zero() + { + return Some(WordInterval { min: U256::ZERO, max: bounds.below }); + } + None + } +} diff --git a/crates/evm/symbolic/src/runtime/solver/reasoning/mod.rs b/crates/evm/symbolic/src/runtime/solver/reasoning/mod.rs new file mode 100644 index 0000000000000..e236629f7655e --- /dev/null +++ b/crates/evm/symbolic/src/runtime/solver/reasoning/mod.rs @@ -0,0 +1,9 @@ +//! Lightweight reasoning passes over normalized solver constraints. + +use super::{normalize::ConstraintContext, *}; + +mod monotonic_product; + +pub(super) use monotonic_product::{ + product_monotonic_unsat_normalized, remove_implied_monotonic_constraints, +}; diff --git a/crates/evm/symbolic/src/runtime/solver/reasoning/monotonic_product.rs b/crates/evm/symbolic/src/runtime/solver/reasoning/monotonic_product.rs new file mode 100644 index 0000000000000..1b1174850d3f7 --- /dev/null +++ b/crates/evm/symbolic/src/runtime/solver/reasoning/monotonic_product.rs @@ -0,0 +1,293 @@ +use super::*; + +type LessThanFacts<'a> = HashSet<(&'a SymExpr, &'a SymExpr)>; +type LessOrEqualFacts<'a> = HashSet<(&'a SymExpr, &'a SymExpr)>; +type PositiveFacts<'a> = HashSet<&'a SymExpr>; + +#[derive(Default)] +struct OrderFacts<'a> { + less_than: LessThanFacts<'a>, + less_or_equal: LessOrEqualFacts<'a>, + positive: PositiveFacts<'a>, +} + +/// Returns whether normalized monotonic product facts make constraints unsatisfiable. +pub(crate) fn product_monotonic_unsat_normalized(constraints: &[SymBoolExpr]) -> bool { + let facts = order_facts(constraints.iter()); + let bounds = ConstraintContext::new(constraints); + + constraints.iter().any(|constraint| { + reversed_strict_comparison(constraint) + .is_some_and(|(left, right)| expr_less_or_equal(right, left, &facts, &bounds)) + || product_less_than_negation(constraint).is_some_and( + |(left_a, left_b, right_a, right_b)| { + product_less_than_known( + left_a, + left_b, + right_a, + right_b, + &facts.less_than, + &facts.positive, + &bounds, + ) + }, + ) + }) +} + +/// Removes hard-arithmetic comparisons implied by the remaining path constraints. +/// +/// This keeps a sound monotonic success path from falling through to the heuristic witness +/// search, whose satisfiable models are useful for counterexamples but cannot establish a proof. +pub(in super::super) fn remove_implied_monotonic_constraints( + mut constraints: Vec, +) -> Vec { + // Remove constraints one at a time so two candidates cannot justify each other and then both + // disappear from the final query. + let mut index = 0; + while index < constraints.len() { + if !constraints[index].contains_hard_arith() { + index += 1; + continue; + } + let Some((left, right)) = less_or_equal_comparison(&constraints[index]) else { + index += 1; + continue; + }; + let base = constraints + .iter() + .enumerate() + .filter(|(candidate, _)| *candidate != index) + .map(|(_, constraint)| constraint.clone()) + .collect::>(); + let facts = order_facts(base.iter()); + let bounds = ConstraintContext::new(&base); + if expr_less_or_equal(left, right, &facts, &bounds) { + constraints.remove(index); + } else { + index += 1; + } + } + constraints +} + +fn order_facts<'a>(constraints: impl IntoIterator) -> OrderFacts<'a> { + let mut facts = OrderFacts::default(); + for constraint in constraints { + collect_order_facts(constraint, &mut facts); + } + facts +} + +fn collect_order_facts<'a>(expr: &'a SymBoolExpr, facts: &mut OrderFacts<'a>) { + match expr.kind() { + SymBoolExprKind::And(values) => { + for value in values.iter() { + collect_order_facts(value, facts); + } + } + SymBoolExprKind::Cmp(SymCmpOp::Ult, left, right) => { + facts.less_than.insert((left, right)); + facts.less_or_equal.insert((left, right)); + if left.as_const().is_some_and(|value| value.is_zero()) { + facts.positive.insert(right); + } + } + SymBoolExprKind::Cmp(SymCmpOp::Ugt, left, right) => { + facts.less_than.insert((right, left)); + facts.less_or_equal.insert((right, left)); + if right.as_const().is_some_and(|value| value.is_zero()) { + facts.positive.insert(left); + } + } + SymBoolExprKind::Cmp(SymCmpOp::Ule, left, right) => { + facts.less_or_equal.insert((left, right)); + } + SymBoolExprKind::Cmp(SymCmpOp::Uge, left, right) => { + facts.less_or_equal.insert((right, left)); + } + SymBoolExprKind::Cmp(SymCmpOp::Eq, left, right) => { + facts.less_or_equal.insert((left, right)); + facts.less_or_equal.insert((right, left)); + } + SymBoolExprKind::Not(value) => { + if let Some(expr) = nonzero_expr(value) { + facts.positive.insert(expr); + } + if let SymBoolExprKind::Cmp(op, left, right) = value.kind() { + match op { + // !(a < b) => b <= a + SymCmpOp::Ult => { + facts.less_or_equal.insert((right, left)); + } + // !(a > b) => a <= b + SymCmpOp::Ugt => { + facts.less_or_equal.insert((left, right)); + } + // !(a <= b) => b < a + SymCmpOp::Ule => { + facts.less_than.insert((right, left)); + facts.less_or_equal.insert((right, left)); + } + // !(a >= b) => a < b + SymCmpOp::Uge => { + facts.less_than.insert((left, right)); + facts.less_or_equal.insert((left, right)); + } + SymCmpOp::Eq | SymCmpOp::Slt | SymCmpOp::Sgt => {} + } + } + } + SymBoolExprKind::Const(_) | SymBoolExprKind::Cmp(SymCmpOp::Slt | SymCmpOp::Sgt, _, _) => {} + } +} + +/// Returns a strict comparison that contradicts `right <= left`. +fn reversed_strict_comparison(expr: &SymBoolExpr) -> Option<(&SymExpr, &SymExpr)> { + match expr.kind() { + SymBoolExprKind::Cmp(SymCmpOp::Ult, left, right) => Some((left, right)), + SymBoolExprKind::Cmp(SymCmpOp::Ugt, left, right) => Some((right, left)), + SymBoolExprKind::Not(value) => match value.kind() { + SymBoolExprKind::Cmp(SymCmpOp::Ule, left, right) => Some((right, left)), + SymBoolExprKind::Cmp(SymCmpOp::Uge, left, right) => Some((left, right)), + _ => None, + }, + _ => None, + } +} + +/// Returns the unsigned weak ordering asserted by this constraint. +fn less_or_equal_comparison(expr: &SymBoolExpr) -> Option<(&SymExpr, &SymExpr)> { + match expr.kind() { + SymBoolExprKind::Cmp(SymCmpOp::Ule, left, right) => Some((left, right)), + SymBoolExprKind::Cmp(SymCmpOp::Uge, left, right) => Some((right, left)), + SymBoolExprKind::Not(value) => match value.kind() { + SymBoolExprKind::Cmp(SymCmpOp::Ult, left, right) => Some((right, left)), + SymBoolExprKind::Cmp(SymCmpOp::Ugt, left, right) => Some((left, right)), + _ => None, + }, + _ => None, + } +} + +/// Returns whether the known unsigned order and no-overflow bounds imply `left <= right`. +fn expr_less_or_equal<'a>( + left: &'a SymExpr, + right: &'a SymExpr, + facts: &OrderFacts<'a>, + bounds: &ConstraintContext, +) -> bool { + if left == right || facts.less_or_equal.contains(&(left, right)) { + return true; + } + + match (left.kind(), right.kind()) { + ( + SymExprKind::BinOp(SymBinOp::UDiv, left_num, left_den), + SymExprKind::BinOp(SymBinOp::UDiv, right_num, right_den), + ) if left_den == right_den => expr_less_or_equal(left_num, right_num, facts, bounds), + ( + SymExprKind::BinOp(SymBinOp::Mul, left_a, left_b), + SymExprKind::BinOp(SymBinOp::Mul, right_a, right_b), + ) if bounds.mul_cannot_overflow_256(left_a, left_b) + && bounds.mul_cannot_overflow_256(right_a, right_b) => + { + product_less_or_equal_known(left_a, left_b, right_a, right_b, facts, bounds) + } + _ => false, + } +} + +fn product_less_or_equal_known<'a>( + left_a: &'a SymExpr, + left_b: &'a SymExpr, + right_a: &'a SymExpr, + right_b: &'a SymExpr, + facts: &OrderFacts<'a>, + bounds: &ConstraintContext, +) -> bool { + product_less_or_equal_known_ordered(left_a, left_b, right_a, right_b, facts, bounds) + || product_less_or_equal_known_ordered(left_b, left_a, right_a, right_b, facts, bounds) + || product_less_or_equal_known_ordered(left_a, left_b, right_b, right_a, facts, bounds) + || product_less_or_equal_known_ordered(left_b, left_a, right_b, right_a, facts, bounds) +} + +fn product_less_or_equal_known_ordered<'a>( + left_a: &'a SymExpr, + left_b: &'a SymExpr, + right_a: &'a SymExpr, + right_b: &'a SymExpr, + facts: &OrderFacts<'a>, + bounds: &ConstraintContext, +) -> bool { + expr_less_or_equal(left_a, right_a, facts, bounds) + && expr_less_or_equal(left_b, right_b, facts, bounds) +} + +fn nonzero_expr(expr: &SymBoolExpr) -> Option<&SymExpr> { + let SymBoolExprKind::Cmp(SymCmpOp::Eq, left, right) = expr.kind() else { return None }; + if left.as_const().is_some_and(|value| value.is_zero()) { + Some(right) + } else if right.as_const().is_some_and(|value| value.is_zero()) { + Some(left) + } else { + None + } +} + +fn product_less_than_negation( + expr: &SymBoolExpr, +) -> Option<(&SymExpr, &SymExpr, &SymExpr, &SymExpr)> { + let SymBoolExprKind::Not(value) = expr.kind() else { return None }; + let SymBoolExprKind::Cmp(SymCmpOp::Ult, left, right) = value.kind() else { + return None; + }; + let (left_a, left_b) = mul_operands(left)?; + let (right_a, right_b) = mul_operands(right)?; + Some((left_a, left_b, right_a, right_b)) +} + +fn product_less_than_known<'a>( + left_a: &'a SymExpr, + left_b: &'a SymExpr, + right_a: &'a SymExpr, + right_b: &'a SymExpr, + less_than: &LessThanFacts<'a>, + positive: &PositiveFacts<'a>, + bounds: &ConstraintContext, +) -> bool { + product_less_than_known_ordered(left_a, left_b, right_a, right_b, less_than, positive, bounds) + || product_less_than_known_ordered( + left_b, left_a, right_a, right_b, less_than, positive, bounds, + ) + || product_less_than_known_ordered( + left_a, left_b, right_b, right_a, less_than, positive, bounds, + ) + || product_less_than_known_ordered( + left_b, left_a, right_b, right_a, less_than, positive, bounds, + ) +} + +fn product_less_than_known_ordered<'a>( + left_a: &'a SymExpr, + left_b: &'a SymExpr, + right_a: &'a SymExpr, + right_b: &'a SymExpr, + less_than: &LessThanFacts<'a>, + positive: &PositiveFacts<'a>, + bounds: &ConstraintContext, +) -> bool { + positive.contains(left_a) + && positive.contains(left_b) + && less_than.contains(&(left_a, right_a)) + && less_than.contains(&(left_b, right_b)) + && bounds.mul_cannot_overflow_256(left_a, left_b) + && bounds.mul_cannot_overflow_256(right_a, right_b) +} + +fn mul_operands(expr: &SymExpr) -> Option<(&SymExpr, &SymExpr)> { + match expr.kind() { + SymExprKind::BinOp(SymBinOp::Mul, left, right) => Some((left, right)), + _ => None, + } +} diff --git a/crates/evm/symbolic/src/runtime/solver/smt/mod.rs b/crates/evm/symbolic/src/runtime/solver/smt/mod.rs new file mode 100644 index 0000000000000..87b1ddab24285 --- /dev/null +++ b/crates/evm/symbolic/src/runtime/solver/smt/mod.rs @@ -0,0 +1,368 @@ +//! Query-level SMT-LIB assertion emission with common-subexpression bindings. + +use super::*; + +pub(super) fn write_smt_assertions( + cx: &SymCx, + out: &mut String, + constraints: &[SymBoolExpr], +) -> Result<(), SymbolicError> { + if constraints.is_empty() { + return Ok(()); + } + if constraints.iter().any(SymBoolExpr::contains_gasleft) { + return Err(SymbolicError::Unsupported("GAS/gasleft() not modeled")); + } + + let plan = SmtCsePlan::new(constraints); + if plan.bindings.is_empty() { + for constraint in constraints { + let _ = writeln!(out, "(assert {})", constraint.smt(cx)); + } + return Ok(()); + } + + let writer = SmtCseWriter { cx, plan: &plan }; + // define binding_0 = term_0 + // ... + // define binding_n = term_n + // assert constraint_0 + // ... + // assert constraint_n + for (idx, binding) in plan.bindings.iter().enumerate() { + out.push_str("(define-fun "); + binding.write_definition_header(out, idx); + match binding { + SmtBinding::Expr(expr) => writer.write_expr(out, expr, Some(idx), None), + SmtBinding::Bool(expr) => writer.write_bool(out, expr, None, Some(idx)), + } + out.push_str(")\n"); + } + for constraint in constraints { + out.push_str("(assert "); + writer.write_bool(out, constraint, None, None); + out.push_str(")\n"); + } + Ok(()) +} + +#[derive(Default)] +struct SmtCseVisit { + count: usize, + binding: Option, + collected: bool, +} + +struct SmtCsePlan { + expr_visits: HashMap, + bool_visits: HashMap, + bindings: Vec, +} + +impl SmtCsePlan { + fn new(constraints: &[SymBoolExpr]) -> Self { + let mut plan = Self { + expr_visits: HashMap::default(), + bool_visits: HashMap::default(), + bindings: Vec::new(), + }; + for constraint in constraints { + plan.count_bool(constraint); + } + for constraint in constraints { + plan.collect_bool_binding(constraint); + } + plan + } + + fn count_expr(&mut self, expr: &SymExpr) { + let visit = self.expr_visits.entry(expr.clone()).or_default(); + visit.count += 1; + if visit.count != 1 { + return; + } + match expr.kind() { + SymExprKind::Const(_) + | SymExprKind::Var(_) + | SymExprKind::GasLeft(_) + | SymExprKind::Keccak { .. } + | SymExprKind::Hash { .. } => {} + SymExprKind::Not(value) => self.count_expr(value), + SymExprKind::BinOp(_, left, right) => { + self.count_expr(left); + self.count_expr(right); + } + SymExprKind::TernOp(_, left, right, modulus) => { + self.count_expr(modulus); + self.count_expr(left); + self.count_expr(right); + self.count_expr(modulus); + } + SymExprKind::Ite(cond, left, right) => { + self.count_bool(cond); + self.count_expr(left); + self.count_expr(right); + } + } + } + + fn count_bool(&mut self, expr: &SymBoolExpr) { + let visit = self.bool_visits.entry(expr.clone()).or_default(); + visit.count += 1; + if visit.count != 1 { + return; + } + match expr.kind() { + SymBoolExprKind::Const(_) => {} + SymBoolExprKind::Not(value) => self.count_bool(value), + SymBoolExprKind::And(values) => { + for value in values.iter() { + self.count_bool(value); + } + } + SymBoolExprKind::Cmp(_, left, right) => { + self.count_expr(left); + self.count_expr(right); + } + } + } + + fn collect_expr_binding(&mut self, expr: &SymExpr) { + { + let Some(visit) = self.expr_visits.get_mut(expr) else { + return; + }; + if visit.collected { + return; + } + visit.collected = true; + } + match expr.kind() { + SymExprKind::Const(_) + | SymExprKind::Var(_) + | SymExprKind::GasLeft(_) + | SymExprKind::Keccak { .. } + | SymExprKind::Hash { .. } => {} + SymExprKind::Not(value) => self.collect_expr_binding(value), + SymExprKind::BinOp(_, left, right) => { + self.collect_expr_binding(left); + self.collect_expr_binding(right); + } + SymExprKind::TernOp(_, left, right, modulus) => { + self.collect_expr_binding(modulus); + self.collect_expr_binding(left); + self.collect_expr_binding(right); + } + SymExprKind::Ite(cond, left, right) => { + self.collect_bool_binding(cond); + self.collect_expr_binding(left); + self.collect_expr_binding(right); + } + } + self.bind_expr(expr); + } + + fn collect_bool_binding(&mut self, expr: &SymBoolExpr) { + { + let Some(visit) = self.bool_visits.get_mut(expr) else { + return; + }; + if visit.collected { + return; + } + visit.collected = true; + } + match expr.kind() { + SymBoolExprKind::Const(_) => {} + SymBoolExprKind::Not(value) => self.collect_bool_binding(value), + SymBoolExprKind::And(values) => { + for value in values.iter() { + self.collect_bool_binding(value); + } + } + SymBoolExprKind::Cmp(_, left, right) => { + self.collect_expr_binding(left); + self.collect_expr_binding(right); + } + } + self.bind_bool(expr); + } + + fn bind_expr(&mut self, expr: &SymExpr) { + let Some(visit) = self.expr_visits.get_mut(expr) else { + return; + }; + if visit.count <= 1 || visit.binding.is_some() || !Self::expr_can_bind(expr) { + return; + } + let idx = self.bindings.len(); + visit.binding = Some(idx); + self.bindings.push(SmtBinding::Expr(expr.clone())); + } + + fn bind_bool(&mut self, expr: &SymBoolExpr) { + let Some(visit) = self.bool_visits.get_mut(expr) else { + return; + }; + if visit.count <= 1 + || visit.binding.is_some() + || matches!(expr.kind(), SymBoolExprKind::Const(_)) + { + return; + } + let idx = self.bindings.len(); + visit.binding = Some(idx); + self.bindings.push(SmtBinding::Bool(expr.clone())); + } + + fn expr_can_bind(expr: &SymExpr) -> bool { + !matches!( + expr.kind(), + SymExprKind::Const(_) + | SymExprKind::Var(_) + | SymExprKind::GasLeft(_) + | SymExprKind::Keccak { .. } + | SymExprKind::Hash { .. } + ) + } +} + +enum SmtBinding { + Expr(SymExpr), + Bool(SymBoolExpr), +} + +impl SmtBinding { + fn write_definition_header(&self, out: &mut String, idx: usize) { + match self { + Self::Expr(_) => { + let _ = write!(out, "__sym_expr_{idx}"); + out.push_str(" () (_ BitVec 256) "); + } + Self::Bool(_) => { + let _ = write!(out, "__sym_bool_{idx}"); + out.push_str(" () Bool "); + } + } + } +} + +struct SmtCseWriter<'a> { + cx: &'a SymCx, + plan: &'a SmtCsePlan, +} + +impl SmtCseWriter<'_> { + fn write_expr( + &self, + out: &mut String, + expr: &SymExpr, + skip_expr: Option, + skip_bool: Option, + ) { + if let Some(idx) = self.plan.expr_visits.get(expr).and_then(|visit| visit.binding) + && Some(idx) != skip_expr + { + let _ = write!(out, "__sym_expr_{idx}"); + return; + } + + match expr.kind() { + SymExprKind::Const(value) => { + let _ = write!(out, "(_ bv{value} 256)"); + } + SymExprKind::Var(symbol) + | SymExprKind::GasLeft(symbol) + | SymExprKind::Keccak { name: symbol, .. } + | SymExprKind::Hash { name: symbol, .. } => out.push_str(self.cx.symbol_name(*symbol)), + SymExprKind::Not(value) => { + out.push_str("(bvnot "); + self.write_expr(out, value, skip_expr, skip_bool); + out.push(')'); + } + SymExprKind::BinOp(op, left, right) => { + let _ = write!(out, "({} ", op.smt()); + self.write_expr(out, left, skip_expr, skip_bool); + out.push(' '); + self.write_expr(out, right, skip_expr, skip_bool); + out.push(')'); + } + SymExprKind::TernOp(op, left, right, modulus) => { + self.write_wide_modular_arithmetic(out, op.smt(), left, right, modulus); + } + SymExprKind::Ite(cond, left, right) => { + out.push_str("(ite "); + self.write_bool(out, cond, skip_expr, skip_bool); + out.push(' '); + self.write_expr(out, left, skip_expr, skip_bool); + out.push(' '); + self.write_expr(out, right, skip_expr, skip_bool); + out.push(')'); + } + } + } + + fn write_wide_modular_arithmetic( + &self, + out: &mut String, + op: &'static str, + left: &SymExpr, + right: &SymExpr, + modulus: &SymExpr, + ) { + // if modulus == 0: + // 0 + // else: + // low_256((zext(left) op zext(right)) urem zext(modulus)) + out.push_str("(ite (= "); + self.write_expr(out, modulus, None, None); + out.push_str(" (_ bv0 256)) (_ bv0 256) ((_ extract 255 0) (bvurem ("); + out.push_str(op); + out.push_str(" ((_ zero_extend 256) "); + self.write_expr(out, left, None, None); + out.push_str(") ((_ zero_extend 256) "); + self.write_expr(out, right, None, None); + out.push_str(")) ((_ zero_extend 256) "); + self.write_expr(out, modulus, None, None); + out.push_str("))))"); + } + + fn write_bool( + &self, + out: &mut String, + expr: &SymBoolExpr, + skip_expr: Option, + skip_bool: Option, + ) { + if let Some(idx) = self.plan.bool_visits.get(expr).and_then(|visit| visit.binding) + && Some(idx) != skip_bool + { + let _ = write!(out, "__sym_bool_{idx}"); + return; + } + + match expr.kind() { + SymBoolExprKind::Const(value) => out.push_str(if *value { "true" } else { "false" }), + SymBoolExprKind::Not(value) => { + out.push_str("(not "); + self.write_bool(out, value, skip_expr, skip_bool); + out.push(')'); + } + SymBoolExprKind::And(values) => { + out.push_str("(and"); + for value in values.iter() { + out.push(' '); + self.write_bool(out, value, skip_expr, skip_bool); + } + out.push(')'); + } + SymBoolExprKind::Cmp(op, left, right) => { + let _ = write!(out, "({} ", op.smt()); + self.write_expr(out, left, skip_expr, skip_bool); + out.push(' '); + self.write_expr(out, right, skip_expr, skip_bool); + out.push(')'); + } + } + } +} diff --git a/crates/evm/symbolic/src/runtime/state.rs b/crates/evm/symbolic/src/runtime/state.rs new file mode 100644 index 0000000000000..49aaf5def5db1 --- /dev/null +++ b/crates/evm/symbolic/src/runtime/state.rs @@ -0,0 +1,2479 @@ +use super::*; +use foundry_evm::revm::context_interface::cfg::gas::CALL_STIPEND; + +const MAX_BOUND_ANALYSIS_VISITS: usize = 256; + +#[derive(Clone, Debug)] +pub(crate) struct PathState { + pub(crate) depth: usize, + pub(crate) call_depth: usize, + pub(crate) origin: Address, + pub(crate) origin_word: SymExpr, + pub(crate) gas_price: SymExpr, + pub(crate) ffi_enabled: bool, + pub(crate) block: SymbolicBlock, + pub(crate) frame: CallFrame, + pub(crate) world: SymbolicWorld, + pub(crate) prank: SymbolicPrank, + pub(crate) constraints: Vec, + pub(crate) next_symbol: usize, + pub(crate) recorded_logs: Option>, + pub(crate) access_record: Option, + pub(crate) root_calldata: Option, + /// Predicate context follows child calls but is not committed back to handler state. + pub(crate) invariant_predicate: bool, + corpus_seed_models: Vec>, + branch_target: Option, + branch_target_reached: bool, + needs_feasibility_check: bool, + pub(crate) loop_jumps: HashMap, + pub(crate) expected_revert: Option, + pub(crate) assume_no_revert_next_call: Option, + pub(crate) expected_emit: Option, + pub(crate) expected_calls: Vec, + pub(crate) expected_creates: Vec, + pub(crate) call_mocks: Vec, + pub(crate) function_mocks: Vec, + pub(crate) persistent_accounts: HashSet
, + pub(crate) wallets: IndexSet
, + pub(crate) labels: HashMap, + pub(crate) storage_load_hooks: HashMap, + pub(crate) storage_store_hooks: HashMap, + pub(crate) mapping_storage_store_hooks: HashMap<(Address, U256), SymbolicStorageHook>, + pub(crate) mapping_hook_keccak_preimages: HashMap<(Address, SymExpr), Arc<[SymExpr]>>, + pub(crate) storage_hook_active: bool, + pub(crate) pending_storage_hook_revert: bool, +} + +impl PathState { + pub(crate) fn new( + cx: &mut SymCx, + address: Address, + caller: Address, + callvalue: U256, + calldata: SymbolicCalldata, + ffi_enabled: bool, + ) -> Self { + let mut state = Self::empty(cx, address, caller, ffi_enabled); + let callvalue = SymExpr::constant(cx, callvalue); + let call_data = calldata.call_data(cx); + state.frame = CallFrame::new(cx, address, address, caller, callvalue, false, call_data); + state.constraints = calldata.constraints().to_vec(); + state.root_calldata = Some(calldata); + state + } + + pub(crate) fn empty( + cx: &mut SymCx, + address: Address, + caller: Address, + ffi_enabled: bool, + ) -> Self { + let origin_word = SymExpr::constant(cx, address_word(caller)); + let gas_price = SymExpr::zero(cx); + let block = SymbolicBlock::new(cx); + let callvalue = SymExpr::zero(cx); + let calldata = SymBytes::empty(cx); + let calldata = SymCalldata::from_bytes(cx, calldata); + let frame = CallFrame::new(cx, address, address, caller, callvalue, false, calldata); + Self { + depth: 0, + call_depth: 0, + origin: caller, + origin_word, + gas_price, + ffi_enabled, + block, + frame, + world: SymbolicWorld::default(), + prank: SymbolicPrank::default(), + constraints: Vec::new(), + next_symbol: 0, + recorded_logs: None, + access_record: None, + root_calldata: None, + invariant_predicate: false, + corpus_seed_models: Vec::new(), + branch_target: None, + branch_target_reached: false, + needs_feasibility_check: false, + loop_jumps: HashMap::default(), + expected_revert: None, + assume_no_revert_next_call: None, + expected_emit: None, + expected_calls: Vec::new(), + expected_creates: Vec::new(), + call_mocks: Vec::new(), + function_mocks: Vec::new(), + persistent_accounts: HashSet::default(), + wallets: IndexSet::default(), + labels: HashMap::default(), + storage_load_hooks: HashMap::default(), + storage_store_hooks: HashMap::default(), + mapping_storage_store_hooks: HashMap::default(), + mapping_hook_keccak_preimages: HashMap::default(), + storage_hook_active: false, + pending_storage_hook_revert: false, + } + } + + pub(crate) fn apply_executor_env( + &mut self, + cx: &mut SymCx, + executor: &Executor, + ) { + self.block = SymbolicBlock::from_executor(cx, executor); + let gas_price = executor + .inspector() + .cheatcodes + .as_ref() + .and_then(|cheats| cheats.gas_price) + .unwrap_or_else(|| executor.tx_env().gas_price()); + self.gas_price = SymExpr::constant(cx, U256::from(gas_price)); + if let Some(cheats) = executor.inspector().cheatcodes.as_ref() { + for (target, overwrite) in cheats.arbitrary_storage_target_overwrite_modes() { + self.world.enable_arbitrary_storage(target, overwrite); + } + for (target, source) in cheats.arbitrary_storage_copied_target_sources() { + Arc::make_mut(&mut self.world.state) + .arbitrary_storage_copies + .insert(target, source); + } + self.storage_load_hooks.extend(cheats.storage_load_hooks().map(|(target, hook)| { + ( + target, + SymbolicStorageHook { + callback_target: hook.callback_target, + callback_selector: hook.callback_selector, + }, + ) + })); + self.storage_store_hooks.extend(cheats.storage_store_hooks().map(|(target, hook)| { + ( + target, + SymbolicStorageHook { + callback_target: hook.callback_target, + callback_selector: hook.callback_selector, + }, + ) + })); + self.mapping_storage_store_hooks.extend(cheats.mapping_storage_store_hooks().map( + |(target, root, hook)| { + ( + (target, root.into()), + SymbolicStorageHook { + callback_target: hook.callback_target, + callback_selector: hook.callback_selector, + }, + ) + }, + )); + } + } + + pub(crate) fn child(&self, frame: CallFrame) -> Self { + let mut child = self.clone(); + child.call_depth += 1; + child.frame = frame; + // A prank changes the call being entered; calls made by the callee use normal EVM caller + // semantics unless the callee sets its own prank. + child.prank = SymbolicPrank::default(); + child.loop_jumps.clear(); + child.expected_revert = None; + child.assume_no_revert_next_call = None; + child + } + + pub(crate) fn storage_hook_child(&self, frame: CallFrame) -> Self { + let mut child = self.child(frame); + child.storage_hook_active = true; + child.recorded_logs = None; + child.access_record = None; + child.expected_emit = None; + child.expected_calls.clear(); + child.expected_creates.clear(); + child.call_mocks.clear(); + child.function_mocks.clear(); + child.set_branch_target(None); + child + } + + pub(crate) fn copy_call_output_offset( + &mut self, + cx: &mut SymCx, + dest: SymExpr, + size: &BoundedCopySize, + ) -> Result<(), SymbolicError> { + let CallFrame { memory, return_data, .. } = &mut self.frame; + memory.copy_call_output_offset(cx, dest, size, return_data) + } + + pub(crate) fn constrained_usize(&self, cx: &mut SymCx, expr: &SymExpr) -> Option { + self.constrained_usize_checked(cx, expr).and_then(Result::ok) + } + + pub(crate) fn constrained_usize_checked( + &self, + cx: &mut SymCx, + expr: &SymExpr, + ) -> Option> { + self.constrained_word(cx, expr).map(|value| usize::try_from(value).map_err(|_| value)) + } + + pub(crate) fn upper_bound_usize(&self, _cx: &mut SymCx, expr: &SymExpr) -> Option { + let mut bounds = HashMap::default(); + let mut ordering = HashMap::default(); + let mut remaining = MAX_BOUND_ANALYSIS_VISITS; + self.expr_upper_bound_usize_cached(expr, &mut bounds, &mut ordering, &mut remaining) + } + + /// Returns a conservative lower bound for values representable as host memory offsets. + pub(crate) fn lower_bound_usize(&self, expr: &SymExpr) -> usize { + let mut lower_bounds = HashMap::default(); + let mut upper_bounds = HashMap::default(); + let mut ordering = HashMap::default(); + let mut remaining = MAX_BOUND_ANALYSIS_VISITS; + self.expr_lower_bound_usize( + expr, + &mut lower_bounds, + &mut upper_bounds, + &mut ordering, + &mut remaining, + ) + } + + pub(crate) fn constrained_word(&self, cx: &mut SymCx, expr: &SymExpr) -> Option { + expr.as_const().or_else(|| { + self.constraints + .iter() + .find_map(|constraint| { + constraint.forces_expr_const_with_context(expr, &self.constraints) + }) + .or_else(|| self.constrained_expr_value(cx, expr)) + }) + } + + pub(crate) fn constrained_expr_value(&self, cx: &mut SymCx, expr: &SymExpr) -> Option { + if let Some(value) = expr.eval() { + return Some(value); + } + if let Some(value) = expr.known_word() { + return Some(value); + } + + let mut vars = SymbolicVars::default(); + expr.collect_eval_vars(&mut vars); + let mut model = SymbolicModel::default(); + for var in vars { + let var_expr = SymExpr::get_var(cx, var); + let value = self.constraints.iter().find_map(|constraint| { + constraint.forces_expr_const_with_context(&var_expr, &self.constraints) + })?; + model.insert(var, value); + } + + expr.eval_model(&model).ok() + } + + pub(crate) fn split_corpus_seed_models( + &self, + condition: &SymBoolExpr, + ) -> (Vec>, Vec>) { + let mut true_models = Vec::new(); + let mut false_models = Vec::new(); + for model in &self.corpus_seed_models { + match condition.eval_model_if_complete(model.as_ref()) { + Ok(Some(true)) => true_models.push(Arc::clone(model)), + Ok(Some(false)) => false_models.push(Arc::clone(model)), + Ok(None) | Err(_) => { + true_models.push(Arc::clone(model)); + false_models.push(Arc::clone(model)); + } + } + } + (true_models, false_models) + } + + pub(crate) fn set_corpus_seed_models(&mut self, models: Vec>) { + self.corpus_seed_models = models; + } + + pub(crate) const fn corpus_seed_model_count(&self) -> usize { + self.corpus_seed_models.len() + } + + pub(crate) const fn set_branch_target(&mut self, target: Option) { + self.branch_target = target; + self.branch_target_reached = false; + } + + pub(crate) const fn branch_target(&self) -> Option { + self.branch_target + } + + pub(crate) const fn mark_branch_target_reached(&mut self) { + self.branch_target_reached = true; + } + + pub(crate) fn inherit_branch_target_progress(&mut self, child: &Self) { + if self.branch_target == child.branch_target && child.branch_target_reached { + self.mark_branch_target_reached(); + } + } + + pub(crate) fn take_noncommitting_check_state(&mut self, check: &mut Self) { + self.constraints = std::mem::take(&mut check.constraints); + self.next_symbol = self.next_symbol.max(check.next_symbol); + self.world.merge_replay_metadata_from(&check.world); + self.storage_load_hooks = std::mem::take(&mut check.storage_load_hooks); + self.storage_store_hooks = std::mem::take(&mut check.storage_store_hooks); + self.mapping_storage_store_hooks = std::mem::take(&mut check.mapping_storage_store_hooks); + } + + pub(crate) fn take_call_outcome_state(&mut self, child: &mut Self) { + self.constraints = std::mem::take(&mut child.constraints); + self.next_symbol = child.next_symbol; + self.inherit_branch_target_progress(child); + self.storage_load_hooks = std::mem::take(&mut child.storage_load_hooks); + self.storage_store_hooks = std::mem::take(&mut child.storage_store_hooks); + self.mapping_storage_store_hooks = std::mem::take(&mut child.mapping_storage_store_hooks); + self.mapping_hook_keccak_preimages = + std::mem::take(&mut child.mapping_hook_keccak_preimages); + self.recorded_logs = child.recorded_logs.take(); + self.access_record = child.access_record.take(); + // Inspector state survives child reverts and exceptional halts. + self.block = child.block.clone(); + self.expected_calls = std::mem::take(&mut child.expected_calls); + self.call_mocks = std::mem::take(&mut child.call_mocks); + self.function_mocks = std::mem::take(&mut child.function_mocks); + } + + pub(crate) fn take_reverted_top_level_effects(&mut self, mut reverted: Self) { + self.take_noncommitting_check_state(&mut reverted); + self.block = reverted.block; + self.recorded_logs = reverted.recorded_logs; + self.access_record = reverted.access_record; + self.expected_revert = reverted.expected_revert; + self.assume_no_revert_next_call = reverted.assume_no_revert_next_call; + self.expected_emit = reverted.expected_emit; + self.expected_calls = reverted.expected_calls; + self.expected_creates = reverted.expected_creates; + self.call_mocks = reverted.call_mocks; + self.function_mocks = reverted.function_mocks; + } + + /// Returns `true` if the path can be materialized into a replayable corpus seed. + /// + /// Gas-dependent constraints are never modeled, so a seed for such a path would carry a + /// fabricated `gasleft()` value; skip the seed rather than failing the whole run. + pub(crate) fn can_materialize_seed(&self) -> bool { + !self.constraints.iter().any(SymBoolExpr::contains_gasleft) + } + + pub(crate) const fn satisfies_branch_target(&self) -> bool { + self.branch_target.is_none() || self.branch_target_reached + } + + pub(crate) const fn defer_feasibility_check(&mut self) { + self.needs_feasibility_check = true; + } + + pub(crate) const fn take_deferred_feasibility_check(&mut self) -> bool { + let needs_check = self.needs_feasibility_check; + self.needs_feasibility_check = false; + needs_check + } + + fn expr_upper_bound_usize_cached( + &self, + expr: &SymExpr, + bounds: &mut HashMap>, + ordering: &mut HashMap<(SymExpr, SymExpr), bool>, + remaining: &mut usize, + ) -> Option { + if let Some(bound) = bounds.get(expr) { + return *bound; + } + if let Some(value) = expr.as_const() { + return usize::try_from(value).ok(); + } + + let constraint_bound = self.constraint_upper_bound_usize(expr); + let structural_bound = remaining.checked_sub(1).and_then(|next| { + *remaining = next; + match expr.kind() { + SymExprKind::Const(value) => usize::try_from(*value).ok(), + SymExprKind::Var(_) + | SymExprKind::GasLeft(_) + | SymExprKind::Keccak { .. } + | SymExprKind::Hash { .. } => None, + SymExprKind::Not(_) => None, + SymExprKind::TernOp(_, _, _, modulus) => match modulus.as_const() { + Some(modulus) if modulus.is_zero() => Some(0), + Some(modulus) => usize::try_from(modulus - U256::ONE).ok(), + None => self + .expr_upper_bound_usize_cached(modulus, bounds, ordering, remaining) + .and_then(|bound| bound.checked_sub(1)), + }, + SymExprKind::Ite(condition, left, right) => { + let left_bound = + self.expr_upper_bound_usize_cached(left, bounds, ordering, remaining); + let right_bound = + self.expr_upper_bound_usize_cached(right, bounds, ordering, remaining); + match (left_bound, right_bound) { + (Some(left_bound), Some(right_bound)) => Some(left_bound.max(right_bound)), + (None, Some(right_bound)) => condition + .implies_unsigned_less_or_equal(true, left, right, remaining) + .then_some(right_bound), + (Some(left_bound), None) => condition + .implies_unsigned_less_or_equal(false, right, left, remaining) + .then_some(left_bound), + (None, None) => None, + } + } + SymExprKind::BinOp(op, left, right) => match op { + SymBinOp::Add => self + .expr_upper_bound_usize_cached(left, bounds, ordering, remaining)? + .checked_add( + self.expr_upper_bound_usize_cached(right, bounds, ordering, remaining)?, + ), + SymBinOp::Mul => self + .expr_upper_bound_usize_cached(left, bounds, ordering, remaining)? + .checked_mul( + self.expr_upper_bound_usize_cached(right, bounds, ordering, remaining)?, + ), + SymBinOp::UDiv => { + let left = + self.expr_upper_bound_usize_cached(left, bounds, ordering, remaining)?; + match right.as_const()? { + divisor if divisor.is_zero() => Some(0), + divisor => Some(left / usize::try_from(divisor).ok()?), + } + } + SymBinOp::URem => match right.as_const() { + Some(divisor) if divisor.is_zero() => Some(0), + Some(divisor) => usize::try_from(divisor - U256::ONE).ok(), + None => { + self.expr_upper_bound_usize_cached(left, bounds, ordering, remaining) + } + }, + SymBinOp::And => right + .as_const() + .and_then(|value| usize::try_from(value).ok()) + .or_else(|| left.as_const().and_then(|value| usize::try_from(value).ok())) + .map(|mask| { + self.expr_upper_bound_usize_cached(left, bounds, ordering, remaining) + .or_else(|| { + self.expr_upper_bound_usize_cached( + right, bounds, ordering, remaining, + ) + }) + .map_or(mask, |bound| bound.min(mask)) + }), + SymBinOp::Shr => { + let left = + self.expr_upper_bound_usize_cached(left, bounds, ordering, remaining)?; + let shift = usize::try_from(right.as_const()?).ok()?; + Some(if shift >= usize::BITS as usize { 0 } else { left >> shift }) + } + SymBinOp::Sub => { + if let Some(difference) = left + .constant_difference(right) + .and_then(|difference| usize::try_from(difference).ok()) + { + return Some(difference); + } + let left_bound = + self.expr_upper_bound_usize_cached(left, bounds, ordering, remaining)?; + self.expressions_are_unsigned_ordered(right, left, ordering, remaining) + .then_some(left_bound) + } + SymBinOp::Shl => match right.as_const() { + Some(shift) if shift >= U256::from(256) => Some(0), + Some(shift) => usize::try_from(shift).ok().and_then(|shift| { + let bound = self + .expr_upper_bound_usize_cached(left, bounds, ordering, remaining)?; + if bound == 0 { + return Some(0); + } + let factor = 1usize.checked_shl(u32::try_from(shift).ok()?)?; + bound.checked_mul(factor) + }), + None => None, + }, + SymBinOp::SDiv + | SymBinOp::SRem + | SymBinOp::Or + | SymBinOp::Xor + | SymBinOp::Sar => None, + }, + } + }); + + let bound = match (constraint_bound, structural_bound) { + (Some(left), Some(right)) => Some(left.min(right)), + (Some(bound), None) | (None, Some(bound)) => Some(bound), + (None, None) => None, + }; + bounds.insert(expr.clone(), bound); + bound + } + + fn expressions_are_unsigned_ordered( + &self, + left: &SymExpr, + right: &SymExpr, + ordering: &mut HashMap<(SymExpr, SymExpr), bool>, + remaining: &mut usize, + ) -> bool { + if left == right { + return true; + } + let key = (left.clone(), right.clone()); + if let Some(ordered) = ordering.get(&key) { + return *ordered; + } + let Some(next) = remaining.checked_sub(1) else { return false }; + *remaining = next; + + let ordered = if let (Some(left), Some(right)) = (left.as_const(), right.as_const()) { + left <= right + } else if let SymExprKind::Ite(condition, then_value, else_value) = left.kind() { + let then_ordered = condition + .implies_unsigned_less_or_equal(true, then_value, right, remaining) + || self.expressions_are_unsigned_ordered(then_value, right, ordering, remaining); + let else_ordered = condition + .implies_unsigned_less_or_equal(false, else_value, right, remaining) + || self.expressions_are_unsigned_ordered(else_value, right, ordering, remaining); + then_ordered && else_ordered + } else if let SymExprKind::Ite(condition, then_value, else_value) = right.kind() { + let then_ordered = condition + .implies_unsigned_less_or_equal(true, left, then_value, remaining) + || self.expressions_are_unsigned_ordered(left, then_value, ordering, remaining); + let else_ordered = condition + .implies_unsigned_less_or_equal(false, left, else_value, remaining) + || self.expressions_are_unsigned_ordered(left, else_value, ordering, remaining); + then_ordered && else_ordered + } else { + false + }; + ordering.insert(key, ordered); + ordered + } + + fn expr_lower_bound_usize( + &self, + expr: &SymExpr, + lower_bounds: &mut HashMap, + upper_bounds: &mut HashMap>, + ordering: &mut HashMap<(SymExpr, SymExpr), bool>, + remaining: &mut usize, + ) -> usize { + if let Some(bound) = lower_bounds.get(expr) { + return *bound; + } + if let Some(value) = expr.as_const().and_then(|value| usize::try_from(value).ok()) { + return value; + } + let Some(next) = remaining.checked_sub(1) else { + return 0; + }; + *remaining = next; + + let bound = match expr.kind() { + SymExprKind::Const(value) => usize::try_from(*value).unwrap_or_default(), + SymExprKind::Ite(_, left, right) => { + let left = self.expr_lower_bound_usize( + left, + lower_bounds, + upper_bounds, + ordering, + remaining, + ); + let right = self.expr_lower_bound_usize( + right, + lower_bounds, + upper_bounds, + ordering, + remaining, + ); + left.min(right) + } + SymExprKind::BinOp(SymBinOp::Add, left, right) => { + let no_wrap = self + .expr_upper_bound_usize_cached(left, upper_bounds, ordering, remaining) + .and_then(|left| { + self.expr_upper_bound_usize_cached(right, upper_bounds, ordering, remaining) + .and_then(|right| left.checked_add(right)) + }) + .is_some(); + if no_wrap { + let left = self.expr_lower_bound_usize( + left, + lower_bounds, + upper_bounds, + ordering, + remaining, + ); + let right = self.expr_lower_bound_usize( + right, + lower_bounds, + upper_bounds, + ordering, + remaining, + ); + left.checked_add(right).unwrap_or_default() + } else { + 0 + } + } + SymExprKind::BinOp(SymBinOp::Sub, left, right) => left + .constant_difference(right) + .and_then(|difference| usize::try_from(difference).ok()) + .unwrap_or_default(), + SymExprKind::BinOp(SymBinOp::Or, left, right) => { + let left = self.expr_lower_bound_usize( + left, + lower_bounds, + upper_bounds, + ordering, + remaining, + ); + let right = self.expr_lower_bound_usize( + right, + lower_bounds, + upper_bounds, + ordering, + remaining, + ); + left.max(right) + } + SymExprKind::Var(_) + | SymExprKind::GasLeft(_) + | SymExprKind::Keccak { .. } + | SymExprKind::Hash { .. } + | SymExprKind::Not(_) + | SymExprKind::BinOp(_, _, _) + | SymExprKind::TernOp(_, _, _, _) => 0, + }; + lower_bounds.insert(expr.clone(), bound); + bound + } + + pub(crate) fn constraint_upper_bound_usize(&self, expr: &SymExpr) -> Option { + let mut bound: Option = None; + for constraint in &self.constraints { + if let Some(candidate) = constraint.upper_bound_usize(expr) { + bound = Some(bound.map_or(candidate, |bound| bound.min(candidate))); + } + } + bound + } + + pub(crate) fn expect_constrained_usize( + &self, + cx: &mut SymCx, + expr: SymExpr, + reason: &'static str, + ) -> Result { + self.constrained_usize(cx, &expr).ok_or(SymbolicError::Unsupported(reason)) + } + + pub(crate) fn expect_constrained_word( + &self, + cx: &mut SymCx, + expr: SymExpr, + reason: &'static str, + ) -> Result { + self.constrained_word(cx, &expr).ok_or(SymbolicError::Unsupported(reason)) + } + + pub(crate) fn bin_word(&mut self, cx: &mut SymCx, op: SymBinOp) -> Result<(), SymbolicError> { + let a = self.stack.pop()?; + let b = self.stack.pop()?; + self.stack.push(SymExpr::binop(cx, op, a, b)) + } + + pub(crate) fn bin_word_div_zero_guard( + &mut self, + cx: &mut SymCx, + op: SymBinOp, + ) -> Result<(), SymbolicError> { + let a = self.stack.pop()?; + let b = self.stack.pop()?; + let zero = SymExpr::zero(cx); + let condition = SymBoolExpr::eq(cx, b.clone(), zero.clone()); + let expr = SymExpr::binop(cx, op, a, b); + self.stack.push(SymExpr::ite(cx, condition, zero, expr)) + } + + pub(crate) fn cmp_word_condition( + &mut self, + cx: &mut SymCx, + op: SymCmpOp, + ) -> Result { + let a = self.stack.pop()?; + let b = self.stack.pop()?; + Ok(SymBoolExpr::cmp(cx, op, a, b)) + } + + pub(crate) fn shift_word(&mut self, cx: &mut SymCx, op: SymBinOp) -> Result<(), SymbolicError> { + let shift = self.stack.pop()?; + let value = self.stack.pop()?; + let expr = SymExpr::binop(cx, op, value, shift); + let result = expr.known_word().map(|word| SymExpr::constant(cx, word)).unwrap_or(expr); + self.stack.push(result) + } + + pub(crate) fn exp_word(&mut self, cx: &mut SymCx) -> Result<(), SymbolicError> { + let base = self.stack.pop()?; + let exponent = self.stack.pop()?; + let result = if let Some(exponent) = self.constrained_word(cx, &exponent) { + if let Some(base_value) = base.as_const() { + SymExpr::constant(cx, base_value.wrapping_pow(exponent)) + } else if exponent <= U256::from(SYMBOLIC_EXP_CONCRETE_EXPONENT_LIMIT) { + exp_expr_for_concrete_exponent( + cx, + base, + usize::try_from(exponent).expect("checked symbolic exponent"), + ) + } else { + return Err(SymbolicError::Unsupported("symbolic EXP base")); + } + } else { + let exponent_limit = if base.as_const().is_some() { + CONCRETE_BASE_SYMBOLIC_EXPONENT_LIMIT + } else { + SYMBOLIC_EXP_CONCRETE_EXPONENT_LIMIT + }; + let max_exponent = self + .upper_bound_usize(cx, &exponent) + .filter(|exponent| *exponent <= exponent_limit as usize) + .ok_or(SymbolicError::Unsupported("symbolic EXP exponent"))?; + let mut expr = SymExpr::zero(cx); + for candidate in (0..=max_exponent).rev() { + let candidate_expr = SymExpr::constant(cx, U256::from(candidate)); + let condition = SymBoolExpr::eq(cx, exponent.clone(), candidate_expr); + let value = exp_expr_for_concrete_exponent(cx, base.clone(), candidate); + expr = SymExpr::ite(cx, condition, value, expr); + } + expr + }; + self.stack.push(result) + } + + pub(crate) fn balance( + &self, + cx: &mut SymCx, + executor: &Executor, + address: Address, + ) -> SymExpr { + self.world.balance_word_for_address(cx, executor, address) + } + + pub(crate) fn balance_word( + &mut self, + cx: &mut SymCx, + executor: &Executor, + address_expr: SymExpr, + ) -> Result { + self.world.balance_word(cx, executor, address_expr) + } + + pub(crate) fn extcode_size_word( + &mut self, + cx: &mut SymCx, + executor: &Executor, + address_expr: SymExpr, + ) -> Result { + self.world.extcode_size_word(cx, executor, address_expr) + } + + pub(crate) fn extcode_hash_word( + &mut self, + cx: &mut SymCx, + executor: &Executor, + address_expr: SymExpr, + ) -> Result { + self.world.extcode_hash_word(cx, executor, address_expr) + } + + pub(crate) fn extcode_bytes_word( + &mut self, + cx: &mut SymCx, + executor: &Executor, + address_expr: SymExpr, + offset: SymExpr, + size: usize, + ) -> Result { + self.world.extcode_bytes_word(cx, executor, address_expr, offset, size) + } + + pub(crate) fn pop_address_word_or_symbolic_slot( + &mut self, + cx: &mut SymCx, + ) -> Result<(SymExpr, Address), SymbolicError> { + let expr = self.stack.pop()?; + let address = self.address_or_symbolic_slot(cx, expr.clone()); + Ok((expr, address)) + } + + pub(crate) fn address_or_symbolic_slot(&mut self, cx: &mut SymCx, expr: SymExpr) -> Address { + if let Some(value) = self.constrained_word(cx, &expr) { + return word_to_address(value); + } + self.world.resolve_address(&expr).unwrap_or_else(|| self.world.symbolic_address_slot(expr)) + } + + pub(crate) fn fresh_word(&mut self, cx: &mut SymCx, prefix: &'static str) -> SymExpr { + let id = self.next_symbol; + self.next_symbol += 1; + SymExpr::var(cx, &format!("{prefix}_{id}")) + } + + pub(crate) fn fresh_gasleft(&mut self, cx: &mut SymCx) -> SymExpr { + let id = self.next_symbol; + self.next_symbol += 1; + SymExpr::gas_left(cx, id) + } + + pub(crate) fn fresh_bounded_uint(&mut self, cx: &mut SymCx, bits: U256) -> SymExpr { + let value = self.fresh_word(cx, "symbolic"); + if bits < U256::from(256) { + let upper = if bits.is_zero() { + U256::ZERO + } else { + U256::ONE << usize::try_from(bits).expect("checked bit width") + }; + self.constraints.push(SymBoolExpr::cmp_word_const(cx, SymCmpOp::Ult, &value, upper)); + } + value + } + + pub(crate) fn fresh_bytes(&mut self, cx: &mut SymCx, len: usize) -> Vec { + (0..len).map(|_| self.fresh_bounded_uint(cx, U256::from(8))).collect() + } + + pub(crate) fn fresh_printable_ascii_bytes( + &mut self, + cx: &mut SymCx, + len: usize, + ) -> Vec { + (0..len) + .map(|_| { + let byte = self.fresh_bounded_uint(cx, U256::from(8)); + self.constraints.push(SymBoolExpr::cmp_word_const( + cx, + SymCmpOp::Uge, + &byte, + U256::from(0x20), + )); + self.constraints.push(SymBoolExpr::cmp_word_const( + cx, + SymCmpOp::Ule, + &byte, + U256::from(0x7e), + )); + byte + }) + .collect() + } + + pub(crate) fn fresh_bounded_int(&mut self, cx: &mut SymCx, bits: U256) -> SymExpr { + let value = self.fresh_word(cx, "symbolic"); + if bits.is_zero() { + self.constraints.push(SymBoolExpr::eq_word_const(cx, &value, U256::ZERO)); + } else if bits < U256::from(256) { + let magnitude = U256::ONE << (usize::try_from(bits).expect("checked bit width") - 1); + let lt = SymBoolExpr::cmp_word_const(cx, SymCmpOp::Ult, &value, magnitude); + let ge = SymBoolExpr::cmp_word_const( + cx, + SymCmpOp::Uge, + &value, + U256::ZERO.wrapping_sub(magnitude), + ); + let condition = SymBoolExpr::or(cx, vec![lt, ge]); + self.constraints.push(condition); + } + value + } + + pub(crate) fn prank_for_next_call(&mut self) -> (Address, SymExpr, Option<(Address, SymExpr)>) { + if let Some((caller, caller_word)) = self.prank.next_caller.take() { + (caller, caller_word, self.prank.next_origin.take()) + } else { + match self.prank.persistent_caller.clone() { + Some((caller, caller_word)) => { + (caller, caller_word, self.prank.persistent_origin.clone()) + } + None => { + (self.address, self.address_word.clone(), self.prank.persistent_origin.clone()) + } + } + } + } + + pub(crate) fn read_callers_words(&self, cx: &mut SymCx) -> Vec { + let (mode, caller, origin) = if let Some((_, caller_word)) = self.prank.next_caller.as_ref() + { + ( + U256::from(3), + caller_word.clone(), + self.prank + .next_origin + .as_ref() + .map(|(_, origin_word)| origin_word.clone()) + .unwrap_or_else(|| self.origin_word.clone()), + ) + } else if let Some((_, caller_word)) = self.prank.persistent_caller.as_ref() { + ( + U256::from(4), + caller_word.clone(), + self.prank + .persistent_origin + .as_ref() + .map(|(_, origin_word)| origin_word.clone()) + .unwrap_or_else(|| self.origin_word.clone()), + ) + } else { + (U256::ZERO, self.caller_word.clone(), self.origin_word.clone()) + }; + vec![SymExpr::constant(cx, mode), caller, origin] + } + + pub(crate) fn record_log(&mut self, log: SymbolicLog) { + if let Some(logs) = &mut self.recorded_logs { + logs.push(log); + } + } + + pub(crate) fn record_sload(&mut self, address: Address, slot: SymExpr) { + if let Some(record) = &mut self.access_record { + record.read(address, slot); + } + } + + pub(crate) fn record_sstore(&mut self, address: Address, slot: SymExpr) { + if let Some(record) = &mut self.access_record { + record.write(address, slot); + } + } + + pub(crate) fn expectations_satisfied(&self) -> bool { + self.expected_revert.is_none() + && self.expected_emit.as_ref().is_none_or(ExpectedEmit::is_satisfied) + && self.expected_calls.iter().all(ExpectedCall::is_satisfied) + && self.expected_creates.is_empty() + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct SymbolicStorageHook { + pub(crate) callback_target: Address, + pub(crate) callback_selector: [u8; 4], +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct SymbolicLog { + topics: Arc<[SymExpr]>, + data_len: SymExpr, + data: SymBytes, + emitter: Address, +} + +impl SymbolicLog { + pub(crate) fn new( + topics: Vec, + data_len: SymExpr, + data: SymBytes, + emitter: Address, + ) -> Self { + Self { topics: topics.into(), data_len, data, emitter } + } + + pub(crate) fn into_parts(self) -> (Arc<[SymExpr]>, SymExpr, SymBytes, Address) { + (self.topics, self.data_len, self.data, self.emitter) + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct AccessRecord { + reads: HashMap>, + writes: HashMap>, +} + +impl AccessRecord { + pub(crate) fn read(&mut self, address: Address, slot: SymExpr) { + Self::push_unique_slot(self.reads.entry(address).or_default(), slot); + } + + pub(crate) fn write(&mut self, address: Address, slot: SymExpr) { + self.read(address, slot.clone()); + Self::push_unique_slot(self.writes.entry(address).or_default(), slot); + } + + pub(crate) fn addresses(&self) -> Vec
{ + let mut addresses = HashSet::
::default(); + addresses.extend(self.reads.keys().copied()); + addresses.extend(self.writes.keys().copied()); + let mut addresses = addresses.into_iter().collect::>(); + addresses.sort_unstable(); + addresses + } + + pub(crate) fn read_slots(&self, address: Address) -> Vec { + self.reads.get(&address).cloned().unwrap_or_default() + } + + pub(crate) fn write_slots(&self, address: Address) -> Vec { + self.writes.get(&address).cloned().unwrap_or_default() + } + + fn push_unique_slot(slots: &mut Vec, slot: SymExpr) { + if !slots.iter().any(|existing| existing == &slot) { + slots.push(slot); + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ExpectedRevert { + data: ExpectedRevertData, + reverter: Option, + remaining: u64, +} + +impl ExpectedRevert { + pub(crate) fn new(data: ExpectedRevertData, reverter: Option, remaining: u64) -> Self { + Self { data, reverter, remaining: remaining.max(1) } + } + + pub(crate) const fn consume_one(&mut self) -> bool { + self.remaining = self.remaining.saturating_sub(1); + self.remaining == 0 + } + + pub(crate) fn match_condition( + &self, + cx: &mut SymCx, + reverter: Address, + return_data: &SymReturnData, + ) -> Option { + let mut conditions = Vec::new(); + if let Some(expected_reverter) = &self.reverter { + conditions.push(expected_reverter.address_match_condition(cx, reverter)); + } + match &self.data { + ExpectedRevertData::Any => {} + ExpectedRevertData::Prefix(prefix) => { + if return_data.len() < prefix.len() { + return None; + } + let prefix_len = SymExpr::constant(cx, U256::from(prefix.len())); + conditions.push(SymBoolExpr::cmp_word_expr( + cx, + SymCmpOp::Uge, + &return_data.len_word, + prefix_len, + )); + conditions.extend((0..prefix.len()).map(|offset| { + let expected = prefix.byte(cx, offset); + let actual = return_data.byte(cx, offset); + SymBoolExpr::eq(cx, actual, expected) + })); + } + ExpectedRevertData::Exact(data) => { + if return_data.len() < data.len() { + return None; + } + let len = SymExpr::constant(cx, U256::from(data.len())); + conditions.push(SymBoolExpr::eq(cx, return_data.len_word.clone(), len)); + conditions.extend((0..data.len()).map(|offset| { + let expected = data.byte(cx, offset); + let actual = return_data.byte(cx, offset); + SymBoolExpr::eq(cx, actual, expected) + })); + } + } + Some(SymBoolExpr::and(cx, conditions)) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ExpectedRevertData { + Any, + Prefix(SymBytes), + Exact(SymBytes), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum AssumeNoRevert { + Any, + Filtered(Vec), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ExpectedCall { + callee: SymExpr, + value: Option, + gas: Option, + min_gas: Option, + data: SymBytes, + expected: u64, + observed: u64, + exact: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ExpectedCreate { + bytecode: Vec, + deployer: SymExpr, + kind: CreateKind, +} + +impl ExpectedCreate { + pub(crate) const fn new(bytecode: Vec, deployer: SymExpr, kind: CreateKind) -> Self { + Self { bytecode, deployer, kind } + } + + pub(crate) fn match_condition( + &self, + cx: &mut SymCx, + deployer: Address, + kind: CreateKind, + bytecode: &[u8], + ) -> Option { + (self.kind == kind && self.bytecode == bytecode) + .then(|| self.deployer.address_match_condition(cx, deployer)) + } +} + +impl ExpectedCall { + pub(crate) fn new( + callee: SymExpr, + value: Option, + gas: Option, + min_gas: Option, + data: SymBytes, + count: Option, + ) -> Self { + let (gas, min_gas) = if value.is_some_and(|value| !value.is_zero()) { + ( + gas.map(|gas| gas.saturating_add(CALL_STIPEND)), + min_gas.map(|gas| gas.saturating_add(CALL_STIPEND)), + ) + } else { + (gas, min_gas) + }; + Self { + callee, + value, + gas, + min_gas, + data, + expected: count.unwrap_or(1), + observed: 0, + exact: count.is_some(), + } + } + + pub(crate) const fn value(&self) -> Option { + self.value + } + + pub(crate) fn match_condition( + &self, + cx: &mut SymCx, + callee: Address, + value: Option, + gas: &SymExpr, + calldata: &SymBytes, + ) -> Result, SymbolicError> { + if !self.value.is_none_or(|expected| value.is_some_and(|value| expected == value)) + || !self.gas_matches(gas, value)? + { + return Ok(None); + } + let Some(data_condition) = calldata.prefix_condition(cx, &self.data) else { + return Ok(None); + }; + let callee_condition = self.callee.address_match_condition(cx, callee); + Ok(Some(SymBoolExpr::and(cx, vec![callee_condition, data_condition]))) + } + + fn gas_matches(&self, gas: &SymExpr, value: Option) -> Result { + if self.gas.is_none() && self.min_gas.is_none() { + return Ok(true); + } + let mut gas = gas.as_const_or("symbolic expected call gas")?; + if value.is_some_and(|value| !value.is_zero()) { + gas = gas.saturating_add(U256::from(CALL_STIPEND)); + } + Ok(self.gas.is_none_or(|expected| gas == U256::from(expected)) + && self.min_gas.is_none_or(|expected| gas >= U256::from(expected))) + } + + pub(crate) const fn observe(&mut self) -> bool { + if self.exact && self.observed >= self.expected { + return false; + } + self.observed = self.observed.saturating_add(1); + true + } + + pub(crate) const fn is_satisfied(&self) -> bool { + if self.exact { self.observed == self.expected } else { self.observed >= self.expected } + } +} + +/// Registers an expected call using the concrete cheatcode's keyed-additive semantics. +pub(crate) fn register_expected_call( + expected_calls: &mut Vec, + cx: &mut SymCx, + expected: ExpectedCall, +) -> Result<(), &'static str> { + if let Some(existing) = expected_calls + .iter_mut() + .find(|call| call.callee == expected.callee && call.data.same_bytes(cx, &expected.data)) + { + if expected.exact { + return Err("counted expected calls can only bet set once"); + } + if existing.exact { + return Err("cannot overwrite a counted expectCall with a non-counted expectCall"); + } + existing.expected += 1; + } else { + expected_calls.push(expected); + } + Ok(()) +} + +#[derive(Clone, Debug)] +pub(crate) struct CallMock { + pub(crate) callee: SymExpr, + value: Option, + pub(crate) data: SymBytes, + returns: Vec, + reverts: bool, + calls: usize, +} + +impl CallMock { + pub(crate) const fn new( + callee: SymExpr, + value: Option, + data: SymBytes, + returns: Vec, + reverts: bool, + ) -> Self { + Self { callee, value, data, returns, reverts, calls: 0 } + } + + pub(crate) const fn value(&self) -> Option { + self.value + } + + pub(crate) fn specificity(&self) -> (usize, bool) { + (self.data.len(), self.value.is_some()) + } + + pub(crate) fn match_condition( + &self, + cx: &mut SymCx, + callee: Address, + value: Option, + calldata: &SymBytes, + ) -> Option { + if !self.value.is_none_or(|expected| value.is_some_and(|value| expected == value)) { + return None; + } + let data_condition = calldata.prefix_condition(cx, &self.data)?; + let callee_condition = self.callee.address_match_condition(cx, callee); + Some(SymBoolExpr::and(cx, vec![callee_condition, data_condition])) + } + + pub(crate) fn next_outcome(&mut self, cx: &mut SymCx) -> CallMockOutcome { + let idx = self.calls.min(self.returns.len().saturating_sub(1)); + self.calls = self.calls.saturating_add(1); + CallMockOutcome { + return_data: self.returns.get(idx).cloned().unwrap_or_else(|| SymReturnData::empty(cx)), + reverts: self.reverts, + } + } +} + +#[derive(Clone, Debug)] +pub(crate) struct CallMockOutcome { + return_data: SymReturnData, + reverts: bool, +} + +impl CallMockOutcome { + pub(crate) fn into_parts(self) -> (SymReturnData, bool) { + (self.return_data, self.reverts) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct FunctionMock { + callee: SymExpr, + target: Address, + data: SymBytes, +} + +impl FunctionMock { + pub(crate) const fn new(callee: SymExpr, target: Address, data: SymBytes) -> Self { + Self { callee, target, data } + } + + pub(crate) fn matches_definition( + &self, + cx: &mut SymCx, + callee: &SymExpr, + data: &SymBytes, + ) -> bool { + self.callee == *callee && self.data.same_bytes(cx, data) + } + + pub(crate) const fn set_target(&mut self, target: Address) { + self.target = target; + } + + pub(crate) fn calldata_len(&self) -> usize { + self.data.len() + } + + pub(crate) const fn target(&self) -> Address { + self.target + } + + pub(crate) fn match_condition( + &self, + cx: &mut SymCx, + callee: Address, + calldata: &SymBytes, + ) -> Option { + let data_condition = calldata.prefix_condition(cx, &self.data)?; + let callee_condition = self.callee.address_match_condition(cx, callee); + Some(SymBoolExpr::and(cx, vec![callee_condition, data_condition])) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ExpectedEmit { + checks: ExpectedEmitChecks, + emitter: Option, + remaining: u64, + template: Option, +} + +impl ExpectedEmit { + pub(crate) fn new( + checks: ExpectedEmitChecks, + emitter: Option, + remaining: u64, + ) -> Self { + Self { checks, emitter, remaining: remaining.max(1), template: None } + } + + pub(crate) const fn is_satisfied(&self) -> bool { + self.template.is_none() && self.remaining == 0 + } + + pub(crate) const fn template(&self) -> Option<&SymbolicLog> { + self.template.as_ref() + } + + pub(crate) fn set_template(&mut self, log: SymbolicLog) { + self.template = Some(log); + } + + pub(crate) fn consume_one(&mut self) -> bool { + self.remaining = self.remaining.saturating_sub(1); + if self.remaining == 0 { + self.template = None; + true + } else { + false + } + } + + pub(crate) fn match_condition( + &self, + cx: &mut SymCx, + template: &SymbolicLog, + actual: &SymbolicLog, + ) -> Option { + let mut conditions = Vec::new(); + if let Some(expected_emitter) = &self.emitter { + conditions.push(expected_emitter.address_match_condition(cx, actual.emitter)); + } + for (idx, &check_topic) in self.checks.topics.iter().enumerate() { + if !check_topic { + continue; + } + match (template.topics.get(idx), actual.topics.get(idx)) { + (Some(left), Some(right)) => { + conditions.push(SymBoolExpr::eq(cx, left.clone(), right.clone())); + } + (None, None) => {} + _ => return None, + } + } + + if self.checks.data { + conditions.push(SymBoolExpr::eq( + cx, + template.data_len.clone(), + actual.data_len.clone(), + )); + if template.data.len() != actual.data.len() { + return None; + } + conditions.extend((0..template.data.len()).map(|idx| { + let template = template.data.byte(cx, idx); + let actual = actual.data.byte(cx, idx); + SymBoolExpr::eq(cx, template, actual) + })); + } + + Some(SymBoolExpr::and(cx, conditions)) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct ExpectedEmitChecks { + topics: [bool; 4], + data: bool, +} + +impl Default for ExpectedEmitChecks { + fn default() -> Self { + Self { topics: [true; 4], data: true } + } +} + +impl ExpectedEmitChecks { + pub(crate) fn from_non_anonymous_args( + cx: &mut SymCx, + memory: &SymMemory, + args_offset: usize, + ) -> Result { + Ok(Self { + topics: [ + true, + read_abi_bool_arg(cx, memory, args_offset, 0, "symbolic vm.expectEmit")?, + read_abi_bool_arg(cx, memory, args_offset, 1, "symbolic vm.expectEmit")?, + read_abi_bool_arg(cx, memory, args_offset, 2, "symbolic vm.expectEmit")?, + ], + data: read_abi_bool_arg(cx, memory, args_offset, 3, "symbolic vm.expectEmit")?, + }) + } + + pub(crate) fn from_anonymous_args( + cx: &mut SymCx, + memory: &SymMemory, + args_offset: usize, + ) -> Result { + Ok(Self { + topics: [ + read_abi_bool_arg(cx, memory, args_offset, 0, "symbolic vm.expectEmitAnonymous")?, + read_abi_bool_arg(cx, memory, args_offset, 1, "symbolic vm.expectEmitAnonymous")?, + read_abi_bool_arg(cx, memory, args_offset, 2, "symbolic vm.expectEmitAnonymous")?, + read_abi_bool_arg(cx, memory, args_offset, 3, "symbolic vm.expectEmitAnonymous")?, + ], + data: read_abi_bool_arg(cx, memory, args_offset, 4, "symbolic vm.expectEmitAnonymous")?, + }) + } +} + +impl Deref for PathState { + type Target = CallFrame; + + fn deref(&self) -> &Self::Target { + &self.frame + } +} + +impl DerefMut for PathState { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.frame + } +} + +#[derive(Clone, Debug)] +pub(crate) struct CallFrame { + pub(crate) pc: usize, + pub(crate) address: Address, + pub(crate) address_word: SymExpr, + pub(crate) storage_address: Address, + pub(crate) caller: Address, + pub(crate) caller_word: SymExpr, + pub(crate) callvalue: SymExpr, + pub(crate) is_static: bool, + pub(crate) calldata: SymCalldata, + pub(crate) stack: SymStack, + pub(crate) memory: SymMemory, + pub(crate) return_data: SymReturnData, +} + +impl CallFrame { + pub(crate) fn new( + cx: &mut SymCx, + address: Address, + storage_address: Address, + caller: Address, + callvalue: SymExpr, + is_static: bool, + calldata: SymCalldata, + ) -> Self { + Self { + pc: 0, + address, + address_word: SymExpr::constant(cx, address_word(address)), + storage_address, + caller, + caller_word: SymExpr::constant(cx, address_word(caller)), + callvalue, + is_static, + calldata, + stack: SymStack::default(), + memory: SymMemory::default(), + return_data: SymReturnData::empty(cx), + } + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct SymbolicPrank { + next_caller: Option<(Address, SymExpr)>, + next_origin: Option<(Address, SymExpr)>, + persistent_caller: Option<(Address, SymExpr)>, + persistent_origin: Option<(Address, SymExpr)>, +} + +impl SymbolicPrank { + pub(crate) fn set_next( + &mut self, + caller: (Address, SymExpr), + origin: Option<(Address, SymExpr)>, + ) { + self.next_caller = Some(caller); + self.next_origin = origin; + } + + pub(crate) fn set_persistent( + &mut self, + caller: (Address, SymExpr), + origin: Option<(Address, SymExpr)>, + ) { + self.persistent_caller = Some(caller); + self.persistent_origin = origin; + } + + pub(crate) const fn has_active(&self) -> bool { + self.next_caller.is_some() + || self.next_origin.is_some() + || self.persistent_caller.is_some() + || self.persistent_origin.is_some() + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct StorageWrite { + address: Address, + key: SymExpr, + value: SymExpr, +} + +impl StorageWrite { + pub(crate) const fn new(address: Address, key: SymExpr, value: SymExpr) -> Self { + Self { address, key, value } + } + + pub(crate) fn select_from( + cx: &mut SymCx, + writes: &[Self], + address: Address, + key: SymExpr, + base: SymExpr, + ) -> SymExpr { + let mut value = base; + for write in writes.iter().filter(|write| write.address == address) { + value = + key.clone().select_storage_write(cx, write.key.clone(), write.value.clone(), value); + } + value + } +} + +/// World data shared copy-on-write by branched paths and snapshots. +#[derive(Clone, Debug, Default)] +pub(crate) struct SymbolicWorldState { + storage: Vec, + transient_storage: Vec, + created_accounts: HashSet
, + current_transaction_created_accounts: HashSet
, + balances: HashMap, + code_cache: HashMap, + nonces: HashMap, + existing_accounts: HashSet
, + destroyed_accounts: HashSet
, + arbitrary_storage_accounts: HashMap, + arbitrary_storage_copies: HashMap, + arbitrary_storage_all: bool, + zero_init_symbolic_storage: bool, + symbolic_address_aliases: HashMap, + replay_storage_slots: HashMap>, +} + +#[derive(Clone, Debug)] +struct SymbolicReplayStorageSlot { + address: Address, + slot: U256, +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct SymbolicWorld { + state: Arc, + snapshots: HashMap>, + next_snapshot_id: u64, +} + +impl Deref for SymbolicWorld { + type Target = SymbolicWorldState; + + fn deref(&self) -> &Self::Target { + &self.state + } +} + +impl SymbolicWorld { + pub(crate) fn is_destroyed(&self, address: Address) -> bool { + self.destroyed_accounts.contains(&address) + } + + pub(crate) fn set_storage_layout(&mut self, layout: SymbolicStorageLayout) { + let state = Arc::make_mut(&mut self.state); + state.arbitrary_storage_all = matches!(layout, SymbolicStorageLayout::Generic); + state.zero_init_symbolic_storage = matches!(layout, SymbolicStorageLayout::ZeroInit); + } + + pub(crate) fn sload( + &mut self, + cx: &mut SymCx, + executor: &Executor, + address: Address, + key: SymExpr, + concrete_key: Option, + ) -> Result { + let base = self.storage_base(cx, executor, address, &key, concrete_key)?; + let read_key = concrete_key.map(|key| SymExpr::constant(cx, key)).unwrap_or(key); + Ok(StorageWrite::select_from(cx, &self.storage, address, read_key, base)) + } + + pub(crate) fn sstore(&mut self, address: Address, key: SymExpr, value: SymExpr) { + Arc::make_mut(&mut self.state).storage.push(StorageWrite::new(address, key, value)); + } + + pub(crate) fn tload(&self, cx: &mut SymCx, address: Address, key: SymExpr) -> SymExpr { + let base = SymExpr::zero(cx); + StorageWrite::select_from(cx, &self.transient_storage, address, key, base) + } + + pub(crate) fn tstore(&mut self, address: Address, key: SymExpr, value: SymExpr) { + Arc::make_mut(&mut self.state) + .transient_storage + .push(StorageWrite::new(address, key, value)); + } + + /// Clears transaction-scoped state at a top-level call boundary. + pub(crate) fn clear_transaction_scoped_state(&mut self) { + let state = Arc::make_mut(&mut self.state); + state.transient_storage.clear(); + state.current_transaction_created_accounts.clear(); + } + + pub(crate) fn mark_current_transaction_created(&mut self, address: Address) { + let state = Arc::make_mut(&mut self.state); + state.created_accounts.insert(address); + state.current_transaction_created_accounts.insert(address); + } + + /// Returns whether `address` was created in the current top-level symbolic transaction. + pub(crate) fn was_created_in_current_transaction(&self, address: Address) -> bool { + self.current_transaction_created_accounts.contains(&address) + } + + pub(crate) fn enable_arbitrary_storage(&mut self, address: Address, overwrite: bool) { + Arc::make_mut(&mut self.state).arbitrary_storage_accounts.insert(address, overwrite); + } + + pub(crate) fn replay_storage_symbols(&self) -> SymbolicVars { + self.replay_storage_slots.keys().copied().collect() + } + + pub(crate) fn replay_storage_assignments( + &self, + model: &SymbolicModel, + ) -> Result, SymbolicError> { + let mut assignments = std::collections::BTreeMap::<(Address, U256), U256>::new(); + for (symbol, slots) in &self.replay_storage_slots { + let Some(value) = model.get(symbol).copied() else { continue }; + for slot in slots { + match assignments.entry((slot.address, slot.slot)) { + std::collections::btree_map::Entry::Vacant(entry) => { + entry.insert(value); + } + std::collections::btree_map::Entry::Occupied(entry) + if *entry.get() == value => {} + std::collections::btree_map::Entry::Occupied(_) => { + return Err(SymbolicError::Solver( + "conflicting symbolic storage replay assignments".to_string(), + )); + } + } + } + } + Ok(assignments + .into_iter() + .map(|((address, slot), value)| SymbolicStorageAssignment { address, slot, value }) + .collect()) + } + + pub(crate) fn resolve_address(&self, expr: &SymExpr) -> Option
{ + expr.as_const().map(word_to_address).or_else(|| { + self.symbolic_address_aliases.get(expr).copied().or_else(|| { + self.symbolic_address_aliases.iter().find_map(|(alias, address)| { + expr.symbolic_address_equivalent(alias).then_some(*address) + }) + }) + }) + } + + pub(crate) fn symbolic_address_slot(&mut self, expr: SymExpr) -> Address { + if let Some(address) = self.resolve_address(&expr) { + return address; + } + let address = expr.representative_symbolic_address(); + Arc::make_mut(&mut self.state).symbolic_address_aliases.insert(expr, address); + address + } + + pub(crate) fn symbolic_word_for_address(&self, address: Address) -> Option { + self.symbolic_address_aliases + .iter() + .find_map(|(word, slot)| (*slot == address).then(|| word.clone())) + } + + pub(crate) fn snapshot_state(&mut self) -> U256 { + let id = U256::from(self.next_snapshot_id); + self.next_snapshot_id = self.next_snapshot_id.saturating_add(1); + self.snapshots.insert(id, Arc::clone(&self.state)); + id + } + + pub(crate) fn restore_snapshot(&mut self, id: U256) -> bool { + let Some(snapshot) = self.snapshots.get(&id) else { + return false; + }; + self.state = Arc::clone(snapshot); + true + } + + pub(crate) fn delete_snapshot(&mut self, id: U256) -> bool { + self.snapshots.remove(&id).is_some() + } + + pub(crate) fn delete_snapshots(&mut self) { + self.snapshots.clear(); + } + + pub(crate) fn storage_base( + &mut self, + cx: &mut SymCx, + executor: &Executor, + address: Address, + key: &SymExpr, + concrete_key: Option, + ) -> Result { + if let Some(base) = self.arbitrary_storage_base(cx, executor, address, key, concrete_key)? { + return Ok(base); + } + if self.created_accounts.contains(&address) { + return Ok(SymExpr::zero(cx)); + } + if let Some(key) = concrete_key { + return executor + .backend() + .storage_ref(address, key) + .map(|value| SymExpr::constant(cx, value)) + .map_err(|err| SymbolicError::Backend(err.to_string())); + } + if let Some(key) = key.as_const() { + executor + .backend() + .storage_ref(address, key) + .map(|value| SymExpr::constant(cx, value)) + .map_err(|err| SymbolicError::Backend(err.to_string())) + } else if self.zero_init_symbolic_storage { + Ok(SymExpr::zero(cx)) + } else { + let name = symbolic_storage_symbol(cx, address, key); + Ok(SymExpr::get_var(cx, name)) + } + } + + fn arbitrary_storage_base( + &mut self, + cx: &mut SymCx, + executor: &Executor, + address: Address, + key: &SymExpr, + concrete_key: Option, + ) -> Result, SymbolicError> { + if let Some(slot) = concrete_key.or_else(|| key.as_const()) + && !self.arbitrary_storage_all + { + let overwrite_arbitrary_storage = + self.arbitrary_storage_accounts.get(&address).copied(); + let has_arbitrary_storage = overwrite_arbitrary_storage.is_some(); + let is_copied_storage = + !has_arbitrary_storage && self.arbitrary_storage_copies.contains_key(&address); + let preserve_nonzero_slot = + overwrite_arbitrary_storage == Some(false) || is_copied_storage; + if preserve_nonzero_slot { + let concrete = executor + .backend() + .storage_ref(address, slot) + .map_err(|err| SymbolicError::Backend(err.to_string()))?; + if !concrete.is_zero() { + return Ok(Some(SymExpr::constant(cx, concrete))); + } + } + } + + Ok(self.unchecked_arbitrary_storage_base(cx, address, key, concrete_key)) + } + + fn unchecked_arbitrary_storage_base( + &mut self, + cx: &mut SymCx, + address: Address, + key: &SymExpr, + concrete_key: Option, + ) -> Option { + let overwrite_arbitrary_storage = self.arbitrary_storage_accounts.get(&address).copied(); + let has_arbitrary_storage = overwrite_arbitrary_storage.is_some(); + let copied_source = (!has_arbitrary_storage) + .then(|| self.arbitrary_storage_copies.get(&address).copied()) + .flatten(); + let symbol_address = if self.arbitrary_storage_all || has_arbitrary_storage { + address + } else { + copied_source? + }; + let symbol = symbolic_storage_symbol(cx, symbol_address, key); + if let Some(slot) = concrete_key.or_else(|| key.as_const()) { + if has_arbitrary_storage { + self.record_replay_storage_slot(symbol, address, slot); + } + if let Some(source) = copied_source { + self.record_replay_storage_slot(symbol, source, slot); + self.record_replay_storage_slot(symbol, address, slot); + } + } + let value = SymExpr::get_var(cx, symbol); + if let Some(source) = copied_source { + self.sstore(source, key.clone(), value.clone()); + } + Some(value) + } + + fn record_replay_storage_slot(&mut self, symbol: Symbol, address: Address, slot: U256) { + if self.replay_storage_slots.get(&symbol).is_some_and(|slots| { + slots.iter().any(|existing| existing.address == address && existing.slot == slot) + }) { + return; + } + Arc::make_mut(&mut self.state) + .replay_storage_slots + .entry(symbol) + .or_default() + .push(SymbolicReplayStorageSlot { address, slot }); + } + + fn merge_replay_metadata_from(&mut self, other: &Self) { + for (symbol, slots) in &other.replay_storage_slots { + for slot in slots { + self.record_replay_storage_slot(*symbol, slot.address, slot.slot); + } + } + let state = Arc::make_mut(&mut self.state); + for (expr, address) in &other.symbolic_address_aliases { + state.symbolic_address_aliases.entry(expr.clone()).or_insert(*address); + } + } + + pub(crate) fn backend_balance( + &self, + executor: &Executor, + address: Address, + ) -> U256 { + executor + .backend() + .basic_ref(address) + .ok() + .flatten() + .map(|account| account.balance) + .unwrap_or_default() + } + + pub(crate) fn balance_word_for_address( + &self, + cx: &mut SymCx, + executor: &Executor, + address: Address, + ) -> SymExpr { + if self.destroyed_accounts.contains(&address) { + return SymExpr::zero(cx); + } + self.balances + .get(&address) + .cloned() + .unwrap_or_else(|| SymExpr::constant(cx, self.backend_balance(executor, address))) + } + + pub(crate) fn balance_word( + &mut self, + cx: &mut SymCx, + executor: &Executor, + address_expr: SymExpr, + ) -> Result { + if let Some(address) = self.resolve_address(&address_expr) { + return Ok(self.balance_word_for_address(cx, executor, address)); + } + + let expr = address_expr; + let representative = expr.representative_symbolic_address(); + let mut result = self.balance_word_for_address(cx, executor, representative); + for (address, balance) in &self.balances { + if self.destroyed_accounts.contains(address) { + continue; + } + let address = SymExpr::constant(cx, address_word(*address)); + let condition = SymBoolExpr::eq(cx, expr.clone(), address); + result = SymExpr::ite(cx, condition, balance.clone(), result); + } + + Ok(result) + } + + pub(crate) fn set_balance_word(&mut self, address: Address, value: SymExpr) { + let account_exists = !value.as_const().is_some_and(|value| value.is_zero()); + let state = Arc::make_mut(&mut self.state); + state.balances.insert(address, value); + if account_exists { + state.existing_accounts.insert(address); + state.destroyed_accounts.remove(&address); + } + } + + pub(crate) fn transfer( + &mut self, + cx: &mut SymCx, + executor: &Executor, + from: Address, + to: Address, + value: SymExpr, + ) { + if from == to || value.as_const().is_some_and(|value| value.is_zero()) { + return; + } + let from_balance = self.balance_word_for_address(cx, executor, from); + let to_balance = self.balance_word_for_address(cx, executor, to); + let from_balance = SymExpr::binop(cx, SymBinOp::Sub, from_balance, value.clone()); + let to_balance = SymExpr::binop(cx, SymBinOp::Add, to_balance, value); + self.set_balance_word(from, from_balance); + self.set_balance_word(to, to_balance); + } + + pub(crate) fn nonce( + &self, + executor: &Executor, + address: Address, + ) -> Result { + if self.destroyed_accounts.contains(&address) { + return Ok(self.nonces.get(&address).copied().unwrap_or_default()); + } + if let Some(nonce) = self.nonces.get(&address) { + return Ok(*nonce); + } + executor + .backend() + .basic_ref(address) + .map_err(|err| SymbolicError::Backend(err.to_string())) + .map(|account| account.map(|account| account.nonce).unwrap_or_default()) + } + + pub(crate) fn set_nonce(&mut self, address: Address, nonce: u64) { + let state = Arc::make_mut(&mut self.state); + state.nonces.insert(address, nonce); + if nonce != 0 { + state.existing_accounts.insert(address); + state.destroyed_accounts.remove(&address); + } + } + + pub(crate) fn increment_nonce( + &mut self, + executor: &Executor, + address: Address, + ) -> Result<(), SymbolicError> { + let nonce = self.nonce(executor, address)?; + self.set_nonce(address, nonce.saturating_add(1)); + Ok(()) + } + + pub(crate) fn has_code_or_nonce( + &mut self, + cx: &mut SymCx, + executor: &Executor, + address: Address, + ) -> Result { + if self.destroyed_accounts.contains(&address) { + return Ok(false); + } + Ok(!self.extcode(cx, executor, address)?.is_empty() || self.nonce(executor, address)? != 0) + } + + pub(crate) fn install_code(&mut self, address: Address, code: SymCode) { + let state = Arc::make_mut(&mut self.state); + state.code_cache.insert(address, code); + state.existing_accounts.insert(address); + state.destroyed_accounts.remove(&address); + } + + /// Implements legacy `SELFDESTRUCT` semantics. + pub(crate) fn selfdestruct_legacy( + &mut self, + cx: &mut SymCx, + executor: &Executor, + address: Address, + beneficiary: Address, + ) -> Result<(), SymbolicError> { + let balance = self.balance_word_for_address(cx, executor, address); + if beneficiary != address && !balance.as_const().is_some_and(|value| value.is_zero()) { + let beneficiary_balance = self.balance_word_for_address(cx, executor, beneficiary); + let beneficiary_balance = + SymExpr::binop(cx, SymBinOp::Add, beneficiary_balance, balance); + self.set_balance_word(beneficiary, beneficiary_balance); + } + let nonce = if self.nonces.contains_key(&address) { + None + } else { + Some(self.nonce(executor, address)?) + }; + let zero = SymExpr::zero(cx); + let empty_code = SymCode::empty(cx); + let state = Arc::make_mut(&mut self.state); + state.balances.insert(address, zero); + state.code_cache.insert(address, empty_code); + if let Some(nonce) = nonce { + state.nonces.insert(address, nonce); + } + state.storage.retain(|write| write.address != address); + state.transient_storage.retain(|write| write.address != address); + state.created_accounts.remove(&address); + state.current_transaction_created_accounts.remove(&address); + state.existing_accounts.remove(&address); + state.destroyed_accounts.insert(address); + Ok(()) + } + + /// Implements Cancun+ `SELFDESTRUCT` semantics for accounts not created in the current tx. + pub(crate) fn selfdestruct_cancun_existing( + &mut self, + cx: &mut SymCx, + executor: &Executor, + address: Address, + beneficiary: Address, + ) { + let balance = self.balance_word_for_address(cx, executor, address); + if beneficiary != address && !balance.as_const().is_some_and(|value| value.is_zero()) { + let beneficiary_balance = self.balance_word_for_address(cx, executor, beneficiary); + // Symbolic balances are treated as possibly non-zero, matching transfer's + // account-existence approximation. + let beneficiary_balance = + SymExpr::binop(cx, SymBinOp::Add, beneficiary_balance, balance); + self.set_balance_word(beneficiary, beneficiary_balance); + let zero = SymExpr::zero(cx); + Arc::make_mut(&mut self.state).balances.insert(address, zero); + } + } + + pub(crate) fn account_exists( + &mut self, + cx: &mut SymCx, + executor: &Executor, + address: Address, + ) -> Result { + if address == CHEATCODE_ADDRESS || address == SYMBOLIC_VM_COMPAT_ADDRESS { + return Ok(true); + } + if self.destroyed_accounts.contains(&address) { + return Ok(false); + } + if self.existing_accounts.contains(&address) { + return Ok(true); + } + if self + .balances + .get(&address) + .is_some_and(|balance| !balance.as_const().is_some_and(|value| value.is_zero())) + || self.nonces.get(&address).is_some_and(|nonce| *nonce != 0) + || self.code_cache.get(&address).is_some_and(|code| !code.is_empty()) + { + Arc::make_mut(&mut self.state).existing_accounts.insert(address); + return Ok(true); + } + + let Some(account) = executor + .backend() + .basic_ref(address) + .map_err(|err| SymbolicError::Backend(err.to_string()))? + else { + return Ok(false); + }; + + if account.nonce != 0 || !account.balance.is_zero() { + Arc::make_mut(&mut self.state).existing_accounts.insert(address); + return Ok(true); + } + + if let Some(code) = account.code.as_ref() + && !code.is_empty() + { + let code = SymCode::from_bytecode(cx, code); + let state = Arc::make_mut(&mut self.state); + state.code_cache.insert(address, code); + state.existing_accounts.insert(address); + return Ok(true); + } + + Ok(false) + } + + pub(crate) fn extcode( + &mut self, + cx: &mut SymCx, + executor: &Executor, + address: Address, + ) -> Result { + if address == CHEATCODE_ADDRESS || address == SYMBOLIC_VM_COMPAT_ADDRESS { + return Ok(SymCode::concrete(cx, vec![0])); + } + let spec_id: SpecId = executor.spec_id().into(); + if precompile_number_for_spec(address, spec_id).is_some() { + return Ok(SymCode::empty(cx)); + } + if self.destroyed_accounts.contains(&address) { + return Ok(SymCode::empty(cx)); + } + if let Some(code) = self.code_cache.get(&address) { + return Ok(code.clone()); + } + let account = executor + .backend() + .basic_ref(address) + .map_err(|err| SymbolicError::Backend(err.to_string()))?; + if let Some(account) = account.as_ref() + && (account.nonce != 0 + || !account.balance.is_zero() + || account.code.as_ref().is_some_and(|code| !code.is_empty())) + { + Arc::make_mut(&mut self.state).existing_accounts.insert(address); + } + let bytecode = account.as_ref().and_then(|account| account.code.as_ref()); + let code = bytecode + .map(|bytecode| SymCode::from_bytecode(cx, bytecode)) + .unwrap_or_else(|| SymCode::empty(cx)); + Arc::make_mut(&mut self.state).code_cache.insert(address, code.clone()); + Ok(code) + } + + pub(crate) fn extcode_hash_for_address( + &mut self, + cx: &mut SymCx, + executor: &Executor, + address: Address, + ) -> Result { + if self.account_exists(cx, executor, address)? { + let code = self.extcode(cx, executor, address)?; + let bytes = code.read_byte_exprs(cx, 0, code.len()); + Ok(keccak_word(cx, bytes)) + } else { + Ok(SymExpr::zero(cx)) + } + } + + pub(crate) fn extcode_size_word( + &mut self, + cx: &mut SymCx, + executor: &Executor, + address_expr: SymExpr, + ) -> Result { + if let Some(address) = self.resolve_address(&address_expr) { + let len = self.extcode(cx, executor, address)?.len(); + return Ok(SymExpr::constant(cx, U256::from(len))); + } + + let expr = address_expr; + let representative = expr.representative_symbolic_address(); + let len = self.extcode(cx, executor, representative)?.len(); + let mut result = SymExpr::constant(cx, U256::from(len)); + for (address, code) in &self.code_cache { + if self.destroyed_accounts.contains(address) { + continue; + } + let address = SymExpr::constant(cx, address_word(*address)); + let condition = SymBoolExpr::eq(cx, expr.clone(), address); + let len = SymExpr::constant(cx, U256::from(code.len())); + result = SymExpr::ite(cx, condition, len, result); + } + + Ok(result) + } + + pub(crate) fn extcode_hash_word( + &mut self, + cx: &mut SymCx, + executor: &Executor, + address_expr: SymExpr, + ) -> Result { + if let Some(address) = self.resolve_address(&address_expr) { + return self.extcode_hash_for_address(cx, executor, address); + } + + let expr = address_expr; + let representative = expr.representative_symbolic_address(); + let mut result = self.extcode_hash_for_address(cx, executor, representative)?; + let cached_codes = self.code_cache.iter().collect::>(); + for (address, code) in cached_codes.into_iter().rev() { + let hash = if self.destroyed_accounts.contains(address) { + SymExpr::zero(cx) + } else { + let bytes = code.read_byte_exprs(cx, 0, code.len()); + keccak_word(cx, bytes) + }; + let address = SymExpr::constant(cx, address_word(*address)); + let condition = SymBoolExpr::eq(cx, expr.clone(), address); + result = SymExpr::ite(cx, condition, hash, result); + } + + Ok(result) + } + + pub(crate) fn extcode_bytes_word( + &mut self, + cx: &mut SymCx, + executor: &Executor, + address_expr: SymExpr, + offset: SymExpr, + size: usize, + ) -> Result { + if let Some(address) = self.resolve_address(&address_expr) { + return Ok(self.extcode(cx, executor, address)?.read_bytes_offset(cx, offset, size)); + } + + let expr = address_expr; + let representative = expr.representative_symbolic_address(); + let mut result = self.extcode(cx, executor, representative)?.read_byte_exprs_offset( + cx, + offset.clone(), + size, + ); + let cached_codes = self.code_cache.iter().collect::>(); + for (address, code) in cached_codes.into_iter().rev() { + let bytes = if self.destroyed_accounts.contains(address) { + vec![SymExpr::zero(cx); size] + } else { + code.read_byte_exprs_offset(cx, offset.clone(), size) + }; + let address = SymExpr::constant(cx, address_word(*address)); + let condition = SymBoolExpr::eq(cx, expr.clone(), address); + for (idx, byte) in bytes.into_iter().enumerate() { + result[idx] = SymExpr::ite(cx, condition.clone(), byte, result[idx].clone()); + } + } + + Ok(SymBytes::exprs(cx, result)) + } + + pub(crate) fn symbolic_call_targets( + &mut self, + cx: &mut SymCx, + executor: &Executor, + ) -> Result, SymbolicError> { + let mut addresses = HashSet::
::default(); + addresses.extend(self.code_cache.keys().copied()); + addresses.extend(self.existing_accounts.iter().copied()); + addresses.extend(executor.backend().mem_db().cache.accounts.keys().copied()); + if let Some(db) = executor.backend().active_fork_db() { + addresses.extend(db.cache.accounts.keys().copied()); + } + let mut addresses = addresses.into_iter().collect::>(); + addresses.sort_unstable(); + + let mut targets = Vec::new(); + let spec_id: SpecId = executor.spec_id().into(); + for address in addresses { + if address == CHEATCODE_ADDRESS + || address == SYMBOLIC_VM_COMPAT_ADDRESS + || precompile_number_for_spec(address, spec_id).is_some() + { + continue; + } + if !self.extcode(cx, executor, address)?.is_empty() { + targets.push(address); + } + } + Ok(targets) + } +} + +fn symbolic_storage_symbol(cx: &mut SymCx, address: Address, key: &SymExpr) -> Symbol { + stable_symbol( + cx, + "storage", + format!("{address:?}:{:?}", ExpressionDigests::identity([key])).as_bytes(), + ) +} + +#[derive(Clone, Debug)] +pub(crate) struct SymbolicBlock { + pub(crate) chain_id: SymExpr, + pub(crate) coinbase: Address, + pub(crate) timestamp: SymExpr, + pub(crate) number: SymExpr, + pub(crate) difficulty: SymExpr, + pub(crate) gaslimit: SymExpr, + pub(crate) basefee: SymExpr, + pub(crate) blob_basefee: SymExpr, + pub(crate) block_hashes: HashMap, + pub(crate) blob_hashes: Vec, +} + +impl SymbolicBlock { + pub(crate) fn new(cx: &mut SymCx) -> Self { + Self { + chain_id: SymExpr::one(cx), + coinbase: Address::ZERO, + timestamp: SymExpr::zero(cx), + number: SymExpr::zero(cx), + difficulty: SymExpr::zero(cx), + gaslimit: SymExpr::zero(cx), + basefee: SymExpr::zero(cx), + blob_basefee: SymExpr::zero(cx), + block_hashes: HashMap::default(), + blob_hashes: Vec::new(), + } + } + + pub(crate) fn from_executor( + cx: &mut SymCx, + executor: &Executor, + ) -> Self { + let evm_env = executor.evm_env(); + let block = executor + .inspector() + .cheatcodes + .as_ref() + .and_then(|cheats| cheats.block.as_ref()) + .unwrap_or(evm_env.block_env()); + let difficulty = block.prevrandao().map(Into::into).unwrap_or_else(|| block.difficulty()); + + Self { + chain_id: SymExpr::constant(cx, U256::from(evm_env.cfg_env.chain_id)), + coinbase: block.beneficiary(), + timestamp: SymExpr::constant(cx, block.timestamp()), + number: SymExpr::constant(cx, block.number()), + difficulty: SymExpr::constant(cx, difficulty), + gaslimit: SymExpr::constant(cx, U256::from(block.gas_limit())), + basefee: SymExpr::constant(cx, U256::from(block.basefee())), + blob_basefee: SymExpr::constant( + cx, + U256::from(block.blob_gasprice().unwrap_or_default()), + ), + block_hashes: HashMap::default(), + blob_hashes: executor.tx_env().blob_versioned_hashes().to_vec(), + } + } + + pub(crate) fn set_block_hash( + &mut self, + block_number: U256, + block_hash: SymExpr, + ) -> Result<(), SymbolicError> { + let current = self.number.as_const_or("symbolic vm.setBlockhash current number")?; + if block_number < current && current - block_number <= U256::from(256) { + self.block_hashes.insert(block_number, block_hash); + } + Ok(()) + } + + pub(crate) fn block_hash( + &self, + cx: &mut SymCx, + executor: &Executor, + block_number: U256, + ) -> Result { + let current = self.number.as_const_or("symbolic BLOCKHASH current number")?; + if block_number >= current || current - block_number > U256::from(256) { + return Ok(SymExpr::zero(cx)); + } + if let Some(hash) = self.block_hashes.get(&block_number) { + return Ok(hash.clone()); + } + let Ok(block_number) = u64::try_from(block_number) else { + return Ok(SymExpr::zero(cx)); + }; + let hash = executor + .backend() + .block_hash_ref(block_number) + .map_err(|err| SymbolicError::Backend(err.to_string()))?; + Ok(SymExpr::constant(cx, U256::from_be_slice(hash.as_slice()))) + } + + pub(crate) fn block_hash_word( + &self, + cx: &mut SymCx, + executor: &Executor, + block_number: SymExpr, + ) -> Result { + if let Some(block_number) = block_number.as_const() { + return self.block_hash(cx, executor, block_number); + } + let current = self.number.as_const_or("symbolic BLOCKHASH current number")?; + if current.is_zero() { + return Ok(SymExpr::zero(cx)); + } + + let mut result = SymExpr::zero(cx); + let max_distance = + usize::try_from(current.min(U256::from(256))).expect("checked blockhash distance"); + for distance in (1..=max_distance).rev() { + let candidate = current - U256::from(distance); + let hash = self.block_hash(cx, executor, candidate)?; + if hash.as_const().is_some_and(|hash| hash.is_zero()) { + continue; + } + let candidate = SymExpr::constant(cx, candidate); + let condition = SymBoolExpr::eq(cx, block_number.clone(), candidate); + result = SymExpr::ite(cx, condition, hash, result); + } + + Ok(result) + } +} diff --git a/crates/evm/symbolic/src/runtime/symbols.rs b/crates/evm/symbolic/src/runtime/symbols.rs new file mode 100644 index 0000000000000..1ff21810b1a5a --- /dev/null +++ b/crates/evm/symbolic/src/runtime/symbols.rs @@ -0,0 +1,52 @@ +use super::*; +use inturn::InternerSymbol; +use std::num::NonZeroU32; + +pub(crate) type SymbolicVars = IndexSet; + +pub(crate) type SymbolicModel = HashMap; + +pub(crate) trait SymbolicModelLookup { + fn value(&self, name: Symbol) -> Option; + + fn contains_name(&self, name: Symbol) -> bool { + self.value(name).is_some() + } +} + +impl SymbolicModelLookup for SymbolicModel { + fn value(&self, name: Symbol) -> Option { + self.get(&name).copied() + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) struct Symbol(NonZeroU32); + +impl Symbol { + pub(crate) const fn id(&self) -> NonZeroU32 { + self.0 + } +} + +impl InternerSymbol for Symbol { + fn try_from_usize(id: usize) -> Option { + id.checked_add(1).and_then(|id| u32::try_from(id).ok()).and_then(NonZeroU32::new).map(Self) + } + + fn to_usize(self) -> usize { + usize::try_from(self.0.get() - 1).expect("symbol id fits usize") + } +} + +impl fmt::Debug for Symbol { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, f) + } +} + +impl fmt::Display for Symbol { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "sym{}", self.id()) + } +} diff --git a/crates/evm/traces/Cargo.toml b/crates/evm/traces/Cargo.toml index f555d619fa228..a32baaf314683 100644 --- a/crates/evm/traces/Cargo.toml +++ b/crates/evm/traces/Cargo.toml @@ -20,6 +20,8 @@ foundry-compilers.workspace = true foundry-linking.workspace = true foundry-config.workspace = true foundry-evm-core.workspace = true +foundry-evm-hardforks.workspace = true +foundry-evm-networks.workspace = true alloy-dyn-abi = { workspace = true, features = ["arbitrary", "eip712"] } alloy-json-abi.workspace = true @@ -32,17 +34,50 @@ alloy-primitives = { workspace = true, features = [ alloy-sol-types.workspace = true revm-inspectors.workspace = true +tempo-contracts.workspace = true +tempo-precompiles.workspace = true +monad-revm = { workspace = true, optional = true } +base-common-precompiles = { workspace = true, optional = true, features = ["std"] } + +async-trait.workspace = true +ciborium.workspace = true eyre.workspace = true futures.workspace = true itertools.workspace = true +memchr.workspace = true +rayon.workspace = true +reqwest.workspace = true +revm.workspace = true +serde_json = { workspace = true, features = ["raw_value"] } serde.workspace = true -serde_json.workspace = true +solar.workspace = true +tempfile.workspace = true tokio = { workspace = true, features = ["time", "macros"] } tracing.workspace = true -tempfile.workspace = true -rayon.workspace = true -solar-parse.workspace = true -revm.workspace = true +yansi.workspace = true [dev-dependencies] -tempfile.workspace = true +ron.workspace = true +snapbox.workspace = true +tokio = { workspace = true, features = ["test-util"] } + +[features] +default = ["optimism"] +monad = [ + "dep:monad-revm", + "foundry-evm-core/monad", + "foundry-evm-hardforks/monad", + "foundry-evm-networks/monad", +] +base = [ + "dep:base-common-precompiles", + "foundry-common/base", + "foundry-evm-core/base", + "foundry-evm-hardforks/base", + "foundry-evm-networks/base", +] +optimism = [ + "foundry-common/optimism", + "foundry-evm-core/optimism", + "foundry-evm-networks/optimism", +] diff --git a/crates/evm/traces/src/backtrace/mod.rs b/crates/evm/traces/src/backtrace/mod.rs new file mode 100644 index 0000000000000..1b479954b8d59 --- /dev/null +++ b/crates/evm/traces/src/backtrace/mod.rs @@ -0,0 +1,419 @@ +//! Solidity stack trace support for test failures. + +use crate::{CallTrace, SparsedTraceArena}; +use alloy_primitives::{Address, Bytes, map::HashMap}; +use foundry_compilers::{ + Artifact, ArtifactId, ProjectCompileOutput, + artifacts::{ConfigurableContractArtifact, Libraries, sourcemap::SourceMap}, +}; +use std::{fmt, path::PathBuf}; +use yansi::Paint; + +mod source_map; +use source_map::load_build_sources; +pub use source_map::{PcSourceMapper, SourceData}; + +/// Linked library information for backtrace resolution. +/// +/// Contains the path, name, and deployed address of a linked library +/// to enable proper frame resolution in backtraces. +#[derive(Debug, Clone)] +struct LinkedLib { + /// The source file path of the library + path: PathBuf, + /// The name of the library contract + name: String, + /// The deployed address of the library + address: Address, +} + +/// Holds a reference to [`ProjectCompileOutput`] to fetch artifacts and sources for backtrace +/// generation. +pub struct BacktraceBuilder<'a> { + /// Linked libraries from configuration + linked_libraries: Vec, + /// Reference to project output for on-demand source loading + output: &'a ProjectCompileOutput, + /// Project root + root: PathBuf, + /// Disable source locations + /// + /// Source locations will be inaccurately reported if the files have been compiled with via-ir + disable_source_locs: bool, + /// Sources grouped by [`ArtifactId::build_id`] to avoid re-reading files for artifacts from + /// the same build + /// + /// The source [`Vec`] is indexed by the compiler source ID, and contains the source path and + /// source content. + build_sources_cache: HashMap>, +} + +impl<'a> BacktraceBuilder<'a> { + /// Instantiates a backtrace builder from a [`ProjectCompileOutput`]. + pub fn new( + output: &'a ProjectCompileOutput, + root: PathBuf, + linked_libraries: Option, + disable_source_locs: bool, + ) -> Self { + let linked_libs = linked_libraries + .map(|libs| { + libs.libs + .iter() + .flat_map(|(path, libs_map)| { + libs_map.iter().map(move |(name, addr_str)| (path, name, addr_str)) + }) + .filter_map(|(path, name, addr_str)| { + addr_str.parse().ok().map(|address| LinkedLib { + path: path.clone(), + name: name.clone(), + address, + }) + }) + .collect() + }) + .unwrap_or_default(); + + Self { + linked_libraries: linked_libs, + output, + root, + disable_source_locs, + build_sources_cache: HashMap::default(), + } + } + + /// Generates a backtrace from a [`SparsedTraceArena`]. + pub fn from_traces(&mut self, arena: &SparsedTraceArena) -> Backtrace<'_> { + // Resolve addresses to artifacts using trace labels and linked libraries + let artifacts_by_address = self.resolve_addresses(arena); + for (artifact_id, _) in artifacts_by_address.values() { + let build_id = &artifact_id.build_id; + if !self.build_sources_cache.contains_key(build_id) + && let Some(sources) = load_build_sources(build_id, self.output, &self.root) + { + self.build_sources_cache.insert(build_id.clone(), sources); + } + } + + Backtrace::new( + artifacts_by_address, + &self.build_sources_cache, + self.linked_libraries.clone(), + self.disable_source_locs, + arena, + ) + } + + /// Resolves contract addresses to [`ArtifactId`] and their [`SourceData`] from trace labels and + /// linked libraries. + fn resolve_addresses( + &self, + arena: &SparsedTraceArena, + ) -> HashMap { + let mut artifacts_by_address = HashMap::default(); + + // Collect all labels from traces first + let label_to_address = arena + .nodes() + .iter() + .filter_map(|node| { + if let Some(decoded) = &node.trace.decoded + && let Some(label) = &decoded.label + { + return Some((label.as_str(), node.trace.address)); + } + None + }) + .collect::>(); + + // Build linked library target IDs + let linked_lib_targets = self + .linked_libraries + .iter() + .map(|lib| (format!("{}:{}", lib.path.display(), lib.name), lib.address)) + .collect::>(); + + let get_source = |artifact: &ConfigurableContractArtifact| -> Option<(SourceMap, Bytes)> { + let source_map = artifact.get_source_map_deployed()?.ok()?; + let deployed_bytecode = artifact.get_deployed_bytecode_bytes()?.into_owned(); + + if deployed_bytecode.is_empty() { + return None; + } + + Some((source_map, deployed_bytecode)) + }; + + for (artifact_id, artifact) in self.output.artifact_ids() { + // Match and insert artifacts using trace labels + if let Some(address) = label_to_address.get(artifact_id.name.as_str()) + && let Some((source_map, bytecode)) = get_source(artifact) + { + // Match and insert artifacts using trace labels + artifacts_by_address + .insert(*address, (artifact_id.clone(), SourceData { source_map, bytecode })); + } else if let Some(&lib_address) = + // Match and insert the linked library artifacts + linked_lib_targets.get(&artifact_id.identifier()).or_else(|| { + let id = artifact_id + .clone() + .with_stripped_file_prefixes(&self.root) + .identifier(); + linked_lib_targets.get(&id) + }) + && let Some((source_map, bytecode)) = get_source(artifact) + { + // Insert linked libraries + artifacts_by_address + .insert(lib_address, (artifact_id, SourceData { source_map, bytecode })); + } + } + + artifacts_by_address + } +} + +/// A Solidity stack trace for a test failure. +/// +/// Generates a backtrace from a [`SparsedTraceArena`] by leveraging source maps and bytecode. +/// +/// It uses the program counter (PC) from the traces to map to a specific source location for the +/// call. +/// +/// Each step/call in the backtrace is classified as a BacktraceFrame +#[non_exhaustive] +pub struct Backtrace<'a> { + /// The frames of the backtrace, from innermost (where the revert happened) to outermost. + frames: Vec, + /// Map from address to PcSourceMapper + pc_mappers: HashMap>, + /// Linked libraries from configuration + linked_libraries: Vec, + /// Disable pinpointing source locations in files + /// + /// Should be disabled when via-ir is enabled + disable_source_locs: bool, +} + +impl<'a> Backtrace<'a> { + /// Creates a backtrace from collected artifacts and sources. + fn new( + artifacts_by_address: HashMap, + build_sources: &'a HashMap>, + linked_libraries: Vec, + disable_source_locs: bool, + arena: &SparsedTraceArena, + ) -> Self { + let mut pc_mappers = HashMap::default(); + + // Build PC source mappers for each contract + if !disable_source_locs { + for (addr, (artifact_id, source_data)) in artifacts_by_address { + if let Some(sources) = build_sources.get(&artifact_id.build_id) { + let mapper = PcSourceMapper::new(source_data, sources); + pc_mappers.insert(addr, mapper); + } + } + } + + let mut backtrace = + Self { frames: Vec::new(), pc_mappers, linked_libraries, disable_source_locs }; + + backtrace.extract_frames(arena); + + backtrace + } + + /// Extracts backtrace frames from a trace arena. + fn extract_frames(&mut self, arena: &SparsedTraceArena) { + let resolved_arena = &arena.arena; + + if resolved_arena.nodes().is_empty() { + return; + } + + // Find the deepest failed node (where the actual revert happened) + let mut current_idx = None; + let mut max_depth = 0; + + for (idx, node) in resolved_arena.nodes().iter().enumerate() { + if !node.trace.success && node.trace.depth >= max_depth { + max_depth = node.trace.depth; + current_idx = Some(idx); + } + } + + if current_idx.is_none() { + return; + } + + // Build the call stack by walking from the deepest node back to root + while let Some(idx) = current_idx { + let node = &resolved_arena.nodes()[idx]; + let trace = &node.trace; + + if let Some(frame) = self.create_frame(trace) { + self.frames.push(frame); + } + + current_idx = node.parent; + } + } + + /// Creates a frame from a call trace. + fn create_frame(&self, trace: &CallTrace) -> Option { + let contract_address = trace.address; + let mut frame = BacktraceFrame::new(contract_address); + + // Try to get source location from PC mapper + if !self.disable_source_locs + && let Some(source_location) = trace.steps.last().and_then(|last_step| { + self.pc_mappers.get(&contract_address).and_then(|m| m.map_pc(last_step.pc)) + }) + { + frame = frame + .with_source_location( + source_location.file, + source_location.line, + source_location.column, + ) + .with_byte_offset(source_location.offset); + } + + if let Some(decoded) = &trace.decoded { + if let Some(label) = &decoded.label { + frame = frame.with_contract_name(label.clone()); + } else if let Some(lib) = + self.linked_libraries.iter().find(|l| l.address == contract_address) + { + frame = frame.with_contract_name(lib.name.clone()); + } + + if let Some(call_data) = &decoded.call_data { + let sig = &call_data.signature; + let func_name = + if let Some(paren_pos) = sig.find('(') { &sig[..paren_pos] } else { sig }; + frame = frame.with_function_name(func_name.to_string()); + } + } + + Some(frame) + } + + /// Returns true if the backtrace is empty. + pub const fn is_empty(&self) -> bool { + self.frames.is_empty() + } +} + +impl fmt::Display for Backtrace<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.frames.is_empty() { + return Ok(()); + } + + writeln!(f, "{}", Paint::yellow("Backtrace:"))?; + + for frame in &self.frames { + write!(f, " ")?; + write!(f, "at ")?; + writeln!(f, "{frame}")?; + } + + Ok(()) + } +} + +/// A single frame in a backtrace. +#[derive(Debug, Clone)] +struct BacktraceFrame { + /// The contract address where this frame is executing. + pub contract_address: Address, + /// The contract name, if known. + pub contract_name: Option, + /// The function name, if known. + pub function_name: Option, + /// The source file path. + pub file: Option, + /// The line number in the source file. + pub line: Option, + /// The column number in the source file. + pub column: Option, + /// The byte offset in the source file. + pub byte_offset: Option, +} + +impl BacktraceFrame { + /// Creates a new backtrace frame. + const fn new(contract_address: Address) -> Self { + Self { + contract_address, + contract_name: None, + function_name: None, + file: None, + line: None, + column: None, + byte_offset: None, + } + } + + /// Sets the contract name. + fn with_contract_name(mut self, name: String) -> Self { + self.contract_name = Some(name); + self + } + + /// Sets the function name. + fn with_function_name(mut self, name: String) -> Self { + self.function_name = Some(name); + self + } + + /// Sets the source location. + fn with_source_location(mut self, file: PathBuf, line: usize, column: usize) -> Self { + self.file = Some(file); + self.line = Some(line); + self.column = Some(column); + self + } + + /// Sets the byte offset. + const fn with_byte_offset(mut self, offset: usize) -> Self { + self.byte_offset = Some(offset); + self + } +} + +// Format: . (FILE:LINE:COL) +impl fmt::Display for BacktraceFrame { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut result = String::new(); + + // No contract name, show address + result.push_str(self.contract_name.as_ref().unwrap_or(&self.contract_address.to_string())); + + // Add function name if available + result.push_str(&self.function_name.as_ref().map_or(String::new(), |f| format!(".{f}"))); + + if let Some(file) = &self.file { + result.push_str(" ("); + result.push_str(&file.display().to_string()); + } + + if let Some(line) = self.line { + result.push(':'); + result.push_str(&line.to_string()); + + result.push(':'); + result.push_str(&self.column.as_ref().map_or("0".to_string(), |c| c.to_string())); + } + + // Add location in parentheses if available + if self.file.is_some() || self.line.is_some() { + result.push(')'); + } + + write!(f, "{result}") + } +} diff --git a/crates/evm/traces/src/backtrace/source_map.rs b/crates/evm/traces/src/backtrace/source_map.rs new file mode 100644 index 0000000000000..daad2bc93d5f8 --- /dev/null +++ b/crates/evm/traces/src/backtrace/source_map.rs @@ -0,0 +1,194 @@ +//! Source map decoding and PC mapping utilities. + +use alloy_primitives::Bytes; +use foundry_compilers::{ProjectCompileOutput, artifacts::sourcemap::SourceMap}; +use foundry_evm_core::ic::IcPcMap; +use std::path::{Path, PathBuf}; + +/// Source data for a single contract. +#[derive(Debug, Clone)] +pub struct SourceData { + /// Runtime source map for the contract + pub source_map: SourceMap, + /// Deployed bytecode for accurate PC mapping + pub bytecode: Bytes, +} + +/// Maps program counters to source locations. +pub struct PcSourceMapper<'a> { + /// Mapping from instruction counter to program counter. + ic_pc_map: IcPcMap, + /// Source data consists of the source_map and the deployed bytecode + source_data: SourceData, + /// Source files i.e source path and content (indexed by source_id) + sources: &'a [(PathBuf, String)], + /// Cached line offset mappings for each source file. + line_offsets: Vec>, +} + +impl<'a> PcSourceMapper<'a> { + /// Creates a new PC to source mapper. + pub fn new(source_data: SourceData, sources: &'a [(PathBuf, String)]) -> Self { + // Build instruction counter to program counter mapping + let ic_pc_map = IcPcMap::new(source_data.bytecode.as_ref()); + + // Pre-calculate line offsets for each source file + let line_offsets = + sources.iter().map(|(_, content)| compute_line_offsets(content)).collect(); + + Self { ic_pc_map, source_data, sources, line_offsets } + } + + /// Maps a program counter to source location. + pub fn map_pc(&self, pc: usize) -> Option { + // Find the instruction counter for this PC + let ic = self.find_instruction_counter(pc)?; + + // Get the source element for this instruction + let element = self.source_data.source_map.get(ic)?; + + // Get the source file index - returns None if index is -1 + let source_idx_opt = element.index(); + + let source_idx = source_idx_opt? as usize; + if source_idx >= self.sources.len() { + return None; + } + + // Get the source file info + let (file_path, content) = &self.sources[source_idx]; + + // Convert byte offset to line and column + let offset = element.offset() as usize; + + // Check if offset is valid for this source file + if offset >= content.len() { + return None; + } + + let line_offsets = self.line_offsets.get(source_idx)?; + let (line, column) = offset_to_line_column(line_offsets, offset); + + trace!( + file = ?file_path, + line = line, + column = column, + offset = offset, + "Mapped PC to source location" + ); + + Some(SourceLocation { + file: file_path.clone(), + line, + column, + length: element.length() as usize, + offset, + }) + } + + /// Finds the instruction counter for a given program counter. + fn find_instruction_counter(&self, pc: usize) -> Option { + // The IcPcMap maps IC -> PC, we need the reverse + // We find the highest IC that has a PC <= our target PC + let mut best_ic = None; + let mut best_pc = 0; + + for (ic, mapped_pc) in self.ic_pc_map.iter() { + let mapped_pc = *mapped_pc as usize; + if mapped_pc <= pc && mapped_pc >= best_pc { + best_pc = mapped_pc; + best_ic = Some(*ic as usize); + } + } + + best_ic + } +} +/// Represents a location in source code. +#[derive(Debug, Clone)] +pub struct SourceLocation { + pub file: PathBuf, + pub line: usize, + pub column: usize, + pub length: usize, + /// Byte offset in the source file + /// This specifically useful when one source file contains multiple contracts / libraries. + pub offset: usize, +} + +/// Computes the byte offset where each line starts in source content. +fn compute_line_offsets(content: &str) -> Vec { + let mut offsets = vec![0]; + offsets.extend(memchr::memchr_iter(b'\n', content.as_bytes()).map(|offset| offset + 1)); + offsets +} + +/// Converts a byte offset to 1-indexed line and column numbers. +fn offset_to_line_column(line_offsets: &[usize], offset: usize) -> (usize, usize) { + let line = line_offsets.partition_point(|&line_start| line_start <= offset) - 1; + let column = offset - line_offsets[line]; + (line + 1, column + 1) +} + +/// Loads sources for a specific ArtifactId.build_id +pub fn load_build_sources( + build_id: &str, + output: &ProjectCompileOutput, + root: &Path, +) -> Option> { + let build_ctx = output.builds().find(|(bid, _)| *bid == build_id).map(|(_, ctx)| ctx)?; + + // Determine the size needed for sources vector + // Highest source_id + let max_source_id = build_ctx.source_id_to_path.keys().max().map_or(0, |id| *id) as usize; + + // Vec of source path and it's content + let mut sources = vec![(PathBuf::new(), String::new()); max_source_id + 1]; + + // Populate sources at their correct indices + for (source_id, source_path) in &build_ctx.source_id_to_path { + let idx = *source_id as usize; + + let full_path = + if source_path.is_absolute() { source_path.clone() } else { root.join(source_path) }; + let mut source_content = foundry_common::fs::read_to_string(&full_path).unwrap_or_default(); + + // Normalize line endings for windows + if source_content.contains('\r') { + source_content = source_content.replace("\r\n", "\n"); + } + + // Convert path to relative PathBuf + let path_buf = source_path.strip_prefix(root).unwrap_or(source_path).to_path_buf(); + + sources[idx] = (path_buf, source_content); + } + + Some(sources) +} + +#[cfg(test)] +mod tests { + use super::{compute_line_offsets, offset_to_line_column}; + + fn line_column(content: &str, offset: usize) -> (usize, usize) { + offset_to_line_column(&compute_line_offsets(content), offset) + } + + #[test] + fn maps_byte_offsets_to_line_and_column() { + assert_eq!(line_column("abc", 0), (1, 1)); + assert_eq!(line_column("abc", 2), (1, 3)); + + assert_eq!(line_column("abc\ndef", 3), (1, 4)); + assert_eq!(line_column("abc\ndef", 4), (2, 1)); + assert_eq!(line_column("abc\ndef", 6), (2, 3)); + + assert_eq!(line_column("a\nbc\ndef", 0), (1, 1)); + assert_eq!(line_column("a\nbc\ndef", 1), (1, 2)); + assert_eq!(line_column("a\nbc\ndef", 2), (2, 1)); + assert_eq!(line_column("a\nbc\ndef", 4), (2, 3)); + assert_eq!(line_column("a\nbc\ndef", 5), (3, 1)); + assert_eq!(line_column("a\nbc\ndef", 7), (3, 3)); + } +} diff --git a/crates/evm/traces/src/debug/mod.rs b/crates/evm/traces/src/debug/mod.rs index dba0dc0d905b4..742f9cdacaf4d 100644 --- a/crates/evm/traces/src/debug/mod.rs +++ b/crates/evm/traces/src/debug/mod.rs @@ -9,7 +9,7 @@ use foundry_common::fmt::format_token; use foundry_compilers::artifacts::sourcemap::{Jump, SourceElement}; use revm::bytecode::opcode::OpCode; use revm_inspectors::tracing::types::{CallTraceStep, DecodedInternalCall, DecodedTraceStep}; -pub use sources::{ArtifactData, ContractSources, SourceData}; +pub use sources::{ArtifactData, ContractSources, DebugSourceScope, DebugVariable, SourceData}; #[derive(Clone, Debug)] pub struct DebugTraceIdentifier { @@ -18,7 +18,7 @@ pub struct DebugTraceIdentifier { } impl DebugTraceIdentifier { - pub fn new(contracts_sources: ContractSources) -> Self { + pub const fn new(contracts_sources: ContractSources) -> Self { Self { contracts_sources } } @@ -26,7 +26,16 @@ impl DebugTraceIdentifier { /// /// Accepts the node itself and identified name of the contract which node corresponds to. pub fn identify_node_steps(&self, node: &mut CallTraceNode, contract_name: &str) { - DebugStepsWalker::new(node, &self.contracts_sources, contract_name).walk(); + Self::identify_node_steps_with_sources(node, &self.contracts_sources, contract_name); + } + + /// Identifies internal function invocations without taking ownership of source metadata. + pub fn identify_node_steps_with_sources( + node: &mut CallTraceNode, + sources: &ContractSources, + contract_name: &str, + ) { + DebugStepsWalker::new(node, sources, contract_name).walk(); } } @@ -65,7 +74,7 @@ struct DebugStepsWalker<'a> { } impl<'a> DebugStepsWalker<'a> { - pub fn new( + pub const fn new( node: &'a mut CallTraceNode, sources: &'a ContractSources, contract_name: &'a str, @@ -93,10 +102,6 @@ impl<'a> DebugStepsWalker<'a> { self.src_map(self.current_step - 1) } - fn current_src_map(&self) -> Option<(SourceElement, &SourceData)> { - self.src_map(self.current_step) - } - fn is_same_loc(&self, step: usize, other: usize) -> bool { let Some((loc, _)) = self.src_map(step) else { return false; @@ -119,7 +124,7 @@ impl<'a> DebugStepsWalker<'a> { return; } - let Some((source_element, source)) = self.current_src_map() else { + let Some((source_element, source)) = self.src_map(self.current_step) else { return; }; @@ -143,25 +148,30 @@ impl<'a> DebugStepsWalker<'a> { // Try to decode function inputs and outputs from the stack and memory. let (inputs, outputs) = self .src_map(start_idx + 1) - .map(|(source_element, source)| { + .and_then(|(source_element, source)| { let start = source_element.offset() as usize; - let end = start + source_element.length() as usize; - let fn_definition = source.source[start..end].replace('\n', ""); + let (fn_definition, _) = + source_span(&source.source, start, source_element.length() as usize)?; + let fn_definition = fn_definition.replace('\n', ""); let (inputs, outputs) = parse_types(&fn_definition); - ( + Some(( inputs.and_then(|t| { - try_decode_args_from_step(&t, &self.node.trace.steps[start_idx + 1]) + decode_step_parameters( + &t, + &self.node.trace.steps[start_idx + 1], + Some(self.node.trace.data.as_ref()), + ) }), - outputs.and_then(|t| try_decode_args_from_step(&t, self.current_step())), - ) + outputs.and_then(|t| decode_step_parameters(&t, self.current_step(), None)), + )) }) .unwrap_or_default(); - self.node.trace.steps[start_idx].decoded = Some(DecodedTraceStep::InternalCall( + self.node.trace.steps[start_idx].decoded = Some(Box::new(DecodedTraceStep::InternalCall( DecodedInternalCall { func_name, args: inputs, return_data: outputs }, self.current_step, - )); + ))); } fn process(&mut self) { @@ -196,25 +206,59 @@ impl<'a> DebugStepsWalker<'a> { /// Tries to parse the function name from the source code and detect the contract name which /// contains the given function. /// -/// Returns string in the format `Contract::function`. +/// Returns a string in the format `Contract::function(types)` when parameters can be resolved, +/// falling back to `Contract::function`. fn parse_function_from_loc(source: &SourceData, loc: &SourceElement) -> Option { let start = loc.offset() as usize; - let end = start + loc.length() as usize; - let src_len = source.source.len(); - - // Handle special case of preprocessed test sources. - if start > src_len || end > src_len { - return None; - } + let (source_part, end) = source_span(&source.source, start, loc.length() as usize)?; - let source_part = &source.source[start..end]; if !source_part.starts_with("function") { return None; } let function_name = source_part.split_once("function")?.1.split('(').next()?.trim(); let contract_name = source.find_contract_name(start, end)?; - Some(format!("{contract_name}::{function_name}")) + Some(internal_function_identifier(contract_name, function_name, source_part)) +} + +fn internal_function_identifier( + contract_name: &str, + function_name: &str, + source_part: &str, +) -> String { + let signature = canonical_function_signature(function_name, source_part) + .unwrap_or_else(|| function_name.to_string()); + format!("{contract_name}::{signature}") +} + +fn canonical_function_signature(function_name: &str, source_part: &str) -> Option { + let source_part = source_part.replace('\n', ""); + let (inputs, _) = parse_types(&source_part); + let inputs = inputs?; + let types = + inputs.params.iter().map(|param| param.resolve().ok()).collect::>>()?; + Some(function_signature(function_name, &types)) +} + +/// Formats an ABI-style function signature from a name and canonical parameter types. +pub fn function_signature(function_name: &str, types: &[DynSolType]) -> String { + let mut signature = String::new(); + signature.push_str(function_name); + signature.push('('); + for (i, ty) in types.iter().enumerate() { + if i > 0 { + signature.push(','); + } + signature.push_str(&ty.sol_type_name()); + } + signature.push(')'); + signature +} + +fn source_span(source: &str, start: usize, len: usize) -> Option<(&str, usize)> { + let end = start.checked_add(len)?; + + Some((source.get(start..end)?, end)) } /// Parses function input and output types into [Parameters]. @@ -232,64 +276,129 @@ fn parse_types(source: &str) -> (Option>, Option>) (inputs, outputs) } -/// Given [Parameters] and [CallTraceStep], tries to decode parameters by using stack and memory. -fn try_decode_args_from_step(args: &Parameters<'_>, step: &CallTraceStep) -> Option> { +/// Given [Parameters] and [CallTraceStep], tries to decode parameters by using stack, memory, and +/// call data. +pub fn decode_step_parameters( + args: &Parameters<'_>, + step: &CallTraceStep, + calldata: Option<&[u8]>, +) -> Option> { let params = &args.params; if params.is_empty() { return Some(vec![]); } - let types = params.iter().map(|p| p.resolve().ok().map(|t| (t, p.storage))).collect::>(); + let types = params + .iter() + .map(|p| { + p.resolve().ok().map(|t| { + let slots = stack_slots(&t, p.storage); + (t, p.storage, slots) + }) + }) + .collect::>(); let stack = step.stack.as_ref()?; + let stack_slots = + types.iter().map(|type_| type_.as_ref().map_or(1, |(_, _, slots)| *slots)).sum::(); - if stack.len() < types.len() { + if stack.len() < stack_slots { return None; } - let inputs = &stack[stack.len() - types.len()..]; + let inputs = &stack[stack.len() - stack_slots..]; + let memory = step.memory.as_ref().map(|memory| memory.as_bytes().as_ref()); + let mut input_idx = 0; + let mut decoded = Vec::with_capacity(types.len()); - let decoded = inputs - .iter() - .zip(types.iter()) - .map(|(input, type_and_storage)| { - type_and_storage - .as_ref() - .and_then(|(type_, storage)| { - match (type_, storage) { - // HACK: alloy parser treats user-defined types as uint8: https://github.com/alloy-rs/core/pull/386 - // - // filter out `uint8` params which are marked as storage or memory as this - // is not possible in Solidity and means that type is user-defined - (DynSolType::Uint(8), Some(Storage::Memory | Storage::Storage)) => None, - (_, Some(Storage::Memory)) => decode_from_memory( - type_, - step.memory.as_ref()?.as_bytes(), - input.try_into().ok()?, - ), - // Read other types from stack - _ => type_.abi_decode(&input.to_be_bytes::<32>()).ok(), - } - }) + for type_and_storage in &types { + let Some((type_, storage, slots)) = type_and_storage.as_ref() else { + input_idx += 1; + decoded.push("".to_string()); + continue; + }; + let input = &inputs[input_idx..input_idx + *slots]; + input_idx += *slots; + + decoded.push( + decode_parameter(type_, *storage, input, memory, calldata) .as_ref() .map(format_token) - .unwrap_or_else(|| "".to_string()) - }) - .collect(); + .unwrap_or_else(|| "".to_string()), + ); + } Some(decoded) } +const fn stack_slots(ty: &DynSolType, storage: Option) -> usize { + match (ty, storage) { + ( + DynSolType::String | DynSolType::Bytes | DynSolType::Array(_), + Some(Storage::Calldata), + ) => 2, + _ => 1, + } +} + +fn decode_parameter( + ty: &DynSolType, + storage: Option, + stack_words: &[U256], + memory: Option<&[u8]>, + calldata: Option<&[u8]>, +) -> Option { + let input = stack_words.first()?; + + match (ty, storage) { + // HACK: alloy parser treats user-defined types as uint8: https://github.com/alloy-rs/core/pull/386 + // + // filter out `uint8` params which are marked as storage, memory, or calldata as this + // is not possible in Solidity and means that type is user-defined + (DynSolType::Uint(8), Some(Storage::Memory | Storage::Storage | Storage::Calldata)) => None, + (_, Some(Storage::Storage)) => None, + (_, Some(Storage::Memory)) => decode_from_memory(ty, memory?, input.try_into().ok()?), + (_, Some(Storage::Calldata)) => decode_from_calldata(ty, calldata?, stack_words), + // Read other types from stack + _ => ty.abi_decode(&input.to_be_bytes::<32>()).ok(), + } +} + +fn decode_from_calldata( + ty: &DynSolType, + calldata: &[u8], + stack_words: &[U256], +) -> Option { + let offset: usize = stack_words.first()?.try_into().ok()?; + + match ty { + // For calldata `string` and `bytes`, Solidity keeps the byte offset and length on stack. + DynSolType::String | DynSolType::Bytes => { + let length: usize = stack_words.get(1)?.try_into().ok()?; + let data = memory_range(calldata, offset, length)?; + + match ty { + DynSolType::Bytes => Some(DynSolValue::Bytes(data.to_vec())), + DynSolType::String => { + Some(DynSolValue::String(String::from_utf8_lossy(data).to_string())) + } + _ => unreachable!(), + } + } + _ => None, + } +} + /// Decodes given [DynSolType] from memory. fn decode_from_memory(ty: &DynSolType, memory: &[u8], location: usize) -> Option { - let first_word = memory.get(location..location + 32)?; + let first_word = memory_range(memory, location, 32)?; match ty { // For `string` and `bytes` layout is a word with length followed by the data DynSolType::String | DynSolType::Bytes => { let length: usize = U256::from_be_slice(first_word).try_into().ok()?; - let data = memory.get(location + 32..location + 32 + length)?; + let data = memory_range(memory, location.checked_add(32)?, length)?; match ty { DynSolType::Bytes => Some(DynSolValue::Bytes(data.to_vec())), @@ -305,18 +414,19 @@ fn decode_from_memory(ty: &DynSolType, memory: &[u8], location: usize) -> Option let (length, start) = match ty { DynSolType::FixedArray(_, length) => (*length, location), DynSolType::Array(_) => { - (U256::from_be_slice(first_word).try_into().ok()?, location + 32) + (U256::from_be_slice(first_word).try_into().ok()?, location.checked_add(32)?) } _ => unreachable!(), }; + memory_range(memory, start, length.checked_mul(32)?)?; let mut decoded = Vec::with_capacity(length); for i in 0..length { - let offset = start + i * 32; + let offset = start.checked_add(i.checked_mul(32)?)?; let location = match inner.as_ref() { // Arrays of variable length types are arrays of pointers to the values DynSolType::String | DynSolType::Bytes | DynSolType::Array(_) => { - U256::from_be_slice(memory.get(offset..offset + 32)?).try_into().ok()? + U256::from_be_slice(memory_range(memory, offset, 32)?).try_into().ok()? } _ => offset, }; @@ -329,3 +439,127 @@ fn decode_from_memory(ty: &DynSolType, memory: &[u8], location: usize) -> Option _ => ty.abi_decode(first_word).ok(), } } + +fn memory_range(memory: &[u8], start: usize, len: usize) -> Option<&[u8]> { + memory.get(start..start.checked_add(len)?) +} + +#[cfg(test)] +mod tests { + use super::{ + decode_from_memory, decode_step_parameters, internal_function_identifier, source_span, + }; + use alloy_dyn_abi::{DynSolType, parser::Parameters}; + use alloy_primitives::{Bytes, U256}; + use revm::{bytecode::opcode::OpCode, interpreter::InstructionResult}; + use revm_inspectors::tracing::types::CallTraceStep; + + fn trace_step(stack: Vec) -> CallTraceStep { + CallTraceStep { + pc: 0, + op: OpCode::STOP, + stack: Some(stack.into_boxed_slice()), + push_stack: None, + memory: None, + returndata: Bytes::new(), + gas_remaining: 0, + gas_refund_counter: 0, + gas_used: 0, + gas_cost: 0, + state_gas_cost: None, + state_gas_reservoir: None, + state_gas_spent: 0, + storage_change: None, + status: Some(InstructionResult::Stop), + immediate_bytes: None, + decoded: None, + } + } + + #[test] + fn source_span_returns_none_for_invalid_ranges() { + assert_eq!(source_span("abcdef", 2, 3), Some(("cde", 5))); + assert_eq!(source_span("abcdef", 7, 1), None); + assert_eq!(source_span("abcdef", usize::MAX, 1), None); + } + + #[test] + fn internal_function_identifier_includes_canonical_signature() { + assert_eq!( + internal_function_identifier( + "DebugMe", + "foo", + "function foo(uint256 amount, bool ok) internal returns (uint256) {", + ), + "DebugMe::foo(uint256,bool)" + ); + } + + #[test] + fn decode_from_memory_rejects_overflow_location() { + assert_eq!(decode_from_memory(&DynSolType::Bytes, &[0; 64], usize::MAX), None); + } + + #[test] + fn decode_from_memory_rejects_oversized_dynamic_array_length() { + let memory = U256::from(1_000_000).to_be_bytes::<32>(); + let ty = DynSolType::Array(Box::new(DynSolType::Uint(256))); + + assert_eq!(decode_from_memory(&ty, &memory, 0), None); + } + + #[test] + fn decode_step_parameters_marks_storage_params_unknown() { + let params = Parameters::parse("(uint256[] storage values)").unwrap(); + let step = trace_step(vec![U256::from(5)]); + + assert_eq!( + decode_step_parameters(¶ms, &step, None), + Some(vec!["".to_string()]) + ); + } + + #[test] + fn decode_step_parameters_aligns_static_arg_before_calldata_bytes() { + let params = Parameters::parse("(bytes32 digest, bytes calldata signature)").unwrap(); + let digest = U256::from(0x1234); + let offset = 0x44; + let mut calldata = vec![0; offset]; + calldata.extend_from_slice(&[0x11, 0x22, 0x33]); + let step = trace_step(vec![digest, U256::from(offset), U256::from(3)]); + + assert_eq!( + decode_step_parameters(¶ms, &step, Some(&calldata)), + Some(vec![ + "0x0000000000000000000000000000000000000000000000000000000000001234".to_string(), + "0x112233".to_string(), + ]) + ); + } + + #[test] + fn decode_step_parameters_marks_calldata_bytes_unknown_without_calldata() { + let params = Parameters::parse("(bytes calldata signature)").unwrap(); + let step = trace_step(vec![U256::from(0x44), U256::from(3)]); + + assert_eq!( + decode_step_parameters(¶ms, &step, None), + Some(vec!["".to_string()]) + ); + } + + #[test] + fn decode_step_parameters_aligns_static_arg_after_unsupported_calldata_array() { + let params = Parameters::parse("(uint256[] calldata values, bytes32 digest)").unwrap(); + let digest = U256::from(0x1234); + let step = trace_step(vec![U256::from(0x44), U256::from(2), digest]); + + assert_eq!( + decode_step_parameters(¶ms, &step, Some(&[])), + Some(vec![ + "".to_string(), + "0x0000000000000000000000000000000000000000000000000000000000001234".to_string(), + ]) + ); + } +} diff --git a/crates/evm/traces/src/debug/sources.rs b/crates/evm/traces/src/debug/sources.rs index 579291ea6fd98..bd51be3af791e 100644 --- a/crates/evm/traces/src/debug/sources.rs +++ b/crates/evm/traces/src/debug/sources.rs @@ -1,9 +1,12 @@ use eyre::{Context, Result}; -use foundry_common::{compact_to_contract, strip_bytecode_placeholders}; +use foundry_common::{ + compact_to_contract, external_compiler::is_external_artifact, is_deploy_helper_path, + strip_bytecode_placeholders, +}; use foundry_compilers::{ - Artifact, Compiler, ProjectCompileOutput, + Artifact, ProjectCompileOutput, artifacts::{ - Bytecode, Contract, ContractBytecodeSome, Libraries, Source, + Bytecode, ContractBytecodeSome, Libraries, Source, sourcemap::{SourceElement, SourceMap}, }, multi::MultiCompilerLanguage, @@ -11,7 +14,7 @@ use foundry_compilers::{ use foundry_evm_core::ic::PcIcMap; use foundry_linking::Linker; use rayon::prelude::*; -use solar_parse::{Parser, interface::Session}; +use solar::{ast, interface::SpannedOption}; use std::{ collections::{BTreeMap, HashMap, HashSet}, fmt::Write, @@ -27,12 +30,51 @@ pub struct SourceData { pub path: PathBuf, /// Maps contract name to (start, end) of the contract definition in the source code. /// This is useful for determining which contract contains given function definition. - contract_definitions: Vec<(String, Range)>, + pub contract_definitions: Vec<(String, Range)>, + /// Solidity function scopes and variable declarations available to the debugger. + pub debug_scopes: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DebugSourceScope { + pub contract_name: String, + pub function_name: String, + pub range: Range, + pub body_range: Range, + pub parameters_src: String, + pub returns_src: Option, + pub parameters: Vec, + pub returns: Vec, + pub locals: Vec, +} + +impl DebugSourceScope { + pub fn visible_locals(&self, offset: usize) -> impl Iterator { + self.locals.iter().filter(move |local| { + local.declaration.end <= offset + && offset >= local.scope.start + && offset <= local.scope.end + }) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DebugVariable { + pub name: Option, + pub declaration: Range, + pub scope: Range, } impl SourceData { - pub fn new(source: Arc, language: MultiCompilerLanguage, path: PathBuf) -> Self { + pub fn new( + output: &ProjectCompileOutput, + source: Arc, + language: MultiCompilerLanguage, + path: PathBuf, + root: &Path, + ) -> Self { let mut contract_definitions = Vec::new(); + let mut debug_scopes = Vec::new(); match language { MultiCompilerLanguage::Vyper(_) => { @@ -42,25 +84,33 @@ impl SourceData { } } MultiCompilerLanguage::Solc(_) => { - let sess = Session::builder().with_silent_emitter(None).build(); - let _ = sess.enter(|| -> solar_parse::interface::Result<()> { - let arena = solar_parse::ast::Arena::new(); - let filename = path.clone().into(); - let mut parser = - Parser::from_source_code(&sess, &arena, filename, source.to_string())?; - let ast = parser.parse_file().map_err(|e| e.emit())?; - for item in ast.items { - if let solar_parse::ast::ItemKind::Contract(contract) = &item.kind { - let range = item.span.lo().to_usize()..item.span.hi().to_usize(); - contract_definitions.push((contract.name.to_string(), range)); + let r = output.parser().solc().compiler().enter(|compiler| -> Option<()> { + let (_, source) = compiler.gcx().get_ast_source(root.join(&path))?; + let source_map = compiler.sess().source_map(); + for item in source.ast.as_ref()?.items.iter() { + if let solar::ast::ItemKind::Contract(contract) = &item.kind { + let Some(contract_range) = span_to_range(source_map, item.span) else { + continue; + }; + contract_definitions + .push((contract.name.to_string(), contract_range.clone())); + collect_contract_debug_scopes( + source_map, + contract, + contract_range, + &mut debug_scopes, + ); } } - Ok(()) + Some(()) }); + if r.is_none() { + warn!("failed to parse contract definitions for {}", path.display()); + } } } - Self { source, language, path, contract_definitions } + Self { source, language, path, contract_definitions, debug_scopes } } /// Finds name of contract that contains given loc. @@ -70,6 +120,205 @@ impl SourceData { .find(|(_, r)| start >= r.start && end <= r.end) .map(|(name, _)| name.as_str()) } + + /// Finds the innermost Solidity function scope containing the given byte range. + pub fn find_debug_scope(&self, start: usize, end: usize) -> Option<&DebugSourceScope> { + self.debug_scopes + .iter() + .filter(|scope| start >= scope.range.start && end <= scope.range.end) + .min_by_key(|scope| scope.range.end.saturating_sub(scope.range.start)) + } +} + +fn collect_contract_debug_scopes( + source_map: &solar::interface::source_map::SourceMap, + contract: &ast::ItemContract<'_>, + contract_range: Range, + out: &mut Vec, +) { + let mut scopes = Vec::new(); + for item in contract.body.iter() { + let ast::ItemKind::Function(func) = &item.kind else { continue }; + if !func.is_implemented() { + continue; + } + + let Some(function_range) = span_to_range(source_map, item.span) else { continue }; + let body_range = + span_to_range(source_map, func.body_span).unwrap_or_else(|| function_range.clone()); + let function_name = function_name(func); + let parameters_src = + source_map.span_to_snippet(func.header.parameters.span).unwrap_or_default(); + let returns_src = func + .header + .returns + .as_ref() + .and_then(|returns| source_map.span_to_snippet(returns.span).ok()); + + let mut locals = Vec::new(); + if let Some(body) = &func.body { + collect_block_locals(source_map, body, body_range.clone(), &mut locals); + } + + scopes.push(DebugSourceScope { + contract_name: contract.name.to_string(), + function_name, + range: function_range.clone(), + body_range, + parameters_src, + returns_src, + parameters: variables_from_list( + source_map, + &func.header.parameters, + function_range.clone(), + ), + returns: func + .header + .returns + .as_ref() + .map(|returns| variables_from_list(source_map, returns, function_range.clone())) + .unwrap_or_default(), + locals, + }); + } + + // Keep inherited/inline source-map lookups deterministic when multiple ranges contain a PC. + scopes.sort_by_key(|scope| { + ( + scope.range.start, + scope.range.end.saturating_sub(scope.range.start), + scope.contract_name.clone(), + scope.function_name.clone(), + ) + }); + + // Do not let a malformed nested item extend past the contract it was collected from. + scopes.retain(|scope| { + scope.range.start >= contract_range.start && scope.range.end <= contract_range.end + }); + out.extend(scopes); +} + +fn function_name(func: &ast::ItemFunction<'_>) -> String { + match func.kind { + ast::FunctionKind::Constructor => "constructor".to_string(), + ast::FunctionKind::Fallback => "fallback".to_string(), + ast::FunctionKind::Receive => "receive".to_string(), + ast::FunctionKind::Modifier => { + func.header.name.as_ref().map(|n| n.as_str()).unwrap_or("modifier").to_string() + } + ast::FunctionKind::Function => { + func.header.name.as_ref().map(|n| n.as_str()).unwrap_or("function").to_string() + } + } +} + +fn variables_from_list( + source_map: &solar::interface::source_map::SourceMap, + vars: &[ast::VariableDefinition<'_>], + scope: Range, +) -> Vec { + vars.iter().filter_map(|var| variable_from_definition(source_map, var, scope.clone())).collect() +} + +fn collect_block_locals( + source_map: &solar::interface::source_map::SourceMap, + block: &ast::Block<'_>, + fallback_scope: Range, + out: &mut Vec, +) { + let scope = span_to_range(source_map, block.span).unwrap_or(fallback_scope); + for stmt in block.stmts.iter() { + collect_stmt_locals(source_map, stmt, scope.clone(), out); + } +} + +fn collect_stmt_locals( + source_map: &solar::interface::source_map::SourceMap, + stmt: &ast::Stmt<'_>, + scope: Range, + out: &mut Vec, +) { + match &stmt.kind { + ast::StmtKind::DeclSingle(var) => { + if let Some(var) = variable_from_definition(source_map, var, scope) { + out.push(var); + } + } + ast::StmtKind::DeclMulti(vars, _) => { + for var in vars.iter() { + let SpannedOption::Some(var) = var else { continue }; + if let Some(var) = variable_from_definition(source_map, var, scope.clone()) { + out.push(var); + } + } + } + ast::StmtKind::Block(block) | ast::StmtKind::UncheckedBlock(block) => { + collect_block_locals(source_map, block, scope, out); + } + ast::StmtKind::If(_, then_stmt, else_stmt) => { + let then_scope = + span_to_range(source_map, then_stmt.span).unwrap_or_else(|| scope.clone()); + collect_stmt_locals(source_map, then_stmt, then_scope, out); + if let Some(else_stmt) = else_stmt { + let else_scope = + span_to_range(source_map, else_stmt.span).unwrap_or_else(|| scope.clone()); + collect_stmt_locals(source_map, else_stmt, else_scope, out); + } + } + ast::StmtKind::For { init, body, .. } => { + let for_scope = span_to_range(source_map, stmt.span).unwrap_or_else(|| scope.clone()); + if let Some(init) = init { + collect_stmt_locals(source_map, init, for_scope.clone(), out); + } + collect_stmt_locals(source_map, body, for_scope, out); + } + ast::StmtKind::While(_, body) | ast::StmtKind::DoWhile(body, _) => { + let stmt_scope = span_to_range(source_map, stmt.span).unwrap_or(scope); + collect_stmt_locals(source_map, body, stmt_scope, out); + } + ast::StmtKind::Try(try_stmt) => { + for clause in try_stmt.clauses.iter() { + let clause_scope = + span_to_range(source_map, clause.span).unwrap_or_else(|| scope.clone()); + for arg in clause.args.iter() { + if let Some(arg) = + variable_from_definition(source_map, arg, clause_scope.clone()) + { + out.push(arg); + } + } + collect_block_locals(source_map, &clause.block, clause_scope, out); + } + } + ast::StmtKind::Assembly(_) + | ast::StmtKind::Break + | ast::StmtKind::Continue + | ast::StmtKind::Emit(..) + | ast::StmtKind::Expr(_) + | ast::StmtKind::Return(_) + | ast::StmtKind::Revert(..) + | ast::StmtKind::Placeholder => {} + } +} + +fn variable_from_definition( + source_map: &solar::interface::source_map::SourceMap, + var: &ast::VariableDefinition<'_>, + scope: Range, +) -> Option { + Some(DebugVariable { + name: var.name.map(|name| name.to_string()), + declaration: span_to_range(source_map, var.span)?, + scope, + }) +} + +fn span_to_range( + source_map: &solar::interface::source_map::SourceMap, + span: solar::interface::Span, +) -> Option> { + source_map.span_to_range(span).ok() } #[derive(Clone, Debug)] @@ -135,15 +384,12 @@ impl ContractSources { Ok(sources) } - pub fn insert>( + pub fn insert( &mut self, - output: &ProjectCompileOutput, + output: &ProjectCompileOutput, root: &Path, libraries: Option<&Libraries>, - ) -> Result<()> - where - C::Language: Into, - { + ) -> Result<()> { let link_data = libraries.map(|libraries| { let linker = Linker::new(root, output.artifact_ids().collect()); (linker, libraries) @@ -151,6 +397,7 @@ impl ContractSources { let artifacts: Vec<_> = output .artifact_ids() + .filter(|(id, _)| !is_external_artifact(&id.build_id)) .collect::>() .par_iter() .map(|(id, artifact)| { @@ -186,7 +433,10 @@ impl ContractSources { for (build_id, build) in output.builds() { for (source_id, path) in &build.source_id_to_path { if !path.exists() { - removed_files.insert(path); + // Preprocessor deploy helpers are compiled from memory and never exist on disk. + if !is_deploy_helper_path(path) { + removed_files.insert(path); + } continue; } @@ -197,9 +447,11 @@ impl ContractSources { })?; let stripped = path.strip_prefix(root).unwrap_or(path).to_path_buf(); let source_data = Arc::new(SourceData::new( - source.content.clone(), - build.language.into(), + output, + source.content, + build.language, stripped, + root, )); entry.insert(source_data.clone()); source_data @@ -303,3 +555,47 @@ impl ContractSources { }) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn variable(name: &str, declaration: Range, scope: Range) -> DebugVariable { + DebugVariable { name: Some(name.to_string()), declaration, scope } + } + + fn scope(locals: Vec) -> DebugSourceScope { + DebugSourceScope { + contract_name: "DebugMe".to_string(), + function_name: "foo".to_string(), + range: 0..100, + body_range: 10..90, + parameters_src: String::new(), + returns_src: None, + parameters: Vec::new(), + returns: Vec::new(), + locals, + } + } + + #[test] + fn visible_locals_require_declaration_and_scope() { + let scope = scope(vec![ + variable("before", 10..15, 10..90), + variable("after", 70..75, 10..90), + variable("nested", 20..25, 20..40), + ]); + + let names = |offset| { + scope + .visible_locals(offset) + .map(|variable| variable.name.as_deref().unwrap()) + .collect::>() + }; + + assert_eq!(names(14), Vec::<&str>::new()); + assert_eq!(names(30), ["before", "nested"]); + assert_eq!(names(50), ["before"]); + assert_eq!(names(80), ["before", "after"]); + } +} diff --git a/crates/evm/traces/src/decoder/base.rs b/crates/evm/traces/src/decoder/base.rs new file mode 100644 index 0000000000000..5a150713cb15f --- /dev/null +++ b/crates/evm/traces/src/decoder/base.rs @@ -0,0 +1,684 @@ +//! Base precompile ABIs for trace decoding. +//! +//! `base-common-precompiles` declares these interfaces without `#[sol(abi)]`, so it exposes no +//! [`JsonAbi`](alloy_json_abi::JsonAbi) for `register_address_abi` to consume. These declarations +//! mirror the canonical ones so traces name Base precompile calls, events, and errors, following +//! the same approach `super::monad` takes for its staking interfaces. +//! +//! The `abi_matches_canonical_selectors` tests below pin every selector against the canonical +//! types, so an interface change in Base fails here instead of silently decoding traces wrong. + +use alloy_sol_types::sol; + +sol! { + /// Mirror of `base_common_precompiles::IActivationRegistry`. + #[sol(abi)] + interface IActivationRegistry { + event FeatureActivated(bytes32 indexed feature, address indexed caller); + event FeatureDeactivated(bytes32 indexed feature, address indexed caller); + event AdminChanged( + address indexed previousAdmin, + address indexed newAdmin, + address indexed caller + ); + + error Unauthorized(address caller); + error AlreadyActivated(bytes32 feature); + error FeatureNotActivated(bytes32 feature); + error DelegateCallNotAllowed(); + error StaticCallNotAllowed(); + error NonPayable(); + error AdminStorageNotEnabled(); + error ZeroAdminAddress(); + + function isActivated(bytes32 feature) external view returns (bool); + function checkActivated(bytes32 feature) external view; + function admin() external view returns (address); + function setAdmin(address newAdmin) external; + function activate(bytes32 feature) external; + function deactivate(bytes32 feature) external; + } +} + +sol! { + /// Mirror of `base_common_precompiles::IB20Factory`, the surface frozen at Beryl. + #[sol(abi)] + interface IB20Factory { + enum B20Variant { + ASSET, + STABLECOIN + } + + struct B20StablecoinCreateParams { + uint8 version; + string name; + string symbol; + address initialAdmin; + string currency; + } + + struct B20AssetCreateParams { + uint8 version; + string name; + string symbol; + address initialAdmin; + uint8 decimals; + } + + error NonPayable(); + error TokenAlreadyExists(address token); + error InvalidVariant(); + error UnsupportedVersion(uint8 version, B20Variant variant); + error MissingRequiredField(string field); + error InvalidCurrency(string code); + error InvalidDecimals(uint8 decimals); + error InitCallFailed(uint256 index); + + event B20Created( + address indexed token, + B20Variant indexed variant, + string name, + string symbol, + uint8 decimals, + bytes variantParams + ); + + struct B20StablecoinEventParams { + uint8 version; + string currency; + } + + function createB20( + B20Variant variant, + bytes32 salt, + bytes calldata params, + bytes[] calldata initCalls + ) external returns (address token); + function getB20Address(B20Variant variant, address sender, bytes32 salt) external view returns (address); + function isB20(address token) external view returns (bool); + function isB20Initialized(address token) external view returns (bool); + } +} + +sol! { + /// **Deliberately partial** mirror of `base_common_precompiles::IB20`, the B-20 token surface. + /// + /// B-20 tokens are created by the factory at derived addresses, so they cannot be registered + /// per address like the fixed precompiles. These members are therefore available as a + /// Base-only fallback after address-scoped and caller-supplied metadata. + /// + /// Only Base-specific members belong here. The ERC-20, EIP-2612 and AccessControl portions are + /// omitted on purpose: the global function map is not network-scoped, so adding competing + /// candidates for `transfer`, `balanceOf`, `approve` or `permit` could change how ordinary + /// token traces decode in any Base-enabled build. Foundry already has to order + /// `IStorageCredits` against `ITIP20` to keep `balanceOf`'s return type stable, which is the + /// same hazard. `b20_surface_excludes_erc20_members` pins this boundary. + #[sol(abi)] + interface IB20Extensions { + enum PausableFeature { + TRANSFER, + MINT, + BURN, + SEIZE + } + + event Memo(address indexed caller, bytes32 indexed memo); + event BurnedBlocked(address indexed caller, address indexed from, uint256 amount); + event Seized(address indexed caller, address indexed from, address indexed to, uint256 amount); + event LastAdminRenounced(address indexed previousAdmin); + event Paused(address indexed updater, PausableFeature[] features); + event Unpaused(address indexed updater, PausableFeature[] features); + event PolicyUpdated(bytes32 indexed policyScope, uint64 oldPolicyId, uint64 newPolicyId); + + function mintWithMemo(address to, uint256 amount, bytes32 memo) external; + function burnWithMemo(uint256 amount, bytes32 memo) external; + function burnBlocked(address from, uint256 amount) external; + function transferWithMemo(address to, uint256 amount, bytes32 memo) external returns (bool); + function seizeWithMemo(address from, address to, uint256 amount, bytes32 memo) external; + function pausedFeatures() external view returns (PausableFeature[] memory); + function isPaused(PausableFeature feature) external view returns (bool); + function pause(PausableFeature[] calldata features) external; + function unpause(PausableFeature[] calldata features) external; + function policyId(bytes32 policyScope) external view returns (uint64); + function updatePolicy(bytes32 policyScope, uint64 newPolicyId) external; + function contractURI() external view returns (string); + function updateContractURI(string calldata newURI) external; + } +} + +sol! { + /// Mirror of `base_common_precompiles::IPolicyRegistry`, the canonical Cobalt surface. + /// + /// Only this surface is mirrored: its call set is a strict superset of Beryl's V1, so it also + /// names Beryl-era policy traces. `policy_registry_covers_v1_selectors` pins that. + #[sol(abi)] + interface IPolicyRegistry { + enum PolicyType { + BLOCKLIST, + ALLOWLIST, + UNION, + INTERSECT + } + + error NonPayable(); + error Unauthorized(); + error PolicyNotFound(); + error IncompatiblePolicyType(); + error ZeroAddress(); + error BatchSizeTooLarge(uint256 maxBatchSize); + error NoPendingAdmin(); + error ChildPoliciesOutsideOfRange(); + error InvalidChildPolicy(uint64 childPolicyId); + + event PolicyCreated(uint64 indexed policyId, address indexed creator, PolicyType policyType); + event PolicyAdminStaged(uint64 indexed policyId, address indexed currentAdmin, address indexed pendingAdmin); + event PolicyAdminUpdated(uint64 indexed policyId, address indexed previousAdmin, address indexed newAdmin); + event AllowlistUpdated(uint64 indexed policyId, address indexed updater, bool allowed, address[] accounts); + event BlocklistUpdated(uint64 indexed policyId, address indexed updater, bool blocked, address[] accounts); + event CompositePolicyUpdated(uint64 indexed policyId, address indexed updater, uint64[] childPolicyIds); + + function createPolicy(address admin, PolicyType policyType) external returns (uint64); + function createPolicyWithAccounts(address admin, PolicyType policyType, address[] calldata accounts) external returns (uint64); + function createCompositePolicy(address admin, PolicyType policyType, uint64[] calldata childPolicyIds) external returns (uint64); + function updateComposite(uint64 policyId, uint64[] calldata childPolicyIds) external; + function stageUpdateAdmin(uint64 policyId, address newAdmin) external; + function finalizeUpdateAdmin(uint64 policyId) external; + function renounceAdmin(uint64 policyId) external; + function updateAllowlist(uint64 policyId, bool allowed, address[] calldata accounts) external; + function updateBlocklist(uint64 policyId, bool blocked, address[] calldata accounts) external; + function isAuthorized(uint64 policyId, address account) external view returns (bool); + function MIN_COMPOSITE_CHILD_POLICIES() external view returns (uint256); + function MAX_COMPOSITE_CHILD_POLICIES() external view returns (uint256); + function policyExists(uint64 policyId) external view returns (bool); + function policyAdmin(uint64 policyId) external view returns (address); + function pendingPolicyAdmin(uint64 policyId) external view returns (address); + function compositePolicyChildIds(uint64 policyId) external view returns (uint64[] memory); + } +} + +sol! { + /// Mirror of `base_common_precompiles::INonceManager`. + #[sol(abi)] + interface INonceManager { + error DelegateCallNotAllowed(); + error NonPayable(); + error ProtocolNonceNotSupported(); + error InvalidNonceKey(); + error NonceOverflow(); + error InvalidExpiringNonceExpiry(); + error ExpiringNonceReplay(); + error ExpiringNonceSetFull(); + + event NonceIncremented(address indexed account, uint256 indexed nonceKey, uint64 newNonce); + + function getNonce(address account, uint256 nonceKey) external view returns (uint64); + } +} + +sol! { + /// Mirror of `base_common_precompiles::ITransactionContext`. + #[sol(abi)] + interface ITransactionContext { + error DelegateCallNotAllowed(); + error NonPayable(); + + function getTransactionSender() external view returns (address); + function getTransactionPayer() external view returns (address); + function getTransactionSenderActorId() external view returns (bytes32); + } +} + +#[cfg(test)] +mod tests { + use crate::{CallTrace, CallTraceDecoderBuilder}; + use alloy_dyn_abi::{DynSolValue, FunctionExt}; + use alloy_primitives::{Address, B256, Bytes, U256}; + use alloy_sol_types::{SolCall, SolEnum, SolError, SolEvent, SolInterface}; + use base_common_precompiles::{ + self as canonical, ActivationRegistryStorage, B20FactoryStorage, NonceManagerStorage, + TxContextStorage, + }; + use foundry_evm_hardforks::{BaseUpgrade, FoundryHardfork}; + use foundry_evm_networks::NetworkConfigs; + use revm::interpreter::InstructionResult; + + #[tokio::test] + async fn registered_abis_decode_base_precompile_calls() { + let decoder = CallTraceDecoderBuilder::new() + .with_networks(NetworkConfigs::with_base()) + .with_hardfork(Some(FoundryHardfork::Base(BaseUpgrade::Cobalt))) + .build(); + + let activate = + super::IActivationRegistry::activateCall { feature: B256::repeat_byte(0x11) }; + let trace = CallTrace { + address: ActivationRegistryStorage::ADDRESS, + data: activate.abi_encode().into(), + success: true, + ..Default::default() + }; + let decoded = decoder.decode_function(&trace).await; + assert_eq!(decoded.label.as_deref(), Some("ActivationRegistry")); + assert_eq!( + decoded.call_data.expect("activate should decode").signature, + "activate(bytes32)" + ); + + let get_nonce = super::INonceManager::getNonceCall { + account: Address::repeat_byte(0x22), + nonceKey: U256::from(7), + }; + let trace = CallTrace { + address: NonceManagerStorage::ADDRESS, + data: get_nonce.abi_encode().into(), + success: true, + ..Default::default() + }; + let decoded = decoder.decode_function(&trace).await; + assert_eq!( + decoded.call_data.expect("getNonce should decode").signature, + "getNonce(address,uint256)" + ); + + let create = super::IB20Factory::createB20Call { + variant: super::IB20Factory::B20Variant::ASSET, + salt: B256::repeat_byte(0x33), + params: Bytes::new(), + initCalls: Vec::new(), + }; + let trace = CallTrace { + address: B20FactoryStorage::ADDRESS, + data: create.abi_encode().into(), + success: true, + ..Default::default() + }; + let decoded = decoder.decode_function(&trace).await; + assert_eq!(decoded.label.as_deref(), Some("B20Factory")); + let signature = decoded.call_data.expect("createB20 should decode").signature; + assert!(signature.starts_with("createB20("), "{signature}"); + } + + #[tokio::test] + async fn b20_transfer_with_memo_decodes_bool_output() { + let call = super::IB20Extensions::transferWithMemoCall { + to: Address::repeat_byte(0x11), + amount: U256::ONE, + memo: B256::repeat_byte(0x22), + }; + let abi = super::IB20Extensions::abi::contract(); + let function = abi.functions.get("transferWithMemo").unwrap().first().unwrap(); + let trace = CallTrace { + address: Address::repeat_byte(0x33), + data: call.abi_encode().into(), + output: function.abi_encode_output(&[DynSolValue::Bool(true)]).unwrap().into(), + success: true, + ..Default::default() + }; + + let base = + CallTraceDecoderBuilder::new().with_networks(NetworkConfigs::with_base()).build(); + let decoded = base.decode_function(&trace).await; + assert_eq!( + decoded.call_data.unwrap().signature, + "transferWithMemo(address,uint256,bytes32)" + ); + assert_eq!(decoded.return_data.as_deref(), Some("true")); + + let custom_abi = alloy_json_abi::JsonAbi::parse([ + "function transferWithMemo(address,uint256,bytes32) returns (uint256)", + ]) + .unwrap(); + let custom_base = CallTraceDecoderBuilder::new() + .with_networks(NetworkConfigs::with_base()) + .with_abi(&custom_abi) + .build(); + assert_eq!(custom_base.decode_function(&trace).await.return_data.as_deref(), Some("1")); + + let tempo = + CallTraceDecoderBuilder::new().with_networks(NetworkConfigs::with_tempo()).build(); + assert_eq!(tempo.decode_function(&trace).await.return_data, None); + } + + #[tokio::test] + async fn base_precompile_errors_are_address_scoped() { + let trace = CallTrace { + address: ActivationRegistryStorage::ADDRESS, + output: super::IActivationRegistry::NonPayable {}.abi_encode().into(), + success: false, + status: Some(InstructionResult::Revert), + ..Default::default() + }; + let base = CallTraceDecoderBuilder::new() + .with_networks(NetworkConfigs::with_base()) + .with_hardfork(Some(FoundryHardfork::Base(BaseUpgrade::Beryl))) + .build(); + assert_eq!(base.decode_function(&trace).await.return_data.as_deref(), Some("NonPayable()")); + + let ethereum = CallTraceDecoderBuilder::new() + .with_networks(NetworkConfigs::with_ethereum()) + .with_hardfork(Some(FoundryHardfork::Base(BaseUpgrade::Beryl))) + .build(); + assert_ne!( + ethereum.decode_function(&trace).await.return_data.as_deref(), + Some("NonPayable()") + ); + } + + /// The transaction-context precompile only exists from Cobalt, so a Beryl decoder must not + /// name its calls. This uses a Base-unique selector on purpose: generic names like + /// `getNonce(address,uint256)` also exist in Tempo's globally registered nonce ABI and would + /// decode regardless of the Base upgrade. + #[tokio::test] + async fn pre_cobalt_decoder_does_not_register_eip8130_surfaces() { + let call = super::ITransactionContext::getTransactionSenderActorIdCall {}; + let trace = CallTrace { + address: TxContextStorage::ADDRESS, + data: call.abi_encode().into(), + success: true, + ..Default::default() + }; + + let beryl = CallTraceDecoderBuilder::new() + .with_networks(NetworkConfigs::with_base()) + .with_hardfork(Some(FoundryHardfork::Base(BaseUpgrade::Beryl))) + .build(); + assert!( + beryl.decode_function(&trace).await.call_data.is_none(), + "Beryl must not decode a Cobalt-only precompile" + ); + + let cobalt = CallTraceDecoderBuilder::new() + .with_networks(NetworkConfigs::with_base()) + .with_hardfork(Some(FoundryHardfork::Base(BaseUpgrade::Cobalt))) + .build(); + assert_eq!( + cobalt.decode_function(&trace).await.call_data.expect("Cobalt should decode").signature, + "getTransactionSenderActorId()" + ); + } + + #[test] + fn activation_registry_abi_matches_canonical_selectors() { + assert_eq!( + super::IActivationRegistry::activateCall::SELECTOR, + canonical::IActivationRegistry::activateCall::SELECTOR + ); + assert_eq!( + super::IActivationRegistry::deactivateCall::SELECTOR, + canonical::IActivationRegistry::deactivateCall::SELECTOR + ); + assert_eq!( + super::IActivationRegistry::isActivatedCall::SELECTOR, + canonical::IActivationRegistry::isActivatedCall::SELECTOR + ); + assert_eq!( + super::IActivationRegistry::checkActivatedCall::SELECTOR, + canonical::IActivationRegistry::checkActivatedCall::SELECTOR + ); + assert_eq!( + super::IActivationRegistry::adminCall::SELECTOR, + canonical::IActivationRegistry::adminCall::SELECTOR + ); + assert_eq!( + super::IActivationRegistry::setAdminCall::SELECTOR, + canonical::IActivationRegistry::setAdminCall::SELECTOR + ); + assert_eq!( + super::IActivationRegistry::Unauthorized::SELECTOR, + canonical::IActivationRegistry::Unauthorized::SELECTOR + ); + assert_eq!( + super::IActivationRegistry::StaticCallNotAllowed::SELECTOR, + canonical::IActivationRegistry::StaticCallNotAllowed::SELECTOR + ); + assert_eq!( + super::IActivationRegistry::NonPayable::SELECTOR, + canonical::IActivationRegistry::NonPayable::SELECTOR + ); + assert_eq!( + super::IActivationRegistry::FeatureActivated::SIGNATURE_HASH, + canonical::IActivationRegistry::FeatureActivated::SIGNATURE_HASH + ); + assert_eq!( + super::IActivationRegistry::AdminChanged::SIGNATURE_HASH, + canonical::IActivationRegistry::AdminChanged::SIGNATURE_HASH + ); + } + + /// Base pins this surface with an `AbiFingerprint`, but that type is only exported under its + /// `test-utils` feature, so this compares against the canonical types directly instead. + /// `B20Variant`'s ordinals are asserted explicitly because they are the one thing selectors + /// cannot catch: Solidity encodes enums as `uint8`, so a reorder moves no signature, yet the + /// ordinal is byte `[10]` of every token address the factory deploys. + #[test] + fn b20_factory_abi_matches_canonical_surface() { + assert_eq!( + super::IB20Factory::createB20Call::SELECTOR, + canonical::IB20Factory::createB20Call::SELECTOR + ); + assert_eq!( + super::IB20Factory::getB20AddressCall::SELECTOR, + canonical::IB20Factory::getB20AddressCall::SELECTOR + ); + assert_eq!( + super::IB20Factory::isB20Call::SELECTOR, + canonical::IB20Factory::isB20Call::SELECTOR + ); + assert_eq!( + super::IB20Factory::isB20InitializedCall::SELECTOR, + canonical::IB20Factory::isB20InitializedCall::SELECTOR + ); + assert_eq!( + super::IB20Factory::TokenAlreadyExists::SELECTOR, + canonical::IB20Factory::TokenAlreadyExists::SELECTOR + ); + assert_eq!( + super::IB20Factory::UnsupportedVersion::SELECTOR, + canonical::IB20Factory::UnsupportedVersion::SELECTOR + ); + assert_eq!( + super::IB20Factory::B20Created::SIGNATURE_HASH, + canonical::IB20Factory::B20Created::SIGNATURE_HASH + ); + + assert_eq!( + super::IB20Factory::B20Variant::COUNT, + canonical::IB20Factory::B20Variant::COUNT + ); + assert_eq!( + super::IB20Factory::B20Variant::ASSET as u8, + canonical::IB20Factory::B20Variant::ASSET as u8 + ); + assert_eq!( + super::IB20Factory::B20Variant::STABLECOIN as u8, + canonical::IB20Factory::B20Variant::STABLECOIN as u8 + ); + } + + /// `PolicyType` ordinals ride the top byte of every policy ID, so they are asserted alongside + /// the selectors for the same reason as `B20Variant`. + #[test] + fn policy_registry_abi_matches_canonical_surface() { + assert_eq!( + super::IPolicyRegistry::createPolicyCall::SELECTOR, + canonical::IPolicyRegistry::createPolicyCall::SELECTOR + ); + assert_eq!( + super::IPolicyRegistry::createCompositePolicyCall::SELECTOR, + canonical::IPolicyRegistry::createCompositePolicyCall::SELECTOR + ); + assert_eq!( + super::IPolicyRegistry::isAuthorizedCall::SELECTOR, + canonical::IPolicyRegistry::isAuthorizedCall::SELECTOR + ); + assert_eq!( + super::IPolicyRegistry::updateAllowlistCall::SELECTOR, + canonical::IPolicyRegistry::updateAllowlistCall::SELECTOR + ); + assert_eq!( + super::IPolicyRegistry::InvalidChildPolicy::SELECTOR, + canonical::IPolicyRegistry::InvalidChildPolicy::SELECTOR + ); + assert_eq!( + super::IPolicyRegistry::PolicyCreated::SIGNATURE_HASH, + canonical::IPolicyRegistry::PolicyCreated::SIGNATURE_HASH + ); + assert_eq!( + super::IPolicyRegistry::CompositePolicyUpdated::SIGNATURE_HASH, + canonical::IPolicyRegistry::CompositePolicyUpdated::SIGNATURE_HASH + ); + + assert_eq!( + super::IPolicyRegistry::PolicyType::COUNT, + canonical::IPolicyRegistry::PolicyType::COUNT + ); + for (mirror, expected) in [ + ( + super::IPolicyRegistry::PolicyType::BLOCKLIST as u8, + canonical::IPolicyRegistry::PolicyType::BLOCKLIST as u8, + ), + ( + super::IPolicyRegistry::PolicyType::ALLOWLIST as u8, + canonical::IPolicyRegistry::PolicyType::ALLOWLIST as u8, + ), + ( + super::IPolicyRegistry::PolicyType::UNION as u8, + canonical::IPolicyRegistry::PolicyType::UNION as u8, + ), + ( + super::IPolicyRegistry::PolicyType::INTERSECT as u8, + canonical::IPolicyRegistry::PolicyType::INTERSECT as u8, + ), + ] { + assert_eq!(mirror, expected); + } + } + + /// Mirroring only the Cobalt surface is safe because it is a strict superset of Beryl's. If + /// Base ever diverges a shared signature, this fails and V1 needs its own mirror. + #[test] + fn policy_registry_covers_v1_selectors() { + let mirrored: Vec<[u8; 4]> = + super::IPolicyRegistry::IPolicyRegistryCalls::selectors().collect(); + for selector in canonical::IPolicyRegistryV1::IPolicyRegistryCalls::selectors() { + assert!( + mirrored.contains(&selector), + "Beryl selector {selector:?} is absent from the mirrored Cobalt surface" + ); + } + } + + #[test] + fn b20_extensions_abi_matches_canonical_surface() { + assert_eq!( + super::IB20Extensions::mintWithMemoCall::SELECTOR, + canonical::IB20::mintWithMemoCall::SELECTOR + ); + assert_eq!( + super::IB20Extensions::burnWithMemoCall::SELECTOR, + canonical::IB20::burnWithMemoCall::SELECTOR + ); + assert_eq!( + super::IB20Extensions::burnBlockedCall::SELECTOR, + canonical::IB20::burnBlockedCall::SELECTOR + ); + assert_eq!( + super::IB20Extensions::seizeWithMemoCall::SELECTOR, + canonical::IB20::seizeWithMemoCall::SELECTOR + ); + assert_eq!( + super::IB20Extensions::policyIdCall::SELECTOR, + canonical::IB20::policyIdCall::SELECTOR + ); + assert_eq!( + super::IB20Extensions::pauseCall::SELECTOR, + canonical::IB20::pauseCall::SELECTOR + ); + assert_eq!( + super::IB20Extensions::contractURICall::SELECTOR, + canonical::IB20::contractURICall::SELECTOR + ); + assert_eq!( + super::IB20Extensions::Seized::SIGNATURE_HASH, + canonical::IB20::Seized::SIGNATURE_HASH + ); + assert_eq!( + super::IB20Extensions::Memo::SIGNATURE_HASH, + canonical::IB20::Memo::SIGNATURE_HASH + ); + assert_eq!( + super::IB20Extensions::PolicyUpdated::SIGNATURE_HASH, + canonical::IB20::PolicyUpdated::SIGNATURE_HASH + ); + + // Pausable ordinals are load-bearing in `pause`/`unpause` calldata. + assert_eq!( + super::IB20Extensions::PausableFeature::COUNT, + canonical::IB20::PausableFeature::COUNT + ); + assert_eq!( + super::IB20Extensions::PausableFeature::SEIZE as u8, + canonical::IB20::PausableFeature::SEIZE as u8 + ); + } + + /// This surface is registered globally rather than per address, so it must never carry the + /// ERC-20, EIP-2612 or AccessControl members: a competing candidate for those selectors could + /// change how ordinary token traces decode on every network in a Base-enabled build. + #[test] + fn b20_surface_excludes_erc20_members() { + let mirrored: Vec<[u8; 4]> = + super::IB20Extensions::IB20ExtensionsCalls::selectors().collect(); + for excluded in [ + canonical::IB20::transferCall::SELECTOR, + canonical::IB20::transferFromCall::SELECTOR, + canonical::IB20::approveCall::SELECTOR, + canonical::IB20::balanceOfCall::SELECTOR, + canonical::IB20::allowanceCall::SELECTOR, + canonical::IB20::permitCall::SELECTOR, + canonical::IB20::hasRoleCall::SELECTOR, + canonical::IB20::grantRoleCall::SELECTOR, + ] { + assert!( + !mirrored.contains(&excluded), + "standard selector {excluded:?} must stay out of the global map" + ); + } + } + + #[test] + fn nonce_manager_abi_matches_canonical_selectors() { + assert_eq!( + super::INonceManager::getNonceCall::SELECTOR, + canonical::INonceManager::getNonceCall::SELECTOR + ); + assert_eq!( + super::INonceManager::ExpiringNonceReplay::SELECTOR, + canonical::INonceManager::ExpiringNonceReplay::SELECTOR + ); + assert_eq!( + super::INonceManager::InvalidExpiringNonceExpiry::SELECTOR, + canonical::INonceManager::InvalidExpiringNonceExpiry::SELECTOR + ); + assert_eq!( + super::INonceManager::NonceIncremented::SIGNATURE_HASH, + canonical::INonceManager::NonceIncremented::SIGNATURE_HASH + ); + } + + #[test] + fn transaction_context_abi_matches_canonical_selectors() { + assert_eq!( + super::ITransactionContext::getTransactionSenderCall::SELECTOR, + canonical::ITransactionContext::getTransactionSenderCall::SELECTOR + ); + assert_eq!( + super::ITransactionContext::getTransactionPayerCall::SELECTOR, + canonical::ITransactionContext::getTransactionPayerCall::SELECTOR + ); + assert_eq!( + super::ITransactionContext::getTransactionSenderActorIdCall::SELECTOR, + canonical::ITransactionContext::getTransactionSenderActorIdCall::SELECTOR + ); + } +} diff --git a/crates/evm/traces/src/decoder/mod.rs b/crates/evm/traces/src/decoder/mod.rs index 616e78acbdad3..f66975481d58c 100644 --- a/crates/evm/traces/src/decoder/mod.rs +++ b/crates/evm/traces/src/decoder/mod.rs @@ -1,35 +1,93 @@ use crate::{ - CallTrace, CallTraceArena, CallTraceNode, DecodedCallData, + CallTrace, CallTraceArena, CallTraceNode, DecodedCallData, DecodedTraceStep, debug::DebugTraceIdentifier, + erc8021, identifier::{IdentifiedAddress, LocalTraceIdentifier, SignaturesIdentifier, TraceIdentifier}, }; -use alloy_dyn_abi::{DecodedEvent, DynSolValue, EventExt, FunctionExt, JsonAbiExt}; -use alloy_json_abi::{Error, Event, Function, JsonAbi}; +use alloy_dyn_abi::{ + DecodedEvent as AlloyDecodedEvent, DynSolValue, EventExt, FunctionExt, JsonAbiExt, +}; +use alloy_json_abi::{Constructor, Error, Event, Function, JsonAbi}; use alloy_primitives::{ - Address, B256, LogData, Selector, - map::{HashMap, HashSet, hash_map::Entry}, + Address, B256, LogData, Selector, U256, + map::{AddressHashMap, HashMap, HashSet}, }; +use alloy_sol_types::SolValue; use foundry_common::{ ContractsByArtifact, SELECTOR_LEN, abi::get_indexed_event, fmt::format_token, - get_contract_name, selectors::SelectorKind, + get_contract_name, selectors::SelectorKind, tempo::OUSD_ADDRESS, }; +use foundry_config::TracingConfig; use foundry_evm_core::{ abi::{Vm, console}, - constants::{ - CALLER, CHEATCODE_ADDRESS, DEFAULT_CREATE2_DEPLOYER, HARDHAT_CONSOLE_ADDRESS, - TEST_CONTRACT_ADDRESS, - }, + constants::{CALLER, CHEATCODE_ADDRESS, DEFAULT_CREATE2_DEPLOYER, HARDHAT_CONSOLE_ADDRESS}, decode::RevertDecoder, precompiles::{ - BLAKE_2F, EC_ADD, EC_MUL, EC_PAIRING, EC_RECOVER, IDENTITY, MOD_EXP, POINT_EVALUATION, - RIPEMD_160, SHA_256, + BLAKE_2F, BLS12_G1ADD, BLS12_G1MSM, BLS12_G2ADD, BLS12_G2MSM, BLS12_MAP_FP_TO_G1, + BLS12_MAP_FP2_TO_G2, BLS12_PAIRING_CHECK, CELO_TRANSFER, EC_ADD, EC_MUL, EC_PAIRING, + EC_RECOVER, IDENTITY, MOD_EXP, POINT_EVALUATION, RIPEMD_160, SHA_256, }, }; +use foundry_evm_hardforks::{ExecutionSpec, FoundryHardfork, TempoHardfork}; +use foundry_evm_networks::{ + NetworkConfigs, NetworkVariant, TEMPO_PRECOMPILES, celo::transfer::CELO_TRANSFER_LABEL, +}; use itertools::Itertools; +use revm::{bytecode::opcode::OpCode, interpreter::InstructionResult}; use revm_inspectors::tracing::types::{DecodedCallLog, DecodedCallTrace}; use std::{collections::BTreeMap, sync::OnceLock}; +use tempo_contracts::precompiles::{ + CURRENT_COMMITTEE_ADDRESS, IAccountKeychain, IAddressRegistry, ICurrentCommittee, IFeeManager, + IReceivePolicyGuard, ISignatureVerifier, IStablecoinDEX, IStorageCredits, ITIP20ChannelReserve, + ITIP20Factory, ITIP403Registry, IValidatorConfig, +}; +use tempo_precompiles::{ + PATH_USD_ADDRESS, RECEIVE_POLICY_GUARD_ADDRESS, TIP403_REGISTRY_ADDRESS, nonce::INonce, + tip20::ITIP20, +}; + +#[cfg(feature = "base")] +use base_common_precompiles::{ + ActivationRegistryStorage, B20FactoryStorage, NonceManagerStorage, PolicyRegistryStorage, + TxContextStorage, +}; +#[cfg(feature = "base")] +use foundry_evm_hardforks::BaseSpecId; +#[cfg(feature = "base")] +use foundry_evm_networks::{active_base_precompiles, is_base_precompile_active_at}; + +#[cfg(feature = "monad")] +type MonadHardfork = foundry_evm_hardforks::MonadHardfork; -mod precompiles; +#[cfg(feature = "base")] +mod base; +#[cfg(feature = "monad")] +mod monad; +pub(crate) mod precompiles; + +/// Address-scoped events keyed by signature and indexed input count; anonymous events have no +/// signature. +type AddressEvents = HashMap, usize), Vec>>; + +/// A decoded event with both display-formatted parameters and their underlying ABI values. +#[derive(Debug, Default)] +pub struct DecodedEvent { + /// The event name or canonical signature. + pub name: Option, + /// Event parameter names, display-formatted values, and decoded ABI values. + pub params: Option>, +} + +impl DecodedEvent { + fn into_call_log(self) -> DecodedCallLog { + DecodedCallLog { + name: self.name, + params: self.params.map(|params| { + params.into_iter().map(|(name, display, _)| (name, display)).collect() + }), + } + } +} /// Build a new [CallTraceDecoder]. #[derive(Default)] @@ -55,7 +113,16 @@ impl CallTraceDecoderBuilder { /// Add known errors to the decoder. #[inline] pub fn with_abi(mut self, abi: &JsonAbi) -> Self { - self.decoder.collect_abi(abi, None); + self.decoder.collect_abi(abi, None, true); + self + } + + /// Add events from an ABI for a specific contract address. + #[inline] + pub fn with_address_events(mut self, address: Address, abi: &JsonAbi) -> Self { + for event in abi.events() { + self.decoder.push_address_event(address, event.clone()); + } self } @@ -64,7 +131,7 @@ impl CallTraceDecoderBuilder { pub fn with_known_contracts(mut self, contracts: &ContractsByArtifact) -> Self { trace!(target: "evm::traces", len=contracts.len(), "collecting known contract ABIs"); for contract in contracts.values() { - self.decoder.collect_abi(&contract.abi, None); + self.decoder.collect_abi(&contract.abi, None, true); } self } @@ -77,11 +144,21 @@ impl CallTraceDecoderBuilder { /// Sets the verbosity level of the decoder. #[inline] - pub fn with_verbosity(mut self, level: u8) -> Self { + pub const fn with_verbosity(mut self, level: u8) -> Self { self.decoder.verbosity = level; self } + /// Applies trace rendering settings. + #[inline] + pub fn with_tracing_config(mut self, config: &TracingConfig) -> Self { + self.decoder.labels.extend(config.labels.clone()); + self.decoder.verbosity = config.verbosity; + self.decoder.disable_labels = config.disable_labels; + self.decoder.compact_labels = config.compact_labels; + self + } + /// Sets the signature identifier for events and functions. #[inline] pub fn with_signature_identifier(mut self, identifier: SignaturesIdentifier) -> Self { @@ -91,11 +168,45 @@ impl CallTraceDecoderBuilder { /// Sets the signature identifier for events and functions. #[inline] - pub fn with_label_disabled(mut self, disable_alias: bool) -> Self { + pub const fn with_label_disabled(mut self, disable_alias: bool) -> Self { self.decoder.disable_labels = disable_alias; self } + /// Sets the chain ID for network-specific precompile detection. + #[inline] + pub const fn with_chain_id(mut self, chain_id: Option) -> Self { + self.decoder.chain_id = chain_id; + self + } + + /// Sets the execution network used for network-specific precompile detection. + #[inline] + pub fn with_execution_network(self, network: NetworkVariant) -> Self { + self.with_networks(network.into()) + } + + /// Sets the complete execution profile used for network-specific precompile detection. + #[inline] + pub const fn with_networks(mut self, networks: NetworkConfigs) -> Self { + self.decoder.networks = Some(networks); + self + } + + /// Sets the hardfork used for network-specific metadata and precompile detection. + #[inline] + pub const fn with_hardfork(mut self, hardfork: Option) -> Self { + self.decoder.hardfork = hardfork; + self + } + + /// Hides addresses in trace parameters when a label is available. + #[inline] + pub const fn with_compact_labels(mut self, compact: bool) -> Self { + self.decoder.compact_labels = compact; + self + } + /// Sets the debug identifier for the decoder. #[inline] pub fn with_debug_identifier(mut self, identifier: DebugTraceIdentifier) -> Self { @@ -105,7 +216,13 @@ impl CallTraceDecoderBuilder { /// Build the decoder. #[inline] - pub fn build(self) -> CallTraceDecoder { + pub fn build(mut self) -> CallTraceDecoder { + self.decoder.base_labels = self.decoder.labels.clone(); + self.decoder.register_celo_metadata(); + #[cfg(feature = "monad")] + self.decoder.register_monad_metadata(); + #[cfg(feature = "base")] + self.decoder.register_base_metadata(); self.decoder } } @@ -125,6 +242,8 @@ pub struct CallTraceDecoder { pub contracts: HashMap, /// Address labels. pub labels: HashMap, + /// Labels configured when the decoder was built. + base_labels: HashMap, /// Contract addresses that have a receive function. pub receive_contracts: HashSet
, /// Contract addresses that have fallback functions, mapped to function selectors of that @@ -136,10 +255,18 @@ pub struct CallTraceDecoder { /// All known functions. pub functions: HashMap>, + /// Functions identified for a specific contract address. + pub functions_by_address: HashMap>>, + /// Constructors identified for a specific contract address. + pub constructors_by_address: HashMap, + /// Creation input offsets where ABI-encoded constructor arguments begin. + pub constructor_args_offsets: HashMap, /// All known events. /// /// Key is: `(topics[0], topics.len() - 1)`. pub events: BTreeMap<(B256, usize), Vec>, + /// Regular and anonymous events identified for a specific contract address. + events_by_address: Option>, /// Revert decoder. Contains all known custom errors. pub revert_decoder: RevertDecoder, @@ -153,9 +280,84 @@ pub struct CallTraceDecoder { /// Disable showing of labels. pub disable_labels: bool, + + /// The chain ID, used to determine network-specific precompiles. + pub chain_id: Option, + + /// Execution profile, kept separate from the source chain used for external identification. + networks: Option, + + /// Detailed opcodes for analysis. + pub opcodes: Vec, + + /// The hardfork used to determine network-specific metadata and precompiles. + hardfork: Option, + + /// Hide addresses when a label is available, showing only the label. + pub compact_labels: bool, } impl CallTraceDecoder { + fn register_celo_metadata(&mut self) { + if precompiles::is_known_precompile( + CELO_TRANSFER, + self.networks, + self.chain_id, + self.hardfork, + ) { + self.labels.entry(CELO_TRANSFER).or_insert_with(|| CELO_TRANSFER_LABEL.to_string()); + } + } + + #[cfg(feature = "base")] + fn register_base_metadata(&mut self) { + if self.networks.is_some_and(|networks| !networks.is_base()) { + return; + } + let Some(upgrade) = + self.hardfork.and_then(BaseSpecId::from_foundry_hardfork).map(|spec| spec.upgrade()) + else { + return; + }; + + for (label, address) in active_base_precompiles(upgrade) { + self.labels.entry(address).or_insert_with(|| label.to_string()); + } + + // Labels alone leave calls showing a raw selector, so register the ABIs too. Each is + // scoped to its address and gated on the upgrade that installs it. + if is_base_precompile_active_at(ActivationRegistryStorage::ADDRESS, upgrade) { + self.register_address_abi( + ActivationRegistryStorage::ADDRESS, + &base::IActivationRegistry::abi::contract(), + ); + } + if is_base_precompile_active_at(PolicyRegistryStorage::ADDRESS, upgrade) { + self.register_address_abi( + PolicyRegistryStorage::ADDRESS, + &base::IPolicyRegistry::abi::contract(), + ); + } + if is_base_precompile_active_at(B20FactoryStorage::ADDRESS, upgrade) { + self.register_address_abi( + B20FactoryStorage::ADDRESS, + &base::IB20Factory::abi::contract(), + ); + } + if is_base_precompile_active_at(NonceManagerStorage::ADDRESS, upgrade) { + self.register_address_abi( + NonceManagerStorage::ADDRESS, + &base::INonceManager::abi::contract(), + ); + } + if is_base_precompile_active_at(TxContextStorage::ADDRESS, upgrade) { + self.register_address_abi( + TxContextStorage::ADDRESS, + &base::ITransactionContext::abi::contract(), + ); + } + } + /// Creates a new call trace decoder. /// /// The call trace decoder always knows how to decode calls to the cheatcode address, as well @@ -167,42 +369,130 @@ impl CallTraceDecoder { INIT.get_or_init(Self::init) } + /// Returns the hardfork used for network-specific metadata. + pub const fn hardfork(&self) -> Option { + self.hardfork + } + + /// Rebuilds address-scoped metadata for a new hardfork. + /// + /// Hardfork changes invalidate previously identified addresses because the set of active + /// precompiles can change. Global ABI and signature metadata is preserved. + pub fn set_hardfork(&mut self, hardfork: Option) { + if self.hardfork == hardfork { + return; + } + self.hardfork = hardfork; + self.clear_addresses(); + } + + #[instrument(name = "CallTraceDecoder::init", level = "debug")] fn init() -> Self { + // Materialized once so the revert decoder can take references below. + let tempo_abis = [ + IFeeManager::abi::contract(), + ITIP20::abi::contract(), + ITIP403Registry::abi::contract(), + ITIP20Factory::abi::contract(), + IStablecoinDEX::abi::contract(), + IStorageCredits::abi::contract(), + INonce::abi::contract(), + IValidatorConfig::abi::contract(), + IAccountKeychain::abi::contract(), + IAddressRegistry::abi::contract(), + ITIP20ChannelReserve::abi::contract(), + ISignatureVerifier::abi::contract(), + IReceivePolicyGuard::abi::contract(), + ]; + let mut labels = HashMap::from_iter([ + (CHEATCODE_ADDRESS, "VM".to_string()), + (HARDHAT_CONSOLE_ADDRESS, "console".to_string()), + (DEFAULT_CREATE2_DEPLOYER, "Create2Deployer".to_string()), + (CALLER, "DefaultSender".to_string()), + (EC_RECOVER, "ECRecover".to_string()), + (SHA_256, "SHA-256".to_string()), + (RIPEMD_160, "RIPEMD-160".to_string()), + (IDENTITY, "Identity".to_string()), + (MOD_EXP, "ModExp".to_string()), + (EC_ADD, "ECAdd".to_string()), + (EC_MUL, "ECMul".to_string()), + (EC_PAIRING, "ECPairing".to_string()), + (BLAKE_2F, "Blake2F".to_string()), + (POINT_EVALUATION, "PointEvaluation".to_string()), + (BLS12_G1ADD, "BLS12_G1ADD".to_string()), + (BLS12_G1MSM, "BLS12_G1MSM".to_string()), + (BLS12_G2ADD, "BLS12_G2ADD".to_string()), + (BLS12_G2MSM, "BLS12_G2MSM".to_string()), + (BLS12_PAIRING_CHECK, "BLS12_PAIRING_CHECK".to_string()), + (BLS12_MAP_FP_TO_G1, "BLS12_MAP_FP_TO_G1".to_string()), + (BLS12_MAP_FP2_TO_G2, "BLS12_MAP_FP2_TO_G2".to_string()), + // Tempo + (PATH_USD_ADDRESS, "PathUSD".to_string()), + (OUSD_ADDRESS, "OUSD".to_string()), + ]); + // `CurrentCommittee` is only labeled in an active Tempo T8 context, see `decode_function`. + labels.extend( + TEMPO_PRECOMPILES + .iter() + .filter(|(_, address)| *address != CURRENT_COMMITTEE_ADDRESS) + .map(|(label, address)| (*address, (*label).to_string())), + ); + + let functions = console::hh::abi::functions() + .into_values() + .chain(Vm::abi::functions().into_values()) + .chain(IFeeManager::abi::functions().into_values()) + // `IStorageCredits` shares the `balanceOf(address)` selector with `ITIP20`, so it + // must be chained first to keep `ITIP20`'s `uint256` return as the global fallback. + .chain(IStorageCredits::abi::functions().into_values()) + .chain(ITIP20::abi::functions().into_values()) + .chain(ITIP403Registry::abi::functions().into_values()) + .chain(ITIP20Factory::abi::functions().into_values()) + .chain(IStablecoinDEX::abi::functions().into_values()) + .chain(INonce::abi::functions().into_values()) + .chain(IValidatorConfig::abi::functions().into_values()) + .chain(IAccountKeychain::abi::functions().into_values()) + .chain(IAddressRegistry::abi::functions().into_values()) + .chain(ITIP20ChannelReserve::abi::functions().into_values()) + .chain(ISignatureVerifier::abi::functions().into_values()) + .chain(IReceivePolicyGuard::abi::functions().into_values()) + .flatten() + .map(|func| (func.selector(), vec![func])) + .collect(); + + let events = console::ds::abi::events() + .into_values() + .chain(IFeeManager::abi::events().into_values()) + .chain(ITIP20::abi::events().into_values()) + .chain(ITIP403Registry::abi::events().into_values()) + .chain(ITIP20Factory::abi::events().into_values()) + .chain(IStablecoinDEX::abi::events().into_values()) + .chain(INonce::abi::events().into_values()) + .chain(IValidatorConfig::abi::events().into_values()) + .chain(IAccountKeychain::abi::events().into_values()) + .chain(IAddressRegistry::abi::events().into_values()) + .chain(ITIP20ChannelReserve::abi::events().into_values()) + .chain(ISignatureVerifier::abi::events().into_values()) + .chain(IReceivePolicyGuard::abi::events().into_values()) + .flatten() + .map(|event| ((event.selector(), indexed_inputs(&event)), vec![event])) + .collect(); + Self { contracts: Default::default(), - labels: HashMap::from_iter([ - (CHEATCODE_ADDRESS, "VM".to_string()), - (HARDHAT_CONSOLE_ADDRESS, "console".to_string()), - (DEFAULT_CREATE2_DEPLOYER, "Create2Deployer".to_string()), - (CALLER, "DefaultSender".to_string()), - (TEST_CONTRACT_ADDRESS, "DefaultTestContract".to_string()), - (EC_RECOVER, "ECRecover".to_string()), - (SHA_256, "SHA-256".to_string()), - (RIPEMD_160, "RIPEMD-160".to_string()), - (IDENTITY, "Identity".to_string()), - (MOD_EXP, "ModExp".to_string()), - (EC_ADD, "ECAdd".to_string()), - (EC_MUL, "ECMul".to_string()), - (EC_PAIRING, "ECPairing".to_string()), - (BLAKE_2F, "Blake2F".to_string()), - (POINT_EVALUATION, "PointEvaluation".to_string()), - ]), + labels, + base_labels: Default::default(), receive_contracts: Default::default(), fallback_contracts: Default::default(), non_fallback_contracts: Default::default(), - - functions: console::hh::abi::functions() - .into_values() - .chain(Vm::abi::functions().into_values()) - .flatten() - .map(|func| (func.selector(), vec![func])) - .collect(), - events: console::ds::abi::events() - .into_values() - .flatten() - .map(|event| ((event.selector(), indexed_inputs(&event)), vec![event])) - .collect(), - revert_decoder: Default::default(), + functions, + functions_by_address: Default::default(), + constructors_by_address: Default::default(), + constructor_args_offsets: Default::default(), + events, + events_by_address: None, + // Decode Tempo precompile custom errors by name in traces. + revert_decoder: RevertDecoder::new().with_abis(tempo_abis.iter()), signature_identifier: None, verbosity: 0, @@ -210,6 +500,14 @@ impl CallTraceDecoder { debug_identifier: None, disable_labels: false, + + chain_id: None, + networks: None, + + opcodes: Vec::new(), + + hardfork: None, + compact_labels: false, } } @@ -217,20 +515,59 @@ impl CallTraceDecoder { pub fn clear_addresses(&mut self) { self.contracts.clear(); - let default_labels = &Self::new().labels; - if self.labels.len() > default_labels.len() { - self.labels.clone_from(default_labels); + if self.base_labels.is_empty() { + self.labels.clone_from(&Self::new().labels); + } else { + self.labels.clone_from(&self.base_labels); } self.receive_contracts.clear(); self.fallback_contracts.clear(); + self.non_fallback_contracts.clear(); + self.functions_by_address.clear(); + self.events_by_address = None; + self.constructors_by_address.clear(); + self.constructor_args_offsets.clear(); + + self.register_celo_metadata(); + #[cfg(feature = "monad")] + self.register_monad_metadata(); + #[cfg(feature = "base")] + self.register_base_metadata(); + } + + /// Returns labels for precompiles active in this decoder's chain context. + pub fn precompile_labels(&self) -> AddressHashMap { + self.labels + .iter() + .filter(|(address, _)| { + precompiles::is_known_precompile( + **address, + self.networks, + self.chain_id, + self.hardfork, + ) + }) + .map(|(address, label)| (*address, label.clone())) + .collect() } /// Identify unknown addresses in the specified call trace using the specified identifier. /// /// Unknown contracts are contracts that either lack a label or an ABI. pub fn identify(&mut self, arena: &CallTraceArena, identifier: &mut impl TraceIdentifier) { - self.collect_identified_addresses(self.identify_addresses(arena, identifier)); + self.collect_identified_addresses(self.identify_addresses(arena, identifier), true); + } + + /// Identifies unknown addresses without re-registering their ABIs globally. + /// + /// The identified ABIs must already be registered in this decoder. + pub fn identify_scoped( + &mut self, + arena: &CallTraceArena, + identifier: &mut impl TraceIdentifier, + ) { + self.collect_identified_addresses(self.identify_addresses(arena, identifier), false); } /// Identify unknown addresses in the specified call trace using the specified identifier. @@ -242,6 +579,17 @@ impl CallTraceDecoder { identifier: &'a mut impl TraceIdentifier, ) -> Vec> { let nodes = arena.nodes().iter().filter(|node| { + // Skip precompile addresses, they will never resolve externally. + if node.is_precompile() + || precompiles::is_known_precompile_call( + &node.trace, + self.networks, + self.chain_id, + self.hardfork, + ) + { + return false; + } let address = &node.trace.address; !self.labels.contains_key(address) || !self.contracts.contains_key(address) }); @@ -253,21 +601,280 @@ impl CallTraceDecoder { self.events.entry((event.selector(), indexed_inputs(&event))).or_default().push(event); } + /// Adds a single event to the decoder for a specific contract address. + pub fn push_address_event(&mut self, address: Address, event: Event) { + let events = self + .events_by_address + .get_or_insert_with(Default::default) + .entry(address) + .or_default() + .entry(((!event.anonymous).then(|| event.selector()), indexed_inputs(&event))) + .or_default(); + if !events.contains(&event) { + events.push(event); + } + } + /// Adds a single function to the decoder. pub fn push_function(&mut self, function: Function) { - match self.functions.entry(function.selector()) { - Entry::Occupied(entry) => { - // This shouldn't happen that often. - if entry.get().contains(&function) { - return; + let selector = function.selector(); + let functions = self.functions.entry(selector).or_default(); + + if Self::push_function_to(functions, function) && functions.len() > 1 { + let function = functions.last().expect("function was just inserted"); + let signature = function.signature(); + trace!(target: "evm::traces", %selector, new=%signature, "duplicate function selector"); + } + } + + /// Adds a single function to the decoder for a specific contract address. + pub fn push_address_function(&mut self, address: Address, function: Function) { + let functions = self + .functions_by_address + .entry(address) + .or_default() + .entry(function.selector()) + .or_default(); + Self::push_function_to(functions, function); + } + + fn push_function_to(functions: &mut Vec, function: Function) -> bool { + if functions.contains(&function) { + false + } else { + functions.push(function); + true + } + } + + /// Returns the functions registered for `selector` at `address`. + /// + /// Address-scoped and caller-supplied metadata takes precedence over network fallbacks. + pub fn functions_for_selector( + &self, + address: Address, + selector: &Selector, + ) -> Option<&[Function]> { + if self.is_current_committee_active(address) { + static FUNCTIONS: OnceLock>> = OnceLock::new(); + if let Some(functions) = FUNCTIONS + .get_or_init(|| { + ICurrentCommittee::abi::functions() + .into_values() + .flatten() + .map(|function| (function.selector(), vec![function])) + .collect() + }) + .get(selector) + { + return Some(functions); + } + } + if let Some(functions) = + self.functions_by_address.get(&address).and_then(|functions| functions.get(selector)) + { + return Some(functions); + } + #[cfg(feature = "base")] + let base_functions = self.base_functions_for_selector(selector); + // Tempo's function with this selector has no output, while B20 returns a bool. + #[cfg(feature = "base")] + if let Some(functions) = base_functions + && functions.first().is_some_and(|function| function.name == "transferWithMemo") + { + if let Some(global) = self.functions.get(selector) { + let tempo_fallback = global.first().is_some_and(|function| { + function.name == "transferWithMemo" && function.outputs.is_empty() + }); + if !tempo_fallback { + return Some(global); + } + if global.len() > 1 { + return Some(&global[1..]); } - trace!(target: "evm::traces", selector=%entry.key(), new=%function.signature(), "duplicate function selector"); - entry.into_mut().push(function); } - Entry::Vacant(entry) => { - entry.insert(vec![function]); + return Some(functions); + } + let functions = self.functions.get(selector); + #[cfg(feature = "base")] + let functions = functions.or(base_functions); + functions.map(Vec::as_slice) + } + + #[cfg(feature = "base")] + fn is_base_context(&self) -> bool { + self.networks.map_or_else( + || self.hardfork.and_then(BaseSpecId::from_foundry_hardfork).is_some(), + |networks| networks.is_base(), + ) + } + + #[cfg(feature = "base")] + fn base_functions_for_selector(&self, selector: &Selector) -> Option<&'static Vec> { + if !self.is_base_context() { + return None; + } + static FUNCTIONS: OnceLock>> = OnceLock::new(); + FUNCTIONS + .get_or_init(|| { + base::IB20Extensions::abi::functions() + .into_values() + .flatten() + .map(|function| (function.selector(), vec![function])) + .collect() + }) + .get(selector) + } + + #[cfg(feature = "base")] + fn base_events(&self) -> Option<&'static BTreeMap<(B256, usize), Vec>> { + if !self.is_base_context() { + return None; + } + static EVENTS: OnceLock>> = OnceLock::new(); + Some(EVENTS.get_or_init(|| { + base::IB20Extensions::abi::events() + .into_values() + .flatten() + .map(|event| ((event.selector(), indexed_inputs(&event)), vec![event])) + .collect() + })) + } + + #[cfg(feature = "base")] + fn base_revert_decoder(&self, address: Address) -> Option<&'static RevertDecoder> { + let upgrade = + self.hardfork.and_then(BaseSpecId::from_foundry_hardfork).map(|spec| spec.upgrade())?; + if !self.is_base_context() || !is_base_precompile_active_at(address, upgrade) { + return None; + } + + static DECODERS: OnceLock> = OnceLock::new(); + DECODERS + .get_or_init(|| { + HashMap::from_iter([ + ( + ActivationRegistryStorage::ADDRESS, + RevertDecoder::new().with_abi(&base::IActivationRegistry::abi::contract()), + ), + ( + PolicyRegistryStorage::ADDRESS, + RevertDecoder::new().with_abi(&base::IPolicyRegistry::abi::contract()), + ), + ( + B20FactoryStorage::ADDRESS, + RevertDecoder::new().with_abi(&base::IB20Factory::abi::contract()), + ), + ( + NonceManagerStorage::ADDRESS, + RevertDecoder::new().with_abi(&base::INonceManager::abi::contract()), + ), + ( + TxContextStorage::ADDRESS, + RevertDecoder::new().with_abi(&base::ITransactionContext::abi::contract()), + ), + ]) + }) + .get(&address) + } + + #[cfg(feature = "monad")] + fn register_monad_metadata(&mut self) { + if self.networks.is_some_and(|networks| !networks.is_monad()) { + return; + } + let Some(hardfork) = self.hardfork.and_then(MonadHardfork::from_foundry_hardfork) else { + return; + }; + + self.labels + .entry(foundry_evm_core::constants::MONAD_CHEATCODE_ADDRESS) + .or_insert_with(|| "MonadVM".to_string()); + self.register_address_abi( + monad_revm::staking::STAKING_ADDRESS, + &monad::IMonadStaking::abi::contract(), + ); + self.register_address_abi( + monad_revm::staking::STAKING_ADDRESS, + &monad::IMonadStakingSyscalls::abi::contract(), + ); + self.labels + .entry(monad_revm::staking::STAKING_ADDRESS) + .or_insert_with(|| "Staking".to_string()); + + if foundry_evm_networks::is_monad_precompile_active_at( + monad_revm::reserve_balance::abi::RESERVE_BALANCE_ADDRESS, + hardfork, + ) { + self.register_address_abi( + monad_revm::reserve_balance::abi::RESERVE_BALANCE_ADDRESS, + &monad::IReserveBalance::abi::contract(), + ); + self.labels + .entry(monad_revm::reserve_balance::abi::RESERVE_BALANCE_ADDRESS) + .or_insert_with(|| "ReserveBalance".to_string()); + } + } + + #[cfg(any(feature = "base", feature = "monad"))] + fn register_address_abi(&mut self, address: Address, abi: &JsonAbi) { + for function in abi.functions() { + self.push_address_function(address, function.clone()); + } + for event in abi.events() { + self.push_address_event(address, event.clone()); + } + } + + fn is_current_committee_active(&self, address: Address) -> bool { + address == CURRENT_COMMITTEE_ADDRESS + && self + .hardfork + .and_then(TempoHardfork::from_foundry_hardfork) + .is_some_and(|hardfork| hardfork.is_t8()) + && precompiles::is_known_precompile( + address, + self.networks, + self.chain_id, + self.hardfork, + ) + } + + /// Selects the appropriate function from a list of functions with the same selector by + /// checking which one decodes the calldata. + /// + /// Address-scoped function lookup should happen before this to avoid using ABI metadata from a + /// different contract when multiple functions have the same input types. + fn select_contract_function<'a>( + &self, + functions: &'a [Function], + trace: &CallTrace, + ) -> &'a [Function] { + // Suffixed calldata must start with the exact ABI encoding of the function inputs, so + // packed calldata isn't decoded as a function with fewer inputs. Cheatcode calls are + // always decoded to redact sensitive inputs. + if trace.address != CHEATCODE_ADDRESS + && let Some(args) = suffixed_abi_args(&trace.data) + { + return functions + .iter() + .position(|func| is_abi_encoded_input(func, args)) + .map_or(&[], |i| &functions[i..=i]); + } + + // When there are selector collisions, try to decode the calldata with each function + // to determine which one is actually being called. The correct function should + // decode successfully while the wrong ones will fail due to parameter type mismatches. + if functions.len() > 1 { + for (i, func) in functions.iter().enumerate() { + if trace.data.len() >= SELECTOR_LEN + && func.abi_decode_input(&trace.data[SELECTOR_LEN..]).is_ok() + { + return &functions[i..i + 1]; + } } } + functions } /// Adds a single error to the decoder. @@ -275,11 +882,15 @@ impl CallTraceDecoder { self.revert_decoder.push_error(error); } - pub fn without_label(&mut self, disable: bool) { + pub const fn without_label(&mut self, disable: bool) { self.disable_labels = disable; } - fn collect_identified_addresses(&mut self, mut addrs: Vec>) { + fn collect_identified_addresses( + &mut self, + mut addrs: Vec>, + global: bool, + ) { addrs.sort_by_key(|identity| identity.address); addrs.dedup_by_key(|identity| identity.address); if addrs.is_empty() { @@ -287,37 +898,61 @@ impl CallTraceDecoder { } trace!(target: "evm::traces", len=addrs.len(), "collecting address identities"); - for IdentifiedAddress { address, label, contract, abi, artifact_id: _ } in addrs { + for IdentifiedAddress { + address, + label, + contract, + abi, + constructor_args_offset, + artifact_id: _, + } in addrs + { let _span = trace_span!(target: "evm::traces", "identity", ?contract, ?label).entered(); if let Some(contract) = contract { self.contracts.entry(address).or_insert(contract); } - if let Some(label) = label { + if let Some(label) = label.filter(|s| !s.is_empty()) { self.labels.entry(address).or_insert(label); } if let Some(abi) = abi { - self.collect_abi(&abi, Some(address)); + self.collect_abi(&abi, Some(address), global); + } + + if let Some(offset) = constructor_args_offset { + self.constructor_args_offsets.entry(address).or_insert(offset); } } } - fn collect_abi(&mut self, abi: &JsonAbi, address: Option
) { + fn collect_abi(&mut self, abi: &JsonAbi, address: Option
, global: bool) { let len = abi.len(); if len == 0 { return; } trace!(target: "evm::traces", len, ?address, "collecting ABI"); for function in abi.functions() { - self.push_function(function.clone()); + if let Some(address) = address { + self.push_address_function(address, function.clone()); + } + if global { + self.push_function(function.clone()); + } } - for event in abi.events() { - self.push_event(event.clone()); + if let Some(address) = address + && let Some(constructor) = abi.constructor() + { + self.constructors_by_address.entry(address).or_insert_with(|| constructor.clone()); } - for error in abi.errors() { - self.push_error(error.clone()); + if global { + for event in abi.events() { + self.push_event(event.clone()); + } + for error in abi.errors() { + self.push_error(error.clone()); + } } if let Some(address) = address { if abi.receive.is_some() { @@ -339,9 +974,49 @@ impl CallTraceDecoder { /// [CallTraceDecoder::decode_event] for more details. pub async fn populate_traces(&self, traces: &mut Vec) { for node in traces { - node.trace.decoded = self.decode_function(&node.trace).await; + if !self.opcodes.is_empty() { + for step in &mut node.trace.steps { + if step.decoded.is_some() { + continue; + } + for opcode in &self.opcodes { + if step.op == *opcode { + let res = match &step.storage_change { + Some(change) if step.op == OpCode::SSTORE => { + if let Some(had_value) = change.had_value { + format!( + "[{}] {} 0x{:x}: 0x{:x} → 0x{:x}", + step.gas_cost, + opcode, + change.key, + had_value, + change.value + ) + } else { + format!( + "[{}] {} 0x{:x} → (0x{:x})", + step.gas_cost, opcode, change.key, change.value + ) + } + } + Some(change) => format!( + "[{}] {} 0x{:x} → (0x{:x})", + step.gas_cost, opcode, change.key, change.value + ), + None => format!("[{}] {}", step.gas_cost, opcode), + }; + + step.decoded = Some(Box::new(DecodedTraceStep::Line(res))); + break; + } + } + } + } + + node.trace.decoded = Some(Box::new(self.decode_function(&node.trace).await)); for log in &mut node.logs { - log.decoded = self.decode_event(&log.raw_log).await; + log.decoded = + Some(Box::new(self.decode_event_with_address(log.address, &log.raw_log).await)); } if let Some(debug) = self.debug_identifier.as_ref() @@ -352,16 +1027,55 @@ impl CallTraceDecoder { } } + /// Returns the same function signature as [`Self::decode_function`] without formatting + /// arguments and return values when the ABI identifies a single function. + pub async fn decode_function_signature(&self, trace: &CallTrace) -> Option { + if !trace.kind.is_any_create() + && trace.address != DEFAULT_CREATE2_DEPLOYER + && !precompiles::is_known_precompile_call( + trace, + self.networks, + self.chain_id, + self.hardfork, + ) + && is_abi_call_data(&trace.data) + // Suffixed calldata is only decoded if it matches the function inputs. + && suffixed_abi_args(&trace.data).is_none() + && let Some(selector) = trace.data.first_chunk().map(Selector::from) + && let Some([function]) = self.functions_for_selector(trace.address, &selector) + && self + .fallback_contracts + .get(&trace.address) + .is_none_or(|selectors| selectors.contains(&selector)) + { + return Some(function.signature()); + } + + self.decode_function(trace).await.call_data.map(|data| data.signature) + } + /// Decodes a call trace. pub async fn decode_function(&self, trace: &CallTrace) -> DecodedCallTrace { - let label = - if self.disable_labels { None } else { self.labels.get(&trace.address).cloned() }; + let label = if self.disable_labels { + None + } else if let Some(label) = self.labels.get(&trace.address) { + Some(label.clone()) + } else if self.is_current_committee_active(trace.address) { + Some("CurrentCommittee".to_string()) + } else { + None + }; if trace.kind.is_any_create() { - return DecodedCallTrace { label, ..Default::default() }; + return DecodedCallTrace { + label, + call_data: self.decode_constructor_input(trace), + return_data: None, + }; } - if let Some(trace) = precompiles::decode(trace, 1) { + if let Some(trace) = precompiles::decode(trace, self.networks, self.chain_id, self.hardfork) + { return trace; } @@ -370,33 +1084,38 @@ impl CallTraceDecoder { return DecodedCallTrace { label, call_data: Some(DecodedCallData { signature: "create2".to_string(), args: vec![] }), - return_data: self.default_return_data(trace), + return_data: self.default_return_data(trace).await, }; } if is_abi_call_data(cdata) { let selector = Selector::try_from(&cdata[..SELECTOR_LEN]).unwrap(); - let mut functions = Vec::new(); - let functions = match self.functions.get(&selector) { - Some(fs) => fs, + let mut identified_functions = Vec::new(); + let functions = match self.functions_for_selector(trace.address, &selector) { + Some(functions) => functions, None => { if let Some(identifier) = &self.signature_identifier && let Some(function) = identifier.identify_function(selector).await { - functions.push(function); + identified_functions.push(function); } - &functions + &identified_functions } }; + let contract_functions = self.select_contract_function(functions, trace); + // Check if unsupported fn selector: calldata dooes NOT point to one of its selectors + // non-fallback contract + no receive if let Some(contract_selectors) = self.non_fallback_contracts.get(&trace.address) && !contract_selectors.contains(&selector) && (!cdata.is_empty() || !self.receive_contracts.contains(&trace.address)) { - let return_data = if !trace.success { - let revert_msg = self.revert_decoder.decode(&trace.output, trace.status); + let return_data = if trace.success { + None + } else { + let revert_msg = + self.decode_revert_at(trace.address, &trace.output, trace.status).await; if trace.output.is_empty() || revert_msg.contains("EvmError: Revert") { Some(format!( @@ -406,30 +1125,28 @@ impl CallTraceDecoder { } else { Some(revert_msg) } - } else { - None }; - if let Some(func) = functions.first() { - return DecodedCallTrace { + return if let Some(func) = contract_functions.first() { + DecodedCallTrace { label, call_data: Some(self.decode_function_input(trace, func)), return_data, - }; + } } else { - return DecodedCallTrace { + DecodedCallTrace { label, call_data: self.fallback_call_data(trace), return_data, - }; + } }; } - let [func, ..] = &functions[..] else { + let [func, ..] = contract_functions else { return DecodedCallTrace { label, call_data: self.fallback_call_data(trace), - return_data: self.default_return_data(trace), + return_data: self.default_return_data(trace).await, }; }; @@ -446,13 +1163,26 @@ impl CallTraceDecoder { DecodedCallTrace { label, call_data: Some(call_data), - return_data: self.decode_function_output(trace, functions), + return_data: self.decode_function_output(trace, contract_functions).await, } } else { + // Calls to the cheatcode address with a known selector must not fall back to raw + // calldata rendering: even malformed calldata can contain sensitive inputs like + // private keys that the custom cheatcode decoding redacts. + if trace.address == CHEATCODE_ADDRESS + && let Some(selector) = cdata.first_chunk().map(Selector::from) + && let Some([func, ..]) = self.functions_for_selector(trace.address, &selector) + { + return DecodedCallTrace { + label, + call_data: Some(self.decode_function_input(trace, func)), + return_data: self.default_return_data(trace).await, + }; + } DecodedCallTrace { label, call_data: self.fallback_call_data(trace), - return_data: self.default_return_data(trace), + return_data: self.default_return_data(trace).await, } } } @@ -469,56 +1199,135 @@ impl CallTraceDecoder { } if args.is_none() - && let Ok(v) = func.abi_decode_input(&trace.data[SELECTOR_LEN..]) + && let Ok(decoded) = func.abi_decode_input(&trace.data[SELECTOR_LEN..]) { - args = Some(v.iter().map(|value| self.format_value(value)).collect()); + args = Some( + decoded + .iter() + .zip(&func.inputs) + .map(|(value, input)| { + self.format_param_value( + Some(trace.address), + &func.name, + &input.name, + &input.ty, + value, + ) + }) + .collect(), + ); } } DecodedCallData { signature: func.signature(), args: args.unwrap_or_default() } } + /// Decodes constructor input from a creation trace. + fn decode_constructor_input(&self, trace: &CallTrace) -> Option { + let constructor = self.constructors_by_address.get(&trace.address)?; + let offset = *self.constructor_args_offsets.get(&trace.address)?; + let data = trace.data.get(offset..)?; + let decoded = constructor.abi_decode_input(data).ok()?; + let args = decoded + .iter() + .zip(&constructor.inputs) + .map(|(value, input)| { + self.format_param_value( + Some(trace.address), + "constructor", + &input.name, + &input.ty, + value, + ) + }) + .collect(); + + Some(DecodedCallData { signature: constructor_signature(constructor), args }) + } + /// Custom decoding for cheatcode inputs. fn decode_cheatcode_inputs(&self, func: &Function, data: &[u8]) -> Option> { match func.name.as_str() { - "expectRevert" => Some(vec![self.revert_decoder.decode(data, None)]), - "addr" | "createWallet" | "deriveKey" | "rememberKey" => { + "expectRevert" => { + let decoded = match data.get(SELECTOR_LEN..) { + Some(data) => func.abi_decode_input(data).ok(), + None => None, + }; + let Some(decoded) = decoded else { + return Some(vec![self.revert_decoder.decode(data, None)]); + }; + let Some(first) = decoded.first() else { + return Some(vec![self.revert_decoder.decode(data, None)]); + }; + let expected_revert = match first { + DynSolValue::Bytes(bytes) => bytes.as_slice(), + DynSolValue::FixedBytes(word, size) => &word[..*size], + _ => return None, + }; + Some( + std::iter::once(self.revert_decoder.decode(expected_revert, None)) + .chain(decoded.iter().skip(1).map(|value| self.format_value(value))) + .collect(), + ) + } + "addr" | "createEd25519Key" | "createWallet" | "deriveKey" | "publicKeyEd25519" | + "publicKeyP256" | "rememberKey" | "rememberKeys" => { // Redact private key in all cases Some(vec!["".to_string()]) } "broadcast" | "startBroadcast" => { // Redact private key if defined // broadcast(uint256) / startBroadcast(uint256) - if !func.inputs.is_empty() && func.inputs[0].ty == "uint256" { - Some(vec!["".to_string()]) - } else { - None - } + (!func.inputs.is_empty() && func.inputs[0].ty == "uint256").then(|| vec!["".to_string()]) } "getNonce" => { // Redact private key if defined // getNonce(Wallet) - if !func.inputs.is_empty() && func.inputs[0].ty == "tuple" { - Some(vec!["".to_string()]) - } else { - None - } + (!func.inputs.is_empty() && func.inputs[0].ty == "tuple").then(|| vec!["".to_string()]) } - "sign" | "signP256" => { - let mut decoded = func.abi_decode_input(&data[SELECTOR_LEN..]).ok()?; + "sign" | "signCompact" | "signP256" | "signKeychain" | "signKeychainAdmin" => { + // Fail closed: when the arguments of a key-taking cheatcode cannot be + // decoded, redact everything instead of deferring to generic rendering. + let Ok(mut decoded) = func.abi_decode_input(&data[SELECTOR_LEN..]) else { + return Some(vec!["".to_string()]); + }; - // Redact private key and replace in trace - // sign(uint256,bytes32) / signP256(uint256,bytes32) / sign(Wallet,bytes32) + // Redact private key and replace in trace when the first parameter is a raw + // private key (uint256) or a Wallet struct (tuple); digest-only and + // signer-address overloads carry no key material and are left as-is. if !decoded.is_empty() && (func.inputs[0].ty == "uint256" || func.inputs[0].ty == "tuple") { decoded[0] = DynSolValue::String("".to_string()); } - Some(decoded.iter().map(format_token).collect()) + Some(decoded.iter().map(|value| self.format_value(value)).collect()) + } + "signWithNonceUnsafe" => { + let Ok(mut decoded) = func.abi_decode_input(&data[SELECTOR_LEN..]) else { + return Some(vec!["".to_string()]); + }; + // Redact private key and nonce and replace in trace for + // signWithNonceUnsafe(uint256 privateKey, bytes32 digest, uint256 nonce). + // The nonce is the raw ECDSA k value: together with the digest and the + // returned signature it allows recovering the private key. + decoded[0] = DynSolValue::String("".to_string()); + decoded[2] = DynSolValue::String("".to_string()); + Some(decoded.iter().map(|value| self.format_value(value)).collect()) + } + "signEd25519" => { + let Ok(mut decoded) = func.abi_decode_input(&data[SELECTOR_LEN..]) else { + return Some(vec!["".to_string()]); + }; + // Redact private key and replace in trace for + // signEd25519(bytes namespace, bytes message, bytes32 privateKey) + decoded[2] = DynSolValue::String("".to_string()); + Some(decoded.iter().map(|value| self.format_value(value)).collect()) } "signDelegation" | "signAndAttachDelegation" => { - let mut decoded = func.abi_decode_input(&data[SELECTOR_LEN..]).ok()?; + let Ok(mut decoded) = func.abi_decode_input(&data[SELECTOR_LEN..]) else { + return Some(vec!["".to_string()]); + }; // Redact private key and replace in trace for // signAndAttachDelegation(address implementation, uint256 privateKey) // signDelegation(address implementation, uint256 privateKey) @@ -608,9 +1417,13 @@ impl CallTraceDecoder { } /// Decodes a function's output into the given trace. - fn decode_function_output(&self, trace: &CallTrace, funcs: &[Function]) -> Option { + async fn decode_function_output( + &self, + trace: &CallTrace, + funcs: &[Function], + ) -> Option { if !trace.success { - return self.default_return_data(trace); + return self.default_return_data(trace).await; } if trace.address == CHEATCODE_ADDRESS @@ -640,7 +1453,7 @@ impl CallTraceDecoder { fn decode_cheatcode_outputs(&self, func: &Function) -> Option { match func.name.as_str() { s if s.starts_with("env") => Some(""), - "createWallet" | "deriveKey" => Some(""), + "createEd25519Key" | "createWallet" | "deriveKey" => Some(""), "promptSecret" | "promptSecretUint" => Some(""), "parseJson" if self.verbosity < 5 => Some(""), "readFile" if self.verbosity < 5 => Some(""), @@ -666,54 +1479,204 @@ impl CallTraceDecoder { } /// The default decoded return data for a trace. - fn default_return_data(&self, trace: &CallTrace) -> Option { + async fn default_return_data(&self, trace: &CallTrace) -> Option { // For calls with status None or successful status, don't decode revert data // This is due to trace.status is derived from the revm_interpreter::InstructionResult in // revm-inspectors status will `None` post revm 27, as `InstructionResult::Continue` does // not exists anymore. - if trace.status.is_none() || trace.status.is_some_and(|s| s.is_ok()) { + if trace.status.is_none_or(|s| s.is_ok()) || trace.success { return None; } - (!trace.success).then(|| self.revert_decoder.decode(&trace.output, trace.status)) + Some(self.decode_revert_at(trace.address, &trace.output, trace.status).await) } - /// Decodes an event. - pub async fn decode_event(&self, log: &LogData) -> DecodedCallLog { - let &[t0, ..] = log.topics() else { return DecodedCallLog { name: None, params: None } }; - - let mut events = Vec::new(); - let events = match self.events.get(&(t0, log.topics().len() - 1)) { - Some(es) => es, - None => { - if let Some(identifier) = &self.signature_identifier - && let Some(event) = identifier.identify_event(t0).await - { - events.push(get_indexed_event(event, log)); - } - &events - } - }; + /// Decodes revert data into a human-readable representation. + /// + /// If the revert decoder does not know the custom error, tries identifying the error selector + /// with the signatures identifier, like unknown function and event signatures. This resolves + /// errors from the local signatures cache populated by `forge build`, or from remote signature + /// databases. + async fn decode_revert(&self, output: &[u8], status: Option) -> String { + if let Some(reason) = self.revert_decoder.maybe_decode_known(output) { + return reason; + } + if let Some(identifier) = &self.signature_identifier + && let Some((selector, data)) = output.split_first_chunk::() + && let Some(error) = identifier.identify_error(Selector::from(*selector)).await + && let Ok(decoded) = error.abi_decode_input(data) + { + return format!("{}({})", error.name, decoded.iter().map(format_token).format(", ")); + } + self.revert_decoder.decode(output, status) + } + + async fn decode_revert_at( + &self, + address: Address, + output: &[u8], + status: Option, + ) -> String { + #[cfg(feature = "base")] + if let Some(reason) = + self.base_revert_decoder(address).and_then(|decoder| decoder.maybe_decode_known(output)) + { + return reason; + } + if self.is_current_committee_active(address) { + static DECODER: OnceLock = OnceLock::new(); + let decoder = DECODER + .get_or_init(|| RevertDecoder::new().with_abi(&ICurrentCommittee::abi::contract())); + if let Some(reason) = decoder.maybe_decode_known(output) { + return reason; + } + } + self.decode_revert(output, status).await + } + + /// Decodes an event. + pub async fn decode_event(&self, log: &LogData) -> DecodedCallLog { + self.decode_event_inner(None, log, false).await.into_call_log() + } + + /// Decodes an event emitted by a known address. + pub async fn decode_event_with_address( + &self, + address: Address, + log: &LogData, + ) -> DecodedCallLog { + self.decode_event_inner(Some(address), log, false).await.into_call_log() + } + + /// Decodes an event emitted by a known address, using its canonical signature as the name. + pub async fn decode_event_with_address_signature( + &self, + address: Address, + log: &LogData, + ) -> DecodedEvent { + self.decode_event_inner(Some(address), log, true).await + } + + async fn decode_event_inner( + &self, + address: Option
, + log: &LogData, + canonical_signature: bool, + ) -> DecodedEvent { + let key = log.topics().first().map(|&topic| (topic, log.topics().len() - 1)); + let events = address.and_then(|address| self.events_by_address.as_deref()?.get(&address)); + let address_events = key.and_then(|(topic, count)| events?.get(&(Some(topic), count))); + let global_events = key.and_then(|key| self.events.get(&key)); + #[cfg(feature = "base")] + let base_events = key.and_then(|key| self.base_events()?.get(&key)); + #[cfg(not(feature = "base"))] + let base_events: Option<&Vec> = None; + let fallback_events = global_events.or(base_events); + let regular_events = address_events.or(fallback_events); + let anonymous_events = events.and_then(|events| events.get(&(None, log.topics().len()))); + + let decoded = if canonical_signature && address_events.is_none() { + // Topic zero does not encode indexed parameter placement, so global metadata is only + // a hint. Try every compatible layout and reject ambiguity. + let mut events = fallback_events + .into_iter() + .flatten() + .flat_map(|event| indexed_event_candidates(event.clone(), log)) + .collect::>(); + events.extend(anonymous_events.into_iter().flatten().cloned()); + self.decode_unique_event_candidates(address, log, &events, true) + } else if canonical_signature || anonymous_events.is_some() { + self.decode_unique_event_candidates( + address, + log, + regular_events.into_iter().flatten().chain(anonymous_events.into_iter().flatten()), + canonical_signature, + ) + } else { + self.decode_event_candidates(address, log, regular_events.into_iter().flatten(), false) + }; + if let Some(decoded) = decoded { + return decoded; + } + + if regular_events.is_some() || anonymous_events.is_some() { + return DecodedEvent::default(); + } + + if let Some(&topic) = log.topics().first() + && let Some(identifier) = &self.signature_identifier + && let Some(event) = identifier.identify_event(topic).await + { + if !canonical_signature { + let event = get_indexed_event(event, log); + return self + .decode_event_candidates(address, log, [&event], false) + .unwrap_or_default(); + } + let mut decoded = indexed_event_candidates(event, log) + .filter_map(|event| self.decode_event_candidates(address, log, [&event], true)); + let event = decoded.next(); + if event.is_some() && decoded.next().is_some() { + return DecodedEvent::default(); + } + if let Some(decoded) = event { + return decoded; + } + } + + DecodedEvent::default() + } + + fn decode_event_candidates<'a>( + &self, + address: Option
, + log: &LogData, + events: impl IntoIterator, + canonical_signature: bool, + ) -> Option { for event in events { if let Ok(decoded) = event.decode_log(log) { let params = reconstruct_params(event, &decoded); - return DecodedCallLog { - name: Some(event.name.clone()), - params: Some( - params - .into_iter() - .zip(event.inputs.iter()) - .map(|(param, input)| { - // undo patched names - let name = input.name.clone(); - (name, self.format_value(¶m)) - }) - .collect(), - ), - }; + let params = params + .into_iter() + .zip(event.inputs.iter()) + .map(|(param, input)| { + // Undo patched names. + let name = input.name.clone(); + let display = self.format_param_value( + address, + &event.name, + &input.name, + &input.ty, + ¶m, + ); + (name, display, param) + }) + .collect(); + return Some(DecodedEvent { + name: Some(if canonical_signature { + event.signature() + } else { + event.name.clone() + }), + params: Some(params), + }); } } + None + } - DecodedCallLog { name: None, params: None } + fn decode_unique_event_candidates<'a>( + &self, + address: Option
, + log: &LogData, + events: impl IntoIterator, + canonical_signature: bool, + ) -> Option { + let mut decoded = events.into_iter().filter_map(|event| { + self.decode_event_candidates(address, log, [event], canonical_signature) + }); + let event = decoded.next()?; + decoded.next().is_none().then_some(event) } /// Prefetches function and event signatures into the identifier cache @@ -742,7 +1705,12 @@ impl CallTraceDecoder { // Ignore known addresses. if n.trace.address == DEFAULT_CREATE2_DEPLOYER || n.is_precompile() - || precompiles::is_known_precompile(n.trace.address, 1) + || precompiles::is_known_precompile_call( + &n.trace, + self.networks, + self.chain_id, + self.hardfork, + ) { return false; } @@ -754,9 +1722,19 @@ impl CallTraceDecoder { }) .filter_map(|n| n.trace.data.first_chunk().map(Selector::from)) .filter(|selector| !self.functions.contains_key(selector)); + let errors = nodes + .iter() + .filter(|&n| { + // Only consider reverted traces whose output the revert decoder cannot decode. + n.trace.status.is_some_and(|s| !s.is_ok()) + && !n.trace.success + && self.revert_decoder.maybe_decode_known(&n.trace.output).is_none() + }) + .filter_map(|n| n.trace.output.first_chunk().map(Selector::from)); let selectors = events .map(SelectorKind::Event) .chain(functions.map(SelectorKind::Function)) + .chain(errors.map(SelectorKind::Error)) .unique() .collect::>(); let _ = identifier.identify(&selectors).await; @@ -765,22 +1743,93 @@ impl CallTraceDecoder { /// Pretty-prints a value. fn format_value(&self, value: &DynSolValue) -> String { if let DynSolValue::Address(addr) = value + && !self.disable_labels && let Some(label) = self.labels.get(addr) { + if self.compact_labels { + return label.clone(); + } return format!("{label}: [{addr}]"); } format_token(value) } + + fn format_param_value( + &self, + address: Option
, + context_name: &str, + input_name: &str, + input_ty: &str, + value: &DynSolValue, + ) -> String { + self.format_claim_receipt_bytes(address, context_name, input_name, input_ty, value) + .map(|value| self.format_value(&value)) + .unwrap_or_else(|| self.format_value(value)) + } + + fn format_claim_receipt_bytes( + &self, + address: Option
, + context_name: &str, + input_name: &str, + input_ty: &str, + value: &DynSolValue, + ) -> Option { + if !matches!(address, Some(RECEIVE_POLICY_GUARD_ADDRESS | TIP403_REGISTRY_ADDRESS)) { + return None; + } + if input_name != "receipt" || input_ty != "bytes" { + return None; + } + if !matches!(context_name, "balanceOf" | "claim" | "burnBlockedReceipt" | "TransferBlocked") + { + return None; + } + let DynSolValue::Bytes(bytes) = value else { return None }; + let decoded = IReceivePolicyGuard::ClaimReceiptV1::abi_decode(bytes).ok()?; + + Some(DynSolValue::CustomStruct { + name: "ClaimReceiptV1".to_string(), + prop_names: vec![ + "version".to_string(), + "token".to_string(), + "recoveryAuthority".to_string(), + "originator".to_string(), + "recipient".to_string(), + "blockedAt".to_string(), + "blockedNonce".to_string(), + "blockedReason".to_string(), + "kind".to_string(), + "memo".to_string(), + ], + tuple: vec![ + DynSolValue::Uint(U256::from(decoded.version), 8), + DynSolValue::Address(decoded.token), + DynSolValue::Address(decoded.recoveryAuthority), + DynSolValue::Address(decoded.originator), + DynSolValue::Address(decoded.recipient), + DynSolValue::Uint(U256::from(decoded.blockedAt), 64), + DynSolValue::Uint(U256::from(decoded.blockedNonce), 64), + DynSolValue::Uint(U256::from(decoded.blockedReason), 8), + DynSolValue::Uint(U256::from(decoded.kind as u8), 8), + DynSolValue::FixedBytes(decoded.memo, 32), + ], + }) + } } /// Returns `true` if the given function calldata (including function selector) is ABI-encoded. /// -/// This is a simple heuristic to avoid fetching non ABI-encoded selectors. +/// This is a simple heuristic to avoid fetching non ABI-encoded selectors. A trailing ERC-8021 +/// attribution suffix is ignored. fn is_abi_call_data(data: &[u8]) -> bool { + let data = erc8021::strip_suffix(data); match data.len().cmp(&SELECTOR_LEN) { std::cmp::Ordering::Less => false, std::cmp::Ordering::Equal => true, - std::cmp::Ordering::Greater => is_abi_data(&data[SELECTOR_LEN..]), + std::cmp::Ordering::Greater => { + is_abi_data(&data[SELECTOR_LEN..]) || suffixed_abi_args(data).is_some() + } } } @@ -796,9 +1845,28 @@ fn is_abi_data(data: &[u8]) -> bool { data[data.len() - rem..].iter().all(|byte| *byte == 0) } +/// Returns the word-aligned arguments of the given function calldata if they are followed by a +/// non-zero suffix shorter than a word, such as tracking bytes appended by routers. A trailing +/// ERC-8021 attribution suffix is removed first. +/// +/// The first argument word must be left-padded like an address, integer, boolean or offset, which +/// packed calldata rarely is. +fn suffixed_abi_args(data: &[u8]) -> Option<&[u8]> { + let args = erc8021::strip_suffix(data).get(SELECTOR_LEN..)?; + let (args, suffix) = args.split_at(args.len() - args.len() % 32); + (args.starts_with(&[0; 12]) && suffix.iter().any(|byte| *byte != 0)).then_some(args) +} + +/// Returns `true` if `args` is exactly the ABI encoding of the function inputs. +fn is_abi_encoded_input(func: &Function, args: &[u8]) -> bool { + func.abi_decode_input(args) + .and_then(|values| func.abi_encode_input_raw(&values)) + .is_ok_and(|encoded| encoded == args) +} + /// Restore the order of the params of a decoded event, /// as Alloy returns the indexed and unindexed params separately. -fn reconstruct_params(event: &Event, decoded: &DecodedEvent) -> Vec { +fn reconstruct_params(event: &Event, decoded: &AlloyDecodedEvent) -> Vec { let mut indexed = 0; let mut unindexed = 0; let mut inputs = vec![]; @@ -822,26 +1890,799 @@ fn indexed_inputs(event: &Event) -> usize { event.inputs.iter().filter(|param| param.indexed).count() } +fn indexed_event_candidates(mut event: Event, log: &LogData) -> impl Iterator { + for (index, input) in event.inputs.iter_mut().enumerate() { + if input.name.is_empty() { + input.name = format!("param{index}"); + } + input.indexed = false; + } + (0..event.inputs.len()).combinations(log.topics().len() - 1).map(move |indexed| { + let mut event = event.clone(); + for index in indexed { + event.inputs[index].indexed = true; + } + event + }) +} + +fn constructor_signature(constructor: &Constructor) -> String { + format!( + "constructor({})", + constructor.inputs.iter().map(|input| input.selector_type()).format(",") + ) +} + #[cfg(test)] mod tests { use super::*; - use alloy_primitives::hex; + use crate::CallKind; + use alloy_primitives::{address, aliases::U96, bytes, hex}; + use alloy_sol_types::{SolCall, SolError, SolEvent}; + use foundry_evm_core::precompiles::P256_VERIFY; + use std::borrow::Cow; + use tempo_precompiles::{ + ACCOUNT_KEYCHAIN_ADDRESS, SIGNATURE_VERIFIER_ADDRESS, STORAGE_CREDITS_ADDRESS, + TIP_FEE_MANAGER_ADDRESS, TIP20_CHANNEL_RESERVE_ADDRESS, TIP20_FACTORY_ADDRESS, + VALIDATOR_CONFIG_V2_ADDRESS, + }; + + #[cfg(feature = "base")] + use foundry_evm_hardforks::BaseUpgrade; + + #[cfg(feature = "monad")] + use monad_revm::{ + reserve_balance::interface::IReserveBalance::dippedIntoReserveCall, + staking::interface::IMonadStaking::getEpochCall, + }; + + #[cfg(feature = "monad")] + fn function_abi_items(functions: impl IntoIterator) -> Vec<(String, String)> { + let mut items = functions + .into_iter() + .map(|function| (function.selector().to_string(), function.signature())) + .collect::>(); + items.sort(); + items + } + + #[cfg(feature = "monad")] + fn event_abi_items(events: impl IntoIterator) -> Vec<(String, usize, String)> { + let mut items = events + .into_iter() + .map(|event| (event.selector().to_string(), indexed_inputs(&event), event.signature())) + .collect::>(); + items.sort(); + items + } + + #[cfg(feature = "monad")] + fn typed_call_abi_item() -> (String, String) { + (Selector::from(C::SELECTOR).to_string(), C::SIGNATURE.to_string()) + } + + #[cfg(feature = "monad")] + fn typed_event_abi_item() -> (String, usize, String) { + let signature_topics = usize::from(!E::ANONYMOUS); + let indexed_inputs = ::COUNT - signature_topics; + (E::SIGNATURE_HASH.to_string(), indexed_inputs, E::SIGNATURE.to_string()) + } + + #[cfg(feature = "monad")] + fn monad_decoder(hardfork: MonadHardfork) -> CallTraceDecoder { + CallTraceDecoderBuilder::new() + .with_execution_network(NetworkVariant::Monad) + .with_chain_id(Some(143)) + .with_hardfork(Some(hardfork.into())) + .build() + } #[test] - fn test_should_redact() { + #[cfg(feature = "monad")] + fn test_monad_decoder_abis_match_monad_revm() { + use monad_revm::{ + reserve_balance::interface::IReserveBalance as RevmReserveBalance, + staking::interface::IMonadStaking as RevmMonadStaking, + }; + + let local_staking_functions = monad::IMonadStaking::abi::functions() + .into_values() + .chain(monad::IMonadStakingSyscalls::abi::functions().into_values()) + .flatten(); + let mut revm_staking_functions = vec![ + typed_call_abi_item::(), + typed_call_abi_item::(), + typed_call_abi_item::(), + typed_call_abi_item::(), + typed_call_abi_item::(), + typed_call_abi_item::(), + typed_call_abi_item::(), + typed_call_abi_item::(), + typed_call_abi_item::(), + typed_call_abi_item::(), + typed_call_abi_item::(), + typed_call_abi_item::(), + typed_call_abi_item::(), + typed_call_abi_item::(), + typed_call_abi_item::(), + typed_call_abi_item::(), + typed_call_abi_item::(), + typed_call_abi_item::(), + typed_call_abi_item::(), + typed_call_abi_item::(), + typed_call_abi_item::(), + ]; + revm_staking_functions.sort(); + assert_eq!( + function_abi_items(local_staking_functions), + revm_staking_functions, + "Monad staking function selectors drifted from monad_revm", + ); + + let mut revm_staking_events = vec![ + typed_event_abi_item::(), + typed_event_abi_item::(), + typed_event_abi_item::(), + typed_event_abi_item::(), + typed_event_abi_item::(), + typed_event_abi_item::(), + typed_event_abi_item::(), + typed_event_abi_item::(), + typed_event_abi_item::(), + ]; + revm_staking_events.sort(); + assert_eq!( + event_abi_items(monad::IMonadStaking::abi::events().into_values().flatten()), + revm_staking_events, + "Monad staking event selectors drifted from monad_revm", + ); + + assert_eq!( + function_abi_items(monad::IReserveBalance::abi::functions().into_values().flatten()), + vec![typed_call_abi_item::()], + "Monad reserve-balance function selectors drifted from monad_revm", + ); + + assert_eq!( + event_abi_items(monad::IReserveBalance::abi::events().into_values().flatten()), + Vec::<(String, usize, String)>::new(), + "Monad reserve-balance event selectors drifted from monad_revm", + ); + } + + #[tokio::test] + async fn function_signature_matches_full_decoding() { + let address = Address::repeat_byte(0x11); + let fallback = Address::repeat_byte(0x22); + let no_fallback = Address::repeat_byte(0x33); + let scoped = Address::repeat_byte(0x44); + let known_fallback = Address::repeat_byte(0x55); + let mut decoder = CallTraceDecoder::new().clone(); + let function = Function::parse("roundTrip(uint256[]) returns (uint256[])").unwrap(); + let data = function + .abi_encode_input(&[DynSolValue::Array(vec![DynSolValue::Uint(U256::MAX, 256)])]) + .unwrap(); + decoder.push_function(function.clone()); + decoder.push_address_function(scoped, function.clone()); + decoder.fallback_contracts.insert(fallback, HashSet::default()); + decoder + .fallback_contracts + .insert(known_fallback, HashSet::from_iter([function.selector()])); + decoder.receive_contracts.insert(fallback); + decoder.non_fallback_contracts.insert(no_fallback, HashSet::default()); + decoder + .constructors_by_address + .insert(address, Constructor::parse("constructor()").unwrap()); + decoder.constructor_args_offsets.insert(address, 0); + + // A global collision still resolves through the existing calldata checks. + let transfer = Function::parse("transferFrom(address,address,uint256)").unwrap(); + let collision = Function::parse("gasprice_bit_ether(int128)").unwrap(); + assert_eq!(transfer.selector(), collision.selector()); + let collision_data = + [collision.selector().as_slice(), B256::with_last_byte(1).as_slice()].concat(); + decoder.push_function(transfer.clone()); + decoder.push_function(collision); + decoder.push_address_function(scoped, transfer); + + for address in [ + address, + fallback, + no_fallback, + scoped, + known_fallback, + IDENTITY, + DEFAULT_CREATE2_DEPLOYER, + ] { + for kind in [CallKind::Call, CallKind::DelegateCall, CallKind::Create] { + for success in [true, false] { + for data in [ + vec![], + vec![1, 2, 3], + vec![0xff; SELECTOR_LEN], + function.selector().to_vec(), + [function.selector().as_slice(), &[0xff; 32]].concat(), + data.clone(), + collision_data.clone(), + ] { + let trace = CallTrace { + address, + kind, + success, + status: (!success).then_some(InstructionResult::Revert), + data: data.into(), + output: vec![0xff; 64].into(), + ..Default::default() + }; + assert_eq!( + decoder.decode_function_signature(&trace).await, + decoder + .decode_function(&trace) + .await + .call_data + .map(|data| data.signature), + "{address} {kind:?} success={success} calldata={}", + trace.data, + ); + } + } + } + } + } + + #[test] + fn test_selector_collision_resolution() { + use alloy_json_abi::Function; + use alloy_primitives::Address; + + // Create two functions with the same selector but different signatures + let func1 = Function::parse("transferFrom(address,address,uint256)").unwrap(); + let func2 = Function::parse("gasprice_bit_ether(int128)").unwrap(); + + // Verify they have the same selector (this is the collision) + assert_eq!(func1.selector(), func2.selector()); + + let functions = vec![func1, func2]; + + // Create a mock trace with calldata that matches func1 + let trace = CallTrace { + address: Address::repeat_byte(0x12), + data: bytes!( + "23b872dd000000000000000000000000000000000000000000000000000000000000012300000000000000000000000000000000000000000000000000000000000004560000000000000000000000000000000000000000000000000000000000000064" + ), + ..Default::default() + }; + + let decoder = CallTraceDecoder::new(); + let result = decoder.select_contract_function(&functions, &trace); + + // Should return only the function that can decode the calldata (func1) + assert_eq!(result.len(), 1); + assert_eq!(result[0].signature(), "transferFrom(address,address,uint256)"); + } + + #[test] + fn test_selector_collision_resolution_second_function() { + use alloy_json_abi::Function; + use alloy_primitives::Address; + + // Create two functions with the same selector but different signatures + let func1 = Function::parse("transferFrom(address,address,uint256)").unwrap(); + let func2 = Function::parse("gasprice_bit_ether(int128)").unwrap(); + + let functions = vec![func1, func2]; + + // Create a mock trace with calldata that matches func2 + let trace = CallTrace { + address: Address::repeat_byte(0x12), + data: bytes!( + "23b872dd0000000000000000000000000000000000000000000000000000000000000064" + ), + ..Default::default() + }; + let decoder = CallTraceDecoder::new(); + let result = decoder.select_contract_function(&functions, &trace); + + // Should return only the function that can decode the calldata (func2) + assert_eq!(result.len(), 1); + assert_eq!(result[0].signature(), "gasprice_bit_ether(int128)"); + } + + #[test] + fn test_is_abi_call_data() { + let call_data = |args: &[&[u8]]| [&[0x07, 0xed, 0x23, 0x79][..], &args.concat()].concat(); + let address = Address::repeat_byte(0x11); + let word = address.into_word(); + let suffix = hex!("2a6f45f2"); + + assert!(is_abi_call_data(&call_data(&[&word[..]]))); + assert!(is_abi_call_data(&call_data(&[&word[..], &[0; 4]]))); + assert!(is_abi_call_data(&call_data(&[&word[..], &suffix]))); + // Packed arguments and suffixes without a whole ABI-encoded word. + assert!(!is_abi_call_data(&call_data(&[&address[..], &word[..]]))); + assert!(!is_abi_call_data(&call_data(&[&suffix]))); + // ERC-8021 attribution suffixes are removed exactly, whatever the first word. + let erc8021 = hex!("a161776a6364705f666163696c31000e0280218021802180218021802180218021"); + assert!(is_abi_call_data(&call_data(&[&B256::repeat_byte(0x11)[..], &erc8021]))); + } + + #[tokio::test] + async fn test_decode_call_data_with_suffix() { + let abi = JsonAbi::parse([ + "function execute(bytes commands, bytes[] inputs, uint256 deadline)", + "function chargeFee()", + ]) + .unwrap(); + let identifier = SignaturesIdentifier::new_offline_with_abis([&abi]).unwrap(); + let mut decoder = + CallTraceDecoderBuilder::new().with_signature_identifier(identifier).build(); + let tag = hex!("756e6978000001a0ee8a61e9800089610000006d0100"); + let trace = |data: &[&[u8]]| CallTrace { + address: Address::repeat_byte(0x12), + data: data.concat().into(), + success: true, + ..Default::default() + }; + + let execute = abi.function("execute").unwrap().first().unwrap(); + let data = execute + .abi_encode_input(&[ + DynSolValue::Bytes(vec![0x0b]), + DynSolValue::Array(vec![DynSolValue::Bytes(vec![0x01; 3])]), + DynSolValue::Uint(U256::ONE, 256), + ]) + .unwrap(); + let call_data = decoder.decode_function(&trace(&[&data, &tag])).await.call_data.unwrap(); + assert_eq!(call_data.signature, "execute(bytes,bytes[],uint256)"); + assert_eq!(call_data.args, ["0x0b", "[0x010101]", "1"]); + + // A trailing ERC-8021 attribution suffix of any length is removed before the tag. + let erc8021 = hex!( + "72616e67655f7661756c742c63656c6f5f6533386364643332313061361d0080218021802180218021802180218021" + ); + let decoded = decoder.decode_function(&trace(&[&data, &tag, &erc8021])).await; + assert_eq!(decoded.call_data.unwrap().args, call_data.args); + + // Packed arguments are not decoded as a function with fewer inputs, including on a known + // contract without a fallback that lacks the selector. + let charge_fee = abi.function("chargeFee").unwrap().first().unwrap(); + let word = Address::repeat_byte(0x11).into_word(); + let packed = trace(&[&charge_fee.selector()[..], &word[..], &tag]); + assert!(decoder.decode_function(&packed).await.call_data.is_none()); + decoder.non_fallback_contracts.insert(packed.address, HashSet::default()); + assert!(decoder.decode_function(&packed).await.call_data.is_none()); + } + + #[cfg(feature = "base")] + #[tokio::test] + async fn base_fallback_does_not_shadow_ethereum_abi() { + let abi = JsonAbi::parse(["function pause(uint8[] features) returns (bool)"]).unwrap(); + let function = abi.functions().next().unwrap(); + let trace = CallTrace { + address: Address::repeat_byte(0x12), + data: function + .abi_encode_input(&[DynSolValue::Array(vec![DynSolValue::Uint(U256::ZERO, 8)])]) + .unwrap() + .into(), + output: function.abi_encode_output(&[DynSolValue::Bool(true)]).unwrap().into(), + success: true, + ..Default::default() + }; + + let ethereum = CallTraceDecoderBuilder::new() + .with_execution_network(NetworkVariant::Ethereum) + .with_abi(&abi) + .build(); + let decoded = ethereum.decode_function(&trace).await; + assert_eq!(decoded.call_data.unwrap().signature, "pause(uint8[])"); + assert_eq!(decoded.return_data.as_deref(), Some("true")); + + let base = + CallTraceDecoderBuilder::new().with_execution_network(NetworkVariant::Base).build(); + let decoded = base.decode_function(&trace).await; + assert_eq!(decoded.call_data.unwrap().signature, "pause(uint8[])"); + assert_eq!(decoded.return_data, None); + } + + #[tokio::test] + async fn identified_event_does_not_shadow_builtin_metadata() { + let address = Address::repeat_byte(0x12); + let mut decoder = CallTraceDecoder::new().clone(); + let abi = JsonAbi::parse(["event log_string(string)"]).unwrap(); + decoder.collect_abi(&abi, Some(address), true); + + let event = Event::parse("event log_string(string val)").unwrap(); + let log = LogData::new_unchecked( + vec![event.selector()], + ("script ran".to_string(),).abi_encode().into(), + ); + let decoded = decoder.decode_event_with_address(address, &log).await; + + assert_eq!(decoded.name.as_deref(), Some("log_string")); + assert_eq!(decoded.params.unwrap()[0].0, "val"); + } + + #[tokio::test] + async fn address_scoped_abis_decode_all_registered_events() { + let address = Address::repeat_byte(0x12); + let proxy = JsonAbi::parse(["event ProxyEvent()"]).unwrap(); + let implementation = JsonAbi::parse(["event ImplementationEvent()"]).unwrap(); + let decoder = CallTraceDecoderBuilder::new() + .with_address_events(address, &implementation) + .with_address_events(address, &proxy) + .build(); + + for event in proxy.events().chain(implementation.events()) { + let log = LogData::new_unchecked(vec![event.selector()], Default::default()); + let decoded = decoder.decode_event_with_address(address, &log).await; + assert_eq!(decoded.name.as_deref(), Some(event.name.as_str())); + assert!(decoder.decode_event_with_address(Address::ZERO, &log).await.name.is_none()); + } + } + + #[tokio::test] + async fn clearing_addresses_removes_regular_and_anonymous_events() { + let address = Address::repeat_byte(0x12); + let abi = JsonAbi::parse([ + "event ScopedValue(uint256 value)", + "event AnonymousValue(uint256 value) anonymous", + ]) + .unwrap(); + let mut decoder = CallTraceDecoderBuilder::new() + .with_address_events(address, &abi) + .with_address_events(address, &abi) + .build(); + let global = Event::parse("event GlobalValue()").unwrap(); + let global_log = LogData::new_unchecked(vec![global.selector()], Default::default()); + decoder.push_event(global); + + for event in abi.events() { + let topics = if event.anonymous { vec![] } else { vec![event.selector()] }; + let log = LogData::new_unchecked(topics, (U256::from(7),).abi_encode().into()); + assert_eq!( + decoder.decode_event_with_address(address, &log).await.name.as_deref(), + Some(event.name.as_str()) + ); + + let mut cleared = decoder.clone(); + cleared.clear_addresses(); + assert!(cleared.decode_event_with_address(address, &log).await.name.is_none()); + assert_eq!( + cleared.decode_event(&global_log).await.name.as_deref(), + Some("GlobalValue") + ); + } + } + + #[tokio::test] + async fn canonical_address_events_require_a_unique_match() { + let address = Address::repeat_byte(0x12); + let implementation = + JsonAbi::parse(["event Value(address indexed who, uint256 amount)"]).unwrap(); + let proxy = JsonAbi::parse(["event Value(address who, uint256 indexed amount)"]).unwrap(); + let event = proxy.events().next().unwrap(); + let log = LogData::new_unchecked( + vec![event.selector(), U256::from(42).into()], + (Address::repeat_byte(0x34),).abi_encode().into(), + ); + let decoder = CallTraceDecoderBuilder::new() + .with_address_events(address, &implementation) + .with_address_events(address, &proxy) + .build(); + + let decoded = decoder.decode_event_with_address_signature(address, &log).await; + assert!(decoded.name.is_none()); + assert!(decoded.params.is_none()); + } + + #[tokio::test] + async fn canonical_global_events_require_a_unique_indexed_placement() { + let known = Event::parse( + "event AmbiguousLayout(address indexed from, address indexed to, uint256 amount)", + ) + .unwrap(); + let emitted = Event::parse( + "event AmbiguousLayout(address from, address indexed to, uint256 indexed amount)", + ) + .unwrap(); + assert_eq!(known.selector(), emitted.selector()); + + let address = Address::repeat_byte(0x12); + let from = Address::repeat_byte(0x34); + let to = Address::repeat_byte(0x56); + let amount = U256::from(42); + let log = LogData::new_unchecked( + vec![emitted.selector(), to.into_word(), amount.into()], + (from,).abi_encode().into(), + ); + let mut decoder = CallTraceDecoder::new().clone(); + decoder.push_event(known); + + let decoded = decoder.decode_event_with_address_signature(address, &log).await; + assert!(decoded.name.is_none()); + assert!(decoded.params.is_none()); + + decoder.push_address_event(address, emitted); + let decoded = decoder.decode_event_with_address_signature(address, &log).await; + assert_eq!(decoded.name.as_deref(), Some("AmbiguousLayout(address,address,uint256)")); + let params = decoded.params.unwrap(); + assert_eq!(params[0].2, DynSolValue::Address(from)); + assert_eq!(params[1].2, DynSolValue::Address(to)); + assert_eq!(params[2].2, DynSolValue::Uint(amount, 256)); + } + + #[tokio::test] + async fn identified_events_preserve_legacy_indexed_placement_for_traces() { + let event = Event::parse( + "event DecoderAmbiguousIndexedPlacement(address indexed owner, uint256 id)", + ) + .unwrap(); + let mut abi = JsonAbi::new(); + abi.events.insert(event.name.clone(), vec![event.clone()]); + let log = LogData::new_unchecked( + vec![event.selector(), U256::from(42).into()], + (Address::repeat_byte(0x34),).abi_encode().into(), + ); + let identifier = SignaturesIdentifier::new_offline_with_abis([&abi]).unwrap(); + let decoder = CallTraceDecoderBuilder::new().with_signature_identifier(identifier).build(); + + let decoded = decoder.decode_event(&log).await; + assert_eq!(decoded.name.as_deref(), Some("DecoderAmbiguousIndexedPlacement")); + + let decoded = decoder.decode_event_with_address_signature(Address::ZERO, &log).await; + assert!(decoded.name.is_none()); + assert!(decoded.params.is_none()); + } + + #[tokio::test] + async fn address_scoped_anonymous_events_require_a_unique_match() { + let address = Address::repeat_byte(0x12); + let abi = JsonAbi::parse(["event AnonymousValue(uint256 value) anonymous"]).unwrap(); + let log = LogData::new_unchecked(Vec::new(), (U256::from(7),).abi_encode().into()); + let decoded = CallTraceDecoderBuilder::new() + .with_address_events(address, &abi) + .build() + .decode_event_with_address(address, &log) + .await; + assert_eq!(decoded.name.as_deref(), Some("AnonymousValue")); + + let abi = JsonAbi::parse([ + "event AnonymousValue(uint256 value) anonymous", + "event AnonymousAmount(uint256 amount) anonymous", + ]) + .unwrap(); + let decoder = CallTraceDecoderBuilder::new().with_address_events(address, &abi).build(); + + let decoded = decoder.decode_event_with_address(address, &log).await; + + assert!(decoded.name.is_none()); + assert!(decoded.params.is_none()); + + let abi = JsonAbi::parse([ + "event RegularValue(uint256 value)", + "event AnonymousValue(uint256 indexed value) anonymous", + ]) + .unwrap(); + let regular = abi.events().find(|event| !event.anonymous).unwrap(); + let log = LogData::new_unchecked(vec![regular.selector()], Default::default()); + let decoded = CallTraceDecoderBuilder::new() + .with_address_events(address, &abi) + .build() + .decode_event_with_address_signature(address, &log) + .await; + assert_eq!(decoded.name.as_deref(), Some("AnonymousValue(uint256)")); + + let abi = JsonAbi::parse([ + "event RegularValue()", + "event AnonymousValue(bytes32 indexed value) anonymous", + ]) + .unwrap(); + let regular = abi.events().find(|event| !event.anonymous).unwrap(); + let log = LogData::new_unchecked(vec![regular.selector()], Default::default()); + let decoder = CallTraceDecoderBuilder::new().with_address_events(address, &abi).build(); + let decoded = decoder.decode_event_with_address_signature(address, &log).await; + assert!(decoded.name.is_none()); + assert!(decoded.params.is_none()); + let decoded = decoder.decode_event_with_address(address, &log).await; + assert!(decoded.name.is_none()); + assert!(decoded.params.is_none()); + } + + #[test] + fn compact_labels_hide_address_in_trace_parameters() { + let address = Address::with_last_byte(1); + let value = DynSolValue::Address(address); + let tracing = TracingConfig { + labels: AddressHashMap::from_iter([(address, "Alice".to_string())]), + ..Default::default() + }; + let decoder = CallTraceDecoderBuilder::new().with_tracing_config(&tracing).build(); + assert_eq!(decoder.format_value(&value), format!("Alice: [{address}]")); + + let tracing = TracingConfig { compact_labels: true, ..tracing }; + let decoder = CallTraceDecoderBuilder::new().with_tracing_config(&tracing).build(); + assert_eq!(decoder.format_value(&value), "Alice"); + + let tracing = TracingConfig { disable_labels: true, ..tracing }; + let decoder = CallTraceDecoderBuilder::new().with_tracing_config(&tracing).build(); + assert_eq!(decoder.format_value(&value), address.to_string()); + } + + #[test] + fn configured_labels_survive_address_reset() { + let configured = address!("0x0000000000000000000000000000000000000100"); + let discovered = address!("0x0000000000000000000000000000000000000200"); + let tracing = TracingConfig { + labels: AddressHashMap::from_iter([(configured, "configured".to_string())]), + ..Default::default() + }; + let mut decoder = CallTraceDecoderBuilder::new().with_tracing_config(&tracing).build(); + decoder.labels.insert(discovered, "discovered".to_string()); + + decoder.clear_addresses(); + + assert_eq!(decoder.labels.get(&configured).map(String::as_str), Some("configured")); + assert!(!decoder.labels.contains_key(&discovered)); + } + + #[tokio::test] + async fn test_decode_constructor_input() { + let address = Address::repeat_byte(0x12); + let constructor = Constructor::parse("constructor(uint256 amount, address owner)").unwrap(); + let args = constructor + .abi_encode_input(&[ + DynSolValue::Uint(U256::from(42), 256), + DynSolValue::Address(Address::repeat_byte(0x34)), + ]) + .unwrap(); + let mut data = vec![0x60, 0x80, 0x60, 0x40]; + let offset = data.len(); + data.extend_from_slice(&args); + let trace = CallTrace { + address, + kind: revm_inspectors::tracing::types::CallKind::Create, + data: data.into(), + ..Default::default() + }; + let mut decoder = CallTraceDecoder::new().clone(); + decoder.constructors_by_address.insert(address, constructor); + decoder.constructor_args_offsets.insert(address, offset); + + let decoded = decoder.decode_function(&trace).await; + let call_data = decoded.call_data.expect("constructor input should decode"); + + assert_eq!(call_data.signature, "constructor(uint256,address)"); + assert_eq!(call_data.args[0], "42"); + assert_eq!(call_data.args[1], format!("{}", Address::repeat_byte(0x34))); + } + + #[test] + fn test_should_redact() { + let mut decoder = CallTraceDecoder::new().clone(); + decoder.labels.insert(Address::repeat_byte(0x22), "signer".to_string()); + + let expected_revert_bytes4 = vec![0xde, 0xad, 0xbe, 0xef]; + let expect_revert_bytes4_data = Function::parse("expectRevert(bytes4)") + .unwrap() + .abi_encode_input(&[DynSolValue::FixedBytes( + B256::right_padding_from(expected_revert_bytes4.as_slice()), + 4, + )]) + .unwrap(); + + let expected_revert_bytes = hex!( + "08c379a000000000000000000000000000000000000000000000000000000000\ + 0000002000000000000000000000000000000000000000000000000000000000\ + 00000004626f6f6d000000000000000000000000000000000000000000000000" + ) + .to_vec(); + let expect_revert_bytes_data = Function::parse("expectRevert(bytes)") + .unwrap() + .abi_encode_input(&[DynSolValue::Bytes(expected_revert_bytes.clone())]) + .unwrap(); + + let reverter = Address::repeat_byte(0x11); + let expect_revert_bytes4_address_data = Function::parse("expectRevert(bytes4,address)") + .unwrap() + .abi_encode_input(&[ + DynSolValue::FixedBytes( + B256::right_padding_from(expected_revert_bytes4.as_slice()), + 4, + ), + DynSolValue::Address(reverter), + ]) + .unwrap(); + + let count = 42_u64; + let expect_revert_bytes_count_data = Function::parse("expectRevert(bytes,uint64)") + .unwrap() + .abi_encode_input(&[ + DynSolValue::Bytes(expected_revert_bytes.clone()), + DynSolValue::Uint(alloy_primitives::U256::from(count), 64), + ]) + .unwrap(); + + let expect_revert_bytes_address_count_data = + Function::parse("expectRevert(bytes,address,uint64)") + .unwrap() + .abi_encode_input(&[ + DynSolValue::Bytes(expected_revert_bytes.clone()), + DynSolValue::Address(reverter), + DynSolValue::Uint(alloy_primitives::U256::from(count), 64), + ]) + .unwrap(); + + let expect_revert_runtime_data = expected_revert_bytes4.clone(); // [function_signature, data, expected] let cheatcode_input_test_cases = vec![ + // Should decode the expected revert payload, not full cheatcode calldata: + ( + "expectRevert(bytes4)", + expect_revert_bytes4_data, + Some(vec![decoder.revert_decoder.decode(expected_revert_bytes4.as_slice(), None)]), + ), + ( + "expectRevert(bytes)", + expect_revert_bytes_data, + Some(vec![decoder.revert_decoder.decode(expected_revert_bytes.as_slice(), None)]), + ), + ( + "expectRevert(bytes4)", + expect_revert_runtime_data.clone(), + Some(vec![ + decoder.revert_decoder.decode(expect_revert_runtime_data.as_slice(), None), + ]), + ), + ( + "expectRevert(bytes4,address)", + expect_revert_bytes4_address_data, + Some(vec![ + decoder.revert_decoder.decode(expected_revert_bytes4.as_slice(), None), + decoder.format_value(&DynSolValue::Address(reverter)), + ]), + ), + ( + "expectRevert(bytes,uint64)", + expect_revert_bytes_count_data, + Some(vec![ + decoder.revert_decoder.decode(expected_revert_bytes.as_slice(), None), + decoder + .format_value(&DynSolValue::Uint(alloy_primitives::U256::from(count), 64)), + ]), + ), + ( + "expectRevert(bytes,address,uint64)", + expect_revert_bytes_address_count_data, + Some(vec![ + decoder.revert_decoder.decode(expected_revert_bytes.as_slice(), None), + decoder.format_value(&DynSolValue::Address(reverter)), + decoder + .format_value(&DynSolValue::Uint(alloy_primitives::U256::from(count), 64)), + ]), + ), + ( + "expectRevert()", + expect_revert_runtime_data.clone(), + Some(vec![ + decoder.revert_decoder.decode(expect_revert_runtime_data.as_slice(), None), + ]), + ), // Should redact private key from traces in all cases: ("addr(uint256)", vec![], Some(vec!["".to_string()])), + ("createEd25519Key(bytes32)", vec![], Some(vec!["".to_string()])), ("createWallet(string)", vec![], Some(vec!["".to_string()])), ("createWallet(uint256)", vec![], Some(vec!["".to_string()])), ("deriveKey(string,uint32)", vec![], Some(vec!["".to_string()])), ("deriveKey(string,string,uint32)", vec![], Some(vec!["".to_string()])), ("deriveKey(string,uint32,string)", vec![], Some(vec!["".to_string()])), ("deriveKey(string,string,uint32,string)", vec![], Some(vec!["".to_string()])), + ("publicKeyEd25519(bytes32)", vec![], Some(vec!["".to_string()])), + ("publicKeyP256(uint256)", vec![], Some(vec!["".to_string()])), ("rememberKey(uint256)", vec![], Some(vec!["".to_string()])), + ("rememberKeys(string,string,uint32)", vec![], Some(vec!["".to_string()])), + ("rememberKeys(string,string,string,uint32)", vec![], Some(vec!["".to_string()])), // // Should redact private key from traces in specific cases with exceptions: ("broadcast(uint256)", vec![], Some(vec!["".to_string()])), @@ -885,29 +2726,189 @@ mod tests { ]), ), ( - // cast calldata "createFork(string)" "https://eth-mainnet.g.alchemy.com/v2/api_key" - "createFork(string)", + "signCompact(uint256,bytes32)", hex!( " - 31ba3498 - 0000000000000000000000000000000000000000000000000000000000000020 - 000000000000000000000000000000000000000000000000000000000000002c - 68747470733a2f2f6574682d6d61696e6e65742e672e616c6368656d792e636f - 6d2f76322f6170695f6b65790000000000000000000000000000000000000000 - " + cc2a781f + 7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6 + 0000000000000000000000000000000000000000000000000000000000000000 + " ) .to_vec(), - Some(vec!["\"\"".to_string()]), + Some(vec![ + "\"\"".to_string(), + "0x0000000000000000000000000000000000000000000000000000000000000000" + .to_string(), + ]), ), ( - // cast calldata "createFork(string)" "wss://eth-mainnet.g.alchemy.com/v2/api_key" - "createFork(string)", + // signCompact(Wallet,bytes32) + "signCompact((address,uint256,uint256,uint256),bytes32)", hex!( " - 31ba3498 - 0000000000000000000000000000000000000000000000000000000000000020 - 000000000000000000000000000000000000000000000000000000000000002a - 7773733a2f2f6574682d6d61696e6e65742e672e616c6368656d792e636f6d2f + 3d0e292f + 0000000000000000000000001111111111111111111111111111111111111111 + 0000000000000000000000000000000000000000000000000000000000000001 + 0000000000000000000000000000000000000000000000000000000000000002 + 7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6 + 0000000000000000000000000000000000000000000000000000000000000000 + " + ) + .to_vec(), + Some(vec![ + "\"\"".to_string(), + "0x0000000000000000000000000000000000000000000000000000000000000000" + .to_string(), + ]), + ), + ( + // Ignore: `private key` is not passed, digest is signed by the script signer. + "signCompact(bytes32)", + hex!( + " + a282dc4b + 0000000000000000000000000000000000000000000000000000000000000000 + " + ) + .to_vec(), + Some(vec![ + "0x0000000000000000000000000000000000000000000000000000000000000000" + .to_string(), + ]), + ), + ( + // Ignore: `signer` is a public address. + "signCompact(address,bytes32)", + hex!( + " + 8e2f97bf + 0000000000000000000000001111111111111111111111111111111111111111 + 0000000000000000000000000000000000000000000000000000000000000000 + " + ) + .to_vec(), + Some(vec![ + "0x1111111111111111111111111111111111111111".to_string(), + "0x0000000000000000000000000000000000000000000000000000000000000000" + .to_string(), + ]), + ), + ( + "signWithNonceUnsafe(uint256,bytes32,uint256)", + hex!( + " + 2012783a + 7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6 + 0000000000000000000000000000000000000000000000000000000000000000 + 0000000000000000000000000000000000000000000000000000000000000001 + " + ) + .to_vec(), + Some(vec![ + "\"\"".to_string(), + "0x0000000000000000000000000000000000000000000000000000000000000000" + .to_string(), + "\"\"".to_string(), + ]), + ), + ( + "signKeychain(uint256,address,bytes32)", + hex!( + " + 5804c690 + 7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6 + 0000000000000000000000001111111111111111111111111111111111111111 + 0000000000000000000000000000000000000000000000000000000000000000 + " + ) + .to_vec(), + Some(vec![ + "\"\"".to_string(), + "0x1111111111111111111111111111111111111111".to_string(), + "0x0000000000000000000000000000000000000000000000000000000000000000" + .to_string(), + ]), + ), + ( + "signKeychainAdmin(uint256,address,bytes32)", + hex!( + " + bc5fb2f7 + 7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6 + 0000000000000000000000001111111111111111111111111111111111111111 + 0000000000000000000000000000000000000000000000000000000000000000 + " + ) + .to_vec(), + Some(vec![ + "\"\"".to_string(), + "0x1111111111111111111111111111111111111111".to_string(), + "0x0000000000000000000000000000000000000000000000000000000000000000" + .to_string(), + ]), + ), + ( + // Labels are applied to address arguments while the key is redacted. + "signKeychain(uint256,address,bytes32)", + hex!( + " + 5804c690 + 7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6 + 0000000000000000000000002222222222222222222222222222222222222222 + 0000000000000000000000000000000000000000000000000000000000000000 + " + ) + .to_vec(), + Some(vec![ + "\"\"".to_string(), + "signer: [0x2222222222222222222222222222222222222222]".to_string(), + "0x0000000000000000000000000000000000000000000000000000000000000000" + .to_string(), + ]), + ), + ( + // cast calldata "signEd25519(bytes,bytes,bytes32)" "0x6e73" "0x6d7367" + // 0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6 + "signEd25519(bytes,bytes,bytes32)", + hex!( + " + ef609c65 + 0000000000000000000000000000000000000000000000000000000000000060 + 00000000000000000000000000000000000000000000000000000000000000a0 + 7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6 + 0000000000000000000000000000000000000000000000000000000000000002 + 6e73000000000000000000000000000000000000000000000000000000000000 + 0000000000000000000000000000000000000000000000000000000000000003 + 6d73670000000000000000000000000000000000000000000000000000000000 + " + ) + .to_vec(), + Some(vec!["0x6e73".to_string(), "0x6d7367".to_string(), "\"\"".to_string()]), + ), + ( + // cast calldata "createFork(string)" "https://eth-mainnet.g.alchemy.com/v2/api_key" + "createFork(string)", + hex!( + " + 31ba3498 + 0000000000000000000000000000000000000000000000000000000000000020 + 000000000000000000000000000000000000000000000000000000000000002c + 68747470733a2f2f6574682d6d61696e6e65742e672e616c6368656d792e636f + 6d2f76322f6170695f6b65790000000000000000000000000000000000000000 + " + ) + .to_vec(), + Some(vec!["\"\"".to_string()]), + ), + ( + // cast calldata "createFork(string)" "wss://eth-mainnet.g.alchemy.com/v2/api_key" + "createFork(string)", + hex!( + " + 31ba3498 + 0000000000000000000000000000000000000000000000000000000000000020 + 000000000000000000000000000000000000000000000000000000000000002a + 7773733a2f2f6574682d6d61696e6e65742e672e616c6368656d792e636f6d2f 76322f6170695f6b657900000000000000000000000000000000000000000000 " ) @@ -1158,6 +3159,7 @@ mod tests { // [function_signature, expected] let cheatcode_output_test_cases = vec![ // Should redact private key on output in all cases: + ("createEd25519Key(bytes32)", Some("".to_string())), ("createWallet(string)", Some("".to_string())), ("deriveKey(string,uint32)", Some("".to_string())), // Should redact RPC URL if defined, except if referenced by an alias: @@ -1178,4 +3180,1442 @@ mod tests { assert_eq!(result, expected, "Output case failed for: {function_signature}"); } } + + #[tokio::test] + async fn test_should_redact_end_to_end() { + let decoder = CallTraceDecoder::new(); + let pk_hex = "7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6"; + let pk = hex!("7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6"); + let cheatcode_trace = |data: Vec| CallTrace { + address: CHEATCODE_ADDRESS, + data: data.into(), + success: true, + ..Default::default() + }; + + // Well-formed sign(uint256,bytes32) resolves through selector lookup and redacts the + // private key. + let call = Vm::sign_1Call { privateKey: U256::from_be_bytes(pk), digest: B256::ZERO }; + let decoded = decoder.decode_function(&cheatcode_trace(call.abi_encode())).await; + let call_data = decoded.call_data.expect("sign should decode"); + assert_eq!(call_data.signature, "sign(uint256,bytes32)"); + assert_eq!(call_data.args[0], "\"\""); + assert!(!call_data.args.join(",").contains(pk_hex)); + + // signEd25519 redacts the trailing private key argument. + let call = Vm::signEd25519Call { + namespace: b"ns".into(), + message: b"msg".into(), + privateKey: B256::from(pk), + }; + let decoded = decoder.decode_function(&cheatcode_trace(call.abi_encode())).await; + let call_data = decoded.call_data.expect("signEd25519 should decode"); + assert_eq!(call_data.signature, "signEd25519(bytes,bytes,bytes32)"); + assert_eq!(call_data.args[2], "\"\""); + assert!(!call_data.args.join(",").contains(pk_hex)); + + // Malformed calldata (nonzero trailing byte fails the ABI shape heuristic) must not + // fall back to raw calldata rendering. + let call = Vm::sign_1Call { privateKey: U256::from_be_bytes(pk), digest: B256::ZERO }; + let mut data = call.abi_encode(); + data.push(0xff); + let decoded = decoder.decode_function(&cheatcode_trace(data)).await; + let call_data = decoded.call_data.expect("malformed calldata must not fall back to raw"); + assert_eq!(call_data.signature, "sign(uint256,bytes32)"); + assert!(!call_data.args.join(",").contains(pk_hex)); + + // Suffixed calldata that isn't the exact ABI encoding (dirty string padding) must not + // fall back to raw calldata rendering either. + let call = Vm::deriveKey_0Call { + mnemonic: "test test test test test test test test test test test junk".to_string(), + index: 0, + }; + let mut data = call.abi_encode(); + *data.last_mut().unwrap() = 0xff; + data.push(0xff); + let decoded = decoder.decode_function(&cheatcode_trace(data)).await; + let call_data = decoded.call_data.expect("suffixed calldata must not fall back to raw"); + assert_eq!(call_data.args, vec!["".to_string()]); + + // Truncated calldata cannot be decoded at all and fails closed. + let data = Vm::sign_1Call::SELECTOR.iter().copied().chain(pk).collect::>(); + let decoded = decoder.decode_function(&cheatcode_trace(data)).await; + let call_data = decoded.call_data.expect("truncated calldata must not fall back to raw"); + assert_eq!(call_data.signature, "sign(uint256,bytes32)"); + assert_eq!(call_data.args, vec!["".to_string()]); + } + + #[tokio::test] + async fn test_tempo_decode_preserves_existing_labels() { + let decoder = CallTraceDecoder::new(); + let trace = CallTrace { address: PATH_USD_ADDRESS, success: true, ..Default::default() }; + + let decoded = decoder.decode_function(&trace).await; + assert_eq!(decoded.label.as_deref(), Some("PathUSD")); + } + + #[tokio::test] + async fn test_tempo_decode_labels_ousd() { + let decoder = CallTraceDecoder::new(); + let transfer = ITIP20::transferCall { + to: address!("0x0000000000000000000000000000000000000def"), + amount: U256::from(1_000_000u64), + }; + let trace = CallTrace { + address: OUSD_ADDRESS, + data: transfer.abi_encode().into(), + success: true, + ..Default::default() + }; + + let decoded = decoder.decode_function(&trace).await; + assert_eq!(decoded.label.as_deref(), Some("OUSD")); + let call_data = decoded.call_data.expect("OUSD transfer should decode"); + assert_eq!(call_data.signature, "transfer(address,uint256)"); + } + + #[tokio::test] + async fn test_t5_decode_does_not_synthesize_general_target_label() { + let mut decoder = CallTraceDecoder::new().clone(); + decoder.chain_id = Some(4217); + let trace = CallTrace { + address: address!("0x0000000000000000000000000000000000000123"), + depth: 0, + success: true, + ..Default::default() + }; + + let decoded = decoder.decode_function(&trace).await; + assert_eq!(decoded.label, None); + } + + #[tokio::test] + async fn test_t5_tip20_logo_uri_calls_and_events_decode() { + let decoder = CallTraceDecoder::new(); + + let call = ITIP20::setLogoURICall { newLogoURI: "https://example.com/logo.png".into() }; + let trace = CallTrace { + address: PATH_USD_ADDRESS, + data: call.abi_encode().into(), + success: true, + ..Default::default() + }; + let decoded = decoder.decode_function(&trace).await; + let call_data = decoded.call_data.expect("setLogoURI should decode"); + assert_eq!(call_data.signature, "setLogoURI(string)"); + assert_eq!(call_data.args, vec!["\"https://example.com/logo.png\"".to_string()]); + + let call = ITIP20::logoURICall {}; + let trace = CallTrace { + address: PATH_USD_ADDRESS, + data: call.abi_encode().into(), + success: true, + ..Default::default() + }; + let decoded = decoder.decode_function(&trace).await; + assert_eq!(decoded.call_data.expect("logoURI should decode").signature, "logoURI()"); + + let event = ITIP20::LogoURIUpdated { + updater: address!("0x0000000000000000000000000000000000000abc"), + newLogoURI: "ipfs://logo".into(), + }; + let decoded = decoder.decode_event(&event.encode_log_data()).await; + assert_eq!(decoded.name.as_deref(), Some("LogoURIUpdated")); + let params = decoded.params.expect("LogoURIUpdated params should decode"); + assert_eq!(params[0].0, "updater"); + assert!( + params[0].1.to_ascii_lowercase().contains("0000000000000000000000000000000000000abc") + ); + assert_eq!(params[1], ("newLogoURI".into(), "\"ipfs://logo\"".into())); + } + + #[tokio::test] + async fn test_t5_tip20_factory_create_token_with_logo_decodes() { + let decoder = CallTraceDecoder::new(); + let call = ITIP20Factory::createToken_1Call { + name: "Example USD".into(), + symbol: "xUSD".into(), + currency: "USD".into(), + quoteToken: PATH_USD_ADDRESS, + admin: address!("0x0000000000000000000000000000000000000abc"), + salt: B256::repeat_byte(0x11), + logoURI: "https://example.com/xusd.png".into(), + }; + let trace = CallTrace { + address: TIP20_FACTORY_ADDRESS, + data: call.abi_encode().into(), + success: true, + ..Default::default() + }; + let decoded = decoder.decode_function(&trace).await; + let call_data = decoded.call_data.expect("createToken overload should decode"); + assert_eq!( + call_data.signature, + "createToken(string,string,string,address,address,bytes32,string)" + ); + assert_eq!(call_data.args[6], "\"https://example.com/xusd.png\""); + } + + #[tokio::test] + async fn test_t5_stablecoin_dex_order_flipped_event_decodes() { + let decoder = CallTraceDecoder::new(); + let event = IStablecoinDEX::OrderFlipped { + orderId: 42, + maker: address!("0x0000000000000000000000000000000000000abc"), + token: PATH_USD_ADDRESS, + amount: 1_000_000, + isBid: false, + tick: 100, + flipTick: 100, + }; + let decoded = decoder.decode_event(&event.encode_log_data()).await; + assert_eq!(decoded.name.as_deref(), Some("OrderFlipped")); + let params = decoded.params.expect("OrderFlipped params should decode"); + assert_eq!(params[0], ("orderId".into(), "42".into())); + assert_eq!(params[4], ("isBid".into(), "false".into())); + assert_eq!(params[5], ("tick".into(), "100".into())); + assert_eq!(params[6], ("flipTick".into(), "100".into())); + } + + #[tokio::test] + async fn test_t5_channel_reserve_call_and_event_decode() { + let mut decoder = CallTraceDecoder::new().clone(); + decoder.chain_id = Some(4217); + + let open = ITIP20ChannelReserve::openCall { + payee: address!("0x0000000000000000000000000000000000000abc"), + operator: Address::ZERO, + token: PATH_USD_ADDRESS, + deposit: U96::from(1_000_000u64), + salt: B256::repeat_byte(0x22), + authorizedSigner: Address::ZERO, + }; + let trace = CallTrace { + address: TIP20_CHANNEL_RESERVE_ADDRESS, + data: open.abi_encode().into(), + depth: 0, + success: true, + ..Default::default() + }; + let decoded = decoder.decode_function(&trace).await; + assert_eq!(decoded.label.as_deref(), Some("TIP20ChannelReserve")); + assert_eq!( + decoded.call_data.expect("open should decode").signature, + "open(address,address,address,uint96,bytes32,address)" + ); + + let transfer = ITIP20::transferCall { + to: address!("0x0000000000000000000000000000000000000def"), + amount: U256::from(1_000_000u64), + }; + let trace = CallTrace { + address: PATH_USD_ADDRESS, + data: transfer.abi_encode().into(), + depth: 0, + success: true, + ..Default::default() + }; + let decoded = decoder.decode_function(&trace).await; + assert_eq!(decoded.label.as_deref(), Some("PathUSD")); + let json = serde_json::to_string(&decoded).expect("decoded trace serializes"); + assert!(json.contains(r#""label":"PathUSD""#)); + assert!(!json.contains("payment-lane")); + + let balance_of = ITIP20::balanceOfCall { + account: address!("0x0000000000000000000000000000000000000def"), + }; + let trace = CallTrace { + address: PATH_USD_ADDRESS, + data: balance_of.abi_encode().into(), + depth: 0, + success: true, + ..Default::default() + }; + let decoded = decoder.decode_function(&trace).await; + assert_eq!(decoded.label.as_deref(), Some("PathUSD")); + + let event = ITIP20ChannelReserve::ChannelOpened { + channelId: B256::repeat_byte(0x33), + payer: address!("0x0000000000000000000000000000000000000123"), + payee: address!("0x0000000000000000000000000000000000000abc"), + operator: Address::ZERO, + token: PATH_USD_ADDRESS, + authorizedSigner: Address::ZERO, + salt: B256::repeat_byte(0x22), + expiringNonceHash: B256::repeat_byte(0x44), + deposit: U96::from(1_000_000u64), + }; + let decoded = decoder.decode_event(&event.encode_log_data()).await; + assert_eq!(decoded.name.as_deref(), Some("ChannelOpened")); + let params = decoded.params.expect("ChannelOpened params should decode"); + assert_eq!(params[0].0, "channelId"); + assert_eq!(params[8].0, "deposit"); + assert!(params[8].1.starts_with("1000000")); + } + + #[tokio::test] + #[cfg(feature = "monad")] + async fn test_decodes_monad_staking_precompile_call() { + let trace = CallTrace { + address: monad_revm::staking::STAKING_ADDRESS, + data: getEpochCall::SELECTOR.into(), + output: + monad_revm::staking::interface::IMonadStaking::getEpochCall::abi_encode_returns( + &monad_revm::staking::interface::IMonadStaking::getEpochReturn { + epoch: 42, + inEpochDelayPeriod: true, + }, + ) + .into(), + success: true, + ..Default::default() + }; + + let decoded = monad_decoder(MonadHardfork::MonadEight).decode_function(&trace).await; + + assert_eq!(decoded.label.as_deref(), Some("Staking")); + let call_data = decoded.call_data.expect("call data"); + assert_eq!(call_data.signature, "getEpoch()"); + assert!(call_data.args.is_empty()); + assert_eq!(decoded.return_data.as_deref(), Some("42, true")); + } + + #[tokio::test] + #[cfg(feature = "monad")] + async fn test_decodes_monad_staking_syscall() { + let block_author = Address::repeat_byte(0x42); + let trace = CallTrace { + address: monad_revm::staking::STAKING_ADDRESS, + data: monad::IMonadStakingSyscalls::syscallRewardCall { blockAuthor: block_author } + .abi_encode() + .into(), + success: true, + ..Default::default() + }; + + let decoder = monad_decoder(MonadHardfork::MonadEight); + let expected_author = decoder.format_value(&DynSolValue::Address(block_author)); + let decoded = decoder.decode_function(&trace).await; + + assert_eq!(decoded.label.as_deref(), Some("Staking")); + let call_data = decoded.call_data.expect("call data"); + assert_eq!(call_data.signature, "syscallReward(address)"); + assert_eq!(call_data.args, vec![expected_author]); + } + + #[tokio::test] + #[cfg(feature = "monad")] + async fn test_decodes_monad_reserve_balance_precompile_call() { + let trace = CallTrace { + address: monad_revm::reserve_balance::abi::RESERVE_BALANCE_ADDRESS, + data: dippedIntoReserveCall::SELECTOR.into(), + output: true.abi_encode().into(), + success: true, + ..Default::default() + }; + + let decoded = monad_decoder(MonadHardfork::MonadNine).decode_function(&trace).await; + + assert_eq!(decoded.label.as_deref(), Some("ReserveBalance")); + let call_data = decoded.call_data.expect("call data"); + assert_eq!(call_data.signature, "dippedIntoReserve()"); + assert!(call_data.args.is_empty()); + assert_eq!(decoded.return_data.as_deref(), Some("true")); + } + + #[tokio::test] + #[cfg(feature = "monad")] + async fn test_decodes_monad_staking_precompile_event() { + let event = Event::parse( + "event Delegate(uint64 indexed validatorId,address indexed delegator,uint256 amount,uint64 activationEpoch)", + ) + .unwrap(); + let delegator = Address::repeat_byte(0x11); + let log = LogData::new_unchecked( + vec![event.selector(), topic_from_u64(7), topic_from_address(delegator)], + (U256::from(1000), 9_u64).abi_encode().into(), + ); + + let decoder = monad_decoder(MonadHardfork::MonadEight); + let decoded = + decoder.decode_event_with_address(monad_revm::staking::STAKING_ADDRESS, &log).await; + + assert_eq!(decoded.name.as_deref(), Some("Delegate")); + let params = decoded.params.expect("params"); + assert_eq!(params[0], ("validatorId".to_string(), "7".to_string())); + assert_eq!(params[1].0, "delegator"); + assert_eq!(params[2], ("amount".to_string(), "1000".to_string())); + assert_eq!(params[3], ("activationEpoch".to_string(), "9".to_string())); + + let collision = decoder + .decode_event_with_address( + monad_revm::reserve_balance::abi::RESERVE_BALANCE_ADDRESS, + &log, + ) + .await; + assert_eq!(collision.name, None); + } + + #[tokio::test] + #[cfg(feature = "monad")] + async fn test_monad_metadata_is_not_registered_for_ethereum() { + let decoder = CallTraceDecoderBuilder::new().with_chain_id(Some(1)).build(); + for address in [ + foundry_evm_core::constants::MONAD_CHEATCODE_ADDRESS, + monad_revm::staking::STAKING_ADDRESS, + monad_revm::reserve_balance::abi::RESERVE_BALANCE_ADDRESS, + ] { + assert!(!decoder.labels.contains_key(&address)); + } + + let staking_trace = CallTrace { + address: monad_revm::staking::STAKING_ADDRESS, + data: getEpochCall::SELECTOR.into(), + output: + monad_revm::staking::interface::IMonadStaking::getEpochCall::abi_encode_returns( + &monad_revm::staking::interface::IMonadStaking::getEpochReturn { + epoch: 42, + inEpochDelayPeriod: true, + }, + ) + .into(), + success: true, + ..Default::default() + }; + let staking = decoder.decode_function(&staking_trace).await; + assert_ne!( + staking.call_data.as_ref().map(|call| call.signature.as_str()), + Some("getEpoch()") + ); + + let reserve_trace = CallTrace { + address: monad_revm::reserve_balance::abi::RESERVE_BALANCE_ADDRESS, + data: dippedIntoReserveCall::SELECTOR.into(), + output: true.abi_encode().into(), + success: true, + ..Default::default() + }; + let reserve = decoder.decode_function(&reserve_trace).await; + assert_ne!( + reserve.call_data.as_ref().map(|call| call.signature.as_str()), + Some("dippedIntoReserve()") + ); + + let event = Event::parse( + "event Delegate(uint64 indexed validatorId,address indexed delegator,uint256 amount,uint64 activationEpoch)", + ) + .unwrap(); + let log = LogData::new_unchecked( + vec![ + event.selector(), + topic_from_u64(7), + topic_from_address(Address::repeat_byte(0x11)), + ], + (U256::from(1000), 9_u64).abi_encode().into(), + ); + for address in [ + monad_revm::staking::STAKING_ADDRESS, + monad_revm::reserve_balance::abi::RESERVE_BALANCE_ADDRESS, + ] { + let decoded = decoder.decode_event_with_address(address, &log).await; + assert_eq!(decoded.name, None); + } + } + + #[tokio::test] + #[cfg(feature = "monad")] + async fn test_monad_reserve_metadata_starts_at_monad_nine() { + let mut monad_eight = monad_decoder(MonadHardfork::MonadEight); + monad_eight.clear_addresses(); + assert_eq!( + monad_eight + .labels + .get(&foundry_evm_core::constants::MONAD_CHEATCODE_ADDRESS) + .map(String::as_str), + Some("MonadVM") + ); + assert_eq!( + monad_eight.labels.get(&monad_revm::staking::STAKING_ADDRESS).map(String::as_str), + Some("Staking") + ); + assert!( + !monad_eight + .labels + .contains_key(&monad_revm::reserve_balance::abi::RESERVE_BALANCE_ADDRESS) + ); + + let trace = CallTrace { + address: monad_revm::reserve_balance::abi::RESERVE_BALANCE_ADDRESS, + data: dippedIntoReserveCall::SELECTOR.into(), + output: true.abi_encode().into(), + success: true, + ..Default::default() + }; + let before = monad_eight.decode_function(&trace).await; + assert_ne!( + before.call_data.as_ref().map(|call| call.signature.as_str()), + Some("dippedIntoReserve()") + ); + + let mut monad_nine = monad_decoder(MonadHardfork::MonadNine); + monad_nine.clear_addresses(); + assert_eq!( + monad_nine + .labels + .get(&monad_revm::reserve_balance::abi::RESERVE_BALANCE_ADDRESS) + .map(String::as_str), + Some("ReserveBalance") + ); + let after = monad_nine.decode_function(&trace).await; + assert_eq!( + after.call_data.as_ref().map(|call| call.signature.as_str()), + Some("dippedIntoReserve()") + ); + } + + #[tokio::test] + #[cfg(feature = "monad")] + async fn test_monad_metadata_refreshes_across_hardforks() { + let trace = CallTrace { + address: monad_revm::reserve_balance::abi::RESERVE_BALANCE_ADDRESS, + data: dippedIntoReserveCall::SELECTOR.into(), + output: true.abi_encode().into(), + success: true, + ..Default::default() + }; + let mut decoder = monad_decoder(MonadHardfork::MonadEight); + + decoder.set_hardfork(Some(MonadHardfork::MonadNine.into())); + assert_eq!(decoder.hardfork(), Some(MonadHardfork::MonadNine.into())); + assert_eq!( + decoder + .labels + .get(&monad_revm::reserve_balance::abi::RESERVE_BALANCE_ADDRESS) + .map(String::as_str), + Some("ReserveBalance") + ); + let monad_nine = decoder.decode_function(&trace).await; + assert_eq!( + monad_nine.call_data.as_ref().map(|call| call.signature.as_str()), + Some("dippedIntoReserve()") + ); + + decoder.set_hardfork(Some(MonadHardfork::MonadEight.into())); + assert_eq!(decoder.hardfork(), Some(MonadHardfork::MonadEight.into())); + assert!( + !decoder + .labels + .contains_key(&monad_revm::reserve_balance::abi::RESERVE_BALANCE_ADDRESS) + ); + assert_eq!( + decoder.labels.get(&monad_revm::staking::STAKING_ADDRESS).map(String::as_str), + Some("Staking") + ); + let monad_eight = decoder.decode_function(&trace).await; + assert_ne!( + monad_eight.call_data.as_ref().map(|call| call.signature.as_str()), + Some("dippedIntoReserve()") + ); + } + + #[cfg(feature = "monad")] + fn topic_from_u64(value: u64) -> B256 { + let mut topic = [0u8; 32]; + topic[24..].copy_from_slice(&value.to_be_bytes()); + B256::from(topic) + } + + #[cfg(feature = "monad")] + fn topic_from_address(address: Address) -> B256 { + let mut topic = [0u8; 32]; + topic[12..].copy_from_slice(address.as_slice()); + B256::from(topic) + } + + #[tokio::test] + async fn test_t7_storage_credits_call_and_error_decode() { + let mut decoder = CallTraceDecoder::new().clone(); + decoder.chain_id = Some(4217); + + // A write call decodes to its signature and the precompile address is labeled. + let set_mode = IStorageCredits::setModeCall { newMode: IStorageCredits::Mode::Direct }; + let trace = CallTrace { + address: STORAGE_CREDITS_ADDRESS, + data: set_mode.abi_encode().into(), + depth: 0, + success: true, + ..Default::default() + }; + let decoded = decoder.decode_function(&trace).await; + assert_eq!(decoded.label.as_deref(), Some("StorageCredits")); + assert_eq!(decoded.call_data.expect("setMode should decode").signature, "setMode(uint8)"); + + // A view call unique to this precompile also decodes. + let mode_of = IStorageCredits::modeOfCall { account: Address::repeat_byte(0x11) }; + let trace = CallTrace { + address: STORAGE_CREDITS_ADDRESS, + data: mode_of.abi_encode().into(), + depth: 0, + success: true, + ..Default::default() + }; + let decoded = decoder.decode_function(&trace).await; + assert_eq!(decoded.call_data.expect("modeOf should decode").signature, "modeOf(address)"); + + // The precompile's custom errors decode by name in reverts. + let revert = decoder + .revert_decoder + .decode(IStorageCredits::InvalidMode {}.abi_encode().as_slice(), None); + assert!(revert.contains("InvalidMode"), "{revert}"); + + // `balanceOf(address)` collides with `ITIP20`'s selector; the global map must keep + // `ITIP20`'s `uint256` return so ordinary token balances above `u64::MAX` still decode. + let selector = IStorageCredits::balanceOfCall::SELECTOR; + let funcs = decoder.functions.get(&selector).expect("balanceOf selector is registered"); + assert!( + funcs.iter().any(|f| f.outputs.first().is_some_and(|o| o.ty == "uint256")), + "global balanceOf must return uint256" + ); + } + + // A mock identifier that records which addresses it was asked to identify. + struct RecordingIdentifier { + queried: Vec
, + } + impl TraceIdentifier for RecordingIdentifier { + fn identify_addresses(&mut self, nodes: &[&CallTraceNode]) -> Vec> { + self.queried.extend(nodes.iter().map(|n| n.trace.address)); + Vec::new() + } + } + + struct AbiIdentifier { + abi: JsonAbi, + } + + impl TraceIdentifier for AbiIdentifier { + fn identify_addresses(&mut self, nodes: &[&CallTraceNode]) -> Vec> { + nodes + .iter() + .map(|node| IdentifiedAddress { + address: node.trace.address, + label: Some("Scoped".to_string()), + contract: Some("Scoped".to_string()), + abi: Some(Cow::Borrowed(&self.abi)), + constructor_args_offset: None, + artifact_id: None, + }) + .collect() + } + } + + #[test] + fn scoped_identification_reuses_global_abi() { + let abi = JsonAbi::parse(["function scoped(uint256)"]).unwrap(); + let function = abi.functions().next().unwrap().clone(); + let selector = function.selector(); + let mut decoder = CallTraceDecoderBuilder::new().with_abi(&abi).build(); + let global_functions = decoder.functions.get(&selector).unwrap().len(); + + let address = Address::repeat_byte(0x42); + let mut arena = CallTraceArena::default(); + arena.nodes_mut()[0].trace.address = address; + decoder.identify_scoped(&arena, &mut AbiIdentifier { abi }); + + assert_eq!(decoder.functions.get(&selector).unwrap().len(), global_functions); + assert_eq!(decoder.functions_by_address[&address][&selector], [function]); + } + + #[tokio::test] + async fn inactive_p256_address_uses_contract_identity() { + let abi = JsonAbi::parse(["function ordinaryCode()"]).unwrap(); + let function = abi.functions().next().unwrap(); + let mut arena = CallTraceArena::default(); + arena.nodes_mut()[0].trace = CallTrace { + address: P256_VERIFY, + maybe_precompile: Some(false), + data: function.selector().into(), + success: true, + ..Default::default() + }; + + let mut decoder = CallTraceDecoder::new().clone(); + decoder.identify(&arena, &mut AbiIdentifier { abi }); + let decoded = decoder.decode_function(&arena.nodes()[0].trace).await; + + assert_eq!(decoded.label.as_deref(), Some("Scoped")); + assert_eq!(decoded.call_data.unwrap().signature, "ordinaryCode()"); + } + + #[test] + fn p256_is_not_an_unconditional_precompile_label() { + assert!(!CallTraceDecoder::new().precompile_labels().contains_key(&P256_VERIFY)); + } + + #[test] + fn test_identify_addresses_skips_evm_precompiles() { + use foundry_evm_core::precompiles::SHA_256; + + let decoder = CallTraceDecoder::new(); + + let mut arena = CallTraceArena::default(); + let regular_addr = Address::repeat_byte(0x42); + arena.nodes_mut()[0].trace.address = regular_addr; + + // Standard EVM precompile flagged by the inspector. + arena.nodes_mut().push(CallTraceNode { + trace: CallTrace { + address: SHA_256, + depth: 1, + maybe_precompile: Some(true), + ..Default::default() + }, + idx: 1, + ..Default::default() + }); + + // Standard EVM precompile NOT flagged, caught by is_known_precompile. + arena.nodes_mut().push(CallTraceNode { + trace: CallTrace { + address: SHA_256, + depth: 1, + maybe_precompile: None, + ..Default::default() + }, + idx: 2, + ..Default::default() + }); + + let mut identifier = RecordingIdentifier { queried: Vec::new() }; + decoder.identify_addresses(&arena, &mut identifier); + + assert_eq!(identifier.queried, vec![regular_addr]); + } + + #[test] + fn test_identify_addresses_skips_tempo_precompiles() { + use foundry_evm_core::tempo::{TEMPO_PRECOMPILE_ADDRESSES, TIP20_CHANNEL_RESERVE_ADDRESS}; + + // Decoder with Tempo chain ID (4217). + let decoder = CallTraceDecoderBuilder::new() + .with_chain_id(Some(4217)) + .with_hardfork(Some(TempoHardfork::T5.into())) + .build(); + + assert_eq!( + decoder.labels.get(&TIP20_CHANNEL_RESERVE_ADDRESS), + Some(&"TIP20ChannelReserve".to_string()) + ); + + let mut arena = CallTraceArena::default(); + let regular_addr = Address::repeat_byte(0x42); + arena.nodes_mut()[0].trace.address = regular_addr; + + // Tempo precompile — not flagged by inspector, caught by is_known_precompile + // only when chain_id is a Tempo chain. + let tempo_precompile = TEMPO_PRECOMPILE_ADDRESSES[0]; + arena.nodes_mut().push(CallTraceNode { + trace: CallTrace { + address: tempo_precompile, + depth: 1, + maybe_precompile: None, + ..Default::default() + }, + idx: 1, + ..Default::default() + }); + + let mut identifier = RecordingIdentifier { queried: Vec::new() }; + decoder.identify_addresses(&arena, &mut identifier); + + // On a Tempo chain, the Tempo precompile should be filtered out. + assert_eq!(identifier.queried, vec![regular_addr]); + } + + #[test] + fn test_precompile_labels_follow_tempo_hardfork_activation_boundaries() { + let labels_for_hardfork = |hardfork: TempoHardfork| { + CallTraceDecoderBuilder::new() + .with_hardfork(Some(hardfork.into())) + .build() + .precompile_labels() + }; + + let t4_labels = labels_for_hardfork(TempoHardfork::T4); + assert_eq!(t4_labels.get(&TIP_FEE_MANAGER_ADDRESS), Some(&"FeeManager".to_string())); + assert!(!t4_labels.contains_key(&TIP20_CHANNEL_RESERVE_ADDRESS)); + assert!(!t4_labels.contains_key(&RECEIVE_POLICY_GUARD_ADDRESS)); + assert!(!t4_labels.contains_key(&STORAGE_CREDITS_ADDRESS)); + + let t5_labels = labels_for_hardfork(TempoHardfork::T5); + assert_eq!(t5_labels.get(&TIP_FEE_MANAGER_ADDRESS), Some(&"FeeManager".to_string())); + assert_eq!( + t5_labels.get(&TIP20_CHANNEL_RESERVE_ADDRESS), + Some(&"TIP20ChannelReserve".to_string()) + ); + assert!(!t5_labels.contains_key(&RECEIVE_POLICY_GUARD_ADDRESS)); + assert!(!t5_labels.contains_key(&STORAGE_CREDITS_ADDRESS)); + + let t6_labels = labels_for_hardfork(TempoHardfork::T6); + assert_eq!( + t6_labels.get(&TIP20_CHANNEL_RESERVE_ADDRESS), + Some(&"TIP20ChannelReserve".to_string()) + ); + assert_eq!( + t6_labels.get(&RECEIVE_POLICY_GUARD_ADDRESS), + Some(&"ReceivePolicyGuard".to_string()) + ); + assert!(!t6_labels.contains_key(&STORAGE_CREDITS_ADDRESS)); + + let t7_labels = labels_for_hardfork(TempoHardfork::T7); + assert_eq!( + t7_labels.get(&RECEIVE_POLICY_GUARD_ADDRESS), + Some(&"ReceivePolicyGuard".to_string()) + ); + assert_eq!(t7_labels.get(&STORAGE_CREDITS_ADDRESS), Some(&"StorageCredits".to_string())); + + let ethereum_labels = CallTraceDecoderBuilder::new() + .with_execution_network(NetworkVariant::Ethereum) + .with_chain_id(Some(4217)) + .with_hardfork(Some(TempoHardfork::T7.into())) + .build() + .precompile_labels(); + assert!(!ethereum_labels.contains_key(&TIP20_CHANNEL_RESERVE_ADDRESS)); + assert!(!ethereum_labels.contains_key(&RECEIVE_POLICY_GUARD_ADDRESS)); + assert!(!ethereum_labels.contains_key(&STORAGE_CREDITS_ADDRESS)); + } + + #[test] + fn celo_profile_is_authoritative_for_custom_precompile_labels() { + let mut custom_celo = CallTraceDecoderBuilder::new() + .with_networks(NetworkConfigs::with_celo()) + .with_chain_id(Some(98_765_432)) + .build(); + assert_eq!( + custom_celo.precompile_labels().get(&CELO_TRANSFER), + Some(&CELO_TRANSFER_LABEL.to_string()) + ); + custom_celo.clear_addresses(); + assert_eq!( + custom_celo.precompile_labels().get(&CELO_TRANSFER), + Some(&CELO_TRANSFER_LABEL.to_string()) + ); + + let explicit_ethereum = CallTraceDecoderBuilder::new() + .with_networks(NetworkConfigs::default()) + .with_chain_id(Some(42_220)) + .build(); + assert!(!explicit_ethereum.precompile_labels().contains_key(&CELO_TRANSFER)); + + let inferred_celo = CallTraceDecoderBuilder::new().with_chain_id(Some(42_220)).build(); + assert_eq!( + inferred_celo.precompile_labels().get(&CELO_TRANSFER), + Some(&CELO_TRANSFER_LABEL.to_string()) + ); + } + + #[cfg(feature = "base")] + #[test] + fn test_precompile_labels_follow_base_upgrade_boundaries() { + let labels_for_upgrade = |upgrade| { + CallTraceDecoderBuilder::new() + .with_chain_id(Some(8453)) + .with_hardfork(Some(FoundryHardfork::Base(upgrade))) + .build() + .precompile_labels() + }; + let base_precompiles = NetworkConfigs::with_base().precompiles(None); + let base_label_count = |labels: &AddressHashMap| { + base_precompiles.values().filter(|address| labels.contains_key(*address)).count() + }; + + assert_eq!(base_label_count(&labels_for_upgrade(BaseUpgrade::Azul)), 0); + assert_eq!(base_label_count(&labels_for_upgrade(BaseUpgrade::Beryl)), 3); + assert_eq!(base_label_count(&labels_for_upgrade(BaseUpgrade::Cobalt)), 5); + } + + #[tokio::test] + async fn test_current_committee_decoding_is_durable_and_context_gated() { + let abi = ICurrentCommittee::abi::contract(); + let function = abi.functions.get("getCommitteeMembers").unwrap().first().unwrap(); + let output = function + .abi_encode_output(&[ + DynSolValue::Uint(U256::from(7), 64), + DynSolValue::Array(vec![DynSolValue::FixedBytes(B256::with_last_byte(1), 32)]), + ]) + .unwrap(); + let trace = CallTrace { + address: CURRENT_COMMITTEE_ADDRESS, + data: function.selector().into(), + output: output.into(), + success: true, + ..Default::default() + }; + + let mut decoder = CallTraceDecoderBuilder::new() + .with_chain_id(Some(4217)) + .with_hardfork(Some(TempoHardfork::T8.into())) + .build(); + decoder.clear_addresses(); + let decoded = decoder.decode_function(&trace).await; + assert_eq!(decoded.label.as_deref(), Some("CurrentCommittee")); + assert_eq!(decoded.call_data.unwrap().signature, "getCommitteeMembers()"); + assert_eq!( + decoded.return_data.unwrap(), + "7, [0x0000000000000000000000000000000000000000000000000000000000000001]" + ); + + for decoder in [ + CallTraceDecoderBuilder::new() + .with_chain_id(Some(4217)) + .with_hardfork(Some(TempoHardfork::T7.into())) + .build(), + CallTraceDecoderBuilder::new() + .with_chain_id(Some(1)) + .with_hardfork(Some(TempoHardfork::T8.into())) + .build(), + ] { + let decoded = decoder.decode_function(&trace).await; + assert_ne!(decoded.label.as_deref(), Some("CurrentCommittee")); + assert!(decoded.call_data.is_none()); + } + } + + #[tokio::test] + async fn test_current_committee_unauthorized_is_address_scoped() { + let output = ICurrentCommittee::Unauthorized {}.abi_encode(); + let trace = CallTrace { + address: CURRENT_COMMITTEE_ADDRESS, + output: output.clone().into(), + success: false, + status: Some(InstructionResult::Revert), + ..Default::default() + }; + let decoder = CallTraceDecoderBuilder::new() + .with_chain_id(Some(4217)) + .with_hardfork(Some(TempoHardfork::T8.into())) + .build(); + assert_eq!( + decoder.decode_function(&trace).await.return_data.as_deref(), + Some("Unauthorized()") + ); + + let unrelated = CallTrace { address: Address::ZERO, ..trace }; + let baseline = CallTraceDecoder::new().decode_function(&unrelated).await; + assert_eq!(decoder.decode_function(&unrelated).await.return_data, baseline.return_data); + } + + #[test] + fn test_precompile_labels_skip_tempo_precompiles_on_other_chains() { + let decoder = CallTraceDecoderBuilder::new() + .with_chain_id(Some(1)) + .with_hardfork(Some(TempoHardfork::T6.into())) + .build(); + + let labels = decoder.precompile_labels(); + assert!(!labels.contains_key(&TIP_FEE_MANAGER_ADDRESS)); + assert!(!labels.contains_key(&RECEIVE_POLICY_GUARD_ADDRESS)); + assert!(!labels.contains_key(&STORAGE_CREDITS_ADDRESS)); + } + + #[test] + fn test_tempo_hardfork_labels_do_not_clobber_user_labels() { + let reserve_label = "UserReserve".to_string(); + let guard_label = "UserGuard".to_string(); + let decoder = CallTraceDecoderBuilder::new() + .with_labels([ + (TIP20_CHANNEL_RESERVE_ADDRESS, reserve_label.clone()), + (RECEIVE_POLICY_GUARD_ADDRESS, guard_label.clone()), + ]) + .with_hardfork(Some(TempoHardfork::T6.into())) + .build(); + + assert_eq!(decoder.labels.get(&TIP20_CHANNEL_RESERVE_ADDRESS), Some(&reserve_label)); + assert_eq!(decoder.labels.get(&RECEIVE_POLICY_GUARD_ADDRESS), Some(&guard_label)); + } + + #[test] + fn test_tempo_hardfork_transitions_rebuild_address_metadata() { + let user_address = address!("0000000000000000000000000000000000001234"); + let user_label = "UserLabel".to_string(); + let mut decoder = CallTraceDecoderBuilder::new() + .with_execution_network(NetworkVariant::Tempo) + .with_labels([(user_address, user_label.clone())]) + .with_hardfork(Some(TempoHardfork::T4.into())) + .build(); + + let labels = decoder.precompile_labels(); + assert!(labels.contains_key(&TIP_FEE_MANAGER_ADDRESS)); + assert!(!labels.contains_key(&TIP20_CHANNEL_RESERVE_ADDRESS)); + assert!(!labels.contains_key(&RECEIVE_POLICY_GUARD_ADDRESS)); + + decoder.set_hardfork(Some(TempoHardfork::T6.into())); + let labels = decoder.precompile_labels(); + assert!(labels.contains_key(&TIP_FEE_MANAGER_ADDRESS)); + assert!(labels.contains_key(&TIP20_CHANNEL_RESERVE_ADDRESS)); + assert!(labels.contains_key(&RECEIVE_POLICY_GUARD_ADDRESS)); + assert_eq!(decoder.labels.get(&user_address), Some(&user_label)); + + decoder.set_hardfork(Some(TempoHardfork::T4.into())); + let labels = decoder.precompile_labels(); + assert!(labels.contains_key(&TIP_FEE_MANAGER_ADDRESS)); + assert!(!labels.contains_key(&TIP20_CHANNEL_RESERVE_ADDRESS)); + assert!(!labels.contains_key(&RECEIVE_POLICY_GUARD_ADDRESS)); + assert_eq!(decoder.labels.get(&user_address), Some(&user_label)); + + decoder.set_hardfork(None); + assert_eq!(decoder.labels.get(&user_address), Some(&user_label)); + } + + #[test] + fn test_tempo_hardfork_none_does_not_remove_user_reserve_label() { + use foundry_evm_core::tempo::TIP20_CHANNEL_RESERVE_ADDRESS; + + let reserve_label = "UserReserve".to_string(); + let decoder = CallTraceDecoderBuilder::new() + .with_labels([(TIP20_CHANNEL_RESERVE_ADDRESS, reserve_label.clone())]) + .with_hardfork(None) + .build(); + + assert_eq!(decoder.labels.get(&TIP20_CHANNEL_RESERVE_ADDRESS), Some(&reserve_label)); + } + + #[tokio::test] + async fn test_labels_validator_config_v2() { + let decoder = CallTraceDecoderBuilder::new() + .with_execution_network(NetworkVariant::Tempo) + .with_hardfork(Some(TempoHardfork::T8.into())) + .build(); + let trace = CallTrace { address: VALIDATOR_CONFIG_V2_ADDRESS, ..Default::default() }; + + assert_eq!( + decoder.decode_function(&trace).await.label.as_deref(), + Some("ValidatorConfigV2") + ); + assert_eq!( + decoder.precompile_labels().get(&VALIDATOR_CONFIG_V2_ADDRESS).map(String::as_str), + Some("ValidatorConfigV2") + ); + } + + #[tokio::test] + async fn test_decode_receive_policy_guard_at_t6() { + let function = Function::parse("claim(address,bytes)").unwrap(); + let data = function + .abi_encode_input(&[ + DynSolValue::Address(Address::repeat_byte(0x11)), + DynSolValue::Bytes(vec![0x12, 0x34]), + ]) + .unwrap(); + let trace = CallTrace { + address: RECEIVE_POLICY_GUARD_ADDRESS, + data: data.into(), + success: true, + ..Default::default() + }; + + let decoder = CallTraceDecoderBuilder::new() + .with_chain_id(Some(4217)) + .with_hardfork(Some(TempoHardfork::T6.into())) + .build(); + let decoded = decoder.decode_function(&trace).await; + + assert_eq!(decoded.label, Some("ReceivePolicyGuard".to_string())); + assert_eq!(decoded.call_data.unwrap().signature, "claim(address,bytes)"); + } + + #[tokio::test] + async fn test_t6_receive_policy_calls_decode() { + let decoder = CallTraceDecoderBuilder::new() + .with_chain_id(Some(4217)) + .with_hardfork(Some(TempoHardfork::T6.into())) + .build(); + + let set_policy = ITIP403Registry::setReceivePolicyCall { + senderPolicyId: 7, + tokenFilterId: 9, + recoveryAuthority: address!("0x0000000000000000000000000000000000000abc"), + }; + let decoded = decoder + .decode_function(&CallTrace { + address: TIP403_REGISTRY_ADDRESS, + data: set_policy.abi_encode().into(), + success: true, + ..Default::default() + }) + .await; + let call_data = decoded.call_data.expect("setReceivePolicy should decode"); + assert_eq!(decoded.label.as_deref(), Some("TIP403Registry")); + assert_eq!(call_data.signature, "setReceivePolicy(uint64,uint64,address)"); + assert_eq!(call_data.args[0], "7"); + assert_eq!(call_data.args[1], "9"); + + let validate = ITIP403Registry::validateReceivePolicyCall { + token: PATH_USD_ADDRESS, + sender: address!("0x0000000000000000000000000000000000000def"), + receiver: address!("0x0000000000000000000000000000000000000123"), + }; + let decoded = decoder + .decode_function(&CallTrace { + address: TIP403_REGISTRY_ADDRESS, + data: validate.abi_encode().into(), + success: true, + ..Default::default() + }) + .await; + let call_data = decoded.call_data.expect("validateReceivePolicy should decode"); + assert_eq!(call_data.signature, "validateReceivePolicy(address,address,address)"); + assert!(call_data.args[0].contains("PathUSD")); + } + + #[tokio::test] + async fn test_t6_admin_key_calls_decode() { + let decoder = CallTraceDecoderBuilder::new() + .with_chain_id(Some(4217)) + .with_hardfork(Some(TempoHardfork::T6.into())) + .build(); + let account = address!("0x0000000000000000000000000000000000000abc"); + let key = address!("0x0000000000000000000000000000000000000def"); + let signature = vec![0x04, 0xaa, 0xbb]; + + let cases = [ + ( + ACCOUNT_KEYCHAIN_ADDRESS, + IAccountKeychain::authorizeAdminKeyCall { + keyId: key, + signatureType: IAccountKeychain::SignatureType::Secp256k1, + witness: B256::repeat_byte(0x11), + } + .abi_encode() + .into(), + "authorizeAdminKey(address,uint8,bytes32)", + ), + ( + ACCOUNT_KEYCHAIN_ADDRESS, + IAccountKeychain::isAdminKeyCall { account, keyId: key }.abi_encode().into(), + "isAdminKey(address,address)", + ), + ( + SIGNATURE_VERIFIER_ADDRESS, + ISignatureVerifier::verifyKeychainCall { + account, + hash: B256::repeat_byte(0x22), + signature: signature.clone().into(), + } + .abi_encode() + .into(), + "verifyKeychain(address,bytes32,bytes)", + ), + ( + SIGNATURE_VERIFIER_ADDRESS, + ISignatureVerifier::verifyKeychainAdminCall { + account, + hash: B256::repeat_byte(0x33), + signature: signature.into(), + } + .abi_encode() + .into(), + "verifyKeychainAdmin(address,bytes32,bytes)", + ), + ]; + + for (address, data, signature) in cases { + let decoded = decoder + .decode_function(&CallTrace { address, data, success: true, ..Default::default() }) + .await; + assert_eq!( + decoded.call_data.expect("T6 keychain call should decode").signature, + signature + ); + } + } + + #[tokio::test] + async fn test_t6_receive_policy_and_admin_events_decode() { + let decoder = CallTraceDecoder::new(); + let account = address!("0x0000000000000000000000000000000000000abc"); + let key = address!("0x0000000000000000000000000000000000000def"); + + let events = [ + ( + ITIP403Registry::ReceivePolicyUpdated { + account, + senderPolicyId: 7, + tokenFilterId: 9, + recoveryAuthority: key, + } + .encode_log_data(), + "ReceivePolicyUpdated", + ), + ( + IAccountKeychain::AdminKeyAuthorized { account, publicKey: key }.encode_log_data(), + "AdminKeyAuthorized", + ), + ( + IReceivePolicyGuard::ReceiptClaimed { + token: PATH_USD_ADDRESS, + receiver: account, + blockedNonce: 11, + blockedAt: 12, + receiptVersion: 1, + originator: key, + recipient: account, + recoveryAuthority: key, + caller: key, + to: account, + amount: U256::from(123), + } + .encode_log_data(), + "ReceiptClaimed", + ), + ( + IReceivePolicyGuard::ReceiptBurned { + token: PATH_USD_ADDRESS, + receiver: account, + blockedNonce: 11, + blockedAt: 12, + receiptVersion: 1, + originator: key, + recipient: account, + recoveryAuthority: key, + caller: key, + amount: U256::from(123), + } + .encode_log_data(), + "ReceiptBurned", + ), + ]; + + for (log, expected_name) in events { + let decoded = decoder.decode_event(&log).await; + assert_eq!(decoded.name.as_deref(), Some(expected_name)); + assert!(decoded.params.expect("event params should decode").len() >= 2); + } + } + + #[tokio::test] + async fn test_t6_claim_receipt_bytes_decode_in_calls_and_transfer_blocked_event() { + let decoder = CallTraceDecoder::new(); + let recovery = address!("0x0000000000000000000000000000000000000abc"); + let originator = address!("0x0000000000000000000000000000000000000def"); + let recipient = address!("0x0000000000000000000000000000000000000123"); + let receipt = IReceivePolicyGuard::ClaimReceiptV1::new( + PATH_USD_ADDRESS, + recovery, + originator, + recipient, + 12, + 34, + ITIP403Registry::BlockedReason::RECEIVE_POLICY as u8, + IReceivePolicyGuard::InboundKind::TRANSFER, + B256::repeat_byte(0x44), + ) + .abi_encode(); + + let claim = + IReceivePolicyGuard::claimCall { to: recipient, receipt: receipt.clone().into() }; + let decoded = decoder + .decode_function(&CallTrace { + address: RECEIVE_POLICY_GUARD_ADDRESS, + data: claim.abi_encode().into(), + success: true, + ..Default::default() + }) + .await; + let call_data = decoded.call_data.expect("claim should decode"); + assert_eq!(call_data.signature, "claim(address,bytes)"); + assert!(call_data.args[1].contains("ClaimReceiptV1")); + assert!(call_data.args[1].contains("blockedNonce: 34")); + assert!(call_data.args[1].contains(&originator.to_string())); + + let decoded = decoder + .decode_function(&CallTrace { + address: Address::repeat_byte(0x77), + data: claim.abi_encode().into(), + success: true, + ..Default::default() + }) + .await; + let call_data = decoded.call_data.expect("matching claim selector should decode"); + assert_eq!(call_data.signature, "claim(address,bytes)"); + assert!(!call_data.args[1].contains("ClaimReceiptV1")); + assert!(call_data.args[1].starts_with("0x")); + + let blocked = IReceivePolicyGuard::TransferBlocked { + token: PATH_USD_ADDRESS, + receiver: recipient, + blockedNonce: 34, + amount: U256::from(123), + receiptVersion: 1, + receipt: receipt.into(), + }; + let blocked_log = blocked.encode_log_data(); + let decoded = + decoder.decode_event_with_address(RECEIVE_POLICY_GUARD_ADDRESS, &blocked_log).await; + assert_eq!(decoded.name.as_deref(), Some("TransferBlocked")); + let params = decoded.params.expect("TransferBlocked params should decode"); + let receipt = params.iter().find(|(name, _)| name == "receipt").unwrap(); + assert!(receipt.1.contains("ClaimReceiptV1")); + assert!(receipt.1.contains("blockedReason: 2")); + assert!(receipt.1.contains("kind: 0")); + + let decoded = + decoder.decode_event_with_address(Address::repeat_byte(0x77), &blocked_log).await; + assert_eq!(decoded.name.as_deref(), Some("TransferBlocked")); + let params = decoded.params.expect("TransferBlocked params should decode"); + let receipt = params.iter().find(|(name, _)| name == "receipt").unwrap(); + assert!(!receipt.1.contains("ClaimReceiptV1")); + assert!(receipt.1.starts_with("0x")); + } + + #[test] + fn test_identify_addresses_does_not_skip_future_tempo_precompiles() { + use foundry_evm_core::tempo::TIP20_CHANNEL_RESERVE_ADDRESS; + + let decoder = CallTraceDecoderBuilder::new() + .with_chain_id(Some(4217)) + .with_hardfork(Some(TempoHardfork::T4.into())) + .build(); + + let mut arena = CallTraceArena::default(); + let regular_addr = Address::repeat_byte(0x42); + arena.nodes_mut()[0].trace.address = regular_addr; + + arena.nodes_mut().push(CallTraceNode { + trace: CallTrace { + address: TIP20_CHANNEL_RESERVE_ADDRESS, + depth: 1, + maybe_precompile: None, + ..Default::default() + }, + idx: 1, + ..Default::default() + }); + + let mut identifier = RecordingIdentifier { queried: Vec::new() }; + decoder.identify_addresses(&arena, &mut identifier); + + assert_eq!(identifier.queried, vec![regular_addr, TIP20_CHANNEL_RESERVE_ADDRESS]); + } + + #[test] + fn test_identify_addresses_does_not_skip_tempo_precompiles_on_other_chains() { + use foundry_evm_core::tempo::TEMPO_PRECOMPILE_ADDRESSES; + + // Decoder with Ethereum mainnet chain ID (1). + let mut decoder = CallTraceDecoder::new().clone(); + decoder.chain_id = Some(1); + + let mut arena = CallTraceArena::default(); + let regular_addr = Address::repeat_byte(0x42); + arena.nodes_mut()[0].trace.address = regular_addr; + + let tempo_precompile = TEMPO_PRECOMPILE_ADDRESSES[0]; + arena.nodes_mut().push(CallTraceNode { + trace: CallTrace { + address: tempo_precompile, + depth: 1, + maybe_precompile: None, + ..Default::default() + }, + idx: 1, + ..Default::default() + }); + + let mut identifier = RecordingIdentifier { queried: Vec::new() }; + decoder.identify_addresses(&arena, &mut identifier); + + // On Ethereum, Tempo precompile addresses are regular contracts — should NOT be filtered. + assert_eq!(identifier.queried, vec![regular_addr, tempo_precompile]); + } + + #[test] + #[cfg(feature = "monad")] + fn test_identify_addresses_skips_monad_precompiles() { + let decoder = monad_decoder(MonadHardfork::MonadNine); + + let mut arena = CallTraceArena::default(); + let regular_addr = Address::repeat_byte(0x42); + arena.nodes_mut()[0].trace.address = regular_addr; + + arena.nodes_mut().push(CallTraceNode { + trace: CallTrace { + address: monad_revm::staking::STAKING_ADDRESS, + depth: 1, + maybe_precompile: None, + ..Default::default() + }, + idx: 1, + ..Default::default() + }); + arena.nodes_mut().push(CallTraceNode { + trace: CallTrace { + address: monad_revm::reserve_balance::abi::RESERVE_BALANCE_ADDRESS, + depth: 1, + maybe_precompile: None, + ..Default::default() + }, + idx: 2, + ..Default::default() + }); + + let mut identifier = RecordingIdentifier { queried: Vec::new() }; + decoder.identify_addresses(&arena, &mut identifier); + + assert_eq!(identifier.queried, vec![regular_addr]); + } + + #[test] + #[cfg(feature = "monad")] + fn test_identify_addresses_does_not_skip_monad_precompiles_on_other_chains() { + let decoder = CallTraceDecoderBuilder::new().with_chain_id(Some(1)).build(); + + let mut arena = CallTraceArena::default(); + let regular_addr = Address::repeat_byte(0x42); + arena.nodes_mut()[0].trace.address = regular_addr; + + arena.nodes_mut().push(CallTraceNode { + trace: CallTrace { + address: monad_revm::staking::STAKING_ADDRESS, + depth: 1, + maybe_precompile: None, + ..Default::default() + }, + idx: 1, + ..Default::default() + }); + arena.nodes_mut().push(CallTraceNode { + trace: CallTrace { + address: monad_revm::reserve_balance::abi::RESERVE_BALANCE_ADDRESS, + depth: 1, + maybe_precompile: None, + ..Default::default() + }, + idx: 2, + ..Default::default() + }); + + let mut identifier = RecordingIdentifier { queried: Vec::new() }; + decoder.identify_addresses(&arena, &mut identifier); + + assert_eq!( + identifier.queried, + vec![ + regular_addr, + monad_revm::staking::STAKING_ADDRESS, + monad_revm::reserve_balance::abi::RESERVE_BALANCE_ADDRESS, + ] + ); + } + + #[test] + #[cfg(feature = "monad")] + fn execution_network_overrides_nested_source_monad_precompile_detection() { + assert!(!precompiles::is_known_precompile( + monad_revm::reserve_balance::abi::RESERVE_BALANCE_ADDRESS, + Some(NetworkVariant::Ethereum.into()), + Some(143), + Some(MonadHardfork::MonadNine.into()), + )); + } } diff --git a/crates/evm/traces/src/decoder/monad.rs b/crates/evm/traces/src/decoder/monad.rs new file mode 100644 index 0000000000000..fa9403d51645d --- /dev/null +++ b/crates/evm/traces/src/decoder/monad.rs @@ -0,0 +1,22 @@ +use alloy_sol_types::sol; + +sol!( + #[sol(abi)] + "src/decoder/monad/IMonadStaking.sol" +); + +sol!( + #[sol(abi)] + "src/decoder/monad/IReserveBalance.sol" +); + +alloy_sol_types::sol! { + /// Monad staking syscalls are intentionally not part of the public monad-std interface, but + /// the trace decoder still knows them so syscall traces get named. + #[sol(abi)] + interface IMonadStakingSyscalls { + function syscallOnEpochChange(uint64 epoch) external; + function syscallReward(address blockAuthor) external; + function syscallSnapshot() external; + } +} diff --git a/crates/evm/traces/src/decoder/monad/IMonadStaking.sol b/crates/evm/traces/src/decoder/monad/IMonadStaking.sol new file mode 100644 index 0000000000000..e93807d0b90fb --- /dev/null +++ b/crates/evm/traces/src/decoder/monad/IMonadStaking.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.13 <0.9.0; + +// Mirrors category-labs/monad-std/src/interfaces/IMonadStaking.sol for trace decoding. +interface IMonadStaking { + event ValidatorRewarded(uint64 indexed validatorId, address indexed from, uint256 amount, uint64 epoch); + event ValidatorCreated(uint64 indexed validatorId, address indexed authAddress, uint256 commission); + event ValidatorStatusChanged(uint64 indexed validatorId, uint64 flags); + event Delegate(uint64 indexed validatorId, address indexed delegator, uint256 amount, uint64 activationEpoch); + event Undelegate( + uint64 indexed validatorId, address indexed delegator, uint8 withdrawId, uint256 amount, uint64 activationEpoch + ); + event Withdraw(uint64 indexed validatorId, address indexed delegator, uint8 withdrawId, uint256 amount, uint64 epoch); + event ClaimRewards(uint64 indexed validatorId, address indexed delegator, uint256 amount, uint64 epoch); + event CommissionChanged(uint64 indexed validatorId, uint256 oldCommission, uint256 newCommission); + event EpochChanged(uint64 oldEpoch, uint64 newEpoch); + + function addValidator(bytes calldata payload, bytes calldata signedSecpMessage, bytes calldata signedBlsMessage) + external + payable + returns (uint64 validatorId); + + function delegate(uint64 validatorId) external payable returns (bool success); + function undelegate(uint64 validatorId, uint256 amount, uint8 withdrawId) external returns (bool success); + function compound(uint64 validatorId) external returns (bool success); + function withdraw(uint64 validatorId, uint8 withdrawId) external returns (bool success); + function claimRewards(uint64 validatorId) external returns (bool success); + function changeCommission(uint64 validatorId, uint256 commission) external returns (bool success); + function externalReward(uint64 validatorId) external payable returns (bool success); + function getEpoch() external returns (uint64 epoch, bool inEpochDelayPeriod); + function getProposerValId() external returns (uint64 valId); + function getValidator(uint64 validatorId) + external + returns ( + address authAddress, + uint64 flags, + uint256 stake, + uint256 accRewardPerToken, + uint256 commission, + uint256 unclaimedRewards, + uint256 consensusStake, + uint256 consensusCommission, + uint256 snapshotStake, + uint256 snapshotCommission, + bytes memory secpPubkey, + bytes memory blsPubkey + ); + function getDelegator(uint64 validatorId, address delegator) + external + returns ( + uint256 stake, + uint256 accRewardPerToken, + uint256 unclaimedRewards, + uint256 deltaStake, + uint256 nextDeltaStake, + uint64 deltaEpoch, + uint64 nextDeltaEpoch + ); + function getWithdrawalRequest(uint64 validatorId, address delegator, uint8 withdrawId) + external + returns (uint256 withdrawalAmount, uint256 accRewardPerToken, uint64 withdrawEpoch); + function getConsensusValidatorSet(uint32 startIndex) + external + returns (bool isDone, uint32 nextIndex, uint64[] memory valIds); + function getSnapshotValidatorSet(uint32 startIndex) + external + returns (bool isDone, uint32 nextIndex, uint64[] memory valIds); + function getExecutionValidatorSet(uint32 startIndex) + external + returns (bool isDone, uint32 nextIndex, uint64[] memory valIds); + function getDelegations(address delegator, uint64 startValId) + external + returns (bool isDone, uint64 nextValId, uint64[] memory valIds); + function getDelegators(uint64 validatorId, address startDelegator) + external + returns (bool isDone, address nextDelegator, address[] memory delegators); +} diff --git a/crates/evm/traces/src/decoder/monad/IReserveBalance.sol b/crates/evm/traces/src/decoder/monad/IReserveBalance.sol new file mode 100644 index 0000000000000..9360805327c72 --- /dev/null +++ b/crates/evm/traces/src/decoder/monad/IReserveBalance.sol @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.13 <0.9.0; + +// Mirrors category-labs/monad-std/src/interfaces/IReserveBalance.sol for trace decoding. +interface IReserveBalance { + function dippedIntoReserve() external returns (bool dipped); +} diff --git a/crates/evm/traces/src/decoder/precompiles.rs b/crates/evm/traces/src/decoder/precompiles.rs index 5a8048c77240b..2f7310d7f4f17 100644 --- a/crates/evm/traces/src/decoder/precompiles.rs +++ b/crates/evm/traces/src/decoder/precompiles.rs @@ -1,10 +1,17 @@ use crate::{CallTrace, DecodedCallData}; use alloy_primitives::{Address, B256, U256, hex}; use alloy_sol_types::{SolCall, abi, sol}; -use foundry_evm_core::precompiles::{ - BLAKE_2F, EC_ADD, EC_MUL, EC_PAIRING, EC_RECOVER, IDENTITY, MOD_EXP, POINT_EVALUATION, - RIPEMD_160, SHA_256, +use foundry_config::{Chain, NamedChain}; +use foundry_evm_core::{ + precompiles::{ + BLAKE_2F, BLS12_G1ADD, BLS12_G1MSM, BLS12_G2ADD, BLS12_G2MSM, BLS12_MAP_FP_TO_G1, + BLS12_MAP_FP2_TO_G2, BLS12_PAIRING_CHECK, CELO_TRANSFER, EC_ADD, EC_MUL, EC_PAIRING, + EC_RECOVER, IDENTITY, MOD_EXP, P256_VERIFY, POINT_EVALUATION, RIPEMD_160, SHA_256, + }, + tempo::{TEMPO_PRECOMPILE_ADDRESSES, TEMPO_TIP20_TOKENS, active_tempo_precompile_addresses}, }; +use foundry_evm_hardforks::{ExecutionSpec, FoundryHardfork, TempoHardfork}; +use foundry_evm_networks::NetworkConfigs; use itertools::Itertools; use revm_inspectors::tracing::types::DecodedCallTrace; @@ -33,115 +40,243 @@ interface Precompiles { /* 0x08 */ function ecpairing(EcPairingInput[] input) returns (bool success); /* 0x09 */ function blake2f(uint32 rounds, uint64[8] h, uint64[16] m, uint64[2] t, bool f) returns (uint64[8] h); /* 0x0a */ function pointEvaluation(bytes32 versionedHash, bytes32 z, bytes32 y, bytes1[48] commitment, bytes1[48] proof) returns (bytes value); + + // Prague BLS12-381 precompiles (EIP-2537) + /* 0x0b */ function bls12G1Add(bytes p1, bytes p2) returns (bytes result); + /* 0x0c */ function bls12G1Msm(bytes[] scalarsAndPoints) returns (bytes result); + /* 0x0d */ function bls12G2Add(bytes p1, bytes p2) returns (bytes result); + /* 0x0e */ function bls12G2Msm(bytes[] scalarsAndPoints) returns (bytes result); + /* 0x0f */ function bls12PairingCheck(bytes[] pairs) returns (bool success); + /* 0x10 */ function bls12MapFpToG1(bytes fp) returns (bytes result); + /* 0x11 */ function bls12MapFp2ToG2(bytes fp2) returns (bytes result); + + // Osaka precompiles (EIP-7212) + /* 0x100 */ function p256Verify(bytes32 hash, uint256 r, uint256 s, uint256 qx, uint256 qy) returns (bool success); } } use Precompiles::*; -macro_rules! tri { - ($e:expr) => { - match $e { - Ok(x) => x, - Err(_) => return None, - } - }; +pub(super) trait Precompile { + fn address(&self) -> Address; + fn signature(&self, data: &[u8]) -> &'static str; + + fn decode_call(&self, data: &[u8]) -> alloy_sol_types::Result> { + Ok(vec![hex::encode_prefixed(data)]) + } + + fn decode_return(&self, data: &[u8]) -> alloy_sol_types::Result> { + Ok(vec![hex::encode_prefixed(data)]) + } } -pub(super) fn is_known_precompile(address: Address, _chain_id: u64) -> bool { - address[..19].iter().all(|&x| x == 0) - && matches!( - address, - EC_RECOVER - | SHA_256 - | RIPEMD_160 - | IDENTITY - | MOD_EXP - | EC_ADD - | EC_MUL - | EC_PAIRING - | BLAKE_2F - | POINT_EVALUATION - ) +// Note: we use the ABI decoder, but this is not necessarily ABI-encoded data. It's just a +// convenient way to decode the data. + +const PRECOMPILES: &[&dyn Precompile] = &[ + &Ecrecover, + &Sha256, + &Ripemd160, + &Identity, + &ModExp, + &EcAdd, + &Ecmul, + &Ecpairing, + &Blake2f, + &PointEvaluation, + &Bls12G1Add, + &Bls12G1Msm, + &Bls12G2Add, + &Bls12G2Msm, + &Bls12PairingCheck, + &Bls12MapFpToG1, + &Bls12MapFp2ToG2, + &P256Verify, +]; + +struct Ecrecover; +impl Precompile for Ecrecover { + fn address(&self) -> Address { + EC_RECOVER + } + + fn signature(&self, _: &[u8]) -> &'static str { + ecrecoverCall::SIGNATURE + } + + fn decode_call(&self, data: &[u8]) -> alloy_sol_types::Result> { + let ecrecoverCall { hash, v, r, s } = ecrecoverCall::abi_decode_raw(data)?; + Ok(vec![hash.to_string(), v.to_string(), r.to_string(), s.to_string()]) + } + + fn decode_return(&self, data: &[u8]) -> alloy_sol_types::Result> { + let ret = ecrecoverCall::abi_decode_returns(data)?; + Ok(vec![ret.to_string()]) + } } -/// Tries to decode a precompile call. Returns `Some` if successful. -pub(super) fn decode(trace: &CallTrace, _chain_id: u64) -> Option { - if !is_known_precompile(trace.address, _chain_id) { - return None; +struct Sha256; +impl Precompile for Sha256 { + fn address(&self) -> Address { + SHA_256 } - let data = &trace.data; + fn signature(&self, _: &[u8]) -> &'static str { + sha256Call::SIGNATURE + } - let (signature, args) = match trace.address { - EC_RECOVER => { - let (sig, ecrecoverCall { hash, v, r, s }) = tri!(abi_decode_call(data)); - (sig, vec![hash.to_string(), v.to_string(), r.to_string(), s.to_string()]) - } - SHA_256 => (sha256Call::SIGNATURE, vec![data.to_string()]), - RIPEMD_160 => (ripemdCall::SIGNATURE, vec![data.to_string()]), - IDENTITY => (identityCall::SIGNATURE, vec![data.to_string()]), - MOD_EXP => (modexpCall::SIGNATURE, tri!(decode_modexp(data))), - EC_ADD => { - let (sig, ecaddCall { x1, y1, x2, y2 }) = tri!(abi_decode_call(data)); - (sig, vec![x1.to_string(), y1.to_string(), x2.to_string(), y2.to_string()]) - } - EC_MUL => { - let (sig, ecmulCall { x1, y1, s }) = tri!(abi_decode_call(data)); - (sig, vec![x1.to_string(), y1.to_string(), s.to_string()]) - } - EC_PAIRING => (ecpairingCall::SIGNATURE, tri!(decode_ecpairing(data))), - BLAKE_2F => (blake2fCall::SIGNATURE, tri!(decode_blake2f(data))), - POINT_EVALUATION => (pointEvaluationCall::SIGNATURE, tri!(decode_kzg(data))), - _ => return None, - }; + fn decode_return(&self, data: &[u8]) -> alloy_sol_types::Result> { + let ret = sha256Call::abi_decode_returns(data)?; + Ok(vec![ret.to_string()]) + } +} + +struct Ripemd160; +impl Precompile for Ripemd160 { + fn address(&self) -> Address { + RIPEMD_160 + } - Some(DecodedCallTrace { - label: Some("PRECOMPILES".to_string()), - call_data: Some(DecodedCallData { signature: signature.to_string(), args }), - // TODO: Decode return data too. - return_data: None, - }) + fn signature(&self, _: &[u8]) -> &'static str { + ripemdCall::SIGNATURE + } + + fn decode_return(&self, data: &[u8]) -> alloy_sol_types::Result> { + let ret = ripemdCall::abi_decode_returns(data)?; + Ok(vec![ret.to_string()]) + } } -// Note: we use the ABI decoder, but this is not necessarily ABI-encoded data. It's just a -// convenient way to decode the data. +struct Identity; +impl Precompile for Identity { + fn address(&self) -> Address { + IDENTITY + } -fn decode_modexp(data: &[u8]) -> alloy_sol_types::Result> { - let mut decoder = abi::Decoder::new(data); - let b_size = decoder.take_offset()?; - let e_size = decoder.take_offset()?; - let m_size = decoder.take_offset()?; - let b = decoder.take_slice(b_size)?; - let e = decoder.take_slice(e_size)?; - let m = decoder.take_slice(m_size)?; - Ok(vec![ - b_size.to_string(), - e_size.to_string(), - m_size.to_string(), - hex::encode_prefixed(b), - hex::encode_prefixed(e), - hex::encode_prefixed(m), - ]) + fn signature(&self, _: &[u8]) -> &'static str { + identityCall::SIGNATURE + } } -fn decode_ecpairing(data: &[u8]) -> alloy_sol_types::Result> { - let mut decoder = abi::Decoder::new(data); - let mut values = Vec::new(); - // input must be either empty or a multiple of 6 32-byte values - let mut tmp = <[&B256; 6]>::default(); - while !decoder.is_empty() { - for tmp in &mut tmp { - *tmp = decoder.take_word()?; +struct ModExp; +impl Precompile for ModExp { + fn address(&self) -> Address { + MOD_EXP + } + + fn signature(&self, _: &[u8]) -> &'static str { + modexpCall::SIGNATURE + } + + fn decode_call(&self, data: &[u8]) -> alloy_sol_types::Result> { + let mut decoder = abi::Decoder::new(data); + let b_size = decoder.take_offset()?; + let e_size = decoder.take_offset()?; + let m_size = decoder.take_offset()?; + let b = decoder.take_slice(b_size)?; + let e = decoder.take_slice(e_size)?; + let m = decoder.take_slice(m_size)?; + Ok(vec![ + b_size.to_string(), + e_size.to_string(), + m_size.to_string(), + hex::encode_prefixed(b), + hex::encode_prefixed(e), + hex::encode_prefixed(m), + ]) + } +} + +struct EcAdd; +impl Precompile for EcAdd { + fn address(&self) -> Address { + EC_ADD + } + + fn signature(&self, _: &[u8]) -> &'static str { + ecaddCall::SIGNATURE + } + + fn decode_call(&self, data: &[u8]) -> alloy_sol_types::Result> { + let ecaddCall { x1, y1, x2, y2 } = ecaddCall::abi_decode_raw(data)?; + Ok(vec![x1.to_string(), y1.to_string(), x2.to_string(), y2.to_string()]) + } + + fn decode_return(&self, data: &[u8]) -> alloy_sol_types::Result> { + let ecaddReturn { x, y } = ecaddCall::abi_decode_returns(data)?; + Ok(vec![x.to_string(), y.to_string()]) + } +} + +struct Ecmul; +impl Precompile for Ecmul { + fn address(&self) -> Address { + EC_MUL + } + + fn signature(&self, _: &[u8]) -> &'static str { + ecmulCall::SIGNATURE + } + + fn decode_call(&self, data: &[u8]) -> alloy_sol_types::Result> { + let ecmulCall { x1, y1, s } = ecmulCall::abi_decode_raw(data)?; + Ok(vec![x1.to_string(), y1.to_string(), s.to_string()]) + } + + fn decode_return(&self, data: &[u8]) -> alloy_sol_types::Result> { + let ecmulReturn { x, y } = ecmulCall::abi_decode_returns(data)?; + Ok(vec![x.to_string(), y.to_string()]) + } +} + +struct Ecpairing; +impl Precompile for Ecpairing { + fn address(&self) -> Address { + EC_PAIRING + } + + fn signature(&self, _: &[u8]) -> &'static str { + ecpairingCall::SIGNATURE + } + + fn decode_call(&self, data: &[u8]) -> alloy_sol_types::Result> { + let mut decoder = abi::Decoder::new(data); + let mut values = Vec::new(); + // input must be either empty or a multiple of 6 32-byte values + let mut tmp = <[&B256; 6]>::default(); + while !decoder.is_empty() { + for tmp in &mut tmp { + *tmp = decoder.take_word()?; + } + values.push(iter_to_string(tmp.iter().map(|x| Into::::into(**x)))); } - values.push(iter_to_string(tmp.iter().map(|x| U256::from_be_bytes(x.0)))); + Ok(values) + } + + fn decode_return(&self, data: &[u8]) -> alloy_sol_types::Result> { + let ret = ecpairingCall::abi_decode_returns(data)?; + Ok(vec![ret.to_string()]) + } +} + +struct Blake2f; +impl Precompile for Blake2f { + fn address(&self) -> Address { + BLAKE_2F + } + + fn signature(&self, _: &[u8]) -> &'static str { + blake2fCall::SIGNATURE + } + + fn decode_call(&self, data: &[u8]) -> alloy_sol_types::Result> { + decode_blake2f(data) } - Ok(values) } fn decode_blake2f<'a>(data: &'a [u8]) -> alloy_sol_types::Result> { let mut decoder = abi::Decoder::new(data); let rounds = u32::from_be_bytes(decoder.take_slice(4)?.try_into().unwrap()); - let u64_le_list = - |x: &'a [u8]| x.chunks_exact(8).map(|x| u64::from_le_bytes(x.try_into().unwrap())); + let u64_le_list = |x: &'a [u8]| x.as_chunks::<8>().0.iter().map(|x| u64::from_le_bytes(*x)); let h = u64_le_list(decoder.take_slice(64)?); let m = u64_le_list(decoder.take_slice(128)?); let t = u64_le_list(decoder.take_slice(16)?); @@ -155,35 +290,413 @@ fn decode_blake2f<'a>(data: &'a [u8]) -> alloy_sol_types::Result> { ]) } -fn decode_kzg(data: &[u8]) -> alloy_sol_types::Result> { - let mut decoder = abi::Decoder::new(data); - let versioned_hash = decoder.take_word()?; - let z = decoder.take_word()?; - let y = decoder.take_word()?; - let commitment = decoder.take_slice(48)?; - let proof = decoder.take_slice(48)?; - Ok(vec![ - versioned_hash.to_string(), - z.to_string(), - y.to_string(), - hex::encode_prefixed(commitment), - hex::encode_prefixed(proof), - ]) -} +struct PointEvaluation; +impl Precompile for PointEvaluation { + fn address(&self) -> Address { + POINT_EVALUATION + } -fn abi_decode_call(data: &[u8]) -> alloy_sol_types::Result<(&'static str, T)> { - // raw because there are no selectors here - Ok((T::SIGNATURE, T::abi_decode_raw(data)?)) + fn signature(&self, _: &[u8]) -> &'static str { + pointEvaluationCall::SIGNATURE + } + + fn decode_call(&self, data: &[u8]) -> alloy_sol_types::Result> { + let mut decoder = abi::Decoder::new(data); + let versioned_hash = decoder.take_word()?; + let z = decoder.take_word()?; + let y = decoder.take_word()?; + let commitment = decoder.take_slice(48)?; + let proof = decoder.take_slice(48)?; + Ok(vec![ + versioned_hash.to_string(), + z.to_string(), + y.to_string(), + hex::encode_prefixed(commitment), + hex::encode_prefixed(proof), + ]) + } } fn iter_to_string, T: std::fmt::Display>(iter: I) -> String { format!("[{}]", iter.format(", ")) } +const G1_POINT_SIZE: usize = 128; +const G2_POINT_SIZE: usize = 256; +const SCALAR_SIZE: usize = 32; +const FP_SIZE: usize = 64; + +struct Bls12G1Add; +impl Precompile for Bls12G1Add { + fn address(&self) -> Address { + BLS12_G1ADD + } + + fn signature(&self, _: &[u8]) -> &'static str { + bls12G1AddCall::SIGNATURE + } + + fn decode_call(&self, data: &[u8]) -> alloy_sol_types::Result> { + let (p1, rest) = take_at_most(data, G1_POINT_SIZE); + let (p2, _) = take_at_most(rest, G1_POINT_SIZE); + Ok(vec![hex::encode_prefixed(p1), hex::encode_prefixed(p2)]) + } +} + +struct Bls12G1Msm; +impl Precompile for Bls12G1Msm { + fn address(&self) -> Address { + BLS12_G1MSM + } + + fn signature(&self, _: &[u8]) -> &'static str { + bls12G1MsmCall::SIGNATURE + } + + fn decode_call(&self, data: &[u8]) -> alloy_sol_types::Result> { + let pair_size = G1_POINT_SIZE + SCALAR_SIZE; + Ok(data.chunks(pair_size).map(hex::encode_prefixed).collect()) + } +} + +struct Bls12G2Add; +impl Precompile for Bls12G2Add { + fn address(&self) -> Address { + BLS12_G2ADD + } + + fn signature(&self, _: &[u8]) -> &'static str { + bls12G2AddCall::SIGNATURE + } + + fn decode_call(&self, data: &[u8]) -> alloy_sol_types::Result> { + let (p1, rest) = take_at_most(data, G2_POINT_SIZE); + let (p2, _) = take_at_most(rest, G2_POINT_SIZE); + Ok(vec![hex::encode_prefixed(p1), hex::encode_prefixed(p2)]) + } +} + +struct Bls12G2Msm; +impl Precompile for Bls12G2Msm { + fn address(&self) -> Address { + BLS12_G2MSM + } + + fn signature(&self, _: &[u8]) -> &'static str { + bls12G2MsmCall::SIGNATURE + } + + fn decode_call(&self, data: &[u8]) -> alloy_sol_types::Result> { + let pair_size = G2_POINT_SIZE + SCALAR_SIZE; + Ok(data.chunks(pair_size).map(hex::encode_prefixed).collect()) + } +} + +struct Bls12PairingCheck; +impl Precompile for Bls12PairingCheck { + fn address(&self) -> Address { + BLS12_PAIRING_CHECK + } + + fn signature(&self, _: &[u8]) -> &'static str { + bls12PairingCheckCall::SIGNATURE + } + + fn decode_call(&self, data: &[u8]) -> alloy_sol_types::Result> { + let pair_size = G1_POINT_SIZE + G2_POINT_SIZE; + Ok(data.chunks(pair_size).map(hex::encode_prefixed).collect()) + } + + fn decode_return(&self, data: &[u8]) -> alloy_sol_types::Result> { + let ret = bls12PairingCheckCall::abi_decode_returns(data)?; + Ok(vec![ret.to_string()]) + } +} + +struct Bls12MapFpToG1; +impl Precompile for Bls12MapFpToG1 { + fn address(&self) -> Address { + BLS12_MAP_FP_TO_G1 + } + + fn signature(&self, _: &[u8]) -> &'static str { + bls12MapFpToG1Call::SIGNATURE + } + + fn decode_call(&self, data: &[u8]) -> alloy_sol_types::Result> { + let (fp, _) = take_at_most(data, FP_SIZE); + Ok(vec![hex::encode_prefixed(fp)]) + } +} + +struct Bls12MapFp2ToG2; +impl Precompile for Bls12MapFp2ToG2 { + fn address(&self) -> Address { + BLS12_MAP_FP2_TO_G2 + } + + fn signature(&self, _: &[u8]) -> &'static str { + bls12MapFp2ToG2Call::SIGNATURE + } + + fn decode_call(&self, data: &[u8]) -> alloy_sol_types::Result> { + let (fp2, _) = take_at_most(data, G1_POINT_SIZE); + Ok(vec![hex::encode_prefixed(fp2)]) + } +} + +struct P256Verify; +impl Precompile for P256Verify { + fn address(&self) -> Address { + P256_VERIFY + } + + fn signature(&self, _: &[u8]) -> &'static str { + p256VerifyCall::SIGNATURE + } + + fn decode_call(&self, data: &[u8]) -> alloy_sol_types::Result> { + let p256VerifyCall { hash, r, s, qx, qy } = p256VerifyCall::abi_decode_raw(data)?; + Ok(vec![hash.to_string(), r.to_string(), s.to_string(), qx.to_string(), qy.to_string()]) + } + + fn decode_return(&self, data: &[u8]) -> alloy_sol_types::Result> { + let ret = p256VerifyCall::abi_decode_returns(data)?; + Ok(vec![ret.to_string()]) + } +} + +fn take_at_most(data: &[u8], n: usize) -> (&[u8], &[u8]) { + let n = n.min(data.len()); + data.split_at(n) +} + +pub(crate) fn is_known_precompile( + address: Address, + networks: Option, + chain_id: Option, + hardfork: Option, +) -> bool { + let tempo_hardfork = hardfork.and_then(TempoHardfork::from_foundry_hardfork); + + // Standard EVM precompiles (all chains). + // An 18-byte zero prefix, as `P256_VERIFY` (0x..0100) occupies the two lowest bytes. + let is_standard = address[..18].iter().all(|&x| x == 0) + && matches!( + address, + EC_RECOVER + | SHA_256 + | RIPEMD_160 + | IDENTITY + | MOD_EXP + | EC_ADD + | EC_MUL + | EC_PAIRING + | BLAKE_2F + | POINT_EVALUATION + | BLS12_G1ADD + | BLS12_G1MSM + | BLS12_G2ADD + | BLS12_G2MSM + | BLS12_PAIRING_CHECK + | BLS12_MAP_FP_TO_G1 + | BLS12_MAP_FP2_TO_G2 + | P256_VERIFY + ); + if is_standard { + return true; + } + // Tempo precompiles and TIP20 fee tokens (only on Tempo chains). + let is_tempo_precompile = match tempo_hardfork { + Some(hardfork) => active_tempo_precompile_addresses(hardfork).any(|addr| addr == address), + None => TEMPO_PRECOMPILE_ADDRESSES.contains(&address), + }; + let is_tempo_context = networks.map_or_else( + || { + chain_id + .map(|id| Chain::from_id(id).is_tempo()) + .unwrap_or_else(|| tempo_hardfork.is_some()) + }, + |networks| networks.is_tempo(), + ); + if is_tempo_context && (is_tempo_precompile || TEMPO_TIP20_TOKENS.contains(&address)) { + return true; + } + // Monad precompiles (only on a Monad chain or in an explicitly configured Monad context). + #[cfg(feature = "monad")] + { + let monad_hardfork = + hardfork.and_then(foundry_evm_hardforks::MonadHardfork::from_foundry_hardfork); + let is_monad_context = networks.map_or_else( + || { + chain_id.is_some_and(|id| { + matches!( + Chain::from_id(id).named(), + Some(NamedChain::Monad | NamedChain::MonadTestnet) + ) + }) || monad_hardfork.is_some() + }, + |networks| networks.is_monad(), + ); + if is_monad_context { + if address == monad_revm::staking::STAKING_ADDRESS { + return true; + } + if address == monad_revm::reserve_balance::abi::RESERVE_BALANCE_ADDRESS + && monad_hardfork.is_none_or(|hardfork| { + foundry_evm_networks::is_monad_precompile_active_at(address, hardfork) + }) + { + return true; + } + } + } + // Base precompiles (only on a Base chain or in an explicitly configured Base context). + #[cfg(feature = "base")] + { + let base_upgrade = hardfork + .and_then(foundry_evm_hardforks::BaseSpecId::from_foundry_hardfork) + .map(|spec| spec.upgrade()); + let is_base_context = networks.map_or_else( + || { + chain_id.is_some_and(|id| { + matches!( + Chain::from_id(id).named(), + Some(NamedChain::Base | NamedChain::BaseSepolia) + ) + }) || base_upgrade.is_some() + }, + |networks| networks.is_base(), + ); + if is_base_context + && base_upgrade.is_some_and(|upgrade| { + foundry_evm_networks::is_base_precompile_active_at(address, upgrade) + }) + { + return true; + } + } + // Celo transfer precompile (only on Celo chains). + let is_celo_context = networks.map_or_else( + || { + chain_id.is_some_and(|id| { + matches!( + Chain::from_id(id).named(), + Some(NamedChain::Celo | NamedChain::CeloSepolia) + ) + }) + }, + |networks| networks.is_celo(), + ); + is_celo_context && address == CELO_TRANSFER +} + +pub(crate) fn is_known_precompile_call( + trace: &CallTrace, + networks: Option, + chain_id: Option, + hardfork: Option, +) -> bool { + // Unlike the long-established low addresses, P256 is hardfork-dependent. Traces without an + // execution classification, such as RPC callTracer frames, cannot safely infer it by address. + if trace.address == P256_VERIFY && trace.maybe_precompile != Some(true) { + return false; + } + is_known_precompile(trace.address, networks, chain_id, hardfork) +} + +/// Tries to decode a precompile call. Returns `Some` if successful. +pub(super) fn decode( + trace: &CallTrace, + networks: Option, + chain_id: Option, + hardfork: Option, +) -> Option { + if !is_known_precompile_call(trace, networks, chain_id, hardfork) { + return None; + } + + for &precompile in PRECOMPILES { + if trace.address == precompile.address() { + let signature = precompile.signature(&trace.data); + + let args = precompile + .decode_call(&trace.data) + .unwrap_or_else(|_| vec![trace.data.to_string()]); + + let return_data = precompile + .decode_return(&trace.output) + .unwrap_or_else(|_| vec![trace.output.to_string()]); + let return_data = if return_data.len() == 1 { + return_data.into_iter().next().unwrap() + } else { + format!("({})", return_data.join(", ")) + }; + + return Some(DecodedCallTrace { + label: Some("PRECOMPILES".to_string()), + call_data: Some(DecodedCallData { signature: signature.to_string(), args }), + return_data: Some(return_data), + }); + } + } + + None +} + #[cfg(test)] mod tests { use super::*; - use alloy_primitives::hex; + use alloy_primitives::{address, hex}; + + #[cfg(feature = "base")] + use base_common_precompiles::ActivationRegistryStorage; + #[cfg(feature = "base")] + use foundry_evm_hardforks::BaseUpgrade; + + #[test] + fn known_precompile_boundaries() { + assert!(is_known_precompile(P256_VERIFY, None, None, None)); + assert!(!is_known_precompile( + address!("0x0000000000000000000000000000000000000101"), + None, + None, + None + )); + assert!(!is_known_precompile(Address::with_last_byte(0x12), None, None, None)); + } + + #[cfg(feature = "base")] + #[test] + fn base_precompiles_require_a_known_upgrade() { + let address = ActivationRegistryStorage::ADDRESS; + assert!(!is_known_precompile(address, Some(NetworkConfigs::with_base()), Some(8453), None)); + assert!(is_known_precompile( + address, + Some(NetworkConfigs::with_base()), + Some(8453), + Some(FoundryHardfork::Base(BaseUpgrade::Beryl)) + )); + } + + #[test] + fn decodes_only_confirmed_p256_precompile_calls() { + for maybe_precompile in [None, Some(false)] { + let trace = CallTrace { address: P256_VERIFY, maybe_precompile, ..Default::default() }; + assert!(decode(&trace, None, None, None).is_none()); + } + + let trace = + CallTrace { address: P256_VERIFY, maybe_precompile: Some(true), ..Default::default() }; + assert!(decode(&trace, None, None, None).is_some()); + } + + #[test] + fn decodes_established_precompile_despite_negative_execution_classification() { + let trace = + CallTrace { address: SHA_256, maybe_precompile: Some(false), ..Default::default() }; + assert!(decode(&trace, None, None, None).is_some()); + } #[test] fn ecpairing() { @@ -216,7 +729,7 @@ mod tests { 30364cd4f8a293b1a04f0153548d3e01baad091c69097ca4e9f26be63e4095b5 " ); - let decoded = decode_ecpairing(&data).unwrap(); + let decoded = Ecpairing.decode_call(&data).unwrap(); // 4 arrays of 6 32-byte values assert_eq!(decoded.len(), 4); } diff --git a/crates/evm/traces/src/erc8021.rs b/crates/evm/traces/src/erc8021.rs new file mode 100644 index 0000000000000..07a7f0f748211 --- /dev/null +++ b/crates/evm/traces/src/erc8021.rs @@ -0,0 +1,147 @@ +//! [ERC-8021](https://github.com/ethereum/ERCs/pull/1209) transaction attribution suffixes. +//! +//! The suffix is `schemaData || schemaId (1 byte) || marker (16 bytes)` appended to calldata, and +//! the length of `schemaData` is read backwards according to `schemaId`. + +use alloy_primitives::hex; +use itertools::Itertools; +use serde::Deserialize; +use std::fmt; + +/// Marker that ends an ERC-8021 attribution suffix. +const MARKER: [u8; 16] = hex!("80218021802180218021802180218021"); + +/// Codes attributed by an ERC-8021 suffix. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct Attribution { + /// The attributed codes, with the role of the entity for schema 2. + codes: Vec<(String, Option<&'static str>)>, +} + +impl Attribution { + /// Decodes the ERC-8021 attribution suffix at the end of the given calldata. + /// + /// Returns `None` if there is no suffix, its schema is unknown, or it attributes no codes. + /// Custom registries and metadata are not decoded. + pub fn decode(data: &[u8]) -> Option { + let (_, schema_id, schema_data) = split_suffix(data)?; + let codes = match schema_id { + // `... || codes || codesLength (1)` + 0 | 1 => { + let (&codes_len, rest) = schema_data.split_last()?; + let codes = std::str::from_utf8(&rest[rest.len() - codes_len as usize..]).ok()?; + codes + .split(',') + .filter(|code| !code.is_empty()) + .map(|code| (code.into(), None)) + .collect::>() + } + // `cborData || cborLength (2)` + 2 => { + let cbor = &schema_data[..schema_data.len() - 2]; + let CborAttribution { app, wallet, services } = ciborium::from_reader(cbor).ok()?; + app.map(|code| (code, Some("app"))) + .into_iter() + .chain(wallet.map(|code| (code, Some("wallet")))) + .chain(services.into_iter().map(|code| (code, Some("service")))) + .collect::>() + } + _ => return None, + }; + (!codes.is_empty()).then_some(Self { codes }) + } +} + +impl fmt::Display for Attribution { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // Codes come from calldata, so escape control characters before printing them. + let codes = self.codes.iter().format_with(", ", |(code, role), f| match role { + Some(role) => f(&format_args!("{} ({role})", code.escape_debug())), + None => f(&code.escape_debug()), + }); + write!(f, "{codes}") + } +} + +/// Entity codes of a schema 2 attribution. +#[derive(Deserialize)] +struct CborAttribution { + #[serde(rename = "a")] + app: Option, + #[serde(rename = "w")] + wallet: Option, + #[serde(rename = "s", default)] + services: Vec, +} + +/// Returns the given calldata without a trailing ERC-8021 attribution suffix. +pub fn strip_suffix(data: &[u8]) -> &[u8] { + split_suffix(data).map_or(data, |(data, ..)| data) +} + +/// Splits a trailing ERC-8021 attribution suffix off the given calldata, returning the calldata +/// before it, the schema id and the schema data. +fn split_suffix(data: &[u8]) -> Option<(&[u8], u8, &[u8])> { + let rest = data.strip_suffix(&MARKER)?; + let (&schema_id, rest) = rest.split_last()?; + let schema_data_len = match schema_id { + // `codes || codesLength (1)` + 0 => 1 + *rest.last()? as usize, + // `codeRegistryAddress (20) || chainId || chainIdLength (1) || codes || codesLength (1)` + 1 => { + let codes_len = *rest.last()? as usize; + let chain_id_len = *rest.get(rest.len().checked_sub(2 + codes_len)?)? as usize; + 22 + codes_len + chain_id_len + } + // `schemaData || schemaDataLength (2)` + _ => 2 + u16::from_be_bytes(*rest.last_chunk()?) as usize, + }; + let (data, schema_data) = rest.split_at(rest.len().checked_sub(schema_data_len)?); + Some((data, schema_id, schema_data)) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Test vectors from ERC-8021. + const SCHEMA_0: &[u8] = &hex!("dddddddd62617365617070070080218021802180218021802180218021"); + const SCHEMA_1: &[u8] = &hex!( + "ddddddddcccccccccccccccccccccccccccccccccccccccc210502626173656170702C6D6F7270686F0E0180218021802180218021802180218021" + ); + const SCHEMA_2: &[u8] = + &hex!("dddddddda161616762617365617070000b0280218021802180218021802180218021"); + const SCHEMA_2_METADATA: &[u8] = &hex!( + "dddddddda46161676261736561707061776570726976796172a16161a26163663078323130356161782a307842636632423935393845633037383165453439323330436141434630464233433838314235313935616da26c75746d5f63616d706169676e6c77696e7465722d70726f6d6f66736f7572636566776562617070007b0280218021802180218021802180218021" + ); + const SCHEMA_2_SERVICES: &[u8] = &hex!( + "dddddddda361616762617365617070617765707269767961738269666c617368626f747365746974616e00260280218021802180218021802180218021" + ); + const UNKNOWN_SCHEMA: &[u8] = &hex!("ddddddddff80218021802180218021802180218021"); + + #[test] + fn test_strip_suffix() { + for data in [SCHEMA_0, SCHEMA_1, SCHEMA_2, SCHEMA_2_METADATA, SCHEMA_2_SERVICES] { + assert_eq!(strip_suffix(data), hex!("dddddddd")); + } + assert_eq!(strip_suffix(UNKNOWN_SCHEMA), UNKNOWN_SCHEMA); + } + + #[test] + fn test_decode_attribution() { + let decode = + |data: &[u8]| Attribution::decode(data).map(|attribution| attribution.to_string()); + assert_eq!(decode(SCHEMA_0).as_deref(), Some("baseapp")); + assert_eq!(decode(SCHEMA_1).as_deref(), Some("baseapp, morpho")); + assert_eq!(decode(SCHEMA_2).as_deref(), Some("baseapp (app)")); + assert_eq!(decode(SCHEMA_2_METADATA).as_deref(), Some("baseapp (app), privy (wallet)")); + assert_eq!( + decode(SCHEMA_2_SERVICES).as_deref(), + Some("baseapp (app), privy (wallet), flashbots (service), titan (service)") + ); + assert_eq!(decode(UNKNOWN_SCHEMA), None); + // Control characters are escaped. + let data = [&hex!("1b5b33316d0500")[..], &MARKER].concat(); + assert_eq!(decode(&data).as_deref(), Some("\\u{1b}[31m")); + } +} diff --git a/crates/evm/traces/src/folded_stack_trace.rs b/crates/evm/traces/src/folded_stack_trace.rs index 8b843b56c0efa..effebb6d68dc0 100644 --- a/crates/evm/traces/src/folded_stack_trace.rs +++ b/crates/evm/traces/src/folded_stack_trace.rs @@ -4,50 +4,66 @@ use revm_inspectors::tracing::{ types::{CallTraceNode, CallTraceStep, DecodedTraceStep, TraceMemberOrder}, }; -/// Builds a folded stack trace from a call trace arena. -pub fn build(arena: &CallTraceArena) -> Vec { - let mut fst = EvmFoldedStackTraceBuilder::default(); - fst.process_call_node(arena.nodes(), 0); - fst.build() -} - /// Wrapper for building a folded stack trace using EVM call trace node. #[derive(Default)] pub struct EvmFoldedStackTraceBuilder { + /// Trace produced in isolate mode, meaning refund needs to be reversed at the depth=1 + /// frame for consistent gas values. + isolate: bool, /// Raw folded stack trace builder. fst: FoldedStackTraceBuilder, } impl EvmFoldedStackTraceBuilder { - /// Returns the folded stack trace. + pub fn new(isolate: bool) -> Self { + Self { isolate, fst: FoldedStackTraceBuilder::default() } + } + + /// Returns the folded stack trace as formatted strings. pub fn build(self) -> Vec { self.fst.build() } - /// Creates an entry for a EVM CALL in the folded stack trace. This method recursively processes - /// all the children nodes of the call node and at the end it exits. + /// Creates an entry for an EVM CALL in the folded stack trace. This method recursively + /// processes all the children nodes of the call node and at the end it exits. pub fn process_call_node(&mut self, nodes: &[CallTraceNode], idx: usize) { let node = &nodes[idx]; let func_name = if node.trace.kind.is_any_create() { - let contract_name = node.trace.decoded.label.as_deref().unwrap_or("Contract"); + let contract_name = node + .trace + .decoded + .as_ref() + .and_then(|dc| dc.label.as_deref()) + .unwrap_or("Contract"); format!("new {contract_name}") } else { let selector = node .selector() .map(|selector| selector.encode_hex_with_prefix()) .unwrap_or_else(|| "fallback".to_string()); - let signature = - node.trace.decoded.call_data.as_ref().map(|dc| &dc.signature).unwrap_or(&selector); + let signature = node + .trace + .decoded + .as_ref() + .and_then(|dc| dc.call_data.as_ref()) + .map(|dc| &dc.signature) + .unwrap_or(&selector); - if let Some(label) = &node.trace.decoded.label { + if let Some(label) = node.trace.decoded.as_ref().and_then(|dc| dc.label.as_ref()) { format!("{label}.{signature}") } else { signature.clone() } }; - self.fst.enter(func_name, node.trace.gas_used as i64); + let mut gas_used = node.trace.gas_used; + let max_refund_adjust_depth = if self.isolate { 1 } else { 0 }; + if node.trace.depth <= max_refund_adjust_depth { + gas_used += node.trace.gas_refund_counter; + } + + self.fst.enter(func_name, gas_used); // Track internal function step exits to do in this call context. let mut step_exits = vec![]; @@ -87,10 +103,12 @@ impl EvmFoldedStackTraceBuilder { ) { let step = &steps[step_idx]; if let Some(decoded_step) = &step.decoded { - match decoded_step { + match decoded_step.as_ref() { DecodedTraceStep::InternalCall(decoded_internal_call, step_end_idx) => { - let gas_used = steps[*step_end_idx].gas_used.saturating_sub(step.gas_used); - self.fst.enter(decoded_internal_call.func_name.clone(), gas_used as i64); + // Gas metering resets can increase the remaining gas across an internal call. + let gas_used = + step.gas_remaining.saturating_sub(steps[*step_end_idx].gas_remaining); + self.fst.enter(decoded_internal_call.func_name.clone(), gas_used); step_exits.push(*step_end_idx); } DecodedTraceStep::Line(_) => {} @@ -144,17 +162,18 @@ pub struct FoldedStackTraceBuilder { exits: usize, } +/// A single entry in a folded stack trace. #[derive(Debug, Default)] struct TraceEntry { /// Names of all functions in the call stack of this trace. names: Vec, - /// Gas consumed by this function, allowed to be negative due to refunds. - gas: i64, + /// Gas consumed by this function, not including refunds. + gas: u64, } impl FoldedStackTraceBuilder { /// Enter execution of a function call that consumes `gas`. - pub fn enter(&mut self, label: String, gas: i64) { + pub fn enter(&mut self, label: String, gas: u64) { let mut names = self.traces.last().map(|entry| entry.names.clone()).unwrap_or_default(); while self.exits > 0 { @@ -167,24 +186,20 @@ impl FoldedStackTraceBuilder { } /// Exit execution of a function call. - pub fn exit(&mut self) { + pub const fn exit(&mut self) { self.exits += 1; } - /// Returns folded stack trace. + /// Returns folded stack trace as formatted strings. pub fn build(mut self) -> Vec { self.subtract_children(); - self.build_without_subtraction() + self.traces.iter().map(|e| format!("{} {}", e.names.join(";"), e.gas)).collect() } /// Internal method to build the folded stack trace without subtracting gas consumed by /// the children function calls. - fn build_without_subtraction(&mut self) -> Vec { - let mut lines = Vec::new(); - for TraceEntry { names, gas } in &self.traces { - lines.push(format!("{} {}", names.join(";"), gas)); - } - lines + pub fn build_without_subtraction(&self) -> Vec { + self.traces.iter().map(|e| format!("{} {}", e.names.join(";"), e.gas)).collect() } /// Subtracts gas consumed by the children function calls from the parent function calls. @@ -197,7 +212,7 @@ impl FoldedStackTraceBuilder { let parent_trace_to_match = &names[..names.len() - 1]; for parent in left.iter_mut().rev() { if parent.names == parent_trace_to_match { - parent.gas -= gas; + parent.gas = parent.gas.saturating_sub(*gas); break; } } @@ -206,7 +221,46 @@ impl FoldedStackTraceBuilder { } } +/// Builds a folded stack trace from a call trace arena. +pub fn build(arena: &CallTraceArena, isolate: bool) -> Vec { + let mut fst = EvmFoldedStackTraceBuilder::new(isolate); + if !arena.nodes().is_empty() { + fst.process_call_node(arena.nodes(), 0); + } + fst.build() +} + +#[cfg(test)] mod tests { + use alloy_primitives::{Bytes, U256}; + use revm::{bytecode::opcode::OpCode, interpreter::InstructionResult}; + use revm_inspectors::tracing::{ + CallTraceArena, + types::{CallTraceStep, DecodedInternalCall, DecodedTraceStep, TraceMemberOrder}, + }; + + fn trace_step(gas_remaining: u64) -> CallTraceStep { + CallTraceStep { + pc: 0, + op: OpCode::STOP, + stack: Some(Vec::::new().into_boxed_slice()), + push_stack: None, + memory: None, + returndata: Bytes::new(), + gas_remaining, + gas_refund_counter: 0, + gas_used: 0, + gas_cost: 0, + state_gas_cost: None, + state_gas_reservoir: None, + state_gas_spent: 0, + storage_change: None, + status: Some(InstructionResult::Stop), + immediate_bytes: None, + decoded: None, + } + } + #[test] fn test_fst_1() { let mut trace = super::FoldedStackTraceBuilder::default(); @@ -299,4 +353,60 @@ mod tests { ] ); } + + #[test] + fn folded_stack_trace_saturates_parent_gas() { + let mut trace = super::FoldedStackTraceBuilder::default(); + trace.enter("top".to_string(), 100); + trace.enter("child".to_string(), 150); + + assert_eq!(trace.build(), vec!["top 0", "top;child 150"]); + } + + #[test] + fn folded_stack_trace_keeps_precise_internal_function_names() { + let mut arena = CallTraceArena::default(); + let root = &mut arena.nodes_mut()[0]; + root.trace.gas_used = 100; + root.trace.gas_limit = 100; + root.trace.steps = vec![trace_step(100), trace_step(70)]; + root.trace.steps[0].decoded = Some(Box::new(DecodedTraceStep::InternalCall( + DecodedInternalCall { + func_name: "DebugVarsTest::foo(uint256)".to_string(), + args: Some(vec!["42".to_string()]), + return_data: Some(vec!["43".to_string()]), + }, + 1, + ))); + root.ordering = vec![TraceMemberOrder::Step(0), TraceMemberOrder::Step(1)]; + + assert_eq!( + super::build(&arena, false), + vec!["fallback 70", "fallback;DebugVarsTest::foo(uint256) 30",] + ); + } + + #[test] + fn folded_stack_trace_saturates_internal_call_gas_on_reset_metering() { + // Model an internal call whose gas meter resets before returning. + let mut arena = CallTraceArena::default(); + let root = &mut arena.nodes_mut()[0]; + root.trace.gas_used = 100; + root.trace.gas_limit = 100; + root.trace.steps = vec![trace_step(70), trace_step(100)]; + root.trace.steps[0].decoded = Some(Box::new(DecodedTraceStep::InternalCall( + DecodedInternalCall { + func_name: "DebugVarsTest::resetsGas()".to_string(), + args: None, + return_data: None, + }, + 1, + ))); + root.ordering = vec![TraceMemberOrder::Step(0), TraceMemberOrder::Step(1)]; + + assert_eq!( + super::build(&arena, false), + vec!["fallback 100", "fallback;DebugVarsTest::resetsGas() 0",] + ); + } } diff --git a/crates/evm/traces/src/identifier/etherscan.rs b/crates/evm/traces/src/identifier/etherscan.rs deleted file mode 100644 index ecfb0d9d59f2b..0000000000000 --- a/crates/evm/traces/src/identifier/etherscan.rs +++ /dev/null @@ -1,269 +0,0 @@ -use super::{IdentifiedAddress, TraceIdentifier}; -use crate::debug::ContractSources; -use alloy_primitives::Address; -use foundry_block_explorers::{ - contract::{ContractMetadata, Metadata}, - errors::EtherscanError, -}; -use foundry_common::compile::etherscan_project; -use foundry_config::{Chain, Config}; -use futures::{ - future::join_all, - stream::{FuturesUnordered, Stream, StreamExt}, - task::{Context, Poll}, -}; -use revm_inspectors::tracing::types::CallTraceNode; -use std::{ - borrow::Cow, - collections::BTreeMap, - pin::Pin, - sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }, -}; -use tokio::time::{Duration, Interval}; - -/// A trace identifier that tries to identify addresses using Etherscan. -pub struct EtherscanIdentifier { - /// The Etherscan client - client: Arc, - /// Tracks whether the API key provides was marked as invalid - /// - /// After the first [EtherscanError::InvalidApiKey] this will get set to true, so we can - /// prevent any further attempts - invalid_api_key: Arc, - pub contracts: BTreeMap, - pub sources: BTreeMap, -} - -impl EtherscanIdentifier { - /// Creates a new Etherscan identifier with the given client - pub fn new(config: &Config, chain: Option) -> eyre::Result> { - // In offline mode, don't use Etherscan. - if config.offline { - return Ok(None); - } - let Some(config) = config.get_etherscan_config_with_chain(chain)? else { - return Ok(None); - }; - trace!(target: "traces::etherscan", chain=?config.chain, url=?config.api_url, "using etherscan identifier"); - Ok(Some(Self { - client: Arc::new(config.into_client()?), - invalid_api_key: Arc::new(AtomicBool::new(false)), - contracts: BTreeMap::new(), - sources: BTreeMap::new(), - })) - } - - /// Goes over the list of contracts we have pulled from the traces, clones their source from - /// Etherscan and compiles them locally, for usage in the debugger. - pub async fn get_compiled_contracts(&self) -> eyre::Result { - // TODO: Add caching so we dont double-fetch contracts. - let outputs_fut = self - .contracts - .iter() - // filter out vyper files - .filter(|(_, metadata)| !metadata.is_vyper()) - .map(|(address, metadata)| async move { - sh_println!("Compiling: {} {address}", metadata.contract_name)?; - let root = tempfile::tempdir()?; - let root_path = root.path(); - let project = etherscan_project(metadata, root_path)?; - let output = project.compile()?; - - if output.has_compiler_errors() { - eyre::bail!("{output}") - } - - Ok((project, output, root)) - }) - .collect::>(); - - // poll all the futures concurrently - let outputs = join_all(outputs_fut).await; - - let mut sources: ContractSources = Default::default(); - - // construct the map - for res in outputs { - let (project, output, _root) = res?; - sources.insert(&output, project.root(), None)?; - } - - Ok(sources) - } - - fn identify_from_metadata( - &self, - address: Address, - metadata: &Metadata, - ) -> IdentifiedAddress<'static> { - let label = metadata.contract_name.clone(); - let abi = metadata.abi().ok().map(Cow::Owned); - IdentifiedAddress { - address, - label: Some(label.clone()), - contract: Some(label), - abi, - artifact_id: None, - } - } -} - -impl TraceIdentifier for EtherscanIdentifier { - fn identify_addresses(&mut self, nodes: &[&CallTraceNode]) -> Vec> { - if self.invalid_api_key.load(Ordering::Relaxed) || nodes.is_empty() { - return Vec::new(); - } - - trace!(target: "evm::traces::etherscan", "identify {} addresses", nodes.len()); - - let mut identities = Vec::new(); - let mut fetcher = EtherscanFetcher::new( - self.client.clone(), - Duration::from_secs(1), - 5, - Arc::clone(&self.invalid_api_key), - ); - - for &node in nodes { - let address = node.trace.address; - if let Some(metadata) = self.contracts.get(&address) { - identities.push(self.identify_from_metadata(address, metadata)); - } else { - fetcher.push(address); - } - } - - let fetched_identities = foundry_common::block_on( - fetcher - .map(|(address, metadata)| { - let addr = self.identify_from_metadata(address, &metadata); - self.contracts.insert(address, metadata); - addr - }) - .collect::>>(), - ); - - identities.extend(fetched_identities); - identities - } -} - -type EtherscanFuture = - Pin)>>>; - -/// A rate limit aware Etherscan client. -/// -/// Fetches information about multiple addresses concurrently, while respecting rate limits. -struct EtherscanFetcher { - /// The Etherscan client - client: Arc, - /// The time we wait if we hit the rate limit - timeout: Duration, - /// The interval we are currently waiting for before making a new request - backoff: Option, - /// The maximum amount of requests to send concurrently - concurrency: usize, - /// The addresses we have yet to make requests for - queue: Vec
, - /// The in progress requests - in_progress: FuturesUnordered, - /// tracks whether the API key provides was marked as invalid - invalid_api_key: Arc, -} - -impl EtherscanFetcher { - fn new( - client: Arc, - timeout: Duration, - concurrency: usize, - invalid_api_key: Arc, - ) -> Self { - Self { - client, - timeout, - backoff: None, - concurrency, - queue: Vec::new(), - in_progress: FuturesUnordered::new(), - invalid_api_key, - } - } - - fn push(&mut self, address: Address) { - self.queue.push(address); - } - - fn queue_next_reqs(&mut self) { - while self.in_progress.len() < self.concurrency { - let Some(addr) = self.queue.pop() else { break }; - let client = Arc::clone(&self.client); - self.in_progress.push(Box::pin(async move { - trace!(target: "traces::etherscan", ?addr, "fetching info"); - let res = client.contract_source_code(addr).await; - (addr, res) - })); - } - } -} - -impl Stream for EtherscanFetcher { - type Item = (Address, Metadata); - - fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let pin = self.get_mut(); - - loop { - if let Some(mut backoff) = pin.backoff.take() - && backoff.poll_tick(cx).is_pending() - { - pin.backoff = Some(backoff); - return Poll::Pending; - } - - pin.queue_next_reqs(); - - let mut made_progress_this_iter = false; - match pin.in_progress.poll_next_unpin(cx) { - Poll::Pending => {} - Poll::Ready(None) => return Poll::Ready(None), - Poll::Ready(Some((addr, res))) => { - made_progress_this_iter = true; - match res { - Ok(mut metadata) => { - if let Some(item) = metadata.items.pop() { - return Poll::Ready(Some((addr, item))); - } - } - Err(EtherscanError::RateLimitExceeded) => { - warn!(target: "traces::etherscan", "rate limit exceeded on attempt"); - pin.backoff = Some(tokio::time::interval(pin.timeout)); - pin.queue.push(addr); - } - Err(EtherscanError::InvalidApiKey) => { - warn!(target: "traces::etherscan", "invalid api key"); - // mark key as invalid - pin.invalid_api_key.store(true, Ordering::Relaxed); - return Poll::Ready(None); - } - Err(EtherscanError::BlockedByCloudflare) => { - warn!(target: "traces::etherscan", "blocked by cloudflare"); - // mark key as invalid - pin.invalid_api_key.store(true, Ordering::Relaxed); - return Poll::Ready(None); - } - Err(err) => { - warn!(target: "traces::etherscan", "could not get etherscan info: {:?}", err); - } - } - } - } - - if !made_progress_this_iter { - return Poll::Pending; - } - } - } -} diff --git a/crates/evm/traces/src/identifier/external.rs b/crates/evm/traces/src/identifier/external.rs new file mode 100644 index 0000000000000..6801ca434c969 --- /dev/null +++ b/crates/evm/traces/src/identifier/external.rs @@ -0,0 +1,1405 @@ +use super::{IdentifiedAddress, TraceIdentifier}; +use crate::debug::ContractSources; +use alloy_json_abi::JsonAbi; +use alloy_primitives::{ + Address, + map::{AddressMap, AddressSet, Entry, HashMap, HashSet}, +}; +use eyre::WrapErr; +use foundry_block_explorers::{contract::Metadata, errors::EtherscanError}; +use foundry_common::compile::etherscan_project; +use foundry_config::{Chain, Config, EtherscanConfigs, NamedChain, ResolvedEtherscanConfig}; +use foundry_evm_core::constants::{CHEATCODE_ADDRESS, HARDHAT_CONSOLE_ADDRESS}; +use futures::{ + future::join_all, + stream::{FuturesUnordered, Stream, StreamExt}, + task::{Context, Poll}, +}; +use revm_inspectors::tracing::types::CallTraceNode; +use serde::Deserialize; +use std::{ + borrow::Cow, + pin::Pin, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, +}; +use tokio::time::{Duration, Interval}; + +/// A trace identifier that tries to identify addresses using Etherscan. +pub struct ExternalIdentifier { + fetchers: Vec>, + /// Cached contracts. + contracts: HashMap)>, + /// Remaining time external identification may block trace rendering. + remaining_budget: Duration, +} + +/// The [`Config`] settings an [`ExternalIdentifier`] is built from, detached from the config +/// itself. +/// +/// Lets a consumer that keeps only a snapshot of the config — the cheatcode config — build an +/// identifier for a chain it doesn't learn until runtime, when a test selects a fork. +#[derive(Clone, Debug, Default)] +pub struct ExternalIdentifierConfig { + /// Whether network access is disabled altogether. + offline: bool, + /// How long identification may block, in seconds. Zero disables it. + timeout: u64, + /// Whether to skip system proxy lookups when building the explorer client. + no_proxy: bool, + /// The `[etherscan]` table, and the settings that pick an entry out of it. + etherscan: EtherscanConfigs, + etherscan_alias: Option, + etherscan_api_key: Option, + /// The configured chain, used when the caller doesn't name one. + chain: Option, +} + +impl ExternalIdentifierConfig { + /// Takes the settings an [`ExternalIdentifier`] needs out of `config`. + pub fn new(config: &Config) -> Self { + Self { + offline: config.offline, + timeout: config.tracing.external_identification_timeout, + no_proxy: config.eth_rpc_no_proxy, + etherscan: config.etherscan.clone(), + etherscan_alias: config.etherscan_alias().map(str::to_string), + etherscan_api_key: config.etherscan_api_key.clone(), + chain: config.chain, + } + } + + /// Builds an identifier that looks contracts up on `chain`. + /// + /// Returns `None` when there is nothing to look them up with: identification is off, or + /// neither Sourcify nor a block explorer is usable. + pub fn identifier(&self, chain: Option) -> Option { + self.identifier_with( + chain, + self.etherscan_alias.as_deref(), + self.etherscan_api_key.as_deref(), + true, + ) + } + + /// Builds an identifier for compiling storage layouts on exactly `chain`. + /// + /// Storage layouts are cached by chain, so an alias for another chain must not override the + /// chain that executed the recorded storage access. Sourcify is omitted because its compact + /// response does not contain the verified sources required for compilation. + pub fn storage_identifier(&self, chain: Chain) -> Option { + self.identifier_with(Some(chain), None, self.literal_api_key(), false) + } + + fn identifier_with( + &self, + chain: Option, + etherscan_alias: Option<&str>, + etherscan_api_key: Option<&str>, + sourcify: bool, + ) -> Option { + if self.offline || self.timeout == 0 { + return None; + } + + let (chain, etherscan) = self.resolve_explorer(chain, etherscan_alias, etherscan_api_key); + + let mut fetchers = Vec::>::new(); + // Sourcify never indexes local development chains. + if sourcify + && let Some(chain) = chain + && !matches!(chain.named(), Some(NamedChain::AnvilHardhat | NamedChain::Dev)) + { + debug!(target: "evm::traces::external", ?chain, "using sourcify identifier"); + fetchers.push(Arc::new(SourcifyFetcher::new(chain))); + } + if let Some(config) = etherscan { + debug!(target: "evm::traces::external", chain=?config.chain, url=?config.api_url, "using etherscan identifier"); + match config.into_client_with_no_proxy(self.no_proxy) { + Ok(client) => { + fetchers.push(Arc::new(EtherscanFetcher::new(client))); + } + Err(err) => { + warn!(target: "evm::traces::external", ?err, "failed to create etherscan client"); + } + } + } + if fetchers.is_empty() { + debug!(target: "evm::traces::external", "no fetchers enabled"); + return None; + } + + Some(ExternalIdentifier { + fetchers, + contracts: Default::default(), + remaining_budget: Duration::from_secs(self.timeout), + }) + } + + /// Picks the explorer config and the chain to look contracts up on. + fn resolve_explorer( + &self, + chain: Option, + etherscan_alias: Option<&str>, + etherscan_api_key: Option<&str>, + ) -> (Option, Option) { + let mut resolved = + self.etherscan.resolve_for(etherscan_alias, etherscan_api_key, chain.or(self.chain)); + // Only an alias can resolve to a chain other than the requested one. An alias pinned to + // another chain must not redirect lookups away from the traced chain. + if let Some(chain) = chain + && let Ok(Some(config)) = &resolved + && config.chain.is_some_and(|c| c != chain) + { + resolved = self.etherscan.resolve_for(None, self.literal_api_key(), Some(chain)); + } + match resolved { + Ok(Some(config)) => (chain.or(config.chain), Some(config)), + Ok(None) => { + warn!(target: "evm::traces::external", "etherscan config not found"); + (chain, None) + } + Err(err) => { + warn!(target: "evm::traces::external", ?err, "failed to get etherscan config"); + (chain, None) + } + } + } + + /// Maximum time a storage-layout lookup may block. + pub const fn storage_timeout(&self) -> Duration { + Duration::from_secs(self.timeout) + } + + /// Returns `etherscan_api_key` unless it names an `[etherscan]` entry. + /// + /// `etherscan_api_key` also accepts a table alias. An alias name must not be sent as the + /// literal API key when the alias itself is not used. + fn literal_api_key(&self) -> Option<&str> { + self.etherscan_api_key.as_deref().filter(|key| !self.etherscan.contains_key(*key)) + } +} + +impl ExternalIdentifier { + /// Creates a new external identifier with the given client. + pub fn new(config: &Config, chain: Option) -> eyre::Result> { + Ok(ExternalIdentifierConfig::new(config).identifier(chain)) + } + + /// Goes over the list of contracts we have pulled from the traces, clones their source from + /// Etherscan and compiles them locally, for usage in the debugger. + pub async fn get_compiled_contracts(&self) -> eyre::Result { + // Collect contract info upfront so we can reference it in error messages + let contracts_info: Vec<_> = self + .contracts + .iter() + // filter out vyper files and contracts without metadata + .filter_map(|(addr, (_, metadata))| { + if let Some(metadata) = metadata.as_ref() + && !metadata.is_vyper() + { + Some((*addr, metadata)) + } else { + None + } + }) + .collect(); + + let outputs_fut = contracts_info + .iter() + .map(|(addr, metadata)| async move { + sh_println!("Compiling: {} {addr}", metadata.contract_name)?; + let root = tempfile::tempdir()?; + let root_path = root.path(); + let project = etherscan_project(metadata, root_path)?; + let output = project.compile()?; + if output.has_compiler_errors() { + eyre::bail!("{output}"); + } + + Ok((project, output, root)) + }) + .collect::>(); + + // poll all the futures concurrently + let outputs = join_all(outputs_fut).await; + + let mut sources: ContractSources = Default::default(); + + // construct the map + for (idx, res) in outputs.into_iter().enumerate() { + let (addr, metadata) = &contracts_info[idx]; + let name = &metadata.contract_name; + let (project, output, _) = + res.wrap_err_with(|| format!("Failed to compile contract {name} at {addr}"))?; + sources + .insert(&output, project.root(), None) + .wrap_err_with(|| format!("Failed to insert contract {name} at {addr}"))?; + } + + Ok(sources) + } + + fn identify_from_metadata( + &self, + address: Address, + metadata: &Metadata, + ) -> IdentifiedAddress<'static> { + let label = metadata.contract_name.clone(); + let abi = metadata.abi().ok().map(Cow::Owned); + IdentifiedAddress { + address, + label: Some(label.clone()), + contract: Some(label), + abi, + constructor_args_offset: None, + artifact_id: None, + } + } + + fn cache_fetched(&mut self, address: Address, value: (FetcherKind, Option)) { + match self.contracts.entry(address) { + Entry::Occupied(mut occupied_entry) => { + let old = occupied_entry.get(); + // Only override when the new result is strictly better: + // - new has metadata and old doesn't, OR + // - both have metadata but new is from Etherscan and old is not. + // Never downgrade a successful lookup to None. + let should_replace = match (&old.1, &value.1) { + (None, Some(_)) => true, + (Some(_), None) => false, + _ => { + matches!(value.0, FetcherKind::Etherscan) + && !matches!(old.0, FetcherKind::Etherscan) + } + }; + if should_replace { + occupied_entry.insert(value); + } + } + Entry::Vacant(vacant_entry) => { + vacant_entry.insert(value); + } + } + } + + async fn fetch_addresses_async(&mut self, addresses: &[Address]) { + self.fetch_addresses_with_timeout(addresses, self.remaining_budget).await; + } + + async fn fetch_addresses_with_timeout(&mut self, addresses: &[Address], timeout: Duration) { + let timeout = timeout.min(self.remaining_budget); + if addresses.is_empty() || timeout.is_zero() { + return; + } + + let fetchers = self + .fetchers + .clone() + .into_iter() + .map(|fetcher| ExternalFetcher::new(fetcher, addresses)); + let started = tokio::time::Instant::now(); + let timed_out = tokio::time::timeout(timeout, async { + let mut fetched = futures::stream::select_all(fetchers); + while let Some((address, value)) = fetched.next().await { + self.cache_fetched(address, value); + } + }) + .await + .is_err(); + self.remaining_budget = self.remaining_budget.saturating_sub(started.elapsed()); + if timed_out && self.remaining_budget.is_zero() { + warn!(target: "evm::traces::external", "external identification timed out; disabling it for the remainder of this session"); + } + } + + /// Fetches all verified ABIs and whether each proxy chain was fully resolved. + pub async fn get_abis( + &mut self, + addresses: &[Address], + ) -> Vec<(Address, eyre::Result<(Vec, bool)>)> { + const MAX_PROXY_DEPTH: usize = 16; + + struct Chain { + current: Option
, + visited: HashSet
, + abis: Vec, + complete: bool, + } + + let mut chains = addresses + .iter() + .map(|&address| Chain { + current: Some(address), + visited: HashSet::default(), + abis: Vec::new(), + complete: true, + }) + .collect::>(); + + for _ in 0..MAX_PROXY_DEPTH { + let to_fetch = chains + .iter() + .filter_map(|chain| chain.current) + .filter(|address| !self.contracts.contains_key(address)) + .collect::>() + .into_iter() + .collect::>(); + self.fetch_addresses_async(&to_fetch).await; + + let mut has_next = false; + for chain in &mut chains { + let Some(current) = chain.current else { continue }; + if !chain.visited.insert(current) { + chain.current = None; + chain.complete = false; + continue; + } + let Some((_, Some(metadata))) = self.contracts.get(¤t) else { + chain.current = None; + chain.complete = false; + continue; + }; + if let Ok(abi) = metadata.abi() { + chain.abis.push(abi); + } else { + chain.complete = false; + } + chain.current = (metadata.proxy != 0).then_some(metadata.implementation).flatten(); + if metadata.proxy != 0 && chain.current.is_none() { + chain.complete = false; + } + has_next |= chain.current.is_some(); + } + if !has_next { + break; + } + } + + chains + .into_iter() + .zip(addresses.iter().copied()) + .map(|(mut chain, address)| { + chain.complete &= chain.current.is_none(); + let result = if chain.abis.is_empty() { + Err(eyre::eyre!("external ABI lookup failed")) + } else { + Ok((chain.abis.into_iter().rev().collect(), chain.complete)) + }; + (address, result) + }) + .collect() + } + + /// Fetches metadata for the exact addresses supplied, without following explorer proxy hints. + /// + /// Storage decoding uses the implementation address recorded by the EVM, which is authoritative + /// for historical forks and proxies that upgrade over time. + pub async fn get_metadata( + &mut self, + addresses: &[Address], + timeout: Duration, + ) -> AddressMap> { + let to_fetch = addresses + .iter() + .copied() + .filter(|address| !self.contracts.contains_key(address)) + .collect::>(); + self.fetch_addresses_with_timeout(&to_fetch, timeout).await; + addresses + .iter() + .filter_map(|address| { + self.contracts.get(address).map(|(_, metadata)| (*address, metadata.clone())) + }) + .collect() + } +} + +impl TraceIdentifier for ExternalIdentifier { + fn identify_addresses(&mut self, nodes: &[&CallTraceNode]) -> Vec> { + if nodes.is_empty() { + return Vec::new(); + } + + trace!(target: "evm::traces::external", "identify {} addresses", nodes.len()); + + let mut identities = Vec::new(); + let mut to_fetch = AddressSet::default(); + + // Check cache first. + for &node in nodes { + let address = node.trace.address; + // Foundry-only addresses that are never deployed contracts on a real chain. + if matches!(address, CHEATCODE_ADDRESS | HARDHAT_CONSOLE_ADDRESS) { + continue; + } + if let Some((_, metadata)) = self.contracts.get(&address) { + if let Some(metadata) = metadata { + identities.push(self.identify_from_metadata(address, metadata)); + } else { + // Do nothing. We know that this contract was not verified. + } + } else { + to_fetch.insert(address); + } + } + + if to_fetch.is_empty() { + return identities; + } + if self.remaining_budget.is_zero() { + return identities; + } + trace!(target: "evm::traces::external", "fetching {} addresses", to_fetch.len()); + + let to_fetch = to_fetch.into_iter().collect::>(); + foundry_common::block_on(self.fetch_addresses_async(&to_fetch)); + + for address in to_fetch { + if let Some((_, Some(metadata))) = self.contracts.get(&address) { + identities.push(self.identify_from_metadata(address, metadata)); + } + } + trace!(target: "evm::traces::external", "identified {} addresses", identities.len()); + identities + } +} + +type FetchFuture = + Pin, EtherscanError>)>>>; + +/// Maximum number of times a single address is retried after a rate limit or a transient +/// Cloudflare block before we give up on it. Bounded so a persistent block can't loop forever. +const MAX_TRANSIENT_RETRIES: u32 = 5; + +fn backoff_interval(period: Duration) -> Interval { + tokio::time::interval_at(tokio::time::Instant::now() + period, period) +} + +/// A rate limit aware fetcher. +/// +/// Fetches information about multiple addresses concurrently, while respecting rate limits. +struct ExternalFetcher { + /// The fetcher + fetcher: Arc, + /// The time we wait if we hit the rate limit + timeout: Duration, + /// The interval we are currently waiting for before making a new request + backoff: Option, + /// The maximum amount of requests to send concurrently + concurrency: usize, + /// The addresses we have yet to make requests for + queue: Vec
, + /// The in progress requests + in_progress: FuturesUnordered, + /// Per-address retry counter for rate limits and transient Cloudflare blocks. + attempts: HashMap, +} + +impl ExternalFetcher { + fn new(fetcher: Arc, to_fetch: &[Address]) -> Self { + Self { + timeout: fetcher.timeout(), + backoff: None, + concurrency: fetcher.concurrency(), + fetcher, + queue: to_fetch.to_vec(), + in_progress: FuturesUnordered::new(), + attempts: HashMap::default(), + } + } + + fn queue_next_reqs(&mut self) { + while self.in_progress.len() < self.concurrency { + let Some(addr) = self.queue.pop() else { break }; + let fetcher = Arc::clone(&self.fetcher); + self.in_progress.push(Box::pin(async move { + trace!(target: "evm::traces::external", ?addr, "fetching info"); + let res = fetcher.fetch(addr).await; + (addr, res) + })); + } + } + + /// Requeues `addr` after a backoff, unless it already used all of its retries. + /// + /// Returns `false` when the address is dropped. A dropped address is not cached, so a + /// transient failure does not become an "unverified" result. + fn retry(&mut self, addr: Address) -> bool { + let attempts = self.attempts.entry(addr).or_default(); + *attempts += 1; + if *attempts > MAX_TRANSIENT_RETRIES { + return false; + } + self.backoff = Some(backoff_interval(self.timeout)); + self.queue.push(addr); + true + } +} + +impl Stream for ExternalFetcher { + type Item = (Address, (FetcherKind, Option)); + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let pin = self.get_mut(); + + let _guard = + info_span!("evm::traces::external", kind=?pin.fetcher.kind(), "ExternalFetcher") + .entered(); + + if pin.fetcher.invalid_api_key().load(Ordering::Relaxed) { + return Poll::Ready(None); + } + + loop { + if let Some(mut backoff) = pin.backoff.take() + && backoff.poll_tick(cx).is_pending() + { + pin.backoff = Some(backoff); + return Poll::Pending; + } + + pin.queue_next_reqs(); + + let mut made_progress_this_iter = false; + match pin.in_progress.poll_next_unpin(cx) { + Poll::Pending => {} + Poll::Ready(None) => return Poll::Ready(None), + Poll::Ready(Some((addr, res))) => { + made_progress_this_iter = true; + match res { + Ok(metadata) => { + return Poll::Ready(Some((addr, (pin.fetcher.kind(), metadata)))); + } + Err(EtherscanError::ContractCodeNotVerified(_)) => { + return Poll::Ready(Some((addr, (pin.fetcher.kind(), None)))); + } + Err(EtherscanError::RateLimitExceeded) => { + if pin.retry(addr) { + warn!(target: "evm::traces::external", "rate limit exceeded, backing off"); + } else { + warn!(target: "evm::traces::external", "rate limit exceeded, giving up on address"); + } + } + Err(EtherscanError::InvalidApiKey) => { + warn!(target: "evm::traces::external", "invalid api key"); + // mark key as invalid + pin.fetcher.invalid_api_key().store(true, Ordering::Relaxed); + return Poll::Ready(None); + } + Err(EtherscanError::ChainNotSupported(chain)) => { + warn!(target: "evm::traces::external", %chain, "chain not supported"); + // No address on this chain can resolve, so stop using the fetcher. + pin.fetcher.invalid_api_key().store(true, Ordering::Relaxed); + return Poll::Ready(None); + } + Err(EtherscanError::BlockedByCloudflare) => { + // A Cloudflare block is transient rate limiting (often triggered + // by request bursts), not a permanent failure like an invalid key. + // Back off and retry the address a bounded number of times instead + // of aborting the whole stream, which would abandon every still- + // queued address and leave traces only partially decoded (#9880). + if pin.retry(addr) { + warn!(target: "evm::traces::external", "blocked by cloudflare, backing off"); + } else { + warn!(target: "evm::traces::external", "blocked by cloudflare, giving up on address"); + // No conclusion: do not turn a transient outage into a cached + // "unverified" result. + } + } + Err(err) => { + warn!(target: "evm::traces::external", ?err, "could not get info"); + // Only ContractCodeNotVerified is a conclusive negative result. + } + } + } + } + + if !made_progress_this_iter { + return Poll::Pending; + } + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FetcherKind { + Etherscan, + Sourcify, +} + +#[async_trait::async_trait] +trait ExternalFetcherT: Send + Sync { + fn kind(&self) -> FetcherKind; + fn timeout(&self) -> Duration; + fn concurrency(&self) -> usize; + fn invalid_api_key(&self) -> &AtomicBool; + async fn fetch(&self, address: Address) -> Result, EtherscanError>; +} + +struct EtherscanFetcher { + client: foundry_block_explorers::Client, + invalid_api_key: AtomicBool, +} + +impl EtherscanFetcher { + const fn new(client: foundry_block_explorers::Client) -> Self { + Self { client, invalid_api_key: AtomicBool::new(false) } + } +} + +#[async_trait::async_trait] +impl ExternalFetcherT for EtherscanFetcher { + fn kind(&self) -> FetcherKind { + FetcherKind::Etherscan + } + + fn timeout(&self) -> Duration { + Duration::from_secs(1) + } + + fn concurrency(&self) -> usize { + 5 + } + + fn invalid_api_key(&self) -> &AtomicBool { + &self.invalid_api_key + } + + async fn fetch(&self, address: Address) -> Result, EtherscanError> { + self.client.contract_source_code(address).await.map(|mut metadata| metadata.items.pop()) + } +} + +struct SourcifyFetcher { + client: reqwest::Client, + chain: Chain, + url: String, + invalid_api_key: AtomicBool, +} + +impl SourcifyFetcher { + fn new(chain: Chain) -> Self { + Self { + client: reqwest::Client::builder() + .user_agent(foundry_common::DEFAULT_USER_AGENT) + .build() + .expect("Client::builder() with static config cannot fail"), + chain, + url: format!("https://sourcify.dev/server/v2/contract/{}", chain.id()), + invalid_api_key: AtomicBool::new(false), + } + } +} + +#[async_trait::async_trait] +impl ExternalFetcherT for SourcifyFetcher { + fn kind(&self) -> FetcherKind { + FetcherKind::Sourcify + } + + fn timeout(&self) -> Duration { + Duration::from_secs(1) + } + + fn concurrency(&self) -> usize { + 5 + } + + fn invalid_api_key(&self) -> &AtomicBool { + &self.invalid_api_key + } + + async fn fetch(&self, address: Address) -> Result, EtherscanError> { + let url = format!("{url}/{address}?fields=abi,compilation", url = self.url); + let response = self + .client + .get(url) + .send() + .await + .map_err(|e| EtherscanError::Unknown(e.to_string()))?; + let code = response.status(); + match code.as_u16() { + // Not verified. + 404 => return Err(EtherscanError::ContractCodeNotVerified(address)), + // Too many requests. + 429 => return Err(EtherscanError::RateLimitExceeded), + // Bad request. Only a recognized error code is final; anything else is not cached. + 400 => { + let error = response + .json::() + .await + .map_err(|e| EtherscanError::Unknown(e.to_string()))?; + return Err(match error.custom_code.as_str() { + "unsupported_chain" => EtherscanError::ChainNotSupported(self.chain), + "invalid_parameter" => EtherscanError::ContractCodeNotVerified(address), + _ => EtherscanError::Unknown(format!("{error:#?}")), + }); + } + _ => {} + } + let response: SourcifyResponse = + response.json().await.map_err(|e| EtherscanError::Unknown(e.to_string()))?; + trace!(target: "evm::traces::external", "Sourcify response for {address}: {response:#?}"); + match response { + SourcifyResponse::Success(metadata) => Ok(Some(metadata.into())), + SourcifyResponse::Error(error) => Err(EtherscanError::Unknown(format!("{error:#?}"))), + } + } +} + +/// Sourcify API response for `/v2/contract/{chainId}/{address}`. +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +enum SourcifyResponse { + Success(SourcifyMetadata), + Error(SourcifyError), +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +#[expect(dead_code)] // Used in Debug. +struct SourcifyError { + custom_code: String, + message: String, + error_id: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SourcifyMetadata { + #[serde(default)] + abi: Option>, + #[serde(default)] + compilation: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Compilation { + #[serde(default)] + compiler_version: String, + #[serde(default)] + name: String, +} + +impl From for Metadata { + fn from(metadata: SourcifyMetadata) -> Self { + let SourcifyMetadata { abi, compilation } = metadata; + let (contract_name, compiler_version) = compilation + .map(|c| (c.name, c.compiler_version)) + .unwrap_or_else(|| (String::new(), String::new())); + // Defaulted fields may be fetched from sourcify but we don't make use of them. + Self { + source_code: foundry_block_explorers::contract::SourceCodeMetadata::Sources( + Default::default(), + ), + abi: Box::::from(abi.unwrap_or_default()).into(), + contract_name, + compiler_version, + optimization_used: 0, + runs: 0, + constructor_arguments: Default::default(), + evm_version: String::new(), + library: String::new(), + license_type: String::new(), + proxy: 0, + implementation: None, + swarm_source: String::new(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + collections::HashSet as StdHashSet, + future::pending, + io::{Read, Write}, + net::TcpListener, + sync::{ + Mutex, + atomic::{AtomicUsize, Ordering as AtomicOrdering}, + }, + }; + + struct TestFetcher { + kind: FetcherKind, + delay: Option, + contract_name: Option<&'static str>, + calls: Arc, + invalid: AtomicBool, + } + + #[async_trait::async_trait] + impl ExternalFetcherT for TestFetcher { + fn kind(&self) -> FetcherKind { + self.kind + } + + fn timeout(&self) -> Duration { + Duration::from_millis(1) + } + + fn concurrency(&self) -> usize { + 1 + } + + fn invalid_api_key(&self) -> &AtomicBool { + &self.invalid + } + + async fn fetch(&self, _address: Address) -> Result, EtherscanError> { + self.calls.fetch_add(1, AtomicOrdering::Relaxed); + let Some(delay) = self.delay else { return pending().await }; + if !delay.is_zero() { + tokio::time::sleep(delay).await; + } + Ok(self.contract_name.map(metadata)) + } + } + + struct RateLimitedFetcher { + calls: Arc, + invalid: AtomicBool, + } + + #[async_trait::async_trait] + impl ExternalFetcherT for RateLimitedFetcher { + fn kind(&self) -> FetcherKind { + FetcherKind::Sourcify + } + + fn timeout(&self) -> Duration { + Duration::from_millis(5) + } + + fn concurrency(&self) -> usize { + 1 + } + + fn invalid_api_key(&self) -> &AtomicBool { + &self.invalid + } + + async fn fetch(&self, _address: Address) -> Result, EtherscanError> { + self.calls.fetch_add(1, AtomicOrdering::Relaxed); + Err(EtherscanError::RateLimitExceeded) + } + } + + struct ErrorFetcher { + calls: Arc, + invalid: AtomicBool, + } + + #[async_trait::async_trait] + impl ExternalFetcherT for ErrorFetcher { + fn kind(&self) -> FetcherKind { + FetcherKind::Etherscan + } + + fn timeout(&self) -> Duration { + Duration::ZERO + } + + fn concurrency(&self) -> usize { + 1 + } + + fn invalid_api_key(&self) -> &AtomicBool { + &self.invalid + } + + async fn fetch(&self, _address: Address) -> Result, EtherscanError> { + self.calls.fetch_add(1, AtomicOrdering::Relaxed); + Err(EtherscanError::Unknown("temporary explorer failure".to_string())) + } + } + + fn metadata(contract_name: &str) -> Metadata { + SourcifyMetadata { + abi: None, + compilation: Some(Compilation { + compiler_version: String::new(), + name: contract_name.to_string(), + }), + } + .into() + } + + fn test_identifier( + fetchers: Vec>, + remaining_budget: Duration, + ) -> ExternalIdentifier { + ExternalIdentifier { fetchers, contracts: Default::default(), remaining_budget } + } + + #[test] + fn zero_timeout_disables_external_identification() { + let mut config = Config::default(); + config.tracing.external_identification_timeout = 0; + + assert!(ExternalIdentifier::new(&config, Some(Chain::mainnet())).unwrap().is_none()); + } + + #[test] + fn alias_for_another_chain_does_not_redirect_lookups() { + let config = ExternalIdentifierConfig { + timeout: 1, + etherscan: serde_json::from_value(serde_json::json!({ + "mainnet": { "key": "key" } + })) + .unwrap(), + etherscan_alias: Some("mainnet".to_string()), + etherscan_api_key: Some("mainnet".to_string()), + ..Default::default() + }; + + assert!(config.storage_identifier(Chain::from(8453)).is_none()); + + let kinds = |identifier: ExternalIdentifier| { + identifier.fetchers.iter().map(|fetcher| fetcher.kind()).collect::>() + }; + assert_eq!( + kinds(config.identifier(Some(Chain::from(8453))).unwrap()), + [FetcherKind::Sourcify] + ); + assert_eq!( + kinds(config.identifier(Some(Chain::mainnet())).unwrap()), + [FetcherKind::Sourcify, FetcherKind::Etherscan] + ); + + // The traced chain's own entry is used instead of the foreign alias. + let config = ExternalIdentifierConfig { + etherscan: serde_json::from_value(serde_json::json!({ + "mainnet": { "key": "key" }, + "base": { "key": "key" } + })) + .unwrap(), + ..config + }; + let (chain, etherscan) = + config.resolve_explorer(Some(Chain::from(8453)), Some("mainnet"), Some("mainnet")); + assert_eq!(chain, Some(Chain::from(8453))); + assert_eq!(etherscan.unwrap().chain, Some(Chain::from(8453))); + + // A chainless alias keeps its explorer URL without clearing the traced chain. + let config = ExternalIdentifierConfig { + etherscan: serde_json::from_value(serde_json::json!({ + "custom": { "key": "key", "url": "https://explorer.invalid/api" } + })) + .unwrap(), + etherscan_alias: Some("custom".to_string()), + etherscan_api_key: Some("custom".to_string()), + ..config + }; + assert_eq!( + kinds(config.identifier(Some(Chain::from(8453))).unwrap()), + [FetcherKind::Sourcify, FetcherKind::Etherscan] + ); + } + + /// Fetcher that returns a transient Cloudflare block the first time it sees an address, then + /// succeeds. Mirrors Etherscan/Cloudflare throttling a burst of concurrent requests. + struct FlakyCloudflareFetcher { + seen: Mutex>, + invalid: AtomicBool, + } + + #[async_trait::async_trait] + impl ExternalFetcherT for FlakyCloudflareFetcher { + fn kind(&self) -> FetcherKind { + FetcherKind::Etherscan + } + fn timeout(&self) -> Duration { + Duration::from_millis(1) + } + fn concurrency(&self) -> usize { + 1 + } + fn invalid_api_key(&self) -> &AtomicBool { + &self.invalid + } + async fn fetch(&self, address: Address) -> Result, EtherscanError> { + let first_time = self.seen.lock().unwrap().insert(address); + if first_time { Err(EtherscanError::BlockedByCloudflare) } else { Ok(None) } + } + } + + /// Regression test for #9880: a transient Cloudflare block on one address must not abandon the + /// rest of the queue. Before the fix the fetcher returned `Poll::Ready(None)` on the first + /// block, ending the stream and leaving later addresses unidentified (partial trace decoding). + #[tokio::test] + async fn cloudflare_block_retries_instead_of_abandoning_queue() { + let addrs: Vec
= (1u8..=4).map(Address::with_last_byte).collect(); + let fetcher: Arc = Arc::new(FlakyCloudflareFetcher { + seen: Mutex::new(StdHashSet::new()), + invalid: AtomicBool::new(false), + }); + + let collected: Vec<_> = ExternalFetcher::new(fetcher, &addrs).collect().await; + + let got: StdHashSet
= collected.into_iter().map(|(addr, _)| addr).collect(); + let want: StdHashSet
= addrs.into_iter().collect(); + assert_eq!(got, want, "every address must be yielded despite a transient cloudflare block"); + } + + #[tokio::test(start_paused = true)] + async fn timeout_keeps_partial_results_and_opens_circuit() { + let successful_calls = Arc::new(AtomicUsize::new(0)); + let stalled_calls = Arc::new(AtomicUsize::new(0)); + let fetchers: Vec> = vec![ + Arc::new(TestFetcher { + kind: FetcherKind::Sourcify, + delay: Some(Duration::ZERO), + contract_name: Some("PartialResult"), + calls: Arc::clone(&successful_calls), + invalid: AtomicBool::new(false), + }), + Arc::new(TestFetcher { + kind: FetcherKind::Etherscan, + delay: None, + contract_name: None, + calls: Arc::clone(&stalled_calls), + invalid: AtomicBool::new(false), + }), + ]; + let mut identifier = test_identifier(fetchers, Duration::from_millis(20)); + let address = Address::with_last_byte(1); + + identifier.fetch_addresses_async(&[address]).await; + + assert!(identifier.remaining_budget.is_zero()); + assert_eq!( + identifier.contracts[&address].1.as_ref().unwrap().contract_name, + "PartialResult" + ); + assert_eq!(successful_calls.load(AtomicOrdering::Relaxed), 1); + assert_eq!(stalled_calls.load(AtomicOrdering::Relaxed), 1); + + identifier.fetch_addresses_async(&[Address::with_last_byte(2)]).await; + assert_eq!(successful_calls.load(AtomicOrdering::Relaxed), 1); + assert_eq!(stalled_calls.load(AtomicOrdering::Relaxed), 1); + } + + #[tokio::test(flavor = "multi_thread")] + async fn timeout_returns_partial_identity() { + let fetchers: Vec> = vec![ + Arc::new(TestFetcher { + kind: FetcherKind::Sourcify, + delay: Some(Duration::ZERO), + contract_name: Some("PartialResult"), + calls: Arc::new(AtomicUsize::new(0)), + invalid: AtomicBool::new(false), + }), + Arc::new(TestFetcher { + kind: FetcherKind::Etherscan, + delay: None, + contract_name: None, + calls: Arc::new(AtomicUsize::new(0)), + invalid: AtomicBool::new(false), + }), + ]; + let mut identifier = test_identifier(fetchers, Duration::from_millis(20)); + let mut node = CallTraceNode::default(); + node.trace.address = Address::with_last_byte(1); + + let identities = identifier.identify_addresses(&[&node]); + + assert_eq!(identities.len(), 1); + assert_eq!(identities[0].label.as_deref(), Some("PartialResult")); + } + + #[tokio::test(start_paused = true)] + async fn timeout_budget_is_cumulative_across_fetches() { + let calls = Arc::new(AtomicUsize::new(0)); + let fetcher: Arc = Arc::new(TestFetcher { + kind: FetcherKind::Sourcify, + delay: Some(Duration::from_millis(20)), + contract_name: Some("FirstResult"), + calls: Arc::clone(&calls), + invalid: AtomicBool::new(false), + }); + let mut identifier = test_identifier(vec![fetcher], Duration::from_millis(30)); + let first = Address::with_last_byte(1); + let second = Address::with_last_byte(2); + + identifier.fetch_addresses_async(&[first]).await; + assert!(identifier.contracts[&first].1.is_some()); + assert!(identifier.remaining_budget < Duration::from_millis(15)); + + identifier.fetch_addresses_async(&[second]).await; + assert!(identifier.remaining_budget.is_zero()); + assert!(!identifier.contracts.contains_key(&second)); + assert_eq!(calls.load(AtomicOrdering::Relaxed), 2); + } + + #[tokio::test(start_paused = true)] + async fn metadata_requests_preserve_cumulative_budget() { + let calls = Arc::new(AtomicUsize::new(0)); + let fetcher: Arc = Arc::new(TestFetcher { + kind: FetcherKind::Etherscan, + delay: None, + contract_name: None, + calls: Arc::clone(&calls), + invalid: AtomicBool::new(false), + }); + let mut identifier = test_identifier(vec![fetcher], Duration::from_millis(30)); + let address = Address::with_last_byte(1); + + assert!(identifier.get_metadata(&[address], Duration::from_millis(10)).await.is_empty()); + assert!(!identifier.remaining_budget.is_zero()); + assert!(identifier.remaining_budget <= Duration::from_millis(20)); + assert!(identifier.get_metadata(&[address], Duration::from_secs(1)).await.is_empty()); + assert!(identifier.remaining_budget.is_zero()); + assert!(identifier.get_metadata(&[address], Duration::from_secs(1)).await.is_empty()); + assert_eq!(calls.load(AtomicOrdering::Relaxed), 2); + } + + #[tokio::test(start_paused = true)] + async fn rate_limit_retries_cannot_escape_timeout_budget() { + let calls = Arc::new(AtomicUsize::new(0)); + let fetcher: Arc = Arc::new(RateLimitedFetcher { + calls: Arc::clone(&calls), + invalid: AtomicBool::new(false), + }); + let mut identifier = test_identifier(vec![fetcher], Duration::from_millis(20)); + + identifier.fetch_addresses_async(&[Address::with_last_byte(1)]).await; + + assert!(identifier.remaining_budget.is_zero()); + assert!(calls.load(AtomicOrdering::Relaxed) > 1); + } + + #[tokio::test(start_paused = true)] + async fn rate_limit_retries_are_bounded() { + let calls = Arc::new(AtomicUsize::new(0)); + let fetcher: Arc = Arc::new(RateLimitedFetcher { + calls: Arc::clone(&calls), + invalid: AtomicBool::new(false), + }); + + let fetched = + ExternalFetcher::new(fetcher, &[Address::with_last_byte(1)]).collect::>().await; + + assert!(fetched.is_empty()); + assert_eq!(calls.load(AtomicOrdering::Relaxed), MAX_TRANSIENT_RETRIES as usize + 1); + } + + struct UnsupportedChainFetcher { + calls: Arc, + invalid: AtomicBool, + } + + #[async_trait::async_trait] + impl ExternalFetcherT for UnsupportedChainFetcher { + fn kind(&self) -> FetcherKind { + FetcherKind::Sourcify + } + + fn timeout(&self) -> Duration { + Duration::ZERO + } + + fn concurrency(&self) -> usize { + 1 + } + + fn invalid_api_key(&self) -> &AtomicBool { + &self.invalid + } + + async fn fetch(&self, _address: Address) -> Result, EtherscanError> { + self.calls.fetch_add(1, AtomicOrdering::Relaxed); + Err(EtherscanError::ChainNotSupported(Chain::from_id(12345))) + } + } + + #[tokio::test] + async fn unsupported_chain_disables_fetcher() { + let calls = Arc::new(AtomicUsize::new(0)); + let mut identifier = test_identifier( + vec![Arc::new(UnsupportedChainFetcher { + calls: Arc::clone(&calls), + invalid: AtomicBool::new(false), + })], + Duration::from_secs(1), + ); + + identifier + .fetch_addresses_async(&[Address::with_last_byte(1), Address::with_last_byte(2)]) + .await; + identifier.fetch_addresses_async(&[Address::with_last_byte(3)]).await; + + assert_eq!(calls.load(AtomicOrdering::Relaxed), 1); + } + + #[test] + fn sourcify_skips_local_chains() { + let config = ExternalIdentifierConfig { timeout: 1, ..Default::default() }; + + assert!(config.identifier(Some(Chain::from_id(31337))).is_none()); + assert!(config.identifier(Some(Chain::dev())).is_none()); + assert!(config.identifier(Some(Chain::mainnet())).is_some()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn foundry_addresses_are_not_fetched() { + let calls = Arc::new(AtomicUsize::new(0)); + let mut identifier = test_identifier( + vec![Arc::new(TestFetcher { + kind: FetcherKind::Sourcify, + delay: Some(Duration::ZERO), + contract_name: None, + calls: Arc::clone(&calls), + invalid: AtomicBool::new(false), + })], + Duration::from_secs(1), + ); + let nodes = [CHEATCODE_ADDRESS, HARDHAT_CONSOLE_ADDRESS].map(|address| { + let mut node = CallTraceNode::default(); + node.trace.address = address; + node + }); + + assert!(identifier.identify_addresses(&[&nodes[0], &nodes[1]]).is_empty()); + assert_eq!(calls.load(AtomicOrdering::Relaxed), 0); + } + + /// Serves one HTTP response with status 400 and `body`, then returns the Sourcify result. + async fn sourcify_bad_request(body: &'static str) -> EtherscanError { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let _ = stream.read(&mut [0; 4096]); + let response = format!( + "HTTP/1.1 400 Bad Request\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).unwrap(); + }); + let mut fetcher = SourcifyFetcher::new(Chain::mainnet()); + fetcher.url = format!("http://{addr}"); + fetcher.fetch(Address::with_last_byte(1)).await.unwrap_err() + } + + #[tokio::test] + async fn sourcify_bad_request_is_final_only_when_recognized() { + let error = + sourcify_bad_request(r#"{"customCode":"unsupported_chain","message":"","errorId":""}"#) + .await; + assert!(matches!(error, EtherscanError::ChainNotSupported(_))); + + let error = + sourcify_bad_request(r#"{"customCode":"invalid_parameter","message":"","errorId":""}"#) + .await; + assert!(matches!(error, EtherscanError::ContractCodeNotVerified(_))); + + let error = + sourcify_bad_request(r#"{"customCode":"other","message":"","errorId":""}"#).await; + assert!(matches!(error, EtherscanError::Unknown(_))); + + let error = sourcify_bad_request("bad request").await; + assert!(matches!(error, EtherscanError::Unknown(_))); + } + + #[tokio::test] + async fn transient_errors_are_not_cached_as_unverified() { + let calls = Arc::new(AtomicUsize::new(0)); + let mut identifier = test_identifier( + vec![Arc::new(ErrorFetcher { + calls: Arc::clone(&calls), + invalid: AtomicBool::new(false), + })], + Duration::from_secs(1), + ); + let address = Address::with_last_byte(1); + + assert!(identifier.get_metadata(&[address], Duration::from_secs(1)).await.is_empty()); + assert!(identifier.get_metadata(&[address], Duration::from_secs(1)).await.is_empty()); + assert_eq!(calls.load(AtomicOrdering::Relaxed), 2); + } + + #[test] + fn etherscan_metadata_takes_precedence() { + let address = Address::with_last_byte(1); + let mut identifier = test_identifier(Vec::new(), Duration::ZERO); + + identifier + .cache_fetched(address, (FetcherKind::Sourcify, Some(metadata("SourcifyResult")))); + identifier.cache_fetched(address, (FetcherKind::Etherscan, None)); + assert_eq!( + identifier.contracts[&address].1.as_ref().unwrap().contract_name, + "SourcifyResult" + ); + + identifier + .cache_fetched(address, (FetcherKind::Etherscan, Some(metadata("EtherscanResult")))); + assert_eq!( + identifier.contracts[&address].1.as_ref().unwrap().contract_name, + "EtherscanResult" + ); + } + + #[tokio::test] + async fn proxy_metadata_preserves_address_identity_and_all_abis() { + let proxy = Address::with_last_byte(1); + let implementation_address = Address::with_last_byte(2); + let mut proxy_metadata = metadata("Proxy"); + proxy_metadata.abi = + r#"[{"anonymous":false,"inputs":[],"name":"ProxyEvent","type":"event"}]"#.to_string(); + proxy_metadata.proxy = 1; + proxy_metadata.implementation = Some(implementation_address); + let mut implementation = metadata("Implementation"); + implementation.abi = + r#"[{"anonymous":false,"inputs":[],"name":"ImplementationEvent","type":"event"}]"# + .to_string(); + let mut identifier = test_identifier(Vec::new(), Duration::from_secs(1)); + let identity = identifier.identify_from_metadata(proxy, &proxy_metadata); + assert_eq!(identity.contract.as_deref(), Some("Proxy")); + identifier.cache_fetched(proxy, (FetcherKind::Etherscan, Some(proxy_metadata))); + identifier + .cache_fetched(implementation_address, (FetcherKind::Etherscan, Some(implementation))); + + let mut results = identifier.get_abis(&[proxy]).await; + let (result_address, result) = results.pop().unwrap(); + let (abis, complete) = result.unwrap(); + let event_names = + abis.into_iter().map(|abi| abi.events.into_keys().next().unwrap()).collect::>(); + + assert_eq!(result_address, proxy); + assert!(complete); + assert_eq!(event_names, ["ImplementationEvent", "ProxyEvent"]); + + identifier.contracts.remove(&implementation_address); + let (_, result) = identifier.get_abis(&[proxy]).await.pop().unwrap(); + let (abis, complete) = result.unwrap(); + assert_eq!(abis.len(), 1); + assert!(!complete); + } + + #[tokio::test] + async fn storage_metadata_does_not_follow_explorer_proxy_hints() { + let proxy = Address::with_last_byte(1); + let implementation_address = Address::with_last_byte(2); + let mut proxy_metadata = metadata("Proxy"); + proxy_metadata.proxy = 1; + proxy_metadata.implementation = Some(implementation_address); + let mut identifier = test_identifier(Vec::new(), Duration::from_secs(1)); + identifier.cache_fetched(proxy, (FetcherKind::Etherscan, Some(proxy_metadata))); + identifier.cache_fetched( + implementation_address, + (FetcherKind::Etherscan, Some(metadata("CurrentImplementation"))), + ); + + let result = identifier.get_metadata(&[proxy], Duration::from_secs(1)).await; + + assert_eq!(result[&proxy].as_ref().unwrap().contract_name, "Proxy"); + assert_eq!(result.len(), 1); + } +} diff --git a/crates/evm/traces/src/identifier/local.rs b/crates/evm/traces/src/identifier/local.rs index 21beff0725d59..d9066ef342e65 100644 --- a/crates/evm/traces/src/identifier/local.rs +++ b/crates/evm/traces/src/identifier/local.rs @@ -1,7 +1,7 @@ use super::{IdentifiedAddress, TraceIdentifier}; use alloy_dyn_abi::JsonAbiExt; use alloy_json_abi::JsonAbi; -use alloy_primitives::{Address, Bytes, map::HashMap}; +use alloy_primitives::{Bytes, map::AddressHashMap}; use foundry_common::contracts::{ContractsByArtifact, bytecode_diff_score}; use foundry_compilers::ArtifactId; use revm_inspectors::tracing::types::CallTraceNode; @@ -14,12 +14,11 @@ pub struct LocalTraceIdentifier<'a> { /// Vector of pairs of artifact ID and the runtime code length of the given artifact. ordered_ids: Vec<(&'a ArtifactId, usize)>, /// The contracts bytecode. - contracts_bytecode: Option<&'a HashMap>, + contracts_bytecode: Option<&'a AddressHashMap>, } impl<'a> LocalTraceIdentifier<'a> { /// Creates a new local trace identifier. - #[inline] pub fn new(known_contracts: &'a ContractsByArtifact) -> Self { let mut ordered_ids = known_contracts .iter() @@ -30,14 +29,14 @@ impl<'a> LocalTraceIdentifier<'a> { Self { known_contracts, ordered_ids, contracts_bytecode: None } } - pub fn with_bytecodes(mut self, contracts_bytecode: &'a HashMap) -> Self { + pub const fn with_bytecodes(mut self, contracts_bytecode: &'a AddressHashMap) -> Self { self.contracts_bytecode = Some(contracts_bytecode); self } /// Returns the known contracts. #[inline] - pub fn contracts(&self) -> &'a ContractsByArtifact { + pub const fn contracts(&self) -> &'a ContractsByArtifact { self.known_contracts } @@ -46,7 +45,7 @@ impl<'a> LocalTraceIdentifier<'a> { &self, runtime_code: &[u8], creation_code: &[u8], - ) -> Option<(&'a ArtifactId, &'a JsonAbi)> { + ) -> Option<(&'a ArtifactId, &'a JsonAbi, Option)> { let len = runtime_code.len(); let mut min_score = f64::MAX; @@ -63,6 +62,7 @@ impl<'a> LocalTraceIdentifier<'a> { if let Some(bytecode) = contract_bytecode { let mut current_bytecode = current_bytecode; + let mut constructor_args_offset = None; if is_creation && current_bytecode.len() > bytecode.len() { // Try to decode ctor args with contract abi. if let Some(constructor) = contract.abi.constructor() { @@ -70,7 +70,8 @@ impl<'a> LocalTraceIdentifier<'a> { if constructor.abi_decode_input(constructor_args).is_ok() { // If we can decode args with current abi then remove args from // code to compare. - current_bytecode = ¤t_bytecode[..bytecode.len()] + current_bytecode = ¤t_bytecode[..bytecode.len()]; + constructor_args_offset = Some(bytecode.len()); } } } @@ -78,38 +79,38 @@ impl<'a> LocalTraceIdentifier<'a> { let score = bytecode_diff_score(&bytecode, current_bytecode); if score == 0.0 { trace!(target: "evm::traces::local", "found exact match"); - return Some((id, &contract.abi)); + return Some((id, &contract.abi, constructor_args_offset)); } if score < *min_score { *min_score = score; - min_score_id = Some((id, &contract.abi)); + min_score_id = Some((id, &contract.abi, constructor_args_offset)); } } None }; - // Check `[len * 0.9, ..., len * 1.1]`. - let max_len = (len * 11) / 10; + if !creation_code.is_empty() { + // Check `[len * 0.9, ..., len * 1.1]`. + let max_len = (len * 11) / 10; - // Start at artifacts with the same code length: `len..len*1.1`. - let same_length_idx = self.find_index(len); - for idx in same_length_idx..self.ordered_ids.len() { - let (id, len) = self.ordered_ids[idx]; - if len > max_len { - break; - } - if let found @ Some(_) = check(id, true, &mut min_score) { - return found; + // Start at artifacts with the same code length: `len..len*1.1`. + let same_length_idx = self.find_index(len); + for &(id, len) in &self.ordered_ids[same_length_idx..] { + if len > max_len { + break; + } + if let found @ Some(_) = check(id, true, &mut min_score) { + return found; + } } - } - // Iterate over the remaining artifacts with less code length: `len*0.9..len`. - let min_len = (len * 9) / 10; - let idx = self.find_index(min_len); - for i in idx..same_length_idx { - let (id, _) = self.ordered_ids[i]; - if let found @ Some(_) = check(id, true, &mut min_score) { - return found; + // Iterate over the remaining artifacts with less code length: `len*0.9..len`. + let min_len = (len * 9) / 10; + let idx = self.find_index(min_len); + for &(id, _) in &self.ordered_ids[idx..same_length_idx] { + if let found @ Some(_) = check(id, true, &mut min_score) { + return found; + } } } @@ -176,7 +177,8 @@ impl TraceIdentifier for LocalTraceIdentifier<'_> { (code.as_ref(), &[] as &[u8]) } }; - let (id, abi) = self.identify_code(runtime_code, creation_code)?; + let (id, abi, constructor_args_offset) = + self.identify_code(runtime_code, creation_code)?; trace!(target: "evm::traces::local", id=%id.identifier(), "identified"); Some(IdentifiedAddress { @@ -184,6 +186,7 @@ impl TraceIdentifier for LocalTraceIdentifier<'_> { contract: Some(id.identifier()), label: Some(id.name.clone()), abi: Some(Cow::Borrowed(abi)), + constructor_args_offset, artifact_id: Some(id.clone()), }) }) diff --git a/crates/evm/traces/src/identifier/mod.rs b/crates/evm/traces/src/identifier/mod.rs index 8ad929b7317a0..ce9000b3771ee 100644 --- a/crates/evm/traces/src/identifier/mod.rs +++ b/crates/evm/traces/src/identifier/mod.rs @@ -1,5 +1,5 @@ use alloy_json_abi::JsonAbi; -use alloy_primitives::{Address, Bytes, map::HashMap}; +use alloy_primitives::{Address, Bytes, map::AddressHashMap}; use foundry_common::ContractsByArtifact; use foundry_compilers::ArtifactId; use foundry_config::{Chain, Config}; @@ -9,13 +9,14 @@ use std::borrow::Cow; mod local; pub use local::LocalTraceIdentifier; -mod etherscan; -pub use etherscan::EtherscanIdentifier; +mod external; +pub use external::{ExternalIdentifier, ExternalIdentifierConfig}; mod signatures; pub use signatures::{SignaturesCache, SignaturesIdentifier}; /// An address identified by a [`TraceIdentifier`]. +#[derive(Debug)] pub struct IdentifiedAddress<'a> { /// The address. pub address: Address, @@ -27,6 +28,8 @@ pub struct IdentifiedAddress<'a> { pub contract: Option, /// The ABI of the contract at this address. pub abi: Option>, + /// Byte offset where ABI-encoded constructor arguments begin in the creation input. + pub constructor_args_offset: Option, /// The artifact ID of the contract, if any. pub artifact_id: Option, } @@ -41,8 +44,8 @@ pub trait TraceIdentifier { pub struct TraceIdentifiers<'a> { /// The local trace identifier. pub local: Option>, - /// The optional Etherscan trace identifier. - pub etherscan: Option, + /// The optional external trace identifier. + pub external: Option, } impl Default for TraceIdentifiers<'_> { @@ -53,6 +56,10 @@ impl Default for TraceIdentifiers<'_> { impl TraceIdentifier for TraceIdentifiers<'_> { fn identify_addresses(&mut self, nodes: &[&CallTraceNode]) -> Vec> { + if nodes.is_empty() { + return Vec::new(); + } + let mut identities = Vec::with_capacity(nodes.len()); if let Some(local) = &mut self.local { identities.extend(local.identify_addresses(nodes)); @@ -60,8 +67,8 @@ impl TraceIdentifier for TraceIdentifiers<'_> { return identities; } } - if let Some(etherscan) = &mut self.etherscan { - identities.extend(etherscan.identify_addresses(nodes)); + if let Some(external) = &mut self.external { + identities.extend(external.identify_addresses(nodes)); } identities } @@ -70,7 +77,7 @@ impl TraceIdentifier for TraceIdentifiers<'_> { impl<'a> TraceIdentifiers<'a> { /// Creates a new, empty instance. pub const fn new() -> Self { - Self { local: None, etherscan: None } + Self { local: None, external: None } } /// Sets the local identifier. @@ -83,21 +90,21 @@ impl<'a> TraceIdentifiers<'a> { pub fn with_local_and_bytecodes( mut self, known_contracts: &'a ContractsByArtifact, - contracts_bytecode: &'a HashMap, + contracts_bytecode: &'a AddressHashMap, ) -> Self { self.local = Some(LocalTraceIdentifier::new(known_contracts).with_bytecodes(contracts_bytecode)); self } - /// Sets the etherscan identifier. - pub fn with_etherscan(mut self, config: &Config, chain: Option) -> eyre::Result { - self.etherscan = EtherscanIdentifier::new(config, chain)?; + /// Sets the external identifier. + pub fn with_external(mut self, config: &Config, chain: Option) -> eyre::Result { + self.external = ExternalIdentifier::new(config, chain)?; Ok(self) } /// Returns `true` if there are no set identifiers. - pub fn is_empty(&self) -> bool { - self.local.is_none() && self.etherscan.is_none() + pub const fn is_empty(&self) -> bool { + self.local.is_none() && self.external.is_none() } } diff --git a/crates/evm/traces/src/identifier/signatures.rs b/crates/evm/traces/src/identifier/signatures.rs index 22b35f952c060..767f6437327dc 100644 --- a/crates/evm/traces/src/identifier/signatures.rs +++ b/crates/evm/traces/src/identifier/signatures.rs @@ -1,5 +1,8 @@ use alloy_json_abi::{Error, Event, Function, JsonAbi}; -use alloy_primitives::{B256, Selector, map::HashMap}; +use alloy_primitives::{ + B256, Selector, + map::{HashMap, HashSet}, +}; use eyre::Result; use foundry_common::{ abi::{get_error, get_event, get_func}, @@ -59,11 +62,15 @@ impl From<&SignaturesCache> for SignaturesDiskCache { let (functions, errors, events) = value.signatures.iter().fold( (BTreeMap::new(), BTreeMap::new(), BTreeMap::new()), |mut acc, (kind, signature)| { - let value = signature.clone().unwrap_or_default(); - match *kind { - SelectorKind::Function(selector) => _ = acc.0.insert(selector, value), - SelectorKind::Error(selector) => _ = acc.1.insert(selector, value), - SelectorKind::Event(selector) => _ = acc.2.insert(selector, value), + // Only persist resolved signatures. Unknown selectors (None) are kept + // in-memory for session dedup but not written to disk, so they can be + // re-queried in future sessions once the signature database is updated. + if let Some(value) = signature.clone() { + match *kind { + SelectorKind::Function(selector) => _ = acc.0.insert(selector, value), + SelectorKind::Error(selector) => _ = acc.1.insert(selector, value), + SelectorKind::Event(selector) => _ = acc.2.insert(selector, value), + } } acc }, @@ -83,7 +90,7 @@ impl Serialize for SignaturesCache { impl SignaturesCache { /// Loads the cache from a file. - #[instrument(target = "evm::traces")] + #[instrument(target = "evm::traces", name = "SignaturesCache::load")] pub fn load(path: &Path) -> Self { trace!(target: "evm::traces", ?path, "reading signature cache"); fs::read_json_file(path) @@ -94,7 +101,7 @@ impl SignaturesCache { } /// Saves the cache to a file. - #[instrument(target = "evm::traces", skip(self))] + #[instrument(target = "evm::traces", name = "SignaturesCache::save", skip(self))] pub fn save(&self, path: &Path) { if let Some(parent) = path.parent() && let Err(err) = std::fs::create_dir_all(parent) @@ -110,7 +117,28 @@ impl SignaturesCache { /// Updates the cache from an ABI. pub fn extend_from_abi(&mut self, abi: &JsonAbi) { - self.extend(abi.items().filter_map(|item| match item { + self.extend(Self::signatures_from_abi(abi)); + } + + /// Updates the cache from ABIs without overwriting previous entries from the same batch. + fn extend_from_abis_without_collisions<'a>( + &mut self, + abis: impl IntoIterator, + ) { + let mut seeded: HashSet = HashSet::default(); + for abi in abis { + for (selector, signature) in Self::signatures_from_abi(abi) { + if seeded.insert(selector) { + self.insert(selector, signature); + } else { + trace!(target: "evm::traces", ?selector, %signature, "skipping duplicate ABI signature"); + } + } + } + } + + fn signatures_from_abi(abi: &JsonAbi) -> impl Iterator + '_ { + abi.items().filter_map(|item| match item { alloy_json_abi::AbiItem::Function(f) => { Some((SelectorKind::Function(f.selector()), f.signature())) } @@ -121,7 +149,7 @@ impl SignaturesCache { Some((SelectorKind::Event(e.selector()), e.full_signature())) } _ => None, - })); + }) } /// Inserts a single signature into the cache. @@ -149,9 +177,14 @@ impl SignaturesCache { /// An identifier that tries to identify functions and events using signatures found at /// `https://openchain.xyz` or a local cache. #[derive(Clone, Debug)] -pub struct SignaturesIdentifier { +pub struct SignaturesIdentifier(Arc); + +#[derive(Debug)] +struct SignaturesIdentifierInner { /// Cached selectors for functions, events and custom errors. - cache: Arc>, + cache: RwLock, + /// ABI events keyed by topic0 and indexed topic count. + local_events: HashMap<(B256, usize), Vec>, /// Location where to save the signature cache. cache_path: Option, /// The OpenChain client to fetch signatures from. `None` if disabled on construction. @@ -166,7 +199,12 @@ impl SignaturesIdentifier { /// Creates a new `SignaturesIdentifier` from the global configuration. pub fn from_config(config: &Config) -> Result { - Self::new(config.offline) + Self::new(config.offline || config.tracing.external_identification_timeout == 0) + } + + /// Creates an offline `SignaturesIdentifier` with the default cache directory and local ABIs. + pub fn new_offline_with_abis<'a>(abis: impl IntoIterator) -> Result { + Ok(Self::new_offline_with_abis_from_cache(Config::foundry_cache_dir().as_deref(), abis)) } /// Creates a new `SignaturesIdentifier`. @@ -174,25 +212,72 @@ impl SignaturesIdentifier { /// - `cache_dir` is the cache directory to store the signatures. /// - `offline` disables the OpenChain client. pub fn new_with(cache_dir: Option<&Path>, offline: bool) -> Result { - let client = if !offline { Some(OpenChainClient::new()?) } else { None }; - let (cache, cache_path) = if let Some(cache_dir) = cache_dir { + let client = if offline { None } else { Some(OpenChainClient::new()?) }; + Ok(Self::from_cache(Self::load_cache(cache_dir), client)) + } + + fn new_offline_with_abis_from_cache<'a>( + cache_dir: Option<&Path>, + abis: impl IntoIterator, + ) -> Self { + let abis = abis.into_iter().collect::>(); + let (mut cache, cache_path) = Self::load_cache(cache_dir); + cache.extend_from_abis_without_collisions(abis.iter().copied()); + let local_events = Self::local_events_from_abis(abis); + Self::from_cache_and_events((cache, cache_path), None, local_events) + } + + fn load_cache(cache_dir: Option<&Path>) -> (SignaturesCache, Option) { + if let Some(cache_dir) = cache_dir { let path = cache_dir.join("signatures"); let cache = SignaturesCache::load(&path); (cache, Some(path)) } else { Default::default() - }; - Ok(Self { cache: Arc::new(RwLock::new(cache)), cache_path, client }) + } + } + + fn from_cache( + (cache, cache_path): (SignaturesCache, Option), + client: Option, + ) -> Self { + Self::from_cache_and_events((cache, cache_path), client, Default::default()) + } + + fn from_cache_and_events( + (cache, cache_path): (SignaturesCache, Option), + client: Option, + local_events: HashMap<(B256, usize), Vec>, + ) -> Self { + Self(Arc::new(SignaturesIdentifierInner { + cache: RwLock::new(cache), + local_events, + cache_path, + client, + })) + } + + fn local_events_from_abis<'a>( + abis: impl IntoIterator, + ) -> HashMap<(B256, usize), Vec> { + let mut local_events: HashMap<(B256, usize), Vec> = HashMap::default(); + for abi in abis { + for event in abi.events() { + local_events + .entry(( + event.selector(), + event.inputs.iter().filter(|input| input.indexed).count(), + )) + .or_default() + .push(event.clone()); + } + } + local_events } /// Saves the cache to the file system. pub fn save(&self) { - if let Some(path) = &self.cache_path { - self.cache - .try_read() - .expect("SignaturesIdentifier cache is locked while attempting to save") - .save(path); - } + self.0.save(); } /// Identifies `Function`s. @@ -221,6 +306,20 @@ impl SignaturesIdentifier { self.identify_events([identifier]).await.pop().unwrap() } + /// Identifies an `Event`, preferring local ABI events with the matching indexed topic count. + pub async fn identify_event_with_indexed_count( + &self, + identifier: B256, + indexed_count: usize, + ) -> Option { + if let Some(events) = self.0.local_events.get(&(identifier, indexed_count)) + && let Some(event) = events.first() + { + return Some(event.clone()); + } + self.identify_event(identifier).await + } + /// Identifies `Error`s. pub async fn identify_errors( &self, @@ -241,21 +340,26 @@ impl SignaturesIdentifier { } trace!(target: "evm::traces", ?selectors, "identifying selectors"); - let mut cache_r = self.cache.read().await; - if let Some(client) = &self.client { - let query = - selectors.iter().copied().filter(|v| !cache_r.contains_key(v)).collect::>(); - if !query.is_empty() { - drop(cache_r); - let mut cache_w = self.cache.write().await; - if let Ok(res) = client.decode_selectors(&query).await { - for (selector, signatures) in std::iter::zip(query, res) { - cache_w.signatures.insert(selector, signatures.into_iter().next()); - } + let mut cache_r = self.0.cache.read().await; + if let Some(client) = &self.0.client + && selectors.iter().any(|selector| !cache_r.contains_key(selector)) + { + drop(cache_r); + let mut cache_w = self.0.cache.write().await; + let query = selectors + .iter() + .copied() + .filter(|selector| !cache_w.contains_key(selector)) + .collect::>(); + if !query.is_empty() + && let Ok(res) = client.decode_selectors(&query).await + { + for (selector, signatures) in std::iter::zip(query, res) { + cache_w.signatures.insert(selector, signatures.into_iter().next()); } - drop(cache_w); - cache_r = self.cache.read().await; } + drop(cache_w); + cache_r = self.0.cache.read().await; } selectors.iter().map(|selector| cache_r.get(selector).unwrap_or_default()).collect() } @@ -270,8 +374,126 @@ impl SignaturesIdentifier { } } -impl Drop for SignaturesIdentifier { +impl SignaturesIdentifierInner { + fn save(&self) { + // We only identify new signatures if the client is enabled. + if let Some(path) = &self.cache_path + && self.client.is_some() + { + self.cache + .try_read() + .expect("SignaturesIdentifier cache is locked while attempting to save") + .save(path); + } + } +} + +impl Drop for SignaturesIdentifierInner { fn drop(&mut self) { self.save(); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn zero_external_identification_timeout_disables_client() { + let mut config = Config::default(); + config.tracing.external_identification_timeout = 0; + + let identifier = SignaturesIdentifier::from_config(&config).unwrap(); + + assert!(identifier.0.client.is_none()); + } + + #[test] + fn unknown_signatures_not_persisted_to_disk() { + let known_selector = SelectorKind::Function(Selector::from([0xaa, 0xbb, 0xcc, 0xdd])); + let unknown_selector = SelectorKind::Error(Selector::from([0x11, 0x22, 0x33, 0x44])); + + let mut cache = SignaturesCache::default(); + cache.signatures.insert(known_selector, Some("transfer(address,uint256)".into())); + cache.signatures.insert(unknown_selector, None); + + // Verify both are in memory. + assert!(cache.contains_key(&known_selector)); + assert!(cache.contains_key(&unknown_selector)); + + // Round-trip through the disk format. + let disk: SignaturesDiskCache = (&cache).into(); + let reloaded = SignaturesCache::from(disk); + + // Known signature survives the round-trip. + assert_eq!(reloaded.get(&known_selector), Some(Some("transfer(address,uint256)".into()))); + // Unknown signature is gone — it will be re-queried next session. + assert_eq!(reloaded.get(&unknown_selector), None); + assert!(!reloaded.contains_key(&unknown_selector)); + } + + #[tokio::test] + async fn abi_seeded_signatures_are_not_persisted_to_disk() { + let temp = tempfile::tempdir().unwrap(); + let event = Event::parse("event CodexEphemeral(uint256 indexed value)").unwrap(); + let mut abi = JsonAbi::new(); + abi.events.insert(event.name.clone(), vec![event.clone()]); + + { + let identifier = + SignaturesIdentifier::new_offline_with_abis_from_cache(Some(temp.path()), [&abi]); + let decoded = identifier.identify_event(event.selector()).await; + assert_eq!(decoded.as_ref().map(Event::full_signature), Some(event.full_signature())); + identifier.save(); + } + + let reloaded = SignaturesCache::load(&temp.path().join("signatures")); + assert!(!reloaded.contains_key(&SelectorKind::Event(event.selector()))); + } + + #[test] + fn abi_seeded_collisions_keep_first_signature() { + let first = Event::parse("event CodexCollision(uint256 indexed value)").unwrap(); + let second = Event::parse("event CodexCollision(uint256 value)").unwrap(); + + let mut first_abi = JsonAbi::new(); + first_abi.events.insert(first.name.clone(), vec![first.clone()]); + let mut second_abi = JsonAbi::new(); + second_abi.events.insert(second.name.clone(), vec![second]); + + let mut cache = SignaturesCache::default(); + cache.extend_from_abis_without_collisions([&first_abi, &second_abi]); + + assert_eq!( + cache.get(&SelectorKind::Event(first.selector())), + Some(Some(first.full_signature())) + ); + } + + #[tokio::test] + async fn abi_seeded_events_prefer_matching_indexed_count() { + let one_topic = + Event::parse("event CodexIndexedCount(uint256 indexed marker, uint256 value)").unwrap(); + let two_topics = + Event::parse("event CodexIndexedCount(uint256 indexed marker, uint256 indexed value)") + .unwrap(); + + let mut one_topic_abi = JsonAbi::new(); + one_topic_abi.events.insert(one_topic.name.clone(), vec![one_topic.clone()]); + let mut two_topics_abi = JsonAbi::new(); + two_topics_abi.events.insert(two_topics.name.clone(), vec![two_topics.clone()]); + + let identifier = SignaturesIdentifier::new_offline_with_abis_from_cache( + None, + [&two_topics_abi, &one_topic_abi], + ); + + let decoded_one_topic = + identifier.identify_event_with_indexed_count(one_topic.selector(), 1).await.unwrap(); + let decoded_two_topics = + identifier.identify_event_with_indexed_count(two_topics.selector(), 2).await.unwrap(); + + assert_eq!(decoded_one_topic.full_signature(), one_topic.full_signature()); + assert_eq!(decoded_two_topics.full_signature(), two_topics.full_signature()); + } +} diff --git a/crates/evm/traces/src/lib.rs b/crates/evm/traces/src/lib.rs index 5417f47e90f0c..80610682843ce 100644 --- a/crates/evm/traces/src/lib.rs +++ b/crates/evm/traces/src/lib.rs @@ -3,7 +3,7 @@ //! EVM trace identifying and decoding. #![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] +#![cfg_attr(docsrs, feature(doc_cfg))] #[macro_use] extern crate foundry_common; @@ -15,26 +15,83 @@ use foundry_common::{ contracts::{ContractsByAddress, ContractsByArtifact}, shell, }; +use foundry_config::{Chain, Config}; +use foundry_evm_hardforks::{FoundryHardfork, TempoHardfork}; +use foundry_evm_networks::NetworkConfigs; use revm::bytecode::opcode::OpCode; -use revm_inspectors::tracing::{ - OpcodeFilter, - types::{DecodedTraceStep, TraceMemberOrder}, -}; +use revm_inspectors::tracing::OpcodeFilter; use serde::{Deserialize, Serialize}; use std::{ borrow::Cow, - collections::BTreeSet, + collections::{BTreeMap, BTreeSet}, + fmt, ops::{Deref, DerefMut}, }; -use alloy_primitives::map::HashMap; +use alloy_primitives::{Address, U256, map::HashMap}; +use tempo_contracts::precompiles::TIP20_CHANNEL_RESERVE_ADDRESS; + +/// Network context used to identify and decode execution traces. +#[derive(Clone, Copy, Debug)] +pub struct TraceContext { + chain: Chain, + networks: NetworkConfigs, + hardfork: Option, +} + +impl TraceContext { + pub const fn new( + chain: Chain, + networks: NetworkConfigs, + hardfork: Option, + ) -> Self { + Self { chain, networks, hardfork } + } + + pub const fn chain(self) -> Chain { + self.chain + } + + pub const fn networks(self) -> NetworkConfigs { + self.networks + } + + pub const fn hardfork(self) -> Option { + self.hardfork + } + + pub const fn with_hardfork(mut self, hardfork: Option) -> Self { + self.hardfork = hardfork; + self + } + + /// Completes metadata for a remotely executed trace before decoding. + /// Locally executed traces must use [`Self::hardfork`] directly, including `None`, so a + /// configured hardfork cannot replace an explicit execution-spec override. + pub fn decoding_hardfork(self, config: &Config) -> Option { + let execution_network = self.networks.execution_network(); + let mut hardfork = self + .hardfork + .or(config.hardfork) + .filter(|hardfork| hardfork.namespace() == execution_network.hardfork_namespace()); + if hardfork.is_none() && execution_network.is_tempo() { + hardfork = Some(config.evm_spec_id::().into()); + } + #[cfg(feature = "monad")] + if hardfork.is_none() && execution_network.is_monad() { + hardfork = Some(config.evm_spec_id::().into()); + } + hardfork + } +} pub use revm_inspectors::tracing::{ CallTraceArena, FourByteInspector, GethTraceBuilder, ParityTraceBuilder, StackSnapshotType, TraceWriter, TracingInspector, TracingInspectorConfig, types::{ - CallKind, CallLog, CallTrace, CallTraceNode, DecodedCallData, DecodedCallLog, - DecodedCallTrace, + CallKind, CallLog, CallTrace, CallTraceNode, CallTraceStep, DecodedCallData, + DecodedCallLog, DecodedCallTrace, DecodedInternalCall, DecodedTraceStep, RecordedMemory, + StorageChange, StorageChangeReason, TraceMemberOrder, }, }; @@ -45,17 +102,43 @@ pub mod identifier; use identifier::LocalTraceIdentifier; mod decoder; -pub use decoder::{CallTraceDecoder, CallTraceDecoderBuilder}; +pub use decoder::{CallTraceDecoder, CallTraceDecoderBuilder, DecodedEvent}; pub mod debug; pub use debug::DebugTraceIdentifier; pub mod folded_stack_trace; +pub mod backtrace; +pub mod speedscope; + +pub mod erc8021; + pub type Traces = Vec<(TraceKind, SparsedTraceArena)>; +/// Presentation-only detail for an otherwise empty EVM revert. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub enum RevertDiagnostic { + /// A call targeted an address without code. + CallToNonContract(Address), + /// A delegate call targeted an address without code. + DelegateCallToNonContract(Address), +} + +impl fmt::Display for RevertDiagnostic { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::CallToNonContract(addr) => write!(f, "call to non-contract address {addr}"), + Self::DelegateCallToNonContract(addr) => write!( + f, + "delegatecall to non-contract address {addr} (usually an unliked library)" + ), + } + } +} + /// Trace arena keeping track of ignored trace items. -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Deserialize)] pub struct SparsedTraceArena { /// Full trace arena. #[serde(flatten)] @@ -64,13 +147,55 @@ pub struct SparsedTraceArena { /// See `foundry_cheatcodes::utils::IgnoredTraces` for more information. #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub ignored: HashMap<(usize, usize), (usize, usize)>, + /// Presentation-only revert diagnostics, keyed by trace node index. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub diagnostics: HashMap, +} + +impl Serialize for SparsedTraceArena { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + #[derive(Serialize)] + struct ResolvedArena<'a> { + #[serde(flatten)] + arena: &'a CallTraceArena, + #[serde(skip_serializing_if = "HashMap::is_empty")] + ignored: &'a HashMap<(usize, usize), (usize, usize)>, + } + + ResolvedArena { arena: &self.resolve_diagnostics(), ignored: &self.ignored } + .serialize(serializer) + } } impl SparsedTraceArena { + /// Applies presentation-only diagnostics to the provided arena. + fn apply_diagnostics(&self, arena: &mut CallTraceArena) { + for (&node_idx, diagnostic) in &self.diagnostics { + if let Some(node) = arena.nodes_mut().get_mut(node_idx) { + node.trace.decoded.get_or_insert_default().return_data = + Some(diagnostic.to_string()); + } + } + } + + /// Applies presentation-only diagnostics without consuming ignored trace ranges. + fn resolve_diagnostics(&self) -> Cow<'_, CallTraceArena> { + if self.diagnostics.is_empty() { + Cow::Borrowed(&self.arena) + } else { + let mut arena = self.arena.clone(); + self.apply_diagnostics(&mut arena); + Cow::Owned(arena) + } + } + /// Goes over entire trace arena and removes ignored trace items. fn resolve_arena(&self) -> Cow<'_, CallTraceArena> { if self.ignored.is_empty() { - Cow::Borrowed(&self.arena) + self.resolve_diagnostics() } else { let mut arena = self.arena.clone(); @@ -109,8 +234,8 @@ impl SparsedTraceArena { // we only remove decoded internal calls if they did not start/pause tracing Some(TraceMemberOrder::Step(step_idx)) => { // If this is an internal call beginning, track it in `internal_calls` - if let Some(DecodedTraceStep::InternalCall(_, end_step_idx)) = - &nodes[node_idx].trace.steps[step_idx].decoded + if let Some(decoded) = &nodes[node_idx].trace.steps[step_idx].decoded + && let DecodedTraceStep::InternalCall(_, end_step_idx) = &**decoded { internal_calls.push((item_idx, remove, *end_step_idx)); // we decide if we should remove it later @@ -153,6 +278,8 @@ impl SparsedTraceArena { clear_node(arena.nodes_mut(), 0, &self.ignored, &mut None); + self.apply_diagnostics(&mut arena); + Cow::Owned(arena) } } @@ -185,6 +312,75 @@ pub fn render_trace_arena(arena: &SparsedTraceArena) -> String { render_trace_arena_inner(arena, false, false) } +/// Prunes trace depth if depth is provided as an argument. +pub fn prune_trace_depth(arena: &mut CallTraceArena, depth: usize) { + for node in arena.nodes_mut() { + if node.trace.depth >= depth { + node.ordering.clear(); + } + } +} + +/// Returns a serializable trace arena containing only nodes visible at `depth`. +pub fn trace_arena_at_depth(arena: &SparsedTraceArena, depth: usize) -> SparsedTraceArena { + let mut arena = arena.resolve_arena().into_owned(); + let nodes = arena.nodes_mut(); + let mut reachable = vec![false; nodes.len()]; + let mut pending = vec![0]; + while let Some(node_idx) = pending.pop() { + if reachable[node_idx] { + continue; + } + reachable[node_idx] = true; + let node = &nodes[node_idx]; + if node.trace.depth < depth { + pending.extend(node.ordering.iter().filter_map(|item| match item { + TraceMemberOrder::Call(child_idx) => Some(node.children[*child_idx]), + _ => None, + })); + } + } + + let mut remapped = vec![None; nodes.len()]; + for (next_idx, node) in nodes.iter_mut().filter(|node| reachable[node.idx]).enumerate() { + remapped[node.idx] = Some(next_idx); + if node.trace.depth >= depth { + node.ordering.clear(); + node.children.clear(); + } else { + let mut child_positions = vec![None; node.children.len()]; + let mut children = Vec::with_capacity(node.children.len()); + for (old_position, child) in node.children.iter().copied().enumerate() { + if reachable[child] { + child_positions[old_position] = Some(children.len()); + children.push(child); + } + } + node.children = children; + node.ordering = std::mem::take(&mut node.ordering) + .into_iter() + .filter_map(|item| match item { + TraceMemberOrder::Call(child_idx) => { + Some(TraceMemberOrder::Call(child_positions[child_idx]?)) + } + item => Some(item), + }) + .collect(); + } + } + + nodes.retain(|node| reachable[node.idx]); + for node in nodes { + node.idx = remapped[node.idx].expect("retained trace node has a remapped index"); + node.parent = node.parent.and_then(|parent| remapped[parent]); + for child in &mut node.children { + *child = remapped[*child].expect("retained trace child has a remapped index"); + } + } + + SparsedTraceArena { arena, ignored: Default::default(), diagnostics: Default::default() } +} + /// Render a collection of call traces to a string optionally including contract creation bytecodes /// and in JSON format. pub fn render_trace_arena_inner( @@ -193,7 +389,33 @@ pub fn render_trace_arena_inner( with_storage_changes: bool, ) -> String { if shell::is_json() { - return serde_json::to_string(&arena.resolve_arena()).expect("Failed to write traces"); + return serde_json::to_string(&arena.resolve_arena()).expect("Failed to serialize traces"); + } + + let mut resolved = arena.resolve_arena(); + + let mut tempo_changes = None; + if with_storage_changes { + tempo_changes = tempo_channel_storage_decodes(&resolved); + + let needs_dedup = resolved.as_ref().nodes().iter().any(|node| { + node.trace.steps.iter().any(|step| { + step.storage_change.is_some() + && matches!(step.decoded.as_deref(), Some(DecodedTraceStep::Line(_))) + }) + }); + if needs_dedup { + // Remove storage text that is already represented by an opcode line. + for node in resolved.to_mut().nodes_mut() { + for step in &mut node.trace.steps { + if step.storage_change.is_some() + && matches!(step.decoded.as_deref(), Some(DecodedTraceStep::Line(_))) + { + step.storage_change = None; + } + } + } + } } let mut w = TraceWriter::new(Vec::::new()) @@ -201,11 +423,85 @@ pub fn render_trace_arena_inner( .use_colors(convert_color_choice(shell::color_choice())) .write_bytecodes(with_bytecodes) .with_storage_changes(with_storage_changes); - w.write_arena(&arena.resolve_arena()).expect("Failed to write traces"); - String::from_utf8(w.into_writer()).expect("trace writer wrote invalid UTF-8") + w.write_arena(resolved.as_ref()).expect("Failed to write traces"); + let mut rendered = + String::from_utf8(w.into_writer()).expect("trace writer wrote invalid UTF-8"); + if let Some(tempo_changes) = tempo_changes { + if !rendered.ends_with('\n') { + rendered.push('\n'); + } + rendered.push_str(&tempo_changes); + } + + rendered +} + +fn tempo_channel_storage_decodes(arena: &CallTraceArena) -> Option { + let decoded_changes = arena + .nodes() + .iter() + .filter(|node| node.trace.address == TIP20_CHANNEL_RESERVE_ADDRESS) + .flat_map(compact_channel_storage_changes) + .collect::>(); + + if decoded_changes.is_empty() { + return None; + } + + let mut rendered = String::new(); + rendered.push_str("Decoded TIP20ChannelReserve storage:\n"); + for (slot, before, after) in decoded_changes { + rendered.push_str(&format!( + " @ {}: {} -> {}\n", + format_storage_word(slot), + format_channel_state(before), + format_channel_state(after), + )); + } + Some(rendered) } -fn convert_color_choice(choice: shell::ColorChoice) -> revm_inspectors::ColorChoice { +fn compact_channel_storage_changes(node: &CallTraceNode) -> Vec<(U256, U256, U256)> { + let mut changes_map = BTreeMap::new(); + for step in &node.trace.steps { + if let Some(change) = &step.storage_change + && change.had_value.is_some() + { + let (_first, last) = changes_map.entry(change.key).or_insert((&**change, &**change)); + *last = &**change; + } + } + + changes_map + .into_iter() + .filter_map(|(key, (first, last))| { + let before = first.had_value.unwrap_or_default(); + let after = last.value; + (before != after).then_some((key, before, after)) + }) + .collect() +} + +fn format_channel_state(value: U256) -> String { + let (settled, deposit, close_requested_at) = decode_channel_state(value); + format!("{{settled: {settled}, deposit: {deposit}, closeRequestedAt: {close_requested_at}}}") +} + +fn decode_channel_state(value: U256) -> (U256, U256, u32) { + let mask96 = (U256::ONE << 96) - U256::ONE; + let mask32 = (U256::ONE << 32) - U256::ONE; + let settled: U256 = value & mask96; + let deposit: U256 = (value >> 96usize) & mask96; + let close_requested_at_word: U256 = (value >> 192usize) & mask32; + let close_requested_at = close_requested_at_word.to::(); + (settled, deposit, close_requested_at) +} + +fn format_storage_word(value: U256) -> String { + if value < U256::from(1_000_000u64) { value.to_string() } else { format!("0x{value:x}") } +} + +const fn convert_color_choice(choice: shell::ColorChoice) -> revm_inspectors::ColorChoice { match choice { shell::ColorChoice::Auto => revm_inspectors::ColorChoice::Auto, shell::ColorChoice::Always => revm_inspectors::ColorChoice::Always, @@ -226,7 +522,7 @@ impl TraceKind { /// /// [`Deployment`]: TraceKind::Deployment #[must_use] - pub fn is_deployment(self) -> bool { + pub const fn is_deployment(self) -> bool { matches!(self, Self::Deployment) } @@ -234,7 +530,7 @@ impl TraceKind { /// /// [`Setup`]: TraceKind::Setup #[must_use] - pub fn is_setup(self) -> bool { + pub const fn is_setup(self) -> bool { matches!(self, Self::Setup) } @@ -242,7 +538,7 @@ impl TraceKind { /// /// [`Execution`]: TraceKind::Execution #[must_use] - pub fn is_execution(self) -> bool { + pub const fn is_execution(self) -> bool { matches!(self, Self::Execution) } } @@ -266,7 +562,7 @@ pub fn load_contracts<'a>( } /// Different kinds of internal functions tracing. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum InternalTraceMode { #[default] None, @@ -276,102 +572,509 @@ pub enum InternalTraceMode { Full, } -impl From for TraceMode { - fn from(mode: InternalTraceMode) -> Self { - match mode { - InternalTraceMode::None => Self::None, - InternalTraceMode::Simple => Self::JumpSimple, - InternalTraceMode::Full => Self::Jump, +/// Opcode step recording granularity. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum StepRecording { + /// No opcode steps. + #[default] + None, + /// Record only JUMP/JUMPDEST steps. + Jumps, + /// Record all opcode steps. + All, +} + +impl StepRecording { + const fn merge(self, other: Self) -> Self { + match (self, other) { + (Self::All, _) | (_, Self::All) => Self::All, + (Self::Jumps, _) | (_, Self::Jumps) => Self::Jumps, + (Self::None, Self::None) => Self::None, } } } -// Different kinds of traces used by different foundry components. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)] -pub enum TraceMode { - /// Disabled tracing. - #[default] - None, - /// Simple call trace, no steps tracing required. - Call, - /// Call trace with tracing for JUMP and JUMPDEST opcode steps. - /// - /// Used for internal functions identification. Does not track memory snapshots. - JumpSimple, - /// Call trace with tracing for JUMP and JUMPDEST opcode steps. - /// - /// Same as `JumpSimple`, but tracks memory snapshots as well. - Jump, - /// Call trace with complete steps tracing. - /// - /// Used by debugger. - Debug, - /// Debug trace with storage changes. - RecordStateDiff, +/// Trace data requirements composed across independent feature axes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct TraceRequirements { + calls: bool, + steps: StepRecording, + memory_snapshots: bool, + stack_snapshots: bool, + returndata_snapshots: bool, + immediate_bytes: bool, + state_diff: bool, + bytecode: bool, } -impl TraceMode { - pub const fn is_none(self) -> bool { - matches!(self, Self::None) +impl TraceRequirements { + pub const fn none() -> Self { + Self { + calls: false, + steps: StepRecording::None, + memory_snapshots: false, + stack_snapshots: false, + returndata_snapshots: false, + immediate_bytes: false, + state_diff: false, + bytecode: false, + } } - pub const fn is_call(self) -> bool { - matches!(self, Self::Call) + pub const fn with_calls(mut self, yes: bool) -> Self { + self.calls |= yes; + self } - pub const fn is_jump_simple(self) -> bool { - matches!(self, Self::JumpSimple) + pub const fn merge(mut self, other: Self) -> Self { + self.calls |= other.calls; + self.steps = self.steps.merge(other.steps); + self.memory_snapshots |= other.memory_snapshots; + self.stack_snapshots |= other.stack_snapshots; + self.returndata_snapshots |= other.returndata_snapshots; + self.immediate_bytes |= other.immediate_bytes; + self.state_diff |= other.state_diff; + self.bytecode |= other.bytecode; + self } - pub const fn is_jump(self) -> bool { - matches!(self, Self::Jump) + pub const fn with_steps(mut self, steps: StepRecording) -> Self { + self.steps = self.steps.merge(steps); + self } - pub const fn record_state_diff(self) -> bool { - matches!(self, Self::RecordStateDiff) + pub const fn with_memory_snapshots(mut self, yes: bool) -> Self { + self.memory_snapshots |= yes; + self } - pub const fn is_debug(self) -> bool { - matches!(self, Self::Debug) + pub const fn with_stack_snapshots(mut self, yes: bool) -> Self { + self.stack_snapshots |= yes; + self } - pub fn with_debug(self, yes: bool) -> Self { - if yes { std::cmp::max(self, Self::Debug) } else { self } + pub const fn with_debug(mut self, yes: bool) -> Self { + if yes { + self.calls = true; + self.steps = StepRecording::All; + self.memory_snapshots = true; + self.stack_snapshots = true; + self.returndata_snapshots = true; + self.immediate_bytes = true; + self.state_diff = true; + self.bytecode = true; + } + self + } + + pub const fn with_decode_internal(self, mode: InternalTraceMode) -> Self { + match mode { + InternalTraceMode::None => self, + InternalTraceMode::Simple => { + self.with_calls(true).with_steps(StepRecording::Jumps).with_stack_snapshots(true) + } + InternalTraceMode::Full => self + .with_calls(true) + .with_steps(StepRecording::Jumps) + .with_memory_snapshots(true) + .with_stack_snapshots(true), + } } - pub fn with_decode_internal(self, mode: InternalTraceMode) -> Self { - std::cmp::max(self, mode.into()) + pub const fn with_all_steps(self, yes: bool) -> Self { + if yes { self.with_calls(true).with_steps(StepRecording::All) } else { self } } - pub fn with_state_changes(self, yes: bool) -> Self { - if yes { std::cmp::max(self, Self::RecordStateDiff) } else { self } + pub const fn with_state_changes(mut self, yes: bool) -> Self { + self.state_diff |= yes; + if yes { + self.calls = true; + } + self } - pub fn with_verbosity(self, verbosity: u8) -> Self { - if verbosity >= 3 { std::cmp::max(self, Self::Call) } else { self } + pub const fn with_verbosity(self, verbosity: u8) -> Self { + match verbosity { + 0..3 => self, + 3..=4 => self.with_calls(true), + _ if matches!(self.steps, StepRecording::All) => self.with_calls(true), + _ => self.with_state_changes(true), + } } pub fn into_config(self) -> Option { - if self.is_none() { - None - } else { - TracingInspectorConfig { - record_steps: self >= Self::JumpSimple, - record_memory_snapshots: self >= Self::Jump, - record_stack_snapshots: if self >= Self::JumpSimple { - StackSnapshotType::Full - } else { - StackSnapshotType::None - }, - record_logs: true, - record_state_diff: self.record_state_diff(), - record_returndata_snapshots: self.is_debug(), - record_opcodes_filter: (self.is_jump() || self.is_jump_simple()) - .then(|| OpcodeFilter::new().enabled(OpCode::JUMP).enabled(OpCode::JUMPDEST)), - exclude_precompile_calls: false, - record_immediate_bytes: self.is_debug(), - } - .into() + if !self.calls && self.steps == StepRecording::None && !self.state_diff { + return None; + } + + let steps = if self.state_diff { StepRecording::All } else { self.steps }; + TracingInspectorConfig { + record_steps: steps != StepRecording::None, + record_inputs: true, + record_bytecode: self.bytecode, + step_limit: None, + record_memory_snapshots: self.memory_snapshots, + record_stack_snapshots: if self.stack_snapshots { + StackSnapshotType::Full + } else { + StackSnapshotType::None + }, + record_logs: true, + record_state_diff: self.state_diff, + record_returndata_snapshots: self.returndata_snapshots, + record_opcodes_filter: match steps { + StepRecording::None | StepRecording::All => None, + StepRecording::Jumps => { + Some(OpcodeFilter::new().enabled(OpCode::JUMP).enabled(OpCode::JUMPDEST)) + } + }, + exclude_precompile_calls: false, + record_immediate_bytes: self.immediate_bytes, + record_step_deltas: false, + } + .into() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::Bytes; + use revm::interpreter::InstructionResult; + + #[test] + fn trace_context_uses_the_execution_network_hardfork_namespace() { + let config = Config { + hardfork: Some(FoundryHardfork::Ethereum( + foundry_evm_hardforks::EthereumHardfork::Cancun, + )), + ..Default::default() + }; + let context = TraceContext::new(Chain::tempo_mainnet(), NetworkConfigs::with_tempo(), None); + + assert!(matches!(context.decoding_hardfork(&config), Some(FoundryHardfork::Tempo(_)))); + } + + #[test] + fn trace_depth_projection_removes_and_reindexes_nodes() { + let mut arena = CallTraceArena::default(); + arena.nodes_mut().extend([ + CallTraceNode { + parent: Some(0), + children: vec![2], + idx: 1, + trace: CallTrace { depth: 1, ..Default::default() }, + ordering: vec![TraceMemberOrder::Call(0)], + ..Default::default() + }, + CallTraceNode { + parent: Some(1), + idx: 2, + trace: CallTrace { depth: 2, ..Default::default() }, + ..Default::default() + }, + CallTraceNode { + parent: Some(0), + idx: 3, + trace: CallTrace { depth: 1, ..Default::default() }, + ..Default::default() + }, + ]); + let root = &mut arena.nodes_mut()[0]; + root.children = vec![1, 3]; + root.ordering = vec![TraceMemberOrder::Call(0), TraceMemberOrder::Call(1)]; + let arena = SparsedTraceArena { + arena, + ignored: Default::default(), + diagnostics: Default::default(), + }; + + let arena = trace_arena_at_depth(&arena, 1); + + assert_eq!(arena.nodes().len(), 3); + assert_eq!(arena.nodes()[0].children, [1, 2]); + assert_eq!(arena.nodes()[1].idx, 1); + assert!(arena.nodes()[1].children.is_empty()); + assert!(arena.nodes()[1].ordering.is_empty()); + assert_eq!(arena.nodes()[2].idx, 2); + assert_eq!(arena.nodes()[2].parent, Some(0)); + assert!(arena.ignored.is_empty()); + } + + #[test] + fn decodes_tip1034_packed_channel_state() { + let settled = U256::from(123u64); + let deposit = U256::from(456u64); + let close_requested_at = U256::from(1_780_495_200u64); + let packed = settled | (deposit << 96usize) | (close_requested_at << 192usize); + + assert_eq!(decode_channel_state(packed), (settled, deposit, 1_780_495_200)); + assert_eq!( + format_channel_state(packed), + "{settled: 123, deposit: 456, closeRequestedAt: 1780495200}" + ); + } + + #[test] + fn tempo_storage_decodes_do_not_insert_extra_blank_line() { + let mut arena = CallTraceArena::default(); + let root = &mut arena.nodes_mut()[0]; + root.ordering.push(TraceMemberOrder::Step(0)); + root.trace = CallTrace { + address: TIP20_CHANNEL_RESERVE_ADDRESS, + success: true, + steps: vec![CallTraceStep { + pc: 0, + op: OpCode::SSTORE, + stack: None, + push_stack: None, + memory: None, + returndata: Bytes::new(), + gas_remaining: 0, + gas_refund_counter: 0, + gas_used: 0, + gas_cost: 0, + state_gas_cost: None, + state_gas_reservoir: None, + state_gas_spent: 0, + storage_change: Some(Box::new(StorageChange { + key: U256::ONE, + value: U256::from(2), + had_value: Some(U256::ONE), + reason: StorageChangeReason::SSTORE, + })), + status: Some(InstructionResult::Stop), + immediate_bytes: None, + decoded: None, + }], + ..Default::default() + }; + + let rendered = render_trace_arena_inner( + &SparsedTraceArena { + arena, + ignored: Default::default(), + diagnostics: Default::default(), + }, + false, + true, + ); + + assert!(rendered.contains("\nDecoded TIP20ChannelReserve storage:\n")); + assert!(!rendered.contains("\n\nDecoded TIP20ChannelReserve storage:\n")); + } + + #[test] + fn revert_diagnostic_only_changes_resolved_trace() { + let traces = SparsedTraceArena { + arena: CallTraceArena::default(), + ignored: Default::default(), + diagnostics: HashMap::from_iter([( + 0, + RevertDiagnostic::CallToNonContract(alloy_primitives::Address::ZERO), + )]), + }; + + let resolved = traces.resolve_arena(); + assert_eq!( + resolved.nodes()[0].trace.decoded.as_ref().unwrap().return_data.as_deref(), + Some("call to non-contract address 0x0000000000000000000000000000000000000000") + ); + assert!(resolved.nodes()[0].trace.output.is_empty()); + assert!(traces.arena.nodes()[0].trace.decoded.is_none()); + assert!(traces.arena.nodes()[0].trace.output.is_empty()); + } + + #[test] + fn serialization_resolves_diagnostics_without_consuming_ignored_ranges() { + let mut arena = CallTraceArena::default(); + let root = &mut arena.nodes_mut()[0]; + root.logs = vec![CallLog::default(), CallLog::default(), CallLog::default()]; + root.ordering = + vec![TraceMemberOrder::Log(0), TraceMemberOrder::Log(1), TraceMemberOrder::Log(2)]; + + let traces = SparsedTraceArena { + arena, + ignored: HashMap::from_iter([((0, 1), (0, 2))]), + diagnostics: HashMap::from_iter([( + 0, + RevertDiagnostic::CallToNonContract(alloy_primitives::Address::ZERO), + )]), + }; + + let serialized = ron::to_string(&traces).unwrap(); + let deserialized: SparsedTraceArena = ron::from_str(&serialized).unwrap(); + assert_eq!( + deserialized.arena.nodes()[0].ordering, + [TraceMemberOrder::Log(0), TraceMemberOrder::Log(1), TraceMemberOrder::Log(2)] + ); + assert_eq!(deserialized.ignored, traces.ignored); + assert!(deserialized.diagnostics.is_empty()); + assert_eq!( + deserialized.arena.nodes()[0].trace.decoded.as_ref().unwrap().return_data.as_deref(), + Some("call to non-contract address 0x0000000000000000000000000000000000000000") + ); + assert!(deserialized.arena.nodes()[0].trace.output.is_empty()); + + let resolved = deserialized.resolve_arena(); + assert_eq!(resolved.nodes()[0].ordering, [TraceMemberOrder::Log(2)]); + assert_eq!( + resolved.nodes()[0].trace.decoded.as_ref().unwrap().return_data.as_deref(), + Some("call to non-contract address 0x0000000000000000000000000000000000000000") + ); + assert!(render_trace_arena(&deserialized).contains("call to non-contract address")); + } + + #[test] + fn verbosity_0_through_2_is_noop() { + for v in 0..=2 { + assert_eq!( + TraceRequirements::none().with_verbosity(v), + TraceRequirements::none(), + "v={v}" + ); + assert_eq!( + TraceRequirements::none().with_calls(true).with_verbosity(v), + TraceRequirements::none().with_calls(true), + "v={v}" + ); + assert_eq!( + TraceRequirements::none().with_debug(true).with_verbosity(v), + TraceRequirements::none().with_debug(true), + "v={v}" + ); } } + + #[test] + fn verbosity_3_and_4_raises_to_call() { + for v in 3..=4 { + assert_eq!( + TraceRequirements::none().with_verbosity(v), + TraceRequirements::none().with_calls(true), + "v={v}" + ); + assert_eq!( + TraceRequirements::none().with_debug(true).with_verbosity(v), + TraceRequirements::none().with_debug(true), + "v={v}" + ); + assert_eq!( + TraceRequirements::none().with_state_changes(true).with_verbosity(v), + TraceRequirements::none().with_state_changes(true), + "v={v}" + ); + } + } + + #[test] + fn verbosity_5_raises_to_record_state_diff() { + let state_changes = TraceRequirements::none().with_state_changes(true); + + assert_eq!(TraceRequirements::none().with_verbosity(5), state_changes); + assert_eq!(TraceRequirements::none().with_calls(true).with_verbosity(5), state_changes); + let cfg = TraceRequirements::none() + .with_calls(true) + .with_steps(StepRecording::Jumps) + .with_verbosity(5) + .into_config() + .unwrap(); + assert!(cfg.record_state_diff); + assert!(cfg.record_opcodes_filter.is_none()); + assert_eq!( + TraceRequirements::none().with_debug(true).with_verbosity(5), + TraceRequirements::none().with_debug(true) + ); + assert_eq!( + TraceRequirements::none().with_state_changes(true).with_verbosity(5), + state_changes + ); + } + + #[test] + fn config_at_verbosity_0_is_none() { + assert!(TraceRequirements::none().with_verbosity(0).into_config().is_none()); + } + + #[test] + fn config_at_verbosity_3_records_calls_only() { + let cfg = TraceRequirements::none().with_verbosity(3).into_config().unwrap(); + assert!(!cfg.record_steps, "verbosity 3 should not record steps"); + assert!(!cfg.record_state_diff, "verbosity 3 should not record state diff"); + assert!(cfg.record_logs, "verbosity 3 should record logs"); + } + + #[test] + fn config_at_verbosity_5_records_steps_and_state_diff() { + let cfg = TraceRequirements::none().with_verbosity(5).into_config().unwrap(); + assert!(cfg.record_steps, "verbosity 5 must record steps for backtraces"); + assert!(cfg.record_state_diff, "verbosity 5 must record state diff"); + assert!(cfg.record_logs, "verbosity 5 must record logs"); + // RecordStateDiff should NOT enable expensive debug-level features. + assert!(!cfg.record_memory_snapshots, "verbosity 5 should not record memory snapshots"); + assert_eq!( + cfg.record_stack_snapshots, + StackSnapshotType::None, + "verbosity 5 should not record stack snapshots" + ); + // State diff requires all opcodes to capture SLOAD/SSTORE, so no filter. + assert!( + cfg.record_opcodes_filter.is_none(), + "verbosity 5 needs unfiltered opcodes for state diff" + ); + } + + #[test] + fn config_debug_mode_unchanged() { + // Debug mode must still enable full recording for the debugger. + let cfg = TraceRequirements::none().with_debug(true).into_config().unwrap(); + assert!(cfg.record_steps); + assert!(cfg.record_memory_snapshots, "Debug must record memory snapshots"); + assert_eq!( + cfg.record_stack_snapshots, + StackSnapshotType::Full, + "Debug must record full stack snapshots" + ); + assert!(cfg.record_returndata_snapshots, "Debug must record returndata"); + assert!(cfg.record_immediate_bytes, "Debug must record immediate bytes"); + assert!(cfg.record_opcodes_filter.is_none(), "Debug must record all opcodes (no filter)"); + assert!(cfg.record_state_diff, "Debug should record storage accesses for the debugger"); + } + + #[test] + fn requirements_preserve_internal_decode_with_state_diff() { + let cfg = TraceRequirements::none() + .with_decode_internal(InternalTraceMode::Full) + .with_state_changes(true) + .into_config() + .unwrap(); + + assert!(cfg.record_steps, "requirements should record opcode steps"); + assert!(cfg.record_memory_snapshots, "Full internal decoding needs memory snapshots"); + assert_eq!( + cfg.record_stack_snapshots, + StackSnapshotType::Full, + "internal decoding needs stack snapshots" + ); + assert!(cfg.record_state_diff, "state changes should be recorded"); + assert!(cfg.record_opcodes_filter.is_none(), "state diff needs unfiltered opcodes"); + } + + #[test] + fn requirements_all_steps_avoid_debug_snapshots() { + let cfg = + TraceRequirements::none().with_all_steps(true).with_verbosity(5).into_config().unwrap(); + + assert!(cfg.record_steps, "all steps must record opcode steps"); + assert!(cfg.record_opcodes_filter.is_none(), "all steps must record every opcode step"); + assert!(!cfg.record_memory_snapshots, "all steps should not record memory snapshots"); + assert_eq!( + cfg.record_stack_snapshots, + StackSnapshotType::None, + "all steps should not record stack snapshots" + ); + assert!(!cfg.record_returndata_snapshots, "all steps should not record returndata"); + assert!(!cfg.record_immediate_bytes, "all steps should not record immediate bytes"); + assert!(!cfg.record_state_diff, "all steps should not record state diffs"); + } } diff --git a/crates/evm/traces/src/speedscope/builder.rs b/crates/evm/traces/src/speedscope/builder.rs new file mode 100644 index 0000000000000..252337a4a61da --- /dev/null +++ b/crates/evm/traces/src/speedscope/builder.rs @@ -0,0 +1,424 @@ +//! Speedscope profile generation for EVM execution traces. +//! +//! This module converts EVM execution traces into the speedscope evented profile format. +//! Gas consumption is used as the value unit, so flame graph widths represent gas usage. + +use super::schema::{EventedProfile, Frame, Profile, SpeedscopeFile, ValueUnit}; +use alloy_primitives::{hex::ToHexExt, map::HashMap}; +use revm_inspectors::tracing::{ + CallTraceArena, + types::{CallTraceNode, CallTraceStep, DecodedTraceStep, TraceMemberOrder}, +}; +use std::borrow::Cow; + +struct SpeedscopeBuilder<'a> { + file: SpeedscopeFile<'a>, + profile: EventedProfile<'a>, + frame_cache: HashMap, + cumulative_gas: u64, +} + +impl<'a> SpeedscopeBuilder<'a> { + fn new(name: String) -> Self { + Self { + file: SpeedscopeFile::new(name.clone()), + profile: EventedProfile::new(name, ValueUnit::None), + frame_cache: HashMap::default(), + cumulative_gas: 0, + } + } + + fn build(mut self) -> SpeedscopeFile<'a> { + self.profile.set_end_value(self.cumulative_gas); + self.file.add_profile(Profile::Evented(self.profile)); + self.file + } + + fn process_call_node(&mut self, nodes: &[CallTraceNode], idx: usize, isolate: bool) { + let node = &nodes[idx]; + let frame_idx = self.frame_idx(call_frame_name(node)); + let start_gas = self.cumulative_gas; + let gas_used = call_gas_used(node, isolate); + + self.profile.open_frame(frame_idx, self.cumulative_gas); + + let mut step_exits = Vec::new(); + for (order_idx, order) in node.ordering.iter().enumerate() { + match order { + TraceMemberOrder::Step(step_idx) => { + self.exit_previous_steps(&mut step_exits, *step_idx); + let is_call_step = + matches!(node.ordering.get(order_idx + 1), Some(TraceMemberOrder::Call(_))); + self.process_step(&node.trace.steps, *step_idx, is_call_step, &mut step_exits); + } + TraceMemberOrder::Call(child_idx) => { + let child_node_idx = node.children[*child_idx]; + self.process_call_node(nodes, child_node_idx, isolate); + } + TraceMemberOrder::Log(_) => {} + } + } + + while let Some(step_exit) = step_exits.pop() { + self.profile.close_frame(step_exit.frame_idx, self.cumulative_gas); + } + + let consumed = self.cumulative_gas.saturating_sub(start_gas); + self.cumulative_gas = self.cumulative_gas.saturating_add(gas_used.saturating_sub(consumed)); + self.profile.close_frame(frame_idx, self.cumulative_gas); + } + + fn process_step( + &mut self, + steps: &[CallTraceStep], + step_idx: usize, + skip_gas: bool, + step_exits: &mut Vec, + ) { + let Some(step) = steps.get(step_idx) else { return }; + + if let Some(DecodedTraceStep::InternalCall(decoded, step_end_idx)) = step.decoded.as_deref() + { + let frame_idx = self.frame_idx(decoded.func_name.clone()); + self.profile.open_frame(frame_idx, self.cumulative_gas); + step_exits.push(StepExit { step_idx: *step_end_idx, frame_idx }); + } + + if !skip_gas { + self.cumulative_gas = self.cumulative_gas.saturating_add(step.gas_cost); + } + } + + fn exit_previous_steps(&mut self, step_exits: &mut Vec, step_idx: usize) { + while step_exits.last().is_some_and(|exit| exit.step_idx <= step_idx) { + let step_exit = step_exits.pop().unwrap(); + self.profile.close_frame(step_exit.frame_idx, self.cumulative_gas); + } + } + + fn frame_idx(&mut self, name: String) -> usize { + if let Some(idx) = self.frame_cache.get(name.as_str()) { + return *idx; + } + + let idx = self.file.add_frame(Frame::new(Cow::Owned(name.clone()))); + self.frame_cache.insert(name, idx); + idx + } +} + +struct StepExit { + step_idx: usize, + frame_idx: usize, +} + +/// Builds a speedscope profile from a call trace arena. +/// +/// Walks the trace arena directly so the Time Order view preserves execution order. +pub fn build<'a>( + arena: &CallTraceArena, + test_name: &str, + contract_name: &str, + isolate: bool, +) -> SpeedscopeFile<'a> { + let name = format!("{contract_name}::{test_name}"); + let mut builder = SpeedscopeBuilder::new(name); + + if !arena.nodes().is_empty() { + builder.process_call_node(arena.nodes(), 0, isolate); + } + + builder.build() +} + +fn call_frame_name(node: &CallTraceNode) -> String { + if node.trace.kind.is_any_create() { + let contract_name = + node.trace.decoded.as_ref().and_then(|dc| dc.label.as_deref()).unwrap_or("Contract"); + return format!("new {contract_name}"); + } + + let selector = node + .selector() + .map(|selector| selector.encode_hex_with_prefix()) + .unwrap_or_else(|| "fallback".to_string()); + let signature = node + .trace + .decoded + .as_ref() + .and_then(|dc| dc.call_data.as_ref()) + .map(|dc| &dc.signature) + .unwrap_or(&selector); + + if let Some(label) = node.trace.decoded.as_ref().and_then(|dc| dc.label.as_ref()) { + format!("{label}.{signature}") + } else { + signature.clone() + } +} + +const fn call_gas_used(node: &CallTraceNode, isolate: bool) -> u64 { + let mut gas_used = node.trace.gas_used; + let max_refund_adjust_depth = if isolate { 1 } else { 0 }; + if node.trace.depth <= max_refund_adjust_depth { + gas_used = gas_used.saturating_add(node.trace.gas_refund_counter); + } + gas_used +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{CallKind, CallTrace, DecodedCallData, DecodedCallTrace}; + use revm::{bytecode::opcode::OpCode, interpreter::InstructionResult}; + use snapbox::prelude::*; + + fn trace_step(gas_cost: u64) -> CallTraceStep { + CallTraceStep { + pc: 0, + op: OpCode::STOP, + stack: None, + push_stack: None, + memory: None, + returndata: Default::default(), + gas_remaining: 0, + gas_refund_counter: 0, + gas_used: 0, + gas_cost, + state_gas_cost: None, + state_gas_reservoir: None, + state_gas_spent: 0, + storage_change: None, + status: Some(InstructionResult::Stop), + immediate_bytes: None, + decoded: None, + } + } + + fn decoded_call(label: &str, signature: &str) -> Option> { + Some(Box::new(DecodedCallTrace { + label: Some(label.to_string()), + call_data: Some(DecodedCallData { signature: signature.to_string(), args: vec![] }), + return_data: None, + })) + } + + #[test] + fn test_empty_profile() { + let arena = CallTraceArena::default(); + let profile = build(&arena, "testExample", "TestContract", false); + let json = serde_json::to_string_pretty(&profile).unwrap(); + + snapbox::assert_data_eq!( + json.is_json(), + (snapbox::str![[r#" +{ + "$schema": "https://www.speedscope.app/file-format-schema.json", + "shared": { + "frames": [ + { + "name": "fallback" + } + ] + }, + "profiles": [ + { + "type": "evented", + "name": "TestContract::testExample", + "unit": "none", + "startValue": 0, + "endValue": 0, + "events": [ + { + "type": "O", + "frame": 0, + "at": 0 + }, + { + "type": "C", + "frame": 0, + "at": 0 + } + ] + } + ], + "name": "TestContract::testExample", + "exporter": "foundry" +} +"#]]) + .is_json(), + ); + } + + #[test] + fn test_build_preserves_parent_work_ordering() { + let mut arena = CallTraceArena::default(); + { + let root = &mut arena.nodes_mut()[0]; + root.trace = CallTrace { + kind: CallKind::Call, + gas_used: 400, + steps: vec![ + trace_step(20), + trace_step(1_000_000), + trace_step(30), + trace_step(1_000_000), + trace_step(50), + ], + decoded: decoded_call("Parent", "run()"), + ..Default::default() + }; + root.children = vec![1, 2]; + root.ordering = vec![ + TraceMemberOrder::Step(0), + TraceMemberOrder::Step(1), + TraceMemberOrder::Call(0), + TraceMemberOrder::Step(2), + TraceMemberOrder::Step(3), + TraceMemberOrder::Call(1), + TraceMemberOrder::Step(4), + ]; + } + + arena.nodes_mut().push(CallTraceNode { + parent: Some(0), + idx: 1, + trace: CallTrace { + depth: 1, + kind: CallKind::Call, + gas_used: 100, + decoded: decoded_call("Child", "first()"), + ..Default::default() + }, + ..Default::default() + }); + arena.nodes_mut().push(CallTraceNode { + parent: Some(0), + idx: 2, + trace: CallTrace { + depth: 1, + kind: CallKind::Call, + gas_used: 200, + decoded: decoded_call("Child", "second()"), + ..Default::default() + }, + ..Default::default() + }); + + let file = build(&arena, "test", "Test", false); + let json = serde_json::to_string_pretty(&file).unwrap(); + + snapbox::assert_data_eq!( + json.is_json(), + (snapbox::str![[r#" +{ + "$schema": "https://www.speedscope.app/file-format-schema.json", + "shared": { + "frames": [ + { + "name": "Parent.run()" + }, + { + "name": "Child.first()" + }, + { + "name": "Child.second()" + } + ] + }, + "profiles": [ + { + "type": "evented", + "name": "Test::test", + "unit": "none", + "startValue": 0, + "endValue": 400, + "events": [ + { + "type": "O", + "frame": 0, + "at": 0 + }, + { + "type": "O", + "frame": 1, + "at": 20 + }, + { + "type": "C", + "frame": 1, + "at": 120 + }, + { + "type": "O", + "frame": 2, + "at": 150 + }, + { + "type": "C", + "frame": 2, + "at": 350 + }, + { + "type": "C", + "frame": 0, + "at": 400 + } + ] + } + ], + "name": "Test::test", + "exporter": "foundry" +} +"#]]) + .is_json(), + ); + } + + #[test] + fn test_monotonic_events() { + let mut arena = CallTraceArena::default(); + { + let root = &mut arena.nodes_mut()[0]; + root.trace = CallTrace { + gas_used: 225, + steps: vec![trace_step(100), trace_step(75)], + decoded: decoded_call("A", "a()"), + ..Default::default() + }; + root.children = vec![1]; + root.ordering = vec![ + TraceMemberOrder::Step(0), + TraceMemberOrder::Call(0), + TraceMemberOrder::Step(1), + ]; + } + arena.nodes_mut().push(CallTraceNode { + parent: Some(0), + idx: 1, + trace: CallTrace { + depth: 1, + gas_used: 50, + decoded: decoded_call("B", "b()"), + ..Default::default() + }, + ..Default::default() + }); + + let file = build(&arena, "test", "Test", false); + + // Extract events + if let Profile::Evented(profile) = &file.profiles[0] { + let mut last_at = 0u64; + for event in &profile.events { + assert!( + event.at >= last_at, + "Event at {} is less than previous {}", + event.at, + last_at + ); + last_at = event.at; + } + } + } +} diff --git a/crates/evm/traces/src/speedscope/mod.rs b/crates/evm/traces/src/speedscope/mod.rs new file mode 100644 index 0000000000000..a5654313ab40c --- /dev/null +++ b/crates/evm/traces/src/speedscope/mod.rs @@ -0,0 +1,4 @@ +//! [Speedscope](https://speedscope.app/) traces. + +pub mod builder; +pub mod schema; diff --git a/crates/evm/traces/src/speedscope/schema.rs b/crates/evm/traces/src/speedscope/schema.rs new file mode 100644 index 0000000000000..a69372b0b9bcf --- /dev/null +++ b/crates/evm/traces/src/speedscope/schema.rs @@ -0,0 +1,320 @@ +//! Speedscope JSON format types for profile generation. +//! +//! This module provides Rust types that serialize to the speedscope file format. +//! See: +//! +//! The format supports two profile types: +//! - **Evented**: Open/close frame events with timestamps (like function calls) +//! - **Sampled**: Periodic stack samples with weights + +use serde::{Deserialize, Serialize}; +use std::borrow::Cow; + +/// The speedscope JSON schema URL. +pub const SCHEMA: &str = "https://www.speedscope.app/file-format-schema.json"; + +/// Root container for a speedscope profile file. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpeedscopeFile<'a> { + /// JSON schema reference. + #[serde(rename = "$schema")] + pub schema: &'static str, + + /// Shared data between profiles (primarily the frame definitions). + pub shared: Shared<'a>, + + /// List of profiles in this file. + pub profiles: Vec>, + + /// Optional name for the profile file. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option>, + + /// Index of the initially active profile. + #[serde(skip_serializing_if = "Option::is_none")] + pub active_profile_index: Option, + + /// Name of the tool that exported this profile. + #[serde(skip_serializing_if = "Option::is_none")] + pub exporter: Option>, +} + +impl<'a> SpeedscopeFile<'a> { + /// Creates a new speedscope file with the given name. + pub fn new(name: impl Into>) -> Self { + Self { + schema: SCHEMA, + shared: Shared::default(), + profiles: Vec::new(), + name: Some(name.into()), + active_profile_index: None, + exporter: Some(Cow::Borrowed("foundry")), + } + } + + /// Adds a frame and returns its index. + pub fn add_frame(&mut self, frame: Frame<'a>) -> usize { + let idx = self.shared.frames.len(); + self.shared.frames.push(frame); + idx + } + + /// Adds a profile to this file. + pub fn add_profile(&mut self, profile: Profile<'a>) { + self.profiles.push(profile); + } +} + +/// Shared data between profiles, primarily frame definitions. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Shared<'a> { + /// All frames referenced by profiles in this file. + pub frames: Vec>, +} + +/// A stack frame definition. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct Frame<'a> { + /// The name of the frame (function name, etc.). + pub name: Cow<'a, str>, + + /// Optional source file path. + #[serde(skip_serializing_if = "Option::is_none")] + pub file: Option>, + + /// Optional line number in the source file. + #[serde(skip_serializing_if = "Option::is_none")] + pub line: Option, + + /// Optional column number in the source file. + #[serde(skip_serializing_if = "Option::is_none")] + pub col: Option, +} + +impl<'a> Frame<'a> { + /// Creates a new frame with just a name. + pub fn new(name: impl Into>) -> Self { + Self { name: name.into(), file: None, line: None, col: None } + } +} + +/// A profile within a speedscope file. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum Profile<'a> { + /// Event-based profile with explicit open/close events. + #[serde(rename = "evented")] + Evented(EventedProfile<'a>), + + /// Sample-based profile with periodic stack snapshots. + #[serde(rename = "sampled")] + Sampled(SampledProfile<'a>), +} + +/// An event-based profile where function calls are explicit open/close events. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EventedProfile<'a> { + /// Name of this profile (e.g., thread name or test name). + pub name: Cow<'a, str>, + + /// Unit for the values (e.g., "nanoseconds", "bytes"). + pub unit: ValueUnit, + + /// Starting value (usually 0). + pub start_value: u64, + + /// Ending value (total time/gas/etc.). + pub end_value: u64, + + /// List of frame open/close events. + pub events: Vec, +} + +impl<'a> EventedProfile<'a> { + /// Creates a new evented profile with the given name and unit. + pub fn new(name: impl Into>, unit: ValueUnit) -> Self { + Self { name: name.into(), unit, start_value: 0, end_value: 0, events: Vec::new() } + } + + /// Adds an open frame event at the given timestamp. + pub fn open_frame(&mut self, frame: usize, at: u64) { + self.events.push(Event { event_type: EventType::Open, frame, at }); + } + + /// Adds a close frame event at the given timestamp. + pub fn close_frame(&mut self, frame: usize, at: u64) { + self.events.push(Event { event_type: EventType::Close, frame, at }); + } + + /// Sets the end value based on the final timestamp. + pub const fn set_end_value(&mut self, end_value: u64) { + self.end_value = end_value; + } +} + +/// A sample-based profile with periodic stack snapshots. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SampledProfile<'a> { + /// Name of this profile. + pub name: Cow<'a, str>, + + /// Unit for the weight values. + pub unit: ValueUnit, + + /// Starting value. + pub start_value: u64, + + /// Ending value. + pub end_value: u64, + + /// Stack samples (each sample is a list of frame indices, bottom to top). + pub samples: Vec>, + + /// Weights for each sample. + pub weights: Vec, +} + +/// Units for profile values. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ValueUnit { + /// No unit (raw counts). + None, + /// Nanoseconds. + Nanoseconds, + /// Microseconds. + Microseconds, + /// Milliseconds. + Milliseconds, + /// Seconds. + Seconds, + /// Bytes. + Bytes, +} + +/// A frame open or close event in an evented profile. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Event { + /// Type of event (open or close). + #[serde(rename = "type")] + pub event_type: EventType, + + /// Index into the shared frames array. + pub frame: usize, + + /// Timestamp when this event occurred. + pub at: u64, +} + +/// Type of frame event. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum EventType { + /// Frame was opened (function entered). + #[serde(rename = "O")] + Open, + /// Frame was closed (function returned). + #[serde(rename = "C")] + Close, +} + +#[cfg(test)] +mod tests { + use super::*; + use snapbox::prelude::*; + + #[test] + fn test_serialize_empty_file() { + let file = SpeedscopeFile::new("test"); + let json = serde_json::to_string_pretty(&file).unwrap(); + + snapbox::assert_data_eq!( + json.is_json(), + (snapbox::str![[r#" +{ + "$schema": "https://www.speedscope.app/file-format-schema.json", + "shared": { + "frames": [] + }, + "profiles": [], + "name": "test", + "exporter": "foundry" +} +"#]]) + .is_json(), + ); + } + + #[test] + fn test_serialize_evented_profile() { + let mut file = SpeedscopeFile::new("test"); + let frame_a = file.add_frame(Frame::new("a")); + let frame_b = file.add_frame(Frame::new("b")); + + let mut profile = EventedProfile::new("main", ValueUnit::Nanoseconds); + profile.open_frame(frame_a, 0); + profile.open_frame(frame_b, 0); + profile.close_frame(frame_b, 100); + profile.close_frame(frame_a, 200); + profile.set_end_value(200); + + file.add_profile(Profile::Evented(profile)); + + let json = serde_json::to_string_pretty(&file).unwrap(); + + snapbox::assert_data_eq!( + json.is_json(), + (snapbox::str![[r#" +{ + "$schema": "https://www.speedscope.app/file-format-schema.json", + "shared": { + "frames": [ + { + "name": "a" + }, + { + "name": "b" + } + ] + }, + "profiles": [ + { + "type": "evented", + "name": "main", + "unit": "nanoseconds", + "startValue": 0, + "endValue": 200, + "events": [ + { + "type": "O", + "frame": 0, + "at": 0 + }, + { + "type": "O", + "frame": 1, + "at": 0 + }, + { + "type": "C", + "frame": 1, + "at": 100 + }, + { + "type": "C", + "frame": 0, + "at": 200 + } + ] + } + ], + "name": "test", + "exporter": "foundry" +} +"#]]) + .is_json(), + ); + } +} diff --git a/crates/fmt/Cargo.toml b/crates/fmt/Cargo.toml index bc1f44fdc5e03..985e021a22be2 100644 --- a/crates/fmt/Cargo.toml +++ b/crates/fmt/Cargo.toml @@ -14,17 +14,19 @@ workspace = true [dependencies] foundry-config.workspace = true +foundry-common.workspace = true -alloy-primitives.workspace = true +solar.workspace = true -ariadne = "0.5" itertools.workspace = true -solang-parser.workspace = true -thiserror.workspace = true -tracing.workspace = true +similar = { version = "3", features = ["inline"] } [dev-dependencies] -itertools.workspace = true -similar-asserts.workspace = true +foundry-test-utils.workspace = true + toml.workspace = true -tracing-subscriber = { workspace = true, features = ["env-filter"] } +snapbox.workspace = true + +[features] +default = ["optimism"] +optimism = ["foundry-common/optimism"] diff --git a/crates/fmt/README.md b/crates/fmt/README.md index 014988bef019d..2a151009b1019 100644 --- a/crates/fmt/README.md +++ b/crates/fmt/README.md @@ -6,71 +6,51 @@ is tested on the [Prettier Solidity Plugin](https://github.com/prettier-solidity ## Architecture -The formatter works in two steps: +The formatter is built on top of [Solar](https://github.com/paradigmxyz/solar), and the architecture is based on a Wadler-style pretty-printing engine. The formatting process consists of two main steps: -1. Parse Solidity source code with [solang](https://github.com/hyperledger-labs/solang) into the PT (Parse Tree) - (not the same as Abstract Syntax Tree, [see difference](https://stackoverflow.com/a/9864571)). -2. Walk the PT and output new source code that's compliant with provided config and rule set. +1. **Parsing**: The Solidity source code is parsed using **`solar`** into an **Abstract Syntax Tree (AST)**. The AST is a tree representation of the code's syntactic structure. +2. **Printing**: The AST is traversed by a visitor, which generates a stream of abstract tokens that are then processed by a pretty-printing engine to produce the final formatted code. -The technique for walking the tree is based on [Visitor Pattern](https://en.wikipedia.org/wiki/Visitor_pattern) -and works as following: +### The Pretty Printer (`pp`) -1. Implement `Formatter` callback functions for each PT node type. - Every callback function should write formatted output for the current node - and call `Visitable::visit` function for child nodes delegating the output writing. -1. Implement `Visitable` trait and its `visit` function for each PT node type. Every `visit` function should call - corresponding `Formatter`'s callback function. +The core of the formatter is a pretty-printing engine inspired by Philip Wadler's algorithm, and adapted from the implementations in `rustc_ast_pretty` and `prettyplease`. Its goal is to produce an optimal and readable layout by making intelligent decisions about line breaks. -### Output +The process works like this: -The formatted output is written into the output buffer in _chunks_. The `Chunk` struct holds the content to be written & -metadata for it. This includes the comments surrounding the content as well as the `needs_space` flag specifying whether -this _chunk_ needs a space. The flag overrides the default behavior of `Formatter::next_char_needs_space` method. +1. **AST to Abstract Tokens**: The formatter's `State` object walks the `solar` AST. Instead of directly writing strings, it translates the AST nodes into a stream of abstract formatting "commands" called `Token`s. This decouples the code's structure from the final text output. The primary tokens are: + * **`String`**: An atomic, unbreakable piece of text, like a keyword (`function`), an identifier (`myVar`), or a literal (`42`). + * **`Break`**: A potential line break. This is the core of the engine's flexibility. The `Printer` later decides whether to render a `Break` as a single space or as a newline with appropriate indentation. + * **`Begin`/`End`**: These tokens define a logical group of tokens that should be formatted as a single unit. This allows the printer to decide how to format the entire group at once. -The content gets written into the `FormatBuffer` which contains the information about the current indentation level, -indentation length, current state as well as the other data determining the rules for writing the content. -`FormatBuffer` implements the `std::fmt::Write` trait where it evaluates the current information and decides how the -content should be written to the destination. +2. **Grouping and Breaking Strategy**: The `Begin` and `End` tokens create formatting "boxes" that guide the breaking strategy. There are two main types of boxes: + * **Consistent Box (`cbox`)**: If *any* `Break` inside this box becomes a newline, then *all* `Break`s inside it must also become newlines. This is ideal for lists like function parameters or struct fields, ensuring they are either all on one line or neatly arranged with one item per line. + * **Inconsistent Box (`ibox`)**: `Break`s within this box are independent. The printer can wrap a long line at any `Break` point without forcing other breaks in the same box to become newlines. This is useful for formatting long expressions or comments. -### Comments +3. **The `Printer` Engine**: The `Printer` consumes this stream of tokens and makes the final decisions: + * It maintains a buffer of tokens and tracks the remaining space on the current line based on the configured `line_length`. + * When it encounters a `Begin` token for a group, it calculates whether the entire group could fit on the current line if all its `Break`s were spaces. + * **If it fits**, all `Break`s in that group are rendered as spaces. + * **If it doesn't fit**, `Break`s are rendered as newlines, and the indentation level is adjusted accordingly based on the box's rules (consistent or inconsistent). -The solang parser does not output comments as a type of parse tree node, but rather -in a list alongside the parse tree with location information. It is therefore necessary -to infer where to insert the comments and how to format them while traversing the parse tree. +Crucially, this entire process is deterministic. Because the formatter completely rebuilds the code from the AST, it discards all original whitespace, line breaks, and other stylistic variations. This means that for a given AST and configuration, the output will always be identical. No matter how inconsistently the input code is formatted, the result is a single, canonical representation, ensuring predictability and consistency across any codebase. -To handle this, the formatter pre-parses the comments and puts them into two categories: -Prefix and Postfix comments. Prefix comments refer to the node directly after them, and -postfix comments refer to the node before them. As an illustration: +> **Debug Mode**: To visualize the debug output, and understand how the pretty-printer makes its decisions about boxes and breaks, see the [Debug](#debug) section in Testing. -```solidity -// This is a prefix comment -/* This is also a prefix comment */ -uint variable = 1 + 2; /* this is postfix */ // this is postfix too - // and this is a postfix comment on the next line -``` +### Comments -To insert the comments into the appropriate areas, strings get converted to chunks -before being written to the buffer. A chunk is any string that cannot be split by -whitespace. A chunk also carries with it the surrounding comment information. Thereby -when writing the chunk the comments can be added before and after the chunk as well -as any whitespace surrounding. +Comment handling is a critical aspect of the formatter, designed to preserve developer intent while restructuring the code. -To construct a chunk, the string and the location of the string is given to the -Formatter and the pre-parsed comments before the start and end of the string are -associated with that string. The source code can then further be chunked before the -chunks are written to the buffer. +1. **Categorization**: Comments are parsed and categorized by their position and style: `Isolated` (on its own line), `Mixed` (on a line with code), and `Trailing` (at the end of a line). -To write the chunk, first the comments associated with the start of the chunk get -written to the buffer. Then the Formatter checks if any whitespace is needed between -what's been written to the buffer and what's in the chunk and inserts it where appropriate. -If the chunk content fits on the same line, it will be written directly to the buffer, -otherwise it will be written on the next line. Finally, any associated postfix -comments also get written. +2. **Blank Line Handling**: Blank lines in the source code are treated as a special `BlankLine` comment type, allowing the formatter to preserve vertical spacing that separates logical blocks of code. However, to maintain a clean and consistent vertical rhythm, any sequence of multiple blank lines is collapsed into a single blank line. This prevents excessive empty space in the formatted output. -### Example +3. **Integration with Printing**: During the AST traversal, the formatter queries for comments that appear before the current code element. These comments, including blank lines, are then strategically inserted into the `Printer`'s token stream. The formatter inserts `Break` tokens around comments to ensure they are correctly spaced from the surrounding code, and emits one or two `hardbreak`s for blank lines to maintain the original vertical rhythm. -Source code +This approach allows the formatter to respect both the syntactic structure of the code and the developer's textual annotations and spacing, producing a clean, readable, and intentional layout. +### Example + +**Source Code** ```solidity pragma solidity ^0.8.10 ; contract HelloWorld { @@ -79,23 +59,37 @@ contract HelloWorld { } + event Greet( string indexed name) ; ``` -Parse Tree (simplified) - +**Abstract Syntax Tree (AST) (simplified)** ```text SourceUnit - | PragmaDirective("solidity", "^0.8.10") - | ContractDefinition("HelloWorld") - | VariableDefinition("string", "message", null, ["public"]) - | FunctionDefinition("constructor") - | Parameter("string", "initMessage", ["memory"]) - | EventDefinition("string", "Greet", ["indexed"], ["name"]) + ├─ PragmaDirective("solidity", "^0.8.10") + ├─ ItemContract("HelloWorld") + │ ├─ VariableDefinition { name: "message", ty: "string", visibility: "public" } + │ └─ ItemFunction { + │ kind: Constructor, + │ header: FunctionHeader { + │ parameters: [ + │ VariableDefinition { name: "initMessage", ty: "string", data_location: "memory" } + │ ] + │ }, + │ body: Block { + │ stmts: [ + │ Stmt { kind: Expr(Assign {lhs: Ident("message"), rhs: Ident("initMessage")}) } + │ ] + │ } + │ } + └─ ItemEvent { name: "Greet", parameters: [ + VariableDefinition { name: "name", ty: "string", indexed: true } + ] } ``` -Formatted source code that was reconstructed from the Parse Tree +**Formatted Source Code** +The code is reconstructed from the AST using the pretty-printer. ```solidity pragma solidity ^0.8.10; @@ -112,112 +106,139 @@ event Greet(string indexed name); ### Configuration -The formatter supports multiple configuration options defined in `FormatterConfig`. - -| Option | Default | Description | -| ---------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| line_length | 120 | Maximum line length where formatter will try to wrap the line | -| tab_width | 4 | Number of spaces per indentation level | -| bracket_spacing | false | Print spaces between brackets | -| int_types | long | Style of uint/int256 types. Available options: `long`, `short`, `preserve` | -| multiline_func_header | attributes_first | Style of multiline function header in case it doesn't fit. Available options: `params_first`, `params_first_multi`, `attributes_first`, `all`, `all_params` | -| quote_style | double | Style of quotation marks. Available options: `double`, `single`, `preserve` | -| number_underscore | preserve | Style of underscores in number literals. Available options: `preserve`, `remove`, `thousands` | -| hex_underscore | remove | Style of underscores in hex literals. Available options: `preserve`, `remove`, `bytes` | -| single_line_statement_blocks | preserve | Style of single line blocks in statements. Available options: `single`, `multi`, `preserve` | -| override_spacing | false | Print space in state variable, function and modifier `override` attribute | -| wrap_comments | false | Wrap comments on `line_length` reached | -| ignore | [] | Globs to ignore | -| contract_new_lines | false | Add new line at start and end of contract declarations | -| sort_imports | false | Sort import statements alphabetically in groups | -| style | space | Configures if spaces or tabs should be used for indents. `tab_width` will be ignored if set to `tab`. Available options: `space`, `tab` | - -### Disable Line - -The formatter can be disabled on specific lines by adding a comment `// forgefmt: disable-next-line`, like this: +The formatter supports multiple configuration options defined in `foundry.toml`. + +| Option | Default | Description | +| :--- | :--- | :--- | +| `line_length` | `120` | Maximum line length where the formatter will try to wrap the line. | +| `tab_width` | `4` | Number of spaces per indentation level. Ignored if `style` is `tab`. | +| `style` | `space` | The style of indentation. Options: `space`, `tab`. | +| `bracket_spacing` | `false` | Print spaces between brackets. | +| `int_types` | `long` | Style for `uint256`/`int256` types. Options: `long`, `short`, `preserve`. | +| `multiline_func_header` | `attributes_first` | The style of multiline function headers. Options: `attributes_first`, `params_always`, `params_first_multi`, `all`, `all_params`. | +| `prefer_compact` | `all` | Style that determines if a broken list, should keep its elements together on their own line, before breaking individually. Options: `none`, `calls`, `events`, `errors`, `events_errors`, `all`. | +| `quote_style` | `double` | The style of quotation marks. Options: `double`, `single`, `preserve`. | +| `number_underscore` | `preserve` | The style of underscores in number literals. Options: `preserve`, `remove`, `thousands`. | +| `hex_underscore` | `remove` | The style of underscores in hex literals. Options: `preserve`, `remove`, `bytes`. | +| `single_line_statement_blocks` | `preserve` | The style of single-line blocks in statements. Options: `preserve`, `single`, `multi`. | +| `override_spacing` | `false` | Print a space in the `override` attribute. | +| `wrap_comments` | `false` | Wrap comments when `line_length` is reached. | +| `docs_style` | `preserve` | Enforces the style of doc (natspec) comments. Options: `preserve`, `line`, `block`. | +| `ignore` | `[]` | Globs to ignore. | +| `contract_new_lines` | `false` | Add a new line at the start and end of contract declarations. | +| `sort_imports` | `false` | Sort import statements alphabetically in groups. A group is a set of imports separated by a newline. | +| `namespace_import_style` | `prefer_plain` | Style for namespace imports. Options: `prefer_plain` (`import "foo" as foo;`), `prefer_glob` (`import * as foo from "foo";`), `preserve`. | +| `pow_no_space` | `false` | Suppress spaces around the power operator (`**`). | +| `single_line_imports` | `false` | Keep single imports on a single line, even if they exceed the line length limit. | + +> Check [`FormatterConfig`](../config/src/fmt.rs) for a more detailed explanation. +### Inline Configuration + +The formatter can be instructed to skip specific sections of code using inline comments. While the tool supports fine-grained control, it is generally more robust and efficient to disable formatting for entire AST items or statements. + +This approach is preferred because it allows the formatter to treat the entire disabled item as a single, opaque unit. It can simply copy the original source text for that item's span instead of partially formatting a line, switching to copy mode, and then resuming formatting. This leads to more predictable output and avoids potential edge cases with complex, partially-disabled statements. + +#### Disable Line + +These directives are best used when they apply to a complete, self-contained AST statement, as shown below. In this case, `uint x = 100;` is a full statement, making it a good candidate for a line-based disable. + +To disable the next line: ```solidity // forgefmt: disable-next-line uint x = 100; ``` -Alternatively, the comment can also be placed at the end of the line. In this case, you'd have to use `disable-line` -instead: - +To disable the current line: ```solidity uint x = 100; // forgefmt: disable-line ``` +#### Disable Block -### Disable Block - -The formatter can be disabled for a section of code by adding a comment `// forgefmt: disable-start` before and a -comment `// forgefmt: disable-end` after, like this: +This is the recommended approach for complex, multi-line constructs where you want to preserve specific formatting. In the example below, the entire `function` definition is disabled. This is preferable to trying to disable individual lines within the signature, because lines like `uint256 b /* a comment that goes inside the comma */,` do not correspond to a complete AST item or statement on their own. Disabling the whole item is cleaner and more aligned with the code's structure. ```solidity // forgefmt: disable-start -uint x = 100; -uint y = 101; +function fnWithManyArguments( + uint a, + uint256 b /* a comment that goes inside the comma */, + uint256 c +) external returns (bool) { // forgefmt: disable-end ``` -### Testing +## Contributing -Tests reside under the `fmt/testdata` folder and specify the malformatted & expected Solidity code. The source code file -is named `original.sol` and expected file(s) are named in a format `({prefix}.)?fmt.sol`. Multiple expected files are -needed for tests covering available configuration options. +Check out the [foundry contribution guide](https://github.com/foundry-rs/foundry/blob/master/CONTRIBUTING.md). + +Guidelines for contributing to `forge fmt`: + +### Opening an issue + +1. Create a short, concise title describing the issue. + * **Bad**: `Forge fmt does not work` + * **Good**: `bug(forge-fmt): misplaces postfix comment on if-statement` +2. Fill in the issue template fields, including Foundry version, platform, and component info. +3. Provide code snippets showing the current and expected behaviors. +4. If it's a feature request, explain why the feature is needed. +5. Add the `C-forge` and `Cmd-forge-fmt` labels. + +### Fixing a Bug or Developing a Feature + +1. Specify the issue being addressed in the PR description. +2. Add a note on your solution in the PR description. +3. Ensure the PR includes comprehensive acceptance tests under `fmt/testdata/`, covering: + * The specific case being fixed/added. + * Behavior with different kinds of comments (isolated, mixed, trailing). + * If it's a new config value, tests covering all available options. + +### Testing -The default configuration values can be overridden from within the expected file by adding a comment in the format -`// config: {config_entry} = {config_value}`. For example: +Tests are located in the `fmt/testdata` folder. Each test consists of an `original.sol` file and one or more expected output files, named `*.fmt.sol`. +The default configuration can be overridden from within an expected output file by adding a comment in the format `// config: {config_key} = {config_value}`. For example: ```solidity // config: line_length = 160 ``` -The `test_directory` macro is used to specify a new folder with source files for the test suite. Each test suite has the -following process: +The testing process for each test suite is as follows: +1. Read `original.sol` and the corresponding `*.fmt.sol` expected output. +2. Parse any `// config:` comments from the expected file to create a test-specific configuration. +3. Format `original.sol` and assert that the output matches the content of `*.fmt.sol`. +4. To ensure **idempotency**, format the content of `*.fmt.sol` again and assert that the output does not change. -1. Preparse comments with config values -2. Parse and compare the AST for source & expected files. - - The `AstEq` trait defines the comparison rules for the AST nodes -3. Format the source file and assert the equality of the output with the expected file. -4. Format the expected files and assert the idempotency of the formatting operation. +### Debug -## Contributing +The formatter includes a debug mode that provides visual insight into the pretty-printer's decision-making process. This is invaluable for troubleshooting complex formatting issues and understanding how the boxes and breaks described in [The Pretty Printer](#the-pretty-printer-pp) section work. -Check out the [foundry contribution guide](https://github.com/foundry-rs/foundry/blob/master/CONTRIBUTING.md). +To enable it, run the formatter with the `FMT_DEBUG` environment variable set: +```sh +FMT_DEBUG=1 cargo test -p forge-fmt --test formatter Repros +``` -Guidelines for contributing to `forge fmt`: +When enabled, the output will be annotated with special characters representing the printer's internal state: -### Opening an issue +* **Boxes**: + * `«` and `»`: Mark the start and end of a **consistent** box (`cbox`). + * `‹` and `›`: Mark the start and end of an **inconsistent** box (`ibox`). + +* **Breaks**: + * `·`: Represents a `Break` token, which could be a space or a newline. + +For example, running debug mode on the `HelloWorld` contract from earlier would produce an output like this: + +```text +pragma solidity ^0.8.10;· +· +«‹«contract HelloWorld »{›· +‹‹ string· public· message››;· +· +« constructor«(«‹‹string memory initMessage››»)» {»· +«‹ message = ·initMessage›·;· +» }· +»}· +· +event Greet(««‹‹string indexed name››»»);· +``` -1. Create a short concise title describing an issue. - - Bad Title Examples - ```text - Forge fmt does not work - Forge fmt breaks - Forge fmt unexpected behavior - ``` - - Good Title Examples - ```text - Forge fmt postfix comment misplaced - Forge fmt does not inline short yul blocks - ``` -2. Fill in the issue template fields that include foundry version, platform & component info. -3. Provide the code snippets showing the current & expected behaviors. -4. If it's a feature request, specify why this feature is needed. -5. Besides the default label (`T-Bug` for bugs or `T-feature` for features), add `C-forge` and `Cmd-forge-fmt` labels. - -### Fixing A Bug - -1. Specify an issue that is being addressed in the PR description. -2. Add a note on the solution in the PR description. -3. Make sure the PR includes the acceptance test(s). - -### Developing A Feature - -1. Specify an issue that is being addressed in the PR description. -2. Add a note on the solution in the PR description. -3. Provide the test coverage for the new feature. These should include: - - Adding malformatted & expected solidity code under `fmt/testdata/$dir/` - - Testing the behavior of pre and postfix comments - - If it's a new config value, tests covering **all** available options +This annotated output allows you to see exactly how the printer is grouping tokens and where it considers inserting a space or a newline. This makes it much easier to diagnose why a certain layout is being produced. diff --git a/crates/fmt/src/buffer.rs b/crates/fmt/src/buffer.rs deleted file mode 100644 index c9281faed4a09..0000000000000 --- a/crates/fmt/src/buffer.rs +++ /dev/null @@ -1,497 +0,0 @@ -//! Format buffer. - -use crate::{ - comments::{CommentState, CommentStringExt}, - string::{QuoteState, QuotedStringExt}, -}; -use foundry_config::fmt::IndentStyle; -use std::fmt::Write; - -/// An indent group. The group may optionally skip the first line -#[derive(Clone, Debug, Default)] -struct IndentGroup { - skip_line: bool, -} - -#[derive(Clone, Copy, Debug)] -enum WriteState { - LineStart(CommentState), - WriteTokens(CommentState), - WriteString(char), -} - -impl WriteState { - fn comment_state(&self) -> CommentState { - match self { - Self::LineStart(state) => *state, - Self::WriteTokens(state) => *state, - Self::WriteString(_) => CommentState::None, - } - } -} - -impl Default for WriteState { - fn default() -> Self { - Self::LineStart(CommentState::default()) - } -} - -/// A wrapper around a `std::fmt::Write` interface. The wrapper keeps track of indentation as well -/// as information about the last `write_str` command if available. The formatter may also be -/// restricted to a single line, in which case it will throw an error on a newline -#[derive(Clone, Debug)] -pub struct FormatBuffer { - pub w: W, - indents: Vec, - base_indent_len: usize, - tab_width: usize, - style: IndentStyle, - last_char: Option, - current_line_len: usize, - restrict_to_single_line: bool, - state: WriteState, -} - -impl FormatBuffer { - pub fn new(w: W, tab_width: usize, style: IndentStyle) -> Self { - Self { - w, - tab_width, - style, - base_indent_len: 0, - indents: vec![], - current_line_len: 0, - last_char: None, - restrict_to_single_line: false, - state: WriteState::default(), - } - } - - /// Create a new temporary buffer based on an existing buffer which retains information about - /// the buffer state, but has a blank String as its underlying `Write` interface - pub fn create_temp_buf(&self) -> FormatBuffer { - let mut new = FormatBuffer::new(String::new(), self.tab_width, self.style); - new.base_indent_len = self.total_indent_len(); - new.current_line_len = self.current_line_len(); - new.last_char = self.last_char; - new.restrict_to_single_line = self.restrict_to_single_line; - new.state = match self.state { - WriteState::WriteTokens(state) | WriteState::LineStart(state) => { - WriteState::LineStart(state) - } - WriteState::WriteString(ch) => WriteState::WriteString(ch), - }; - new - } - - /// Restrict the buffer to a single line - pub fn restrict_to_single_line(&mut self, restricted: bool) { - self.restrict_to_single_line = restricted; - } - - /// Indent the buffer by delta - pub fn indent(&mut self, delta: usize) { - self.indents.extend(std::iter::repeat_n(IndentGroup::default(), delta)); - } - - /// Dedent the buffer by delta - pub fn dedent(&mut self, delta: usize) { - self.indents.truncate(self.indents.len() - delta); - } - - /// Get the current level of the indent. This is multiplied by the tab width to get the - /// resulting indent - fn level(&self) -> usize { - self.indents.iter().filter(|i| !i.skip_line).count() - } - - /// Check if the last indent group is being skipped - pub fn last_indent_group_skipped(&self) -> bool { - self.indents.last().map(|i| i.skip_line).unwrap_or(false) - } - - /// Set whether the last indent group should be skipped - pub fn set_last_indent_group_skipped(&mut self, skip_line: bool) { - if let Some(i) = self.indents.last_mut() { - i.skip_line = skip_line - } - } - - /// Get the current indent size. level * tab_width for spaces and level for tabs - pub fn current_indent_len(&self) -> usize { - match self.style { - IndentStyle::Space => self.level() * self.tab_width, - IndentStyle::Tab => self.level(), - } - } - - /// Get the char used for indent - pub fn indent_char(&self) -> char { - match self.style { - IndentStyle::Space => ' ', - IndentStyle::Tab => '\t', - } - } - - /// Get the indent len for the given level - pub fn get_indent_len(&self, level: usize) -> usize { - match self.style { - IndentStyle::Space => level * self.tab_width, - IndentStyle::Tab => level, - } - } - - /// Get the total indent size - pub fn total_indent_len(&self) -> usize { - self.current_indent_len() + self.base_indent_len - } - - /// Get the current written position (this does not include the indent size) - pub fn current_line_len(&self) -> usize { - self.current_line_len - } - - /// Check if the buffer is at the beginning of a new line - pub fn is_beginning_of_line(&self) -> bool { - matches!(self.state, WriteState::LineStart(_)) - } - - /// Start a new indent group (skips first indent) - pub fn start_group(&mut self) { - self.indents.push(IndentGroup { skip_line: true }); - } - - /// End the last indent group - pub fn end_group(&mut self) { - self.indents.pop(); - } - - /// Get the last char written to the buffer - pub fn last_char(&self) -> Option { - self.last_char - } - - /// When writing a newline apply state changes - fn handle_newline(&mut self, mut comment_state: CommentState) { - if comment_state == CommentState::Line { - comment_state = CommentState::None; - } - self.current_line_len = 0; - self.set_last_indent_group_skipped(false); - self.last_char = Some('\n'); - self.state = WriteState::LineStart(comment_state); - } -} - -impl FormatBuffer { - /// Write a raw string to the buffer. This will ignore indents and remove the indents of the - /// written string to match the current base indent of this buffer if it is a temp buffer - pub fn write_raw(&mut self, s: impl AsRef) -> std::fmt::Result { - self._write_raw(s.as_ref()) - } - - fn _write_raw(&mut self, s: &str) -> std::fmt::Result { - let mut lines = s.lines().peekable(); - let mut comment_state = self.state.comment_state(); - while let Some(line) = lines.next() { - // remove the whitespace that covered by the base indent length (this is normally the - // case with temporary buffers as this will be re-added by the underlying IndentWriter - // later on - let (new_comment_state, line_start) = line - .comment_state_char_indices() - .with_state(comment_state) - .take(self.base_indent_len) - .take_while(|(_, _, ch)| ch.is_whitespace()) - .last() - .map(|(state, idx, ch)| (state, idx + ch.len_utf8())) - .unwrap_or((comment_state, 0)); - comment_state = new_comment_state; - let trimmed_line = &line[line_start..]; - if !trimmed_line.is_empty() { - self.w.write_str(trimmed_line)?; - self.current_line_len += trimmed_line.len(); - self.last_char = trimmed_line.chars().next_back(); - self.state = WriteState::WriteTokens(comment_state); - } - if lines.peek().is_some() || s.ends_with('\n') { - if self.restrict_to_single_line { - return Err(std::fmt::Error); - } - self.w.write_char('\n')?; - self.handle_newline(comment_state); - } - } - Ok(()) - } -} - -impl Write for FormatBuffer { - fn write_str(&mut self, mut s: &str) -> std::fmt::Result { - if s.is_empty() { - return Ok(()); - } - - let mut indent = self.indent_char().to_string().repeat(self.current_indent_len()); - - loop { - match self.state { - WriteState::LineStart(mut comment_state) => { - match s.find(|b| b != '\n') { - // No non-empty lines in input, write the entire string (only newlines) - None => { - if !s.is_empty() { - self.w.write_str(s)?; - self.handle_newline(comment_state); - } - break; - } - - // We can see the next non-empty line. Write up to the - // beginning of that line, then insert an indent, then - // continue. - Some(len) => { - let (head, tail) = s.split_at(len); - self.w.write_str(head)?; - self.w.write_str(&indent)?; - self.current_line_len = 0; - self.last_char = Some(self.indent_char()); - // a newline has been inserted - if len > 0 { - if self.last_indent_group_skipped() { - indent = self - .indent_char() - .to_string() - .repeat(self.get_indent_len(self.level() + 1)); - self.set_last_indent_group_skipped(false); - } - if comment_state == CommentState::Line { - comment_state = CommentState::None; - } - } - s = tail; - self.state = WriteState::WriteTokens(comment_state); - } - } - } - WriteState::WriteTokens(comment_state) => { - if s.is_empty() { - break; - } - - // find the next newline or non-comment string separator (e.g. ' or ") - let mut len = 0; - let mut new_state = WriteState::WriteTokens(comment_state); - for (state, idx, ch) in s.comment_state_char_indices().with_state(comment_state) - { - len = idx; - if ch == '\n' { - if self.restrict_to_single_line { - return Err(std::fmt::Error); - } - new_state = WriteState::LineStart(state); - break; - } else if state == CommentState::None && (ch == '\'' || ch == '"') { - new_state = WriteState::WriteString(ch); - break; - } else { - new_state = WriteState::WriteTokens(state); - } - } - - if matches!(new_state, WriteState::WriteTokens(_)) { - // No newlines or strings found, write the entire string - self.w.write_str(s)?; - self.current_line_len += s.len(); - self.last_char = s.chars().next_back(); - self.state = new_state; - break; - } else { - // A newline or string has been found. Write up to that character and - // continue on the tail - let (head, tail) = s.split_at(len + 1); - self.w.write_str(head)?; - s = tail; - match new_state { - WriteState::LineStart(comment_state) => { - self.handle_newline(comment_state) - } - new_state => { - self.current_line_len += head.len(); - self.last_char = head.chars().next_back(); - self.state = new_state; - } - } - } - } - WriteState::WriteString(quote) => { - match s.quoted_ranges().with_state(QuoteState::String(quote)).next() { - // No end found, write the rest of the string - None => { - self.w.write_str(s)?; - self.current_line_len += s.len(); - self.last_char = s.chars().next_back(); - break; - } - // String end found, write the string and continue to add tokens after - Some((_, _, len)) => { - let (head, tail) = s.split_at(len + 1); - self.w.write_str(head)?; - if let Some((_, last)) = head.rsplit_once('\n') { - self.set_last_indent_group_skipped(false); - self.current_line_len = last.len(); - } else { - self.current_line_len += head.len(); - } - self.last_char = Some(quote); - s = tail; - self.state = WriteState::WriteTokens(CommentState::None); - } - } - } - } - } - - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - const TAB_WIDTH: usize = 4; - - #[test] - fn test_buffer_indents() -> std::fmt::Result { - let delta = 1; - - let mut buf = FormatBuffer::new(String::new(), TAB_WIDTH, IndentStyle::Space); - assert_eq!(buf.indents.len(), 0); - assert_eq!(buf.level(), 0); - assert_eq!(buf.current_indent_len(), 0); - assert_eq!(buf.style, IndentStyle::Space); - - buf.indent(delta); - assert_eq!(buf.indents.len(), delta); - assert_eq!(buf.level(), delta); - assert_eq!(buf.current_indent_len(), delta * TAB_WIDTH); - - buf.indent(delta); - buf.set_last_indent_group_skipped(true); - assert!(buf.last_indent_group_skipped()); - assert_eq!(buf.indents.len(), delta * 2); - assert_eq!(buf.level(), delta); - assert_eq!(buf.current_indent_len(), delta * TAB_WIDTH); - buf.dedent(delta); - - buf.dedent(delta); - assert_eq!(buf.indents.len(), 0); - assert_eq!(buf.level(), 0); - assert_eq!(buf.current_indent_len(), 0); - - // panics on extra dedent - let res = std::panic::catch_unwind(|| buf.clone().dedent(delta)); - assert!(res.is_err()); - - Ok(()) - } - - #[test] - fn test_identical_temp_buf() -> std::fmt::Result { - let content = "test string"; - let multiline_content = "test\nmultiline\nmultiple"; - let mut buf = FormatBuffer::new(String::new(), TAB_WIDTH, IndentStyle::Space); - - // create identical temp buf - let mut temp = buf.create_temp_buf(); - writeln!(buf, "{content}")?; - writeln!(temp, "{content}")?; - assert_eq!(buf.w, format!("{content}\n")); - assert_eq!(temp.w, buf.w); - assert_eq!(temp.current_line_len, buf.current_line_len); - assert_eq!(temp.base_indent_len, buf.total_indent_len()); - - let delta = 1; - buf.indent(delta); - - let mut temp_indented = buf.create_temp_buf(); - assert!(temp_indented.w.is_empty()); - assert_eq!(temp_indented.base_indent_len, buf.total_indent_len()); - assert_eq!(temp_indented.level() + delta, buf.level()); - - let indent = " ".repeat(delta * TAB_WIDTH); - - let mut original_buf = buf.clone(); - write!(buf, "{multiline_content}")?; - let expected_content = format!( - "{}\n{}{}", - content, - indent, - multiline_content.lines().collect::>().join(&format!("\n{indent}")) - ); - assert_eq!(buf.w, expected_content); - - write!(temp_indented, "{multiline_content}")?; - - // write temp buf to original and assert the result - write!(original_buf, "{}", temp_indented.w)?; - assert_eq!(buf.w, original_buf.w); - - Ok(()) - } - - #[test] - fn test_preserves_original_content_with_default_settings() -> std::fmt::Result { - let contents = [ - "simple line", - r" - some - multiline - content", - "// comment", - "/* comment */", - r"mutliline - content - // comment1 - with comments - /* comment2 */ ", - ]; - - for content in &contents { - let mut buf = FormatBuffer::new(String::new(), TAB_WIDTH, IndentStyle::Space); - write!(buf, "{content}")?; - assert_eq!(&buf.w, content); - } - - Ok(()) - } - - #[test] - fn test_indent_char() -> std::fmt::Result { - assert_eq!( - FormatBuffer::new(String::new(), TAB_WIDTH, IndentStyle::Space).indent_char(), - ' ' - ); - assert_eq!( - FormatBuffer::new(String::new(), TAB_WIDTH, IndentStyle::Tab).indent_char(), - '\t' - ); - Ok(()) - } - - #[test] - fn test_indent_len() -> std::fmt::Result { - // Space should use level * TAB_WIDTH - let mut buf = FormatBuffer::new(String::new(), TAB_WIDTH, IndentStyle::Space); - assert_eq!(buf.current_indent_len(), 0); - buf.indent(2); - assert_eq!(buf.current_indent_len(), 2 * TAB_WIDTH); - - // Tab should use level - buf = FormatBuffer::new(String::new(), TAB_WIDTH, IndentStyle::Tab); - assert_eq!(buf.current_indent_len(), 0); - buf.indent(2); - assert_eq!(buf.current_indent_len(), 2); - Ok(()) - } -} diff --git a/crates/fmt/src/chunk.rs b/crates/fmt/src/chunk.rs deleted file mode 100644 index 7d9ce25c7fbd0..0000000000000 --- a/crates/fmt/src/chunk.rs +++ /dev/null @@ -1,60 +0,0 @@ -use crate::comments::CommentWithMetadata; - -/// Holds information about a non-whitespace-splittable string, and the surrounding comments -#[derive(Clone, Debug, Default)] -pub struct Chunk { - pub postfixes_before: Vec, - pub prefixes: Vec, - pub content: String, - pub postfixes: Vec, - pub needs_space: Option, -} - -impl From for Chunk { - fn from(string: String) -> Self { - Self { content: string, ..Default::default() } - } -} - -impl From<&str> for Chunk { - fn from(string: &str) -> Self { - Self { content: string.to_owned(), ..Default::default() } - } -} - -// The struct with information about chunks used in the [Formatter::surrounded] method -#[derive(Debug)] -pub struct SurroundingChunk { - pub before: Option, - pub next: Option, - pub spaced: Option, - pub content: String, -} - -impl SurroundingChunk { - pub fn new( - content: impl std::fmt::Display, - before: Option, - next: Option, - ) -> Self { - Self { before, next, content: format!("{content}"), spaced: None } - } - - pub fn spaced(mut self) -> Self { - self.spaced = Some(true); - self - } - - pub fn non_spaced(mut self) -> Self { - self.spaced = Some(false); - self - } - - pub fn loc_before(&self) -> usize { - self.before.unwrap_or_default() - } - - pub fn loc_next(&self) -> Option { - self.next - } -} diff --git a/crates/fmt/src/comments.rs b/crates/fmt/src/comments.rs deleted file mode 100644 index cb69181315005..0000000000000 --- a/crates/fmt/src/comments.rs +++ /dev/null @@ -1,456 +0,0 @@ -use crate::inline_config::{InlineConfigItem, InvalidInlineConfigItem}; -use itertools::Itertools; -use solang_parser::pt::*; -use std::collections::VecDeque; - -/// The type of a Comment -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum CommentType { - /// A Line comment (e.g. `// ...`) - Line, - /// A Block comment (e.g. `/* ... */`) - Block, - /// A Doc Line comment (e.g. `/// ...`) - DocLine, - /// A Doc Block comment (e.g. `/** ... */`) - DocBlock, -} - -/// The comment position -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum CommentPosition { - /// Comes before the code it describes - Prefix, - /// Comes after the code it describes - Postfix, -} - -/// Comment with additional metadata -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CommentWithMetadata { - pub ty: CommentType, - pub loc: Loc, - pub has_newline_before: bool, - pub indent_len: usize, - pub comment: String, - pub position: CommentPosition, -} - -impl PartialOrd for CommentWithMetadata { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl Ord for CommentWithMetadata { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { - self.loc.cmp(&other.loc) - } -} - -impl CommentWithMetadata { - fn new( - comment: Comment, - position: CommentPosition, - has_newline_before: bool, - indent_len: usize, - ) -> Self { - let (ty, loc, comment) = match comment { - Comment::Line(loc, comment) => (CommentType::Line, loc, comment), - Comment::Block(loc, comment) => (CommentType::Block, loc, comment), - Comment::DocLine(loc, comment) => (CommentType::DocLine, loc, comment), - Comment::DocBlock(loc, comment) => (CommentType::DocBlock, loc, comment), - }; - Self { - comment: comment.trim_end().to_string(), - ty, - loc, - position, - has_newline_before, - indent_len, - } - } - - /// Construct a comment with metadata by analyzing its surrounding source code - fn from_comment_and_src(comment: Comment, src: &str, last_comment: Option<&Self>) -> Self { - let src_before = &src[..comment.loc().start()]; - if src_before.is_empty() { - return Self::new(comment, CommentPosition::Prefix, false, 0); - } - - let mut lines_before = src_before.lines().rev(); - let this_line = - if src_before.ends_with('\n') { "" } else { lines_before.next().unwrap_or_default() }; - let indent_len = this_line.chars().take_while(|c| c.is_whitespace()).count(); - let last_line = lines_before.next().map(str::trim_start); - - if matches!(comment, Comment::DocLine(..) | Comment::DocBlock(..)) { - return Self::new( - comment, - CommentPosition::Prefix, - last_line.is_none_or(str::is_empty), - indent_len, - ); - } - - // TODO: this loop takes almost the entirety of the time spent in parsing, which is up to - // 80% of `crate::fmt` - let mut code_end = 0; - for (state, idx, ch) in src_before.comment_state_char_indices() { - if matches!(state, CommentState::None) && !ch.is_whitespace() { - code_end = idx; - } - } - - let (position, has_newline_before) = if src_before[code_end..].contains('\n') { - // comment sits on a line without code - if let Some(last_line) = last_line { - if last_line.is_empty() { - // line before is empty - (CommentPosition::Prefix, true) - } else { - // line has something - // check if the last comment after code was a postfix comment - if last_comment - .is_some_and(|last| last.loc.end() > code_end && !last.is_prefix()) - { - // get the indent size of the next item of code - let next_indent_len = src[comment.loc().end()..] - .non_comment_chars() - .take_while(|ch| ch.is_whitespace()) - .fold(indent_len, |indent, ch| if ch == '\n' { 0 } else { indent + 1 }); - if indent_len > next_indent_len { - // the comment indent is bigger than the next code indent - (CommentPosition::Postfix, false) - } else { - // the comment indent is equal to or less than the next code - // indent - (CommentPosition::Prefix, false) - } - } else { - // if there is no postfix comment after the piece of code - (CommentPosition::Prefix, false) - } - } - } else { - // beginning of file - (CommentPosition::Prefix, false) - } - } else { - // comment is after some code - (CommentPosition::Postfix, false) - }; - - Self::new(comment, position, has_newline_before, indent_len) - } - - pub fn is_line(&self) -> bool { - matches!(self.ty, CommentType::Line | CommentType::DocLine) - } - - pub fn is_doc_block(&self) -> bool { - matches!(self.ty, CommentType::DocBlock) - } - - pub fn is_prefix(&self) -> bool { - matches!(self.position, CommentPosition::Prefix) - } - - pub fn is_before(&self, byte: usize) -> bool { - self.loc.start() < byte - } - - /// Returns the contents of the comment without the start and end tokens - pub fn contents(&self) -> &str { - let mut s = self.comment.as_str(); - if let Some(stripped) = s.strip_prefix(self.start_token()) { - s = stripped; - } - if let Some(end_token) = self.end_token() - && let Some(stripped) = s.strip_suffix(end_token) - { - s = stripped; - } - s - } - - /// The start token of the comment - #[inline] - pub const fn start_token(&self) -> &'static str { - match self.ty { - CommentType::Line => "//", - CommentType::Block => "/*", - CommentType::DocLine => "///", - CommentType::DocBlock => "/**", - } - } - - /// The token that gets written on the newline when the - /// comment is wrapped - #[inline] - pub const fn wrap_token(&self) -> &'static str { - match self.ty { - CommentType::Line => "// ", - CommentType::DocLine => "/// ", - CommentType::Block => "", - CommentType::DocBlock => " * ", - } - } - - /// The end token of the comment - #[inline] - pub const fn end_token(&self) -> Option<&'static str> { - match self.ty { - CommentType::Line | CommentType::DocLine => None, - CommentType::Block | CommentType::DocBlock => Some("*/"), - } - } -} - -/// A list of comments -#[derive(Clone, Debug, Default)] -pub struct Comments { - prefixes: VecDeque, - postfixes: VecDeque, -} - -impl Comments { - pub fn new(mut comments: Vec, src: &str) -> Self { - let mut prefixes = VecDeque::with_capacity(comments.len()); - let mut postfixes = VecDeque::with_capacity(comments.len()); - let mut last_comment = None; - - comments.sort_by_key(|comment| comment.loc()); - for comment in comments { - let comment = CommentWithMetadata::from_comment_and_src(comment, src, last_comment); - let vec = if comment.is_prefix() { &mut prefixes } else { &mut postfixes }; - vec.push_back(comment); - last_comment = Some(vec.back().unwrap()); - } - Self { prefixes, postfixes } - } - - /// Helper for removing comments before a byte offset - fn remove_comments_before( - comments: &mut VecDeque, - byte: usize, - ) -> Vec { - let pos = comments - .iter() - .find_position(|comment| !comment.is_before(byte)) - .map(|(idx, _)| idx) - .unwrap_or_else(|| comments.len()); - if pos == 0 { - return Vec::new(); - } - comments.rotate_left(pos); - comments.split_off(comments.len() - pos).into() - } - - /// Remove any prefix comments that occur before the byte offset in the src - pub(crate) fn remove_prefixes_before(&mut self, byte: usize) -> Vec { - Self::remove_comments_before(&mut self.prefixes, byte) - } - - /// Remove any postfix comments that occur before the byte offset in the src - pub(crate) fn remove_postfixes_before(&mut self, byte: usize) -> Vec { - Self::remove_comments_before(&mut self.postfixes, byte) - } - - /// Remove any comments that occur before the byte offset in the src - pub(crate) fn remove_all_comments_before(&mut self, byte: usize) -> Vec { - self.remove_prefixes_before(byte) - .into_iter() - .merge(self.remove_postfixes_before(byte)) - .collect() - } - - pub(crate) fn pop(&mut self) -> Option { - if self.iter().next()?.is_prefix() { - self.prefixes.pop_front() - } else { - self.postfixes.pop_front() - } - } - - pub(crate) fn iter(&self) -> impl Iterator { - self.prefixes.iter().merge(self.postfixes.iter()) - } - - /// Parse all comments to return a list of inline config items. This will return an iterator of - /// results of parsing comments which start with `forgefmt:` - pub fn parse_inline_config_items( - &self, - ) -> impl Iterator> + '_ - { - self.iter() - .filter_map(|comment| { - Some((comment, comment.contents().trim_start().strip_prefix("forgefmt:")?.trim())) - }) - .map(|(comment, item)| { - let loc = comment.loc; - item.parse().map(|out| (loc, out)).map_err(|out| (loc, out)) - }) - } -} - -/// The state of a character in a string with possible comments -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum CommentState { - /// character not in a comment - #[default] - None, - /// First `/` in line comment start `"//"` - LineStart1, - /// Second `/` in line comment start `"//"` - LineStart2, - /// Character in a line comment - Line, - /// `/` in block comment start `"/*"` - BlockStart1, - /// `*` in block comment start `"/*"` - BlockStart2, - /// Character in a block comment - Block, - /// `*` in block comment end `"*/"` - BlockEnd1, - /// `/` in block comment end `"*/"` - BlockEnd2, -} - -/// An Iterator over characters and indices in a string slice with information about the state of -/// comments -pub struct CommentStateCharIndices<'a> { - iter: std::str::CharIndices<'a>, - state: CommentState, -} - -impl<'a> CommentStateCharIndices<'a> { - #[inline] - fn new(string: &'a str) -> Self { - Self { iter: string.char_indices(), state: CommentState::None } - } - - #[inline] - pub fn with_state(mut self, state: CommentState) -> Self { - self.state = state; - self - } - - #[inline] - pub fn peek(&mut self) -> Option<(usize, char)> { - self.iter.clone().next() - } -} - -impl Iterator for CommentStateCharIndices<'_> { - type Item = (CommentState, usize, char); - - #[inline] - fn next(&mut self) -> Option { - let (idx, ch) = self.iter.next()?; - match self.state { - CommentState::None => { - if ch == '/' { - self.state = match self.peek() { - Some((_, '/')) => CommentState::LineStart1, - Some((_, '*')) => CommentState::BlockStart1, - _ => CommentState::None, - }; - } - } - CommentState::LineStart1 => { - self.state = CommentState::LineStart2; - } - CommentState::LineStart2 => { - self.state = CommentState::Line; - } - CommentState::Line => { - if ch == '\n' { - self.state = CommentState::None; - } - } - CommentState::BlockStart1 => { - self.state = CommentState::BlockStart2; - } - CommentState::BlockStart2 => { - self.state = CommentState::Block; - } - CommentState::Block => { - if ch == '*' - && let Some((_, '/')) = self.peek() - { - self.state = CommentState::BlockEnd1; - } - } - CommentState::BlockEnd1 => { - self.state = CommentState::BlockEnd2; - } - CommentState::BlockEnd2 => { - self.state = CommentState::None; - } - } - Some((self.state, idx, ch)) - } - - #[inline] - fn size_hint(&self) -> (usize, Option) { - self.iter.size_hint() - } - - #[inline] - fn count(self) -> usize { - self.iter.count() - } -} - -impl std::iter::FusedIterator for CommentStateCharIndices<'_> {} - -/// An Iterator over characters in a string slice which are not a part of comments -pub struct NonCommentChars<'a>(CommentStateCharIndices<'a>); - -impl Iterator for NonCommentChars<'_> { - type Item = char; - - #[inline] - fn next(&mut self) -> Option { - for (state, _, ch) in self.0.by_ref() { - if state == CommentState::None { - return Some(ch); - } - } - None - } -} - -/// Helpers for iterating over comment containing strings -pub trait CommentStringExt { - fn comment_state_char_indices(&self) -> CommentStateCharIndices<'_>; - - #[inline] - fn non_comment_chars(&self) -> NonCommentChars<'_> { - NonCommentChars(self.comment_state_char_indices()) - } - - #[inline] - fn trim_comments(&self) -> String { - self.non_comment_chars().collect() - } -} - -impl CommentStringExt for T -where - T: AsRef, -{ - #[inline] - fn comment_state_char_indices(&self) -> CommentStateCharIndices<'_> { - CommentStateCharIndices::new(self.as_ref()) - } -} - -impl CommentStringExt for str { - #[inline] - fn comment_state_char_indices(&self) -> CommentStateCharIndices<'_> { - CommentStateCharIndices::new(self) - } -} diff --git a/crates/fmt/src/formatter.rs b/crates/fmt/src/formatter.rs deleted file mode 100644 index c0fcbdfc65fb8..0000000000000 --- a/crates/fmt/src/formatter.rs +++ /dev/null @@ -1,3895 +0,0 @@ -//! A Solidity formatter - -use crate::{ - FormatterConfig, InlineConfig, IntTypes, - buffer::*, - chunk::*, - comments::{ - CommentPosition, CommentState, CommentStringExt, CommentType, CommentWithMetadata, Comments, - }, - format_diagnostics_report, - helpers::import_path_string, - macros::*, - solang_ext::{pt::*, *}, - string::{QuoteState, QuotedStringExt}, - visit::{Visitable, Visitor}, -}; -use alloy_primitives::Address; -use foundry_config::fmt::{HexUnderscore, MultilineFuncHeaderStyle, SingleLineBlockStyle}; -use itertools::{Either, Itertools}; -use solang_parser::diagnostics::Diagnostic; -use std::{fmt::Write, path::PathBuf, str::FromStr}; -use thiserror::Error; - -type Result = std::result::Result; - -/// A custom Error thrown by the Formatter -#[derive(Debug, Error)] -pub enum FormatterError { - /// Error thrown by `std::fmt::Write` interfaces - #[error(transparent)] - Fmt(#[from] std::fmt::Error), - /// Encountered invalid parse tree item. - #[error("encountered invalid parse tree item at {0:?}")] - InvalidParsedItem(Loc), - /// Failed to parse the source code - #[error("failed to parse file:\n{}", format_diagnostics_report(_0, _1.as_deref(), _2))] - Parse(String, Option, Vec), - /// All other errors - #[error(transparent)] - Custom(Box), -} - -impl FormatterError { - fn fmt() -> Self { - Self::Fmt(std::fmt::Error) - } - - fn custom(err: impl std::error::Error + Send + Sync + 'static) -> Self { - Self::Custom(Box::new(err)) - } -} - -#[expect(unused_macros)] -macro_rules! format_err { - ($msg:literal $(,)?) => { - $crate::formatter::FormatterError::custom($msg.to_string()) - }; - ($err:expr $(,)?) => { - $crate::formatter::FormatterError::custom($err) - }; - ($fmt:expr, $($arg:tt)*) => { - $crate::formatter::FormatterError::custom(format!($fmt, $($arg)*)) - }; -} - -macro_rules! bail { - ($msg:literal $(,)?) => { - return Err($crate::formatter::format_err!($msg)) - }; - ($err:expr $(,)?) => { - return Err($err) - }; - ($fmt:expr, $($arg:tt)*) => { - return Err($crate::formatter::format_err!($fmt, $(arg)*)) - }; -} - -// TODO: store context entities as references without copying -/// Current context of the Formatter (e.g. inside Contract or Function definition) -#[derive(Debug, Default)] -struct Context { - contract: Option, - function: Option, - if_stmt_single_line: Option, -} - -impl Context { - /// Returns true if the current function context is the constructor - pub(crate) fn is_constructor_function(&self) -> bool { - self.function.as_ref().is_some_and(|f| matches!(f.ty, FunctionTy::Constructor)) - } -} - -/// A Solidity formatter -#[derive(Debug)] -pub struct Formatter<'a, W> { - buf: FormatBuffer, - source: &'a str, - config: FormatterConfig, - temp_bufs: Vec>, - context: Context, - comments: Comments, - inline_config: InlineConfig, -} - -impl<'a, W: Write> Formatter<'a, W> { - pub fn new( - w: W, - source: &'a str, - comments: Comments, - inline_config: InlineConfig, - config: FormatterConfig, - ) -> Self { - Self { - buf: FormatBuffer::new(w, config.tab_width, config.style), - source, - config, - temp_bufs: Vec::new(), - context: Context::default(), - comments, - inline_config, - } - } - - /// Get the Write interface of the current temp buffer or the underlying Write - fn buf(&mut self) -> &mut dyn Write { - match &mut self.temp_bufs[..] { - [] => &mut self.buf as &mut dyn Write, - [.., buf] => buf as &mut dyn Write, - } - } - - /// Casts the current writer `w` as a `String` reference. Should only be used for debugging. - unsafe fn buf_contents(&self) -> &String { - unsafe { *(&raw const self.buf.w as *const &mut String) } - } - - /// Casts the current `W` writer or the current temp buffer as a `String` reference. - /// Should only be used for debugging. - #[expect(dead_code)] - unsafe fn temp_buf_contents(&self) -> &String { - match &self.temp_bufs[..] { - [] => unsafe { self.buf_contents() }, - [.., buf] => &buf.w, - } - } - - buf_fn! { fn indent(&mut self, delta: usize) } - buf_fn! { fn dedent(&mut self, delta: usize) } - buf_fn! { fn start_group(&mut self) } - buf_fn! { fn end_group(&mut self) } - buf_fn! { fn create_temp_buf(&self) -> FormatBuffer } - buf_fn! { fn restrict_to_single_line(&mut self, restricted: bool) } - buf_fn! { fn current_line_len(&self) -> usize } - buf_fn! { fn total_indent_len(&self) -> usize } - buf_fn! { fn is_beginning_of_line(&self) -> bool } - buf_fn! { fn last_char(&self) -> Option } - buf_fn! { fn last_indent_group_skipped(&self) -> bool } - buf_fn! { fn set_last_indent_group_skipped(&mut self, skip: bool) } - buf_fn! { fn write_raw(&mut self, s: impl AsRef) -> std::fmt::Result } - buf_fn! { fn indent_char(&self) -> char } - - /// Do the callback within the context of a temp buffer - fn with_temp_buf( - &mut self, - mut fun: impl FnMut(&mut Self) -> Result<()>, - ) -> Result> { - self.temp_bufs.push(self.create_temp_buf()); - let res = fun(self); - let out = self.temp_bufs.pop().unwrap(); - res?; - Ok(out) - } - - /// Does the next written character require whitespace before - fn next_char_needs_space(&self, next_char: char) -> bool { - if self.is_beginning_of_line() { - return false; - } - let last_char = - if let Some(last_char) = self.last_char() { last_char } else { return false }; - if last_char.is_whitespace() || next_char.is_whitespace() { - return false; - } - match last_char { - '{' => match next_char { - '{' | '[' | '(' => false, - '/' => true, - _ => self.config.bracket_spacing, - }, - '(' | '.' | '[' => matches!(next_char, '/'), - '/' => true, - _ => match next_char { - '}' => self.config.bracket_spacing, - ')' | ',' | '.' | ';' | ']' => false, - _ => true, - }, - } - } - - /// Is length of the `text` with respect to already written line <= `config.line_length` - fn will_it_fit(&self, text: impl AsRef) -> bool { - let text = text.as_ref(); - if text.is_empty() { - return true; - } - if text.contains('\n') { - return false; - } - let space: usize = self.next_char_needs_space(text.chars().next().unwrap()).into(); - self.config.line_length - >= self - .total_indent_len() - .saturating_add(self.current_line_len()) - .saturating_add(text.chars().count() + space) - } - - /// Write empty brackets with respect to `config.bracket_spacing` setting: - /// `"{ }"` if `true`, `"{}"` if `false` - fn write_empty_brackets(&mut self) -> Result<()> { - let brackets = if self.config.bracket_spacing { "{ }" } else { "{}" }; - write_chunk!(self, "{brackets}")?; - Ok(()) - } - - /// Write semicolon to the buffer - fn write_semicolon(&mut self) -> Result<()> { - write!(self.buf(), ";")?; - Ok(()) - } - - /// Write whitespace separator to the buffer - /// `"\n"` if `multiline` is `true`, `" "` if `false` - fn write_whitespace_separator(&mut self, multiline: bool) -> Result<()> { - if !self.is_beginning_of_line() { - write!(self.buf(), "{}", if multiline { "\n" } else { " " })?; - } - Ok(()) - } - - /// Write new line with preserved `last_indent_group_skipped` flag - fn write_preserved_line(&mut self) -> Result<()> { - let last_indent_group_skipped = self.last_indent_group_skipped(); - writeln!(self.buf())?; - self.set_last_indent_group_skipped(last_indent_group_skipped); - Ok(()) - } - - /// Write unformatted src and comments for given location. - fn write_raw_src(&mut self, loc: Loc) -> Result<()> { - let disabled_stmts_src = String::from_utf8(self.source.as_bytes()[loc.range()].to_vec()) - .map_err(FormatterError::custom)?; - self.write_raw(disabled_stmts_src.trim_end())?; - self.write_whitespace_separator(true)?; - // Remove comments as they're already included in disabled src. - let _ = self.comments.remove_all_comments_before(loc.end()); - Ok(()) - } - - /// Returns number of blank lines in source between two byte indexes - fn blank_lines(&self, start: usize, end: usize) -> usize { - // because of sorting import statements, start can be greater than end - if start > end { - return 0; - } - self.source[start..end].trim_comments().matches('\n').count() - } - - /// Get the byte offset of the next line - fn find_next_line(&self, byte_offset: usize) -> Option { - let mut iter = self.source[byte_offset..].char_indices(); - while let Some((_, ch)) = iter.next() { - match ch { - '\n' => return iter.next().map(|(idx, _)| byte_offset + idx), - '\r' => { - return iter.next().and_then(|(idx, ch)| match ch { - '\n' => iter.next().map(|(idx, _)| byte_offset + idx), - _ => Some(byte_offset + idx), - }); - } - _ => {} - } - } - None - } - - /// Find the next instance of the character in source excluding comments - fn find_next_in_src(&self, byte_offset: usize, needle: char) -> Option { - self.source[byte_offset..] - .comment_state_char_indices() - .position(|(state, _, ch)| needle == ch && state == CommentState::None) - .map(|p| byte_offset + p) - } - - /// Find the start of the next instance of a slice in source - fn find_next_str_in_src(&self, byte_offset: usize, needle: &str) -> Option { - let subset = &self.source[byte_offset..]; - needle.chars().next().and_then(|first_char| { - subset - .comment_state_char_indices() - .position(|(state, idx, ch)| { - first_char == ch - && state == CommentState::None - && idx + needle.len() <= subset.len() - && subset[idx..idx + needle.len()] == *needle - }) - .map(|p| byte_offset + p) - }) - } - - /// Extends the location to the next instance of a character. Returns true if the loc was - /// extended - fn extend_loc_until(&self, loc: &mut Loc, needle: char) -> bool { - if let Some(end) = self.find_next_in_src(loc.end(), needle).map(|offset| offset + 1) { - *loc = loc.with_end(end); - true - } else { - false - } - } - - /// Return the flag whether the attempt should be made - /// to write the block on a single line. - /// If the block style is configured to [SingleLineBlockStyle::Preserve], - /// lookup whether there was a newline introduced in `[start_from, end_at]` range - /// where `end_at` is the start of the block. - fn should_attempt_block_single_line( - &mut self, - stmt: &mut Statement, - start_from: usize, - ) -> bool { - match self.config.single_line_statement_blocks { - SingleLineBlockStyle::Single => true, - SingleLineBlockStyle::Multi => false, - SingleLineBlockStyle::Preserve => { - let end_at = match stmt { - Statement::Block { statements, .. } if !statements.is_empty() => { - statements.first().as_ref().unwrap().loc().start() - } - Statement::Expression(loc, _) => loc.start(), - _ => stmt.loc().start(), - }; - - self.find_next_line(start_from).is_some_and(|loc| loc >= end_at) - } - } - } - - /// Create a chunk given a string and the location information - fn chunk_at( - &mut self, - byte_offset: usize, - next_byte_offset: Option, - needs_space: Option, - content: impl std::fmt::Display, - ) -> Chunk { - Chunk { - postfixes_before: self.comments.remove_postfixes_before(byte_offset), - prefixes: self.comments.remove_prefixes_before(byte_offset), - content: content.to_string(), - postfixes: next_byte_offset - .map(|byte_offset| self.comments.remove_postfixes_before(byte_offset)) - .unwrap_or_default(), - needs_space, - } - } - - /// Create a chunk given a callback - fn chunked( - &mut self, - byte_offset: usize, - next_byte_offset: Option, - mut fun: impl FnMut(&mut Self) -> Result<()>, - ) -> Result { - self.chunked_mono(byte_offset, next_byte_offset, &mut fun) - } - - fn chunked_mono( - &mut self, - byte_offset: usize, - next_byte_offset: Option, - fun: &mut dyn FnMut(&mut Self) -> Result<()>, - ) -> Result { - let postfixes_before = self.comments.remove_postfixes_before(byte_offset); - let prefixes = self.comments.remove_prefixes_before(byte_offset); - let content = self.with_temp_buf(fun)?.w; - let postfixes = next_byte_offset - .map(|byte_offset| self.comments.remove_postfixes_before(byte_offset)) - .unwrap_or_default(); - Ok(Chunk { postfixes_before, prefixes, content, postfixes, needs_space: None }) - } - - /// Create a chunk given a [Visitable] item - fn visit_to_chunk( - &mut self, - byte_offset: usize, - next_byte_offset: Option, - visitable: &mut impl Visitable, - ) -> Result { - self.chunked(byte_offset, next_byte_offset, |fmt| { - visitable.visit(fmt)?; - Ok(()) - }) - } - - /// Transform [Visitable] items to the list of chunks - fn items_to_chunks<'b>( - &mut self, - next_byte_offset: Option, - items: impl Iterator + 'b, - ) -> Result> { - let mut items = items.peekable(); - let mut out = Vec::with_capacity(items.size_hint().1.unwrap_or(0)); - while let Some((loc, item)) = items.next() { - let chunk_next_byte_offset = - items.peek().map(|(loc, _)| loc.start()).or(next_byte_offset); - - let chunk = if self.inline_config.is_disabled(loc) { - // If item format is disabled, we determine last disabled line from item and create - // chunk with raw src. - let mut disabled_loc = loc; - self.chunked(disabled_loc.start(), chunk_next_byte_offset, |fmt| { - while fmt.inline_config.is_disabled(disabled_loc) { - if let Some(next_line) = fmt.find_next_line(disabled_loc.end()) { - disabled_loc = disabled_loc.with_end(next_line); - } else { - break; - } - } - fmt.write_raw_src(disabled_loc)?; - Ok(()) - })? - } else { - self.visit_to_chunk(loc.start(), chunk_next_byte_offset, item)? - }; - out.push(chunk); - } - Ok(out) - } - - /// Transform [Visitable] items to a list of chunks and then sort those chunks. - fn items_to_chunks_sorted<'b>( - &mut self, - next_byte_offset: Option, - items: impl Iterator + 'b, - ) -> Result> { - let mut items = items.peekable(); - let mut out = Vec::with_capacity(items.size_hint().1.unwrap_or(0)); - while let Some(item) = items.next() { - let chunk_next_byte_offset = - items.peek().map(|next| next.loc().start()).or(next_byte_offset); - let chunk = self.visit_to_chunk(item.loc().start(), chunk_next_byte_offset, item)?; - out.push((item, chunk)); - } - out.sort_by(|(a, _), (b, _)| a.cmp(b)); - Ok(out.into_iter().map(|(_, c)| c).collect()) - } - - /// Write a comment to the buffer formatted. - /// WARNING: This may introduce a newline if the comment is a Line comment - /// or if the comment are wrapped - fn write_comment(&mut self, comment: &CommentWithMetadata, is_first: bool) -> Result<()> { - if self.inline_config.is_disabled(comment.loc) { - return self.write_raw_comment(comment); - } - - match comment.position { - CommentPosition::Prefix => self.write_prefix_comment(comment, is_first), - CommentPosition::Postfix => self.write_postfix_comment(comment), - } - } - - /// Write a comment with position [CommentPosition::Prefix] - fn write_prefix_comment( - &mut self, - comment: &CommentWithMetadata, - is_first: bool, - ) -> Result<()> { - if !self.is_beginning_of_line() { - self.write_preserved_line()?; - } - if !is_first && comment.has_newline_before { - self.write_preserved_line()?; - } - - if matches!(comment.ty, CommentType::DocBlock) { - let mut lines = comment.contents().trim().lines(); - writeln!(self.buf(), "{}", comment.start_token())?; - lines.try_for_each(|l| self.write_doc_block_line(comment, l))?; - write!(self.buf(), " {}", comment.end_token().unwrap())?; - self.write_preserved_line()?; - return Ok(()); - } - - write!(self.buf(), "{}", comment.start_token())?; - - let mut wrapped = false; - let contents = comment.contents(); - let mut lines = contents.lines().peekable(); - while let Some(line) = lines.next() { - wrapped |= self.write_comment_line(comment, line)?; - if lines.peek().is_some() { - self.write_preserved_line()?; - } - } - - if let Some(end) = comment.end_token() { - // Check if the end token in the original comment was on the separate line - if !wrapped && comment.comment.lines().count() > contents.lines().count() { - self.write_preserved_line()?; - } - write!(self.buf(), "{end}")?; - } - if self.find_next_line(comment.loc.end()).is_some() { - self.write_preserved_line()?; - } - - Ok(()) - } - - /// Write a comment with position [CommentPosition::Postfix] - fn write_postfix_comment(&mut self, comment: &CommentWithMetadata) -> Result<()> { - let indented = self.is_beginning_of_line(); - self.indented_if(indented, 1, |fmt| { - if !indented && fmt.next_char_needs_space('/') { - fmt.write_whitespace_separator(false)?; - } - - write!(fmt.buf(), "{}", comment.start_token())?; - let start_token_pos = fmt.current_line_len(); - - let mut lines = comment.contents().lines().peekable(); - fmt.grouped(|fmt| { - while let Some(line) = lines.next() { - fmt.write_comment_line(comment, line)?; - if lines.peek().is_some() { - fmt.write_whitespace_separator(true)?; - } - } - Ok(()) - })?; - - if let Some(end) = comment.end_token() { - // If comment is not multiline, end token has to be aligned with the start - if fmt.is_beginning_of_line() { - write!(fmt.buf(), "{}{end}", " ".repeat(start_token_pos))?; - } else { - write!(fmt.buf(), "{end}")?; - } - } - - if comment.is_line() { - fmt.write_whitespace_separator(true)?; - } - Ok(()) - }) - } - - /// Write the line of a doc block comment line - fn write_doc_block_line(&mut self, comment: &CommentWithMetadata, line: &str) -> Result<()> { - if line.trim().starts_with('*') { - let line = line.trim().trim_start_matches('*'); - let needs_space = line.chars().next().is_some_and(|ch| !ch.is_whitespace()); - write!(self.buf(), " *{}", if needs_space { " " } else { "" })?; - self.write_comment_line(comment, line)?; - self.write_whitespace_separator(true)?; - return Ok(()); - } - - let indent_whitespace_count = line - .char_indices() - .take_while(|(idx, ch)| ch.is_whitespace() && *idx <= self.buf.current_indent_len()) - .count(); - let to_skip = if indent_whitespace_count < self.buf.current_indent_len() { - 0 - } else { - self.buf.current_indent_len() - }; - - write!(self.buf(), " *")?; - let content = &line[to_skip..]; - if !content.trim().is_empty() { - write!(self.buf(), " ")?; - self.write_comment_line(comment, &line[to_skip..])?; - } - self.write_whitespace_separator(true)?; - Ok(()) - } - - /// Write a comment line that might potentially overflow the maximum line length - /// and, if configured, will be wrapped to the next line. - fn write_comment_line(&mut self, comment: &CommentWithMetadata, line: &str) -> Result { - if self.will_it_fit(line) || !self.config.wrap_comments { - let start_with_ws = - line.chars().next().map(|ch| ch.is_whitespace()).unwrap_or_default(); - if !self.is_beginning_of_line() || !start_with_ws { - write!(self.buf(), "{line}")?; - return Ok(false); - } - - // if this is the beginning of the line, - // the comment should start with at least an indent - let indent = self.buf.current_indent_len(); - let mut chars = line - .char_indices() - .skip_while(|(idx, ch)| ch.is_whitespace() && *idx < indent) - .map(|(_, ch)| ch); - let padded = - format!("{}{}", self.indent_char().to_string().repeat(indent), chars.join("")); - self.write_raw(padded)?; - return Ok(false); - } - - let mut words = line.split(' ').peekable(); - while let Some(word) = words.next() { - if self.is_beginning_of_line() { - write!(self.buf(), "{}", word.trim_start())?; - } else { - self.write_raw(word)?; - } - - if let Some(next) = words.peek() { - if !word.is_empty() && !self.will_it_fit(next) { - // the next word doesn't fit on this line, - // write remaining words on the next - self.write_whitespace_separator(true)?; - // write newline wrap token - write!(self.buf(), "{}", comment.wrap_token())?; - self.write_comment_line(comment, &words.join(" "))?; - return Ok(true); - } - - self.write_whitespace_separator(false)?; - } - } - Ok(false) - } - - /// Write a raw comment. This is like [`write_comment`](Self::write_comment) but won't do any - /// formatting or worry about whitespace behind the comment. - fn write_raw_comment(&mut self, comment: &CommentWithMetadata) -> Result<()> { - self.write_raw(&comment.comment)?; - if comment.is_line() { - self.write_preserved_line()?; - } - Ok(()) - } - - // TODO handle whitespace between comments for disabled sections - /// Write multiple comments - fn write_comments<'b>( - &mut self, - comments: impl IntoIterator, - ) -> Result<()> { - let mut comments = comments.into_iter().peekable(); - let mut last_byte_written = match comments.peek() { - Some(comment) => comment.loc.start(), - None => return Ok(()), - }; - let mut is_first = true; - for comment in comments { - let unwritten_whitespace_loc = - Loc::File(comment.loc.file_no(), last_byte_written, comment.loc.start()); - if self.inline_config.is_disabled(unwritten_whitespace_loc) { - self.write_raw(&self.source[unwritten_whitespace_loc.range()])?; - self.write_raw_comment(comment)?; - last_byte_written = if comment.is_line() { - self.find_next_line(comment.loc.end()).unwrap_or_else(|| comment.loc.end()) - } else { - comment.loc.end() - }; - } else { - self.write_comment(comment, is_first)?; - } - is_first = false; - } - Ok(()) - } - - /// Write a postfix comments before a given location - fn write_postfix_comments_before(&mut self, byte_end: usize) -> Result<()> { - let comments = self.comments.remove_postfixes_before(byte_end); - self.write_comments(&comments) - } - - /// Write all prefix comments before a given location - fn write_prefix_comments_before(&mut self, byte_end: usize) -> Result<()> { - let comments = self.comments.remove_prefixes_before(byte_end); - self.write_comments(&comments) - } - - /// Check if a chunk will fit on the current line - fn will_chunk_fit(&mut self, format_string: &str, chunk: &Chunk) -> Result { - if let Some(chunk_str) = self.simulate_to_single_line(|fmt| fmt.write_chunk(chunk))? { - Ok(self.will_it_fit(format_string.replacen("{}", &chunk_str, 1))) - } else { - Ok(false) - } - } - - /// Check if a separated list of chunks will fit on the current line - fn are_chunks_separated_multiline<'b>( - &mut self, - format_string: &str, - items: impl IntoIterator, - separator: &str, - ) -> Result { - let items = items.into_iter().collect_vec(); - if let Some(chunks) = self.simulate_to_single_line(|fmt| { - fmt.write_chunks_separated(items.iter().copied(), separator, false) - })? { - Ok(!self.will_it_fit(format_string.replacen("{}", &chunks, 1))) - } else { - Ok(true) - } - } - - /// Write the chunk and any surrounding comments into the buffer - /// This will automatically add whitespace before the chunk given the rule set in - /// `next_char_needs_space`. If the chunk does not fit on the current line it will be put on - /// to the next line - fn write_chunk(&mut self, chunk: &Chunk) -> Result<()> { - // handle comments before chunk - self.write_comments(&chunk.postfixes_before)?; - self.write_comments(&chunk.prefixes)?; - - // trim chunk start - let content = if chunk.content.starts_with('\n') { - let mut chunk = chunk.content.trim_start().to_string(); - chunk.insert(0, '\n'); - chunk - } else if chunk.content.starts_with(self.indent_char()) { - let mut chunk = chunk.content.trim_start().to_string(); - chunk.insert(0, ' '); - chunk - } else { - chunk.content.clone() - }; - - if !content.is_empty() { - // add whitespace if necessary - let needs_space = chunk - .needs_space - .unwrap_or_else(|| self.next_char_needs_space(content.chars().next().unwrap())); - if needs_space { - if self.will_it_fit(&content) { - write!(self.buf(), " ")?; - } else { - writeln!(self.buf())?; - } - } - - // write chunk - write!(self.buf(), "{content}")?; - } - - // write any postfix comments - self.write_comments(&chunk.postfixes)?; - - Ok(()) - } - - /// Write chunks separated by a separator. If `multiline`, each chunk will be written to a - /// separate line - fn write_chunks_separated<'b>( - &mut self, - chunks: impl IntoIterator, - separator: &str, - multiline: bool, - ) -> Result<()> { - let mut chunks = chunks.into_iter().peekable(); - while let Some(chunk) = chunks.next() { - let mut chunk = chunk.clone(); - - // handle postfixes before and add newline if necessary - self.write_comments(&std::mem::take(&mut chunk.postfixes_before))?; - if multiline && !self.is_beginning_of_line() { - writeln!(self.buf())?; - } - - // remove postfixes so we can add separator between - let postfixes = std::mem::take(&mut chunk.postfixes); - - self.write_chunk(&chunk)?; - - // add separator - if chunks.peek().is_some() { - write!(self.buf(), "{separator}")?; - self.write_comments(&postfixes)?; - if multiline && !self.is_beginning_of_line() { - writeln!(self.buf())?; - } - } else { - self.write_comments(&postfixes)?; - } - } - Ok(()) - } - - /// Apply the callback indented by the indent size - fn indented(&mut self, delta: usize, fun: impl FnMut(&mut Self) -> Result<()>) -> Result<()> { - self.indented_if(true, delta, fun) - } - - /// Apply the callback indented by the indent size if the condition is true - fn indented_if( - &mut self, - condition: bool, - delta: usize, - mut fun: impl FnMut(&mut Self) -> Result<()>, - ) -> Result<()> { - if condition { - self.indent(delta); - } - let res = fun(self); - if condition { - self.dedent(delta); - } - res?; - Ok(()) - } - - /// Apply the callback into an indent group. The first line of the indent group is not - /// indented but lines thereafter are - fn grouped(&mut self, mut fun: impl FnMut(&mut Self) -> Result<()>) -> Result { - self.start_group(); - let res = fun(self); - let indented = !self.last_indent_group_skipped(); - self.end_group(); - res?; - Ok(indented) - } - - /// Add a function context around a procedure and revert the context at the end of the procedure - /// regardless of the response - fn with_function_context( - &mut self, - context: FunctionDefinition, - mut fun: impl FnMut(&mut Self) -> Result<()>, - ) -> Result<()> { - self.context.function = Some(context); - let res = fun(self); - self.context.function = None; - res - } - - /// Add a contract context around a procedure and revert the context at the end of the procedure - /// regardless of the response - fn with_contract_context( - &mut self, - context: ContractDefinition, - mut fun: impl FnMut(&mut Self) -> Result<()>, - ) -> Result<()> { - self.context.contract = Some(context); - let res = fun(self); - self.context.contract = None; - res - } - - /// Create a transaction. The result of the transaction is not applied to the buffer unless - /// `Transacton::commit` is called - fn transact<'b>( - &'b mut self, - fun: impl FnMut(&mut Self) -> Result<()>, - ) -> Result> { - Transaction::new(self, fun) - } - - /// Do the callback and return the result on the buffer as a string - fn simulate_to_string(&mut self, fun: impl FnMut(&mut Self) -> Result<()>) -> Result { - Ok(self.transact(fun)?.buffer) - } - - /// Turn a chunk and its surrounding comments into a string - fn chunk_to_string(&mut self, chunk: &Chunk) -> Result { - self.simulate_to_string(|fmt| fmt.write_chunk(chunk)) - } - - /// Try to create a string based on a callback. If the string does not fit on a single line - /// this will return `None` - fn simulate_to_single_line( - &mut self, - mut fun: impl FnMut(&mut Self) -> Result<()>, - ) -> Result> { - let mut single_line = false; - let tx = self.transact(|fmt| { - fmt.restrict_to_single_line(true); - single_line = match fun(fmt) { - Ok(()) => true, - Err(FormatterError::Fmt(_)) => false, - Err(err) => bail!(err), - }; - Ok(()) - })?; - Ok(if single_line && tx.will_it_fit(&tx.buffer) { Some(tx.buffer) } else { None }) - } - - /// Try to apply a callback to a single line. If the callback cannot be applied to a single - /// line the callback will not be applied to the buffer and `false` will be returned. Otherwise - /// `true` will be returned - fn try_on_single_line(&mut self, mut fun: impl FnMut(&mut Self) -> Result<()>) -> Result { - let mut single_line = false; - let tx = self.transact(|fmt| { - fmt.restrict_to_single_line(true); - single_line = match fun(fmt) { - Ok(()) => true, - Err(FormatterError::Fmt(_)) => false, - Err(err) => bail!(err), - }; - Ok(()) - })?; - Ok(if single_line && tx.will_it_fit(&tx.buffer) { - tx.commit()?; - true - } else { - false - }) - } - - /// Surrounds a callback with parentheses. The callback will try to be applied to a single - /// line. If the callback cannot be applied to a single line the callback will applied to the - /// nextline indented. The callback receives a `multiline` hint as the second argument which - /// receives `true` in the latter case - fn surrounded( - &mut self, - first: SurroundingChunk, - last: SurroundingChunk, - mut fun: impl FnMut(&mut Self, bool) -> Result<()>, - ) -> Result<()> { - let first_chunk = - self.chunk_at(first.loc_before(), first.loc_next(), first.spaced, first.content); - self.write_chunk(&first_chunk)?; - - let multiline = !self.try_on_single_line(|fmt| { - fun(fmt, false)?; - let last_chunk = - fmt.chunk_at(last.loc_before(), last.loc_next(), last.spaced, &last.content); - fmt.write_chunk(&last_chunk)?; - Ok(()) - })?; - - if multiline { - self.indented(1, |fmt| { - fmt.write_whitespace_separator(true)?; - let stringified = fmt.with_temp_buf(|fmt| fun(fmt, true))?.w; - write_chunk!(fmt, "{}", stringified.trim_start()) - })?; - if !last.content.trim_start().is_empty() { - self.indented(1, |fmt| fmt.write_whitespace_separator(true))?; - } - let last_chunk = - self.chunk_at(last.loc_before(), last.loc_next(), last.spaced, &last.content); - self.write_chunk(&last_chunk)?; - } - - Ok(()) - } - - /// Write each [Visitable] item on a separate line. The function will check if there are any - /// blank lines between each visitable statement and will apply a single blank line if there - /// exists any. The `needs_space` callback can force a newline and is given the last_item if - /// any and the next item as arguments - fn write_lined_visitable<'b, I, V, F>( - &mut self, - loc: Loc, - items: I, - needs_space_fn: F, - ) -> Result<()> - where - I: Iterator + 'b, - V: Visitable + CodeLocation + 'b, - F: Fn(&V, &V) -> bool, - { - let mut items = items.collect::>(); - items.reverse(); - // get next item - let pop_next = |fmt: &mut Self, items: &mut Vec<&'b mut V>| { - let comment = - fmt.comments.iter().next().filter(|comment| comment.loc.end() < loc.end()); - let item = items.last(); - if let (Some(comment), Some(item)) = (comment, item) { - if comment.loc < item.loc() { - Some(Either::Left(fmt.comments.pop().unwrap())) - } else { - Some(Either::Right(items.pop().unwrap())) - } - } else if comment.is_some() { - Some(Either::Left(fmt.comments.pop().unwrap())) - } else if item.is_some() { - Some(Either::Right(items.pop().unwrap())) - } else { - None - } - }; - // get whitespace between to offsets. this needs to account for possible left over - // semicolons which are not included in the `Loc` - let unwritten_whitespace = |from: usize, to: usize| { - let to = to.max(from); - let mut loc = Loc::File(loc.file_no(), from, to); - let src = &self.source[from..to]; - if let Some(semi) = src.find(';') { - loc = loc.with_start(from + semi + 1); - } - (loc, &self.source[loc.range()]) - }; - - let mut last_byte_written = match ( - self.comments.iter().next().filter(|comment| comment.loc.end() < loc.end()), - items.last(), - ) { - (Some(comment), Some(item)) => comment.loc.min(item.loc()), - (None, Some(item)) => item.loc(), - (Some(comment), None) => comment.loc, - (None, None) => return Ok(()), - } - .start(); - - let mut last_loc: Option = None; - let mut visited_locs: Vec = Vec::new(); - - // marker for whether the next item needs additional space - let mut needs_space = false; - let mut last_comment = None; - - while let Some(mut line_item) = pop_next(self, &mut items) { - let loc = line_item.as_ref().either(|c| c.loc, |i| i.loc()); - let (unwritten_whitespace_loc, unwritten_whitespace) = - unwritten_whitespace(last_byte_written, loc.start()); - let ignore_whitespace = if self.inline_config.is_disabled(unwritten_whitespace_loc) { - trace!("Unwritten whitespace: {unwritten_whitespace:?}"); - self.write_raw(unwritten_whitespace)?; - true - } else { - false - }; - match line_item.as_mut() { - Either::Left(comment) => { - if ignore_whitespace { - self.write_raw_comment(comment)?; - if unwritten_whitespace.contains('\n') { - needs_space = false; - } - } else { - self.write_comment(comment, last_loc.is_none())?; - if last_loc.is_some() && comment.has_newline_before { - needs_space = false; - } - } - } - Either::Right(item) => { - if !ignore_whitespace { - self.write_whitespace_separator(true)?; - if let Some(mut last_loc) = last_loc { - // here's an edge case when we reordered items so the last_loc isn't - // necessarily the item that directly precedes the current item because - // the order might have changed, so we need to find the last item that - // is before the current item by checking the recorded locations - if let Some(last_item) = visited_locs - .iter() - .rev() - .find(|prev_item| prev_item.start() > last_loc.end()) - { - last_loc = *last_item; - } - - // The blank lines check is susceptible additional trailing new lines - // because the block docs can contain - // multiple lines, but the function def should follow directly after the - // block comment - let is_last_doc_comment = matches!( - last_comment, - Some(CommentWithMetadata { ty: CommentType::DocBlock, .. }) - ); - - if needs_space - || (!is_last_doc_comment - && self.blank_lines(last_loc.end(), loc.start()) > 1) - { - writeln!(self.buf())?; - } - } - } - if let Some(next_item) = items.last() { - needs_space = needs_space_fn(item, next_item); - } - trace!("Visiting {}", { - let n = std::any::type_name::(); - n.strip_prefix("solang_parser::pt::").unwrap_or(n) - }); - item.visit(self)?; - } - } - - last_loc = Some(loc); - visited_locs.push(loc); - - last_comment = None; - - last_byte_written = loc.end(); - if let Some(comment) = line_item.left() { - if comment.is_line() { - last_byte_written = - self.find_next_line(last_byte_written).unwrap_or(last_byte_written); - } - last_comment = Some(comment); - } - } - - // write manually to avoid eof comment being detected as first - let comments = self.comments.remove_prefixes_before(loc.end()); - for comment in comments { - self.write_comment(&comment, false)?; - } - - let (unwritten_src_loc, mut unwritten_whitespace) = - unwritten_whitespace(last_byte_written, loc.end()); - if self.inline_config.is_disabled(unwritten_src_loc) { - if unwritten_src_loc.end() == self.source.len() { - // remove EOF line ending - unwritten_whitespace = unwritten_whitespace - .strip_suffix('\n') - .map(|w| w.strip_suffix('\r').unwrap_or(w)) - .unwrap_or(unwritten_whitespace); - } - trace!("Unwritten whitespace: {unwritten_whitespace:?}"); - self.write_raw(unwritten_whitespace)?; - } - - Ok(()) - } - - /// Visit the right side of an assignment. The function will try to write the assignment on a - /// single line or indented on the next line. If it can't do this it resorts to letting the - /// expression decide how to split itself on multiple lines - fn visit_assignment(&mut self, expr: &mut Expression) -> Result<()> { - if self.try_on_single_line(|fmt| expr.visit(fmt))? { - return Ok(()); - } - - self.write_postfix_comments_before(expr.loc().start())?; - self.write_prefix_comments_before(expr.loc().start())?; - - if self.try_on_single_line(|fmt| fmt.indented(1, |fmt| expr.visit(fmt)))? { - return Ok(()); - } - - let mut fit_on_next_line = false; - self.indented(1, |fmt| { - let tx = fmt.transact(|fmt| { - writeln!(fmt.buf())?; - fit_on_next_line = fmt.try_on_single_line(|fmt| expr.visit(fmt))?; - Ok(()) - })?; - if fit_on_next_line { - tx.commit()?; - } - Ok(()) - })?; - - if !fit_on_next_line { - self.indented_if(expr.is_unsplittable(), 1, |fmt| expr.visit(fmt))?; - } - - Ok(()) - } - - /// Visit the list of comma separated items. - /// If the prefix is not empty, then the function will write - /// the whitespace before the parentheses (if they are required). - fn visit_list( - &mut self, - prefix: &str, - items: &mut [T], - start_offset: Option, - end_offset: Option, - paren_required: bool, - ) -> Result<()> - where - T: Visitable + CodeLocation, - { - write_chunk!(self, "{}", prefix)?; - let whitespace = if !prefix.is_empty() { " " } else { "" }; - let next_after_start_offset = items.first().map(|item| item.loc().start()); - let first_surrounding = SurroundingChunk::new("", start_offset, next_after_start_offset); - let last_surrounding = SurroundingChunk::new(")", None, end_offset); - if items.is_empty() { - if paren_required { - write!(self.buf(), "{whitespace}(")?; - self.surrounded(first_surrounding, last_surrounding, |fmt, _| { - // write comments before the list end - write_chunk!(fmt, end_offset.unwrap_or_default(), "")?; - Ok(()) - })?; - } - } else { - write!(self.buf(), "{whitespace}(")?; - self.surrounded(first_surrounding, last_surrounding, |fmt, multiline| { - let args = - fmt.items_to_chunks(end_offset, items.iter_mut().map(|arg| (arg.loc(), arg)))?; - let multiline = - multiline && fmt.are_chunks_separated_multiline("{}", &args, ",")?; - fmt.write_chunks_separated(&args, ",", multiline)?; - Ok(()) - })?; - } - Ok(()) - } - - /// Visit the block item. Attempt to write it on the single - /// line if requested. Surround by curly braces and indent - /// each line otherwise. Returns `true` if the block fit - /// on a single line - fn visit_block( - &mut self, - loc: Loc, - statements: &mut [T], - attempt_single_line: bool, - attempt_omit_braces: bool, - ) -> Result - where - T: Visitable + CodeLocation, - { - if attempt_single_line && statements.len() == 1 { - let fits_on_single = self.try_on_single_line(|fmt| { - if !attempt_omit_braces { - write!(fmt.buf(), "{{ ")?; - } - statements.first_mut().unwrap().visit(fmt)?; - if !attempt_omit_braces { - write!(fmt.buf(), " }}")?; - } - Ok(()) - })?; - - if fits_on_single { - return Ok(true); - } - } - - // Determine if any of start / end of the block is disabled and block lines boundaries. - let is_start_disabled = self.inline_config.is_disabled(loc.with_end(loc.start())); - let is_end_disabled = self.inline_config.is_disabled(loc.with_start(loc.end())); - let end_of_first_line = self.find_next_line(loc.start()).unwrap_or_default(); - let end_of_last_line = self.find_next_line(loc.end()).unwrap_or_default(); - - // Write first line of the block: - // - as it is until the end of line, if format disabled - // - start block if line formatted - if is_start_disabled { - self.write_raw_src(loc.with_end(end_of_first_line))?; - } else { - write_chunk!(self, "{{")?; - } - - // Write comments and close block if no statement. - if statements.is_empty() { - self.indented(1, |fmt| { - fmt.write_prefix_comments_before(loc.end())?; - fmt.write_postfix_comments_before(loc.end())?; - Ok(()) - })?; - - write_chunk!(self, "}}")?; - return Ok(false); - } - - // Determine writable statements by excluding statements from disabled start / end lines. - // We check the position of last statement from first line (if disabled) and position of - // first statement from last line (if disabled) and slice accordingly. - let writable_statements = match ( - statements.iter().rposition(|stmt| { - is_start_disabled - && self.find_next_line(stmt.loc().end()).unwrap_or_default() - == end_of_first_line - }), - statements.iter().position(|stmt| { - is_end_disabled - && self.find_next_line(stmt.loc().end()).unwrap_or_default() == end_of_last_line - }), - ) { - // We have statements on both disabled start / end lines. - (Some(start), Some(end)) => { - if start == end || start + 1 == end { - None - } else { - Some(&mut statements[start + 1..end]) - } - } - // We have statements only on disabled start line. - (Some(start), None) => { - if start + 1 == statements.len() { - None - } else { - Some(&mut statements[start + 1..]) - } - } - // We have statements only on disabled end line. - (None, Some(end)) => { - if end == 0 { - None - } else { - Some(&mut statements[..end]) - } - } - // No statements on disabled start / end line. - (None, None) => Some(statements), - }; - - // Write statements that are not on any disabled first / last block line. - let mut statements_loc = loc; - if let Some(writable_statements) = writable_statements { - if let Some(first_statement) = writable_statements.first() { - statements_loc = statements_loc.with_start(first_statement.loc().start()); - self.write_whitespace_separator(true)?; - self.write_postfix_comments_before(statements_loc.start())?; - } - // If last line is disabled then statements location ends where last block line starts. - if is_end_disabled && let Some(last_statement) = writable_statements.last() { - statements_loc = statements_loc - .with_end(self.find_next_line(last_statement.loc().end()).unwrap_or_default()); - } - self.indented(1, |fmt| { - fmt.write_lined_visitable( - statements_loc, - writable_statements.iter_mut(), - |_, _| false, - )?; - Ok(()) - })?; - self.write_whitespace_separator(true)?; - } - - // Write last line of the block: - // - as it is from where statements location ends until the end of last line, if format - // disabled - // - close block if line formatted - if is_end_disabled { - self.write_raw_src(loc.with_start(statements_loc.end()).with_end(end_of_last_line))?; - } else { - if end_of_first_line != end_of_last_line { - self.write_whitespace_separator(true)?; - } - write_chunk!(self, loc.end(), "}}")?; - } - - Ok(false) - } - - /// Visit statement as `Statement::Block`. - fn visit_stmt_as_block( - &mut self, - stmt: &mut Statement, - attempt_single_line: bool, - ) -> Result { - match stmt { - Statement::Block { loc, statements, .. } => { - self.visit_block(*loc, statements, attempt_single_line, true) - } - _ => self.visit_block(stmt.loc(), &mut [stmt], attempt_single_line, true), - } - } - - /// Visit the generic member access expression and - /// attempt flatten it by checking if the inner expression - /// matches a given member access variant. - fn visit_member_access<'b, T, M>( - &mut self, - expr: &'b mut Box, - ident: &mut Identifier, - mut matcher: M, - ) -> Result<()> - where - T: CodeLocation + Visitable, - M: FnMut(&mut Self, &'b mut Box) -> Result, &'b mut Identifier)>>, - { - let chunk_member_access = |fmt: &mut Self, ident: &mut Identifier, expr: &mut Box| { - fmt.chunked(ident.loc.start(), Some(expr.loc().start()), |fmt| ident.visit(fmt)) - }; - - let mut chunks: Vec = vec![chunk_member_access(self, ident, expr)?]; - let mut remaining = expr; - while let Some((inner_expr, inner_ident)) = matcher(self, remaining)? { - chunks.push(chunk_member_access(self, inner_ident, inner_expr)?); - remaining = inner_expr; - } - - chunks.reverse(); - chunks.iter_mut().for_each(|chunk| chunk.content.insert(0, '.')); - - if !self.try_on_single_line(|fmt| fmt.write_chunks_separated(&chunks, "", false))? { - self.grouped(|fmt| fmt.write_chunks_separated(&chunks, "", true))?; - } - Ok(()) - } - - /// Visit the yul string with an optional identifier. - /// If the identifier is present, write the value in the format `:`. - /// - /// Ref: - fn visit_yul_string_with_ident( - &mut self, - loc: Loc, - val: &str, - ident: &mut Option, - ) -> Result<()> { - let ident = - if let Some(ident) = ident { format!(":{}", ident.name) } else { String::new() }; - write_chunk!(self, loc.start(), loc.end(), "{val}{ident}")?; - Ok(()) - } - - /// Format a quoted string as `prefix"string"` where the quote character is handled - /// by the configuration `quote_style` - fn quote_str(&self, loc: Loc, prefix: Option<&str>, string: &str) -> String { - let get_og_quote = || { - self.source[loc.range()] - .quote_state_char_indices() - .find_map( - |(state, _, ch)| { - if matches!(state, QuoteState::Opening(_)) { Some(ch) } else { None } - }, - ) - .expect("Could not find quote character for quoted string") - }; - let mut quote = self.config.quote_style.quote().unwrap_or_else(get_og_quote); - let mut quoted = format!("{quote}{string}{quote}"); - if !quoted.is_quoted() { - quote = get_og_quote(); - quoted = format!("{quote}{string}{quote}"); - } - let prefix = prefix.unwrap_or(""); - format!("{prefix}{quoted}") - } - - /// Write a quoted string. See `Formatter::quote_str` for more information - fn write_quoted_str(&mut self, loc: Loc, prefix: Option<&str>, string: &str) -> Result<()> { - write_chunk!(self, loc.start(), loc.end(), "{}", self.quote_str(loc, prefix, string)) - } - - /// Write and format numbers. This will fix underscores as well as remove unnecessary 0's and - /// exponents - fn write_num_literal( - &mut self, - loc: Loc, - value: &str, - fractional: Option<&str>, - exponent: &str, - unit: &mut Option, - ) -> Result<()> { - let config = self.config.number_underscore; - - // get source if we preserve underscores - let (value, fractional, exponent) = if config.is_preserve() { - let source = &self.source[loc.start()..loc.end()]; - // Strip unit - let (source, _) = source.split_once(' ').unwrap_or((source, "")); - let (val, exp) = source.split_once(['e', 'E']).unwrap_or((source, "")); - let (val, fract) = - val.split_once('.').map(|(val, fract)| (val, Some(fract))).unwrap_or((val, None)); - ( - val.trim().to_string(), - fract.map(|fract| fract.trim().to_string()), - exp.trim().to_string(), - ) - } else { - // otherwise strip underscores - ( - value.trim().replace('_', ""), - fractional.map(|fract| fract.trim().replace('_', "")), - exponent.trim().replace('_', ""), - ) - }; - - // strip any padded 0's - let val = value.trim_start_matches('0'); - let fract = fractional.as_ref().map(|fract| fract.trim_end_matches('0')); - let (exp_sign, mut exp) = if let Some(exp) = exponent.strip_prefix('-') { - ("-", exp) - } else { - ("", exponent.as_str()) - }; - exp = exp.trim().trim_start_matches('0'); - - let add_underscores = |string: &str, reversed: bool| -> String { - if !config.is_thousands() || string.len() < 5 { - return string.to_string(); - } - if reversed { - Box::new(string.as_bytes().chunks(3)) as Box> - } else { - Box::new(string.as_bytes().rchunks(3).rev()) as Box> - } - .map(|chunk| std::str::from_utf8(chunk).expect("valid utf8 content.")) - .collect::>() - .join("_") - }; - - let mut out = String::new(); - if val.is_empty() { - out.push('0'); - } else { - out.push_str(&add_underscores(val, false)); - } - if let Some(fract) = fract { - out.push('.'); - if fract.is_empty() { - out.push('0'); - } else { - // TODO re-enable me on the next solang-parser v0.1.18 - // currently disabled because of the following bug - // https://github.com/hyperledger-labs/solang/pull/954 - // out.push_str(&add_underscores(fract, true)); - out.push_str(fract) - } - } - if !exp.is_empty() { - out.push('e'); - out.push_str(exp_sign); - out.push_str(&add_underscores(exp, false)); - } - - write_chunk!(self, loc.start(), loc.end(), "{out}")?; - self.write_unit(unit) - } - - /// Write and hex literals according to the configuration. - fn write_hex_literal(&mut self, lit: &HexLiteral) -> Result<()> { - let HexLiteral { loc, hex } = lit; - match self.config.hex_underscore { - HexUnderscore::Remove => self.write_quoted_str(*loc, Some("hex"), hex), - HexUnderscore::Preserve => { - let quote = &self.source[loc.start()..loc.end()].trim_start_matches("hex"); - // source is always quoted so we remove the quotes first so we can adhere to the - // configured quoting style - let hex = "e[1..quote.len() - 1]; - self.write_quoted_str(*loc, Some("hex"), hex) - } - HexUnderscore::Bytes => { - // split all bytes - let hex = hex - .chars() - .chunks(2) - .into_iter() - .map(|chunk| chunk.collect::()) - .collect::>() - .join("_"); - self.write_quoted_str(*loc, Some("hex"), &hex) - } - } - } - - /// Write built-in unit. - fn write_unit(&mut self, unit: &mut Option) -> Result<()> { - if let Some(unit) = unit { - write_chunk!(self, unit.loc.start(), unit.loc.end(), "{}", unit.name)?; - } - Ok(()) - } - - /// Write the function header - fn write_function_header( - &mut self, - func: &mut FunctionDefinition, - body_loc: Option, - header_multiline: bool, - ) -> Result { - let func_name = if let Some(ident) = &func.name { - format!("{} {}", func.ty, ident.name) - } else { - func.ty.to_string() - }; - - // calculate locations of chunk groups - let attrs_loc = func.attributes.first().map(|attr| attr.loc()); - let returns_loc = func.returns.first().map(|param| param.0); - - let params_next_offset = attrs_loc - .as_ref() - .or(returns_loc.as_ref()) - .or(body_loc.as_ref()) - .map(|loc| loc.start()); - let attrs_end = returns_loc.as_ref().or(body_loc.as_ref()).map(|loc| loc.start()); - let returns_end = body_loc.as_ref().map(|loc| loc.start()); - - let mut params_multiline = false; - - let params_loc = { - let mut loc = func.loc.with_end(func.loc.start()); - self.extend_loc_until(&mut loc, ')'); - loc - }; - let params_disabled = self.inline_config.is_disabled(params_loc); - if params_disabled { - let chunk = self.chunked(func.loc.start(), None, |fmt| fmt.visit_source(params_loc))?; - params_multiline = chunk.content.contains('\n'); - self.write_chunk(&chunk)?; - } else { - let first_surrounding = SurroundingChunk::new( - format!("{func_name}("), - Some(func.loc.start()), - Some( - func.params - .first() - .map(|param| param.0.start()) - .unwrap_or_else(|| params_loc.end()), - ), - ); - self.surrounded( - first_surrounding, - SurroundingChunk::new(")", None, params_next_offset), - |fmt, multiline| { - let params = fmt.items_to_chunks( - params_next_offset, - func.params - .iter_mut() - .filter_map(|(loc, param)| param.as_mut().map(|param| (*loc, param))), - )?; - let after_params = if !func.attributes.is_empty() || !func.returns.is_empty() { - "" - } else if func.body.is_some() { - " {" - } else { - ";" - }; - let should_multiline = header_multiline - && matches!( - fmt.config.multiline_func_header, - MultilineFuncHeaderStyle::ParamsFirst - | MultilineFuncHeaderStyle::ParamsFirstMulti - | MultilineFuncHeaderStyle::All - | MultilineFuncHeaderStyle::AllParams - ); - params_multiline = should_multiline - || multiline - || fmt.are_chunks_separated_multiline( - &format!("{{}}){after_params}"), - ¶ms, - ",", - )?; - // Write new line if we have only one parameter and params first set, - // or if the function definition is multiline and all params set. - let single_param_multiline = matches!( - fmt.config.multiline_func_header, - MultilineFuncHeaderStyle::ParamsFirst - ) || params_multiline - && matches!( - fmt.config.multiline_func_header, - MultilineFuncHeaderStyle::AllParams - ); - if params.len() == 1 && single_param_multiline { - writeln!(fmt.buf())?; - } - fmt.write_chunks_separated(¶ms, ",", params_multiline)?; - Ok(()) - }, - )?; - } - - let mut write_attributes = |fmt: &mut Self, multiline: bool| -> Result<()> { - // write attributes - if !func.attributes.is_empty() { - let attrs_loc = func - .attributes - .first() - .unwrap() - .loc() - .with_end_from(&func.attributes.last().unwrap().loc()); - if fmt.inline_config.is_disabled(attrs_loc) { - // If params are also disabled then write functions attributes on the same line. - if params_disabled { - fmt.write_whitespace_separator(false)?; - let attrs_src = - String::from_utf8(self.source.as_bytes()[attrs_loc.range()].to_vec()) - .map_err(FormatterError::custom)?; - fmt.write_raw(attrs_src)?; - } else { - fmt.indented(1, |fmt| fmt.visit_source(attrs_loc))?; - } - } else { - fmt.write_postfix_comments_before(attrs_loc.start())?; - fmt.write_whitespace_separator(multiline)?; - let attributes = - fmt.items_to_chunks_sorted(attrs_end, func.attributes.iter_mut())?; - fmt.indented(1, |fmt| { - fmt.write_chunks_separated(&attributes, "", multiline)?; - Ok(()) - })?; - } - } - - // write returns - if !func.returns.is_empty() { - let returns_start_loc = func.returns.first().unwrap().0; - let returns_loc = returns_start_loc.with_end_from(&func.returns.last().unwrap().0); - if fmt.inline_config.is_disabled(returns_loc) { - fmt.write_whitespace_separator(false)?; - let returns_src = - String::from_utf8(self.source.as_bytes()[returns_loc.range()].to_vec()) - .map_err(FormatterError::custom)?; - fmt.write_raw(format!("returns ({returns_src})"))?; - } else { - let mut returns = fmt.items_to_chunks( - returns_end, - func.returns - .iter_mut() - .filter_map(|(loc, param)| param.as_mut().map(|param| (*loc, param))), - )?; - - // there's an issue with function return value that would lead to indent issues because those can be formatted with line breaks - for function_chunk in - returns.iter_mut().filter(|chunk| chunk.content.starts_with("function(")) - { - // this will bypass the recursive indent that was applied when the function - // content was formatted in the chunk - function_chunk.content = function_chunk - .content - .split('\n') - .map(|s| s.trim_start()) - .collect::>() - .join("\n"); - } - - fmt.write_postfix_comments_before(returns_loc.start())?; - fmt.write_whitespace_separator(multiline)?; - fmt.indented(1, |fmt| { - fmt.surrounded( - SurroundingChunk::new("returns (", Some(returns_loc.start()), None), - SurroundingChunk::new(")", None, returns_end), - |fmt, multiline_hint| { - fmt.write_chunks_separated(&returns, ",", multiline_hint)?; - Ok(()) - }, - )?; - Ok(()) - })?; - } - } - Ok(()) - }; - - let should_multiline = header_multiline - && if params_multiline { - matches!( - self.config.multiline_func_header, - MultilineFuncHeaderStyle::All | MultilineFuncHeaderStyle::AllParams - ) - } else { - matches!( - self.config.multiline_func_header, - MultilineFuncHeaderStyle::AttributesFirst - ) - }; - let attrs_multiline = should_multiline - || !self.try_on_single_line(|fmt| { - write_attributes(fmt, false)?; - if !fmt.will_it_fit(if func.body.is_some() { " {" } else { ";" }) { - bail!(FormatterError::fmt()) - } - Ok(()) - })?; - if attrs_multiline { - write_attributes(self, true)?; - } - Ok(attrs_multiline) - } - - /// Write potentially nested `if statements` - fn write_if_stmt( - &mut self, - loc: Loc, - cond: &mut Expression, - if_branch: &mut Box, - else_branch: &mut Option>, - ) -> Result<(), FormatterError> { - let single_line_stmt_wide = self.context.if_stmt_single_line.unwrap_or_default(); - - visit_source_if_disabled_else!(self, loc.with_end(if_branch.loc().start()), { - self.surrounded( - SurroundingChunk::new("if (", Some(loc.start()), Some(cond.loc().start())), - SurroundingChunk::new(")", None, Some(if_branch.loc().start())), - |fmt, _| { - fmt.write_prefix_comments_before(cond.loc().end())?; - cond.visit(fmt)?; - fmt.write_postfix_comments_before(if_branch.loc().start()) - }, - )?; - }); - - let cond_close_paren_loc = - self.find_next_in_src(cond.loc().end(), ')').unwrap_or_else(|| cond.loc().end()); - let attempt_single_line = single_line_stmt_wide - && self.should_attempt_block_single_line(if_branch.as_mut(), cond_close_paren_loc); - let if_branch_is_single_line = self.visit_stmt_as_block(if_branch, attempt_single_line)?; - if single_line_stmt_wide && !if_branch_is_single_line { - bail!(FormatterError::fmt()) - } - - if let Some(else_branch) = else_branch { - self.write_postfix_comments_before(else_branch.loc().start())?; - if if_branch_is_single_line { - writeln!(self.buf())?; - } - write_chunk!(self, else_branch.loc().start(), "else")?; - if let Statement::If(loc, cond, if_branch, else_branch) = else_branch.as_mut() { - self.visit_if(*loc, cond, if_branch, else_branch, false)?; - } else { - let else_branch_is_single_line = - self.visit_stmt_as_block(else_branch, attempt_single_line)?; - if single_line_stmt_wide && !else_branch_is_single_line { - bail!(FormatterError::fmt()) - } - } - } - Ok(()) - } - - /// Sorts grouped import statement alphabetically. - fn sort_imports(&self, source_unit: &mut SourceUnit) { - // first we need to find the grouped import statements - // A group is defined as a set of import statements that are separated by a blank line - let mut import_groups = Vec::new(); - let mut current_group = Vec::new(); - let mut source_unit_parts = source_unit.0.iter().enumerate().peekable(); - while let Some((i, part)) = source_unit_parts.next() { - if let SourceUnitPart::ImportDirective(_) = part { - current_group.push(i); - let current_loc = part.loc(); - if let Some((_, next_part)) = source_unit_parts.peek() { - let next_loc = next_part.loc(); - // import statements are followed by a new line, so if there are more than one - // we have a group - if self.blank_lines(current_loc.end(), next_loc.start()) > 1 { - import_groups.push(std::mem::take(&mut current_group)); - } - } - } else if !current_group.is_empty() { - import_groups.push(std::mem::take(&mut current_group)); - } - } - - if !current_group.is_empty() { - import_groups.push(current_group); - } - - if import_groups.is_empty() { - // nothing to sort - return; - } - - // order all groups alphabetically - for group in &import_groups { - // SAFETY: group is not empty - let first = group[0]; - let last = group.last().copied().expect("group is not empty"); - let import_directives = &mut source_unit.0[first..=last]; - - // sort rename style imports alphabetically based on the actual import and not the - // rename - for source_unit_part in import_directives.iter_mut() { - if let SourceUnitPart::ImportDirective(Import::Rename(_, renames, _)) = - source_unit_part - { - renames.sort_by_cached_key(|(og_ident, _)| og_ident.name.clone()); - } - } - - import_directives.sort_by_cached_key(|item| match item { - SourceUnitPart::ImportDirective(import) => match import { - Import::Plain(path, _) => path.to_string(), - Import::GlobalSymbol(path, _, _) => path.to_string(), - Import::Rename(path, _, _) => path.to_string(), - }, - _ => { - unreachable!("import group contains non-import statement") - } - }); - } - } -} - -// Traverse the Solidity Parse Tree and write to the code formatter -impl Visitor for Formatter<'_, W> { - type Error = FormatterError; - - #[instrument(name = "source", skip(self))] - fn visit_source(&mut self, loc: Loc) -> Result<()> { - let source = String::from_utf8(self.source.as_bytes()[loc.range()].to_vec()) - .map_err(FormatterError::custom)?; - let mut lines = source.splitn(2, '\n'); - - write_chunk!(self, loc.start(), "{}", lines.next().unwrap())?; - if let Some(remainder) = lines.next() { - // Call with `self.write_str` and not `write!`, so we can have `\n` at the beginning - // without triggering an indentation - self.write_raw(format!("\n{remainder}"))?; - } - - let _ = self.comments.remove_all_comments_before(loc.end()); - - Ok(()) - } - - #[instrument(name = "SU", skip_all)] - fn visit_source_unit(&mut self, source_unit: &mut SourceUnit) -> Result<()> { - if self.config.sort_imports { - self.sort_imports(source_unit); - } - // TODO: do we need to put pragma and import directives at the top of the file? - // source_unit.0.sort_by_key(|item| match item { - // SourceUnitPart::PragmaDirective(_, _, _) => 0, - // SourceUnitPart::ImportDirective(_, _) => 1, - // _ => usize::MAX, - // }); - let loc = Loc::File( - source_unit - .loc_opt() - .or_else(|| self.comments.iter().next().map(|comment| comment.loc)) - .map(|loc| loc.file_no()) - .unwrap_or_default(), - 0, - self.source.len(), - ); - - self.write_lined_visitable( - loc, - source_unit.0.iter_mut(), - |last_unit, unit| match last_unit { - SourceUnitPart::PragmaDirective(..) => { - !matches!(unit, SourceUnitPart::PragmaDirective(..)) - } - SourceUnitPart::ImportDirective(_) => { - !matches!(unit, SourceUnitPart::ImportDirective(_)) - } - SourceUnitPart::ErrorDefinition(_) => { - !matches!(unit, SourceUnitPart::ErrorDefinition(_)) - } - SourceUnitPart::Using(_) => !matches!(unit, SourceUnitPart::Using(_)), - SourceUnitPart::VariableDefinition(_) => { - !matches!(unit, SourceUnitPart::VariableDefinition(_)) - } - SourceUnitPart::Annotation(_) => false, - _ => true, - }, - )?; - - // EOF newline - if self.last_char() != Some('\n') { - writeln!(self.buf())?; - } - - Ok(()) - } - - #[instrument(name = "contract", skip_all)] - fn visit_contract(&mut self, contract: &mut ContractDefinition) -> Result<()> { - return_source_if_disabled!(self, contract.loc); - - self.with_contract_context(contract.clone(), |fmt| { - let contract_name = contract.name.safe_unwrap(); - - visit_source_if_disabled_else!( - fmt, - contract.loc.with_end_from( - &contract.base.first().map(|b| b.loc).unwrap_or(contract_name.loc) - ), - { - fmt.grouped(|fmt| { - write_chunk!(fmt, contract.loc.start(), "{}", contract.ty)?; - write_chunk!(fmt, contract_name.loc.end(), "{}", contract_name.name)?; - if !contract.base.is_empty() { - write_chunk!( - fmt, - contract_name.loc.end(), - contract.base.first().unwrap().loc.start(), - "is" - )?; - } - Ok(()) - })?; - } - ); - - if !contract.base.is_empty() { - visit_source_if_disabled_else!( - fmt, - contract - .base - .first() - .unwrap() - .loc - .with_end_from(&contract.base.last().unwrap().loc), - { - fmt.indented(1, |fmt| { - let base_end = contract.parts.first().map(|part| part.loc().start()); - let bases = fmt.items_to_chunks( - base_end, - contract.base.iter_mut().map(|base| (base.loc, base)), - )?; - let multiline = - fmt.are_chunks_separated_multiline("{}", &bases, ",")?; - fmt.write_chunks_separated(&bases, ",", multiline)?; - fmt.write_whitespace_separator(multiline)?; - Ok(()) - })?; - } - ); - } - - if let Some(layout) = &mut contract.layout { - write_chunk!(fmt, "layout at ")?; - fmt.visit_expr(layout.loc(), layout)?; - write_chunk!(fmt, " ")?; - } - - write_chunk!(fmt, "{{")?; - - fmt.indented(1, |fmt| { - if let Some(first) = contract.parts.first() { - fmt.write_postfix_comments_before(first.loc().start())?; - fmt.write_whitespace_separator(true)?; - } else { - return Ok(()); - } - - if fmt.config.contract_new_lines { - write_chunk!(fmt, "\n")?; - } - - fmt.write_lined_visitable( - contract.loc, - contract.parts.iter_mut(), - |last_part, part| match last_part { - ContractPart::ErrorDefinition(_) => { - !matches!(part, ContractPart::ErrorDefinition(_)) - } - ContractPart::EventDefinition(_) => { - !matches!(part, ContractPart::EventDefinition(_)) - } - ContractPart::VariableDefinition(_) => { - !matches!(part, ContractPart::VariableDefinition(_)) - } - ContractPart::TypeDefinition(_) => { - !matches!(part, ContractPart::TypeDefinition(_)) - } - ContractPart::EnumDefinition(_) => { - !matches!(part, ContractPart::EnumDefinition(_)) - } - ContractPart::Using(_) => !matches!(part, ContractPart::Using(_)), - ContractPart::FunctionDefinition(last_def) => { - if last_def.is_empty() { - match part { - ContractPart::FunctionDefinition(def) => !def.is_empty(), - _ => true, - } - } else { - true - } - } - ContractPart::Annotation(_) => false, - _ => true, - }, - ) - })?; - - if !contract.parts.is_empty() { - fmt.write_whitespace_separator(true)?; - - if fmt.config.contract_new_lines { - write_chunk!(fmt, "\n")?; - } - } - - write_chunk!(fmt, contract.loc.end(), "}}")?; - - Ok(()) - })?; - - Ok(()) - } - - // Support extension for Solana/Substrate - #[instrument(name = "annotation", skip_all)] - fn visit_annotation(&mut self, annotation: &mut Annotation) -> Result<()> { - return_source_if_disabled!(self, annotation.loc); - let id = self.simulate_to_string(|fmt| annotation.id.visit(fmt))?; - write!(self.buf(), "@{id}")?; - write!(self.buf(), "(")?; - annotation.value.visit(self)?; - write!(self.buf(), ")")?; - Ok(()) - } - - fn visit_pragma( - &mut self, - pragma: &mut PragmaDirective, - ) -> std::result::Result<(), Self::Error> { - let loc = pragma.loc(); - return_source_if_disabled!(self, loc, ';'); - - match pragma { - PragmaDirective::Identifier(loc, id1, id2) => { - write_chunk!( - self, - loc.start(), - loc.end(), - "pragma {}{}{};", - id1.as_ref().map(|id| id.name.to_string()).unwrap_or_default(), - if id1.is_some() && id2.is_some() { " " } else { "" }, - id2.as_ref().map(|id| id.name.to_string()).unwrap_or_default(), - )?; - } - PragmaDirective::StringLiteral(_loc, id, lit) => { - write_chunk!(self, "pragma {} ", id.name)?; - let StringLiteral { loc, string, .. } = lit; - write_chunk!(self, loc.start(), loc.end(), "\"{string}\";")?; - } - PragmaDirective::Version(loc, id, version) => { - write_chunk!(self, loc.start(), id.loc().end(), "pragma {}", id.name)?; - let version_loc = loc.with_start(version[0].loc().start()); - self.visit_source(version_loc)?; - self.write_semicolon()?; - } - } - Ok(()) - } - - #[instrument(name = "import_plain", skip_all)] - fn visit_import_plain(&mut self, loc: Loc, import: &mut ImportPath) -> Result<()> { - return_source_if_disabled!(self, loc, ';'); - - self.grouped(|fmt| { - write_chunk!(fmt, loc.start(), import.loc().start(), "import")?; - fmt.write_quoted_str(import.loc(), None, &import_path_string(import))?; - fmt.write_semicolon()?; - Ok(()) - })?; - Ok(()) - } - - #[instrument(name = "import_global", skip_all)] - fn visit_import_global( - &mut self, - loc: Loc, - global: &mut ImportPath, - alias: &mut Identifier, - ) -> Result<()> { - return_source_if_disabled!(self, loc, ';'); - - self.grouped(|fmt| { - write_chunk!(fmt, loc.start(), global.loc().start(), "import")?; - fmt.write_quoted_str(global.loc(), None, &import_path_string(global))?; - write_chunk!(fmt, loc.start(), alias.loc.start(), "as")?; - alias.visit(fmt)?; - fmt.write_semicolon()?; - Ok(()) - })?; - Ok(()) - } - - #[instrument(name = "import_renames", skip_all)] - fn visit_import_renames( - &mut self, - loc: Loc, - imports: &mut [(Identifier, Option)], - from: &mut ImportPath, - ) -> Result<()> { - return_source_if_disabled!(self, loc, ';'); - - if imports.is_empty() { - self.grouped(|fmt| { - write_chunk!(fmt, loc.start(), "import")?; - fmt.write_empty_brackets()?; - write_chunk!(fmt, loc.start(), from.loc().start(), "from")?; - fmt.write_quoted_str(from.loc(), None, &import_path_string(from))?; - fmt.write_semicolon()?; - Ok(()) - })?; - return Ok(()); - } - - let imports_start = imports.first().unwrap().0.loc.start(); - - write_chunk!(self, loc.start(), imports_start, "import")?; - - self.surrounded( - SurroundingChunk::new("{", Some(imports_start), None), - SurroundingChunk::new("}", None, Some(from.loc().start())), - |fmt, _multiline| { - let mut imports = imports.iter_mut().peekable(); - let mut import_chunks = Vec::new(); - while let Some((ident, alias)) = imports.next() { - import_chunks.push(fmt.chunked( - ident.loc.start(), - imports.peek().map(|(ident, _)| ident.loc.start()), - |fmt| { - fmt.grouped(|fmt| { - ident.visit(fmt)?; - if let Some(alias) = alias { - write_chunk!(fmt, ident.loc.end(), alias.loc.start(), "as")?; - alias.visit(fmt)?; - } - Ok(()) - })?; - Ok(()) - }, - )?); - } - - let multiline = fmt.are_chunks_separated_multiline( - &format!("{{}} }} from \"{}\";", import_path_string(from)), - &import_chunks, - ",", - )?; - fmt.write_chunks_separated(&import_chunks, ",", multiline)?; - Ok(()) - }, - )?; - - self.grouped(|fmt| { - write_chunk!(fmt, imports_start, from.loc().start(), "from")?; - fmt.write_quoted_str(from.loc(), None, &import_path_string(from))?; - fmt.write_semicolon()?; - Ok(()) - })?; - - Ok(()) - } - - #[instrument(name = "enum", skip_all)] - fn visit_enum(&mut self, enumeration: &mut EnumDefinition) -> Result<()> { - return_source_if_disabled!(self, enumeration.loc); - - let enum_name = enumeration.name.safe_unwrap_mut(); - let mut name = - self.visit_to_chunk(enum_name.loc.start(), Some(enum_name.loc.end()), enum_name)?; - name.content = format!("enum {} ", name.content); - if enumeration.values.is_empty() { - self.write_chunk(&name)?; - self.write_empty_brackets()?; - } else { - name.content.push('{'); - self.write_chunk(&name)?; - - self.indented(1, |fmt| { - let values = fmt.items_to_chunks( - Some(enumeration.loc.end()), - enumeration.values.iter_mut().map(|ident| { - let ident = ident.safe_unwrap_mut(); - (ident.loc, ident) - }), - )?; - fmt.write_chunks_separated(&values, ",", true)?; - writeln!(fmt.buf())?; - Ok(()) - })?; - write_chunk!(self, "}}")?; - } - - Ok(()) - } - - #[instrument(name = "assembly", skip_all)] - fn visit_assembly( - &mut self, - loc: Loc, - dialect: &mut Option, - block: &mut YulBlock, - flags: &mut Option>, - ) -> Result<(), Self::Error> { - return_source_if_disabled!(self, loc); - - write_chunk!(self, loc.start(), "assembly")?; - if let Some(StringLiteral { loc, string, .. }) = dialect { - write_chunk!(self, loc.start(), loc.end(), "\"{string}\"")?; - } - if let Some(flags) = flags - && !flags.is_empty() - { - let loc_start = flags.first().unwrap().loc.start(); - self.surrounded( - SurroundingChunk::new("(", Some(loc_start), None), - SurroundingChunk::new(")", None, Some(block.loc.start())), - |fmt, _| { - let mut flags = flags.iter_mut().peekable(); - let mut chunks = vec![]; - while let Some(flag) = flags.next() { - let next_byte_offset = flags.peek().map(|next_flag| next_flag.loc.start()); - chunks.push(fmt.chunked(flag.loc.start(), next_byte_offset, |fmt| { - write!(fmt.buf(), "\"{}\"", flag.string)?; - Ok(()) - })?); - } - fmt.write_chunks_separated(&chunks, ",", false)?; - Ok(()) - }, - )?; - } - - block.visit(self) - } - - #[instrument(name = "block", skip_all)] - fn visit_block( - &mut self, - loc: Loc, - unchecked: bool, - statements: &mut Vec, - ) -> Result<()> { - return_source_if_disabled!(self, loc); - if unchecked { - write_chunk!(self, loc.start(), "unchecked ")?; - } - - self.visit_block(loc, statements, false, false)?; - Ok(()) - } - - #[instrument(name = "args", skip_all)] - fn visit_args(&mut self, loc: Loc, args: &mut Vec) -> Result<(), Self::Error> { - return_source_if_disabled!(self, loc); - - write!(self.buf(), "{{")?; - - let mut args_iter = args.iter_mut().peekable(); - let mut chunks = Vec::new(); - while let Some(NamedArgument { loc: arg_loc, name, expr }) = args_iter.next() { - let next_byte_offset = args_iter - .peek() - .map(|NamedArgument { loc: arg_loc, .. }| arg_loc.start()) - .unwrap_or_else(|| loc.end()); - chunks.push(self.chunked(arg_loc.start(), Some(next_byte_offset), |fmt| { - fmt.grouped(|fmt| { - write_chunk!(fmt, name.loc.start(), "{}: ", name.name)?; - expr.visit(fmt) - })?; - Ok(()) - })?); - } - - if let Some(first) = chunks.first_mut() - && first.prefixes.is_empty() - && first.postfixes_before.is_empty() - && !self.config.bracket_spacing - { - first.needs_space = Some(false); - } - let multiline = self.are_chunks_separated_multiline("{}}", &chunks, ",")?; - self.indented_if(multiline, 1, |fmt| fmt.write_chunks_separated(&chunks, ",", multiline))?; - - let prefix = if multiline && !self.is_beginning_of_line() { - "\n" - } else if self.config.bracket_spacing { - " " - } else { - "" - }; - let closing_bracket = format!("{prefix}{}", "}"); - if let Some(arg) = args.last() { - write_chunk!(self, arg.loc.end(), "{closing_bracket}")?; - } else { - write_chunk!(self, "{closing_bracket}")?; - } - - Ok(()) - } - - #[instrument(name = "expr", skip_all)] - fn visit_expr(&mut self, loc: Loc, expr: &mut Expression) -> Result<()> { - return_source_if_disabled!(self, loc); - - match expr { - Expression::Type(loc, ty) => match ty { - Type::Address => write_chunk!(self, loc.start(), "address")?, - Type::AddressPayable => write_chunk!(self, loc.start(), "address payable")?, - Type::Payable => write_chunk!(self, loc.start(), "payable")?, - Type::Bool => write_chunk!(self, loc.start(), "bool")?, - Type::String => write_chunk!(self, loc.start(), "string")?, - Type::Bytes(n) => write_chunk!(self, loc.start(), "bytes{}", n)?, - Type::Rational => write_chunk!(self, loc.start(), "rational")?, - Type::DynamicBytes => write_chunk!(self, loc.start(), "bytes")?, - &mut Type::Int(ref n) | &mut Type::Uint(ref n) => { - let int = if matches!(ty, Type::Int(_)) { "int" } else { "uint" }; - match n { - 256 => match self.config.int_types { - IntTypes::Long => write_chunk!(self, loc.start(), "{int}{n}")?, - IntTypes::Short => write_chunk!(self, loc.start(), "{int}")?, - IntTypes::Preserve => self.visit_source(*loc)?, - }, - _ => write_chunk!(self, loc.start(), "{int}{n}")?, - } - } - Type::Mapping { loc, key, key_name, value, value_name } => { - let arrow_loc = self.find_next_str_in_src(loc.start(), "=>"); - let close_paren_loc = - self.find_next_in_src(value.loc().end(), ')').unwrap_or(loc.end()); - let first = SurroundingChunk::new( - "mapping(", - Some(loc.start()), - Some(key.loc().start()), - ); - let last = SurroundingChunk::new(")", Some(close_paren_loc), Some(loc.end())) - .non_spaced(); - self.surrounded(first, last, |fmt, multiline| { - fmt.grouped(|fmt| { - key.visit(fmt)?; - - if let Some(name) = key_name { - let end_loc = arrow_loc.unwrap_or(value.loc().start()); - write_chunk!(fmt, name.loc.start(), end_loc, " {}", name)?; - } else if let Some(arrow_loc) = arrow_loc { - fmt.write_postfix_comments_before(arrow_loc)?; - } - - let mut write_arrow_and_value = |fmt: &mut Self| { - write!(fmt.buf(), "=> ")?; - value.visit(fmt)?; - if let Some(name) = value_name { - write_chunk!(fmt, name.loc.start(), " {}", name)?; - } - Ok(()) - }; - - let rest_str = fmt.simulate_to_string(&mut write_arrow_and_value)?; - let multiline = multiline && !fmt.will_it_fit(rest_str); - fmt.write_whitespace_separator(multiline)?; - - write_arrow_and_value(fmt)?; - - fmt.write_postfix_comments_before(close_paren_loc)?; - fmt.write_prefix_comments_before(close_paren_loc) - })?; - Ok(()) - })?; - } - Type::Function { .. } => self.visit_source(*loc)?, - }, - Expression::BoolLiteral(loc, val) => { - write_chunk!(self, loc.start(), loc.end(), "{val}")?; - } - Expression::NumberLiteral(loc, val, exp, unit) => { - self.write_num_literal(*loc, val, None, exp, unit)?; - } - Expression::HexNumberLiteral(loc, val, unit) => { - // ref: https://docs.soliditylang.org/en/latest/types.html?highlight=address%20literal#address-literals - let val = if val.len() == 42 { - Address::from_str(val).expect("").to_string() - } else { - val.to_owned() - }; - write_chunk!(self, loc.start(), loc.end(), "{val}")?; - self.write_unit(unit)?; - } - Expression::RationalNumberLiteral(loc, val, fraction, exp, unit) => { - self.write_num_literal(*loc, val, Some(fraction), exp, unit)?; - } - Expression::StringLiteral(vals) => { - for StringLiteral { loc, string, unicode } in vals { - let prefix = if *unicode { Some("unicode") } else { None }; - self.write_quoted_str(*loc, prefix, string)?; - } - } - Expression::HexLiteral(vals) => { - for val in vals { - self.write_hex_literal(val)?; - } - } - Expression::AddressLiteral(loc, val) => { - // support of solana/substrate address literals - self.write_quoted_str(*loc, Some("address"), val)?; - } - Expression::Parenthesis(loc, expr) => { - self.surrounded( - SurroundingChunk::new("(", Some(loc.start()), None), - SurroundingChunk::new(")", None, Some(loc.end())), - |fmt, _| expr.visit(fmt), - )?; - } - Expression::ArraySubscript(_, ty_exp, index_expr) => { - ty_exp.visit(self)?; - write!(self.buf(), "[")?; - index_expr.as_mut().map(|index| index.visit(self)).transpose()?; - write!(self.buf(), "]")?; - } - Expression::ArraySlice(loc, expr, start, end) => { - expr.visit(self)?; - write!(self.buf(), "[")?; - let mut write_slice = |fmt: &mut Self, multiline| -> Result<()> { - if multiline { - fmt.write_whitespace_separator(true)?; - } - fmt.grouped(|fmt| { - start.as_mut().map(|start| start.visit(fmt)).transpose()?; - write!(fmt.buf(), ":")?; - if let Some(end) = end { - let mut chunk = - fmt.chunked(end.loc().start(), Some(loc.end()), |fmt| { - end.visit(fmt) - })?; - if chunk.prefixes.is_empty() - && chunk.postfixes_before.is_empty() - && (start.is_none() || fmt.will_it_fit(&chunk.content)) - { - chunk.needs_space = Some(false); - } - fmt.write_chunk(&chunk)?; - } - Ok(()) - })?; - if multiline { - fmt.write_whitespace_separator(true)?; - } - Ok(()) - }; - - if !self.try_on_single_line(|fmt| write_slice(fmt, false))? { - self.indented(1, |fmt| write_slice(fmt, true))?; - } - - write!(self.buf(), "]")?; - } - Expression::ArrayLiteral(loc, exprs) => { - write_chunk!(self, loc.start(), "[")?; - let chunks = self.items_to_chunks( - Some(loc.end()), - exprs.iter_mut().map(|expr| (expr.loc(), expr)), - )?; - let multiline = self.are_chunks_separated_multiline("{}]", &chunks, ",")?; - self.indented_if(multiline, 1, |fmt| { - fmt.write_chunks_separated(&chunks, ",", multiline)?; - if multiline { - fmt.write_postfix_comments_before(loc.end())?; - fmt.write_prefix_comments_before(loc.end())?; - fmt.write_whitespace_separator(true)?; - } - Ok(()) - })?; - write_chunk!(self, loc.end(), "]")?; - } - Expression::PreIncrement(..) - | Expression::PostIncrement(..) - | Expression::PreDecrement(..) - | Expression::PostDecrement(..) - | Expression::Not(..) - | Expression::UnaryPlus(..) - | Expression::Add(..) - | Expression::Negate(..) - | Expression::Subtract(..) - | Expression::Power(..) - | Expression::Multiply(..) - | Expression::Divide(..) - | Expression::Modulo(..) - | Expression::ShiftLeft(..) - | Expression::ShiftRight(..) - | Expression::BitwiseNot(..) - | Expression::BitwiseAnd(..) - | Expression::BitwiseXor(..) - | Expression::BitwiseOr(..) - | Expression::Less(..) - | Expression::More(..) - | Expression::LessEqual(..) - | Expression::MoreEqual(..) - | Expression::And(..) - | Expression::Or(..) - | Expression::Equal(..) - | Expression::NotEqual(..) => { - let spaced = expr.has_space_around(); - let op = expr.operator().unwrap(); - - match expr.components_mut() { - (Some(left), Some(right)) => { - left.visit(self)?; - - let right_chunk = - self.chunked(right.loc().start(), Some(loc.end()), |fmt| { - write_chunk!(fmt, right.loc().start(), "{op}")?; - right.visit(fmt)?; - Ok(()) - })?; - - self.grouped(|fmt| fmt.write_chunk(&right_chunk))?; - } - (Some(left), None) => { - left.visit(self)?; - write_chunk_spaced!(self, loc.end(), Some(spaced), "{op}")?; - } - (None, Some(right)) => { - write_chunk!(self, right.loc().start(), "{op}")?; - let mut right_chunk = - self.visit_to_chunk(right.loc().end(), Some(loc.end()), right)?; - right_chunk.needs_space = Some(spaced); - self.write_chunk(&right_chunk)?; - } - (None, None) => {} - } - } - Expression::Assign(..) - | Expression::AssignOr(..) - | Expression::AssignAnd(..) - | Expression::AssignXor(..) - | Expression::AssignShiftLeft(..) - | Expression::AssignShiftRight(..) - | Expression::AssignAdd(..) - | Expression::AssignSubtract(..) - | Expression::AssignMultiply(..) - | Expression::AssignDivide(..) - | Expression::AssignModulo(..) => { - let op = expr.operator().unwrap(); - let (left, right) = expr.components_mut(); - let (left, right) = (left.unwrap(), right.unwrap()); - - left.visit(self)?; - write_chunk!(self, "{op}")?; - self.visit_assignment(right)?; - } - Expression::ConditionalOperator(loc, cond, first_expr, second_expr) => { - cond.visit(self)?; - - let first_expr = self.chunked( - first_expr.loc().start(), - Some(second_expr.loc().start()), - |fmt| { - write_chunk!(fmt, "?")?; - first_expr.visit(fmt) - }, - )?; - let second_expr = - self.chunked(second_expr.loc().start(), Some(loc.end()), |fmt| { - write_chunk!(fmt, ":")?; - second_expr.visit(fmt) - })?; - - let chunks = vec![first_expr, second_expr]; - if !self.try_on_single_line(|fmt| fmt.write_chunks_separated(&chunks, "", false))? { - self.grouped(|fmt| fmt.write_chunks_separated(&chunks, "", true))?; - } - } - Expression::Variable(ident) => { - write_chunk!(self, loc.end(), "{}", ident.name)?; - } - Expression::MemberAccess(_, expr, ident) => { - self.visit_member_access(expr, ident, |fmt, expr| match expr.as_mut() { - Expression::MemberAccess(_, inner_expr, inner_ident) => { - Ok(Some((inner_expr, inner_ident))) - } - expr => { - expr.visit(fmt)?; - Ok(None) - } - })?; - } - Expression::List(loc, items) => { - self.surrounded( - SurroundingChunk::new( - "(", - Some(loc.start()), - items.first().map(|item| item.0.start()), - ), - SurroundingChunk::new(")", None, Some(loc.end())), - |fmt, _| { - let items = fmt.items_to_chunks( - Some(loc.end()), - items.iter_mut().map(|(loc, item)| (*loc, item)), - )?; - let write_items = |fmt: &mut Self, multiline| { - fmt.write_chunks_separated(&items, ",", multiline) - }; - if !fmt.try_on_single_line(|fmt| write_items(fmt, false))? { - write_items(fmt, true)?; - } - Ok(()) - }, - )?; - } - Expression::FunctionCall(loc, expr, exprs) => { - self.visit_expr(expr.loc(), expr)?; - self.visit_list("", exprs, Some(expr.loc().end()), Some(loc.end()), true)?; - } - Expression::NamedFunctionCall(loc, expr, args) => { - self.visit_expr(expr.loc(), expr)?; - write!(self.buf(), "(")?; - self.visit_args(*loc, args)?; - write!(self.buf(), ")")?; - } - Expression::FunctionCallBlock(_, expr, stmt) => { - expr.visit(self)?; - stmt.visit(self)?; - } - Expression::New(_, expr) => { - write_chunk!(self, "new ")?; - self.visit_expr(expr.loc(), expr)?; - } - _ => self.visit_source(loc)?, - }; - - Ok(()) - } - - #[instrument(name = "ident", skip_all)] - fn visit_ident(&mut self, loc: Loc, ident: &mut Identifier) -> Result<()> { - return_source_if_disabled!(self, loc); - write_chunk!(self, loc.end(), "{}", ident.name)?; - Ok(()) - } - - #[instrument(name = "ident_path", skip_all)] - fn visit_ident_path(&mut self, idents: &mut IdentifierPath) -> Result<(), Self::Error> { - if idents.identifiers.is_empty() { - return Ok(()); - } - return_source_if_disabled!(self, idents.loc); - - idents.identifiers.iter_mut().skip(1).for_each(|chunk| { - if !chunk.name.starts_with('.') { - chunk.name.insert(0, '.') - } - }); - let chunks = self.items_to_chunks( - Some(idents.loc.end()), - idents.identifiers.iter_mut().map(|ident| (ident.loc, ident)), - )?; - self.grouped(|fmt| { - let multiline = fmt.are_chunks_separated_multiline("{}", &chunks, "")?; - fmt.write_chunks_separated(&chunks, "", multiline) - })?; - Ok(()) - } - - #[instrument(name = "emit", skip_all)] - fn visit_emit(&mut self, loc: Loc, event: &mut Expression) -> Result<()> { - return_source_if_disabled!(self, loc); - write_chunk!(self, loc.start(), "emit")?; - event.visit(self)?; - self.write_semicolon()?; - Ok(()) - } - - #[instrument(name = "var_definition", skip_all)] - fn visit_var_definition(&mut self, var: &mut VariableDefinition) -> Result<()> { - return_source_if_disabled!(self, var.loc, ';'); - - var.ty.visit(self)?; - - let multiline = self.grouped(|fmt| { - let var_name = var.name.safe_unwrap_mut(); - let name_start = var_name.loc.start(); - - let attrs = fmt.items_to_chunks_sorted(Some(name_start), var.attrs.iter_mut())?; - if !fmt.try_on_single_line(|fmt| fmt.write_chunks_separated(&attrs, "", false))? { - fmt.write_chunks_separated(&attrs, "", true)?; - } - - let mut name = fmt.visit_to_chunk(name_start, Some(var_name.loc.end()), var_name)?; - if var.initializer.is_some() { - name.content.push_str(" ="); - } - fmt.write_chunk(&name)?; - - Ok(()) - })?; - - var.initializer - .as_mut() - .map(|init| self.indented_if(multiline, 1, |fmt| fmt.visit_assignment(init))) - .transpose()?; - - self.write_semicolon()?; - - Ok(()) - } - - #[instrument(name = "var_definition_stmt", skip_all)] - fn visit_var_definition_stmt( - &mut self, - loc: Loc, - declaration: &mut VariableDeclaration, - expr: &mut Option, - ) -> Result<()> { - return_source_if_disabled!(self, loc, ';'); - - let declaration = self - .chunked(declaration.loc.start(), None, |fmt| fmt.visit_var_declaration(declaration))?; - let multiline = declaration.content.contains('\n'); - self.write_chunk(&declaration)?; - - if let Some(expr) = expr { - write!(self.buf(), " =")?; - self.indented_if(multiline, 1, |fmt| fmt.visit_assignment(expr))?; - } - - self.write_semicolon() - } - - #[instrument(name = "var_declaration", skip_all)] - fn visit_var_declaration(&mut self, var: &mut VariableDeclaration) -> Result<()> { - return_source_if_disabled!(self, var.loc); - self.grouped(|fmt| { - var.ty.visit(fmt)?; - if let Some(storage) = &var.storage { - write_chunk!(fmt, storage.loc().end(), "{storage}")?; - } - let var_name = var.name.safe_unwrap(); - write_chunk!(fmt, var_name.loc.end(), "{var_name}") - })?; - Ok(()) - } - - #[instrument(name = "return", skip_all)] - fn visit_return(&mut self, loc: Loc, expr: &mut Option) -> Result<(), Self::Error> { - return_source_if_disabled!(self, loc, ';'); - - self.write_postfix_comments_before(loc.start())?; - self.write_prefix_comments_before(loc.start())?; - - if expr.is_none() { - write_chunk!(self, loc.end(), "return;")?; - return Ok(()); - } - - let expr = expr.as_mut().unwrap(); - let expr_loc_start = expr.loc().start(); - let write_return = |fmt: &mut Self| -> Result<()> { - write_chunk!(fmt, loc.start(), "return")?; - fmt.write_postfix_comments_before(expr_loc_start)?; - Ok(()) - }; - - let mut write_return_with_expr = |fmt: &mut Self| -> Result<()> { - let fits_on_single = fmt.try_on_single_line(|fmt| { - write_return(fmt)?; - expr.visit(fmt) - })?; - if fits_on_single { - return Ok(()); - } - - let mut fit_on_next_line = false; - let tx = fmt.transact(|fmt| { - fmt.grouped(|fmt| { - write_return(fmt)?; - if !fmt.is_beginning_of_line() { - fmt.write_whitespace_separator(true)?; - } - fit_on_next_line = fmt.try_on_single_line(|fmt| expr.visit(fmt))?; - Ok(()) - })?; - Ok(()) - })?; - if fit_on_next_line { - tx.commit()?; - return Ok(()); - } - - write_return(fmt)?; - expr.visit(fmt)?; - Ok(()) - }; - - write_return_with_expr(self)?; - write_chunk!(self, loc.end(), ";")?; - Ok(()) - } - - #[instrument(name = "revert", skip_all)] - fn visit_revert( - &mut self, - loc: Loc, - error: &mut Option, - args: &mut Vec, - ) -> Result<(), Self::Error> { - return_source_if_disabled!(self, loc, ';'); - write_chunk!(self, loc.start(), "revert")?; - if let Some(error) = error { - error.visit(self)?; - } - self.visit_list("", args, None, Some(loc.end()), true)?; - self.write_semicolon()?; - - Ok(()) - } - - #[instrument(name = "revert_named_args", skip_all)] - fn visit_revert_named_args( - &mut self, - loc: Loc, - error: &mut Option, - args: &mut Vec, - ) -> Result<(), Self::Error> { - return_source_if_disabled!(self, loc, ';'); - - write_chunk!(self, loc.start(), "revert")?; - let mut error_indented = false; - if let Some(error) = error - && !self.try_on_single_line(|fmt| error.visit(fmt))? - { - error.visit(self)?; - error_indented = true; - } - - if args.is_empty() { - write!(self.buf(), "({{}});")?; - return Ok(()); - } - - write!(self.buf(), "(")?; - self.indented_if(error_indented, 1, |fmt| fmt.visit_args(loc, args))?; - write!(self.buf(), ")")?; - self.write_semicolon()?; - - Ok(()) - } - - #[instrument(name = "break", skip_all)] - fn visit_break(&mut self, loc: Loc, semicolon: bool) -> Result<()> { - if semicolon { - return_source_if_disabled!(self, loc, ';'); - } else { - return_source_if_disabled!(self, loc); - } - write_chunk!(self, loc.start(), loc.end(), "break{}", if semicolon { ";" } else { "" }) - } - - #[instrument(name = "continue", skip_all)] - fn visit_continue(&mut self, loc: Loc, semicolon: bool) -> Result<()> { - if semicolon { - return_source_if_disabled!(self, loc, ';'); - } else { - return_source_if_disabled!(self, loc); - } - write_chunk!(self, loc.start(), loc.end(), "continue{}", if semicolon { ";" } else { "" }) - } - - #[instrument(name = "try", skip_all)] - fn visit_try( - &mut self, - loc: Loc, - expr: &mut Expression, - returns: &mut Option<(Vec<(Loc, Option)>, Box)>, - clauses: &mut Vec, - ) -> Result<(), Self::Error> { - return_source_if_disabled!(self, loc); - - let try_next_byte = clauses.first().map(|c| match c { - CatchClause::Simple(loc, ..) => loc.start(), - CatchClause::Named(loc, ..) => loc.start(), - }); - let try_chunk = self.chunked(loc.start(), try_next_byte, |fmt| { - write_chunk!(fmt, loc.start(), expr.loc().start(), "try")?; - expr.visit(fmt)?; - if let Some((params, stmt)) = returns { - let mut params = - params.iter_mut().filter(|(_, param)| param.is_some()).collect::>(); - let byte_offset = params.first().map_or(stmt.loc().start(), |p| p.0.start()); - fmt.surrounded( - SurroundingChunk::new("returns (", Some(byte_offset), None), - SurroundingChunk::new(")", None, params.last().map(|p| p.0.end())), - |fmt, _| { - let chunks = fmt.items_to_chunks( - Some(stmt.loc().start()), - params.iter_mut().map(|(loc, ident)| (*loc, ident)), - )?; - let multiline = fmt.are_chunks_separated_multiline("{})", &chunks, ",")?; - fmt.write_chunks_separated(&chunks, ",", multiline)?; - Ok(()) - }, - )?; - stmt.visit(fmt)?; - } - Ok(()) - })?; - - let mut chunks = vec![try_chunk]; - for clause in clauses { - let (loc, ident, mut param, stmt) = match clause { - CatchClause::Simple(loc, param, stmt) => (loc, None, param.as_mut(), stmt), - CatchClause::Named(loc, ident, param, stmt) => { - (loc, Some(ident), Some(param), stmt) - } - }; - - let chunk = self.chunked(loc.start(), Some(stmt.loc().start()), |fmt| { - write_chunk!(fmt, "catch")?; - if let Some(ident) = ident.as_ref() { - fmt.write_postfix_comments_before( - param.as_ref().map(|p| p.loc.start()).unwrap_or_else(|| ident.loc.end()), - )?; - write_chunk!(fmt, ident.loc.start(), "{}", ident.name)?; - } - if let Some(param) = param.as_mut() { - write_chunk_spaced!(fmt, param.loc.start(), Some(ident.is_none()), "(")?; - fmt.surrounded( - SurroundingChunk::new("", Some(param.loc.start()), None), - SurroundingChunk::new(")", None, Some(stmt.loc().start())), - |fmt, _| param.visit(fmt), - )?; - } - - stmt.visit(fmt)?; - Ok(()) - })?; - - chunks.push(chunk); - } - - let multiline = self.are_chunks_separated_multiline("{}", &chunks, "")?; - if !multiline { - self.write_chunks_separated(&chunks, "", false)?; - return Ok(()); - } - - let mut chunks = chunks.iter_mut().peekable(); - let mut prev_multiline = false; - - // write try chunk first - if let Some(chunk) = chunks.next() { - let chunk_str = self.simulate_to_string(|fmt| fmt.write_chunk(chunk))?; - write!(self.buf(), "{chunk_str}")?; - prev_multiline = chunk_str.contains('\n'); - } - - while let Some(chunk) = chunks.next() { - let chunk_str = self.simulate_to_string(|fmt| fmt.write_chunk(chunk))?; - let multiline = chunk_str.contains('\n'); - self.indented_if(!multiline, 1, |fmt| { - chunk.needs_space = Some(false); - let on_same_line = prev_multiline && (multiline || chunks.peek().is_none()); - let prefix = if fmt.is_beginning_of_line() { - "" - } else if on_same_line { - " " - } else { - "\n" - }; - let chunk_str = format!("{prefix}{chunk_str}"); - write!(fmt.buf(), "{chunk_str}")?; - Ok(()) - })?; - prev_multiline = multiline; - } - Ok(()) - } - - #[instrument(name = "if", skip_all)] - fn visit_if( - &mut self, - loc: Loc, - cond: &mut Expression, - if_branch: &mut Box, - else_branch: &mut Option>, - is_first_stmt: bool, - ) -> Result<(), Self::Error> { - return_source_if_disabled!(self, loc); - - if !is_first_stmt { - self.write_if_stmt(loc, cond, if_branch, else_branch)?; - return Ok(()); - } - - self.context.if_stmt_single_line = Some(true); - let mut stmt_fits_on_single = false; - let tx = self.transact(|fmt| { - stmt_fits_on_single = match fmt.write_if_stmt(loc, cond, if_branch, else_branch) { - Ok(()) => true, - Err(FormatterError::Fmt(_)) => false, - Err(err) => bail!(err), - }; - Ok(()) - })?; - - if stmt_fits_on_single { - tx.commit()?; - } else { - self.context.if_stmt_single_line = Some(false); - self.write_if_stmt(loc, cond, if_branch, else_branch)?; - } - self.context.if_stmt_single_line = None; - - Ok(()) - } - - #[instrument(name = "do_while", skip_all)] - fn visit_do_while( - &mut self, - loc: Loc, - body: &mut Statement, - cond: &mut Expression, - ) -> Result<(), Self::Error> { - return_source_if_disabled!(self, loc, ';'); - write_chunk!(self, loc.start(), "do ")?; - self.visit_stmt_as_block(body, false)?; - visit_source_if_disabled_else!(self, loc.with_start(body.loc().end()), { - self.surrounded( - SurroundingChunk::new("while (", Some(cond.loc().start()), None), - SurroundingChunk::new(");", None, Some(loc.end())), - |fmt, _| cond.visit(fmt), - )?; - }); - Ok(()) - } - - #[instrument(name = "while", skip_all)] - fn visit_while( - &mut self, - loc: Loc, - cond: &mut Expression, - body: &mut Statement, - ) -> Result<(), Self::Error> { - return_source_if_disabled!(self, loc); - self.surrounded( - SurroundingChunk::new("while (", Some(loc.start()), None), - SurroundingChunk::new(")", None, Some(cond.loc().end())), - |fmt, _| { - cond.visit(fmt)?; - fmt.write_postfix_comments_before(body.loc().start()) - }, - )?; - - let cond_close_paren_loc = - self.find_next_in_src(cond.loc().end(), ')').unwrap_or_else(|| cond.loc().end()); - let attempt_single_line = self.should_attempt_block_single_line(body, cond_close_paren_loc); - self.visit_stmt_as_block(body, attempt_single_line)?; - Ok(()) - } - - #[instrument(name = "for", skip_all)] - fn visit_for( - &mut self, - loc: Loc, - init: &mut Option>, - cond: &mut Option>, - update: &mut Option>, - body: &mut Option>, - ) -> Result<(), Self::Error> { - return_source_if_disabled!(self, loc); - - let next_byte_end = update.as_ref().map(|u| u.loc().end()); - self.surrounded( - SurroundingChunk::new("for (", Some(loc.start()), None), - SurroundingChunk::new(")", None, next_byte_end), - |fmt, _| { - let mut write_for_loop_header = |fmt: &mut Self, multiline: bool| -> Result<()> { - match init { - Some(stmt) => stmt.visit(fmt), - None => fmt.write_semicolon(), - }?; - if multiline { - fmt.write_whitespace_separator(true)?; - } - - cond.visit(fmt)?; - fmt.write_semicolon()?; - if multiline { - fmt.write_whitespace_separator(true)?; - } - - match update { - Some(expr) => expr.visit(fmt), - None => Ok(()), - } - }; - let multiline = !fmt.try_on_single_line(|fmt| write_for_loop_header(fmt, false))?; - if multiline { - write_for_loop_header(fmt, true)?; - } - Ok(()) - }, - )?; - match body { - Some(body) => { - self.visit_stmt_as_block(body, false)?; - } - None => { - self.write_empty_brackets()?; - } - }; - Ok(()) - } - - #[instrument(name = "function", skip_all)] - fn visit_function(&mut self, func: &mut FunctionDefinition) -> Result<()> { - if func.body.is_some() { - return_source_if_disabled!(self, func.loc()); - } else { - return_source_if_disabled!(self, func.loc(), ';'); - } - - self.with_function_context(func.clone(), |fmt| { - fmt.write_postfix_comments_before(func.loc.start())?; - fmt.write_prefix_comments_before(func.loc.start())?; - - let body_loc = func.body.as_ref().map(CodeLocation::loc); - let mut attrs_multiline = false; - let fits_on_single = fmt.try_on_single_line(|fmt| { - fmt.write_function_header(func, body_loc, false)?; - Ok(()) - })?; - if !fits_on_single { - attrs_multiline = fmt.write_function_header(func, body_loc, true)?; - } - - // write function body - match &mut func.body { - Some(body) => { - let body_loc = body.loc(); - // Handle case where block / statements starts on disabled line. - if fmt.inline_config.is_disabled(body_loc.with_end(body_loc.start())) { - match body { - Statement::Block { statements, .. } if !statements.is_empty() => { - fmt.write_whitespace_separator(false)?; - fmt.visit_block(body_loc, statements, false, false)?; - return Ok(()); - } - _ => { - // Attrs should be written on same line if first line is disabled - // and there's no statement. - attrs_multiline = false - } - } - } - - let byte_offset = body_loc.start(); - let body = fmt.visit_to_chunk(byte_offset, Some(body_loc.end()), body)?; - fmt.write_whitespace_separator( - attrs_multiline && !(func.attributes.is_empty() && func.returns.is_empty()), - )?; - fmt.write_chunk(&body)?; - } - None => fmt.write_semicolon()?, - } - Ok(()) - })?; - - Ok(()) - } - - #[instrument(name = "function_attribute", skip_all)] - fn visit_function_attribute(&mut self, attribute: &mut FunctionAttribute) -> Result<()> { - return_source_if_disabled!(self, attribute.loc()); - - match attribute { - FunctionAttribute::Mutability(mutability) => { - write_chunk!(self, mutability.loc().end(), "{mutability}")? - } - FunctionAttribute::Visibility(visibility) => { - // Visibility will always have a location in a Function attribute - write_chunk!(self, visibility.loc_opt().unwrap().end(), "{visibility}")? - } - FunctionAttribute::Virtual(loc) => write_chunk!(self, loc.end(), "virtual")?, - FunctionAttribute::Immutable(loc) => write_chunk!(self, loc.end(), "immutable")?, - FunctionAttribute::Override(loc, args) => { - write_chunk!(self, loc.start(), "override")?; - if !args.is_empty() && self.config.override_spacing { - self.write_whitespace_separator(false)?; - } - self.visit_list("", args, None, Some(loc.end()), false)? - } - FunctionAttribute::BaseOrModifier(loc, base) => { - // here we need to find out if this attribute belongs to the constructor because the - // modifier need to include the trailing parenthesis - // This is very ambiguous because the modifier can either by an inherited contract - // or a modifier here: e.g.: This is valid constructor: - // `constructor() public Ownable() OnlyOwner {}` - let is_constructor = self.context.is_constructor_function(); - // we can't make any decisions here regarding trailing `()` because we'd need to - // find out if the `base` is a solidity modifier or an - // interface/contract therefore we use its raw content. - - // we can however check if the contract `is` the `base`, this however also does - // not cover all cases - let is_contract_base = self.context.contract.as_ref().is_some_and(|contract| { - contract.base.iter().any(|contract_base| { - contract_base - .name - .identifiers - .iter() - .zip(&base.name.identifiers) - .all(|(l, r)| l.name == r.name) - }) - }); - - if is_contract_base { - base.visit(self)?; - } else if is_constructor { - // This is ambiguous because the modifier can either by an inherited - // contract modifiers with empty parenthesis are - // valid, but not required so we make the assumption - // here that modifiers are lowercase - let mut base_or_modifier = - self.visit_to_chunk(loc.start(), Some(loc.end()), base)?; - let is_lowercase = - base_or_modifier.content.chars().next().is_some_and(|c| c.is_lowercase()); - if is_lowercase && base_or_modifier.content.ends_with("()") { - base_or_modifier.content.truncate(base_or_modifier.content.len() - 2); - } - - self.write_chunk(&base_or_modifier)?; - } else { - let mut base_or_modifier = - self.visit_to_chunk(loc.start(), Some(loc.end()), base)?; - if base_or_modifier.content.ends_with("()") { - base_or_modifier.content.truncate(base_or_modifier.content.len() - 2); - } - self.write_chunk(&base_or_modifier)?; - } - } - FunctionAttribute::Error(loc) => self.visit_parser_error(*loc)?, - }; - - Ok(()) - } - - #[instrument(name = "var_attribute", skip_all)] - fn visit_var_attribute(&mut self, attribute: &mut VariableAttribute) -> Result<()> { - return_source_if_disabled!(self, attribute.loc()); - - let token = match attribute { - VariableAttribute::Visibility(visibility) => Some(visibility.to_string()), - VariableAttribute::Constant(_) => Some("constant".to_string()), - VariableAttribute::Immutable(_) => Some("immutable".to_string()), - VariableAttribute::StorageType(_) => None, // Unsupported - VariableAttribute::Override(loc, idents) => { - write_chunk!(self, loc.start(), "override")?; - if !idents.is_empty() && self.config.override_spacing { - self.write_whitespace_separator(false)?; - } - self.visit_list("", idents, Some(loc.start()), Some(loc.end()), false)?; - None - } - VariableAttribute::StorageLocation(storage) => Some(storage.to_string()), - }; - if let Some(token) = token { - let loc = attribute.loc(); - write_chunk!(self, loc.start(), loc.end(), "{}", token)?; - } - Ok(()) - } - - #[instrument(name = "base", skip_all)] - fn visit_base(&mut self, base: &mut Base) -> Result<()> { - return_source_if_disabled!(self, base.loc); - - let name_loc = &base.name.loc; - let mut name = self.chunked(name_loc.start(), Some(name_loc.end()), |fmt| { - fmt.visit_ident_path(&mut base.name)?; - Ok(()) - })?; - - if base.args.is_none() || base.args.as_ref().unwrap().is_empty() { - // This is ambiguous because the modifier can either by an inherited contract or a - // modifier - if self.context.function.is_some() { - name.content.push_str("()"); - } - self.write_chunk(&name)?; - return Ok(()); - } - - let args = base.args.as_mut().unwrap(); - let args_start = CodeLocation::loc(args.first().unwrap()).start(); - - name.content.push('('); - let formatted_name = self.chunk_to_string(&name)?; - - let multiline = !self.will_it_fit(&formatted_name); - - self.surrounded( - SurroundingChunk::new(&formatted_name, Some(args_start), None), - SurroundingChunk::new(")", None, Some(base.loc.end())), - |fmt, multiline_hint| { - let args = fmt.items_to_chunks( - Some(base.loc.end()), - args.iter_mut().map(|arg| (arg.loc(), arg)), - )?; - let multiline = multiline - || multiline_hint - || fmt.are_chunks_separated_multiline("{}", &args, ",")?; - fmt.write_chunks_separated(&args, ",", multiline)?; - Ok(()) - }, - )?; - - Ok(()) - } - - #[instrument(name = "parameter", skip_all)] - fn visit_parameter(&mut self, parameter: &mut Parameter) -> Result<()> { - return_source_if_disabled!(self, parameter.loc); - self.grouped(|fmt| { - parameter.ty.visit(fmt)?; - if let Some(storage) = ¶meter.storage { - write_chunk!(fmt, storage.loc().end(), "{storage}")?; - } - if let Some(name) = ¶meter.name { - write_chunk!(fmt, parameter.loc.end(), "{}", name.name)?; - } - Ok(()) - })?; - Ok(()) - } - - #[instrument(name = "struct", skip_all)] - fn visit_struct(&mut self, structure: &mut StructDefinition) -> Result<()> { - return_source_if_disabled!(self, structure.loc); - self.grouped(|fmt| { - let struct_name = structure.name.safe_unwrap_mut(); - write_chunk!(fmt, struct_name.loc.start(), "struct")?; - struct_name.visit(fmt)?; - if structure.fields.is_empty() { - return fmt.write_empty_brackets(); - } - - write!(fmt.buf(), " {{")?; - fmt.surrounded( - SurroundingChunk::new("", Some(struct_name.loc.end()), None), - SurroundingChunk::new("}", None, Some(structure.loc.end())), - |fmt, _multiline| { - let chunks = fmt.items_to_chunks( - Some(structure.loc.end()), - structure.fields.iter_mut().map(|ident| (ident.loc, ident)), - )?; - for mut chunk in chunks { - chunk.content.push(';'); - fmt.write_chunk(&chunk)?; - fmt.write_whitespace_separator(true)?; - } - Ok(()) - }, - ) - })?; - - Ok(()) - } - - #[instrument(name = "event", skip_all)] - fn visit_event(&mut self, event: &mut EventDefinition) -> Result<()> { - return_source_if_disabled!(self, event.loc, ';'); - - let event_name = event.name.safe_unwrap_mut(); - let mut name = - self.visit_to_chunk(event_name.loc.start(), Some(event.loc.end()), event_name)?; - name.content = format!("event {}(", name.content); - - let last_chunk = if event.anonymous { ") anonymous;" } else { ");" }; - if event.fields.is_empty() { - name.content.push_str(last_chunk); - self.write_chunk(&name)?; - } else { - let byte_offset = event.fields.first().unwrap().loc.start(); - let first_chunk = self.chunk_to_string(&name)?; - self.surrounded( - SurroundingChunk::new(first_chunk, Some(byte_offset), None), - SurroundingChunk::new(last_chunk, None, Some(event.loc.end())), - |fmt, multiline| { - let params = fmt - .items_to_chunks(None, event.fields.iter_mut().map(|arg| (arg.loc, arg)))?; - - let multiline = - multiline && fmt.are_chunks_separated_multiline("{}", ¶ms, ",")?; - fmt.write_chunks_separated(¶ms, ",", multiline) - }, - )?; - } - - Ok(()) - } - - #[instrument(name = "event_parameter", skip_all)] - fn visit_event_parameter(&mut self, param: &mut EventParameter) -> Result<()> { - return_source_if_disabled!(self, param.loc); - - self.grouped(|fmt| { - param.ty.visit(fmt)?; - if param.indexed { - write_chunk!(fmt, param.loc.start(), "indexed")?; - } - if let Some(name) = ¶m.name { - write_chunk!(fmt, name.loc.end(), "{}", name.name)?; - } - Ok(()) - })?; - Ok(()) - } - - #[instrument(name = "error", skip_all)] - fn visit_error(&mut self, error: &mut ErrorDefinition) -> Result<()> { - return_source_if_disabled!(self, error.loc, ';'); - - let error_name = error.name.safe_unwrap_mut(); - let mut name = self.visit_to_chunk(error_name.loc.start(), None, error_name)?; - name.content = format!("error {}", name.content); - - let formatted_name = self.chunk_to_string(&name)?; - write!(self.buf(), "{formatted_name}")?; - let start_offset = error.fields.first().map(|f| f.loc.start()); - self.visit_list("", &mut error.fields, start_offset, Some(error.loc.end()), true)?; - self.write_semicolon()?; - - Ok(()) - } - - #[instrument(name = "error_parameter", skip_all)] - fn visit_error_parameter(&mut self, param: &mut ErrorParameter) -> Result<()> { - return_source_if_disabled!(self, param.loc); - self.grouped(|fmt| { - param.ty.visit(fmt)?; - if let Some(name) = ¶m.name { - write_chunk!(fmt, name.loc.end(), "{}", name.name)?; - } - Ok(()) - })?; - Ok(()) - } - - #[instrument(name = "type_definition", skip_all)] - fn visit_type_definition(&mut self, def: &mut TypeDefinition) -> Result<()> { - return_source_if_disabled!(self, def.loc, ';'); - self.grouped(|fmt| { - write_chunk!(fmt, def.loc.start(), def.name.loc.start(), "type")?; - def.name.visit(fmt)?; - write_chunk!(fmt, def.name.loc.end(), CodeLocation::loc(&def.ty).start(), "is")?; - def.ty.visit(fmt)?; - fmt.write_semicolon()?; - Ok(()) - })?; - Ok(()) - } - - #[instrument(name = "stray_semicolon", skip_all)] - fn visit_stray_semicolon(&mut self) -> Result<()> { - self.write_semicolon() - } - - #[instrument(name = "opening_paren", skip_all)] - fn visit_opening_paren(&mut self) -> Result<()> { - write_chunk!(self, "(")?; - Ok(()) - } - - #[instrument(name = "closing_paren", skip_all)] - fn visit_closing_paren(&mut self) -> Result<()> { - write_chunk!(self, ")")?; - Ok(()) - } - - #[instrument(name = "newline", skip_all)] - fn visit_newline(&mut self) -> Result<()> { - writeln_chunk!(self)?; - Ok(()) - } - - #[instrument(name = "using", skip_all)] - fn visit_using(&mut self, using: &mut Using) -> Result<()> { - return_source_if_disabled!(self, using.loc, ';'); - - write_chunk!(self, using.loc.start(), "using")?; - - let ty_start = using.ty.as_mut().map(|ty| CodeLocation::loc(&ty).start()); - let global_start = using.global.as_mut().map(|global| global.loc.start()); - let loc_end = using.loc.end(); - - let (is_library, mut list_chunks) = match &mut using.list { - UsingList::Library(library) => { - (true, vec![self.visit_to_chunk(library.loc.start(), None, library)?]) - } - UsingList::Functions(funcs) => { - let mut funcs = funcs.iter_mut().peekable(); - let mut chunks = Vec::new(); - while let Some(func) = funcs.next() { - let next_byte_end = funcs.peek().map(|func| func.loc.start()); - chunks.push(self.chunked(func.loc.start(), next_byte_end, |fmt| { - fmt.visit_ident_path(&mut func.path)?; - if let Some(op) = func.oper { - write!(fmt.buf(), " as {op}")?; - } - Ok(()) - })?); - } - (false, chunks) - } - UsingList::Error => return self.visit_parser_error(using.loc), - }; - - let for_chunk = self.chunk_at( - using.loc.start(), - Some(ty_start.or(global_start).unwrap_or(loc_end)), - None, - "for", - ); - let ty_chunk = if let Some(ty) = &mut using.ty { - self.visit_to_chunk(ty.loc().start(), Some(global_start.unwrap_or(loc_end)), ty)? - } else { - self.chunk_at(using.loc.start(), Some(global_start.unwrap_or(loc_end)), None, "*") - }; - let global_chunk = using - .global - .as_mut() - .map(|global| self.visit_to_chunk(global.loc.start(), Some(using.loc.end()), global)) - .transpose()?; - - let write_for_def = |fmt: &mut Self| { - fmt.grouped(|fmt| { - fmt.write_chunk(&for_chunk)?; - fmt.write_chunk(&ty_chunk)?; - if let Some(global_chunk) = global_chunk.as_ref() { - fmt.write_chunk(global_chunk)?; - } - Ok(()) - })?; - Ok(()) - }; - - let simulated_for_def = self.simulate_to_string(write_for_def)?; - - if is_library { - let chunk = list_chunks.pop().unwrap(); - if self.will_chunk_fit(&format!("{{}} {simulated_for_def};"), &chunk)? { - self.write_chunk(&chunk)?; - } else { - self.write_whitespace_separator(true)?; - self.grouped(|fmt| { - fmt.write_chunk(&chunk)?; - Ok(()) - })?; - self.write_whitespace_separator(true)?; - } - } else { - self.surrounded( - SurroundingChunk::new("{", Some(using.loc.start()), None), - SurroundingChunk::new( - "}", - None, - Some(ty_start.or(global_start).unwrap_or(loc_end)), - ), - |fmt, _multiline| { - let multiline = fmt.are_chunks_separated_multiline( - &format!("{{ {{}} }} {simulated_for_def};"), - &list_chunks, - ",", - )?; - fmt.write_chunks_separated(&list_chunks, ",", multiline)?; - Ok(()) - }, - )?; - } - write_for_def(self)?; - - self.write_semicolon()?; - - Ok(()) - } - - #[instrument(name = "yul_block", skip_all)] - fn visit_yul_block( - &mut self, - loc: Loc, - statements: &mut Vec, - attempt_single_line: bool, - ) -> Result<(), Self::Error> { - return_source_if_disabled!(self, loc); - self.visit_block(loc, statements, attempt_single_line, false)?; - Ok(()) - } - - #[instrument(name = "yul_expr", skip_all)] - fn visit_yul_expr(&mut self, expr: &mut YulExpression) -> Result<(), Self::Error> { - return_source_if_disabled!(self, expr.loc()); - - match expr { - YulExpression::BoolLiteral(loc, val, ident) => { - let val = if *val { "true" } else { "false" }; - self.visit_yul_string_with_ident(*loc, val, ident) - } - YulExpression::FunctionCall(expr) => self.visit_yul_function_call(expr), - YulExpression::HexNumberLiteral(loc, val, ident) => { - self.visit_yul_string_with_ident(*loc, val, ident) - } - YulExpression::HexStringLiteral(val, ident) => self.visit_yul_string_with_ident( - val.loc, - &self.quote_str(val.loc, Some("hex"), &val.hex), - ident, - ), - YulExpression::NumberLiteral(loc, val, expr, ident) => { - let val = if expr.is_empty() { val.to_owned() } else { format!("{val}e{expr}") }; - self.visit_yul_string_with_ident(*loc, &val, ident) - } - YulExpression::StringLiteral(val, ident) => self.visit_yul_string_with_ident( - val.loc, - &self.quote_str(val.loc, None, &val.string), - ident, - ), - YulExpression::SuffixAccess(_, expr, ident) => { - self.visit_member_access(expr, ident, |fmt, expr| match expr.as_mut() { - YulExpression::SuffixAccess(_, inner_expr, inner_ident) => { - Ok(Some((inner_expr, inner_ident))) - } - expr => { - expr.visit(fmt)?; - Ok(None) - } - }) - } - YulExpression::Variable(ident) => { - write_chunk!(self, ident.loc.start(), ident.loc.end(), "{}", ident.name) - } - } - } - - #[instrument(name = "yul_assignment", skip_all)] - fn visit_yul_assignment( - &mut self, - loc: Loc, - exprs: &mut Vec, - expr: &mut Option<&mut YulExpression>, - ) -> Result<(), Self::Error> - where - T: Visitable + CodeLocation, - { - return_source_if_disabled!(self, loc); - - self.grouped(|fmt| { - let chunks = - fmt.items_to_chunks(None, exprs.iter_mut().map(|expr| (expr.loc(), expr)))?; - - let multiline = fmt.are_chunks_separated_multiline("{} := ", &chunks, ",")?; - fmt.write_chunks_separated(&chunks, ",", multiline)?; - - if let Some(expr) = expr { - write_chunk!(fmt, expr.loc().start(), ":=")?; - let chunk = fmt.visit_to_chunk(expr.loc().start(), Some(loc.end()), expr)?; - if !fmt.will_chunk_fit("{}", &chunk)? { - fmt.write_whitespace_separator(true)?; - } - fmt.write_chunk(&chunk)?; - } - Ok(()) - })?; - Ok(()) - } - - #[instrument(name = "yul_for", skip_all)] - fn visit_yul_for(&mut self, stmt: &mut YulFor) -> Result<(), Self::Error> { - return_source_if_disabled!(self, stmt.loc); - write_chunk!(self, stmt.loc.start(), "for")?; - self.visit_yul_block(stmt.init_block.loc, &mut stmt.init_block.statements, true)?; - stmt.condition.visit(self)?; - self.visit_yul_block(stmt.post_block.loc, &mut stmt.post_block.statements, true)?; - self.visit_yul_block(stmt.execution_block.loc, &mut stmt.execution_block.statements, true)?; - Ok(()) - } - - #[instrument(name = "yul_function_call", skip_all)] - fn visit_yul_function_call(&mut self, stmt: &mut YulFunctionCall) -> Result<(), Self::Error> { - return_source_if_disabled!(self, stmt.loc); - write_chunk!(self, stmt.loc.start(), "{}", stmt.id.name)?; - self.visit_list("", &mut stmt.arguments, None, Some(stmt.loc.end()), true) - } - - #[instrument(name = "yul_fun_def", skip_all)] - fn visit_yul_fun_def(&mut self, stmt: &mut YulFunctionDefinition) -> Result<(), Self::Error> { - return_source_if_disabled!(self, stmt.loc); - - write_chunk!(self, stmt.loc.start(), "function {}", stmt.id.name)?; - - self.visit_list("", &mut stmt.params, None, None, true)?; - - if !stmt.returns.is_empty() { - self.grouped(|fmt| { - write_chunk!(fmt, "->")?; - - let chunks = fmt.items_to_chunks( - Some(stmt.body.loc.start()), - stmt.returns.iter_mut().map(|param| (param.loc, param)), - )?; - let multiline = fmt.are_chunks_separated_multiline("{}", &chunks, ",")?; - fmt.write_chunks_separated(&chunks, ",", multiline)?; - if multiline { - fmt.write_whitespace_separator(true)?; - } - Ok(()) - })?; - } - - stmt.body.visit(self)?; - - Ok(()) - } - - #[instrument(name = "yul_if", skip_all)] - fn visit_yul_if( - &mut self, - loc: Loc, - expr: &mut YulExpression, - block: &mut YulBlock, - ) -> Result<(), Self::Error> { - return_source_if_disabled!(self, loc); - write_chunk!(self, loc.start(), "if")?; - expr.visit(self)?; - self.visit_yul_block(block.loc, &mut block.statements, true) - } - - #[instrument(name = "yul_leave", skip_all)] - fn visit_yul_leave(&mut self, loc: Loc) -> Result<(), Self::Error> { - return_source_if_disabled!(self, loc); - write_chunk!(self, loc.start(), loc.end(), "leave") - } - - #[instrument(name = "yul_switch", skip_all)] - fn visit_yul_switch(&mut self, stmt: &mut YulSwitch) -> Result<(), Self::Error> { - return_source_if_disabled!(self, stmt.loc); - - write_chunk!(self, stmt.loc.start(), "switch")?; - stmt.condition.visit(self)?; - writeln_chunk!(self)?; - let mut cases = stmt.cases.iter_mut().peekable(); - while let Some(YulSwitchOptions::Case(loc, expr, block)) = cases.next() { - write_chunk!(self, loc.start(), "case")?; - expr.visit(self)?; - self.visit_yul_block(block.loc, &mut block.statements, true)?; - let is_last = cases.peek().is_none(); - if !is_last || stmt.default.is_some() { - writeln_chunk!(self)?; - } - } - if let Some(YulSwitchOptions::Default(loc, ref mut block)) = stmt.default { - write_chunk!(self, loc.start(), "default")?; - self.visit_yul_block(block.loc, &mut block.statements, true)?; - } - Ok(()) - } - - #[instrument(name = "yul_var_declaration", skip_all)] - fn visit_yul_var_declaration( - &mut self, - loc: Loc, - idents: &mut Vec, - expr: &mut Option, - ) -> Result<(), Self::Error> { - return_source_if_disabled!(self, loc); - self.grouped(|fmt| { - write_chunk!(fmt, loc.start(), "let")?; - fmt.visit_yul_assignment(loc, idents, &mut expr.as_mut()) - })?; - Ok(()) - } - - #[instrument(name = "yul_typed_ident", skip_all)] - fn visit_yul_typed_ident(&mut self, ident: &mut YulTypedIdentifier) -> Result<(), Self::Error> { - return_source_if_disabled!(self, ident.loc); - self.visit_yul_string_with_ident(ident.loc, &ident.id.name, &mut ident.ty) - } - - #[instrument(name = "parser_error", skip_all)] - fn visit_parser_error(&mut self, loc: Loc) -> Result<()> { - Err(FormatterError::InvalidParsedItem(loc)) - } -} - -/// An action which may be committed to a Formatter -struct Transaction<'f, 'a, W> { - fmt: &'f mut Formatter<'a, W>, - buffer: String, - comments: Comments, -} - -impl<'a, W> std::ops::Deref for Transaction<'_, 'a, W> { - type Target = Formatter<'a, W>; - fn deref(&self) -> &Self::Target { - self.fmt - } -} - -impl std::ops::DerefMut for Transaction<'_, '_, W> { - fn deref_mut(&mut self) -> &mut Self::Target { - self.fmt - } -} - -impl<'f, 'a, W: Write> Transaction<'f, 'a, W> { - /// Create a new transaction from a callback - fn new( - fmt: &'f mut Formatter<'a, W>, - fun: impl FnMut(&mut Formatter<'a, W>) -> Result<()>, - ) -> Result { - let mut comments = fmt.comments.clone(); - let buffer = fmt.with_temp_buf(fun)?.w; - comments = std::mem::replace(&mut fmt.comments, comments); - Ok(Self { fmt, buffer, comments }) - } - - /// Commit the transaction to the Formatter - fn commit(self) -> Result { - self.fmt.comments = self.comments; - write_chunk!(self.fmt, "{}", self.buffer)?; - Ok(self.buffer) - } -} diff --git a/crates/fmt/src/helpers.rs b/crates/fmt/src/helpers.rs deleted file mode 100644 index 9d28cebd5ca5b..0000000000000 --- a/crates/fmt/src/helpers.rs +++ /dev/null @@ -1,135 +0,0 @@ -use crate::{ - Comments, Formatter, FormatterConfig, FormatterError, Visitable, - inline_config::{InlineConfig, InvalidInlineConfigItem}, -}; -use ariadne::{Color, Fmt, Label, Report, ReportKind, Source}; -use itertools::Itertools; -use solang_parser::{diagnostics::Diagnostic, pt::*}; -use std::{fmt::Write, path::Path}; - -/// Result of parsing the source code -#[derive(Debug)] -pub struct Parsed<'a> { - /// The original source code. - pub src: &'a str, - /// The parse tree. - pub pt: SourceUnit, - /// Parsed comments. - pub comments: Comments, - /// Parsed inline config. - pub inline_config: InlineConfig, - /// Invalid inline config items parsed. - pub invalid_inline_config_items: Vec<(Loc, InvalidInlineConfigItem)>, -} - -/// Parse source code. -pub fn parse(src: &str) -> Result, FormatterError> { - parse_raw(src).map_err(|diag| FormatterError::Parse(src.to_string(), None, diag)) -} - -/// Parse source code with a path for diagnostics. -pub fn parse2<'s>(src: &'s str, path: Option<&Path>) -> Result, FormatterError> { - parse_raw(src) - .map_err(|diag| FormatterError::Parse(src.to_string(), path.map(ToOwned::to_owned), diag)) -} - -/// Parse source code, returning a list of diagnostics on failure. -pub fn parse_raw(src: &str) -> Result, Vec> { - let (pt, comments) = solang_parser::parse(src, 0)?; - let comments = Comments::new(comments, src); - let (inline_config_items, invalid_inline_config_items): (Vec<_>, Vec<_>) = - comments.parse_inline_config_items().partition_result(); - let inline_config = InlineConfig::new(inline_config_items, src); - Ok(Parsed { src, pt, comments, inline_config, invalid_inline_config_items }) -} - -/// Format parsed code -pub fn format_to( - writer: W, - mut parsed: Parsed<'_>, - config: FormatterConfig, -) -> Result<(), FormatterError> { - trace!(?parsed, ?config, "Formatting"); - let mut formatter = - Formatter::new(writer, parsed.src, parsed.comments, parsed.inline_config, config); - parsed.pt.visit(&mut formatter) -} - -/// Parse and format a string with default settings -pub fn format(src: &str) -> Result { - let parsed = parse(src)?; - - let mut output = String::new(); - format_to(&mut output, parsed, FormatterConfig::default())?; - - Ok(output) -} - -/// Converts the start offset of a `Loc` to `(line, col)` -pub fn offset_to_line_column(content: &str, start: usize) -> (usize, usize) { - debug_assert!(content.len() > start); - - // first line is `1` - let mut line_counter = 1; - for (offset, c) in content.chars().enumerate() { - if c == '\n' { - line_counter += 1; - } - if offset > start { - return (line_counter, offset - start); - } - } - - unreachable!("content.len() > start") -} - -/// Formats parser diagnostics -pub fn format_diagnostics_report( - content: &str, - path: Option<&Path>, - diagnostics: &[Diagnostic], -) -> String { - if diagnostics.is_empty() { - return String::new(); - } - - let filename = - path.map(|p| p.file_name().unwrap().to_string_lossy().to_string()).unwrap_or_default(); - let mut s = Vec::new(); - for diag in diagnostics { - let span = (filename.as_str(), diag.loc.start()..diag.loc.end()); - let mut report = Report::build(ReportKind::Error, span.clone()) - .with_message(format!("{:?}", diag.ty)) - .with_label( - Label::new(span) - .with_color(Color::Red) - .with_message(diag.message.as_str().fg(Color::Red)), - ); - - for note in &diag.notes { - report = report.with_note(¬e.message); - } - - report.finish().write((filename.as_str(), Source::from(content)), &mut s).unwrap(); - } - String::from_utf8(s).unwrap() -} - -pub fn import_path_string(path: &ImportPath) -> String { - match path { - ImportPath::Filename(s) => s.string.clone(), - ImportPath::Path(p) => p.to_string(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - // - #[test] - fn test_interface_format() { - let s = "interface I {\n function increment() external;\n function number() external view returns (uint256);\n function setNumber(uint256 newNumber) external;\n}"; - let _formatted = format(s).unwrap(); - } -} diff --git a/crates/fmt/src/inline_config.rs b/crates/fmt/src/inline_config.rs deleted file mode 100644 index e3177efcb5034..0000000000000 --- a/crates/fmt/src/inline_config.rs +++ /dev/null @@ -1,167 +0,0 @@ -use crate::comments::{CommentState, CommentStringExt}; -use itertools::Itertools; -use solang_parser::pt::Loc; -use std::{fmt, str::FromStr}; - -/// An inline config item -#[derive(Clone, Copy, Debug)] -pub enum InlineConfigItem { - /// Disables the next code item regardless of newlines - DisableNextItem, - /// Disables formatting on the current line - DisableLine, - /// Disables formatting between the next newline and the newline after - DisableNextLine, - /// Disables formatting for any code that follows this and before the next "disable-end" - DisableStart, - /// Disables formatting for any code that precedes this and after the previous "disable-start" - DisableEnd, -} - -impl FromStr for InlineConfigItem { - type Err = InvalidInlineConfigItem; - fn from_str(s: &str) -> Result { - Ok(match s { - "disable-next-item" => Self::DisableNextItem, - "disable-line" => Self::DisableLine, - "disable-next-line" => Self::DisableNextLine, - "disable-start" => Self::DisableStart, - "disable-end" => Self::DisableEnd, - s => return Err(InvalidInlineConfigItem(s.into())), - }) - } -} - -#[derive(Debug)] -pub struct InvalidInlineConfigItem(String); - -impl fmt::Display for InvalidInlineConfigItem { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_fmt(format_args!("Invalid inline config item: {}", self.0)) - } -} - -/// A disabled formatting range. `loose` designates that the range includes any loc which -/// may start in between start and end, whereas the strict version requires that -/// `range.start >= loc.start <=> loc.end <= range.end` -#[derive(Debug)] -struct DisabledRange { - start: usize, - end: usize, - loose: bool, -} - -impl DisabledRange { - fn includes(&self, loc: Loc) -> bool { - loc.start() >= self.start && (if self.loose { loc.start() } else { loc.end() } <= self.end) - } -} - -/// An inline config. Keeps track of disabled ranges. -/// -/// This is a list of Inline Config items for locations in a source file. This is -/// usually acquired by parsing the comments for an `forgefmt:` items. -/// -/// See [`Comments::parse_inline_config_items`](crate::Comments::parse_inline_config_items) for -/// details. -#[derive(Debug, Default)] -pub struct InlineConfig { - disabled_ranges: Vec, -} - -impl InlineConfig { - /// Build a new inline config with an iterator of inline config items and their locations in a - /// source file - pub fn new(items: impl IntoIterator, src: &str) -> Self { - let mut disabled_ranges = vec![]; - let mut disabled_range_start = None; - let mut disabled_depth = 0usize; - for (loc, item) in items.into_iter().sorted_by_key(|(loc, _)| loc.start()) { - match item { - InlineConfigItem::DisableNextItem => { - let offset = loc.end(); - let mut char_indices = src[offset..] - .comment_state_char_indices() - .filter_map(|(state, idx, ch)| match state { - CommentState::None => Some((idx, ch)), - _ => None, - }) - .skip_while(|(_, ch)| ch.is_whitespace()); - if let Some((mut start, _)) = char_indices.next() { - start += offset; - let end = char_indices - .find(|(_, ch)| !ch.is_whitespace()) - .map(|(idx, _)| offset + idx) - .unwrap_or(src.len()); - disabled_ranges.push(DisabledRange { start, end, loose: true }); - } - } - InlineConfigItem::DisableLine => { - let mut prev_newline = - src[..loc.start()].char_indices().rev().skip_while(|(_, ch)| *ch != '\n'); - let start = prev_newline - .next() - .map(|(idx, _)| { - if let Some((idx, ch)) = prev_newline.next() { - match ch { - '\r' => idx, - _ => idx + 1, - } - } else { - idx - } - }) - .unwrap_or_default(); - - let end_offset = loc.end(); - let mut next_newline = - src[end_offset..].char_indices().skip_while(|(_, ch)| *ch != '\n'); - let end = - end_offset + next_newline.next().map(|(idx, _)| idx).unwrap_or_default(); - - disabled_ranges.push(DisabledRange { start, end, loose: false }); - } - InlineConfigItem::DisableNextLine => { - let offset = loc.end(); - let mut char_indices = - src[offset..].char_indices().skip_while(|(_, ch)| *ch != '\n').skip(1); - if let Some((mut start, _)) = char_indices.next() { - start += offset; - let end = char_indices - .find(|(_, ch)| *ch == '\n') - .map(|(idx, _)| offset + idx + 1) - .unwrap_or(src.len()); - disabled_ranges.push(DisabledRange { start, end, loose: false }); - } - } - InlineConfigItem::DisableStart => { - if disabled_depth == 0 { - disabled_range_start = Some(loc.end()); - } - disabled_depth += 1; - } - InlineConfigItem::DisableEnd => { - disabled_depth = disabled_depth.saturating_sub(1); - if disabled_depth == 0 - && let Some(start) = disabled_range_start.take() - { - disabled_ranges.push(DisabledRange { - start, - end: loc.start(), - loose: false, - }) - } - } - } - } - if let Some(start) = disabled_range_start.take() { - disabled_ranges.push(DisabledRange { start, end: src.len(), loose: false }) - } - Self { disabled_ranges } - } - - /// Check if the location is in a disabled range - pub fn is_disabled(&self, loc: Loc) -> bool { - self.disabled_ranges.iter().any(|range| range.includes(loc)) - } -} diff --git a/crates/fmt/src/lib.rs b/crates/fmt/src/lib.rs index 0ad3e336f7af2..075629fcaf277 100644 --- a/crates/fmt/src/lib.rs +++ b/crates/fmt/src/lib.rs @@ -1,27 +1,288 @@ #![doc = include_str!("../README.md")] #![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] - -#[macro_use] -extern crate tracing; - -mod buffer; -pub mod chunk; -mod comments; -mod formatter; -mod helpers; -pub mod inline_config; -mod macros; -pub mod solang_ext; -mod string; -pub mod visit; +#![cfg_attr(docsrs, feature(doc_cfg))] -pub use foundry_config::fmt::*; +const DEBUG: bool = false || option_env!("FMT_DEBUG").is_some(); +const DEBUG_INDENT: bool = false || option_env!("FMT_DEBUG").is_some(); + +use foundry_common::comments::{ + Comment, Comments, + inline_config::{InlineConfig, InlineConfigItem}, +}; + +mod state; + +mod pp; -pub use comments::Comments; -pub use formatter::{Formatter, FormatterError}; -pub use helpers::{ - Parsed, format, format_diagnostics_report, format_to, offset_to_line_column, parse, parse2, +use solar::{ + parse::{ + ast::{SourceUnit, Span}, + interface::{Session, diagnostics::EmittedDiagnostics, source_map::SourceFile}, + }, + sema::{Compiler, Gcx, Source}, }; -pub use inline_config::InlineConfig; -pub use visit::{Visitable, Visitor}; + +use std::{path::Path, sync::Arc}; + +pub use foundry_config::fmt::*; + +/// The result of the formatter. +pub type FormatterResult = DiagnosticsResult; + +/// The result of the formatter. +#[derive(Debug)] +pub enum DiagnosticsResult { + /// Everything went well. + Ok(T), + /// No errors encountered, but warnings or other non-error diagnostics were emitted. + OkWithDiagnostics(T, E), + /// Errors encountered, but a result was produced anyway. + ErrRecovered(T, E), + /// Fatal errors encountered. + Err(E), +} + +impl DiagnosticsResult { + /// Converts the formatter result into a standard result. + /// + /// This ignores any non-error diagnostics if `Ok`, and any valid result if `Err`. + pub fn into_result(self) -> Result { + match self { + Self::Ok(s) | Self::OkWithDiagnostics(s, _) => Ok(s), + Self::ErrRecovered(_, d) | Self::Err(d) => Err(d), + } + } + + /// Returns the result, even if it was produced with errors. + pub fn into_ok(self) -> Result { + match self { + Self::Ok(s) | Self::OkWithDiagnostics(s, _) | Self::ErrRecovered(s, _) => Ok(s), + Self::Err(e) => Err(e), + } + } + + /// Returns any result produced. + pub const fn ok_ref(&self) -> Option<&T> { + match self { + Self::Ok(s) | Self::OkWithDiagnostics(s, _) | Self::ErrRecovered(s, _) => Some(s), + Self::Err(_) => None, + } + } + + /// Returns any diagnostics emitted. + pub const fn err_ref(&self) -> Option<&E> { + match self { + Self::Ok(_) => None, + Self::OkWithDiagnostics(_, d) | Self::ErrRecovered(_, d) | Self::Err(d) => Some(d), + } + } + + /// Returns `true` if the result is `Ok`. + pub const fn is_ok(&self) -> bool { + matches!(self, Self::Ok(_) | Self::OkWithDiagnostics(_, _)) + } + + /// Returns `true` if the result is `Err`. + pub const fn is_err(&self) -> bool { + !self.is_ok() + } +} + +pub fn format_file( + path: &Path, + config: Arc, + compiler: &mut Compiler, +) -> FormatterResult { + format_inner(config, compiler, &|sess| { + sess.source_map().load_file(path).map_err(|e| sess.dcx.err(e.to_string()).emit()) + }) +} + +pub fn format_source( + source: &str, + path: Option<&Path>, + config: Arc, + compiler: &mut Compiler, +) -> FormatterResult { + format_inner(config, compiler, &|sess| { + let name = match path { + Some(path) => solar::parse::interface::source_map::FileName::Real(path.to_path_buf()), + None => solar::parse::interface::source_map::FileName::Stdin, + }; + sess.source_map() + .new_source_file(name, source) + .map_err(|e| sess.dcx.err(e.to_string()).emit()) + }) +} + +/// Format a string input with the default compiler. +pub fn format(source: &str, config: FormatterConfig) -> FormatterResult { + let mut compiler = Compiler::new( + solar::interface::Session::builder().with_buffer_emitter(Default::default()).build(), + ); + + format_source(source, None, Arc::new(config), &mut compiler) +} + +fn format_inner( + config: Arc, + compiler: &mut Compiler, + mk_file: &(dyn Fn(&Session) -> solar::parse::interface::Result> + Sync + Send), +) -> FormatterResult { + // First pass formatting + let first_result = format_once(config.clone(), compiler, mk_file); + + // If first pass was not successful, return the result + if first_result.is_err() { + return first_result; + } + let Some(first_formatted) = first_result.ok_ref() else { return first_result }; + + // Second pass formatting + let second_result = format_once(config, compiler, &|sess| { + // Need a new name since we can't overwrite the original file. + let prev_sf = mk_file(sess)?; + let new_name = match &prev_sf.name { + solar::interface::source_map::FileName::Real(path) => { + path.with_extension("again.sol").into() + } + solar::interface::source_map::FileName::Stdin => { + solar::interface::source_map::FileName::Custom("stdin-again".to_string()) + } + solar::interface::source_map::FileName::Custom(name) => { + solar::interface::source_map::FileName::Custom(format!("{name}-again")) + } + }; + sess.source_map() + .new_source_file(new_name, first_formatted) + .map_err(|e| sess.dcx.err(e.to_string()).emit()) + }); + + // Check if the two passes produce the same output (idempotency) + match (first_result.ok_ref(), second_result.ok_ref()) { + (Some(first), Some(second)) if first != second => { + panic!("formatter is not idempotent:\n{}", diff(first, second)); + } + _ => {} + } + + assert!( + !(first_result.is_ok() && second_result.is_err() && !DEBUG), + "failed to format a second time:\nfirst_result={first_result:#?}\nsecond_result={second_result:#?}" + ); + first_result +} + +fn diff(first: &str, second: &str) -> impl std::fmt::Display { + use std::fmt::Write; + let diff = similar::TextDiff::from_lines(first, second); + let mut s = String::new(); + for change in diff.iter_all_changes() { + let tag = match change.tag() { + similar::ChangeTag::Delete => "-", + similar::ChangeTag::Insert => "+", + similar::ChangeTag::Equal => " ", + }; + write!(s, "{tag}{change}").unwrap(); + } + s +} + +fn format_once( + config: Arc, + compiler: &mut Compiler, + mk_file: &( + dyn Fn(&solar::interface::Session) -> solar::interface::Result> + + Send + + Sync + ), +) -> FormatterResult { + let res = compiler.enter_mut(|c| -> solar::interface::Result { + let mut pcx = c.parse(); + pcx.set_resolve_imports(false); + let file = mk_file(c.sess())?; + pcx.add_file(file.clone()); + pcx.parse(); + c.dcx().has_errors()?; + + let gcx = c.gcx(); + let (_, source) = gcx.get_ast_source(&file.name).unwrap(); + Ok(format_ast(gcx, source, config).expect("unable to format AST")) + }); + + let diagnostics = compiler.sess().dcx.emitted_diagnostics().unwrap(); + match (res, compiler.sess().dcx.has_errors()) { + (Ok(s), Ok(())) if diagnostics.is_empty() => FormatterResult::Ok(s), + (Ok(s), Ok(())) => FormatterResult::OkWithDiagnostics(s, diagnostics), + (Ok(s), Err(_)) => FormatterResult::ErrRecovered(s, diagnostics), + (Err(_), Ok(_)) => unreachable!(), + (Err(_), Err(_)) => FormatterResult::Err(diagnostics), + } +} + +// A parallel-safe "worker" function. +pub fn format_ast<'ast>( + gcx: Gcx<'ast>, + source: &'ast Source<'ast>, + config: Arc, +) -> Option { + let comments = Comments::new( + &source.file, + gcx.sess.source_map(), + true, + config.wrap_comments, + matches!(config.style, IndentStyle::Tab).then(|| config.tab_width), + ); + let ast = source.ast.as_ref()?; + let inline_config = parse_inline_config(gcx.sess, &comments, ast); + + let mut state = state::State::new(&source.file, config, inline_config, comments); + state.print_source_unit(ast); + Some(state.s.eof()) +} + +fn parse_inline_config<'ast>( + sess: &Session, + comments: &Comments, + ast: &'ast SourceUnit<'ast>, +) -> InlineConfig<()> { + let parse_item = |mut item: &str, cmnt: &Comment| -> Option<(Span, InlineConfigItem<()>)> { + if let Some(prefix) = cmnt.prefix() { + item = item.strip_prefix(prefix).unwrap_or(item); + } + if let Some(suffix) = cmnt.suffix() { + item = item.strip_suffix(suffix).unwrap_or(item); + } + let item = item.trim_start().strip_prefix("forgefmt:")?.trim(); + match item.parse::>() { + Ok(InlineConfigItem::DisableLine(())) if cmnt.style.is_isolated() => { + Some((cmnt.span, InlineConfigItem::DisableNextItem(()))) + } + Ok(item) => Some((cmnt.span, item)), + Err(e) => { + sess.dcx.warn(e.to_string()).span(cmnt.span).emit(); + None + } + } + }; + + let items = comments.iter().flat_map(|cmnt| { + let mut found_items = Vec::with_capacity(2); + // Always process the first line. + if let Some(line) = cmnt.lines.first() + && let Some(item) = parse_item(line, cmnt) + { + found_items.push(item); + } + // If the comment has more than one line, process the last line. + if cmnt.lines.len() > 1 + && let Some(line) = cmnt.lines.last() + && let Some(item) = parse_item(line, cmnt) + { + found_items.push(item); + } + found_items + }); + + InlineConfig::from_ast(items, ast, sess.source_map()) +} diff --git a/crates/fmt/src/macros.rs b/crates/fmt/src/macros.rs deleted file mode 100644 index 68305d9f2f11d..0000000000000 --- a/crates/fmt/src/macros.rs +++ /dev/null @@ -1,125 +0,0 @@ -macro_rules! write_chunk { - ($self:expr, $format_str:literal) => {{ - write_chunk!($self, $format_str,) - }}; - ($self:expr, $format_str:literal, $($arg:tt)*) => {{ - $self.write_chunk(&format!($format_str, $($arg)*).into()) - }}; - ($self:expr, $loc:expr) => {{ - write_chunk!($self, $loc, "") - }}; - ($self:expr, $loc:expr, $format_str:literal) => {{ - write_chunk!($self, $loc, $format_str,) - }}; - ($self:expr, $loc:expr, $format_str:literal, $($arg:tt)*) => {{ - let chunk = $self.chunk_at($loc, None, None, format_args!($format_str, $($arg)*),); - $self.write_chunk(&chunk) - }}; - ($self:expr, $loc:expr, $end_loc:expr, $format_str:literal) => {{ - write_chunk!($self, $loc, $end_loc, $format_str,) - }}; - ($self:expr, $loc:expr, $end_loc:expr, $format_str:literal, $($arg:tt)*) => {{ - let chunk = $self.chunk_at($loc, Some($end_loc), None, format_args!($format_str, $($arg)*),); - $self.write_chunk(&chunk) - }}; - ($self:expr, $loc:expr, $end_loc:expr, $needs_space:expr, $format_str:literal, $($arg:tt)*) => {{ - let chunk = $self.chunk_at($loc, Some($end_loc), Some($needs_space), format_args!($format_str, $($arg)*),); - $self.write_chunk(&chunk) - }}; -} - -macro_rules! writeln_chunk { - ($self:expr) => {{ - writeln_chunk!($self, "") - }}; - ($self:expr, $format_str:literal) => {{ - writeln_chunk!($self, $format_str,) - }}; - ($self:expr, $format_str:literal, $($arg:tt)*) => {{ - write_chunk!($self, "{}\n", format_args!($format_str, $($arg)*)) - }}; - ($self:expr, $loc:expr) => {{ - writeln_chunk!($self, $loc, "") - }}; - ($self:expr, $loc:expr, $format_str:literal) => {{ - writeln_chunk!($self, $loc, $format_str,) - }}; - ($self:expr, $loc:expr, $format_str:literal, $($arg:tt)*) => {{ - write_chunk!($self, $loc, "{}\n", format_args!($format_str, $($arg)*)) - }}; - ($self:expr, $loc:expr, $end_loc:expr, $format_str:literal) => {{ - writeln_chunk!($self, $loc, $end_loc, $format_str,) - }}; - ($self:expr, $loc:expr, $end_loc:expr, $format_str:literal, $($arg:tt)*) => {{ - write_chunk!($self, $loc, $end_loc, "{}\n", format_args!($format_str, $($arg)*)) - }}; -} - -macro_rules! write_chunk_spaced { - ($self:expr, $loc:expr, $needs_space:expr, $format_str:literal) => {{ - write_chunk_spaced!($self, $loc, $needs_space, $format_str,) - }}; - ($self:expr, $loc:expr, $needs_space:expr, $format_str:literal, $($arg:tt)*) => {{ - let chunk = $self.chunk_at($loc, None, $needs_space, format_args!($format_str, $($arg)*),); - $self.write_chunk(&chunk) - }}; -} - -macro_rules! buf_fn { - ($vis:vis fn $name:ident(&self $(,)? $($arg_name:ident : $arg_ty:ty),*) $(-> $ret:ty)?) => { - $vis fn $name(&self, $($arg_name : $arg_ty),*) $(-> $ret)? { - if self.temp_bufs.is_empty() { - self.buf.$name($($arg_name),*) - } else { - self.temp_bufs.last().unwrap().$name($($arg_name),*) - } - } - }; - ($vis:vis fn $name:ident(&mut self $(,)? $($arg_name:ident : $arg_ty:ty),*) $(-> $ret:ty)?) => { - $vis fn $name(&mut self, $($arg_name : $arg_ty),*) $(-> $ret)? { - if self.temp_bufs.is_empty() { - self.buf.$name($($arg_name),*) - } else { - self.temp_bufs.last_mut().unwrap().$name($($arg_name),*) - } - } - }; -} - -macro_rules! return_source_if_disabled { - ($self:expr, $loc:expr) => {{ - let loc = $loc; - if $self.inline_config.is_disabled(loc) { - trace!("Returning because disabled: {loc:?}"); - return $self.visit_source(loc); - } - }}; - ($self:expr, $loc:expr, $suffix:literal) => {{ - let mut loc = $loc; - let has_suffix = $self.extend_loc_until(&mut loc, $suffix); - if $self.inline_config.is_disabled(loc) { - $self.visit_source(loc)?; - trace!("Returning because disabled: {loc:?}"); - if !has_suffix { - write!($self.buf(), "{}", $suffix)?; - } - return Ok(()); - } - }}; -} - -macro_rules! visit_source_if_disabled_else { - ($self:expr, $loc:expr, $block:block) => {{ - let loc = $loc; - if $self.inline_config.is_disabled(loc) { - $self.visit_source(loc)?; - } else $block - }}; -} - -pub(crate) use buf_fn; -pub(crate) use return_source_if_disabled; -pub(crate) use visit_source_if_disabled_else; -pub(crate) use write_chunk; -pub(crate) use write_chunk_spaced; -pub(crate) use writeln_chunk; diff --git a/crates/fmt/src/pp/convenience.rs b/crates/fmt/src/pp/convenience.rs new file mode 100644 index 0000000000000..f162b11416b68 --- /dev/null +++ b/crates/fmt/src/pp/convenience.rs @@ -0,0 +1,172 @@ +use super::{BeginToken, BreakToken, Breaks, IndentStyle, Printer, SIZE_INFINITY, Token}; +use std::borrow::Cow; + +impl Printer { + /// "raw box" + pub fn rbox(&mut self, indent: isize, breaks: Breaks) { + self.scan_begin(BeginToken { indent: IndentStyle::Block { offset: indent }, breaks }); + } + + /// Inconsistent breaking box + pub fn ibox(&mut self, indent: isize) { + self.rbox(indent, Breaks::Inconsistent); + } + + /// Consistent breaking box + pub fn cbox(&mut self, indent: isize) { + self.rbox(indent, Breaks::Consistent); + } + + pub fn visual_align(&mut self) { + self.scan_begin(BeginToken { indent: IndentStyle::Visual, breaks: Breaks::Consistent }); + } + + pub fn break_offset(&mut self, n: usize, off: isize) { + self.scan_break(BreakToken { offset: off, blank_space: n, ..BreakToken::default() }); + } + + pub fn end(&mut self) { + self.scan_end(); + } + + pub fn eof(mut self) -> String { + self.scan_eof(); + self.out + } + + pub fn word(&mut self, w: impl Into>) { + self.scan_string(w.into()); + } + + fn spaces(&mut self, n: usize) { + self.break_offset(n, 0); + } + + pub fn zerobreak(&mut self) { + self.spaces(0); + } + + pub fn space(&mut self) { + self.spaces(1); + } + + pub fn hardbreak(&mut self) { + self.spaces(SIZE_INFINITY as usize); + } + + pub fn last_token_is_neverbreak(&self) -> bool { + if let Some(token) = self.last_token() { + return token.is_neverbreak(); + } + + false + } + + pub fn last_token_is_break(&self) -> bool { + if let Some(token) = self.last_token() { + return matches!(token, Token::Break(_)); + } + false + } + + pub fn last_token_is_space(&self) -> bool { + if let Some(token) = self.last_token() + && token.is_space() + { + return true; + } + + self.out.ends_with(' ') + } + + pub fn is_beginning_of_line(&self) -> bool { + match self.last_token() { + // Verbatim-printed source can end with a line break. Regular tokens never contain one. + Some(Token::String(s)) => s.ends_with('\n'), + Some(last_token) => last_token.is_hardbreak(), + None => self.out.is_empty() || self.out.ends_with('\n'), + } + } + + /// Attempts to identify whether the current position is: + /// 1. the beginning of a line (empty) + /// 2. a line with only indentation (just whitespaces) + /// + /// NOTE: this is still an educated guess, based on a heuristic. + pub fn is_bol_or_only_ind(&self) -> bool { + for i in self.buf.index_range().rev() { + let token = &self.buf[i].token; + if token.is_hardbreak() { + return true; + } + // Verbatim-printed source can contain line breaks; judge by its last line. + if let Token::String(s) = token + && let Some(pos) = s.rfind('\n') + { + return s[pos + 1..].trim().is_empty(); + } + if Self::token_has_non_whitespace_content(token) { + return false; + } + } + + let last_line = + if let Some(pos) = self.out.rfind('\n') { &self.out[pos + 1..] } else { &self.out[..] }; + + last_line.trim().is_empty() + } + + fn token_has_non_whitespace_content(token: &Token) -> bool { + match token { + Token::String(s) => !s.trim().is_empty(), + Token::Break(BreakToken { pre_break: Some(s), .. }) => !s.trim().is_empty(), + _ => false, + } + } + + pub(crate) fn hardbreak_tok_offset(offset: isize) -> Token { + Token::Break(BreakToken { + offset, + blank_space: SIZE_INFINITY as usize, + ..BreakToken::default() + }) + } + + pub fn hardbreak_if_nonempty(&mut self) { + self.scan_break(BreakToken { + blank_space: SIZE_INFINITY as usize, + if_nonempty: true, + ..BreakToken::default() + }); + } + + pub fn neverbreak(&mut self) { + self.scan_break(BreakToken { never_break: true, ..BreakToken::default() }); + } +} + +impl Token { + pub(crate) const fn is_neverbreak(&self) -> bool { + if let Self::Break(BreakToken { never_break, .. }) = *self { + return never_break; + } + false + } + + pub(crate) const fn is_hardbreak(&self) -> bool { + if let Self::Break(BreakToken { blank_space, never_break, .. }) = *self { + return blank_space == SIZE_INFINITY as usize && !never_break; + } + false + } + + pub(crate) fn is_space(&self) -> bool { + match self { + Self::Break(BreakToken { offset, blank_space, .. }) => { + *offset == 0 && *blank_space == 1 + } + Self::String(s) => s.ends_with(' '), + _ => false, + } + } +} diff --git a/crates/fmt/src/pp/helpers.rs b/crates/fmt/src/pp/helpers.rs new file mode 100644 index 0000000000000..9b1a5aa91582e --- /dev/null +++ b/crates/fmt/src/pp/helpers.rs @@ -0,0 +1,46 @@ +use super::{Printer, Token}; +use std::borrow::Cow; + +impl Printer { + pub fn word_space(&mut self, w: impl Into>) { + self.word(w); + self.space(); + } + + /// Adds a new hardbreak if not at the beginning of the line. + /// If there was a buffered break token, replaces it (ensures hardbreak) keeping the offset. + pub fn hardbreak_if_not_bol(&mut self) { + if !self.is_bol_or_only_ind() { + if let Some(Token::Break(last)) = self.last_token_still_buffered() + && last.offset != 0 + { + self.replace_last_token_still_buffered(Self::hardbreak_tok_offset(last.offset)); + return; + } + self.hardbreak(); + } + } + + pub fn space_if_not_bol(&mut self) { + if !self.is_bol_or_only_ind() { + self.space(); + } + } + + pub fn nbsp(&mut self) { + self.word(" "); + } + + pub fn space_or_nbsp(&mut self, breaks: bool) { + if breaks { + self.space(); + } else { + self.nbsp(); + } + } + + pub fn word_nbsp(&mut self, w: impl Into>) { + self.word(w); + self.nbsp(); + } +} diff --git a/crates/fmt/src/pp/mod.rs b/crates/fmt/src/pp/mod.rs new file mode 100644 index 0000000000000..849462bb51841 --- /dev/null +++ b/crates/fmt/src/pp/mod.rs @@ -0,0 +1,492 @@ +//! Adapted from [`rustc_ast_pretty`](https://github.com/rust-lang/rust/blob/07d3fd1d9b9c1f07475b96a9d168564bf528db68/compiler/rustc_ast_pretty/src/pp.rs) +//! and [`prettyplease`](https://github.com/dtolnay/prettyplease/blob/8eb8c14649aea32e810732bd4d64fe519e6b752a/src/algorithm.rs). + +use crate::{DEBUG, DEBUG_INDENT}; +use ring::RingBuffer; +use std::{borrow::Cow, cmp, collections::VecDeque, iter}; + +mod convenience; +mod helpers; +mod ring; + +// Every line is allowed at least this much space, even if highly indented. +const MIN_SPACE: isize = 40; + +/// How to break. Described in more detail in the module docs. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Breaks { + Consistent, + Inconsistent, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum IndentStyle { + /// Vertically aligned under whatever column this block begins at. + /// ```ignore + /// fn demo(arg1: usize, + /// arg2: usize) {} + /// ``` + Visual, + /// Indented relative to the indentation level of the previous line. + /// ```ignore + /// fn demo( + /// arg1: usize, + /// arg2: usize, + /// ) {} + /// ``` + Block { offset: isize }, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct BreakToken { + pub(crate) offset: isize, + pub(crate) blank_space: usize, + pub(crate) pre_break: Option<&'static str>, + pub(crate) post_break: Option<&'static str>, + pub(crate) if_nonempty: bool, + pub(crate) never_break: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct BeginToken { + indent: IndentStyle, + breaks: Breaks, +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum Token { + // In practice a string token contains either a `&'static str` or a + // `String`. `Cow` is overkill for this because we never modify the data, + // but it's more convenient than rolling our own more specialized type. + String(Cow<'static, str>), + Break(BreakToken), + Begin(BeginToken), + End, +} + +#[derive(Copy, Clone, Debug)] +enum PrintFrame { + Fits(Breaks), + Broken(usize, Breaks), +} + +pub(crate) const SIZE_INFINITY: isize = 0xffff; + +#[derive(Debug)] +pub struct Printer { + out: String, + /// Number of spaces left on line. + space: isize, + /// Ring-buffer of tokens and calculated sizes. + buf: RingBuffer, + /// Running size of stream "...left". + left_total: isize, + /// Running size of stream "...right". + right_total: isize, + /// Pseudo-stack, really a ring too. Holds the + /// primary-ring-buffers index of the Begin that started the + /// current block, possibly with the most recent Break after that + /// Begin (if there is any) on top of it. Stuff is flushed off the + /// bottom as it becomes irrelevant due to the primary ring-buffer + /// advancing. + scan_stack: VecDeque, + /// Stack of blocks-in-progress being flushed by print. + print_stack: Vec, + /// Level of indentation of current line. + indent: usize, + /// Buffered indentation to avoid writing trailing whitespace. + pending_indentation: usize, + /// The token most recently popped from the left boundary of the + /// ring-buffer for printing. + last_printed: Option, + + /// Target line width. + margin: isize, + /// If `Some(tab_width)` the printer will use tabs for indentation. + indent_config: Option, +} + +#[derive(Debug)] +pub struct BufEntry { + token: Token, + size: isize, +} + +impl Printer { + pub fn new(margin: usize, use_tab_with_size: Option) -> Self { + let margin = (margin as isize).clamp(MIN_SPACE, SIZE_INFINITY - 1); + Self { + out: String::new(), + space: margin, + buf: RingBuffer::new(), + left_total: 0, + right_total: 0, + scan_stack: VecDeque::new(), + print_stack: Vec::new(), + indent: 0, + pending_indentation: 0, + last_printed: None, + + margin, + indent_config: use_tab_with_size, + } + } + + /// Predicts available space on the current or next line based on pending breaks. + /// + /// This function provides a heuristic for estimating available space by checking if + /// an unconditional hard break is pending in the buffer. The printer's internal + /// `self.space` value may not accurately reflect pending formatting decisions. + /// + /// # Returns + /// + /// - The full `margin` if an unconditional hard break is pending, signaling that a new line + /// will be created. Callers should apply their own indentation logic as they have more + /// semantic context about the code structure. + /// - The current space left (`self.space`) if no hard break is found, which can be trusted when + /// no line breaks are imminent. + /// + /// # Trade-offs + /// + /// This heuristic may overestimate available space, + /// but provides a reliable signal for hard breaks while keeping the implementation + /// simple. + pub(crate) fn space_left(&self) -> usize { + // Scan backwards through the buffer for the last unconditional hard break. + for i in self.buf.index_range().rev() { + let token = &self.buf[i].token; + + if let Token::Break(break_token) = token + && break_token.blank_space as isize >= SIZE_INFINITY + && !break_token.never_break + { + return self.margin as usize; + } + + // Stop at first non-end token. + if !matches!(token, Token::End) { + break; + } + } + + // If no hard break pending, return actual space on current line or the full margin if space + // is negative. + (if self.space < 0 { self.margin } else { self.space }) as usize + } + + pub(crate) fn last_token(&self) -> Option<&Token> { + self.last_token_still_buffered().or(self.last_printed.as_ref()) + } + + pub(crate) fn last_token_still_buffered(&self) -> Option<&Token> { + if self.buf.is_empty() { + return None; + } + Some(&self.buf.last().token) + } + + /// Be very careful with this! + pub(crate) fn replace_last_token_still_buffered(&mut self, token: Token) { + self.buf.last_mut().token = token; + } + + /// WARNING: Be very careful with this! + /// + /// Searches backwards through the buffer to find and replace the last token + /// that satisfies a predicate. This is a specialized and sensitive operation. + /// + /// This function's traversal logic is specifically designed to handle cases + /// where formatting boxes have been closed (e.g., after a multi-line + /// comment). It will automatically skip over any trailing `Token::End` + /// tokens to find the substantive token before them. + /// + /// The search stops as soon as it encounters any token other than `End` + /// (i.e., a `String`, `Break`, or `Begin`). The provided predicate is then + /// called on that token. If the predicate returns `true`, the token is + /// replaced. + /// + /// This function will only ever evaluate the predicate on **one** token. + pub(crate) fn find_and_replace_last_token_still_buffered( + &mut self, + new_token: Token, + predicate: F, + ) where + F: FnOnce(&Token) -> bool, + { + for i in self.buf.index_range().rev() { + let token = &self.buf[i].token; + if matches!(token, Token::End) { + // It's safe to skip the end of a box. + continue; + } + + // Apply the predicate and return after the first non-end token. + if predicate(token) { + self.buf[i].token = new_token; + } + break; + } + } + + fn scan_eof(&mut self) { + if !self.scan_stack.is_empty() { + self.check_stack(0); + self.advance_left(); + } + } + + fn scan_begin(&mut self, token: BeginToken) { + if self.scan_stack.is_empty() { + self.left_total = 1; + self.right_total = 1; + self.buf.clear(); + } + let right = self.buf.push(BufEntry { token: Token::Begin(token), size: -self.right_total }); + self.scan_stack.push_back(right); + } + + fn scan_end(&mut self) { + if self.scan_stack.is_empty() { + self.print_end(); + } else { + if !self.buf.is_empty() + && let Token::Break(break_token) = self.buf.last().token + { + if self.buf.len() >= 2 + && let Token::Begin(_) = self.buf.second_last().token + { + self.buf.pop_last(); + self.buf.pop_last(); + self.scan_stack.pop_back(); + self.scan_stack.pop_back(); + self.right_total -= break_token.blank_space as isize; + return; + } + if break_token.if_nonempty { + self.buf.pop_last(); + self.scan_stack.pop_back(); + self.right_total -= break_token.blank_space as isize; + } + } + let right = self.buf.push(BufEntry { token: Token::End, size: -1 }); + self.scan_stack.push_back(right); + } + } + + pub(crate) fn scan_break(&mut self, token: BreakToken) { + if self.scan_stack.is_empty() { + self.left_total = 1; + self.right_total = 1; + self.buf.clear(); + } else { + self.check_stack(0); + } + let right = self.buf.push(BufEntry { token: Token::Break(token), size: -self.right_total }); + self.scan_stack.push_back(right); + self.right_total += token.blank_space as isize; + } + + fn scan_string(&mut self, string: Cow<'static, str>) { + if self.scan_stack.is_empty() { + self.print_string(&string); + } else { + let len = string.len() as isize; + self.buf.push(BufEntry { token: Token::String(string), size: len }); + self.right_total += len; + self.check_stream(); + } + } + + #[track_caller] + pub(crate) fn offset(&mut self, offset: isize) { + // Verbatim-printed source can leave a string (or a flushed buffer) where a break is + // normally expected; there is no break to adjust in that case. + if self.buf.is_empty() { + return; + } + match &mut self.buf.last_mut().token { + Token::Break(token) => token.offset += offset, + Token::Begin(_) | Token::String(_) => {} + Token::End => unreachable!(), + } + } + + pub(crate) fn ends_with(&self, ch: char) -> bool { + for i in self.buf.index_range().rev() { + if let Token::String(token) = &self.buf[i].token { + return token.ends_with(ch); + } + } + self.out.ends_with(ch) + } + + fn check_stream(&mut self) { + while self.right_total - self.left_total > self.space { + if *self.scan_stack.front().unwrap() == self.buf.index_range().start { + self.scan_stack.pop_front().unwrap(); + self.buf.first_mut().size = SIZE_INFINITY; + } + + self.advance_left(); + + if self.buf.is_empty() { + break; + } + } + } + + fn advance_left(&mut self) { + while self.buf.first().size >= 0 { + let left = self.buf.pop_first(); + + match &left.token { + Token::String(string) => { + self.left_total += left.size; + self.print_string(string); + } + Token::Break(token) => { + self.left_total += token.blank_space as isize; + self.print_break(*token, left.size); + } + Token::Begin(token) => self.print_begin(*token, left.size), + Token::End => self.print_end(), + } + + self.last_printed = Some(left.token); + + if self.buf.is_empty() { + break; + } + } + } + + fn check_stack(&mut self, mut depth: usize) { + while let Some(&index) = self.scan_stack.back() { + let entry = &mut self.buf[index]; + match entry.token { + Token::Begin(_) => { + if depth == 0 { + break; + } + self.scan_stack.pop_back().unwrap(); + entry.size += self.right_total; + depth -= 1; + } + Token::End => { + // paper says + not =, but that makes no sense. + self.scan_stack.pop_back().unwrap(); + entry.size = 1; + depth += 1; + } + _ => { + self.scan_stack.pop_back().unwrap(); + entry.size += self.right_total; + if depth == 0 { + break; + } + } + } + } + } + + fn get_top(&self) -> PrintFrame { + self.print_stack.last().copied().unwrap_or(PrintFrame::Broken(0, Breaks::Inconsistent)) + } + + fn print_begin(&mut self, token: BeginToken, size: isize) { + if DEBUG { + self.out.push(match token.breaks { + Breaks::Consistent => '«', + Breaks::Inconsistent => '‹', + }); + if DEBUG_INDENT && let IndentStyle::Block { offset } = token.indent { + self.out.extend(offset.to_string().chars().map(|ch| match ch { + '0'..='9' => ['₀', '₁', '₂', '₃', '₄', '₅', '₆', '₇', '₈', '₉'] + [(ch as u8 - b'0') as usize], + '-' => '₋', + _ => unreachable!(), + })); + } + } + + if size > self.space { + self.print_stack.push(PrintFrame::Broken(self.indent, token.breaks)); + self.indent = match token.indent { + IndentStyle::Block { offset } => { + usize::try_from(self.indent as isize + offset).unwrap() + } + IndentStyle::Visual if self.out.ends_with('\n') => self.pending_indentation, + IndentStyle::Visual => (self.margin - self.space) as usize, + }; + } else { + self.print_stack.push(PrintFrame::Fits(token.breaks)); + } + } + + fn print_end(&mut self) { + let breaks = match self.print_stack.pop().unwrap() { + PrintFrame::Broken(indent, breaks) => { + self.indent = indent; + breaks + } + PrintFrame::Fits(breaks) => breaks, + }; + if DEBUG { + self.out.push(match breaks { + Breaks::Consistent => '»', + Breaks::Inconsistent => '›', + }); + } + } + + fn print_break(&mut self, token: BreakToken, size: isize) { + let fits = token.never_break + || match self.get_top() { + PrintFrame::Fits(..) => true, + PrintFrame::Broken(.., Breaks::Consistent) => false, + PrintFrame::Broken(.., Breaks::Inconsistent) => size <= self.space, + }; + if fits { + self.pending_indentation += token.blank_space; + self.space -= token.blank_space as isize; + if DEBUG { + self.out.push('·'); + } + } else { + if let Some(pre_break) = token.pre_break { + self.print_indent(); + self.out.push_str(pre_break); + } + if DEBUG { + self.out.push('·'); + } + self.out.push('\n'); + let indent = self.indent as isize + token.offset; + self.pending_indentation = usize::try_from(indent).expect("negative indentation"); + self.space = cmp::max(self.margin - indent, MIN_SPACE); + if let Some(post_break) = token.post_break { + self.print_string(post_break); + } + } + } + + fn print_string(&mut self, string: &str) { + self.print_indent(); + self.out.push_str(string); + self.space -= string.len() as isize; + } + + fn print_indent(&mut self) { + self.out.reserve(self.pending_indentation); + if let Some(tab_width) = self.indent_config { + let num_tabs = self.pending_indentation / tab_width; + self.out.extend(iter::repeat_n('\t', num_tabs)); + + let remainder = self.pending_indentation % tab_width; + self.out.extend(iter::repeat_n(' ', remainder)); + } else { + self.out.extend(iter::repeat_n(' ', self.pending_indentation)); + } + self.pending_indentation = 0; + } +} diff --git a/crates/fmt/src/pp/ring.rs b/crates/fmt/src/pp/ring.rs new file mode 100644 index 0000000000000..198f3c0a1baee --- /dev/null +++ b/crates/fmt/src/pp/ring.rs @@ -0,0 +1,95 @@ +use std::{ + collections::VecDeque, + ops::{Index, IndexMut, Range}, +}; + +#[derive(Debug)] +pub(crate) struct RingBuffer { + data: VecDeque, + // Abstract index of data[0] in the infinitely sized queue. + offset: usize, +} + +impl RingBuffer { + pub(crate) const fn new() -> Self { + Self { data: VecDeque::new(), offset: 0 } + } + + pub(crate) fn is_empty(&self) -> bool { + self.data.is_empty() + } + + pub(crate) fn len(&self) -> usize { + self.data.len() + } + + pub(crate) fn push(&mut self, value: T) -> usize { + let index = self.offset + self.data.len(); + self.data.push_back(value); + index + } + + pub(crate) fn clear(&mut self) { + self.data.clear(); + } + + pub(crate) fn index_range(&self) -> Range { + self.offset..self.offset + self.data.len() + } + + #[inline] + #[track_caller] + pub(crate) fn first(&self) -> &T { + &self.data[0] + } + + #[inline] + #[track_caller] + pub(crate) fn first_mut(&mut self) -> &mut T { + &mut self.data[0] + } + + #[inline] + #[track_caller] + pub(crate) fn pop_first(&mut self) -> T { + self.offset += 1; + self.data.pop_front().unwrap() + } + + #[inline] + #[track_caller] + pub(crate) fn last(&self) -> &T { + self.data.back().unwrap() + } + + #[inline] + #[track_caller] + pub(crate) fn last_mut(&mut self) -> &mut T { + self.data.back_mut().unwrap() + } + + #[inline] + #[track_caller] + pub(crate) fn second_last(&self) -> &T { + &self.data[self.data.len() - 2] + } + + #[inline] + #[track_caller] + pub(crate) fn pop_last(&mut self) { + self.data.pop_back().unwrap(); + } +} + +impl Index for RingBuffer { + type Output = T; + fn index(&self, index: usize) -> &Self::Output { + &self.data[index.checked_sub(self.offset).unwrap()] + } +} + +impl IndexMut for RingBuffer { + fn index_mut(&mut self, index: usize) -> &mut Self::Output { + &mut self.data[index.checked_sub(self.offset).unwrap()] + } +} diff --git a/crates/fmt/src/solang_ext/ast_eq.rs b/crates/fmt/src/solang_ext/ast_eq.rs deleted file mode 100644 index e19d3469ce829..0000000000000 --- a/crates/fmt/src/solang_ext/ast_eq.rs +++ /dev/null @@ -1,708 +0,0 @@ -use alloy_primitives::{Address, I256, U256}; -use solang_parser::pt::*; -use std::str::FromStr; - -/// Helper to convert a string number into a comparable one -fn to_num(string: &str) -> I256 { - if string.is_empty() { - return I256::ZERO; - } - string.replace('_', "").trim().parse().unwrap() -} - -/// Helper to convert the fractional part of a number into a comparable one. -/// This will reverse the number so that 0's can be ignored -fn to_num_reversed(string: &str) -> U256 { - if string.is_empty() { - return U256::from(0); - } - string.replace('_', "").trim().chars().rev().collect::().parse().unwrap() -} - -/// Helper to filter [ParameterList] to omit empty -/// parameters -fn filter_params(list: &ParameterList) -> ParameterList { - list.iter().filter(|(_, param)| param.is_some()).cloned().collect::>() -} - -/// Check if two ParseTrees are equal ignoring location information or ordering if ordering does -/// not matter -pub trait AstEq { - fn ast_eq(&self, other: &Self) -> bool; -} - -impl AstEq for Loc { - fn ast_eq(&self, _other: &Self) -> bool { - true - } -} - -impl AstEq for IdentifierPath { - fn ast_eq(&self, other: &Self) -> bool { - self.identifiers.ast_eq(&other.identifiers) - } -} - -impl AstEq for SourceUnit { - fn ast_eq(&self, other: &Self) -> bool { - self.0.ast_eq(&other.0) - } -} - -impl AstEq for VariableDefinition { - fn ast_eq(&self, other: &Self) -> bool { - let sorted_attrs = |def: &Self| { - let mut attrs = def.attrs.clone(); - attrs.sort(); - attrs - }; - self.ty.ast_eq(&other.ty) - && self.name.ast_eq(&other.name) - && self.initializer.ast_eq(&other.initializer) - && sorted_attrs(self).ast_eq(&sorted_attrs(other)) - } -} - -impl AstEq for FunctionDefinition { - fn ast_eq(&self, other: &Self) -> bool { - // attributes - let sorted_attrs = |def: &Self| { - let mut attrs = def.attributes.clone(); - attrs.sort(); - attrs - }; - - // params - let left_params = filter_params(&self.params); - let right_params = filter_params(&other.params); - let left_returns = filter_params(&self.returns); - let right_returns = filter_params(&other.returns); - - self.ty.ast_eq(&other.ty) - && self.name.ast_eq(&other.name) - && left_params.ast_eq(&right_params) - && self.return_not_returns.ast_eq(&other.return_not_returns) - && left_returns.ast_eq(&right_returns) - && self.body.ast_eq(&other.body) - && sorted_attrs(self).ast_eq(&sorted_attrs(other)) - } -} - -impl AstEq for Base { - fn ast_eq(&self, other: &Self) -> bool { - self.name.ast_eq(&other.name) - && self.args.clone().unwrap_or_default().ast_eq(&other.args.clone().unwrap_or_default()) - } -} - -impl AstEq for Vec -where - T: AstEq, -{ - fn ast_eq(&self, other: &Self) -> bool { - if self.len() != other.len() { - false - } else { - self.iter().zip(other.iter()).all(|(left, right)| left.ast_eq(right)) - } - } -} - -impl AstEq for Option -where - T: AstEq, -{ - fn ast_eq(&self, other: &Self) -> bool { - match (self, other) { - (Some(left), Some(right)) => left.ast_eq(right), - (None, None) => true, - _ => false, - } - } -} - -impl AstEq for Box -where - T: AstEq, -{ - fn ast_eq(&self, other: &Self) -> bool { - T::ast_eq(self, other) - } -} - -impl AstEq for () { - fn ast_eq(&self, _other: &Self) -> bool { - true - } -} - -impl AstEq for &T -where - T: AstEq, -{ - fn ast_eq(&self, other: &Self) -> bool { - T::ast_eq(self, other) - } -} - -impl AstEq for String { - fn ast_eq(&self, other: &Self) -> bool { - match (Address::from_str(self), Address::from_str(other)) { - (Ok(left), Ok(right)) => left == right, - _ => self == other, - } - } -} - -macro_rules! ast_eq_field { - (#[ast_eq_use($convert_func:ident)] $field:ident) => { - $convert_func($field) - }; - ($field:ident) => { - $field - }; -} - -macro_rules! gen_ast_eq_enum { - ($self:expr, $other:expr, $name:ident { - $($unit_variant:ident),* $(,)? - _ - $($tuple_variant:ident ( $($(#[ast_eq_use($tuple_convert_func:ident)])? $tuple_field:ident),* $(,)? )),* $(,)? - _ - $($struct_variant:ident { $($(#[ast_eq_use($struct_convert_func:ident)])? $struct_field:ident),* $(,)? }),* $(,)? - }) => { - match $self { - $($name::$unit_variant => gen_ast_eq_enum!($other, $name, $unit_variant),)* - $($name::$tuple_variant($($tuple_field),*) => - gen_ast_eq_enum!($other, $name, $tuple_variant ($($(#[ast_eq_use($tuple_convert_func)])? $tuple_field),*)),)* - $($name::$struct_variant { $($struct_field),* } => - gen_ast_eq_enum!($other, $name, $struct_variant {$($(#[ast_eq_use($struct_convert_func)])? $struct_field),*}),)* - } - }; - ($other:expr, $name:ident, $unit_variant:ident) => { - { - matches!($other, $name::$unit_variant) - } - }; - ($other:expr, $name:ident, $tuple_variant:ident ( $($(#[ast_eq_use($tuple_convert_func:ident)])? $tuple_field:ident),* $(,)? ) ) => { - { - let left = ($(ast_eq_field!($(#[ast_eq_use($tuple_convert_func)])? $tuple_field)),*); - if let $name::$tuple_variant($($tuple_field),*) = $other { - let right = ($(ast_eq_field!($(#[ast_eq_use($tuple_convert_func)])? $tuple_field)),*); - left.ast_eq(&right) - } else { - false - } - } - }; - ($other:expr, $name:ident, $struct_variant:ident { $($(#[ast_eq_use($struct_convert_func:ident)])? $struct_field:ident),* $(,)? } ) => { - { - let left = ($(ast_eq_field!($(#[ast_eq_use($struct_convert_func)])? $struct_field)),*); - if let $name::$struct_variant { $($struct_field),* } = $other { - let right = ($(ast_eq_field!($(#[ast_eq_use($struct_convert_func)])? $struct_field)),*); - left.ast_eq(&right) - } else { - false - } - } - }; -} - -macro_rules! wrap_in_box { - ($stmt:expr, $loc:expr) => { - if !matches!(**$stmt, Statement::Block { .. }) { - Box::new(Statement::Block { - loc: $loc, - unchecked: false, - statements: vec![*$stmt.clone()], - }) - } else { - $stmt.clone() - } - }; -} - -impl AstEq for Statement { - fn ast_eq(&self, other: &Self) -> bool { - match self { - Self::If(loc, expr, stmt1, stmt2) => { - #[expect(clippy::borrowed_box)] - let wrap_if = |stmt1: &Box, stmt2: &Option>| { - ( - wrap_in_box!(stmt1, *loc), - stmt2.as_ref().map(|stmt2| { - if matches!(**stmt2, Self::If(..)) { - stmt2.clone() - } else { - wrap_in_box!(stmt2, *loc) - } - }), - ) - }; - let (stmt1, stmt2) = wrap_if(stmt1, stmt2); - let left = (loc, expr, &stmt1, &stmt2); - if let Self::If(loc, expr, stmt1, stmt2) = other { - let (stmt1, stmt2) = wrap_if(stmt1, stmt2); - let right = (loc, expr, &stmt1, &stmt2); - left.ast_eq(&right) - } else { - false - } - } - Self::While(loc, expr, stmt1) => { - let stmt1 = wrap_in_box!(stmt1, *loc); - let left = (loc, expr, &stmt1); - if let Self::While(loc, expr, stmt1) = other { - let stmt1 = wrap_in_box!(stmt1, *loc); - let right = (loc, expr, &stmt1); - left.ast_eq(&right) - } else { - false - } - } - Self::DoWhile(loc, stmt1, expr) => { - let stmt1 = wrap_in_box!(stmt1, *loc); - let left = (loc, &stmt1, expr); - if let Self::DoWhile(loc, stmt1, expr) = other { - let stmt1 = wrap_in_box!(stmt1, *loc); - let right = (loc, &stmt1, expr); - left.ast_eq(&right) - } else { - false - } - } - Self::For(loc, stmt1, expr, stmt2, stmt3) => { - let stmt3 = stmt3.as_ref().map(|stmt3| wrap_in_box!(stmt3, *loc)); - let left = (loc, stmt1, expr, stmt2, &stmt3); - if let Self::For(loc, stmt1, expr, stmt2, stmt3) = other { - let stmt3 = stmt3.as_ref().map(|stmt3| wrap_in_box!(stmt3, *loc)); - let right = (loc, stmt1, expr, stmt2, &stmt3); - left.ast_eq(&right) - } else { - false - } - } - Self::Try(loc, expr, returns, catch) => { - let left_returns = - returns.as_ref().map(|(params, stmt)| (filter_params(params), stmt)); - let left = (loc, expr, left_returns, catch); - if let Self::Try(loc, expr, returns, catch) = other { - let right_returns = - returns.as_ref().map(|(params, stmt)| (filter_params(params), stmt)); - let right = (loc, expr, right_returns, catch); - left.ast_eq(&right) - } else { - false - } - } - _ => gen_ast_eq_enum!(self, other, Statement { - _ - Args(loc, args), - Expression(loc, expr), - VariableDefinition(loc, decl, expr), - Continue(loc, ), - Break(loc, ), - Return(loc, expr), - Revert(loc, expr, expr2), - RevertNamedArgs(loc, expr, args), - Emit(loc, expr), - // provide overridden variants regardless - If(loc, expr, stmt1, stmt2), - While(loc, expr, stmt1), - DoWhile(loc, stmt1, expr), - For(loc, stmt1, expr, stmt2, stmt3), - Try(loc, expr, params, clause), - Error(loc) - _ - Block { - loc, - unchecked, - statements, - }, - Assembly { - loc, - dialect, - block, - flags, - }, - }), - } - } -} - -macro_rules! derive_ast_eq { - ($name:ident) => { - impl AstEq for $name { - fn ast_eq(&self, other: &Self) -> bool { - self == other - } - } - }; - (($($index:tt $gen:tt),*)) => { - impl < $( $gen ),* > AstEq for ($($gen,)*) where $($gen: AstEq),* { - fn ast_eq(&self, other: &Self) -> bool { - $( - if !self.$index.ast_eq(&other.$index) { - return false - } - )* - true - } - } - }; - (struct $name:ident { $($field:ident),* $(,)? }) => { - impl AstEq for $name { - fn ast_eq(&self, other: &Self) -> bool { - let $name { $($field),* } = self; - let left = ($($field),*); - let $name { $($field),* } = other; - let right = ($($field),*); - left.ast_eq(&right) - } - } - }; - (enum $name:ident { - $($unit_variant:ident),* $(,)? - _ - $($tuple_variant:ident ( $($(#[ast_eq_use($tuple_convert_func:ident)])? $tuple_field:ident),* $(,)? )),* $(,)? - _ - $($struct_variant:ident { $($(#[ast_eq_use($struct_convert_func:ident)])? $struct_field:ident),* $(,)? }),* $(,)? - }) => { - impl AstEq for $name { - fn ast_eq(&self, other: &Self) -> bool { - gen_ast_eq_enum!(self, other, $name { - $($unit_variant),* - _ - $($tuple_variant ( $($(#[ast_eq_use($tuple_convert_func)])? $tuple_field),* )),* - _ - $($struct_variant { $($(#[ast_eq_use($struct_convert_func)])? $struct_field),* }),* - }) - } - } - } -} - -derive_ast_eq! { (0 A) } -derive_ast_eq! { (0 A, 1 B) } -derive_ast_eq! { (0 A, 1 B, 2 C) } -derive_ast_eq! { (0 A, 1 B, 2 C, 3 D) } -derive_ast_eq! { (0 A, 1 B, 2 C, 3 D, 4 E) } -derive_ast_eq! { (0 A, 1 B, 2 C, 3 D, 4 E, 5 F) } -derive_ast_eq! { (0 A, 1 B, 2 C, 3 D, 4 E, 5 F, 6 G) } -derive_ast_eq! { bool } -derive_ast_eq! { u8 } -derive_ast_eq! { u16 } -derive_ast_eq! { I256 } -derive_ast_eq! { U256 } -derive_ast_eq! { struct Identifier { loc, name } } -derive_ast_eq! { struct HexLiteral { loc, hex } } -derive_ast_eq! { struct StringLiteral { loc, unicode, string } } -derive_ast_eq! { struct Parameter { loc, annotation, ty, storage, name } } -derive_ast_eq! { struct NamedArgument { loc, name, expr } } -derive_ast_eq! { struct YulBlock { loc, statements } } -derive_ast_eq! { struct YulFunctionCall { loc, id, arguments } } -derive_ast_eq! { struct YulFunctionDefinition { loc, id, params, returns, body } } -derive_ast_eq! { struct YulSwitch { loc, condition, cases, default } } -derive_ast_eq! { struct YulFor { - loc, - init_block, - condition, - post_block, - execution_block, -}} -derive_ast_eq! { struct YulTypedIdentifier { loc, id, ty } } -derive_ast_eq! { struct VariableDeclaration { loc, ty, storage, name } } -derive_ast_eq! { struct Using { loc, list, ty, global } } -derive_ast_eq! { struct UsingFunction { loc, path, oper } } -derive_ast_eq! { struct TypeDefinition { loc, name, ty } } -derive_ast_eq! { struct ContractDefinition { loc, ty, name, base, layout, parts } } -derive_ast_eq! { struct EventParameter { loc, ty, indexed, name } } -derive_ast_eq! { struct ErrorParameter { loc, ty, name } } -derive_ast_eq! { struct EventDefinition { loc, name, fields, anonymous } } -derive_ast_eq! { struct ErrorDefinition { loc, keyword, name, fields } } -derive_ast_eq! { struct StructDefinition { loc, name, fields } } -derive_ast_eq! { struct EnumDefinition { loc, name, values } } -derive_ast_eq! { struct Annotation { loc, id, value } } -derive_ast_eq! { enum PragmaDirective { - _ - Identifier(loc, id1, id2), - StringLiteral(loc, id, lit), - Version(loc, id, version), - _ -}} -derive_ast_eq! { enum UsingList { - Error, - _ - Library(expr), - Functions(exprs), - _ -}} -derive_ast_eq! { enum UserDefinedOperator { - BitwiseAnd, - BitwiseNot, - Negate, - BitwiseOr, - BitwiseXor, - Add, - Divide, - Modulo, - Multiply, - Subtract, - Equal, - More, - MoreEqual, - Less, - LessEqual, - NotEqual, - _ - _ -}} -derive_ast_eq! { enum Visibility { - _ - External(loc), - Public(loc), - Internal(loc), - Private(loc), - _ -}} -derive_ast_eq! { enum Mutability { - _ - Pure(loc), - View(loc), - Constant(loc), - Payable(loc), - _ -}} -derive_ast_eq! { enum FunctionAttribute { - _ - Mutability(muta), - Visibility(visi), - Virtual(loc), - Immutable(loc), - Override(loc, idents), - BaseOrModifier(loc, base), - Error(loc), - _ -}} -derive_ast_eq! { enum StorageLocation { - _ - Memory(loc), - Storage(loc), - Calldata(loc), - Transient(loc), - _ -}} -derive_ast_eq! { enum Type { - Address, - AddressPayable, - Payable, - Bool, - Rational, - DynamicBytes, - String, - _ - Int(int), - Uint(int), - Bytes(int), - _ - Mapping{ loc, key, key_name, value, value_name }, - Function { params, attributes, returns }, -}} -derive_ast_eq! { enum Expression { - _ - PostIncrement(loc, expr1), - PostDecrement(loc, expr1), - New(loc, expr1), - ArraySubscript(loc, expr1, expr2), - ArraySlice( - loc, - expr1, - expr2, - expr3, - ), - MemberAccess(loc, expr1, ident1), - FunctionCall(loc, expr1, exprs1), - FunctionCallBlock(loc, expr1, stmt), - NamedFunctionCall(loc, expr1, args), - Not(loc, expr1), - BitwiseNot(loc, expr1), - Delete(loc, expr1), - PreIncrement(loc, expr1), - PreDecrement(loc, expr1), - UnaryPlus(loc, expr1), - Negate(loc, expr1), - Power(loc, expr1, expr2), - Multiply(loc, expr1, expr2), - Divide(loc, expr1, expr2), - Modulo(loc, expr1, expr2), - Add(loc, expr1, expr2), - Subtract(loc, expr1, expr2), - ShiftLeft(loc, expr1, expr2), - ShiftRight(loc, expr1, expr2), - BitwiseAnd(loc, expr1, expr2), - BitwiseXor(loc, expr1, expr2), - BitwiseOr(loc, expr1, expr2), - Less(loc, expr1, expr2), - More(loc, expr1, expr2), - LessEqual(loc, expr1, expr2), - MoreEqual(loc, expr1, expr2), - Equal(loc, expr1, expr2), - NotEqual(loc, expr1, expr2), - And(loc, expr1, expr2), - Or(loc, expr1, expr2), - ConditionalOperator(loc, expr1, expr2, expr3), - Assign(loc, expr1, expr2), - AssignOr(loc, expr1, expr2), - AssignAnd(loc, expr1, expr2), - AssignXor(loc, expr1, expr2), - AssignShiftLeft(loc, expr1, expr2), - AssignShiftRight(loc, expr1, expr2), - AssignAdd(loc, expr1, expr2), - AssignSubtract(loc, expr1, expr2), - AssignMultiply(loc, expr1, expr2), - AssignDivide(loc, expr1, expr2), - AssignModulo(loc, expr1, expr2), - BoolLiteral(loc, bool1), - NumberLiteral(loc, #[ast_eq_use(to_num)] str1, #[ast_eq_use(to_num)] str2, unit), - RationalNumberLiteral( - loc, - #[ast_eq_use(to_num)] str1, - #[ast_eq_use(to_num_reversed)] str2, - #[ast_eq_use(to_num)] str3, - unit - ), - HexNumberLiteral(loc, str1, unit), - StringLiteral(strs1), - Type(loc, ty1), - HexLiteral(hexs1), - AddressLiteral(loc, str1), - Variable(ident1), - List(loc, params1), - ArrayLiteral(loc, exprs1), - Parenthesis(loc, expr) - _ -}} -derive_ast_eq! { enum CatchClause { - _ - Simple(param, ident, stmt), - Named(loc, ident, param, stmt), - _ -}} -derive_ast_eq! { enum YulStatement { - _ - Assign(loc, exprs, expr), - VariableDeclaration(loc, idents, expr), - If(loc, expr, block), - For(yul_for), - Switch(switch), - Leave(loc), - Break(loc), - Continue(loc), - Block(block), - FunctionDefinition(def), - FunctionCall(func), - Error(loc), - _ -}} -derive_ast_eq! { enum YulExpression { - _ - BoolLiteral(loc, boo, ident), - NumberLiteral(loc, string1, string2, ident), - HexNumberLiteral(loc, string, ident), - HexStringLiteral(hex, ident), - StringLiteral(string, ident), - Variable(ident), - FunctionCall(func), - SuffixAccess(loc, expr, ident), - _ -}} -derive_ast_eq! { enum YulSwitchOptions { - _ - Case(loc, expr, block), - Default(loc, block), - _ -}} -derive_ast_eq! { enum SourceUnitPart { - _ - ContractDefinition(def), - PragmaDirective(pragma), - ImportDirective(import), - EnumDefinition(def), - StructDefinition(def), - EventDefinition(def), - ErrorDefinition(def), - FunctionDefinition(def), - VariableDefinition(def), - TypeDefinition(def), - Using(using), - StraySemicolon(loc), - Annotation(annotation), - _ -}} -derive_ast_eq! { enum ImportPath { - _ - Filename(lit), - Path(path), - _ -}} -derive_ast_eq! { enum Import { - _ - Plain(string, loc), - GlobalSymbol(string, ident, loc), - Rename(string, idents, loc), - _ -}} -derive_ast_eq! { enum FunctionTy { - Constructor, - Function, - Fallback, - Receive, - Modifier, - _ - _ -}} -derive_ast_eq! { enum ContractPart { - _ - StructDefinition(def), - EventDefinition(def), - EnumDefinition(def), - ErrorDefinition(def), - VariableDefinition(def), - FunctionDefinition(def), - TypeDefinition(def), - StraySemicolon(loc), - Using(using), - Annotation(annotation), - _ -}} -derive_ast_eq! { enum ContractTy { - _ - Abstract(loc), - Contract(loc), - Interface(loc), - Library(loc), - _ -}} -derive_ast_eq! { enum VariableAttribute { - _ - Visibility(visi), - Constant(loc), - Immutable(loc), - Override(loc, idents), - StorageType(st), - StorageLocation(st), - _ -}} - -// Who cares -impl AstEq for StorageType { - fn ast_eq(&self, _other: &Self) -> bool { - true - } -} - -impl AstEq for VersionComparator { - fn ast_eq(&self, _other: &Self) -> bool { - true - } -} diff --git a/crates/fmt/src/solang_ext/loc.rs b/crates/fmt/src/solang_ext/loc.rs deleted file mode 100644 index cec21d7714385..0000000000000 --- a/crates/fmt/src/solang_ext/loc.rs +++ /dev/null @@ -1,168 +0,0 @@ -use solang_parser::pt; -use std::{borrow::Cow, rc::Rc, sync::Arc}; - -/// Returns the code location. -/// -/// Patched version of [`pt::CodeLocation`]: includes the block of a [`pt::FunctionDefinition`] in -/// its `loc`. -pub trait CodeLocationExt { - /// Returns the code location of `self`. - fn loc(&self) -> pt::Loc; -} - -impl CodeLocationExt for &T { - fn loc(&self) -> pt::Loc { - (**self).loc() - } -} - -impl CodeLocationExt for &mut T { - fn loc(&self) -> pt::Loc { - (**self).loc() - } -} - -impl CodeLocationExt for Cow<'_, T> { - fn loc(&self) -> pt::Loc { - (**self).loc() - } -} - -impl CodeLocationExt for Box { - fn loc(&self) -> pt::Loc { - (**self).loc() - } -} - -impl CodeLocationExt for Rc { - fn loc(&self) -> pt::Loc { - (**self).loc() - } -} - -impl CodeLocationExt for Arc { - fn loc(&self) -> pt::Loc { - (**self).loc() - } -} - -// FunctionDefinition patch -impl CodeLocationExt for pt::FunctionDefinition { - #[inline] - #[track_caller] - fn loc(&self) -> pt::Loc { - let mut loc = self.loc; - if let Some(ref body) = self.body { - loc.use_end_from(&pt::CodeLocation::loc(body)); - } - loc - } -} - -impl CodeLocationExt for pt::ContractPart { - #[inline] - #[track_caller] - fn loc(&self) -> pt::Loc { - match self { - Self::FunctionDefinition(f) => f.loc(), - _ => pt::CodeLocation::loc(self), - } - } -} - -impl CodeLocationExt for pt::SourceUnitPart { - #[inline] - #[track_caller] - fn loc(&self) -> pt::Loc { - match self { - Self::FunctionDefinition(f) => f.loc(), - _ => pt::CodeLocation::loc(self), - } - } -} - -impl CodeLocationExt for pt::ImportPath { - fn loc(&self) -> pt::Loc { - match self { - Self::Filename(s) => s.loc(), - Self::Path(i) => i.loc(), - } - } -} - -impl CodeLocationExt for pt::VersionComparator { - fn loc(&self) -> pt::Loc { - match self { - Self::Plain { loc, .. } - | Self::Operator { loc, .. } - | Self::Or { loc, .. } - | Self::Range { loc, .. } => *loc, - } - } -} - -macro_rules! impl_delegate { - ($($t:ty),+ $(,)?) => {$( - impl CodeLocationExt for $t { - #[inline] - #[track_caller] - fn loc(&self) -> pt::Loc { - pt::CodeLocation::loc(self) - } - } - )+}; -} - -impl_delegate! { - pt::Annotation, - pt::Base, - pt::ContractDefinition, - pt::EnumDefinition, - pt::ErrorDefinition, - pt::ErrorParameter, - pt::EventDefinition, - pt::EventParameter, - pt::PragmaDirective, - // pt::FunctionDefinition, - pt::HexLiteral, - pt::Identifier, - pt::IdentifierPath, - pt::NamedArgument, - pt::Parameter, - // pt::SourceUnit, - pt::StringLiteral, - pt::StructDefinition, - pt::TypeDefinition, - pt::Using, - pt::UsingFunction, - pt::VariableDeclaration, - pt::VariableDefinition, - pt::YulBlock, - pt::YulFor, - pt::YulFunctionCall, - pt::YulFunctionDefinition, - pt::YulSwitch, - pt::YulTypedIdentifier, - - pt::CatchClause, - pt::Comment, - // pt::ContractPart, - pt::ContractTy, - pt::Expression, - pt::FunctionAttribute, - // pt::FunctionTy, - pt::Import, - pt::Loc, - pt::Mutability, - // pt::SourceUnitPart, - pt::Statement, - pt::StorageLocation, - // pt::Type, - // pt::UserDefinedOperator, - pt::UsingList, - pt::VariableAttribute, - // pt::Visibility, - pt::YulExpression, - pt::YulStatement, - pt::YulSwitchOptions, -} diff --git a/crates/fmt/src/solang_ext/mod.rs b/crates/fmt/src/solang_ext/mod.rs deleted file mode 100644 index 3aa7c526c5ba1..0000000000000 --- a/crates/fmt/src/solang_ext/mod.rs +++ /dev/null @@ -1,29 +0,0 @@ -//! Extension traits and modules to the [`solang_parser`] crate. - -/// Same as [`solang_parser::pt`], but with the patched `CodeLocation`. -pub mod pt { - #[doc(no_inline)] - pub use super::loc::CodeLocationExt as CodeLocation; - - #[doc(no_inline)] - pub use solang_parser::pt::{ - Annotation, Base, CatchClause, Comment, ContractDefinition, ContractPart, ContractTy, - EnumDefinition, ErrorDefinition, ErrorParameter, EventDefinition, EventParameter, - Expression, FunctionAttribute, FunctionDefinition, FunctionTy, HexLiteral, Identifier, - IdentifierPath, Import, ImportPath, Loc, Mutability, NamedArgument, OptionalCodeLocation, - Parameter, ParameterList, PragmaDirective, SourceUnit, SourceUnitPart, Statement, - StorageLocation, StringLiteral, StructDefinition, Type, TypeDefinition, - UserDefinedOperator, Using, UsingFunction, UsingList, VariableAttribute, - VariableDeclaration, VariableDefinition, Visibility, YulBlock, YulExpression, YulFor, - YulFunctionCall, YulFunctionDefinition, YulStatement, YulSwitch, YulSwitchOptions, - YulTypedIdentifier, - }; -} - -mod ast_eq; -mod loc; -mod safe_unwrap; - -pub use ast_eq::AstEq; -pub use loc::CodeLocationExt; -pub use safe_unwrap::SafeUnwrap; diff --git a/crates/fmt/src/solang_ext/safe_unwrap.rs b/crates/fmt/src/solang_ext/safe_unwrap.rs deleted file mode 100644 index fe2810ad9705a..0000000000000 --- a/crates/fmt/src/solang_ext/safe_unwrap.rs +++ /dev/null @@ -1,52 +0,0 @@ -use solang_parser::pt; - -/// Trait implemented to unwrap optional parse tree items initially introduced in -/// [hyperledger/solang#1068]. -/// -/// Note that the methods of this trait should only be used on parse tree items' fields, like -/// [pt::VariableDefinition] or [pt::EventDefinition], where the `name` field is `None` only when an -/// error occurred during parsing. -/// -/// [hyperledger/solang#1068]: https://github.com/hyperledger/solang/pull/1068 -pub trait SafeUnwrap { - /// See [SafeUnwrap]. - fn safe_unwrap(&self) -> &T; - - /// See [SafeUnwrap]. - fn safe_unwrap_mut(&mut self) -> &mut T; -} - -#[inline(never)] -#[cold] -#[track_caller] -fn invalid() -> ! { - panic!("invalid parse tree") -} - -macro_rules! impl_ { - ($($t:ty),+ $(,)?) => { - $( - impl SafeUnwrap<$t> for Option<$t> { - #[inline] - #[track_caller] - fn safe_unwrap(&self) -> &$t { - match *self { - Some(ref x) => x, - None => invalid(), - } - } - - #[inline] - #[track_caller] - fn safe_unwrap_mut(&mut self) -> &mut $t { - match *self { - Some(ref mut x) => x, - None => invalid(), - } - } - } - )+ - }; -} - -impl_!(pt::Identifier, pt::StringLiteral); diff --git a/crates/fmt/src/state/common.rs b/crates/fmt/src/state/common.rs new file mode 100644 index 0000000000000..54ca621ee10fa --- /dev/null +++ b/crates/fmt/src/state/common.rs @@ -0,0 +1,967 @@ +use super::{CommentConfig, Separator, State}; +use crate::pp::{BreakToken, Printer, SIZE_INFINITY}; +use foundry_common::iter::IterDelimited; +use foundry_config::fmt as config; +use itertools::{Either, Itertools}; +use solar::parse::{ + Cursor, + ast::{self, Span}, + interface::BytePos, +}; +use std::{borrow::Cow, fmt::Debug}; + +pub(crate) trait LitExt<'ast> { + fn is_str_concatenation(&self) -> bool; +} + +impl<'ast> LitExt<'ast> for ast::Lit<'ast> { + /// Checks if a the input literal is a string literal with multiple parts. + fn is_str_concatenation(&self) -> bool { + if let ast::LitKind::Str(_, _, parts) = &self.kind { !parts.is_empty() } else { false } + } +} + +/// Language-specific pretty printing. Common for both: Solidity + Yul. +impl<'ast> State<'_, 'ast> { + pub(super) fn print_lit_inner(&mut self, lit: &'ast ast::Lit<'ast>, is_yul: bool) { + let ast::Lit { span, symbol, ref kind } = *lit; + if self.handle_span(span, false) { + return; + } + + match *kind { + ast::LitKind::Str(kind, ..) => { + self.s.ibox(0); + let mut literals = lit.literals().peekable(); + while let Some((span, symbol)) = literals.next() { + if !self.handle_span(span, false) { + let quote_pos = span.lo() + kind.prefix().len() as u32; + self.print_str_lit(kind, quote_pos, symbol.as_str()); + } + if let Some((next_span, _)) = literals.peek() { + if !self.print_trailing_comment(span.hi(), Some(next_span.lo())) { + self.space_if_not_bol(); + } + } else { + self.neverbreak(); + } + } + self.end(); + } + ast::LitKind::Number(_) | ast::LitKind::Rational(_) => { + self.print_num_literal(symbol.as_str(), is_yul); + } + ast::LitKind::Address(value) => self.word(value.to_string()), + ast::LitKind::Bool(value) => self.word(if value { "true" } else { "false" }), + ast::LitKind::Err(_) => self.word(symbol.to_string()), + } + } + + fn print_num_literal(&mut self, source: &str, is_yul: bool) { + fn strip_underscores_if(b: bool, s: &str) -> Cow<'_, str> { + if b && s.contains('_') { Cow::Owned(s.replace('_', "")) } else { Cow::Borrowed(s) } + } + + fn add_underscores( + out: &mut String, + config: config::NumberUnderscore, + string: &str, + is_dec: bool, + is_yul: bool, + reversed: bool, + ) { + // The underscore thousand separator is only valid in Solidity decimal numbers. + // It is not supported by hex numbers, nor Yul literals. + // https://github.com/foundry-rs/foundry/issues/12111 + if !config.is_thousands() || !is_dec || is_yul || string.len() < 5 { + out.push_str(string); + return; + } + + let chunks = if reversed { + Either::Left(string.as_bytes().chunks(3)) + } else { + Either::Right(string.as_bytes().rchunks(3).rev()) + } + .map(|chunk| std::str::from_utf8(chunk).unwrap()); + for chunk in Itertools::intersperse(chunks, "_") { + out.push_str(chunk); + } + } + + debug_assert!(source.is_ascii(), "{source:?}"); + + let config = self.config.number_underscore; + let is_dec = !["0x", "0b", "0o"].iter().any(|prefix| source.starts_with(prefix)); + + let (val, exp) = if is_dec { + source.split_once(['e', 'E']).unwrap_or((source, "")) + } else { + (source, "") + }; + let (val, fract) = val.split_once('.').unwrap_or((val, "")); + + let strip_underscores = !config.is_preserve() || is_yul; + let mut val = &strip_underscores_if(strip_underscores, val)[..]; + let mut exp = &strip_underscores_if(strip_underscores, exp)[..]; + let mut fract = &strip_underscores_if(strip_underscores, fract)[..]; + + // strip any padded 0's + let mut exp_sign = ""; + if is_dec { + val = val.trim_start_matches('0'); + fract = fract.trim_end_matches('0'); + (exp_sign, exp) = + if let Some(exp) = exp.strip_prefix('-') { ("-", exp) } else { ("", exp) }; + exp = exp.trim_start_matches('0'); + } + + let mut out = String::with_capacity(source.len() * 2); + if val.is_empty() { + out.push('0'); + } else { + add_underscores(&mut out, config, val, is_dec, is_yul, false); + } + if source.contains('.') { + out.push('.'); + match (fract.is_empty(), exp.is_empty()) { + // `X.YeZ`: keep as is + (false, false) => out.push_str(fract), + // `X.Y` + (false, true) => add_underscores(&mut out, config, fract, is_dec, is_yul, true), + // `X.` -> `X.0` + (true, _) => out.push('0'), + }; + } + if !exp.is_empty() { + out.push('e'); + out.push_str(exp_sign); + add_underscores(&mut out, config, exp, is_dec, is_yul, false); + } + + self.word(out); + } + + /// `s` should be the *unescaped contents of the string literal*. + pub(super) fn print_str_lit(&mut self, kind: ast::StrKind, quote_pos: BytePos, s: &str) { + self.print_comments(quote_pos, CommentConfig::default()); + let s = self.str_lit_to_string(kind, quote_pos, s); + self.word(s); + } + + /// `s` should be the *unescaped contents of the string literal*. + fn str_lit_to_string(&self, kind: ast::StrKind, quote_pos: BytePos, s: &str) -> String { + let prefix = kind.prefix(); + let quote = match self.config.quote_style { + config::QuoteStyle::Double => '\"', + config::QuoteStyle::Single => '\'', + config::QuoteStyle::Preserve => self.char_at(quote_pos).unwrap_or_default(), + }; + debug_assert!(matches!(quote, '\"' | '\''), "{quote:?}"); + let s = solar::parse::interface::data_structures::fmt::from_fn(move |f| { + if matches!(kind, ast::StrKind::Hex) { + match self.config.hex_underscore { + config::HexUnderscore::Preserve => {} + config::HexUnderscore::Remove | config::HexUnderscore::Bytes => { + let mut clean = s.to_string().replace('_', ""); + if matches!(self.config.hex_underscore, config::HexUnderscore::Bytes) { + clean = + clean.chars().chunks(2).into_iter().map(|c| c.format("")).join("_"); + } + return f.write_str(&clean); + } + }; + } + f.write_str(s) + }); + let mut s = format!("{prefix}{quote}{s}{quote}"); + + // If the output is not a single token then revert to the original quote. + #[allow(unstable_name_collisions)] + if Cursor::new(&s).exactly_one().is_err() { + let other_quote = if quote == '\"' { '\'' } else { '\"' }; + { + let s = unsafe { s.as_bytes_mut() }; + s[prefix.len()] = other_quote as u8; + s[s.len() - 1] = other_quote as u8; + } + debug_assert!(Cursor::new(&s).exactly_one().map(|_| true).unwrap()); + } + + s + } + + pub(super) fn print_tuple_empty(&mut self, pos_lo: BytePos, pos_hi: BytePos) { + if self.handle_span(Span::new(pos_lo, pos_hi), true) { + return; + } + + self.print_inside_parens(|state| { + state.s.cbox(state.ind); + if let Some(cmnt) = + state.print_comments(pos_hi, CommentConfig::skip_ws().mixed_prev_space()) + { + if cmnt.is_mixed() { + state.s.offset(-state.ind); + } else { + state.break_offset_if_not_bol(0, -state.ind, false); + } + } + state.end(); + }); + } + + pub(super) fn print_tuple<'a, T, P, S>( + &mut self, + values: &'a [T], + pos_lo: BytePos, + pos_hi: BytePos, + mut print: P, + mut get_span: S, + format: ListFormat, + ) where + P: FnMut(&mut Self, &'a T), + S: FnMut(&T) -> Span, + { + if self.handle_span(Span::new(pos_lo, pos_hi), true) { + return; + } + + if values.is_empty() { + self.print_tuple_empty(pos_lo, pos_hi); + return; + } + + if !(values.len() == 1 && format.is_inline()) { + // Use commasep + self.print_inside_parens(|state| { + state.commasep(values, pos_lo, pos_hi, print, get_span, format) + }); + return; + } + + // Format single-item inline lists directly without boxes + self.print_inside_parens(|state| { + let span = get_span(&values[0]); + state.s.cbox(state.ind); + let skip_break = if state.peek_comment_before(span.hi()).is_some() { + state.hardbreak(); + false + } else { + true + }; + + state.print_comments(span.lo(), CommentConfig::skip_ws().mixed_prev_space()); + print(state, &values[0]); + + // Bound the scan to the closing paren. Unbounded, it reaches past the list and claims a + // comment that trails whatever follows it, such as the modifiers of a function type. + if !state.print_trailing_comment(span.hi(), Some(pos_hi)) && skip_break { + state.neverbreak(); + } else { + state.break_offset_if_not_bol(0, -state.ind, false); + } + state.end(); + }); + } + + pub(super) fn print_array<'a, T, P, S>( + &mut self, + values: &'a [T], + span: Span, + print: P, + get_span: S, + ) where + P: FnMut(&mut Self, &'a T), + S: FnMut(&T) -> Span, + { + if self.handle_span(span, false) { + return; + } + + self.print_word("["); + self.commasep(values, span.lo(), span.hi(), print, get_span, ListFormat::compact()); + self.print_word("]"); + } + + pub(super) fn commasep_opening_logic( + &mut self, + values: &[T], + mut get_span: S, + format: ListFormat, + manual_opening: bool, + ) -> bool + where + S: FnMut(&T) -> Span, + { + let Some(span) = values.first().map(&mut get_span) else { + return false; + }; + + // If first item is uninformed (just a comma), and it has its own comment, skip it. + // It will be dealt with when printing the item in the main loop of `commasep`. + if span.is_dummy() + && let Some(next_pos) = values.get(1).map(|v| get_span(v).lo()) + && self.peek_comment_before(next_pos).is_some() + { + return true; + } + + // Check for comments before the first item. + if let Some((cmnt_span, cmnt_style)) = + self.peek_comment_before(span.lo()).map(|c| (c.span, c.style)) + { + let cmnt_disabled = self.inline_config.is_disabled(cmnt_span); + // Handle special formatting for disabled code with isolated comments. + if self.cursor.enabled && cmnt_disabled && cmnt_style.is_isolated() { + self.print_sep(Separator::Hardbreak); + if !format.with_delimiters { + self.s.offset(self.ind); + } + }; + + // If manual opening flag is passed, we simply force the break, and skip the comment. + // It will be dealt with when printing the item in the main loop of `commasep`. + if manual_opening { + self.hardbreak(); + self.s.offset(self.ind); + return true; + } + + let cmnt_config = if format.with_delimiters { + CommentConfig::skip_ws().mixed_no_break().mixed_prev_space() + } else { + CommentConfig::skip_ws().no_breaks().mixed_prev_space().offset(self.ind) + }; + // Apply spacing based on comment styles. + if let Some(last_style) = self.print_comments(span.lo(), cmnt_config) { + match (cmnt_style.is_mixed(), last_style.is_mixed()) { + (true, true) => { + if format.breaks_with_comments() { + self.hardbreak(); + } else { + self.space(); + } + if !format.with_delimiters && !cmnt_disabled { + self.s.offset(self.ind); + } + } + (false, true) => { + self.nbsp(); + } + (false, false) if !format.with_delimiters && !cmnt_disabled => { + self.hardbreak(); + self.s.offset(self.ind); + } + _ => {} + } + } + if self.cursor.enabled { + self.cursor.advance_to(span.lo(), true); + } + return true; + } + + if self.cursor.enabled { + self.cursor.advance_to(span.lo(), true); + } + + if !values.is_empty() && !format.with_delimiters { + format.print_break(true, values.len(), &mut self.s); + self.s.offset(self.ind); + return true; + } + + false + } + + pub(super) fn commasep<'a, T, P, S>( + &mut self, + values: &'a [T], + _pos_lo: BytePos, + pos_hi: BytePos, + mut print: P, + mut get_span: S, + format: ListFormat, + ) where + P: FnMut(&mut Self, &'a T), + S: FnMut(&T) -> Span, + { + if values.is_empty() { + return; + } + + // We can't simply check `peek_comment_before(pos_hi)` cause we would also account for + // comments in the child expression, and those don't matter. + let has_comments = + // check for comments before the first element + self.peek_comment_before(get_span(&values[0]).lo()).is_some() || + // check for comments between elements + values.windows(2).any(|w| self.peek_comment_between(get_span(&w[0]).hi(), get_span(&w[1]).lo()).is_some()) || + // check for comments after the last element + self.peek_comment_between(get_span(values.last().unwrap()).hi(), pos_hi).is_some(); + + // For calls with opts and args, which should break consistently, we need to skip the + // wrapping cbox to prioritize call args breaking before the call opts. Because of that, we + // must manually offset the breaks between args, so that they are properly indented. + let manual_opening = + format.is_consistent() && !format.with_delimiters && self.call_with_opts_and_args; + // When there are comments, we can preserve the cbox, as they will make it break + let manual_offset = !has_comments && manual_opening; + + let is_single_without_cmnts = values.len() == 1 && !format.break_single && !has_comments; + let skip_first_break = if format.with_delimiters || format.is_inline() { + self.s.cbox(if format.no_ind { 0 } else { self.ind }); + if is_single_without_cmnts { + true + } else { + self.commasep_opening_logic(values, &mut get_span, format, manual_opening) + } + } else { + let res = self.commasep_opening_logic(values, &mut get_span, format, manual_opening); + if !manual_offset { + self.s.cbox(if format.no_ind { 0 } else { self.ind }); + } + res + }; + + if let Some(sym) = format.prev_symbol() { + self.word_space(sym); + } else if is_single_without_cmnts && format.with_space { + self.nbsp(); + } else if !skip_first_break && !format.is_inline() { + format.print_break(true, values.len(), &mut self.s); + if manual_offset { + self.s.offset(self.ind); + } + } + + if format.is_compact() && !(format.breaks_with_comments() && has_comments) { + self.s.cbox(0); + } + + let mut last_delimiter_break = !format.with_delimiters; + let mut skip_last_break = + is_single_without_cmnts || !format.with_delimiters || format.is_inline(); + for (i, value) in values.iter().enumerate() { + let is_last = i == values.len() - 1; + if self + .print_comments(get_span(value).lo(), CommentConfig::skip_ws().mixed_prev_space()) + .is_some_and(|cmnt| cmnt.is_mixed()) + && format.breaks_with_comments() + { + self.hardbreak(); // trailing and isolated comments already hardbreak + } + + // Avoid printing the last uninformed item, so that we can handle line breaks. + if !(is_last && get_span(value).is_dummy()) { + print(self, value); + } + + let next_span = if is_last { None } else { Some(get_span(&values[i + 1])) }; + let next_pos = next_span.map(Span::lo).unwrap_or(pos_hi); + let cmnt_before_next = + self.peek_comment_before(next_pos).map(|cmnt| (cmnt.span, cmnt.style)); + + if !is_last { + // Handle disabled lines with comments after the value, but before the comma. + if cmnt_before_next.is_some_and(|(cmnt_span, _)| { + let span = self.cursor.span(cmnt_span.lo()); + self.inline_config.is_disabled(span) + // NOTE: necessary workaround to patch this edgecase due to lack of spans for the commas. + && self.snippet(span).is_some_and(|snip| !snip.contains(',')) + }) { + self.print_comments( + next_pos, + CommentConfig::skip_ws().mixed_no_break().mixed_prev_space(), + ); + } + self.print_word(","); + } + + if !is_last + && format.breaks_with_comments() + && cmnt_before_next.is_some_and(|(cmnt_span, cmnt_style)| { + let disabled = self.inline_config.is_disabled(cmnt_span); + (cmnt_style.is_mixed() && !disabled) || (cmnt_style.is_isolated() && disabled) + }) + { + self.hardbreak(); // trailing and isolated comments already hardbreak + } + + // Print trailing comments. + let comment_config = if !is_last || format.with_delimiters { + CommentConfig::skip_ws().mixed_no_break().mixed_prev_space() + } else { + CommentConfig::skip_ws().no_breaks().mixed_prev_space() + }; + let with_trailing = self.print_comments(next_pos, comment_config).is_some(); + + if is_last && with_trailing { + if self.is_bol_or_only_ind() { + // if a trailing comment is printed at the very end, we have to manually adjust + // the offset to avoid having a double break. + self.break_offset_if_not_bol(0, -self.ind, false); + } else { + self.s.break_offset(SIZE_INFINITY as usize, -self.ind); + } + skip_last_break = true; + last_delimiter_break = false; + } + + // Final break if needed before the next value. + if let Some(next_span) = next_span + && !self.is_bol_or_only_ind() + && !self.inline_config.is_disabled(next_span) + && !next_span.is_dummy() + { + format.print_break(false, values.len(), &mut self.s); + if manual_offset { + self.s.offset(self.ind); + } + } + } + + if format.is_compact() && !(format.breaks_with_comments() && has_comments) { + self.end(); + } + if !skip_last_break { + if let Some(sym) = format.post_symbol() { + format.print_break(false, values.len(), &mut self.s); + self.s.offset(-self.ind); + self.word(sym); + } else { + format.print_break(true, values.len(), &mut self.s); + self.s.offset(-self.ind); + } + } else if is_single_without_cmnts && format.with_space { + self.nbsp(); + } else if let Some(sym) = format.post_symbol() { + self.nbsp(); + self.word(sym); + } + + if !manual_offset { + self.end(); + } + self.cursor.advance_to(pos_hi, true); + + if last_delimiter_break { + format.print_break(true, values.len(), &mut self.s); + } + } + + pub(super) fn print_path(&mut self, path: &'ast ast::PathSlice, consistent_break: bool) { + if consistent_break { + self.s.cbox(self.ind); + } else { + self.s.ibox(self.ind); + } + for (pos, ident) in path.segments().iter().delimited() { + self.print_ident(ident); + if !pos.is_last { + if !self.emit_or_revert { + self.zerobreak(); + } + self.word("."); + } + } + self.end(); + } + + pub(super) fn print_block_inner( + &mut self, + block: &'ast [T], + block_format: BlockFormat, + mut print: impl FnMut(&mut Self, &'ast T), + mut get_block_span: impl FnMut(&'ast T) -> Span, + pos_hi: BytePos, + ) { + // Attempt to print in a single line. + if block_format.attempt_single_line() && block.len() == 1 { + self.print_single_line_block(block, block_format, print, get_block_span); + return; + } + + // Empty blocks with comments require special attention. + if block.is_empty() { + self.print_empty_block(block_format, pos_hi); + return; + } + + // update block depth + self.block_depth += 1; + + // Print multiline block comments. + let block_lo = get_block_span(&block[0]).lo(); + match block_format { + BlockFormat::NoBraces(None) => { + if !self.handle_span(self.cursor.span(block_lo), false) { + self.print_comments(block_lo, CommentConfig::default()); + } + self.s.cbox(0); + } + BlockFormat::NoBraces(Some(offset)) => { + let enabled = + !self.inline_config.is_disabled(Span::new(block_lo, block_lo + BytePos(1))) + && !self.handle_span(self.cursor.span(block_lo), true); + match self + .peek_comment() + .and_then(|cmnt| (cmnt.span.hi() < block_lo).then_some((cmnt.span, cmnt.style))) + { + Some((span, style)) => { + if enabled { + // Inline config is not disabled and span not handled + if !self.inline_config.is_disabled(span) || style.is_isolated() { + self.cursor.advance_to(span.lo(), true); + self.break_offset(SIZE_INFINITY as usize, offset); + } + if let Some(cmnt) = self.print_comments( + block_lo, + CommentConfig::skip_leading_ws(false).offset(offset), + ) && !cmnt.is_mixed() + && !cmnt.is_blank() + { + self.s.offset(offset); + } + } else if style.is_isolated() { + self.print_sep_unhandled(Separator::Hardbreak); + self.s.offset(offset); + } + } + None => { + if enabled { + self.zerobreak(); + self.s.offset(offset); + } else if self.cursor.enabled { + self.print_sep_unhandled(Separator::Space); + self.s.offset(offset); + self.cursor.advance_to(block_lo, true); + } + } + } + self.s.cbox(self.ind); + } + _ => { + self.print_word("{"); + self.s.cbox(self.ind); + if !self.handle_span(self.cursor.span(block_lo), false) + && self + .print_comments(block_lo, CommentConfig::default()) + .is_none_or(|cmnt| cmnt.is_mixed()) + { + self.hardbreak_if_nonempty(); + } + } + } + + // Print multiline block statements. + for (i, stmt) in block.iter().enumerate() { + let is_last = i == block.len() - 1; + print(self, stmt); + + let is_disabled = self.inline_config.is_disabled(get_block_span(stmt)); + let (next_enabled, next_lo) = if is_last { + (false, None) + } else { + let next_span = get_block_span(&block[i + 1]); + ( + !self.inline_config.is_disabled(next_span), + self.peek_comment_before(next_span.lo()).is_none().then_some(next_span.lo()), + ) + }; + + // when this stmt and the next one are enabled, break normally (except if last stmt) + if !is_disabled + && next_enabled + && (!is_last + || self.peek_comment_before(pos_hi).is_some_and(|cmnt| cmnt.style.is_mixed())) + { + self.hardbreak_if_not_bol(); + continue; + } + // when this stmt is disabled and the next one is enabled, break if there is no + // enabled preceding comment. Otherwise the breakpoint is handled by the comment. + if is_disabled + && next_enabled + && let Some(next_lo) = next_lo + && self + .peek_comment_before(next_lo) + .is_none_or(|cmnt| self.inline_config.is_disabled(cmnt.span)) + { + self.hardbreak_if_not_bol() + } + } + + self.print_comments( + pos_hi, + CommentConfig::skip_trailing_ws().mixed_no_break().mixed_prev_space(), + ); + if !block_format.breaks() { + if !self.last_token_is_break() && !self.is_beginning_of_line() { + self.hardbreak(); + } + self.s.offset(-self.ind); + } + self.end(); + if block_format.with_braces() { + self.print_word("}"); + } + + // restore block depth + self.block_depth -= 1; + } + + fn print_single_line_block( + &mut self, + block: &'ast [T], + block_format: BlockFormat, + mut print: impl FnMut(&mut Self, &'ast T), + mut get_block_span: impl FnMut(&'ast T) -> Span, + ) { + self.s.cbox(self.ind); + + match block_format { + BlockFormat::Compact(true) => { + self.scan_break(BreakToken { pre_break: Some("{"), ..Default::default() }); + print(self, &block[0]); + self.print_comments(get_block_span(&block[0]).hi(), CommentConfig::default()); + self.s.scan_break(BreakToken { post_break: Some("}"), ..Default::default() }); + self.s.offset(-self.ind); + } + _ => { + self.word("{"); + self.space(); + print(self, &block[0]); + self.print_comments(get_block_span(&block[0]).hi(), CommentConfig::default()); + self.space_if_not_bol(); + self.s.offset(-self.ind); + self.word("}"); + } + } + + self.end(); + } + + fn print_empty_block(&mut self, block_format: BlockFormat, pos_hi: BytePos) { + let has_braces = block_format.with_braces(); + + // Trailing comments are printed after the block + if self.peek_comment_before(pos_hi).is_none_or(|c| c.style.is_trailing()) { + if self.config.bracket_spacing { + if has_braces { + self.word("{ }"); + } else { + self.nbsp(); + } + } else if has_braces { + self.word("{}"); + } + self.print_comments(pos_hi, CommentConfig::skip_ws()); + return; + } + + // Non-trailing or mixed comments - print inside block + if has_braces { + self.word("{"); + } + let offset = if let BlockFormat::NoBraces(Some(off)) = block_format { off } else { 0 }; + self.print_comments( + pos_hi, + self.cmnt_config() + .offset(offset) + .mixed_no_break() + .mixed_prev_space() + .mixed_post_glued(), + ); + self.print_comments( + pos_hi, + CommentConfig::default().mixed_no_break().mixed_prev_space().mixed_post_glued(), + ); + if has_braces { + self.word("}"); + } + } +} + +/// Formatting style for comma-separated lists. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ListFormat { + /// The core formatting strategy. + kind: ListFormatKind, + /// If `true`, it means that the list already carries indentation. + no_ind: bool, + /// If `true`, a single-element list may break. + break_single: bool, + /// If `true`, a comment within the list forces a break. + breaks_cmnts: bool, + /// If `true`, a space is added after the opening delimiter and before the closing one. + with_space: bool, + /// If `true`, the list is enclosed in delimiters. + with_delimiters: bool, +} + +/// The kind of formatting style for a list. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ListFormatKind { + /// Always breaks for multiple elements. + AlwaysBreak, + /// Breaks all elements if any break. + Consistent, + /// Attempts to fit all elements in one line, before breaking consistently. + Compact, + /// The list is printed inline, without breaks. + Inline, + /// Special formatting for Yul return values. + Yul { sym_prev: Option<&'static str>, sym_post: Option<&'static str> }, +} + +impl Default for ListFormat { + fn default() -> Self { + Self { + kind: ListFormatKind::Consistent, + no_ind: false, + break_single: false, + breaks_cmnts: false, + with_space: false, + with_delimiters: true, + } + } +} + +impl ListFormat { + // -- GETTER METHODS ------------------------------------------------------- + pub(crate) const fn prev_symbol(&self) -> Option<&'static str> { + if let ListFormatKind::Yul { sym_prev, .. } = self.kind { sym_prev } else { None } + } + + pub(crate) const fn post_symbol(&self) -> Option<&'static str> { + if let ListFormatKind::Yul { sym_post, .. } = self.kind { sym_post } else { None } + } + + pub(crate) const fn is_consistent(&self) -> bool { + matches!(self.kind, ListFormatKind::Consistent) + } + + pub(crate) const fn is_compact(&self) -> bool { + matches!(self.kind, ListFormatKind::Compact) + } + + pub(crate) const fn is_inline(&self) -> bool { + matches!(self.kind, ListFormatKind::Inline) + } + + pub(crate) const fn breaks_with_comments(&self) -> bool { + self.breaks_cmnts + } + + // -- BUILDER METHODS ------------------------------------------------------ + pub(crate) fn inline() -> Self { + Self { kind: ListFormatKind::Inline, ..Default::default() } + } + + pub(crate) fn consistent() -> Self { + Self { kind: ListFormatKind::Consistent, ..Default::default() } + } + + pub(crate) fn compact() -> Self { + Self { kind: ListFormatKind::Compact, ..Default::default() } + } + + pub(crate) fn always_break() -> Self { + Self { + kind: ListFormatKind::AlwaysBreak, + breaks_cmnts: true, + break_single: true, + with_delimiters: true, + ..Default::default() + } + } + + pub(crate) fn yul(sym_prev: Option<&'static str>, sym_post: Option<&'static str>) -> Self { + Self { + kind: ListFormatKind::Yul { sym_prev, sym_post }, + breaks_cmnts: true, + with_delimiters: true, + ..Default::default() + } + } + + pub(crate) const fn without_ind(mut self, without: bool) -> Self { + if !self.is_inline() { + self.no_ind = without; + } + self + } + + pub(crate) const fn break_single(mut self, value: bool) -> Self { + if !self.is_inline() { + self.break_single = value; + } + self + } + + pub(crate) const fn break_cmnts(mut self) -> Self { + if !self.is_inline() { + self.breaks_cmnts = true; + } + self + } + + pub(crate) const fn with_space(mut self) -> Self { + if !self.is_inline() { + self.with_space = true; + } + self + } + + pub(crate) const fn with_delimiters(mut self, with: bool) -> Self { + if matches!(self.kind, ListFormatKind::Compact | ListFormatKind::Consistent) { + self.with_delimiters = with; + } + self + } + + // -- PRINTER METHODS ------------------------------------------------------ + pub(crate) fn print_break(&self, soft: bool, elems: usize, p: &mut Printer) { + match self.kind { + ListFormatKind::Inline => p.nbsp(), // CAREFUL: we can't use `pp.offset()` afterwards + ListFormatKind::AlwaysBreak if elems > 1 || (self.break_single && elems == 1) => { + p.hardbreak() + } + _ => { + if soft && !self.with_space { + p.zerobreak(); + } else { + p.space(); + } + } + } + } +} + +/// Formatting style for code blocks +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[expect(dead_code)] +pub(crate) enum BlockFormat { + Regular, + /// Attempts to fit all elements in one line, before breaking consistently. Flags whether to + /// use braces or not. + Compact(bool), + /// Doesn't print braces. Flags the offset that should be applied before opening the block box. + /// Useful when the caller needs to manually handle the braces. + NoBraces(Option), +} + +impl BlockFormat { + pub(crate) const fn with_braces(&self) -> bool { + !matches!(self, Self::NoBraces(_)) + } + pub(crate) const fn breaks(&self) -> bool { + matches!(self, Self::NoBraces(None)) + } + + pub(crate) const fn attempt_single_line(&self) -> bool { + matches!(self, Self::Compact(_)) + } +} diff --git a/crates/fmt/src/state/mod.rs b/crates/fmt/src/state/mod.rs new file mode 100644 index 0000000000000..5013bcd3ee98c --- /dev/null +++ b/crates/fmt/src/state/mod.rs @@ -0,0 +1,1463 @@ +#![allow(clippy::too_many_arguments)] +use crate::{ + FormatterConfig, InlineConfig, + pp::{self, BreakToken, SIZE_INFINITY, Token}, + state::sol::BinOpGroup, +}; +use foundry_common::{ + comments::{Comment, CommentStyle, Comments, estimate_line_width, line_with_tabs}, + iter::IterDelimited, +}; +use foundry_config::fmt::{DocCommentStyle, IndentStyle, IntTypes}; +use solar::parse::{ + ast::{self, Span}, + interface::{BytePos, source_map::SourceFile}, + token, +}; +use std::{borrow::Cow, ops::Deref, sync::Arc}; + +mod common; +mod sol; +mod yul; + +/// Specifies the nature of a complex call. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum CallContextKind { + /// A chained method call, `a().b()`. + Chained, + + /// A nested function call, `a(b())`. + Nested, +} + +/// Formatting context for a call expression. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct CallContext { + /// The kind call. + pub(super) kind: CallContextKind, + + /// The size of the callee's head, excluding its arguments. + pub(super) size: usize, + + /// Whether this chain context added its own indentation box. + pub(super) has_indent: bool, +} + +impl CallContext { + pub(super) const fn nested(size: usize) -> Self { + Self { kind: CallContextKind::Nested, size, has_indent: false } + } + + pub(super) const fn chained(size: usize, has_indent: bool) -> Self { + Self { kind: CallContextKind::Chained, size, has_indent } + } + + pub(super) const fn is_nested(&self) -> bool { + matches!(self.kind, CallContextKind::Nested) + } + + pub(super) const fn is_chained(&self) -> bool { + matches!(self.kind, CallContextKind::Chained) + } +} + +#[derive(Debug, Default)] +pub(super) struct CallStack { + stack: Vec, +} + +impl Deref for CallStack { + type Target = [CallContext]; + fn deref(&self) -> &Self::Target { + &self.stack + } +} + +impl CallStack { + pub(crate) fn push(&mut self, call: CallContext) { + self.stack.push(call); + } + + pub(crate) fn pop(&mut self) -> Option { + self.stack.pop() + } + + pub(crate) fn is_nested(&self) -> bool { + self.last().is_some_and(|call| call.is_nested()) + } + + /// Returns true if the direct parent chain has its own indentation. + /// Used to determine if commasep should skip its own indentation (to avoid double indent). + pub(crate) const fn has_indented_parent_chain(&self) -> bool { + matches!( + self.stack.as_slice(), + [.., parent, last] if last.is_nested() && parent.is_chained() && parent.has_indent + ) + } +} + +#[derive(Clone, Copy)] +struct ChainedNamedCall { + callee: Span, + keep_inline: bool, +} + +pub(super) struct State<'sess, 'ast> { + // CORE COMPONENTS + pub(super) s: pp::Printer, + ind: isize, + + file: &'sess SourceFile, + pub(super) comments: Comments, + config: Arc, + inline_config: InlineConfig<()>, + cursor: SourcePos, + + // FORMATTING CONTEXT: + // Whether the source file uses CRLF (`\r\n`) line endings. + has_crlf: bool, + // The current contract being formatted, if inside a contract definition. + contract: Option<&'ast ast::ItemContract<'ast>>, + // Current block nesting depth (incremented for each `{...}` block entered). + block_depth: usize, + // Stack tracking nested and chained function calls. + call_stack: CallStack, + + // Whether the current statement should be formatted as a single line, or not. + single_line_stmt: Option, + // The current binary expression chain context, if inside one. + binary_expr: Option, + // Whether inside a `return` statement that contains a binary expression, or not. + return_bin_expr: bool, + // Whether inside a call with call options and at least one argument. + call_with_opts_and_args: bool, + // Callee of the current chained call with named arguments, if any. + chained_named_call: Option, + // Whether to skip the index soft breaks because the callee fits inline. + skip_index_break: bool, + // Whether inside an `emit` or `revert` call with a qualified path, or not. + emit_or_revert: bool, + // Whether inside a variable initialization expression, or not. + var_init: bool, +} + +impl std::ops::Deref for State<'_, '_> { + type Target = pp::Printer; + + #[inline(always)] + fn deref(&self) -> &Self::Target { + &self.s + } +} + +impl std::ops::DerefMut for State<'_, '_> { + #[inline(always)] + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.s + } +} + +struct SourcePos { + pos: BytePos, + enabled: bool, +} + +impl SourcePos { + /// While `enabled`, the position is a loose lower bound that is resynced by `advance_to`. + /// While disabled, it is the exact start of the not-yet-printed source of a disabled region. + pub(super) fn advance(&mut self, bytes: u32) { + self.pos += BytePos(bytes); + } + + pub(super) fn advance_to(&mut self, pos: BytePos, enabled: bool) { + // Ignore stale updates while disabled, as the exact position must be preserved until the + // remainder of the disabled region has been printed. + if self.enabled || pos >= self.pos { + self.pos = std::cmp::max(pos, self.pos); + self.enabled = enabled; + } + } + + pub(super) fn next_line(&mut self, is_at_crlf: bool) { + self.pos += if is_at_crlf { 2 } else { 1 }; + } + + pub(super) fn span(&self, to: BytePos) -> Span { + Span::new(self.pos, to) + } +} + +pub(super) enum Separator { + Nbsp, + Space, + Hardbreak, + SpaceOrNbsp(bool), +} + +impl Separator { + fn print(&self, p: &mut pp::Printer) { + match self { + Self::Nbsp => p.nbsp(), + Self::Space => p.space(), + Self::Hardbreak => p.hardbreak(), + Self::SpaceOrNbsp(breaks) => p.space_or_nbsp(*breaks), + } + } +} + +/// Generic methods +impl<'sess> State<'sess, '_> { + pub(super) fn new( + file: &'sess SourceFile, + config: Arc, + inline_config: InlineConfig<()>, + comments: Comments, + ) -> Self { + Self { + s: pp::Printer::new( + config.line_length, + matches!(config.style, IndentStyle::Tab).then(|| config.tab_width), + ), + ind: config.tab_width as isize, + file, + comments, + config, + inline_config, + cursor: SourcePos { pos: file.start_pos, enabled: true }, + has_crlf: false, + contract: None, + single_line_stmt: None, + call_with_opts_and_args: false, + chained_named_call: None, + skip_index_break: false, + binary_expr: None, + return_bin_expr: false, + emit_or_revert: false, + var_init: false, + block_depth: 0, + call_stack: CallStack::default(), + } + } + + /// Checks a span of the source for a carriage return (`\r`) to determine if the file + /// uses CRLF line endings. + /// + /// If a `\r` is found, `self.has_crlf` is set to `true`. This is intended to be + /// called once at the beginning of the formatting process for efficiency. + fn check_crlf(&mut self, span: Span) { + if let Some(snip) = self.snippet(span) + && snip.contains('\r') + { + self.has_crlf = true; + } + } + + /// Checks if the cursor is currently positioned at the start of a CRLF sequence (`\r\n`). + /// The check is only meaningful if `self.has_crlf` is true. + fn is_at_crlf(&self) -> bool { + self.has_crlf && self.char_at(self.cursor.pos) == Some('\r') + } + + /// Advances the cursor past the line break assumed to be represented by a printed separator. + /// + /// While the cursor is disabled it marks the exact start of not-yet-printed source, so it may + /// only advance if it actually sits at a line break; otherwise the separator does not consume + /// any source (e.g. it was already printed verbatim by a disabled trailing comment). + fn cursor_next_line(&mut self) { + if self.cursor.enabled || matches!(self.char_at(self.cursor.pos), Some('\n' | '\r')) { + self.cursor.next_line(self.is_at_crlf()); + } + } + + /// Computes the space left, bounded by the max space left. + fn space_left(&self) -> usize { + std::cmp::min(self.s.space_left(), self.max_space_left(0)) + } + + /// Computes the maximum space left given the context information available: + /// `block_depth`, `tab_width`, and a user-defined unavailable size `prefix_len`. + fn max_space_left(&self, prefix_len: usize) -> usize { + self.config + .line_length + .saturating_sub(self.block_depth * self.config.tab_width + prefix_len) + } + + fn break_offset_if_not_bol(&mut self, n: usize, off: isize, search: bool) { + // When searching, the break token is expected to be inside a closed box. Thus, we will + // traverse the buffer and evaluate the first non-end token. + if search { + // We do something pretty sketchy here: tuck the nonzero offset-adjustment we + // were going to deposit along with the break into the previous hardbreak. + self.find_and_replace_last_token_still_buffered( + pp::Printer::hardbreak_tok_offset(off), + |token| token.is_hardbreak(), + ); + return; + } + + // When not explicitly searching, the break token is expected to be the last token. + if !self.is_beginning_of_line() { + self.break_offset(n, off) + } else if off != 0 + && let Some(last_token) = self.last_token_still_buffered() + && last_token.is_hardbreak() + { + // We do something pretty sketchy here: tuck the nonzero offset-adjustment we + // were going to deposit along with the break into the previous hardbreak. + self.replace_last_token_still_buffered(pp::Printer::hardbreak_tok_offset(off)); + } + } + + fn braces_break(&mut self) { + if self.config.bracket_spacing { + self.space(); + } else { + self.zerobreak(); + } + } +} + +/// Span to source. +impl<'sess> State<'sess, '_> { + fn snippet(&self, span: Span) -> Option<&'sess str> { + let start = span.lo().0.checked_sub(self.file.start_pos.0)? as usize; + let end = span.hi().0.checked_sub(self.file.start_pos.0)? as usize; + self.file.src.get(start..end) + } + + fn source_line(&self, pos: BytePos) -> usize { + self.file.lookup_line(self.file.relative_position(pos)).map_or(0, |line| line + 1) + } + + fn char_at(&self, pos: BytePos) -> Option { + let offset = pos.0.checked_sub(self.file.start_pos.0)? as usize; + self.file.src.get(offset..)?.chars().next() + } + + /// Returns the position of the first `{` within the span, ignoring the ones inside comments. + fn find_opening_brace(&self, span: Span) -> Option { + self.find_uncommented_char(span, '{') + } + + /// Returns the position of the first matching character within the span, ignoring comments. + fn find_uncommented_char(&self, span: Span, needle: char) -> Option { + let snip = self.snippet(span)?; + let mut idx = 0; + while idx < snip.len() { + let rest = &snip[idx..]; + if rest.starts_with(needle) { + return Some(span.lo() + idx as u32); + } + idx += if let Some(line) = rest.strip_prefix("//") { + 2 + line.find('\n').unwrap_or(line.len()) + } else if let Some(block) = rest.strip_prefix("/*") { + 2 + block.find("*/").map_or(block.len(), |end| end + 2) + } else { + rest.chars().next().map_or(1, char::len_utf8) + }; + } + None + } + + fn print_span(&mut self, span: Span) { + match self.snippet(span) { + Some(s) => self.s.word(if matches!(self.config.style, IndentStyle::Tab) { + snippet_with_tabs(s.to_owned(), self.config.tab_width) + } else { + s.to_owned() + }), + None => panic!("failed to print {span:?}: invalid source span"), + } + // Drop comments that are included in the span. + while let Some(cmnt) = self.peek_comment() { + if cmnt.pos() >= span.hi() { + break; + } + let _ = self.next_comment().unwrap(); + } + // Update cursor + self.cursor.advance_to(span.hi(), false); + } + + /// Returns `true` if the span is disabled and has been printed as-is. + #[must_use] + fn handle_span(&mut self, span: Span, skip_prev_cmnts: bool) -> bool { + if !skip_prev_cmnts { + self.print_comments(span.lo(), CommentConfig::default()); + } + self.print_span_if_disabled(span) + } + + /// Returns `true` if the span is disabled and has been printed as-is. + #[inline] + #[must_use] + fn print_span_if_disabled(&mut self, span: Span) -> bool { + let cursor_span = self.cursor.span(span.hi()); + if self.inline_config.is_disabled(cursor_span) { + self.print_span_cold(cursor_span); + return true; + } + if self.inline_config.is_disabled(span) { + self.print_span_cold(span); + return true; + } + false + } + + #[cold] + fn print_span_cold(&mut self, span: Span) { + self.print_span(span); + } + + fn print_tokens(&mut self, tokens: &[token::Token]) { + // Leave unchanged. + let span = Span::join_first_last(tokens.iter().map(|t| t.span)); + self.print_span(span); + } + + fn print_word(&mut self, w: impl Into>) { + let cow = w.into(); + self.cursor.advance(cow.len() as u32); + self.word(cow); + } + + fn print_sep(&mut self, sep: Separator) { + if self.handle_span( + self.cursor.span(self.cursor.pos + if self.is_at_crlf() { 2 } else { 1 }), + true, + ) { + return; + } + + self.print_sep_unhandled(sep); + } + + fn print_sep_unhandled(&mut self, sep: Separator) { + sep.print(&mut self.s); + self.cursor_next_line(); + } + + fn print_ident(&mut self, ident: &ast::Ident) { + if self.handle_span(ident.span, true) { + return; + } + + self.print_comments(ident.span.lo(), CommentConfig::skip_ws()); + self.word(ident.to_string()); + } + + fn print_inside_parens(&mut self, f: F) + where + F: FnOnce(&mut Self), + { + self.print_word("("); + f(self); + self.print_word(")"); + } + + /// Account for brace spacing, ignoring literal and comment text. + fn brace_spacing_delta( + &self, + line: &str, + in_block_comment: &mut bool, + quote: &mut Option, + ) -> isize { + let bytes = line.as_bytes(); + let mut delta = 0; + let mut i = 0; + while i < bytes.len() { + if let Some(delimiter) = *quote { + match bytes[i] { + b'\\' => i = (i + 2).min(bytes.len()), + c if c == delimiter => { + *quote = None; + i += 1; + } + _ => i += 1, + } + continue; + } + if *in_block_comment { + if bytes.get(i..i + 2) == Some(b"*/") { + *in_block_comment = false; + i += 2; + } else { + i += 1; + } + continue; + } + match bytes[i] { + b'/' if bytes.get(i + 1) == Some(&b'/') => break, + b'/' if bytes.get(i + 1) == Some(&b'*') => { + *in_block_comment = true; + i += 2; + } + delimiter @ (b'\'' | b'"') => { + *quote = Some(delimiter); + i += 1; + } + b'{' | b'}' => { + let inner = if bytes[i] == b'{' { + bytes.get(i + 1) + } else { + i.checked_sub(1).and_then(|j| bytes.get(j)) + }; + if let Some(&inner) = inner + && !matches!(inner, b'{' | b'}') + { + delta += match (matches!(inner, b' ' | b'\t'), self.config.bracket_spacing) + { + (true, false) => -1, + (false, true) => 1, + _ => 0, + }; + } + i += 1; + } + _ => i += 1, + } + } + delta + } + + /// Measure integer type spelling from parsed types so identifiers and literal text do not + /// affect the estimate. + fn int_type_delta(&self, ty: &ast::Type<'_>) -> isize { + match &ty.kind { + ast::TypeKind::Elementary( + ast::ElementaryType::Int(size) | ast::ElementaryType::UInt(size), + ) => match (self.config.int_types, size.bits_raw()) { + (IntTypes::Long, 0) => 3, + (IntTypes::Short, 256) => -3, + _ => 0, + }, + ast::TypeKind::Array(array) => self.int_type_delta(&array.element), + ast::TypeKind::Mapping(mapping) => { + self.int_type_delta(&mapping.key) + self.int_type_delta(&mapping.value) + } + ast::TypeKind::Function(function) => { + let params: isize = function + .parameters + .vars + .iter() + .map(|param| self.int_type_delta(¶m.ty)) + .sum(); + let returns: isize = function.returns.as_ref().map_or(0, |returns| { + returns.vars.iter().map(|ret| self.int_type_delta(&ret.ty)).sum() + }); + params + returns + } + _ => 0, + } + } + + fn estimate_type_size(&self, ty: &ast::Type<'_>) -> usize { + self.estimate_size(ty.span).saturating_add_signed(self.int_type_delta(ty)) + } + + fn estimate_size(&self, span: Span) -> usize { + self.estimate_size_inner(span, false) + } + + /// Call argument layout needs printed delimiter widths. Keep other layout decisions on their + /// existing estimate so already stable source does not change formatting. + fn estimate_call_args_size(&self, span: Span) -> usize { + self.estimate_size_inner(span, true) + } + + /// Parentheses and index brackets have no inner padding in the printed assignment. + /// Keep comment-bearing expressions on their existing estimate. + fn estimate_assignment_size(&self, span: Span) -> usize { + self.estimate_size_inner(span, !self.has_comment_between(span.lo(), span.hi())) + } + + fn estimate_size_inner(&self, span: Span, normalize_delimiters: bool) -> usize { + if let Some(snip) = self.snippet(span) { + let (mut size, mut first, mut prev_needs_space) = (0, true, false); + let (mut in_block_comment, mut quote) = (false, None); + + for line in snip.lines() { + let line = line.trim(); + + if prev_needs_space { + size += 1; + } else if !first && let Some(char) = line.chars().next() { + // A line break or a space are required if this line: + // - starts with an operator. + // - starts with one of the ternary operators + // - starts with a bracket and fmt config forces bracket spacing. + match char { + '&' | '|' | '=' | '>' | '<' | '+' | '-' | '*' | '/' | '%' | '^' | '?' + | ':' => size += 1, + ')' | ']' + if self.config.bracket_spacing + && (!normalize_delimiters || quote.is_some()) => + { + size += 1 + } + '}' if self.config.bracket_spacing => size += 1, + _ => (), + } + } + first = false; + + // trim spaces before and after mixed comments + let mut search = line; + loop { + if let Some((lhs, comment)) = search.split_once(r#"/*"#) { + size += lhs.trim_end().len() + 2; + search = comment; + } else if let Some((comment, rhs)) = search.split_once(r#"*/"#) { + size += comment.len() + 2; + search = rhs; + } else { + size += search.trim().len(); + break; + } + } + let brace_delta = self.brace_spacing_delta(line, &mut in_block_comment, &mut quote); + size = size.saturating_add_signed(brace_delta); + + // Next line requires a line break if this one: + // - ends with a bracket and fmt config forces bracket spacing. + // - ends with ',' a line break or a space are required. + // - ends with ';' a line break is required. + // - ends with an operator, mirroring lines that start with one. + prev_needs_space = match line.chars().next_back() { + Some('{') => self.config.bracket_spacing, + Some('[' | '(') => { + self.config.bracket_spacing && (!normalize_delimiters || quote.is_some()) + } + Some(',' | ';') => true, + Some( + '&' | '|' | '=' | '>' | '<' | '+' | '-' | '*' | '/' | '%' | '^' | '?' | ':', + ) => !line.ends_with("*/"), + _ => false, + }; + } + return size; + } + + span.to_range().len() + } + + fn same_source_line(&self, a: BytePos, b: BytePos) -> bool { + self.source_line(a) == self.source_line(b) + } +} + +/// Comment-related methods. +impl<'sess> State<'sess, '_> { + /// Returns `None` if the span is disabled and has been printed as-is. + #[must_use] + fn handle_comment(&mut self, cmnt: Comment, skip_break: bool) -> Option { + if self.cursor.enabled { + if self.inline_config.is_disabled(cmnt.span) { + // The comment is copied verbatim below, which appends it to whatever was printed + // last. An isolated comment had a line of its own in the source, so give it that + // line back rather than gluing it to the previous statement. + if cmnt.style.is_isolated() { + self.hardbreak_if_not_bol(); + } else if cmnt.style.is_trailing() && !self.last_token_is_space() { + self.nbsp(); + } + self.print_span_cold(cmnt.span); + if !skip_break && (cmnt.style.is_isolated() || cmnt.style.is_trailing()) { + self.print_sep(Separator::Hardbreak); + } + return None; + } + } else if self.print_span_if_disabled(cmnt.span) { + if !skip_break && (cmnt.style.is_isolated() || cmnt.style.is_trailing()) { + self.print_sep(Separator::Hardbreak); + } + return None; + } + Some(cmnt) + } + + fn cmnt_config(&self) -> CommentConfig { + Default::default() + } + + const fn print_docs(&mut self, docs: &'_ ast::DocComments<'_>) { + // Intentionally no-op. Handled with `self.comments`. + let _ = docs; + } + + /// Prints comments that are before the given position. + /// + /// Returns `Some` with the style of the last comment printed, or `None` if no comment was + /// printed. + fn print_comments(&mut self, pos: BytePos, mut config: CommentConfig) -> Option { + let mut last_style: Option = None; + let mut is_leading = true; + let config_cache = config; + let mut buffered_blank = None; + let mut previous_mixed_at_bol = false; + while self.peek_comment().is_some_and(|c| c.pos() < pos) { + let mut cmnt = self.next_comment().unwrap(); + let style_cache = cmnt.style; + + // Merge consecutive line doc comments when converting to block style + if self.config.docs_style == foundry_config::fmt::DocCommentStyle::Block + && cmnt.is_doc + && cmnt.kind == ast::CommentKind::Line + { + let mut ref_line = self.source_line(cmnt.span.hi()); + while let Some(next_cmnt) = self.peek_comment() { + if !next_cmnt.is_doc + || next_cmnt.kind != ast::CommentKind::Line + || ref_line + 1 != self.source_line(next_cmnt.span.lo()) + { + break; + } + + let next_to_merge = self.next_comment().unwrap(); + cmnt.lines.extend(next_to_merge.lines); + cmnt.span = cmnt.span.to(next_to_merge.span); + ref_line += 1; + } + } + + // Ensure breaks are never skipped when there are multiple comments + if self.peek_comment_before(pos).is_some() { + config.iso_no_break = false; + config.trailing_no_break = false; + } + + // Handle disabled comments + let Some(mut cmnt) = self.handle_comment( + cmnt, + if style_cache.is_isolated() { + config.iso_no_break + } else { + config.trailing_no_break + }, + ) else { + last_style = Some(style_cache); + continue; + }; + + if cmnt.style.is_blank() { + match config.skip_blanks { + Some(Skip::All) => continue, + Some(Skip::Leading { resettable: true }) if is_leading => continue, + Some(Skip::Leading { resettable: false }) if last_style.is_none() => continue, + Some(Skip::Trailing) => { + buffered_blank = Some(cmnt); + continue; + } + _ => (), + } + // Never print blank lines after docs comments + } else if !cmnt.is_doc { + is_leading = false; + } + + if let Some(blank) = buffered_blank.take() { + self.print_comment(blank, config); + } + + if previous_mixed_at_bol + && cmnt.style.is_trailing() + && matches!(cmnt.kind, ast::CommentKind::Line) + { + self.hardbreak_if_not_bol(); + } + + // A trailing comment moved onto its own line is isolated on the next run. + // Use that style now so following comments receive the same indentation. + if cmnt.style.is_trailing() && self.is_bol_or_only_ind() { + cmnt.style = CommentStyle::Isolated; + } + + // Handle mixed with follow-up comment + if cmnt.style.is_mixed() { + if let Some(cmnt) = self.peek_comment_before(pos) { + config.mixed_no_break_prev = true; + config.mixed_no_break_post = true; + config.mixed_post_nbsp = false; + // The separator within a run of mixed comments must never break, even with + // `wrap_comments`: a breakable space inside a broken consistent box always + // breaks, which splits the run and reclassifies its comments on the next run. + if cmnt.style.is_mixed() { + config = config.mixed_post_glued(); + } + } + + // Ensure consecutive mixed comments don't have a double-space + if last_style.is_some_and(|s| s.is_mixed()) { + config.mixed_no_break_prev = true; + config.mixed_no_break_post = true; + config.mixed_prev_space = false; + } + } else if config.offset != 0 + && cmnt.style.is_isolated() + && last_style.is_some_and(|s| s.is_isolated()) + { + self.offset(config.offset); + } + + last_style = Some(cmnt.style); + previous_mixed_at_bol = cmnt.style.is_mixed() && self.is_bol_or_only_ind(); + self.print_comment(cmnt, config); + config = config_cache; + } + last_style + } + + /// Prints a line, wrapping it if it starts with the given prefix. + fn print_wrapped_line( + &mut self, + line: &str, + prefix: &'static str, + break_offset: isize, + is_doc: bool, + ) { + if !line.starts_with(prefix) { + self.word(line.to_owned()); + return; + } + + fn post_break_prefix(prefix: &'static str, has_content: bool) -> &'static str { + if !has_content { + return prefix; + } + match prefix { + "///" => "/// ", + "//" => "// ", + "/*" => "/* ", + " *" => " * ", + _ => prefix, + } + } + + self.ibox(0); + self.word(prefix); + + let content = &line[prefix.len()..]; + let content = if is_doc { + // Doc comments preserve leading whitespaces (right after the prefix) as nbps. + let ws_len = content + .char_indices() + .take_while(|(_, c)| c.is_whitespace()) + .last() + .map_or(0, |(idx, c)| idx + c.len_utf8()); + let (leading_ws, rest) = content.split_at(ws_len); + if !leading_ws.is_empty() { + self.word(leading_ws.to_owned()); + } + rest + } else { + // Non-doc comments: replace first whitespace with nbsp, rest of content continues + if let Some(first_char) = content.chars().next() { + if first_char.is_whitespace() { + self.nbsp(); + &content[first_char.len_utf8()..] + } else { + content + } + } else { + "" + } + }; + + let post_break = post_break_prefix(prefix, !content.is_empty()); + + // Process content character by character to preserve consecutive whitespaces + let (mut chars, mut current_word) = (content.chars().peekable(), String::new()); + while let Some(ch) = chars.next() { + if ch.is_whitespace() { + // Print current word + if !current_word.is_empty() { + self.word(std::mem::take(&mut current_word)); + } + + // Preserve multiple spaces while adding a single break + let mut ws_count = 1; + while chars.peek().is_some_and(|c| c.is_whitespace()) { + ws_count += 1; + chars.next(); + } + self.s.scan_break(BreakToken { + offset: break_offset, + blank_space: ws_count, + post_break: if post_break.starts_with("/*") { None } else { Some(post_break) }, + ..Default::default() + }); + continue; + } + + current_word.push(ch); + } + + // Print final word + if !current_word.is_empty() { + self.word(current_word); + } + + self.end(); + } + + /// Merges consecutive line comments to avoid orphan words. + fn merge_comment_lines(&self, lines: &[String], prefix: &str) -> Vec { + // Do not apply smart merging to block comments + if lines.is_empty() || lines.len() < 2 || !prefix.starts_with("//") { + return lines.to_vec(); + } + + let mut result = Vec::new(); + let mut i = 0; + + while i < lines.len() { + let current_line = &lines[i]; + + // Keep empty lines, and non-prefixed lines, untouched + if current_line.trim().is_empty() || !current_line.starts_with(prefix) { + result.push(current_line.clone()); + i += 1; + continue; + } + + if i + 1 < lines.len() { + let next_line = &lines[i + 1]; + + // Check if next line has the same prefix and is not empty. + if next_line.starts_with(prefix) && !next_line.trim().is_empty() { + let next_content = next_line[prefix.len()..].trim_start(); + + // Keep each NatSpec tag on its own doc-comment line. Merging a wrapped + // `@dev`/`@param` line with the following tag changes the tag boundary. + if next_content.starts_with('@') { + result.push(current_line.clone()); + i += 1; + continue; + } + + // Only merge if the current line doesn't fit within available width + if estimate_line_width(current_line, self.config.tab_width) > self.space_left() + { + // Merge the lines and let the wrapper handle breaking if needed + let merged_line = format!("{current_line} {next_content}"); + result.push(merged_line); + + // Skip both lines since they are merged + i += 2; + continue; + } + } + } + + // No merge possible, keep the line as-is + result.push(current_line.clone()); + i += 1; + } + + result + } + + fn print_comment(&mut self, mut cmnt: Comment, mut config: CommentConfig) { + self.cursor.advance_to(cmnt.span.hi(), true); + + if cmnt.is_doc { + cmnt = style_doc_comment(self.config.docs_style, cmnt); + } + + match cmnt.style { + CommentStyle::Mixed => { + let Some(prefix) = cmnt.prefix() else { return }; + let never_break = self.last_token_is_neverbreak(); + let starts_line = self.is_bol_or_only_ind() || self.last_token_is_break(); + if !self.is_bol_or_only_ind() { + match (never_break || config.mixed_no_break_prev, config.mixed_prev_space) { + (false, true) => config.space(&mut self.s), + (false, false) => config.zerobreak(&mut self.s), + (true, true) => self.nbsp(), + (true, false) => (), + }; + } + if self.config.wrap_comments { + // Merge and wrap comments + if starts_line { + self.ibox(config.offset); + } + let merged_lines = self.merge_comment_lines(&cmnt.lines, prefix); + for (pos, line) in merged_lines.into_iter().delimited() { + self.print_wrapped_line(&line, prefix, 0, cmnt.is_doc); + if !pos.is_last { + self.hardbreak(); + } + } + if starts_line { + self.end(); + } + } else { + // Match the opening-column normalization of continuation lines. + self.visual_align(); + for (pos, line) in cmnt.lines.into_iter().delimited() { + if !line.is_empty() { + self.word(line); + } + if !pos.is_last { + self.hardbreak(); + } + } + self.end(); + } + if config.mixed_post_nbsp { + if config.mixed_post_glued { + self.nbsp(); + } else { + config.nbsp_or_space(self.config.wrap_comments, &mut self.s); + } + self.cursor.advance(1); + } else if !config.mixed_no_break_post { + config.space(&mut self.s); + self.cursor.advance(1); + } + } + CommentStyle::Isolated => { + let Some(mut prefix) = cmnt.prefix() else { return }; + if !config.iso_no_break { + config.hardbreak_if_not_bol(self.is_bol_or_only_ind(), &mut self.s); + } + + if self.config.wrap_comments { + // Merge and wrap comments + let merged_lines = self.merge_comment_lines(&cmnt.lines, prefix); + for (pos, line) in merged_lines.into_iter().delimited() { + let hb = |this: &mut Self| { + this.hardbreak(); + if pos.is_last { + this.cursor.next_line(this.is_at_crlf()); + } + }; + if line.is_empty() { + hb(self); + continue; + } + if pos.is_first { + self.ibox(config.offset); + if cmnt.is_doc && matches!(prefix, "/**") { + self.word(prefix); + hb(self); + prefix = " * "; + continue; + } + } + + self.print_wrapped_line(&line, prefix, 0, cmnt.is_doc); + + if pos.is_last { + self.end(); + if !config.iso_no_break { + hb(self); + } + } else { + hb(self); + } + } + } else { + // No wrapping, print as-is + for (pos, line) in cmnt.lines.into_iter().delimited() { + let hb = |this: &mut Self| { + this.hardbreak(); + if pos.is_last { + this.cursor.next_line(this.is_at_crlf()); + } + }; + if line.is_empty() { + hb(self); + continue; + } + if pos.is_first { + self.ibox(config.offset); + if cmnt.is_doc && matches!(prefix, "/**") { + self.word(prefix); + hb(self); + prefix = " * "; + continue; + } + } + + self.word(line); + + if pos.is_last { + self.end(); + if !config.iso_no_break { + hb(self); + } + } else { + hb(self); + } + } + } + } + CommentStyle::Trailing => { + let Some(prefix) = cmnt.prefix() else { return }; + self.neverbreak(); + if !self.is_bol_or_only_ind() { + self.nbsp(); + } + + if !self.config.wrap_comments && cmnt.lines.len() == 1 { + self.word(cmnt.lines.pop().unwrap()); + } else if self.config.wrap_comments { + if cmnt.is_doc || matches!(cmnt.kind, ast::CommentKind::Line) { + config.offset = 0; + } else { + config.offset = self.ind; + } + for (lpos, line) in cmnt.lines.into_iter().delimited() { + if !line.is_empty() { + self.print_wrapped_line(&line, prefix, config.offset, cmnt.is_doc); + } + if !lpos.is_last { + config.hardbreak(&mut self.s); + } + } + } else { + self.visual_align(); + for (pos, line) in cmnt.lines.into_iter().delimited() { + if !line.is_empty() { + self.word(line); + if !pos.is_last { + self.hardbreak(); + } + } + } + self.end(); + } + + if !config.trailing_no_break { + self.print_sep(Separator::Hardbreak); + } + } + + CommentStyle::BlankLine => { + // Pre-requisite: ensure that blank links are printed at the beginning of new line. + if !self.last_token_is_break() && !self.is_bol_or_only_ind() { + config.hardbreak(&mut self.s); + self.cursor.next_line(self.is_at_crlf()); + } + + // We need to do at least one, possibly two hardbreaks. + let twice = match self.last_token() { + Some(Token::String(s)) => ";" == s, + Some(Token::Begin(_)) => true, + Some(Token::End) => true, + _ => false, + }; + if twice { + config.hardbreak(&mut self.s); + self.cursor.next_line(self.is_at_crlf()); + } + config.hardbreak(&mut self.s); + self.cursor.next_line(self.is_at_crlf()); + } + } + } + + fn peek_comment<'b>(&'b self) -> Option<&'b Comment> + where + 'sess: 'b, + { + self.comments.peek() + } + + fn peek_comment_before<'b>(&'b self, pos: BytePos) -> Option<&'b Comment> + where + 'sess: 'b, + { + self.comments.iter().take_while(|c| c.pos() < pos).find(|c| !c.style.is_blank()) + } + + /// Returns `true` if the next comment is a mixed comment that starts before the given + /// position. + fn peek_mixed_comment_before(&self, pos: Option) -> bool { + pos.is_some_and(|pos| { + self.peek_comment().is_some_and(|cmnt| cmnt.pos() < pos && cmnt.style.is_mixed()) + }) + } + + fn has_comment_before_with(&self, pos: BytePos, f: F) -> bool + where + F: FnMut(&Comment) -> bool, + { + self.comments.iter().take_while(|c| c.pos() < pos).any(f) + } + + fn peek_comment_between<'b>(&'b self, pos_lo: BytePos, pos_hi: BytePos) -> Option<&'b Comment> + where + 'sess: 'b, + { + self.comments + .iter() + .skip_while(|c| c.pos() < pos_lo) + .take_while(|c| c.pos() < pos_hi) + .find(|c| !c.style.is_blank()) + } + + fn has_comment_between(&self, start_pos: BytePos, end_pos: BytePos) -> bool { + self.comments.iter().filter(|c| c.pos() > start_pos && c.pos() < end_pos).any(|_| true) + } + + fn has_breakable_comment_between(&self, start_pos: BytePos, end_pos: BytePos) -> bool { + self.comments + .iter() + .filter(|comment| comment.pos() >= start_pos && comment.pos() < end_pos) + .any(|comment| !comment.style.is_blank()) + } + + pub(crate) fn next_comment(&mut self) -> Option { + self.comments.next() + } + + fn peek_trailing_comment<'b>( + &'b self, + span_pos: BytePos, + next_pos: Option, + ) -> Option<&'b Comment> + where + 'sess: 'b, + { + self.comments.peek_trailing_in_file(self.file, span_pos, next_pos).map(|(cmnt, _)| cmnt) + } + + fn print_trailing_comment_inner( + &mut self, + span_pos: BytePos, + next_pos: Option, + config: Option, + ) -> bool { + let mut printed = 0; + if let Some((_, n)) = self.comments.peek_trailing_in_file(self.file, span_pos, next_pos) { + let config = + config.unwrap_or(CommentConfig::skip_ws().mixed_no_break().mixed_prev_space()); + while printed <= n { + let cmnt = self.comments.next().unwrap(); + if let Some(cmnt) = self.handle_comment(cmnt, config.trailing_no_break) { + self.print_comment(cmnt, config); + }; + printed += 1; + } + } + printed != 0 + } + + fn print_trailing_comment(&mut self, span_pos: BytePos, next_pos: Option) -> bool { + self.print_trailing_comment_inner(span_pos, next_pos, None) + } + + fn print_trailing_comment_no_break(&mut self, span_pos: BytePos, next_pos: Option) { + self.print_trailing_comment_inner( + span_pos, + next_pos, + Some(CommentConfig::skip_ws().trailing_no_break().mixed_no_break().mixed_prev_space()), + ); + } + + fn print_remaining_comments(&mut self, skip_leading_ws: bool) { + // If there aren't any remaining comments, then we need to manually + // make sure there is a line break at the end. + if self.peek_comment().is_none() && !self.is_bol_or_only_ind() { + self.hardbreak(); + return; + } + + let mut is_leading = true; + while let Some(cmnt) = self.next_comment() { + if cmnt.style.is_blank() && skip_leading_ws && is_leading { + continue; + } + + is_leading = false; + if let Some(cmnt) = self.handle_comment(cmnt, false) { + self.print_comment(cmnt, CommentConfig::default()); + } else if self.peek_comment().is_none() && !self.is_bol_or_only_ind() { + self.hardbreak(); + } + } + } +} + +#[derive(Clone, Copy)] +enum Skip { + All, + Leading { resettable: bool }, + Trailing, +} + +#[derive(Default, Clone, Copy)] +pub(crate) struct CommentConfig { + // Config: all + skip_blanks: Option, + offset: isize, + + // Config: isolated comments + iso_no_break: bool, + // Config: trailing comments + trailing_no_break: bool, + // Config: mixed comments + mixed_prev_space: bool, + mixed_post_nbsp: bool, + /// Makes `mixed_post_nbsp` emit a hard space even with `wrap_comments`, which would + /// otherwise use a breakable one. Required when the comment is glued to a closing token, as + /// a break in between detaches it and reclassifies the comment on the next run. + mixed_post_glued: bool, + mixed_no_break_prev: bool, + mixed_no_break_post: bool, +} + +impl CommentConfig { + pub(crate) fn skip_ws() -> Self { + Self { skip_blanks: Some(Skip::All), ..Default::default() } + } + + /// Config for comments that are the sole content of an otherwise empty block, so that they + /// are surrounded by spaces: `{ /* comment */ }`. + pub(crate) fn empty_block() -> Self { + Self::skip_ws().mixed_no_break().mixed_prev_space().mixed_post_glued() + } + + pub(crate) fn skip_leading_ws(resettable: bool) -> Self { + Self { skip_blanks: Some(Skip::Leading { resettable }), ..Default::default() } + } + + pub(crate) fn skip_trailing_ws() -> Self { + Self { skip_blanks: Some(Skip::Trailing), ..Default::default() } + } + + pub(crate) const fn offset(mut self, off: isize) -> Self { + self.offset = off; + self + } + + pub(crate) const fn no_breaks(mut self) -> Self { + self.iso_no_break = true; + self.trailing_no_break = true; + self.mixed_no_break_prev = true; + self.mixed_no_break_post = true; + self + } + + pub(crate) const fn trailing_no_break(mut self) -> Self { + self.trailing_no_break = true; + self + } + + pub(crate) const fn mixed_no_break(mut self) -> Self { + self.mixed_no_break_prev = true; + self.mixed_no_break_post = true; + self + } + + pub(crate) const fn mixed_no_break_post(mut self) -> Self { + self.mixed_no_break_post = true; + self + } + + pub(crate) const fn mixed_prev_space(mut self) -> Self { + self.mixed_prev_space = true; + self + } + + pub(crate) const fn mixed_post_nbsp(mut self) -> Self { + self.mixed_post_nbsp = true; + self + } + + pub(crate) const fn mixed_post_glued(mut self) -> Self { + self.mixed_post_nbsp = true; + self.mixed_post_glued = true; + self + } + + pub(crate) fn hardbreak_if_not_bol(&self, is_bol: bool, p: &mut pp::Printer) { + if self.offset != 0 && !is_bol { + self.hardbreak(p); + } else { + p.hardbreak_if_not_bol(); + } + } + + pub(crate) fn hardbreak(&self, p: &mut pp::Printer) { + p.break_offset(SIZE_INFINITY as usize, self.offset); + } + + pub(crate) fn space(&self, p: &mut pp::Printer) { + p.break_offset(1, self.offset); + } + + pub(crate) fn nbsp_or_space(&self, breaks: bool, p: &mut pp::Printer) { + if breaks { + self.space(p); + } else { + p.nbsp(); + } + } + + pub(crate) fn zerobreak(&self, p: &mut pp::Printer) { + p.break_offset(0, self.offset); + } +} + +fn snippet_with_tabs(s: String, tab_width: usize) -> String { + let mut formatted = String::with_capacity(s.len()); + for line in s.split_inclusive('\n') { + let (line, has_newline) = + line.strip_suffix('\n').map_or((line, false), |line| (line, true)); + let line = line.strip_suffix('\r').unwrap_or(line); + line_with_tabs(&mut formatted, line, tab_width, None); + if has_newline { + formatted.push('\n'); + } + } + + formatted +} + +/// Formats a doc comment with the requested style. +/// +/// NOTE: assumes comments have already been normalized. +fn style_doc_comment(style: DocCommentStyle, mut cmnt: Comment) -> Comment { + match style { + DocCommentStyle::Line if cmnt.kind == ast::CommentKind::Block => { + let mut new_lines = Vec::new(); + for (pos, line) in cmnt.lines.iter().delimited() { + if pos.is_first || pos.is_last { + // Skip the opening '/**' and closing '*/' lines + continue; + } + + // Convert ' * {content}' to '/// {content}' + let trimmed = line.trim_start(); + if let Some(content) = trimmed.strip_prefix('*') { + new_lines.push(format!("///{content}")); + } else if !trimmed.is_empty() { + new_lines.push(format!("/// {trimmed}")); + } + } + + cmnt.lines = new_lines; + cmnt.kind = ast::CommentKind::Line; + cmnt + } + DocCommentStyle::Block if cmnt.kind == ast::CommentKind::Line => { + let mut new_lines = vec!["/**".to_string()]; + + for line in &cmnt.lines { + // Convert '/// {content}' to ' * {content}' + new_lines.push(format!(" *{content}", content = &line[3..])) + } + + new_lines.push(" */".to_string()); + cmnt.lines = new_lines; + cmnt.kind = ast::CommentKind::Block; + cmnt + } + // Otherwise, no conversion needed. + _ => cmnt, + } +} diff --git a/crates/fmt/src/state/sol.rs b/crates/fmt/src/state/sol.rs new file mode 100644 index 0000000000000..4121627d99130 --- /dev/null +++ b/crates/fmt/src/state/sol.rs @@ -0,0 +1,3597 @@ +#![allow(clippy::too_many_arguments)] + +use super::{ + ChainedNamedCall, CommentConfig, Separator, State, + common::{BlockFormat, ListFormat}, +}; +use crate::{ + pp::SIZE_INFINITY, + state::{CallContext, common::LitExt}, +}; +use foundry_common::{comments::Comment, iter::IterDelimited}; +use foundry_config::fmt::{self as config, MultilineFuncHeaderStyle}; +use solar::{ + ast::BoxSlice, + interface::SpannedOption, + parse::{ + ast::{self, Span}, + interface::BytePos, + }, +}; +use std::{collections::HashMap, fmt::Debug}; + +#[rustfmt::skip] +macro_rules! get_span { + () => { |value| value.span }; + (()) => { |value| value.span() }; +} + +/// Language-specific pretty printing: Solidity. +impl<'ast> State<'_, 'ast> { + pub(crate) fn print_source_unit(&mut self, source_unit: &'ast ast::SourceUnit<'ast>) { + // Figure out if the cursor needs to check for CR (`\r`). + if let Some(item) = source_unit.items.first() { + self.check_crlf(item.span.to(source_unit.items.last().unwrap().span)); + } + + let mut items = source_unit.items.iter().peekable(); + let mut is_first = true; + while let Some(item) = items.next() { + // If imports shouldn't be sorted, or if the item is not an import, print it directly. + if !self.config.sort_imports || !matches!(item.kind, ast::ItemKind::Import(_)) { + self.print_item(item, is_first); + is_first = false; + if let Some(next_item) = items.peek() { + self.separate_items(next_item, false); + } + continue; + } + + // Otherwise, collect a group of consecutive imports and sort them before printing. + let mut import_group = vec![item]; + while let Some(next_item) = items.peek() { + // Groups end when the next item is not an import or when there is a blank line. + if !matches!(next_item.kind, ast::ItemKind::Import(_)) + || self.has_comment_between(item.span.hi(), next_item.span.lo()) + { + break; + } + import_group.push(items.next().unwrap()); + } + + import_group.sort_by_key(|item| { + if let ast::ItemKind::Import(import) = &item.kind { + import.path.value.as_str() + } else { + unreachable!("Expected an import item") + } + }); + + for (pos, group_item) in import_group.iter().delimited() { + self.print_item(group_item, is_first); + is_first = false; + + if !pos.is_last { + self.hardbreak_if_not_bol(); + } + } + if let Some(next_item) = items.peek() { + self.separate_items(next_item, false); + } + } + + self.print_remaining_comments(is_first); + } + + /// Prints a hardbreak if the item needs an isolated line break. + fn separate_items(&mut self, next_item: &'ast ast::Item<'ast>, advance: bool) { + if !item_needs_iso(&next_item.kind) { + return; + } + // Never isolate items within a `disable-start`/`disable-end` region, where the source + // layout is preserved verbatim. The cursor sits right past the line break that follows the + // previous item, so check the byte that was last copied from the source. Line-based + // directives such as `disable-line` only opt out of formatting that line's contents, so + // they keep the isolation break. + if self.cursor.pos > BytePos(0) + && self + .inline_config + .is_disabled_block(Span::new(self.cursor.pos - BytePos(1), self.cursor.pos)) + { + return; + } + let span = next_item.span; + + let cmnts = self + .comments + .iter() + .filter_map(|c| (c.pos() < span.lo()).then_some(c.style)) + .collect::>(); + + if let Some(first) = cmnts.first() + && let Some(last) = cmnts.last() + { + if !(first.is_blank() || last.is_blank()) { + self.hardbreak(); + return; + } + if advance { + if self.peek_comment_before(span.lo()).is_some() { + self.print_comments(span.lo(), CommentConfig::default()); + } else if self.inline_config.is_disabled(span.shrink_to_lo()) { + self.hardbreak(); + self.cursor.advance_to(span.lo(), true); + } + } + } else { + self.hardbreak(); + } + } + + fn print_item(&mut self, item: &'ast ast::Item<'ast>, skip_ws: bool) { + let ast::Item { ref docs, span, ref kind } = *item; + self.print_docs(docs); + + // The comments preceding the item are printed before checking whether it is disabled, + // because printing a disabled item copies the source verbatim and drops every comment + // that ends before it. + let cmnt = self.print_comments( + span.lo(), + if skip_ws { CommentConfig::skip_leading_ws(false) } else { CommentConfig::default() }, + ); + + if self.print_span_if_disabled(span) { + if !self.print_trailing_comment(span.hi(), None) { + self.print_sep(Separator::Hardbreak); + } + return; + } + + if cmnt.is_some_and(|cmnt| cmnt.is_mixed()) { + self.zerobreak(); + } + + match kind { + ast::ItemKind::Pragma(pragma) => self.print_pragma(pragma), + ast::ItemKind::Import(import) => self.print_import(import), + ast::ItemKind::Using(using) => self.print_using(using), + ast::ItemKind::Contract(contract) => self.print_contract(contract, span), + ast::ItemKind::Function(func) => self.print_function(func), + ast::ItemKind::Variable(var) => self.print_var_def(var), + ast::ItemKind::Struct(strukt) => self.print_struct(strukt, span), + ast::ItemKind::Enum(enm) => self.print_enum(enm, span), + ast::ItemKind::Udvt(udvt) => self.print_udvt(udvt), + ast::ItemKind::Error(err) => self.print_error(err), + ast::ItemKind::Event(event) => self.print_event(event), + } + + self.cursor.advance_to(span.hi(), true); + self.print_comments(span.hi(), CommentConfig::default()); + self.print_trailing_comment(span.hi(), None); + self.hardbreak_if_not_bol(); + self.cursor_next_line(); + } + + fn print_pragma(&mut self, pragma: &'ast ast::PragmaDirective<'ast>) { + self.word("pragma "); + match &pragma.tokens { + ast::PragmaTokens::Version(ident, semver_req) => { + self.print_ident(ident); + self.nbsp(); + self.word(semver_req.to_string()); + } + ast::PragmaTokens::Custom(a, b) => { + self.print_ident_or_strlit(a); + if let Some(b) = b { + self.nbsp(); + self.print_ident_or_strlit(b); + } + } + ast::PragmaTokens::Verbatim(tokens) => { + self.print_tokens(tokens); + } + } + self.word(";"); + } + + fn print_commasep_aliases<'a, I>(&mut self, aliases: I) + where + I: Iterator)>, + 'ast: 'a, + { + for (pos, (ident, alias)) in aliases.delimited() { + self.print_ident(ident); + if let Some(alias) = alias { + self.word(" as "); + self.print_ident(alias); + } + if !pos.is_last { + self.word(","); + self.space(); + } + } + } + + fn print_import(&mut self, import: &'ast ast::ImportDirective<'ast>) { + let ast::ImportDirective { path, items } = import; + self.word("import "); + + use ast::ImportItems; + use config::NamespaceImportStyle as NIStyle; + + match (items, self.config.namespace_import_style) { + (ImportItems::Plain(None), _) => { + self.print_ast_str_lit(path); + } + + (ImportItems::Plain(Some(source_alias)), NIStyle::Preserve | NIStyle::PreferPlain) + | (ImportItems::Glob(source_alias), NIStyle::PreferPlain) => { + self.print_ast_str_lit(path); + self.word(" as "); + self.print_ident(source_alias); + } + + (ImportItems::Glob(source_alias), NIStyle::Preserve | NIStyle::PreferGlob) + | (ImportItems::Plain(Some(source_alias)), NIStyle::PreferGlob) => { + self.word("*"); + self.word(" as "); + self.print_ident(source_alias); + self.word(" from "); + self.print_ast_str_lit(path); + } + + (ImportItems::Aliases(aliases), _) => { + // Check if we should keep single imports on one line + let use_single_line = self.config.single_line_imports && aliases.len() == 1; + + if use_single_line { + self.word("{"); + if self.config.bracket_spacing { + self.nbsp(); + } + } else { + self.s.cbox(self.ind); + self.word("{"); + self.braces_break(); + } + + if self.config.sort_imports { + let mut sorted: Vec<_> = aliases.iter().collect(); + sorted.sort_by_key(|(ident, _alias)| ident.name.as_str()); + self.print_commasep_aliases(sorted.into_iter()); + } else { + self.print_commasep_aliases(aliases.iter()); + }; + + if use_single_line { + if self.config.bracket_spacing { + self.nbsp(); + } + self.word("}"); + } else { + self.braces_break(); + self.s.offset(-self.ind); + self.word("}"); + self.end(); + } + self.word(" from "); + self.print_ast_str_lit(path); + } + } + self.word(";"); + } + + fn print_using(&mut self, using: &'ast ast::UsingDirective<'ast>) { + let ast::UsingDirective { list, ty, global } = using; + self.word("using "); + match list { + ast::UsingList::Single(path) => self.print_path(path, true), + ast::UsingList::Multiple(items) => { + self.s.cbox(self.ind); + self.word("{"); + self.braces_break(); + for (pos, (path, op)) in items.iter().delimited() { + self.print_path(path, true); + if let Some(op) = op { + self.word(" as "); + self.word(op.to_str()); + } + if !pos.is_last { + self.word(","); + self.space(); + } + } + self.braces_break(); + self.s.offset(-self.ind); + self.word("}"); + self.end(); + } + } + self.word(" for "); + if let Some(ty) = ty { + self.print_ty(ty); + } else { + self.word("*"); + } + if *global { + self.word(" global"); + } + self.word(";"); + } + + fn print_contract(&mut self, c: &'ast ast::ItemContract<'ast>, span: Span) { + let ast::ItemContract { kind, name, layout, bases, body } = c; + self.contract = Some(c); + self.cursor.advance_to(span.lo(), true); + + // Position of the body's opening brace, needed to identify the comments that belong to + // the contract header. The `is` and `layout` clauses can appear in either order, so the + // header ends at whichever clause ends last. + let header_hi = bases + .last() + .map(|base| base.span().hi()) + .max(layout.as_ref().map(|layout| layout.span.hi())) + .unwrap_or(name.span.hi()); + let body_lo = body.first().map_or(span.hi(), |item| item.span.lo()); + let brace = self.find_opening_brace(Span::new(header_hi, body_lo)); + + self.s.cbox(self.ind); + self.ibox(0); + self.cbox(0); + self.word_nbsp(kind.to_str()); + self.print_ident(name); + self.nbsp(); + + if let Some(layout) = layout + && !self.handle_span(layout.span, false) + { + self.word("layout at "); + self.print_expr(layout.slot); + let breaks = !bases.is_empty() || !self.peek_mixed_comment_before(brace); + self.print_sep(Separator::SpaceOrNbsp(breaks)); + } + + if let Some(first) = bases.first().map(|base| base.span()) + && let Some(last) = bases.last().map(|base| base.span()) + && self.inline_config.is_disabled(first.to(last)) + { + _ = self.handle_span(first.until(last), false); + } else if !bases.is_empty() { + self.word("is"); + self.space(); + let last = bases.len() - 1; + for (i, base) in bases.iter().enumerate() { + if !self.handle_span(base.span(), false) { + self.print_modifier_call(base, false); + if i != last { + self.word(","); + if self + .print_comments( + bases[i + 1].span().lo(), + CommentConfig::skip_ws().mixed_prev_space().mixed_post_nbsp(), + ) + .is_none() + { + self.space(); + } + } + } + } + if self.print_trailing_comment(bases.last().unwrap().span().hi(), None) { + self.s.offset(-self.ind); + } else if self.peek_mixed_comment_before(brace) { + self.nbsp(); + } else { + self.space(); + self.s.offset(-self.ind); + } + } + + // Print the comments preceding the opening brace, otherwise they get relocated into the + // contract body. They are glued to both the header and the brace, as breaking them apart + // turns them into trailing comments, which are relocated again on the next run. + while self.peek_mixed_comment_before(brace) { + let cmnt = self.next_comment().unwrap(); + if let Some(cmnt) = self.handle_comment(cmnt, true) { + self.print_comment(cmnt, CommentConfig::skip_ws().mixed_no_break()); + } + self.nbsp(); + } + self.end(); + + self.print_word("{"); + self.end(); + if body.is_empty() { + match self.print_comments(span.hi(), CommentConfig::empty_block()) { + // Adjust the offset of the trailing break from comment printing + // so the closing brace is not indented + Some(_) if self.last_token_is_break() => self.s.offset(-self.ind), + Some(_) => {} + None if self.config.bracket_spacing => self.nbsp(), + None => {} + } + self.end(); + } else { + // update block depth + self.block_depth += 1; + + self.print_sep(Separator::Hardbreak); + if self.config.contract_new_lines { + self.hardbreak(); + } + let body_lo = body[0].span.lo(); + if self.peek_comment_before(body_lo).is_some() { + self.print_comments(body_lo, CommentConfig::skip_leading_ws(true)); + } + + let mut is_first = true; + let mut items = body.iter().peekable(); + while let Some(item) = items.next() { + self.print_item(item, is_first); + is_first = false; + if let Some(next_item) = items.peek() { + if self.inline_config.is_disabled(next_item.span) { + _ = self.handle_span(next_item.span, false); + } else { + self.separate_items(next_item, true); + } + } + } + + let cmnt = self.print_comments(span.hi(), CommentConfig::skip_trailing_ws()); + let mut glued = false; + if self.last_token_is_break() { + if self.config.contract_new_lines && cmnt.is_some_and(|cmnt| !cmnt.is_blank()) { + self.print_sep(Separator::Hardbreak); + } + self.s.offset(-self.ind); + } else { + glued = self.glue_brace_to_trailing_comments(cmnt.is_some()); + } + self.end(); + if self.config.contract_new_lines && !glued { + self.hardbreak_if_nonempty(); + } + + // restore block depth + self.block_depth -= 1; + } + // The cursor is updated with the actual span; a disabled trailing comment of the last item + // may have already consumed source beyond the closing brace. + self.word("}"); + + self.cursor.advance_to(span.hi(), true); + self.contract = None; + } + + /// Glues the closing brace of an item body to a trailing run of mixed comments. + /// + /// A trailing run of mixed comments ends in a string token; a break in between would + /// reclassify the last comment on the next run, so the brace is glued with a hard space. + /// Bodies that end with a pending break (the caller adjusts its offset instead), an existing + /// space, or verbatim source that already broke the line are left unchanged. + /// + /// Returns `true` if the brace was glued. + fn glue_brace_to_trailing_comments(&mut self, printed: bool) -> bool { + if printed + && !self.last_token_is_break() + && !self.last_token_is_space() + && !self.is_beginning_of_line() + { + self.nbsp(); + return true; + } + false + } + + fn print_struct(&mut self, strukt: &'ast ast::ItemStruct<'ast>, span: Span) { + let ast::ItemStruct { name, fields } = strukt; + let ind = if self.estimate_size(name.span) + 8 >= self.space_left() { self.ind } else { 0 }; + self.s.ibox(self.ind); + self.word("struct"); + self.space(); + self.print_ident(name); + self.word(" {"); + if !fields.is_empty() { + self.break_offset(SIZE_INFINITY as usize, ind); + } + self.s.ibox(0); + for var in fields.iter() { + self.print_var_def(var); + if !self.print_trailing_comment(var.span.hi(), None) { + self.hardbreak(); + } + } + let cmnt_config = + if fields.is_empty() { CommentConfig::empty_block() } else { CommentConfig::skip_ws() }; + let printed = self.print_comments(span.hi(), cmnt_config).is_some(); + if self.last_token_is_break() { + if ind == 0 { + self.s.offset(-self.ind); + } + } else { + self.glue_brace_to_trailing_comments(printed); + } + self.end(); + self.end(); + self.word("}"); + } + + fn print_enum(&mut self, enm: &'ast ast::ItemEnum<'ast>, span: Span) { + let ast::ItemEnum { name, variants } = enm; + self.s.cbox(self.ind); + self.word("enum "); + self.print_ident(name); + self.word(" {"); + self.hardbreak_if_nonempty(); + let mut printed = false; + for (pos, ident) in variants.iter().delimited() { + self.print_comments(ident.span.lo(), CommentConfig::default()); + self.print_ident(ident); + if !pos.is_last { + self.word(","); + } + printed = self.print_trailing_comment(ident.span.hi(), None); + if !printed { + self.hardbreak(); + } + } + if self.print_comments(span.hi(), CommentConfig::skip_ws()).is_some() { + printed = true; + } + if self.last_token_is_break() { + self.s.offset(-self.ind); + } else { + self.glue_brace_to_trailing_comments(printed); + } + self.end(); + self.word("}"); + } + + fn print_udvt(&mut self, udvt: &'ast ast::ItemUdvt<'ast>) { + let ast::ItemUdvt { name, ty } = udvt; + self.word("type "); + self.print_ident(name); + self.word(" is "); + self.print_ty(ty); + self.word(";"); + } + + // NOTE(rusowsky): Functions are the only source unit item that handle inline (disabled) format + fn print_function(&mut self, func: &'ast ast::ItemFunction<'ast>) { + let ast::ItemFunction { kind, ref header, ref body, body_span } = *func; + let ast::FunctionHeader { + name, + ref parameters, + visibility, + state_mutability: sm, + virtual_, + ref override_, + ref returns, + .. + } = *header; + + self.s.cbox(self.ind); + + // Print fn name and params + _ = self.handle_span(self.cursor.span(header.span.lo()), false); + self.print_word(kind.to_str()); + if let Some(name) = name { + self.print_sep(Separator::Nbsp); + self.print_ident(&name); + self.cursor.advance_to(name.span.hi(), true); + } + self.s.cbox(-self.ind); + let header_style = self.config.multiline_func_header; + let params_format = match header_style { + MultilineFuncHeaderStyle::ParamsAlways => ListFormat::always_break(), + MultilineFuncHeaderStyle::All + if header.parameters.len() > 1 && !self.can_header_be_inlined(func) => + { + ListFormat::always_break() + } + MultilineFuncHeaderStyle::AllParams + if !header.parameters.is_empty() && !self.can_header_be_inlined(func) => + { + ListFormat::always_break() + } + _ => ListFormat::consistent().break_cmnts().break_single( + // ensure fn params are always breakable when there is a single `Contract.Struct` + parameters.len() == 1 + && matches!( + ¶meters[0].ty, + ast::Type { kind: ast::TypeKind::Custom(ty), .. } if ty.segments().len() > 1 + ), + ), + }; + self.print_parameter_list(parameters, parameters.span, params_format); + self.end(); + + // Map attributes to their corresponding comments + let (mut map, attributes, first_attrib_pos) = + AttributeCommentMapper::new(returns.as_ref(), body_span.lo()).build(self, header); + + let mut handle_pre_cmnts = |this: &mut Self, span: Span| -> bool { + if this.inline_config.is_disabled(span) + // Note: `map` is still captured from the outer scope, which is fine. + && let Some((pre_cmnts, ..)) = map.remove(&span.lo()) + { + for (pos, cmnt) in pre_cmnts.into_iter().delimited() { + if pos.is_first && cmnt.style.is_isolated() && !this.is_bol_or_only_ind() { + this.print_sep(Separator::Hardbreak); + } + if let Some(cmnt) = this.handle_comment(cmnt, false) { + this.print_comment(cmnt, CommentConfig::skip_ws().mixed_post_nbsp()); + } + if pos.is_last { + return true; + } + } + } + false + }; + + let skip_attribs = returns.as_ref().is_some_and(|ret| { + let attrib_span = Span::new(first_attrib_pos, ret.span.lo()); + handle_pre_cmnts(self, attrib_span); + self.handle_span(attrib_span, false) + }); + let skip_returns = { + let pos = if skip_attribs { self.cursor.pos } else { first_attrib_pos }; + let ret_span = Span::new(pos, body_span.lo()); + handle_pre_cmnts(self, ret_span); + self.handle_span(ret_span, false) + }; + + let attrib_box = self.config.multiline_func_header.params_first() + || (self.config.multiline_func_header.attrib_first() + && !self.can_header_params_be_inlined(func)); + if attrib_box { + self.s.cbox(0); + } + if !(skip_attribs || skip_returns) { + // Print fn attributes in correct order + if let Some(v) = visibility { + self.print_fn_attribute(v.span, &mut map, &mut |s| s.word(v.to_str())); + } + if let Some(sm) = sm + && !matches!(*sm, ast::StateMutability::NonPayable) + { + self.print_fn_attribute(sm.span, &mut map, &mut |s| s.word(sm.to_str())); + } + if let Some(v) = virtual_ { + self.print_fn_attribute(v, &mut map, &mut |s| s.word("virtual")); + } + if let Some(o) = override_ { + self.print_fn_attribute(o.span, &mut map, &mut |s| s.print_override(o)); + } + for m in attributes.iter().filter(|a| matches!(a.kind, AttributeKind::Modifier(_))) { + if let AttributeKind::Modifier(modifier) = m.kind { + let is_base = self.is_modifier_a_base_contract(kind, modifier); + self.print_fn_attribute(m.span, &mut map, &mut |s| { + s.print_modifier_call(modifier, is_base) + }); + } + } + } + if !skip_returns + && let Some(ret) = returns + && !ret.is_empty() + { + if !self.handle_span(self.cursor.span(ret.span.lo()), false) { + if !self.is_bol_or_only_ind() && !self.last_token_is_space() { + self.print_sep(Separator::Space); + } + self.cursor.advance_to(ret.span.lo(), true); + self.print_word("returns "); + } + self.print_parameter_list( + ret, + ret.span, + ListFormat::consistent(), // .with_cmnts_break(false), + ); + } + + // Print fn body + if let Some(body) = body { + if self.handle_span(self.cursor.span(body_span.lo()), false) { + // Print spacing if necessary. Updates cursor. + } else { + if let Some(cmnt) = self.peek_comment_before(body_span.lo()) { + if cmnt.style.is_mixed() { + // These shouldn't update the cursor, as we've already dealt with it above + self.space(); + self.s.offset(-self.ind); + self.print_comments(body_span.lo(), CommentConfig::skip_ws()); + } else { + self.zerobreak(); + self.s.offset(-self.ind); + self.print_comments(body_span.lo(), CommentConfig::skip_ws()); + self.s.offset(-self.ind); + } + } else { + // If there are no modifiers, overrides, nor returns never break + if header.modifiers.is_empty() + && header.override_.is_none() + && returns.as_ref().is_none_or(|r| r.is_empty()) + && (header.visibility().is_none() || body.is_empty()) + { + self.nbsp(); + } else { + self.space(); + self.s.offset(-self.ind); + } + } + self.cursor.advance_to(body_span.lo(), true); + } + self.print_word("{"); + self.end(); + if attrib_box { + self.end(); + } + + self.print_block_without_braces(body, body_span.hi(), Some(self.ind)); + if self.cursor.enabled || self.cursor.pos < body_span.hi() { + self.print_word("}"); + self.cursor.advance_to(body_span.hi(), true); + } + } else { + self.print_comments(body_span.lo(), CommentConfig::skip_ws().mixed_prev_space()); + self.end(); + if attrib_box { + self.end(); + } + self.neverbreak(); + self.print_word(";"); + } + + if let Some(cmnt) = self.peek_trailing_comment(body_span.hi(), None) { + if cmnt.is_doc { + // trailing doc comments after the fn body are isolated + // these shouldn't update the cursor, as this is our own formatting + self.hardbreak(); + self.hardbreak(); + } + self.print_trailing_comment(body_span.hi(), None); + } + } + + fn print_fn_attribute( + &mut self, + span: Span, + map: &mut AttributeCommentMap, + print_fn: &mut dyn FnMut(&mut Self), + ) { + match map.remove(&span.lo()) { + Some((pre_cmnts, inner_cmnts, post_cmnts)) => { + // Print preceding comments. The separator before the attribute is printed below, + // so a mixed comment must not add one of its own or the two become a blank line. + let mut previous_mixed = false; + for cmnt in pre_cmnts { + // A line comment after a mixed comment becomes isolated once the header wraps. + if previous_mixed + && cmnt.style.is_trailing() + && matches!(cmnt.kind, ast::CommentKind::Line) + { + self.hardbreak_if_not_bol(); + } + previous_mixed = cmnt.style.is_mixed(); + let Some(cmnt) = self.handle_comment(cmnt, false) else { + continue; + }; + self.print_comment( + cmnt, + CommentConfig::default().mixed_no_break_post().mixed_prev_space(), + ); + } + // Push the inner comments back to the queue, so that they are printed in their + // intended place. + for cmnt in inner_cmnts.into_iter().rev() { + self.comments.push_front(cmnt); + } + let enabled = if self.handle_span(span, false) { + false + } else { + if !self.is_bol_or_only_ind() { + self.space(); + } + self.ibox(0); + print_fn(self); + self.cursor.advance_to(span.hi(), true); + true + }; + // Print subsequent comments. + for cmnt in post_cmnts { + let Some(cmnt) = self.handle_comment(cmnt, false) else { + continue; + }; + self.print_comment(cmnt, CommentConfig::default().mixed_prev_space()); + } + if enabled { + self.end(); + } + } + // Fallback for attributes not in the map (should never happen) + None => { + if !self.is_bol_or_only_ind() { + self.space(); + } + print_fn(self); + self.cursor.advance_to(span.hi(), true); + } + } + } + + fn is_modifier_a_base_contract( + &self, + kind: ast::FunctionKind, + modifier: &'ast ast::Modifier<'ast>, + ) -> bool { + // Add `()` in functions when the modifier is a base contract. + // HACK: heuristics: + // 1. exactly matches the name of a base contract as declared in the `contract is`; + // this does not account for inheritance; + let is_contract_base = self.contract.is_some_and(|contract| { + contract + .bases + .iter() + .any(|contract_base| contract_base.name.to_string() == modifier.name.to_string()) + }); + // 2. assume that title case names in constructors are bases. + // LEGACY: constructors used to also be `function NameOfContract...`; not checked. + let is_constructor = matches!(kind, ast::FunctionKind::Constructor); + // LEGACY: we are checking the beginning of the path, not the last segment. + is_contract_base + || (is_constructor + && modifier.name.first().name.as_str().starts_with(char::is_uppercase)) + } + + fn print_error(&mut self, err: &'ast ast::ItemError<'ast>) { + let ast::ItemError { name, parameters } = err; + self.word("error "); + self.print_ident(name); + self.print_parameter_list( + parameters, + parameters.span, + if self.config.prefer_compact.errors() { + ListFormat::compact() + } else { + ListFormat::consistent() + }, + ); + self.word(";"); + } + + fn print_event(&mut self, event: &'ast ast::ItemEvent<'ast>) { + let ast::ItemEvent { name, parameters, anonymous } = event; + self.word("event "); + self.print_ident(name); + self.print_parameter_list( + parameters, + parameters.span, + if self.config.prefer_compact.events() { + ListFormat::compact().break_cmnts() + } else { + ListFormat::consistent().break_cmnts() + }, + ); + if *anonymous { + self.word(" anonymous"); + } + self.word(";"); + } + + fn print_var_def(&mut self, var: &'ast ast::VariableDefinition<'ast>) { + self.print_var(var, true); + self.word(";"); + } + + /// Prints the RHS of an assignment or variable initializer. + fn print_assign_rhs( + &mut self, + rhs: &'ast ast::Expr<'ast>, + lhs_size: usize, + space_left: usize, + ty: Option<&ast::TypeKind<'ast>>, + cache: bool, + ) { + // Check if the total expression overflows but the RHS would fit alone on a new line. + // This helps keep the RHS together on a single line when possible. + let rhs_size = if is_binary_expr(&rhs.kind) + && !self.has_comment_between(rhs.span.lo(), rhs.span.hi()) + { + self.estimate_binary_size(rhs) + } else if assignment_member_depth(rhs) >= 2 + && self.peek_comment_before(rhs.span.lo()).is_none() + { + // These chains can break before a member after collapsing their terminal delimiters. + // Size that collapsed form before choosing the assignment break instead. + self.estimate_assignment_size(rhs.span) + } else { + self.estimate_size(rhs.span) + }; + let overflows = lhs_size + rhs_size >= space_left; + let fits_alone = rhs_size + self.config.tab_width < space_left; + let fits_alone_no_cmnts = + fits_alone && !self.has_comment_between(rhs.span.lo(), rhs.span.hi()); + let force_break = overflows && fits_alone_no_cmnts; + + if lhs_size <= space_left { + self.neverbreak(); + } + + // Handle comments before the RHS expression + if let Some(cmnt) = self.peek_comment_before(rhs.span.lo()) + && self.inline_config.is_disabled(cmnt.span) + { + self.print_sep(Separator::Nbsp); + } + if self + .print_comments( + rhs.span.lo(), + CommentConfig::skip_ws().mixed_no_break().mixed_prev_space(), + ) + .is_some_and(|cmnt| cmnt.is_trailing()) + { + self.break_offset_if_not_bol(SIZE_INFINITY as usize, self.ind, false); + } + + // Match on expression kind to determine formatting strategy + match &rhs.kind { + ast::ExprKind::Lit(lit, ..) if lit.is_str_concatenation() => { + // String concatenations stay on the same line with nbsp + self.print_sep(Separator::Nbsp); + self.neverbreak(); + self.s.ibox(self.ind); + self.print_expr(rhs); + self.end(); + } + ast::ExprKind::Lit(..) if ty.is_none() && !fits_alone => { + // Long string in assign expr goes on its own line + self.print_sep(Separator::Space); + self.s.offset(self.ind); + self.print_expr(rhs); + } + ast::ExprKind::Binary(lhs, op, _) => { + let print_inline = |this: &mut Self| { + this.print_sep(Separator::Nbsp); + this.neverbreak(); + this.print_expr(rhs); + }; + let print_with_break = |this: &mut Self, force_break: bool| { + if !this.is_bol_or_only_ind() { + if force_break { + this.print_sep(Separator::Hardbreak); + } else { + this.print_sep(Separator::Space); + } + } + this.s.offset(this.ind); + this.s.ibox(this.ind); + this.print_expr(rhs); + this.end(); + }; + + // Binary expressions: check if we need to break and indent + if force_break { + print_with_break(self, true); + } else if self.estimate_lhs_size(rhs, op) + lhs_size > space_left { + if has_complex_successor(&rhs.kind, true) + && get_callee_head_size(lhs) + lhs_size <= space_left + { + // Keep complex exprs (where callee fits) inline, as they will have breaks + if is_call(&lhs.kind) { + self.s.ibox(-self.ind); + print_inline(self); + self.end(); + } else { + print_inline(self); + } + } else { + print_with_break(self, false); + } + } + // Otherwise, if expr fits, ensure no breaks + else { + print_inline(self); + } + } + _ => { + // General case: handle calls, complex successors, and other expressions + let callee_doesnt_fit = if let ast::ExprKind::Call(call_expr, ..) = &rhs.kind { + let callee_size = get_callee_head_size(call_expr); + callee_size + lhs_size > space_left + && callee_size + self.config.tab_width < space_left + } else { + false + }; + + if (lhs_size + 1 >= space_left && !is_call_chain(&rhs.kind, false)) + || callee_doesnt_fit + { + self.s.ibox(self.ind); + } else { + self.s.ibox(0); + }; + + if has_complex_successor(&rhs.kind, true) + && !matches!(&rhs.kind, ast::ExprKind::Member(..)) + { + // delegate breakpoints to `self.commasep(..)` for complex successors + if !self.is_bol_or_only_ind() { + let needs_offset = !callee_doesnt_fit + && rhs_size + lhs_size + 1 >= space_left + && fits_alone_no_cmnts; + let separator = if callee_doesnt_fit || needs_offset { + Separator::Space + } else { + Separator::Nbsp + }; + self.print_sep(separator); + if needs_offset { + self.s.offset(self.ind); + } + } + } else { + if !self.is_bol_or_only_ind() { + self.print_sep_unhandled(Separator::Space); + } + // apply type-dependent indentation if type info is available + if let Some(ty) = ty + && matches!(ty, ast::TypeKind::Elementary(..) | ast::TypeKind::Mapping(..)) + { + self.s.offset(self.ind); + } + } + self.print_expr(rhs); + self.end(); + } + } + + self.var_init = cache; + } + + fn print_var(&mut self, var: &'ast ast::VariableDefinition<'ast>, is_var_def: bool) { + let ast::VariableDefinition { + span, + ty, + visibility, + mutability, + data_location, + override_, + indexed, + name, + initializer, + } = var; + + if self.handle_span(*span, false) { + return; + } + + // NOTE(rusowsky): this is hacky but necessary to properly estimate if we figure out if we + // have double breaks (which should have double indentation) or not. + // Alternatively, we could achieve the same behavior with a new box group that supports + // "continuation" which would only increase indentation if its parent box broke. + let init_space_left = self.space_left(); + let mut pre_init_size = self.estimate_size(ty.span); + + // Non-elementary types use commasep which has its own padding. + self.s.ibox(0); + if override_.is_some() { + self.s.cbox(self.ind); + } else { + self.s.ibox(self.ind); + } + self.print_ty(ty); + + self.print_attribute(visibility.map(|v| v.to_str()), is_var_def, &mut pre_init_size); + self.print_attribute(mutability.map(|m| m.to_str()), is_var_def, &mut pre_init_size); + self.print_attribute(data_location.map(|d| d.to_str()), is_var_def, &mut pre_init_size); + + if let Some(override_) = override_ { + if self + .print_comments(override_.span.lo(), CommentConfig::skip_ws().mixed_prev_space()) + .is_none() + { + self.print_sep(Separator::SpaceOrNbsp(is_var_def)); + } + self.ibox(0); + self.print_override(override_); + pre_init_size += self.estimate_size(override_.span) + 1; + } + + if *indexed { + self.print_attribute(indexed.then_some("indexed"), is_var_def, &mut pre_init_size); + } + + if let Some(ident) = name { + self.print_sep(Separator::SpaceOrNbsp(is_var_def && override_.is_none())); + self.print_comments( + ident.span.lo(), + CommentConfig::skip_ws().mixed_no_break().mixed_post_nbsp(), + ); + self.print_ident(ident); + pre_init_size += self.estimate_size(ident.span) + 1; + } + if let Some(init) = initializer { + let cache = self.var_init; + self.var_init = true; + + pre_init_size += 2; + self.print_word(" ="); + if override_.is_some() { + self.end(); + } + self.end(); + + self.print_assign_rhs(init, pre_init_size, init_space_left, Some(&ty.kind), cache); + } else { + if override_.is_some() { + self.end(); + } + self.end(); + } + self.end(); + } + + fn print_attribute( + &mut self, + attribute: Option<&'static str>, + is_var_def: bool, + size: &mut usize, + ) { + if let Some(s) = attribute { + self.print_sep(Separator::SpaceOrNbsp(is_var_def)); + self.print_word(s); + *size += s.len() + 1; + } + } + + fn print_parameter_list( + &mut self, + parameters: &'ast [ast::VariableDefinition<'ast>], + span: Span, + format: ListFormat, + ) { + if self.handle_span(span, false) { + return; + } + + self.print_tuple( + parameters, + span.lo(), + span.hi(), + |fmt, var| fmt.print_var(var, false), + get_span!(), + format, + ); + } + + fn print_ident_or_strlit(&mut self, value: &'ast ast::IdentOrStrLit) { + match value { + ast::IdentOrStrLit::Ident(ident) => self.print_ident(ident), + ast::IdentOrStrLit::StrLit(strlit) => self.print_ast_str_lit(strlit), + } + } + + /// Prints a raw AST string literal, which is unescaped. + fn print_ast_str_lit(&mut self, strlit: &'ast ast::StrLit) { + self.print_str_lit(ast::StrKind::Str, strlit.span.lo(), strlit.value.as_str()); + } + + fn print_ty(&mut self, ty: &'ast ast::Type<'ast>) { + if self.handle_span(ty.span, false) { + return; + } + + match &ty.kind { + &ast::TypeKind::Elementary(ty) => 'b: { + match ty { + // `address payable` is normalized to `address`. + ast::ElementaryType::Address(true) => { + self.word("address payable"); + break 'b; + } + // Integers are normalized to long form. + ast::ElementaryType::Int(size) | ast::ElementaryType::UInt(size) => { + match (self.config.int_types, size.bits_raw()) { + (config::IntTypes::Short, 0 | 256) + | (config::IntTypes::Preserve, 0) => { + let short = match ty { + ast::ElementaryType::Int(_) => "int", + ast::ElementaryType::UInt(_) => "uint", + _ => unreachable!(), + }; + self.word(short); + break 'b; + } + _ => {} + } + } + _ => {} + } + self.word(ty.to_abi_str()); + } + ast::TypeKind::Array(ast::TypeArray { element, size }) => { + self.print_ty(element); + let open_bracket = self + .find_uncommented_char(Span::new(element.span.hi(), ty.span.hi()), '[') + .unwrap(); + self.print_comments( + open_bracket, + CommentConfig::skip_ws().mixed_prev_space().mixed_post_nbsp(), + ); + if let Some(size) = size { + self.word("["); + self.print_expr(size); + self.word("]"); + } else { + self.word("[]"); + } + } + ast::TypeKind::Function(ast::TypeFunction { + parameters, + visibility, + state_mutability, + returns, + }) => { + self.cbox(0); + self.word("function"); + self.print_parameter_list(parameters, parameters.span, ListFormat::inline()); + + if let Some(v) = visibility { + self.space(); + self.word(v.to_str()); + } + if let Some(sm) = state_mutability + && !matches!(**sm, ast::StateMutability::NonPayable) + { + self.space(); + self.word(sm.to_str()); + } + if let Some(ret) = returns + && !ret.is_empty() + { + self.nbsp(); + self.word("returns"); + self.nbsp(); + self.print_parameter_list( + ret, + ret.span, + ListFormat::consistent(), // .with_cmnts_break(false), + ); + } + self.end(); + } + ast::TypeKind::Mapping(ast::TypeMapping { key, key_name, value, value_name }) => { + self.word("mapping("); + self.s.cbox(0); + if let Some(cmnt) = self.peek_comment_before(key.span.lo()) { + if cmnt.style.is_mixed() { + self.print_comments( + key.span.lo(), + CommentConfig::skip_ws().mixed_no_break().mixed_prev_space(), + ); + self.break_offset_if_not_bol(SIZE_INFINITY as usize, 0, false); + } else { + self.print_comments(key.span.lo(), CommentConfig::skip_ws()); + } + } + // Fitting a mapping in one line takes, at least, 16 chars (one-char var name): + // 'mapping(' + {key} + ' => ' {value} ') ' + {name} + ';' + // To be more conservative, we use 18 to decide whether to force a break or not. + else if 18 + + self.estimate_type_size(key) + + key_name.map(|k| self.estimate_size(k.span)).unwrap_or(0) + + self.estimate_type_size(value) + + value_name.map(|v| self.estimate_size(v.span)).unwrap_or(0) + >= self.space_left() + { + self.hardbreak(); + } else { + self.zerobreak(); + } + self.s.cbox(0); + self.print_ty(key); + if let Some(ident) = key_name { + if self + .print_comments( + ident.span.lo(), + CommentConfig::skip_ws() + .mixed_no_break() + .mixed_prev_space() + .mixed_post_nbsp(), + ) + .is_none() + { + self.nbsp(); + } + self.print_ident(ident); + } + // NOTE(rusowsky): unless we add more spans to solar, using `value.span.lo()` + // consumes "comment6" of which should be printed after the `=>` + self.print_comments( + value.span.lo(), + CommentConfig::skip_ws().mixed_no_break().mixed_prev_space(), + ); + if !self.is_bol_or_only_ind() { + self.space(); + } + self.s.offset(self.ind); + self.word("=> "); + self.s.ibox(self.ind); + self.print_ty(value); + if let Some(ident) = value_name { + self.neverbreak(); + if self + .print_comments( + ident.span.lo(), + CommentConfig::skip_ws() + .mixed_no_break() + .mixed_prev_space() + .mixed_post_nbsp(), + ) + .is_none() + { + self.nbsp(); + } + self.print_ident(ident); + if self + .peek_comment_before(ty.span.hi()) + .is_some_and(|cmnt| cmnt.style.is_mixed()) + { + self.neverbreak(); + self.print_comments( + value.span.lo(), + CommentConfig::skip_ws().mixed_no_break(), + ); + } + } + self.end(); + self.end(); + if self + .print_comments( + ty.span.hi(), + CommentConfig::skip_ws().mixed_no_break().mixed_prev_space(), + ) + .is_some_and(|cmnt| !cmnt.is_mixed()) + { + self.break_offset_if_not_bol(0, -self.ind, false); + } else { + self.zerobreak(); + self.s.offset(-self.ind); + } + self.end(); + self.word(")"); + } + ast::TypeKind::Custom(path) => self.print_path(path, false), + } + } + + fn print_override(&mut self, override_: &'ast ast::Override<'ast>) { + let ast::Override { span, paths } = override_; + if self.handle_span(*span, false) { + return; + } + self.word("override"); + if !paths.is_empty() { + if self.config.override_spacing { + self.nbsp(); + } + self.print_tuple( + paths, + span.lo(), + span.hi(), + |this, path| this.print_path(path, false), + get_span!(()), + ListFormat::consistent(), // .with_cmnts_break(false), + ); + } + } + + /* --- Expressions --- */ + /// Prints an expression by matching on its variant and delegating to the appropriate + /// printer method, handling all Solidity expression kinds. + fn print_expr(&mut self, expr: &'ast ast::Expr<'ast>) { + let ast::Expr { span, ref kind } = *expr; + if self.handle_span(span, false) { + return; + } + + match kind { + ast::ExprKind::Array(exprs) => { + self.print_array(exprs, expr.span, |this, e| this.print_expr(e), get_span!()) + } + ast::ExprKind::Assign(lhs, None, rhs) => self.print_assign_expr(lhs, rhs), + ast::ExprKind::Assign(lhs, Some(op), rhs) => self.print_bin_expr(lhs, op, rhs, true), + ast::ExprKind::Binary(lhs, op, rhs) => self.print_bin_expr(lhs, op, rhs, false), + ast::ExprKind::Call(call_expr, call_args) => { + let cache = self.call_with_opts_and_args; + let chained_named_call_cache = self.chained_named_call; + // Keep calls within a chained callee inline when they fit, so a multiline named + // argument list does not force an earlier break inside the callee. + let keep_inline = chained_named_call_cache + .is_some_and(|call| call.keep_inline && call.callee.contains(expr.span)) + && !self.has_comments_between_elements(call_args.span, call_args.exprs()); + self.call_with_opts_and_args = is_call_with_opts_and_args(&expr.kind); + let named_args_size = if call_args.is_empty() { + 4 + usize::from(self.config.bracket_spacing) + } else { + 2 + }; + self.chained_named_call = (matches!(call_args.kind, ast::CallArgsKind::Named(_)) + && is_call_chain(&call_expr.kind, true)) + .then(|| ChainedNamedCall { + callee: call_expr.span, + keep_inline: !call_chain_contains_options(call_expr) + && !self.has_comment_between(call_expr.span.lo(), call_expr.span.hi()) + && self + .estimate_call_chain_size(call_expr) + .is_some_and(|size| size + named_args_size <= self.space_left()), + }) + .or_else(|| { + chained_named_call_cache.filter(|call| call.callee.contains(expr.span)) + }); + let list_format = if keep_inline { + ListFormat::inline() + } else { + ListFormat::compact().break_cmnts().break_single(true) + }; + let terminal_callee = call_expr.peel_parens(); + let callee_has_breakable_comment = self + .has_breakable_comment_between(call_expr.span.lo(), terminal_callee.span.lo()) + || self.has_breakable_comment_between( + terminal_callee.span.hi(), + call_expr.span.hi(), + ) + || if let ast::ExprKind::Member(member_expr, ident) = &terminal_callee.kind { + self.has_breakable_comment_between(member_expr.span.hi(), ident.span.lo()) + } else { + false + }; + self.print_member_or_call_chain( + call_expr, + MemberOrCallArgs::CallArgs( + self.estimate_call_args_size(call_args.span), + self.has_comments_between_elements(call_args.span, call_args.exprs()), + ), + |s| { + let callee_suffix_can_break = callee_has_breakable_comment + || match &terminal_callee.kind { + ast::ExprKind::Member(member_expr, _) => { + s.member_suffix_emits_break(terminal_callee, member_expr) + } + ast::ExprKind::Index(..) => !s.skip_index_break, + _ => false, + }; + s.print_call_args( + call_args, + list_format.without_ind(s.return_bin_expr).with_delimiters( + !s.call_with_opts_and_args + || s.call_stack + .last() + .is_some_and(|call| call.is_chained() && call.has_indent), + ), + get_callee_head_size(call_expr), + callee_suffix_can_break, + ); + }, + ); + self.call_with_opts_and_args = cache; + self.chained_named_call = chained_named_call_cache; + } + ast::ExprKind::CallOptions(expr, named_args) => { + // the flag is only meant to be used to format the call args + let cache = self.call_with_opts_and_args; + self.call_with_opts_and_args = false; + + self.print_expr(expr); + self.print_named_args(named_args, span.hi(), false); + + // restore cached value + self.call_with_opts_and_args = cache; + } + ast::ExprKind::Delete(expr) => { + self.word("delete "); + self.print_expr(expr); + } + ast::ExprKind::Ident(ident) => self.print_ident(ident), + ast::ExprKind::Index(expr, kind) => self.print_index_expr(span, expr, kind), + ast::ExprKind::Lit(lit, unit) => { + self.print_lit_inner(lit, false); + if let Some(unit) = unit { + self.nbsp(); + self.word(unit.to_str()); + } + } + ast::ExprKind::Member(member_expr, ident) => { + self.print_member_or_call_chain( + member_expr, + MemberOrCallArgs::Member(self.estimate_size(ident.span)), + |s| { + let has_mixed_comment = s + .peek_comment_between(member_expr.span.hi(), ident.span.lo()) + .is_some_and(|comment| comment.style.is_mixed()); + let break_before_suffix = if has_mixed_comment { + s.print_comments( + ident.span.lo(), + CommentConfig::skip_ws().mixed_no_break().mixed_prev_space(), + ); + true + } else { + !s.print_trailing_comment(member_expr.span.hi(), Some(ident.span.lo())) + && s.peek_comment_between(member_expr.span.hi(), ident.span.lo()) + .is_none() + && s.member_suffix_emits_break(expr, member_expr) + }; + if break_before_suffix { + s.zerobreak(); + } + s.word("."); + s.print_ident(ident); + }, + ); + } + ast::ExprKind::New(ty) => { + self.word("new "); + self.print_ty(ty); + } + ast::ExprKind::Payable(args) => { + self.word("payable"); + self.print_call_args(args, ListFormat::compact().break_cmnts(), 7, false); + } + ast::ExprKind::Ternary(cond, then, els) => self.print_ternary_expr(cond, then, els), + ast::ExprKind::Tuple(exprs) => self.print_tuple( + exprs, + span.lo(), + span.hi(), + |this, expr| match expr.as_ref() { + SpannedOption::Some(expr) => this.print_expr(expr), + SpannedOption::None(span) => { + this.print_comments( + span.hi(), + CommentConfig::skip_ws().mixed_no_break_post(), + ); + } + }, + |expr| match expr.as_ref() { + SpannedOption::Some(expr) => expr.span, + // Manually handled by printing the comment when `None` + SpannedOption::None(..) => Span::DUMMY, + }, + ListFormat::compact().break_single(is_binary_expr(&expr.kind)), + ), + ast::ExprKind::TypeCall(ty) => { + self.word("type"); + self.print_tuple( + std::slice::from_ref(ty), + span.lo(), + span.hi(), + Self::print_ty, + get_span!(), + ListFormat::consistent(), + ); + } + ast::ExprKind::Type(ty) => self.print_ty(ty), + ast::ExprKind::Unary(un_op, expr) => { + let prefix = un_op.kind.is_prefix(); + let op = un_op.kind.to_str(); + if prefix { + self.word(op); + } + self.print_expr(expr); + if !prefix { + debug_assert!(un_op.kind.is_postfix()); + self.word(op); + } + } + ast::ExprKind::Err(_) => self.print_span(span), + } + self.cursor.advance_to(span.hi(), true); + } + + /// Prints a simple assignment expression of the form `lhs = rhs`. + fn print_assign_expr(&mut self, lhs: &'ast ast::Expr<'ast>, rhs: &'ast ast::Expr<'ast>) { + let cache = self.var_init; + self.var_init = true; + + let space_left = self.space_left(); + let lhs_size = self.estimate_size(lhs.span); + // Normalize only indexes that the existing layout keeps together. Longer indexes keep + // their own breaks, so their source delimiter padding remains relevant to that layout. + let lhs_size = if matches!(lhs.kind, ast::ExprKind::Index(..)) + && lhs_size + 2 <= space_left + && !self.has_comment_between(lhs.span.lo(), rhs.span.lo()) + { + self.estimate_assignment_size(lhs.span) + } else { + lhs_size + }; + self.print_expr(lhs); + self.word(" ="); + self.print_assign_rhs(rhs, lhs_size + 2, space_left, None, cache); + } + + /// Prints a binary operator expression. Handles operator chains and formatting. + fn print_bin_expr( + &mut self, + lhs: &'ast ast::Expr<'ast>, + bin_op: &ast::BinOp, + rhs: &'ast ast::Expr<'ast>, + is_assign: bool, + ) { + let prev_chain = self.binary_expr; + let is_chain = prev_chain.is_some_and(|prev| prev == bin_op.kind.group()); + + // Opening box if starting a new operator chain. + if !is_chain { + self.binary_expr = Some(bin_op.kind.group()); + + let indent = if (is_assign && has_complex_successor(&rhs.kind, true)) + || self.call_stack.is_nested() + && is_call_chain(&lhs.kind, false) + && self.estimate_size(lhs.span) >= self.space_left() + { + 0 + } else { + self.ind + }; + self.s.ibox(indent); + } + + // Print LHS. + self.print_expr(lhs); + + // Handle assignment (`+=`, etc.) vs binary ops (`+`, `*`, etc.). + let no_trailing_comment = !self.print_trailing_comment(lhs.span.hi(), Some(rhs.span.lo())); + if is_assign { + if no_trailing_comment { + self.nbsp(); + } + self.word(bin_op.kind.to_str()); + self.word("= "); + } else { + if no_trailing_comment + && self + .print_comments( + bin_op.span.lo(), + CommentConfig::skip_ws().mixed_no_break().mixed_prev_space(), + ) + .is_none_or(|cmnt| cmnt.is_mixed()) + { + if !self.config.pow_no_space || !matches!(bin_op.kind, ast::BinOpKind::Pow) { + self.space_if_not_bol(); + } else if !self.is_bol_or_only_ind() && !self.last_token_is_break() { + self.zerobreak(); + } + } + + self.word(bin_op.kind.to_str()); + + if !self.config.pow_no_space || !matches!(bin_op.kind, ast::BinOpKind::Pow) { + self.nbsp(); + } + } + + // Print RHS with optional ibox if mixed comment precedes. + let rhs_has_mixed_comment = + self.peek_comment_before(rhs.span.lo()).is_some_and(|cmnt| cmnt.style.is_mixed()); + if rhs_has_mixed_comment { + self.ibox(0); + self.print_expr(rhs); + self.end(); + } else { + self.print_expr(rhs); + } + + // End current box if this was top-level in the chain. + if !is_chain { + self.binary_expr = prev_chain; + self.end(); + } + } + + /// Prints an indexing expression. + fn print_index_expr( + &mut self, + span: Span, + expr: &'ast ast::Expr<'ast>, + kind: &'ast ast::IndexKind<'ast>, + ) { + self.print_expr(expr); + self.word("["); + self.s.cbox(self.ind); + + let mut skip_break = false; + let mut zerobreak = |this: &mut Self| { + if this.skip_index_break { + skip_break = true; + } else { + this.zerobreak(); + } + }; + match kind { + ast::IndexKind::Index(Some(inner_expr)) => { + zerobreak(self); + self.print_expr(inner_expr); + } + ast::IndexKind::Index(None) => {} + ast::IndexKind::Range(start, end) => { + if let Some(start_expr) = start { + if self + .print_comments(start_expr.span.lo(), CommentConfig::skip_ws()) + .is_none_or(|s| s.is_mixed()) + { + zerobreak(self); + } + self.print_expr(start_expr); + } else { + zerobreak(self); + } + + self.word(":"); + + if let Some(end_expr) = end { + self.s.ibox(self.ind); + if start.is_some() { + zerobreak(self); + } + self.print_comments( + end_expr.span.lo(), + CommentConfig::skip_ws() + .mixed_prev_space() + .mixed_no_break() + .mixed_post_nbsp(), + ); + self.print_expr(end_expr); + } + + // Trailing comment handling. + let is_trailing = if let Some(style) = self.print_comments( + span.hi(), + CommentConfig::skip_ws().mixed_no_break().mixed_prev_space(), + ) { + skip_break = true; + style.is_trailing() + } else { + false + }; + + // Adjust indentation and line breaks. + match (skip_break, end.is_some()) { + (true, true) => { + self.break_offset_if_not_bol(0, -2 * self.ind, false); + self.end(); + if !is_trailing { + self.break_offset_if_not_bol(0, -self.ind, false); + } + } + (true, false) => { + self.break_offset_if_not_bol(0, -self.ind, false); + } + (false, true) => { + self.end(); + } + _ => {} + } + } + } + + if !skip_break { + self.zerobreak(); + self.s.offset(-self.ind); + } + + self.end(); + self.word("]"); + } + + /// Prints a ternary expression of the form `cond ? then : else`. + fn print_ternary_expr( + &mut self, + cond: &'ast ast::Expr<'ast>, + then: &'ast ast::Expr<'ast>, + els: &'ast ast::Expr<'ast>, + ) { + self.s.cbox(self.ind); + self.s.ibox(0); + + let print_sub_expr = |this: &mut Self, span_lo, prefix, expr: &'ast ast::Expr<'ast>| { + match prefix { + Some(prefix) => { + if this.peek_comment_before(span_lo).is_some() { + this.space(); + } + this.print_comments(span_lo, CommentConfig::skip_ws()); + this.end(); + if !this.is_bol_or_only_ind() { + this.space(); + } + this.s.ibox(0); + this.word(prefix); + } + None => { + this.print_comments(expr.span.lo(), CommentConfig::skip_ws()); + } + }; + this.print_expr(expr); + }; + + // conditional expression + self.s.ibox(-self.ind); + print_sub_expr(self, then.span.lo(), None, cond); + self.end(); + // then expression + print_sub_expr(self, then.span.lo(), Some("? "), then); + // else expression + print_sub_expr(self, els.span.lo(), Some(": "), els); + + self.end(); + self.neverbreak(); + self.s.offset(-self.ind); + self.end(); + } + + // If `add_parens_if_empty` is true, then add parentheses `()` even if there are no arguments. + fn print_modifier_call( + &mut self, + modifier: &'ast ast::Modifier<'ast>, + add_parens_if_empty: bool, + ) { + let ast::Modifier { name, arguments } = modifier; + self.print_path(name, false); + if !arguments.is_empty() || add_parens_if_empty { + self.print_call_args( + arguments, + ListFormat::compact().break_cmnts(), + name.to_string().len(), + false, + ); + } + } + + fn member_suffix_emits_break(&self, expr: &ast::Expr<'_>, member_expr: &ast::Expr<'_>) -> bool { + match member_expr.kind { + ast::ExprKind::Ident(_) | ast::ExprKind::Type(_) => false, + ast::ExprKind::Index(..) if self.skip_index_break => false, + _ if self + .chained_named_call + .is_some_and(|call| call.keep_inline && call.callee.contains(expr.span)) => + { + false + } + // Don't add a break when accessing a field after a call with named args. + // e.g., `_lzSend({_dstEid: x, ...}).guid` should keep `.guid` + // on the same line as the closing `})`. + // See: https://github.com/foundry-rs/foundry/issues/12399 + _ if is_call_with_named_args(&member_expr.kind) => false, + _ => true, + } + } + + fn print_member_or_call_chain( + &mut self, + child_expr: &'ast ast::Expr<'ast>, + member_or_args: MemberOrCallArgs, + print_suffix: F, + ) where + F: FnOnce(&mut Self), + { + fn member_depth(depth: usize, expr: &ast::Expr<'_>) -> usize { + if let ast::ExprKind::Member(child, ..) = &expr.kind { + member_depth(depth + 1, child) + } else { + depth + } + } + + let (mut extra_box, skip_cache) = (false, self.skip_index_break); + let parent_is_chain = self.call_stack.last().copied().is_some_and(|call| call.is_chained()); + if !parent_is_chain { + // Estimate sizes of callee and optional member + let callee_size = get_callee_head_size(child_expr) + member_or_args.member_size(); + let expr_size = self.estimate_size(child_expr.span); + + let callee_fits_line = self.space_left() > callee_size + 1; + let total_fits_line = self.space_left() > expr_size + member_or_args.size() + 2; + let no_cmnt_or_mixed = + self.peek_comment_before(child_expr.span.hi()).is_none_or(|c| c.style.is_mixed()); + + // If call with options, add an extra box to prioritize breaking the call args. + if self.call_with_opts_and_args { + self.cbox(0); + extra_box = true; + } + + // Determine if this chain will add its own indentation + let keep_chain_inline = self + .chained_named_call + .is_some_and(|call| call.keep_inline && call.callee.contains(child_expr.span)); + let chain_has_indent = !keep_chain_inline + && (is_call_chain(&child_expr.kind, true) + || !(no_cmnt_or_mixed + || matches!(&child_expr.kind, ast::ExprKind::CallOptions(..))) + || !callee_fits_line + || (member_depth(0, child_expr) >= 2 + && (!total_fits_line || member_or_args.has_comments()))); + + // Start a new chain if needed + if is_call_chain(&child_expr.kind, false) { + self.call_stack.push(CallContext::chained(callee_size, chain_has_indent)); + } + + if chain_has_indent { + self.s.cbox(self.ind); + } else { + self.skip_index_break = true; + self.cbox(0); + } + } + + // Recursively print the child/prefix expression. + self.print_expr(child_expr); + + // If an extra box was opened, close it + if extra_box { + self.end(); + } + + // Call the closure to print the suffix for the current link, with the calculated position. + print_suffix(self); + + // If a chain was started, clean up the state and end the box. + if !parent_is_chain { + if is_call_chain(&child_expr.kind, false) { + self.call_stack.pop(); + } + self.end(); + } + + // Restore cache + if self.skip_index_break { + self.skip_index_break = skip_cache; + } + } + + fn print_call_args( + &mut self, + args: &'ast ast::CallArgs<'ast>, + format: ListFormat, + callee_size: usize, + callee_suffix_can_break: bool, + ) { + let ast::CallArgs { span, ref kind } = *args; + if self.handle_span(span, true) { + return; + } + + self.call_stack.push(CallContext::nested(callee_size)); + + // Clear the binary expression cache before the call. + let cache = self.binary_expr.take(); + + match kind { + ast::CallArgsKind::Unnamed(exprs) => { + self.print_tuple( + exprs, + span.lo(), + span.hi(), + |this, e| this.print_expr(e), + get_span!(), + format, + ); + } + ast::CallArgsKind::Named(named_args) => { + let without_ind = + self.call_stack.has_indented_parent_chain() && !callee_suffix_can_break; + self.print_inside_parens(|state| { + state.print_named_args(named_args, span.hi(), without_ind) + }); + } + } + + // Restore the cache to continue with the current chain. + self.binary_expr = cache; + self.call_stack.pop(); + } + + fn print_named_args( + &mut self, + args: &'ast [ast::NamedArg<'ast>], + pos_hi: BytePos, + without_ind: bool, + ) { + let list_format = match (self.config.bracket_spacing, self.config.prefer_compact.calls()) { + (false, true) => ListFormat::compact(), + (false, false) => ListFormat::consistent(), + (true, true) => ListFormat::compact().with_space(), + (true, false) => ListFormat::consistent().with_space(), + }; + + self.word("{"); + // Use the start position of the first argument's name for comment processing. + if let Some(first_arg) = args.first() { + let list_lo = first_arg.name.span.lo(); + self.commasep( + args, + list_lo, + pos_hi, + // Closure to print a single named argument (`name: value`) + |s, arg| { + s.cbox(0); + s.print_ident(&arg.name); + s.word(":"); + if s.same_source_line(arg.name.span.hi(), arg.value.span.hi()) + || !s.print_trailing_comment(arg.name.span.hi(), None) + { + s.nbsp(); + } + s.print_comments( + arg.value.span.lo(), + CommentConfig::skip_ws().mixed_no_break().mixed_post_nbsp(), + ); + s.print_expr(arg.value); + s.end(); + }, + |arg| arg.name.span.until(arg.value.span), + list_format + .break_cmnts() + .break_single(true) + .without_ind(without_ind) + .with_delimiters(!self.call_with_opts_and_args), + ); + } else if self.config.bracket_spacing { + self.nbsp(); + } + self.word("}"); + } + + /* --- Statements --- */ + /// Prints the given statement in the source code, handling formatting, inline documentation, + /// trailing comments and layout logic for various statement kinds. + fn print_stmt(&mut self, stmt: &'ast ast::Stmt<'ast>) { + self.print_stmt_bound(stmt, None); + } + + /// Prints a statement with a bounded trailing-comment scan. + fn print_stmt_bound(&mut self, stmt: &'ast ast::Stmt<'ast>, next_pos: Option) { + let ast::Stmt { ref docs, span, ref kind } = *stmt; + self.print_docs(docs); + + // Handle disabled statements. + if self.handle_span(span, false) { + self.print_trailing_comment_no_break(stmt.span.hi(), next_pos); + return; + } + + // return statements can't have a preceding comment in the same line. + let force_break = matches!(kind, ast::StmtKind::Return(..)) + && self.peek_comment_before(span.lo()).is_some_and(|cmnt| cmnt.style.is_mixed()); + + match kind { + ast::StmtKind::Assembly(ast::StmtAssembly { dialect, flags, block }) => { + self.print_assembly_stmt(span, dialect, flags, block) + } + ast::StmtKind::DeclSingle(var) => self.print_var(var, true), + ast::StmtKind::DeclMulti(vars, init_expr) => { + self.print_multi_decl_stmt(span, vars, init_expr) + } + ast::StmtKind::Block(stmts) => self.print_block(stmts, span), + ast::StmtKind::Break => self.word("break"), + ast::StmtKind::Continue => self.word("continue"), + ast::StmtKind::DoWhile(stmt, cond) => { + self.word("do "); + self.print_stmt_as_block(stmt, cond.span.lo(), false); + self.nbsp(); + self.print_if_cond("while", cond, cond.span.hi()); + } + ast::StmtKind::Emit(path, args) => self.print_emit_or_revert("emit", path, args), + ast::StmtKind::Expr(expr) => self.print_expr(expr), + ast::StmtKind::For { init, cond, next, body } => { + self.print_for_stmt(span, init, cond, next, body) + } + ast::StmtKind::If(cond, then, els_opt) => self.print_if_stmt(span, cond, then, els_opt), + ast::StmtKind::Return(expr) => self.print_return_stmt(force_break, expr), + ast::StmtKind::Revert(path, args) => self.print_emit_or_revert("revert", path, args), + ast::StmtKind::Try(ast::StmtTry { expr, clauses }) => { + self.print_try_stmt(expr, clauses) + } + ast::StmtKind::UncheckedBlock(block) => { + self.word("unchecked "); + self.print_block(block, stmt.span); + } + ast::StmtKind::While(cond, stmt) => { + // Check if blocks should be inlined and update cache if necessary + let inline = self.is_single_line_block(span.lo(), cond, stmt, None); + if !inline.is_cached && self.single_line_stmt.is_none() { + self.single_line_stmt = Some(inline.outcome); + } + + // Print while cond and its statement + self.print_if_cond("while", cond, stmt.span.lo()); + self.nbsp(); + self.print_stmt_as_block(stmt, stmt.span.hi(), inline.outcome); + + // Clear cache if necessary + if !inline.is_cached && self.single_line_stmt.is_some() { + self.single_line_stmt = None; + } + } + ast::StmtKind::Placeholder => self.word("_"), + } + if stmt_needs_semi(kind) { + self.neverbreak(); // semicolon shouldn't account for linebreaks + self.word(";"); + self.cursor.advance_to(span.hi(), true); + } + // print comments without breaks, as those are handled by the caller. + let ends_with_line_comment = self + .comments + .iter() + .take_while(|cmnt| cmnt.pos() < stmt.span.hi()) + .filter(|cmnt| !cmnt.style.is_blank()) + .last() + .is_some_and(|cmnt| { + cmnt.style.is_trailing() && matches!(cmnt.kind, ast::CommentKind::Line) + }); + self.print_comments( + stmt.span.hi(), + CommentConfig::skip_trailing_ws() + .trailing_no_break() + .mixed_no_break() + .mixed_prev_space(), + ); + if ends_with_line_comment && self.peek_comment().is_some() { + self.hardbreak_if_not_bol(); + } + self.print_trailing_comment_no_break(stmt.span.hi(), next_pos); + } + + /// Prints an `assembly` statement, including optional dialect and flags, + /// followed by its Yul block. + fn print_assembly_stmt( + &mut self, + span: Span, + dialect: &'ast Option, + flags: &'ast [ast::StrLit], + block: &'ast ast::yul::Block<'ast>, + ) { + _ = self.handle_span(self.cursor.span(span.lo()), false); + if !self.handle_span(span.until(block.span), false) { + self.cursor.advance_to(span.lo(), true); + self.print_word("assembly "); // 9 chars + if let Some(dialect) = dialect { + self.print_ast_str_lit(dialect); + self.print_sep(Separator::Nbsp); + } + if !flags.is_empty() { + self.print_tuple( + flags, + span.lo(), + block.span.lo(), + Self::print_ast_str_lit, + get_span!(), + ListFormat::consistent(), + ); + self.print_sep(Separator::Nbsp); + } + } + self.print_yul_block(block, block.span, false, 9); + } + + /// Prints a multiple-variable declaration with a single initializer expression, + /// formatted as a tuple-style assignment (e.g., `(a, b) = foo();`). + fn print_multi_decl_stmt( + &mut self, + span: Span, + vars: &'ast BoxSlice<'ast, SpannedOption>>, + init_expr: &'ast ast::Expr<'ast>, + ) { + let space_left = self.space_left(); + + self.s.ibox(self.ind); + self.s.ibox(-self.ind); + self.print_tuple( + vars, + span.lo(), + init_expr.span.lo(), + |this, var| match var { + SpannedOption::Some(var) => this.print_var(var, true), + SpannedOption::None(span) => { + this.print_comments(span.hi(), CommentConfig::skip_ws().mixed_no_break_post()); + } + }, + |var| match var { + SpannedOption::Some(var) => var.span, + // Manually handled by printing the comment when `None` + SpannedOption::None(..) => Span::DUMMY, + }, + ListFormat::consistent(), + ); + self.end(); + self.word(" ="); + + if self.estimate_size(init_expr.span) + self.config.tab_width + <= std::cmp::max(space_left, self.space_left()) + { + self.print_sep(Separator::Space); + self.ibox(0); + } else { + self.print_sep(Separator::Nbsp); + self.neverbreak(); + self.s.ibox(-self.ind); + } + self.print_expr(init_expr); + self.end(); + self.end(); + } + + /// Prints a `for` loop statement, including its initializer, condition, + /// increment expression, and loop body, with formatting and spacing. + fn print_for_stmt( + &mut self, + span: Span, + init: &'ast Option<&mut ast::Stmt<'ast>>, + cond: &'ast Option<&mut ast::Expr<'ast>>, + next: &'ast Option<&mut ast::Expr<'ast>>, + body: &'ast ast::Stmt<'ast>, + ) { + self.cbox(0); + self.s.ibox(self.ind); + let open_paren = self.find_uncommented_char(span, '(').unwrap(); + self.print_word("for"); + if self + .print_comments( + open_paren, + CommentConfig::skip_ws().mixed_prev_space().mixed_post_nbsp(), + ) + .is_none() + { + self.nbsp(); + } + self.cursor.advance_to(open_paren, true); + self.print_word("("); + let init_has_leading_comment = + init.as_ref().is_some_and(|stmt| self.peek_comment_before(stmt.span.lo()).is_some()); + if !init_has_leading_comment { + self.zerobreak(); + } + + // Print init. + self.s.cbox(0); + let init_trailing_comment = match init { + Some(init_stmt) => { + let has_trailing_comment = cond.as_ref().is_some_and(|cond| { + self.comments + .iter() + .skip_while(|cmnt| cmnt.pos() < init_stmt.span.hi()) + .take_while(|cmnt| cmnt.pos() < cond.span.lo()) + .any(|cmnt| cmnt.style.is_trailing()) + }); + self.print_stmt_bound(init_stmt, Some(init_stmt.span.hi())); + has_trailing_comment + } + None => { + self.print_word(";"); + false + } + }; + + // Print condition. + match cond { + Some(cond_expr) => { + if init_trailing_comment { + self.hardbreak_if_not_bol(); + } + self.print_sep(Separator::Space); + self.print_expr(cond_expr); + } + None => self.zerobreak(), + } + self.print_word(";"); + + // Print next clause. + match next { + Some(next_expr) => { + self.space(); + self.print_expr(next_expr); + } + None => self.zerobreak(), + } + + // Close head. + self.break_offset_if_not_bol(0, -self.ind, false); + self.end(); + self.print_word(") "); + self.neverbreak(); + self.end(); + + // Print comments and body. + self.print_comments(body.span.lo(), CommentConfig::skip_ws()); + self.print_stmt_as_block(body, span.hi(), false); + self.end(); + } + + /// Prints an `if` statement, including its condition, `then` block, and any chained + /// `else` or `else if` branches, handling inline formatting decisions and comments. + fn print_if_stmt( + &mut self, + span: Span, + cond: &'ast ast::Expr<'ast>, + then: &'ast ast::Stmt<'ast>, + els_opt: &'ast Option<&mut ast::Stmt<'ast>>, + ) { + // Check if blocks should be inlined and update cache if necessary + let inline = self.is_single_line_block(span.lo(), cond, then, els_opt.as_ref()); + let set_inline_cache = !inline.is_cached && self.single_line_stmt.is_none(); + if set_inline_cache { + self.single_line_stmt = Some(inline.outcome); + } + + self.cbox(0); + self.ibox(0); + // Print if stmt + self.print_if_no_else(cond, then, inline.outcome); + + // Print else (if) stmts, if any + let mut current_else = els_opt.as_deref(); + while let Some(els) = current_else { + if self.ends_with('}') { + // If there are comments with line breaks, don't add spaces to mixed comments + if self.has_comment_before_with(els.span.lo(), |cmnt| !cmnt.style.is_mixed()) { + // If last comment is miced, ensure line break + if self + .print_comments(els.span.lo(), CommentConfig::skip_ws().mixed_no_break()) + .is_some_and(|cmnt| cmnt.is_mixed()) + { + self.hardbreak(); + } + } + // Otherwise, ensure a non-breaking space is added + else if self + .print_comments( + els.span.lo(), + CommentConfig::skip_ws() + .mixed_no_break() + .mixed_prev_space() + .mixed_post_nbsp(), + ) + .is_none() + { + self.nbsp(); + } + } else { + self.hardbreak_if_not_bol(); + if self + .print_comments(els.span.lo(), CommentConfig::skip_ws()) + .is_some_and(|cmnt| cmnt.is_mixed()) + { + self.hardbreak(); + }; + } + + self.ibox(0); + self.print_word("else "); + match &els.kind { + ast::StmtKind::If(cond, then, next_else) => { + self.print_if_no_else(cond, then, inline.outcome); + current_else = next_else.as_deref(); + } + _ => { + self.print_stmt_as_block(els, span.hi(), inline.outcome); + self.end(); // end ibox for final else + break; + } + } + } + self.end(); + + // Clear inline cache if we set it earlier. + if set_inline_cache { + self.single_line_stmt = None; + } + } + + /// Prints a `return` statement, optionally including a return expression. + /// Handles spacing, line breaking, and formatting. + fn print_return_stmt(&mut self, force_break: bool, expr: &'ast Option<&mut ast::Expr<'ast>>) { + if force_break { + self.hardbreak_if_not_bol(); + } + + let space_left = self.space_left(); + let expr_size = expr.as_ref().map_or(0, |expr| self.estimate_size(expr.span)); + + // `return ' + expr + ';' + let overflows = space_left < 8 + expr_size; + let fits_alone = space_left > expr_size; + + if let Some(expr) = expr { + let is_simple = matches!(expr.kind, ast::ExprKind::Lit(..) | ast::ExprKind::Ident(..)); + let allow_break = overflows && fits_alone; + + self.return_bin_expr = is_binary_expr(&expr.kind); + self.s.ibox(if is_simple || allow_break { self.ind } else { 0 }); + + self.print_word("return"); + + match self.print_comments( + expr.span.lo(), + CommentConfig::skip_ws().mixed_no_break().mixed_prev_space().mixed_post_nbsp(), + ) { + Some(_) if !is_simple => self.s.offset(self.ind), + None => self.print_sep(Separator::SpaceOrNbsp(allow_break)), + _ => {} + } + + self.print_expr(expr); + self.end(); + self.return_bin_expr = false; + } else { + self.print_word("return"); + } + } + + /// Prints a `try` statement along with its associated `catch` clauses, + /// following Solidity's `try ... returns (...) { ... } catch (...) { ... }` syntax. + fn print_try_stmt( + &mut self, + expr: &'ast ast::Expr<'ast>, + clauses: &'ast [ast::TryCatchClause<'ast>], + ) { + self.cbox(0); + if let Some((first, other)) = clauses.split_first() { + // Print the 'try' clause + let ast::TryCatchClause { args, block, span: try_span, .. } = first; + self.cbox(0); + self.ibox(0); + self.print_word("try "); + self.print_comments(expr.span.lo(), CommentConfig::skip_ws()); + self.print_expr(expr); + + // Print comments. + self.print_comments( + args.first().map(|p| p.span.lo()).unwrap_or_else(|| expr.span.lo()), + CommentConfig::skip_ws(), + ); + if !self.is_beginning_of_line() { + self.nbsp(); + } + + if args.is_empty() { + self.end(); + } else { + self.print_word("returns "); + self.print_word("("); + self.zerobreak(); + self.end(); + let span = args.span.with_hi(block.span.lo()); + self.commasep( + args, + span.lo(), + span.hi(), + |fmt, var| fmt.print_var(var, false), + get_span!(), + ListFormat::compact().with_delimiters(false), + ); + self.print_word(")"); + self.nbsp(); + } + if block.is_empty() { + self.print_block(block, *try_span); + self.end(); + } else { + self.print_word("{"); + self.end(); + self.neverbreak(); + self.print_trailing_comment_no_break(try_span.lo(), None); + self.print_block_without_braces(block, try_span.hi(), Some(self.ind)); + if self.cursor.enabled || self.cursor.pos < try_span.hi() { + self.print_word("}"); + self.cursor.advance_to(try_span.hi(), true); + } + } + + let mut skip_ind = false; + if self.print_trailing_comment(try_span.hi(), other.first().map(|c| c.span.lo())) { + // if a trailing comment is printed at the very end, we have to manually + // adjust the offset to avoid having a double break. + self.break_offset_if_not_bol(0, self.ind, false); + skip_ind = true; + }; + + let mut prev_block_multiline = self.is_multiline_block(block, false, true); + + // Handle 'catch' clauses + for (pos, ast::TryCatchClause { name, args, block, span: catch_span }) in + other.iter().delimited() + { + let current_block_multiline = self.is_multiline_block(block, false, true); + if !pos.is_first || !skip_ind { + if (pos.is_first && block.is_empty() && is_call_with_named_args(&expr.kind)) + || (prev_block_multiline && (current_block_multiline || pos.is_last)) + { + self.nbsp(); + } else { + self.space(); + if !current_block_multiline { + self.s.offset(self.ind); + } + } + } + self.s.ibox(self.ind); + self.print_comments( + catch_span.lo(), + CommentConfig::skip_ws().mixed_no_break().mixed_post_nbsp(), + ); + + self.print_word("catch "); + if !args.is_empty() { + self.print_comments( + args[0].span.lo(), + CommentConfig::skip_ws().mixed_no_break().mixed_post_nbsp(), + ); + if let Some(name) = name { + self.print_ident(name); + } + self.print_parameter_list( + args, + args.span.with_hi(block.span.lo()), + ListFormat::inline(), + ); + self.nbsp(); + } + self.print_word("{"); + self.end(); + if !block.is_empty() { + self.print_trailing_comment_no_break(catch_span.lo(), None); + } + self.print_block_without_braces(block, catch_span.hi(), Some(self.ind)); + if self.cursor.enabled || self.cursor.pos < try_span.hi() { + self.print_word("}"); + self.cursor.advance_to(catch_span.hi(), true); + } + + prev_block_multiline = current_block_multiline; + } + } + self.end(); + } + + fn print_if_no_else( + &mut self, + cond: &'ast ast::Expr<'ast>, + then: &'ast ast::Stmt<'ast>, + inline: bool, + ) { + if !self.handle_span(cond.span.until(then.span), true) { + self.print_if_cond("if", cond, then.span.lo()); + // if empty block without comments, ensure braces are inlined + if let ast::StmtKind::Block(block) = &then.kind + && block.is_empty() + && self.peek_comment_before(then.span.hi()).is_none() + { + self.neverbreak(); + self.print_sep(Separator::Nbsp); + } else if inline + && is_call(&cond.kind) + && matches!( + self.config.single_line_statement_blocks, + config::SingleLineBlockStyle::Preserve + ) + { + // Keep the body beside a wrapped call condition so Preserve sees the same + // layout on the next pass. + self.print_sep(Separator::Nbsp); + } else { + self.print_sep(Separator::Space); + } + } + self.end(); + self.print_stmt_as_block(then, then.span.hi(), inline); + self.cursor.advance_to(then.span.hi(), true); + } + + fn print_if_cond(&mut self, kw: &'static str, cond: &'ast ast::Expr<'ast>, pos_hi: BytePos) { + self.print_word(kw); + self.print_sep_unhandled(Separator::Nbsp); + self.print_tuple( + std::slice::from_ref(cond), + cond.span.lo(), + pos_hi, + Self::print_expr, + get_span!(), + ListFormat::compact().break_cmnts().break_single(is_binary_expr(&cond.kind)), + ); + } + + fn print_emit_or_revert( + &mut self, + kw: &'static str, + path: &'ast ast::PathSlice, + args: &'ast ast::CallArgs<'ast>, + ) { + self.word(kw); + if self + .print_comments( + path.span().lo(), + CommentConfig::skip_ws().mixed_no_break().mixed_prev_space().mixed_post_nbsp(), + ) + .is_none() + { + self.nbsp(); + }; + self.s.cbox(0); + self.emit_or_revert = path.segments().len() > 1; + self.print_path(path, false); + let format = if self.config.prefer_compact.calls() { + ListFormat::compact() + } else { + ListFormat::consistent() + }; + self.print_call_args(args, format.break_cmnts(), path.to_string().len(), false); + self.emit_or_revert = false; + self.end(); + } + + fn print_block(&mut self, block: &'ast [ast::Stmt<'ast>], span: Span) { + self.print_block_inner( + block, + BlockFormat::Regular, + Self::print_stmt, + |b| b.span, + span.hi(), + ); + } + + fn print_block_without_braces( + &mut self, + block: &'ast [ast::Stmt<'ast>], + pos_hi: BytePos, + offset: Option, + ) { + self.print_block_inner( + block, + BlockFormat::NoBraces(offset), + Self::print_stmt, + |b| b.span, + pos_hi, + ); + } + + // Body of a if/loop. + fn print_stmt_as_block(&mut self, stmt: &'ast ast::Stmt<'ast>, pos_hi: BytePos, inline: bool) { + if self.handle_span(stmt.span, false) { + return; + } + + let stmts = if let ast::StmtKind::Block(stmts) = &stmt.kind { + stmts + } else { + std::slice::from_ref(stmt) + }; + + if inline && stmts.len() == 1 { + self.neverbreak(); + self.print_block_without_braces(stmts, pos_hi, None); + } else { + // Reset cache for nested (child) stmts within this (parent) block. + let inline_parent = self.single_line_stmt.take(); + + self.print_word("{"); + self.print_block_without_braces(stmts, pos_hi, Some(self.ind)); + self.print_word("}"); + + // Restore cache for the rest of stmts within the same height. + self.single_line_stmt = inline_parent; + } + } + + /// Determines if an `if/else` block should be inlined. + /// Also returns if the value was cached, so that it can be cleaned afterwards. + /// + /// # Returns + /// + /// A tuple `(should_inline, was_cached)`. The second boolean is `true` if the + /// decision was retrieved from the cache or is a final decision based on config, + /// preventing the caller from clearing a cache value that was never set. + fn is_single_line_block( + &mut self, + stmt_span_lo: BytePos, + cond: &'ast ast::Expr<'ast>, + then: &'ast ast::Stmt<'ast>, + els_opt: Option<&'ast &'ast mut ast::Stmt<'ast>>, + ) -> Decision { + // Dangling-else guard runs before the cache check so an inlined parent can't + // coerce this `if` into dropping braces and rebinding its `else`. + if Self::then_block_can_capture_trailing_else(then, els_opt.is_some()) { + return Decision { outcome: false, is_cached: false }; + } + + // If a decision is already cached from a parent, use it directly. + if let Some(cached_decision) = self.single_line_stmt { + return Decision { outcome: cached_decision, is_cached: true }; + } + + // Empty statements are always printed as blocks. + if std::slice::from_ref(then).is_empty() { + return Decision { outcome: false, is_cached: false }; + } + + // Comments near cond can break single-line layouts. Print as blocks in this case + if self.peek_comment_between(stmt_span_lo, then.span.lo()).is_some() { + return Decision { outcome: false, is_cached: false }; + } + + // If possible, take an early decision based on the block style configuration. + match self.config.single_line_statement_blocks { + config::SingleLineBlockStyle::Preserve => { + if self.is_stmt_in_new_line(cond, then) || self.is_multiline_block_stmt(then, true) + { + return Decision { outcome: false, is_cached: false }; + } + } + config::SingleLineBlockStyle::Single => { + if self.is_multiline_block_stmt(then, true) { + return Decision { outcome: false, is_cached: false }; + } + } + config::SingleLineBlockStyle::Multi => { + return Decision { outcome: false, is_cached: false }; + } + }; + + // If no decision was made, estimate the length to be formatted. + // NOTE: conservative check -> worst-case scenario is formatting as multi-line block. + if !self.can_stmts_be_inlined(cond, then, els_opt) { + return Decision { outcome: false, is_cached: false }; + } + + // If the parent would fit, check all of its children. + if let ast::StmtKind::If(child_cond, child_then, child_els_opt) = &then.kind { + let child_decision = self.is_single_line_block( + then.span.lo(), + child_cond, + child_then, + child_els_opt.as_ref(), + ); + if !child_decision.outcome { + return child_decision; + } + } + if let Some(stmt) = els_opt { + if let ast::StmtKind::If(child_cond, child_then, child_els_opt) = &stmt.kind { + return self.is_single_line_block( + stmt.span.lo(), + child_cond, + child_then, + child_els_opt.as_ref(), + ); + } else if self.is_multiline_block_stmt(stmt, true) { + return Decision { outcome: false, is_cached: false }; + } + } + + // If all children can also fit, allow single-line block. + Decision { outcome: true, is_cached: false } + } + + fn is_inline_stmt(&self, stmt: &'ast ast::Stmt<'ast>, cond_len: usize) -> bool { + if let ast::StmtKind::If(cond, then, els_opt) = &stmt.kind { + let if_span = cond.span.to(then.span); + if !self.same_source_line(if_span.lo(), if_span.hi()) + && matches!( + self.config.single_line_statement_blocks, + config::SingleLineBlockStyle::Preserve + ) + { + return false; + } + if cond_len + self.estimate_size(if_span) >= self.space_left() { + return false; + } + if let Some(els) = els_opt + && !self.is_inline_stmt(els, 6) + { + return false; + } + } else { + if matches!( + self.config.single_line_statement_blocks, + config::SingleLineBlockStyle::Preserve + ) && !self.same_source_line(stmt.span.lo(), stmt.span.hi()) + { + return false; + } + if cond_len + self.estimate_size(stmt.span) >= self.space_left() { + return false; + } + } + true + } + + /// Checks if a statement was explicitly written in a new line. + fn is_stmt_in_new_line( + &self, + cond: &'ast ast::Expr<'ast>, + then: &'ast ast::Stmt<'ast>, + ) -> bool { + let span_between = cond.span.between(then.span); + if let Some(snip) = self.snippet(span_between) { + // Check for newlines after the closing parenthesis of the `if (...)`. + if let Some((_, after_paren)) = snip.split_once(')') { + return after_paren.lines().count() > 1; + } + } + false + } + + /// Returns true if eliding the braces of `then` would expose an inner `if` to a + /// trailing `else` or change the AST shape on round-trip. + fn then_block_can_capture_trailing_else( + then: &'ast ast::Stmt<'ast>, + has_outer_else: bool, + ) -> bool { + let ast::StmtKind::Block(block) = &then.kind else { return false }; + if block.stmts.len() != 1 { + return false; + } + match &block.stmts[0].kind { + ast::StmtKind::If(_, _, inner_else) => has_outer_else || inner_else.is_some(), + ast::StmtKind::While(..) | ast::StmtKind::For { .. } => has_outer_else, + _ => false, + } + } + + /// Checks if a block statement `{ ... }` contains more than one line of actual code. + fn is_multiline_block_stmt( + &mut self, + stmt: &'ast ast::Stmt<'ast>, + empty_as_multiline: bool, + ) -> bool { + match &stmt.kind { + ast::StmtKind::Block(block) => { + self.is_multiline_block(block, empty_as_multiline, false) + } + ast::StmtKind::While(cond, body) => { + !self.is_single_line_block(stmt.span.lo(), cond, body, None).outcome + } + ast::StmtKind::For { body, .. } => { + // In `print_for_stmt`, `print_stmt_as_block(body, span.hi(), false)` is called with + // `inline = false`. So only empty can be single-line. + if let ast::StmtKind::Block(block) = &body.kind { + self.is_multiline_block(block, empty_as_multiline, true) + } else { + true + } + } + + ast::StmtKind::If(_, _, Some(_)) => true, + ast::StmtKind::If(_, then, None) => { + self.is_multiline_block_stmt(then, empty_as_multiline) + } + + // these ones always has an inner block, so we mark them as multiline + ast::StmtKind::Assembly(_) + | ast::StmtKind::DoWhile(_, _) + | ast::StmtKind::Try(_) + | ast::StmtKind::UncheckedBlock(_) => true, + + ast::StmtKind::Break + | ast::StmtKind::Continue + | ast::StmtKind::DeclMulti(_, _) + | ast::StmtKind::DeclSingle(_) + | ast::StmtKind::Emit(_, _) + | ast::StmtKind::Expr(_) + | ast::StmtKind::Return(_) + | ast::StmtKind::Revert(_, _) + | ast::StmtKind::Placeholder => false, + } + } + + /// Checks if a block statement `{ ... }` should be treated as multiline, + /// either because it spans multiple lines or contains multiple statements. + fn is_multiline_block( + &mut self, + block: &'ast ast::Block<'ast>, + empty_as_multiline: bool, + force_single_as_multiline: bool, + ) -> bool { + if block.stmts.is_empty() { + return empty_as_multiline; + } + // A block with multiple statements should never be inlined, regardless of + // whether it was written on a single line in the source. + if block.stmts.len() > 1 { + return true; + } + + if force_single_as_multiline { + return true; + } + + // Check for multiline block.span first. + // Block can spans multipline because of comments. + if !self.same_source_line(block.span.lo(), block.span.hi()) + && let Some(snip) = self.snippet(block.span) + { + let code_lines = snip.lines().filter(|line| { + let trimmed = line.trim(); + // Ignore empty lines and lines with only '{' or '}' + if empty_as_multiline { + !trimmed.is_empty() && trimmed != "{" && trimmed != "}" + } else { + !trimmed.is_empty() + } + }); + if code_lines.count() > 1 { + return true; + } + } + + let stmt = &block.stmts[0]; + + // Comments can break single-line layout. Mark block as multiline if there is a comment at + // the beginning. + if self.peek_comment_between(block.span.lo(), stmt.span.lo()).is_some() { + return true; + } + + self.is_multiline_block_stmt(stmt, empty_as_multiline) + } + + /// Performs a size estimation to see if the if/else can fit on one line. + fn can_stmts_be_inlined( + &mut self, + cond: &'ast ast::Expr<'ast>, + then: &'ast ast::Stmt<'ast>, + els_opt: Option<&'ast &'ast mut ast::Stmt<'ast>>, + ) -> bool { + let cond_len = self.estimate_size(cond.span); + + // If the condition fits in one line, 6 chars: 'if (' + {cond} + ') ' + {then} + // Otherwise chars: ') ' + {then} + let then_margin = if 6 + cond_len < self.space_left() { 6 + cond_len } else { 2 }; + + if !self.is_inline_stmt(then, then_margin) { + return false; + } + + // Always 6 chars for the else: 'else ' + els_opt.is_none_or(|els| self.is_inline_stmt(els, 6)) + } + + fn can_header_be_inlined(&mut self, func: &ast::ItemFunction<'_>) -> bool { + self.estimate_header_size(func) <= self.space_left() + } + + fn can_header_params_be_inlined(&mut self, func: &ast::ItemFunction<'_>) -> bool { + self.estimate_header_params_size(func) <= self.space_left() + } + + fn estimate_header_size(&mut self, func: &ast::ItemFunction<'_>) -> usize { + let ast::ItemFunction { kind: _, ref header, ref body, body_span: _ } = *func; + + // ' ' + visibility + let visibility = header.visibility.map_or(0, |v| self.estimate_size(v.span) + 1); + // ' ' + state mutability + let mutability = header.state_mutability.map_or(0, |sm| self.estimate_size(sm.span) + 1); + // ' ' + modifier + (' ' + modifier) + let m = header.modifiers.iter().fold(0, |len, m| len + self.estimate_size(m.span())); + let modifiers = if m != 0 { m + 1 } else { 0 }; + // ' ' + override + let override_ = header.override_.as_ref().map_or(0, |o| self.estimate_size(o.span) + 1); + // ' ' + virtual + let virtual_ = if header.virtual_.is_none() { 0 } else { 8 }; + // ' returns(' + var + (', ' + var) + ')' + let returns = header.returns.as_ref().map_or(0, |ret| { + ret.vars + .iter() + .fold(0, |len, p| if len != 0 { len + 2 } else { 10 } + self.estimate_size(p.span)) + }); + // ' {' or ';' + let end = if body.is_some() { 2 } else { 1 }; + + self.estimate_header_params_size(func) + + visibility + + mutability + + modifiers + + override_ + + virtual_ + + returns + + end + } + + fn estimate_header_params_size(&mut self, func: &ast::ItemFunction<'_>) -> usize { + let ast::ItemFunction { kind, ref header, body: _, body_span: _ } = *func; + + let kw = match kind { + ast::FunctionKind::Constructor => 11, // 'constructor' + ast::FunctionKind::Function => 9, // 'function ' + ast::FunctionKind::Modifier => 9, // 'modifier ' + ast::FunctionKind::Fallback => 8, // 'fallback' + ast::FunctionKind::Receive => 7, // 'receive' + }; + + // '(' + param + (', ' + param) + ')' + let params = header + .parameters + .vars + .iter() + .fold(0, |len, p| if len != 0 { len + 2 } else { 2 } + self.estimate_size(p.span)); + + kw + header.name.map_or(0, |name| self.estimate_size(name.span)) + std::cmp::max(2, params) + } + + /// Estimates a comment-free binary expression using the printed operator spacing. + fn estimate_binary_size(&self, expr: &ast::Expr<'_>) -> usize { + match &expr.kind { + ast::ExprKind::Binary(lhs, op, rhs) => { + let spaces = if self.config.pow_no_space && matches!(op.kind, ast::BinOpKind::Pow) { + 0 + } else { + 2 + }; + self.estimate_binary_size(lhs) + + op.kind.to_str().len() + + spaces + + self.estimate_binary_size(rhs) + } + ast::ExprKind::Tuple(exprs) if let [SpannedOption::Some(inner)] = exprs.as_ref() => { + self.estimate_binary_size(inner) + 2 + } + _ => self.estimate_size(expr.span), + } + } + + fn estimate_lhs_size(&self, expr: &ast::Expr<'_>, parent_op: &ast::BinOp) -> usize { + match &expr.kind { + ast::ExprKind::Binary(lhs, op, _) if op.kind.group() == parent_op.kind.group() => { + self.estimate_lhs_size(lhs, op) + } + _ => self.estimate_size(expr.span), + } + } + + fn estimate_call_chain_size(&self, expr: &ast::Expr<'_>) -> Option { + match &expr.kind { + ast::ExprKind::Call(callee, args) => { + let ast::CallArgsKind::Unnamed(args) = &args.kind else { return None }; + let mut size = self.estimate_call_chain_size(callee)? + 2; + for arg in args.iter() { + size += self.estimate_call_chain_size(arg)?; + } + Some(size + args.len().saturating_sub(1) * 2) + } + ast::ExprKind::Ident(ident) => Some(ident.to_string().len()), + ast::ExprKind::Index(expr, kind) => { + let index_size = match kind { + ast::IndexKind::Index(Some(index)) => self.estimate_call_chain_size(index)?, + ast::IndexKind::Index(None) => 0, + ast::IndexKind::Range(start, end) => { + let start = match start { + Some(start) => self.estimate_call_chain_size(start)?, + None => 0, + }; + let end = match end { + Some(end) => self.estimate_call_chain_size(end)?, + None => 0, + }; + start + end + 1 + } + }; + Some(self.estimate_call_chain_size(expr)? + index_size + 2) + } + // Zero is invariant under all number underscore configurations. + ast::ExprKind::Lit(lit, None) + if matches!(lit.kind, ast::LitKind::Number(_)) && lit.symbol.as_str() == "0" => + { + Some(1) + } + ast::ExprKind::Member(expr, ident) => { + Some(self.estimate_call_chain_size(expr)? + ident.to_string().len() + 1) + } + ast::ExprKind::Tuple(exprs) if let [SpannedOption::Some(expr)] = exprs.as_ref() => { + Some(self.estimate_call_chain_size(expr)? + 2) + } + _ => None, + } + } + + fn has_comments_between_elements(&self, limits: Span, elements: I) -> bool + where + I: IntoIterator>, + { + let mut last_span_end = limits.lo(); + for expr in elements { + if self.has_comment_between(last_span_end, expr.span.lo()) { + return true; + } + last_span_end = expr.span.hi(); + } + + self.has_comment_between(last_span_end, limits.hi()) + } +} + +// -- HELPERS (language-specific) ---------------------------------------------- + +#[derive(Debug)] +enum MemberOrCallArgs { + Member(usize), + CallArgs(usize, bool), +} + +impl MemberOrCallArgs { + const fn size(&self) -> usize { + match self { + Self::CallArgs(size, ..) | Self::Member(size) => *size, + } + } + + const fn member_size(&self) -> usize { + match self { + Self::CallArgs(..) => 0, + Self::Member(size) => *size, + } + } + + const fn has_comments(&self) -> bool { + matches!(self, Self::CallArgs(.., true)) + } +} + +#[derive(Debug, Clone)] +#[expect(dead_code)] +enum AttributeKind<'ast> { + Visibility(ast::Visibility), + StateMutability(ast::StateMutability), + Virtual, + Override(&'ast ast::Override<'ast>), + Modifier(&'ast ast::Modifier<'ast>), +} + +type AttributeCommentMap = HashMap, Vec, Vec)>; + +#[derive(Debug, Clone)] +struct AttributeInfo<'ast> { + kind: AttributeKind<'ast>, + span: Span, +} + +/// Helper struct to map attributes to their associated comments in function headers. +struct AttributeCommentMapper<'ast> { + limit_pos: BytePos, + comments: Vec, + attributes: Vec>, +} + +impl<'ast> AttributeCommentMapper<'ast> { + fn new(returns: Option<&'ast ast::ParameterList<'ast>>, body_pos: BytePos) -> Self { + Self { + comments: Vec::new(), + attributes: Vec::new(), + limit_pos: returns.as_ref().map_or(body_pos, |ret| ret.span.lo()), + } + } + + #[allow(clippy::type_complexity)] + fn build( + mut self, + state: &mut State<'_, 'ast>, + header: &'ast ast::FunctionHeader<'ast>, + ) -> (AttributeCommentMap, Vec>, BytePos) { + let first_attr = self.collect_attributes(header); + if !self.attributes.is_empty() { + self.cache_comments(state); + } + (self.map(), self.attributes, first_attr) + } + + fn map(&mut self) -> AttributeCommentMap { + let mut map = HashMap::new(); + for a in 0..self.attributes.len() { + let is_last = a == self.attributes.len() - 1; + let (mut before, mut inner, mut after) = (Vec::new(), Vec::new(), Vec::new()); + + let before_limit = self.attributes[a].span.lo(); + let inner_limit = self.attributes[a].span.hi(); + let after_limit = + if is_last { self.limit_pos } else { self.attributes[a + 1].span.lo() }; + + let mut c = 0; + while c < self.comments.len() { + if self.comments[c].pos() <= before_limit { + before.push(self.comments.remove(c)); + } else if self.comments[c].pos() <= inner_limit { + inner.push(self.comments.remove(c)); + } else if (after.is_empty() || is_last) && self.comments[c].pos() <= after_limit { + after.push(self.comments.remove(c)); + } else { + c += 1; + } + } + map.insert(before_limit, (before, inner, after)); + } + map + } + + fn collect_attributes(&mut self, header: &'ast ast::FunctionHeader<'ast>) -> BytePos { + let mut first_pos = BytePos(u32::MAX); + if let Some(v) = header.visibility { + if v.span.lo() < first_pos { + first_pos = v.span.lo() + } + self.attributes + .push(AttributeInfo { kind: AttributeKind::Visibility(*v), span: v.span }); + } + if let Some(sm) = header.state_mutability { + if sm.span.lo() < first_pos { + first_pos = sm.span.lo() + } + self.attributes + .push(AttributeInfo { kind: AttributeKind::StateMutability(*sm), span: sm.span }); + } + if let Some(span) = header.virtual_ { + if span.lo() < first_pos { + first_pos = span.lo() + } + self.attributes.push(AttributeInfo { kind: AttributeKind::Virtual, span }); + } + if let Some(ref o) = header.override_ { + if o.span.lo() < first_pos { + first_pos = o.span.lo() + } + self.attributes.push(AttributeInfo { kind: AttributeKind::Override(o), span: o.span }); + } + for m in header.modifiers.iter() { + if m.span().lo() < first_pos { + first_pos = m.span().lo() + } + self.attributes + .push(AttributeInfo { kind: AttributeKind::Modifier(m), span: m.span() }); + } + self.attributes.sort_by_key(|attr| attr.span.lo()); + first_pos + } + + fn cache_comments(&mut self, state: &mut State<'_, 'ast>) { + let mut pending = None; + for cmnt in state.comments.iter() { + if cmnt.pos() >= self.limit_pos { + break; + } + match pending { + Some(ref p) => pending = Some(p + 1), + None => pending = Some(0), + } + } + while let Some(p) = pending { + if p == 0 { + pending = None; + } else { + pending = Some(p - 1); + } + let cmnt = state.next_comment().unwrap(); + if cmnt.style.is_blank() { + continue; + } + self.comments.push(cmnt); + } + } +} + +const fn stmt_needs_semi(stmt: &ast::StmtKind<'_>) -> bool { + match stmt { + ast::StmtKind::Assembly { .. } + | ast::StmtKind::Block { .. } + | ast::StmtKind::For { .. } + | ast::StmtKind::If { .. } + | ast::StmtKind::Try { .. } + | ast::StmtKind::UncheckedBlock { .. } + | ast::StmtKind::While { .. } => false, + + ast::StmtKind::DeclSingle { .. } + | ast::StmtKind::DeclMulti { .. } + | ast::StmtKind::Break { .. } + | ast::StmtKind::Continue { .. } + | ast::StmtKind::DoWhile { .. } + | ast::StmtKind::Emit { .. } + | ast::StmtKind::Expr { .. } + | ast::StmtKind::Return { .. } + | ast::StmtKind::Revert { .. } + | ast::StmtKind::Placeholder { .. } => true, + } +} + +/// Returns `true` if the item needs an isolated line break. +fn item_needs_iso(item: &ast::ItemKind<'_>) -> bool { + match item { + ast::ItemKind::Pragma(..) + | ast::ItemKind::Import(..) + | ast::ItemKind::Using(..) + | ast::ItemKind::Variable(..) + | ast::ItemKind::Udvt(..) + | ast::ItemKind::Enum(..) + | ast::ItemKind::Error(..) + | ast::ItemKind::Event(..) => false, + + ast::ItemKind::Contract(..) => true, + + ast::ItemKind::Struct(strukt) => !strukt.fields.is_empty(), + ast::ItemKind::Function(func) => { + func.body.as_ref().is_some_and(|b| !b.is_empty()) + && !matches!(func.kind, ast::FunctionKind::Modifier) + } + } +} + +const fn is_binary_expr(expr_kind: &ast::ExprKind<'_>) -> bool { + matches!(expr_kind, ast::ExprKind::Binary(..)) +} + +fn has_complex_successor(expr_kind: &ast::ExprKind<'_>, left: bool) -> bool { + match expr_kind { + ast::ExprKind::Binary(lhs, _, rhs) => { + if left { + has_complex_successor(&lhs.kind, left) + } else { + has_complex_successor(&rhs.kind, left) + } + } + ast::ExprKind::Unary(_, expr) => has_complex_successor(&expr.kind, left), + ast::ExprKind::Lit(..) | ast::ExprKind::Ident(_) => false, + ast::ExprKind::Tuple(..) => false, + _ => true, + } +} + +const fn is_call(expr_kind: &ast::ExprKind<'_>) -> bool { + matches!(expr_kind, ast::ExprKind::Call(..)) +} + +/// Returns true if this is a call with named arguments (struct-style syntax). +/// Used to determine if `.field` after such a call should avoid breaking. +/// E.g., `_lzSend({_dstEid: x, ...}).guid` → true (named args call) +/// E.g., `someFunc(a, b).field` → false (positional args) +const fn is_call_with_named_args(expr_kind: &ast::ExprKind<'_>) -> bool { + if let ast::ExprKind::Call(_, args) = expr_kind { + matches!(args.kind, ast::CallArgsKind::Named(_)) + } else { + false + } +} + +fn is_call_chain(expr_kind: &ast::ExprKind<'_>, must_have_child: bool) -> bool { + match expr_kind { + ast::ExprKind::Index(child, ..) | ast::ExprKind::Member(child, ..) => { + is_call_chain(&child.kind, false) + } + ast::ExprKind::Tuple(exprs) if let [SpannedOption::Some(child)] = exprs.as_ref() => { + is_call_chain(&child.kind, must_have_child) + } + _ => !must_have_child && is_call(expr_kind), + } +} + +fn call_chain_contains_options(expr: &ast::Expr<'_>) -> bool { + match &expr.peel_parens().kind { + ast::ExprKind::CallOptions(..) => true, + ast::ExprKind::Call(expr, ..) + | ast::ExprKind::Index(expr, ..) + | ast::ExprKind::Member(expr, ..) => call_chain_contains_options(expr), + _ => false, + } +} + +fn is_call_with_opts_and_args(expr_kind: &ast::ExprKind<'_>) -> bool { + if let ast::ExprKind::Call(call_expr, call_args) = expr_kind { + matches!(call_expr.kind, ast::ExprKind::CallOptions(..)) && !call_args.is_empty() + } else { + false + } +} + +#[derive(Debug)] +struct Decision { + outcome: bool, + is_cached: bool, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum BinOpGroup { + Arithmetic, + Bitwise, + Comparison, + Logical, +} + +trait BinOpExt { + fn group(&self) -> BinOpGroup; +} + +impl BinOpExt for ast::BinOpKind { + fn group(&self) -> BinOpGroup { + match self { + Self::Or | Self::And => BinOpGroup::Logical, + Self::Eq | Self::Ne | Self::Lt | Self::Le | Self::Gt | Self::Ge => { + BinOpGroup::Comparison + } + Self::BitOr | Self::BitXor | Self::BitAnd | Self::Shl | Self::Shr | Self::Sar => { + BinOpGroup::Bitwise + } + Self::Add | Self::Sub | Self::Mul | Self::Div | Self::Rem | Self::Pow => { + BinOpGroup::Arithmetic + } + } + } +} + +/// Calculates the size the callee's "head," excluding its arguments. +/// +/// # Examples +/// +/// - `myFunction(..)`: 8 (length of `myFunction`) +/// - `uint256(..)`: 7 (length of `uint256`) +/// - `abi.encode(..)`: 10 (length of `abi.encode`) +/// - `foo(..).bar(..)`: 3 (length of `foo`) +pub(super) fn get_callee_head_size(callee: &ast::Expr<'_>) -> usize { + match &callee.kind { + ast::ExprKind::Ident(id) => id.as_str().len(), + ast::ExprKind::Type(ast::Type { kind: ast::TypeKind::Elementary(ty), .. }) => { + ty.to_abi_str().len() + } + ast::ExprKind::Index(base, idx) => { + let idx_len = match idx { + ast::IndexKind::Index(expr) => expr.as_ref().map_or(0, |e| get_callee_head_size(e)), + ast::IndexKind::Range(e1, e2) => { + 1 + e1.as_ref().map_or(0, |e| get_callee_head_size(e)) + + e2.as_ref().map_or(0, |e| get_callee_head_size(e)) + } + }; + get_callee_head_size(base) + 2 + idx_len + } + ast::ExprKind::Member(base, member_ident) => { + match &base.kind { + ast::ExprKind::Ident(..) | ast::ExprKind::Type(..) => { + get_callee_head_size(base) + 1 + member_ident.as_str().len() + } + + // Chainned calls are not traversed, and instead just the member identifier is used + ast::ExprKind::Member(child, ..) if !is_call(&child.kind) => { + get_callee_head_size(base) + 1 + member_ident.as_str().len() + } + _ => member_ident.as_str().len(), + } + } + ast::ExprKind::Binary(lhs, _, _) => get_callee_head_size(lhs), + + // If the callee is not an identifier or member access, it has no "head" + _ => 0, + } +} + +/// Counts member links in an assignment RHS, through calls and indexes. +fn assignment_member_depth(expr: &ast::Expr<'_>) -> usize { + match &expr.kind { + ast::ExprKind::Member(child, _) => 1 + assignment_member_depth(child), + ast::ExprKind::Call(child, _) | ast::ExprKind::Index(child, _) => { + assignment_member_depth(child) + } + _ => 0, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{FormatterConfig, InlineConfig}; + use foundry_common::comments::Comments; + use solar::{ + interface::{Session, source_map::FileName}, + sema::Compiler, + }; + use std::sync::Arc; + + /// This helper extracts function headers from the AST and passes them to the test function. + fn parse_and_test(source: &str, test_fn: F) + where + F: FnOnce(&mut State<'_, '_>, &ast::ItemFunction<'_>) + Send, + { + let session = Session::builder().with_buffer_emitter(Default::default()).build(); + let mut compiler = Compiler::new(session); + + compiler + .enter_mut(|c| -> solar::interface::Result<()> { + let mut pcx = c.parse(); + pcx.set_resolve_imports(false); + + // Create a source file using stdin as the filename + let file = c + .sess() + .source_map() + .new_source_file(FileName::Stdin, source) + .map_err(|e| c.sess().dcx.err(e.to_string()).emit())?; + + pcx.add_file(file.clone()); + pcx.parse(); + c.dcx().has_errors()?; + + // Get AST from parsed source and setup the formatter + let gcx = c.gcx(); + let (_, source_obj) = gcx.get_ast_source(&file.name).expect("Failed to get AST"); + let ast = source_obj.ast.as_ref().expect("No AST found"); + let comments = + Comments::new(&source_obj.file, gcx.sess.source_map(), true, false, None); + let config = Arc::new(FormatterConfig::default()); + let inline_config = InlineConfig::default(); + let mut state = State::new(&source_obj.file, config, inline_config, comments); + + // Extract the first function header (either top-level or inside a contract) + let func = ast + .items + .iter() + .find_map(|item| match &item.kind { + ast::ItemKind::Function(func) => Some(func), + ast::ItemKind::Contract(contract) => { + contract.body.iter().find_map(|contract_item| { + match &contract_item.kind { + ast::ItemKind::Function(func) => Some(func), + _ => None, + } + }) + } + _ => None, + }) + .expect("No function found in source"); + + // Run the closure + test_fn(&mut state, func); + + Ok(()) + }) + .expect("Test failed"); + } + + #[test] + fn test_estimate_header_sizes() { + let test_cases = [ + ("function foo();", 14, 15), + ("function foo() {}", 14, 16), + ("function foo() public {}", 14, 23), + ("function foo(uint256 a) public {}", 23, 32), + ("function foo(uint256 a, address b, bool c) public {}", 42, 51), + ("function foo() public pure {}", 14, 28), + ("function foo() public virtual {}", 14, 31), + ("function foo() public override {}", 14, 32), + ("function foo() public onlyOwner {}", 14, 33), + ("function foo() public returns(uint256) {}", 14, 40), + ("function foo() public returns(uint256, address) {}", 14, 49), + ("function foo(uint256 a) public virtual override returns(uint256) {}", 23, 66), + ("function foo() external payable {}", 14, 33), + // other function types + ("contract C { constructor() {} }", 13, 15), + ("contract C { constructor(uint256 a) {} }", 22, 24), + ("contract C { modifier onlyOwner() {} }", 20, 22), + ("contract C { modifier onlyRole(bytes32 role) {} }", 31, 33), + ("contract C { fallback() external payable {} }", 10, 29), + ("contract C { receive() external payable {} }", 9, 28), + ]; + + for (source, expected_params, expected_header) in &test_cases { + parse_and_test(source, |state, func| { + let params_size = state.estimate_header_params_size(func); + assert_eq!( + params_size, *expected_params, + "Failed params size: expected {expected_params}, got {params_size} for source: {source}", + ); + + let header_size = state.estimate_header_size(func); + assert_eq!( + header_size, *expected_header, + "Failed header size: expected {expected_header}, got {header_size} for source: {source}", + ); + }); + } + } +} diff --git a/crates/fmt/src/state/yul.rs b/crates/fmt/src/state/yul.rs new file mode 100644 index 0000000000000..7a8258dca4dc0 --- /dev/null +++ b/crates/fmt/src/state/yul.rs @@ -0,0 +1,350 @@ +#![allow(clippy::too_many_arguments)] + +use super::{ + CommentConfig, State, + common::{BlockFormat, ListFormat}, +}; +use solar::parse::ast::{self, Span, yul}; + +#[rustfmt::skip] +macro_rules! get_span { + () => { |value| value.span }; + (()) => { |value| value.span() }; +} + +/// Language-specific pretty printing: Yul. +impl<'ast> State<'_, 'ast> { + fn print_lit_yul(&mut self, lit: &'ast ast::Lit<'ast>) { + self.print_lit_inner(lit, true); + } + + pub(crate) fn print_yul_stmt(&mut self, stmt: &'ast yul::Stmt<'ast>) { + let yul::Stmt { ref docs, span, ref kind } = *stmt; + self.print_docs(docs); + if self.handle_span(span, false) { + return; + } + + match kind { + yul::StmtKind::Block(stmts) => self.print_yul_block(stmts, span, false, 0), + yul::StmtKind::AssignSingle(path, expr) => { + self.print_path(path, false); + self.word(" :="); + self.neverbreak(); + if self + .print_comments(expr.span.lo(), CommentConfig::skip_ws().mixed_prev_space()) + .is_none() + { + self.nbsp(); + } + self.cursor.advance_to(expr.span.lo(), self.cursor.enabled); + self.print_yul_expr(expr); + } + yul::StmtKind::AssignMulti(paths, expr_call) => { + self.ibox(0); + self.commasep( + paths, + stmt.span.lo(), + stmt.span.hi(), + |this, path| this.print_path(path, false), + get_span!(()), + ListFormat::consistent(), + ); + self.word(" :="); + self.space(); + self.s.offset(self.ind); + self.ibox(0); + self.print_yul_expr(expr_call); + self.end(); + self.end(); + } + yul::StmtKind::Expr(expr_call) => self.print_yul_expr(expr_call), + yul::StmtKind::If(expr, stmts) => { + self.print_word("if "); // 3 chars + self.print_yul_expr(expr); + self.nbsp(); // 1 char + self.print_yul_block(stmts, span, false, 4 + self.estimate_size(expr.span)); + } + yul::StmtKind::For(yul::StmtFor { init, cond, step, body }) => { + self.ibox(0); + + self.print_word("for "); // 4 chars + self.print_yul_block(init, init.span, false, 4); + + self.space(); + self.print_yul_expr(cond); + + self.space(); + self.print_yul_block(step, step.span, false, 0); + + self.space(); + self.print_yul_block(body, body.span, false, 0); + + self.end(); + } + yul::StmtKind::Switch(yul::StmtSwitch { selector, cases }) => { + self.print_word("switch "); + self.print_yul_expr(selector); + + self.print_trailing_comment(selector.span.hi(), None); + + for yul::StmtSwitchCase { constant, body, span } in cases.iter() { + self.hardbreak_if_not_bol(); + if let Some(constant) = constant { + self.print_comments( + constant.span.lo(), + CommentConfig::default().mixed_prev_space(), + ); + self.print_word("case "); + self.print_lit_yul(constant); + self.nbsp(); + } else { + self.print_comments( + body.span.lo(), + CommentConfig::default().mixed_prev_space(), + ); + self.print_word("default "); + } + self.print_yul_block(body, *span, false, 0); + + self.print_trailing_comment(selector.span.hi(), None); + } + } + yul::StmtKind::Leave => self.print_word("leave"), + yul::StmtKind::Break => self.print_word("break"), + yul::StmtKind::Continue => self.print_word("continue"), + yul::StmtKind::FunctionDef(func) => { + let yul::Function { name, parameters, returns, body } = func; + let params_hi = parameters + .last() + .map_or(returns.first().map_or(body.span.lo(), |r| r.span.lo()), |p| { + p.span.hi() + }); + + self.cbox(0); + self.s.ibox(0); + self.print_word("function "); + self.print_ident(name); + self.print_tuple( + parameters, + span.lo(), + params_hi, + Self::print_ident, + get_span!(), + ListFormat::consistent(), + ); + self.nbsp(); + let has_returns = !returns.is_empty(); + let skip_opening_brace = has_returns; + if self.can_yul_header_params_be_inlined(func) { + self.neverbreak(); + } + if has_returns { + self.commasep( + returns, + returns.first().map_or(params_hi, |ret| ret.span.lo()), + returns.last().map_or(body.span.lo(), |ret| ret.span.hi()), + Self::print_ident, + get_span!(), + ListFormat::yul(Some("->"), Some("{")), + ); + } + self.end(); + self.print_yul_block(body, span, skip_opening_brace, 0); + self.end(); + } + yul::StmtKind::VarDecl(idents, expr) => { + self.s.ibox(self.ind); + self.print_word("let "); + self.commasep( + idents, + stmt.span.lo(), + idents.last().map_or(stmt.span.lo(), |i| i.span.hi()), + Self::print_ident, + get_span!(), + ListFormat::consistent(), + ); + if let Some(expr) = expr { + self.print_word(" :="); + self.space(); + self.print_yul_expr(expr); + } + self.end(); + } + } + } + + fn print_yul_expr(&mut self, expr: &'ast yul::Expr<'ast>) { + let yul::Expr { span, ref kind } = *expr; + if self.handle_span(span, false) { + return; + } + + match kind { + yul::ExprKind::Path(path) => self.print_path(path, false), + yul::ExprKind::Call(yul::ExprCall { name, arguments }) => { + self.print_ident(name); + self.print_tuple( + arguments, + span.lo(), + span.hi(), + |s, arg| s.print_yul_expr(arg), + get_span!(), + ListFormat::consistent().break_single(true), + ); + } + yul::ExprKind::Lit(lit) => { + if matches!(&lit.kind, ast::LitKind::Address(_)) { + self.print_span_cold(lit.span); + } else { + self.print_lit_yul(lit); + } + } + } + } + + pub(super) fn print_yul_block( + &mut self, + block: &'ast yul::Block<'ast>, + span: Span, + skip_opening_brace: bool, + prefix_len: usize, + ) { + if self.handle_span(span, false) { + return; + } + + if !skip_opening_brace { + self.print_word("{"); + } + + let can_inline_block = if block.len() <= 1 + && !self.is_multiline_yul_block(block) + && !self.yul_block_breaks(block) + { + if self.max_space_left(prefix_len) == 0 { + self.estimate_size(block.span) + self.config.tab_width < self.space_left() + } else { + self.estimate_size(block.span) + prefix_len < self.space_left() + } + } else { + false + }; + if can_inline_block { + self.neverbreak(); + self.print_block_inner( + block, + BlockFormat::NoBraces(None), + |s, stmt| { + s.nbsp(); + s.print_yul_stmt(stmt); + if s.peek_comment_before(stmt.span.hi()).is_none() + && s.peek_trailing_comment(stmt.span.hi(), None).is_none() + { + s.nbsp(); + } + s.print_comments( + stmt.span.hi(), + CommentConfig::skip_ws().mixed_no_break().mixed_post_nbsp(), + ); + if !s.last_token_is_space() { + s.nbsp(); + } + }, + |b| b.span, + span.hi(), + ); + } else { + let (mut i, n_args) = (0, block.len().saturating_sub(1)); + self.print_block_inner( + block, + BlockFormat::NoBraces(Some(self.ind)), + |s, stmt| { + s.print_yul_stmt(stmt); + s.print_comments(stmt.span.hi(), CommentConfig::default()); + if i == n_args { + s.print_trailing_comment(stmt.span.hi(), Some(span.hi())); + } else { + let next_span = block[i + 1].span; + s.print_trailing_comment(stmt.span.hi(), Some(next_span.lo())); + if !s.is_bol_or_only_ind() && !s.inline_config.is_disabled(stmt.span) { + // when disabling a single line, manually add a nonbreaking line jump so + // that the indentation of the disabled line is maintained. + if s.inline_config.is_disabled(next_span) + && s.peek_comment_before(next_span.lo()) + .is_none_or(|cmnt| !cmnt.style.is_isolated()) + { + s.word("\n"); + // otherwise, use a regular hardbreak + } else { + s.hardbreak_if_not_bol(); + } + } + i += 1; + } + }, + |b| b.span, + span.hi(), + ); + } + self.print_word("}"); + self.print_trailing_comment(span.hi(), None); + } + + /// Checks if a block statement `{ ... }` contains more than one line of actual code. + fn is_multiline_yul_block(&self, block: &'ast yul::Block<'ast>) -> bool { + if block.stmts.is_empty() { + return false; + } + if !self.same_source_line(block.span.lo(), block.span.hi()) + && let Some(snip) = self.snippet(block.span) + { + let code_lines = snip.lines().filter(|line| { + let trimmed = line.trim(); + // Ignore empty lines and lines with only '{' or '}' + !trimmed.is_empty() + }); + return code_lines.count() > 1; + } + false + } + + /// Whether printing `stmt` always emits a line break. A block whose only statement breaks + /// cannot be kept on one line however short its source was, so `can_inline_block` has to know. + fn yul_stmt_breaks(&self, stmt: &'ast yul::Stmt<'ast>) -> bool { + match &stmt.kind { + // Each case is printed after a hardbreak. + yul::StmtKind::Switch(..) => true, + yul::StmtKind::Block(block) => self.yul_block_breaks(block), + yul::StmtKind::If(_, block) => self.yul_block_breaks(block), + yul::StmtKind::For(yul::StmtFor { init, step, body, .. }) => { + self.yul_block_breaks(init) + || self.yul_block_breaks(step) + || self.yul_block_breaks(body) + } + yul::StmtKind::FunctionDef(yul::Function { body, .. }) => self.yul_block_breaks(body), + _ => false, + } + } + + /// Whether printing `block` always emits a line break. Blocks with more than one statement + /// are never inlined, so they always do. + fn yul_block_breaks(&self, block: &'ast yul::Block<'ast>) -> bool { + block.len() > 1 || block.stmts.iter().any(|stmt| self.yul_stmt_breaks(stmt)) + } + + fn estimate_yul_header_params_size(&mut self, func: &yul::Function<'_>) -> usize { + // '(' + param + (', ' + param) + ')' + let params = func + .parameters + .iter() + .fold(0, |len, p| if len != 0 { len + 2 } else { 2 } + self.estimate_size(p.span)); + + // 'function ' + name + ' ' + params + ' ->' + 9 + self.estimate_size(func.name.span) + 1 + params + 3 + } + + fn can_yul_header_params_be_inlined(&mut self, func: &yul::Function<'_>) -> bool { + self.estimate_yul_header_params_size(func) <= self.space_left() + } +} diff --git a/crates/fmt/src/string.rs b/crates/fmt/src/string.rs deleted file mode 100644 index 984c15423ed5a..0000000000000 --- a/crates/fmt/src/string.rs +++ /dev/null @@ -1,181 +0,0 @@ -//! Helpers for dealing with quoted strings - -/// The state of a character in a string with quotable components -/// This is a simplified version of the -/// [actual parser](https://docs.soliditylang.org/en/v0.8.15/grammar.html#a4.SolidityLexer.EscapeSequence) -/// as we don't care about hex or other character meanings -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub enum QuoteState { - /// Not currently in quoted string - #[default] - None, - /// The opening character of a quoted string - Opening(char), - /// A character in a quoted string - String(char), - /// The `\` in an escape sequence `"\n"` - Escaping(char), - /// The escaped character e.g. `n` in `"\n"` - Escaped(char), - /// The closing character - Closing(char), -} - -/// An iterator over characters and indices in a string slice with information about quoted string -/// states -pub struct QuoteStateCharIndices<'a> { - iter: std::str::CharIndices<'a>, - state: QuoteState, -} - -impl<'a> QuoteStateCharIndices<'a> { - fn new(string: &'a str) -> Self { - Self { iter: string.char_indices(), state: QuoteState::None } - } - pub fn with_state(mut self, state: QuoteState) -> Self { - self.state = state; - self - } -} - -impl Iterator for QuoteStateCharIndices<'_> { - type Item = (QuoteState, usize, char); - fn next(&mut self) -> Option { - let (idx, ch) = self.iter.next()?; - match self.state { - QuoteState::None | QuoteState::Closing(_) => { - if ch == '\'' || ch == '"' { - self.state = QuoteState::Opening(ch); - } else { - self.state = QuoteState::None - } - } - QuoteState::String(quote) | QuoteState::Opening(quote) | QuoteState::Escaped(quote) => { - if ch == quote { - self.state = QuoteState::Closing(quote) - } else if ch == '\\' { - self.state = QuoteState::Escaping(quote) - } else { - self.state = QuoteState::String(quote) - } - } - QuoteState::Escaping(quote) => self.state = QuoteState::Escaped(quote), - } - Some((self.state, idx, ch)) - } -} - -/// An iterator over the indices of quoted string locations -pub struct QuotedRanges<'a>(QuoteStateCharIndices<'a>); - -impl QuotedRanges<'_> { - pub fn with_state(mut self, state: QuoteState) -> Self { - self.0 = self.0.with_state(state); - self - } -} - -impl Iterator for QuotedRanges<'_> { - type Item = (char, usize, usize); - fn next(&mut self) -> Option { - let (quote, start) = loop { - let (state, idx, _) = self.0.next()?; - match state { - QuoteState::Opening(quote) - | QuoteState::Escaping(quote) - | QuoteState::Escaped(quote) - | QuoteState::String(quote) => break (quote, idx), - QuoteState::Closing(quote) => return Some((quote, idx, idx)), - QuoteState::None => {} - } - }; - for (state, idx, _) in self.0.by_ref() { - if matches!(state, QuoteState::Closing(_)) { - return Some((quote, start, idx)); - } - } - None - } -} - -/// Helpers for iterating over quoted strings -pub trait QuotedStringExt { - /// Returns an iterator of characters, indices and their quoted string state. - fn quote_state_char_indices(&self) -> QuoteStateCharIndices<'_>; - - /// Returns an iterator of quoted string ranges. - fn quoted_ranges(&self) -> QuotedRanges<'_> { - QuotedRanges(self.quote_state_char_indices()) - } - - /// Check to see if a string is quoted. This will return true if the first character - /// is a quote and the last character is a quote with no non-quoted sections in between. - fn is_quoted(&self) -> bool { - let mut iter = self.quote_state_char_indices(); - if !matches!(iter.next(), Some((QuoteState::Opening(_), _, _))) { - return false; - } - while let Some((state, _, _)) = iter.next() { - if matches!(state, QuoteState::Closing(_)) { - return iter.next().is_none(); - } - } - false - } -} - -impl QuotedStringExt for T -where - T: AsRef, -{ - fn quote_state_char_indices(&self) -> QuoteStateCharIndices<'_> { - QuoteStateCharIndices::new(self.as_ref()) - } -} - -impl QuotedStringExt for str { - fn quote_state_char_indices(&self) -> QuoteStateCharIndices<'_> { - QuoteStateCharIndices::new(self) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use similar_asserts::assert_eq; - - #[test] - fn quote_state_char_indices() { - assert_eq!( - r#"a'a"\'\"\n\\'a"#.quote_state_char_indices().collect::>(), - vec![ - (QuoteState::None, 0, 'a'), - (QuoteState::Opening('\''), 1, '\''), - (QuoteState::String('\''), 2, 'a'), - (QuoteState::String('\''), 3, '"'), - (QuoteState::Escaping('\''), 4, '\\'), - (QuoteState::Escaped('\''), 5, '\''), - (QuoteState::Escaping('\''), 6, '\\'), - (QuoteState::Escaped('\''), 7, '"'), - (QuoteState::Escaping('\''), 8, '\\'), - (QuoteState::Escaped('\''), 9, 'n'), - (QuoteState::Escaping('\''), 10, '\\'), - (QuoteState::Escaped('\''), 11, '\\'), - (QuoteState::Closing('\''), 12, '\''), - (QuoteState::None, 13, 'a'), - ] - ); - } - - #[test] - fn quoted_ranges() { - let string = r#"testing "double quoted" and 'single quoted' strings"#; - assert_eq!( - string - .quoted_ranges() - .map(|(quote, start, end)| (quote, &string[start..=end])) - .collect::>(), - vec![('"', r#""double quoted""#), ('\'', "'single quoted'")] - ); - } -} diff --git a/crates/fmt/src/visit.rs b/crates/fmt/src/visit.rs deleted file mode 100644 index 86eeec7d57b62..0000000000000 --- a/crates/fmt/src/visit.rs +++ /dev/null @@ -1,633 +0,0 @@ -//! Visitor helpers to traverse the [solang Solidity Parse Tree](solang_parser::pt). - -use crate::solang_ext::{CodeLocationExt, pt::*}; - -/// A trait that is invoked while traversing the Solidity Parse Tree. -/// Each method of the [Visitor] trait is a hook that can be potentially overridden. -/// -/// Currently the main implementer of this trait is the [`Formatter`](crate::Formatter<'_>) struct. -pub trait Visitor { - type Error: std::error::Error; - - fn visit_source(&mut self, _loc: Loc) -> Result<(), Self::Error> { - Ok(()) - } - - fn visit_source_unit(&mut self, _source_unit: &mut SourceUnit) -> Result<(), Self::Error> { - Ok(()) - } - - fn visit_contract(&mut self, _contract: &mut ContractDefinition) -> Result<(), Self::Error> { - Ok(()) - } - - fn visit_annotation(&mut self, annotation: &mut Annotation) -> Result<(), Self::Error> { - self.visit_source(annotation.loc) - } - - fn visit_pragma(&mut self, pragma: &mut PragmaDirective) -> Result<(), Self::Error> { - self.visit_source(pragma.loc()) - } - - fn visit_import_plain( - &mut self, - _loc: Loc, - _import: &mut ImportPath, - ) -> Result<(), Self::Error> { - Ok(()) - } - - fn visit_import_global( - &mut self, - _loc: Loc, - _global: &mut ImportPath, - _alias: &mut Identifier, - ) -> Result<(), Self::Error> { - Ok(()) - } - - fn visit_import_renames( - &mut self, - _loc: Loc, - _imports: &mut [(Identifier, Option)], - _from: &mut ImportPath, - ) -> Result<(), Self::Error> { - Ok(()) - } - - fn visit_enum(&mut self, _enum: &mut EnumDefinition) -> Result<(), Self::Error> { - Ok(()) - } - - fn visit_assembly( - &mut self, - loc: Loc, - _dialect: &mut Option, - _block: &mut YulBlock, - _flags: &mut Option>, - ) -> Result<(), Self::Error> { - self.visit_source(loc) - } - - fn visit_block( - &mut self, - loc: Loc, - _unchecked: bool, - _statements: &mut Vec, - ) -> Result<(), Self::Error> { - self.visit_source(loc) - } - - fn visit_args(&mut self, loc: Loc, _args: &mut Vec) -> Result<(), Self::Error> { - self.visit_source(loc) - } - - /// Don't write semicolon at the end because expressions can appear as both - /// part of other node and a statement in the function body - fn visit_expr(&mut self, loc: Loc, _expr: &mut Expression) -> Result<(), Self::Error> { - self.visit_source(loc) - } - - fn visit_ident(&mut self, loc: Loc, _ident: &mut Identifier) -> Result<(), Self::Error> { - self.visit_source(loc) - } - - fn visit_ident_path(&mut self, idents: &mut IdentifierPath) -> Result<(), Self::Error> { - self.visit_source(idents.loc) - } - - fn visit_emit(&mut self, loc: Loc, _event: &mut Expression) -> Result<(), Self::Error> { - self.visit_source(loc)?; - self.visit_stray_semicolon()?; - - Ok(()) - } - - fn visit_var_definition(&mut self, var: &mut VariableDefinition) -> Result<(), Self::Error> { - self.visit_source(var.loc)?; - self.visit_stray_semicolon()?; - - Ok(()) - } - - fn visit_var_definition_stmt( - &mut self, - loc: Loc, - _declaration: &mut VariableDeclaration, - _expr: &mut Option, - ) -> Result<(), Self::Error> { - self.visit_source(loc)?; - self.visit_stray_semicolon() - } - - fn visit_var_declaration(&mut self, var: &mut VariableDeclaration) -> Result<(), Self::Error> { - self.visit_source(var.loc) - } - - fn visit_return( - &mut self, - loc: Loc, - _expr: &mut Option, - ) -> Result<(), Self::Error> { - self.visit_source(loc)?; - self.visit_stray_semicolon()?; - - Ok(()) - } - - fn visit_revert( - &mut self, - loc: Loc, - _error: &mut Option, - _args: &mut Vec, - ) -> Result<(), Self::Error> { - self.visit_source(loc)?; - self.visit_stray_semicolon()?; - - Ok(()) - } - - fn visit_revert_named_args( - &mut self, - loc: Loc, - _error: &mut Option, - _args: &mut Vec, - ) -> Result<(), Self::Error> { - self.visit_source(loc)?; - self.visit_stray_semicolon()?; - - Ok(()) - } - - fn visit_break(&mut self, loc: Loc, _semicolon: bool) -> Result<(), Self::Error> { - self.visit_source(loc) - } - - fn visit_continue(&mut self, loc: Loc, _semicolon: bool) -> Result<(), Self::Error> { - self.visit_source(loc) - } - - #[expect(clippy::type_complexity)] - fn visit_try( - &mut self, - loc: Loc, - _expr: &mut Expression, - _returns: &mut Option<(Vec<(Loc, Option)>, Box)>, - _clauses: &mut Vec, - ) -> Result<(), Self::Error> { - self.visit_source(loc) - } - - fn visit_if( - &mut self, - loc: Loc, - _cond: &mut Expression, - _if_branch: &mut Box, - _else_branch: &mut Option>, - _is_first_stmt: bool, - ) -> Result<(), Self::Error> { - self.visit_source(loc) - } - - fn visit_do_while( - &mut self, - loc: Loc, - _body: &mut Statement, - _cond: &mut Expression, - ) -> Result<(), Self::Error> { - self.visit_source(loc) - } - - fn visit_while( - &mut self, - loc: Loc, - _cond: &mut Expression, - _body: &mut Statement, - ) -> Result<(), Self::Error> { - self.visit_source(loc) - } - - fn visit_for( - &mut self, - loc: Loc, - _init: &mut Option>, - _cond: &mut Option>, - _update: &mut Option>, - _body: &mut Option>, - ) -> Result<(), Self::Error> { - self.visit_source(loc) - } - - fn visit_function(&mut self, func: &mut FunctionDefinition) -> Result<(), Self::Error> { - self.visit_source(func.loc())?; - if func.body.is_none() { - self.visit_stray_semicolon()?; - } - - Ok(()) - } - - fn visit_function_attribute( - &mut self, - attribute: &mut FunctionAttribute, - ) -> Result<(), Self::Error> { - self.visit_source(attribute.loc())?; - Ok(()) - } - - fn visit_var_attribute( - &mut self, - attribute: &mut VariableAttribute, - ) -> Result<(), Self::Error> { - self.visit_source(attribute.loc())?; - Ok(()) - } - - fn visit_base(&mut self, base: &mut Base) -> Result<(), Self::Error> { - self.visit_source(base.loc) - } - - fn visit_parameter(&mut self, parameter: &mut Parameter) -> Result<(), Self::Error> { - self.visit_source(parameter.loc) - } - - fn visit_struct(&mut self, structure: &mut StructDefinition) -> Result<(), Self::Error> { - self.visit_source(structure.loc)?; - - Ok(()) - } - - fn visit_event(&mut self, event: &mut EventDefinition) -> Result<(), Self::Error> { - self.visit_source(event.loc)?; - self.visit_stray_semicolon()?; - - Ok(()) - } - - fn visit_event_parameter(&mut self, param: &mut EventParameter) -> Result<(), Self::Error> { - self.visit_source(param.loc) - } - - fn visit_error(&mut self, error: &mut ErrorDefinition) -> Result<(), Self::Error> { - self.visit_source(error.loc)?; - self.visit_stray_semicolon()?; - - Ok(()) - } - - fn visit_error_parameter(&mut self, param: &mut ErrorParameter) -> Result<(), Self::Error> { - self.visit_source(param.loc) - } - - fn visit_type_definition(&mut self, def: &mut TypeDefinition) -> Result<(), Self::Error> { - self.visit_source(def.loc) - } - - fn visit_stray_semicolon(&mut self) -> Result<(), Self::Error> { - Ok(()) - } - - fn visit_opening_paren(&mut self) -> Result<(), Self::Error> { - Ok(()) - } - - fn visit_closing_paren(&mut self) -> Result<(), Self::Error> { - Ok(()) - } - - fn visit_newline(&mut self) -> Result<(), Self::Error> { - Ok(()) - } - - fn visit_using(&mut self, using: &mut Using) -> Result<(), Self::Error> { - self.visit_source(using.loc)?; - self.visit_stray_semicolon()?; - - Ok(()) - } - - fn visit_yul_block( - &mut self, - loc: Loc, - _stmts: &mut Vec, - _attempt_single_line: bool, - ) -> Result<(), Self::Error> { - self.visit_source(loc) - } - - fn visit_yul_expr(&mut self, expr: &mut YulExpression) -> Result<(), Self::Error> { - self.visit_source(expr.loc()) - } - - fn visit_yul_assignment( - &mut self, - loc: Loc, - _exprs: &mut Vec, - _expr: &mut Option<&mut YulExpression>, - ) -> Result<(), Self::Error> - where - T: Visitable + CodeLocationExt, - { - self.visit_source(loc) - } - - fn visit_yul_for(&mut self, stmt: &mut YulFor) -> Result<(), Self::Error> { - self.visit_source(stmt.loc) - } - - fn visit_yul_function_call(&mut self, stmt: &mut YulFunctionCall) -> Result<(), Self::Error> { - self.visit_source(stmt.loc) - } - - fn visit_yul_fun_def(&mut self, stmt: &mut YulFunctionDefinition) -> Result<(), Self::Error> { - self.visit_source(stmt.loc) - } - - fn visit_yul_if( - &mut self, - loc: Loc, - _expr: &mut YulExpression, - _block: &mut YulBlock, - ) -> Result<(), Self::Error> { - self.visit_source(loc) - } - - fn visit_yul_leave(&mut self, loc: Loc) -> Result<(), Self::Error> { - self.visit_source(loc) - } - - fn visit_yul_switch(&mut self, stmt: &mut YulSwitch) -> Result<(), Self::Error> { - self.visit_source(stmt.loc) - } - - fn visit_yul_var_declaration( - &mut self, - loc: Loc, - _idents: &mut Vec, - _expr: &mut Option, - ) -> Result<(), Self::Error> { - self.visit_source(loc) - } - - fn visit_yul_typed_ident(&mut self, ident: &mut YulTypedIdentifier) -> Result<(), Self::Error> { - self.visit_source(ident.loc) - } - - fn visit_parser_error(&mut self, loc: Loc) -> Result<(), Self::Error> { - self.visit_source(loc) - } -} - -/// Visitable trait for [`solang_parser::pt`] types. -/// -/// All [`solang_parser::pt`] types, such as [Statement], should implement the [Visitable] trait -/// that accepts a trait [Visitor] implementation, which has various callback handles for Solidity -/// Parse Tree nodes. -/// -/// We want to take a `&mut self` to be able to implement some advanced features in the future such -/// as modifying the Parse Tree before formatting it. -pub trait Visitable { - fn visit(&mut self, v: &mut V) -> Result<(), V::Error> - where - V: Visitor; -} - -impl Visitable for &mut T -where - T: Visitable, -{ - fn visit(&mut self, v: &mut V) -> Result<(), V::Error> - where - V: Visitor, - { - T::visit(self, v) - } -} - -impl Visitable for Option -where - T: Visitable, -{ - fn visit(&mut self, v: &mut V) -> Result<(), V::Error> - where - V: Visitor, - { - if let Some(inner) = self.as_mut() { inner.visit(v) } else { Ok(()) } - } -} - -impl Visitable for Box -where - T: Visitable, -{ - fn visit(&mut self, v: &mut V) -> Result<(), V::Error> - where - V: Visitor, - { - T::visit(self, v) - } -} - -impl Visitable for Vec -where - T: Visitable, -{ - fn visit(&mut self, v: &mut V) -> Result<(), V::Error> - where - V: Visitor, - { - for item in self.iter_mut() { - item.visit(v)?; - } - Ok(()) - } -} - -impl Visitable for SourceUnitPart { - fn visit(&mut self, v: &mut V) -> Result<(), V::Error> - where - V: Visitor, - { - match self { - Self::ContractDefinition(contract) => v.visit_contract(contract), - Self::PragmaDirective(pragma) => v.visit_pragma(pragma), - Self::ImportDirective(import) => import.visit(v), - Self::EnumDefinition(enumeration) => v.visit_enum(enumeration), - Self::StructDefinition(structure) => v.visit_struct(structure), - Self::EventDefinition(event) => v.visit_event(event), - Self::ErrorDefinition(error) => v.visit_error(error), - Self::FunctionDefinition(function) => v.visit_function(function), - Self::VariableDefinition(variable) => v.visit_var_definition(variable), - Self::TypeDefinition(def) => v.visit_type_definition(def), - Self::StraySemicolon(_) => v.visit_stray_semicolon(), - Self::Using(using) => v.visit_using(using), - Self::Annotation(annotation) => v.visit_annotation(annotation), - } - } -} - -impl Visitable for Import { - fn visit(&mut self, v: &mut V) -> Result<(), V::Error> - where - V: Visitor, - { - match self { - Self::Plain(import, loc) => v.visit_import_plain(*loc, import), - Self::GlobalSymbol(global, import_as, loc) => { - v.visit_import_global(*loc, global, import_as) - } - Self::Rename(from, imports, loc) => v.visit_import_renames(*loc, imports, from), - } - } -} - -impl Visitable for ContractPart { - fn visit(&mut self, v: &mut V) -> Result<(), V::Error> - where - V: Visitor, - { - match self { - Self::StructDefinition(structure) => v.visit_struct(structure), - Self::EventDefinition(event) => v.visit_event(event), - Self::ErrorDefinition(error) => v.visit_error(error), - Self::EnumDefinition(enumeration) => v.visit_enum(enumeration), - Self::VariableDefinition(variable) => v.visit_var_definition(variable), - Self::FunctionDefinition(function) => v.visit_function(function), - Self::TypeDefinition(def) => v.visit_type_definition(def), - Self::StraySemicolon(_) => v.visit_stray_semicolon(), - Self::Using(using) => v.visit_using(using), - Self::Annotation(annotation) => v.visit_annotation(annotation), - } - } -} - -impl Visitable for Statement { - fn visit(&mut self, v: &mut V) -> Result<(), V::Error> - where - V: Visitor, - { - match self { - Self::Block { loc, unchecked, statements } => { - v.visit_block(*loc, *unchecked, statements) - } - Self::Assembly { loc, dialect, block, flags } => { - v.visit_assembly(*loc, dialect, block, flags) - } - Self::Args(loc, args) => v.visit_args(*loc, args), - Self::If(loc, cond, if_branch, else_branch) => { - v.visit_if(*loc, cond, if_branch, else_branch, true) - } - Self::While(loc, cond, body) => v.visit_while(*loc, cond, body), - Self::Expression(loc, expr) => { - v.visit_expr(*loc, expr)?; - v.visit_stray_semicolon() - } - Self::VariableDefinition(loc, declaration, expr) => { - v.visit_var_definition_stmt(*loc, declaration, expr) - } - Self::For(loc, init, cond, update, body) => v.visit_for(*loc, init, cond, update, body), - Self::DoWhile(loc, body, cond) => v.visit_do_while(*loc, body, cond), - Self::Continue(loc) => v.visit_continue(*loc, true), - Self::Break(loc) => v.visit_break(*loc, true), - Self::Return(loc, expr) => v.visit_return(*loc, expr), - Self::Revert(loc, error, args) => v.visit_revert(*loc, error, args), - Self::RevertNamedArgs(loc, error, args) => v.visit_revert_named_args(*loc, error, args), - Self::Emit(loc, event) => v.visit_emit(*loc, event), - Self::Try(loc, expr, returns, clauses) => v.visit_try(*loc, expr, returns, clauses), - Self::Error(loc) => v.visit_parser_error(*loc), - } - } -} - -impl Visitable for Loc { - fn visit(&mut self, v: &mut V) -> Result<(), V::Error> - where - V: Visitor, - { - v.visit_source(*self) - } -} - -impl Visitable for Expression { - fn visit(&mut self, v: &mut V) -> Result<(), V::Error> - where - V: Visitor, - { - v.visit_expr(self.loc(), self) - } -} - -impl Visitable for Identifier { - fn visit(&mut self, v: &mut V) -> Result<(), V::Error> - where - V: Visitor, - { - v.visit_ident(self.loc, self) - } -} - -impl Visitable for VariableDeclaration { - fn visit(&mut self, v: &mut V) -> Result<(), V::Error> - where - V: Visitor, - { - v.visit_var_declaration(self) - } -} - -impl Visitable for YulBlock { - fn visit(&mut self, v: &mut V) -> Result<(), V::Error> - where - V: Visitor, - { - v.visit_yul_block(self.loc, self.statements.as_mut(), false) - } -} - -impl Visitable for YulStatement { - fn visit(&mut self, v: &mut V) -> Result<(), V::Error> - where - V: Visitor, - { - match self { - Self::Assign(loc, exprs, expr) => v.visit_yul_assignment(*loc, exprs, &mut Some(expr)), - Self::Block(block) => v.visit_yul_block(block.loc, block.statements.as_mut(), false), - Self::Break(loc) => v.visit_break(*loc, false), - Self::Continue(loc) => v.visit_continue(*loc, false), - Self::For(stmt) => v.visit_yul_for(stmt), - Self::FunctionCall(stmt) => v.visit_yul_function_call(stmt), - Self::FunctionDefinition(stmt) => v.visit_yul_fun_def(stmt), - Self::If(loc, expr, block) => v.visit_yul_if(*loc, expr, block), - Self::Leave(loc) => v.visit_yul_leave(*loc), - Self::Switch(stmt) => v.visit_yul_switch(stmt), - Self::VariableDeclaration(loc, idents, expr) => { - v.visit_yul_var_declaration(*loc, idents, expr) - } - Self::Error(loc) => v.visit_parser_error(*loc), - } - } -} - -macro_rules! impl_visitable { - ($type:ty, $func:ident) => { - impl Visitable for $type { - fn visit(&mut self, v: &mut V) -> Result<(), V::Error> - where - V: Visitor, - { - v.$func(self) - } - } - }; -} - -impl_visitable!(SourceUnit, visit_source_unit); -impl_visitable!(FunctionAttribute, visit_function_attribute); -impl_visitable!(VariableAttribute, visit_var_attribute); -impl_visitable!(Parameter, visit_parameter); -impl_visitable!(Base, visit_base); -impl_visitable!(EventParameter, visit_event_parameter); -impl_visitable!(ErrorParameter, visit_error_parameter); -impl_visitable!(IdentifierPath, visit_ident_path); -impl_visitable!(YulExpression, visit_yul_expr); -impl_visitable!(YulTypedIdentifier, visit_yul_typed_ident); diff --git a/crates/fmt/testdata/ArrayExpressions/fmt.sol b/crates/fmt/testdata/ArrayExpressions/fmt.sol index adda7a30e098d..b98d43f1b313f 100644 --- a/crates/fmt/testdata/ArrayExpressions/fmt.sol +++ b/crates/fmt/testdata/ArrayExpressions/fmt.sol @@ -6,7 +6,7 @@ contract ArrayExpressions { uint256 length = 10; uint256[] memory sample2 = new uint256[](length); - uint256[] /* comment1 */ memory /* comment2 */ sample3; // comment3 + uint256 /* comment1 */ [] memory /* comment2 */ sample3; // comment3 /* ARRAY SLICE */ msg.data[4:]; @@ -63,7 +63,8 @@ contract ArrayExpressions { 2, /* comment9 */ 3 // comment10 ]; - uint256[1] memory literal3 = - [ /* comment11 */ someVeryVeryLongVariableName /* comment13 */ ]; + uint256[1] memory literal3 = [ /* comment11 */ + someVeryVeryLongVariableName /* comment13 */ + ]; } } diff --git a/crates/fmt/testdata/AssignmentMemberChain/120.fmt.sol b/crates/fmt/testdata/AssignmentMemberChain/120.fmt.sol new file mode 100644 index 0000000000000..3c4c0213a7208 --- /dev/null +++ b/crates/fmt/testdata/AssignmentMemberChain/120.fmt.sol @@ -0,0 +1,54 @@ +// config: line_length = 120 +// config: bracket_spacing = true +function f() { + if (outputSelection.actualEvents) { + jsonOut = context.actualEvents.serializeTransferLogs("root", "actualEvents"); + } +} + +contract Nested { + function f() external { + if (a) { + if (b) { + if (c) { + if (d) { + newOffer[j] = orders[orderInsertionIndex].parameters.offer[j - 1]; + } + } + } + } + } +} + +contract Initializer { + function f() external { + for (uint256 i; i < 2; i++) { + AdvancedOrder memory order = context.executionState.orders[fulfillmentComponent.orderIndex]; + } + } +} + +contract Commented { + function f() external { + if (a) { + if (b) { + if (c) { + if (d) { + newOf[j] = /* preserve */ orders[orderInsertionIndex].parameters.offer[j - 1]; + } + } + } + } + } +} + +contract AlreadyStable { + function f() external { + request.seaport = ConsiderationInterface(0x00000000000000ADc04C56Bf30aC9d3c0aAF14dC); + if (a) { + if (b) { + consideration = orderToExecute.receivedItems[potentialCandidate.itemIndex]; + } + } + } +} diff --git a/crates/fmt/testdata/AssignmentMemberChain/80-no-spacing.fmt.sol b/crates/fmt/testdata/AssignmentMemberChain/80-no-spacing.fmt.sol new file mode 100644 index 0000000000000..6ddbe4da80a3d --- /dev/null +++ b/crates/fmt/testdata/AssignmentMemberChain/80-no-spacing.fmt.sol @@ -0,0 +1,60 @@ +// config: line_length = 80 +// config: bracket_spacing = false +function f() { + if (outputSelection.actualEvents) { + jsonOut = + context.actualEvents.serializeTransferLogs("root", "actualEvents"); + } +} + +contract Nested { + function f() external { + if (a) { + if (b) { + if (c) { + if (d) { + newOffer[j] = + orders[orderInsertionIndex].parameters.offer[j - 1]; + } + } + } + } + } +} + +contract Initializer { + function f() external { + for (uint256 i; i < 2; i++) { + AdvancedOrder memory order = + context.executionState.orders[fulfillmentComponent.orderIndex]; + } + } +} + +contract Commented { + function f() external { + if (a) { + if (b) { + if (c) { + if (d) { + newOf[j] = /* preserve */ + orders[orderInsertionIndex].parameters.offer[j - 1]; + } + } + } + } + } +} + +contract AlreadyStable { + function f() external { + request.seaport = + ConsiderationInterface(0x00000000000000ADc04C56Bf30aC9d3c0aAF14dC); + if (a) { + if (b) { + consideration = + orderToExecute.receivedItems[potentialCandidate.itemIndex]; + } + } + } +} diff --git a/crates/fmt/testdata/AssignmentMemberChain/80.fmt.sol b/crates/fmt/testdata/AssignmentMemberChain/80.fmt.sol new file mode 100644 index 0000000000000..d6697597d651b --- /dev/null +++ b/crates/fmt/testdata/AssignmentMemberChain/80.fmt.sol @@ -0,0 +1,62 @@ +// config: line_length = 80 +// config: bracket_spacing = true +function f() { + if (outputSelection.actualEvents) { + jsonOut = + context.actualEvents.serializeTransferLogs("root", "actualEvents"); + } +} + +contract Nested { + function f() external { + if (a) { + if (b) { + if (c) { + if (d) { + newOffer[j] = + orders[orderInsertionIndex].parameters.offer[j - 1]; + } + } + } + } + } +} + +contract Initializer { + function f() external { + for (uint256 i; i < 2; i++) { + AdvancedOrder memory order = + context.executionState.orders[fulfillmentComponent.orderIndex]; + } + } +} + +contract Commented { + function f() external { + if (a) { + if (b) { + if (c) { + if (d) { + newOf[j] = /* preserve */ + orders[orderInsertionIndex].parameters.offer[j - 1]; + } + } + } + } + } +} + +contract AlreadyStable { + function f() external { + request.seaport = ConsiderationInterface( + 0x00000000000000ADc04C56Bf30aC9d3c0aAF14dC + ); + if (a) { + if (b) { + consideration = orderToExecute.receivedItems[ + potentialCandidate.itemIndex + ]; + } + } + } +} diff --git a/crates/fmt/testdata/AssignmentMemberChain/88.fmt.sol b/crates/fmt/testdata/AssignmentMemberChain/88.fmt.sol new file mode 100644 index 0000000000000..1f96b32b2543f --- /dev/null +++ b/crates/fmt/testdata/AssignmentMemberChain/88.fmt.sol @@ -0,0 +1,59 @@ +// config: line_length = 88 +// config: bracket_spacing = true +function f() { + if (outputSelection.actualEvents) { + jsonOut = context.actualEvents.serializeTransferLogs("root", "actualEvents"); + } +} + +contract Nested { + function f() external { + if (a) { + if (b) { + if (c) { + if (d) { + newOffer[j] = + orders[orderInsertionIndex].parameters.offer[j - 1]; + } + } + } + } + } +} + +contract Initializer { + function f() external { + for (uint256 i; i < 2; i++) { + AdvancedOrder memory order = + context.executionState.orders[fulfillmentComponent.orderIndex]; + } + } +} + +contract Commented { + function f() external { + if (a) { + if (b) { + if (c) { + if (d) { + newOf[j] = /* preserve */ orders[orderInsertionIndex].parameters + .offer[j - 1]; + } + } + } + } + } +} + +contract AlreadyStable { + function f() external { + request.seaport = + ConsiderationInterface(0x00000000000000ADc04C56Bf30aC9d3c0aAF14dC); + if (a) { + if (b) { + consideration = + orderToExecute.receivedItems[potentialCandidate.itemIndex]; + } + } + } +} diff --git a/crates/fmt/testdata/AssignmentMemberChain/original.sol b/crates/fmt/testdata/AssignmentMemberChain/original.sol new file mode 100644 index 0000000000000..e933bb0938af3 --- /dev/null +++ b/crates/fmt/testdata/AssignmentMemberChain/original.sol @@ -0,0 +1,63 @@ +function f() { + if (outputSelection.actualEvents) { + jsonOut = context.actualEvents.serializeTransferLogs( + "root", + "actualEvents" + ); + } +} + +contract Nested { + function f() external { + if (a) { + if (b) { + if (c) { + if (d) { + newOffer[j] = orders[orderInsertionIndex].parameters.offer[ + j - 1 + ]; + } + } + } + } + } +} + +contract Initializer { + function f() external { + for (uint256 i; i < 2; i++) { + AdvancedOrder memory order = context.executionState.orders[ + fulfillmentComponent.orderIndex + ]; + } + } +} + +contract Commented { + function f() external { + if (a) { + if (b) { + if (c) { + if (d) { + newOf[j] /* preserve */ = orders[orderInsertionIndex].parameters.offer[j - 1]; + } + } + } + } + } +} + +contract AlreadyStable { + function f() external { + request.seaport = ConsiderationInterface( + 0x00000000000000ADc04C56Bf30aC9d3c0aAF14dC + ); + if (a) { + if (b) { + consideration = orderToExecute.receivedItems[ + potentialCandidate.itemIndex + ]; + } + } + } +} diff --git a/crates/fmt/testdata/BlockComments/tab.fmt.sol b/crates/fmt/testdata/BlockComments/tab.fmt.sol index 3b12bee813749..527f372abd489 100644 --- a/crates/fmt/testdata/BlockComments/tab.fmt.sol +++ b/crates/fmt/testdata/BlockComments/tab.fmt.sol @@ -1,7 +1,7 @@ // config: style = "tab" contract CounterTest is Test { /** - * @dev Initializes the contract by setting a `name` and a `symbol` to the token collection. + * @dev Initializes the contract by setting a `name` and a `symbol` to the token collection. */ constructor(string memory name_, string memory symbol_) { _name = name_; @@ -9,7 +9,7 @@ contract CounterTest is Test { } /** - * @dev See {IERC721-balanceOf}. + * @dev See {IERC721-balanceOf}. */ function test_Increment() public { counter.increment(); @@ -17,7 +17,7 @@ contract CounterTest is Test { } /** - * @dev See {IERC165-supportsInterface}. + * @dev See {IERC165-supportsInterface}. */ function test_Increment() public { counter.increment(); diff --git a/crates/fmt/testdata/BlockCommentsFunction/tab.fmt.sol b/crates/fmt/testdata/BlockCommentsFunction/tab.fmt.sol index 04a0986cb73db..40eae6c9069ce 100644 --- a/crates/fmt/testdata/BlockCommentsFunction/tab.fmt.sol +++ b/crates/fmt/testdata/BlockCommentsFunction/tab.fmt.sol @@ -2,20 +2,20 @@ contract A { Counter public counter; /** - * TODO: this fuzz use too much time to execute - * function testGetFuzz(bytes[2][] memory kvs) public { - * for (uint256 i = 0; i < kvs.length; i++) { - * bytes32 root = trie.update(kvs[i][0], kvs[i][1]); - * console.logBytes32(root); - * } + * TODO: this fuzz use too much time to execute + * function testGetFuzz(bytes[2][] memory kvs) public { + * for (uint256 i = 0; i < kvs.length; i++) { + * bytes32 root = trie.update(kvs[i][0], kvs[i][1]); + * console.logBytes32(root); + * } * - * for (uint256 i = 0; i < kvs.length; i++) { - * (bool exist, bytes memory value) = trie.get(kvs[i][0]); - * console.logBool(exist); - * console.logBytes(value); - * require(exist); - * require(BytesSlice.equal(value, trie.getRaw(kvs[i][0]))); - * } - * } + * for (uint256 i = 0; i < kvs.length; i++) { + * (bool exist, bytes memory value) = trie.get(kvs[i][0]); + * console.logBool(exist); + * console.logBytes(value); + * require(exist); + * require(BytesSlice.equal(value, trie.getRaw(kvs[i][0]))); + * } + * } */ } diff --git a/crates/fmt/testdata/CallOptionsAssign/long.fmt.sol b/crates/fmt/testdata/CallOptionsAssign/long.fmt.sol new file mode 100644 index 0000000000000..b30a813e254f4 --- /dev/null +++ b/crates/fmt/testdata/CallOptionsAssign/long.fmt.sol @@ -0,0 +1,7 @@ +// config: line_length = 120 +contract C { + function f() external payable returns (Execution[] memory executions) { + executions = + zone.executeMatchAdvancedOrders{value: msg.value}(seaportAddress, orders, criteriaResolvers, fulfillments); + } +} diff --git a/crates/fmt/testdata/CallOptionsAssign/original.sol b/crates/fmt/testdata/CallOptionsAssign/original.sol new file mode 100644 index 0000000000000..fafc615b7c156 --- /dev/null +++ b/crates/fmt/testdata/CallOptionsAssign/original.sol @@ -0,0 +1,10 @@ +contract C { + function f() external payable returns (Execution[] memory executions) { + executions = zone.executeMatchAdvancedOrders{ value: msg.value }( + seaportAddress, + orders, + criteriaResolvers, + fulfillments + ); + } +} diff --git a/crates/fmt/testdata/CommentEmptyLine/fmt.sol b/crates/fmt/testdata/CommentEmptyLine/fmt.sol new file mode 100644 index 0000000000000..a2407017f4be9 --- /dev/null +++ b/crates/fmt/testdata/CommentEmptyLine/fmt.sol @@ -0,0 +1,5 @@ +pragma solidity ^0.8.0; + +contract ProofOfConcept { + // some comment +} diff --git a/crates/fmt/testdata/CommentEmptyLine/original.sol b/crates/fmt/testdata/CommentEmptyLine/original.sol new file mode 100644 index 0000000000000..9c7f35770e9f3 --- /dev/null +++ b/crates/fmt/testdata/CommentEmptyLine/original.sol @@ -0,0 +1,6 @@ +pragma solidity ^0.8.0; + +contract ProofOfConcept { + // some comment + +} diff --git a/crates/fmt/testdata/ConstructorModifierStyle/fmt.sol b/crates/fmt/testdata/ConstructorModifierStyle/fmt.sol index 88694860aded2..be61b28170c73 100644 --- a/crates/fmt/testdata/ConstructorModifierStyle/fmt.sol +++ b/crates/fmt/testdata/ConstructorModifierStyle/fmt.sol @@ -9,5 +9,7 @@ import {IAchievements} from "./interfaces/IAchievements.sol"; import {SoulBound1155} from "./abstracts/SoulBound1155.sol"; contract Achievements is IAchievements, SoulBound1155, Ownable { - constructor(address owner) Ownable() ERC1155() {} + constructor(address owner) my_modifier Ownable() ERC1155() {} + + function f() my_modifier MyModifier my_modifier MyModifier {} } diff --git a/crates/fmt/testdata/ConstructorModifierStyle/original.sol b/crates/fmt/testdata/ConstructorModifierStyle/original.sol index 88694860aded2..eb418ba126178 100644 --- a/crates/fmt/testdata/ConstructorModifierStyle/original.sol +++ b/crates/fmt/testdata/ConstructorModifierStyle/original.sol @@ -9,5 +9,7 @@ import {IAchievements} from "./interfaces/IAchievements.sol"; import {SoulBound1155} from "./abstracts/SoulBound1155.sol"; contract Achievements is IAchievements, SoulBound1155, Ownable { - constructor(address owner) Ownable() ERC1155() {} + constructor(address owner) my_modifier Ownable() ERC1155() {} + + function f() my_modifier MyModifier() my_modifier() MyModifier {} } diff --git a/crates/fmt/testdata/ContractDefinition/bracket-spacing.fmt.sol b/crates/fmt/testdata/ContractDefinition/bracket-spacing.fmt.sol index dca4e325d39c9..6addc6844cec3 100644 --- a/crates/fmt/testdata/ContractDefinition/bracket-spacing.fmt.sol +++ b/crates/fmt/testdata/ContractDefinition/bracket-spacing.fmt.sol @@ -2,6 +2,14 @@ // config: bracket_spacing = true contract ContractDefinition is Contract1, Contract2, Contract3, Contract4, Contract5 { } +contract EmptyBodyWithComment { /* body */ } + +contract EmptyBodyWithComments { /* one */ /* two */ } + +contract NonEmptyBodyTrailingComments { + uint256 x; + /* one */ /* two */ } + // comment 7 contract SampleContract { // spaced comment 1 @@ -13,7 +21,11 @@ contract SampleContract { constructor() { /* comment 9 */ } // comment 10 // comment 11 - function max( /* comment 13 */ uint256 arg1, uint256 /* comment 14 */ arg2, uint256 /* comment 15 */ ) + function max( /* comment 13 */ + uint256 arg1, + uint256 /* comment 14 */ arg2, + uint256 /* comment 15 */ + ) // comment 16 external /* comment 17 */ pure @@ -35,3 +47,48 @@ contract ERC20DecimalsMock is ERC20 { _decimals = decimals_; } } + +contract SomeContract is + ERC165Upgradeable, // 1 inherited component + ISomeContract // 4 inherited components +{ } + +contract AnotherContract is + Adminable, /* 1 inherited components */ + UUPSUpgradeable /* 1 inherited component */ +{ } + +contract WithLayoutAndBase layout at 69 is Base { } + +contract ERC7201Short layout at erc7201("s") is Base { } + +contract ERC7201Mid layout at erc7201("openzeppelin.med") is Base { } + +contract ERC7201OverMax layout at erc7201("openzeppelin.storage.exceeds.max") is Base { } + +interface IERC721 /* is IERC165 */ { + function balanceOf(address owner) external view returns (uint256); +} + +interface IERC721Empty /* is IERC165 */ { } + +contract WithBaseAndHeaderComment is Base /* base */ { + uint256 x; +} + +contract WithLayoutAndHeaderComment layout at 69 /* layout */ { } + +contract WithLayoutAfterBase layout at erc7201("x{y") is Base /* layout */ { + uint256 x; +} + +contract MultipleHeaderComments /* one */ /* two */ { } + +contract VeryLongContractNameThatForcesTheHeaderToBreak is BaseOne, BaseTwo /* base */ { + uint256 x; +} + +contract HeaderCommentOnItsOwnLine { + // isolated + uint256 x; +} diff --git a/crates/fmt/testdata/ContractDefinition/contract-new-lines.fmt.sol b/crates/fmt/testdata/ContractDefinition/contract-new-lines.fmt.sol index 2e9661f956dcf..171132913d2a7 100644 --- a/crates/fmt/testdata/ContractDefinition/contract-new-lines.fmt.sol +++ b/crates/fmt/testdata/ContractDefinition/contract-new-lines.fmt.sol @@ -7,6 +7,15 @@ contract ContractDefinition is Contract5 {} +contract EmptyBodyWithComment { /* body */ } + +contract EmptyBodyWithComments { /* one */ /* two */ } + +contract NonEmptyBodyTrailingComments { + + uint256 x; + /* one */ /* two */ } + // comment 7 contract SampleContract { @@ -50,3 +59,63 @@ contract ERC20DecimalsMock is ERC20 { } } + +contract SomeContract is + ERC165Upgradeable, // 1 inherited component + ISomeContract // 4 inherited components +{} + +contract AnotherContract is + Adminable, /* 1 inherited components */ + UUPSUpgradeable /* 1 inherited component */ +{} + +contract WithLayoutAndBase layout at 69 is Base {} + +contract ERC7201Short layout at erc7201("s") is Base {} + +contract ERC7201Mid layout at erc7201("openzeppelin.med") is Base {} + +contract ERC7201OverMax layout at erc7201("openzeppelin.storage.exceeds.max") + is + Base +{} + +interface IERC721 /* is IERC165 */ { + + function balanceOf(address owner) external view returns (uint256); + +} + +interface IERC721Empty /* is IERC165 */ {} + +contract WithBaseAndHeaderComment is Base /* base */ { + + uint256 x; + +} + +contract WithLayoutAndHeaderComment layout at 69 /* layout */ {} + +contract WithLayoutAfterBase layout at erc7201("x{y") is Base /* layout */ { + + uint256 x; + +} + +contract MultipleHeaderComments /* one */ /* two */ {} + +contract VeryLongContractNameThatForcesTheHeaderToBreak is + BaseOne, + BaseTwo /* base */ { + + uint256 x; + +} + +contract HeaderCommentOnItsOwnLine { + + // isolated + uint256 x; + +} diff --git a/crates/fmt/testdata/ContractDefinition/fmt.sol b/crates/fmt/testdata/ContractDefinition/fmt.sol index 551e84decfc5b..8b8d84a4ab30b 100644 --- a/crates/fmt/testdata/ContractDefinition/fmt.sol +++ b/crates/fmt/testdata/ContractDefinition/fmt.sol @@ -6,6 +6,14 @@ contract ContractDefinition is Contract5 {} +contract EmptyBodyWithComment { /* body */ } + +contract EmptyBodyWithComments { /* one */ /* two */ } + +contract NonEmptyBodyTrailingComments { + uint256 x; + /* one */ /* two */ } + // comment 7 contract SampleContract { // spaced comment 1 @@ -45,3 +53,53 @@ contract ERC20DecimalsMock is ERC20 { _decimals = decimals_; } } + +contract SomeContract is + ERC165Upgradeable, // 1 inherited component + ISomeContract // 4 inherited components +{} + +contract AnotherContract is + Adminable, /* 1 inherited components */ + UUPSUpgradeable /* 1 inherited component */ +{} + +contract WithLayoutAndBase layout at 69 is Base {} + +contract ERC7201Short layout at erc7201("s") is Base {} + +contract ERC7201Mid layout at erc7201("openzeppelin.med") is Base {} + +contract ERC7201OverMax layout at erc7201("openzeppelin.storage.exceeds.max") + is + Base +{} + +interface IERC721 /* is IERC165 */ { + function balanceOf(address owner) external view returns (uint256); +} + +interface IERC721Empty /* is IERC165 */ {} + +contract WithBaseAndHeaderComment is Base /* base */ { + uint256 x; +} + +contract WithLayoutAndHeaderComment layout at 69 /* layout */ {} + +contract WithLayoutAfterBase layout at erc7201("x{y") is Base /* layout */ { + uint256 x; +} + +contract MultipleHeaderComments /* one */ /* two */ {} + +contract VeryLongContractNameThatForcesTheHeaderToBreak is + BaseOne, + BaseTwo /* base */ { + uint256 x; +} + +contract HeaderCommentOnItsOwnLine { + // isolated + uint256 x; +} diff --git a/crates/fmt/testdata/ContractDefinition/original.sol b/crates/fmt/testdata/ContractDefinition/original.sol index 4c671985bda7b..4bbc24b3d7487 100644 --- a/crates/fmt/testdata/ContractDefinition/original.sol +++ b/crates/fmt/testdata/ContractDefinition/original.sol @@ -1,6 +1,12 @@ contract ContractDefinition is Contract1, Contract2, Contract3, Contract4, Contract5 { } +contract EmptyBodyWithComment { /* body */ } + +contract EmptyBodyWithComments { /* one */ /* two */ } + +contract NonEmptyBodyTrailingComments { uint256 x; /* one */ /* two */ } + // comment 7 contract SampleContract { @@ -17,7 +23,7 @@ contract SampleContract { // comment 16 external /* comment 17 */ pure - returns(uint256) + returns(uint256) // comment 18 { // comment 19 return arg1 > arg2 ? arg1 : arg2; @@ -38,3 +44,46 @@ contract ERC20DecimalsMock is ERC20 { _decimals = decimals_; } } + +contract SomeContract is + ERC165Upgradeable, // 1 inherited component + ISomeContract // 4 inherited components +{ } + +contract AnotherContract is + Adminable, /* 1 inherited components */ + UUPSUpgradeable /* 1 inherited component */ +{ } + +contract WithLayoutAndBase layout at 69 is Base {} +contract ERC7201Short layout at erc7201("s") is Base {} +contract ERC7201Mid layout at erc7201("openzeppelin.med") is Base {} +contract ERC7201OverMax layout at erc7201("openzeppelin.storage.exceeds.max") is Base {} + +interface IERC721 /* is IERC165 */ { + function balanceOf(address owner) external view returns (uint256); +} + +interface IERC721Empty /* is IERC165 */ {} + +contract WithBaseAndHeaderComment is Base /* base */ { + uint256 x; +} + +contract WithLayoutAndHeaderComment layout at 69 /* layout */ {} + +contract WithLayoutAfterBase is Base layout at erc7201("x{y") /* layout */ { + uint256 x; +} + +contract MultipleHeaderComments /* one */ /* two */ {} + +contract VeryLongContractNameThatForcesTheHeaderToBreak is BaseOne, BaseTwo /* base */ { + uint256 x; +} + +contract HeaderCommentOnItsOwnLine +// isolated +{ + uint256 x; +} diff --git a/crates/fmt/testdata/ContractDefinition/wrap-comments.fmt.sol b/crates/fmt/testdata/ContractDefinition/wrap-comments.fmt.sol new file mode 100644 index 0000000000000..6a81c086379be --- /dev/null +++ b/crates/fmt/testdata/ContractDefinition/wrap-comments.fmt.sol @@ -0,0 +1,106 @@ +// config: wrap_comments = true +contract ContractDefinition is + Contract1, + Contract2, + Contract3, + Contract4, + Contract5 +{} + +contract EmptyBodyWithComment { /* body */ } + +contract EmptyBodyWithComments { /* one */ /* two */ } + +contract NonEmptyBodyTrailingComments { + uint256 x; + /* one */ /* two */ } + +// comment 7 +contract SampleContract { + // spaced comment 1 + + // spaced comment 2 + // that spans multiple lines + + // comment 8 + constructor() { /* comment 9 */ } // comment 10 + + // comment 11 + function max( /* comment 13 */ + uint256 arg1, + uint256 /* comment 14 */ arg2, + uint256 /* comment 15 */ + ) + // comment 16 + external /* comment 17 */ + pure + returns (uint256) + // comment 18 + { + // comment 19 + return arg1 > arg2 ? arg1 : arg2; + } +} + +// comment 20 +contract /* comment 21 */ ExampleContract is /* comment 22 */ SampleContract {} + +contract ERC20DecimalsMock is ERC20 { + uint8 private immutable _decimals; + + constructor(string memory name_, string memory symbol_, uint8 decimals_) + ERC20(name_, symbol_) + { + _decimals = decimals_; + } +} + +contract SomeContract is + ERC165Upgradeable, // 1 inherited component + ISomeContract // 4 inherited components +{} + +contract AnotherContract is + Adminable, /* 1 inherited components */ + UUPSUpgradeable /* 1 inherited component */ +{} + +contract WithLayoutAndBase layout at 69 is Base {} + +contract ERC7201Short layout at erc7201("s") is Base {} + +contract ERC7201Mid layout at erc7201("openzeppelin.med") is Base {} + +contract ERC7201OverMax layout at erc7201("openzeppelin.storage.exceeds.max") + is + Base +{} + +interface IERC721 /* is IERC165 */ { + function balanceOf(address owner) external view returns (uint256); +} + +interface IERC721Empty /* is IERC165 */ {} + +contract WithBaseAndHeaderComment is Base /* base */ { + uint256 x; +} + +contract WithLayoutAndHeaderComment layout at 69 /* layout */ {} + +contract WithLayoutAfterBase layout at erc7201("x{y") is Base /* layout */ { + uint256 x; +} + +contract MultipleHeaderComments /* one */ /* two */ {} + +contract VeryLongContractNameThatForcesTheHeaderToBreak is + BaseOne, + BaseTwo /* base */ { + uint256 x; +} + +contract HeaderCommentOnItsOwnLine { + // isolated + uint256 x; +} diff --git a/crates/fmt/testdata/DocComments/block.fmt.sol b/crates/fmt/testdata/DocComments/block.fmt.sol new file mode 100644 index 0000000000000..be2f35cc64499 --- /dev/null +++ b/crates/fmt/testdata/DocComments/block.fmt.sol @@ -0,0 +1,123 @@ +// config: docs_style = "block" +pragma solidity ^0.8.13; + +/** + * @title A Hello world example + */ +contract HelloWorld { + /** + * Some example struct + */ + struct Person { + uint256 age; + address wallet; + } + + /** + * Here's a more double asterix comment + */ + Person public theDude; + + /** + * Will this long comment be wrapped leaving + * orphan words? + */ + Person public anotherDude; + + /** + * Constructs the dude + * @param age The dude's age + */ + constructor(uint256 age) { + theDude = Person({age: age, wallet: msg.sender}); + } + + /** + * @dev does nothing + */ + function example() public { + /** + * Does this add a whitespace error? + * + * Let's find out. + */ + } + + /** + * @dev Calculates a rectangle's surface and perimeter. + * @param w Width of the rectangle. + * @param h Height of the rectangle. + * @return s The calculated surface. + * @return p The calculated perimeter. + */ + function rectangle(uint256 w, uint256 h) + public + pure + returns (uint256 s, uint256 p) + { + s = w * h; + p = 2 * (w + h); + } + + /** + * A long doc line comment that will be wrapped + */ + function docLineOverflow() external {} + + /** + * @dev No-ops are allowed. No-ops are allowed. No-ops are allowed. No-ops are allowed. No-ops are allowed. No-ops are allowed. + * @dev Zero checks are not systematically performed. + */ + function adjacentDocTagsOverflow() external {} + + function docLinePostfixOverflow() external {} + + /** + * A long doc line comment that will be wrapped + */ + + /** + * @notice Here is my comment + * - item 1 + * - item 2 + * Some equations: + * y = mx + b + */ + function anotherExample() external {} + + /** + * contract A { + * function foo() public { + * // does nothing. + * } + * } + */ + function multilineIndent() external {} + + /** + * contract A { + * function foo() public { + * // does nothing. + * } + * } + */ + function multilineMalformedIndent() external {} + + /** + * contract A { + * function withALongNameThatWillCauseCommentWrap() public { + * // does nothing. + * } + * } + */ + function malformedIndentOverflow() external {} +} + +/** + * contract A { + * function foo() public { + * // does nothing. + * } + * } + */ +function freeFloatingMultilineIndent() {} diff --git a/crates/fmt/testdata/DocComments/fmt.sol b/crates/fmt/testdata/DocComments/fmt.sol index 4248f0fe587da..1d1115cf258a9 100644 --- a/crates/fmt/testdata/DocComments/fmt.sol +++ b/crates/fmt/testdata/DocComments/fmt.sol @@ -13,6 +13,10 @@ contract HelloWorld { */ Person public theDude; + /// Will this long comment be wrapped leaving + /// orphan words? + Person public anotherDude; + /// Constructs the dude /// @param age The dude's age constructor(uint256 age) { @@ -49,6 +53,10 @@ contract HelloWorld { /// A long doc line comment that will be wrapped function docLineOverflow() external {} + /// @dev No-ops are allowed. No-ops are allowed. No-ops are allowed. No-ops are allowed. No-ops are allowed. No-ops are allowed. + /// @dev Zero checks are not systematically performed. + function adjacentDocTagsOverflow() external {} + function docLinePostfixOverflow() external {} /// A long doc line comment that will be wrapped diff --git a/crates/fmt/testdata/DocComments/line.fmt.sol b/crates/fmt/testdata/DocComments/line.fmt.sol new file mode 100644 index 0000000000000..73457fb4e1db0 --- /dev/null +++ b/crates/fmt/testdata/DocComments/line.fmt.sol @@ -0,0 +1,91 @@ +// config: docs_style = "line" +pragma solidity ^0.8.13; + +/// @title A Hello world example +contract HelloWorld { + /// Some example struct + struct Person { + uint256 age; + address wallet; + } + + /// Here's a more double asterix comment + Person public theDude; + + /// Will this long comment be wrapped leaving + /// orphan words? + Person public anotherDude; + + /// Constructs the dude + /// @param age The dude's age + constructor(uint256 age) { + theDude = Person({age: age, wallet: msg.sender}); + } + + /// @dev does nothing + function example() public { + /// Does this add a whitespace error? + /// + /// Let's find out. + } + + /// @dev Calculates a rectangle's surface and perimeter. + /// @param w Width of the rectangle. + /// @param h Height of the rectangle. + /// @return s The calculated surface. + /// @return p The calculated perimeter. + function rectangle(uint256 w, uint256 h) + public + pure + returns (uint256 s, uint256 p) + { + s = w * h; + p = 2 * (w + h); + } + + /// A long doc line comment that will be wrapped + function docLineOverflow() external {} + + /// @dev No-ops are allowed. No-ops are allowed. No-ops are allowed. No-ops are allowed. No-ops are allowed. No-ops are allowed. + /// @dev Zero checks are not systematically performed. + function adjacentDocTagsOverflow() external {} + + function docLinePostfixOverflow() external {} + + /// A long doc line comment that will be wrapped + + /// @notice Here is my comment + /// - item 1 + /// - item 2 + /// Some equations: + /// y = mx + b + function anotherExample() external {} + + /// contract A { + /// function foo() public { + /// // does nothing. + /// } + /// } + function multilineIndent() external {} + + /// contract A { + /// function foo() public { + /// // does nothing. + /// } + /// } + function multilineMalformedIndent() external {} + + /// contract A { + /// function withALongNameThatWillCauseCommentWrap() public { + /// // does nothing. + /// } + /// } + function malformedIndentOverflow() external {} +} + +/// contract A { +/// function foo() public { +/// // does nothing. +/// } +/// } +function freeFloatingMultilineIndent() {} diff --git a/crates/fmt/testdata/DocComments/original.sol b/crates/fmt/testdata/DocComments/original.sol index 28f654b57903d..21e9368a5c235 100644 --- a/crates/fmt/testdata/DocComments/original.sol +++ b/crates/fmt/testdata/DocComments/original.sol @@ -14,6 +14,10 @@ contract HelloWorld { */ Person public theDude; + /// Will this long comment be wrapped leaving + /// orphan words? + Person public anotherDude; + /// Constructs the dude /// @param age The dude's age constructor(uint256 age) { @@ -27,7 +31,7 @@ contract HelloWorld { function example() public { /** * Does this add a whitespace error? - * + * * Let's find out. */ } @@ -43,10 +47,14 @@ contract HelloWorld { p = 2 * (w + h); } - /// A long doc line comment that will be wrapped + /// A long doc line comment that will be wrapped function docLineOverflow() external {} - function docLinePostfixOverflow() external {} /// A long doc line comment that will be wrapped + /// @dev No-ops are allowed. No-ops are allowed. No-ops are allowed. No-ops are allowed. No-ops are allowed. No-ops are allowed. + /// @dev Zero checks are not systematically performed. + function adjacentDocTagsOverflow() external {} + + function docLinePostfixOverflow() external {} /// A long doc line comment that will be wrapped /** * @notice Here is my comment diff --git a/crates/fmt/testdata/DocComments/tab.fmt.sol b/crates/fmt/testdata/DocComments/tab.fmt.sol index 0a2ca7a309431..323b244b5fdfb 100644 --- a/crates/fmt/testdata/DocComments/tab.fmt.sol +++ b/crates/fmt/testdata/DocComments/tab.fmt.sol @@ -10,10 +10,14 @@ contract HelloWorld { } /** - * Here's a more double asterix comment + * Here's a more double asterix comment */ Person public theDude; + /// Will this long comment be wrapped leaving + /// orphan words? + Person public anotherDude; + /// Constructs the dude /// @param age The dude's age constructor(uint256 age) { @@ -21,22 +25,22 @@ contract HelloWorld { } /** - * @dev does nothing + * @dev does nothing */ function example() public { /** - * Does this add a whitespace error? + * Does this add a whitespace error? * - * Let's find out. + * Let's find out. */ } /** - * @dev Calculates a rectangle's surface and perimeter. - * @param w Width of the rectangle. - * @param h Height of the rectangle. - * @return s The calculated surface. - * @return p The calculated perimeter. + * @dev Calculates a rectangle's surface and perimeter. + * @param w Width of the rectangle. + * @param h Height of the rectangle. + * @return s The calculated surface. + * @return p The calculated perimeter. */ function rectangle(uint256 w, uint256 h) public @@ -50,52 +54,56 @@ contract HelloWorld { /// A long doc line comment that will be wrapped function docLineOverflow() external {} + /// @dev No-ops are allowed. No-ops are allowed. No-ops are allowed. No-ops are allowed. No-ops are allowed. No-ops are allowed. + /// @dev Zero checks are not systematically performed. + function adjacentDocTagsOverflow() external {} + function docLinePostfixOverflow() external {} /// A long doc line comment that will be wrapped /** - * @notice Here is my comment - * - item 1 - * - item 2 - * Some equations: - * y = mx + b + * @notice Here is my comment + * - item 1 + * - item 2 + * Some equations: + * y = mx + b */ function anotherExample() external {} /** - * contract A { - * function foo() public { - * // does nothing. - * } - * } + * contract A { + * function foo() public { + * // does nothing. + * } + * } */ function multilineIndent() external {} /** - * contract A { - * function foo() public { - * // does nothing. - * } - * } + * contract A { + * function foo() public { + * // does nothing. + * } + * } */ function multilineMalformedIndent() external {} /** - * contract A { - * function withALongNameThatWillCauseCommentWrap() public { - * // does nothing. - * } - * } + * contract A { + * function withALongNameThatWillCauseCommentWrap() public { + * // does nothing. + * } + * } */ function malformedIndentOverflow() external {} } /** - * contract A { - * function foo() public { - * // does nothing. - * } - * } + * contract A { + * function foo() public { + * // does nothing. + * } + * } */ function freeFloatingMultilineIndent() {} diff --git a/crates/fmt/testdata/DocComments/wrap-comments.fmt.sol b/crates/fmt/testdata/DocComments/wrap-comments.fmt.sol index c3c7fe00c9180..c68c08479f9a3 100644 --- a/crates/fmt/testdata/DocComments/wrap-comments.fmt.sol +++ b/crates/fmt/testdata/DocComments/wrap-comments.fmt.sol @@ -11,17 +11,19 @@ contract HelloWorld { } /** - * Here's a more double asterix - * comment + * Here's a more double asterix comment */ Person public theDude; + /// Will this long comment be wrapped + /// leaving orphan words? + Person public anotherDude; + /// Constructs the dude /// @param age The dude's age constructor(uint256 age) { theDude = Person({ - age: age, - wallet: msg.sender + age: age, wallet: msg.sender }); } @@ -30,21 +32,19 @@ contract HelloWorld { */ function example() public { /** - * Does this add a whitespace - * error? + * Does this add a whitespace error? * * Let's find out. */ } /** - * @dev Calculates a rectangle's - * surface and perimeter. + * @dev Calculates a rectangle's surface + * and perimeter. * @param w Width of the rectangle. * @param h Height of the rectangle. * @return s The calculated surface. - * @return p The calculated - * perimeter. + * @return p The calculated perimeter. */ function rectangle( uint256 w, @@ -58,18 +58,24 @@ contract HelloWorld { p = 2 * (w + h); } - /// A long doc line comment that - /// will be wrapped - function docLineOverflow() - external - {} + /// A long doc line comment that will be + /// wrapped + function docLineOverflow() external {} + + /// @dev No-ops are allowed. No-ops are + /// allowed. No-ops are allowed. No-ops + /// are allowed. No-ops are allowed. + /// No-ops are allowed. + /// @dev Zero checks are not + /// systematically performed. + function adjacentDocTagsOverflow() + external {} function docLinePostfixOverflow() - external - {} + external {} - /// A long doc line comment that - /// will be wrapped + /// A long doc line comment that will be + /// wrapped /** * @notice Here is my comment @@ -78,9 +84,7 @@ contract HelloWorld { * Some equations: * y = mx + b */ - function anotherExample() - external - {} + function anotherExample() external {} /** * contract A { @@ -89,9 +93,7 @@ contract HelloWorld { * } * } */ - function multilineIndent() - external - {} + function multilineIndent() external {} /** * contract A { @@ -101,8 +103,7 @@ contract HelloWorld { * } */ function multilineMalformedIndent() - external - {} + external {} /** * contract A { @@ -114,8 +115,7 @@ contract HelloWorld { * } */ function malformedIndentOverflow() - external - {} + external {} } /** diff --git a/crates/fmt/testdata/EmitStatement/120.compact.fmt.sol b/crates/fmt/testdata/EmitStatement/120.compact.fmt.sol new file mode 100644 index 0000000000000..99b1446b894d8 --- /dev/null +++ b/crates/fmt/testdata/EmitStatement/120.compact.fmt.sol @@ -0,0 +1,48 @@ +// config: line_length = 120 +event NewEvent(address beneficiary, uint256 index, uint64 timestamp, uint64 endTimestamp); + +function emitEvent() { + emit NewEvent(beneficiary, _vestingBeneficiaries.length - 1, uint64(block.timestamp), endTimestamp); + + emit NewEvent( /* beneficiary */ + beneficiary, + /* index */ + _vestingBeneficiaries.length - 1, + /* timestamp */ + uint64(block.timestamp), + /* end timestamp */ + endTimestamp + ); + + emit NewEvent( + beneficiary, // beneficiary + _vestingBeneficiaries.length - 1, // index + uint64(block.timestamp), // timestamp + endTimestamp // end timestamp + ); + + // https://github.com/foundry-rs/foundry/issues/12029 + emit OperatorSharesDecreased( + defaultOperator, + address(0), + strategyMock, + depositAmount / 6 // 1 withdrawal not queued so decreased + ); + + // https://github.com/foundry-rs/foundry/issues/12146 + emit ISablierComptroller.DisableCustomFeeUSD( + protocol_protocol, caller_caller, user_users.sender, previousMinFeeUSD_0, newMinFeeUSD_feeUSD + ); + emit ISablierComptroller.DisableCustomFeeUSD({ + protocol: protocol, caller: caller, user: users.sender, previousMinFeeUSD: 0, newMinFeeUSD: feeUSD + }); + + emit ISablierLockupLinear.CreateLockupLinearStream({ + streamId: streamId, + commonParams: Lockup.CreateEventCommon({ + funder: msg.sender, sender: sender, recipient: recipient, depositAmount: depositAmount + }), + cliffTime: cliffTime, + unlockAmounts: unlockAmounts + }); +} diff --git a/crates/fmt/testdata/EmitStatement/120.fmt.sol b/crates/fmt/testdata/EmitStatement/120.fmt.sol new file mode 100644 index 0000000000000..8e12d544bacf9 --- /dev/null +++ b/crates/fmt/testdata/EmitStatement/120.fmt.sol @@ -0,0 +1,60 @@ +// config: line_length = 120 +// config: prefer_compact = "none" +event NewEvent(address beneficiary, uint256 index, uint64 timestamp, uint64 endTimestamp); + +function emitEvent() { + emit NewEvent(beneficiary, _vestingBeneficiaries.length - 1, uint64(block.timestamp), endTimestamp); + + emit NewEvent( /* beneficiary */ + beneficiary, + /* index */ + _vestingBeneficiaries.length - 1, + /* timestamp */ + uint64(block.timestamp), + /* end timestamp */ + endTimestamp + ); + + emit NewEvent( + beneficiary, // beneficiary + _vestingBeneficiaries.length - 1, // index + uint64(block.timestamp), // timestamp + endTimestamp // end timestamp + ); + + // https://github.com/foundry-rs/foundry/issues/12029 + emit OperatorSharesDecreased( + defaultOperator, + address(0), + strategyMock, + depositAmount / 6 // 1 withdrawal not queued so decreased + ); + + // https://github.com/foundry-rs/foundry/issues/12146 + emit ISablierComptroller.DisableCustomFeeUSD( + protocol_protocol, + caller_caller, + user_users.sender, + previousMinFeeUSD_0, + newMinFeeUSD_feeUSD + ); + emit ISablierComptroller.DisableCustomFeeUSD({ + protocol: protocol, + caller: caller, + user: users.sender, + previousMinFeeUSD: 0, + newMinFeeUSD: feeUSD + }); + + emit ISablierLockupLinear.CreateLockupLinearStream({ + streamId: streamId, + commonParams: Lockup.CreateEventCommon({ + funder: msg.sender, + sender: sender, + recipient: recipient, + depositAmount: depositAmount + }), + cliffTime: cliffTime, + unlockAmounts: unlockAmounts + }); +} diff --git a/crates/fmt/testdata/EmitStatement/fmt.sol b/crates/fmt/testdata/EmitStatement/fmt.sol index 0fac66b9b2b80..b31df04a7818e 100644 --- a/crates/fmt/testdata/EmitStatement/fmt.sol +++ b/crates/fmt/testdata/EmitStatement/fmt.sol @@ -11,8 +11,7 @@ function emitEvent() { endTimestamp ); - emit NewEvent( - /* beneficiary */ + emit NewEvent( /* beneficiary */ beneficiary, /* index */ _vestingBeneficiaries.length - 1, @@ -28,4 +27,40 @@ function emitEvent() { uint64(block.timestamp), // timestamp endTimestamp // end timestamp ); + + // https://github.com/foundry-rs/foundry/issues/12029 + emit OperatorSharesDecreased( + defaultOperator, + address(0), + strategyMock, + depositAmount / 6 // 1 withdrawal not queued so decreased + ); + + // https://github.com/foundry-rs/foundry/issues/12146 + emit ISablierComptroller.DisableCustomFeeUSD( + protocol_protocol, + caller_caller, + user_users.sender, + previousMinFeeUSD_0, + newMinFeeUSD_feeUSD + ); + emit ISablierComptroller.DisableCustomFeeUSD({ + protocol: protocol, + caller: caller, + user: users.sender, + previousMinFeeUSD: 0, + newMinFeeUSD: feeUSD + }); + + emit ISablierLockupLinear.CreateLockupLinearStream({ + streamId: streamId, + commonParams: Lockup.CreateEventCommon({ + funder: msg.sender, + sender: sender, + recipient: recipient, + depositAmount: depositAmount + }), + cliffTime: cliffTime, + unlockAmounts: unlockAmounts + }); } diff --git a/crates/fmt/testdata/EmitStatement/original.sol b/crates/fmt/testdata/EmitStatement/original.sol index 661abb78261d7..bb554fa974ebd 100644 --- a/crates/fmt/testdata/EmitStatement/original.sol +++ b/crates/fmt/testdata/EmitStatement/original.sol @@ -20,5 +20,29 @@ function emitEvent() { _vestingBeneficiaries.length - 1, // index uint64(block.timestamp), // timestamp endTimestamp // end timestamp - ); + ); + + // https://github.com/foundry-rs/foundry/issues/12029 + emit OperatorSharesDecreased( + defaultOperator, + address(0), + strategyMock, + depositAmount / 6 // 1 withdrawal not queued so decreased + ); + + // https://github.com/foundry-rs/foundry/issues/12146 + emit ISablierComptroller.DisableCustomFeeUSD(protocol_protocol, caller_caller, user_users.sender, previousMinFeeUSD_0, newMinFeeUSD_feeUSD); + emit ISablierComptroller.DisableCustomFeeUSD({ protocol: protocol, caller: caller, user: users.sender, previousMinFeeUSD: 0, newMinFeeUSD: feeUSD }); + + emit ISablierLockupLinear.CreateLockupLinearStream({ + streamId: streamId, + commonParams: Lockup.CreateEventCommon({ + funder: msg.sender, + sender: sender, + recipient: recipient, + depositAmount: depositAmount + }), + cliffTime: cliffTime, + unlockAmounts: unlockAmounts + }); } diff --git a/crates/fmt/testdata/EnumDefinition/bracket-spacing.fmt.sol b/crates/fmt/testdata/EnumDefinition/bracket-spacing.fmt.sol index a4ae0f0192051..a63390b117e24 100644 --- a/crates/fmt/testdata/EnumDefinition/bracket-spacing.fmt.sol +++ b/crates/fmt/testdata/EnumDefinition/bracket-spacing.fmt.sol @@ -1,12 +1,15 @@ // config: bracket_spacing = true contract EnumDefinitions { - enum Empty { } + enum Empty {} enum ActionChoices { GoLeft, GoRight, GoStraight, SitStill } + enum TrailingComments { + GoLeft + /* one */ /* two */ } enum States { State1, State2, diff --git a/crates/fmt/testdata/EnumDefinition/fmt.sol b/crates/fmt/testdata/EnumDefinition/fmt.sol index 437268aff6423..a176a25a67803 100644 --- a/crates/fmt/testdata/EnumDefinition/fmt.sol +++ b/crates/fmt/testdata/EnumDefinition/fmt.sol @@ -6,6 +6,9 @@ contract EnumDefinitions { GoStraight, SitStill } + enum TrailingComments { + GoLeft + /* one */ /* two */ } enum States { State1, State2, diff --git a/crates/fmt/testdata/EnumDefinition/original.sol b/crates/fmt/testdata/EnumDefinition/original.sol index 69aadf884aec2..d09abb6ce410b 100644 --- a/crates/fmt/testdata/EnumDefinition/original.sol +++ b/crates/fmt/testdata/EnumDefinition/original.sol @@ -3,5 +3,6 @@ contract EnumDefinitions { } enum ActionChoices { GoLeft, GoRight, GoStraight, SitStill } + enum TrailingComments { GoLeft /* one */ /* two */ } enum States { State1, State2, State3, State4, State5, State6, State7, State8, State9 } } \ No newline at end of file diff --git a/crates/fmt/testdata/EnumDefinition/wrap-comments.fmt.sol b/crates/fmt/testdata/EnumDefinition/wrap-comments.fmt.sol new file mode 100644 index 0000000000000..2d316d5d32a2b --- /dev/null +++ b/crates/fmt/testdata/EnumDefinition/wrap-comments.fmt.sol @@ -0,0 +1,24 @@ +// config: wrap_comments = true +contract EnumDefinitions { + enum Empty {} + enum ActionChoices { + GoLeft, + GoRight, + GoStraight, + SitStill + } + enum TrailingComments { + GoLeft + /* one */ /* two */ } + enum States { + State1, + State2, + State3, + State4, + State5, + State6, + State7, + State8, + State9 + } +} diff --git a/crates/fmt/testdata/EnumVariants/fmt.sol b/crates/fmt/testdata/EnumVariants/fmt.sol index b33b8846984d2..3f1c59b8f2595 100644 --- a/crates/fmt/testdata/EnumVariants/fmt.sol +++ b/crates/fmt/testdata/EnumVariants/fmt.sol @@ -10,8 +10,8 @@ interface I { /// A modification applied to either `msg.sender` or `tx.origin`. Returned by `readCallers`. enum CallerMode2 { /// No caller modification is currently active. - None, - /// No caller modification is currently active2. + None, /// No caller modification is currently active2. + Some } diff --git a/crates/fmt/testdata/FnAttributeComment/fmt.sol b/crates/fmt/testdata/FnAttributeComment/fmt.sol new file mode 100644 index 0000000000000..1f133e10fe487 --- /dev/null +++ b/crates/fmt/testdata/FnAttributeComment/fmt.sol @@ -0,0 +1,12 @@ +contract C { + function someFunctionWithAnExtremelyLongNameThatForcesTheHeaderToBreakAcrossLines() + /* @use-src 0:"input.sol", 1:"#utility.yul" */ + public {} + + function shortOne() /* c */ public {} + + function mixedThenLine() + /* a */ + // b + public {} +} diff --git a/crates/fmt/testdata/FnAttributeComment/original.sol b/crates/fmt/testdata/FnAttributeComment/original.sol new file mode 100644 index 0000000000000..98b3bfb3638d6 --- /dev/null +++ b/crates/fmt/testdata/FnAttributeComment/original.sol @@ -0,0 +1,8 @@ +contract C { + function someFunctionWithAnExtremelyLongNameThatForcesTheHeaderToBreakAcrossLines() /* @use-src 0:"input.sol", 1:"#utility.yul" */ public { } + + function shortOne() /* c */ public {} + + function mixedThenLine() /* a */ // b + public {} +} diff --git a/crates/fmt/testdata/ForStatement/block-single.fmt.sol b/crates/fmt/testdata/ForStatement/block-single.fmt.sol new file mode 100644 index 0000000000000..98f1f3eaf227d --- /dev/null +++ b/crates/fmt/testdata/ForStatement/block-single.fmt.sol @@ -0,0 +1,119 @@ +// config: single_line_statement_blocks = "single" +pragma solidity ^0.8.8; + +contract ForStatement { + function test() external { + for (uint256 i1; i1 < 10; i1++) { + i1++; + } + + uint256 i2; + for (++i2; i2 < 10; i2++) {} + + uint256 veryLongVariableName = 1000; + for ( + uint256 i3; + i3 < 10 && veryLongVariableName > 999 + && veryLongVariableName < 1001; + i3++ + ) { + i3++; + } + + for ( + uint256 i3; + i3 < 10 && veryLongVariableName > 900 && veryLongVariableName < 999; + i3++ + ) { + i3++; + } + + for (type(uint256).min;;) {} + + for (;;) { + "test"; + } + + for (uint256 i4; i4 < 10; i4++) { + i4++; + } + + for (uint256 i5;;) { + for (uint256 i6 = 10; i6 > i5; i6--) { + i5++; + } + } + + for (;;) { + doIt(); + } + for (;;) { + doIt(); + doIt(); + } + + while (c) { + for (;;) { + x(); + } + } + } + + function bracedTrailingComment() external { + uint256 x; + for (uint256 i = 0; i < 10; ++i) { + // step + x++; + } + } + + function bracelessTrailingComment() external { + uint256 x; + for (uint256 i = 0; i < 10; ++i) { + x++; // step + } + } + + function emptyBodyTrailingComment() external { + for (uint256 i = 0; i < 10; ++i) { // step + } + } + + function leadingCommentUnaffected() external { + // leading comment, must not move + for (uint256 i = 0; i < 10; ++i) { + i; + } + } + + function commentAfterHeaderNoCondNoNext() external { + for (uint256 i = 0;;) // after header + {} + } + + function missingConditionTrailingComment() external { + for (uint256 i = 0;; ++i) { + // c + i; + } + } + + function missingIncrementTrailingComment() external { + for (uint256 i = 0; i < 10;) { + // c + i; + } + } + + function initializerTrailingComment() external { + for ( + uint256 i = 0; + + // init + i < 10; + ++i + ) { + i++; + } + } +} diff --git a/crates/fmt/testdata/ForStatement/fmt.sol b/crates/fmt/testdata/ForStatement/fmt.sol index a1bb4b2e6a28c..2dc1cb675a569 100644 --- a/crates/fmt/testdata/ForStatement/fmt.sol +++ b/crates/fmt/testdata/ForStatement/fmt.sol @@ -12,7 +12,16 @@ contract ForStatement { uint256 veryLongVariableName = 1000; for ( uint256 i3; - i3 < 10 && veryLongVariableName > 999 && veryLongVariableName < 1001; + i3 < 10 && veryLongVariableName > 999 + && veryLongVariableName < 1001; + i3++ + ) { + i3++; + } + + for ( + uint256 i3; + i3 < 10 && veryLongVariableName > 900 && veryLongVariableName < 999; i3++ ) { i3++; @@ -33,5 +42,77 @@ contract ForStatement { i5++; } } + + for (;;) { + doIt(); + } + for (;;) { + doIt(); + doIt(); + } + + while (c) { + for (;;) { + x(); + } + } + } + + function bracedTrailingComment() external { + uint256 x; + for (uint256 i = 0; i < 10; ++i) { + // step + x++; + } + } + + function bracelessTrailingComment() external { + uint256 x; + for (uint256 i = 0; i < 10; ++i) { + x++; // step + } + } + + function emptyBodyTrailingComment() external { + for (uint256 i = 0; i < 10; ++i) { // step + } + } + + function leadingCommentUnaffected() external { + // leading comment, must not move + for (uint256 i = 0; i < 10; ++i) { + i; + } + } + + function commentAfterHeaderNoCondNoNext() external { + for (uint256 i = 0;;) // after header + {} + } + + function missingConditionTrailingComment() external { + for (uint256 i = 0;; ++i) { + // c + i; + } + } + + function missingIncrementTrailingComment() external { + for (uint256 i = 0; i < 10;) { + // c + i; + } + } + + function initializerTrailingComment() external { + for ( + uint256 i = 0; + + // init + i < 10; + ++i + ) { + i++; + } } } diff --git a/crates/fmt/testdata/ForStatement/original.sol b/crates/fmt/testdata/ForStatement/original.sol index e98288dd1cbce..d23097a6d4126 100644 --- a/crates/fmt/testdata/ForStatement/original.sol +++ b/crates/fmt/testdata/ForStatement/original.sol @@ -11,7 +11,7 @@ contract ForStatement { uint256 i2; for(++i2;i2<10;i2++) - + {} uint256 veryLongVariableName = 1000; @@ -20,6 +20,12 @@ contract ForStatement { ; i3++) { i3 ++ ; } + for ( uint256 i3; i3 < 10 + && veryLongVariableName>900 && veryLongVariableName< 999 + ; i3++) + { i3 ++ ; } + + for (type(uint256).min;;) {} for (;;) { "test" ; } @@ -29,5 +35,58 @@ contract ForStatement { for (uint256 i5; ;) for (uint256 i6 = 10; i6 > i5; i6--) i5++; + + for (;;) doIt(); + for (;;) { doIt(); doIt(); } + + while (c) for (;;) x(); + } + + function bracedTrailingComment() external { + uint256 x; + for (uint256 i = 0; i < 10; ++i) { // step + x++; + } + } + + function bracelessTrailingComment() external { + uint256 x; + for (uint256 i = 0; i < 10; ++i) x++; // step + } + + function emptyBodyTrailingComment() external { + for (uint256 i = 0; i < 10; ++i) { // step + } + } + + function leadingCommentUnaffected() external { + // leading comment, must not move + for (uint256 i = 0; i < 10; ++i) { + i; + } + } + + function commentAfterHeaderNoCondNoNext() external { + for (uint256 i = 0;;) // after header + {} + } + + function missingConditionTrailingComment() external { + for (uint256 i = 0; ; ++i) { // c + i; + } + } + + function missingIncrementTrailingComment() external { + for (uint256 i = 0; i < 10;) { // c + i; + } + } + + function initializerTrailingComment() external { + for (uint256 i = 0; // init + i < 10; ++i) { + i++; + } } -} \ No newline at end of file +} diff --git a/crates/fmt/testdata/FunctionCallArgsStatement/bracket-spacing.fmt.sol b/crates/fmt/testdata/FunctionCallArgsStatement/bracket-spacing.fmt.sol index 93e5eb1a2e793..3e7513c854d47 100644 --- a/crates/fmt/testdata/FunctionCallArgsStatement/bracket-spacing.fmt.sol +++ b/crates/fmt/testdata/FunctionCallArgsStatement/bracket-spacing.fmt.sol @@ -1,3 +1,4 @@ +// config: line_length = 120 // config: bracket_spacing = true interface ITarget { function run() external payable; @@ -11,10 +12,7 @@ contract FunctionCallArgsStatement { gas = 1 gwei; } - function veryAndVeryLongNameOfSomeGasEstimateFunction() - public - returns (uint256) - { + function veryAndVeryLongNameOfSomeGasEstimateFunction() public returns (uint256) { return gasleft(); } @@ -31,12 +29,11 @@ contract FunctionCallArgsStatement { target.run{ gas: estimate(), value: value(1) }(); - target.run{ - value: value(1 ether), - gas: veryAndVeryLongNameOfSomeGasEstimateFunction() - }(); + target.run{ value: value(1 ether), gas: veryAndVeryLongNameOfSomeGasEstimateFunction() }(); - target.run{ /* comment 1 */ value: /* comment2 */ 1 }; + target.run{ /* comment 1 */ + value: /* comment2 */ 1 + }; target.run{ /* comment3 */ value: 1, // comment4 @@ -51,5 +48,10 @@ contract FunctionCallArgsStatement { }; vm.expectEmit({ checkTopic1: false, checkTopic2: false }); + + lockup.withdraw{ value: LOCKUP_MIN_FEE_WEI }({ + streamId: streamId, to: users.recipient, amount: withdrawAmount + }); + portal.xcall{ value: msg.value }(id, ConfLevel.Finalized, Predeploys.NominaBridgeNative, xcalldata); } } diff --git a/crates/fmt/testdata/FunctionCallArgsStatement/fmt.sol b/crates/fmt/testdata/FunctionCallArgsStatement/fmt.sol index 5a5cc5f634281..927f9040755f7 100644 --- a/crates/fmt/testdata/FunctionCallArgsStatement/fmt.sol +++ b/crates/fmt/testdata/FunctionCallArgsStatement/fmt.sol @@ -35,7 +35,9 @@ contract FunctionCallArgsStatement { gas: veryAndVeryLongNameOfSomeGasEstimateFunction() }(); - target.run{ /* comment 1 */ value: /* comment2 */ 1}; + target.run{ /* comment 1 */ + value: /* comment2 */ 1 + }; target.run{ /* comment3 */ value: 1, // comment4 @@ -50,5 +52,12 @@ contract FunctionCallArgsStatement { }; vm.expectEmit({checkTopic1: false, checkTopic2: false}); + + lockup.withdraw{value: LOCKUP_MIN_FEE_WEI}({ + streamId: streamId, to: users.recipient, amount: withdrawAmount + }); + portal.xcall{value: msg.value}( + id, ConfLevel.Finalized, Predeploys.NominaBridgeNative, xcalldata + ); } } diff --git a/crates/fmt/testdata/FunctionCallArgsStatement/original.sol b/crates/fmt/testdata/FunctionCallArgsStatement/original.sol index b2cfaa2f28f9a..2a25b90726287 100644 --- a/crates/fmt/testdata/FunctionCallArgsStatement/original.sol +++ b/crates/fmt/testdata/FunctionCallArgsStatement/original.sol @@ -23,18 +23,18 @@ contract FunctionCallArgsStatement { target.run{gas:1,value:0x00}(); - target.run{ - gas : 1000, - value: 1 ether + target.run{ + gas : 1000, + value: 1 ether } (); target.run{ gas: estimate(), - value: value(1) }(); + value: value(1) }(); target.run { value: value(1 ether), gas: veryAndVeryLongNameOfSomeGasEstimateFunction() } (); - target.run /* comment 1 */ { value: /* comment2 */ 1 }; + target.run /* comment 1 */ { value: /* comment2 */ 1 }; target.run { /* comment3 */ value: 1, // comment4 gas: gasleft()}; @@ -46,5 +46,8 @@ contract FunctionCallArgsStatement { gas: gasleft()}; vm.expectEmit({ checkTopic1: false, checkTopic2: false }); + + lockup.withdraw{ value: LOCKUP_MIN_FEE_WEI }({ streamId: streamId, to: users.recipient, amount: withdrawAmount }); + portal.xcall{ value: msg.value }(id, ConfLevel.Finalized, Predeploys.NominaBridgeNative, xcalldata); } -} \ No newline at end of file +} diff --git a/crates/fmt/testdata/FunctionDefinition/all-params.fmt.sol b/crates/fmt/testdata/FunctionDefinition/all-params.fmt.sol index db7164d284a54..f7ff46fd7cd2c 100644 --- a/crates/fmt/testdata/FunctionDefinition/all-params.fmt.sol +++ b/crates/fmt/testdata/FunctionDefinition/all-params.fmt.sol @@ -15,7 +15,7 @@ interface FunctionInterfaces { uint256 x1, // x1 postfix // x2 prefix uint256 x2, // x2 postfix - // x2 postfix2 + // x2 postfix2 /* multi-line x3 prefix */ @@ -39,9 +39,8 @@ interface FunctionInterfaces { // y2 prefix uint256 y2, // y2 postfix // y3 prefix - uint256 y3 - ); // y3 postfix - // function postfix + uint256 y3 // y3 postfix + ); // function postfix /*////////////////////////////////////////////////////////////////////////// TEST @@ -332,10 +331,10 @@ interface FunctionInterfaces { } contract FunctionDefinitions { - function() external {} + function f() external {} fallback() external {} - function() external payable {} + function f() external payable {} fallback() external payable {} receive() external payable {} @@ -729,4 +728,13 @@ contract FunctionOverrides is { a = 1; } + + function simple( + address _target, + bytes memory _payload + ) + internal + { + a = 1; + } } diff --git a/crates/fmt/testdata/FunctionDefinition/all.fmt.sol b/crates/fmt/testdata/FunctionDefinition/all.fmt.sol index 6d90880679199..10272e81a04ec 100644 --- a/crates/fmt/testdata/FunctionDefinition/all.fmt.sol +++ b/crates/fmt/testdata/FunctionDefinition/all.fmt.sol @@ -15,7 +15,7 @@ interface FunctionInterfaces { uint256 x1, // x1 postfix // x2 prefix uint256 x2, // x2 postfix - // x2 postfix2 + // x2 postfix2 /* multi-line x3 prefix */ @@ -39,9 +39,8 @@ interface FunctionInterfaces { // y2 prefix uint256 y2, // y2 postfix // y3 prefix - uint256 y3 - ); // y3 postfix - // function postfix + uint256 y3 // y3 postfix + ); // function postfix /*////////////////////////////////////////////////////////////////////////// TEST @@ -332,10 +331,10 @@ interface FunctionInterfaces { } contract FunctionDefinitions { - function() external {} + function f() external {} fallback() external {} - function() external payable {} + function f() external payable {} fallback() external payable {} receive() external payable {} @@ -727,4 +726,13 @@ contract FunctionOverrides is { a = 1; } + + function simple( + address _target, + bytes memory _payload + ) + internal + { + a = 1; + } } diff --git a/crates/fmt/testdata/FunctionDefinition/fmt.sol b/crates/fmt/testdata/FunctionDefinition/fmt.sol index 9e34a8bea2682..3d8ecaed032cd 100644 --- a/crates/fmt/testdata/FunctionDefinition/fmt.sol +++ b/crates/fmt/testdata/FunctionDefinition/fmt.sol @@ -14,7 +14,7 @@ interface FunctionInterfaces { uint256 x1, // x1 postfix // x2 prefix uint256 x2, // x2 postfix - // x2 postfix2 + // x2 postfix2 /* multi-line x3 prefix */ @@ -38,9 +38,8 @@ interface FunctionInterfaces { // y2 prefix uint256 y2, // y2 postfix // y3 prefix - uint256 y3 - ); // y3 postfix - // function postfix + uint256 y3 // y3 postfix + ); // function postfix /*////////////////////////////////////////////////////////////////////////// TEST @@ -323,10 +322,10 @@ interface FunctionInterfaces { } contract FunctionDefinitions { - function() external {} + function f() external {} fallback() external {} - function() external payable {} + function f() external payable {} fallback() external payable {} receive() external payable {} @@ -706,4 +705,10 @@ contract FunctionOverrides is { a = 1; } + + function simple(address _target, bytes memory _payload) + internal + { + a = 1; + } } diff --git a/crates/fmt/testdata/FunctionDefinition/original.sol b/crates/fmt/testdata/FunctionDefinition/original.sol index 97db649d55660..02eb64e8e6fb0 100644 --- a/crates/fmt/testdata/FunctionDefinition/original.sol +++ b/crates/fmt/testdata/FunctionDefinition/original.sol @@ -91,10 +91,10 @@ interface FunctionInterfaces { } contract FunctionDefinitions { - function () external {} + function f() external {} fallback () external {} - function () external payable {} + function f() external payable {} fallback () external payable {} receive () external payable {} @@ -214,5 +214,11 @@ contract FunctionOverrides is FunctionInterfaces, FunctionDefinitions { function oneParam(uint256 x) override(FunctionInterfaces, FunctionDefinitions, SomeOtherFunctionContract, SomeImport.AndAnotherFunctionContract) { a = 1; } -} + function simple(address _target, bytes memory _payload) + internal + { + a = 1; + } + +} diff --git a/crates/fmt/testdata/FunctionDefinition/override-spacing.fmt.sol b/crates/fmt/testdata/FunctionDefinition/override-spacing.fmt.sol index 516e5c2fd42ed..5d62b4411da9e 100644 --- a/crates/fmt/testdata/FunctionDefinition/override-spacing.fmt.sol +++ b/crates/fmt/testdata/FunctionDefinition/override-spacing.fmt.sol @@ -15,7 +15,7 @@ interface FunctionInterfaces { uint256 x1, // x1 postfix // x2 prefix uint256 x2, // x2 postfix - // x2 postfix2 + // x2 postfix2 /* multi-line x3 prefix */ @@ -39,9 +39,8 @@ interface FunctionInterfaces { // y2 prefix uint256 y2, // y2 postfix // y3 prefix - uint256 y3 - ); // y3 postfix - // function postfix + uint256 y3 // y3 postfix + ); // function postfix /*////////////////////////////////////////////////////////////////////////// TEST @@ -324,10 +323,10 @@ interface FunctionInterfaces { } contract FunctionDefinitions { - function() external {} + function f() external {} fallback() external {} - function() external payable {} + function f() external payable {} fallback() external payable {} receive() external payable {} @@ -707,4 +706,10 @@ contract FunctionOverrides is { a = 1; } + + function simple(address _target, bytes memory _payload) + internal + { + a = 1; + } } diff --git a/crates/fmt/testdata/FunctionDefinition/params-always.fmt.sol b/crates/fmt/testdata/FunctionDefinition/params-always.fmt.sol new file mode 100644 index 0000000000000..0afc214dd6fd8 --- /dev/null +++ b/crates/fmt/testdata/FunctionDefinition/params-always.fmt.sol @@ -0,0 +1,722 @@ +// config: line_length = 60 +// config: multiline_func_header = "params_always" +interface FunctionInterfaces { + function noParamsNoModifiersNoReturns(); + + function oneParam( + uint256 x + ); + + function oneModifier() modifier1; + + function oneReturn() returns (uint256 y1); + + // function prefix + function withComments( // function name postfix + // x1 prefix + uint256 x1, // x1 postfix + // x2 prefix + uint256 x2, // x2 postfix + // x2 postfix2 + /* + multi-line x3 prefix + */ + uint256 x3 // x3 postfix + ) + // public prefix + public // public postfix + // pure prefix + pure // pure postfix + // modifier1 prefix + modifier1 // modifier1 postfix + // modifier2 prefix + modifier2 /* + mutliline modifier2 postfix + */ + // modifier3 prefix + modifier3 // modifier3 postfix + returns ( + // y1 prefix + uint256 y1, // y1 postfix + // y2 prefix + uint256 y2, // y2 postfix + // y3 prefix + uint256 y3 // y3 postfix + ); // function postfix + + /*////////////////////////////////////////////////////////////////////////// + TEST + //////////////////////////////////////////////////////////////////////////*/ + function manyParams( + uint256 x1, + uint256 x2, + uint256 x3, + uint256 x4, + uint256 x5, + uint256 x6, + uint256 x7, + uint256 x8, + uint256 x9, + uint256 x10 + ); + + function manyModifiers() + modifier1 + modifier2 + modifier3 + modifier4 + modifier5 + modifier6 + modifier7 + modifier8 + modifier9 + modifier10; + + function manyReturns() + returns ( + uint256 y1, + uint256 y2, + uint256 y3, + uint256 y4, + uint256 y5, + uint256 y6, + uint256 y7, + uint256 y8, + uint256 y9, + uint256 y10 + ); + + function someParamsSomeModifiers( + uint256 x1, + uint256 x2, + uint256 x3 + ) modifier1 modifier2 modifier3; + + function someParamsSomeReturns( + uint256 x1, + uint256 x2, + uint256 x3 + ) returns (uint256 y1, uint256 y2, uint256 y3); + + function someModifiersSomeReturns() + modifier1 + modifier2 + modifier3 + returns (uint256 y1, uint256 y2, uint256 y3); + + function someParamSomeModifiersSomeReturns( + uint256 x1, + uint256 x2, + uint256 x3 + ) + modifier1 + modifier2 + modifier3 + returns (uint256 y1, uint256 y2, uint256 y3); + + function someParamsManyModifiers( + uint256 x1, + uint256 x2, + uint256 x3 + ) + modifier1 + modifier2 + modifier3 + modifier4 + modifier5 + modifier6 + modifier7 + modifier8 + modifier9 + modifier10; + + function someParamsManyReturns( + uint256 x1, + uint256 x2, + uint256 x3 + ) + returns ( + uint256 y1, + uint256 y2, + uint256 y3, + uint256 y4, + uint256 y5, + uint256 y6, + uint256 y7, + uint256 y8, + uint256 y9, + uint256 y10 + ); + + function manyParamsSomeModifiers( + uint256 x1, + uint256 x2, + uint256 x3, + uint256 x4, + uint256 x5, + uint256 x6, + uint256 x7, + uint256 x8, + uint256 x9, + uint256 x10 + ) modifier1 modifier2 modifier3; + + function manyParamsSomeReturns( + uint256 x1, + uint256 x2, + uint256 x3, + uint256 x4, + uint256 x5, + uint256 x6, + uint256 x7, + uint256 x8, + uint256 x9, + uint256 x10 + ) returns (uint256 y1, uint256 y2, uint256 y3); + + function manyParamsManyModifiers( + uint256 x1, + uint256 x2, + uint256 x3, + uint256 x4, + uint256 x5, + uint256 x6, + uint256 x7, + uint256 x8, + uint256 x9, + uint256 x10 + ) + modifier1 + modifier2 + modifier3 + modifier4 + modifier5 + modifier6 + modifier7 + modifier8 + modifier9 + modifier10; + + function manyParamsManyReturns( + uint256 x1, + uint256 x2, + uint256 x3, + uint256 x4, + uint256 x5, + uint256 x6, + uint256 x7, + uint256 x8, + uint256 x9, + uint256 x10 + ) + returns ( + uint256 y1, + uint256 y2, + uint256 y3, + uint256 y4, + uint256 y5, + uint256 y6, + uint256 y7, + uint256 y8, + uint256 y9, + uint256 y10 + ); + + function manyParamsManyModifiersManyReturns( + uint256 x1, + uint256 x2, + uint256 x3, + uint256 x4, + uint256 x5, + uint256 x6, + uint256 x7, + uint256 x8, + uint256 x9, + uint256 x10 + ) + modifier1 + modifier2 + modifier3 + modifier4 + modifier5 + modifier6 + modifier7 + modifier8 + modifier9 + modifier10 + returns ( + uint256 y1, + uint256 y2, + uint256 y3, + uint256 y4, + uint256 y5, + uint256 y6, + uint256 y7, + uint256 y8, + uint256 y9, + uint256 y10 + ); + + function modifierOrderCorrect01() + public + view + virtual + override + modifier1 + modifier2 + returns (uint256); + + function modifierOrderCorrect02() + private + pure + virtual + modifier1 + modifier2 + returns (string); + + function modifierOrderCorrect03() + external + payable + override + modifier1 + modifier2 + returns (address); + + function modifierOrderCorrect04() + internal + virtual + override + modifier1 + modifier2 + returns (uint256); + + function modifierOrderIncorrect01() + public + view + virtual + override + modifier1 + modifier2 + returns (uint256); + + function modifierOrderIncorrect02() + external + virtual + override + modifier1 + modifier2 + returns (uint256); + + function modifierOrderIncorrect03() + internal + pure + virtual + modifier1 + modifier2 + returns (uint256); + + function modifierOrderIncorrect04() + external + payable + override + modifier1 + modifier2 + returns (uint256); +} + +contract FunctionDefinitions { + function f() external {} + fallback() external {} + + function f() external payable {} + fallback() external payable {} + receive() external payable {} + + function noParamsNoModifiersNoReturns() { + a = 1; + } + + function oneParam( + uint256 x + ) { + a = 1; + } + + function oneModifier() modifier1 { + a = 1; + } + + function oneReturn() returns (uint256 y1) { + a = 1; + } + + function manyParams( + uint256 x1, + uint256 x2, + uint256 x3, + uint256 x4, + uint256 x5, + uint256 x6, + uint256 x7, + uint256 x8, + uint256 x9, + uint256 x10 + ) { + a = 1; + } + + function manyModifiers() + modifier1 + modifier2 + modifier3 + modifier4 + modifier5 + modifier6 + modifier7 + modifier8 + modifier9 + modifier10 + { + a = 1; + } + + function manyReturns() + returns ( + uint256 y1, + uint256 y2, + uint256 y3, + uint256 y4, + uint256 y5, + uint256 y6, + uint256 y7, + uint256 y8, + uint256 y9, + uint256 y10 + ) + { + a = 1; + } + + function someParamsSomeModifiers( + uint256 x1, + uint256 x2, + uint256 x3 + ) modifier1 modifier2 modifier3 { + a = 1; + } + + function someParamsSomeReturns( + uint256 x1, + uint256 x2, + uint256 x3 + ) returns (uint256 y1, uint256 y2, uint256 y3) { + a = 1; + } + + function someModifiersSomeReturns() + modifier1 + modifier2 + modifier3 + returns (uint256 y1, uint256 y2, uint256 y3) + { + a = 1; + } + + function someParamSomeModifiersSomeReturns( + uint256 x1, + uint256 x2, + uint256 x3 + ) + modifier1 + modifier2 + modifier3 + returns (uint256 y1, uint256 y2, uint256 y3) + { + a = 1; + } + + function someParamsManyModifiers( + uint256 x1, + uint256 x2, + uint256 x3 + ) + modifier1 + modifier2 + modifier3 + modifier4 + modifier5 + modifier6 + modifier7 + modifier8 + modifier9 + modifier10 + { + a = 1; + } + + function someParamsManyReturns( + uint256 x1, + uint256 x2, + uint256 x3 + ) + returns ( + uint256 y1, + uint256 y2, + uint256 y3, + uint256 y4, + uint256 y5, + uint256 y6, + uint256 y7, + uint256 y8, + uint256 y9, + uint256 y10 + ) + { + a = 1; + } + + function manyParamsSomeModifiers( + uint256 x1, + uint256 x2, + uint256 x3, + uint256 x4, + uint256 x5, + uint256 x6, + uint256 x7, + uint256 x8, + uint256 x9, + uint256 x10 + ) modifier1 modifier2 modifier3 { + a = 1; + } + + function manyParamsSomeReturns( + uint256 x1, + uint256 x2, + uint256 x3, + uint256 x4, + uint256 x5, + uint256 x6, + uint256 x7, + uint256 x8, + uint256 x9, + uint256 x10 + ) returns (uint256 y1, uint256 y2, uint256 y3) { + a = 1; + } + + function manyParamsManyModifiers( + uint256 x1, + uint256 x2, + uint256 x3, + uint256 x4, + uint256 x5, + uint256 x6, + uint256 x7, + uint256 x8, + uint256 x9, + uint256 x10 + ) + public + modifier1 + modifier2 + modifier3 + modifier4 + modifier5 + modifier6 + modifier7 + modifier8 + modifier9 + modifier10 + { + a = 1; + } + + function manyParamsManyReturns( + uint256 x1, + uint256 x2, + uint256 x3, + uint256 x4, + uint256 x5, + uint256 x6, + uint256 x7, + uint256 x8, + uint256 x9, + uint256 x10 + ) + returns ( + uint256 y1, + uint256 y2, + uint256 y3, + uint256 y4, + uint256 y5, + uint256 y6, + uint256 y7, + uint256 y8, + uint256 y9, + uint256 y10 + ) + { + a = 1; + } + + function manyParamsManyModifiersManyReturns( + uint256 x1, + uint256 x2, + uint256 x3, + uint256 x4, + uint256 x5, + uint256 x6, + uint256 x7, + uint256 x8, + uint256 x9, + uint256 x10 + ) + modifier1 + modifier2 + modifier3 + modifier4 + modifier5 + modifier6 + modifier7 + modifier8 + modifier9 + modifier10 + returns ( + uint256 y1, + uint256 y2, + uint256 y3, + uint256 y4, + uint256 y5, + uint256 y6, + uint256 y7, + uint256 y8, + uint256 y9, + uint256 y10 + ) + { + a = 1; + } + + function modifierOrderCorrect01() + public + view + virtual + override + modifier1 + modifier2 + returns (uint256) + { + a = 1; + } + + function modifierOrderCorrect02() + private + pure + virtual + modifier1 + modifier2 + returns (string) + { + a = 1; + } + + function modifierOrderCorrect03() + external + payable + override + modifier1 + modifier2 + returns (address) + { + a = 1; + } + + function modifierOrderCorrect04() + internal + virtual + override + modifier1 + modifier2 + returns (uint256) + { + a = 1; + } + + function modifierOrderIncorrect01() + public + view + virtual + override + modifier1 + modifier2 + returns (uint256) + { + a = 1; + } + + function modifierOrderIncorrect02() + external + virtual + override + modifier1 + modifier2 + returns (uint256) + { + a = 1; + } + + function modifierOrderIncorrect03() + internal + pure + virtual + modifier1 + modifier2 + returns (uint256) + { + a = 1; + } + + function modifierOrderIncorrect04() + external + payable + override + modifier1 + modifier2 + returns (uint256) + { + a = 1; + } + + fallback() external payable virtual {} + receive() external payable virtual {} +} + +contract FunctionOverrides is + FunctionInterfaces, + FunctionDefinitions +{ + function noParamsNoModifiersNoReturns() override { + a = 1; + } + + function oneParam( + uint256 x + ) + override( + FunctionInterfaces, + FunctionDefinitions, + SomeOtherFunctionContract, + SomeImport.AndAnotherFunctionContract + ) + { + a = 1; + } + + function simple( + address _target, + bytes memory _payload + ) internal { + a = 1; + } +} diff --git a/crates/fmt/testdata/FunctionDefinition/params-first.fmt.sol b/crates/fmt/testdata/FunctionDefinition/params-first.fmt.sol deleted file mode 100644 index 3e7ebfff6b3aa..0000000000000 --- a/crates/fmt/testdata/FunctionDefinition/params-first.fmt.sol +++ /dev/null @@ -1,716 +0,0 @@ -// config: line_length = 60 -// config: multiline_func_header = "params_first" -interface FunctionInterfaces { - function noParamsNoModifiersNoReturns(); - - function oneParam( - uint256 x - ); - - function oneModifier() modifier1; - - function oneReturn() returns (uint256 y1); - - // function prefix - function withComments( // function name postfix - // x1 prefix - uint256 x1, // x1 postfix - // x2 prefix - uint256 x2, // x2 postfix - // x2 postfix2 - /* - multi-line x3 prefix - */ - uint256 x3 // x3 postfix - ) - // public prefix - public // public postfix - // pure prefix - pure // pure postfix - // modifier1 prefix - modifier1 // modifier1 postfix - // modifier2 prefix - modifier2 /* - mutliline modifier2 postfix - */ - // modifier3 prefix - modifier3 // modifier3 postfix - returns ( - // y1 prefix - uint256 y1, // y1 postfix - // y2 prefix - uint256 y2, // y2 postfix - // y3 prefix - uint256 y3 - ); // y3 postfix - // function postfix - - /*////////////////////////////////////////////////////////////////////////// - TEST - //////////////////////////////////////////////////////////////////////////*/ - function manyParams( - uint256 x1, - uint256 x2, - uint256 x3, - uint256 x4, - uint256 x5, - uint256 x6, - uint256 x7, - uint256 x8, - uint256 x9, - uint256 x10 - ); - - function manyModifiers() - modifier1 - modifier2 - modifier3 - modifier4 - modifier5 - modifier6 - modifier7 - modifier8 - modifier9 - modifier10; - - function manyReturns() - returns ( - uint256 y1, - uint256 y2, - uint256 y3, - uint256 y4, - uint256 y5, - uint256 y6, - uint256 y7, - uint256 y8, - uint256 y9, - uint256 y10 - ); - - function someParamsSomeModifiers( - uint256 x1, - uint256 x2, - uint256 x3 - ) modifier1 modifier2 modifier3; - - function someParamsSomeReturns( - uint256 x1, - uint256 x2, - uint256 x3 - ) returns (uint256 y1, uint256 y2, uint256 y3); - - function someModifiersSomeReturns() - modifier1 - modifier2 - modifier3 - returns (uint256 y1, uint256 y2, uint256 y3); - - function someParamSomeModifiersSomeReturns( - uint256 x1, - uint256 x2, - uint256 x3 - ) - modifier1 - modifier2 - modifier3 - returns (uint256 y1, uint256 y2, uint256 y3); - - function someParamsManyModifiers( - uint256 x1, - uint256 x2, - uint256 x3 - ) - modifier1 - modifier2 - modifier3 - modifier4 - modifier5 - modifier6 - modifier7 - modifier8 - modifier9 - modifier10; - - function someParamsManyReturns( - uint256 x1, - uint256 x2, - uint256 x3 - ) - returns ( - uint256 y1, - uint256 y2, - uint256 y3, - uint256 y4, - uint256 y5, - uint256 y6, - uint256 y7, - uint256 y8, - uint256 y9, - uint256 y10 - ); - - function manyParamsSomeModifiers( - uint256 x1, - uint256 x2, - uint256 x3, - uint256 x4, - uint256 x5, - uint256 x6, - uint256 x7, - uint256 x8, - uint256 x9, - uint256 x10 - ) modifier1 modifier2 modifier3; - - function manyParamsSomeReturns( - uint256 x1, - uint256 x2, - uint256 x3, - uint256 x4, - uint256 x5, - uint256 x6, - uint256 x7, - uint256 x8, - uint256 x9, - uint256 x10 - ) returns (uint256 y1, uint256 y2, uint256 y3); - - function manyParamsManyModifiers( - uint256 x1, - uint256 x2, - uint256 x3, - uint256 x4, - uint256 x5, - uint256 x6, - uint256 x7, - uint256 x8, - uint256 x9, - uint256 x10 - ) - modifier1 - modifier2 - modifier3 - modifier4 - modifier5 - modifier6 - modifier7 - modifier8 - modifier9 - modifier10; - - function manyParamsManyReturns( - uint256 x1, - uint256 x2, - uint256 x3, - uint256 x4, - uint256 x5, - uint256 x6, - uint256 x7, - uint256 x8, - uint256 x9, - uint256 x10 - ) - returns ( - uint256 y1, - uint256 y2, - uint256 y3, - uint256 y4, - uint256 y5, - uint256 y6, - uint256 y7, - uint256 y8, - uint256 y9, - uint256 y10 - ); - - function manyParamsManyModifiersManyReturns( - uint256 x1, - uint256 x2, - uint256 x3, - uint256 x4, - uint256 x5, - uint256 x6, - uint256 x7, - uint256 x8, - uint256 x9, - uint256 x10 - ) - modifier1 - modifier2 - modifier3 - modifier4 - modifier5 - modifier6 - modifier7 - modifier8 - modifier9 - modifier10 - returns ( - uint256 y1, - uint256 y2, - uint256 y3, - uint256 y4, - uint256 y5, - uint256 y6, - uint256 y7, - uint256 y8, - uint256 y9, - uint256 y10 - ); - - function modifierOrderCorrect01() - public - view - virtual - override - modifier1 - modifier2 - returns (uint256); - - function modifierOrderCorrect02() - private - pure - virtual - modifier1 - modifier2 - returns (string); - - function modifierOrderCorrect03() - external - payable - override - modifier1 - modifier2 - returns (address); - - function modifierOrderCorrect04() - internal - virtual - override - modifier1 - modifier2 - returns (uint256); - - function modifierOrderIncorrect01() - public - view - virtual - override - modifier1 - modifier2 - returns (uint256); - - function modifierOrderIncorrect02() - external - virtual - override - modifier1 - modifier2 - returns (uint256); - - function modifierOrderIncorrect03() - internal - pure - virtual - modifier1 - modifier2 - returns (uint256); - - function modifierOrderIncorrect04() - external - payable - override - modifier1 - modifier2 - returns (uint256); -} - -contract FunctionDefinitions { - function() external {} - fallback() external {} - - function() external payable {} - fallback() external payable {} - receive() external payable {} - - function noParamsNoModifiersNoReturns() { - a = 1; - } - - function oneParam( - uint256 x - ) { - a = 1; - } - - function oneModifier() modifier1 { - a = 1; - } - - function oneReturn() returns (uint256 y1) { - a = 1; - } - - function manyParams( - uint256 x1, - uint256 x2, - uint256 x3, - uint256 x4, - uint256 x5, - uint256 x6, - uint256 x7, - uint256 x8, - uint256 x9, - uint256 x10 - ) { - a = 1; - } - - function manyModifiers() - modifier1 - modifier2 - modifier3 - modifier4 - modifier5 - modifier6 - modifier7 - modifier8 - modifier9 - modifier10 - { - a = 1; - } - - function manyReturns() - returns ( - uint256 y1, - uint256 y2, - uint256 y3, - uint256 y4, - uint256 y5, - uint256 y6, - uint256 y7, - uint256 y8, - uint256 y9, - uint256 y10 - ) - { - a = 1; - } - - function someParamsSomeModifiers( - uint256 x1, - uint256 x2, - uint256 x3 - ) modifier1 modifier2 modifier3 { - a = 1; - } - - function someParamsSomeReturns( - uint256 x1, - uint256 x2, - uint256 x3 - ) returns (uint256 y1, uint256 y2, uint256 y3) { - a = 1; - } - - function someModifiersSomeReturns() - modifier1 - modifier2 - modifier3 - returns (uint256 y1, uint256 y2, uint256 y3) - { - a = 1; - } - - function someParamSomeModifiersSomeReturns( - uint256 x1, - uint256 x2, - uint256 x3 - ) - modifier1 - modifier2 - modifier3 - returns (uint256 y1, uint256 y2, uint256 y3) - { - a = 1; - } - - function someParamsManyModifiers( - uint256 x1, - uint256 x2, - uint256 x3 - ) - modifier1 - modifier2 - modifier3 - modifier4 - modifier5 - modifier6 - modifier7 - modifier8 - modifier9 - modifier10 - { - a = 1; - } - - function someParamsManyReturns( - uint256 x1, - uint256 x2, - uint256 x3 - ) - returns ( - uint256 y1, - uint256 y2, - uint256 y3, - uint256 y4, - uint256 y5, - uint256 y6, - uint256 y7, - uint256 y8, - uint256 y9, - uint256 y10 - ) - { - a = 1; - } - - function manyParamsSomeModifiers( - uint256 x1, - uint256 x2, - uint256 x3, - uint256 x4, - uint256 x5, - uint256 x6, - uint256 x7, - uint256 x8, - uint256 x9, - uint256 x10 - ) modifier1 modifier2 modifier3 { - a = 1; - } - - function manyParamsSomeReturns( - uint256 x1, - uint256 x2, - uint256 x3, - uint256 x4, - uint256 x5, - uint256 x6, - uint256 x7, - uint256 x8, - uint256 x9, - uint256 x10 - ) returns (uint256 y1, uint256 y2, uint256 y3) { - a = 1; - } - - function manyParamsManyModifiers( - uint256 x1, - uint256 x2, - uint256 x3, - uint256 x4, - uint256 x5, - uint256 x6, - uint256 x7, - uint256 x8, - uint256 x9, - uint256 x10 - ) - public - modifier1 - modifier2 - modifier3 - modifier4 - modifier5 - modifier6 - modifier7 - modifier8 - modifier9 - modifier10 - { - a = 1; - } - - function manyParamsManyReturns( - uint256 x1, - uint256 x2, - uint256 x3, - uint256 x4, - uint256 x5, - uint256 x6, - uint256 x7, - uint256 x8, - uint256 x9, - uint256 x10 - ) - returns ( - uint256 y1, - uint256 y2, - uint256 y3, - uint256 y4, - uint256 y5, - uint256 y6, - uint256 y7, - uint256 y8, - uint256 y9, - uint256 y10 - ) - { - a = 1; - } - - function manyParamsManyModifiersManyReturns( - uint256 x1, - uint256 x2, - uint256 x3, - uint256 x4, - uint256 x5, - uint256 x6, - uint256 x7, - uint256 x8, - uint256 x9, - uint256 x10 - ) - modifier1 - modifier2 - modifier3 - modifier4 - modifier5 - modifier6 - modifier7 - modifier8 - modifier9 - modifier10 - returns ( - uint256 y1, - uint256 y2, - uint256 y3, - uint256 y4, - uint256 y5, - uint256 y6, - uint256 y7, - uint256 y8, - uint256 y9, - uint256 y10 - ) - { - a = 1; - } - - function modifierOrderCorrect01() - public - view - virtual - override - modifier1 - modifier2 - returns (uint256) - { - a = 1; - } - - function modifierOrderCorrect02() - private - pure - virtual - modifier1 - modifier2 - returns (string) - { - a = 1; - } - - function modifierOrderCorrect03() - external - payable - override - modifier1 - modifier2 - returns (address) - { - a = 1; - } - - function modifierOrderCorrect04() - internal - virtual - override - modifier1 - modifier2 - returns (uint256) - { - a = 1; - } - - function modifierOrderIncorrect01() - public - view - virtual - override - modifier1 - modifier2 - returns (uint256) - { - a = 1; - } - - function modifierOrderIncorrect02() - external - virtual - override - modifier1 - modifier2 - returns (uint256) - { - a = 1; - } - - function modifierOrderIncorrect03() - internal - pure - virtual - modifier1 - modifier2 - returns (uint256) - { - a = 1; - } - - function modifierOrderIncorrect04() - external - payable - override - modifier1 - modifier2 - returns (uint256) - { - a = 1; - } - - fallback() external payable virtual {} - receive() external payable virtual {} -} - -contract FunctionOverrides is - FunctionInterfaces, - FunctionDefinitions -{ - function noParamsNoModifiersNoReturns() override { - a = 1; - } - - function oneParam( - uint256 x - ) - override( - FunctionInterfaces, - FunctionDefinitions, - SomeOtherFunctionContract, - SomeImport.AndAnotherFunctionContract - ) - { - a = 1; - } -} diff --git a/crates/fmt/testdata/FunctionDefinition/params-multi.fmt.sol b/crates/fmt/testdata/FunctionDefinition/params-multi.fmt.sol index cd2015c9e050e..24052f8066b75 100644 --- a/crates/fmt/testdata/FunctionDefinition/params-multi.fmt.sol +++ b/crates/fmt/testdata/FunctionDefinition/params-multi.fmt.sol @@ -15,7 +15,7 @@ interface FunctionInterfaces { uint256 x1, // x1 postfix // x2 prefix uint256 x2, // x2 postfix - // x2 postfix2 + // x2 postfix2 /* multi-line x3 prefix */ @@ -39,9 +39,8 @@ interface FunctionInterfaces { // y2 prefix uint256 y2, // y2 postfix // y3 prefix - uint256 y3 - ); // y3 postfix - // function postfix + uint256 y3 // y3 postfix + ); // function postfix /*////////////////////////////////////////////////////////////////////////// TEST @@ -324,10 +323,10 @@ interface FunctionInterfaces { } contract FunctionDefinitions { - function() external {} + function f() external {} fallback() external {} - function() external payable {} + function f() external payable {} fallback() external payable {} receive() external payable {} @@ -707,4 +706,11 @@ contract FunctionOverrides is { a = 1; } + + function simple( + address _target, + bytes memory _payload + ) internal { + a = 1; + } } diff --git a/crates/fmt/testdata/FunctionDefinitionWithFunctionReturns/fmt.sol b/crates/fmt/testdata/FunctionDefinitionWithFunctionReturns/fmt.sol index 7b751e22ec26a..f961e7bd9601c 100644 --- a/crates/fmt/testdata/FunctionDefinitionWithFunctionReturns/fmt.sol +++ b/crates/fmt/testdata/FunctionDefinitionWithFunctionReturns/fmt.sol @@ -5,10 +5,7 @@ contract ReturnFnFormat { function returnsFunction() internal pure - returns ( - function() - internal pure returns (uint256) - ) + returns (function() internal pure returns (uint256)) {} } diff --git a/crates/fmt/testdata/FunctionType/fmt.sol b/crates/fmt/testdata/FunctionType/fmt.sol index 39053d816058f..d89b0533f9f56 100644 --- a/crates/fmt/testdata/FunctionType/fmt.sol +++ b/crates/fmt/testdata/FunctionType/fmt.sol @@ -1,6 +1,6 @@ -// config: line_length = 90 +// config: line_length = 100 library ArrayUtils { - function map(uint256[] memory self, function (uint) pure returns (uint) f) + function map(uint256[] memory self, function(uint256) pure returns (uint256) f) internal pure returns (uint256[] memory r) @@ -11,7 +11,7 @@ library ArrayUtils { } } - function reduce(uint256[] memory self, function (uint, uint) pure returns (uint) f) + function reduce(uint256[] memory self, function(uint256, uint256) pure returns (uint256) f) internal pure returns (uint256 r) @@ -28,4 +28,28 @@ library ArrayUtils { r[i] = i; } } + + function _castToPure(function(bytes memory) internal view fnIn) + returns (function(bytes memory) pure fnOut) + { + assembly { + fnOut := fnIn + } + } +} + +contract FnTypeTrailingComment { + function f( + function(bytes memory) external pure /*g*/ + ) + public + pure {} +} + +library FnTypeTrailingCommentInLibrary { + function f( + function(uint256) internal returns (uint256) /*x*/ + ) + internal + pure {} } diff --git a/crates/fmt/testdata/FunctionType/original.sol b/crates/fmt/testdata/FunctionType/original.sol index 27b402d85ef5f..14a3e7457422a 100644 --- a/crates/fmt/testdata/FunctionType/original.sol +++ b/crates/fmt/testdata/FunctionType/original.sol @@ -28,4 +28,21 @@ library ArrayUtils { r[i] = i; } } + + function _castToPure(function(bytes memory) internal view fnIn) +returns (function(bytes memory) pure fnOut) + { + assembly { + fnOut := fnIn + } + } +} + +contract FnTypeTrailingComment { + function f(function(bytes memory) pure external /*g*/) public pure {} +} + +library FnTypeTrailingCommentInLibrary { + function f(function(uint) internal returns (uint) /*x*/) pure internal { + } } diff --git a/crates/fmt/testdata/IfStatement/block-multi.fmt.sol b/crates/fmt/testdata/IfStatement/block-multi.fmt.sol index dcd8bb83eaa8f..26e1f6fe010f2 100644 --- a/crates/fmt/testdata/IfStatement/block-multi.fmt.sol +++ b/crates/fmt/testdata/IfStatement/block-multi.fmt.sol @@ -50,8 +50,8 @@ contract IfStatement { /* comment9 */ else if ( /* comment10 */ anotherLongCondition // comment11 - ) { /* comment12 */ + ) { execute(); } // comment13 /* comment14 */ @@ -93,8 +93,15 @@ contract IfStatement { } if (condition) { - execute(); - } // comment18 + if (anotherLongCondition) { + execute(); + executeElse(); + } + } + + if (condition) { + execute(); // comment18 + } if (condition) { executeWithMultipleParameters(condition, anotherLongCondition); @@ -167,5 +174,153 @@ contract IfStatement { } else { executeElse(); } + + if (condition) { + execute(); + execute(); + } + + if (condition) { + if (condition) { + execute(); + execute(); + } + } + + if (condition) { + if (condition) { + if (condition) { + execute(); + execute(); + } + } + } + + while (condition) { + if (condition) { + execute(); + execute(); + } + } + + if (true) { + if (true) { + doIt(); + doIt(); + } + } + + if (condition) { + if (condition) { + execute(); /* comment */ + } + } + + if (condition) { + if (condition) { + /* comment */ + execute(); + } + } + + if (condition) { + if ( + condition /* comment */ + ) { + execute(); + } + } + + if (condition) { + if ( /* comment */ + condition + ) { + execute(); + } + } + + if (condition) { + if (condition) { /* comment */ } + } + + if (condition) { + if (condition) { + if (condition) { + execute(); /* comment */ + } + } + } + + if (condition) { + if (condition) { + if (condition) { + /* comment */ + execute(); + } + } + } + + if (condition) { + if (condition) { + if ( + condition /* comment */ + ) { + execute(); + } + } + } + + if (condition) { + if (condition) { + if (condition) { /* comment */ } + } + } + + if (a) { + while ( /* comment */ + b + ) { + x(); + } + } + + if (a) { + if (a) { + while ( /* comment */ + b + ) { + x(); + } + } + } + } + + function test_nestedBkocks() public { + if (accesses[i].account == address(simpleStorage)) { + for (uint256 j = 0; j < accesses[i].storageAccesses.length; j++) { + bytes32 slot = accesses[i].storageAccesses[j].slot; + if (slot == bytes32(uint256(0))) { + foundValueSlot = true; + } + if (slot == bytes32(uint256(1))) { + foundOwnerSlot = true; + } + if (slot == bytes32(uint256(2))) { + foundValuesSlot0 = true; + } + if (slot == bytes32(uint256(3))) { + foundValuesSlot1 = true; + } + if (slot == bytes32(uint256(4))) { + foundValuesSlot2 = true; + } + } + } + } + + function test_emptyIfBlock() external { + if (block.number < 10) {} else { + revert(); + } } } diff --git a/crates/fmt/testdata/IfStatement/block-single.fmt.sol b/crates/fmt/testdata/IfStatement/block-single.fmt.sol index ba2b9998b184c..eaea170b82a9d 100644 --- a/crates/fmt/testdata/IfStatement/block-single.fmt.sol +++ b/crates/fmt/testdata/IfStatement/block-single.fmt.sol @@ -43,8 +43,8 @@ contract IfStatement { /* comment9 */ else if ( /* comment10 */ anotherLongCondition // comment11 - ) { /* comment12 */ + ) { execute(); } // comment13 /* comment14 */ @@ -53,7 +53,9 @@ contract IfStatement { if ( // comment16 condition /* comment17 */ - ) execute(); + ) { + execute(); + } if (condition) execute(); else executeElse(); @@ -68,6 +70,13 @@ contract IfStatement { if (condition) if (anotherLongCondition) execute(); + if (condition) { + if (anotherLongCondition) { + execute(); + executeElse(); + } + } + if (condition) execute(); // comment18 if (condition) { @@ -119,5 +128,133 @@ contract IfStatement { else if (condition) execute(); else if (condition) execute(); else executeElse(); + + if (condition) { + execute(); + execute(); + } + + if (condition) { + if (condition) { + execute(); + execute(); + } + } + + if (condition) { + if (condition) { + if (condition) { + execute(); + execute(); + } + } + } + + while (condition) { + if (condition) { + execute(); + execute(); + } + } + + if (true) { + if (true) { + doIt(); + doIt(); + } + } + + if (condition) if (condition) execute(); /* comment */ + + if (condition) { + if (condition) { + /* comment */ + execute(); + } + } + + if (condition) { + if ( + condition /* comment */ + ) { + execute(); + } + } + + if (condition) { + if ( /* comment */ + condition + ) { + execute(); + } + } + + if (condition) { + if (condition) { /* comment */ } + } + + if (condition) if (condition) if (condition) execute(); /* comment */ + + if (condition) { + if (condition) { + if (condition) { + /* comment */ + execute(); + } + } + } + + if (condition) { + if (condition) { + if ( + condition /* comment */ + ) { + execute(); + } + } + } + + if (condition) { + if (condition) { + if (condition) { /* comment */ } + } + } + + if (a) { + while ( /* comment */ + b + ) { + x(); + } + } + + if (a) { + if (a) { + while ( /* comment */ + b + ) { + x(); + } + } + } + } + + function test_nestedBkocks() public { + if (accesses[i].account == address(simpleStorage)) { + for (uint256 j = 0; j < accesses[i].storageAccesses.length; j++) { + bytes32 slot = accesses[i].storageAccesses[j].slot; + if (slot == bytes32(uint256(0))) foundValueSlot = true; + if (slot == bytes32(uint256(1))) foundOwnerSlot = true; + if (slot == bytes32(uint256(2))) foundValuesSlot0 = true; + if (slot == bytes32(uint256(3))) foundValuesSlot1 = true; + if (slot == bytes32(uint256(4))) foundValuesSlot2 = true; + } + } + } + + function test_emptyIfBlock() external { + if (block.number < 10) {} else { + revert(); + } } } diff --git a/crates/fmt/testdata/IfStatement/fmt.sol b/crates/fmt/testdata/IfStatement/fmt.sol index cb2f8874f83d5..9374466c6f1e2 100644 --- a/crates/fmt/testdata/IfStatement/fmt.sol +++ b/crates/fmt/testdata/IfStatement/fmt.sol @@ -49,8 +49,8 @@ contract IfStatement { /* comment9 */ else if ( /* comment10 */ anotherLongCondition // comment11 - ) { /* comment12 */ + ) { execute(); } // comment13 /* comment14 */ @@ -83,6 +83,13 @@ contract IfStatement { if (condition) if (anotherLongCondition) execute(); + if (condition) { + if (anotherLongCondition) { + execute(); + executeElse(); + } + } + if (condition) execute(); // comment18 if (condition) { @@ -141,5 +148,133 @@ contract IfStatement { } else { executeElse(); } + + if (condition) { + execute(); + execute(); + } + + if (condition) { + if (condition) { + execute(); + execute(); + } + } + + if (condition) { + if (condition) { + if (condition) { + execute(); + execute(); + } + } + } + + while (condition) { + if (condition) { + execute(); + execute(); + } + } + + if (true) { + if (true) { + doIt(); + doIt(); + } + } + + if (condition) if (condition) execute(); /* comment */ + + if (condition) { + if (condition) { + /* comment */ + execute(); + } + } + + if (condition) { + if ( + condition /* comment */ + ) { + execute(); + } + } + + if (condition) { + if ( /* comment */ + condition + ) { + execute(); + } + } + + if (condition) { + if (condition) { /* comment */ } + } + + if (condition) if (condition) if (condition) execute(); /* comment */ + + if (condition) { + if (condition) { + if (condition) { + /* comment */ + execute(); + } + } + } + + if (condition) { + if (condition) { + if ( + condition /* comment */ + ) { + execute(); + } + } + } + + if (condition) { + if (condition) { + if (condition) { /* comment */ } + } + } + + if (a) { + while ( /* comment */ + b + ) { + x(); + } + } + + if (a) { + if (a) { + while ( /* comment */ + b + ) { + x(); + } + } + } + } + + function test_nestedBkocks() public { + if (accesses[i].account == address(simpleStorage)) { + for (uint256 j = 0; j < accesses[i].storageAccesses.length; j++) { + bytes32 slot = accesses[i].storageAccesses[j].slot; + if (slot == bytes32(uint256(0))) foundValueSlot = true; + if (slot == bytes32(uint256(1))) foundOwnerSlot = true; + if (slot == bytes32(uint256(2))) foundValuesSlot0 = true; + if (slot == bytes32(uint256(3))) foundValuesSlot1 = true; + if (slot == bytes32(uint256(4))) foundValuesSlot2 = true; + } + } + } + + function test_emptyIfBlock() external { + if (block.number < 10) {} else { + revert(); + } } } diff --git a/crates/fmt/testdata/IfStatement/original.sol b/crates/fmt/testdata/IfStatement/original.sol index b36829bbbf6bf..1aa38531c2d77 100644 --- a/crates/fmt/testdata/IfStatement/original.sol +++ b/crates/fmt/testdata/IfStatement/original.sol @@ -15,9 +15,9 @@ function executeWithVeryVeryVeryLongNameAndSomeParameter(bool parameter) {} contract IfStatement { function test() external { - if( true) + if( true) { - execute() ; + execute() ; } bool condition; bool anotherLongCondition; bool andAnotherVeryVeryLongCondition ; @@ -51,7 +51,7 @@ contract IfStatement { /* comment14 */ else { } // comment15 if ( - // comment16 + // comment16 condition /* comment17 */ ) { @@ -74,6 +74,8 @@ contract IfStatement { if (condition) if (anotherLongCondition) execute(); + if (condition) if (anotherLongCondition) { execute(); executeElse(); } + if (condition) execute(); // comment18 if (condition) executeWithMultipleParameters(condition, anotherLongCondition); @@ -115,5 +117,56 @@ contract IfStatement { else if (condition) execute(); else executeElse(); + + if (condition) { execute(); execute(); } + + if (condition) if (condition) { execute(); execute(); } + + if (condition) if (condition) if (condition) { execute(); execute(); } + + while (condition) if (condition) { execute(); execute(); } + + if (true) { if (true) { doIt(); doIt(); } } + + if (condition) if (condition) { execute(); /* comment */ } + + if (condition) if (condition) { /* comment */ execute(); } + + if (condition) if (condition) /* comment */ { execute(); } + + if (condition) if /* comment */ (condition) { execute(); } + + if (condition) if (condition) { /* comment */ } + + if (condition) if (condition) if (condition) { execute(); /* comment */ } + + if (condition) if (condition) if (condition) { /* comment */ execute(); } + + if (condition) if (condition) if (condition) /* comment */ { execute(); } + + if (condition) if (condition) if (condition) { /* comment */ } + + if (a) while /* comment */ (b) { x(); } + + if (a) if (a) while /* comment */ (b) { x(); } + } + + function test_nestedBkocks() public { + if (accesses[i].account == address(simpleStorage)) { + for (uint256 j = 0; j < accesses[i].storageAccesses.length; j++) { + bytes32 slot = accesses[i].storageAccesses[j].slot; + if (slot == bytes32(uint256(0))) foundValueSlot = true; + if (slot == bytes32(uint256(1))) foundOwnerSlot = true; + if (slot == bytes32(uint256(2))) foundValuesSlot0 = true; + if (slot == bytes32(uint256(3))) foundValuesSlot1 = true; + if (slot == bytes32(uint256(4))) foundValuesSlot2 = true; + } + } } -} \ No newline at end of file + + function test_emptyIfBlock() external { + if (block.number < 10) {} else { + revert(); + } + } +} diff --git a/crates/fmt/testdata/IfStatement2/120.fmt.sol b/crates/fmt/testdata/IfStatement2/120.fmt.sol new file mode 100644 index 0000000000000..beb31cec549b9 --- /dev/null +++ b/crates/fmt/testdata/IfStatement2/120.fmt.sol @@ -0,0 +1,28 @@ +// config: line_length = 120 +contract IfStatement { + function test() external { + bool anotherLongCondition; + + if (condition && ((condition || anotherLongCondition))) execute(); + } + + // https://github.com/foundry-rs/foundry/issues/12102 + function repro() external { + for (uint256 i; i < len; ++i) { + proportions[i] = totalDepositedTvl == 0 + ? 0 + : Math.mulDiv(vaultUsdValue[i], 1e18, totalDepositedTvl, Math.Rounding.Floor); + proportions[i] = totalDepositedTvl == 0 + ? 0 + : Math.mulDiv(vaultUsdValue[i], 1e18, totalDepositedTvl, Math.Rounding.Floor); + } + } + + // https://github.com/foundry-rs/foundry/issues/12315 + function repro_longComplexExpr() { + vars.expectedSnapshotTime = withdrawAmount + <= getDescaledAmount(flow.getSnapshotDebtScaled(streamId), flow.getTokenDecimals(streamId)) + ? flow.getSnapshotTime(streamId) + : getBlockTimestamp(); + } +} diff --git a/crates/fmt/testdata/IfStatement2/fmt.sol b/crates/fmt/testdata/IfStatement2/fmt.sol index 10ae43601d4ad..7dc3ce088bb5c 100644 --- a/crates/fmt/testdata/IfStatement2/fmt.sol +++ b/crates/fmt/testdata/IfStatement2/fmt.sol @@ -4,4 +4,37 @@ contract IfStatement { if (condition && ((condition || anotherLongCondition))) execute(); } + + // https://github.com/foundry-rs/foundry/issues/12102 + function repro() external { + for (uint256 i; i < len; ++i) { + proportions[i] = totalDepositedTvl == 0 + ? 0 + : Math.mulDiv( + vaultUsdValue[i], + 1e18, + totalDepositedTvl, + Math.Rounding.Floor + ); + proportions[i] = totalDepositedTvl == 0 + ? 0 + : Math.mulDiv( + vaultUsdValue[i], + 1e18, + totalDepositedTvl, + Math.Rounding.Floor + ); + } + } + + // https://github.com/foundry-rs/foundry/issues/12315 + function repro_longComplexExpr() { + vars.expectedSnapshotTime = withdrawAmount + <= getDescaledAmount( + flow.getSnapshotDebtScaled(streamId), + flow.getTokenDecimals(streamId) + ) + ? flow.getSnapshotTime(streamId) + : getBlockTimestamp(); + } } diff --git a/crates/fmt/testdata/IfStatement2/original.sol b/crates/fmt/testdata/IfStatement2/original.sol index df020c04bbe33..1dac9109a458f 100644 --- a/crates/fmt/testdata/IfStatement2/original.sol +++ b/crates/fmt/testdata/IfStatement2/original.sol @@ -7,4 +7,23 @@ contract IfStatement { ) ) execute(); } -} \ No newline at end of file + + // https://github.com/foundry-rs/foundry/issues/12102 + function repro() external { + for (uint i; i < len; ++i) { + proportions[i] = + totalDepositedTvl == 0 ? 0 : Math.mulDiv(vaultUsdValue[i], 1e18, totalDepositedTvl, Math.Rounding.Floor); + proportions[i] = totalDepositedTvl == 0 + ? 0 + : Math.mulDiv(vaultUsdValue[i], 1e18, totalDepositedTvl, Math.Rounding.Floor); + } + } + + // https://github.com/foundry-rs/foundry/issues/12315 + function repro_longComplexExpr() { + vars. expectedSnapshotTime = withdrawAmount + <= getDescaledAmount(flow.getSnapshotDebtScaled (streamId), flow.getTokenDecimals(streamId)) + ? flow.getSnapshotTime(streamId) + : getBlockTimestamp (); + } +} diff --git a/crates/fmt/testdata/IfStatement3/block-multi.fmt.sol b/crates/fmt/testdata/IfStatement3/block-multi.fmt.sol new file mode 100644 index 0000000000000..0d3fb7d1cb04f --- /dev/null +++ b/crates/fmt/testdata/IfStatement3/block-multi.fmt.sol @@ -0,0 +1,122 @@ +// config: single_line_statement_blocks = "multi" +contract DanglingElse { + function f(bool a, bool b) external pure returns (uint256) { + if (a) { + if (b) { + return 1; + } + } else { + return 2; + } + return 0; + } + + function g(bool a, bool b) external pure returns (uint256) { + if (a) { + if (b) { + return 1; + } else { + return 7; + } + } else { + return 2; + } + return 0; + } + + function h(bool a, bool b, bool c) external pure returns (uint256) { + if (a) { + return 9; + } else if (b) { + if (c) { + return 1; + } + } else { + return 2; + } + return 0; + } + + function w(bool a, bool b, bool c) external pure returns (uint256) { + if (a) { + while (c) { + if (b) { + return 1; + } + } + } else { + return 2; + } + return 0; + } + + function fr(bool a, bool b, uint256 n) external pure returns (uint256) { + if (a) { + for (uint256 i; i < n; ++i) { + if (b) { + return 1; + } + } + } else { + return 2; + } + return 0; + } + + function ok(bool a, bool b) external pure returns (uint256) { + if (a) { + if (b) { + return 1; + } + } + return 0; + } + + function nested(bool x, bool a, bool b) external pure returns (uint256) { + if (x) { + if (a) { + if (b) { + return 1; + } + } else { + return 2; + } + } + return 0; + } + + function cIso(bool a, bool b) external pure returns (uint256) { + // isolated comment + if (a) { + if (b) { + return 1; + } + } else { + return 2; + } + return 0; + } + + function cMix(bool a, bool b) external pure returns (uint256) { + if (a) { + /* mixed */ + if (b) { + return 1; + } + } else { + return 2; + } + return 0; + } + + function cTrl(bool a, bool b) external pure returns (uint256) { + if (a) { + if (b) { + return 1; // trailing + } + } else { + return 2; + } + return 0; + } +} diff --git a/crates/fmt/testdata/IfStatement3/block-single.fmt.sol b/crates/fmt/testdata/IfStatement3/block-single.fmt.sol new file mode 100644 index 0000000000000..463ac9f2df52d --- /dev/null +++ b/crates/fmt/testdata/IfStatement3/block-single.fmt.sol @@ -0,0 +1,97 @@ +// config: single_line_statement_blocks = "single" +contract DanglingElse { + function f(bool a, bool b) external pure returns (uint256) { + if (a) { + if (b) return 1; + } else { + return 2; + } + return 0; + } + + function g(bool a, bool b) external pure returns (uint256) { + if (a) { + if (b) return 1; + else return 7; + } else { + return 2; + } + return 0; + } + + function h(bool a, bool b, bool c) external pure returns (uint256) { + if (a) { + return 9; + } else if (b) { + if (c) return 1; + } else { + return 2; + } + return 0; + } + + function w(bool a, bool b, bool c) external pure returns (uint256) { + if (a) { + while (c) if (b) return 1; + } else { + return 2; + } + return 0; + } + + function fr(bool a, bool b, uint256 n) external pure returns (uint256) { + if (a) { + for (uint256 i; i < n; ++i) { + if (b) return 1; + } + } else { + return 2; + } + return 0; + } + + function ok(bool a, bool b) external pure returns (uint256) { + if (a) if (b) return 1; + return 0; + } + + function nested(bool x, bool a, bool b) external pure returns (uint256) { + if (x) { + if (a) { + if (b) return 1; + } else { + return 2; + } + } + return 0; + } + + function cIso(bool a, bool b) external pure returns (uint256) { + // isolated comment + if (a) { + if (b) return 1; + } else { + return 2; + } + return 0; + } + + function cMix(bool a, bool b) external pure returns (uint256) { + if (a) { + /* mixed */ + if (b) return 1; + } else { + return 2; + } + return 0; + } + + function cTrl(bool a, bool b) external pure returns (uint256) { + if (a) { + if (b) return 1; // trailing + } else { + return 2; + } + return 0; + } +} diff --git a/crates/fmt/testdata/IfStatement3/fmt.sol b/crates/fmt/testdata/IfStatement3/fmt.sol new file mode 100644 index 0000000000000..f87034c20fc94 --- /dev/null +++ b/crates/fmt/testdata/IfStatement3/fmt.sol @@ -0,0 +1,96 @@ +contract DanglingElse { + function f(bool a, bool b) external pure returns (uint256) { + if (a) { + if (b) return 1; + } else { + return 2; + } + return 0; + } + + function g(bool a, bool b) external pure returns (uint256) { + if (a) { + if (b) return 1; + else return 7; + } else { + return 2; + } + return 0; + } + + function h(bool a, bool b, bool c) external pure returns (uint256) { + if (a) { + return 9; + } else if (b) { + if (c) return 1; + } else { + return 2; + } + return 0; + } + + function w(bool a, bool b, bool c) external pure returns (uint256) { + if (a) { + while (c) if (b) return 1; + } else { + return 2; + } + return 0; + } + + function fr(bool a, bool b, uint256 n) external pure returns (uint256) { + if (a) { + for (uint256 i; i < n; ++i) { + if (b) return 1; + } + } else { + return 2; + } + return 0; + } + + function ok(bool a, bool b) external pure returns (uint256) { + if (a) if (b) return 1; + return 0; + } + + function nested(bool x, bool a, bool b) external pure returns (uint256) { + if (x) { + if (a) { + if (b) return 1; + } else { + return 2; + } + } + return 0; + } + + function cIso(bool a, bool b) external pure returns (uint256) { + // isolated comment + if (a) { + if (b) return 1; + } else { + return 2; + } + return 0; + } + + function cMix(bool a, bool b) external pure returns (uint256) { + if (a) { + /* mixed */ + if (b) return 1; + } else { + return 2; + } + return 0; + } + + function cTrl(bool a, bool b) external pure returns (uint256) { + if (a) { + if (b) return 1; // trailing + } else { + return 2; + } + return 0; + } +} diff --git a/crates/fmt/testdata/IfStatement3/original.sol b/crates/fmt/testdata/IfStatement3/original.sol new file mode 100644 index 0000000000000..62e495e296158 --- /dev/null +++ b/crates/fmt/testdata/IfStatement3/original.sol @@ -0,0 +1,55 @@ +contract DanglingElse { + function f(bool a, bool b) external pure returns (uint256) { + if (a) { if (b) return 1; } else return 2; + return 0; + } + + function g(bool a, bool b) external pure returns (uint256) { + if (a) { if (b) return 1; else return 7; } else return 2; + return 0; + } + + function h(bool a, bool b, bool c) external pure returns (uint256) { + if (a) return 9; + else if (b) { if (c) return 1; } + else return 2; + return 0; + } + + function w(bool a, bool b, bool c) external pure returns (uint256) { + if (a) { while (c) if (b) return 1; } else return 2; + return 0; + } + + function fr(bool a, bool b, uint256 n) external pure returns (uint256) { + if (a) { for (uint256 i; i < n; ++i) if (b) return 1; } else return 2; + return 0; + } + + function ok(bool a, bool b) external pure returns (uint256) { + if (a) { if (b) return 1; } + return 0; + } + + function nested(bool x, bool a, bool b) external pure returns (uint256) { + if (x) { if (a) { if (b) return 1; } else return 2; } + return 0; + } + + function cIso(bool a, bool b) external pure returns (uint256) { + // isolated comment + if (a) { if (b) return 1; } else return 2; + return 0; + } + + function cMix(bool a, bool b) external pure returns (uint256) { + if (a) { /* mixed */ if (b) return 1; } else return 2; + return 0; + } + + function cTrl(bool a, bool b) external pure returns (uint256) { + if (a) { if (b) return 1; // trailing + } else return 2; + return 0; + } +} diff --git a/crates/fmt/testdata/IfStatementLongCondition/120.fmt.sol b/crates/fmt/testdata/IfStatementLongCondition/120.fmt.sol new file mode 100644 index 0000000000000..e37a0d0bedb7f --- /dev/null +++ b/crates/fmt/testdata/IfStatementLongCondition/120.fmt.sol @@ -0,0 +1,12 @@ +// config: line_length = 120 +contract C { + function f() external { + for (uint256 i = 0; i < targets.length; i++) { + if (ITimelock(localTimelock) + .queuedTransactions( + keccak256(abi.encode(targets[i], values[i], signatures[i], calldatas[i], eta)) + )) revert TransactionAlreadyQueued(); + ITimelock(localTimelock).queueTransaction(targets[i], values[i], signatures[i], calldatas[i], eta); + } + } +} diff --git a/crates/fmt/testdata/IfStatementLongCondition/original.sol b/crates/fmt/testdata/IfStatementLongCondition/original.sol new file mode 100644 index 0000000000000..dafca31fd33e9 --- /dev/null +++ b/crates/fmt/testdata/IfStatementLongCondition/original.sol @@ -0,0 +1,8 @@ +contract C { + function f() external { + for (uint i = 0; i < targets.length; i++) { + if (ITimelock(localTimelock).queuedTransactions(keccak256(abi.encode(targets[i], values[i], signatures[i], calldatas[i], eta)))) revert TransactionAlreadyQueued(); + ITimelock(localTimelock).queueTransaction(targets[i], values[i], signatures[i], calldatas[i], eta); + } + } +} diff --git a/crates/fmt/testdata/IfStatementMultilineCall/120.fmt.sol b/crates/fmt/testdata/IfStatementMultilineCall/120.fmt.sol new file mode 100644 index 0000000000000..effa92ba8d428 --- /dev/null +++ b/crates/fmt/testdata/IfStatementMultilineCall/120.fmt.sol @@ -0,0 +1,8 @@ +// config: line_length = 120 +contract C { + function f() external { + if (check( + encode(aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa) + )) ping(); + } +} diff --git a/crates/fmt/testdata/IfStatementMultilineCall/original.sol b/crates/fmt/testdata/IfStatementMultilineCall/original.sol new file mode 100644 index 0000000000000..3fc4d1f8f9bf9 --- /dev/null +++ b/crates/fmt/testdata/IfStatementMultilineCall/original.sol @@ -0,0 +1,5 @@ +contract C { + function f() external { + if (check(encode(aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa))) ping(); + } +} diff --git a/crates/fmt/testdata/ImportDirective/bracket-spacing.fmt.sol b/crates/fmt/testdata/ImportDirective/bracket-spacing.fmt.sol index 1db94929ab7c7..b0f4fd067d465 100644 --- a/crates/fmt/testdata/ImportDirective/bracket-spacing.fmt.sol +++ b/crates/fmt/testdata/ImportDirective/bracket-spacing.fmt.sol @@ -5,8 +5,8 @@ import "SomeFile.sol" as SomeOtherFile; import "SomeFile.sol" as SomeOtherFile; import "AnotherFile.sol" as SomeSymbol; import "AnotherFile.sol" as SomeSymbol; -import { symbol1 as alias, symbol2 } from "File.sol"; -import { symbol1 as alias, symbol2 } from "File.sol"; +import { symbol1 as alias0, symbol2 } from "File.sol"; +import { symbol1 as alias0, symbol2 } from "File.sol"; import { symbol1 as alias1, symbol2 as alias2, @@ -19,3 +19,8 @@ import { symbol3 as alias3, symbol4 } from "File2.sol"; + +// Single import that exceeds line length (121 chars) +import { + ITransparentUpgradeableProxy +} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; diff --git a/crates/fmt/testdata/ImportDirective/fmt.sol b/crates/fmt/testdata/ImportDirective/fmt.sol index 4915b8ab203c8..c4ce5f3166750 100644 --- a/crates/fmt/testdata/ImportDirective/fmt.sol +++ b/crates/fmt/testdata/ImportDirective/fmt.sol @@ -4,8 +4,8 @@ import "SomeFile.sol" as SomeOtherFile; import "SomeFile.sol" as SomeOtherFile; import "AnotherFile.sol" as SomeSymbol; import "AnotherFile.sol" as SomeSymbol; -import {symbol1 as alias, symbol2} from "File.sol"; -import {symbol1 as alias, symbol2} from "File.sol"; +import {symbol1 as alias0, symbol2} from "File.sol"; +import {symbol1 as alias0, symbol2} from "File.sol"; import { symbol1 as alias1, symbol2 as alias2, @@ -18,3 +18,8 @@ import { symbol3 as alias3, symbol4 } from "File2.sol"; + +// Single import that exceeds line length (121 chars) +import { + ITransparentUpgradeableProxy +} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; diff --git a/crates/fmt/testdata/ImportDirective/namespace-import-prefer-glob.fmt.sol b/crates/fmt/testdata/ImportDirective/namespace-import-prefer-glob.fmt.sol new file mode 100644 index 0000000000000..e930ceee0754d --- /dev/null +++ b/crates/fmt/testdata/ImportDirective/namespace-import-prefer-glob.fmt.sol @@ -0,0 +1,26 @@ +// config: namespace_import_style = "prefer_glob" +import "SomeFile.sol"; +import "SomeFile.sol"; +import * as SomeOtherFile from "SomeFile.sol"; +import * as SomeOtherFile from "SomeFile.sol"; +import * as SomeSymbol from "AnotherFile.sol"; +import * as SomeSymbol from "AnotherFile.sol"; +import {symbol1 as alias0, symbol2} from "File.sol"; +import {symbol1 as alias0, symbol2} from "File.sol"; +import { + symbol1 as alias1, + symbol2 as alias2, + symbol3 as alias3, + symbol4 +} from "File2.sol"; +import { + symbol1 as alias1, + symbol2 as alias2, + symbol3 as alias3, + symbol4 +} from "File2.sol"; + +// Single import that exceeds line length (121 chars) +import { + ITransparentUpgradeableProxy +} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; diff --git a/crates/fmt/testdata/ImportDirective/namespace-import-prefer-plain.fmt.sol b/crates/fmt/testdata/ImportDirective/namespace-import-prefer-plain.fmt.sol new file mode 100644 index 0000000000000..6ea4cabeeb1ec --- /dev/null +++ b/crates/fmt/testdata/ImportDirective/namespace-import-prefer-plain.fmt.sol @@ -0,0 +1,26 @@ +// config: namespace_import_style = "prefer_plain" +import "SomeFile.sol"; +import "SomeFile.sol"; +import "SomeFile.sol" as SomeOtherFile; +import "SomeFile.sol" as SomeOtherFile; +import "AnotherFile.sol" as SomeSymbol; +import "AnotherFile.sol" as SomeSymbol; +import {symbol1 as alias0, symbol2} from "File.sol"; +import {symbol1 as alias0, symbol2} from "File.sol"; +import { + symbol1 as alias1, + symbol2 as alias2, + symbol3 as alias3, + symbol4 +} from "File2.sol"; +import { + symbol1 as alias1, + symbol2 as alias2, + symbol3 as alias3, + symbol4 +} from "File2.sol"; + +// Single import that exceeds line length (121 chars) +import { + ITransparentUpgradeableProxy +} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; diff --git a/crates/fmt/testdata/ImportDirective/namespace-import-preserve.fmt.sol b/crates/fmt/testdata/ImportDirective/namespace-import-preserve.fmt.sol new file mode 100644 index 0000000000000..607817b9eadfb --- /dev/null +++ b/crates/fmt/testdata/ImportDirective/namespace-import-preserve.fmt.sol @@ -0,0 +1,26 @@ +// config: namespace_import_style = "preserve" +import "SomeFile.sol"; +import "SomeFile.sol"; +import "SomeFile.sol" as SomeOtherFile; +import "SomeFile.sol" as SomeOtherFile; +import * as SomeSymbol from "AnotherFile.sol"; +import * as SomeSymbol from "AnotherFile.sol"; +import {symbol1 as alias0, symbol2} from "File.sol"; +import {symbol1 as alias0, symbol2} from "File.sol"; +import { + symbol1 as alias1, + symbol2 as alias2, + symbol3 as alias3, + symbol4 +} from "File2.sol"; +import { + symbol1 as alias1, + symbol2 as alias2, + symbol3 as alias3, + symbol4 +} from "File2.sol"; + +// Single import that exceeds line length (121 chars) +import { + ITransparentUpgradeableProxy +} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; diff --git a/crates/fmt/testdata/ImportDirective/original.sol b/crates/fmt/testdata/ImportDirective/original.sol index 0e18e10c14dab..2a18f88f0fa33 100644 --- a/crates/fmt/testdata/ImportDirective/original.sol +++ b/crates/fmt/testdata/ImportDirective/original.sol @@ -4,7 +4,10 @@ import "SomeFile.sol" as SomeOtherFile; import 'SomeFile.sol' as SomeOtherFile; import * as SomeSymbol from "AnotherFile.sol"; import * as SomeSymbol from 'AnotherFile.sol'; -import {symbol1 as alias, symbol2} from "File.sol"; -import {symbol1 as alias, symbol2} from 'File.sol'; +import {symbol1 as alias0, symbol2} from "File.sol"; +import {symbol1 as alias0, symbol2} from 'File.sol'; import {symbol1 as alias1, symbol2 as alias2, symbol3 as alias3, symbol4} from "File2.sol"; import {symbol1 as alias1, symbol2 as alias2, symbol3 as alias3, symbol4} from 'File2.sol'; + +// Single import that exceeds line length (121 chars) +import { ITransparentUpgradeableProxy } from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; diff --git a/crates/fmt/testdata/ImportDirective/preserve-quote.fmt.sol b/crates/fmt/testdata/ImportDirective/preserve-quote.fmt.sol index d1bf9852c02e5..c759965928ec2 100644 --- a/crates/fmt/testdata/ImportDirective/preserve-quote.fmt.sol +++ b/crates/fmt/testdata/ImportDirective/preserve-quote.fmt.sol @@ -5,8 +5,8 @@ import "SomeFile.sol" as SomeOtherFile; import 'SomeFile.sol' as SomeOtherFile; import "AnotherFile.sol" as SomeSymbol; import 'AnotherFile.sol' as SomeSymbol; -import {symbol1 as alias, symbol2} from "File.sol"; -import {symbol1 as alias, symbol2} from 'File.sol'; +import {symbol1 as alias0, symbol2} from "File.sol"; +import {symbol1 as alias0, symbol2} from 'File.sol'; import { symbol1 as alias1, symbol2 as alias2, @@ -19,3 +19,8 @@ import { symbol3 as alias3, symbol4 } from 'File2.sol'; + +// Single import that exceeds line length (121 chars) +import { + ITransparentUpgradeableProxy +} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; diff --git a/crates/fmt/testdata/ImportDirective/single-quote.fmt.sol b/crates/fmt/testdata/ImportDirective/single-quote.fmt.sol index 10449e079ae81..1820bd5166841 100644 --- a/crates/fmt/testdata/ImportDirective/single-quote.fmt.sol +++ b/crates/fmt/testdata/ImportDirective/single-quote.fmt.sol @@ -5,8 +5,8 @@ import 'SomeFile.sol' as SomeOtherFile; import 'SomeFile.sol' as SomeOtherFile; import 'AnotherFile.sol' as SomeSymbol; import 'AnotherFile.sol' as SomeSymbol; -import {symbol1 as alias, symbol2} from 'File.sol'; -import {symbol1 as alias, symbol2} from 'File.sol'; +import {symbol1 as alias0, symbol2} from 'File.sol'; +import {symbol1 as alias0, symbol2} from 'File.sol'; import { symbol1 as alias1, symbol2 as alias2, @@ -19,3 +19,8 @@ import { symbol3 as alias3, symbol4 } from 'File2.sol'; + +// Single import that exceeds line length (121 chars) +import { + ITransparentUpgradeableProxy +} from '@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol'; diff --git a/crates/fmt/testdata/ImportDirective/single_line_import.fmt.sol b/crates/fmt/testdata/ImportDirective/single_line_import.fmt.sol new file mode 100644 index 0000000000000..5644e336dbb97 --- /dev/null +++ b/crates/fmt/testdata/ImportDirective/single_line_import.fmt.sol @@ -0,0 +1,24 @@ +// config: single_line_imports = true +import "SomeFile.sol"; +import "SomeFile.sol"; +import "SomeFile.sol" as SomeOtherFile; +import "SomeFile.sol" as SomeOtherFile; +import "AnotherFile.sol" as SomeSymbol; +import "AnotherFile.sol" as SomeSymbol; +import {symbol1 as alias0, symbol2} from "File.sol"; +import {symbol1 as alias0, symbol2} from "File.sol"; +import { + symbol1 as alias1, + symbol2 as alias2, + symbol3 as alias3, + symbol4 +} from "File2.sol"; +import { + symbol1 as alias1, + symbol2 as alias2, + symbol3 as alias3, + symbol4 +} from "File2.sol"; + +// Single import that exceeds line length (121 chars) +import {ITransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; diff --git a/crates/fmt/testdata/IndexedAssignment/120.fmt.sol b/crates/fmt/testdata/IndexedAssignment/120.fmt.sol new file mode 100644 index 0000000000000..dc9c88898d35a --- /dev/null +++ b/crates/fmt/testdata/IndexedAssignment/120.fmt.sol @@ -0,0 +1,26 @@ +// config: line_length = 120 +// config: bracket_spacing = true +contract Route { + function f() external { + _OrderToRouteType[BasicOrderType.ETH_TO_ERC1155_PARTIAL_RESTRICTED] = BasicOrderRouteType.ETH_TO_ERC1155; + } +} + +contract Boolean { + function f() external { + context.expectations.ineligibleFailures[uint256(ineligibleFailure)] = true; + } +} + +contract AlreadyStable { + function f() external { + _OrderToRouteType[BasicOrderType.ERC20_TO_ERC1155_PARTIAL_RESTRICTED] = BasicOrderRouteType.ERC20_TO_ERC1155; + } +} + +contract Commented { + function f() external { + _OrderToRouteType[BasicOrderType.ETH_TO_ERC1155_PARTIAL_RESTRICTED] = /* preserve */ + BasicOrderRouteType.ETH_TO_ERC1155; + } +} diff --git a/crates/fmt/testdata/IndexedAssignment/80-no-spacing.fmt.sol b/crates/fmt/testdata/IndexedAssignment/80-no-spacing.fmt.sol new file mode 100644 index 0000000000000..81b412676054d --- /dev/null +++ b/crates/fmt/testdata/IndexedAssignment/80-no-spacing.fmt.sol @@ -0,0 +1,29 @@ +// config: line_length = 80 +// config: bracket_spacing = false +contract Route { + function f() external { + _OrderToRouteType[BasicOrderType.ETH_TO_ERC1155_PARTIAL_RESTRICTED] = + BasicOrderRouteType.ETH_TO_ERC1155; + } +} + +contract Boolean { + function f() external { + context.expectations.ineligibleFailures[uint256(ineligibleFailure)] = + true; + } +} + +contract AlreadyStable { + function f() external { + _OrderToRouteType[BasicOrderType.ERC20_TO_ERC1155_PARTIAL_RESTRICTED] = + BasicOrderRouteType.ERC20_TO_ERC1155; + } +} + +contract Commented { + function f() external { + _OrderToRouteType[BasicOrderType.ETH_TO_ERC1155_PARTIAL_RESTRICTED] = /* preserve */ + BasicOrderRouteType.ETH_TO_ERC1155; + } +} diff --git a/crates/fmt/testdata/IndexedAssignment/80.fmt.sol b/crates/fmt/testdata/IndexedAssignment/80.fmt.sol new file mode 100644 index 0000000000000..821cad54a37ff --- /dev/null +++ b/crates/fmt/testdata/IndexedAssignment/80.fmt.sol @@ -0,0 +1,30 @@ +// config: line_length = 80 +// config: bracket_spacing = true +contract Route { + function f() external { + _OrderToRouteType[BasicOrderType.ETH_TO_ERC1155_PARTIAL_RESTRICTED] = + BasicOrderRouteType.ETH_TO_ERC1155; + } +} + +contract Boolean { + function f() external { + context.expectations.ineligibleFailures[uint256(ineligibleFailure)] = + true; + } +} + +contract AlreadyStable { + function f() external { + _OrderToRouteType[ + BasicOrderType.ERC20_TO_ERC1155_PARTIAL_RESTRICTED + ] = BasicOrderRouteType.ERC20_TO_ERC1155; + } +} + +contract Commented { + function f() external { + _OrderToRouteType[BasicOrderType.ETH_TO_ERC1155_PARTIAL_RESTRICTED] = /* preserve */ + BasicOrderRouteType.ETH_TO_ERC1155; + } +} diff --git a/crates/fmt/testdata/IndexedAssignment/88.fmt.sol b/crates/fmt/testdata/IndexedAssignment/88.fmt.sol new file mode 100644 index 0000000000000..19e46a3300010 --- /dev/null +++ b/crates/fmt/testdata/IndexedAssignment/88.fmt.sol @@ -0,0 +1,28 @@ +// config: line_length = 88 +// config: bracket_spacing = true +contract Route { + function f() external { + _OrderToRouteType[BasicOrderType.ETH_TO_ERC1155_PARTIAL_RESTRICTED] = + BasicOrderRouteType.ETH_TO_ERC1155; + } +} + +contract Boolean { + function f() external { + context.expectations.ineligibleFailures[uint256(ineligibleFailure)] = true; + } +} + +contract AlreadyStable { + function f() external { + _OrderToRouteType[BasicOrderType.ERC20_TO_ERC1155_PARTIAL_RESTRICTED] = + BasicOrderRouteType.ERC20_TO_ERC1155; + } +} + +contract Commented { + function f() external { + _OrderToRouteType[BasicOrderType.ETH_TO_ERC1155_PARTIAL_RESTRICTED] = /* preserve */ + BasicOrderRouteType.ETH_TO_ERC1155; + } +} diff --git a/crates/fmt/testdata/IndexedAssignment/original.sol b/crates/fmt/testdata/IndexedAssignment/original.sol new file mode 100644 index 0000000000000..d55cdab25f5be --- /dev/null +++ b/crates/fmt/testdata/IndexedAssignment/original.sol @@ -0,0 +1,30 @@ +contract Route { + function f() external { + _OrderToRouteType[ + BasicOrderType.ETH_TO_ERC1155_PARTIAL_RESTRICTED + ] = BasicOrderRouteType.ETH_TO_ERC1155; + } +} + +contract Boolean { + function f() external { + context.expectations.ineligibleFailures[ + uint256(ineligibleFailure) + ] = true; + } +} + +contract AlreadyStable { + function f() external { + _OrderToRouteType[ + BasicOrderType.ERC20_TO_ERC1155_PARTIAL_RESTRICTED + ] = BasicOrderRouteType.ERC20_TO_ERC1155; + } +} + +contract Commented { + function f() external { + _OrderToRouteType[BasicOrderType.ETH_TO_ERC1155_PARTIAL_RESTRICTED] = /* preserve */ + BasicOrderRouteType.ETH_TO_ERC1155; + } +} diff --git a/crates/fmt/testdata/InlineDisable/fmt.sol b/crates/fmt/testdata/InlineDisable/fmt.sol index d7adea60b32da..b3e37ccc8e60f 100644 --- a/crates/fmt/testdata/InlineDisable/fmt.sol +++ b/crates/fmt/testdata/InlineDisable/fmt.sol @@ -1,8 +1,8 @@ -pragma solidity ^0.5.2; - // forgefmt: disable-next-line pragma solidity ^0.5.2; +pragma solidity ^0.5.2; + import { symbol1 as alias1, symbol2 as alias2, @@ -10,6 +10,7 @@ import { symbol4 } from "File2.sol"; +// comment before the directive // forgefmt: disable-next-line import {symbol1 as alias1, symbol2 as alias2, symbol3 as alias3, symbol4} from 'File2.sol'; @@ -29,7 +30,7 @@ enum States { enum States { State1, State2, State3, State4, State5, State6, State7, State8, State9 } // forgefmt: disable-next-line -bytes32 constant private BYTES = 0x035aff83d86937d35b32e04f0ddc6ff469290eef2f1b692d8a815c89404d4749; +bytes32 constant BYTES = 0x035aff83d86937d35b32e04f0ddc6ff469290eef2f1b692d8a815c89404d4749; // forgefmt: disable-start @@ -37,7 +38,7 @@ bytes32 constant private BYTES = 0x035aff83d86937d35b32e04f0ddc6ff469290eef2f1b6 // comment2 -/* comment 3 */ /* +/* comment 3 */ /* comment4 */ // comment 5 @@ -105,7 +106,7 @@ function testDoWhile() external { uint256 i; do { "test"; } while (i != 0); - do + do {} while ( @@ -113,9 +114,9 @@ i != 0); bool someVeryVeryLongCondition; do { "test"; } while( - someVeryVeryLongCondition && !someVeryVeryLongCondition && + someVeryVeryLongCondition && !someVeryVeryLongCondition && !someVeryVeryLongCondition && -someVeryVeryLongCondition); +someVeryVeryLongCondition); do i++; while(i < 10); @@ -134,7 +135,7 @@ function forStatement() { uint256 i2; for(++i2;i2<10;i2++) - + {} uint256 veryLongVariableName = 1000; @@ -161,18 +162,18 @@ function callArgTest() { target.run{gas:1,value:0x00}(); - target.run{ - gas : 1000, - value: 1 ether + target.run{ + gas : 1000, + value: 1 ether } (); target.run{ gas: estimate(), - value: value(1) }(); + value: value(1) }(); target.run { value: value(1 ether), gas: veryAndVeryLongNameOfSomeGasEstimateFunction() } (); - target.run /* comment 1 */ { value: /* comment2 */ 1 }; + target.run /* comment 1 */ { value: /* comment2 */ 1 }; target.run { /* comment3 */ value: 1, // comment4 gas: gasleft()}; @@ -240,7 +241,7 @@ function returnTest() { 0x00; } - if (val == 1) { return + if (val == 1) { return 1; } if (val == 2) { @@ -270,11 +271,11 @@ function namedFuncCall() { ComplexStruct memory complex = ComplexStruct({ val: 1, anotherVal: 2, flag: true, timestamp: block.timestamp }); StructWithAVeryLongNameThatExceedsMaximumLengthThatIsAllowedForFormatting memory long = StructWithAVeryLongNameThatExceedsMaximumLengthThatIsAllowedForFormatting({ whyNameSoLong: "dunno" }); - + SimpleStruct memory simple2 = SimpleStruct( - { // comment1 + { // comment1 /* comment2 */ val : /* comment3 */ 0 - + } ); // forgefmt: disable-end @@ -294,10 +295,10 @@ function revertTest() { ts: block.timestamp, message: "some reason" }); - + revert SomeVeryVeryVeryLongErrorNameWithNamedArgumentsThatExceedsMaximumLength({ val: 0, ts: 0x00, message: "something unpredictable happened that caused execution to revert"}); - revert // comment1 + revert // comment1 ({}); // forgefmt: disable-end } @@ -329,7 +330,7 @@ function thisTest() { this // comment1 .someVeryVeryVeryLongVariableNameThatWillBeAccessedByThisKeyword(); address(this).balance; - + address thisAddress = address( // comment2 /* comment3 */ this // comment 4 @@ -365,10 +366,10 @@ function tryTest() { } try unknown.lookupMultipleValues() returns (uint256, uint256, uint256, uint256, uint256) {} catch Error(string memory) {} catch {} - + try unknown.lookupMultipleValues() returns (uint256, uint256, uint256, uint256, uint256) { unknown.doSomething(); - } + } catch Error(string memory) { unknown.handleError(); } @@ -385,7 +386,7 @@ function testArray() { : /* comment2 */ msg.data.length // comment3 ]; msg.data[ - // comment4 + // comment4 4 // comment5 :msg.data.length /* comment6 */]; // forgefmt: disable-end @@ -457,7 +458,7 @@ function testWhile() { i3 < 10 ) { i3++; } - uint256 i4; while (i4 < 10) + uint256 i4; while (i4 < 10) { i4 ++ ;} @@ -469,11 +470,8 @@ function testWhile() { } function testLine() {} - function /* forgefmt: disable-line */ testLine( ) { } - function testLine() {} - function testLine( ) { } // forgefmt: disable-line // forgefmt: disable-start @@ -487,7 +485,7 @@ error TopLevelCustomErrorArgWithoutName (string); event Event1(uint256 indexed a, uint256 indexed a, uint256 indexed a, uint256 indexed a, uint256 indexed a, uint256 indexed a, uint256 indexed a, uint256 indexed a, uint256 indexed a, uint256 indexed a); -// forgefmt: disable-stop +// forgefmt: disable-end function setNumber(uint256 newNumber /* param1 */, uint256 sjdfasdfasdfasdfasfsdfsadfasdfasdfasdfsadjfkhasdfljkahsdfkjasdkfhsaf /* param2 */) public view returns (bool,bool) { /* inline*/ number1 = newNumber1; // forgefmt: disable-line number = newNumber; @@ -505,3 +503,55 @@ function setNumber(uint256 newNumber, uint256 sjdfasdfasdfasdfasfsdfsadfasdfasdf number = newNumber; number1 = newNumber1; // forgefmt: disable-line } + +// forgefmt: disable-start +contract DisabledItemA {} +contract DisabledItemB {} + +contract DisabledItemC {} +// forgefmt: disable-end +contract EnabledAfterDisabled { + uint256 number; +} + +contract DisabledMemberSeparation { + // forgefmt: disable-start + function first() public { number = 1; } + function second() public { number = 2; } + // forgefmt: disable-end + function third() public { + number = 3; + } +} + +contract TrailingDisableStart { + uint256 x; +} // forgefmt: disable-start +contract DisabledSuffix {} +// forgefmt: disable-end + +contract InlineTrailingDisableStart { + uint256 y; // forgefmt: disable-start +} +contract DisabledSuffix2 {} +// forgefmt: disable-end + +struct StructTrailingDisableStart { + uint256 a; // forgefmt: disable-start +} +contract DisabledSuffix3 {} +// forgefmt: disable-end + +contract IsolationBefore {} + +contract IsolationDisabledLine {} // forgefmt: disable-line + +function disableStartAfterStatement(string[2][] memory rpcs) { + for (uint256 i = 0; i < rpcs.length; i++) { + (string memory name, string memory rpcUrl) = (rpcs[i][0], rpcs[i][1]); + // forgefmt: disable-start + if (isEqual(name, "anvil")) x = rpcUrl; + else if (isEqual(name, "hardhat")) y = rpcUrl; + // forgefmt: disable-end + } +} diff --git a/crates/fmt/testdata/InlineDisable/original.sol b/crates/fmt/testdata/InlineDisable/original.sol index 7731678940cbf..21e5066d0f0bf 100644 --- a/crates/fmt/testdata/InlineDisable/original.sol +++ b/crates/fmt/testdata/InlineDisable/original.sol @@ -1,10 +1,11 @@ +// forgefmt: disable-next-line pragma solidity ^0.5.2; -// forgefmt: disable-next-line pragma solidity ^0.5.2; import {symbol1 as alias1, symbol2 as alias2, symbol3 as alias3, symbol4} from 'File2.sol'; +// comment before the directive // forgefmt: disable-next-line import {symbol1 as alias1, symbol2 as alias2, symbol3 as alias3, symbol4} from 'File2.sol'; @@ -14,7 +15,7 @@ enum States { State1, State2, State3, State4, State5, State6, State7, State8, St enum States { State1, State2, State3, State4, State5, State6, State7, State8, State9 } // forgefmt: disable-next-line -bytes32 constant private BYTES = 0x035aff83d86937d35b32e04f0ddc6ff469290eef2f1b692d8a815c89404d4749; +bytes32 constant BYTES = 0x035aff83d86937d35b32e04f0ddc6ff469290eef2f1b692d8a815c89404d4749; // forgefmt: disable-start @@ -22,7 +23,7 @@ bytes32 constant private BYTES = 0x035aff83d86937d35b32e04f0ddc6ff469290eef2f1b6 // comment2 -/* comment 3 */ /* +/* comment 3 */ /* comment4 */ // comment 5 @@ -87,7 +88,7 @@ function testDoWhile() external { uint256 i; do { "test"; } while (i != 0); - do + do {} while ( @@ -95,9 +96,9 @@ i != 0); bool someVeryVeryLongCondition; do { "test"; } while( - someVeryVeryLongCondition && !someVeryVeryLongCondition && + someVeryVeryLongCondition && !someVeryVeryLongCondition && !someVeryVeryLongCondition && -someVeryVeryLongCondition); +someVeryVeryLongCondition); do i++; while(i < 10); @@ -116,7 +117,7 @@ function forStatement() { uint256 i2; for(++i2;i2<10;i2++) - + {} uint256 veryLongVariableName = 1000; @@ -143,18 +144,18 @@ function callArgTest() { target.run{gas:1,value:0x00}(); - target.run{ - gas : 1000, - value: 1 ether + target.run{ + gas : 1000, + value: 1 ether } (); target.run{ gas: estimate(), - value: value(1) }(); + value: value(1) }(); target.run { value: value(1 ether), gas: veryAndVeryLongNameOfSomeGasEstimateFunction() } (); - target.run /* comment 1 */ { value: /* comment2 */ 1 }; + target.run /* comment 1 */ { value: /* comment2 */ 1 }; target.run { /* comment3 */ value: 1, // comment4 gas: gasleft()}; @@ -211,7 +212,7 @@ function literalTest() { // forgefmt: disable-end // forgefmt: disable-next-line - bytes memory bytecode = + bytes memory bytecode = hex"ff"; } @@ -222,7 +223,7 @@ function returnTest() { 0x00; } - if (val == 1) { return + if (val == 1) { return 1; } if (val == 2) { @@ -252,11 +253,11 @@ function namedFuncCall() { ComplexStruct memory complex = ComplexStruct({ val: 1, anotherVal: 2, flag: true, timestamp: block.timestamp }); StructWithAVeryLongNameThatExceedsMaximumLengthThatIsAllowedForFormatting memory long = StructWithAVeryLongNameThatExceedsMaximumLengthThatIsAllowedForFormatting({ whyNameSoLong: "dunno" }); - + SimpleStruct memory simple2 = SimpleStruct( - { // comment1 + { // comment1 /* comment2 */ val : /* comment3 */ 0 - + } ); // forgefmt: disable-end @@ -276,10 +277,10 @@ function revertTest() { ts: block.timestamp, message: "some reason" }); - + revert SomeVeryVeryVeryLongErrorNameWithNamedArgumentsThatExceedsMaximumLength({ val: 0, ts: 0x00, message: "something unpredictable happened that caused execution to revert"}); - revert // comment1 + revert // comment1 ({}); // forgefmt: disable-end } @@ -311,7 +312,7 @@ function thisTest() { this // comment1 .someVeryVeryVeryLongVariableNameThatWillBeAccessedByThisKeyword(); address(this).balance; - + address thisAddress = address( // comment2 /* comment3 */ this // comment 4 @@ -347,10 +348,10 @@ function tryTest() { } try unknown.lookupMultipleValues() returns (uint256, uint256, uint256, uint256, uint256) {} catch Error(string memory) {} catch {} - + try unknown.lookupMultipleValues() returns (uint256, uint256, uint256, uint256, uint256) { unknown.doSomething(); - } + } catch Error(string memory) { unknown.handleError(); } @@ -367,7 +368,7 @@ function testArray() { : /* comment2 */ msg.data.length // comment3 ]; msg.data[ - // comment4 + // comment4 4 // comment5 :msg.data.length /* comment6 */]; // forgefmt: disable-end @@ -439,7 +440,7 @@ function testWhile() { i3 < 10 ) { i3++; } - uint256 i4; while (i4 < 10) + uint256 i4; while (i4 < 10) { i4 ++ ;} @@ -450,9 +451,9 @@ function testWhile() { // forgefmt: disable-end } -function testLine( ) { } -function /* forgefmt: disable-line */ testLine( ) { } -function testLine( ) { } +function testLine( ) { } +function /* forgefmt: disable-line */ testLine( ) { } +function testLine( ) { } function testLine( ) { } // forgefmt: disable-line // forgefmt: disable-start @@ -466,7 +467,7 @@ error TopLevelCustomErrorArgWithoutName (string); event Event1(uint256 indexed a, uint256 indexed a, uint256 indexed a, uint256 indexed a, uint256 indexed a, uint256 indexed a, uint256 indexed a, uint256 indexed a, uint256 indexed a, uint256 indexed a); -// forgefmt: disable-stop +// forgefmt: disable-end function setNumber(uint256 newNumber /* param1 */, uint256 sjdfasdfasdfasdfasfsdfsadfasdfasdfasdfsadjfkhasdfljkahsdfkjasdkfhsaf /* param2 */) public view returns (bool,bool) { /* inline*/ number1 = newNumber1; // forgefmt: disable-line number = newNumber; @@ -484,3 +485,46 @@ function setNumber(uint256 newNumber, uint256 sjdfasdfasdfasdfasfsdfsadfasdfasdf number = newNumber; number1 = newNumber1; // forgefmt: disable-line } + +// forgefmt: disable-start +contract DisabledItemA {} +contract DisabledItemB {} + +contract DisabledItemC {} +// forgefmt: disable-end +contract EnabledAfterDisabled { uint256 number; } + +contract DisabledMemberSeparation { + // forgefmt: disable-start + function first() public { number = 1; } + function second() public { number = 2; } + // forgefmt: disable-end + function third() public { number = 3; } +} + +contract TrailingDisableStart { + uint256 x; +} // forgefmt: disable-start +contract DisabledSuffix {} +// forgefmt: disable-end + +contract InlineTrailingDisableStart { uint256 y; } // forgefmt: disable-start +contract DisabledSuffix2 {} +// forgefmt: disable-end + +struct StructTrailingDisableStart { uint256 a; } // forgefmt: disable-start +contract DisabledSuffix3 {} +// forgefmt: disable-end + +contract IsolationBefore {} +contract IsolationDisabledLine {} // forgefmt: disable-line + +function disableStartAfterStatement(string[2][] memory rpcs) { + for (uint256 i = 0; i < rpcs.length; i++) { + (string memory name, string memory rpcUrl) = (rpcs[i][0], rpcs[i][1]); + // forgefmt: disable-start + if (isEqual(name, "anvil")) x = rpcUrl; + else if (isEqual(name, "hardhat")) y = rpcUrl; + // forgefmt: disable-end + } +} diff --git a/crates/fmt/testdata/LineComments/fmt.sol b/crates/fmt/testdata/LineComments/fmt.sol new file mode 100644 index 0000000000000..82dee85c0ad4e --- /dev/null +++ b/crates/fmt/testdata/LineComments/fmt.sol @@ -0,0 +1,47 @@ +contract LineComments { + mapping( + address account // Account key. + => uint256 balance + ) named; + mapping( + address // Unnamed key. + => uint256 + ) unnamed; + mapping( + address // Value type. + => uint256 + ) afterArrow; + mapping( + address + => mapping( + uint256 id // Nested key. + => uint256 + ) + ) nested; + mapping(address /* Block key. */ => uint256) blockComment; + + function tuples() external pure returns (uint256 x, uint256 y) { + ( + // Omitted first result. + , + x + ) = (1, 2); + ( + x, + // Omitted middle result. + , + y + ) = (1, 2, 3); + ( + x, + // Consecutive omissions. + ,, + y + ) = (1, 2, 3, 4); + ( + x, + // Omitted last result. + ) = (1, 2); + (/* Block omission. */, x) = (1, 2); + } +} diff --git a/crates/fmt/testdata/LineComments/original.sol b/crates/fmt/testdata/LineComments/original.sol new file mode 100644 index 0000000000000..ff836be37b342 --- /dev/null +++ b/crates/fmt/testdata/LineComments/original.sol @@ -0,0 +1,30 @@ +contract LineComments { + mapping(address account // Account key. + => uint256 balance) named; + mapping(address // Unnamed key. + => uint256) unnamed; + mapping(address => // Value type. + uint256) afterArrow; + mapping(address => mapping(uint256 id // Nested key. + => uint256)) nested; + mapping(address /* Block key. */ => uint256) blockComment; + + function tuples() external pure returns (uint256 x, uint256 y) { + ( + // Omitted first result. + , x + ) = (1, 2); + (x, + // Omitted middle result. + , y + ) = (1, 2, 3); + (x, + // Consecutive omissions. + , , y + ) = (1, 2, 3, 4); + (x, + // Omitted last result. + ) = (1, 2); + (/* Block omission. */, x) = (1, 2); + } +} diff --git a/crates/fmt/testdata/LiteralExpression/fmt.sol b/crates/fmt/testdata/LiteralExpression/fmt.sol index 7fa6878e5586d..1551b4a9cb316 100644 --- a/crates/fmt/testdata/LiteralExpression/fmt.sol +++ b/crates/fmt/testdata/LiteralExpression/fmt.sol @@ -11,7 +11,7 @@ contract LiteralExpressions { // number literals 1; 123_000; - 1_2e345_678; + // 1_2e345_678; -1; 2e-10; // comment5 @@ -22,6 +22,8 @@ contract LiteralExpressions { 0x00; 0x123_456; 0x2eff_abde; + address(0xCAFE); + address(0xBEEF); // rational number literals 0.1; diff --git a/crates/fmt/testdata/LiteralExpression/original.sol b/crates/fmt/testdata/LiteralExpression/original.sol index 5c559531de955..916e927da1ff0 100644 --- a/crates/fmt/testdata/LiteralExpression/original.sol +++ b/crates/fmt/testdata/LiteralExpression/original.sol @@ -10,7 +10,7 @@ contract LiteralExpressions { // number literals 1; 123_000; - 1_2e345_678; + // 1_2e345_678; -1; 2e-10; // comment5 @@ -20,6 +20,8 @@ contract LiteralExpressions { 0x00; 0x123_456; 0x2eff_abde; + address(0xCAFE); + address(0xBEEF); // rational number literals .1; diff --git a/crates/fmt/testdata/LiteralExpression/preserve-quote.fmt.sol b/crates/fmt/testdata/LiteralExpression/preserve-quote.fmt.sol index 3d9490804f231..7e9f6e0b2ec79 100644 --- a/crates/fmt/testdata/LiteralExpression/preserve-quote.fmt.sol +++ b/crates/fmt/testdata/LiteralExpression/preserve-quote.fmt.sol @@ -12,7 +12,7 @@ contract LiteralExpressions { // number literals 1; 123_000; - 1_2e345_678; + // 1_2e345_678; -1; 2e-10; // comment5 @@ -23,6 +23,8 @@ contract LiteralExpressions { 0x00; 0x123_456; 0x2eff_abde; + address(0xCAFE); + address(0xBEEF); // rational number literals 0.1; diff --git a/crates/fmt/testdata/LiteralExpression/single-quote.fmt.sol b/crates/fmt/testdata/LiteralExpression/single-quote.fmt.sol index cdc67a2c6c814..67244e687fe03 100644 --- a/crates/fmt/testdata/LiteralExpression/single-quote.fmt.sol +++ b/crates/fmt/testdata/LiteralExpression/single-quote.fmt.sol @@ -12,7 +12,7 @@ contract LiteralExpressions { // number literals 1; 123_000; - 1_2e345_678; + // 1_2e345_678; -1; 2e-10; // comment5 @@ -23,6 +23,8 @@ contract LiteralExpressions { 0x00; 0x123_456; 0x2eff_abde; + address(0xCAFE); + address(0xBEEF); // rational number literals 0.1; diff --git a/crates/fmt/testdata/MappingNamedParams/long.fmt.sol b/crates/fmt/testdata/MappingNamedParams/long.fmt.sol new file mode 100644 index 0000000000000..e402b2a42c97e --- /dev/null +++ b/crates/fmt/testdata/MappingNamedParams/long.fmt.sol @@ -0,0 +1,10 @@ +// config: line_length = 120 +contract C { + mapping(uint256 nameSame => mapping(uint256 name1 => mapping(uint256 nameSame => uint256 name3) name6) name4) map; + + function main() external { + mapping( + uint256 nameSame => mapping(uint256 name1 => mapping(uint256 nameSame => uint256 name3) name6) name4 + ) storage _map = map; + } +} diff --git a/crates/fmt/testdata/MappingNamedParams/original.sol b/crates/fmt/testdata/MappingNamedParams/original.sol new file mode 100644 index 0000000000000..c4f7cd46e78c3 --- /dev/null +++ b/crates/fmt/testdata/MappingNamedParams/original.sol @@ -0,0 +1,7 @@ +contract C { + mapping(uint nameSame => mapping(uint name1 => mapping(uint nameSame => uint name3) name6) name4) map; + + function main() external { + mapping(uint nameSame => mapping(uint name1 => mapping(uint nameSame => uint name3) name6) name4) storage _map = map; + } +} diff --git a/crates/fmt/testdata/MappingType/fmt.sol b/crates/fmt/testdata/MappingType/fmt.sol index 7f6297cff9455..61d5cd02fb300 100644 --- a/crates/fmt/testdata/MappingType/fmt.sol +++ b/crates/fmt/testdata/MappingType/fmt.sol @@ -29,7 +29,7 @@ contract Mapping { // comment2 ) mapping6; mapping( /* comment3 */ - uint256 /* comment4 */ key /* comment5 */ - => /* comment6 */ uint256 /* comment7 */ value /* comment8 */ /* comment9 */ + uint256 /* comment4 */ key /* comment5 */ /* comment6 */ + => uint256 /* comment7 */ value /* comment8 */ /* comment9 */ ) /* comment10 */ mapping7; } diff --git a/crates/fmt/testdata/MemberChainIndent/long.fmt.sol b/crates/fmt/testdata/MemberChainIndent/long.fmt.sol new file mode 100644 index 0000000000000..2e5936fa1785b --- /dev/null +++ b/crates/fmt/testdata/MemberChainIndent/long.fmt.sol @@ -0,0 +1,11 @@ +// config: line_length = 120 +// config: bracket_spacing = true +library L { + function f() internal { + require( + vars.receiver + .executeOperation(params.assets, params.amounts, vars.totalPremiums, params.user, params.params), + Errors.InvalidFlashloanExecutorReturn() + ); + } +} diff --git a/crates/fmt/testdata/MemberChainIndent/original.sol b/crates/fmt/testdata/MemberChainIndent/original.sol new file mode 100644 index 0000000000000..9e215782f656e --- /dev/null +++ b/crates/fmt/testdata/MemberChainIndent/original.sol @@ -0,0 +1,14 @@ +library L { + function f() internal { + require( + vars.receiver.executeOperation( + params.assets, + params.amounts, + vars.totalPremiums, + params.user, + params.params + ), + Errors.InvalidFlashloanExecutorReturn() + ); + } +} diff --git a/crates/fmt/testdata/MethodChain/fmt.sol b/crates/fmt/testdata/MethodChain/fmt.sol new file mode 100644 index 0000000000000..3953ff700db1f --- /dev/null +++ b/crates/fmt/testdata/MethodChain/fmt.sol @@ -0,0 +1,27 @@ +// config: line_length = 120 +// https://github.com/foundry-rs/foundry/issues/12344 +contract MethodChain { + function shortChain() external { + value.first().second(); + } + + function longChain() external { + stdstore.target(address(eigenDAServiceManager)) + .sig("batchIdToBatchMetadataHash(uint32)") + .with_key(defaultBatchId) + .checked_write(CertV1Lib.hashBatchMetadata(batchMetadata)); + } + + function commentedChain() external { + factory() // preserve + .first() + .second(); + } + + function standaloneCommentChain() external { + factory(). + // preserve + first() + .second(); + } +} diff --git a/crates/fmt/testdata/MethodChain/original.sol b/crates/fmt/testdata/MethodChain/original.sol new file mode 100644 index 0000000000000..b5b3f1047fbb2 --- /dev/null +++ b/crates/fmt/testdata/MethodChain/original.sol @@ -0,0 +1,21 @@ +// https://github.com/foundry-rs/foundry/issues/12344 +contract MethodChain { +function shortChain() external { +value.first().second(); +} + +function longChain() external { +stdstore.target(address(eigenDAServiceManager)).sig("batchIdToBatchMetadataHash(uint32)").with_key(defaultBatchId).checked_write(CertV1Lib.hashBatchMetadata(batchMetadata)); +} + +function commentedChain() external { +factory() // preserve +.first().second(); +} + +function standaloneCommentChain() external { +factory() +// preserve +.first().second(); +} +} diff --git a/crates/fmt/testdata/MethodChainCallOptions/fmt.sol b/crates/fmt/testdata/MethodChainCallOptions/fmt.sol new file mode 100644 index 0000000000000..1d4b164ad8278 --- /dev/null +++ b/crates/fmt/testdata/MethodChainCallOptions/fmt.sol @@ -0,0 +1,19 @@ +// config: line_length = 60 +contract MethodChainCallOptions { + function formatCallOptions() external { + target{value: amount}(shortArg) + .firstLongMethod() + .secondLongMethod(); + } + + function formatOverflowingCallOptions() external { + target{ + value: v + }( + firstExtremelyLongArgument, + secondExtremelyLongArgument + ) + .firstLongMethod() + .secondLongMethod(); + } +} diff --git a/crates/fmt/testdata/MethodChainCallOptions/original.sol b/crates/fmt/testdata/MethodChainCallOptions/original.sol new file mode 100644 index 0000000000000..4e076f1c6156a --- /dev/null +++ b/crates/fmt/testdata/MethodChainCallOptions/original.sol @@ -0,0 +1,9 @@ +contract MethodChainCallOptions { +function formatCallOptions() external { +target{value: amount}(shortArg).firstLongMethod().secondLongMethod(); +} + +function formatOverflowingCallOptions() external { +target{value: v}(firstExtremelyLongArgument, secondExtremelyLongArgument).firstLongMethod().secondLongMethod(); +} +} diff --git a/crates/fmt/testdata/MixedBlockComments/fmt.sol b/crates/fmt/testdata/MixedBlockComments/fmt.sol new file mode 100644 index 0000000000000..3c826f3779c19 --- /dev/null +++ b/crates/fmt/testdata/MixedBlockComments/fmt.sol @@ -0,0 +1,24 @@ +contract MixedBlockComments { + function condition(uint256 x) external pure returns (uint256) { + if ( + x /* First line. + more text. */ > 0 + ) { + return 1; + } + return 0; + } + + function expression(uint256 x) external pure returns (uint256) { + return x /* Explanation. + Indented detail. + + More detail. */ + 1; + } + + function aligned(uint256 x) external pure returns (uint256) { + return x /* Column A. + Column B. + Column C. */ + 1; + } +} diff --git a/crates/fmt/testdata/MixedBlockComments/original.sol b/crates/fmt/testdata/MixedBlockComments/original.sol new file mode 100644 index 0000000000000..fd41c899bc41d --- /dev/null +++ b/crates/fmt/testdata/MixedBlockComments/original.sol @@ -0,0 +1,20 @@ +contract MixedBlockComments { + function condition(uint256 x) external pure returns (uint256) { + if (x /* First line. +more text. */ > 0) { return 1; } + return 0; + } + + function expression(uint256 x) external pure returns (uint256) { + return x /* Explanation. + Indented detail. + + More detail. */ + 1; + } + + function aligned(uint256 x) external pure returns (uint256) { + return x /* Column A. + Column B. + Column C. */ + 1; + } +} diff --git a/crates/fmt/testdata/MixedBlockComments/tab.fmt.sol b/crates/fmt/testdata/MixedBlockComments/tab.fmt.sol new file mode 100644 index 0000000000000..90c78870d8fff --- /dev/null +++ b/crates/fmt/testdata/MixedBlockComments/tab.fmt.sol @@ -0,0 +1,25 @@ +// config: style = "tab" +contract MixedBlockComments { + function condition(uint256 x) external pure returns (uint256) { + if ( + x /* First line. + more text. */ > 0 + ) { + return 1; + } + return 0; + } + + function expression(uint256 x) external pure returns (uint256) { + return x /* Explanation. + Indented detail. + + More detail. */ + 1; + } + + function aligned(uint256 x) external pure returns (uint256) { + return x /* Column A. + Column B. + Column C. */ + 1; + } +} diff --git a/crates/fmt/testdata/ModifierDefinition/fmt.sol b/crates/fmt/testdata/ModifierDefinition/fmt.sol index e735f25b4c51d..8a5ca6caf5075 100644 --- a/crates/fmt/testdata/ModifierDefinition/fmt.sol +++ b/crates/fmt/testdata/ModifierDefinition/fmt.sol @@ -11,4 +11,32 @@ contract ModifierDefinitions { uint256 d ) {} modifier overridden() override(Base1, Base2) {} + modifier inlineBlock()/* Inline explanation. */ { + _; + } + modifier trailingLine(uint256 a) // Trailing explanation. + { + require(a > 0); + _; + } + modifier isolatedLine() + // Isolated explanation. + { + _; + } + modifier isolatedBlock() + /* Block explanation. */ + { + _; + } + modifier multiline() + /* First line. + Second line. */ + { + _; + } + modifier attributed() virtual /* Virtual. */ { + _; + } + constructor()/* Constructor explanation. */ {} } diff --git a/crates/fmt/testdata/ModifierDefinition/original.sol b/crates/fmt/testdata/ModifierDefinition/original.sol index f4a1c428429bb..fed60c6e2a3eb 100644 --- a/crates/fmt/testdata/ModifierDefinition/original.sol +++ b/crates/fmt/testdata/ModifierDefinition/original.sol @@ -6,4 +6,17 @@ contract ModifierDefinitions { modifier fourParams(uint a,uint b ,uint c, uint d) {} modifier overridden ( ) override ( Base1 , Base2) {} + modifier inlineBlock() /* Inline explanation. */ { _; } + modifier trailingLine(uint a) // Trailing explanation. + { require(a > 0); _; } + modifier isolatedLine() + // Isolated explanation. + { _; } + modifier isolatedBlock() + /* Block explanation. */ + { _; } + modifier multiline() /* First line. + Second line. */ { _; } + modifier attributed() virtual /* Virtual. */ { _; } + constructor() /* Constructor explanation. */ {} } diff --git a/crates/fmt/testdata/ModifierDefinition/override-spacing.fmt.sol b/crates/fmt/testdata/ModifierDefinition/override-spacing.fmt.sol index 68906fcdc0f65..0fef11f141f3f 100644 --- a/crates/fmt/testdata/ModifierDefinition/override-spacing.fmt.sol +++ b/crates/fmt/testdata/ModifierDefinition/override-spacing.fmt.sol @@ -12,4 +12,32 @@ contract ModifierDefinitions { uint256 d ) {} modifier overridden() override (Base1, Base2) {} + modifier inlineBlock()/* Inline explanation. */ { + _; + } + modifier trailingLine(uint256 a) // Trailing explanation. + { + require(a > 0); + _; + } + modifier isolatedLine() + // Isolated explanation. + { + _; + } + modifier isolatedBlock() + /* Block explanation. */ + { + _; + } + modifier multiline() + /* First line. + Second line. */ + { + _; + } + modifier attributed() virtual /* Virtual. */ { + _; + } + constructor()/* Constructor explanation. */ {} } diff --git a/crates/fmt/testdata/NamedCallArgsInChain/fmt.sol b/crates/fmt/testdata/NamedCallArgsInChain/fmt.sol new file mode 100644 index 0000000000000..70717bb08d9a4 --- /dev/null +++ b/crates/fmt/testdata/NamedCallArgsInChain/fmt.sol @@ -0,0 +1,208 @@ +// config: line_length = 120 +// https://github.com/foundry-rs/foundry/issues/15755 +interface IVault { + function updatePosition( + uint256 positionId_, + uint256 batchId_, + address account_, + address operator_, + uint256 amount_, + uint256 deadline_, + bytes calldata data_ + ) external; +} + +contract NamedCallArgsInChain { + function regular( + address vault, + uint256 positionId, + uint256 batchId, + address account, + address operator, + uint256 amount, + uint256 deadline, + bytes calldata data + ) external { + IVault(vault).updatePosition({ + positionId_: positionId, + batchId_: batchId, + account_: account, + operator_: operator, + amount_: amount, + deadline_: deadline, + data_: data + }); + } + + function overlongCallee(address vault, uint256 positionId, uint256 batchId) external { + IExtremelyLongVaultInterfaceNameThatStillFits(vault) + .updateAnExtremelyLongPositionNameThatMakesTheCombinedCalleeOverflow({ + positionId_: positionId, + batchId_: batchId, + account_: address(0), + operator_: address(0), + amount_: 0, + deadline_: 0, + data_: "" + }); + } + + function parenthesizedOverlongCallee(address vault, uint256 positionId, uint256 batchId) external { + (IExtremelyLongVaultInterfaceNameThatStillFits(vault) + .updateAnExtremelyLongPositionNameThatMakesTheCombinedCalleeOverflow)({ + extremelyLongPositionIdentifier_: extremelyLongPositionIdentifier, + extremelyLongBatchIdentifier_: extremelyLongBatchIdentifier, + extremelyLongAccountIdentifier_: extremelyLongAccountIdentifier + }); + } + + function calleeAtLineBoundary(address vault, uint256 positionId, uint256 batchId) external { + IVault(vault) + .updatePositionAtTheExactConfiguredLineLengthBoundaryWithoutForcingTheBaseInterfaceConversionToWrap({ + positionId_: positionId, batchId_: batchId + }); + } + + function reviewCases(uint256 firstExtremelyLongValueName, uint256 secondExtremelyLongValueName) external { + factory().foo(bar(firstExtremelyLongValueName)).baz({ + firstExtremelyLongArgumentName: firstExtremelyLongValueName, + secondExtremelyLongArgumentName: secondExtremelyLongValueName + }); + factory() // preserve + .item() + .update({ + firstExtremelyLongArgumentName: firstExtremelyLongValueName, + secondExtremelyLongArgumentName: secondExtremelyLongValueName + }); + factory() + .item( /* preserve */ + firstExtremelyLongValueName + ) + .update({ + firstExtremelyLongArgumentName: firstExtremelyLongValueName, + secondExtremelyLongArgumentName: secondExtremelyLongValueName + }); + factory().items()[0].update({ + firstExtremelyLongArgumentName: firstExtremelyLongValueName, + secondExtremelyLongArgumentName: secondExtremelyLongValueName + }); + (factory().item()).update({ + firstExtremelyLongArgumentName: firstExtremelyLongValueName, + secondExtremelyLongArgumentName: secondExtremelyLongValueName + }); + foo{value: 1}() + .bar({ + firstExtremelyLongArgumentName: firstExtremelyLongValueName, + secondExtremelyLongArgumentName: secondExtremelyLongValueName + }); + (foo{value: 1})() + .bar({ + firstExtremelyLongArgumentName: firstExtremelyLongValueName, + secondExtremelyLongArgumentName: secondExtremelyLongValueName + }); + } + + // https://github.com/foundry-rs/foundry/issues/15823 + function issue15823(uint256 redactedId, uint256 setId, bytes calldata redactedEngineData) external { + RedactedRootConfiguration.getRedactedEngine({ + redactedId: redactedId + }).initializeSetRedacted({setId_: setId, redactedId_: redactedId, redactedEngineData_: redactedEngineData}); + } + + function attempted( + address vault, + uint256 positionId, + uint256 batchId, + address account, + address operator, + uint256 amount, + uint256 deadline, + bytes calldata data + ) external { + try IVault(vault).updatePosition({ + positionId_: positionId, + batchId_: batchId, + account_: account, + operator_: operator, + amount_: amount, + deadline_: deadline, + data_: data + }) {} catch {} + } + + // https://github.com/foundry-rs/foundry/issues/15823 + function nestedNamedCalls( + uint256 extremelyLongRedactedIdentifier, + uint256 extremelyLongSetIdentifier, + bytes calldata extremelyLongRedactedEngineData + ) external { + RedactedRootConfiguration.getRedactedEngine({ + extremelyLongRedactedIdentifier_: extremelyLongRedactedIdentifier + }).initializeSetRedacted({ + extremelyLongSetIdentifier_: extremelyLongSetIdentifier, + extremelyLongRedactedIdentifier_: extremelyLongRedactedIdentifier, + extremelyLongRedactedEngineData_: extremelyLongRedactedEngineData + }); + Root.first({ + a: a + }).second({ + b: b + }).third({ + firstExtremelyLongArgumentName: firstExtremelyLongValueName, + secondExtremelyLongArgumentName: secondExtremelyLongValueName + }); + Root.first({a: a}) // preserve + .second({ + firstExtremelyLongArgumentName: firstExtremelyLongValueName, + secondExtremelyLongArgumentName: secondExtremelyLongValueName + }); + ( + Root.first({a: a}).second // preserve + )({ + firstExtremelyLongArgumentName: firstExtremelyLongValueName, + secondExtremelyLongArgumentName: secondExtremelyLongValueName + }); + Root.first({a: a}). + /* preserve */ + second({ + firstExtremelyLongArgumentName: firstExtremelyLongValueName, + secondExtremelyLongArgumentName: secondExtremelyLongValueName + }); + Root.first({a: a}) /* preserve */ + .second({ + firstExtremelyLongArgumentName: firstExtremelyLongValueName, + secondExtremelyLongArgumentName: secondExtremelyLongValueName + }); + Root.first({a: a}) /* adjacent */ + .second({ + firstExtremelyLongArgumentName: firstExtremelyLongValueName, + secondExtremelyLongArgumentName: secondExtremelyLongValueName + }); + ( + Root.first({a: a}).second /* preserve */ + )({ + firstExtremelyLongArgumentName: firstExtremelyLongValueName, + secondExtremelyLongArgumentName: secondExtremelyLongValueName + }); + ( + Root.first({a: a}).second /* adjacent */ + )({ + firstExtremelyLongArgumentName: firstExtremelyLongValueName, + secondExtremelyLongArgumentName: secondExtremelyLongValueName + }); + ( + /* preserve */ + Root.first({a: a}).second + )({ + firstExtremelyLongArgumentName: firstExtremelyLongValueName, + secondExtremelyLongArgumentName: secondExtremelyLongValueName + }); + factory() + .anExtremelyLongHandlersArrayNameThatForcesTheIndexSuffixToWrap[ + anExtremelyLongIndexExpressionNameThatAlsoForcesTheIndexSuffixToWrap + ]({ + firstExtremelyLongArgumentName: firstExtremelyLongValueName, + secondExtremelyLongArgumentName: secondExtremelyLongValueName + }); + } +} diff --git a/crates/fmt/testdata/NamedCallArgsInChain/original.sol b/crates/fmt/testdata/NamedCallArgsInChain/original.sol new file mode 100644 index 0000000000000..50dc7011355c8 --- /dev/null +++ b/crates/fmt/testdata/NamedCallArgsInChain/original.sol @@ -0,0 +1,98 @@ +// https://github.com/foundry-rs/foundry/issues/15755 +interface IVault { + function updatePosition(uint256 positionId_, uint256 batchId_, address account_, address operator_, uint256 amount_, uint256 deadline_, bytes calldata data_) external; +} + +contract NamedCallArgsInChain { + function regular(address vault, uint256 positionId, uint256 batchId, address account, address operator, uint256 amount, uint256 deadline, bytes calldata data) external { + IVault(vault).updatePosition({positionId_: positionId, batchId_: batchId, account_: account, operator_: operator, amount_: amount, deadline_: deadline, data_: data}); + } + + function overlongCallee(address vault, uint256 positionId, uint256 batchId) external { + IExtremelyLongVaultInterfaceNameThatStillFits(vault).updateAnExtremelyLongPositionNameThatMakesTheCombinedCalleeOverflow({positionId_: positionId, batchId_: batchId, account_: address(0), operator_: address(0), amount_: 0, deadline_: 0, data_: ""}); + } + + function parenthesizedOverlongCallee(address vault, uint256 positionId, uint256 batchId) external { + (IExtremelyLongVaultInterfaceNameThatStillFits(vault) + .updateAnExtremelyLongPositionNameThatMakesTheCombinedCalleeOverflow)({ + extremelyLongPositionIdentifier_: extremelyLongPositionIdentifier, + extremelyLongBatchIdentifier_: extremelyLongBatchIdentifier, + extremelyLongAccountIdentifier_: extremelyLongAccountIdentifier + }); + } + + function calleeAtLineBoundary(address vault, uint256 positionId, uint256 batchId) external { + IVault(vault).updatePositionAtTheExactConfiguredLineLengthBoundaryWithoutForcingTheBaseInterfaceConversionToWrap({positionId_: positionId, batchId_: batchId}); + } + + function reviewCases(uint256 firstExtremelyLongValueName, uint256 secondExtremelyLongValueName) external { + factory().foo(bar(firstExtremelyLongValueName)).baz({firstExtremelyLongArgumentName: firstExtremelyLongValueName, secondExtremelyLongArgumentName: secondExtremelyLongValueName}); + factory() // preserve + .item().update({firstExtremelyLongArgumentName: firstExtremelyLongValueName, secondExtremelyLongArgumentName: secondExtremelyLongValueName}); + factory().item(/* preserve */ firstExtremelyLongValueName).update({firstExtremelyLongArgumentName: firstExtremelyLongValueName, secondExtremelyLongArgumentName: secondExtremelyLongValueName}); + factory().items()[0].update({firstExtremelyLongArgumentName: firstExtremelyLongValueName, secondExtremelyLongArgumentName: secondExtremelyLongValueName}); + (factory().item()).update({firstExtremelyLongArgumentName: firstExtremelyLongValueName, secondExtremelyLongArgumentName: secondExtremelyLongValueName}); + foo{value: 1}().bar({firstExtremelyLongArgumentName: firstExtremelyLongValueName, secondExtremelyLongArgumentName: secondExtremelyLongValueName}); + (foo{value: 1})().bar({firstExtremelyLongArgumentName: firstExtremelyLongValueName, secondExtremelyLongArgumentName: secondExtremelyLongValueName}); + } + + // https://github.com/foundry-rs/foundry/issues/15823 + function issue15823(uint256 redactedId, uint256 setId, bytes calldata redactedEngineData) external { + RedactedRootConfiguration.getRedactedEngine({ + redactedId: redactedId + }).initializeSetRedacted({ + setId_: setId, redactedId_: redactedId, redactedEngineData_: redactedEngineData + }); + } + + function attempted(address vault, uint256 positionId, uint256 batchId, address account, address operator, uint256 amount, uint256 deadline, bytes calldata data) external { + try IVault(vault).updatePosition({positionId_: positionId, batchId_: batchId, account_: account, operator_: operator, amount_: amount, deadline_: deadline, data_: data}) {} catch {} + } + + // https://github.com/foundry-rs/foundry/issues/15823 + function nestedNamedCalls(uint256 extremelyLongRedactedIdentifier, uint256 extremelyLongSetIdentifier, bytes calldata extremelyLongRedactedEngineData) external { + RedactedRootConfiguration.getRedactedEngine({ + extremelyLongRedactedIdentifier_: extremelyLongRedactedIdentifier + }).initializeSetRedacted({ + extremelyLongSetIdentifier_: extremelyLongSetIdentifier, extremelyLongRedactedIdentifier_: extremelyLongRedactedIdentifier, extremelyLongRedactedEngineData_: extremelyLongRedactedEngineData + }); + Root.first({a: a}).second({b: b}).third({firstExtremelyLongArgumentName: firstExtremelyLongValueName, secondExtremelyLongArgumentName: secondExtremelyLongValueName}); + Root.first({a: a}) // preserve + .second({firstExtremelyLongArgumentName: firstExtremelyLongValueName, secondExtremelyLongArgumentName: secondExtremelyLongValueName}); + (Root.first({a: a}).second // preserve + )({ + firstExtremelyLongArgumentName: firstExtremelyLongValueName, + secondExtremelyLongArgumentName: secondExtremelyLongValueName + }); + Root.first({a: a}) + /* preserve */ + .second({ + firstExtremelyLongArgumentName: firstExtremelyLongValueName, + secondExtremelyLongArgumentName: secondExtremelyLongValueName + }); + Root.first({a: a}) /* preserve */ .second({ + firstExtremelyLongArgumentName: firstExtremelyLongValueName, + secondExtremelyLongArgumentName: secondExtremelyLongValueName + }); + Root.first({a: a})/* adjacent */.second({ + firstExtremelyLongArgumentName: firstExtremelyLongValueName, + secondExtremelyLongArgumentName: secondExtremelyLongValueName + }); + (Root.first({a: a}).second /* preserve */)({ + firstExtremelyLongArgumentName: firstExtremelyLongValueName, + secondExtremelyLongArgumentName: secondExtremelyLongValueName + }); + (Root.first({a: a}).second/* adjacent */)({ + firstExtremelyLongArgumentName: firstExtremelyLongValueName, + secondExtremelyLongArgumentName: secondExtremelyLongValueName + }); + ( + /* preserve */ + Root.first({a: a}).second + )({ + firstExtremelyLongArgumentName: firstExtremelyLongValueName, + secondExtremelyLongArgumentName: secondExtremelyLongValueName + }); + factory().anExtremelyLongHandlersArrayNameThatForcesTheIndexSuffixToWrap[anExtremelyLongIndexExpressionNameThatAlsoForcesTheIndexSuffixToWrap]({firstExtremelyLongArgumentName: firstExtremelyLongValueName, secondExtremelyLongArgumentName: secondExtremelyLongValueName}); + } +} diff --git a/crates/fmt/testdata/NamedFunctionCallExpression/fmt.sol b/crates/fmt/testdata/NamedFunctionCallExpression/fmt.sol index 14a24c9003888..e8f832e3e299f 100644 --- a/crates/fmt/testdata/NamedFunctionCallExpression/fmt.sol +++ b/crates/fmt/testdata/NamedFunctionCallExpression/fmt.sol @@ -1,3 +1,4 @@ +// config: prefer_compact = "events_errors" contract NamedFunctionCallExpression { struct SimpleStruct { uint256 val; @@ -26,22 +27,20 @@ contract NamedFunctionCallExpression { }); StructWithAVeryLongNameThatExceedsMaximumLengthThatIsAllowedForFormatting - memory long = - StructWithAVeryLongNameThatExceedsMaximumLengthThatIsAllowedForFormatting({ + memory + long = StructWithAVeryLongNameThatExceedsMaximumLengthThatIsAllowedForFormatting({ whyNameSoLong: "dunno" }); SimpleStruct memory simple2 = SimpleStruct({ // comment1 - /* comment2 */ - val: /* comment3 */ 0 + /* comment2 */ val: /* comment3 */ 0 }); - SimpleStruct memory simple3 = SimpleStruct({ - /* comment4 */ + SimpleStruct memory simple3 = SimpleStruct({ /* comment4 */ // comment5 val: // comment6 - 0 // comment7 - // comment8 + 0 // comment7 + // comment8 }); } } diff --git a/crates/fmt/testdata/NamedFunctionCallExpression/original.sol b/crates/fmt/testdata/NamedFunctionCallExpression/original.sol index 8b34474a7134f..5546c60d6bc40 100644 --- a/crates/fmt/testdata/NamedFunctionCallExpression/original.sol +++ b/crates/fmt/testdata/NamedFunctionCallExpression/original.sol @@ -9,20 +9,20 @@ contract NamedFunctionCallExpression { ComplexStruct memory complex = ComplexStruct({ val: 1, anotherVal: 2, flag: true, timestamp: block.timestamp }); StructWithAVeryLongNameThatExceedsMaximumLengthThatIsAllowedForFormatting memory long = StructWithAVeryLongNameThatExceedsMaximumLengthThatIsAllowedForFormatting({ whyNameSoLong: "dunno" }); - + SimpleStruct memory simple2 = SimpleStruct( - { // comment1 + { // comment1 /* comment2 */ val : /* comment3 */ 0 - + } ); SimpleStruct memory simple3 = SimpleStruct( - /* comment4 */ { + /* comment4 */ { // comment5 val: // comment6 0 // comment7 // comment8 }); } -} \ No newline at end of file +} diff --git a/crates/fmt/testdata/NestedNamedCallArgumentChain/fmt.sol b/crates/fmt/testdata/NestedNamedCallArgumentChain/fmt.sol new file mode 100644 index 0000000000000..0816297da7c86 --- /dev/null +++ b/crates/fmt/testdata/NestedNamedCallArgumentChain/fmt.sol @@ -0,0 +1,43 @@ +// config: line_length = 120 +contract NestedNamedCallArgumentChain { + function format(uint256 firstExtremelyLongValueName, uint256 secondExtremelyLongValueName) external { + factory() + .item( + innerFactory().anExtremelyLongMethodNameThatForcesTheNestedCalleeToWrap({ + firstExtremelyLongArgumentName: firstExtremelyLongValueName, + secondExtremelyLongArgumentName: secondExtremelyLongValueName + }) + ) + .update(); + factory() + .item( + innerFactory().methodThatMakesThisNamedCallExactlyOneHundredAndTwentyOneCharsLongXX({ + value: firstExtremelyLongValueName + }) + ) + .update(); + factory() + .item( + innerFactory().anExtremelyLongMethodNameThatForcesTheNestedCalleeToWrap({ + firstExtremelyLongArgumentName: firstExtremelyLongValueName, + secondExtremelyLongArgumentName: secondExtremelyLongValueName + }) + ) // keep this comment + .update(); + factory().item(innerFactory().method({value: firstExtremelyLongValueName})).update(); + factory() + .item( + innerFactory().anExtremelyLongMethodNameThatForcesTheNestedCalleeToWrap({ + firstExtremelyLongArgumentName: true + }) + ) + .update(); + factory() + .item( + innerFactory().anExtremelyLongMethodNameThatForcesTheNestedCalleeToWrap({ + value: firstExtremelyLongValueName + secondExtremelyLongValueName + }) + ) + .update(); + } +} diff --git a/crates/fmt/testdata/NestedNamedCallArgumentChain/original.sol b/crates/fmt/testdata/NestedNamedCallArgumentChain/original.sol new file mode 100644 index 0000000000000..6383457d281b4 --- /dev/null +++ b/crates/fmt/testdata/NestedNamedCallArgumentChain/original.sol @@ -0,0 +1,11 @@ +contract NestedNamedCallArgumentChain { + function format(uint256 firstExtremelyLongValueName, uint256 secondExtremelyLongValueName) external { + factory().item(innerFactory().anExtremelyLongMethodNameThatForcesTheNestedCalleeToWrap({firstExtremelyLongArgumentName: firstExtremelyLongValueName, secondExtremelyLongArgumentName: secondExtremelyLongValueName})).update(); + factory().item(innerFactory().methodThatMakesThisNamedCallExactlyOneHundredAndTwentyOneCharsLongXX({value: firstExtremelyLongValueName})).update(); + factory().item(innerFactory().anExtremelyLongMethodNameThatForcesTheNestedCalleeToWrap({firstExtremelyLongArgumentName: firstExtremelyLongValueName, secondExtremelyLongArgumentName: secondExtremelyLongValueName})) // keep this comment + .update(); + factory().item(innerFactory().method({value: firstExtremelyLongValueName})).update(); + factory().item(innerFactory().anExtremelyLongMethodNameThatForcesTheNestedCalleeToWrap({firstExtremelyLongArgumentName: true})).update(); + factory().item(innerFactory().anExtremelyLongMethodNameThatForcesTheNestedCalleeToWrap({value: firstExtremelyLongValueName + secondExtremelyLongValueName})).update(); + } +} diff --git a/crates/fmt/testdata/NonKeywords/fmt.sol b/crates/fmt/testdata/NonKeywords/fmt.sol index 207eed1c96816..2786450ba571e 100644 --- a/crates/fmt/testdata/NonKeywords/fmt.sol +++ b/crates/fmt/testdata/NonKeywords/fmt.sol @@ -2,8 +2,8 @@ struct S { uint256 error; uint256 layout; uint256 at; + // uint256 transient; } -// uint256 transient; function f() { uint256 error = 0; @@ -21,8 +21,8 @@ function f() { error: 0, layout: 0, at: 0 + // transient: 0 }); - // transient: 0 x.error = 0; x.layout = 0; diff --git a/crates/fmt/testdata/NumberLiteralUnderscore/fmt.sol b/crates/fmt/testdata/NumberLiteralUnderscore/fmt.sol index 7c9f5740de76a..aef6b27d769cf 100644 --- a/crates/fmt/testdata/NumberLiteralUnderscore/fmt.sol +++ b/crates/fmt/testdata/NumberLiteralUnderscore/fmt.sol @@ -1,25 +1,30 @@ contract NumberLiteral { + bytes4 internal constant HEX_NUM = 0x01ffc9a7; + function test() external { 1; 123_000; - 1_2e345_678; + // 1_2e345_678; // solar error: exponent too large -1; 2e-10; 0.1; 1.3; 2.5e1; 1.23454; - 1.2e34_5_678; - 134411.2e34_5_678; - 13431.134112e34_135_678; + // 1.2e34_5_678; // solar error: exponent too large + // 134411.2e34_5_678; // solar error: exponent too large + // 13431.134112e34_135_678; // solar error: exponent too large 13431.0134112; - 13431.134112e-139_3141340; - 134411.2e34_5_6780; - 13431.134112e34_135_6780; - 0.134112; + // 13431.134112e-139_3141340; // solar error: exponent too large + // 00134411.200e0034_5_6780; // solar error: leading zeros are not allowed in integers + // 013431.13411200e34_135_6780; // solar error: leading zeros are not allowed in integers + // 00.1341120000; // solar error: leading zeros are not allowed in integers 1.0; - 13431.134112e-139_3141340; - 123e456; + // 0013431.13411200e-00139_3141340; // solar error: leading zeros are not allowed in integers + 10_234e56; + 1234e56; + 10000; 1_000; + 5_267.8268764263694426e18; } } diff --git a/crates/fmt/testdata/NumberLiteralUnderscore/original.sol b/crates/fmt/testdata/NumberLiteralUnderscore/original.sol index 8e88fc6d29472..fad579d1316ec 100644 --- a/crates/fmt/testdata/NumberLiteralUnderscore/original.sol +++ b/crates/fmt/testdata/NumberLiteralUnderscore/original.sol @@ -1,25 +1,30 @@ contract NumberLiteral { + bytes4 internal constant HEX_NUM = 0x01ffc9a7; + function test() external { 1; 123_000; - 1_2e345_678; + // 1_2e345_678; // solar error: exponent too large -1; 2e-10; .1; 1.3; 2.5e1; 1.23454e0; - 1.2e34_5_678; - 134411.2e34_5_678; - 13431.134112e34_135_678; + // 1.2e34_5_678; // solar error: exponent too large + // 134411.2e34_5_678; // solar error: exponent too large + // 13431.134112e34_135_678; // solar error: exponent too large 13431.0134112; - 13431.134112e-139_3141340; - 00134411.200e0034_5_6780; - 013431.13411200e34_135_6780; - 00.1341120000; + // 13431.134112e-139_3141340; // solar error: exponent too large + // 00134411.200e0034_5_6780; // solar error: leading zeros are not allowed in integers + // 013431.13411200e34_135_6780; // solar error: leading zeros are not allowed in integers + // 00.1341120000; // solar error: leading zeros are not allowed in integers 1.0000; - 0013431.13411200e-00139_3141340; - 123E456; + // 0013431.13411200e-00139_3141340; // solar error: leading zeros are not allowed in integers + 10_234E56; + 1234E56; + 10000; 1_000; + 5_267.8268764263694426e18; } } diff --git a/crates/fmt/testdata/NumberLiteralUnderscore/preserve.fmt.sol b/crates/fmt/testdata/NumberLiteralUnderscore/preserve.fmt.sol index d87dc99d9653d..77dde9162e28d 100644 --- a/crates/fmt/testdata/NumberLiteralUnderscore/preserve.fmt.sol +++ b/crates/fmt/testdata/NumberLiteralUnderscore/preserve.fmt.sol @@ -1,26 +1,31 @@ // config: number_underscore = "preserve" contract NumberLiteral { + bytes4 internal constant HEX_NUM = 0x01ffc9a7; + function test() external { 1; 123_000; - 1_2e345_678; + // 1_2e345_678; // solar error: exponent too large -1; 2e-10; 0.1; 1.3; 2.5e1; 1.23454; - 1.2e34_5_678; - 134411.2e34_5_678; - 13431.134112e34_135_678; + // 1.2e34_5_678; // solar error: exponent too large + // 134411.2e34_5_678; // solar error: exponent too large + // 13431.134112e34_135_678; // solar error: exponent too large 13431.0134112; - 13431.134112e-139_3141340; - 134411.2e34_5_6780; - 13431.134112e34_135_6780; - 0.134112; + // 13431.134112e-139_3141340; // solar error: exponent too large + // 00134411.200e0034_5_6780; // solar error: leading zeros are not allowed in integers + // 013431.13411200e34_135_6780; // solar error: leading zeros are not allowed in integers + // 00.1341120000; // solar error: leading zeros are not allowed in integers 1.0; - 13431.134112e-139_3141340; - 123e456; + // 0013431.13411200e-00139_3141340; // solar error: leading zeros are not allowed in integers + 10_234e56; + 1234e56; + 10000; 1_000; + 5_267.8268764263694426e18; } } diff --git a/crates/fmt/testdata/NumberLiteralUnderscore/remove.fmt.sol b/crates/fmt/testdata/NumberLiteralUnderscore/remove.fmt.sol index cbde2e9b9fe61..61b2ce2bd0c3e 100644 --- a/crates/fmt/testdata/NumberLiteralUnderscore/remove.fmt.sol +++ b/crates/fmt/testdata/NumberLiteralUnderscore/remove.fmt.sol @@ -1,26 +1,31 @@ // config: number_underscore = "remove" contract NumberLiteral { + bytes4 internal constant HEX_NUM = 0x01ffc9a7; + function test() external { 1; 123000; - 12e345678; + // 1_2e345_678; // solar error: exponent too large -1; 2e-10; 0.1; 1.3; 2.5e1; 1.23454; - 1.2e345678; - 134411.2e345678; - 13431.134112e34135678; + // 1.2e34_5_678; // solar error: exponent too large + // 134411.2e34_5_678; // solar error: exponent too large + // 13431.134112e34_135_678; // solar error: exponent too large 13431.0134112; - 13431.134112e-1393141340; - 134411.2e3456780; - 13431.134112e341356780; - 0.134112; + // 13431.134112e-139_3141340; // solar error: exponent too large + // 00134411.200e0034_5_6780; // solar error: leading zeros are not allowed in integers + // 013431.13411200e34_135_6780; // solar error: leading zeros are not allowed in integers + // 00.1341120000; // solar error: leading zeros are not allowed in integers 1.0; - 13431.134112e-1393141340; - 123e456; + // 0013431.13411200e-00139_3141340; // solar error: leading zeros are not allowed in integers + 10234e56; + 1234e56; + 10000; 1000; + 5267.8268764263694426e18; } } diff --git a/crates/fmt/testdata/NumberLiteralUnderscore/thousands.fmt.sol b/crates/fmt/testdata/NumberLiteralUnderscore/thousands.fmt.sol index a9fc8a69ae6fa..261d49b5021a5 100644 --- a/crates/fmt/testdata/NumberLiteralUnderscore/thousands.fmt.sol +++ b/crates/fmt/testdata/NumberLiteralUnderscore/thousands.fmt.sol @@ -1,26 +1,31 @@ // config: number_underscore = "thousands" contract NumberLiteral { + bytes4 internal constant HEX_NUM = 0x01ffc9a7; + function test() external { 1; 123_000; - 12e345_678; + // 1_2e345_678; // solar error: exponent too large -1; 2e-10; 0.1; 1.3; 2.5e1; - 1.23454; - 1.2e345_678; - 134_411.2e345_678; - 13_431.134112e34_135_678; - 13_431.0134112; - 13_431.134112e-1_393_141_340; - 134_411.2e3_456_780; - 13_431.134112e341_356_780; - 0.134112; + 1.234_54; + // 1.2e34_5_678; // solar error: exponent too large + // 134411.2e34_5_678; // solar error: exponent too large + // 13431.134112e34_135_678; // solar error: exponent too large + 13_431.013_411_2; + // 13431.134112e-139_3141340; // solar error: exponent too large + // 00134411.200e0034_5_6780; // solar error: leading zeros are not allowed in integers + // 013431.13411200e34_135_6780; // solar error: leading zeros are not allowed in integers + // 00.1341120000; // solar error: leading zeros are not allowed in integers 1.0; - 13_431.134112e-1_393_141_340; - 123e456; + // 0013431.13411200e-00139_3141340; // solar error: leading zeros are not allowed in integers + 10_234e56; + 1234e56; + 10_000; 1000; + 5267.8268764263694426e18; } } diff --git a/crates/fmt/testdata/OperatorExpressions/120.fmt.sol b/crates/fmt/testdata/OperatorExpressions/120.fmt.sol new file mode 100644 index 0000000000000..5ee85c3b6d185 --- /dev/null +++ b/crates/fmt/testdata/OperatorExpressions/120.fmt.sol @@ -0,0 +1,91 @@ +// config: line_length = 120 +function test() { + uint256 expr001 = (1 + 2) + 3; + uint256 expr002 = 1 + (2 + 3); + uint256 expr003 = 1 * 2 + 3; + uint256 expr004 = (1 * 2) + 3; + uint256 expr005 = 1 * (2 + 3); + uint256 expr006 = 1 + 2 * 3; + uint256 expr007 = (1 + 2) * 3; + uint256 expr008 = 1 + (2 * 3); + uint256 expr009 = 1 ** 2 ** 3; + uint256 expr010 = 1 ** (2 ** 3); + uint256 expr011 = (1 ** 2) ** 3; + uint256 expr012 = ++expr011 + 1; + bool expr013 = ++expr012 == expr011 - 1; + bool expr014 = ++(++expr013)--; + if (++batch.movesPerformed == drivers.length) createNewBatch(); + sum += getPrice( + ACCELERATE_STARTING_PRICE, + ACCELERATE_PER_PERIOD_DECREASE, + idleTicks, + actionsSold[ActionType.ACCELERATE] + i, + ACCELERATE_SELL_PER_TICK + ) / 1e18; + other += 1e18 + / getPrice( + ACCELERATE_STARTING_PRICE, + ACCELERATE_PER_PERIOD_DECREASE, + idleTicks, + actionsSold[ActionType.ACCELERATE] + i, + ACCELERATE_SELL_PER_TICK + ); + if ( + op == 0x54 // SLOAD + || op == 0x55 // SSTORE + || op == 0xF0 // CREATE + || op == 0xF1 // CALL + || op == 0xF2 // CALLCODE + || op == 0xF4 // DELEGATECALL + || op == 0xF5 // CREATE2 + || op == 0xFA // STATICCALL + || op == 0xFF // SELFDESTRUCT + ) { + return false; + } +} + +function test_nested() { + require( + keccak256(abi.encodePacked("some long string")) == keccak256(abi.encodePacked("some other long string")), + "string mismatch" + ); + + state.zeroForOne = + IERC20(Currency.unwrap(state.poolKey1.currency0)) == IERC20(Currency.unwrap(state.poolKey0.curerncy1)); + + coreAddresses.evc == address(0) && coreAddresses.protocolConfig == address(0) + && coreAddresses.sequenceRegistry == address(0) && coreAddresses.balanceTracker == address(0) + && coreAddresses.permit2 == address(0); + + return spender == ownerOf(tokenId) || getApproved[tokenId] == spender || isApprovedForAll[ownerOf(tokenId)][spender]; +} + +function new_y(uint256 x, uint256 dx, uint256 x_basis, uint256 y, uint256 y_basis) external pure returns (uint256) { + return _get_y( + x * _VELODROME_TOKEN_BASIS / x_basis, + dx * _VELODROME_TOKEN_BASIS / x_basis, + y * _VELODROME_TOKEN_BASIS / y_basis + ) * y_basis / _VELODROME_TOKEN_BASIS * aReallyLongIdentifierThatMakesTheOperatorExpressionBreak; +} + +contract Repro { + bytes4 public constant MINIMAL_INTERFACE_ID = this.calculateMinFeeWeiFor.selector ^ this.convertUSDFeeToWei.selector + ^ this.execute.selector ^ this.getMinFeeUSDFor.selector; + bool isTestnet = chainId == ARBITRUM_SEPOLIA || chainId == BASE_SEPOLIA || chainId == MODE_SEPOLIA + || chainId == OPTIMISM_SEPOLIA || chainId == SEPOLIA; + + function test() { + assign = this.calculateMinFeeWeiFor.selector ^ this.convertUSDFeeToWei.selector ^ this.execute.selector + ^ this.getMinFeeUSDFor.selector; + isMainnet = chainId == ABSTRACT || chainId == ARBITRUM || chainId == AVALANCHE || chainId == BASE + || chainId == BERACHAIN || chainId == BLAST || chainId == BSC || chainId == CHILIZ || chainId == COREDAO + || chainId == ETHEREUM || chainId == GNOSIS || chainId == HYPEREVM || chainId == LIGHTLINK + || chainId == LINEA || chainId == MODE || chainId == MORPH || chainId == OPTIMISM || chainId == POLYGON + || chainId == SCROLL || chainId == SEI || chainId == SOPHON || chainId == SUPERSEED || chainId == SONIC + || chainId == UNICHAIN || chainId == XDC || chainId == ZKSYNC; + + callsGas += (3 * FixedPointMathLib.divUp(paramsLength, 32)) + + FixedPointMathLib.mulDivUp(paramsLength, paramsLength, 524_288); + } +} diff --git a/crates/fmt/testdata/OperatorExpressions/fmt.sol b/crates/fmt/testdata/OperatorExpressions/fmt.sol index be0cec9173d55..139b9428b6483 100644 --- a/crates/fmt/testdata/OperatorExpressions/fmt.sol +++ b/crates/fmt/testdata/OperatorExpressions/fmt.sol @@ -39,5 +39,69 @@ function test() { || op == 0xF5 // CREATE2 || op == 0xFA // STATICCALL || op == 0xFF // SELFDESTRUCT - ) return false; + ) { + return false; + } +} + +function test_nested() { + require( + keccak256(abi.encodePacked("some long string")) + == keccak256(abi.encodePacked("some other long string")), + "string mismatch" + ); + + state.zeroForOne = IERC20(Currency.unwrap(state.poolKey1.currency0)) + == IERC20(Currency.unwrap(state.poolKey0.curerncy1)); + + coreAddresses.evc == address(0) + && coreAddresses.protocolConfig == address(0) + && coreAddresses.sequenceRegistry == address(0) + && coreAddresses.balanceTracker == address(0) + && coreAddresses.permit2 == address(0); + + return spender == ownerOf(tokenId) || getApproved[tokenId] == spender + || isApprovedForAll[ownerOf(tokenId)][spender]; +} + +function new_y( + uint256 x, + uint256 dx, + uint256 x_basis, + uint256 y, + uint256 y_basis +) external pure returns (uint256) { + return _get_y( + x * _VELODROME_TOKEN_BASIS / x_basis, + dx * _VELODROME_TOKEN_BASIS / x_basis, + y * _VELODROME_TOKEN_BASIS / y_basis + ) * y_basis / _VELODROME_TOKEN_BASIS + * aReallyLongIdentifierThatMakesTheOperatorExpressionBreak; +} + +contract Repro { + bytes4 public constant MINIMAL_INTERFACE_ID = + this.calculateMinFeeWeiFor.selector ^ this.convertUSDFeeToWei.selector + ^ this.execute.selector ^ this.getMinFeeUSDFor.selector; + bool isTestnet = chainId == ARBITRUM_SEPOLIA || chainId == BASE_SEPOLIA + || chainId == MODE_SEPOLIA || chainId == OPTIMISM_SEPOLIA + || chainId == SEPOLIA; + + function test() { + assign = this.calculateMinFeeWeiFor.selector + ^ this.convertUSDFeeToWei.selector ^ this.execute.selector + ^ this.getMinFeeUSDFor.selector; + isMainnet = chainId == ABSTRACT || chainId == ARBITRUM + || chainId == AVALANCHE || chainId == BASE || chainId == BERACHAIN + || chainId == BLAST || chainId == BSC || chainId == CHILIZ + || chainId == COREDAO || chainId == ETHEREUM || chainId == GNOSIS + || chainId == HYPEREVM || chainId == LIGHTLINK || chainId == LINEA + || chainId == MODE || chainId == MORPH || chainId == OPTIMISM + || chainId == POLYGON || chainId == SCROLL || chainId == SEI + || chainId == SOPHON || chainId == SUPERSEED || chainId == SONIC + || chainId == UNICHAIN || chainId == XDC || chainId == ZKSYNC; + + callsGas += (3 * FixedPointMathLib.divUp(paramsLength, 32)) + + FixedPointMathLib.mulDivUp(paramsLength, paramsLength, 524_288); + } } diff --git a/crates/fmt/testdata/OperatorExpressions/original.sol b/crates/fmt/testdata/OperatorExpressions/original.sol index c3e788d0a9385..0de13f2149dd0 100644 --- a/crates/fmt/testdata/OperatorExpressions/original.sol +++ b/crates/fmt/testdata/OperatorExpressions/original.sol @@ -7,9 +7,9 @@ function test() { uint256 expr006 = 1 + 2 * 3; uint256 expr007 = (1 + 2) * 3; uint256 expr008 = 1 + (2 * 3); - uint256 expr009 = 1 ** 2 ** 3; - uint256 expr010 = 1 ** (2 ** 3); - uint256 expr011 = (1 ** 2) ** 3; + uint256 expr009 = 1**2 ** 3; + uint256 expr010 = 1**(2 ** 3); + uint256 expr011 = (1**2) ** 3; uint256 expr012 = ++expr011 + 1; bool expr013 = ++expr012 == expr011 - 1; bool expr014 = ++(++expr013)--; @@ -28,3 +28,50 @@ function test() { || op == 0xFF // SELFDESTRUCT ) return false; } + +function test_nested() { + require( + keccak256(abi.encodePacked("some long string")) + == keccak256(abi.encodePacked("some other long string")), + "string mismatch" + ); + + state.zeroForOne = IERC20(Currency.unwrap(state.poolKey1.currency0)) + == IERC20(Currency.unwrap(state.poolKey0.curerncy1)); + + coreAddresses.evc == address(0) && coreAddresses.protocolConfig == address(0) + && coreAddresses.sequenceRegistry == address(0) && coreAddresses.balanceTracker == address(0) + && coreAddresses.permit2 == address(0); + + return spender == ownerOf(tokenId) || getApproved[tokenId] == spender + || isApprovedForAll[ownerOf(tokenId)][spender]; +} + +function new_y(uint256 x, uint256 dx, uint256 x_basis, uint256 y, uint256 y_basis) + external + pure + returns (uint256) +{ + return _get_y( + x * _VELODROME_TOKEN_BASIS / x_basis, + dx * _VELODROME_TOKEN_BASIS / x_basis, + y * _VELODROME_TOKEN_BASIS / y_basis + ) * y_basis / _VELODROME_TOKEN_BASIS * aReallyLongIdentifierThatMakesTheOperatorExpressionBreak; +} + +contract Repro { + bytes4 public constant MINIMAL_INTERFACE_ID = this.calculateMinFeeWeiFor.selector ^ this.convertUSDFeeToWei.selector ^ this.execute.selector ^ this.getMinFeeUSDFor.selector; + bool isTestnet = chainId == ARBITRUM_SEPOLIA || chainId == BASE_SEPOLIA || chainId == MODE_SEPOLIA || chainId == OPTIMISM_SEPOLIA || chainId == SEPOLIA; + + function test() { + assign = this.calculateMinFeeWeiFor.selector ^ this.convertUSDFeeToWei.selector ^ this.execute.selector ^ this.getMinFeeUSDFor.selector; + isMainnet = chainId == ABSTRACT || chainId == ARBITRUM || chainId == AVALANCHE || chainId == BASE + || chainId == BERACHAIN || chainId == BLAST || chainId == BSC || chainId == CHILIZ || chainId == COREDAO + || chainId == ETHEREUM || chainId == GNOSIS || chainId == HYPEREVM || chainId == LIGHTLINK || chainId == LINEA + || chainId == MODE || chainId == MORPH || chainId == OPTIMISM || chainId == POLYGON || chainId == SCROLL + || chainId == SEI || chainId == SOPHON || chainId == SUPERSEED || chainId == SONIC || chainId == UNICHAIN + || chainId == XDC || chainId == ZKSYNC; + + callsGas += (3 * FixedPointMathLib.divUp(paramsLength, 32)) + FixedPointMathLib.mulDivUp(paramsLength, paramsLength, 524_288); + } +} diff --git a/crates/fmt/testdata/OperatorExpressions/pow-no-space.fmt.sol b/crates/fmt/testdata/OperatorExpressions/pow-no-space.fmt.sol new file mode 100644 index 0000000000000..f63e3af8893cf --- /dev/null +++ b/crates/fmt/testdata/OperatorExpressions/pow-no-space.fmt.sol @@ -0,0 +1,108 @@ +// config: pow_no_space = true +function test() { + uint256 expr001 = (1 + 2) + 3; + uint256 expr002 = 1 + (2 + 3); + uint256 expr003 = 1 * 2 + 3; + uint256 expr004 = (1 * 2) + 3; + uint256 expr005 = 1 * (2 + 3); + uint256 expr006 = 1 + 2 * 3; + uint256 expr007 = (1 + 2) * 3; + uint256 expr008 = 1 + (2 * 3); + uint256 expr009 = 1**2**3; + uint256 expr010 = 1**(2**3); + uint256 expr011 = (1**2)**3; + uint256 expr012 = ++expr011 + 1; + bool expr013 = ++expr012 == expr011 - 1; + bool expr014 = ++(++expr013)--; + if (++batch.movesPerformed == drivers.length) createNewBatch(); + sum += getPrice( + ACCELERATE_STARTING_PRICE, + ACCELERATE_PER_PERIOD_DECREASE, + idleTicks, + actionsSold[ActionType.ACCELERATE] + i, + ACCELERATE_SELL_PER_TICK + ) / 1e18; + other += 1e18 + / getPrice( + ACCELERATE_STARTING_PRICE, + ACCELERATE_PER_PERIOD_DECREASE, + idleTicks, + actionsSold[ActionType.ACCELERATE] + i, + ACCELERATE_SELL_PER_TICK + ); + if ( + op == 0x54 // SLOAD + || op == 0x55 // SSTORE + || op == 0xF0 // CREATE + || op == 0xF1 // CALL + || op == 0xF2 // CALLCODE + || op == 0xF4 // DELEGATECALL + || op == 0xF5 // CREATE2 + || op == 0xFA // STATICCALL + || op == 0xFF // SELFDESTRUCT + ) { + return false; + } +} + +function test_nested() { + require( + keccak256(abi.encodePacked("some long string")) + == keccak256(abi.encodePacked("some other long string")), + "string mismatch" + ); + + state.zeroForOne = IERC20(Currency.unwrap(state.poolKey1.currency0)) + == IERC20(Currency.unwrap(state.poolKey0.curerncy1)); + + coreAddresses.evc == address(0) + && coreAddresses.protocolConfig == address(0) + && coreAddresses.sequenceRegistry == address(0) + && coreAddresses.balanceTracker == address(0) + && coreAddresses.permit2 == address(0); + + return spender == ownerOf(tokenId) || getApproved[tokenId] == spender + || isApprovedForAll[ownerOf(tokenId)][spender]; +} + +function new_y( + uint256 x, + uint256 dx, + uint256 x_basis, + uint256 y, + uint256 y_basis +) external pure returns (uint256) { + return _get_y( + x * _VELODROME_TOKEN_BASIS / x_basis, + dx * _VELODROME_TOKEN_BASIS / x_basis, + y * _VELODROME_TOKEN_BASIS / y_basis + ) * y_basis / _VELODROME_TOKEN_BASIS + * aReallyLongIdentifierThatMakesTheOperatorExpressionBreak; +} + +contract Repro { + bytes4 public constant MINIMAL_INTERFACE_ID = + this.calculateMinFeeWeiFor.selector ^ this.convertUSDFeeToWei.selector + ^ this.execute.selector ^ this.getMinFeeUSDFor.selector; + bool isTestnet = chainId == ARBITRUM_SEPOLIA || chainId == BASE_SEPOLIA + || chainId == MODE_SEPOLIA || chainId == OPTIMISM_SEPOLIA + || chainId == SEPOLIA; + + function test() { + assign = this.calculateMinFeeWeiFor.selector + ^ this.convertUSDFeeToWei.selector ^ this.execute.selector + ^ this.getMinFeeUSDFor.selector; + isMainnet = chainId == ABSTRACT || chainId == ARBITRUM + || chainId == AVALANCHE || chainId == BASE || chainId == BERACHAIN + || chainId == BLAST || chainId == BSC || chainId == CHILIZ + || chainId == COREDAO || chainId == ETHEREUM || chainId == GNOSIS + || chainId == HYPEREVM || chainId == LIGHTLINK || chainId == LINEA + || chainId == MODE || chainId == MORPH || chainId == OPTIMISM + || chainId == POLYGON || chainId == SCROLL || chainId == SEI + || chainId == SOPHON || chainId == SUPERSEED || chainId == SONIC + || chainId == UNICHAIN || chainId == XDC || chainId == ZKSYNC; + + callsGas += (3 * FixedPointMathLib.divUp(paramsLength, 32)) + + FixedPointMathLib.mulDivUp(paramsLength, paramsLength, 524_288); + } +} diff --git a/crates/fmt/testdata/Repros/fmt.sol b/crates/fmt/testdata/Repros/fmt.sol index 0a480c0b02bdf..9f11bfd91a113 100644 --- a/crates/fmt/testdata/Repros/fmt.sol +++ b/crates/fmt/testdata/Repros/fmt.sol @@ -1,5 +1,13 @@ // Repros of fmt issues +// https://github.com/foundry-rs/foundry/issues/7944 +import {ERC20} from "@contracts/token/ERC20/ERC20.sol"; +import {ERC20Permit} from "@contracts/token/ERC20/ext/ERC20Permit.sol"; +import {ERC20Burnable} from "@contracts/token/ERC20/ext/ERC20Burnable.sol"; +import {IERC20} from "@contracts/token/ERC20/IERC20.sol"; +import {IERC20Permit} from "@contracts/token/ERC20/ext/ERC20Permit.sol"; +import {AccessControl} from "@contracts/access/AccessControl.sol"; + // https://github.com/foundry-rs/foundry/issues/4403 function errorIdentifier() { bytes memory error = bytes(""); @@ -11,9 +19,7 @@ function one() external { this.other({ data: abi.encodeCall( this.other, - ( - "bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla" - ) + ("bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla") ) }); } @@ -48,15 +54,15 @@ contract TestContract { } function test1() public { - assembly{ sstore( 1, 1) /* inline comment*/ // forgefmt: disable-line + assembly { sstore( 1, 1) /* inline comment*/ // forgefmt: disable-line sstore(2, 2) } } function test2() public { - assembly{ sstore( 1, 1) // forgefmt: disable-line + assembly { sstore( 1, 1) // forgefmt: disable-line sstore(2, 2) - sstore(3, 3)// forgefmt: disable-line + sstore(3, 3) // forgefmt: disable-line sstore(4, 4) } } @@ -65,19 +71,19 @@ contract TestContract { // forgefmt: disable-next-line assembly{ sstore( 1, 1) sstore(2, 2) - sstore(3, 3)// forgefmt: disable-line + sstore(3, 3) // forgefmt: disable-line sstore(4, 4) - }// forgefmt: disable-line + } // forgefmt: disable-line } function test4() public { // forgefmt: disable-next-line - assembly{ + assembly { sstore(1, 1) sstore(2, 2) - sstore(3, 3)// forgefmt: disable-line + sstore(3, 3) // forgefmt: disable-line sstore(4, 4) - }// forgefmt: disable-line + } // forgefmt: disable-line if (condition) execute(); // comment7 } @@ -159,3 +165,308 @@ contract DbgFmtTest is Test { return 0; } } + +// https://github.com/foundry-rs/foundry/issues/8557 +// https://github.com/foundry-rs/foundry/issues/11249 +function argListRepro(address tokenIn, uint256 amountIn, bool data) { + maverickV2SwapCallback( + tokenIn, + amountIn, // forgefmt: disable-line + // forgefmt: disable-next-line + 0 /* we didn't bother loading `amountOut` because we don't use it */, + data + ); +} + +// https://github.com/foundry-rs/foundry/issues/11905 +function noBlanksLinesBeforeIdentifiers() public { + timelockController.grantRole(keccak256("EXECUTOR_ROLE"), address(0)); +} + +// https://github.com/foundry-rs/foundry/issues/11913 +function rustfmtBlankLinesInStmtBlocks() public { + if (someCondition) { + bar = true; + + emit Foo(bar); + } +} + +contract NestedCallsTest is Test { + string constant errMsg = "User provided message"; + uint256 constant maxDecimals = 77; + + Vm constant vm = Vm(HEVM_ADDRESS); + + function test_nestedCalls() public { + vm._expectCheatcodeRevert( + bytes(string.concat(errMsg, ": ", left, " != ", right)) + ); + } + + function test_assemblyFnComments() public { + assembly { + function setJPoint(i, x, y, z) { + // We will multiply by `0x80` (i.e. `shl(7, i)`) instead + // since the memory expansion costs are cheaper than doing `mul(0x60, i)`. + // Also help combine the lookup expression for `u1` and `u2` in `jMultShamir`. + i := shl(7, i) + mstore(i, x) + mstore(add(i, returndatasize()), y) + mstore(add(i, 0x40), z) + } + } + } + + function test_binOpsInsideNestedBlocks() public { + for (uint256 i = 0; i < steps.length; i++) { + if ( + step.opcode == 0x52 + && /*MSTORE*/ step.stack[0] == testContract.memPtr() // MSTORE offset + && step.stack[1] == testContract.expectedValueInMemory() // MSTORE val + ) { + mstoreCalled = true; + } + } + } +} + +contract ERC1967Factory { + /// @dev Returns a pointer to the initialization code of a proxy created via this factory. + function _initCode() internal view returns (bytes32 m) { + assembly { + /** + * -------------------------------------------------------------------------------------+ + * CREATION (9 bytes) | + * -------------------------------------------------------------------------------------| + * Opcode | Mnemonic | Stack | Memory | + * -------------------------------------------------------------------------------------| + * 60 runSize | PUSH1 runSize | r | | + * 3d | RETURNDATASIZE | 0 r | | + * 81 | DUP2 | r 0 r | | + * 60 offset | PUSH1 offset | o r 0 r | | + * 3d | RETURNDATASIZE | 0 o r 0 r | | + * 39 | CODECOPY | 0 r | [0..runSize): runtime code | + * f3 | RETURN | | [0..runSize): runtime code | + * -------------------------------------------------------------------------------------| + * RUNTIME (127 bytes) | + * -------------------------------------------------------------------------------------| + * Opcode | Mnemonic | Stack | Memory | + * -------------------------------------------------------------------------------------| + * | + * ::: keep some values in stack :::::::::::::::::::::::::::::::::::::::::::::::::::::: | + * 3d | RETURNDATASIZE | 0 | | + * 3d | RETURNDATASIZE | 0 0 | | + * | + * ::: check if caller is factory ::::::::::::::::::::::::::::::::::::::::::::::::::::: | + * 33 | CALLER | c 0 0 | | + * 73 factory | PUSH20 factory | f c 0 0 | | + * 14 | EQ | isf 0 0 | | + * 60 0x57 | PUSH1 0x57 | dest isf 0 0 | | + * 57 | JUMPI | 0 0 | | + * | + * ::: copy calldata to memory :::::::::::::::::::::::::::::::::::::::::::::::::::::::: | + * 36 | CALLDATASIZE | cds 0 0 | | + * 3d | RETURNDATASIZE | 0 cds 0 0 | | + * 3d | RETURNDATASIZE | 0 0 cds 0 0 | | + * 37 | CALLDATACOPY | 0 0 | [0..calldatasize): calldata | + * | + * ::: delegatecall to implementation ::::::::::::::::::::::::::::::::::::::::::::::::: | + * 36 | CALLDATASIZE | cds 0 0 | [0..calldatasize): calldata | + * 3d | RETURNDATASIZE | 0 cds 0 0 | [0..calldatasize): calldata | + * 7f slot | PUSH32 slot | s 0 cds 0 0 | [0..calldatasize): calldata | + * 54 | SLOAD | i 0 cds 0 0 | [0..calldatasize): calldata | + * 5a | GAS | g i 0 cds 0 0 | [0..calldatasize): calldata | + * f4 | DELEGATECALL | succ | [0..calldatasize): calldata | + * | + * ::: copy returndata to memory :::::::::::::::::::::::::::::::::::::::::::::::::::::: | + * 3d | RETURNDATASIZE | rds succ | [0..calldatasize): calldata | + * 60 0x00 | PUSH1 0x00 | 0 rds succ | [0..calldatasize): calldata | + * 80 | DUP1 | 0 0 rds succ | [0..calldatasize): calldata | + * 3e | RETURNDATACOPY | succ | [0..returndatasize): returndata | + * | + * ::: branch on delegatecall status :::::::::::::::::::::::::::::::::::::::::::::::::: | + * 60 0x52 | PUSH1 0x52 | dest succ | [0..returndatasize): returndata | + * 57 | JUMPI | | [0..returndatasize): returndata | + * | + * ::: delegatecall failed, revert :::::::::::::::::::::::::::::::::::::::::::::::::::: | + * 3d | RETURNDATASIZE | rds | [0..returndatasize): returndata | + * 60 0x00 | PUSH1 0x00 | 0 rds | [0..returndatasize): returndata | + * fd | REVERT | | [0..returndatasize): returndata | + * | + * ::: delegatecall succeeded, return ::::::::::::::::::::::::::::::::::::::::::::::::: | + * 5b | JUMPDEST | | [0..returndatasize): returndata | + * 3d | RETURNDATASIZE | rds | [0..returndatasize): returndata | + * 60 0x00 | PUSH1 0x00 | 0 rds | [0..returndatasize): returndata | + * f3 | RETURN | | [0..returndatasize): returndata | + * | + * ::: set new implementation (caller is factory) ::::::::::::::::::::::::::::::::::::: | + * 5b | JUMPDEST | 0 0 | | + * 3d | RETURNDATASIZE | 0 0 0 | | + * 35 | CALLDATALOAD | impl 0 0 | | + * 60 0x20 | PUSH1 0x20 | w impl 0 0 | | + * 35 | CALLDATALOAD | slot impl 0 0 | | + * 55 | SSTORE | 0 0 | | + * | + * ::: no extra calldata, return :::::::::::::::::::::::::::::::::::::::::::::::::::::: | + * 60 0x40 | PUSH1 0x40 | 2w 0 0 | | + * 80 | DUP1 | 2w 2w 0 0 | | + * 36 | CALLDATASIZE | cds 2w 2w 0 0 | | + * 11 | GT | gt 2w 0 0 | | + * 15 | ISZERO | lte 2w 0 0 | | + * 60 0x52 | PUSH1 0x52 | dest lte 2w 0 0 | | + * 57 | JUMPI | 2w 0 0 | | + * | + * ::: copy extra calldata to memory :::::::::::::::::::::::::::::::::::::::::::::::::: | + * 36 | CALLDATASIZE | cds 2w 0 0 | | + * 03 | SUB | t 0 0 | | + * 80 | DUP1 | t t 0 0 | | + * 60 0x40 | PUSH1 0x40 | 2w t t 0 0 | | + * 3d | RETURNDATASIZE | 0 2w t t 0 0 | | + * 37 | CALLDATACOPY | t 0 0 | [0..t): extra calldata | + * | + * ::: delegatecall to implementation ::::::::::::::::::::::::::::::::::::::::::::::::: | + * 3d | RETURNDATASIZE | 0 t 0 0 | [0..t): extra calldata | + * 3d | RETURNDATASIZE | 0 0 t 0 0 | [0..t): extra calldata | + * 35 | CALLDATALOAD | i 0 t 0 0 | [0..t): extra calldata | + * 5a | GAS | g i 0 t 0 0 | [0..t): extra calldata | + * f4 | DELEGATECALL | succ | [0..t): extra calldata | + * | + * ::: copy returndata to memory :::::::::::::::::::::::::::::::::::::::::::::::::::::: | + * 3d | RETURNDATASIZE | rds succ | [0..t): extra calldata | + * 60 0x00 | PUSH1 0x00 | 0 rds succ | [0..t): extra calldata | + * 80 | DUP1 | 0 0 rds succ | [0..t): extra calldata | + * 3e | RETURNDATACOPY | succ | [0..returndatasize): returndata | + * | + * ::: branch on delegatecall status :::::::::::::::::::::::::::::::::::::::::::::::::: | + * 60 0x52 | PUSH1 0x52 | dest succ | [0..returndatasize): returndata | + * 57 | JUMPI | | [0..returndatasize): returndata | + * | + * ::: delegatecall failed, revert :::::::::::::::::::::::::::::::::::::::::::::::::::: | + * 3d | RETURNDATASIZE | rds | [0..returndatasize): returndata | + * 60 0x00 | PUSH1 0x00 | 0 rds | [0..returndatasize): returndata | + * fd | REVERT | | [0..returndatasize): returndata | + * -------------------------------------------------------------------------------------+ + */ + m := mload(0x40) + // forgefmt: disable-start + switch shr(112, address()) + case 0 { + // If the factory's address has six or more leading zero bytes. + mstore(add(m, 0x75), 0x604c573d6000fd) // 7 + mstore(add(m, 0x6e), 0x3d3560203555604080361115604c5736038060403d373d3d355af43d6000803e) // 32 + mstore(add(m, 0x4e), 0x3735a920a3ca505d382bbc545af43d6000803e604c573d6000fd5b3d6000f35b) // 32 + mstore(add(m, 0x2e), 0x14605157363d3d37363d7f360894a13ba1a3210667c828492db98dca3e2076cc) // 32 + mstore(add(m, 0x0e), address()) // 14 + mstore(m, 0x60793d8160093d39f33d3d336d) // 9 + 4 + } + default { + mstore(add(m, 0x7b), 0x6052573d6000fd) // 7 + mstore(add(m, 0x74), 0x3d356020355560408036111560525736038060403d373d3d355af43d6000803e) // 32 + mstore(add(m, 0x54), 0x3735a920a3ca505d382bbc545af43d6000803e6052573d6000fd5b3d6000f35b) // 32 + mstore(add(m, 0x34), 0x14605757363d3d37363d7f360894a13ba1a3210667c828492db98dca3e2076cc) // 32 + mstore(add(m, 0x14), address()) // 20 + mstore(m, 0x607f3d8160093d39f33d3d3373) // 9 + 4 + } + // forgefmt: disable-end + } + } +} + +/// @title Wrapped Ether Hook +/// @notice Hook for wrapping/unwrapping ETH in Uniswap V4 pools +/// @dev Implements 1:1 wrapping/unwrapping of ETH to WETH +contract WETHHook is BaseTokenWrapperHook { + /// @notice The WETH9 contract + WETH public immutable weth; + + /// @notice Creates a new WETH wrapper hook + /// @param _manager The Uniswap V4 pool manager + /// @param _weth The WETH9 contract address + constructor(IPoolManager _manager, address payable _weth) + BaseTokenWrapperHook( + _manager, + Currency.wrap(_weth), // wrapper token is WETH + CurrencyLibrary.ADDRESS_ZERO // underlying token is ETH (address(0)) + ) + { + weth = WETH(payable(_weth)); + } +} + +// https://github.com/foundry-rs/foundry/issues/12529 +library TransferMessageLib { + struct TransferMessage { + bytes32 sender; + address receiver; + address token; + uint256 amount; + } + + function pack(TransferMessage memory m) + internal + pure + returns (bytes memory) + { + return ""; + } +} + +contract ChainedStructCall { + using TransferMessageLib for TransferMessageLib.TransferMessage; + bytes32 someBytes32Value; + address someAddressValue; + uint256 someUint256Value; + + function test_chainedStructIndentation() public { + bytes memory payload = TransferMessageLib.TransferMessage({ + sender: someBytes32Value, + receiver: someAddressValue, + token: someAddressValue, + amount: someUint256Value + }).pack(); + } +} + +// https://github.com/foundry-rs/foundry/issues/13362 +type V4PoolManager is address; +type Currency is address; + +interface IHooks {} + +struct PoolKey { + Currency currency0; + Currency currency1; + uint24 fee; + int24 tickSpacing; + IHooks hooks; +} + +interface IPoolManager { + function initialize(PoolKey memory key, uint160 sqrtPriceX96) + external + returns (int24); +} + +contract NestedNamedArgsInChainedCall { + function test_nestedNamedArgs( + V4PoolManager pool, + address currency0, + address currency1, + uint24 fee, + int24 tickSpacing, + uint160 sqrtPriceX96 + ) internal returns (int24 tick) { + tick = IPoolManager(V4PoolManager.unwrap(pool)) + .initialize( + PoolKey({ + currency0: Currency.wrap(currency0), + currency1: Currency.wrap(currency1), + fee: fee, + tickSpacing: tickSpacing, + hooks: IHooks(address(0)) + }), + sqrtPriceX96 + ); + } +} diff --git a/crates/fmt/testdata/Repros/original.sol b/crates/fmt/testdata/Repros/original.sol index 6f18784d39dea..cf2e963461753 100644 --- a/crates/fmt/testdata/Repros/original.sol +++ b/crates/fmt/testdata/Repros/original.sol @@ -1,5 +1,13 @@ // Repros of fmt issues +// https://github.com/foundry-rs/foundry/issues/7944 +import { ERC20 } from "@contracts/token/ERC20/ERC20.sol"; +import { ERC20Permit } from "@contracts/token/ERC20/ext/ERC20Permit.sol"; +import { ERC20Burnable } from "@contracts/token/ERC20/ext/ERC20Burnable.sol"; +import { IERC20 } from "@contracts/token/ERC20/IERC20.sol"; +import { IERC20Permit } from "@contracts/token/ERC20/ext/ERC20Permit.sol"; +import { AccessControl } from "@contracts/access/AccessControl.sol"; + // https://github.com/foundry-rs/foundry/issues/4403 function errorIdentifier() { bytes memory error = bytes(""); @@ -158,3 +166,274 @@ contract DbgFmtTest is Test { return 0; } } + +// https://github.com/foundry-rs/foundry/issues/8557 +// https://github.com/foundry-rs/foundry/issues/11249 +function argListRepro(address tokenIn, uint256 amountIn, bool data) { + maverickV2SwapCallback( + tokenIn, + amountIn, // forgefmt: disable-line + // forgefmt: disable-next-line + 0 /* we didn't bother loading `amountOut` because we don't use it */, + data + ); +} + +// https://github.com/foundry-rs/foundry/issues/11905 +function noBlanksLinesBeforeIdentifiers() public { + timelockController + + + + .grantRole(keccak256("EXECUTOR_ROLE"), address(0)); +} + +// https://github.com/foundry-rs/foundry/issues/11913 +function rustfmtBlankLinesInStmtBlocks() public { + if (someCondition) { + + + + bar = true; + + + + emit Foo(bar); + + + } +} + +contract NestedCallsTest is Test { + string constant errMsg = "User provided message"; + uint256 constant maxDecimals = 77; + + Vm constant vm = Vm(HEVM_ADDRESS); + + function test_nestedCalls() public { + vm._expectCheatcodeRevert( + bytes(string.concat(errMsg, ": ", left, " != ", right)) + ); + } + + function test_assemblyFnComments() public { + assembly { + function setJPoint(i, x, y, z) { + // We will multiply by `0x80` (i.e. `shl(7, i)`) instead + // since the memory expansion costs are cheaper than doing `mul(0x60, i)`. + // Also help combine the lookup expression for `u1` and `u2` in `jMultShamir`. + i := shl(7, i) + mstore(i, x) + mstore(add(i, returndatasize()), y) + mstore(add(i, 0x40), z) + } + } + } + + function test_binOpsInsideNestedBlocks() public { + for (uint256 i = 0; i < steps.length; i++) { + if ( + step.opcode == 0x52 + && /*MSTORE*/ step.stack[0] == testContract.memPtr() // MSTORE offset + && step.stack[1] == testContract.expectedValueInMemory() // MSTORE val + ) { + mstoreCalled = true; + } + } + } +} + +contract ERC1967Factory { + /// @dev Returns a pointer to the initialization code of a proxy created via this factory. + function _initCode() internal view returns (bytes32 m) { + assembly { + /** + * -------------------------------------------------------------------------------------+ + * CREATION (9 bytes) | + * -------------------------------------------------------------------------------------| + * Opcode | Mnemonic | Stack | Memory | + * -------------------------------------------------------------------------------------| + * 60 runSize | PUSH1 runSize | r | | + * 3d | RETURNDATASIZE | 0 r | | + * 81 | DUP2 | r 0 r | | + * 60 offset | PUSH1 offset | o r 0 r | | + * 3d | RETURNDATASIZE | 0 o r 0 r | | + * 39 | CODECOPY | 0 r | [0..runSize): runtime code | + * f3 | RETURN | | [0..runSize): runtime code | + * -------------------------------------------------------------------------------------| + * RUNTIME (127 bytes) | + * -------------------------------------------------------------------------------------| + * Opcode | Mnemonic | Stack | Memory | + * -------------------------------------------------------------------------------------| + * | + * ::: keep some values in stack :::::::::::::::::::::::::::::::::::::::::::::::::::::: | + * 3d | RETURNDATASIZE | 0 | | + * 3d | RETURNDATASIZE | 0 0 | | + * | + * ::: check if caller is factory ::::::::::::::::::::::::::::::::::::::::::::::::::::: | + * 33 | CALLER | c 0 0 | | + * 73 factory | PUSH20 factory | f c 0 0 | | + * 14 | EQ | isf 0 0 | | + * 60 0x57 | PUSH1 0x57 | dest isf 0 0 | | + * 57 | JUMPI | 0 0 | | + * | + * ::: copy calldata to memory :::::::::::::::::::::::::::::::::::::::::::::::::::::::: | + * 36 | CALLDATASIZE | cds 0 0 | | + * 3d | RETURNDATASIZE | 0 cds 0 0 | | + * 3d | RETURNDATASIZE | 0 0 cds 0 0 | | + * 37 | CALLDATACOPY | 0 0 | [0..calldatasize): calldata | + * | + * ::: delegatecall to implementation ::::::::::::::::::::::::::::::::::::::::::::::::: | + * 36 | CALLDATASIZE | cds 0 0 | [0..calldatasize): calldata | + * 3d | RETURNDATASIZE | 0 cds 0 0 | [0..calldatasize): calldata | + * 7f slot | PUSH32 slot | s 0 cds 0 0 | [0..calldatasize): calldata | + * 54 | SLOAD | i 0 cds 0 0 | [0..calldatasize): calldata | + * 5a | GAS | g i 0 cds 0 0 | [0..calldatasize): calldata | + * f4 | DELEGATECALL | succ | [0..calldatasize): calldata | + * | + * ::: copy returndata to memory :::::::::::::::::::::::::::::::::::::::::::::::::::::: | + * 3d | RETURNDATASIZE | rds succ | [0..calldatasize): calldata | + * 60 0x00 | PUSH1 0x00 | 0 rds succ | [0..calldatasize): calldata | + * 80 | DUP1 | 0 0 rds succ | [0..calldatasize): calldata | + * 3e | RETURNDATACOPY | succ | [0..returndatasize): returndata | + * | + * ::: branch on delegatecall status :::::::::::::::::::::::::::::::::::::::::::::::::: | + * 60 0x52 | PUSH1 0x52 | dest succ | [0..returndatasize): returndata | + * 57 | JUMPI | | [0..returndatasize): returndata | + * | + * ::: delegatecall failed, revert :::::::::::::::::::::::::::::::::::::::::::::::::::: | + * 3d | RETURNDATASIZE | rds | [0..returndatasize): returndata | + * 60 0x00 | PUSH1 0x00 | 0 rds | [0..returndatasize): returndata | + * fd | REVERT | | [0..returndatasize): returndata | + * | + * ::: delegatecall succeeded, return ::::::::::::::::::::::::::::::::::::::::::::::::: | + * 5b | JUMPDEST | | [0..returndatasize): returndata | + * 3d | RETURNDATASIZE | rds | [0..returndatasize): returndata | + * 60 0x00 | PUSH1 0x00 | 0 rds | [0..returndatasize): returndata | + * f3 | RETURN | | [0..returndatasize): returndata | + * | + * ::: set new implementation (caller is factory) ::::::::::::::::::::::::::::::::::::: | + * 5b | JUMPDEST | 0 0 | | + * 3d | RETURNDATASIZE | 0 0 0 | | + * 35 | CALLDATALOAD | impl 0 0 | | + * 60 0x20 | PUSH1 0x20 | w impl 0 0 | | + * 35 | CALLDATALOAD | slot impl 0 0 | | + * 55 | SSTORE | 0 0 | | + * | + * ::: no extra calldata, return :::::::::::::::::::::::::::::::::::::::::::::::::::::: | + * 60 0x40 | PUSH1 0x40 | 2w 0 0 | | + * 80 | DUP1 | 2w 2w 0 0 | | + * 36 | CALLDATASIZE | cds 2w 2w 0 0 | | + * 11 | GT | gt 2w 0 0 | | + * 15 | ISZERO | lte 2w 0 0 | | + * 60 0x52 | PUSH1 0x52 | dest lte 2w 0 0 | | + * 57 | JUMPI | 2w 0 0 | | + * | + * ::: copy extra calldata to memory :::::::::::::::::::::::::::::::::::::::::::::::::: | + * 36 | CALLDATASIZE | cds 2w 0 0 | | + * 03 | SUB | t 0 0 | | + * 80 | DUP1 | t t 0 0 | | + * 60 0x40 | PUSH1 0x40 | 2w t t 0 0 | | + * 3d | RETURNDATASIZE | 0 2w t t 0 0 | | + * 37 | CALLDATACOPY | t 0 0 | [0..t): extra calldata | + * | + * ::: delegatecall to implementation ::::::::::::::::::::::::::::::::::::::::::::::::: | + * 3d | RETURNDATASIZE | 0 t 0 0 | [0..t): extra calldata | + * 3d | RETURNDATASIZE | 0 0 t 0 0 | [0..t): extra calldata | + * 35 | CALLDATALOAD | i 0 t 0 0 | [0..t): extra calldata | + * 5a | GAS | g i 0 t 0 0 | [0..t): extra calldata | + * f4 | DELEGATECALL | succ | [0..t): extra calldata | + * | + * ::: copy returndata to memory :::::::::::::::::::::::::::::::::::::::::::::::::::::: | + * 3d | RETURNDATASIZE | rds succ | [0..t): extra calldata | + * 60 0x00 | PUSH1 0x00 | 0 rds succ | [0..t): extra calldata | + * 80 | DUP1 | 0 0 rds succ | [0..t): extra calldata | + * 3e | RETURNDATACOPY | succ | [0..returndatasize): returndata | + * | + * ::: branch on delegatecall status :::::::::::::::::::::::::::::::::::::::::::::::::: | + * 60 0x52 | PUSH1 0x52 | dest succ | [0..returndatasize): returndata | + * 57 | JUMPI | | [0..returndatasize): returndata | + * | + * ::: delegatecall failed, revert :::::::::::::::::::::::::::::::::::::::::::::::::::: | + * 3d | RETURNDATASIZE | rds | [0..returndatasize): returndata | + * 60 0x00 | PUSH1 0x00 | 0 rds | [0..returndatasize): returndata | + * fd | REVERT | | [0..returndatasize): returndata | + * -------------------------------------------------------------------------------------+ + */ + m := mload(0x40) + // forgefmt: disable-start + switch shr(112, address()) + case 0 { + // If the factory's address has six or more leading zero bytes. + mstore(add(m, 0x75), 0x604c573d6000fd) // 7 + mstore(add(m, 0x6e), 0x3d3560203555604080361115604c5736038060403d373d3d355af43d6000803e) // 32 + mstore(add(m, 0x4e), 0x3735a920a3ca505d382bbc545af43d6000803e604c573d6000fd5b3d6000f35b) // 32 + mstore(add(m, 0x2e), 0x14605157363d3d37363d7f360894a13ba1a3210667c828492db98dca3e2076cc) // 32 + mstore(add(m, 0x0e), address()) // 14 + mstore(m, 0x60793d8160093d39f33d3d336d) // 9 + 4 + } + default { + mstore(add(m, 0x7b), 0x6052573d6000fd) // 7 + mstore(add(m, 0x74), 0x3d356020355560408036111560525736038060403d373d3d355af43d6000803e) // 32 + mstore(add(m, 0x54), 0x3735a920a3ca505d382bbc545af43d6000803e6052573d6000fd5b3d6000f35b) // 32 + mstore(add(m, 0x34), 0x14605757363d3d37363d7f360894a13ba1a3210667c828492db98dca3e2076cc) // 32 + mstore(add(m, 0x14), address()) // 20 + mstore(m, 0x607f3d8160093d39f33d3d3373) // 9 + 4 + } + // forgefmt: disable-end + } + } +} + +/// @title Wrapped Ether Hook +/// @notice Hook for wrapping/unwrapping ETH in Uniswap V4 pools +/// @dev Implements 1:1 wrapping/unwrapping of ETH to WETH +contract WETHHook is BaseTokenWrapperHook { + /// @notice The WETH9 contract + WETH public immutable weth; + + /// @notice Creates a new WETH wrapper hook + /// @param _manager The Uniswap V4 pool manager + /// @param _weth The WETH9 contract address + constructor(IPoolManager _manager, address payable _weth) + BaseTokenWrapperHook( + _manager, + Currency.wrap(_weth), // wrapper token is WETH + CurrencyLibrary.ADDRESS_ZERO // underlying token is ETH (address(0)) + ) + { + weth = WETH(payable(_weth)); + } +} + +// https://github.com/foundry-rs/foundry/issues/12529 +library TransferMessageLib { + struct TransferMessage { bytes32 sender; address receiver; address token; uint256 amount; } + function pack(TransferMessage memory m) internal pure returns (bytes memory) { return ""; } +} + +contract ChainedStructCall { + using TransferMessageLib for TransferMessageLib.TransferMessage; + bytes32 someBytes32Value; + address someAddressValue; + uint256 someUint256Value; + + function test_chainedStructIndentation() public { + bytes memory payload = TransferMessageLib.TransferMessage({ + sender: someBytes32Value, receiver: someAddressValue, token: someAddressValue, amount: someUint256Value + }).pack(); + } +} + +// https://github.com/foundry-rs/foundry/issues/13362 +type V4PoolManager is address; +type Currency is address; +interface IHooks {} +struct PoolKey { Currency currency0; Currency currency1; uint24 fee; int24 tickSpacing; IHooks hooks; } +interface IPoolManager { function initialize(PoolKey memory key, uint160 sqrtPriceX96) external returns (int24); } + +contract NestedNamedArgsInChainedCall { + function test_nestedNamedArgs(V4PoolManager pool, address currency0, address currency1, uint24 fee, int24 tickSpacing, uint160 sqrtPriceX96) internal returns (int24 tick) { + tick = IPoolManager(V4PoolManager.unwrap(pool)).initialize(PoolKey({currency0: Currency.wrap(currency0), currency1: Currency.wrap(currency1), fee: fee, tickSpacing: tickSpacing, hooks: IHooks(address(0))}), sqrtPriceX96); + } +} diff --git a/crates/fmt/testdata/Repros/sorted.fmt.sol b/crates/fmt/testdata/Repros/sorted.fmt.sol new file mode 100644 index 0000000000000..30a569a162bb5 --- /dev/null +++ b/crates/fmt/testdata/Repros/sorted.fmt.sol @@ -0,0 +1,473 @@ +// config: sort_imports = true +// Repros of fmt issues + +// https://github.com/foundry-rs/foundry/issues/7944 +import {AccessControl} from "@contracts/access/AccessControl.sol"; +import {ERC20} from "@contracts/token/ERC20/ERC20.sol"; +import {IERC20} from "@contracts/token/ERC20/IERC20.sol"; +import {ERC20Burnable} from "@contracts/token/ERC20/ext/ERC20Burnable.sol"; +import {ERC20Permit} from "@contracts/token/ERC20/ext/ERC20Permit.sol"; +import {IERC20Permit} from "@contracts/token/ERC20/ext/ERC20Permit.sol"; + +// https://github.com/foundry-rs/foundry/issues/4403 +function errorIdentifier() { + bytes memory error = bytes(""); + if (error.length > 0) {} +} + +// https://github.com/foundry-rs/foundry/issues/7549 +function one() external { + this.other({ + data: abi.encodeCall( + this.other, + ("bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla") + ) + }); +} + +// https://github.com/foundry-rs/foundry/issues/3979 +contract Format { + bool public test; + + function testing(uint256 amount) public payable { + if ( + // This is a comment + msg.value == amount + ) { + test = true; + } else { + test = false; + } + + if ( + // Another one + block.timestamp >= amount + ) {} + } +} + +// https://github.com/foundry-rs/foundry/issues/3830 +contract TestContract { + function test(uint256 a) public { + if (a > 1) { + a = 2; + } // forgefmt: disable-line + } + + function test1() public { + assembly { sstore( 1, 1) /* inline comment*/ // forgefmt: disable-line + sstore(2, 2) + } + } + + function test2() public { + assembly { sstore( 1, 1) // forgefmt: disable-line + sstore(2, 2) + sstore(3, 3) // forgefmt: disable-line + sstore(4, 4) + } + } + + function test3() public { + // forgefmt: disable-next-line + assembly{ sstore( 1, 1) + sstore(2, 2) + sstore(3, 3) // forgefmt: disable-line + sstore(4, 4) + } // forgefmt: disable-line + } + + function test4() public { + // forgefmt: disable-next-line + assembly { + sstore(1, 1) + sstore(2, 2) + sstore(3, 3) // forgefmt: disable-line + sstore(4, 4) + } // forgefmt: disable-line + if (condition) execute(); // comment7 + } + + function test5() public { + assembly { sstore(0, 0) }// forgefmt: disable-line + } + + function test6() returns (bool) { // forgefmt: disable-line + if ( true ) { // forgefmt: disable-line + } + return true ; } // forgefmt: disable-line + + function test7() returns (bool) { // forgefmt: disable-line + if (true) { // forgefmt: disable-line + uint256 a = 1; // forgefmt: disable-line + } + return true; + } + + function test8() returns (bool) { // forgefmt: disable-line + if ( true ) { // forgefmt: disable-line + uint256 a = 1; + } else { + uint256 b = 1; // forgefmt: disable-line + } + return true; + } +} + +// https://github.com/foundry-rs/foundry/issues/5825 +library MyLib { + bytes32 private constant TYPE_HASH = keccak256( + // forgefmt: disable-start + "MyStruct(" + "uint8 myEnum," + "address myAddress" + ")" + // forgefmt: disable-end + ); + + bytes32 private constant TYPE_HASH_1 = keccak256( + "MyStruct(" "uint8 myEnum," "address myAddress" ")" // forgefmt: disable-line + ); + + // forgefmt: disable-start + bytes32 private constant TYPE_HASH_2 = keccak256( + "MyStruct(" + "uint8 myEnum," + "address myAddress" + ")" + ); + // forgefmt: disable-end +} + +contract IfElseTest { + function setNumber(uint256 newNumber) public { + number = newNumber; + if (newNumber = 1) { + number = 1; + } else if (newNumber = 2) { + // number = 2; + } else { + newNumber = 3; + } + } +} + +contract DbgFmtTest is Test { + function test_argsList() public { + uint256 result1 = internalNoArgs({}); + result2 = add({a: 1, b: 2}); + } + + function add(uint256 a, uint256 b) internal pure returns (uint256) { + return a + b; + } + + function internalNoArgs() internal pure returns (uint256) { + return 0; + } +} + +// https://github.com/foundry-rs/foundry/issues/8557 +// https://github.com/foundry-rs/foundry/issues/11249 +function argListRepro(address tokenIn, uint256 amountIn, bool data) { + maverickV2SwapCallback( + tokenIn, + amountIn, // forgefmt: disable-line + // forgefmt: disable-next-line + 0 /* we didn't bother loading `amountOut` because we don't use it */, + data + ); +} + +// https://github.com/foundry-rs/foundry/issues/11905 +function noBlanksLinesBeforeIdentifiers() public { + timelockController.grantRole(keccak256("EXECUTOR_ROLE"), address(0)); +} + +// https://github.com/foundry-rs/foundry/issues/11913 +function rustfmtBlankLinesInStmtBlocks() public { + if (someCondition) { + bar = true; + + emit Foo(bar); + } +} + +contract NestedCallsTest is Test { + string constant errMsg = "User provided message"; + uint256 constant maxDecimals = 77; + + Vm constant vm = Vm(HEVM_ADDRESS); + + function test_nestedCalls() public { + vm._expectCheatcodeRevert( + bytes(string.concat(errMsg, ": ", left, " != ", right)) + ); + } + + function test_assemblyFnComments() public { + assembly { + function setJPoint(i, x, y, z) { + // We will multiply by `0x80` (i.e. `shl(7, i)`) instead + // since the memory expansion costs are cheaper than doing `mul(0x60, i)`. + // Also help combine the lookup expression for `u1` and `u2` in `jMultShamir`. + i := shl(7, i) + mstore(i, x) + mstore(add(i, returndatasize()), y) + mstore(add(i, 0x40), z) + } + } + } + + function test_binOpsInsideNestedBlocks() public { + for (uint256 i = 0; i < steps.length; i++) { + if ( + step.opcode == 0x52 + && /*MSTORE*/ step.stack[0] == testContract.memPtr() // MSTORE offset + && step.stack[1] == testContract.expectedValueInMemory() // MSTORE val + ) { + mstoreCalled = true; + } + } + } +} + +contract ERC1967Factory { + /// @dev Returns a pointer to the initialization code of a proxy created via this factory. + function _initCode() internal view returns (bytes32 m) { + assembly { + /** + * -------------------------------------------------------------------------------------+ + * CREATION (9 bytes) | + * -------------------------------------------------------------------------------------| + * Opcode | Mnemonic | Stack | Memory | + * -------------------------------------------------------------------------------------| + * 60 runSize | PUSH1 runSize | r | | + * 3d | RETURNDATASIZE | 0 r | | + * 81 | DUP2 | r 0 r | | + * 60 offset | PUSH1 offset | o r 0 r | | + * 3d | RETURNDATASIZE | 0 o r 0 r | | + * 39 | CODECOPY | 0 r | [0..runSize): runtime code | + * f3 | RETURN | | [0..runSize): runtime code | + * -------------------------------------------------------------------------------------| + * RUNTIME (127 bytes) | + * -------------------------------------------------------------------------------------| + * Opcode | Mnemonic | Stack | Memory | + * -------------------------------------------------------------------------------------| + * | + * ::: keep some values in stack :::::::::::::::::::::::::::::::::::::::::::::::::::::: | + * 3d | RETURNDATASIZE | 0 | | + * 3d | RETURNDATASIZE | 0 0 | | + * | + * ::: check if caller is factory ::::::::::::::::::::::::::::::::::::::::::::::::::::: | + * 33 | CALLER | c 0 0 | | + * 73 factory | PUSH20 factory | f c 0 0 | | + * 14 | EQ | isf 0 0 | | + * 60 0x57 | PUSH1 0x57 | dest isf 0 0 | | + * 57 | JUMPI | 0 0 | | + * | + * ::: copy calldata to memory :::::::::::::::::::::::::::::::::::::::::::::::::::::::: | + * 36 | CALLDATASIZE | cds 0 0 | | + * 3d | RETURNDATASIZE | 0 cds 0 0 | | + * 3d | RETURNDATASIZE | 0 0 cds 0 0 | | + * 37 | CALLDATACOPY | 0 0 | [0..calldatasize): calldata | + * | + * ::: delegatecall to implementation ::::::::::::::::::::::::::::::::::::::::::::::::: | + * 36 | CALLDATASIZE | cds 0 0 | [0..calldatasize): calldata | + * 3d | RETURNDATASIZE | 0 cds 0 0 | [0..calldatasize): calldata | + * 7f slot | PUSH32 slot | s 0 cds 0 0 | [0..calldatasize): calldata | + * 54 | SLOAD | i 0 cds 0 0 | [0..calldatasize): calldata | + * 5a | GAS | g i 0 cds 0 0 | [0..calldatasize): calldata | + * f4 | DELEGATECALL | succ | [0..calldatasize): calldata | + * | + * ::: copy returndata to memory :::::::::::::::::::::::::::::::::::::::::::::::::::::: | + * 3d | RETURNDATASIZE | rds succ | [0..calldatasize): calldata | + * 60 0x00 | PUSH1 0x00 | 0 rds succ | [0..calldatasize): calldata | + * 80 | DUP1 | 0 0 rds succ | [0..calldatasize): calldata | + * 3e | RETURNDATACOPY | succ | [0..returndatasize): returndata | + * | + * ::: branch on delegatecall status :::::::::::::::::::::::::::::::::::::::::::::::::: | + * 60 0x52 | PUSH1 0x52 | dest succ | [0..returndatasize): returndata | + * 57 | JUMPI | | [0..returndatasize): returndata | + * | + * ::: delegatecall failed, revert :::::::::::::::::::::::::::::::::::::::::::::::::::: | + * 3d | RETURNDATASIZE | rds | [0..returndatasize): returndata | + * 60 0x00 | PUSH1 0x00 | 0 rds | [0..returndatasize): returndata | + * fd | REVERT | | [0..returndatasize): returndata | + * | + * ::: delegatecall succeeded, return ::::::::::::::::::::::::::::::::::::::::::::::::: | + * 5b | JUMPDEST | | [0..returndatasize): returndata | + * 3d | RETURNDATASIZE | rds | [0..returndatasize): returndata | + * 60 0x00 | PUSH1 0x00 | 0 rds | [0..returndatasize): returndata | + * f3 | RETURN | | [0..returndatasize): returndata | + * | + * ::: set new implementation (caller is factory) ::::::::::::::::::::::::::::::::::::: | + * 5b | JUMPDEST | 0 0 | | + * 3d | RETURNDATASIZE | 0 0 0 | | + * 35 | CALLDATALOAD | impl 0 0 | | + * 60 0x20 | PUSH1 0x20 | w impl 0 0 | | + * 35 | CALLDATALOAD | slot impl 0 0 | | + * 55 | SSTORE | 0 0 | | + * | + * ::: no extra calldata, return :::::::::::::::::::::::::::::::::::::::::::::::::::::: | + * 60 0x40 | PUSH1 0x40 | 2w 0 0 | | + * 80 | DUP1 | 2w 2w 0 0 | | + * 36 | CALLDATASIZE | cds 2w 2w 0 0 | | + * 11 | GT | gt 2w 0 0 | | + * 15 | ISZERO | lte 2w 0 0 | | + * 60 0x52 | PUSH1 0x52 | dest lte 2w 0 0 | | + * 57 | JUMPI | 2w 0 0 | | + * | + * ::: copy extra calldata to memory :::::::::::::::::::::::::::::::::::::::::::::::::: | + * 36 | CALLDATASIZE | cds 2w 0 0 | | + * 03 | SUB | t 0 0 | | + * 80 | DUP1 | t t 0 0 | | + * 60 0x40 | PUSH1 0x40 | 2w t t 0 0 | | + * 3d | RETURNDATASIZE | 0 2w t t 0 0 | | + * 37 | CALLDATACOPY | t 0 0 | [0..t): extra calldata | + * | + * ::: delegatecall to implementation ::::::::::::::::::::::::::::::::::::::::::::::::: | + * 3d | RETURNDATASIZE | 0 t 0 0 | [0..t): extra calldata | + * 3d | RETURNDATASIZE | 0 0 t 0 0 | [0..t): extra calldata | + * 35 | CALLDATALOAD | i 0 t 0 0 | [0..t): extra calldata | + * 5a | GAS | g i 0 t 0 0 | [0..t): extra calldata | + * f4 | DELEGATECALL | succ | [0..t): extra calldata | + * | + * ::: copy returndata to memory :::::::::::::::::::::::::::::::::::::::::::::::::::::: | + * 3d | RETURNDATASIZE | rds succ | [0..t): extra calldata | + * 60 0x00 | PUSH1 0x00 | 0 rds succ | [0..t): extra calldata | + * 80 | DUP1 | 0 0 rds succ | [0..t): extra calldata | + * 3e | RETURNDATACOPY | succ | [0..returndatasize): returndata | + * | + * ::: branch on delegatecall status :::::::::::::::::::::::::::::::::::::::::::::::::: | + * 60 0x52 | PUSH1 0x52 | dest succ | [0..returndatasize): returndata | + * 57 | JUMPI | | [0..returndatasize): returndata | + * | + * ::: delegatecall failed, revert :::::::::::::::::::::::::::::::::::::::::::::::::::: | + * 3d | RETURNDATASIZE | rds | [0..returndatasize): returndata | + * 60 0x00 | PUSH1 0x00 | 0 rds | [0..returndatasize): returndata | + * fd | REVERT | | [0..returndatasize): returndata | + * -------------------------------------------------------------------------------------+ + */ + m := mload(0x40) + // forgefmt: disable-start + switch shr(112, address()) + case 0 { + // If the factory's address has six or more leading zero bytes. + mstore(add(m, 0x75), 0x604c573d6000fd) // 7 + mstore(add(m, 0x6e), 0x3d3560203555604080361115604c5736038060403d373d3d355af43d6000803e) // 32 + mstore(add(m, 0x4e), 0x3735a920a3ca505d382bbc545af43d6000803e604c573d6000fd5b3d6000f35b) // 32 + mstore(add(m, 0x2e), 0x14605157363d3d37363d7f360894a13ba1a3210667c828492db98dca3e2076cc) // 32 + mstore(add(m, 0x0e), address()) // 14 + mstore(m, 0x60793d8160093d39f33d3d336d) // 9 + 4 + } + default { + mstore(add(m, 0x7b), 0x6052573d6000fd) // 7 + mstore(add(m, 0x74), 0x3d356020355560408036111560525736038060403d373d3d355af43d6000803e) // 32 + mstore(add(m, 0x54), 0x3735a920a3ca505d382bbc545af43d6000803e6052573d6000fd5b3d6000f35b) // 32 + mstore(add(m, 0x34), 0x14605757363d3d37363d7f360894a13ba1a3210667c828492db98dca3e2076cc) // 32 + mstore(add(m, 0x14), address()) // 20 + mstore(m, 0x607f3d8160093d39f33d3d3373) // 9 + 4 + } + // forgefmt: disable-end + } + } +} + +/// @title Wrapped Ether Hook +/// @notice Hook for wrapping/unwrapping ETH in Uniswap V4 pools +/// @dev Implements 1:1 wrapping/unwrapping of ETH to WETH +contract WETHHook is BaseTokenWrapperHook { + /// @notice The WETH9 contract + WETH public immutable weth; + + /// @notice Creates a new WETH wrapper hook + /// @param _manager The Uniswap V4 pool manager + /// @param _weth The WETH9 contract address + constructor(IPoolManager _manager, address payable _weth) + BaseTokenWrapperHook( + _manager, + Currency.wrap(_weth), // wrapper token is WETH + CurrencyLibrary.ADDRESS_ZERO // underlying token is ETH (address(0)) + ) + { + weth = WETH(payable(_weth)); + } +} + +// https://github.com/foundry-rs/foundry/issues/12529 +library TransferMessageLib { + struct TransferMessage { + bytes32 sender; + address receiver; + address token; + uint256 amount; + } + + function pack(TransferMessage memory m) + internal + pure + returns (bytes memory) + { + return ""; + } +} + +contract ChainedStructCall { + using TransferMessageLib for TransferMessageLib.TransferMessage; + bytes32 someBytes32Value; + address someAddressValue; + uint256 someUint256Value; + + function test_chainedStructIndentation() public { + bytes memory payload = TransferMessageLib.TransferMessage({ + sender: someBytes32Value, + receiver: someAddressValue, + token: someAddressValue, + amount: someUint256Value + }).pack(); + } +} + +// https://github.com/foundry-rs/foundry/issues/13362 +type V4PoolManager is address; +type Currency is address; + +interface IHooks {} + +struct PoolKey { + Currency currency0; + Currency currency1; + uint24 fee; + int24 tickSpacing; + IHooks hooks; +} + +interface IPoolManager { + function initialize(PoolKey memory key, uint160 sqrtPriceX96) + external + returns (int24); +} + +contract NestedNamedArgsInChainedCall { + function test_nestedNamedArgs( + V4PoolManager pool, + address currency0, + address currency1, + uint24 fee, + int24 tickSpacing, + uint160 sqrtPriceX96 + ) internal returns (int24 tick) { + tick = IPoolManager(V4PoolManager.unwrap(pool)) + .initialize( + PoolKey({ + currency0: Currency.wrap(currency0), + currency1: Currency.wrap(currency1), + fee: fee, + tickSpacing: tickSpacing, + hooks: IHooks(address(0)) + }), + sqrtPriceX96 + ); + } +} diff --git a/crates/fmt/testdata/Repros/tab.fmt.sol b/crates/fmt/testdata/Repros/tab.fmt.sol index 565768269e1ea..44d4f5a7a670a 100644 --- a/crates/fmt/testdata/Repros/tab.fmt.sol +++ b/crates/fmt/testdata/Repros/tab.fmt.sol @@ -1,6 +1,14 @@ // config: style = "tab" // Repros of fmt issues +// https://github.com/foundry-rs/foundry/issues/7944 +import {ERC20} from "@contracts/token/ERC20/ERC20.sol"; +import {ERC20Permit} from "@contracts/token/ERC20/ext/ERC20Permit.sol"; +import {ERC20Burnable} from "@contracts/token/ERC20/ext/ERC20Burnable.sol"; +import {IERC20} from "@contracts/token/ERC20/IERC20.sol"; +import {IERC20Permit} from "@contracts/token/ERC20/ext/ERC20Permit.sol"; +import {AccessControl} from "@contracts/access/AccessControl.sol"; + // https://github.com/foundry-rs/foundry/issues/4403 function errorIdentifier() { bytes memory error = bytes(""); @@ -12,9 +20,7 @@ function one() external { this.other({ data: abi.encodeCall( this.other, - ( - "bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla" - ) + ("bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla bla") ) }); } @@ -45,40 +51,40 @@ contract TestContract { function test(uint256 a) public { if (a > 1) { a = 2; - } // forgefmt: disable-line + } // forgefmt: disable-line } function test1() public { - assembly{ sstore( 1, 1) /* inline comment*/ // forgefmt: disable-line + assembly { sstore( 1, 1) /* inline comment*/ // forgefmt: disable-line sstore(2, 2) } } function test2() public { - assembly{ sstore( 1, 1) // forgefmt: disable-line + assembly { sstore( 1, 1) // forgefmt: disable-line sstore(2, 2) - sstore(3, 3)// forgefmt: disable-line + sstore(3, 3) // forgefmt: disable-line sstore(4, 4) } } function test3() public { // forgefmt: disable-next-line - assembly{ sstore( 1, 1) + assembly{ sstore( 1, 1) sstore(2, 2) - sstore(3, 3)// forgefmt: disable-line + sstore(3, 3) // forgefmt: disable-line sstore(4, 4) - }// forgefmt: disable-line + } // forgefmt: disable-line } function test4() public { // forgefmt: disable-next-line - assembly{ + assembly { sstore(1, 1) sstore(2, 2) - sstore(3, 3)// forgefmt: disable-line + sstore(3, 3) // forgefmt: disable-line sstore(4, 4) - }// forgefmt: disable-line + } // forgefmt: disable-line if (condition) execute(); // comment7 } @@ -89,7 +95,7 @@ contract TestContract { function test6() returns (bool) { // forgefmt: disable-line if ( true ) { // forgefmt: disable-line } - return true ; } // forgefmt: disable-line + return true ; } // forgefmt: disable-line function test7() returns (bool) { // forgefmt: disable-line if (true) { // forgefmt: disable-line @@ -99,7 +105,7 @@ contract TestContract { } function test8() returns (bool) { // forgefmt: disable-line - if ( true ) { // forgefmt: disable-line + if ( true ) { // forgefmt: disable-line uint256 a = 1; } else { uint256 b = 1; // forgefmt: disable-line @@ -113,10 +119,10 @@ library MyLib { bytes32 private constant TYPE_HASH = keccak256( // forgefmt: disable-start "MyStruct(" - "uint8 myEnum," - "address myAddress" - ")" - // forgefmt: disable-end + "uint8 myEnum," + "address myAddress" + ")" + // forgefmt: disable-end ); bytes32 private constant TYPE_HASH_1 = keccak256( @@ -124,13 +130,13 @@ library MyLib { ); // forgefmt: disable-start - bytes32 private constant TYPE_HASH_2 = keccak256( - "MyStruct(" - "uint8 myEnum," - "address myAddress" - ")" - ); - // forgefmt: disable-end + bytes32 private constant TYPE_HASH_2 = keccak256( + "MyStruct(" + "uint8 myEnum," + "address myAddress" + ")" + ); + // forgefmt: disable-end } contract IfElseTest { @@ -160,3 +166,308 @@ contract DbgFmtTest is Test { return 0; } } + +// https://github.com/foundry-rs/foundry/issues/8557 +// https://github.com/foundry-rs/foundry/issues/11249 +function argListRepro(address tokenIn, uint256 amountIn, bool data) { + maverickV2SwapCallback( + tokenIn, + amountIn, // forgefmt: disable-line + // forgefmt: disable-next-line + 0 /* we didn't bother loading `amountOut` because we don't use it */, + data + ); +} + +// https://github.com/foundry-rs/foundry/issues/11905 +function noBlanksLinesBeforeIdentifiers() public { + timelockController.grantRole(keccak256("EXECUTOR_ROLE"), address(0)); +} + +// https://github.com/foundry-rs/foundry/issues/11913 +function rustfmtBlankLinesInStmtBlocks() public { + if (someCondition) { + bar = true; + + emit Foo(bar); + } +} + +contract NestedCallsTest is Test { + string constant errMsg = "User provided message"; + uint256 constant maxDecimals = 77; + + Vm constant vm = Vm(HEVM_ADDRESS); + + function test_nestedCalls() public { + vm._expectCheatcodeRevert( + bytes(string.concat(errMsg, ": ", left, " != ", right)) + ); + } + + function test_assemblyFnComments() public { + assembly { + function setJPoint(i, x, y, z) { + // We will multiply by `0x80` (i.e. `shl(7, i)`) instead + // since the memory expansion costs are cheaper than doing `mul(0x60, i)`. + // Also help combine the lookup expression for `u1` and `u2` in `jMultShamir`. + i := shl(7, i) + mstore(i, x) + mstore(add(i, returndatasize()), y) + mstore(add(i, 0x40), z) + } + } + } + + function test_binOpsInsideNestedBlocks() public { + for (uint256 i = 0; i < steps.length; i++) { + if ( + step.opcode == 0x52 + && /*MSTORE*/ step.stack[0] == testContract.memPtr() // MSTORE offset + && step.stack[1] == testContract.expectedValueInMemory() // MSTORE val + ) { + mstoreCalled = true; + } + } + } +} + +contract ERC1967Factory { + /// @dev Returns a pointer to the initialization code of a proxy created via this factory. + function _initCode() internal view returns (bytes32 m) { + assembly { + /** + * -------------------------------------------------------------------------------------+ + * CREATION (9 bytes) | + * -------------------------------------------------------------------------------------| + * Opcode | Mnemonic | Stack | Memory | + * -------------------------------------------------------------------------------------| + * 60 runSize | PUSH1 runSize | r | | + * 3d | RETURNDATASIZE | 0 r | | + * 81 | DUP2 | r 0 r | | + * 60 offset | PUSH1 offset | o r 0 r | | + * 3d | RETURNDATASIZE | 0 o r 0 r | | + * 39 | CODECOPY | 0 r | [0..runSize): runtime code | + * f3 | RETURN | | [0..runSize): runtime code | + * -------------------------------------------------------------------------------------| + * RUNTIME (127 bytes) | + * -------------------------------------------------------------------------------------| + * Opcode | Mnemonic | Stack | Memory | + * -------------------------------------------------------------------------------------| + * | + * ::: keep some values in stack :::::::::::::::::::::::::::::::::::::::::::::::::::::: | + * 3d | RETURNDATASIZE | 0 | | + * 3d | RETURNDATASIZE | 0 0 | | + * | + * ::: check if caller is factory ::::::::::::::::::::::::::::::::::::::::::::::::::::: | + * 33 | CALLER | c 0 0 | | + * 73 factory | PUSH20 factory | f c 0 0 | | + * 14 | EQ | isf 0 0 | | + * 60 0x57 | PUSH1 0x57 | dest isf 0 0 | | + * 57 | JUMPI | 0 0 | | + * | + * ::: copy calldata to memory :::::::::::::::::::::::::::::::::::::::::::::::::::::::: | + * 36 | CALLDATASIZE | cds 0 0 | | + * 3d | RETURNDATASIZE | 0 cds 0 0 | | + * 3d | RETURNDATASIZE | 0 0 cds 0 0 | | + * 37 | CALLDATACOPY | 0 0 | [0..calldatasize): calldata | + * | + * ::: delegatecall to implementation ::::::::::::::::::::::::::::::::::::::::::::::::: | + * 36 | CALLDATASIZE | cds 0 0 | [0..calldatasize): calldata | + * 3d | RETURNDATASIZE | 0 cds 0 0 | [0..calldatasize): calldata | + * 7f slot | PUSH32 slot | s 0 cds 0 0 | [0..calldatasize): calldata | + * 54 | SLOAD | i 0 cds 0 0 | [0..calldatasize): calldata | + * 5a | GAS | g i 0 cds 0 0 | [0..calldatasize): calldata | + * f4 | DELEGATECALL | succ | [0..calldatasize): calldata | + * | + * ::: copy returndata to memory :::::::::::::::::::::::::::::::::::::::::::::::::::::: | + * 3d | RETURNDATASIZE | rds succ | [0..calldatasize): calldata | + * 60 0x00 | PUSH1 0x00 | 0 rds succ | [0..calldatasize): calldata | + * 80 | DUP1 | 0 0 rds succ | [0..calldatasize): calldata | + * 3e | RETURNDATACOPY | succ | [0..returndatasize): returndata | + * | + * ::: branch on delegatecall status :::::::::::::::::::::::::::::::::::::::::::::::::: | + * 60 0x52 | PUSH1 0x52 | dest succ | [0..returndatasize): returndata | + * 57 | JUMPI | | [0..returndatasize): returndata | + * | + * ::: delegatecall failed, revert :::::::::::::::::::::::::::::::::::::::::::::::::::: | + * 3d | RETURNDATASIZE | rds | [0..returndatasize): returndata | + * 60 0x00 | PUSH1 0x00 | 0 rds | [0..returndatasize): returndata | + * fd | REVERT | | [0..returndatasize): returndata | + * | + * ::: delegatecall succeeded, return ::::::::::::::::::::::::::::::::::::::::::::::::: | + * 5b | JUMPDEST | | [0..returndatasize): returndata | + * 3d | RETURNDATASIZE | rds | [0..returndatasize): returndata | + * 60 0x00 | PUSH1 0x00 | 0 rds | [0..returndatasize): returndata | + * f3 | RETURN | | [0..returndatasize): returndata | + * | + * ::: set new implementation (caller is factory) ::::::::::::::::::::::::::::::::::::: | + * 5b | JUMPDEST | 0 0 | | + * 3d | RETURNDATASIZE | 0 0 0 | | + * 35 | CALLDATALOAD | impl 0 0 | | + * 60 0x20 | PUSH1 0x20 | w impl 0 0 | | + * 35 | CALLDATALOAD | slot impl 0 0 | | + * 55 | SSTORE | 0 0 | | + * | + * ::: no extra calldata, return :::::::::::::::::::::::::::::::::::::::::::::::::::::: | + * 60 0x40 | PUSH1 0x40 | 2w 0 0 | | + * 80 | DUP1 | 2w 2w 0 0 | | + * 36 | CALLDATASIZE | cds 2w 2w 0 0 | | + * 11 | GT | gt 2w 0 0 | | + * 15 | ISZERO | lte 2w 0 0 | | + * 60 0x52 | PUSH1 0x52 | dest lte 2w 0 0 | | + * 57 | JUMPI | 2w 0 0 | | + * | + * ::: copy extra calldata to memory :::::::::::::::::::::::::::::::::::::::::::::::::: | + * 36 | CALLDATASIZE | cds 2w 0 0 | | + * 03 | SUB | t 0 0 | | + * 80 | DUP1 | t t 0 0 | | + * 60 0x40 | PUSH1 0x40 | 2w t t 0 0 | | + * 3d | RETURNDATASIZE | 0 2w t t 0 0 | | + * 37 | CALLDATACOPY | t 0 0 | [0..t): extra calldata | + * | + * ::: delegatecall to implementation ::::::::::::::::::::::::::::::::::::::::::::::::: | + * 3d | RETURNDATASIZE | 0 t 0 0 | [0..t): extra calldata | + * 3d | RETURNDATASIZE | 0 0 t 0 0 | [0..t): extra calldata | + * 35 | CALLDATALOAD | i 0 t 0 0 | [0..t): extra calldata | + * 5a | GAS | g i 0 t 0 0 | [0..t): extra calldata | + * f4 | DELEGATECALL | succ | [0..t): extra calldata | + * | + * ::: copy returndata to memory :::::::::::::::::::::::::::::::::::::::::::::::::::::: | + * 3d | RETURNDATASIZE | rds succ | [0..t): extra calldata | + * 60 0x00 | PUSH1 0x00 | 0 rds succ | [0..t): extra calldata | + * 80 | DUP1 | 0 0 rds succ | [0..t): extra calldata | + * 3e | RETURNDATACOPY | succ | [0..returndatasize): returndata | + * | + * ::: branch on delegatecall status :::::::::::::::::::::::::::::::::::::::::::::::::: | + * 60 0x52 | PUSH1 0x52 | dest succ | [0..returndatasize): returndata | + * 57 | JUMPI | | [0..returndatasize): returndata | + * | + * ::: delegatecall failed, revert :::::::::::::::::::::::::::::::::::::::::::::::::::: | + * 3d | RETURNDATASIZE | rds | [0..returndatasize): returndata | + * 60 0x00 | PUSH1 0x00 | 0 rds | [0..returndatasize): returndata | + * fd | REVERT | | [0..returndatasize): returndata | + * -------------------------------------------------------------------------------------+ + */ + m := mload(0x40) + // forgefmt: disable-start + switch shr(112, address()) + case 0 { + // If the factory's address has six or more leading zero bytes. + mstore(add(m, 0x75), 0x604c573d6000fd) // 7 + mstore(add(m, 0x6e), 0x3d3560203555604080361115604c5736038060403d373d3d355af43d6000803e) // 32 + mstore(add(m, 0x4e), 0x3735a920a3ca505d382bbc545af43d6000803e604c573d6000fd5b3d6000f35b) // 32 + mstore(add(m, 0x2e), 0x14605157363d3d37363d7f360894a13ba1a3210667c828492db98dca3e2076cc) // 32 + mstore(add(m, 0x0e), address()) // 14 + mstore(m, 0x60793d8160093d39f33d3d336d) // 9 + 4 + } + default { + mstore(add(m, 0x7b), 0x6052573d6000fd) // 7 + mstore(add(m, 0x74), 0x3d356020355560408036111560525736038060403d373d3d355af43d6000803e) // 32 + mstore(add(m, 0x54), 0x3735a920a3ca505d382bbc545af43d6000803e6052573d6000fd5b3d6000f35b) // 32 + mstore(add(m, 0x34), 0x14605757363d3d37363d7f360894a13ba1a3210667c828492db98dca3e2076cc) // 32 + mstore(add(m, 0x14), address()) // 20 + mstore(m, 0x607f3d8160093d39f33d3d3373) // 9 + 4 + } + // forgefmt: disable-end + } + } +} + +/// @title Wrapped Ether Hook +/// @notice Hook for wrapping/unwrapping ETH in Uniswap V4 pools +/// @dev Implements 1:1 wrapping/unwrapping of ETH to WETH +contract WETHHook is BaseTokenWrapperHook { + /// @notice The WETH9 contract + WETH public immutable weth; + + /// @notice Creates a new WETH wrapper hook + /// @param _manager The Uniswap V4 pool manager + /// @param _weth The WETH9 contract address + constructor(IPoolManager _manager, address payable _weth) + BaseTokenWrapperHook( + _manager, + Currency.wrap(_weth), // wrapper token is WETH + CurrencyLibrary.ADDRESS_ZERO // underlying token is ETH (address(0)) + ) + { + weth = WETH(payable(_weth)); + } +} + +// https://github.com/foundry-rs/foundry/issues/12529 +library TransferMessageLib { + struct TransferMessage { + bytes32 sender; + address receiver; + address token; + uint256 amount; + } + + function pack(TransferMessage memory m) + internal + pure + returns (bytes memory) + { + return ""; + } +} + +contract ChainedStructCall { + using TransferMessageLib for TransferMessageLib.TransferMessage; + bytes32 someBytes32Value; + address someAddressValue; + uint256 someUint256Value; + + function test_chainedStructIndentation() public { + bytes memory payload = TransferMessageLib.TransferMessage({ + sender: someBytes32Value, + receiver: someAddressValue, + token: someAddressValue, + amount: someUint256Value + }).pack(); + } +} + +// https://github.com/foundry-rs/foundry/issues/13362 +type V4PoolManager is address; +type Currency is address; + +interface IHooks {} + +struct PoolKey { + Currency currency0; + Currency currency1; + uint24 fee; + int24 tickSpacing; + IHooks hooks; +} + +interface IPoolManager { + function initialize(PoolKey memory key, uint160 sqrtPriceX96) + external + returns (int24); +} + +contract NestedNamedArgsInChainedCall { + function test_nestedNamedArgs( + V4PoolManager pool, + address currency0, + address currency1, + uint24 fee, + int24 tickSpacing, + uint160 sqrtPriceX96 + ) internal returns (int24 tick) { + tick = IPoolManager(V4PoolManager.unwrap(pool)) + .initialize( + PoolKey({ + currency0: Currency.wrap(currency0), + currency1: Currency.wrap(currency1), + fee: fee, + tickSpacing: tickSpacing, + hooks: IHooks(address(0)) + }), + sqrtPriceX96 + ); + } +} diff --git a/crates/fmt/testdata/ReprosCalls/110.fmt.sol b/crates/fmt/testdata/ReprosCalls/110.fmt.sol new file mode 100644 index 0000000000000..cb1c128f523db --- /dev/null +++ b/crates/fmt/testdata/ReprosCalls/110.fmt.sol @@ -0,0 +1,167 @@ +// config: line_length = 110 +function repros() public { + require( + keccak256(abi.encodePacked("this is a long string")) + == keccak256(abi.encodePacked("some other long string")), + "string mismatch" + ); + + address lerp = + LerpFactoryLike(lerpFab()).newLerp(_name, _target, _what, _startTime, _start, _end, _duration); + + (oracleRouter, eVault) = execute( + oracleRouterFactory, deployRouterForOracle, eVaultFactory, upgradable, asset, oracle, unitOfAccount + ); + + if (eVault == address(0)) { + eVault = address( + GenericFactory(eVaultFactory) + .createProxy(address(0), true, abi.encodePacked(asset, address(0), address(0))) + ); + } + + content = string.concat( + "{\"description\": \"", + description, + "\", \"name\": \"0x Settler feature ", + ItoA.itoa(Feature.unwrap(feature)), + "\"}\n" + ); + + oracleInfo = abi.encode( + LidoOracleInfo({base: IOracle(oracleAddress).WSTETH(), quote: IOracle(oracleAddress).STETH()}) + ); + + return someFunction().getValue().modifyValue().negate().scaleBySomeFactor(1000).transformToTuple(); + + SnapshotRegistry(adapterRegistry) + .add(adapter, LidoFundamentalOracle(adapter).WSTETH(), LidoFundamentalOracle(adapter).WETH()); + + (bool success, bytes memory data) = GenericFactory(eVaultFactory) + .implementation() + .staticcall(abi.encodePacked(EVCUtil.EVC.selector, uint256(0), uint256(0))); + + IEVC.BatchItem[] memory items = new IEVC.BatchItem[](3); + + items[0] = IEVC.BatchItem({ + onBehalfOfAccount: user, + targetContract: address(eGRT), + value: 0, + data: abi.encodeCall(IERC4626.withdraw, (1500e18, address(swapper), user)) + }); + items[1] = IEVC.BatchItem({ + onBehalfOfAccount: user, + targetContract: address(swapper), + value: 0, + data: abi.encodeCall(Swapper.multicall, multicallItems) + }); + items[2] = IEVC.BatchItem({ + onBehalfOfAccount: user, + targetContract: address(swapVerifier), + value: 0, + data: abi.encodeCall( + swapVerifier.verifyDebtMax, (address(eSTETH), user, exactOutTolerance, type(uint256).max) + ) + }); + + uint256 fork = vm.createSelectFork("arbitrum", bytes32(0xdeadc0ffeedeadbeef)); + + ConstructorVictim victim = new ConstructorVictim(sender, "msg.sender", "not set during prank"); + + vm._expectCheatcodeRevert("short msg doesn't break"); + vm._expectCheatcodeRevert("failed parsing as `uint256`: missing hex prefix for hex string"); + vm.thisIsJustAReallyLongMemberWithoutAcall.LetsSeeHowItBreaks.willItBreakAsIntendedOrNot; + + bytes4[] memory targets = new bytes4[](0); + targets[0] = FuzzArtifactSelector("TargetArtifactSelectors.t.sol:Hi", selectors); + + emit IERC712View.Transfer(Create3.predict(_salt, address(_deployer)), address(o), id); + + return _verifyDeploymentRootHash(_getMerkleRoot(proof, hash), originalOwner) + .ternary(IERC1271.isValidSignature.selector, bytes4(0xffffffff)); +} + +function returnLongBinaryOp() returns (bytes32) { + return bytes32( + uint256(Feature.unwrap(feature)) << 128 | uint256(block.chainid) << 64 | uint256(Nonce.unwrap(nonce)) + ); +} + +contract Repros { + function test() public { + uint256 globalBuyAmount = + Take.take(state, notes, uint32(IPoolManager.take.selector), recipient, minBuyAmount); + uint256 globalBuyAmount = + Take.take(state, notes, uint32(IPoolManager.take.selector), recipient, minBuyAmount); + + { + u.executionData = _transferExecution(address(paymentToken), address(0xabcd), 1 ether); + u.executionData = _transferExecution(address(paymentToken), address(0xabcd), 1 ether); + } + + ISettlerBase.AllowedSlippage memory allowedSlippage = ISettlerBase.AllowedSlippage({ + recipient: payable(address(0)), buyToken: IERC20(address(0)), minAmountOut: 0 + }); + ISettlerBase.AllowedSlippage memory allowedSlippage = ISettlerBase.AllowedSlippage({ + recipient: payable(address(0)), buyToken: IERC20(address(0)), minAmountOut: 0 + }); + + ISignatureTransfer.PermitTransferFrom memory permit = defaultERC20PermitTransfer( + address(fromToken()), + amount(), + 0 /* nonce */ + ); + ISignatureTransfer.PermitTransferFrom memory permit = defaultERC20PermitTransfer( + address(fromToken()), + amount(), + 0 /* nonce */ + ); + + // https://github.com/foundry-rs/foundry/issues/11834 + CurrenciesOutOfOrderOrEqual.selector + .revertWith(Currency.unwrap(key.currency0), Currency.unwrap(key.currency1)); + + nestedStruct.withCalls.thatCause + .aBreak( + param1, + param2, + param3 // long line + ); + + // https://github.com/foundry-rs/foundry/issues/11835 + feeGrowthInside0X128 = + self.feeGrowthGlobal0X128 - lower.feeGrowthOutside0X128 - upper.feeGrowthOutside0X128; + feeGrowthInside0X128 = + self.feeGrowthGlobal0X128 - lower.feeGrowthOutside0X128 - upper.feeGrowthOutside0X128; + + // https://github.com/foundry-rs/foundry/issues/11875 + lpTail = LpPosition({ + tickLower: posTickLower, tickUpper: posTickUpper, liquidity: lpTailLiquidity, id: uint16(id) + }); + } + + // https://github.com/foundry-rs/foundry/issues/11834 + function test_ffi_fuzz_addLiquidity_defaultPool(IPoolManager.ModifyLiquidityParams memory paramSeed) + public + { + a = 1; + } + + // https://github.com/foundry-rs/foundry/issues/12324 + function test_longCallWithOpts() { + flow.withdraw{value: FLOW_MIN_FEE_WEI}({ + streamId: defaultStreamId, to: users.eve, amount: WITHDRAW_AMOUNT_6D + }); + flow.withdraw{ + value: FLOW_MIN_FEE_WEI /* cmnt */ + }({ + streamId: defaultStreamId, + to: users.eve, + /* cmnt */ + amount: WITHDRAW_AMOUNT_6D + }); + flow.withdraw{value: FLOW_MIN_FEE_WEI}({ // cmnt + streamId: defaultStreamId, to: users.eve, amount: WITHDRAW_AMOUNT_6D + }); + } +} diff --git a/crates/fmt/testdata/ReprosCalls/120.fmt.sol b/crates/fmt/testdata/ReprosCalls/120.fmt.sol new file mode 100644 index 0000000000000..e6b76615e0bbf --- /dev/null +++ b/crates/fmt/testdata/ReprosCalls/120.fmt.sol @@ -0,0 +1,153 @@ +// config: line_length = 120 +// config: bracket_spacing = true +function repros() public { + require( + keccak256(abi.encodePacked("this is a long string")) == keccak256(abi.encodePacked("some other long string")), + "string mismatch" + ); + + address lerp = LerpFactoryLike(lerpFab()).newLerp(_name, _target, _what, _startTime, _start, _end, _duration); + + (oracleRouter, eVault) = + execute(oracleRouterFactory, deployRouterForOracle, eVaultFactory, upgradable, asset, oracle, unitOfAccount); + + if (eVault == address(0)) { + eVault = address( + GenericFactory(eVaultFactory).createProxy(address(0), true, abi.encodePacked(asset, address(0), address(0))) + ); + } + + content = string.concat( + "{\"description\": \"", + description, + "\", \"name\": \"0x Settler feature ", + ItoA.itoa(Feature.unwrap(feature)), + "\"}\n" + ); + + oracleInfo = + abi.encode(LidoOracleInfo({ base: IOracle(oracleAddress).WSTETH(), quote: IOracle(oracleAddress).STETH() })); + + return someFunction().getValue().modifyValue().negate().scaleBySomeFactor(1000).transformToTuple(); + + SnapshotRegistry(adapterRegistry) + .add(adapter, LidoFundamentalOracle(adapter).WSTETH(), LidoFundamentalOracle(adapter).WETH()); + + (bool success, bytes memory data) = GenericFactory(eVaultFactory) + .implementation() + .staticcall(abi.encodePacked(EVCUtil.EVC.selector, uint256(0), uint256(0))); + + IEVC.BatchItem[] memory items = new IEVC.BatchItem[](3); + + items[0] = IEVC.BatchItem({ + onBehalfOfAccount: user, + targetContract: address(eGRT), + value: 0, + data: abi.encodeCall(IERC4626.withdraw, (1500e18, address(swapper), user)) + }); + items[1] = IEVC.BatchItem({ + onBehalfOfAccount: user, + targetContract: address(swapper), + value: 0, + data: abi.encodeCall(Swapper.multicall, multicallItems) + }); + items[2] = IEVC.BatchItem({ + onBehalfOfAccount: user, + targetContract: address(swapVerifier), + value: 0, + data: abi.encodeCall(swapVerifier.verifyDebtMax, (address(eSTETH), user, exactOutTolerance, type(uint256).max)) + }); + + uint256 fork = vm.createSelectFork("arbitrum", bytes32(0xdeadc0ffeedeadbeef)); + + ConstructorVictim victim = new ConstructorVictim(sender, "msg.sender", "not set during prank"); + + vm._expectCheatcodeRevert("short msg doesn't break"); + vm._expectCheatcodeRevert("failed parsing as `uint256`: missing hex prefix for hex string"); + vm.thisIsJustAReallyLongMemberWithoutAcall.LetsSeeHowItBreaks.willItBreakAsIntendedOrNot; + + bytes4[] memory targets = new bytes4[](0); + targets[0] = FuzzArtifactSelector("TargetArtifactSelectors.t.sol:Hi", selectors); + + emit IERC712View.Transfer(Create3.predict(_salt, address(_deployer)), address(o), id); + + return _verifyDeploymentRootHash(_getMerkleRoot(proof, hash), originalOwner) + .ternary(IERC1271.isValidSignature.selector, bytes4(0xffffffff)); +} + +function returnLongBinaryOp() returns (bytes32) { + return + bytes32(uint256(Feature.unwrap(feature)) << 128 | uint256(block.chainid) << 64 | uint256(Nonce.unwrap(nonce))); +} + +contract Repros { + function test() public { + uint256 globalBuyAmount = Take.take(state, notes, uint32(IPoolManager.take.selector), recipient, minBuyAmount); + uint256 globalBuyAmount = Take.take(state, notes, uint32(IPoolManager.take.selector), recipient, minBuyAmount); + + { + u.executionData = _transferExecution(address(paymentToken), address(0xabcd), 1 ether); + u.executionData = _transferExecution(address(paymentToken), address(0xabcd), 1 ether); + } + + ISettlerBase.AllowedSlippage memory allowedSlippage = ISettlerBase.AllowedSlippage({ + recipient: payable(address(0)), buyToken: IERC20(address(0)), minAmountOut: 0 + }); + ISettlerBase.AllowedSlippage memory allowedSlippage = ISettlerBase.AllowedSlippage({ + recipient: payable(address(0)), buyToken: IERC20(address(0)), minAmountOut: 0 + }); + + ISignatureTransfer.PermitTransferFrom memory permit = defaultERC20PermitTransfer( + address(fromToken()), + amount(), + 0 /* nonce */ + ); + ISignatureTransfer.PermitTransferFrom memory permit = defaultERC20PermitTransfer( + address(fromToken()), + amount(), + 0 /* nonce */ + ); + + // https://github.com/foundry-rs/foundry/issues/11834 + CurrenciesOutOfOrderOrEqual.selector.revertWith(Currency.unwrap(key.currency0), Currency.unwrap(key.currency1)); + + nestedStruct.withCalls.thatCause + .aBreak( + param1, + param2, + param3 // long line + ); + + // https://github.com/foundry-rs/foundry/issues/11835 + feeGrowthInside0X128 = self.feeGrowthGlobal0X128 - lower.feeGrowthOutside0X128 - upper.feeGrowthOutside0X128; + feeGrowthInside0X128 = self.feeGrowthGlobal0X128 - lower.feeGrowthOutside0X128 - upper.feeGrowthOutside0X128; + + // https://github.com/foundry-rs/foundry/issues/11875 + lpTail = LpPosition({ + tickLower: posTickLower, tickUpper: posTickUpper, liquidity: lpTailLiquidity, id: uint16(id) + }); + } + + // https://github.com/foundry-rs/foundry/issues/11834 + function test_ffi_fuzz_addLiquidity_defaultPool(IPoolManager.ModifyLiquidityParams memory paramSeed) public { + a = 1; + } + + // https://github.com/foundry-rs/foundry/issues/12324 + function test_longCallWithOpts() { + flow.withdraw{ value: FLOW_MIN_FEE_WEI }({ + streamId: defaultStreamId, to: users.eve, amount: WITHDRAW_AMOUNT_6D + }); + flow.withdraw{ + value: FLOW_MIN_FEE_WEI /* cmnt */ + }({ + streamId: defaultStreamId, + to: users.eve, + /* cmnt */ + amount: WITHDRAW_AMOUNT_6D + }); + flow.withdraw{ value: FLOW_MIN_FEE_WEI }({ // cmnt + streamId: defaultStreamId, to: users.eve, amount: WITHDRAW_AMOUNT_6D + }); + } +} diff --git a/crates/fmt/testdata/ReprosCalls/80.fmt.sol b/crates/fmt/testdata/ReprosCalls/80.fmt.sol new file mode 100644 index 0000000000000..bb9250d657476 --- /dev/null +++ b/crates/fmt/testdata/ReprosCalls/80.fmt.sol @@ -0,0 +1,230 @@ +// config: line_length = 80 +function repros() public { + require( + keccak256(abi.encodePacked("this is a long string")) + == keccak256(abi.encodePacked("some other long string")), + "string mismatch" + ); + + address lerp = LerpFactoryLike(lerpFab()) + .newLerp(_name, _target, _what, _startTime, _start, _end, _duration); + + (oracleRouter, eVault) = execute( + oracleRouterFactory, + deployRouterForOracle, + eVaultFactory, + upgradable, + asset, + oracle, + unitOfAccount + ); + + if (eVault == address(0)) { + eVault = address( + GenericFactory(eVaultFactory) + .createProxy( + address(0), + true, + abi.encodePacked(asset, address(0), address(0)) + ) + ); + } + + content = string.concat( + "{\"description\": \"", + description, + "\", \"name\": \"0x Settler feature ", + ItoA.itoa(Feature.unwrap(feature)), + "\"}\n" + ); + + oracleInfo = abi.encode( + LidoOracleInfo({ + base: IOracle(oracleAddress).WSTETH(), + quote: IOracle(oracleAddress).STETH() + }) + ); + + return someFunction() + .getValue() + .modifyValue() + .negate() + .scaleBySomeFactor(1000) + .transformToTuple(); + + SnapshotRegistry(adapterRegistry) + .add( + adapter, + LidoFundamentalOracle(adapter).WSTETH(), + LidoFundamentalOracle(adapter).WETH() + ); + + (bool success, bytes memory data) = GenericFactory(eVaultFactory) + .implementation() + .staticcall( + abi.encodePacked(EVCUtil.EVC.selector, uint256(0), uint256(0)) + ); + + IEVC.BatchItem[] memory items = new IEVC.BatchItem[](3); + + items[0] = IEVC.BatchItem({ + onBehalfOfAccount: user, + targetContract: address(eGRT), + value: 0, + data: abi.encodeCall( + IERC4626.withdraw, (1500e18, address(swapper), user) + ) + }); + items[1] = IEVC.BatchItem({ + onBehalfOfAccount: user, + targetContract: address(swapper), + value: 0, + data: abi.encodeCall(Swapper.multicall, multicallItems) + }); + items[2] = IEVC.BatchItem({ + onBehalfOfAccount: user, + targetContract: address(swapVerifier), + value: 0, + data: abi.encodeCall( + swapVerifier.verifyDebtMax, + (address(eSTETH), user, exactOutTolerance, type(uint256).max) + ) + }); + + uint256 fork = + vm.createSelectFork("arbitrum", bytes32(0xdeadc0ffeedeadbeef)); + + ConstructorVictim victim = + new ConstructorVictim(sender, "msg.sender", "not set during prank"); + + vm._expectCheatcodeRevert("short msg doesn't break"); + vm._expectCheatcodeRevert( + "failed parsing as `uint256`: missing hex prefix for hex string" + ); + vm.thisIsJustAReallyLongMemberWithoutAcall.LetsSeeHowItBreaks + .willItBreakAsIntendedOrNot; + + bytes4[] memory targets = new bytes4[](0); + targets[0] = + FuzzArtifactSelector("TargetArtifactSelectors.t.sol:Hi", selectors); + + emit IERC712View.Transfer( + Create3.predict(_salt, address(_deployer)), address(o), id + ); + + return _verifyDeploymentRootHash(_getMerkleRoot(proof, hash), originalOwner) + .ternary(IERC1271.isValidSignature.selector, bytes4(0xffffffff)); +} + +function returnLongBinaryOp() returns (bytes32) { + return bytes32( + uint256(Feature.unwrap(feature)) << 128 | uint256(block.chainid) << 64 + | uint256(Nonce.unwrap(nonce)) + ); +} + +contract Repros { + function test() public { + uint256 globalBuyAmount = Take.take( + state, + notes, + uint32(IPoolManager.take.selector), + recipient, + minBuyAmount + ); + uint256 globalBuyAmount = Take.take( + state, + notes, + uint32(IPoolManager.take.selector), + recipient, + minBuyAmount + ); + + { + u.executionData = _transferExecution( + address(paymentToken), address(0xabcd), 1 ether + ); + u.executionData = _transferExecution( + address(paymentToken), address(0xabcd), 1 ether + ); + } + + ISettlerBase.AllowedSlippage memory allowedSlippage = + ISettlerBase.AllowedSlippage({ + recipient: payable(address(0)), + buyToken: IERC20(address(0)), + minAmountOut: 0 + }); + ISettlerBase.AllowedSlippage memory allowedSlippage = + ISettlerBase.AllowedSlippage({ + recipient: payable(address(0)), + buyToken: IERC20(address(0)), + minAmountOut: 0 + }); + + ISignatureTransfer.PermitTransferFrom memory permit = + defaultERC20PermitTransfer( + address(fromToken()), + amount(), + 0 /* nonce */ + ); + ISignatureTransfer.PermitTransferFrom memory permit = + defaultERC20PermitTransfer( + address(fromToken()), + amount(), + 0 /* nonce */ + ); + + // https://github.com/foundry-rs/foundry/issues/11834 + CurrenciesOutOfOrderOrEqual.selector + .revertWith( + Currency.unwrap(key.currency0), Currency.unwrap(key.currency1) + ); + + nestedStruct.withCalls.thatCause + .aBreak( + param1, + param2, + param3 // long line + ); + + // https://github.com/foundry-rs/foundry/issues/11835 + feeGrowthInside0X128 = self.feeGrowthGlobal0X128 + - lower.feeGrowthOutside0X128 - upper.feeGrowthOutside0X128; + feeGrowthInside0X128 = self.feeGrowthGlobal0X128 + - lower.feeGrowthOutside0X128 - upper.feeGrowthOutside0X128; + + // https://github.com/foundry-rs/foundry/issues/11875 + lpTail = LpPosition({ + tickLower: posTickLower, + tickUpper: posTickUpper, + liquidity: lpTailLiquidity, + id: uint16(id) + }); + } + + // https://github.com/foundry-rs/foundry/issues/11834 + function test_ffi_fuzz_addLiquidity_defaultPool( + IPoolManager.ModifyLiquidityParams memory paramSeed + ) public { + a = 1; + } + + // https://github.com/foundry-rs/foundry/issues/12324 + function test_longCallWithOpts() { + flow.withdraw{value: FLOW_MIN_FEE_WEI}({ + streamId: defaultStreamId, to: users.eve, amount: WITHDRAW_AMOUNT_6D + }); + flow.withdraw{ + value: FLOW_MIN_FEE_WEI /* cmnt */ + }({ + streamId: defaultStreamId, + to: users.eve, + /* cmnt */ + amount: WITHDRAW_AMOUNT_6D + }); + flow.withdraw{value: FLOW_MIN_FEE_WEI}({ // cmnt + streamId: defaultStreamId, to: users.eve, amount: WITHDRAW_AMOUNT_6D + }); + } +} diff --git a/crates/fmt/testdata/ReprosCalls/consistent.120.fmt.sol b/crates/fmt/testdata/ReprosCalls/consistent.120.fmt.sol new file mode 100644 index 0000000000000..40169f91b2431 --- /dev/null +++ b/crates/fmt/testdata/ReprosCalls/consistent.120.fmt.sol @@ -0,0 +1,166 @@ +// config: line_length = 120 +// config: bracket_spacing = true +// config: prefer_compact = "none" +function repros() public { + require( + keccak256(abi.encodePacked("this is a long string")) == keccak256(abi.encodePacked("some other long string")), + "string mismatch" + ); + + address lerp = LerpFactoryLike(lerpFab()).newLerp(_name, _target, _what, _startTime, _start, _end, _duration); + + (oracleRouter, eVault) = + execute(oracleRouterFactory, deployRouterForOracle, eVaultFactory, upgradable, asset, oracle, unitOfAccount); + + if (eVault == address(0)) { + eVault = address( + GenericFactory(eVaultFactory).createProxy(address(0), true, abi.encodePacked(asset, address(0), address(0))) + ); + } + + content = string.concat( + "{\"description\": \"", + description, + "\", \"name\": \"0x Settler feature ", + ItoA.itoa(Feature.unwrap(feature)), + "\"}\n" + ); + + oracleInfo = + abi.encode(LidoOracleInfo({ base: IOracle(oracleAddress).WSTETH(), quote: IOracle(oracleAddress).STETH() })); + + return someFunction().getValue().modifyValue().negate().scaleBySomeFactor(1000).transformToTuple(); + + SnapshotRegistry(adapterRegistry) + .add(adapter, LidoFundamentalOracle(adapter).WSTETH(), LidoFundamentalOracle(adapter).WETH()); + + (bool success, bytes memory data) = GenericFactory(eVaultFactory) + .implementation() + .staticcall(abi.encodePacked(EVCUtil.EVC.selector, uint256(0), uint256(0))); + + IEVC.BatchItem[] memory items = new IEVC.BatchItem[](3); + + items[0] = IEVC.BatchItem({ + onBehalfOfAccount: user, + targetContract: address(eGRT), + value: 0, + data: abi.encodeCall(IERC4626.withdraw, (1500e18, address(swapper), user)) + }); + items[1] = IEVC.BatchItem({ + onBehalfOfAccount: user, + targetContract: address(swapper), + value: 0, + data: abi.encodeCall(Swapper.multicall, multicallItems) + }); + items[2] = IEVC.BatchItem({ + onBehalfOfAccount: user, + targetContract: address(swapVerifier), + value: 0, + data: abi.encodeCall(swapVerifier.verifyDebtMax, (address(eSTETH), user, exactOutTolerance, type(uint256).max)) + }); + + uint256 fork = vm.createSelectFork("arbitrum", bytes32(0xdeadc0ffeedeadbeef)); + + ConstructorVictim victim = new ConstructorVictim(sender, "msg.sender", "not set during prank"); + + vm._expectCheatcodeRevert("short msg doesn't break"); + vm._expectCheatcodeRevert("failed parsing as `uint256`: missing hex prefix for hex string"); + vm.thisIsJustAReallyLongMemberWithoutAcall.LetsSeeHowItBreaks.willItBreakAsIntendedOrNot; + + bytes4[] memory targets = new bytes4[](0); + targets[0] = FuzzArtifactSelector("TargetArtifactSelectors.t.sol:Hi", selectors); + + emit IERC712View.Transfer(Create3.predict(_salt, address(_deployer)), address(o), id); + + return _verifyDeploymentRootHash(_getMerkleRoot(proof, hash), originalOwner) + .ternary(IERC1271.isValidSignature.selector, bytes4(0xffffffff)); +} + +function returnLongBinaryOp() returns (bytes32) { + return + bytes32(uint256(Feature.unwrap(feature)) << 128 | uint256(block.chainid) << 64 | uint256(Nonce.unwrap(nonce))); +} + +contract Repros { + function test() public { + uint256 globalBuyAmount = Take.take(state, notes, uint32(IPoolManager.take.selector), recipient, minBuyAmount); + uint256 globalBuyAmount = Take.take(state, notes, uint32(IPoolManager.take.selector), recipient, minBuyAmount); + + { + u.executionData = _transferExecution(address(paymentToken), address(0xabcd), 1 ether); + u.executionData = _transferExecution(address(paymentToken), address(0xabcd), 1 ether); + } + + ISettlerBase.AllowedSlippage memory allowedSlippage = ISettlerBase.AllowedSlippage({ + recipient: payable(address(0)), + buyToken: IERC20(address(0)), + minAmountOut: 0 + }); + ISettlerBase.AllowedSlippage memory allowedSlippage = ISettlerBase.AllowedSlippage({ + recipient: payable(address(0)), + buyToken: IERC20(address(0)), + minAmountOut: 0 + }); + + ISignatureTransfer.PermitTransferFrom memory permit = defaultERC20PermitTransfer( + address(fromToken()), + amount(), + 0 /* nonce */ + ); + ISignatureTransfer.PermitTransferFrom memory permit = defaultERC20PermitTransfer( + address(fromToken()), + amount(), + 0 /* nonce */ + ); + + // https://github.com/foundry-rs/foundry/issues/11834 + CurrenciesOutOfOrderOrEqual.selector.revertWith(Currency.unwrap(key.currency0), Currency.unwrap(key.currency1)); + + nestedStruct.withCalls.thatCause + .aBreak( + param1, + param2, + param3 // long line + ); + + // https://github.com/foundry-rs/foundry/issues/11835 + feeGrowthInside0X128 = self.feeGrowthGlobal0X128 - lower.feeGrowthOutside0X128 - upper.feeGrowthOutside0X128; + feeGrowthInside0X128 = self.feeGrowthGlobal0X128 - lower.feeGrowthOutside0X128 - upper.feeGrowthOutside0X128; + + // https://github.com/foundry-rs/foundry/issues/11875 + lpTail = LpPosition({ + tickLower: posTickLower, + tickUpper: posTickUpper, + liquidity: lpTailLiquidity, + id: uint16(id) + }); + } + + // https://github.com/foundry-rs/foundry/issues/11834 + function test_ffi_fuzz_addLiquidity_defaultPool(IPoolManager.ModifyLiquidityParams memory paramSeed) public { + a = 1; + } + + // https://github.com/foundry-rs/foundry/issues/12324 + function test_longCallWithOpts() { + flow.withdraw{ value: FLOW_MIN_FEE_WEI }({ + streamId: defaultStreamId, + to: users.eve, + amount: WITHDRAW_AMOUNT_6D + }); + flow.withdraw{ + value: FLOW_MIN_FEE_WEI /* cmnt */ + }({ + streamId: defaultStreamId, + to: users.eve, + /* cmnt */ + amount: WITHDRAW_AMOUNT_6D + }); + flow.withdraw{ value: FLOW_MIN_FEE_WEI }({ + // cmnt + streamId: defaultStreamId, + to: users.eve, + amount: WITHDRAW_AMOUNT_6D + }); + } +} diff --git a/crates/fmt/testdata/ReprosCalls/original.sol b/crates/fmt/testdata/ReprosCalls/original.sol new file mode 100644 index 0000000000000..cb92b2a0c5b13 --- /dev/null +++ b/crates/fmt/testdata/ReprosCalls/original.sol @@ -0,0 +1,148 @@ +function repros() public { + require( + keccak256(abi.encodePacked("this is a long string")) == keccak256(abi.encodePacked("some other long string")), + "string mismatch" + ); + + address lerp = LerpFactoryLike(lerpFab()).newLerp(_name, _target, _what, _startTime, _start, _end, _duration); + + (oracleRouter, eVault) = + execute(oracleRouterFactory, deployRouterForOracle, eVaultFactory, upgradable, asset, oracle, unitOfAccount); + + if (eVault == address(0)) { + eVault = address(GenericFactory(eVaultFactory) + .createProxy(address(0), true, abi.encodePacked(asset, address(0), address(0)))); + } + + content = string.concat( + "{\"description\": \"", + description, + "\", \"name\": \"0x Settler feature ", + ItoA.itoa(Feature.unwrap(feature)), + "\"}\n" + ); + + oracleInfo = + abi.encode(LidoOracleInfo({base: IOracle(oracleAddress).WSTETH(), quote: IOracle(oracleAddress).STETH()})); + + return someFunction().getValue().modifyValue().negate().scaleBySomeFactor(1000).transformToTuple(); + + SnapshotRegistry(adapterRegistry).add( + adapter, LidoFundamentalOracle(adapter).WSTETH(), LidoFundamentalOracle(adapter).WETH() + ); + + (bool success, bytes memory data) = GenericFactory(eVaultFactory).implementation().staticcall( + abi.encodePacked(EVCUtil.EVC.selector, uint256(0), uint256(0)) + ); + +IEVC.BatchItem[] memory items = new IEVC.BatchItem[](3); + + items[0] = IEVC.BatchItem({ + onBehalfOfAccount: user, + targetContract: address(eGRT), + value: 0, + data: abi.encodeCall(IERC4626.withdraw, (1500e18, address(swapper), user)) + }); + items[1] = IEVC.BatchItem({ + onBehalfOfAccount: user, + targetContract: address(swapper), + value: 0, + data: abi.encodeCall(Swapper.multicall, multicallItems) + }); + items[2] = IEVC.BatchItem({ + onBehalfOfAccount: user, + targetContract: address(swapVerifier), + value: 0, + data: abi.encodeCall(swapVerifier.verifyDebtMax, (address(eSTETH), user, exactOutTolerance, type(uint256).max)) + }); + + uint256 fork = vm.createSelectFork("arbitrum", bytes32(0xdeadc0ffeedeadbeef)); + + ConstructorVictim victim = new ConstructorVictim( sender, "msg.sender", "not set during prank" ); + + vm._expectCheatcodeRevert("short msg doesn't break"); + vm._expectCheatcodeRevert( "failed parsing as `uint256`: missing hex prefix for hex string" ); + vm.thisIsJustAReallyLongMemberWithoutAcall.LetsSeeHowItBreaks.willItBreakAsIntendedOrNot; + + bytes4[] memory targets = new bytes4[](0); + targets[0] = FuzzArtifactSelector("TargetArtifactSelectors.t.sol:Hi", selectors); + + emit IERC712View.Transfer( Create3.predict(_salt, address(_deployer)), address(o), id ); + + return _verifyDeploymentRootHash( +_getMerkleRoot(proof, hash), originalOwner +).ternary( +IERC1271.isValidSignature.selector, bytes4(0xffffffff) +); +} + +function returnLongBinaryOp() returns (bytes32) { + return + bytes32(uint256(Feature.unwrap(feature)) << 128 | uint256(block.chainid) << 64 | uint256(Nonce.unwrap(nonce))); +} + +contract Repros { + function test() public { + uint256 globalBuyAmount = + Take.take(state, notes, uint32(IPoolManager.take.selector), recipient, minBuyAmount); + uint256 globalBuyAmount = Take.take( + state, notes, uint32(IPoolManager.take.selector), recipient, minBuyAmount + ); + + { + u.executionData = + _transferExecution(address(paymentToken), address(0xabcd), 1 ether); + u.executionData = _transferExecution( + address(paymentToken), address(0xabcd), 1 ether + ); + } + + ISettlerBase.AllowedSlippage memory allowedSlippage = ISettlerBase.AllowedSlippage({ + recipient: payable(address(0)), + buyToken: IERC20(address(0)), + minAmountOut: 0 + }); + ISettlerBase.AllowedSlippage memory allowedSlippage = ISettlerBase.AllowedSlippage({ + recipient: payable(address(0)), buyToken: IERC20(address(0)), minAmountOut: 0 + }); + + ISignatureTransfer.PermitTransferFrom memory permit = + defaultERC20PermitTransfer(address(fromToken()), amount(), 0 /* nonce */); + ISignatureTransfer.PermitTransferFrom memory permit = defaultERC20PermitTransfer( + address(fromToken()), amount(), 0 /* nonce */ + ); + + // https://github.com/foundry-rs/foundry/issues/11834 + CurrenciesOutOfOrderOrEqual.selector.revertWith( + Currency.unwrap(key.currency0), Currency.unwrap(key.currency1) + ); + + nestedStruct.withCalls.thatCause.aBreak(param1, param2, param3 // long line + ); + + // https://github.com/foundry-rs/foundry/issues/11835 + feeGrowthInside0X128 = + self.feeGrowthGlobal0X128 - lower.feeGrowthOutside0X128 - upper.feeGrowthOutside0X128; + feeGrowthInside0X128 = self.feeGrowthGlobal0X128 + - lower.feeGrowthOutside0X128 - upper.feeGrowthOutside0X128; + + // https://github.com/foundry-rs/foundry/issues/11875 + lpTail = + LpPosition({ tickLower: posTickLower, tickUpper: posTickUpper, liquidity: lpTailLiquidity, id: uint16(id) }); + } + + // https://github.com/foundry-rs/foundry/issues/11834 + function test_ffi_fuzz_addLiquidity_defaultPool( + IPoolManager.ModifyLiquidityParams memory paramSeed + ) public { + a = 1; + } + + // https://github.com/foundry-rs/foundry/issues/12324 + function test_longCallWithOpts() { + flow.withdraw{ value: FLOW_MIN_FEE_WEI }({streamId: defaultStreamId, to: users.eve, amount: WITHDRAW_AMOUNT_6D }); + flow.withdraw{ value: FLOW_MIN_FEE_WEI /* cmnt */ }({ streamId: defaultStreamId, to: users.eve, /* cmnt */ amount: WITHDRAW_AMOUNT_6D }); + flow.withdraw{ value: FLOW_MIN_FEE_WEI }({ // cmnt + streamId: defaultStreamId, to: users.eve, amount: WITHDRAW_AMOUNT_6D }); + } +} diff --git a/crates/fmt/testdata/ReprosFunctionDefs/all.120.fmt.sol b/crates/fmt/testdata/ReprosFunctionDefs/all.120.fmt.sol new file mode 100644 index 0000000000000..ef580620d8486 --- /dev/null +++ b/crates/fmt/testdata/ReprosFunctionDefs/all.120.fmt.sol @@ -0,0 +1,17 @@ +// config: line_length = 120 +// config: multiline_func_header = "all" +contract Repros { + // https://github.com/foundry-rs/foundry/issues/12109 + function createDefaultStream(UD21x18 ratePerSecond, uint40 startTime, IERC20 token_) internal returns (uint256); + + function calculateStreamedPercentage( + uint128 streamedAmount, + uint128 depositedAmount + ) + internal + pure + returns (uint256) + { + a = 1; + } +} diff --git a/crates/fmt/testdata/ReprosFunctionDefs/original.sol b/crates/fmt/testdata/ReprosFunctionDefs/original.sol new file mode 100644 index 0000000000000..19c97e95f0c6c --- /dev/null +++ b/crates/fmt/testdata/ReprosFunctionDefs/original.sol @@ -0,0 +1,5 @@ +contract Repros { + // https://github.com/foundry-rs/foundry/issues/12109 + function createDefaultStream(UD21x18 ratePerSecond, uint40 startTime, IERC20 token_) internal returns (uint256); + function calculateStreamedPercentage(uint128 streamedAmount, uint128 depositedAmount) internal pure returns (uint256) { a = 1; } +} diff --git a/crates/fmt/testdata/ReturnStatement/fmt.sol b/crates/fmt/testdata/ReturnStatement/fmt.sol index d628d6097233d..493ffb087d62e 100644 --- a/crates/fmt/testdata/ReturnStatement/fmt.sol +++ b/crates/fmt/testdata/ReturnStatement/fmt.sol @@ -23,7 +23,9 @@ contract ReturnStatement { 0x00; } - if (val == 1) return 1; + if (val == 1) { + return 1; + } if (val == 2) { return 3 - 1; @@ -60,7 +62,11 @@ contract ReturnStatement { ); } - return someFunction().getValue().modifyValue().negate() - .scaleBySomeFactor(1000).transformToTuple(); + return someFunction() + .getValue() + .modifyValue() + .negate() + .scaleBySomeFactor(1000) + .transformToTuple(); } } diff --git a/crates/fmt/testdata/ReturnStatement/original.sol b/crates/fmt/testdata/ReturnStatement/original.sol index 9cfaa82d6c524..f6305661ff2d4 100644 --- a/crates/fmt/testdata/ReturnStatement/original.sol +++ b/crates/fmt/testdata/ReturnStatement/original.sol @@ -23,7 +23,7 @@ contract ReturnStatement { 0x00; } - if (val == 1) { return + if (val == 1) { return 1; } if (val == 2) { @@ -45,14 +45,14 @@ contract ReturnStatement { function returnMultipleValues(uint256 val) external returns (uint256, uint256, bool) { if (val == 0) { return /* return mul 1 */ (0, 1,/* return mul 2 */ false); } - if (val == 1) { + if (val == 1) { // return mul 3 return /* return mul 4 */ ( 987654321, 1234567890,/* return mul 5 */ false); } if (val == 2) { - return /* return mul 6 */ ( 1234567890 + 987654321 + 87654123536, 987654321 + 1234567890 + 124245235235, true); + return /* return mul 6 */ ( 1234567890 + 987654321 + 87654123536, 987654321 + 1234567890 + 124245235235, true); } return someFunction().getValue().modifyValue().negate().scaleBySomeFactor(1000).transformToTuple(); diff --git a/crates/fmt/testdata/RevertNamedArgsStatement/bracket-spacing.fmt.sol b/crates/fmt/testdata/RevertNamedArgsStatement/bracket-spacing.fmt.sol new file mode 100644 index 0000000000000..0ba90b1060973 --- /dev/null +++ b/crates/fmt/testdata/RevertNamedArgsStatement/bracket-spacing.fmt.sol @@ -0,0 +1,38 @@ +// config: prefer_compact = "events_errors" +// config: bracket_spacing = true +contract RevertNamedArgsStatement { + error EmptyError(); + error SimpleError(uint256 val); + error ComplexError(uint256 val, uint256 ts, string message); + error SomeVeryVeryVeryLongErrorNameWithNamedArgumentsThatExceedsMaximumLength( + uint256 val, uint256 ts, string message + ); + + function test() external { + revert({ }); + + revert EmptyError({ }); + + revert SimpleError({ val: 0 }); + + revert ComplexError({ + val: 0, + ts: block.timestamp, + message: "some reason" + }); + + revert SomeVeryVeryVeryLongErrorNameWithNamedArgumentsThatExceedsMaximumLength({ + val: 0, + ts: 0x00, + message: "something unpredictable happened that caused execution to revert" + }); + + revert({ }); // comment1 + + revert /* comment2 */ SimpleError({ /* comment3 */ // comment4 + val: 0 // comment 5 + }); + + revert Errors.Unauthorized({ caller: msg.sender, neededRole: role }); + } +} diff --git a/crates/fmt/testdata/RevertNamedArgsStatement/fmt.sol b/crates/fmt/testdata/RevertNamedArgsStatement/fmt.sol index 9ad6b042b731a..11c8069fe521c 100644 --- a/crates/fmt/testdata/RevertNamedArgsStatement/fmt.sol +++ b/crates/fmt/testdata/RevertNamedArgsStatement/fmt.sol @@ -1,3 +1,4 @@ +// config: prefer_compact = "events_errors" contract RevertNamedArgsStatement { error EmptyError(); error SimpleError(uint256 val); @@ -19,17 +20,18 @@ contract RevertNamedArgsStatement { message: "some reason" }); - revert - SomeVeryVeryVeryLongErrorNameWithNamedArgumentsThatExceedsMaximumLength({ - val: 0, - ts: 0x00, - message: "something unpredictable happened that caused execution to revert" - }); + revert SomeVeryVeryVeryLongErrorNameWithNamedArgumentsThatExceedsMaximumLength({ + val: 0, + ts: 0x00, + message: "something unpredictable happened that caused execution to revert" + }); revert({}); // comment1 revert /* comment2 */ SimpleError({ /* comment3 */ // comment4 val: 0 // comment 5 }); + + revert Errors.Unauthorized({caller: msg.sender, neededRole: role}); } } diff --git a/crates/fmt/testdata/RevertNamedArgsStatement/original.sol b/crates/fmt/testdata/RevertNamedArgsStatement/original.sol index 2c9e35ba3d16c..1713aec13b47c 100644 --- a/crates/fmt/testdata/RevertNamedArgsStatement/original.sol +++ b/crates/fmt/testdata/RevertNamedArgsStatement/original.sol @@ -19,14 +19,16 @@ contract RevertNamedArgsStatement { ts: block.timestamp, message: "some reason" }); - + revert SomeVeryVeryVeryLongErrorNameWithNamedArgumentsThatExceedsMaximumLength({ val: 0, ts: 0x00, message: "something unpredictable happened that caused execution to revert"}); - revert // comment1 + revert // comment1 ({}); - revert /* comment2 */ SimpleError /* comment3 */ ({ // comment4 + revert /* comment2 */ SimpleError /* comment3 */ ({ // comment4 val:0 // comment 5 }); + + revert Errors.Unauthorized({ caller: msg.sender, neededRole: role }); } } diff --git a/crates/fmt/testdata/RevertStatement/fmt.sol b/crates/fmt/testdata/RevertStatement/fmt.sol index 3a677a5cadfdb..4ed9eb348d39d 100644 --- a/crates/fmt/testdata/RevertStatement/fmt.sol +++ b/crates/fmt/testdata/RevertStatement/fmt.sol @@ -1,3 +1,4 @@ +// config: prefer_compact = "none" contract RevertStatement { error TestError(uint256, bool, string); @@ -27,7 +28,9 @@ contract RevertStatement { message // comment5 /* comment6 */ ); - revert( /* comment7 */ /* comment8 */ message /* comment9 */ ); /* comment10 */ // comment11 + revert( /* comment7 */ /* comment8 */ + message /* comment9 */ + ); /* comment10 */ // comment11 revert( string.concat( @@ -40,11 +43,15 @@ contract RevertStatement { revert TestError(0, false, message); revert TestError( - 0, false, someVeryLongFunctionNameToGetDynamicErrorMessageString() + 0, + false, + someVeryLongFunctionNameToGetDynamicErrorMessageString() ); revert /* comment13 */ /* comment14 */ TestError( /* comment15 */ - 1234567890, false, message + 1234567890, + false, + message ); revert TestError( /* comment16 */ diff --git a/crates/fmt/testdata/RevertStatement/original.sol b/crates/fmt/testdata/RevertStatement/original.sol index 3426fdb1e5ad5..0c64dfeb25159 100644 --- a/crates/fmt/testdata/RevertStatement/original.sol +++ b/crates/fmt/testdata/RevertStatement/original.sol @@ -6,17 +6,17 @@ contract RevertStatement { } function test(string memory message) external { - revert ( ) ; + revert ( ) ; revert ( /* comment1 */ ); - - revert + + revert ( ) ; - // comment2 + // comment2 revert ( // comment3 ); @@ -24,7 +24,7 @@ contract RevertStatement { revert ( message ); - revert ( + revert ( // comment4 message // comment5 /* comment6 */ ); @@ -35,10 +35,10 @@ contract RevertStatement { revert TestError(0, false, message); revert TestError(0, false, someVeryLongFunctionNameToGetDynamicErrorMessageString()); - + revert /* comment13 */ /* comment14 */ TestError /* comment15 */(1234567890, false, message); revert TestError ( /* comment16 */ 1, true, someVeryLongFunctionNameToGetDynamicErrorMessageString() /* comment17 */); } -} \ No newline at end of file +} diff --git a/crates/fmt/testdata/SimpleComments/fmt.sol b/crates/fmt/testdata/SimpleComments/fmt.sol index 6e8d5195bd143..1520db43d4162 100644 --- a/crates/fmt/testdata/SimpleComments/fmt.sol +++ b/crates/fmt/testdata/SimpleComments/fmt.sol @@ -1,6 +1,18 @@ contract SimpleComments { + uint40 constant PERIOD = uint40(12345); // ~578 days + // Represents the depletion timestamp + uint40 constant WARP_PERIOD = FEB_1_2025 + PERIOD; + + //´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*: + // VARIABLES + //.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.• + mapping(address /* asset */ => address /* router */) public router; + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* FUNCTIONS */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + constructor() { // TODO: do this and that @@ -49,6 +61,35 @@ contract SimpleComments { return // a line comment that exceeds line width value; } + + // https://github.com/foundry-rs/foundry/issues/11836 + function test5() public { + ( + /* poolIndex */, + uint256 sellAmount1, + uint256 buyAmount1, + /* poolKey1 */, + /* sellToken */, + /* buyToken */, + /* sellTokenBalanceBefore */, + uint256 buyTokenBalanceBefore1, + /* hashMul */, + /* hashMod */ + ) = _swapPre(2, TOTAL_SUPPLY / 1_000, false, zeroForOne1); + } + + // https://github.com/foundry-rs/foundry/issues/12045 + function test6() { + ( + // uint80 roundID + , + int256 dataFeedAnswer, + // uint startedAt + , + uint256 updatedAt, + // uint80 answeredInRound + ) = dataFeedContract.latestRoundData(); + } } /* diff --git a/crates/fmt/testdata/SimpleComments/original.sol b/crates/fmt/testdata/SimpleComments/original.sol index d41c686b2f783..7020527b43671 100644 --- a/crates/fmt/testdata/SimpleComments/original.sol +++ b/crates/fmt/testdata/SimpleComments/original.sol @@ -1,6 +1,17 @@ contract SimpleComments { + uint40 constant PERIOD = uint40(12345); // ~578 days + // Represents the depletion timestamp + uint40 constant WARP_PERIOD = FEB_1_2025 + PERIOD; + + //´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*: + // VARIABLES + //.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.• + mapping(address /* asset */ => address /* router */) public router; + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* FUNCTIONS */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ constructor() { // TODO: do this and that @@ -50,6 +61,35 @@ contract SimpleComments { return // a line comment that exceeds line width value; } + + // https://github.com/foundry-rs/foundry/issues/11836 + function test5() public { + ( + /* poolIndex */, + uint256 sellAmount1, + uint256 buyAmount1, + /* poolKey1 */, + /* sellToken */, + /* buyToken */, + /* sellTokenBalanceBefore */, + uint256 buyTokenBalanceBefore1, + /* hashMul */, + /* hashMod */ + ) = _swapPre(2, TOTAL_SUPPLY / 1_000, false, zeroForOne1); + } + + // https://github.com/foundry-rs/foundry/issues/12045 + function test6() { + ( + // uint80 roundID + , + int256 dataFeedAnswer, + // uint startedAt + , + uint256 updatedAt, + // uint80 answeredInRound + ) = dataFeedContract.latestRoundData(); + } } /* @@ -80,4 +120,4 @@ function test() {} // some comment // another comment -// eof comment \ No newline at end of file +// eof comment diff --git a/crates/fmt/testdata/SimpleComments/wrap-comments.fmt.sol b/crates/fmt/testdata/SimpleComments/wrap-comments.fmt.sol index 06ddc1cc19669..d41b8efa99f08 100644 --- a/crates/fmt/testdata/SimpleComments/wrap-comments.fmt.sol +++ b/crates/fmt/testdata/SimpleComments/wrap-comments.fmt.sol @@ -1,9 +1,22 @@ // config: line_length = 60 // config: wrap_comments = true contract SimpleComments { + uint40 constant PERIOD = uint40(12345); // ~578 days + // Represents the depletion timestamp + uint40 constant WARP_PERIOD = FEB_1_2025 + PERIOD; + + //´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*: + // VARIABLES + //.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.• + mapping(address /* asset */ => address /* router */) public router; + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ + /* FUNCTIONS + */ + /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ + constructor() { // TODO: do this and that @@ -37,8 +50,8 @@ contract SimpleComments { function test4() public view returns (uint256) { uint256 abc; // long postfix comment that exceeds - // line width. the comment should be split and - // carried over to the next line + // line width. the comment should be split and + // carried over to the next line uint256 abc2; // reallylongsinglewordcommentthatexceedslinewidththecommentshouldbesplitandcarriedovertothenextline // long prefix comment that exceeds line width. the @@ -54,13 +67,43 @@ contract SimpleComments { uint256 value; return /* a long block comment that exceeds line - width */ - value; + width */ value; return /* a block comment that exceeds line width */ value; return // a line comment that exceeds line width value; } + + // https://github.com/foundry-rs/foundry/issues/11836 + function test5() public { + ( + /* poolIndex */, + uint256 sellAmount1, + uint256 buyAmount1, + /* poolKey1 */, + /* sellToken */, + /* buyToken */, + /* sellTokenBalanceBefore */, + uint256 buyTokenBalanceBefore1, + /* hashMul */, + /* hashMod */ + ) = _swapPre( + 2, TOTAL_SUPPLY / 1_000, false, zeroForOne1 + ); + } + + // https://github.com/foundry-rs/foundry/issues/12045 + function test6() { + ( + // uint80 roundID + , + int256 dataFeedAnswer, + // uint startedAt + , + uint256 updatedAt, + // uint80 answeredInRound + ) = dataFeedContract.latestRoundData(); + } } /* diff --git a/crates/fmt/testdata/SortedImports/fmt.sol b/crates/fmt/testdata/SortedImports/fmt.sol index f9b2c0ee2a9c3..0c821d365fbf5 100644 --- a/crates/fmt/testdata/SortedImports/fmt.sol +++ b/crates/fmt/testdata/SortedImports/fmt.sol @@ -13,8 +13,8 @@ import { symbol3 as alias1, symbol4 } from "File0.sol"; -import {symbol1 as alias, symbol2} from "File2.sol"; -import {symbol1 as alias, symbol2} from "File3.sol"; +import {symbol1 as alias0, symbol2} from "File2.sol"; +import {symbol1 as alias0, symbol2} from "File3.sol"; import { symbol1 as alias1, symbol2 as alias2, @@ -29,6 +29,6 @@ import {Something2, Something3} from "someFile.sol"; // This is a comment import {Something2, Something3} from "someFile.sol"; -import {symbol1 as alias, symbol2} from "File3.sol"; +import {symbol1 as alias0, symbol2} from "File3.sol"; // comment inside group is treated as a separator for now -import {symbol1 as alias, symbol2} from "File2.sol"; +import {symbol1 as alias0, symbol2} from "File2.sol"; diff --git a/crates/fmt/testdata/SortedImports/original.sol b/crates/fmt/testdata/SortedImports/original.sol index 54b3ca3b59cfb..b52bbda3ad097 100644 --- a/crates/fmt/testdata/SortedImports/original.sol +++ b/crates/fmt/testdata/SortedImports/original.sol @@ -6,8 +6,8 @@ import "SomeFile0.sol" as SomeOtherFile; import "AnotherFile2.sol" as SomeSymbol; import "AnotherFile1.sol" as SomeSymbol; -import {symbol2, symbol1 as alias} from "File3.sol"; -import {symbol2, symbol1 as alias} from "File2.sol"; +import {symbol2, symbol1 as alias0} from "File3.sol"; +import {symbol2, symbol1 as alias0} from "File2.sol"; import {symbol2 as alias2, symbol1 as alias1, symbol3 as alias3, symbol4} from "File6.sol"; import {symbol3 as alias1, symbol2 as alias2, symbol1 as alias3, symbol4} from "File0.sol"; @@ -18,6 +18,6 @@ import {Something3, Something2} from "someFile.sol"; // This is a comment import {Something3, Something2} from "someFile.sol"; -import {symbol2, symbol1 as alias} from "File3.sol"; +import {symbol2, symbol1 as alias0} from "File3.sol"; // comment inside group is treated as a separator for now -import {symbol2, symbol1 as alias} from "File2.sol"; \ No newline at end of file +import {symbol2, symbol1 as alias0} from "File2.sol"; \ No newline at end of file diff --git a/crates/fmt/testdata/StructDefinition/bracket-spacing.fmt.sol b/crates/fmt/testdata/StructDefinition/bracket-spacing.fmt.sol index 3e1c8ea4e3ff1..2b085566b2844 100644 --- a/crates/fmt/testdata/StructDefinition/bracket-spacing.fmt.sol +++ b/crates/fmt/testdata/StructDefinition/bracket-spacing.fmt.sol @@ -1,11 +1,17 @@ // config: bracket_spacing = true -struct Foo { } +struct Foo {} struct Bar { uint256 foo; string bar; } +struct EmptyStructWithComment { /* body */ } + +struct NonEmptyStructTrailingComments { + uint256 x; + /* one */ /* two */ } + struct MyStruct { // first 1 // first 2 diff --git a/crates/fmt/testdata/StructDefinition/fmt.sol b/crates/fmt/testdata/StructDefinition/fmt.sol index 78c5079cd37e9..5be072332e0fc 100644 --- a/crates/fmt/testdata/StructDefinition/fmt.sol +++ b/crates/fmt/testdata/StructDefinition/fmt.sol @@ -5,6 +5,12 @@ struct Bar { string bar; } +struct EmptyStructWithComment { /* body */ } + +struct NonEmptyStructTrailingComments { + uint256 x; + /* one */ /* two */ } + struct MyStruct { // first 1 // first 2 diff --git a/crates/fmt/testdata/StructDefinition/original.sol b/crates/fmt/testdata/StructDefinition/original.sol index a82d7a92e7d7a..a89766eb28a50 100644 --- a/crates/fmt/testdata/StructDefinition/original.sol +++ b/crates/fmt/testdata/StructDefinition/original.sol @@ -1,6 +1,11 @@ struct Foo { + } struct Bar { uint foo ;string bar ; } +struct EmptyStructWithComment { /* body */ } + +struct NonEmptyStructTrailingComments { uint256 x; /* one */ /* two */ } + struct MyStruct { // first 1 // first 2 diff --git a/crates/fmt/testdata/StructDefinition/wrap-comments.fmt.sol b/crates/fmt/testdata/StructDefinition/wrap-comments.fmt.sol new file mode 100644 index 0000000000000..5419f8c898ff9 --- /dev/null +++ b/crates/fmt/testdata/StructDefinition/wrap-comments.fmt.sol @@ -0,0 +1,21 @@ +// config: wrap_comments = true +struct Foo {} + +struct Bar { + uint256 foo; + string bar; +} + +struct EmptyStructWithComment { /* body */ } + +struct NonEmptyStructTrailingComments { + uint256 x; + /* one */ /* two */ } + +struct MyStruct { + // first 1 + // first 2 + uint256 field1; + // second + uint256 field2; +} diff --git a/crates/fmt/testdata/StructFieldAccess/fmt.sol b/crates/fmt/testdata/StructFieldAccess/fmt.sol new file mode 100644 index 0000000000000..22b16bde3f77c --- /dev/null +++ b/crates/fmt/testdata/StructFieldAccess/fmt.sol @@ -0,0 +1,46 @@ +// config: line_length = 120 +// https://github.com/foundry-rs/foundry/issues/12399 +contract StructFieldAccess { + function a() external { + bytes32 guid = + _lzSend({ + _dstEid: dstEid, + _message: message, + _options: OptionsBuilder.newOptions().addExecutorLzReceiveOption({_gas: gasLimit, _value: 0}), + _fee: MessagingFee({nativeFee: msg.value, lzTokenFee: 0}), + _refundAddress: msg.sender + }).guid; + } + + function b() external view returns (uint256) { + return _quote({ + _dstEid: dstEid, + _message: message, + _options: OptionsBuilder.newOptions().addExecutorLzReceiveOption({_gas: gasLimit, _value: 0}), + _payInLzToken: false + }).nativeFee; + } + + // Simple cases + function c() external { + uint256 val = getData().value; + bool flag = getStruct({param: 1}).isActive; + } + + // Nested struct field access + function d() external { + uint256 nested = getOuter().inner.value; + } + + // Chained calls with named args + function e() external { + bytes32 guid = + _lzSend({ + _dstEid: dstEid, + _message: message, + _options: OptionsBuilder.newOptions().addExecutorLzReceiveOption({_gas: gasLimit, _value: 0}), + _fee: MessagingFee({nativeFee: msg.value, lzTokenFee: 0}), + _refundAddress: msg.sender + }).wrap({wrapper: wrapperAddress, extraData: bytes("")}).guid; + } +} diff --git a/crates/fmt/testdata/StructFieldAccess/original.sol b/crates/fmt/testdata/StructFieldAccess/original.sol new file mode 100644 index 0000000000000..7671273c028be --- /dev/null +++ b/crates/fmt/testdata/StructFieldAccess/original.sol @@ -0,0 +1,43 @@ +// https://github.com/foundry-rs/foundry/issues/12399 +contract StructFieldAccess { + function a() external { + bytes32 guid = _lzSend({ + _dstEid: dstEid, + _message: message, + _options: OptionsBuilder.newOptions().addExecutorLzReceiveOption({_gas: gasLimit, _value: 0}), + _fee: MessagingFee({nativeFee: msg.value, lzTokenFee: 0}), + _refundAddress: msg.sender + }).guid; + } + + function b() external view returns (uint256) { + return _quote({ + _dstEid: dstEid, + _message: message, + _options: OptionsBuilder.newOptions().addExecutorLzReceiveOption({_gas: gasLimit, _value: 0}), + _payInLzToken: false + }).nativeFee; + } + + // Simple cases + function c() external { + uint256 val = getData().value; + bool flag = getStruct({param: 1}).isActive; + } + + // Nested struct field access + function d() external { + uint256 nested = getOuter().inner.value; + } + + // Chained calls with named args + function e() external { + bytes32 guid = _lzSend({ + _dstEid: dstEid, + _message: message, + _options: OptionsBuilder.newOptions().addExecutorLzReceiveOption({_gas: gasLimit, _value: 0}), + _fee: MessagingFee({nativeFee: msg.value, lzTokenFee: 0}), + _refundAddress: msg.sender + }).wrap({wrapper: wrapperAddress, extraData: bytes("")}).guid; + } +} diff --git a/crates/fmt/testdata/ThisExpression/fmt.sol b/crates/fmt/testdata/ThisExpression/fmt.sol index 239a6073eae39..458f8f6b859e6 100644 --- a/crates/fmt/testdata/ThisExpression/fmt.sol +++ b/crates/fmt/testdata/ThisExpression/fmt.sol @@ -1,8 +1,7 @@ contract ThisExpression { function someFunc() public {} function someVeryVeryVeryLongVariableNameThatWillBeAccessedByThisKeyword() - public - {} + public {} function test() external { this.someFunc(); @@ -13,8 +12,7 @@ contract ThisExpression { address thisAddress = address( // comment2 - /* comment3 */ - this // comment 4 + /* comment3 */ this // comment 4 ); } } diff --git a/crates/fmt/testdata/TrailingComma/fmt.sol b/crates/fmt/testdata/TrailingComma/fmt.sol index 034ac5d33088d..c69a6562fce9a 100644 --- a/crates/fmt/testdata/TrailingComma/fmt.sol +++ b/crates/fmt/testdata/TrailingComma/fmt.sol @@ -1,8 +1,4 @@ contract C is Contract { - modifier m(uint256) {} - // invalid solidity code, but valid pt - modifier m2(uint256) returns (uint256) {} - function f(uint256 a) external {} function f2(uint256 a, bytes32 b) external returns (uint256) {} diff --git a/crates/fmt/testdata/TrailingComma/original.sol b/crates/fmt/testdata/TrailingComma/original.sol index c06460f250aa8..644eb064c9ba6 100644 --- a/crates/fmt/testdata/TrailingComma/original.sol +++ b/crates/fmt/testdata/TrailingComma/original.sol @@ -1,12 +1,8 @@ contract C is Contract { - modifier m(uint256, ,,, ) {} - // invalid solidity code, but valid pt - modifier m2(uint256) returns (uint256,,,) {} - function f(uint256 a, ) external {} - function f2(uint256 a, , , ,bytes32 b) external returns (uint256,,,,) {} + function f2(uint256 a, bytes32 b,) external returns (uint256,) {} function f3() external { - try some.invoke() returns (uint256,,,uint256) {} catch {} + try some.invoke() returns (uint256,uint256,) {} catch {} } } diff --git a/crates/fmt/testdata/TryStatement/fmt.sol b/crates/fmt/testdata/TryStatement/fmt.sol index d49687eb1285a..cff134e646038 100644 --- a/crates/fmt/testdata/TryStatement/fmt.sol +++ b/crates/fmt/testdata/TryStatement/fmt.sol @@ -62,8 +62,7 @@ contract TryStatement { catch { /* comment6 */ } // comment7 - try unknown.empty() { - // comment8 + try unknown.empty() { // comment8 unknown.doSomething(); } /* comment9 */ catch /* comment10 */ Error(string memory) { unknown.handleError(); @@ -71,4 +70,35 @@ contract TryStatement { unknown.handleError(); } catch {} } + + function test_multiParam() { + Mock mock = new Mock(); + + try mock.add(2, 3) { + revert(); + } catch (bytes memory err) { + require(keccak256(err) == keccak256(ERROR_MESSAGE)); + } + } + + function test_multiComment() { + try vm.envString("API_KEY") returns (string memory) { + console2.log("Forked Ethereum mainnet"); + // Fork mainnet at a specific block for consistency + vm.createSelectFork(vm.rpcUrl("mainnet"), 21_900_000); + // do something + } catch { + /* sadness */ + // more sadness + revert(); + } + } + + function try_reallyLongCall() { + try AggregatorV3Interface(oracle).latestRoundData() returns ( + uint80, int256 _price, uint256, uint256 _updatedAt, uint80 + ) { + return true; + } catch {} // https://github.com/foundry-rs/foundry/issues/12240 + } } diff --git a/crates/fmt/testdata/TryStatement/original.sol b/crates/fmt/testdata/TryStatement/original.sol index 9fc158b20195a..288ad81293738 100644 --- a/crates/fmt/testdata/TryStatement/original.sol +++ b/crates/fmt/testdata/TryStatement/original.sol @@ -39,10 +39,10 @@ contract TryStatement { } try unknown.lookupMultipleValues() returns (uint256, uint256, uint256, uint256, uint256) {} catch Error(string memory) {} catch {} - + try unknown.lookupMultipleValues() returns (uint256, uint256, uint256, uint256, uint256) { unknown.doSomething(); - } + } catch Error(string memory) { unknown.handleError(); } @@ -55,7 +55,7 @@ contract TryStatement { catch /* comment6 */ {} // comment7 - try unknown.empty() { // comment8 + try unknown.empty() { // comment8 unknown.doSomething(); } /* comment9 */ catch /* comment10 */ Error(string memory) { unknown.handleError(); @@ -63,4 +63,32 @@ contract TryStatement { unknown.handleError(); } catch {} } -} \ No newline at end of file + + function test_multiParam() { + Mock mock = new Mock(); + + try mock.add(2, 3) { + revert(); + } catch (bytes memory err) { + require(keccak256(err) == keccak256(ERROR_MESSAGE)); + } + } + + function test_multiComment() { + try vm.envString("API_KEY") returns (string memory) { + console2.log("Forked Ethereum mainnet"); + // Fork mainnet at a specific block for consistency + vm.createSelectFork(vm.rpcUrl("mainnet"), 21_900_000); + // do something + } catch /* sadness */ { + // more sadness + revert(); + } + } + + function try_reallyLongCall() { + try AggregatorV3Interface(oracle).latestRoundData() returns (uint80, int256 _price, uint256, uint256 _updatedAt, uint80) { + return true; + } catch {} // https://github.com/foundry-rs/foundry/issues/12240 + } +} diff --git a/crates/fmt/testdata/UnitExpression/fmt.sol b/crates/fmt/testdata/UnitExpression/fmt.sol index ceb16c86c7813..1f1fa4d727e71 100644 --- a/crates/fmt/testdata/UnitExpression/fmt.sol +++ b/crates/fmt/testdata/UnitExpression/fmt.sol @@ -15,10 +15,12 @@ contract UnitExpression { uint256 someVeryVeryVeryLongVariableNameForTheMultiplierForEtherValue; value = someVeryVeryVeryLongVariableNameForTheMultiplierForEtherValue - * 1 /* comment1 */ ether; // comment2 + * 1 ether; + value = someVeryVeryVeryLongVariableNameForTheMultiplierForEtherValue + * 1 ether; /* comment1 */ // comment2 - value = 1 // comment3 - // comment4 - ether; // comment5 + value = 1 ether; // comment3 + // comment4 + // comment5 } } diff --git a/crates/fmt/testdata/UnitExpression/original.sol b/crates/fmt/testdata/UnitExpression/original.sol index f85af34fe7770..1a252bf5b3e5c 100644 --- a/crates/fmt/testdata/UnitExpression/original.sol +++ b/crates/fmt/testdata/UnitExpression/original.sol @@ -14,10 +14,11 @@ contract UnitExpression { uint256 someVeryVeryVeryLongVariableNameForTheMultiplierForEtherValue; + value = someVeryVeryVeryLongVariableNameForTheMultiplierForEtherValue * 1 ether; value = someVeryVeryVeryLongVariableNameForTheMultiplierForEtherValue * 1 /* comment1 */ ether; // comment2 value = 1 // comment3 // comment4 ether; // comment5 } -} \ No newline at end of file +} diff --git a/crates/fmt/testdata/VariableAssignment/bracket-spacing.fmt.sol b/crates/fmt/testdata/VariableAssignment/bracket-spacing.fmt.sol index 8896668d1e43c..07b4866e4d6cb 100644 --- a/crates/fmt/testdata/VariableAssignment/bracket-spacing.fmt.sol +++ b/crates/fmt/testdata/VariableAssignment/bracket-spacing.fmt.sol @@ -24,4 +24,49 @@ contract TestContract { uint256 allowed = allowance[from][msg.sender]; allowance[from][msg.sender] = allowed; } + + function test_longAssignements() public { + string[] memory inputs = new string[](3); + inputs[0] = "bash"; + inputs[1] = "-c"; + inputs[2] = + "echo -n 0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000966666920776f726b730000000000000000000000000000000000000000000000"; + } + + function test_stringConcatenation() public { + string memory strConcat = "0," "11579208923731619542357098500868790785," + "0x0000000000000000000000000000000000000000000000000000000000000000," + "0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"; + } + + // https://github.com/foundry-rs/foundry/issues/12254 + function test_longIndexedCall() { + bytes memory message = mailboxes[destinationDomain].buildMessage( + originDomain, + bytes32(0), + address(inbox).toBytes32(), + abi.encode(orderId, bytes32(0), address(0)) + ); + // should have identicall behavior when call of the same size without indexing + bytes memory message = mailboxes_destinationDomains.buildMessage( + originDomain, + bytes32(0), + address(inbox).toBytes32(), + abi.encode(orderId, bytes32(0), address(0)) + ); + } + + // https://github.com/foundry-rs/foundry/issues/12322 + function test_longComplexBinExpr() { + vars.previousTotalDebt = getDescaledAmount( + flow.getSnapshotDebtScaled(streamId), + flow.getTokenDecimals(streamId) + ) + vars.previousOngoingDebtScaled; + + vars.previousTotalDebt = vars.reallyLongVarThatCausesALineBreak + + vars.previousOngoingDebtScaled; + + vars.previousTotalDebt = vars.reallyLongVarThatCausesALineBreak() + .previousOngoingDebtScaled(); + } } diff --git a/crates/fmt/testdata/VariableAssignment/fmt.sol b/crates/fmt/testdata/VariableAssignment/fmt.sol index 07480d873c21e..080992fe050b0 100644 --- a/crates/fmt/testdata/VariableAssignment/fmt.sol +++ b/crates/fmt/testdata/VariableAssignment/fmt.sol @@ -23,4 +23,49 @@ contract TestContract { uint256 allowed = allowance[from][msg.sender]; allowance[from][msg.sender] = allowed; } + + function test_longAssignements() public { + string[] memory inputs = new string[](3); + inputs[0] = "bash"; + inputs[1] = "-c"; + inputs[2] = + "echo -n 0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000966666920776f726b730000000000000000000000000000000000000000000000"; + } + + function test_stringConcatenation() public { + string memory strConcat = "0," "11579208923731619542357098500868790785," + "0x0000000000000000000000000000000000000000000000000000000000000000," + "0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"; + } + + // https://github.com/foundry-rs/foundry/issues/12254 + function test_longIndexedCall() { + bytes memory message = mailboxes[destinationDomain].buildMessage( + originDomain, + bytes32(0), + address(inbox).toBytes32(), + abi.encode(orderId, bytes32(0), address(0)) + ); + // should have identicall behavior when call of the same size without indexing + bytes memory message = mailboxes_destinationDomains.buildMessage( + originDomain, + bytes32(0), + address(inbox).toBytes32(), + abi.encode(orderId, bytes32(0), address(0)) + ); + } + + // https://github.com/foundry-rs/foundry/issues/12322 + function test_longComplexBinExpr() { + vars.previousTotalDebt = getDescaledAmount( + flow.getSnapshotDebtScaled(streamId), + flow.getTokenDecimals(streamId) + ) + vars.previousOngoingDebtScaled; + + vars.previousTotalDebt = vars.reallyLongVarThatCausesALineBreak + + vars.previousOngoingDebtScaled; + + vars.previousTotalDebt = vars.reallyLongVarThatCausesALineBreak() + .previousOngoingDebtScaled(); + } } diff --git a/crates/fmt/testdata/VariableAssignment/original.sol b/crates/fmt/testdata/VariableAssignment/original.sol index 07480d873c21e..2ccc26c97df29 100644 --- a/crates/fmt/testdata/VariableAssignment/original.sol +++ b/crates/fmt/testdata/VariableAssignment/original.sol @@ -23,4 +23,33 @@ contract TestContract { uint256 allowed = allowance[from][msg.sender]; allowance[from][msg.sender] = allowed; } + + function test_longAssignements() public { + string[] memory inputs = new string[](3); + inputs[0] = "bash"; + inputs[1] = "-c"; + inputs[2] = "echo -n 0x0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000966666920776f726b730000000000000000000000000000000000000000000000"; + } + + function test_stringConcatenation() public { + string memory strConcat = "0," "11579208923731619542357098500868790785," + "0x0000000000000000000000000000000000000000000000000000000000000000," + "0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"; + } + + // https://github.com/foundry-rs/foundry/issues/12254 + function test_longIndexedCall() { + bytes memory message = mailboxes[destinationDomain].buildMessage(originDomain, bytes32(0), address(inbox).toBytes32(), abi.encode(orderId, bytes32(0), address(0))); + // should have identicall behavior when call of the same size without indexing + bytes memory message = mailboxes_destinationDomains.buildMessage(originDomain, bytes32(0), address(inbox).toBytes32(), abi.encode(orderId, bytes32(0), address(0))); + } + + // https://github.com/foundry-rs/foundry/issues/12322 + function test_longComplexBinExpr() { + vars.previousTotalDebt = getDescaledAmount(flow.getSnapshotDebtScaled(streamId), flow.getTokenDecimals(streamId)) + vars.previousOngoingDebtScaled; + + vars.previousTotalDebt = vars.reallyLongVarThatCausesALineBreak + vars.previousOngoingDebtScaled; + + vars.previousTotalDebt = vars.reallyLongVarThatCausesALineBreak() .previousOngoingDebtScaled(); + } } diff --git a/crates/fmt/testdata/VariableDefinition/fmt.sol b/crates/fmt/testdata/VariableDefinition/fmt.sol index 85a88e5326de8..d783890c8b0e2 100644 --- a/crates/fmt/testdata/VariableDefinition/fmt.sol +++ b/crates/fmt/testdata/VariableDefinition/fmt.sol @@ -2,26 +2,23 @@ contract Contract layout at 69 { bytes32 transient a; - bytes32 private constant BYTES; + bytes32 private constant BYTES = 0; bytes32 private constant - override(Base1) BYTES; + override(Base1) BYTES = 0; bytes32 private constant - override(Base1, Base2) BYTES; + override(Base1, Base2) BYTES = 0; bytes32 private constant - immutable - override BYTES; + override BYTES = 0; bytes32 private constant - immutable - override - BYTES_VERY_VERY_VERY_LONG; + override BYTES_VERY_VERY_VERY_LONG = 0; bytes32 private constant @@ -31,22 +28,19 @@ contract Contract layout at 69 { SomeLongBaseContract, AndAnotherVeryLongBaseContract, Imported.Contract - ) BYTES_OVERRIDDEN; + ) BYTES_OVERRIDDEN = 0; bytes32 private constant BYTES = 0x035aff83d86937d35b32e04f0ddc6ff469290eef2f1b692d8a815c89404d4749; bytes32 private constant - immutable override BYTES = 0x035aff83d86937d35b32e04f0ddc6ff469290eef2f1b692d8a815c89404d4749; bytes32 private constant - immutable - override - BYTES_VERY_VERY_VERY_LONG = + override BYTES_VERY_VERY_VERY_LONG = 0x035aff83d86937d35b32e04f0ddc6ff469290eef2f1b692d8a815c89404d4749; bytes32 private constant BYTES_VERY_VERY_LONG = diff --git a/crates/fmt/testdata/VariableDefinition/original.sol b/crates/fmt/testdata/VariableDefinition/original.sol index 279c62e55b30c..eccd61018e24b 100644 --- a/crates/fmt/testdata/VariableDefinition/original.sol +++ b/crates/fmt/testdata/VariableDefinition/original.sol @@ -1,18 +1,18 @@ contract Contract layout at 69 { bytes32 transient a; - bytes32 constant private BYTES; - bytes32 private constant override (Base1) BYTES; - bytes32 private constant override (Base1, Base2) BYTES; - bytes32 private constant override immutable BYTES; - bytes32 private constant override immutable BYTES_VERY_VERY_VERY_LONG; - bytes32 private constant override(Base1, Base2, SomeLongBaseContract, AndAnotherVeryLongBaseContract, Imported.Contract) BYTES_OVERRIDDEN; + bytes32 constant private BYTES = 0; + bytes32 private constant override (Base1) BYTES = 0; + bytes32 private constant override (Base1, Base2) BYTES = 0; + bytes32 private constant override BYTES = 0; + bytes32 private constant override BYTES_VERY_VERY_VERY_LONG = 0; + bytes32 private constant override(Base1, Base2, SomeLongBaseContract, AndAnotherVeryLongBaseContract, Imported.Contract) BYTES_OVERRIDDEN = 0; bytes32 constant private BYTES = 0x035aff83d86937d35b32e04f0ddc6ff469290eef2f1b692d8a815c89404d4749; - bytes32 private constant override immutable BYTES = + bytes32 private constant override BYTES = 0x035aff83d86937d35b32e04f0ddc6ff469290eef2f1b692d8a815c89404d4749; - bytes32 private constant override immutable BYTES_VERY_VERY_VERY_LONG = + bytes32 private constant override BYTES_VERY_VERY_VERY_LONG = 0x035aff83d86937d35b32e04f0ddc6ff469290eef2f1b692d8a815c89404d4749; bytes32 private constant BYTES_VERY_VERY_LONG = 0x035aff83d86937d35b32e04f0ddc6ff469290eef2f1b692d8a815c89404d4749; diff --git a/crates/fmt/testdata/VariableDefinition/override-spacing.fmt.sol b/crates/fmt/testdata/VariableDefinition/override-spacing.fmt.sol index 41ef397f65156..3b78c06f2368e 100644 --- a/crates/fmt/testdata/VariableDefinition/override-spacing.fmt.sol +++ b/crates/fmt/testdata/VariableDefinition/override-spacing.fmt.sol @@ -3,26 +3,23 @@ contract Contract layout at 69 { bytes32 transient a; - bytes32 private constant BYTES; + bytes32 private constant BYTES = 0; bytes32 private constant - override (Base1) BYTES; + override (Base1) BYTES = 0; bytes32 private constant - override (Base1, Base2) BYTES; + override (Base1, Base2) BYTES = 0; bytes32 private constant - immutable - override BYTES; + override BYTES = 0; bytes32 private constant - immutable - override - BYTES_VERY_VERY_VERY_LONG; + override BYTES_VERY_VERY_VERY_LONG = 0; bytes32 private constant @@ -32,22 +29,19 @@ contract Contract layout at 69 { SomeLongBaseContract, AndAnotherVeryLongBaseContract, Imported.Contract - ) BYTES_OVERRIDDEN; + ) BYTES_OVERRIDDEN = 0; bytes32 private constant BYTES = 0x035aff83d86937d35b32e04f0ddc6ff469290eef2f1b692d8a815c89404d4749; bytes32 private constant - immutable override BYTES = 0x035aff83d86937d35b32e04f0ddc6ff469290eef2f1b692d8a815c89404d4749; bytes32 private constant - immutable - override - BYTES_VERY_VERY_VERY_LONG = + override BYTES_VERY_VERY_VERY_LONG = 0x035aff83d86937d35b32e04f0ddc6ff469290eef2f1b692d8a815c89404d4749; bytes32 private constant BYTES_VERY_VERY_LONG = diff --git a/crates/fmt/testdata/WhileStatement/block-multi.fmt.sol b/crates/fmt/testdata/WhileStatement/block-multi.fmt.sol index cff7ac40b3d29..7e7fb61a82407 100644 --- a/crates/fmt/testdata/WhileStatement/block-multi.fmt.sol +++ b/crates/fmt/testdata/WhileStatement/block-multi.fmt.sol @@ -55,10 +55,21 @@ contract WhileStatement { while (condition) { doIt(); + doIt(); } - while ( - // comment1 + while (condition) { + if (condition) { + doIt(); + doIt(); + } + } + + while (condition) { + doIt(); + } + + while ( // comment1 condition ) { doIt(); @@ -76,5 +87,56 @@ contract WhileStatement { ) { doIt(); } + + while (condition) { + doIt(); + doIt(); + } + + while ( /* cmt */ + cond + ) { + doIt(); + doIt(); + } + + while (cond) { + while ( /* cmt */ + cond + ) { + doIt(); + doIt(); + } + } + + while ( + cond /* cmt */ + ) { + doIt(); + doIt(); + } + + while (cond) { + while ( + cond /* cmt */ + ) { + doIt(); + doIt(); + } + } + + while (cond) { + /* cmt */ + doIt(); + doIt(); + } + + while (cond) { + while (cond) { + /* cmt */ + doIt(); + doIt(); + } + } } } diff --git a/crates/fmt/testdata/WhileStatement/block-single.fmt.sol b/crates/fmt/testdata/WhileStatement/block-single.fmt.sol index ee5c48b7d911f..88e7cbeb11937 100644 --- a/crates/fmt/testdata/WhileStatement/block-single.fmt.sol +++ b/crates/fmt/testdata/WhileStatement/block-single.fmt.sol @@ -33,20 +33,86 @@ contract WhileStatement { while (condition) doIt(); + while (condition) { + doIt(); + doIt(); + } + + while (condition) { + if (condition) { + doIt(); + doIt(); + } + } + while (condition) doIt(); - while ( - // comment1 + while ( // comment1 condition - ) doIt(); + ) { + doIt(); + } while ( condition // comment2 - ) doIt(); + ) { + doIt(); + } while ( someLongVariableName < 10 && someLongVariableName < 11 && someLongVariableName < 12 ) doIt(); + + while (condition) { + doIt(); + doIt(); + } + + while ( /* cmt */ + cond + ) { + doIt(); + doIt(); + } + + while (cond) { + while ( /* cmt */ + cond + ) { + doIt(); + doIt(); + } + } + + while ( + cond /* cmt */ + ) { + doIt(); + doIt(); + } + + while (cond) { + while ( + cond /* cmt */ + ) { + doIt(); + doIt(); + } + } + + while (cond) { + /* cmt */ + doIt(); + doIt(); + } + + while (cond) { + while (cond) { + /* cmt */ + doIt(); + doIt(); + } + } } } diff --git a/crates/fmt/testdata/WhileStatement/fmt.sol b/crates/fmt/testdata/WhileStatement/fmt.sol index 131c4eaedb799..9e803b8a9382f 100644 --- a/crates/fmt/testdata/WhileStatement/fmt.sol +++ b/crates/fmt/testdata/WhileStatement/fmt.sol @@ -40,20 +40,86 @@ contract WhileStatement { while (condition) doIt(); + while (condition) { + doIt(); + doIt(); + } + + while (condition) { + if (condition) { + doIt(); + doIt(); + } + } + while (condition) doIt(); - while ( - // comment1 + while ( // comment1 condition - ) doIt(); + ) { + doIt(); + } while ( condition // comment2 - ) doIt(); + ) { + doIt(); + } while ( someLongVariableName < 10 && someLongVariableName < 11 && someLongVariableName < 12 ) doIt(); + + while (condition) { + doIt(); + doIt(); + } + + while ( /* cmt */ + cond + ) { + doIt(); + doIt(); + } + + while (cond) { + while ( /* cmt */ + cond + ) { + doIt(); + doIt(); + } + } + + while ( + cond /* cmt */ + ) { + doIt(); + doIt(); + } + + while (cond) { + while ( + cond /* cmt */ + ) { + doIt(); + doIt(); + } + } + + while (cond) { + /* cmt */ + doIt(); + doIt(); + } + + while (cond) { + while (cond) { + /* cmt */ + doIt(); + doIt(); + } + } } } diff --git a/crates/fmt/testdata/WhileStatement/original.sol b/crates/fmt/testdata/WhileStatement/original.sol index 8b245b0cfa0f8..3564da0f3eccb 100644 --- a/crates/fmt/testdata/WhileStatement/original.sol +++ b/crates/fmt/testdata/WhileStatement/original.sol @@ -36,6 +36,10 @@ contract WhileStatement { while(condition) { doIt(); } + while(condition) { doIt(); doIt(); } + + while(condition) if (condition) { doIt(); doIt(); } + while (condition) doIt(); @@ -47,5 +51,19 @@ contract WhileStatement { ) doIt(); while ( someLongVariableName < 10 && someLongVariableName < 11 && someLongVariableName < 12) doIt(); + + while (condition) { doIt(); doIt(); } + + while /* cmt */ (cond) { doIt(); doIt(); } + + while (cond) while /* cmt */ (cond) { doIt(); doIt(); } + + while (cond) /* cmt */ { doIt(); doIt(); } + + while (cond) while (cond) /* cmt */ { doIt(); doIt(); } + + while (cond) {/* cmt */ doIt(); doIt(); } + + while (cond) while (cond) {/* cmt */ doIt(); doIt(); } } } \ No newline at end of file diff --git a/crates/fmt/testdata/Yul/fmt.sol b/crates/fmt/testdata/Yul/fmt.sol index 2f37eb2f290fe..26ae8f2eb884d 100644 --- a/crates/fmt/testdata/Yul/fmt.sol +++ b/crates/fmt/testdata/Yul/fmt.sol @@ -1,3 +1,4 @@ +// config: number_underscore = "thousands" contract Yul { function test() external { // https://github.com/euler-xyz/euler-contracts/blob/d4f207a4ac5a6e8ab7447a0f09d1399150c41ef4/contracts/vendor/MerkleProof.sol#L54 @@ -69,7 +70,7 @@ contract Yul { } // ************ - /* + /* calls pair.swap( tokenOutNo == 0 ? amountOut : 0, tokenOutNo == 1 ? amountOut : 0, @@ -96,7 +97,9 @@ contract Yul { mstore(0xe0, 0x80) let s2 := call(sub(gas(), 5000), pair, 0, 0x7c, 0xa4, 0, 0) - if iszero(s2) { revert(3, 3) } + if iszero(s2) { + revert(3, 3) + } } // https://github.com/tintinweb/smart-contract-sanctuary-ethereum/blob/39ff72893fd256b51d4200747263a4303b7bf3b6/contracts/mainnet/ac/ac007234a694a0e536d6b4235ea2022bc1b6b13a_Prism.sol#L147 @@ -172,8 +175,8 @@ contract Yul { v7 {} - let zero:u32 := 0:u32 - let v:u256, t:u32 := sample(1, 2) + let zero := 0 + let v, t := sample(1, 2) let x, y := sample2(2, 1) let val1, val2, val3, val4, val5, val6, val7 @@ -184,5 +187,70 @@ contract Yul { assembly { a := 1 /* some really really really long comment that should not fit in one line */ } + + assembly ("memory-safe") { + let fmp := mload(0x40) + // do something + } + + assembly { + let addrSlot := + or( + mul(eq(0x8f283970, fnSel), adminSlot), // `changeAdmin(address)`. + mul(eq(0x0900f010, fnSel), adminSlot) // `upgrade(address)`. + ) + + for {} 1 {} { + if iszero(not(mload(i))) { break } // Break if all limbs are zero. + } + + switch mode + // Get value. + case 0 { + result := getStr( + input, + _BITPOS_VALUE, + _BITPOS_VALUE_LENGTH, + _VALUE_INITED + ) + } + // Get children. + case 3 { result := children(input) } + // Parse. + default { + let p := add(input, 0x20) + let e := add(p, mload(input)) + if iszero(eq(p, e)) { + let c := chr(e) + mstore8(e, 34) // Place a '"' at the end to speed up parsing. + // The `34 << 248` makes `mallocItem` preserve '"' at the end. + mstore(0x00, setP(shl(248, 34), _BITPOS_STRING, input)) + result, p := parseValue(input, 0, p, e) + mstore8(e, c) // Restore the original char at the end. + } + } + } + + assembly { + function parseNumber(s_, packed_, pIn_, end_) -> _item, _pOut { + _pOut := pIn_ + if eq(chr(_pOut), 45) { _pOut := add(_pOut, 1) } // '-'. + if iszero(lt(sub(chr(_pOut), 48), 10)) { fail() } // Not '0'..'9'. + let c_ := chr(_pOut) + _pOut := add(_pOut, 1) + if iszero(eq(c_, 48)) { + _pOut := skip0To9s(_pOut, end_, 0) + } // Not '0'. + if eq(chr(_pOut), 46) { + _pOut := skip0To9s(add(_pOut, 1), end_, 1) + } // '.'. + let t_ := mload(_pOut) + if eq(or(0x20, byte(0, t_)), 101) { + // forgefmt: disable-next-item + _pOut := skip0To9s(add(byte(sub(byte(1, t_), 14), 0x010001), // '+', '-'. + add(_pOut, 1)), end_, 1) + } + } + } } } diff --git a/crates/fmt/testdata/Yul/original.sol b/crates/fmt/testdata/Yul/original.sol index 5bd47c8dd9796..1fba402759726 100644 --- a/crates/fmt/testdata/Yul/original.sol +++ b/crates/fmt/testdata/Yul/original.sol @@ -67,7 +67,7 @@ contract Yul { } // ************ - /* + /* calls pair.swap( tokenOutNo == 0 ? amountOut : 0, tokenOutNo == 1 ? amountOut : 0, @@ -93,7 +93,7 @@ contract Yul { // empty bytes mstore(0xe0, 0x80) - let s2 := call(sub(gas(), 5000), pair, 0, 0x7c, 0xa4, 0, 0) + let s2 := call(sub(gas(), 5_000), pair, 0, 0x7c, 0xa4, 0, 0) if iszero(s2) { revert(3, 3) } @@ -128,8 +128,8 @@ contract Yul { function functionThatReturnsSevenValuesAndCanBeUsedInAssignment() -> v1, v2, v3, v4, v5, v6, v7 {} - let zero:u32 := 0:u32 - let v:u256, t:u32 := sample(1, 2) + let zero := 0 + let v, t := sample(1, 2) let x, y := sample2(2, 1) let val1, val2, val3, val4, val5, val6, val7 @@ -137,5 +137,59 @@ contract Yul { } assembly { a := 1 /* some really really really long comment that should not fit in one line */ } + + assembly ("memory-safe") { + let fmp := mload(0x40) + // do something + } + + assembly { + let addrSlot := + or( + mul(eq(0x8f283970, fnSel), adminSlot), // `changeAdmin(address)`. + mul(eq(0x0900f010, fnSel), adminSlot) // `upgrade(address)`. + ) + + for {} 1 {} { + if iszero(not(mload(i))) { break } // Break if all limbs are zero. + } + + switch mode + // Get value. + case 0 { result := getStr(input, _BITPOS_VALUE, _BITPOS_VALUE_LENGTH, _VALUE_INITED) } + // Get children. + case 3 { result := children(input) } + // Parse. + default { + let p := add(input, 0x20) + let e := add(p, mload(input)) + if iszero(eq(p, e)) { + let c := chr(e) + mstore8(e, 34) // Place a '"' at the end to speed up parsing. + // The `34 << 248` makes `mallocItem` preserve '"' at the end. + mstore(0x00, setP(shl(248, 34), _BITPOS_STRING, input)) + result, p := parseValue(input, 0, p, e) + mstore8(e, c) // Restore the original char at the end. + } + } + } + + assembly { + function parseNumber(s_, packed_, pIn_, end_) -> _item, _pOut { + _pOut := pIn_ + if eq(chr(_pOut), 45) { _pOut := add(_pOut, 1) } // '-'. + if iszero(lt(sub(chr(_pOut), 48), 10)) { fail() } // Not '0'..'9'. + let c_ := chr(_pOut) + _pOut := add(_pOut, 1) + if iszero(eq(c_, 48)) { _pOut := skip0To9s(_pOut, end_, 0) } // Not '0'. + if eq(chr(_pOut), 46) { _pOut := skip0To9s(add(_pOut, 1), end_, 1) } // '.'. + let t_ := mload(_pOut) + if eq(or(0x20, byte(0, t_)), 101) { + // forgefmt: disable-next-item + _pOut := skip0To9s(add(byte(sub(byte(1, t_), 14), 0x010001), // '+', '-'. + add(_pOut, 1)), end_, 1) + } + } + } } } diff --git a/crates/fmt/testdata/YulInlineBlock/fmt.sol b/crates/fmt/testdata/YulInlineBlock/fmt.sol new file mode 100644 index 0000000000000..288ee9b5f7958 --- /dev/null +++ b/crates/fmt/testdata/YulInlineBlock/fmt.sol @@ -0,0 +1,35 @@ +contract YulInlineBlock { + function switchStmt() external view { + assembly { + switch 0 + case 0 {} + default {} + } + } + + function forStmt() external view { + assembly { + for {} 1 { pop(sload(0)) } { + break + continue + } + } + } + + function nestedIfWithTwoStmts() external view { + assembly { + if 1 { + pop(sload(0)) + pop(sload(1)) + } + } + } + + function stillInlined() external view { + assembly { pop(sload(0)) } + } + + function ifStillInlined() external view { + assembly { if 1 { pop(sload(0)) } } + } +} diff --git a/crates/fmt/testdata/YulInlineBlock/original.sol b/crates/fmt/testdata/YulInlineBlock/original.sol new file mode 100644 index 0000000000000..a60d83e0c898b --- /dev/null +++ b/crates/fmt/testdata/YulInlineBlock/original.sol @@ -0,0 +1,21 @@ +contract YulInlineBlock { + function switchStmt() external view { + assembly { switch 0 case 0 {} default {} } + } + + function forStmt() external view { + assembly { for {} 1 { pop(sload(0)) } { break continue } } + } + + function nestedIfWithTwoStmts() external view { + assembly { if 1 { pop(sload(0)) pop(sload(1)) } } + } + + function stillInlined() external view { + assembly { pop(sload(0)) } + } + + function ifStillInlined() external view { + assembly { if 1 { pop(sload(0)) } } + } +} diff --git a/crates/fmt/testdata/YulStrings/fmt.sol b/crates/fmt/testdata/YulStrings/fmt.sol index d05caeb26692a..6b440daed24da 100644 --- a/crates/fmt/testdata/YulStrings/fmt.sol +++ b/crates/fmt/testdata/YulStrings/fmt.sol @@ -3,12 +3,9 @@ contract Yul { assembly { let a := "abc" let b := "abc" - let c := "abc":u32 - let d := "abc":u32 - let e := hex"deadbeef" - let f := hex"deadbeef" - let g := hex"deadbeef":u32 - let h := hex"deadbeef":u32 + let c := hex"deadbeef" + let d := hex"deadbeef" + let e := 0xffffffffffffffffffffffffffffffffffffffff datacopy(0, dataoffset("runtime"), datasize("runtime")) return(0, datasize("runtime")) } diff --git a/crates/fmt/testdata/YulStrings/original.sol b/crates/fmt/testdata/YulStrings/original.sol index fb3d5d20f4b76..ce066ef5c11e1 100644 --- a/crates/fmt/testdata/YulStrings/original.sol +++ b/crates/fmt/testdata/YulStrings/original.sol @@ -3,12 +3,9 @@ contract Yul { assembly { let a := "abc" let b := 'abc' - let c := "abc":u32 - let d := 'abc':u32 - let e := hex"deadbeef" - let f := hex'deadbeef' - let g := hex"deadbeef":u32 - let h := hex'deadbeef':u32 + let c := hex"deadbeef" + let d := hex'deadbeef' + let e := 0xffffffffffffffffffffffffffffffffffffffff datacopy(0, dataoffset('runtime'), datasize("runtime")) return(0, datasize("runtime")) } diff --git a/crates/fmt/testdata/YulStrings/preserve-quote.fmt.sol b/crates/fmt/testdata/YulStrings/preserve-quote.fmt.sol index dff9435396706..cb309029bbb87 100644 --- a/crates/fmt/testdata/YulStrings/preserve-quote.fmt.sol +++ b/crates/fmt/testdata/YulStrings/preserve-quote.fmt.sol @@ -4,12 +4,9 @@ contract Yul { assembly { let a := "abc" let b := 'abc' - let c := "abc":u32 - let d := 'abc':u32 - let e := hex"deadbeef" - let f := hex'deadbeef' - let g := hex"deadbeef":u32 - let h := hex'deadbeef':u32 + let c := hex"deadbeef" + let d := hex'deadbeef' + let e := 0xffffffffffffffffffffffffffffffffffffffff datacopy(0, dataoffset('runtime'), datasize("runtime")) return(0, datasize("runtime")) } diff --git a/crates/fmt/testdata/YulStrings/single-quote.fmt.sol b/crates/fmt/testdata/YulStrings/single-quote.fmt.sol index f1fc7fb8b514a..21ee6bc956e7d 100644 --- a/crates/fmt/testdata/YulStrings/single-quote.fmt.sol +++ b/crates/fmt/testdata/YulStrings/single-quote.fmt.sol @@ -4,12 +4,9 @@ contract Yul { assembly { let a := 'abc' let b := 'abc' - let c := 'abc':u32 - let d := 'abc':u32 - let e := hex'deadbeef' - let f := hex'deadbeef' - let g := hex'deadbeef':u32 - let h := hex'deadbeef':u32 + let c := hex'deadbeef' + let d := hex'deadbeef' + let e := 0xffffffffffffffffffffffffffffffffffffffff datacopy(0, dataoffset('runtime'), datasize('runtime')) return(0, datasize('runtime')) } diff --git a/crates/fmt/tests/formatter.rs b/crates/fmt/tests/formatter.rs index 132b73902d1d9..fb768ca795bfb 100644 --- a/crates/fmt/tests/formatter.rs +++ b/crates/fmt/tests/formatter.rs @@ -1,244 +1,968 @@ -use forge_fmt::{FormatterConfig, format_to, parse, solang_ext::AstEq}; -use itertools::Itertools; -use std::{fs, path::PathBuf}; -use tracing_subscriber::{EnvFilter, FmtSubscriber}; - -fn tracing() { - let subscriber = FmtSubscriber::builder() - .with_env_filter(EnvFilter::from_default_env()) - .with_test_writer() - .finish(); - let _ = tracing::subscriber::set_global_default(subscriber); -} - -fn test_directory(base_name: &str, test_config: TestConfig) { - tracing(); - let mut original = None; - - let tests = - fs::read_dir(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata").join(base_name)) - .unwrap() - .filter_map(|path| { - let path = path.unwrap().path(); - let source = fs::read_to_string(&path).unwrap(); - - if let Some(filename) = path.file_name().and_then(|name| name.to_str()) { - if filename == "original.sol" { - original = Some(source); - } else if filename - .strip_suffix("fmt.sol") - .map(|filename| filename.strip_suffix('.')) - .is_some() - { - // The majority of the tests were written with the assumption - // that the default value for max line length is `80`. - // Preserve that to avoid rewriting test logic. - let default_config = - FormatterConfig { line_length: 80, ..Default::default() }; - - let mut config = toml::Value::try_from(default_config).unwrap(); - let config_table = config.as_table_mut().unwrap(); - let mut lines = source.split('\n').peekable(); - let mut line_num = 1; - while let Some(line) = lines.peek() { - let entry = line - .strip_prefix("//") - .and_then(|line| line.trim().strip_prefix("config:")) - .map(str::trim); - let entry = if let Some(entry) = entry { entry } else { break }; - - let values = match toml::from_str::(entry) { - Ok(toml::Value::Table(table)) => table, - _ => panic!("Invalid config item in {filename} at {line_num}"), - }; - config_table.extend(values); - - line_num += 1; - lines.next(); - } - let config = config - .try_into() - .unwrap_or_else(|err| panic!("Invalid config for {filename}: {err}")); - - return Some((filename.to_string(), config, lines.join("\n"))); - } - } - - None - }) - .collect::>(); - - for (filename, config, formatted) in tests { - test_formatter( - &filename, - config, - original.as_ref().expect("original.sol not found"), - &formatted, - test_config, - ); +use forge_fmt::{DocCommentStyle, FormatterConfig}; +use foundry_config::fmt::IndentStyle; +use foundry_test_utils::init_tracing; +use snapbox::{Data, assert_data_eq}; +use solar::sema::Compiler; +use std::{ + fs, + path::{Path, PathBuf}, + sync::Arc, +}; + +#[track_caller] +fn format(source: &str, path: &Path, fmt_config: Arc) -> String { + let mut compiler = Compiler::new( + solar::interface::Session::builder().with_buffer_emitter(Default::default()).build(), + ); + + match forge_fmt::format_source(source, Some(path), fmt_config, &mut compiler).into_result() { + Ok(formatted) => formatted, + Err(e) => panic!("failed to format {path:?}: {e}"), } } +#[track_caller] fn assert_eof(content: &str) { - assert!(content.ends_with('\n') && !content.ends_with("\n\n")); + assert!(content.ends_with('\n'), "missing trailing newline"); + assert!(!content.ends_with("\n\n"), "extra trailing newline"); } -fn test_formatter( - filename: &str, - config: FormatterConfig, - source: &str, - expected_source: &str, - test_config: TestConfig, -) { - #[derive(Eq)] - struct PrettyString(String); +#[test] +fn binary_assignment_layout_ignores_operator_spacing() { + for (line_length, source, expected) in [ + ( + 105, + r#"contract C { + function f() external { + p2pSupplyRate = + p2pSupplyRate.mul(CompoundMath.WAD - shareOfTheDelta) + + _params.poolRate.mul(shareOfTheDelta); + } +} +"#, + r#"contract C { + function f() external { + p2pSupplyRate = p2pSupplyRate.mul(CompoundMath.WAD - shareOfTheDelta) + + _params.poolRate.mul(shareOfTheDelta); + } +} +"#, + ), + ( + 116, + r#"contract C { + function f() external { + p2pSupplyGrowthFactor = + p2pGrowthFactor - + (_params.reserveFactor * (p2pGrowthFactor - poolSupplyGrowthFactor)) / + MAX_BASIS_POINTS; + } +} +"#, + r#"contract C { + function f() external { + p2pSupplyGrowthFactor = p2pGrowthFactor + - (_params.reserveFactor * (p2pGrowthFactor - poolSupplyGrowthFactor)) / MAX_BASIS_POINTS; + } +} +"#, + ), + ( + 120, + r#"contract C { + function f() external { + uint256 poolTVL = (IERC20Detailed(address(_cpToken)).totalSupply() * + _cpToken.getCurrentExchangeRate()) / 10**18; + } +} +"#, + r#"contract C { + function f() external { + uint256 poolTVL = + (IERC20Detailed(address(_cpToken)).totalSupply() * _cpToken.getCurrentExchangeRate()) / 10 ** 18; + } +} +"#, + ), + ] { + let config = Arc::new(FormatterConfig { line_length, ..Default::default() }); + assert_eq!(format(source, Path::new("test.sol"), config), expected); + } +} - impl PartialEq for PrettyString { - fn eq(&self, other: &Self) -> bool { - self.0.lines().eq(other.0.lines()) - } +#[test] +fn line_end_operators_do_not_change_layout() { + for (source, expected) in [ + ( + r#"contract C { + function f() internal view returns (uint256) { + return + super._postOpGasBudget(userOp) + + Math.ternary(_fetchGuarantor(userOp) == address(0), 0, _guaranteedPostOpCost()); + } +} +"#, + r#"contract C { + function f() internal view returns (uint256) { + return super._postOpGasBudget(userOp) + + Math.ternary(_fetchGuarantor(userOp) == address(0), 0, _guaranteedPostOpCost()); } +} +"#, + ), + ( + r#"contract C { + function f() internal view returns (uint256) { + return c + ? super._postOpGasBudget(userOp) + + Math.ternary(_fetchGuarantor(userOp) == address(0), 0, _postOpBudget()) + : 0; + } +} +"#, + r#"contract C { + function f() internal view returns (uint256) { + return c + ? super._postOpGasBudget(userOp) + Math.ternary(_fetchGuarantor(userOp) == address(0), 0, _postOpBudget()) + : 0; + } +} +"#, + ), + ] { + let config = Arc::new(FormatterConfig::default()); + assert_eq!(format(source, Path::new("test.sol"), config.clone()), expected); + assert_eq!(format(expected, Path::new("test.sol"), config), expected); + } +} - impl std::fmt::Debug for PrettyString { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.0) - } +#[test] +fn for_initializer_leading_comment_is_idempotent() { + let source = r#"contract C { + function f() external { + for ( + /* lead + detail */ uint i = 0; i < 1; ++i + ) {} + } +} +"#; + let expected = r#"contract C { + function f() external { + for ( + /* lead + detail */ + uint256 i = 0; + i < 1; + ++i + ) {} } +} +"#; - assert_eof(expected_source); + assert_eq!( + format(source, Path::new("test.sol"), Arc::new(FormatterConfig::default())), + expected + ); +} - let source_parsed = match parse(source) { - Ok(p) => p, - Err(e) => panic!("{e}"), - }; - let expected_parsed = match parse(expected_source) { - Ok(p) => p, - Err(e) => panic!("{e}"), - }; +#[test] +fn for_initializer_comment_run_is_idempotent() { + let source = r#"contract C { + function f() external { + for (uint i = 0 /* detail + more */; // after init + i < 1; ++i) {} + } +} +"#; + let expected = r#"contract C { + function f() external { + for ( + uint256 i = 0; + + /* detail + more */ + // after init + i < 1; + ++i + ) {} + } +} +"#; + + assert_eq!( + format(source, Path::new("test.sol"), Arc::new(FormatterConfig::default())), + expected + ); +} - if !test_config.skip_compare_ast_eq && !source_parsed.pt.ast_eq(&expected_parsed.pt) { - similar_asserts::assert_eq!( - source_parsed.pt, - expected_parsed.pt, - "(formatted Parse Tree == expected Parse Tree) in {}", - filename +#[test] +fn for_keyword_comment_is_idempotent() { + let source = r#"contract C { + function f() external { + for // comment + (; ; ++i) {} + } +} +"#; + let expected = r#"contract C { + function f() external { + for // comment + (;; ++i) {} + } +} +"#; + + assert_eq!( + format(source, Path::new("test.sol"), Arc::new(FormatterConfig::default())), + expected + ); +} + +#[test] +fn block_opening_comment_run_is_idempotent() { + let source = r#"contract C { + function f() external { + if (a) {} else { // First. + // Second. + f(); + } + } +} +"#; + let expected = r#"contract C { + function f() external { + if (a) {} else { + // First. + // Second. + f(); + } + } +} +"#; + for wrap_comments in [false, true] { + let config = Arc::new(FormatterConfig { wrap_comments, ..Default::default() }); + assert_eq!(format(source, Path::new("test.sol"), config), expected); + } +} + +#[test] +fn chained_named_call_layout_ignores_source_spacing() { + let path = Path::new("test.sol"); + + for (line_length, bracket_spacing, compact, spaced) in [ + ( + 40, + false, + "factory().foo(a,b,c).baz({value: result});", + "factory().foo(a, b, c).baz({value: result});", + ), + ( + 32, + false, + "factory().foo(a+b).baz({value: result});", + "factory().foo(a + b).baz({value: result});", + ), + ( + 34, + false, + "factory().foo([a,b]).baz({value: result});", + "factory().foo([a, b]).baz({value: result});", + ), + (38, true, "factory().foo(a,b,c).baz({});", "factory().foo(a, b, c).baz({ });"), + ] { + let config = + Arc::new(FormatterConfig { line_length, bracket_spacing, ..Default::default() }); + let source = |expr| format!("contract C {{ function f() external {{ {expr} }} }}"); + assert_eq!( + format(&source(compact), path, config.clone()), + format(&source(spaced), path, config), ); } +} - let expected = PrettyString(expected_source.to_string()); +#[test] +fn statement_trailing_blank_line_is_idempotent() { + let source = r#"contract C { + function f() external { + uint value; + value += 1 + /* detail. */ - let mut source_formatted = String::new(); - format_to(&mut source_formatted, source_parsed, config.clone()).unwrap(); - assert_eof(&source_formatted); + ; + } +} +"#; + let expected = r#"contract C { + function f() external { + uint256 value; + value += 1; + /* detail. */ + } +} +"#; - let source_formatted = PrettyString(source_formatted); + assert_eq!( + format(source, Path::new("test.sol"), Arc::new(FormatterConfig::default())), + expected + ); +} - similar_asserts::assert_eq!( - source_formatted, - expected, - "(formatted == expected) in {}", - filename +#[test] +fn source_offsets_with_unicode_and_crlf() { + let source = r#"// 😀 文 +contract C { + /// First line. + /// Second line. + function f() external { + // forgefmt: disable-next-line + uint x = 1; // Comment. + } +} +"# + .replace('\n', "\r\n"); + let path = Path::new("offset.sol"); + let mut compiler = Compiler::new( + solar::interface::Session::builder().with_buffer_emitter(Default::default()).build(), ); + forge_fmt::format_source( + "// Different file with Unicode: 😀\ncontract Prefix {}", + Some(Path::new("prefix.sol")), + Arc::new(FormatterConfig::default()), + &mut compiler, + ) + .into_result() + .unwrap(); - let mut expected_formatted = String::new(); - format_to(&mut expected_formatted, expected_parsed, config).unwrap(); - assert_eof(&expected_formatted); + for config in [ + FormatterConfig::default(), + FormatterConfig { + wrap_comments: true, + docs_style: DocCommentStyle::Block, + ..Default::default() + }, + ] { + let config = Arc::new(config); + let expected = format(&source, path, config.clone()); + let actual = forge_fmt::format_source(&source, Some(path), config, &mut compiler) + .into_result() + .unwrap(); + assert_eq!(actual, expected); + } +} - let expected_formatted = PrettyString(expected_formatted); +// +#[test] +fn disable_line_uses_comment_context() { + let source = r#"contract C { + function f() public { + // forgefmt: disable-line + assembly { sstore( 0, 0) + sstore(1, 1) + } + + assembly { sstore( 2, 2) } // forgefmt: disable-line + } +} +"#; + let expected = r#"contract C { + function f() public { + // forgefmt: disable-line + assembly { sstore( 0, 0) + sstore(1, 1) + } + assembly { sstore( 2, 2) } // forgefmt: disable-line + } +} +"#; - similar_asserts::assert_eq!( - expected_formatted, - expected, - "(formatted == expected) in {}", - filename + assert_eq!( + format(source, Path::new("test.sol"), Arc::new(FormatterConfig::default())), + expected ); } -#[derive(Clone, Copy, Default)] -struct TestConfig { - /// Whether to compare the formatted source code AST with the original AST - skip_compare_ast_eq: bool, +#[test] +fn narrow_multiline_comment_is_idempotent() { + let source = r#"contract C { + function f() external { + for (uint i = 0; i < 10; ++i) /* detail. + more text. */ {} + } +} +"#; + let expected = "contract C {\n function f() external {\n for (uint256 i = 0; i < 10; ++i) \n /* detail.\n more text. */\n {}\n }\n}\n"; + let config = Arc::new(FormatterConfig { line_length: 40, ..Default::default() }); + + let first = format(source, Path::new("test.sol"), config.clone()); + assert_eq!(first, expected); + assert_eq!(format(&first, Path::new("test.sol"), config), first); } -impl TestConfig { - fn skip_compare_ast_eq() -> Self { - Self { skip_compare_ast_eq: true } +#[test] +fn wrapped_mixed_comment_at_line_start_is_idempotent() { + let source = r#"contract C { + function f() external { + /* detail. + more text. */ uint value; } } +"#; + let expected = r#"contract C { + function f() external { + /* detail. + more text. */ + uint256 value; + } +} +"#; + let config = Arc::new(FormatterConfig { wrap_comments: true, ..Default::default() }); -macro_rules! test_dir { - ($dir:ident $(,)?) => { - test_dir!($dir, Default::default()); - }; - ($dir:ident, $config:expr $(,)?) => { - #[expect(non_snake_case)] - #[test] - fn $dir() { - test_directory(stringify!($dir), $config); + let first = format(source, Path::new("test.sol"), config.clone()); + assert_eq!(first, expected); + assert_eq!(format(&first, Path::new("test.sol"), config), first); +} + +#[test] +fn trailing_line_comment_separates_following_comment() { + let source = r#"contract C { + function f() external { + uint value; + value // Trailing line. + ; /* Following block. + more text. */ + } +} +"#; + let expected = r#"contract C { + function f() external { + uint256 value; + value; // Trailing line. + /* Following block. + more text. */ + } +} +"#; + + assert_eq!( + format(source, Path::new("test.sol"), Arc::new(FormatterConfig::default())), + expected + ); +} + +#[test] +fn tab_style_preserves_crlf_disabled_block_lines() { + let source = "contract C {\n// forgefmt: disable-start\nfunction disabled( ) external { uint value = 1; }\n// forgefmt: disable-end\nuint value;\n}\n" + .replace('\n', "\r\n"); + let expected = "contract C {\n\t// forgefmt: disable-start\nfunction disabled( ) external { uint value = 1; }\n// forgefmt: disable-end\n\tuint256 value;\n}\n"; + let config = Arc::new(FormatterConfig { style: IndentStyle::Tab, ..Default::default() }); + + assert_eq!(format(&source, Path::new("test.sol"), config), expected); +} + +#[test] +fn array_type_comment_before_bracket_is_idempotent() { + let source = r#"contract C { + function f() external { + uint256 /* first */ [ + /* second */ + + 3 + ] memory values; + } +} +"#; + let expected = r#"contract C { + function f() external { + uint256 /* first */ [ + /* second */ + + 3] memory values; + } +} +"#; + + assert_eq!( + format(source, Path::new("test.sol"), Arc::new(FormatterConfig::default())), + expected + ); +} + +#[test] +fn yul_assignment_comment_is_idempotent() { + let source = r#"contract C { + function f() external pure returns (uint256 x) { + assembly { + x := /* comment */ add(1, 2) } - }; + } +} +"#; + let expected = r#"contract C { + function f() external pure returns (uint256 x) { + assembly { + x := /* comment */ + add(1, 2) + } + } } +"#; + + assert_eq!( + format(source, Path::new("test.sol"), Arc::new(FormatterConfig::default())), + expected + ); +} + +#[test] +fn return_expression_comment_is_idempotent() { + let source = r#"contract C { + function f() external pure returns (uint256, uint256, bool) { + return /* return values */ (1234567890, 9876543210, false); + } +} +"#; + let expected = r#"contract C { + function f() + external + pure + returns (uint256, uint256, bool) + { + return /* return values */ + (1234567890, 9876543210, false); + } +} +"#; + let config = + Arc::new(FormatterConfig { line_length: 60, wrap_comments: true, ..Default::default() }); + + assert_eq!(format(source, Path::new("test.sol"), config), expected); +} + +fn tests_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("testdata") +} + +fn test_directory(base_name: &str) { + init_tracing(); + let dir = tests_dir().join(base_name); + let mut original = fs::read_to_string(dir.join("original.sol")).unwrap(); + if cfg!(windows) { + original = original.replace("\r\n", "\n"); + } + let mut handles = vec![]; + for res in dir.read_dir().unwrap() { + let entry = res.unwrap(); + let path = entry.path(); -macro_rules! test_directories { - ($($dir:ident),+ $(,)?) => {$( - test_dir!($dir); - )+}; + let filename = path.file_name().and_then(|name| name.to_str()).unwrap(); + if filename == "original.sol" { + continue; + } + assert!(path.is_file(), "expected file: {path:?}"); + assert!(filename.ends_with("fmt.sol"), "unknown file: {path:?}"); + + let mut expected = fs::read_to_string(&path).unwrap(); + if cfg!(windows) { + expected = expected + .replace("\r\n", "\n") + .replace(r"\'", r"/'") + .replace(r#"\""#, r#"/""#) + .replace("\\\n", "/\n"); + } + + // The majority of the tests were written with the assumption that the default value for max + // line length is `80`. Preserve that to avoid rewriting test logic. + let default_config = FormatterConfig { line_length: 80, ..Default::default() }; + + let mut config = toml::Value::try_from(default_config).unwrap(); + let config_table = config.as_table_mut().unwrap(); + let mut comments_end = 0; + for (i, line) in expected.lines().enumerate() { + let line_num = i + 1; + let Some(entry) = line + .strip_prefix("//") + .and_then(|line| line.trim().strip_prefix("config:")) + .map(str::trim) + else { + break; + }; + + let values = match toml::from_str::(entry) { + Ok(toml::Value::Table(table)) => table, + r => panic!("invalid fmt config item in {filename} at {line_num}: {r:?}"), + }; + config_table.extend(values); + + comments_end += line.len() + 1; + } + let config = Arc::new( + config + .try_into::() + .unwrap_or_else(|err| panic!("invalid test config for {filename}: {err}")), + ); + + let original = original.clone(); + let tname = format!("{base_name}/{filename}"); + let spawn = move || { + test_formatter(&path, config.clone(), &original, &expected, comments_end); + }; + handles.push(std::thread::Builder::new().name(tname).spawn(spawn).unwrap()); + } + let results = handles.into_iter().map(|h| h.join()).collect::>(); + for result in results { + result.unwrap(); + } } -test_directories! { +fn test_formatter( + expected_path: &Path, + config: Arc, + source: &str, + expected_source: &str, + comments_end: usize, +) { + let path = &*expected_path.with_file_name("original.sol"); + let expected_data = || Data::read_from(expected_path, None).raw(); + + let mut source_formatted = format(source, path, config.clone()); + // Inject `expected`'s comments, if any, so we can use the expected file as a snapshot. + source_formatted.insert_str(0, &expected_source[..comments_end]); + assert_data_eq!(&source_formatted, expected_data()); + assert_eof(&source_formatted); + + let mut expected_content = std::fs::read_to_string(expected_path).unwrap(); + if cfg!(windows) { + expected_content = expected_content.replace("\r\n", "\n"); + } + let expected_formatted = format(&expected_content, expected_path, config); + assert_data_eq!(&expected_formatted, expected_data()); + assert_eof(expected_source); + assert_eof(&expected_formatted); +} + +fn test_all_dirs_are_declared(dirs: &[&str]) { + let mut undeclared = vec![]; + for actual_dir in tests_dir().read_dir().unwrap().filter_map(Result::ok) { + let path = actual_dir.path(); + assert!(path.is_dir(), "expected directory: {path:?}"); + let actual_dir_name = path.file_name().unwrap().to_str().unwrap(); + if !dirs.contains(&actual_dir_name) { + undeclared.push(actual_dir_name.to_string()); + } + } + assert!( + undeclared.is_empty(), + "the following test directories are not declared in the test suite macro call: {undeclared:#?}" + ) +} + +macro_rules! fmt_tests { + ($($(#[$attr:meta])* $dir:ident),+ $(,)?) => { + #[test] + fn all_dirs_are_declared() { + test_all_dirs_are_declared(&[$(stringify!($dir)),*]); + } + + $( + #[allow(non_snake_case)] + #[test] + $(#[$attr])* + fn $dir() { + test_directory(stringify!($dir)); + } + )+ + }; +} + +fmt_tests! { + #[ignore = "annotations are not valid Solidity"] + Annotation, + ArrayExpressions, + AssignmentMemberChain, + BlockComments, + BlockCommentsFunction, + CallOptionsAssign, + CommentEmptyLine, + ConditionalOperatorExpression, ConstructorDefinition, ConstructorModifierStyle, ContractDefinition, DocComments, + DoWhileStatement, + EmitStatement, EnumDefinition, + EnumVariants, ErrorDefinition, EventDefinition, + FnAttributeComment, + ForStatement, + FunctionCall, + FunctionCallArgsStatement, FunctionDefinition, FunctionDefinitionWithFunctionReturns, FunctionType, + HexUnderscore, + IfStatement, + IfStatement2, + IfStatement3, + IfStatementLongCondition, + IfStatementMultilineCall, ImportDirective, + IndexedAssignment, + InlineDisable, + IntTypes, + LineComments, + LiteralExpression, + MappingNamedParams, + MappingType, + MemberChainIndent, + MethodChain, + MethodChainCallOptions, + MixedBlockComments, ModifierDefinition, + NamedCallArgsInChain, + NestedNamedCallArgumentChain, + NamedFunctionCallExpression, + NonKeywords, + NumberLiteralUnderscore, + OperatorExpressions, + PragmaDirective, + Repros, + ReprosCalls, + ReprosFunctionDefs, + ReturnStatement, + RevertNamedArgsStatement, + RevertStatement, + SimpleComments, + SortedImports, StatementBlock, StructDefinition, + StructFieldAccess, + ThisExpression, + #[ignore = "Solar errors when parsing inputs with trailing commas"] + TrailingComma, + TryStatement, TypeDefinition, + UnitExpression, UsingDirective, + VariableAssignment, VariableDefinition, - OperatorExpressions, WhileStatement, - DoWhileStatement, - ForStatement, - IfStatement, - IfStatement2, - VariableAssignment, - FunctionCallArgsStatement, - RevertStatement, - RevertNamedArgsStatement, - ReturnStatement, - TryStatement, - ConditionalOperatorExpression, - NamedFunctionCallExpression, - ArrayExpressions, - UnitExpression, - ThisExpression, - SimpleComments, - LiteralExpression, Yul, + YulInlineBlock, YulStrings, - IntTypes, - InlineDisable, - NumberLiteralUnderscore, - HexUnderscore, - FunctionCall, - TrailingComma, - PragmaDirective, - Annotation, - MappingType, - EmitStatement, - Repros, - BlockComments, - BlockCommentsFunction, - EnumVariants, } -test_dir!(SortedImports, TestConfig::skip_compare_ast_eq()); -test_dir!(NonKeywords, TestConfig::skip_compare_ast_eq()); +#[test] +fn test_override_state_variable_without_initializer_does_not_leak_indent() { + init_tracing(); + + let cases = [ + ( + "top-level items after override variable", + r#"pragma solidity ^0.8.28; + +contract BaseStorage { + uint256 public total; +} + +contract ChildStorage is BaseStorage { + uint256 public override total; +} + +struct Info { + uint256 a; +} + +function topLevel(uint256 value) pure returns (uint256) { + return value; +} +"#, + r#"pragma solidity ^0.8.28; + +contract BaseStorage { + uint256 public total; +} + +contract ChildStorage is BaseStorage { + uint256 public override total; +} + +struct Info { + uint256 a; +} + +function topLevel(uint256 value) pure returns (uint256) { + return value; +} +"#, + ), + ( + "contract member after override variable", + r#"pragma solidity ^0.8.28; + +contract BaseStorage { + uint256 public total; +} + +contract ChildStorage is BaseStorage { + uint256 public override total; + uint256 public next; +} +"#, + r#"pragma solidity ^0.8.28; + +contract BaseStorage { + uint256 public total; +} + +contract ChildStorage is BaseStorage { + uint256 public override total; + uint256 public next; +} +"#, + ), + ( + "override path list without initializer", + r#"pragma solidity ^0.8.28; + +contract BaseA { + uint256 public total; +} + +contract BaseB { + uint256 public total; +} + +contract ChildStorage is BaseA, BaseB { + uint256 public override(BaseA, BaseB) total; +} + +error AfterOverride(uint256 value); +"#, + r#"pragma solidity ^0.8.28; + +contract BaseA { + uint256 public total; +} + +contract BaseB { + uint256 public total; +} + +contract ChildStorage is BaseA, BaseB { + uint256 public override(BaseA, BaseB) total; +} + +error AfterOverride(uint256 value); +"#, + ), + ( + "override variable with initializer", + r#"pragma solidity ^0.8.28; + +contract BaseStorage { + uint256 public total; +} + +contract ChildStorage is BaseStorage { + uint256 public override total = 0; +} + +struct AfterInitializer { + uint256 a; +} +"#, + r#"pragma solidity ^0.8.28; + +contract BaseStorage { + uint256 public total; +} + +contract ChildStorage is BaseStorage { + uint256 public override total = 0; +} + +struct AfterInitializer { + uint256 a; +} +"#, + ), + ]; + + let fmt_config = Arc::new(FormatterConfig::default()); + let path = Path::new("override-indent.sol"); + + for (case, source, expected) in cases { + let formatted = format(source, path, fmt_config.clone()); + assert_eq!(formatted, expected, "{case}"); + } +} + +#[test] +fn size_estimate_ignores_literal_contents() { + for (line_length, bracket_spacing, literal, control) in [ + (55, true, "{a}{b}{c}{d}", "abcdefghijkl"), + (59, false, "uint uint uint uint", "word word word word"), + ] { + let config = + Arc::new(FormatterConfig { line_length, bracket_spacing, ..Default::default() }); + let source = format!( + "contract C {{ function f(uint a) external pure returns (bytes memory) {{ bytes memory encoded = abi.encode(\"{literal}\", a, a, a); return encoded; }} }}\n" + ); + let control_source = source.replace(literal, control); + let expected = format(&control_source, Path::new("test.sol"), config.clone()) + .replace(control, literal); + assert_eq!(format(&source, Path::new("test.sol"), config), expected); + } +} + +#[test] +fn brace_spacing_size_estimate_handles_tabs() { + let config = + Arc::new(FormatterConfig { line_length: 120, bracket_spacing: true, ..Default::default() }); + let source = r#"contract C { + function f() external { + executions = factory({ a: assetAddress, b: receiver, c: amountToSend, d: currentNonce, e: expiryTime, f: requiredFee }); + } +} +"#; + let expected = r#"contract C { + function f() external { + executions = + factory({ a: assetAddress, b: receiver, c: amountToSend, d: currentNonce, e: expiryTime, f: requiredFee }); + } +} +"#; + + assert_eq!(format(source, Path::new("test.sol"), config), expected); +} + +#[test] +fn concatenated_string_trailing_comment_stays_after_last_literal() { + let source = r#"contract C { + function f() public pure returns (bytes memory) { + return bytes.concat( + "abc" + "123456789012345678901234567890123456789012345678901234567890" // Longer than 32 bytes + ); + } +} +"#; + let expected = r#"contract C { + function f() public pure returns (bytes memory) { + return + bytes.concat( + "abc" "123456789012345678901234567890123456789012345678901234567890" // Longer than 32 bytes + ); + } +} +"#; + + assert_eq!( + format(source, Path::new("concatenated-string.sol"), Arc::new(FormatterConfig::default())), + expected + ); +} diff --git a/crates/forge/Cargo.toml b/crates/forge/Cargo.toml index ee5727837862c..03f99c2006ce1 100644 --- a/crates/forge/Cargo.toml +++ b/crates/forge/Cargo.toml @@ -26,15 +26,17 @@ harness = false # lib foundry-block-explorers = { workspace = true, features = ["foundry-compilers"] } foundry-common.workspace = true -foundry-compilers = { workspace = true, features = ["full"] } +foundry-compilers.workspace = true foundry-config.workspace = true foundry-evm.workspace = true -foundry-evm-core.workspace = true +foundry-evm-networks.workspace = true +foundry-evm-symbolic.workspace = true foundry-linking.workspace = true comfy-table.workspace = true eyre.workspace = true proptest.workspace = true +rand.workspace = true rayon.workspace = true serde.workspace = true tracing.workspace = true @@ -50,81 +52,129 @@ forge-script.workspace = true forge-sol-macro-gen.workspace = true foundry-cli.workspace = true foundry-debugger.workspace = true +foundry-tui.workspace = true +foundry-wallets = { workspace = true, features = ["browser", "tempo"] } alloy-chains.workspace = true +alloy-consensus.workspace = true alloy-dyn-abi.workspace = true alloy-json-abi.workspace = true alloy-network.workspace = true alloy-primitives = { workspace = true, features = ["serde"] } alloy-provider = { workspace = true, features = ["reqwest", "ws", "ipc"] } -alloy-rpc-types.workspace = true -alloy-serde.workspace = true alloy-signer.workspace = true alloy-transport.workspace = true -revm.workspace = true +tempo-alloy.workspace = true clap = { version = "4", features = ["derive", "env", "unicode", "wrap_help"] } -clap_complete = "4" -clap_complete_fig = "4" +clap_complete.workspace = true +ctrlc = "3" +dialoguer.workspace = true dunce.workspace = true +flate2.workspace = true +globset = "0.4" indicatif.workspace = true inferno = { version = "0.12", default-features = false } itertools.workspace = true parking_lot.workspace = true regex = { workspace = true, default-features = false } +revm = { workspace = true, features = ["parse"] } semver.workspace = true serde_json.workspace = true -similar = { version = "2", features = ["inline"] } -solang-parser.workspace = true -solar-parse.workspace = true -solar-sema.workspace = true -strum = { workspace = true, features = ["derive"] } +similar = { version = "3", features = ["inline"] } +solar = { workspace = true, features = ["clap"] } +solar-lsp.workspace = true thiserror.workspace = true -tokio = { workspace = true, features = ["time"] } -toml_edit = "0.22" +tokio = { workspace = true, features = ["time", "signal", "process"] } +toml_edit.workspace = true watchexec = "8.0" watchexec-events = "6.0" watchexec-signals = "5.0" +which.workspace = true clearscreen = "4.0" evm-disassembler.workspace = true path-slash.workspace = true +reqwest = { workspace = true, features = ["json"] } +url.workspace = true -# doc server -axum = { workspace = true, features = ["ws"] } -tower-http = { workspace = true, features = ["fs"] } -opener = "0.7" +axum.workspace = true +tower-http = { workspace = true, features = ["cors"] } +opener = "0.8" # soldeer soldeer-commands.workspace = true -quick-junit = "0.5.1" +soldeer-core.workspace = true +quick-junit = "0.7.0" +tempfile.workspace = true + +[target.'cfg(unix)'.dependencies] +rustix = { version = "1", features = ["process"] } [dev-dependencies] +alloy-rpc-types.workspace = true +async-lsp.workspace = true +tokio-util.workspace = true +futures = { workspace = true, features = ["executor"] } alloy-hardforks.workspace = true anvil.workspace = true forge-script-sequence.workspace = true foundry-test-utils.workspace = true -foundry-wallets.workspace = true -futures.workspace = true -reqwest = { workspace = true, features = ["json"] } -mockall = "0.13" +mockall = "0.15" globset = "0.4" -paste = "1.0" similar-asserts.workspace = true -svm = { package = "svm-rs", version = "0.5", default-features = false, features = [ - "rustls", -] } +svm.workspace = true tempfile.workspace = true alloy-signer-local.workspace = true +base-common-consensus = { workspace = true, features = ["serde", "std"] } + +[target.'cfg(unix)'.dev-dependencies] +rexpect.workspace = true [features] -default = ["jemalloc"] -asm-keccak = ["alloy-primitives/asm-keccak"] +default = ["jemalloc", "asm-keccak", "optimism"] +asm-keccak = ["alloy-primitives/asm-keccak", "revm/asm-keccak"] jemalloc = ["foundry-cli/jemalloc"] mimalloc = ["foundry-cli/mimalloc"] tracy-allocator = ["foundry-cli/tracy-allocator"] aws-kms = ["foundry-wallets/aws-kms"] gcp-kms = ["foundry-wallets/gcp-kms"] -isolate-by-default = ["foundry-config/isolate-by-default"] +turnkey = ["foundry-wallets/turnkey"] +touch-id = ["foundry-wallets/touch-id"] +monad = [ + "anvil/monad", + "foundry-config/monad", + "foundry-evm/monad", + "foundry-evm-networks/monad", + "foundry-common/monad", + "foundry-cli/monad", + "forge-script/monad", + "forge-verify/monad", +] +base = [ + "anvil/base", + "foundry-evm/base", + "foundry-evm-networks/base", + "foundry-evm-symbolic/base", + "foundry-common/base", + "foundry-config/base", + "foundry-cli/base", + "foundry-debugger/base", + "foundry-test-utils/base", + "forge-script/base", + "forge-verify/base", +] +optimism = [ + "foundry-evm/optimism", + "foundry-evm-networks/optimism", + "foundry-common/optimism", + "foundry-cli/optimism", + "forge-script/optimism", + "forge-verify/optimism", + "forge-doc/optimism", + "forge-fmt/optimism", + "forge-lint/optimism", + "forge-sol-macro-gen/optimism", +] diff --git a/crates/forge/assets/.gitignoreTemplate b/crates/forge/assets/.gitignoreTemplate index 85198aaa55b84..c8e4586809297 100644 --- a/crates/forge/assets/.gitignoreTemplate +++ b/crates/forge/assets/.gitignoreTemplate @@ -2,6 +2,10 @@ cache/ out/ +# Commonly ignored directories +.logs/ +.ignore/ + # Ignores development broadcast logs !/broadcast /broadcast/*/31337/ @@ -12,3 +16,5 @@ docs/ # Dotenv file .env +.env.* +!.env.example diff --git a/crates/forge/assets/generated/TestTemplate.t.sol b/crates/forge/assets/generated/TestTemplate.t.sol deleted file mode 100644 index 816a8c68fd0a9..0000000000000 --- a/crates/forge/assets/generated/TestTemplate.t.sol +++ /dev/null @@ -1,13 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.13; - -import {Test, console} from "forge-std/Test.sol"; -import {{contract_name}} from "../src/{contract_name}.sol"; - -contract {contract_name}Test is Test { - {contract_name} public {instance_name}; - - function setUp() public { - {instance_name} = new {contract_name}(); - } -} diff --git a/crates/forge/assets/CounterTemplate.s.sol b/crates/forge/assets/solidity/CounterTemplate.s.sol similarity index 100% rename from crates/forge/assets/CounterTemplate.s.sol rename to crates/forge/assets/solidity/CounterTemplate.s.sol diff --git a/crates/forge/assets/CounterTemplate.sol b/crates/forge/assets/solidity/CounterTemplate.sol similarity index 100% rename from crates/forge/assets/CounterTemplate.sol rename to crates/forge/assets/solidity/CounterTemplate.sol diff --git a/crates/forge/assets/CounterTemplate.t.sol b/crates/forge/assets/solidity/CounterTemplate.t.sol similarity index 100% rename from crates/forge/assets/CounterTemplate.t.sol rename to crates/forge/assets/solidity/CounterTemplate.t.sol diff --git a/crates/forge/assets/solidity/workflowTemplate.yml b/crates/forge/assets/solidity/workflowTemplate.yml new file mode 100644 index 0000000000000..c909f061185f2 --- /dev/null +++ b/crates/forge/assets/solidity/workflowTemplate.yml @@ -0,0 +1,35 @@ +name: CI + +permissions: {} + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + check: + name: Foundry project + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + + - name: Show Forge version + run: forge --version + + - name: Run Forge fmt + run: forge fmt --check + + - name: Run Forge build + run: forge build --sizes + + - name: Run Forge tests + run: forge test -vvv diff --git a/crates/forge/assets/tempo/MailTemplate.s.sol b/crates/forge/assets/tempo/MailTemplate.s.sol new file mode 100644 index 0000000000000..45006f7cd0e06 --- /dev/null +++ b/crates/forge/assets/tempo/MailTemplate.s.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Script} from "forge-std/Script.sol"; +import {ITIP20} from "tempo-std/interfaces/ITIP20.sol"; +import {ITIP20RolesAuth} from "tempo-std/interfaces/ITIP20RolesAuth.sol"; +import {StdPrecompiles} from "tempo-std/StdPrecompiles.sol"; +import {StdTokens} from "tempo-std/StdTokens.sol"; +import {Mail} from "../src/Mail.sol"; + +contract MailScript is Script { + function setUp() public {} + + function run(string memory salt) public { + vm.startBroadcast(); + + address feeToken = vm.envOr("TEMPO_FEE_TOKEN", StdTokens.PATH_USD_ADDRESS); + StdPrecompiles.TIP_FEE_MANAGER.setUserToken(feeToken); + + ITIP20 token = ITIP20( + StdPrecompiles.TIP20_FACTORY + .createToken("testUSD", "tUSD", "USD", StdTokens.PATH_USD, msg.sender, keccak256(bytes(salt))) + ); + + ITIP20RolesAuth(address(token)).grantRole(token.ISSUER_ROLE(), msg.sender); + + token.mint(msg.sender, 1_000_000 * 10 ** token.decimals()); + + new Mail(token); + + vm.stopBroadcast(); + } +} diff --git a/crates/forge/assets/tempo/MailTemplate.sol b/crates/forge/assets/tempo/MailTemplate.sol new file mode 100644 index 0000000000000..2caed92d47517 --- /dev/null +++ b/crates/forge/assets/tempo/MailTemplate.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {ITIP20} from "tempo-std/interfaces/ITIP20.sol"; +import {StdPrecompiles} from "tempo-std/StdPrecompiles.sol"; + +/// @title Mail +/// @notice Send mail with TIP-20 token attachments on Tempo. +/// +/// Supports two modes: +/// 1. Direct — call `sendMail()` yourself (uses `msg.sender`). +/// 2. Relayed — sign a mail off-chain and let anyone deliver it on-chain. +/// +/// Relayed mode uses the [TIP-1020] `SignatureVerifier` precompile to verify the +/// sender's Tempo signature. Unlike Ethereum's `ecrecover`, this precompile: +/// - Supports secp256k1, P256, and WebAuthn signature types +/// - Reverts on invalid signatures instead of returning `address(0)` +/// - Maintains forward compatibility with future Tempo account types +/// +/// [TIP-1020]: +contract Mail { + /// @notice Emitted when a mail is sent, either directly or via a relayer. + event MailSent(address indexed from, address indexed to, string message, Attachment attachment); + + /// @notice A TIP-20 token transfer bundled with a mail. + struct Attachment { + uint256 amount; + bytes32 memo; + } + + /// @notice The TIP-20 token used for mail attachments. + ITIP20 public token; + + /// @notice Per-sender nonce to prevent signature replay on relayed mails (requires T3). + mapping(address => uint256) public nonces; + + constructor(ITIP20 token_) { + token = token_; + } + + /// @notice Send mail directly (sender = msg.sender). + function sendMail(address to, string memory message, Attachment memory attachment) external { + token.transferFromWithMemo(msg.sender, to, attachment.amount, attachment.memo); + emit MailSent(msg.sender, to, message, attachment); + } + + /// @notice Send mail on behalf of `from` using their off-chain Tempo signature (requires T3). + /// @dev The sender must have pre-approved this contract to spend their tokens. + function sendMail( + address from, + address to, + string memory message, + Attachment memory attachment, + bytes calldata signature + ) external { + bytes32 hash = getDigest(from, to, message, attachment); + + // `verify()` returns `false` on signer mismatch, reverts on malformed signatures. + require(StdPrecompiles.SIGNATURE_VERIFIER.verify(from, hash, signature), "invalid signature"); + + // `recover()` returns the signer address directly, reverts on malformed signatures. + require(StdPrecompiles.SIGNATURE_VERIFIER.recover(hash, signature) == from, "invalid signature"); + + nonces[from]++; + token.transferFromWithMemo(from, to, attachment.amount, attachment.memo); + emit MailSent(from, to, message, attachment); + } + + /// @notice Compute the digest a sender must sign to authorize a relayed mail. + function getDigest(address from, address to, string memory message, Attachment memory attachment) + public + view + returns (bytes32) + { + return keccak256(abi.encode(address(this), block.chainid, from, to, message, attachment, nonces[from])); + } +} diff --git a/crates/forge/assets/tempo/MailTemplate.t.sol b/crates/forge/assets/tempo/MailTemplate.t.sol new file mode 100644 index 0000000000000..c924cd47f9b40 --- /dev/null +++ b/crates/forge/assets/tempo/MailTemplate.t.sol @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Test} from "forge-std/Test.sol"; +import {ITIP20} from "tempo-std/interfaces/ITIP20.sol"; +import {ITIP20RolesAuth} from "tempo-std/interfaces/ITIP20RolesAuth.sol"; +import {StdPrecompiles} from "tempo-std/StdPrecompiles.sol"; +import {StdTokens} from "tempo-std/StdTokens.sol"; +import {Mail} from "../src/Mail.sol"; + +/// @notice Tests for direct mail sending (no signature verification). +contract MailTest is Test { + ITIP20 public token; + Mail public mail; + + address public constant ALICE = address(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266); + address public BOB; + + function setUp() public virtual { + address feeToken = vm.envOr("TEMPO_FEE_TOKEN", StdTokens.PATH_USD_ADDRESS); + StdPrecompiles.TIP_FEE_MANAGER.setUserToken(feeToken); + + token = ITIP20( + StdPrecompiles.TIP20_FACTORY + .createToken("testUSD", "tUSD", "USD", StdTokens.PATH_USD, address(this), bytes32(0)) + ); + + ITIP20RolesAuth(address(token)).grantRole(token.ISSUER_ROLE(), address(this)); + + BOB = address(uint160(uint256(keccak256(abi.encodePacked("tempo-mail-bob", address(token)))))); + + mail = new Mail(token); + } + + function test_SendMail() public { + token.mint(ALICE, 100_000 * 10 ** token.decimals()); + + Mail.Attachment memory attachment = + Mail.Attachment({amount: 100 * 10 ** token.decimals(), memo: "Invoice #1234"}); + + vm.startPrank(ALICE); + token.approve(address(mail), attachment.amount); + mail.sendMail(BOB, "Hello Bob, here is your invoice.", attachment); + vm.stopPrank(); + + assertEq(token.balanceOf(BOB), attachment.amount); + assertEq(token.balanceOf(ALICE), 100_000 * 10 ** token.decimals() - attachment.amount); + } + + function testFuzz_SendMail(uint128 mintAmount, uint128 sendAmount, string memory message, bytes32 memo) public { + mintAmount = uint128(bound(mintAmount, 0, type(uint128).max)); + sendAmount = uint128(bound(sendAmount, 0, mintAmount)); + + token.mint(ALICE, mintAmount); + + Mail.Attachment memory attachment = Mail.Attachment({amount: sendAmount, memo: memo}); + + vm.startPrank(ALICE); + token.approve(address(mail), sendAmount); + mail.sendMail(BOB, message, attachment); + vm.stopPrank(); + + assertEq(token.balanceOf(BOB), sendAmount); + assertEq(token.balanceOf(ALICE), mintAmount - sendAmount); + } +} + +/// @notice Tests for relayed mail using the TIP-1020 SignatureVerifier precompile (requires T3+). +contract MailRelayTest is MailTest { + // secp256k1 keys (used by vm.sign / vm.addr) + uint256 internal constant ALICE_PK = 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80; + uint256 internal constant BAD_SIGNER_PK = 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d; + + // P256 key (used by vm.signP256 / vm.publicKeyP256) + uint256 internal constant CAROL_P256_PK = 0x1; + address internal CAROL; + bytes32 internal carolPubX; + bytes32 internal carolPubY; + + uint256 internal constant P256_ORDER = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551; + uint256 internal constant P256N_HALF = 0x7FFFFFFF800000007FFFFFFFFFFFFFFFDE737D56D38BCF4279DCE5617E3192A8; + + function setUp() public override { + super.setUp(); + + // Derive P256 public key and Tempo address for Carol + (uint256 x, uint256 y) = vm.publicKeyP256(CAROL_P256_PK); + carolPubX = bytes32(x); + carolPubY = bytes32(y); + CAROL = address(uint160(uint256(keccak256(abi.encodePacked(x, y))))); + } + + /// @notice Relayed send with a secp256k1 signature — Alice signs, Bob delivers. + function test_SendMailWithSecp256k1Signature() public { + token.mint(ALICE, 100_000 * 10 ** token.decimals()); + + Mail.Attachment memory attachment = + Mail.Attachment({amount: 100 * 10 ** token.decimals(), memo: "Invoice #1234"}); + + vm.prank(ALICE); + token.approve(address(mail), attachment.amount); + + string memory message = "Hello Bob, here is your invoice."; + bytes32 digest = mail.getDigest(ALICE, BOB, message, attachment); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(ALICE_PK, digest); + + vm.prank(BOB); + mail.sendMail(ALICE, BOB, message, attachment, abi.encodePacked(r, s, v)); + + assertEq(token.balanceOf(BOB), attachment.amount); + assertEq(mail.nonces(ALICE), 1); + } + + /// @notice Relayed send with a P256 signature — Carol signs, Bob delivers. + function test_SendMailWithP256Signature() public { + token.mint(CAROL, 100_000 * 10 ** token.decimals()); + + Mail.Attachment memory attachment = + Mail.Attachment({amount: 100 * 10 ** token.decimals(), memo: "Invoice #1234"}); + + vm.prank(CAROL); + token.approve(address(mail), attachment.amount); + + string memory message = "Hello Bob, signed with P256."; + bytes32 digest = mail.getDigest(CAROL, BOB, message, attachment); + (bytes32 r, bytes32 s) = vm.signP256(CAROL_P256_PK, digest); + s = _normalizeP256S(s); + + bytes memory sig = abi.encodePacked(uint8(0x01), r, s, carolPubX, carolPubY, uint8(0)); + + vm.prank(BOB); + mail.sendMail(CAROL, BOB, message, attachment, sig); + + assertEq(token.balanceOf(BOB), attachment.amount); + assertEq(mail.nonces(CAROL), 1); + } + + /// @notice Replaying the same signature fails (nonce incremented). + function test_ReplayReverts() public { + token.mint(ALICE, 100_000 * 10 ** token.decimals()); + + Mail.Attachment memory attachment = Mail.Attachment({amount: 50 * 10 ** token.decimals(), memo: "tip"}); + + vm.prank(ALICE); + token.approve(address(mail), attachment.amount * 2); + + string memory message = "tip"; + bytes32 digest = mail.getDigest(ALICE, BOB, message, attachment); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(ALICE_PK, digest); + bytes memory sig = abi.encodePacked(r, s, v); + + mail.sendMail(ALICE, BOB, message, attachment, sig); + + vm.expectRevert(); + mail.sendMail(ALICE, BOB, message, attachment, sig); + } + + /// @notice Submitting a different account's signature as Alice's fails. + function test_WrongSignerReverts() public { + Mail.Attachment memory attachment = Mail.Attachment({amount: 100, memo: "fake"}); + + string memory message = "spoofed"; + bytes32 digest = mail.getDigest(ALICE, BOB, message, attachment); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(BAD_SIGNER_PK, digest); + + vm.expectRevert("invalid signature"); + mail.sendMail(ALICE, BOB, message, attachment, abi.encodePacked(r, s, v)); + } + + /// @dev Normalize P256 s to low-s form (required by the precompile). + function _normalizeP256S(bytes32 s) internal pure returns (bytes32) { + uint256 sVal = uint256(s); + if (sVal > P256N_HALF) return bytes32(P256_ORDER - sVal); + return s; + } +} diff --git a/crates/forge/assets/tempo/README.md b/crates/forge/assets/tempo/README.md new file mode 100644 index 0000000000000..603f4a3d97d85 --- /dev/null +++ b/crates/forge/assets/tempo/README.md @@ -0,0 +1,57 @@ +## Tempo Foundry + +**Foundry is a blazing fast, portable and modular toolkit for Ethereum application development written in Rust.** + +Tempo's fork of Foundry consists of: + +- **Forge**: Ethereum testing framework (like Truffle, Hardhat and DappTools). +- **Cast**: Swiss army knife for interacting with EVM smart contracts, sending transactions and getting chain data. + +## Documentation + +https://book.getfoundry.sh/ + +## Usage + +### Build + +```shell +$ forge build +``` + +### Test + +```shell +$ forge test +``` + +### Format + +```shell +$ forge fmt +``` + +### Gas Snapshots + +```shell +$ forge snapshot +``` + +### Deploy + +```shell +$ forge script script/Mail.s.sol:MailScript --rpc-url --private-key +``` + +### Cast + +```shell +$ cast +``` + +### Help + +```shell +$ forge --help +$ cast --help +``` diff --git a/crates/forge/assets/tempo/workflowTemplate.yml b/crates/forge/assets/tempo/workflowTemplate.yml new file mode 100644 index 0000000000000..224b3240006cd --- /dev/null +++ b/crates/forge/assets/tempo/workflowTemplate.yml @@ -0,0 +1,37 @@ +name: CI + +permissions: {} + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + check: + name: Tempo Foundry project + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + with: + version: nightly + + - name: Show Forge version + run: forge --version + + - name: Run Forge fmt + run: forge fmt --check + + - name: Run Forge build + run: forge build --sizes + + - name: Run Forge tests + run: forge test -vvv diff --git a/crates/forge/assets/vyper/CounterTemplate.s.sol b/crates/forge/assets/vyper/CounterTemplate.s.sol new file mode 100644 index 0000000000000..aab0faa29c80c --- /dev/null +++ b/crates/forge/assets/vyper/CounterTemplate.s.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Script} from "forge-std/Script.sol"; +import {ICounter} from "../src/ICounter.sol"; + +contract CounterScript is Script { + ICounter public counter; + + function setUp() public {} + + function run() public { + vm.startBroadcast(); + + counter = ICounter(deployCode("src/Counter.vy")); + + vm.stopBroadcast(); + } +} diff --git a/crates/forge/assets/vyper/CounterTemplate.t.sol b/crates/forge/assets/vyper/CounterTemplate.t.sol new file mode 100644 index 0000000000000..b492089b190ea --- /dev/null +++ b/crates/forge/assets/vyper/CounterTemplate.t.sol @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +import {Test} from "forge-std/Test.sol"; +import {ICounter} from "../src/ICounter.sol"; + +contract CounterTest is Test { + ICounter public counter; + + function setUp() public { + counter = ICounter(deployCode("src/Counter.vy")); + counter.setNumber(0); + } + + function test_Increment() public { + counter.increment(); + assertEq(counter.number(), 1); + } + + function testFuzz_SetNumber(uint256 x) public { + counter.setNumber(x); + assertEq(counter.number(), x); + } +} diff --git a/crates/forge/assets/vyper/CounterTemplate.vy b/crates/forge/assets/vyper/CounterTemplate.vy new file mode 100644 index 0000000000000..f0a00db3d6209 --- /dev/null +++ b/crates/forge/assets/vyper/CounterTemplate.vy @@ -0,0 +1,11 @@ +# pragma version ~=0.4.3 + +number: public(uint256) + +@external +def setNumber(newNumber: uint256): + self.number = newNumber + +@external +def increment(): + self.number += 1 \ No newline at end of file diff --git a/crates/forge/assets/vyper/ICounterTemplate.sol b/crates/forge/assets/vyper/ICounterTemplate.sol new file mode 100644 index 0000000000000..a3219a951776f --- /dev/null +++ b/crates/forge/assets/vyper/ICounterTemplate.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +interface ICounter { + function number() external view returns (uint256); + function setNumber(uint256 newNumber) external; + function increment() external; +} diff --git a/crates/forge/assets/vyper/workflowTemplate.yml b/crates/forge/assets/vyper/workflowTemplate.yml new file mode 100644 index 0000000000000..739e656fc809d --- /dev/null +++ b/crates/forge/assets/vyper/workflowTemplate.yml @@ -0,0 +1,46 @@ +name: CI + +permissions: {} + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + check: + name: Foundry project + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + + - name: Show Forge version + run: forge --version + + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: "3.x" + + - name: Install Vyper + run: pip install git+https://github.com/vyperlang/vyper.git@v0.4.3 + + - name: Show the Vyper version + run: vyper --version + + - name: Run Forge fmt + run: forge fmt --check + + - name: Run Forge build + run: forge build --sizes + + - name: Run Forge tests + run: forge test -vvv diff --git a/crates/forge/assets/workflowTemplate.yml b/crates/forge/assets/workflowTemplate.yml deleted file mode 100644 index 4481ec6a8b2de..0000000000000 --- a/crates/forge/assets/workflowTemplate.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: CI - -on: - push: - pull_request: - workflow_dispatch: - -env: - FOUNDRY_PROFILE: ci - -jobs: - check: - name: Foundry project - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - submodules: recursive - - - name: Install Foundry - uses: foundry-rs/foundry-toolchain@v1 - - - name: Show Forge version - run: | - forge --version - - - name: Run Forge fmt - run: | - forge fmt --check - id: fmt - - - name: Run Forge build - run: | - forge build --sizes - id: build - - - name: Run Forge tests - run: | - forge test -vvv - id: test diff --git a/crates/forge/bin/main.rs b/crates/forge/bin/main.rs index bce1bd085f1fa..88527bff89238 100644 --- a/crates/forge/bin/main.rs +++ b/crates/forge/bin/main.rs @@ -1,6 +1,14 @@ //! The `forge` CLI: build, test, fuzz, debug and deploy Solidity contracts, like Hardhat, Brownie, //! Ape. +#![cfg_attr( + target_os = "macos", + allow( + linker_messages, + reason = "Apple ld cannot encode Forge's large unwind table in its compact format" + ) +)] + use forge::args::run; #[global_allocator] diff --git a/crates/forge/src/args.rs b/crates/forge/src/args.rs index de7087cecb904..928889d602251 100644 --- a/crates/forge/src/args.rs +++ b/crates/forge/src/args.rs @@ -1,40 +1,42 @@ use crate::{ - cmd::{cache::CacheSubcommands, generate::GenerateSubcommands, watch}, + cmd::{cache::CacheSubcommands, watch}, opts::{Forge, ForgeSubcommand}, }; use clap::{CommandFactory, Parser}; use clap_complete::generate; use eyre::Result; -use foundry_cli::{handler, utils}; -use foundry_common::shell; +use foundry_cli::utils; +use foundry_common::{sh_warn, shell}; use foundry_evm::inspectors::cheatcodes::{ForgeContext, set_execution_context}; /// Run the `forge` command line interface. pub fn run() -> Result<()> { - setup()?; + foundry_cli::opts::GlobalArgs::check_markdown_help::(); + + let warnings = setup()?; let args = Forge::parse(); args.global.init()?; + for warning in warnings { + let _ = foundry_common::sh_warn!("{warning}"); + } run_command(args) } /// Setup the global logger and other utilities. -pub fn setup() -> Result<()> { - utils::install_crypto_provider(); - handler::install(); - utils::load_dotenv(); +pub fn setup() -> Result> { + let warnings = utils::common_setup(); utils::subscriber(); - utils::enable_paint(); - Ok(()) + Ok(warnings) } /// Run the subcommand. pub fn run_command(args: Forge) -> Result<()> { // Set the execution context based on the subcommand. let context = match &args.cmd { - ForgeSubcommand::Test(_) => ForgeContext::Test, + ForgeSubcommand::Test(_) | ForgeSubcommand::Fuzz(_) => ForgeContext::Test, ForgeSubcommand::Coverage(_) => ForgeContext::Coverage, ForgeSubcommand::Snapshot(_) => ForgeContext::Snapshot, ForgeSubcommand::Script(cmd) => { @@ -63,7 +65,12 @@ pub fn run_command(args: Forge) -> Result<()> { outcome.ensure_ok(silent) } } - ForgeSubcommand::Script(cmd) => global.block_on(cmd.run_script()), + ForgeSubcommand::Fuzz(cmd) => { + let silent = cmd.is_junit() || shell::is_json(); + let outcome = global.block_on(cmd.run())?; + outcome.ensure_ok(silent) + } + ForgeSubcommand::Script(cmd) => block_on_command(global, || cmd.run_script()), ForgeSubcommand::Coverage(cmd) => { if cmd.is_watch() { global.block_on(watch::watch_coverage(cmd)) @@ -72,14 +79,17 @@ pub fn run_command(args: Forge) -> Result<()> { } } ForgeSubcommand::Bind(cmd) => cmd.run(), - ForgeSubcommand::Build(cmd) => { - if cmd.is_watch() { - global.block_on(watch::watch_build(cmd)) + ForgeSubcommand::Build { args, locked } => { + if args.is_watch() { + global.block_on(watch::watch_build(args)) } else { - cmd.run().map(drop) + global.block_on(args.run(locked)).map(drop) } } - ForgeSubcommand::VerifyContract(args) => global.block_on(args.run()), + ForgeSubcommand::VerifyContract(mut args) => { + args.print_submission_result_to_stdout = true; + global.block_on(args.run()) + } ForgeSubcommand::VerifyCheck(args) => global.block_on(args.run()), ForgeSubcommand::VerifyBytecode(cmd) => global.block_on(cmd.run()), ForgeSubcommand::Clone(cmd) => global.block_on(cmd.run()), @@ -89,27 +99,21 @@ pub fn run_command(args: Forge) -> Result<()> { }, ForgeSubcommand::Create(cmd) => global.block_on(cmd.run()), ForgeSubcommand::Update(cmd) => cmd.run(), - ForgeSubcommand::Install(cmd) => cmd.run(), + ForgeSubcommand::Install(cmd) => global.block_on(cmd.run()), + ForgeSubcommand::Reinit(cmd) => cmd.run(), ForgeSubcommand::Remove(cmd) => cmd.run(), ForgeSubcommand::Remappings(cmd) => cmd.run(), - ForgeSubcommand::Init(cmd) => cmd.run(), + ForgeSubcommand::Init(cmd) => global.block_on(cmd.run()), ForgeSubcommand::Completions { shell } => { generate(shell, &mut Forge::command(), "forge", &mut std::io::stdout()); Ok(()) } - ForgeSubcommand::GenerateFigSpec => { - clap_complete::generate( - clap_complete_fig::Fig, - &mut Forge::command(), - "forge", - &mut std::io::stdout(), - ); - Ok(()) - } ForgeSubcommand::Clean { root } => { let config = utils::load_config_with_root(root.as_deref())?; let project = config.project()?; - config.cleanup(&project)?; + for warning in config.cleanup(&project)? { + let _ = sh_warn!("{warning}"); + } Ok(()) } ForgeSubcommand::Snapshot(cmd) => { @@ -130,29 +134,30 @@ pub fn run_command(args: Forge) -> Result<()> { ForgeSubcommand::Flatten(cmd) => cmd.run(), ForgeSubcommand::Inspect(cmd) => cmd.run(), ForgeSubcommand::Tree(cmd) => cmd.run(), - ForgeSubcommand::Geiger(cmd) => { - let n = cmd.run()?; - if n > 0 { - std::process::exit(n as i32); - } - Ok(()) - } + ForgeSubcommand::Geiger(cmd) => global.block_on(cmd.run()), ForgeSubcommand::Doc(cmd) => { if cmd.is_watch() { global.block_on(watch::watch_doc(cmd)) } else { - global.block_on(cmd.run())?; - Ok(()) + global.block_on(cmd.run()) } } ForgeSubcommand::Selectors { command } => global.block_on(command.run()), - ForgeSubcommand::Generate(cmd) => match cmd.sub { - GenerateSubcommands::Test(cmd) => cmd.run(), - }, ForgeSubcommand::Compiler(cmd) => cmd.run(), ForgeSubcommand::Soldeer(cmd) => global.block_on(cmd.run()), ForgeSubcommand::Eip712(cmd) => cmd.run(), ForgeSubcommand::BindJson(cmd) => cmd.run(), - ForgeSubcommand::Lint(cmd) => cmd.run(), + ForgeSubcommand::Lint(cmd) => global.block_on(cmd.run()), + ForgeSubcommand::Lsp(cmd) => global.block_on(crate::cmd::lsp::run(cmd)), } } + +fn block_on_command( + global: &foundry_cli::opts::GlobalArgs, + make_future: impl FnOnce() -> Fut, +) -> Fut::Output +where + Fut: std::future::Future, +{ + global.block_on(make_future()) +} diff --git a/crates/forge/src/brutalizer/assembly.rs b/crates/forge/src/brutalizer/assembly.rs new file mode 100644 index 0000000000000..c3930dc592758 --- /dev/null +++ b/crates/forge/src/brutalizer/assembly.rs @@ -0,0 +1,206 @@ +use solar::{ + ast::{Block, FunctionKind, ItemFunction, Span, StmtKind, Visibility}, + interface::BytePos, +}; + +use super::{span_seed, splitmix64, transform::Transform}; + +pub(super) fn assembly_transforms(func: &ItemFunction<'_>) -> Vec { + let Some(body) = &func.body else { return Vec::new() }; + + let visibility = func.header.visibility(); + let kind = Some(func.kind); + if !block_contains_assembly(body) || !is_eligible_function(visibility, kind) { + return Vec::new(); + } + + let insert_pos = body.span.lo().0 + 1; + let insert_span = Span::new(BytePos(insert_pos), BytePos(insert_pos)); + let replacement = format!( + "{}{}", + generate_memory_brutalization_assembly(insert_span), + generate_fmp_misalignment_assembly(insert_span) + ); + vec![Transform::Insert { offset: insert_pos as usize, replacement }] +} + +const fn is_eligible_function(visibility: Option, kind: Option) -> bool { + if let Some(kind) = kind + && !matches!(kind, FunctionKind::Function) + { + return false; + } + + matches!(visibility, Some(Visibility::External)) +} + +fn generate_memory_brutalization_assembly(span: Span) -> String { + let s = span_seed(span); + let w0 = splitmix64(s); + let w1 = splitmix64(s.wrapping_add(1)); + let w2 = splitmix64(s.wrapping_add(2)); + let w3 = splitmix64(s.wrapping_add(3)); + let w4 = splitmix64(s.wrapping_add(4)); + let w5 = splitmix64(s.wrapping_add(5)); + let w6 = splitmix64(s.wrapping_add(6)); + let w7 = splitmix64(s.wrapping_add(7)); + let s0 = splitmix64(s.wrapping_add(8)); + let s1 = splitmix64(s.wrapping_add(9)); + let s2 = splitmix64(s.wrapping_add(10)); + let s3 = splitmix64(s.wrapping_add(11)); + format!( + " assembly {{ \ + mstore(0x00, 0x{w0:016x}{w1:016x}{w2:016x}{w3:016x}) \ + mstore(0x20, 0x{w4:016x}{w5:016x}{w6:016x}{w7:016x}) \ + let _b_p := mload(0x40) \ + mstore(_b_p, 0x{s0:016x}{s1:016x}{s2:016x}{s3:016x}) \ + for {{ let _b_i := 0x20 }} lt(_b_i, 0x400) {{ _b_i := add(_b_i, 0x20) }} {{ \ + mstore(add(_b_p, _b_i), keccak256(add(_b_p, sub(_b_i, 0x20)), 0x20)) \ + }} \ + }} " + ) +} + +fn generate_fmp_misalignment_assembly(span: Span) -> String { + let offset = deterministic_fmp_offset(span); + format!(" assembly {{ mstore(0x40, add(mload(0x40), {offset})) }} ") +} + +fn deterministic_fmp_offset(span: Span) -> u8 { + ((span_seed(span) % 31) as u8) | 1 +} + +fn block_contains_assembly(block: &Block<'_>) -> bool { + block.stmts.iter().any(|stmt| stmt_contains_assembly(&stmt.kind)) +} + +fn stmt_contains_assembly(kind: &StmtKind<'_>) -> bool { + match kind { + StmtKind::Assembly(_) => true, + StmtKind::Block(block) | StmtKind::UncheckedBlock(block) => block_contains_assembly(block), + StmtKind::If(_, then_stmt, else_stmt) => { + stmt_contains_assembly(&then_stmt.kind) + || else_stmt.as_ref().is_some_and(|s| stmt_contains_assembly(&s.kind)) + } + StmtKind::While(_, body) | StmtKind::DoWhile(body, _) => stmt_contains_assembly(&body.kind), + StmtKind::For { body, .. } => stmt_contains_assembly(&body.kind), + StmtKind::Try(try_stmt) => { + try_stmt.clauses.iter().any(|clause| block_contains_assembly(&clause.block)) + } + _ => false, + } +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::deterministic_fmp_offset; + use crate::brutalizer::brutalize_source; + use solar::{ast::Span, interface::BytePos}; + + fn brutalize(source: &str) -> String { + brutalize_source(Path::new("test.sol"), source).unwrap() + } + + #[test] + fn memory_injected_for_external_assembly_function() { + let source = r#" +pragma solidity ^0.8.0; +contract T { + function f() external pure returns (uint256 r) { + assembly { r := 42 } + } +} +"#; + let result = brutalize(source); + assert!(result.contains("mstore(0x00,")); + assert!(result.contains("mstore(0x20,")); + assert!(result.contains("let _b_p := mload(0x40)")); + assert!(result.contains("keccak256(")); + } + + #[test] + fn fmp_injected_for_external_assembly_function() { + let source = r#" +pragma solidity ^0.8.0; +contract T { + function f() external pure returns (uint256 r) { + assembly { r := 42 } + } +} +"#; + let result = brutalize(source); + assert!(result.contains("mstore(0x40, add(mload(0x40),")); + } + + #[test] + fn memory_fill_precedes_fmp_misalignment() { + let source = r#" +pragma solidity ^0.8.0; +contract T { + function f() external pure returns (uint256 r) { + assembly { r := 42 } + } +} +"#; + let result = brutalize(source); + let memory_pos = result.find("let _b_p := mload(0x40)").unwrap(); + let fmp_pos = result.find("mstore(0x40, add(mload(0x40),").unwrap(); + assert!(memory_pos < fmp_pos); + } + + #[test] + fn not_injected_for_non_assembly_function() { + let source = r#" +pragma solidity ^0.8.0; +contract T { + function f() external pure returns (uint256) { + return 42; + } +} +"#; + let result = brutalize(source); + assert!(!result.contains("mstore(0x00,")); + assert!(!result.contains("mstore(0x40, add(")); + } + + #[test] + fn not_injected_for_public_function() { + let source = r#" +pragma solidity ^0.8.0; +contract T { + function f() public pure returns (uint256 r) { + assembly { r := 42 } + } +} +"#; + let result = brutalize(source); + assert!(!result.contains("mstore(0x00,")); + } + + #[test] + fn not_injected_for_internal_function() { + let source = r#" +pragma solidity ^0.8.0; +contract T { + function f() internal pure returns (uint256 r) { + assembly { r := 42 } + } +} +"#; + let result = brutalize(source); + assert!(!result.contains("mstore(0x00,")); + } + + #[test] + fn fmp_offset_is_odd_and_bounded() { + for i in 0..100u32 { + let span = Span::new(BytePos(i), BytePos(i + 10)); + let offset = deterministic_fmp_offset(span); + assert!(offset > 0, "offset must be non-zero"); + assert!(offset < 32, "offset must be < 32"); + assert!(offset % 2 == 1, "offset must be odd"); + } + } +} diff --git a/crates/forge/src/brutalizer/mod.rs b/crates/forge/src/brutalizer/mod.rs new file mode 100644 index 0000000000000..e021cd5d7c556 --- /dev/null +++ b/crates/forge/src/brutalizer/mod.rs @@ -0,0 +1,233 @@ +use std::path::{Path, PathBuf}; + +use foundry_config::Config; +use solar::{ + ast::{ + Span, + interface::{Session, source_map::FileName}, + }, + parse::Parser, +}; + +mod assembly; +mod transform; +mod value; +mod visitor; + +use transform::apply_transforms; +use visitor::collect_transforms; + +pub fn brutalize_source(path: &Path, source: &str) -> eyre::Result { + let sess = Session::builder().with_silent_emitter(None).build(); + + let result = sess.enter(|| -> solar::interface::Result<_> { + let arena = solar::ast::Arena::new(); + let ast = { + let mut parser = Parser::from_lazy_source_code( + &sess, + &arena, + FileName::from(path.to_path_buf()), + || Ok(source.to_string()), + )?; + parser.parse_file().map_err(|e| e.emit())? + }; + + Ok(collect_transforms(source, &ast)) + }); + + let transforms = match result { + Ok(t) => t, + Err(_) => { + eyre::bail!("failed to parse {}", path.display()); + } + }; + + Ok(apply_transforms(source, transforms)) +} + +/// Brutalize all .sol source files in a temp project directory. +/// +/// Walks the src directory under `temp_dir`, parses each .sol file, applies all +/// brutalizations (value XOR, memory, FMP), and writes the result back in-place. +/// +/// Returns the number of files brutalized. +pub fn brutalize_project(config: &Config, temp_dir: &Path) -> eyre::Result { + let src_rel = config.src.strip_prefix(&config.root).unwrap_or(&config.src); + let src_dir = temp_dir.join(src_rel); + + if !src_dir.exists() { + return Ok(0); + } + + let src_rel = src_rel.to_path_buf(); + let skipped_dirs = crate::workspace::handled_project_roots(config)? + .into_iter() + .filter(|rel| !rel.as_os_str().is_empty() && *rel != src_rel) + .map(|rel| temp_dir.join(rel)) + .collect::>(); + + brutalize_sol_files_in_dir(&src_dir, &skipped_dirs) +} + +fn brutalize_sol_files_in_dir(dir: &Path, skipped_dirs: &[PathBuf]) -> eyre::Result { + let mut count = 0; + let entries = std::fs::read_dir(dir)?; + for entry in entries { + let entry = entry?; + let path = entry.path(); + if skipped_dirs.contains(&path) { + continue; + } + if path.is_dir() { + count += brutalize_sol_files_in_dir(&path, skipped_dirs)?; + } else if path.extension().is_some_and(|ext| ext == "sol") && !is_test_or_script(&path) { + let source = std::fs::read_to_string(&path)?; + let brutalized = brutalize_source(&path, &source)?; + if brutalized != source { + std::fs::write(&path, brutalized)?; + count += 1; + } + } + } + Ok(count) +} + +fn is_test_or_script(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(".t.sol") || name.ends_with(".s.sol")) +} + +/// Applies the splitmix64 finalizer to produce a well-distributed 64-bit hash. +const fn splitmix64(mut x: u64) -> u64 { + x ^= x >> 30; + x = x.wrapping_mul(0xbf58476d1ce4e5b9); + x ^= x >> 27; + x = x.wrapping_mul(0x94d049bb133111eb); + x ^= x >> 31; + x +} + +/// Derives a deterministic seed from a span's byte offsets. +fn span_seed(span: Span) -> u64 { + let lo = span.lo().0 as u64; + let hi = span.hi().0 as u64; + splitmix64(lo.wrapping_mul(0x9e3779b97f4a7c15) ^ hi.wrapping_mul(0xff51afd7ed558ccd)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn brutalize(source: &str) -> String { + brutalize_source(Path::new("test.sol"), source).unwrap() + } + + #[test] + fn deterministic_output() { + let source = r#" +pragma solidity ^0.8.0; +contract T { + function f(uint160 x) external pure returns (address) { + return address(x); + } +} +"#; + let r1 = brutalize(source); + let r2 = brutalize(source); + assert_eq!(r1, r2); + } + + #[test] + fn no_change_without_casts_or_assembly() { + let source = r#" +pragma solidity ^0.8.0; +contract T { + uint256 public x; + function set(uint256 v) external { + x = v; + } +} +"#; + let result = brutalize(source); + assert_eq!(result, source); + } + + #[test] + fn special_functions_not_brutalized() { + for body in [ + "constructor() { assembly { sstore(0, 1) } }", + "fallback() external { assembly { sstore(0, 1) } }", + "receive() external payable { assembly { sstore(0, 1) } }", + ] { + let source = format!("pragma solidity ^0.8.0;\ncontract T {{ {body} }}\n"); + let result = brutalize(&source); + assert!(!result.contains("mstore(0x00,"), "should not inject for: {body}"); + } + + let free_fn = r#" +pragma solidity ^0.8.0; +function freeFunc() pure returns (uint256 r) { + assembly { r := 42 } +} +"#; + let result = brutalize(free_fn); + assert!(!result.contains("mstore(0x00,")); + } + + #[test] + fn assembly_in_nested_control_flow() { + for body in [ + "if (true) { assembly { r := 42 } }", + "for (uint256 i; i < 1; i++) { assembly { r := 42 } }", + "unchecked { assembly { r := 42 } }", + ] { + let source = format!( + "pragma solidity ^0.8.0;\ncontract T {{\n\ + function f() external pure returns (uint256 r) {{ {body} }}\n}}\n" + ); + let result = brutalize(&source); + assert!(result.contains("mstore(0x00,"), "should inject for: {body}"); + assert!( + result.contains("mstore(0x40, add(mload(0x40),"), + "should inject FMP for: {body}" + ); + } + } + + #[test] + fn cast_and_assembly_in_same_function() { + let source = r#" +pragma solidity ^0.8.0; +contract T { + function f(uint256 x) external pure returns (uint8 r) { + r = uint8(x); + assembly { r := add(r, 1) } + } +} +"#; + let result = brutalize(source); + assert!(result.contains("uint8(uint256(uint8(x)) | (uint256(0x")); + assert!(result.contains("mstore(0x00,")); + assert!(result.contains("mstore(0x40, add(mload(0x40),")); + } + + #[test] + fn visibility_gates_injection_not_casts() { + let source = r#" +pragma solidity ^0.8.0; +contract T { + function f(uint256 x) internal pure returns (uint8) { + return uint8(x); + } + function g(uint256 x) public pure returns (uint8) { + return uint8(x); + } +} +"#; + let result = brutalize(source); + let count = result.matches("uint8(uint256(uint8(x)) | (uint256(0x").count(); + assert_eq!(count, 2, "casts in internal/public should be brutalized"); + assert!(!result.contains("mstore(0x00,"), "no memory injection for non-external"); + } +} diff --git a/crates/forge/src/brutalizer/transform.rs b/crates/forge/src/brutalizer/transform.rs new file mode 100644 index 0000000000000..ebdf09d2447f4 --- /dev/null +++ b/crates/forge/src/brutalizer/transform.rs @@ -0,0 +1,45 @@ +use solar::ast::Span; + +pub(super) enum Transform { + Insert { offset: usize, replacement: String }, + Replace { span: Span, replacement: String }, +} + +impl Transform { + fn start(&self) -> usize { + match self { + Self::Insert { offset, .. } => *offset, + Self::Replace { span, .. } => span.lo().0 as usize, + } + } + + fn end(&self) -> usize { + match self { + Self::Insert { offset, .. } => *offset, + Self::Replace { span, .. } => span.hi().0 as usize, + } + } +} + +pub(super) fn span_text(source: &str, span: Span) -> Option<&str> { + let lo = span.lo().0 as usize; + let hi = span.hi().0 as usize; + source.get(lo..hi) +} + +pub(super) fn apply_transforms(source: &str, mut transforms: Vec) -> String { + transforms.sort_by(|a, b| b.start().cmp(&a.start()).then_with(|| b.end().cmp(&a.end()))); + + let mut result = source.to_string(); + for transform in transforms { + match transform { + Transform::Insert { offset, replacement } => result.insert_str(offset, &replacement), + Transform::Replace { span, replacement } => { + let lo = span.lo().0 as usize; + let hi = span.hi().0 as usize; + result.replace_range(lo..hi, &replacement); + } + } + } + result +} diff --git a/crates/forge/src/brutalizer/value.rs b/crates/forge/src/brutalizer/value.rs new file mode 100644 index 0000000000000..281d12faef9e6 --- /dev/null +++ b/crates/forge/src/brutalizer/value.rs @@ -0,0 +1,237 @@ +use solar::ast::{ElementaryType, Span, Type, TypeKind, TypeSize}; + +use super::span_seed; + +pub(super) fn deterministic_mask(span: Span) -> String { + let h = span_seed(span); + let mask = if h == 0 { 1 } else { h }; + format!("0x{mask:016x}") +} + +pub(super) fn brutalize_cast(ty: &Type<'_>, arg_text: &str, mask: &str) -> Option { + match &ty.kind { + TypeKind::Elementary(elem_ty) => match elem_ty { + ElementaryType::Address(payable) => Some(brutalize_address(*payable, arg_text, mask)), + ElementaryType::UInt(size) => brutalize_uint(*size, arg_text, mask), + ElementaryType::Int(size) => brutalize_int(*size, arg_text, mask), + ElementaryType::FixedBytes(size) => brutalize_fixed_bytes(*size, arg_text, mask), + ElementaryType::Bool => None, + ElementaryType::Bytes | ElementaryType::String => None, + ElementaryType::Fixed(..) | ElementaryType::UFixed(..) => None, + }, + _ => None, + } +} + +pub(super) fn brutalize_payable_address(arg_text: &str, mask: &str) -> String { + brutalize_address(true, arg_text, mask) +} + +fn brutalize_address(payable: bool, arg_text: &str, mask: &str) -> String { + let expr = format!( + "address(uint160(uint256(uint160(address({arg_text}))) | (uint256({mask}) << 160)))" + ); + if payable { format!("payable({expr})") } else { expr } +} + +fn brutalize_uint(size: TypeSize, arg_text: &str, mask: &str) -> Option { + let bits = size.bits_raw(); + let actual_bits = if bits == 0 { 256 } else { bits }; + if actual_bits >= 256 { + return None; + } + let mask = dirty_mask(mask, usize::from(256 - actual_bits)); + Some(format!( + "uint{actual_bits}(uint256(uint{actual_bits}({arg_text})) | (uint256({mask}) << {actual_bits}))" + )) +} + +fn brutalize_int(size: TypeSize, arg_text: &str, mask: &str) -> Option { + let bits = size.bits_raw(); + let actual_bits = if bits == 0 { 256 } else { bits }; + if actual_bits >= 256 { + return None; + } + let mask = dirty_mask(mask, usize::from(256 - actual_bits)); + Some(format!( + "int{actual_bits}(int256(int{actual_bits}({arg_text})) ^ int256(uint256({mask}) << {actual_bits}))" + )) +} + +fn brutalize_fixed_bytes(size: TypeSize, arg_text: &str, mask: &str) -> Option { + let bytes = size.bytes_raw(); + if bytes >= 32 || bytes == 0 { + return None; + } + let unused_bits = (32 - bytes) * 8; + let mask = dirty_mask(mask, usize::from(unused_bits)); + Some(format!( + "bytes{bytes}(bytes32(bytes{bytes}({arg_text})) | bytes32(uint256({mask}) & ((uint256(1) << {unused_bits}) - 1)))" + )) +} + +fn dirty_mask(mask: &str, unused_bits: usize) -> String { + let Ok(mut value) = u64::from_str_radix(mask.trim_start_matches("0x"), 16) else { + return mask.to_string(); + }; + if unused_bits < 64 { + let width_mask = (1u64 << unused_bits) - 1; + value &= width_mask; + if value == 0 { + value = 1; + } + } + format!("0x{value:016x}") +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use crate::brutalizer::brutalize_source; + + fn brutalize(source: &str) -> String { + brutalize_source(Path::new("test.sol"), source).unwrap() + } + + #[test] + fn address_cast() { + let source = r#" +pragma solidity ^0.8.0; +contract T { + function f(uint160 x) external pure returns (address) { + return address(x); + } +} +"#; + let result = brutalize(source); + assert!(result.contains("address(uint160(uint256(uint160(address(x)))")); + assert!(result.contains("| (uint256(0x")); + assert!(result.contains("<< 160)")); + } + + #[test] + fn uint8_cast() { + let source = r#" +pragma solidity ^0.8.0; +contract T { + function f(uint256 x) external pure returns (uint8) { + return uint8(x); + } +} +"#; + let result = brutalize(source); + assert!(result.contains("uint8(uint256(uint8(x)) | (uint256(0x")); + assert!(result.contains("<< 8)")); + } + + #[test] + fn int16_cast() { + let source = r#" +pragma solidity ^0.8.0; +contract T { + function f(int256 x) external pure returns (int16) { + return int16(x); + } +} +"#; + let result = brutalize(source); + assert!(result.contains("int16(int256(int16(x)) ^ int256(uint256(0x")); + assert!(result.contains("<< 16)")); + } + + #[test] + fn payable_address_cast() { + let source = r#" +pragma solidity ^0.8.0; +contract T { + function f(address x) external pure returns (address payable) { + return payable(x); + } +} +"#; + let result = brutalize(source); + assert!(result.contains("payable(address(uint160(uint256(uint160(address(x)))")); + assert!(result.contains("| (uint256(0x")); + assert!(result.contains("<< 160)")); + } + + #[test] + fn signed_cast_uses_xor() { + let source = r#" +pragma solidity ^0.8.0; +contract T { + function f() external pure returns (int16) { + return int16(-1); + } +} +"#; + let result = brutalize(source); + assert!(result.contains("int16(int256(int16(-1)) ^ int256(uint256(0x")); + assert!(result.contains("<< 16)")); + } + + #[test] + fn bytes4_cast() { + let source = r#" +pragma solidity ^0.8.0; +contract T { + function f(bytes32 x) external pure returns (bytes4) { + return bytes4(x); + } +} +"#; + let result = brutalize(source); + assert!(result.contains("bytes4(bytes32(bytes4(x)) | bytes32(uint256(0x")); + assert!(result.contains("<< 224")); + } + + #[test] + fn dirty_mask_is_nonzero_in_effective_width() { + assert_eq!(super::dirty_mask("0x0000000000000100", 8), "0x0000000000000001"); + assert_eq!(super::dirty_mask("0x0000000000000101", 8), "0x0000000000000001"); + } + + #[test] + fn uint256_cast_not_brutalized() { + let source = r#" +pragma solidity ^0.8.0; +contract T { + function f(uint128 x) external pure returns (uint256) { + return uint256(x); + } +} +"#; + let result = brutalize(source); + assert_eq!(result, source); + } + + #[test] + fn bool_cast_not_brutalized() { + let source = r#" +pragma solidity ^0.8.0; +contract T { + function f(bool x) external pure returns (bool) { + return bool(x); + } +} +"#; + let result = brutalize(source); + assert_eq!(result, source); + } + + #[test] + fn multiple_casts_in_one_function() { + let source = r#" +pragma solidity ^0.8.0; +contract T { + function f(uint256 a, uint256 b) external pure returns (uint8, uint16) { + return (uint8(a), uint16(b)); + } +} +"#; + let result = brutalize(source); + assert!(result.contains("uint8(uint256(uint8(a)) | (uint256(0x")); + assert!(result.contains("uint16(uint256(uint16(b)) | (uint256(0x")); + } +} diff --git a/crates/forge/src/brutalizer/visitor.rs b/crates/forge/src/brutalizer/visitor.rs new file mode 100644 index 0000000000000..6f02b7a0b72f6 --- /dev/null +++ b/crates/forge/src/brutalizer/visitor.rs @@ -0,0 +1,77 @@ +use std::ops::ControlFlow; + +use solar::ast::{CallArgsKind, Expr, ExprKind, ItemFunction, SourceUnit, Type, visit::Visit}; + +use super::{ + assembly::assembly_transforms, + transform::{Transform, span_text}, + value::{brutalize_cast, brutalize_payable_address, deterministic_mask}, +}; + +struct BrutalizerVisitor<'src> { + transforms: Vec, + source: &'src str, +} + +impl<'src> BrutalizerVisitor<'src> { + const fn new(source: &'src str) -> Self { + Self { transforms: Vec::new(), source } + } +} + +impl<'ast, 'src> Visit<'ast> for BrutalizerVisitor<'src> { + type BreakValue = (); + + fn visit_expr(&mut self, expr: &'ast Expr<'ast>) -> ControlFlow { + if let Some((ty, arg_text)) = cast_call(self.source, expr) { + let mask = deterministic_mask(expr.span); + if let Some(replacement) = brutalize_cast(ty, arg_text, &mask) { + self.transforms.push(Transform::Replace { span: expr.span, replacement }); + return ControlFlow::Continue(()); + } + } + if let Some(arg_text) = payable_call(self.source, expr) { + let mask = deterministic_mask(expr.span); + let replacement = brutalize_payable_address(arg_text, &mask); + self.transforms.push(Transform::Replace { span: expr.span, replacement }); + return ControlFlow::Continue(()); + } + + self.walk_expr(expr) + } + + fn visit_item_function( + &mut self, + func: &'ast ItemFunction<'ast>, + ) -> ControlFlow { + self.transforms.extend(assembly_transforms(func)); + self.walk_item_function(func) + } +} + +fn payable_call<'src>(source: &'src str, expr: &Expr<'_>) -> Option<&'src str> { + let ExprKind::Payable(call_args) = &expr.kind else { return None }; + let CallArgsKind::Unnamed(args_exprs) = &call_args.kind else { return None }; + let arg_text = span_text(source, args_exprs.first()?.span)?; + (!arg_text.is_empty()).then_some(arg_text) +} + +fn cast_call<'ast, 'src>( + source: &'src str, + expr: &'ast Expr<'ast>, +) -> Option<(&'ast Type<'ast>, &'src str)> { + let ExprKind::Call(callee, call_args) = &expr.kind else { return None }; + let ExprKind::Type(ty) = &callee.kind else { return None }; + let CallArgsKind::Unnamed(args_exprs) = &call_args.kind else { return None }; + let arg_text = span_text(source, args_exprs.first()?.span)?; + (!arg_text.is_empty()).then_some((ty, arg_text)) +} + +pub(super) fn collect_transforms<'ast>( + source: &str, + ast: &'ast SourceUnit<'ast>, +) -> Vec { + let mut visitor = BrutalizerVisitor::new(source); + let _ = visitor.visit_source_unit(ast); + visitor.transforms +} diff --git a/crates/forge/src/cmd/bind.rs b/crates/forge/src/cmd/bind.rs index acf69b158ae07..5a4e1cb7c0769 100644 --- a/crates/forge/src/cmd/bind.rs +++ b/crates/forge/src/cmd/bind.rs @@ -1,12 +1,23 @@ +use alloy_json_abi::ToSolConfig; use alloy_primitives::map::HashSet; use clap::{Parser, ValueHint}; use eyre::Result; use forge_sol_macro_gen::{MultiSolMacroGen, SolMacroGen}; use foundry_cli::{opts::BuildOpts, utils::LoadConfig}; -use foundry_common::{compile::ProjectCompiler, fs::json_files}; +use foundry_common::{ + compile::{ProjectCompiler, compile_abi_project}, + fs::json_files, +}; +use foundry_compilers::{ + Graph, ProjectPathsConfig, + cache::CompilerCache, + multi::{MultiCompilerParser, MultiCompilerSettings}, +}; use foundry_config::impl_figment_convert; use regex::Regex; +use solar::ast::{Item, ItemKind}; use std::{ + collections::BTreeMap, fs, path::{Path, PathBuf}, }; @@ -117,28 +128,46 @@ impl BindArgs { eyre::bail!("`--ethers` bindings have been removed. Use `--alloy` (default) instead."); } - if !self.skip_build { - let project = self.build.project()?; - let _ = ProjectCompiler::new().compile(&project)?; - } + let config = if self.skip_build { + self.load_config()? + } else { + self.load_config_with_dependencies()? + }; + let artifacts = config.out.clone(); + let enum_definitions = if self.skip_build { + let paths = config.project_paths(); + cached_enum_definitions( + &paths, + &artifacts, + self.get_json_files(&artifacts)?.map(|(_, path)| path), + ) + } else { + let mut project = config.project()?; + let output = compile_abi_project( + &mut project, + ProjectCompiler::new().external_compilers(&config), + )?; + enum_definitions(output.parser()) + }; - let config = self.load_config()?; - let artifacts = config.out; let bindings_root = self.bindings.clone().unwrap_or_else(|| artifacts.join("bindings")); + let sol_config = ToSolConfig::new().enum_definitions(enum_definitions); if bindings_root.exists() { if !self.overwrite { - sh_println!("Bindings found. Checking for consistency.")?; - return self.check_existing_bindings(&artifacts, &bindings_root); + sh_status!("Bindings found. Checking for consistency.")?; + let mut bindings = self.get_solmacrogen(&artifacts)?; + bindings.generate_bindings(!self.skip_extra_derives, &sol_config)?; + return self.check_existing_bindings(&bindings, &bindings_root); } trace!(?artifacts, "Removing existing bindings"); fs::remove_dir_all(&bindings_root)?; } - self.generate_bindings(&artifacts, &bindings_root)?; + self.generate_bindings(&artifacts, &bindings_root, &sol_config)?; - sh_println!("Bindings have been generated to {}", bindings_root.display())?; + sh_status!("Bindings have been generated to {}", bindings_root.display())?; Ok(()) } @@ -189,7 +218,7 @@ impl BindArgs { let name = stem.split('.').next().unwrap(); // Best effort identifier cleanup. - let name = name.replace(char::is_whitespace, "").replace('-', "_"); + let name = name.replace(char::is_whitespace, "").replace(['-', '$'], "_"); Some((name, path)) }) @@ -202,20 +231,22 @@ impl BindArgs { .get_json_files(artifacts)? .filter_map(|(name, path)| { trace!(?path, "parsing SolMacroGen from file"); - if dup.insert(name.clone()) { Some(SolMacroGen::new(path, name)) } else { None } + dup.insert(name.clone()).then(|| SolMacroGen::new(path, name)) }) .collect::>(); - let multi = MultiSolMacroGen::new(artifacts, instances); + let multi = MultiSolMacroGen::new(instances); eyre::ensure!(!multi.instances.is_empty(), "No contract artifacts found"); Ok(multi) } /// Check that the existing bindings match the expected abigen output - fn check_existing_bindings(&self, artifacts: &Path, bindings_root: &Path) -> Result<()> { - let mut bindings = self.get_solmacrogen(artifacts)?; - bindings.generate_bindings(!self.skip_extra_derives)?; - sh_println!("Checking bindings for {} contracts", bindings.instances.len())?; + fn check_existing_bindings( + &self, + bindings: &MultiSolMacroGen, + bindings_root: &Path, + ) -> Result<()> { + sh_status!("Checking bindings for {} contracts", bindings.instances.len())?; bindings.check_consistency( &self.crate_name, &self.crate_version, @@ -226,18 +257,31 @@ impl BindArgs { self.alloy_version.clone(), self.alloy_rev.clone(), )?; - sh_println!("OK.")?; + sh_status!("OK.")?; Ok(()) } /// Generate the bindings - fn generate_bindings(&self, artifacts: &Path, bindings_root: &Path) -> Result<()> { - let mut solmacrogen = self.get_solmacrogen(artifacts)?; - sh_println!("Generating bindings for {} contracts", solmacrogen.instances.len())?; + fn generate_bindings( + &self, + artifacts: &Path, + bindings_root: &Path, + sol_config: &ToSolConfig, + ) -> Result<()> { + let mut bindings = self.get_solmacrogen(artifacts)?; + sh_status!("Generating bindings for {} contracts", bindings.instances.len())?; - if !self.module { + if self.module { + trace!(single_file = self.single_file, "generating module"); + bindings.write_to_module( + bindings_root, + self.single_file, + !self.skip_extra_derives, + sol_config, + )?; + } else { trace!(single_file = self.single_file, "generating crate"); - solmacrogen.write_to_crate( + bindings.write_to_crate( &self.crate_name, &self.crate_version, &self.crate_description, @@ -247,13 +291,7 @@ impl BindArgs { self.alloy_version.clone(), self.alloy_rev.clone(), !self.skip_extra_derives, - )?; - } else { - trace!(single_file = self.single_file, "generating module"); - solmacrogen.write_to_module( - bindings_root, - self.single_file, - !self.skip_extra_derives, + sol_config, )?; } @@ -261,6 +299,87 @@ impl BindArgs { } } +fn cached_enum_definitions( + paths: &ProjectPathsConfig, + artifacts_root: &Path, + artifacts: impl Iterator, +) -> BTreeMap> { + let Ok(graph) = Graph::::resolve(paths) else { + return BTreeMap::default(); + }; + let Ok(cache) = CompilerCache::::read_joined(paths) else { + return BTreeMap::default(); + }; + let sources_are_fresh = graph.nodes.iter().all(|node| { + cache + .entry(node.path()) + .is_some_and(|entry| entry.content_hash == node.unpack().1.content_hash()) + }); + if !sources_are_fresh || !cache.all_artifacts_exist() { + return BTreeMap::default(); + } + let cached_artifacts = cache + .entries() + .flat_map(|entry| entry.artifacts.values()) + .flat_map(|versions| versions.values()) + .flat_map(|profiles| profiles.values()) + .map(|artifact| artifact.path.clone()) + .collect::>(); + if artifacts.into_iter().any(|artifact| { + !artifact.starts_with(artifacts_root.join(".external")) + && !cached_artifacts.contains(&artifact) + }) { + return BTreeMap::default(); + } + enum_definitions(graph.parser()) +} + +fn enum_definitions(parser: &MultiCompilerParser) -> BTreeMap> { + parser.solc().compiler().enter(|compiler| { + let mut definitions = BTreeMap::default(); + let mut ambiguous = HashSet::default(); + for source in compiler.sources().iter() { + if let Some(ast) = &source.ast { + collect_enum_definitions(ast.items.iter(), None, &mut definitions, &mut ambiguous); + } + } + definitions + }) +} + +fn collect_enum_definitions<'ast>( + items: impl Iterator>, + owner: Option<&str>, + definitions: &mut BTreeMap>, + ambiguous: &mut HashSet, +) { + for item in items { + match &item.kind { + ItemKind::Enum(enum_item) => { + let name = enum_item.name.to_string(); + let key = owner.map_or_else(|| name.clone(), |owner| format!("{owner}.{name}")); + let variants = enum_item.variants.iter().map(ToString::to_string).collect(); + if definitions.get(&key).is_some_and(|existing| existing != &variants) { + definitions.remove(&key); + ambiguous.insert(key); + } else if !ambiguous.contains(&key) { + definitions.insert(key, variants); + } + } + ItemKind::Contract(contract) => { + let owner = contract.name.to_string(); + collect_enum_definitions( + contract.body.iter(), + Some(&owner), + definitions, + ambiguous, + ); + } + _ => {} + } + } +} + pub enum Filter { All, Select(Vec), diff --git a/crates/forge/src/cmd/bind_json.rs b/crates/forge/src/cmd/bind_json.rs index 59c5f741658b8..da209502ca156 100644 --- a/crates/forge/src/cmd/bind_json.rs +++ b/crates/forge/src/cmd/bind_json.rs @@ -2,14 +2,14 @@ use super::eip712::Resolver; use clap::{Parser, ValueHint}; use eyre::Result; use foundry_cli::{ - opts::{BuildOpts, solar_pcx_from_solc_project}, + opts::{BuildOpts, configure_pcx_from_solc}, utils::LoadConfig, }; use foundry_common::{TYPE_BINDING_PREFIX, fs}; use foundry_compilers::{ CompilerInput, Graph, Project, artifacts::{Source, Sources}, - multi::{MultiCompilerLanguage, MultiCompilerParsedSource}, + multi::{MultiCompilerLanguage, MultiCompilerParser}, solc::{SolcLanguage, SolcVersionedInput}, }; use foundry_config::Config; @@ -17,12 +17,11 @@ use itertools::Itertools; use path_slash::PathExt; use rayon::prelude::*; use semver::Version; -use solar_parse::{ +use solar::parse::{ Parser as SolarParser, ast::{self, Arena, FunctionKind, Span, VarMut, interface::source_map::FileName, visit::Visit}, interface::Session, }; -use solar_sema::thread_local::ThreadLocal; use std::{ collections::{BTreeMap, BTreeSet, HashSet}, fmt::Write, @@ -48,13 +47,13 @@ pub struct BindJsonArgs { impl BindJsonArgs { pub fn run(self) -> Result<()> { - let config = self.load_config()?; + let config = self.load_config_with_dependencies()?; let project = config.ephemeral_project()?; let target_path = config.root.join(self.out.as_ref().unwrap_or(&config.bind_json.out)); // Step 1: Read and preprocess sources let sources = project.paths.read_input_files()?; - let graph = Graph::::resolve_sources(&project.paths, sources)?; + let graph = Graph::::resolve_sources(&project.paths, sources)?; // We only generate bindings for a single Solidity version to avoid conflicts. let (version, mut sources, _) = graph @@ -104,18 +103,20 @@ impl BindJsonArgs { /// in most of the cases. fn preprocess_sources(&self, sources: &mut Sources) -> Result<()> { let sess = Session::builder().with_stderr_emitter().build(); - let result = sess.enter_parallel(|| -> solar_parse::interface::Result<()> { + let result = sess.enter(|| -> solar::interface::Result<()> { sources.0.par_iter_mut().try_for_each(|(path, source)| { let mut content = Arc::try_unwrap(std::mem::take(&mut source.content)).unwrap(); let arena = Arena::new(); - let mut parser = SolarParser::from_source_code( - &sess, - &arena, - FileName::Real(path.clone()), - content.to_string(), - )?; - let ast = parser.parse_file().map_err(|e| e.emit())?; + let ast = { + let mut parser = SolarParser::from_source_code( + &sess, + &arena, + FileName::Real(path.clone()), + content.clone(), + )?; + parser.parse_file().map_err(|e| e.emit())? + }; let mut visitor = PreprocessorVisitor::new(); let _ = visitor.visit_source_unit(&ast); @@ -146,23 +147,25 @@ impl BindJsonArgs { let input = SolcVersionedInput::build(sources, settings, SolcLanguage::Solidity, version); let mut sess = Session::builder().with_stderr_emitter().build(); - sess.dcx = sess.dcx.set_flags(|flags| flags.track_diagnostics = false); + sess.dcx.set_flags_mut(|flags| flags.track_diagnostics = false); + let mut compiler = solar::sema::Compiler::new(sess); let mut structs_to_write = Vec::new(); - sess.enter_parallel(|| -> Result<()> { + compiler.enter_mut(|compiler| -> Result<()> { // Set up the parsing context with the project paths, without adding the source files - let mut parsing_context = solar_pcx_from_solc_project(&sess, project, &input, false); + let mut pcx = compiler.parse(); + configure_pcx_from_solc(&mut pcx, &project.paths, &input, false); let mut target_files = HashSet::new(); for (path, source) in &input.input.sources { - if !include.is_empty() { - if !include.iter().any(|matcher| matcher.is_match(path)) { + if include.is_empty() { + // Exclude library files by default + if project.paths.has_library_ancestor(path) { continue; } } else { - // Exclude library files by default - if project.paths.has_library_ancestor(path) { + if !include.iter().any(|matcher| matcher.is_match(path)) { continue; } } @@ -171,51 +174,50 @@ impl BindJsonArgs { continue; } - if let Ok(src_file) = - sess.source_map().new_source_file(path.clone(), source.content.as_str()) + if let Ok(src_file) = compiler + .sess() + .source_map() + .new_source_file(path.clone(), source.content.as_str()) { - target_files.insert(src_file.stable_id); - parsing_context.add_file(src_file); + target_files.insert(Arc::clone(&src_file)); + pcx.add_file(src_file); } } // Parse and resolve - let hir_arena = ThreadLocal::new(); - if let Ok(Some(gcx)) = parsing_context.parse_and_lower(&hir_arena) { - let hir = &gcx.get().hir; - let resolver = Resolver::new(gcx); - for id in resolver.struct_ids() { - if let Some(schema) = resolver.resolve_struct_eip712(id) { - let def = hir.strukt(id); - let source = hir.source(def.source); - - if !target_files.contains(&source.file.stable_id) { - continue; - } - - if let FileName::Real(ref path) = source.file.name { - structs_to_write.push(StructToWrite { - name: def.name.as_str().into(), - contract_name: def - .contract - .map(|id| hir.contract(id).name.as_str().into()), - path: path - .strip_prefix(root) - .unwrap_or_else(|_| path) - .to_path_buf(), - schema, - // will be filled later - import_alias: None, - name_in_fns: String::new(), - }); - } + pcx.parse(); + let Ok(ControlFlow::Continue(())) = compiler.lower_asts() else { return Ok(()) }; + let gcx = compiler.gcx(); + let hir = &gcx.hir; + let resolver = Resolver::new(gcx); + for id in resolver.struct_ids() { + if let Some(schema) = resolver.resolve_struct_eip712(id) { + let def = hir.strukt(id); + let source = hir.source(def.source); + + if !target_files.contains(&source.file) { + continue; + } + + if let FileName::Real(path) = &source.file.name { + structs_to_write.push(StructToWrite { + name: def.name.as_str().into(), + contract_name: def + .contract + .map(|id| hir.contract(id).name.as_str().into()), + path: path.strip_prefix(root).unwrap_or(path).to_path_buf(), + schema, + // will be filled later + import_alias: None, + name_in_fns: String::new(), + }); } } } Ok(()) })?; - eyre::ensure!(sess.dcx.has_errors().is_ok(), "errors occurred"); + eyre::ensure!(compiler.sess().dcx.has_errors().is_ok(), "errors occurred"); // Resolve import aliases and function names self.resolve_conflicts(&mut structs_to_write); @@ -285,7 +287,7 @@ impl BindJsonArgs { } } - for (s, fn_name) in structs_to_write.iter_mut().zip(fn_names.into_iter()) { + for (s, fn_name) in structs_to_write.iter_mut().zip(fn_names) { s.name_in_fns = fn_name.unwrap_or(s.name.clone()); } } @@ -387,7 +389,7 @@ library JsonBindings { } fs::write(target_path, &result)?; - sh_println!("Bindings written to {}", target_path.display())?; + sh_status!("Bindings written to {}", target_path.display())?; Ok(()) } @@ -398,7 +400,7 @@ struct PreprocessorVisitor { } impl PreprocessorVisitor { - fn new() -> Self { + const fn new() -> Self { Self { updates: Vec::new() } } @@ -426,7 +428,7 @@ impl PreprocessorVisitor { } impl<'ast> Visit<'ast> for PreprocessorVisitor { - type BreakValue = solar_parse::interface::data_structures::Never; + type BreakValue = solar::interface::data_structures::Never; fn visit_item_function( &mut self, @@ -452,7 +454,7 @@ impl<'ast> Visit<'ast> for PreprocessorVisitor { var: &'ast ast::VariableDefinition<'ast>, ) -> ControlFlow { // Remove `immutable` attributes. - if let Some(VarMut::Immutable) = var.mutability { + if var.mutability == Some(VarMut::Immutable) { self.updates.push((var.span, "")); } diff --git a/crates/forge/src/cmd/build.rs b/crates/forge/src/cmd/build.rs index 1dbb334b6227f..5a67fcc0ba244 100644 --- a/crates/forge/src/cmd/build.rs +++ b/crates/forge/src/cmd/build.rs @@ -1,12 +1,19 @@ -use super::{install, watch::WatchArgs}; +use super::watch::WatchArgs; +use crate::Lockfile; use clap::Parser; use eyre::Result; -use forge_lint::{linter::Linter, sol::SolidityLinter}; +use forge_lint::{ + linter::Linter, + sol::{DeniedLintDiagnostics, SolidityLinter}, +}; use foundry_cli::{ - opts::{BuildOpts, solar_pcx_from_build_opts}, - utils::{LoadConfig, cache_local_signatures}, + opts::{BuildOpts, configure_pcx_from_solc, get_solar_sources_from_compile_output}, + utils::{Git, LoadConfig, cache_local_signatures}, +}; +use foundry_common::{ + compile::{ContractSizeLimits, ProjectCompiler}, + shell, }; -use foundry_common::{compile::ProjectCompiler, shell}; use foundry_compilers::{ CompilationError, FileFilter, Project, ProjectCompileOutput, compilers::{Language, multi::MultiCompilerLanguage}, @@ -20,10 +27,14 @@ use foundry_config::{ error::Kind::InvalidType, value::{Dict, Map, Value}, }, - filter::expand_globs, + filter::{expand_globs, is_ignored_path}, }; use serde::Serialize; -use std::path::PathBuf; +use solar::{ + interface::{Session, config::CompileOpts}, + sema::Compiler, +}; +use std::{fmt::Write, path::PathBuf}; foundry_config::merge_impl_figment_convert!(BuildArgs, build); @@ -61,6 +72,14 @@ pub struct BuildArgs { #[serde(skip)] pub ignore_eip_3860: bool, + /// Skip the post-build lint step for this invocation. + /// + /// Equivalent to setting `lint_on_build = false` under `[lint]` in foundry.toml, + /// but only for the current command. + #[arg(long, visible_alias = "skip-lint")] + #[serde(skip)] + pub no_lint: bool, + #[command(flatten)] #[serde(flatten)] pub build: BuildOpts, @@ -71,58 +90,83 @@ pub struct BuildArgs { } impl BuildArgs { - pub fn run(self) -> Result { + pub async fn run(self, locked: bool) -> Result { let mut config = self.load_config()?; - if install::install_missing_dependencies(&mut config) && config.auto_detect_remappings { - // need to re-configure here to also catch additional remappings - config = self.load_config()?; + if locked { + self.check_foundry_lock_consistency(&config)?; } + self.install_missing_dependencies(&mut config)?; + + self.check_soldeer_lock_consistency(&config).await; + let project = config.project()?; // Collect sources to compile if build subdirectories specified. let mut files = vec![]; + let mut selected_paths = vec![]; if let Some(paths) = &self.paths { for path in paths { let joined = project.root().join(path); let path = if joined.exists() { &joined } else { path }; + selected_paths.push(path.clone()); files.extend(source_files_iter(path, MultiCompilerLanguage::FILE_EXTENSIONS)); } - if files.is_empty() { - eyre::bail!("No source files found in specified build paths.") + if files.is_empty() && config.external_compilers.is_empty() { + eyre::bail!("No source files found in specified build paths."); } } let format_json = shell::is_json(); - let compiler = ProjectCompiler::new() + + let mut output = ProjectCompiler::new() + .external_compilers(&config) .files(files) + .selected_paths(selected_paths) .dynamic_test_linking(config.dynamic_test_linking) + .print_compiler_settings(shell::verbosity() >= 2) .print_names(self.names) .print_sizes(self.sizes) .ignore_eip_3860(self.ignore_eip_3860) - .bail(!format_json); - - let output = compiler.compile(&project)?; + .size_limits(contract_size_limits(&config)) + .bail(!format_json) + .compile(&project)?; // Cache project selectors. cache_local_signatures(&output)?; - if format_json && !self.names && !self.sizes { + if format_json && (!self.names && !self.sizes || output.has_compiler_errors()) { sh_println!("{}", serde_json::to_string_pretty(&output.output())?)?; } + if format_json && output.has_compiler_errors() { + std::process::exit(1); + } - // Only run the `SolidityLinter` if there are no compilation errors - if output.output().errors.iter().all(|e| !e.is_error()) { - self.lint(&project, &config)?; + // Only run the `SolidityLinter` if lint on build and no compilation errors. + if !self.no_lint + && config.lint.lint_on_build + && !output.output().errors.iter().any(|e| e.is_error()) + && let Err(err) = self.lint(&project, &config, self.paths.as_deref(), &mut output) + { + if err.downcast_ref::().is_none() { + emit_lint_failure_notice(); + } + return Err(err.wrap_err("post-build lint step failed")); } Ok(output) } - fn lint(&self, project: &Project, config: &Config) -> Result<()> { + fn lint( + &self, + project: &Project, + config: &Config, + files: Option<&[PathBuf]>, + output: &mut ProjectCompileOutput, + ) -> Result<()> { let format_json = shell::is_json(); - if project.compiler.solc.is_some() && config.lint.lint_on_build && !shell::is_quiet() { + if project.compiler.solc.is_some() && !shell::is_quiet() { let linter = SolidityLinter::new(config.project_paths()) .with_json_emitter(format_json) .with_description(!format_json) @@ -139,10 +183,11 @@ impl BuildArgs { .lint .exclude_lints .iter() - .filter_map(|s| forge_lint::sol::SolLint::try_from(s.as_str()).ok()) + .filter_map(|s| s.parse::().ok()) .collect(), ) - }); + }) + .with_lint_specific(&config.lint.lint_specific); // Expand ignore globs and canonicalize from the get go let ignored = expand_globs(&config.root, config.lint.ignore.iter())? @@ -156,30 +201,38 @@ impl BuildArgs { .project_paths::() .input_files_iter() .filter(|p| { - skip.is_match(p) - && !(ignored.contains(p) || ignored.contains(&curr_dir.join(p))) + // Lint only specified build files, if any. + if let Some(files) = files { + return files.iter().any(|file| &curr_dir.join(file) == p); + } + skip.is_match(p) && !is_ignored_path(p, &ignored, &curr_dir) }) .collect::>(); - if !input_files.is_empty() { - let sess = linter.init(); - - let pcx = solar_pcx_from_build_opts( - &sess, - &self.build, - Some(project), - Some(&input_files), - )?; - linter.early_lint(&input_files, pcx); - - let pcx = solar_pcx_from_build_opts( - &sess, - &self.build, - Some(project), - Some(&input_files), - )?; - linter.late_lint(&input_files, pcx); + let solar_sources = + get_solar_sources_from_compile_output(config, output, Some(&input_files), None)?; + if solar_sources.input.sources.is_empty() { + if !input_files.is_empty() { + sh_warn!("unable to lint. Solar only supports Solidity versions >=0.8.0")?; + } + return Ok(()); } + + // NOTE(rusowsky): Once solar can drop unsupported versions, rather than creating a new + // compiler, we should reuse the parser from the project output. + let opts = CompileOpts::default(); + let mut compiler = + Compiler::new(Session::builder().opts(opts).with_stderr_emitter().build()); + + // Load the solar-compatible sources to the pcx before linting + compiler.enter_mut(|compiler| { + let mut pcx = compiler.parse(); + configure_pcx_from_solc(&mut pcx, &config.project_paths(), &solar_sources, true); + pcx.set_resolve_imports(true); + pcx.parse(); + }); + + linter.lint(&input_files, config.deny, &mut compiler)?; } Ok(()) @@ -195,7 +248,7 @@ impl BuildArgs { } /// Returns whether `BuildArgs` was configured with `--watch` - pub fn is_watch(&self) -> bool { + pub const fn is_watch(&self) -> bool { self.watch.watch.is_some() } @@ -209,6 +262,89 @@ impl BuildArgs { Ok([config.src, config.test, config.script, foundry_toml]) }) } + + /// Check soldeer.lock file consistency using soldeer_core APIs + async fn check_soldeer_lock_consistency(&self, config: &Config) { + let soldeer_lock_path = config.root.join("soldeer.lock"); + if !soldeer_lock_path.exists() { + return; + } + + // Note: read_lockfile returns Ok with empty entries for malformed files + let Ok(lockfile) = soldeer_core::lock::read_lockfile(&soldeer_lock_path) else { + return; + }; + + let deps_dir = config.root.join("dependencies"); + for entry in &lockfile.entries { + let dep_name = entry.name(); + + // Use soldeer_core's integrity check + match soldeer_core::install::check_dependency_integrity(entry, &deps_dir).await { + Ok(status) => { + use soldeer_core::install::DependencyStatus; + // Check if status indicates a problem + if matches!( + status, + DependencyStatus::Missing | DependencyStatus::FailedIntegrity + ) { + sh_warn!("Dependency '{}' integrity check failed: {:?}", dep_name, status) + .ok(); + } + } + Err(e) => { + sh_warn!("Dependency '{}' integrity check error: {}", dep_name, e).ok(); + } + } + } + } + + /// Checks foundry.lock file consistency with Git submodules. + fn check_foundry_lock_consistency(&self, config: &Config) -> Result<()> { + let git = Git::new(&config.root); + let mut lockfile = Lockfile::new(&config.root).with_git(&git); + let mismatches = lockfile.check()?; + if mismatches.is_empty() { + return Ok(()); + } + + let mut message = String::from("foundry.lock does not match installed dependencies:"); + for mismatch in mismatches { + write!(message, "\n {mismatch}")?; + } + Err(eyre::eyre!(message)) + } +} + +fn contract_size_limits(config: &Config) -> ContractSizeLimits { + config + .code_size_limit + .map(ContractSizeLimits::with_runtime_limit) + .or_else(|| { + config + .networks + .contract_size_limits() + .map(|limits| ContractSizeLimits::new(limits.runtime, limits.initcode)) + }) + .unwrap_or_else(|| ContractSizeLimits::for_spec_id(config.evm_spec_id())) +} +/// Notice shown on lint-on-build failure; printed separately so it survives single-line +/// cause-chain rendering. +const LINT_FAILURE_NOTICE: &str = "\ +note: internal lint engine failure (compilation itself succeeded). +note: please file a bug report at + https://github.com/foundry-rs/foundry/issues/new?template=BUG-FORM.yml + and attach the full output above. +help: rerun with `--no-lint` to skip linting for this build, or consider temporarily + disabling forge lint on build: + https://getfoundry.sh/forge/linting#disable-linting-on-build +"; + +fn emit_lint_failure_notice() { + if shell::is_json() { + return; + } + let _ = sh_eprintln!("\n{LINT_FAILURE_NOTICE}"); } // Make this args a `figment::Provider` so that it can be merged into the `Config` diff --git a/crates/forge/src/cmd/cache.rs b/crates/forge/src/cmd/cache.rs index 417a5d5ec6754..9c78f2c3c6d90 100644 --- a/crates/forge/src/cmd/cache.rs +++ b/crates/forge/src/cmd/cache.rs @@ -4,9 +4,9 @@ use clap::{ builder::{PossibleValuesParser, TypedValueParser}, }; use eyre::Result; +use foundry_common::sh_warn; use foundry_config::{Chain, Config, NamedChain, cache}; use std::{ffi::OsStr, str::FromStr}; -use strum::VariantNames; /// CLI arguments for `forge cache`. #[derive(Debug, Parser)] @@ -57,16 +57,24 @@ impl CleanArgs { pub fn run(self) -> Result<()> { let Self { chains, blocks, etherscan } = self; + eyre::ensure!( + blocks.is_empty() || !chains.iter().any(|chain| matches!(chain, ChainOrAll::All)), + "--blocks requires explicit chains and cannot be used with 'all'" + ); + for chain_or_all in chains { match chain_or_all { ChainOrAll::NamedChain(chain) => { - clean_chain_cache(chain, blocks.to_vec(), etherscan)? + clean_chain_cache(chain, blocks.clone(), etherscan)? } ChainOrAll::All => { - if etherscan { - Config::clean_foundry_etherscan_cache()?; + let warnings = if etherscan { + Config::clean_foundry_etherscan_cache()? } else { Config::clean_foundry_cache()? + }; + for warning in warnings { + let _ = sh_warn!("{warning}"); } } } @@ -101,7 +109,7 @@ impl LsArgs { ChainOrAll::All => cache = Config::list_foundry_cache()?, } } - sh_print!("{cache}")?; + sh_eprint!("{cache}")?; Ok(()) } } @@ -128,17 +136,24 @@ impl FromStr for ChainOrAll { fn clean_chain_cache(chain: impl Into, blocks: Vec, etherscan: bool) -> Result<()> { let chain = chain.into(); + let mut warnings = Vec::new(); if blocks.is_empty() { - Config::clean_foundry_etherscan_chain_cache(chain)?; + warnings.extend(Config::clean_foundry_etherscan_chain_cache(chain)?); if etherscan { + for warning in warnings { + let _ = sh_warn!("{warning}"); + } return Ok(()); } - Config::clean_foundry_chain_cache(chain)?; + warnings.extend(Config::clean_foundry_chain_cache(chain)?); } else { for block in blocks { - Config::clean_foundry_block_cache(chain, block)?; + warnings.extend(Config::clean_foundry_block_cache(chain, block)?); } } + for warning in warnings { + let _ = sh_warn!("{warning}"); + } Ok(()) } @@ -150,7 +165,8 @@ pub struct ChainOrAllValueParser { impl Default for ChainOrAllValueParser { fn default() -> Self { - Self { inner: possible_chains() } + let inner = Some(&"all").into_iter().chain(NamedChain::VARIANT_NAMES).into(); + Self { inner } } } @@ -172,10 +188,6 @@ impl TypedValueParser for ChainOrAllValueParser { } } -fn possible_chains() -> PossibleValuesParser { - Some(&"all").into_iter().chain(NamedChain::VARIANTS).into() -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/forge/src/cmd/clone.rs b/crates/forge/src/cmd/clone.rs index 2cbf040f371a1..3978436786e58 100644 --- a/crates/forge/src/cmd/clone.rs +++ b/crates/forge/src/cmd/clone.rs @@ -1,32 +1,41 @@ use super::{init::InitArgs, install::DependencyInstallOpts}; use alloy_primitives::{Address, Bytes, ChainId, TxHash}; -use clap::{Parser, ValueHint}; +use clap::{Parser, ValueEnum, ValueHint}; use eyre::Result; +use forge_verify::sourcify::SOURCIFY_URL; use foundry_block_explorers::{ Client, - contract::{ContractCreationData, ContractMetadata, Metadata}, + contract::{ + ContractCreationData, ContractMetadata, Metadata, SourceCodeEntry, SourceCodeMetadata, + }, errors::EtherscanError, }; use foundry_cli::{ opts::EtherscanOpts, utils::{Git, LoadConfig}, }; -use foundry_common::{compile::ProjectCompiler, fs}; +use foundry_common::{compile::ProjectCompiler, fs, fs::canonicalize_path}; use foundry_compilers::{ ProjectCompileOutput, ProjectPathsConfig, artifacts::{ ConfigurableContractArtifact, Settings, StorageLayout, - output_selection::ContractOutputSelection, + output_selection::{ContractOutputSelection, OutputSelection}, remappings::{RelativeRemapping, Remapping}, }, compilers::solc::Solc, }; use foundry_config::{Chain, Config}; +use path_slash::PathExt; +use reqwest::StatusCode; +use serde::Deserialize; use std::{ + collections::{BTreeMap, HashMap}, fs::read_dir, - path::{Path, PathBuf}, + path::{Component, Path, PathBuf}, time::Duration, }; +use tracing::trace; +use url::Url; /// CloneMetadata stores the metadata that are not included by `foundry.toml` but necessary for a /// cloned contract. The metadata can be serialized to a metadata file in the cloned project root. @@ -52,14 +61,25 @@ pub struct CloneMetadata { pub storage_layout: StorageLayout, } +/// Source explorer type for `forge clone`. +#[derive(Clone, Copy, Debug, ValueEnum, Default)] +pub enum SourceExplorer { + /// Use Etherscan API (default). + #[default] + Etherscan, + /// Use Sourcify API. + Sourcify, +} + /// CLI arguments for `forge clone`. /// -/// `forge clone` clones an on-chain contract from block explorers (e.g., Etherscan) in the -/// following steps: +/// `forge clone` clones an on-chain contract from block explorers (e.g., Etherscan, Sourcify) in +/// the following steps: /// 1. Fetch the contract source code from the block explorer. /// 2. Initialize a empty foundry project at the `root` directory specified in `CloneArgs`. /// 3. Dump the contract sources to the source directory. -/// 4. Update the `foundry.toml` configuration file with the compiler settings from Etherscan. +/// 4. Update the `foundry.toml` configuration file with the compiler settings from the block +/// explorer. /// 5. Try compile the cloned contract, so that we can get the original storage layout. This /// original storage layout is preserved in the `CloneMetadata` so that if the user later /// modifies the contract, it is possible to quickly check the storage layout compatibility with @@ -78,7 +98,7 @@ pub struct CloneArgs { #[arg(long)] pub no_remappings_txt: bool, - /// Keep the original directory structure collected from Etherscan. + /// Keep the original directory structure collected from the block explorer. /// /// If this flag is set, the directory structure of the cloned project will be kept as is. /// By default, the directory structure is re-orgnized to increase the readability, but may @@ -86,56 +106,123 @@ pub struct CloneArgs { #[arg(long)] pub keep_directory_structure: bool, + /// Source explorer to use for fetching contract data. + /// + /// Can be either "etherscan" (default) or "sourcify". + #[arg(long, default_value = "etherscan", value_name = "EXPLORER")] + pub source: SourceExplorer, + + /// Custom Sourcify API URL. + /// + /// Implies `--source sourcify`. + #[arg(long, value_name = "URL")] + pub sourcify_url: Option, + + /// Clone the implementation contract if the address is a proxy. + #[arg(long)] + pub implementation: bool, + #[command(flatten)] pub etherscan: EtherscanOpts, + /// Do not create a commit after cloning. + /// + /// This is a noop flag kept for backwards compatibility, as `forge clone` no longer commits + /// by default. Use `--commit` to opt into creating a commit. + #[arg(long, hide = true)] + pub no_commit: bool, + #[command(flatten)] pub install: DependencyInstallOpts, } impl CloneArgs { pub async fn run(self) -> Result<()> { - let Self { address, root, install, etherscan, no_remappings_txt, keep_directory_structure } = - self; + let Self { + address, + root, + install, + etherscan, + no_remappings_txt, + keep_directory_structure, + source, + sourcify_url, + implementation, + no_commit: _, + } = self; // step 0. get the chain and api key from the config let config = etherscan.load_config()?; let chain = config.chain.unwrap_or_default(); - let etherscan_api_version = config.get_etherscan_api_version(Some(chain)); - let etherscan_api_key = config.get_etherscan_api_key(Some(chain)).unwrap_or_default(); - let client = - Client::new_with_api_version(chain, etherscan_api_key.clone(), etherscan_api_version)?; - // step 1. get the metadata from client - sh_println!("Downloading the source code of {address} from Etherscan...")?; + // If sourcify_url is specified, use Sourcify as the source + let source = if sourcify_url.is_some() { SourceExplorer::Sourcify } else { source }; + eyre::ensure!( + !implementation || matches!(source, SourceExplorer::Etherscan), + "--implementation is only supported with Etherscan" + ); - let meta = Self::collect_metadata_from_client(address, &client).await?; + // step 1. get the metadata from client based on source type + let (address, meta, explorer_name, sourcify_client) = match source { + SourceExplorer::Etherscan => { + let client = config + .get_etherscan_config_with_chain(Some(chain))? + .ok_or_else(|| { + eyre::eyre!("No Etherscan API key configured for chain {chain}") + })? + .into_client_with_no_proxy(config.eth_rpc_no_proxy)?; + sh_status!("Downloading the source code of {address} from Etherscan...")?; + let (address, meta) = + Self::collect_metadata_from_client(address, &client, implementation).await?; + (address, meta, "Etherscan", None) + } + SourceExplorer::Sourcify => { + let client = SourcifyClient::with_url(chain, sourcify_url.as_deref()); + sh_status!("Downloading the source code of {address} from Sourcify...")?; + let (address, meta) = + Self::collect_metadata_from_client(address, &client, false).await?; + (address, meta, "Sourcify", Some(client)) + } + }; // step 2. initialize an empty project - Self::init_an_empty_project(&root, install)?; + Self::init_an_empty_project(&root, install).await?; // canonicalize the root path // note that at this point, the root directory must have been created - let root = dunce::canonicalize(&root)?; + let root = canonicalize_path(&root)?; // step 3. parse the metadata Self::parse_metadata(&meta, chain, &root, no_remappings_txt, keep_directory_structure) .await?; // step 4. collect the compilation metadata - // if the etherscan api key is not set, we need to wait for 3 seconds between calls - sh_println!("Collecting the creation information of {address} from Etherscan...")?; - - if etherscan_api_key.is_empty() { - sh_warn!("Waiting for 5 seconds to avoid rate limit...")?; - tokio::time::sleep(Duration::from_secs(5)).await; + sh_status!("Collecting the creation information of {address} from {explorer_name}...")?; + + match source { + SourceExplorer::Etherscan => { + let etherscan_config = + config.get_etherscan_config_with_chain(Some(chain))?.ok_or_else(|| { + eyre::eyre!("No Etherscan API key configured for chain {chain}") + })?; + if etherscan_config.key.is_empty() { + sh_warn!("Waiting for 5 seconds to avoid rate limit...")?; + tokio::time::sleep(Duration::from_secs(5)).await; + } + let client = etherscan_config.into_client_with_no_proxy(config.eth_rpc_no_proxy)?; + Self::collect_compilation_metadata(&meta, chain, address, &root, &client).await?; + } + SourceExplorer::Sourcify => { + // Reuse the client from step 1 to benefit from cached creation data + let client = sourcify_client.expect("Sourcify client should exist"); + Self::collect_compilation_metadata(&meta, chain, address, &root, &client).await?; + } } - Self::collect_compilation_metadata(&meta, chain, address, &root, &client).await?; // step 5. git add and commit the changes if needed if install.commit { let git = Git::new(&root); git.add(Some("--all"))?; - let msg = format!("chore: forge clone {address}"); + let msg = format!("chore: forge clone {address} from {explorer_name}"); git.commit(&msg)?; } @@ -146,15 +233,33 @@ impl CloneArgs { /// /// * `address` - the address of the contract to be cloned. /// * `client` - the client of the block explorer. - pub(crate) async fn collect_metadata_from_client( + pub(crate) async fn collect_metadata_from_client( address: Address, client: &C, - ) -> Result { + implementation: bool, + ) -> Result<(Address, Metadata)> { let mut meta = client.contract_source_code(address).await?; eyre::ensure!(meta.items.len() == 1, "contract not found or ill-formed"); let meta = meta.items.remove(0); + + if !implementation || meta.proxy == 0 { + eyre::ensure!(!meta.is_vyper(), "Vyper contracts are not supported"); + return Ok((address, meta)); + } + + let implementation_address = meta + .implementation + .ok_or_else(|| eyre::eyre!("proxy at {address} has no implementation address"))?; + sh_status!( + "Contract at {address} is a proxy, cloning implementation at \ + {implementation_address}..." + )?; + + let mut meta = client.contract_source_code(implementation_address).await?; + eyre::ensure!(meta.items.len() == 1, "contract not found or ill-formed"); + let meta = meta.items.remove(0); eyre::ensure!(!meta.is_vyper(), "Vyper contracts are not supported"); - Ok(meta) + Ok((implementation_address, meta)) } /// Initialize an empty project at the root directory. @@ -162,17 +267,14 @@ impl CloneArgs { /// * `root` - the root directory of the project. /// * `enable_git` - whether to enable git for the project. /// * `quiet` - whether to print messages. - pub(crate) fn init_an_empty_project(root: &Path, install: DependencyInstallOpts) -> Result<()> { - // let's try to init the project with default init args - let init_args = InitArgs { root: root.to_path_buf(), install, ..Default::default() }; - init_args.run().map_err(|e| eyre::eyre!("Project init error: {:?}", e))?; - - // remove the unnecessary example contracts - // XXX (ZZ): this is a temporary solution until we have a proper way to remove contracts, - // e.g., add a field in the InitArgs to control the example contract generation - fs::remove_file(root.join("src/Counter.sol"))?; - fs::remove_file(root.join("test/Counter.t.sol"))?; - fs::remove_file(root.join("script/Counter.s.sol"))?; + pub(crate) async fn init_an_empty_project( + root: &Path, + install: DependencyInstallOpts, + ) -> Result<()> { + // Initialize the project with empty set to true to avoid creating example contracts + let init_args = + InitArgs { root: root.to_path_buf(), install, empty: true, ..Default::default() }; + init_args.run().await.map_err(|e| eyre::eyre!("Project init error: {:?}", e))?; Ok(()) } @@ -180,12 +282,12 @@ impl CloneArgs { /// Collect the compilation metadata of the cloned contract. /// This function compiles the cloned contract and collects the compilation metadata. /// - /// * `meta` - the metadata of the contract (from Etherscan). + /// * `meta` - the metadata of the contract (from block explorer). /// * `chain` - the chain where the contract to be cloned locates. /// * `address` - the address of the contract to be cloned. /// * `root` - the root directory of the cloned project. /// * `client` - the client of the block explorer. - pub(crate) async fn collect_compilation_metadata( + pub(crate) async fn collect_compilation_metadata( meta: &Metadata, chain: Chain, address: Address, @@ -197,7 +299,7 @@ impl CloneArgs { let (main_file, main_artifact) = find_main_contract(&compile_output, &meta.contract_name)?; let main_file = main_file.strip_prefix(root)?.to_path_buf(); let storage_layout = - main_artifact.storage_layout.to_owned().expect("storage layout not found"); + main_artifact.storage_layout.clone().expect("storage layout not found"); // dump the metadata to the root directory let creation_tx = client.contract_creation_data(address).await?; @@ -237,6 +339,7 @@ impl CloneArgs { ) -> Result<()> { // dump sources and update the remapping in configuration let remappings = dump_sources(meta, root, keep_directory_structure)?; + ensure_source_entrypoint(root)?; Config::update_at(root, |config, doc| { let profile = config.profile.as_str().as_str(); @@ -427,7 +530,13 @@ fn dump_sources(meta: &Metadata, root: &PathBuf, no_reorg: bool) -> Result Result Result Result Result Result<()> { + let src = root.join("src"); + let has_src_sources = fs::files_with_ext(&src, "sol").next().is_some(); + if has_src_sources { + return Ok(()); + } + let forge_std = root.join("lib/forge-std"); + let mut sources = fs::files_with_ext(root, "sol") + .filter(|path| !path.starts_with(&src) && !path.starts_with(&forge_std)) + .collect::>(); + if sources.is_empty() { + eyre::ensure!(has_src_sources, "no Solidity sources found in cloned contract"); + return Ok(()); + } + sources.sort_unstable(); + + let imports = sources + .into_iter() + .enumerate() + .map(|(index, path)| source_import(root, index, &path)) + .collect::>>()? + .join("\n"); + let mut entrypoint = src.join("Clone.sol"); + let mut index = 0; + while entrypoint.exists() { + index += 1; + entrypoint = src.join(format!("Clone{index}.sol")); + } + fs::write(entrypoint, format!("{imports}\n"))?; + Ok(()) +} + +/// Return a namespaced import for a source path that is safe to embed in Solidity. +fn source_import(root: &Path, index: usize, path: &Path) -> Result { + let path = path.strip_prefix(root)?; + eyre::ensure!(path.to_str().is_some(), "source path is not valid UTF-8: {path:?}"); + let path = path.to_slash_lossy(); + eyre::ensure!( + !path.chars().any(|c| c == '"' || c == '\\' || c.is_control()), + "source path contains characters unsupported in a Solidity import: {path:?}" + ); + Ok(format!("import * as CloneSource{index} from \"../{path}\";")) +} + /// Compile the project in the root directory, and return the compilation result. pub fn compile_project(root: &Path) -> Result { let mut config = Config::load_with_root(root)?.sanitized(); config.extra_output.push(ContractOutputSelection::StorageLayout); - let project = config.project()?; + let mut project = config.project()?; + project.no_artifacts = true; + project.update_output_selection(|selection| { + *selection = OutputSelection::common_output_selection(["storageLayout".to_string()]); + }); let compiler = ProjectCompiler::new(); compiler.compile(&project) } @@ -593,10 +752,10 @@ pub fn find_main_contract<'a>( rv.ok_or_else(|| eyre::eyre!("contract not found")) } -/// EtherscanClient is a trait that defines the methods to interact with Etherscan. +/// ExplorerClient is a trait that defines the methods to interact with block explorers. /// It is defined as a wrapper of the `foundry_block_explorers::Client` to allow mocking. #[cfg_attr(test, mockall::automock)] -pub(crate) trait EtherscanClient { +pub(crate) trait ExplorerClient { async fn contract_source_code( &self, address: Address, @@ -607,8 +766,7 @@ pub(crate) trait EtherscanClient { ) -> std::result::Result; } -impl EtherscanClient for Client { - #[inline] +impl ExplorerClient for Client { async fn contract_source_code( &self, address: Address, @@ -616,7 +774,6 @@ impl EtherscanClient for Client { self.contract_source_code(address).await } - #[inline] async fn contract_creation_data( &self, address: Address, @@ -625,18 +782,334 @@ impl EtherscanClient for Client { } } +/// SourcifyClient is a client for interacting with Sourcify API. +pub(crate) struct SourcifyClient { + client: reqwest::Client, + chain: Chain, + base_url: String, + /// Whether the base_url already contains the full path (v2/contract/chain) + is_full_path: bool, + /// Cached creation data from the first API call + cached_creation_data: std::sync::Arc>>, +} + +impl SourcifyClient { + pub fn with_url(chain: Chain, verifier_url: Option<&str>) -> Self { + let (base_url, is_full_path) = match verifier_url { + Some(url) => ( + Url::parse(url).unwrap_or_else(|_| Url::parse(SOURCIFY_URL).unwrap()), + true, // Custom URL contains full path + ), + None => (Url::parse(SOURCIFY_URL).unwrap(), false), + }; + Self { + client: reqwest::Client::new(), + chain, + base_url: base_url.to_string().trim_end_matches('/').to_string(), + is_full_path, + cached_creation_data: std::sync::Arc::new(std::sync::Mutex::new(None)), + } + } + + fn get_contract_url(&self, address: Address, fields: &str) -> String { + if self.is_full_path { + // Custom URL already contains v2/contract/chain, just append address and fields + format!("{}/{}?fields={}", self.base_url, address, fields) + } else { + // Default URL, need to build full path + format!( + "{}/v2/contract/{}/{}?fields={}", + self.base_url, + self.chain.id(), + address, + fields + ) + } + } +} + +/// Sourcify API response for contract files. +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +#[allow(dead_code, clippy::large_enum_variant)] +enum SourcifyContractResponse { + Success(SourcifyContractData), + Error(SourcifyErrorResponse), +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SourcifyContractData { + #[serde(default)] + sources: Option>, + #[serde(default)] + abi: Option, + #[serde(default)] + compilation: Option, + #[serde(default)] + #[allow(dead_code)] + creation_code: Option, + #[serde(default)] + #[allow(dead_code)] + deployed_bytecode: Option, + #[serde(default)] + #[allow(dead_code)] + runtime_bytecode: Option, + #[serde(default)] + deployment: Option, + // Additional fields that may be present in the response + #[serde(default)] + #[allow(dead_code)] + match_id: Option, + #[serde(default)] + #[allow(dead_code)] + creation_match: Option, + #[serde(default)] + #[allow(dead_code)] + runtime_match: Option, + #[serde(default)] + #[allow(dead_code)] + verified_at: Option, + #[serde(default)] + #[allow(dead_code)] + r#match: Option, + #[serde(default)] + #[allow(dead_code)] + chain_id: Option, + #[serde(default)] + #[allow(dead_code)] + address: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SourcifySourceFile { + #[serde(default)] + content: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SourcifyCompilation { + #[serde(default)] + compiler_version: String, + #[serde(default)] + name: String, + #[serde(default)] + compiler_settings: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SourcifyDeployment { + #[serde(default)] + transaction_hash: Option, + #[serde(default)] + deployer: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +struct SourcifyErrorResponse { + #[serde(default)] + custom_code: String, + #[serde(default)] + message: String, + #[serde(default)] + #[allow(dead_code)] + error_id: String, + // Error responses should not have sources field + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + #[allow(dead_code)] + sources: Option<()>, +} + +impl ExplorerClient for SourcifyClient { + async fn contract_source_code( + &self, + address: Address, + ) -> std::result::Result { + // Request all fields including creation data to cache them + let url = self.get_contract_url(address, "sources,abi,compilation,deployment"); + let response = self + .client + .get(&url) + .send() + .await + .map_err(|e| EtherscanError::Unknown(e.to_string()))?; + + let status = response.status(); + trace!("Sourcify API response: status={:?}, url={}", status, url); + + match status { + StatusCode::NOT_FOUND => return Err(EtherscanError::ContractCodeNotVerified(address)), + StatusCode::TOO_MANY_REQUESTS => return Err(EtherscanError::RateLimitExceeded), + _ => {} + } + + // Read response body once + let response_text = + response.text().await.map_err(|e| EtherscanError::Unknown(e.to_string()))?; + trace!("Sourcify API response body: {}", response_text); + + if !status.is_success() { + return Err(EtherscanError::Unknown(format!( + "Sourcify API error (status {status}): {response_text}" + ))); + } + + // Use the untagged enum to properly handle both success and error responses + let response: SourcifyContractResponse = + serde_json::from_str(&response_text).map_err(|e| { + // Truncate response for error message to avoid huge output + let truncated = if response_text.len() > 500 { + format!("{}... (truncated)", &response_text[..500]) + } else { + response_text.clone() + }; + EtherscanError::Unknown(format!( + "Failed to parse Sourcify response: {e}. Response: {truncated}" + )) + })?; + + let data = match response { + SourcifyContractResponse::Success(data) => data, + SourcifyContractResponse::Error(error) => { + let error_msg = if error.custom_code.is_empty() && error.message.is_empty() { + "Unknown Sourcify API error".to_string() + } else { + format!("Sourcify API error: {} - {}", error.custom_code, error.message) + }; + return Err(EtherscanError::Unknown(error_msg)); + } + }; + + let sources_map = data.sources.ok_or_else(|| { + EtherscanError::Unknown("Sourcify response missing sources field".to_string()) + })?; + + // Convert sources map to SourceCodeMetadata::Sources format + let sources: HashMap = sources_map + .into_iter() + .map(|(path, source_file)| (path, SourceCodeEntry { content: source_file.content })) + .collect(); + + let source_code = SourceCodeMetadata::Sources(sources); + + let contract_name = data + .compilation + .as_ref() + .map(|c| c.name.clone()) + .unwrap_or_else(|| "Contract".to_string()); + + let compiler_version = + data.compilation.as_ref().map(|c| c.compiler_version.clone()).unwrap_or_default(); + + let abi = data.abi.map(|a| a.to_string()).unwrap_or_default(); + + // Cache creation data for later use in contract_creation_data + let tx_hash = data + .deployment + .as_ref() + .and_then(|d| d.transaction_hash.as_ref()) + .and_then(|h| h.parse().ok()) + .unwrap_or(TxHash::ZERO); + let creator = data + .deployment + .as_ref() + .and_then(|d| d.deployer.as_ref()) + .and_then(|a| a.parse().ok()) + .unwrap_or(Address::ZERO); + let creation_data = ContractCreationData { + contract_address: address, + contract_creator: creator, + transaction_hash: tx_hash, + }; + if let Ok(mut cache) = self.cached_creation_data.lock() { + *cache = Some(creation_data); + } + + // Extract compiler_settings from compilation if available + let constructor_arguments = Bytes::default(); + let optimization_used = data + .compilation + .as_ref() + .and_then(|c| c.compiler_settings.as_ref()) + .and_then(|s| s.get("optimizer")) + .and_then(|o| o.get("enabled")) + .and_then(|e| e.as_bool()) + .map(|b| if b { 1 } else { 0 }) + .unwrap_or(0); + + let runs = data + .compilation + .as_ref() + .and_then(|c| c.compiler_settings.as_ref()) + .and_then(|s| s.get("optimizer")) + .and_then(|o| o.get("runs")) + .and_then(|r| r.as_u64()) + .unwrap_or(0); + + Ok(ContractMetadata { + items: vec![Metadata { + source_code, + abi, + contract_name, + compiler_version, + optimization_used, + runs, + constructor_arguments, + evm_version: String::new(), + library: String::new(), + license_type: String::new(), + proxy: 0, + implementation: None, + swarm_source: String::new(), + }], + }) + } + + async fn contract_creation_data( + &self, + address: Address, + ) -> std::result::Result { + // Check cache first + if let Ok(cache) = self.cached_creation_data.lock() + && let Some(ref cached_data) = *cache + && cached_data.contract_address == address + { + return Ok(*cached_data); + } + + // If cache is empty or address doesn't match, use fallback values + // This should rarely happen since we cache in contract_source_code + trace!("Creation data not in cache for address {address}, using fallback values"); + let creation_data = ContractCreationData { + contract_address: address, + contract_creator: Address::ZERO, + transaction_hash: TxHash::ZERO, + }; + if let Ok(mut cache) = self.cached_creation_data.lock() { + *cache = Some(creation_data); + } + + Ok(creation_data) + } +} + #[cfg(test)] mod tests { use super::*; - use alloy_primitives::hex; + use alloy_primitives::{address, hex}; use foundry_compilers::CompilerContract; use foundry_test_utils::rpc::next_etherscan_api_key; - use std::collections::BTreeMap; #[expect(clippy::disallowed_macros)] fn assert_successful_compilation(root: &PathBuf) -> ProjectCompileOutput { println!("project_root: {root:#?}"); - compile_project(root).expect("compilation failure") + let config = Config::load_with_root(root).unwrap().sanitized(); + ProjectCompiler::new().compile(&config.project().unwrap()).expect("compilation failure") } fn assert_compilation_result( @@ -644,8 +1117,8 @@ mod tests { contract_name: &str, stripped_creation_code: &str, ) { - compiled.compiled_contracts_by_compiler_version().iter().for_each(|(_, contracts)| { - contracts.iter().for_each(|(name, contract)| { + for contracts in compiled.compiled_contracts_by_compiler_version().values() { + for (name, contract) in contracts { if name == contract_name { let compiled_creation_code = contract.bin_ref().expect("creation code not found"); @@ -655,11 +1128,11 @@ mod tests { "inconsistent creation code" ); } - }); - }); + } + } } - fn mock_etherscan(address: Address) -> impl super::EtherscanClient { + fn mock_etherscan(address: Address) -> impl super::ExplorerClient { // load mock data let mut mocked_data = BTreeMap::new(); let data_folder = @@ -686,7 +1159,7 @@ mod tests { let (metadata, creation_data) = mocked_data.get(&address).unwrap(); let metadata = metadata.clone(); let creation_data = *creation_data; - let mut mocked_client = super::MockEtherscanClient::new(); + let mut mocked_client = super::MockExplorerClient::new(); mocked_client .expect_contract_source_code() .times(1) @@ -698,11 +1171,221 @@ mod tests { mocked_client } + fn contract_metadata( + contract_name: &str, + proxy: bool, + implementation: Option
, + ) -> ContractMetadata { + ContractMetadata { + items: vec![Metadata { + source_code: SourceCodeMetadata::SourceCode(format!( + "contract {contract_name} {{}}" + )), + abi: "[]".to_string(), + contract_name: contract_name.to_string(), + compiler_version: "v0.8.10+commit.fc410830".to_string(), + optimization_used: 1, + runs: 100_000, + constructor_arguments: Bytes::new(), + evm_version: "Default".to_string(), + library: String::new(), + license_type: String::new(), + proxy: u64::from(proxy), + implementation, + swarm_source: String::new(), + }], + } + } + + #[test] + fn test_dump_sources_rejects_contract_name_paths() { + for absolute in [false, true] { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("project"); + let victim = temp.path().join("victim"); + std::fs::create_dir_all(root.join("src")).unwrap(); + std::fs::create_dir_all(root.join("lib")).unwrap(); + std::fs::create_dir_all(&victim).unwrap(); + let victim_file = victim.join("outside.sol"); + std::fs::write(&victim_file, "contract Outside {}").unwrap(); + let contract_name = if absolute { + victim.to_string_lossy().into_owned() + } else { + "../../victim".to_string() + }; + let meta = contract_metadata(&contract_name, false, None).items.remove(0); + + let err = dump_sources(&meta, &root, false).unwrap_err(); + + assert_eq!(err.to_string(), format!("invalid contract name: {contract_name:?}")); + assert!(victim_file.exists()); + assert!(!root.join("raw_sources").exists()); + assert!(!root.join("src/outside.sol").exists()); + } + } + + #[test] + fn test_dump_sources_accepts_contract_name() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().to_path_buf(); + std::fs::create_dir(root.join("src")).unwrap(); + std::fs::create_dir(root.join("lib")).unwrap(); + let meta = contract_metadata("Contract_2", false, None).items.remove(0); + + dump_sources(&meta, &root, false).unwrap(); + + assert!(root.join("src/Contract.sol").exists()); + assert!(!root.join("raw_sources").exists()); + } + + #[tokio::test] + async fn test_resolves_sparklend_proxy_implementation() { + let proxy = address!("0xC02aB1A5eaA8d1B114EF786D9bde108cD4364359"); + let implementation = address!("0x6175ddec3b9b38c88157c10a01ed4a3fa8639cc6"); + let proxy_meta = contract_metadata( + "InitializableImmutableAdminUpgradeabilityProxy", + true, + Some(implementation), + ); + let implementation_meta = contract_metadata("AToken", false, None); + let mut client = super::MockExplorerClient::new(); + client.expect_contract_source_code().times(2).returning(move |address| { + if address == proxy { + Ok(proxy_meta.clone()) + } else if address == implementation { + Ok(implementation_meta.clone()) + } else { + unreachable!("unexpected address: {address}") + } + }); + + let (resolved, meta) = + CloneArgs::collect_metadata_from_client(proxy, &client, true).await.unwrap(); + + assert_eq!(resolved, implementation); + assert_eq!(meta.contract_name, "AToken"); + } + + #[tokio::test] + async fn test_resolves_solidity_implementation_from_vyper_proxy() { + let proxy = Address::with_last_byte(1); + let implementation = Address::with_last_byte(2); + let mut proxy_meta = contract_metadata("VyperProxy", true, Some(implementation)); + proxy_meta.items[0].compiler_version = "vyper:0.3.10".to_string(); + let implementation_meta = contract_metadata("Implementation", false, None); + let mut client = super::MockExplorerClient::new(); + client.expect_contract_source_code().times(2).returning(move |address| { + if address == proxy { + Ok(proxy_meta.clone()) + } else if address == implementation { + Ok(implementation_meta.clone()) + } else { + unreachable!("unexpected address: {address}") + } + }); + + let (resolved, meta) = + CloneArgs::collect_metadata_from_client(proxy, &client, true).await.unwrap(); + + assert_eq!(resolved, implementation); + assert_eq!(meta.contract_name, "Implementation"); + } + + #[tokio::test] + async fn test_stops_at_direct_proxy_implementation() { + let first = Address::with_last_byte(1); + let second = Address::with_last_byte(2); + let third = Address::with_last_byte(3); + let first_meta = contract_metadata("FirstProxy", true, Some(second)); + let second_meta = contract_metadata("SecondProxy", true, Some(third)); + let mut client = super::MockExplorerClient::new(); + client.expect_contract_source_code().times(2).returning(move |address| { + if address == first { + Ok(first_meta.clone()) + } else if address == second { + Ok(second_meta.clone()) + } else { + unreachable!("unexpected address: {address}") + } + }); + + let (resolved, meta) = + CloneArgs::collect_metadata_from_client(first, &client, true).await.unwrap(); + + assert_eq!(resolved, second); + assert_eq!(meta.contract_name, "SecondProxy"); + } + + #[test] + fn test_adds_entrypoint_for_library_sources() { + let temp = tempfile::tempdir().unwrap(); + let src = temp.path().join("src"); + let library = temp.path().join("lib/dependency/src"); + let other_library = temp.path().join("lib/other/src"); + let forge_std = temp.path().join("lib/forge-std/src"); + std::fs::create_dir_all(&src).unwrap(); + std::fs::create_dir_all(&library).unwrap(); + std::fs::create_dir_all(&other_library).unwrap(); + std::fs::create_dir_all(&forge_std).unwrap(); + std::fs::write(library.join("AToken.sol"), "contract AToken {}").unwrap(); + std::fs::write(library.join("Helper.sol"), "contract Helper {}").unwrap(); + std::fs::write(other_library.join("Helper.sol"), "contract Helper {}").unwrap(); + std::fs::write(forge_std.join("Test.sol"), "contract Test {}").unwrap(); + std::fs::write( + temp.path().join("foundry.toml"), + "[profile.default]\nsrc = \"src\"\nlibs = [\"lib\"]\n", + ) + .unwrap(); + + ensure_source_entrypoint(temp.path()).unwrap(); + + assert_eq!( + std::fs::read_to_string(src.join("Clone.sol")).unwrap(), + "import * as CloneSource0 from \"../lib/dependency/src/AToken.sol\";\n\ + import * as CloneSource1 from \"../lib/dependency/src/Helper.sol\";\n\ + import * as CloneSource2 from \"../lib/other/src/Helper.sol\";\n" + ); + let output = compile_project(temp.path()).unwrap(); + let (_, artifact) = find_main_contract(&output, "AToken").unwrap(); + assert!(artifact.storage_layout.is_some()); + assert!(artifact.bytecode.is_none()); + assert!(!temp.path().join("out").exists()); + } + + #[test] + fn test_does_not_add_entrypoint_when_src_has_sources() { + let temp = tempfile::tempdir().unwrap(); + let src = temp.path().join("src"); + let library = temp.path().join("lib/dependency/src"); + std::fs::create_dir_all(&src).unwrap(); + std::fs::create_dir_all(&library).unwrap(); + std::fs::write(src.join("Contract.sol"), "contract Contract {}").unwrap(); + std::fs::write(library.join("Dependency.sol"), "contract Dependency {}").unwrap(); + + ensure_source_entrypoint(temp.path()).unwrap(); + + assert!(!src.join("Clone.sol").exists()); + } + + #[test] + fn test_rejects_unsafe_source_import_path() { + let root = Path::new("root"); + let path = root.join("lib/dependency/Unsafe\"Source.sol"); + + let err = source_import(root, 0, &path).unwrap_err(); + + assert_eq!( + err.to_string(), + "source path contains characters unsupported in a Solidity import: \ + \"lib/dependency/Unsafe\\\"Source.sol\"" + ); + } + /// Fetch the metadata and creation data from Etherscan and dump them to the testdata folder. #[tokio::test(flavor = "multi_thread")] #[ignore = "this test is used to dump mock data from Etherscan"] async fn test_dump_mock_data() { - let address: Address = "0x9d27527Ada2CF29fBDAB2973cfa243845a08Bd3F".parse().unwrap(); + let address = address!("0x9d27527Ada2CF29fBDAB2973cfa243845a08Bd3F"); let data_folder = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../testdata/etherscan") .join(address.to_string()); @@ -724,11 +1407,15 @@ mod tests { /// Run the clone command with the specified contract address and assert the compilation. async fn one_test_case(address: Address, check_compilation_result: bool) { - let mut project_root = tempfile::tempdir().unwrap().path().to_path_buf(); + let temp_dir = tempfile::tempdir().unwrap(); + let mut project_root = temp_dir.path().to_path_buf(); let client = mock_etherscan(address); - let meta = CloneArgs::collect_metadata_from_client(address, &client).await.unwrap(); - CloneArgs::init_an_empty_project(&project_root, DependencyInstallOpts::default()).unwrap(); - project_root = dunce::canonicalize(&project_root).unwrap(); + let (_, meta) = + CloneArgs::collect_metadata_from_client(address, &client, false).await.unwrap(); + CloneArgs::init_an_empty_project(&project_root, DependencyInstallOpts::default()) + .await + .unwrap(); + project_root = canonicalize_path(&project_root).unwrap(); CloneArgs::parse_metadata(&meta, Chain::mainnet(), &project_root, false, false) .await .unwrap(); @@ -747,54 +1434,53 @@ mod tests { pick_creation_info(&address.to_string()).expect("creation code not found"); assert_compilation_result(rv, contract_name, stripped_creation_code); } - std::fs::remove_dir_all(project_root).unwrap(); } #[tokio::test(flavor = "multi_thread")] async fn test_clone_single_file_contract() { - let address = "0x35Fb958109b70799a8f9Bc2a8b1Ee4cC62034193".parse().unwrap(); + let address = address!("0x35Fb958109b70799a8f9Bc2a8b1Ee4cC62034193"); one_test_case(address, true).await } #[tokio::test(flavor = "multi_thread")] async fn test_clone_contract_with_optimization_details() { - let address = "0x8B3D32cf2bb4d0D16656f4c0b04Fa546274f1545".parse().unwrap(); + let address = address!("0x8B3D32cf2bb4d0D16656f4c0b04Fa546274f1545"); one_test_case(address, true).await } #[tokio::test(flavor = "multi_thread")] async fn test_clone_contract_with_libraries() { - let address = "0xDb53f47aC61FE54F456A4eb3E09832D08Dd7BEec".parse().unwrap(); + let address = address!("0xDb53f47aC61FE54F456A4eb3E09832D08Dd7BEec"); one_test_case(address, true).await } #[tokio::test(flavor = "multi_thread")] async fn test_clone_contract_with_metadata() { - let address = "0x71356E37e0368Bd10bFDbF41dC052fE5FA24cD05".parse().unwrap(); + let address = address!("0x71356E37e0368Bd10bFDbF41dC052fE5FA24cD05"); one_test_case(address, true).await } #[tokio::test(flavor = "multi_thread")] - async fn test_clone_contract_with_relative_import() { - let address = "0x3a23F943181408EAC424116Af7b7790c94Cb97a5".parse().unwrap(); + async fn flaky_test_clone_contract_with_relative_import() { + let address = address!("0x3a23F943181408EAC424116Af7b7790c94Cb97a5"); one_test_case(address, false).await } #[tokio::test(flavor = "multi_thread")] async fn test_clone_contract_with_original_remappings() { - let address = "0x9ab6b21cdf116f611110b048987e58894786c244".parse().unwrap(); + let address = address!("0x9ab6b21cdf116f611110b048987e58894786c244"); one_test_case(address, false).await } #[tokio::test(flavor = "multi_thread")] async fn test_clone_contract_with_relative_import2() { - let address = "0x044b75f554b886A065b9567891e45c79542d7357".parse().unwrap(); + let address = address!("0x044b75f554b886A065b9567891e45c79542d7357"); one_test_case(address, false).await } #[tokio::test(flavor = "multi_thread")] async fn test_clone_contract_with_nested_src() { - let address = "0x9d27527Ada2CF29fBDAB2973cfa243845a08Bd3F".parse().unwrap(); + let address = address!("0x9d27527Ada2CF29fBDAB2973cfa243845a08Bd3F"); one_test_case(address, false).await } diff --git a/crates/forge/src/cmd/compiler.rs b/crates/forge/src/cmd/compiler.rs index a939d8823308b..9b436a2a5f4b0 100644 --- a/crates/forge/src/cmd/compiler.rs +++ b/crates/forge/src/cmd/compiler.rs @@ -1,7 +1,15 @@ use clap::{Parser, Subcommand, ValueHint}; use eyre::Result; +use foundry_cli::install; use foundry_common::shell; -use foundry_compilers::{Graph, artifacts::EvmVersion}; +use foundry_compilers::{ + Graph, Project, + artifacts::EvmVersion, + compilers::{ + multi::{MultiCompiler, MultiCompilerLanguage}, + solc::{Solc, SolcCompiler}, + }, +}; use foundry_config::Config; use semver::Version; use serde::Serialize; @@ -32,6 +40,9 @@ pub enum CompilerSubcommands { /// Resolved compiler within the project. #[derive(Serialize)] struct ResolvedCompiler { + /// Compiler language. + #[serde(skip)] + language: MultiCompilerLanguage, /// Compiler version. version: Version, /// Max supported EVM version of compiler. @@ -52,14 +63,19 @@ pub struct ResolveArgs { /// Skip files that match the given regex pattern. #[arg(long, short, value_name = "REGEX")] skip: Option, + + /// Print only the executable path for a single resolved compiler. + #[arg(long)] + path: bool, } impl ResolveArgs { pub fn run(self) -> Result<()> { - let Self { root, skip } = self; + let Self { root, skip, path } = self; let root = root.unwrap_or_else(|| PathBuf::from(".")); - let config = Config::load_with_root(&root)?; + let mut config = Config::load_with_root(&root)?; + install::install_missing_dependencies(&mut config, || Config::load_with_root(&root))?; let project = config.project()?; let graph = Graph::resolve(&project.paths)?; @@ -72,8 +88,8 @@ impl ResolveArgs { .iter() .map(|(version, sources, _)| { let paths: Vec = sources - .iter() - .filter_map(|(path_file, _)| { + .keys() + .filter_map(|path_file| { let path_str = path_file .strip_prefix(&project.paths.root) .unwrap_or(path_file) @@ -92,17 +108,11 @@ impl ResolveArgs { }) .collect(); - let evm_version = if shell::verbosity() > 1 { - let evm = EvmVersion::default() - .normalize_version_solc(version) - .unwrap_or_default(); - - Some(evm) - } else { - None - }; + let evm_version = (shell::verbosity() > 1).then(|| { + EvmVersion::default().normalize_version_solc(version).unwrap_or_default() + }); - ResolvedCompiler { version: version.clone(), evm_version, paths } + ResolvedCompiler { language, version: version.clone(), evm_version, paths } }) .filter(|version| !version.paths.is_empty()) .collect(); @@ -115,13 +125,34 @@ impl ResolveArgs { // Clear paths if verbosity is 0, performed only after filtering to avoid being // skipped. if shell::verbosity() == 0 { - versions_with_paths.iter_mut().for_each(|version| version.paths.clear()); + for version in &mut versions_with_paths { + version.paths.clear(); + } } output.insert(language.to_string(), versions_with_paths); } } + if path { + let mut compilers = output.values().flatten(); + let Some(compiler) = compilers.next() else { + eyre::bail!("no compiler resolved"); + }; + eyre::ensure!( + compilers.next().is_none(), + "multiple compilers resolved; use `forge compiler resolve` to inspect them" + ); + + let path = resolved_compiler_path(&project, compiler)?; + if shell::is_json() { + sh_println!("{}", serde_json::to_string(&path)?)?; + } else { + sh_println!("{}", path.display())?; + } + return Ok(()); + } + if shell::is_json() { sh_println!("{}", serde_json::to_string(&output)?)?; return Ok(()); @@ -166,3 +197,32 @@ impl ResolveArgs { Ok(()) } } + +fn resolved_compiler_path( + project: &Project, + compiler: &ResolvedCompiler, +) -> Result { + match compiler.language { + MultiCompilerLanguage::Solc(_) => { + let solc = project + .compiler + .solc + .as_ref() + .ok_or_else(|| eyre::eyre!("Solidity compiler is not available"))?; + match solc { + SolcCompiler::AutoDetect => Solc::find_svm_installed_version(&compiler.version)? + .map(|solc| solc.solc) + .ok_or_else(|| { + eyre::eyre!("Solidity compiler {} is not installed", compiler.version) + }), + SolcCompiler::Specific(solc) => Ok(solc.solc.clone()), + } + } + MultiCompilerLanguage::Vyper(_) => project + .compiler + .vyper + .as_ref() + .map(|vyper| vyper.path.clone()) + .ok_or_else(|| eyre::eyre!("Vyper compiler is not available")), + } +} diff --git a/crates/forge/src/cmd/config.rs b/crates/forge/src/cmd/config.rs index c337e1fd4facc..49716146c456b 100644 --- a/crates/forge/src/cmd/config.rs +++ b/crates/forge/src/cmd/config.rs @@ -1,8 +1,8 @@ use super::build::BuildArgs; use clap::Parser; use eyre::Result; -use foundry_cli::utils::LoadConfig; -use foundry_common::{evm::EvmArgs, shell}; +use foundry_cli::{opts::EvmArgs, utils::LoadConfig}; +use foundry_common::shell; use foundry_config::fix::fix_tomls; foundry_config::impl_figment_convert!(ConfigArgs, build, evm); diff --git a/crates/forge/src/cmd/coverage.rs b/crates/forge/src/cmd/coverage.rs index ce42d73269c41..4178ab3a477cc 100644 --- a/crates/forge/src/cmd/coverage.rs +++ b/crates/forge/src/cmd/coverage.rs @@ -1,31 +1,48 @@ -use super::{install, test::TestArgs, watch::WatchArgs}; -use crate::{ - MultiContractRunnerBuilder, - coverage::{ - BytecodeReporter, ContractId, CoverageReport, CoverageReporter, CoverageSummaryReporter, - DebugReporter, ItemAnchor, LcovReporter, - analysis::{SourceAnalysis, SourceFile, SourceFiles}, - anchors::find_anchors, - }, +use super::{ + test::{ProjectPathsAwareFilter, TestArgs, TestExecutionOptions}, + watch::WatchArgs, +}; +use crate::coverage::{ + BytecodeReporter, ContractId, CoverageAttributionReporter, CoverageReport, CoverageReporter, + CoverageSummaryReporter, DebugReporter, ItemAnchors, LcovReporter, ResolvedHitMap, + ResolvedHitMaps, + analysis::{SourceAnalysis, SourceFiles}, + anchors::{find_anchors, find_execution_anchors}, +}; +use alloy_json_abi::StateMutability; +use alloy_primitives::{ + Address, B256, Bytes, U256, keccak256, + map::{HashMap, HashSet}, +}; +use clap::{Parser, ValueHint}; +use eyre::Result; +use foundry_cli::utils::{FoundryPathExt, LoadConfig, STATIC_FUZZ_SEED}; +use foundry_common::{ + ContractsByArtifactBuilder, TestFilter, compile::ProjectCompiler, errors::convert_solar_errors, + version::SHORT_VERSION, }; -use alloy_primitives::{Address, Bytes, U256, map::HashMap}; -use clap::{Parser, ValueEnum, ValueHint}; -use eyre::{Context, Result}; -use foundry_cli::utils::{LoadConfig, STATIC_FUZZ_SEED}; -use foundry_common::compile::ProjectCompiler; use foundry_compilers::{ - Artifact, ArtifactId, Project, ProjectCompileOutput, ProjectPathsConfig, - artifacts::{ - CompactBytecode, CompactDeployedBytecode, SolcLanguage, Source, sourcemap::SourceMap, + Artifact, ArtifactId, Graph, Project, ProjectCompileOutput, ProjectPathsConfig, + VYPER_EXTENSIONS, + artifacts::{CompactBytecode, CompactDeployedBytecode, Source, sourcemap::SourceMap}, + cache::SOLIDITY_FILES_CACHE_FILENAME, + compilers::{ + Language, + multi::{MultiCompilerLanguage, MultiCompilerParser}, }, - compilers::multi::MultiCompiler, + error::SolcError, + utils::source_files_iter, }; -use foundry_config::Config; -use foundry_evm::opts::EvmOpts; -use foundry_evm_core::ic::IcPcMap; +use foundry_config::{ + Config, CoverageConfig, CoverageReportKind, InlineConfig, parse_lcov_version, +}; +use foundry_evm::{core::ic::IcPcMap, opts::EvmOpts}; +use globset::{Glob, GlobSetBuilder}; use rayon::prelude::*; -use semver::{Version, VersionReq}; +use semver::Version; use std::{ + collections::{BTreeMap, BTreeSet}, + fs, path::{Path, PathBuf}, sync::Arc, }; @@ -34,12 +51,29 @@ use std::{ foundry_config::impl_figment_convert!(CoverageArgs, test); /// CLI arguments for `forge coverage`. +/// +/// Most flags here have a corresponding `[profile..coverage]` config +/// option in `foundry.toml`. CLI flags take precedence over config; the helper +/// `resolve_with` merges them after the config is loaded. #[derive(Parser)] +#[command(after_long_help = r#"Source attribution: + Coverage follows compiler source maps. Inherited modifier code is reported under the + source where the modifier is declared. Dependency sources are excluded by default; + use `--include-libs` to include their coverage. + +Compatibility: + `forge coverage` supports test filters and `--watch`, but not test-only output or + execution modes such as `--json`, `--junit`, `--list`, `--debug`, flame profiles, + symbolic artifact replay, showmap replay, brutalization, or mutation testing. Use + `--report lcov` for interoperable coverage data or `--report attribution` for + Foundry's per-test JSON attribution report."#)] pub struct CoverageArgs { /// The report type to use for coverage. /// - /// This flag can be used multiple times. - #[arg(long, value_enum, default_value = "summary")] + /// This flag can be used multiple times. Falls back to the + /// `[profile..coverage] report` config value when not provided + /// (default: `summary`). + #[arg(long, value_enum)] report: Vec, /// The version of the LCOV "tracefile" format to use. @@ -50,7 +84,14 @@ pub struct CoverageArgs { /// - `1.x`: The original v1 format. /// - `2.0`: Adds support for "line end" numbers for functions. /// - `2.2`: Changes the format of functions. - #[arg(long, default_value = "1", value_parser = parse_lcov_version)] + /// + /// Falls back to the `[profile..coverage] lcov_version` config value + /// when not provided. + #[arg(long = "lcov-version", value_parser = parse_lcov_version)] + lcov_version_cli: Option, + + /// The resolved LCOV version to use after merging CLI and config values. + #[arg(skip = Version::new(1, 0, 0))] lcov_version: Version, /// Enable viaIR with minimum optimization @@ -62,16 +103,16 @@ pub struct CoverageArgs { /// The path to output the report. /// - /// If not specified, the report will be stored in the root of the project. + /// Used only when a single file report is requested. If not specified, the + /// report will be stored in the root of the project. #[arg( long, - short, value_hint = ValueHint::FilePath, value_name = "PATH" )] report_file: Option, - /// Whether to include libraries in the coverage report. + /// Include dependency sources in the coverage report. #[arg(long)] include_libs: bool, @@ -83,142 +124,360 @@ pub struct CoverageArgs { #[arg(skip)] reporters: Vec>, + /// Glob patterns of source files to exclude from the coverage report. + /// Populated from `[profile..coverage] skip_files` after config is + /// loaded; not exposed directly on the CLI. + #[arg(skip)] + skip_files: Vec, + #[command(flatten)] test: TestArgs, } impl CoverageArgs { + fn report_path(&self, root: &Path, default_file_name: &str) -> PathBuf { + let report_file = + (self.file_report_count() == 1).then_some(self.report_file.as_deref()).flatten(); + root.join(report_file.unwrap_or_else(|| Path::new(default_file_name))) + } + + fn file_report_count(&self) -> usize { + let has_lcov = self.report.iter().any(|kind| matches!(kind, CoverageReportKind::Lcov)); + let has_attribution = + self.report.iter().any(|kind| matches!(kind, CoverageReportKind::Attribution)); + usize::from(has_lcov) + usize::from(has_attribution) + } + + pub(crate) fn ensure_mode_compatible(&self) -> Result<()> { + self.test.ensure_coverage_mode_compatible() + } + pub async fn run(mut self) -> Result<()> { + self.ensure_mode_compatible()?; + let (mut config, evm_opts) = self.load_config_and_evm_opts()?; // install missing dependencies - if install::install_missing_dependencies(&mut config) && config.auto_detect_remappings { - // need to re-configure here to also catch additional remappings - config = self.load_config()?; - } + self.install_missing_dependencies(&mut config)?; - // Set fuzz seed so coverage reports are deterministic - config.fuzz.seed = Some(U256::from_be_bytes(STATIC_FUZZ_SEED)); + // Default to a static fuzz seed so coverage reports are deterministic, + // but allow the user to override it via `--fuzz-seed` or `[fuzz] seed` in config. + if config.fuzz.seed.is_none() { + config.fuzz.seed = Some(U256::from_be_bytes(STATIC_FUZZ_SEED)); + } - // Coverage analysis requires the Solc AST output. - config.ast = true; + // Merge CLI args with `[profile..coverage]` config values. CLI + // flags take precedence; unset CLI flags fall back to the config. + self.resolve_with(&config.coverage); + let filter = self.test.filter(&config)?; - let (paths, output) = { - let (project, output) = self.build(&config)?; + let (paths, mut output) = { + let (project, output) = self.build(&config, &filter)?; (project.paths, output) }; + if self.report_file.is_some() && self.file_report_count() > 1 { + sh_warn!( + "`--report-file` is ignored when multiple file reports are requested; \ + each report will use its default output path" + )?; + } + self.populate_reporters(&paths.root); sh_println!("Analysing contracts...")?; - let report = self.prepare(&paths, &output)?; + let report = self.prepare(&paths, &mut output)?; sh_println!("Running tests...")?; - self.collect(&paths.root, &output, report, Arc::new(config), evm_opts).await + self.collect(&paths.root, &output, report, config, evm_opts, filter).await + } + + /// Merge `[profile..coverage]` config values into this struct. CLI + /// flags already set on `self` win; unset/false flags inherit from + /// `config`. + /// + /// After this returns: + /// - `self.report` is non-empty. + /// - boolean flags reflect `cli || config` (CLI cannot disable a flag set to `true` in config; + /// this matches the pre-existing flag-only semantics where booleans defaulted to `false`). + fn resolve_with(&mut self, config: &CoverageConfig) { + if self.report.is_empty() { + self.report.clone_from(&config.report); + } + self.lcov_version = + self.lcov_version_cli.clone().unwrap_or_else(|| config.lcov_version.clone()); + if !self.ir_minimum { + self.ir_minimum = config.ir_minimum; + } + if self.report_file.is_none() { + self.report_file.clone_from(&config.report_file); + } + if !self.include_libs { + self.include_libs = config.include_libs; + } + if !self.exclude_tests { + self.exclude_tests = config.exclude_tests; + } + // Glob filters are additive — there's no CLI flag for these, so always + // take from config. + self.skip_files.clone_from(&config.skip_files); } fn populate_reporters(&mut self, root: &Path) { self.reporters = self .report .iter() - .map(|report_kind| match report_kind { + .filter_map(|report_kind| match report_kind { CoverageReportKind::Summary => { - Box::::default() as Box + Some(Box::::default() as Box) } CoverageReportKind::Lcov => { - let path = - root.join(self.report_file.as_deref().unwrap_or("lcov.info".as_ref())); - Box::new(LcovReporter::new(path, self.lcov_version.clone())) + let path = self.report_path(root, "lcov.info"); + Some(Box::new(LcovReporter::new(path, self.lcov_version.clone()))) } - CoverageReportKind::Bytecode => Box::new(BytecodeReporter::new( + CoverageReportKind::Bytecode => Some(Box::new(BytecodeReporter::new( root.to_path_buf(), root.join("bytecode-coverage"), - )), - CoverageReportKind::Debug => Box::new(DebugReporter), + ))), + CoverageReportKind::Debug => Some(Box::new(DebugReporter)), + CoverageReportKind::Attribution => None, }) .collect::>(); } /// Builds the project. - fn build(&self, config: &Config) -> Result<(Project, ProjectCompileOutput)> { + fn build( + &self, + config: &Config, + filter: &ProjectPathsAwareFilter, + ) -> Result<(Project, ProjectCompileOutput)> { let mut project = config.ephemeral_project()?; + // A contended or unavailable cache retains ephemeral compilation. Keep the lock until + // artifacts and build contexts have been loaded or published together. + let cache = (config.cache && !config.deny.warnings()) + .then(|| config.coverage_cache_path()) + .flatten() + .and_then(|path| { + fs::create_dir_all(path.parent()?).ok()?; + let lock = Config::lock_coverage_cache(&path).ok()?; + match fs::create_dir(&path) { + Ok(()) => { + if fs::File::create_new(path.join(Config::COVERAGE_CACHE_MARKER)).is_err() { + let _ = fs::remove_dir(&path); + return None; + } + } + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { + if !path.join(Config::COVERAGE_CACHE_MARKER).is_file() { + return None; + } + } + Err(_) => return None, + } + Some((path, lock)) + }); + let original_paths = project.paths.clone(); + if let Some((path, _)) = &cache { + project.cached = true; + project.no_artifacts = false; + project.paths.cache = path.join(SOLIDITY_FILES_CACHE_FILENAME); + project.paths.artifacts = path.join("artifacts"); + project.paths.build_infos = path.join("build-info"); + project.paths.slash_paths(); + } if self.ir_minimum { - // print warning message sh_warn!( "`--ir-minimum` enables `viaIR` with minimum optimization, \ which can result in inaccurate source mappings.\n\ Only use this flag as a workaround if you are experiencing \"stack too deep\" errors.\n\ Note that `viaIR` is production ready since Solidity 0.8.13 and above.\n\ - See more: https://github.com/foundry-rs/foundry/issues/3357" + See more: https://book.getfoundry.sh/guides/best-practices/stack-too-deep" )?; - - // Enable viaIR with minimum optimization: https://github.com/ethereum/solidity/issues/12533#issuecomment-1013073350 - // And also in new releases of Solidity: https://github.com/ethereum/solidity/issues/13972#issuecomment-1628632202 - project.settings.solc.settings = - project.settings.solc.settings.with_via_ir_minimum_optimization(); - - // Sanitize settings for solc 0.8.4 if version cannot be detected: https://github.com/foundry-rs/foundry/issues/9322 - // But keep the EVM version: https://github.com/ethereum/solidity/issues/15775 - let evm_version = project.settings.solc.evm_version; - let version = config.solc_version().unwrap_or_else(|| Version::new(0, 8, 4)); - project.settings.solc.settings.sanitize(&version, SolcLanguage::Solidity); - project.settings.solc.evm_version = evm_version; } else { sh_warn!( "optimizer settings and `viaIR` have been disabled for accurate coverage reports.\n\ If you encounter \"stack too deep\" errors, consider using `--ir-minimum` which \ - enables `viaIR` with minimum optimization resolving most of the errors" + enables `viaIR` with minimum optimization resolving most of the errors.\n\ + See more: https://book.getfoundry.sh/guides/best-practices/stack-too-deep" )?; - - project.settings.solc.optimizer.disable(); - project.settings.solc.optimizer.runs = None; - project.settings.solc.optimizer.details = None; - project.settings.solc.via_ir = None; } - let output = ProjectCompiler::default() - .compile(&project)? - .with_stripped_file_prefixes(project.root()); + config.disable_optimizations(&mut project, self.ir_minimum); + + let files = (filter.args().path_pattern.is_some() + || filter.args().path_pattern_inverse.is_some()) + .then(|| { + source_files_iter(&config.src, MultiCompilerLanguage::FILE_EXTENSIONS) + .chain( + source_files_iter(&config.test, MultiCompilerLanguage::FILE_EXTENSIONS) + // Preserve path-filter behavior for conventional test files while still + // scanning non-test fixtures under the test root. + .filter(|path| !path.is_sol_test() || filter.matches_path(path)), + ) + // Coverage reports include scripts even though they are not test targets. + .chain(source_files_iter(&config.script, MultiCompilerLanguage::FILE_EXTENSIONS)) + .collect::>() + }); + let compile = |project: &Project| { + let mut compiler = ProjectCompiler::new() + .external_compilers(config) + .external_artifacts(false) + .dynamic_test_linking(config.dynamic_test_linking); + if let Some(files) = &files { + compiler = compiler.files(files.iter().cloned()); + } + compiler.compile(project) + }; + // Coverage needs complete build contexts, including sources without contract artifacts. + // Invalidate the owned cache as a whole on edits so source IDs, artifact names, and + // build-info files all belong to the same compilation. + let cached_output = (|| -> Result> { + let Some((path, _)) = &cache else { return Ok(None) }; + let mut sources = if let Some(files) = &files + && !files.is_empty() + { + Source::read_all(files)? + } else { + project.paths.read_input_files()? + }; + if let Some(filter) = &project.sparse_output { + sources.retain(|path, _| filter.is_match(path)); + } + let graph = Graph::::resolve_sources(&project.paths, sources)?; + let resolved = graph.into_sources_by_version(&project)?; + let mut jobs = resolved + .sources + .iter() + .flat_map(|(language, jobs)| { + jobs.iter().map(move |(version, sources, (profile, settings))| { + let sources = sources + .iter() + .map(|(path, source)| { + (path, source.content_hash(), source.kind.is_dirty()) + }) + .collect::>(); + serde_json::to_value((language, version, profile, settings, sources)) + }) + }) + .collect::, _>>()?; + jobs.sort_unstable_by_key(serde_json::Value::to_string); + let primary_profiles = resolved.primary_profiles.iter().collect::>(); + let mut identity = serde_json::to_value(( + SHORT_VERSION, + &project.paths, + (&config.solc, &config.vyper, &config.extra_args), + (&config.extra_output, &config.extra_output_files), + config.dynamic_test_linking, + jobs, + primary_profiles, + ))?; + identity.sort_all_objects(); + let fingerprint = keccak256(serde_json::to_vec(&identity)?).to_string(); + let marker = path.join(Config::COVERAGE_CACHE_MARKER); + if fs::read_to_string(&marker).ok().as_deref() != Some(&fingerprint) { + // Retain ownership even if compilation fails before the new fingerprint is saved. + fs::write(&marker, "")?; + for entry in fs::read_dir(path)? { + let entry = entry?; + if entry.file_name() == Config::COVERAGE_CACHE_MARKER { + continue; + } + if entry.file_type()?.is_dir() { + fs::remove_dir_all(entry.path())?; + } else { + fs::remove_file(entry.path())?; + } + } + } + let output = compile(&project)?; + // Compiler builds with no artifacts can be pruned from the cache. Coverage still + // reports their free functions, so recover their source IDs with a fresh compilation. + let mapped_sources = output + .builds() + .flat_map(|(_, build)| build.source_id_to_path.values().map(PathBuf::as_path)) + .collect::>(); + if output + .graph() + .files() + .any(|idx| !mapped_sources.contains(output.graph().node_path(idx))) + { + return Ok(None); + } + // Publication is optional once the compiler has returned a complete output. + if let Err(err) = fs::write(marker, fingerprint) { + debug!(%err, "failed to publish coverage cache fingerprint"); + } + Ok(Some(output)) + })(); + let output = match cached_output { + Ok(Some(output)) => output, + Err(err) + if !err.chain().any(|cause| { + cause.is::() + || matches!(cause.downcast_ref(), Some(SolcError::Io(_))) + }) => + { + return Err(err); + } + result => { + if let Err(err) = result { + debug!(%err, "coverage cache unavailable; compiling without persistence"); + } + project.cached = false; + project.no_artifacts = true; + project.paths = original_paths; + compile(&project)? + } + }; + let output = output.with_stripped_file_prefixes(project.root()); Ok((project, output)) } /// Builds the coverage report. - #[instrument(name = "prepare", skip_all)] + #[instrument(name = "Coverage::prepare", skip_all)] fn prepare( &self, project_paths: &ProjectPathsConfig, - output: &ProjectCompileOutput, + output: &mut ProjectCompileOutput, ) -> Result { let mut report = CoverageReport::default(); + output.parser_mut().solc_mut().compiler_mut().enter_mut(|compiler| { + if compiler.gcx().stage() < Some(solar::config::CompilerStage::Lowering) { + let _ = compiler.lower_asts(); + } + convert_solar_errors(compiler.dcx()) + })?; + let output = &*output; + // Collect source files. - let mut versioned_sources = HashMap::>::default(); - for (path, source_file, version) in output.output().sources.sources_with_version() { - report.add_source(version.clone(), source_file.id as usize, path.clone()); + let mut sources_by_build = HashMap::::default(); + for (build_id, build) in output.builds() { + for (source_id, path) in &build.source_id_to_path { + if output.graph().get_parsed_source(path).is_none() + || path + .extension() + .and_then(|s| s.to_str()) + .is_some_and(|ext| VYPER_EXTENSIONS.contains(&ext)) + { + continue; + } + let path = path.strip_prefix(&project_paths.root).unwrap_or(path); + report.add_source(build_id.clone(), *source_id as usize, path.to_path_buf()); - // Filter out libs dependencies and tests. - if (!self.include_libs && project_paths.has_library_ancestor(path)) - || (self.exclude_tests && project_paths.is_test(path)) - { - continue; - } + if (!self.include_libs && project_paths.has_library_ancestor(path)) + || (self.exclude_tests && project_paths.is_test(path)) + { + continue; + } - if let Some(ast) = &source_file.ast { - let file = project_paths.root.join(path); - trace!(root=?project_paths.root, ?file, "reading source file"); - - let source = SourceFile { - ast, - source: Source::read(&file) - .wrap_err("Could not read source code for analysis")?, - }; - versioned_sources - .entry(version.clone()) + sources_by_build + .entry(build_id.clone()) .or_default() .sources - .insert(source_file.id as usize, source); + .insert(*source_id, project_paths.root.join(path)); } } @@ -227,17 +486,17 @@ impl CoverageArgs { .artifact_ids() .par_bridge() // This parses source maps, so we want to run it in parallel. .filter_map(|(id, artifact)| { - let source_id = report.get_source_id(id.version.clone(), id.source.clone())?; + let source_id = report.get_source_id(&id.build_id, &id.source)?; ArtifactData::new(&id, source_id, artifact) }) .collect(); // Add coverage items. - for (version, sources) in &versioned_sources { - let source_analysis = SourceAnalysis::new(sources)?; + for (build_id, sources) in &sources_by_build { + let source_analysis = SourceAnalysis::new(sources, output)?; let anchors = artifacts .par_iter() - .filter(|artifact| artifact.contract_id.version == *version) + .filter(|artifact| artifact.contract_id.build_id == *build_id) .map(|artifact| { let creation_code_anchors = artifact.creation.find_anchors(&source_analysis); let deployed_code_anchors = artifact.deployed.find_anchors(&source_analysis); @@ -245,7 +504,23 @@ impl CoverageArgs { }) .collect_vec_list(); report.add_anchors(anchors.into_iter().flatten()); - report.add_analysis(version.clone(), source_analysis); + for artifact in + artifacts.iter().filter(|artifact| artifact.contract_id.build_id == *build_id) + { + let execution_anchors = find_execution_anchors( + artifact.contract_id.source_id as u32, + &artifact.contract_id.contract_name, + &source_analysis, + ); + report.add_execution_anchors( + artifact.contract_id.clone(), + execution_anchors, + artifact.function_selectors.iter().copied(), + artifact.has_receive, + artifact.fallback_payable, + ); + } + report.add_analysis(build_id.clone(), source_analysis); } if self.reporters.iter().any(|reporter| reporter.needs_source_maps()) { @@ -258,103 +533,173 @@ impl CoverageArgs { } /// Runs tests, collects coverage data and generates the final report. + #[instrument(name = "Coverage::collect", skip_all)] async fn collect( mut self, - root: &Path, + project_root: &Path, output: &ProjectCompileOutput, mut report: CoverageReport, - config: Arc, + config: Config, evm_opts: EvmOpts, + filter: ProjectPathsAwareFilter, ) -> Result<()> { - let verbosity = evm_opts.verbosity; - - // Build the contract runner - let env = evm_opts.evm_env().await?; - let runner = MultiContractRunnerBuilder::new(config.clone()) - .initial_balance(evm_opts.initial_balance) - .evm_spec(config.evm_spec_id()) - .sender(evm_opts.sender) - .with_fork(evm_opts.get_fork(&config, env.clone())) - .set_coverage(true) - .build::(root, output, env, evm_opts)?; - - let known_contracts = runner.known_contracts.clone(); - - let filter = self.test.filter(&config)?; - let outcome = self.test.run_tests(runner, config, verbosity, &filter, output).await?; - - outcome.ensure_ok(false)?; + let inline_config = Arc::new(InlineConfig::new_parsed(output, &config)?); + let outcome = self + .test + .run_tests( + project_root, + config, + evm_opts, + output, + &filter, + TestExecutionOptions::coverage(inline_config), + ) + .await?; + + let known_contracts = outcome.known_contracts.as_ref().unwrap(); + // Linking removes library references. Match runtime against the compiler artifacts to + // retain those offsets, while keeping linked bytecode for creation matching. + let runtime_contracts = ContractsByArtifactBuilder::new( + output.artifact_ids().map(|(id, artifact)| (id, artifact.into())), + ) + .build(); + let mut resolved_hit_maps = ResolvedHitMaps::default(); + let mut unresolved_hit_maps = HashSet::::default(); // Add hit data to the coverage report - let data = outcome.results.iter().flat_map(|(_, suite)| { - let mut hits = Vec::new(); + for suite in outcome.results.values() { for result in suite.test_results.values() { let Some(hit_maps) = result.line_coverage.as_ref() else { continue }; - for map in hit_maps.0.values() { - if let Some((id, _)) = known_contracts.find_by_deployed_code(map.bytecode()) { - hits.push((id, map, true)); - } else if let Some((id, _)) = - known_contracts.find_by_creation_code(map.bytecode()) - { - hits.push((id, map, false)); + + for (code_hash, map) in &hit_maps.0 { + if let Some(resolved) = resolved_hit_maps.get(code_hash) { + report.add_hit_map( + &resolved.contract_id, + map, + resolved.is_deployed_code, + )?; + continue; + } + if unresolved_hit_maps.contains(code_hash) { + continue; } - } - } - hits - }); - for (artifact_id, map, is_deployed_code) in data { - if let Some(source_id) = - report.get_source_id(artifact_id.version.clone(), artifact_id.source.clone()) - { - report.add_hit_map( - &ContractId { + // Only exact matches are credited: source maps of a merely similar artifact + // would attribute hits to code that never ran. Creation code may be followed + // by any constructor data, which leaves the artifact's instructions unchanged. + let Some((artifact_id, is_deployed_code)) = runtime_contracts + .find_by_deployed_code_exact(map.bytecode()) + .map(|(id, _)| (id, true)) + .or_else(|| { + known_contracts + .iter() + .find(|(_, contract)| { + contract + .bytecode() + .is_some_and(|code| map.bytecode().starts_with(code)) + }) + .map(|(id, _)| (id, false)) + }) + else { + if let Some((similar, kind)) = known_contracts + .find_by_deployed_code(map.bytecode()) + .map(|(id, _)| (id, "runtime")) + .or_else(|| { + known_contracts + .find_by_creation_code(map.bytecode()) + .map(|(id, _)| (id, "creation")) + }) + { + sh_warn!( + "excluding executed {kind} code from coverage: it resembles `{}` but does not match it exactly", + similar.identifier() + )?; + } + unresolved_hit_maps.insert(*code_hash); + continue; + }; + + let Some(source_id) = + report.get_source_id(&artifact_id.build_id, &artifact_id.source) + else { + continue; + }; + let contract_id = ContractId { version: artifact_id.version.clone(), + build_id: artifact_id.build_id.clone(), source_id, contract_name: artifact_id.name.as_str().into(), - }, - map, - is_deployed_code, - )?; + }; + + report.add_hit_map(&contract_id, map, is_deployed_code)?; + + resolved_hit_maps + .entry(*code_hash) + .or_insert(ResolvedHitMap { contract_id, is_deployed_code }); + } } } // Filter out ignored sources from the report. + let file_root = filter.paths().root.as_path(); if let Some(not_re) = &filter.args().coverage_pattern_inverse { - let file_root = filter.paths().root.as_path(); report.retain_sources(|path: &Path| { let path = path.strip_prefix(file_root).unwrap_or(path); !not_re.is_match(&path.to_string_lossy()) }); } + if !self.skip_files.is_empty() { + let mut builder = GlobSetBuilder::new(); + for pattern in &self.skip_files { + let glob = Glob::new(pattern).map_err(|e| { + eyre::eyre!("invalid glob in coverage.skip_files: '{pattern}': {e}") + })?; + builder.add(glob); + } + let set = builder + .build() + .map_err(|e| eyre::eyre!("failed to build coverage.skip_files glob set: {e}"))?; + report.retain_sources(|path: &Path| { + let path = path.strip_prefix(file_root).unwrap_or(path); + !set.is_match(path) + }); + } // Output final reports. - for reporter in &mut self.reporters { - reporter.report(&report)?; + self.report(&report)?; + + if self.report.iter().any(|kind| matches!(kind, CoverageReportKind::Attribution)) { + let reporter = CoverageAttributionReporter::new( + self.report_path(project_root, "coverage-attribution.json"), + ); + reporter.report(&report, &outcome, &resolved_hit_maps)?; } + // Check for test failures after generating coverage report. + // This ensures coverage data is written even when tests fail. + outcome.ensure_ok(false)?; + + Ok(()) + } + + #[instrument(name = "Coverage::report", skip_all)] + fn report(&mut self, report: &CoverageReport) -> Result<()> { + for reporter in &mut self.reporters { + let _guard = debug_span!("reporter.report", kind=%reporter.name()).entered(); + reporter.report(report)?; + } Ok(()) } - pub fn is_watch(&self) -> bool { + pub const fn is_watch(&self) -> bool { self.test.is_watch() } - pub fn watch(&self) -> &WatchArgs { + pub const fn watch(&self) -> &WatchArgs { &self.test.watch } } -/// Coverage reports to generate. -#[derive(Clone, Debug, Default, ValueEnum)] -pub enum CoverageReportKind { - #[default] - Summary, - Lcov, - Debug, - Bytecode, -} - /// Helper function that will link references in unlinked bytecode to the 0 address. /// /// This is needed in order to analyze the bytecode for contracts that use libraries. @@ -381,13 +726,27 @@ pub struct ArtifactData { pub contract_id: ContractId, pub creation: BytecodeData, pub deployed: BytecodeData, + pub function_selectors: Vec<[u8; 4]>, + pub has_receive: bool, + pub fallback_payable: bool, } impl ArtifactData { pub fn new(id: &ArtifactId, source_id: usize, artifact: &impl Artifact) -> Option { + let abi = artifact.get_abi(); + let function_selectors = abi + .as_ref() + .map(|abi| abi.functions().map(|function| function.selector().into()).collect()) + .unwrap_or_default(); + let has_receive = abi.as_ref().is_some_and(|abi| abi.receive.is_some()); + let fallback_payable = abi + .as_ref() + .and_then(|abi| abi.fallback) + .is_some_and(|fallback| fallback.state_mutability == StateMutability::Payable); Some(Self { contract_id: ContractId { version: id.version.clone(), + build_id: id.build_id.clone(), source_id, contract_name: id.name.as_str().into(), }, @@ -403,6 +762,9 @@ impl ArtifactData { .get_deployed_bytecode() .and_then(|bytecode| dummy_link_deployed_bytecode(bytecode.into_owned()))?, ), + function_selectors, + has_receive, + fallback_payable, }) } } @@ -426,25 +788,11 @@ impl BytecodeData { Self { source_map, bytecode, ic_pc_map } } - pub fn find_anchors(&self, source_analysis: &SourceAnalysis) -> Vec { + pub fn find_anchors(&self, source_analysis: &SourceAnalysis) -> ItemAnchors { find_anchors(&self.bytecode, &self.source_map, &self.ic_pc_map, source_analysis) } } -fn parse_lcov_version(s: &str) -> Result { - let vr = VersionReq::parse(&format!("={s}")).map_err(|e| e.to_string())?; - let [c] = &vr.comparators[..] else { - return Err("invalid version".to_string()); - }; - if c.op != semver::Op::Exact { - return Err("invalid version".to_string()); - } - if !c.pre.is_empty() { - return Err("pre-releases are not supported".to_string()); - } - Ok(Version::new(c.major, c.minor.unwrap_or(0), c.patch.unwrap_or(0))) -} - #[cfg(test)] mod tests { use super::*; @@ -457,4 +805,34 @@ mod tests { assert_eq!(parse_lcov_version("1.1").unwrap(), Version::new(1, 1, 0)); assert_eq!(parse_lcov_version("1.11").unwrap(), Version::new(1, 11, 0)); } + + #[test] + fn resolve_lcov_version_uses_config_when_cli_absent() { + let mut args = CoverageArgs::parse_from(["coverage"]); + let config = CoverageConfig { lcov_version: Version::new(2, 2, 0), ..Default::default() }; + + args.resolve_with(&config); + + assert_eq!(args.lcov_version, Version::new(2, 2, 0)); + } + + #[test] + fn resolve_lcov_version_keeps_explicit_cli_default() { + let mut args = CoverageArgs::parse_from(["coverage", "--lcov-version", "1"]); + let config = CoverageConfig { lcov_version: Version::new(2, 2, 0), ..Default::default() }; + + args.resolve_with(&config); + + assert_eq!(args.lcov_version, Version::new(1, 0, 0)); + } + + #[test] + fn resolve_lcov_version_keeps_explicit_cli_value() { + let mut args = CoverageArgs::parse_from(["coverage", "--lcov-version", "2"]); + let config = CoverageConfig { lcov_version: Version::new(2, 2, 0), ..Default::default() }; + + args.resolve_with(&config); + + assert_eq!(args.lcov_version, Version::new(2, 0, 0)); + } } diff --git a/crates/forge/src/cmd/create.rs b/crates/forge/src/cmd/create.rs index f12099ac51386..3d1531bc3197f 100644 --- a/crates/forge/src/cmd/create.rs +++ b/crates/forge/src/cmd/create.rs @@ -1,44 +1,55 @@ -use crate::cmd::install; use alloy_chains::Chain; -use alloy_dyn_abi::{DynSolValue, JsonAbiExt, Specifier}; -use alloy_json_abi::{Constructor, JsonAbi}; -use alloy_network::{AnyNetwork, AnyTransactionReceipt, EthereumWallet, TransactionBuilder}; -use alloy_primitives::{Address, Bytes, hex}; -use alloy_provider::{PendingTransactionError, Provider, ProviderBuilder}; -use alloy_rpc_types::TransactionRequest; -use alloy_serde::WithOtherFields; -use alloy_signer::Signer; +use alloy_consensus::{SignableTransaction, Signed}; +use alloy_dyn_abi::{DynSolValue, JsonAbiExt}; +use alloy_json_abi::JsonAbi; +use alloy_network::{Ethereum, EthereumWallet, Network, ReceiptResponse, TransactionBuilder}; +use alloy_primitives::{Address, Bytes, U256, hex}; +use alloy_provider::{PendingTransactionError, Provider, ProviderBuilder as AlloyProviderBuilder}; +use alloy_signer::{Signature, Signer}; use alloy_transport::TransportError; use clap::{Parser, ValueHint}; -use eyre::{Context, Result}; -use forge_verify::{RetryArgs, VerifierArgs, VerifyArgs}; +use eyre::{Context, ContextCompat, Result}; +use forge_verify::{RetryArgs, VerifierArgs, VerifyArgs, parse_etherscan_license_type}; use foundry_cli::{ opts::{BuildOpts, EthereumOpts, EtherscanOpts, TransactionOpts}, - utils::{self, LoadConfig, read_constructor_args_file, remove_contract}, + utils::{ + LoadConfig, ResolvedLane, apply_gas_estimate_multiplier, find_contract_artifacts, + maybe_print_resolved_lane, parse_constructor_args, read_constructor_args_file, + resolve_lane, + }, }; use foundry_common::{ - compile::{self}, - fmt::parse_tokens, + FoundryTransactionBuilder, compile, + provider::{ + ProviderBuilder, + fee::{estimate_eip1559_fees, resolve_broadcast_eip1559_fees}, + }, shell, + tempo::{maybe_print_fee_token, resolve_and_set_fee_token}, }; use foundry_compilers::{ ArtifactId, artifacts::BytecodeObject, info::ContractInfo, utils::canonicalize, }; use foundry_config::{ - Config, + Config, Eip1559FeeEstimatePreset, figment::{ self, Metadata, Profile, value::{Dict, Map}, }, merge_impl_figment_convert, }; +use foundry_wallets::{ + BrowserWalletOpts, TempoAccountsWallet, WalletSigner, wallet_browser::signer::BrowserSigner, +}; use serde_json::json; use std::{borrow::Borrow, marker::PhantomData, path::PathBuf, sync::Arc, time::Duration}; +use tempo_alloy::TempoNetwork; merge_impl_figment_convert!(CreateArgs, build, eth); /// CLI arguments for `forge create`. #[derive(Clone, Debug, Parser)] +#[command(mut_arg("auth", |arg| arg.hide(true)))] pub struct CreateArgs { /// The contract identifier in the form `:`. contract: ContractInfo, @@ -80,10 +91,28 @@ pub struct CreateArgs { #[arg(long, requires = "verify")] show_standard_json_input: bool, + /// The Etherscan license type code or SPDX identifier to include with the verification + /// request. + /// + /// Accepts either an Etherscan numeric license code or a common SPDX identifier such as `MIT`. + /// This is only used for Etherscan-style verifiers when `--verify` is enabled. + #[arg( + long, + requires = "verify", + value_name = "LICENSE", + help_heading = "Verifier options", + value_parser = parse_etherscan_license_type, + )] + license_type: Option, + /// Timeout to use for broadcasting transactions. #[arg(long, env = "ETH_TIMEOUT")] pub timeout: Option, + /// Relative percentage to multiply the gas estimate by. + #[arg(long, value_name = "PERCENT", help_heading = "Transaction options")] + gas_estimate_multiplier: Option, + #[command(flatten)] build: BuildOpts, @@ -98,18 +127,82 @@ pub struct CreateArgs { #[command(flatten)] retry: RetryArgs, + + /// Browser wallet options + #[command(flatten)] + browser: BrowserWalletOpts, } impl CreateArgs { /// Executes the command to create a contract pub async fn run(mut self) -> Result<()> { + if self.tx.tempo.sponsor_url.is_some() { + eyre::bail!( + "--sponsor-url is not supported by forge create; use --tempo.sponsor with \ + --tempo.sponsor-signer or --tempo.sponsor-sig" + ); + } + + // Resolve chain early so we can dispatch to the correct network type. + let chain = if let Some(chain) = self.chain_id() { + chain + } else { + let config = self.load_config()?; + let provider = ProviderBuilder::::from_config(&config)?.build()?; + let chain_id = provider.get_chain_id().await?; + let chain = Chain::from(chain_id); + self.eth.etherscan.chain = Some(chain); + chain + }; + let mut wallet = self.eth.wallet.clone(); + if !chain.is_tempo() && !self.tx.tempo.is_tempo() { + // Do not let a matching entry in the Tempo Accounts store change an ordinary Ethereum + // deployment into a Tempo transaction. + wallet.from = None; + } + let (signer, tempo_access_key) = wallet.maybe_signer_for_chain(chain.id()).await?; + + // Never deploy from an account other than the one explicitly selected by the user. Unlocked + // and browser deployments do not use the resolved signer. + let deployer = signer + .as_ref() + .map(|signer| signer.address()) + .or_else(|| tempo_access_key.as_ref().map(|ak| ak.account())); + if !self.unlocked + && !self.browser.browser + && let Some(from) = self.eth.wallet.from + && let Some(deployer) = deployer + && from != deployer + { + eyre::bail!( + "the sender specified via `--from`/`ETH_FROM` ({from}) does not match the \ + signer address ({deployer})" + ); + } + + if tempo_access_key.is_some() || self.tx.tempo.is_tempo() || chain.is_tempo() { + self.run_generic::(signer, tempo_access_key).await + } else { + self.run_generic::(signer, None).await + } + } + + async fn run_generic( + mut self, + pre_resolved_signer: Option, + access_key: Option, + ) -> Result<()> + where + N::TxEnvelope: From>, + N::UnsignedTx: SignableTransaction, + N::TransactionRequest: FoundryTransactionBuilder + serde::Serialize, + N::ReceiptResponse: serde::Serialize, + { let mut config = self.load_config()?; + let resolve_unknown_fee_token_symbol = !config.eth_rpc_curl; // Install missing dependencies. - if install::install_missing_dependencies(&mut config) && config.auto_detect_remappings { - // need to re-configure here to also catch additional remappings - config = self.load_config()?; - } + self.install_missing_dependencies(&mut config)?; // Find Project & Compile let project = config.project()?; @@ -120,9 +213,13 @@ impl CreateArgs { project.find_contract_path(&self.contract.name)? }; - let output = compile::compile_target(&target_path, &project, shell::is_json())?; + let compiler = compile::ProjectCompiler::new() + .external_compilers(&config) + .quiet(shell::is_json()) + .target_files([target_path.clone()]); + let output = compiler.compile(&project)?; - let (abi, bin, id) = remove_contract(output, &target_path, &self.contract.name)?; + let (abi, bin, id) = find_contract_artifacts(output, &target_path, &self.contract.name)?; let bin = match bin.object { BytecodeObject::Bytecode(_) => bin.object, @@ -138,7 +235,7 @@ impl CreateArgs { eyre::bail!( "Dynamic linking not supported in `create` command - deploy the following library contracts first, then provide the address to link at compile time\n{}", link_refs - ) + ); } }; @@ -146,27 +243,60 @@ impl CreateArgs { let params = if let Some(constructor) = &abi.constructor { let constructor_args = self.constructor_args_path.clone().map(read_constructor_args_file).transpose()?; - self.parse_constructor_args( + parse_constructor_args( constructor, constructor_args.as_deref().unwrap_or(&self.constructor_args), )? } else { + if !self.constructor_args.is_empty() || self.constructor_args_path.is_some() { + sh_warn!( + "`{}` has no constructor; ignoring provided constructor arguments", + self.contract.name + )?; + } vec![] }; - let provider = utils::get_provider(&config)?; + let provider = ProviderBuilder::::from_config(&config)?.build()?; - // respect chain, if set explicitly via cmd args - let chain_id = if let Some(chain_id) = self.chain_id() { - chain_id - } else { - provider.get_chain_id().await? - }; + // Inject access key ID into TempoOpts so it's set before gas estimation. + if let Some(ref ak) = access_key { + self.tx.tempo.key_id = Some(ak.key_id()?); + } + + // Resolve `--tempo.lane ` against the lanes file (default + // `/tempo.lanes.toml`) and populate `self.tx.tempo.nonce_key` from the lane. + // Must happen before `self.deploy(...)` so `TempoOpts::apply` picks up the nonce_key. + let resolved_lane = resolve_lane(&mut self.tx.tempo, &config.root)?; + let expires_at = self.tx.tempo.resolve_expires(); // Whether to broadcast the transaction or not let dry_run = !self.broadcast; - if self.unlocked { + // Launch browser signer if `--browser` flag is set + let browser = self.browser.run::().await?; + + if let Some(browser) = browser { + // Deploy with browser wallet + let deployer_address = browser.address(); + self.deploy( + abi, + bin, + params, + provider, + deployer_address, + config.transaction_timeout, + id, + dry_run, + None, + Some(browser), + resolved_lane, + expires_at, + resolve_unknown_fee_token_symbol, + config.eip1559_fee_estimate, + ) + .await + } else if self.unlocked { // Deploy with unlocked account let sender = self.eth.wallet.from.expect("required"); self.deploy( @@ -174,18 +304,45 @@ impl CreateArgs { bin, params, provider, - chain_id, sender, config.transaction_timeout, id, dry_run, + None, + None, + resolved_lane, + expires_at, + resolve_unknown_fee_token_symbol, + config.eip1559_fee_estimate, + ) + .await + } else if let Some(ak) = access_key { + let deployer_address = ak.account(); + self.deploy( + abi, + bin, + params, + provider, + deployer_address, + config.transaction_timeout, + id, + dry_run, + Some(ak), + None, + resolved_lane, + expires_at, + resolve_unknown_fee_token_symbol, + config.eip1559_fee_estimate, ) .await } else { // Deploy with signer - let signer = self.eth.wallet.signer().await?; + let signer = match pre_resolved_signer { + Some(s) => s, + None => self.eth.wallet.signer().await?, + }; let deployer = signer.address(); - let provider = ProviderBuilder::<_, _, AnyNetwork>::default() + let provider = AlloyProviderBuilder::<_, _, N>::default() .wallet(EthereumWallet::new(signer)) .connect_provider(provider); self.deploy( @@ -193,19 +350,24 @@ impl CreateArgs { bin, params, provider, - chain_id, deployer, config.transaction_timeout, id, dry_run, + None, + None, + resolved_lane, + expires_at, + resolve_unknown_fee_token_symbol, + config.eip1559_fee_estimate, ) .await } } - /// Returns the provided chain id, if any. - fn chain_id(&self) -> Option { - self.eth.etherscan.chain.map(|chain| chain.id()) + /// Returns the resolved chain, if any. + const fn chain_id(&self) -> Option { + self.eth.etherscan.chain } /// Ensures the verify command can be executed. @@ -217,7 +379,6 @@ impl CreateArgs { async fn verify_preflight_check( &self, constructor_args: Option, - chain: u64, id: &ArtifactId, ) -> Result<()> { // NOTE: this does not represent the same `VerifyArgs` that would be sent after deployment, @@ -228,62 +389,89 @@ impl CreateArgs { compiler_version: Some(id.version.to_string()), constructor_args, constructor_args_path: None, + no_auto_detect: false, + use_solc: None, num_of_optimizations: None, etherscan: EtherscanOpts { key: self.eth.etherscan.key.clone(), - api_version: self.eth.etherscan.api_version, - chain: Some(chain.into()), + chain: self.chain_id(), }, rpc: Default::default(), flatten: false, force: false, skip_is_verified_check: true, watch: true, + print_submission_result_to_stdout: false, retry: self.retry, libraries: self.build.libraries.clone(), root: None, verifier: self.verifier.clone(), - via_ir: self.build.via_ir, + via_ir: self.build.compiler.via_ir, + license_type: self.license_type.clone(), evm_version: self.build.compiler.evm_version, show_standard_json_input: self.show_standard_json_input, guess_constructor_args: false, - compilation_profile: Some(id.profile.to_string()), + compilation_profile: Some(id.profile.clone()), language: None, + creation_transaction_hash: None, }; // Check config for Etherscan API Keys to avoid preflight check failing if no // ETHERSCAN_API_KEY value set. let config = verify.load_config()?; - verify.etherscan.key = - config.get_etherscan_config_with_chain(Some(chain.into()))?.map(|c| c.key); + verify.etherscan.key = config + .get_etherscan_config_with_chain(self.chain_id())? + .map(|c| c.key) + .or_else(|| config.etherscan_api_key.clone()); let context = verify.resolve_context().await?; - verify.verification_provider()?.preflight_verify_check(verify, context).await?; + verify.verification_provider()?.preflight_verify_check(verify.clone(), context).await?; + + let api_key = verify.verifier.resolve_api_key(verify.etherscan.key.as_deref()); + let chain = verify.etherscan.chain.context("chain ID not resolved")?; + verify + .verifier + .check_credentials(api_key, chain, &config) + .await + .wrap_err("Verification preflight check failed")?; + Ok(()) } /// Deploys the contract #[expect(clippy::too_many_arguments)] - async fn deploy>( + async fn deploy>( self, abi: JsonAbi, bin: BytecodeObject, args: Vec, provider: P, - chain: u64, deployer_address: Address, timeout: u64, id: ArtifactId, dry_run: bool, - ) -> Result<()> { + mut tempo_keychain: Option, + browser_signer: Option>, + resolved_lane: Option, + expires_at: Option, + resolve_unknown_fee_token_symbol: bool, + eip1559_fee_estimate: Eip1559FeeEstimatePreset, + ) -> Result<()> + where + N::TransactionRequest: FoundryTransactionBuilder + serde::Serialize, + N::ReceiptResponse: serde::Serialize, + { + let chain = self.chain_id().context("chain ID not resolved")?; + let bin = bin.into_bytes().unwrap_or_default(); if bin.is_empty() { - eyre::bail!("no bytecode found in bin object for {}", self.contract.name) + eyre::bail!("no bytecode found in bin object for {}", self.contract.name); } let provider = Arc::new(provider); - let factory = ContractFactory::new(abi.clone(), bin.clone(), provider.clone(), timeout); + let factory = + ContractFactory::::new(abi.clone(), bin.clone(), provider.clone(), timeout); let is_args_empty = args.is_empty(); let mut deployer = @@ -294,53 +482,100 @@ impl CreateArgs { e } })?; - let is_legacy = self.tx.legacy || Chain::from(chain).is_legacy(); + let is_legacy = self.tx.legacy || chain.is_legacy(); deployer.tx.set_from(deployer_address); - deployer.tx.set_chain_id(chain); + deployer.tx.set_chain_id(chain.id()); // `to` field must be set explicitly, cannot be None. - if deployer.tx.to.is_none() { + if deployer.tx.to().is_none() { deployer.tx.set_create(); } - deployer.tx.set_nonce(if let Some(nonce) = self.tx.nonce { - Ok(nonce.to()) - } else { - provider.get_transaction_count(deployer_address).await - }?); - // set tx value if specified - if let Some(value) = self.tx.value { - deployer.tx.set_value(value); + // Apply user-provided gas, fee, nonce, and Tempo options. + self.tx.apply::(&mut deployer.tx, is_legacy); + + // Convert only AA CREATE transactions into a call entry. Plain Tempo + // CREATE transactions remain Ethereum transactions, while AA requests + // (for example, an expiring nonce) require a non-empty `calls` list. + if deployer.tx.is_tempo_aa() { + deployer.tx.convert_create_to_call(); } - deployer.tx.set_gas_limit(if let Some(gas_limit) = self.tx.gas_limit { - Ok(gas_limit.to()) - } else { - provider.estimate_gas(deployer.tx.clone()).await - }?); + if tempo_keychain.is_some() && deployer.tx.nonce_key().is_none() { + deployer.tx.set_nonce_key(U256::ZERO); + } + + // Fetch defaults from provider for values not specified by user. + if self.tx.nonce.is_none() && !self.tx.tempo.expiring_nonce { + deployer.tx.set_nonce(provider.get_transaction_count(deployer_address).await?); + } + + maybe_print_resolved_lane(resolved_lane.as_ref(), deployer.tx.nonce().unwrap_or_default())?; + + if let Some(wallet) = tempo_keychain.as_ref() { + tempo_keychain = + Some(deployer.tx.prepare_with_tempo_wallet(provider.as_ref(), wallet).await?); + } if is_legacy { - let gas_price = if let Some(gas_price) = self.tx.gas_price { - gas_price.to() - } else { - provider.get_gas_price().await? - }; - deployer.tx.set_gas_price(gas_price); + if self.tx.gas_price.is_none() { + deployer.tx.set_gas_price(provider.get_gas_price().await?); + } } else { - let estimate = provider.estimate_eip1559_fees().await.wrap_err("Failed to estimate EIP1559 fees. This chain might not support EIP1559, try adding --legacy to your command.")?; - let priority_fee = if let Some(priority_fee) = self.tx.priority_gas_price { - priority_fee.to() - } else { - estimate.max_priority_fee_per_gas - }; - let max_fee = if let Some(max_fee) = self.tx.gas_price { - max_fee.to() + if self.tx.gas_price.is_none() || self.tx.priority_gas_price.is_none() { + let estimate = estimate_eip1559_fees(&provider, eip1559_fee_estimate).await.wrap_err("Failed to estimate EIP1559 fees. This chain might not support EIP1559, try adding --legacy to your command.")?; + + // Only honor the browser-suggested tip when the user has not pinned + // a priority fee; `resolve_broadcast_eip1559_fees` ignores a lower tip. + let browser_suggested_tip = + if browser_signer.is_some() && self.tx.priority_gas_price.is_none() { + provider.get_max_priority_fee_per_gas().await.ok() + } else { + None + }; + + // User `--gas-price`/`--priority-gas-price` overrides are applied + // below only for unset fields; pass `None` to avoid double-applying. + let estimate = + resolve_broadcast_eip1559_fees(estimate, None, None, browser_suggested_tip)?; + + if self.tx.priority_gas_price.is_none() { + deployer.tx.set_max_priority_fee_per_gas(estimate.max_priority_fee_per_gas); + } + if self.tx.gas_price.is_none() { + deployer.tx.set_max_fee_per_gas(estimate.max_fee_per_gas); + } + } + if let (Some(max_fee), Some(priority)) = + (deployer.tx.max_fee_per_gas(), deployer.tx.max_priority_fee_per_gas()) + { + eyre::ensure!( + priority <= max_fee, + "max priority fee per gas ({priority}) cannot exceed max fee per gas ({max_fee})" + ); + } + } + + // set access list if specified + if let Some(access_list) = match self.tx.access_list { + None => None, + Some(None) => Some(provider.create_access_list(&deployer.tx).await?.access_list), + Some(Some(ref access_list)) => Some(access_list.clone()), + } { + deployer.tx.set_access_list(access_list); + } + + if self.tx.gas_limit.is_none() { + let request = if browser_signer.is_some() { + deployer.tx.browser_wallet_gas_estimation_request() } else { - estimate.max_fee_per_gas + deployer.tx.clone() }; - - deployer.tx.set_max_fee_per_gas(max_fee); - deployer.tx.set_max_priority_fee_per_gas(priority_fee); + let estimated = provider.estimate_gas(request).await?; + deployer.tx.set_gas_limit(apply_gas_estimate_multiplier( + estimated, + self.gas_estimate_multiplier, + )?); } // Before we actually deploy the contract we try check if the verify settings are valid @@ -354,11 +589,18 @@ impl CreateArgs { constructor_args = Some(hex::encode(encoded_args)); } - self.verify_preflight_check(constructor_args.clone(), chain, &id).await?; + self.verify_preflight_check(constructor_args.clone(), &id).await?; } if dry_run { - if !shell::is_json() { + if shell::is_json() { + let output = json!({ + "contract": self.contract.name, + "transaction": &deployer.tx, + "abi":&abi + }); + sh_println!("{}", serde_json::to_string_pretty(&output)?)?; + } else { sh_warn!("Dry run enabled, not broadcasting transaction\n")?; sh_println!("Contract: {}", self.contract.name)?; @@ -371,43 +613,103 @@ impl CreateArgs { sh_warn!( "To broadcast this transaction, add --broadcast to the previous command. See forge create --help for more." )?; - } else { - let output = json!({ - "contract": self.contract.name, - "transaction": &deployer.tx, - "abi":&abi - }); - sh_println!("{}", serde_json::to_string_pretty(&output)?)?; } return Ok(()); } + if let Some(ts) = expires_at { + sh_status!("Transaction expires at unix timestamp {ts}")?; + } + + let tempo_sponsor = self.tx.tempo.sponsor_config().await?; + if let Some(sponsor) = &tempo_sponsor { + sponsor + .resolve_and_set_fee_token( + resolve_unknown_fee_token_symbol.then_some(&provider), + &mut deployer.tx, + ) + .await?; + sponsor.attach_and_print::(&mut deployer.tx, deployer_address).await?; + } else { + let fee_token = resolve_and_set_fee_token( + resolve_unknown_fee_token_symbol.then_some(&provider), + &mut deployer.tx, + Some(deployer_address), + ) + .await?; + maybe_print_fee_token(resolve_unknown_fee_token_symbol.then_some(&provider), fee_token) + .await?; + } + // Deploy the actual contract - let (deployed_contract, receipt) = deployer.send_with_receipt().await?; + let (deployed_contract, receipt) = if let Some(browser) = browser_signer { + // Browser wallet signs and sends the transaction + let tx_hash = browser.send_transaction_via_browser(deployer.tx).await?; + + // Wait for the transaction to be confirmed, then fetch the receipt. + provider + .watch_pending_transaction(alloy_provider::PendingTransactionConfig::new(tx_hash)) + .await? + .await?; + + let receipt = provider + .get_transaction_receipt(tx_hash) + .await? + .ok_or_else(|| eyre::eyre!("could not get transaction receipt for {tx_hash}"))?; + + if !receipt.status() { + eyre::bail!("deployment transaction failed (receipt status 0): {tx_hash}"); + } + + let address = receipt + .contract_address() + .ok_or_else(|| eyre::eyre!("contract was not deployed"))?; + + (address, receipt) + } else if let Some(wallet) = tempo_keychain { + let raw_tx = deployer.tx.sign_with_tempo_wallet(&wallet).await?; + + let receipt = provider + .send_raw_transaction(&raw_tx) + .await? + .with_required_confirmations(1) + .with_timeout(Some(Duration::from_secs(timeout))) + .get_receipt() + .await?; + + let address = receipt + .contract_address() + .ok_or_else(|| eyre::eyre!("contract was not deployed"))?; + + (address, receipt) + } else { + deployer.send_with_receipt().await? + }; let address = deployed_contract; + let tx_hash = receipt.transaction_hash(); if shell::is_json() { let output = json!({ "deployer": deployer_address.to_string(), "deployedTo": address.to_string(), - "transactionHash": receipt.transaction_hash + "transactionHash": tx_hash }); sh_println!("{}", serde_json::to_string_pretty(&output)?)?; } else { sh_println!("Deployer: {deployer_address}")?; sh_println!("Deployed to: {address}")?; - sh_println!("Transaction hash: {:?}", receipt.transaction_hash)?; + sh_println!("Transaction hash: {tx_hash:?}")?; }; if !self.verify { return Ok(()); } - sh_println!("Starting contract verification...")?; + sh_status!("Starting contract verification...")?; let num_of_optimizations = if let Some(optimizer) = self.build.compiler.optimize { - if optimizer { Some(self.build.compiler.optimizer_runs.unwrap_or(200)) } else { None } + optimizer.then(|| self.build.compiler.optimizer_runs.unwrap_or(200)) } else { self.build.compiler.optimizer_runs }; @@ -418,52 +720,41 @@ impl CreateArgs { compiler_version: Some(id.version.to_string()), constructor_args, constructor_args_path: None, + no_auto_detect: false, + use_solc: None, num_of_optimizations, - etherscan: EtherscanOpts { - key: self.eth.etherscan.key(), - api_version: self.eth.etherscan.api_version, - chain: Some(chain.into()), - }, + etherscan: EtherscanOpts { key: self.eth.etherscan.key(), chain: Some(chain) }, rpc: Default::default(), flatten: false, force: false, skip_is_verified_check: true, watch: true, + print_submission_result_to_stdout: false, retry: self.retry, libraries: self.build.libraries.clone(), root: None, verifier: self.verifier, - via_ir: self.build.via_ir, + via_ir: self.build.compiler.via_ir, + license_type: self.license_type, evm_version: self.build.compiler.evm_version, show_standard_json_input: self.show_standard_json_input, guess_constructor_args: false, - compilation_profile: Some(id.profile.to_string()), + compilation_profile: Some(id.profile.clone()), language: None, + creation_transaction_hash: Some(tx_hash), }; - sh_println!("Waiting for {} to detect contract deployment...", verify.verifier.verifier)?; + // Load the full config (including foundry.toml) so the key used for resolution matches + // what `verify.run()` will actually use, preventing a "Waiting for sourcify..." message + // when the run will actually use Etherscan (or vice versa for unknown chains). + let verify_config = verify.load_config()?; + let effective_key = verify_config + .get_etherscan_config_with_chain(Some(chain))? + .map(|c| c.key) + .or_else(|| verify_config.etherscan_api_key.clone()); + let resolved_verifier = verify.verifier.resolve(effective_key.as_deref(), Some(chain)); + sh_status!("Waiting for {resolved_verifier} to detect contract deployment...")?; verify.run().await } - - /// Parses the given constructor arguments into a vector of `DynSolValue`s, by matching them - /// against the constructor's input params. - /// - /// Returns a list of parsed values that match the constructor's input params. - fn parse_constructor_args( - &self, - constructor: &Constructor, - constructor_args: &[String], - ) -> Result> { - let mut params = Vec::with_capacity(constructor.inputs.len()); - for (input, arg) in constructor.inputs.iter().zip(constructor_args) { - // resolve the input type directly - let ty = input - .resolve() - .wrap_err_with(|| format!("Could not resolve constructor arg: input={input}"))?; - params.push((ty, arg)); - } - let params = params.iter().map(|(ty, arg)| (ty, arg.as_str())); - parse_tokens(params).map_err(Into::into) - } } impl figment::Provider for CreateArgs { @@ -485,30 +776,30 @@ impl figment::Provider for CreateArgs { /// compatibility with less-abstract Contracts. /// /// For full usage docs, see [`DeploymentTxFactory`]. -pub type ContractFactory

= DeploymentTxFactory

; +pub type ContractFactory = DeploymentTxFactory; /// Helper which manages the deployment transaction of a smart contract. It /// wraps a deployment transaction, and retrieves the contract address output /// by it. #[derive(Debug)] #[must_use = "ContractDeploymentTx does nothing unless you `send` it"] -pub struct ContractDeploymentTx { +pub struct ContractDeploymentTx { /// the actual deployer, exposed for overriding the defaults - pub deployer: Deployer

, + pub deployer: Deployer, /// marker for the `Contract` type to create afterwards /// /// this type will be used to construct it via `From::from(Contract)` _contract: PhantomData, } -impl Clone for ContractDeploymentTx { +impl Clone for ContractDeploymentTx { fn clone(&self) -> Self { Self { deployer: self.deployer.clone(), _contract: self._contract } } } -impl From> for ContractDeploymentTx { - fn from(deployer: Deployer

) -> Self { +impl From> for ContractDeploymentTx { + fn from(deployer: Deployer) -> Self { Self { deployer, _contract: PhantomData } } } @@ -516,21 +807,21 @@ impl From> for ContractDeploymentTx { /// Helper which manages the deployment transaction of a smart contract #[derive(Clone, Debug)] #[must_use = "Deployer does nothing unless you `send` it"] -pub struct Deployer

{ +pub struct Deployer { /// The deployer's transaction, exposed for overriding the defaults - pub tx: WithOtherFields, + pub tx: N::TransactionRequest, client: P, confs: usize, timeout: u64, } -impl> Deployer

{ +impl> Deployer { /// Broadcasts the contract deployment transaction and after waiting for it to /// be sufficiently confirmed (default: 1), it returns a tuple with the [`Address`] at the - /// deployed contract's address and the corresponding [`AnyTransactionReceipt`]. + /// deployed contract's address and the corresponding receipt. pub async fn send_with_receipt( self, - ) -> Result<(Address, AnyTransactionReceipt), ContractDeploymentError> { + ) -> Result<(Address, N::ReceiptResponse), ContractDeploymentError> { let receipt = self .client .borrow() @@ -541,8 +832,12 @@ impl> Deployer

{ .get_receipt() .await?; + if !receipt.status() { + return Err(ContractDeploymentError::DeploymentFailed(receipt.transaction_hash())); + } + let address = - receipt.contract_address.ok_or(ContractDeploymentError::ContractNotDeployed)?; + receipt.contract_address().ok_or(ContractDeploymentError::ContractNotDeployed)?; Ok((address, receipt)) } @@ -552,19 +847,20 @@ impl> Deployer

{ /// created which manages the Contract bytecode and Application Binary Interface /// (ABI), usually generated from the Solidity compiler. #[derive(Clone, Debug)] -pub struct DeploymentTxFactory

{ +pub struct DeploymentTxFactory { client: P, abi: JsonAbi, bytecode: Bytes, timeout: u64, + _network: PhantomData, } -impl + Clone> DeploymentTxFactory

{ +impl + Clone> DeploymentTxFactory { /// Creates a factory for deployment of the Contract with bytecode, and the /// constructor defined in the abi. The client will be used to send any deployment /// transaction. - pub fn new(abi: JsonAbi, bytecode: Bytes, client: P, timeout: u64) -> Self { - Self { client, abi, bytecode, timeout } + pub const fn new(abi: JsonAbi, bytecode: Bytes, client: P, timeout: u64) -> Self { + Self { client, abi, bytecode, timeout, _network: PhantomData } } /// Create a deployment tx using the provided tokens as constructor @@ -572,7 +868,10 @@ impl + Clone> DeploymentTxFactory

{ pub fn deploy_tokens( self, params: Vec, - ) -> Result, ContractDeploymentError> { + ) -> Result, ContractDeploymentError> + where + N::TransactionRequest: FoundryTransactionBuilder, + { // Encode the constructor args & concatenate with the bytecode if necessary let data: Bytes = match (self.abi.constructor(), params.is_empty()) { (None, false) => return Err(ContractDeploymentError::ConstructorError), @@ -588,8 +887,8 @@ impl + Clone> DeploymentTxFactory

{ }; // create the tx object. Since we're deploying a contract, `to` is `None` - let tx = WithOtherFields::new(TransactionRequest::default().input(data.into())); - + let mut tx = N::TransactionRequest::default(); + tx.set_input(data); Ok(Deployer { client: self.client.clone(), tx, confs: 1, timeout: self.timeout }) } } @@ -603,6 +902,8 @@ pub enum ContractDeploymentError { DetokenizationError(#[from] alloy_dyn_abi::Error), #[error("contract was not deployed")] ContractNotDeployed, + #[error("deployment transaction failed (receipt status 0): {0}")] + DeploymentFailed(alloy_primitives::TxHash), #[error(transparent)] RpcError(#[from] TransportError), } @@ -616,7 +917,9 @@ impl From for ContractDeploymentError { #[cfg(test)] mod tests { use super::*; + use alloy_json_abi::Constructor; use alloy_primitives::I256; + use foundry_cli::utils::parse_json; #[test] fn can_parse_create() { @@ -628,10 +931,48 @@ mod tests { "10", "--delay", "30", + "--license-type", + "13", + "--gas-estimate-multiplier", + "125", ]); assert_eq!(args.retry.retries, 10); assert_eq!(args.retry.delay, 30); + assert_eq!(args.license_type.as_deref(), Some("13")); + assert_eq!(args.gas_estimate_multiplier, Some(125)); + } + + #[test] + fn create_help_hides_auth() { + let help = ::command().render_long_help().to_string(); + assert!(!help.contains("--auth")); } + + #[test] + fn can_parse_create_license_type_spdx() { + let args: CreateArgs = CreateArgs::parse_from([ + "foundry-cli", + "src/Domains.sol:Domains", + "--verify", + "--license-type", + "MIT", + ]); + assert_eq!(args.license_type.as_deref(), Some("3")); + } + + #[test] + fn errors_on_invalid_create_license_type() { + let err = CreateArgs::try_parse_from([ + "foundry-cli", + "src/Domains.sol:Domains", + "--verify", + "--license-type", + "definitely-not-a-license", + ]) + .unwrap_err(); + assert!(err.to_string().contains("unsupported Etherscan license type")); + } + #[test] fn can_parse_chain_id() { let args: CreateArgs = CreateArgs::parse_from([ @@ -645,7 +986,7 @@ mod tests { "--chain-id", "9999", ]); - assert_eq!(args.chain_id(), Some(9999)); + assert_eq!(args.chain_id().map(|c| c.id()), Some(9999)); } #[test] @@ -656,8 +997,8 @@ mod tests { "--constructor-args", "Hello", ]); - let constructor: Constructor = serde_json::from_str(r#"{"type":"constructor","inputs":[{"name":"_name","type":"string","internalType":"string"}],"stateMutability":"nonpayable"}"#).unwrap(); - let params = args.parse_constructor_args(&constructor, &args.constructor_args).unwrap(); + let constructor: Constructor = parse_json(r#"{"type":"constructor","inputs":[{"name":"_name","type":"string","internalType":"string"}],"stateMutability":"nonpayable"}"#).unwrap(); + let params = parse_constructor_args(&constructor, &args.constructor_args).unwrap(); assert_eq!(params, vec![DynSolValue::String("Hello".to_string())]); } @@ -669,8 +1010,8 @@ mod tests { "--constructor-args", "[(1,2), (2,3), (3,4)]", ]); - let constructor: Constructor = serde_json::from_str(r#"{"type":"constructor","inputs":[{"name":"_points","type":"tuple[]","internalType":"struct Point[]","components":[{"name":"x","type":"uint256","internalType":"uint256"},{"name":"y","type":"uint256","internalType":"uint256"}]}],"stateMutability":"nonpayable"}"#).unwrap(); - let _params = args.parse_constructor_args(&constructor, &args.constructor_args).unwrap(); + let constructor: Constructor = parse_json(r#"{"type":"constructor","inputs":[{"name":"_points","type":"tuple[]","internalType":"struct Point[]","components":[{"name":"x","type":"uint256","internalType":"uint256"},{"name":"y","type":"uint256","internalType":"uint256"}]}],"stateMutability":"nonpayable"}"#).unwrap(); + let _params = parse_constructor_args(&constructor, &args.constructor_args).unwrap(); } #[test] @@ -681,8 +1022,8 @@ mod tests { "--constructor-args", "-5", ]); - let constructor: Constructor = serde_json::from_str(r#"{"type":"constructor","inputs":[{"name":"_name","type":"int256","internalType":"int256"}],"stateMutability":"nonpayable"}"#).unwrap(); - let params = args.parse_constructor_args(&constructor, &args.constructor_args).unwrap(); + let constructor: Constructor = parse_json(r#"{"type":"constructor","inputs":[{"name":"_name","type":"int256","internalType":"int256"}],"stateMutability":"nonpayable"}"#).unwrap(); + let params = parse_constructor_args(&constructor, &args.constructor_args).unwrap(); assert_eq!(params, vec![DynSolValue::Int(I256::unchecked_from(-5), 256)]); } } diff --git a/crates/forge/src/cmd/doc.rs b/crates/forge/src/cmd/doc.rs new file mode 100644 index 0000000000000..458b755a63bbf --- /dev/null +++ b/crates/forge/src/cmd/doc.rs @@ -0,0 +1,167 @@ +//! `forge doc` + +use crate::cmd::{install, watch::WatchArgs}; +use clap::{Parser, ValueHint}; +use eyre::Result; +use forge_doc::DocBuilder; +use foundry_cli::{ + opts::{GH_REPO_PREFIX_REGEX, configure_pcx_all_sources_with_status}, + utils::Git, +}; +use foundry_common::{compile::ProjectCompiler, shell}; +use foundry_config::{Config, load_config_with_root}; +use solar::{interface::Session, sema::Compiler}; +use std::{path::PathBuf, time::Instant}; + +#[derive(Clone, Debug, Parser)] +pub struct DocArgs { + /// The project's root path. + /// + /// By default root of the Git repository, if in one, + /// or the current working directory. + #[arg(long, value_hint = ValueHint::DirPath, value_name = "PATH")] + pub root: Option, + + /// The doc's output path. + /// + /// By default, it is the `docs/` directory in the project root. + /// The directory is created if it does not exist, and contains a + /// ready-to-use [vocs](https://vocs.dev) site scaffold alongside the + /// generated MDX pages. + #[arg(long, short, value_hint = ValueHint::DirPath, value_name = "PATH")] + out: Option, + + /// Document external library sources as well as the project's own sources. + #[arg(long, short)] + pub include_libraries: bool, + + /// Path to the `hardhat-deploy` or `forge-deploy` artifact directory. + /// + /// Leave blank to use the default (`/deployments`). + /// Omit the flag entirely to disable deployment address injection. + #[arg(long, value_name = "PATH", num_args(0..=1))] + pub deployments: Option>, + + #[command(flatten)] + pub watch: WatchArgs, + + /// Deprecated flag after the migration to Vocs. Previously, it was used to serve docs locally. + #[arg(long, hide = true)] + serve: bool, +} + +impl DocArgs { + pub async fn run(self) -> Result<()> { + if self.serve { + eyre::bail!( + "`--serve` has been removed. Generate the docs with `forge doc`, \ + then run `npm run dev` from the generated docs directory." + ); + } + let mut config = self.config()?; + + install::install_missing_dependencies(&mut config, || self.config())?; + + let root = &config.root; + let project = config.ephemeral_project()?; + let mut compiler = Compiler::new(Session::builder().with_stderr_emitter().build()); + let source_status = compiler.enter_mut(|compiler| -> Result<_> { + let mut pcx = compiler.parse(); + pcx.set_resolve_imports(true); + let status = + configure_pcx_all_sources_with_status(&mut pcx, &config, Some(&project), None)?; + pcx.parse(); + Ok(status) + })?; + + // Solar does not support Solidity versions prior to 0.8.0. Preserve support for old, + // mixed-version, and Solidity-free projects by using the existing compiler-backed parser. + let mut output = if source_status.is_fully_supported() { + None + } else { + let mut compile_project = config.solar_project()?; + compile_project.no_artifacts = true; + Some(ProjectCompiler::new().compile(&compile_project)?) + }; + + let mut doc_cfg = config.doc; + if let Some(out) = self.out.clone() { + doc_cfg.out = out; + } + + // Auto-detect repository URL from git remote. + if doc_cfg.repository.is_none() + && let Some(remote) = Git::new(root).remote_url("origin") + && let Some(captures) = GH_REPO_PREFIX_REGEX.captures(&remote) + { + let brand = captures.name("brand").unwrap().as_str(); + let tld = captures.name("tld").unwrap().as_str(); + let project_path = GH_REPO_PREFIX_REGEX.replace(&remote, ""); + doc_cfg.repository = + Some(format!("https://{brand}.{tld}/{}", project_path.trim_end_matches(".git"))); + } + + let git = Git::new(root); + let commit = doc_cfg.commit.clone().or_else(|| git.commit_hash(false, "HEAD").ok()); + // Best-effort branch detection for editLink. May yield "HEAD" when in + // detached HEAD state; treat that as unknown. + let branch = git.current_rev_branch(root).ok().map(|(_, b)| b).filter(|b| b != "HEAD"); + + let mut builder = DocBuilder::new( + root.clone(), + project.paths.sources, + project.paths.libraries, + self.include_libraries, + ); + builder.commit = commit; + builder.branch = branch; + builder.deployments = self.deployments; + builder.config = doc_cfg.clone(); + + let out_dir = if doc_cfg.out.is_absolute() { doc_cfg.out } else { root.join(&doc_cfg.out) }; + + if !shell::is_quiet() { + sh_println!("Generating documentation...")?; + } + let started = Instant::now(); + let compiler = if let Some(output) = &mut output { + output.parser_mut().solc_mut().compiler_mut() + } else { + &mut compiler + }; + let stats = builder.build(compiler)?; + let elapsed = started.elapsed(); + + if !shell::is_quiet() { + sh_println!( + "Generated {pages} page{ps} from {sources} source{ss} in {elapsed:.2?} (render {render:.2?}, site {site:.2?})", + pages = stats.pages, + ps = if stats.pages <= 1 { "" } else { "s" }, + sources = stats.sources, + ss = if stats.sources <= 1 { "" } else { "s" }, + elapsed = elapsed, + render = stats.render_elapsed, + site = stats.site_elapsed, + )?; + + // TODO: `--legacy-peer-deps` flag is required waku dependency conflict resolution. + // Remove this flag once vocs v2 and waku v1 are released. + sh_println!( + "\nDocumentation written to: {}\n\nTo preview:\n cd {}\n npm install --legacy-peer-deps\n npm run dev", + out_dir.display(), + out_dir.display(), + )?; + } + + Ok(()) + } + + /// Returns whether watch mode is enabled. + pub const fn is_watch(&self) -> bool { + self.watch.watch.is_some() + } + + pub fn config(&self) -> Result { + load_config_with_root(self.root.as_deref()) + } +} diff --git a/crates/forge/src/cmd/doc/mod.rs b/crates/forge/src/cmd/doc/mod.rs deleted file mode 100644 index a9f094beb48fc..0000000000000 --- a/crates/forge/src/cmd/doc/mod.rs +++ /dev/null @@ -1,142 +0,0 @@ -use super::watch::WatchArgs; -use clap::{Parser, ValueHint}; -use eyre::Result; -use forge_doc::{ - ContractInheritance, Deployments, DocBuilder, GitSource, InferInlineHyperlinks, Inheritdoc, -}; -use foundry_cli::opts::GH_REPO_PREFIX_REGEX; -use foundry_common::compile::ProjectCompiler; -use foundry_config::{Config, load_config_with_root}; -use std::{path::PathBuf, process::Command}; - -mod server; -use server::Server; - -#[derive(Clone, Debug, Parser)] -pub struct DocArgs { - /// The project's root path. - /// - /// By default root of the Git repository, if in one, - /// or the current working directory. - #[arg(long, value_hint = ValueHint::DirPath, value_name = "PATH")] - pub root: Option, - - /// The doc's output path. - /// - /// By default, it is the `docs/` in project root. - #[arg( - long, - short, - value_hint = ValueHint::DirPath, - value_name = "PATH", - )] - out: Option, - - /// Build the `mdbook` from generated files. - #[arg(long, short)] - build: bool, - - /// Serve the documentation. - #[arg(long, short)] - serve: bool, - - /// Open the documentation in a browser after serving. - #[arg(long, requires = "serve")] - open: bool, - - /// Hostname for serving documentation. - #[arg(long, requires = "serve")] - hostname: Option, - - #[command(flatten)] - pub watch: WatchArgs, - - /// Port for serving documentation. - #[arg(long, short, requires = "serve")] - port: Option, - - /// The relative path to the `hardhat-deploy` or `forge-deploy` artifact directory. Leave blank - /// for default. - #[arg(long)] - deployments: Option>, - - /// Whether to create docs for external libraries. - #[arg(long, short)] - include_libraries: bool, -} - -impl DocArgs { - pub async fn run(self) -> Result<()> { - let config = self.config()?; - let root = &config.root; - let project = config.project()?; - let compiler = ProjectCompiler::new().quiet(true); - let _output = compiler.compile(&project)?; - - let mut doc_config = config.doc; - if let Some(out) = self.out { - doc_config.out = out; - } - if doc_config.repository.is_none() { - // Attempt to read repo from git - if let Ok(output) = Command::new("git").args(["remote", "get-url", "origin"]).output() - && !output.stdout.is_empty() - { - let remote = String::from_utf8(output.stdout)?.trim().to_owned(); - if let Some(captures) = GH_REPO_PREFIX_REGEX.captures(&remote) { - let brand = captures.name("brand").unwrap().as_str(); - let tld = captures.name("tld").unwrap().as_str(); - let project = GH_REPO_PREFIX_REGEX.replace(&remote, ""); - doc_config.repository = - Some(format!("https://{brand}.{tld}/{}", project.trim_end_matches(".git"))); - } - } - } - - let commit = foundry_cli::utils::Git::new(root).commit_hash(false, "HEAD").ok(); - - let mut builder = DocBuilder::new( - root.clone(), - project.paths.sources, - project.paths.libraries, - self.include_libraries, - ) - .with_should_build(self.build) - .with_config(doc_config.clone()) - .with_fmt(config.fmt) - .with_preprocessor(ContractInheritance { include_libraries: self.include_libraries }) - .with_preprocessor(Inheritdoc::default()) - .with_preprocessor(InferInlineHyperlinks::default()) - .with_preprocessor(GitSource { - root: root.clone(), - commit, - repository: doc_config.repository.clone(), - }); - - // If deployment docgen is enabled, add the [Deployments] preprocessor - if let Some(deployments) = self.deployments { - builder = builder.with_preprocessor(Deployments { root: root.clone(), deployments }); - } - - builder.build()?; - - if self.serve { - Server::new(doc_config.out) - .with_hostname(self.hostname.unwrap_or_else(|| "localhost".into())) - .with_port(self.port.unwrap_or(3000)) - .open(self.open) - .serve()?; - } - - Ok(()) - } - - /// Returns whether watch mode is enabled - pub fn is_watch(&self) -> bool { - self.watch.watch.is_some() - } - - pub fn config(&self) -> Result { - load_config_with_root(self.root.as_deref()) - } -} diff --git a/crates/forge/src/cmd/doc/server.rs b/crates/forge/src/cmd/doc/server.rs deleted file mode 100644 index 7bffd9131fdb1..0000000000000 --- a/crates/forge/src/cmd/doc/server.rs +++ /dev/null @@ -1,107 +0,0 @@ -use axum::{Router, routing::get_service}; -use forge_doc::mdbook::{MDBook, utils::fs::get_404_output_file}; -use std::{ - io, - net::{SocketAddr, ToSocketAddrs}, - path::PathBuf, -}; -use tower_http::services::{ServeDir, ServeFile}; - -/// The HTTP endpoint for the websocket used to trigger reloads when a file changes. -const LIVE_RELOAD_ENDPOINT: &str = "/__livereload"; - -/// Basic mdbook server. Given a path, hostname and port, serves the mdbook. -#[derive(Debug)] -pub struct Server { - path: PathBuf, - hostname: String, - port: usize, - open: bool, -} - -impl Default for Server { - fn default() -> Self { - Self { path: PathBuf::default(), hostname: "localhost".to_owned(), port: 3000, open: false } - } -} - -impl Server { - /// Create a new instance. - pub fn new(path: PathBuf) -> Self { - Self { path, ..Default::default() } - } - - /// Set the host to serve on. - pub fn with_hostname(mut self, hostname: String) -> Self { - self.hostname = hostname; - self - } - - /// Set the port to serve on. - pub fn with_port(mut self, port: usize) -> Self { - self.port = port; - self - } - - /// Set whether to open the browser after serving. - pub fn open(mut self, open: bool) -> Self { - self.open = open; - self - } - - /// Serve the mdbook. - pub fn serve(self) -> eyre::Result<()> { - let mut book = - MDBook::load(&self.path).map_err(|err| eyre::eyre!("failed to load book: {err:?}"))?; - - let reload = LIVE_RELOAD_ENDPOINT.strip_prefix('/').unwrap(); - book.config.set("output.html.live-reload-endpoint", reload).unwrap(); - // Override site-url for local serving of the 404 file - book.config.set("output.html.site-url", "/").unwrap(); - - book.build().map_err(|err| eyre::eyre!("failed to build book: {err:?}"))?; - - let address = format!("{}:{}", self.hostname, self.port); - let sockaddr: SocketAddr = address - .to_socket_addrs()? - .next() - .ok_or_else(|| eyre::eyre!("no address found for {}", address))?; - let build_dir = book.build_dir_for("html"); - let input_404 = book - .config - .get("output.html.input-404") - .and_then(|v| v.as_str()) - .map(ToString::to_string); - let file_404 = get_404_output_file(&input_404); - - let serving_url = format!("http://{address}"); - sh_println!("Serving on: {serving_url}")?; - - let thread_handle = std::thread::spawn(move || serve(build_dir, sockaddr, &file_404)); - - if self.open { - open(serving_url); - } - - match thread_handle.join() { - Ok(r) => r.map_err(Into::into), - Err(e) => std::panic::resume_unwind(e), - } - } -} - -#[tokio::main] -async fn serve(build_dir: PathBuf, address: SocketAddr, file_404: &str) -> io::Result<()> { - let file_404 = build_dir.join(file_404); - let svc = ServeDir::new(build_dir).not_found_service(ServeFile::new(file_404)); - let app = Router::new().fallback_service(get_service(svc)); - let tcp_listener = tokio::net::TcpListener::bind(address).await?; - axum::serve(tcp_listener, app.into_make_service()).await -} - -fn open>(path: P) { - info!("Opening web browser"); - if let Err(e) = opener::open(path) { - error!("Error opening web browser: {}", e); - } -} diff --git a/crates/forge/src/cmd/eip712.rs b/crates/forge/src/cmd/eip712.rs index 3c72e0416f512..61bf245293076 100644 --- a/crates/forge/src/cmd/eip712.rs +++ b/crates/forge/src/cmd/eip712.rs @@ -1,20 +1,19 @@ use alloy_primitives::{B256, keccak256}; use clap::{Parser, ValueHint}; use eyre::Result; -use foundry_cli::opts::{BuildOpts, solar_pcx_from_build_opts}; +use foundry_cli::{opts::BuildOpts, utils::LoadConfig}; +use foundry_common::{compile::ProjectCompiler, shell}; use serde::Serialize; -use solar_parse::interface::Session; -use solar_sema::{ - GcxWrapper, Hir, +use solar::sema::{ + Gcx, Hir, hir::StructId, - thread_local::ThreadLocal, ty::{Ty, TyKind}, }; use std::{ collections::BTreeMap, fmt::{Display, Formatter, Result as FmtResult, Write}, + ops::ControlFlow, path::{Path, PathBuf}, - slice, }; foundry_config::impl_figment_convert!(Eip712Args, build); @@ -26,10 +25,6 @@ pub struct Eip712Args { #[arg(value_hint = ValueHint::FilePath, value_name = "PATH")] pub target_path: PathBuf, - /// Output in JSON format. - #[arg(long, help = "Output in JSON format")] - pub json: bool, - #[command(flatten)] build: BuildOpts, } @@ -52,23 +47,13 @@ impl Display for Eip712Output { impl Eip712Args { pub fn run(self) -> Result<()> { - let mut sess = Session::builder().with_stderr_emitter().build(); - sess.dcx = sess.dcx.set_flags(|flags| flags.track_diagnostics = false); - - sess.enter_parallel(|| -> Result<()> { - // Set up the parsing context with the project paths and sources. - let parsing_context = solar_pcx_from_build_opts( - &sess, - &self.build, - None, - Some(slice::from_ref(&self.target_path)), - )?; - - // Parse and resolve - let hir_arena = ThreadLocal::new(); - let Ok(Some(gcx)) = parsing_context.parse_and_lower(&hir_arena) else { - return Err(eyre::eyre!("failed parsing")); - }; + let config = self.build.load_config_with_dependencies()?; + let project = config.solar_project()?; + let mut output = ProjectCompiler::new().files([self.target_path]).compile(&project)?; + let compiler = output.parser_mut().solc_mut().compiler_mut(); + compiler.enter_mut(|compiler| -> Result<()> { + let Ok(ControlFlow::Continue(())) = compiler.lower_asts() else { return Ok(()) }; + let gcx = compiler.gcx(); let resolver = Resolver::new(gcx); let outputs = resolver @@ -83,18 +68,23 @@ impl Eip712Args { }) .collect::>(); - if self.json { + if shell::is_json() { sh_println!("{json}", json = serde_json::to_string_pretty(&outputs)?)?; } else { for output in &outputs { - sh_println!("{output}")?; + sh_eprintln!("{output}")?; } } Ok(()) })?; - eyre::ensure!(sess.dcx.has_errors().is_ok(), "errors occurred"); + // `compiler.sess()` inside of `ProjectCompileOutput` is built with `with_buffer_emitter`. + let diags = compiler.sess().dcx.emitted_diagnostics().unwrap(); + if compiler.sess().dcx.has_errors().is_err() { + eyre::bail!("{diags}"); + } + let _ = sh_eprint!("{diags}"); Ok(()) } @@ -103,19 +93,19 @@ impl Eip712Args { /// Generates the EIP-712 `encodeType` string for a given struct. /// /// Requires a reference to the source HIR. -pub struct Resolver<'hir> { - gcx: GcxWrapper<'hir>, +pub struct Resolver<'gcx> { + gcx: Gcx<'gcx>, } -impl<'hir> Resolver<'hir> { +impl<'gcx> Resolver<'gcx> { /// Constructs a new [`Resolver`] for the supplied [`Hir`] instance. - pub fn new(gcx: GcxWrapper<'hir>) -> Self { + pub const fn new(gcx: Gcx<'gcx>) -> Self { Self { gcx } } #[inline] - fn hir(&self) -> &'hir Hir<'hir> { - &self.gcx.get().hir + fn hir(&self) -> &'gcx Hir<'gcx> { + &self.gcx.hir } /// Returns the [`StructId`]s of every user-defined struct in source order. @@ -128,7 +118,7 @@ impl<'hir> Resolver<'hir> { let strukt = self.hir().strukt(id).name.as_str(); match self.hir().strukt(id).contract { Some(cid) => { - let full_name = self.gcx.get().contract_fully_qualified_name(cid).to_string(); + let full_name = self.gcx.contract_fully_qualified_name(cid).to_string(); let relevant = Path::new(&full_name) .file_name() .and_then(|s| s.to_str()) @@ -166,7 +156,7 @@ impl<'hir> Resolver<'hir> { for (idx, field_id) in def.fields.iter().enumerate() { let field = self.hir().variable(*field_id); - let ty = self.resolve_type(self.gcx.get().type_of_hir_ty(&field.ty), subtypes)?; + let ty = self.resolve_type(self.gcx.type_of_hir_ty(&field.ty), subtypes)?; write!(result, "{ty} {name}", name = field.name?.as_str()).ok()?; @@ -196,7 +186,7 @@ impl<'hir> Resolver<'hir> { fn resolve_type( &self, - ty: Ty<'hir>, + ty: Ty<'gcx>, subtypes: &mut BTreeMap, ) -> Option { let ty = ty.peel_refs(); @@ -215,7 +205,7 @@ impl<'hir> Resolver<'hir> { TyKind::Struct(id) => { let def = self.hir().strukt(id); let name = match subtypes.iter().find(|(_, cached_id)| id == **cached_id) { - Some((name, _)) => name.to_string(), + Some((name, _)) => name.clone(), None => { // Otherwise, assign new name let mut i = 0; @@ -229,7 +219,7 @@ impl<'hir> Resolver<'hir> { // Recursively resolve fields to populate subtypes for &field_id in def.fields { - let field_ty = self.gcx.get().type_of_item(field_id.into()); + let field_ty = self.gcx.type_of_item(field_id.into()); self.resolve_type(field_ty, subtypes)?; } name diff --git a/crates/forge/src/cmd/flatten.rs b/crates/forge/src/cmd/flatten.rs index 8bc5d44724619..fdf04dd3a7481 100644 --- a/crates/forge/src/cmd/flatten.rs +++ b/crates/forge/src/cmd/flatten.rs @@ -4,12 +4,7 @@ use foundry_cli::{ opts::{BuildOpts, ProjectPathOpts}, utils::LoadConfig, }; -use foundry_common::{compile::with_compilation_reporter, fs}; -use foundry_compilers::{ - compilers::solc::SolcLanguage, - error::SolcError, - flatten::{Flattener, FlattenerError}, -}; +use foundry_common::{flatten, fs, fs::canonicalize_path}; use std::path::PathBuf; /// CLI arguments for `forge flatten`. @@ -40,31 +35,17 @@ impl FlattenArgs { // flatten is a subset of `BuildArgs` so we can reuse that to get the config let build = BuildOpts { project_paths, ..Default::default() }; - let config = build.load_config()?; + let config = build.load_config_with_dependencies()?; let project = config.ephemeral_project()?; - let target_path = dunce::canonicalize(target_path)?; - - let flattener = - with_compilation_reporter(true, || Flattener::new(project.clone(), &target_path)); - - let flattened = match flattener { - Ok(flattener) => Ok(flattener.flatten()), - Err(FlattenerError::Compilation(_)) => { - // Fallback to the old flattening implementation if we couldn't compile the target - // successfully. This would be the case if the target has invalid - // syntax. (e.g. Solang) - project.paths.with_language::().flatten(&target_path) - } - Err(FlattenerError::Other(err)) => Err(err), - } - .map_err(|err: SolcError| eyre::eyre!("Failed to flatten: {err}"))?; + let target_path = canonicalize_path(target_path)?; + let flattened = flatten(project, &target_path)?; match output { Some(output) => { fs::create_dir_all(output.parent().unwrap())?; fs::write(&output, flattened)?; - sh_println!("Flattened file written at {}", output.display())?; + sh_status!("Flattened file written at {}", output.display())?; } None => sh_println!("{flattened}")?, }; diff --git a/crates/forge/src/cmd/fmt.rs b/crates/forge/src/cmd/fmt.rs index 60551841cc1d3..f4a349c0b0979 100644 --- a/crates/forge/src/cmd/fmt.rs +++ b/crates/forge/src/cmd/fmt.rs @@ -1,18 +1,23 @@ use super::watch::WatchArgs; use clap::{Parser, ValueHint}; -use eyre::{Context, Result}; -use forge_fmt::{format_to, parse}; +use eyre::Result; use foundry_cli::utils::{FoundryPathExt, LoadConfig}; -use foundry_common::fs; +use foundry_common::{errors::convert_solar_errors, fs}; use foundry_compilers::{compilers::solc::SolcLanguage, solc::SOLC_EXTENSIONS}; -use foundry_config::{filter::expand_globs, impl_figment_convert_basic}; +use foundry_config::{ + Config, filter::expand_globs, find_project_root, fmt::FormatterConfig, + impl_figment_convert_basic, +}; use rayon::prelude::*; use similar::{ChangeTag, TextDiff}; +use solar::sema::Compiler; use std::{ + collections::{HashMap, hash_map::Entry}, fmt::{self, Write}, io, - io::{Read, Write as _}, + io::Write as _, path::{Path, PathBuf}, + sync::Arc, }; use yansi::{Color, Paint, Style}; @@ -30,6 +35,10 @@ pub struct FmtArgs { #[arg(long, value_hint = ValueHint::DirPath, value_name = "PATH")] root: Option, + /// Use each input file's nearest `foundry.toml` for formatter settings. + #[arg(long, conflicts_with = "root")] + nearest: bool, + /// Run in 'check' mode. /// /// Exits with 0 if input is formatted correctly. @@ -49,34 +58,80 @@ impl_figment_convert_basic!(FmtArgs); impl FmtArgs { pub fn run(self) -> Result<()> { + if self.nearest { + for var in ["FOUNDRY_CONFIG", "FOUNDRY_ROOT", "DAPP_ROOT"] { + if std::env::var_os(var).is_some() { + eyre::bail!("`--nearest` cannot be used when `{var}` is set"); + } + } + } let config = self.load_config()?; + let cwd = std::env::current_dir()?; - // Expand ignore globs and canonicalize from the get go - let ignored = expand_globs(&config.root, config.fmt.ignore.iter())? + // Expand ignore globs and canonicalize from the get go. In nearest mode, ignores are + // expanded from the config nearest each input file instead. + let ignored = if self.nearest { + Vec::new() + } else { + expand_globs(&config.root, config.fmt.ignore.iter())? + .iter() + .flat_map(fs::canonicalize_path) + .collect::>() + }; + + // Expand lib globs separately - we only exclude these during discovery, not explicit paths + let libs = expand_globs(&config.root, config.libs.iter().filter_map(|p| p.to_str()))? .iter() - .flat_map(foundry_common::fs::canonicalize_path) + .flat_map(fs::canonicalize_path) .collect::>(); - let cwd = std::env::current_dir()?; - let input = match &self.paths[..] { - [] => { - // Retrieve the project paths, and filter out the ignored ones. - let project_paths: Vec = config - .project_paths::() - .input_files_iter() - .filter(|p| !(ignored.contains(p) || ignored.contains(&cwd.join(p)))) - .collect(); - Input::Paths(project_paths) - } - [one] if one == Path::new("-") => { - let mut s = String::new(); - io::stdin().read_to_string(&mut s).expect("Failed to read from stdin"); - Input::Stdin(s) + // Helper to check if a file path is under any of the given directories. + let is_under_dir = |file_path: &Path, dirs: &[PathBuf]| -> bool { + let check_against_dir = |dir: &PathBuf| { + file_path.starts_with(dir) + || cwd.join(file_path).starts_with(dir) + || fs::canonicalize_path(file_path).is_ok_and(|p| p.starts_with(dir)) + }; + + dirs.iter().any(check_against_dir) + }; + + // Directory walks follow symlinks, so only keep discovered files that resolve inside the + // project root or the configured/explicit directory. Explicit file paths are always + // included. + let root = fs::canonicalize_path(&config.root).unwrap_or_else(|_| config.root.clone()); + let is_contained = |file_path: &Path, dir: &Path| -> bool { + let Ok(resolved) = fs::canonicalize_path(file_path) else { return false }; + if resolved.starts_with(&root) || resolved.starts_with(dir) { + return true; } + let _ = sh_warn!( + "Skipping {}: it resolves outside of the project root.\n\ + HINT: Pass the path explicitly to format it: `forge fmt `", + file_path.display() + ); + false + }; + + let default_paths = self.paths.is_empty(); + let paths = if default_paths { + let paths = config.project_paths::(); + // Missing default directories are normal, and configured directories may be external. + [paths.sources, paths.tests, paths.scripts] + .into_iter() + .filter(|path| path.exists()) + .collect() + } else { + self.paths + }; + let mut input = match &paths[..] { + [one] if one == Path::new("-") => Input::Stdin, paths => { let mut inputs = Vec::with_capacity(paths.len()); for path in paths { - if !ignored.is_empty() + // Check if path is in ignored directories + if !self.nearest + && !ignored.is_empty() && ((path.is_absolute() && ignored.contains(path)) || ignored.contains(&cwd.join(path))) { @@ -84,12 +139,29 @@ impl FmtArgs { } if path.is_dir() { - inputs.extend(foundry_compilers::utils::source_files_iter( - path, - SOLC_EXTENSIONS, - )); + // Only explicitly requested library directories opt in to formatting libs. + let exclude_libs = default_paths || !is_under_dir(path, &libs); + let dir = fs::canonicalize_path(path)?; + inputs.extend( + foundry_compilers::utils::source_files_iter(path, SOLC_EXTENSIONS) + .filter(|p| { + !((!self.nearest + && (ignored.contains(p) + || ignored.contains(&cwd.join(p)) + || is_under_dir(p, &ignored))) + || (exclude_libs && is_under_dir(p, &libs))) + }) + .filter(|p| is_contained(p, &dir)), + ); } else if path.is_sol() { - inputs.push(path.to_path_buf()); + if default_paths + && ((!self.nearest && is_under_dir(path, &ignored)) + || is_under_dir(path, &libs)) + { + continue; + } + // CLI file paths are always included, even if in a lib. + inputs.push(path.clone()); } else { warn!("Cannot process path {}", path.display()); } @@ -98,60 +170,59 @@ impl FmtArgs { } }; - let format = |source: String, path: Option<&Path>| -> Result<_> { - let name = match path { - Some(path) => path.strip_prefix(&config.root).unwrap_or(path).display().to_string(), - None => "stdin".to_string(), + let nearest_fmt_configs = if self.nearest { + let Input::Paths(paths) = &mut input else { + eyre::bail!("`--nearest` cannot be used with stdin"); }; - - let parsed = parse(&source).wrap_err_with(|| { - format!("Failed to parse Solidity code for {name}. Leaving source unchanged.") - })?; - - if !parsed.invalid_inline_config_items.is_empty() { - for (loc, warning) in &parsed.invalid_inline_config_items { - let mut lines = source[..loc.start().min(source.len())].split('\n'); - let col = lines.next_back().unwrap().len() + 1; - let row = lines.count() + 1; - sh_warn!("[{}:{}:{}] {}", name, row, col, warning)?; - } - } - - let mut output = String::new(); - format_to(&mut output, parsed, config.fmt.clone()).unwrap(); - - solang_parser::parse(&output, 0).map_err(|diags| { - eyre::eyre!( - "Failed to construct valid Solidity code for {name}. Leaving source unchanged.\n\ - Debug info: {diags:?}\n\ - Formatted output:\n\n{output}" - ) - })?; - - let diff = TextDiff::from_lines(&source, &output); - let new_format = diff.ratio() < 1.0; - if self.check || path.is_none() { - if self.raw { - sh_print!("{output}")?; - } - - // If new format then compute diff summary. - if new_format { - return Ok(Some(format_diff_summary(&name, &diff))); - } - } else if let Some(path) = path { - // If new format then write it on disk. - if new_format { - fs::write(path, output)?; + let mut root_configs: HashMap, Vec)> = + HashMap::new(); + let mut path_configs = HashMap::new(); + let mut filtered_paths = Vec::with_capacity(paths.len()); + + for path in std::mem::take(paths) { + let path = fs::canonicalize_path(path)?; + let root = find_project_root(path.parent())?; + let (fmt_config, ignored) = match root_configs.entry(root) { + Entry::Occupied(entry) => entry.into_mut(), + Entry::Vacant(entry) => { + let nearest_config = Config::load_with_root(entry.key())?.sanitized(); + if entry.key() != &config.root { + for warning in &nearest_config.warnings { + let _ = sh_warn!("{warning}"); + } + } + let ignored = + expand_globs(&nearest_config.root, nearest_config.fmt.ignore.iter())? + .iter() + .flat_map(fs::canonicalize_path) + .collect(); + entry.insert((Arc::new(nearest_config.fmt), ignored)) + } + }; + // Both the input path and expanded ignore paths are canonicalized above, so a + // component-wise prefix check correctly covers ignored files and directories. + if ignored.iter().any(|ignored| path.starts_with(ignored)) { + continue; } + path_configs.insert(path.clone(), fmt_config.clone()); + filtered_paths.push(path); } - Ok(None) + *paths = filtered_paths; + Some(path_configs) + } else { + None }; - let diffs = match input { - Input::Stdin(source) => format(source, None).map(|diff| vec![diff]), - Input::Paths(paths) => { - if paths.is_empty() { + let mut compiler = Compiler::new( + solar::interface::Session::builder().with_buffer_emitter(Default::default()).build(), + ); + + // Parse, format, and check the diffs. + compiler.enter_mut(|compiler| { + let mut pcx = compiler.parse(); + pcx.set_resolve_imports(false); + match input { + Input::Paths(paths) if paths.is_empty() => { sh_warn!( "Nothing to format.\n\ HINT: If you are working outside of the project, \ @@ -159,53 +230,114 @@ impl FmtArgs { )?; return Ok(()); } - paths - .par_iter() - .map(|path| { - let source = fs::read_to_string(path)?; - format(source, Some(path)) - }) - .collect() + Input::Paths(paths) => _ = pcx.par_load_files(paths), + Input::Stdin => _ = pcx.load_stdin(), } - }?; + pcx.parse(); + + let gcx = compiler.gcx(); + let fmt_config = Arc::new(config.fmt); + let diffs: Vec = gcx + .sources + .raw + .par_iter() + .filter_map(|source_unit| { + let path = source_unit.file.name.as_real(); + let original = source_unit.file.src.as_str(); + let source_fmt_config = + if let Some(nearest_fmt_configs) = nearest_fmt_configs.as_ref() { + let Some(source_path) = path else { + return Some(Err(eyre::eyre!( + "could not resolve formatter config for stdin" + ))); + }; + let source_path = match fs::canonicalize_path(source_path) { + Ok(path) => path, + Err(err) => return Some(Err(err.into())), + }; + let Some(fmt_config) = nearest_fmt_configs.get(&source_path) else { + return Some(Err(eyre::eyre!( + "could not resolve formatter config for {}", + source_path.display() + ))); + }; + fmt_config.clone() + } else { + fmt_config.clone() + }; + let formatted = forge_fmt::format_ast(gcx, source_unit, source_fmt_config)?; + let from_stdin = path.is_none(); + + // Return formatted code when read from stdin and raw enabled. + // + if from_stdin && self.raw { + return Some(Ok(formatted)); + } + + if original == formatted { + return None; + } - let mut diffs = diffs.iter().flatten(); - if let Some(first) = diffs.next() { - // This branch is only reachable with stdin or --check + if self.check || from_stdin { + let summary = if self.raw { + formatted + } else { + let name = match path { + Some(path) => path + .strip_prefix(&config.root) + .unwrap_or(path) + .display() + .to_string(), + None => "stdin".to_string(), + }; + format_diff_summary(&name, &TextDiff::from_lines(original, &formatted)) + }; + Some(Ok(summary)) + } else if let Some(path) = path { + match fs::write(path, formatted) { + Ok(()) => {} + Err(e) => return Some(Err(e.into())), + } + let _ = sh_status!("Formatted {}", path.display()); + None + } else { + unreachable!() + } + }) + .collect::>()?; - if !self.raw { + if !diffs.is_empty() { + // This block is only reached in --check mode when files need formatting. let mut stdout = io::stdout().lock(); - let first = std::iter::once(first); - for (i, diff) in first.chain(diffs).enumerate() { + for (i, diff) in diffs.iter().enumerate() { if i > 0 { let _ = stdout.write_all(b"\n"); } let _ = stdout.write_all(diff.as_bytes()); } + if self.check { + std::process::exit(1); + } } - if self.check { - std::process::exit(1); - } - } - - Ok(()) + convert_solar_errors(compiler.dcx()) + }) } /// Returns whether `FmtArgs` was configured with `--watch` - pub fn is_watch(&self) -> bool { + pub const fn is_watch(&self) -> bool { self.watch.watch.is_some() } } -struct Line(Option); - #[derive(Debug)] enum Input { - Stdin(String), + Stdin, Paths(Vec), } +struct Line(Option); + impl fmt::Display for Line { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self.0 { @@ -215,7 +347,7 @@ impl fmt::Display for Line { } } -fn format_diff_summary<'a>(name: &str, diff: &'a TextDiff<'a, 'a, '_, str>) -> String { +fn format_diff_summary<'a>(name: &str, diff: &'a TextDiff<'a, 'a, str>) -> String { let cap = 128; let mut diff_summary = String::with_capacity(cap); diff --git a/crates/forge/src/cmd/fuzz.rs b/crates/forge/src/cmd/fuzz.rs new file mode 100644 index 0000000000000..1b2d80547b067 --- /dev/null +++ b/crates/forge/src/cmd/fuzz.rs @@ -0,0 +1,1659 @@ +use crate::{ + cmd::test::{CampaignArgs, FilterArgs, FuzzMinimizeReplaySession, ShowmapDomainArg, TestArgs}, + multi_runner::{ + FuzzMinimizeEdgeIndices, FuzzMinimizeMode, FuzzMinimizeObservation, ShowmapConfig, + }, + result::TestOutcome, +}; +use alloy_dyn_abi::{DynSolValue, JsonAbiExt}; +use alloy_json_abi::{Function, JsonAbi}; +use alloy_primitives::{Address, B256, Function as SolFunction, I256, Selector, U256}; +use clap::{Parser, Subcommand, ValueEnum, ValueHint}; +use eyre::{Context, Result, bail}; +use flate2::{Compression, write::GzEncoder}; +use foundry_cli::{ + opts::{BuildOpts, EvmArgs, GlobalArgs}, + utils::LoadConfig, +}; +use foundry_common::{ + fmt::format_tokens_raw, + fs, sh_println, sh_status, + shell::{OutputMode, Shell}, +}; +use foundry_config::{Config, filter::GlobMatcher}; +use foundry_evm::{ + executors::{CorpusDirEntry, ReplayObservation, ShowmapDomain, read_corpus_tree}, + fuzz::BasicTxDetails, +}; +use serde::Serialize; +use std::{ + collections::BTreeMap, + fs::OpenOptions, + io::{BufWriter, Write}, + path::{Path, PathBuf}, + pin::Pin, + time::{SystemTime, UNIX_EPOCH}, +}; +use tempfile::{Builder as TempDirBuilder, TempDir}; + +type FuzzOutcomeFuture = Pin>>>; + +/// Run and manage Forge fuzzing corpora. +#[derive(Clone, Debug, Parser)] +pub struct FuzzArgs { + #[command(subcommand)] + pub command: FuzzSubcommands, +} + +impl FuzzArgs { + pub fn run(self) -> FuzzOutcomeFuture { + match self.command { + FuzzSubcommands::Run(args) => { + let mut test = TestArgs::from_fuzz_run(args); + test.enable_fuzz_only_with_auto_fuzz_corpus(); + Box::pin(test.run()) + } + FuzzSubcommands::Replay(args) => Box::pin(args.run()), + FuzzSubcommands::Show(args) => Box::pin(async move { + args.run()?; + Ok(TestOutcome::empty(None, true)) + }), + FuzzSubcommands::Cmin(args) => Box::pin(async move { + args.run().await?; + Ok(TestOutcome::empty(None, true)) + }), + FuzzSubcommands::Tmin(args) => Box::pin(async move { + args.run().await?; + Ok(TestOutcome::empty(None, true)) + }), + } + } + + pub const fn is_junit(&self) -> bool { + match &self.command { + FuzzSubcommands::Run(args) => args.junit, + FuzzSubcommands::Replay(args) => args.is_junit(), + FuzzSubcommands::Show(_) | FuzzSubcommands::Cmin(_) | FuzzSubcommands::Tmin(_) => false, + } + } +} + +#[derive(Clone, Debug, Subcommand)] +#[allow(clippy::large_enum_variant)] +pub enum FuzzSubcommands { + /// Run only fuzz and invariant tests. + Run(FuzzRunArgs), + /// Replay persisted fuzz failures, or corpus entries with `--corpus-dir`. + Replay(FuzzReplayArgs), + /// Print persisted corpus entries. + Show(FuzzShowArgs), + /// Minimize a corpus by keeping entries that contribute new coverage. + Cmin(FuzzCminArgs), + /// Minimize one corpus entry while preserving its failure or coverage. + Tmin(FuzzTminArgs), +} + +/// Run only fuzz and invariant tests. +#[derive(Clone, Debug, Parser)] +pub struct FuzzRunArgs { + #[command(flatten)] + pub(crate) global: GlobalArgs, + + /// The contract file you want to test, it's a shortcut for --match-path. + #[arg(value_hint = ValueHint::FilePath)] + pub(crate) path: Option, + + #[command(flatten)] + pub(crate) filter: FilterArgs, + + #[command(flatten)] + pub(crate) campaign: CampaignArgs, + + #[command(flatten)] + pub(crate) evm: EvmArgs, + + #[command(flatten)] + pub(crate) build: BuildOpts, + + /// Output test results as JUnit XML report. + #[arg(long, conflicts_with_all = ["quiet", "json", "gas_report", "list", "show_progress"], help_heading = "Display options")] + pub(crate) junit: bool, + + /// Exit with code 0 even if a test fails. + #[arg(long, env = "FORGE_ALLOW_FAILURE")] + pub(crate) allow_failure: bool, + + /// Stop running tests after the first failure. + #[arg(long)] + pub(crate) fail_fast: bool, + + /// Re-run recorded test failures from last run. + /// If no failure recorded then regular test run is performed. + #[arg(long)] + pub(crate) rerun: bool, + + /// Show test execution progress. + #[arg(long, conflicts_with_all = ["quiet", "json"], help_heading = "Display options")] + pub(crate) show_progress: bool, + + /// The Etherscan (or equivalent) API key. + #[arg(long, env = "ETHERSCAN_API_KEY", value_name = "KEY")] + pub(crate) etherscan_api_key: Option, + + /// List fuzz and invariant tests instead of running them. + #[arg(long, short, conflicts_with_all = ["show_progress"], help_heading = "Display options")] + pub(crate) list: bool, + + /// Print a gas report. + #[arg(long, env = "FORGE_GAS_REPORT")] + pub(crate) gas_report: bool, + + /// Replay the persisted corpus and emit AFL-`afl-showmap`-style coverage + /// files at the given output directory. + #[arg( + long, + value_name = "DIR", + value_hint = ValueHint::DirPath, + help_heading = "Showmap replay", + conflicts_with_all = ["rerun", "fuzz_input_file", "gas_report"], + )] + pub(crate) showmap_out: Option, + + /// Emit one showmap file per corpus entry (default: one aggregated file per test). + #[arg(long, help_heading = "Showmap replay", requires = "showmap_out")] + pub(crate) showmap_per_input: bool, + + /// Coverage domain(s) to dump. + #[arg( + long, + value_enum, + default_value_t = ShowmapDomainArg::Evm, + help_heading = "Showmap replay", + requires = "showmap_out", + )] + pub(crate) showmap_domain: ShowmapDomainArg, + + /// Approach name (used as a subdirectory of `--showmap-out`). + #[arg( + long, + default_value = "replay", + help_heading = "Showmap replay", + requires = "showmap_out" + )] + pub(crate) showmap_approach: String, + + /// Trial identifier embedded in each showmap filename. + #[arg(long, help_heading = "Showmap replay", requires = "showmap_out")] + pub(crate) showmap_trial: Option, + + /// Override the corpus directory to replay. + #[arg( + long, + value_name = "PATH", + value_hint = ValueHint::DirPath, + help_heading = "Showmap replay", + requires = "showmap_out", + )] + pub(crate) showmap_corpus_dir: Option, + + /// File to rerun fuzz failures from. + #[arg(long, value_name = "PATH", value_hint = ValueHint::FilePath, conflicts_with = "list")] + pub(crate) fuzz_input_file: Option, +} + +/// Replay persisted fuzz failures, or corpus entries with `--corpus-dir`. +#[derive(Clone, Debug, Parser)] +pub struct FuzzReplayArgs { + #[command(flatten)] + run: FuzzRunArgs, +} + +impl FuzzReplayArgs { + async fn run(self) -> Result { + let corpus_dir = self.run.campaign.corpus_dir.clone(); + if corpus_dir.is_some() && self.run.fuzz_input_file.is_some() { + bail!("`--fuzz-input-file` cannot be combined with `--corpus-dir`"); + } + let mut test = TestArgs::from_fuzz_run(self.run); + if corpus_dir.is_none() { + test.enable_fuzz_failure_replay(); + return test.run().await; + } + + let replay_id = + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default(); + test.set_showmap_override(ShowmapConfig { + out_dir: std::env::temp_dir().join(format!("forge-fuzz-replay-{replay_id}")), + approach: "replay".to_string(), + trial: "replay".to_string(), + per_input: false, + domain: ShowmapDomain::Evm, + corpus_dir, + emit_files: false, + }); + test.run().await + } + + const fn is_junit(&self) -> bool { + self.run.junit + } +} + +#[derive(Clone, Copy, Debug, Default, ValueEnum)] +#[clap(rename_all = "lowercase")] +pub enum CorpusShowFormat { + #[default] + Human, + Json, +} + +/// Print persisted corpus entries. +#[derive(Clone, Debug, Parser)] +pub struct FuzzShowArgs { + /// Corpus directory or a single corpus file. + #[arg(value_name = "PATH", value_hint = ValueHint::AnyPath)] + corpus: PathBuf, + /// Output format. + #[arg(long, value_enum, default_value_t)] + format: CorpusShowFormat, + /// Maximum number of entries to print. + #[arg(long, value_name = "N")] + limit: Option, +} + +impl FuzzShowArgs { + fn run(&self) -> Result<()> { + let decoder = CorpusDecoder::load(); + let entries = read_entries(&self.corpus, self.limit, &decoder)?; + match self.format { + CorpusShowFormat::Human => { + for entry in entries { + sh_println!("{} ({} txs)", entry.path.display(), entry.sequence.len())?; + for (idx, tx) in entry.sequence.iter().enumerate() { + if let Some(decoded) = &tx.decoded { + let ambiguity = if decoded.ambiguous_contracts.is_empty() { + String::new() + } else { + format!(" ambiguous=[{}]", decoded.ambiguous_contracts.join(",")) + }; + sh_println!( + " {idx}: {} sender={} target={} value={}{}", + decoded.call, + tx.raw.sender, + tx.raw.call_details.target, + tx.raw + .call_details + .value + .map(|v| v.to_string()) + .unwrap_or_else(|| "0".to_string()), + ambiguity + )?; + } else { + sh_println!( + " {idx}: target={} sender={} calldata={} value={}", + tx.raw.call_details.target, + tx.raw.sender, + tx.raw.call_details.calldata, + tx.raw + .call_details + .value + .map(|v| v.to_string()) + .unwrap_or_else(|| "0".to_string()) + )?; + } + } + } + } + CorpusShowFormat::Json => sh_println!("{}", serde_json::to_string_pretty(&entries)?)?, + } + Ok(()) + } +} + +/// Minimize a corpus by keeping entries that contribute new coverage. +#[derive(Clone, Debug, Parser)] +pub struct FuzzCminArgs { + #[command(flatten)] + test: FuzzMinimizeTestArgs, + /// Input corpus directory. + #[arg(value_name = "CORPUS_DIR", value_hint = ValueHint::DirPath)] + corpus_dir: PathBuf, + /// Output corpus directory. + #[arg(long = "corpus-out", value_name = "DIR", value_hint = ValueHint::DirPath)] + out: PathBuf, +} + +impl FuzzCminArgs { + async fn run(self) -> Result<()> { + if cmin_out_exists(&self.out) { + bail!("output corpus directory already exists: {}", self.out.display()); + } + + let staging_out = temporary_cmin_out(&self.out)?; + let summary = self.run_to(staging_out.path()).await?; + let staging_path = staging_out.keep(); + + if cmin_out_exists(&self.out) { + bail!( + "output corpus directory already exists: {}; minimized corpus remains staged at {}", + self.out.display(), + staging_path.display() + ); + } + + std::fs::rename(&staging_path, &self.out).with_context(|| { + format!( + "failed to rename minimized corpus {} to {}", + staging_path.display(), + self.out.display() + ) + })?; + + sh_println!( + "minimized corpus: kept {}/{} entries in {}", + summary.kept, + summary.total, + self.out.display() + )?; + if summary.skipped > 0 { + sh_status!( + "skipped {} entries or txs that could not be read or replayed", + summary.skipped + )?; + } + Ok(()) + } + + async fn run_to(&self, out_dir: &Path) -> Result { + let session = self.test.clone().prepare_session(&self.corpus_dir).await?; + let mut kept = 0usize; + let mut total = 0usize; + let mut skipped_entries = 0usize; + let mut unreadable = 0usize; + let mut empty = 0usize; + let mut unmatched_txs = 0usize; + let mut rejected_txs = 0usize; + let mut failed_entries = 0usize; + let mut failed_replays = 0usize; + let mut replayed = 0usize; + let mut cumulative = BTreeMap::::new(); + let evm_edge_indices = FuzzMinimizeEdgeIndices::default(); + + for entry in read_corpus_entries(&self.corpus_dir)? { + total += 1; + let sequence = entry + .read_tx_seq() + .with_context(|| format!("failed to read corpus entry {}", entry.path.display())); + let Ok(sequence) = sequence else { + skipped_entries += 1; + unreadable += 1; + continue; + }; + if sequence.is_empty() { + skipped_entries += 1; + empty += 1; + continue; + } + let observations = replay_candidate( + &session, + evm_edge_indices.clone(), + sequence, + FuzzMinimizeMode::Cmin, + )?; + let mut entry_improved = false; + let mut entry_failed = false; + let mut entry_failed_replays = 0usize; + let mut entry_replayed = 0usize; + let mut entry_unmatched_txs = 0usize; + let mut entry_rejected_txs = 0usize; + for FuzzMinimizeObservation { target, observation } in observations { + if observation.has_non_predicate_failure() { + entry_failed = true; + entry_failed_replays += observation.replayed; + continue; + } + entry_replayed += observation.replayed; + entry_unmatched_txs = entry_unmatched_txs.max(observation.unmatched); + entry_rejected_txs = entry_rejected_txs.max(observation.skipped); + let cumulative = cumulative.entry(target).or_default(); + entry_improved |= merge_new_edges(cumulative, &observation); + } + if entry_replayed > 0 { + replayed += entry_replayed; + } else if entry_failed { + skipped_entries += 1; + failed_entries += 1; + failed_replays += entry_failed_replays; + } else { + unmatched_txs += entry_unmatched_txs; + rejected_txs += entry_rejected_txs; + } + if !entry_improved { + continue; + } + + let out = if self.corpus_dir.is_file() { + out_dir.join(entry.path.file_name().unwrap_or_default()) + } else { + let relative = entry.path.strip_prefix(&self.corpus_dir).with_context(|| { + format!( + "corpus entry {} is not under {}", + entry.path.display(), + self.corpus_dir.display() + ) + })?; + out_dir.join(relative) + }; + if let Some(parent) = out.parent() { + fs::create_dir_all(parent)?; + } + std::fs::copy(&entry.path, &out).with_context(|| { + format!("failed to copy {} to {}", entry.path.display(), out.display()) + })?; + kept += 1; + } + + if total > 0 && replayed == 0 { + let corpus = self.corpus_dir.display(); + if unreadable == total { + bail!( + "replayed 0 transactions from {corpus}; all {unreadable} corpus entries could not be read" + ); + } + if failed_entries > 0 { + bail!( + "replayed 0 successful transactions from {corpus}; {failed_entries} corpus \ + entries failed during replay after {failed_replays} replayed transactions" + ); + } + if unmatched_txs > 0 { + bail!( + "replayed 0 transactions from {corpus}; {unmatched_txs} transactions did not match \ + the test; check that --mc/--mt and replay-critical options match the corpus \ + entries" + ); + } + if rejected_txs > 0 { + bail!( + "replayed 0 transactions from {corpus}; {rejected_txs} transactions were rejected \ + by vm.assume or vm.skip" + ); + } + if empty == total.saturating_sub(unreadable) { + bail!( + "replayed 0 transactions from {corpus}; corpus entries were empty{}", + if unreadable > 0 { + format!(" or unreadable ({unreadable} unreadable)") + } else { + String::new() + } + ); + } + bail!( + "replayed 0 transactions from {corpus}; {unreadable} unreadable entries, {empty} \ + empty entries" + ); + } + + Ok(CminSummary { kept, total, skipped: skipped_entries + unmatched_txs + rejected_txs }) + } +} + +fn cmin_out_exists(out: &Path) -> bool { + std::fs::symlink_metadata(out).is_ok() +} + +struct CminSummary { + kept: usize, + total: usize, + skipped: usize, +} + +/// Minimize one corpus entry while preserving its failure or coverage. +#[derive(Clone, Debug, Parser)] +pub struct FuzzTminArgs { + #[command(flatten)] + test: FuzzMinimizeTestArgs, + /// Input corpus file or directory. + #[arg(value_name = "INPUT", value_hint = ValueHint::AnyPath)] + input: PathBuf, + /// Output corpus file or directory. + #[arg(long = "corpus-out", value_name = "PATH", value_hint = ValueHint::AnyPath)] + out: PathBuf, + /// Maximum candidate replays to attempt per corpus entry. + #[arg(long, default_value_t = 5000, value_name = "N")] + max_attempts: usize, +} + +impl FuzzTminArgs { + async fn run(self) -> Result<()> { + if self.max_attempts == 0 { + bail!("--max-attempts must be greater than 0"); + } + if self.input.is_dir() { self.run_dir().await } else { self.run_file().await } + } + + async fn run_file(self) -> Result<()> { + validate_tmin_output_path(&self.out)?; + + let mut sequence = read_single_sequence(&self.input)?; + if sequence.is_empty() { + bail!("corpus entry {} is empty", self.input.display()); + } + + let before_txs = sequence.len(); + let decoder_args = self.test.clone(); + let corpus_root = self + .input + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let session = self.test.prepare_session(corpus_root).await?; + let decoder = decoder_args.decoder(); + let attempts = + minimize_entry(&session, &decoder, &self.input, &mut sequence, self.max_attempts)?; + write_sequence_create_new(&self.out, &sequence)?; + + sh_println!( + "minimized entry: {before_txs} txs -> {} txs in {}", + sequence.len(), + self.out.display() + )?; + sh_status!("attempted {attempts} candidate replays")?; + Ok(()) + } + + async fn run_dir(self) -> Result<()> { + if cmin_out_exists(&self.out) { + bail!("output corpus directory already exists: {}", self.out.display()); + } + + let entries = read_corpus_entries(&self.input)?; + let staging_out = temporary_cmin_out(&self.out)?; + let decoder_args = self.test.clone(); + let session = self.test.prepare_session(&self.input).await?; + let decoder = decoder_args.decoder(); + let mut total_entries = 0usize; + let mut before_txs = 0usize; + let mut after_txs = 0usize; + let mut attempts = 0usize; + let mut skipped_entries = 0usize; + + for entry in entries { + let sequence = entry + .read_tx_seq() + .with_context(|| format!("failed to read corpus entry {}", entry.path.display())); + let Ok(mut sequence) = sequence else { + skipped_entries += 1; + continue; + }; + if sequence.is_empty() { + skipped_entries += 1; + continue; + } + before_txs += sequence.len(); + attempts += + minimize_entry(&session, &decoder, &entry.path, &mut sequence, self.max_attempts)?; + after_txs += sequence.len(); + + let relative = entry.path.strip_prefix(&self.input).with_context(|| { + format!( + "corpus entry {} is not under {}", + entry.path.display(), + self.input.display() + ) + })?; + write_sequence_create_new(&staging_out.path().join(relative), &sequence)?; + total_entries += 1; + } + if total_entries == 0 { + bail!("no readable non-empty corpus entries found under {}", self.input.display()); + } + + let staging_path = staging_out.keep(); + if cmin_out_exists(&self.out) { + bail!( + "output corpus directory already exists: {}; minimized corpus remains staged at {}", + self.out.display(), + staging_path.display() + ); + } + std::fs::rename(&staging_path, &self.out).with_context(|| { + format!( + "failed to rename minimized corpus {} to {}", + staging_path.display(), + self.out.display() + ) + })?; + + sh_println!( + "minimized corpus: {total_entries} entries, {before_txs} txs -> {after_txs} txs in {}", + self.out.display() + )?; + sh_status!("attempted {attempts} candidate replays")?; + if skipped_entries > 0 { + sh_status!("skipped {skipped_entries} entries that could not be read or were empty")?; + } + Ok(()) + } +} + +fn minimize_entry( + session: &FuzzMinimizeReplaySession, + decoder: &CorpusDecoder, + input: &Path, + sequence: &mut Vec, + max_attempts: usize, +) -> Result { + let evm_edge_indices = FuzzMinimizeEdgeIndices::default(); + let baseline = replay_baseline(session, evm_edge_indices.clone(), sequence.clone()) + .with_context(|| format!("failed to replay baseline corpus entry {}", input.display()))?; + if baseline.requirements.is_empty() { + bail!( + "replayed 0 transactions from {}; check that --mc/--mt and replay-critical options match the corpus entry", + input.display() + ); + } + if !baseline.has_failure && !baseline.has_coverage { + bail!("baseline replay for {} produced no failure or edge coverage", input.display()); + } + + let mut ctx = MinimizeContext::new(session, evm_edge_indices, baseline, max_attempts); + minimize_sequence(&mut ctx, sequence, decoder)?; + Ok(ctx.attempts) +} + +struct ReplayBaseline { + requirements: BTreeMap, + has_failure: bool, + has_coverage: bool, +} + +fn replay_baseline( + session: &FuzzMinimizeReplaySession, + evm_edge_indices: FuzzMinimizeEdgeIndices, + sequence: Vec, +) -> Result { + let observations = + replay_candidate(session, evm_edge_indices, sequence, FuzzMinimizeMode::Tmin)?; + let mut requirements = BTreeMap::new(); + let mut has_failure = false; + let mut has_coverage = false; + for FuzzMinimizeObservation { target, observation } in observations { + let observation_has_failure = !observation.failures.is_empty(); + let observation_has_coverage = has_edges(&observation); + if observation.replayed == 0 && !observation_has_failure && !observation_has_coverage { + continue; + } + has_failure |= observation_has_failure; + has_coverage |= observation_has_coverage; + requirements.insert(target, observation); + } + Ok(ReplayBaseline { requirements, has_failure, has_coverage }) +} + +struct MinimizeContext<'a> { + session: &'a FuzzMinimizeReplaySession, + evm_edge_indices: FuzzMinimizeEdgeIndices, + baseline: ReplayBaseline, + max_attempts: usize, + attempts: usize, +} + +impl<'a> MinimizeContext<'a> { + const fn new( + session: &'a FuzzMinimizeReplaySession, + evm_edge_indices: FuzzMinimizeEdgeIndices, + baseline: ReplayBaseline, + max_attempts: usize, + ) -> Self { + Self { session, evm_edge_indices, baseline, max_attempts, attempts: 0 } + } + + const fn at_budget(&self) -> bool { + self.attempts >= self.max_attempts + } + + const fn remaining_attempts(&self) -> usize { + self.max_attempts.saturating_sub(self.attempts) + } + + fn accepts(&mut self, candidate: &[BasicTxDetails]) -> Result { + if self.at_budget() { + return Ok(false); + } + self.attempts += 1; + let observations = replay_candidate( + self.session, + self.evm_edge_indices.clone(), + candidate.to_vec(), + FuzzMinimizeMode::Tmin, + )?; + let observations = observations + .into_iter() + .map(|obs| (obs.target, obs.observation)) + .collect::>(); + + if has_new_active_targets(&observations, &self.baseline.requirements) { + return Ok(false); + } + + for (target, baseline) in &self.baseline.requirements { + let Some(candidate) = observations.get(target) else { + return Ok(false); + }; + if !baseline.failures.is_empty() { + if candidate.failures != baseline.failures { + return Ok(false); + } + } else if !candidate.failures.is_empty() || !same_edge_hit_sets(candidate, baseline) { + return Ok(false); + } + } + + Ok(true) + } +} + +fn has_new_active_targets( + observations: &BTreeMap, + baseline: &BTreeMap, +) -> bool { + observations.iter().any(|(target, observation)| { + !baseline.contains_key(target) + && (!observation.failures.is_empty() + || observation.replayed > 0 + || has_edges(observation)) + }) +} + +fn minimize_sequence( + ctx: &mut MinimizeContext<'_>, + sequence: &mut Vec, + decoder: &CorpusDecoder, +) -> Result<()> { + let mut idx = 0; + while idx < sequence.len() && !ctx.at_budget() { + let removed = sequence.remove(idx); + if ctx.accepts(sequence)? { + continue; + } + sequence.insert(idx, removed); + idx += 1; + } + + let mut idx = 0; + while idx < sequence.len() && !ctx.at_budget() { + let restore = sequence[idx].clone(); + cleanup_metadata(&mut sequence[idx]); + if !ctx.accepts(sequence)? { + sequence[idx] = restore; + } + idx += 1; + } + + let mut tx_idx = 0; + while tx_idx < sequence.len() && !ctx.at_budget() { + loop { + let candidates = abi_calldata_candidates( + sequence[tx_idx].call_details.calldata.as_ref(), + decoder, + ctx.remaining_attempts(), + ); + if candidates.is_empty() { + break; + }; + + let mut accepted = false; + for calldata in candidates { + if calldata.len() > sequence[tx_idx].call_details.calldata.len() { + continue; + } + let restore = + std::mem::replace(&mut sequence[tx_idx].call_details.calldata, calldata.into()); + if ctx.accepts(sequence)? { + accepted = true; + break; + } + sequence[tx_idx].call_details.calldata = restore; + if ctx.at_budget() { + break; + } + } + if !accepted || ctx.at_budget() { + break; + } + } + tx_idx += 1; + } + + Ok(()) +} + +fn temporary_cmin_out(out: &Path) -> Result { + let parent = + out.parent().filter(|parent| !parent.as_os_str().is_empty()).unwrap_or(Path::new(".")); + let filename = + out.file_name().ok_or_else(|| eyre::eyre!("missing output corpus directory name"))?; + let prefix = format!(".{}.tmp-", filename.to_string_lossy()); + TempDirBuilder::new().prefix(&prefix).tempdir_in(parent).with_context(|| { + format!("failed to create temporary output directory for {}", out.display()) + }) +} + +fn read_single_sequence(path: &Path) -> Result> { + let entries = read_corpus_tree(path)?; + let [entry] = entries.as_slice() else { + bail!("expected one corpus entry at {}, found {}", path.display(), entries.len()); + }; + entry + .read_tx_seq() + .with_context(|| format!("failed to read corpus entry {}", entry.path.display())) +} + +fn validate_tmin_output_path(path: &Path) -> Result<()> { + if std::fs::symlink_metadata(path).is_ok() { + bail!("output corpus file already exists: {}", path.display()); + } + Ok(()) +} + +fn write_sequence_create_new(path: &Path, sequence: &[BasicTxDetails]) -> Result<()> { + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + fs::create_dir_all(parent)?; + } + + let file = OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .with_context(|| format!("failed to create output corpus file {}", path.display()))?; + if is_gzip_path(path) { + let writer = BufWriter::new(file); + let mut encoder = GzEncoder::new(writer, Compression::default()); + serde_json::to_writer(&mut encoder, &sequence) + .with_context(|| format!("failed to write output corpus file {}", path.display()))?; + let mut writer = encoder + .finish() + .with_context(|| format!("failed to finish output corpus file {}", path.display()))?; + writer + .flush() + .with_context(|| format!("failed to flush output corpus file {}", path.display()))?; + } else { + let mut writer = BufWriter::new(file); + serde_json::to_writer(&mut writer, &sequence) + .with_context(|| format!("failed to write output corpus file {}", path.display()))?; + writer + .flush() + .with_context(|| format!("failed to flush output corpus file {}", path.display()))?; + } + Ok(()) +} + +fn is_gzip_path(path: &Path) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("gz")) +} + +fn has_edges(observation: &ReplayObservation) -> bool { + observation.evm_edges.iter().any(|&edge| edge != 0) + || observation.sancov_edges.iter().any(|&edge| edge != 0) +} + +fn same_edge_hit_sets(candidate: &ReplayObservation, baseline: &ReplayObservation) -> bool { + same_edge_hit_set(&candidate.evm_edges, &baseline.evm_edges) + && same_edge_hit_set(&candidate.sancov_edges, &baseline.sancov_edges) +} + +fn same_edge_hit_set(candidate: &[u8], baseline: &[u8]) -> bool { + let len = candidate.len().max(baseline.len()); + (0..len).all(|idx| { + let candidate_hit = candidate.get(idx).copied().unwrap_or_default() != 0; + let baseline_hit = baseline.get(idx).copied().unwrap_or_default() != 0; + candidate_hit == baseline_hit + }) +} + +fn cleanup_metadata(tx: &mut BasicTxDetails) { + if tx.warp == Some(U256::ZERO) { + tx.warp = None; + } + if tx.roll == Some(U256::ZERO) { + tx.roll = None; + } + if tx.call_details.value == Some(U256::ZERO) { + tx.call_details.value = None; + } +} + +fn abi_calldata_candidates(calldata: &[u8], decoder: &CorpusDecoder, limit: usize) -> Vec> { + if limit == 0 { + return Vec::new(); + } + let Some((function, args)) = decoder.unique_decodable_function(calldata) else { + return Vec::new(); + }; + + let mut candidates = Vec::new(); + for arg_idx in 0..args.len() { + for value in value_candidates(&args[arg_idx], limit.saturating_sub(candidates.len())) { + let mut candidate_args = args.clone(); + candidate_args[arg_idx] = value; + let Ok(encoded) = function.abi_encode_input(&candidate_args) else { + continue; + }; + if encoded.as_slice() != calldata && !candidates.contains(&encoded) { + candidates.push(encoded); + if candidates.len() >= limit { + return candidates; + } + } + } + } + candidates +} + +fn value_candidates(value: &DynSolValue, limit: usize) -> Vec { + let mut candidates = Vec::new(); + push_scalar_value_candidates(value, &mut candidates, limit); + push_compound_value_candidates(value, &mut candidates, limit); + candidates.into_iter().filter(|candidate| candidate != value).collect() +} + +fn push_candidate(candidates: &mut Vec, limit: usize, candidate: DynSolValue) -> bool { + if candidates.len() >= limit { + return false; + } + candidates.push(candidate); + true +} + +fn push_scalar_value_candidates( + value: &DynSolValue, + candidates: &mut Vec, + limit: usize, +) { + match value { + DynSolValue::Bool(_) => { + push_candidate(candidates, limit, DynSolValue::Bool(false)); + } + DynSolValue::Uint(value, bits) => { + if !value.is_zero() { + push_candidate(candidates, limit, DynSolValue::Uint(U256::ZERO, *bits)); + } + if *value > U256::ONE { + push_candidate(candidates, limit, DynSolValue::Uint(U256::ONE, *bits)); + } + } + DynSolValue::Int(value, bits) => { + if *value != I256::ZERO { + push_candidate(candidates, limit, DynSolValue::Int(I256::ZERO, *bits)); + } + if *value != I256::ZERO + && *value != I256::from_raw(U256::ONE) + && *value != I256::MINUS_ONE + { + push_candidate( + candidates, + limit, + DynSolValue::Int(I256::from_raw(U256::ONE), *bits), + ); + } + if *value != I256::ZERO + && *value != I256::from_raw(U256::ONE) + && *value != I256::MINUS_ONE + { + push_candidate(candidates, limit, DynSolValue::Int(I256::MINUS_ONE, *bits)); + } + } + DynSolValue::Address(_) => { + push_candidate(candidates, limit, DynSolValue::Address(Address::ZERO)); + } + DynSolValue::FixedBytes(_, size) => { + push_candidate(candidates, limit, DynSolValue::FixedBytes(B256::ZERO, *size)); + } + DynSolValue::Function(_) => { + push_candidate(candidates, limit, DynSolValue::Function(SolFunction::ZERO)); + } + DynSolValue::Bytes(bytes) => { + push_candidate(candidates, limit, DynSolValue::Bytes(Vec::new())); + if bytes.len() > 1 { + push_candidate( + candidates, + limit, + DynSolValue::Bytes(bytes[..bytes.len() / 2].to_vec()), + ); + } + } + DynSolValue::String(string) => { + push_candidate(candidates, limit, DynSolValue::String(String::new())); + if string.len() > 1 { + let mut half = string.len() / 2; + while half > 0 && !string.is_char_boundary(half) { + half -= 1; + } + push_candidate(candidates, limit, DynSolValue::String(string[..half].to_string())); + } + } + DynSolValue::Array(_) + | DynSolValue::FixedArray(_) + | DynSolValue::Tuple(_) + | DynSolValue::CustomStruct { .. } => {} + } +} + +fn push_compound_value_candidates( + value: &DynSolValue, + candidates: &mut Vec, + limit: usize, +) { + match value { + DynSolValue::Array(values) => { + push_candidate(candidates, limit, DynSolValue::Array(Vec::new())); + if values.len() > 1 { + push_candidate( + candidates, + limit, + DynSolValue::Array(values[..values.len() / 2].to_vec()), + ); + } + push_child_value_candidates(values, candidates, limit, |values| { + DynSolValue::Array(values.to_vec()) + }); + } + DynSolValue::FixedArray(values) => { + push_child_value_candidates(values, candidates, limit, |values| { + DynSolValue::FixedArray(values.to_vec()) + }); + } + DynSolValue::Tuple(values) => { + push_child_value_candidates(values, candidates, limit, |values| { + DynSolValue::Tuple(values.to_vec()) + }); + } + DynSolValue::CustomStruct { name, prop_names, tuple } => { + push_child_value_candidates(tuple, candidates, limit, |values| { + DynSolValue::CustomStruct { + name: name.clone(), + prop_names: prop_names.clone(), + tuple: values.to_vec(), + } + }); + } + DynSolValue::Bool(_) + | DynSolValue::Uint(_, _) + | DynSolValue::Int(_, _) + | DynSolValue::Address(_) + | DynSolValue::FixedBytes(_, _) + | DynSolValue::Function(_) + | DynSolValue::Bytes(_) + | DynSolValue::String(_) => {} + } +} + +fn push_child_value_candidates( + values: &[DynSolValue], + candidates: &mut Vec, + limit: usize, + rebuild: impl Fn(&[DynSolValue]) -> DynSolValue, +) { + for idx in 0..values.len() { + if candidates.len() >= limit { + return; + } + for child in value_candidates(&values[idx], limit.saturating_sub(candidates.len())) { + let mut values = values.to_vec(); + values[idx] = child; + candidates.push(rebuild(&values)); + if candidates.len() >= limit { + return; + } + } + } +} + +#[derive(Serialize)] +pub struct DisplayCorpusEntry { + path: PathBuf, + sequence: Vec, +} + +#[derive(Serialize)] +struct DisplayTxDetails { + #[serde(flatten)] + raw: BasicTxDetails, + #[serde(skip_serializing_if = "Option::is_none")] + decoded: Option, +} + +#[derive(Serialize)] +struct DecodedCall { + #[serde(skip_serializing_if = "Option::is_none")] + contract: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + ambiguous_contracts: Vec, + signature: String, + args: Vec, + call: String, +} + +struct IndexedFunction { + contract: String, + function: Function, +} + +#[derive(Default)] +struct CorpusDecoder { + functions: BTreeMap>, +} + +impl CorpusDecoder { + fn load() -> Self { + Config::load().ok().map(|config| Self::from_artifacts(&config.out)).unwrap_or_default() + } + + fn from_artifacts(out: &Path) -> Self { + let mut this = Self::default(); + if !out.is_dir() { + return this; + } + + for path in fs::json_files(out) { + let Ok(artifact) = fs::read_json_file::(&path) else { + continue; + }; + let Some(abi_value) = artifact.get("abi").cloned() else { + continue; + }; + let Ok(abi) = serde_json::from_value::(abi_value) else { + continue; + }; + let contract = + path.file_stem().and_then(|name| name.to_str()).unwrap_or("").to_string(); + + for function in abi.functions().cloned() { + this.functions + .entry(function.selector()) + .or_default() + .push(IndexedFunction { contract: contract.clone(), function }); + } + } + + this + } + + fn decode(&self, tx: &BasicTxDetails) -> Option { + let calldata = tx.call_details.calldata.as_ref(); + if calldata.len() < 4 { + return None; + } + + let selector = Selector::from_slice(&calldata[..4]); + let functions = self.functions.get(&selector)?; + let (function, decoded_args) = self.unique_decodable_function(calldata)?; + let args = format_tokens_raw(&decoded_args).collect::>(); + let signature = function.signature(); + + let mut contracts = functions + .iter() + .filter(|indexed| indexed.function.signature() == signature.as_str()) + .map(|indexed| indexed.contract.clone()) + .collect::>(); + contracts.sort(); + contracts.dedup(); + + let function_call = format!("{}({})", function.name, args.join(", ")); + if contracts.len() == 1 { + let contract = contracts.pop()?; + Some(DecodedCall { + call: format!("{contract}.{function_call}"), + contract: Some(contract), + ambiguous_contracts: Vec::new(), + signature, + args, + }) + } else { + Some(DecodedCall { + call: function_call, + contract: None, + ambiguous_contracts: contracts, + signature, + args, + }) + } + } + + fn unique_decodable_function(&self, calldata: &[u8]) -> Option<(&Function, Vec)> { + if calldata.len() < 4 { + return None; + } + + let selector = Selector::from_slice(&calldata[..4]); + let mut unique = None; + for (function, decoded_args) in + self.functions.get(&selector)?.iter().filter_map(|indexed| { + let decoded_args = indexed.function.abi_decode_input(&calldata[4..]).ok()?; + Some((&indexed.function, decoded_args)) + }) + { + let signature = function.signature(); + match &unique { + Some((existing, _, _)) if existing == &signature => {} + Some(_) => return None, + None => unique = Some((signature, function, decoded_args)), + } + } + unique.map(|(_, function, decoded_args)| (function, decoded_args)) + } +} + +fn read_entries( + path: &Path, + limit: Option, + decoder: &CorpusDecoder, +) -> Result> { + let iter = read_corpus_entries(path)?.into_iter().take(limit.unwrap_or(usize::MAX)); + iter.map(|entry| { + let sequence = entry + .read_tx_seq() + .with_context(|| format!("failed to read corpus entry {}", entry.path.display()))?; + let sequence = sequence + .into_iter() + .map(|raw| { + let decoded = decoder.decode(&raw); + DisplayTxDetails { raw, decoded } + }) + .collect(); + Ok(DisplayCorpusEntry { path: entry.path, sequence }) + }) + .collect() +} + +fn read_corpus_entries(path: &Path) -> Result> { + let entries = read_corpus_tree(path)?; + if entries.is_empty() { + bail!("no corpus entries found under {}", path.display()); + } + Ok(entries) +} + +#[derive(Clone, Debug, Parser)] +struct FuzzMinimizeTestArgs { + #[command(flatten)] + global: GlobalArgs, + #[command(flatten)] + filter: FilterArgs, + #[command(flatten)] + evm: EvmArgs, + #[command(flatten)] + build: BuildOpts, +} + +impl FuzzMinimizeTestArgs { + async fn prepare_session(self, corpus_dir: &Path) -> Result { + let mut test = TestArgs::parse_from(["test", "-q"]); + test.set_fuzz_minimize_replay_options(self.global, self.evm, self.build, self.filter); + test.enable_fuzz_only(); + prepare_minimize_session(&mut test, corpus_dir).await + } + + fn decoder(&self) -> CorpusDecoder { + self.build + .load_config_no_warnings() + .ok() + .map(|config| CorpusDecoder::from_artifacts(&config.out)) + .unwrap_or_default() + } +} + +struct QuietShellGuard { + previous: OutputMode, +} + +impl QuietShellGuard { + fn new() -> Self { + let mut shell = Shell::get(); + let previous = shell.output_mode(); + shell.set_output_mode(OutputMode::Quiet); + Self { previous } + } +} + +impl Drop for QuietShellGuard { + fn drop(&mut self) { + Shell::get().set_output_mode(self.previous); + } +} + +async fn prepare_minimize_session( + test: &mut TestArgs, + corpus_dir: &Path, +) -> Result { + let _quiet = QuietShellGuard::new(); + test.prepare_fuzz_minimize_replay(corpus_dir).await +} + +fn replay_candidate( + session: &FuzzMinimizeReplaySession, + evm_edge_indices: FuzzMinimizeEdgeIndices, + sequence: Vec, + mode: FuzzMinimizeMode, +) -> Result> { + let _quiet = QuietShellGuard::new(); + session.replay(sequence, evm_edge_indices, mode) +} + +fn merge_new_edges(cumulative: &mut ReplayObservation, observation: &ReplayObservation) -> bool { + merge_new_edge_vec(&mut cumulative.evm_edges, &observation.evm_edges) + | merge_new_edge_vec(&mut cumulative.sancov_edges, &observation.sancov_edges) +} + +fn merge_new_edge_vec(cumulative: &mut Vec, candidate: &[u8]) -> bool { + if cumulative.len() < candidate.len() { + cumulative.resize(candidate.len(), 0); + } + let mut improved = false; + for (cumulative, &candidate) in cumulative.iter_mut().zip(candidate) { + if *cumulative < candidate { + *cumulative = candidate; + improved = true; + } + } + improved +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::CommandFactory; + use foundry_evm::executors::ReplayFailure; + + #[test] + fn fuzz_args_clap_shape_is_valid() { + FuzzArgs::command().debug_assert(); + } + + #[test] + fn fuzz_run_rejects_fuzz_worker() { + assert!(FuzzArgs::try_parse_from(["foundry-cli", "run", "--fuzz-worker", "1"]).is_err()); + } + + #[test] + fn fuzz_run_rejects_fuzz_run() { + assert!(FuzzArgs::try_parse_from(["foundry-cli", "run", "--fuzz-run", "1"]).is_err()); + } + + fn decoder_with_functions(functions: Vec) -> CorpusDecoder { + let mut decoder = CorpusDecoder::default(); + for function in functions { + decoder + .functions + .entry(function.selector()) + .or_default() + .push(IndexedFunction { contract: "Target".to_string(), function }); + } + decoder + } + + fn candidate_args(function: &Function, candidates: Vec>) -> Vec> { + candidates + .into_iter() + .map(|calldata| function.abi_decode_input(&calldata[4..]).unwrap()) + .collect() + } + + #[test] + fn merge_new_edges_keeps_sancov_hit_count_bucket_increases() { + let mut cumulative = ReplayObservation { sancov_edges: vec![0, 1], ..Default::default() }; + let candidate = ReplayObservation { sancov_edges: vec![0, 8], ..Default::default() }; + + assert!(merge_new_edges(&mut cumulative, &candidate)); + assert_eq!(cumulative.sancov_edges, vec![0, 8]); + } + + #[test] + fn same_edge_hit_sets_allow_hit_count_bucket_changes() { + let baseline = ReplayObservation { evm_edges: vec![0, 8], ..Default::default() }; + let candidate = ReplayObservation { evm_edges: vec![0, 1], ..Default::default() }; + + assert!(same_edge_hit_sets(&candidate, &baseline)); + } + + #[test] + fn same_edge_hit_sets_treat_missing_trailing_buckets_as_zero() { + let baseline = ReplayObservation { sancov_edges: vec![0, 0], ..Default::default() }; + let candidate = ReplayObservation { sancov_edges: vec![0], ..Default::default() }; + + assert!(same_edge_hit_sets(&candidate, &baseline)); + } + + #[test] + fn has_new_active_targets_rejects_candidate_only_activity() { + let baseline = BTreeMap::from([( + "A".to_string(), + ReplayObservation { evm_edges: vec![1], ..Default::default() }, + )]); + let observations = BTreeMap::from([ + ("A".to_string(), ReplayObservation { evm_edges: vec![1], ..Default::default() }), + ("B".to_string(), ReplayObservation { evm_edges: vec![1], ..Default::default() }), + ]); + + assert!(has_new_active_targets(&observations, &baseline)); + } + + #[test] + fn has_new_active_targets_rejects_candidate_only_failures() { + let baseline = BTreeMap::from([( + "A".to_string(), + ReplayObservation { evm_edges: vec![1], ..Default::default() }, + )]); + let observations = BTreeMap::from([ + ("A".to_string(), ReplayObservation { evm_edges: vec![1], ..Default::default() }), + ( + "B".to_string(), + ReplayObservation { + failures: std::collections::BTreeSet::from([ReplayFailure::AfterInvariant]), + ..Default::default() + }, + ), + ]); + + assert!(has_new_active_targets(&observations, &baseline)); + } + + #[test] + fn has_new_active_targets_rejects_candidate_only_replayed_transactions() { + let baseline = BTreeMap::from([( + "A".to_string(), + ReplayObservation { evm_edges: vec![1], ..Default::default() }, + )]); + let observations = BTreeMap::from([ + ("A".to_string(), ReplayObservation { evm_edges: vec![1], ..Default::default() }), + ("B".to_string(), ReplayObservation { replayed: 1, ..Default::default() }), + ]); + + assert!(has_new_active_targets(&observations, &baseline)); + } + + #[test] + fn has_new_active_targets_allows_inactive_candidate_only_targets() { + let baseline = BTreeMap::from([( + "A".to_string(), + ReplayObservation { evm_edges: vec![1], ..Default::default() }, + )]); + let observations = BTreeMap::from([ + ("A".to_string(), ReplayObservation { evm_edges: vec![1], ..Default::default() }), + ("B".to_string(), ReplayObservation::default()), + ]); + + assert!(!has_new_active_targets(&observations, &baseline)); + } + + #[test] + fn abi_calldata_candidates_simplify_scalar_values() { + let function = Function::parse("target(uint256,int256,bool,address)").unwrap(); + let decoder = decoder_with_functions(vec![function.clone()]); + let calldata = function + .abi_encode_input(&[ + DynSolValue::Uint(U256::from(42), 256), + DynSolValue::Int(I256::from_raw(U256::from(42)), 256), + DynSolValue::Bool(true), + DynSolValue::Address(Address::repeat_byte(0x11)), + ]) + .unwrap(); + + let candidates = + candidate_args(&function, abi_calldata_candidates(&calldata, &decoder, usize::MAX)); + + assert!(candidates.iter().any(|args| args[0] == DynSolValue::Uint(U256::ZERO, 256))); + assert!(candidates.iter().any(|args| args[0] == DynSolValue::Uint(U256::ONE, 256))); + assert!(candidates.iter().any(|args| args[1] == DynSolValue::Int(I256::ZERO, 256))); + assert!( + candidates + .iter() + .any(|args| { args[1] == DynSolValue::Int(I256::from_raw(U256::ONE), 256) }) + ); + assert!(candidates.iter().any(|args| args[1] == DynSolValue::Int(I256::MINUS_ONE, 256))); + assert!(candidates.iter().any(|args| args[2] == DynSolValue::Bool(false))); + assert!(candidates.iter().any(|args| args[3] == DynSolValue::Address(Address::ZERO))); + } + + #[test] + fn abi_calldata_candidates_do_not_oscillate_signed_one_values() { + let function = Function::parse("target(int256)").unwrap(); + let decoder = decoder_with_functions(vec![function.clone()]); + let one = DynSolValue::Int(I256::from_raw(U256::ONE), 256); + let minus_one = DynSolValue::Int(I256::MINUS_ONE, 256); + + let one_calldata = function.abi_encode_input(&[one]).unwrap(); + let one_candidates = + candidate_args(&function, abi_calldata_candidates(&one_calldata, &decoder, usize::MAX)); + assert_eq!(one_candidates, vec![vec![DynSolValue::Int(I256::ZERO, 256)]]); + + let minus_one_calldata = function.abi_encode_input(&[minus_one]).unwrap(); + let minus_one_candidates = candidate_args( + &function, + abi_calldata_candidates(&minus_one_calldata, &decoder, usize::MAX), + ); + assert_eq!(minus_one_candidates, vec![vec![DynSolValue::Int(I256::ZERO, 256)]]); + } + + #[test] + fn abi_calldata_candidates_shrink_dynamic_values() { + let function = Function::parse("target(bytes,string,uint256[])").unwrap(); + let decoder = decoder_with_functions(vec![function.clone()]); + let calldata = function + .abi_encode_input(&[ + DynSolValue::Bytes(vec![1, 2, 3, 4]), + DynSolValue::String("abcdef".to_string()), + DynSolValue::Array(vec![ + DynSolValue::Uint(U256::from(10), 256), + DynSolValue::Uint(U256::from(11), 256), + DynSolValue::Uint(U256::from(12), 256), + DynSolValue::Uint(U256::from(13), 256), + ]), + ]) + .unwrap(); + + let candidates = + candidate_args(&function, abi_calldata_candidates(&calldata, &decoder, usize::MAX)); + + assert!(candidates.iter().any(|args| args[0] == DynSolValue::Bytes(Vec::new()))); + assert!(candidates.iter().any(|args| args[0] == DynSolValue::Bytes(vec![1, 2]))); + assert!(candidates.iter().any(|args| args[1] == DynSolValue::String(String::new()))); + assert!(candidates.iter().any(|args| args[1] == DynSolValue::String("abc".to_string()))); + assert!(candidates.iter().any(|args| args[2] == DynSolValue::Array(Vec::new()))); + assert!(candidates.iter().any(|args| { + args[2] + == DynSolValue::Array(vec![ + DynSolValue::Uint(U256::from(10), 256), + DynSolValue::Uint(U256::from(11), 256), + ]) + })); + } + + #[test] + fn abi_calldata_candidates_simplify_tuple_children() { + let function = Function::parse("target((uint256,bool,address))").unwrap(); + let decoder = decoder_with_functions(vec![function.clone()]); + let calldata = function + .abi_encode_input(&[DynSolValue::Tuple(vec![ + DynSolValue::Uint(U256::from(42), 256), + DynSolValue::Bool(true), + DynSolValue::Address(Address::repeat_byte(0x11)), + ])]) + .unwrap(); + + let candidates = + candidate_args(&function, abi_calldata_candidates(&calldata, &decoder, usize::MAX)); + + assert!(candidates.iter().any(|args| { + args[0] + == DynSolValue::Tuple(vec![ + DynSolValue::Uint(U256::ZERO, 256), + DynSolValue::Bool(true), + DynSolValue::Address(Address::repeat_byte(0x11)), + ]) + })); + assert!(candidates.iter().any(|args| { + args[0] + == DynSolValue::Tuple(vec![ + DynSolValue::Uint(U256::from(42), 256), + DynSolValue::Bool(false), + DynSolValue::Address(Address::repeat_byte(0x11)), + ]) + })); + assert!(candidates.iter().any(|args| { + args[0] + == DynSolValue::Tuple(vec![ + DynSolValue::Uint(U256::from(42), 256), + DynSolValue::Bool(true), + DynSolValue::Address(Address::ZERO), + ]) + })); + } + + #[test] + fn abi_calldata_candidates_skip_ambiguous_or_undecodable_calldata() { + let function = Function::parse("target(uint256)").unwrap(); + let other = Function::parse("other(uint256)").unwrap(); + let calldata = + function.abi_encode_input(&[DynSolValue::Uint(U256::from(42), 256)]).unwrap(); + + let mut ambiguous = CorpusDecoder::default(); + ambiguous.functions.entry(function.selector()).or_default().extend([ + IndexedFunction { contract: "Target".to_string(), function: function.clone() }, + IndexedFunction { contract: "Other".to_string(), function: other }, + ]); + assert!(abi_calldata_candidates(&calldata, &ambiguous, usize::MAX).is_empty()); + + let decoder = decoder_with_functions(vec![function]); + assert!( + abi_calldata_candidates(&calldata[..calldata.len() - 1], &decoder, usize::MAX) + .is_empty() + ); + } + + #[test] + fn abi_calldata_candidates_accept_same_signature_with_metadata_differences() { + let function = + Function::parse("function target(uint256 value) external returns (uint256)").unwrap(); + let same_signature = Function::parse("function target(uint256 value) view").unwrap(); + let calldata = + function.abi_encode_input(&[DynSolValue::Uint(U256::from(42), 256)]).unwrap(); + + let mut decoder = CorpusDecoder::default(); + decoder.functions.entry(function.selector()).or_default().extend([ + IndexedFunction { contract: "WithReturn".to_string(), function: function.clone() }, + IndexedFunction { contract: "NoReturn".to_string(), function: same_signature }, + ]); + + let candidates = + candidate_args(&function, abi_calldata_candidates(&calldata, &decoder, usize::MAX)); + + assert!(candidates.iter().any(|args| args[0] == DynSolValue::Uint(U256::ZERO, 256))); + } +} diff --git a/crates/forge/src/cmd/geiger.rs b/crates/forge/src/cmd/geiger.rs index 2caed87583e99..49570dcf01d06 100644 --- a/crates/forge/src/cmd/geiger.rs +++ b/crates/forge/src/cmd/geiger.rs @@ -1,89 +1,38 @@ use clap::{Parser, ValueHint}; -use eyre::{Result, WrapErr}; -use foundry_cli::utils::LoadConfig; -use foundry_compilers::{Graph, resolver::parse::SolData}; -use foundry_config::{Config, impl_figment_convert_basic}; -use itertools::Itertools; -use solar_parse::{ast, ast::visit::Visit, interface::Session}; -use std::{ - ops::ControlFlow, - path::{Path, PathBuf}, -}; +use eyre::Result; +use foundry_cli::opts::BuildOpts; +use foundry_config::{DenyLevel, impl_figment_convert}; +use std::path::PathBuf; /// CLI arguments for `forge geiger`. +/// +/// This command is an alias for `forge lint --only-lint unsafe-cheatcode` +/// and detects usage of unsafe cheat codes in a project and its dependencies. #[derive(Clone, Debug, Parser)] pub struct GeigerArgs { /// Paths to files or directories to detect. #[arg( - conflicts_with = "root", value_hint = ValueHint::FilePath, value_name = "PATH", - num_args(1..), + num_args(0..) )] paths: Vec, - /// The project's root path. - /// - /// By default root of the Git repository, if in one, - /// or the current working directory. - #[arg(long, value_hint = ValueHint::DirPath, value_name = "PATH")] - root: Option, - - /// Globs to ignore. - #[arg( - long, - value_hint = ValueHint::FilePath, - value_name = "PATH", - num_args(1..), - )] - ignore: Vec, - #[arg(long, hide = true)] check: bool, + #[arg(long, hide = true)] full: bool, + + #[command(flatten)] + build: BuildOpts, } -impl_figment_convert_basic!(GeigerArgs); +impl_figment_convert!(GeigerArgs, build); impl GeigerArgs { - pub fn sources(&self, config: &Config) -> Result> { - let cwd = std::env::current_dir()?; - - let mut sources: Vec = { - if self.paths.is_empty() { - let paths = config.project_paths(); - Graph::::resolve(&paths)? - .files() - .keys() - .filter(|f| !paths.has_library_ancestor(f)) - .cloned() - .collect() - } else { - self.paths - .iter() - .flat_map(|path| foundry_common::fs::files_with_ext(path, "sol")) - .unique() - .collect() - } - }; - - sources.retain_mut(|path| { - let abs_path = if path.is_absolute() { path.clone() } else { cwd.join(&path) }; - *path = abs_path.strip_prefix(&cwd).unwrap_or(&abs_path).to_path_buf(); - !self.ignore.iter().any(|ignore| { - if ignore.is_absolute() { - abs_path.starts_with(ignore) - } else { - abs_path.starts_with(cwd.join(ignore)) - } - }) - }); - - Ok(sources) - } - - pub fn run(self) -> Result { + pub async fn run(self) -> Result<()> { + // Deprecated flags warnings if self.check { sh_warn!("`--check` is deprecated as it's now the default behavior\n")?; } @@ -91,72 +40,21 @@ impl GeigerArgs { sh_warn!("`--full` is deprecated as reports are not generated anymore\n")?; } - let config = self.load_config()?; - let sources = self.sources(&config).wrap_err("Failed to resolve files")?; - - if config.ffi { - sh_warn!("FFI enabled\n")?; - } - - let mut sess = Session::builder().with_stderr_emitter().build(); - sess.dcx = sess.dcx.set_flags(|flags| flags.track_diagnostics = false); - let unsafe_cheatcodes = &[ - "ffi".to_string(), - "readFile".to_string(), - "readLine".to_string(), - "writeFile".to_string(), - "writeLine".to_string(), - "removeFile".to_string(), - "closeFile".to_string(), - "setEnv".to_string(), - "deriveKey".to_string(), - ]; - Ok(sess - .enter(|| sources.iter().map(|file| lint_file(&sess, unsafe_cheatcodes, file)).sum())) - } -} - -fn lint_file(sess: &Session, unsafe_cheatcodes: &[String], path: &Path) -> usize { - try_lint_file(sess, unsafe_cheatcodes, path).unwrap_or(0) -} - -fn try_lint_file( - sess: &Session, - unsafe_cheatcodes: &[String], - path: &Path, -) -> solar_parse::interface::Result { - let arena = solar_parse::ast::Arena::new(); - let mut parser = solar_parse::Parser::from_file(sess, &arena, path)?; - let ast = parser.parse_file().map_err(|e| e.emit())?; - let mut visitor = Visitor::new(sess, unsafe_cheatcodes); - let _ = visitor.visit_source_unit(&ast); - Ok(visitor.count) -} - -struct Visitor<'a> { - sess: &'a Session, - count: usize, - unsafe_cheatcodes: &'a [String], -} - -impl<'a> Visitor<'a> { - fn new(sess: &'a Session, unsafe_cheatcodes: &'a [String]) -> Self { - Self { sess, count: 0, unsafe_cheatcodes } - } -} - -impl<'ast> Visit<'ast> for Visitor<'_> { - type BreakValue = solar_parse::interface::data_structures::Never; + sh_warn!( + "`forge geiger` is deprecated, as it is just an alias for `forge lint --only-lint unsafe-cheatcode`\n" + )?; + + // Convert geiger command to lint command with specific lint filter + let mut lint_args = crate::cmd::lint::LintArgs { + paths: self.paths, + severity: None, + lint: Some(vec!["unsafe-cheatcode".to_string()]), + report_unused_suppressions: false, + build: self.build, + }; + lint_args.build.deny = Some(DenyLevel::Notes); - fn visit_expr(&mut self, expr: &'ast ast::Expr<'ast>) -> ControlFlow { - if let ast::ExprKind::Call(lhs, _args) = &expr.kind - && let ast::ExprKind::Member(_lhs, member) = &lhs.kind - && self.unsafe_cheatcodes.iter().any(|c| c.as_str() == member.as_str()) - { - let msg = format!("usage of unsafe cheatcode `vm.{member}`"); - self.sess.dcx.err(msg).span(member.span).emit(); - self.count += 1; - } - self.walk_expr(expr) + // Run the lint command with the geiger-specific configuration + lint_args.run().await } } diff --git a/crates/forge/src/cmd/generate/mod.rs b/crates/forge/src/cmd/generate/mod.rs deleted file mode 100644 index f1f69d8385618..0000000000000 --- a/crates/forge/src/cmd/generate/mod.rs +++ /dev/null @@ -1,70 +0,0 @@ -use clap::{Parser, Subcommand}; -use eyre::Result; -use foundry_common::fs; -use std::path::Path; -use yansi::Paint; - -/// CLI arguments for `forge generate`. -#[derive(Debug, Parser)] -pub struct GenerateArgs { - #[command(subcommand)] - pub sub: GenerateSubcommands, -} - -#[derive(Debug, Subcommand)] -pub enum GenerateSubcommands { - /// Scaffolds test file for given contract. - Test(GenerateTestArgs), -} - -#[derive(Debug, Parser)] -pub struct GenerateTestArgs { - /// Contract name for test generation. - #[arg(long, short, value_name = "CONTRACT_NAME")] - pub contract_name: String, -} - -impl GenerateTestArgs { - pub fn run(self) -> Result<()> { - let contract_name = format_identifier(&self.contract_name, true); - let instance_name = format_identifier(&self.contract_name, false); - - // Create the test file content. - let test_content = include_str!("../../../assets/generated/TestTemplate.t.sol"); - let test_content = test_content - .replace("{contract_name}", &contract_name) - .replace("{instance_name}", &instance_name); - - // Create the test directory if it doesn't exist. - fs::create_dir_all("test")?; - - // Define the test file path - let test_file_path = Path::new("test").join(format!("{contract_name}.t.sol")); - - // Write the test content to the test file. - fs::write(&test_file_path, test_content)?; - - sh_println!("{} test file: {}", "Generated".green(), test_file_path.to_str().unwrap())?; - Ok(()) - } -} - -/// Utility function to convert an identifier to pascal or camel case. -fn format_identifier(input: &str, is_pascal_case: bool) -> String { - let mut result = String::new(); - let mut capitalize_next = is_pascal_case; - - for word in input.split_whitespace() { - if !word.is_empty() { - let (first, rest) = word.split_at(1); - let formatted_word = if capitalize_next { - format!("{}{}", first.to_uppercase(), rest) - } else { - format!("{}{}", first.to_lowercase(), rest) - }; - capitalize_next = true; - result.push_str(&formatted_word); - } - } - result -} diff --git a/crates/forge/src/cmd/init.rs b/crates/forge/src/cmd/init.rs index 7866f14d63908..800925c972fb8 100644 --- a/crates/forge/src/cmd/init.rs +++ b/crates/forge/src/cmd/init.rs @@ -2,9 +2,10 @@ use super::install::DependencyInstallOpts; use clap::{Parser, ValueHint}; use eyre::Result; use foundry_cli::utils::Git; -use foundry_common::fs; +use foundry_common::{fs, fs::canonicalize_path}; use foundry_compilers::artifacts::remappings::Remapping; use foundry_config::Config; +use foundry_evm_networks::NetworkVariant; use std::path::{Path, PathBuf}; use yansi::Paint; @@ -37,20 +38,59 @@ pub struct InitArgs { #[arg(long, conflicts_with = "template")] pub vscode: bool, + /// Initialize a Vyper project template. + #[arg(long, conflicts_with = "template")] + pub vyper: bool, + + /// Initialize a project template for the specified network in Foundry. + #[arg(long, short, num_args = 1, value_name = "NETWORK", conflicts_with_all = &["vyper", "template"])] + pub network: Option, + + /// Use the parent git repository instead of initializing a new one. + /// Only valid if the target is in a git repository. + #[arg(long, conflicts_with = "template")] + pub use_parent_git: bool, + + /// Do not create example contracts (Counter.sol, Counter.t.sol, Counter.s.sol). + #[arg(long, conflicts_with = "template")] + pub empty: bool, + + /// Do not create an initial commit. + /// + /// This is a noop flag kept for backwards compatibility, as `forge init` no longer commits by + /// default. Use `--commit` to opt into creating a commit. + #[arg(long, hide = true)] + pub no_commit: bool, + #[command(flatten)] pub install: DependencyInstallOpts, } impl InitArgs { - pub fn run(self) -> Result<()> { - let Self { root, template, branch, install, offline, force, vscode } = self; + pub async fn run(self) -> Result<()> { + let Self { + root, + template, + branch, + install, + offline, + force, + vscode, + use_parent_git, + vyper, + network, + empty, + no_commit: _, + } = self; let DependencyInstallOpts { shallow, no_git, commit } = install; + let tempo = network.is_some_and(|network| network.is_tempo()); + // create the root dir if it does not exist if !root.exists() { fs::create_dir_all(&root)?; } - let root = dunce::canonicalize(root)?; + let root = canonicalize_path(root)?; let git = Git::new(&root).shallow(shallow); // if a template is provided, then this command initializes a git repo, @@ -64,7 +104,7 @@ impl InitArgs { } else { "https://github.com/".to_string() + &template }; - sh_println!("Initializing {} from {}...", root.display(), template)?; + sh_status!("Initializing {} from {}...", root.display(), template)?; // initialize the git repository git.init()?; @@ -106,7 +146,7 @@ impl InitArgs { git.ensure_clean()?; } - sh_println!("Initializing {}...", root.display())?; + sh_status!("Initializing {}...", root.display())?; // make the dirs let src = root.join("src"); @@ -118,22 +158,90 @@ impl InitArgs { let script = root.join("script"); fs::create_dir_all(&script)?; - // write the contract file - let contract_path = src.join("Counter.sol"); - fs::write(contract_path, include_str!("../../assets/CounterTemplate.sol"))?; - // write the tests - let contract_path = test.join("Counter.t.sol"); - fs::write(contract_path, include_str!("../../assets/CounterTemplate.t.sol"))?; - // write the script - let contract_path = script.join("Counter.s.sol"); - fs::write(contract_path, include_str!("../../assets/CounterTemplate.s.sol"))?; - // Write the default README file + // Only create example contracts if not disabled + if !empty { + if vyper { + // write the contract file + let contract_path = src.join("Counter.vy"); + fs::write( + contract_path, + include_str!("../../assets/vyper/CounterTemplate.vy"), + )?; + let interface_path = src.join("ICounter.sol"); + fs::write( + interface_path, + include_str!("../../assets/vyper/ICounterTemplate.sol"), + )?; + + // write the tests + let contract_path = test.join("Counter.t.sol"); + fs::write( + contract_path, + include_str!("../../assets/vyper/CounterTemplate.t.sol"), + )?; + + // write the script + let contract_path = script.join("Counter.s.sol"); + fs::write( + contract_path, + include_str!("../../assets/vyper/CounterTemplate.s.sol"), + )?; + } else if tempo { + // write the contract file + let contract_path = src.join("Mail.sol"); + fs::write(contract_path, include_str!("../../assets/tempo/MailTemplate.sol"))?; + + // write the tests + let contract_path = test.join("Mail.t.sol"); + fs::write( + contract_path, + include_str!("../../assets/tempo/MailTemplate.t.sol"), + )?; + + // write the script + let contract_path = script.join("Mail.s.sol"); + fs::write( + contract_path, + include_str!("../../assets/tempo/MailTemplate.s.sol"), + )?; + } else { + // write the contract file + let contract_path = src.join("Counter.sol"); + fs::write( + contract_path, + include_str!("../../assets/solidity/CounterTemplate.sol"), + )?; + + // write the tests + let contract_path = test.join("Counter.t.sol"); + fs::write( + contract_path, + include_str!("../../assets/solidity/CounterTemplate.t.sol"), + )?; + + // write the script + let contract_path = script.join("Counter.s.sol"); + fs::write( + contract_path, + include_str!("../../assets/solidity/CounterTemplate.s.sol"), + )?; + } + } + + // Write the README file let readme_path = root.join("README.md"); - fs::write(readme_path, include_str!("../../assets/README.md"))?; + if tempo { + fs::write(readme_path, include_str!("../../assets/tempo/README.md"))?; + } else { + fs::write(readme_path, include_str!("../../assets/README.md"))?; + } // write foundry.toml, if it doesn't exist already let dest = root.join(Config::FILE_NAME); let mut config = Config::load_with_root(&root)?; + if let Some(network) = network { + config.networks = network.into(); + } if !dest.exists() { fs::write(dest, config.clone().into_basic().to_string_pretty()?)?; } @@ -141,17 +249,28 @@ impl InitArgs { // set up the repo if !no_git { - init_git_repo(git, commit)?; + init_git_repo(git, commit, use_parent_git, vyper, tempo)?; } // install forge-std if !offline { if root.join("lib/forge-std").exists() { sh_warn!("\"lib/forge-std\" already exists, skipping install...")?; - self.install.install(&mut config, vec![])?; + self.install.install(&mut config, vec![]).await?; } else { let dep = "https://github.com/foundry-rs/forge-std".parse()?; - self.install.install(&mut config, vec![dep])?; + self.install.install(&mut config, vec![dep]).await?; + } + + // install tempo-std + if tempo { + if root.join("lib/tempo-std").exists() { + sh_warn!("\"lib/tempo-std\" already exists, skipping install...")?; + self.install.install(&mut config, vec![]).await?; + } else { + let dep = "https://github.com/tempoxyz/tempo-std".parse()?; + self.install.install(&mut config, vec![dep]).await?; + } } } @@ -161,19 +280,26 @@ impl InitArgs { } } - sh_println!("{}", " Initialized forge project".green())?; + sh_status!("{}", " Initialized forge project".green())?; Ok(()) } } -/// Initialises `root` as a git repository, if it isn't one already. +/// Initialises `root` as a git repository, if it isn't one already, unless 'use_parent_git' is +/// true. /// /// Creates `.gitignore` and `.github/workflows/test.yml`, if they don't exist already. /// /// Commits everything in `root` if `commit` is true. -fn init_git_repo(git: Git<'_>, commit: bool) -> Result<()> { - // git init - if !git.is_in_repo()? { +fn init_git_repo( + git: Git<'_>, + commit: bool, + use_parent_git: bool, + vyper: bool, + tempo: bool, +) -> Result<()> { + // `git init` + if !git.is_in_repo()? || (!use_parent_git && !git.is_repo_root()?) { git.init()?; } @@ -187,7 +313,14 @@ fn init_git_repo(git: Git<'_>, commit: bool) -> Result<()> { let workflow = git.root.join(".github/workflows/test.yml"); if !workflow.exists() { fs::create_dir_all(workflow.parent().unwrap())?; - fs::write(workflow, include_str!("../../assets/workflowTemplate.yml"))?; + + if vyper { + fs::write(workflow, include_str!("../../assets/vyper/workflowTemplate.yml"))?; + } else if tempo { + fs::write(workflow, include_str!("../../assets/tempo/workflowTemplate.yml"))?; + } else { + fs::write(workflow, include_str!("../../assets/solidity/workflowTemplate.yml"))?; + } } // commit everything diff --git a/crates/forge/src/cmd/inspect.rs b/crates/forge/src/cmd/inspect.rs index c7ef0772f3cae..ac4adfc044aee 100644 --- a/crates/forge/src/cmd/inspect.rs +++ b/crates/forge/src/cmd/inspect.rs @@ -1,26 +1,34 @@ -use alloy_json_abi::{EventParam, InternalType, JsonAbi, Param}; -use alloy_primitives::{hex, keccak256}; +use alloy_json_abi::{Event, EventParam, InternalType, JsonAbi, Param}; use clap::Parser; -use comfy_table::{Cell, Table, modifiers::UTF8_ROUND_CORNERS}; +use comfy_table::{ + Cell, Table, + presets::{ASCII_FULL, ASCII_MARKDOWN}, +}; use eyre::{Result, eyre}; -use foundry_cli::opts::{BuildOpts, CompilerOpts}; +use foundry_cli::{ + opts::{BuildOpts, CompilerOpts}, + utils::LoadConfig, +}; use foundry_common::{ compile::{PathOrContractInfo, ProjectCompiler}, find_matching_contract_artifact, find_target_path, shell, }; use foundry_compilers::{ + ProjectCompileOutput, artifacts::{ StorageLayout, output_selection::{ BytecodeOutputSelection, ContractOutputSelection, DeployedBytecodeOutputSelection, - EvmOutputSelection, EwasmOutputSelection, + EvmOutputSelection, EwasmOutputSelection, OutputSelection, }, }, - solc::SolcLanguage, + solc::{SolcCompiler, SolcLanguage}, }; +use path_slash::PathExt; use regex::Regex; use serde_json::{Map, Value}; -use std::{collections::BTreeMap, fmt, str::FromStr, sync::LazyLock}; +use solar::sema::interface::source_map::FileName; +use std::{collections::BTreeMap, fmt, ops::ControlFlow, path::Path, str::FromStr, sync::LazyLock}; /// CLI arguments for `forge inspect`. #[derive(Clone, Debug, Parser)] @@ -40,14 +48,21 @@ pub struct InspectArgs { /// Whether to remove comments when inspecting `ir` and `irOptimized` artifact fields. #[arg(long, short, help_heading = "Display options")] pub strip_yul_comments: bool, + + /// Whether to wrap the table to the terminal width. + #[arg(long, short, help_heading = "Display options")] + pub wrap: bool, } impl InspectArgs { pub fn run(self) -> Result<()> { - let Self { contract, field, build, strip_yul_comments } = self; + let Self { contract, field, build, strip_yul_comments, wrap } = self; trace!(target: "forge", ?field, ?contract, "running forge inspect"); + let user_extra_output = !build.compiler.extra_output.is_empty() + || !build.compiler.extra_output_files.is_empty(); + // Map field to ContractOutputSelection let mut cos = build.compiler.extra_output; if !field.can_skip_field() && !cos.iter().any(|selected| field == *selected) { @@ -61,9 +76,6 @@ impl InspectArgs { build.compiler.optimize }; - // Get the solc version if specified - let solc_version = build.use_solc.clone(); - // Build modified Args let modified_build_args = BuildOpts { compiler: CompilerOpts { extra_output: cos, optimize: optimized, ..build.compiler }, @@ -71,22 +83,57 @@ impl InspectArgs { }; // Build the project - let project = modified_build_args.project()?; - let compiler = ProjectCompiler::new().quiet(true); + let config = modified_build_args.load_config_with_dependencies()?; + let mut project = config.project()?; + if !user_extra_output + && !project.build_info + && let Some(selection) = field.inspect_output_selection() + { + project.no_artifacts = true; + project + .update_output_selection(|output_selection| *output_selection = selection.clone()); + } let target_path = find_target_path(&project, &contract)?; - let mut output = compiler.files([target_path.clone()]).compile(&project)?; + if field == ContractArtifactField::Linearization && !is_solidity_source(&target_path) { + eyre::bail!( + "linearization inspection is only supported for Solidity contracts (.sol targets)" + ); + } + let mut compiler = ProjectCompiler::new() + .external_compilers(&config) + .external_artifacts(false) + .target_files([target_path.clone()]) + .quiet(true); + if project.no_artifacts + && project.cached + && !config.force + && is_solidity_source(&target_path) + && project.artifacts.additional_files == Default::default() + // Cached artifacts do not retain compiler diagnostics. + && !config.deny.warnings() + { + project.no_artifacts = false; + if field != ContractOutputSelection::Abi { + project.update_output_selection(|selection| { + *selection = + std::mem::take(selection).with_output("*", "*", ["abi".to_string()]); + }); + } + compiler = compiler.cache_abi(); + } + let mut output = compiler.compile(&project)?; // Find the artifact let artifact = find_matching_contract_artifact(&mut output, &target_path, contract.name())?; // Match on ContractArtifactFields and pretty-print match field { + ContractArtifactField::Artifact => { + print_json(&artifact)?; + } ContractArtifactField::Abi => { - let abi = artifact - .abi - .as_ref() - .ok_or_else(|| eyre::eyre!("Failed to fetch lossless ABI"))?; - print_abi(abi)?; + let abi = artifact.abi.as_ref().ok_or_else(|| missing_error("ABI"))?; + print_abi(abi, wrap)?; } ContractArtifactField::Bytecode => { print_json_str(&artifact.bytecode, Some("object"))?; @@ -101,13 +148,20 @@ impl InspectArgs { print_json_str(&artifact.legacy_assembly, None)?; } ContractArtifactField::MethodIdentifiers => { - print_method_identifiers(&artifact.method_identifiers)?; + print_method_identifiers(&artifact.method_identifiers, wrap)?; } ContractArtifactField::GasEstimates => { print_json(&artifact.gas_estimates)?; } ContractArtifactField::StorageLayout => { - print_storage_layout(artifact.storage_layout.as_ref())?; + print_storage_layout(artifact.storage_layout.as_ref(), "storage layout", wrap)?; + } + ContractArtifactField::TransientStorageLayout => { + print_storage_layout( + artifact.transient_storage_layout.as_ref(), + "transient storage layout", + wrap, + )?; } ContractArtifactField::DevDoc => { print_json(&artifact.devdoc)?; @@ -125,19 +179,27 @@ impl InspectArgs { print_json(&artifact.userdoc)?; } ContractArtifactField::Ewasm => { - print_json_str(&artifact.ewasm, None)?; + let ewasm = artifact.ewasm.as_ref().ok_or_else(|| missing_error("EWASM output"))?; + print_json_str(ewasm, None)?; } ContractArtifactField::Errors => { let out = artifact.abi.as_ref().map_or(Map::new(), parse_errors); - print_errors_events(&out, true)?; + print_errors_events(&out, true, wrap)?; } ContractArtifactField::Events => { let out = artifact.abi.as_ref().map_or(Map::new(), parse_events); - print_errors_events(&out, false)?; + print_errors_events(&out, false, wrap)?; } ContractArtifactField::StandardJson => { - let standard_json = if let Some(version) = solc_version { - let version = version.parse()?; + let version = match &project.compiler.solc { + Some(SolcCompiler::Specific(solc)) => Some(solc.version.clone()), + _ => artifact + .metadata + .as_ref() + .map(|metadata| metadata.compiler.version.parse()) + .transpose()?, + }; + let standard_json = if let Some(version) = version { let mut standard_json = project.standard_json_input(&target_path)?.normalize_evm_version(&version); standard_json.settings.sanitize(&version, SolcLanguage::Solidity); @@ -147,6 +209,31 @@ impl InspectArgs { }; print_json(&standard_json)?; } + ContractArtifactField::Libraries => { + let all_libs: Vec = artifact + .all_link_references() + .into_iter() + .flat_map(|(path, libs)| { + libs.into_keys().map(move |lib| format!("{path}:{lib}")) + }) + .collect(); + if shell::is_json() { + return print_json(&all_libs); + } + sh_status!("Dynamically linked libraries:")?; + for lib in &all_libs { + sh_println!("{lib}")?; + } + } + ContractArtifactField::Linearization => { + print_linearization( + &mut output, + project.root(), + &target_path, + contract.name(), + wrap, + )?; + } }; Ok(()) @@ -155,7 +242,7 @@ impl InspectArgs { fn parse_errors(abi: &JsonAbi) -> Map { let mut out = serde_json::Map::new(); - for er in abi.errors.iter().flat_map(|(_, errors)| errors) { + for er in abi.errors.values().flatten() { let types = get_ty_sig(&er.inputs); let sig = format!("{:x}", er.selector()); let sig_trimmed = &sig[0..8]; @@ -166,14 +253,19 @@ fn parse_errors(abi: &JsonAbi) -> Map { fn parse_events(abi: &JsonAbi) -> Map { let mut out = serde_json::Map::new(); - for ev in abi.events.iter().flat_map(|(_, events)| events) { + for ev in abi.events.values().flatten() { let types = parse_event_params(&ev.inputs); - let topic = hex::encode(keccak256(ev.signature())); - out.insert(format!("{}({})", ev.name, types), format!("0x{topic}").into()); + let topic = event_topic(ev).map_or(Value::Null, Into::into); + out.insert(format!("{}({})", ev.name, types), topic); } out } +/// Returns topic0 for non-anonymous events. Anonymous events have no signature topic. +fn event_topic(ev: &Event) -> Option { + (!ev.anonymous).then(|| ev.selector().to_string()) +} + fn parse_event_params(ev_params: &[EventParam]) -> String { ev_params .iter() @@ -187,66 +279,75 @@ fn parse_event_params(ev_params: &[EventParam]) -> String { .join(",") } -fn print_abi(abi: &JsonAbi) -> Result<()> { +fn print_abi(abi: &JsonAbi, should_wrap: bool) -> Result<()> { if shell::is_json() { return print_json(abi); } let headers = vec![Cell::new("Type"), Cell::new("Signature"), Cell::new("Selector")]; - print_table(headers, |table| { - // Print events - for ev in abi.events.iter().flat_map(|(_, events)| events) { - let types = parse_event_params(&ev.inputs); - let selector = ev.selector().to_string(); - table.add_row(["event", &format!("{}({})", ev.name, types), &selector]); - } + print_table( + headers, + |table| { + // Print events + for ev in abi.events.values().flatten() { + let types = parse_event_params(&ev.inputs); + let signature = if ev.anonymous { + format!("{}({}) anonymous", ev.name, types) + } else { + format!("{}({})", ev.name, types) + }; + let selector = event_topic(ev).unwrap_or_default(); + table.add_row(["event", &signature, &selector]); + } - // Print errors - for er in abi.errors.iter().flat_map(|(_, errors)| errors) { - let selector = er.selector().to_string(); - table.add_row([ - "error", - &format!("{}({})", er.name, get_ty_sig(&er.inputs)), - &selector, - ]); - } + // Print errors + for er in abi.errors.values().flatten() { + let selector = er.selector().to_string(); + table.add_row([ + "error", + &format!("{}({})", er.name, get_ty_sig(&er.inputs)), + &selector, + ]); + } - // Print functions - for func in abi.functions.iter().flat_map(|(_, f)| f) { - let selector = func.selector().to_string(); - let state_mut = func.state_mutability.as_json_str(); - let func_sig = if !func.outputs.is_empty() { - format!( - "{}({}) {state_mut} returns ({})", - func.name, - get_ty_sig(&func.inputs), - get_ty_sig(&func.outputs) - ) - } else { - format!("{}({}) {state_mut}", func.name, get_ty_sig(&func.inputs)) - }; - table.add_row(["function", &func_sig, &selector]); - } + // Print functions + for func in abi.functions.values().flatten() { + let selector = func.selector().to_string(); + let state_mut = func.state_mutability.as_json_str(); + let func_sig = if func.outputs.is_empty() { + format!("{}({}) {state_mut}", func.name, get_ty_sig(&func.inputs)) + } else { + format!( + "{}({}) {state_mut} returns ({})", + func.name, + get_ty_sig(&func.inputs), + get_ty_sig(&func.outputs) + ) + }; + table.add_row(["function", &func_sig, &selector]); + } - if let Some(constructor) = abi.constructor() { - let state_mut = constructor.state_mutability.as_json_str(); - table.add_row([ - "constructor", - &format!("constructor({}) {state_mut}", get_ty_sig(&constructor.inputs)), - "", - ]); - } + if let Some(constructor) = abi.constructor() { + let state_mut = constructor.state_mutability.as_json_str(); + table.add_row([ + "constructor", + &format!("constructor({}) {state_mut}", get_ty_sig(&constructor.inputs)), + "", + ]); + } - if let Some(fallback) = &abi.fallback { - let state_mut = fallback.state_mutability.as_json_str(); - table.add_row(["fallback", &format!("fallback() {state_mut}"), ""]); - } + if let Some(fallback) = &abi.fallback { + let state_mut = fallback.state_mutability.as_json_str(); + table.add_row(["fallback", &format!("fallback() {state_mut}"), ""]); + } - if let Some(receive) = &abi.receive { - let state_mut = receive.state_mutability.as_json_str(); - table.add_row(["receive", &format!("receive() {state_mut}"), ""]); - } - }) + if let Some(receive) = &abi.receive { + let state_mut = receive.state_mutability.as_json_str(); + table.add_row(["receive", &format!("receive() {state_mut}"), ""]); + } + }, + should_wrap, + ) } fn get_ty_sig(inputs: &[Param]) -> String { @@ -274,9 +375,13 @@ fn internal_ty(ty: &InternalType) -> String { } } -pub fn print_storage_layout(storage_layout: Option<&StorageLayout>) -> Result<()> { +pub fn print_storage_layout( + storage_layout: Option<&StorageLayout>, + field: &str, + should_wrap: bool, +) -> Result<()> { let Some(storage_layout) = storage_layout else { - eyre::bail!("Could not get storage layout"); + return Err(missing_error(field)); }; if shell::is_json() { @@ -292,24 +397,31 @@ pub fn print_storage_layout(storage_layout: Option<&StorageLayout>) -> Result<() Cell::new("Contract"), ]; - print_table(headers, |table| { - for slot in &storage_layout.storage { - let storage_type = storage_layout.types.get(&slot.storage_type); - table.add_row([ - slot.label.as_str(), - storage_type.map_or("?", |t| &t.label), - &slot.slot, - &slot.offset.to_string(), - storage_type.map_or("?", |t| &t.number_of_bytes), - &slot.contract, - ]); - } - }) + print_table( + headers, + |table| { + for slot in &storage_layout.storage { + let storage_type = storage_layout.types.get(&slot.storage_type); + table.add_row([ + slot.label.as_str(), + storage_type.map_or("?", |t| &t.label), + &slot.slot, + &slot.offset.to_string(), + storage_type.map_or("?", |t| &t.number_of_bytes), + &slot.contract, + ]); + } + }, + should_wrap, + ) } -fn print_method_identifiers(method_identifiers: &Option>) -> Result<()> { +fn print_method_identifiers( + method_identifiers: &Option>, + should_wrap: bool, +) -> Result<()> { let Some(method_identifiers) = method_identifiers else { - eyre::bail!("Could not get method identifiers"); + return Err(missing_error("method identifiers")); }; if shell::is_json() { @@ -318,14 +430,18 @@ fn print_method_identifiers(method_identifiers: &Option let headers = vec![Cell::new("Method"), Cell::new("Identifier")]; - print_table(headers, |table| { - for (method, identifier) in method_identifiers { - table.add_row([method, identifier]); - } - }) + print_table( + headers, + |table| { + for (method, identifier) in method_identifiers { + table.add_row([method.as_str(), identifier.as_str()]); + } + }, + should_wrap, + ) } -fn print_errors_events(map: &Map, is_err: bool) -> Result<()> { +fn print_errors_events(map: &Map, is_err: bool, should_wrap: bool) -> Result<()> { if shell::is_json() { return print_json(map); } @@ -335,25 +451,145 @@ fn print_errors_events(map: &Map, is_err: bool) -> Result<()> { } else { vec![Cell::new("Event"), Cell::new("Topic")] }; - print_table(headers, |table| { - for (method, selector) in map { - table.add_row([method, selector.as_str().unwrap()]); - } - }) + print_table( + headers, + |table| { + for (method, selector) in map { + table.add_row([method.as_str(), selector.as_str().unwrap_or("")]); + } + }, + should_wrap, + ) } -fn print_table(headers: Vec, add_rows: impl FnOnce(&mut Table)) -> Result<()> { +fn print_table( + headers: Vec, + add_rows: impl FnOnce(&mut Table), + should_wrap: bool, +) -> Result<()> { let mut table = Table::new(); - table.apply_modifier(UTF8_ROUND_CORNERS); + if shell::is_markdown() { + table.load_style(ASCII_MARKDOWN); + } else { + table.load_style(ASCII_FULL.with_rounded_corners()); + } table.set_header(headers); + if should_wrap { + table.set_content_arrangement(comfy_table::ContentArrangement::Dynamic); + } add_rows(&mut table); sh_println!("\n{table}\n")?; Ok(()) } +fn print_linearization( + output: &mut ProjectCompileOutput, + root: &Path, + target_path: &Path, + target_name: Option<&str>, + should_wrap: bool, +) -> Result<()> { + let mut chain = Vec::new(); + let mut lowered = false; + let compiler = output.parser_mut().solc_mut().compiler_mut(); + compiler.enter_mut(|compiler| -> Result<()> { + let Ok(ControlFlow::Continue(())) = compiler.lower_asts() else { return Ok(()) }; + lowered = true; + + let hir = &compiler.gcx().hir; + let matching_contracts = hir + .contract_ids() + .filter(|id| { + let contract = hir.contract(*id); + if let Some(target_name) = target_name + && contract.name.as_str() != target_name + { + return false; + } + + matches!( + &hir.source(contract.source).file.name, + FileName::Real(path) if path == target_path + ) + }) + .collect::>(); + + let target_contract = match matching_contracts.as_slice() { + [id] => *id, + [] => { + if let Some(target_name) = target_name { + eyre::bail!( + "Could not find contract `{target_name}` in `{}`", + target_path.display() + ); + } + eyre::bail!("Could not find contract in `{}`", target_path.display()); + } + _ => { + eyre::bail!( + "Multiple contracts found in the same file, please specify the target : or " + ); + } + }; + + for (order, base_id) in hir.contract(target_contract).linearized_bases.iter().enumerate() { + let contract = hir.contract(*base_id); + let source = hir.source(contract.source); + let FileName::Real(path) = &source.file.name else { continue }; + let path = path.strip_prefix(root).unwrap_or(path); + chain.push(( + order, + path.to_slash_lossy().into_owned(), + contract.name.as_str().to_string(), + )); + } + + Ok(()) + })?; + + // `compiler.sess()` inside of `ProjectCompileOutput` is built with `with_buffer_emitter`. + let diags = compiler.sess().dcx.emitted_diagnostics().unwrap(); + if compiler.sess().dcx.has_errors().is_err() { + eyre::bail!("{diags}"); + } + let _ = sh_eprint!("{diags}"); + if !lowered { + eyre::bail!( + "unable to inspect linearization: failed to lower Solidity ASTs for `{}`", + target_path.display() + ); + } + + if shell::is_json() { + let contracts = chain + .into_iter() + .map(|(order, source, contract)| { + serde_json::json!({ + "order": order, + "source": source, + "contract": contract, + }) + }) + .collect::>(); + return print_json(&contracts); + } + + let headers = vec![Cell::new("Order"), Cell::new("Source"), Cell::new("Contract")]; + print_table( + headers, + |table| { + for (order, source, contract) in &chain { + table.add_row([order.to_string(), source.clone(), contract.clone()]); + } + }, + should_wrap, + ) +} + /// Contract level output selection #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum ContractArtifactField { + Artifact, Abi, Bytecode, DeployedBytecode, @@ -363,6 +599,7 @@ pub enum ContractArtifactField { MethodIdentifiers, GasEstimates, StorageLayout, + TransientStorageLayout, DevDoc, Ir, IrOptimized, @@ -372,6 +609,8 @@ pub enum ContractArtifactField { Errors, Events, StandardJson, + Libraries, + Linearization, } macro_rules! impl_value_enum { @@ -381,7 +620,6 @@ macro_rules! impl_value_enum { pub const ALL: &'static [Self] = &[$(Self::$field),+]; /// Returns the string representation of `self`. - #[inline] pub const fn as_str(&self) -> &'static str { match self { $( @@ -391,7 +629,6 @@ macro_rules! impl_value_enum { } /// Returns all the aliases of `self`. - #[inline] pub const fn aliases(&self) -> &'static [&'static str] { match self { $( @@ -402,17 +639,14 @@ macro_rules! impl_value_enum { } impl ::clap::ValueEnum for $name { - #[inline] fn value_variants<'a>() -> &'a [Self] { Self::ALL } - #[inline] fn to_possible_value(&self) -> Option<::clap::builder::PossibleValue> { Some(::clap::builder::PossibleValue::new(Self::as_str(self)).aliases(Self::aliases(self))) } - #[inline] fn from_str(input: &str, ignore_case: bool) -> Result { let _ = ignore_case; ::from_str(input) @@ -436,6 +670,8 @@ macro_rules! impl_value_enum { impl_value_enum! { enum ContractArtifactField { + Artifact => "artifact" | "artifactJson" | "artifact-json" | "artifact_json" + | "output", Abi => "abi", Bytecode => "bytecode" | "bytes" | "b", DeployedBytecode => "deployedBytecode" | "deployed_bytecode" | "deployed-bytecode" @@ -451,6 +687,10 @@ impl_value_enum! { | "gasestimates", StorageLayout => "storageLayout" | "storage_layout" | "storage-layout" | "storagelayout" | "storage", + TransientStorageLayout => "transientStorageLayout" | "transient_storage_layout" + | "transient-storage-layout" | "transientstoragelayout" + | "transientStorage" | "transient-storage" | "transient_storage" + | "transientstorage" | "transient" | "tsl", DevDoc => "devdoc" | "dev-doc" | "devDoc", Ir => "ir" | "iR" | "IR", IrOptimized => "irOptimized" | "ir-optimized" | "iroptimized" | "iro" | "iropt", @@ -460,6 +700,10 @@ impl_value_enum! { Errors => "errors" | "er", Events => "events" | "ev", StandardJson => "standardJson" | "standard-json" | "standard_json", + Libraries => "libraries" | "lib" | "libs", + Linearization => "linearization" | "linearizedInheritance" + | "linearized-inheritance" | "linearized_inheritance" + | "linearizedBases" | "linearized-bases" | "linearized_bases", } } @@ -469,6 +713,7 @@ impl TryFrom for ContractOutputSelection { fn try_from(field: ContractArtifactField) -> Result { type Caf = ContractArtifactField; match field { + Caf::Artifact => Err(eyre!("Artifact is not supported for ContractOutputSelection")), Caf::Abi => Ok(Self::Abi), Caf::Bytecode => { Ok(Self::Evm(EvmOutputSelection::ByteCode(BytecodeOutputSelection::All))) @@ -481,6 +726,7 @@ impl TryFrom for ContractOutputSelection { Caf::MethodIdentifiers => Ok(Self::Evm(EvmOutputSelection::MethodIdentifiers)), Caf::GasEstimates => Ok(Self::Evm(EvmOutputSelection::GasEstimates)), Caf::StorageLayout => Ok(Self::StorageLayout), + Caf::TransientStorageLayout => Ok(Self::TransientStorageLayout), Caf::DevDoc => Ok(Self::DevDoc), Caf::Ir => Ok(Self::Ir), Caf::IrOptimized => Ok(Self::IrOptimized), @@ -492,6 +738,10 @@ impl TryFrom for ContractOutputSelection { Caf::StandardJson => { Err(eyre!("StandardJson is not supported for ContractOutputSelection")) } + Caf::Libraries => Err(eyre!("Libraries is not supported for ContractOutputSelection")), + Caf::Linearization => { + Err(eyre!("Linearization is not supported for ContractOutputSelection")) + } } } } @@ -502,8 +752,7 @@ impl PartialEq for ContractArtifactField { type Eos = EvmOutputSelection; matches!( (self, other), - (Self::Abi | Self::Events, Cos::Abi) - | (Self::Errors, Cos::Abi) + (Self::Abi | Self::Events | Self::Errors, Cos::Abi) | (Self::Bytecode, Cos::Evm(Eos::ByteCode(_))) | (Self::DeployedBytecode, Cos::Evm(Eos::DeployedByteCode(_))) | (Self::Assembly | Self::AssemblyOptimized, Cos::Evm(Eos::Assembly)) @@ -511,6 +760,7 @@ impl PartialEq for ContractArtifactField { | (Self::MethodIdentifiers, Cos::Evm(Eos::MethodIdentifiers)) | (Self::GasEstimates, Cos::Evm(Eos::GasEstimates)) | (Self::StorageLayout, Cos::StorageLayout) + | (Self::TransientStorageLayout, Cos::TransientStorageLayout) | (Self::DevDoc, Cos::DevDoc) | (Self::Ir, Cos::Ir) | (Self::IrOptimized, Cos::IrOptimized) @@ -530,7 +780,30 @@ impl fmt::Display for ContractArtifactField { impl ContractArtifactField { /// Returns true if this field does not need to be passed to the compiler. pub const fn can_skip_field(&self) -> bool { - matches!(self, Self::Bytecode | Self::DeployedBytecode | Self::StandardJson) + matches!( + self, + Self::Artifact + | Self::Bytecode + | Self::DeployedBytecode + | Self::StandardJson + | Self::Libraries + | Self::Linearization + ) + } + + fn inspect_output_selection(&self) -> Option { + match self { + Self::Artifact + | Self::Bytecode + | Self::DeployedBytecode + | Self::StandardJson + | Self::Libraries + | Self::Linearization => None, + _ => { + let selection: ContractOutputSelection = (*self).try_into().ok()?; + Some(OutputSelection::common_output_selection([selection.to_string()])) + } + } } } @@ -540,40 +813,52 @@ fn print_json(obj: &impl serde::Serialize) -> Result<()> { } fn print_json_str(obj: &impl serde::Serialize, key: Option<&str>) -> Result<()> { - sh_println!("{}", get_json_str(obj, key)?)?; + let value = serde_json::to_value(obj)?; + let value = key.and_then(|k| value.get(k)).unwrap_or(&value); + if shell::is_json() { + sh_println!("{}", serde_json::to_string_pretty(value)?)?; + } else { + let s = match value.as_str() { + Some(s) => s.to_string(), + None => format!("{value:#}"), + }; + sh_println!("{s}")?; + } Ok(()) } fn print_yul(yul: Option<&str>, strip_comments: bool) -> Result<()> { let Some(yul) = yul else { - eyre::bail!("Could not get IR output"); + return Err(missing_error("IR output")); }; static YUL_COMMENTS: LazyLock = LazyLock::new(|| Regex::new(r"(///.*\n\s*)|(\s*/\*\*.*?\*/)").unwrap()); - if strip_comments { - sh_println!("{}", YUL_COMMENTS.replace_all(yul, ""))?; + let out = if strip_comments { + YUL_COMMENTS.replace_all(yul, "").into_owned() } else { - sh_println!("{yul}")?; + yul.to_string() + }; + + if shell::is_json() { + sh_println!("{}", serde_json::to_string(&out)?)?; + } else { + sh_println!("{out}")?; } Ok(()) } -fn get_json_str(obj: &impl serde::Serialize, key: Option<&str>) -> Result { - let value = serde_json::to_value(obj)?; - let mut value_ref = &value; - if let Some(key) = key - && let Some(value2) = value.get(key) - { - value_ref = value2; - } - let s = match value_ref.as_str() { - Some(s) => s.to_string(), - None => format!("{value_ref:#}"), - }; - Ok(s) +fn is_solidity_source(path: &Path) -> bool { + path.extension().and_then(|ext| ext.to_str()).is_some_and(|ext| ext.eq_ignore_ascii_case("sol")) +} + +fn missing_error(field: &str) -> eyre::Error { + eyre!( + "{field} missing from artifact; \ + this could be a spurious caching issue, consider running `forge clean`" + ) } #[cfg(test)] @@ -583,7 +868,15 @@ mod tests { #[test] fn contract_output_selection() { for &field in ContractArtifactField::ALL { - if field == ContractArtifactField::StandardJson { + if field == ContractArtifactField::Artifact { + let selection: Result = field.try_into(); + assert!( + selection + .unwrap_err() + .to_string() + .eq("Artifact is not supported for ContractOutputSelection") + ); + } else if field == ContractArtifactField::StandardJson { let selection: Result = field.try_into(); assert!( selection @@ -591,6 +884,22 @@ mod tests { .to_string() .eq("StandardJson is not supported for ContractOutputSelection") ); + } else if field == ContractArtifactField::Libraries { + let selection: Result = field.try_into(); + assert!( + selection + .unwrap_err() + .to_string() + .eq("Libraries is not supported for ContractOutputSelection") + ); + } else if field == ContractArtifactField::Linearization { + let selection: Result = field.try_into(); + assert!( + selection + .unwrap_err() + .to_string() + .eq("Linearization is not supported for ContractOutputSelection") + ); } else { let selection: ContractOutputSelection = field.try_into().unwrap(); assert_eq!(field, selection); diff --git a/crates/forge/src/cmd/install.rs b/crates/forge/src/cmd/install.rs deleted file mode 100644 index 4501a4aff8607..0000000000000 --- a/crates/forge/src/cmd/install.rs +++ /dev/null @@ -1,599 +0,0 @@ -use crate::{DepIdentifier, FOUNDRY_LOCK, Lockfile}; -use clap::{Parser, ValueHint}; -use eyre::{Context, Result}; -use foundry_cli::{ - opts::Dependency, - utils::{CommandUtils, Git, LoadConfig}, -}; -use foundry_common::fs; -use foundry_config::{Config, impl_figment_convert_basic}; -use regex::Regex; -use semver::Version; -use std::{ - io::IsTerminal, - path::{Path, PathBuf}, - str, - sync::LazyLock, -}; -use yansi::Paint; - -static DEPENDENCY_VERSION_TAG_REGEX: LazyLock = - LazyLock::new(|| Regex::new(r"^v?\d+(\.\d+)*$").unwrap()); - -/// CLI arguments for `forge install`. -#[derive(Clone, Debug, Parser)] -#[command(override_usage = "forge install [OPTIONS] [DEPENDENCIES]... - forge install [OPTIONS] /@... - forge install [OPTIONS] =/@... - forge install [OPTIONS] @git url>...)] - forge install [OPTIONS] ...")] -pub struct InstallArgs { - /// The dependencies to install. - /// - /// A dependency can be a raw URL, or the path to a GitHub repository. - /// - /// Additionally, a ref can be provided by adding @ to the dependency path. - /// - /// A ref can be: - /// - A branch: master - /// - A tag: v1.2.3 - /// - A commit: 8e8128 - /// - /// For exact match, a ref can be provided with `@tag=`, `@branch=` or `@rev=` prefix. - /// - /// Target installation directory can be added via `=` suffix. - /// The dependency will installed to `lib/`. - dependencies: Vec, - - /// The project's root path. - /// - /// By default root of the Git repository, if in one, - /// or the current working directory. - #[arg(long, value_hint = ValueHint::DirPath, value_name = "PATH")] - pub root: Option, - - #[command(flatten)] - opts: DependencyInstallOpts, -} - -impl_figment_convert_basic!(InstallArgs); - -impl InstallArgs { - pub fn run(self) -> Result<()> { - let mut config = self.load_config()?; - self.opts.install(&mut config, self.dependencies) - } -} - -#[derive(Clone, Copy, Debug, Default, Parser)] -pub struct DependencyInstallOpts { - /// Perform shallow clones instead of deep ones. - /// - /// Improves performance and reduces disk usage, but prevents switching branches or tags. - #[arg(long)] - pub shallow: bool, - - /// Install without adding the dependency as a submodule. - #[arg(long)] - pub no_git: bool, - - /// Create a commit after installing the dependencies. - #[arg(long)] - pub commit: bool, -} - -impl DependencyInstallOpts { - pub fn git(self, config: &Config) -> Git<'_> { - Git::from_config(config).shallow(self.shallow) - } - - /// Installs all missing dependencies. - /// - /// See also [`Self::install`]. - /// - /// Returns true if any dependency was installed. - pub fn install_missing_dependencies(self, config: &mut Config) -> bool { - let lib = config.install_lib_dir(); - if self.git(config).has_missing_dependencies(Some(lib)).unwrap_or(false) { - // The extra newline is needed, otherwise the compiler output will overwrite the message - let _ = sh_println!("Missing dependencies found. Installing now...\n"); - if self.install(config, Vec::new()).is_err() { - let _ = - sh_warn!("Your project has missing dependencies that could not be installed."); - } - true - } else { - false - } - } - - /// Installs all dependencies - pub fn install(self, config: &mut Config, dependencies: Vec) -> Result<()> { - let Self { no_git, commit, .. } = self; - - let git = self.git(config); - - let install_lib_dir = config.install_lib_dir(); - let libs = git.root.join(install_lib_dir); - - let mut lockfile = Lockfile::new(&config.root); - if !no_git { - lockfile = lockfile.with_git(&git); - - // Check if submodules are uninitialized, if so, we need to fetch all submodules - // This is to ensure that foundry.lock syncs successfully and doesn't error out, when - // looking for commits/tags in submodules - if git.submodules_uninitialized()? { - trace!(lib = %libs.display(), "submodules uninitialized"); - git.submodule_update(false, false, false, true, Some(&libs))?; - } - } - - let out_of_sync_deps = lockfile.sync(config.install_lib_dir())?; - - if dependencies.is_empty() && !no_git { - // Use the root of the git repository to look for submodules. - let root = Git::root_of(git.root)?; - match git.has_submodules(Some(&root)) { - Ok(true) => { - sh_println!("Updating dependencies in {}", libs.display())?; - - // recursively fetch all submodules (without fetching latest) - git.submodule_update(false, false, false, true, Some(&libs))?; - lockfile.write()?; - } - Err(err) => { - sh_err!("Failed to check for submodules: {err}")?; - } - _ => { - // no submodules, nothing to do - } - } - } - - fs::create_dir_all(&libs)?; - - let installer = Installer { git, commit }; - for dep in dependencies { - let path = libs.join(dep.name()); - let rel_path = path - .strip_prefix(git.root) - .wrap_err("Library directory is not relative to the repository root")?; - sh_println!( - "Installing {} in {} (url: {:?}, tag: {:?})", - dep.name, - path.display(), - dep.url, - dep.tag - )?; - - // this tracks the actual installed tag - let installed_tag; - let mut dep_id = None; - if no_git { - installed_tag = installer.install_as_folder(&dep, &path)?; - } else { - if commit { - git.ensure_clean()?; - } - installed_tag = installer.install_as_submodule(&dep, &path)?; - - let mut new_insertion = false; - // Pin branch to submodule if branch is used - if let Some(tag_or_branch) = &installed_tag { - // First, check if this tag has a branch - dep_id = Some(DepIdentifier::resolve_type(&git, &path, tag_or_branch)?); - if git.has_branch(tag_or_branch, &path)? - && dep_id.as_ref().is_some_and(|id| id.is_branch()) - { - // always work with relative paths when directly modifying submodules - git.cmd() - .args(["submodule", "set-branch", "-b", tag_or_branch]) - .arg(rel_path) - .exec()?; - - let rev = git.get_rev(tag_or_branch, &path)?; - - dep_id = Some(DepIdentifier::Branch { - name: tag_or_branch.to_string(), - rev, - r#override: false, - }); - } - - trace!(?dep_id, ?tag_or_branch, "resolved dep id"); - if let Some(dep_id) = &dep_id { - new_insertion = true; - lockfile.insert(rel_path.to_path_buf(), dep_id.clone()); - } - - if commit { - // update .gitmodules which is at the root of the repo, - // not necessarily at the root of the current Foundry project - let root = Git::root_of(git.root)?; - git.root(&root).add(Some(".gitmodules"))?; - } - } - - if new_insertion - || out_of_sync_deps.as_ref().is_some_and(|o| !o.is_empty()) - || !lockfile.exists() - { - lockfile.write()?; - } - - // commit the installation - if commit { - let mut msg = String::with_capacity(128); - msg.push_str("forge install: "); - msg.push_str(dep.name()); - - if let Some(tag) = &installed_tag { - msg.push_str("\n\n"); - - if let Some(dep_id) = &dep_id { - msg.push_str(dep_id.to_string().as_str()); - } else { - msg.push_str(tag); - } - } - - if !lockfile.is_empty() { - git.root(&config.root).add(Some(FOUNDRY_LOCK))?; - } - git.commit(&msg)?; - } - } - - let mut msg = format!(" {} {}", "Installed".green(), dep.name); - if let Some(tag) = dep.tag.or(installed_tag) { - msg.push(' '); - - if let Some(dep_id) = dep_id { - msg.push_str(dep_id.to_string().as_str()); - } else { - msg.push_str(tag.as_str()); - } - } - sh_println!("{msg}")?; - } - - // update `libs` in config if not included yet - if !config.libs.iter().any(|p| p == install_lib_dir) { - config.libs.push(install_lib_dir.to_path_buf()); - config.update_libs()?; - } - - Ok(()) - } -} - -pub fn install_missing_dependencies(config: &mut Config) -> bool { - DependencyInstallOpts::default().install_missing_dependencies(config) -} - -#[derive(Clone, Copy, Debug)] -struct Installer<'a> { - git: Git<'a>, - commit: bool, -} - -impl Installer<'_> { - /// Installs the dependency as an ordinary folder instead of a submodule - fn install_as_folder(self, dep: &Dependency, path: &Path) -> Result> { - let url = dep.require_url()?; - Git::clone(dep.tag.is_none(), url, Some(&path))?; - let mut dep = dep.clone(); - - if dep.tag.is_none() { - // try to find latest semver release tag - dep.tag = self.last_tag(path); - } - - // checkout the tag if necessary - self.git_checkout(&dep, path, false)?; - - trace!("updating dependency submodules recursively"); - self.git.root(path).submodule_update( - false, - false, - false, - true, - std::iter::empty::(), - )?; - - // remove git artifacts - fs::remove_dir_all(path.join(".git"))?; - - Ok(dep.tag) - } - - /// Installs the dependency as new submodule. - /// - /// This will add the git submodule to the given dir, initialize it and checkout the tag if - /// provided or try to find the latest semver, release tag. - fn install_as_submodule(self, dep: &Dependency, path: &Path) -> Result> { - // install the dep - self.git_submodule(dep, path)?; - - let mut dep = dep.clone(); - if dep.tag.is_none() { - // try to find latest semver release tag - dep.tag = self.last_tag(path); - } - - // checkout the tag if necessary - self.git_checkout(&dep, path, true)?; - - trace!("updating dependency submodules recursively"); - self.git.root(path).submodule_update( - false, - false, - false, - true, - std::iter::empty::(), - )?; - - // sync submodules config with changes in .gitmodules, see - self.git.root(path).submodule_sync()?; - - if self.commit { - self.git.add(Some(path))?; - } - - Ok(dep.tag) - } - - fn last_tag(self, path: &Path) -> Option { - if self.git.shallow { - None - } else { - self.git_semver_tags(path).ok().and_then(|mut tags| tags.pop()).map(|(tag, _)| tag) - } - } - - /// Returns all semver git tags sorted in ascending order - fn git_semver_tags(self, path: &Path) -> Result> { - let out = self.git.root(path).tag()?; - let mut tags = Vec::new(); - // tags are commonly prefixed which would make them not semver: v1.2.3 is not a semantic - // version - let common_prefixes = &["v-", "v", "release-", "release"]; - for tag in out.lines() { - let mut maybe_semver = tag; - for &prefix in common_prefixes { - if let Some(rem) = tag.strip_prefix(prefix) { - maybe_semver = rem; - break; - } - } - match Version::parse(maybe_semver) { - Ok(v) => { - // ignore if additional metadata, like rc, beta, etc... - if v.build.is_empty() && v.pre.is_empty() { - tags.push((tag.to_string(), v)); - } - } - Err(err) => { - warn!(?err, ?maybe_semver, "No semver tag"); - } - } - } - - tags.sort_by(|(_, a), (_, b)| a.cmp(b)); - - Ok(tags) - } - - /// Install the given dependency as git submodule in `target_dir`. - fn git_submodule(self, dep: &Dependency, path: &Path) -> Result<()> { - let url = dep.require_url()?; - - // make path relative to the git root, already checked above - let path = path.strip_prefix(self.git.root).unwrap(); - - trace!(?dep, url, ?path, "installing git submodule"); - self.git.submodule_add(true, url, path) - } - - fn git_checkout(self, dep: &Dependency, path: &Path, recurse: bool) -> Result { - // no need to checkout if there is no tag - let Some(mut tag) = dep.tag.clone() else { return Ok(String::new()) }; - - let mut is_branch = false; - // only try to match tag if current terminal is a tty - if std::io::stdout().is_terminal() { - if tag.is_empty() { - tag = self.match_tag(&tag, path)?; - } else if let Some(branch) = self.match_branch(&tag, path)? { - trace!(?tag, ?branch, "selecting branch for given tag"); - tag = branch; - is_branch = true; - } - } - let url = dep.url.as_ref().unwrap(); - - let res = self.git.root(path).checkout(recurse, &tag); - if let Err(mut e) = res { - // remove dependency on failed checkout - fs::remove_dir_all(path)?; - if e.to_string().contains("did not match any file(s) known to git") { - e = eyre::eyre!("Tag: \"{tag}\" not found for repo \"{url}\"!") - } - return Err(e); - } - - if is_branch { Ok(tag) } else { Ok(String::new()) } - } - - /// disambiguate tag if it is a version tag - fn match_tag(self, tag: &str, path: &Path) -> Result { - // only try to match if it looks like a version tag - if !DEPENDENCY_VERSION_TAG_REGEX.is_match(tag) { - return Ok(tag.into()); - } - - // generate candidate list by filtering `git tag` output, valid ones are those "starting - // with" the user-provided tag (ignoring the starting 'v'), for example, if the user - // specifies 1.5, then v1.5.2 is a valid candidate, but v3.1.5 is not - let trimmed_tag = tag.trim_start_matches('v').to_string(); - let output = self.git.root(path).tag()?; - let mut candidates: Vec = output - .trim() - .lines() - .filter(|x| x.trim_start_matches('v').starts_with(&trimmed_tag)) - .map(|x| x.to_string()) - .rev() - .collect(); - - // no match found, fall back to the user-provided tag - if candidates.is_empty() { - return Ok(tag.into()); - } - - // have exact match - for candidate in &candidates { - if candidate == tag { - return Ok(tag.into()); - } - } - - // only one candidate, ask whether the user wants to accept or not - if candidates.len() == 1 { - let matched_tag = &candidates[0]; - let input = prompt!( - "Found a similar version tag: {matched_tag}, do you want to use this instead? [Y/n] " - )?; - return if match_yn(input) { Ok(matched_tag.clone()) } else { Ok(tag.into()) }; - } - - // multiple candidates, ask the user to choose one or skip - candidates.insert(0, String::from("SKIP AND USE ORIGINAL TAG")); - sh_println!("There are multiple matching tags:")?; - for (i, candidate) in candidates.iter().enumerate() { - sh_println!("[{i}] {candidate}")?; - } - - let n_candidates = candidates.len(); - loop { - let input: String = - prompt!("Please select a tag (0-{}, default: 1): ", n_candidates - 1)?; - let s = input.trim(); - // default selection, return first candidate - let n = if s.is_empty() { Ok(1) } else { s.parse() }; - // match user input, 0 indicates skipping and use original tag - match n { - Ok(0) => return Ok(tag.into()), - Ok(i) if (1..=n_candidates).contains(&i) => { - let c = &candidates[i]; - sh_println!("[{i}] {c} selected")?; - return Ok(c.clone()); - } - _ => continue, - } - } - } - - fn match_branch(self, tag: &str, path: &Path) -> Result> { - // fetch remote branches and check for tag - let output = self.git.root(path).cmd().args(["branch", "-r"]).get_stdout_lossy()?; - - let mut candidates = output - .lines() - .map(|x| x.trim().trim_start_matches("origin/")) - .filter(|x| x.starts_with(tag)) - .map(ToString::to_string) - .rev() - .collect::>(); - - trace!(?candidates, ?tag, "found branch candidates"); - - // no match found, fall back to the user-provided tag - if candidates.is_empty() { - return Ok(None); - } - - // have exact match - for candidate in &candidates { - if candidate == tag { - return Ok(Some(tag.to_string())); - } - } - - // only one candidate, ask whether the user wants to accept or not - if candidates.len() == 1 { - let matched_tag = &candidates[0]; - let input = prompt!( - "Found a similar branch: {matched_tag}, do you want to use this instead? [Y/n] " - )?; - return if match_yn(input) { Ok(Some(matched_tag.clone())) } else { Ok(None) }; - } - - // multiple candidates, ask the user to choose one or skip - candidates.insert(0, format!("{tag} (original branch)")); - sh_println!("There are multiple matching branches:")?; - for (i, candidate) in candidates.iter().enumerate() { - sh_println!("[{i}] {candidate}")?; - } - - let n_candidates = candidates.len(); - let input: String = prompt!( - "Please select a tag (0-{}, default: 1, Press to cancel): ", - n_candidates - 1 - )?; - let input = input.trim(); - - // default selection, return None - if input.is_empty() { - sh_println!("Canceled branch matching")?; - return Ok(None); - } - - // match user input, 0 indicates skipping and use original tag - match input.parse::() { - Ok(0) => Ok(Some(tag.into())), - Ok(i) if (1..=n_candidates).contains(&i) => { - let c = &candidates[i]; - sh_println!("[{i}] {c} selected")?; - Ok(Some(c.clone())) - } - _ => Ok(None), - } - } -} - -/// Matches on the result of a prompt for yes/no. -/// -/// Defaults to true. -fn match_yn(input: String) -> bool { - let s = input.trim().to_lowercase(); - matches!(s.as_str(), "" | "y" | "yes") -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::tempdir; - - #[test] - #[ignore = "slow"] - fn get_oz_tags() { - let tmp = tempdir().unwrap(); - let git = Git::new(tmp.path()); - let installer = Installer { git, commit: false }; - - git.init().unwrap(); - - let dep: Dependency = "openzeppelin/openzeppelin-contracts".parse().unwrap(); - let libs = tmp.path().join("libs"); - fs::create_dir(&libs).unwrap(); - let submodule = libs.join("openzeppelin-contracts"); - installer.git_submodule(&dep, &submodule).unwrap(); - assert!(submodule.exists()); - - let tags = installer.git_semver_tags(&submodule).unwrap(); - assert!(!tags.is_empty()); - let v480: Version = "4.8.0".parse().unwrap(); - assert!(tags.iter().any(|(_, v)| v == &v480)); - } -} diff --git a/crates/forge/src/cmd/lint.rs b/crates/forge/src/cmd/lint.rs index 4b3bf58893a69..db5b89e6c8a1d 100644 --- a/crates/forge/src/cmd/lint.rs +++ b/crates/forge/src/cmd/lint.rs @@ -5,11 +5,16 @@ use forge_lint::{ sol::{SolLint, SolLintError, SolidityLinter}, }; use foundry_cli::{ - opts::{BuildOpts, solar_pcx_from_build_opts}, + opts::{BuildOpts, configure_pcx_all_sources}, utils::{FoundryPathExt, LoadConfig}, }; -use foundry_compilers::{solc::SolcLanguage, utils::SOLC_EXTENSIONS}; -use foundry_config::{filter::expand_globs, lint::Severity}; +use foundry_common::shell; +use foundry_compilers::{FileFilter, solc::SolcLanguage, utils::SOLC_EXTENSIONS}; +use foundry_config::{ + SkipBuildFilters, + filter::{expand_globs, is_ignored_path}, + lint::Severity, +}; use std::path::PathBuf; /// CLI arguments for `forge lint`. @@ -17,33 +22,36 @@ use std::path::PathBuf; pub struct LintArgs { /// Path to the file to be checked. Overrides the `ignore` project config. #[arg(value_hint = ValueHint::FilePath, value_name = "PATH", num_args(1..))] - paths: Vec, + pub(crate) paths: Vec, /// Specifies which lints to run based on severity. Overrides the `severity` project config. /// /// Supported values: `high`, `med`, `low`, `info`, `gas`. #[arg(long, value_name = "SEVERITY", num_args(1..))] - severity: Option>, + pub(crate) severity: Option>, /// Specifies which lints to run based on their ID (e.g., "incorrect-shift"). Overrides the /// `exclude_lints` project config. #[arg(long = "only-lint", value_name = "LINT_ID", num_args(1..))] - lint: Option>, + pub(crate) lint: Option>, - /// Activates the linter's JSON formatter (rustc-compatible). + /// Report inline suppression comments (e.g. `// forge-lint: disable-next-line(...)`) that did + /// not suppress any diagnostic during the run. #[arg(long)] - json: bool, + pub(crate) report_unused_suppressions: bool, #[command(flatten)] - build: BuildOpts, + pub(crate) build: BuildOpts, } foundry_config::impl_figment_convert!(LintArgs, build); impl LintArgs { - pub fn run(self) -> Result<()> { - let config = self.load_config()?; - let project = config.project()?; + pub async fn run(self) -> Result<()> { + let format_json = shell::is_json(); + let config = self.load_config_with_dependencies()?; + + let project = config.ephemeral_project()?; let path_config = config.project_paths(); // Expand ignore globs and canonicalize from the get go @@ -53,13 +61,13 @@ impl LintArgs { .collect::>(); let cwd = std::env::current_dir()?; - let input = match &self.paths[..] { + let mut input = match &self.paths[..] { [] => { // Retrieve the project paths, and filter out the ignored ones. config .project_paths::() .input_files_iter() - .filter(|p| !(ignored.contains(p) || ignored.contains(&cwd.join(p)))) + .filter(|p| !is_ignored_path(p, &ignored, &cwd)) .collect() } paths => { @@ -70,54 +78,88 @@ impl LintArgs { inputs .extend(foundry_compilers::utils::source_files(path, SOLC_EXTENSIONS)); } else if path.is_sol() { - inputs.push(path.to_path_buf()); + inputs.push(path.clone()); } else { - warn!("Cannot process path {}", path.display()); + warn!("cannot process path {}", path.display()); } } inputs } }; + let skip = SkipBuildFilters::new(config.skip.clone(), config.root.clone()); + input.retain(|path| skip.is_match(path)); if input.is_empty() { - sh_println!("Nothing to lint")?; + sh_status!("nothing to lint")?; return Ok(()); } let parse_lints = |lints: &[String]| -> Result, SolLintError> { - lints.iter().map(|s| SolLint::try_from(s.as_str())).collect() + lints.iter().map(|s| s.parse()).collect() }; // Override default lint config with user-defined lints - let (include, exclude) = match &self.lint { - Some(cli_lints) => (Some(parse_lints(cli_lints)?), None), - None => (None, Some(parse_lints(&config.lint.exclude_lints)?)), - }; - - // Override default severity config with user-defined severity - let severity = match self.severity { - Some(target) => target, - None => config.lint.severity, + // When --only-lint is used, bypass the severity filter by setting it to None + let (include, exclude, severity) = match &self.lint { + Some(cli_lints) => (Some(parse_lints(cli_lints)?), None, vec![]), + None => { + let severity = self.severity.clone().unwrap_or(config.lint.severity.clone()); + (None, Some(parse_lints(&config.lint.exclude_lints)?), severity) + } }; if project.compiler.solc.is_none() { - return Err(eyre!("Linting not supported for this language")); + return Err(eyre!("linting not supported for this language")); } let linter = SolidityLinter::new(path_config) - .with_json_emitter(self.json) + .with_json_emitter(format_json) + .with_json_emitter_stdout(format_json) .with_description(true) .with_lints(include) .without_lints(exclude) - .with_severity(if severity.is_empty() { None } else { Some(severity) }); - - let sess = linter.init(); + .with_severity(if severity.is_empty() { None } else { Some(severity) }) + .with_report_unused_suppressions(self.report_unused_suppressions) + .with_lint_specific(&config.lint.lint_specific); - let pcx = solar_pcx_from_build_opts(&sess, &self.build, Some(&project), Some(&input))?; - linter.early_lint(&input, pcx); - - let pcx = solar_pcx_from_build_opts(&sess, &self.build, Some(&project), Some(&input))?; - linter.late_lint(&input, pcx); + let mut opts = solar::interface::config::CompileOpts::default(); + if format_json { + opts.error_format = solar::interface::config::ErrorFormat::RustcJson; + } + let session = solar::interface::Session::builder().opts(opts); + let session = + if format_json { session.build() } else { session.with_stderr_emitter().build() }; + if format_json { + let writer = Box::new(std::io::BufWriter::new(std::io::stdout())); + let emitter = solar::interface::diagnostics::JsonEmitter::new( + writer, + session.clone_source_map(), + solar::interface::ColorChoice::Never, + ) + .rustc_like(true); + session.dcx.set_emitter(Box::new(emitter)); + } + let mut compiler = solar::sema::Compiler::new(session); + + // Load the solar-compatible sources to the pcx before linting + let has_sources = compiler.enter_mut(|compiler| -> Result { + let mut pcx = compiler.parse(); + pcx.set_resolve_imports(true); + let has_sources = + configure_pcx_all_sources(&mut pcx, &config, Some(&project), Some(&input))?; + pcx.parse(); + Ok(has_sources) + })?; + if !has_sources { + return Err(eyre!("unable to lint. Solar only supports Solidity versions >=0.8.0")); + } + if let Err(err) = linter.lint(&input, config.deny, &mut compiler) { + if format_json && compiler.dcx().has_errors().is_err() { + // Solar already emitted the error, so bypass the top-level error printer. + std::process::exit(1); + } + return Err(err); + } Ok(()) } diff --git a/crates/forge/src/cmd/lsp.rs b/crates/forge/src/cmd/lsp.rs new file mode 100644 index 0000000000000..fba0c4f45821e --- /dev/null +++ b/crates/forge/src/cmd/lsp.rs @@ -0,0 +1,97 @@ +use clap::Parser; +use eyre::Result; +use foundry_compilers::compilers::multi::MultiCompilerLanguage; +use foundry_config::{Config, load_config_with_root}; +use solar::config::ImportRemapping; +use solar_lsp::FoundryWorkspaceConfig; +use std::{ + io::IsTerminal, + path::{MAIN_SEPARATOR, Path, PathBuf}, +}; + +mod editor; + +/// Open a Solidity project in VS Code or run its language server. +#[derive(Debug, Default, Parser)] +pub struct LspArgs { + /// Run the language server over standard input/output. + #[arg(long, conflicts_with_all = ["vscode", "path", "code_path"])] + pub stdio: bool, + + /// Open a VS Code Extension Development Host even when input is redirected. + #[arg(long)] + pub vscode: bool, + + /// Project directory to open in VS Code. Defaults to the current directory. + #[arg(value_hint = clap::ValueHint::DirPath)] + pub path: Option, + + /// VS Code executable or command. Defaults to `code` on PATH. + #[arg(long, value_hint = clap::ValueHint::ExecutablePath)] + pub code_path: Option, +} + +pub async fn run(args: LspArgs) -> Result<()> { + if !args.stdio + && (args.vscode + || args.path.is_some() + || args.code_path.is_some() + || std::io::stdin().is_terminal()) + { + return editor::launch(args.path.as_deref(), args.code_path.as_deref()); + } + + let config = solar_lsp::LaunchConfig::from(solar::config::LspArgs { stdio: args.stdio }) + .with_default_forge_path(std::env::current_exe()?) + .with_selected_profile(Config::selected_profile().to_string()) + .with_foundry_workspace_config_loader(|root| { + foundry_workspace_config(root, load_config_with_root(Some(root))?) + }); + solar_lsp::launch(config).await?; + Ok(()) +} + +fn foundry_workspace_config(root: &Path, config: Config) -> Result { + let paths = config.project_paths::(); + let resolved_root = paths.root; + let sources = rebase_workspace_path(&resolved_root, root, paths.sources); + let tests = rebase_workspace_path(&resolved_root, root, paths.tests); + let scripts = rebase_workspace_path(&resolved_root, root, paths.scripts); + + Ok(FoundryWorkspaceConfig::new(root) + .with_source_roots([sources.clone(), tests.clone(), scripts.clone()]) + .with_flycheck_source_roots([sources, tests, scripts]) + .with_include_paths( + paths + .libraries + .into_iter() + .chain(paths.include_paths) + .map(|path| rebase_workspace_path(&resolved_root, root, path)), + ) + .with_import_remappings(paths.remappings.into_iter().map(|remapping| ImportRemapping { + context: rebase_remapping_path( + &resolved_root, + root, + remapping.context.unwrap_or_default(), + ), + prefix: remapping.name, + path: rebase_remapping_path(&resolved_root, root, remapping.path), + })) + .with_evm_version(config.evm_version.to_string().parse()?)) +} + +fn rebase_workspace_path(resolved_root: &Path, root: &Path, path: PathBuf) -> PathBuf { + let Ok(relative) = path.strip_prefix(resolved_root) else { return path }; + root.join(relative) +} + +fn rebase_remapping_path(resolved_root: &Path, root: &Path, path: impl Into) -> String { + let path = path.into(); + let has_directory_boundary = path.ends_with(['/', '\\']); + let mut rebased = + rebase_workspace_path(resolved_root, root, PathBuf::from(path)).display().to_string(); + if has_directory_boundary && !rebased.ends_with(['/', '\\']) { + rebased.push(MAIN_SEPARATOR); + } + rebased +} diff --git a/crates/forge/src/cmd/lsp/editor.rs b/crates/forge/src/cmd/lsp/editor.rs new file mode 100644 index 0000000000000..706e4ce5c2515 --- /dev/null +++ b/crates/forge/src/cmd/lsp/editor.rs @@ -0,0 +1,421 @@ +//! Launch the bundled VS Code client without requiring a Foundry checkout. + +use alloy_primitives::{Keccak256, keccak256}; +use eyre::{Context, Result, ensure, eyre}; +use flate2::read::GzDecoder; +use foundry_common::fs::canonicalize_path; +use foundry_config::Config; +use std::{ + fs, + io::{self, Write}, + path::{Path, PathBuf}, + process::{Command, Stdio}, +}; + +#[cfg(unix)] +use std::os::unix::fs::{DirBuilderExt, MetadataExt, PermissionsExt, symlink}; + +const CLIENT: &[u8] = include_bytes!("../../../../../editors/vscode/dist/extension.js.gz"); +const ASSETS: &[(&str, &[u8])] = &[ + ("package.json", include_bytes!("../../../../../editors/vscode/package.json")), + ( + "language-configuration.json", + include_bytes!("../../../../../editors/vscode/language-configuration.json"), + ), + ( + "syntaxes/solidity.json", + include_bytes!("../../../../../editors/vscode/syntaxes/solidity.json"), + ), + ( + "syntaxes/solidity-markdown-injection.json", + include_bytes!("../../../../../editors/vscode/syntaxes/solidity-markdown-injection.json"), + ), + ("syntaxes/LICENSE", include_bytes!("../../../../../editors/vscode/syntaxes/LICENSE")), + ("LICENSE", include_bytes!("../../../../../editors/vscode/LICENSE")), + ("LICENSE-MIT", include_bytes!("../../../../../LICENSE-MIT")), + ("LICENSE-APACHE", include_bytes!("../../../../../LICENSE-APACHE")), + ("NOTICE.md", include_bytes!("../../../../../editors/vscode/NOTICE.md")), + ( + "THIRD_PARTY_NOTICES.txt", + include_bytes!("../../../../../editors/vscode/dist/THIRD_PARTY_NOTICES.txt"), + ), +]; + +pub(super) fn launch(path: Option<&Path>, code_path: Option<&Path>) -> Result<()> { + let project = + canonicalize_path(path.map(Path::to_path_buf).unwrap_or(std::env::current_dir()?)) + .wrap_err("Could not open the project directory")?; + ensure!(project.is_dir(), "Project path must be a directory: {}", project.display()); + let forge = std::env::current_exe()?; + let profile = Config::selected_profile().to_string(); + let code = code_path.map(Path::to_path_buf).unwrap_or_else(default_code_path); + let launch_error = || { + format!( + "Could not launch VS Code using {}. Install VS Code and its `code` command, \ + or pass --code-path to the VS Code CLI. \ + Use `forge lsp --stdio` for another editor.", + code.display() + ) + }; + let code = which::which(&code).wrap_err_with(launch_error)?; + let code_target = canonicalize_path(&code).wrap_err_with(launch_error)?; + let cache = Config::foundry_cache_dir() + .ok_or_else(|| eyre!("Could not find the Foundry cache directory"))? + .join("lsp"); + let extension = prepare_extension(&cache)?; + + // Keep launcher paths distinct for dispatchers such as Snap, even when their targets match. + // Include the target too so repointing a launcher symlink cannot reuse another editor's state. + let session_key = + keccak256(serde_json::to_vec(&(&project, &forge, &profile, &code, &code_target))?); + let session = + vscode_session_dir(&Config::data_dir()?.join("lsp"), &format!("{session_key:x}")[..16])?; + // Portable VS Code appends a Unix socket name to user-data even when XDG_RUNTIME_DIR is set. + // A short link keeps sockets below platform limits without storing editor state in /tmp. + #[cfg(unix)] + let session = vscode_session_link(&session, Path::new("/tmp"))?; + let user_data = session.join("user-data"); + let extensions = session.join("extensions"); + fs::create_dir_all(user_data.join("User"))?; + fs::create_dir_all(&extensions)?; + let settings = user_data.join("User/settings.json"); + if !settings.exists() { + let mut file = tempfile::NamedTempFile::new_in(user_data.join("User"))?; + serde_json::to_writer_pretty( + &mut file, + &serde_json::json!({ + "solarLsp.forgePath": forge, + "workbench.startupEditor": "none", + }), + )?; + file.write_all(b"\n")?; + if let Err(error) = file.persist_noclobber(&settings) + && error.error.kind() != io::ErrorKind::AlreadyExists + { + return Err(error.into()); + } + } + + sh_status!("Opening VS Code with Forge Solidity support: {}", project.display())?; + let status = Command::new(&code) + .arg("--new-window") + .arg("--extensionDevelopmentPath") + .arg(&extension) + .arg("--user-data-dir") + .arg(&user_data) + .arg("--extensions-dir") + .arg(&extensions) + .arg(&project) + .env_remove("VSCODE_APPDATA") + .env_remove("VSCODE_EXTENSIONS") + // Portable installations otherwise override --user-data-dir during autodetection. + .env("VSCODE_PORTABLE", &session) + .env_remove("VSCODE_IPC_HOOK_CLI") + .env("FOUNDRY_LSP_FORGE", &forge) + .env("FOUNDRY_PROFILE", &profile) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::inherit()) + .status() + .wrap_err_with(launch_error)?; + ensure!(status.success(), "VS Code launcher exited with {status}"); + Ok(()) +} + +fn vscode_session_dir(data: &Path, key: &str) -> Result { + let root = data.join("vscode"); + let session = root.join(key); + #[cfg(unix)] + { + fs::create_dir_all(data)?; + let uid = rustix::process::geteuid().as_raw(); + create_private_dir(&root, uid)?; + create_private_dir(&session, uid)?; + } + #[cfg(not(unix))] + fs::create_dir_all(&session)?; + Ok(session) +} + +#[cfg(unix)] +fn vscode_session_link(session: &Path, temp: &Path) -> Result { + let session = canonicalize_path(session)?; + let uid = rustix::process::geteuid().as_raw(); + let root = temp.join(format!("foundry-lsp-{uid}")); + create_private_dir(&root, uid)?; + // Include the data location so separate homes cannot share a running editor. The prefix also + // avoids colliding with older launchers' temporary profile directories. + let key = keccak256(session.as_os_str().as_encoded_bytes()); + let link = root.join(format!("p-{}", &format!("{key:x}")[..16])); + if let Err(error) = symlink(&session, &link) + && error.kind() != io::ErrorKind::AlreadyExists + { + return Err(error.into()); + } + let metadata = fs::symlink_metadata(&link)?; + ensure!( + metadata.file_type().is_symlink() && metadata.uid() == uid, + "VS Code session link is not a symlink owned by the current user: {}", + link.display() + ); + ensure!( + fs::read_link(&link)? == session, + "VS Code session link points to an unexpected directory: {}", + link.display() + ); + Ok(link) +} + +#[cfg(unix)] +fn create_private_dir(path: &Path, uid: u32) -> Result<()> { + // Atomic creation and no-follow metadata reject directories planted by another user. + if let Err(error) = fs::DirBuilder::new().mode(0o700).create(path) + && error.kind() != io::ErrorKind::AlreadyExists + { + return Err(error.into()); + } + let metadata = fs::symlink_metadata(path)?; + ensure!( + metadata.file_type().is_dir(), + "VS Code session path is not a directory: {}", + path.display() + ); + ensure!( + metadata.uid() == uid, + "VS Code session path is not owned by the current user: {}", + path.display() + ); + ensure!( + metadata.permissions().mode() & 0o777 == 0o700, + "VS Code session path is not private: {}", + path.display() + ); + Ok(()) +} + +fn prepare_extension(cache: &Path) -> Result { + let mut hash = Keccak256::new(); + hash.update(CLIENT); + for (name, bytes) in ASSETS { + hash.update(name.as_bytes()); + hash.update(bytes); + } + let parent = cache.join("extensions"); + let directory = parent.join(format!("{:x}", hash.finalize())); + if directory.is_dir() { + return Ok(directory); + } + + fs::create_dir_all(&parent)?; + let staging = tempfile::Builder::new().prefix(".extract-").tempdir_in(&parent)?; + fs::create_dir_all(staging.path().join("out"))?; + io::copy( + &mut GzDecoder::new(CLIENT), + &mut fs::File::create(staging.path().join("out/extension.js"))?, + )?; + for (name, bytes) in ASSETS { + let path = staging.path().join(name); + fs::create_dir_all(path.parent().unwrap())?; + fs::write(path, bytes)?; + } + // Publish a complete directory atomically; concurrent launches may finish extraction first. + if let Err(error) = fs::rename(staging.path(), &directory) + && !directory.is_dir() + { + return Err(error).wrap_err("Could not cache the bundled VS Code extension"); + } + Ok(directory) +} + +fn default_code_path() -> PathBuf { + #[cfg(target_os = "macos")] + { + if std::env::var_os("PATH").is_some_and(|paths| { + std::env::split_paths(&paths).any(|path| path.join("code").is_file()) + }) { + return PathBuf::from("code"); + } + // The application includes a CLI even when `code` has not been added to PATH. + let cli = + PathBuf::from("/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code"); + if cli.is_file() { + return cli; + } + } + PathBuf::from(if cfg!(windows) { "code.cmd" } else { "code" }) +} + +#[cfg(test)] +mod tests { + use std::fs; + + #[cfg(unix)] + use std::os::unix::{ + fs::{PermissionsExt, symlink}, + net::UnixListener, + }; + + #[test] + fn vscode_session_uses_durable_storage() { + let data = tempfile::tempdir().unwrap(); + let session = super::vscode_session_dir(data.path(), "0123456789abcdef").unwrap(); + assert_eq!(session, data.path().join("vscode/0123456789abcdef")); + assert!(session.is_dir()); + } + + #[cfg(unix)] + #[test] + fn vscode_session_rejects_symlink_root() { + let temp = tempfile::Builder::new().prefix("fl-").tempdir_in("/tmp").unwrap(); + let data = tempfile::tempdir().unwrap(); + let redirected = tempfile::tempdir().unwrap(); + let session = super::vscode_session_dir(data.path(), "0123456789abcdef").unwrap(); + let link = super::vscode_session_link(&session, temp.path()).unwrap(); + let root = link.parent().unwrap(); + fs::remove_dir_all(root).unwrap(); + symlink(redirected.path(), root).unwrap(); + + assert!(super::vscode_session_link(&session, temp.path()).is_err()); + assert_eq!(fs::read_dir(redirected.path()).unwrap().count(), 0); + } + + #[cfg(unix)] + #[test] + fn vscode_session_rejects_symlink_session() { + let data = tempfile::tempdir().unwrap(); + let redirected = tempfile::tempdir().unwrap(); + let session = super::vscode_session_dir(data.path(), "0123456789abcdef").unwrap(); + fs::remove_dir(&session).unwrap(); + symlink(redirected.path(), &session).unwrap(); + + assert!(super::vscode_session_dir(data.path(), "0123456789abcdef").is_err()); + assert_eq!(fs::read_dir(redirected.path()).unwrap().count(), 0); + } + + #[cfg(unix)] + #[test] + fn vscode_session_rejects_unexpected_link() { + let temp = tempfile::Builder::new().prefix("fl-").tempdir_in("/tmp").unwrap(); + let data = tempfile::tempdir().unwrap(); + let redirected = tempfile::tempdir().unwrap(); + let session = super::vscode_session_dir(data.path(), "0123456789abcdef").unwrap(); + let link = super::vscode_session_link(&session, temp.path()).unwrap(); + fs::remove_file(&link).unwrap(); + symlink(redirected.path(), &link).unwrap(); + assert!(super::vscode_session_link(&session, temp.path()).is_err()); + assert_eq!(fs::read_dir(redirected.path()).unwrap().count(), 0); + + fs::remove_file(&link).unwrap(); + fs::create_dir(&link).unwrap(); + assert!(super::vscode_session_link(&session, temp.path()).is_err()); + assert!(link.is_dir()); + } + + #[cfg(unix)] + #[test] + fn vscode_session_rejects_shared_permissions() { + let temp = tempfile::Builder::new().prefix("fl-").tempdir_in("/tmp").unwrap(); + let data = tempfile::tempdir().unwrap(); + let session = super::vscode_session_dir(data.path(), "0123456789abcdef").unwrap(); + for directory in [&session, session.parent().unwrap()] { + fs::set_permissions(directory, fs::Permissions::from_mode(0o755)).unwrap(); + assert!(super::vscode_session_dir(data.path(), "0123456789abcdef").is_err()); + fs::set_permissions(directory, fs::Permissions::from_mode(0o700)).unwrap(); + } + let link = super::vscode_session_link(&session, temp.path()).unwrap(); + fs::set_permissions(link.parent().unwrap(), fs::Permissions::from_mode(0o755)).unwrap(); + assert!(super::vscode_session_link(&session, temp.path()).is_err()); + } + + #[cfg(unix)] + #[test] + fn vscode_session_rejects_another_owner() { + let temp = tempfile::Builder::new().prefix("fl-").tempdir_in("/tmp").unwrap(); + let uid = rustix::process::geteuid().as_raw(); + let error = super::create_private_dir(temp.path(), uid.wrapping_add(1)).unwrap_err(); + assert!( + error.to_string().starts_with("VS Code session path is not owned by the current user:") + ); + } + + #[cfg(unix)] + #[test] + fn vscode_session_allows_concurrent_launches() { + let temp = tempfile::Builder::new().prefix("fl-").tempdir_in("/tmp").unwrap(); + let data = tempfile::tempdir().unwrap(); + std::thread::scope(|scope| { + let threads = (0..8) + .map(|_| { + scope.spawn(|| { + let session = + super::vscode_session_dir(data.path(), "0123456789abcdef").unwrap(); + super::vscode_session_link(&session, temp.path()).unwrap() + }) + }) + .collect::>(); + let session = super::vscode_session_dir(data.path(), "0123456789abcdef").unwrap(); + let expected = super::vscode_session_link(&session, temp.path()).unwrap(); + for thread in threads { + assert_eq!(thread.join().unwrap(), expected); + } + }); + } + + #[cfg(unix)] + #[test] + fn vscode_session_path_fits_unix_socket_limit() { + let temp = tempfile::Builder::new().prefix("fl-").tempdir_in("/tmp").unwrap(); + let data = tempfile::tempdir().unwrap(); + let session = super::vscode_session_dir( + &data.path().join("long-home-directory".repeat(8)), + "0123456789abcdef", + ) + .unwrap(); + fs::create_dir(session.join("user-data")).unwrap(); + assert!(session.as_os_str().len() > 103); + let link = super::vscode_session_link(&session, temp.path()).unwrap(); + let socket = link.join("user-data/1.13-main.sock"); + let length = socket.as_os_str().len(); + assert!(length < 103, "{length} bytes"); + let _listener = UnixListener::bind(socket).unwrap(); + } + + #[cfg(unix)] + #[test] + fn vscode_session_uses_private_stable_root() { + let temp = tempfile::Builder::new().prefix("fl-").tempdir_in("/tmp").unwrap(); + let data = tempfile::tempdir().unwrap(); + let session = super::vscode_session_dir(data.path(), "fedcba9876543210").unwrap(); + let link = super::vscode_session_link(&session, temp.path()).unwrap(); + assert_eq!(super::vscode_session_dir(data.path(), "fedcba9876543210").unwrap(), session); + assert_eq!(super::vscode_session_link(&session, temp.path()).unwrap(), link); + for directory in [&session, session.parent().unwrap(), link.parent().unwrap()] { + assert_eq!(fs::metadata(directory).unwrap().permissions().mode() & 0o777, 0o700); + } + } + + #[cfg(unix)] + #[test] + fn vscode_session_survives_temp_cleanup() { + let temp = tempfile::Builder::new().prefix("fl-").tempdir_in("/tmp").unwrap(); + let data = tempfile::tempdir().unwrap(); + let session = super::vscode_session_dir(data.path(), "0123456789abcdef").unwrap(); + let link = super::vscode_session_link(&session, temp.path()).unwrap(); + let files = + ["user-data/User/settings.json", "user-data/User/History/entry", "extensions/entry"]; + for file in files { + let path = link.join(file); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, file).unwrap(); + } + fs::remove_dir_all(temp.path()).unwrap(); + for file in files { + assert_eq!(fs::read_to_string(session.join(file)).unwrap(), file); + } + fs::create_dir(temp.path()).unwrap(); + assert_eq!(super::vscode_session_link(&session, temp.path()).unwrap(), link); + for file in files { + assert_eq!(fs::read_to_string(link.join(file)).unwrap(), file); + } + } +} diff --git a/crates/forge/src/cmd/mod.rs b/crates/forge/src/cmd/mod.rs index 0a0945bab99e9..e3860871377de 100644 --- a/crates/forge/src/cmd/mod.rs +++ b/crates/forge/src/cmd/mod.rs @@ -5,6 +5,8 @@ //! implement `figment::Provider` which allows the subcommand to override the config's defaults, see //! [`foundry_config::Config`]. +pub use foundry_cli::install; + pub mod bind; pub mod bind_json; pub mod build; @@ -18,12 +20,13 @@ pub mod doc; pub mod eip712; pub mod flatten; pub mod fmt; +pub mod fuzz; pub mod geiger; -pub mod generate; pub mod init; pub mod inspect; -pub mod install; pub mod lint; +pub mod lsp; +pub mod reinit; pub mod remappings; pub mod remove; pub mod selectors; diff --git a/crates/forge/src/cmd/reinit.rs b/crates/forge/src/cmd/reinit.rs new file mode 100644 index 0000000000000..9c74ad2fca3ae --- /dev/null +++ b/crates/forge/src/cmd/reinit.rs @@ -0,0 +1,31 @@ +use clap::{Parser, ValueHint}; +use eyre::Result; +use foundry_cli::utils::{CommandUtils, Git, LoadConfig}; +use foundry_config::impl_figment_convert_basic; +use std::{path::PathBuf, process::Command}; + +/// CLI arguments for `forge reinit`. +#[derive(Clone, Debug, Parser)] +pub struct ReinitArgs { + /// The project's root path. + /// + /// By default root of the Git repository, if in one, + /// or the current working directory. + #[arg(long, value_hint = ValueHint::DirPath, value_name = "PATH")] + root: Option, +} +impl_figment_convert_basic!(ReinitArgs); + +impl ReinitArgs { + pub fn run(self) -> Result<()> { + let config = self.load_config()?; + let root = Git::root_of(&config.root)?; + let git = Git::new(&root); + + Command::new("git") + .current_dir(&root) + .args(["submodule", "deinit", "--force", "."]) + .exec()?; + git.submodule_update(false, false, false, true, std::iter::empty::()) + } +} diff --git a/crates/forge/src/cmd/remappings.rs b/crates/forge/src/cmd/remappings.rs index c475e8199588e..fc0c796ce3238 100644 --- a/crates/forge/src/cmd/remappings.rs +++ b/crates/forge/src/cmd/remappings.rs @@ -30,16 +30,15 @@ impl RemappingArgs { } for (group, remappings) in groups { if let Some(group) = group { - sh_println!("Context: {group}")?; + sh_status!("Context: {group}")?; } else { - sh_println!("Global:")?; + sh_status!("Global:")?; } - for mut remapping in remappings { - remapping.context = None; // avoid writing context twice - sh_println!("- {remapping}")?; + for remapping in remappings { + sh_println!("{remapping}")?; } - sh_println!()?; + sh_eprintln!()?; } } else { for remapping in config.remappings { diff --git a/crates/forge/src/cmd/remove.rs b/crates/forge/src/cmd/remove.rs index 9e4629a5b726b..187466a52e443 100644 --- a/crates/forge/src/cmd/remove.rs +++ b/crates/forge/src/cmd/remove.rs @@ -33,7 +33,6 @@ impl RemoveArgs { let config = self.load_config()?; let (root, paths, _) = super::update::dependencies_paths(&self.dependencies, &config)?; let git_modules = root.join(".git/modules"); - let git = Git::new(&root); let mut lockfile = Lockfile::new(&config.root).with_git(&git); let _synced = lockfile.sync(config.install_lib_dir())?; @@ -42,10 +41,18 @@ impl RemoveArgs { git.rm(self.force, &paths)?; // remove all the dependencies from .git/modules - for (Dependency { name, url, tag, .. }, path) in self.dependencies.iter().zip(&paths) { - sh_println!("Removing '{name}' in {}, (url: {url:?}, tag: {tag:?})", path.display())?; + for (Dependency { name, tag, .. }, path) in self.dependencies.iter().zip(&paths) { + // Get the URL from git submodule config instead of using the parsed dependency URL + let url = git.submodule_url(path).unwrap_or(None); + sh_status!( + "Removing '{name}' in {}, (url: {}, tag: {})", + path.display(), + url.as_deref().unwrap_or("None"), + tag.as_deref().unwrap_or("None") + )?; let _ = lockfile.remove(path); std::fs::remove_dir_all(git_modules.join(path))?; + git.remove_submodule_config(path)?; } lockfile.write()?; diff --git a/crates/forge/src/cmd/selectors.rs b/crates/forge/src/cmd/selectors.rs index 3ced02aee0b8a..33374dd707afe 100644 --- a/crates/forge/src/cmd/selectors.rs +++ b/crates/forge/src/cmd/selectors.rs @@ -1,17 +1,33 @@ use alloy_primitives::hex; use clap::Parser; -use comfy_table::{Table, modifiers::UTF8_ROUND_CORNERS}; +use comfy_table::{ + Table, + presets::{ASCII_FULL, ASCII_MARKDOWN}, +}; use eyre::Result; use foundry_cli::{ - opts::{BuildOpts, CompilerOpts, ProjectPathOpts}, - utils::{FoundryPathExt, cache_local_signatures}, + opts::{BuildOpts, ProjectPathOpts}, + utils::{FoundryPathExt, LoadConfig, cache_local_signatures, cache_signatures_from_abis}, }; use foundry_common::{ - compile::{PathOrContractInfo, ProjectCompiler, compile_target}, + compile::{ + PathOrContractInfo, ProjectCompiler, compile_abi_project, compile_abi_project_cached, + }, + external_compiler::is_external_artifact, + fs::canonicalize_path, selectors::{SelectorImportData, import_selectors}, + shell, +}; +use foundry_compilers::{ + Project, ProjectCompileOutput, + artifacts::{ + ConfigurableContractArtifact, + output_selection::{ContractOutputSelection, EvmOutputSelection, OutputSelection}, + }, + info::ContractInfo, + multi::MultiCompiler, }; -use foundry_compilers::{artifacts::output_selection::ContractOutputSelection, info::ContractInfo}; -use std::fs::canonicalize; +use std::{collections::BTreeMap, path::Path}; /// CLI arguments for `forge selectors`. #[derive(Clone, Debug, Parser)] @@ -56,6 +72,9 @@ pub enum SelectorsSubcommands { #[command(flatten)] project_paths: ProjectPathOpts, + + #[arg(long, help = "Do not group the selectors by contract in separate tables.")] + no_group: bool, }, /// Find if a selector is present in the project @@ -72,6 +91,8 @@ pub enum SelectorsSubcommands { /// Cache project selectors (enables trace with local contracts functions and events). #[command(visible_alias = "c")] Cache { + #[arg(long, help = "Path to a folder containing additional abis to include in the cache")] + extra_abis_path: Option, #[command(flatten)] project_paths: ProjectPathOpts, }, @@ -80,57 +101,34 @@ pub enum SelectorsSubcommands { impl SelectorsSubcommands { pub async fn run(self) -> Result<()> { match self { - Self::Cache { project_paths } => { - sh_println!("Caching selectors for contracts in the project...")?; - let build_args = BuildOpts { - project_paths, - compiler: CompilerOpts { - extra_output: vec![ContractOutputSelection::Abi], - ..Default::default() - }, - ..Default::default() - }; + Self::Cache { project_paths, extra_abis_path } => { + if let Some(extra_abis_path) = extra_abis_path { + sh_status!("Caching selectors for ABIs at {extra_abis_path}")?; + cache_signatures_from_abis(extra_abis_path)?; + } - // compile the project to get the artifacts/abis - let project = build_args.project()?; - let outcome = ProjectCompiler::new().quiet(true).compile(&project)?; + sh_status!("Caching selectors for contracts in the project...")?; + let (mut project, compiler) = project_from_paths(project_paths)?; + let outcome = compile_abi_project(&mut project, compiler.quiet(true))?; cache_local_signatures(&outcome)?; } Self::Upload { contract, all, project_paths } => { - let build_args = BuildOpts { - project_paths: project_paths.clone(), - compiler: CompilerOpts { - extra_output: vec![ContractOutputSelection::Abi], - ..Default::default() - }, - ..Default::default() - }; - - let project = build_args.project()?; + let (mut project, compiler) = project_from_paths(project_paths)?; let output = if let Some(contract_info) = &contract { let Some(contract_name) = contract_info.name() else { - eyre::bail!("No contract name provided.") + eyre::bail!("No contract name provided."); }; let target_path = contract_info .path() .map(Ok) .unwrap_or_else(|| project.find_contract_path(contract_name))?; - compile_target(&target_path, &project, false)? + compile_abi_project(&mut project, compiler.target_files([target_path]))? } else { - ProjectCompiler::new().compile(&project)? + compile_abi_project(&mut project, compiler)? }; let artifacts = if all { - output - .into_artifacts_with_files() - .filter(|(file, _, _)| { - let is_sources_path = file.starts_with(&project.paths.sources); - let is_test = file.is_sol_test(); - - is_sources_path && !is_test - }) - .map(|(_, contract, artifact)| (contract, artifact)) - .collect() + selector_artifacts(output, &project.paths.sources) } else { let contract_info = contract.unwrap(); let contract = contract_info.name().unwrap().to_string(); @@ -151,37 +149,69 @@ impl SelectorsSubcommands { vec![(contract, artifact)] }; - let mut artifacts = artifacts.into_iter().peekable(); - while let Some((contract, artifact)) = artifacts.next() { + let mut abis = Vec::with_capacity(artifacts.len()); + for (contract, artifact) in artifacts { let abi = artifact.abi.ok_or_else(|| eyre::eyre!("Unable to fetch abi"))?; if abi.functions.is_empty() && abi.events.is_empty() && abi.errors.is_empty() { continue; } - sh_println!("Uploading selectors for {contract}...")?; - - // upload abi to selector database - import_selectors(SelectorImportData::Abi(vec![abi])).await?.describe(); - - if artifacts.peek().is_some() { - sh_println!()? - } + sh_status!("Uploading selectors for {contract}...")?; + abis.push(abi); + } + if !abis.is_empty() { + import_selectors(SelectorImportData::Abi(abis)).await?.describe(); } } Self::Collision { mut first_contract, mut second_contract, build } => { // Compile the project with the two contracts included - let project = build.project()?; - let mut compiler = ProjectCompiler::new().quiet(true); + let user_extra_output = !build.compiler.extra_output.is_empty() + || !build.compiler.extra_output_files.is_empty(); + let config = build.load_config_with_dependencies()?; + let mut project = config.project()?; + if !user_extra_output && !project.build_info { + project.no_artifacts = true; + project.update_output_selection(|selection| { + *selection = OutputSelection::common_output_selection([ + ContractOutputSelection::Evm(EvmOutputSelection::MethodIdentifiers) + .to_string(), + ]); + }); + } + let mut compiler = ProjectCompiler::new() + .external_compilers(&config) + .external_artifacts(false) + .quiet(true); + if project.no_artifacts + && project.cached + && !config.force + && project.artifacts.additional_files == Default::default() + // Cached artifacts do not retain compiler diagnostics. + && !config.deny.warnings() + // Preserve first-match selection for ambiguous unqualified names. + && [&first_contract, &second_contract].into_iter().all(|contract| { + contract.path.is_some() + || (config.external_compilers.is_empty() + && project.find_contract_path(&contract.name).is_ok()) + }) + { + project.no_artifacts = false; + project.update_output_selection(|selection| { + *selection = + std::mem::take(selection).with_output("*", "*", ["abi".to_string()]); + }); + compiler = compiler.cache_abi(); + } if let Some(contract_path) = &mut first_contract.path { - let target_path = canonicalize(&*contract_path)?; + let target_path = canonicalize_path(&*contract_path)?; *contract_path = target_path.to_string_lossy().to_string(); - compiler = compiler.files([target_path]); + compiler = compiler.target_files([target_path]); } if let Some(contract_path) = &mut second_contract.path { - let target_path = canonicalize(&*contract_path)?; + let target_path = canonicalize_path(&*contract_path)?; *contract_path = target_path.to_string_lossy().to_string(); - compiler = compiler.files([target_path]); + compiler = compiler.target_files([target_path]); } let output = compiler.compile(&project)?; @@ -203,7 +233,7 @@ impl SelectorsSubcommands { .filter_map(|(k1, v1)| { second_method_map .iter() - .find_map(|(k2, v2)| if **v2 == *v1 { Some((k2, v2)) } else { None }) + .find_map(|(k2, v2)| (**v2 == *v1).then_some((k2, v2))) .map(|(k2, v2)| (v2, k1, k2)) }) .collect(); @@ -212,33 +242,37 @@ impl SelectorsSubcommands { sh_println!("No colliding method selectors between the two contracts.")?; } else { let mut table = Table::new(); - table.apply_modifier(UTF8_ROUND_CORNERS); + if shell::is_markdown() { + table.load_style(ASCII_MARKDOWN); + } else { + table.load_style(ASCII_FULL.with_rounded_corners()); + } table.set_header([ String::from("Selector"), first_contract.name, second_contract.name, ]); for method in &colliding_methods { - table.add_row([method.0, method.1, method.2]); + #[allow(clippy::tuple_array_conversions)] + table.add_row(<[_; 3]>::from(*method)); } sh_println!("{} collisions found:", colliding_methods.len())?; sh_println!("\n{table}\n")?; } } - Self::List { contract, project_paths } => { - sh_println!("Listing selectors for contracts in the project...")?; - let build_args = BuildOpts { - project_paths, - compiler: CompilerOpts { - extra_output: vec![ContractOutputSelection::Abi], - ..Default::default() - }, - ..Default::default() + Self::List { contract, project_paths, no_group } => { + sh_status!("Listing selectors for contracts in the project...")?; + let (mut project, compiler) = project_from_paths(project_paths)?; + let target_path = contract + .as_ref() + .filter(|_| project.no_artifacts) + .and_then(|contract| project.find_contract_path(contract).ok()); + let compiler = if let Some(target_path) = target_path { + compiler.target_files([target_path]) + } else { + compiler }; - - // compile the project to get the artifacts/abis - let project = build_args.project()?; - let outcome = ProjectCompiler::new().quiet(true).compile(&project)?; + let outcome = compile_abi_project(&mut project, compiler.quiet(true))?; let artifacts = if let Some(contract) = contract { let found_artifact = outcome.find_first(&contract); let artifact = found_artifact @@ -259,92 +293,128 @@ impl SelectorsSubcommands { .clone(); vec![(contract, artifact)] } else { - outcome - .into_artifacts_with_files() - .filter(|(file, _, _)| { - let is_sources_path = file.starts_with(&project.paths.sources); - let is_test = file.is_sol_test(); - - is_sources_path && !is_test - }) - .map(|(_, contract, artifact)| (contract, artifact)) - .collect() + selector_artifacts(outcome, &project.paths.sources) }; - let mut artifacts = artifacts.into_iter().peekable(); + let mut artifacts = artifacts.into_iter(); - while let Some((contract, artifact)) = artifacts.next() { - let abi = artifact.abi.ok_or_else(|| eyre::eyre!("Unable to fetch abi"))?; - if abi.functions.is_empty() && abi.events.is_empty() && abi.errors.is_empty() { - continue; + #[derive(PartialEq, PartialOrd, Eq, Ord)] + enum SelectorType { + Function, + Event, + Error, + } + impl std::fmt::Display for SelectorType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Function => write!(f, "Function"), + Self::Event => write!(f, "Event"), + Self::Error => write!(f, "Error"), + } } + } - sh_println!("{contract}")?; + let mut selectors = + BTreeMap::>>::new(); - let mut table = Table::new(); - table.apply_modifier(UTF8_ROUND_CORNERS); + for (contract, artifact) in artifacts.by_ref() { + let abi = artifact.abi.ok_or_else(|| eyre::eyre!("Unable to fetch abi"))?; - table.set_header(["Type", "Signature", "Selector"]); + let contract_selectors = selectors.entry(contract).or_default(); for func in abi.functions() { let sig = func.signature(); let selector = func.selector(); - table.add_row(["Function", &sig, &hex::encode_prefixed(selector)]); + contract_selectors + .entry(SelectorType::Function) + .or_default() + .push((hex::encode_prefixed(selector), sig)); } for event in abi.events() { let sig = event.signature(); let selector = event.selector(); - table.add_row(["Event", &sig, &hex::encode_prefixed(selector)]); + contract_selectors + .entry(SelectorType::Event) + .or_default() + .push((hex::encode_prefixed(selector), sig)); } for error in abi.errors() { let sig = error.signature(); let selector = error.selector(); - table.add_row(["Error", &sig, &hex::encode_prefixed(selector)]); + contract_selectors + .entry(SelectorType::Error) + .or_default() + .push((hex::encode_prefixed(selector), sig)); } + } - sh_println!("\n{table}\n")?; + if no_group { + let mut table = Table::new(); + if shell::is_markdown() { + table.load_style(ASCII_MARKDOWN); + } else { + table.load_style(ASCII_FULL.with_rounded_corners()); + } + table.set_header(["Type", "Signature", "Selector", "Contract"]); + + for (contract, contract_selectors) in selectors { + for (selector_type, selectors) in contract_selectors { + for (selector, sig) in selectors { + table.add_row([ + selector_type.to_string(), + sig, + selector, + contract.clone(), + ]); + } + } + } + + sh_println!("\n{table}")?; + } else { + for (idx, (contract, contract_selectors)) in selectors.into_iter().enumerate() { + sh_println!("{}{contract}", if idx == 0 { "" } else { "\n" })?; + let mut table = Table::new(); + if shell::is_markdown() { + table.load_style(ASCII_MARKDOWN); + } else { + table.load_style(ASCII_FULL.with_rounded_corners()); + } + table.set_header(["Type", "Signature", "Selector"]); - if artifacts.peek().is_some() { - sh_println!()? + for (selector_type, selectors) in contract_selectors { + for (selector, sig) in selectors { + table.add_row([selector_type.to_string(), sig, selector]); + } + } + sh_println!("\n{table}")?; } } } Self::Find { selector, project_paths } => { - sh_println!("Searching for selector {selector:?} in the project...")?; - - let build_args = BuildOpts { - project_paths, - compiler: CompilerOpts { - extra_output: vec![ContractOutputSelection::Abi], - ..Default::default() - }, - ..Default::default() - }; + sh_status!("Searching for selector {selector:?} in the project...")?; - let project = build_args.project()?; - let outcome = ProjectCompiler::new().quiet(true).compile(&project)?; - let artifacts = outcome - .into_artifacts_with_files() - .filter(|(file, _, _)| { - let is_sources_path = file.starts_with(&project.paths.sources); - let is_test = file.is_sol_test(); - is_sources_path && !is_test - }) - .collect::>(); + let (mut project, compiler) = project_from_paths(project_paths)?; + let outcome = compile_abi_project_cached(&mut project, compiler.quiet(true))?; + let artifacts = selector_artifacts(outcome, &project.paths.sources); let mut table = Table::new(); - table.apply_modifier(UTF8_ROUND_CORNERS); + if shell::is_markdown() { + table.load_style(ASCII_MARKDOWN); + } else { + table.load_style(ASCII_FULL.with_rounded_corners()); + } table.set_header(["Type", "Signature", "Selector", "Contract"]); - for (_file, contract, artifact) in artifacts { - let abi = artifact.abi.ok_or_else(|| eyre::eyre!("Unable to fetch abi"))?; + let selector_str = selector.strip_prefix("0x").unwrap_or(selector.as_str()); + let selector_bytes = hex::decode(selector_str)?; - let selector_bytes = - hex::decode(selector.strip_prefix("0x").unwrap_or(&selector))?; + for (contract, artifact) in artifacts { + let abi = artifact.abi.ok_or_else(|| eyre::eyre!("Unable to fetch abi"))?; for func in abi.functions() { if func.selector().as_slice().starts_with(selector_bytes.as_slice()) { @@ -381,7 +451,7 @@ impl SelectorsSubcommands { } if table.row_count() > 0 { - sh_println!("\nFound {} instance(s)...", table.row_count())?; + sh_status!("Found {} instance(s)...", table.row_count())?; sh_println!("\n{table}\n")?; } else { return Err(eyre::eyre!("\nSelector not found in the project.")); @@ -391,3 +461,41 @@ impl SelectorsSubcommands { Ok(()) } } + +fn selector_artifacts( + output: ProjectCompileOutput, + sources: &Path, +) -> Vec<(String, ConfigurableContractArtifact)> { + output + .into_artifacts() + .filter(|(id, _)| { + (id.source.starts_with(sources) || is_external_artifact(&id.build_id)) + && !id.source.is_sol_test() + }) + .map(|(id, artifact)| { + let name = if is_external_artifact(&id.build_id) { + id.name + } else { + // Built-in artifact filenames may include compiler versions and profiles. + id.name.split('.').next().unwrap().to_owned() + }; + (name, artifact) + }) + .collect() +} + +fn project_from_paths( + project_paths: ProjectPathOpts, +) -> Result<(Project, ProjectCompiler)> { + let build = BuildOpts { project_paths, ..Default::default() }; + let config = build.load_config_with_dependencies()?; + let compiler = ProjectCompiler::new() + .external_compilers(&config) + .external_artifacts(false) + .dynamic_test_linking(config.dynamic_test_linking); + let mut project = config.project()?; + if !project.build_info { + project.no_artifacts = true; + } + Ok((project, compiler)) +} diff --git a/crates/forge/src/cmd/snapshot.rs b/crates/forge/src/cmd/snapshot.rs index 43cf4a158dea7..0a786078d8e9c 100644 --- a/crates/forge/src/cmd/snapshot.rs +++ b/crates/forge/src/cmd/snapshot.rs @@ -2,8 +2,13 @@ use super::test; use crate::result::{SuiteTestResult, TestKindReport, TestOutcome}; use alloy_primitives::{U256, map::HashMap}; use clap::{Parser, ValueHint, builder::RangedU64ValueParser}; +use comfy_table::{ + Cell, Color, Row, Table, + presets::{ASCII_FULL, ASCII_MARKDOWN}, +}; use eyre::{Context, Result}; use foundry_cli::utils::STATIC_FUZZ_SEED; +use foundry_common::shell; use regex::Regex; use std::{ cmp::Ordering, @@ -70,6 +75,10 @@ pub struct GasSnapshotArgs { )] tolerance: Option, + /// How to sort diff results. + #[arg(long, value_name = "ORDER")] + diff_sort: Option, + /// All test arguments are supported #[command(flatten)] pub(crate) test: test::TestArgs, @@ -81,7 +90,7 @@ pub struct GasSnapshotArgs { impl GasSnapshotArgs { /// Returns whether `GasSnapshotArgs` was configured with `--watch` - pub fn is_watch(&self) -> bool { + pub const fn is_watch(&self) -> bool { self.test.is_watch() } @@ -91,33 +100,48 @@ impl GasSnapshotArgs { } pub async fn run(mut self) -> Result<()> { - // Set fuzz seed so gas snapshots are deterministic - self.test.fuzz_seed = Some(U256::from_be_bytes(STATIC_FUZZ_SEED)); + // Default to a static fuzz seed so gas snapshots are deterministic, + // but allow the user to override it via `--fuzz-seed`. + if self.test.fuzz_seed.is_none() { + self.test.fuzz_seed = Some(U256::from_be_bytes(STATIC_FUZZ_SEED)); + } - let outcome = self.test.execute_tests().await?; + let outcome = self.test.compile_and_run().await?; + if !shell::is_quiet() + && !outcome.allow_failure + && self.diff.is_none() + && self.check.is_none() + && outcome.failed() > 0 + { + sh_eprintln!( + "Error: gas snapshot file \"{}\" was not written because the test run failed", + self.snap.display() + )?; + } outcome.ensure_ok(false)?; let tests = self.config.apply(outcome); if let Some(path) = self.diff { let snap = path.as_ref().unwrap_or(&self.snap); let snaps = read_gas_snapshot(snap)?; - diff(tests, snaps)?; + diff(tests, snaps, self.diff_sort.unwrap_or_default())?; } else if let Some(path) = self.check { let snap = path.as_ref().unwrap_or(&self.snap); let snaps = read_gas_snapshot(snap)?; - if check(tests, snaps, self.tolerance) { - std::process::exit(0) - } else { - std::process::exit(1) - } + let code = if check(tests, snaps, self.tolerance) { 0 } else { 1 }; + std::process::exit(code) } else { + if matches!(self.format, Some(Format::Table)) { + let table = build_gas_snapshot_table(&tests); + sh_println!("\n{}", table)?; + } write_to_gas_snapshot_file(&tests, self.snap, self.format)?; } Ok(()) } } -// TODO implement pretty tables +// Gas report format on stdout. #[derive(Clone, Debug)] pub enum Format { Table, @@ -154,8 +178,22 @@ struct GasSnapshotConfig { max: Option, } +/// Sort order for diff output +#[derive(Clone, Debug, Default, clap::ValueEnum)] +enum DiffSortOrder { + /// Sort by percentage change (smallest to largest) - default behavior + #[default] + Percentage, + /// Sort by percentage change (largest to smallest) + PercentageDesc, + /// Sort by absolute gas change (smallest to largest) + Absolute, + /// Sort by absolute gas change (largest to smallest) + AbsoluteDesc, +} + impl GasSnapshotConfig { - fn is_in_gas_range(&self, gas_used: u64) -> bool { + const fn is_in_gas_range(&self, gas_used: u64) -> bool { if let Some(min) = self.min && gas_used < min { @@ -173,6 +211,7 @@ impl GasSnapshotConfig { let mut tests = outcome .into_tests() .filter(|test| self.is_in_gas_range(test.gas_used())) + .flat_map(expand_invariant_snapshot_entries) .collect::>(); if self.asc { @@ -185,6 +224,23 @@ impl GasSnapshotConfig { } } +/// Expands merged invariant campaigns into per-predicate gas snapshot rows. +fn expand_invariant_snapshot_entries(test: SuiteTestResult) -> Vec { + if !test.result.kind.is_invariant() || test.result.invariant_predicate_results.len() <= 1 { + return vec![test]; + } + + test.result + .invariant_predicate_results + .iter() + .map(|predicate| { + let mut expanded = test.clone(); + expanded.signature = format!("{}()", predicate.name); + expanded + }) + .collect() +} + /// A general entry in a gas snapshot file /// /// Has the form: @@ -202,69 +258,86 @@ impl FromStr for GasSnapshotEntry { type Err = String; fn from_str(s: &str) -> Result { - RE_BASIC_SNAPSHOT_ENTRY + let cap = RE_BASIC_SNAPSHOT_ENTRY .captures(s) - .and_then(|cap| { - cap.name("file").and_then(|file| { - cap.name("sig").and_then(|sig| { - if let Some(gas) = cap.name("gas") { - Some(Self { - contract_name: file.as_str().to_string(), - signature: sig.as_str().to_string(), - gas_used: TestKindReport::Unit { - gas: gas.as_str().parse().unwrap(), - }, - }) - } else if let Some(runs) = cap.name("runs") { - cap.name("avg") - .and_then(|avg| cap.name("med").map(|med| (runs, avg, med))) - .map(|(runs, avg, med)| Self { - contract_name: file.as_str().to_string(), - signature: sig.as_str().to_string(), - gas_used: TestKindReport::Fuzz { - runs: runs.as_str().parse().unwrap(), - median_gas: med.as_str().parse().unwrap(), - mean_gas: avg.as_str().parse().unwrap(), - }, - }) - } else { - cap.name("invruns") - .and_then(|runs| { - cap.name("calls").and_then(|avg| { - cap.name("reverts").map(|med| (runs, avg, med)) - }) - }) - .map(|(runs, calls, reverts)| Self { - contract_name: file.as_str().to_string(), - signature: sig.as_str().to_string(), - gas_used: TestKindReport::Invariant { - runs: runs.as_str().parse().unwrap(), - calls: calls.as_str().parse().unwrap(), - reverts: reverts.as_str().parse().unwrap(), - metrics: HashMap::default(), - failed_corpus_replays: 0, - }, - }) - } - }) - }) - }) - .ok_or_else(|| format!("Could not extract Snapshot Entry for {s}")) + .ok_or_else(|| format!("Could not extract Snapshot Entry for {s}"))?; + let contract_name = cap + .name("file") + .ok_or_else(|| format!("Could not extract Snapshot Entry for {s}"))? + .as_str() + .to_string(); + let signature = cap + .name("sig") + .ok_or_else(|| format!("Could not extract Snapshot Entry for {s}"))? + .as_str() + .to_string(); + + let gas_used = if let Some(gas) = cap.name("gas") { + TestKindReport::Unit { gas: parse_snapshot_value(gas.as_str(), "gas")? } + } else if let Some(runs) = cap.name("runs") { + let avg = cap + .name("avg") + .ok_or_else(|| format!("Could not extract Snapshot Entry for {s}"))?; + let med = cap + .name("med") + .ok_or_else(|| format!("Could not extract Snapshot Entry for {s}"))?; + TestKindReport::Fuzz { + runs: parse_snapshot_value(runs.as_str(), "runs")?, + median_gas: parse_snapshot_value(med.as_str(), "median gas")?, + mean_gas: parse_snapshot_value(avg.as_str(), "mean gas")?, + failed_corpus_replays: 0, + } + } else { + let runs = cap + .name("invruns") + .ok_or_else(|| format!("Could not extract Snapshot Entry for {s}"))?; + let calls = cap + .name("calls") + .ok_or_else(|| format!("Could not extract Snapshot Entry for {s}"))?; + let reverts = cap + .name("reverts") + .ok_or_else(|| format!("Could not extract Snapshot Entry for {s}"))?; + TestKindReport::Invariant { + runs: parse_snapshot_value(runs.as_str(), "runs")?, + calls: parse_snapshot_value(calls.as_str(), "calls")?, + reverts: parse_snapshot_value(reverts.as_str(), "reverts")?, + failed_corpus_replays: 0, + optimization_best_value: None, + } + }; + + Ok(Self { contract_name, signature, gas_used }) } } +fn parse_snapshot_value(value: &str, field: &str) -> Result { + value.parse().map_err(|_| format!("invalid {field} value `{value}` in gas snapshot")) +} + /// Reads a list of gas snapshot entries from a gas snapshot file. fn read_gas_snapshot(path: impl AsRef) -> Result> { let path = path.as_ref(); let mut entries = Vec::new(); - for line in io::BufReader::new( + for (line_number, line) in io::BufReader::new( fs::File::open(path) .wrap_err(format!("failed to read snapshot file \"{}\"", path.display()))?, ) .lines() + .enumerate() { - entries - .push(GasSnapshotEntry::from_str(line?.as_str()).map_err(|err| eyre::eyre!("{err}"))?); + let line_number = line_number + 1; + let line = line.wrap_err_with(|| { + format!("failed to read snapshot file \"{}\" at line {line_number}", path.display()) + })?; + let entry = GasSnapshotEntry::from_str(&line) + .map_err(|err| eyre::eyre!(err)) + .wrap_err_with(|| { + format!( + "failed to parse snapshot file \"{}\" at line {line_number}", + path.display() + ) + })?; + entries.push(entry); } Ok(entries) } @@ -289,6 +362,31 @@ fn write_to_gas_snapshot_file( Ok(fs::write(path, content)?) } +fn build_gas_snapshot_table(tests: &[SuiteTestResult]) -> Table { + let mut table = Table::new(); + if shell::is_markdown() { + table.load_style(ASCII_MARKDOWN); + } else { + table.load_style(ASCII_FULL.with_rounded_corners()); + } + + table.set_header(vec![ + Cell::new("Contract").fg(Color::Cyan), + Cell::new("Signature").fg(Color::Cyan), + Cell::new("Report").fg(Color::Cyan), + ]); + + for test in tests { + let mut row = Row::new(); + row.add_cell(Cell::new(test.contract_name())); + row.add_cell(Cell::new(&test.signature)); + row.add_cell(Cell::new(test.result.kind.report())); + table.add_row(row); + } + + table +} + /// A Gas snapshot entry diff. #[derive(Clone, Debug, PartialEq, Eq)] pub struct GasSnapshotDiff { @@ -302,13 +400,22 @@ impl GasSnapshotDiff { /// /// `> 0` if the source used more gas /// `< 0` if the target used more gas - fn gas_change(&self) -> i128 { + const fn gas_change(&self) -> i128 { self.source_gas_used.gas() as i128 - self.target_gas_used.gas() as i128 } /// Determines the percentage change fn gas_diff(&self) -> f64 { - self.gas_change() as f64 / self.target_gas_used.gas() as f64 + let target_gas = self.target_gas_used.gas(); + if target_gas > 0 { + self.gas_change() as f64 / target_gas as f64 + } else if self.source_gas_used.gas() == 0 { + // No percentage change when both values are zero. + 0.0 + } else { + // Preserve an unbounded increase from zero. + f64::INFINITY + } } } @@ -331,7 +438,7 @@ fn check( { let source_gas = test.result.kind.report(); if !within_tolerance(source_gas.gas(), target_gas.gas(), tolerance) { - let _ = sh_println!( + let _ = sh_eprintln!( "Diff in \"{}::{}\": consumed \"{}\" gas, expected \"{}\" gas ", test.contract_name(), test.signature, @@ -341,7 +448,7 @@ fn check( has_diff = true; } } else { - let _ = sh_println!( + let _ = sh_eprintln!( "No matching snapshot entry found for \"{}::{}\" in snapshot file", test.contract_name(), test.signature @@ -353,43 +460,122 @@ fn check( } /// Compare the set of tests with an existing gas snapshot. -fn diff(tests: Vec, snaps: Vec) -> Result<()> { +fn diff( + tests: Vec, + snaps: Vec, + sort_order: DiffSortOrder, +) -> Result<()> { let snaps = snaps .into_iter() .map(|s| ((s.contract_name, s.signature), s.gas_used)) .collect::>(); let mut diffs = Vec::with_capacity(tests.len()); - for test in tests.into_iter() { + let mut new_tests = Vec::new(); + + for test in tests { if let Some(target_gas_used) = snaps.get(&(test.contract_name().to_string(), test.signature.clone())).cloned() { diffs.push(GasSnapshotDiff { source_gas_used: test.result.kind.report(), - signature: test.signature, + signature: format!("{}::{}", test.contract_name(), test.signature), target_gas_used, }); + } else { + // Track new tests + new_tests.push(format!("{}::{}", test.contract_name(), test.signature)); } } + + let mut increased = 0; + let mut decreased = 0; + let mut unchanged = 0; let mut overall_gas_change = 0i128; let mut overall_gas_used = 0i128; - diffs.sort_by(|a, b| a.gas_diff().abs().total_cmp(&b.gas_diff().abs())); + // Sort based on user preference + match sort_order { + DiffSortOrder::Percentage => { + // Default: sort by percentage change (smallest to largest) + diffs.sort_by(|a, b| a.gas_diff().abs().total_cmp(&b.gas_diff().abs())); + } + DiffSortOrder::PercentageDesc => { + // Sort by percentage change (largest to smallest) + diffs.sort_by(|a, b| b.gas_diff().abs().total_cmp(&a.gas_diff().abs())); + } + DiffSortOrder::Absolute => { + // Sort by absolute gas change (smallest to largest) + diffs.sort_by_key(|d| d.gas_change().abs()); + } + DiffSortOrder::AbsoluteDesc => { + // Sort by absolute gas change (largest to smallest) + diffs.sort_by_key(|d| std::cmp::Reverse(d.gas_change().abs())); + } + } - for diff in diffs { + for diff in &diffs { let gas_change = diff.gas_change(); overall_gas_change += gas_change; overall_gas_used += diff.target_gas_used.gas() as i128; let gas_diff = diff.gas_diff(); + + // Classify changes + if gas_change > 0 { + increased += 1; + } else if gas_change < 0 { + decreased += 1; + } else { + unchanged += 1; + } + + // Display with icon and before/after values + let icon = if gas_change > 0 { + "↑".red().to_string() + } else if gas_change < 0 { + "↓".green().to_string() + } else { + "━".to_string() + }; + sh_println!( - "{} (gas: {} ({})) ", + "{} {} (gas: {} → {} | {} {})", + icon, diff.signature, + diff.target_gas_used.gas(), + diff.source_gas_used.gas(), fmt_change(gas_change), fmt_pct_change(gas_diff) )?; } - let overall_gas_diff = overall_gas_change as f64 / overall_gas_used as f64; - sh_println!( + // Display new tests if any + if !new_tests.is_empty() { + sh_eprintln!("\n{}", "New tests:".yellow())?; + for test in new_tests { + sh_eprintln!(" {} {}", "+".green(), test)?; + } + } + + // Summary separator + sh_eprintln!("\n{}", "-".repeat(80))?; + + let overall_gas_diff = if overall_gas_used > 0 { + overall_gas_change as f64 / overall_gas_used as f64 + } else { + 0.0 + }; + + sh_eprintln!( + "Total tests: {}, {} {}, {} {}, {} {}", + diffs.len(), + "↑".red().to_string(), + increased, + "↓".green().to_string(), + decreased, + "━", + unchanged + )?; + sh_eprintln!( "Overall gas change: {} ({})", fmt_change(overall_gas_change), fmt_pct_change(overall_gas_diff) @@ -411,9 +597,7 @@ fn fmt_pct_change(change: f64) -> String { fn fmt_change(change: i128) -> String { match change.cmp(&0) { Ordering::Less => format!("{change}").green().to_string(), - Ordering::Equal => { - format!("{change}") - } + Ordering::Equal => change.to_string(), Ordering::Greater => format!("{change}").red().to_string(), } } @@ -428,6 +612,10 @@ fn within_tolerance(source_gas: u64, target_gas: u64, tolerance_pct: Option } else { (target_gas, source_gas) }; + if hi == 0 { + // No percentage difference when both values are zero. + return true; + } let diff = (1. - (lo as f64 / hi as f64)) * 100.; diff < tolerance as f64 } else { @@ -446,6 +634,7 @@ mod tests { assert!(!within_tolerance(100, 106, Some(5))); assert!(!within_tolerance(106, 100, Some(5))); assert!(within_tolerance(100, 100, None)); + assert!(within_tolerance(0, 0, Some(5))); } #[test] @@ -471,7 +660,12 @@ mod tests { GasSnapshotEntry { contract_name: "Test".to_string(), signature: "deposit()".to_string(), - gas_used: TestKindReport::Fuzz { runs: 256, median_gas: 200, mean_gas: 100 } + gas_used: TestKindReport::Fuzz { + runs: 256, + median_gas: 200, + mean_gas: 100, + failed_corpus_replays: 0 + } } ); } @@ -489,8 +683,8 @@ mod tests { runs: 256, calls: 100, reverts: 200, - metrics: HashMap::default(), failed_corpus_replays: 0, + optimization_best_value: None, } } ); @@ -509,10 +703,108 @@ mod tests { runs: 256, calls: 3840, reverts: 2388, - metrics: HashMap::default(), failed_corpus_replays: 0, + optimization_best_value: None, } } ); } + + #[test] + fn rejects_overflowing_gas_snapshot_values() { + let u64_overflow = (u64::MAX as u128 + 1).to_string(); + let usize_overflow = (usize::MAX as u128 + 1).to_string(); + let cases = [ + ( + format!("Example:testFoo() (gas: {u64_overflow})"), + format!("invalid gas value `{u64_overflow}` in gas snapshot"), + ), + ( + format!("Example:testFoo() (runs: {usize_overflow}, μ: 1, ~: 1)"), + format!("invalid runs value `{usize_overflow}` in gas snapshot"), + ), + ( + format!("Example:testFoo() (runs: 1, μ: {u64_overflow}, ~: 1)"), + format!("invalid mean gas value `{u64_overflow}` in gas snapshot"), + ), + ( + format!("Example:testFoo() (runs: 1, μ: 1, ~: {u64_overflow})"), + format!("invalid median gas value `{u64_overflow}` in gas snapshot"), + ), + ( + format!("Example:invariantFoo() (runs: {usize_overflow}, calls: 1, reverts: 1)"), + format!("invalid runs value `{usize_overflow}` in gas snapshot"), + ), + ( + format!("Example:invariantFoo() (runs: 1, calls: {usize_overflow}, reverts: 1)"), + format!("invalid calls value `{usize_overflow}` in gas snapshot"), + ), + ( + format!("Example:invariantFoo() (runs: 1, calls: 1, reverts: {usize_overflow})"), + format!("invalid reverts value `{usize_overflow}` in gas snapshot"), + ), + ]; + + for (snapshot, expected) in cases { + assert_eq!(GasSnapshotEntry::from_str(&snapshot).unwrap_err(), expected); + } + } + + #[test] + fn accepts_maximum_gas_snapshot_values() { + let unit = format!("Example:testFoo() (gas: {})", u64::MAX); + assert_eq!( + GasSnapshotEntry::from_str(&unit).unwrap().gas_used, + TestKindReport::Unit { gas: u64::MAX } + ); + + let fuzz = + format!("Example:testFoo() (runs: {}, μ: {}, ~: {})", usize::MAX, u64::MAX, u64::MAX); + assert_eq!( + GasSnapshotEntry::from_str(&fuzz).unwrap().gas_used, + TestKindReport::Fuzz { + runs: usize::MAX, + median_gas: u64::MAX, + mean_gas: u64::MAX, + failed_corpus_replays: 0, + } + ); + + let invariant = format!( + "Example:invariantFoo() (runs: {}, calls: {}, reverts: {})", + usize::MAX, + usize::MAX, + usize::MAX + ); + assert_eq!( + GasSnapshotEntry::from_str(&invariant).unwrap().gas_used, + TestKindReport::Invariant { + runs: usize::MAX, + calls: usize::MAX, + reverts: usize::MAX, + failed_corpus_replays: 0, + optimization_best_value: None, + } + ); + } + + #[test] + fn reports_snapshot_path_and_line_for_invalid_values() { + let snapshot = tempfile::NamedTempFile::new().unwrap(); + fs::write( + snapshot.path(), + "Example:testFoo() (gas: 1)\nExample:testBar() (gas: 18446744073709551616)", + ) + .unwrap(); + + let error = read_gas_snapshot(snapshot.path()).unwrap_err(); + + assert_eq!( + format!("{error:#}"), + format!( + "failed to parse snapshot file \"{}\" at line 2: invalid gas value `18446744073709551616` in gas snapshot", + snapshot.path().display() + ) + ); + } } diff --git a/crates/forge/src/cmd/soldeer.rs b/crates/forge/src/cmd/soldeer.rs index fd7cf2d0505de..702435e7e60f8 100644 --- a/crates/forge/src/cmd/soldeer.rs +++ b/crates/forge/src/cmd/soldeer.rs @@ -1,5 +1,6 @@ use clap::Parser; use eyre::Result; +use foundry_cli::utils; use foundry_common::shell; use soldeer_commands::{Command, Verbosity}; @@ -10,13 +11,27 @@ use soldeer_commands::{Command, Verbosity}; override_usage = "Native Solidity Package Manager, run `forge soldeer [COMMAND] --help` for more details" )] pub struct SoldeerArgs { - /// Command must be one of the following init/install/login/push/uninstall/update/version. + /// Command must be one of the following + /// init/install/update/login/push/uninstall/clean/version/help. #[command(subcommand)] command: Command, } impl SoldeerArgs { pub async fn run(self) -> Result<()> { + // Reconfigure the tracing filter so that Soldeer's log output is visible when `-v` flags + // are used + if std::env::var_os("RUST_LOG").is_none() { + let level = match shell::verbosity() { + 0 => "error", + 1 => "warn", + 2 => "info", + 3 => "debug", + _ => "trace", + }; + utils::update_tracing_filter(level); + } + let verbosity = Verbosity::new(shell::verbosity(), if shell::is_quiet() { 1 } else { 0 }); match soldeer_commands::run(self.command, verbosity).await { Ok(_) => Ok(()), diff --git a/crates/forge/src/cmd/test/evm_profile_server.rs b/crates/forge/src/cmd/test/evm_profile_server.rs new file mode 100644 index 0000000000000..991172f1043fd --- /dev/null +++ b/crates/forge/src/cmd/test/evm_profile_server.rs @@ -0,0 +1,89 @@ +//! Local HTTP server for serving EVM profiles to speedscope.app. +//! +//! This module implements a temporary local HTTP server that: +//! 1. Serves the profile JSON at `/{token}/profile.json` +//! 2. Sets CORS headers to allow the viewer to fetch it +//! 3. Constructs the proper URL and opens it in the browser +use axum::{ + Router, + body::Bytes, + extract::State, + http::{Method, StatusCode, header}, + response::{IntoResponse, Response}, + routing::get, +}; +use eyre::Result; +use foundry_common::{sh_err, sh_println}; +use std::{ + hash::{DefaultHasher, Hasher}, + time::{SystemTime, UNIX_EPOCH}, +}; +use tokio::net::TcpListener; +use tower_http::cors::{Any, CorsLayer}; + +/// Serves a profile on a local HTTP server and opens it in the browser. +/// +/// Takes the already-serialized profile JSON bytes. +/// The server runs until Ctrl+C is pressed. +pub async fn serve_and_open( + profile_json: Vec, + test_name: &str, + contract_name: &str, +) -> Result<()> { + let token = generate_token(); + let app = Router::new() + .route(&format!("/{token}/profile.json"), get(serve_profile)) + .layer( + CorsLayer::new() + .allow_origin(Any) + .allow_methods([Method::GET, Method::OPTIONS]) + .allow_headers(Any), + ) + .with_state(Bytes::from(profile_json)); + + let listener = TcpListener::bind("127.0.0.1:0").await?; + let port = listener.local_addr()?.port(); + + let profile_url = percent_encode(&format!("http://127.0.0.1:{port}/{token}/profile.json")); + let title = percent_encode(&format!("{contract_name}::{test_name}")); + let viewer_url = format!("https://www.speedscope.app/#profileURL={profile_url}&title={title}"); + + sh_println!("Profile server running at http://127.0.0.1:{port}")?; + sh_println!("Opening speedscope: {viewer_url}")?; + if let Err(e) = opener::open(&viewer_url) { + sh_err!("Failed to open browser: {e}")?; + } + sh_println!("\nPress Ctrl+C to stop the server.")?; + + // Run the server until interrupted. + axum::serve(listener, app).await?; + Ok(()) +} + +/// Generates a random token for the URL path (32 hex characters). +fn generate_token() -> String { + let nanos = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0); + let mut hasher = DefaultHasher::new(); + hasher.write_u128(nanos); + hasher.write_usize(std::process::id() as usize); + format!("{nanos:016x}{:016x}", hasher.finish()) +} + +/// Percent-encode a URL for embedding in viewer URL parameters. +fn percent_encode(url: &str) -> String { + let mut result = String::with_capacity(url.len() * 3); + for byte in url.bytes() { + match byte { + // Unreserved characters (RFC 3986). + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + result.push(byte as char) + } + _ => result.push_str(&format!("%{byte:02X}")), + } + } + result +} + +async fn serve_profile(State(profile_json): State) -> Response { + (StatusCode::OK, [(header::CONTENT_TYPE, "application/json")], profile_json).into_response() +} diff --git a/crates/forge/src/cmd/test/filter.rs b/crates/forge/src/cmd/test/filter.rs index 9857efc12c43b..ae44b2d0720ae 100644 --- a/crates/forge/src/cmd/test/filter.rs +++ b/crates/forge/src/cmd/test/filter.rs @@ -1,13 +1,31 @@ +use alloy_json_abi::Function; use clap::Parser; -use foundry_common::TestFilter; +use foundry_common::{TestFilter, TestFunctionKind, fs::canonicalize_path}; use foundry_compilers::{FileFilter, ProjectPathsConfig}; use foundry_config::{Config, filter::GlobMatcher}; +use serde::{Deserialize, Serialize}; use std::{fmt, path::Path}; +/// A failed test persisted for `forge test --rerun`. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct RerunFailure { + /// The test suite identifier (`path:contract_name`). + pub contract: String, + /// The test signature or invariant predicate name. + pub test: String, +} + +/// Persisted `forge test --rerun` failures. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct RerunFailures { + pub version: u8, + pub failures: Vec, +} + /// The filter to use during testing. /// /// See also `FileFilter`. -#[derive(Clone, Parser)] +#[derive(Clone, Default, Parser)] #[command(next_help_heading = "Test filtering")] pub struct FilterArgs { /// Only run test functions matching the specified regex pattern. @@ -46,7 +64,7 @@ pub struct FilterArgs { impl FilterArgs { /// Returns true if the filter is empty. - pub fn is_empty(&self) -> bool { + pub const fn is_empty(&self) -> bool { self.test_pattern.is_none() && self.test_pattern_inverse.is_none() && self.contract_pattern.is_none() @@ -57,42 +75,52 @@ impl FilterArgs { /// Merges the set filter globs with the config's values pub fn merge_with_config(mut self, config: &Config) -> ProjectPathsAwareFilter { - if self.test_pattern.is_none() { - self.test_pattern = config.test_pattern.clone().map(Into::into); - } - if self.test_pattern_inverse.is_none() { - self.test_pattern_inverse = config.test_pattern_inverse.clone().map(Into::into); - } - if self.contract_pattern.is_none() { - self.contract_pattern = config.contract_pattern.clone().map(Into::into); - } - if self.contract_pattern_inverse.is_none() { - self.contract_pattern_inverse = config.contract_pattern_inverse.clone().map(Into::into); - } - if self.path_pattern.is_none() { - self.path_pattern = config.path_pattern.clone().map(Into::into); - } - if self.path_pattern_inverse.is_none() { - self.path_pattern_inverse = config.path_pattern_inverse.clone().map(Into::into); - } - if self.coverage_pattern_inverse.is_none() { - self.coverage_pattern_inverse = config.coverage_pattern_inverse.clone().map(Into::into); + self.test_pattern = + self.test_pattern.or_else(|| config.test_pattern.clone().map(Into::into)); + self.test_pattern_inverse = self + .test_pattern_inverse + .or_else(|| config.test_pattern_inverse.clone().map(Into::into)); + self.contract_pattern = + self.contract_pattern.or_else(|| config.contract_pattern.clone().map(Into::into)); + self.contract_pattern_inverse = self + .contract_pattern_inverse + .or_else(|| config.contract_pattern_inverse.clone().map(Into::into)); + self.path_pattern = + self.path_pattern.or_else(|| config.path_pattern.clone().map(Into::into)); + self.path_pattern_inverse = self + .path_pattern_inverse + .or_else(|| config.path_pattern_inverse.clone().map(Into::into)); + self.coverage_pattern_inverse = self + .coverage_pattern_inverse + .or_else(|| config.coverage_pattern_inverse.clone().map(Into::into)); + ProjectPathsAwareFilter { + args_filter: self, + paths: config.project_paths(), + rerun_failures: None, } - ProjectPathsAwareFilter { args_filter: self, paths: config.project_paths() } + } + + /// Returns all patterns as `(flag, pattern)` pairs. + fn patterns(&self) -> [(&'static str, Option<&str>); 7] { + [ + ("match-test", self.test_pattern.as_ref().map(|r| r.as_str())), + ("no-match-test", self.test_pattern_inverse.as_ref().map(|r| r.as_str())), + ("match-contract", self.contract_pattern.as_ref().map(|r| r.as_str())), + ("no-match-contract", self.contract_pattern_inverse.as_ref().map(|r| r.as_str())), + ("match-path", self.path_pattern.as_ref().map(|g| g.as_str())), + ("no-match-path", self.path_pattern_inverse.as_ref().map(|g| g.as_str())), + ("no-match-coverage", self.coverage_pattern_inverse.as_ref().map(|r| r.as_str())), + ] } } impl fmt::Debug for FilterArgs { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("FilterArgs") - .field("match-test", &self.test_pattern.as_ref().map(|r| r.as_str())) - .field("no-match-test", &self.test_pattern_inverse.as_ref().map(|r| r.as_str())) - .field("match-contract", &self.contract_pattern.as_ref().map(|r| r.as_str())) - .field("no-match-contract", &self.contract_pattern_inverse.as_ref().map(|r| r.as_str())) - .field("match-path", &self.path_pattern.as_ref().map(|g| g.as_str())) - .field("no-match-path", &self.path_pattern_inverse.as_ref().map(|g| g.as_str())) - .field("no-match-coverage", &self.coverage_pattern_inverse.as_ref().map(|g| g.as_str())) - .finish_non_exhaustive() + let mut s = f.debug_struct("FilterArgs"); + for (name, pattern) in self.patterns() { + s.field(name, &pattern); + } + s.finish_non_exhaustive() } } @@ -106,62 +134,28 @@ impl FileFilter for FilterArgs { } impl TestFilter for FilterArgs { - fn matches_test(&self, test_name: &str) -> bool { - let mut ok = true; - if let Some(re) = &self.test_pattern { - ok = ok && re.is_match(test_name); - } - if let Some(re) = &self.test_pattern_inverse { - ok = ok && !re.is_match(test_name); - } - ok + fn matches_test(&self, test_signature: &str) -> bool { + self.test_pattern.as_ref().is_none_or(|re| re.is_match(test_signature)) + && self.test_pattern_inverse.as_ref().is_none_or(|re| !re.is_match(test_signature)) } fn matches_contract(&self, contract_name: &str) -> bool { - let mut ok = true; - if let Some(re) = &self.contract_pattern { - ok = ok && re.is_match(contract_name); - } - if let Some(re) = &self.contract_pattern_inverse { - ok = ok && !re.is_match(contract_name); - } - ok + self.contract_pattern.as_ref().is_none_or(|re| re.is_match(contract_name)) + && self.contract_pattern_inverse.as_ref().is_none_or(|re| !re.is_match(contract_name)) } fn matches_path(&self, path: &Path) -> bool { - let mut ok = true; - if let Some(re) = &self.path_pattern { - ok = ok && re.is_match(path); - } - if let Some(re) = &self.path_pattern_inverse { - ok = ok && !re.is_match(path); - } - ok + self.path_pattern.as_ref().is_none_or(|g| g.is_match(path)) + && self.path_pattern_inverse.as_ref().is_none_or(|g| !g.is_match(path)) } } impl fmt::Display for FilterArgs { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - if let Some(p) = &self.test_pattern { - writeln!(f, "\tmatch-test: `{}`", p.as_str())?; - } - if let Some(p) = &self.test_pattern_inverse { - writeln!(f, "\tno-match-test: `{}`", p.as_str())?; - } - if let Some(p) = &self.contract_pattern { - writeln!(f, "\tmatch-contract: `{}`", p.as_str())?; - } - if let Some(p) = &self.contract_pattern_inverse { - writeln!(f, "\tno-match-contract: `{}`", p.as_str())?; - } - if let Some(p) = &self.path_pattern { - writeln!(f, "\tmatch-path: `{}`", p.as_str())?; - } - if let Some(p) = &self.path_pattern_inverse { - writeln!(f, "\tno-match-path: `{}`", p.as_str())?; - } - if let Some(p) = &self.coverage_pattern_inverse { - writeln!(f, "\tno-match-coverage: `{}`", p.as_str())?; + for (name, pattern) in self.patterns() { + if let Some(pattern) = pattern { + writeln!(f, "\t{name}: `{pattern}`")?; + } } Ok(()) } @@ -172,28 +166,69 @@ impl fmt::Display for FilterArgs { pub struct ProjectPathsAwareFilter { args_filter: FilterArgs, paths: ProjectPathsConfig, + rerun_failures: Option>, } impl ProjectPathsAwareFilter { /// Returns true if the filter is empty. - pub fn is_empty(&self) -> bool { + pub const fn is_empty(&self) -> bool { self.args_filter.is_empty() } /// Returns the CLI arguments. - pub fn args(&self) -> &FilterArgs { + pub const fn args(&self) -> &FilterArgs { &self.args_filter } /// Returns the CLI arguments mutably. - pub fn args_mut(&mut self) -> &mut FilterArgs { + pub const fn args_mut(&mut self) -> &mut FilterArgs { &mut self.args_filter } /// Returns the project paths. - pub fn paths(&self) -> &ProjectPathsConfig { + pub const fn paths(&self) -> &ProjectPathsConfig { &self.paths } + + /// Sets exact contract/test pairs persisted by `forge test --rerun`. + pub fn set_rerun_failures(&mut self, failures: Vec) { + self.rerun_failures = Some(failures); + } + + /// Returns exact contract/test pairs persisted by `forge test --rerun`. + pub fn rerun_failures(&self) -> Option<&[RerunFailure]> { + self.rerun_failures.as_deref() + } + + fn matches_rerun_contract(&self, failure_contract: &str, contract_id: &str) -> bool { + if failure_contract == contract_id { + return true; + } + let (Some((failure_path, failure_name)), Some((contract_path, contract_name))) = + (failure_contract.rsplit_once(':'), contract_id.rsplit_once(':')) + else { + return false; + }; + if failure_name != contract_name { + return false; + } + + let normalize = |path: &str| { + let path = Path::new(path); + if let Ok(path) = path.strip_prefix(&self.paths.root) { + return path.to_path_buf(); + } + if path.is_absolute() + && let Ok(root) = canonicalize_path(&self.paths.root) + && let Ok(path) = canonicalize_path(path) + && let Ok(path) = path.strip_prefix(root) + { + return path.to_path_buf(); + } + path.to_path_buf() + }; + normalize(failure_path) == normalize(contract_path) + } } impl FileFilter for ProjectPathsAwareFilter { @@ -207,8 +242,8 @@ impl FileFilter for ProjectPathsAwareFilter { } impl TestFilter for ProjectPathsAwareFilter { - fn matches_test(&self, test_name: &str) -> bool { - self.args_filter.matches_test(test_name) + fn matches_test(&self, test_signature: &str) -> bool { + self.args_filter.matches_test(test_signature) } fn matches_contract(&self, contract_name: &str) -> bool { @@ -220,6 +255,24 @@ impl TestFilter for ProjectPathsAwareFilter { path = path.strip_prefix(&self.paths.root).unwrap_or(path); self.args_filter.matches_path(path) && !self.paths.has_library_ancestor(path) } + + fn matches_test_function_kind_in_contract( + &self, + contract_id: &str, + func: &Function, + kind: TestFunctionKind, + ) -> bool { + let signature = func.signature(); + if !kind.is_any_test() || !self.args_filter.matches_test(&signature) { + return false; + } + let Some(failures) = &self.rerun_failures else { return true }; + let name = signature.split('(').next().unwrap_or(&signature); + failures.iter().any(|failure| { + self.matches_rerun_contract(&failure.contract, contract_id) + && (failure.test == signature || failure.test == name) + }) + } } impl fmt::Display for ProjectPathsAwareFilter { diff --git a/crates/forge/src/cmd/test/mod.rs b/crates/forge/src/cmd/test/mod.rs index e6171f0cf17c5..7ec1052f740b5 100644 --- a/crates/forge/src/cmd/test/mod.rs +++ b/crates/forge/src/cmd/test/mod.rs @@ -1,30 +1,56 @@ -use super::{install, test::filter::ProjectPathsAwareFilter, watch::WatchArgs}; +use super::{fuzz::FuzzRunArgs, watch::WatchArgs}; use crate::{ - MultiContractRunner, MultiContractRunnerBuilder, TestFilter, + MultiContractRunner, MultiContractRunnerBuilder, brutalizer, decode::decode_console_logs, gas_report::GasReport, - multi_runner::matches_contract, - result::{SuiteResult, TestOutcome, TestStatus}, + multi_runner::{ + FuzzFailureReplayConfig, FuzzMinimizeConfig, FuzzMinimizeEdgeIndices, FuzzMinimizeMode, + FuzzMinimizeObservation, MultiNetworkConfig, ShowmapConfig, SymbolicArtifactReplayConfig, + TestFunctionMatcher, is_generated_symbolic_regression_contract, + }, + mutation::{MutationRunConfig, run_mutation_testing}, + result::{ + SYMBOLIC_COUNTEREXAMPLE_ARTIFACT_SCHEMA, SuiteResult, SymbolicCounterexampleArtifact, + SymbolicReplayStatus, TestKind, TestKindReport, TestOutcome, TestResult, TestStatus, + }, + runner::{effective_test_function_kind, inline_config_for}, + symbolic_regression::{ + SymbolicRegression, SymbolicRegressionConfig, attach_symbolic_regressions_to_suites, + collect_symbolic_artifacts_from_suites, emit_symbolic_regressions, + }, traces::{ CallTraceDecoderBuilder, InternalTraceMode, TraceKind, debug::{ContractSources, DebugTraceIdentifier}, decode_trace_arena, folded_stack_trace, identifier::SignaturesIdentifier, + render_trace_arena_inner, speedscope, }, + workspace, }; +use alloy_json_abi::JsonAbi; use alloy_primitives::U256; use chrono::Utc; -use clap::{Parser, ValueHint}; +use clap::{Parser, ValueEnum, ValueHint}; +use dialoguer::{Select, console::Term}; use eyre::{Context, OptionExt, Result, bail}; -use foundry_block_explorers::EtherscanApiVersion; use foundry_cli::{ - opts::{BuildOpts, GlobalArgs}, - utils::{self, LoadConfig}, + opts::{BuildOpts, EvmArgs, GlobalArgs, TracingArgs}, + utils::{self, FoundryPathExt, LoadConfig}, +}; +use foundry_common::{ + ContractsByArtifact, EmptyTestFilter, TestFilter, TestFunctionExt, TestFunctionKind, + compile::{ProjectCompiler, compile_abi_project_cached}, + external_compiler::is_external_artifact, + fs, + fs::canonicalize_path, + sh_status, sh_warn, shell, }; -use foundry_common::{TestFunctionExt, compile::ProjectCompiler, evm::EvmArgs, fs, shell}; use foundry_compilers::{ - ProjectCompileOutput, - artifacts::output_selection::OutputSelection, + Artifact, ArtifactId, ProjectCompileOutput, + artifacts::{ + BytecodeObject, ConfigurableContractArtifact, Libraries, + output_selection::ContractOutputSelection, + }, compilers::{ Language, multi::{MultiCompiler, MultiCompilerLanguage}, @@ -32,39 +58,443 @@ use foundry_compilers::{ utils::source_files_iter, }; use foundry_config::{ - Config, figment, + Config, InlineConfig, InvariantDepthMode, InvariantWorkers, figment, figment::{ Metadata, Profile, Provider, - value::{Dict, Map}, + value::{Dict, Map, Value}, }, filter::GlobMatcher, + fs_permissions::FsAccessPermission, +}; +use foundry_debugger::{Debugger, DebuggerLayout}; +use foundry_evm::{ + backend::Backend, + core::evm::{EthEvmNetwork, FoundryEvmNetwork, TempoEvmNetwork}, + executors::{ExecutorBuilder, ShowmapDomain}, + fuzz::{BaseCounterExample, BasicTxDetails, CounterExample}, + opts::EvmOpts, + traces::{ + backtrace::BacktraceBuilder, identifier::TraceIdentifiers, prune_trace_depth, + trace_arena_at_depth, + }, }; -use foundry_debugger::Debugger; -use foundry_evm::traces::identifier::TraceIdentifiers; +use foundry_evm_networks::NetworkVariant; +use foundry_tui::tui_mode; +use quick_junit::{NonSuccessKind, Report, TestCase, TestCaseStatus, TestSuite}; +use rand::Rng; use regex::Regex; +use revm::{bytecode::opcode::OpCode, context::Transaction}; +use solar::ast::{ContractKind as SolarContractKind, ItemKind}; use std::{ collections::{BTreeMap, BTreeSet}, fmt::Write, - path::PathBuf, - sync::{Arc, mpsc::channel}, + path::{Path, PathBuf}, + sync::{Arc, Mutex, mpsc::channel}, time::{Duration, Instant}, }; +use tempfile::TempDir; use yansi::Paint; +#[cfg(feature = "base")] +use foundry_evm::core::evm::BaseEvmNetwork; + +#[cfg(feature = "monad")] +use foundry_evm::core::evm::MonadEvmNetwork; + +#[cfg(feature = "optimism")] +use foundry_evm::core::evm::OpEvmNetwork; + +mod evm_profile_server; mod filter; mod summary; -use crate::{result::TestKind, traces::render_trace_arena_inner}; -pub use filter::FilterArgs; -use quick_junit::{NonSuccessKind, Report, TestCase, TestCaseStatus, TestSuite}; +use filter::RerunFailures; use summary::{TestSummaryReport, format_invariant_metrics_table}; +pub use filter::{FilterArgs, ProjectPathsAwareFilter, RerunFailure}; + +const DEBUGGER_MATCHING_TESTS_DISPLAY_LIMIT: usize = 12; +const AUTO_FUZZ_FAILURE_DIR: &str = "fuzz"; +const AUTO_CORPUS_DIR: &str = "corpus"; + // Loads project's figment and merges the build cli arguments into it foundry_config::merge_impl_figment_convert!(TestArgs, build, evm); -/// CLI arguments for `forge test`. +fn validate_showmap_config(showmap: &ShowmapConfig) -> Result<()> { + for (kind, name) in [("approach", &showmap.approach), ("trial", &showmap.trial)] { + let path = Path::new(name); + if name.is_empty() + || path.is_absolute() + || path.components().count() != 1 + || name.contains(['/', '\\']) + || matches!(name.as_str(), "." | "..") + { + bail!( + "invalid showmap {kind} `{name}`: expected a single file-name component without path separators" + ); + } + } + Ok(()) +} + +/// Compiled runners for every network pass of a `forge fuzz` minimization command. +pub(crate) struct FuzzMinimizeReplaySession { + filter: ProjectPathsAwareFilter, + passes: Vec, +} + +type FuzzMinimizeReplay = Box Result<()>>; + +struct FuzzMinimizeReplayPass { + target_count: usize, + replay: FuzzMinimizeReplay, +} + +impl FuzzMinimizeReplaySession { + pub(crate) fn replay( + &self, + sequence: Vec, + evm_edge_indices: FuzzMinimizeEdgeIndices, + mode: FuzzMinimizeMode, + ) -> Result> { + let observations = Arc::new(Mutex::new(Vec::new())); + let fuzz_minimize = FuzzMinimizeConfig { + input: sequence.into(), + mode, + evm_edge_indices, + observations: observations.clone(), + }; + for pass in self.passes.iter().filter(|pass| pass.target_count > 0) { + (pass.replay)(&self.filter, fuzz_minimize.clone())?; + } + let observations = observations + .lock() + .map_err(|_| eyre::eyre!("minimize observations lock poisoned"))? + .clone(); + if observations.is_empty() { + bail!("fuzz minimization replay produced no observation for the matched test"); + } + Ok(observations) + } +} + +fn fuzz_minimize_pass( + runner: MultiContractRunner, + filter: &ProjectPathsAwareFilter, +) -> FuzzMinimizeReplayPass { + let target_count = count_fuzz_minimize_targets(&runner, filter); + let replay = move |filter: &ProjectPathsAwareFilter, fuzz_minimize| -> Result<()> { + let mut runner = runner.clone(); + // Release forks that this replay creates when the replay ends. + runner.backend = runner.backend.clone_with_fork_scope()?; + runner.tcfg.fuzz_minimize = Some(fuzz_minimize); + for (suite, suite_result) in runner.test_collect(filter)? { + for (test, test_result) in suite_result.test_results { + if test_result.status.is_failure() { + bail!( + "fuzz minimization replay failed for {suite}::{test}: {}", + test_result.reason.as_deref().unwrap_or("unknown error") + ); + } + } + } + Ok(()) + }; + FuzzMinimizeReplayPass { target_count, replay: Box::new(replay) } +} + +fn count_fuzz_minimize_targets( + runner: &MultiContractRunner, + filter: &dyn TestFilter, +) -> usize { + let matcher = runner.test_function_matcher(); + runner + .matching_contracts(filter) + .map(|(id, contract)| { + let (fuzz, invariant) = matcher.count_fuzz_engine_targets( + filter, + id, + &contract.abi, + &runner.tcfg.multi_network, + ); + fuzz + invariant + }) + .sum() +} + +/// Evaluates `$body` with `$fen` bound to the concrete network type selected by `$evm_opts`. +macro_rules! dispatch_network { + ($evm_opts:expr, | $fen:ident | $body:expr) => { + match $evm_opts.networks.execution_network() { + #[cfg(feature = "base")] + NetworkVariant::Base => { + type $fen = BaseEvmNetwork; + $body + } + NetworkVariant::Tempo => { + type $fen = TempoEvmNetwork; + $body + } + #[cfg(feature = "monad")] + NetworkVariant::Monad => { + type $fen = MonadEvmNetwork; + $body + } + #[cfg(feature = "optimism")] + NetworkVariant::Optimism => { + type $fen = OpEvmNetwork; + $body + } + NetworkVariant::Ethereum => { + type $fen = EthEvmNetwork; + $body + } + } + }; +} + +/// Output format for EVM execution profiles. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)] +pub enum EvmProfileFormat { + /// Speedscope format, opens in speedscope.app. + #[default] + Speedscope, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum TraceOutputKind { + Flamegraph, + Flamechart, + EvmProfile(EvmProfileFormat), +} + +impl TraceOutputKind { + const fn label(self) -> &'static str { + match self { + Self::Flamegraph => "flamegraph", + Self::Flamechart => "flamechart", + Self::EvmProfile(_) => "EVM profile", + } + } +} + +/// CLI mirror of `foundry_evm::executors::ShowmapDomain`. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, ValueEnum)] +#[clap(rename_all = "lowercase")] +pub enum ShowmapDomainArg { + #[default] + Evm, + Sancov, + Both, +} + +impl From for ShowmapDomain { + fn from(d: ShowmapDomainArg) -> Self { + match d { + ShowmapDomainArg::Evm => Self::Evm, + ShowmapDomainArg::Sancov => Self::Sancov, + ShowmapDomainArg::Both => Self::Both, + } + } +} + +#[derive(Clone, Debug)] +pub(crate) struct TestExecutionOptions { + pub(crate) coverage: bool, + pub(crate) decode_internal: InternalTraceMode, + pub(crate) multi_network: MultiNetworkConfig, + pub(crate) fuzz_input: Option, + pub(crate) replay_symbolic_artifact: Option, + pub(crate) inline_config: Arc, + pub(crate) selected_sources: BTreeSet, +} + +impl TestExecutionOptions { + pub(crate) fn default_run(inline_config: Arc) -> Self { + Self { + coverage: false, + decode_internal: InternalTraceMode::None, + multi_network: MultiNetworkConfig::default(), + fuzz_input: None, + replay_symbolic_artifact: None, + inline_config, + selected_sources: BTreeSet::new(), + } + } + + pub(crate) fn coverage(inline_config: Arc) -> Self { + Self { coverage: true, ..Self::default_run(inline_config) } + } +} + +/// Config and EVM options for one network pass of a multi-network run. +struct NetworkPass { + config: Config, + evm_opts: EvmOpts, + multi_network: MultiNetworkConfig, +} + +/// Splits a run into the default network pass and one override pass per annotated network. +fn network_passes( + config: Config, + evm_opts: EvmOpts, + override_networks: &[NetworkVariant], +) -> (NetworkPass, Vec) { + let multi_network = |pass_network| MultiNetworkConfig { + all_override_networks: override_networks.to_vec(), + pass_network, + }; + let override_passes = override_networks + .iter() + .map(|&network| { + let mut evm_opts = evm_opts.clone(); + evm_opts.set_explicit_network(network); + let mut config = config.clone(); + config.networks = evm_opts.networks; + NetworkPass { config, evm_opts, multi_network: multi_network(Some(network)) } + }) + .collect(); + (NetworkPass { config, evm_opts, multi_network: multi_network(None) }, override_passes) +} + +struct CompiledTestProject { + project_root: PathBuf, + config: Config, + evm_opts: EvmOpts, + output: ProjectCompileOutput, + filter: ProjectPathsAwareFilter, + inline_config: Arc, + replay_symbolic_artifact: Option, + selected_sources: BTreeSet, + /// Keeps the brutalized copy of the project alive while its tests run. + _brutalized_workspace: Option, +} + +/// Shared campaign arguments for `forge fuzz run`. #[derive(Clone, Debug, Parser)] +#[command(next_help_heading = "Campaign options")] +pub struct CampaignArgs { + /// Number of runs to execute for each fuzz or invariant campaign. + #[arg(long, value_name = "RUNS")] + pub runs: Option, + + /// Campaign-global timeout in seconds. + #[arg(long, value_name = "TIMEOUT")] + pub timeout: Option, + + /// Set seed used to generate randomness during fuzz runs. + #[arg(long)] + pub seed: Option, + + /// Number of calls executed to try to break invariants in one run. + #[arg(long, value_name = "DEPTH")] + pub depth: Option, + + /// Minimum sampled invariant depth when `--depth-mode random` is active. + #[arg(long, value_name = "DEPTH")] + pub min_depth: Option, + + /// How invariant run depth is selected. + #[arg(long, value_name = "fixed|random")] + pub depth_mode: Option, + + /// Number of workers to use for invariant test campaigns, or `auto` to derive from `--jobs`. + #[arg(long, value_name = "WORKERS")] + pub workers: Option, + + /// Directory for fuzz and invariant corpus persistence. + #[arg(long, value_name = "PATH", value_hint = ValueHint::DirPath)] + pub corpus_dir: Option, + + /// Percent of calldata generated from the dictionary. + #[arg(long, value_name = "PERCENT")] + pub dictionary_weight: Option, + + /// Maximum dictionary addresses, or `max`. + #[arg(long, value_name = "N|max")] + pub dictionary_addresses: Option, + + /// Maximum dictionary values, or `max`. + #[arg(long, value_name = "N|max")] + pub dictionary_values: Option, + + /// Maximum dictionary literals, or `max`. + #[arg(long, value_name = "N|max")] + pub dictionary_literals: Option, + + /// Percent chance that coverage-guided fuzzing generates fresh input instead of mutating + /// corpus input. + #[arg(long, value_name = "PERCENT")] + pub corpus_random_sequence_weight: Option, + + /// Percent chance that fuzzed payable calls carry non-zero msg.value. + #[arg(long, value_name = "PERCENT")] + pub payable_value_weight: Option, + + /// Corpus mutation weight for splice. + #[arg(long, value_name = "WEIGHT")] + pub mutation_weight_splice: Option, + + /// Corpus mutation weight for repeat. + #[arg(long, value_name = "WEIGHT")] + pub mutation_weight_repeat: Option, + + /// Corpus mutation weight for interleave. + #[arg(long, value_name = "WEIGHT")] + pub mutation_weight_interleave: Option, + + /// Corpus mutation weight for prefix replacement. + #[arg(long, value_name = "WEIGHT")] + pub mutation_weight_prefix: Option, + + /// Corpus mutation weight for suffix replacement. + #[arg(long, value_name = "WEIGHT")] + pub mutation_weight_suffix: Option, + + /// Corpus mutation weight for ABI argument mutation. + #[arg(long, value_name = "WEIGHT")] + pub mutation_weight_abi: Option, + + /// Corpus mutation weight for comparison-operand mutation. + #[arg(long, value_name = "WEIGHT")] + pub mutation_weight_cmp: Option, + + /// Directory for fuzz branch frontier artifacts. + #[arg(long, value_name = "PATH", value_hint = ValueHint::DirPath)] + pub frontier_dir: Option, + + /// Maximum number of fuzz branch frontier records to write per test. + #[arg(long, value_name = "COUNT")] + pub frontier_limit: Option, +} + +/// CLI arguments for `forge test`. +#[derive(Clone, Debug, Default, Parser)] #[command(next_help_heading = "Test options")] pub struct TestArgs { + /// Internal mode used by `forge fuzz` to run only fuzz and invariant tests. + #[arg(skip)] + fuzz_only: bool, + + /// Internal mode used by `forge fuzz run` to default the fuzz corpus dir. + #[arg(skip)] + auto_fuzz_corpus: bool, + + /// Internal showmap/replay override used by `forge fuzz replay`. + #[arg(skip)] + pub(crate) showmap_override: Option, + + /// Internal mode used by `forge fuzz replay` to replay persisted fuzz failures. + #[arg(skip)] + pub(crate) fuzz_failure_replay: bool, + + /// Internal override used by `forge fuzz run --runs` for invariant campaigns. + #[arg(skip)] + pub(crate) invariant_runs_override: Option, + + /// Internal override used by `forge fuzz run --timeout` for invariant campaigns. + #[arg(skip)] + pub(crate) invariant_timeout_override: Option, + // Include global options for users of this struct. #[command(flatten)] pub global: GlobalArgs, @@ -79,31 +509,59 @@ pub struct TestArgs { /// /// If the matching test is a fuzz test, then it will open the debugger on the first failure /// case. If the fuzz test does not fail, it will open the debugger on the last fuzz case. - #[arg(long, conflicts_with_all = ["flamegraph", "flamechart", "decode_internal", "rerun"])] + #[arg(long, conflicts_with_all = ["flamegraph", "flamechart", "evm_profile", "decode_internal", "rerun"])] debug: bool, + /// Debugger layout to use. + #[arg(long = "debug-layout", requires = "debug", value_enum)] + debug_layout: Option, + /// Generate a flamegraph for a single test. Implies `--decode-internal`. /// /// A flame graph is used to visualize which functions or operations within the smart contract /// are consuming the most gas overall in a sorted manner. - #[arg(long)] + #[arg( + long, + group = "trace_output", + conflicts_with_all = ["flamechart", "evm_profile", "json", "junit", "list"] + )] flamegraph: bool, /// Generate a flamechart for a single test. Implies `--decode-internal`. /// /// A flame chart shows the gas usage over time, illustrating when each function is /// called (execution order) and how much gas it consumes at each point in the timeline. - #[arg(long, conflicts_with = "flamegraph")] + #[arg( + long, + group = "trace_output", + conflicts_with_all = ["flamegraph", "evm_profile", "json", "junit", "list"] + )] flamechart: bool, - /// Identify internal functions in traces. + /// Generate an execution profile for a single test. /// - /// This will trace internal functions and decode stack parameters. + /// Creates a profile where each EVM call is recorded with gas consumption. + /// Opens the profile in speedscope.app unless `--no-open` is passed. + /// Implies `--decode-internal`. + #[arg( + long, + value_name = "FORMAT", + num_args = 0..=1, + default_missing_value = "speedscope", + value_enum, + group = "trace_output", + conflicts_with_all = ["flamegraph", "flamechart", "json", "junit", "list"] + )] + evm_profile: Option, + + /// Don't open the generated profile, flamegraph, or flamechart. /// - /// Parameters stored in memory (such as bytes or arrays) are currently decoded only when a - /// single function is matched, similarly to `--debug`, for performance reasons. - #[arg(long)] - decode_internal: bool, + /// The profile is saved to disk without starting the local viewer server. + #[arg(long, requires = "trace_output")] + no_open: bool, + + #[command(flatten)] + tracing: TracingArgs, /// Dumps all debugger steps to file. #[arg( @@ -131,9 +589,19 @@ pub struct TestArgs { allow_failure: bool, /// Suppress successful test traces and show only traces for failures. - #[arg(long, short, env = "FORGE_SUPPRESS_SUCCESSFUL_TRACES", help_heading = "Display options")] + #[arg(long, short, env = "FORGE_SUPPRESS_SUCCESSFUL_TRACES", help_heading = "Trace options")] suppress_successful_traces: bool, + /// Write test results as JSON to the specified file. + #[arg( + long, + value_name = "PATH", + value_hint = ValueHint::FilePath, + conflicts_with = "list", + help_heading = "Display options" + )] + json_file: Option, + /// Output test results as JUnit XML report. #[arg(long, conflicts_with_all = ["quiet", "json", "gas_report", "summary", "list", "show_progress"], help_heading = "Display options")] pub junit: bool, @@ -146,10 +614,6 @@ pub struct TestArgs { #[arg(long, env = "ETHERSCAN_API_KEY", value_name = "KEY")] etherscan_api_key: Option, - /// The Etherscan API version. - #[arg(long, env = "ETHERSCAN_API_VERSION", value_name = "VERSION")] - etherscan_api_version: Option, - /// List tests instead of running them. #[arg(long, short, conflicts_with_all = ["show_progress", "decode_internal", "summary"], help_heading = "Display options")] list: bool, @@ -161,378 +625,1908 @@ pub struct TestArgs { #[arg(long, env = "FOUNDRY_FUZZ_RUNS", value_name = "RUNS")] pub fuzz_runs: Option, + /// Number of workers to use for invariant test campaigns, or `auto` to derive from `--jobs`. + #[arg(long, env = "FOUNDRY_INVARIANT_WORKERS", value_name = "WORKERS")] + pub invariant_workers: Option, + + /// Run only the fuzz case at the given 1-based run index. + #[arg(long, env = "FOUNDRY_FUZZ_RUN", value_name = "RUN")] + pub fuzz_run: Option, + + /// Run the fuzz case from the given worker. Requires `--fuzz-run`. + #[arg(long, env = "FOUNDRY_FUZZ_WORKER", value_name = "WORKER", requires = "fuzz_run")] + pub fuzz_worker: Option, + /// Timeout for each fuzz run in seconds. #[arg(long, env = "FOUNDRY_FUZZ_TIMEOUT", value_name = "TIMEOUT")] pub fuzz_timeout: Option, - /// File to rerun fuzz failures from. - #[arg(long)] - pub fuzz_input_file: Option, + /// Percent of fuzz calldata generated from the dictionary. + #[arg(long, env = "FOUNDRY_FUZZ_DICTIONARY_WEIGHT", value_name = "PERCENT")] + pub fuzz_dictionary_weight: Option, - /// Show test execution progress. - #[arg(long, conflicts_with_all = ["quiet", "json"], help_heading = "Display options")] - pub show_progress: bool, + /// Maximum fuzz dictionary addresses, or `max`. + #[arg(long, env = "FOUNDRY_FUZZ_MAX_FUZZ_DICTIONARY_ADDRESSES", value_name = "N|max")] + pub fuzz_dictionary_addresses: Option, - /// Re-run recorded test failures from last run. - /// If no failure recorded then regular test run is performed. - #[arg(long)] - pub rerun: bool, + /// Maximum fuzz dictionary values, or `max`. + #[arg(long, env = "FOUNDRY_FUZZ_MAX_FUZZ_DICTIONARY_VALUES", value_name = "N|max")] + pub fuzz_dictionary_values: Option, - /// Print test summary table. - #[arg(long, help_heading = "Display options")] - pub summary: bool, + /// Maximum fuzz dictionary literals, or `max`. + #[arg(long, env = "FOUNDRY_FUZZ_MAX_FUZZ_DICTIONARY_LITERALS", value_name = "N|max")] + pub fuzz_dictionary_literals: Option, - /// Print detailed test summary table. - #[arg(long, help_heading = "Display options", requires = "summary")] - pub detailed: bool, + /// Percent chance that coverage-guided fuzzing generates fresh input instead of mutating + /// corpus input. + #[arg(long, env = "FOUNDRY_FUZZ_CORPUS_RANDOM_SEQUENCE_WEIGHT", value_name = "PERCENT")] + pub fuzz_corpus_random_sequence_weight: Option, - #[command(flatten)] - filter: FilterArgs, + /// Directory for fuzz corpus persistence. + #[arg(long, env = "FOUNDRY_FUZZ_CORPUS_DIR", value_name = "PATH", value_hint = ValueHint::DirPath)] + pub fuzz_corpus_dir: Option, - #[command(flatten)] - evm: EvmArgs, + /// Directory for fuzz branch frontier artifacts. + #[arg(long, env = "FOUNDRY_FUZZ_FRONTIER_DIR", value_name = "PATH", value_hint = ValueHint::DirPath)] + pub fuzz_frontier_dir: Option, - #[command(flatten)] - pub build: BuildOpts, + /// Maximum number of fuzz branch frontier records to write per test. + #[arg(long, env = "FOUNDRY_FUZZ_FRONTIER_LIMIT", value_name = "COUNT")] + pub fuzz_frontier_limit: Option, - #[command(flatten)] - pub watch: WatchArgs, -} + /// Percent chance that fuzzed payable calls carry non-zero msg.value. + #[arg(long, env = "FOUNDRY_FUZZ_PAYABLE_VALUE_WEIGHT", value_name = "PERCENT")] + pub fuzz_payable_value_weight: Option, -impl TestArgs { - pub async fn run(self) -> Result { - trace!(target: "forge::test", "executing test command"); - self.execute_tests().await - } + /// Corpus mutation weight for splice. + #[arg(long, env = "FOUNDRY_FUZZ_MUTATION_WEIGHT_SPLICE", value_name = "WEIGHT")] + pub fuzz_mutation_weight_splice: Option, - /// Returns sources which include any tests to be executed. - /// If no filters are provided, sources are filtered by existence of test/invariant methods in - /// them, If filters are provided, sources are additionally filtered by them. - pub fn get_sources_to_compile( - &self, - config: &Config, - filter: &ProjectPathsAwareFilter, - ) -> Result> { - let mut project = config.create_project(true, true)?; - project.update_output_selection(|selection| { - *selection = OutputSelection::common_output_selection(["abi".to_string()]); - }); + /// Corpus mutation weight for repeat. + #[arg(long, env = "FOUNDRY_FUZZ_MUTATION_WEIGHT_REPEAT", value_name = "WEIGHT")] + pub fuzz_mutation_weight_repeat: Option, - let output = project.compile()?; + /// Corpus mutation weight for interleave. + #[arg(long, env = "FOUNDRY_FUZZ_MUTATION_WEIGHT_INTERLEAVE", value_name = "WEIGHT")] + pub fuzz_mutation_weight_interleave: Option, - if output.has_compiler_errors() { - sh_println!("{output}")?; - eyre::bail!("Compilation failed"); - } + /// Corpus mutation weight for prefix replacement. + #[arg(long, env = "FOUNDRY_FUZZ_MUTATION_WEIGHT_PREFIX", value_name = "WEIGHT")] + pub fuzz_mutation_weight_prefix: Option, - // ABIs of all sources - let abis = output - .into_artifacts() - .filter_map(|(id, artifact)| artifact.abi.map(|abi| (id, abi))) - .collect::>(); + /// Corpus mutation weight for suffix replacement. + #[arg(long, env = "FOUNDRY_FUZZ_MUTATION_WEIGHT_SUFFIX", value_name = "WEIGHT")] + pub fuzz_mutation_weight_suffix: Option, - // Filter sources by their abis and contract names. - let mut test_sources = abis - .iter() - .filter(|(id, abi)| matches_contract(id, abi, filter)) - .map(|(id, _)| id.source.clone()) - .collect::>(); + /// Corpus mutation weight for ABI argument mutation. + #[arg(long, env = "FOUNDRY_FUZZ_MUTATION_WEIGHT_ABI", value_name = "WEIGHT")] + pub fuzz_mutation_weight_abi: Option, - if test_sources.is_empty() { - if filter.is_empty() { - sh_println!( - "No tests found in project! \ - Forge looks for functions that starts with `test`." - )?; - } else { - sh_println!("No tests match the provided pattern:")?; - sh_print!("{filter}")?; - - // Try to suggest a test when there's no match - if let Some(test_pattern) = &filter.args().test_pattern { - let test_name = test_pattern.as_str(); - let candidates = abis - .into_iter() - .filter(|(id, _)| { - filter.matches_path(&id.source) && filter.matches_contract(&id.name) - }) - .flat_map(|(_, abi)| abi.functions.into_keys()) - .collect::>(); - if let Some(suggestion) = utils::did_you_mean(test_name, candidates).pop() { - sh_println!("\nDid you mean `{suggestion}`?")?; - } - } - } + /// Corpus mutation weight for comparison-operand mutation. + #[arg(long, env = "FOUNDRY_FUZZ_MUTATION_WEIGHT_CMP", value_name = "WEIGHT")] + pub fuzz_mutation_weight_cmp: Option, - eyre::bail!("No tests to run"); - } + /// File to rerun fuzz failures from. + #[arg( + long, + value_name = "PATH", + value_hint = ValueHint::FilePath, + conflicts_with_all = ["fuzz_run", "list"] + )] + pub fuzz_input_file: Option, - // Always recompile all sources to ensure that `getCode` cheatcode can use any artifact. - test_sources.extend(source_files_iter( - &project.paths.sources, - MultiCompilerLanguage::FILE_EXTENSIONS, - )); + /// Number of calls executed to try to break invariants in one run. + #[arg(long, env = "FOUNDRY_INVARIANT_DEPTH", value_name = "DEPTH")] + pub invariant_depth: Option, - Ok(test_sources) - } + /// Minimum sampled invariant depth when `--invariant-depth-mode random` is active. + #[arg(long, env = "FOUNDRY_INVARIANT_MIN_DEPTH", value_name = "DEPTH")] + pub invariant_min_depth: Option, - /// Executes all the tests in the project. - /// - /// This will trigger the build process first. On success all test contracts that match the - /// configured filter will be executed - /// - /// Returns the test results for all matching tests. - pub async fn execute_tests(mut self) -> Result { - // Merge all configs. - let (mut config, mut evm_opts) = self.load_config_and_evm_opts()?; + /// How invariant run depth is selected. + #[arg(long, env = "FOUNDRY_INVARIANT_DEPTH_MODE", value_name = "fixed|random")] + pub invariant_depth_mode: Option, - // Explicitly enable isolation for gas reports for more correct gas accounting. - if self.gas_report { - evm_opts.isolate = true; - } else { - // Do not collect gas report traces if gas report is not enabled. - config.fuzz.gas_report_samples = 0; - config.invariant.gas_report_samples = 0; - } + /// Percent of invariant calldata/senders generated from the dictionary. + #[arg(long, env = "FOUNDRY_INVARIANT_DICTIONARY_WEIGHT", value_name = "PERCENT")] + pub invariant_dictionary_weight: Option, - // Install missing dependencies. - if install::install_missing_dependencies(&mut config) && config.auto_detect_remappings { - // need to re-configure here to also catch additional remappings - config = self.load_config()?; - } + /// Maximum invariant dictionary addresses, or `max`. + #[arg(long, env = "FOUNDRY_INVARIANT_MAX_FUZZ_DICTIONARY_ADDRESSES", value_name = "N|max")] + pub invariant_dictionary_addresses: Option, - // Set up the project. - let project = config.project()?; + /// Maximum invariant dictionary values, or `max`. + #[arg(long, env = "FOUNDRY_INVARIANT_MAX_FUZZ_DICTIONARY_VALUES", value_name = "N|max")] + pub invariant_dictionary_values: Option, - let filter = self.filter(&config)?; - trace!(target: "forge::test", ?filter, "using filter"); + /// Maximum invariant dictionary literals, or `max`. + #[arg(long, env = "FOUNDRY_INVARIANT_MAX_FUZZ_DICTIONARY_LITERALS", value_name = "N|max")] + pub invariant_dictionary_literals: Option, - let sources_to_compile = self.get_sources_to_compile(&config, &filter)?; + /// Percent chance that coverage-guided invariant fuzzing injects fresh calls while extending + /// corpus sequences. + #[arg(long, env = "FOUNDRY_INVARIANT_CORPUS_RANDOM_SEQUENCE_WEIGHT", value_name = "PERCENT")] + pub invariant_corpus_random_sequence_weight: Option, - let compiler = ProjectCompiler::new() - .dynamic_test_linking(config.dynamic_test_linking) - .quiet(shell::is_json() || self.junit) - .files(sources_to_compile); + /// Directory for invariant corpus persistence. + #[arg(long, env = "FOUNDRY_INVARIANT_CORPUS_DIR", value_name = "PATH", value_hint = ValueHint::DirPath)] + pub invariant_corpus_dir: Option, - let output = compiler.compile(&project)?; + /// Directory for invariant branch frontier artifacts. + #[arg(long, env = "FOUNDRY_INVARIANT_FRONTIER_DIR", value_name = "PATH", value_hint = ValueHint::DirPath)] + pub invariant_frontier_dir: Option, - // Create test options from general project settings and compiler output. - let project_root = &project.paths.root; + /// Maximum number of invariant branch frontier records to write per campaign. + #[arg(long, env = "FOUNDRY_INVARIANT_FRONTIER_LIMIT", value_name = "COUNT")] + pub invariant_frontier_limit: Option, - let should_debug = self.debug; - let should_draw = self.flamegraph || self.flamechart; + /// Percent chance that fuzzed payable invariant calls carry non-zero msg.value. + #[arg(long, env = "FOUNDRY_INVARIANT_PAYABLE_VALUE_WEIGHT", value_name = "PERCENT")] + pub invariant_payable_value_weight: Option, - // Determine print verbosity and executor verbosity. - let verbosity = evm_opts.verbosity; - if (self.gas_report && evm_opts.verbosity < 3) || self.flamegraph || self.flamechart { - evm_opts.verbosity = 3; - } + /// Corpus mutation weight for splice. + #[arg(long, env = "FOUNDRY_INVARIANT_MUTATION_WEIGHT_SPLICE", value_name = "WEIGHT")] + pub invariant_mutation_weight_splice: Option, - let env = evm_opts.evm_env().await?; + /// Corpus mutation weight for repeat. + #[arg(long, env = "FOUNDRY_INVARIANT_MUTATION_WEIGHT_REPEAT", value_name = "WEIGHT")] + pub invariant_mutation_weight_repeat: Option, - // Enable internal tracing for more informative flamegraph. - if should_draw && !self.decode_internal { - self.decode_internal = true; - } + /// Corpus mutation weight for interleave. + #[arg(long, env = "FOUNDRY_INVARIANT_MUTATION_WEIGHT_INTERLEAVE", value_name = "WEIGHT")] + pub invariant_mutation_weight_interleave: Option, - // Choose the internal function tracing mode, if --decode-internal is provided. - let decode_internal = if self.decode_internal { - // If more than one function matched, we enable simple tracing. - // If only one function matched, we enable full tracing. This is done in `run_tests`. - InternalTraceMode::Simple - } else { - InternalTraceMode::None - }; + /// Corpus mutation weight for prefix replacement. + #[arg(long, env = "FOUNDRY_INVARIANT_MUTATION_WEIGHT_PREFIX", value_name = "WEIGHT")] + pub invariant_mutation_weight_prefix: Option, - // Prepare the test builder. - let config = Arc::new(config); - let runner = MultiContractRunnerBuilder::new(config.clone()) - .set_debug(should_debug) - .set_decode_internal(decode_internal) - .initial_balance(evm_opts.initial_balance) - .evm_spec(config.evm_spec_id()) - .sender(evm_opts.sender) - .with_fork(evm_opts.get_fork(&config, env.clone())) - .enable_isolation(evm_opts.isolate) - .odyssey(evm_opts.odyssey) - .build::(project_root, &output, env, evm_opts)?; + /// Corpus mutation weight for suffix replacement. + #[arg(long, env = "FOUNDRY_INVARIANT_MUTATION_WEIGHT_SUFFIX", value_name = "WEIGHT")] + pub invariant_mutation_weight_suffix: Option, - let libraries = runner.libraries.clone(); - let mut outcome = self.run_tests(runner, config, verbosity, &filter, &output).await?; + /// Corpus mutation weight for ABI argument mutation. + #[arg(long, env = "FOUNDRY_INVARIANT_MUTATION_WEIGHT_ABI", value_name = "WEIGHT")] + pub invariant_mutation_weight_abi: Option, - if should_draw { - let (suite_name, test_name, mut test_result) = - outcome.remove_first().ok_or_eyre("no tests were executed")?; + /// Corpus mutation weight for comparison-operand mutation. + #[arg(long, env = "FOUNDRY_INVARIANT_MUTATION_WEIGHT_CMP", value_name = "WEIGHT")] + pub invariant_mutation_weight_cmp: Option, - let (_, arena) = test_result - .traces - .iter_mut() - .find(|(kind, _)| *kind == TraceKind::Execution) - .unwrap(); + /// Run symbolic check*/prove*/invariant*/statefulFuzz* tests. + #[arg(long, env = "FOUNDRY_SYMBOLIC")] + pub symbolic: bool, - // Decode traces. - let decoder = outcome.last_run_decoder.as_ref().unwrap(); - decode_trace_arena(arena, decoder).await; - let mut fst = folded_stack_trace::build(arena); + /// Replay a durable symbolic counterexample artifact emitted by `forge test --symbolic`. + #[arg( + long, + value_name = "PATH", + value_hint = ValueHint::FilePath, + conflicts_with_all = [ + "debug", + "flamegraph", + "flamechart", + "rerun", + "fuzz_input_file", + "showmap_out", + "path", + "test_pattern", + "test_pattern_inverse", + "contract_pattern", + "contract_pattern_inverse", + "path_pattern", + "no-match-path", + ], + )] + pub replay_symbolic_artifact: Option, - let label = if self.flamegraph { "flamegraph" } else { "flamechart" }; - let contract = suite_name.split(':').next_back().unwrap(); - let test_name = test_name.trim_end_matches("()"); - let file_name = format!("cache/{label}_{contract}_{test_name}.svg"); - let file = std::fs::File::create(&file_name).wrap_err("failed to create file")?; - let file = std::io::BufWriter::new(file); + /// Emit Solidity regression tests for confirmed symbolic counterexamples. + #[arg(long, env = "FOUNDRY_SYMBOLIC_EMIT_REGRESSION")] + pub emit_regression: bool, - let mut options = inferno::flamegraph::Options::default(); - options.title = format!("{label} {contract}::{test_name}"); - options.count_name = "gas".to_string(); - if self.flamechart { - options.flame_chart = true; - fst.reverse(); - } + /// File or directory for generated symbolic regression tests. + #[arg( + long, + env = "FOUNDRY_SYMBOLIC_REGRESSION_OUT", + value_name = "PATH", + value_hint = ValueHint::AnyPath, + requires = "emit_regression" + )] + pub regression_out: Option, - // Generate SVG. - inferno::flamegraph::from_lines(&mut options, fst.iter().map(String::as_str), file) - .wrap_err("failed to write svg")?; - sh_println!("Saved to {file_name}")?; + /// Overwrite existing generated symbolic regression tests. + #[arg(long, env = "FOUNDRY_SYMBOLIC_REGRESSION_OVERWRITE", requires = "emit_regression")] + pub regression_overwrite: bool, - // Open SVG in default program. - if let Err(e) = opener::open(&file_name) { - sh_err!("Failed to open {file_name}; please open it manually: {e}")?; - } - } + /// Run fuzz tests symbolically and persist non-failing concrete inputs to the fuzz corpus. + #[arg(long, env = "FOUNDRY_SYMBOLIC_SEED_CORPUS")] + pub symbolic_seed_corpus: bool, - if should_debug { - // Get first non-empty suite result. We will have only one such entry. - let (_, _, test_result) = - outcome.remove_first().ok_or_eyre("no tests were executed")?; + /// Run fuzz tests symbolically using existing fuzz corpus entries as path-priority hints. + #[arg(long, env = "FOUNDRY_SYMBOLIC_USE_FUZZ_CORPUS")] + pub symbolic_use_fuzz_corpus: bool, - let sources = - ContractSources::from_project_output(&output, project.root(), Some(&libraries))?; + /// Maximum number of fuzz corpus entries to import for one symbolic test. + #[arg(long, env = "FOUNDRY_SYMBOLIC_CORPUS_SEED_LIMIT", value_name = "COUNT")] + pub symbolic_corpus_seed_limit: Option, - // Run the debugger. - let mut builder = Debugger::builder() - .traces( - test_result.traces.iter().filter(|(t, _)| t.is_execution()).cloned().collect(), - ) - .sources(sources) - .breakpoints(test_result.breakpoints.clone()); + /// Run targeted symbolic solving from existing fuzz branch frontier artifacts. + #[arg(long, env = "FOUNDRY_SYMBOLIC_USE_FUZZ_FRONTIERS")] + pub symbolic_use_fuzz_frontiers: bool, - if let Some(decoder) = &outcome.last_run_decoder { - builder = builder.decoder(decoder); - } + /// Check invariants from imported stateful fuzz frontier prefixes before flipping comparisons. + /// Also enables invariant frontier seeding. + #[arg(long, env = "FOUNDRY_SYMBOLIC_CHECK_INVARIANT_FRONTIERS")] + pub symbolic_check_invariant_frontiers: bool, - let mut debugger = builder.build(); - if let Some(dump_path) = self.dump { - debugger.dump_to_file(&dump_path)?; - } else { - debugger.try_run_tui()?; - } - } + /// Maximum number of fuzz branch frontiers to try for one symbolic test. + #[arg(long, env = "FOUNDRY_SYMBOLIC_FRONTIER_LIMIT", value_name = "COUNT")] + pub symbolic_frontier_limit: Option, - Ok(outcome) - } + /// Comma-separated fuzz branch frontier artifact IDs to try. + #[arg(long, env = "FOUNDRY_SYMBOLIC_FRONTIER_IDS", value_name = "IDS", value_delimiter = ',')] + pub symbolic_frontier_ids: Option>, - /// Run all tests that matches the filter predicate from a test runner - pub async fn run_tests( - &self, - mut runner: MultiContractRunner, - config: Arc, - verbosity: u8, - filter: &ProjectPathsAwareFilter, - output: &ProjectCompileOutput, - ) -> eyre::Result { - if self.list { - return list(runner, filter); - } + /// Comma-separated fuzz branch frontier comparison PCs to try. + #[arg(long, env = "FOUNDRY_SYMBOLIC_FRONTIER_PCS", value_name = "PCS", value_delimiter = ',')] + pub symbolic_frontier_pcs: Option>, - trace!(target: "forge::test", "running all tests"); + /// Comma-separated fuzz branch frontier calldata selectors to try. + #[arg( + long, + env = "FOUNDRY_SYMBOLIC_FRONTIER_SELECTORS", + value_name = "SELECTORS", + value_delimiter = ',' + )] + pub symbolic_frontier_selectors: Option>, - // If we need to render to a serialized format, we should not print anything else to stdout. - let silent = self.gas_report && shell::is_json() || self.summary && shell::is_json(); + /// Solver executable used for symbolic tests. + #[arg(long, env = "FOUNDRY_SYMBOLIC_SOLVER", value_name = "PATH_OR_NAME")] + pub symbolic_solver: Option, - let num_filtered = runner.matching_test_functions(filter).count(); - if num_filtered != 1 && (self.debug || self.flamegraph || self.flamechart) { - let action = if self.flamegraph { - "generate a flamegraph" - } else if self.flamechart { + /// Exact solver command used for symbolic tests. + #[arg(long, env = "FOUNDRY_SYMBOLIC_SOLVER_COMMAND", value_name = "COMMAND")] + pub symbolic_solver_command: Option, + + /// Comma-separated SMT solver names or commands to race in parallel for symbolic tests. + #[arg( + long, + env = "FOUNDRY_SYMBOLIC_SOLVER_PORTFOLIO", + value_delimiter = ',', + value_name = "SOLVER_OR_COMMAND,..." + )] + pub symbolic_solver_portfolio: Option>, + + /// SMT solver timeout in seconds; also bounds symbolic invariant exploration. + #[arg(long, env = "FOUNDRY_SYMBOLIC_TIMEOUT", value_name = "SECONDS")] + pub symbolic_timeout: Option, + + /// Halmos-compatible symbolic loop bound. + #[arg(long, env = "FOUNDRY_SYMBOLIC_LOOP", value_name = "N")] + pub symbolic_loop: Option, + + /// Halmos-compatible symbolic execution depth alias. + #[arg(long, env = "FOUNDRY_SYMBOLIC_DEPTH", value_name = "N")] + pub symbolic_depth: Option, + + /// Halmos-compatible symbolic path width alias. + #[arg(long, env = "FOUNDRY_SYMBOLIC_WIDTH", value_name = "N")] + pub symbolic_width: Option, + + /// Maximum number of opcodes executed along a symbolic path. + #[arg(long, env = "FOUNDRY_SYMBOLIC_MAX_DEPTH", value_name = "N")] + pub symbolic_max_depth: Option, + + /// Maximum number of symbolic paths to explore per test. + #[arg(long, env = "FOUNDRY_SYMBOLIC_MAX_PATHS", value_name = "N")] + pub symbolic_max_paths: Option, + + /// Maximum number of calls in a bounded symbolic invariant sequence. + #[arg(long, env = "FOUNDRY_SYMBOLIC_INVARIANT_DEPTH", value_name = "N")] + pub symbolic_invariant_depth: Option, + + /// Maximum number of solver queries per symbolic test. + #[arg(long, env = "FOUNDRY_SYMBOLIC_MAX_SOLVER_QUERIES", value_name = "N")] + pub symbolic_max_solver_queries: Option, + + /// Default bounded length for symbolic dynamic ABI inputs. + #[arg(long, env = "FOUNDRY_SYMBOLIC_DEFAULT_DYNAMIC_LENGTH", value_name = "N")] + pub symbolic_default_dynamic_length: Option, + + /// Maximum permitted bounded length for symbolic dynamic ABI inputs. + #[arg(long, env = "FOUNDRY_SYMBOLIC_MAX_DYNAMIC_LENGTH", value_name = "N")] + pub symbolic_max_dynamic_length: Option, + + /// Per-dynamic-input symbolic lengths, applied in ABI traversal order. + #[arg( + long, + env = "FOUNDRY_SYMBOLIC_ARRAY_LENGTHS", + value_delimiter = ',', + value_name = "N,..." + )] + pub symbolic_array_lengths: Option>, + + /// Maximum symbolic calldata size in bytes. + #[arg(long, env = "FOUNDRY_SYMBOLIC_MAX_CALLDATA_BYTES", value_name = "N")] + pub symbolic_max_calldata_bytes: Option, + + /// Expand symbolic external call targets over known deployed contracts. + #[arg(long, env = "FOUNDRY_SYMBOLIC_CALL_TARGETS")] + pub symbolic_call_targets: bool, + + /// Dump SMT-LIB queries issued by symbolic tests. + #[arg(long, env = "FOUNDRY_SYMBOLIC_DUMP_SMT")] + pub symbolic_dump_smt: bool, + + /// Symbolic storage modelling mode. + #[arg( + long, + env = "FOUNDRY_SYMBOLIC_STORAGE_LAYOUT", + value_name = "solidity|generic|zero_init", + value_parser = ["solidity", "generic", "zero_init"] + )] + pub symbolic_storage_layout: Option, + + /// Show test execution progress. + #[arg(long, conflicts_with_all = ["quiet", "json"], help_heading = "Display options")] + pub show_progress: bool, + + /// Re-run recorded test failures from last run. + /// If no failure recorded then regular test run is performed. + #[arg(long)] + pub rerun: bool, + + /// Print the given opcodes in trace output, with their gas + /// cost and the storage slot and value, if available. + /// + /// Accepts a comma-separated list of opcode names, e.g. + /// `--opcodes SLOAD,MLOAD,SSTORE`. Names are in uppercase. + /// Requires `-vvvvv` to render. + #[arg(long, value_parser = parse_opcode, value_delimiter(','), conflicts_with_all = ["json", "junit", "list", "debug"])] + pub opcodes: Vec, + + /// Print test summary table. + #[arg(long, help_heading = "Display options")] + pub summary: bool, + + /// Print detailed test summary table. + #[arg(long, help_heading = "Display options", requires = "summary")] + pub detailed: bool, + + /// Replay the persisted corpus and emit AFL-`afl-showmap`-style coverage + /// files at the given output directory. Disables the regular fuzz/invariant + /// campaign and skips unit tests. + #[arg( + long, + value_name = "DIR", + value_hint = ValueHint::DirPath, + help_heading = "Showmap replay", + conflicts_with_all = ["debug", "flamegraph", "flamechart", "evm_profile", "rerun", "fuzz_input_file", "gas_report"], + )] + pub showmap_out: Option, + + /// Emit one showmap file per corpus entry (default: one aggregated file per test). + #[arg(long, help_heading = "Showmap replay", requires = "showmap_out")] + pub showmap_per_input: bool, + + /// Coverage domain(s) to dump. + #[arg( + long, + value_enum, + default_value_t = ShowmapDomainArg::Evm, + help_heading = "Showmap replay", + requires = "showmap_out", + )] + pub showmap_domain: ShowmapDomainArg, + + /// Approach name (used as a subdirectory of `--showmap-out`). + #[arg( + long, + default_value = "replay", + help_heading = "Showmap replay", + requires = "showmap_out" + )] + pub showmap_approach: String, + + /// Trial identifier embedded in each showmap filename. Defaults to a unique + /// `trial-` so reruns don't overwrite previous trials. + #[arg(long, help_heading = "Showmap replay", requires = "showmap_out")] + pub showmap_trial: Option, + + /// Override the corpus directory to replay (defaults to the per-test + /// `corpus_dir` resolved from config). + #[arg( + long, + value_name = "PATH", + value_hint = ValueHint::DirPath, + help_heading = "Showmap replay", + requires = "showmap_out", + )] + pub showmap_corpus_dir: Option, + + /// Decode the storage layouts of contracts outside the local project in state diffs, by + /// compiling the verified source a block explorer has for them. + #[arg(long)] + pub decode_external_storage: bool, + + #[command(flatten)] + filter: FilterArgs, + + #[command(flatten)] + evm: EvmArgs, + + #[command(flatten)] + pub build: BuildOpts, + + #[command(flatten)] + pub watch: WatchArgs, + + /// Enable mutation testing. + /// If passed with file paths, only those files will be tested. + #[arg(long, num_args(0..), value_name = "PATH")] + pub mutate: Option>, + + /// Specify which files to mutate with glob pattern matching. + /// + /// Mutually exclusive with passing explicit paths to `--mutate`; either + /// supply paths to `--mutate` or use this glob filter, not both. + #[arg(long, value_name = "PATTERN", requires = "mutate", conflicts_with = "mutate_contract")] + pub mutate_path: Option, + + /// Only mutate contracts whose name matches the specified regex pattern. + /// + /// Mutually exclusive with `--mutate-path`. + #[arg(long, value_name = "REGEX", requires = "mutate")] + pub mutate_contract: Option, + + /// Number of parallel workers for mutation testing. + /// Defaults to the number of CPU cores. + #[arg(long, value_name = "JOBS", requires = "mutate")] + pub mutation_jobs: Option, + + /// Best-effort per-mutant wall-clock timeout in seconds. Mutants that + /// exceed it are recorded as "timed out" and cleanup continues in the + /// background with bounded pending workers. + /// + /// Analogous to `--invariant-timeout` for invariant campaigns. + #[arg(long, value_name = "TIMEOUT", requires = "mutate")] + pub mutation_timeout: Option, + + /// Override optimizer runs for mutation testing compile-and-test runs. + #[arg(long, value_name = "RUNS", requires = "mutate")] + pub mutation_optimizer_runs: Option, + + /// Override via-ir for mutation testing compile-and-test runs. + #[arg(long, default_missing_value = "true", num_args = 0..=1, requires = "mutate")] + pub mutation_via_ir: Option, + + /// Enable brutalization mode. + /// + /// Catches latent bugs that normal tests miss because the EVM initializes + /// memory to zero and registers to clean values. Applies source-level + /// sanitizers before compiling: + /// + /// - Dirties unused bits in sub-256-bit type casts (address, uint8, bytes4, etc.) to catch + /// assembly code that assumes clean upper bits when using legacy codegen. Via-IR may clean + /// these bits before inline assembly observes them. + /// - Fills scratch space (0x00-0x3f) and memory beyond the free memory pointer with junk to + /// catch uninitialized memory reads + /// - Misaligns the free memory pointer to catch word-alignment assumptions + /// + /// If `forge test` passes but `forge test --brutalize` fails, the code has + /// a robustness issue that could manifest when called in a different context. + // TODO: evaluate if we can relax the conflict with replay_symbolic_artifact + #[arg(long, conflicts_with_all = ["mutate", "replay_symbolic_artifact"])] + pub brutalize: bool, +} + +impl TestArgs { + pub async fn run(mut self) -> Result { + trace!(target: "forge::test", "executing test command"); + self.compile_and_run().await + } + + pub(crate) fn ensure_mutation_mode_compatible(&self, coverage: bool) -> Result<()> { + if self.mutate.is_none() { + return Ok(()); + } + // Mutation testing has bespoke orchestration that is not compatible with these modes. + // Run this before compiling when the caller owns the build step so project errors do not + // mask CLI conflicts. + let conflicts = enabled_flags([ + (self.list, "--list"), + (self.debug, "--debug"), + (self.flamegraph, "--flamegraph"), + (self.flamechart, "--flamechart"), + (self.evm_profile.is_some(), "--evm-profile"), + (self.junit, "--junit"), + (self.json_file.is_some(), "--json-file"), + (coverage, "coverage"), + (self.showmap_out.is_some(), "--showmap-out"), + (self.replay_symbolic_artifact.is_some(), "--replay-symbolic-artifact"), + ]); + if !conflicts.is_empty() { + bail!( + "`--mutate` cannot be combined with: {}. Re-run without those flags to use \ + mutation testing.", + conflicts.join(", ") + ); + } + Ok(()) + } + + pub(crate) fn ensure_coverage_mode_compatible(&self) -> Result<()> { + self.ensure_mutation_mode_compatible(true)?; + let conflicts = enabled_flags([ + (shell::is_json(), "--json"), + (self.junit, "--junit"), + (self.json_file.is_some(), "--json-file"), + (self.list, "--list"), + (self.debug, "--debug"), + (self.flamegraph, "--flamegraph"), + (self.flamechart, "--flamechart"), + (self.evm_profile.is_some(), "--evm-profile"), + (self.showmap_out.is_some(), "--showmap-out"), + (self.brutalize, "--brutalize"), + (self.replay_symbolic_artifact.is_some(), "--replay-symbolic-artifact"), + ]); + if !conflicts.is_empty() { + bail!( + "`forge coverage` cannot be combined with: {}. Use `--report lcov` for an \ + interoperable coverage report or `--report attribution` for per-test JSON \ + attribution.", + conflicts.join(", ") + ); + } + Ok(()) + } + + /// Builds a `ShowmapConfig` from the showmap CLI flags, if `--showmap-out` is set. + fn showmap_config(&self) -> Result> { + let showmap = match (&self.showmap_override, &self.showmap_out) { + (Some(showmap), _) => showmap.clone(), + (None, Some(out_dir)) => ShowmapConfig { + out_dir: out_dir.clone(), + approach: self.showmap_approach.clone(), + // Default trial id uses nanosecond precision so back-to-back invocations + // don't collide and overwrite each other's output files. + trial: self.showmap_trial.clone().unwrap_or_else(|| { + let ns = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + format!("trial-{ns}") + }), + per_input: self.showmap_per_input, + domain: self.showmap_domain.into(), + corpus_dir: self.showmap_corpus_dir.clone(), + emit_files: true, + }, + (None, None) => return Ok(None), + }; + validate_showmap_config(&showmap)?; + Ok(Some(showmap)) + } + + /// Restricts this test invocation to fuzz and invariant tests. + pub(crate) const fn enable_fuzz_only(&mut self) { + self.fuzz_only = true; + } + + /// Restricts this test invocation to fuzz and invariant tests and enables a default fuzz corpus + /// dir after user config is loaded. + pub(crate) const fn enable_fuzz_only_with_auto_fuzz_corpus(&mut self) { + self.fuzz_only = true; + self.auto_fuzz_corpus = true; + } + + fn apply_test_config_overrides(&self, config: &mut Config) { + if self.auto_fuzz_corpus && config.fuzz.corpus.corpus_dir.is_none() { + config.fuzz.corpus.corpus_dir = Some(match &config.fuzz.failure_persist_dir { + Some(root) => root.join(AUTO_CORPUS_DIR), + None => config.cache_path.join(AUTO_FUZZ_FAILURE_DIR).join(AUTO_CORPUS_DIR), + }); + } + if self.debug && !config.extra_output.contains(&ContractOutputSelection::StorageLayout) { + config.extra_output.push(ContractOutputSelection::StorageLayout); + } + } + + /// Disables gas report sampling unless a gas report is requested, in which case isolation is + /// enabled for more correct gas accounting. + const fn apply_gas_report_overrides(&self, config: &mut Config, evm_opts: &mut EvmOpts) { + if self.gas_report { + evm_opts.isolate = true; + } else { + config.fuzz.gas_report_samples = 0; + config.invariant.gas_report_samples = 0; + } + } + + /// Overrides showmap config for callers that reuse replay mode without the + /// `forge test --showmap-*` CLI flags. + pub(crate) fn set_showmap_override(&mut self, showmap: ShowmapConfig) { + self.showmap_override = Some(showmap); + } + + /// Sets replay-critical options for internal fuzz minimizer callers. + pub(crate) fn set_fuzz_minimize_replay_options( + &mut self, + global: GlobalArgs, + evm: EvmArgs, + build: BuildOpts, + filter: FilterArgs, + ) { + self.global = global; + self.evm = evm; + self.build = build; + self.filter = filter; + } + + /// Replays persisted fuzz failures without running a new fuzz campaign. + pub(crate) const fn enable_fuzz_failure_replay(&mut self) { + self.fuzz_failure_replay = true; + } + + fn warn_unsupported_engine_flags( + &self, + output: &ProjectCompileOutput, + config: &Config, + inline_config: &InlineConfig, + filter: &ProjectPathsAwareFilter, + multi_network: &MultiNetworkConfig, + ) -> Result<()> { + if !self.fuzz_only { + return Ok(()); + } + let matcher = TestFunctionMatcher::new(config, inline_config, None); + let (mut fuzz, mut invariant) = (0, 0); + for (id, _, abi) in matching_test_contracts(output, config, &matcher, filter) { + let (f, i) = matcher.count_fuzz_engine_targets(filter, &id, abi, multi_network); + fuzz += f; + invariant += i; + } + let unused: &[(bool, &str, &str)] = if fuzz == 0 && invariant > 0 { + &[ + ( + self.fuzz_frontier_dir.is_some() && self.invariant_frontier_dir.is_none(), + "--frontier-dir", + "fuzz", + ), + ( + self.fuzz_frontier_limit.is_some() && self.invariant_frontier_limit.is_none(), + "--frontier-limit", + "fuzz", + ), + (self.fuzz_run.is_some(), "--fuzz-run", "fuzz"), + ] + } else if invariant == 0 && fuzz > 0 { + &[ + (self.invariant_depth.is_some(), "--depth", "invariant"), + (self.invariant_min_depth.is_some(), "--min-depth", "invariant"), + (self.invariant_depth_mode.is_some(), "--depth-mode", "invariant"), + (self.invariant_workers.is_some(), "--workers", "invariant"), + ] + } else { + &[] + }; + for (set, flag, engine) in unused { + if *set { + sh_warn!( + "`{flag}` only applies to {engine} tests; no matched {engine} tests were found." + )?; + } + } + Ok(()) + } + + /// Builds the delegated `forge test` invocation for `forge fuzz run`. + pub(crate) fn from_fuzz_run(args: FuzzRunArgs) -> Self { + let campaign = args.campaign; + Self { + fuzz_only: true, + global: args.global, + path: args.path, + gas_report: args.gas_report, + allow_failure: args.allow_failure, + junit: args.junit, + fail_fast: args.fail_fast, + etherscan_api_key: args.etherscan_api_key, + list: args.list, + fuzz_input_file: args.fuzz_input_file, + show_progress: args.show_progress, + rerun: args.rerun, + showmap_out: args.showmap_out, + showmap_per_input: args.showmap_per_input, + showmap_domain: args.showmap_domain, + showmap_approach: args.showmap_approach, + showmap_trial: args.showmap_trial, + showmap_corpus_dir: args.showmap_corpus_dir, + filter: args.filter, + evm: args.evm, + build: args.build, + fuzz_seed: campaign.seed, + fuzz_runs: campaign.runs, + invariant_runs_override: campaign.runs, + fuzz_timeout: campaign.timeout.map(u64::from), + invariant_timeout_override: campaign.timeout, + fuzz_dictionary_weight: campaign.dictionary_weight, + invariant_dictionary_weight: campaign.dictionary_weight, + fuzz_dictionary_addresses: campaign.dictionary_addresses.clone(), + invariant_dictionary_addresses: campaign.dictionary_addresses, + fuzz_dictionary_values: campaign.dictionary_values.clone(), + invariant_dictionary_values: campaign.dictionary_values, + fuzz_dictionary_literals: campaign.dictionary_literals.clone(), + invariant_dictionary_literals: campaign.dictionary_literals, + fuzz_corpus_random_sequence_weight: campaign.corpus_random_sequence_weight, + invariant_corpus_random_sequence_weight: campaign.corpus_random_sequence_weight, + fuzz_corpus_dir: campaign.corpus_dir.clone(), + invariant_corpus_dir: campaign.corpus_dir, + fuzz_payable_value_weight: campaign.payable_value_weight, + invariant_payable_value_weight: campaign.payable_value_weight, + fuzz_mutation_weight_splice: campaign.mutation_weight_splice, + invariant_mutation_weight_splice: campaign.mutation_weight_splice, + fuzz_mutation_weight_repeat: campaign.mutation_weight_repeat, + invariant_mutation_weight_repeat: campaign.mutation_weight_repeat, + fuzz_mutation_weight_interleave: campaign.mutation_weight_interleave, + invariant_mutation_weight_interleave: campaign.mutation_weight_interleave, + fuzz_mutation_weight_prefix: campaign.mutation_weight_prefix, + invariant_mutation_weight_prefix: campaign.mutation_weight_prefix, + fuzz_mutation_weight_suffix: campaign.mutation_weight_suffix, + invariant_mutation_weight_suffix: campaign.mutation_weight_suffix, + fuzz_mutation_weight_abi: campaign.mutation_weight_abi, + invariant_mutation_weight_abi: campaign.mutation_weight_abi, + fuzz_mutation_weight_cmp: campaign.mutation_weight_cmp, + invariant_mutation_weight_cmp: campaign.mutation_weight_cmp, + fuzz_frontier_dir: campaign.frontier_dir.clone(), + invariant_frontier_dir: campaign.frontier_dir, + fuzz_frontier_limit: campaign.frontier_limit, + invariant_frontier_limit: campaign.frontier_limit, + invariant_depth: campaign.depth, + invariant_min_depth: campaign.min_depth, + invariant_depth_mode: campaign.depth_mode, + invariant_workers: campaign.workers, + ..Self::default() + } + } + + fn load_symbolic_artifact_replay(&self) -> Result> { + let Some(path) = &self.replay_symbolic_artifact else { + return Ok(None); + }; + if !self.filter.is_empty() || self.path.is_some() { + bail!( + "symbolic artifact mode cannot be combined with test selection filters; \ + the artifact selects its original target" + ); + } + + let display = path.display(); + let value = fs::read_json_file::(path) + .wrap_err(format!("failed to read symbolic counterexample artifact {display}"))?; + let schema_version = + value.get("schema_version").and_then(serde_json::Value::as_u64).ok_or_else(|| { + eyre::eyre!( + "symbolic counterexample artifact {display} is missing numeric schema_version" + ) + })?; + if schema_version != 1 { + bail!( + "unsupported symbolic counterexample artifact schema version {schema_version} in {display}" + ); + } + let schema = value.get("schema").and_then(serde_json::Value::as_str).ok_or_else(|| { + eyre::eyre!("symbolic counterexample artifact {display} is missing string schema") + })?; + if schema != SYMBOLIC_COUNTEREXAMPLE_ARTIFACT_SCHEMA { + bail!("unsupported symbolic counterexample artifact schema `{schema}` in {display}"); + } + let artifact = serde_json::from_value::(value) + .wrap_err(format!("failed to parse symbolic counterexample artifact {display}"))?; + if artifact.calls.is_empty() { + bail!("symbolic counterexample artifact {display} has no calls"); + } + if artifact.replay.status != SymbolicReplayStatus::Confirmed { + bail!( + "symbolic counterexample artifact {display} replay status must be confirmed, got {:?}", + artifact.replay.status, + ); + } + let contract = &artifact.test.contract; + if !matches!(contract.rsplit_once(':'), Some((p, n)) if !p.is_empty() && !n.is_empty()) { + bail!( + "symbolic counterexample artifact {display} test.contract must be `path:Contract`, got `{contract}`" + ); + } + Ok(Some(SymbolicArtifactReplayConfig { artifact, path: path.clone() })) + } + + fn load_fuzz_input( + &self, + output: &ProjectCompileOutput, + config: &Config, + inline_config: &InlineConfig, + filter: &ProjectPathsAwareFilter, + ) -> Result> { + let Some(path) = &self.fuzz_input_file else { + return Ok(None); + }; + let failure = fs::read_json_file::(path)?; + let Some(selector) = failure.calldata.get(..4) else { + bail!( + "fuzz input file {} contains calldata shorter than a 4-byte selector", + path.display() + ); + }; + let targets = + matching_fuzz_replay_targets(output, config, inline_config, filter, selector)?; + let [(contract, test)] = targets.as_slice() else { + if targets.is_empty() { + bail!( + "fuzz input file {} does not match any selected stateless fuzz test", + path.display() + ); + } + bail!( + "fuzz input file {} matches {} selected stateless fuzz tests; replay requires exactly one target", + path.display(), + targets.len() + ); + }; + Ok(Some(FuzzFailureReplayConfig { + failure: Arc::new(failure), + contract: contract.clone(), + test: test.clone(), + })) + } + + /// Discovers test ABIs before contract and function filters select compilation roots. + fn compile_test_abis( + &self, + config: &Config, + test_filter: &ProjectPathsAwareFilter, + ) -> Result<(BTreeSet, ProjectCompileOutput)> { + let mut project = config.create_project(config.cache, true)?; + let sources = source_files_iter(&config.src, MultiCompilerLanguage::FILE_EXTENSIONS) + .chain( + // Preserve path-filter behavior for conventional test files while still + // scanning non-test fixtures under the test root. + source_files_iter(&config.test, MultiCompilerLanguage::FILE_EXTENSIONS) + .filter(|path| !path.is_sol_test() || test_filter.matches_path(path)), + ) + .collect::>(); + // ABI discovery does not need dynamic linking, whose job-specific cache prevents + // incremental ABI compilation after edits. + let output = compile_abi_project_cached( + &mut project, + ProjectCompiler::new() + .external_compilers(config) + .files(sources.iter().cloned()) + .source_order_fallback( + source_files_iter(&config.src, MultiCompilerLanguage::FILE_EXTENSIONS).chain( + source_files_iter(&config.test, MultiCompilerLanguage::FILE_EXTENSIONS), + ), + ) + .quiet(true), + )?; + if output.has_compiler_errors() { + sh_println!("{output}")?; + bail!("Compilation failed"); + } + + Ok((sources, output)) + } + + /// Returns a list of files that need to be compiled in order to run all the tests that match + /// the given filter, and the inline config parsed from the ABI-only compilation when one was + /// needed. + /// + /// For filtered runs, this includes all configured source roots, non-test fixture roots, and + /// runnable tests that match the filter. Imported dependencies remain attached to those roots + /// so compiler profiles and restrictions apply to the complete source graph. + #[instrument(target = "forge::test", skip_all)] + fn get_sources_to_compile( + &self, + config: &Config, + test_filter: &ProjectPathsAwareFilter, + symbolic_artifact_replay: Option<&SymbolicArtifactReplayConfig>, + ) -> Result<(BTreeSet, Option>)> { + let src_files = || source_files_iter(&config.src, MultiCompilerLanguage::FILE_EXTENSIONS); + let test_files = || source_files_iter(&config.test, MultiCompilerLanguage::FILE_EXTENSIONS); + + // An empty filter doesn't filter out anything. + // We can still optimize slightly by excluding scripts. + if test_filter.is_empty() { + return Ok((src_files().chain(test_files()).collect(), None)); + } + + let (sources, output) = self.compile_test_abis(config, test_filter)?; + + let inline_config = Arc::new(InlineConfig::new_parsed(&output, config)?); + let test_matcher = + TestFunctionMatcher::new(config, &inline_config, symbolic_artifact_replay); + let paths = config.project_paths::(); + let empty_filter = EmptyTestFilter::default(); + let filter_args = test_filter.args(); + let has_contract_or_test_filter = filter_args.test_pattern.is_some() + || filter_args.test_pattern_inverse.is_some() + || filter_args.contract_pattern.is_some() + || filter_args.contract_pattern_inverse.is_some(); + + // `MultiContractRunner::build` strips the root prefix from artifact source paths so the + // identifiers it constructs are project-relative. Match that here for the filter check + // (notably for the `--rerun` failure list, which is persisted relative) but return the + // original absolute source paths so downstream compilation can locate them. + let files = output + .artifact_ids() + .filter_map(|(id, artifact)| artifact.abi.as_ref().map(|abi| (id, abi))) + // Imported dependencies must remain attached to their roots so compilation restrictions + // apply to the entire source graph instead of compiling dependencies with default + // settings. + .filter(|(id, _)| sources.contains(&id.source)) + .filter(|(id, abi)| { + if id.source.starts_with(&paths.sources) { + return true; + } + if paths.is_script(&id.source) && !paths.is_test(&id.source) { + return false; + } + let stripped = id.clone().with_stripped_file_prefixes(&config.root); + // ABI-only compilation can omit test functions with invalid bodies, so preserve the + // existing filter behavior for conventional test files instead of treating them as + // fixtures. + if stripped.source.is_sol_test() { + return if has_contract_or_test_filter { + test_matcher.matches_contract(test_filter, &stripped, abi) + } else { + test_filter.matches_path(&stripped.source) + }; + } + !test_matcher.matches_contract(&empty_filter, &stripped, abi) + || test_matcher.matches_contract(test_filter, &stripped, abi) + }) + .map(|(id, _)| id.source) + .collect(); + Ok((files, Some(inline_config))) + } + + /// Executes all the tests in the project. + /// + /// This will trigger the build process first. On success all test contracts that match the + /// configured filter will be executed + /// + /// Returns the test results for all matching tests. + pub async fn compile_and_run(&mut self) -> Result { + self.ensure_mutation_mode_compatible(false)?; + let compiled = self.compile_project().await?; + self.run_tests( + &compiled.project_root, + compiled.config, + compiled.evm_opts, + &compiled.output, + &compiled.filter, + TestExecutionOptions { + replay_symbolic_artifact: compiled.replay_symbolic_artifact, + selected_sources: compiled.selected_sources, + ..TestExecutionOptions::default_run(compiled.inline_config) + }, + ) + .await + } + + /// Copies the project into a temporary workspace, brutalizes its sources and rebases `config` + /// onto that workspace. + fn brutalize_workspace(&self, config: &mut Config) -> Result { + let silent = shell::is_json(); + let temp_dir = TempDir::with_prefix("forge_brutalize_")?; + let temp_path = temp_dir.path(); + + if config.via_ir && !silent { + sh_warn!( + "--brutalize value cast dirty-bits checks are ineffective with via-IR; memory and free-memory-pointer checks still apply" + )?; + } + if !silent { + sh_status!("Brutalizing source files...")?; + } + workspace::copy_project(config, temp_path)?; + let count = brutalizer::brutalize_project(config, temp_path)?; + if !silent { + sh_status!("Brutalized {count} source files, compiling from temp workspace...")?; + } + + let test_failures_file = config.test_failures_file.clone(); + *config = workspace::rebase_config_paths(config, temp_path).sanitized(); + config.test_failures_file = test_failures_file; + Ok(temp_dir) + } + + async fn compile_project(&mut self) -> Result { + // Merge all configs. + let (mut config, evm_opts) = self.load_config_and_evm_opts()?; + + self.install_missing_dependencies(&mut config)?; + let brutalized_workspace = + if self.brutalize { Some(self.brutalize_workspace(&mut config)?) } else { None }; + let should_mutate = self.mutate.is_some(); + if should_mutate { + // Force dyn test linking and cache usage for mutation testing after any config reload. + config.dynamic_test_linking = true; + config.cache = true; + apply_mutation_compiler_overrides(&mut config); + } + self.apply_test_config_overrides(&mut config); + + // Set up the project. + let project = config.project()?; + let project_root = project.paths.root.clone(); + + let replay_symbolic_artifact = self.load_symbolic_artifact_replay()?; + let mut filter = self.filter(&config)?; + if let Some(replay) = &replay_symbolic_artifact { + let filter_args = filter.args_mut(); + filter_args.test_pattern_inverse = None; + filter_args.contract_pattern_inverse = None; + filter_args.path_pattern_inverse = None; + let contract = replay.artifact.test.contract.as_str(); + let (path, contract) = contract.rsplit_once(':').unwrap_or(("", contract)); + filter_args.test_pattern = + Some(Regex::new(&format!("^{}$", regex::escape(&replay.artifact.test.test)))?); + filter_args.contract_pattern = + Some(Regex::new(&format!("^{}$", regex::escape(contract)))?); + if !path.is_empty() { + filter_args.path_pattern = Some(globset::escape(path).parse::()?); + } + } + trace!(target: "forge::test", ?filter, "using filter"); + + let compiler = ProjectCompiler::new() + .external_compilers(&config) + .dynamic_test_linking(config.dynamic_test_linking) + .quiet(shell::is_json() || self.junit); + let (output, selected_sources, inline_config) = if self.list { + let compiler = compiler.external_artifacts(false); + // Only the ABI is needed to list tests, so skip the full compile when possible. + let compiler = if filter.args().path_pattern.is_some() + && config.extra_output.is_empty() + && config.extra_output_files.is_empty() + && !config.build_info + { + let files = project + .paths + .input_files_iter() + .filter(|path| filter.matches_path(path)) + .collect::>(); + if files.is_empty() { compiler } else { compiler.files(files) } + } else { + compiler + }; + // Listing must not replace full artifacts with ABI-only ones, which later cached + // builds would treat as fresh. + let mut project = config.create_project(config.cache, true)?; + (compile_abi_project_cached(&mut project, compiler)?, BTreeSet::new(), None) + } else { + let (files, inline_config) = + self.get_sources_to_compile(&config, &filter, replay_symbolic_artifact.as_ref())?; + let compiler = if filter.is_empty() { + compiler + } else { + compiler.source_order_fallback( + source_files_iter(&config.src, MultiCompilerLanguage::FILE_EXTENSIONS).chain( + source_files_iter(&config.test, MultiCompilerLanguage::FILE_EXTENSIONS), + ), + ) + }; + let output = compiler.files(files.clone()).compile(&project); + let output = if should_mutate { + output.wrap_err( + "Mutation testing compiler profile failed to compile before applying mutations", + )? + } else { + output? + }; + (output, files, inline_config) + }; + let inline_config = match inline_config { + Some(inline_config) => inline_config, + None => Arc::new(InlineConfig::new_parsed(&output, &config)?), + }; + + Ok(CompiledTestProject { + project_root, + config, + evm_opts, + output, + filter, + inline_config, + replay_symbolic_artifact, + selected_sources, + _brutalized_workspace: brutalized_workspace, + }) + } + + pub(crate) async fn prepare_fuzz_minimize_replay( + &mut self, + corpus_dir: &Path, + ) -> Result { + let CompiledTestProject { mut config, mut evm_opts, output, filter, inline_config, .. } = + self.compile_project().await?; + + if config.fuzz.run == Some(0) { + bail!("`fuzz.run` must be greater than 0"); + } + self.apply_gas_report_overrides(&mut config, &mut evm_opts); + for corpus in [&mut config.fuzz.corpus, &mut config.invariant.corpus] { + corpus.corpus_dir.get_or_insert_with(|| corpus_dir.to_path_buf()); + } + config.fuzz.seed = config.fuzz.seed.or(Some(U256::ZERO)); + + evm_opts.infer_network_from_fork().await?; + config.networks = evm_opts.networks; + + let override_networks = inline_config.referenced_override_networks(&config.profile); + let (default_pass, override_passes) = network_passes(config, evm_opts, &override_networks); + let mut passes = Vec::new(); + for NetworkPass { config, evm_opts, multi_network } in + std::iter::once(default_pass).chain(override_passes) + { + let execution = TestExecutionOptions { + multi_network, + ..TestExecutionOptions::default_run(inline_config.clone()) + }; + let config = Arc::new(config); + passes.push(dispatch_network!(&evm_opts, |Net| { + let backend = Backend::::spawn(evm_opts.get_fork( + &config, + evm_opts.env.chain_id.unwrap_or_default(), + None, + ))?; + let runner = self + .build_runner::( + config, + evm_opts, + &output, + execution, + backend, + ExecutorBuilder::::new(), + ) + .await?; + fuzz_minimize_pass(runner, &filter) + })); + } + + if passes.iter().all(|pass| pass.target_count == 0) { + bail!("fuzz minimization requires at least one matched fuzz or invariant test"); + } + Ok(FuzzMinimizeReplaySession { filter, passes }) + } + + /// Executes all the tests in the project. + /// + /// See [`Self::compile_and_run`] for more details. + pub(crate) async fn run_tests( + &mut self, + project_root: &Path, + mut config: Config, + mut evm_opts: EvmOpts, + output: &ProjectCompileOutput, + filter: &ProjectPathsAwareFilter, + mut execution: TestExecutionOptions, + ) -> Result { + self.ensure_mutation_mode_compatible(execution.coverage)?; + + if config.fuzz.run == Some(0) { + bail!("`fuzz.run` must be greater than 0"); + } + + if self.list { + return list_from_output( + output, + &config, + &execution.inline_config, + filter, + self.fuzz_only, + execution.replay_symbolic_artifact.as_ref(), + ); + } + + execution.fuzz_input = + self.load_fuzz_input(output, &config, &execution.inline_config, filter)?; + self.warn_unsupported_engine_flags( + output, + &config, + &execution.inline_config, + filter, + &execution.multi_network, + )?; + + let mut filter = filter.clone(); + self.apply_gas_report_overrides(&mut config, &mut evm_opts); + + // Generate a random fuzz seed if none provided, for reproducibility. + config.fuzz.seed = config + .fuzz + .seed + .or_else(|| Some(U256::from_be_bytes(rand::rng().random::<[u8; 32]>()))); + + let trace_output = if self.flamegraph { + Some(TraceOutputKind::Flamegraph) + } else if self.flamechart { + Some(TraceOutputKind::Flamechart) + } else { + self.evm_profile.map(TraceOutputKind::EvmProfile) + }; + + // Determine executor verbosity. + if evm_opts.verbosity < 3 && (self.gas_report || trace_output.is_some()) { + evm_opts.verbosity = 3; + } + + // Enable internal tracing for more informative flamegraph/profile. Simple tracing is + // upgraded to full tracing in `run_tests_inner` when exactly one test matches. + config.tracing = self.tracing.resolve(&config.tracing, evm_opts.verbosity); + let json_trace_depth = config.tracing.trace_depth; + execution.decode_internal = if config.tracing.decode_internal || trace_output.is_some() { + InternalTraceMode::Simple + } else { + InternalTraceMode::None + }; + + // Auto-detect network from fork chain ID when not explicitly configured. + evm_opts.infer_network_from_fork().await?; + // Inline configuration starts from this base config. Materialize the inferred execution + // network so unrelated inline overrides cannot erase the fork's EVM family. + config.networks = evm_opts.networks; + let verbosity = evm_opts.verbosity; + + // Box each network's run so the dispatch arms' locals stay off this frame. + dispatch_network!(&evm_opts, |Net| { + Box::pin(async { + let backend = Backend::::spawn(evm_opts.get_fork( + &config, + evm_opts.env.chain_id.unwrap_or_default(), + None, + ))?; + // Retain the baseline inputs for the mutation campaign. + let config_for_mutation = config.clone(); + let evm_opts_for_mutation = evm_opts.clone(); + + // Run each distinct per-test network annotation as a separate pass and merge + // results. + let override_networks = + execution.inline_config.referenced_override_networks(&config.profile); + let is_multi_pass = !override_networks.is_empty(); + let multi_pass_timer = Instant::now(); + let (default_pass, override_passes) = + network_passes(config, evm_opts, &override_networks); + let default_config = Arc::new(default_pass.config); + let default_verbosity = default_pass.evm_opts.verbosity; + let runner = self + .build_runner::( + default_config.clone(), + default_pass.evm_opts, + output, + TestExecutionOptions { + multi_network: default_pass.multi_network, + ..execution.clone() + }, + backend.clone(), + ExecutorBuilder::::new(), + ) + .await?; + let libraries = runner.libraries.clone(); + let mut outcome = self + .run_tests_inner(runner, default_config, default_verbosity, &mut filter, output) + .await?; + for pass in override_passes { + let (_, pass_outcome) = + self.run_network_pass(pass, output, &mut filter, execution.clone()).await?; + merge_outcomes(&mut outcome, pass_outcome); + } + if is_multi_pass { + // Per-pass summaries are suppressed in `run_tests_inner`. + self.print_summary(&outcome, multi_pass_timer.elapsed())?; + } + + // Record failures once after merging all network passes, including successful runs. + persist_run_failures(&config_for_mutation, &outcome); + + if let Some(replay) = &execution.replay_symbolic_artifact { + let target = &replay.artifact.test; + match outcome.tests().count() { + 0 => bail!( + "symbolic artifact target `{}::{}` was not found", + target.contract, + target.test + ), + 1 => {} + replayed => bail!( + "symbolic artifact target `{}::{}` matched {replayed} tests; replay requires exactly one target", + target.contract, + target.test + ), + } + } + + if let Some(path) = &self.json_file { + let mut results = + outcome.json_file_results.take().unwrap_or_else(|| outcome.results.clone()); + prepare_results_for_json(&mut results, verbosity, json_trace_depth); + fs::write_json_file(path, &results)?; + } + + if let Some(trace_output) = trace_output { + self.render_trace_output(trace_output, &mut outcome).await?; + } + + if self.debug { + // Get first non-empty suite result. We will have only one such entry. + let (_, _, test_result) = + outcome.remove_first().ok_or_eyre("no tests were executed")?; + let sources = ContractSources::from_project_output( + output, + project_root, + Some(&libraries), + )?; + + // Prefer execution traces for normal debug runs, but when execution never + // starts (for example if `setUp()` reverts), fall back to available + // setup/deployment traces. + let mut traces = test_result + .traces + .iter() + .filter(|(kind, _)| kind.is_execution()) + .cloned() + .collect::>(); + if traces.is_empty() { + traces = test_result.traces.clone(); + } + if let Some(decoder) = &outcome.last_run_decoder { + for (_, arena) in &mut traces { + decode_trace_arena(arena, decoder).await; + } + } + + let mut builder = Debugger::builder() + .traces(traces) + .sources(sources) + .breakpoints(test_result.breakpoints) + .layout(self.debug_layout.unwrap_or_default()); + if let Some(decoder) = &outcome.last_run_decoder { + builder = builder.decoder(decoder); + } + if let Some(known_contracts) = &outcome.known_contracts { + builder = builder.known_contracts(known_contracts); + } + let mut debugger = builder.build(); + if let Some(dump_path) = &self.dump { + debugger.dump_to_file(dump_path)?; + } else { + debugger.try_run_tui()?; + } + } + + // All tests have been run once before reaching this point + if let Some(mutate) = &self.mutate { + if outcome.failed() > 0 { + bail!( + "Mutation testing compiler profile failed its unmutated baseline run; \ + adjust `--mutation-via-ir` / `--mutation-optimizer-runs` or fix the tests \ + before running mutation testing" + ); + } + // A green baseline that ran zero non-skipped tests would report every + // compileable mutant as `Alive`, so hard-error instead of producing a + // misleading report. + if outcome.successes().next().is_none() { + bail!( + "Mutation testing requires at least one passing baseline test; the \ + current filter/path selection matched zero non-skipped tests. Loosen \ + `--match-test` / `--match-contract` / `--match-path` or check the \ + project layout." + ); + } + // Clap can't express this conflict because `--mutate` takes an optional list of + // paths. + if !mutate.is_empty() && self.mutate_path.is_some() { + bail!( + "`--mutate-path ` cannot be combined with explicit paths passed to `--mutate`; pass either paths or a glob pattern, not both" + ); + } + // The mutation runner builds a single-pass `MultiContractRunner` and does not + // honor inline per-test network annotations, which would silently run tests on + // the wrong network and produce false survivors / kills. + if is_multi_pass { + bail!( + "Mutation testing does not yet support inline per-test network \ + overrides (found {} annotated network(s)). Re-run without `--mutate` \ + or remove the per-test network annotations.", + override_networks.len() + ); + } + ensure_mutation_workspace_safe(&config_for_mutation)?; + + let json_output = shell::is_json(); + let selected_sources_relative = execution + .selected_sources + .iter() + .filter_map(|path| { + path.strip_prefix(&config_for_mutation.root).ok().map(PathBuf::from) + }) + .collect::>(); + let mutation_config = MutationRunConfig { + mutate_paths: mutate.clone(), + mutate_path_pattern: self.mutate_path.clone(), + mutate_contract_pattern: self.mutate_contract.clone(), + num_workers: self.mutation_jobs.unwrap_or(0), + show_progress: self.show_progress, + json_output, + // Carry the filter the baseline actually used (positional path shorthand + // folded into `path_pattern`, `--rerun` failures injected into + // `test_pattern`) and its isolation flag so every mutant exercises the + // exact same test set. + filter_args: filter.args().clone(), + rerun_failures: filter.rerun_failures().map(<[RerunFailure]>::to_vec), + selected_sources_relative, + isolate: evm_opts_for_mutation.isolate, + }; + let result = run_mutation_testing( + Arc::new(config_for_mutation), + output, + evm_opts_for_mutation, + backend, + ExecutorBuilder::::new(), + mutation_config, + ) + .await?; + if result.cancelled { + std::process::exit(130); + } + if json_output { + let json_output = result.summary.to_json_output(result.duration_secs); + sh_println!("{}", serde_json::to_string(&json_output)?)?; + } + outcome = TestOutcome::empty(None, true); + } + + Ok::<_, eyre::Report>(outcome) + }) + .await + }) + } + + /// Renders the flamegraph, flamechart or EVM profile of the single executed test. + async fn render_trace_output( + &self, + trace_output: TraceOutputKind, + outcome: &mut TestOutcome, + ) -> Result<()> { + let label = trace_output.label(); + let no_tests = match trace_output { + TraceOutputKind::EvmProfile(_) => "cannot generate EVM profile: no tests were executed", + TraceOutputKind::Flamegraph | TraceOutputKind::Flamechart => "no tests were executed", + }; + if outcome.tests().next().is_none() { + bail!("{no_tests}"); + } + let decoder = outcome + .last_run_decoder + .clone() + .ok_or_else(|| eyre::eyre!("cannot generate {label}: missing trace decoder"))?; + let (suite_name, test_name, test_result) = + outcome + .results + .iter_mut() + .find_map(|(suite_name, suite)| { + suite.test_results.iter_mut().next().map(|(test_name, result)| { + (suite_name.as_str(), test_name.as_str(), result) + }) + }) + .ok_or_else(|| eyre::eyre!("{no_tests}"))?; + let contract = suite_name.split(':').next_back().unwrap(); + let test_name = test_name.trim_end_matches("()"); + let (_, arena) = + test_result.traces.iter_mut().find(|(kind, _)| kind.is_execution()).ok_or_else( + || { + eyre::eyre!( + "cannot generate {label} for {contract}::{test_name}: no execution trace \ + (test may have failed in setUp/constructor or been skipped)" + ) + }, + )?; + decode_trace_arena(arena, &decoder).await; + + match trace_output { + TraceOutputKind::Flamegraph | TraceOutputKind::Flamechart => { + let mut folded_stack_trace = folded_stack_trace::build(arena, self.evm.isolate); + let flame_chart = trace_output == TraceOutputKind::Flamechart; + if flame_chart { + folded_stack_trace.reverse(); + } + let file_name = format!("cache/{label}_{contract}_{test_name}.svg"); + let file = std::fs::File::create(&file_name).wrap_err("failed to create file")?; + let mut options = inferno::flamegraph::Options::default(); + options.title = format!("{label} {contract}::{test_name}"); + options.count_name = "gas".to_string(); + options.flame_chart = flame_chart; + inferno::flamegraph::from_lines( + &mut options, + folded_stack_trace.iter().map(String::as_str), + std::io::BufWriter::new(file), + ) + .wrap_err("failed to write svg")?; + sh_println!("Saved to {file_name}")?; + if !self.no_open + && let Err(e) = opener::open(&file_name) + { + sh_err!("Failed to open {file_name}; please open it manually: {e}")?; + } + } + TraceOutputKind::EvmProfile(EvmProfileFormat::Speedscope) => { + let profile = + speedscope::builder::build(arena, test_name, contract, self.evm.isolate); + let profile_json = serde_json::to_vec(&profile)?; + let profile_path = format!("cache/evm_profile_{contract}_{test_name}.json"); + fs::write(&profile_path, &profile_json)?; + sh_println!("Profile saved to {profile_path}")?; + if !self.no_open { + evm_profile_server::serve_and_open(profile_json, test_name, contract).await?; + } + } + } + Ok(()) + } + + /// Builds the test runner for the network selected by `evm_opts`. + async fn build_runner( + &self, + config: Arc, + evm_opts: EvmOpts, + output: &ProjectCompileOutput, + execution: TestExecutionOptions, + backend: Backend, + executor_builder: ExecutorBuilder, + ) -> Result> { + let (evm_env, tx_env) = backend.env(&evm_opts).await?; + let create2_deployer_available = + backend.can_use_create2_deployer(evm_opts.create2_deployer).await?; + + MultiContractRunnerBuilder::new(config.clone(), execution.inline_config) + .set_debug(self.debug) + .set_decode_internal(execution.decode_internal) + .set_record_all_steps(self.evm_profile.is_some()) + .initial_balance(evm_opts.initial_balance) + .sender(evm_opts.sender) + .enable_isolation(evm_opts.isolate) + .fail_fast(self.fail_fast) + .set_coverage(execution.coverage) + .with_multi_network(execution.multi_network) + .with_showmap(self.showmap_config()?) + .with_fuzz_only(self.fuzz_only) + .with_fuzz_failure_replay(self.fuzz_failure_replay) + .with_fuzz_input(execution.fuzz_input) + .with_symbolic_artifact_replay(execution.replay_symbolic_artifact) + .with_create2_deployer_available(create2_deployer_available) + .build::( + output, + evm_env, + tx_env, + evm_opts, + backend, + executor_builder, + ) + } + + /// Builds the runner for one network pass and runs its tests. + async fn run_network_pass( + &self, + pass: NetworkPass, + output: &ProjectCompileOutput, + filter: &mut ProjectPathsAwareFilter, + execution: TestExecutionOptions, + ) -> Result<(Libraries, TestOutcome)> { + let NetworkPass { config, evm_opts, multi_network } = pass; + let execution = TestExecutionOptions { multi_network, ..execution }; + let verbosity = evm_opts.verbosity; + let config = Arc::new(config); + dispatch_network!(&evm_opts, |Net| { + let backend = Backend::::spawn(evm_opts.get_fork( + &config, + evm_opts.env.chain_id.unwrap_or_default(), + None, + ))?; + let runner = self + .build_runner::( + config.clone(), + evm_opts, + output, + execution, + backend, + ExecutorBuilder::::new(), + ) + .await?; + let libraries = runner.libraries.clone(); + let outcome = self.run_tests_inner(runner, config, verbosity, filter, output).await?; + Ok((libraries, outcome)) + }) + } + + /// Emits symbolic regression tests for the counterexample artifacts in `results` when + /// `--emit-regression` is set, and attaches them to the results. + fn emit_symbolic_regressions( + &self, + config: &Config, + known_contracts: &ContractsByArtifact, + results: &mut BTreeMap, + ) -> Result> { + if !self.emit_regression { + return Ok(Vec::new()); + } + let regression = SymbolicRegressionConfig { + out: self + .regression_out + .clone() + .map(|path| if path.is_relative() { config.root.join(path) } else { path }), + overwrite: self.regression_overwrite, + }; + let artifacts = collect_symbolic_artifacts_from_suites(results.values()); + let regressions = + emit_symbolic_regressions(config, ®ression, known_contracts, &artifacts)?; + attach_symbolic_regressions_to_suites(results.values_mut(), ®ressions); + Ok(regressions) + } + + /// Prints the run summary, or the detailed summary table when `--summary` is set. + fn print_summary(&self, outcome: &TestOutcome, duration: Duration) -> Result<()> { + if !self.summary && !shell::is_json() { + sh_println!("{}", outcome.summary(duration))?; + } + if self.summary && !outcome.results.is_empty() { + sh_println!("{}", TestSummaryReport::new(self.detailed, outcome))?; + } + Ok(()) + } + + /// Run all tests that matches the filter predicate from a test runner + async fn run_tests_inner( + &self, + mut runner: MultiContractRunner, + config: Arc, + verbosity: u8, + filter: &mut ProjectPathsAwareFilter, + output: &ProjectCompileOutput, + ) -> Result { + let fuzz_seed = config.fuzz.seed; + + trace!(target: "forge::test", "running all tests"); + + // If we need to render to a serialized format, we should not print anything else to stdout. + let silent = shell::is_json() && (self.gas_report || self.summary || self.mutate.is_some()); + let tracing = &config.tracing; + let trace_verbosity = tracing.verbosity; + + let mut num_filtered = runner.matching_test_functions(filter).count(); + + if !self.opcodes.is_empty() && trace_verbosity < 5 { + sh_eprintln!()?; + bail!("Not enough verbosity. Use -vvvvv to show opcodes."); + } + + if num_filtered == 0 { + let empty_filter = EmptyTestFilter::default(); + let mut has_tests = runner.matching_test_functions(&empty_filter).next().is_some(); + // Selective compilation omits unmatched contracts. Consult ABI discovery only + // when diagnostics need the tests that the runner cannot see. + let discovery = if filter.is_empty() { + None + } else { + Some(self.compile_test_abis(&config, filter)?.1) + }; + let mut candidates = + runner.all_test_functions(filter).map(|f| &f.name).collect::>(); + if let Some(output) = &discovery { + let matcher = runner.test_function_matcher(); + for (id, artifact) in output.artifact_ids() { + let id = id.with_stripped_file_prefixes(&config.root); + if let Some(abi) = &artifact.abi + && matcher.matches_contract(&empty_filter, &id, abi) + && is_runnable_test_contract(output, &config, &id, artifact, abi) + { + has_tests = true; + if filter.matches_path(&id.source) && filter.matches_contract(&id.name) { + candidates.extend( + matcher + .test_functions(id.identifier(), abi, |_, _, kind| { + kind.is_any_test() + }) + .map(|function| &function.name), + ); + } + } + } + } + if has_tests { + let mut msg = format!("no tests match the provided pattern:\n{filter}"); + // Try to suggest a test when there's no match. + if let Some(test_pattern) = &filter.args().test_pattern + && let Some(suggestion) = + utils::did_you_mean(test_pattern.as_str(), candidates).pop() + { + write!(msg, "\nDid you mean `{suggestion}`?")?; + } + sh_warn!("{msg}")?; + } else { + sh_warn!( + "No tests found in project! Forge looks for functions that start with `test`" + )?; + } + // Machine-readable modes still get a well-formed, empty document on stdout. + if self.junit { + sh_println!("{}", junit_xml_report(&BTreeMap::new(), verbosity).to_string()?)?; + } else if self.mutate.is_none() && !self.gas_report && !self.summary && shell::is_json() + { + sh_println!("{}", serde_json::to_string(&BTreeMap::::new())?)?; + } + return Ok(TestOutcome::empty(Some(runner.known_contracts.clone()), false)); + } + + let debug_selection_term = Term::stderr(); + let interactive_debug_selection = self.debug + && num_filtered != 1 + && tui_mode().is_interactive() + && debug_selection_term.is_term(); + let mut matching_debug_tests = if interactive_debug_selection { + collect_matching_debug_tests(&runner.list_signatures(filter)) + } else if self.debug && num_filtered != 1 { + collect_matching_debug_tests(&runner.list(filter)) + } else { + Vec::new() + }; + if interactive_debug_selection { + ctrlc::set_handler(|| { + let _ = Term::stderr().show_cursor(); + std::process::exit(130); + })?; + + let Some(selected) = Select::new() + .with_prompt("Select a test to debug") + .items( + matching_debug_tests + .iter() + .map(|test| format!("{}.{}", test.contract, test.test)), + ) + .max_length(DEBUGGER_MATCHING_TESTS_DISPLAY_LIMIT) + .interact_on_opt(&debug_selection_term)? + else { + bail!("Debugger test selection cancelled"); + }; + + filter.set_rerun_failures(vec![matching_debug_tests.swap_remove(selected)]); + num_filtered = 1; + } + + if num_filtered != 1 + && (self.debug || self.flamegraph || self.flamechart || self.evm_profile.is_some()) + { + let action = if self.flamegraph { + "generate a flamegraph" + } else if self.flamechart { "generate a flamechart" + } else if self.evm_profile.is_some() { + "generate an EVM profile" } else { "run the debugger" }; - let filter = if filter.is_empty() { + let filter_hint = if filter.is_empty() { String::new() } else { format!("\n\nFilter used:\n{filter}") }; - eyre::bail!( - "{num_filtered} tests matched your criteria, but exactly 1 test must match in order to {action}.\n\n\ - Use --match-contract and --match-path to further limit the search.{filter}", + let matching_tests_hint = if self.debug { + format_matching_debug_tests(&matching_debug_tests) + } else { + String::new() + }; + let narrowing_hint = if self.debug { + "Use --match-test , --match-contract, and --match-path to further limit the search." + } else { + "Use --match-contract and --match-path to further limit the search." + }; + bail!( + "{num_filtered} tests matched your criteria, but exactly 1 test must match in order to {action}.{matching_tests_hint}\n\n\ + {narrowing_hint}{filter_hint}", ); } // If exactly one test matched, we enable full tracing. - if num_filtered == 1 && self.decode_internal { + if num_filtered == 1 && runner.decode_internal != InternalTraceMode::None { runner.decode_internal = InternalTraceMode::Full; } // Run tests in a non-streaming fashion and collect results for serialization. - if !self.gas_report && !self.summary && shell::is_json() { + let serialize_json = + self.mutate.is_none() && !self.gas_report && !self.summary && shell::is_json(); + if serialize_json || self.junit { let mut results = runner.test_collect(filter)?; - results.values_mut().for_each(|suite_result| { - for test_result in suite_result.test_results.values_mut() { - if verbosity >= 2 { - // Decode logs at level 2 and above. - test_result.decoded_logs = decode_console_logs(&test_result.logs); - } else { - // Empty logs for non verbose runs. - test_result.logs = vec![]; - } + if serialize_json { + prepare_results_for_json(&mut results, verbosity, tracing.trace_depth); + } + self.emit_symbolic_regressions(&config, &runner.known_contracts, &mut results)?; + let rendered = if serialize_json { + serde_json::to_string(&results)? + } else { + junit_xml_report(&results, verbosity).to_string()? + }; + sh_println!("{rendered}")?; + + let mut gas_snapshots = BTreeMap::>::new(); + for result in results.values().flat_map(|suite| suite.test_results.values()) { + for (group, new_snapshots) in &result.gas_snapshots { + gas_snapshots.entry(group.clone()).or_default().extend(new_snapshots.clone()); } - }); - sh_println!("{}", serde_json::to_string(&results)?)?; - return Ok(TestOutcome::new(results, self.allow_failure)); - } + } + if !gas_snapshots.is_empty() { + self.check_and_write_gas_snapshots(&config, &gas_snapshots)?; + } - if self.junit { - let results = runner.test_collect(filter)?; - sh_println!("{}", junit_xml_report(&results, verbosity).to_string()?)?; - return Ok(TestOutcome::new(results, self.allow_failure)); + return Ok(TestOutcome::new( + Some(runner.known_contracts), + results, + self.allow_failure, + fuzz_seed, + )); } - let remote_chain_id = runner.evm_opts.get_remote_chain_id().await; + let remote_chain = runner + .backend + .is_in_forking_mode() + .then(|| runner.tcfg.fork_chain_id.or(runner.tx_env.chain_id())) + .flatten() + .map(Into::into); let known_contracts = runner.known_contracts.clone(); - let libraries = runner.libraries.clone(); + // Capture multi-pass state before moving `runner` into the spawn task. + // In multi-pass mode the per-pass summary is suppressed; the merged summary is + // printed once by the caller after all passes complete. + let is_multi_pass = !runner.tcfg.multi_network.all_override_networks.is_empty(); + let resolved_hardfork = runner.tcfg.hardfork; + let networks = runner.tcfg.evm_opts.networks; + let extra_cheatcode_addresses = runner.tcfg.executor_builder.extra_cheatcode_addresses(); + let decode_internal = runner.decode_internal != InternalTraceMode::None; + // Run tests in a streaming fashion. let (tx, rx) = channel::<(String, SuiteResult)>(); let timer = Instant::now(); let show_progress = config.show_progress; let handle = tokio::task::spawn_blocking({ let filter = filter.clone(); - move || runner.test(&filter, tx, show_progress) + move || runner.test(&filter, tx, show_progress).map(|()| runner) }); // Set up trace identifiers. let mut identifier = TraceIdentifiers::new().with_local(&known_contracts); - // Avoid using etherscan for gas report as we decode more traces and this will be - // expensive. - if !self.gas_report { - identifier = identifier.with_etherscan(&config, remote_chain_id)?; + // Avoid using external identifiers for gas report as we decode more traces and this will be + // expensive. Also skip external identifiers for local tests (no remote chain) to avoid + // unnecessary Etherscan API calls that significantly slow down test execution. + if !self.gas_report && remote_chain.is_some() { + identifier = identifier.with_external(&config, remote_chain)?; } // Build the trace decoder. let mut builder = CallTraceDecoderBuilder::new() + .with_tracing_config(tracing) .with_known_contracts(&known_contracts) - .with_verbosity(verbosity); + .with_networks(networks) + .with_chain_id(remote_chain.map(|c| c.id())) + .with_hardfork(resolved_hardfork); // Signatures are of no value for gas reports. if !self.gas_report { builder = builder.with_signature_identifier(SignaturesIdentifier::from_config(&config)?); } - - if self.decode_internal { + // The debugger resolves frame identities before decoding internal calls. + if decode_internal && !self.debug { let sources = ContractSources::from_project_output(output, &config.root, Some(&libraries))?; builder = builder.with_debug_identifier(DebugTraceIdentifier::new(sources)); @@ -544,35 +2538,46 @@ impl TestArgs { config.gas_reports.clone(), config.gas_reports_ignore.clone(), config.gas_reports_include_tests, + extra_cheatcode_addresses.iter().copied(), ) }); let mut gas_snapshots = BTreeMap::>::new(); - let mut outcome = TestOutcome::empty(self.allow_failure); + let mut outcome = TestOutcome::empty(None, self.allow_failure); + outcome.fuzz_seed = fuzz_seed; + + // Some outputs need trace identities even if the textual trace is not rendered. + let always_identify_traces = self.gas_report + || self.debug + || self.flamegraph + || self.flamechart + || self.evm_profile.is_some(); let mut any_test_failed = false; - for (contract_name, suite_result) in rx { - let tests = &suite_result.test_results; + let mut backtrace_builder = None; + while let Ok((contract_name, mut suite_result)) = rx.recv() { + let len = suite_result.len(); + let tests = &mut suite_result.test_results; + let has_tests = !tests.is_empty(); + + // In multi-pass (per-test network override) mode, skip suites that contributed no + // tests to this pass so we don't emit a stray blank line in the suite header or + // pollute the outcome with empty entries. + if is_multi_pass && !has_tests && suite_result.warnings.is_empty() { + continue; + } // Clear the addresses and labels from previous test. decoder.clear_addresses(); - // We identify addresses if we're going to print *any* trace or gas report. - let identify_addresses = verbosity >= 3 - || self.gas_report - || self.debug - || self.flamegraph - || self.flamechart; - // Print suite header. if !silent { sh_println!()?; for warning in &suite_result.warnings { sh_warn!("{warning}")?; } - if !tests.is_empty() { - let len = tests.len(); + if has_tests { let tests = if len > 1 { "tests" } else { "test" }; sh_println!("Ran {len} {tests} for {contract_name}")?; } @@ -580,12 +2585,33 @@ impl TestArgs { // Process individual test results, printing logs and traces when necessary. for (name, result) in tests { - let show_traces = - !self.suppress_successful_traces || result.status == TestStatus::Failure; + let test_failed = result.status.is_failure(); + let show_traces = !self.suppress_successful_traces || test_failed; + // Trace verbosity. + // - 0..3: nothing. + // - 3: only display traces for failed tests. + // - 4: also display the setup trace for failed tests. + // - 5..: display all traces for all tests, including storage changes. + let should_include_trace = |kind: &TraceKind| match kind { + TraceKind::Execution => { + (trace_verbosity == 3 && test_failed) || trace_verbosity >= 4 + } + TraceKind::Setup => { + (trace_verbosity == 4 && test_failed) || trace_verbosity >= 5 + } + TraceKind::Deployment => false, + }; + let renders_trace = !silent + && show_traces + && result.traces.iter().any(|(kind, _)| should_include_trace(kind)); + let identify_addresses = always_identify_traces || renders_trace; + if !silent { - sh_println!("{}", result.short_result(name))?; + sh_println!("{}", result.short_result_with_suite(name, &contract_name))?; + for artifact in &result.counterexample_artifacts { + sh_warn!("Counterexample artifact: {}", artifact.path.display())?; + } - // Display invariant metrics if invariant kind. if let TestKind::Invariant { metrics, .. } = &result.kind && !metrics.is_empty() { @@ -608,39 +2634,41 @@ impl TestArgs { // We shouldn't break out of the outer loop directly here so that we finish // processing the remaining tests and print the suite summary. - any_test_failed |= result.status == TestStatus::Failure; + any_test_failed |= result.status.is_failure(); // Clear the addresses and labels from previous runs. decoder.clear_addresses(); - decoder - .labels - .extend(result.labeled_addresses.iter().map(|(k, v)| (*k, v.clone()))); + if identify_addresses { + decoder.labels.extend(result.labels.iter().map(|(k, v)| (*k, v.clone()))); + } // Identify addresses and decode traces. - let mut decoded_traces = Vec::with_capacity(result.traces.len()); - for (kind, arena) in &mut result.traces.clone() { - if identify_addresses { + let mut decoded_traces = Vec::new(); + if identify_addresses { + for (kind, arena) in &mut result.traces { + if self.debug && !result.debug_bytecodes.is_empty() { + let mut local_identifier = TraceIdentifiers::new() + .with_local_and_bytecodes( + &known_contracts, + &result.debug_bytecodes, + ); + decoder.identify(arena, &mut local_identifier); + } decoder.identify(arena, &mut identifier); - } - // verbosity: - // - 0..3: nothing - // - 3: only display traces for failed tests - // - 4: also display the setup trace for failed tests - // - 5..: display all traces for all tests, including storage changes - let should_include = match kind { - TraceKind::Execution => { - (verbosity == 3 && result.status.is_failure()) || verbosity >= 4 - } - TraceKind::Setup => { - (verbosity == 4 && result.status.is_failure()) || verbosity >= 5 + if renders_trace && should_include_trace(kind) { + decoder.opcodes = self.opcodes.clone(); + decode_trace_arena(arena, &decoder).await; + let rendered = match tracing.trace_depth { + Some(trace_depth) => { + let mut arena = arena.clone(); + prune_trace_depth(&mut arena, trace_depth); + render_trace_arena_inner(&arena, false, trace_verbosity > 4) + } + None => render_trace_arena_inner(arena, false, trace_verbosity > 4), + }; + decoded_traces.push(rendered); } - TraceKind::Deployment => false, - }; - - if should_include { - decode_trace_arena(arena, &decoder).await; - decoded_traces.push(render_trace_arena_inner(arena, false, verbosity > 4)); } } @@ -651,6 +2679,29 @@ impl TestArgs { } } + // Extract and display backtrace for failed tests when trace verbosity >= 3. + // At trace verbosity 3-4 backtraces show contract/function names only. + // At trace verbosity 5 backtraces include source file locations. + if !silent + && test_failed + && trace_verbosity >= 3 + && let Some((_, arena)) = + result.traces.iter().find(|(kind, _)| kind.is_execution()) + { + let builder = backtrace_builder.get_or_insert_with(|| { + BacktraceBuilder::new( + output, + config.root.clone(), + config.parsed_libraries().ok(), + config.via_ir, + ) + }); + let backtrace = builder.from_traces(arena); + if !backtrace.is_empty() { + sh_println!("{}", backtrace)?; + } + } + if let Some(gas_report) = &mut gas_report { gas_report.analyze(result.traces.iter().map(|(_, a)| &a.arena), &decoder).await; @@ -660,118 +2711,36 @@ impl TestArgs { // Re-execute setup and deployment traces to collect identities created in // setUp and constructor. for (kind, arena) in &result.traces { - if !matches!(kind, TraceKind::Execution) { - decoder.identify(arena, &mut identifier); + if !kind.is_execution() { + decoder.identify_scoped(arena, &mut identifier); } } for arena in trace { - decoder.identify(arena, &mut identifier); + decoder.identify_scoped(arena, &mut identifier); gas_report.analyze([arena], &decoder).await; } } } - // Collect and merge gas snapshots. - for (group, new_snapshots) in &result.gas_snapshots { - gas_snapshots.entry(group.clone()).or_default().extend(new_snapshots.clone()); - } - } - - // Write gas snapshots to disk if any were collected. - if !gas_snapshots.is_empty() { - // By default `gas_snapshot_check` is set to `false` in the config. - // - // The user can either: - // - Set `FORGE_SNAPSHOT_CHECK=true` in the environment. - // - Pass `--gas-snapshot-check=true` as a CLI argument. - // - Set `gas_snapshot_check = true` in the config. - // - // If the user passes `--gas-snapshot-check=` then it will override the config - // and the environment variable, disabling the check if `false` is passed. - // - // Exiting early with code 1 if differences are found. - if self.gas_snapshot_check.unwrap_or(config.gas_snapshot_check) { - let differences_found = gas_snapshots.clone().into_iter().fold( - false, - |mut found, (group, snapshots)| { - // If the snapshot file doesn't exist, we can't compare so we skip. - if !&config.snapshots.join(format!("{group}.json")).exists() { - return false; - } - - let previous_snapshots: BTreeMap = - fs::read_json_file(&config.snapshots.join(format!("{group}.json"))) - .expect("Failed to read snapshots from disk"); - - let diff: BTreeMap<_, _> = snapshots - .iter() - .filter_map(|(k, v)| { - previous_snapshots.get(k).and_then(|previous_snapshot| { - if previous_snapshot != v { - Some(( - k.clone(), - (previous_snapshot.clone(), v.clone()), - )) - } else { - None - } - }) - }) - .collect(); - - if !diff.is_empty() { - let _ = sh_eprintln!( - "{}", - format!("\n[{group}] Failed to match snapshots:").red().bold() - ); - - for (key, (previous_snapshot, snapshot)) in &diff { - let _ = sh_eprintln!( - "{}", - format!("- [{key}] {previous_snapshot} → {snapshot}").red() - ); - } - - found = true; - } - - found - }, - ); - - if differences_found { - sh_eprintln!()?; - eyre::bail!("Snapshots differ from previous run"); + if shell::is_json() + && let Some(trace_depth) = tracing.trace_depth + { + for (_, arena) in &mut result.traces { + *arena = trace_arena_at_depth(arena, trace_depth); } } + // Clear memory. + result.gas_report_traces = Default::default(); - // By default `gas_snapshot_emit` is set to `true` in the config. - // - // The user can either: - // - Set `FORGE_SNAPSHOT_EMIT=false` in the environment. - // - Pass `--gas-snapshot-emit=false` as a CLI argument. - // - Set `gas_snapshot_emit = false` in the config. - // - // If the user passes `--gas-snapshot-emit=` then it will override the config - // and the environment variable, enabling the check if `true` is passed. - if self.gas_snapshot_emit.unwrap_or(config.gas_snapshot_emit) { - // Create `snapshots` directory if it doesn't exist. - fs::create_dir_all(&config.snapshots)?; - - // Write gas snapshots to disk per group. - gas_snapshots.clone().into_iter().for_each(|(group, snapshots)| { - fs::write_pretty_json_file( - &config.snapshots.join(format!("{group}.json")), - &snapshots, - ) - .expect("Failed to write gas snapshots to disk"); - }); + // Collect and merge gas snapshots. + for (group, new_snapshots) in &result.gas_snapshots { + gas_snapshots.entry(group.clone()).or_default().extend(new_snapshots.clone()); } } // Print suite summary. - if !silent { + if !silent && has_tests { sh_println!("{}", suite_result.summary())?; } @@ -783,6 +2752,23 @@ impl TestArgs { break; } } + + // Check and write snapshots once all suites are in, since a group can span several suites. + if !gas_snapshots.is_empty() { + self.check_and_write_gas_snapshots(&config, &gas_snapshots)?; + } + + let regressions = + self.emit_symbolic_regressions(&config, &known_contracts, &mut outcome.results)?; + if !silent { + for regression in regressions { + sh_warn!( + "Regression test: {} (from {})", + regression.path.display(), + regression.artifact.display() + )?; + } + } outcome.last_run_decoder = Some(decoder); let duration = timer.elapsed(); @@ -790,40 +2776,121 @@ impl TestArgs { if let Some(gas_report) = gas_report { let finalized = gas_report.finalize(); - sh_println!("{}", &finalized)?; + sh_println!("{finalized}")?; outcome.gas_report = Some(finalized); } - if !self.summary && !shell::is_json() { - sh_println!("{}", outcome.summary(duration))?; + if !is_multi_pass { + self.print_summary(&outcome, duration)?; } - if self.summary && !outcome.results.is_empty() { - let summary_report = TestSummaryReport::new(self.detailed, outcome.clone()); - sh_println!("{}", &summary_report)?; - } + // Keep the receiver alive only when its queued results are needed for the JSON file. + let json_results_rx = self.json_file.is_some().then_some(rx); // Reattach the task. - if let Err(e) = handle.await { - match e.try_into_panic() { + match handle.await { + Ok(result) => { + let runner = result?; + outcome.known_contracts = Some(runner.known_contracts); + } + Err(e) => match e.try_into_panic() { Ok(payload) => std::panic::resume_unwind(payload), Err(e) => return Err(e.into()), - } + }, } - // Persist test run failures to enable replaying. - persist_run_failures(&config, &outcome); + // Include suites that completed after fail-fast stopped console output in the JSON file. + if let Some(rx) = json_results_rx { + let mut results = outcome.results.clone(); + for (contract_name, suite_result) in rx.try_iter() { + if is_multi_pass + && suite_result.test_results.is_empty() + && suite_result.warnings.is_empty() + { + continue; + } + results.insert(contract_name, suite_result); + } + outcome.json_file_results = Some(results); + } Ok(outcome) } + /// Compares the collected gas snapshots against the ones on disk and writes them back, + /// depending on `--gas-snapshot-check` / `--gas-snapshot-emit` and their config defaults. + /// + /// The CLI flags override the config and environment, so `--gas-snapshot-check=false` disables + /// a check enabled in the config. Exits with code 1 if differences are found. + fn check_and_write_gas_snapshots( + &self, + config: &Config, + gas_snapshots: &BTreeMap>, + ) -> Result<()> { + if self.gas_snapshot_check.unwrap_or(config.gas_snapshot_check) { + let mut differences_found = false; + for (group, snapshots) in gas_snapshots { + let path = config.snapshots.join(format!("{group}.json")); + // If the snapshot file doesn't exist, we can't compare so we skip. + if !path.exists() { + continue; + } + let previous_snapshots: BTreeMap = + fs::read_json_file(&path).expect("Failed to read snapshots from disk"); + let diff = snapshots + .iter() + .filter_map(|(k, v)| { + previous_snapshots + .get(k) + .filter(|previous| *previous != v) + .map(|p| (k, p, v)) + }) + .collect::>(); + if diff.is_empty() { + continue; + } + let _ = sh_eprintln!( + "{}", + format!("\n[{group}] Failed to match snapshots:").red().bold() + ); + for (key, previous_snapshot, snapshot) in diff { + let _ = sh_eprintln!( + "{}", + format!("- [{key}] {previous_snapshot} → {snapshot}").red() + ); + } + differences_found = true; + } + if differences_found { + sh_eprintln!()?; + bail!("Snapshots differ from previous run"); + } + } + + if self.gas_snapshot_emit.unwrap_or(config.gas_snapshot_emit) { + fs::create_dir_all(&config.snapshots)?; + for (group, snapshots) in gas_snapshots { + fs::write_pretty_json_file( + &config.snapshots.join(format!("{group}.json")), + &snapshots, + ) + .expect("Failed to write gas snapshots to disk"); + } + } + Ok(()) + } + /// Returns the flattened [`FilterArgs`] arguments merged with [`Config`]. /// Loads and applies filter from file if only last test run failures performed. pub fn filter(&self, config: &Config) -> Result { let mut filter = self.filter.clone(); - if self.rerun { - filter.test_pattern = last_run_failures(config); - } + let rerun_failures = if self.rerun { + let failures = last_run_failures(config); + filter.test_pattern = failures.test_pattern; + failures.failures + } else { + None + }; if filter.path_pattern.is_some() { if self.path.is_some() { bail!("Can not supply both --match-path and |path|"); @@ -831,11 +2898,15 @@ impl TestArgs { } else { filter.path_pattern = self.path.clone(); } - Ok(filter.merge_with_config(config)) + let mut filter = filter.merge_with_config(config); + if let Some(failures) = rerun_failures { + filter.set_rerun_failures(failures); + } + Ok(filter) } /// Returns whether `BuildArgs` was configured with `--watch` - pub fn is_watch(&self) -> bool { + pub const fn is_watch(&self) -> bool { self.watch.watch.is_some() } @@ -848,50 +2919,386 @@ impl TestArgs { } } +/// Returns the names of the enabled flags. +fn enabled_flags(flags: [(bool, &'static str); N]) -> Vec<&'static str> { + flags.into_iter().filter_map(|(enabled, name)| enabled.then_some(name)).collect() +} + +fn prepare_results_for_json( + results: &mut BTreeMap, + verbosity: u8, + trace_depth: Option, +) { + for test_result in results.values_mut().flat_map(|suite| suite.test_results.values_mut()) { + if verbosity >= 2 { + test_result.decoded_logs = decode_console_logs(&test_result.logs); + } else { + test_result.logs = Vec::new(); + } + for (_, arena) in &mut test_result.traces { + // Discard presentation-only decoding populated by the streaming renderer. + for node in arena.nodes_mut() { + node.trace.decoded = None; + for log in &mut node.logs { + log.decoded = None; + } + for step in &mut node.trace.steps { + step.decoded = None; + } + } + if let Some(trace_depth) = trace_depth { + *arena = trace_arena_at_depth(arena, trace_depth); + } + } + } +} + +/// Bails if mutation testing could corrupt the real dependency trees. +/// +/// The mutation runner symlinks dependency directories (`lib`, `node_modules`, `dependencies`) +/// into each per-mutant TempDir for performance (see `workspace::copy_project`). That isolation +/// breaks down if tests can write to those shared trees, either via `vm.writeFile` (broad +/// `fs_permissions`) or arbitrary `ffi` calls. +fn ensure_mutation_workspace_safe(config: &Config) -> Result<()> { + if config.ffi { + bail!( + "Mutation testing is unsafe with `ffi = true`: per-mutant workspaces share \ + symlinked dependency directories, and arbitrary FFI commands run by tests \ + can race or corrupt the real `lib`/`node_modules`/`dependencies` trees. \ + Disable ffi in your foundry.toml to run mutation tests." + ); + } + + // Only refuse write-capable `fs_permissions` whose path can actually reach one of the + // symlinked dependency trees. Scoped writes (e.g. `./out`, `./snapshots`) are safe. + let root = &config.root; + let canonicalize_through_existing_ancestor = |path: &Path| -> PathBuf { + let resolved = if path.is_absolute() { path.to_path_buf() } else { root.join(path) }; + if let Ok(canon) = canonicalize_path(&resolved) { + return canon; + } + let mut missing = Vec::new(); + let mut ancestor = resolved.as_path(); + while !ancestor.exists() { + let Some(name) = ancestor.file_name() else { break }; + missing.push(name.to_owned()); + let Some(parent) = ancestor.parent() else { break }; + ancestor = parent; + } + let mut canon = canonicalize_path(ancestor).unwrap_or_else(|_| ancestor.into()); + canon.extend(missing.iter().rev()); + canon + }; + + let shared_dep_dirs = config + .libs + .iter() + .filter(|p| p.exists()) + .cloned() + .chain( + ["node_modules", "dependencies"] + .into_iter() + .map(|dep_dir| root.join(dep_dir)) + .filter(|dep_path| dep_path.is_dir()), + ) + .map(|p| canonicalize_through_existing_ancestor(&p)) + .collect::>(); + + let permissions = &config.fs_permissions.permissions; + let effective_permission = |path: &Path| -> Option { + let mut max_path_len = 0; + let mut highest_permission = FsAccessPermission::None; + for perm in permissions { + let permission_path = canonicalize_through_existing_ancestor(&perm.path); + if !path.starts_with(&permission_path) { + continue; + } + let path_len = permission_path.components().count(); + if path_len > max_path_len { + max_path_len = path_len; + highest_permission = perm.access; + } else if path_len == max_path_len { + highest_permission = match (highest_permission, perm.access) { + (FsAccessPermission::ReadWrite, _) + | (FsAccessPermission::Read, FsAccessPermission::Write) + | (FsAccessPermission::Write, FsAccessPermission::Read) => { + FsAccessPermission::ReadWrite + } + (FsAccessPermission::None, perm) => perm, + (existing_perm, _) => existing_perm, + }; + } + } + (max_path_len > 0).then_some(highest_permission) + }; + let grants_write = |path: &Path| { + matches!( + effective_permission(path), + Some(FsAccessPermission::Write | FsAccessPermission::ReadWrite) + ) + }; + + let unsafe_write_paths = permissions + .iter() + .filter(|perm| { + matches!(perm.access, FsAccessPermission::Write | FsAccessPermission::ReadWrite) + }) + .filter(|perm| { + let perm_path = canonicalize_through_existing_ancestor(&perm.path); + shared_dep_dirs.iter().any(|dep| { + if perm_path.starts_with(dep) { + grants_write(&perm_path) + } else if dep.starts_with(&perm_path) { + grants_write(dep) + } else { + false + } + }) + }) + .map(|perm| format!(" - {}", perm.path.display())) + .collect::>(); + if !unsafe_write_paths.is_empty() { + bail!( + "Mutation testing is unsafe with write-capable `fs_permissions` that can \ + reach the symlinked dependency trees (`lib`/`node_modules`/`dependencies`); \ + per-mutant workspaces share those trees, so `vm.writeFile` calls would race \ + against or corrupt your real dependencies. Restrict the following \ + `fs_permissions` entries to read-only or scope them away from dependency \ + paths:\n{}", + unsafe_write_paths.join("\n") + ); + } + Ok(()) +} + +/// Builds a figment dictionary from `key => Option` pairs, skipping unset values. +macro_rules! dict { + ($($key:literal => $value:expr),* $(,)?) => {{ + let mut dict = Dict::default(); + $(if let Some(value) = $value { + dict.insert($key.to_string(), Value::from(value)); + })* + dict + }}; +} + +/// Renders an optional path for the figment provider. +fn path_string(path: &Option) -> Option { + path.as_ref().map(|path| path.to_string_lossy().to_string()) +} + impl Provider for TestArgs { fn metadata(&self) -> Metadata { Metadata::named("Core Build Args Provider") } fn data(&self) -> Result, figment::Error> { - let mut dict = Dict::default(); + let fuzz = dict! { + "seed" => self.fuzz_seed.map(|seed| seed.to_string()), + "runs" => self.fuzz_runs, + "run" => self.fuzz_run, + "worker" => self.fuzz_worker, + "timeout" => self.fuzz_timeout, + "dictionary_weight" => self.fuzz_dictionary_weight, + "max_fuzz_dictionary_addresses" => self.fuzz_dictionary_addresses.clone(), + "max_fuzz_dictionary_values" => self.fuzz_dictionary_values.clone(), + "max_fuzz_dictionary_literals" => self.fuzz_dictionary_literals.clone(), + "corpus_random_sequence_weight" => self.fuzz_corpus_random_sequence_weight, + "corpus_dir" => path_string(&self.fuzz_corpus_dir), + "frontier_dir" => path_string(&self.fuzz_frontier_dir), + "frontier_limit" => self.fuzz_frontier_limit, + "payable_value_weight" => self.fuzz_payable_value_weight, + "mutation_weight_splice" => self.fuzz_mutation_weight_splice, + "mutation_weight_repeat" => self.fuzz_mutation_weight_repeat, + "mutation_weight_interleave" => self.fuzz_mutation_weight_interleave, + "mutation_weight_prefix" => self.fuzz_mutation_weight_prefix, + "mutation_weight_suffix" => self.fuzz_mutation_weight_suffix, + "mutation_weight_abi" => self.fuzz_mutation_weight_abi, + "mutation_weight_cmp" => self.fuzz_mutation_weight_cmp, + }; + let invariant = dict! { + "runs" => self.invariant_runs_override, + "depth" => self.invariant_depth, + "min_depth" => self.invariant_min_depth, + "depth_mode" => self.invariant_depth_mode.map(Value::serialize).transpose()?, + "workers" => self.invariant_workers.map(Value::serialize).transpose()?, + "dictionary_weight" => self.invariant_dictionary_weight, + "max_fuzz_dictionary_addresses" => self.invariant_dictionary_addresses.clone(), + "max_fuzz_dictionary_values" => self.invariant_dictionary_values.clone(), + "max_fuzz_dictionary_literals" => self.invariant_dictionary_literals.clone(), + "corpus_random_sequence_weight" => self.invariant_corpus_random_sequence_weight, + "corpus_random_sequence_weight_configured" => + self.invariant_corpus_random_sequence_weight.map(|_| true), + "corpus_dir" => path_string(&self.invariant_corpus_dir), + "frontier_dir" => path_string(&self.invariant_frontier_dir), + "frontier_limit" => self.invariant_frontier_limit, + "payable_value_weight" => self.invariant_payable_value_weight, + "timeout" => self.invariant_timeout_override, + "mutation_weight_splice" => self.invariant_mutation_weight_splice, + "mutation_weight_repeat" => self.invariant_mutation_weight_repeat, + "mutation_weight_interleave" => self.invariant_mutation_weight_interleave, + "mutation_weight_prefix" => self.invariant_mutation_weight_prefix, + "mutation_weight_suffix" => self.invariant_mutation_weight_suffix, + "mutation_weight_abi" => self.invariant_mutation_weight_abi, + "mutation_weight_cmp" => self.invariant_mutation_weight_cmp, + }; + let symbolic = dict! { + "enabled" => self.symbolic.then_some(true), + "seed_corpus" => self.symbolic_seed_corpus.then_some(true), + "use_fuzz_corpus" => self.symbolic_use_fuzz_corpus.then_some(true), + "corpus_seed_limit" => self.symbolic_corpus_seed_limit, + "use_fuzz_frontiers" => self.symbolic_use_fuzz_frontiers.then_some(true), + "check_invariant_frontiers" => + self.symbolic_check_invariant_frontiers.then_some(true), + "frontier_limit" => self.symbolic_frontier_limit, + "frontier_ids" => self.symbolic_frontier_ids.clone(), + "frontier_pcs" => self.symbolic_frontier_pcs.clone(), + "frontier_selectors" => self.symbolic_frontier_selectors.clone(), + "solver" => self.symbolic_solver.clone(), + "solver_command" => self.symbolic_solver_command.clone(), + "solver_portfolio" => self.symbolic_solver_portfolio.clone(), + "timeout" => self.symbolic_timeout, + "loop" => self.symbolic_loop, + "depth" => self.symbolic_depth, + "width" => self.symbolic_width, + "max_depth" => self.symbolic_max_depth, + "max_paths" => self.symbolic_max_paths, + "invariant_depth" => self.symbolic_invariant_depth, + "max_solver_queries" => self.symbolic_max_solver_queries, + "default_dynamic_length" => self.symbolic_default_dynamic_length, + "max_dynamic_length" => self.symbolic_max_dynamic_length, + "array_lengths" => self.symbolic_array_lengths.clone(), + "max_calldata_bytes" => self.symbolic_max_calldata_bytes, + "symbolic_call_targets" => self.symbolic_call_targets.then_some(true), + "dump_smt" => self.symbolic_dump_smt.then_some(true), + "storage_layout" => self.symbolic_storage_layout.clone(), + }; + let mutation = dict! { + "timeout" => self.mutation_timeout, + "optimizer_runs" => self.mutation_optimizer_runs, + "via_ir" => self.mutation_via_ir, + }; - let mut fuzz_dict = Dict::default(); - if let Some(fuzz_seed) = self.fuzz_seed { - fuzz_dict.insert("seed".to_string(), fuzz_seed.to_string().into()); - } - if let Some(fuzz_runs) = self.fuzz_runs { - fuzz_dict.insert("runs".to_string(), fuzz_runs.into()); - } - if let Some(fuzz_timeout) = self.fuzz_timeout { - fuzz_dict.insert("timeout".to_string(), fuzz_timeout.into()); - } - if let Some(fuzz_input_file) = self.fuzz_input_file.clone() { - fuzz_dict.insert("failure_persist_file".to_string(), fuzz_input_file.into()); + let mut dict = dict! { + "fuzz" => Some(fuzz), + "invariant" => (!invariant.is_empty()).then_some(invariant), + "symbolic" => Some(symbolic), + "etherscan_api_key" => + self.etherscan_api_key.as_ref().filter(|s| !s.trim().is_empty()).cloned(), + "show_progress" => self.show_progress.then_some(true), + "decode_external_storage" => self.decode_external_storage.then_some(true), + }; + // Mutation-testing CLI overrides + if !mutation.is_empty() { + dict.insert("mutation".to_string(), mutation.into()); } - dict.insert("fuzz".to_string(), fuzz_dict.into()); + Ok(Map::from([(Config::selected_profile(), dict)])) + } +} - if let Some(etherscan_api_key) = - self.etherscan_api_key.as_ref().filter(|s| !s.trim().is_empty()) - { - dict.insert("etherscan_api_key".to_string(), etherscan_api_key.to_string().into()); - } +fn parse_opcode(s: &str) -> Result { + OpCode::parse(s).ok_or_else(|| format!("invalid opcode: {s}")) +} - if let Some(api_version) = &self.etherscan_api_version { - dict.insert("etherscan_api_version".to_string(), api_version.to_string().into()); +const fn apply_mutation_compiler_overrides(config: &mut Config) { + if let Some(optimizer_runs) = config.mutation.optimizer_runs { + let default_optimizer_settings = + matches!(config.optimizer, Some(false)) && matches!(config.optimizer_runs, Some(200)); + config.optimizer_runs = Some(optimizer_runs as usize); + if default_optimizer_settings { + config.optimizer = None; } + config.normalize_optimizer_settings(); + } + if let Some(via_ir) = config.mutation.via_ir { + config.via_ir = via_ir; + } +} + +/// Returns whether an ABI-discovered contract could be included in the test runner. +fn is_runnable_test_contract( + output: &ProjectCompileOutput, + config: &Config, + id: &ArtifactId, + artifact: &ConfigurableContractArtifact, + abi: &JsonAbi, +) -> bool { + if abi.constructor.as_ref().is_some_and(|constructor| !constructor.inputs.is_empty()) + || is_external_artifact(&id.build_id) + && artifact.get_bytecode_bytes().is_none_or(|bytecode| bytecode.is_empty()) + { + return false; + } - if self.show_progress { - dict.insert("show_progress".to_string(), true.into()); + let contract_name = id.name.split('.').next().unwrap_or(&id.name); + let path = config.root.join(&id.source); + let compiler = output.parser().solc().compiler(); + compiler.enter_sequential(|compiler| { + if let Some((_, source)) = compiler.gcx().get_ast_source(&path) + && let Some(ast) = &source.ast + && let Some(contract) = ast.items.iter().find_map(|item| match &item.kind { + ItemKind::Contract(contract) if contract.name.as_str() == contract_name => { + Some(contract) + } + _ => None, + }) + { + return matches!( + contract.kind, + SolarContractKind::Contract | SolarContractKind::Library + ); } + true + }) +} - Ok(Map::from([(Config::selected_profile(), dict)])) - } +/// Returns the deployable contracts in `output` that match `filter`, with project-relative ids. +fn matching_test_contracts<'a>( + output: &'a ProjectCompileOutput, + config: &'a Config, + matcher: &'a TestFunctionMatcher<'a>, + filter: &'a ProjectPathsAwareFilter, +) -> impl Iterator + 'a { + output.artifact_ids().filter_map(move |(id, artifact)| { + let abi = artifact.abi.as_ref()?; + let id = id.with_stripped_file_prefixes(&config.root); + let deployable = abi.constructor.as_ref().is_none_or(|c| c.inputs.is_empty()); + (deployable && matcher.matches_contract(filter, &id, abi)).then_some((id, artifact, abi)) + }) } -/// Lists all matching tests -fn list(runner: MultiContractRunner, filter: &ProjectPathsAwareFilter) -> Result { - let results = runner.list(filter); +/// Lists all matching tests without building a runner. +fn list_from_output( + output: &ProjectCompileOutput, + config: &Config, + inline_config: &InlineConfig, + filter: &ProjectPathsAwareFilter, + fuzz_only: bool, + symbolic_artifact_replay: Option<&SymbolicArtifactReplayConfig>, +) -> Result { + let matcher = TestFunctionMatcher::new(config, inline_config, symbolic_artifact_replay); + let mut results = BTreeMap::>>::new(); + for (id, _, abi) in matching_test_contracts(output, config, &matcher, filter) { + let identifier = id.identifier(); + let generated_symbolic_regression = is_generated_symbolic_regression_contract(abi); + let tests = abi + .functions() + .filter(|func| { + let kind = + matcher.test_function_kind(&identifier, func, generated_symbolic_regression); + (!fuzz_only + || matches!( + kind, + TestFunctionKind::FuzzTest { .. } | TestFunctionKind::InvariantTest + )) + && filter.matches_test_function_kind_in_contract(&identifier, func, kind) + }) + .map(|func| func.name.clone()) + .collect::>(); + if !tests.is_empty() { + results.entry(id.source.display().to_string()).or_default().insert(id.name, tests); + } + } if shell::is_json() { sh_println!("{}", serde_json::to_string(&results)?)?; @@ -904,36 +3311,185 @@ fn list(runner: MultiContractRunner, filter: &ProjectPathsAwareFilter) -> Result } } } - Ok(TestOutcome::empty(false)) + Ok(TestOutcome::empty(None, false)) +} + +fn matching_fuzz_replay_targets( + output: &ProjectCompileOutput, + config: &Config, + inline_config: &InlineConfig, + filter: &ProjectPathsAwareFilter, + selector: &[u8], +) -> Result> { + let matcher = TestFunctionMatcher::new(config, inline_config, None); + let mut targets = Vec::new(); + for (id, artifact, abi) in matching_test_contracts(output, config, &matcher, filter) { + let has_creation_code = + artifact.get_bytecode_object().is_some_and(|object| match object.as_ref() { + BytecodeObject::Bytecode(bytecode) => !bytecode.is_empty(), + BytecodeObject::Unlinked(_) => true, + }); + if !has_creation_code { + continue; + } + let contract = id.identifier(); + let generated_symbolic_regression = is_generated_symbolic_regression_contract(abi); + for func in abi.functions() { + let kind = matcher.test_function_kind(&contract, func, generated_symbolic_regression); + if !kind.is_fuzz_test() + || !filter.matches_test_function_kind_in_contract(&contract, func, kind) + { + continue; + } + let function_config = inline_config_for(config, inline_config, &contract, Some(func))?; + if effective_test_function_kind(kind, &function_config, func).is_fuzz_test() + && func.selector() == selector + { + targets.push((contract.clone(), func.signature())); + } + } + } + Ok(targets) +} + +/// Merges `other` into `base` by extending suite results. +/// +/// For suites that appear in both, test results are combined (function-level pass routing ensures +/// each function appears in exactly one pass, so there are no key conflicts in practice). +fn merge_outcomes(base: &mut TestOutcome, mut other: TestOutcome) { + if let Some(other_results) = other.json_file_results.take() { + let base_results = base.json_file_results.get_or_insert_with(|| base.results.clone()); + merge_suite_results(base_results, other_results); + } + merge_suite_results(&mut base.results, other.results); + if let Some(decoder) = other.last_run_decoder { + base.last_run_decoder = Some(decoder); + } +} + +fn merge_suite_results( + base: &mut BTreeMap, + other: BTreeMap, +) { + for (suite_id, other_suite) in other { + if let Some(base_suite) = base.get_mut(&suite_id) { + base_suite.test_results.extend(other_suite.test_results); + base_suite.warnings.extend(other_suite.warnings); + base_suite.duration = base_suite.duration.max(other_suite.duration); + } else { + base.insert(suite_id, other_suite); + } + } +} + +fn collect_matching_debug_tests( + matching_tests: &BTreeMap>>, +) -> Vec { + matching_tests + .iter() + .flat_map(|(source, contracts)| { + contracts.iter().flat_map(move |(contract, tests)| { + tests.iter().map(move |test| RerunFailure { + contract: format!("{source}:{contract}"), + test: test.clone(), + }) + }) + }) + .collect() +} + +fn format_matching_debug_tests(matching_tests: &[RerunFailure]) -> String { + if matching_tests.is_empty() { + return String::new(); + } + let mut output = String::from("\n\nMatching tests:"); + for test in matching_tests.iter().take(DEBUGGER_MATCHING_TESTS_DISPLAY_LIMIT) { + write!(output, "\n {}.{}", test.contract, test.test).unwrap(); + } + if matching_tests.len() > DEBUGGER_MATCHING_TESTS_DISPLAY_LIMIT { + write!( + output, + "\n ... and {} more", + matching_tests.len() - DEBUGGER_MATCHING_TESTS_DISPLAY_LIMIT + ) + .unwrap(); + } + output +} + +struct LastRunFailures { + test_pattern: Option, + failures: Option>, } /// Load persisted filter (with last test run failures) from file. -fn last_run_failures(config: &Config) -> Option { - match fs::read_to_string(&config.test_failures_file) { - Ok(filter) => Some(Regex::new(&filter).unwrap()), - Err(_) => None, +fn last_run_failures(config: &Config) -> LastRunFailures { + let Ok(filter) = fs::read_to_string(&config.test_failures_file) else { + return LastRunFailures { test_pattern: None, failures: None }; + }; + + if let Ok(failures) = serde_json::from_str::(&filter) { + if failures.failures.is_empty() { + return LastRunFailures { test_pattern: None, failures: None }; + } + let test_pattern = failures + .failures + .iter() + .map(|failure| regex::escape(&failure.test)) + .collect::>() + .join("|"); + let test_pattern = Regex::new(&test_pattern).ok(); + return LastRunFailures { test_pattern, failures: Some(failures.failures) }; } + + // Legacy format: a plain regex. + let test_pattern = Regex::new(&filter) + .inspect_err(|e| { + _ = sh_warn!("failed to parse test filter from {:?}: {e}", config.test_failures_file) + }) + .ok(); + LastRunFailures { test_pattern, failures: None } } -/// Persist filter with last test run failures (only if there's any failure). +/// Replace the last run failures, clearing the record when the run succeeds. fn persist_run_failures(config: &Config, outcome: &TestOutcome) { - if outcome.failed() > 0 && fs::create_file(&config.test_failures_file).is_ok() { - let mut filter = String::new(); - let mut failures = outcome.failures().peekable(); - while let Some((test_name, _)) = failures.next() { - if test_name.is_any_test() - && let Some(test_match) = test_name.split("(").next() - { - filter.push_str(test_match); - if failures.peek().is_some() { - filter.push('|'); - } - } + if outcome.failed() == 0 { + let _ = fs::remove_file(&config.test_failures_file); + } else if fs::create_file(&config.test_failures_file).is_ok() { + let failures = outcome + .results + .iter() + .flat_map(|(contract, suite)| { + suite.test_results.iter().filter(|(_, result)| result.status.is_failure()).flat_map( + move |(test_name, test_result)| { + rerun_filter_matches(test_name, test_result) + .map(move |test| RerunFailure { contract: contract.clone(), test }) + }, + ) + }) + .collect::>(); + + if let Ok(output) = serde_json::to_string(&RerunFailures { version: 1, failures }) { + let _ = fs::write(&config.test_failures_file, output); } - let _ = fs::write(&config.test_failures_file, filter); } } +/// Returns the rerun keys of a failed test: its failed invariant predicates, or the test name when +/// no predicate failed. +fn rerun_filter_matches<'a>( + test_name: &'a str, + test_result: &'a TestResult, +) -> impl Iterator + 'a { + let predicate_failures = + test_result.invariant_failures.iter().filter_map(|failure| failure.predicate_name()); + let has_predicate_failures = predicate_failures.clone().next().is_some(); + let fallback = test_name.is_any_test().then(|| test_name.split('(').next()).flatten(); + predicate_failures + .chain(fallback.into_iter().filter(move |_| !has_predicate_failures)) + .map(str::to_owned) +} + /// Generate test report in JUnit XML report format. fn junit_xml_report(results: &BTreeMap, verbosity: u8) -> Report { let mut total_duration = Duration::default(); @@ -945,73 +3501,543 @@ fn junit_xml_report(results: &BTreeMap, verbosity: u8) -> R test_suite.set_time(suite_result.duration); test_suite.set_system_out(suite_result.summary()); for (test_name, test_result) in &suite_result.test_results { - let mut test_status = match test_result.status { - TestStatus::Success => TestCaseStatus::success(), - TestStatus::Failure => TestCaseStatus::non_success(NonSuccessKind::Failure), - TestStatus::Skipped => TestCaseStatus::skipped(), - }; - if let Some(reason) = &test_result.reason { - test_status.set_message(reason); - } + add_junit_test_cases(&mut test_suite, test_name, test_result, verbosity); + } + junit_report.add_test_suite(test_suite); + } + junit_report.set_time(total_duration); + junit_report +} - let mut test_case = TestCase::new(test_name, test_status); - test_case.set_time(test_result.duration); +/// Adds JUnit test cases for a test result. +/// +/// Invariant campaigns are expanded into per-predicate and per-handler cases so CI can report +/// contract-level execution without losing failure attribution. +fn add_junit_test_cases( + test_suite: &mut TestSuite, + test_name: &str, + test_result: &TestResult, + verbosity: u8, +) { + let output = JunitOutput::new(test_result, verbosity); + let expanded_invariant = test_result.kind.is_invariant() + && (!test_result.invariant_predicate_results.is_empty() + || !test_result.invariant_handler_failures.is_empty()); + + if !expanded_invariant { + add_junit_test_case( + test_suite, + test_name, + test_result.status, + test_result.reason.as_deref(), + test_result.duration, + output.system_out(test_result, test_name), + ); + return; + } - let mut sys_out = String::new(); - let result_report = test_result.kind.report(); - write!(sys_out, "{test_result} {test_name} {result_report}").unwrap(); - if verbosity >= 2 && !test_result.logs.is_empty() { - write!(sys_out, "\\nLogs:\\n").unwrap(); - let console_logs = decode_console_logs(&test_result.logs); - for log in console_logs { - write!(sys_out, " {log}\\n").unwrap(); - } + let mut add_expanded_case = + |name: &str, + status: TestStatus, + reason: Option<&str>, + counterexample: Option<&CounterExample>| { + add_junit_test_case( + test_suite, + name, + status, + reason, + test_result.duration, + output.case_system_out(status, reason, name, counterexample), + ); + }; + + if test_result.invariant_predicate_results.is_empty() { + let failure = test_result.invariant_failures.first(); + let status = if failure.is_some() { TestStatus::Failure } else { TestStatus::Success }; + add_expanded_case( + test_name, + status, + failure.map(|failure| failure.reason()), + failure.and_then(|failure| failure.counterexample()), + ); + } else { + for predicate in &test_result.invariant_predicate_results { + let failure = test_result + .invariant_failures + .iter() + .find(|failure| failure.name() == predicate.name.as_str()); + add_expanded_case( + &format!("{}()", predicate.name), + predicate.status, + predicate.reason.as_deref(), + failure.and_then(|failure| failure.counterexample()), + ); + } + } + + for failure in &test_result.invariant_handler_failures { + add_expanded_case( + &format!("handler {}", failure.name()), + TestStatus::Failure, + Some(failure.reason()), + failure.counterexample(), + ); + } +} + +/// Adds a single JUnit test case to the suite. +fn add_junit_test_case( + test_suite: &mut TestSuite, + test_name: &str, + status: TestStatus, + message: Option<&str>, + duration: Duration, + system_out: String, +) { + let mut test_status = match status { + TestStatus::Success => TestCaseStatus::success(), + TestStatus::Failure => TestCaseStatus::non_success(NonSuccessKind::Failure), + TestStatus::Skipped => TestCaseStatus::skipped(), + }; + if let Some(message) = message { + test_status.set_message(message); + } + let mut test_case = TestCase::new(test_name, test_status); + test_case.set_time(duration); + test_case.set_system_out(system_out); + test_suite.add_test_case(test_case); +} + +/// Helper for assembling JUnit output strings. +struct JunitOutput { + result_report: TestKindReport, + logs: Option>, +} + +impl JunitOutput { + fn new(test_result: &TestResult, verbosity: u8) -> Self { + Self { + result_report: test_result.kind.report(), + logs: (verbosity >= 2 && !test_result.logs.is_empty()) + .then(|| decode_console_logs(&test_result.logs)), + } + } + + /// Renders the suite-level `system-out` payload. + fn system_out(&self, test_result: &TestResult, test_name: &str) -> String { + let mut sys_out = format!("{test_result} {test_name} {}", self.result_report); + self.append_logs(&mut sys_out); + sys_out + } + + /// Renders the case-level `system-out` payload. + fn case_system_out( + &self, + status: TestStatus, + message: Option<&str>, + test_name: &str, + counterexample: Option<&CounterExample>, + ) -> String { + let mut sys_out = match (status, message) { + (TestStatus::Success, _) => "[PASS]".to_string(), + (TestStatus::Failure, message) => format!("[FAIL: {}]", message.unwrap_or_default()), + (TestStatus::Skipped, Some(message)) => format!("[SKIP: {message}]"), + (TestStatus::Skipped, None) => "[SKIP]".to_string(), + }; + write!(sys_out, " {test_name} {}", self.result_report).unwrap(); + if let Some(CounterExample::Sequence(original, sequence)) = counterexample { + writeln!(sys_out, "\n\t[Sequence] (original: {original}, shrunk: {})", sequence.len()) + .unwrap(); + for ex in sequence { + writeln!(sys_out, "{ex}").unwrap(); } + } + self.append_logs(&mut sys_out); + sys_out + } - test_case.set_system_out(sys_out); - test_suite.add_test_case(test_case); + /// Appends captured console logs to the output payload. + fn append_logs(&self, sys_out: &mut String) { + if let Some(logs) = &self.logs { + write!(sys_out, "\\nLogs:\\n").unwrap(); + for log in logs { + write!(sys_out, " {log}\\n").unwrap(); + } } - junit_report.add_test_suite(test_suite); } - junit_report.set_time(total_duration); - junit_report } #[cfg(test)] mod tests { use super::*; - use foundry_config::Chain; - #[test] - fn watch_parse() { - let args: TestArgs = TestArgs::parse_from(["foundry-cli", "-vw"]); - assert!(args.watch.watch.is_some()); + /// Parses `args` with the given environment variables set, restoring the previous values. + fn parse_with_env(vars: &[(&str, &str)], args: &[&str]) -> TestArgs { + static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _guard = ENV_LOCK.lock().unwrap(); + let previous = vars.iter().map(|(name, _)| std::env::var_os(name)).collect::>(); + for (name, value) in vars { + unsafe { std::env::set_var(name, value) }; + } + let parsed = + TestArgs::try_parse_from(["foundry-cli"].into_iter().chain(args.iter().copied())); + for ((name, _), previous) in vars.iter().zip(previous) { + match previous { + Some(previous) => unsafe { std::env::set_var(name, previous) }, + None => unsafe { std::env::remove_var(name) }, + } + } + parsed.unwrap() } #[test] - fn fuzz_seed() { - let args: TestArgs = TestArgs::parse_from(["foundry-cli", "--fuzz-seed", "0x10"]); + fn parses_flags_without_cli_coverage() { + assert!(TestArgs::parse_from(["foundry-cli", "-vw"]).watch.watch.is_some()); + assert!(TestArgs::parse_from(["foundry-cli", "--compact-labels"]).tracing.compact_labels); + // + let args = + TestArgs::parse_from(["foundry-cli", "-vvv", "--gas-report", "--fuzz-seed", "0x10"]); assert!(args.fuzz_seed.is_some()); + let args = TestArgs::parse_from(["foundry-cli", "--invariant-workers", "auto"]); + assert_eq!(args.invariant_workers, Some(InvariantWorkers::Auto)); + assert_eq!( + figment::Figment::from(&args) + .extract_inner::("invariant.workers") + .unwrap(), + InvariantWorkers::Auto + ); } - // #[test] - fn fuzz_seed_exists() { - let args: TestArgs = - TestArgs::parse_from(["foundry-cli", "-vvv", "--gas-report", "--fuzz-seed", "0x10"]); - assert!(args.fuzz_seed.is_some()); + fn parses_env_vars() { + let args = parse_with_env( + &[ + ("FOUNDRY_INVARIANT_WORKERS", "auto"), + ("FOUNDRY_FUZZ_CORPUS_DIR", "env_fuzz_corpus"), + ("FOUNDRY_INVARIANT_CORPUS_DIR", "env_invariant_corpus"), + ], + &[], + ); + assert_eq!(args.invariant_workers, Some(InvariantWorkers::Auto)); + assert_eq!(args.fuzz_corpus_dir, Some(PathBuf::from("env_fuzz_corpus"))); + assert_eq!(args.invariant_corpus_dir, Some(PathBuf::from("env_invariant_corpus"))); + } + + #[test] + fn showmap_override_validates_path_component_names() { + let mut args = TestArgs::parse_from(["foundry-cli"]); + args.set_showmap_override(ShowmapConfig { + out_dir: PathBuf::from("showmap"), + approach: "../outside".to_string(), + trial: "trial".to_string(), + per_input: false, + domain: ShowmapDomain::Evm, + corpus_dir: None, + emit_files: false, + }); + + let err = args.showmap_config().unwrap_err().to_string(); + assert!(err.contains("expected a single file-name component"), "{err}"); + } + + #[test] + fn debugger_test_candidates_preserve_exact_suite_ids() { + let matching = BTreeMap::from([( + "test/Counter.t.sol".to_string(), + BTreeMap::from([( + "CounterTest".to_string(), + vec!["testFuzz_SetNumber(uint256)".to_string(), "test_Increment()".to_string()], + )]), + )]); + + let candidates = collect_matching_debug_tests(&matching); + + assert_eq!(candidates[0].contract, "test/Counter.t.sol:CounterTest"); + assert_eq!(candidates[0].test, "testFuzz_SetNumber(uint256)"); + assert_eq!(candidates[1].test, "test_Increment()"); + assert_eq!( + format_matching_debug_tests(&candidates), + "\n\nMatching tests:\n test/Counter.t.sol:CounterTest.testFuzz_SetNumber(uint256)\n test/Counter.t.sol:CounterTest.test_Increment()" + ); + } + + #[test] + fn fuzz_run_adapter_writes_unified_campaign_dials_sparsely() { + let args = FuzzRunArgs::parse_from([ + "foundry-cli", + "--runs", + "9", + "--timeout", + "3", + "--seed", + "0x10", + "--depth", + "7", + "--workers", + "2", + "--frontier-dir", + "frontiers", + "--frontier-limit", + "17", + ]); + let args = TestArgs::from_fuzz_run(args); + let figment = figment::Figment::from(&args); + + assert_eq!(figment.extract_inner::("fuzz.runs").unwrap(), 9); + assert_eq!(figment.extract_inner::("fuzz.timeout").unwrap(), 3); + assert_eq!(figment.extract_inner::("fuzz.seed").unwrap(), "16"); + assert_eq!(figment.extract_inner::("invariant.runs").unwrap(), 9); + assert_eq!(figment.extract_inner::("invariant.timeout").unwrap(), 3); + assert_eq!(figment.extract_inner::("invariant.depth").unwrap(), 7); + assert_eq!( + figment.extract_inner::("fuzz.frontier_dir").unwrap(), + PathBuf::from("frontiers") + ); + assert_eq!(figment.extract_inner::("fuzz.frontier_limit").unwrap(), 17); + assert_eq!( + figment.extract_inner::("invariant.frontier_dir").unwrap(), + PathBuf::from("frontiers") + ); + assert_eq!(figment.extract_inner::("invariant.frontier_limit").unwrap(), 17); + assert_eq!( + figment.extract_inner::("invariant.workers").unwrap(), + InvariantWorkers::Fixed(std::num::NonZeroUsize::new(2).unwrap()) + ); + } + + #[test] + fn fuzz_run_adapter_writes_invariant_workers_sparsely() { + let args = TestArgs::from_fuzz_run(FuzzRunArgs::parse_from(["foundry-cli"])); + let figment = figment::Figment::from(&args); + + assert_eq!(args.invariant_workers, None); + assert!(figment.extract_inner::("invariant.workers").is_err()); + } + + #[test] + fn mutation_compiler_overrides_are_extracted() { + let args = TestArgs::parse_from([ + "foundry-cli", + "--mutate", + "--mutation-optimizer-runs", + "1", + "--mutation-via-ir", + "false", + ]); + let figment = figment::Figment::from(&args); + assert_eq!(figment.extract_inner::("mutation.optimizer_runs").unwrap(), 1); + assert!(!figment.extract_inner::("mutation.via_ir").unwrap()); + } + + #[test] + fn mutation_compiler_overrides_update_only_mutation_config_clone() { + let mut config = Config { + optimizer_runs: Some(999), + via_ir: true, + mutation: foundry_config::MutationConfig { + optimizer_runs: Some(1), + via_ir: Some(false), + ..Default::default() + }, + ..Default::default() + }; + + apply_mutation_compiler_overrides(&mut config); + + assert_eq!(config.optimizer_runs, Some(1)); + assert!(!config.via_ir); } #[test] - fn extract_chain() { - let test = |arg: &str, expected: Chain| { - let args = TestArgs::parse_from(["foundry-cli", arg]); - assert_eq!(args.evm.env.chain, Some(expected)); - let (config, evm_opts) = args.load_config_and_evm_opts().unwrap(); - assert_eq!(config.chain, Some(expected)); - assert_eq!(evm_opts.env.chain_id, Some(expected.id())); + fn mutation_optimizer_runs_normalize_default_optimizer_settings() { + let mut config = Config { + optimizer: Some(false), + optimizer_runs: Some(200), + mutation: foundry_config::MutationConfig { + optimizer_runs: Some(1), + ..Default::default() + }, + ..Default::default() }; - test("--chain-id=1", Chain::mainnet()); - test("--chain-id=42", Chain::from_id(42)); + + apply_mutation_compiler_overrides(&mut config); + + assert_eq!(config.optimizer, Some(true)); + assert_eq!(config.optimizer_runs, Some(1)); + } + + #[test] + fn auto_fuzz_corpus_defaults_to_cache_failure_layout() { + let mut args = TestArgs::parse_from(["foundry-cli"]); + args.enable_fuzz_only_with_auto_fuzz_corpus(); + let mut config = Config::default(); + + args.apply_test_config_overrides(&mut config); + + assert_eq!( + config.fuzz.corpus.corpus_dir, + Some(config.cache_path.join(AUTO_FUZZ_FAILURE_DIR).join(AUTO_CORPUS_DIR)) + ); + assert_eq!(config.invariant.corpus.corpus_dir, None); + } + + #[test] + fn auto_fuzz_corpus_uses_configured_failure_persist_dirs() { + let mut args = TestArgs::parse_from(["foundry-cli"]); + args.enable_fuzz_only_with_auto_fuzz_corpus(); + let mut config = Config::default(); + config.fuzz.failure_persist_dir = Some(PathBuf::from("custom_fuzz_failures")); + + args.apply_test_config_overrides(&mut config); + + assert_eq!( + config.fuzz.corpus.corpus_dir, + Some(PathBuf::from("custom_fuzz_failures").join(AUTO_CORPUS_DIR)) + ); + assert_eq!(config.invariant.corpus.corpus_dir, None); + } + + #[test] + fn auto_fuzz_corpus_preserves_configured_corpus_dirs() { + let mut args = TestArgs::parse_from(["foundry-cli"]); + args.enable_fuzz_only_with_auto_fuzz_corpus(); + let mut config = Config::default(); + config.fuzz.corpus.corpus_dir = Some(PathBuf::from("configured_fuzz_corpus")); + config.invariant.corpus.corpus_dir = Some(PathBuf::from("configured_invariant_corpus")); + + args.apply_test_config_overrides(&mut config); + + assert_eq!(config.fuzz.corpus.corpus_dir, Some(PathBuf::from("configured_fuzz_corpus"))); + assert_eq!( + config.invariant.corpus.corpus_dir, + Some(PathBuf::from("configured_invariant_corpus")) + ); + } + + #[test] + fn fuzz_only_does_not_enable_auto_fuzz_corpus() { + let mut args = TestArgs::parse_from(["foundry-cli"]); + args.enable_fuzz_only(); + let mut config = Config::default(); + + args.apply_test_config_overrides(&mut config); + + assert_eq!(config.fuzz.corpus.corpus_dir, None); + assert_eq!(config.invariant.corpus.corpus_dir, None); + } + + #[test] + fn debug_brutalize_includes_storage_layout_output() { + let args = TestArgs::parse_from(["foundry-cli", "--debug", "--brutalize"]); + let mut config = Config::default(); + + args.apply_test_config_overrides(&mut config); + + assert_eq!(config.extra_output, vec![ContractOutputSelection::StorageLayout]); + } + + #[test] + fn fuzz_and_invariant_config_flags() { + let args = TestArgs::parse_from([ + "foundry-cli", + "--fuzz-dictionary-weight", + "35", + "--fuzz-dictionary-addresses", + "max", + "--fuzz-dictionary-values", + "1234", + "--fuzz-dictionary-literals", + "4321", + "--fuzz-corpus-random-sequence-weight", + "55", + "--fuzz-corpus-dir", + "fuzz_corpus", + "--fuzz-frontier-dir", + "fuzz_frontiers", + "--fuzz-frontier-limit", + "7", + "--fuzz-payable-value-weight", + "12", + "--fuzz-mutation-weight-splice", + "4", + "--fuzz-mutation-weight-abi", + "3", + "--fuzz-mutation-weight-cmp", + "5", + "--symbolic-use-fuzz-frontiers", + "--symbolic-check-invariant-frontiers", + "--symbolic-frontier-limit", + "3", + "--symbolic-frontier-ids", + "4,9", + "--symbolic-frontier-pcs", + "123,456", + "--symbolic-frontier-selectors", + "0x12345678,deadbeef", + "--invariant-depth", + "300", + "--invariant-min-depth", + "20", + "--invariant-depth-mode", + "random", + "--invariant-workers", + "4", + "--invariant-dictionary-weight", + "45", + "--invariant-dictionary-addresses", + "8765", + "--invariant-dictionary-values", + "max", + "--invariant-dictionary-literals", + "6789", + "--invariant-corpus-random-sequence-weight", + "25", + "--invariant-corpus-dir", + "invariant_corpus", + "--invariant-payable-value-weight", + "34", + "--invariant-mutation-weight-splice", + "2", + "--invariant-mutation-weight-cmp", + "7", + ]); + + let config = Config::default().merge_inline_provider(&args).unwrap(); + assert_eq!(config.fuzz.dictionary.dictionary_weight, 35); + assert_eq!(config.fuzz.dictionary.max_fuzz_dictionary_addresses, usize::MAX); + assert_eq!(config.fuzz.dictionary.max_fuzz_dictionary_values, 1234); + assert_eq!(config.fuzz.dictionary.max_fuzz_dictionary_literals, 4321); + assert_eq!(config.fuzz.corpus.corpus_random_sequence_weight, 55); + assert_eq!(config.fuzz.corpus.corpus_dir, Some(PathBuf::from("fuzz_corpus"))); + assert_eq!(config.fuzz.corpus.frontier_dir, Some(PathBuf::from("fuzz_frontiers"))); + assert_eq!(config.fuzz.corpus.frontier_limit, 7); + assert_eq!(config.fuzz.corpus.payable_value_weight, 12); + assert_eq!(config.fuzz.corpus.mutation_weights.mutation_weight_splice, 4); + assert_eq!(config.fuzz.corpus.mutation_weights.mutation_weight_abi, 3); + assert_eq!(config.fuzz.corpus.mutation_weights.mutation_weight_cmp, 5); + assert!(config.symbolic.use_fuzz_frontiers); + assert!(config.symbolic.check_invariant_frontiers); + assert_eq!(config.symbolic.frontier_limit, 3); + assert_eq!(config.symbolic.frontier_ids, vec![4, 9]); + assert_eq!(config.symbolic.frontier_pcs, vec![123, 456]); + assert_eq!(config.symbolic.frontier_selectors, vec!["0x12345678", "deadbeef"]); + assert_eq!(config.invariant.depth, 300); + assert_eq!(config.invariant.min_depth, 20); + assert_eq!(config.invariant.depth_mode, InvariantDepthMode::Random); + assert_eq!(config.invariant.dictionary.dictionary_weight, 45); + assert_eq!(config.invariant.dictionary.max_fuzz_dictionary_addresses, 8765); + assert_eq!(config.invariant.dictionary.max_fuzz_dictionary_values, usize::MAX); + assert_eq!(config.invariant.dictionary.max_fuzz_dictionary_literals, 6789); + assert_eq!(config.invariant.corpus.corpus_random_sequence_weight, 25); + assert_eq!(config.invariant.corpus.corpus_dir, Some(PathBuf::from("invariant_corpus"))); + assert!(config.invariant.corpus_random_sequence_weight_configured); + assert_eq!( + config.invariant.workers, + InvariantWorkers::Fixed(std::num::NonZeroUsize::new(4).unwrap()) + ); + assert!(config.invariant.workers_configured); + assert_eq!(config.invariant.corpus.payable_value_weight, 34); + assert_eq!(config.invariant.corpus.mutation_weights.mutation_weight_splice, 2); + assert_eq!(config.invariant.corpus.mutation_weights.mutation_weight_cmp, 7); } } diff --git a/crates/forge/src/cmd/test/summary.rs b/crates/forge/src/cmd/test/summary.rs index 5b29877a42495..c689602363f5c 100644 --- a/crates/forge/src/cmd/test/summary.rs +++ b/crates/forge/src/cmd/test/summary.rs @@ -1,129 +1,109 @@ use crate::cmd::test::TestOutcome; -use comfy_table::{Cell, Color, Row, Table, modifiers::UTF8_ROUND_CORNERS}; -use foundry_common::reports::{ReportKind, report_kind}; +use comfy_table::{ + Cell, Color, Row, Table, + presets::{ASCII_FULL, ASCII_MARKDOWN}, +}; +use foundry_common::shell; use foundry_evm::executors::invariant::InvariantMetrics; use itertools::Itertools; use serde_json::json; use std::{collections::HashMap, fmt::Display}; /// Represents a test summary report. -pub struct TestSummaryReport { - /// The kind of report to generate. - report_kind: ReportKind, +pub struct TestSummaryReport<'a> { /// Whether the report should be detailed. is_detailed: bool, /// The test outcome to report. - outcome: TestOutcome, + outcome: &'a TestOutcome, } -impl TestSummaryReport { - pub fn new(is_detailed: bool, outcome: TestOutcome) -> Self { - Self { report_kind: report_kind(), is_detailed, outcome } +impl<'a> TestSummaryReport<'a> { + pub const fn new(is_detailed: bool, outcome: &'a TestOutcome) -> Self { + Self { is_detailed, outcome } } } -impl Display for TestSummaryReport { +impl Display for TestSummaryReport<'_> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { - match self.report_kind { - ReportKind::Text => { - writeln!(f, "\n{}", &self.format_table_output(&self.is_detailed, &self.outcome))?; - } - ReportKind::JSON => { - writeln!(f, "{}", &self.format_json_output(&self.is_detailed, &self.outcome))?; - } + if shell::is_json() { + writeln!(f, "{}", self.format_json_output()) + } else { + writeln!(f, "\n{}", self.format_table_output()) } - - Ok(()) } } -impl TestSummaryReport { - // Helper function to format the JSON output. - fn format_json_output(&self, is_detailed: &bool, outcome: &TestOutcome) -> String { - let output = json!({ - "results": outcome.results.iter().map(|(contract, suite)| { +impl TestSummaryReport<'_> { + fn format_json_output(&self) -> String { + let results = self + .outcome + .results + .iter() + .map(|(contract, suite)| { let (suite_path, suite_name) = contract.split_once(':').unwrap(); - let passed = suite.successes().count(); - let failed = suite.failures().count(); - let skipped = suite.skips().count(); let mut result = json!({ "suite": suite_name, - "passed": passed, - "failed": failed, - "skipped": skipped, + "passed": suite.successes().count(), + "failed": suite.failures().count(), + "skipped": suite.skipped(), }); - - if *is_detailed { - result["file_path"] = serde_json::Value::String(suite_path.to_string()); - result["duration"] = serde_json::Value::String(format!("{:.2?}", suite.duration)); + if self.is_detailed { + result["file_path"] = suite_path.into(); + result["duration"] = format!("{:.2?}", suite.duration).into(); } - result - }).collect::>(), - }); - - serde_json::to_string_pretty(&output).unwrap() + }) + .collect::>(); + serde_json::to_string_pretty(&json!({ "results": results })).unwrap() } - fn format_table_output(&self, is_detailed: &bool, outcome: &TestOutcome) -> Table { - let mut table = Table::new(); - table.apply_modifier(UTF8_ROUND_CORNERS); - + fn format_table_output(&self) -> Table { + let mut table = new_table(); let mut row = Row::from(vec![ Cell::new("Test Suite"), Cell::new("Passed").fg(Color::Green), Cell::new("Failed").fg(Color::Red), Cell::new("Skipped").fg(Color::Yellow), ]); - if *is_detailed { + if self.is_detailed { row.add_cell(Cell::new("File Path").fg(Color::Cyan)); row.add_cell(Cell::new("Duration").fg(Color::Cyan)); } table.set_header(row); - // Traverse the test_results vector and build the table - for (contract, suite) in &outcome.results { - let mut row = Row::new(); + for (contract, suite) in &self.outcome.results { let (suite_path, suite_name) = contract.split_once(':').unwrap(); - - let passed = suite.successes().count(); - let mut passed_cell = Cell::new(passed); - - let failed = suite.failures().count(); - let mut failed_cell = Cell::new(failed); - - let skipped = suite.skips().count(); - let mut skipped_cell = Cell::new(skipped); - - row.add_cell(Cell::new(suite_name)); - - if passed > 0 { - passed_cell = passed_cell.fg(Color::Green); - } - row.add_cell(passed_cell); - - if failed > 0 { - failed_cell = failed_cell.fg(Color::Red); - } - row.add_cell(failed_cell); - - if skipped > 0 { - skipped_cell = skipped_cell.fg(Color::Yellow); - } - row.add_cell(skipped_cell); - + let count_cell = |count: usize, color| { + let cell = Cell::new(count); + if count > 0 { cell.fg(color) } else { cell } + }; + let mut row = Row::from(vec![ + Cell::new(suite_name), + count_cell(suite.successes().count(), Color::Green), + count_cell(suite.failures().count(), Color::Red), + count_cell(suite.skipped(), Color::Yellow), + ]); if self.is_detailed { row.add_cell(Cell::new(suite_path)); - row.add_cell(Cell::new(format!("{:.2?}", suite.duration).to_string())); + row.add_cell(Cell::new(format!("{:.2?}", suite.duration))); } - table.add_row(row); } - table } } +/// Creates a table styled for the current shell output format. +fn new_table() -> Table { + let mut table = Table::new(); + if shell::is_markdown() { + table.load_style(ASCII_MARKDOWN); + } else { + table.load_style(ASCII_FULL.with_rounded_corners()); + } + table +} + /// Helper function to create the invariant metrics table. /// /// ╭-----------------------+----------------+-------+---------+----------╮ @@ -140,9 +120,7 @@ impl TestSummaryReport { pub(crate) fn format_invariant_metrics_table( test_metrics: &HashMap, ) -> Table { - let mut table = Table::new(); - table.apply_modifier(UTF8_ROUND_CORNERS); - + let mut table = new_table(); table.set_header(vec![ Cell::new("Contract"), Cell::new("Selector"), @@ -151,40 +129,21 @@ pub(crate) fn format_invariant_metrics_table( Cell::new("Discards").fg(Color::Yellow), ]); - for name in test_metrics.keys().sorted() { - if let Some((contract, selector)) = + let count_cell = + |count: usize, color| Cell::new(count).fg(if count > 0 { color } else { Color::White }); + for (name, metrics) in test_metrics.iter().sorted_by_key(|(name, _)| *name) { + let Some((contract, selector)) = name.split_once(':').map_or(name.as_str(), |(_, contract)| contract).split_once('.') - { - let mut row = Row::new(); - row.add_cell(Cell::new(contract)); - row.add_cell(Cell::new(selector)); - - if let Some(metrics) = test_metrics.get(name) { - let calls_cell = Cell::new(metrics.calls).fg(if metrics.calls > 0 { - Color::Green - } else { - Color::White - }); - - let reverts_cell = Cell::new(metrics.reverts).fg(if metrics.reverts > 0 { - Color::Red - } else { - Color::White - }); - - let discards_cell = Cell::new(metrics.discards).fg(if metrics.discards > 0 { - Color::Yellow - } else { - Color::White - }); - - row.add_cell(calls_cell); - row.add_cell(reverts_cell); - row.add_cell(discards_cell); - } - - table.add_row(row); - } + else { + continue; + }; + table.add_row(vec![ + Cell::new(contract), + Cell::new(selector), + count_cell(metrics.calls, Color::Green), + count_cell(metrics.reverts, Color::Red), + count_cell(metrics.discards, Color::Yellow), + ]); } table } diff --git a/crates/forge/src/cmd/tree.rs b/crates/forge/src/cmd/tree.rs index 30184af5d138e..93a55c3d94d1a 100644 --- a/crates/forge/src/cmd/tree.rs +++ b/crates/forge/src/cmd/tree.rs @@ -3,7 +3,7 @@ use eyre::Result; use foundry_cli::{opts::ProjectPathOpts, utils::LoadConfig}; use foundry_compilers::{ Graph, - resolver::{Charset, TreeOptions, parse::SolData}, + resolver::{Charset, TreeOptions}, }; /// CLI arguments for `forge tree`. @@ -27,8 +27,8 @@ foundry_config::impl_figment_convert!(TreeArgs, project_paths); impl TreeArgs { pub fn run(self) -> Result<()> { - let config = self.load_config()?; - let graph = Graph::::resolve(&config.project_paths())?; + let config = self.load_config_with_dependencies()?; + let graph = ::resolve(&config.project_paths())?; let opts = TreeOptions { charset: self.charset, no_dedupe: self.no_dedupe }; graph.print_with_options(opts); diff --git a/crates/forge/src/cmd/update.rs b/crates/forge/src/cmd/update.rs index dfb821aedc9d6..0a53124bc98b8 100644 --- a/crates/forge/src/cmd/update.rs +++ b/crates/forge/src/cmd/update.rs @@ -6,8 +6,9 @@ use foundry_cli::{ opts::Dependency, utils::{Git, LoadConfig}, }; +use foundry_common::fs::canonicalize_path; use foundry_config::{Config, impl_figment_convert_basic}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use yansi::Paint; /// CLI arguments for `forge update`. @@ -37,114 +38,174 @@ impl UpdateArgs { pub fn run(self) -> Result<()> { let config = self.load_config()?; // dep_overrides consists of absolute paths of dependencies and their tags - let (root, _paths, dep_overrides) = dependencies_paths(&self.dependencies, &config)?; + let (root, paths, dep_overrides) = dependencies_paths(&self.dependencies, &config)?; // Mapping of relative path of lib to its tag type // e.g "lib/forge-std" -> DepIdentifier::Tag { name: "v0.1.0", rev: "1234567" } let git = Git::new(&root); let mut foundry_lock = Lockfile::new(&config.root).with_git(&git); let out_of_sync_deps = foundry_lock.sync(config.install_lib_dir())?; + let submodules = git.submodules_in(Path::new(""))?; + if let Some(path) = foundry_lock + .iter() + .map(|(path, _)| path) + .filter(|path| !submodules.iter().any(|submodule| submodule.path() == *path)) + .min() + { + eyre::bail!( + "foundry.lock entry `{}` does not match an installed Git submodule", + path.display() + ); + } + + // Update selected branches without an explicit ref override. + for (path, dep_id) in foundry_lock.iter_mut() { + if dep_id.is_branch() + && (paths.is_empty() || paths.contains(path)) + && !dep_overrides.contains_key(&root.join(path)) + { + dep_id.mark_override(); + } + } - // update the submodules' tags if any overrides are present + // Update the submodules' tags if any overrides are present. let mut prev_dep_ids: DepMap = HashMap::default(); - if dep_overrides.is_empty() { - // running `forge update`, update all deps - foundry_lock.iter_mut().for_each(|(_path, dep_id)| { - // Set r#override flag to true if the dep is a branch + for (dep_path, override_tag) in &dep_overrides { + let rel_path = dep_path + .strip_prefix(&root) + .wrap_err("Dependency path is not relative to the repository root")?; + + if let Ok(mut dep_id) = DepIdentifier::resolve_type(&git, dep_path, override_tag) { + // Store the previous state before overriding + let prev = foundry_lock.get(rel_path).cloned(); + + // If it's a branch, mark it as overridden so it gets updated below if let DepIdentifier::Branch { .. } = dep_id { dep_id.mark_override(); } - }); - } else { - for (dep_path, override_tag) in &dep_overrides { - let rel_path = dep_path - .strip_prefix(&root) - .wrap_err("Dependency path is not relative to the repository root")?; - if let Ok(dep_id) = DepIdentifier::resolve_type(&git, dep_path, override_tag) { - let prev = foundry_lock.override_dep(rel_path, dep_id)?; + + // Update the lockfile + foundry_lock.override_dep(rel_path, dep_id)?; + + // Only track as updated if there was a previous dependency + if let Some(prev) = prev { prev_dep_ids.insert(rel_path.to_owned(), prev); - } else { - sh_warn!( - "Could not r#override submodule at {} with tag {}, try using forge install", - rel_path.display(), - override_tag - )?; } + } else { + sh_warn!( + "Could not r#override submodule at {} with tag {}, try using forge install", + rel_path.display(), + override_tag + )?; } } // fetch the latest changes for each submodule (recursively if flag is set) let git = Git::new(&root); - let update_paths = self.update_dep_paths(&foundry_lock); + let mut update_paths = self.update_dep_paths(&foundry_lock); + let initialize_nested = !update_paths.is_empty(); + // Include every selected dependency, including pins that need initialization or recursive + // updates. With no selection, preserve the update-all behavior. + if !paths.is_empty() { + update_paths.clone_from(&paths); + } trace!(?update_paths, "updating deps at"); - if self.recursive { - // update submodules recursively - git.submodule_update(self.force, true, false, true, update_paths)?; - } else { - let is_empty = update_paths.is_empty(); + git.submodule_update(self.force, true, false, self.recursive, update_paths)?; - // update submodules - git.submodule_update(self.force, true, false, false, update_paths)?; + if !self.recursive && initialize_nested && paths.is_empty() { + git.submodule_foreach(false, "git submodule update --init --progress --recursive")?; + } - if !is_empty { - // initialize submodules of each submodule recursively (otherwise direct submodule - // dependencies will revert to last commit) - git.submodule_foreach(false, "git submodule update --init --progress --recursive")?; + let canonical_root = canonicalize_path(&root)?; + let mut checkout_paths = foundry_lock + .iter() + .map(|(path, _)| path) + .filter(|path| paths.is_empty() || paths.contains(path)) + .collect::>(); + checkout_paths.sort(); + for path in checkout_paths { + let target = root.join(path); + let initialized = dunce::canonicalize(&target).is_ok_and(|target| { + target.starts_with(&canonical_root) + && Git::new(&target).is_repo_root().unwrap_or(false) + }); + if !initialized { + eyre::bail!( + "Dependency at `{}` is not an initialized Git submodule worktree", + path.display() + ); } } - // Branches should get updated to their latest commit on `forge update`. - // i.e if previously submodule was tracking branch `main` at rev `1234567` and now the - // remote `main` branch is at `7654321`, then submodule should also be updated to `7654321`. - // This tracking is automatically handled by git, but we need to update the lockfile entry - // to reflect the latest commit. - if dep_overrides.is_empty() { - let branch_overrides = foundry_lock - .iter_mut() - .filter_map(|(path, dep_id)| { - if dep_id.is_branch() && dep_id.overridden() { - return Some((path, dep_id)); - } - None - }) - .collect::>(); - - for (path, dep_id) in branch_overrides { - let (curr_rev, curr_branch) = git.current_rev_branch(&root.join(path))?; - let name = dep_id.name(); - // This can occur when the submodule is manually checked out to a different branch. - if curr_branch != name { - let warn_msg = format!( - r#"Lockfile sync warning - Lockfile is tracking branch {name} for submodule at {path:?}, but the submodule is currently on {curr_branch}. - Checking out branch {name} for submodule at {path:?}."#, - ); - let _ = sh_warn!("{}", warn_msg); - git.checkout_at(name, &root.join(path)).wrap_err(format!( - "Could not checkout branch {name} for submodule at {}", - path.display() - ))?; + // Update branches to their latest commit from origin + // This handles both explicit updates (forge update dep@branch) and + // general updates (forge update) for branch-tracked dependencies + let branch_overrides = foundry_lock + .iter_mut() + .filter_map(|(path, dep_id)| { + if dep_id.is_branch() && dep_id.overridden() { + return Some((path, dep_id)); } + None + }) + .collect::>(); - // Update the lockfile entry to reflect the latest commit - let prev = std::mem::replace( - dep_id, - DepIdentifier::Branch { - name: name.to_string(), - rev: curr_rev, - r#override: true, - }, - ); + for (path, dep_id) in branch_overrides { + let submodule_path = root.join(path); + let name = dep_id.name(); + + // Fetch and checkout the latest commit from the remote branch + git.fetch_and_checkout_branch(&submodule_path, name)?; + + // Now get the updated revision after syncing with origin + let (updated_rev, _) = git.current_rev_branch(&submodule_path)?; + + // Update the lockfile entry to reflect the latest commit + let prev = std::mem::replace( + dep_id, + DepIdentifier::Branch { + name: name.to_string(), + rev: updated_rev, + r#override: true, + }, + ); + + // Only insert if we don't already have a previous state for this path + // (e.g., from explicit overrides where we converted tag to branch) + if !prev_dep_ids.contains_key(path) { prev_dep_ids.insert(path.to_owned(), prev); } } // checkout the submodules at the correct tags + // Skip branches that were already updated above to avoid reverting to local branch for (path, dep_id) in foundry_lock.iter() { + // Ignore other dependencies if single update. + if !paths.is_empty() && !paths.contains(path) { + continue; + } + + // Skip branches that were already updated + if dep_id.is_branch() && dep_id.overridden() { + continue; + } git.checkout_at(dep_id.checkout_id(), &root.join(path))?; } + if !self.recursive && initialize_nested { + // Initialize nested submodules after restoring their selected parents' final revisions. + for path in &paths { + git.root(&root.join(path)).submodule_update( + false, + false, + false, + true, + std::iter::empty::(), + )?; + } + } + if out_of_sync_deps.is_some_and(|o| !o.is_empty()) || foundry_lock.iter().any(|(_, dep_id)| dep_id.overridden()) { @@ -154,7 +215,7 @@ impl UpdateArgs { // Print updates from => to for (path, prev) in prev_dep_ids { let curr = foundry_lock.get(&path).unwrap(); - sh_println!( + sh_status!( "Updated dep at '{}', (from: {prev}, to: {curr})", path.display().green(), prev = prev, @@ -171,7 +232,7 @@ impl UpdateArgs { .iter() .filter_map(|(path, dep_id)| { if dep_id.overridden() { - return Some(path.to_path_buf()); + return Some(path.clone()); } None }) @@ -206,7 +267,7 @@ pub fn dependencies_paths( .wrap_err("Library directory is not relative to the repository root")?; if let Some(tag) = &dep.tag { - overrides.insert(dep_path.to_owned(), tag.to_owned()); + overrides.insert(dep_path.clone(), tag.to_owned()); } paths.push(rel_path.to_owned()); } diff --git a/crates/forge/src/cmd/watch.rs b/crates/forge/src/cmd/watch.rs index 67aa70546b89a..0bf480650bba5 100644 --- a/crates/forge/src/cmd/watch.rs +++ b/crates/forge/src/cmd/watch.rs @@ -2,16 +2,19 @@ use super::{ build::BuildArgs, coverage::CoverageArgs, doc::DocArgs, fmt::FmtArgs, snapshot::GasSnapshotArgs, test::TestArgs, }; -use alloy_primitives::map::HashSet; -use clap::Parser; +use crate::opts::Forge; +use alloy_primitives::map::{HashMap, HashSet}; +use clap::{CommandFactory, Parser}; use eyre::Result; use foundry_cli::utils::{self, FoundryPathExt, LoadConfig}; use foundry_config::Config; use parking_lot::Mutex; use std::{ + ffi::{OsStr, OsString}, + io::IsTerminal, path::PathBuf, sync::{ - Arc, + Arc, OnceLock, Weak, atomic::{AtomicU8, Ordering}, }, time::Duration, @@ -25,13 +28,44 @@ use watchexec::{ paths::summarise_events_to_env, }; use watchexec_events::{ - Event, Priority, ProcessEnd, Tag, + Event, KeyCode, Keyboard, Priority, ProcessEnd, Tag, filekind::{AccessKind, FileEventKind}, }; use watchexec_signals::Signal; use yansi::{Color, Paint}; type SpawnHook = Arc; +type KeyboardConfig = Arc>>; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum KeyboardAction { + Rerun, + Quit, +} + +fn keyboard_action(events: &[Event]) -> Option { + let mut rerun = false; + + for tag in events.iter().flat_map(|event| &event.tags) { + match tag { + Tag::Keyboard(Keyboard::Eof) => return Some(KeyboardAction::Quit), + Tag::Keyboard(Keyboard::Key { key: KeyCode::Char('a'), modifiers }) + if modifiers.is_empty() => + { + rerun = true; + } + _ => {} + } + } + + rerun.then_some(KeyboardAction::Rerun) +} + +fn set_keyboard_events(config: &Option, enable: bool) { + if let Some(config) = config.as_ref().and_then(|config| config.get()).and_then(Weak::upgrade) { + config.keyboard_events(enable); + } +} #[derive(Clone, Debug, Default, Parser)] #[command(next_help_heading = "Watch options")] @@ -52,6 +86,13 @@ pub struct WatchArgs { #[arg(long)] pub run_all: bool, + /// Re-run only previously failed tests first when a change is made. + /// + /// If all previously failed tests pass, the full test suite will be run automatically. + /// This is particularly useful for TDD workflows where you want fast feedback on failures. + #[arg(long, alias = "rerun-failures")] + pub rerun_failed: bool, + /// File update debounce delay. /// /// During the delay, incoming change events are accumulated and @@ -78,25 +119,34 @@ impl WatchArgs { &self, default_paths: impl FnOnce() -> Result, ) -> Result { - self.watchexec_config_generic(default_paths, None) + self.watchexec_config_generic(default_paths, None, None) } - /// Creates a new [`watchexec::Config`] with a custom command spawn hook. + /// Creates a new [`watchexec::Config`] with a custom command spawn hook and optional keyboard + /// events while the command is idle. /// /// If paths were provided as arguments the these will be used as the watcher's pathset, /// otherwise the path the closure returns will be used. - pub fn watchexec_config_with_override, P: Into>( + fn watchexec_config_with_override, P: Into>( &self, default_paths: impl FnOnce() -> Result, + watch_keyboard: bool, spawn_hook: impl Fn(&[Event], &mut TokioCommand) + Send + Sync + 'static, - ) -> Result { - self.watchexec_config_generic(default_paths, Some(Arc::new(spawn_hook))) + ) -> Result<(watchexec::Config, Option)> { + let keyboard_config = watch_keyboard.then(|| Arc::new(OnceLock::new())); + let config = self.watchexec_config_generic( + default_paths, + Some(Arc::new(spawn_hook)), + keyboard_config.clone(), + )?; + Ok((config, keyboard_config)) } fn watchexec_config_generic, P: Into>( &self, default_paths: impl FnOnce() -> Result, spawn_hook: Option, + keyboard_config: Option, ) -> Result { let mut paths = self.watch.as_deref().unwrap_or_default(); let storage: Vec<_>; @@ -104,13 +154,14 @@ impl WatchArgs { storage = default_paths()?.into_iter().map(Into::into).filter(|p| p.exists()).collect(); paths = &storage; } - self.watchexec_config_inner(paths, spawn_hook) + self.watchexec_config_inner(paths, spawn_hook, keyboard_config) } fn watchexec_config_inner( &self, paths: &[PathBuf], spawn_hook: Option, + keyboard_config: Option, ) -> Result { let config = watchexec::Config::default(); @@ -124,8 +175,7 @@ impl WatchArgs { config.pathset(paths.iter().map(|p| p.as_path())); - let n_path_args = self.watch.as_deref().unwrap_or_default().len(); - let base_command = Arc::new(watch_command(cmd_args(n_path_args))); + let base_command = Arc::new(watch_command(cmd_args())?); let id = watchexec::Id::default(); let quit_again = Arc::new(AtomicU8::new(0)); @@ -171,18 +221,25 @@ impl WatchArgs { }; let signals = action.signals().collect::>(); + let keyboard_action = keyboard_action(&action.events); - if signals.contains(&Signal::Terminate) || signals.contains(&Signal::Interrupt) { + if signals.contains(&Signal::Terminate) + || signals.contains(&Signal::Interrupt) + || keyboard_action == Some(KeyboardAction::Quit) + { return quit(action); } - // Only filesystem events below here (or empty synthetic events). - if action.paths().next().is_none() && !action.events.iter().any(|e| e.is_empty()) { - debug!("no filesystem or synthetic events, skip without doing more"); + // Only filesystem, keyboard rerun, or empty synthetic events below here. + if action.paths().next().is_none() + && keyboard_action != Some(KeyboardAction::Rerun) + && !action.events.iter().any(|e| e.is_empty()) + { + debug!("no filesystem, rerun, or synthetic events, skip without doing more"); return action; } - if cfg!(target_os = "linux") { + if cfg!(target_os = "linux") && keyboard_action != Some(KeyboardAction::Rerun) { // Reading a file now triggers `Access(Open)` events on Linux due to: // https://github.com/notify-rs/notify/pull/612 // This causes an infinite rebuild loop: the build reads a file, @@ -194,14 +251,13 @@ impl WatchArgs { if e.is_empty() { has_synthetic_events = true; break; - } else { - for tag in &e.tags { - if let Tag::FileEventKind(kind) = tag - && !matches!(kind, FileEventKind::Access(AccessKind::Open(_))) { - has_file_events = true; - break 'outer; - } - } + } + for tag in &e.tags { + if let Tag::FileEventKind(kind) = tag + && !matches!(kind, FileEventKind::Access(AccessKind::Open(_))) { + has_file_events = true; + break 'outer; + } } } if !has_file_events && !has_synthetic_events { @@ -210,8 +266,13 @@ impl WatchArgs { } } + // Let the child own stdin while it runs. This keeps prompts and the debugger from + // racing Watchexec's keyboard event source for terminal input. + set_keyboard_events(&keyboard_config, false); + job.run({ let job = job.clone(); + let keyboard_config = keyboard_config.clone(); move |context| { if context.current.is_running() && no_restart { return; @@ -221,7 +282,7 @@ impl WatchArgs { let job = job.clone(); move |context| { clear_screen(); - setup_process(job, &context.command) + setup_process(job, &context.command, keyboard_config) } }); } @@ -234,14 +295,14 @@ impl WatchArgs { } } -fn setup_process(job: Job, _command: &Command) { +fn setup_process(job: Job, _command: &Command, keyboard_config: Option) { tokio::spawn(async move { job.to_wait().await; - job.run(move |context| end_of_process(context.current)); + job.run(move |context| end_of_process(context.current, keyboard_config)); }); } -fn end_of_process(state: &CommandState) { +fn end_of_process(state: &CommandState, keyboard_config: Option) { let CommandState::Finished { status, started, finished } = state else { return; }; @@ -263,6 +324,7 @@ fn end_of_process(state: &CommandState) { }; let quiet = false; + set_keyboard_events(&keyboard_config, true); if !quiet { let _ = sh_eprintln!("{}", format!("[{msg}]").paint(fg.foreground())); } @@ -270,7 +332,17 @@ fn end_of_process(state: &CommandState) { /// Runs the given [`watchexec::Config`]. pub async fn run(config: watchexec::Config) -> Result<()> { + run_inner(config, None).await +} + +async fn run_inner( + config: watchexec::Config, + keyboard_config: Option, +) -> Result<()> { let wx = Watchexec::with_config(config)?; + if let Some(config) = keyboard_config { + debug_assert!(config.set(Arc::downgrade(&wx.config)).is_ok()); + } wx.send_event(Event::default(), Priority::Urgent).await?; wx.main().await??; Ok(()) @@ -303,9 +375,29 @@ pub async fn watch_test(args: TestArgs) -> Result<()> { let last_test_files = Mutex::new(HashSet::::default()); let project_root = config.root.to_string_lossy().into_owned(); - let config = args.watch.watchexec_config_with_override( + let test_failures_file = config.test_failures_file.clone(); + let rerun_failed = args.watch.rerun_failed; + let watch_keyboard = std::io::stdin().is_terminal(); + + let (config, keyboard_config) = args.watch.watchexec_config_with_override( || Ok([&config.test, &config.src]), + watch_keyboard, move |events, command| { + if keyboard_action(events) == Some(KeyboardAction::Rerun) { + return; + } + + // Check if we should prioritize rerunning failed tests + let has_failures = rerun_failed && test_failures_file.exists(); + + if has_failures { + // Smart mode: rerun failed tests first + trace!("Smart watch mode: will rerun failed tests first"); + command.arg("--rerun"); + // Don't add file-specific filters when rerunning failures + return; + } + let mut changed_sol_test_files: HashSet<_> = events .iter() .flat_map(|e| e.paths()) @@ -345,10 +437,17 @@ pub async fn watch_test(args: TestArgs) -> Result<()> { } }, )?; - run(config).await + + if watch_keyboard { + let _ = sh_eprintln!("[Press 'a' to rerun all tests]"); + } + + run_inner(config, keyboard_config).await } pub async fn watch_coverage(args: CoverageArgs) -> Result<()> { + args.ensure_mode_compatible()?; + let config = args.watch().watchexec_config(|| { let config = args.load_config()?; Ok([config.test, config.src]) @@ -364,11 +463,66 @@ pub async fn watch_fmt(args: FmtArgs) -> Result<()> { run(config).await } -/// Executes a [`Watchexec`] that listens for changes in the project's sources directory +/// Executes a [`Watchexec`] that listens for changes affecting the generated +/// documentation: project sources, optionally library sources, the README that +/// becomes the homepage, the deployments directory, and the foundry config file +/// itself. Without these, the live preview goes silently stale on common edits. pub async fn watch_doc(args: DocArgs) -> Result<()> { + let include_libraries = args.include_libraries; + let deployments_arg = args.deployments.clone(); let config = args.watch.watchexec_config(|| { let config = args.config()?; - Ok([config.src]) + let root = config.root.clone(); + + let mut paths = Vec::new(); + + // Solidity sources. + paths.push(config.src.clone()); + + // External libraries when explicitly opted in. + if include_libraries { + for lib in &config.libs { + paths.push(lib.clone()); + } + } + + // Homepage source: explicit override, else /README.md when present. + if let Some(hp) = &config.doc.homepage { + let hp_path = if hp.is_absolute() { hp.clone() } else { root.join(hp) }; + if hp_path.exists() { + paths.push(hp_path); + } + } + // Mirror vocs homepage resolution: /README.md takes priority over + // /README.md. + let src_readme = config.src.join("README.md"); + if src_readme.exists() { + paths.push(src_readme); + } + let readme = root.join("README.md"); + if readme.exists() { + paths.push(readme); + } + + // Deployments directory: only when the user enabled `--deployments`. + if let Some(dir_opt) = deployments_arg.as_ref() { + let dep_dir = match dir_opt { + Some(p) if p.is_absolute() => p.clone(), + Some(p) => root.join(p), + None => root.join("deployments"), + }; + if dep_dir.exists() { + paths.push(dep_dir); + } + } + + // Foundry config file (`foundry.toml`), when present. + let toml = root.join("foundry.toml"); + if toml.exists() { + paths.push(toml); + } + + Ok(paths) })?; run(config).await } @@ -380,59 +534,373 @@ pub async fn watch_doc(args: DocArgs) -> Result<()> { /// # Panics /// /// Panics if `args` is empty. -fn watch_command(mut args: Vec) -> Command { +fn watch_command(mut args: Vec) -> Result { debug_assert!(!args.is_empty()); - let prog = args.remove(0); - Command { program: Program::Exec { prog: prog.into(), args }, options: Default::default() } + let prog = PathBuf::from(args.remove(0)); + let args = args + .into_iter() + .map(|arg| { + arg.into_string().map_err(|arg| { + eyre::eyre!( + "watchexec requires UTF-8 command arguments; {arg:?} is not valid UTF-8" + ) + }) + }) + .collect::>>()?; + Ok(Command { program: Program::Exec { prog, args }, options: Default::default() }) +} + +/// Returns the env args without the `--watch` flag from the args for the Watchexec command. +/// +/// `args_os` is used instead of `args` because the latter panics on an argument that is not valid +/// Unicode, and a watched path is a `PathBuf`, which is allowed to be non-Unicode on Unix. +fn cmd_args() -> Vec { + clean_cmd_args(std::env::args_os().collect()) } -/// Returns the env args without the `--watch` flag from the args for the Watchexec command -fn cmd_args(num: usize) -> Vec { - clean_cmd_args(num, std::env::args().collect()) +/// How a command-line argument relates to the `watch` flag. +enum WatchFlag { + /// Not the `watch` flag. + Other, + /// `--watch`, `-w`, or a short flag cluster ending in `w`, with the paths given as the + /// arguments that follow it. Holds the valueless short flags leading up to `w`, which are kept. + Bare(Option), + /// `--watch=`, `-w`, or a short flag cluster with the path attached to its `w`. + /// Holds the valueless short flags leading up to `w`, which are kept. + Inline(Option), } +/// Removes the `--watch` flag from the args for the Watchexec command, so that the child command +/// does not enter watch mode itself and spawn another watcher. +/// +/// `watch` is an `Option>`, so clap accepts the flag as `--watch `, +/// `--watch=`, `-w `, and `-w`, combines it with other short flags (`-vw`, +/// `-vw`), and allows it to repeat, appending the paths of every occurrence. #[instrument(level = "debug", ret)] -fn clean_cmd_args(num: usize, mut cmd_args: Vec) -> Vec { - if let Some(pos) = cmd_args.iter().position(|arg| arg == "--watch" || arg == "-w") { - cmd_args.drain(pos..=(pos + num)); - } +fn clean_cmd_args(cmd_args: Vec) -> Vec { + let mut cleaned = Vec::with_capacity(cmd_args.len()); + let (value_less_short_flags, variable_options) = command_options(&cmd_args); + let mut args = cmd_args.into_iter().peekable(); + cleaned.extend(args.next()); + let mut deferred = Vec::new(); + + while let Some(arg) = args.next() { + if arg == "--" { + cleaned.append(&mut deferred); + cleaned.push(arg); + cleaned.extend(args); + break; + } - // There's another edge case where short flags are combined into one which is supported by clap, - // like `-vw` for verbosity and watch - // this removes any `w` from concatenated short flags - if let Some(pos) = cmd_args.iter().position(|arg| { - fn contains_w_in_short(arg: &str) -> Option { - let mut iter = arg.chars().peekable(); - if *iter.peek()? != '-' { - return None; + match watch_flag(&arg, &value_less_short_flags) { + WatchFlag::Other => { + let option = arg.as_encoded_bytes().strip_prefix(b"--").map(|bytes| { + let end = bytes.iter().position(|&byte| byte == b'=').unwrap_or(bytes.len()); + (&bytes[..end], end < bytes.len()) + }); + if let Some((name, attached)) = option + && let Ok(name) = std::str::from_utf8(name) + && let Some(&max_values) = variable_options.get(name) + { + // Keep greedy options and their original values together at the end, so + // removing watch cannot make them consume a previously positional argument. + deferred.push(arg); + if !attached { + for _ in 0..max_values { + if !args.peek().is_some_and(|next| !looks_like_option(next)) { + break; + } + deferred.extend(args.next()); + } + } + } else { + cleaned.push(arg); + } } - iter.next(); - if *iter.peek()? == '-' { - return None; + WatchFlag::Inline(kept) => cleaned.extend(kept), + WatchFlag::Bare(kept) => { + cleaned.extend(kept); + // clap consumes the values of a `num_args(0..)` argument greedily, up to the next + // option-like argument, so the paths follow the flag until one of those. + while args.peek().is_some_and(|next| !looks_like_option(next)) { + args.next(); + } } - Some(iter.any(|c| c == 'w')) - } - contains_w_in_short(arg).unwrap_or(false) - }) { - let clean_arg = cmd_args[pos].replace('w', ""); - if clean_arg == "-" { - cmd_args.remove(pos); - } else { - cmd_args[pos] = clean_arg; } } - cmd_args + cleaned.extend(deferred); + cleaned +} + +/// Returns how `arg` relates to the `watch` flag. +fn watch_flag(arg: &OsStr, value_less_short_flags: &HashSet) -> WatchFlag { + let bytes = arg.as_encoded_bytes(); + if bytes == b"--watch" || bytes == b"-w" { + return WatchFlag::Bare(None); + } + if bytes.starts_with(b"--watch=") { + return WatchFlag::Inline(None); + } + let Some(pos) = short_watch_pos(bytes, value_less_short_flags) else { + return WatchFlag::Other; + }; + // Everything after `w` is the path attached to it; only the preceding valueless short flags + // are kept. The prefix is ASCII because it consists only of recognized option bytes. + let kept = (pos > 1).then(|| OsString::from(std::str::from_utf8(&bytes[..pos]).unwrap())); + if bytes.len() == pos + 1 { WatchFlag::Bare(kept) } else { WatchFlag::Inline(kept) } +} + +/// Returns the byte index after `w` in a short flag cluster, if `arg` contains a watch flag. +/// +/// clap parses a short flag cluster from left to right. Only short flags with no value in the +/// active command may precede `w`; a value-taking flag such as `-D` or `-C` makes the remaining +/// bytes its value instead. +fn short_watch_pos(arg: &[u8], value_less_short_flags: &HashSet) -> Option { + let cluster = arg.strip_prefix(b"-")?; + if cluster.is_empty() || cluster.starts_with(b"-") { + return None; + } + let pos = cluster.iter().position(|&byte| byte == b'w')?; + cluster[..pos] + .iter() + .all(|&byte| byte.is_ascii() && value_less_short_flags.contains(&(byte as char))) + .then_some(pos + 1) +} + +/// Returns whether `arg` looks like an option, which is where clap stops consuming the values of a +/// `num_args(0..)` argument. A lone `-` is a value. +fn looks_like_option(arg: &OsStr) -> bool { + let bytes = arg.as_encoded_bytes(); + bytes.len() > 1 && bytes.starts_with(b"-") +} + +/// Returns valueless short flags and variable-arity long options for the active command. +fn command_options(cmd_args: &[OsString]) -> (HashSet, HashMap) { + let mut command = Forge::command(); + let mut flags = HashSet::default(); + let mut variable_options = HashMap::default(); + if let Ok(matches) = command.try_get_matches_from_mut(cmd_args) + && let Some((name, _)) = matches.subcommand() + && let Some(command) = command.find_subcommand(name) + { + // Parsing builds the selected subcommand, including inherited global arguments. + for arg in command.get_arguments() { + let range = arg.get_num_args().unwrap_or_default(); + if range.max_values() == 0 { + flags.extend(arg.get_short()); + flags.extend(arg.get_all_short_aliases().unwrap_or_default()); + } else if arg.get_id() != "watch" + && range.min_values() != range.max_values() + && let Some(long) = arg.get_long() + { + variable_options.insert(long.to_owned(), range.max_values()); + for alias in arg.get_all_aliases().unwrap_or_default() { + variable_options.insert(alias.to_owned(), range.max_values()); + } + } + } + } + (flags, variable_options) } #[cfg(test)] mod tests { use super::*; + use watchexec_events::Modifiers; + + #[cfg(unix)] + use std::os::unix::ffi::OsStrExt; + + fn key_event(key: char, modifiers: Modifiers) -> Event { + Event { + tags: vec![Tag::Keyboard(Keyboard::Key { key: KeyCode::Char(key), modifiers })], + ..Default::default() + } + } + + fn clean(args: &[&str]) -> Vec { + clean_cmd_args(args.iter().copied().map(OsString::from).collect()) + } + + #[test] + fn classifies_keyboard_actions() { + assert_eq!( + keyboard_action(&[key_event('a', Modifiers::default())]), + Some(KeyboardAction::Rerun) + ); + assert_eq!(keyboard_action(&[key_event('x', Modifiers::default())]), None); + assert_eq!( + keyboard_action(&[key_event('a', Modifiers { ctrl: true, ..Default::default() })]), + None + ); + + let eof = Event { tags: vec![Tag::Keyboard(Keyboard::Eof)], ..Default::default() }; + assert_eq!( + keyboard_action(&[key_event('a', Modifiers::default()), eof]), + Some(KeyboardAction::Quit) + ); + } + + #[test] + fn locked_survives_cleaning_watch_args() { + assert_eq!( + clean(&["forge", "build", "--locked", "--watch", "src", "test"]), + ["forge", "build", "--locked"] + ); + + assert_eq!( + clean(&["forge", "build", "--watch", "src", "--locked"]), + ["forge", "build", "--locked"] + ); + } + + #[test] + fn watch_spellings_survive_cleaning_watch_args() { + assert_eq!(clean(&["forge", "build", "-vw"]), ["forge", "build", "-v"]); + assert_eq!(clean(&["forge", "test", "--watch", "src"]), ["forge", "test"]); + assert_eq!(clean(&["forge", "test", "--watch=src"]), ["forge", "test"]); + assert_eq!(clean(&["forge", "test", "-w", "src"]), ["forge", "test"]); + assert_eq!(clean(&["forge", "test", "-wsrc"]), ["forge", "test"]); + assert_eq!(clean(&["forge", "test", "-vw", "src"]), ["forge", "test", "-v"]); + assert_eq!(clean(&["forge", "test", "-vwsrc"]), ["forge", "test", "-v"]); + } + + #[test] + fn repeated_watch_survives_cleaning_watch_args() { + assert_eq!( + clean(&["forge", "test", "--watch", "src", "--watch", "test"]), + ["forge", "test"] + ); + + assert_eq!(clean(&["forge", "test", "--watch=src", "-w"]), ["forge", "test"]); + assert_eq!(clean(&["forge", "test", "-w", "--watch=src"]), ["forge", "test"]); + } + + #[test] + fn other_flags_survive_cleaning_watch_args() { + // `--watch-delay` is a different flag. + assert_eq!( + clean(&["forge", "test", "--watch-delay", "1s"]), + ["forge", "test", "--watch-delay", "1s"] + ); + + // A `w` in the value attached to another short flag is not the watch flag. + assert_eq!(clean(&["forge", "test", "-C./workspace"]), ["forge", "test", "-C./workspace"]); + + assert_eq!(clean(&["forge", "build", "-Dwarnings"]), ["forge", "build", "-Dwarnings"]); + } + + #[test] + fn command_specific_short_flags_survive_cleaning_watch_args() { + assert_eq!(clean(&["forge", "build", "-qw", "src"]), ["forge", "build", "-q"]); + assert_eq!(clean(&["forge", "fmt", "-rw", "src"]), ["forge", "fmt", "-r"]); + assert_eq!(clean(&["forge", "b", "-vw"]), ["forge", "b", "-v"]); + } + + #[test] + fn watch_cleanup_stops_after_double_dash() { + assert_eq!( + clean(&["forge", "fmt", "--watch", "src", "--", "--watch=keep.sol"]), + ["forge", "fmt", "--", "--watch=keep.sol"] + ); + } + + #[cfg(unix)] + #[test] + fn non_unicode_watch_survives_cleaning_watch_args() { + let mut args = ["forge", "test", "--watch"].map(OsString::from).to_vec(); + args.push(OsStr::from_bytes(b"src/\xff").to_os_string()); + + assert_eq!(clean_cmd_args(args), ["forge", "test"]); + } + + #[cfg(unix)] + #[test] + fn non_unicode_watch_suffixes_and_options_are_preserved() { + for path in [b"--watch=src/\xff".as_slice(), b"-wsrc/\xff".as_slice()] { + let args = ["forge", "fmt"] + .map(OsString::from) + .into_iter() + .chain([OsStr::from_bytes(path).to_os_string()]) + .collect(); + assert_eq!(clean_cmd_args(args), ["forge", "fmt"]); + } + + let args = ["forge", "build", "-w", "src"] + .map(OsString::from) + .into_iter() + .chain([OsStr::from_bytes(b"--out=out/\xff").to_os_string()]) + .collect(); + assert_eq!( + clean_cmd_args(args), + vec![ + OsString::from("forge"), + OsString::from("build"), + OsStr::from_bytes(b"--out=out/\xff").to_os_string(), + ] + ); + } + + #[cfg(unix)] + #[test] + fn non_unicode_program_path_is_preserved() { + let args = vec![OsStr::from_bytes(b"forge\xff").to_os_string(), OsString::from("build")]; + let command = watch_command(args).unwrap(); + let Program::Exec { prog, args } = command.program else { panic!("expected exec program") }; + assert_eq!(prog, PathBuf::from(OsStr::from_bytes(b"forge\xff"))); + assert_eq!(args, ["build"]); + } + + #[test] + fn watch_cleanup_preserves_argument_values() { + for args in [ + vec!["forge", "build", "--optimize", "--watch=src", "src/Counter.sol", "--sizes"], + vec!["forge", "snapshot", "--check", "--watch=src", "test/Counter.t.sol"], + vec!["forge", "snapshot", "--optimize", "--check", "-wsrc", "test/Counter.t.sol"], + vec![ + "forge", + "snapshot", + "--check", + "--watch=src", + "test/Counter.t.sol", + "--snap", + "custom", + ], + vec!["forge", "build", "--skip", "test", "script", "--watch=src", "src/Counter.sol"], + vec![ + "forge", + "build", + "--skip", + "test", + "--watch=src", + "src/Counter.sol", + "--skip=script", + ], + vec!["forge", "build", "--optimize", "--watch=src", "--", "src/Counter.sol"], + ] { + let mut command = Forge::command(); + let parent = command.try_get_matches_from_mut(&args).unwrap(); + let child = Forge::command().try_get_matches_from(clean(&args)).unwrap(); + let (name, parent) = parent.subcommand().unwrap(); + let (_, child) = child.subcommand().unwrap(); + assert!(child.get_raw("watch").is_none(), "{args:?}"); + for arg in command.find_subcommand(name).unwrap().get_arguments() { + let id = arg.get_id(); + if id == "watch" { + continue; + } + assert_eq!( + parent.get_raw(id.as_str()).map(|values| values.collect::>()), + child.get_raw(id.as_str()).map(|values| values.collect::>()), + "{args:?}: {id}" + ); + } + } + } #[test] - fn parse_cmd_args() { - let args = vec!["-vw".to_string()]; - let cleaned = clean_cmd_args(0, args); - assert_eq!(cleaned, vec!["-v".to_string()]); + fn watch_cleanup_preserves_program_name() { + assert_eq!(clean(&["--watch=forge", "fmt", "--watch", "src"]), ["--watch=forge", "fmt"]); } } diff --git a/crates/forge/src/coverage.rs b/crates/forge/src/coverage.rs index b75514cabe329..f92b889049e91 100644 --- a/crates/forge/src/coverage.rs +++ b/crates/forge/src/coverage.rs @@ -1,12 +1,17 @@ //! Coverage reports. +use crate::result::{TestKind, TestOutcome, TestResult, TestStatus}; use alloy_primitives::map::{HashMap, HashSet}; -use comfy_table::{Attribute, Cell, Color, Row, Table, modifiers::UTF8_ROUND_CORNERS}; +use comfy_table::{ + Attribute, Cell, Color, Row, Table, + presets::{ASCII_FULL, ASCII_MARKDOWN}, +}; use evm_disassembler::disassemble_bytes; -use foundry_common::fs; +use foundry_common::{fs, shell}; use semver::Version; +use serde::{Serialize, ser::SerializeSeq}; use std::{ - collections::hash_map, + collections::{BTreeMap, hash_map}, io::Write, path::{Path, PathBuf}, }; @@ -15,6 +20,9 @@ pub use foundry_evm::coverage::*; /// A coverage reporter. pub trait CoverageReporter { + /// Returns a debug string for the reporter. + fn name(&self) -> &'static str; + /// Returns `true` if the reporter needs source maps for the final report. fn needs_source_maps(&self) -> bool { false @@ -35,7 +43,11 @@ pub struct CoverageSummaryReporter { impl Default for CoverageSummaryReporter { fn default() -> Self { let mut table = Table::new(); - table.apply_modifier(UTF8_ROUND_CORNERS); + if shell::is_markdown() { + table.load_style(ASCII_MARKDOWN); + } else { + table.load_style(ASCII_FULL.with_rounded_corners()); + } table.set_header(vec![ Cell::new("File"), @@ -45,7 +57,7 @@ impl Default for CoverageSummaryReporter { Cell::new("% Funcs"), ]); - Self { table, total: CoverageSummary::default() } + Self { table, total: CoverageSummary::new() } } } @@ -62,6 +74,10 @@ impl CoverageSummaryReporter { } impl CoverageReporter for CoverageSummaryReporter { + fn name(&self) -> &'static str { + "summary" + } + fn report(&mut self, report: &CoverageReport) -> eyre::Result<()> { for (path, summary) in report.summary_by_file() { self.total.merge(&summary); @@ -75,20 +91,18 @@ impl CoverageReporter for CoverageSummaryReporter { } fn format_cell(hits: usize, total: usize) -> Cell { - let percentage = if total == 0 { 1. } else { hits as f64 / total as f64 }; - - let mut cell = - Cell::new(format!("{:.2}% ({hits}/{total})", percentage * 100.)).fg(match percentage { - _ if total == 0 => Color::Grey, - _ if percentage < 0.5 => Color::Red, - _ if percentage < 0.75 => Color::Yellow, - _ => Color::Green, - }); - if total == 0 { - cell = cell.add_attribute(Attribute::Dim); + return Cell::new(format!("N/A ({hits}/{total})")) + .fg(Color::Grey) + .add_attribute(Attribute::Dim); } - cell + + let percentage = hits as f64 / total as f64; + Cell::new(format!("{:.2}% ({hits}/{total})", percentage * 100.)).fg(match percentage { + _ if percentage < 0.5 => Color::Red, + _ if percentage < 0.75 => Color::Yellow, + _ => Color::Green, + }) } /// Writes the coverage report in [LCOV]'s [tracefile format]. @@ -102,22 +116,39 @@ pub struct LcovReporter { impl LcovReporter { /// Create a new LCOV reporter. - pub fn new(path: PathBuf, version: Version) -> Self { + pub const fn new(path: PathBuf, version: Version) -> Self { Self { path, version } } } impl CoverageReporter for LcovReporter { + fn name(&self) -> &'static str { + "lcov" + } + fn report(&mut self, report: &CoverageReport) -> eyre::Result<()> { let mut out = std::io::BufWriter::new(fs::create_file(&self.path)?); let mut fn_index = 0usize; for (path, items) in report.items_by_file() { - let summary = CoverageSummary::from_items(items.iter().copied()); + let summary = CoverageSummary::from_items(&items); writeln!(out, "TN:")?; writeln!(out, "SF:{}", path.display())?; + // First pass: collect line hits for DA records. + // Track both which lines have been recorded and the max hits per line. + let mut line_hits: HashMap = HashMap::default(); + for item in &items { + if matches!(item.kind, CoverageItemKind::Line | CoverageItemKind::Statement) { + let line = item.loc.lines.start; + line_hits + .entry(line) + .and_modify(|h| *h = (*h).max(item.hits)) + .or_insert(item.hits); + } + } + let mut recorded_lines = HashSet::new(); for item in items { @@ -127,7 +158,13 @@ impl CoverageReporter for LcovReporter { let hits = item.hits; match item.kind { CoverageItemKind::Function { ref name } => { - let name = format!("{}.{name}", item.loc.contract_name); + // Free (file-level) functions have no contract scope; emit the bare + // name rather than a leading-dot `.name`. + let name = if item.loc.contract_name.is_empty() { + name.to_string() + } else { + format!("{}.{name}", item.loc.contract_name) + }; if self.version >= Version::new(2, 2, 0) { // v2.2 changed the FN format. writeln!(out, "FNL:{fn_index},{line},{end_line}")?; @@ -143,18 +180,26 @@ impl CoverageReporter for LcovReporter { } } // Add lines / statement hits only once. - CoverageItemKind::Line | CoverageItemKind::Statement => { - if recorded_lines.insert(line) { - writeln!(out, "DA:{line},{hits}")?; - } + CoverageItemKind::Line | CoverageItemKind::Statement + if recorded_lines.insert(line) => + { + writeln!(out, "DA:{line},{}", line_hits[&line])?; } CoverageItemKind::Branch { branch_id, path_id, .. } => { - writeln!( - out, - "BRDA:{line},{branch_id},{path_id},{}", - if hits == 0 { "-".to_string() } else { hits.to_string() } - )?; + // Per LCOV spec: "-" means the expression was never evaluated (line not + // executed), "0" means branch exists but was never taken. + // Check if the line containing this branch was hit. + let line_was_hit = line_hits.get(&line).is_some_and(|&h| h > 0); + let hits_str = if hits > 0 { + hits.to_string() + } else if line_was_hit { + "0".to_string() + } else { + "-".to_string() + }; + writeln!(out, "BRDA:{line},{branch_id},{path_id},{hits_str}")?; } + _ => {} } } @@ -180,43 +225,271 @@ impl CoverageReporter for LcovReporter { } } +/// Writes per-test coverage attribution as JSON. +pub struct CoverageAttributionReporter { + path: PathBuf, +} + +/// A hit map resolved to the contract coverage metadata it belongs to. +pub struct ResolvedHitMap { + pub contract_id: ContractId, + pub is_deployed_code: bool, +} + +pub type ResolvedHitMaps = alloy_primitives::map::B256HashMap; + +impl CoverageAttributionReporter { + /// Create a new attribution reporter. + pub const fn new(path: PathBuf) -> Self { + Self { path } + } + + /// Writes per-test coverage attribution for the provided outcome. + pub fn report( + &self, + report: &CoverageReport, + outcome: &TestOutcome, + resolved_hit_maps: &ResolvedHitMaps, + ) -> eyre::Result<()> { + let payload = AttributionReport { + version: 1, + tests: AttributionTests { report, outcome, resolved_hit_maps }, + }; + let mut out = std::io::BufWriter::new(fs::create_file(&self.path)?); + serde_json::to_writer(&mut out, &payload)?; + writeln!(out)?; + out.flush()?; + + sh_println!("Wrote coverage attribution report.")?; + + Ok(()) + } +} + +/// Top-level JSON payload for per-test coverage attribution. +#[derive(Serialize)] +struct AttributionReport<'a> { + version: u8, + tests: AttributionTests<'a>, +} + +/// Coverage attributed to a single executed test. +#[derive(Serialize)] +struct AttributionTest { + suite: String, + test: String, + status: &'static str, + kind: &'static str, + covered: Vec, +} + +/// A source range covered by a test, with hit counts and item metadata. +#[derive(Serialize)] +struct AttributionItem { + source: String, + contract: String, + kind: &'static str, + /// The start of a 1-based, half-open line range. + line_start: u32, + /// The end of a 1-based, half-open line range. + line_end: u32, + /// The start of a 0-based, half-open byte range. + byte_start: u32, + /// The end of a 0-based, half-open byte range. + byte_end: u32, + hits: u32, + #[serde(skip_serializing_if = "Option::is_none")] + function: Option, + #[serde(skip_serializing_if = "Option::is_none")] + branch_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + path_id: Option, +} + +/// Serializer state for streaming attribution entries from test results. +struct AttributionTests<'a> { + report: &'a CoverageReport, + outcome: &'a TestOutcome, + resolved_hit_maps: &'a ResolvedHitMaps, +} + +impl Serialize for AttributionTests<'_> { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let len = self.outcome.results.values().map(|suite| suite.test_results.len()).sum(); + let mut seq = serializer.serialize_seq(Some(len))?; + + for (suite, suite_result) in &self.outcome.results { + for (test, result) in &suite_result.test_results { + seq.serialize_element(&AttributionTest { + suite: suite.clone(), + test: test.clone(), + status: test_status_name(result.status), + kind: test_kind_name(&result.kind), + covered: attributed_items(self.report, self.resolved_hit_maps, result), + })?; + } + } + + seq.end() + } +} + +fn attributed_items( + report: &CoverageReport, + resolved_hit_maps: &ResolvedHitMaps, + result: &TestResult, +) -> Vec { + type AttributionItemKey = ( + String, + String, + &'static str, + u32, + u32, + u32, + u32, + Option, + Option, + Option, + ); + + let mut items = BTreeMap::::new(); + let Some(hit_maps) = result.line_coverage.as_ref() else { return Vec::new() }; + + for (code_hash, map) in &hit_maps.0 { + let Some(resolved) = resolved_hit_maps.get(code_hash) else { continue }; + + for (item, hits) in + report.hit_items_for_hit_map(&resolved.contract_id, map, resolved.is_deployed_code) + { + let Some(source_path) = + report.get_source_path(&resolved.contract_id.build_id, item.loc.source_id) + else { + continue; + }; + + let source = source_path.display().to_string(); + let contract = item.loc.contract_name.to_string(); + let (kind, function, branch_id, path_id) = coverage_item_kind_fields(&item.kind); + let line_start = item.loc.lines.start; + let line_end = item.loc.lines.end; + let byte_start = item.loc.bytes.start; + let byte_end = item.loc.bytes.end; + let key = ( + source.clone(), + contract.clone(), + kind, + line_start, + line_end, + byte_start, + byte_end, + function.clone(), + branch_id, + path_id, + ); + + items.entry(key).and_modify(|item| item.hits += hits).or_insert(AttributionItem { + source, + contract, + kind, + line_start, + line_end, + byte_start, + byte_end, + hits, + function, + branch_id, + path_id, + }); + } + } + + items.into_values().collect() +} + +fn coverage_item_kind_fields( + kind: &CoverageItemKind, +) -> (&'static str, Option, Option, Option) { + match kind { + CoverageItemKind::Line => ("line", None, None, None), + CoverageItemKind::Statement => ("statement", None, None, None), + CoverageItemKind::Branch { branch_id, path_id, .. } => { + ("branch", None, Some(*branch_id), Some(*path_id)) + } + CoverageItemKind::Function { name } => ("function", Some(name.to_string()), None, None), + } +} + +const fn test_status_name(status: TestStatus) -> &'static str { + match status { + TestStatus::Success => "success", + TestStatus::Failure => "failure", + TestStatus::Skipped => "skipped", + } +} + +const fn test_kind_name(kind: &TestKind) -> &'static str { + match kind { + TestKind::Unit { .. } => "unit", + TestKind::Fuzz { .. } => "fuzz", + TestKind::Invariant { .. } => "invariant", + TestKind::Table { .. } => "table", + TestKind::Symbolic { .. } => "symbolic", + TestKind::Replay { .. } => "replay", + } +} + /// A super verbose reporter for debugging coverage while it is still unstable. pub struct DebugReporter; impl CoverageReporter for DebugReporter { + fn name(&self) -> &'static str { + "debug" + } + fn report(&mut self, report: &CoverageReport) -> eyre::Result<()> { for (path, items) in report.items_by_file() { - sh_println!("Uncovered for {}:", path.display())?; + let src = fs::read_to_string(path)?; + sh_println!("{}:", path.display())?; for item in items { - if item.hits == 0 { - sh_println!("- {item}")?; - } + sh_println!("- {}", item.fmt_with_source(Some(&src)))?; } sh_println!()?; } - for (contract_id, anchors) in &report.anchors { - sh_println!("Anchors for {contract_id}:")?; - let anchors = anchors - .0 + for (contract_id, (cta, rta)) in &report.anchors { + if cta.anchors.is_empty() && rta.anchors.is_empty() { + continue; + } + + let anchors = cta + .anchors .iter() .map(|anchor| (false, anchor)) - .chain(anchors.1.iter().map(|anchor| (true, anchor))); - for (is_deployed, anchor) in anchors { - sh_println!("- {anchor}")?; - if is_deployed { - sh_println!("- Creation code")?; - } else { - sh_println!("- Runtime code")?; - } - sh_println!( - " - Refers to item: {}", - report + .chain(rta.anchors.iter().map(|anchor| (true, anchor))) + .filter_map(|(is_runtime, anchor)| { + let item = report .analyses - .get(&contract_id.version) - .and_then(|items| items.get(anchor.item_id)) - .map_or_else(|| "None".to_owned(), |item| item.to_string()) - )?; + .get(&contract_id.build_id) + .and_then(|items| items.get(anchor.item_id))?; + // Source filters retain analyses to keep anchor item IDs stable, so debug + // output must apply the same reportable-source filter as other reporters. + report + .get_source_path(&contract_id.build_id, item.loc.source_id) + .is_some() + .then_some((is_runtime, anchor, item)) + }) + .collect::>(); + if anchors.is_empty() { + continue; + } + + sh_println!("Anchors for {contract_id}:")?; + for (is_runtime, anchor, item) in anchors { + let kind = if is_runtime { " runtime" } else { "creation" }; + sh_println!("- {kind} {anchor}: {item}")?; } sh_println!()?; } @@ -231,12 +504,16 @@ pub struct BytecodeReporter { } impl BytecodeReporter { - pub fn new(root: PathBuf, destdir: PathBuf) -> Self { + pub const fn new(root: PathBuf, destdir: PathBuf) -> Self { Self { root, destdir } } } impl CoverageReporter for BytecodeReporter { + fn name(&self) -> &'static str { + "bytecode" + } + fn needs_source_maps(&self) -> bool { true } @@ -262,9 +539,8 @@ impl CoverageReporter for BytecodeReporter { .map(|h| format!("[{h:03}]")) .unwrap_or(" ".to_owned()); let source_id = source_element.index(); - let source_path = source_id.and_then(|i| { - report.source_paths.get(&(contract_id.version.clone(), i as usize)) - }); + let source_path = source_id + .and_then(|i| report.get_source_path(&contract_id.build_id, i as usize)); let code = format!("{code:?}"); let start = source_element.offset() as usize; @@ -334,3 +610,16 @@ impl LineNumberCache { Ok((lo, pos)) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_summary_cell_is_not_applicable() { + assert_eq!( + format_cell(0, 0), + Cell::new("N/A (0/0)").fg(Color::Grey).add_attribute(Attribute::Dim) + ); + } +} diff --git a/crates/forge/src/gas_report.rs b/crates/forge/src/gas_report.rs index cab3f6bdec796..5f364828a455c 100644 --- a/crates/forge/src/gas_report.rs +++ b/crates/forge/src/gas_report.rs @@ -2,16 +2,15 @@ use crate::{ constants::{CHEATCODE_ADDRESS, HARDHAT_CONSOLE_ADDRESS}, - traces::{CallTraceArena, CallTraceDecoder, CallTraceNode, DecodedCallData}, + traces::{CallTraceArena, CallTraceDecoder, CallTraceNode}, }; -use alloy_primitives::map::HashSet; -use comfy_table::{Cell, Color, Table, modifiers::UTF8_ROUND_CORNERS}; -use foundry_common::{ - TestFunctionExt, calc, - reports::{ReportKind, report_kind}, +use alloy_primitives::{Address, map::HashSet}; +use comfy_table::{ + Cell, CellAlignment, Color, Table, + presets::{ASCII_FULL, ASCII_MARKDOWN}, }; +use foundry_common::{TestFunctionExt, calc, get_contract_name, shell}; use foundry_evm::traces::CallKind; - use serde::{Deserialize, Serialize}; use serde_json::json; use std::{collections::BTreeMap, fmt::Display}; @@ -21,14 +20,15 @@ use std::{collections::BTreeMap, fmt::Display}; pub struct GasReport { /// Whether to report any contracts. report_any: bool, - /// What kind of report to generate. - report_kind: ReportKind, /// Contracts to generate the report for. report_for: HashSet, /// Contracts to ignore when generating the report. ignore: HashSet, /// Whether to include gas reports for tests. include_tests: bool, + /// Additional network-specific cheatcode addresses omitted from reports. + #[serde(skip)] + extra_cheatcode_addresses: HashSet

, /// All contracts that were analyzed grouped by their identifier /// ``test/Counter.t.sol:CounterTest pub contracts: BTreeMap, @@ -39,17 +39,17 @@ impl GasReport { report_for: impl IntoIterator, ignore: impl IntoIterator, include_tests: bool, + extra_cheatcode_addresses: impl IntoIterator, ) -> Self { let report_for = report_for.into_iter().collect::>(); - let ignore = ignore.into_iter().collect::>(); let report_any = report_for.is_empty() || report_for.contains("*"); Self { report_any, - report_kind: report_kind(), report_for, - ignore, + ignore: ignore.into_iter().collect(), include_tests, - ..Default::default() + extra_cheatcode_addresses: extra_cheatcode_addresses.into_iter().collect(), + contracts: BTreeMap::new(), } } @@ -73,6 +73,12 @@ impl GasReport { self.report_any || self.report_for.contains(contract_name) } + fn is_internal_address(&self, address: Address) -> bool { + address == CHEATCODE_ADDRESS + || address == HARDHAT_CONSOLE_ADDRESS + || self.extra_cheatcode_addresses.contains(&address) + } + /// Analyzes the given traces and generates a gas report. pub async fn analyze( &mut self, @@ -86,21 +92,17 @@ impl GasReport { async fn analyze_node(&mut self, node: &CallTraceNode, decoder: &CallTraceDecoder) { let trace = &node.trace; - - if trace.address == CHEATCODE_ADDRESS || trace.address == HARDHAT_CONSOLE_ADDRESS { + if self.is_internal_address(trace.address) { return; } - - let Some(name) = decoder.contracts.get(&node.trace.address) else { return }; - let contract_name = name.rsplit(':').next().unwrap_or(name); - + let Some(name) = decoder.contracts.get(&trace.address) else { return }; + let contract_name = get_contract_name(name); if !self.should_report(contract_name) { return; } - let contract_info = self.contracts.entry(name.to_string()).or_default(); - let is_create_call = trace.kind.is_any_create(); - // Record contract deployment size. + let contract_info = self.contracts.entry(name.clone()).or_default(); + let is_create_call = trace.kind.is_any_create(); if is_create_call { trace!(contract_name, "adding create size info"); contract_info.size = trace.data.len(); @@ -112,23 +114,22 @@ impl GasReport { return; } - let decoded = || decoder.decode_function(&node.trace); - if is_create_call { trace!(contract_name, "adding create gas info"); contract_info.gas = trace.gas_used; - } else if let Some(DecodedCallData { signature, .. }) = decoded().await.call_data { + } else if let Some(signature) = decoder.decode_function_signature(trace).await { let name = signature.split('(').next().unwrap(); - // ignore any test/setup functions + // Ignore any test/setup functions. if self.include_tests || !name.test_function_kind().is_known() { trace!(contract_name, signature, "adding gas info"); - let gas_info = contract_info + contract_info .functions .entry(name.to_string()) .or_default() - .entry(signature.clone()) - .or_default(); - gas_info.frames.push(trace.gas_used); + .entry(signature) + .or_default() + .frames + .push(trace.gas_used); } } } @@ -137,128 +138,111 @@ impl GasReport { #[must_use] pub fn finalize(mut self) -> Self { trace!("finalizing gas report"); - for contract in self.contracts.values_mut() { - for sigs in contract.functions.values_mut() { - for func in sigs.values_mut() { - func.frames.sort_unstable(); - func.min = func.frames.first().copied().unwrap_or_default(); - func.max = func.frames.last().copied().unwrap_or_default(); - func.mean = calc::mean(&func.frames); - func.median = calc::median_sorted(&func.frames); - func.calls = func.frames.len() as u64; - } - } + for func in self + .contracts + .values_mut() + .flat_map(|c| c.functions.values_mut().flat_map(|s| s.values_mut())) + { + func.frames.sort_unstable(); + func.min = func.frames.first().copied().unwrap_or_default(); + func.max = func.frames.last().copied().unwrap_or_default(); + func.mean = calc::mean(&func.frames); + func.median = calc::median_sorted(&func.frames); + func.calls = func.frames.len() as u64; } self } + + /// Contracts with at least one recorded function call. + fn reported_contracts(&self) -> impl Iterator { + self.contracts.iter().filter(|(name, contract)| { + if contract.functions.is_empty() { + trace!(name, "gas report contract without functions"); + } + !contract.functions.is_empty() + }) + } } impl Display for GasReport { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { - match self.report_kind { - ReportKind::Text => { - for (name, contract) in &self.contracts { - if contract.functions.is_empty() { - trace!(name, "gas report contract without functions"); - continue; - } - - let table = self.format_table_output(contract, name); - writeln!(f, "\n{table}")?; - } - } - ReportKind::JSON => { - writeln!(f, "{}", &self.format_json_output())?; - } + if shell::is_json() { + return writeln!(f, "{}", self.format_json_output()); + } + for (name, contract) in self.reported_contracts() { + writeln!(f, "\n{}", format_table_output(contract, name))?; } - Ok(()) } } impl GasReport { fn format_json_output(&self) -> String { - serde_json::to_string( - &self - .contracts - .iter() - .filter_map(|(name, contract)| { - if contract.functions.is_empty() { - trace!(name, "gas report contract without functions"); - return None; - } - - let functions = contract - .functions - .iter() - .flat_map(|(_, sigs)| { - sigs.iter().map(|(sig, gas_info)| { - let display_name = sig.replace(':', ""); - (display_name, gas_info) - }) - }) - .collect::>(); - - Some(json!({ - "contract": name, - "deployment": { - "gas": contract.gas, - "size": contract.size, - }, - "functions": functions, - })) + let contracts = self + .reported_contracts() + .map(|(name, contract)| { + let functions = contract + .functions + .values() + .flat_map(|sigs| sigs.iter().map(|(sig, info)| (sig.replace(':', ""), info))) + .collect::>(); + json!({ + "contract": name, + "deployment": { "gas": contract.gas, "size": contract.size }, + "functions": functions, }) - .collect::>(), - ) - .unwrap() + }) + .collect::>(); + serde_json::to_string(&contracts).unwrap() } +} - fn format_table_output(&self, contract: &ContractInfo, name: &str) -> Table { - let mut table = Table::new(); - table.apply_modifier(UTF8_ROUND_CORNERS); - - table.set_header(vec![Cell::new(format!("{name} Contract")).fg(Color::Magenta)]); - - table.add_row(vec![ - Cell::new("Deployment Cost").fg(Color::Cyan), - Cell::new("Deployment Size").fg(Color::Cyan), - ]); - table.add_row(vec![ - Cell::new(contract.gas.to_string()), - Cell::new(contract.size.to_string()), - ]); - - // Add a blank row to separate deployment info from function info. - table.add_row(vec![Cell::new("")]); - - table.add_row(vec![ - Cell::new("Function Name"), - Cell::new("Min").fg(Color::Green), - Cell::new("Avg").fg(Color::Yellow), - Cell::new("Median").fg(Color::Yellow), - Cell::new("Max").fg(Color::Red), - Cell::new("# Calls").fg(Color::Cyan), - ]); - - contract.functions.iter().for_each(|(fname, sigs)| { - sigs.iter().for_each(|(sig, gas_info)| { - // Show function signature if overloaded else display function name. - let display_name = - if sigs.len() == 1 { fname.to_string() } else { sig.replace(':', "") }; - - table.add_row(vec![ - Cell::new(display_name), - Cell::new(gas_info.min.to_string()).fg(Color::Green), - Cell::new(gas_info.mean.to_string()).fg(Color::Yellow), - Cell::new(gas_info.median.to_string()).fg(Color::Yellow), - Cell::new(gas_info.max.to_string()).fg(Color::Red), - Cell::new(gas_info.calls.to_string()), - ]); - }) - }); +fn format_table_output(contract: &ContractInfo, name: &str) -> Table { + let num = |value: &dyn Display, color: Option| { + let cell = Cell::new(value.to_string()).set_alignment(CellAlignment::Right); + match color { + Some(color) => cell.fg(color), + None => cell, + } + }; - table + let mut table = Table::new(); + if shell::is_markdown() { + table.load_style(ASCII_MARKDOWN); + } else { + table.load_style(ASCII_FULL.with_rounded_corners()); + } + table.set_header(vec![Cell::new(format!("{name} Contract")).fg(Color::Magenta)]); + table.add_row(vec![ + Cell::new("Deployment Cost").fg(Color::Cyan), + Cell::new("Deployment Size").fg(Color::Cyan), + ]); + table.add_row(vec![num(&contract.gas, None), num(&contract.size, None)]); + // Add a blank row to separate deployment info from function info. + table.add_row(vec![Cell::new("")]); + table.add_row(vec![ + Cell::new("Function Name"), + Cell::new("Min").fg(Color::Green), + Cell::new("Avg").fg(Color::Yellow), + Cell::new("Median").fg(Color::Yellow), + Cell::new("Max").fg(Color::Red), + Cell::new("# Calls").fg(Color::Cyan), + ]); + for (fname, sigs) in &contract.functions { + for (sig, gas_info) in sigs { + // Show function signature if overloaded else display function name. + let display_name = if sigs.len() == 1 { fname.clone() } else { sig.replace(':', "") }; + table.add_row(vec![ + Cell::new(display_name), + num(&gas_info.min, Some(Color::Green)), + num(&gas_info.mean, Some(Color::Yellow)), + num(&gas_info.median, Some(Color::Yellow)), + num(&gas_info.max, Some(Color::Red)), + num(&gas_info.calls, None), + ]); + } } + table } #[derive(Clone, Debug, Default, Serialize, Deserialize)] @@ -280,3 +264,18 @@ pub struct GasInfo { #[serde(skip)] pub frames: Vec, } + +#[cfg(test)] +mod tests { + use super::*; + use foundry_evm::constants::MONAD_CHEATCODE_ADDRESS; + + #[test] + fn network_cheatcode_addresses_are_opt_in() { + let ethereum = GasReport::new([], [], false, []); + assert!(!ethereum.is_internal_address(MONAD_CHEATCODE_ADDRESS)); + + let monad = GasReport::new([], [], false, [MONAD_CHEATCODE_ADDRESS]); + assert!(monad.is_internal_address(MONAD_CHEATCODE_ADDRESS)); + } +} diff --git a/crates/forge/src/lib.rs b/crates/forge/src/lib.rs index fdbad8e4e6b2e..768fedac85e60 100644 --- a/crates/forge/src/lib.rs +++ b/crates/forge/src/lib.rs @@ -1,7 +1,7 @@ //! Forge is a fast and flexible Ethereum testing framework. #![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] +#![cfg_attr(docsrs, feature(doc_cfg))] #[macro_use] extern crate foundry_common; @@ -9,26 +9,37 @@ extern crate foundry_common; #[macro_use] extern crate tracing; +// Required for optional features (aws-kms, gcp-kms, turnkey) +#[cfg(any(feature = "aws-kms", feature = "gcp-kms", feature = "turnkey"))] +use foundry_wallets as _; + pub mod args; pub mod cmd; pub mod opts; pub mod coverage; +pub mod brutalizer; + pub mod gas_report; pub mod multi_runner; pub use multi_runner::{MultiContractRunner, MultiContractRunnerBuilder}; +pub mod mutation; + +pub mod workspace; + mod runner; pub use runner::ContractRunner; mod progress; pub mod result; +mod symbolic_minimizer; +mod symbolic_regression; // TODO: remove pub use foundry_common::traits::TestFilter; pub use foundry_evm::*; -mod lockfile; -pub use lockfile::{DepIdentifier, DepMap, FOUNDRY_LOCK, Lockfile}; +pub use foundry_cli::lockfile::{DepIdentifier, DepMap, FOUNDRY_LOCK, Lockfile}; diff --git a/crates/forge/src/lockfile.rs b/crates/forge/src/lockfile.rs deleted file mode 100644 index 9c7339ffbfb43..0000000000000 --- a/crates/forge/src/lockfile.rs +++ /dev/null @@ -1,385 +0,0 @@ -//! foundry.lock handler type. - -use std::{ - collections::hash_map::Entry, - path::{Path, PathBuf}, -}; - -use alloy_primitives::map::HashMap; -use eyre::{OptionExt, Result}; -use foundry_cli::utils::Git; -use serde::{Deserialize, Serialize}; - -pub const FOUNDRY_LOCK: &str = "foundry.lock"; - -/// A type alias for a HashMap of dependencies keyed by relative path to the submodule dir. -pub type DepMap = HashMap; - -/// A lockfile handler that keeps track of the dependencies and their current state. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Lockfile<'a> { - /// A map of the dependencies keyed by relative path to the submodule dir. - #[serde(flatten)] - deps: DepMap, - /// This is optional to handle no-git scencarios. - #[serde(skip)] - git: Option<&'a Git<'a>>, - /// Absolute path to the lockfile. - #[serde(skip)] - lockfile_path: PathBuf, -} - -impl<'a> Lockfile<'a> { - /// Create a new [`Lockfile`] instance. - /// - /// `project_root` is the absolute path to the project root. - /// - /// You will need to call [`Lockfile::read`] or [`Lockfile::sync`] to load the lockfile. - pub fn new(project_root: &Path) -> Self { - Self { deps: HashMap::default(), git: None, lockfile_path: project_root.join(FOUNDRY_LOCK) } - } - - /// Set the git instance to be used for submodule operations. - pub fn with_git(mut self, git: &'a Git<'_>) -> Self { - self.git = Some(git); - self - } - - /// Sync the foundry.lock file with the current state of `git submodules`. - /// - /// If the lockfile and git submodules are out of sync, it returns a [`DepMap`] consisting of - /// _only_ the out-of-sync dependencies. - /// - /// This method writes the lockfile to project root if: - /// - The lockfile does not exist. - /// - The lockfile is out of sync with the git submodules. - pub fn sync(&mut self, lib: &Path) -> Result> { - match self.read() { - Ok(_) => {} - Err(e) => { - if !e.to_string().contains("Lockfile not found") { - return Err(e); - } - } - } - - if let Some(git) = &self.git { - let submodules = git.submodules()?; - - if submodules.is_empty() { - trace!("No submodules found. Skipping sync."); - return Ok(None); - } - - let modules_with_branch = git - .read_submodules_with_branch(&Git::root_of(git.root)?, lib.file_name().unwrap())?; - - let mut out_of_sync: DepMap = HashMap::default(); - for sub in &submodules { - let rel_path = sub.path(); - let rev = sub.rev(); - - let entry = self.deps.entry(rel_path.to_path_buf()); - - match entry { - Entry::Occupied(e) => { - if e.get().rev() != rev { - out_of_sync.insert(rel_path.to_path_buf(), e.get().clone()); - } - } - Entry::Vacant(e) => { - // Check if there is branch specified for the submodule at rel_path in - // .gitmodules - let maybe_branch = modules_with_branch.get(rel_path).map(|b| b.to_string()); - - trace!(?maybe_branch, submodule = ?rel_path, "submodule branch"); - if let Some(branch) = maybe_branch { - let dep_id = DepIdentifier::Branch { - name: branch, - rev: rev.to_string(), - r#override: false, - }; - e.insert(dep_id.clone()); - out_of_sync.insert(rel_path.to_path_buf(), dep_id); - continue; - } - - let dep_id = DepIdentifier::Rev { rev: rev.to_string(), r#override: false }; - trace!(submodule=?rel_path, ?dep_id, "submodule dep_id"); - e.insert(dep_id.clone()); - out_of_sync.insert(rel_path.to_path_buf(), dep_id); - } - } - } - - return Ok(if out_of_sync.is_empty() { None } else { Some(out_of_sync) }); - } - - Ok(None) - } - - /// Loads the lockfile from the project root. - /// - /// Throws an error if the lockfile does not exist. - pub fn read(&mut self) -> Result<()> { - if !self.lockfile_path.exists() { - return Err(eyre::eyre!("Lockfile not found at {}", self.lockfile_path.display())); - } - - let lockfile_str = foundry_common::fs::read_to_string(&self.lockfile_path)?; - - self.deps = serde_json::from_str(&lockfile_str)?; - - trace!(lockfile = ?self.deps, "loaded lockfile"); - - Ok(()) - } - - /// Writes the lockfile to the project root. - pub fn write(&self) -> Result<()> { - foundry_common::fs::write_pretty_json_file(&self.lockfile_path, &self.deps)?; - trace!(at= ?self.lockfile_path, "wrote lockfile"); - - Ok(()) - } - - /// Insert a dependency into the lockfile. - /// If the dependency already exists, it will be updated. - /// - /// Note: This does not write the updated lockfile to disk, only inserts the dep in-memory. - pub fn insert(&mut self, path: PathBuf, dep_id: DepIdentifier) { - self.deps.insert(path, dep_id); - } - - /// Get the [`DepIdentifier`] for a submodule at a given path. - pub fn get(&self, path: &Path) -> Option<&DepIdentifier> { - self.deps.get(path) - } - - /// Removes a dependency from the lockfile. - /// - /// Note: This does not write the updated lockfile to disk, only removes the dep in-memory. - pub fn remove(&mut self, path: &Path) -> Option { - self.deps.remove(path) - } - - /// Override a dependency in the lockfile. - /// - /// Returns the overridden/previous [`DepIdentifier`]. - /// This is used in `forge update` to decide whether a dep's tag/branch/rev should be updated. - /// - /// Throws an error if the dependency is not found in the lockfile. - pub fn override_dep( - &mut self, - dep: &Path, - mut new_dep_id: DepIdentifier, - ) -> Result { - let prev = self - .deps - .get_mut(dep) - .map(|d| { - new_dep_id.mark_override(); - std::mem::replace(d, new_dep_id) - }) - .ok_or_eyre(format!("Dependency not found in lockfile: {}", dep.display()))?; - - Ok(prev) - } - - /// Returns the num of dependencies in the lockfile. - pub fn len(&self) -> usize { - self.deps.len() - } - - /// Returns whether the lockfile is empty. - pub fn is_empty(&self) -> bool { - self.deps.is_empty() - } - - /// Returns an iterator over the lockfile. - pub fn iter(&self) -> impl Iterator { - self.deps.iter() - } - - /// Returns an mutable iterator over the lockfile. - pub fn iter_mut(&mut self) -> impl Iterator { - self.deps.iter_mut() - } - - pub fn exists(&self) -> bool { - self.lockfile_path.exists() - } -} - -// Implement .iter() for &LockFile - -/// Identifies whether a dependency (submodule) is referenced by a branch, -/// tag or rev (commit hash). -/// -/// Each enum variant consists of an `r#override` flag which is used in `forge update` to decide -/// whether to update a dep or not. This flag is skipped during serialization. -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub enum DepIdentifier { - /// `name` of the branch and the `rev` it is currently pointing to. - /// Running `forge update`, will update the `name` branch to the latest `rev`. - #[serde(rename = "branch")] - Branch { - name: String, - rev: String, - #[serde(skip)] - r#override: bool, - }, - /// Release tag `name` and the `rev` it is currently pointing to. - /// Running `forge update` does not update the tag/rev. - /// Dependency will remain pinned to the existing tag/rev unless r#override like so `forge - /// update owner/dep@tag=different_tag`. - #[serde(rename = "tag")] - Tag { - name: String, - rev: String, - #[serde(skip)] - r#override: bool, - }, - /// Commit hash `rev` the submodule is currently pointing to. - /// Running `forge update` does not update the rev. - /// Dependency will remain pinned to the existing rev unless r#override. - #[serde(rename = "rev", untagged)] - Rev { - rev: String, - #[serde(skip)] - r#override: bool, - }, -} - -impl DepIdentifier { - /// Resolves the [`DepIdentifier`] for a submodule at a given path. - /// `lib_path` is the absolute path to the submodule. - pub fn resolve_type(git: &Git<'_>, lib_path: &Path, s: &str) -> Result { - trace!(lib_path = ?lib_path, resolving_type = ?s, "resolving submodule identifier"); - // Get the tags for the submodule - if git.has_tag(s, lib_path)? { - let rev = git.get_rev(s, lib_path)?; - return Ok(Self::Tag { name: String::from(s), rev, r#override: false }); - } - - if git.has_branch(s, lib_path)? { - let rev = git.get_rev(s, lib_path)?; - return Ok(Self::Branch { name: String::from(s), rev, r#override: false }); - } - - if git.has_rev(s, lib_path)? { - return Ok(Self::Rev { rev: String::from(s), r#override: false }); - } - - Err(eyre::eyre!("Could not resolve tag type for submodule at path {}", lib_path.display())) - } - - /// Get the commit hash of the dependency. - pub fn rev(&self) -> &str { - match self { - Self::Branch { rev, .. } => rev, - Self::Tag { rev, .. } => rev, - Self::Rev { rev, .. } => rev, - } - } - - /// Get the name of the dependency. - /// - /// In case of a Rev, this will return the commit hash. - pub fn name(&self) -> &str { - match self { - Self::Branch { name, .. } => name, - Self::Tag { name, .. } => name, - Self::Rev { rev, .. } => rev, - } - } - - /// Get the name/rev to checkout at. - pub fn checkout_id(&self) -> &str { - match self { - Self::Branch { name, .. } => name, - Self::Tag { name, .. } => name, - Self::Rev { rev, .. } => rev, - } - } - - /// Marks as dependency as overridden. - pub fn mark_override(&mut self) { - match self { - Self::Branch { r#override, .. } => *r#override = true, - Self::Tag { r#override, .. } => *r#override = true, - Self::Rev { r#override, .. } => *r#override = true, - } - } - - /// Returns whether the dependency has been overridden. - pub fn overridden(&self) -> bool { - match self { - Self::Branch { r#override, .. } => *r#override, - Self::Tag { r#override, .. } => *r#override, - Self::Rev { r#override, .. } => *r#override, - } - } - - /// Returns whether the dependency is a branch. - pub fn is_branch(&self) -> bool { - matches!(self, Self::Branch { .. }) - } -} - -impl std::fmt::Display for DepIdentifier { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Branch { name, rev, .. } => write!(f, "branch={name}@{rev}"), - Self::Tag { name, rev, .. } => write!(f, "tag={name}@{rev}"), - Self::Rev { rev, .. } => write!(f, "rev={rev}"), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn serde_dep_identifier() { - let branch = DepIdentifier::Branch { - name: "main".to_string(), - rev: "b7954c3e9ce1d487b49489f5800f52f4b77b7351".to_string(), - r#override: false, - }; - - let tag = DepIdentifier::Tag { - name: "v0.1.0".to_string(), - rev: "b7954c3e9ce1d487b49489f5800f52f4b77b7351".to_string(), - r#override: false, - }; - - let rev = DepIdentifier::Rev { - rev: "b7954c3e9ce1d487b49489f5800f52f4b77b7351".to_string(), - r#override: false, - }; - - let branch_str = serde_json::to_string(&branch).unwrap(); - let tag_str = serde_json::to_string(&tag).unwrap(); - let rev_str = serde_json::to_string(&rev).unwrap(); - - assert_eq!( - branch_str, - r#"{"branch":{"name":"main","rev":"b7954c3e9ce1d487b49489f5800f52f4b77b7351"}}"# - ); - assert_eq!( - tag_str, - r#"{"tag":{"name":"v0.1.0","rev":"b7954c3e9ce1d487b49489f5800f52f4b77b7351"}}"# - ); - assert_eq!(rev_str, r#"{"rev":"b7954c3e9ce1d487b49489f5800f52f4b77b7351"}"#); - - let branch_de: DepIdentifier = serde_json::from_str(&branch_str).unwrap(); - let tag_de: DepIdentifier = serde_json::from_str(&tag_str).unwrap(); - let rev_de: DepIdentifier = serde_json::from_str(&rev_str).unwrap(); - - assert_eq!(branch, branch_de); - assert_eq!(tag, tag_de); - assert_eq!(rev, rev_de); - } -} diff --git a/crates/forge/src/multi_runner.rs b/crates/forge/src/multi_runner.rs index 76ae5d6da10e9..c48b394ea094b 100644 --- a/crates/forge/src/multi_runner.rs +++ b/crates/forge/src/multi_runner.rs @@ -1,38 +1,51 @@ //! Forge test runner for multiple contracts. use crate::{ - ContractRunner, TestFilter, progress::TestsProgress, result::SuiteResult, - runner::LIBRARY_DEPLOYER, + ContractRunner, TestFilter, + progress::TestsProgress, + result::{SuiteResult, SymbolicCounterexampleArtifact, SymbolicCounterexampleArtifactKind}, + runner::{ + ContractRunnerContext, InvariantCampaignScope, count_runnable_invariant_campaign_anchors, + function_matches_network_pass, + }, + symbolic_regression::SYMBOLIC_REGRESSION_MARKER, }; use alloy_json_abi::{Function, JsonAbi}; -use alloy_primitives::{Address, Bytes, U256}; +use alloy_primitives::{Address, Bytes, ChainId, U256}; use eyre::Result; -use foundry_common::{ContractsByArtifact, TestFunctionExt, get_contract_name, shell::verbosity}; +use foundry_cli::opts::configure_pcx_from_compile_output; +use foundry_common::{ + ContractsByArtifact, ContractsByArtifactBuilder, EmptyTestFilter, LIBRARY_DEPLOYER, + TestFunctionKind, external_compiler::external_artifact_is_test_eligible, get_contract_name, +}; use foundry_compilers::{ - Artifact, ArtifactId, ProjectCompileOutput, + Artifact, ArtifactId, Compiler, ProjectCompileOutput, artifacts::{Contract, Libraries}, - compilers::Compiler, }; -use foundry_config::{Config, InlineConfig}; +use foundry_config::{Config, FoundryHardfork, InlineConfig}; use foundry_evm::{ - Env, backend::Backend, + core::evm::{EvmEnvFor, FoundryEvmNetwork, SpecFor, TxEnvFor}, decode::RevertDecoder, - executors::{Executor, ExecutorBuilder}, - fork::CreateFork, - inspectors::CheatsConfig, - opts::EvmOpts, - traces::{InternalTraceMode, TraceMode}, + executors::{EarlyExit, Executor, ExecutorBuilder, ReplayObservation, ShowmapDomain}, + fuzz::{ + BaseCounterExample, BasicTxDetails, + strategies::{EnumBounds, LiteralsDictionary}, + }, + inspectors::{CheatsConfig, EdgeIndexMap}, + opts::{EvmOpts, ExecutionSpecContext, resolve_execution_spec}, + traces::{InternalTraceMode, TraceRequirements}, }; -use foundry_linking::{LinkOutput, Linker}; +use foundry_evm_networks::NetworkVariant; + +use foundry_linking::{DetailedLinkOutput, LinkOutput, Linker, LinkerError, Resolver}; use rayon::prelude::*; -use revm::primitives::hardfork::SpecId; use std::{ borrow::Borrow, - collections::BTreeMap, - fmt::Debug, - path::Path, - sync::{Arc, mpsc}, + collections::{BTreeMap, BTreeSet}, + ops::{Deref, DerefMut}, + path::PathBuf, + sync::{Arc, Mutex, mpsc}, time::Instant, }; @@ -40,13 +53,15 @@ use std::{ pub struct TestContract { pub abi: JsonAbi, pub bytecode: Bytes, + pub library_addresses: BTreeSet
, } pub type DeployableContracts = BTreeMap; /// A multi contract runner receives a set of contracts deployed in an EVM instance and proceeds /// to run all test functions in these contracts. -pub struct MultiContractRunner { +#[derive(Clone, Debug)] +pub struct MultiContractRunner { /// Mapping of contract name to JsonAbi, creation bytecode and library bytecode which /// needs to be deployed & linked against pub contracts: DeployableContracts, @@ -56,37 +71,65 @@ pub struct MultiContractRunner { pub revert_decoder: RevertDecoder, /// Libraries to deploy. pub libs_to_deploy: Vec, + /// Addresses of libraries required by linked test artifacts. + pub library_addresses: Vec
, + /// How libraries should be deployed. + pub library_deployment: LibraryDeployment, /// Library addresses used to link contracts. pub libraries: Libraries, - - /// The fork to use at launch - pub fork: Option, + /// Solar compiler instance, to grant syntactic and semantic analysis capabilities + pub analysis: Arc, + /// Literals dictionary for fuzzing. + pub fuzz_literals: LiteralsDictionary, + /// Literals dictionary for invariant fuzzing. + pub invariant_literals: LiteralsDictionary, + /// Variant counts for project enums, used to constrain fuzzed enum inputs. + pub enum_bounds: EnumBounds, + + /// Pristine backend cloned for every test execution. + pub backend: Backend, /// The base configuration for the test runner. - pub tcfg: TestRunnerConfig, + pub tcfg: TestRunnerConfig, +} + +/// Forge-local library deployment strategy. +#[derive(Clone, Copy, Debug)] +pub enum LibraryDeployment { + Nonce, + Create2 { deployer: Address, salt: alloy_primitives::B256 }, } -impl std::ops::Deref for MultiContractRunner { - type Target = TestRunnerConfig; +impl Deref for MultiContractRunner { + type Target = TestRunnerConfig; fn deref(&self) -> &Self::Target { &self.tcfg } } -impl std::ops::DerefMut for MultiContractRunner { +impl DerefMut for MultiContractRunner { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.tcfg } } -impl MultiContractRunner { +impl MultiContractRunner { + pub(crate) fn test_function_matcher(&self) -> TestFunctionMatcher<'_> { + TestFunctionMatcher::new( + &self.config, + &self.inline_config, + self.tcfg.symbolic_artifact_replay.as_ref(), + ) + } + /// Returns an iterator over all contracts that match the filter. pub fn matching_contracts<'a: 'b, 'b>( &'a self, filter: &'b dyn TestFilter, ) -> impl Iterator + 'b { - self.contracts.iter().filter(|&(id, c)| matches_contract(id, &c.abi, filter)) + let matcher = self.test_function_matcher(); + self.contracts.iter().filter(move |&(id, c)| matcher.matches_contract(filter, id, &c.abi)) } /// Returns an iterator over all test functions that match the filter. @@ -94,9 +137,9 @@ impl MultiContractRunner { &'a self, filter: &'b dyn TestFilter, ) -> impl Iterator + 'b { + let matcher = self.test_function_matcher(); self.matching_contracts(filter) - .flat_map(|(_, c)| c.abi.functions()) - .filter(|func| is_matching_test(func, filter)) + .flat_map(move |(id, c)| matcher.matching_test_functions(filter, id, &c.abi)) } /// Returns an iterator over all test functions in contracts that match the filter. @@ -104,43 +147,54 @@ impl MultiContractRunner { &'a self, filter: &'b dyn TestFilter, ) -> impl Iterator + 'b { + let matcher = self.test_function_matcher(); self.contracts .iter() .filter(|(id, _)| filter.matches_path(&id.source) && filter.matches_contract(&id.name)) - .flat_map(|(_, c)| c.abi.functions()) - .filter(|func| func.is_any_test()) + .flat_map(move |(id, c)| { + matcher.test_functions(id.identifier(), &c.abi, |_, _, kind| kind.is_any_test()) + }) } /// Returns all matching tests grouped by contract grouped by file (file -> (contract -> tests)) pub fn list(&self, filter: &dyn TestFilter) -> BTreeMap>> { - self.matching_contracts(filter) - .map(|(id, c)| { - let source = id.source.as_path().display().to_string(); - let name = id.name.clone(); - let tests = c - .abi - .functions() - .filter(|func| is_matching_test(func, filter)) - .map(|func| func.name.clone()) - .collect::>(); - (source, name, tests) - }) - .fold(BTreeMap::new(), |mut acc, (source, name, tests)| { - acc.entry(source).or_default().insert(name, tests); - acc - }) + self.list_with(filter, |func| func.name.clone()) } - /// Executes _all_ tests that match the given `filter`. - /// - /// The same as [`test`](Self::test), but returns the results instead of streaming them. - /// - /// Note that this method returns only when all tests have been executed. - pub fn test_collect( - &mut self, + pub(crate) fn list_signatures( + &self, filter: &dyn TestFilter, - ) -> Result> { - Ok(self.test_iter(filter)?.collect()) + ) -> BTreeMap>> { + self.list_with(filter, |func| func.signature()) + } + + fn list_with( + &self, + filter: &dyn TestFilter, + format_test: impl Fn(&Function) -> String, + ) -> BTreeMap>> { + let matcher = self.test_function_matcher(); + let fuzz_only = self.tcfg.fuzz_only; + let mut out = BTreeMap::<_, BTreeMap<_, _>>::new(); + for (id, c) in self.matching_contracts(filter) { + let tests = matcher + .test_functions(id.identifier(), &c.abi, |contract_id, func, kind| { + (!fuzz_only + || matches!( + kind, + TestFunctionKind::FuzzTest { .. } | TestFunctionKind::InvariantTest + )) + && filter.matches_test_function_kind_in_contract(contract_id, func, kind) + }) + .map(&format_test) + .collect::>(); + if !tests.is_empty() { + out.entry(id.source.display().to_string()) + .or_default() + .insert(id.name.clone(), tests); + } + } + out } /// Executes _all_ tests that match the given `filter`. @@ -148,13 +202,13 @@ impl MultiContractRunner { /// The same as [`test`](Self::test), but returns the results instead of streaming them. /// /// Note that this method returns only when all tests have been executed. - pub fn test_iter( + pub fn test_collect( &mut self, filter: &dyn TestFilter, - ) -> Result> { + ) -> Result> { let (tx, rx) = mpsc::channel(); self.test(filter, tx, false)?; - Ok(rx.into_iter()) + Ok(rx.into_iter().collect()) } /// Executes _all_ tests that match the given `filter`. @@ -173,56 +227,70 @@ impl MultiContractRunner { trace!("running all tests"); // The DB backend that serves all the data. - let db = Backend::spawn(self.fork.take())?; + let db = self.backend.clone(); let find_timer = Instant::now(); let contracts = self.matching_contracts(filter).collect::>(); - let find_time = find_timer.elapsed(); debug!( "Found {} test contracts out of {} in {:?}", contracts.len(), self.contracts.len(), - find_time, + find_timer.elapsed(), ); - - if show_progress { - let tests_progress = TestsProgress::new(contracts.len(), rayon::current_num_threads()); - // Collect test suite results to stream at the end of test run. - let results: Vec<(String, SuiteResult)> = contracts - .par_iter() - .map(|&(id, contract)| { - let _guard = tokio_handle.enter(); - tests_progress.inner.lock().start_suite_progress(&id.identifier()); - - let result = self.run_test_suite( - id, - contract, - &db, - filter, - &tokio_handle, - Some(&tests_progress), - ); - - tests_progress - .inner - .lock() - .end_suite_progress(&id.identifier(), result.summary()); - - (id.identifier(), result) - }) - .collect(); - - tests_progress.inner.lock().clear(); - - results.iter().for_each(|result| { - let _ = tx.send(result.to_owned()); - }); - } else { - contracts.par_iter().for_each(|&(id, contract)| { - let _guard = tokio_handle.enter(); - let result = self.run_test_suite(id, contract, &db, filter, &tokio_handle, None); - let _ = tx.send((id.identifier(), result)); + let num_invariant_campaign_anchors = contracts + .iter() + .map(|(id, contract)| { + count_runnable_invariant_campaign_anchors( + &contract.abi, + filter, + InvariantCampaignScope { + config: &self.tcfg.config, + inline_config: &self.tcfg.inline_config, + contract_name: &id.identifier(), + all_override_networks: &self.tcfg.multi_network.all_override_networks, + pass_network: self.tcfg.multi_network.pass_network.as_ref(), + }, + ) }) + .sum(); + + let progress = show_progress + .then(|| TestsProgress::new(contracts.len(), rayon::current_num_threads())); + let run_suite = |&(id, contract): &(&ArtifactId, &TestContract)| { + let _guard = tokio_handle.enter(); + let identifier = id.identifier(); + if let Some(progress) = &progress { + progress.inner.lock().start_suite_progress(&identifier); + } + let result = self.run_test_suite( + id, + contract, + &db, + filter, + ContractRunnerContext { + progress: progress.as_ref(), + tokio_handle: tokio_handle.clone(), + num_invariant_campaign_anchors, + }, + ); + if let Some(progress) = &progress { + progress.inner.lock().end_suite_progress(&identifier, result.summary()); + } + (identifier, result) + }; + + if let Some(progress) = &progress { + // Collect test suite results to stream at the end of test run, once the progress bars + // have been cleared. + let results = contracts.par_iter().map(run_suite).collect::>(); + progress.inner.lock().clear(); + for result in results { + let _ = tx.send(result); + } + } else { + contracts.par_iter().for_each(|contract| { + let _ = tx.send(run_suite(contract)); + }); } Ok(()) @@ -232,33 +300,28 @@ impl MultiContractRunner { &self, artifact_id: &ArtifactId, contract: &TestContract, - db: &Backend, + db: &Backend, filter: &dyn TestFilter, - tokio_handle: &tokio::runtime::Handle, - progress: Option<&TestsProgress>, + context: ContractRunnerContext<'_>, ) -> SuiteResult { let identifier = artifact_id.identifier(); - let mut span_name = identifier.as_str(); - - if !enabled!(tracing::Level::TRACE) { - span_name = get_contract_name(&identifier); - } + let span_name = if enabled!(tracing::Level::TRACE) { + identifier.as_str() + } else { + get_contract_name(&identifier) + }; let span = debug_span!("suite", name = %span_name); - let span_local = span.clone(); - let _guard = span_local.enter(); + let _guard = span.clone().entered(); debug!("start executing all tests in contract"); - let executor = self.tcfg.executor(self.known_contracts.clone(), artifact_id, db.clone()); - let runner = ContractRunner::new( - &identifier, - contract, - executor, - progress, - tokio_handle, - span, - self, + let executor = self.tcfg.executor( + self.known_contracts.clone(), + self.analysis.clone(), + artifact_id, + db.clone(), ); + let runner = ContractRunner::new(&identifier, contract, executor, span, self, context); let r = runner.run_tests(filter); debug!(duration=?r.duration, "executed all tests in contract"); @@ -267,11 +330,105 @@ impl MultiContractRunner { } } +/// Tracks network assignment across a multi-network test run. +/// +/// When inline config specifies different networks for different tests, the runner performs one +/// pass per distinct network. This struct encodes which pass we're in so each `ContractRunner` +/// can skip tests that belong to a different pass. +/// +/// Default (empty `all_override_networks`, `None` pass) = single-pass mode, every test runs. +#[derive(Clone, Debug, Default)] +pub struct MultiNetworkConfig { + /// All networks explicitly referenced in inline config annotations across the whole suite. + /// Empty means single-pass mode (no per-test network overrides present). + pub all_override_networks: Vec, + /// The network this pass is responsible for. + /// `None` = default pass: runs tests *without* an explicit network annotation (or annotated + /// with a network not in `all_override_networks`). + /// `Some(v)` = override pass: runs only tests annotated with exactly `v`. + pub pass_network: Option, +} + +/// CLI-only options that switch fuzz/invariant tests into corpus replay +/// mode that emits AFL-`afl-showmap`-style coverage files. +#[derive(Clone, Debug)] +pub struct ShowmapConfig { + /// Output root directory for showmap files. + pub out_dir: PathBuf, + /// Approach name; used as a subdirectory under `out_dir`. + pub approach: String, + /// Trial identifier embedded in each emitted filename to keep reruns separate. + pub trial: String, + /// One file per corpus entry instead of one aggregated file per test. + pub per_input: bool, + /// Which bitmap(s) to dump. + pub domain: ShowmapDomain, + /// Optional override for the corpus directory to replay from. + /// When unset, the per-test corpus dir derived from config is used. + pub corpus_dir: Option, + /// Whether replay should emit showmap files. + pub emit_files: bool, +} + +pub type FuzzMinimizeEdgeIndices = Arc>>>>; + +/// Replay behavior required by a fuzz minimization command. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FuzzMinimizeMode { + /// Replay complete entries so corpus minimization observes all coverage and failures. + Cmin, + /// Stop at the campaign boundary so transaction minimization ignores unreachable suffixes. + Tmin, +} + +/// CLI-only options that switch fuzz/invariant tests into single-entry replay +/// mode for corpus minimization. +#[derive(Clone, Debug)] +pub struct FuzzMinimizeConfig { + /// Entry to replay. + pub input: Arc<[BasicTxDetails]>, + /// Whether replay serves corpus or transaction minimization. + pub mode: FuzzMinimizeMode, + /// Shared edge-index assignments for all candidate replays in this minimization invocation, + /// namespaced by matched target. + pub evm_edge_indices: FuzzMinimizeEdgeIndices, + /// Shared replay observations collected from matched fuzz/invariant tests. + pub observations: Arc>>, +} + +/// Replay observation for one matched minimization target. +#[derive(Clone, Debug)] +pub struct FuzzMinimizeObservation { + /// Stable target identity for this minimization run. + pub target: String, + /// Replay result for this target. + pub observation: ReplayObservation, +} + +#[derive(Clone, Debug)] +pub struct SymbolicArtifactReplayConfig { + /// Artifact payload to replay. + pub artifact: SymbolicCounterexampleArtifact, + /// Path the artifact was loaded from, used in diagnostics. + pub path: PathBuf, +} + +/// A validated stateless fuzz failure and its unique replay target. +#[derive(Clone, Debug)] +pub struct FuzzFailureReplayConfig { + /// Artifact payload to replay. + pub failure: Arc, + /// Fully qualified contract identifier selected for replay. + pub contract: String, + /// Function signature selected for replay. + pub test: String, +} + /// Configuration for the test runner. /// /// This is modified after instantiation through inline config. -#[derive(Clone)] -pub struct TestRunnerConfig { +#[derive(Clone, Debug)] +pub struct TestRunnerConfig { /// Project config. pub config: Arc, /// Inline configuration. @@ -279,10 +436,20 @@ pub struct TestRunnerConfig { /// EVM configuration. pub evm_opts: EvmOpts, + /// Executor construction selected by concrete network dispatch. + pub executor_builder: ExecutorBuilder, /// EVM environment. - pub env: Env, + pub evm_env: EvmEnvFor, + /// Transaction environment. + pub tx_env: TxEnvFor, /// EVM version. - pub spec_id: SpecId, + pub spec_id: SpecFor, + /// Exact network hardfork selected for the execution environment. + pub hardfork: Option, + /// Source chain ID used to resolve fork hardfork schedules. + pub fork_chain_id: Option, + /// Exact hardfork reported by the fork endpoint. + pub fork_hardfork: Option, /// The address which will be used to deploy the initial contracts and send all transactions. pub sender: Address, @@ -292,52 +459,76 @@ pub struct TestRunnerConfig { pub debug: bool, /// Whether to enable steps tracking in the tracer. pub decode_internal: InternalTraceMode, + /// Whether to record every opcode step without debugger snapshots. + pub record_all_steps: bool, /// Whether to enable call isolation. pub isolation: bool, - /// Whether to enable Odyssey features. - pub odyssey: bool, + /// Whether to exit early on test failure or if test run interrupted. + pub early_exit: EarlyExit, + + /// Multi-network pass configuration. Default = single-pass mode. + pub multi_network: MultiNetworkConfig, + + /// When set, fuzz/invariant tests run in corpus replay mode and emit + /// AFL-`afl-showmap`-style files instead of running a campaign. + pub showmap: Option, + /// When set, fuzz/invariant tests replay one candidate input and record minimization facts. + pub fuzz_minimize: Option, + /// Run only fuzz and invariant tests. + pub fuzz_only: bool, + /// Replay persisted fuzz failures without running a new fuzz campaign. + pub fuzz_failure_replay: bool, + /// Validated explicit stateless fuzz failure to replay. + pub fuzz_input: Option, + + /// When set, run only the matching test and replay this artifact's concrete payload. + pub symbolic_artifact_replay: Option, } -impl TestRunnerConfig { +impl TestRunnerConfig { /// Reconfigures all fields using the given `config`. /// This is for example used to override the configuration with inline config. pub fn reconfigure_with(&mut self, config: Arc) { debug_assert!(!Arc::ptr_eq(&self.config, &config)); - self.spec_id = config.evm_spec_id(); self.sender = config.sender; - self.odyssey = config.odyssey; + self.evm_opts.networks = config.networks; + self.hardfork = resolve_execution_spec( + config.evm_version, + config.hardfork, + &mut self.evm_env, + ExecutionSpecContext::local_or_fork(self.fork_chain_id, self.fork_hardfork), + None, + ); + self.spec_id = self.evm_env.cfg_env.spec; self.isolation = config.isolate; - - // Specific to Forge, not present in config. - // TODO: self.evm_opts - // TODO: self.env - // self.coverage = N/A; - // self.debug = N/A; - // self.decode_internal = N/A; - + // `line_coverage`, `debug`, `decode_internal` and `record_all_steps` are Forge-specific + // and not present in the config. + // TODO: `self.evm_opts` and `self.evm_env` are only partially reconfigured. + self.evm_opts.always_use_create_2_factory = config.always_use_create_2_factory; + self.evm_opts.no_fork_bal = config.no_fork_bal; + self.evm_opts.fork_state_by_number = config.fork_state_by_number; self.config = config; } /// Configures the given executor with this configuration. - pub fn configure_executor(&self, executor: &mut Executor) { - // TODO: See above - + pub fn configure_executor(&self, executor: &mut Executor) { + debug_assert!( + executor.backend().networks().has_same_execution_profile(&self.evm_opts.networks) + ); + debug_assert!( + executor.inspector().networks.has_same_execution_profile(&self.evm_opts.networks) + ); let inspector = executor.inspector_mut(); - // inspector.set_env(&self.env); if let Some(cheatcodes) = inspector.cheatcodes.as_mut() { - cheatcodes.config = - Arc::new(cheatcodes.config.clone_with(&self.config, self.evm_opts.clone())); + let mut config = cheatcodes.config.clone_with(&self.config, self.evm_opts.clone()); + config.isolate = self.isolation; + cheatcodes.config = Arc::new(config); } - inspector.tracing(self.trace_mode()); + inspector.tracing_requirements(self.trace_requirements()); inspector.collect_line_coverage(self.line_coverage); inspector.enable_isolation(self.isolation); - inspector.odyssey(self.odyssey); - // inspector.set_create2_deployer(self.evm_opts.create2_deployer); - - // executor.env_mut().clone_from(&self.env); executor.set_spec_id(self.spec_id); - // executor.set_gas_limit(self.evm_opts.gas_limit()); executor.set_legacy_assertions(self.config.legacy_assertions); } @@ -345,42 +536,48 @@ impl TestRunnerConfig { pub fn executor( &self, known_contracts: ContractsByArtifact, + analysis: Arc, artifact_id: &ArtifactId, - db: Backend, - ) -> Executor { - let cheats_config = Arc::new(CheatsConfig::new( + db: Backend, + ) -> Executor { + let mut cheats_config = CheatsConfig::new( &self.config, self.evm_opts.clone(), Some(known_contracts), Some(artifact_id.clone()), - )); - ExecutorBuilder::new() + false, + ); + cheats_config.isolate = self.isolation; + let cheats_config = Arc::new(cheats_config); + self.executor_builder + .clone() .inspectors(|stack| { stack + .logs(self.config.live_logs) .cheatcodes(cheats_config) - .trace_mode(self.trace_mode()) + .trace_requirements(self.trace_requirements()) .line_coverage(self.line_coverage) .enable_isolation(self.isolation) - .odyssey(self.odyssey) .create2_deployer(self.evm_opts.create2_deployer) + .set_analysis(analysis) }) .spec_id(self.spec_id) .gas_limit(self.evm_opts.gas_limit()) .legacy_assertions(self.config.legacy_assertions) - .build(self.env.clone(), db) + .build(self.evm_env.clone(), self.tx_env.clone(), db, self.evm_opts.networks) } - fn trace_mode(&self) -> TraceMode { - TraceMode::default() + fn trace_requirements(&self) -> TraceRequirements { + TraceRequirements::none() .with_debug(self.debug) .with_decode_internal(self.decode_internal) - .with_verbosity(self.evm_opts.verbosity) - .with_state_changes(verbosity() > 4) + .with_all_steps(self.record_all_steps) + .with_verbosity(self.config.tracing.verbosity.max(self.evm_opts.verbosity)) } } /// Builder used for instantiating the multi-contract runner -#[derive(Clone, Debug)] +#[derive(Clone)] #[must_use = "builders do nothing unless you call `build` on them"] pub struct MultiContractRunnerBuilder { /// The address which will be used to deploy the initial contracts and send all @@ -388,173 +585,542 @@ pub struct MultiContractRunnerBuilder { pub sender: Option
, /// The initial balance for each one of the deployed smart contracts pub initial_balance: U256, - /// The EVM spec to use - pub evm_spec: Option, - /// The fork to use at launch - pub fork: Option, /// Project config. pub config: Arc, + /// Parsed inline configuration. + pub inline_config: Arc, /// Whether or not to collect line coverage info pub line_coverage: bool, /// Whether or not to collect debug info pub debug: bool, /// Whether to enable steps tracking in the tracer. pub decode_internal: InternalTraceMode, + /// Whether to record every opcode step without debugger snapshots. + pub record_all_steps: bool, /// Whether to enable call isolation pub isolation: bool, - /// Whether to enable Odyssey features. - pub odyssey: bool, + /// Whether to exit early on test failure. + pub fail_fast: bool, + /// Multi-network pass configuration. + pub multi_network: MultiNetworkConfig, + /// Showmap replay mode (CLI-only, off by default). + pub showmap: Option, + /// Run only fuzz and invariant tests. + pub fuzz_only: bool, + /// Replay persisted fuzz failures without running a new fuzz campaign. + pub fuzz_failure_replay: bool, + /// Validated explicit stateless fuzz failure to replay. + pub fuzz_input: Option, + /// Symbolic artifact replay mode (CLI-only, off by default). + pub symbolic_artifact_replay: Option, + /// Whether the configured CREATE2 deployer is available in the execution environment. + pub create2_deployer_available: Option, } impl MultiContractRunnerBuilder { - pub fn new(config: Arc) -> Self { + fn create2_deployer_available( + &self, + evm_opts: &EvmOpts, + backend: &Backend, + ) -> bool { + self.create2_deployer_available.unwrap_or_else(|| { + !backend.is_in_forking_mode() + && evm_opts.fork_url.is_none() + && evm_opts.create2_deployer == foundry_evm::constants::DEFAULT_CREATE2_DEPLOYER + }) + } + + pub fn new(config: Arc, inline_config: Arc) -> Self { Self { config, - sender: Default::default(), - initial_balance: Default::default(), - evm_spec: Default::default(), - fork: Default::default(), - line_coverage: Default::default(), - debug: Default::default(), - isolation: Default::default(), + inline_config, + sender: None, + initial_balance: U256::ZERO, + line_coverage: false, + debug: false, + isolation: false, decode_internal: Default::default(), - odyssey: Default::default(), + record_all_steps: false, + fail_fast: false, + multi_network: Default::default(), + showmap: None, + fuzz_only: false, + fuzz_failure_replay: false, + fuzz_input: None, + symbolic_artifact_replay: None, + create2_deployer_available: None, } } - pub fn sender(mut self, sender: Address) -> Self { - self.sender = Some(sender); + pub const fn with_create2_deployer_available(mut self, available: bool) -> Self { + self.create2_deployer_available = Some(available); self } - pub fn initial_balance(mut self, initial_balance: U256) -> Self { - self.initial_balance = initial_balance; + pub fn with_showmap(mut self, showmap: Option) -> Self { + self.showmap = showmap; self } - pub fn evm_spec(mut self, spec: SpecId) -> Self { - self.evm_spec = Some(spec); + pub const fn with_fuzz_only(mut self, fuzz_only: bool) -> Self { + self.fuzz_only = fuzz_only; self } - pub fn with_fork(mut self, fork: Option) -> Self { - self.fork = fork; + pub const fn with_fuzz_failure_replay(mut self, fuzz_failure_replay: bool) -> Self { + self.fuzz_failure_replay = fuzz_failure_replay; self } - pub fn set_coverage(mut self, enable: bool) -> Self { + pub fn with_fuzz_input(mut self, fuzz_input: Option) -> Self { + self.fuzz_input = fuzz_input; + self + } + + pub fn with_symbolic_artifact_replay( + mut self, + replay: Option, + ) -> Self { + self.symbolic_artifact_replay = replay; + self + } + + pub const fn sender(mut self, sender: Address) -> Self { + self.sender = Some(sender); + self + } + + pub const fn initial_balance(mut self, initial_balance: U256) -> Self { + self.initial_balance = initial_balance; + self + } + + pub const fn set_coverage(mut self, enable: bool) -> Self { self.line_coverage = enable; self } - pub fn set_debug(mut self, enable: bool) -> Self { + pub const fn set_debug(mut self, enable: bool) -> Self { self.debug = enable; self } - pub fn set_decode_internal(mut self, mode: InternalTraceMode) -> Self { + pub const fn set_decode_internal(mut self, mode: InternalTraceMode) -> Self { self.decode_internal = mode; self } - pub fn enable_isolation(mut self, enable: bool) -> Self { - self.isolation = enable; + pub const fn set_record_all_steps(mut self, enable: bool) -> Self { + self.record_all_steps = enable; + self + } + + pub fn with_multi_network(mut self, multi_network: MultiNetworkConfig) -> Self { + self.multi_network = multi_network; self } - pub fn odyssey(mut self, enable: bool) -> Self { - self.odyssey = enable; + pub const fn fail_fast(mut self, fail_fast: bool) -> Self { + self.fail_fast = fail_fast; + self + } + + pub const fn enable_isolation(mut self, enable: bool) -> Self { + self.isolation = enable; self } /// Given an EVM, proceeds to return a runner which is able to execute all tests /// against that evm - pub fn build>( + pub fn build>( self, - root: &Path, output: &ProjectCompileOutput, - env: Env, + mut evm_env: EvmEnvFor, + tx_env: TxEnvFor, evm_opts: EvmOpts, - ) -> Result { - let contracts = output - .artifact_ids() - .map(|(id, v)| (id.with_stripped_file_prefixes(root), v)) - .collect(); + backend: Backend, + executor_builder: ExecutorBuilder, + ) -> Result> { + let root = &self.config.root; + let coverage_artifacts = self + .line_coverage + .then(|| self.config.coverage_cache_path()) + .flatten() + .map(|path| path.join("artifacts")); + let artifact_id = |mut id: ArtifactId| { + // Artifact-path cheatcodes retain the logical output paths even when coverage + // compilation stores its unoptimized artifacts in a separate cache. + if let Some(coverage_artifacts) = &coverage_artifacts + && let Ok(path) = id.path.strip_prefix(coverage_artifacts) + { + id.path = self.config.out.join(path); + } + id.with_stripped_file_prefixes(root).with_slashed_paths() + }; + let contracts = output.artifact_ids().map(|(id, v)| (artifact_id(id), v)).collect(); let linker = Linker::new(root, contracts); // Build revert decoder from ABIs of all artifacts. let abis = linker .contracts - .iter() - .filter_map(|(_, contract)| contract.abi.as_ref().map(|abi| abi.borrow())); + .values() + .filter_map(|contract| contract.abi.as_ref().map(|abi| abi.borrow())); let revert_decoder = RevertDecoder::new().with_abis(abis); - let LinkOutput { libraries, libs_to_deploy } = linker.link_with_nonce_or_address( - Default::default(), - LIBRARY_DEPLOYER, - 0, - linker.contracts.keys(), - )?; - - let linked_contracts = linker.get_linked_artifacts(&libraries)?; - - // Create a mapping of name => (abi, deployment code, Vec) + let configured_libraries = self.config.libraries_with_remappings()?; + let create2 = if self.create2_deployer_available(&evm_opts, &backend) { + match linker.link_with_create2_detailed( + configured_libraries.clone(), + evm_opts.create2_deployer, + self.config.create2_library_salt, + linker.contracts.keys(), + ) { + Ok(output) => Some(output), + Err(LinkerError::CyclicDependency) => None, + Err(err) => return Err(err.into()), + } + } else { + None + }; + let ( + DetailedLinkOutput { + output: LinkOutput { libraries, library_addresses, libs_to_deploy }, + artifact_libraries, + .. + }, + library_deployment, + ) = match create2 { + Some(output) => { + let deployment = if output.output.libs_to_deploy.is_empty() { + LibraryDeployment::Nonce + } else { + LibraryDeployment::Create2 { + deployer: evm_opts.create2_deployer, + salt: self.config.create2_library_salt, + } + }; + (output, deployment) + } + None => ( + linker.link_with_nonce_or_address_detailed( + configured_libraries, + LIBRARY_DEPLOYER, + 0, + linker.contracts.keys(), + )?, + LibraryDeployment::Nonce, + ), + }; + + let linked_contracts = linker + .get_linked_artifacts_cow_with_artifact_libraries(&libraries, &artifact_libraries)?; + let inline_config = self.inline_config; + + // Collect every deployable test contract: a test contract with a default constructor. let mut deployable_contracts = DeployableContracts::default(); - + let test_matcher = TestFunctionMatcher::new( + &self.config, + &inline_config, + self.symbolic_artifact_replay.as_ref(), + ); + let empty_filter = EmptyTestFilter::default(); + let resolver = Resolver::new(&linker); for (id, contract) in linked_contracts.iter() { - let Some(abi) = &contract.abi else { continue }; - - // if it's a test, link it and add to deployable contracts - if abi.constructor.as_ref().map(|c| c.inputs.is_empty()).unwrap_or(true) - && abi.functions().any(|func| func.name.is_any_test()) + let Some(abi) = contract.abi.as_ref() else { continue }; + if abi.constructor.as_ref().is_some_and(|c| !c.inputs.is_empty()) + || !test_matcher.matches_contract(&empty_filter, id, abi) { - let Some(bytecode) = - contract.get_bytecode_bytes().map(|b| b.into_owned()).filter(|b| !b.is_empty()) - else { - continue; - }; - - deployable_contracts - .insert(id.clone(), TestContract { abi: abi.clone(), bytecode }); + continue; } + linker.ensure_linked(contract, id)?; + let Some(bytecode) = + contract.get_bytecode_bytes().map(|b| b.into_owned()).filter(|b| !b.is_empty()) + else { + continue; + }; + let artifact_libraries = artifact_libraries.get(id).unwrap_or(&libraries); + let library_addresses = resolver.linked_library_addresses(id, artifact_libraries)?; + deployable_contracts.insert( + id.clone(), + TestContract { abi: abi.clone().into_owned(), bytecode, library_addresses }, + ); } - let known_contracts = ContractsByArtifact::new(linked_contracts); + // Create known contracts from linked contracts and storage layout information (if any). + let known_contracts = ContractsByArtifactBuilder::new(linked_contracts) + .with_storage_layouts(output.artifact_ids().filter_map(|(id, artifact)| { + artifact.storage_layout.as_ref().map(|layout| (artifact_id(id), layout.clone())) + })) + .build(); + + // Initialize and configure the solar compiler. + let mut analysis = solar::sema::Compiler::new( + solar::interface::Session::builder().with_stderr_emitter().build(), + ); + let dcx = analysis.dcx_mut(); + dcx.set_emitter(Box::new( + solar::interface::diagnostics::HumanEmitter::stderr(Default::default()) + .source_map(Some(dcx.source_map().unwrap())), + )); + dcx.set_flags_mut(|f| f.track_diagnostics = false); + + // Populate solar's global context by parsing and lowering the sources. + let files: Vec<_> = output.output().sources.as_ref().keys().cloned().collect(); + analysis.enter_mut(|compiler| -> Result<()> { + let mut pcx = compiler.parse(); + configure_pcx_from_compile_output( + &mut pcx, + &self.config, + output, + (!self.line_coverage && !files.is_empty()).then_some(&files), + )?; + pcx.parse(); + let _ = compiler.lower_asts(); + Ok(()) + })?; + let analysis = Arc::new(analysis); + + // Enum variant counts used to constrain fuzzed enum inputs to valid values. + let enum_bounds = EnumBounds::collect(&analysis); + let literals = |max_literals| { + LiteralsDictionary::new( + Some(analysis.clone()), + Some(self.config.project_paths()), + max_literals, + ) + }; + let fuzz_max_literals = self.config.fuzz.dictionary.max_fuzz_dictionary_literals; + let invariant_max_literals = self.config.invariant.dictionary.max_fuzz_dictionary_literals; + let fuzz_literals = literals(fuzz_max_literals); + let invariant_literals = if invariant_max_literals == fuzz_max_literals { + fuzz_literals.clone() + } else { + literals(invariant_max_literals) + }; + + let fork_context = backend.fork()?.as_ref().map(|fork| fork.context()); + let fork_chain_id = fork_context.map(|context| context.source_chain_id); + let fork_hardfork = fork_context.and_then(|context| context.hardfork); + let hardfork = resolve_execution_spec( + self.config.evm_version, + self.config.hardfork, + &mut evm_env, + ExecutionSpecContext::local_or_fork(fork_chain_id, fork_hardfork), + None, + ); + let spec_id = evm_env.cfg_env.spec; Ok(MultiContractRunner { contracts: deployable_contracts, revert_decoder, known_contracts, libs_to_deploy, + library_addresses, + library_deployment, libraries, - - fork: self.fork, + analysis, + fuzz_literals, + invariant_literals, + enum_bounds, tcfg: TestRunnerConfig { evm_opts, - env, - spec_id: self.evm_spec.unwrap_or_else(|| self.config.evm_spec_id()), + executor_builder, + evm_env, + tx_env, + spec_id, + hardfork, + fork_chain_id, + fork_hardfork, sender: self.sender.unwrap_or(self.config.sender), - line_coverage: self.line_coverage, debug: self.debug, decode_internal: self.decode_internal, - inline_config: Arc::new(InlineConfig::new_parsed(output, &self.config)?), + record_all_steps: self.record_all_steps, + inline_config, isolation: self.isolation, - odyssey: self.odyssey, - + early_exit: EarlyExit::new(self.fail_fast), + multi_network: self.multi_network, + showmap: self.showmap, + fuzz_minimize: None, + fuzz_only: self.fuzz_only, + fuzz_failure_replay: self.fuzz_failure_replay, + fuzz_input: self.fuzz_input, + symbolic_artifact_replay: self.symbolic_artifact_replay, config: self.config, }, + + backend, + }) + } +} + +#[derive(Clone, Copy)] +pub(crate) struct TestFunctionMatcher<'a> { + config: &'a Config, + inline_config: &'a InlineConfig, + symbolic_artifact_replay: Option<&'a SymbolicArtifactReplayConfig>, +} + +impl<'a> TestFunctionMatcher<'a> { + pub(crate) const fn new( + config: &'a Config, + inline_config: &'a InlineConfig, + symbolic_artifact_replay: Option<&'a SymbolicArtifactReplayConfig>, + ) -> Self { + Self { config, inline_config, symbolic_artifact_replay } + } + + fn symbolic_tests_enabled(&self, contract_id: &str) -> bool { + self.symbolic_artifact_replay.is_some_and(|artifact| { + artifact.artifact.kind == SymbolicCounterexampleArtifactKind::SingleCall + }) || self.inline_config.contract_symbolic_enabled( + &self.config.profile, + contract_id, + self.config.symbolic.enabled, + ) + } + + pub(crate) fn test_function_kind( + &self, + contract_id: &str, + func: &Function, + generated_symbolic_regression: bool, + ) -> TestFunctionKind { + if generated_symbolic_regression && !func.name.starts_with("test_regression_") { + return TestFunctionKind::Unknown; + } + + TestFunctionKind::classify( + func.name.as_str(), + !func.inputs.is_empty(), + self.symbolic_tests_enabled(contract_id), + ) + } + + /// Returns the functions of `abi` accepted by `keep`, which is given the contract identifier, + /// the function and its classification. + pub(crate) fn test_functions( + self, + contract_id: String, + abi: &JsonAbi, + mut keep: impl FnMut(&str, &Function, TestFunctionKind) -> bool, + ) -> impl Iterator { + let generated_symbolic_regression = is_generated_symbolic_regression_contract(abi); + abi.functions().filter(move |func| { + let kind = self.test_function_kind(&contract_id, func, generated_symbolic_regression); + keep(&contract_id, func, kind) + }) + } + + /// Returns the test functions of `abi` that match `filter`. + fn matching_test_functions<'b>( + self, + filter: &dyn TestFilter, + id: &ArtifactId, + abi: &'b JsonAbi, + ) -> impl Iterator { + self.test_functions(id.identifier(), abi, move |contract_id, func, kind| { + filter.matches_test_function_kind_in_contract(contract_id, func, kind) }) } + + /// Counts the fuzz test functions and runnable invariant campaign anchors of `abi` that + /// match `filter` in the current network pass. + pub(crate) fn count_fuzz_engine_targets( + &self, + filter: &dyn TestFilter, + id: &ArtifactId, + abi: &JsonAbi, + multi_network: &MultiNetworkConfig, + ) -> (usize, usize) { + let contract_name = id.identifier(); + let matches_network_pass = |func: &Function| { + function_matches_network_pass( + &multi_network.all_override_networks, + multi_network.pass_network.as_ref(), + self.inline_config.network_for(&self.config.profile, &contract_name, &func.name), + ) + }; + let fuzz = self + .test_functions(contract_name.clone(), abi, |contract_id, func, kind| { + kind.is_fuzz_test() + && filter.matches_test_function_kind_in_contract(contract_id, func, kind) + && matches_network_pass(func) + }) + .count(); + let invariant = count_runnable_invariant_campaign_anchors( + abi, + filter, + InvariantCampaignScope { + config: self.config, + inline_config: self.inline_config, + contract_name: &contract_name, + all_override_networks: &multi_network.all_override_networks, + pass_network: multi_network.pass_network.as_ref(), + }, + ); + (fuzz, invariant) + } + + pub(crate) fn matches_contract( + &self, + filter: &dyn TestFilter, + id: &ArtifactId, + abi: &JsonAbi, + ) -> bool { + external_artifact_is_test_eligible(&id.build_id) + && filter.matches_path(&id.source) + && filter.matches_contract(&id.name) + && self.matching_test_functions(filter, id, abi).next().is_some() + } } -pub fn matches_contract(id: &ArtifactId, abi: &JsonAbi, filter: &dyn TestFilter) -> bool { - (filter.matches_path(&id.source) && filter.matches_contract(&id.name)) - && abi.functions().any(|func| is_matching_test(func, filter)) +pub(crate) fn is_generated_symbolic_regression_contract(abi: &JsonAbi) -> bool { + abi.functions().any(|func| func.name == SYMBOLIC_REGRESSION_MARKER && func.inputs.is_empty()) } -/// Returns `true` if the function is a test function that matches the given filter. -pub(crate) fn is_matching_test(func: &Function, filter: &dyn TestFilter) -> bool { - func.is_any_test() && filter.matches_test(&func.signature()) +#[cfg(test)] +mod tests { + use super::*; + use foundry_evm::core::evm::EthEvmNetwork; + + fn abi_with_functions(functions: &[&str]) -> JsonAbi { + let mut abi = JsonAbi::new(); + for function in functions { + let function = Function::parse(function).unwrap(); + abi.functions.entry(function.name.clone()).or_default().push(function); + } + abi + } + + #[test] + fn generated_symbolic_regression_detection_uses_marker() { + let user_suffix_abi = abi_with_functions(&["test_fails()"]); + assert!(!is_generated_symbolic_regression_contract(&user_suffix_abi)); + + let generated_abi = + abi_with_functions(&[&format!("{SYMBOLIC_REGRESSION_MARKER}()"), "test_fails()"]); + assert!(is_generated_symbolic_regression_contract(&generated_abi)); + } + + #[test] + fn create2_deployer_availability_default_is_conservative() { + let config = Arc::new(Config::default()); + let builder = MultiContractRunnerBuilder::new(config, Arc::new(InlineConfig::new())); + let mut evm_opts = EvmOpts::default(); + let backend = Backend::::spawn(None).unwrap(); + assert!(builder.create2_deployer_available(&evm_opts, &backend)); + + evm_opts.fork_url = Some("http://localhost:8545".into()); + assert!(!builder.create2_deployer_available(&evm_opts, &backend)); + evm_opts.fork_url = None; + evm_opts.create2_deployer = Address::ZERO; + assert!(!builder.create2_deployer_available(&evm_opts, &backend)); + assert!( + builder + .with_create2_deployer_available(true) + .create2_deployer_available(&evm_opts, &backend) + ); + } } diff --git a/crates/forge/src/mutation/mod.rs b/crates/forge/src/mutation/mod.rs new file mode 100644 index 0000000000000..ae15825257de4 --- /dev/null +++ b/crates/forge/src/mutation/mod.rs @@ -0,0 +1,757 @@ +use std::{ + collections::{BTreeMap, HashSet, hash_map::DefaultHasher}, + hash::{Hash, Hasher}, + path::{Path, PathBuf}, + sync::Arc, +}; + +use crate::mutation::{ + mutant::{Mutant, MutationResult}, + orchestrator::project_relative_path, + visitor::MutantVisitor, +}; +pub use crate::mutation::{ + orchestrator::{MutationRunConfig, MutationRunResult, run_mutation_testing}, + progress::MutationProgress, + reporter::MutationReporter, + runner::run_mutations_parallel_with_progress, +}; +use eyre::eyre; +use foundry_common::sh_warn; +use serde::{Deserialize, Serialize}; +use solar::{ + ast::{ + Span, + interface::{Session, source_map::FileName}, + visit::Visit, + }, + parse::Parser, +}; + +#[derive(Clone, Copy)] +enum CacheKind<'a> { + Mutants, + Results { execution_key: &'a str }, + Survived { execution_key: &'a str }, +} + +#[derive(Serialize, Deserialize)] +struct CachedMutationResults { + mutant_count: usize, + mutant_hash: u64, + results: Vec<(Mutant, MutationResult)>, +} + +pub mod mutant; +mod mutators; +pub mod orchestrator; +pub mod progress; +mod reporter; +pub mod runner; +mod type_analysis; +mod visitor; + +pub struct MutationsSummary { + dead: Vec, + survived: Vec, + invalid: Vec, + skipped: Vec, + /// Mutants whose compile-and-test work exceeded the configured timeout. + /// Tracked separately so they are not counted toward survived/killed. + timed_out: Vec, +} + +impl Default for MutationsSummary { + fn default() -> Self { + Self::new() + } +} + +impl MutationsSummary { + pub const fn new() -> Self { + Self { + dead: Vec::new(), + survived: Vec::new(), + invalid: Vec::new(), + skipped: Vec::new(), + timed_out: Vec::new(), + } + } + + pub fn update_invalid_mutant(&mut self, mutant: Mutant) { + self.invalid.push(mutant); + } + + pub fn add_dead_mutant(&mut self, mutant: Mutant) { + self.dead.push(mutant); + } + + pub fn add_survived_mutant(&mut self, mutant: Mutant) { + self.survived.push(mutant); + } + + pub fn add_skipped_mutant(&mut self, mutant: Mutant) { + self.skipped.push(mutant); + } + + pub fn add_timed_out_mutant(&mut self, mutant: Mutant) { + self.timed_out.push(mutant); + } + + pub const fn total_mutants(&self) -> usize { + self.dead.len() + + self.survived.len() + + self.invalid.len() + + self.skipped.len() + + self.timed_out.len() + } + + pub const fn total_dead(&self) -> usize { + self.dead.len() + } + + pub const fn total_survived(&self) -> usize { + self.survived.len() + } + + pub const fn total_invalid(&self) -> usize { + self.invalid.len() + } + + pub const fn total_skipped(&self) -> usize { + self.skipped.len() + } + + pub const fn total_timed_out(&self) -> usize { + self.timed_out.len() + } + + pub const fn get_dead(&self) -> &Vec { + &self.dead + } + + pub const fn get_survived(&self) -> &Vec { + &self.survived + } + + pub const fn get_invalid(&self) -> &Vec { + &self.invalid + } + + pub const fn get_timed_out(&self) -> &Vec { + &self.timed_out + } + + /// Merge another MutationsSummary into this one + pub fn merge(&mut self, other: &Self) { + self.dead.extend(other.dead.clone()); + self.survived.extend(other.survived.clone()); + self.invalid.extend(other.invalid.clone()); + self.skipped.extend(other.skipped.clone()); + self.timed_out.extend(other.timed_out.clone()); + } + + /// Calculate mutation score (percentage of dead mutants out of valid mutants) + /// Higher scores indicate better test coverage + pub fn mutation_score(&self) -> f64 { + let valid_mutants = self.dead.len() + self.survived.len(); + if valid_mutants == 0 { 0.0 } else { self.dead.len() as f64 / valid_mutants as f64 * 100.0 } + } + + /// Mutants that reached a test verdict and can contribute to the score. + pub const fn total_evaluated(&self) -> usize { + self.dead.len() + self.survived.len() + } + + /// Whether the score is useful enough to present as a coverage signal. + pub const fn has_reliable_score(&self) -> bool { + self.total_evaluated() > 0 && self.timed_out.len() < self.total_evaluated() + } + + /// Convert to JSON output format. + /// + /// Output is sorted deterministically: files in lexicographic order + /// (`BTreeMap` keys), and survived mutants within each file sorted by + /// `(line, column, original, mutant)`. Without this, parallel worker + /// completion order leaks into the JSON and breaks downstream diffing, + /// snapshot tests, and reproducibility. + pub fn to_json_output(&self, duration_secs: f64) -> MutationJsonOutput { + let mut survived_mutants: BTreeMap> = BTreeMap::new(); + + for mutant in &self.survived { + let file_path = mutant.relative_path(); + let entry = survived_mutants.entry(file_path).or_default(); + entry.push(SurvivedMutantJson::from_mutant(mutant)); + } + + for entries in survived_mutants.values_mut() { + entries.sort_by(|a, b| { + (a.line, a.column, &a.original, &a.mutant).cmp(&( + b.line, + b.column, + &b.original, + &b.mutant, + )) + }); + } + + MutationJsonOutput { + summary: MutationSummaryJson { + total: self.total_mutants(), + killed: self.total_dead(), + survived: self.total_survived(), + invalid: self.total_invalid(), + skipped: self.total_skipped(), + timed_out: self.total_timed_out(), + mutation_score: self.mutation_score(), + duration_secs, + }, + survived_mutants, + } + } +} + +/// JSON output for mutation testing results. +/// +/// Uses [`BTreeMap`] for `survived_mutants` so file ordering in the emitted +/// JSON is deterministic. +#[derive(Debug, Clone, Serialize)] +pub struct MutationJsonOutput { + pub summary: MutationSummaryJson, + pub survived_mutants: BTreeMap>, +} + +/// Summary section of JSON output +#[derive(Debug, Clone, Serialize)] +pub struct MutationSummaryJson { + pub total: usize, + pub killed: usize, + pub survived: usize, + pub invalid: usize, + pub skipped: usize, + pub timed_out: usize, + pub mutation_score: f64, + pub duration_secs: f64, +} + +/// Individual survived mutant in JSON output +#[derive(Debug, Clone, Serialize)] +pub struct SurvivedMutantJson { + pub line: usize, + pub column: usize, + pub original: String, + pub mutant: String, +} + +impl SurvivedMutantJson { + /// Create from a Mutant, using the full original expression + pub fn from_mutant(mutant: &Mutant) -> Self { + Self { + line: mutant.line_number, + column: mutant.column_number, + original: mutant.original.clone(), + mutant: mutant.mutation.to_string(), + } + } +} + +/// Tracks spans where mutations have survived (weren't killed by tests). +/// Used for adaptive mutation testing to skip redundant mutations. +#[derive(Debug, Clone, Default)] +pub struct SurvivedSpans { + spans: HashSet<(u32, u32)>, // (lo, hi) byte positions +} + +impl SurvivedSpans { + pub fn new() -> Self { + Self { spans: HashSet::new() } + } + + /// Mark a span as having a surviving mutation + pub fn mark_survived(&mut self, span: Span) { + self.spans.insert((span.lo().0, span.hi().0)); + } + + /// Check if any survived parent span contains this span. + /// + /// Exact span matches are not skipped: a persisted survived-span cache only + /// records byte ranges, not which mutant at that range survived. Re-testing + /// exact spans after an interrupted run keeps known survivors from being + /// converted into `Skipped` results in the next complete cache. + pub fn should_skip(&self, span: Span) -> bool { + let (lo, hi) = (span.lo().0, span.hi().0); + + self.spans.iter().any(|&(parent_lo, parent_hi)| { + parent_lo <= lo && hi <= parent_hi && (parent_lo != lo || parent_hi != hi) + }) + } + + /// Serialize to a list of (lo, hi) pairs for caching + fn to_vec(&self) -> Vec<(u32, u32)> { + self.spans.iter().copied().collect() + } + + /// Deserialize from a list of (lo, hi) pairs + fn from_vec(pairs: Vec<(u32, u32)>) -> Self { + Self { spans: pairs.into_iter().collect() } + } +} + +pub struct MutationHandler { + contract_to_mutate: PathBuf, + pub src: Arc, + pub mutations: Vec, + config: Arc, + report: MutationsSummary, + survived_spans: SurvivedSpans, + /// Optional regex used to restrict mutation to specific contracts within + /// the file (matches against contract name). + contract_filter: Option, + mutation_exclusions: type_analysis::MutationExclusionSet, +} + +impl MutationHandler { + pub fn new(contract_to_mutate: PathBuf, config: Arc) -> Self { + Self { + contract_to_mutate, + src: Arc::default(), + mutations: vec![], + config, + report: MutationsSummary::new(), + survived_spans: SurvivedSpans::new(), + contract_filter: None, + mutation_exclusions: type_analysis::MutationExclusionSet::new(), + } + } + + /// Restrict mutation to contracts whose name matches `filter`. + pub fn with_contract_filter(mut self, filter: regex::Regex) -> Self { + self.contract_filter = Some(filter); + self + } + + /// Exclude operator replacements rejected by type analysis. + pub(crate) fn with_mutation_exclusions( + mut self, + mutations: type_analysis::MutationExclusionSet, + ) -> Self { + self.mutation_exclusions = mutations; + self + } + + pub fn read_source_contract(&mut self) -> Result<(), std::io::Error> { + let content = std::fs::read_to_string(&self.contract_to_mutate)?; + self.src = Arc::new(content); + Ok(()) + } + + /// Add a dead mutant to the report + pub fn add_dead_mutant(&mut self, mutant: Mutant) { + self.report.add_dead_mutant(mutant); + } + + /// Add a survived mutant to the report + pub fn add_survived_mutant(&mut self, mutant: Mutant) { + self.report.add_survived_mutant(mutant); + } + + /// Add an invalid mutant to the report + pub fn add_invalid_mutant(&mut self, mutant: Mutant) { + self.report.update_invalid_mutant(mutant); + } + + pub fn add_skipped_mutant(&mut self, mutant: Mutant) { + self.report.add_skipped_mutant(mutant); + } + + pub fn add_timed_out_mutant(&mut self, mutant: Mutant) { + self.report.add_timed_out_mutant(mutant); + } + + /// Get a reference to the current report + pub const fn get_report(&self) -> &MutationsSummary { + &self.report + } + + // Note: we now get the build hash directly from the recent compile output (see test flow) + + /// Returns the cache file path for the given build hash and cache kind. + /// The filename encodes a hash of the full contract path to prevent collisions + /// between files with the same stem in different directories, and a hash of + /// the active mutation config so changes to enabled operators invalidate + /// previously cached mutants. Result-like caches also include an execution + /// key so stale outcomes are not reused after test/config/EVM changes. + fn cache_file_path(&self, hash: &str, kind: CacheKind<'_>) -> PathBuf { + let mut hasher = DefaultHasher::new(); + self.contract_to_mutate.hash(&mut hasher); + let path_hash = hasher.finish(); + + // Hash the effective set of enabled mutation operators so mutant cache + // entries are invalidated when the user changes `include_operators` / + // `exclude_operators` in their config. + // + // Also fold in the active `--mutate-contract` regex pattern, because + // running with vs. without that filter produces a different mutant set + // for the same file. + let mut mutant_cfg_hasher = DefaultHasher::new(); + // Version salt for this mutant-set cache schema. Bump this if the + // inputs that define generated mutants change. + "mutant-set-v8".hash(&mut mutant_cfg_hasher); + for op in self.config.mutation.enabled_operators() { + op.to_string().hash(&mut mutant_cfg_hasher); + } + match self.contract_filter.as_ref() { + Some(re) => { + "filter:".hash(&mut mutant_cfg_hasher); + re.as_str().hash(&mut mutant_cfg_hasher); + } + None => "nofilter".hash(&mut mutant_cfg_hasher), + } + let mut exclusion_hashes = self + .mutation_exclusions + .iter() + .map(|exclusion| { + let mut hasher = DefaultHasher::new(); + exclusion.hash(&mut hasher); + hasher.finish() + }) + .collect::>(); + exclusion_hashes.sort_unstable(); + "exclusions:".hash(&mut mutant_cfg_hasher); + exclusion_hashes.len().hash(&mut mutant_cfg_hasher); + for exclusion_hash in exclusion_hashes { + exclusion_hash.hash(&mut mutant_cfg_hasher); + } + let mutant_cfg_hash = mutant_cfg_hasher.finish(); + + let (ext, execution_suffix) = match kind { + CacheKind::Mutants => ("mutants", String::new()), + CacheKind::Results { execution_key } => ("results", format!("_{execution_key}")), + CacheKind::Survived { execution_key } => ("survived", format!("_{execution_key}")), + }; + + let stem = + self.contract_to_mutate.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown"); + self.config.root.join(&self.config.mutation_dir).join(format!( + "{hash}_{stem}_{path_hash:x}_{mutant_cfg_hash:x}{execution_suffix}.{ext}" + )) + } + + /// Persists cached mutants using build hash for cache invalidation. + pub fn persist_cached_mutants(&self, hash: &str, mutants: &[Mutant]) -> std::io::Result<()> { + let cache_file = self.cache_file_path(hash, CacheKind::Mutants); + if let Some(dir) = cache_file.parent() { + std::fs::create_dir_all(dir)?; + } + let json = serde_json::to_string_pretty(mutants).map_err(std::io::Error::other)?; + std::fs::write(cache_file, json) + } + + /// Persists results for mutants using build hash for cache invalidation. + pub fn persist_cached_results( + &self, + hash: &str, + execution_key: &str, + mutants: &[Mutant], + results: &[(Mutant, crate::mutation::mutant::MutationResult)], + ) -> std::io::Result<()> { + let cache_file = self.cache_file_path(hash, CacheKind::Results { execution_key }); + if let Some(dir) = cache_file.parent() { + std::fs::create_dir_all(dir)?; + } + let cached = CachedMutationResults { + mutant_count: mutants.len(), + mutant_hash: mutant_set_hash(mutants), + results: results.to_vec(), + }; + let json = serde_json::to_string_pretty(&cached).map_err(std::io::Error::other)?; + std::fs::write(cache_file, json) + } + + /// Read a source string, and for each contract found, gets its ast and visit it to list + /// all mutations to conduct. + pub async fn generate_ast(&mut self) -> eyre::Result<()> { + let path = &self.contract_to_mutate; + let target_content = Arc::clone(&self.src); + let sess = Session::builder().with_silent_emitter(None).build(); + + let contract_filter = self.contract_filter.clone(); + + let result = sess.enter(|| -> eyre::Result> { + let arena = solar::ast::Arena::new(); + let mut parser = + Parser::from_lazy_source_code(&sess, &arena, FileName::from(path.clone()), || { + Ok((*target_content).clone()) + }) + .map_err(|_e| failed_to_parse(path))?; + + let ast = parser.parse_file().map_err(|e| { + e.emit(); + failed_to_parse(path) + })?; + drop(parser); + + // Store the root-relative path in mutants so reports do not depend on where the + // project is. + let mutant_path = + project_relative_path(&self.config.root, path).unwrap_or_else(|| path.clone()); + let operators = self.config.mutation.enabled_operators(); + let mut mutant_visitor = MutantVisitor::with_operators(mutant_path, &operators) + .with_source(&target_content) + .with_mutation_exclusions(self.mutation_exclusions.clone()); + + if let Some(filter) = contract_filter { + mutant_visitor = + mutant_visitor.with_contract_filter(move |name| filter.is_match(name)); + } + let _ = mutant_visitor.visit_source_unit(&ast); + + for err in mutant_visitor.take_errors() { + let _ = sh_warn!("{err:?}"); + } + + Ok(mutant_visitor.mutation_to_conduct) + }); + + match result { + Ok(mutations) => { + self.mutations.extend(mutations); + Ok(()) + } + Err(err) => Err(err), + } + } + + /// Retrieves cached mutants using build hash. + pub fn retrieve_cached_mutants(&self, hash: &str) -> Option> { + let cache_file = self.cache_file_path(hash, CacheKind::Mutants); + let data = std::fs::read_to_string(cache_file).ok()?; + serde_json::from_str(&data).ok() + } + + /// Retrieves cached results using build hash. + pub fn retrieve_cached_mutant_results( + &self, + hash: &str, + execution_key: &str, + mutants: &[Mutant], + ) -> Option> { + let cache_file = self.cache_file_path(hash, CacheKind::Results { execution_key }); + let data = std::fs::read_to_string(cache_file).ok()?; + let cached: CachedMutationResults = serde_json::from_str(&data).ok()?; + (cached.mutant_count == mutants.len() && cached.mutant_hash == mutant_set_hash(mutants)) + .then_some(cached.results) + } + + /// Mark a span as having a surviving mutation + pub fn mark_span_survived(&mut self, span: Span) { + self.survived_spans.mark_survived(span); + } + + /// Check if a span should be skipped (has survived mutation or is child of survived span) + pub fn should_skip_span(&self, span: Span) -> bool { + self.survived_spans.should_skip(span) + } + + /// Persist survived spans to cache for adaptive mutation testing. + pub fn persist_survived_spans(&self, hash: &str, execution_key: &str) -> std::io::Result<()> { + let cache_file = self.cache_file_path(hash, CacheKind::Survived { execution_key }); + if let Some(dir) = cache_file.parent() { + std::fs::create_dir_all(dir)?; + } + let spans = self.survived_spans.to_vec(); + let json = serde_json::to_string_pretty(&spans).map_err(std::io::Error::other)?; + std::fs::write(cache_file, json) + } + + /// Retrieve survived spans from cache. + pub fn retrieve_survived_spans(&mut self, hash: &str, execution_key: &str) -> bool { + let cache_file = self.cache_file_path(hash, CacheKind::Survived { execution_key }); + + if let Ok(data) = std::fs::read_to_string(cache_file) + && let Ok(pairs) = serde_json::from_str::>(&data) + { + self.survived_spans = SurvivedSpans::from_vec(pairs); + return true; + } + + false + } +} + +fn failed_to_parse(path: &Path) -> eyre::Report { + eyre!("failed to parse {}", path.display()) +} + +fn mutant_set_hash(mutants: &[Mutant]) -> u64 { + let mut entries: Vec<_> = mutants + .iter() + .map(|mutant| { + ( + mutant.span.lo().0, + mutant.span.hi().0, + mutant.mutation.to_string(), + mutant.original.clone(), + ) + }) + .collect(); + entries.sort(); + + let mut hasher = DefaultHasher::new(); + for entry in entries { + entry.hash(&mut hasher); + } + hasher.finish() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mutation::type_analysis::{AssignmentReplacement, MutationExclusion}; + use foundry_config::Config; + use solar::ast::interface::BytePos; + use tempfile::TempDir; + + fn test_handler(config: Config) -> MutationHandler { + let source = config.root.join("src").join("Counter.sol"); + MutationHandler::new(source, Arc::new(config)) + } + + fn test_config() -> (TempDir, Config) { + let temp = TempDir::new().unwrap(); + let config = Config { + root: temp.path().to_path_buf(), + mutation_dir: "cache/mutation".into(), + ..Default::default() + }; + (temp, config) + } + + fn mutant(lo: u32, hi: u32, original: &str) -> Mutant { + Mutant { + path: PathBuf::from("src/Counter.sol"), + span: Span::new(BytePos(lo), BytePos(hi)), + mutation: mutant::MutationType::DeleteExpression, + original: original.to_string(), + source_line: "number++;".to_string(), + line_number: 1, + column_number: 1, + } + } + + #[test] + fn result_cache_path_includes_execution_key() { + let (_temp, config) = test_config(); + let handler = test_handler(config); + + let first = + handler.cache_file_path("build", CacheKind::Results { execution_key: "exec-a" }); + let second = + handler.cache_file_path("build", CacheKind::Results { execution_key: "exec-b" }); + let mutants = handler.cache_file_path("build", CacheKind::Mutants); + + assert_ne!(first, second); + assert_ne!(first, mutants); + assert_ne!(second, mutants); + } + + #[test] + fn survived_span_cache_path_includes_execution_key() { + let (_temp, config) = test_config(); + let handler = test_handler(config); + + let first = + handler.cache_file_path("build", CacheKind::Survived { execution_key: "exec-a" }); + let second = + handler.cache_file_path("build", CacheKind::Survived { execution_key: "exec-b" }); + + assert_ne!(first, second); + } + + #[test] + fn mutant_cache_path_ignores_execution_only_timeout() { + let (_temp, mut first_config) = test_config(); + let mut second_config = first_config.clone(); + + first_config.mutation.timeout = Some(1); + second_config.mutation.timeout = Some(99); + + let first = test_handler(first_config).cache_file_path("build", CacheKind::Mutants); + let second = test_handler(second_config).cache_file_path("build", CacheKind::Mutants); + + assert_eq!(first, second); + } + + #[test] + fn mutation_cache_paths_include_type_analysis_exclusions() { + let (_temp, config) = test_config(); + let without_exclusions = test_handler(config.clone()); + let exclusion = MutationExclusion::assignment( + Span::new(BytePos(10), BytePos(20)), + AssignmentReplacement::Zero, + ); + let with_exclusions = test_handler(config).with_mutation_exclusions([exclusion].into()); + without_exclusions.persist_cached_mutants("build", &[mutant(10, 20, "account")]).unwrap(); + + assert!(with_exclusions.retrieve_cached_mutants("build").is_none()); + + for kind in [ + CacheKind::Mutants, + CacheKind::Results { execution_key: "exec" }, + CacheKind::Survived { execution_key: "exec" }, + ] { + assert_ne!( + without_exclusions.cache_file_path("build", kind), + with_exclusions.cache_file_path("build", kind), + ); + } + } + + #[test] + fn result_cache_validates_current_mutant_set() { + let (_temp, config) = test_config(); + let handler = test_handler(config); + let mutants = vec![mutant(10, 20, "number++")]; + let results = vec![(mutants[0].clone(), MutationResult::Dead)]; + + handler.persist_cached_results("build", "exec", &mutants, &results).unwrap(); + + assert!(handler.retrieve_cached_mutant_results("build", "exec", &mutants).is_some()); + + let changed_mutants = vec![mutant(10, 20, "number--")]; + assert!( + handler.retrieve_cached_mutant_results("build", "exec", &changed_mutants).is_none() + ); + } + + #[test] + fn mutation_score_is_unreliable_when_evaluated_mutants_equal_timeouts() { + let mut summary = MutationsSummary::new(); + summary.add_dead_mutant(mutant(10, 20, "number++")); + summary.add_timed_out_mutant(mutant(30, 40, "number--")); + + assert_eq!(summary.total_evaluated(), 1); + assert!(!summary.has_reliable_score()); + } + + #[test] + fn mutation_score_is_unreliable_when_timeouts_dominate() { + let mut summary = MutationsSummary::new(); + summary.add_dead_mutant(mutant(10, 20, "number++")); + summary.add_timed_out_mutant(mutant(30, 40, "number--")); + summary.add_timed_out_mutant(mutant(50, 60, "number += 1")); + + assert_eq!(summary.total_evaluated(), 1); + assert!(!summary.has_reliable_score()); + } + + #[test] + fn mutation_score_is_unreliable_with_no_evaluated_mutants() { + let mut summary = MutationsSummary::new(); + summary.add_timed_out_mutant(mutant(10, 20, "number++")); + + assert_eq!(summary.total_evaluated(), 0); + assert!(!summary.has_reliable_score()); + assert_eq!(summary.mutation_score(), 0.0); + } +} diff --git a/crates/forge/src/mutation/mutant.rs b/crates/forge/src/mutation/mutant.rs new file mode 100644 index 0000000000000..25a961b5748fa --- /dev/null +++ b/crates/forge/src/mutation/mutant.rs @@ -0,0 +1,397 @@ +use std::{ + fmt::Display, + path::{Component, PathBuf}, +}; + +use serde::{Deserialize, Serialize}; +use solar::{ + interface::BytePos, + parse::ast::{BinOpKind, LitKind, Span, StrKind, UnOpKind}, +}; + +use super::visitor::AssignVarTypes; + +/// Wraps an unary operator mutated, to easily store pre/post-fix op swaps +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UnaryOpMutated { + /// String containing the whole new expression (operator and its target) + /// eg `a++` + new_expression: String, + + /// The underlying operator used by this mutant + #[serde(serialize_with = "serialize_unop_kind", deserialize_with = "deserialize_unop_kind")] + pub resulting_op_kind: UnOpKind, +} + +// Custom serialization for UnOpKind +fn serialize_unop_kind(value: &UnOpKind, serializer: S) -> Result +where + S: serde::Serializer, +{ + let s = format!("{value:?}"); + serializer.serialize_str(&s) +} + +fn deserialize_unop_kind<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + match s.as_str() { + "PreInc" => Ok(UnOpKind::PreInc), + "PostInc" => Ok(UnOpKind::PostInc), + "PreDec" => Ok(UnOpKind::PreDec), + "PostDec" => Ok(UnOpKind::PostDec), + "Not" => Ok(UnOpKind::Not), + "BitNot" => Ok(UnOpKind::BitNot), + "Neg" => Ok(UnOpKind::Neg), + other => Err(serde::de::Error::custom(format!("Unknown UnOpKind: {other}"))), + } +} + +impl UnaryOpMutated { + pub const fn new(new_expression: String, resulting_op_kind: UnOpKind) -> Self { + Self { new_expression, resulting_op_kind } + } +} + +impl Display for UnaryOpMutated { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.new_expression) + } +} + +// Custom serialization for BinOpKind +fn serialize_binop(value: &BinOpKind, serializer: S) -> Result +where + S: serde::Serializer, +{ + let s = format!("{value:?}"); + serializer.serialize_str(&s) +} + +fn deserialize_binop<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + match s.as_str() { + "Add" => Ok(BinOpKind::Add), + "Sub" => Ok(BinOpKind::Sub), + "Mul" => Ok(BinOpKind::Mul), + "Div" => Ok(BinOpKind::Div), + "And" => Ok(BinOpKind::And), + "Or" => Ok(BinOpKind::Or), + "Eq" => Ok(BinOpKind::Eq), + "Ne" => Ok(BinOpKind::Ne), + "Lt" => Ok(BinOpKind::Lt), + "Le" => Ok(BinOpKind::Le), + "Gt" => Ok(BinOpKind::Gt), + "Ge" => Ok(BinOpKind::Ge), + "BitAnd" => Ok(BinOpKind::BitAnd), + "BitOr" => Ok(BinOpKind::BitOr), + "BitXor" => Ok(BinOpKind::BitXor), + "Shl" => Ok(BinOpKind::Shl), + "Shr" => Ok(BinOpKind::Shr), + "Sar" => Ok(BinOpKind::Sar), + "Pow" => Ok(BinOpKind::Pow), + "Rem" => Ok(BinOpKind::Rem), + other => Err(serde::de::Error::custom(format!("Unknown BinOpKind: {other}"))), + } +} + +// @todo add a mutation from universalmutator: line swap (swap two lines of code, as it +// could theoretically uncover untested reentrancies +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub enum OwnedStrKind { + Str, + Unicode, + Hex, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum OwnedLiteral { + Str { + kind: OwnedStrKind, + text: String, + }, + Number(alloy_primitives::U256), + Rational(String), + Address(String), + Bool(bool), + Err(String), + /// Signed-negation of a numeric literal (e.g. `-123`). We cannot represent + /// negative values inside `Number(U256)` (the cast wraps via two's + /// complement and renders as a huge unsigned literal), so we carry the + /// negation textually and render it as `-{val}`. + NegatedNumber(alloy_primitives::U256), +} + +impl From<&LitKind<'_>> for OwnedLiteral { + fn from(lit_kind: &LitKind<'_>) -> Self { + match lit_kind { + LitKind::Bool(b) => Self::Bool(*b), + LitKind::Number(n) => Self::Number(*n), + LitKind::Rational(r) => Self::Rational(r.to_string()), + LitKind::Address(addr) => Self::Address(addr.to_string()), + LitKind::Str(sk, bytesym, _extras) => { + let text = String::from_utf8_lossy(bytesym.as_byte_str()).into_owned(); + let kind = match sk { + StrKind::Str => OwnedStrKind::Str, + StrKind::Unicode => OwnedStrKind::Unicode, + StrKind::Hex => OwnedStrKind::Hex, + }; + Self::Str { kind, text } + } + LitKind::Err(_) => Self::Err("parse_error".to_string()), + } + } +} + +impl Display for OwnedLiteral { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Bool(val) => write!(f, "{val}"), + Self::Number(val) => write!(f, "{val}"), + Self::NegatedNumber(val) => write!(f, "-{val}"), + Self::Rational(s) => write!(f, "{s}"), + Self::Address(s) => write!(f, "{s}"), + Self::Str { kind, text } => match kind { + OwnedStrKind::Str => write!(f, "\"{text}\""), + OwnedStrKind::Unicode => write!(f, "unicode\"{text}\""), + OwnedStrKind::Hex => write!(f, "hex\"{text}\""), + }, + Self::Err(s) => write!(f, "{s}"), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum MutationType { + // Note: Solar's LitKind::Number(U256) doesn't differentiate int vs uint - it only stores the + // numeric value without signedness info. For now we generate mutations for both and let solc + // filter out invalid ones (e.g., -x on uint). Future improvement: track variable types in a + // symbol table to avoid generating invalid mutants. + /// For an initializer x, of type + /// bool: replace x with !x + /// uint: replace x with 0 + /// int: replace x with 0; replace x with -x (temp: this is mutated for uint as well) + /// + /// For a binary op y: apply BinaryOp(y) + Assignment(AssignVarTypes), + + /// For a binary op y in BinOpKind ("+", "-", ">=", etc) + /// replace y with each non-y in op (legacy, kept for cache compatibility) + #[serde(serialize_with = "serialize_binop", deserialize_with = "deserialize_binop")] + BinaryOp(BinOpKind), + + /// Binary operator mutation with full expression context + /// Stores both the new operator and the full mutated expression for display + BinaryOpExpr { + #[serde(serialize_with = "serialize_binop", deserialize_with = "deserialize_binop")] + new_op: BinOpKind, + mutated_expr: String, + }, + + /// For a delete expr x `delete foo`, replace x with `assert(true)` + DeleteExpression, + + /// replace "delegatecall" with "call" + ElimDelegate, + + /// Gambit doesn't implement nor define it? + FunctionCall, + + // /// For a if(x) condition x: + // /// replace x with true; replace x with false + // This mutation is not used anymore, as we mutate the condition as an expression, + // which will creates true/false mutant as well as more complex conditions (eg if(foo++ > + // --bar) ) IfStatementMutation, + /// For a require(x) condition: + /// replace x with true; replace x with false + // Same as for IfStatementMutation, the expression inside the require is mutated as an + // expression to handle increment etc + Require, + + /// For require(condition)/assert(condition), mutate the condition: + /// - require(x) -> require(true) (always passes - security critical!) + /// - require(x) -> require(false) (always fails) + /// - require(x) -> require(!x) (inverted condition) + RequireCondition { + /// The mutated full call expression + mutated_call: String, + }, + + // @todo review if needed -> this might creates *a lot* of combinations for super-polyadic fn + // tho only swapping same type (to avoid obvious compilation failure), but should + // take into account implicit casting too... + /// For 2 args of the same type x,y in a function args: + /// swap(x, y) + SwapArgumentsFunction, + + // @todo same remark as above, might end up in a space too big to explore + filtering out + // based on type + /// For an expr taking 2 expression x, y (x+y, x-y, x = x + ...): + /// swap(x, y) + SwapArgumentsOperator, + + /// For an unary operator x in UnOpKind (eg "++", "--", "~", "!"): + /// replace x with all other operator in op + /// Pre or post- are different UnOp + UnaryOperator(UnaryOpMutated), + + YulOpcode { + original_opcode: String, + new_opcode: String, + mutated_expr: String, + }, +} + +impl Display for MutationType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Assignment(kind) => match kind { + AssignVarTypes::Literal(lit) => write!(f, "{lit}"), + AssignVarTypes::Identifier(ident) => write!(f, "{ident}"), + AssignVarTypes::NegatedIdentifier(ident) => write!(f, "-{ident}"), + }, + Self::BinaryOp(kind) => write!(f, "{}", kind.to_str()), + Self::BinaryOpExpr { mutated_expr, .. } => write!(f, "{mutated_expr}"), + Self::DeleteExpression => write!(f, "assert(true)"), + Self::ElimDelegate => write!(f, "call"), + Self::UnaryOperator(mutated) => write!(f, "{mutated}"), + Self::RequireCondition { mutated_call } => write!(f, "{mutated_call}"), + + Self::YulOpcode { mutated_expr, .. } => write!(f, "{mutated_expr}"), + + Self::FunctionCall + | Self::Require + | Self::SwapArgumentsFunction + | Self::SwapArgumentsOperator => write!(f, ""), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum MutationResult { + Dead, + Alive, + Invalid, + Skipped, + /// The mutant's compile-and-test run exceeded the configured timeout. + /// Treated as unresolved: not counted toward survived or killed. + TimedOut, +} + +impl MutationResult { + /// Short uppercase label used in progress / reporter output. + pub const fn label(&self) -> &'static str { + match self { + Self::Dead => "KILLED", + Self::Alive => "SURVIVED", + Self::Invalid => "INVALID", + Self::Skipped => "SKIPPED", + Self::TimedOut => "TIMED OUT", + } + } +} + +/// A given mutation +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Mutant { + /// The source path relative to the project root, or the absolute path if the source is + /// outside the root. + pub path: PathBuf, + #[serde(serialize_with = "serialize_span", deserialize_with = "deserialize_span")] + pub span: Span, + pub mutation: MutationType, + /// The original source text that will be replaced by this mutation (full expression) + #[serde(default)] + pub original: String, + /// The full source line for context (e.g., "uint256 x = a * b;") + #[serde(default)] + pub source_line: String, + /// Line number in the source file (1-indexed) + #[serde(default)] + pub line_number: usize, + /// Column number in the source file (1-indexed) + #[serde(default)] + pub column_number: usize, +} + +// Custom serialization for Span (since solar::parse::ast::Span doesn't implement Serialize) +fn serialize_span(span: &Span, serializer: S) -> Result +where + S: serde::Serializer, +{ + use serde::Serialize; + #[derive(Serialize)] + struct SpanHelper { + lo: u32, + hi: u32, + } + SpanHelper { lo: span.lo().0, hi: span.hi().0 }.serialize(serializer) +} + +fn deserialize_span<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + use serde::Deserialize; + #[derive(Deserialize)] + struct SpanHelper { + lo: u32, + hi: u32, + } + let helper = SpanHelper::deserialize(deserializer)?; + Ok(Span::new(BytePos(helper.lo), BytePos(helper.hi))) +} + +impl Mutant { + /// Returns the mutant path with `/` separators. + /// + /// Mutant paths are relative to the project root, so this returns the full path. If the + /// source is outside the project root, the path is absolute: this then starts at the first + /// well-known directory (`src`, `test`, `script`, `lib`, `contracts`), or uses the file name. + pub fn relative_path(&self) -> String { + let components = self + .path + .components() + .filter_map(|c| match c { + Component::Normal(s) => Some(s.to_string_lossy()), + _ => None, + }) + .collect::>(); + let start = if self.path.is_relative() { + 0 + } else { + components + .iter() + .position(|s| matches!(s.as_ref(), "src" | "test" | "script" | "lib" | "contracts")) + .unwrap_or(components.len().saturating_sub(1)) + }; + components[start..].join("/") + } + + /// Returns a concise one-line description of the mutation (full original code) + pub fn short_description(&self) -> String { + let original = if self.original.is_empty() { + "".to_string() + } else { + self.original.trim().to_string() + }; + let mutated = self.mutation.to_string(); + + format!("`{}` → `{}`", original, mutated.trim()) + } +} + +impl Display for Mutant { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.line_number > 0 { + write!(f, "{}:{}: {}", self.relative_path(), self.line_number, self.short_description()) + } else { + write!(f, "{}: {}", self.relative_path(), self.short_description()) + } + } +} diff --git a/crates/forge/src/mutation/mutators/assembly_mutator.rs b/crates/forge/src/mutation/mutators/assembly_mutator.rs new file mode 100644 index 0000000000000..1d2b328a3a895 --- /dev/null +++ b/crates/forge/src/mutation/mutators/assembly_mutator.rs @@ -0,0 +1,217 @@ +use std::collections::HashMap; + +use eyre::Result; +use solar::ast::yul; + +use super::{MutationContext, Mutator}; +use crate::mutation::mutant::{Mutant, MutationType}; + +pub struct AssemblyMutator { + opcode_mutations: HashMap<&'static str, Vec<&'static str>>, +} + +impl Default for AssemblyMutator { + fn default() -> Self { + Self::new() + } +} + +impl AssemblyMutator { + pub fn new() -> Self { + let mut opcode_mutations: HashMap<&'static str, Vec<&'static str>> = HashMap::new(); + + // Arithmetic — stay within arithmetic family + opcode_mutations.insert("add", vec!["sub", "mul"]); + opcode_mutations.insert("sub", vec!["add", "mul", "div"]); + opcode_mutations.insert("mul", vec!["add", "div"]); + opcode_mutations.insert("div", vec!["mul", "sub", "mod"]); + opcode_mutations.insert("sdiv", vec!["smod", "mul"]); + opcode_mutations.insert("mod", vec!["div", "mul"]); + opcode_mutations.insert("smod", vec!["sdiv", "mod"]); + opcode_mutations.insert("exp", vec!["mul", "add"]); + opcode_mutations.insert("addmod", vec!["mulmod"]); + opcode_mutations.insert("mulmod", vec!["addmod"]); + + // Comparisons — stay within comparison family + opcode_mutations.insert("lt", vec!["gt", "eq", "slt"]); + opcode_mutations.insert("gt", vec!["lt", "eq", "sgt"]); + opcode_mutations.insert("slt", vec!["sgt", "lt"]); + opcode_mutations.insert("sgt", vec!["slt", "gt"]); + opcode_mutations.insert("eq", vec!["lt", "gt"]); + + // Bitwise — stay within bitwise family + opcode_mutations.insert("and", vec!["or", "xor"]); + opcode_mutations.insert("or", vec!["and", "xor"]); + opcode_mutations.insert("xor", vec!["and", "or"]); + + // Shifts — stay within shift family + opcode_mutations.insert("shl", vec!["shr", "sar"]); + opcode_mutations.insert("shr", vec!["shl", "sar"]); + opcode_mutations.insert("sar", vec!["shr", "shl"]); + + Self { opcode_mutations } + } + + pub fn get_mutations(&self, opcode: &str) -> Option<&[&'static str]> { + self.opcode_mutations.get(opcode).map(|v| v.as_slice()) + } +} + +impl Mutator for AssemblyMutator { + fn generate_mutants(&self, context: &MutationContext<'_>) -> Result> { + let yul_expr = context.yul_expr.ok_or_else(|| eyre::eyre!("No Yul expression"))?; + + let call = match &yul_expr.kind { + yul::ExprKind::Call(call) => call, + _ => return Ok(vec![]), + }; + + let opcode_name = call.name.as_str(); + + let alternatives = match self.get_mutations(opcode_name) { + Some(alts) => alts, + None => return Ok(vec![]), + }; + + let original = context.original_text(); + if original.is_empty() { + return Ok(vec![]); + } + + let expected_len = (context.span.hi().0 - context.span.lo().0) as usize; + if original.len() != expected_len { + return Ok(vec![]); + } + + let source_line = context.source_line(); + let line_number = context.line_number(); + let column_number = context.column_number(); + + let name_span = call.name.span; + + let mutants = alternatives + .iter() + .filter_map(|&new_opcode| { + let mutated = + replace_at_span(&original, context.span, name_span, opcode_name, new_opcode)?; + Some(Mutant { + span: context.span, + mutation: MutationType::YulOpcode { + original_opcode: opcode_name.to_string(), + new_opcode: new_opcode.to_string(), + mutated_expr: mutated, + }, + path: context.path.clone(), + original: original.clone(), + source_line: source_line.clone(), + line_number, + column_number, + }) + }) + .collect(); + + Ok(mutants) + } + + fn is_applicable(&self, ctxt: &MutationContext<'_>) -> bool { + if let Some(yul_expr) = ctxt.yul_expr + && let yul::ExprKind::Call(call) = &yul_expr.kind + { + return self.opcode_mutations.contains_key(call.name.as_str()); + } + false + } +} + +fn replace_at_span( + original: &str, + outer_span: solar::ast::Span, + target_span: solar::ast::Span, + expected_opcode: &str, + replacement: &str, +) -> Option { + let outer_lo = outer_span.lo().0 as usize; + let target_lo = target_span.lo().0 as usize; + let target_hi = target_span.hi().0 as usize; + + let rel_lo = target_lo.checked_sub(outer_lo)?; + let rel_hi = target_hi.checked_sub(outer_lo)?; + + if rel_lo > rel_hi || rel_hi > original.len() { + return None; + } + + let prefix = original.get(..rel_lo)?; + let replaced = original.get(rel_lo..rel_hi)?; + let suffix = original.get(rel_hi..)?; + + if replaced != expected_opcode { + return None; + } + + Some(format!("{prefix}{replacement}{suffix}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use solar::{ast::Span, interface::BytePos}; + + #[test] + fn test_opcode_mutations_exist() { + let mutator = AssemblyMutator::new(); + + assert!(mutator.get_mutations("add").unwrap().contains(&"sub")); + assert!(mutator.get_mutations("mul").unwrap().contains(&"div")); + + assert!(mutator.get_mutations("lt").unwrap().contains(&"gt")); + assert!(mutator.get_mutations("slt").unwrap().contains(&"sgt")); + + assert!(mutator.get_mutations("and").unwrap().contains(&"or")); + assert!(mutator.get_mutations("shl").unwrap().contains(&"shr")); + } + + #[test] + fn test_no_cross_family_mutations() { + let mutator = AssemblyMutator::new(); + let add_alts = mutator.get_mutations("add").unwrap(); + assert!(!add_alts.contains(&"xor"), "add should not mutate to xor (cross-family)"); + assert!(!add_alts.contains(&"and"), "add should not mutate to and (cross-family)"); + + let mul_alts = mutator.get_mutations("mul").unwrap(); + assert!(!mul_alts.contains(&"and"), "mul should not mutate to and (cross-family)"); + } + + #[test] + fn test_no_iszero_not_mapping() { + let mutator = AssemblyMutator::new(); + assert!(mutator.get_mutations("iszero").is_none(), "iszero should not be mutated"); + assert!(mutator.get_mutations("not").is_none(), "not should not be mutated"); + } + + #[test] + fn test_no_mload_sload_mapping() { + let mutator = AssemblyMutator::new(); + assert!(mutator.get_mutations("mload").is_none()); + assert!(mutator.get_mutations("sload").is_none()); + } + + #[test] + fn test_replace_at_span() { + for (case, original, outer, target, expected) in [ + ("valid", "add(a, b)", (10, 19), (10, 13), Some("sub(a, b)")), + ("target outside outer", "add(a, b)", (20, 29), (10, 13), None), + ("target exceeds length", "add(a, b)", (10, 19), (10, 30), None), + ("opcode mismatch", "mul(a, b)", (10, 19), (10, 13), None), + ("empty original", "", (10, 19), (10, 13), None), + ] { + let outer = Span::new(BytePos(outer.0), BytePos(outer.1)); + let target = Span::new(BytePos(target.0), BytePos(target.1)); + assert_eq!( + replace_at_span(original, outer, target, "add", "sub").as_deref(), + expected, + "{case}", + ); + } + } +} diff --git a/crates/forge/src/mutation/mutators/assignment_mutator.rs b/crates/forge/src/mutation/mutators/assignment_mutator.rs new file mode 100644 index 0000000000000..85e148fdc73f5 --- /dev/null +++ b/crates/forge/src/mutation/mutators/assignment_mutator.rs @@ -0,0 +1,149 @@ +use alloy_primitives::U256; +use eyre::Result; +use solar::ast::{ExprKind, Span}; + +use crate::mutation::{ + mutant::{Mutant, MutationType, OwnedLiteral}, + mutators::{MutationContext, Mutator}, + visitor::AssignVarTypes, +}; + +pub struct AssignmentMutator; + +impl Mutator for AssignmentMutator { + fn generate_mutants(&self, context: &MutationContext<'_>) -> Result> { + let (assign_var_type, replacement_span) = match extract_rhs_info(context) { + Some(info) => info, + None => return Ok(vec![]), // is_applicable should filter this + }; + + let original = context.original_text(); + let source_line = context.source_line(); + let line_number = context.line_number(); + let column_number = context.column_number(); + + match assign_var_type { + AssignVarTypes::Literal(ref lit) => match lit { + OwnedLiteral::Bool(val) => Ok(vec![Mutant { + span: replacement_span, + mutation: MutationType::Assignment(AssignVarTypes::Literal( + OwnedLiteral::Bool(!val), + )), + path: context.path.clone(), + original, + source_line, + line_number, + column_number, + }]), + OwnedLiteral::Number(val) if val.is_zero() => Ok(vec![]), + OwnedLiteral::Number(val) => Ok(vec![ + Mutant { + span: replacement_span, + mutation: MutationType::Assignment(AssignVarTypes::Literal( + OwnedLiteral::Number(U256::ZERO), + )), + path: context.path.clone(), + original: original.clone(), + source_line: source_line.clone(), + line_number, + column_number, + }, + // Negation of a numeric literal must be carried textually: + // applying `-*val` on a `U256` wraps via two's complement + // and would render as a huge unsigned literal (e.g. `1` + // becomes `2^256 - 1`), producing wrong-source mutants. + Mutant { + span: replacement_span, + mutation: MutationType::Assignment(AssignVarTypes::Literal( + OwnedLiteral::NegatedNumber(*val), + )), + path: context.path.clone(), + original, + source_line, + line_number, + column_number, + }, + ]), + OwnedLiteral::Str { .. } => Ok(vec![]), + OwnedLiteral::Rational(_) => Ok(vec![]), + OwnedLiteral::Address(_) => Ok(vec![]), + OwnedLiteral::Err(_) => Ok(vec![]), + // `NegatedNumber` is only ever constructed *as* a mutant; it + // does not appear as an original literal in the source AST, + // so there is nothing to mutate here. + OwnedLiteral::NegatedNumber(_) => Ok(vec![]), + }, + AssignVarTypes::Identifier(ref ident) => Ok(vec![ + Mutant { + span: replacement_span, + mutation: MutationType::Assignment(AssignVarTypes::Literal( + OwnedLiteral::Number(U256::ZERO), + )), + path: context.path.clone(), + original: original.clone(), + source_line: source_line.clone(), + line_number, + column_number, + }, + Mutant { + span: replacement_span, + mutation: MutationType::Assignment(AssignVarTypes::NegatedIdentifier( + ident.clone(), + )), + path: context.path.clone(), + original, + source_line, + line_number, + column_number, + }, + ]), + AssignVarTypes::NegatedIdentifier(_) => Ok(vec![]), + } + } + + /// Match is the expr is an assign with a var definition having a literal or identifier as + /// initializer + fn is_applicable(&self, context: &MutationContext<'_>) -> bool { + if let Some(expr) = context.expr { + if let ExprKind::Assign(_lhs, _op_opt, rhs_actual_expr) = &expr.kind { + matches!(rhs_actual_expr.kind, ExprKind::Lit(..) | ExprKind::Ident(..)) + } else { + false // Not an assign + } + } else if let Some(var_definition) = context.var_definition { + if let Some(init) = &var_definition.initializer { + matches!(&init.kind, ExprKind::Lit(..) | ExprKind::Ident(..)) + } else { + false // No initializer + } + } else { + false // Not an expression or var_definition + } + } +} + +fn extract_rhs_info<'ast>(context: &MutationContext<'ast>) -> Option<(AssignVarTypes, Span)> { + let relevant_expr_for_rhs = if let Some(var_definition) = context.var_definition { + var_definition.initializer.as_ref()? + } else { + let expr = context.expr?; + match &expr.kind { + ExprKind::Assign(_lhs, _op_opt, rhs_actual_expr) => &**rhs_actual_expr, + // If the context.expr is already what we want to get the type from + // (e.g. a simple Lit or Ident being passed directly, though is_applicable filters this) + ExprKind::Lit(..) | ExprKind::Ident(..) => expr, + _ => return None, + } + }; + + match &relevant_expr_for_rhs.kind { + ExprKind::Lit(kind, _) => { + let owned = OwnedLiteral::from(&kind.kind); + Some((AssignVarTypes::Literal(owned), relevant_expr_for_rhs.span)) + } + ExprKind::Ident(val) => { + Some((AssignVarTypes::Identifier(val.to_string()), relevant_expr_for_rhs.span)) + } + _ => None, + } +} diff --git a/crates/forge/src/mutation/mutators/binary_op_mutator.rs b/crates/forge/src/mutation/mutators/binary_op_mutator.rs new file mode 100644 index 0000000000000..bc6ea6e23da46 --- /dev/null +++ b/crates/forge/src/mutation/mutators/binary_op_mutator.rs @@ -0,0 +1,134 @@ +use eyre::{OptionExt, Result}; +use solar::ast::{BinOp, BinOpKind, Expr, ExprKind, Span}; + +use super::{MutationContext, Mutator}; +use crate::mutation::mutant::{Mutant, MutationType}; + +pub struct BinaryOpMutator; + +impl Mutator for BinaryOpMutator { + fn generate_mutants(&self, context: &MutationContext<'_>) -> Result> { + let expr = context.expr.ok_or_eyre("BinaryOpMutator: no expression")?; + let (bin_op, _op_span, lhs, rhs, compound_assignment) = get_bin_op_parts(expr)?; + let op = bin_op.kind; + + let operations_bools = vec![ + BinOpKind::Lt, + BinOpKind::Le, + BinOpKind::Gt, + BinOpKind::Ge, + BinOpKind::Eq, + BinOpKind::Ne, + BinOpKind::Or, + BinOpKind::And, + ]; + + // `>>>` is parsed but not implemented for integer types in Solidity, so a `Sar` mutant + // never compiles and only costs a build per mutation site. + let operations_num_bitwise = vec![ + BinOpKind::Shr, + BinOpKind::Shl, + BinOpKind::BitAnd, + BinOpKind::BitOr, + BinOpKind::BitXor, + BinOpKind::Add, + BinOpKind::Sub, + BinOpKind::Pow, + BinOpKind::Mul, + BinOpKind::Div, + BinOpKind::Rem, + ]; + + let operations = + if operations_bools.contains(&op) { operations_bools } else { operations_num_bitwise }; + + // Extract LHS and RHS text from source + let source = context.source.unwrap_or(""); + let lhs_text = extract_span_text(source, lhs.span); + let rhs_text = extract_span_text(source, rhs.span); + let op_str = op.to_str(); + + let original_expr = if compound_assignment { + format!("{lhs_text} {op_str}= {rhs_text}") + } else { + format!("{lhs_text} {op_str} {rhs_text}") + }; + + // Use the full expression span for the mutation (not just the operator span) + let expr_span = context.span; + + // Get line context + let source_line = context.source_line(); + let line_number = context.line_number(); + let column_number = context.column_number(); + + Ok(operations + .into_iter() + .filter(|&kind| kind != op) + .filter(|&kind| !compound_assignment || is_valid_compound_assignment_op(kind)) + .map(|kind| { + let mutated_expr = if compound_assignment { + format!("{} {}= {}", lhs_text, kind.to_str(), rhs_text) + } else { + format!("{} {} {}", lhs_text, kind.to_str(), rhs_text) + }; + Mutant { + span: expr_span, + mutation: MutationType::BinaryOpExpr { new_op: kind, mutated_expr }, + path: context.path.clone(), + original: original_expr.clone(), + source_line: source_line.clone(), + line_number, + column_number, + } + }) + .collect()) + } + + fn is_applicable(&self, ctxt: &MutationContext<'_>) -> bool { + if ctxt.expr.is_none() { + return false; + } + + matches!( + ctxt.expr.unwrap().kind, + ExprKind::Binary(_, _, _) | ExprKind::Assign(_, Some(_), _) + ) + } +} + +const fn is_valid_compound_assignment_op(kind: BinOpKind) -> bool { + matches!( + kind, + BinOpKind::BitOr + | BinOpKind::BitXor + | BinOpKind::BitAnd + | BinOpKind::Shl + | BinOpKind::Shr + | BinOpKind::Add + | BinOpKind::Sub + | BinOpKind::Mul + | BinOpKind::Div + | BinOpKind::Rem + ) +} + +/// Extract the binary operator, its span, and LHS/RHS expressions +fn get_bin_op_parts<'a>( + expr: &'a Expr<'a>, +) -> Result<(BinOp, Span, &'a Expr<'a>, &'a Expr<'a>, bool)> { + match &expr.kind { + ExprKind::Assign(lhs, Some(op), rhs) => Ok((*op, op.span, lhs, rhs, true)), + ExprKind::Binary(lhs, op, rhs) => Ok((*op, op.span, lhs, rhs, false)), + _ => { + eyre::bail!("BinaryOpMutator: unexpected expression kind"); + } + } +} + +/// Extract text from source given a span +fn extract_span_text(source: &str, span: Span) -> String { + let lo = span.lo().0 as usize; + let hi = span.hi().0 as usize; + source.get(lo..hi).map(|s| s.trim().to_string()).unwrap_or_default() +} diff --git a/crates/forge/src/mutation/mutators/delete_expression_mutator.rs b/crates/forge/src/mutation/mutators/delete_expression_mutator.rs new file mode 100644 index 0000000000000..d4bea4adf7296 --- /dev/null +++ b/crates/forge/src/mutation/mutators/delete_expression_mutator.rs @@ -0,0 +1,26 @@ +use eyre::Result; +use solar::ast::ExprKind; + +use super::{MutationContext, Mutator}; + +use crate::mutation::mutant::{Mutant, MutationType}; + +pub struct DeleteExpressionMutator; + +impl Mutator for DeleteExpressionMutator { + fn generate_mutants(&self, ctxt: &MutationContext<'_>) -> Result> { + Ok(vec![Mutant { + span: ctxt.span, + mutation: MutationType::DeleteExpression, + path: ctxt.path.clone(), + original: ctxt.original_text(), + source_line: ctxt.source_line(), + line_number: ctxt.line_number(), + column_number: ctxt.column_number(), + }]) + } + + fn is_applicable(&self, ctxt: &MutationContext<'_>) -> bool { + if let Some(expr) = ctxt.expr { matches!(expr.kind, ExprKind::Delete(_)) } else { false } + } +} diff --git a/crates/forge/src/mutation/mutators/elim_delegate_mutator.rs b/crates/forge/src/mutation/mutators/elim_delegate_mutator.rs new file mode 100644 index 0000000000000..bc36f10bb790a --- /dev/null +++ b/crates/forge/src/mutation/mutators/elim_delegate_mutator.rs @@ -0,0 +1,62 @@ +use std::fmt::Display; + +use eyre::Result; +use solar::ast::ExprKind; + +use super::{MutationContext, Mutator}; + +use crate::mutation::mutant::{Mutant, MutationType}; + +pub struct ElimDelegateMutator; + +impl Mutator for ElimDelegateMutator { + fn generate_mutants(&self, context: &MutationContext<'_>) -> Result> { + // Narrow the span to just the `delegatecall` identifier so the replacement + // text ("call") does not clobber the surrounding call expression + // (e.g. `target.delegatecall(data)`). + let ident_span = context + .expr + .as_ref() + .and_then(|expr| match &expr.kind { + ExprKind::Call(callee, _) => Some(callee), + _ => None, + }) + .and_then(|callee| match &callee.kind { + ExprKind::Member(_, ident) => Some(ident.span), + _ => None, + }) + .unwrap_or(context.span); + + Ok(vec![Mutant { + span: ident_span, + mutation: MutationType::ElimDelegate, + path: context.path.clone(), + // Use the narrowed identifier as the "original" text so the diff line + // ("- delegatecall" / "+ call") matches the actual textual replacement. + original: "delegatecall".to_string(), + source_line: context.source_line(), + line_number: context.line_number(), + column_number: context.column_number(), + }]) + } + + fn is_applicable(&self, ctxt: &MutationContext<'_>) -> bool { + ctxt.expr + .as_ref() + .and_then(|expr| match &expr.kind { + ExprKind::Call(callee, _) => Some(callee), + _ => None, + }) + .and_then(|callee| match &callee.kind { + ExprKind::Member(_, ident) => Some(ident), + _ => None, + }) + .is_some_and(|ident| ident.to_string() == "delegatecall") + } +} + +impl Display for ElimDelegateMutator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "") + } +} diff --git a/crates/forge/src/mutation/mutators/mod.rs b/crates/forge/src/mutation/mutators/mod.rs new file mode 100644 index 0000000000000..20af2f8b155a2 --- /dev/null +++ b/crates/forge/src/mutation/mutators/mod.rs @@ -0,0 +1,167 @@ +use std::path::PathBuf; + +use eyre::Result; +use solar::ast::{Expr, Span, VariableDefinition, yul}; + +use crate::mutation::Mutant; + +pub mod assembly_mutator; +pub mod assignment_mutator; +pub mod binary_op_mutator; +pub mod delete_expression_mutator; +pub mod elim_delegate_mutator; +pub mod mutator_registry; +pub mod require_mutator; +pub mod unary_op_mutator; + +pub trait Mutator: Send + Sync { + /// Generate all mutant corresponding to a given context + fn generate_mutants(&self, ctxt: &MutationContext<'_>) -> Result>; + /// True if a mutator can be applied to an expression/node + fn is_applicable(&self, ctxt: &MutationContext<'_>) -> bool; +} + +#[derive(Debug)] +pub struct MutationContext<'a> { + pub path: PathBuf, + pub span: Span, + /// The expression to mutate + pub expr: Option<&'a Expr<'a>>, + pub var_definition: Option<&'a VariableDefinition<'a>>, + /// Yul expression for assembly block mutations + pub yul_expr: Option<&'a yul::Expr<'a>>, + /// The full source code (used to extract original text for mutations) + pub source: Option<&'a str>, +} + +impl MutationContext<'_> { + /// Extract the original source text covered by this context's span + pub fn original_text(&self) -> String { + self.source + .and_then(|src| { + let lo = self.span.lo().0 as usize; + let hi = self.span.hi().0 as usize; + src.get(lo..hi).map(|s| s.to_string()) + }) + .unwrap_or_default() + } + + /// Get the line number (1-indexed) for this context's span + pub fn line_number(&self) -> usize { + self.source + .map(|src| { + let pos = self.span.lo().0 as usize; + src.get(..pos).map(|s| s.lines().count()).unwrap_or(0).max(1) + }) + .unwrap_or(1) + } + + /// Get the column number (1-indexed) for this context's span + pub fn column_number(&self) -> usize { + self.source + .map(|src| { + let pos = self.span.lo().0 as usize; + let line_start = + src.get(..pos).and_then(|s| s.rfind('\n')).map(|i| i + 1).unwrap_or(0); + pos - line_start + 1 + }) + .unwrap_or(1) + } + + /// Get the full source line containing this span + pub fn source_line(&self) -> String { + self.source + .and_then(|src| { + let pos = self.span.lo().0 as usize; + // Find line start + let line_start = src[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0); + // Find line end + let line_end = src[pos..].find('\n').map(|i| pos + i).unwrap_or(src.len()); + src.get(line_start..line_end).map(|s| s.trim().to_string()) + }) + .unwrap_or_default() + } +} + +impl<'a> MutationContext<'a> { + #[must_use] + pub const fn builder() -> MutationContextBuilder<'a> { + MutationContextBuilder::new() + } +} + +pub struct MutationContextBuilder<'a> { + path: Option, + span: Option, + expr: Option<&'a Expr<'a>>, + var_definition: Option<&'a VariableDefinition<'a>>, + yul_expr: Option<&'a yul::Expr<'a>>, + source: Option<&'a str>, +} + +impl<'a> MutationContextBuilder<'a> { + // Create a new empty builder + pub const fn new() -> Self { + MutationContextBuilder { + path: None, + span: None, + expr: None, + var_definition: None, + yul_expr: None, + source: None, + } + } + + // Required + pub fn with_path(mut self, path: PathBuf) -> Self { + self.path = Some(path); + self + } + + // Required + pub const fn with_span(mut self, span: Span) -> Self { + self.span = Some(span); + self + } + + // Optional + pub const fn with_expr(mut self, expr: &'a Expr<'a>) -> Self { + self.expr = Some(expr); + self + } + + // Optional + pub const fn with_var_definition(mut self, var_definition: &'a VariableDefinition<'a>) -> Self { + self.var_definition = Some(var_definition); + self + } + + // Optional + pub const fn with_yul_expr(mut self, yul_expr: &'a yul::Expr<'a>) -> Self { + self.yul_expr = Some(yul_expr); + self + } + + // Optional - provide source code for extracting original text + pub const fn with_source(mut self, source: &'a str) -> Self { + self.source = Some(source); + self + } + + pub fn build(self) -> Result, &'static str> { + let span = self.span.ok_or("Span is required for MutationContext")?; + let path = self.path.ok_or("Path is required for MutationContext")?; + + Ok(MutationContext { + path, + span, + expr: self.expr, + var_definition: self.var_definition, + yul_expr: self.yul_expr, + source: self.source, + }) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/forge/src/mutation/mutators/mutator_registry.rs b/crates/forge/src/mutation/mutators/mutator_registry.rs new file mode 100644 index 0000000000000..9c0cda6b53fae --- /dev/null +++ b/crates/forge/src/mutation/mutators/mutator_registry.rs @@ -0,0 +1,113 @@ +use crate::mutation::mutant::Mutant; +use eyre::Report; +use foundry_config::MutatorType; + +use super::{ + MutationContext, Mutator, assembly_mutator, assignment_mutator, binary_op_mutator, + delete_expression_mutator, elim_delegate_mutator, require_mutator, unary_op_mutator, +}; + +/// Registry of all available mutators (ie implementing the Mutator trait) +pub struct MutatorRegistry { + mutators: Vec>, +} + +pub struct MutationGenerationResult { + pub mutations: Vec, + pub errors: Vec, +} + +impl MutatorRegistry { + #[cfg(test)] + pub fn default() -> Self { + Self::from_enabled(&MutatorType::all()) + } + + pub fn from_enabled(enabled: &[MutatorType]) -> Self { + let mut registry = Self { mutators: Vec::new() }; + + for ty in enabled { + match ty { + MutatorType::Assembly => { + registry.mutators.push(Box::new(assembly_mutator::AssemblyMutator::new())); + } + MutatorType::Assignment => { + registry.mutators.push(Box::new(assignment_mutator::AssignmentMutator)); + } + MutatorType::BinaryOp => { + registry.mutators.push(Box::new(binary_op_mutator::BinaryOpMutator)); + } + MutatorType::DeleteExpression => { + registry + .mutators + .push(Box::new(delete_expression_mutator::DeleteExpressionMutator)); + } + MutatorType::ElimDelegate => { + registry.mutators.push(Box::new(elim_delegate_mutator::ElimDelegateMutator)); + } + MutatorType::Require => { + registry.mutators.push(Box::new(require_mutator::RequireMutator)); + } + MutatorType::UnaryOp => { + registry.mutators.push(Box::new(unary_op_mutator::UnaryOpMutator)); + } + } + } + + registry + } + + #[cfg(test)] + pub fn new_with_mutators(mutators: Vec>) -> Self { + Self { mutators } + } + + /// Find all applicable mutators for a given context and return the corresponding mutations + /// and any mutator errors encountered while generating them. + pub fn generate_mutations(&self, context: &MutationContext<'_>) -> MutationGenerationResult { + let mut mutations = Vec::new(); + let mut errors = Vec::new(); + for mutator in self.mutators.iter().filter(|mutator| mutator.is_applicable(context)) { + match mutator.generate_mutants(context) { + Ok(generated) => mutations.extend(generated), + Err(err) => errors.push(err), + } + } + MutationGenerationResult { mutations, errors } + } +} + +#[cfg(test)] +mod tests { + use eyre::{Result, eyre}; + use solar::ast::Span; + + use super::*; + + struct FailingMutator; + + impl Mutator for FailingMutator { + fn generate_mutants(&self, _ctxt: &MutationContext<'_>) -> Result> { + Err(eyre!("synthetic mutator failure")) + } + + fn is_applicable(&self, _ctxt: &MutationContext<'_>) -> bool { + true + } + } + + #[test] + fn generate_mutations_collects_mutator_errors() { + let registry = MutatorRegistry::new_with_mutators(vec![Box::new(FailingMutator)]); + let context = MutationContext::builder() + .with_path("test.sol".into()) + .with_span(Span::DUMMY) + .build() + .unwrap(); + + let result = registry.generate_mutations(&context); + assert!(result.mutations.is_empty()); + let err = result.errors.into_iter().next().unwrap(); + assert!(err.to_string().contains("synthetic mutator failure")); + } +} diff --git a/crates/forge/src/mutation/mutators/require_mutator.rs b/crates/forge/src/mutation/mutators/require_mutator.rs new file mode 100644 index 0000000000000..6b2e8ae420ea0 --- /dev/null +++ b/crates/forge/src/mutation/mutators/require_mutator.rs @@ -0,0 +1,146 @@ +//! Require/Assert condition mutator. +//! +//! This mutator targets security-critical validation patterns in Solidity: +//! - `require(condition)` / `require(condition, "message")` +//! - `assert(condition)` +//! +//! Mutations generated: +//! - `require(x)` -> `require(true)` - Always passes (security critical!) +//! - `require(x)` -> `require(false)` - Always fails +//! - `require(x)` -> `require(!x)` - Inverted condition +//! +//! These mutations are particularly valuable for security testing because: +//! - Access control checks (onlyOwner patterns) +//! - Input validation (bounds checking, address validation) +//! - State preconditions (reentrancy guards, paused checks) + +use eyre::Result; +use solar::ast::{CallArgsKind, Expr, ExprKind, UnOpKind}; + +use super::{MutationContext, Mutator}; +use crate::mutation::mutant::{Mutant, MutationType}; + +pub struct RequireMutator; + +impl Mutator for RequireMutator { + fn generate_mutants(&self, context: &MutationContext<'_>) -> Result> { + let expr = context.expr.ok_or_else(|| eyre::eyre!("RequireMutator: no expression"))?; + + // Extract function name and arguments + let (func_name, args_exprs) = match &expr.kind { + ExprKind::Call(callee, call_args) => { + let name = match &callee.kind { + ExprKind::Ident(ident) => ident.to_string(), + _ => return Ok(vec![]), + }; + // Extract the expressions from CallArgs + let exprs = match &call_args.kind { + CallArgsKind::Unnamed(exprs) => exprs, + CallArgsKind::Named(_) => return Ok(vec![]), // Named args not supported + }; + (name, exprs) + } + _ => return Ok(vec![]), + }; + + // Only handle require and assert + if func_name != "require" && func_name != "assert" { + return Ok(vec![]); + } + + // Need at least one argument (the condition) + if args_exprs.is_empty() { + return Ok(vec![]); + } + + let condition_expr = &args_exprs[0]; + let original = context.original_text(); + let source_line = context.source_line(); + let line_number = context.line_number(); + let column_number = context.column_number(); + + let source = context.source.unwrap_or(""); + + // Build the rest of the call (message and other arguments after the condition, + // if any) using span-based extraction so that commas inside the condition + // expression (e.g. `require(foo(a, b))`) do not break splitting. + let rest_args = if args_exprs.len() > 1 { + // Extract from the character right after the condition expression up to + // the last argument's end. + let start = condition_expr.span.hi().0 as usize; + let end = args_exprs.last().map(|e| e.span.hi().0 as usize).unwrap_or(start); + source.get(start..end).map(|s| s.to_string()).unwrap_or_default() + } else { + String::new() + }; + + let mut mutants = Vec::new(); + let mut push_mutant = |mutated_call: String, original: String, source_line: String| { + if mutated_call.trim() == original.trim() { + return; + } + mutants.push(Mutant { + span: expr.span, + mutation: MutationType::RequireCondition { mutated_call }, + path: context.path.clone(), + original, + source_line, + line_number, + column_number, + }); + }; + + // Mutation 1: require(x) -> require(true) + // This is security-critical: if tests pass, the condition was never actually needed + let mutated_true = format!("{func_name}(true{rest_args})"); + push_mutant(mutated_true, original.clone(), source_line.clone()); + + let mutated_false = format!("{func_name}(false{rest_args})"); + push_mutant(mutated_false, original.clone(), source_line.clone()); + + let inverted_condition = invert_condition_text(source, condition_expr); + let mutated_inverted = format!("{func_name}({inverted_condition}{rest_args})"); + push_mutant(mutated_inverted, original, source_line); + + Ok(mutants) + } + + fn is_applicable(&self, ctxt: &MutationContext<'_>) -> bool { + ctxt.expr + .as_ref() + .and_then(|expr| match &expr.kind { + ExprKind::Call(callee, call_args) => { + // Must have at least one argument + match &call_args.kind { + CallArgsKind::Unnamed(exprs) if !exprs.is_empty() => {} + _ => return None, + } + match &callee.kind { + ExprKind::Ident(ident) => Some(ident.to_string()), + _ => None, + } + } + _ => None, + }) + .is_some_and(|name| name == "require" || name == "assert") + } +} + +/// Extract text from source given a span +fn extract_span_text(source: &str, span: solar::ast::Span) -> String { + let lo = span.lo().0 as usize; + let hi = span.hi().0 as usize; + source.get(lo..hi).map(|s| s.to_string()).unwrap_or_default() +} + +fn invert_condition_text(source: &str, condition_expr: &Expr<'_>) -> String { + match &condition_expr.kind { + ExprKind::Unary(op, inner) if op.kind == UnOpKind::Not => { + extract_span_text(source, inner.span) + } + _ => { + let condition = extract_span_text(source, condition_expr.span); + format!("!({})", condition.trim()) + } + } +} diff --git a/crates/forge/src/mutation/mutators/tests/assembly_mutator_test.rs b/crates/forge/src/mutation/mutators/tests/assembly_mutator_test.rs new file mode 100644 index 0000000000000..e17f1a48dccd9 --- /dev/null +++ b/crates/forge/src/mutation/mutators/tests/assembly_mutator_test.rs @@ -0,0 +1,488 @@ +//! Integration tests for the AssemblyMutator using real-world Solady-inspired patterns. + +use std::path::PathBuf; + +use solar::{ + ast::{Arena, interface::source_map::FileName, visit::Visit}, + parse::Parser, +}; + +use crate::mutation::{Session, mutant::MutationType, visitor::MutantVisitor}; + +/// Solady FixedPointMathLib: `fullMulDivUnchecked` uses mul, sub, div, lt, and, xor. +#[test] +fn test_solady_full_mul_div_unchecked() { + let source = r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +library FixedPointMathLib { + function fullMulDivUnchecked(uint256 x, uint256 y, uint256 d) + internal pure returns (uint256 z) + { + assembly { + z := mul(x, y) + let mm := mulmod(x, y, not(0)) + let p1 := sub(mm, add(z, lt(mm, z))) + let t := and(d, sub(0, d)) + d := div(d, t) + let inv := xor(2, mul(3, d)) + inv := mul(inv, sub(2, mul(d, inv))) + } + } +} +"#; + + let yul = yul_mutations(source); + assert_has_opcode_mutation(&yul, "mul", "add"); + assert_has_opcode_mutation(&yul, "mul", "div"); + assert_has_opcode_mutation(&yul, "sub", "add"); + assert_has_opcode_mutation(&yul, "div", "mul"); + assert_has_opcode_mutation(&yul, "lt", "gt"); + assert_has_opcode_mutation(&yul, "and", "or"); + assert_has_opcode_mutation(&yul, "xor", "and"); + assert_has_opcode_mutation(&yul, "mulmod", "addmod"); +} + +/// Solady: `divUp` uses iszero, mod, div, add. +#[test] +fn test_solady_div_up() { + let source = r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +library FixedPointMathLib { + function divUp(uint256 x, uint256 d) internal pure returns (uint256 z) { + assembly { + if iszero(d) { revert(0, 0) } + z := add(iszero(iszero(mod(x, d))), div(x, d)) + } + } +} +"#; + + let yul = yul_mutations(source); + assert_has_opcode_mutation(&yul, "add", "sub"); + assert_has_opcode_mutation(&yul, "div", "mul"); + assert_has_opcode_mutation(&yul, "mod", "div"); +} + +/// Solady: `zeroFloorSub` / `saturatingSub` uses gt, sub, mul. +#[test] +fn test_solady_zero_floor_sub() { + let source = r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +library FixedPointMathLib { + function zeroFloorSub(uint256 x, uint256 y) internal pure returns (uint256 z) { + assembly { + z := mul(gt(x, y), sub(x, y)) + } + } +} +"#; + + let yul = yul_mutations(source); + assert_has_opcode_mutation(&yul, "gt", "lt"); + assert_has_opcode_mutation(&yul, "sub", "add"); + assert_has_opcode_mutation(&yul, "mul", "add"); +} + +/// Solady: `min(uint256)` uses lt, xor, mul. +#[test] +fn test_solady_min_unsigned() { + let source = r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +library FixedPointMathLib { + function min(uint256 x, uint256 y) internal pure returns (uint256 z) { + assembly { + z := xor(x, mul(xor(x, y), lt(y, x))) + } + } +} +"#; + + let yul = yul_mutations(source); + assert_has_opcode_mutation(&yul, "lt", "gt"); + assert_has_opcode_mutation(&yul, "lt", "eq"); + assert_has_opcode_mutation(&yul, "xor", "and"); +} + +/// Solady: `max(int256)` uses sgt — tests signed comparison mutations. +#[test] +fn test_solady_max_signed() { + let source = r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +library FixedPointMathLib { + function max(int256 x, int256 y) internal pure returns (int256 z) { + assembly { + z := xor(x, mul(xor(x, y), sgt(y, x))) + } + } +} +"#; + + let yul = yul_mutations(source); + assert_has_opcode_mutation(&yul, "sgt", "slt"); + assert_has_opcode_mutation(&yul, "sgt", "gt"); +} + +/// Solady: `dist(int256)` uses sgt, sub, xor, add — signed distance. +#[test] +fn test_solady_dist_signed() { + let source = r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +library FixedPointMathLib { + function dist(int256 x, int256 y) internal pure returns (uint256 z) { + assembly { + z := add(xor(sub(0, sgt(x, y)), sub(y, x)), sgt(x, y)) + } + } +} +"#; + + let yul = yul_mutations(source); + assert_has_opcode_mutation(&yul, "sgt", "slt"); + assert_has_opcode_mutation(&yul, "sub", "add"); + assert_has_opcode_mutation(&yul, "add", "sub"); + assert_has_opcode_mutation(&yul, "xor", "or"); +} + +/// Solady: `saturatingAdd` uses or, sub, lt, add. +#[test] +fn test_solady_saturating_add() { + let source = r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +library FixedPointMathLib { + function saturatingAdd(uint256 x, uint256 y) internal pure returns (uint256 z) { + assembly { + z := or(sub(0, lt(add(x, y), x)), add(x, y)) + } + } +} +"#; + + let yul = yul_mutations(source); + assert_has_opcode_mutation(&yul, "or", "and"); + assert_has_opcode_mutation(&yul, "lt", "gt"); +} + +/// Solady: `log256` uses shl, shr, lt, or — shift mutations. +#[test] +fn test_solady_log256_shifts() { + let source = r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +library FixedPointMathLib { + function log256(uint256 x) internal pure returns (uint256 r) { + assembly { + r := shl(7, lt(0xffffffffffffffffffffffffffffffff, x)) + r := or(r, shl(6, lt(0xffffffffffffffff, shr(r, x)))) + r := or(r, shl(5, lt(0xffffffff, shr(r, x)))) + } + } +} +"#; + + let yul = yul_mutations(source); + assert_has_opcode_mutation(&yul, "shl", "shr"); + assert_has_opcode_mutation(&yul, "shr", "shl"); + assert_has_opcode_mutation(&yul, "or", "and"); +} + +/// Solady: `rawAddMod` / `rawMulMod` — addmod ↔ mulmod swaps. +#[test] +fn test_solady_addmod_mulmod() { + let source = r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +library FixedPointMathLib { + function rawAddMod(uint256 x, uint256 y, uint256 d) internal pure returns (uint256 z) { + assembly { + z := addmod(x, y, d) + } + } + + function rawMulMod(uint256 x, uint256 y, uint256 d) internal pure returns (uint256 z) { + assembly { + z := mulmod(x, y, d) + } + } +} +"#; + + let yul = yul_mutations(source); + assert_has_opcode_mutation(&yul, "addmod", "mulmod"); + assert_has_opcode_mutation(&yul, "mulmod", "addmod"); +} + +/// Opcodes NOT in the mapping should produce zero mutations. +#[test] +fn test_unmapped_opcodes_not_mutated() { + let source = r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract Example { + function test() external view returns (uint256) { + assembly { + let x := mload(0x40) + mstore(0, caller()) + let h := keccak256(0, 32) + sstore(h, x) + } + } +} +"#; + + let yul = yul_mutations(source); + let bad_opcodes = ["mload", "mstore", "caller", "keccak256", "sstore"]; + for opcode in bad_opcodes { + assert!( + !yul.iter().any(|m| m.original_opcode == opcode), + "'{opcode}' should NOT be mutated" + ); + } +} + +/// Empty assembly block should produce zero Yul mutations. +#[test] +fn test_empty_assembly_block() { + let source = r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract Example { + function test() external pure { + assembly {} + } +} +"#; + + let yul = yul_mutations(source); + assert!(yul.is_empty(), "Empty assembly should produce no mutations"); +} + +/// Assembly inside a library function body is traversed. +#[test] +fn test_library_assembly_traversal() { + let source = r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +library AssemblyLib { + function addAsm(uint256 a, uint256 b) internal pure returns (uint256 result) { + assembly { + result := add(a, b) + } + } +} +"#; + + let yul = yul_mutations(source); + assert_has_opcode_mutation(&yul, "add", "sub"); + assert_has_opcode_mutation(&yul, "add", "mul"); +} + +/// Nested calls: only the outermost opcode at each visit should be mutated, +/// inner calls get their own visit. +#[test] +fn test_nested_calls_mutated_independently() { + let source = r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract Example { + function test(uint256 x, uint256 y) external pure returns (uint256) { + assembly { + mstore(0, add(mul(x, y), div(x, y))) + } + } +} +"#; + + let yul = yul_mutations(source); + assert_has_opcode_mutation(&yul, "add", "sub"); + assert_has_opcode_mutation(&yul, "mul", "div"); + assert_has_opcode_mutation(&yul, "div", "mul"); +} + +/// Solidity code without assembly should produce zero Yul mutations. +#[test] +fn test_no_assembly_no_yul_mutations() { + let source = r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract PureSolidity { + function add(uint256 a, uint256 b) external pure returns (uint256) { + return a + b; + } + + function compare(uint256 a, uint256 b) external pure returns (bool) { + return a < b; + } +} +"#; + + let yul = yul_mutations(source); + assert!(yul.is_empty(), "Pure Solidity should produce no Yul mutations"); +} + +/// Solady: `invMod` uses a for-loop with div, sub, mul, eq, slt, mod. +#[test] +fn test_solady_inv_mod_loop() { + let source = r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +library FixedPointMathLib { + function invMod(uint256 a, uint256 n) internal pure returns (uint256 x) { + assembly { + let g := n + let r := mod(a, n) + for { let y := 1 } 1 {} { + let q := div(g, r) + let t := g + g := r + r := sub(t, mul(r, q)) + let u := x + x := y + y := sub(u, mul(y, q)) + if iszero(r) { break } + } + x := mul(eq(g, 1), add(x, mul(slt(x, 0), n))) + } + } +} +"#; + + let yul = yul_mutations(source); + assert_has_opcode_mutation(&yul, "div", "mul"); + assert_has_opcode_mutation(&yul, "sub", "add"); + assert_has_opcode_mutation(&yul, "mul", "add"); + assert_has_opcode_mutation(&yul, "mod", "div"); + assert_has_opcode_mutation(&yul, "eq", "lt"); + assert_has_opcode_mutation(&yul, "slt", "sgt"); +} + +/// Solady: `rpow` uses exp — exponentiation mutation. +#[test] +fn test_solady_rpow_exp() { + let source = r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +library FixedPointMathLib { + function rpow(uint256 x, uint256 y, uint256 b) internal pure returns (uint256 z) { + assembly { + z := mul(b, iszero(y)) + if x { + z := xor(b, mul(xor(b, x), and(y, 1))) + let half := shr(1, b) + for { y := shr(1, y) } y { y := shr(1, y) } { + let xx := mul(x, x) + let xxRound := add(xx, half) + if or(lt(xxRound, xx), shr(128, x)) { + revert(0, 0) + } + x := shr(1, add(mul(xxRound, xxRound), half)) + } + } + } + } +} +"#; + + let yul = yul_mutations(source); + assert_has_opcode_mutation(&yul, "shr", "shl"); + assert_has_opcode_mutation(&yul, "xor", "and"); + assert_has_opcode_mutation(&yul, "or", "and"); +} + +/// Span-based replacement correctly handles the exact opcode token, +/// verified by checking the mutated expression text. +#[test] +fn test_span_replacement_correctness() { + let source = r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract Example { + function test(uint256 a, uint256 b) external pure returns (uint256 r) { + assembly { + r := add(a, b) + } + } +} +"#; + + let yul = yul_mutations(source); + + let add_to_sub: Vec<_> = + yul.iter().filter(|m| m.original_opcode == "add" && m.new_opcode == "sub").collect(); + + assert_eq!(add_to_sub.len(), 1, "Should have exactly one add->sub mutation"); + assert_eq!(add_to_sub[0].mutated_expr, "sub(a, b)"); +} + +struct YulMutation { + original_opcode: String, + new_opcode: String, + mutated_expr: String, +} + +fn yul_mutations(source: &str) -> Vec { + let sess = Session::builder().with_silent_emitter(None).build(); + + sess.enter(|| -> solar::interface::Result> { + let arena = Arena::new(); + let mut parser = Parser::from_lazy_source_code( + &sess, + &arena, + FileName::Real(PathBuf::from("test.sol")), + || Ok(source.to_string()), + )?; + + let ast = parser.parse_file().map_err(|e| e.emit())?; + drop(parser); + + let mut visitor = MutantVisitor::default(PathBuf::from("test.sol")).with_source(source); + let _ = visitor.visit_source_unit(&ast); + + Ok(visitor + .mutation_to_conduct + .into_iter() + .filter_map(|m| match m.mutation { + MutationType::YulOpcode { original_opcode, new_opcode, mutated_expr } => { + Some(YulMutation { original_opcode, new_opcode, mutated_expr }) + } + _ => None, + }) + .collect()) + }) + .unwrap_or_default() +} + +fn assert_has_opcode_mutation(mutations: &[YulMutation], from: &str, to: &str) { + assert!( + mutations.iter().any(|m| m.original_opcode == from && m.new_opcode == to), + "Expected mutation {from} -> {to}. Got: [{}]", + mutations + .iter() + .map(|m| format!("{} -> {}", m.original_opcode, m.new_opcode)) + .collect::>() + .join(", ") + ); +} diff --git a/crates/forge/src/mutation/mutators/tests/assignment_mutator_test.rs b/crates/forge/src/mutation/mutators/tests/assignment_mutator_test.rs new file mode 100644 index 0000000000000..dbbfdc412e37c --- /dev/null +++ b/crates/forge/src/mutation/mutators/tests/assignment_mutator_test.rs @@ -0,0 +1,17 @@ +use crate::mutation::mutators::{ + assignment_mutator::AssignmentMutator, tests::helper::mutator_tests, +}; + +// Each emitted mutation only carries the *replacement text* for the RHS +// span — not the full statement. So `x = 123` mutates to `0` (zero) and +// `-123` (signed-negation), not `x = 0` / `x = -123`. +mutator_tests!(AssignmentMutator; + assign_lit: "x = y" => Some(vec!["0", "-y"]); + assign_number: "x = 123" => Some(vec!["0", "-123"]); + assign_zero: "x = 0" => None; + assign_true: "x = true" => Some(vec!["false"]); + assign_false: "x = false" => Some(vec!["true"]); + assign_declare: "uint256 x = 123" => Some(vec!["0", "-123"]); + declare_zero: "uint256 x = 0" => None; + non_assign: "a = b + c" => None; +); diff --git a/crates/forge/src/mutation/mutators/tests/binary_op_mutator_test.rs b/crates/forge/src/mutation/mutators/tests/binary_op_mutator_test.rs new file mode 100644 index 0000000000000..b672a971e79d9 --- /dev/null +++ b/crates/forge/src/mutation/mutators/tests/binary_op_mutator_test.rs @@ -0,0 +1,20 @@ +use crate::mutation::mutators::{binary_op_mutator::BinaryOpMutator, tests::helper::mutator_tests}; + +mutator_tests!(BinaryOpMutator; + add: "x + y" => Some(vec!["x - y", "x * y", "x / y", "x % y", "x ** y", "x << y", "x >> y", "x & y", "x | y", "x ^ y"]); + sub: "x - y" => Some(vec!["x + y", "x * y", "x / y", "x % y", "x ** y", "x << y", "x >> y", "x & y", "x | y", "x ^ y"]); + mul: "x * y" => Some(vec!["x + y", "x - y", "x / y", "x % y", "x ** y", "x << y", "x >> y", "x & y", "x | y", "x ^ y"]); + div: "x / y" => Some(vec!["x + y", "x - y", "x * y", "x % y", "x ** y", "x << y", "x >> y", "x & y", "x | y", "x ^ y"]); + modulus: "x % y" => Some(vec!["x + y", "x - y", "x * y", "x / y", "x ** y", "x << y", "x >> y", "x & y", "x | y", "x ^ y"]); + pow: "x ** y" => Some(vec!["x + y", "x - y", "x * y", "x / y", "x % y", "x << y", "x >> y", "x & y", "x | y", "x ^ y"]); + bit_shift_left: "x << y" => Some(vec!["x + y", "x - y", "x * y", "x / y", "x % y", "x ** y", "x >> y", "x & y", "x | y", "x ^ y"]); + bit_shift_right: "x >> y" => Some(vec!["x + y", "x - y", "x * y", "x / y", "x % y", "x ** y", "x << y", "x & y", "x | y", "x ^ y"]); + bit_shift_right_unsigned: "x >>> y" => Some(vec!["x + y", "x - y", "x * y", "x / y", "x % y", "x ** y", "x << y", "x >> y", "x & y", "x | y", "x ^ y"]); + bit_and: "x & y" => Some(vec!["x + y", "x - y", "x * y", "x / y", "x % y", "x ** y", "x << y", "x >> y", "x | y", "x ^ y"]); + bit_or: "x | y" => Some(vec!["x + y", "x - y", "x * y", "x / y", "x % y", "x ** y", "x << y", "x >> y", "x & y", "x ^ y"]); + bit_xor: "x ^ y" => Some(vec!["x + y", "x - y", "x * y", "x / y", "x % y", "x ** y", "x << y", "x >> y", "x & y", "x | y"]); + non_binary: "a = true" => None; + compound_assign_add: "a += b" => Some(vec!["a >>= b", "a <<= b", "a &= b", "a |= b", "a ^= b", "a -= b", "a *= b", "a /= b", "a %= b"]); + compound_assign_sub: "a -= b" => Some(vec!["a >>= b", "a <<= b", "a &= b", "a |= b", "a ^= b", "a += b", "a *= b", "a /= b", "a %= b"]); + compound_assign_mul: "a *= b" => Some(vec!["a >>= b", "a <<= b", "a &= b", "a |= b", "a ^= b", "a += b", "a -= b", "a /= b", "a %= b"]); +); diff --git a/crates/forge/src/mutation/mutators/tests/delete_expression_mutator_test.rs b/crates/forge/src/mutation/mutators/tests/delete_expression_mutator_test.rs new file mode 100644 index 0000000000000..3d68858757319 --- /dev/null +++ b/crates/forge/src/mutation/mutators/tests/delete_expression_mutator_test.rs @@ -0,0 +1,11 @@ +use crate::mutation::mutators::{ + delete_expression_mutator::DeleteExpressionMutator, tests::helper::mutator_tests, +}; + +// `delete x` is replaced by `assert(true)` (a no-op statement) — the test +// expects the mutation's *replacement text*, not the original expression +// stripped of the `delete` keyword. +mutator_tests!(DeleteExpressionMutator; + delete_expr: "delete x" => Some(vec!["assert(true)"]); + non_delete: "a = b + c" => None; +); diff --git a/crates/forge/src/mutation/mutators/tests/elim_delegate_mutator_test.rs b/crates/forge/src/mutation/mutators/tests/elim_delegate_mutator_test.rs new file mode 100644 index 0000000000000..913aef0ef7bf8 --- /dev/null +++ b/crates/forge/src/mutation/mutators/tests/elim_delegate_mutator_test.rs @@ -0,0 +1,13 @@ +use crate::mutation::mutators::{ + elim_delegate_mutator::ElimDelegateMutator, tests::helper::mutator_tests, +}; + +// The mutator narrows the replacement span to just the `delegatecall` +// identifier and rewrites it to `call`, so the emitted mutation text is +// just `"call"`. It only matches plain `.delegatecall(args)` Call +// expressions; the variant with `{value: ...}` parses as a `CallOptions` +// wrapper and is intentionally left to a follow-up. +mutator_tests!(ElimDelegateMutator; + delegate_expr: "target.delegatecall(data)" => Some(vec!["call"]); + non_delegate: "target.call(data)" => None; +); diff --git a/crates/forge/src/mutation/mutators/tests/helper.rs b/crates/forge/src/mutation/mutators/tests/helper.rs new file mode 100644 index 0000000000000..7dd763439f0bd --- /dev/null +++ b/crates/forge/src/mutation/mutators/tests/helper.rs @@ -0,0 +1,128 @@ +use solar::{ + ast::{Arena, interface::source_map::FileName, visit::Visit}, + parse::Parser, +}; + +use std::path::PathBuf; + +use crate::mutation::{Session, mutators::Mutator, visitor::MutantVisitor}; + +pub struct MutatorTestCase<'a> { + /// Source code to test - should be valid Solidity code + /// e.g., `"// SPDX-License-Identifier: MIT\npragma solidity ^0.8.0;\ncontract C { function f() + /// { x = 1; } }"` + pub input: &'a str, + /// All the mutations expected for this input, using this mutator + pub expected_mutations: Option>, +} + +pub trait MutatorTester { + fn test_mutator(mutator: M, test_case: MutatorTestCase<'_>) { + // Wrap the snippet in a minimal but valid Solidity source unit so the + // parser/visitor actually walks into expression contexts. Bare + // fragments like `"x + y"` or `"a += b"` are not parseable on their + // own; without wrapping, the parser would emit errors that the test + // harness used to swallow, silently making mutator tests vacuous. + let wrapped = format!( + "// SPDX-License-Identifier: MIT\n\ + pragma solidity ^0.8.0;\n\ + contract __TestC {{\n\ + function __test() public {{\n\ + {input};\n\ + }}\n\ + }}\n", + input = test_case.input, + ); + + let sess = Session::builder().with_silent_emitter(None).build(); + + let outcome = sess.enter(|| -> solar::interface::Result> { + let arena = Arena::new(); + + let mut parser = Parser::from_lazy_source_code( + &sess, + &arena, + FileName::Real(PathBuf::from("test.sol")), + || Ok(wrapped.clone()), + )?; + + let ast = parser.parse_file().map_err(|e| e.emit())?; + drop(parser); + + let mut mutant_visitor = MutantVisitor::new_with_mutators( + PathBuf::from("test.sol"), + vec![Box::new(mutator)], + ) + .with_source(&wrapped); + + let _ = mutant_visitor.visit_source_unit(&ast); + + Ok(mutant_visitor + .mutation_to_conduct + .into_iter() + .map(|m| m.mutation.to_string()) + .collect()) + }); + + // Surface parse/visit errors instead of silently passing the test. + let mutations = outcome.unwrap_or_else(|_| { + panic!( + "mutator test input failed to parse/visit; wrapped source was:\n{wrapped}\n\ + raw input: {input:?}", + input = test_case.input, + ) + }); + + if let Some(expected) = test_case.expected_mutations { + let mut actual = mutations; + actual.sort(); + + let mut expected = expected.into_iter().map(str::to_string).collect::>(); + expected.sort(); + + assert_eq!(actual, expected, "Unexpected mutation set for input {:?}", test_case.input); + } else { + assert!( + mutations.is_empty(), + "Expected no mutations, got {}: {:?}", + mutations.len(), + mutations, + ); + } + } +} + +// Implement for unit test module +impl MutatorTester for () {} + +/// Generates one `#[test]` function per case for a [`Mutator`]. +/// +/// Each case becomes a standalone test (parallel execution, individual reporting, +/// IDE run buttons), without pulling in `rstest`. +/// +/// # Example +/// +/// ```ignore +/// mutator_tests!(UnaryOpMutator; +/// pre_inc: "++x" => Some(vec!["--x", "~x", "-x", "x++", "x--"]); +/// non_unary: "a = b + c" => None; +/// ); +/// ``` +macro_rules! mutator_tests { + ($mutator:expr; $($name:ident: $input:expr => $expected:expr);+ $(;)?) => { + $( + #[test] + fn $name() { + <() as $crate::mutation::mutators::tests::helper::MutatorTester>::test_mutator( + $mutator, + $crate::mutation::mutators::tests::helper::MutatorTestCase { + input: $input, + expected_mutations: $expected, + }, + ); + } + )+ + }; +} + +pub(crate) use mutator_tests; diff --git a/crates/forge/src/mutation/mutators/tests/mod.rs b/crates/forge/src/mutation/mutators/tests/mod.rs new file mode 100644 index 0000000000000..b1c8b4f85996f --- /dev/null +++ b/crates/forge/src/mutation/mutators/tests/mod.rs @@ -0,0 +1,9 @@ +mod assembly_mutator_test; +mod assignment_mutator_test; +mod binary_op_mutator_test; +mod delete_expression_mutator_test; +mod elim_delegate_mutator_test; +mod require_mutator_test; +mod unary_op_mutator_test; + +mod helper; diff --git a/crates/forge/src/mutation/mutators/tests/require_mutator_test.rs b/crates/forge/src/mutation/mutators/tests/require_mutator_test.rs new file mode 100644 index 0000000000000..a363cd82caa8a --- /dev/null +++ b/crates/forge/src/mutation/mutators/tests/require_mutator_test.rs @@ -0,0 +1,16 @@ +use crate::mutation::mutators::{require_mutator::RequireMutator, tests::helper::mutator_tests}; + +mutator_tests!(RequireMutator; + require_true: "require(true)" => Some(vec!["require(false)", "require(!(true))"]); + require_false: "require(false)" => Some(vec!["require(true)", "require(!(false))"]); + require_not: "require(!paused, \"paused\")" => Some(vec![ + "require(false, \"paused\")", + "require(paused, \"paused\")", + "require(true, \"paused\")", + ]); + require_not_composite: "require(!paused && isOwner, \"paused\")" => Some(vec![ + "require(!(!paused && isOwner), \"paused\")", + "require(false, \"paused\")", + "require(true, \"paused\")", + ]); +); diff --git a/crates/forge/src/mutation/mutators/tests/unary_op_mutator_test.rs b/crates/forge/src/mutation/mutators/tests/unary_op_mutator_test.rs new file mode 100644 index 0000000000000..bdff889f99d53 --- /dev/null +++ b/crates/forge/src/mutation/mutators/tests/unary_op_mutator_test.rs @@ -0,0 +1,67 @@ +use crate::mutation::mutators::{tests::helper::mutator_tests, unary_op_mutator::UnaryOpMutator}; + +mutator_tests!(UnaryOpMutator; + pre_inc: "++x" => Some(vec!["--x", "~x", "-x", "x++", "x--"]); + pre_dec: "--x" => Some(vec!["++x", "~x", "-x", "x++", "x--"]); + neg: "-x" => Some(vec!["++x", "--x", "~x", "x++", "x--"]); + bit_not: "~x" => Some(vec!["++x", "--x", "-x", "x++", "x--"]); + post_inc: "x++" => Some(vec!["++x", "--x", "~x", "-x", "x--"]); + post_dec: "x--" => Some(vec!["++x", "--x", "~x", "-x", "x++"]); + bool_not: "!x" => Some(vec!["x"]); + indexed_post_inc: "arr[i]++" => Some(vec![ + "++arr[i]", + "--arr[i]", + "~arr[i]", + "-arr[i]", + "arr[i]--", + ]); + member_post_inc: "boxValue.value++" => Some(vec![ + "++boxValue.value", + "--boxValue.value", + "~boxValue.value", + "-boxValue.value", + "boxValue.value--", + ]); + chained_member_post_inc: "foo().bar++" => Some(vec![ + "++foo().bar", + "--foo().bar", + "~foo().bar", + "-foo().bar", + "foo().bar--", + ]); + not_parenthesized_binary: "!(a == b)" => Some(vec!["(a == b)"]); + negated_call: "-foo()" => Some(vec!["~foo()"]); + negated_storage_push: "-values.push()" => Some(vec![ + "++values.push()", + "--values.push()", + "~values.push()", + "values.push()++", + "values.push()--", + ]); + negated_method_named_push: "-producer.push(1)" => Some(vec!["~producer.push(1)"]); + parenthesized_storage_push: "-(values.push())" => Some(vec![ + "++(values.push())", + "--(values.push())", + "~(values.push())", + "(values.push())++", + "(values.push())--", + ]); + indexed_call: "-getStorageArray()[i]" => Some(vec![ + "++getStorageArray()[i]", + "--getStorageArray()[i]", + "~getStorageArray()[i]", + "getStorageArray()[i]++", + "getStorageArray()[i]--", + ]); + bit_not_binary: "~(a + b)" => Some(vec!["-(a + b)"]); + negated_cast: "-uint256(x)" => Some(vec!["~uint256(x)"]); + negated_literal: "-1" => Some(vec!["~1"]); + parenthesized_identifier: "-(x)" => Some(vec![ + "++(x)", + "--(x)", + "~(x)", + "(x)++", + "(x)--", + ]); + non_unary: "a = b + c" => None; +); diff --git a/crates/forge/src/mutation/mutators/unary_op_mutator.rs b/crates/forge/src/mutation/mutators/unary_op_mutator.rs new file mode 100644 index 0000000000000..63cff88136780 --- /dev/null +++ b/crates/forge/src/mutation/mutators/unary_op_mutator.rs @@ -0,0 +1,145 @@ +use eyre::Result; +use solar::ast::{ExprKind, Span, UnOpKind}; + +use super::{MutationContext, Mutator}; +use crate::mutation::mutant::{Mutant, MutationType, UnaryOpMutated}; + +pub struct UnaryOpMutator; + +impl Mutator for UnaryOpMutator { + fn generate_mutants(&self, context: &MutationContext<'_>) -> Result> { + let operations = vec![ + UnOpKind::PreInc, // number + UnOpKind::PreDec, // n + UnOpKind::Neg, // n + UnOpKind::BitNot, // n + ]; + + let post_fixed_operations = vec![UnOpKind::PostInc, UnOpKind::PostDec]; + + let expr = context.expr.unwrap(); + + let (op, target) = match &expr.kind { + ExprKind::Unary(un_op, target) => (un_op.kind, &**target), + _ => unreachable!(), + }; + + let target_content = extract_span_text(context.source.unwrap_or(""), target.span); + if target_content.is_empty() { + return Ok(vec![]); + } + + let original = context.original_text(); + let source_line = context.source_line(); + let line_number = context.line_number(); + let column_number = context.column_number(); + + // Bool has only the Not operator as possible target -> we try removing it + if op == UnOpKind::Not { + return Ok(vec![Mutant { + span: expr.span, + mutation: MutationType::UnaryOperator(UnaryOpMutated::new( + target_content, + UnOpKind::Not, + )), + path: context.path.clone(), + original, + source_line, + line_number, + column_number, + }]); + } + + let mut mutations: Vec; + + mutations = operations + .into_iter() + .filter(|&kind| kind != op) + .filter(|&kind| { + !matches!(kind, UnOpKind::PreInc | UnOpKind::PreDec) + || !is_definitely_non_lvalue(target) + }) + .map(|kind| { + let new_expression = format!("{}{}", kind.to_str(), target_content); + + let mutated = UnaryOpMutated::new(new_expression, kind); + + Mutant { + span: expr.span, + mutation: MutationType::UnaryOperator(mutated), + path: context.path.clone(), + original: original.clone(), + source_line: source_line.clone(), + line_number, + column_number, + } + }) + .collect(); + + mutations.extend( + post_fixed_operations + .into_iter() + .filter(|&kind| kind != op && !is_definitely_non_lvalue(target)) + .map(|kind| { + let new_expression = format!("{}{}", target_content, kind.to_str()); + + let mutated = UnaryOpMutated::new(new_expression, kind); + + Mutant { + span: expr.span, + mutation: MutationType::UnaryOperator(mutated), + path: context.path.clone(), + original: original.clone(), + source_line: source_line.clone(), + line_number, + column_number, + } + }), + ); + + Ok(mutations) + } + + fn is_applicable(&self, ctxt: &MutationContext<'_>) -> bool { + if let Some(expr) = ctxt.expr + && let ExprKind::Unary(_, _) = &expr.kind + { + return true; + } + + false + } +} + +fn is_definitely_non_lvalue(expr: &solar::ast::Expr<'_>) -> bool { + if let ExprKind::Call(callee, args) = &expr.peel_parens().kind { + return !(args.is_empty() + && matches!( + &callee.peel_parens().kind, + ExprKind::Member(_, member) if member.as_str() == "push" + )); + } + + matches!( + &expr.peel_parens().kind, + ExprKind::Array(_) + | ExprKind::Assign(..) + | ExprKind::Binary(..) + | ExprKind::CallOptions(..) + | ExprKind::Delete(_) + | ExprKind::Lit(..) + | ExprKind::New(_) + | ExprKind::Payable(_) + | ExprKind::Ternary(..) + | ExprKind::Tuple(_) + | ExprKind::TypeCall(_) + | ExprKind::Type(_) + | ExprKind::Unary(..) + ) +} + +fn extract_span_text(source: &str, span: Span) -> String { + let lo = span.lo().0 as usize; + let hi = span.hi().0 as usize; + source.get(lo..hi).map(str::trim).unwrap_or_default().to_string() +} diff --git a/crates/forge/src/mutation/orchestrator.rs b/crates/forge/src/mutation/orchestrator.rs new file mode 100644 index 0000000000000..40c3792b390c4 --- /dev/null +++ b/crates/forge/src/mutation/orchestrator.rs @@ -0,0 +1,935 @@ +//! Mutation testing orchestrator. +//! +//! This module coordinates the mutation testing workflow, including: +//! - Filtering source files for mutation +//! - Managing mutation handlers per file +//! - Running mutations in parallel with caching +//! - Aggregating results and reporting + +use std::{ + collections::{BTreeMap, BTreeSet, HashSet}, + path::{Path, PathBuf}, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::Instant, +}; + +use alloy_primitives::keccak256; +use eyre::{Result, WrapErr}; +use foundry_cli::utils::FoundryPathExt; +use foundry_common::{compile::ProjectCompiler, sh_println}; +use foundry_compilers::{ + Language, ProjectCompileOutput, + compilers::multi::{MultiCompiler, MultiCompilerLanguage}, + utils::source_files_iter, +}; +use foundry_config::{Config, filter::GlobMatcher}; +use foundry_evm::{ + backend::Backend, core::evm::FoundryEvmNetwork, executors::ExecutorBuilder, fork::Fork, + opts::EvmOpts, +}; + +use crate::{ + cmd::test::{FilterArgs, RerunFailure}, + mutation::{ + MutationHandler, MutationProgress, MutationReporter, MutationsSummary, + mutant::{Mutant, MutationResult}, + runner::{MutationEvmConfig, run_mutations_parallel_with_progress}, + type_analysis::{collect_mutation_exclusions, normalize_path}, + }, +}; + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, serde::Serialize)] +struct ArtifactCacheFingerprint { + source: String, + name: String, + version: String, + build_id: String, + profile: String, +} + +#[derive(serde::Serialize)] +struct ExecutionCacheFingerprint<'a> { + schema: &'static str, + config: &'a Config, + evm_opts: &'a EvmOpts, + resolved_fork: Option, + filter_args: FilterArgsFingerprint<'a>, + rerun_failures: Option<&'a [RerunFailure]>, + artifacts: &'a [ArtifactCacheFingerprint], +} + +#[derive(serde::Serialize)] +struct FilterArgsFingerprint<'a> { + test_pattern: Option<&'a str>, + test_pattern_inverse: Option<&'a str>, + contract_pattern: Option<&'a str>, + contract_pattern_inverse: Option<&'a str>, + path_pattern: Option<&'a str>, + path_pattern_inverse: Option<&'a str>, +} + +/// Configuration for mutation testing run. +pub struct MutationRunConfig { + /// Paths to mutate (if empty, use all source files). + pub mutate_paths: Vec, + /// Optional glob pattern to filter paths. + pub mutate_path_pattern: Option, + /// Optional contract regex pattern to filter contracts. + pub mutate_contract_pattern: Option, + /// Number of parallel workers (0 = auto-detect). + pub num_workers: usize, + /// Whether to show progress display. + pub show_progress: bool, + /// Whether to output JSON (suppress all other output). + pub json_output: bool, + /// Test filter (`--match-test`, `--match-contract`, `--match-path`, ...) + /// applied identically to baseline and every mutant run so they exercise + /// the same test set. + pub filter_args: FilterArgs, + /// Exact contract/test pairs selected by `--rerun`, if present. These are + /// not representable by `FilterArgs` alone because rerun stores precise + /// suite identifiers in addition to a test-name regex. + pub rerun_failures: Option>, + /// Project-relative source files selected for the baseline compile. + /// Re-rooted into each per-mutant workspace so compilation and execution + /// honor the same filtered test universe. + pub selected_sources_relative: Vec, + /// EVM isolation flag — mirrors the canonical `forge test` runner so + /// baseline and mutant runs use the same execution model. + pub isolate: bool, +} + +impl MutationRunConfig { + /// Determine number of workers, using auto-detection if 0. + pub fn effective_workers(&self) -> usize { + if self.num_workers == 0 { + std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1) + } else { + self.num_workers + } + } +} + +/// Result of a mutation testing run. +pub struct MutationRunResult { + /// Summary of all mutations across all files. + pub summary: MutationsSummary, + /// Whether the run was cancelled (e.g., Ctrl+C). + pub cancelled: bool, + /// Duration of the mutation testing run in seconds. + pub duration_secs: f64, +} + +/// Run mutation testing on the project. +/// +/// This function encapsulates the mutation testing logic that was previously +/// in the test command. It handles: +/// - Filtering source files based on patterns +/// - Per-file mutation handling with caching +/// - Parallel mutation execution +/// - Result aggregation and reporting +pub async fn run_mutation_testing( + config: Arc, + output: &ProjectCompileOutput, + evm_opts: EvmOpts, + backend: Backend, + executor_builder: ExecutorBuilder, + mutation_config: MutationRunConfig, +) -> Result { + let create2_deployer_available = + backend.can_use_create2_deployer(evm_opts.create2_deployer).await?; + let fork = backend.fork()?; + let mutation_evm = MutationEvmConfig { + opts: evm_opts.clone(), + backend, + executor_builder, + create2_deployer_available, + }; + let num_workers = mutation_config.effective_workers(); + let json_output = mutation_config.json_output; + let artifact_link_references = output.artifact_ids().filter_map(|(id, artifact)| { + let source = project_relative_path(&config.root, &id.source)?; + let links = artifact + .all_link_references() + .into_keys() + .filter_map(|file| project_relative_path(&config.root, Path::new(&file))) + .collect::>(); + Some((source, links)) + }); + let selected_sources_relative = mutation_compile_sources( + mutation_config.selected_sources_relative.iter().cloned(), + artifact_link_references, + ); + + // Determine which paths to mutate + let mutate_paths = resolve_mutate_paths(&config, output, &mutation_config)?; + let execution_cache_output = ProjectCompiler::new() + .dynamic_test_linking(config.dynamic_test_linking) + .quiet(json_output) + .files( + selected_sources_relative + .iter() + .map(|path| config.root.join(path)) + .filter(|path| path.exists()) + .collect::>(), + ) + .compile(&config.project()?)?; + let execution_cache_key = mutation_execution_cache_key( + &config, + &execution_cache_output, + &evm_opts, + fork.as_ref(), + &mutation_config.filter_args, + mutation_config.rerun_failures.as_deref(), + )?; + let mut mutation_exclusions = collect_mutation_exclusions(&config, output).unwrap_or_default(); + + if !mutation_config.show_progress && !json_output { + sh_println!("Running mutation tests with {} parallel workers...", num_workers)?; + } + + let mut mutation_summary = MutationsSummary::new(); + let mut cancelled = false; + let start_time = Instant::now(); + let cancellation_requested = Arc::new(AtomicBool::new(false)); + let ctrlc_handle = { + let cancellation_requested = Arc::clone(&cancellation_requested); + tokio::spawn(async move { + if tokio::signal::ctrl_c().await.is_ok() { + cancellation_requested.store(true, Ordering::SeqCst); + } + }) + }; + + for path in mutate_paths { + if cancellation_requested.load(Ordering::SeqCst) { + cancelled = true; + break; + } + + if !mutation_config.show_progress && !json_output { + sh_println!("Running mutation tests for {}", path.display())?; + } + + // Create handler for this file, optionally restricting to a subset of + // contracts by name when --mutate-contract is provided. + let mut handler = MutationHandler::new(path.clone(), config.clone()); + if let Some(mutations) = mutation_exclusions.remove(&normalize_path(&path)) { + handler = handler.with_mutation_exclusions(mutations); + } + if let Some(filter) = &mutation_config.mutate_contract_pattern { + handler = handler.with_contract_filter(filter.clone()); + } + handler.read_source_contract()?; + + // Get build ID for caching + let build_id = output + .artifact_ids() + .find_map(|(id, _)| (id.source == path).then_some(id.build_id)) + .unwrap_or_default(); + + // Load persisted survived spans before generating/loading mutants so + // resumed runs can retain adaptively skipped points as Skipped results + // while only executing mutants whose spans still need coverage. + handler.retrieve_survived_spans(&build_id, &execution_cache_key); + + // Generate or load cached mutants. Adaptive resume happens after the + // full mutant set is known so skipped points are still counted and + // reported as Skipped instead of disappearing from totals. + let mut mutants = if let Some(ms) = handler.retrieve_cached_mutants(&build_id) { + ms + } else { + handler.generate_ast().await?; + handler.mutations.clone() + }; + + if mutants.is_empty() { + if !mutation_config.show_progress && !json_output { + sh_println!(" No mutants generated for {}", path.display())?; + } + continue; + } + + // Check for cached results only after the current mutant set is known. + // The result cache carries a count/hash of that set so stale or partial + // caches cannot suppress newly generated mutants. + if let Some(prior) = + handler.retrieve_cached_mutant_results(&build_id, &execution_cache_key, &mutants) + { + if !mutation_config.show_progress && !json_output { + sh_println!(" Using cached results for {} mutants", prior.len())?; + } + for (mutant, status) in prior { + match status { + MutationResult::Dead => handler.add_dead_mutant(mutant), + MutationResult::Alive => handler.add_survived_mutant(mutant), + MutationResult::Invalid => handler.add_invalid_mutant(mutant), + MutationResult::Skipped => handler.add_skipped_mutant(mutant), + MutationResult::TimedOut => handler.add_timed_out_mutant(mutant), + } + } + mutation_summary.merge(handler.get_report()); + continue; + } + + // Sort mutations by span for optimal adaptive testing + mutants.sort_by(|a, b| { + a.span.lo().0.cmp(&b.span.lo().0).then_with(|| b.span.hi().0.cmp(&a.span.hi().0)) + }); + + let (mutants_to_test, skipped_results) = + partition_adaptively_skipped_mutants(&mut handler, &mutants); + + // Create progress display if enabled (not in JSON mode) + let progress = if mutation_config.show_progress && !json_output { + let p = MutationProgress::with_timeout( + mutants_to_test.len(), + num_workers, + config.mutation.timeout, + ); + // Show relative path from project root + let display_path = + path.strip_prefix(&config.root).unwrap_or(&path).display().to_string(); + p.set_current_file(&display_path); + Some(p) + } else if !json_output { + sh_println!( + " Generated {} mutants; testing {}, adaptively skipped {}", + mutants.len(), + mutants_to_test.len(), + skipped_results.len() + )?; + None + } else { + None + }; + + // Run mutations in parallel using isolated workspaces + let batch = run_mutations_parallel_with_progress( + mutants_to_test, + path.clone(), + handler.src.clone(), + config.clone(), + mutation_evm.clone(), + num_workers, + progress, + json_output, + mutation_config.filter_args.clone(), + mutation_config.rerun_failures.clone(), + Arc::new(selected_sources_relative.clone()), + mutation_config.isolate, + Arc::clone(&cancellation_requested), + )?; + let file_cancelled = batch.cancelled; + + // Collect results for caching + let mut results_vec = Vec::with_capacity(skipped_results.len() + batch.results.len()); + results_vec.extend(skipped_results); + for result in batch.results { + results_vec.push((result.mutant.clone(), result.result.clone())); + match result.result { + MutationResult::Dead => handler.add_dead_mutant(result.mutant), + MutationResult::Alive => { + handler.mark_span_survived(result.mutant.span); + handler.add_survived_mutant(result.mutant); + } + MutationResult::Invalid => handler.add_invalid_mutant(result.mutant), + MutationResult::Skipped => handler.add_skipped_mutant(result.mutant), + MutationResult::TimedOut => handler.add_timed_out_mutant(result.mutant), + } + } + + // Detect cancellation early so we can decide whether the result set is + // complete before persisting it. Without this guard a Ctrl+C mid-run + // would write a *partial* results vector to the cache and the next run + // would treat that subset as the full answer for this file. + let complete_run = !file_cancelled && results_vec.len() == mutants.len(); + + // Persist results for caching only when the run for this file is + // complete. Partial caches are silent correctness bugs: + // - cancelled runs would be reloaded as authoritative + // - non-cancelled-but-short result vectors indicate a bug, not a hit + // The mutants list itself is fine to persist (it's deterministic from + // the AST + operator set) and so are survived spans (best-effort hint). + // + // Sort the persisted result vector by mutant span so the on-disk + // cache is independent of rayon worker completion order; otherwise + // the cache file changes content-hash run-to-run even when the + // outcomes are identical, defeating diffing and reproducibility. + results_vec.sort_by(|(a, _), (b, _)| { + a.span.lo().0.cmp(&b.span.lo().0).then_with(|| a.span.hi().0.cmp(&b.span.hi().0)) + }); + if !mutants.is_empty() && !build_id.is_empty() { + let _ = handler.persist_cached_mutants(&build_id, &mutants); + if complete_run { + let _ = handler.persist_cached_results( + &build_id, + &execution_cache_key, + &mutants, + &results_vec, + ); + } + let _ = handler.persist_survived_spans(&build_id, &execution_cache_key); + } + + mutation_summary.merge(handler.get_report()); + + // If cancelled, break out of the loop + if file_cancelled { + cancelled = true; + break; + } + } + cancelled |= cancellation_requested.load(Ordering::SeqCst); + + // Report results + let duration = start_time.elapsed(); + let duration_secs = duration.as_secs_f64(); + + // Only show human-readable report if not in JSON mode + if !json_output { + MutationReporter::new().report(&mutation_summary, duration); + } + + ctrlc_handle.abort(); + + Ok(MutationRunResult { summary: mutation_summary, cancelled, duration_secs }) +} + +/// Build the cache discriminator for mutation *results*. +/// +/// Mutant generation only depends on the source build + selected mutators, but +/// result correctness depends on the compiled test universe and execution +/// settings. Hashing the full serialized config intentionally includes fuzz / +/// invariant settings, test filters, fs permissions, sender/balance/env values, +/// and future config fields unless explicitly skipped by `Config` itself. The +/// artifact fingerprint covers the same filter-selected source and test build +/// IDs that baseline and mutant runs compile. Worker count is not included: every +/// mutant is tested independently, so the results do not depend on it. +fn mutation_execution_cache_key( + config: &Config, + output: &ProjectCompileOutput, + evm_opts: &EvmOpts, + resolved_fork: Option<&Fork>, + filter_args: &FilterArgs, + rerun_failures: Option<&[RerunFailure]>, +) -> Result { + let artifacts = output + .artifact_ids() + .map(|(id, _)| ArtifactCacheFingerprint { + source: id.source.display().to_string(), + name: id.name, + version: id.version.to_string(), + build_id: id.build_id, + profile: id.profile, + }) + .collect::>(); + mutation_execution_cache_key_from_parts_with_rerun_failures( + config, + evm_opts, + resolved_fork.map(Fork::fingerprint), + filter_args, + rerun_failures, + artifacts, + ) +} + +#[cfg(test)] +fn mutation_execution_cache_key_from_parts( + config: &Config, + evm_opts: &EvmOpts, + filter_args: &FilterArgs, + artifacts: Vec, +) -> Result { + mutation_execution_cache_key_from_parts_with_rerun_failures( + config, + evm_opts, + None, + filter_args, + None, + artifacts, + ) +} + +fn mutation_execution_cache_key_from_parts_with_rerun_failures( + config: &Config, + evm_opts: &EvmOpts, + resolved_fork: Option, + filter_args: &FilterArgs, + rerun_failures: Option<&[RerunFailure]>, + mut artifacts: Vec, +) -> Result { + artifacts.sort(); + let fingerprint = ExecutionCacheFingerprint { + schema: "mutation-results-v2", + config, + evm_opts, + resolved_fork, + filter_args: filter_args_fingerprint(filter_args), + rerun_failures, + artifacts: &artifacts, + }; + let encoded = serde_json::to_vec(&fingerprint) + .wrap_err("failed to encode mutation execution cache key")?; + + Ok(keccak256(encoded).to_string()) +} + +fn filter_args_fingerprint(filter_args: &FilterArgs) -> FilterArgsFingerprint<'_> { + FilterArgsFingerprint { + test_pattern: filter_args.test_pattern.as_ref().map(|re| re.as_str()), + test_pattern_inverse: filter_args.test_pattern_inverse.as_ref().map(|re| re.as_str()), + contract_pattern: filter_args.contract_pattern.as_ref().map(|re| re.as_str()), + contract_pattern_inverse: filter_args + .contract_pattern_inverse + .as_ref() + .map(|re| re.as_str()), + path_pattern: filter_args.path_pattern.as_ref().map(|glob| glob.as_str()), + path_pattern_inverse: filter_args.path_pattern_inverse.as_ref().map(|glob| glob.as_str()), + } +} + +pub(super) fn project_relative_path(root: &Path, path: &Path) -> Option { + if path.is_relative() { + return Some(path.to_path_buf()); + } + + if let Ok(stripped) = path.strip_prefix(root) { + return Some(stripped.to_path_buf()); + } + + path.canonicalize().ok()?.strip_prefix(root.canonicalize().ok()?).ok().map(PathBuf::from) +} + +fn mutation_compile_sources( + selected_sources: impl IntoIterator, + artifact_link_references: impl IntoIterator)>, +) -> Vec { + let link_edges = artifact_link_references.into_iter().collect::>(); + let mut selected_sources_relative = selected_sources.into_iter().collect::>(); + let mut queue = selected_sources_relative.iter().cloned().collect::>(); + + while let Some(source) = queue.pop() { + if let Some(links) = link_edges.get(&source) { + for link in links { + if selected_sources_relative.insert(link.clone()) { + queue.push(link.clone()); + } + } + } + } + + selected_sources_relative.into_iter().collect() +} + +fn partition_adaptively_skipped_mutants( + handler: &mut MutationHandler, + mutants: &[Mutant], +) -> (Vec, Vec<(Mutant, MutationResult)>) { + let mut skipped_results = Vec::new(); + let mutants_to_test = mutants + .iter() + .filter_map(|mutant| { + if handler.should_skip_span(mutant.span) { + handler.add_skipped_mutant(mutant.clone()); + skipped_results.push((mutant.clone(), MutationResult::Skipped)); + None + } else { + Some(mutant.clone()) + } + }) + .collect(); + + (mutants_to_test, skipped_results) +} + +/// Resolve which paths to mutate based on configuration. +/// +/// Resolution order: +/// 1. Pick the *base* set of candidate files: +/// - `--mutate-path ` → all source files matching the glob, OR +/// - explicit `--mutate PATH...` → those validated files, OR +/// - default → every Solidity file under `config.src`. +/// 2. If `--mutate-contract ` is set, intersect the base set with files that contain at +/// least one contract whose name matches the regex. The per-file contract filter still +/// re-applies inside the handler. +fn resolve_mutate_paths( + config: &Config, + output: &ProjectCompileOutput, + mutation_config: &MutationRunConfig, +) -> Result> { + // 1. Base path set. + let base: Vec = if let Some(pattern) = &mutation_config.mutate_path_pattern { + let paths: Vec<_> = source_files_iter(&config.src, MultiCompilerLanguage::FILE_EXTENSIONS) + .filter(|entry| entry.is_sol() && !entry.is_sol_test() && pattern.is_match(entry)) + .collect(); + if paths.is_empty() { + eyre::bail!("no source matched --mutate-path pattern `{pattern}`"); + } + paths + } else if !mutation_config.mutate_paths.is_empty() { + let root_canon = + config.root.canonicalize().wrap_err("failed to canonicalize project root")?; + let mut validated = Vec::with_capacity(mutation_config.mutate_paths.len()); + for path in &mutation_config.mutate_paths { + let resolved = if path.is_relative() { config.root.join(path) } else { path.clone() }; + if !resolved.exists() { + eyre::bail!("mutate path does not exist: {}", resolved.display()); + } + if !resolved.is_file() { + eyre::bail!("mutate path is not a file: {}", resolved.display()); + } + let canon = resolved + .canonicalize() + .wrap_err_with(|| format!("failed to canonicalize: {}", resolved.display()))?; + if !canon.starts_with(&root_canon) { + eyre::bail!("mutate path is outside the project root: {}", resolved.display()); + } + if !canon.is_sol() { + eyre::bail!("mutate path is not a Solidity file: {}", resolved.display()); + } + if canon.is_sol_test() { + eyre::bail!( + "mutate path is a test file, not a source file: {}", + resolved.display() + ); + } + validated.push(canon); + } + validated + } else { + source_files_iter(&config.src, MultiCompilerLanguage::FILE_EXTENSIONS) + .filter(|entry| entry.is_sol() && !entry.is_sol_test()) + .collect() + }; + + // 2. Intersect with `--mutate-contract` if set, so explicit `--mutate ` combined with + // `--mutate-contract ` does the principled thing (the listed files, restricted to + // those containing a matching contract) instead of silently expanding to every source file. + let paths = if let Some(contract_pattern) = &mutation_config.mutate_contract_pattern { + let matching_sources: HashSet = output + .artifact_ids() + .filter_map(|(id, _)| contract_pattern.is_match(&id.name).then_some(id.source.clone())) + .collect(); + let paths: Vec<_> = + base.into_iter().filter(|entry| matching_sources.contains(entry)).collect(); + if paths.is_empty() { + if mutation_config.mutate_paths.is_empty() + && mutation_config.mutate_path_pattern.is_none() + { + eyre::bail!("no source matched --mutate-contract pattern `{contract_pattern}`"); + } + eyre::bail!("no source matched --mutate-contract within the selected mutation paths"); + } + paths + } else { + base + }; + + Ok(paths) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + use crate::mutation::mutant::MutationType; + use solar::{ast::Span, interface::BytePos}; + + fn artifact(build_id: &str) -> ArtifactCacheFingerprint { + ArtifactCacheFingerprint { + source: "src/Counter.sol".to_string(), + name: "Counter".to_string(), + version: "0.8.30".to_string(), + build_id: build_id.to_string(), + profile: "default".to_string(), + } + } + + fn filter_args() -> FilterArgs { + FilterArgs { + test_pattern: None, + test_pattern_inverse: None, + contract_pattern: None, + contract_pattern_inverse: None, + path_pattern: None, + path_pattern_inverse: None, + coverage_pattern_inverse: None, + } + } + + fn mutant(lo: u32, hi: u32) -> Mutant { + Mutant { + path: PathBuf::from("src/Counter.sol"), + span: Span::new(BytePos(lo), BytePos(hi)), + mutation: MutationType::DeleteExpression, + original: "number++".to_string(), + source_line: "number++;".to_string(), + line_number: 1, + column_number: 1, + } + } + + #[test] + fn execution_cache_key_changes_when_fuzz_config_changes() { + let first = Config::default(); + let mut second = first.clone(); + second.fuzz.runs += 1; + + let evm_opts = EvmOpts::default(); + let filter_args = filter_args(); + let artifacts = vec![artifact("build-a")]; + + let first_key = mutation_execution_cache_key_from_parts( + &first, + &evm_opts, + &filter_args, + artifacts.clone(), + ) + .unwrap(); + let second_key = + mutation_execution_cache_key_from_parts(&second, &evm_opts, &filter_args, artifacts) + .unwrap(); + + assert_ne!(first_key, second_key); + } + + #[test] + fn execution_cache_key_changes_when_evm_options_change() { + let config = Config::default(); + let first = EvmOpts::default(); + let mut second = first.clone(); + second.memory_limit = first.memory_limit + 1; + + let filter_args = filter_args(); + let artifacts = vec![artifact("build-a")]; + + let first_key = mutation_execution_cache_key_from_parts( + &config, + &first, + &filter_args, + artifacts.clone(), + ) + .unwrap(); + let second_key = + mutation_execution_cache_key_from_parts(&config, &second, &filter_args, artifacts) + .unwrap(); + + assert_ne!(first_key, second_key); + } + + #[test] + fn execution_cache_key_changes_when_resolved_fork_changes() { + let config = Config::default(); + let evm_opts = EvmOpts::default(); + let filter_args = filter_args(); + let artifacts = vec![artifact("build-a")]; + + let first_key = mutation_execution_cache_key_from_parts_with_rerun_failures( + &config, + &evm_opts, + Some(alloy_primitives::B256::with_last_byte(1)), + &filter_args, + None, + artifacts.clone(), + ) + .unwrap(); + let second_key = mutation_execution_cache_key_from_parts_with_rerun_failures( + &config, + &evm_opts, + Some(alloy_primitives::B256::with_last_byte(2)), + &filter_args, + None, + artifacts, + ) + .unwrap(); + + assert_ne!(first_key, second_key); + } + + #[test] + fn execution_cache_key_changes_when_compiled_artifacts_change() { + let config = Config::default(); + let evm_opts = EvmOpts::default(); + let filter_args = filter_args(); + + let first_key = mutation_execution_cache_key_from_parts( + &config, + &evm_opts, + &filter_args, + vec![artifact("build-a")], + ) + .unwrap(); + let second_key = mutation_execution_cache_key_from_parts( + &config, + &evm_opts, + &filter_args, + vec![artifact("build-b")], + ) + .unwrap(); + + assert_ne!(first_key, second_key); + } + + #[test] + fn execution_cache_key_sorts_artifacts_before_hashing() { + let config = Config::default(); + let evm_opts = EvmOpts::default(); + let filter_args = filter_args(); + + let first = vec![artifact("build-a"), artifact("build-b")]; + let second = vec![artifact("build-b"), artifact("build-a")]; + + let first_key = + mutation_execution_cache_key_from_parts(&config, &evm_opts, &filter_args, first) + .unwrap(); + let second_key = + mutation_execution_cache_key_from_parts(&config, &evm_opts, &filter_args, second) + .unwrap(); + + assert_eq!(first_key, second_key); + } + + #[test] + fn execution_cache_key_changes_when_match_test_filter_changes() { + let config = Config::default(); + let evm_opts = EvmOpts::default(); + let mut first_filter = filter_args(); + let mut second_filter = filter_args(); + first_filter.test_pattern = Some(regex::Regex::new("testA|testAlpha").unwrap()); + second_filter.test_pattern = Some(regex::Regex::new("testB|testBeta").unwrap()); + let artifacts = vec![artifact("build-a")]; + + let first_key = mutation_execution_cache_key_from_parts( + &config, + &evm_opts, + &first_filter, + artifacts.clone(), + ) + .unwrap(); + let second_key = + mutation_execution_cache_key_from_parts(&config, &evm_opts, &second_filter, artifacts) + .unwrap(); + + assert_ne!(first_key, second_key); + } + + #[test] + fn execution_cache_key_changes_when_match_path_filter_changes() { + let config = Config::default(); + let evm_opts = EvmOpts::default(); + let mut first_filter = filter_args(); + let mut second_filter = filter_args(); + first_filter.path_pattern = Some(GlobMatcher::from_str("test/A.t.sol").unwrap()); + second_filter.path_pattern = Some(GlobMatcher::from_str("test/B.t.sol").unwrap()); + let artifacts = vec![artifact("build-a")]; + + let first_key = mutation_execution_cache_key_from_parts( + &config, + &evm_opts, + &first_filter, + artifacts.clone(), + ) + .unwrap(); + let second_key = + mutation_execution_cache_key_from_parts(&config, &evm_opts, &second_filter, artifacts) + .unwrap(); + + assert_ne!(first_key, second_key); + } + + #[test] + fn execution_cache_key_changes_when_rerun_failures_change() { + let config = Config::default(); + let evm_opts = EvmOpts::default(); + let filter_args = filter_args(); + let first_failures = vec![RerunFailure { + contract: "test/Counter.t.sol:WeakTest".to_string(), + test: "test_increment()".to_string(), + }]; + let second_failures = vec![RerunFailure { + contract: "test/Counter.t.sol:StrongTest".to_string(), + test: "test_increment()".to_string(), + }]; + let artifacts = vec![artifact("build-a")]; + + let first_key = mutation_execution_cache_key_from_parts_with_rerun_failures( + &config, + &evm_opts, + None, + &filter_args, + Some(&first_failures), + artifacts.clone(), + ) + .unwrap(); + let second_key = mutation_execution_cache_key_from_parts_with_rerun_failures( + &config, + &evm_opts, + None, + &filter_args, + Some(&second_failures), + artifacts, + ) + .unwrap(); + + assert_ne!(first_key, second_key); + } + + #[test] + fn mutation_compile_sources_only_include_selected_link_reference_closure() { + let sources = mutation_compile_sources( + [PathBuf::from("test/Selected.t.sol")], + [ + ( + PathBuf::from("test/Selected.t.sol"), + BTreeSet::from([PathBuf::from("test/SelectedLinkedHelper.sol")]), + ), + ( + PathBuf::from("test/SelectedLinkedHelper.sol"), + BTreeSet::from([PathBuf::from("test/TransitiveLinkedHelper.sol")]), + ), + ( + PathBuf::from("test/Unrelated.t.sol"), + BTreeSet::from([PathBuf::from("test/UnusedLinkedHelper.sol")]), + ), + ], + ); + + assert_eq!( + sources, + vec![ + PathBuf::from("test/Selected.t.sol"), + PathBuf::from("test/SelectedLinkedHelper.sol"), + PathBuf::from("test/TransitiveLinkedHelper.sol"), + ] + ); + } + + #[test] + fn resumed_adaptive_skips_are_reported_as_skipped_results() { + let mut handler = + MutationHandler::new(PathBuf::from("src/Counter.sol"), Arc::new(Config::default())); + handler.mark_span_survived(Span::new(BytePos(10), BytePos(20))); + + let exact_survivor = mutant(10, 20); + let skipped_child = mutant(12, 18); + let unrelated = mutant(30, 40); + let (mutants_to_test, skipped_results) = partition_adaptively_skipped_mutants( + &mut handler, + &[exact_survivor.clone(), skipped_child.clone(), unrelated.clone()], + ); + + assert_eq!(mutants_to_test.len(), 2); + assert_eq!(mutants_to_test[0].span, exact_survivor.span); + assert_eq!(mutants_to_test[1].span, unrelated.span); + assert_eq!(skipped_results.len(), 1); + assert!(matches!(skipped_results[0].1, MutationResult::Skipped)); + assert_eq!(skipped_results[0].0.span, skipped_child.span); + assert_eq!(handler.get_report().total_skipped(), 1); + assert_eq!(handler.get_report().total_mutants(), 1); + } +} diff --git a/crates/forge/src/mutation/progress.rs b/crates/forge/src/mutation/progress.rs new file mode 100644 index 0000000000000..51b39fc4a58a8 --- /dev/null +++ b/crates/forge/src/mutation/progress.rs @@ -0,0 +1,321 @@ +//! Progress display for mutation testing. + +use std::{ + collections::HashMap, + sync::{ + Arc, + atomic::{AtomicBool, AtomicUsize, Ordering}, + }, + time::{Duration, Instant}, +}; + +use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; +use parking_lot::Mutex; +use yansi::Paint; + +use crate::mutation::mutant::{Mutant, MutationResult}; + +/// Live tally of mutant outcomes, rendered into the overall progress bar. +#[derive(Debug, Default, Clone, Copy)] +struct LiveCounts { + killed: usize, + survived: usize, + invalid: usize, + skipped: usize, + timed_out: usize, +} + +impl LiveCounts { + const fn record(&mut self, result: &MutationResult) { + match result { + MutationResult::Dead => self.killed += 1, + MutationResult::Alive => self.survived += 1, + MutationResult::Invalid => self.invalid += 1, + MutationResult::Skipped => self.skipped += 1, + MutationResult::TimedOut => self.timed_out += 1, + } + } +} + +/// State stored per active mutant so we can show per-mutant elapsed time and +/// remove the correct row when a mutant completes (rather than FIFO which +/// breaks under parallel completion). +#[derive(Debug)] +struct ActiveMutant { + pb: ProgressBar, + started_at: Instant, +} + +/// State for mutation testing progress display. +#[derive(Debug)] +pub struct MutationProgressState { + multi: MultiProgress, + overall_progress: ProgressBar, + /// Active mutant progress bars keyed by a stable identifier (path + span + + /// mutation string) so completion correctly removes the right row. + active_mutants: HashMap, + /// Running per-result counts displayed on the overall bar. + counts: LiveCounts, + /// Optional per-mutant timeout (seconds), shown next to each active row. + timeout_secs: Option, + /// Number of parallel workers, used in the prefix. + num_workers: usize, +} + +impl MutationProgressState { + pub fn new(total_mutants: usize, num_workers: usize) -> Self { + Self::with_timeout(total_mutants, num_workers, None) + } + + pub fn with_timeout( + total_mutants: usize, + num_workers: usize, + timeout_secs: Option, + ) -> Self { + let multi = MultiProgress::new(); + + // Overall progress bar: includes elapsed wall-clock plus a running + // tally (killed / survived / invalid / timed-out / skipped). + let overall_progress = multi.add(ProgressBar::new(total_mutants as u64)); + overall_progress.set_style( + ProgressStyle::with_template( + "{bar:40.cyan/blue} {pos:>4}/{len:4} mutants ({prefix} jobs) [{elapsed_precise}] {wide_msg}", + ) + .unwrap() + .progress_chars("##-"), + ); + overall_progress.set_prefix(num_workers.to_string()); + overall_progress.enable_steady_tick(Duration::from_millis(100)); + + Self { + multi, + overall_progress, + active_mutants: HashMap::with_capacity(num_workers), + counts: LiveCounts::default(), + timeout_secs, + num_workers, + } + } + + /// Set the current file being tested. Renders as part of the overall + /// bar's message together with the running tally. + pub fn set_current_file(&mut self, file: &str) { + // Re-emit message so the file is reflected immediately. + let msg = self.format_message(file); + self.overall_progress.set_message(msg); + } + + fn format_message(&self, file: &str) -> String { + let counts = self.counts; + let timeout_suffix = match self.timeout_secs { + Some(t) => format!(" · timeout {t}s/mutant"), + None => String::new(), + }; + format!( + "k:{} s:{} i:{} t:{} sk:{}{} · {}", + counts.killed, + counts.survived, + counts.invalid, + counts.timed_out, + counts.skipped, + timeout_suffix, + file, + ) + } + + fn refresh_message(&self) { + // Preserve the file segment from the last-rendered message if any. + let current = self.overall_progress.message(); + let file = current.rsplit(" · ").next().unwrap_or(""); + self.overall_progress.set_message(self.format_message(file)); + } + + /// Stable identifier for a mutant — used as the key in `active_mutants`. + fn mutant_key(mutant: &Mutant) -> String { + format!( + "{}:{}-{}:{}", + mutant.path.display(), + mutant.span.lo().0, + mutant.span.hi().0, + mutant.mutation, + ) + } + + /// Add a mutant being tested + pub fn add_mutant_progress(&mut self, mutant: &Mutant) { + let pb = self.multi.add(ProgressBar::new_spinner()); + pb.set_style( + ProgressStyle::with_template(" {spinner} {wide_msg}").unwrap().tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈ "), + ); + pb.enable_steady_tick(Duration::from_millis(100)); + + let display = format!( + "line {}: `{}` → `{}`", + mutant.line_number, + truncate_str(&mutant.original, 40), + truncate_str(&mutant.mutation.to_string(), 40), + ); + pb.set_message(display); + + self.active_mutants + .insert(Self::mutant_key(mutant), ActiveMutant { pb, started_at: Instant::now() }); + } + + /// Complete a mutant and show result. Prints a one-line summary above the + /// bars (via `multi.suspend`) before clearing that mutant's spinner. + pub fn complete_mutant(&mut self, mutant: &Mutant, result: &MutationResult) { + self.counts.record(result); + self.overall_progress.inc(1); + + let elapsed = self + .active_mutants + .remove(&Self::mutant_key(mutant)) + .map(|am| { + let el = am.started_at.elapsed(); + am.pb.finish_and_clear(); + el + }) + .unwrap_or_default(); + + // Only emit per-result completion lines for things the user cares + // about (kills, survivors, timeouts). Invalid and skipped are noisy. + // Pad the raw label *before* applying color so ANSI escapes don't + // throw off alignment. + let raw_label = format!("{:9}", result.label()); + let label = match result { + MutationResult::Dead => Paint::green(&raw_label).bold().to_string(), + MutationResult::Alive => Paint::red(&raw_label).bold().to_string(), + MutationResult::TimedOut => Paint::yellow(&raw_label).bold().to_string(), + MutationResult::Invalid | MutationResult::Skipped => { + self.refresh_message(); + return; + } + }; + + let line = format!( + " {label} line {ln}: `{orig}` → `{mut_}` ({elapsed:.1?})", + ln = mutant.line_number, + orig = truncate_str(&mutant.original, 40), + mut_ = truncate_str(&mutant.mutation.to_string(), 40), + elapsed = elapsed, + ); + self.multi.suspend(|| { + let _ = foundry_common::sh_println!("{line}"); + }); + self.refresh_message(); + } + + /// Clear all progress bars + pub fn clear(&mut self) { + for (_, am) in self.active_mutants.drain() { + am.pb.finish_and_clear(); + } + self.overall_progress.finish_and_clear(); + let _ = self.multi.clear(); + } + + /// Finish with a message + pub fn finish(&mut self, message: &str) { + for (_, am) in self.active_mutants.drain() { + am.pb.finish_and_clear(); + } + self.overall_progress.finish_with_message(message.to_string()); + } + + /// Used for tests / introspection. + #[allow(dead_code)] + pub const fn num_workers(&self) -> usize { + self.num_workers + } +} + +/// Thread-safe wrapper for mutation progress +#[derive(Debug, Clone)] +pub struct MutationProgress { + pub inner: Arc>, + pub cancelled: Arc, + pub completed: Arc, + pub total: usize, +} + +impl MutationProgress { + pub fn new(total_mutants: usize, num_workers: usize) -> Self { + Self::with_timeout(total_mutants, num_workers, None) + } + + pub fn with_timeout( + total_mutants: usize, + num_workers: usize, + timeout_secs: Option, + ) -> Self { + Self { + inner: Arc::new(Mutex::new(MutationProgressState::with_timeout( + total_mutants, + num_workers, + timeout_secs, + ))), + cancelled: Arc::new(AtomicBool::new(false)), + completed: Arc::new(AtomicUsize::new(0)), + total: total_mutants, + } + } + + /// Check if testing was cancelled (Ctrl+C) + pub fn is_cancelled(&self) -> bool { + self.cancelled.load(Ordering::SeqCst) + } + + /// Signal cancellation + pub fn cancel(&self) { + self.cancelled.store(true, Ordering::SeqCst); + } + + /// Set the current file + pub fn set_current_file(&self, file: &str) { + self.inner.lock().set_current_file(file); + } + + /// Record a mutant starting + pub fn start_mutant(&self, mutant: &Mutant) { + self.inner.lock().add_mutant_progress(mutant); + } + + /// Record a mutant completing + pub fn complete_mutant(&self, mutant: &Mutant, result: &MutationResult) -> usize { + let completed = self.completed.fetch_add(1, Ordering::SeqCst) + 1; + self.inner.lock().complete_mutant(mutant, result); + completed + } + + /// Clear progress display + pub fn clear(&self) { + MutationProgressState::clear(&mut self.inner.lock()); + } + + /// Finish with message + pub fn finish(&self, message: &str) { + self.inner.lock().finish(message); + } +} + +/// Truncate a string to max length, centering around the middle (where the operator typically is) +fn truncate_str(s: &str, max_len: usize) -> String { + let s = s.trim(); + if s.len() <= max_len { + return s.to_string(); + } + + // Center the truncation around the middle of the string + let half = max_len.saturating_sub(3) / 2; // Leave room for "..." + let mid = s.len() / 2; + let start = mid.saturating_sub(half); + let end = (start + max_len.saturating_sub(3)).min(s.len()); + + if start == 0 { + format!("{}...", &s[..end]) + } else if end == s.len() { + format!("...{}", &s[start..]) + } else { + format!("...{}...", &s[start..end]) + } +} diff --git a/crates/forge/src/mutation/reporter.rs b/crates/forge/src/mutation/reporter.rs new file mode 100644 index 0000000000000..479bb4dee44a3 --- /dev/null +++ b/crates/forge/src/mutation/reporter.rs @@ -0,0 +1,207 @@ +use comfy_table::{Cell, Color, Row, Table, presets::UTF8_FULL}; +use std::time::Duration; +use yansi::Paint; + +use crate::mutation::{MutationsSummary, mutant::Mutant}; + +pub struct MutationReporter { + table: Table, +} + +impl Default for MutationReporter { + fn default() -> Self { + Self::new() + } +} + +impl MutationReporter { + pub fn new() -> Self { + let mut table = Table::new(); + table.load_style(UTF8_FULL.with_rounded_corners()); + + table.set_header(vec![ + Cell::new("Status"), + Cell::new("# Mutants"), + Cell::new("% of Total"), + ]); + + Self { table } + } + + pub fn report(&mut self, summary: &MutationsSummary, duration: Duration) { + let total = summary.total_mutants(); + if total == 0 { + let _ = sh_println!("No mutants were generated."); + return; + } + + // Summary table + self.add_row("Survived", summary.total_survived(), total, Color::Red); + self.add_row("Killed", summary.total_dead(), total, Color::Green); + self.add_row("Invalid", summary.total_invalid(), total, Color::DarkGrey); + self.add_row("Skipped", summary.total_skipped(), total, Color::Yellow); + if summary.total_timed_out() > 0 { + self.add_row("Timed out", summary.total_timed_out(), total, Color::Magenta); + } + + let _ = sh_println!("\n{}", "═".repeat(60)); + let _ = sh_println!("{}", Paint::bold("MUTATION TESTING RESULTS")); + let _ = sh_println!("{}", "═".repeat(60)); + + let _ = sh_println!("\n{}\n", self.table); + + // Legend: short, factual definitions of each status. + let _ = sh_println!("{}", Paint::dim("Legend:")); + let _ = sh_println!(" {} - tests did not catch the mutation", Paint::red("Survived")); + let _ = sh_println!(" {} - tests caught the mutation", Paint::green("Killed")); + let _ = sh_println!(" {} - mutation produced a compilation error", Paint::dim("Invalid")); + let _ = sh_println!( + " {} - redundant mutation on the same expression", + Paint::yellow("Skipped") + ); + let _ = sh_println!( + " {} - compile/test exceeded the configured timeout\n", + Paint::magenta("Timed out") + ); + + // Format duration similar to test output + let duration_str = if duration.as_secs() >= 60 { + format!("{}m {:.2}s", duration.as_secs() / 60, duration.as_secs_f64() % 60.0) + } else { + format!("{:.2}s", duration.as_secs_f64()) + }; + + if summary.has_reliable_score() { + // Mutation score with color + let score = summary.mutation_score(); + let score_display = format!("{score:.1}%"); + let score_colored = if score >= 80.0 { + Paint::green(&score_display).bold() + } else if score >= 60.0 { + Paint::yellow(&score_display).bold() + } else { + Paint::red(&score_display).bold() + }; + + let _ = sh_println!( + "Mutation Score: {} ({}/{} mutants killed); finished in {}", + score_colored, + summary.total_dead(), + summary.total_evaluated(), + duration_str + ); + } else { + let _ = sh_println!( + "Mutation Score: unavailable ({} timed out, {} evaluated); finished in {}", + summary.total_timed_out(), + summary.total_evaluated(), + duration_str + ); + let _ = sh_println!( + "{}", + Paint::yellow( + "Timed-out mutants dominate this run. Increase --mutation-timeout or use a \ + faster mutation profile, for example lower --mutation-optimizer-runs or set \ + --mutation-via-ir false." + ) + .bold() + ); + } + + // Survived mutants section - the most important for developers. + if !summary.get_survived().is_empty() { + let _ = sh_println!("\n{}", "─".repeat(60)); + let _ = sh_println!("{}", Paint::red("Survived mutants").bold()); + let _ = sh_println!("{}", "─".repeat(60)); + + // Sort by (file, line, column, span, mutation text) so the + // reported order is deterministic across runs / worker counts. + // Workers complete in arbitrary order, so without this every run + // can permute the report. + let mut survived: Vec<&Mutant> = summary.get_survived().iter().collect(); + survived.sort_by(|a, b| { + ( + a.relative_path(), + a.line_number, + a.column_number, + a.span.lo().0, + a.span.hi().0, + a.mutation.to_string(), + ) + .cmp(&( + b.relative_path(), + b.line_number, + b.column_number, + b.span.lo().0, + b.span.hi().0, + b.mutation.to_string(), + )) + }); + for (i, mutant) in survived.iter().enumerate() { + self.print_survived_mutant(i + 1, mutant); + } + } + + // Killed mutants (collapsed: just count). + if !summary.get_dead().is_empty() { + let _ = sh_println!("\n{}", "─".repeat(60)); + let _ = sh_println!("{} mutants {}", summary.total_dead(), Paint::green("killed")); + } + + // Invalid mutants (collapsed: just count). + if !summary.get_invalid().is_empty() { + let _ = sh_println!("\n{}", "─".repeat(60)); + let _ = sh_println!("{} mutants {}", summary.total_invalid(), Paint::dim("invalid")); + } + + // Timed-out mutants (collapsed: just count). + if !summary.get_timed_out().is_empty() { + let _ = sh_println!("\n{}", "─".repeat(60)); + let _ = sh_println!( + "{} mutants {}", + summary.total_timed_out(), + Paint::magenta("timed out") + ); + } + + let _ = sh_println!("\n{}", "═".repeat(60)); + } + + fn add_row(&mut self, status: &str, count: usize, total: usize, color: Color) { + let pct = if total > 0 { count as f64 / total as f64 * 100.0 } else { 0.0 }; + + let mut row = Row::new(); + row.add_cell(Cell::new(status).fg(color)) + .add_cell(Cell::new(count.to_string())) + .add_cell(Cell::new(format!("{pct:.1}%"))); + self.table.add_row(row); + } + + fn print_survived_mutant(&self, index: usize, mutant: &Mutant) { + // Show file:line + let location = if mutant.line_number > 0 { + format!("{}:{}", mutant.relative_path(), mutant.line_number) + } else { + mutant.relative_path() + }; + + let _ = sh_println!("\n {}. {}", Paint::red(&index).bold(), Paint::bold(&location)); + + // Show the source line context if available + if !mutant.source_line.is_empty() { + let _ = sh_println!(" {}", Paint::dim(&mutant.source_line)); + } + + // Show the diff + let _ = sh_println!(" {}", Paint::dim("Mutation:")); + let original = if mutant.original.is_empty() { + "".to_string() + } else { + mutant.original.clone() + }; + let mutated = mutant.mutation.to_string(); + + let _ = sh_println!(" {} {}", Paint::red("-"), Paint::red(original.trim())); + let _ = sh_println!(" {} {}", Paint::green("+"), Paint::green(&mutated)); + } +} diff --git a/crates/forge/src/mutation/runner.rs b/crates/forge/src/mutation/runner.rs new file mode 100644 index 0000000000000..2dfb9e2700bb6 --- /dev/null +++ b/crates/forge/src/mutation/runner.rs @@ -0,0 +1,842 @@ +//! Parallel mutation testing runner. +//! +//! This module provides high-performance parallel execution of mutation tests. +//! Each mutant is tested in an isolated temporary workspace to enable concurrent execution. + +use crate::{ + MultiContractRunnerBuilder, + cmd::test::{FilterArgs, RerunFailure}, + mutation::{ + mutant::{Mutant, MutationResult}, + progress::MutationProgress, + }, + result::SuiteResult, + workspace, +}; +use eyre::Result; +use foundry_common::{compile::ProjectCompiler, fs::canonicalize_path, sh_eprintln, sh_println}; +use foundry_compilers::compilers::multi::MultiCompiler; +use foundry_config::{Config, InlineConfig}; +use foundry_evm::{ + backend::Backend, + core::evm::{EthEvmNetwork, FoundryEvmNetwork}, + executors::ExecutorBuilder, + opts::EvmOpts, +}; +use std::{ + collections::BTreeMap, + fs, + panic::{self, AssertUnwindSafe}, + path::{Path, PathBuf}, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, AtomicUsize, Ordering}, + mpsc, + }, + thread::JoinHandle, + time::{Duration, Instant}, +}; +use tempfile::TempDir; + +const MUTATION_STACK_SIZE: usize = 16 * 1024 * 1024; + +#[cfg(test)] +const MUTATION_STACK_PROBE_ENV: &str = "FOUNDRY_MUTATION_STACK_PROBE"; +#[cfg(test)] +const MUTATION_STACK_PROBE_MARKER_ENV: &str = "FOUNDRY_MUTATION_STACK_PROBE_MARKER"; +#[cfg(test)] +static MUTATION_STACK_PROBE_RAN: AtomicBool = AtomicBool::new(false); + +/// Result of testing a single mutant. +#[derive(Debug, Clone)] +pub struct MutantTestResult { + pub mutant: Mutant, + pub result: MutationResult, +} + +/// Result of a parallel mutation batch. +#[derive(Debug, Clone)] +pub struct MutationBatchResult { + pub results: Vec, + pub cancelled: bool, +} + +/// Immutable EVM inputs shared by the baseline and every mutation worker. +#[derive(Clone)] +pub struct MutationEvmConfig { + pub opts: EvmOpts, + pub backend: Backend, + pub executor_builder: ExecutorBuilder, + pub create2_deployer_available: bool, +} + +/// Tracks progress, cancellation, and timed-out workers across parallel workers. +pub struct SharedMutationState { + /// Progress counter. + pub completed: AtomicUsize, + pub total: AtomicUsize, + /// Cancellation flag (Ctrl+C) + pub cancelled: Arc, + /// Optional progress display + pub progress: Option, + /// Whether to suppress all output (for JSON mode) + pub silent: bool, + /// Worker threads spawned for timed-out mutants. We keep these handles + /// alive (and the `TempDir` they own) so that: + /// 1. The `TempDir` is *not* dropped while the worker is still touching it. + /// 2. We can join the threads at the end of the run and surface leaks. + pub pending_workers: Mutex>>, + /// Maximum number of timed-out worker handles to keep pending at once. + /// Older handles are joined before parking more, bounding cleanup backlog. + max_pending_workers: AtomicUsize, +} + +impl SharedMutationState { + pub const fn new( + cancelled: Arc, + silent: bool, + progress: Option, + ) -> Self { + Self { + completed: AtomicUsize::new(0), + total: AtomicUsize::new(0), + cancelled, + progress, + silent, + pending_workers: Mutex::new(Vec::new()), + max_pending_workers: AtomicUsize::new(usize::MAX), + } + } + + pub fn is_cancelled(&self) -> bool { + self.cancelled.load(Ordering::SeqCst) + } + + pub fn cancel(&self) { + self.cancelled.store(true, Ordering::SeqCst); + if let Some(ref progress) = self.progress { + progress.cancel(); + } + } + + pub fn increment_completed(&self) -> usize { + self.completed.fetch_add(1, Ordering::SeqCst) + 1 + } + + pub fn set_max_pending_workers(&self, max: usize) { + self.max_pending_workers.store(max.max(1), Ordering::SeqCst); + } + + fn park_timed_out_worker(&self, handle: JoinHandle<()>) { + let mut pending = match self.pending_workers.lock() { + Ok(pending) => pending, + Err(_) => { + let _ = handle.join(); + return; + } + }; + + let max_pending = self.max_pending_workers.load(Ordering::SeqCst).max(1); + while pending.len() >= max_pending { + let old_handle = pending.remove(0); + drop(pending); + let _ = old_handle.join(); + pending = match self.pending_workers.lock() { + Ok(pending) => pending, + Err(_) => { + let _ = handle.join(); + return; + } + }; + } + + pending.push(handle); + } +} + +impl Default for SharedMutationState { + fn default() -> Self { + Self::new(Arc::new(AtomicBool::new(false)), false, None) + } +} + +/// Run mutation tests in parallel with optional progress display. +#[allow(clippy::too_many_arguments)] +pub fn run_mutations_parallel_with_progress( + mutants: Vec, + source_path: PathBuf, + original_source: Arc, + config: Arc, + evm: MutationEvmConfig, + num_workers: usize, + progress: Option, + silent: bool, + filter_args: FilterArgs, + rerun_failures: Option>, + selected_sources_relative: Arc>, + isolate: bool, + cancellation_requested: Arc, +) -> Result { + let total = mutants.len(); + if total == 0 { + return Ok(MutationBatchResult { results: vec![], cancelled: false }); + } + + // Default to available parallelism if num_workers is 0 + let num_workers = if num_workers == 0 { + std::thread::available_parallelism().map(|p| p.get()).unwrap_or(1) + } else { + num_workers + }; + + let shared_state = Arc::new(SharedMutationState::new(cancellation_requested, silent, progress)); + shared_state.total.store(total, Ordering::SeqCst); + shared_state.set_max_pending_workers(num_workers); + + // Only print if no progress bar and not silent + if shared_state.progress.is_none() && !shared_state.silent { + let _ = sh_println!("Running {} mutants in parallel with {} workers", total, num_workers); + } + + // Get relative path of source within project - MUST be relative for safety + // Canonicalize paths to handle relative vs absolute path comparisons + let source_abs = + if source_path.is_absolute() { source_path } else { config.root.join(&source_path) }; + + let root_abs = config.root.canonicalize().unwrap_or_else(|_| config.root.clone()); + let source_abs = source_abs.canonicalize().unwrap_or(source_abs); + + let source_relative = source_abs + .strip_prefix(&root_abs) + .map_err(|_| { + eyre::eyre!( + "Source path {} is not under project root {}", + source_abs.display(), + root_abs.display() + ) + })? + .to_path_buf(); + + workspace::ensure_safe_relative_path(&source_relative, "source", &source_abs)?; + + // `ProjectPathsConfig` canonicalizes its root. Create mutant workspaces beneath the canonical + // temp root as well so explicit compiler inputs, project-local remappings, and the project + // root all use the same path spelling (notably `/private/var` rather than `/var` on macOS). + let temp_root = std::env::temp_dir(); + let temp_root = canonicalize_path(&temp_root).map_err(|err| { + eyre::eyre!("failed to canonicalize mutation temp root {}: {err}", temp_root.display()) + })?; + + // Configure rayon thread pool + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(num_workers) + .stack_size(MUTATION_STACK_SIZE) + .build() + .map_err(|e| eyre::eyre!("Failed to create thread pool: {}", e))?; + + // Use a thread-safe collection to store results as they complete + let completed_results: Arc>> = + Arc::new(Mutex::new(Vec::with_capacity(total))); + + let filter_args = Arc::new(filter_args); + let rerun_failures = Arc::new(rerun_failures); + + // Each worker pulls mutants from a shared queue. Do not use a parallel iterator here: a worker + // that waits on another pool (for example solar's parser pool in `Session::enter`) steals + // queued jobs from this pool. If those jobs are mutants, they nest on one stack, and each + // waiting mutant keeps its compiler session and thread pool alive until the run ends. + let queue = Mutex::new(mutants.into_iter()); + pool.broadcast(|_| { + loop { + if shared_state.is_cancelled() { + break; + } + let Some(mutant) = queue.lock().ok().and_then(|mut queue| queue.next()) else { + break; + }; + + // Wrap in catch_unwind to prevent one panic from aborting the entire run + let mutant_clone = mutant.clone(); + let result = panic::catch_unwind(AssertUnwindSafe(|| { + test_single_mutant_isolated( + mutant, + &source_relative, + &original_source, + &config, + &evm, + &shared_state, + &temp_root, + &filter_args, + &rerun_failures, + &selected_sources_relative, + isolate, + ) + })); + + let test_result = match result { + Ok(r) => r, + Err(_) => { + if shared_state.progress.is_none() { + let _ = sh_eprintln!("Panic while testing mutant: {}", mutant_clone); + } + MutantTestResult { mutant: mutant_clone, result: MutationResult::Invalid } + } + }; + + // Store result immediately + if let Ok(mut results) = completed_results.lock() { + results.push(test_result); + } + } + }); + + // Extract results + let results = Arc::try_unwrap(completed_results) + .map(|m| m.into_inner().unwrap_or_default()) + .unwrap_or_default(); + + // Drain and join any worker threads that were left running by a + // wall-clock `TimedOut`. Each worker owns its own `TempDir`, so joining + // here is what actually deletes the per-mutant workspace from disk. This + // is the difference between a clean shutdown and stale `forge_mutation_*` + // directories piling up under `$TMPDIR`. + // + // We intentionally block: by this point all rayon work is done, the + // wall-clock budget has already been spent, and the only thing left to do + // is reclaim cleanup. Each worker stops its test run at the same deadline + // (see `compile_and_test`), which bounds how long any individual worker can + // actually run. + let pending = shared_state + .pending_workers + .lock() + .map(|mut g| std::mem::take(&mut *g)) + .unwrap_or_default(); + let pending_count = pending.len(); + if pending_count > 0 && !shared_state.silent && shared_state.progress.is_none() { + let _ = sh_println!("Waiting for {pending_count} timed-out worker(s) to finish cleanup..."); + } + for handle in pending { + let _ = handle.join(); + } + + let cancelled = shared_state.is_cancelled(); + + // Clear progress and handle cancellation + if let Some(ref progress) = shared_state.progress { + progress.clear(); + } + if cancelled && !shared_state.silent { + let _ = sh_println!( + "\nMutation testing cancelled. Showing results for {} completed mutants.\n", + results.len() + ); + } + + Ok(MutationBatchResult { results, cancelled }) +} + +/// Test a single mutant in an isolated temporary workspace. +#[allow(clippy::too_many_arguments)] +fn test_single_mutant_isolated( + mutant: Mutant, + source_relative: &PathBuf, + original_source: &Arc, + config: &Arc, + evm: &MutationEvmConfig, + shared_state: &Arc, + temp_root: &Path, + filter_args: &Arc, + rerun_failures: &Arc>>, + selected_sources_relative: &Arc>, + isolate: bool, +) -> MutantTestResult { + // Show progress or log + if let Some(ref progress) = shared_state.progress { + progress.start_mutant(&mutant); + } else if !shared_state.silent { + let completed = shared_state.increment_completed(); + let total = shared_state.total.load(Ordering::SeqCst); + let _ = sh_println!("[{}/{}] Testing mutant: {}", completed, total, mutant); + } + + // Create isolated workspace using TempDir for automatic cleanup on drop + let temp_dir = match TempDir::with_prefix_in("forge_mutation_", temp_root) { + Ok(dir) => dir, + Err(e) => { + let _ = + sh_eprintln!("Failed to create temp directory in {}: {}", temp_root.display(), e); + return MutantTestResult { mutant, result: MutationResult::Invalid }; + } + }; + + // Copy project to temp directory + if let Err(e) = workspace::copy_project(config, temp_dir.path()) { + let _ = sh_eprintln!("Failed to copy project: {}", e); + return MutantTestResult { mutant, result: MutationResult::Invalid }; + } + + // Apply mutation - source_relative is guaranteed to be relative at this point + let mutated_source_path = temp_dir.path().join(source_relative); + if let Err(e) = apply_mutation(&mutant, original_source, &mutated_source_path) { + let _ = sh_eprintln!("Failed to apply mutation: {}", e); + return MutantTestResult { mutant, result: MutationResult::Invalid }; + } + + // Start from the already materialized baseline config instead of reloading + // `foundry.toml`, so CLI overrides and runtime normalization stay identical + // between the baseline run and every mutant run. + let temp_config = Arc::new(workspace::rebase_config_paths(config, temp_dir.path())); + + // Compile and test, optionally bounded by a wall-clock timeout. + // + // Lifetime contract: `temp_dir` (the `TempDir`) must live *at least* as + // long as the worker thread that reads from `temp_path`. Dropping the + // `TempDir` early would delete the workspace while a worker still touches + // it, which is a real correctness bug (random compile/test failures and + // dangling fs handles on Windows). + // + // To satisfy that contract we move `temp_dir` ownership into the worker + // thread. If the wall-clock budget fires the outer call returns + // `TimedOut`, but the `TempDir` only drops when the worker thread itself + // exits. The `JoinHandle` is stored in `shared_state.pending_workers` and + // joined at the end of the parallel run. + let timeout = config.mutation.timeout.map(|s| Duration::from_secs(s as u64)); + + let result = match timeout { + Some(budget) => run_compile_and_test_with_timeout( + temp_config, + evm, + budget, + temp_dir, + shared_state, + filter_args.clone(), + rerun_failures.clone(), + selected_sources_relative.clone(), + isolate, + ), + None => { + let res = match compile_and_test( + &temp_config, + evm, + filter_args, + rerun_failures.as_ref().as_deref(), + selected_sources_relative, + isolate, + None, + ) { + Ok(true) => MutationResult::Dead, + Ok(false) => MutationResult::Alive, + Err(_) => MutationResult::Invalid, + }; + drop(temp_dir); // explicit: workspace is only safe to remove now + res + } + }; + + // Update progress + if let Some(ref progress) = shared_state.progress { + progress.complete_mutant(&mutant, &result); + } + + MutantTestResult { mutant, result } +} + +/// Run `compile_and_test` on a worker thread and wait at most `budget` for it +/// to complete. Returns `TimedOut` on overrun and `Invalid` on infrastructure +/// errors / panics. +/// +/// The worker stops its test run at the same deadline, so a timed-out worker +/// does not keep running fuzz or invariant campaigns in the background. +/// +/// The worker takes ownership of `temp_dir` so the underlying workspace +/// directory is only dropped when the worker thread actually exits. On +/// timeout the `JoinHandle` is parked in `shared_state.pending_workers` +/// and joined at the end of the parallel run. +#[allow(clippy::too_many_arguments)] +fn run_compile_and_test_with_timeout( + config: Arc, + evm: &MutationEvmConfig, + budget: Duration, + temp_dir: TempDir, + shared_state: &Arc, + filter_args: Arc, + rerun_failures: Arc>>, + selected_sources_relative: Arc>, + isolate: bool, +) -> MutationResult { + let deadline = Instant::now() + budget; + let (tx, rx) = mpsc::channel::>(); + let evm = evm.clone(); + // Move `temp_dir` into the worker so its `Drop` only runs after the worker + // thread exits. Do NOT capture by reference — the worker may outlive this + // function on timeout. + let cfg = Arc::clone(&config); + let filter_for_worker = Arc::clone(&filter_args); + let rerun_for_worker = Arc::clone(&rerun_failures); + let selected_sources_for_worker = Arc::clone(&selected_sources_relative); + + let spawn_result = std::thread::Builder::new() + .stack_size(MUTATION_STACK_SIZE) + .name("mutation-worker".to_string()) + .spawn(move || { + // `test_collect` uses Rayon internally. Because this timeout worker + // is not itself a Rayon worker, nested parallel iterators would + // otherwise escape to the global pool and its default-sized stacks. + let res = panic::catch_unwind(AssertUnwindSafe(|| { + with_mutation_test_pool(|| { + compile_and_test( + &cfg, + &evm, + &filter_for_worker, + rerun_for_worker.as_ref().as_deref(), + &selected_sources_for_worker, + isolate, + Some(deadline), + ) + }) + })) + .unwrap_or_else(|_| Err(eyre::eyre!("worker panicked"))); + let _ = tx.send(res); + // Keep `temp_dir` alive until *after* the worker is done with the + // workspace. Dropping here (vs at function entry on timeout) + // guarantees no use-after-free of the filesystem. + drop(temp_dir); + }); + + let handle = match spawn_result { + Ok(h) => h, + Err(_) => return MutationResult::Invalid, + }; + + match rx.recv_timeout(deadline.saturating_duration_since(Instant::now())) { + // The worker stops its tests at the deadline, so a result that arrives + // after it can come from an incomplete test run. + Ok(_) if Instant::now() >= deadline => { + let _ = handle.join(); + MutationResult::TimedOut + } + Ok(Ok(true)) => { + // Worker finished and sent a result; join briefly so the TempDir + // is actually cleaned up before we return. + let _ = handle.join(); + MutationResult::Dead + } + Ok(Ok(false)) => { + let _ = handle.join(); + MutationResult::Alive + } + Ok(Err(_)) => { + let _ = handle.join(); + MutationResult::Invalid + } + Err(_) => { + // Timeout fired. The worker is still running and still owns the + // TempDir; park the handle so we can join (and reclaim cleanup) + // at the end of the parallel run instead of leaking it. + shared_state.park_timed_out_worker(handle); + MutationResult::TimedOut + } + } +} + +fn with_mutation_test_pool(op: impl FnOnce() -> Result + Send) -> Result { + rayon::ThreadPoolBuilder::new() + .num_threads(1) + .stack_size(MUTATION_STACK_SIZE) + .thread_name(|_| "mutation-test".to_string()) + .build() + .map_err(|err| eyre::eyre!("failed to create mutation test pool: {err}"))? + .install(|| { + #[cfg(test)] + if std::env::var_os(MUTATION_STACK_PROBE_ENV).is_some() { + std::hint::black_box(mutation_stack_probe(1024)); + MUTATION_STACK_PROBE_RAN.store(true, Ordering::Release); + } + op() + }) +} + +#[cfg(test)] +#[inline(never)] +fn mutation_stack_probe(depth: usize) -> usize { + let frame = [depth as u8; 8 * 1024]; + std::hint::black_box(&frame); + if depth == 0 { + frame[0] as usize + } else { + let result = mutation_stack_probe(depth - 1).wrapping_add(frame[depth % frame.len()] as _); + std::hint::black_box(&frame); + result + } +} + +/// Apply a mutation to a source file. +fn apply_mutation(mutant: &Mutant, original_source: &str, dest_path: &Path) -> Result<()> { + let span = mutant.span; + let replacement = mutant.mutation.to_string(); + let start_pos = span.lo().0 as usize; + let end_pos = span.hi().0 as usize; + + // Use checked slicing to avoid panics on invalid spans or non-UTF8 boundaries + let before = original_source.get(..start_pos).ok_or_else(|| { + eyre::eyre!( + "Invalid mutation span: start {} is out of bounds for source length {}", + start_pos, + original_source.len() + ) + })?; + + let after = original_source.get(end_pos..).ok_or_else(|| { + eyre::eyre!( + "Invalid mutation span: end {} is out of bounds for source length {}", + end_pos, + original_source.len() + ) + })?; + + let mut new_content = String::with_capacity(before.len() + replacement.len() + after.len()); + new_content.push_str(before); + new_content.push_str(&replacement); + new_content.push_str(after); + + // Ensure parent directory exists + if let Some(parent) = dest_path.parent() { + fs::create_dir_all(parent)?; + } + + fs::write(dest_path, new_content)?; + Ok(()) +} + +/// Compiles and tests a mutant using the campaign's already selected EVM and remote backend. +/// +/// If `deadline` is set, the test run stops at the deadline like an interrupted `forge test`. +/// This keeps fuzz and invariant run limits unchanged, because their `timeout` settings would +/// turn a run-limited campaign into a time-based one. +fn compile_and_test( + config: &Arc, + evm: &MutationEvmConfig, + filter_args: &FilterArgs, + rerun_failures: Option<&[RerunFailure]>, + selected_sources_relative: &[PathBuf], + isolate: bool, + deadline: Option, +) -> Result { + let evm_opts = &evm.opts; + // Compile + let files = selected_sources_relative + .iter() + .map(|path| config.root.join(path)) + .filter(|path| path.exists()) + .collect::>(); + let compiler = ProjectCompiler::new() + .dynamic_test_linking(config.dynamic_test_linking) + .quiet(true) + .files(files); + + let compile_output = compiler.compile(&config.project()?)?; + let inline_config = Arc::new(InlineConfig::new_parsed(&compile_output, config)?); + + // Rebuild the per-mutant test filter so `--match-test`, `--match-contract`, + // `--match-path`, ... are honored against the temp workspace's paths + // (not the original project root). Without this the mutant runs would + // ignore user filters and execute a different test set than the baseline. + let mut filter = filter_args.clone().merge_with_config(config); + if let Some(rerun_failures) = rerun_failures { + filter.set_rerun_failures(rerun_failures.to_vec()); + } + + // Run tests - need a multi-threaded Tokio runtime since test() uses rayon internally + // with par_iter, and rayon workers need tokio handle access + let rt = tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) // Minimize overhead, tests use rayon for parallelism + .enable_all() + .build() + .map_err(|e| eyre::eyre!("Failed to create tokio runtime: {}", e))?; + + // Use block_on to run within the runtime context + let results: BTreeMap = rt.block_on(async { + let (evm_env, tx_env) = evm.backend.env(evm_opts).await?; + + // Build test runner mirroring the canonical `forge test` runner: same + // isolation flag, same fail-fast semantics for mutation, and same + // filter so kept/skipped tests stay consistent across baseline and + // mutant runs. + let mut runner = MultiContractRunnerBuilder::new(config.clone(), inline_config) + .set_debug(false) + .initial_balance(evm_opts.initial_balance) + .sender(evm_opts.sender) + .enable_isolation(isolate) + .fail_fast(true) + .with_create2_deployer_available(evm.create2_deployer_available) + .build::( + &compile_output, + evm_env, + tx_env, + evm_opts.clone(), + evm.backend.clone_with_fork_scope()?, + evm.executor_builder.clone(), + )?; + + if let Some(deadline) = deadline { + let early_exit = runner.tcfg.early_exit.clone(); + tokio::spawn(async move { + tokio::time::sleep_until(deadline.into()).await; + early_exit.record_ctrl_c(); + }); + } + + runner.test_collect(&filter) + })?; + + // Check if any test failed (mutant killed) + let killed = results.values().any(|suite| suite.failed() > 0); + + Ok(killed) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::U256; + use std::process::Command; + + #[test] + fn timeout_path_uses_enlarged_test_worker_stack() { + if std::env::var_os(MUTATION_STACK_PROBE_ENV).is_some() { + let workspace = TempDir::new().unwrap(); + let source = PathBuf::from("src/StackProbe.t.sol"); + fs::create_dir_all(workspace.path().join("src")).unwrap(); + fs::write( + workspace.path().join(&source), + "contract StackProbeTest { function test_stackProbe() public {} }", + ) + .unwrap(); + let mut config = Config::with_root(workspace.path()); + config.out = workspace.path().join("out"); + config.cache_path = workspace.path().join("cache/solidity-files-cache.json"); + let config = Arc::new(config); + let evm = MutationEvmConfig { + opts: EvmOpts::default(), + backend: Backend::::spawn(None).unwrap(), + executor_builder: ExecutorBuilder::::new(), + create2_deployer_available: false, + }; + let shared_state = Arc::new(SharedMutationState::default()); + + let result = run_compile_and_test_with_timeout( + config, + &evm, + Duration::from_secs(60), + workspace, + &shared_state, + Arc::new(FilterArgs::default()), + Arc::new(None), + Arc::new(vec![source]), + false, + ); + + assert!(!matches!(result, MutationResult::TimedOut), "unexpected result: {result:?}"); + assert!(MUTATION_STACK_PROBE_RAN.load(Ordering::Acquire)); + fs::write(std::env::var_os(MUTATION_STACK_PROBE_MARKER_ENV).unwrap(), b"ok").unwrap(); + return; + } + + let marker_dir = TempDir::new().unwrap(); + let marker = marker_dir.path().join("completed"); + let output = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "mutation::runner::tests::timeout_path_uses_enlarged_test_worker_stack", + ]) + .env(MUTATION_STACK_PROBE_ENV, "1") + .env(MUTATION_STACK_PROBE_MARKER_ENV, &marker) + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!( + output.status.success(), + "stack-pressure child failed:\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert!(marker.exists(), "stack-pressure child did not complete the production path"); + } + + #[test] + fn park_timed_out_worker_bounds_pending_handles() { + let state = SharedMutationState::default(); + state.set_max_pending_workers(1); + + state.park_timed_out_worker(std::thread::spawn(|| {})); + assert_eq!(state.pending_workers.lock().unwrap().len(), 1); + + state.park_timed_out_worker(std::thread::spawn(|| {})); + assert_eq!(state.pending_workers.lock().unwrap().len(), 1); + + let pending = std::mem::take(&mut *state.pending_workers.lock().unwrap()); + for handle in pending { + handle.join().unwrap(); + } + } + + #[test] + fn temp_config_preserves_materialized_overrides_and_rebases_paths() { + let project = TempDir::new().unwrap(); + let temp = TempDir::new().unwrap(); + let root = project.path(); + + let mut config = Config { + root: root.to_path_buf(), + src: root.join("contracts"), + test: root.join("checks"), + script: root.join("deploy"), + out: root.join("custom-out"), + cache_path: root.join("custom-cache"), + snapshots: root.join("custom-snapshots"), + broadcast: root.join("custom-broadcast"), + mutation_dir: root.join("custom-cache/mutation"), + libs: vec![root.join("vendor")], + include_paths: vec![root.join("shared")], + allow_paths: vec![root.join("fixtures")], + dynamic_test_linking: true, + cache: true, + ..Default::default() + }; + config.fuzz.seed = Some(U256::from(42)); + config.fuzz.timeout = Some(90); + config.invariant.timeout = Some(80); + config.fuzz.failure_persist_dir = Some(root.join("custom-cache/fuzz")); + config.invariant.failure_persist_dir = Some(root.join("custom-cache/invariant")); + config.mutation.timeout = Some(5); + + let temp_config = workspace::rebase_config_paths(&config, temp.path()); + let expected_root = canonicalize_path(temp.path()).unwrap(); + + assert_eq!(temp_config.root, expected_root); + assert_eq!(temp_config.src, expected_root.join("contracts")); + assert_eq!(temp_config.test, expected_root.join("checks")); + assert_eq!(temp_config.script, expected_root.join("deploy")); + assert_eq!(temp_config.out, expected_root.join("custom-out")); + assert_eq!(temp_config.cache_path, expected_root.join("custom-cache")); + assert_eq!(temp_config.snapshots, expected_root.join("custom-snapshots")); + assert_eq!(temp_config.broadcast, expected_root.join("custom-broadcast")); + assert_eq!(temp_config.mutation_dir, expected_root.join("custom-cache/mutation")); + assert_eq!(temp_config.libs, vec![expected_root.join("vendor")]); + assert_eq!(temp_config.include_paths, vec![expected_root.join("shared")]); + assert_eq!(temp_config.allow_paths, vec![expected_root.join("fixtures")]); + assert_eq!( + temp_config.fuzz.failure_persist_dir, + Some(expected_root.join("custom-cache/fuzz")) + ); + assert_eq!( + temp_config.invariant.failure_persist_dir, + Some(expected_root.join("custom-cache/invariant")) + ); + assert!(temp_config.dynamic_test_linking); + assert!(temp_config.cache); + assert_eq!(temp_config.fuzz.seed, Some(U256::from(42))); + assert_eq!(temp_config.fuzz.timeout, Some(90)); + assert_eq!(temp_config.invariant.timeout, Some(80)); + } +} diff --git a/crates/forge/src/mutation/type_analysis.rs b/crates/forge/src/mutation/type_analysis.rs new file mode 100644 index 0000000000000..af26c86cd202f --- /dev/null +++ b/crates/forge/src/mutation/type_analysis.rs @@ -0,0 +1,911 @@ +//! Type-aware filtering for operator mutations. +//! +//! This module records replacements that are type-invalid. It deliberately does not filter +//! replacements merely because they return the same value for every input: different operators can +//! still compile to gas-distinct bytecode. + +use std::{ + collections::{HashMap, HashSet}, + ops::ControlFlow, + path::{Path, PathBuf}, +}; + +use eyre::Result; +use foundry_cli::opts::configure_pcx_from_compile_output; +use foundry_compilers::{ProjectCompileOutput, compilers::multi::MultiCompiler}; +use foundry_config::Config; +use solar::{ + ast::{BinOpKind, LitKind, UnOpKind}, + interface::{Session, source_map::FileName}, + sema::{ + Compiler, CompilerRef, Gcx, + hir::{self, ElementaryType, ExprKind, Visit}, + ty::TyKind, + }, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +enum ReplacementOperator { + Assignment(AssignmentReplacement), + Binary(BinOpKind), + Unary(UnOpKind), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum AssignmentReplacement { + Zero, + Negate, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct MutationExclusion { + lo: u32, + hi: u32, + new_op: ReplacementOperator, +} + +impl MutationExclusion { + pub fn assignment(span: solar::ast::Span, replacement: AssignmentReplacement) -> Self { + Self { + lo: span.lo().0, + hi: span.hi().0, + new_op: ReplacementOperator::Assignment(replacement), + } + } + + pub fn binary(span: solar::ast::Span, new_op: BinOpKind) -> Self { + Self { lo: span.lo().0, hi: span.hi().0, new_op: ReplacementOperator::Binary(new_op) } + } + + pub fn unary(span: solar::ast::Span, new_op: UnOpKind) -> Self { + Self { lo: span.lo().0, hi: span.hi().0, new_op: ReplacementOperator::Unary(new_op) } + } +} + +pub type MutationExclusionSet = HashSet; +pub type MutationExclusionsByPath = HashMap; + +pub fn collect_mutation_exclusions( + config: &Config, + output: &ProjectCompileOutput, +) -> Result { + let mut compiler = Compiler::new(Session::builder().with_silent_emitter(None).build()); + compiler.enter_mut(|compiler| { + let mut pcx = compiler.parse(); + configure_pcx_from_compile_output(&mut pcx, config, output, None)?; + pcx.parse(); + + let Ok(ControlFlow::Continue(())) = compiler.lower_asts() else { + return Ok(MutationExclusionsByPath::new()); + }; + Ok(analyze_and_collect(compiler)) + }) +} + +fn analyze_and_collect(compiler: &CompilerRef<'_>) -> MutationExclusionsByPath { + let Ok(ControlFlow::Continue(())) = compiler.analysis() else { + return MutationExclusionsByPath::new(); + }; + if compiler.dcx().has_errors().is_err() { + return MutationExclusionsByPath::new(); + } + collect_from_gcx(compiler.gcx()) +} + +fn collect_from_gcx<'gcx>(gcx: Gcx<'gcx>) -> MutationExclusionsByPath { + let mut by_path = MutationExclusionsByPath::new(); + for source_id in gcx.hir.source_ids() { + let source = gcx.hir.source(source_id); + let FileName::Real(path) = &source.file.name else { continue }; + let mut collector = MutationExclusionCollector { + gcx, + // HIR uses offsets in the compiler-wide source map, while the mutation visitor parses + // each target independently and uses offsets relative to that file. + source_start: source.file.start_pos.0, + mutations: MutationExclusionSet::new(), + }; + let _ = collector.visit_nested_source(source_id); + if !collector.mutations.is_empty() { + by_path.insert(normalize_path(path), collector.mutations); + } + } + by_path +} + +pub fn normalize_path(path: &Path) -> PathBuf { + path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) +} + +struct MutationExclusionCollector<'hir> { + gcx: Gcx<'hir>, + source_start: u32, + mutations: MutationExclusionSet, +} + +impl<'hir> Visit<'hir> for MutationExclusionCollector<'hir> { + type BreakValue = (); + + fn hir(&self) -> &'hir hir::Hir<'hir> { + &self.gcx.hir + } + + fn visit_expr(&mut self, expr: &'hir hir::Expr<'hir>) -> ControlFlow { + if let ExprKind::Assign(destination, op, value) = &expr.kind + && let Some(ty) = self.gcx.type_of_expr(destination.id) + { + if let Some(op) = op { + self.collect_compound_assignment(value, ty, op.kind); + } else { + self.collect_assignment(value, ty); + } + } + if let ExprKind::Binary(left, op, right) = &expr.kind + && (op.kind.is_cmp() || matches!(op.kind, BinOpKind::And | BinOpKind::Or)) + { + self.collect_comparison(expr, left, op.kind, right); + } + if let ExprKind::Unary(_, operand) = &expr.kind + && let Some(kind) = unary_operand_kind(self.gcx, operand) + && let Some(span) = self.local_span(expr.span) + { + match kind { + UnaryOperandKind::SignedInteger => {} + UnaryOperandKind::UnsignedInteger => { + self.mutations.insert(MutationExclusion::unary(span, UnOpKind::Neg)); + } + UnaryOperandKind::FixedBytes => { + for op in [ + UnOpKind::PreInc, + UnOpKind::PreDec, + UnOpKind::PostInc, + UnOpKind::PostDec, + UnOpKind::Neg, + ] { + self.mutations.insert(MutationExclusion::unary(span, op)); + } + } + } + } + if let ExprKind::Unary(_, operand) = &expr.kind + && is_non_storage_push_call(self.gcx, operand) + && let Some(span) = self.local_span(expr.span) + { + for op in [UnOpKind::PreInc, UnOpKind::PreDec, UnOpKind::PostInc, UnOpKind::PostDec] { + self.mutations.insert(MutationExclusion::unary(span, op)); + } + } + self.walk_expr(expr) + } + + fn visit_var(&mut self, var: &'hir hir::Variable<'hir>) -> ControlFlow { + if let Some(value) = var.initializer { + self.collect_assignment(value, self.gcx.type_of_hir_ty(&var.ty)); + } + self.walk_var(var) + } +} + +impl MutationExclusionCollector<'_> { + fn collect_assignment(&mut self, value: &hir::Expr<'_>, destination: solar::sema::ty::Ty<'_>) { + let Some(span) = self.local_span(value.span) else { return }; + let Some(kind) = assignment_destination_kind(destination) else { return }; + + if !kind.accepts_zero() { + self.mutations.insert(MutationExclusion::assignment(span, AssignmentReplacement::Zero)); + } + if !kind.accepts_negation() { + self.mutations + .insert(MutationExclusion::assignment(span, AssignmentReplacement::Negate)); + } + } + + fn collect_compound_assignment( + &mut self, + value: &hir::Expr<'_>, + destination: solar::sema::ty::Ty<'_>, + op: BinOpKind, + ) { + let Some(span) = self.local_span(value.span) else { return }; + let Some(destination) = assignment_destination_kind(destination) else { return }; + let Some(value_accepts_negation) = assignment_value_accepts_negation(self.gcx, value) + else { + return; + }; + + if op.is_shift() || !destination.accepts_negation() || !value_accepts_negation { + self.mutations + .insert(MutationExclusion::assignment(span, AssignmentReplacement::Negate)); + } + } + + fn collect_comparison( + &mut self, + expr: &hir::Expr<'_>, + left: &hir::Expr<'_>, + original: BinOpKind, + right: &hir::Expr<'_>, + ) { + let Some(span) = self.local_span(expr.span) else { return }; + + for candidate in [ + BinOpKind::Lt, + BinOpKind::Le, + BinOpKind::Gt, + BinOpKind::Ge, + BinOpKind::Eq, + BinOpKind::Ne, + BinOpKind::Or, + BinOpKind::And, + ] { + if candidate != original + && is_type_invalid_replacement(self.gcx, left, original, right, candidate) + { + self.mutations.insert(MutationExclusion::binary(span, candidate)); + } + } + } + + fn local_span(&self, span: solar::ast::Span) -> Option { + let lo = span.lo().0.checked_sub(self.source_start)?; + let hi = span.hi().0.checked_sub(self.source_start)?; + Some(solar::ast::Span::new(solar::interface::BytePos(lo), solar::interface::BytePos(hi))) + } +} + +#[derive(Clone, Copy)] +enum AssignmentDestinationKind { + SignedNumber, + UnsignedNumber, + FixedBytes, + Other, +} + +impl AssignmentDestinationKind { + const fn accepts_zero(self) -> bool { + !matches!(self, Self::Other) + } + + const fn accepts_negation(self) -> bool { + matches!(self, Self::SignedNumber) + } +} + +fn assignment_destination_kind(ty: solar::sema::ty::Ty<'_>) -> Option { + match ty.peel_refs().kind { + TyKind::Elementary(ElementaryType::Int(_) | ElementaryType::Fixed(..)) => { + Some(AssignmentDestinationKind::SignedNumber) + } + TyKind::Elementary(ElementaryType::UInt(_) | ElementaryType::UFixed(..)) => { + Some(AssignmentDestinationKind::UnsignedNumber) + } + TyKind::Elementary(ElementaryType::FixedBytes(_)) => { + Some(AssignmentDestinationKind::FixedBytes) + } + TyKind::Udvt(..) | TyKind::Err(_) => None, + _ => Some(AssignmentDestinationKind::Other), + } +} + +fn assignment_value_accepts_negation(gcx: Gcx<'_>, value: &hir::Expr<'_>) -> Option { + if matches!(&value.peel_parens().kind, ExprKind::Lit(lit) if matches!(lit.kind, LitKind::Number(_))) + { + return Some(true); + } + + let ty = gcx.type_of_expr(value.peel_parens().id)?; + match ty.peel_refs().kind { + TyKind::Elementary(ElementaryType::Int(_) | ElementaryType::Fixed(..)) => Some(true), + TyKind::Elementary( + ElementaryType::UInt(_) | ElementaryType::UFixed(..) | ElementaryType::FixedBytes(_), + ) => Some(false), + TyKind::Udvt(..) | TyKind::Err(_) => None, + _ => Some(false), + } +} + +fn is_type_invalid_replacement( + gcx: Gcx<'_>, + left: &hir::Expr<'_>, + original: BinOpKind, + right: &hir::Expr<'_>, + candidate: BinOpKind, +) -> bool { + match candidate { + BinOpKind::And | BinOpKind::Or => matches!( + comparison_operand_kind(gcx, left, right), + Some(ComparisonOperandKind::Function | ComparisonOperandKind::Other) + ), + BinOpKind::Lt | BinOpKind::Le | BinOpKind::Gt | BinOpKind::Ge => { + matches!(original, BinOpKind::And | BinOpKind::Or) + || matches!( + comparison_operand_kind(gcx, left, right), + Some(ComparisonOperandKind::Bool | ComparisonOperandKind::Function) + ) + } + _ => false, + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ComparisonOperandKind { + Bool, + Function, + Other, +} + +fn comparison_operand_kind( + gcx: Gcx<'_>, + left: &hir::Expr<'_>, + right: &hir::Expr<'_>, +) -> Option { + let left = gcx.type_of_expr(left.id)?; + let right = gcx.type_of_expr(right.id)?; + match (left.peel_refs().kind, right.peel_refs().kind) { + (TyKind::Elementary(ElementaryType::Bool), TyKind::Elementary(ElementaryType::Bool)) => { + Some(ComparisonOperandKind::Bool) + } + (TyKind::Fn(_), TyKind::Fn(_)) => Some(ComparisonOperandKind::Function), + (TyKind::Err(_), _) | (_, TyKind::Err(_)) => None, + _ => Some(ComparisonOperandKind::Other), + } +} + +#[derive(Clone, Copy)] +enum UnaryOperandKind { + SignedInteger, + UnsignedInteger, + FixedBytes, +} + +fn unary_operand_kind(gcx: Gcx<'_>, expr: &hir::Expr<'_>) -> Option { + let ty = gcx.type_of_expr(expr.peel_parens().id)?; + match ty.peel_refs().kind { + TyKind::Elementary(ElementaryType::Int(_)) => Some(UnaryOperandKind::SignedInteger), + TyKind::Elementary(ElementaryType::UInt(_)) => Some(UnaryOperandKind::UnsignedInteger), + TyKind::Elementary(ElementaryType::FixedBytes(_)) => Some(UnaryOperandKind::FixedBytes), + _ => None, + } +} + +fn is_non_storage_push_call(gcx: Gcx<'_>, expr: &hir::Expr<'_>) -> bool { + let ExprKind::Call(callee, args) = &expr.peel_parens().kind else { return false }; + let ExprKind::Member(receiver, member) = &callee.peel_parens().kind else { return false }; + if member.as_str() != "push" || !args.is_empty() { + return false; + } + + !gcx.type_of_expr(receiver.id).is_some_and(|ty| { + matches!( + ty.kind, + TyKind::Ref(inner, location) + if location.is_storage() + && matches!( + inner.kind, + TyKind::DynArray(_) | TyKind::Elementary(ElementaryType::Bytes) + ) + ) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use solar::interface::BytePos; + + fn collect(source: &str) -> MutationExclusionSet { + let path = PathBuf::from("Test.sol"); + let mut compiler = Compiler::new(Session::builder().with_silent_emitter(None).build()); + compiler.enter_mut(|compiler| { + let mut pcx = compiler.parse(); + let file = + pcx.sess.source_map().new_source_file(path.clone(), source).expect("source file"); + pcx.add_file(file); + pcx.parse(); + assert!(matches!(compiler.lower_asts(), Ok(ControlFlow::Continue(())))); + analyze_and_collect(compiler).remove(&path).unwrap_or_default() + }) + } + + fn mutation(source: &str, expression: &str, new_op: BinOpKind) -> MutationExclusion { + let lo = source.find(expression).expect("expression") as u32; + MutationExclusion::binary( + solar::ast::Span::new(BytePos(lo), BytePos(lo + expression.len() as u32)), + new_op, + ) + } + + fn unary_mutation(source: &str, expression: &str, new_op: UnOpKind) -> MutationExclusion { + let lo = source.find(expression).expect("expression") as u32; + MutationExclusion::unary( + solar::ast::Span::new(BytePos(lo), BytePos(lo + expression.len() as u32)), + new_op, + ) + } + + fn assignment_mutation( + source: &str, + value: &str, + replacement: AssignmentReplacement, + ) -> MutationExclusion { + let lo = source.rfind(value).expect("value") as u32; + MutationExclusion::assignment( + solar::ast::Span::new(BytePos(lo), BytePos(lo + value.len() as u32)), + replacement, + ) + } + + #[test] + fn excludes_assignments_invalid_for_destination_type() { + let source = r#" +enum Choice { A, B } + +contract Test { + function check(address account, bool flag, uint256 unsigned, Choice choice) external pure { + address accountCopy = account; + bool flagCopy = flag; + uint256 unsignedCopy = unsigned; + Choice choiceCopy = choice; + } +} +"#; + let mutations = collect(source); + + for value in ["account", "flag", "choice"] { + assert!(mutations.contains(&assignment_mutation( + source, + value, + AssignmentReplacement::Zero, + ))); + assert!(mutations.contains(&assignment_mutation( + source, + value, + AssignmentReplacement::Negate, + ))); + } + assert!(!mutations.contains(&assignment_mutation( + source, + "unsigned", + AssignmentReplacement::Zero, + ))); + assert!(mutations.contains(&assignment_mutation( + source, + "unsigned", + AssignmentReplacement::Negate, + ))); + } + + #[test] + fn uses_lhs_type_for_assignments() { + let source = r#" +contract Test { + function check(address account, bool flag) external pure { + account = account; + flag = flag; + } +} +"#; + let mutations = collect(source); + + for value in ["account", "flag"] { + assert!(mutations.contains(&assignment_mutation( + source, + value, + AssignmentReplacement::Zero, + ))); + assert!(mutations.contains(&assignment_mutation( + source, + value, + AssignmentReplacement::Negate, + ))); + } + } + + #[test] + fn excludes_invalid_compound_assignment_negation() { + let source = r#" +contract Test { + uint256 unsignedTotal; + int256 signedTotal; + bytes32 word; + + function check( + uint256 unsignedAmount, + int256 signedAmount, + bytes32 mask, + uint8 shift + ) external { + unsignedTotal += unsignedAmount; + signedTotal += signedAmount; + word &= mask; + signedTotal <<= shift; + signedTotal += 11; + signedTotal &= 12; + signedTotal <<= 13; + signedTotal >>= 14; + } +} +"#; + let mutations = collect(source); + + for value in ["unsignedAmount", "mask", "shift"] { + assert!(mutations.contains(&assignment_mutation( + source, + value, + AssignmentReplacement::Negate, + ))); + } + assert!(!mutations.contains(&assignment_mutation( + source, + "signedAmount", + AssignmentReplacement::Negate, + ))); + for value in ["11", "12"] { + assert!(!mutations.contains(&assignment_mutation( + source, + value, + AssignmentReplacement::Negate, + ))); + } + for value in ["13", "14"] { + assert!(mutations.contains(&assignment_mutation( + source, + value, + AssignmentReplacement::Negate, + ))); + } + for value in ["unsignedAmount", "signedAmount", "mask", "shift"] { + assert!(!mutations.contains(&assignment_mutation( + source, + value, + AssignmentReplacement::Zero, + ))); + } + } + + #[test] + fn preserves_valid_signed_and_fixed_bytes_assignments() { + let source = r#" +contract Test { + function check(int256 signed, bytes32 word) external pure { + int256 signedCopy = signed; + bytes32 wordCopy = word; + } +} +"#; + let mutations = collect(source); + + for replacement in [AssignmentReplacement::Zero, AssignmentReplacement::Negate] { + assert!(!mutations.contains(&assignment_mutation(source, "signed", replacement))); + } + assert!(!mutations.contains(&assignment_mutation( + source, + "word", + AssignmentReplacement::Zero, + ))); + assert!(mutations.contains(&assignment_mutation( + source, + "word", + AssignmentReplacement::Negate, + ))); + } + + #[test] + fn preserves_udvt_assignments() { + let source = r#" +type Amount is uint256; + +contract Test { + function check(Amount amount) external pure { + Amount copy = amount; + } +} +"#; + let mutations = collect(source); + + for replacement in [AssignmentReplacement::Zero, AssignmentReplacement::Negate] { + assert!(!mutations.contains(&assignment_mutation(source, "amount", replacement))); + } + } + + #[test] + fn preserves_all_mutations_when_analysis_has_errors() { + let source = r#" +contract Test { + function check(address account) external pure { + address copy = account; + unresolved = unresolved; + } +} +"#; + + assert!(collect(source).is_empty()); + } + + #[test] + fn preserves_gas_distinct_unsigned_boundary_mutations() { + let source = r#" +contract Test { + function check(uint256 x) external pure returns (bool) { + return x == 0; + } +} +"#; + let mutations = collect(source); + + assert!(!mutations.contains(&mutation(source, "x == 0", BinOpKind::Le))); + assert!(!mutations.contains(&mutation(source, "x == 0", BinOpKind::Lt))); + } + + #[test] + fn excludes_negation_only_for_unsigned_unary_operands() { + let source = r#" +contract Test { + function check(uint256 unsigned, int256 signed) external pure { + unsigned++; + ++unsigned; + signed++; + } +} +"#; + let mutations = collect(source); + + assert!(mutations.contains(&unary_mutation(source, "unsigned++", UnOpKind::Neg))); + assert!(mutations.contains(&unary_mutation(source, "++unsigned", UnOpKind::Neg))); + assert!(!mutations.contains(&unary_mutation(source, "signed++", UnOpKind::Neg))); + } + + #[test] + fn excludes_lvalue_mutations_for_user_defined_push() { + let source = r#" +contract Test { + function push() external pure returns (int256) { + return 1; + } + + function check() external view returns (int256) { + return -this.push(); + } +} +"#; + let mutations = collect(source); + + for op in [UnOpKind::PreInc, UnOpKind::PreDec, UnOpKind::PostInc, UnOpKind::PostDec] { + assert!(mutations.contains(&unary_mutation(source, "-this.push()", op))); + } + } + + #[test] + fn preserves_lvalue_mutations_for_storage_push() { + let source = r#" +contract Test { + int256[] values; + + function check() external returns (int256) { + return -values.push(); + } +} +"#; + let mutations = collect(source); + + for op in [UnOpKind::PreInc, UnOpKind::PreDec, UnOpKind::PostInc, UnOpKind::PostDec] { + assert!(!mutations.contains(&unary_mutation(source, "-values.push()", op))); + } + } + + #[test] + fn excludes_invalid_fixed_bytes_unary_mutations() { + let source = r#" +contract Test { + function check(bytes32 word) external pure returns (bytes32) { + return ~word; + } + + function checkNumber(uint256 number) external pure returns (uint256) { + return ~number; + } +} +"#; + let mutations = collect(source); + + for op in [ + UnOpKind::PreInc, + UnOpKind::PreDec, + UnOpKind::PostInc, + UnOpKind::PostDec, + UnOpKind::Neg, + ] { + assert!(mutations.contains(&unary_mutation(source, "~word", op))); + } + assert!(!mutations.contains(&unary_mutation(source, "~word", UnOpKind::BitNot))); + + for op in [UnOpKind::PreInc, UnOpKind::PreDec, UnOpKind::PostInc, UnOpKind::PostDec] { + assert!(!mutations.contains(&unary_mutation(source, "~number", op))); + } + assert!(mutations.contains(&unary_mutation(source, "~number", UnOpKind::Neg))); + } + + #[test] + fn excludes_invalid_fixed_bytes_storage_push_mutations() { + let source = r#" +contract Test { + bytes32[] values; + + function check() external returns (bytes32) { + return ~values.push(); + } +} +"#; + let mutations = collect(source); + + for op in [ + UnOpKind::PreInc, + UnOpKind::PreDec, + UnOpKind::PostInc, + UnOpKind::PostDec, + UnOpKind::Neg, + ] { + assert!(mutations.contains(&unary_mutation(source, "~values.push()", op))); + } + } + + #[test] + fn preserves_overloaded_negation_for_unsigned_udvt() { + let source = r#" +type Amount is uint256; + +function negate(Amount amount) pure returns (Amount) { + return Amount.wrap(type(uint256).max - Amount.unwrap(amount)); +} + +function complement(Amount amount) pure returns (Amount) { + return Amount.wrap(~Amount.unwrap(amount)); +} + +using {negate as -, complement as ~} for Amount global; + +contract Test { + function check(Amount amount) external pure returns (Amount) { + return ~amount; + } +} +"#; + let mutations = collect(source); + + assert!(!mutations.contains(&unary_mutation(source, "~amount", UnOpKind::Neg))); + } + + #[test] + fn preserves_negation_for_unresolved_operand() { + let source = r#" +contract Test { + function check() external pure { + unresolved++; + } +} +"#; + let mutations = collect(source); + + assert!(!mutations.contains(&unary_mutation(source, "unresolved++", UnOpKind::Neg))); + } + + #[test] + fn excludes_logical_replacements_for_numeric_comparisons() { + let source = r#" +contract Test { + function check(uint256 left, uint256 right) external pure returns (bool) { + return left == right; + } +} +"#; + let mutations = collect(source); + + assert!(mutations.contains(&mutation(source, "left == right", BinOpKind::And))); + assert!(mutations.contains(&mutation(source, "left == right", BinOpKind::Or))); + assert!(!mutations.contains(&mutation(source, "left == right", BinOpKind::Lt))); + assert!(!mutations.contains(&mutation(source, "left == right", BinOpKind::Ne))); + } + + #[test] + fn excludes_ordered_replacements_for_boolean_equality() { + let source = r#" +contract Test { + function check(bool left, bool right) external pure returns (bool) { + return left == right; + } +} +"#; + let mutations = collect(source); + + for candidate in [BinOpKind::Lt, BinOpKind::Le, BinOpKind::Gt, BinOpKind::Ge] { + assert!(mutations.contains(&mutation(source, "left == right", candidate))); + } + for candidate in [BinOpKind::Ne, BinOpKind::And, BinOpKind::Or] { + assert!(!mutations.contains(&mutation(source, "left == right", candidate))); + } + } + + #[test] + fn excludes_ordered_replacements_for_logical_operations() { + let source = r#" +contract Test { + function check(bool left, bool right) external pure returns (bool) { + return left && right; + } +} +"#; + let mutations = collect(source); + + for candidate in [BinOpKind::Lt, BinOpKind::Le, BinOpKind::Gt, BinOpKind::Ge] { + assert!(mutations.contains(&mutation(source, "left && right", candidate))); + } + for candidate in [BinOpKind::Eq, BinOpKind::Ne, BinOpKind::Or] { + assert!(!mutations.contains(&mutation(source, "left && right", candidate))); + } + } + + #[test] + fn excludes_non_equality_replacements_for_function_comparisons() { + let source = r#" +contract Test { + function check( + function() external left, + function() external right + ) external pure returns (bool) { + return left == right; + } +} +"#; + let mutations = collect(source); + + for candidate in [ + BinOpKind::Lt, + BinOpKind::Le, + BinOpKind::Gt, + BinOpKind::Ge, + BinOpKind::And, + BinOpKind::Or, + ] { + assert!(mutations.contains(&mutation(source, "left == right", candidate))); + } + assert!(!mutations.contains(&mutation(source, "left == right", BinOpKind::Ne))); + } + + #[test] + fn preserves_reversed_and_upper_boundary_mutations() { + let source = r#" +contract Test { + function lower(uint256 x) external pure returns (bool) { + return 0 == x; + } + + function upper(uint8 x) external pure returns (bool) { + return x == type(uint8).max; + } +} +"#; + let mutations = collect(source); + + assert!(!mutations.contains(&mutation(source, "0 == x", BinOpKind::Ge))); + assert!(!mutations.contains(&mutation(source, "0 == x", BinOpKind::Gt))); + assert!(!mutations.contains(&mutation(source, "x == type(uint8).max", BinOpKind::Ge))); + } + + #[test] + fn preserves_member_and_typed_constant_boundary_mutations() { + let source = r#" +contract Test { + function empty(bytes memory value) external pure returns (bool) { + return value.length == 0; + } + + function zero(address value) external pure returns (bool) { + return value == address(0); + } +} +"#; + let mutations = collect(source); + + assert!(!mutations.contains(&mutation(source, "value.length == 0", BinOpKind::Le))); + assert!(!mutations.contains(&mutation(source, "value == address(0)", BinOpKind::Le))); + } +} diff --git a/crates/forge/src/mutation/visitor.rs b/crates/forge/src/mutation/visitor.rs new file mode 100644 index 0000000000000..4eb552e01cdcd --- /dev/null +++ b/crates/forge/src/mutation/visitor.rs @@ -0,0 +1,522 @@ +use crate::mutation::{ + mutant::{Mutant, OwnedLiteral}, + mutators::{MutationContext, mutator_registry::MutatorRegistry}, + type_analysis::{AssignmentReplacement, MutationExclusion, MutationExclusionSet}, +}; +use eyre::Report; +use foundry_config::MutatorType; +use solar::ast::{Expr, ItemContract, VariableDefinition, visit::Visit, yul}; +use std::{ops::ControlFlow, path::PathBuf}; + +#[cfg(test)] +use crate::mutation::mutators::Mutator; + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub enum AssignVarTypes { + Literal(OwnedLiteral), + Identifier(String), + NegatedIdentifier(String), +} + +/// A visitor which collect all expression to mutate as well as the mutation types +#[allow(clippy::type_complexity)] +pub struct MutantVisitor<'src> { + pub mutation_to_conduct: Vec, + errors: Vec, + pub mutator_registry: MutatorRegistry, + pub path: PathBuf, + pub source: Option<&'src str>, + /// Optional per-contract name filter. When `Some`, mutations are only collected + /// from contracts whose name matches the predicate. + pub contract_filter: Option bool>>, + /// Whether the currently-visited contract is allowed by `contract_filter`. + /// `true` when no filter is set or when we are visiting a contract whose name + /// matched the filter. Top-level items (outside any contract) are always + /// considered "allowed". + in_allowed_contract: bool, + mutation_exclusions: MutationExclusionSet, +} + +impl<'src> MutantVisitor<'src> { + /// Create a visitor with the specified mutator operators enabled + pub fn with_operators(path: PathBuf, operators: &[MutatorType]) -> Self { + Self { + mutation_to_conduct: Vec::new(), + errors: Vec::new(), + mutator_registry: MutatorRegistry::from_enabled(operators), + path, + source: None, + contract_filter: None, + in_allowed_contract: true, + mutation_exclusions: MutationExclusionSet::new(), + } + } + + /// Use all mutators from registry (all operators enabled) + #[cfg(test)] + pub fn default(path: PathBuf) -> Self { + Self { + mutation_to_conduct: Vec::new(), + errors: Vec::new(), + mutator_registry: MutatorRegistry::default(), + path, + source: None, + contract_filter: None, + in_allowed_contract: true, + mutation_exclusions: MutationExclusionSet::new(), + } + } + + /// Use only a set of mutators + #[cfg(test)] + pub fn new_with_mutators(path: PathBuf, mutators: Vec>) -> Self { + Self { + mutation_to_conduct: Vec::new(), + errors: Vec::new(), + mutator_registry: MutatorRegistry::new_with_mutators(mutators), + path, + source: None, + contract_filter: None, + in_allowed_contract: true, + mutation_exclusions: MutationExclusionSet::new(), + } + } + + /// Set the source code for extracting original text + pub const fn with_source(mut self, source: &'src str) -> Self { + self.source = Some(source); + self + } + + /// Set a contract-name filter; only contracts whose name matches the + /// predicate will have their bodies mutated. + pub fn with_contract_filter(mut self, filter: F) -> Self + where + F: Fn(&str) -> bool + 'static, + { + self.contract_filter = Some(Box::new(filter)); + self + } + + /// Exclude operator replacements rejected by type analysis. + pub fn with_mutation_exclusions(mut self, mutations: MutationExclusionSet) -> Self { + self.mutation_exclusions = mutations; + self + } + + pub fn take_errors(&mut self) -> Vec { + std::mem::take(&mut self.errors) + } + + fn collect_mutations(&mut self, context: &MutationContext<'_>) { + let result = self.mutator_registry.generate_mutations(context); + self.mutation_to_conduct.extend(result.mutations.into_iter().filter(|mutant| { + let exclusion = match &mutant.mutation { + crate::mutation::mutant::MutationType::Assignment(kind) => { + let replacement = match kind { + AssignVarTypes::Literal(OwnedLiteral::Number(value)) if value.is_zero() => { + AssignmentReplacement::Zero + } + AssignVarTypes::Literal(OwnedLiteral::NegatedNumber(_)) + | AssignVarTypes::NegatedIdentifier(_) => AssignmentReplacement::Negate, + _ => return true, + }; + MutationExclusion::assignment(mutant.span, replacement) + } + crate::mutation::mutant::MutationType::BinaryOpExpr { new_op, .. } => { + MutationExclusion::binary(mutant.span, *new_op) + } + crate::mutation::mutant::MutationType::UnaryOperator(unary) => { + MutationExclusion::unary(mutant.span, unary.resulting_op_kind) + } + _ => return true, + }; + !self.mutation_exclusions.contains(&exclusion) + })); + + for err in result.errors { + self.errors.push(err.wrap_err(format!( + "failed to generate mutations for {}:{}:{}", + self.path.display(), + context.line_number(), + context.column_number() + ))); + } + } +} + +impl<'ast> Visit<'ast> for MutantVisitor<'ast> { + type BreakValue = (); + + fn visit_item_contract( + &mut self, + contract: &'ast ItemContract<'ast>, + ) -> ControlFlow { + // When a contract name filter is configured, only descend into matching + // contracts. We toggle `in_allowed_contract` for the duration of the + // walk so nested visit_expr / visit_variable_definition calls can gate + // mutant collection accordingly. + let prev = self.in_allowed_contract; + self.in_allowed_contract = match &self.contract_filter { + Some(filter) => filter(contract.name.as_str()), + None => true, + }; + let res = self.walk_item_contract(contract); + self.in_allowed_contract = prev; + res + } + + fn visit_variable_definition( + &mut self, + var: &'ast VariableDefinition<'ast>, + ) -> ControlFlow { + // Skip entirely when the surrounding contract is filtered out. + if !self.in_allowed_contract { + return self.walk_variable_definition(var); + } + + let mut builder = MutationContext::builder() + .with_path(self.path.clone()) + .with_span(var.span) + .with_var_definition(var); + + if let Some(src) = self.source { + builder = builder.with_source(src); + } + + let context = builder + .build() + .expect("MutationContext requires both path and span for variable definition"); + + self.collect_mutations(&context); + self.walk_variable_definition(var) + } + + fn visit_expr(&mut self, expr: &'ast Expr<'ast>) -> ControlFlow { + // Skip entirely when the surrounding contract is filtered out. + if !self.in_allowed_contract { + return self.walk_expr(expr); + } + + let mut builder = MutationContext::builder() + .with_path(self.path.clone()) + .with_span(expr.span) + .with_expr(expr); + + if let Some(src) = self.source { + builder = builder.with_source(src); + } + + let context = + builder.build().expect("MutationContext requires both path and span for expression"); + + self.collect_mutations(&context); + self.walk_expr(expr) + } + + fn visit_yul_expr(&mut self, expr: &'ast yul::Expr<'ast>) -> ControlFlow { + // Skip entirely when the surrounding contract is filtered out. + if !self.in_allowed_contract { + return self.walk_yul_expr(expr); + } + + let mut builder = MutationContext::builder() + .with_path(self.path.clone()) + .with_span(expr.span) + .with_yul_expr(expr); + + if let Some(src) = self.source { + builder = builder.with_source(src); + } + + let context = builder + .build() + .expect("MutationContext requires both path and span for yul expression"); + + self.collect_mutations(&context); + self.walk_yul_expr(expr) + } +} + +#[cfg(test)] +mod tests { + use eyre::{Result, eyre}; + use solar::{ + ast::{Arena, interface::source_map::FileName}, + parse::Parser, + }; + + use super::*; + use crate::mutation::{Session, mutant::MutationType}; + + struct FailingExprMutator; + + impl Mutator for FailingExprMutator { + fn generate_mutants(&self, _ctxt: &MutationContext<'_>) -> Result> { + Err(eyre!("synthetic visitor failure")) + } + + fn is_applicable(&self, ctxt: &MutationContext<'_>) -> bool { + ctxt.expr.is_some() + } + } + + struct PassingExprMutator; + + impl Mutator for PassingExprMutator { + fn generate_mutants(&self, ctxt: &MutationContext<'_>) -> Result> { + Ok(vec![Mutant { + path: ctxt.path.clone(), + span: ctxt.span, + mutation: MutationType::DeleteExpression, + original: ctxt.original_text(), + source_line: ctxt.source_line(), + line_number: ctxt.line_number(), + column_number: ctxt.column_number(), + }]) + } + + fn is_applicable(&self, ctxt: &MutationContext<'_>) -> bool { + ctxt.expr.is_some() + } + } + + #[test] + fn visitor_collects_mutations_and_surfaces_mutator_errors() { + let source = "\ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; +contract Test { + function test() public { + uint256 x = 1 + 2; + } +} +"; + let path = PathBuf::from("test.sol"); + let sess = Session::builder().with_silent_emitter(None).build(); + + sess.enter(|| { + let arena = Arena::new(); + let mut parser = + Parser::from_lazy_source_code(&sess, &arena, FileName::Real(path.clone()), || { + Ok(source.to_string()) + }) + .unwrap(); + let ast = parser.parse_file().map_err(|e| e.emit()).unwrap(); + drop(parser); + let mut visitor = MutantVisitor::new_with_mutators( + path, + vec![Box::new(FailingExprMutator), Box::new(PassingExprMutator)], + ) + .with_source(source); + + let _ = visitor.visit_source_unit(&ast); + let errors = visitor.take_errors(); + + assert!(!visitor.mutation_to_conduct.is_empty()); + assert!(!errors.is_empty()); + + let err = format!("{:?}", errors[0]); + assert!(err.contains("failed to generate mutations for test.sol:")); + assert!(err.contains("synthetic visitor failure")); + }); + } + + #[test] + fn visitor_excludes_binary_mutations_rejected_by_type_analysis() { + let source = "\ +pragma solidity ^0.8.0; +contract Test { + function check(uint256 x) public pure returns (bool) { + return x == 0; + } +} +"; + let path = PathBuf::from("test.sol"); + let lo = source.find("x == 0").unwrap() as u32; + let span = solar::ast::Span::new( + solar::interface::BytePos(lo), + solar::interface::BytePos(lo + "x == 0".len() as u32), + ); + let mutation_exclusions = + [MutationExclusion::binary(span, solar::ast::BinOpKind::Le)].into_iter().collect(); + let sess = Session::builder().with_silent_emitter(None).build(); + + sess.enter(|| { + let arena = Arena::new(); + let mut parser = + Parser::from_lazy_source_code(&sess, &arena, FileName::Real(path.clone()), || { + Ok(source.to_string()) + }) + .unwrap(); + let ast = parser.parse_file().map_err(|e| e.emit()).unwrap(); + drop(parser); + let mut visitor = MutantVisitor::default(path) + .with_source(source) + .with_mutation_exclusions(mutation_exclusions); + + let _ = visitor.visit_source_unit(&ast); + let comparison_mutations = visitor.mutation_to_conduct.iter().filter_map(|mutant| { + let MutationType::BinaryOpExpr { new_op, .. } = mutant.mutation else { + return None; + }; + (mutant.span == span).then_some(new_op) + }); + let comparison_mutations = comparison_mutations.collect::>(); + + assert!(!comparison_mutations.contains(&solar::ast::BinOpKind::Le)); + assert!(comparison_mutations.contains(&solar::ast::BinOpKind::Lt)); + }); + } + + #[test] + fn visitor_excludes_assignment_mutations_rejected_by_type_analysis() { + let source = "\ +pragma solidity ^0.8.0; +contract Test { + function check(address account) public pure { + address copy = account; + } +} +"; + let path = PathBuf::from("test.sol"); + let lo = source.rfind("account").unwrap() as u32; + let span = solar::ast::Span::new( + solar::interface::BytePos(lo), + solar::interface::BytePos(lo + "account".len() as u32), + ); + let mutation_exclusions = [ + MutationExclusion::assignment(span, AssignmentReplacement::Zero), + MutationExclusion::assignment(span, AssignmentReplacement::Negate), + ] + .into_iter() + .collect(); + let sess = Session::builder().with_silent_emitter(None).build(); + + sess.enter(|| { + let arena = Arena::new(); + let mut parser = + Parser::from_lazy_source_code(&sess, &arena, FileName::Real(path.clone()), || { + Ok(source.to_string()) + }) + .unwrap(); + let ast = parser.parse_file().map_err(|e| e.emit()).unwrap(); + drop(parser); + let mut visitor = MutantVisitor::new_with_mutators( + path, + vec![Box::new(crate::mutation::mutators::assignment_mutator::AssignmentMutator)], + ) + .with_source(source) + .with_mutation_exclusions(mutation_exclusions); + + let _ = visitor.visit_source_unit(&ast); + + assert!(visitor.mutation_to_conduct.iter().all(|mutant| mutant.span != span)); + }); + } + + #[test] + fn visitor_excludes_negated_number_but_retains_zero_assignment() { + let source = "\ +pragma solidity ^0.8.0; +contract Test { + function check() public pure { + uint8 copy = 1; + } +} +"; + let path = PathBuf::from("test.sol"); + let lo = source.rfind('1').unwrap() as u32; + let span = + solar::ast::Span::new(solar::interface::BytePos(lo), solar::interface::BytePos(lo + 1)); + let mutation_exclusions = + [MutationExclusion::assignment(span, AssignmentReplacement::Negate)] + .into_iter() + .collect(); + let sess = Session::builder().with_silent_emitter(None).build(); + + sess.enter(|| { + let arena = Arena::new(); + let mut parser = + Parser::from_lazy_source_code(&sess, &arena, FileName::Real(path.clone()), || { + Ok(source.to_string()) + }) + .unwrap(); + let ast = parser.parse_file().map_err(|e| e.emit()).unwrap(); + drop(parser); + let mut visitor = MutantVisitor::new_with_mutators( + path, + vec![Box::new(crate::mutation::mutators::assignment_mutator::AssignmentMutator)], + ) + .with_source(source) + .with_mutation_exclusions(mutation_exclusions); + + let _ = visitor.visit_source_unit(&ast); + let replacements = visitor + .mutation_to_conduct + .iter() + .filter(|mutant| mutant.span == span) + .map(|mutant| mutant.mutation.to_string()) + .collect::>(); + + assert_eq!(replacements, ["0"]); + }); + } + + #[test] + fn visitor_excludes_unsigned_negation_but_retains_unary_swaps() { + let source = "\ +pragma solidity ^0.8.0; +contract Test { + function increment(uint256 x) public pure { + x++; + } +} +"; + let path = PathBuf::from("test.sol"); + let lo = source.find("x++").unwrap() as u32; + let span = solar::ast::Span::new( + solar::interface::BytePos(lo), + solar::interface::BytePos(lo + "x++".len() as u32), + ); + let mutation_exclusions = + [MutationExclusion::unary(span, solar::ast::UnOpKind::Neg)].into_iter().collect(); + let sess = Session::builder().with_silent_emitter(None).build(); + + sess.enter(|| { + let arena = Arena::new(); + let mut parser = + Parser::from_lazy_source_code(&sess, &arena, FileName::Real(path.clone()), || { + Ok(source.to_string()) + }) + .unwrap(); + let ast = parser.parse_file().map_err(|e| e.emit()).unwrap(); + drop(parser); + let mut visitor = MutantVisitor::new_with_mutators( + path, + vec![Box::new(crate::mutation::mutators::unary_op_mutator::UnaryOpMutator)], + ) + .with_source(source) + .with_mutation_exclusions(mutation_exclusions); + + let _ = visitor.visit_source_unit(&ast); + let unary_mutations = visitor + .mutation_to_conduct + .iter() + .filter_map(|mutant| { + let MutationType::UnaryOperator(unary) = &mutant.mutation else { + return None; + }; + (mutant.span == span).then_some(unary.resulting_op_kind) + }) + .collect::>(); + + assert_eq!(unary_mutations.len(), 4); + assert!(!unary_mutations.contains(&solar::ast::UnOpKind::Neg)); + assert!(unary_mutations.contains(&solar::ast::UnOpKind::PreInc)); + assert!(unary_mutations.contains(&solar::ast::UnOpKind::PreDec)); + assert!(unary_mutations.contains(&solar::ast::UnOpKind::BitNot)); + assert!(unary_mutations.contains(&solar::ast::UnOpKind::PostDec)); + }); + } +} diff --git a/crates/forge/src/opts.rs b/crates/forge/src/opts.rs index d0309326b286f..c4b74f73e642d 100644 --- a/crates/forge/src/opts.rs +++ b/crates/forge/src/opts.rs @@ -1,9 +1,9 @@ use crate::cmd::{ bind::BindArgs, bind_json, build::BuildArgs, cache::CacheArgs, clone::CloneArgs, compiler::CompilerArgs, config, coverage, create::CreateArgs, doc::DocArgs, eip712, flatten, - fmt::FmtArgs, geiger, generate, init::InitArgs, inspect, install::InstallArgs, lint::LintArgs, - remappings::RemappingArgs, remove::RemoveArgs, selectors::SelectorsSubcommands, snapshot, - soldeer, test, tree, update, + fmt::FmtArgs, fuzz::FuzzArgs, geiger, init::InitArgs, inspect, install::InstallArgs, + lint::LintArgs, lsp::LspArgs, reinit::ReinitArgs, remappings::RemappingArgs, + remove::RemoveArgs, selectors::SelectorsSubcommands, snapshot, soldeer, test, tree, update, }; use clap::{Parser, Subcommand, ValueHint}; use forge_script::ScriptArgs; @@ -32,25 +32,61 @@ pub struct Forge { #[derive(Subcommand)] pub enum ForgeSubcommand { - /// Run the project's tests. - #[command(visible_alias = "t")] + /// Run the project's tests + /// + /// Examples: + /// - forge test + /// - forge test --match-test test_Increment -vvvv (show traces for a matching test) + /// - forge test --match-contract CounterTest --fuzz-runs 1000 + #[command(verbatim_doc_comment, visible_alias = "t")] Test(test::TestArgs), - /// Run a smart contract as a script, building transactions that can be sent onchain. + /// Run and manage Forge fuzzing corpora. + Fuzz(FuzzArgs), + + /// Run a smart contract as a script, building transactions that can be sent onchain + /// + /// Examples: + /// - forge script script/Counter.s.sol (simulate the script locally) + /// - forge script script/Counter.s.sol --rpc-url $RPC_URL --broadcast --account dev + /// - forge script script/Counter.s.sol --rpc-url $RPC_URL --resume + #[command(verbatim_doc_comment)] Script(ScriptArgs), - /// Generate coverage reports. + /// Generate coverage reports + /// + /// Examples: + /// - forge coverage + /// - forge coverage --report lcov --report-file lcov.info + /// - forge coverage --match-contract CounterTest + #[command(verbatim_doc_comment)] Coverage(coverage::CoverageArgs), /// Generate Rust bindings for smart contracts. #[command(alias = "bi")] Bind(BindArgs), - /// Build the project's smart contracts. - #[command(visible_aliases = ["b", "compile"])] - Build(BuildArgs), + /// Build the project's smart contracts + /// + /// Examples: + /// - forge build + /// - forge build --sizes (print a contract size report) + /// - forge build --watch (rebuild on file changes) + #[command(verbatim_doc_comment, visible_aliases = ["b", "compile"])] + Build { + /// Require foundry.lock to match direct Git dependency submodules. + #[arg(long)] + locked: bool, + #[command(flatten)] + args: BuildArgs, + }, - /// Clone a contract from Etherscan. + /// Clone a contract from Etherscan + /// + /// Examples: + /// - forge clone $WETH weth --etherscan-api-key $KEY (clone WETH into ./weth) + /// - forge clone --chain sepolia --etherscan-api-key $KEY $ADDRESS my-contract + #[command(verbatim_doc_comment)] Clone(CloneArgs), /// Update one or multiple dependencies. @@ -59,12 +95,20 @@ pub enum ForgeSubcommand { #[command(visible_alias = "u")] Update(update::UpdateArgs), - /// Install one or multiple dependencies. + /// Install one or multiple dependencies /// /// If no arguments are provided, then existing dependencies will be installed. - #[command(visible_alias = "i")] + /// + /// Examples: + /// - forge install (install all dependencies of the project) + /// - forge install openzeppelin/openzeppelin-contracts + /// - forge install openzeppelin/openzeppelin-contracts@v5.0.2 (pin a version) + #[command(verbatim_doc_comment, visible_aliases = ["i", "add"])] Install(InstallArgs), + /// Reinitialize the project's Git submodules, discarding local changes. + Reinit(ReinitArgs), + /// Remove one or multiple dependencies. #[command(visible_alias = "rm")] Remove(RemoveArgs), @@ -73,11 +117,15 @@ pub enum ForgeSubcommand { #[command(visible_alias = "re")] Remappings(RemappingArgs), - /// Verify smart contracts on Etherscan. - #[command(visible_alias = "v")] + /// Verify smart contracts on Etherscan and Sourcify + /// + /// Examples: + /// - forge verify-contract --chain sepolia $ADDRESS src/Counter.sol:Counter --watch + /// - forge verify-contract $ADDRESS src/Counter.sol:Counter --verifier sourcify + #[command(verbatim_doc_comment, visible_alias = "v")] VerifyContract(VerifyArgs), - /// Check verification status on Etherscan. + /// Check verification status on the selected verifier. #[command(visible_alias = "vc")] VerifyCheck(VerifyCheckArgs), @@ -85,8 +133,12 @@ pub enum ForgeSubcommand { #[command(visible_alias = "vb")] VerifyBytecode(VerifyBytecodeArgs), - /// Deploy a smart contract. - #[command(visible_alias = "c")] + /// Deploy a smart contract + /// + /// Examples: + /// - forge create Counter --rpc-url $RPC_URL --account dev --broadcast + /// - forge create Token --private-key $PK --broadcast --constructor-args Token TKN + #[command(verbatim_doc_comment, visible_alias = "c")] Create(CreateArgs), /// Create a new Forge project. @@ -96,13 +148,9 @@ pub enum ForgeSubcommand { #[command(visible_alias = "com")] Completions { #[arg(value_enum)] - shell: clap_complete::Shell, + shell: foundry_cli::clap::Shell, }, - /// Generate Fig autocompletion spec. - #[command(visible_alias = "fig")] - GenerateFigSpec, - /// Remove the build artifacts and cache directories. #[command(visible_alias = "cl")] Clean { @@ -117,8 +165,13 @@ pub enum ForgeSubcommand { /// Manage the Foundry cache. Cache(CacheArgs), - /// Create a gas snapshot of each test's gas usage. - #[command(visible_alias = "s")] + /// Create a gas snapshot of each test's gas usage + /// + /// Examples: + /// - forge snapshot + /// - forge snapshot --diff (compare against the existing .gas-snapshot file) + /// - forge snapshot --check (fail if gas usage does not match .gas-snapshot) + #[command(verbatim_doc_comment, visible_alias = "s")] Snapshot(snapshot::GasSnapshotArgs), /// Display the current config. @@ -129,22 +182,38 @@ pub enum ForgeSubcommand { #[command(visible_alias = "f")] Flatten(flatten::FlattenArgs), - /// Format Solidity source files. + /// Format Solidity source files + /// + /// Examples: + /// - forge fmt + /// - forge fmt --check (report formatting issues without writing changes) + /// - forge fmt src/Counter.sol + #[command(verbatim_doc_comment)] Fmt(FmtArgs), /// Lint Solidity source files #[command(visible_alias = "l")] Lint(LintArgs), - /// Get specialized information about a smart contract. - #[command(visible_alias = "in")] + /// Open Solidity in VS Code or start the language server. + Lsp(LspArgs), + + /// Get specialized information about a smart contract + /// + /// Examples: + /// - forge inspect Counter abi + /// - forge inspect Counter bytecode + /// - forge inspect src/Counter.sol:Counter storageLayout + #[command(verbatim_doc_comment, visible_alias = "in")] Inspect(inspect::InspectArgs), /// Display a tree visualization of the project's dependency graph. #[command(visible_alias = "tr")] Tree(tree::TreeArgs), - /// Detects usage of unsafe cheat codes in a project and its dependencies. + /// DEPRECATED: Detects usage of unsafe cheat codes in a project and its dependencies. + /// + /// This is an alias for `forge lint --only-lint unsafe-cheatcode`. Geiger(geiger::GeigerArgs), /// Generate documentation for the project. @@ -157,9 +226,6 @@ pub enum ForgeSubcommand { command: SelectorsSubcommands, }, - /// Generate scaffold files. - Generate(generate::GenerateArgs), - /// Compiler utilities. Compiler(CompilerArgs), @@ -182,4 +248,45 @@ mod tests { fn verify_cli() { Forge::command().debug_assert(); } + + #[test] + fn parse_lsp_args() { + let args = Forge::try_parse_from(["forge", "lsp", "--stdio"]).unwrap(); + let ForgeSubcommand::Lsp(args) = args.cmd else { + panic!("expected lsp subcommand"); + }; + assert!(args.stdio); + } + + #[test] + fn parse_lsp_editor_args() { + let args = Forge::try_parse_from([ + "forge", + "lsp", + "--vscode", + "--code-path", + "code-insiders", + "project", + ]) + .unwrap(); + let ForgeSubcommand::Lsp(args) = args.cmd else { + panic!("expected lsp subcommand"); + }; + assert!(!args.stdio); + assert!(args.vscode); + assert_eq!(args.path, Some(PathBuf::from("project"))); + assert_eq!(args.code_path, Some(PathBuf::from("code-insiders"))); + } + + #[test] + fn lsp_stdio_conflicts_with_editor_args() { + for options in [&["--vscode"][..], &["--code-path", "code"], &["project"]] { + let error = Forge::try_parse_from( + ["forge", "lsp", "--stdio"].into_iter().chain(options.iter().copied()), + ) + .err() + .expect("stdio must reject editor launch options"); + assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict); + } + } } diff --git a/crates/forge/src/progress.rs b/crates/forge/src/progress.rs index c87554617f31f..03abcba94a719 100644 --- a/crates/forge/src/progress.rs +++ b/crates/forge/src/progress.rs @@ -1,9 +1,13 @@ +//! Progress bars for the test run. + use alloy_primitives::map::HashMap; use chrono::Utc; -use indicatif::{MultiProgress, ProgressBar}; +use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use parking_lot::Mutex; use std::{sync::Arc, time::Duration}; +const TICK_CHARS: &str = "⠁⠂⠄⡀⢀⠠⠐⠈ "; + /// State of [ProgressBar]s displayed for the given test run. /// Shows progress of all test suites matching filter. /// For each test within the test suite an individual progress bar is displayed. @@ -20,26 +24,26 @@ pub struct TestsProgressState { } impl TestsProgressState { - // Creates overall tests progress state. + /// Creates overall tests progress state. pub fn new(suites_len: usize, threads_no: usize) -> Self { let multi = MultiProgress::new(); let overall_progress = multi.add(ProgressBar::new(suites_len as u64)); overall_progress.set_style( - indicatif::ProgressStyle::with_template("{bar:40.cyan/blue} {pos:>7}/{len:7} {msg}") + ProgressStyle::with_template("{bar:40.cyan/blue} {pos:>7}/{len:7} {msg}") .unwrap() .progress_chars("##-"), ); - overall_progress.set_message(format!("completed (with {} threads)", threads_no as u64)); + overall_progress.set_message(format!("completed (with {threads_no} threads)")); Self { multi, overall_progress, suites_progress: HashMap::default() } } /// Creates new test suite progress and add it to overall progress. - pub fn start_suite_progress(&mut self, suite_name: &String) { + pub fn start_suite_progress(&mut self, suite_name: &str) { let suite_progress = self.multi.add(ProgressBar::new_spinner()); suite_progress.set_style( - indicatif::ProgressStyle::with_template("{spinner} {wide_msg:.bold.dim}") + ProgressStyle::with_template("{spinner} {wide_msg:.bold.dim}") .unwrap() - .tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈ "), + .tick_chars(TICK_CHARS), ); suite_progress.set_message(format!("{suite_name} ")); suite_progress.enable_steady_tick(Duration::from_millis(100)); @@ -47,15 +51,13 @@ impl TestsProgressState { } /// Prints suite result summary and removes it from overall progress. - pub fn end_suite_progress(&mut self, suite_name: &String, result_summary: String) { - if let Some(suite_progress) = self.suites_progress.remove(suite_name) { - self.multi.suspend(|| { - let _ = sh_println!("{suite_name}\n ↪ {result_summary}"); - }); - suite_progress.finish_and_clear(); - // Increment test progress bar to reflect completed test suite. - self.overall_progress.inc(1); - } + pub fn end_suite_progress(&mut self, suite_name: &str, result_summary: String) { + let Some(suite_progress) = self.suites_progress.remove(suite_name) else { return }; + self.multi.suspend(|| { + let _ = sh_println!("{suite_name}\n ↪ {result_summary}"); + }); + suite_progress.finish_and_clear(); + self.overall_progress.inc(1); } /// Creates progress entry for fuzz tests. @@ -65,29 +67,24 @@ impl TestsProgressState { pub fn start_fuzz_progress( &mut self, suite_name: &str, - test_name: &String, + test_name: &str, timeout: Option, runs: u32, ) -> Option { - if let Some(suite_progress) = self.suites_progress.get(suite_name) { - let fuzz_progress = - self.multi.insert_after(suite_progress, ProgressBar::new(runs as u64)); - let template = if let Some(timeout) = timeout { - let ends_at = (Utc::now() + chrono::Duration::seconds(timeout.into())) - .format("%H:%M:%S %Y-%m-%d") - .to_string(); - format!(" ↪ {{prefix:.bold.dim}}: [{{pos}}] Runs, ends at {ends_at} UTC {{msg}}") - } else { - " ↪ {prefix:.bold.dim}: [{pos}/{len}] Runs {msg}".to_string() - }; - fuzz_progress.set_style( - indicatif::ProgressStyle::with_template(&template).unwrap().tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈ "), - ); - fuzz_progress.set_prefix(test_name.to_string()); - Some(fuzz_progress) + let suite_progress = self.suites_progress.get(suite_name)?; + let fuzz_progress = self.multi.insert_after(suite_progress, ProgressBar::new(runs as u64)); + let template = if let Some(timeout) = timeout { + let ends_at = (Utc::now() + chrono::Duration::seconds(timeout.into())) + .format("%H:%M:%S %Y-%m-%d"); + format!(" ↪ {{prefix:.bold.dim}}: [{{pos}}] Runs, ends at {ends_at} UTC {{msg}}") } else { - None - } + " ↪ {prefix:.bold.dim}: [{pos}/{len}] Runs {msg}".to_string() + }; + fuzz_progress + .set_style(ProgressStyle::with_template(&template).unwrap().tick_chars(TICK_CHARS)); + fuzz_progress.set_prefix(test_name.to_owned()); + fuzz_progress.enable_steady_tick(Duration::from_millis(100)); + Some(fuzz_progress) } /// Removes overall test progress. @@ -107,18 +104,3 @@ impl TestsProgress { Self { inner: Arc::new(Mutex::new(TestsProgressState::new(suites_len, threads_no))) } } } - -/// Helper function for creating fuzz test progress bar. -pub fn start_fuzz_progress( - tests_progress: Option<&TestsProgress>, - suite_name: &str, - test_name: &String, - timeout: Option, - runs: u32, -) -> Option { - if let Some(progress) = tests_progress { - progress.inner.lock().start_fuzz_progress(suite_name, test_name, timeout, runs) - } else { - None - } -} diff --git a/crates/forge/src/result.rs b/crates/forge/src/result.rs index c2456bb6af172..05e44f85d4c24 100644 --- a/crates/forge/src/result.rs +++ b/crates/forge/src/result.rs @@ -1,30 +1,46 @@ //! Test outcomes. use crate::{ - fuzz::{BaseCounterExample, FuzzedCases}, + fuzz::{BaseCounterExample, BasicTxDetails}, gas_report::GasReport, }; use alloy_primitives::{ - Address, Log, + Address, B256, Bytes, I256, Log, Selector, U256, map::{AddressHashMap, HashMap}, }; use eyre::Report; -use foundry_common::{evm::Breakpoints, get_contract_name, get_file_name, shell}; +use foundry_common::{ContractsByArtifact, get_contract_name, shell}; +use foundry_config::{SymbolicConfig, SymbolicExplorationOrder, SymbolicStorageLayout}; use foundry_evm::{ + core::{Breakpoints, evm::FoundryEvmNetwork}, coverage::HitMaps, decode::SkipReason, - executors::{RawCallResult, invariant::InvariantMetrics}, - fuzz::{CounterExample, FuzzCase, FuzzFixtures, FuzzTestResult}, + executors::{ + RawCallResult, + invariant::{CheckSequenceFailureSite, CheckSequenceOutcome, InvariantMetrics}, + }, + fuzz::{ + CallDetails, CounterExample, FuzzCase, FuzzFixtures, FuzzTestResult, + strategies::EvmFuzzState, + }, traces::{CallTraceArena, CallTraceDecoder, TraceKind, Traces}, }; +use foundry_evm_symbolic::{SymbolicStats, SymbolicStopReason, SymbolicStorageAssignment}; use serde::{Deserialize, Serialize}; use std::{ collections::{BTreeMap, HashMap as Map}, fmt::{self, Write}, + path::PathBuf, + sync::OnceLock, time::Duration, }; use yansi::Paint; +const INVARIANT_CAMPAIGN_FALLBACK_NAME: &str = "Invariant campaign"; +const SYMBOLIC_RESULT_SCHEMA_VERSION: u32 = 1; +pub const SYMBOLIC_COUNTEREXAMPLE_ARTIFACT_SCHEMA: &str = "foundry:symbolic.counterexample@v1"; +pub const SYMBOLIC_COUNTEREXAMPLE_ARTIFACT_SCHEMA_VERSION: u32 = 1; + /// The aggregated result of a test run. #[derive(Clone, Debug)] pub struct TestOutcome { @@ -32,6 +48,9 @@ pub struct TestOutcome { /// /// Essentially `identifier => signature => result`. pub results: BTreeMap, + /// Complete results for JSON file output, including suites hidden from fail-fast console + /// output. + pub(crate) json_file_results: Option>, /// Whether to allow test failures without failing the entire test run. pub allow_failure: bool, /// The decoder used to decode traces and logs. @@ -42,17 +61,34 @@ pub struct TestOutcome { pub last_run_decoder: Option, /// The gas report, if requested. pub gas_report: Option, + /// Known contracts from the test run (used for coverage). + pub known_contracts: Option, + /// The fuzz seed used for the test run. + pub fuzz_seed: Option, } impl TestOutcome { /// Creates a new test outcome with the given results. - pub fn new(results: BTreeMap, allow_failure: bool) -> Self { - Self { results, allow_failure, last_run_decoder: None, gas_report: None } + pub const fn new( + known_contracts: Option, + results: BTreeMap, + allow_failure: bool, + fuzz_seed: Option, + ) -> Self { + Self { + results, + json_file_results: None, + allow_failure, + last_run_decoder: None, + gas_report: None, + known_contracts, + fuzz_seed, + } } /// Creates a new empty test outcome. - pub fn empty(allow_failure: bool) -> Self { - Self::new(BTreeMap::new(), allow_failure) + pub const fn empty(known_contracts: Option, allow_failure: bool) -> Self { + Self::new(known_contracts, BTreeMap::new(), allow_failure, None) } /// Returns an iterator over all individual succeeding tests and their names. @@ -75,51 +111,47 @@ impl TestOutcome { self.results.values().flat_map(|suite| suite.tests()) } - /// Flattens the test outcome into a list of individual tests. - // TODO: Replace this with `tests` and make it return `TestRef<'_>` - pub fn into_tests_cloned(&self) -> impl Iterator + '_ { - self.results - .iter() - .flat_map(|(file, suite)| { - suite - .test_results - .iter() - .map(move |(sig, result)| (file.clone(), sig.clone(), result.clone())) - }) - .map(|(artifact_id, signature, result)| SuiteTestResult { - artifact_id, - signature, - result, - }) - } - /// Flattens the test outcome into a list of individual tests. pub fn into_tests(self) -> impl Iterator { - self.results - .into_iter() - .flat_map(|(file, suite)| { - suite.test_results.into_iter().map(move |t| (file.clone(), t)) - }) - .map(|(artifact_id, (signature, result))| SuiteTestResult { - artifact_id, + self.results.into_iter().flat_map(|(artifact_id, suite)| { + suite.test_results.into_iter().map(move |(signature, result)| SuiteTestResult { + artifact_id: artifact_id.clone(), signature, result, }) + }) } /// Returns the number of tests that passed. pub fn passed(&self) -> usize { - self.successes().count() + self.results.values().map(SuiteResult::passed).sum() } /// Returns the number of tests that were skipped. pub fn skipped(&self) -> usize { - self.skips().count() + self.results.values().map(SuiteResult::skipped).sum() } /// Returns the number of tests that failed. pub fn failed(&self) -> usize { - self.failures().count() + self.results.values().map(SuiteResult::failed).sum() + } + + /// Returns `true` if any fuzz or invariant test failed. + pub fn has_fuzz_failures(&self) -> bool { + self.failures().any(|(_, t)| t.kind.is_fuzz() || t.kind.is_invariant()) + } + + /// Returns `true` if all failing tests can be meaningfully inspected with `forge test --debug`. + fn failed_tests_are_debuggable(&self) -> bool { + self.failures().all(|(_, result)| result.is_debuggable_failure()) + } + + /// Returns the shared parallel worker count of all failing invariant tests, if they agree. + fn invariant_workers_hint(&self) -> Option { + let mut workers = self.failures().filter_map(|(_, result)| result.kind.invariant_workers()); + let first = workers.next()?; + (first > 1 && workers.all(|workers| workers == first)).then_some(first) } /// Sums up all the durations of all individual test suites. @@ -133,38 +165,30 @@ impl TestOutcome { pub fn summary(&self, wall_clock_time: Duration) -> String { let num_test_suites = self.results.len(); let suites = if num_test_suites == 1 { "suite" } else { "suites" }; - let total_passed = self.passed(); - let total_failed = self.failed(); - let total_skipped = self.skipped(); - let total_tests = total_passed + total_failed + total_skipped; + let (passed, failed, skipped) = (self.passed(), self.failed(), self.skipped()); format!( - "\nRan {} test {} in {:.2?} ({:.2?} CPU time): {} tests passed, {} failed, {} skipped ({} total tests)", - num_test_suites, - suites, - wall_clock_time, + "\nRan {num_test_suites} test {suites} in {wall_clock_time:.2?} ({:.2?} CPU time): {} tests passed, {} failed, {} skipped ({} total tests)", self.total_time(), - total_passed.green(), - total_failed.red(), - total_skipped.yellow(), - total_tests + passed.green(), + failed.red(), + skipped.yellow(), + passed + failed + skipped ) } /// Checks if there are any failures and failures are disallowed. pub fn ensure_ok(&self, silent: bool) -> eyre::Result<()> { - let outcome = self; - let failures = outcome.failures().count(); - if outcome.allow_failure || failures == 0 { + let failures = self.failures().count(); + if self.allow_failure || failures == 0 { return Ok(()); } if shell::is_quiet() || silent { - // TODO: Avoid process::exit std::process::exit(1); } sh_println!("\nFailing tests:")?; - for (suite_name, suite) in &outcome.results { + for (suite_name, suite) in &self.results { let failed = suite.failed(); if failed == 0 { continue; @@ -173,30 +197,53 @@ impl TestOutcome { let term = if failed > 1 { "tests" } else { "test" }; sh_println!("Encountered {failed} failing {term} in {suite_name}")?; for (name, result) in suite.failures() { - sh_println!("{}", result.short_result(name))?; + sh_println!("{}", result.short_result_with_suite(name, suite_name))?; } sh_println!()?; } - let successes = outcome.passed(); sh_println!( "Encountered a total of {} failing tests, {} tests succeeded", failures.to_string().red(), - successes.to_string().green() + self.passed().to_string().green() )?; - // TODO: Avoid process::exit + let test_word = if failures == 1 { "test" } else { "tests" }; + sh_println!( + "\nTip: Run {} to retry only the {failures} failed {test_word}", + "`forge test --rerun`".cyan() + )?; + if self.failed_tests_are_debuggable() { + sh_println!( + "Tip: Run {} to inspect one failing test in the debugger", + "`forge test --debug --match-test `".cyan() + )?; + } + + // Print seed for fuzz/invariant test failures to enable reproduction. + if let Some(seed) = self.fuzz_seed + && self.has_fuzz_failures() + { + sh_println!( + "\nFuzz seed: {} (use {} to reproduce)", + format!("{seed:#x}").cyan(), + "`--fuzz-seed`".cyan() + )?; + if let Some(invariant_workers) = self.invariant_workers_hint() { + sh_println!( + "Invariant workers: {invariant_workers} (use {} to reproduce)", + format!("`--invariant-workers {invariant_workers}`").cyan() + )?; + } + } + std::process::exit(1); } /// Removes first test result, if any. pub fn remove_first(&mut self) -> Option<(String, String, TestResult)> { self.results.iter_mut().find_map(|(suite_name, suite)| { - if let Some(test_name) = suite.test_results.keys().next().cloned() { - let result = suite.test_results.remove(&test_name).unwrap(); - Some((suite_name.clone(), test_name, result)) - } else { - None - } + let (test_name, result) = suite.test_results.pop_first()?; + Some((suite_name.clone(), test_name, result)) }) } } @@ -220,10 +267,10 @@ impl SuiteResult { mut warnings: Vec, ) -> Self { // Add deprecated cheatcodes warning, if any of them used in current test suite. - let mut deprecated_cheatcodes = HashMap::new(); - for test_result in test_results.values() { - deprecated_cheatcodes.extend(test_result.deprecated_cheatcodes.clone()); - } + let deprecated_cheatcodes = test_results + .values() + .flat_map(|result| result.deprecated_cheatcodes.iter().map(|(k, v)| (*k, *v))) + .collect::>(); if !deprecated_cheatcodes.is_empty() { let mut warning = "the following cheatcode(s) are deprecated and will be removed in future versions:" @@ -257,17 +304,17 @@ impl SuiteResult { /// Returns the number of tests that passed. pub fn passed(&self) -> usize { - self.successes().count() + self.test_results.values().filter(|t| t.status.is_success()).count() } /// Returns the number of tests that were skipped. pub fn skipped(&self) -> usize { - self.skips().count() + self.test_results.values().map(TestResult::skipped_count).sum() } /// Returns the number of tests that failed. pub fn failed(&self) -> usize { - self.failures().count() + self.test_results.values().filter(|t| t.status.is_failure()).count() } /// Iterator over all tests and their names @@ -282,7 +329,7 @@ impl SuiteResult { /// The number of tests in this test suite. pub fn len(&self) -> usize { - self.test_results.len() + self.test_results.values().map(TestResult::logical_count).sum() } /// Sums up all the durations of all individual tests in this suite. @@ -297,8 +344,7 @@ impl SuiteResult { let failed = self.failed(); let result = if failed == 0 { "ok".green() } else { "FAILED".red() }; format!( - "Suite result: {}. {} passed; {} failed; {} skipped; finished in {:.2?} ({:.2?} CPU time)", - result, + "Suite result: {result}. {} passed; {} failed; {} skipped; finished in {:.2?} ({:.2?} CPU time)", self.passed().green(), failed.red(), self.skipped().yellow(), @@ -324,7 +370,7 @@ pub struct SuiteTestResult { impl SuiteTestResult { /// Returns the gas used by the test. - pub fn gas_used(&self) -> u64 { + pub const fn gas_used(&self) -> u64 { self.result.kind.report().gas() } @@ -332,11 +378,6 @@ impl SuiteTestResult { pub fn contract_name(&self) -> &str { get_contract_name(&self.artifact_id) } - - /// Returns the file name of the artifact ID. - pub fn file_name(&self) -> &str { - get_file_name(&self.artifact_id) - } } /// The status of a test. @@ -351,39 +392,909 @@ pub enum TestStatus { impl TestStatus { /// Returns `true` if the test was successful. #[inline] - pub fn is_success(self) -> bool { + pub const fn is_success(self) -> bool { matches!(self, Self::Success) } /// Returns `true` if the test failed. #[inline] - pub fn is_failure(self) -> bool { + pub const fn is_failure(self) -> bool { matches!(self, Self::Failure) } /// Returns `true` if the test was skipped. #[inline] - pub fn is_skipped(self) -> bool { + pub const fn is_skipped(self) -> bool { matches!(self, Self::Skipped) } } +/// A failure surfaced by an invariant test campaign — either a broken `invariant_*` +/// predicate ([`Self::Predicate`]) or a handler-side assertion bug ([`Self::Handler`]). +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum InvariantFailure { + /// A broken `invariant_*` predicate. + Predicate { + /// Invariant function name (e.g. `invariant_cond3`). + name: String, + /// Revert reason or assertion failure message. + reason: String, + /// Counterexample sequence, when one is available. + #[serde(default, skip_serializing_if = "Option::is_none")] + counterexample: Option, + /// Durable replay artifact for this counterexample, when one was written. + #[serde(default, skip_serializing_if = "Option::is_none")] + artifact: Option, + /// Deterministic concrete minimization details for this sequence, when minimized. + #[serde(default, skip_serializing_if = "Option::is_none")] + minimization: Option, + /// Path where the counterexample was persisted for re-running and shrinking. + persisted_path: PathBuf, + /// Whether this failure is the stable campaign anchor. + /// When `true` and this is the only single-predicate failure, the function name is + /// omitted on the `[FAIL: ...]` line (the trailing summary already identifies it). + #[serde(default)] + is_anchor: bool, + }, + /// A handler-side assertion bug discovered during the campaign. + Handler { + /// Best-effort human-readable name of the failing call, e.g. `Counter::increment` or + /// `0xabc...::0x12345678` when the contract/function cannot be resolved. + name: String, + /// Address of the handler whose call asserted/reverted with an assertion. + reverter: Address, + /// 4-byte selector of the failing handler function. + selector: Selector, + /// Decoded revert/assert reason. + reason: String, + /// Counterexample sequence leading up to (and including) the failing call. + #[serde(default, skip_serializing_if = "Option::is_none")] + counterexample: Option, + /// Durable replay artifact for this counterexample, when one was written. + #[serde(default, skip_serializing_if = "Option::is_none")] + artifact: Option, + }, +} + +impl InvariantFailure { + /// Reason rendered on the `[FAIL: ...]` line. + pub fn reason(&self) -> &str { + match self { + Self::Predicate { reason, .. } | Self::Handler { reason, .. } => reason, + } + } + + /// Human-readable name (invariant fn name, or `Contract::function` for handler bugs). + pub fn name(&self) -> &str { + match self { + Self::Predicate { name, .. } | Self::Handler { name, .. } => name, + } + } + + /// Invariant predicate name, if this is a predicate failure. + pub fn predicate_name(&self) -> Option<&str> { + match self { + Self::Predicate { name, .. } => Some(name), + Self::Handler { .. } => None, + } + } + + /// Counterexample sequence, when one is available. + pub const fn counterexample(&self) -> Option<&CounterExample> { + match self { + Self::Predicate { counterexample, .. } | Self::Handler { counterexample, .. } => { + counterexample.as_ref() + } + } + } + + /// Durable replay artifact for this failure, when one was written. + pub const fn artifact(&self) -> Option<&SymbolicArtifactRef> { + match self { + Self::Predicate { artifact, .. } | Self::Handler { artifact, .. } => artifact.as_ref(), + } + } + + /// Deterministic concrete minimization details for predicate failures. + pub const fn minimization(&self) -> Option<&SymbolicCounterexampleMinimization> { + match self { + Self::Predicate { minimization, .. } => minimization.as_ref(), + Self::Handler { .. } => None, + } + } +} + +/// Pass/fail status for an invariant predicate evaluated inside a contract-level campaign. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct InvariantPredicateResult { + /// Invariant function name (e.g. `invariant_balance`). + pub name: String, + /// Predicate status within the logical campaign. + pub status: TestStatus, + /// Revert reason or assertion message when the predicate failed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +/// Stable machine-readable outcome for `forge test --symbolic` JSON output. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SymbolicResult { + /// Schema version for the symbolic result object. + #[serde(default = "symbolic_result_schema_version")] + pub schema_version: u32, + /// Normalized symbolic outcome. + pub status: SymbolicResultStatus, + /// Incomplete reason when [`Self::status`] is [`SymbolicResultStatus::Incomplete`]. + pub incomplete: Option, + /// Effective bounds used by this symbolic run. + pub bounds: SymbolicBounds, + /// Solver identity and counters collected during this run. + pub solver: SymbolicSolverMetadata, + /// Soundness assumptions that bound what a `pass` proves. + pub assumptions: Vec, + /// Where an agent can find the concrete replay trace, when one was produced. + pub call_trace: SymbolicCallTrace, + /// Concrete replay metadata for counterexample candidates. + pub replay: SymbolicReplayMetadata, + /// Concrete counterexample data, when the solver produced a candidate. + pub counterexample: Option, + /// Fuzz corpus seeds imported into symbolic execution, when enabled. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub corpus_seeds: Option, + /// Durable counterexample artifact, when one was written. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub artifact: Option, + /// Deterministic concrete minimization details, when a replayed counterexample was minimized. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub minimization: Option, +} + +impl SymbolicResult { + /// Creates a symbolic pass result. + pub fn pass(config: &SymbolicConfig, stats: SymbolicStats) -> Self { + Self::base(config, stats) + } + + /// Creates a symbolic counterexample result that concrete replay confirmed. + pub fn fail_counterexample( + config: &SymbolicConfig, + stats: SymbolicStats, + call_trace: SymbolicCallTrace, + counterexample: SymbolicCounterexample, + ) -> Self { + Self { + counterexample: Some(counterexample), + ..Self::fail_counterexample_sequence(config, stats, call_trace) + } + } + + /// Creates a symbolic sequence counterexample result that concrete replay confirmed. + pub fn fail_counterexample_sequence( + config: &SymbolicConfig, + stats: SymbolicStats, + call_trace: SymbolicCallTrace, + ) -> Self { + Self { + status: SymbolicResultStatus::FailCounterexample, + replay: SymbolicReplayMetadata::confirmed(), + call_trace, + ..Self::base(config, stats) + } + } + + /// Creates an incomplete symbolic result. + pub fn incomplete( + config: &SymbolicConfig, + kind: SymbolicStopReason, + reason: impl Into, + stats: SymbolicStats, + replay: SymbolicReplayMetadata, + call_trace: SymbolicCallTrace, + counterexample: Option, + ) -> Self { + Self { + status: SymbolicResultStatus::Incomplete, + incomplete: Some(SymbolicIncomplete::new(kind, reason)), + replay, + call_trace, + counterexample, + ..Self::base(config, stats) + } + } + + /// A passing result carrying the run's bounds, solver metadata and assumptions. + fn base(config: &SymbolicConfig, stats: SymbolicStats) -> Self { + Self { + schema_version: SYMBOLIC_RESULT_SCHEMA_VERSION, + status: SymbolicResultStatus::Pass, + incomplete: None, + bounds: SymbolicBounds::from_config(config), + solver: SymbolicSolverMetadata { + name: config.solver.clone(), + command: config.solver_command.clone(), + portfolio: config.solver_portfolio.clone(), + stats, + }, + assumptions: SymbolicAssumption::default_assumptions(), + call_trace: SymbolicCallTrace::none(), + replay: SymbolicReplayMetadata::not_required(), + counterexample: None, + corpus_seeds: None, + artifact: None, + minimization: None, + } + } + + /// Attaches fuzz corpus import metadata to this symbolic result. + pub fn with_corpus_seeds(mut self, corpus_seeds: SymbolicCorpusSeedMetadata) -> Self { + self.corpus_seeds = Some(corpus_seeds); + self + } + + /// Attaches a durable replay artifact reference to this symbolic result. + pub fn with_artifact(mut self, artifact: SymbolicArtifactRef) -> Self { + self.artifact = Some(artifact); + self + } + + /// Attaches deterministic minimization metadata to this symbolic result. + pub fn with_minimization(mut self, minimization: SymbolicCounterexampleMinimization) -> Self { + self.minimization = Some(minimization); + self + } +} + +/// Fuzz corpus import metadata for a symbolic run. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SymbolicCorpusSeedMetadata { + /// Corpus root used for the current test, after contract/test path expansion. + pub corpus_dir: Option, + /// Maximum imported seeds allowed by configuration. + pub limit: usize, + /// Number of corpus files considered. + pub loaded: usize, + /// Number of corpus files skipped because they were unreadable or not a matching single call. + pub skipped: usize, + /// Seeds modeled by symbolic execution as path-priority hints. + pub used: Vec, +} + +/// One fuzz corpus seed modeled by symbolic execution. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SymbolicCorpusSeedRef { + /// Corpus file path. + pub path: PathBuf, + /// ABI-encoded calldata imported from the corpus file. + pub calldata: Bytes, +} + +/// Reference to a durable symbolic counterexample artifact. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SymbolicArtifactRef { + /// Artifact schema id. + pub schema: String, + /// Path to the artifact file. + pub path: PathBuf, +} + +impl SymbolicArtifactRef { + /// Creates a reference to a symbolic counterexample artifact. + pub fn new(path: impl Into) -> Self { + Self { schema: SYMBOLIC_COUNTEREXAMPLE_ARTIFACT_SCHEMA.to_string(), path: path.into() } + } +} + +/// Reference to a generated Solidity regression test for a symbolic counterexample. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SymbolicRegressionRef { + /// Source counterexample artifact path. + pub artifact: PathBuf, + /// Generated Solidity regression test path. + pub path: PathBuf, +} + +/// Before/after artifact references and counters for concrete symbolic counterexample minimization. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SymbolicCounterexampleMinimization { + /// Original confirmed replay artifact before minimization. + pub original: SymbolicArtifactRef, + /// Minimized confirmed replay artifact after minimization. + pub minimized: SymbolicArtifactRef, + /// Number of concrete replay candidates tried. + pub attempts: usize, + /// Number of replay candidates accepted. + pub accepted: usize, + /// ABI calldata byte length before minimization. + pub original_calldata_bytes: usize, + /// ABI calldata byte length after minimization. + pub minimized_calldata_bytes: usize, + /// Stateful sequence length before minimization, when this minimized a sequence. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub original_sequence_len: Option, + /// Stateful sequence length after minimization, when this minimized a sequence. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub minimized_sequence_len: Option, +} + +impl SymbolicCounterexampleMinimization { + /// Creates concrete minimization metadata. + pub const fn new( + original: SymbolicArtifactRef, + minimized: SymbolicArtifactRef, + attempts: usize, + accepted: usize, + original_calldata_bytes: usize, + minimized_calldata_bytes: usize, + ) -> Self { + Self { + original, + minimized, + attempts, + accepted, + original_calldata_bytes, + minimized_calldata_bytes, + original_sequence_len: None, + minimized_sequence_len: None, + } + } + + /// Adds stateful sequence lengths to minimization metadata. + pub const fn with_sequence_lengths( + mut self, + original_sequence_len: usize, + minimized_sequence_len: usize, + ) -> Self { + self.original_sequence_len = Some(original_sequence_len); + self.minimized_sequence_len = Some(minimized_sequence_len); + self + } +} + +/// Normalized symbolic outcome names for agents and other JSON consumers. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SymbolicResultStatus { + /// All explored paths completed without a feasible failure. + Pass, + /// A solver counterexample was replayed concretely and still failed. + FailCounterexample, + /// The engine stopped before a proof or replayed counterexample. + Incomplete, +} + +/// Incomplete symbolic run reason. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SymbolicIncomplete { + /// Stable reason kind. + pub kind: String, + /// Human-readable detail. + pub reason: String, +} + +impl SymbolicIncomplete { + fn new(kind: SymbolicStopReason, reason: impl Into) -> Self { + let kind = match kind { + SymbolicStopReason::Stuck => "stuck", + SymbolicStopReason::RevertAll => "revert_all", + SymbolicStopReason::Timeout => "timeout", + SymbolicStopReason::Error => "error", + }; + Self { kind: kind.to_string(), reason: reason.into() } + } +} + +/// Effective symbolic exploration bounds used by the run. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SymbolicBounds { + /// Optional solver timeout in seconds. + pub timeout_seconds: Option, + /// Optional loop-unrolling bound. + pub loop_bound: Option, + /// Effective per-path opcode depth limit. + pub max_depth: u32, + /// Effective symbolic path width limit. + pub max_paths: u32, + /// Maximum calls in a bounded symbolic invariant sequence. + pub invariant_depth: u32, + /// Pending path exploration order. + pub exploration_order: SymbolicExplorationOrder, + /// Maximum normalized solver queries. + pub max_solver_queries: u32, + /// Default bounded length for dynamic ABI inputs. + pub default_dynamic_length: u32, + /// Maximum permitted bounded dynamic ABI input length. + pub max_dynamic_length: u32, + /// Positional dynamic-leaf bounded lengths. + pub array_lengths: Vec, + /// Named dynamic-leaf bounded lengths. + pub dynamic_lengths: BTreeMap>, + /// Default array lengths when no explicit dynamic length exists. + pub default_array_lengths: Vec, + /// Default bytes/string lengths when no explicit dynamic length exists. + pub default_bytes_lengths: Vec, + /// Maximum generated symbolic calldata size in bytes. + pub max_calldata_bytes: u32, + /// Whether symbolic call targets can range over known deployed contracts. + pub symbolic_call_targets: bool, + /// Storage modelling mode. + pub storage_layout: SymbolicStorageLayout, +} + +impl SymbolicBounds { + fn from_config(config: &SymbolicConfig) -> Self { + Self { + timeout_seconds: config.timeout, + loop_bound: config.loop_bound, + max_depth: config.execution_depth(), + max_paths: config.path_width(), + invariant_depth: config.invariant_depth, + exploration_order: config.exploration_order, + max_solver_queries: config.max_solver_queries, + default_dynamic_length: config.default_dynamic_length, + max_dynamic_length: config.max_dynamic_length, + array_lengths: config.array_lengths.clone(), + dynamic_lengths: config.dynamic_lengths.clone(), + default_array_lengths: config.default_array_lengths.clone(), + default_bytes_lengths: config.default_bytes_lengths.clone(), + max_calldata_bytes: config.max_calldata_bytes, + symbolic_call_targets: config.symbolic_call_targets, + storage_layout: config.storage_layout, + } + } +} + +/// Solver identity and counters. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SymbolicSolverMetadata { + /// Configured solver name. + pub name: String, + /// Exact configured solver command, when set. + pub command: Option, + /// Configured solver portfolio entries, when any. + pub portfolio: Vec, + /// Run counters. + pub stats: SymbolicStats, +} + +/// Explicit symbolic assumption attached to a result. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SymbolicAssumption { + /// Stable assumption kind. + pub kind: String, + /// Human-readable detail. + pub description: String, +} + +impl SymbolicAssumption { + fn default_assumptions() -> Vec { + vec![ + Self { + kind: "bounded_exploration".to_string(), + description: "Result is scoped to the configured path, depth, solver-query, loop, calldata, and dynamic-length bounds.".to_string(), + }, + Self { + kind: "hash_model".to_string(), + description: "Symbolic Keccak and hash-like precompile reasoning assumes collision and preimage resistance for modeled cases.".to_string(), + }, + ] + } +} + +/// Concrete replay trace locator. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SymbolicCallTrace { + /// Whether replay produced a trace that may be present in this test result. + pub available: bool, + /// JSON location for the trace when available. + pub source: Option, + /// Trace format at the source location. + pub format: Option, +} + +impl SymbolicCallTrace { + /// No concrete trace was produced. + pub const fn none() -> Self { + Self { available: false, source: None, format: None } + } + + /// A concrete replay trace may be available in the normal test result traces field. + pub fn test_result_traces(available: bool) -> Self { + Self { + available, + source: available.then(|| "test_result.traces".to_string()), + format: available.then(|| "foundry_call_trace_arena".to_string()), + } + } +} + +/// Counterexample replay status. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SymbolicReplayStatus { + /// No replay was required for this result. + NotRequired, + /// Concrete replay confirmed the symbolic counterexample. + Confirmed, + /// Concrete replay did not reproduce the symbolic counterexample. + Mismatch, + /// Concrete replay could not execute because of an error. + Error, + /// Concrete replay was skipped by `vm.skip`. + Skipped, +} + +/// Replay metadata for symbolic counterexample candidates. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SymbolicReplayMetadata { + /// Whether the symbolic outcome required concrete replay. + pub required: bool, + /// Stable replay status. + pub status: SymbolicReplayStatus, + /// Optional replay detail or mismatch reason. + pub reason: Option, +} + +impl SymbolicReplayMetadata { + /// No replay was required. + pub const fn not_required() -> Self { + Self { required: false, status: SymbolicReplayStatus::NotRequired, reason: None } + } + + /// Concrete replay confirmed the counterexample. + pub const fn confirmed() -> Self { + Self { required: true, status: SymbolicReplayStatus::Confirmed, reason: None } + } + + /// Concrete replay did not reproduce the symbolic counterexample. + pub fn mismatch(reason: impl Into) -> Self { + Self { required: true, status: SymbolicReplayStatus::Mismatch, reason: Some(reason.into()) } + } + + /// Concrete replay errored before the candidate could be confirmed. + pub fn error(reason: impl Into) -> Self { + Self { required: true, status: SymbolicReplayStatus::Error, reason: Some(reason.into()) } + } + + /// Concrete replay was skipped by the test. + pub fn skipped(reason: impl Into) -> Self { + Self { required: true, status: SymbolicReplayStatus::Skipped, reason: Some(reason.into()) } + } +} + +/// Stable symbolic counterexample payload. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SymbolicCounterexample { + /// ABI-encoded calldata for replay. + pub calldata: Bytes, + /// Pretty-formatted ABI arguments, when decoded. + pub args: Option, + /// Raw ABI arguments, when decoded. + pub raw_args: Option, + /// Ether value sent with the call, when any. + pub value: Option, +} + +impl From<&BaseCounterExample> for SymbolicCounterexample { + fn from(counterexample: &BaseCounterExample) -> Self { + Self { + calldata: counterexample.calldata.clone(), + args: counterexample.args.clone(), + raw_args: counterexample.raw_args.clone(), + value: counterexample.value, + } + } +} + +/// Durable symbolic counterexample artifact. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SymbolicCounterexampleArtifact { + /// Artifact schema version. + pub schema_version: u32, + /// Artifact schema id. + pub schema: String, + /// Whether this counterexample is a single test call or a stateful sequence. + pub kind: SymbolicCounterexampleArtifactKind, + /// Test identity that produced this counterexample. + pub test: SymbolicCounterexampleTestIdentity, + /// Concrete replay metadata for the counterexample candidate. + pub replay: SymbolicReplayMetadata, + /// Replay semantics that must remain stable when this artifact is replayed. + pub replay_semantics: SymbolicCounterexampleReplaySemantics, + /// Effective bounds used by this symbolic run. + pub bounds: SymbolicBounds, + /// Solver identity and counters collected during this run. + pub solver: SymbolicSolverMetadata, + /// Soundness assumptions that bound what a `pass` proves. + pub assumptions: Vec, + /// Where an agent can find the concrete replay trace, when one was produced. + pub call_trace: SymbolicCallTrace, + /// Concrete setup-storage assignments required before replaying this artifact. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub storage: Vec, + /// Stateful invariant failure origin, when this sequence came from symbolic invariants. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub invariant_failure: Option, + /// Concrete replay calls. + pub calls: Vec, +} + +impl SymbolicCounterexampleArtifact { + /// Creates a durable symbolic counterexample artifact from a symbolic result and call list. + pub fn new( + kind: SymbolicCounterexampleArtifactKind, + test: SymbolicCounterexampleTestIdentity, + symbolic: &SymbolicResult, + replay_semantics: SymbolicCounterexampleReplaySemantics, + calls: Vec, + ) -> Self { + Self { + schema_version: SYMBOLIC_COUNTEREXAMPLE_ARTIFACT_SCHEMA_VERSION, + schema: SYMBOLIC_COUNTEREXAMPLE_ARTIFACT_SCHEMA.to_string(), + kind, + test, + replay: symbolic.replay.clone(), + replay_semantics, + bounds: symbolic.bounds.clone(), + solver: symbolic.solver.clone(), + assumptions: symbolic.assumptions.clone(), + call_trace: symbolic.call_trace.clone(), + storage: Vec::new(), + invariant_failure: None, + calls, + } + } + + /// Attaches setup-storage assignments required for concrete replay. + pub fn with_storage(mut self, storage: Vec) -> Self { + self.storage = storage; + self + } + + /// Attaches stateful invariant failure origin metadata. + pub fn with_invariant_failure( + mut self, + invariant_failure: SymbolicInvariantArtifactFailure, + ) -> Self { + self.invariant_failure = Some(invariant_failure); + self + } +} + +/// Concrete replay semantics captured when a symbolic artifact is confirmed. +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SymbolicCounterexampleReplaySemantics { + /// Whether an invariant sequence replay treats any target-call revert as a failure. + pub fail_on_revert: bool, +} + +/// Symbolic counterexample artifact shape. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SymbolicCounterexampleArtifactKind { + /// A single stateless symbolic test call. + SingleCall, + /// A stateful sequence of calls. + Sequence, +} + +/// Stateful invariant failure origin for a persisted symbolic sequence artifact. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum SymbolicInvariantArtifactFailure { + /// An invariant predicate failed. + Predicate { + /// Invariant function name. + name: String, + /// Exact concrete failure site confirmed during replay. + #[serde(default, skip_serializing_if = "Option::is_none")] + site: Option, + }, + /// A target/handler call asserted before an invariant predicate failed. + Handler { + /// Best-effort human-readable handler function name. + #[serde(default, skip_serializing_if = "Option::is_none")] + name: Option, + /// Address of the handler whose call asserted. + reverter: Address, + /// 4-byte selector of the failing handler call. + selector: Selector, + /// Stable edge fingerprint for the failing handler site. + fingerprint: B256, + }, +} + +/// Concrete invariant failure site stored in symbolic replay artifacts. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum SymbolicInvariantFailureSite { + /// Target/handler call failed before the invariant predicate. + SequenceCall { target: Address, selector: Selector, fingerprint: B256 }, + /// Invariant predicate failed. + Invariant { target: Address, selector: Selector, fingerprint: B256 }, + /// `afterInvariant` hook failed. + AfterInvariant { target: Address, selector: Selector, fingerprint: B256 }, +} + +impl From for SymbolicInvariantFailureSite { + fn from(site: CheckSequenceFailureSite) -> Self { + match site { + CheckSequenceFailureSite::SequenceCall { target, selector, fingerprint } => { + Self::SequenceCall { target, selector, fingerprint } + } + CheckSequenceFailureSite::Invariant { target, selector, fingerprint } => { + Self::Invariant { target, selector, fingerprint } + } + CheckSequenceFailureSite::AfterInvariant { target, selector, fingerprint } => { + Self::AfterInvariant { target, selector, fingerprint } + } + } + } +} + +/// Test identity for a symbolic counterexample artifact. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SymbolicCounterexampleTestIdentity { + /// Contract identifier as reported by Forge. + pub contract: String, + /// Test function signature. + pub test: String, +} + +/// One concrete call in a symbolic counterexample artifact. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SymbolicCounterexampleCall { + /// Amount to increase block timestamp before executing the call. + pub warp: Option, + /// Amount to increase block number before executing the call. + pub roll: Option, + /// Sender used for the call. + pub sender: Address, + /// Target address called. + pub target: Address, + /// ABI-encoded calldata for replay. + pub calldata: Bytes, + /// Ether value sent with the call, when any. + pub value: Option, + /// Human-readable contract identifier, when known. + pub contract_name: Option, + /// ABI function name, when known. + pub function_name: Option, + /// ABI function signature, when known. + pub signature: Option, + /// Pretty-formatted ABI arguments, when decoded. + pub args: Option, + /// Raw ABI arguments, when decoded. + pub raw_args: Option, +} + +impl SymbolicCounterexampleCall { + /// Creates an artifact call from Foundry's base counterexample shape. + pub fn from_base_counterexample( + counterexample: &BaseCounterExample, + default_sender: Address, + default_target: Address, + ) -> Self { + Self { + warp: counterexample.warp, + roll: counterexample.roll, + sender: counterexample.sender.unwrap_or(default_sender), + target: counterexample.addr.unwrap_or(default_target), + calldata: counterexample.calldata.clone(), + value: counterexample.value, + contract_name: counterexample.contract_name.clone(), + function_name: counterexample.func_name.clone(), + signature: counterexample.signature.clone(), + args: counterexample.args.clone(), + raw_args: counterexample.raw_args.clone(), + } + } + + /// Creates Foundry's display counterexample shape from an artifact call. + pub fn to_base_counterexample(&self) -> BaseCounterExample { + BaseCounterExample { + warp: self.warp, + roll: self.roll, + sender: Some(self.sender), + addr: Some(self.target), + calldata: self.calldata.clone(), + value: self.value, + contract_name: self.contract_name.clone(), + func_name: self.function_name.clone(), + signature: self.signature.clone(), + args: self.args.clone(), + raw_args: self.raw_args.clone(), + traces: None, + show_solidity: false, + fuzz: Default::default(), + } + } + + /// Converts an artifact call into Foundry's invariant replay transaction shape. + pub fn to_basic_tx_details(&self) -> BasicTxDetails { + BasicTxDetails { + warp: self.warp, + roll: self.roll, + sender: self.sender, + call_details: CallDetails { + target: self.target, + calldata: self.calldata.clone(), + value: self.value, + }, + } + } +} + /// The result of an executed test. #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct TestResult { /// The test status, indicating whether the test case succeeded, failed, or was marked as - /// skipped. This means that the transaction executed properly, the test was marked as - /// skipped with vm.skip(), or that there was a revert and that the test was expected to - /// fail (prefixed with `testFail`) + /// skipped. This means that the transaction executed properly, or the test was marked as + /// skipped with vm.skip(). pub status: TestStatus, - /// If there was a revert, this field will be populated. Note that the test can - /// still be successful (i.e self.success == true) when it's expected to fail. + /// If there was a revert, this field will be populated. pub reason: Option, + /// The active fork's block number after execution, if any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fork_block_number: Option, + + /// All broken invariant predicates in this campaign in source declaration order. + /// + /// For invariant tests, this is the single source of truth used by the renderer. + /// `reason` and `counterexample` are not populated for invariant tests. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub invariant_failures: Vec, + + /// Per-predicate outcomes for invariant campaigns. This preserves individual + /// `invariant_*` / `statefulFuzz*` pass/fail reporting when multiple predicates are checked + /// by one contract-level campaign. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub invariant_predicate_results: 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, + + /// Total number of invariant predicates exercised in this campaign. When `Some(n)` the + /// user-facing report renders a contract-level `/ invariants broken` summary so + /// users get an at-a-glance health line without counting `[FAIL]` blocks. `None` for + /// single-predicate campaigns. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub invariant_count: Option, + + /// Handler-side assertion bugs found during the campaign, deduped by + /// `(reverter, selector)` site (Medusa/Echidna semantics). Rendered in a dedicated + /// `Assertion Tests` section. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub invariant_handler_failures: Vec, + /// Minimal reproduction test case for failing test pub counterexample: Option, + /// Legacy durable replay artifact for the top-level counterexample, when one was written. + /// + /// Prefer [`Self::counterexample_artifacts`] for new consumers; this compatibility field is + /// maintained by [`Self::add_counterexample_artifact`] for older JSON readers. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub counterexample_artifact: Option, + + /// All durable replay artifacts produced for this test result, normalized for JSON consumers. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub counterexample_artifacts: Vec, + + /// Generated Solidity regression tests for this symbolic counterexample. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub symbolic_regressions: Vec, + /// Any captured & parsed as strings logs along the test's execution which should /// be printed to the user. pub logs: Vec, @@ -395,10 +1306,20 @@ pub struct TestResult { /// What kind of test this was pub kind: TestKind, + /// Stable symbolic result object for `forge test --symbolic --json`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub symbolic: Option, + /// Traces pub traces: Traces, + /// Runtime bytecodes for contracts seen in debug traces. + #[serde(skip)] + pub debug_bytecodes: AddressHashMap, + /// Additional traces to use for gas report. + /// + /// These are cleared after the gas report is analyzed. #[serde(skip)] pub gas_report_traces: Vec>, @@ -407,7 +1328,8 @@ pub struct TestResult { pub line_coverage: Option, /// Labeled addresses - pub labeled_addresses: AddressHashMap, + #[serde(rename = "labeled_addresses")] // Backwards compatibility. + pub labels: AddressHashMap, #[serde(with = "foundry_common::serde_helpers::duration")] pub duration: Duration, @@ -425,50 +1347,240 @@ pub struct TestResult { impl fmt::Display for TestResult { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.render(false, None)) + } +} + +/// Appends a `[label] (original: N, shrunk: M)` header followed by one line per call. +fn write_sequence(s: &mut String, label: &str, original: usize, sequence: &[BaseCounterExample]) { + writeln!(s, "\n\t[{label}] (original: {original}, shrunk: {})", sequence.len()).unwrap(); + for ex in sequence { + writeln!(s, "{ex}").unwrap(); + } +} + +/// Appends `[FAIL: reason]{name_suffix}` plus the counterexample sequence, if any. +/// +/// Returns `true` if a sequence (ending in a newline) was written. +fn write_failure(s: &mut String, failure: &InvariantFailure, name_suffix: &str) -> bool { + write!(s, "[FAIL: {}]{name_suffix}", failure.reason()).unwrap(); + if let Some(CounterExample::Sequence(original, sequence)) = failure.counterexample() { + write_sequence(s, "Sequence", *original, sequence); + return true; + } + false +} + +/// All durable replay artifacts referenced by a counterexample: its own artifact plus the +/// before/after artifacts of its minimization, if any. +fn replay_artifacts<'a>( + artifact: Option<&'a SymbolicArtifactRef>, + minimization: Option<&'a SymbolicCounterexampleMinimization>, +) -> impl Iterator { + artifact.into_iter().chain(minimization.into_iter().flat_map(|m| [&m.original, &m.minimized])) +} + +impl TestResult { + /// Returns `true` if this failed result can be meaningfully inspected with + /// `forge test --debug --match-test`. + const fn is_debuggable_failure(&self) -> bool { + self.status.is_failure() + && !self.kind.is_invariant() + && !self.kind.is_symbolic() + && self.symbolic.is_none() + } + + /// Adds a durable replay artifact to the normalized list and legacy top-level field. + pub fn add_counterexample_artifact(&mut self, artifact: SymbolicArtifactRef) { + if !self.counterexample_artifacts.contains(&artifact) { + self.counterexample_artifacts.push(artifact.clone()); + } + if self.counterexample_artifact.is_none() { + self.counterexample_artifact = Some(artifact); + } + } + + /// Renders the status block, either for the console (`user_facing`) or for JUnit output. + fn render(&self, user_facing: bool, campaign_name: Option<&str>) -> String { + let header = if user_facing { + campaign_name.unwrap_or(INVARIANT_CAMPAIGN_FALLBACK_NAME) + } else { + "Predicates" + }; + let mut s = String::new(); match self.status { - TestStatus::Success => "[PASS]".green().fmt(f), + TestStatus::Success => { + s.push_str("[PASS]"); + // For optimization mode, show the best example sequence in green. + if let Some(CounterExample::Sequence(original, sequence)) = &self.counterexample { + write_sequence(&mut s, "Best sequence", *original, sequence); + } + self.write_predicates(&mut s, header, true); + s.green().wrap().to_string() + } TestStatus::Skipped => { - let mut s = String::from("[SKIP"); + s.push_str("[SKIP"); if let Some(reason) = &self.reason { write!(s, ": {reason}").unwrap(); } s.push(']'); - s.yellow().fmt(f) + self.write_predicates(&mut s, header, true); + s.yellow().to_string() } TestStatus::Failure => { - let mut s = String::from("[FAIL"); - if self.reason.is_some() || self.counterexample.is_some() { + let is_invariant_failure = !self.invariant_failures.is_empty() + || !self.invariant_handler_failures.is_empty(); + if is_invariant_failure { + // Contract-level campaigns identify the broken predicate even when only one + // predicate failed. Preserve the compact legacy shape only for the anchor of a + // single-predicate run. + let named = self.invariant_count.is_some() || self.invariant_failures.len() > 1; + for (i, failure) in self.invariant_failures.iter().enumerate() { + if i > 0 { + s.push('\n'); + } + let is_anchor = + matches!(failure, InvariantFailure::Predicate { is_anchor: true, .. }); + let suffix = if named || !is_anchor { + format!(" {}", failure.name()) + } else { + String::new() + }; + write_failure(&mut s, failure, &suffix); + } + } else { + // Non-invariant failure (unit / fuzz / DS-style): render from the legacy + // `reason` / `counterexample` fields. + s.push_str("[FAIL"); if let Some(reason) = &self.reason { write!(s, ": {reason}").unwrap(); } + match &self.counterexample { + Some(CounterExample::Single(ex)) => { + write!(s, "; counterexample: {ex}]").unwrap(); + } + Some(CounterExample::Sequence(original, sequence)) => { + s.push(']'); + write_sequence(&mut s, "Sequence", *original, sequence); + } + None => s.push(']'), + } + } + + let broken = self.invariant_failures.len(); + let rollup = match self.invariant_count { + Some(total) if total > 1 && is_invariant_failure => { + writeln!(s, "\n{header}: {broken}/{total} invariants broken").unwrap(); + true + } + _ => false, + }; + self.write_predicates(&mut s, header, !user_facing || !rollup); + if broken > 1 + && let Some(dir) = &self.invariant_failure_dir + { + writeln!( + s, + "{broken} invariant failure(s) persisted to {} — rerun to shrink", + dir.display() + ) + .unwrap(); + } - if let Some(counterexample) = &self.counterexample { - match counterexample { - CounterExample::Single(ex) => { - write!(s, "; counterexample: {ex}]").unwrap(); - } - CounterExample::Sequence(original, sequence) => { - s.push_str( - format!( - "]\n\t[Sequence] (original: {original}, shrunk: {})\n", - sequence.len() - ) - .as_str(), - ); - for ex in sequence { - writeln!(s, "{ex}").unwrap(); - } - } + if !self.invariant_handler_failures.is_empty() { + // Separate the section from anything rendered above it. + let preceded = rollup + || broken > 0 + || (user_facing && self.invariant_predicate_results.len() > 1); + writeln!( + s, + "{}{}: {} assertion bug(s) found", + if preceded { "\n" } else { "" }, + if user_facing { "Assertion Tests" } else { "Handler assertions" }, + self.invariant_handler_failures.len() + ) + .unwrap(); + for failure in &self.invariant_handler_failures { + if !write_failure(&mut s, failure, &format!(" {}", failure.name())) { + s.push('\n'); } - } else { - s.push(']'); } - } else { - s.push(']'); } - s.red().fmt(f) + + s.red().wrap().to_string() + } + } + } + + /// Appends the per-predicate summary for multi-predicate campaigns. + fn write_predicates(&self, s: &mut String, header: &str, show_header: bool) { + if self.invariant_predicate_results.len() <= 1 { + return; + } + if show_header { + write!(s, "\n{header}:\n").unwrap(); + } + for predicate in &self.invariant_predicate_results { + let name = &predicate.name; + match (predicate.status, &predicate.reason) { + (TestStatus::Success, _) => writeln!(s, "[PASS] {name}"), + (TestStatus::Failure, reason) => { + writeln!(s, "[FAIL: {}] {name}", reason.as_deref().unwrap_or_default()) + } + (TestStatus::Skipped, Some(reason)) => writeln!(s, "[SKIP: {reason}] {name}"), + (TestStatus::Skipped, None) => writeln!(s, "[SKIP] {name}"), } + .unwrap(); + } + } +} + +macro_rules! extend { + ($a:expr, $b:expr, $trace_kind:expr) => { + if $b.fork_block_number.is_some() { + $a.fork_block_number = $b.fork_block_number; } + $a.logs.extend($b.logs); + $a.labels.extend($b.labels); + $a.traces.extend($b.traces.map(|traces| ($trace_kind, traces))); + $a.debug_bytecodes.extend($b.debug_bytecodes); + $a.merge_coverages($b.line_coverage); + }; +} + +/// Forge-side outcome of an invariant campaign, recorded into a [`TestResult`]. +#[derive(Default)] +pub struct InvariantOutcome { + /// Whether every checked invariant held. + pub success: bool, + /// Fork block number the campaign ran against, if any. + pub fork_block_number: Option, + /// Broken invariants, each with its shrunk call sequence. + pub failures: Vec, + /// Handler assertion failures found while running the campaign. + pub handler_failures: Vec, + /// Per-invariant pass/fail/skip rows. + pub predicate_results: Vec, + /// Directory the failing sequences were persisted to. + pub failure_dir: Option, + /// Number of invariants checked, when the campaign ran more than one. + pub invariant_count: Option, + /// Best sequence found in optimization mode. + pub counterexample: Option, + /// Traces collected for the gas report. + pub gas_report_traces: Vec>, +} + +/// Invariant kind for results that did not run a real campaign (setup failures, replays, skips). +pub(crate) fn invariant_kind(runs: usize, calls: usize, reverts: usize) -> TestKind { + TestKind::Invariant { + runs, + calls, + reverts, + workers: 1, + metrics: Default::default(), + failed_corpus_replays: 0, + optimization_best_value: None, } } @@ -476,10 +1588,12 @@ impl TestResult { /// Creates a new test result starting from test setup results. pub fn new(setup: &TestSetup) -> Self { Self { - labeled_addresses: setup.labels.clone(), + labels: setup.labels.clone(), logs: setup.logs.clone(), traces: setup.traces.clone(), + debug_bytecodes: setup.debug_bytecodes.clone(), line_coverage: setup.coverage.clone(), + fork_block_number: setup.fork_block_number, ..Default::default() } } @@ -496,8 +1610,10 @@ impl TestResult { reason: setup.reason, logs: setup.logs, traces: setup.traces, + debug_bytecodes: setup.debug_bytecodes, line_coverage: setup.coverage, - labeled_addresses: setup.labels, + labels: setup.labels, + fork_block_number: setup.fork_block_number, ..Default::default() } } @@ -516,25 +1632,19 @@ impl TestResult { /// Returns the result for single test. Merges execution results (logs, labeled addresses, /// traces and coverages) in initial setup results. - pub fn single_result( + pub fn single_result( &mut self, success: bool, reason: Option, - raw_call_result: RawCallResult, + raw_call_result: RawCallResult, ) { - self.kind = - TestKind::Unit { gas: raw_call_result.gas_used.wrapping_sub(raw_call_result.stipend) }; - - // Record logs, labels, traces and merge coverages. - self.logs.extend(raw_call_result.logs); - self.labeled_addresses.extend(raw_call_result.labels); - self.traces.extend(raw_call_result.traces.map(|traces| (TraceKind::Execution, traces))); - self.merge_coverages(raw_call_result.line_coverage); - - self.status = match success { - true => TestStatus::Success, - false => TestStatus::Failure, + self.kind = TestKind::Unit { + gas: raw_call_result.gas_used.saturating_sub(raw_call_result.stipend), }; + + extend!(self, raw_call_result, TraceKind::Execution); + + self.status = if success { TestStatus::Success } else { TestStatus::Failure }; self.reason = reason; self.duration = Duration::default(); self.gas_report_traces = Vec::new(); @@ -548,19 +1658,32 @@ impl TestResult { /// Returns the result for a fuzzed test. Merges fuzz execution results (logs, labeled /// addresses, traces and coverages) in initial setup results. - pub fn fuzz_result(&mut self, result: FuzzTestResult) { - self.kind = TestKind::Fuzz { + pub fn fuzz_result(&mut self, mut result: FuzzTestResult) { + let kind = TestKind::Fuzz { + median_gas: result.median_gas(false), + mean_gas: result.mean_gas(false), + first_case: std::mem::take(&mut result.first_case), + runs: result.gas_by_case.len(), + failed_corpus_replays: result.failed_corpus_replays, + }; + self.campaign_result(kind, result); + } + + /// Returns the result for a table test. Merges table test execution results (logs, labeled + /// addresses, traces and coverages) in initial setup results. + pub fn table_result(&mut self, result: FuzzTestResult) { + let kind = TestKind::Table { median_gas: result.median_gas(false), mean_gas: result.mean_gas(false), - first_case: result.first_case, runs: result.gas_by_case.len(), }; + self.campaign_result(kind, result); + } + + fn campaign_result(&mut self, kind: TestKind, result: FuzzTestResult) { + self.kind = kind; - // Record logs, labels, traces and merge coverages. - self.logs.extend(result.logs); - self.labeled_addresses.extend(result.labeled_addresses); - self.traces.extend(result.traces.map(|traces| (TraceKind::Execution, traces))); - self.merge_coverages(result.line_coverage); + extend!(self, result, TraceKind::Execution); self.status = if result.skipped { TestStatus::Skipped @@ -577,100 +1700,206 @@ impl TestResult { self.deprecated_cheatcodes = result.deprecated_cheatcodes; } - /// Returns the skipped result for invariant test. - pub fn invariant_skip(&mut self, reason: SkipReason) { - self.kind = TestKind::Invariant { - runs: 1, - calls: 1, - reverts: 1, - metrics: HashMap::default(), + /// Returns the fail result for fuzz test setup. + pub fn fuzz_setup_fail(&mut self, e: Report) { + self.kind = TestKind::Fuzz { + first_case: Default::default(), + runs: 0, + mean_gas: 0, + median_gas: 0, failed_corpus_replays: 0, }; + self.status = TestStatus::Failure; + debug!(?e, "failed to set up fuzz testing environment"); + self.reason = Some(format!("failed to set up fuzz testing environment: {e}")); + } + + /// Returns the skipped result for invariant campaign with per-predicate outcomes. + pub fn invariant_skip_with_predicates( + &mut self, + reason: SkipReason, + invariant_predicate_results: Vec, + ) { + self.kind = invariant_kind(1, 1, 1); self.status = TestStatus::Skipped; - self.reason = reason.0; + let predicate_count = invariant_predicate_results.len(); + let is_campaign = predicate_count > 1; + self.reason = if is_campaign { None } else { reason.0 }; + self.invariant_count = is_campaign.then_some(predicate_count); + self.invariant_predicate_results = invariant_predicate_results; } /// Returns the fail result for replayed invariant test. pub fn invariant_replay_fail( &mut self, - replayed_entirely: bool, - invariant_name: &String, + outcome: CheckSequenceOutcome, + invariant_name: &str, + fallback_reason: Option, call_sequence: Vec, ) { - self.kind = TestKind::Invariant { - runs: 1, - calls: 1, - reverts: 1, - metrics: HashMap::default(), - failed_corpus_replays: 0, - }; + self.kind = invariant_kind(1, outcome.calls_count, outcome.reverts); self.status = TestStatus::Failure; - self.reason = if replayed_entirely { - Some(format!("{invariant_name} replay failure")) - } else { - Some(format!("{invariant_name} persisted failure revert")) - }; + self.reason = Some(outcome.reason.or(fallback_reason).unwrap_or_else(|| { + let what = if outcome.replayed_entirely { + "replay failure" + } else { + "persisted failure revert" + }; + format!("{invariant_name} {what}") + })); self.counterexample = Some(CounterExample::Sequence(call_sequence.len(), call_sequence)); } + /// Returns the success result for a replayed invariant test. + pub fn invariant_replay_success(&mut self, call_count: usize, reverts: usize) { + self.kind = invariant_kind(1, call_count, reverts); + self.status = TestStatus::Success; + self.reason = None; + } + /// Returns the fail result for invariant test setup. pub fn invariant_setup_fail(&mut self, e: Report) { - self.kind = TestKind::Invariant { - runs: 0, - calls: 0, - reverts: 0, - metrics: HashMap::default(), - failed_corpus_replays: 0, - }; + self.kind = invariant_kind(0, 0, 0); self.status = TestStatus::Failure; self.reason = Some(format!("failed to set up invariant testing environment: {e}")); } /// Returns the invariant test result. - #[expect(clippy::too_many_arguments)] - pub fn invariant_result( + pub fn invariant_result(&mut self, kind: TestKind, outcome: InvariantOutcome) { + // For optimization mode (Some value), always succeed. For check mode (None), use success. + let optimizing = + matches!(kind, TestKind::Invariant { optimization_best_value: Some(_), .. }); + self.kind = kind; + self.status = + if optimizing || outcome.success { TestStatus::Success } else { TestStatus::Failure }; + self.fork_block_number = outcome.fork_block_number; + self.invariant_predicate_results = outcome.predicate_results; + self.invariant_failure_dir = outcome.failure_dir; + self.invariant_count = outcome.invariant_count; + // `counterexample` is only used by the renderer for optimization mode (the "best + // sequence" rendered on success). Invariant check-mode failures live entirely in + // `invariant_failures`; `reason`/`counterexample` stay `None` for invariant tests. + self.counterexample = outcome.counterexample; + for artifact in outcome + .failures + .iter() + .chain(&outcome.handler_failures) + .flat_map(|failure| replay_artifacts(failure.artifact(), failure.minimization())) + { + self.add_counterexample_artifact(artifact.clone()); + } + self.invariant_failures = outcome.failures; + self.invariant_handler_failures = outcome.handler_failures; + self.gas_report_traces = outcome.gas_report_traces; + } + + /// Returns the result for a symbolic test. + pub fn symbolic_result( &mut self, - gas_report_traces: Vec>, - success: bool, + status: TestStatus, reason: Option, counterexample: Option, - cases: Vec, - reverts: usize, - metrics: Map, - failed_corpus_replays: usize, + symbolic: SymbolicResult, ) { - self.kind = TestKind::Invariant { - runs: cases.len(), - calls: cases.iter().map(|sequence| sequence.cases().len()).sum(), - reverts, - metrics, - failed_corpus_replays, - }; - self.status = match success { - true => TestStatus::Success, - false => TestStatus::Failure, - }; + self.kind = TestKind::Symbolic(symbolic.solver.stats); + self.status = status; self.reason = reason; self.counterexample = counterexample; - self.gas_report_traces = gas_report_traces; + self.record_symbolic(symbolic); + self.duration = Duration::default(); + } + + /// Records symbolic execution metadata without changing the test status/kind. + pub(crate) fn record_symbolic(&mut self, symbolic: SymbolicResult) { + for artifact in replay_artifacts(symbolic.artifact.as_ref(), symbolic.minimization.as_ref()) + { + self.add_counterexample_artifact(artifact.clone()); + } + self.symbolic = Some(symbolic); + } + + /// Records a successful showmap replay result. + pub fn replay_result( + &mut self, + corpus_entries: usize, + showmap_files: usize, + skipped_entries: usize, + duration: Duration, + ) { + self.kind = TestKind::Replay { corpus_entries, showmap_files, skipped_entries }; + self.status = TestStatus::Success; + self.duration = duration; + } + + /// Records a skipped showmap replay (e.g. unit test or no corpus available). + pub fn replay_skip(&mut self, reason: impl Into) { + self.kind = TestKind::Replay { corpus_entries: 0, showmap_files: 0, skipped_entries: 0 }; + self.status = TestStatus::Skipped; + self.reason = Some(reason.into()); + self.duration = Duration::default(); + } + + /// Marks a campaign stopped early by fail-fast or Ctrl-C as skipped, so a partial run is not + /// reported as passing. + pub fn interrupt(&mut self) { + let reason = Some("interrupted".to_string()); + self.status = TestStatus::Skipped; + for predicate in &mut self.invariant_predicate_results { + if predicate.status.is_success() { + predicate.status = TestStatus::Skipped; + predicate.reason.clone_from(&reason); + } + } + // Multi-predicate campaigns carry reasons per predicate. + if self.invariant_count.is_none() { + self.reason = reason; + } + } + + /// Formats the test result into a string (for printing), naming invariant campaigns after + /// the suite's contract. + pub(crate) fn short_result_with_suite(&self, name: &str, suite_name: &str) -> String { + let campaign = (self.kind.is_invariant() && self.invariant_count.is_some()) + .then(|| invariant_campaign_display_name(get_contract_name(suite_name))); + let name = campaign.as_deref().unwrap_or(name); + let status = self.render(true, campaign.as_deref()); + let block = match self.fork_block_number { + Some(block) if self.status.is_failure() => format!(" (block: {block})"), + _ => String::new(), + }; + format!("{status} {name}{block} {}", self.kind.report()) } - /// Returns `true` if this is the result of a fuzz test - pub fn is_fuzz(&self) -> bool { - matches!(self.kind, TestKind::Fuzz { .. }) + /// The number of logical tests this result stands for: skipped predicates of a campaign are + /// counted individually. + fn logical_count(&self) -> usize { + let skipped = self.skipped_predicate_count(); + if skipped == 0 { + 1 + } else if self.status.is_skipped() && skipped == self.invariant_predicate_results.len() { + skipped + } else { + 1 + skipped + } } - /// Formats the test result into a string (for printing). - pub fn short_result(&self, name: &str) -> String { - format!("{self} {name} {}", self.kind.report()) + fn skipped_count(&self) -> usize { + let skipped = self.skipped_predicate_count(); + if skipped == 0 && self.status.is_skipped() { 1 } else { skipped } + } + + fn skipped_predicate_count(&self) -> usize { + self.invariant_predicate_results.iter().filter(|p| p.status.is_skipped()).count() } /// Merges the given raw call result into `self`. - pub fn extend(&mut self, call_result: RawCallResult) { - self.logs.extend(call_result.logs); - self.labeled_addresses.extend(call_result.labels); - self.traces.extend(call_result.traces.map(|traces| (TraceKind::Execution, traces))); - self.merge_coverages(call_result.line_coverage); + pub fn extend(&mut self, call_result: RawCallResult) { + extend!(self, call_result, TraceKind::Execution); + } + + /// Merges the given pre-test setup result into `self`. + pub(crate) fn extend_setup(&mut self, call_result: RawCallResult) { + extend!(self, call_result, TraceKind::Setup); } /// Merges the given coverage result into `self`. @@ -689,34 +1918,83 @@ pub enum TestKindReport { runs: usize, mean_gas: u64, median_gas: u64, + failed_corpus_replays: usize, }, Invariant { runs: usize, calls: usize, reverts: usize, - metrics: Map, failed_corpus_replays: usize, + /// For optimization mode (int256 return): the best value achieved. None = check mode. + optimization_best_value: Option, + }, + Table { + runs: usize, + mean_gas: u64, + median_gas: u64, + }, + Symbolic(SymbolicStats), + /// Showmap corpus replay (no campaign performed). + Replay { + corpus_entries: usize, + showmap_files: usize, + skipped_entries: usize, }, } impl fmt::Display for TestKindReport { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Unit { gas } => { - write!(f, "(gas: {gas})") + Self::Unit { gas } => write!(f, "(gas: {gas})"), + Self::Fuzz { runs, mean_gas, median_gas, failed_corpus_replays } => { + write!(f, "(runs: {runs}, μ: {mean_gas}, ~: {median_gas}")?; + if *failed_corpus_replays != 0 { + write!(f, ", failed corpus replays: {failed_corpus_replays}")?; + } + f.write_str(")") } - Self::Fuzz { runs, mean_gas, median_gas } => { + Self::Invariant { + runs, + calls, + reverts, + failed_corpus_replays, + optimization_best_value, + } => { + if let Some(best_value) = optimization_best_value { + return write!(f, "(best: {best_value}, runs: {runs}, calls: {calls})"); + } + write!(f, "(runs: {runs}, calls: {calls}, reverts: {reverts}")?; + if *failed_corpus_replays != 0 { + write!(f, ", failed corpus replays: {failed_corpus_replays}")?; + } + f.write_str(")") + } + Self::Table { runs, mean_gas, median_gas } => { write!(f, "(runs: {runs}, μ: {mean_gas}, ~: {median_gas})") } - Self::Invariant { runs, calls, reverts, metrics: _, failed_corpus_replays } => { - if *failed_corpus_replays != 0 { - write!( - f, - "(runs: {runs}, calls: {calls}, reverts: {reverts}, failed corpus replays: {failed_corpus_replays})" - ) - } else { - write!(f, "(runs: {runs}, calls: {calls}, reverts: {reverts})") + Self::Symbolic(SymbolicStats { + paths, + solver_queries, + smt_queries, + sat_queries, + model_queries, + sat_cache_hits, + model_cache_hits, + heuristic_witnesses, + solver_time_ms, + .. + }) => { + write!( + f, + "(paths: {paths}, queries: {solver_queries}, smt: {smt_queries}, sat: {sat_queries} ({sat_cache_hits} cached), models: {model_queries} ({model_cache_hits} cached), hard-arith: {heuristic_witnesses}, solver: {solver_time_ms}ms)" + ) + } + Self::Replay { corpus_entries, showmap_files, skipped_entries } => { + write!(f, "(replay: {corpus_entries} entries, {showmap_files} files")?; + if *skipped_entries != 0 { + write!(f, ", {skipped_entries} skipped")?; } + f.write_str(")") } } } @@ -724,13 +2002,13 @@ impl fmt::Display for TestKindReport { impl TestKindReport { /// Returns the main gas value to compare against - pub fn gas(&self) -> u64 { + pub const fn gas(&self) -> u64 { match *self { Self::Unit { gas } => gas, // We use the median for comparisons - Self::Fuzz { median_gas, .. } => median_gas, + Self::Fuzz { median_gas, .. } | Self::Table { median_gas, .. } => median_gas, // We return 0 since it's not applicable - Self::Invariant { .. } => 0, + Self::Invariant { .. } | Self::Symbolic { .. } | Self::Replay { .. } => 0, } } } @@ -747,15 +2025,27 @@ pub enum TestKind { runs: usize, mean_gas: u64, median_gas: u64, + failed_corpus_replays: usize, }, /// An invariant test. Invariant { runs: usize, calls: usize, reverts: usize, + /// Actual worker count used by this invariant campaign. + #[serde(default = "default_invariant_workers")] + workers: usize, metrics: Map, failed_corpus_replays: usize, + /// For optimization mode (int256 return): the best value achieved. None = check mode. + optimization_best_value: Option, }, + /// A table test. + Table { runs: usize, mean_gas: u64, median_gas: u64 }, + /// A symbolic test. + Symbolic(SymbolicStats), + /// Showmap corpus replay (no campaign performed). + Replay { corpus_entries: usize, showmap_files: usize, skipped_entries: usize }, } impl Default for TestKind { @@ -765,26 +2055,65 @@ impl Default for TestKind { } impl TestKind { - /// The gas consumed by this test - pub fn report(&self) -> TestKindReport { + /// Returns `true` if this is a fuzz test. + pub const fn is_fuzz(&self) -> bool { + matches!(self, Self::Fuzz { .. }) + } + + /// Returns `true` if this is an invariant test. + pub const fn is_invariant(&self) -> bool { + matches!(self, Self::Invariant { .. }) + } + + /// Returns `true` if this is a symbolic test. + pub const fn is_symbolic(&self) -> bool { + matches!(self, Self::Symbolic { .. }) + } + + /// Actual invariant campaign worker count, if this is an invariant test. + pub const fn invariant_workers(&self) -> Option { match self { - Self::Unit { gas } => TestKindReport::Unit { gas: *gas }, - Self::Fuzz { first_case: _, runs, mean_gas, median_gas } => { - TestKindReport::Fuzz { runs: *runs, mean_gas: *mean_gas, median_gas: *median_gas } + Self::Invariant { workers, .. } => Some(*workers), + _ => None, + } + } + + /// The gas consumed by this test + pub const fn report(&self) -> TestKindReport { + match *self { + Self::Unit { gas } => TestKindReport::Unit { gas }, + Self::Fuzz { runs, mean_gas, median_gas, failed_corpus_replays, .. } => { + TestKindReport::Fuzz { runs, mean_gas, median_gas, failed_corpus_replays } } - Self::Invariant { runs, calls, reverts, metrics: _, failed_corpus_replays } => { - TestKindReport::Invariant { - runs: *runs, - calls: *calls, - reverts: *reverts, - metrics: HashMap::default(), - failed_corpus_replays: *failed_corpus_replays, - } + Self::Invariant { + runs, + calls, + reverts, + failed_corpus_replays, + optimization_best_value, + .. + } => TestKindReport::Invariant { + runs, + calls, + reverts, + failed_corpus_replays, + optimization_best_value, + }, + Self::Table { runs, mean_gas, median_gas } => { + TestKindReport::Table { runs, mean_gas, median_gas } + } + Self::Symbolic(stats) => TestKindReport::Symbolic(stats), + Self::Replay { corpus_entries, showmap_files, skipped_entries } => { + TestKindReport::Replay { corpus_entries, showmap_files, skipped_entries } } } } } +const fn default_invariant_workers() -> usize { + 1 +} + /// The result of a test setup. /// /// Includes the deployment of the required libraries and the test contract itself, and the call to @@ -802,10 +2131,16 @@ pub struct TestSetup { pub labels: AddressHashMap, /// Call traces of the setup. pub traces: Traces, + /// Runtime bytecodes for contracts seen in setup traces. + pub debug_bytecodes: AddressHashMap, /// Coverage info during setup. pub coverage: Option, /// Addresses of external libraries deployed during setup. pub deployed_libs: Vec
, + /// The active fork's block number after setup, if any. + pub fork_block_number: Option, + /// Cached setup-derived fuzz dictionary for stateless fuzz tests. + pub(crate) fuzz_state: OnceLock, /// The reason the setup failed, if it did. pub reason: Option, @@ -824,10 +2159,162 @@ impl TestSetup { Self { reason: Some(reason), skipped: true, ..Default::default() } } - pub fn extend(&mut self, raw: RawCallResult, trace_kind: TraceKind) { - self.logs.extend(raw.logs); - self.labels.extend(raw.labels); - self.traces.extend(raw.traces.map(|traces| (trace_kind, traces))); - HitMaps::merge_opt(&mut self.coverage, raw.line_coverage); + pub fn extend( + &mut self, + raw: RawCallResult, + trace_kind: TraceKind, + ) { + extend!(self, raw, trace_kind); + } + + pub fn merge_coverages(&mut self, other_coverage: Option) { + HitMaps::merge_opt(&mut self.coverage, other_coverage); + } +} + +pub(crate) fn invariant_campaign_display_name(contract_name: &str) -> String { + format!("{contract_name} invariants") +} + +const fn symbolic_result_schema_version() -> u32 { + SYMBOLIC_RESULT_SCHEMA_VERSION +} + +#[cfg(test)] +mod tests { + use super::*; + use foundry_cli::utils::parse_json; + + const SYMBOLIC_RESULT_SCHEMA: &str = + include_str!("../../evm/symbolic/assets/symbolic-result.schema.json"); + const SYMBOLIC_COUNTEREXAMPLE_SCHEMA: &str = + include_str!("../../evm/symbolic/assets/symbolic-counterexample.schema.json"); + + fn schema_defs(schema: &serde_json::Value) -> &serde_json::Map { + schema["$defs"].as_object().expect("schema $defs object") + } + + /// Collects every `$ref` target in `value`. + fn collect_refs<'a>(value: &'a serde_json::Value, refs: &mut Vec<&'a str>) { + match value { + serde_json::Value::Object(map) => { + refs.extend(map.get("$ref").and_then(serde_json::Value::as_str)); + for child in map.values() { + collect_refs(child, refs); + } + } + serde_json::Value::Array(values) => { + for child in values { + collect_refs(child, refs); + } + } + _ => {} + } + } + + #[test] + fn symbolic_schemas_match_result_types() { + let result_schema: serde_json::Value = parse_json(SYMBOLIC_RESULT_SCHEMA).unwrap(); + let counterexample_schema: serde_json::Value = + parse_json(SYMBOLIC_COUNTEREXAMPLE_SCHEMA).unwrap(); + let result_defs = schema_defs(&result_schema); + let counterexample_defs = schema_defs(&counterexample_schema); + + // Every counterexample `$ref` must resolve offline, either locally or into the result + // schema. + let mut refs = Vec::new(); + collect_refs(&counterexample_schema, &mut refs); + for reference in refs { + let resolved = if let Some(name) = reference.strip_prefix( + "https://foundry-rs.github.io/schemas/symbolic-result.v1.schema.json#/$defs/", + ) { + result_defs.contains_key(name) + } else if let Some(name) = reference.strip_prefix("#/$defs/") { + counterexample_defs.contains_key(name) + } else { + false + }; + assert!(resolved, "unresolved schema ref {reference}"); + } + + // The solver stats schema must list exactly the serialized `SymbolicStats` fields. + let stats = serde_json::to_value(SymbolicStats::default()).unwrap(); + let mut expected = stats.as_object().unwrap().keys().collect::>(); + let mut actual = result_defs["solver_stats"]["properties"] + .as_object() + .unwrap() + .keys() + .collect::>(); + expected.sort(); + actual.sort(); + assert_eq!(actual, expected); + } + + fn outcome_with_results(test_results: Vec) -> TestOutcome { + let test_results = test_results + .into_iter() + .enumerate() + .map(|(idx, result)| (format!("test{idx}()"), result)) + .collect(); + let suite = SuiteResult::new(Duration::ZERO, test_results, Vec::new()); + TestOutcome::new(None, BTreeMap::from([("suite".to_string(), suite)]), false, None) + } + + fn failed_result(kind: TestKind) -> TestResult { + TestResult { status: TestStatus::Failure, kind, ..Default::default() } + } + + fn failed_invariant(workers: usize) -> TestResult { + let mut kind = invariant_kind(0, 0, 0); + if let TestKind::Invariant { workers: w, .. } = &mut kind { + *w = workers; + } + failed_result(kind) + } + + #[test] + fn failed_tests_are_debuggable_only_for_concrete_failures() { + let unit = failed_result(TestKind::Unit { gas: 0 }); + assert!(outcome_with_results(vec![unit.clone()]).failed_tests_are_debuggable()); + assert!(!outcome_with_results(vec![failed_invariant(1)]).failed_tests_are_debuggable()); + assert!( + !outcome_with_results(vec![failed_result( + TestKind::Symbolic(SymbolicStats::default()) + )]) + .failed_tests_are_debuggable() + ); + + let mut symbolic_backed = unit; + symbolic_backed.symbolic = + Some(SymbolicResult::pass(&SymbolicConfig::default(), SymbolicStats::default())); + assert!(!outcome_with_results(vec![symbolic_backed]).failed_tests_are_debuggable()); + } + + #[test] + fn invariant_workers_hint_requires_matching_parallel_worker_counts() { + let hint = |workers: &[usize]| { + outcome_with_results(workers.iter().map(|&w| failed_invariant(w)).collect()) + .invariant_workers_hint() + }; + assert_eq!(hint(&[3, 3]), Some(3)); + assert_eq!(hint(&[2, 3]), None); + assert_eq!(hint(&[1]), None); + } + + #[test] + fn invariant_kind_deserializes_legacy_payload_without_workers() { + let kind = serde_json::from_value::(serde_json::json!({ + "Invariant": { + "runs": 4, + "calls": 10, + "reverts": 0, + "metrics": {}, + "failed_corpus_replays": 0, + "optimization_best_value": null + } + })) + .unwrap(); + + assert_eq!(kind.invariant_workers(), Some(1)); } } diff --git a/crates/forge/src/runner.rs b/crates/forge/src/runner.rs index ff92acec6f9ec..39e8953c117ac 100644 --- a/crates/forge/src/runner.rs +++ b/crates/forge/src/runner.rs @@ -2,79 +2,485 @@ use crate::{ MultiContractRunner, TestFilter, - fuzz::{BaseCounterExample, invariant::BasicTxDetails}, - multi_runner::{TestContract, TestRunnerConfig, is_matching_test}, - progress::{TestsProgress, start_fuzz_progress}, - result::{SuiteResult, TestResult, TestSetup}, + coverage::HitMaps, + fuzz::{BaseCounterExample, FuzzTestResult}, + multi_runner::{ + FuzzMinimizeConfig, FuzzMinimizeMode, FuzzMinimizeObservation, LibraryDeployment, + TestContract, TestFunctionMatcher, TestRunnerConfig, + is_generated_symbolic_regression_contract, + }, + progress::TestsProgress, + result::{ + InvariantFailure, InvariantOutcome, InvariantPredicateResult, SuiteResult, + SymbolicArtifactRef, SymbolicCallTrace, SymbolicCorpusSeedMetadata, SymbolicCorpusSeedRef, + SymbolicCounterexample, SymbolicCounterexampleArtifact, SymbolicCounterexampleArtifactKind, + SymbolicCounterexampleCall, SymbolicCounterexampleMinimization, + SymbolicCounterexampleReplaySemantics, SymbolicCounterexampleTestIdentity, + SymbolicInvariantArtifactFailure, SymbolicInvariantFailureSite, SymbolicReplayMetadata, + SymbolicReplayStatus, SymbolicResult, TestKind, TestResult, TestSetup, TestStatus, + invariant_campaign_display_name, invariant_kind, + }, + symbolic_minimizer::{ + MinimizedSequence, minimize_sequence_counterexample, minimize_single_call_counterexample, + }, }; use alloy_dyn_abi::{DynSolValue, JsonAbiExt}; -use alloy_json_abi::Function; -use alloy_primitives::{Address, Bytes, U256, address, map::HashMap}; +use alloy_json_abi::{Function, JsonAbi, StateMutability}; +use alloy_primitives::{ + Address, B256, Bytes, I256, Selector, U256, address, hex, keccak256, + map::{Entry, HashMap, HashSet}, +}; use eyre::Result; -use foundry_common::{TestFunctionExt, TestFunctionKind, contracts::ContractsByAddress}; +use foundry_common::{ + LIBRARY_DEPLOYER, TestFunctionExt, TestFunctionKind, contracts::ContractsByAddress, +}; use foundry_compilers::utils::canonicalized; -use foundry_config::{Config, InvariantConfig}; +use foundry_config::{ + Config, FuzzConfig, FuzzCorpusConfig, FuzzDictionaryConfig, InlineConfig, InvariantConfig, + InvariantDepthMode, SymbolicConfig, +}; use foundry_evm::{ - constants::CALLER, - decode::RevertDecoder, + constants::{CALLER, MAGIC_ASSUME}, + core::{backend::DatabaseExt, evm::FoundryEvmNetwork}, + decode::{RevertDecoder, SkipReason}, executors::{ - CallResult, EvmError, Executor, ITest, RawCallResult, + CallResult, DynamicTargetCtx, EvmError, Executor, ITest, InvariantReplayOptions, + MinimizationReplayInput, RawCallResult, ShowmapOpts, ShowmapReplayTarget, + StatelessReplayTarget, canonical_replay_dirs, fuzz::FuzzedExecutor, invariant::{ - InvariantExecutor, InvariantFuzzError, check_sequence, replay_error, replay_run, + CheckSequenceFailureSite, CheckSequenceOptions, CheckSequenceOutcome, + HandlerAssertionFailure, InvariantExecutor, InvariantFuzzError, ReplayErrorResult, + check_sequence, did_fail_on_assert, execute_tx, execute_tx_and_register_created, + handler_edge_fingerprint, replay_error, replay_handler_failure_sequence, replay_run, }, + persist_corpus_seed, read_corpus_dir, replay_corpus_to_showmap, + replay_sequence_for_minimization, should_ignore_revert, }, fuzz::{ - CounterExample, FuzzFixtures, fixture_name, - invariant::{CallDetails, InvariantContract}, + BasicTxDetails, CallDetails, CounterExample, FuzzFixtures, fixture_name, + invariant::{ + FuzzRunIdentifiedContracts, InvariantContract, InvariantSettings, SenderFilters, + is_optimization_invariant, + }, + strategies::EvmFuzzState, }, - traces::{TraceKind, TraceMode, load_contracts}, + inspectors::{CmpOperands, EdgeCovConfig, EdgeCovKind, cheatcodes::Vm::AccountAccess}, + revm::{bytecode::opcode, primitives::hardfork::SpecId}, + traces::{TraceKind, TraceRequirements, load_contracts}, }; -use itertools::Itertools; -use proptest::test_runner::{ - FailurePersistence, FileFailurePersistence, RngAlgorithm, TestError, TestRng, TestRunner, +use foundry_evm_networks::NetworkVariant; +use foundry_evm_symbolic::{ + SymbolicBranchTarget, SymbolicConcreteInput, SymbolicExecutor, SymbolicInvariantCandidateInput, + SymbolicInvariantCounterexampleKind, SymbolicInvariantRunInput, SymbolicInvariantRunResult, + SymbolicInvariantStep, SymbolicInvariantTarget, SymbolicRunInput, SymbolicRunResult, + SymbolicStats, SymbolicStopReason, SymbolicStorageAssignment, }; +use itertools::Itertools; +use proptest::test_runner::{RngAlgorithm, TestError, TestRng, TestRunner}; use rayon::prelude::*; use serde::{Deserialize, Serialize}; use std::{ borrow::Cow, - cmp::min, collections::BTreeMap, + ops::Deref, path::{Path, PathBuf}, - sync::Arc, + sync::{Arc, Mutex}, time::Instant, }; +use tokio::signal; use tracing::Span; -/// When running tests, we deploy all external libraries present in the project. To avoid additional -/// libraries affecting nonces of senders used in tests, we are using separate address to -/// predeploy libraries. -/// -/// `address(uint160(uint256(keccak256("foundry library deployer"))))` -pub const LIBRARY_DEPLOYER: Address = address!("0x1F95D37F27EA0dEA9C252FC09D5A6eaA97647353"); +const FUZZ_BRANCH_FRONTIER_SCHEMA: &str = "foundry:fuzz.branch-frontiers@v1"; +const STATEFUL_FUZZ_BRANCH_FRONTIER_SCHEMA: &str = "foundry:fuzz.branch-frontiers@v2"; +const FUZZ_BRANCH_FRONTIER_FILE: &str = "branch-frontiers.json"; + +#[derive(Deserialize)] +struct FuzzBranchFrontierArtifact { + schema: String, + version: u32, + test: String, + #[serde(default)] + sequences: Vec>, + frontiers: Vec, +} + +#[derive(Deserialize)] +struct FuzzBranchFrontierRecord { + id: u64, + #[serde(skip)] + both_results_retained: bool, + call_index: usize, + #[serde(default)] + sequence: Vec, + sequence_index: Option, + site: FuzzBranchFrontierSite, + operands: FuzzBranchFrontierOperands, +} + +#[derive(Clone, Copy, Deserialize)] +struct FuzzBranchFrontierSite { + address: Address, + pc: usize, + opcode: u8, +} + +#[derive(Deserialize)] +struct FuzzBranchFrontierOperands { + result: bool, +} + +fn select_stateful_frontiers( + frontiers: Vec, + limit: usize, + explicit_selection: bool, +) -> Vec { + if limit == 0 { + return Vec::new(); + } + + if explicit_selection { + return sample_stateful_frontiers(frontiers, limit); + } + + let mut candidates = Vec::with_capacity(frontiers.len()); + let mut lower_priority = Vec::new(); + for frontier in frontiers { + if frontier.both_results_retained { + lower_priority.push(frontier); + } else { + candidates.push(frontier); + } + } + + let deep_context_index = if !candidates.is_empty() && limit > 1 { + lower_priority + .iter() + .enumerate() + .max_by_key(|(_, frontier)| (frontier.call_index, frontier.id)) + .map(|(index, _)| index) + } else { + None + }; + let deep_context = deep_context_index.map(|index| lower_priority.swap_remove(index)); + let mut selected = select_context_diverse_frontiers(candidates, limit); + selected.extend(select_context_diverse_frontiers( + lower_priority, + limit.saturating_sub(selected.len()), + )); + if let Some(deep_context) = deep_context { + if selected.len() == limit { + let mut context_counts = HashMap::<(Option, usize), usize>::default(); + for frontier in &selected { + *context_counts + .entry((frontier.sequence_index, frontier.call_index)) + .or_default() += 1; + } + let repeated_context = selected + .iter() + .enumerate() + .filter(|(_, frontier)| { + context_counts + .get(&(frontier.sequence_index, frontier.call_index)) + .is_some_and(|count| *count > 1) + }) + .min_by_key(|(_, frontier)| frontier.id) + .map(|(index, _)| index); + if let Some(index) = repeated_context { + selected.remove(index); + } else { + selected.pop(); + } + } + selected.push(deep_context); + } + selected +} + +fn select_context_diverse_frontiers( + mut frontiers: Vec, + limit: usize, +) -> Vec { + if limit == 0 { + return Vec::new(); + } + if frontiers.len() <= limit { + return frontiers; + } + + frontiers.sort_unstable_by_key(|frontier| { + (frontier.sequence_index, frontier.call_index, frontier.id) + }); + let mut representatives = Vec::::new(); + let mut remaining = Vec::new(); + for frontier in frontiers { + let context = (frontier.sequence_index, frontier.call_index); + if let Some(previous) = representatives.last_mut() + && (previous.sequence_index, previous.call_index) == context + { + remaining.push(std::mem::replace(previous, frontier)); + } else { + representatives.push(frontier); + } + } + + let mut selected = sample_stateful_frontiers(representatives, limit); + selected.extend(sample_stateful_frontiers(remaining, limit - selected.len())); + selected +} + +fn sample_stateful_frontiers( + mut frontiers: Vec, + limit: usize, +) -> Vec { + if limit == 0 { + return Vec::new(); + } + frontiers.sort_unstable_by_key(|frontier| (frontier.call_index, frontier.id)); + if frontiers.len() <= limit { + return frontiers; + } + + let total = frontiers.len(); + let denominator = 2 * limit as u128; + let mut indexes = + (0..limit).map(|index| (((2 * index + 1) as u128 * total as u128) / denominator) as usize); + let mut next = indexes.next(); + frontiers + .into_iter() + .enumerate() + .filter_map(|(index, frontier)| { + (Some(index) == next).then(|| { + next = indexes.next(); + frontier + }) + }) + .collect() +} + +fn comparison_result(opcode: u8, lhs: U256, rhs: U256) -> Option { + match opcode { + opcode::EQ => Some(lhs == rhs), + opcode::LT => Some(lhs < rhs), + opcode::GT => Some(lhs > rhs), + opcode::SLT => Some(I256::from_raw(lhs) < I256::from_raw(rhs)), + opcode::SGT => Some(I256::from_raw(lhs) > I256::from_raw(rhs)), + opcode::ISZERO => Some(lhs.is_zero()), + _ => None, + } +} + +fn frontier_comparison_flipped( + site: FuzzBranchFrontierSite, + observed_result: bool, + comparisons: &[CmpOperands], +) -> bool { + comparisons.iter().any(|comparison| { + comparison.address == site.address + && comparison.pc == site.pc + && comparison.opcode == site.opcode + && comparison_result(comparison.opcode, comparison.op1, comparison.op2) + == Some(!observed_result) + }) +} + +pub(crate) struct InvariantCampaignScope<'a> { + pub config: &'a Config, + pub inline_config: &'a InlineConfig, + pub contract_name: &'a str, + pub all_override_networks: &'a [NetworkVariant], + pub pass_network: Option<&'a NetworkVariant>, +} + +struct InvariantCampaignSelection<'a> { + matched_boolean_invariant_fns: Vec<&'a Function>, + merge_boolean_suite: bool, + shared_boolean_namespace: bool, + boolean_suite_anchor: Option<&'a Function>, + optimization_anchors: usize, +} + +impl InvariantCampaignSelection<'_> { + const fn anchor_count(&self) -> usize { + self.optimization_anchors + + if self.matched_boolean_invariant_fns.is_empty() { + 0 + } else if self.merge_boolean_suite { + 1 + } else { + self.matched_boolean_invariant_fns.len() + } + } +} + +pub(crate) fn count_runnable_invariant_campaign_anchors( + abi: &JsonAbi, + filter: &dyn TestFilter, + scope: InvariantCampaignScope<'_>, +) -> usize { + let invariant_fns = abi.functions().filter(|func| func.is_invariant_test()).collect::>(); + if invariant_fns.iter().any(|func| !func.inputs.is_empty()) { + return 0; + } + + let functions = abi + .functions() + .filter(|func| filter.matches_test_function(func)) + .filter(|func| { + function_matches_network_pass( + scope.all_override_networks, + scope.pass_network, + scope.inline_config.network_for( + &scope.config.profile, + scope.contract_name, + &func.name, + ), + ) + }) + .collect::>(); + + select_invariant_campaigns( + &invariant_fns, + &functions, + scope.config, + scope.inline_config, + scope.contract_name, + ) + .anchor_count() +} + +pub(crate) fn function_matches_network_pass( + all_override_networks: &[NetworkVariant], + pass_network: Option<&NetworkVariant>, + func_network: Option, +) -> bool { + if all_override_networks.is_empty() { + return true; + } + match pass_network { + None => func_network.is_none_or(|network| !all_override_networks.contains(&network)), + Some(target) => func_network.as_ref() == Some(target), + } +} + +pub(crate) fn inline_config_for( + config: &Config, + inline_config: &InlineConfig, + contract_name: &str, + func: Option<&Function>, +) -> Result { + let function = func.map(|f| f.name.as_str()).unwrap_or(""); + Ok(config.merge_inline_provider(inline_config.provide(contract_name, function))?) +} + +fn invariant_suite_configs_match( + config: &Config, + inline_config: &InlineConfig, + contract_name: &str, + funcs: &[&Function], +) -> bool { + let Some((anchor, rest)) = funcs.split_first() else { + return true; + }; + let anchor_config = match inline_config_for(config, inline_config, contract_name, Some(anchor)) + { + Ok(config) => config.invariant, + Err(_) => return false, + }; + rest.iter().all(|func| { + inline_config_for(config, inline_config, contract_name, Some(func)) + .map(|config| config.invariant == anchor_config) + .unwrap_or(false) + }) +} + +fn select_invariant_campaigns<'a>( + invariant_fns: &[&'a Function], + functions: &[&'a Function], + config: &Config, + inline_config: &InlineConfig, + contract_name: &str, +) -> InvariantCampaignSelection<'a> { + let boolean_invariant_fns = invariant_fns + .iter() + .copied() + .filter(|func| !is_optimization_invariant(func)) + .collect::>(); + let matched_boolean_invariant_fns = functions + .iter() + .copied() + .filter(|func| func.is_invariant_test() && !is_optimization_invariant(func)) + .collect::>(); + let optimization_anchors = functions + .iter() + .filter(|func| func.is_invariant_test() && is_optimization_invariant(func)) + .count(); + + // Merge compatible selected predicates even when an excluded predicate has different config. + // Decide the corpus/frontier namespace separately from the full suite so filtering cannot + // move an isolated campaign into the contract-level namespace. + let canonical_boolean_anchor = boolean_invariant_fns.first().copied(); + let merge_boolean_suite = !matched_boolean_invariant_fns.is_empty() + && invariant_suite_configs_match( + config, + inline_config, + contract_name, + &matched_boolean_invariant_fns, + ); + let shared_boolean_namespace = merge_boolean_suite + && invariant_suite_configs_match( + config, + inline_config, + contract_name, + &boolean_invariant_fns, + ); + let boolean_suite_anchor = merge_boolean_suite + .then(|| { + canonical_boolean_anchor + .filter(|anchor| matched_boolean_invariant_fns.contains(anchor)) + .or_else(|| matched_boolean_invariant_fns.first().copied()) + }) + .flatten(); + + InvariantCampaignSelection { + matched_boolean_invariant_fns, + merge_boolean_suite, + shared_boolean_namespace, + boolean_suite_anchor, + optimization_anchors, + } +} /// A type that executes all tests of a contract -pub struct ContractRunner<'a> { +pub struct ContractRunner<'a, FEN: FoundryEvmNetwork> { /// The name of the contract. name: &'a str, /// The data of the contract. contract: &'a TestContract, /// The EVM executor. - executor: Executor, + executor: Executor, /// Overall test run progress. progress: Option<&'a TestsProgress>, /// The handle to the tokio runtime. - tokio_handle: &'a tokio::runtime::Handle, + tokio_handle: tokio::runtime::Handle, /// The span of the contract. span: tracing::Span, /// The contract-level configuration. - tcfg: Cow<'a, TestRunnerConfig>, + tcfg: Cow<'a, TestRunnerConfig>, /// The parent runner. - mcr: &'a MultiContractRunner, + mcr: &'a MultiContractRunner, + /// Number of matching invariant campaign anchors in the current test pass. + num_invariant_campaign_anchors: usize, +} + +pub(crate) struct ContractRunnerContext<'a> { + pub(crate) progress: Option<&'a TestsProgress>, + pub(crate) tokio_handle: tokio::runtime::Handle, + pub(crate) num_invariant_campaign_anchors: usize, } -impl<'a> std::ops::Deref for ContractRunner<'a> { - type Target = Cow<'a, TestRunnerConfig>; +impl<'a, FEN: FoundryEvmNetwork> Deref for ContractRunner<'a, FEN> { + type Target = Cow<'a, TestRunnerConfig>; #[inline(always)] fn deref(&self) -> &Self::Target { @@ -82,28 +488,42 @@ impl<'a> std::ops::Deref for ContractRunner<'a> { } } -impl<'a> ContractRunner<'a> { - pub fn new( +impl<'a, FEN: FoundryEvmNetwork> ContractRunner<'a, FEN> { + pub(crate) fn new( name: &'a str, contract: &'a TestContract, - executor: Executor, - progress: Option<&'a TestsProgress>, - tokio_handle: &'a tokio::runtime::Handle, + executor: Executor, span: Span, - mcr: &'a MultiContractRunner, + mcr: &'a MultiContractRunner, + context: ContractRunnerContext<'a>, ) -> Self { Self { name, contract, executor, - progress, - tokio_handle, + progress: context.progress, + tokio_handle: context.tokio_handle, span, tcfg: Cow::Borrowed(&mcr.tcfg), mcr, + num_invariant_campaign_anchors: context.num_invariant_campaign_anchors, } } + /// Returns `true` if `func` should run in the current multi-network pass. + /// + /// In single-pass mode (no inline network overrides) every function passes. + /// In multi-pass mode: + /// - Default pass (`pass_network = None`): includes functions *without* an override annotation. + /// - Override pass (`pass_network = Some(v)`): includes only functions annotated with `v`. + fn function_matches_network_pass(&self, func: &Function) -> bool { + function_matches_network_pass( + &self.mcr.tcfg.multi_network.all_override_networks, + self.mcr.tcfg.multi_network.pass_network.as_ref(), + self.mcr.inline_config.network_for(&self.tcfg.config.profile, self.name, &func.name), + ) + } + /// Deploys the test contract inside the runner from the sending account, and optionally runs /// the `setUp` function on the test contract. pub fn setup(&mut self, call_setup: bool) -> TestSetup { @@ -131,27 +551,108 @@ impl<'a> ContractRunner<'a> { // Deploy libraries. self.executor.set_balance(LIBRARY_DEPLOYER, U256::MAX)?; + let rd = &self.mcr.revert_decoder; let mut result = TestSetup::default(); - for code in &self.mcr.libs_to_deploy { - let deploy_result = self.executor.deploy( - LIBRARY_DEPLOYER, - code.clone(), - U256::ZERO, - Some(&self.mcr.revert_decoder), - ); + let mut pending_account_diffs = Vec::new(); + match self.mcr.library_deployment { + LibraryDeployment::Nonce => { + // Fork state may carry a nonzero deployer nonce. + if !self.mcr.libs_to_deploy.is_empty() { + self.executor.set_account_nonce(LIBRARY_DEPLOYER, 0)?; + } + for (nonce, code) in self.mcr.libs_to_deploy.iter().enumerate() { + // Libraries are linked from nonce zero in the same order they are deployed. + let expected_address = LIBRARY_DEPLOYER.create(nonce as u64); + let (deploy_result, recorded_account_diffs) = + self.deploy_library(expected_address, |executor| { + executor.deploy(LIBRARY_DEPLOYER, code.clone(), U256::ZERO, Some(rd)) + }); + + if let Ok(deployed) = &deploy_result { + result.deployed_libs.push(deployed.address); + if self.contract.library_addresses.contains(&deployed.address) { + pending_account_diffs.extend(recorded_account_diffs); + } + } + + let (raw, reason) = + RawCallResult::from_evm_result(deploy_result.map(Into::into))?; + result.extend(raw, TraceKind::Deployment); + if reason.is_some() { + debug!(?reason, "deployment of library failed"); + result.reason = reason; + return Ok(result); + } + } + } + LibraryDeployment::Create2 { deployer, salt } => { + // Foundry only knows how to install the canonical factory locally. A custom + // factory is usable only when it already exists in fork state. Tempo also + // provides the factory as a predeploy, which must not be deployed again. + if deployer == foundry_evm::constants::DEFAULT_CREATE2_DEPLOYER + && !self.evm_opts.networks.is_tempo() + { + self.executor.deploy_create2_deployer()?; + } + for code in &self.mcr.libs_to_deploy { + let address = deployer.create2_from_code(salt, code); + if self.executor.is_empty_code(address)? { + let calldata = [salt.as_slice(), code.as_ref()].concat().into(); + let (raw, recorded_account_diffs) = + self.deploy_library(address, |executor| { + executor.transact_raw( + LIBRARY_DEPLOYER, + deployer, + calldata, + U256::ZERO, + ) + }); + let raw = raw?; + let (raw, reason) = if raw.reverted { + RawCallResult::from_evm_result(Err(raw.into_evm_error(Some(rd))))? + } else { + (raw, None) + }; + result.extend(raw, TraceKind::Deployment); + if reason.is_some() { + debug!(?reason, "CREATE2 deployment of library failed"); + result.reason = reason; + return Ok(result); + } + if self.executor.is_empty_code(address)? { + result.reason = Some(format!( + "CREATE2 library deployment succeeded but no code was found at {address}" + )); + return Ok(result); + } + pending_account_diffs.extend(recorded_account_diffs); + } + self.executor.backend_mut().add_persistent_account(address); + result.deployed_libs.push(address); + } - // Record deployed library address. - if let Ok(deployed) = &deploy_result { - result.deployed_libs.push(deployed.address); + // Factory calls are test harness setup and must not be observable through the + // last-call gas cheatcodes. + if let Some(cheats) = self.executor.inspector_mut().cheatcodes.as_mut() { + cheats.gas_metering.last_call_gas = None; + cheats.gas_metering.last_frame_gas = None; + } } + } + if !pending_account_diffs.is_empty() + && let Some(cheats) = self.executor.inspector_mut().cheatcodes.as_deref_mut() + { + cheats.set_pending_account_diffs(pending_account_diffs); + } - let (raw, reason) = RawCallResult::from_evm_result(deploy_result.map(Into::into))?; - result.extend(raw, TraceKind::Deployment); - if reason.is_some() { - result.reason = reason; - return Ok(result); + // Configured libraries may already exist and are not present in `libs_to_deploy`. + for &address in &self.mcr.library_addresses { + if !self.executor.is_empty_code(address)? { + result.deployed_libs.push(address); } } + result.deployed_libs.sort_unstable(); + result.deployed_libs.dedup(); let address = self.sender.create(self.executor.get_nonce(self.sender)?); result.address = address; @@ -161,12 +662,8 @@ impl<'a> ContractRunner<'a> { self.executor.set_balance(address, self.initial_balance())?; // Deploy the test contract - let deploy_result = self.executor.deploy( - self.sender, - self.contract.bytecode.clone(), - U256::ZERO, - Some(&self.mcr.revert_decoder), - ); + let deploy_result = + self.executor.deploy(self.sender, self.contract.bytecode.clone(), U256::ZERO, Some(rd)); result.deployment_failure = deploy_result.is_err(); @@ -176,6 +673,7 @@ impl<'a> ContractRunner<'a> { let (raw, reason) = RawCallResult::from_evm_result(deploy_result.map(Into::into))?; result.extend(raw, TraceKind::Deployment); if reason.is_some() { + debug!(?reason, "deployment of test contract failed"); result.reason = reason; return Ok(result); } @@ -185,19 +683,21 @@ impl<'a> ContractRunner<'a> { self.executor.set_balance(CALLER, self.initial_balance())?; self.executor.set_balance(LIBRARY_DEPLOYER, self.initial_balance())?; - self.executor.deploy_create2_deployer()?; + if matches!(self.mcr.library_deployment, LibraryDeployment::Nonce) + && !self.evm_opts.networks.is_tempo() + { + self.executor.deploy_create2_deployer()?; + } // Optionally call the `setUp` function if call_setup { trace!("calling setUp"); - let res = self.executor.setup(None, address, Some(&self.mcr.revert_decoder)); + let res = self.executor.setup(None, address, Some(rd)); let (raw, reason) = RawCallResult::from_evm_result(res)?; result.extend(raw, TraceKind::Setup); result.reason = reason; } - result.fuzz_fixtures = self.fuzz_fixtures(address); - Ok(result) } @@ -205,6 +705,34 @@ impl<'a> ContractRunner<'a> { self.evm_opts.initial_balance } + /// Runs `deploy`, recording the account diffs of linked library deployments so cheatcodes + /// can attribute them to the library. + fn deploy_library( + &mut self, + address: Address, + deploy: impl FnOnce(&mut Executor) -> T, + ) -> (T, Vec) { + let recording = self.contract.library_addresses.contains(&address) + && self + .executor + .inspector_mut() + .cheatcodes + .as_deref_mut() + .is_some_and(|cheats| cheats.start_internal_state_diff_recording()); + let result = deploy(&mut self.executor); + let diffs = if recording { + self.executor + .inspector_mut() + .cheatcodes + .as_deref_mut() + .map(|cheats| cheats.stop_internal_state_diff_recording()) + .unwrap_or_default() + } else { + Vec::new() + }; + (result, diffs) + } + /// Configures this runner with the inline configuration for the contract. fn apply_contract_inline_config(&mut self) -> Result<()> { if self.inline_config.contains_contract(self.name) { @@ -220,9 +748,8 @@ impl<'a> ContractRunner<'a> { /// Returns the configuration for a contract or function. fn inline_config(&self, func: Option<&Function>) -> Result { - let function = func.map(|f| f.name.as_str()).unwrap_or(""); - let config = - self.mcr.inline_config.merge(self.name, function, &self.config).extract::()?; + let mut config = inline_config_for(&self.config, &self.mcr.inline_config, self.name, func)?; + config.networks = config.networks.with_execution_profile(self.tcfg.evm_opts.networks); Ok(config) } @@ -277,13 +804,52 @@ impl<'a> ContractRunner<'a> { fixtures.insert(fixture_name(func.name.clone()), DynSolValue::Array(vals)); }; } - FuzzFixtures::new(fixtures) + FuzzFixtures::new(fixtures).with_enum_bounds(self.mcr.enum_bounds.clone()) + } + + /// Classifies test functions with the current contract-level configuration. + fn test_matcher(&self) -> TestFunctionMatcher<'_> { + TestFunctionMatcher::new( + &self.config, + &self.mcr.inline_config, + self.mcr.tcfg.symbolic_artifact_replay.as_ref(), + ) + } + + /// Returns the test functions selected by `filter` that run in the current network pass. + fn matching_test_functions( + &self, + filter: &dyn TestFilter, + test_matcher: &TestFunctionMatcher<'_>, + ) -> Vec<&'a Function> { + test_matcher + .test_functions(self.name.to_string(), &self.contract.abi, |contract_id, func, kind| { + filter.matches_test_function_kind_in_contract(contract_id, func, kind) + && self.function_matches_network_pass(func) + }) + .collect() } /// Runs all tests for a contract whose names match the provided regular expression pub fn run_tests(mut self, filter: &dyn TestFilter) -> SuiteResult { let start = Instant::now(); let mut warnings = Vec::new(); + let generated_symbolic_regression = + is_generated_symbolic_regression_contract(&self.contract.abi); + // Classified before `setUp`; the full function list is built after setup so + // contract-level inline config can still affect symbolic entrypoint discovery. + let test_matcher = self.test_matcher(); + // Skip suites with no selected tests in this network pass before deploying the + // contract or executing `setUp`. Fuzz-only mode also excludes unit and symbolic tests. + if !self.matching_test_functions(filter, &test_matcher).into_iter().any(|func| { + !self.mcr.tcfg.fuzz_only + || matches!( + test_matcher.test_function_kind(self.name, func, generated_symbolic_regression), + TestFunctionKind::FuzzTest { .. } | TestFunctionKind::InvariantTest + ) + }) { + return SuiteResult::new(start.elapsed(), BTreeMap::new(), warnings); + } // Check if `setUp` function with valid signature declared. let setup_fns: Vec<_> = @@ -301,11 +867,10 @@ impl<'a> ContractRunner<'a> { // There are multiple setUp function, so we return a single test result for `setUp` if setup_fns.len() > 1 { - return SuiteResult::new( - start.elapsed(), - [("setUp()".to_string(), TestResult::fail("multiple setUp functions".to_string()))] - .into(), + return self.failed_suite( + start, warnings, + [("setUp()".to_string(), TestResult::fail("multiple setUp functions".to_string()))], ); } @@ -313,15 +878,13 @@ impl<'a> ContractRunner<'a> { let after_invariant_fns: Vec<_> = self.contract.abi.functions().filter(|func| func.name.is_after_invariant()).collect(); if after_invariant_fns.len() > 1 { - // Return a single test result failure if multiple functions declared. - return SuiteResult::new( - start.elapsed(), + return self.failed_suite( + start, + warnings, [( "afterInvariant()".to_string(), TestResult::fail("multiple afterInvariant functions".to_string()), - )] - .into(), - warnings, + )], ); } let call_after_invariant = after_invariant_fns.first().is_some_and(|after_invariant_fn| { @@ -335,44 +898,80 @@ impl<'a> ContractRunner<'a> { match_sig }); - // 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 = self + .contract + .abi + .functions() + .filter(|func| { + test_matcher + .test_function_kind(self.name, func, generated_symbolic_regression) + .is_invariant_test() + }) + .collect::>(); + + // Validate signatures up front: invariant functions must take no parameters. Without + // this, parameterized `invariant_*` functions would slip into contract-level campaigns + // and fail with a confusing "selector not found" / decode error mid-campaign. Reject + // here with a per-function result so the failure is obvious to the user. + let invalid_invariants = invariant_fns + .iter() + .filter(|f| !f.inputs.is_empty()) + .map(|f| { + let signature = f.signature(); + let reason = format!("invariant `{signature}` must take no parameters"); + (signature, TestResult::fail(reason)) + }) + .collect::>(); + if !invalid_invariants.is_empty() { + return self.failed_suite(start, warnings, invalid_invariants); + } - let prev_tracer = self.executor.inspector_mut().tracer.take(); - if prev_tracer.is_some() || has_invariants { - self.executor.set_tracing(TraceMode::Call); + for invariant in &invariant_fns { + if invariant.outputs.len() == 1 && invariant.outputs[0].ty == "bool" { + warnings.push(format!( + "Invariant function `{}` returns `bool`, but its return value is ignored; use assertions or revert to indicate failure.", + invariant.signature() + )); + } } + // Invariant testing requires tracing to figure out what contracts were created. + // For regular test runs we disable debug-level setup traces as an optimization. + // In `forge test --debug`, keep setup traces in debug mode so setup failures are + // inspectable in the debugger. + let has_invariants = !invariant_fns.is_empty(); + + let should_override_setup_tracing = + !self.tcfg.debug && (self.executor.inspector().tracer.is_some() || has_invariants); + + let prev_tracer = should_override_setup_tracing.then(|| { + let prev_tracer = self.executor.inspector_mut().tracer.take(); + self.executor.set_trace_requirements(TraceRequirements::none().with_calls(true)); + prev_tracer + }); + let setup_time = Instant::now(); - let setup = self.setup(call_setup); + let mut setup = self.setup(call_setup); debug!("finished setting up in {:?}", setup_time.elapsed()); - self.executor.inspector_mut().tracer = prev_tracer; + if let Some(prev_tracer) = prev_tracer { + self.executor.inspector_mut().tracer = prev_tracer; + } if setup.reason.is_some() { // The setup failed, so we return a single test result for `setUp` - let fail_msg = if !setup.deployment_failure { - "setUp()".to_string() - } else { - "constructor()".to_string() - }; - return SuiteResult::new( - start.elapsed(), - [(fail_msg, TestResult::setup_result(setup))].into(), + let name = if setup.deployment_failure { "constructor()" } else { "setUp()" }; + return self.failed_suite( + start, warnings, + [(name.to_string(), TestResult::setup_result(setup))], ); } // Filter out functions sequentially since it's very fast and there is no need to do it // in parallel. let find_timer = Instant::now(); - let functions = self - .contract - .abi - .functions() - .filter(|func| is_matching_test(func, filter)) - .collect::>(); + let functions = self.matching_test_functions(filter, &self.test_matcher()); debug!( "Found {} test functions out of {} in {:?}", functions.len(), @@ -384,28 +983,139 @@ impl<'a> ContractRunner<'a> { load_contracts(setup.traces.iter().map(|(_, t)| &t.arena), &self.mcr.known_contracts) }); - let test_fail_instances = functions + if let Some(replay) = &self.mcr.tcfg.symbolic_artifact_replay { + let artifact = &replay.artifact; + let target = &artifact.test; + let replay_functions = + functions.iter().filter(|func| func.signature() == target.test).collect::>(); + let func = match replay_functions[..] { + [] if !self.mcr.tcfg.multi_network.all_override_networks.is_empty() => { + return SuiteResult::new(start.elapsed(), BTreeMap::new(), warnings); + } + [] => { + let reason = format!( + "symbolic artifact target `{}` was not found in `{}`", + target.test, target.contract + ); + let results = [(target.test.clone(), TestResult::fail(reason))]; + return SuiteResult::new(start.elapsed(), results.into(), warnings); + } + [func] => *func, + _ => { + let reason = format!( + "symbolic artifact target `{}` matched {} functions in `{}`", + target.test, + replay_functions.len(), + target.contract + ); + let results = [(target.test.clone(), TestResult::fail(reason))]; + return SuiteResult::new(start.elapsed(), results.into(), warnings); + } + }; + + let is_sequence = artifact.kind == SymbolicCounterexampleArtifactKind::Sequence; + let kind = if is_sequence { + func.test_function_kind() + } else { + TestFunctionKind::SymbolicTest + }; + let test_start = Instant::now(); + let mut res = if is_sequence && !kind.is_invariant_test() { + TestResult::fail(format!( + "sequence symbolic artifact must target an invariant test, but matched {} function `{}`", + kind.name(), + func.signature(), + )) + } else { + let invariants = if is_sequence { std::slice::from_ref(&func) } else { &[][..] }; + FunctionRunner::new(&self, &setup).run_symbolic_artifact_replay( + func, + invariants, + call_after_invariant, + ) + }; + res.duration = test_start.elapsed(); + debug!(%kind, path = %replay.path.display(), "replayed symbolic artifact"); + return SuiteResult::new(start.elapsed(), [(func.signature(), res)].into(), warnings); + } + + let test_fail_results = functions .iter() - .filter_map(|func| { - TestFunctionKind::classify(&func.name, !func.inputs.is_empty()) - .is_any_test_fail() - .then_some(func.name.clone()) + .filter(|func| func.test_function_kind().is_any_test_fail()) + .map(|func| { + let reason = "`testFail*` has been removed. Consider changing to test_Revert[If|When]_Condition and expecting a revert"; + (func.signature(), TestResult::fail(reason.to_string())) }) .collect::>(); + if !test_fail_results.is_empty() { + return self.failed_suite(start, warnings, test_fail_results); + } - if !test_fail_instances.is_empty() { - let instances = format!( - "Found {} instances: {}", - test_fail_instances.len(), - test_fail_instances.join(", ") - ); - let fail = TestResult::fail("`testFail*` has been removed. Consider changing to test_Revert[If|When]_Condition and expecting a revert".to_string()); - return SuiteResult::new(start.elapsed(), [(instances, fail)].into(), warnings); + if functions.iter().any(|func| { + matches!( + func.test_function_kind(), + TestFunctionKind::FuzzTest { .. } + | TestFunctionKind::TableTest + | TestFunctionKind::InvariantTest + ) + }) { + setup.fuzz_fixtures = self.fuzz_fixtures(setup.address); } + let early_exit = &self.tcfg.early_exit; + let test_matcher = self.test_matcher(); + if self.progress.is_some() { + let interrupt = early_exit.clone(); + self.tokio_handle.spawn(async move { + signal::ctrl_c().await.expect("Failed to listen for Ctrl+C"); + interrupt.record_ctrl_c(); + }); + } + + let InvariantCampaignSelection { + matched_boolean_invariant_fns, + merge_boolean_suite: merge_invariant_suite, + shared_boolean_namespace, + boolean_suite_anchor: invariant_suite_anchor, + optimization_anchors: _, + } = select_invariant_campaigns( + &invariant_fns, + &functions, + &self.config, + &self.mcr.inline_config, + self.name, + ); + let test_results = functions .par_iter() - .map(|&func| { + .filter_map(|&func| { + // Early exit if we're running with fail-fast and a test already failed. + if early_exit.should_stop() { + return None; + } + // Invariant tests run either as a shared boolean suite or as a single + // optimization campaign; other test kinds keep their original invariant set. + let invariants: &[&Function] = if func.is_invariant_test() { + if is_optimization_invariant(func) { + std::slice::from_ref(&func) + } else if merge_invariant_suite { + // Only the suite anchor runs the merged boolean campaign. + if invariant_suite_anchor != Some(func) { + return None; + } + matched_boolean_invariant_fns.as_slice() + } else { + std::slice::from_ref(&func) + } + } else { + invariant_fns.as_slice() + }; + + // Skip invariant anchors that have no predicates to execute. + if func.is_invariant_test() && invariants.is_empty() { + return None; + } + let start = Instant::now(); let _guard = self.tokio_handle.enter(); @@ -417,7 +1127,8 @@ impl<'a> ContractRunner<'a> { } let sig = func.signature(); - let kind = func.test_function_kind(); + let kind = + test_matcher.test_function_kind(self.name, func, generated_symbolic_regression); let _guard = debug_span!( "test", @@ -428,29 +1139,48 @@ impl<'a> ContractRunner<'a> { let mut res = FunctionRunner::new(&self, &setup).run( func, + invariants, + shared_boolean_namespace, kind, call_after_invariant, identified_contracts.as_ref(), ); res.duration = start.elapsed(); - (sig, res) + // Record test failure for early exit (only triggers if fail-fast is enabled). + if res.status.is_failure() { + early_exit.record_failure(); + } + + Some((sig, res)) }) .collect::>(); - let duration = start.elapsed(); - SuiteResult::new(duration, test_results, warnings) + SuiteResult::new(start.elapsed(), test_results, warnings) + } + + /// Returns a suite that failed before its tests could run, tripping the global fail-fast + /// flag so sibling parallel suites (notably long-running invariant campaigns) observe + /// `should_stop()` and exit at their next run boundary instead of running to their timeout. + fn failed_suite( + &self, + start: Instant, + warnings: Vec, + results: impl IntoIterator, + ) -> SuiteResult { + self.tcfg.early_exit.record_failure(); + SuiteResult::new(start.elapsed(), results.into_iter().collect(), warnings) } } /// Executes a single test function, returning a [`TestResult`]. -struct FunctionRunner<'a> { +struct FunctionRunner<'a, FEN: FoundryEvmNetwork> { /// The function-level configuration. - tcfg: Cow<'a, TestRunnerConfig>, + tcfg: Cow<'a, TestRunnerConfig>, /// The EVM executor. - executor: Cow<'a, Executor>, + executor: Cow<'a, Executor>, /// The parent runner. - cr: &'a ContractRunner<'a>, + cr: &'a ContractRunner<'a, FEN>, /// The address of the test contract. address: Address, /// The test setup result. @@ -459,8 +1189,48 @@ struct FunctionRunner<'a> { result: TestResult, } -impl<'a> std::ops::Deref for FunctionRunner<'a> { - type Target = Cow<'a, TestRunnerConfig>; +/// A replayed and shrunk invariant counterexample. +struct ReplayedInvariantSequence { + call_sequence: Vec, + artifact: Option, + minimization: Option, + fork_block_number: Option, +} + +/// An invariant failure confirmed while solving a captured fuzz frontier. +struct ConfirmedFrontierInvariantFailure { + invariant_idx: usize, + call_sequence: Vec, + replay: CheckSequenceOutcome, +} + +/// A stateful call sequence replay target shared by symbolic minimization and failure checks. +#[derive(Clone, Copy)] +struct SequenceReplay<'a> { + invariant_config: &'a InvariantConfig, + invariant_contract: &'a InvariantContract<'a>, + target_invariant: &'a Function, + assertion_failure: bool, + storage: &'a [SymbolicStorageAssignment], +} + +/// Metadata for the symbolic artifact persisted with a replayed invariant sequence. +struct SequenceArtifactSpec<'a> { + file_name: &'a str, + fail_on_revert: bool, + failure: Option, +} + +/// Returns `true` if two sequence replays failed in the same way at the same site. +fn same_sequence_failure(actual: &CheckSequenceOutcome, expected: &CheckSequenceOutcome) -> bool { + actual.replayed_entirely == expected.replayed_entirely + && actual.reason == expected.reason + && actual.failure_site == expected.failure_site + && actual.sequence_assertion_failure == expected.sequence_assertion_failure +} + +impl<'a, FEN: FoundryEvmNetwork> Deref for FunctionRunner<'a, FEN> { + type Target = Cow<'a, TestRunnerConfig>; #[inline(always)] fn deref(&self) -> &Self::Target { @@ -468,13 +1238,10 @@ impl<'a> std::ops::Deref for FunctionRunner<'a> { } } -impl<'a> FunctionRunner<'a> { - fn new(cr: &'a ContractRunner<'a>, setup: &'a TestSetup) -> Self { +impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> { + fn new(cr: &'a ContractRunner<'a, FEN>, setup: &'a TestSetup) -> Self { Self { - tcfg: match &cr.tcfg { - Cow::Borrowed(tcfg) => Cow::Borrowed(tcfg), - Cow::Owned(tcfg) => Cow::Owned(tcfg.clone()), - }, + tcfg: Cow::Borrowed(cr.tcfg.as_ref()), executor: Cow::Borrowed(&cr.executor), cr, address: setup.address, @@ -483,46 +1250,651 @@ impl<'a> FunctionRunner<'a> { } } - fn revert_decoder(&self) -> &'a RevertDecoder { + const fn revert_decoder(&self) -> &'a RevertDecoder { &self.cr.mcr.revert_decoder } - /// Configures this runner with the inline configuration for the contract. - fn apply_function_inline_config(&mut self, func: &Function) -> Result<()> { - if self.inline_config.contains_function(self.cr.name, &func.name) { - let new_config = Arc::new(self.cr.inline_config(Some(func))?); - self.tcfg.to_mut().reconfigure_with(new_config); - self.tcfg.configure_executor(self.executor.to_mut()); - } - Ok(()) + /// Creates the progress bar for a fuzz or invariant campaign, if progress is shown. + fn fuzz_progress( + &self, + test_name: &str, + timeout: Option, + runs: u32, + ) -> Option { + self.cr.progress?.inner.lock().start_fuzz_progress(self.cr.name, test_name, timeout, runs) } - fn run( - mut self, - func: &Function, - kind: TestFunctionKind, - call_after_invariant: bool, - identified_contracts: Option<&ContractsByAddress>, - ) -> TestResult { - if let Err(e) = self.apply_function_inline_config(func) { - self.result.single_fail(Some(e.to_string())); - return self.result; - } + fn fuzz_minimize_target_id(&self, test_name: &str) -> String { + let network = self + .cr + .mcr + .tcfg + .multi_network + .pass_network + .as_ref() + .map(|network| format!("{network:?}")) + .unwrap_or_else(|| "default".to_string()); + format!("{network}:{}::{test_name}", self.cr.name) + } - match kind { - TestFunctionKind::UnitTest { .. } => self.run_unit_test(func), - TestFunctionKind::FuzzTest { .. } => self.run_fuzz_test(func), - TestFunctionKind::TableTest => self.run_table_test(func), - TestFunctionKind::InvariantTest => { - let test_bytecode = &self.cr.contract.bytecode; - self.run_invariant_test( - func, - call_after_invariant, - identified_contracts.unwrap(), - test_bytecode, - ) - } - _ => unreachable!(), + /// Builds a symbolic counterexample artifact for this test. + fn symbolic_artifact( + &self, + test_name: &str, + kind: SymbolicCounterexampleArtifactKind, + symbolic: &SymbolicResult, + fail_on_revert: bool, + calls: Vec, + ) -> SymbolicCounterexampleArtifact { + SymbolicCounterexampleArtifact::new( + kind, + SymbolicCounterexampleTestIdentity { + contract: self.cr.name.to_string(), + test: test_name.to_string(), + }, + symbolic, + SymbolicCounterexampleReplaySemantics { fail_on_revert }, + calls, + ) + } + + /// Writes `artifact` to the stable per-test path, so the latest counterexample replaces + /// older ones, and returns a reference to it. + fn write_symbolic_artifact( + &self, + file_name: &str, + artifact: &SymbolicCounterexampleArtifact, + ) -> Option { + let dir = self + .config + .cache_path + .join("symbolic") + .join(sanitize_symbolic_artifact_component(self.cr.name)); + let path = dir.join(symbolic_artifact_file_name(self.cr.name, file_name, artifact.kind)); + if let Err(err) = foundry_common::fs::create_dir_all(&dir) { + tracing::error!(%err, path = %dir.display(), "Failed to create symbolic artifact dir"); + return None; + } + if let Err(err) = foundry_common::fs::write_json_file(&path, artifact) { + tracing::error!(%err, path = %path.display(), "Failed to write symbolic artifact"); + return None; + } + Some(SymbolicArtifactRef::new(path)) + } + + /// Persists a replay-confirmed stateful counterexample as a sequence artifact. + fn persist_sequence_artifact( + &self, + test_name: &str, + file_name: &str, + calls: Vec, + fail_on_revert: bool, + storage: &[SymbolicStorageAssignment], + failure: Option, + ) -> Option { + if calls.is_empty() || !self.config.symbolic.enabled { + return None; + } + let symbolic = SymbolicResult::incomplete( + &self.config.symbolic, + SymbolicStopReason::Error, + "concrete replay confirmed stateful counterexample", + SymbolicStats::default(), + SymbolicReplayMetadata::confirmed(), + SymbolicCallTrace::none(), + None, + ); + let mut artifact = self.symbolic_artifact( + test_name, + SymbolicCounterexampleArtifactKind::Sequence, + &symbolic, + fail_on_revert, + calls, + ); + if !storage.is_empty() { + artifact = artifact.with_storage(storage.to_vec()); + } + if let Some(failure) = failure { + artifact = artifact.with_invariant_failure(failure); + } + self.write_symbolic_artifact(file_name, &artifact) + } + + /// Converts a counterexample sequence into artifact calls. + fn sequence_calls( + &self, + call_sequence: &[BaseCounterExample], + ) -> Vec { + call_sequence + .iter() + .map(|counterexample| { + SymbolicCounterexampleCall::from_base_counterexample( + counterexample, + CALLER, + self.address, + ) + }) + .collect() + } + + /// Replays a single-call minimization candidate and checks it fails for `expected_reason`. + fn replay_confirmed_symbolic_single_call( + &self, + call: &SymbolicCounterexampleCall, + expected_reason: Option<&str>, + ) -> Result<(RawCallResult, Option), String> { + let Some(expected_reason) = expected_reason else { + return Err("candidate replay has no stable failure reason to compare".to_string()); + }; + + let mut executor = self.clone_executor(); + let raw = execute_tx(&mut executor, &call.to_basic_tx_details()) + .map_err(|err| err.to_string())?; + if executor.is_raw_call_success(self.address, Cow::Borrowed(&raw.state_changeset), &raw) { + return Err("candidate replay succeeded".to_string()); + } + if let Some(reason) = raw.skip_reason() { + return Err(format!("vm.skip during concrete replay: {reason}")); + } + + let reason = (raw.reverted || raw.exit_reason.is_some_and(|reason| !reason.is_ok())) + .then(|| self.revert_decoder().decode(&raw.result, raw.exit_reason)); + if reason.as_deref() != Some(expected_reason) { + return Err(format!( + "candidate replay failed with different reason: expected `{expected_reason}`, got `{}`", + reason.as_deref().unwrap_or("") + )); + } + Ok((raw, reason)) + } + + /// Shrinks and replays a failing invariant call sequence, persisting the confirmed + /// counterexample as a symbolic artifact. + #[expect(clippy::too_many_arguments)] + fn replay_invariant_error_sequence( + &mut self, + replay: SequenceReplay<'_>, + original_calls: &[BasicTxDetails], + inner_sequence: Option>>, + identified_contracts: &ContractsByAddress, + current_settings: &InvariantSettings, + artifact: SequenceArtifactSpec<'_>, + progress: Option<&indicatif::ProgressBar>, + position: Option<(usize, usize)>, + ) -> Result { + let minimization = self.minimize_symbolic_invariant_sequence( + replay, + original_calls, + identified_contracts, + current_settings, + ); + + let mut replay_config = replay.invariant_config.clone(); + let minimized_txes; + let replay_calls = if let Some(minimization) = &minimization { + minimized_txes = minimization + .minimized_calls + .iter() + .map(SymbolicCounterexampleCall::to_basic_tx_details) + .collect::>(); + replay_config.shrink_run_limit = 0; + minimized_txes.as_slice() + } else { + original_calls + }; + + let ReplayErrorResult { counterexample_sequence: call_sequence, fork_block_number, .. } = + self.replay_error( + replay_config, + self.clone_executor_with_symbolic_storage(replay.storage)?, + replay_calls, + inner_sequence, + replay.assertion_failure, + None, + replay.invariant_contract, + replay.target_invariant, + identified_contracts, + progress, + position, + )?; + + let test_name = replay.target_invariant.signature(); + let calls = self.sequence_calls(&call_sequence); + let (artifact_ref, minimization) = match minimization { + None => ( + self.persist_sequence_artifact( + &test_name, + artifact.file_name, + calls, + artifact.fail_on_revert, + replay.storage, + artifact.failure, + ), + None, + ), + Some(minimization) => { + let original = self.persist_sequence_artifact( + &test_name, + &format!("original__{}", artifact.file_name), + minimization.original_calls.clone(), + artifact.fail_on_revert, + replay.storage, + artifact.failure.clone(), + ); + let minimized = self.persist_sequence_artifact( + &test_name, + artifact.file_name, + calls, + artifact.fail_on_revert, + replay.storage, + artifact.failure, + ); + // Schema v1 cannot persist an empty sequence; retain the confirmed original + // artifact. + let primary = if call_sequence.is_empty() { &original } else { &minimized }; + let primary = primary.clone(); + let metadata = original.zip(minimized).map(|(original, minimized)| { + SymbolicCounterexampleMinimization::new( + original, + minimized, + minimization.attempts, + minimization.accepted, + minimization.original_calldata_bytes(), + minimization.minimized_calldata_bytes(), + ) + .with_sequence_lengths( + minimization.original_calls.len(), + minimization.minimized_calls.len(), + ) + }); + (primary, metadata) + } + }; + + Ok(ReplayedInvariantSequence { + call_sequence, + artifact: artifact_ref, + minimization, + fork_block_number, + }) + } + + /// Shrinks and replays a failing call sequence, collecting logs, traces and coverage into + /// the test result. Returns the counterexample, the terminal check outcome when shrinking + /// re-checked the sequence, and the fork block number. + #[expect(clippy::too_many_arguments)] + fn replay_error( + &mut self, + config: InvariantConfig, + executor: Executor, + calls: &[BasicTxDetails], + inner_sequence: Option>>, + expect_assertion_failure: bool, + target_value: Option, + invariant_contract: &InvariantContract<'_>, + target_invariant: &Function, + identified_contracts: &ContractsByAddress, + progress: Option<&indicatif::ProgressBar>, + position: Option<(usize, usize)>, + ) -> Result { + replay_error( + config, + executor, + calls, + inner_sequence, + expect_assertion_failure, + target_value.is_none().then(|| self.revert_decoder()), + target_value, + invariant_contract, + target_invariant, + &self.cr.mcr.known_contracts, + identified_contracts.clone(), + &mut self.result.logs, + &mut self.result.traces, + &mut self.result.debug_bytecodes, + &mut self.result.line_coverage, + &mut self.result.deprecated_cheatcodes, + progress, + &self.tcfg.early_exit, + position, + ) + } + + fn minimize_symbolic_invariant_sequence( + &self, + replay: SequenceReplay<'_>, + calls: &[BasicTxDetails], + identified_contracts: &ContractsByAddress, + current_settings: &InvariantSettings, + ) -> Option { + if !self.config.symbolic.enabled || calls.is_empty() { + return None; + } + + let original_calls = self.sequence_calls(&base_counterexamples( + calls, + identified_contracts, + replay.invariant_config.show_solidity, + )); + let expected = self.symbolic_sequence_failure(replay, &original_calls)?; + let preserves = |candidate: &[SymbolicCounterexampleCall]| { + self.symbolic_sequence_failure(replay, candidate) + .is_some_and(|actual| same_sequence_failure(&actual, &expected)) + }; + + let minimization = minimize_sequence_counterexample( + &original_calls, + &self.symbolic_sequence_sender_candidates(current_settings), + replay.invariant_config.shrink_run_limit as usize, + preserves, + )?; + preserves(&minimization.minimized_calls).then_some(minimization) + } + + fn symbolic_sequence_sender_candidates( + &self, + current_settings: &InvariantSettings, + ) -> Vec
{ + let mut candidates = if current_settings.target_senders.is_empty() { + vec![self.sender, CALLER, address!("0x0000000000000000000000000000000000000100")] + } else { + current_settings.target_senders.clone() + }; + + candidates.retain(|sender| { + !current_settings.excluded_senders.contains(sender) + && (current_settings.target_senders.is_empty() + || current_settings.target_senders.contains(sender)) + }); + candidates.sort_unstable(); + candidates.dedup(); + candidates + } + + /// Replays `calls` concretely and returns the outcome if the sequence still fails. + fn symbolic_sequence_failure( + &self, + replay: SequenceReplay<'_>, + calls: &[SymbolicCounterexampleCall], + ) -> Option { + let txes = + calls.iter().map(SymbolicCounterexampleCall::to_basic_tx_details).collect::>(); + let sequence = (0..txes.len()).collect::>(); + let outcome = check_sequence( + self.clone_executor_with_symbolic_storage(replay.storage).ok()?, + &txes, + &sequence, + replay.invariant_contract.address, + replay.target_invariant.selector().into(), + CheckSequenceOptions { + accumulate_warp_roll: false, + fail_on_revert: replay.invariant_config.fail_on_revert, + expect_assertion_failure: replay.assertion_failure, + call_after_invariant: replay.invariant_contract.call_after_invariant, + rd: Some(self.revert_decoder()), + }, + ) + .ok()?; + (!outcome.success).then_some(outcome) + } + + /// Converts a persisted counterexample into transactions (applying `show_solidity` in + /// place) and replays it through `check_sequence`. + fn replay_persisted_call_sequence( + &self, + invariant_contract: &InvariantContract<'_>, + call_sequence: &mut [BaseCounterExample], + expect_assertion_failure: bool, + storage: &[SymbolicStorageAssignment], + ) -> Result<(Vec, CheckSequenceOutcome)> { + let config = &self.config.invariant; + let txes = base_counterexamples_to_txes(call_sequence, config.show_solidity); + // Replay the whole persisted sequence: it was produced under the depth of an earlier run, + // and cutting it to the current depth would turn a still-failing sequence into a pass. + let sequence = (0..txes.len()).collect::>(); + let outcome = check_sequence( + self.clone_executor_with_symbolic_storage(storage)?, + &txes, + &sequence, + invariant_contract.address, + invariant_contract.anchor().selector().into(), + CheckSequenceOptions { + accumulate_warp_roll: config.has_delay(), + fail_on_revert: config.fail_on_revert, + expect_assertion_failure, + call_after_invariant: invariant_contract.call_after_invariant, + rd: Some(self.revert_decoder()), + }, + )?; + Ok((txes, outcome)) + } + + /// Replays persisted handler-side assertion bugs. A file is kept only if the anchor still + /// asserts at the same `(reverter, selector)` site; stale files (anchor no longer asserts, + /// asserts at a different site, or earlier call asserts) are deleted in place. + fn replay_persisted_handler_failures( + &self, + handlers_dir: &Path, + current_settings: &InvariantSettings, + ) -> (HandlerFailureMap, SymbolicHandlerStorageMap) { + let mut replayed = HandlerFailureMap::new(); + let mut replayed_storage = SymbolicHandlerStorageMap::default(); + let entries = match std::fs::read_dir(handlers_dir) { + Ok(entries) => entries, + Err(err) => { + if err.kind() != std::io::ErrorKind::NotFound { + error!(%err, "Failed to read handler failure dir"); + } + return (replayed, replayed_storage); + } + }; + let config = &self.config.invariant; + let mut replayed_canonical_files = std::collections::HashSet::::new(); + let mut legacy_files = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) != Some("json") { + continue; + } + let Some(InvariantPersistedFailure { + mut call_sequence, + storage, + failure_site, + fingerprint_provenance, + .. + }) = persisted_call_sequence(&path, current_settings) + else { + continue; + }; + if call_sequence.is_empty() { + let _ = std::fs::remove_file(&path); + continue; + } + let expected_site = failure_site.and_then(|site| match site { + SymbolicInvariantFailureSite::SequenceCall { target, selector, fingerprint } => { + Some((target, selector, fingerprint)) + } + _ => None, + }); + let edge_fingerprinted = + expected_site.is_some_and(|(target, selector, fingerprint)| { + fingerprint != handler_edge_fingerprint(None, target, selector) + }); + let txes = base_counterexamples_to_txes(&mut call_sequence, config.show_solidity); + let sequence = (0..txes.len()).collect::>(); + let mut replay_executor = match self.clone_executor_with_symbolic_storage(&storage) { + Ok(executor) => executor, + Err(err) => { + error!(%err, "Failed to apply symbolic storage for handler-side assertion replay"); + continue; + } + }; + if let Some(provenance) = fingerprint_provenance { + replay_executor + .inspector_mut() + .collect_edge_coverage_with_edge_config(provenance.edge_config()); + } + match replay_handler_failure_sequence( + replay_executor, + &txes, + &sequence, + config.has_delay(), + Some(self.revert_decoder()), + ) { + Ok(outcome) if outcome.anchor_asserted => { + let _ = sh_warn!( + "Replayed handler-side assertion bug from {path:?}. \nRun `forge clean` or remove file to ignore." + ); + if let Some((target, selector, fingerprint)) = expected_site { + let canonical_handler = target == outcome.handler_target; + let legacy_handler = target == outcome.reverter; + let different_handler = + (!canonical_handler && !legacy_handler) || selector != outcome.selector; + let verified_fingerprint_mismatch = fingerprint_provenance.is_some() + && fingerprint != outcome.anchor_fingerprint; + if different_handler || verified_fingerprint_mismatch { + let _ = std::fs::remove_file(&path); + continue; + } + if canonical_handler { + replayed_canonical_files.insert(path); + } else { + legacy_files.push(( + path, + handler_failure_file( + handlers_dir, + outcome.handler_target, + outcome.selector, + ), + )); + } + } + // Legacy edge fingerprints have no reproducible provenance. Retain their + // identity after the handler site reproduces instead of replacing or deleting + // them based on an unverifiable fingerprint. + let fingerprint = if edge_fingerprinted && fingerprint_provenance.is_none() { + expected_site.expect("edge fingerprint has a site").2 + } else { + outcome.anchor_fingerprint + }; + let failure = HandlerAssertionFailure::from_replayed_sequence( + txes, + outcome.handler_target, + outcome.selector, + fingerprint, + outcome.revert_reason.unwrap_or_default(), + ); + let site = (failure.reverter, failure.selector); + // On collision keep the shorter reproducer. + let already_shorter = replayed + .get(&site) + .and_then(InvariantFuzzError::as_handler_assertion) + .is_some_and(|existing| { + existing.call_sequence.len() <= failure.call_sequence.len() + }); + if !already_shorter { + replayed_storage.insert( + (failure.reverter, failure.selector, failure.edge_fingerprint), + SymbolicHandlerReplayStorage { + call_sequence: failure.call_sequence.clone(), + assignments: storage, + fingerprint_provenance, + }, + ); + replayed.insert(site, InvariantFuzzError::HandlerAssertion(failure)); + } + } + // Stale: anchor doesn't assert or earlier call asserts. + Ok(_) => { + let _ = std::fs::remove_file(&path); + } + Err(err) => { + error!(%err, "Failed to replay handler-side assertion bug"); + } + } + } + // A legacy symbolic handler identity is removed only after its canonical replacement has + // itself replayed successfully. This keeps migration safe across interrupted/failed writes + // and independent of directory iteration order. + for (legacy, canonical) in legacy_files { + if replayed_canonical_files.contains(&canonical) { + let _ = std::fs::remove_file(legacy); + } + } + (replayed, replayed_storage) + } + + /// Configures this runner with the inline configuration for the contract. + fn apply_function_inline_config(&mut self, func: &Function) -> Result<()> { + if self.inline_config.contains_function(self.cr.name, &func.name) { + let new_config = Arc::new(self.cr.inline_config(Some(func))?); + self.tcfg.to_mut().reconfigure_with(new_config); + self.tcfg.configure_executor(self.executor.to_mut()); + } + Ok(()) + } + + fn run( + mut self, + func: &Function, + invariants: &[&Function], + shared_invariant_namespace: bool, + kind: TestFunctionKind, + call_after_invariant: bool, + identified_contracts: Option<&ContractsByAddress>, + ) -> TestResult { + if let Err(e) = self.apply_function_inline_config(func) { + self.result.single_fail(Some(e.to_string())); + return self.result; + } + let kind = effective_test_function_kind(kind, &self.config, func); + + // In showmap replay mode and `forge fuzz`, only fuzz/invariant tests are runnable. + if (self.cr.mcr.tcfg.showmap.is_some() || self.cr.mcr.tcfg.fuzz_only) + && matches!( + kind, + TestFunctionKind::UnitTest { .. } + | TestFunctionKind::TableTest + | TestFunctionKind::SymbolicTest + ) + { + if let Some(showmap) = self.cr.mcr.tcfg.showmap.as_ref() { + let mode = if showmap.emit_files { "showmap" } else { "replay" }; + self.result.replay_skip(format!("not runnable in {mode} mode")); + } else if self.cr.mcr.tcfg.fuzz_failure_replay { + self.result + .single_skip(SkipReason(Some("not runnable in replay mode".to_string()))); + } else { + self.result.single_skip(SkipReason(Some("not runnable in fuzz mode".to_string()))); + } + return self.result; + } + + match kind { + TestFunctionKind::UnitTest { .. } => self.run_unit_test(func), + TestFunctionKind::FuzzTest { .. } => self.run_fuzz_test(func), + TestFunctionKind::TableTest => self.run_table_test(func), + TestFunctionKind::SymbolicTest => self.run_symbolic_test(func), + TestFunctionKind::InvariantTest => { + 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.iter().copied().map(|f| (f, fail_on_revert_for(f))).collect(); + self.run_invariant_test( + func, + invariant_fns, + shared_invariant_namespace, + call_after_invariant, + identified_contracts.unwrap(), + ) + } + _ => unreachable!(), } } @@ -541,548 +1913,4444 @@ impl<'a> FunctionRunner<'a> { } // Run current unit test. - let (mut raw_call_result, reason) = match self.executor.call( + let Ok((mut raw_call_result, reason)) = self.call_test(func, &[]) else { + return self.result; + }; + let success = self.executor.is_raw_call_mut_success(self.address, &mut raw_call_result); + self.result.single_result(success, reason, raw_call_result); + self.result + } + + /// Calls `func` on the test contract, returning the raw result and revert reason. Skipped + /// and failed calls are recorded in the test result and returned as `Err`. + fn call_test( + &mut self, + func: &Function, + args: &[DynSolValue], + ) -> Result<(RawCallResult, Option), ()> { + match self.executor.call( self.sender, self.address, func, - &[], + args, U256::ZERO, Some(self.revert_decoder()), ) { - Ok(res) => (res.raw, None), - Err(EvmError::Execution(err)) => (err.raw, Some(err.reason)), + Ok(res) => Ok((res.raw, None)), + Err(EvmError::Execution(err)) => Ok((err.raw, Some(err.reason))), Err(EvmError::Skip(reason)) => { self.result.single_skip(reason); - return self.result; + Err(()) } Err(err) => { self.result.single_fail(Some(err.to_string())); - return self.result; + Err(()) } - }; + } + } - let success = - self.executor.is_raw_call_mut_success(self.address, &mut raw_call_result, false); - self.result.single_result(success, reason, raw_call_result); - self.result + /// Builds the symbolic executor input for one run of `func`. + fn symbolic_run_input<'f>( + &'f self, + func: &'f Function, + sender: Address, + collect_success_input: bool, + corpus_seeds: Vec, + branch_target: Option, + ) -> SymbolicRunInput<'f, FEN> { + SymbolicRunInput { + executor: self.executor.as_ref(), + target: self.address, + sender, + function: func, + value: U256::ZERO, + ffi_enabled: self.config.ffi, + collect_success_input, + corpus_seeds, + branch_target, + } } - /// Runs a table test. - /// The parameters dataset (table) is created from defined parameter fixtures, therefore each - /// test table parameter should have the same number of fixtures defined. - /// E.g. for table test - /// - `table_test(uint256 amount, bool swap)` fixtures are defined as - /// - `uint256[] public fixtureAmount = [2, 5]` - /// - `bool[] public fixtureSwap = [true, false]` The `table_test` is then called with the pair - /// of args `(2, true)` and `(5, false)`. - fn run_table_test(mut self, func: &Function) -> TestResult { - // Prepare unit test execution. - if self.prepare_test(func).is_err() { - return self.result; + /// Sets the per-test corpus directory in `fuzz_config` and returns the test path name, the + /// legacy corpus directory and the persisted failure `(dir, file)` paths. + fn fuzz_test_paths<'f>( + &self, + func: &'f Function, + fuzz_config: &mut FuzzConfig, + ) -> (Cow<'f, str>, Option, (PathBuf, PathBuf)) { + let test_name = fuzz_test_path_name(&self.cr.contract.abi, func, fuzz_config, self.cr.name); + let legacy_corpus_dir = legacy_fuzz_corpus_dir( + fuzz_config.corpus.corpus_dir.as_deref(), + self.cr.name, + func, + &test_name, + ); + let failure_paths = test_paths( + &mut fuzz_config.corpus, + fuzz_config.failure_persist_dir.clone().unwrap(), + self.cr.name, + &test_name, + ); + (test_name, legacy_corpus_dir, failure_paths) + } + + /// Imports persisted fuzz corpus entries as symbolic path-priority hints. + fn import_symbolic_fuzz_corpus( + &self, + func: &Function, + ) -> (Vec, Option) { + let mut inputs = Vec::new(); + if !should_symbolically_import_fuzz_corpus(&self.config, func) { + return (inputs, None); } - // Extract and validate fixtures for the first table test parameter. - let Some(first_param) = func.inputs.first() else { - self.result.single_fail(Some("Table test should have at least one parameter".into())); - return self.result; + let mut fuzz_config = self.config.fuzz.clone(); + let (_, legacy_corpus_dir, _) = self.fuzz_test_paths(func, &mut fuzz_config); + let corpus_dir = legacy_corpus_dir.or(fuzz_config.corpus.corpus_dir); + let limit = self.config.symbolic.corpus_seed_limit; + let mut metadata = SymbolicCorpusSeedMetadata { + corpus_dir: corpus_dir.clone(), + limit, + loaded: 0, + skipped: 0, + used: Vec::new(), }; - - let Some(first_param_fixtures) = - &self.setup.fuzz_fixtures.param_fixtures(first_param.name()) - else { - self.result.single_fail(Some("Table test should have fixtures defined".into())); - return self.result; + let Some(corpus_dir) = corpus_dir else { + let _ = sh_warn!( + "`--symbolic-use-fuzz-corpus` requires `--fuzz-corpus-dir` or `fuzz.corpus_dir`; \ + running without imported corpus seeds" + ); + return (inputs, Some(metadata)); }; + if limit == 0 { + return (inputs, Some(metadata)); + } - if first_param_fixtures.is_empty() { - self.result.single_fail(Some("Table test should have at least one fixture".into())); - return self.result; + 'dirs: for replay_dir in canonical_replay_dirs(&corpus_dir) { + let mut entries = read_corpus_dir(&replay_dir).collect::>(); + entries.sort_by(|left, right| left.path.cmp(&right.path)); + for entry in entries { + if inputs.len() >= limit { + break 'dirs; + } + metadata.loaded += 1; + let input = match entry.read_tx_seq() { + Ok(tx_seq) => self.symbolic_corpus_seed_input(func, &tx_seq), + Err(err) => { + debug!(%err, path = %entry.path.display(), "failed to read symbolic corpus seed"); + None + } + }; + let Some(input) = input else { + metadata.skipped += 1; + continue; + }; + metadata.used.push(SymbolicCorpusSeedRef { + path: entry.path, + calldata: input.calldata.clone(), + }); + inputs.push(input); + } } - let fixtures_len = first_param_fixtures.len(); - let mut table_fixtures = vec![&first_param_fixtures[..]]; + debug!( + test = %func.signature(), + corpus_dir = %corpus_dir.display(), + loaded = metadata.loaded, + skipped = metadata.skipped, + imported = inputs.len(), + "imported symbolic fuzz corpus seeds" + ); + (inputs, Some(metadata)) + } - // Collect fixtures for remaining parameters. - for param in &func.inputs[1..] { - let param_name = param.name(); - let Some(fixtures) = &self.setup.fuzz_fixtures.param_fixtures(param.name()) else { - self.result.single_fail(Some(format!("No fixture defined for param {param_name}"))); - return self.result; - }; + /// Imports persisted fuzz branch frontiers as `(id, sender, branch target, input)` seeds. + fn import_symbolic_fuzz_frontiers( + &self, + func: &Function, + fuzz_config: &FuzzConfig, + ) -> Vec<(u64, Address, SymbolicBranchTarget, SymbolicConcreteInput)> { + let limit = self.config.symbolic.frontier_limit; + if limit == 0 { + return Vec::new(); + } - if fixtures.len() != fixtures_len { - self.result.single_fail(Some(format!( - "{} fixtures defined for {param_name} (expected {})", - fixtures.len(), - fixtures_len - ))); - return self.result; + let Some(frontier_dir) = fuzz_config.corpus.frontier_dir.as_ref() else { + let _ = sh_warn!( + "`--symbolic-use-fuzz-frontiers` requires `--fuzz-frontier-dir` or \ + `fuzz.frontier_dir`; running without targeted frontier seeds" + ); + return Vec::new(); + }; + + let frontier_path = frontier_dir.join(FUZZ_BRANCH_FRONTIER_FILE); + let artifact = match foundry_common::fs::read_json_file::( + &frontier_path, + ) { + Ok(artifact) => artifact, + Err(err) => { + debug!( + %err, + path = %frontier_path.display(), + "failed to read fuzz branch frontier artifact" + ); + return Vec::new(); } + }; - table_fixtures.push(&fixtures[..]); + if artifact.schema != FUZZ_BRANCH_FRONTIER_SCHEMA || artifact.version != 1 { + warn!( + schema = %artifact.schema, + version = artifact.version, + path = %frontier_path.display(), + "unsupported fuzz branch frontier artifact" + ); + return Vec::new(); + } + let signature = func.signature(); + if artifact.test != signature { + warn!( + artifact_test = %artifact.test, + test = %signature, + path = %frontier_path.display(), + "fuzz branch frontier artifact does not match symbolic target" + ); + return Vec::new(); } - let progress = start_fuzz_progress( - self.cr.progress, - self.cr.name, - &func.name, - None, - fixtures_len as u32, - ); - - for i in 0..fixtures_len { - // Increment progress bar. - if let Some(progress) = progress.as_ref() { - progress.inc(1); + let requested_ids = &self.config.symbolic.frontier_ids; + let requested_pcs = &self.config.symbolic.frontier_pcs; + let requested_selectors = &self.config.symbolic.frontier_selectors; + let parsed_selectors = parse_frontier_selectors(requested_selectors, &signature); + let selection_active = !requested_ids.is_empty() + || !requested_pcs.is_empty() + || !requested_selectors.is_empty(); + let mut skipped_by_selection = 0usize; + let mut imported_ids = Vec::new(); + let mut imported_pcs = Vec::new(); + let mut imported_selectors = Vec::new(); + let mut imported = Vec::with_capacity(limit.min(artifact.frontiers.len())); + for frontier in artifact.frontiers { + let selector = frontier_selector(&frontier); + if (!requested_ids.is_empty() && !requested_ids.contains(&frontier.id)) + || (!requested_pcs.is_empty() && !requested_pcs.contains(&frontier.site.pc)) + || (!requested_selectors.is_empty() + && selector.is_none_or(|selector| !parsed_selectors.contains(&selector))) + { + skipped_by_selection += 1; + continue; } - - let args = table_fixtures.iter().map(|row| row[i].clone()).collect_vec(); - let (mut raw_call_result, reason) = match self.executor.call( - self.sender, - self.address, - func, - &args, - U256::ZERO, - Some(self.revert_decoder()), - ) { - Ok(res) => (res.raw, None), - Err(EvmError::Execution(err)) => (err.raw, Some(err.reason)), - Err(EvmError::Skip(reason)) => { - self.result.single_skip(reason); - return self.result; + if imported.len() == limit { + if selection_active { + continue; } - Err(err) => { - self.result.single_fail(Some(err.to_string())); - return self.result; - } - }; - - let is_success = - self.executor.is_raw_call_mut_success(self.address, &mut raw_call_result, false); - // Record counterexample if test fails. - if !is_success { - self.result.counterexample = - Some(CounterExample::Single(BaseCounterExample::from_fuzz_call( - Bytes::from(func.abi_encode_input(&args).unwrap()), - args, - raw_call_result.traces.clone(), - ))); - self.result.single_result(false, reason, raw_call_result); - return self.result; + break; } - - // If it's the last iteration and all other runs succeeded, then use last call result - // for logs and traces. - if i == fixtures_len - 1 { - self.result.single_result(true, None, raw_call_result); - return self.result; + if !matches!( + frontier.site.opcode, + opcode::EQ | opcode::LT | opcode::GT | opcode::SLT | opcode::SGT | opcode::ISZERO + ) { + debug!( + opcode = frontier.site.opcode, + id = frontier.id, + "skipping unsupported fuzz branch frontier opcode" + ); + continue; } + let ([call], 0) = (frontier.sequence.as_slice(), frontier.call_index) else { + debug!( + id = frontier.id, + sequence_len = frontier.sequence.len(), + call_index = frontier.call_index, + "skipping non-stateless fuzz branch frontier" + ); + continue; + }; + let Some(input) = self.symbolic_corpus_seed_input(func, std::slice::from_ref(call)) + else { + debug!(id = frontier.id, "skipping fuzz branch frontier with incompatible call"); + continue; + }; + let target = SymbolicBranchTarget::new( + frontier.site.address, + frontier.site.pc, + frontier.site.opcode, + frontier.operands.result, + ); + imported.push((frontier.id, call.sender, target, input)); + imported_ids.push(frontier.id); + imported_pcs.push(frontier.site.pc); + imported_selectors.extend(selector); } - self.result - } - - fn run_invariant_test( - mut self, - func: &Function, - call_after_invariant: bool, - identified_contracts: &ContractsByAddress, - test_bytecode: &Bytes, - ) -> TestResult { - // First, run the test normally to see if it needs to be skipped. - if let Err(EvmError::Skip(reason)) = self.executor.call( - self.sender, - self.address, - func, - &[], - U256::ZERO, - Some(self.revert_decoder()), - ) { - self.result.invariant_skip(reason); - return self.result; - }; - - let runner = self.invariant_runner(); - let invariant_config = &self.config.invariant; - - let mut evm = InvariantExecutor::new( - self.clone_executor(), - runner, - invariant_config.clone(), - identified_contracts, - &self.cr.mcr.known_contracts, + warn_unimported_frontiers("id", requested_ids, &imported_ids, &signature, &frontier_path); + warn_unimported_frontiers("pc", requested_pcs, &imported_pcs, &signature, &frontier_path); + warn_unimported_frontiers( + "selector", + &parsed_selectors, + &imported_selectors, + &signature, + &frontier_path, ); - let invariant_contract = InvariantContract { - address: self.address, - invariant_function: func, - call_after_invariant, - abi: &self.cr.contract.abi, - }; + if selection_active { + let _ = sh_status!( + "Symbolic frontier selection for {signature}: imported {}, skipped {} by target \ + filters (ids: {}; pcs: {}; selectors: {}; limit: {limit})", + imported.len(), + skipped_by_selection, + frontier_filter_display(requested_ids), + frontier_filter_display(requested_pcs), + frontier_filter_display(requested_selectors), + ); + } - let (failure_dir, failure_file) = invariant_failure_paths( - invariant_config, - self.cr.name, - &invariant_contract.invariant_function.name, + debug!( + test = %signature, + path = %frontier_path.display(), + imported = imported.len(), + limit, + skipped_by_selection, + requested_ids = ?requested_ids, + requested_pcs = ?requested_pcs, + requested_selectors = ?requested_selectors, + "imported fuzz branch frontiers for targeted symbolic seeding" ); - let show_solidity = invariant_config.show_solidity; + imported + } - // Try to replay recorded failure if any. - if let Some(mut call_sequence) = - persisted_call_sequence(failure_file.as_path(), test_bytecode) - { - // Create calls from failed sequence and check if invariant still broken. - let txes = call_sequence - .iter_mut() - .map(|seq| { - seq.show_solidity = show_solidity; - BasicTxDetails { - sender: seq.sender.unwrap_or_default(), - call_details: CallDetails { - target: seq.addr.unwrap_or_default(), - calldata: seq.calldata.clone(), - }, - } - }) - .collect::>(); - if let Ok((success, replayed_entirely)) = check_sequence( - self.clone_executor(), - &txes, - (0..min(txes.len(), invariant_config.depth as usize)).collect(), - invariant_contract.address, - invariant_contract.invariant_function.selector().to_vec().into(), - invariant_config.fail_on_revert, - invariant_contract.call_after_invariant, - ) && !success - { - let _ = sh_warn!( - "\ - Replayed invariant failure from {:?} file. \ - Run `forge clean` or remove file to ignore failure and to continue invariant test campaign.", - failure_file.as_path() - ); - // If sequence still fails then replay error to collect traces and - // exit without executing new runs. - let _ = 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, - &txes, - show_solidity, - ); - self.result.invariant_replay_fail( - replayed_entirely, - &invariant_contract.invariant_function.name, - call_sequence, - ); - return self.result; - } + fn import_symbolic_invariant_frontiers( + &self, + invariant_contract: &InvariantContract<'_>, + invariant_config: &InvariantConfig, + ) -> Vec<(FuzzBranchFrontierRecord, Arc<[BasicTxDetails]>)> { + let limit = self.config.symbolic.frontier_limit; + if limit == 0 { + return Vec::new(); } - let progress = start_fuzz_progress( - self.cr.progress, - self.cr.name, - &func.name, - invariant_config.timeout, - invariant_config.runs, - ); - let invariant_result = match evm.invariant_fuzz( - invariant_contract.clone(), - &self.setup.fuzz_fixtures, - &self.setup.deployed_libs, - progress.as_ref(), + let Some(frontier_dir) = invariant_config.corpus.frontier_dir.as_ref() else { + let _ = sh_warn!( + "Symbolic invariant frontier seeding requires `--invariant-frontier-dir` or \ + `invariant.frontier_dir`; running without targeted frontier seeds" + ); + return Vec::new(); + }; + let frontier_path = frontier_dir.join(FUZZ_BRANCH_FRONTIER_FILE); + let artifact = match foundry_common::fs::read_json_file::( + &frontier_path, ) { - Ok(x) => x, - Err(e) => { - self.result.invariant_setup_fail(e); - return self.result; + Ok(artifact) => artifact, + Err(err) => { + debug!( + %err, + path = %frontier_path.display(), + "failed to read invariant branch frontier artifact" + ); + return Vec::new(); } }; - // Merge coverage collected during invariant run with test setup coverage. - self.result.merge_coverages(invariant_result.line_coverage); + if artifact.schema != STATEFUL_FUZZ_BRANCH_FRONTIER_SCHEMA || artifact.version != 2 { + warn!( + schema = %artifact.schema, + version = artifact.version, + path = %frontier_path.display(), + "unsupported invariant branch frontier artifact" + ); + return Vec::new(); + } + let signature = invariant_contract.anchor().signature(); + // Boolean frontiers describe handler execution, so their recorded predicate anchor is + // provenance; candidates are replayed against the currently selected predicates. + if invariant_contract.is_optimization() && artifact.test != signature { + warn!( + artifact_test = %artifact.test, + test = %signature, + path = %frontier_path.display(), + "invariant branch frontier artifact does not match campaign anchor" + ); + return Vec::new(); + } - 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 replay_error( - &case_data, - &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, - progress.as_ref(), - show_solidity, - ) { - Ok(call_sequence) => { - if !call_sequence.is_empty() { - // Persist error in invariant failure dir. - if let Err(err) = foundry_common::fs::create_dir_all(failure_dir) { - error!(%err, "Failed to create invariant failure dir"); - } else if let Err(err) = foundry_common::fs::write_json_file( - failure_file.as_path(), - &InvariantPersistedFailure { - call_sequence: call_sequence.clone(), - driver_bytecode: Some(test_bytecode.clone()), - }, - ) { - error!(%err, "Failed to record call sequence"); - } - - 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)) - } - } - Err(err) => { - error!(%err, "Failed to replay invariant error"); - } - }; - } - InvariantFuzzError::MaxAssumeRejects(_) => {} - }, + let requested_ids = &self.config.symbolic.frontier_ids; + let requested_pcs = &self.config.symbolic.frontier_pcs; + let requested_selectors = &self.config.symbolic.frontier_selectors; + let parsed_selectors = parse_frontier_selectors(requested_selectors, &signature); + let select_frontier_ids = !requested_ids.is_empty(); + let select_frontier_pcs = !requested_pcs.is_empty(); + let select_frontier_selectors = !requested_selectors.is_empty(); - // If invariants ran successfully, replay the last run to collect logs and - // 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"); - } + let FuzzBranchFrontierArtifact { sequences, mut frontiers, .. } = artifact; + let sequences = + sequences.into_iter().map(Arc::<[BasicTxDetails]>::from).collect::>(); + let mut observed_results = HashMap::<(Address, usize, u8), u8>::default(); + for frontier in &frontiers { + let key = (frontier.site.address, frontier.site.pc, frontier.site.opcode); + let result_bit = if frontier.operands.result { 2 } else { 1 }; + *observed_results.entry(key).or_default() |= result_bit; + } + for frontier in &mut frontiers { + frontier.both_results_retained = observed_results + .get(&(frontier.site.address, frontier.site.pc, frontier.site.opcode)) + .is_some_and(|results| *results == 3); + } + frontiers.retain(|frontier| { + if select_frontier_ids && !requested_ids.contains(&frontier.id) { + return false; + } + if select_frontier_pcs && !requested_pcs.contains(&frontier.site.pc) { + return false; + } + if !matches!( + frontier.site.opcode, + opcode::EQ | opcode::LT | opcode::GT | opcode::SLT | opcode::SGT | opcode::ISZERO + ) { + return false; + } + let Some(sequence) = frontier.sequence_index.and_then(|index| sequences.get(index)) + else { + debug!(id = frontier.id, "skipping invariant frontier with missing sequence"); + return false; + }; + let Some(call) = sequence.get(frontier.call_index) else { + debug!( + id = frontier.id, + call_index = frontier.call_index, + sequence_len = sequence.len(), + "skipping invariant frontier with invalid call index" + ); + return false; + }; + if call.warp.is_some_and(|warp| !warp.is_zero()) + || call.roll.is_some_and(|roll| !roll.is_zero()) + || call.call_details.value.is_some_and(|value| !value.is_zero()) + { + return false; } + let selector = call + .call_details + .calldata + .get(..4) + .and_then(|selector| <[u8; 4]>::try_from(selector).ok()) + .map(Selector::from); + !(select_frontier_selectors + && selector.is_none_or(|selector| !parsed_selectors.contains(&selector))) + }); + let explicit_selection = + select_frontier_ids || select_frontier_pcs || select_frontier_selectors; + let frontiers = select_stateful_frontiers(frontiers, limit, explicit_selection); + + let mut imported = Vec::with_capacity(frontiers.len()); + for frontier in frontiers { + let sequence = sequences + .get(frontier.sequence_index.expect("frontier sequence index was validated")) + .expect("frontier sequence was validated"); + imported.push((frontier, Arc::clone(sequence))); } - self.result.invariant_result( - invariant_result.gas_report_traces, - success, - reason, - counterexample, - invariant_result.cases, - invariant_result.reverts, - invariant_result.metrics, - invariant_result.failed_corpus_replays, + debug!( + test = %signature, + path = %frontier_path.display(), + imported = imported.len(), + limit, + "imported invariant branch frontiers for targeted symbolic seeding" ); - self.result + imported } - /// Runs a fuzzed test. - /// - /// Applies the before test txes (if any), fuzzes the current function and returns the - /// `TestResult`. - /// - /// Before test txes are applied in order and state modifications committed to the EVM database - /// (therefore the fuzz test will use the modified state). - /// State modifications of before test txes and fuzz test are discarded after test ends, - /// similar to `eth_call`. - fn run_fuzz_test(mut self, func: &Function) -> TestResult { - // Prepare fuzz test execution. + fn symbolic_corpus_seed_input( + &self, + func: &Function, + tx_seq: &[BasicTxDetails], + ) -> Option { + let [tx] = tx_seq else { + return None; + }; + if tx.call_details.target != self.address + || !tx.call_details.value.unwrap_or_default().is_zero() + { + return None; + } + let calldata = &tx.call_details.calldata; + if calldata.get(..4) != Some(func.selector().as_slice()) { + return None; + } + let args = func.abi_decode_input(&calldata[4..]).ok()?; + Some(SymbolicConcreteInput { args, calldata: calldata.clone() }) + } + + /// Runs a symbolic test and replays any discovered counterexample concretely. + fn run_symbolic_test(mut self, func: &Function) -> TestResult { if self.prepare_test(func).is_err() { return self.result; } - let runner = self.fuzz_runner(); - let fuzz_config = self.config.fuzz.clone(); + let (corpus_seeds, mut corpus_seed_metadata) = self.import_symbolic_fuzz_corpus(func); + if let Some(metadata) = corpus_seed_metadata.as_mut() { + match SymbolicExecutor::modeled_corpus_seed_indexes( + &self.config.symbolic, + func, + &corpus_seeds, + ) { + Ok(indexes) => { + let mut indexes = indexes.into_iter().peekable(); + metadata.used = std::mem::take(&mut metadata.used) + .into_iter() + .enumerate() + .filter_map(|(idx, seed)| indexes.next_if_eq(&idx).map(|_| seed)) + .collect(); + } + Err(err) => { + debug!( + %err, + test = %func.signature(), + "failed to model imported symbolic corpus seeds" + ); + } + } + } + let symbolic_config = self.config.symbolic.clone(); + let mut symbolic = SymbolicExecutor::new(symbolic_config.clone()); + let result = + symbolic.run(self.symbolic_run_input(func, self.sender, false, corpus_seeds, None)); - let progress = start_fuzz_progress( - self.cr.progress, - self.cr.name, - &func.name, - fuzz_config.timeout, - fuzz_config.runs, + let (status, reason, counterexample, symbolic_result) = match result { + SymbolicRunResult::Safe { stats, .. } => { + (TestStatus::Success, None, None, SymbolicResult::pass(&symbolic_config, stats)) + } + SymbolicRunResult::Incomplete { kind, reason, stats } => ( + TestStatus::Failure, + Some(format!("incomplete symbolic execution ({kind:?}): {reason}")), + None, + SymbolicResult::incomplete( + &symbolic_config, + kind, + reason, + stats, + SymbolicReplayMetadata::not_required(), + SymbolicCallTrace::none(), + None, + ), + ), + SymbolicRunResult::Counterexample { args, calldata, stats } => { + self.replay_symbolic_counterexample(func, args, calldata, stats, &symbolic_config) + } + }; + let symbolic_result = match corpus_seed_metadata { + Some(metadata) => symbolic_result.with_corpus_seeds(metadata), + None => symbolic_result, + }; + self.result.symbolic_result(status, reason, counterexample, symbolic_result); + self.result + } + + /// Replays a symbolic counterexample concretely, minimizing and persisting it when it + /// reproduces. + fn replay_symbolic_counterexample( + &mut self, + func: &Function, + args: Vec, + calldata: Bytes, + stats: SymbolicStats, + symbolic_config: &SymbolicConfig, + ) -> (TestStatus, Option, Option, SymbolicResult) { + let symbolic_counterexample = SymbolicCounterexample::from( + &BaseCounterExample::from_fuzz_call(calldata.clone(), args.clone(), None), ); + let incomplete = |reason: String, replay, call_trace| { + SymbolicResult::incomplete( + symbolic_config, + SymbolicStopReason::Error, + reason, + stats, + replay, + call_trace, + Some(symbolic_counterexample.clone()), + ) + }; - // Run fuzz test. - let fuzzed_executor = - FuzzedExecutor::new(self.executor.into_owned(), runner, self.tcfg.sender, fuzz_config); - let result = fuzzed_executor.fuzz( - func, - &self.setup.fuzz_fixtures, - &self.setup.deployed_libs, + let (raw, reason) = match self.executor.call( + self.sender, self.address, - &self.cr.mcr.revert_decoder, - progress.as_ref(), - ); - self.result.fuzz_result(result); - self.result - } + func, + &args, + U256::ZERO, + Some(self.revert_decoder()), + ) { + Ok(res) => (res.raw, None), + Err(EvmError::Execution(err)) => (err.raw, Some(err.reason)), + Err(EvmError::Skip(reason)) => { + let replay_reason = format!("vm.skip during concrete replay: {reason}"); + let symbolic_result = incomplete( + "concrete replay skipped the symbolic counterexample".to_string(), + SymbolicReplayMetadata::skipped(replay_reason), + SymbolicCallTrace::none(), + ); + return (TestStatus::Skipped, reason.0, None, symbolic_result); + } + Err(err) => { + let reason = err.to_string(); + let symbolic_result = incomplete( + reason.clone(), + SymbolicReplayMetadata::error(reason.clone()), + SymbolicCallTrace::none(), + ); + return (TestStatus::Failure, Some(reason), None, symbolic_result); + } + }; - /// Prepares single unit test and fuzz test execution: - /// - set up the test result and executor - /// - check if before test txes are configured and apply them in order - /// - /// Before test txes are arrays of arbitrary calldata obtained by calling the `beforeTest` - /// function with test selector as a parameter. - /// - /// Unit tests within same contract (or even current test) are valid options for before test tx - /// configuration. Test execution stops if any of before test txes fails. - fn prepare_test(&mut self, func: &Function) -> Result<(), ()> { - let address = self.setup.address; + let base_counterexample = + BaseCounterExample::from_fuzz_call(calldata, args, raw.traces.clone()); + if self.executor.is_raw_call_success( + self.address, + Cow::Borrowed(&raw.state_changeset), + &raw, + ) { + // The solver model is not a user-facing counterexample until replay confirms it, so + // report the mismatch as an incomplete run instead. + let call_trace = SymbolicCallTrace::test_result_traces(raw.traces.is_some()); + self.result.extend(raw); + let reason = "symbolic counterexample did not replay".to_string(); + let display_reason = format!( + "incomplete symbolic execution ({:?}): {reason}", + SymbolicStopReason::Error + ); + let symbolic_result = + incomplete(reason.clone(), SymbolicReplayMetadata::mismatch(reason), call_trace); + return (TestStatus::Failure, Some(display_reason), None, symbolic_result); + } - // Apply before test configured functions (if any). - if self.cr.contract.abi.functions().filter(|func| func.name.is_before_test_setup()).count() - == 1 + let original_call = SymbolicCounterexampleCall::from_base_counterexample( + &base_counterexample, + self.sender, + self.address, + ); + let mut final_call = original_call.clone(); + let mut final_raw = raw; + let mut final_reason = reason; + let mut minimization = None; + if final_reason.is_some() + && let Some(candidate) = minimize_single_call_counterexample( + func, + &original_call, + self.tcfg.config.invariant.shrink_run_limit as usize, + |candidate| { + self.replay_confirmed_symbolic_single_call(candidate, final_reason.as_deref()) + .is_ok() + }, + ) { - for calldata in self.executor.call_sol_default( - address, - &ITest::beforeTestSetupCall { testSelector: func.selector() }, - ) { - // Apply before test configured calldata. - match self.executor.to_mut().transact_raw( - self.tcfg.sender, - address, - calldata, - U256::ZERO, + if candidate.changed() { + match self.replay_confirmed_symbolic_single_call( + &candidate.minimized_call, + final_reason.as_deref(), ) { - Ok(call_result) => { - let reverted = call_result.reverted; - - // Merge tx result traces in unit test result. - self.result.extend(call_result); - - // To continue unit test execution the call should not revert. - if reverted { - self.result.single_fail(None); - return Err(()); - } + Ok((raw, reason)) => { + final_call = candidate.minimized_call.clone(); + final_raw = raw; + final_reason = reason; + minimization = Some(candidate); } - Err(_) => { - self.result.single_fail(None); - return Err(()); + Err(err) => { + warn!( + %err, + "discarding symbolic counterexample minimization result that no longer replays" + ); } } + } else { + minimization = Some(candidate); } } - Ok(()) - } - fn fuzz_runner(&self) -> TestRunner { - let config = &self.config.fuzz; - let failure_persist_path = config - .failure_persist_dir - .as_ref() - .unwrap() - .join(config.failure_persist_file.as_ref().unwrap()) - .into_os_string() - .into_string() - .unwrap(); - fuzzer_with_cases( - config.seed, - config.runs, - config.max_test_rejects, - Some(Box::new(FileFailurePersistence::Direct(failure_persist_path.leak()))), + let call_trace = SymbolicCallTrace::test_result_traces(final_raw.traces.is_some()); + let mut base_counterexample = final_call.to_base_counterexample(); + base_counterexample.traces = final_raw.traces.clone(); + self.result.extend(final_raw); + + let signature = func.signature(); + let fail_on_revert = self.config.invariant.fail_on_revert; + let kind = SymbolicCounterexampleArtifactKind::SingleCall; + let mut symbolic_result = SymbolicResult::fail_counterexample( + symbolic_config, + stats, + call_trace, + SymbolicCounterexample::from(&base_counterexample), + ); + let minimized_artifact = self.write_symbolic_artifact( + &signature, + &self.symbolic_artifact( + &signature, + kind, + &symbolic_result, + fail_on_revert, + vec![final_call], + ), + ); + if let Some(artifact) = minimized_artifact.clone() { + symbolic_result = symbolic_result.with_artifact(artifact); + } + if let Some(minimization) = minimization { + let original_result = SymbolicResult::fail_counterexample( + symbolic_config, + stats, + SymbolicCallTrace::none(), + symbolic_counterexample, + ); + let original_artifact = self.write_symbolic_artifact( + &format!("original__{signature}"), + &self.symbolic_artifact( + &signature, + kind, + &original_result, + fail_on_revert, + vec![minimization.original_call.clone()], + ), + ); + if let Some((original, minimized)) = original_artifact.zip(minimized_artifact) { + symbolic_result = + symbolic_result.with_minimization(SymbolicCounterexampleMinimization::new( + original, + minimized, + minimization.attempts, + minimization.accepted, + minimization.original_call.calldata.len(), + minimization.minimized_call.calldata.len(), + )); + } + } + ( + TestStatus::Failure, + final_reason, + Some(CounterExample::Single(base_counterexample)), + symbolic_result, ) } - fn invariant_runner(&self) -> TestRunner { - let config = &self.config.invariant; - fuzzer_with_cases(self.config.fuzz.seed, config.runs, config.max_assume_rejects, None) + /// Replays a durable symbolic counterexample artifact against this freshly set up test. + fn run_symbolic_artifact_replay( + mut self, + func: &Function, + invariants: &[&Function], + call_after_invariant: bool, + ) -> TestResult { + if let Err(reason) = self.replay_symbolic_artifact(func, invariants, call_after_invariant) { + self.result.single_fail(Some(reason)); + } + self.result } - fn clone_executor(&self) -> Executor { - self.executor.clone().into_owned() - } -} + /// Replays a persisted symbolic counterexample artifact against `func`, failing with the + /// mismatch reason when the recorded outcome does not reproduce. + fn replay_symbolic_artifact( + &mut self, + func: &Function, + invariants: &[&Function], + call_after_invariant: bool, + ) -> Result<(), String> { + let Some(replay) = &self.cr.mcr.tcfg.symbolic_artifact_replay else { + return Err("missing symbolic artifact replay config".to_string()); + }; + let artifact = &replay.artifact; + self.apply_function_inline_config(func).map_err(|e| e.to_string())?; -fn fuzzer_with_cases( - seed: Option, - cases: u32, - max_global_rejects: u32, - file_failure_persistence: Option>, -) -> TestRunner { - let config = proptest::test_runner::Config { - failure_persistence: file_failure_persistence, - cases, - max_global_rejects, - // Disable proptest shrink: for fuzz tests we provide single counterexample, - // for invariant tests we shrink outside proptest. - max_shrink_iters: 0, - ..Default::default() - }; + match artifact.kind { + SymbolicCounterexampleArtifactKind::SingleCall => { + if artifact.replay.status != SymbolicReplayStatus::Confirmed { + return Err(format!( + "single-call symbolic artifact replay status must be confirmed, got {:?}", + artifact.replay.status + )); + } + let Some(call) = artifact.calls.first() else { + return Err("symbolic artifact has no calls".to_string()); + }; + if artifact.calls.len() != 1 { + return Err( + "single-call symbolic artifact must contain exactly one call".to_string() + ); + } + // Single-call artifacts are concrete replay inputs: sender, value, warp, and roll + // are intentionally taken from the artifact. Validation only checks that the call + // still targets this test function. + if call.target != self.address { + return Err(format!( + "single-call symbolic artifact target {} does not match test contract {}", + call.target, self.address + )); + } + if call.calldata.get(..4).is_none_or(|selector| func.selector() != selector) { + return Err(format!( + "single-call symbolic artifact calldata does not match `{}` selector", + func.signature() + )); + } - if let Some(seed) = seed { - trace!(target: "forge::test", %seed, "building deterministic fuzzer"); - let rng = TestRng::from_seed(RngAlgorithm::ChaCha, &seed.to_be_bytes::<32>()); - TestRunner::new_with_rng(config, rng) - } else { - trace!(target: "forge::test", "building stochastic fuzzer"); - TestRunner::new(config) - } -} + if self.prepare_test(func).is_err() { + return Ok(()); + } + + let counterexample = || CounterExample::Single(call.to_base_counterexample()); + let mut executor = self.clone_executor(); + let raw = match execute_tx(&mut executor, &call.to_basic_tx_details()) { + Ok(raw) => raw, + Err(err) => { + self.result.counterexample = Some(counterexample()); + return Err(err.to_string()); + } + }; + if executor.is_raw_call_success( + self.address, + Cow::Borrowed(&raw.state_changeset), + &raw, + ) { + self.result.single_result(true, None, raw); + return Ok(()); + } + match raw.into_evm_error(Some(self.revert_decoder())) { + EvmError::Execution(err) => { + let reason = if err.reason.is_empty() { + artifact.replay.reason.clone() + } else { + Some(err.reason.clone()) + }; + self.result.single_result(false, reason, err.raw); + self.result.counterexample = Some(counterexample()); + } + EvmError::Skip(reason) => self.result.single_skip(reason), + err => { + self.result.counterexample = Some(counterexample()); + return Err(err.to_string()); + } + } + } + SymbolicCounterexampleArtifactKind::Sequence => { + let Some(invariant) = invariants.first() else { + return Err( + "sequence symbolic artifact must target an invariant test".to_string() + ); + }; + if artifact.calls.is_empty() { + return Err("symbolic artifact has no calls".to_string()); + } + + let calls = artifact + .calls + .iter() + .map(SymbolicCounterexampleCall::to_base_counterexample) + .collect::>(); + let txes = artifact + .calls + .iter() + .map(SymbolicCounterexampleCall::to_basic_tx_details) + .collect::>(); + let setup_contracts = load_contracts( + self.setup.traces.iter().map(|(_, trace)| &trace.arena), + &self.cr.mcr.known_contracts, + ); + let mut evm = InvariantExecutor::new_with_fuzz_seed( + self.clone_executor(), + self.invariant_runner(), + self.config.fuzz.seed, + self.config.invariant.clone(), + &setup_contracts, + &self.cr.mcr.known_contracts, + self.cr.num_invariant_campaign_anchors, + ); + if let Err(err) = evm.select_contract_artifacts(self.address) { + self.result.invariant_setup_fail(err); + return Ok(()); + } + let (sender_filters, targeted) = + match evm.select_contracts_and_senders(self.address) { + Ok(selected) => selected, + Err(err) => { + self.result.invariant_setup_fail(err); + return Ok(()); + } + }; + let artifact_executor = + match self.clone_executor_with_symbolic_storage(&artifact.storage) { + Ok(executor) => executor, + Err(err) => { + self.result.counterexample = + Some(CounterExample::Sequence(calls.len(), calls)); + return Err(err.to_string()); + } + }; + + let dynamic_target_ctx = evm.dynamic_target_ctx(); + let mut validation_executor = + targeted.is_updatable.then(|| artifact_executor.clone()); + let mut validation_created_contracts = Vec::new(); + for (idx, tx) in txes.iter().enumerate() { + let Some(selector) = tx.call_details.calldata.get(..4) else { + return Err(format!( + "sequence symbolic artifact call {} has calldata shorter than a selector", + idx + 1 + )); + }; + if !targeted.targets().can_replay(tx) { + return Err(format!( + "sequence symbolic artifact call {} targets unknown function {} on {}", + idx + 1, + hex::encode_prefixed(selector), + tx.call_details.target + )); + } + if !sender_filters.allows(tx.sender) { + return Err(format!( + "sequence symbolic artifact call {} uses forbidden sender {}", + idx + 1, + tx.sender + )); + } + if let Some(validation_executor) = validation_executor.as_mut() { + execute_tx_and_register_created( + validation_executor, + tx, + &targeted, + &dynamic_target_ctx, + &mut validation_created_contracts, + ) + .map_err(|err| { + format!( + "sequence symbolic artifact call {} failed during target validation: {err}", + idx + 1 + ) + })?; + } + } + + let artifact_failure = artifact.invariant_failure.as_ref(); + if matches!( + artifact_failure, + Some(SymbolicInvariantArtifactFailure::Predicate { site: None, .. }) + ) { + return Err( + "sequence symbolic artifact does not identify an exact predicate failure site" + .to_string(), + ); + } + let is_handler_artifact = matches!( + artifact_failure, + Some(SymbolicInvariantArtifactFailure::Handler { .. }) + ); + let sequence = (0..txes.len()).collect::>(); + let outcome = match check_sequence( + artifact_executor, + &txes, + &sequence, + self.setup.address, + invariant.selector().into(), + CheckSequenceOptions { + // Artifact replay executes every stored call in order, so each call's + // warp/roll delta is applied directly. Accumulation is only needed when a + // shrink candidate skips calls and must fold removed delays forward. + accumulate_warp_roll: false, + fail_on_revert: is_handler_artifact + || artifact.replay_semantics.fail_on_revert, + expect_assertion_failure: is_handler_artifact, + call_after_invariant, + rd: Some(self.revert_decoder()), + }, + ) { + Ok(outcome) => outcome, + Err(err) => { + self.result.counterexample = + Some(CounterExample::Sequence(calls.len(), calls)); + return Err(err.to_string()); + } + }; + if outcome.success { + self.result.invariant_replay_success(outcome.calls_count, outcome.reverts); + return Ok(()); + } + match artifact_failure { + Some(SymbolicInvariantArtifactFailure::Handler { + name, + reverter, + selector, + fingerprint, + }) => { + let Some(CheckSequenceFailureSite::SequenceCall { + target: actual_target, + selector: actual_selector, + fingerprint: actual_fingerprint, + }) = outcome.failure_site + else { + return Err(format!( + "sequence symbolic artifact replayed a non-handler failure site: \ + {:?}", + outcome.failure_site + )); + }; + let canonical_handler = *reverter == actual_target + && *selector == actual_selector + && *fingerprint == actual_fingerprint; + let legacy_handler = outcome.sequence_reverter == Some(*reverter) + && *reverter != actual_target + && *selector == actual_selector + && (*fingerprint == actual_fingerprint + || *fingerprint + == handler_edge_fingerprint(None, *reverter, *selector)); + if !canonical_handler && !legacy_handler { + return Err(format!( + "sequence symbolic artifact replayed a different handler \ + failure site than the stored artifact: expected \ + {reverter}::{selector} at {fingerprint}, got {:?}", + outcome.failure_site + )); + } + let handler_name = if legacy_handler { + invariant_handler_failure_name( + &setup_contracts, + actual_target, + actual_selector, + ) + } else { + name.clone().unwrap_or_else(|| { + invariant_handler_failure_name( + &setup_contracts, + actual_target, + actual_selector, + ) + }) + }; + self.result.invariant_result( + invariant_kind(1, outcome.calls_count, outcome.reverts), + InvariantOutcome { + handler_failures: vec![InvariantFailure::Handler { + name: handler_name, + reverter: actual_target, + selector: actual_selector, + reason: outcome + .reason + .or_else(|| artifact.replay.reason.clone()) + .unwrap_or_else(|| { + "symbolic handler counterexample".to_string() + }), + counterexample: Some(CounterExample::Sequence( + calls.len(), + calls, + )), + artifact: Some(SymbolicArtifactRef::new(replay.path.clone())), + }], + ..Default::default() + }, + ); + } + _ => { + if let Some(SymbolicInvariantArtifactFailure::Predicate { site, .. }) = + artifact_failure + && outcome.failure_site.map(SymbolicInvariantFailureSite::from) != *site + { + return Err(format!( + "sequence symbolic artifact replayed a different failure \ + origin than the stored predicate: got {:?}", + outcome.failure_site + )); + } + let signature = invariant.signature(); + let invariant_name = match artifact_failure { + Some(SymbolicInvariantArtifactFailure::Predicate { name, .. }) => { + name.as_str() + } + _ => signature.as_str(), + }; + self.result.invariant_replay_fail( + outcome, + invariant_name, + artifact.replay.reason.clone(), + calls, + ); + } + } + } + } + Ok(()) + } + + fn try_seed_fuzz_corpus_from_frontiers(&self, func: &Function, fuzz_config: &FuzzConfig) { + if !self.config.symbolic.use_fuzz_frontiers || !func.test_function_kind().is_fuzz_test() { + return; + } + if fuzz_config.corpus.corpus_dir.is_none() { + let _ = sh_warn!( + "`--symbolic-use-fuzz-frontiers` requires `--fuzz-corpus-dir` or \ + `fuzz.corpus_dir`; skipping targeted frontier seeding" + ); + return; + } + + for (id, sender, target, input) in self.import_symbolic_fuzz_frontiers(func, fuzz_config) { + let mut symbolic = SymbolicExecutor::new(self.config.symbolic.clone()); + let result = symbolic.run(self.symbolic_run_input( + func, + sender, + true, + vec![input], + Some(target), + )); + + let (input, expect_failure) = match result { + SymbolicRunResult::Safe { success_input: Some(input), .. } => (input, false), + SymbolicRunResult::Safe { success_input: None, .. } => { + warn!( + id, + test = %func.signature(), + "targeted symbolic frontier produced no branch-flipping input" + ); + continue; + } + SymbolicRunResult::Incomplete { kind, reason, .. } => { + warn!( + id, + ?kind, + %reason, + test = %func.signature(), + "targeted symbolic frontier incomplete" + ); + continue; + } + SymbolicRunResult::Counterexample { args, calldata, .. } => { + (SymbolicConcreteInput { args, calldata }, true) + } + }; + + let replay = self.symbolic_fuzz_seed_replay(sender, &input, fuzz_config); + if replay != Some(!expect_failure) { + warn!( + id, + ?replay, + test = %func.signature(), + "targeted symbolic frontier seed did not replay with the expected outcome" + ); + continue; + } + + match self.persist_symbolic_fuzz_seed(&fuzz_config.corpus, sender, input.calldata) { + Ok(Some(path)) => { + debug!( + id, + path = %path.display(), + test = %func.signature(), + "persisted targeted symbolic frontier seed" + ); + } + Ok(None) => {} + Err(err) => { + warn!( + %err, + id, + test = %func.signature(), + "failed to persist targeted symbolic frontier seed" + ); + } + } + } + } + + fn replay_invariant_sequence( + &self, + invariant_contract: &InvariantContract<'_>, + invariant_idx: usize, + sequence: &[BasicTxDetails], + replay_order: &[usize], + call_after_invariant: bool, + ) -> Option { + let policy = invariant_contract.invariant_fns[invariant_idx].1; + let outcome = check_sequence( + self.clone_executor(), + sequence, + replay_order, + invariant_contract.address, + invariant_contract.invariant_calldata(invariant_idx), + CheckSequenceOptions { + accumulate_warp_roll: false, + fail_on_revert: policy, + expect_assertion_failure: false, + call_after_invariant, + rd: Some(self.revert_decoder()), + }, + ) + .ok()?; + (!outcome.success && outcome.replayed_entirely).then_some(outcome) + } + + fn replay_invariant_checkpoints( + &self, + invariant_contract: &InvariantContract<'_>, + invariant_config: &InvariantConfig, + sequence: &[BasicTxDetails], + terminal_checkpoint: bool, + ) -> (Vec<(usize, CheckSequenceOutcome)>, Option) { + let mut broken_invariants = Vec::new(); + let mut remaining_invariants = vec![true; invariant_contract.invariant_fns.len()]; + let replay_order = (0..sequence.len()).collect::>(); + + if !invariant_contract.is_optimization() { + for accepted_calls in 1..=sequence.len() { + let should_check = invariant_config.check_interval == 1 + || (invariant_config.check_interval > 1 + && accepted_calls.is_multiple_of(invariant_config.check_interval as usize)) + || (terminal_checkpoint && accepted_calls == sequence.len()); + if !should_check { + continue; + } + + for (invariant_idx, pending) in remaining_invariants.iter_mut().enumerate() { + if !*pending { + continue; + } + let Some(replay) = self.replay_invariant_sequence( + invariant_contract, + invariant_idx, + &sequence[..accepted_calls], + &replay_order[..accepted_calls], + false, + ) else { + continue; + }; + if matches!( + replay.failure_site, + Some(CheckSequenceFailureSite::Invariant { selector, .. }) + if selector == invariant_contract.invariant_fns[invariant_idx].0.selector() + ) { + *pending = false; + broken_invariants.push((invariant_idx, replay)); + } + } + } + } + + let after_invariant_failure = if terminal_checkpoint + && invariant_contract.call_after_invariant + && broken_invariants.is_empty() + { + invariant_contract + .abi + .functions() + .find(|function| { + function.name == "afterInvariant" && function.inputs.is_empty() + }) + .and_then(|after_invariant| { + let calldata = after_invariant.abi_encode_input(&[]).ok()?.into(); + let mut replay = check_sequence( + self.clone_executor(), + sequence, + &replay_order, + invariant_contract.address, + calldata, + CheckSequenceOptions { + accumulate_warp_roll: false, + fail_on_revert: false, + expect_assertion_failure: false, + call_after_invariant: false, + rd: Some(self.revert_decoder()), + }, + ) + .ok()?; + if replay.success || !replay.replayed_entirely { + return None; + } + let Some(CheckSequenceFailureSite::Invariant { + target, + selector, + fingerprint, + }) = replay.failure_site + else { + return None; + }; + if selector != after_invariant.selector() { + return None; + } + replay.failure_site = Some(CheckSequenceFailureSite::AfterInvariant { + target, + selector, + fingerprint, + }); + Some(replay) + }) + } else { + None + }; + + (broken_invariants, after_invariant_failure) + } + + fn solve_invariants_from_frontier_prefix( + &self, + invariant_contract: &InvariantContract<'_>, + invariant_indexes: &[usize], + prefix_executor: &Executor, + target: &SymbolicInvariantTarget, + sender: Address, + prefix: &[BasicTxDetails], + ) -> Vec<(usize, CheckSequenceOutcome, Vec)> { + let after_invariant = invariant_contract + .call_after_invariant + .then(|| { + invariant_contract.abi.functions().find(|function| { + function.name == "afterInvariant" && function.inputs.is_empty() + }) + }) + .flatten(); + + let invariants = invariant_indexes + .iter() + .map(|&idx| invariant_contract.invariant_fns[idx].0) + .collect::>(); + let mut symbolic = SymbolicExecutor::new(self.config.symbolic.clone()); + let result = symbolic.search_invariant_candidates(SymbolicInvariantCandidateInput { + executor: prefix_executor, + invariant_address: invariant_contract.address, + invariants: &invariants, + after_invariant, + target, + handler_sender: sender, + ffi_enabled: self.config.ffi, + }); + if let Some(limitation) = &result.limitation { + debug!( + ?limitation.kind, + reason = %limitation.reason, + candidates = result.candidates.len(), + "symbolic invariant frontier candidate search incomplete" + ); + } + + result + .candidates + .into_iter() + .filter_map(|candidate| { + if !candidate.storage.is_empty() { + return None; + } + let invariant_idx = invariant_indexes[candidate.invariant_idx]; + let call = BasicTxDetails { + warp: None, + roll: None, + sender: candidate.step.sender, + call_details: CallDetails { + target: candidate.step.address, + calldata: candidate.step.calldata, + value: None, + }, + }; + let mut sequence = Vec::with_capacity(prefix.len() + 1); + sequence.extend_from_slice(prefix); + sequence.push(call); + let replay_order = (0..sequence.len()).collect::>(); + let replay = self.replay_invariant_sequence( + invariant_contract, + invariant_idx, + &sequence, + &replay_order, + after_invariant.is_some(), + )?; + let exact_failure = match replay.failure_site? { + CheckSequenceFailureSite::Invariant { selector, .. } => { + selector == invariant_contract.invariant_fns[invariant_idx].0.selector() + } + CheckSequenceFailureSite::AfterInvariant { .. } => true, + CheckSequenceFailureSite::SequenceCall { .. } => false, + }; + exact_failure.then_some((invariant_idx, replay, sequence)) + }) + .collect() + } + + fn try_seed_invariant_corpus_from_frontiers( + &self, + invariant_contract: &InvariantContract<'_>, + invariant_config: &InvariantConfig, + sender_filters: &SenderFilters, + targeted_contracts: &FuzzRunIdentifiedContracts, + dynamic_target_ctx: &DynamicTargetCtx<'_>, + confirmed_invariants: &HashSet, + ) -> Vec { + if !self.config.symbolic.use_fuzz_frontiers + && !self.config.symbolic.check_invariant_frontiers + { + return Vec::new(); + } + if invariant_config.corpus.corpus_dir.is_none() { + let _ = sh_warn!( + "Symbolic invariant frontier seeding requires `--invariant-corpus-dir` or \ + `invariant.corpus_dir`; skipping targeted invariant frontier seeding" + ); + return Vec::new(); + } + + let mut checked_property_calls = HashSet::<(usize, usize)>::default(); + let mut reported_invariants = confirmed_invariants.clone(); + let mut after_invariant_reported = false; + let mut confirmed_failures = Vec::new(); + let is_optimization = invariant_contract.is_optimization(); + let fail_on_revert = invariant_contract.invariant_fns.iter().any(|(_, policy)| *policy); + for (frontier, sequence) in + self.import_symbolic_invariant_frontiers(invariant_contract, invariant_config) + { + let id = frontier.id; + let call_index = frontier.call_index; + let max_depth = match invariant_config.depth_mode { + InvariantDepthMode::Fixed => invariant_config.depth, + InvariantDepthMode::Random => invariant_config.depth.max(1), + }; + if call_index >= max_depth as usize { + debug!( + id, + call_index, + depth = max_depth, + "skipping invariant frontier beyond campaign depth" + ); + continue; + } + let Some(call) = sequence.get(call_index) else { + continue; + }; + let accepted_calls = call_index + 1; + let terminal_checkpoint = match invariant_config.depth_mode { + InvariantDepthMode::Fixed => accepted_calls == invariant_config.depth as usize, + InvariantDepthMode::Random => { + let min_depth = invariant_config.min_depth.max(1); + let max_depth = invariant_config.depth.max(1); + let accepted_calls = accepted_calls as u32; + if invariant_config.depth <= min_depth { + accepted_calls == max_depth + } else { + (min_depth..=max_depth).contains(&accepted_calls) + } + } + }; + let Some(selector) = call + .call_details + .calldata + .get(..4) + .and_then(|selector| <[u8; 4]>::try_from(selector).ok()) + .map(Selector::from) + else { + continue; + }; + let mut prefix_executor = self.clone_executor(); + let mut created_contracts = Vec::new(); + let prefix_targets = FuzzRunIdentifiedContracts::new( + targeted_contracts.targets().clone(), + targeted_contracts.is_updatable, + ); + let prefix_result = sequence[..call_index].iter().try_for_each(|prefix_call| { + if !prefix_targets.targets().can_replay(prefix_call) + || !sender_filters.allows(prefix_call.sender) + { + return Err(eyre::eyre!( + "frontier prefix call is not eligible for this campaign" + )); + } + execute_tx_and_register_created( + &mut prefix_executor, + prefix_call, + &prefix_targets, + dynamic_target_ctx, + &mut created_contracts, + ) + }); + if let Err(err) = prefix_result { + debug!(%err, id, "failed to replay invariant frontier prefix"); + continue; + } + if !sender_filters.allows(call.sender) { + debug!(id, sender = %call.sender, "skipping invariant frontier with forbidden sender"); + continue; + } + let invariant_target = { + let targets = prefix_targets.targets(); + targets.get(&call.call_details.target).and_then(|contract| { + contract.fuzzed_function_by_selector(selector).map(|function| { + SymbolicInvariantTarget { + address: call.call_details.target, + contract_name: Some(contract.identifier.clone()), + function: function.clone(), + } + }) + }) + }; + let Some(invariant_target) = invariant_target else { + debug!(id, selector = %selector, "skipping invariant frontier with unknown target function"); + continue; + }; + let function = &invariant_target.function; + let Ok(args) = function.abi_decode_input(&call.call_details.calldata[4..]) else { + debug!(id, selector = %selector, "skipping invariant frontier with invalid calldata"); + continue; + }; + + let input = + SymbolicConcreteInput { args, calldata: call.call_details.calldata.clone() }; + let property_target_reported = if invariant_contract.call_after_invariant { + reported_invariants.contains(&invariant_contract.anchor_idx) + || after_invariant_reported + } else { + reported_invariants.len() == invariant_contract.invariant_fns.len() + }; + if !is_optimization + && self.config.symbolic.check_invariant_frontiers + && !property_target_reported + && checked_property_calls.insert(( + frontier.sequence_index.expect("frontier sequence index was validated"), + call_index, + )) + { + let mut invariant_indexes = if invariant_contract.call_after_invariant { + vec![invariant_contract.anchor_idx] + } else { + (0..invariant_contract.invariant_fns.len()) + .filter(|idx| !reported_invariants.contains(idx)) + .collect::>() + }; + if invariant_indexes.len() > 1 { + let rotation = (checked_property_calls.len() - 1) % invariant_indexes.len(); + invariant_indexes.rotate_left(rotation); + } + for (_, _, solved_sequence) in self.solve_invariants_from_frontier_prefix( + invariant_contract, + &invariant_indexes, + &prefix_executor, + &invariant_target, + call.sender, + &sequence[..call_index], + ) { + let (broken_invariants, after_invariant_failure) = self + .replay_invariant_checkpoints( + invariant_contract, + invariant_config, + &solved_sequence, + terminal_checkpoint, + ); + for (invariant_idx, replay) in broken_invariants { + if reported_invariants.insert(invariant_idx) { + confirmed_failures.push(ConfirmedFrontierInvariantFailure { + invariant_idx, + call_sequence: solved_sequence.clone(), + replay, + }); + } + } + if let Some(replay) = after_invariant_failure + && !after_invariant_reported + { + after_invariant_reported = true; + confirmed_failures.push(ConfirmedFrontierInvariantFailure { + invariant_idx: invariant_contract.anchor_idx, + call_sequence: solved_sequence.clone(), + replay, + }); + } + match persist_corpus_seed(&invariant_config.corpus, solved_sequence) { + Ok(path) => { + if let Some(path) = path { + debug!(id, path = %path.display(), "persisted property-directed invariant frontier seed"); + } + } + Err(err) => { + warn!(%err, id, "failed to persist property-directed invariant frontier seed"); + } + } + } + } + + let mut symbolic = SymbolicExecutor::new(self.config.symbolic.clone()); + let target = SymbolicBranchTarget::new( + frontier.site.address, + frontier.site.pc, + frontier.site.opcode, + frontier.operands.result, + ); + let search = symbolic.search_branch_target(SymbolicRunInput { + executor: &prefix_executor, + target: call.call_details.target, + sender: call.sender, + function, + value: U256::ZERO, + ffi_enabled: self.config.ffi, + collect_success_input: false, + corpus_seeds: vec![input], + branch_target: Some(target), + }); + if let SymbolicRunResult::Incomplete { kind, reason, .. } = &search.execution { + debug!( + id, + ?kind, + %reason, + candidates = search.candidates.len(), + "targeted invariant frontier incomplete" + ); + } else if search.candidates.is_empty() { + debug!(id, "targeted invariant frontier produced no branch-flipping input"); + } + + let mut selected_branch_seed = None; + let mut selected_failure_seed = false; + for solved_input in search.candidates { + let mut solved_sequence = sequence[..=call_index].to_vec(); + solved_sequence[call_index].call_details.calldata = solved_input.calldata; + let mut replay_executor = prefix_executor.clone(); + replay_executor.inspector_mut().collect_evm_cmp_log(true); + let replay_result = + match execute_tx(&mut replay_executor, &solved_sequence[call_index]) { + Ok(result) => result, + Err(err) => { + debug!(%err, id, "failed to replay solved invariant frontier"); + continue; + } + }; + let comparisons = replay_result.evm_cmp_values.as_deref().unwrap_or_default(); + let branch_flipped = frontier_comparison_flipped( + frontier.site, + frontier.operands.result, + comparisons, + ); + let assertion_failure = + did_fail_on_assert(&replay_result, &replay_result.state_changeset); + let accepted = replay_result.result.as_ref() != MAGIC_ASSUME + && (!replay_result.reverted || fail_on_revert || assertion_failure); + if !branch_flipped || !accepted { + debug!( + id, + branch_flipped, + reverted = replay_result.reverted, + fail_on_revert, + "solved invariant frontier was not eligible during concrete replay" + ); + continue; + } + let (broken_invariants, after_invariant_failure) = self + .replay_invariant_checkpoints( + invariant_contract, + invariant_config, + &solved_sequence, + terminal_checkpoint, + ); + let newly_broken_invariants = broken_invariants + .iter() + .filter(|(idx, _)| reported_invariants.insert(*idx)) + .collect::>(); + let after_invariant_failure = + after_invariant_failure.filter(|_| !after_invariant_reported); + if !newly_broken_invariants.is_empty() || after_invariant_failure.is_some() { + for (invariant_idx, replay) in newly_broken_invariants { + confirmed_failures.push(ConfirmedFrontierInvariantFailure { + invariant_idx: *invariant_idx, + call_sequence: solved_sequence.clone(), + replay: replay.clone(), + }); + } + if let Some(replay) = after_invariant_failure.clone() { + after_invariant_reported = true; + confirmed_failures.push(ConfirmedFrontierInvariantFailure { + invariant_idx: invariant_contract.anchor_idx, + call_sequence: solved_sequence.clone(), + replay, + }); + } + match persist_corpus_seed(&invariant_config.corpus, solved_sequence.clone()) { + Ok(path) => { + if let Some(path) = path { + debug!( + id, + ?broken_invariants, + ?after_invariant_failure, + path = %path.display(), + "persisted property-breaking branch frontier seed" + ); + } + } + Err(err) => { + warn!(%err, id, "failed to persist property-breaking branch frontier seed"); + } + } + } + + if assertion_failure || replay_result.reverted { + if !selected_failure_seed { + selected_branch_seed = Some(solved_sequence); + selected_failure_seed = true; + } + } else { + selected_branch_seed.get_or_insert(solved_sequence); + } + } + if let Some(sequence) = selected_branch_seed { + match persist_corpus_seed(&invariant_config.corpus, sequence) { + Ok(Some(path)) => { + debug!(id, path = %path.display(), "persisted targeted invariant frontier seed"); + } + Ok(None) => {} + Err(err) => { + warn!(%err, id, "failed to persist targeted invariant frontier seed"); + } + } + } + } + confirmed_failures + } + + fn try_seed_fuzz_corpus_symbolically(&self, func: &Function, fuzz_config: &FuzzConfig) { + if !self.config.symbolic.seed_corpus || !func.test_function_kind().is_fuzz_test() { + return; + } + if fuzz_config.corpus.corpus_dir.is_none() { + let _ = sh_warn!( + "`--symbolic-seed-corpus` requires `--fuzz-corpus-dir` or `fuzz.corpus_dir`; \ + skipping symbolic corpus seeding" + ); + return; + } + + let mut symbolic = SymbolicExecutor::new(self.config.symbolic.clone()); + let result = + symbolic.run(self.symbolic_run_input(func, self.sender, true, Vec::new(), None)); + + let input = match result { + SymbolicRunResult::Safe { success_input: Some(input), .. } => input, + SymbolicRunResult::Safe { success_input: None, .. } => { + warn!(test = %func.signature(), "symbolic fuzz corpus seeding found no successful input"); + return; + } + SymbolicRunResult::Incomplete { kind, reason, .. } => { + warn!(?kind, %reason, test = %func.signature(), "symbolic fuzz corpus seeding incomplete"); + return; + } + SymbolicRunResult::Counterexample { .. } => { + warn!(test = %func.signature(), "symbolic fuzz corpus seeding found a counterexample"); + return; + } + }; + + if self.symbolic_fuzz_seed_replay(self.sender, &input, fuzz_config) != Some(true) { + warn!(test = %func.signature(), "symbolic fuzz corpus seed did not pass concrete replay"); + return; + } + + if let Err(err) = + self.persist_symbolic_fuzz_seed(&fuzz_config.corpus, self.sender, input.calldata) + { + warn!(%err, test = %func.signature(), "failed to persist symbolic fuzz corpus seed"); + } + } + + /// Persists a concretely confirmed symbolic input as a fuzz corpus seed. + fn persist_symbolic_fuzz_seed( + &self, + corpus: &FuzzCorpusConfig, + sender: Address, + calldata: Bytes, + ) -> foundry_common::fs::Result> { + persist_corpus_seed( + corpus, + vec![BasicTxDetails { + warp: None, + roll: None, + sender, + call_details: CallDetails { + target: self.address, + calldata, + value: Some(U256::ZERO), + }, + }], + ) + } + + /// Replays a symbolic seed concretely: `Some(success)`, or `None` if the input was rejected. + fn symbolic_fuzz_seed_replay( + &self, + sender: Address, + input: &SymbolicConcreteInput, + fuzz_config: &FuzzConfig, + ) -> Option { + let raw = self + .clone_executor() + .call_raw(sender, self.address, input.calldata.clone(), U256::ZERO) + .ok()?; + if raw.result.as_ref() == MAGIC_ASSUME { + return None; + } + Some( + should_ignore_revert( + fuzz_config.fail_on_revert, + self.address, + raw.reverter, + self.executor.inspector().extra_cheatcode_addresses(), + ) || self.executor.is_raw_call_success( + self.address, + Cow::Borrowed(&raw.state_changeset), + &raw, + ), + ) + } + + /// Runs a table test. + /// The parameters dataset (table) is created from defined parameter fixtures, therefore each + /// test table parameter should have the same number of fixtures defined. + /// E.g. for table test + /// - `table_test(uint256 amount, bool swap)` fixtures are defined as + /// - `uint256[] public fixtureAmount = [2, 5]` + /// - `bool[] public fixtureSwap = [true, false]` The `table_test` is then called with the pair + /// of args `(2, true)` and `(5, false)`. + fn run_table_test(mut self, func: &Function) -> TestResult { + // Prepare unit test execution. + if self.prepare_test(func).is_err() { + return self.result; + } + + // Extract and validate fixtures for the first table test parameter. + let Some(first_param) = func.inputs.first() else { + self.result.single_fail(Some("Table test should have at least one parameter".into())); + return self.result; + }; + + let Some(first_param_fixtures) = + &self.setup.fuzz_fixtures.param_fixtures(first_param.name()) + else { + self.result.single_fail(Some("Table test should have fixtures defined".into())); + return self.result; + }; + + if first_param_fixtures.is_empty() { + self.result.single_fail(Some("Table test should have at least one fixture".into())); + return self.result; + } + + let fixtures_len = first_param_fixtures.len(); + let mut table_fixtures = vec![&first_param_fixtures[..]]; + + // Collect fixtures for remaining parameters. + for param in &func.inputs[1..] { + let param_name = param.name(); + let Some(fixtures) = &self.setup.fuzz_fixtures.param_fixtures(param.name()) else { + self.result.single_fail(Some(format!("No fixture defined for param {param_name}"))); + return self.result; + }; + + if fixtures.len() != fixtures_len { + self.result.single_fail(Some(format!( + "{} fixtures defined for {param_name} (expected {})", + fixtures.len(), + fixtures_len + ))); + return self.result; + } + + table_fixtures.push(&fixtures[..]); + } + + let progress = self.fuzz_progress(&func.name, None, fixtures_len as u32); + + let mut result = FuzzTestResult::default(); + + for i in 0..fixtures_len { + if self.tcfg.early_exit.should_stop() { + self.result.table_result(result); + self.result.interrupt(); + return self.result; + } + + // Increment progress bar. + if let Some(progress) = progress.as_ref() { + progress.inc(1); + } + + let args = table_fixtures.iter().map(|row| row[i].clone()).collect_vec(); + let Ok((mut raw_call_result, reason)) = self.call_test(func, &args) else { + return self.result; + }; + + result.gas_by_case.push((raw_call_result.gas_used, raw_call_result.stipend)); + result.logs.extend(raw_call_result.logs.clone()); + result.labels.extend(raw_call_result.labels.clone()); + HitMaps::merge_opt(&mut result.line_coverage, raw_call_result.line_coverage.clone()); + + let is_success = + self.executor.is_raw_call_mut_success(self.address, &mut raw_call_result); + // Record counterexample if test fails. + if !is_success { + result.counterexample = + Some(CounterExample::Single(BaseCounterExample::from_fuzz_call( + Bytes::from(func.abi_encode_input(&args).unwrap()), + args, + raw_call_result.traces.clone(), + ))); + result.reason = reason; + } + // Stop on the first failure, or after the last row using its call result for logs + // and traces. + if !is_success || i == fixtures_len - 1 { + result.success = is_success; + result.traces = raw_call_result.traces; + result.debug_bytecodes = raw_call_result.debug_bytecodes; + self.result.table_result(result); + return self.result; + } + } + + self.result + } + + fn run_invariant_test( + mut self, + func: &Function, + invariants: Vec<(&Function, bool)>, + shared_invariant_namespace: bool, + call_after_invariant: bool, + identified_contracts: &ContractsByAddress, + ) -> TestResult { + let fuzz_failure_replay = self.cr.mcr.tcfg.fuzz_failure_replay; + let mut invariant_config = self.config.invariant.clone(); + if fuzz_failure_replay { + invariant_config.runs = 0; + } + let invariant_config = &invariant_config; + let is_optimization = is_optimization_invariant(func); + let isolated_campaign = + (is_optimization || !shared_invariant_namespace).then_some(func.name.as_str()); + + let mut live_invariants = Vec::new(); + let mut skipped_predicate_results = Vec::new(); + for (invariant, fail_on_revert) in invariants { + if let Some(reason) = self.invariant_skip_reason(invariant) { + skipped_predicate_results.push(InvariantPredicateResult { + name: invariant.name.clone(), + status: TestStatus::Skipped, + reason: reason.0, + }); + } else { + live_invariants.push((invariant, fail_on_revert)); + } + } + + if live_invariants.is_empty() { + let skip_reason = skipped_predicate_results + .iter() + .find(|predicate| predicate.name == func.name) + .and_then(|predicate| predicate.reason.clone()); + self.result + .invariant_skip_with_predicates(SkipReason(skip_reason), skipped_predicate_results); + return self.result; + } + // Predicates stay in source declaration order; `func` anchors the campaign when it is + // live. + let anchor_idx = + live_invariants.iter().position(|(invariant, _)| *invariant == func).unwrap_or(0); + + let mut executor = self.clone_executor(); + // Enable edge coverage if running with coverage guided fuzzing or with edge coverage + // metrics (useful for benchmarking the fuzzer). + executor.inspector_mut().collect_edge_coverage_with_config(&invariant_config.corpus); + executor + .inspector_mut() + .collect_sancov_edges(invariant_config.corpus.collect_sancov_edges()); + executor + .inspector_mut() + .collect_sancov_trace_cmp(invariant_config.corpus.collect_sancov_trace_cmp()); + let mut config = invariant_config.clone(); + if config.call_override && config.corpus.capture_branch_frontiers() { + let _ = sh_warn!( + "Invariant frontier capture does not support `invariant.call_override`; running \ + the campaign without writing frontier artifacts." + ); + config.corpus.frontier_dir = None; + } + let execution_profile = self.tcfg.evm_opts.networks.execution_profile_name(); + let execution_pass = if self.cr.mcr.tcfg.multi_network.all_override_networks.is_empty() { + "single" + } else if self.cr.mcr.tcfg.multi_network.pass_network.is_some() { + "override" + } else { + "default" + }; + let failure_dir = invariant_suite_paths( + &mut config.corpus, + invariant_config.failure_persist_dir.clone().unwrap(), + self.cr.name, + isolated_campaign, + execution_profile, + execution_pass, + ); + // Snapshot the per-test corpus dir before `config` is moved into `InvariantExecutor`. + let resolved_corpus_dir = config.corpus.corpus_dir.clone(); + + let mut evm = InvariantExecutor::new_with_fuzz_seed( + executor, + self.invariant_runner(), + self.config.fuzz.seed, + config, + identified_contracts, + &self.cr.mcr.known_contracts, + self.cr.num_invariant_campaign_anchors, + ); + + let predicate_count = live_invariants.len() + skipped_predicate_results.len(); + let invariant_contract = InvariantContract::new( + self.address, + self.cr.name, + live_invariants, + anchor_idx, + call_after_invariant, + &self.cr.contract.abi, + ); + let anchor = invariant_contract.anchor(); + let is_campaign = predicate_count > 1; + let invariant_count = is_campaign.then_some(predicate_count); + let invariant_display_name = if is_campaign { + Cow::Owned(invariant_campaign_display_name(self.cr.name)) + } else { + Cow::Borrowed(func.name.as_str()) + }; + + // Select the per-test targets once; the campaign, replay and symbolic paths all need the + // same selection and settings. + if let Err(e) = evm.select_contract_artifacts(self.address) { + self.result.invariant_setup_fail(e); + return self.result; + } + let (sender_filters, targeted) = match evm.select_contracts_and_senders(self.address) { + Ok(selected) => selected, + Err(e) => { + self.result.invariant_setup_fail(e); + return self.result; + } + }; + let current_settings = InvariantSettings::new( + &targeted.targets(), + &sender_filters, + invariant_config.fail_on_revert, + ); + + let showmap = self.cr.mcr.tcfg.showmap.as_ref(); + let minimize = self.cr.mcr.tcfg.fuzz_minimize.as_ref(); + if showmap.is_some() || minimize.is_some() { + let dynamic = evm.dynamic_target_ctx(); + let replay_target = ShowmapReplayTarget { + stateless: None, + fuzz_fail_on_revert: false, + fuzzed_contracts: Some(&targeted), + invariant_address: Some(self.address), + invariant_fns: &invariant_contract.invariant_fns, + invariant_replay: InvariantReplayOptions { + check_interval: invariant_config.check_interval, + call_after_invariant, + is_optimization, + }, + dynamic: Some(&dynamic), + }; + // Showmap replay mode: replay the persisted corpus and emit coverage files instead + // of running the invariant campaign. + if let Some(showmap) = showmap { + let corpus_dir = showmap + .corpus_dir + .clone() + .map(|corpus_dir| { + let target_dir = + invariant_corpus_dir(&corpus_dir, self.cr.name, isolated_campaign); + narrow_generated_corpus_root(corpus_dir, target_dir) + }) + .or(resolved_corpus_dir); + return self.run_showmap(func, &func.name, corpus_dir, showmap, replay_target); + } + if let Some(minimize) = minimize { + let target = self.fuzz_minimize_target_id(&invariant_display_name); + replay_fuzz_minimize( + &mut self.result, + minimize, + target, + &evm.executor, + &invariant_config.corpus, + replay_target, + ); + return self.result; + } + } + + let progress = self.fuzz_progress( + &invariant_display_name, + invariant_config.timeout, + invariant_config.runs, + ); + let primary_failure_file = invariant_failure_file(&failure_dir, anchor); + + // Try to replay recorded failure if any. `forge fuzz replay` checks each selected + // predicate as the replay anchor because merged invariant suites persist failures per + // predicate, while campaign runs use a stable suite anchor. + let mut replayed_persisted_invariant = false; + let mut replayed_predicate_failures = Vec::new(); + let mut confirmed_persisted_invariants = HashSet::default(); + // Normal campaigns must collect secondaries before the anchor's early exit. + let replay_candidates = invariant_contract + .invariant_fns + .iter() + .copied() + .sorted_by_key(|(invariant, _)| (*invariant == anchor) != fuzz_failure_replay) + .collect::>(); + for (replay_invariant, fail_on_revert) in replay_candidates { + let Some(InvariantPersistedFailure { + mut call_sequence, + assertion_failure, + storage, + failure_site, + .. + }) = persisted_invariant_failure(&failure_dir, replay_invariant, ¤t_settings) + else { + continue; + }; + replayed_persisted_invariant = true; + let replay_anchor_idx = invariant_contract + .invariant_fns + .iter() + .position(|(invariant, _)| *invariant == replay_invariant) + .expect("replay anchor must be present in invariant_fns"); + let replay_contract = InvariantContract::new( + self.address, + self.cr.name, + invariant_contract.invariant_fns.clone(), + replay_anchor_idx, + call_after_invariant, + &self.cr.contract.abi, + ); + let Ok((txes, mut replay)) = self.replay_persisted_call_sequence( + &replay_contract, + &mut call_sequence, + assertion_failure, + &storage, + ) else { + continue; + }; + if replay.success { + continue; + } + let Some(confirmed_failure_site) = + replay.failure_site.map(SymbolicInvariantFailureSite::from) + else { + continue; + }; + if failure_site.is_some_and(|expected| expected != confirmed_failure_site) { + continue; + } + if replay_invariant != anchor && !fuzz_failure_replay { + let is_revert = match confirmed_failure_site { + SymbolicInvariantFailureSite::Invariant { selector, .. } + if selector == replay_invariant.selector() => + { + false + } + SymbolicInvariantFailureSite::SequenceCall { .. } + if fail_on_revert && !replay.sequence_assertion_failure => + { + true + } + _ => continue, + }; + confirmed_persisted_invariants.insert(replay_anchor_idx); + replayed_predicate_failures.push(( + replay_invariant.name.clone(), + InvariantFuzzError::from_replayed_invariant( + self.address, + replay_invariant, + txes, + replay.reason, + invariant_config, + fail_on_revert, + assertion_failure, + is_revert, + ), + storage, + confirmed_failure_site, + )); + continue; + } + if is_campaign && !fuzz_failure_replay { + let is_revert = matches!( + confirmed_failure_site, + SymbolicInvariantFailureSite::SequenceCall { .. } + ) && fail_on_revert + && !replay.sequence_assertion_failure; + confirmed_persisted_invariants.insert(replay_anchor_idx); + replayed_predicate_failures.push(( + replay_invariant.name.clone(), + InvariantFuzzError::from_replayed_invariant( + self.address, + replay_invariant, + txes, + replay.reason, + invariant_config, + fail_on_revert, + assertion_failure, + is_revert, + ), + storage, + confirmed_failure_site, + )); + evm.skip_fresh_runs(); + break; + } + let warn = "Replayed invariant failure from persisted file. \nRun `forge clean` or remove file to ignore failure and to continue invariant test campaign."; + if let Some(progress) = &progress { + progress.set_prefix(format!("{invariant_display_name}\n{warn}\n")); + } else { + let _ = sh_warn!("{warn}"); + } + + // If sequence still fails then replay error to collect traces and exit without + // executing new runs. + let trace_executor = match self.clone_executor_with_symbolic_storage(&storage) { + Ok(executor) => executor, + Err(err) => { + error!(%err, "Failed to apply symbolic storage for invariant error replay"); + self.result.single_fail(Some(err.to_string())); + return self.result; + } + }; + let mut replay_config = invariant_config.clone(); + // The persisted replay was confirmed at `confirmed_failure_site`; generic shrinking + // only preserves failure and could append diagnostics from a different failure before + // the later site check rejects it. This also applies to legacy entries without a site. + replay_config.shrink_run_limit = 0; + match self.replay_error( + replay_config, + trace_executor, + &txes, + None, + assertion_failure, + None, + &replay_contract, + replay_invariant, + identified_contracts, + progress.as_ref(), + None, + ) { + Ok(ReplayErrorResult { + counterexample_sequence: sequence, check_result, .. + }) if !sequence.is_empty() => { + call_sequence = sequence; + if let Some(updated) = check_result { + if updated.failure_site.map(SymbolicInvariantFailureSite::from) + != Some(confirmed_failure_site) + { + continue; + } + replay = updated; + } + record_invariant_failure( + &invariant_failure_file(&failure_dir, replay_invariant), + &call_sequence, + ¤t_settings, + assertion_failure, + &storage, + Some(confirmed_failure_site), + None, + ); + } + Ok(_) => {} + Err(err) => { + error!(%err, "Failed to replay invariant error"); + } + } + + self.result.invariant_replay_fail( + replay, + &replay_invariant.name, + None, + call_sequence.clone(), + ); + let signature = replay_invariant.signature(); + if let Some(artifact) = self.persist_sequence_artifact( + &signature, + &format!("{signature}-replay"), + self.sequence_calls(&call_sequence), + self.config.invariant.fail_on_revert, + &storage, + Some(SymbolicInvariantArtifactFailure::Predicate { + name: replay_invariant.name.clone(), + site: Some(confirmed_failure_site), + }), + ) { + self.result.add_counterexample_artifact(artifact); + } + return self.result; + } + + if (self.config.symbolic.use_fuzz_frontiers + || self.config.symbolic.check_invariant_frontiers) + && !fuzz_failure_replay + { + let seeding_config = evm.config(); + let dynamic_target_ctx = evm.dynamic_target_ctx(); + let fresh_failures = self.try_seed_invariant_corpus_from_frontiers( + &invariant_contract, + &seeding_config, + &sender_filters, + &targeted, + &dynamic_target_ctx, + &confirmed_persisted_invariants, + ); + let mut reported_fresh_invariants = confirmed_persisted_invariants.clone(); + for ConfirmedFrontierInvariantFailure { invariant_idx, call_sequence: txes, replay } in + fresh_failures + { + let replay_invariant = invariant_contract.invariant_fns[invariant_idx].0; + let fail_on_revert = invariant_contract.invariant_fns[invariant_idx].1; + let call_sequence = base_counterexamples( + &txes, + identified_contracts, + invariant_config.show_solidity, + ); + let failure_site = replay + .failure_site + .map(SymbolicInvariantFailureSite::from) + .expect("confirmed frontier failure has a failure site"); + // The result map keeps the first failure per predicate; persist that same one. + if !reported_fresh_invariants.insert(invariant_idx) { + continue; + } + + record_invariant_failure( + &invariant_failure_file(&failure_dir, replay_invariant), + &call_sequence, + ¤t_settings, + false, + &[], + Some(failure_site), + None, + ); + replayed_predicate_failures.push(( + replay_invariant.name.clone(), + InvariantFuzzError::from_replayed_invariant( + self.address, + replay_invariant, + txes, + replay.reason, + invariant_config, + fail_on_revert, + false, + false, + ), + Vec::new(), + failure_site, + )); + } + } + + // Replay persisted handler bugs; feed still-reproducing ones into the campaign, + // delete stale files in place. + let (mut persisted_handler_failures, mut symbolic_handler_storage) = self + .replay_persisted_handler_failures(&failure_dir.join("handlers"), ¤t_settings); + + // `forge fuzz replay` (without `--corpus-dir`) only replays persisted failures and + // must never start a fresh campaign. If handler bugs still reproduce, surface them + // through the normal invariant result path below; otherwise report a skip. + if fuzz_failure_replay && persisted_handler_failures.is_empty() { + let reason = if replayed_persisted_invariant { + "no persisted invariant failure reproduced for selected invariants".to_string() + } else { + format!("no persisted invariant failure reproduced for {}", anchor.name) + }; + self.result.single_skip(SkipReason(Some(reason))); + return self.result; + } + + let confirmed_anchor_failure = + replayed_predicate_failures.iter().any(|(name, ..)| name == &anchor.name); + // Do not record a symbolic PASS for an anchor already confirmed to fail concretely. + if self.config.symbolic.enabled && !is_optimization && !confirmed_anchor_failure { + let anchor_fail_on_revert = invariant_contract.invariant_fns[anchor_idx].1; + let after_invariant = call_after_invariant + .then(|| { + self.cr + .contract + .abi + .functions() + .find(|func| func.name == "afterInvariant" && func.inputs.is_empty()) + }) + .flatten(); + let symbolic_targets = targeted + .targets() + .iter() + .flat_map(|(address, contract)| { + let contract_name = Some(contract.identifier.clone()); + contract.abi_fuzzed_functions().map(move |function| SymbolicInvariantTarget { + address: *address, + contract_name: contract_name.clone(), + function: function.clone(), + }) + }) + .collect::>(); + let unsupported_domain_reason = symbolic_invariant_unsupported_domain_reason( + invariant_config, + &sender_filters, + &targeted, + &symbolic_targets, + ); + + let mut symbolic_invariant_config = invariant_config.clone(); + symbolic_invariant_config.fail_on_revert = anchor_fail_on_revert; + let symbolic_config = self.config.symbolic.clone(); + let incomplete = |kind, reason: &str, stats, replay| { + SymbolicResult::incomplete( + &symbolic_config, + kind, + reason, + stats, + replay, + SymbolicCallTrace::none(), + None, + ) + }; + let mut symbolic = SymbolicExecutor::new(symbolic_config.clone()); + match symbolic.run_invariant(SymbolicInvariantRunInput { + executor: &evm.executor, + invariant_address: self.address, + sender: self.sender, + invariant: anchor, + after_invariant, + targets: symbolic_targets, + senders: sender_filters.targeted, + excluded_senders: sender_filters.excluded, + depth: symbolic_config.invariant_depth as usize, + check_interval: invariant_config.check_interval, + fail_on_revert: anchor_fail_on_revert, + ffi_enabled: self.config.ffi, + }) { + SymbolicInvariantRunResult::Safe(stats) => { + self.result.record_symbolic(match unsupported_domain_reason { + Some(reason) => incomplete( + SymbolicStopReason::Stuck, + reason, + stats, + SymbolicReplayMetadata::not_required(), + ), + None => SymbolicResult::pass(&symbolic_config, stats), + }); + } + SymbolicInvariantRunResult::Incomplete { kind, reason, stats } => { + self.result.record_symbolic(incomplete( + kind, + &reason, + stats, + SymbolicReplayMetadata::not_required(), + )); + } + SymbolicInvariantRunResult::Counterexample { + kind, + sequence, + storage, + stats, + } => 'counterexample: { + let is_handler = matches!(kind, SymbolicInvariantCounterexampleKind::Handler); + let symbolic_calls = symbolic_invariant_counterexample_calls( + &sequence, + identified_contracts, + invariant_config.show_solidity, + ); + let check = SequenceReplay { + invariant_config: &symbolic_invariant_config, + invariant_contract: &invariant_contract, + target_invariant: anchor, + assertion_failure: false, + storage: &storage, + }; + let replayed = self + .symbolic_sequence_failure(check, &symbolic_calls) + .ok_or("symbolic invariant counterexample did not replay") + .and_then(|failure| { + let handler_site = match failure.failure_site { + Some(CheckSequenceFailureSite::SequenceCall { + target, + selector, + fingerprint, + }) if is_handler => Some((target, selector, fingerprint)), + _ => None, + }; + if is_handler && handler_site.is_none() { + return Err("symbolic handler counterexample replayed at a \ + non-handler failure site"); + } + Ok((failure, handler_site)) + }); + let (failure, handler_site) = match replayed { + Ok(replayed) => replayed, + Err(reason) => { + self.result.record_symbolic(incomplete( + SymbolicStopReason::Error, + reason, + stats, + SymbolicReplayMetadata::mismatch(reason.to_string()), + )); + break 'counterexample; + } + }; + + let txes = symbolic_calls + .iter() + .map(SymbolicCounterexampleCall::to_basic_tx_details) + .collect::>(); + let original_sequence_len = txes.len(); + let failure_site = failure.failure_site.map(SymbolicInvariantFailureSite::from); + let (artifact_file_name, artifact_failure) = match handler_site { + Some((reverter, selector, fingerprint)) => ( + format!("handler-{reverter}-{selector}"), + SymbolicInvariantArtifactFailure::Handler { + name: Some(invariant_handler_failure_name( + identified_contracts, + reverter, + selector, + )), + reverter, + selector, + fingerprint, + }, + ), + None => ( + anchor.signature(), + SymbolicInvariantArtifactFailure::Predicate { + name: anchor.name.clone(), + site: failure_site, + }, + ), + }; + let replayed = match self.replay_invariant_error_sequence( + SequenceReplay { assertion_failure: is_handler, ..check }, + &txes, + None, + identified_contracts, + ¤t_settings, + SequenceArtifactSpec { + file_name: &artifact_file_name, + fail_on_revert: is_handler || anchor_fail_on_revert, + failure: Some(artifact_failure), + }, + progress.as_ref(), + Some((1, 1)), + ) { + Ok(replayed) => replayed, + Err(err) => { + let reason = format!("symbolic invariant replay failed: {err}"); + self.result.record_symbolic(incomplete( + SymbolicStopReason::Error, + &reason, + stats, + SymbolicReplayMetadata::error(reason.clone()), + )); + break 'counterexample; + } + }; + let ReplayedInvariantSequence { + call_sequence, + artifact, + minimization, + fork_block_number, + } = replayed; + let mut symbolic_result = SymbolicResult::fail_counterexample_sequence( + &symbolic_config, + stats, + SymbolicCallTrace::test_result_traces(!self.result.traces.is_empty()), + ); + if let Some(artifact) = artifact.clone() { + symbolic_result = symbolic_result.with_artifact(artifact); + } + if let Some(minimization) = minimization.clone() { + symbolic_result = symbolic_result.with_minimization(minimization); + } + let reason = failure.reason.unwrap_or_else(|| { + if is_handler { + "symbolic handler counterexample".to_string() + } else { + "symbolic invariant counterexample".to_string() + } + }); + + if let Some((reverter, selector, fingerprint)) = handler_site { + let call_sequence = + call_sequence.iter().map(base_counterexample_to_tx).collect::>(); + symbolic_handler_storage.insert( + (reverter, selector, fingerprint), + SymbolicHandlerReplayStorage { + call_sequence: call_sequence.clone(), + assignments: storage, + fingerprint_provenance: None, + }, + ); + persisted_handler_failures.insert( + (reverter, selector), + InvariantFuzzError::HandlerAssertion(HandlerAssertionFailure { + reverter, + selector, + call_sequence, + original_sequence_len, + revert_reason: reason, + fork_block_number: None, + edge_fingerprint: fingerprint, + }), + ); + self.result.record_symbolic(symbolic_result); + break 'counterexample; + } + + record_invariant_failure( + &primary_failure_file, + &call_sequence, + ¤t_settings, + false, + &storage, + failure_site, + None, + ); + let mut invariant_failures = vec![InvariantFailure::Predicate { + name: anchor.name.clone(), + reason, + counterexample: Some(CounterExample::Sequence( + original_sequence_len, + call_sequence, + )), + artifact, + minimization, + persisted_path: primary_failure_file, + is_anchor: true, + }]; + for (invariant, _) in &invariant_contract.invariant_fns { + if let Some((_, error, _, _)) = replayed_predicate_failures + .iter() + .find(|(name, ..)| name == &invariant.name) + && let Some(calls) = failed_invariant_calls(error) + { + invariant_failures.push(InvariantFailure::Predicate { + name: invariant.name.clone(), + reason: error.revert_reason().unwrap_or_default(), + counterexample: Some(CounterExample::Sequence( + calls.len(), + base_counterexamples( + calls, + identified_contracts, + invariant_config.show_solidity, + ), + )), + artifact: None, + minimization: None, + persisted_path: invariant_failure_file(&failure_dir, invariant), + is_anchor: false, + }); + } + } + let invariant_predicate_results = if is_campaign { + self.sort_predicate_results( + invariant_failures + .iter() + .map(|failure| InvariantPredicateResult { + name: failure.name().to_string(), + status: TestStatus::Failure, + reason: Some(failure.reason().to_string()), + }) + .chain(skipped_predicate_results), + ) + } else { + Vec::new() + }; + self.result.invariant_result( + invariant_kind(1, failure.calls_count, failure.reverts), + InvariantOutcome { + fork_block_number, + failures: invariant_failures, + predicate_results: invariant_predicate_results, + failure_dir: Some(failure_dir), + invariant_count, + ..Default::default() + }, + ); + self.result.record_symbolic(symbolic_result); + return self.result; + } + } + } + + let mut invariant_result = match evm.invariant_fuzz( + invariant_contract.clone(), + &self.setup.fuzz_fixtures, + self.build_fuzz_state(true, None), + progress.as_ref(), + &self.tcfg.early_exit, + persisted_handler_failures, + ) { + Ok(x) => x, + Err(e) if replayed_predicate_failures.is_empty() => { + self.result.invariant_setup_fail(e); + return self.result; + } + Err(e) => { + warn!(%e, "invariant campaign setup failed after replay-confirmed frontier failure"); + Default::default() + } + }; + let mut replayed_predicate_metadata = BTreeMap::new(); + for (name, failure, storage, failure_site) in replayed_predicate_failures { + if let Entry::Vacant(entry) = invariant_result.errors.entry(name) { + replayed_predicate_metadata.insert(entry.key().clone(), (storage, failure_site)); + entry.insert(failure); + } + } + // Merge coverage collected during invariant run with test setup coverage. + self.result.merge_coverages(invariant_result.line_coverage); + + let mut counterexample = None; + // Success requires zero predicate breaks *and* zero handler-side assertion bugs. + let success = + invariant_result.errors.is_empty() && invariant_result.handler_errors.is_empty(); + let single_failure = + invariant_result.errors.len() + invariant_result.handler_errors.len() == 1; + let mut fork_block_number = invariant_result.fork_block_number; + let mut invariant_failures = Vec::new(); + let mut any_failure_persisted = false; + + if success { + if let Some(best_value) = invariant_result.optimization_best_value { + // Optimization mode: replay and shrink to find shortest best sequence. + match self.replay_error( + invariant_config.clone(), + self.clone_executor(), + &invariant_result.optimization_best_sequence, + None, + false, + Some(best_value), + &invariant_contract, + anchor, + identified_contracts, + progress.as_ref(), + None, + ) { + Ok(ReplayErrorResult { counterexample_sequence: sequence, .. }) + if !sequence.is_empty() => + { + counterexample = Some(CounterExample::Sequence( + invariant_result.optimization_best_sequence.len(), + sequence, + )); + } + Err(err) => { + error!(%err, "Failed to replay optimization best sequence"); + } + _ => {} + } + } else if let Err(err) = replay_run( + // Standard check mode: replay last run for traces. + &invariant_contract, + anchor, + self.clone_executor(), + &self.cr.mcr.known_contracts, + identified_contracts.clone(), + &mut self.result.logs, + &mut self.result.traces, + &mut self.result.debug_bytecodes, + &mut self.result.line_coverage, + &mut self.result.deprecated_cheatcodes, + &invariant_result.last_run_inputs, + invariant_config.show_solidity, + ) { + error!(%err, "Failed to replay last invariant run"); + } + } else { + // Total broken invariants in this campaign, used to decorate the shrink progress bar + // with `[i/N]`. `errors` keys cover both the anchor and any broken secondaries. + let total_broken = invariant_result.errors.len(); + // The anchor is shrunk first (as `[1/N]`); secondaries follow and only advance the + // counter when they are actually shrunk so it matches user-visible progress. + let mut next_position = 2usize; + let order = std::iter::once(anchor_idx).chain( + (0..invariant_contract.invariant_fns.len()).filter(|idx| *idx != anchor_idx), + ); + for idx in order { + let is_anchor = idx == anchor_idx; + let invariant = invariant_contract.invariant_fns[idx].0; + let Some(error) = invariant_result.errors.get(&invariant.name) else { + continue; + }; + let persisted_path = invariant_failure_file(&failure_dir, invariant); + let (case_data, calls) = match error { + InvariantFuzzError::BrokenInvariant(case_data) + | InvariantFuzzError::Revert(case_data) => { + (case_data, failed_invariant_calls(error).unwrap_or_default()) + } + // Non-replayable anchor errors (e.g. `MaxAssumeRejects`) still get an entry, + // without a counterexample, so the reason is rendered. + _ if is_anchor => { + invariant_failures.push(InvariantFailure::Predicate { + name: invariant.name.clone(), + reason: error.revert_reason().unwrap_or_default(), + counterexample: None, + artifact: None, + minimization: None, + persisted_path, + is_anchor, + }); + continue; + } + _ => continue, + }; + let replayed_metadata = replayed_predicate_metadata.get(&invariant.name); + + // On Ctrl+C: skip the (potentially long) secondary 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 replayed = if !is_anchor && self.tcfg.early_exit.should_stop() { + if replayed_metadata.is_none() { + record_invariant_failure( + &persisted_path, + &base_counterexamples( + calls, + identified_contracts, + invariant_config.show_solidity, + ), + ¤t_settings, + case_data.assertion_failure, + &[], + None, + None, + ); + } + any_failure_persisted = true; + None + } else { + let position = if is_anchor { + 1 + } else { + next_position += 1; + next_position - 1 + }; + let mut replay_config = invariant_config.clone(); + if replayed_metadata.is_some() { + // The persisted failure site was validated before entering the + // campaign. The generic shrinker only preserves failure, not its site, + // so shrinking here could misattribute a different failure to this + // predicate. + replay_config.shrink_run_limit = 0; + } + let (storage, artifact_failure) = match replayed_metadata { + Some((storage, site)) => ( + storage.as_slice(), + Some(SymbolicInvariantArtifactFailure::Predicate { + name: invariant.name.clone(), + site: Some(*site), + }), + ), + None => (&[][..], None), + }; + let signature = invariant.signature(); + match self.replay_invariant_error_sequence( + SequenceReplay { + invariant_config: &replay_config, + invariant_contract: &invariant_contract, + target_invariant: invariant, + assertion_failure: case_data.assertion_failure, + storage, + }, + calls, + Some(case_data.inner_sequence.clone()), + identified_contracts, + ¤t_settings, + SequenceArtifactSpec { + file_name: &signature, + fail_on_revert: self.config.invariant.fail_on_revert, + failure: artifact_failure, + }, + progress.as_ref(), + Some((position, total_broken)), + ) { + Ok(replayed) if !replayed.call_sequence.is_empty() => { + if single_failure { + fork_block_number = + replayed.fork_block_number.or(fork_block_number); + } + // Keep all replay metadata for a seeded persisted failure. A fresh + // campaign error takes precedence and is persisted normally. + if replayed_metadata.is_none() { + record_invariant_failure( + &persisted_path, + &replayed.call_sequence, + ¤t_settings, + case_data.assertion_failure, + &[], + None, + None, + ); + } + any_failure_persisted = true; + Some(replayed) + } + Ok(_) => None, + Err(err) => { + error!(%err, "Failed to replay invariant error"); + None + } + } + }; + let (counterexample, artifact, minimization) = match replayed { + Some(replayed) => ( + Some(CounterExample::Sequence(calls.len(), replayed.call_sequence)), + replayed.artifact, + replayed.minimization, + ), + None => (None, None, None), + }; + invariant_failures.push(InvariantFailure::Predicate { + name: invariant.name.clone(), + reason: error.revert_reason().unwrap_or_default(), + counterexample, + artifact, + minimization, + persisted_path, + is_anchor, + }); + } + } + + let invariant_failure_dir = any_failure_persisted.then(|| failure_dir.clone()); + let invariant_predicate_results = if is_campaign { + let failures_by_name = invariant_failures + .iter() + .map(|failure| (failure.name(), failure)) + .collect::>(); + self.sort_predicate_results( + invariant_contract + .invariant_fns + .iter() + .map(|(invariant, _)| { + let failure = failures_by_name.get(invariant.name.as_str()); + InvariantPredicateResult { + name: invariant.name.clone(), + status: if failure.is_some() { + TestStatus::Failure + } else { + TestStatus::Success + }, + reason: failure.map(|failure| failure.reason().to_string()), + } + }) + .chain(skipped_predicate_results), + ) + } else { + Vec::new() + }; + + // Convert handler-side assertion bugs into render-ready entries. The name is a + // best-effort `Contract::function` from `identified_contracts`, falling back to + // `0xreverter::0xselector`. Map is keyed by `(reverter, selector)` site so multiple + // code paths through the same function collapse to one entry, rendered in the + // dedicated handler assertions section. + let invariant_handler_failures = invariant_result + .handler_errors + .iter() + // Stable order across runs: sort by `(reverter, selector)` site directly. + .sorted_by(|(a, _), (b, _)| a.cmp(b)) + .filter_map(|(_, err)| err.as_handler_assertion()) + .map(|failure| { + let (reverter, selector) = (failure.reverter, failure.selector); + let name = invariant_handler_failure_name(identified_contracts, reverter, selector); + let symbolic_replay = symbolic_handler_storage + .get(&(reverter, selector, failure.edge_fingerprint)) + .filter(|storage| storage.call_sequence == failure.call_sequence); + let symbolic_storage = + symbolic_replay.map_or(&[][..], |storage| &storage.assignments); + let fingerprint_provenance = if let Some(replay) = symbolic_replay { + replay.fingerprint_provenance + } else if failure.edge_fingerprint + != handler_edge_fingerprint(None, reverter, selector) + { + PersistedFingerprintProvenance::from_corpus(&invariant_config.corpus) + } else { + None + }; + let calls = base_counterexamples( + &failure.call_sequence, + identified_contracts, + invariant_config.show_solidity, + ); + + // Persist for next-run replay (skip if nothing to record). + if !calls.is_empty() { + record_handler_failure( + &failure_dir, + reverter, + selector, + failure.edge_fingerprint, + &calls, + ¤t_settings, + symbolic_storage, + fingerprint_provenance, + ); + } + let artifact = self.persist_sequence_artifact( + &anchor.signature(), + &format!("handler-{reverter}-{selector}"), + self.sequence_calls(&calls), + true, + symbolic_storage, + Some(SymbolicInvariantArtifactFailure::Handler { + name: Some(name.clone()), + reverter, + selector, + fingerprint: failure.edge_fingerprint, + }), + ); + // Preserve pre-shrink length for `(original: N, shrunk: M)` rendering. + let counterexample = (!calls.is_empty()) + .then(|| CounterExample::Sequence(failure.original_sequence_len, calls)); + + InvariantFailure::Handler { + name, + reverter, + selector, + reason: failure.revert_reason.clone(), + counterexample, + artifact, + } + }) + .collect::>(); + + let interrupted = success && invariant_result.interrupted; + self.result.invariant_result( + TestKind::Invariant { + runs: invariant_result.runs, + calls: invariant_result.calls, + reverts: invariant_result.reverts, + workers: invariant_result.workers.max(1), + metrics: invariant_result.metrics, + failed_corpus_replays: invariant_result.failed_corpus_replays, + optimization_best_value: invariant_result.optimization_best_value, + }, + InvariantOutcome { + success, + fork_block_number, + failures: invariant_failures, + handler_failures: invariant_handler_failures, + predicate_results: invariant_predicate_results, + failure_dir: invariant_failure_dir, + invariant_count, + counterexample, + gas_report_traces: invariant_result.gas_report_traces, + }, + ); + if interrupted { + self.result.interrupt(); + } + self.result + } + + /// Orders predicate results by their declaration position in the test contract ABI. + fn sort_predicate_results( + &self, + results: impl Iterator, + ) -> Vec { + results + .sorted_by_key(|predicate| { + self.cr + .contract + .abi + .functions() + .position(|func| func.name == predicate.name) + .unwrap_or(usize::MAX) + }) + .collect() + } + + fn invariant_skip_reason(&self, func: &Function) -> Option { + match self.executor.call( + self.sender, + self.address, + func, + &[], + U256::ZERO, + Some(self.revert_decoder()), + ) { + Err(EvmError::Skip(reason)) => Some(reason), + _ => None, + } + } + + /// Runs a fuzzed test. + /// + /// Applies the before test txes (if any), fuzzes the current function and returns the + /// `TestResult`. + /// + /// Before test txes are applied in order and state modifications committed to the EVM database + /// (therefore the fuzz test will use the modified state). + /// State modifications of before test txes and fuzz test are discarded after test ends, + /// similar to `eth_call`. + fn run_fuzz_test(mut self, func: &Function) -> TestResult { + // Prepare fuzz test execution. + if self.prepare_test(func).is_err() { + return self.result; + } + + let runner = self.fuzz_runner(); + let mut fuzz_config = self.config.fuzz.clone(); + let (test_name, legacy_corpus_dir, (failure_dir, failure_file)) = + self.fuzz_test_paths(func, &mut fuzz_config); + let fuzz_input = self.cr.mcr.tcfg.fuzz_input.as_ref(); + let is_explicit_target = fuzz_input + .is_some_and(|input| input.contract == self.cr.name && input.test == func.signature()); + if is_explicit_target && fuzz_config.run.is_some() { + self.result.fuzz_setup_fail(eyre::eyre!( + "`--fuzz-input-file` cannot be combined with `fuzz.run`" + )); + return self.result; + } + + let replay_target = ShowmapReplayTarget { + stateless: Some(StatelessReplayTarget { function: func, address: self.address }), + fuzz_fail_on_revert: fuzz_config.fail_on_revert, + fuzzed_contracts: None, + invariant_address: None, + invariant_fns: &[], + invariant_replay: InvariantReplayOptions::default(), + dynamic: None, + }; + // Showmap replay mode: replay the persisted corpus and emit coverage + // files instead of running the fuzz campaign. + if let Some(showmap) = self.cr.mcr.tcfg.showmap.as_ref() { + let corpus_dir = showmap + .corpus_dir + .clone() + .map(|corpus_dir| { + legacy_fuzz_corpus_dir(Some(&corpus_dir), self.cr.name, func, &test_name) + .unwrap_or_else(|| { + let target_dir = corpus_dir + .join(contract_short_name(self.cr.name)) + .join(&*test_name); + narrow_generated_corpus_root(corpus_dir, target_dir) + }) + }) + .or(legacy_corpus_dir) + .or_else(|| fuzz_config.corpus.corpus_dir.clone()); + return self.run_showmap(func, &test_name, corpus_dir, showmap, replay_target); + } + if let Some(minimize) = self.cr.mcr.tcfg.fuzz_minimize.as_ref() { + let target = self.fuzz_minimize_target_id(&func.signature()); + replay_fuzz_minimize( + &mut self.result, + minimize, + target, + &self.executor, + &fuzz_config.corpus, + replay_target, + ); + return self.result; + } + + // Load the validated explicit input for its unique target, or fall back to this test's + // canonical cache. + let persisted_failure = if is_explicit_target { + fuzz_input.map(|input| input.failure.as_ref().clone()) + } else { + foundry_common::fs::read_json_file::(&failure_file).ok().or_else( + || { + if test_name == func.name { + return None; + } + let legacy_file = canonicalized(failure_dir.join(&func.name)); + let failure = + foundry_common::fs::read_json_file::(&legacy_file) + .ok()?; + failure + .calldata + .get(..4) + .is_some_and(|selector| func.selector() == selector) + .then_some(failure) + }, + ) + }; + if self.cr.mcr.tcfg.fuzz_failure_replay { + let skip_reason = match &persisted_failure { + None => { + Some(format!("no persisted fuzz failure found at {}", failure_file.display())) + } + Some(failure) + if failure + .calldata + .get(..4) + .is_none_or(|selector| func.selector() != selector) => + { + Some(format!("persisted fuzz failure selector does not match {}", func.name)) + } + Some(_) => None, + }; + if let Some(reason) = skip_reason { + self.result.fuzz_result(FuzzTestResult { + skipped: true, + reason: Some(reason), + ..Default::default() + }); + return self.result; + } + fuzz_config.corpus.corpus_dir = None; + } + + self.try_seed_fuzz_corpus_from_frontiers(func, &fuzz_config); + self.try_seed_fuzz_corpus_symbolically(func, &fuzz_config); + + let progress = self.fuzz_progress( + &func.name, + fuzz_config.timeout, + if fuzz_config.run.is_some() { 1 } else { fuzz_config.runs }, + ); + + let state = self.build_fuzz_state(false, Some(func)); + let mut executor = self.executor.into_owned(); + // Enable edge coverage if running with coverage guided fuzzing or with edge coverage + // metrics (useful for benchmarking the fuzzer). + executor.inspector_mut().collect_edge_coverage_with_config(&fuzz_config.corpus); + executor.inspector_mut().collect_evm_cmp_log(fuzz_config.corpus.collect_evm_cmp_log()); + executor.inspector_mut().collect_sancov_edges(fuzz_config.corpus.collect_sancov_edges()); + executor + .inspector_mut() + .collect_sancov_trace_cmp(fuzz_config.corpus.collect_sancov_trace_cmp()); + let mut fuzzed_executor = FuzzedExecutor::new( + executor, + runner, + self.tcfg.sender, + fuzz_config, + persisted_failure, + legacy_corpus_dir, + ); + let result = if self.cr.mcr.tcfg.fuzz_failure_replay { + fuzzed_executor.replay_persisted_failure( + func, + self.address, + &self.cr.mcr.revert_decoder, + ) + } else { + fuzzed_executor.fuzz( + func, + &self.setup.fuzz_fixtures, + state, + self.address, + &self.cr.mcr.revert_decoder, + progress.as_ref(), + &self.tcfg.early_exit, + &self.cr.tokio_handle, + ) + }; + let result = match result { + Ok(result) => result, + Err(e) => { + self.result.fuzz_setup_fail(e); + return self.result; + } + }; + + // Record counterexample. + if !self.cr.mcr.tcfg.fuzz_failure_replay + && let Some(CounterExample::Single(counterexample)) = &result.counterexample + { + if let Err(err) = foundry_common::fs::create_dir_all(failure_dir) { + error!(%err, "Failed to create fuzz failure dir"); + } else if let Err(err) = + foundry_common::fs::write_json_file(&failure_file, counterexample) + { + error!(%err, "Failed to record call sequence"); + } + } + + self.result.fuzz_result(result); + self.result + } + + fn prepare_test(&mut self, func: &Function) -> Result<(), ()> { + let address = self.setup.address; + + // Apply before test configured functions (if any). + if self.cr.contract.abi.functions().any(|func| func.name.is_before_test_setup()) { + for calldata in self.executor.call_sol_default( + address, + &ITest::beforeTestSetupCall { testSelector: func.selector() }, + ) { + let spec_id: SpecId = self.executor.spec_id().into(); + debug!(?calldata, spec=%spec_id, "applying before_test_setup"); + // Apply before test configured calldata. + let Ok(call_result) = self.executor.to_mut().transact_raw( + self.tcfg.sender, + address, + calldata, + U256::ZERO, + ) else { + self.result.single_fail(None); + return Err(()); + }; + let reverted = call_result.reverted; + // Merge tx result traces in unit test result. + self.result.extend_setup(call_result); + // To continue unit test execution the call should not revert. + if reverted { + self.result.single_fail(None); + return Err(()); + } + } + } + Ok(()) + } + + fn fuzz_runner(&self) -> TestRunner { + let config = &self.config.fuzz; + fuzzer_with_cases(config.seed, config.runs, config.max_test_rejects) + } + + /// Replays the persisted corpus and writes AFL-`afl-showmap`-style files. + fn run_showmap( + mut self, + func: &Function, + test_name: &str, + corpus_dir: Option, + showmap: &crate::multi_runner::ShowmapConfig, + target: ShowmapReplayTarget<'_>, + ) -> TestResult { + let Some(corpus_dir) = corpus_dir else { + self.result.replay_skip("no corpus_dir configured for this test"); + return self.result; + }; + + // Configure executor with the requested coverage collectors. Showmap + // ignores fuzz config defaults: the CLI domain is the source of truth. + // For EVM we enable line coverage rather than edge coverage so the IDs + // (bytecode_hash, pc) are deterministic across forge processes — + // `EdgeCovInspector` uses a per-process random hash and would yield + // non-comparable IDs across approaches. + let mut executor = self.clone_executor(); + let domain = showmap.domain; + executor.inspector_mut().collect_line_coverage(domain.includes_evm()); + executor.inspector_mut().collect_sancov_edges(domain.includes_sancov()); + + // Fold test identity into the approach dir so each `/` contains + // trials of a single test — what `differential-coverage` expects. The + // (anchor) function name is included for invariant tests too so contracts + // with multiple invariant campaigns don't collide on the same approach dir + // (which `File::create_new` would reject). Distinct anchors sharing one + // corpus simply produce equivalent, separately-named approach dirs. + let safe_id = self.cr.name.replace(['/', '\\', ':'], "_"); + let safe_fn = test_name.replace(['/', '\\', ':', '(', ')', ',', ' '], "_"); + let approach = format!("{}__{safe_id}__{safe_fn}", showmap.approach); + let opts = ShowmapOpts { + out_dir: showmap.out_dir.clone(), + approach, + trial: showmap.trial.clone(), + per_input: showmap.per_input, + domain, + emit_files: showmap.emit_files, + }; + + let start = std::time::Instant::now(); + let result = replay_corpus_to_showmap(&executor, &corpus_dir, target, &opts); + let duration = start.elapsed(); + match result { + Ok(stats) => { + if stats.sancov_requested && !stats.sancov_observed && stats.corpus_entries > 0 { + let _ = sh_warn!( + "{}::{}: sancov coverage requested but no hits observed (build is likely not sancov-instrumented)", + self.cr.name, + func.name, + ); + } + if stats.unreadable_entries > 0 { + self.result.single_fail(Some(format!( + "failed to read {} corpus entries from {}", + stats.unreadable_entries, + corpus_dir.display() + ))); + } else if !showmap.emit_files && stats.corpus_entries == 0 { + self.result.replay_skip(format!( + "replayed 0 corpus entries from {}", + corpus_dir.display() + )); + } else { + self.result.replay_result( + stats.corpus_entries, + stats.showmap_files, + stats.skipped_entries, + duration, + ); + } + } + Err(e) => { + self.result.single_fail(Some(e.to_string())); + } + } + self.result + } + + fn invariant_runner(&self) -> TestRunner { + let config = &self.config.invariant; + fuzzer_with_cases(self.config.fuzz.seed, config.runs, config.max_assume_rejects) + } + + fn clone_executor(&self) -> Executor { + self.executor.clone().into_owned() + } + + fn clone_executor_with_symbolic_storage( + &self, + storage: &[SymbolicStorageAssignment], + ) -> Result> { + let mut executor = self.clone_executor(); + for assignment in storage { + executor.set_storage_slot(assignment.address, assignment.slot, assignment.value)?; + if let Some(cheats) = executor.inspector_mut().cheatcodes.as_mut() { + cheats.cache_arbitrary_storage_value( + assignment.address, + assignment.slot, + assignment.value, + ); + } + } + Ok(executor) + } + + fn build_fuzz_state(&self, invariant: bool, func: Option<&Function>) -> EvmFuzzState { + let config = + if invariant { self.config.invariant.dictionary } else { self.config.fuzz.dictionary }; + let has_function_inline_config = + func.is_some_and(|func| self.inline_config.contains_function(self.cr.name, &func.name)); + let can_reuse_setup_state = !invariant + && config == self.cr.config.fuzz.dictionary + && !has_function_inline_config + && !self.cr.contract.abi.functions().any(|func| func.name.is_before_test_setup()); + if can_reuse_setup_state { + return self + .setup + .fuzz_state + .get_or_init(|| self.build_fuzz_state_uncached(false, config)) + .fork(); + } + + self.build_fuzz_state_uncached(invariant, config) + } + + fn build_fuzz_state_uncached( + &self, + invariant: bool, + config: FuzzDictionaryConfig, + ) -> EvmFuzzState { + let literals = + if invariant { &self.cr.mcr.invariant_literals } else { &self.cr.mcr.fuzz_literals }; + if let Some(db) = self.executor.backend().active_fork_db() { + EvmFuzzState::new(&self.setup.deployed_libs, db, config, Some(literals)) + } else { + let db = self.executor.backend().mem_db(); + EvmFuzzState::new(&self.setup.deployed_libs, db, config, Some(literals)) + } + } +} + +fn fuzzer_with_cases(seed: Option, cases: u32, max_global_rejects: u32) -> TestRunner { + let config = proptest::test_runner::Config { + cases, + max_global_rejects, + // Disable proptest shrink: for fuzz tests we provide single counterexample, + // for invariant tests we shrink outside proptest. + max_shrink_iters: 0, + ..Default::default() + }; + + if let Some(seed) = seed { + trace!(target: "forge::test", %seed, "building deterministic fuzzer"); + let rng = TestRng::from_seed(RngAlgorithm::ChaCha, &seed.to_be_bytes::<32>()); + TestRunner::new_with_rng(config, rng) + } else { + trace!(target: "forge::test", "building stochastic fuzzer"); + TestRunner::new(config) + } +} /// Holds data about a persisted invariant failure. #[derive(Serialize, Deserialize)] struct InvariantPersistedFailure { /// Recorded counterexample. call_sequence: Vec, - /// Bytecode of the test contract that generated the counterexample. - #[serde(skip_serializing_if = "Option::is_none")] - driver_bytecode: Option, + /// Invariant settings when the counterexample was generated. + /// Used to determine if the counterexample is still valid. + settings: InvariantSettings, + /// Whether the persisted failure came from a handler assertion instead of the invariant body. + #[serde(default)] + assertion_failure: bool, + /// Concrete setup-storage assignments required before replaying this failure. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + storage: Vec, + /// Exact failure site required to accept a persisted symbolic handler rerun. + #[serde(default, skip_serializing_if = "Option::is_none")] + failure_site: Option, + /// Versioned configuration used to produce a reproducible edge fingerprint. + #[serde(default, skip_serializing_if = "Option::is_none")] + fingerprint_provenance: Option, +} + +/// Reproducible edge-fingerprint algorithms and their capture configuration. +#[derive(Clone, Copy, Serialize, Deserialize)] +#[serde(tag = "algorithm", rename_all = "snake_case")] +enum PersistedFingerprintProvenance { + CollisionFreeV1 { include_call_depth: bool }, + HashV1 { include_call_depth: bool }, +} + +impl PersistedFingerprintProvenance { + fn from_corpus(config: &FuzzCorpusConfig) -> Option { + config.collect_evm_edge_coverage().then(|| { + let include_call_depth = config.evm_edge_coverage_include_call_depth(); + if config.evm_edge_coverage_collision_free() { + Self::CollisionFreeV1 { include_call_depth } + } else { + Self::HashV1 { include_call_depth } + } + }) + } + + const fn edge_config(self) -> EdgeCovConfig { + match self { + Self::CollisionFreeV1 { include_call_depth } => { + EdgeCovConfig::new(EdgeCovKind::CollisionFree, include_call_depth) + } + Self::HashV1 { include_call_depth } => { + EdgeCovConfig::new(EdgeCovKind::Hash, include_call_depth) + } + } + } +} + +/// Persisted handler-side assertion bugs keyed by `(reverter, selector)`. +type HandlerFailureMap = std::collections::HashMap<(Address, Selector), InvariantFuzzError>; +/// Symbolic replay storage for handler bugs keyed by `(reverter, selector, fingerprint)`. +type SymbolicHandlerStorageMap = HashMap<(Address, Selector, B256), SymbolicHandlerReplayStorage>; + +/// Symbolic storage assignments that only apply when replaying the exact recorded sequence. +struct SymbolicHandlerReplayStorage { + call_sequence: Vec, + assignments: Vec, + fingerprint_provenance: Option, } /// Helper function to load failed call sequence from file. -/// Ignores failure if generated with different test contract than the current one. -fn persisted_call_sequence(path: &Path, bytecode: &Bytes) -> Option> { - foundry_common::fs::read_json_file::(path).ok().and_then( - |persisted_failure| { - if let Some(persisted_bytecode) = &persisted_failure.driver_bytecode { - // Ignore persisted sequence if test bytecode doesn't match. - if !bytecode.eq(persisted_bytecode) { - let _= sh_warn!("\ - Failure from {:?} file was ignored because test contract bytecode has changed.", - path - ); - return None; - } +/// Ignores failure if generated with different invariant settings than the current ones. +fn persisted_call_sequence( + path: &Path, + current_settings: &InvariantSettings, +) -> Option { + let persisted = foundry_common::fs::read_json_file::(path).ok()?; + if let Some(diff) = persisted.settings.diff(current_settings) { + let _ = sh_warn!( + "Failure from {path:?} file was ignored because invariant test settings have changed: {diff}" + ); + return None; + } + Some(persisted) +} + +/// Returns the current invariant failure cache path. +fn invariant_failure_file(failure_dir: &Path, invariant: &Function) -> PathBuf { + canonicalized(failure_dir.join("invariants").join(&invariant.name)) +} + +/// Loads a persisted invariant failure from the new cache path, falling back to the legacy path. +fn persisted_invariant_failure( + failure_dir: &Path, + invariant: &Function, + current_settings: &InvariantSettings, +) -> Option { + persisted_call_sequence(&invariant_failure_file(failure_dir, invariant), current_settings) + .or_else(|| { + // Older Foundry versions stored invariant failures directly under the failure root. + let legacy_path = canonicalized(failure_dir.join(&invariant.name)); + let persisted = persisted_call_sequence(&legacy_path, current_settings)?; + let _ = sh_warn!( + "Using legacy invariant failure cache at {}; new failures will be persisted under {}/invariants.", + legacy_path.display(), + failure_dir.display(), + ); + Some(persisted) + }) +} + +/// Converts a persisted counterexample to `BasicTxDetails`, setting `show_solidity` in place. +fn base_counterexamples_to_txes( + call_sequence: &mut [BaseCounterExample], + show_solidity: bool, +) -> Vec { + call_sequence + .iter_mut() + .map(|seq| { + seq.show_solidity = show_solidity; + base_counterexample_to_tx(seq) + }) + .collect() +} + +/// Converts campaign transactions into displayable counterexample calls. +fn base_counterexamples( + calls: &[BasicTxDetails], + identified_contracts: &ContractsByAddress, + show_solidity: bool, +) -> Vec { + calls + .iter() + .map(|tx| { + BaseCounterExample::from_invariant_call(tx, identified_contracts, None, show_solidity) + }) + .collect() +} + +/// Returns the failing call sequence of a replayable invariant error. +fn failed_invariant_calls(error: &InvariantFuzzError) -> Option<&[BasicTxDetails]> { + match error { + InvariantFuzzError::BrokenInvariant(case_data) | InvariantFuzzError::Revert(case_data) => { + let TestError::Fail(_, calls) = &case_data.test_error else { + unreachable!("FailedInvariantCaseData::new always sets TestError::Fail") }; - Some(persisted_failure.call_sequence) + Some(calls) + } + _ => None, + } +} + +fn base_counterexample_to_tx(seq: &BaseCounterExample) -> BasicTxDetails { + BasicTxDetails { + warp: seq.warp, + roll: seq.roll, + sender: seq.sender.unwrap_or_default(), + call_details: CallDetails { + target: seq.addr.unwrap_or_default(), + calldata: seq.calldata.clone(), + value: seq.value, }, - ) + } +} + +fn symbolic_invariant_counterexample_calls( + steps: &[SymbolicInvariantStep], + identified_contracts: &ContractsByAddress, + show_solidity: bool, +) -> Vec { + steps + .iter() + .map(|step| { + let tx = BasicTxDetails { + warp: None, + roll: None, + sender: step.sender, + call_details: CallDetails { + target: step.address, + calldata: step.calldata.clone(), + value: None, + }, + }; + let counterexample = BaseCounterExample::from_invariant_call( + &tx, + identified_contracts, + None, + show_solidity, + ); + SymbolicCounterexampleCall::from_base_counterexample( + &counterexample, + step.sender, + step.address, + ) + }) + .collect() } -/// Helper functions to return canonicalized invariant failure paths. -fn invariant_failure_paths( - config: &InvariantConfig, +fn frontier_selector(frontier: &FuzzBranchFrontierRecord) -> Option { + frontier + .sequence + .get(frontier.call_index) + .and_then(|call| call.call_details.calldata.get(..4)) + .map(Selector::from_slice) +} + +fn parse_frontier_selectors(selectors: &[String], signature: &str) -> Vec { + selectors + .iter() + .filter_map(|selector| { + let parsed = hex::decode(selector) + .ok() + .filter(|bytes| bytes.len() == 4) + .map(|bytes| Selector::from_slice(&bytes)); + if parsed.is_none() { + let _ = sh_warn!( + "invalid symbolic frontier selector `{selector}` for {signature}; expected \ + a 4-byte hex selector like 0x12345678" + ); + } + parsed + }) + .collect() +} + +/// Warns about requested frontier `label`s that the frontier file at `path` did not provide. +fn warn_unimported_frontiers( + label: &str, + requested: &[T], + imported: &[T], + signature: &str, + path: &Path, +) { + for value in requested.iter().filter(|value| !imported.contains(value)) { + warn!( + %value, + label, + test = %signature, + path = %path.display(), + "requested fuzz branch frontier was not imported" + ); + let _ = sh_warn!( + "requested fuzz branch frontier {label} {value} was not imported for {signature}" + ); + } +} + +fn frontier_filter_display(values: &[T]) -> String { + if values.is_empty() { "any".to_string() } else { values.iter().format(", ").to_string() } +} + +/// Returns the contract name without the file path prefix. +fn contract_short_name(contract_name: &str) -> &str { + contract_name.split(':').next_back().unwrap() +} + +/// Returns a stable path component that distinguishes overloaded fuzz tests. +fn fuzz_test_path_name<'a>( + abi: &JsonAbi, + func: &'a Function, + config: &FuzzConfig, contract_name: &str, - invariant_name: &str, -) -> (PathBuf, PathBuf) { - let dir = config +) -> Cow<'a, str> { + let test_name = format!("{}-{:x}", func.name, func.selector()); + let overloaded = abi.functions.get(&func.name).is_some_and(|functions| functions.len() > 1); + let contract = contract_short_name(contract_name); + let has_qualified_artifact = config .failure_persist_dir - .clone() - .unwrap() - .join("failures") - .join(contract_name.split(':').next_back().unwrap()); - let dir = canonicalized(dir); - let file = canonicalized(dir.join(invariant_name)); - (dir, file) + .as_ref() + .is_some_and(|dir| dir.join("failures").join(contract).join(&test_name).exists()) + || [&config.corpus.corpus_dir, &config.corpus.frontier_dir] + .into_iter() + .flatten() + .any(|dir| dir.join(contract).join(&test_name).exists()); + + if overloaded || has_qualified_artifact { + Cow::Owned(test_name) + } else { + Cow::Borrowed(&func.name) + } +} + +/// Returns whether any canonical replay directory under `dir` holds a corpus entry. +fn corpus_has_entries(dir: &Path) -> bool { + canonical_replay_dirs(dir).iter().any(|dir| read_corpus_dir(dir).next().is_some()) +} + +/// Returns the legacy unqualified corpus when the qualified corpus has no entries. +fn legacy_fuzz_corpus_dir( + root: Option<&Path>, + contract_name: &str, + func: &Function, + test_name: &str, +) -> Option { + if test_name == func.name { + return None; + } + let contract = root?.join(contract_short_name(contract_name)); + if corpus_has_entries(&contract.join(test_name)) { + return None; + } + let legacy = contract.join(&func.name); + corpus_has_entries(&legacy).then(|| canonicalized(legacy)) +} + +/// Helper function to set test corpus dir and to compose persisted failure paths. +fn test_paths( + corpus_config: &mut FuzzCorpusConfig, + persist_dir: PathBuf, + contract_name: &str, + test_name: &str, +) -> (PathBuf, PathBuf) { + let contract = contract_short_name(contract_name); + // Update config with corpus dir for current test. + corpus_config.with_test(contract, test_name); + + let failures_dir = canonicalized(persist_dir.join("failures").join(contract)); + let failure_file = canonicalized(failures_dir.join(test_name)); + (failures_dir, failure_file) +} + +/// Returns the corpus directory of a shared contract campaign or an isolated campaign. +fn invariant_corpus_dir( + root: &Path, + contract_name: &str, + isolated_campaign: Option<&str>, +) -> PathBuf { + let dir = root.join(contract_short_name(contract_name)); + if let Some(name) = isolated_campaign { dir.join(name) } else { dir } +} + +/// Returns the collision-free directory for one stateful frontier campaign. +fn invariant_frontier_dir( + root: &Path, + contract_name: &str, + isolated_campaign: Option<&str>, + execution_profile: &str, + execution_pass: &str, +) -> PathBuf { + let contract = stable_hashed_component(contract_short_name(contract_name), contract_name); + let campaign = if let Some(name) = isolated_campaign { + PathBuf::from("isolated").join(sanitize_symbolic_artifact_component(name)) + } else { + PathBuf::from("shared") + }; + root.join("v2").join(contract).join(execution_profile).join(execution_pass).join(campaign) +} + +/// Sets the invariant corpus directory and returns the contract-level failure directory. +fn invariant_suite_paths( + corpus_config: &mut FuzzCorpusConfig, + persist_dir: PathBuf, + contract_name: &str, + isolated_campaign: Option<&str>, + execution_profile: &str, + execution_pass: &str, +) -> PathBuf { + if let Some(root) = &corpus_config.corpus_dir { + corpus_config.corpus_dir = + Some(canonicalized(invariant_corpus_dir(root, contract_name, isolated_campaign))); + } + if let Some(root) = &corpus_config.frontier_dir { + corpus_config.frontier_dir = Some(canonicalized(invariant_frontier_dir( + root, + contract_name, + isolated_campaign, + execution_profile, + execution_pass, + ))); + } + canonicalized(persist_dir.join("failures").join(contract_short_name(contract_name))) +} + +/// Narrows a generated corpus root to the per-test directory when it exists. +fn narrow_generated_corpus_root(corpus_dir: PathBuf, target_dir: PathBuf) -> PathBuf { + let target_is_dir = + std::fs::symlink_metadata(&target_dir).is_ok_and(|metadata| metadata.file_type().is_dir()); + if target_is_dir { canonicalized(target_dir) } else { corpus_dir } +} + +fn sanitize_symbolic_artifact_component(value: &str) -> String { + let sanitized = value + .chars() + .map(|ch| if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { ch } else { '_' }) + .collect::(); + if sanitized.is_empty() { "_".to_string() } else { sanitized } +} + +fn stable_hashed_component(label: &str, identity: &str) -> String { + let hash = keccak256(identity.as_bytes()); + let hash = hex::encode(&hash[..16]); + format!("{}-{hash}", sanitize_symbolic_artifact_component(label)) +} + +fn symbolic_artifact_file_name( + contract_id: &str, + value: &str, + kind: SymbolicCounterexampleArtifactKind, +) -> String { + let identity = format!("{contract_id}\0{value}\0{kind:?}"); + format!("{}.json", stable_hashed_component(value, &identity)) +} + +/// Persists an invariant failure, with any symbolic replay storage and confirmed failure site. +fn record_invariant_failure( + failure_file: &Path, + call_sequence: &[BaseCounterExample], + settings: &InvariantSettings, + assertion_failure: bool, + storage: &[SymbolicStorageAssignment], + failure_site: Option, + fingerprint_provenance: Option, +) { + if let Some(parent) = failure_file.parent() + && let Err(err) = foundry_common::fs::create_dir_all(parent) + { + error!(%err, "Failed to create invariant failure file parent dir"); + return; + } + + if let Err(err) = foundry_common::fs::write_json_file( + failure_file, + &InvariantPersistedFailure { + call_sequence: call_sequence.to_owned(), + settings: settings.clone(), + assertion_failure, + storage: storage.to_vec(), + failure_site, + fingerprint_provenance, + }, + ) { + error!(%err, "Failed to record call sequence"); + } +} + +/// Persists a handler-side assertion bug with symbolic replay storage. +#[expect(clippy::too_many_arguments)] +fn record_handler_failure( + failure_dir: &Path, + reverter: Address, + selector: Selector, + fingerprint: B256, + call_sequence: &[BaseCounterExample], + settings: &InvariantSettings, + storage: &[SymbolicStorageAssignment], + fingerprint_provenance: Option, +) { + let handlers_dir = failure_dir.join("handlers"); + let file = handler_failure_file(&handlers_dir, reverter, selector); + record_invariant_failure( + &file, + call_sequence, + settings, + true, + storage, + Some(SymbolicInvariantFailureSite::SequenceCall { + target: reverter, + selector, + fingerprint, + }), + fingerprint_provenance, + ); +} + +fn handler_failure_file(handlers_dir: &Path, reverter: Address, selector: Selector) -> PathBuf { + let mut buf = [0u8; 24]; + buf[..20].copy_from_slice(reverter.as_slice()); + buf[20..].copy_from_slice(selector.as_slice()); + handlers_dir.join(format!("{:x}.json", keccak256(buf))) +} + +fn invariant_handler_failure_name( + identified_contracts: &ContractsByAddress, + reverter: Address, + selector: Selector, +) -> String { + identified_contracts + .get(&reverter) + .and_then(|(contract_name, abi)| { + abi.functions() + .find(|f| f.selector() == selector) + .map(|f| format!("{contract_name}::{}", f.name)) + }) + .unwrap_or_else(|| format!("{reverter}::{selector}")) +} + +fn should_symbolically_import_fuzz_corpus(config: &Config, func: &Function) -> bool { + config.symbolic.use_fuzz_corpus && func.test_function_kind().is_fuzz_test() +} + +pub(crate) fn effective_test_function_kind( + kind: TestFunctionKind, + config: &Config, + func: &Function, +) -> TestFunctionKind { + if should_symbolically_import_fuzz_corpus(config, func) { + TestFunctionKind::SymbolicTest + } else { + kind + } +} + +fn symbolic_invariant_unsupported_domain_reason( + invariant_config: &InvariantConfig, + sender_filters: &SenderFilters, + targets: &FuzzRunIdentifiedContracts, + symbolic_targets: &[SymbolicInvariantTarget], +) -> Option<&'static str> { + if sender_filters.targeted.is_empty() { + return Some("symbolic invariant execution requires explicit target senders"); + } + if invariant_config.has_delay() { + return Some("symbolic invariant execution does not model warp/roll delays"); + } + if invariant_config.call_override { + return Some("symbolic invariant execution does not model call override targets"); + } + if targets.is_updatable { + return Some("symbolic invariant execution does not model dynamically updatable targets"); + } + if invariant_config.corpus.payable_value_weight > 0 + && symbolic_targets + .iter() + .any(|target| target.function.state_mutability == StateMutability::Payable) + { + return Some("symbolic invariant execution does not model payable call values"); + } + None +} + +/// Replays one corpus-minimization candidate and records its coverage observation. +fn replay_fuzz_minimize( + result: &mut TestResult, + minimize: &FuzzMinimizeConfig, + target: String, + executor: &Executor, + corpus: &FuzzCorpusConfig, + replay_target: ShowmapReplayTarget<'_>, +) { + let Ok(mut evm_edge_indices_by_target) = minimize.evm_edge_indices.lock() else { + result.single_fail(Some("minimize edge index lock poisoned".to_string())); + return; + }; + let evm_edge_indices = evm_edge_indices_by_target + .entry(target.clone()) + .or_insert_with(|| Arc::new(Mutex::new(Default::default()))) + .clone(); + drop(evm_edge_indices_by_target); + let Ok(mut evm_edge_indices) = evm_edge_indices.lock() else { + result.single_fail(Some("minimize edge index lock poisoned".to_string())); + return; + }; + match replay_sequence_for_minimization( + executor, + MinimizationReplayInput { + sequence: minimize.input.as_ref(), + evm_edge_indices: &mut evm_edge_indices, + corpus, + stop_at_campaign_end: matches!(minimize.mode, FuzzMinimizeMode::Tmin), + }, + replay_target, + ) { + Ok(observation) => { + let replayed = observation.replayed; + let skipped = observation.skipped + observation.unmatched; + let Ok(mut observations) = minimize.observations.lock() else { + result.single_fail(Some("minimize observations lock poisoned".to_string())); + return; + }; + observations.push(FuzzMinimizeObservation { target, observation }); + result.replay_result(replayed, 0, skipped, std::time::Duration::ZERO); + } + Err(e) => result.single_fail(Some(e.to_string())), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use foundry_common::EmptyTestFilter; + use foundry_config::NatSpec; + + const CONTRACT_NAME: &str = "src/Test.t.sol:InvariantTest"; + + fn stateful_frontier_record( + id: u64, + sequence_index: usize, + call_index: usize, + both_results_retained: bool, + ) -> FuzzBranchFrontierRecord { + FuzzBranchFrontierRecord { + id, + both_results_retained, + call_index, + sequence: Vec::new(), + sequence_index: Some(sequence_index), + site: FuzzBranchFrontierSite { + address: Address::ZERO, + pc: id as usize, + opcode: opcode::EQ, + }, + operands: FuzzBranchFrontierOperands { result: false }, + } + } + + #[test] + fn symbolic_artifact_file_name_hashes_full_identity() { + let single = symbolic_artifact_file_name( + "src/A.t.sol:Contract", + "test_collision()", + SymbolicCounterexampleArtifactKind::SingleCall, + ); + let same_file_component_different_contract = symbolic_artifact_file_name( + "src/B.t.sol:Contract", + "test_collision()", + SymbolicCounterexampleArtifactKind::SingleCall, + ); + let same_contract_different_kind = symbolic_artifact_file_name( + "src/A.t.sol:Contract", + "test_collision()", + SymbolicCounterexampleArtifactKind::Sequence, + ); + + assert_ne!(single, same_file_component_different_contract); + assert_ne!(single, same_contract_different_kind); + + let hash = single + .strip_prefix("test_collision__-") + .and_then(|value| value.strip_suffix(".json")) + .expect("file name should include sanitized value prefix and json suffix"); + assert_eq!(hash.len(), 32); + } + + #[test] + fn stateful_frontier_paths_include_artifact_pass_and_campaign() { + let root = Path::new("/tmp/frontiers"); + let first = + invariant_frontier_dir(root, "src/a/Same.t.sol:Same", None, "ethereum", "single"); + let other_artifact = + invariant_frontier_dir(root, "src/b/Same.t.sol:Same", None, "ethereum", "single"); + let other_profile = + invariant_frontier_dir(root, "src/a/Same.t.sol:Same", None, "tempo", "override"); + let default_pass = + invariant_frontier_dir(root, "src/a/Same.t.sol:Same", None, "ethereum", "default"); + let override_pass = + invariant_frontier_dir(root, "src/a/Same.t.sol:Same", None, "ethereum", "override"); + let isolated = invariant_frontier_dir( + root, + "src/a/Same.t.sol:Same", + Some("invariant_one"), + "ethereum", + "single", + ); + + assert_ne!(first, other_artifact); + assert_ne!(first, other_profile); + assert_ne!(default_pass, override_pass); + assert_ne!(first, isolated); + assert!(first.ends_with("ethereum/single/shared")); + assert!(isolated.ends_with("ethereum/single/isolated/invariant_one")); + + let mut corpus = FuzzCorpusConfig { + corpus_dir: Some(PathBuf::from("/tmp/corpus")), + frontier_dir: Some(root.to_path_buf()), + ..Default::default() + }; + let failures = invariant_suite_paths( + &mut corpus, + PathBuf::from("/tmp/persist"), + "src/a/Same.t.sol:Same", + Some("invariant_one"), + "ethereum", + "single", + ); + assert_eq!( + corpus.corpus_dir, + Some(canonicalized(PathBuf::from("/tmp/corpus/Same/invariant_one"))) + ); + assert_eq!(corpus.frontier_dir, Some(canonicalized(isolated))); + assert_eq!(failures, canonicalized(PathBuf::from("/tmp/persist/failures/Same"))); + } + + #[test] + fn symbolic_sequence_failure_identity_includes_failure_site() { + let outcome = |site: CheckSequenceFailureSite| CheckSequenceOutcome { + success: false, + replayed_entirely: false, + reason: Some("same reason".to_string()), + calls_count: 1, + reverts: 0, + failure_site: Some(site), + sequence_assertion_failure: true, + sequence_reverter: None, + }; + let site = |target: u8, fingerprint: u8| CheckSequenceFailureSite::SequenceCall { + target: Address::with_last_byte(target), + selector: Selector::from([0, 0, 0, 1]), + fingerprint: B256::repeat_byte(fingerprint), + }; + let expected = outcome(site(1, 1)); + + assert!(same_sequence_failure(&outcome(site(1, 1)), &expected)); + assert!(!same_sequence_failure(&outcome(site(2, 1)), &expected)); + assert!(!same_sequence_failure(&outcome(site(1, 2)), &expected)); + } + + #[test] + fn stateful_frontiers_sample_sequence_depth() { + let frontiers = + [(6, 7), (0, 1), (8, 9), (3, 4), (9, 9), (2, 3), (5, 6), (1, 2), (7, 8), (4, 5)] + .into_iter() + .map(|(id, call_index)| { + stateful_frontier_record(id, id as usize, call_index, false) + }) + .collect(); + + let ids = select_stateful_frontiers(frontiers, 5, false) + .into_iter() + .map(|frontier| frontier.id) + .collect::>(); + + assert_eq!(ids, [1, 3, 5, 7, 9]); + } + + #[test] + fn stateful_frontiers_reserve_deep_retained_context() { + let frontiers = || { + (0..12) + .map(|id| stateful_frontier_record(id, id as usize, id as usize, id >= 10)) + .collect() + }; + + let single_id = select_stateful_frontiers(frontiers(), 1, false)[0].id; + assert_eq!(single_id, 5); + + let ids = select_stateful_frontiers(frontiers(), 5, false) + .into_iter() + .map(|frontier| frontier.id) + .collect::>(); + + assert_eq!(ids, [1, 3, 5, 7, 11]); + } + + #[test] + fn stateful_frontiers_prioritize_distinct_call_contexts() { + let frontiers = [(0, 0, 0), (1, 0, 0), (2, 1, 1), (3, 1, 1), (4, 2, 2)] + .into_iter() + .map(|(id, sequence_index, call_index)| { + stateful_frontier_record(id, sequence_index, call_index, false) + }) + .collect(); + + let ids = select_stateful_frontiers(frontiers, 3, false) + .into_iter() + .map(|frontier| frontier.id) + .collect::>(); + + assert_eq!(ids, [1, 3, 4]); + } + + #[test] + fn stateful_frontier_reservation_keeps_primary_contexts() { + let frontiers = [ + (0, 0, 0, false), + (1, 0, 0, false), + (2, 1, 1, false), + (3, 2, 2, false), + (4, 3, 3, true), + ] + .into_iter() + .map(|(id, sequence_index, call_index, both_results_retained)| { + stateful_frontier_record(id, sequence_index, call_index, both_results_retained) + }) + .collect(); + + let ids = select_stateful_frontiers(frontiers, 4, false) + .into_iter() + .map(|frontier| frontier.id) + .collect::>(); + + assert_eq!(ids, [1, 2, 3, 4]); + } + + #[test] + fn stateful_frontier_reservation_keeps_fallback_contexts() { + let frontiers = + [(0, 0, 0, false), (1, 1, 1, true), (2, 1, 1, true), (3, 2, 2, true), (4, 3, 3, true)] + .into_iter() + .map(|(id, sequence_index, call_index, both_results_retained)| { + stateful_frontier_record(id, sequence_index, call_index, both_results_retained) + }) + .collect(); + + let ids = select_stateful_frontiers(frontiers, 4, false) + .into_iter() + .map(|frontier| frontier.id) + .collect::>(); + + assert_eq!(ids, [0, 2, 3, 4]); + } + + #[test] + fn stateful_frontiers_fill_from_retained_outcomes() { + let frontiers = + (0..5).map(|id| stateful_frontier_record(id, id as usize, id as usize, true)).collect(); + + let ids = select_stateful_frontiers(frontiers, 4, false) + .into_iter() + .map(|frontier| frontier.id) + .collect::>(); + + assert_eq!(ids, [0, 1, 3, 4]); + } + + #[test] + fn stateful_frontier_replay_requires_opposite_result_at_same_site() { + let address = Address::with_last_byte(1); + let site = FuzzBranchFrontierSite { address, pc: 7, opcode: opcode::LT }; + let comparison = |address, pc, op1, op2| CmpOperands { + address, + pc, + opcode: opcode::LT, + op1: U256::from(op1), + op2: U256::from(op2), + }; + + assert!(frontier_comparison_flipped(site, true, &[comparison(address, 7, 2, 1)])); + assert!(!frontier_comparison_flipped(site, true, &[comparison(address, 7, 1, 2)])); + assert!(!frontier_comparison_flipped( + site, + true, + &[comparison(Address::with_last_byte(2), 7, 2, 1)] + )); + assert!(!frontier_comparison_flipped(site, true, &[comparison(address, 8, 2, 1)])); + } + + fn count_anchors(abi: &JsonAbi, inline_config: &InlineConfig) -> usize { + let config = Config::default(); + count_runnable_invariant_campaign_anchors( + abi, + &EmptyTestFilter::default(), + InvariantCampaignScope { + config: &config, + inline_config, + contract_name: CONTRACT_NAME, + all_override_networks: &[], + pass_network: None, + }, + ) + } + + #[test] + fn runnable_campaign_anchor_count_merges_boolean_suite_and_counts_optimizations() { + let abi = JsonAbi::parse([ + "function invariantOne() external", + "function invariantTwo() external", + "function invariantOptimizeA() external returns (int256)", + "function invariantOptimizeB() external returns (int256)", + ]) + .unwrap(); + + assert_eq!(count_anchors(&abi, &InlineConfig::new()), 3); + } + + #[test] + fn runnable_campaign_anchor_count_splits_boolean_suite_when_configs_differ() { + let abi = JsonAbi::parse([ + "function invariantOne() external", + "function invariantTwo() external", + ]) + .unwrap(); + let mut inline_config = InlineConfig::new(); + inline_config + .insert(&NatSpec { + contract: CONTRACT_NAME.to_string(), + function: Some("invariantTwo".to_string()), + line: "1:1".to_string(), + docs: "forge-config: default.invariant.depth = 1".to_string(), + }) + .unwrap(); + + assert_eq!(count_anchors(&abi, &inline_config), 2); + } + + #[test] + fn selected_campaign_merges_without_changing_namespace() { + let abi = JsonAbi::parse([ + "function invariantOne() external", + "function invariantTwo() external", + "function invariantThree() external", + ]) + .unwrap(); + let functions = abi.functions().collect::>(); + let selected = functions + .iter() + .copied() + .filter(|func| func.name != "invariantThree") + .collect::>(); + let mut inline_config = InlineConfig::new(); + inline_config + .insert(&NatSpec { + contract: CONTRACT_NAME.to_string(), + function: Some("invariantThree".to_string()), + line: "1:1".to_string(), + docs: "forge-config: default.invariant.fail-on-revert = true".to_string(), + }) + .unwrap(); + let config = Config::default(); + let selection = select_invariant_campaigns( + &functions, + &selected, + &config, + &inline_config, + CONTRACT_NAME, + ); + assert_eq!(selection.anchor_count(), 1); + assert!(selection.merge_boolean_suite); + assert!(!selection.shared_boolean_namespace); + + let uniform = select_invariant_campaigns( + &functions, + &selected, + &config, + &InlineConfig::new(), + CONTRACT_NAME, + ); + assert_eq!(uniform.anchor_count(), 1); + assert!(uniform.merge_boolean_suite); + assert!(uniform.shared_boolean_namespace); + } + + #[test] + fn runnable_campaign_anchor_count_splits_boolean_suite_when_corpus_weight_provenance_differs() { + let abi = JsonAbi::parse([ + "function invariantOne() external", + "function invariantTwo() external", + ]) + .unwrap(); + let mut inline_config = InlineConfig::new(); + inline_config + .insert(&NatSpec { + contract: CONTRACT_NAME.to_string(), + function: Some("invariantTwo".to_string()), + line: "1:1".to_string(), + docs: "forge-config: default.invariant.corpus_random_sequence_weight = 10" + .to_string(), + }) + .unwrap(); + + assert_eq!(count_anchors(&abi, &inline_config), 2); + } + + #[test] + fn runnable_campaign_anchor_count_respects_network_pass() { + let abi = JsonAbi::parse(["function invariantTempoOnly() external"]).unwrap(); + let mut inline_config = InlineConfig::new(); + inline_config + .insert(&NatSpec { + contract: CONTRACT_NAME.to_string(), + function: Some("invariantTempoOnly".to_string()), + line: "1:1".to_string(), + docs: r#"forge-config: default.networks.network = "tempo""#.to_string(), + }) + .unwrap(); + let config = Config::default(); + let override_networks = [NetworkVariant::Tempo]; + + let default_pass = count_runnable_invariant_campaign_anchors( + &abi, + &EmptyTestFilter::default(), + InvariantCampaignScope { + config: &config, + inline_config: &inline_config, + contract_name: CONTRACT_NAME, + all_override_networks: &override_networks, + pass_network: None, + }, + ); + let tempo_pass = count_runnable_invariant_campaign_anchors( + &abi, + &EmptyTestFilter::default(), + InvariantCampaignScope { + config: &config, + inline_config: &inline_config, + contract_name: CONTRACT_NAME, + all_override_networks: &override_networks, + pass_network: Some(&NetworkVariant::Tempo), + }, + ); + + assert_eq!(default_pass, 0); + assert_eq!(tempo_pass, 1); + } } diff --git a/crates/forge/src/symbolic_minimizer.rs b/crates/forge/src/symbolic_minimizer.rs new file mode 100644 index 0000000000000..31f8fe773cfda --- /dev/null +++ b/crates/forge/src/symbolic_minimizer.rs @@ -0,0 +1,2137 @@ +use crate::result::SymbolicCounterexampleCall; +use alloy_dyn_abi::{DynSolValue, JsonAbiExt}; +use alloy_json_abi::Function; +use alloy_primitives::{Address, B256, Bytes, Function as SolFunction, I256, U256}; +use foundry_evm::executors::invariant::{ + SequenceShrink, ShrinkCandidateKeys, ShrinkRun, shrink_sequence_by_removing, +}; +use itertools::Itertools; + +const MAX_SUBSET_CANDIDATES_PER_PASS: usize = 256; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +struct ReplayCallKey { + warp: Option, + roll: Option, + sender: Address, + target: Address, + calldata: Bytes, + value: Option, +} + +fn replay_call_key(call: &SymbolicCounterexampleCall) -> ReplayCallKey { + ReplayCallKey { + warp: call.warp, + roll: call.roll, + sender: call.sender, + target: call.target, + calldata: call.calldata.clone(), + value: call.value.filter(|value| !value.is_zero()), + } +} + +fn replay_sequence_key(calls: &[SymbolicCounterexampleCall]) -> Vec { + calls.iter().map(replay_call_key).collect() +} + +/// Result of deterministic single-call counterexample minimization. +#[derive(Clone, Debug)] +pub(crate) struct MinimizedSingleCall { + pub original_call: SymbolicCounterexampleCall, + pub minimized_call: SymbolicCounterexampleCall, + pub attempts: usize, + pub accepted: usize, +} + +impl MinimizedSingleCall { + pub(crate) fn changed(&self) -> bool { + self.original_call.calldata != self.minimized_call.calldata + } +} + +/// Result of deterministic stateful sequence counterexample minimization. +#[derive(Clone, Debug)] +pub(crate) struct MinimizedSequence { + pub original_calls: Vec, + pub minimized_calls: Vec, + pub attempts: usize, + pub accepted: usize, +} + +impl MinimizedSequence { + #[cfg(test)] + pub(crate) fn changed(&self) -> bool { + self.original_calls != self.minimized_calls + } + + pub(crate) fn original_calldata_bytes(&self) -> usize { + sequence_calldata_bytes(&self.original_calls) + } + + pub(crate) fn minimized_calldata_bytes(&self) -> usize { + sequence_calldata_bytes(&self.minimized_calls) + } +} + +fn sequence_calldata_bytes(calls: &[SymbolicCounterexampleCall]) -> usize { + calls.iter().map(|call| call.calldata.len()).sum() +} + +/// Minimizes a replay-confirmed stateful sequence while preserving the concrete failure. +/// +/// The caller's `still_fails` predicate must replay the whole candidate sequence and return true +/// only when it preserves the already-confirmed failure identity. +pub(crate) fn minimize_sequence_counterexample( + calls: &[SymbolicCounterexampleCall], + sender_candidates: &[Address], + max_attempts: usize, + mut still_fails: impl FnMut(&[SymbolicCounterexampleCall]) -> bool, +) -> Option { + if calls.is_empty() { + return None; + } + + let original_calls = calls.to_vec(); + let mut minimizer = + SequenceMinimizer::new(original_calls.clone(), max_attempts, &mut still_fails); + + minimizer.minimize_len(); + minimizer.minimize_calldata(); + minimizer.minimize_senders(sender_candidates); + minimizer.minimize_values(); + + let (minimized_calls, attempts, accepted) = minimizer.finish(); + Some(MinimizedSequence { original_calls, minimized_calls, attempts, accepted }) +} + +struct SequenceMinimizer<'a> { + current_calls: Vec, + tried_candidates: ShrinkCandidateKeys>, + run: ShrinkRun, + still_fails: &'a mut dyn FnMut(&[SymbolicCounterexampleCall]) -> bool, +} + +impl<'a> SequenceMinimizer<'a> { + fn new( + current_calls: Vec, + max_attempts: usize, + still_fails: &'a mut dyn FnMut(&[SymbolicCounterexampleCall]) -> bool, + ) -> Self { + let tried_candidates = ShrinkCandidateKeys::new(replay_sequence_key(¤t_calls)); + Self { current_calls, tried_candidates, run: ShrinkRun::new(max_attempts), still_fails } + } + + const fn can_try(&self) -> bool { + self.run.can_try() + } + + const fn remaining_attempts(&self) -> usize { + self.run.remaining_attempts() + } + + fn finish(self) -> (Vec, usize, usize) { + let stats = self.run.finish(); + (self.current_calls, stats.attempts, stats.accepted) + } + + fn try_candidate(&mut self, candidate_calls: Vec) -> bool { + if !self.can_try() || candidate_calls == self.current_calls { + return false; + } + + if !self.tried_candidates.insert(replay_sequence_key(&candidate_calls)) { + return false; + } + + if self.run.try_candidate(|| (self.still_fails)(&candidate_calls)) { + self.current_calls = candidate_calls; + true + } else { + false + } + } + + fn minimize_len(&mut self) { + if self.current_calls.len() <= 1 || !self.can_try() { + return; + } + + let base_calls = self.current_calls.clone(); + let current_calls = &mut self.current_calls; + let tried_candidates = &mut self.tried_candidates; + let still_fails = &mut self.still_fails; + + shrink_sequence_by_removing( + base_calls.len(), + &mut self.run, + || false, + || {}, + |shrinker| { + let candidate_calls = sequence_from_shrink(&base_calls, shrinker); + if candidate_calls == *current_calls { + return None; + } + + if !tried_candidates.insert(replay_sequence_key(&candidate_calls)) { + return None; + } + + if (*still_fails)(&candidate_calls) { + *current_calls = candidate_calls; + Some(true) + } else { + Some(false) + } + }, + ); + } + + fn minimize_calldata(&mut self) { + let mut idx = 0usize; + while idx < self.current_calls.len() && self.can_try() { + let Some(function) = self.current_calls[idx] + .signature + .as_deref() + .and_then(|signature| Function::parse(signature).ok()) + else { + idx += 1; + continue; + }; + let template = self.current_calls[idx].clone(); + let remaining_attempts = self.remaining_attempts(); + let minimized = minimize_single_call_counterexample( + &function, + &template, + remaining_attempts, + |candidate_call| { + let mut candidate_calls = self.current_calls.clone(); + candidate_calls[idx] = candidate_call.clone(); + self.try_candidate(candidate_calls) + }, + ); + if let Some(minimized) = minimized + && minimized.changed() + && let Some(call) = self.current_calls.get_mut(idx) + { + *call = minimized.minimized_call; + } + idx += 1; + } + } + + fn minimize_senders(&mut self, sender_candidates: &[Address]) { + let mut idx = 0usize; + while idx < self.current_calls.len() && self.can_try() { + for sender in sender_candidates.iter().copied() { + if !self.can_try() || self.current_calls[idx].sender == sender { + continue; + } + let mut candidate_calls = self.current_calls.clone(); + candidate_calls[idx].sender = sender; + self.try_candidate(candidate_calls); + } + idx += 1; + } + } + + fn minimize_values(&mut self) { + let mut idx = 0usize; + while idx < self.current_calls.len() && self.can_try() { + self.minimize_call_value(idx); + idx += 1; + } + } + + fn minimize_call_value(&mut self, idx: usize) { + let Some(mut accepted_value) = self.current_calls[idx].value else { + return; + }; + + let mut zero_candidate = self.current_calls.clone(); + zero_candidate[idx].value = None; + if self.try_candidate(zero_candidate) { + return; + } + + if accepted_value.is_zero() { + return; + } + + let mut rejected_value = U256::ZERO; + while accepted_value > rejected_value + U256::ONE && self.can_try() { + let candidate_value = rejected_value + ((accepted_value - rejected_value) >> 1usize); + let mut candidate_calls = self.current_calls.clone(); + candidate_calls[idx].value = Some(candidate_value); + if self.try_candidate(candidate_calls) { + accepted_value = candidate_value; + } else { + rejected_value = candidate_value; + } + } + } +} + +fn sequence_from_shrink( + calls: &[SymbolicCounterexampleCall], + shrinker: &SequenceShrink, +) -> Vec { + shrinker.apply_with_accumulated_delay( + calls, + |call| (call.warp, call.roll), + |mut call, warp, roll| { + if !warp.is_zero() { + call.warp = Some(warp); + } + if !roll.is_zero() { + call.roll = Some(roll); + } + call + }, + ) +} + +/// Minimizes a stateless symbolic counterexample with ABI-valid candidates only. +/// +/// `still_fails` must concretely replay the candidate and return `true` only when it preserves the +/// already-confirmed failure. +pub(crate) fn minimize_single_call_counterexample( + function: &Function, + call: &SymbolicCounterexampleCall, + max_attempts: usize, + mut still_fails: impl FnMut(&SymbolicCounterexampleCall) -> bool, +) -> Option { + if call.calldata.get(..4).is_none_or(|selector| selector != function.selector()) { + return None; + } + + let original_args = function.abi_decode_input(&call.calldata[4..]).ok()?; + let mut current_args = original_args; + let mut current_call = call_with_args(function, call, ¤t_args)?; + let mut run = ShrinkRun::new(max_attempts); + let mut tried_calldata = ShrinkCandidateKeys::new(current_call.calldata.clone()); + + let mut try_args = |candidate_args: &[DynSolValue]| { + if !run.can_try() { + return false; + } + let Some(candidate_call) = call_with_args(function, call, candidate_args) else { + return false; + }; + if candidate_call.calldata == current_call.calldata { + return false; + } + + if !tried_calldata.insert(candidate_call.calldata.clone()) { + return false; + } + + if run.try_candidate(|| still_fails(&candidate_call)) { + current_call = candidate_call; + true + } else { + false + } + }; + minimize_values_batch(&mut current_args, &mut try_args); + minimize_value_subsets(&mut current_args, &mut try_args); + minimize_value_pairs(&mut current_args, &mut try_args); + minimize_values(&mut current_args, &mut try_args); + + current_call = with_formatted_args(current_call, ¤t_args); + let stats = run.finish(); + + Some(MinimizedSingleCall { + original_call: call.clone(), + minimized_call: current_call, + attempts: stats.attempts, + accepted: stats.accepted, + }) +} + +fn call_with_args( + function: &Function, + template: &SymbolicCounterexampleCall, + args: &[DynSolValue], +) -> Option { + let calldata = Bytes::from(function.abi_encode_input(args).ok()?); + Some(SymbolicCounterexampleCall { calldata, args: None, raw_args: None, ..template.clone() }) +} + +fn with_formatted_args( + mut call: SymbolicCounterexampleCall, + args: &[DynSolValue], +) -> SymbolicCounterexampleCall { + call.args = Some(foundry_common::fmt::format_tokens(args).format(", ").to_string()); + call.raw_args = Some(foundry_common::fmt::format_tokens_raw(args).format(", ").to_string()); + call +} + +fn minimize_values_batch( + values: &mut Vec, + try_values: &mut dyn FnMut(&[DynSolValue]) -> bool, +) -> bool { + let candidate_values = values.iter().cloned().map(minimally_simple_value).collect::>(); + if candidate_values == *values { + return false; + } + if try_values(&candidate_values) { + *values = candidate_values; + true + } else { + false + } +} + +fn minimize_value_subsets( + values: &mut Vec, + try_values: &mut dyn FnMut(&[DynSolValue]) -> bool, +) -> bool { + let mut changed = false; + loop { + let simple_values = values.iter().cloned().map(minimally_simple_value).collect::>(); + let shrinkable_idxs = values + .iter() + .zip(&simple_values) + .enumerate() + .filter_map(|(idx, (current, simple))| (current != simple).then_some(idx)) + .collect::>(); + if shrinkable_idxs.len() < 2 { + break; + } + + let mut pass_changed = false; + for subset_size in subset_sizes(shrinkable_idxs.len()) { + let mut subset = Vec::with_capacity(subset_size); + if try_value_subset( + values, + &simple_values, + &shrinkable_idxs, + subset_size, + 0, + &mut subset, + try_values, + ) { + pass_changed = true; + break; + } + } + + if !pass_changed { + break; + } + changed = true; + } + changed +} + +fn try_value_subset( + values: &mut Vec, + simple_values: &[DynSolValue], + shrinkable_idxs: &[usize], + subset_size: usize, + start: usize, + subset: &mut Vec, + try_values: &mut dyn FnMut(&[DynSolValue]) -> bool, +) -> bool { + if subset.len() == subset_size { + let mut candidate_values = values.clone(); + for idx in subset.iter().copied() { + candidate_values[idx] = simple_values[idx].clone(); + } + if try_values(&candidate_values) { + *values = candidate_values; + return true; + } + return false; + } + + let remaining = subset_size - subset.len(); + for choice_idx in start..=shrinkable_idxs.len() - remaining { + subset.push(shrinkable_idxs[choice_idx]); + if try_value_subset( + values, + simple_values, + shrinkable_idxs, + subset_size, + choice_idx + 1, + subset, + try_values, + ) { + return true; + } + subset.pop(); + } + false +} + +fn minimally_simple_value(mut value: DynSolValue) -> DynSolValue { + minimize_value(&mut value, &mut |_| true); + value +} + +fn minimize_u256_pair_candidates( + current_left: U256, + current_right: U256, + mut try_candidate: impl FnMut(U256, U256) -> bool, +) -> bool { + if current_left.is_zero() || current_right.is_zero() { + return false; + } + + let mut accepted_left = current_left; + let mut accepted_right = current_right; + let mut rejected_left = U256::ZERO; + let mut rejected_right = U256::ZERO; + let mut changed = false; + while accepted_left > rejected_left + U256::ONE || accepted_right > rejected_right + U256::ONE { + let candidate_left = if accepted_left > rejected_left + U256::ONE { + rejected_left + ((accepted_left - rejected_left) >> 1usize) + } else { + accepted_left + }; + let candidate_right = if accepted_right > rejected_right + U256::ONE { + rejected_right + ((accepted_right - rejected_right) >> 1usize) + } else { + accepted_right + }; + + if try_candidate(candidate_left, candidate_right) { + accepted_left = candidate_left; + accepted_right = candidate_right; + changed = true; + } else { + rejected_left = candidate_left; + rejected_right = candidate_right; + } + } + changed +} + +fn minimize_values(values: &mut [DynSolValue], try_values: &mut dyn FnMut(&[DynSolValue]) -> bool) { + loop { + let mut changed = false; + for idx in 0..values.len() { + let mut value = values[idx].clone(); + let value_changed = minimize_value(&mut value, &mut |candidate| { + let mut candidate_values = values.to_vec(); + candidate_values[idx] = candidate.clone(); + try_values(&candidate_values) + }); + if value_changed { + values[idx] = value; + changed = true; + } + } + if !changed { + break; + } + } +} + +fn minimize_value_pairs( + values: &mut Vec, + try_values: &mut dyn FnMut(&[DynSolValue]) -> bool, +) -> bool { + let mut changed = false; + loop { + let mut pass_changed = false; + for left_idx in 0..values.len() { + for right_idx in left_idx + 1..values.len() { + let left = values[left_idx].clone(); + let right = values[right_idx].clone(); + if minimize_numeric_value_pair(&left, &right, |left, right| { + let mut candidate_values = values.clone(); + candidate_values[left_idx] = left; + candidate_values[right_idx] = right; + if try_values(&candidate_values) { + *values = candidate_values; + true + } else { + false + } + }) { + pass_changed = true; + break; + } + } + if pass_changed { + break; + } + } + if !pass_changed { + break; + } + changed = true; + } + changed +} + +fn minimize_value( + value: &mut DynSolValue, + try_value: &mut dyn FnMut(&DynSolValue) -> bool, +) -> bool { + let mut changed = false; + loop { + let pass_changed = + minimize_scalar_value(value, try_value) || minimize_compound_value(value, try_value); + if !pass_changed { + break; + } + changed = true; + } + changed +} + +fn minimize_scalar_value( + value: &mut DynSolValue, + try_value: &mut dyn FnMut(&DynSolValue) -> bool, +) -> bool { + match value.clone() { + DynSolValue::Bool(true) => accept_candidate(value, DynSolValue::Bool(false), try_value), + DynSolValue::Bool(false) => false, + DynSolValue::Uint(current, bits) => minimize_uint(value, current, bits, try_value), + DynSolValue::Int(current, bits) => minimize_int(value, current, bits, try_value), + DynSolValue::Address(current) => minimize_address(value, current, try_value), + DynSolValue::FixedBytes(current, size) => { + minimize_fixed_bytes(value, current, size, try_value) + } + DynSolValue::Function(current) => { + if current == SolFunction::ZERO { + false + } else { + accept_candidate(value, DynSolValue::Function(SolFunction::ZERO), try_value) + } + } + DynSolValue::Bytes(current) => minimize_bytes(value, current, try_value), + DynSolValue::String(current) => minimize_string(value, current, try_value), + DynSolValue::Array(_) + | DynSolValue::FixedArray(_) + | DynSolValue::Tuple(_) + | DynSolValue::CustomStruct { .. } => false, + } +} + +fn minimize_compound_value( + value: &mut DynSolValue, + try_value: &mut dyn FnMut(&DynSolValue) -> bool, +) -> bool { + match value.clone() { + DynSolValue::Array(mut elements) => { + if minimize_array_len(value, &mut elements, try_value) { + return true; + } + if let Some(candidate) = minimize_elements_batch( + &mut elements, + |items| DynSolValue::Array(items.to_vec()), + try_value, + ) { + *value = candidate; + return true; + } + if let Some(candidate) = minimize_element_subsets( + &mut elements, + |items| DynSolValue::Array(items.to_vec()), + try_value, + ) { + *value = candidate; + return true; + } + if let Some(candidate) = minimize_element_pairs( + &mut elements, + |items| DynSolValue::Array(items.to_vec()), + try_value, + ) { + *value = candidate; + return true; + } + minimize_elements(&mut elements, |items| DynSolValue::Array(items.to_vec()), try_value) + .is_some_and(|candidate| { + *value = candidate; + true + }) + } + DynSolValue::FixedArray(mut elements) => { + if let Some(candidate) = minimize_elements_batch( + &mut elements, + |items| DynSolValue::FixedArray(items.to_vec()), + try_value, + ) { + *value = candidate; + return true; + } + if let Some(candidate) = minimize_element_subsets( + &mut elements, + |items| DynSolValue::FixedArray(items.to_vec()), + try_value, + ) { + *value = candidate; + return true; + } + if let Some(candidate) = minimize_element_pairs( + &mut elements, + |items| DynSolValue::FixedArray(items.to_vec()), + try_value, + ) { + *value = candidate; + return true; + } + minimize_elements( + &mut elements, + |items| DynSolValue::FixedArray(items.to_vec()), + try_value, + ) + .is_some_and(|candidate| { + *value = candidate; + true + }) + } + DynSolValue::Tuple(mut elements) => { + if let Some(candidate) = minimize_elements_batch( + &mut elements, + |items| DynSolValue::Tuple(items.to_vec()), + try_value, + ) { + *value = candidate; + return true; + } + if let Some(candidate) = minimize_element_subsets( + &mut elements, + |items| DynSolValue::Tuple(items.to_vec()), + try_value, + ) { + *value = candidate; + return true; + } + if let Some(candidate) = minimize_element_pairs( + &mut elements, + |items| DynSolValue::Tuple(items.to_vec()), + try_value, + ) { + *value = candidate; + return true; + } + minimize_elements(&mut elements, |items| DynSolValue::Tuple(items.to_vec()), try_value) + .is_some_and(|candidate| { + *value = candidate; + true + }) + } + DynSolValue::CustomStruct { name, prop_names, mut tuple } => { + if let Some(candidate) = minimize_elements_batch( + &mut tuple, + |items| DynSolValue::CustomStruct { + name: name.clone(), + prop_names: prop_names.clone(), + tuple: items.to_vec(), + }, + try_value, + ) { + *value = candidate; + return true; + } + if let Some(candidate) = minimize_element_subsets( + &mut tuple, + |items| DynSolValue::CustomStruct { + name: name.clone(), + prop_names: prop_names.clone(), + tuple: items.to_vec(), + }, + try_value, + ) { + *value = candidate; + return true; + } + if let Some(candidate) = minimize_element_pairs( + &mut tuple, + |items| DynSolValue::CustomStruct { + name: name.clone(), + prop_names: prop_names.clone(), + tuple: items.to_vec(), + }, + try_value, + ) { + *value = candidate; + return true; + } + minimize_elements( + &mut tuple, + |items| DynSolValue::CustomStruct { + name: name.clone(), + prop_names: prop_names.clone(), + tuple: items.to_vec(), + }, + try_value, + ) + .is_some_and(|candidate| { + *value = candidate; + true + }) + } + _ => false, + } +} + +fn minimize_uint( + value: &mut DynSolValue, + current: U256, + bits: usize, + try_value: &mut dyn FnMut(&DynSolValue) -> bool, +) -> bool { + if !current.is_zero() && accept_candidate(value, DynSolValue::Uint(U256::ZERO, bits), try_value) + { + return true; + } + + let one = U256::ONE; + if current > one && accept_candidate(value, DynSolValue::Uint(one, bits), try_value) { + return true; + } + + let bit_limit = bits.min(256); + for bit in (0..bit_limit).rev() { + let mask = U256::ONE << bit; + if (current & mask).is_zero() { + continue; + } + let candidate = current & !mask; + if accept_candidate(value, DynSolValue::Uint(candidate, bits), try_value) { + return true; + } + } + + minimize_uint_by_search(value, current, bits, try_value) +} + +fn minimize_uint_by_search( + value: &mut DynSolValue, + current: U256, + bits: usize, + try_value: &mut dyn FnMut(&DynSolValue) -> bool, +) -> bool { + if current <= U256::ONE { + return false; + } + + let mut accepted = current; + let mut rejected = U256::ZERO; + let mut changed = false; + while accepted > rejected + U256::ONE { + let candidate: U256 = rejected + ((accepted - rejected) >> 1usize); + if accept_candidate(value, DynSolValue::Uint(candidate, bits), try_value) { + accepted = candidate; + changed = true; + } else { + rejected = candidate; + } + } + + changed +} + +fn minimize_int( + value: &mut DynSolValue, + current: I256, + bits: usize, + try_value: &mut dyn FnMut(&DynSolValue) -> bool, +) -> bool { + if current != I256::ZERO + && accept_candidate(value, DynSolValue::Int(I256::ZERO, bits), try_value) + { + return true; + } + if current.is_negative() + && current != I256::MINUS_ONE + && accept_candidate(value, DynSolValue::Int(I256::MINUS_ONE, bits), try_value) + { + return true; + } + + minimize_int_by_search(value, current, bits, try_value) +} + +fn minimize_int_by_search( + value: &mut DynSolValue, + current: I256, + bits: usize, + try_value: &mut dyn FnMut(&DynSolValue) -> bool, +) -> bool { + let mut accepted_abs = current.unsigned_abs(); + if accepted_abs <= U256::ONE { + return false; + } + + let mut rejected_abs = U256::ZERO; + let mut changed = false; + while accepted_abs > rejected_abs + U256::ONE { + let candidate_abs: U256 = rejected_abs + ((accepted_abs - rejected_abs) >> 1usize); + let candidate = signed_candidate_with_abs(current, candidate_abs); + if accept_candidate(value, DynSolValue::Int(candidate, bits), try_value) { + accepted_abs = candidate_abs; + changed = true; + } else { + rejected_abs = candidate_abs; + } + } + + changed +} + +fn minimize_address( + value: &mut DynSolValue, + current: Address, + try_value: &mut dyn FnMut(&DynSolValue) -> bool, +) -> bool { + for candidate in address_candidates(current) { + if accept_candidate(value, DynSolValue::Address(candidate), try_value) { + return true; + } + } + false +} + +fn address_candidates(current: Address) -> Vec
{ + if current.is_zero() { + return Vec::new(); + } + + let mut candidates = Vec::new(); + candidates.push(Address::ZERO); + + let deadbeef = Address::from_word(B256::from(U256::from(0xdeadbeefu64))); + if current != deadbeef { + candidates.push(deadbeef); + } + + let bytes = current.into_array(); + for idx in 0..bytes.len() { + if bytes[idx] == 0 { + continue; + } + let mut candidate = bytes; + candidate[idx] = 0; + candidates.push(Address::from(candidate)); + } + + candidates +} + +fn minimize_fixed_bytes( + value: &mut DynSolValue, + current: B256, + size: usize, + try_value: &mut dyn FnMut(&DynSolValue) -> bool, +) -> bool { + if !current.is_zero() + && accept_candidate(value, DynSolValue::FixedBytes(B256::ZERO, size), try_value) + { + return true; + } + + let mut bytes = [0u8; 32]; + bytes.copy_from_slice(current.as_slice()); + for idx in (0..size.min(bytes.len())).rev() { + if bytes[idx] == 0 { + continue; + } + let mut candidate = bytes; + candidate[idx] = 0; + if accept_candidate(value, DynSolValue::FixedBytes(B256::from(candidate), size), try_value) + { + return true; + } + } + false +} + +fn minimize_bytes( + value: &mut DynSolValue, + current: Vec, + try_value: &mut dyn FnMut(&DynSolValue) -> bool, +) -> bool { + for len in 0..current.len() { + if accept_candidate(value, DynSolValue::Bytes(current[..len].to_vec()), try_value) { + return true; + } + } + if try_delete_vec_range(¤t, |candidate| { + accept_candidate(value, DynSolValue::Bytes(candidate), try_value) + }) { + return true; + } + if try_slice_vec_range(¤t, |candidate| { + accept_candidate(value, DynSolValue::Bytes(candidate), try_value) + }) { + return true; + } + + for idx in (0..current.len()).rev() { + if current[idx] == 0 { + continue; + } + let mut candidate = current.clone(); + candidate[idx] = 0; + if accept_candidate(value, DynSolValue::Bytes(candidate), try_value) { + return true; + } + } + false +} + +fn minimize_string( + value: &mut DynSolValue, + current: String, + try_value: &mut dyn FnMut(&DynSolValue) -> bool, +) -> bool { + for len in 0..current.len() { + if current.is_char_boundary(len) + && accept_candidate(value, DynSolValue::String(current[..len].to_string()), try_value) + { + return true; + } + } + if try_delete_string_range(¤t, |candidate| { + accept_candidate(value, DynSolValue::String(candidate), try_value) + }) { + return true; + } + if try_slice_string_range(¤t, |candidate| { + accept_candidate(value, DynSolValue::String(candidate), try_value) + }) { + return true; + } + + let current_bytes = current.as_bytes(); + for idx in (0..current_bytes.len()).rev() { + if current_bytes[idx] == 0 { + continue; + } + let mut candidate = current_bytes.to_vec(); + candidate[idx] = 0; + if let Ok(candidate) = String::from_utf8(candidate) + && accept_candidate(value, DynSolValue::String(candidate), try_value) + { + return true; + } + } + false +} + +fn minimize_array_len( + value: &mut DynSolValue, + current: &mut Vec, + try_value: &mut dyn FnMut(&DynSolValue) -> bool, +) -> bool { + for len in 0..current.len() { + let candidate = DynSolValue::Array(current[..len].to_vec()); + if accept_candidate(value, candidate, try_value) { + current.truncate(len); + return true; + } + } + if try_delete_vec_range(current, |candidate| { + accept_candidate(value, DynSolValue::Array(candidate), try_value) + }) { + return true; + } + if try_slice_vec_range(current, |candidate| { + accept_candidate(value, DynSolValue::Array(candidate), try_value) + }) { + return true; + } + false +} + +fn try_delete_vec_range( + current: &[T], + mut try_candidate: impl FnMut(Vec) -> bool, +) -> bool { + for range_len in deletion_lengths(current.len()) { + for start in 0..=current.len() - range_len { + let mut candidate = Vec::with_capacity(current.len() - range_len); + candidate.extend_from_slice(¤t[..start]); + candidate.extend_from_slice(¤t[start + range_len..]); + if try_candidate(candidate) { + return true; + } + } + } + false +} + +fn try_delete_string_range(current: &str, mut try_candidate: impl FnMut(String) -> bool) -> bool { + let mut boundaries = current.char_indices().map(|(idx, _)| idx).collect::>(); + boundaries.push(current.len()); + + for range_len in deletion_lengths(boundaries.len().saturating_sub(1)) { + for start_idx in 0..=boundaries.len() - range_len - 1 { + let start = boundaries[start_idx]; + let end = boundaries[start_idx + range_len]; + let mut candidate = String::with_capacity(current.len() - (end - start)); + candidate.push_str(¤t[..start]); + candidate.push_str(¤t[end..]); + if try_candidate(candidate) { + return true; + } + } + } + false +} + +fn try_slice_vec_range( + current: &[T], + mut try_candidate: impl FnMut(Vec) -> bool, +) -> bool { + for len in 1..current.len() { + for start in 1..=current.len() - len { + let candidate = current[start..start + len].to_vec(); + if try_candidate(candidate) { + return true; + } + } + } + false +} + +fn try_slice_string_range(current: &str, mut try_candidate: impl FnMut(String) -> bool) -> bool { + let mut boundaries = current.char_indices().map(|(idx, _)| idx).collect::>(); + boundaries.push(current.len()); + let char_len = boundaries.len().saturating_sub(1); + + for len in 1..char_len { + for start_idx in 1..=char_len - len { + let start = boundaries[start_idx]; + let end = boundaries[start_idx + len]; + if try_candidate(current[start..end].to_string()) { + return true; + } + } + } + false +} + +fn deletion_lengths(len: usize) -> Vec { + if len == 0 { + return Vec::new(); + } + + let mut lengths = Vec::new(); + let mut range_len = len; + while range_len > 0 { + lengths.push(range_len); + range_len /= 2; + } + lengths.sort_unstable(); + lengths.dedup(); + lengths.reverse(); + lengths +} + +fn minimize_elements( + elements: &mut [DynSolValue], + rebuild: impl Fn(&[DynSolValue]) -> DynSolValue, + try_value: &mut dyn FnMut(&DynSolValue) -> bool, +) -> Option { + for idx in 0..elements.len() { + let mut element = elements[idx].clone(); + let changed = minimize_value(&mut element, &mut |candidate| { + let mut candidate_elements = elements.to_vec(); + candidate_elements[idx] = candidate.clone(); + try_value(&rebuild(&candidate_elements)) + }); + if changed { + elements[idx] = element; + return Some(rebuild(elements)); + } + } + None +} + +fn minimize_elements_batch( + elements: &mut Vec, + rebuild: impl Fn(&[DynSolValue]) -> DynSolValue, + try_value: &mut dyn FnMut(&DynSolValue) -> bool, +) -> Option { + let simple_elements = elements.iter().cloned().map(minimally_simple_value).collect::>(); + if simple_elements == *elements { + return None; + } + let candidate = rebuild(&simple_elements); + try_value(&candidate).then(|| { + *elements = simple_elements; + candidate + }) +} + +fn minimize_element_pairs( + elements: &mut Vec, + rebuild: impl Fn(&[DynSolValue]) -> DynSolValue, + try_value: &mut dyn FnMut(&DynSolValue) -> bool, +) -> Option { + for left_idx in 0..elements.len() { + for right_idx in left_idx + 1..elements.len() { + let left = elements[left_idx].clone(); + let right = elements[right_idx].clone(); + if minimize_numeric_value_pair(&left, &right, |left, right| { + let mut candidate_elements = elements.clone(); + candidate_elements[left_idx] = left; + candidate_elements[right_idx] = right; + if try_value(&rebuild(&candidate_elements)) { + *elements = candidate_elements; + true + } else { + false + } + }) { + return Some(rebuild(elements)); + } + } + } + None +} + +fn minimize_element_subsets( + elements: &mut Vec, + rebuild: impl Fn(&[DynSolValue]) -> DynSolValue, + try_value: &mut dyn FnMut(&DynSolValue) -> bool, +) -> Option { + let simple_elements = elements.iter().cloned().map(minimally_simple_value).collect::>(); + let shrinkable_idxs = elements + .iter() + .zip(&simple_elements) + .enumerate() + .filter_map(|(idx, (current, simple))| (current != simple).then_some(idx)) + .collect::>(); + if shrinkable_idxs.len() < 2 { + return None; + } + + for subset_size in subset_sizes(shrinkable_idxs.len()) { + let mut search = ElementSubsetSearch { + elements, + simple_elements: &simple_elements, + shrinkable_idxs: &shrinkable_idxs, + rebuild: &rebuild, + try_value, + }; + let mut subset = Vec::with_capacity(subset_size); + if let Some(candidate_elements) = + try_element_subset(&mut search, subset_size, 0, &mut subset) + { + *elements = candidate_elements.clone(); + return Some(rebuild(&candidate_elements)); + } + } + None +} + +fn minimize_numeric_value_pair( + left: &DynSolValue, + right: &DynSolValue, + mut try_pair: impl FnMut(DynSolValue, DynSolValue) -> bool, +) -> bool { + match (left, right) { + (DynSolValue::Uint(left, left_bits), DynSolValue::Uint(right, right_bits)) => { + let mut try_uint_pair = |left, right| { + try_pair(DynSolValue::Uint(left, *left_bits), DynSolValue::Uint(right, *right_bits)) + }; + minimize_u256_pair_delta_candidates(*left, *right, &mut try_uint_pair) + } + (DynSolValue::Int(left, left_bits), DynSolValue::Int(right, right_bits)) => { + minimize_i256_pair_candidates(*left, *right, |left, right| { + try_pair(DynSolValue::Int(left, *left_bits), DynSolValue::Int(right, *right_bits)) + }) + } + _ => false, + } +} + +fn minimize_u256_pair_delta_candidates( + current_left: U256, + current_right: U256, + try_candidate: &mut impl FnMut(U256, U256) -> bool, +) -> bool { + let one = U256::ONE; + if (current_left, current_right) != (one, one) + && !current_left.is_zero() + && !current_right.is_zero() + && try_candidate(one, one) + { + return true; + } + + match current_left.cmp(¤t_right) { + std::cmp::Ordering::Equal => { + !current_left.is_zero() && try_candidate(U256::ZERO, U256::ZERO) + } + std::cmp::Ordering::Greater => { + let delta = current_left - current_right; + !current_right.is_zero() && try_candidate(delta, U256::ZERO) + } + std::cmp::Ordering::Less => { + let delta = current_right - current_left; + !current_left.is_zero() && try_candidate(U256::ZERO, delta) + } + } +} + +fn minimize_i256_pair_candidates( + current_left: I256, + current_right: I256, + mut try_candidate: impl FnMut(I256, I256) -> bool, +) -> bool { + if current_left == I256::ZERO || current_right == I256::ZERO { + return false; + } + if current_left.is_negative() != current_right.is_negative() { + return false; + } + + minimize_u256_pair_candidates( + current_left.unsigned_abs(), + current_right.unsigned_abs(), + |left_abs, right_abs| { + let left = signed_candidate_with_abs(current_left, left_abs); + let right = signed_candidate_with_abs(current_right, right_abs); + try_candidate(left, right) + }, + ) +} + +const fn signed_candidate_with_abs(current: I256, abs: U256) -> I256 { + if current.is_negative() { I256::from_raw(abs.wrapping_neg()) } else { I256::from_raw(abs) } +} + +fn subset_sizes(shrinkable_len: usize) -> Vec { + let mut sizes = Vec::new(); + for subset_size in 2..shrinkable_len { + if bounded_combination_count(shrinkable_len, subset_size, MAX_SUBSET_CANDIDATES_PER_PASS) + .is_some() + { + sizes.push(subset_size); + } + } + sizes +} + +fn bounded_combination_count(n: usize, k: usize, max: usize) -> Option { + if k > n { + return None; + } + let k = k.min(n - k); + let mut count = 1usize; + for step in 1..=k { + count = count.checked_mul(n + 1 - step)?; + count /= step; + if count > max { + return None; + } + } + Some(count) +} + +struct ElementSubsetSearch<'a, R> +where + R: Fn(&[DynSolValue]) -> DynSolValue, +{ + elements: &'a [DynSolValue], + simple_elements: &'a [DynSolValue], + shrinkable_idxs: &'a [usize], + rebuild: &'a R, + try_value: &'a mut dyn FnMut(&DynSolValue) -> bool, +} + +fn try_element_subset( + search: &mut ElementSubsetSearch<'_, R>, + subset_size: usize, + start: usize, + subset: &mut Vec, +) -> Option> +where + R: Fn(&[DynSolValue]) -> DynSolValue, +{ + if subset.len() == subset_size { + let mut candidate_elements = search.elements.to_vec(); + for idx in subset.iter().copied() { + candidate_elements[idx] = search.simple_elements[idx].clone(); + } + if (search.try_value)(&(search.rebuild)(&candidate_elements)) { + return Some(candidate_elements); + } + return None; + } + + let remaining = subset_size - subset.len(); + for choice_idx in start..=search.shrinkable_idxs.len() - remaining { + subset.push(search.shrinkable_idxs[choice_idx]); + if let Some(candidate) = try_element_subset(search, subset_size, choice_idx + 1, subset) { + return Some(candidate); + } + subset.pop(); + } + None +} + +fn accept_candidate( + value: &mut DynSolValue, + candidate: DynSolValue, + try_value: &mut dyn FnMut(&DynSolValue) -> bool, +) -> bool { + if *value == candidate { + return false; + } + if try_value(&candidate) { + *value = candidate; + true + } else { + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_json_abi::JsonAbi; + use std::collections::HashSet; + + const TEST_MAX_MINIMIZATION_ATTEMPTS: usize = 5000; + + fn call(function: &Function, args: Vec) -> SymbolicCounterexampleCall { + SymbolicCounterexampleCall { + warp: None, + roll: None, + sender: Address::ZERO, + target: Address::repeat_byte(0x11), + calldata: Bytes::from(function.abi_encode_input(&args).unwrap()), + value: Some(U256::ZERO), + contract_name: Some("Target".to_string()), + function_name: Some(function.name.clone()), + signature: Some(function.signature()), + args: Some(foundry_common::fmt::format_tokens(&args).format(", ").to_string()), + raw_args: Some(foundry_common::fmt::format_tokens_raw(&args).format(", ").to_string()), + } + } + + fn decoded(function: &Function, call: &SymbolicCounterexampleCall) -> Vec { + function.abi_decode_input(&call.calldata[4..]).unwrap() + } + + fn address(value: u64) -> Address { + Address::from_word(B256::from(U256::from(value))) + } + + #[test] + fn minimizes_common_abi_values_with_replay_predicate() { + let abi = + JsonAbi::parse(["function check(uint256,address,bytes,string,uint256[]) external"]) + .unwrap(); + let function = abi.functions().next().unwrap(); + let start = call( + function, + vec![ + DynSolValue::Uint(U256::from(0xff), 256), + DynSolValue::Address(Address::repeat_byte(0xaa)), + DynSolValue::Bytes(vec![0x99, 0x42, 0x88]), + DynSolValue::String("abc".to_string()), + DynSolValue::Array(vec![ + DynSolValue::Uint(U256::ZERO, 256), + DynSolValue::Uint(U256::from(7), 256), + DynSolValue::Uint(U256::from(9), 256), + ]), + ], + ); + + let minimized = + minimize_single_call_counterexample(function, &start, TEST_MAX_MINIMIZATION_ATTEMPTS, |candidate| { + let args = decoded(function, candidate); + matches!(&args[0], DynSolValue::Uint(value, _) if *value & U256::from(0x2a) == U256::from(0x2a)) + && matches!(&args[1], DynSolValue::Address(address) if address.as_slice()[19] == 0xaa) + && matches!(&args[2], DynSolValue::Bytes(bytes) if bytes.get(1) == Some(&0x42)) + && matches!(&args[3], DynSolValue::String(value) if value.starts_with('a')) + && matches!(&args[4], DynSolValue::Array(values) if values.iter().any(|value| matches!(value, DynSolValue::Uint(uint, _) if *uint == U256::from(7)))) + }) + .unwrap(); + + let args = decoded(function, &minimized.minimized_call); + assert_eq!(args[0], DynSolValue::Uint(U256::from(0x2a), 256)); + assert_eq!(args[1], DynSolValue::Address(Address::with_last_byte(0xaa))); + assert_eq!(args[2], DynSolValue::Bytes(vec![0, 0x42])); + assert_eq!(args[3], DynSolValue::String("a".to_string())); + assert_eq!(args[4], DynSolValue::Array(vec![DynSolValue::Uint(U256::from(7), 256)])); + assert!(minimized.changed()); + assert!(minimized.attempts > minimized.accepted); + assert!(minimized.accepted > 0); + } + + #[test] + fn minimizes_with_echidna_style_range_deletion_and_numeric_lowering() { + let abi = + JsonAbi::parse(["function check(uint256,int256,bytes,string,uint256[]) external"]) + .unwrap(); + let function = abi.functions().next().unwrap(); + let start = call( + function, + vec![ + DynSolValue::Uint(U256::from(100), 256), + DynSolValue::Int(I256::try_from(-100).unwrap(), 256), + DynSolValue::Bytes(vec![1, 2, 3, 0x42, 4]), + DynSolValue::String("abcZ".to_string()), + DynSolValue::Array(vec![ + DynSolValue::Uint(U256::ONE, 256), + DynSolValue::Uint(U256::from(2), 256), + DynSolValue::Uint(U256::from(7), 256), + DynSolValue::Uint(U256::from(3), 256), + ]), + ], + ); + + let minimized = minimize_single_call_counterexample(function, &start, TEST_MAX_MINIMIZATION_ATTEMPTS, |candidate| { + let args = decoded(function, candidate); + matches!(&args[0], DynSolValue::Uint(value, _) if *value > U256::from(42)) + && matches!(&args[1], DynSolValue::Int(value, _) if *value < I256::try_from(-42).unwrap()) + && matches!(&args[2], DynSolValue::Bytes(bytes) if bytes.contains(&0x42)) + && matches!(&args[3], DynSolValue::String(value) if value.contains('Z')) + && matches!(&args[4], DynSolValue::Array(values) if values.iter().any(|value| matches!(value, DynSolValue::Uint(uint, _) if *uint == U256::from(7)))) + }) + .unwrap(); + + let args = decoded(function, &minimized.minimized_call); + assert_eq!(args[0], DynSolValue::Uint(U256::from(43), 256)); + assert_eq!(args[1], DynSolValue::Int(I256::try_from(-43).unwrap(), 256)); + assert_eq!(args[2], DynSolValue::Bytes(vec![0x42])); + assert_eq!(args[3], DynSolValue::String("Z".to_string())); + assert_eq!(args[4], DynSolValue::Array(vec![DynSolValue::Uint(U256::from(7), 256)])); + assert!(minimized.changed()); + assert!(minimized.accepted > 0); + } + + #[test] + fn matches_echidna_uint8_threshold_shrink_result() { + let abi = JsonAbi::parse(["function check(uint8) external"]).unwrap(); + let function = abi.functions().next().unwrap(); + let start = call(function, vec![DynSolValue::Uint(U256::from(246), 8)]); + + let minimized = minimize_single_call_counterexample( + function, + &start, + TEST_MAX_MINIMIZATION_ATTEMPTS, + |candidate| { + let args = decoded(function, candidate); + matches!(&args[0], DynSolValue::Uint(value, 8) if *value > U256::from(42)) + }, + ) + .unwrap(); + + assert_eq!( + decoded(function, &minimized.minimized_call), + vec![DynSolValue::Uint(U256::from(43), 8)] + ); + assert!(minimized.attempts < TEST_MAX_MINIMIZATION_ATTEMPTS); + } + + #[test] + fn uint_minimization_prefers_bit_clearing_before_binary_search() { + let mut value = DynSolValue::Uint(U256::from(100), 256); + let accepted = [U256::from(100), U256::from(50), U256::from(36)]; + + loop { + let (current, bits) = match &value { + DynSolValue::Uint(current, bits) => (*current, *bits), + _ => unreachable!(), + }; + if !minimize_uint( + &mut value, + current, + bits, + &mut |candidate| matches!(candidate, DynSolValue::Uint(candidate, _) if accepted.contains(candidate)), + ) { + break; + } + } + + assert_eq!(value, DynSolValue::Uint(U256::from(36), 256)); + } + + #[test] + fn uint_pair_delta_minimization_tries_simple_nonzero_pair_first() { + let mut candidates = Vec::new(); + + assert!(minimize_u256_pair_delta_candidates( + U256::from(100), + U256::from(50), + &mut |left, right| { + candidates.push((left, right)); + (left, right) == (U256::ONE, U256::ONE) + }, + )); + + assert_eq!(candidates, vec![(U256::ONE, U256::ONE)]); + } + + #[test] + fn skips_duplicate_single_call_replay_candidates() { + let abi = JsonAbi::parse(["function check(uint256,uint256) external"]).unwrap(); + let function = abi.functions().next().unwrap(); + let start = call( + function, + vec![DynSolValue::Uint(U256::ONE, 256), DynSolValue::Uint(U256::ONE, 256)], + ); + let mut replayed = HashSet::new(); + + let minimized = minimize_single_call_counterexample( + function, + &start, + TEST_MAX_MINIMIZATION_ATTEMPTS, + |candidate| { + assert!(replayed.insert(candidate.calldata.clone()), "duplicate candidate replay"); + false + }, + ) + .unwrap(); + + assert!(!minimized.changed()); + assert_eq!(minimized.accepted, 0); + assert_eq!(minimized.attempts, 3); + assert_eq!(replayed.len(), minimized.attempts); + } + + #[test] + fn matches_echidna_contiguous_slice_examples() { + let bytes_abi = JsonAbi::parse(["function check(bytes) external"]).unwrap(); + let bytes_function = bytes_abi.functions().next().unwrap(); + let bytes_start = + call(bytes_function, vec![DynSolValue::Bytes(vec![0x99, 0x41, 0x42, 0x88])]); + let bytes_minimized = minimize_single_call_counterexample( + bytes_function, + &bytes_start, + TEST_MAX_MINIMIZATION_ATTEMPTS, + |candidate| { + decoded(bytes_function, candidate) == vec![DynSolValue::Bytes(vec![0x41, 0x42])] + }, + ) + .unwrap(); + assert_eq!( + decoded(bytes_function, &bytes_minimized.minimized_call), + vec![DynSolValue::Bytes(vec![0x41, 0x42])] + ); + + let string_abi = JsonAbi::parse(["function check(string) external"]).unwrap(); + let string_function = string_abi.functions().next().unwrap(); + let string_start = call(string_function, vec![DynSolValue::String("xOKy".to_string())]); + let string_minimized = minimize_single_call_counterexample( + string_function, + &string_start, + TEST_MAX_MINIMIZATION_ATTEMPTS, + |candidate| { + decoded(string_function, candidate) == vec![DynSolValue::String("OK".to_string())] + }, + ) + .unwrap(); + assert_eq!( + decoded(string_function, &string_minimized.minimized_call), + vec![DynSolValue::String("OK".to_string())] + ); + + let array_abi = JsonAbi::parse(["function check(uint256[]) external"]).unwrap(); + let array_function = array_abi.functions().next().unwrap(); + let array_start = call( + array_function, + vec![DynSolValue::Array(vec![ + DynSolValue::Uint(U256::from(9), 256), + DynSolValue::Uint(U256::from(4), 256), + DynSolValue::Uint(U256::from(2), 256), + DynSolValue::Uint(U256::from(8), 256), + ])], + ); + let array_minimized = minimize_single_call_counterexample( + array_function, + &array_start, + TEST_MAX_MINIMIZATION_ATTEMPTS, + |candidate| { + let args = decoded(array_function, candidate); + matches!(&args[0], DynSolValue::Array(values) if values == &[ + DynSolValue::Uint(U256::from(4), 256), + DynSolValue::Uint(U256::from(2), 256), + ]) + }, + ) + .unwrap(); + assert_eq!( + decoded(array_function, &array_minimized.minimized_call), + vec![DynSolValue::Array(vec![ + DynSolValue::Uint(U256::from(4), 256), + DynSolValue::Uint(U256::from(2), 256), + ])] + ); + } + + #[test] + fn matches_echidna_address_deadbeef_and_bool_examples() { + let deadbeef = address(0xdeadbeef); + + let address_abi = JsonAbi::parse(["function check(address) external"]).unwrap(); + let address_function = address_abi.functions().next().unwrap(); + let address_start = + call(address_function, vec![DynSolValue::Address(Address::repeat_byte(0xaa))]); + let address_minimized = minimize_single_call_counterexample( + address_function, + &address_start, + TEST_MAX_MINIMIZATION_ATTEMPTS, + |candidate| { + let args = decoded(address_function, candidate); + matches!(&args[..], [DynSolValue::Address(address)] if *address == deadbeef) + }, + ) + .unwrap(); + assert_eq!( + decoded(address_function, &address_minimized.minimized_call), + vec![DynSolValue::Address(deadbeef)] + ); + + let bool_abi = JsonAbi::parse(["function check(bool) external"]).unwrap(); + let bool_function = bool_abi.functions().next().unwrap(); + let bool_start = call(bool_function, vec![DynSolValue::Bool(true)]); + let bool_minimized = minimize_single_call_counterexample( + bool_function, + &bool_start, + TEST_MAX_MINIMIZATION_ATTEMPTS, + |candidate| decoded(bool_function, candidate) == vec![DynSolValue::Bool(false)], + ) + .unwrap(); + assert_eq!( + decoded(bool_function, &bool_minimized.minimized_call), + vec![DynSolValue::Bool(false)] + ); + } + + #[test] + fn minimizes_correlated_multi_arg_slice_examples() { + let abi = JsonAbi::parse(["function check(bytes,string) external"]).unwrap(); + let function = abi.functions().next().unwrap(); + let start = call( + function, + vec![ + DynSolValue::Bytes(vec![0x99, 0x41, 0x42, 0x88]), + DynSolValue::String("xOKy".to_string()), + ], + ); + + let minimized = minimize_single_call_counterexample( + function, + &start, + TEST_MAX_MINIMIZATION_ATTEMPTS, + |candidate| { + let args = decoded(function, candidate); + matches!(&args[..], [ + DynSolValue::Bytes(bytes), + DynSolValue::String(string), + ] if bytes == &[0x41, 0x42] && string.contains("OK")) + }, + ) + .unwrap(); + + assert_eq!( + decoded(function, &minimized.minimized_call), + vec![DynSolValue::Bytes(vec![0x41, 0x42]), DynSolValue::String("OK".to_string()),] + ); + assert!(minimized.changed()); + } + + #[test] + fn adapts_echidna_values_darray_fixture() { + let abi = JsonAbi::parse(["function add_darray(address[]) external"]).unwrap(); + let function = abi.functions().next().unwrap(); + let target = address(0x123456); + let start = call( + function, + vec![DynSolValue::Array(vec![ + DynSolValue::Address(address(0xaaaa)), + DynSolValue::Address(target), + DynSolValue::Address(address(0xbbbb)), + ])], + ); + + let minimized = minimize_single_call_counterexample( + function, + &start, + TEST_MAX_MINIMIZATION_ATTEMPTS, + |candidate| { + let args = decoded(function, candidate); + matches!(&args[0], DynSolValue::Array(values) if values.iter().any(|value| { + matches!(value, DynSolValue::Address(candidate) if *candidate == target) + })) + }, + ) + .unwrap(); + + assert_eq!( + decoded(function, &minimized.minimized_call), + vec![DynSolValue::Array(vec![DynSolValue::Address(target)])] + ); + assert!(minimized.changed()); + } + + #[test] + fn adapts_echidna_abiv2_dynamic_struct_fixture() { + let abi = JsonAbi::parse(["function yolo((uint256,string,address)) external"]).unwrap(); + let function = abi.functions().next().unwrap(); + let start = call( + function, + vec![DynSolValue::Tuple(vec![ + DynSolValue::Uint(U256::from(137), 256), + DynSolValue::String("xyoloy".to_string()), + DynSolValue::Address(Address::repeat_byte(0xaa)), + ])], + ); + + let minimized = minimize_single_call_counterexample( + function, + &start, + TEST_MAX_MINIMIZATION_ATTEMPTS, + |candidate| { + let args = decoded(function, candidate); + matches!(&args[0], DynSolValue::Tuple(values) + if matches!(&values[..], [ + DynSolValue::Uint(_, _), + DynSolValue::String(value), + DynSolValue::Address(_), + ] if value.contains("yolo"))) + }, + ) + .unwrap(); + + assert_eq!( + decoded(function, &minimized.minimized_call), + vec![DynSolValue::Tuple(vec![ + DynSolValue::Uint(U256::ZERO, 256), + DynSolValue::String("yolo".to_string()), + DynSolValue::Address(Address::ZERO), + ])] + ); + assert!(minimized.changed()); + } + + #[test] + fn adapts_echidna_abiv2_multituple_fixture() { + let abi = JsonAbi::parse(["function f(((bytes)),((bytes))) external"]).unwrap(); + let function = abi.functions().next().unwrap(); + let start = call( + function, + vec![ + DynSolValue::Tuple(vec![DynSolValue::Tuple(vec![DynSolValue::Bytes(vec![ + 0x99, 0x42, 0x88, + ])])]), + DynSolValue::Tuple(vec![DynSolValue::Tuple(vec![DynSolValue::Bytes(vec![ + 0xaa, 0xbb, + ])])]), + ], + ); + + let minimized = minimize_single_call_counterexample( + function, + &start, + TEST_MAX_MINIMIZATION_ATTEMPTS, + |candidate| { + let args = decoded(function, candidate); + matches!(&args[0], DynSolValue::Tuple(outer) + if matches!(&outer[0], DynSolValue::Tuple(inner) + if matches!(&inner[0], DynSolValue::Bytes(bytes) if bytes.contains(&0x42)))) + }, + ) + .unwrap(); + + assert_eq!( + decoded(function, &minimized.minimized_call), + vec![ + DynSolValue::Tuple(vec![DynSolValue::Tuple(vec![DynSolValue::Bytes(vec![0x42])])]), + DynSolValue::Tuple(vec![DynSolValue::Tuple(vec![DynSolValue::Bytes(Vec::new())])]), + ] + ); + assert!(minimized.changed()); + } + + #[test] + fn minimizes_correlated_top_level_abi_value_subsets() { + let abi = JsonAbi::parse(["function check(uint256,uint256,bytes) external"]).unwrap(); + let function = abi.functions().next().unwrap(); + let start = call( + function, + vec![ + DynSolValue::Uint(U256::ONE, 256), + DynSolValue::Uint(U256::ONE, 256), + DynSolValue::Bytes(vec![0x99, 0x42, 0x88]), + ], + ); + + let minimized = minimize_single_call_counterexample( + function, + &start, + TEST_MAX_MINIMIZATION_ATTEMPTS, + |candidate| { + let args = decoded(function, candidate); + matches!(&args[..], [ + DynSolValue::Uint(left, _), + DynSolValue::Uint(right, _), + DynSolValue::Bytes(bytes), + ] if left.is_zero() && right.is_zero() && bytes.contains(&0x42)) + }, + ) + .unwrap(); + + assert_eq!( + decoded(function, &minimized.minimized_call), + vec![ + DynSolValue::Uint(U256::ZERO, 256), + DynSolValue::Uint(U256::ZERO, 256), + DynSolValue::Bytes(vec![0x42]), + ] + ); + assert!(minimized.accepted > 0); + } + + #[test] + fn minimizes_correlated_nested_abi_value_subsets() { + let abi = JsonAbi::parse(["function check((uint256,uint256,bytes)) external"]).unwrap(); + let function = abi.functions().next().unwrap(); + let start = call( + function, + vec![DynSolValue::Tuple(vec![ + DynSolValue::Uint(U256::ONE, 256), + DynSolValue::Uint(U256::ONE, 256), + DynSolValue::Bytes(vec![0x99, 0x42, 0x88]), + ])], + ); + + let minimized = minimize_single_call_counterexample( + function, + &start, + TEST_MAX_MINIMIZATION_ATTEMPTS, + |candidate| { + let args = decoded(function, candidate); + matches!(&args[0], DynSolValue::Tuple(values) + if matches!(&values[..], [ + DynSolValue::Uint(left, _), + DynSolValue::Uint(right, _), + DynSolValue::Bytes(bytes), + ] if left.is_zero() && right.is_zero() && bytes.contains(&0x42))) + }, + ) + .unwrap(); + + assert_eq!( + decoded(function, &minimized.minimized_call), + vec![DynSolValue::Tuple(vec![ + DynSolValue::Uint(U256::ZERO, 256), + DynSolValue::Uint(U256::ZERO, 256), + DynSolValue::Bytes(vec![0x42]), + ]),] + ); + assert!(minimized.accepted > 0); + } + + #[test] + fn adapts_echidna_symbolic_fixed_array_relation_fixture() { + let abi = JsonAbi::parse(["function array(uint256[3]) external"]).unwrap(); + let function = abi.functions().next().unwrap(); + let start = call( + function, + vec![DynSolValue::FixedArray(vec![ + DynSolValue::Uint(U256::from(4_370_001), 256), + DynSolValue::Uint(U256::from(1_524_785_991), 256), + DynSolValue::Uint(U256::from(4_370_000), 256), + ])], + ); + + let minimized = minimize_single_call_counterexample( + function, + &start, + TEST_MAX_MINIMIZATION_ATTEMPTS, + |candidate| { + let args = decoded(function, candidate); + matches!(&args[0], DynSolValue::FixedArray(values) + if matches!(&values[..], [ + DynSolValue::Uint(left, _), + DynSolValue::Uint(_, _), + DynSolValue::Uint(right, _), + ] if *left == *right + U256::ONE)) + }, + ) + .unwrap(); + + assert_eq!( + decoded(function, &minimized.minimized_call), + vec![DynSolValue::FixedArray(vec![ + DynSolValue::Uint(U256::ONE, 256), + DynSolValue::Uint(U256::ZERO, 256), + DynSolValue::Uint(U256::ZERO, 256), + ])] + ); + assert!(minimized.changed()); + assert!(minimized.accepted > 0); + } + + #[test] + fn adapts_echidna_addressarrayutils_duplicate_fixture() { + let abi = JsonAbi::parse(["function checkNoDuplicate(address[]) external"]).unwrap(); + let function = abi.functions().next().unwrap(); + let start = call( + function, + vec![DynSolValue::Array(vec![ + DynSolValue::Address(address(0x20000)), + DynSolValue::Address(address(0xffff_ffff)), + DynSolValue::Address(Address::ZERO), + DynSolValue::Address(address(0x20000)), + DynSolValue::Address(address(0x0001_ffff_fffe)), + DynSolValue::Address(address(0x30000)), + ])], + ); + + let minimized = minimize_single_call_counterexample(function, &start, TEST_MAX_MINIMIZATION_ATTEMPTS, |candidate| { + let args = decoded(function, candidate); + matches!(&args[0], DynSolValue::Array(values) if values.iter().array_combinations().any(|[left, right]| left == right)) + }) + .unwrap(); + + assert_eq!( + decoded(function, &minimized.minimized_call), + vec![DynSolValue::Array(vec![ + DynSolValue::Address(Address::ZERO), + DynSolValue::Address(Address::ZERO), + ])] + ); + assert!(minimized.changed()); + assert!(minimized.accepted > 0); + } + + #[test] + fn skips_duplicate_sequence_replay_candidates() { + let abi = JsonAbi::parse(["function noop() external"]).unwrap(); + let function = abi.functions().next().unwrap(); + let mut start = call(function, Vec::new()); + start.sender = address(0xaaaa); + start.value = Some(U256::ZERO); + let sender = address(0x100); + let mut replays = 0usize; + + let minimized = minimize_sequence_counterexample( + &[start], + &[sender, sender], + TEST_MAX_MINIMIZATION_ATTEMPTS, + |calls| { + replays += 1; + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].sender, sender); + assert_eq!(calls[0].value, Some(U256::ZERO)); + false + }, + ) + .unwrap(); + + assert!(!minimized.changed()); + assert_eq!(minimized.accepted, 0); + assert_eq!(minimized.attempts, 1); + assert_eq!(replays, minimized.attempts); + } + + #[test] + fn minimizes_stateful_sequence_length_calldata_senders_and_values() { + let abi = JsonAbi::parse([ + "function noise(uint256) external", + "function prime(uint256) external", + "function fire(uint256) external payable", + ]) + .unwrap(); + let noise = abi.functions().find(|function| function.name == "noise").unwrap(); + let prime = abi.functions().find(|function| function.name == "prime").unwrap(); + let fire = abi.functions().find(|function| function.name == "fire").unwrap(); + let smaller_sender = address(0x100); + let original_sender = address(0xaaaa); + let mut sequence = vec![ + call(noise, vec![DynSolValue::Uint(U256::from(999), 256)]), + call(prime, vec![DynSolValue::Uint(U256::from(1_000), 256)]), + call(noise, vec![DynSolValue::Uint(U256::from(123), 256)]), + call(fire, vec![DynSolValue::Uint(U256::from(5_000), 256)]), + ]; + for call in &mut sequence { + call.sender = original_sender; + call.value = Some(U256::from(200)); + } + + let minimized = minimize_sequence_counterexample( + &sequence, + &[smaller_sender], + TEST_MAX_MINIMIZATION_ATTEMPTS, + |candidate| { + let mut primed = false; + for call in candidate { + if call.calldata.get(..4) == Some(prime.selector().as_slice()) { + let args = decoded(prime, call); + primed |= matches!(&args[0], DynSolValue::Uint(value, _) if *value > U256::from(40)); + } + if call.calldata.get(..4) == Some(fire.selector().as_slice()) { + let args = decoded(fire, call); + let enough_value = call.value.unwrap_or_default() > U256::from(10); + if primed + && enough_value + && matches!(&args[0], DynSolValue::Uint(value, _) if *value > U256::from(100)) + { + return true; + } + } + } + false + }, + ) + .unwrap(); + + assert!(minimized.changed()); + assert_eq!(minimized.minimized_calls.len(), 2); + assert_eq!( + decoded(prime, &minimized.minimized_calls[0]), + vec![DynSolValue::Uint(U256::from(41), 256)] + ); + assert_eq!( + decoded(fire, &minimized.minimized_calls[1]), + vec![DynSolValue::Uint(U256::from(101), 256)] + ); + assert_eq!(minimized.minimized_calls[0].sender, smaller_sender); + assert_eq!(minimized.minimized_calls[1].sender, smaller_sender); + assert_eq!(minimized.minimized_calls[0].value, None); + assert_eq!(minimized.minimized_calls[1].value, Some(U256::from(11))); + assert_eq!(minimized.original_calldata_bytes(), sequence_calldata_bytes(&sequence)); + assert!(minimized.minimized_calldata_bytes() < minimized.original_calldata_bytes()); + assert!(minimized.accepted > 0); + } + + #[test] + fn leaves_already_minimal_counterexample_replayable() { + let abi = JsonAbi::parse(["function check(int256,bool,bytes3) external"]).unwrap(); + let function = abi.functions().next().unwrap(); + let mut fixed_bytes = [0u8; 32]; + fixed_bytes[2] = 0x42; + let start = call( + function, + vec![ + DynSolValue::Int(I256::MINUS_ONE, 256), + DynSolValue::Bool(false), + DynSolValue::FixedBytes(B256::from(fixed_bytes), 3), + ], + ); + + let minimized = minimize_single_call_counterexample( + function, + &start, + TEST_MAX_MINIMIZATION_ATTEMPTS, + |candidate| { + let args = decoded(function, candidate); + matches!(&args[0], DynSolValue::Int(value, _) if *value == I256::MINUS_ONE) + && matches!(&args[1], DynSolValue::Bool(false)) + && matches!(&args[2], DynSolValue::FixedBytes(bytes, 3) if bytes[2] == 0x42) + }, + ) + .unwrap(); + + assert_eq!(decoded(function, &minimized.minimized_call), decoded(function, &start)); + assert!(!minimized.changed()); + assert_eq!(minimized.accepted, 0); + } +} diff --git a/crates/forge/src/symbolic_regression.rs b/crates/forge/src/symbolic_regression.rs new file mode 100644 index 0000000000000..8e583dc1735b0 --- /dev/null +++ b/crates/forge/src/symbolic_regression.rs @@ -0,0 +1,720 @@ +use crate::result::{ + SYMBOLIC_COUNTEREXAMPLE_ARTIFACT_SCHEMA, SYMBOLIC_COUNTEREXAMPLE_ARTIFACT_SCHEMA_VERSION, + SuiteResult, SymbolicArtifactRef, SymbolicCounterexampleArtifact, + SymbolicCounterexampleArtifactKind, SymbolicRegressionRef, SymbolicReplayStatus, +}; +use alloy_primitives::{U256, hex}; +use eyre::{Result, bail}; +use foundry_common::{TestFunctionExt, contracts::ContractsByArtifact, fs, sh_warn}; +use foundry_config::Config; +use foundry_evm::constants::CALLER; +use std::{ + collections::{HashMap, HashSet}, + fmt::Write, + path::{Component, Path, PathBuf}, +}; + +pub(crate) const SYMBOLIC_REGRESSION_MARKER: &str = "__foundry_symbolic_regression_marker"; + +/// Configuration for emitting Solidity regression tests from symbolic counterexamples. +#[derive(Clone, Debug)] +pub(crate) struct SymbolicRegressionConfig { + pub out: Option, + pub overwrite: bool, +} + +/// Solidity regression file emitted from a symbolic counterexample artifact. +#[derive(Clone, Debug)] +pub(crate) struct SymbolicRegression { + pub artifact: PathBuf, + pub path: PathBuf, +} + +struct PlannedSymbolicRegression { + regression: SymbolicRegression, + contents: String, + test: String, +} + +pub(crate) fn emit_symbolic_regressions( + config: &Config, + regression: &SymbolicRegressionConfig, + known_contracts: &ContractsByArtifact, + results: &[SymbolicArtifactRef], +) -> Result> { + let mut planned = Vec::new(); + let mut seen_tests = HashSet::new(); + for artifact_ref in results { + let artifact = load_artifact(&artifact_ref.path)?; + let (_, contract) = artifact_source_and_contract(&artifact_ref.path, &artifact)?; + if contract.ends_with("_SymbolicRegression") { + continue; + } + let key = ( + artifact.test.contract.clone(), + artifact.test.test.clone(), + match artifact.kind { + SymbolicCounterexampleArtifactKind::SingleCall => "single_call", + SymbolicCounterexampleArtifactKind::Sequence => "sequence", + }, + ); + let suffix = if seen_tests.insert(key) { + None + } else if let Some(suffix) = handler_regression_suffix(&artifact_ref.path) { + Some(suffix) + } else { + continue; + }; + planned.push(plan_symbolic_regression( + config, + regression, + &artifact_ref.path, + &artifact, + known_contracts, + suffix.as_deref(), + )?); + } + + ensure_unique_regression_paths(&planned)?; + + let mut emitted = Vec::new(); + for plan in planned { + if let Some(regression) = write_symbolic_regression(regression, plan)? { + emitted.push(regression); + } + } + Ok(emitted) +} + +fn artifact_source_and_contract<'a>( + artifact_path: &Path, + artifact: &'a SymbolicCounterexampleArtifact, +) -> Result<(&'a str, &'a str)> { + let Some((source, contract)) = artifact.test.contract.rsplit_once(':') else { + bail!( + "symbolic counterexample artifact {} test.contract must be `path:Contract`, got `{}`", + artifact_path.display(), + artifact.test.contract + ); + }; + if source.is_empty() || contract.is_empty() { + bail!( + "symbolic counterexample artifact {} test.contract must be `path:Contract`, got `{}`", + artifact_path.display(), + artifact.test.contract + ); + } + Ok((source, contract)) +} + +pub(crate) fn collect_symbolic_artifacts_from_suites<'a>( + suites: impl IntoIterator, +) -> Vec { + let mut artifacts = Vec::new(); + for suite in suites { + for result in suite.test_results.values() { + for artifact in &result.counterexample_artifacts { + if !artifacts.contains(artifact) { + artifacts.push(artifact.clone()); + } + } + } + } + artifacts +} + +pub(crate) fn attach_symbolic_regressions_to_suites<'a>( + suites: impl IntoIterator, + regressions: &[SymbolicRegression], +) { + for suite in suites { + for result in suite.test_results.values_mut() { + for artifact in &result.counterexample_artifacts { + for regression in regressions { + if artifact.path == regression.artifact + && !result + .symbolic_regressions + .iter() + .any(|existing| existing.path == regression.path) + { + result.symbolic_regressions.push(SymbolicRegressionRef { + artifact: regression.artifact.clone(), + path: regression.path.clone(), + }); + } + } + } + } + } +} + +fn load_artifact(path: &Path) -> Result { + let value = fs::read_json_file::(path)?; + let schema_version = + value.get("schema_version").and_then(serde_json::Value::as_u64).ok_or_else(|| { + eyre::eyre!( + "symbolic counterexample artifact {} is missing numeric schema_version", + path.display() + ) + })?; + if schema_version != u64::from(SYMBOLIC_COUNTEREXAMPLE_ARTIFACT_SCHEMA_VERSION) { + bail!( + "unsupported symbolic counterexample artifact schema version {} in {}", + schema_version, + path.display() + ); + } + let schema = value.get("schema").and_then(serde_json::Value::as_str).ok_or_else(|| { + eyre::eyre!("symbolic counterexample artifact {} is missing string schema", path.display()) + })?; + if schema != SYMBOLIC_COUNTEREXAMPLE_ARTIFACT_SCHEMA { + bail!( + "unsupported symbolic counterexample artifact schema `{}` in {}", + schema, + path.display() + ); + } + let artifact = serde_json::from_value::(value)?; + if artifact.replay.status != SymbolicReplayStatus::Confirmed { + bail!( + "symbolic counterexample artifact {} replay status must be confirmed, got {:?}", + path.display(), + artifact.replay.status + ); + } + if artifact.calls.is_empty() { + bail!("symbolic counterexample artifact {} has no calls", path.display()); + } + Ok(artifact) +} + +fn plan_symbolic_regression( + config: &Config, + regression: &SymbolicRegressionConfig, + artifact_path: &Path, + artifact: &SymbolicCounterexampleArtifact, + known_contracts: &ContractsByArtifact, + suffix: Option<&str>, +) -> Result { + let (source, contract) = artifact_source_and_contract(artifact_path, artifact)?; + + let test_name = + artifact.test.test.split_once('(').map_or(artifact.test.test.as_str(), |(name, _)| name); + let contract_ident = sanitize_identifier(contract); + let test_ident = sanitize_identifier(test_name); + let generated_contract = suffix.map_or_else( + || format!("{contract_ident}_{test_ident}_SymbolicRegression"), + |suffix| format!("{contract_ident}_{test_ident}_{suffix}_SymbolicRegression"), + ); + let vm_interface = format!("{generated_contract}_Vm"); + let generated_test = format!("test_regression_{test_ident}_symbolic"); + let default_path = config.test.join("regressions").join(format!("{generated_contract}.t.sol")); + let path = regression.out.as_ref().map_or(default_path, |out| { + let out_is_file = if out.exists() { + !out.is_dir() + } else { + out.extension().is_some_and(|ext| ext == std::ffi::OsStr::new("sol")) + }; + if out_is_file { out.clone() } else { out.join(format!("{generated_contract}.t.sol")) } + }); + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + let import_path = + relative_solidity_import(path.parent().unwrap_or(&config.root), &config.root.join(source)); + let mut contents = String::new(); + writeln!(contents, "// SPDX-License-Identifier: UNLICENSED")?; + writeln!(contents, "pragma solidity >=0.8.0;")?; + writeln!(contents)?; + writeln!(contents, "import \"{import_path}\";")?; + writeln!(contents)?; + writeln!(contents, "interface {vm_interface} {{")?; + writeln!(contents, " function deal(address who, uint256 newBalance) external;")?; + writeln!(contents, " function prank(address msgSender) external;")?; + writeln!(contents, " function roll(uint256 newHeight) external;")?; + writeln!(contents, " function warp(uint256 newTimestamp) external;")?; + if !artifact.storage.is_empty() { + writeln!( + contents, + " function store(address target, bytes32 slot, bytes32 value) external;" + )?; + } + writeln!(contents, "}}")?; + writeln!(contents)?; + writeln!(contents, "contract {generated_contract} is {contract} {{")?; + writeln!( + contents, + " {vm_interface} private constant __foundrySymbolicVm = {vm_interface}(address(uint160(uint256(keccak256(\"hevm cheat code\")))));" + )?; + writeln!(contents)?; + writeln!( + contents, + " function {SYMBOLIC_REGRESSION_MARKER}() external pure returns (bytes32) {{" + )?; + writeln!(contents, " return keccak256(\"foundry.symbolic.regression\");")?; + writeln!(contents, " }}")?; + writeln!(contents)?; + writeln!(contents, " function {generated_test}() public payable {{")?; + for assignment in &artifact.storage { + writeln!( + contents, + " __foundrySymbolicVm.store({}, bytes32(uint256({})), bytes32(uint256({})));", + assignment.address, + u256_literal(assignment.slot), + u256_literal(assignment.value) + )?; + } + match artifact.kind { + SymbolicCounterexampleArtifactKind::SingleCall => { + let call = artifact.calls.first().expect("single-call artifact has at least one call"); + write_call(&mut contents, call, "address(this)", true, None)?; + } + SymbolicCounterexampleArtifactKind::Sequence => { + let call_after_invariant = call_after_invariant(known_contracts, artifact)?; + for (index, call) in artifact.calls.iter().enumerate() { + write_call( + &mut contents, + call, + &format!("address({})", call.target), + artifact.replay_semantics.fail_on_revert, + Some(index), + )?; + } + writeln!(contents, " __foundrySymbolicVm.prank({CALLER});")?; + writeln!( + contents, + " __foundrySymbolicRegressionCall(address(this), hex\"{}\", 0, true);", + hex::encode(selector_from_signature(&artifact.test.test)) + )?; + if call_after_invariant { + writeln!(contents, " __foundrySymbolicVm.prank({CALLER});")?; + writeln!( + contents, + " __foundrySymbolicRegressionCall(address(this), hex\"{}\", 0, true);", + hex::encode(selector_from_signature("afterInvariant()")) + )?; + } + } + } + writeln!(contents, " }}")?; + writeln!(contents)?; + writeln!( + contents, + " function __foundrySymbolicRegressionCall(address target, bytes memory data, uint256 value, bool bubbleFailure) internal returns (bool) {{" + )?; + writeln!(contents, " if (target != address(this)) {{")?; + writeln!(contents, " uint256 codeSize;")?; + writeln!(contents, " assembly {{ codeSize := extcodesize(target) }}")?; + writeln!( + contents, + " require(codeSize != 0, \"symbolic regression target has no code\");" + )?; + writeln!(contents, " }}")?; + writeln!(contents, " (bool ok, bytes memory ret) = target.call{{value: value}}(data);")?; + writeln!(contents, " if (!ok && bubbleFailure) {{")?; + writeln!(contents, " assembly {{")?; + writeln!(contents, " revert(add(ret, 0x20), mload(ret))")?; + writeln!(contents, " }}")?; + writeln!(contents, " }}")?; + writeln!(contents, " return ok;")?; + writeln!(contents, " }}")?; + writeln!(contents, "}}")?; + + Ok(PlannedSymbolicRegression { + regression: SymbolicRegression { artifact: artifact_path.to_path_buf(), path }, + contents, + test: format!("{}::{}", artifact.test.contract, artifact.test.test), + }) +} + +fn ensure_unique_regression_paths(planned: &[PlannedSymbolicRegression]) -> Result<()> { + let mut seen = HashMap::<&Path, &PlannedSymbolicRegression>::new(); + for plan in planned { + if let Some(previous) = seen.insert(&plan.regression.path, plan) { + bail!( + "multiple symbolic regressions resolve to {}; {} and {} cannot share one output file", + plan.regression.path.display(), + previous.test, + plan.test + ); + } + } + Ok(()) +} + +fn write_symbolic_regression( + regression: &SymbolicRegressionConfig, + plan: PlannedSymbolicRegression, +) -> Result> { + let SymbolicRegression { artifact, path } = &plan.regression; + if path.exists() && !regression.overwrite { + if std::fs::read_to_string(path).is_ok_and(|existing| existing == plan.contents) { + return Ok(Some(plan.regression)); + } + sh_warn!( + "Regression test {} already exists; skipping {} (pass --regression-overwrite to replace it)", + path.display(), + artifact.display() + )?; + return Ok(None); + } + + std::fs::write(path, plan.contents)?; + Ok(Some(plan.regression)) +} + +fn write_call( + out: &mut String, + call: &crate::result::SymbolicCounterexampleCall, + target: &str, + bubble_failure: bool, + sequence_call_index: Option, +) -> Result<()> { + if let Some(warp) = call.warp { + writeln!( + out, + " __foundrySymbolicVm.warp(block.timestamp + {});", + u256_literal(warp) + )?; + } + if let Some(roll) = call.roll { + writeln!(out, " __foundrySymbolicVm.roll(block.number + {});", u256_literal(roll))?; + } + if let Some(value) = call.value + && !value.is_zero() + { + if let Some(index) = sequence_call_index { + writeln!( + out, + " uint256 __foundrySymbolicValue{index} = {} <= address({}).balance ? {} : address({}).balance;", + u256_literal(value), + call.sender, + u256_literal(value), + call.sender + )?; + writeln!( + out, + " if (__foundrySymbolicValue{index} != 0 && {} != address(this)) {{", + call.sender + )?; + writeln!( + out, + " __foundrySymbolicVm.deal(address(this), address(this).balance + __foundrySymbolicValue{index});" + )?; + writeln!( + out, + " __foundrySymbolicVm.deal({}, address({}).balance - __foundrySymbolicValue{index});", + call.sender, call.sender + )?; + writeln!(out, " }}")?; + } else { + writeln!( + out, + " __foundrySymbolicVm.deal(address(this), {});", + u256_literal(value) + )?; + writeln!( + out, + " __foundrySymbolicVm.deal({}, {});", + call.sender, + u256_literal(value) + )?; + } + } + writeln!(out, " __foundrySymbolicVm.prank({});", call.sender)?; + let value = sequence_call_index + .filter(|_| call.value.is_some_and(|value| !value.is_zero())) + .map_or_else( + || call.value.map_or_else(|| "0".to_string(), u256_literal), + |index| format!("__foundrySymbolicValue{index}"), + ); + if let Some(index) = sequence_call_index { + writeln!( + out, + " bool __foundrySymbolicOk{index} = __foundrySymbolicRegressionCall({target}, hex\"{}\", {}, {});", + hex::encode(call.calldata.as_ref()), + value, + if bubble_failure { "true" } else { "false" } + )?; + if call.value.is_some_and(|value| !value.is_zero()) { + writeln!( + out, + " if (__foundrySymbolicValue{index} != 0 && {} != address(this)) {{", + call.sender + )?; + writeln!(out, " if (!__foundrySymbolicOk{index}) {{")?; + writeln!( + out, + " __foundrySymbolicVm.deal({}, address({}).balance + __foundrySymbolicValue{index});", + call.sender, call.sender + )?; + writeln!( + out, + " __foundrySymbolicVm.deal(address(this), address(this).balance - __foundrySymbolicValue{index});" + )?; + writeln!(out, " }}")?; + writeln!(out, " }}")?; + } + } else { + writeln!( + out, + " __foundrySymbolicRegressionCall({target}, hex\"{}\", {}, {});", + hex::encode(call.calldata.as_ref()), + value, + if bubble_failure { "true" } else { "false" } + )?; + } + Ok(()) +} + +fn selector_from_signature(signature: &str) -> [u8; 4] { + let hash = alloy_primitives::keccak256(signature.as_bytes()); + [hash[0], hash[1], hash[2], hash[3]] +} + +fn u256_literal(value: U256) -> String { + if value <= U256::from(u128::MAX) { value.to_string() } else { format!("0x{value:x}") } +} + +fn sanitize_identifier(value: &str) -> String { + let mut ident = String::with_capacity(value.len()); + for ch in value.chars() { + if ch.is_ascii_alphanumeric() || ch == '_' { + ident.push(ch); + } else { + ident.push('_'); + } + } + if ident.is_empty() || ident.as_bytes()[0].is_ascii_digit() { + ident.insert(0, '_'); + } + ident +} + +fn handler_regression_suffix(path: &Path) -> Option { + let stem = path.file_stem()?.to_string_lossy(); + stem.starts_with("handler-").then(|| sanitize_identifier(&stem)) +} + +fn call_after_invariant( + known_contracts: &ContractsByArtifact, + artifact: &SymbolicCounterexampleArtifact, +) -> Result { + let Some((_, contract)) = + known_contracts.find_by_name_or_identifier(&artifact.test.contract)? + else { + return Ok(false); + }; + let mut after_invariant_fns = + contract.abi.functions().filter(|func| func.name.is_after_invariant()); + let Some(after_invariant) = after_invariant_fns.next() else { + return Ok(false); + }; + Ok(after_invariant.name == "afterInvariant" && after_invariant_fns.next().is_none()) +} + +fn relative_solidity_import(from_dir: &Path, to_file: &Path) -> String { + let from = std::fs::canonicalize(from_dir).unwrap_or_else(|_| from_dir.to_path_buf()); + let to = std::fs::canonicalize(to_file).unwrap_or_else(|_| to_file.to_path_buf()); + let from = normalized_components(&from); + let to = normalized_components(&to); + let shared = from.iter().zip(&to).take_while(|(left, right)| left == right).count(); + let mut parts = Vec::new(); + parts.extend(std::iter::repeat_n("..".to_string(), from.len().saturating_sub(shared))); + parts.extend(to[shared..].iter().cloned()); + if parts.is_empty() { + ".".to_string() + } else { + let path = parts.join("/"); + if path.starts_with('.') { path } else { format!("./{path}") } + } +} + +fn normalized_components(path: &Path) -> Vec { + path.components() + .filter_map(|component| match component { + Component::Normal(value) => Some(value.to_string_lossy().to_string()), + Component::ParentDir => Some("..".to_string()), + Component::CurDir => None, + Component::RootDir | Component::Prefix(_) => { + Some(component.as_os_str().to_string_lossy().to_string()) + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::result::{ + SymbolicCallTrace, SymbolicCounterexample, SymbolicCounterexampleReplaySemantics, + SymbolicCounterexampleTestIdentity, SymbolicReplayMetadata, SymbolicResult, + }; + use alloy_primitives::{Address, Bytes}; + use foundry_config::SymbolicConfig; + use foundry_evm_symbolic::SymbolicStats; + + fn config(root: &Path) -> Config { + Config { root: root.to_path_buf(), test: root.join("test"), ..Default::default() } + } + + fn artifact( + kind: SymbolicCounterexampleArtifactKind, + calls: Vec, + ) -> SymbolicCounterexampleArtifact { + let call = calls.first().expect("artifact has at least one call"); + let symbolic = SymbolicResult::fail_counterexample( + &SymbolicConfig::default(), + SymbolicStats::default(), + SymbolicCallTrace::none(), + SymbolicCounterexample { + calldata: call.calldata.clone(), + args: None, + raw_args: None, + value: call.value, + }, + ); + let mut artifact = SymbolicCounterexampleArtifact::new( + kind, + SymbolicCounterexampleTestIdentity { + contract: "test/Regression.t.sol:Regression".to_string(), + test: "invariant_value()".to_string(), + }, + &symbolic, + SymbolicCounterexampleReplaySemantics { fail_on_revert: false }, + calls, + ); + artifact.replay = SymbolicReplayMetadata::confirmed(); + artifact + } + + fn value_call(target: Address) -> crate::result::SymbolicCounterexampleCall { + crate::result::SymbolicCounterexampleCall { + warp: None, + roll: None, + sender: Address::repeat_byte(0xb0), + target, + calldata: Bytes::from_static(&[0x12, 0x34, 0x56, 0x78]), + value: Some(U256::from(10)), + contract_name: None, + function_name: None, + signature: None, + args: None, + raw_args: None, + } + } + + fn plan( + kind: SymbolicCounterexampleArtifactKind, + calls: Vec, + ) -> String { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("test/Regression.t.sol"); + fs::create_dir_all(source.parent().unwrap()).unwrap(); + std::fs::write(&source, "contract Regression {}").unwrap(); + + let artifact_path = temp.path().join("cache/artifact.json"); + let artifact = artifact(kind, calls); + plan_symbolic_regression( + &config(temp.path()), + &SymbolicRegressionConfig { out: None, overwrite: false }, + &artifact_path, + &artifact, + &ContractsByArtifact::default(), + None, + ) + .unwrap() + .contents + } + + #[test] + fn sequence_regression_clamps_and_debits_sender_balance() { + let contents = plan( + SymbolicCounterexampleArtifactKind::Sequence, + vec![value_call(Address::repeat_byte(0x7f)), value_call(Address::repeat_byte(0x8f))], + ); + let lowercase = contents.to_lowercase(); + + assert!(lowercase.contains( + "uint256 __foundrysymbolicvalue0 = 10 <= address(0xb0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0).balance ? 10 : address(0xb0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0).balance;" + )); + assert!(lowercase.contains( + "uint256 __foundrysymbolicvalue1 = 10 <= address(0xb0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0).balance ? 10 : address(0xb0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0).balance;" + )); + assert!(contents.contains("if (__foundrySymbolicValue0 != 0")); + assert!(contents.contains("if (__foundrySymbolicValue1 != 0")); + assert!(contents.contains( + "__foundrySymbolicVm.deal(address(this), address(this).balance + __foundrySymbolicValue0);" + )); + assert!(contents.contains("bool __foundrySymbolicOk0 = __foundrySymbolicRegressionCall")); + assert!(contents.contains("if (!__foundrySymbolicOk0) {")); + assert!(lowercase.contains( + "__foundrysymbolicvm.deal(0xb0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0, address(0xb0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0).balance - __foundrysymbolicvalue0);" + )); + assert!(lowercase.contains( + "__foundrysymbolicvm.deal(0xb0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0, address(0xb0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0).balance - __foundrysymbolicvalue1);" + )); + assert!(lowercase.contains( + "__foundrysymbolicvm.deal(0xb0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0, address(0xb0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0).balance + __foundrysymbolicvalue0);" + )); + assert!(contents.contains( + "__foundrySymbolicVm.deal(address(this), address(this).balance - __foundrySymbolicValue0);" + )); + assert!(lowercase.contains( + "__foundrysymbolicregressioncall(address(0x7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f), hex\"12345678\", __foundrysymbolicvalue0, false);" + )); + assert!(lowercase.contains( + "__foundrysymbolicregressioncall(address(0x8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f), hex\"12345678\", __foundrysymbolicvalue1, false);" + )); + assert!( + !lowercase.contains( + "__foundrysymbolicvm.deal(0xb0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0, 10);" + ) + ); + } + + #[test] + fn single_call_regression_preserves_exact_value_funding() { + let contents = plan( + SymbolicCounterexampleArtifactKind::SingleCall, + vec![value_call(Address::repeat_byte(0x7f))], + ); + let lowercase = contents.to_lowercase(); + + assert!(contents.contains("__foundrySymbolicVm.deal(address(this), 10);")); + assert!( + lowercase.contains( + "__foundrysymbolicvm.deal(0xb0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0, 10);" + ) + ); + assert!(contents.contains( + "__foundrySymbolicRegressionCall(address(this), hex\"12345678\", 10, true);" + )); + assert!(!contents.contains("bool __foundrySymbolicOk0 =")); + } + + #[test] + fn relative_solidity_import_canonicalizes_symlinked_absolute_paths() { + let tmp = Path::new("/tmp"); + let Ok(canonical_tmp) = std::fs::canonicalize(tmp) else { return }; + if canonical_tmp == tmp { + return; + } + + let root = + tmp.join(format!("foundry-symbolic-regression-import-test-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let from_dir = root.join("test"); + std::fs::create_dir_all(&from_dir).unwrap(); + let to_file = from_dir.join("Original.t.sol"); + std::fs::write(&to_file, "contract Original {}").unwrap(); + let canonical_to_file = std::fs::canonicalize(&to_file).unwrap(); + + let import = relative_solidity_import(&from_dir, &canonical_to_file); + + let _ = std::fs::remove_dir_all(&root); + assert_eq!(import, "./Original.t.sol"); + } +} diff --git a/crates/forge/src/workspace.rs b/crates/forge/src/workspace.rs new file mode 100644 index 0000000000000..ee2cc5d7d9e5b --- /dev/null +++ b/crates/forge/src/workspace.rs @@ -0,0 +1,1692 @@ +//! Shared utilities for creating isolated project workspaces. +//! +//! Used by both mutation testing and brutalization to copy a project +//! to a temporary directory for safe source-level modifications. + +use std::{ + fs, + path::{Component, MAIN_SEPARATOR, Path, PathBuf}, +}; + +use alloy_primitives::keccak256; +use eyre::Result; +use foundry_common::fs::{canonicalize_path, normalize_path}; +use foundry_compilers::artifacts::remappings::{RelativeRemapping, Remapping}; +use foundry_config::{ + Config, fs_permissions::FsAccessKind, providers::relative_remapping_preserving_context_boundary, +}; + +/// Check if a path is safe for use as a relative path within a workspace. +/// Rejects absolute paths, parent directory components (..), and other unsafe patterns. +pub fn is_safe_relative_path(p: &Path) -> bool { + !p.is_absolute() + && p.components().all(|c| matches!(c, Component::Normal(_) | Component::CurDir)) +} + +/// Validates that `rel` is a safe relative path. Returns an error mentioning `label` and `orig` +/// if the path contains `..`, is absolute, or otherwise escapes the project root. +pub fn ensure_safe_relative_path(rel: &Path, label: &str, orig: &Path) -> Result<()> { + if !is_safe_relative_path(rel) { + eyre::bail!("requires {label} directory under project root, got: {}", orig.display()); + } + Ok(()) +} + +/// Compute relative path of `path` under `root`, or return the path unchanged if not under root. +pub fn relative_to_root(root: &Path, path: &Path) -> PathBuf { + let root = normalize_existing_ancestor(root); + let path = normalize_existing_ancestor(path); + path.strip_prefix(root).map(|p| p.to_path_buf()).unwrap_or(path) +} + +fn resolve_against_root(root: &Path, path: &Path) -> PathBuf { + let path = if path.is_absolute() { path.to_path_buf() } else { root.join(path) }; + normalize_existing_ancestor(&path) +} + +/// Build a config for a copied temp workspace from an already materialized config. +/// +/// This preserves CLI/env overrides and runtime normalization while rebasing +/// project-local paths from the original root to `temp_path`. +pub fn rebase_config_paths(config: &Config, temp_path: &Path) -> Config { + // Use the same root spelling as the compiler before materializing remappings. Temp paths + // can contain symlinks on macOS or short directory names on Windows. + let temp_root = normalize_existing_ancestor(temp_path); + let temp_path = temp_root.as_path(); + let mut temp_config = config.clone(); + temp_config.root = temp_path.to_path_buf(); + temp_config.src = rebase_project_path(&config.root, temp_path, &config.src); + temp_config.test = rebase_project_path(&config.root, temp_path, &config.test); + temp_config.script = rebase_project_path(&config.root, temp_path, &config.script); + temp_config.out = rebase_project_path(&config.root, temp_path, &config.out); + temp_config.cache_path = rebase_project_path(&config.root, temp_path, &config.cache_path); + temp_config.snapshots = rebase_project_path(&config.root, temp_path, &config.snapshots); + temp_config.broadcast = rebase_project_path(&config.root, temp_path, &config.broadcast); + temp_config.mutation_dir = rebase_project_path(&config.root, temp_path, &config.mutation_dir); + temp_config.test_failures_file = + rebase_project_path(&config.root, temp_path, &config.test_failures_file); + temp_config.build_info_path = config + .build_info_path + .as_ref() + .map(|path| rebase_project_path(&config.root, temp_path, path)); + temp_config.libs = + config.libs.iter().map(|lib| rebase_project_path(&config.root, temp_path, lib)).collect(); + temp_config.remappings = config + .remappings + .iter() + .map(|remapping| rebase_remapping(&config.root, temp_path, remapping)) + .collect(); + temp_config.include_paths = config + .include_paths + .iter() + .map(|path| rebase_project_path(&config.root, temp_path, path)) + .collect(); + temp_config.allow_paths = config + .allow_paths + .iter() + .map(|path| rebase_project_path(&config.root, temp_path, path)) + .collect(); + temp_config.ignored_error_codes_from = config + .ignored_error_codes_from + .iter() + .map(|(path, codes)| (rebase_project_path(&config.root, temp_path, path), codes.clone())) + .collect(); + temp_config.ignored_file_paths = config + .ignored_file_paths + .iter() + .map(|path| rebase_project_path(&config.root, temp_path, path)) + .collect(); + + if let Some(path) = &config.fuzz.failure_persist_dir { + temp_config.fuzz.failure_persist_dir = + Some(rebase_mutable_project_path(config, temp_path, path)); + } + if let Some(path) = &config.fuzz.corpus.corpus_dir { + temp_config.fuzz.corpus.corpus_dir = + Some(rebase_mutable_project_path(config, temp_path, path)); + } + if let Some(path) = &config.fuzz.corpus.frontier_dir { + temp_config.fuzz.corpus.frontier_dir = + Some(rebase_mutable_project_path(config, temp_path, path)); + } + if let Some(path) = &config.invariant.failure_persist_dir { + temp_config.invariant.failure_persist_dir = + Some(rebase_mutable_project_path(config, temp_path, path)); + } + if let Some(path) = &config.invariant.corpus.corpus_dir { + temp_config.invariant.corpus.corpus_dir = + Some(rebase_mutable_project_path(config, temp_path, path)); + } + if let Some(path) = &config.invariant.corpus.frontier_dir { + temp_config.invariant.corpus.frontier_dir = + Some(rebase_mutable_project_path(config, temp_path, path)); + } + for permission in &mut temp_config.fs_permissions.permissions { + let path = rebase_project_path(&config.root, temp_path, &permission.path); + permission.path = normalize_existing_ancestor(&path); + } + if let Some(model_checker) = &mut temp_config.model_checker { + model_checker.contracts = std::mem::take(&mut model_checker.contracts) + .into_iter() + .map(|(path, contracts)| { + let path = rebase_project_path(&config.root, temp_path, Path::new(&path)); + (path.display().to_string(), contracts) + }) + .collect(); + } + + temp_config +} + +fn rebase_project_path(root: &Path, temp_path: &Path, path: &Path) -> PathBuf { + let resolved = resolve_against_root(root, path); + let rel = relative_to_root(root, &resolved); + if rel.is_absolute() { resolved } else { temp_path.join(rel) } +} + +fn rebase_mutable_project_path(config: &Config, temp_path: &Path, path: &Path) -> PathBuf { + let resolved = resolve_against_root(&config.root, path); + let rel = relative_to_root(&config.root, &resolved); + if rel.is_absolute() || is_covered_by_symlinked_project_root(config, &rel) { + return temp_path.join(isolated_mutable_path_rel(&resolved)); + } + temp_path.join(rel) +} + +fn isolated_mutable_path_rel(resolved: &Path) -> PathBuf { + PathBuf::from(".foundry_mutable") + .join(format!("{:x}", keccak256(resolved.as_os_str().as_encoded_bytes()))) +} + +fn is_covered_by_symlinked_project_root(config: &Config, rel: &Path) -> bool { + config.libs.iter().any(|path| { + let resolved = resolve_against_root(&config.root, path); + let lib_rel = relative_to_root(&config.root, &resolved); + !lib_rel.is_absolute() && !lib_rel.as_os_str().is_empty() && rel.starts_with(lib_rel) + }) || ["node_modules", "dependencies"].iter().any(|dep_dir| rel.starts_with(dep_dir)) +} + +fn normalize_existing_ancestor(path: &Path) -> PathBuf { + if path.exists() { + return canonicalize_path(path).unwrap_or_else(|_| path.to_path_buf()); + } + + let mut ancestor = path; + let mut missing = Vec::new(); + while let Some(parent) = ancestor.parent() { + if ancestor.exists() { + break; + } + missing.push(ancestor.strip_prefix(parent).unwrap().to_path_buf()); + ancestor = parent; + } + + let mut normalized = canonicalize_path(ancestor).unwrap_or_else(|_| ancestor.to_path_buf()); + for component in missing.iter().rev() { + normalized.push(component); + } + normalize_path(&normalized) +} + +fn rebase_remapping( + root: &Path, + temp_path: &Path, + remapping: &RelativeRemapping, +) -> RelativeRemapping { + let context_has_boundary = + remapping.context.as_deref().is_some_and(|context| context.ends_with(['/', '\\'])); + let mut remapping: Remapping = remapping.clone().into(); + remapping.path = + rebase_project_path(root, temp_path, Path::new(&remapping.path)).display().to_string(); + if let Some(context) = &mut remapping.context { + *context = rebase_project_path(root, temp_path, Path::new(context)).display().to_string(); + if context_has_boundary && !context.ends_with(['/', '\\']) { + context.push(MAIN_SEPARATOR); + } + } + relative_remapping_preserving_context_boundary(remapping, temp_path) +} + +/// Verify that `candidate` resolves (after following symlinks) to a path that lives +/// inside `allowed_root`. Protects against `src`/`test`/`lib`/etc. being symlinks +/// that escape the project root. +/// +/// `label` and `orig` are only used for error messages. +fn ensure_within_root( + allowed_root: &Path, + candidate: &Path, + label: &str, + orig: &Path, +) -> Result<()> { + // If the path doesn't exist yet, lexical containment is the best we can do. + if !candidate.exists() { + return Ok(()); + } + let canon_root = allowed_root.canonicalize().map_err(|e| { + eyre::eyre!("failed to canonicalize project root {}: {e}", allowed_root.display()) + })?; + let canon_candidate = candidate.canonicalize().map_err(|e| { + eyre::eyre!("failed to canonicalize {label} path {}: {e}", candidate.display()) + })?; + if !canon_candidate.starts_with(&canon_root) { + eyre::bail!( + "{label} path {} escapes project root {} (resolved to {})", + orig.display(), + allowed_root.display(), + canon_candidate.display() + ); + } + Ok(()) +} + +/// Copy essential project files to a temp workspace. +/// +/// Copies src and test directories, symlinks library directories (read-only), +/// and copies config files (foundry.toml, remappings.txt). +pub fn copy_project(config: &Config, temp_dir: &Path) -> Result<()> { + let src_rel = relative_to_root(&config.root, &config.src); + ensure_safe_relative_path(&src_rel, "src", &config.src)?; + ensure_within_root(&config.root, &config.src, "src", &config.src)?; + + let test_rel = relative_to_root(&config.root, &config.test); + ensure_safe_relative_path(&test_rel, "test", &config.test)?; + ensure_within_root(&config.root, &config.test, "test", &config.test)?; + + copy_project_dir_recursive(&config.root, &config.src, &temp_dir.join(&src_rel))?; + + if config.test != config.src { + copy_project_dir_recursive(&config.root, &config.test, &temp_dir.join(&test_rel))?; + } + + let handled_extra_roots = handled_project_roots(config)?; + for extra_path in config.include_paths.iter().chain(config.allow_paths.iter()) { + copy_extra_project_path( + &config.root, + temp_dir, + extra_path, + &handled_extra_roots, + "include/allow", + )?; + } + for remapping in &config.remappings { + let remapping: Remapping = remapping.clone().into(); + copy_extra_project_path( + &config.root, + temp_dir, + Path::new(&remapping.path), + &handled_extra_roots, + "remapping", + )?; + if let Some(context) = remapping.context { + copy_extra_project_path( + &config.root, + temp_dir, + Path::new(&context), + &handled_extra_roots, + "remapping context", + )?; + } + } + for permission in &config.fs_permissions.permissions { + if permission.is_granted(FsAccessKind::Read) { + copy_project_local_permission_path( + &config.root, + temp_dir, + &permission.path, + &handled_extra_roots, + )?; + } + if permission.is_granted(FsAccessKind::Write) { + create_project_local_permission_dir( + &config.root, + temp_dir, + &permission.path, + &handled_extra_roots, + )?; + } + } + + copy_project_local_optional_path( + &config.root, + temp_dir, + &config.fuzz.failure_persist_dir, + &handled_extra_roots, + )?; + copy_project_local_optional_path( + &config.root, + temp_dir, + &config.fuzz.corpus.corpus_dir, + &handled_extra_roots, + )?; + copy_project_local_optional_path( + &config.root, + temp_dir, + &config.fuzz.corpus.frontier_dir, + &handled_extra_roots, + )?; + copy_project_local_optional_path( + &config.root, + temp_dir, + &config.invariant.failure_persist_dir, + &handled_extra_roots, + )?; + copy_project_local_optional_path( + &config.root, + temp_dir, + &config.invariant.corpus.corpus_dir, + &handled_extra_roots, + )?; + copy_project_local_optional_path( + &config.root, + temp_dir, + &config.invariant.corpus.frontier_dir, + &handled_extra_roots, + )?; + + // Copy `script/` too when present and distinct from src/test. Many real + // projects keep helper contracts, deployment scripts, or fixtures under + // `script/` and reference them from tests via relative imports. Without + // this, baselines that compile fine produce a sea of `Invalid` mutants + // for purely-environmental reasons. + if config.script.exists() && config.script != config.src && config.script != config.test { + let script_rel = relative_to_root(&config.root, &config.script); + ensure_safe_relative_path(&script_rel, "script", &config.script)?; + ensure_within_root(&config.root, &config.script, "script", &config.script)?; + copy_project_dir_recursive(&config.root, &config.script, &temp_dir.join(&script_rel))?; + } + + for lib_path in &config.libs { + let resolved = resolve_against_root(&config.root, lib_path); + if resolved.exists() { + let lib_rel = relative_to_root(&config.root, &resolved); + if lib_rel.is_absolute() { + continue; + } + ensure_safe_relative_path(&lib_rel, "lib", lib_path)?; + ensure_within_root(&config.root, &resolved, "lib", lib_path)?; + let target = temp_dir.join(&lib_rel); + + if !target.exists() { + if let Some(parent) = target.parent() { + fs::create_dir_all(parent)?; + } + if symlink_dir(&resolved, &target).is_err() { + copy_dir_recursive(&resolved, &target)?; + } + } + + symlink_nested_libs(&resolved, &target, 0)?; + } + } + + for dep_dir in ["node_modules", "dependencies"] { + let dep_path = config.root.join(dep_dir); + if dep_path.exists() && dep_path.is_dir() { + // Reject if the project-root entry is a symlink that escapes the root. + ensure_within_root(&config.root, &dep_path, dep_dir, &dep_path)?; + let target = temp_dir.join(dep_dir); + if !target.exists() && symlink_dir(&dep_path, &target).is_err() { + copy_dir_recursive(&dep_path, &target)?; + } + } + } + + let foundry_toml = config.root.join("foundry.toml"); + if foundry_toml.exists() { + fs::copy(&foundry_toml, temp_dir.join("foundry.toml"))?; + } + + let remappings = config.root.join("remappings.txt"); + if remappings.exists() { + fs::copy(&remappings, temp_dir.join("remappings.txt"))?; + } + + Ok(()) +} + +pub(crate) fn handled_project_roots(config: &Config) -> Result> { + let mut roots = Vec::new(); + push_handled_project_root(&mut roots, &config.root, &config.src, "src")?; + push_handled_project_root(&mut roots, &config.root, &config.test, "test")?; + + if config.script.exists() && config.script != config.src && config.script != config.test { + push_handled_project_root(&mut roots, &config.root, &config.script, "script")?; + } + + for lib_path in &config.libs { + let resolved = resolve_against_root(&config.root, lib_path); + if resolved.exists() { + let lib_rel = relative_to_root(&config.root, &resolved); + if lib_rel.is_absolute() { + continue; + } + push_handled_project_root(&mut roots, &config.root, &resolved, "lib")?; + } + } + + for dep_dir in ["node_modules", "dependencies"] { + let dep_path = config.root.join(dep_dir); + if dep_path.exists() && dep_path.is_dir() { + roots.push(PathBuf::from(dep_dir)); + } + } + + Ok(roots) +} + +fn push_handled_project_root( + roots: &mut Vec, + root: &Path, + path: &Path, + label: &str, +) -> Result<()> { + let rel = relative_to_root(root, path); + ensure_safe_relative_path(&rel, label, path)?; + ensure_within_root(root, path, label, path)?; + roots.push(rel); + Ok(()) +} + +fn is_covered_by_handled_root(rel: &Path, handled_roots: &[PathBuf]) -> bool { + handled_roots.iter().any(|root| !root.as_os_str().is_empty() && rel.starts_with(root)) +} + +fn copy_extra_project_path( + root: &Path, + temp_dir: &Path, + path: &Path, + handled_roots: &[PathBuf], + label: &str, +) -> Result<()> { + let resolved = resolve_against_root(root, path); + let rel = relative_to_root(root, &resolved); + if rel.is_absolute() { + return Ok(()); + } + ensure_safe_relative_path(&rel, label, path)?; + ensure_within_root(root, &resolved, label, path)?; + + if is_covered_by_handled_root(&rel, handled_roots) { + return Ok(()); + } + + if !resolved.exists() { + return Ok(()); + } + + let target = temp_dir.join(rel); + if resolved.is_dir() { + copy_project_dir_recursive(root, &resolved, &target) + } else { + if let Some(parent) = target.parent() { + fs::create_dir_all(parent)?; + } + fs::copy(&resolved, target)?; + Ok(()) + } +} + +fn copy_project_local_permission_path( + root: &Path, + temp_dir: &Path, + path: &Path, + handled_roots: &[PathBuf], +) -> Result<()> { + let resolved = resolve_against_root(root, path); + let rel = relative_to_root(root, &resolved); + if rel.is_absolute() || rel.as_os_str().is_empty() { + return Ok(()); + } + copy_extra_project_path(root, temp_dir, path, handled_roots, "fs permission") +} + +fn create_project_local_permission_dir( + root: &Path, + temp_dir: &Path, + path: &Path, + handled_roots: &[PathBuf], +) -> Result<()> { + let resolved = resolve_against_root(root, path); + let rel = relative_to_root(root, &resolved); + if rel.is_absolute() || rel.as_os_str().is_empty() { + return Ok(()); + } + ensure_safe_relative_path(&rel, "fs permission", path)?; + ensure_within_root(root, &resolved, "fs permission", path)?; + + if resolved.exists() && resolved.is_dir() { + if !is_covered_by_handled_root(&rel, handled_roots) { + fs::create_dir_all(temp_dir.join(rel))?; + } + return Ok(()); + } + + let Some(parent) = rel.parent() else { return Ok(()) }; + let resolved_parent = root.join(parent); + if !resolved_parent.exists() || !resolved_parent.is_dir() { + return Ok(()); + } + ensure_within_root(root, &resolved_parent, "fs permission", path)?; + + if parent.as_os_str().is_empty() || is_covered_by_handled_root(parent, handled_roots) { + return Ok(()); + } + + fs::create_dir_all(temp_dir.join(parent))?; + if resolved.exists() && resolved.is_file() { + fs::copy(&resolved, temp_dir.join(rel))?; + } + + Ok(()) +} + +fn copy_project_local_optional_path( + root: &Path, + temp_dir: &Path, + path: &Option, + handled_roots: &[PathBuf], +) -> Result<()> { + let Some(path) = path else { return Ok(()) }; + let resolved = resolve_against_root(root, path); + let rel = relative_to_root(root, &resolved); + if rel.as_os_str().is_empty() || !resolved.exists() { + return Ok(()); + } + let is_external = rel.is_absolute(); + let target = if is_external || is_covered_by_handled_root(&rel, handled_roots) { + temp_dir.join(isolated_mutable_path_rel(&resolved)) + } else { + ensure_safe_relative_path(&rel, "corpus/frontier", path)?; + ensure_within_root(root, &resolved, "corpus/frontier", path)?; + temp_dir.join(rel) + }; + if resolved.is_dir() { + if is_external { + copy_dir_recursive(&resolved, &target) + } else { + copy_project_dir_recursive(root, &resolved, &target) + } + } else { + if let Some(parent) = target.parent() { + fs::create_dir_all(parent)?; + } + fs::copy(&resolved, target)?; + Ok(()) + } +} + +/// Create a symlink to a directory (cross-platform). +pub fn symlink_dir(src: &Path, dst: &Path) -> Result<()> { + #[cfg(unix)] + { + std::os::unix::fs::symlink(src, dst)?; + } + #[cfg(windows)] + { + std::os::windows::fs::symlink_dir(src, dst)?; + } + Ok(()) +} + +/// Maximum recursion depth for nested lib symlinks to prevent infinite loops. +const MAX_SYMLINK_DEPTH: usize = 10; + +/// Recursively symlink nested lib directories within a library. +fn symlink_nested_libs(lib_src: &Path, lib_dst: &Path, depth: usize) -> Result<()> { + if depth >= MAX_SYMLINK_DEPTH { + return Ok(()); + } + + let nested_lib_dirs: Vec = + if let Ok(config) = Config::load_with_root_and_fallback(lib_src) { + config.libs + } else { + vec![PathBuf::from("lib")] + }; + + for nested_lib_dir in nested_lib_dirs { + // A dependency's foundry.toml is untrusted input. Reject any nested lib + // path that is absolute or contains `..`, then verify the resolved path + // doesn't escape the dependency root via symlink. + if !is_safe_relative_path(&nested_lib_dir) { + continue; + } + let nested_lib = lib_src.join(&nested_lib_dir); + if !nested_lib.exists() { + continue; + } + // Use symlink_metadata so we don't follow a symlinked nested lib root. + let Ok(meta) = fs::symlink_metadata(&nested_lib) else { continue }; + if meta.file_type().is_symlink() || !meta.is_dir() { + continue; + } + if ensure_within_root(lib_src, &nested_lib, "nested lib", &nested_lib).is_err() { + continue; + } + process_nested_lib_dir(&nested_lib, lib_dst, &nested_lib_dir, depth)?; + } + + Ok(()) +} + +fn process_nested_lib_dir( + nested_lib: &Path, + lib_dst: &Path, + lib_rel: &Path, + depth: usize, +) -> Result<()> { + if !nested_lib.exists() || !nested_lib.is_dir() { + return Ok(()); + } + + let entries = match fs::read_dir(nested_lib) { + Ok(e) => e, + Err(_) => return Ok(()), + }; + + for entry in entries.flatten() { + // Use file_type() (does not follow symlinks) so a symlinked entry in a + // dependency's lib dir cannot be silently followed and re-symlinked + // outside the workspace. + let Ok(file_type) = entry.file_type() else { continue }; + if file_type.is_symlink() || !file_type.is_dir() { + continue; + } + + let entry_path = entry.path(); + let entry_name = entry.file_name(); + let nested_dst = lib_dst.join(lib_rel).join(&entry_name); + + if !nested_dst.exists() { + if let Some(parent) = nested_dst.parent() { + let _ = fs::create_dir_all(parent); + } + let _ = symlink_dir(&entry_path, &nested_dst); + } + + symlink_nested_libs(&entry_path, &nested_dst, depth + 1)?; + } + + Ok(()) +} + +/// Recursively copy a directory, following symlinked directories only within the allowed root. +pub fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> { + let mut visited = Vec::new(); + copy_dir_recursive_inner(src, dst, src, &mut visited) +} + +fn copy_project_dir_recursive(root: &Path, src: &Path, dst: &Path) -> Result<()> { + let mut visited = Vec::new(); + copy_dir_recursive_inner(src, dst, root, &mut visited) +} + +fn copy_dir_recursive_inner( + src: &Path, + dst: &Path, + allowed_root: &Path, + visited: &mut Vec, +) -> Result<()> { + if !src.exists() { + return Ok(()); + } + ensure_within_root(allowed_root, src, "copied directory", src)?; + let canonical = src.canonicalize()?; + if visited.contains(&canonical) { + return Ok(()); + } + visited.push(canonical); + + let result = (|| { + fs::create_dir_all(dst)?; + + for entry in fs::read_dir(src)? { + let entry = entry?; + let path = entry.path(); + let dest_path = dst.join(entry.file_name()); + + let meta = fs::symlink_metadata(&path)?; + + if meta.file_type().is_symlink() { + if path.is_dir() { + if ensure_within_root(allowed_root, &path, "symlinked directory", &path).is_ok() + { + copy_dir_recursive_inner(&path, &dest_path, allowed_root, visited)?; + } + } else { + fs::copy(&path, &dest_path)?; + } + } else if meta.is_dir() { + copy_dir_recursive_inner(&path, &dest_path, allowed_root, visited)?; + } else { + fs::copy(&path, &dest_path)?; + } + } + + Ok(()) + })(); + + visited.pop(); + result +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{collections::BTreeMap, str::FromStr}; + + use foundry_compilers::artifacts::ModelCheckerSettings; + use foundry_config::fs_permissions::PathPermission; + use tempfile::TempDir; + + fn create_test_dir_structure(base: &Path, structure: &[&str]) { + for path in structure { + let full_path = base.join(path); + if path.ends_with('/') { + fs::create_dir_all(&full_path).unwrap(); + } else { + if let Some(parent) = full_path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(&full_path, format!("// {path}")).unwrap(); + } + } + } + + #[test] + fn test_rebase_remapping_preserves_context_directory_boundary() { + let remapping = RelativeRemapping::from(Remapping { + context: Some(format!("lib{MAIN_SEPARATOR}outer{MAIN_SEPARATOR}")), + name: "inner/".to_string(), + path: format!("lib{MAIN_SEPARATOR}outer{MAIN_SEPARATOR}lib{MAIN_SEPARATOR}inner"), + }); + + let remapping = rebase_remapping(Path::new("project"), Path::new("workspace"), &remapping); + assert!(remapping.context.unwrap().ends_with(MAIN_SEPARATOR)); + } + + #[cfg(unix)] + #[test] + fn test_rebase_config_paths_canonicalizes_workspace_alias() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("project"); + let workspace = temp.path().join("workspace"); + let alias = temp.path().join("alias"); + create_test_dir_structure(&root, &["src/Target.sol", "test/Target.t.sol"]); + fs::create_dir(&workspace).unwrap(); + symlink_dir(&workspace, &alias).unwrap(); + + let config = Config { + root: root.clone(), + src: root.join("src"), + test: root.join("test"), + remappings: vec![Remapping::from_str("@src/=src/").unwrap().into()], + ..Default::default() + }; + copy_project(&config, &alias).unwrap(); + let config = rebase_config_paths(&config, &alias).sanitized(); + let canonical_root = canonicalize_path(&workspace).unwrap(); + assert_eq!(config.root, canonical_root); + assert_eq!(config.src, canonical_root.join("src")); + assert_eq!(config.test, canonical_root.join("test")); + let remapping = Remapping::from(config.remappings[0].clone()); + assert_eq!(Path::new(&remapping.path), canonical_root.join("src")); + } + + #[test] + fn test_rebase_config_paths_rebases_materialized_project_paths() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("project"); + let workspace = normalize_existing_ancestor(&temp.path().join("workspace")); + let external = temp.path().join("external"); + + let mut contracts = BTreeMap::new(); + contracts.insert(root.join("src/Target.sol").display().to_string(), vec!["Target".into()]); + contracts + .insert(external.join("External.sol").display().to_string(), vec!["External".into()]); + + let config = Config { + root: root.clone(), + src: root.join("contracts"), + test: root.join("checks"), + script: root.join("deploy"), + out: root.join("custom-out"), + cache_path: root.join("custom-cache"), + snapshots: root.join("custom-snapshots"), + broadcast: root.join("custom-broadcast"), + mutation_dir: root.join("custom-cache/mutation"), + test_failures_file: root.join("custom-cache/test-failures"), + build_info_path: Some(root.join("custom-build-info")), + libs: vec![root.join("vendor"), external.join("lib")], + include_paths: vec![root.join("shared"), PathBuf::from("../external/include")], + allow_paths: vec![root.join("fixtures"), PathBuf::from("../external/fixtures")], + ignored_error_codes_from: vec![ + ( + root.join("contracts"), + vec![foundry_config::SolidityErrorCode::UnusedLocalVariable], + ), + ( + external.join("contracts"), + vec![foundry_config::SolidityErrorCode::UnusedFunctionParameter], + ), + ], + ignored_file_paths: vec![ + root.join("contracts/Ignored.sol"), + external.join("Ignored.sol"), + ], + remappings: vec![ + Remapping::from_str(&format!("@src/={}/", root.join("src").display())) + .unwrap() + .into(), + Remapping::from_str(&format!("@ext/={}/", external.join("src").display())) + .unwrap() + .into(), + ], + fs_permissions: foundry_config::FsPermissions::new([ + PathPermission::read(root.join("fixtures")), + PathPermission::read("../external/fixtures"), + ]), + fuzz: foundry_config::FuzzConfig { + corpus: foundry_config::FuzzCorpusConfig { + corpus_dir: Some(root.join("fuzz-corpus")), + frontier_dir: Some(root.join("fuzz-frontier")), + ..Default::default() + }, + ..Default::default() + }, + invariant: foundry_config::InvariantConfig { + corpus: foundry_config::FuzzCorpusConfig { + corpus_dir: Some(PathBuf::from("invariant-corpus")), + frontier_dir: Some(PathBuf::from("invariant-frontier")), + ..Default::default() + }, + ..Default::default() + }, + model_checker: Some(ModelCheckerSettings { + contracts, + engine: None, + timeout: None, + targets: None, + invariants: None, + show_unproved: None, + div_mod_with_slacks: None, + solvers: None, + show_unsupported: None, + show_proved_safe: None, + }), + ..Default::default() + }; + + let temp_config = rebase_config_paths(&config, &workspace); + let external = normalize_existing_ancestor(&external); + + assert_eq!(temp_config.root, workspace); + assert_eq!(temp_config.src, workspace.join("contracts")); + assert_eq!(temp_config.test, workspace.join("checks")); + assert_eq!(temp_config.script, workspace.join("deploy")); + assert_eq!(temp_config.out, workspace.join("custom-out")); + assert_eq!(temp_config.cache_path, workspace.join("custom-cache")); + assert_eq!(temp_config.snapshots, workspace.join("custom-snapshots")); + assert_eq!(temp_config.broadcast, workspace.join("custom-broadcast")); + assert_eq!(temp_config.mutation_dir, workspace.join("custom-cache/mutation")); + assert_eq!(temp_config.test_failures_file, workspace.join("custom-cache/test-failures")); + assert_eq!(temp_config.build_info_path, Some(workspace.join("custom-build-info"))); + assert_eq!(temp_config.libs, vec![workspace.join("vendor"), external.join("lib")]); + assert_eq!( + temp_config.include_paths, + vec![workspace.join("shared"), external.join("include")] + ); + assert_eq!( + temp_config.allow_paths, + vec![workspace.join("fixtures"), external.join("fixtures")] + ); + assert_eq!( + temp_config.ignored_error_codes_from, + vec![ + ( + workspace.join("contracts"), + vec![foundry_config::SolidityErrorCode::UnusedLocalVariable] + ), + ( + external.join("contracts"), + vec![foundry_config::SolidityErrorCode::UnusedFunctionParameter] + ) + ] + ); + assert_eq!( + temp_config.ignored_file_paths, + vec![workspace.join("contracts/Ignored.sol"), external.join("Ignored.sol")] + ); + + let remappings = + temp_config.remappings.into_iter().map(Remapping::from).collect::>(); + assert_eq!(remappings[0].path, format!("{}/", workspace.join("src").display())); + assert_eq!(remappings[1].path, format!("{}/", external.join("src").display())); + + assert_eq!( + temp_config.fs_permissions.permissions[0].path, + normalize_existing_ancestor(&workspace.join("fixtures")) + ); + assert_eq!( + temp_config.fs_permissions.permissions[1].path, + normalize_existing_ancestor(&external.join("fixtures")) + ); + assert_eq!(temp_config.fuzz.corpus.corpus_dir, Some(workspace.join("fuzz-corpus"))); + assert_eq!(temp_config.fuzz.corpus.frontier_dir, Some(workspace.join("fuzz-frontier"))); + assert_eq!( + temp_config.invariant.corpus.corpus_dir, + Some(workspace.join("invariant-corpus")) + ); + assert_eq!( + temp_config.invariant.corpus.frontier_dir, + Some(workspace.join("invariant-frontier")) + ); + + let contracts = temp_config.model_checker.unwrap().contracts; + assert!( + contracts.contains_key(&workspace.join("src").join("Target.sol").display().to_string()) + ); + assert!(contracts.contains_key(&external.join("External.sol").display().to_string())); + } + + #[test] + fn test_copy_project_preserves_external_read_only_paths() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("project"); + let workspace = normalize_existing_ancestor(&temp.path().join("workspace")); + let external = temp.path().join("shared-solidity"); + create_test_dir_structure(&root, &["src/Target.sol", "test/Target.t.sol"]); + create_test_dir_structure(&external, &["Shared.sol"]); + + let config = Config { + root: root.clone(), + src: root.join("src"), + test: root.join("test"), + include_paths: vec![PathBuf::from("../shared-solidity")], + allow_paths: vec![PathBuf::from("../shared-solidity")], + remappings: vec![Remapping::from_str("shared/=../shared-solidity/").unwrap().into()], + ..Default::default() + }; + + copy_project(&config, &workspace).unwrap(); + let temp_config = rebase_config_paths(&config, &workspace); + let external = normalize_existing_ancestor(&external); + let remappings = + temp_config.remappings.into_iter().map(Remapping::from).collect::>(); + + assert!(workspace.join("src/Target.sol").exists()); + assert!(!workspace.join("shared-solidity/Shared.sol").exists()); + assert_eq!(temp_config.include_paths, vec![external.clone()]); + assert_eq!(temp_config.allow_paths, vec![external.clone()]); + assert_eq!(remappings[0].path, format!("{}/", external.display())); + } + + #[test] + fn test_copy_project_copies_project_local_corpus_and_frontier_paths() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("project"); + let workspace = normalize_existing_ancestor(&temp.path().join("workspace")); + create_test_dir_structure( + &root, + &[ + "src/Target.sol", + "test/Target.t.sol", + "fuzz-corpus/seed.json", + "fuzz-frontier/frontier.json", + "invariant-corpus/seed.json", + "invariant-frontier/frontier.json", + ], + ); + + let config = Config { + root: root.clone(), + src: root.join("src"), + test: root.join("test"), + fuzz: foundry_config::FuzzConfig { + corpus: foundry_config::FuzzCorpusConfig { + corpus_dir: Some(PathBuf::from("fuzz-corpus")), + frontier_dir: Some(PathBuf::from("fuzz-frontier")), + ..Default::default() + }, + ..Default::default() + }, + invariant: foundry_config::InvariantConfig { + corpus: foundry_config::FuzzCorpusConfig { + corpus_dir: Some(PathBuf::from("invariant-corpus")), + frontier_dir: Some(PathBuf::from("invariant-frontier")), + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }; + + copy_project(&config, &workspace).unwrap(); + let temp_config = rebase_config_paths(&config, &workspace); + + assert!(workspace.join("fuzz-corpus/seed.json").exists()); + assert!(workspace.join("fuzz-frontier/frontier.json").exists()); + assert!(workspace.join("invariant-corpus/seed.json").exists()); + assert!(workspace.join("invariant-frontier/frontier.json").exists()); + assert_eq!(temp_config.fuzz.corpus.corpus_dir, Some(workspace.join("fuzz-corpus"))); + assert_eq!(temp_config.fuzz.corpus.frontier_dir, Some(workspace.join("fuzz-frontier"))); + assert_eq!( + temp_config.invariant.corpus.corpus_dir, + Some(workspace.join("invariant-corpus")) + ); + assert_eq!( + temp_config.invariant.corpus.frontier_dir, + Some(workspace.join("invariant-frontier")) + ); + } + + #[test] + fn test_copy_project_isolates_external_corpus_and_frontier_paths() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("project"); + let workspace = normalize_existing_ancestor(&temp.path().join("workspace")); + let corpus = temp.path().join("shared-corpus"); + let frontier = temp.path().join("shared-frontier"); + create_test_dir_structure(&root, &["src/Target.sol", "test/Target.t.sol"]); + create_test_dir_structure(&corpus, &["seed.json"]); + create_test_dir_structure(&frontier, &["frontier.json"]); + + let config = Config { + root: root.clone(), + src: root.join("src"), + test: root.join("test"), + invariant: foundry_config::InvariantConfig { + corpus: foundry_config::FuzzCorpusConfig { + corpus_dir: Some(PathBuf::from("../shared-corpus")), + frontier_dir: Some(PathBuf::from("../shared-frontier")), + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }; + + copy_project(&config, &workspace).unwrap(); + let temp_config = rebase_config_paths(&config, &workspace); + let rebased_corpus = temp_config.invariant.corpus.corpus_dir.unwrap(); + let rebased_frontier = temp_config.invariant.corpus.frontier_dir.unwrap(); + + assert!(rebased_corpus.starts_with(workspace.join(".foundry_mutable"))); + assert!(rebased_frontier.starts_with(workspace.join(".foundry_mutable"))); + assert!(rebased_corpus.join("seed.json").exists()); + assert!(rebased_frontier.join("frontier.json").exists()); + } + + #[test] + fn test_copy_project_merges_overlapping_mutable_paths() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("project"); + let workspace = normalize_existing_ancestor(&temp.path().join("workspace")); + create_test_dir_structure( + &root, + &[ + "src/Target.sol", + "test/Target.t.sol", + "state/corpus/seed.json", + "state/frontier.json", + ], + ); + + let config = Config { + root: root.clone(), + src: root.join("src"), + test: root.join("test"), + fuzz: foundry_config::FuzzConfig { + corpus: foundry_config::FuzzCorpusConfig { + corpus_dir: Some(PathBuf::from("state/corpus")), + frontier_dir: Some(PathBuf::from("state")), + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }; + + copy_project(&config, &workspace).unwrap(); + + assert!(workspace.join("state/corpus/seed.json").exists()); + assert!(workspace.join("state/frontier.json").exists()); + } + + #[test] + fn test_copy_project_isolates_external_failure_persist_dirs() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("project"); + let workspace = normalize_existing_ancestor(&temp.path().join("workspace")); + let fuzz_failures = temp.path().join("shared-fuzz-failures"); + let invariant_failures = temp.path().join("shared-invariant-failures"); + create_test_dir_structure(&root, &["src/Target.sol", "test/Target.t.sol"]); + create_test_dir_structure(&fuzz_failures, &["Target/test/fuzz-failure"]); + create_test_dir_structure(&invariant_failures, &["Target/invariant/invariant-failure"]); + + let config = Config { + root: root.clone(), + src: root.join("src"), + test: root.join("test"), + fuzz: foundry_config::FuzzConfig { + failure_persist_dir: Some(fuzz_failures), + ..Default::default() + }, + invariant: foundry_config::InvariantConfig { + failure_persist_dir: Some(invariant_failures), + ..Default::default() + }, + ..Default::default() + }; + + copy_project(&config, &workspace).unwrap(); + let temp_config = rebase_config_paths(&config, &workspace); + let fuzz_failure_dir = temp_config.fuzz.failure_persist_dir.unwrap(); + let invariant_failure_dir = temp_config.invariant.failure_persist_dir.unwrap(); + + assert!( + fuzz_failure_dir.starts_with(workspace.join(".foundry_mutable")), + "{}", + fuzz_failure_dir.display() + ); + assert!( + invariant_failure_dir.starts_with(workspace.join(".foundry_mutable")), + "{}", + invariant_failure_dir.display() + ); + assert!(fuzz_failure_dir.join("Target/test/fuzz-failure").exists()); + assert!(invariant_failure_dir.join("Target/invariant/invariant-failure").exists()); + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn test_copy_project_isolates_corpus_under_symlinked_lib_root() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("project"); + let workspace = normalize_existing_ancestor(&temp.path().join("workspace")); + create_test_dir_structure( + &root, + &[ + "src/Target.sol", + "test/Target.t.sol", + ".real-lib/mycorpus/seed.json", + ".real-lib/Dependency.sol", + ], + ); + symlink_dir(Path::new(".real-lib"), &root.join("lib")).unwrap(); + + let config = Config { + root: root.clone(), + src: root.join("src"), + test: root.join("test"), + libs: vec![root.join("lib")], + invariant: foundry_config::InvariantConfig { + corpus: foundry_config::FuzzCorpusConfig { + corpus_dir: Some(PathBuf::from("lib/mycorpus")), + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }; + + copy_project(&config, &workspace).unwrap(); + let temp_config = rebase_config_paths(&config, &workspace); + let rebased_corpus = temp_config.invariant.corpus.corpus_dir.unwrap(); + + assert!(rebased_corpus.starts_with(workspace.join(".foundry_mutable"))); + assert!(rebased_corpus.join("seed.json").exists()); + assert!(!rebased_corpus.starts_with(workspace.join("lib"))); + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn test_rebase_config_paths_preserves_symlink_parent_semantics() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("project"); + let workspace = normalize_existing_ancestor(&temp.path().join("workspace")); + let external = temp.path().join("external"); + create_test_dir_structure(&root, &["src/Target.sol", "test/Target.t.sol"]); + create_test_dir_structure( + &external, + &["pkg/Dependency.sol", "include/Shared.sol", "pkg/corpus/seed.json"], + ); + symlink_dir(&external.join("pkg"), &root.join("link")).unwrap(); + + let config = Config { + root: root.clone(), + src: root.join("src"), + test: root.join("test"), + libs: vec![PathBuf::from("link")], + include_paths: vec![PathBuf::from("link/../include")], + invariant: foundry_config::InvariantConfig { + corpus: foundry_config::FuzzCorpusConfig { + corpus_dir: Some(PathBuf::from("link/corpus")), + ..Default::default() + }, + ..Default::default() + }, + ..Default::default() + }; + + copy_project(&config, &workspace).unwrap(); + let temp_config = rebase_config_paths(&config, &workspace); + let rebased_corpus = temp_config.invariant.corpus.corpus_dir.unwrap(); + let external = normalize_existing_ancestor(&external); + + assert_eq!(temp_config.libs, vec![external.join("pkg")]); + assert_eq!(temp_config.include_paths, vec![external.join("include")]); + assert!(rebased_corpus.starts_with(workspace.join(".foundry_mutable"))); + assert!(rebased_corpus.join("seed.json").exists()); + } + + #[cfg(windows)] + #[test] + fn test_isolated_mutable_paths_include_windows_prefix_in_identity() { + let c_drive = isolated_mutable_path_rel(Path::new(r"C:\shared\state")); + let d_drive = isolated_mutable_path_rel(Path::new(r"D:\shared\state")); + + assert_ne!(c_drive, d_drive); + } + + #[test] + fn test_copy_project_copies_project_local_remapping_targets() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("project"); + let workspace = normalize_existing_ancestor(&temp.path().join("workspace")); + create_test_dir_structure( + &root, + &["src/Target.sol", "test/Target.t.sol", "packages/shared/src/Shared.sol"], + ); + + let config = Config { + root: root.clone(), + src: root.join("src"), + test: root.join("test"), + remappings: vec![Remapping::from_str("shared/=packages/shared/src/").unwrap().into()], + ..Default::default() + }; + + copy_project(&config, &workspace).unwrap(); + let temp_config = rebase_config_paths(&config, &workspace); + let remappings = + temp_config.remappings.into_iter().map(Remapping::from).collect::>(); + + assert!(workspace.join("packages/shared/src/Shared.sol").exists()); + assert_eq!( + remappings[0].path, + format!("{}/", workspace.join("packages").join("shared").join("src").display()) + ); + } + + #[test] + fn test_copy_project_preserves_external_libs() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("project"); + let workspace = normalize_existing_ancestor(&temp.path().join("workspace")); + let external = temp.path().join("shared-lib"); + create_test_dir_structure(&root, &["src/Target.sol", "test/Target.t.sol", "lib/Local.sol"]); + create_test_dir_structure(&external, &["External.sol"]); + + let config = Config { + root: root.clone(), + src: root.join("src"), + test: root.join("test"), + libs: vec![PathBuf::from("lib"), PathBuf::from("../shared-lib")], + ..Default::default() + }; + + copy_project(&config, &workspace).unwrap(); + let temp_config = rebase_config_paths(&config, &workspace); + let external = normalize_existing_ancestor(&external); + + assert!(workspace.join("lib/Local.sol").exists()); + assert!(!workspace.join("shared-lib/External.sol").exists()); + assert_eq!(temp_config.libs, vec![workspace.join("lib"), external]); + } + + #[test] + fn test_rebase_config_paths_rebases_relative_fs_permissions() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("project"); + let workspace = normalize_existing_ancestor(&temp.path().join("workspace")); + fs::create_dir_all(root.join("writes")).unwrap(); + fs::create_dir_all(workspace.join("writes")).unwrap(); + fs::create_dir_all(workspace.join("logs/sub")).unwrap(); + + let config = Config { + root, + fs_permissions: foundry_config::FsPermissions::new([ + PathPermission::write("./writes"), + PathPermission::read_write("./logs/sub/a.txt"), + ]), + ..Default::default() + }; + + let temp_config = rebase_config_paths(&config, &workspace).sanitized(); + + assert_eq!(temp_config.root, workspace); + assert_eq!( + temp_config.fs_permissions.permissions[0].path, + canonicalize_path(workspace.join("writes")).unwrap() + ); + assert_eq!( + temp_config.fs_permissions.permissions[1].path, + canonicalize_path(workspace.join("logs/sub")).unwrap().join("a.txt") + ); + } + + #[test] + fn test_symlink_dir_creates_symlink() { + let temp = TempDir::new().unwrap(); + let src = temp.path().join("source_dir"); + let dst = temp.path().join("target_link"); + + fs::create_dir(&src).unwrap(); + fs::write(src.join("file.txt"), "content").unwrap(); + + symlink_dir(&src, &dst).unwrap(); + + assert!(dst.exists()); + assert!(dst.is_symlink()); + assert!(dst.join("file.txt").exists()); + } + + #[test] + fn test_symlink_nested_libs_single_level() { + let temp = TempDir::new().unwrap(); + + let lib_src = temp.path().join("lib_src"); + create_test_dir_structure( + &lib_src, + &[ + "src/Contract.sol", + "lib/", + "lib/openzeppelin/contracts/token/ERC20.sol", + "lib/solmate/src/tokens/ERC20.sol", + ], + ); + + let lib_dst = temp.path().join("lib_dst"); + fs::create_dir(&lib_dst).unwrap(); + + symlink_nested_libs(&lib_src, &lib_dst, 0).unwrap(); + + assert!(lib_dst.join("lib/openzeppelin").exists()); + assert!(lib_dst.join("lib/solmate").exists()); + assert!(lib_dst.join("lib/openzeppelin/contracts/token/ERC20.sol").exists()); + assert!(lib_dst.join("lib/solmate/src/tokens/ERC20.sol").exists()); + } + + #[test] + fn test_symlink_nested_libs_deeply_nested() { + let temp = TempDir::new().unwrap(); + + let lib_src = temp.path().join("lib_src"); + create_test_dir_structure( + &lib_src, + &[ + "src/Main.sol", + "lib/", + "lib/dep-a/src/A.sol", + "lib/dep-a/lib/", + "lib/dep-a/lib/dep-b/src/B.sol", + "lib/dep-a/lib/dep-b/lib/", + "lib/dep-a/lib/dep-b/lib/dep-c/src/C.sol", + ], + ); + + let lib_dst = temp.path().join("lib_dst"); + fs::create_dir(&lib_dst).unwrap(); + + symlink_nested_libs(&lib_src, &lib_dst, 0).unwrap(); + + assert!(lib_dst.join("lib/dep-a").exists()); + assert!(lib_dst.join("lib/dep-a/lib/dep-b").exists()); + assert!(lib_dst.join("lib/dep-a/lib/dep-b/lib/dep-c").exists()); + assert!(lib_dst.join("lib/dep-a/lib/dep-b/lib/dep-c/src/C.sol").exists()); + } + + #[test] + fn test_symlink_nested_libs_no_nested_lib_dir() { + let temp = TempDir::new().unwrap(); + + let lib_src = temp.path().join("lib_src"); + create_test_dir_structure(&lib_src, &["src/Contract.sol", "test/Test.sol"]); + + let lib_dst = temp.path().join("lib_dst"); + fs::create_dir(&lib_dst).unwrap(); + + symlink_nested_libs(&lib_src, &lib_dst, 0).unwrap(); + + assert!(!lib_dst.join("lib").exists()); + } + + #[test] + fn test_symlink_nested_libs_skips_existing() { + let temp = TempDir::new().unwrap(); + + let lib_src = temp.path().join("lib_src"); + create_test_dir_structure(&lib_src, &["lib/", "lib/existing/src/File.sol"]); + + let lib_dst = temp.path().join("lib_dst"); + fs::create_dir_all(lib_dst.join("lib/existing")).unwrap(); + fs::write(lib_dst.join("lib/existing/marker.txt"), "pre-existing").unwrap(); + + symlink_nested_libs(&lib_src, &lib_dst, 0).unwrap(); + + assert!(lib_dst.join("lib/existing/marker.txt").exists()); + } + + #[test] + fn test_copy_dir_recursive_basic() { + let temp = TempDir::new().unwrap(); + + let src = temp.path().join("src"); + create_test_dir_structure( + &src, + &["file1.sol", "subdir/file2.sol", "subdir/nested/file3.sol"], + ); + + let dst = temp.path().join("dst"); + copy_dir_recursive(&src, &dst).unwrap(); + + assert!(dst.join("file1.sol").exists()); + assert!(dst.join("subdir/file2.sol").exists()); + assert!(dst.join("subdir/nested/file3.sol").exists()); + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn test_copy_project_dir_recursive_preserves_in_root_symlink_aliases() { + let temp = TempDir::new().unwrap(); + + let root = temp.path().join("project"); + let src = root.join("src"); + let shared = root.join(".shared/pkg"); + fs::create_dir_all(&shared).unwrap(); + fs::create_dir_all(src.join("nested")).unwrap(); + fs::write(shared.join("Y.sol"), "contract Y {}").unwrap(); + symlink_dir(Path::new("../.shared/pkg"), &src.join("first")).unwrap(); + symlink_dir(Path::new("../../.shared/pkg"), &src.join("nested/second")).unwrap(); + + let dst = temp.path().join("dst/src"); + copy_project_dir_recursive(&root, &src, &dst).unwrap(); + + assert!(dst.join("first/Y.sol").exists()); + assert!(dst.join("nested/second/Y.sol").exists()); + } + + #[test] + fn test_copy_dir_recursive_skips_symlinked_dirs() { + let temp = TempDir::new().unwrap(); + + let src = temp.path().join("src"); + let external = temp.path().join("external"); + + fs::create_dir_all(&external).unwrap(); + fs::write(external.join("secret.txt"), "should not be copied").unwrap(); + + fs::create_dir_all(&src).unwrap(); + fs::write(src.join("file.sol"), "content").unwrap(); + + symlink_dir(&external, &src.join("external_link")).unwrap(); + + let dst = temp.path().join("dst"); + copy_dir_recursive(&src, &dst).unwrap(); + + assert!(dst.join("file.sol").exists()); + assert!(!dst.join("external_link").exists()); + } + + #[test] + fn test_copy_dir_recursive_nonexistent_src() { + let temp = TempDir::new().unwrap(); + + let src = temp.path().join("nonexistent"); + let dst = temp.path().join("dst"); + + copy_dir_recursive(&src, &dst).unwrap(); + assert!(!dst.exists()); + } + + #[test] + fn test_copy_project_copies_include_paths_under_root() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("project"); + let out = temp.path().join("workspace"); + create_test_dir_structure( + &root, + &["src/Counter.sol", "test/Counter.t.sol", "include/Shared.sol"], + ); + + let config = Config { + root: root.clone(), + src: root.join("src"), + test: root.join("test"), + script: root.join("script"), + include_paths: vec![root.join("include")], + ..Default::default() + }; + + copy_project(&config, &out).unwrap(); + + assert!(out.join("include/Shared.sol").exists()); + } + + #[test] + fn test_copy_project_skips_include_paths_covered_by_libs() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("project"); + let out = temp.path().join("workspace"); + create_test_dir_structure( + &root, + &["src/Counter.sol", "test/Counter.t.sol", "lib/foo/Foo.sol", "lib/bar/Bar.sol"], + ); + + let config = Config { + root: root.clone(), + src: root.join("src"), + test: root.join("test"), + script: root.join("script"), + libs: vec![root.join("lib")], + include_paths: vec![root.join("lib/foo")], + ..Default::default() + }; + + copy_project(&config, &out).unwrap(); + + assert!(out.join("lib/foo/Foo.sol").exists()); + assert!(out.join("lib/bar/Bar.sol").exists()); + } + + #[test] + fn test_copy_project_preserves_absolute_external_include_paths() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("project"); + let outside = temp.path().join("outside"); + let out = temp.path().join("workspace"); + create_test_dir_structure(&root, &["src/Counter.sol", "test/Counter.t.sol"]); + create_test_dir_structure(&outside, &["Shared.sol"]); + + let config = Config { + root: root.clone(), + src: root.join("src"), + test: root.join("test"), + script: root.join("script"), + include_paths: vec![outside.clone()], + ..Default::default() + }; + + copy_project(&config, &out).unwrap(); + let temp_config = rebase_config_paths(&config, &out); + let outside = normalize_existing_ancestor(&outside); + + assert_eq!(temp_config.include_paths, vec![outside]); + assert!(!out.join("outside/Shared.sol").exists()); + } + + #[test] + fn test_relative_to_root_basic() { + let root = PathBuf::from("/project"); + let path = PathBuf::from("/project/src/contracts"); + + let rel = relative_to_root(&root, &path); + assert_eq!(rel, PathBuf::from("src/contracts")); + } + + #[test] + fn test_relative_to_root_same_path() { + let root = PathBuf::from("/project"); + let path = PathBuf::from("/project"); + + let rel = relative_to_root(&root, &path); + assert_eq!(rel, PathBuf::from("")); + } + + #[test] + fn test_relative_to_root_outside_root() { + let root = PathBuf::from("/project"); + let path = PathBuf::from("/other/location"); + + let rel = relative_to_root(&root, &path); + assert_eq!(rel, normalize_existing_ancestor(&path)); + } + + #[test] + fn test_ensure_within_root_rejects_symlink_escape() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("project"); + let outside = temp.path().join("outside"); + fs::create_dir_all(&root).unwrap(); + fs::create_dir_all(&outside).unwrap(); + fs::write(outside.join("secret.txt"), "shhh").unwrap(); + + // src is a symlink that points outside the project root. + let src = root.join("src"); + symlink_dir(&outside, &src).unwrap(); + + let err = ensure_within_root(&root, &src, "src", &src).unwrap_err(); + assert!(err.to_string().contains("escapes project root"), "unexpected error: {err}"); + } + + #[test] + fn test_ensure_within_root_accepts_in_root_symlink() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("project"); + let real_src = root.join("real_src"); + fs::create_dir_all(&real_src).unwrap(); + + // src -> real_src is fine: stays inside the project root. + let src_link = root.join("src"); + symlink_dir(&real_src, &src_link).unwrap(); + + ensure_within_root(&root, &src_link, "src", &src_link).unwrap(); + } + + #[test] + fn test_symlink_nested_libs_rejects_traversal_in_dependency_config() { + let temp = TempDir::new().unwrap(); + + // Pretend lib_src is a malicious dependency whose foundry.toml says + // libs = ["../../escape"]. We can't easily write foundry.toml here, so + // exercise the lexical guard directly via is_safe_relative_path: any + // path containing `..` must be rejected before being joined with + // `lib_src`. + let malicious: PathBuf = PathBuf::from("../../escape"); + assert!(!is_safe_relative_path(&malicious)); + + // Sanity check: a benign relative path is still accepted. + let benign: PathBuf = PathBuf::from("lib"); + assert!(is_safe_relative_path(&benign)); + + // And the function returns Ok when there is nothing to do. + let lib_src = temp.path().join("lib_src"); + let lib_dst = temp.path().join("lib_dst"); + fs::create_dir_all(&lib_src).unwrap(); + fs::create_dir_all(&lib_dst).unwrap(); + symlink_nested_libs(&lib_src, &lib_dst, 0).unwrap(); + } + + #[test] + fn test_process_nested_lib_dir_skips_symlinks() { + let temp = TempDir::new().unwrap(); + let outside = temp.path().join("outside"); + fs::create_dir_all(outside.join("secret_pkg/src")).unwrap(); + fs::write(outside.join("secret_pkg/src/Secret.sol"), "secret").unwrap(); + + let lib_src = temp.path().join("lib_src"); + let nested = lib_src.join("lib"); + fs::create_dir_all(&nested).unwrap(); + // A dep that is a symlink pointing outside the lib root. + symlink_dir(&outside.join("secret_pkg"), &nested.join("evil")).unwrap(); + + let lib_dst = temp.path().join("lib_dst"); + fs::create_dir_all(&lib_dst).unwrap(); + + process_nested_lib_dir(&nested, &lib_dst, Path::new("lib"), 0).unwrap(); + + // The symlinked entry must not have been followed into the destination. + assert!(!lib_dst.join("lib/evil").exists(), "symlinked dep was followed"); + } +} diff --git a/crates/forge/tests/cli/backtrace.rs b/crates/forge/tests/cli/backtrace.rs new file mode 100644 index 0000000000000..5c23ce21ad71e --- /dev/null +++ b/crates/forge/tests/cli/backtrace.rs @@ -0,0 +1,694 @@ +//! Tests for backtrace functionality + +use foundry_test_utils::rpc::{next_etherscan_api_key, next_http_rpc_endpoint}; + +#[forgetest] +fn test_backtraces(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.insert_vm(); + prj.add_source("SimpleRevert.sol", include_str!("../fixtures/backtraces/SimpleRevert.sol")); + prj.add_source("StaticCall.sol", include_str!("../fixtures/backtraces/StaticCall.sol")); + prj.add_source("DelegateCall.sol", include_str!("../fixtures/backtraces/DelegateCall.sol")); + prj.add_source("NestedCalls.sol", include_str!("../fixtures/backtraces/NestedCalls.sol")); + + prj.add_test("Backtrace.t.sol", include_str!("../fixtures/backtraces/Backtrace.t.sol")); + + let output = cmd.args(["test", "-vvvvv"]).assert_failure(); + + output.stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful with warnings: +... +Ran 11 tests for test/Backtrace.t.sol:BacktraceTest +[FAIL: panic: assertion failed (0x01)] testAssertFail() ([GAS]) +... +Backtrace: + at SimpleRevert.doAssert + at BacktraceTest.testAssertFail (test/Backtrace.t.sol:40:9) + +[FAIL: CustomError(42, 0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496)] testCustomError() ([GAS]) +... +Backtrace: + at SimpleRevert.doCustomError (src/SimpleRevert.sol:21:16) + at BacktraceTest.testCustomError (test/Backtrace.t.sol:45:9) + +[FAIL: Delegate compute failed] testDelegateCallRequire() ([GAS]) +... +Backtrace: + at DelegateTarget.compute (src/DelegateCall.sol:11:9) + at DelegateCaller.delegateCompute (src/DelegateCall.sol:32:9) + at BacktraceTest.testDelegateCallRequire (test/Backtrace.t.sol:82:9) + +[FAIL: Delegate call failed] testDelegateCallRevert() ([GAS]) +... +Backtrace: + at DelegateTarget.fail (src/DelegateCall.sol:7:9) + at DelegateCaller.delegateFail (src/DelegateCall.sol:26:9) + at BacktraceTest.testDelegateCallRevert (test/Backtrace.t.sol:77:9) + +[FAIL: Failed at internal level 3] testInternalCallChain() ([GAS]) +... +Backtrace: + at BacktraceTest.testInternalCallChain (test/Backtrace.t.sol:72:9) + +[FAIL: Failed at chain level 3] testInternalCallsSameSource() ([GAS]) +... +Backtrace: + at NestedCalls.callChain1 (src/NestedCalls.sol:25:9) + at BacktraceTest.testInternalCallsSameSource (test/Backtrace.t.sol:55:9) + +[FAIL: Maximum depth reached] testNestedCalls() ([GAS]) +... +Backtrace: + at NestedCalls.nestedCall (src/NestedCalls.sol:11:13) + at NestedCalls.nestedCall (src/NestedCalls.sol:13:9) + at NestedCalls.nestedCall (src/NestedCalls.sol:13:9) + at NestedCalls.nestedCall (src/NestedCalls.sol:13:9) + at NestedCalls.nestedCall (src/NestedCalls.sol:13:9) + at BacktraceTest.testNestedCalls (test/Backtrace.t.sol:50:9) + +[FAIL: Value must be greater than zero] testRequireFail() ([GAS]) +... +Backtrace: + at SimpleRevert.doRequire (src/SimpleRevert.sol:11:9) + at BacktraceTest.testRequireFail (test/Backtrace.t.sol:35:9) + +[FAIL: Simple revert message] testSimpleRevert() ([GAS]) +... +Backtrace: + at SimpleRevert.doRevert (src/SimpleRevert.sol:7:9) + at BacktraceTest.testSimpleRevert (test/Backtrace.t.sol:30:9) + +[FAIL: Static compute failed] testStaticCallRequire() ([GAS]) +... +Backtrace: + at StaticTarget.compute (src/StaticCall.sol:11:9) + at StaticCaller.staticCompute (src/StaticCall.sol:30:9) + at BacktraceTest.testStaticCallRequire (test/Backtrace.t.sol:92:9) + +[FAIL: Static call reverted] testStaticCallRevert() ([GAS]) +... +Backtrace: + at StaticTarget.viewFail (src/StaticCall.sol:7:9) + at StaticCaller.staticCallFail (src/StaticCall.sol:25:9) + at BacktraceTest.testStaticCallRevert (test/Backtrace.t.sol:87:9) + +Suite result: FAILED. 0 passed; 11 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest] +fn test_backtrace_with_mixed_compilation(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.insert_vm(); + + prj.add_source( + "SimpleRevert.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract SimpleRevert { + function doRevert(string memory reason) public pure { + revert(reason); + } +} +"#, + ); + + // Add another source file that won't be modified + prj.add_source( + "HelperContract.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract HelperContract { + function getValue() public pure returns (uint256) { + return 42; + } + + function doRevert() public pure { + revert("Helper revert"); + } +} +"#, + ); + + prj.add_test( + "BacktraceTest.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "../src/test.sol"; +import "../src/SimpleRevert.sol"; +import "../src/HelperContract.sol"; + +contract BacktraceTest is DSTest { + SimpleRevert simpleRevert; + HelperContract helper; + + function setUp() public { + simpleRevert = new SimpleRevert(); + helper = new HelperContract(); + } + + function testSimpleRevert() public { + simpleRevert.doRevert("Test failure"); + } + + function testHelperRevert() public { + helper.doRevert(); + } +} +"#, + ); + + let output = cmd.args(["test", "-vvvvv"]).assert_failure(); + + output.stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +... +Ran 2 tests for test/BacktraceTest.t.sol:BacktraceTest +[FAIL: Helper revert] testHelperRevert() ([GAS]) +... +Backtrace: + at HelperContract.doRevert (src/HelperContract.sol:11:9) + at BacktraceTest.testHelperRevert (test/BacktraceTest.t.sol:23:9) + +[FAIL: Test failure] testSimpleRevert() ([GAS]) +... +Backtrace: + at SimpleRevert.doRevert (src/SimpleRevert.sol:7:9) + at BacktraceTest.testSimpleRevert (test/BacktraceTest.t.sol:19:9) + +Suite result: FAILED. 0 passed; 2 failed; 0 skipped; [ELAPSED] +... +"#]]); + + // Modify the source file - add a comment to change line numbers + prj.add_source( + "SimpleRevert.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract SimpleRevert { + function doRevert(string memory reason) public pure { + // Added comment to shift line numbers + revert(reason); + } +} +"#, + ); + + // Modify the test file as well + prj.add_test( + "BacktraceTest.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "../src/test.sol"; +import "../src/SimpleRevert.sol"; +import "../src/HelperContract.sol"; + +contract BacktraceTest is DSTest { + SimpleRevert simpleRevert; + HelperContract helper; + + function setUp() public { + simpleRevert = new SimpleRevert(); + helper = new HelperContract(); + } + + function testSimpleRevert() public { + // Added some comments + // to change line numbers + simpleRevert.doRevert("Test failure"); + } + + function testHelperRevert() public { + helper.doRevert(); + } +} +"#, + ); + + // Second run - mixed compilation (SimpleRevert fresh, BacktraceTest fresh, HelperContract + // cached) + let output = cmd.forge_fuse().args(["test", "-vvvvv"]).assert_failure(); + + output.stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +... +Ran 2 tests for test/BacktraceTest.t.sol:BacktraceTest +[FAIL: Helper revert] testHelperRevert() ([GAS]) +... +Backtrace: + at HelperContract.doRevert (src/HelperContract.sol:11:9) + at BacktraceTest.testHelperRevert (test/BacktraceTest.t.sol:25:9) + +[FAIL: Test failure] testSimpleRevert() ([GAS]) +... +Backtrace: + at SimpleRevert.doRevert (src/SimpleRevert.sol:8:9) + at BacktraceTest.testSimpleRevert (test/BacktraceTest.t.sol:21:9) + +Suite result: FAILED. 0 passed; 2 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest] +fn test_library_backtrace(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.insert_vm(); + + // Add library source files + prj.add_source( + "libraries/InternalMathLib.sol", + include_str!("../fixtures/backtraces/libraries/InternalMathLib.sol"), + ); + prj.add_source( + "libraries/ExternalMathLib.sol", + include_str!("../fixtures/backtraces/libraries/ExternalMathLib.sol"), + ); + prj.add_source( + "LibraryConsumer.sol", + include_str!("../fixtures/backtraces/LibraryConsumer.sol"), + ); + + // Add test file + prj.add_test( + "LibraryBacktrace.t.sol", + include_str!("../fixtures/backtraces/LibraryBacktrace.t.sol"), + ); + + // Configure the linked library. + let config = foundry_config::Config { + libraries: vec!["src/libraries/ExternalMathLib.sol:ExternalMathLib:0x1234567890123456789012345678901234567890".to_string()], + ..Default::default() + }; + prj.write_config(config); + + let output = + cmd.args(["test", "-vvv", "--ast", "--mc", "LibraryBacktraceTest"]).assert_failure(); + + output.stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful[..] +... +Ran 9 tests for test/LibraryBacktrace.t.sol:LibraryBacktraceTest +[FAIL: DivisionByZero()] testExternalDivisionByZero() ([GAS]) +... +Backtrace: + at ExternalMathLib.div + at LibraryConsumer.externalDivide + at LibraryBacktraceTest.testExternalDivisionByZero + +[FAIL: panic: arithmetic underflow or overflow (0x11)] testExternalOverflow() ([GAS]) +... +Backtrace: + at ExternalMathLib.mul + at LibraryConsumer.externalMultiply + at LibraryBacktraceTest.testExternalOverflow + +[FAIL: ExternalMathLib: value must be positive] testExternalRequire() ([GAS]) +... +Backtrace: + at ExternalMathLib.requirePositive + at LibraryConsumer.externalCheckPositive + at LibraryBacktraceTest.testExternalRequire + +[FAIL: Underflow()] testExternalUnderflow() ([GAS]) +... +Backtrace: + at ExternalMathLib.sub + at LibraryConsumer.externalSubtract + at LibraryBacktraceTest.testExternalUnderflow + +[FAIL: DivisionByZero()] testInternalDivisionByZero() ([GAS]) +... +Backtrace: + at LibraryConsumer.internalDivide + at LibraryBacktraceTest.testInternalDivisionByZero + +[FAIL: panic: arithmetic underflow or overflow (0x11)] testInternalOverflow() ([GAS]) +Traces: +... +Backtrace: + at LibraryConsumer.internalMultiply + at LibraryBacktraceTest.testInternalOverflow + +[FAIL: InternalMathLib: value must be positive] testInternalRequire() ([GAS]) +Traces: +... +Backtrace: + at LibraryConsumer.internalCheckPositive + at LibraryBacktraceTest.testInternalRequire + +[FAIL: Underflow()] testInternalUnderflow() ([GAS]) +Traces: +... +Backtrace: + at LibraryConsumer.internalSubtract + at LibraryBacktraceTest.testInternalUnderflow + +[FAIL: DivisionByZero()] testMixedLibraryFailure() ([GAS]) +Traces: +... +Backtrace: + at ExternalMathLib.div + at LibraryConsumer.mixedCalculation + at LibraryBacktraceTest.testMixedLibraryFailure + +Suite result: FAILED. 0 passed; 9 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest] +fn test_multiple_libraries_same_file(prj: _, cmd: _) { + prj.insert_ds_test(); + + prj.add_source( + "libraries/MultipleLibraries.sol", + include_str!("../fixtures/backtraces/libraries/MultipleLibraries.sol"), + ); + prj.add_source( + "MultipleLibraryConsumer.sol", + include_str!("../fixtures/backtraces/MultipleLibraryConsumer.sol"), + ); + + prj.add_test( + "MultipleLibraryBacktrace.t.sol", + include_str!("../fixtures/backtraces/MultipleLibraryBacktrace.t.sol"), + ); + + let output = cmd + .args(["test", "-vvvvv", "--ast", "--mc", "MultipleLibraryBacktraceTest"]) + .assert_failure(); + + output.stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 4 tests for test/MultipleLibraryBacktrace.t.sol:MultipleLibraryBacktraceTest +[FAIL: FirstLibError()] testAllLibrariesFirstFails() ([GAS]) +... +Backtrace: + at MultipleLibraryConsumer.useAllLibraries (src/libraries/MultipleLibraries.sol:10:20) + at MultipleLibraryBacktraceTest.testAllLibrariesFirstFails (test/MultipleLibraryBacktrace.t.sol:31:9) + +[FAIL: FirstLibError()] testFirstLibraryError() ([GAS]) +Traces: +... +Backtrace: + at MultipleLibraryConsumer.useFirstLib (src/libraries/MultipleLibraries.sol:10:20) + at MultipleLibraryBacktraceTest.testFirstLibraryError (test/MultipleLibraryBacktrace.t.sol:16:9) + +[FAIL: SecondLibError()] testSecondLibraryError() ([GAS]) +Traces: +... +Backtrace: + at MultipleLibraryConsumer.useSecondLib (src/libraries/MultipleLibraries.sol:26:20) + at MultipleLibraryBacktraceTest.testSecondLibraryError (test/MultipleLibraryBacktrace.t.sol:21:9) + +[FAIL: ThirdLibError()] testThirdLibraryError() ([GAS]) +Traces: +... +Backtrace: + at MultipleLibraryConsumer.useThirdLib (src/libraries/MultipleLibraries.sol:42:20) + at MultipleLibraryBacktraceTest.testThirdLibraryError (test/MultipleLibraryBacktrace.t.sol:26:9) + +Suite result: FAILED. 0 passed; 4 failed; 0 skipped; [ELAPSED] + +... +"#]]); +} + +#[forgetest] +fn test_fork_backtrace(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.insert_vm(); + + let etherscan_api_key = next_etherscan_api_key(); + let fork_url = next_http_rpc_endpoint(); + + prj.add_source( + "ForkedERC20Wrapper.sol", + include_str!("../fixtures/backtraces/ForkedERC20Wrapper.sol"), + ); + + prj.add_test("ForkBacktrace.t.sol", include_str!("../fixtures/backtraces/ForkBacktrace.t.sol")); + + let output = cmd + .args(["test", "-vvvvv", "--fork-url", &fork_url, "--match-contract", "ForkBacktraceTest"]) + .assert_failure(); + + output.stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +... +Ran 5 tests for test/ForkBacktrace.t.sol:ForkBacktraceTest +[FAIL: USDC transfer failed] testDirectOnChainRevert() (block: [..]) ([GAS]) +... +Backtrace: + at 0x43506849D7C04F9138D1A2050bbF3A0c054402dd.transfer + at 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48.transfer + at ForkBacktraceTest.testDirectOnChainRevert (test/ForkBacktrace.t.sol:36:9) + +[FAIL: ERC20: transfer amount exceeds balance] testNestedFailure() (block: [..]) ([GAS]) +... +Backtrace: + at 0x43506849D7C04F9138D1A2050bbF3A0c054402dd.transfer + at 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48.transfer + at ForkedERC20Wrapper.nestedFailure (src/ForkedERC20Wrapper.sol:14:9) + at ForkBacktraceTest.testNestedFailure (test/ForkBacktrace.t.sol:30:9) + +[FAIL: Account has zero USDC balance] testRequireNonZeroBalance() (block: [..]) ([GAS]) +... +Backtrace: + at ForkedERC20Wrapper.requireNonZeroBalance (src/ForkedERC20Wrapper.sol:23:9) + at ForkBacktraceTest.testRequireNonZeroBalance (test/ForkBacktrace.t.sol:26:9) + +[FAIL: ERC20: transfer amount exceeds allowance] testTransferFromWithoutApproval() (block: [..]) ([GAS]) +... +Backtrace: + at 0x43506849D7C04F9138D1A2050bbF3A0c054402dd.transferFrom + at 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48.transferFrom + at ForkedERC20Wrapper.transferFromWithoutApproval (src/ForkedERC20Wrapper.sol:18:9) + at ForkBacktraceTest.testTransferFromWithoutApproval (test/ForkBacktrace.t.sol:22:9) + +[FAIL: ERC20: transfer amount exceeds balance] testTransferWithoutBalance() (block: [..]) ([GAS]) +... +Backtrace: + at 0x43506849D7C04F9138D1A2050bbF3A0c054402dd.transfer + at 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48.transfer + at ForkedERC20Wrapper.transferWithoutBalance (src/ForkedERC20Wrapper.sol:14:9) + at ForkBacktraceTest.testTransferWithoutBalance (test/ForkBacktrace.t.sol:18:9) + +Suite result: FAILED. 0 passed; 5 failed; 0 skipped; [ELAPSED] +... +"#]]); + + cmd.forge_fuse() + .args([ + "test", + "--mt", + "testTransferFromWithoutApproval", + "-vvvvv", + "--fork-url", + &fork_url, + "--etherscan-api-key", + ðerscan_api_key, + ]) + .assert_failure() + .stdout_eq(str![[r#" +No files changed, compilation skipped +... +Ran 1 test for test/ForkBacktrace.t.sol:ForkBacktraceTest +[FAIL: ERC20: transfer amount exceeds allowance] testTransferFromWithoutApproval() (block: [..]) ([GAS]) +... +Backtrace: + at FiatTokenV2_2.transferFrom + at FiatTokenProxy.fallback + at ForkedERC20Wrapper.transferFromWithoutApproval (src/ForkedERC20Wrapper.sol:18:9) + at ForkBacktraceTest.testTransferFromWithoutApproval (test/ForkBacktrace.t.sol:22:9) +... +"#]]); +} + +#[forgetest] +fn test_backtrace_via_ir_disables_source_lines(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.insert_vm(); + prj.add_source("SimpleRevert.sol", include_str!("../fixtures/backtraces/SimpleRevert.sol")); + prj.add_source("StaticCall.sol", include_str!("../fixtures/backtraces/StaticCall.sol")); + prj.add_source("DelegateCall.sol", include_str!("../fixtures/backtraces/DelegateCall.sol")); + prj.add_source("NestedCalls.sol", include_str!("../fixtures/backtraces/NestedCalls.sol")); + + prj.add_test("Backtrace.t.sol", include_str!("../fixtures/backtraces/Backtrace.t.sol")); + + prj.update_config(|c| c.via_ir = true); + + let output = cmd.args(["test", "-vvvvv"]).assert_failure(); + output.stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +... +[FAIL: Static compute failed] testStaticCallRequire() ([GAS]) +... +Backtrace: + at StaticTarget.compute + at StaticCaller.staticCompute + at BacktraceTest.testStaticCallRequire +... +"#]]); +} + +// Test that backtraces only appear at verbosity 5 (-vvvvv). +// Runs the same failing test at every verbosity level to assert correct output. +#[forgetest] +fn test_backtrace_verbosity_levels(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.insert_vm(); + + prj.add_source( + "SimpleRevert.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract SimpleRevert { + function doRevert() public pure { + revert("Simple revert message"); + } +} +"#, + ); + + prj.add_test( + "BacktraceVerbosity.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "../src/test.sol"; +import "../src/SimpleRevert.sol"; + +contract BacktraceVerbosityTest is DSTest { + SimpleRevert simpleRevert; + + function setUp() public { + simpleRevert = new SimpleRevert(); + } + + function testRevert() public { + simpleRevert.doRevert(); + } +} +"#, + ); + + // -v (verbosity 1): no traces, no backtrace. + cmd.args(["test", "--mc", "BacktraceVerbosityTest", "-v"]).assert_failure().stdout_eq(str![[ + r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +... +Ran 1 test for test/BacktraceVerbosity.t.sol:BacktraceVerbosityTest +[FAIL: Simple revert message] testRevert() ([GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"# + ]]); + + // -vvv (verbosity 3): traces and backtrace WITHOUT source locations. + cmd.forge_fuse() + .args(["test", "--mc", "BacktraceVerbosityTest", "-vvv"]) + .assert_failure() + .stdout_eq(str![[r#" +No files changed, compilation skipped + +Ran 1 test for test/BacktraceVerbosity.t.sol:BacktraceVerbosityTest +[FAIL: Simple revert message] testRevert() ([GAS]) +Traces: + [..] BacktraceVerbosityTest::testRevert() + ├─ [..] SimpleRevert::doRevert() [staticcall] + │ └─ ← [Revert] Simple revert message + └─ ← [Revert] Simple revert message + +Backtrace: + at SimpleRevert.doRevert + at BacktraceVerbosityTest.testRevert + +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); + + // -vvvv (verbosity 4): traces with setup and backtrace WITHOUT source locations. + cmd.forge_fuse() + .args(["test", "--mc", "BacktraceVerbosityTest", "-vvvv"]) + .assert_failure() + .stdout_eq(str![[r#" +No files changed, compilation skipped + +Ran 1 test for test/BacktraceVerbosity.t.sol:BacktraceVerbosityTest +[FAIL: Simple revert message] testRevert() ([GAS]) +Traces: + [..] BacktraceVerbosityTest::setUp() + ├─ [..] → new SimpleRevert@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f + │ └─ ← [Return] [..] + └─ ← [Stop] + + [..] BacktraceVerbosityTest::testRevert() + ├─ [..] SimpleRevert::doRevert() [staticcall] + │ └─ ← [Revert] Simple revert message + └─ ← [Revert] Simple revert message + +Backtrace: + at SimpleRevert.doRevert + at BacktraceVerbosityTest.testRevert + +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); + + // -vvvvv (verbosity 5): traces with setup, storage changes, and backtrace WITH source + // locations. + cmd.forge_fuse() + .args(["test", "--mc", "BacktraceVerbosityTest", "-vvvvv"]) + .assert_failure() + .stdout_eq(str![[r#" +No files changed, compilation skipped + +Ran 1 test for test/BacktraceVerbosity.t.sol:BacktraceVerbosityTest +[FAIL: Simple revert message] testRevert() ([GAS]) +Traces: + [..] BacktraceVerbosityTest::setUp() + ├─ [..] → new SimpleRevert@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f + │ └─ ← [Return] [..] + └─ ← [Stop] + + [..] BacktraceVerbosityTest::testRevert() + ├─ [..] SimpleRevert::doRevert() [staticcall] + │ └─ ← [Revert] Simple revert message + └─ ← [Revert] Simple revert message + +Backtrace: + at SimpleRevert.doRevert (src/SimpleRevert.sol:[..]:[..]) + at BacktraceVerbosityTest.testRevert (test/BacktraceVerbosity.t.sol:[..]:[..]) + +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} diff --git a/crates/forge/tests/cli/bind.rs b/crates/forge/tests/cli/bind.rs new file mode 100644 index 0000000000000..4b4fd8d031bcc --- /dev/null +++ b/crates/forge/tests/cli/bind.rs @@ -0,0 +1,665 @@ +use foundry_compilers::utils::read_json_file; +use foundry_config::SolcReq; +use foundry_test_utils::{TestProject, cargo_profile_dir}; +use std::{fs, path::Path, process::Command}; + +// Keep each generated crate isolated while reusing its dependencies across binding tests. +// Cargo locks the shared target directory across nextest processes and fingerprints each crate. +pub(super) fn bindings_cargo(bindings_path: &Path, subcommand: &str) -> Command { + // Preserve the generated manifest, including its conditional serde_with dependency. + // Refresh these fixtures from the corresponding generated crates when updating bindings deps. + let manifest = fs::read_to_string(bindings_path.join("Cargo.toml")) + .unwrap() + .parse::() + .unwrap(); + let lock = if manifest["dependencies"].get("serde_with").is_some() { + include_str!("../fixtures/bind-with-serde-with/Cargo.lock") + } else { + include_str!("../fixtures/bind/Cargo.lock") + }; + fs::write(bindings_path.join("Cargo.lock"), lock).unwrap(); + + let mut cmd = Command::new("cargo"); + cmd.current_dir(bindings_path) + .args([subcommand, "--locked"]) + .env("CARGO_TARGET_DIR", cargo_profile_dir().join("bind-test-target")); + cmd +} + +fn assert_bindings_compile(bindings_path: &Path) { + let out = bindings_cargo(bindings_path, "check") + .arg("--tests") + .output() + .expect("failed to run cargo check"); + + assert!( + out.status.success(), + "generated bindings should compile\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); +} + +fn add_duplicate_events_source(prj: &TestProject) { + prj.add_source( + "DuplicateEvents.sol", + r#" +interface IERC20Events { + event Transfer(address indexed from, address indexed to, uint256 value); +} + +library EventsLib { + event Transfer(address indexed from, address indexed to, uint256 shares); +} + +contract DuplicateEvents is IERC20Events { + function emitTransfer() external { + emit EventsLib.Transfer(msg.sender, address(0), 0); + } +} +"#, + ); +} + +fn add_namespaced_type_sources(prj: &TestProject) { + prj.add_source( + "External.sol", + r#" +library External { + struct Something { + AnotherThing value; + } + + struct AnotherThing { + uint64 number; + } +} +"#, + ); + prj.add_source( + "MyContract.sol", + r#" +import {External} from "./External.sol"; + +contract MyContract { + event EmittedLog(External.Something value); +} +"#, + ); +} + +fn add_large_array_source(prj: &TestProject) { + prj.add_source( + "BigArrays.sol", + r#" +interface serde_with { + struct LargeArrays { + uint256[48] values; + } + + error LargeArrayError(uint256[48]); + event LargeArrayEvent(uint256[48] values); + + function consume(uint256[48] calldata) external; + function fixedArray() external returns (uint256[48] memory); + function nestedArray() external returns (uint256[48][] memory); + function wrappedArray() external returns (LargeArrays memory); +} +"#, + ); +} + +fn add_namespaced_type_derives_test(bindings_path: &Path) { + let tests_path = bindings_path.join("tests"); + fs::create_dir_all(&tests_path).unwrap(); + fs::write( + tests_path.join("derives.rs"), + r#" +use foundry_contracts::my_contract::MyContract::EmittedLog; +use std::{fmt::Debug, hash::Hash}; + +fn assert_all_derives() {} + +#[test] +fn event_has_all_derives() { + assert_all_derives::(); +} +"#, + ) + .unwrap(); +} + +// +#[forgetest] +fn bind_unlinked_bytecode(prj: _, cmd: _) { + prj.add_source( + "SomeLibContract.sol", + r#" +library SomeLib { + function add(uint256 a, uint256 b) external pure returns (uint256) { + return a + b; + } +} + +contract SomeLibContract { + function add(uint256 a, uint256 b) public pure returns (uint256) { + return SomeLib.add(a, b); + } +} + "#, + ); + cmd.args(["bind", "--select", "SomeLibContract"]) + .assert_success() + .stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +"#]]) + .stderr_eq(str![[r#" +Generating bindings for 1 contracts +Bindings have been generated to [..] + +"#]]); +} + +#[forgetest_init] +fn bind_status_on_stderr(prj: _, cmd: _) { + prj.initialize_default_contracts(); + prj.clear(); + cmd.arg("bind").assert_success().stderr_eq(str![[r#" +Generating bindings for [..] contracts +Bindings have been generated to [..] + +"#]]); +} + +#[forgetest_init] +fn bind_writes_abi_only_artifacts(prj: _, cmd: _) { + prj.add_source( + "BindTarget.sol", + r#" +contract BindTarget { + function value() public pure returns (uint256) { + return 1; + } +} +"#, + ); + prj.clear(); + + cmd.args(["bind", "--select", "^BindTarget$"]).assert_success(); + + let artifact_path = prj.paths().artifacts.join("BindTarget.sol/BindTarget.json"); + let artifact: serde_json::Value = read_json_file(&artifact_path).unwrap(); + let abi = artifact["abi"].as_array().expect("bind artifact should include ABI"); + assert!(!abi.is_empty()); + assert!(artifact["bytecode"]["object"].as_str().is_none()); + assert!(artifact["deployedBytecode"]["object"].as_str().is_none()); +} + +// +#[forgetest] +fn bind_generates_enum_variants(prj: _, cmd: _) { + prj.add_source( + "EnumUser.sol", + r#" +contract EnumUser { + enum Status { Pending, Active } + + function echo(Status status) external pure returns (Status) { + return status; + } +} +"#, + ); + + cmd.args(["bind", "--select", "^EnumUser$"]).assert_success(); + + let bindings_path = prj.root().join("out/bindings"); + let tests_path = bindings_path.join("tests"); + let enum_test = r#" +use foundry_contracts::enum_user::EnumUser::{Status, echoCall}; + +#[test] +fn enum_variants_are_typed() { + let call = echoCall { status: Status::Active }; + assert_eq!(call.status, Status::Active); +} +"#; + fs::create_dir_all(&tests_path).unwrap(); + fs::write(tests_path.join("enums.rs"), enum_test).unwrap(); + assert_bindings_compile(&bindings_path); + + cmd.forge_fuse().args(["bind", "--skip-build", "--select", "^EnumUser$"]).assert_success(); + + prj.add_source( + "EnumUser.sol", + r#" +contract EnumUser { + enum Status { Active, Pending } + + function echo(Status status) external pure returns (Status) { + return status; + } +} +"#, + ); + cmd.forge_fuse() + .args(["bind", "--skip-build", "--overwrite", "--select", "^EnumUser$"]) + .assert_success(); + + let binding = fs::read_to_string(bindings_path.join("src/enum_user.rs")).unwrap(); + assert!(binding.contains("pub struct Status"), "{binding}"); + assert!(!binding.contains("pub enum Status"), "{binding}"); +} + +#[forgetest] +fn bind_skip_build_does_not_require_compiler(prj: _, cmd: _) { + prj.add_source( + "EnumUser.sol", + r#" +contract EnumUser { + enum Status { Pending, Active } + function echo(Status status) external pure returns (Status) { return status; } +} +"#, + ); + cmd.arg("build").assert_success(); + + let missing_solc = prj.root().join("missing-solc"); + prj.update_config(|config| config.solc = Some(SolcReq::Local(missing_solc))); + cmd.forge_fuse().args(["bind", "--skip-build", "--select", "^EnumUser$"]).assert_success(); + + let binding = fs::read_to_string(prj.root().join("out/bindings/src/enum_user.rs")).unwrap(); + assert!(binding.contains("pub enum Status"), "{binding}"); +} + +#[forgetest] +fn bind_skip_build_keeps_enums_with_external_artifacts(prj: _, cmd: _) { + prj.add_source( + "EnumUser.sol", + r#" +contract EnumUser { + enum Status { Pending, Active } + function echo(Status status) external pure returns (Status) { return status; } +} +"#, + ); + cmd.arg("build").assert_success(); + + let artifact = prj.paths().artifacts.join("EnumUser.sol/EnumUser.json"); + let external = prj.paths().artifacts.join(".external/fixture/app/External.json"); + fs::create_dir_all(external.parent().unwrap()).unwrap(); + fs::copy(artifact, external).unwrap(); + + cmd.forge_fuse() + .args(["bind", "--skip-build", "--select", "^(EnumUser|External)$"]) + .assert_success(); + + let binding = fs::read_to_string(prj.root().join("out/bindings/src/enum_user.rs")).unwrap(); + assert!(binding.contains("pub enum Status"), "{binding}"); +} + +#[forgetest] +fn bind_stale_untracked_enum_artifacts_fall_back_to_udvt(prj: _, cmd: _) { + prj.add_source( + "A.sol", + r#" +contract EnumUser { + enum Status { Pending, Active } + function echo(Status status) external pure returns (Status) { return status; } +} +"#, + ); + cmd.arg("build").assert_success(); + + fs::remove_file(prj.paths().sources.join("A.sol")).unwrap(); + prj.add_source( + "Z.sol", + r#" +contract EnumUser { + enum Status { Active, Pending } + function echo(Status status) external pure returns (Status) { return status; } +} +"#, + ); + cmd.forge_fuse().arg("build").assert_success(); + cmd.forge_fuse() + .args(["bind", "--skip-build", "--overwrite", "--select", "^EnumUser$"]) + .assert_success(); + + let binding = fs::read_to_string(prj.root().join("out/bindings/src/enum_user.rs")).unwrap(); + assert!(binding.contains("pub struct Status"), "{binding}"); + assert!(!binding.contains("pub enum Status"), "{binding}"); +} + +#[forgetest] +fn bind_ambiguous_enum_definitions_fall_back_to_udvt(prj: _, cmd: _) { + prj.add_source( + "A.sol", + r#" +contract Duplicate { + enum Status { A } + function status(Status value) external pure returns (Status) { return value; } +} +"#, + ); + prj.add_source( + "B.sol", + r#" +contract Duplicate { + enum Status { B } + function status(Status value) external pure returns (Status) { return value; } +} +"#, + ); + + cmd.args(["bind", "--select", "^Duplicate$"]).assert_success(); + + let binding = fs::read_to_string(prj.root().join("out/bindings/src/duplicate.rs")).unwrap(); + assert!(binding.contains("pub struct Status"), "{binding}"); + assert!(!binding.contains("pub enum Status"), "{binding}"); +} + +// +#[forgetest] +fn bind_event_module_name_shadowing(prj: _, cmd: _) { + prj.add_source( + "IExampleContract.sol", + r#" +interface IExampleContractEvents { + struct SomeEventData { + address sender; + address receiver; + } + + event SomeEvent(SomeEventData eventData); +} + +interface MyIExampleContractEvents { + struct MyEventData { + address sender; + } + + event MyEvent(MyEventData eventData); +} + +interface IExampleContract is IExampleContractEvents, MyIExampleContractEvents {} +"#, + ); + + cmd.args(["bind", "--select", "^IExampleContract$", "--optimize"]) + .assert_success() + .stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +"#]]) + .stderr_eq(str![[r#" +Generating bindings for 1 contracts +Bindings have been generated to [..] + +"#]]); + + cmd.forge_fuse() + .args(["bind", "--select", "^IExampleContract$", "--optimize"]) + .assert_success() + .stderr_eq(str![[r#" +Bindings found. Checking for consistency. +Checking bindings for 1 contracts +OK. + +"#]]); + + let bindings_path = prj.root().join("out/bindings"); + assert_bindings_compile(&bindings_path); +} + +#[forgetest] +fn bind_event_module_prefix_does_not_shadow_aliases(prj: _, cmd: _) { + prj.add_source( + "alloy_sol_types.sol", + r#" +interface alloy_sol_types { + event Ping(address sender); +} +"#, + ); + + cmd.args(["bind", "--select", "^alloy_sol_types$", "--optimize"]) + .assert_success() + .stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +"#]]) + .stderr_eq(str![[r#" +Generating bindings for 1 contracts +Bindings have been generated to [..] + +"#]]); + + let bindings_path = prj.root().join("out/bindings"); + assert_bindings_compile(&bindings_path); +} + +// +#[forgetest] +fn bind_dedups_canonical_event_signatures(prj: _, cmd: _) { + add_duplicate_events_source(&prj); + + cmd.args(["bind", "--select", "^DuplicateEvents$"]).assert_success(); +} + +// +#[forgetest] +fn bind_namespaced_custom_type_derives(prj: _, cmd: _) { + add_namespaced_type_sources(&prj); + + cmd.args(["bind", "--select", "^MyContract$"]).assert_success(); +} + +// +#[forgetest] +fn bind_large_fixed_arrays(prj: _, cmd: _) { + add_large_array_source(&prj); + + cmd.args(["bind", "--select", "^serde_with$"]) + .assert_success() + .stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +"#]]) + .stderr_eq(str![[r#" +Generating bindings for 1 contracts +Bindings have been generated to [..] + +"#]]); + + cmd.forge_fuse().args(["bind", "--select", "^serde_with$"]).assert_success().stderr_eq(str![[ + r#" +Bindings found. Checking for consistency. +Checking bindings for 1 contracts +OK. + +"# + ]]); +} + +#[forgetest] +fn bind_regression_fixtures_compile(prj: _, cmd: _) { + add_duplicate_events_source(&prj); + add_namespaced_type_sources(&prj); + add_large_array_source(&prj); + + cmd.args(["bind", "--select", "^(DuplicateEvents|MyContract|serde_with)$"]).assert_success(); + + let bindings_path = prj.root().join("out/bindings"); + add_namespaced_type_derives_test(&bindings_path); + assert_bindings_compile(&bindings_path); +} + +#[forgetest] +fn bind_single_file_crate_and_module_match(prj: _, cmd: _) { + add_duplicate_events_source(&prj); + add_namespaced_type_sources(&prj); + + let crate_path = prj.root().join("crate-bindings"); + cmd.args([ + "bind", + "--select", + "^(DuplicateEvents|MyContract)$", + "--single-file", + "--bindings-path", + crate_path.to_str().unwrap(), + ]) + .assert_success(); + assert_bindings_compile(&crate_path); + + let module_path = prj.root().join("module-bindings"); + cmd.forge_fuse() + .args([ + "bind", + "--skip-build", + "--select", + "^(DuplicateEvents|MyContract)$", + "--single-file", + "--module", + "--bindings-path", + module_path.to_str().unwrap(), + ]) + .assert_success(); + + assert_eq!( + fs::read(crate_path.join("src/lib.rs")).unwrap(), + fs::read(module_path.join("mod.rs")).unwrap() + ); +} + +// `$` is valid in Solidity identifiers but not Rust identifiers; `forge bind` used to panic +// instead of sanitizing it. +#[forgetest] +fn bind_dollar_sign_in_contract_name(prj: _, cmd: _) { + prj.add_source( + "Foo.sol", + r#" +contract Foo$Bar { + uint256 public value; + + function setValue(uint256 v) public { + value = v; + } +} +"#, + ); + + cmd.args(["bind"]).assert_success(); + + let bindings_path = prj.root().join("out/bindings"); + let binding = fs::read_to_string(bindings_path.join("src/foo_bar.rs")).unwrap(); + assert!(binding.contains("pub mod Foo_Bar"), "{binding}"); + assert!(!binding.contains('$'), "{binding}"); + assert_bindings_compile(&bindings_path); +} + +// `--single-file` bindings used to pass the consistency check regardless of their contents. +#[forgetest] +fn bind_single_file_checks_consistency(prj: _, cmd: _) { + prj.add_source("Foo.sol", "contract Foo { function a() external {} }"); + + let module_path = prj.root().join("module-bindings"); + let crate_args = ["bind", "--select", "^Foo$", "--single-file"]; + let module_args = [ + "bind", + "--select", + "^Foo$", + "--single-file", + "--module", + "--bindings-path", + module_path.to_str().unwrap(), + ]; + cmd.args(crate_args).assert_success(); + cmd.forge_fuse().args(module_args).assert_success(); + + for args in [&crate_args[..], &module_args[..]] { + cmd.forge_fuse().args(args).assert_success().stderr_eq(str![[r#" +Bindings found. Checking for consistency. +Checking bindings for 1 contracts +OK. + +"#]]); + } + + prj.add_source("Foo.sol", "contract Foo { function a() external {} function b() external {} }"); + + cmd.forge_fuse().args(crate_args).assert_failure().stderr_eq(str![[r#" +Bindings found. Checking for consistency. +Checking bindings for 1 contracts +Error: File contents do not match expected contents for "[..]/out/bindings/src/lib.rs" + +"#]]); + cmd.forge_fuse().args(module_args).assert_failure().stderr_eq(str![[r#" +Bindings found. Checking for consistency. +Checking bindings for 1 contracts +Error: File contents do not match expected contents for "[..]/module-bindings/mod.rs" + +"#]]); +} + +#[forgetest] +fn bind_single_file_checks_multiple_contracts(prj: _, cmd: _) { + // The FooCalls contract must not shadow the call enum inside the Foo binding. + prj.add_source( + "Foo.sol", + "contract Foo { function a() external {} } contract FooCalls { function b() external {} }", + ); + + let module_path = prj.root().join("module-bindings"); + let crate_args = ["bind", "--select", "^(Foo|FooCalls)$", "--single-file"]; + let module_args = [ + "bind", + "--select", + "^(Foo|FooCalls)$", + "--single-file", + "--module", + "--bindings-path", + module_path.to_str().unwrap(), + ]; + let crate_file = prj.root().join("out/bindings/src/lib.rs"); + let module_file = module_path.join("mod.rs"); + for (args, file) in [(&crate_args[..], &crate_file), (&module_args[..], &module_file)] { + cmd.forge_fuse().args(args).assert_success(); + cmd.forge_fuse().args(args).assert_success().stderr_eq(str![[r#" +Bindings found. Checking for consistency. +Checking bindings for 2 contracts +OK. + +"#]]); + + // Whitespace changes must not invalidate otherwise identical bindings. + let contents = fs::read_to_string(file).unwrap(); + fs::write(file, format!("\n{contents}\n")).unwrap(); + cmd.forge_fuse().args(args).assert_success(); + } + + fs::remove_file(crate_file).unwrap(); + cmd.forge_fuse().args(crate_args).assert_failure().stderr_eq(str![[r#" +Bindings found. Checking for consistency. +Checking bindings for 2 contracts +Error: [..]/out/bindings/src/lib.rs is not a file + +"#]]); + + fs::remove_file(module_file).unwrap(); + cmd.forge_fuse().args(module_args).assert_failure().stderr_eq(str![[r#" +Bindings found. Checking for consistency. +Checking bindings for 2 contracts +Error: [..]/module-bindings/mod.rs is not a file + +"#]]); +} diff --git a/crates/forge/tests/cli/bind_json.rs b/crates/forge/tests/cli/bind_json.rs index fcc081f6b6f06..98eb589464a42 100644 --- a/crates/forge/tests/cli/bind_json.rs +++ b/crates/forge/tests/cli/bind_json.rs @@ -3,7 +3,8 @@ use foundry_test_utils::snapbox; // tests complete bind-json workflow // ensures that we can run forge-bind even if files are depending on yet non-existent bindings and // that generated bindings are correct -forgetest_init!(test_bind_json, |prj, cmd| { +#[forgetest_init] +fn test_bind_json(prj: _, cmd: _) { prj.add_test( "JsonBindings", r#" @@ -23,7 +24,7 @@ contract BindJsonTest is Test { address addrParam; } - function testTopLevel() public { + function testTopLevel() public pure { string memory json = '{"param1": 1, "param2": -1}'; TopLevelStruct memory topLevel = json.deserializeTopLevelStruct(); assertEq(topLevel.param1, 1); @@ -34,7 +35,7 @@ contract BindJsonTest is Test { assertEq(keccak256(abi.encode(deserialized)), keccak256(abi.encode(topLevel))); } - function testContractLevel() public { + function testContractLevel() public pure { ContractLevelStruct memory contractLevel = ContractLevelStruct({ param1: new address[][](2), addrParam: address(0xBEEF) @@ -48,10 +49,12 @@ contract BindJsonTest is Test { } } "#, - ) - .unwrap(); + ); + + cmd.arg("bind-json").assert_success().stderr_eq(snapbox::str![[r#" +Bindings written to [..] - cmd.arg("bind-json").assert_success(); +"#]]); snapbox::assert_data_eq!( snapbox::Data::read_from(&prj.root().join("utils/JsonBindings.sol"), None), @@ -70,7 +73,7 @@ interface Vm { function serializeJsonType(string calldata typeDescription, bytes memory value) external pure returns (string memory json); function serializeJsonType(string calldata objectKey, string calldata valueKey, string calldata typeDescription, bytes memory value) external returns (string memory json); } - +... library JsonBindings { Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); @@ -122,4 +125,4 @@ library JsonBindings { ); cmd.forge_fuse().args(["test"]).assert_success(); -}); +} diff --git a/crates/forge/tests/cli/build.rs b/crates/forge/tests/cli/build.rs index c0dd0517551d8..25d8e13a9a861 100644 --- a/crates/forge/tests/cli/build.rs +++ b/crates/forge/tests/cli/build.rs @@ -1,8 +1,572 @@ use crate::utils::generate_large_init_contract; -use foundry_test_utils::{forgetest, snapbox::IntoData, str}; +use foundry_compilers::artifacts::{BytecodeHash, EvmVersion}; +use foundry_config::{CompilationRestrictions, SettingsOverrides}; +use foundry_test_utils::{forgetest, forgetest_init, snapbox::IntoData, str, util::OutputExt}; use globset::Glob; +use std::{ + collections::BTreeMap, + fs, + io::Write, + path::{Path, PathBuf}, + process::{Command, Stdio}, +}; -forgetest_init!(can_parse_build_filters, |prj, cmd| { +#[cfg(unix)] +use anvil::{NodeConfig, spawn}; +#[cfg(unix)] +use foundry_config::ExternalCompiler; +#[cfg(unix)] +use std::os::unix::fs::{PermissionsExt, symlink}; + +fn git(root: &Path, args: &[&str]) -> String { + let output = Command::new("git").current_dir(root).args(args).output().unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + String::from_utf8(output.stdout).unwrap().trim().to_string() +} + +fn add_local_submodule(root: &Path, path: &str) -> String { + let source = root.join("lib/forge-std"); + let output = Command::new("git") + .current_dir(root) + .args(["-c", "protocol.file.allow=always", "submodule", "add", "--"]) + .arg(source) + .arg(path) + .output() + .unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + git(&root.join(path), &["rev-parse", "HEAD"]) +} + +#[cfg(unix)] +#[forgetest] +async fn external_compiler_builds_and_caches_native_project(prj: _, cmd: _) { + let native = prj.root().join("native"); + fs::create_dir_all(native.join("src")).unwrap(); + fs::write(native.join("fe.toml"), "[ingot]\nname = \"app\"\nversion = \"1.0.0\"\n").unwrap(); + fs::write(native.join("src/lib.fe"), "pub contract Counter {}\n").unwrap(); + fs::write(native.join("src/other.fe"), "pub contract Other {}\n").unwrap(); + + let adapter = prj.root().join("adapter.sh"); + fs::write( + &adapter, + r#"#!/bin/sh +read -r initialize +printf '%s\n' '{"id":1,"result":{"protocol":"1.0"}}' +read -r discover +app='{"id":"app","compiler":{"name":"fe","version":"26.3.0"},"inputs":["native/fe.toml","native/src/lib.fe"],"capabilities":["build/1"],"effectiveSettings":{"optimization":"s"},"cacheable":true}' +other='{"id":"other","compiler":{"name":"fe","version":"26.3.0"},"inputs":["native/src/other.fe"],"capabilities":["build/1"],"cacheable":true}' +case "$discover" in + *'.sol'*) units='' ;; + *'lib.fe'*) units="$app" ;; + *) units="$app"; [ ! -e native/src/other.fe ] || units="$app,$other" ;; +esac +printf '{"id":2,"result":{"units":[%s]}}\n' "$units" +id=3 +while read -r compile; do + source=lib.fe; name=Counter + case "$compile" in *'"unit":"other"'*) source=other.fe; name=Other ;; esac + : > "$0.compiled" + diagnostics='[]' + if [ -e "$0.warning" ]; then + diagnostics='[{"severity":"warning","message":"fixture warning"}]' + fi + printf '%s\n' "{\"id\":$id,\"result\":{\"diagnostics\":$diagnostics,\"artifacts\":[{\"source\":\"native/src/$source\",\"name\":\"$name\",\"metadata\":{\"language\":\"Fe\"},\"contract\":{\"abi\":[{\"type\":\"function\",\"name\":\"run\",\"inputs\":[],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"testExternalArtifactIsDeployable\",\"inputs\":[],\"outputs\":[],\"stateMutability\":\"nonpayable\"}],\"evm\":{\"bytecode\":{\"object\":\"0x6001600c60003960016000f300\"},\"deployedBytecode\":{\"object\":\"0x00\"}}}},{\"source\":\"native/src/$source\",\"name\":\"CreationOnly\",\"sourceId\":1,\"contract\":{\"evm\":{\"bytecode\":{\"object\":\"0x00\"}}}}]}}" + id=$((id + 1)) +done +"#, + ) + .unwrap(); + let mut permissions = fs::metadata(&adapter).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&adapter, permissions).unwrap(); + prj.update_config(|config| { + config.external_compilers.push(ExternalCompiler { + id: "fixture".to_string(), + command: adapter.clone(), + args: Vec::new(), + roots: vec!["native".into()], + settings: [("optimization".to_string(), "s".into())].into_iter().collect(), + }); + }); + + let output = cmd.forge_fuse().args(["build", "native", "--json"]).assert_success(); + let build_json: serde_json::Value = + serde_json::from_slice(&output.get_output().stdout).unwrap(); + let external_source = native.join("src/lib.fe"); + let counter = &build_json["contracts"][external_source.to_string_lossy().as_ref()]["Counter"] + [0]["contract"]; + assert_eq!(counter["abi"][0]["name"], "run"); + assert_eq!(counter["evm"]["bytecode"]["object"], "6001600c60003960016000f300"); + + let builtin = prj.add_source("Builtin.sol", "contract Builtin {}"); + let output = cmd.forge_fuse().args(["build", "--json", "--force"]).assert_success(); + let build_json: serde_json::Value = + serde_json::from_slice(&output.get_output().stdout).unwrap(); + assert!( + !build_json["contracts"][external_source.to_string_lossy().as_ref()]["Counter"].is_null() + ); + assert!(!build_json["contracts"][builtin.to_string_lossy().as_ref()]["Builtin"].is_null()); + + let invoked = adapter.with_extension("sh.compiled"); + assert!(invoked.exists()); + let artifact = prj.root().join("out/.external/fixture/app/native/src/lib.fe/Counter.json"); + let artifact_json: serde_json::Value = + serde_json::from_slice(&fs::read(&artifact).unwrap()).unwrap(); + assert_eq!(artifact_json["bytecode"]["object"], "0x6001600c60003960016000f300"); + assert_eq!(artifact_json["rawMetadata"], r#"{"language":"Fe"}"#); + + let retained_cache = prj.root().join("cache/external-compilers/fixture/other.json"); + let retained_artifact = + prj.root().join("out/.external/fixture/other/native/src/other.fe/Other.json"); + let cache_before = fs::read(&retained_cache).unwrap(); + let artifact_before = fs::read(&retained_artifact).unwrap(); + fs::remove_file(&invoked).unwrap(); + cmd.forge_fuse().args(["build", "native/src/lib.fe"]).assert_success(); + assert_eq!(fs::read(&retained_cache).unwrap(), cache_before); + assert_eq!(fs::read(&retained_artifact).unwrap(), artifact_before); + cmd.forge_fuse().arg("build").assert_success(); + cmd.forge_fuse().args(["test", "--list"]).assert_success(); + cmd.forge_fuse().arg("build").assert_success(); + assert!(!invoked.exists(), "switching Forge commands unexpectedly recompiled the units"); + assert_eq!(fs::read(&retained_cache).unwrap(), cache_before); + assert_eq!(fs::read(&retained_artifact).unwrap(), artifact_before); + + fs::remove_file(native.join("src/other.fe")).unwrap(); + let read_only_commands = [ + vec!["inspect", "native/src/lib.fe:Counter", "abi"], + vec!["inspect", "native/src/lib.fe:Counter", "artifact"], + vec!["inspect", "native/src/lib.fe:Counter", "bytecode"], + vec!["inspect", "native/src/lib.fe:Counter", "deployedBytecode"], + vec!["test", "--list"], + vec!["selectors", "list"], + vec!["selectors", "find", "c0406226"], + vec!["selectors", "collision", "Counter", "Counter"], + vec!["selectors", "collision", "Counter", "Counter", "--extra-output", "metadata"], + ]; + let counter_before = fs::read(&artifact).unwrap(); + for build_info in [false, true] { + prj.update_config(|config| config.build_info = build_info); + for args in &read_only_commands { + cmd.forge_fuse().args(args).assert_success(); + assert!(!invoked.exists(), "read-only command recompiled cached units: {args:?}"); + assert_eq!(fs::read(&retained_cache).unwrap(), cache_before); + assert_eq!(fs::read(&retained_artifact).unwrap(), artifact_before); + assert_eq!(fs::read(&artifact).unwrap(), counter_before); + } + } + prj.update_config(|config| config.build_info = false); + cmd.forge_fuse().arg("build").assert_success(); + assert!(!retained_cache.exists(), "complete discovery retained a stale cache entry"); + assert!(!retained_artifact.exists(), "complete discovery retained a stale artifact"); + + fs::write(native.join("src/lib.fe"), "pub contract Counter { pub fn value() -> u256 { 1 } }\n") + .unwrap(); + cmd.forge_fuse().arg("build").assert_success(); + assert!(invoked.exists(), "source change did not invalidate external compiler cache"); + + fs::remove_dir_all(prj.root().join("out/.external")).unwrap(); + fs::remove_dir_all(prj.root().join("cache/external-compilers")).unwrap(); + prj.update_config(|config| config.build_info = true); + for args in &read_only_commands { + fs::remove_file(&invoked).unwrap(); + cmd.forge_fuse().args(args).assert_success(); + assert!(invoked.exists(), "read-only command did not compile an uncached unit: {args:?}"); + assert!(!prj.root().join("out/.external").exists()); + assert!(!prj.root().join("cache/external-compilers").exists()); + } + cmd.forge_fuse().arg("build").assert_success(); + let cache = prj.root().join("cache/external-compilers/fixture/app.json"); + let cache_before = fs::read(&cache).unwrap(); + fs::remove_dir_all(prj.root().join("out/.external")).unwrap(); + fs::remove_file(&invoked).unwrap(); + for args in &read_only_commands { + cmd.forge_fuse().args(args).assert_success(); + assert!(!invoked.exists(), "read-only command did not reuse the external cache: {args:?}"); + assert!(!prj.root().join("out/.external").exists()); + assert_eq!(fs::read(&cache).unwrap(), cache_before); + } + + prj.update_config(|config| config.build_info = false); + cmd.forge_fuse().args(["bind", "--select", "^Counter$"]).assert_success(); + assert!(prj.root().join("out/bindings/src/counter.rs").is_file()); + let (_api, handle) = spawn(NodeConfig::test()).await; + cmd.forge_fuse() + .args([ + "create", + "native/src/lib.fe:Counter", + "--rpc-url", + &handle.http_endpoint(), + "--unlocked", + "--from", + &handle.dev_accounts().next().unwrap().to_string(), + "--broadcast", + ]) + .assert_success() + .stdout_eq(str![[r#" +[..] +Deployer: [..] +Deployed to: [..] +[TX_HASH] + +"#]]); + + let output = cmd.forge_fuse().args(["selectors", "list", "--no-group"]).assert_success(); + assert!(output.get_output().stdout_lossy().contains("run()")); + cmd.forge_fuse().args(["script", "native/src/lib.fe:Counter"]).assert_success(); + + prj.add_script( + "UseExternal.s.sol", + r#"interface Vm { + function getCode(string calldata artifact) external returns (bytes memory); +} + +contract UseExternal { + Vm private constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + function run() external { + bytes memory code = vm.getCode("Counter"); + address deployed; + assembly ("memory-safe") { + deployed := create(0, add(code, 0x20), mload(code)) + } + require(deployed != address(0) && deployed.code.length == 1); + } +} +"#, + ); + fs::remove_dir_all(prj.root().join("out/.external")).unwrap(); + fs::remove_dir_all(prj.root().join("cache/external-compilers")).unwrap(); + cmd.forge_fuse().args(["script", "script/UseExternal.s.sol:UseExternal"]).assert_success(); + + cmd.forge_fuse() + .args(["test", "--list", "--json"]) + .assert_success() + .stdout_eq(str![[r#"{}"#]].is_json()); + + fs::create_dir_all(prj.root().join("test")).unwrap(); + fs::write( + prj.root().join("test/ExternalArtifact.t.sol"), + r#"pragma solidity >=0.8.0; + +interface Vm { + function getCode(string calldata artifact) external returns (bytes memory); +} + +contract ExternalArtifactTest { + Vm private constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + function testExternalArtifactIsDeployable() public { + bytes memory code = vm.getCode("Counter"); + address deployed; + assembly ("memory-safe") { + deployed := create(0, add(code, 0x20), mload(code)) + } + require(deployed != address(0) && deployed.code.length == 1); + } +} +"#, + ) + .unwrap(); + fs::remove_dir_all(prj.root().join("out/.external")).unwrap(); + fs::remove_dir_all(prj.root().join("cache/external-compilers")).unwrap(); + cmd.forge_fuse() + .args(["test", "--match-test", "testExternalArtifactIsDeployable"]) + .assert_success(); + + for _ in 0..2 { + cmd.forge_fuse().args(["coverage", "--report", "lcov"]).assert_success(); + } + let coverage = fs::read_to_string(prj.root().join("lcov.info")).unwrap(); + assert!(coverage.contains("SF:script/UseExternal.s.sol"), "{coverage}"); + assert!(!coverage.contains("native/")); + + let adapter_script = fs::read_to_string(&adapter).unwrap(); + fs::write( + &adapter, + adapter_script.replacen(r#"["build/1"]"#, r#"["build/1","forge-tests/1"]"#, 1), + ) + .unwrap(); + cmd.forge_fuse().args(["test", "--match-contract", "^Counter$"]).assert_success().stdout_eq( + str![[r#" +[..] + +Ran 1 test for native/src/lib.fe:Counter +[PASS] testExternalArtifactIsDeployable() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]], + ); + for name in ["Counter.V1", "Counter-V1"] { + fs::write(&adapter, adapter_script.replace("name=Counter", &format!("name={name}"))) + .unwrap(); + cmd.forge_fuse().arg("build").assert_failure().stderr_eq(format!( + "Error: external compiler contract name must contain only ASCII letters, digits, or underscores: {name}\n" + )); + } + fs::write(&adapter, adapter_script.replace("source=lib.fe", "source=missing.fe")).unwrap(); + cmd.forge_fuse().arg("build").assert_failure().stderr_eq(str![[r#" +Error: failed to canonicalize [..]/native/src/missing.fe + +Context: +- [..] + +"#]]); + fs::write(&adapter, &adapter_script).unwrap(); + + let warning = adapter.with_extension("sh.warning"); + fs::write(&warning, "").unwrap(); + fs::remove_dir_all(prj.root().join("cache/external-compilers")).unwrap(); + cmd.forge_fuse().args(["build", "--deny", "warnings"]).assert_failure(); + cmd.forge_fuse().args(["build", "--deny", "never"]).assert_success(); + fs::remove_file(&invoked).unwrap(); + cmd.forge_fuse().args(["build", "--deny", "warnings"]).assert_failure(); + assert!(!invoked.exists(), "cached diagnostics unexpectedly recompiled the external unit"); + + fs::remove_file(warning).unwrap(); + for malformed in [false, true] { + if malformed { + fs::write(&cache, "not json").unwrap(); + cmd.forge_fuse().arg("build").assert_failure(); + } + cmd.forge_fuse().arg("clean").assert_success().stderr_eq(""); + assert!(!prj.root().join("out/.external").exists()); + assert!(!prj.root().join("cache/external-compilers").exists()); + cmd.forge_fuse().arg("build").assert_success(); + assert!(invoked.exists(), "clean retained the external compiler cache"); + fs::remove_file(&invoked).unwrap(); + } + for args in &read_only_commands { + prj.update_config(|config| config.force = true); + cmd.forge_fuse().args(args).assert_success(); + assert!(!prj.root().join("out/.external").exists()); + assert!(!prj.root().join("cache/external-compilers").exists()); + prj.update_config(|config| config.force = false); + cmd.forge_fuse().arg("build").assert_success(); + } + + cmd.forge_fuse().arg("clean").assert_success(); + let external = tempfile::tempdir().unwrap(); + let sentinel = external.path().join("keep"); + fs::write(&sentinel, "unchanged").unwrap(); + fs::create_dir_all(prj.root().join("cache")).unwrap(); + symlink(external.path(), prj.root().join("cache/external-compilers")).unwrap(); + cmd.forge_fuse().arg("clean").assert_success().stderr_eq(""); + assert!(prj.root().join("cache/external-compilers").symlink_metadata().is_err()); + assert_eq!(fs::read_to_string(sentinel).unwrap(), "unchanged"); + + for (response, error) in [ + ("", "adapter exited before responding to `initialize`"), + ( + r#"{"id":1,"result":{"protocol":"2.0"}}"#, + "external compiler `fixture` selected unsupported protocol `2.0`", + ), + ] { + fs::write(&adapter, format!( + "#!/bin/sh\nread -r initialize\nprintf '%s\\n' 'native compiler diagnostic' >&2\nprintf '%s' '{response}'\n" + )).unwrap(); + cmd.forge_fuse().arg("build").assert_failure().stderr_eq(format!( + "Error: adapter stderr: native compiler diagnostic\n\nContext:\n- {error}\n" + )); + } +} + +#[cfg(unix)] +#[forgetest] +fn local_compiler_runs_without_warning(prj: _, cmd: _) { + let solc = prj.root().join("payload"); + let invoked = prj.root().join("payload.invoked"); + fs::write( + &solc, + r#"#!/bin/sh +touch "$0.invoked" +if [ "$1" = "--version" ]; then + echo "solc, the solidity compiler commandline interface" + echo "Version: 0.8.35+commit.69074fbd" + exit 0 +fi +exit 1 +"#, + ) + .unwrap(); + let mut permissions = fs::metadata(&solc).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&solc, permissions).unwrap(); + prj.add_source("Contract", "contract Contract {}"); + prj.update_config(|config| { + config.solc = Some(foundry_config::SolcReq::Local(solc.clone())); + }); + + let output = cmd.arg("build").assert_failure(); + let stderr = output.get_output().stderr_lossy(); + assert!(!stderr.contains("configured to use a local compiler executable"), "{stderr}"); + assert!(invoked.exists(), "local compiler did not run"); +} + +#[forgetest] +fn project_dotenv_loads_without_warning(prj: _, cmd: _) { + fs::write(prj.root().join(".env"), "FOUNDRY_SRC=dotenv-src").unwrap(); + + let output = cmd.args(["config", "--json"]).assert_success(); + let stderr = output.get_output().stderr_lossy(); + assert!(!stderr.contains("Warning: loading project dotenv"), "{stderr}"); + let config: serde_json::Value = serde_json::from_slice(&output.get_output().stdout).unwrap(); + assert_eq!(config["src"], "dotenv-src"); +} + +#[forgetest] +#[cfg(unix)] +fn can_build_physical_and_symlinked_dependency_configs(prj: _, cmd: _) { + let external = tempfile::tempdir().unwrap(); + let physical = prj.root().join("lib/linked"); + let linked = external.path().join("cache/actual-package"); + let write_dependency = |dependency: &std::path::Path| { + fs::create_dir_all(dependency.join("custom-source")).unwrap(); + fs::create_dir_all(dependency.join("vendor/inner/src")).unwrap(); + fs::create_dir_all(dependency.join("vendor/file/src")).unwrap(); + fs::write( + dependency.join("foundry.toml"), + r#" +[profile.default] +src = "custom-source" +remappings = ["special-alias/=vendor/inner/src/"] +"#, + ) + .unwrap(); + fs::write(dependency.join("remappings.txt"), "file-alias/=vendor/file/src/\n").unwrap(); + fs::write( + dependency.join("custom-source/Dep.sol"), + r#" +pragma solidity >=0.8.0; + +import {Thing} from "special-alias/Thing.sol"; +import {FromFile} from "file-alias/FromFile.sol"; + +contract Dep is Thing, FromFile {} +"#, + ) + .unwrap(); + fs::write( + dependency.join("vendor/inner/src/Thing.sol"), + r#" +pragma solidity >=0.8.0; + +contract Thing {} +"#, + ) + .unwrap(); + fs::write( + dependency.join("vendor/file/src/FromFile.sol"), + r#" +pragma solidity >=0.8.0; + +contract FromFile {} +"#, + ) + .unwrap(); + }; + + write_dependency(&physical); + write_dependency(&linked); + prj.add_raw_source( + "Root.sol", + r#" +pragma solidity >=0.8.0; + +import {Dep} from "linked/Dep.sol"; + +contract Root is Dep {} +"#, + ); + + let remappings = str![[r#" +file-alias/=lib/linked/vendor/file/src/ +linked/=lib/linked/custom-source/ +special-alias/=lib/linked/vendor/inner/src/ + +"#]]; + cmd.arg("remappings").assert_success().stdout_eq(remappings.clone()); + cmd.forge_fuse().arg("build").assert_success(); + + fs::remove_dir_all(&physical).unwrap(); + symlink(&linked, &physical).unwrap(); + cmd.forge_fuse().arg("clean").assert_success(); + cmd.forge_fuse().arg("remappings").assert_success().stdout_eq(remappings); + cmd.forge_fuse().arg("build").assert_success(); +} + +#[forgetest] +#[cfg(unix)] +fn can_build_symlinked_dependency_with_existing_standard_source(prj: _, cmd: _) { + let external = tempfile::tempdir().unwrap(); + let dependency = external.path().join("dependency"); + fs::create_dir_all(dependency.join("src")).unwrap(); + fs::write(dependency.join("foundry.toml"), "[profile.default]\nsrc = \"src\"\n").unwrap(); + fs::write(dependency.join("src/Dep.sol"), "pragma solidity >=0.8.0; contract Dep {}\n") + .unwrap(); + prj.update_config(|config| config.libs = vec!["node_modules".into()]); + fs::create_dir_all(prj.root().join("node_modules")).unwrap(); + symlink(&dependency, prj.root().join("node_modules/linked")).unwrap(); + prj.add_raw_source( + "Root.sol", + r#" +pragma solidity >=0.8.0; + +import {Dep} from "linked/src/Dep.sol"; + +contract Root is Dep {} +"#, + ); + + cmd.arg("remappings").assert_success().stdout_eq(str![[r#" +linked/=node_modules/linked/ + +"#]]); + cmd.forge_fuse().arg("build").assert_success(); +} + +#[forgetest] +#[cfg(unix)] +fn can_build_multiple_aliases_to_symlinked_dependency_config(prj: _, cmd: _) { + let external = tempfile::tempdir().unwrap(); + let dependency = external.path().join("dependency"); + fs::create_dir_all(dependency.join("custom-source")).unwrap(); + fs::write(dependency.join("foundry.toml"), "[profile.default]\nsrc = \"custom-source\"\n") + .unwrap(); + fs::write( + dependency.join("custom-source/Dep.sol"), + "pragma solidity >=0.8.0; contract Dep {}\n", + ) + .unwrap(); + symlink(&dependency, prj.root().join("lib/a-alias")).unwrap(); + symlink(&dependency, prj.root().join("lib/z-alias")).unwrap(); + prj.add_raw_source( + "Root.sol", + r#" +pragma solidity >=0.8.0; + +import {Dep as ADep} from "a-alias/Dep.sol"; +import {Dep as ZDep} from "z-alias/Dep.sol"; + +contract Root { + ADep private a; + ZDep private z; +} +"#, + ); + + cmd.arg("remappings").assert_success().stdout_eq(str![[r#" +a-alias/=lib/a-alias/custom-source/ +z-alias/=lib/z-alias/custom-source/ + +"#]]); + cmd.forge_fuse().arg("build").assert_success(); +} + +#[forgetest_init] +fn can_parse_build_filters(prj: _, cmd: _) { + prj.initialize_default_contracts(); prj.clear(); cmd.args(["build", "--names", "--skip", "tests", "scripts"]).assert_success().stdout_eq(str![ @@ -15,9 +579,10 @@ Compiler run successful! "#] ]); -}); +} -forgetest!(throws_on_conflicting_args, |prj, cmd| { +#[forgetest] +fn throws_on_conflicting_args(prj: _, cmd: _) { prj.clear(); cmd.args(["compile", "--format-json", "--quiet"]).assert_failure().stderr_eq(str![[r#" @@ -28,10 +593,11 @@ Usage: forge[..] build --json [PATHS]... For more information, try '--help'. "#]]); -}); +} // tests that json is printed when --format-json is passed -forgetest!(compile_json, |prj, cmd| { +#[forgetest] +fn compile_json(prj: _, cmd: _) { prj.add_source( "jsonError", r" @@ -42,11 +608,9 @@ contract Dummy { } } ", - ) - .unwrap(); + ); - // set up command - cmd.args(["compile", "--format-json"]).assert_success().stderr_eq("").stdout_eq(str![[r#" + let expected = str![[r#" { "errors": [ { @@ -67,11 +631,23 @@ contract Dummy { "contracts": {}, "build_infos": "{...}" } -"#]].is_json()); -}); +"#]] + .is_json(); + + cmd.args(["compile", "--format-json"]) + .assert_failure() + .stderr_eq("") + .stdout_eq(expected.clone()); + cmd.forge_fuse() + .args(["compile", "--format-json", "--sizes"]) + .assert_failure() + .stderr_eq("") + .stdout_eq(expected); +} -forgetest!(initcode_size_exceeds_limit, |prj, cmd| { - prj.add_source("LargeContract.sol", generate_large_init_contract(50_000).as_str()).unwrap(); +#[forgetest] +fn initcode_size_exceeds_limit(prj: _, cmd: _) { + prj.add_source("LargeContract.sol", generate_large_init_contract(50_000).as_str()); cmd.args(["build", "--sizes"]).assert_failure().stdout_eq(str![[r#" [COMPILING_FILES] with [SOLC_VERSION] [SOLC_VERSION] [ELAPSED] @@ -84,6 +660,15 @@ Compiler run successful! ╰---------------+------------------+-------------------+--------------------+---------------------╯ +"#]]); + + cmd.forge_fuse() + .args(["build", "--sizes", "--quiet"]) + .assert_failure() + .stdout_eq("") + .stderr_eq(str![[r#" +Error: some contracts exceed the initcode size limit (EIP-3860: 49152 bytes) + "#]]); cmd.forge_fuse().args(["build", "--sizes", "--json"]).assert_failure().stdout_eq( @@ -100,6 +685,16 @@ Compiler run successful! .is_json(), ); + cmd.forge_fuse().args(["build", "--sizes", "--md"]).assert_failure().stdout_eq(str![[r#" +No files changed, compilation skipped + +| Contract | Runtime Size (B) | Initcode Size (B) | Runtime Margin (B) | Initcode Margin (B) | +|---------------|------------------|-------------------|--------------------|---------------------| +| LargeContract | 62 | 50,125 | 24,514 | -973 | + + +"#]]); + // Ignore EIP-3860 cmd.forge_fuse().args(["build", "--sizes", "--ignore-eip-3860"]).assert_success().stdout_eq( @@ -132,22 +727,167 @@ No files changed, compilation skipped "#]] .is_json(), ); -}); + + cmd.forge_fuse() + .args(["build", "--sizes", "--ignore-eip-3860", "--md"]) + .assert_success() + .stdout_eq(str![[r#" +No files changed, compilation skipped + +| Contract | Runtime Size (B) | Initcode Size (B) | Runtime Margin (B) | Initcode Margin (B) | +|---------------|------------------|-------------------|--------------------|---------------------| +| LargeContract | 62 | 50,125 | 24,514 | -973 | + + +"#]]); +} + +#[forgetest] +fn build_sizes_respects_configured_code_size_limit(prj: _, cmd: _) { + prj.add_source("LargeContract.sol", generate_large_init_contract(50_000).as_str()); + prj.update_config(|config| { + config.code_size_limit = Some(64_000); + }); + + cmd.args(["build", "--sizes", "--json"]).assert_success().stdout_eq( + str![[r#" +{ + "LargeContract": { + "runtime_size": 62, + "init_size": 50125, + "runtime_margin": 63938, + "init_margin": 77875 + } +} +"#]] + .is_json(), + ); +} + +#[cfg(feature = "monad")] +#[forgetest] +fn build_sizes_respects_monad_network_code_size_limit(prj: _, cmd: _) { + prj.add_source("LargeContract.sol", generate_large_init_contract(50_000).as_str()); + prj.update_config(|config| { + config.networks = foundry_evm_networks::NetworkConfigs::with_monad(); + }); + + cmd.args(["build", "--sizes", "--json"]).assert_success().stdout_eq( + str![[r#" +{ + "LargeContract": { + "runtime_size": 62, + "init_size": 50125, + "runtime_margin": 131010, + "init_margin": 212019 + } +} +"#]] + .is_json(), + ); +} + +#[forgetest] +fn build_sizes_respects_amsterdam_code_size_limits(prj: _, cmd: _) { + prj.add_source("LargeContract.sol", generate_large_init_contract(50_000).as_str()); + prj.update_config(|config| { + config.evm_version = EvmVersion::Amsterdam; + }); + + cmd.args(["build", "--sizes", "--json"]).assert_success().stdout_eq( + str![[r#" +{ + "LargeContract": { + "runtime_size": 62, + "init_size": 50125, + "runtime_margin": 65474, + "init_margin": 80947 + } +} +"#]] + .is_json(), + ); +} // tests build output is as expected -forgetest_init!(exact_build_output, |prj, cmd| { +#[forgetest_init] +fn exact_build_output(prj: _, cmd: _) { + prj.initialize_default_contracts(); cmd.args(["build", "--force"]).assert_success().stdout_eq(str![[r#" [COMPILING_FILES] with [SOLC_VERSION] [SOLC_VERSION] [ELAPSED] Compiler run successful! "#]]); -}); +} + +#[forgetest_init] +fn verbose_build_displays_compiler_profiles_in_combined_output(prj: _, cmd: _) { + prj.add_source("Default.sol", "contract Default {}"); + prj.add_source("NoMetadata.sol", "contract NoMetadata {}"); + prj.update_config(|config| { + config.optimizer = Some(true); + config.optimizer_runs = Some(777); + config.via_ir = true; + config.evm_version = EvmVersion::Cancun; + config.additional_compiler_profiles = vec![SettingsOverrides { + name: "no-metadata".to_string(), + via_ir: None, + evm_version: None, + optimizer: None, + optimizer_runs: None, + bytecode_hash: Some(BytecodeHash::None), + }]; + config.compilation_restrictions = vec![CompilationRestrictions { + paths: "src/NoMetadata.sol".parse().unwrap(), + version: None, + via_ir: None, + bytecode_hash: Some(BytecodeHash::None), + min_optimizer_runs: None, + optimizer_runs: None, + max_optimizer_runs: None, + min_evm_version: None, + evm_version: None, + max_evm_version: None, + }]; + }); + + let combined_path = prj.root().join("combined-build-output.log"); + let stdout = fs::File::create(&combined_path).unwrap(); + let stderr = stdout.try_clone().unwrap(); + let status = cmd + .cmd() + .args(["build", "--force", "--no-lint", "-vv"]) + .stdout(stdout) + .stderr(stderr) + .status() + .unwrap(); + let output = fs::read_to_string(combined_path).unwrap(); + assert!(status.success(), "{output}"); + let mut settings = output + .lines() + .filter(|line| line.starts_with("Compiler settings for ")) + .map(|line| { + let (_, settings) = line.split_once(" (profile: ").unwrap(); + format!("Compiler settings (profile: {settings}") + }) + .collect::>(); + settings.sort_unstable(); + assert_data_eq!( + settings.join("\n").into_data(), + str![[r#" +Compiler settings (profile: default): optimizer=true, optimizer_runs=777, via_ir=true, evm_version=cancun +Compiler settings (profile: no-metadata): optimizer=true, optimizer_runs=777, via_ir=true, evm_version=cancun +"#]], + ); +} // tests build output is as expected -forgetest_init!(build_sizes_no_forge_std, |prj, cmd| { +#[forgetest_init] +fn build_sizes_no_forge_std(prj: _, cmd: _) { + prj.initialize_default_contracts(); prj.update_config(|config| { - config.solc = Some(foundry_config::SolcReq::Version(semver::Version::new(0, 8, 27))); + config.solc = Some(foundry_config::SolcReq::Version(semver::Version::new(0, 8, 26))); }); cmd.args(["build", "--sizes"]).assert_success().stdout_eq(str![[r#" @@ -175,18 +915,29 @@ forgetest_init!(build_sizes_no_forge_std, |prj, cmd| { "#]] .is_json(), ); -}); + + cmd.forge_fuse().args(["build", "--sizes", "--md"]).assert_success().stdout_eq(str![[r#" +... + +| Contract | Runtime Size (B) | Initcode Size (B) | Runtime Margin (B) | Initcode Margin (B) | +|----------|------------------|-------------------|--------------------|---------------------| +| Counter | 481 | 509 | 24,095 | 48,643 | + + +"#]]); +} // tests build output --sizes handles multiple contracts with the same name -forgetest_init!(build_sizes_multiple_contracts, |prj, cmd| { +#[forgetest_init] +fn build_sizes_multiple_contracts(prj: _, cmd: _) { + prj.initialize_default_contracts(); prj.add_source( "Foo", r" contract Foo { } ", - ) - .unwrap(); + ); prj.add_source( "a/Counter", @@ -198,8 +949,7 @@ contract Counter { } } ", - ) - .unwrap(); + ); prj.add_source( "b/Counter", @@ -211,8 +961,7 @@ contract Counter { } } ", - ) - .unwrap(); + ); cmd.args(["build", "--sizes"]).assert_success().stdout_eq(str![[r#" ... @@ -231,18 +980,32 @@ contract Counter { "#]]); -}); + + cmd.forge_fuse().args(["build", "--sizes", "--md"]).assert_success().stdout_eq(str![[r#" +... + +| Contract | Runtime Size (B) | Initcode Size (B) | Runtime Margin (B) | Initcode Margin (B) | +|-----------------------------|------------------|-------------------|--------------------|---------------------| +| Counter (src/Counter.sol) | 481 | 509 | 24,095 | 48,643 | +| Counter (src/a/Counter.sol) | 344 | 372 | 24,232 | 48,780 | +| Counter (src/b/Counter.sol) | 291 | 319 | 24,285 | 48,833 | +| Foo | 62 | 88 | 24,514 | 49,064 | + + +"#]]); +} // tests build output --sizes --json handles multiple contracts with the same name -forgetest_init!(build_sizes_multiple_contracts_json, |prj, cmd| { +#[forgetest_init] +fn build_sizes_multiple_contracts_json(prj: _, cmd: _) { + prj.initialize_default_contracts(); prj.add_source( "Foo", r" contract Foo { } ", - ) - .unwrap(); + ); prj.add_source( "a/Counter", @@ -254,8 +1017,7 @@ contract Counter { } } ", - ) - .unwrap(); + ); prj.add_source( "b/Counter", @@ -267,8 +1029,7 @@ contract Counter { } } ", - ) - .unwrap(); + ); cmd.args(["build", "--sizes", "--json"]).assert_success().stdout_eq( str![[r#" @@ -301,10 +1062,126 @@ contract Counter { "#]] .is_json(), ); -}); +} + +// tests that `--sizes` filters out internal libraries (libraries without any external/public +// functions), which are never deployed on their own. +// +#[forgetest] +fn build_sizes_filters_internal_libraries(prj: _, cmd: _) { + prj.add_source( + "Libraries", + r" +// Internal library: all functions internal, never deployed on its own. +library InternalLib { + function add(uint256 a, uint256 b) internal pure returns (uint256) { + return a + b; + } +} + +// Internal library that declares an event and error but no external functions. +library EventLib { + event Ping(uint256 x); + error Boom(); + function ping(uint256 a) internal pure returns (uint256) { + return a; + } +} + +// Public library: deployed and linked. +library PublicLib { + function sub(uint256 a, uint256 b) public pure returns (uint256) { + return a - b; + } +} + +contract Consumer { + using InternalLib for uint256; + function run(uint256 x) external pure returns (uint256) { + return x.add(1); + } +} +", + ); + + // `InternalLib` and `EventLib` must not appear; `PublicLib` and `Consumer` must. + cmd.args(["build", "--sizes"]).assert_success().stdout_eq(str![[r#" +... + +╭-----------+------------------+-------------------+--------------------+---------------------╮ +| Contract | Runtime Size (B) | Initcode Size (B) | Runtime Margin (B) | Initcode Margin (B) | ++=============================================================================================+ +| Consumer | 430 | 458 | 24,146 | 48,694 | +|-----------+------------------+-------------------+--------------------+---------------------| +| PublicLib | 432 | 509 | 24,144 | 48,643 | +╰-----------+------------------+-------------------+--------------------+---------------------╯ + + +"#]]); + + cmd.forge_fuse().args(["build", "--sizes", "--json"]).assert_success().stdout_eq( + str![[r#" +{ + "Consumer": { + "runtime_size": 430, + "init_size": 458, + "runtime_margin": 24146, + "init_margin": 48694 + }, + "PublicLib": { + "runtime_size": 432, + "init_size": 509, + "runtime_margin": 24144, + "init_margin": 48643 + } +} +"#]] + .is_json(), + ); +} + +// tests that when a filtered internal library shares a name with a kept contract, the survivor is +// unique and prints without the `(path)` disambiguation suffix. +// +#[forgetest] +fn build_sizes_filtered_internal_library_frees_unique_name(prj: _, cmd: _) { + prj.add_source( + "a/Foo", + r" +library Foo { + function add(uint256 a) internal pure returns (uint256) { + return a + 1; + } +} +", + ); + prj.add_source( + "b/Foo", + r" +contract Foo { + function f() external pure returns (uint256) { + return 1; + } +} +", + ); + + cmd.args(["build", "--sizes"]).assert_success().stdout_eq(str![[r#" +... + +╭----------+------------------+-------------------+--------------------+---------------------╮ +| Contract | Runtime Size (B) | Initcode Size (B) | Runtime Margin (B) | Initcode Margin (B) | ++============================================================================================+ +| Foo | 175 | 201 | 24,401 | 48,951 | +╰----------+------------------+-------------------+--------------------+---------------------╯ + + +"#]]); +} // tests that skip key in config can be used to skip non-compilable contract -forgetest_init!(test_can_skip_contract, |prj, cmd| { +#[forgetest_init] +fn test_can_skip_contract(prj: _, cmd: _) { prj.add_source( "InvalidContract", r" @@ -312,20 +1189,495 @@ contract InvalidContract { some_invalid_syntax } ", - ) - .unwrap(); + ); prj.add_source( "ValidContract", r" contract ValidContract {} ", - ) - .unwrap(); + ); prj.update_config(|config| { config.skip = vec![Glob::new("src/InvalidContract.sol").unwrap().into()]; }); cmd.args(["build"]).assert_success(); -}); +} + +// +#[forgetest_init] +fn test_consistent_build_output(prj: _, cmd: _) { + prj.add_source( + "AContract.sol", + r#" +import {B} from "/badpath/B.sol"; + +contract A is B {} + "#, + ); + + prj.add_source( + "CContract.sol", + r#" +import {B} from "badpath/B.sol"; + +contract C is B {} + "#, + ); + + cmd.args(["build", "src/AContract.sol"]).assert_failure().stdout_eq(str![[r#" +... +Unable to resolve imports: + "/badpath/B.sol" in "[..]" +with remappings: + forge-std/=[..] +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] + +"#]]); + cmd.forge_fuse().args(["build", "src/CContract.sol"]).assert_failure().stdout_eq(str![[r#" +Unable to resolve imports: + "badpath/B.sol" in "[..]" +with remappings: + forge-std/=[..] +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] + +"#]]); +} + +// +// +#[forgetest] +fn build_with_invalid_natspec(prj: _, cmd: _) { + prj.add_source( + "ContractWithInvalidNatspec.sol", + r#" +contract ContractA { + /// @deprecated quoteExactOutputSingle and exactOutput. Use QuoterV2 instead. +} + +/// Some editors highlight `@note` or `@todo` +/// @note foo bar + +/// @title ContractB +contract ContractB { + /** + some example code in a comment: + import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; + */ +} + "#, + ); + + cmd.args(["build", "src/ContractWithInvalidNatspec.sol"]).assert_success().stderr_eq(str![[ + r#" +warning[6546]: invalid natspec tag '@deprecated', custom tags must use format '@custom:name' + [FILE]:5:5 + │ +5 │ /// @deprecated quoteExactOutputSingle and exactOutput. Use QuoterV2 instead. + │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + │ +... + +warning[6546]: invalid natspec tag '@note', custom tags must use format '@custom:name' + [FILE]:9:1 + │ +9 │ /// @note foo bar + │ ━━━━━━━━━━━━━━━━━ + │ +... + +"# + ]]); +} + +// tests that build succeeds without warning when no soldeer.lock exists +#[forgetest_init] +fn build_no_warning_without_soldeer_lock(prj: _, cmd: _) { + let soldeer_lock = prj.root().join("soldeer.lock"); + // soldeer.lock should not exist in a fresh project + assert!(!soldeer_lock.exists()); + + cmd.args(["build"]).assert_success().stderr_eq(str![[r#" +"#]]); +} + +#[forgetest_init] +fn build_locked_succeeds_when_dependencies_match(cmd: _) { + cmd.args(["build", "--locked"]).assert_success(); +} + +#[forgetest] +fn build_locked_succeeds_without_lockfile_or_dependencies(prj: _, cmd: _) { + assert!(!prj.root().join("foundry.lock").exists()); + + cmd.args(["build"]).assert_success().stderr_eq(""); + cmd.forge_fuse().args(["build", "--locked"]).assert_success().stderr_eq(""); +} + +#[forgetest] +fn build_locked_rejects_lockfile_outside_git_repository(prj: _, cmd: _) { + fs::write(prj.root().join("foundry.lock"), "{}").unwrap(); + + let output = cmd.args(["build", "--locked"]).assert_failure(); + assert!( + output.get_output().stderr_lossy().contains("not a git repository"), + "{}", + output.get_output().stderr_lossy() + ); +} + +#[forgetest] +fn build_locked_honors_git_environment(prj: _, cmd: _) { + let project = prj.root().join("nested"); + fs::create_dir(&project).unwrap(); + let repository = prj.root().join("repository"); + fs::create_dir(&repository).unwrap(); + git(&repository, &["init"]); + git(&repository, &["config", "user.email", "foundry@example.com"]); + git(&repository, &["config", "user.name", "Foundry"]); + git(&repository, &["commit", "--allow-empty", "-m", "initial"]); + let head = git(&repository, &["rev-parse", "HEAD"]); + + fs::write( + prj.root().join(".gitmodules"), + "[submodule \"nested/lib/dep\"]\n\tpath = nested/lib/dep\n\turl = ../dep\n", + ) + .unwrap(); + let git_dir = Path::new("../repository/.git"); + let output = Command::new("git") + .current_dir(&project) + .env("GIT_DIR", git_dir) + .env("GIT_WORK_TREE", "..") + .args(["update-index", "--add", "--cacheinfo", "160000", &head, "nested/lib/dep"]) + .output() + .unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + + cmd.env("GIT_DIR", git_dir); + cmd.env("GIT_WORK_TREE", ".."); + cmd.args(["build", "--locked", "--root", "nested"]).assert_failure().stderr_eq(str![[r#" +Error: foundry.lock does not match installed dependencies: + lib/dep: missing from foundry.lock + lib/dep: dependency submodule is not initialized + +"#]]); +} + +#[forgetest] +fn locked_is_build_only(cmd: _) { + let output = cmd.args(["config", "--locked"]).assert_failure(); + assert!( + output.get_output().stderr_lossy().contains("unexpected argument '--locked'"), + "{}", + output.get_output().stderr_lossy() + ); +} + +#[forgetest_init] +fn build_locked_rejects_malformed_lockfile(prj: _, cmd: _) { + fs::write(prj.root().join("foundry.lock"), "not json").unwrap(); + + cmd.args(["build", "--locked"]).assert_failure().stdout_eq("").stderr_eq(str![[r#" +Error: Failed to read foundry.lock + +Context: +- expected ident at line 1 column 2 + +"#]]); +} + +#[forgetest_init] +fn build_checks_foundry_lock_only_when_locked(prj: _, cmd: _) { + let foundry_lock = prj.root().join("foundry.lock"); + let lockfile = r#"{ + "lib/forge-std": { + "rev": "0000000000000000000000000000000000000000" + } +}"#; + fs::write(&foundry_lock, lockfile).unwrap(); + + cmd.args(["build"]).assert_success().stderr_eq(""); + + fs::write(prj.root().join("src/Broken.sol"), "this is not Solidity").unwrap(); + + cmd.forge_fuse().args(["build", "--locked"]).assert_failure().stdout_eq("").stderr_eq(str![[ + r#" +Error: foundry.lock does not match installed dependencies: + lib/forge-std: expected 0000000000000000000000000000000000000000, found [..] + +"# + ]]); + assert_eq!(fs::read_to_string(foundry_lock).unwrap(), lockfile); +} + +#[forgetest_init] +fn build_locked_reports_uninitialized_dependency_without_installing(prj: _, cmd: _) { + let root = prj.root(); + let foundry_lock = fs::read(root.join("foundry.lock")).unwrap(); + let status = std::process::Command::new("git") + .current_dir(root) + .args(["submodule", "deinit", "-f", "lib/forge-std"]) + .status() + .unwrap(); + assert!(status.success()); + let index = fs::read(root.join(".git/index")).unwrap(); + let git_config = fs::read(root.join(".git/config")).unwrap(); + + cmd.args(["build", "--locked"]).assert_failure().stdout_eq("").stderr_eq(str![[r#" +Error: foundry.lock does not match installed dependencies: + lib/forge-std: dependency submodule is not initialized (expected [..]) + +"#]]); + assert_eq!(fs::read(root.join("foundry.lock")).unwrap(), foundry_lock); + assert_eq!(fs::read(root.join(".git/index")).unwrap(), index); + assert_eq!(fs::read(root.join(".git/config")).unwrap(), git_config); + assert!(!root.join("lib/forge-std/.git").exists()); +} + +#[forgetest_init] +fn build_locked_preserves_uninitialized_state_without_lock_entry(prj: _, cmd: _) { + let root = prj.root(); + fs::remove_file(root.join("foundry.lock")).unwrap(); + let status = std::process::Command::new("git") + .current_dir(root) + .args(["submodule", "deinit", "-f", "lib/forge-std"]) + .status() + .unwrap(); + assert!(status.success()); + + cmd.args(["build", "--locked"]).assert_failure().stdout_eq("").stderr_eq(str![[r#" +Error: foundry.lock does not match installed dependencies: + lib/forge-std: missing from foundry.lock + lib/forge-std: dependency submodule is not initialized + +"#]]); + assert!(!root.join("foundry.lock").exists()); + assert!(!root.join("lib/forge-std/.git").exists()); +} + +#[forgetest_init] +fn build_locked_preserves_conflict_without_lock_entry(prj: _, cmd: _) { + let root = prj.root(); + let submodule = root.join("lib/forge-std"); + let rev = |args: &[&str]| { + let output = Command::new("git").current_dir(&submodule).args(args).output().unwrap(); + assert!(output.status.success()); + String::from_utf8(output.stdout).unwrap().trim().to_string() + }; + let current = rev(&["rev-parse", "HEAD"]); + let previous = rev(&["rev-parse", "HEAD^"]); + fs::remove_file(root.join("foundry.lock")).unwrap(); + assert!( + Command::new("git") + .current_dir(root) + .args(["update-index", "--force-remove", "lib/forge-std"]) + .status() + .unwrap() + .success() + ); + let mut child = Command::new("git") + .current_dir(root) + .args(["update-index", "--index-info"]) + .stdin(Stdio::piped()) + .spawn() + .unwrap(); + write!( + child.stdin.take().unwrap(), + "160000 {previous} 1\tlib/forge-std\n160000 {current} 2\tlib/forge-std\n160000 {previous} 3\tlib/forge-std\n" + ) + .unwrap(); + assert!(child.wait().unwrap().success()); + let index = fs::read(root.join(".git/index")).unwrap(); + + cmd.args(["build", "--locked"]).assert_failure().stdout_eq("").stderr_eq(str![[r#" +Error: foundry.lock does not match installed dependencies: + lib/forge-std: missing from foundry.lock + lib/forge-std: dependency submodule has merge conflicts + +"#]]); + assert_eq!(fs::read(root.join(".git/index")).unwrap(), index); +} + +#[forgetest_init] +fn build_locked_reports_stale_lockfile_entries(prj: _, cmd: _) { + let foundry_lock = prj.root().join("foundry.lock"); + let mut lockfile: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&foundry_lock).unwrap()).unwrap(); + lockfile["lib/stale"] = serde_json::json!({ + "rev": "0000000000000000000000000000000000000000" + }); + fs::write(foundry_lock, serde_json::to_vec_pretty(&lockfile).unwrap()).unwrap(); + + cmd.args(["build", "--locked"]).assert_failure().stdout_eq("").stderr_eq(str![[r#" +Error: foundry.lock does not match installed dependencies: + lib/stale: dependency submodule is missing (expected 0000000000000000000000000000000000000000) + +"#]]); +} + +#[forgetest] +fn build_locked_supports_projects_nested_in_parent_repository(prj: _, cmd: _) { + cmd.git_init(); + cmd.args(["init", "nested", "--use-parent-git"]).assert_success(); + + let root = prj.root(); + let output = Command::new("git") + .current_dir(root) + .args(["-c", "protocol.file.allow=always", "submodule", "add", "--"]) + .arg(root.join("nested/lib/forge-std")) + .arg("sibling") + .output() + .unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + + cmd.forge_fuse().args(["install", "--root", "nested"]).assert_success(); + let foundry_lock = root.join("nested/foundry.lock"); + let mut lockfile: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&foundry_lock).unwrap()).unwrap(); + assert!(lockfile.get("../sibling").is_some()); + for (path, dependency) in lockfile.as_object_mut().unwrap() { + dependency["rev"] = + serde_json::Value::String(git(&root.join("nested").join(path), &["rev-parse", "HEAD"])); + } + fs::write(foundry_lock, serde_json::to_vec_pretty(&lockfile).unwrap()).unwrap(); + + cmd.forge_fuse().args(["build", "--locked", "--no-lint", "--root", "nested"]).assert_success(); +} + +#[forgetest_init] +fn build_locked_supports_dependency_paths_with_spaces(prj: _, cmd: _) { + let root = prj.root(); + git(root, &["mv", "lib/forge-std", "lib/forge std"]); + + let foundry_lock = root.join("foundry.lock"); + let mut lockfile: BTreeMap = + serde_json::from_str(&fs::read_to_string(&foundry_lock).unwrap()).unwrap(); + let forge_std = lockfile.remove(Path::new("lib/forge-std")).unwrap(); + lockfile.insert("lib/forge std".into(), forge_std); + fs::write(foundry_lock, serde_json::to_vec_pretty(&lockfile).unwrap()).unwrap(); + + cmd.args(["build", "--locked"]).assert_success(); +} + +#[forgetest_init] +fn build_locked_accepts_modified_submodule_when_head_matches_lock(prj: _, cmd: _) { + let root = prj.root(); + let submodule = root.join("lib/forge-std"); + let previous = git(&submodule, &["rev-parse", "HEAD^"]); + git(root, &["update-index", "--cacheinfo", "160000", &previous, "lib/forge-std"]); + + cmd.args(["build", "--locked"]).assert_success(); +} + +#[forgetest_init] +fn build_locked_reports_all_mismatches_in_path_order(prj: _, cmd: _) { + let root = prj.root(); + add_local_submodule(root, "lib/second"); + let foundry_lock = root.join("foundry.lock"); + let mut lockfile: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&foundry_lock).unwrap()).unwrap(); + lockfile["lib/forge-std"]["rev"] = + serde_json::Value::String("0000000000000000000000000000000000000000".to_string()); + lockfile["lib/stale"] = serde_json::json!({ + "rev": "1111111111111111111111111111111111111111" + }); + fs::write(foundry_lock, serde_json::to_vec_pretty(&lockfile).unwrap()).unwrap(); + + cmd.args(["build", "--locked"]).assert_failure().stdout_eq("").stderr_eq(str![[r#" +Error: foundry.lock does not match installed dependencies: + lib/forge-std: expected 0000000000000000000000000000000000000000, found [..] + lib/second: missing from foundry.lock (found [..]) + lib/stale: dependency submodule is missing (expected 1111111111111111111111111111111111111111) + +"#]]); +} + +#[forgetest_init] +fn build_locked_supports_custom_dependency_directory(prj: _, cmd: _) { + let root = prj.root(); + fs::create_dir(root.join("dependencies")).unwrap(); + git(root, &["mv", "lib/forge-std", "dependencies/forge-std"]); + prj.update_config(|config| config.libs = vec!["dependencies".into()]); + + let foundry_lock = root.join("foundry.lock"); + let mut lockfile: BTreeMap = + serde_json::from_str(&fs::read_to_string(&foundry_lock).unwrap()).unwrap(); + let forge_std = lockfile.remove(Path::new("lib/forge-std")).unwrap(); + lockfile.insert("dependencies/forge-std".into(), forge_std); + fs::write(foundry_lock, serde_json::to_vec_pretty(&lockfile).unwrap()).unwrap(); + + cmd.args(["build", "--locked"]).assert_success(); +} + +#[forgetest_init] +fn build_locked_supports_project_root_as_dependency_directory(prj: _, cmd: _) { + prj.update_config(|config| config.libs = vec![".".into()]); + + cmd.args(["build", "--locked"]).assert_success(); +} + +#[forgetest_init] +fn build_locked_matches_submodules_outside_dependency_directory(prj: _, cmd: _) { + let root = prj.root(); + add_local_submodule(root, "vendor/second"); + + cmd.forge_fuse().args(["install"]).assert_success(); + + let mut lockfile: serde_json::Value = + serde_json::from_str(&fs::read_to_string(root.join("foundry.lock")).unwrap()).unwrap(); + assert!(lockfile.get("vendor/second").is_some()); + lockfile["lib/forge-std"]["rev"] = + serde_json::Value::String(git(&root.join("lib/forge-std"), &["rev-parse", "HEAD"])); + fs::write(root.join("foundry.lock"), serde_json::to_vec_pretty(&lockfile).unwrap()).unwrap(); + + cmd.forge_fuse().args(["build", "--locked"]).assert_success(); +} + +#[forgetest_init] +fn build_locked_aggregates_missing_submodule_mapping(prj: _, cmd: _) { + let root = prj.root(); + let head = git(&root.join("lib/forge-std"), &["rev-parse", "HEAD"]); + git(root, &["update-index", "--add", "--cacheinfo", "160000", &head, "lib/unmapped"]); + let mut gitmodules = fs::read_to_string(root.join(".gitmodules")).unwrap(); + gitmodules.push_str("\n[submodule \"lib/unmapped\"]\n\turl = ../unmapped\n"); + fs::write(root.join(".gitmodules"), gitmodules).unwrap(); + + let foundry_lock = root.join("foundry.lock"); + let mut lockfile: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&foundry_lock).unwrap()).unwrap(); + lockfile["lib/stale"] = serde_json::json!({ + "rev": "1111111111111111111111111111111111111111" + }); + fs::write(foundry_lock, serde_json::to_vec_pretty(&lockfile).unwrap()).unwrap(); + + cmd.args(["build", "--locked"]).assert_failure().stdout_eq("").stderr_eq(str![[r#" +Error: foundry.lock does not match installed dependencies: + lib/stale: dependency submodule is missing (expected 1111111111111111111111111111111111111111) + lib/unmapped: dependency submodule is missing from .gitmodules + +"#]]); +} + +#[forgetest] +fn deny_warnings_checks_warm_cache(prj: _, cmd: _) { + prj.add_source( + "Warn.sol", + r#"// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; +contract Warn { + function f(uint256 a) public pure returns (uint256) { + uint256 unused; + return a; + } +} +"#, + ); + cmd.forge_fuse().arg("build").assert_success(); + cmd.forge_fuse().args(["build", "--deny", "warnings"]).assert_failure(); + cmd.forge_fuse().args(["test", "--deny", "warnings"]).assert_failure(); + prj.update_config(|config| config.deny = foundry_config::DenyLevel::Warnings); + cmd.forge_fuse().arg("build").assert_failure(); +} diff --git a/crates/forge/tests/cli/cache.rs b/crates/forge/tests/cli/cache.rs index b2f6484262bdf..66a3c3fe7f019 100644 --- a/crates/forge/tests/cli/cache.rs +++ b/crates/forge/tests/cli/cache.rs @@ -1,21 +1,31 @@ //! Tests for various cache command. -forgetest!(can_list_cache, |_prj, cmd| { +#[forgetest] +fn can_list_cache(cmd: _) { cmd.args(["cache", "ls"]); cmd.assert_success(); -}); +} -forgetest!(can_list_cache_all, |_prj, cmd| { +#[forgetest] +fn can_list_cache_all(cmd: _) { cmd.args(["cache", "ls", "all"]); cmd.assert_success(); -}); +} -forgetest!(can_list_specific_chain, |_prj, cmd| { +#[forgetest] +fn can_list_specific_chain(cmd: _) { cmd.args(["cache", "ls", "mainnet"]); cmd.assert_success(); -}); +} -forgetest_init!(can_test_no_cache, |prj, cmd| { +#[forgetest] +fn cache_ls_output_on_stderr(cmd: _) { + cmd.args(["cache", "ls", "mainnet"]).assert_success().stdout_eq(str![""]); +} + +#[forgetest_init] +fn can_test_no_cache(prj: _, cmd: _) { + prj.initialize_default_contracts(); prj.clear_cache(); cmd.args(["test", "--no-cache"]).assert_success(); @@ -23,4 +33,4 @@ forgetest_init!(can_test_no_cache, |prj, cmd| { cmd.forge_fuse().arg("test").assert_success(); assert!(prj.cache().exists(), "cache file should exist"); -}); +} diff --git a/crates/forge/tests/cli/cmd.rs b/crates/forge/tests/cli/cmd.rs index fcf10023cdf6e..d777916fbd13f 100644 --- a/crates/forge/tests/cli/cmd.rs +++ b/crates/forge/tests/cli/cmd.rs @@ -1,17 +1,21 @@ //! Contains various tests for checking forge's commands use crate::constants::*; -use foundry_compilers::artifacts::{ConfigurableContractArtifact, Metadata, remappings::Remapping}; +use foundry_cli::utils::parse_json; +use foundry_compilers::{ + artifacts::{ConfigurableContractArtifact, Metadata, remappings::Remapping}, + solc::Solc, +}; use foundry_config::{ - BasicConfig, Chain, Config, FuzzConfig, InvariantConfig, SolidityErrorCode, parse_with_profile, + BasicConfig, Config, DenyLevel, FuzzConfig, InvariantConfig, SolidityErrorCode, + parse_with_profile, }; use foundry_test_utils::{ foundry_compilers::PathStyle, rpc::next_etherscan_api_key, snapbox::IntoData, - util::{OutputExt, TestCommand, read_string}, + util::{OTHER_SOLC_VERSION, OutputExt, read_string}, }; -use semver::Version; use std::{ fs, path::Path, @@ -19,8 +23,14 @@ use std::{ str::FromStr, }; +#[cfg(unix)] +use foundry_test_utils::util::TestCommand; +#[cfg(unix)] +use std::path::PathBuf; + // tests `--help` is printed to std out -forgetest!(print_help, |_prj, cmd| { +#[forgetest] +fn print_help(cmd: _) { cmd.arg("--help").assert_success().stdout_eq(str![[r#" Build, test, fuzz, debug and deploy Solidity contracts @@ -36,7 +46,10 @@ Options: -j, --threads Number of threads to use. Specifying 0 defaults to the number of logical cores - [aliases: --jobs] + [alias: --jobs] + + --profile + The configuration profile to use -V, --version Print version @@ -53,6 +66,9 @@ Display options: --json Format log messages as JSON + --md + Format log messages as Markdown + -q, --quiet Do not print log messages @@ -67,222 +83,224 @@ Display options: - 2 (-vv): Print logs for all tests. - 3 (-vvv): Print execution traces for failing tests. - 4 (-vvvv): Print execution traces for all tests, and setup traces for failing tests. - - 5 (-vvvvv): Print execution and setup traces for all tests, including storage changes. + - 5 (-vvvvv): Print execution and setup traces for all tests, including storage changes + and + backtraces with line numbers. Find more information in the book: https://getfoundry.sh/forge/overview "#]]); -}); +} // checks that `clean` can be invoked even if out and cache don't exist -forgetest!(can_clean_non_existing, |prj, cmd| { +#[forgetest] +fn can_clean_non_existing(prj: _, cmd: _) { cmd.arg("clean"); cmd.assert_empty_stdout(); prj.assert_cleaned(); -}); +} + +// checks that `clean` doesn't output warnings +#[forgetest_init] +fn can_clean_without_warnings(prj: _, cmd: _) { + prj.add_source( + "Simple.sol", + r#" +pragma solidity ^0.8.5; + +contract Simple { + uint public value = 42; +} +"#, + ); + + prj.create_file( + "foundry.toml", + r#" +[default] +evm_version = "cancun" +solc = "0.8.35" +"#, + ); + // `forge build` warns + cmd.forge_fuse().arg("build").assert_success().stderr_eq(str![[r#" +Warning: Found unknown config section in foundry.toml: [default] +This notation for profiles has been deprecated and may result in the profile not being registered in future versions. +Please use [profile.default] instead or run `forge config --fix`. + +"#]]); + // `forge clear` should not warn + cmd.forge_fuse().arg("clean").assert_success().stderr_eq(str![[r#" + +"#]]); +} + +/// Points `cmd` at an empty temporary `HOME` so the `forge cache` tests never touch the real +/// `~/.foundry/cache`, and returns it with the cache dir inside it. +#[cfg(unix)] +fn isolated_foundry_cache(cmd: &mut TestCommand) -> (tempfile::TempDir, PathBuf) { + let home = tempfile::tempdir().unwrap(); + cmd.env("HOME", home.path()); + cmd.env("XDG_DATA_HOME", home.path().join("data")); + let cache = home.path().join(".foundry").join("cache"); + (home, cache) +} + +/// Creates the given dirs, each with a two byte cache file. +#[cfg(unix)] +fn seed_cache_dirs(dirs: &[&Path]) { + for dir in dirs { + fs::create_dir_all(dir).unwrap(); + fs::write(dir.join("storage.json"), "{}").unwrap(); + } +} // checks that `cache ls` can be invoked and displays the foundry cache -forgetest!( - #[ignore] - can_cache_ls, - |_prj, cmd| { - let chain = Chain::mainnet(); - let block1 = 100; - let block2 = 101; - - let block1_cache_dir = Config::foundry_block_cache_dir(chain, block1).unwrap(); - let block1_file = Config::foundry_block_cache_file(chain, block1).unwrap(); - let block2_cache_dir = Config::foundry_block_cache_dir(chain, block2).unwrap(); - let block2_file = Config::foundry_block_cache_file(chain, block2).unwrap(); - let etherscan_cache_dir = Config::foundry_etherscan_chain_cache_dir(chain).unwrap(); - fs::create_dir_all(block1_cache_dir).unwrap(); - fs::write(block1_file, "{}").unwrap(); - fs::create_dir_all(block2_cache_dir).unwrap(); - fs::write(block2_file, "{}").unwrap(); - fs::create_dir_all(etherscan_cache_dir).unwrap(); - - let output = cmd.args(["cache", "ls"]).assert_success().get_output().stdout_lossy(); - let output_lines = output.split('\n').collect::>(); - println!("{output}"); - - assert_eq!(output_lines.len(), 6); - assert!(output_lines[0].starts_with("-️ mainnet (")); - assert!(output_lines[1].starts_with("\t-️ Block Explorer (")); - assert_eq!(output_lines[2], ""); - assert!(output_lines[3].starts_with("\t-️ Block 100 (")); - assert!(output_lines[4].starts_with("\t-️ Block 101 (")); - assert_eq!(output_lines[5], ""); - - Config::clean_foundry_cache().unwrap(); - } -); +#[cfg(unix)] +#[forgetest] +fn can_cache_ls(cmd: _) { + let (_home, cache) = isolated_foundry_cache(&mut cmd); + let chain = cache.join("rpc").join("mainnet"); + seed_cache_dirs(&[&chain.join("100"), &chain.join("101")]); + fs::create_dir_all(cache.join("etherscan").join("mainnet")).unwrap(); + + cmd.args(["cache", "ls"]).assert_success().stdout_eq("").stderr_eq( + str![[r#" +- mainnet (4.0 B) + - Block Explorer (0.0 B) + + - Block 100 (2.0 B) + - Block 101 (2.0 B) + +"#]] + .unordered(), + ); +} // checks that `cache clean` can be invoked and cleans the foundry cache -// this test is not isolated and modifies ~ so it is ignored -forgetest!( - #[ignore] - can_cache_clean, - |_prj, cmd| { - let cache_dir = Config::foundry_cache_dir().unwrap(); - let path = cache_dir.as_path(); - fs::create_dir_all(path).unwrap(); - cmd.args(["cache", "clean"]); - cmd.assert_empty_stdout(); - - assert!(!path.exists()); - } -); - -// checks that `cache clean --etherscan` can be invoked and only cleans the foundry etherscan cache -// this test is not isolated and modifies ~ so it is ignored -forgetest!( - #[ignore] - can_cache_clean_etherscan, - |_prj, cmd| { - let cache_dir = Config::foundry_cache_dir().unwrap(); - let etherscan_cache_dir = Config::foundry_etherscan_cache_dir().unwrap(); - let path = cache_dir.as_path(); - let etherscan_path = etherscan_cache_dir.as_path(); - fs::create_dir_all(etherscan_path).unwrap(); - cmd.args(["cache", "clean", "--etherscan"]); - cmd.assert_empty_stdout(); - - assert!(path.exists()); - assert!(!etherscan_path.exists()); - - Config::clean_foundry_cache().unwrap(); - } -); - -// checks that `cache clean all --etherscan` can be invoked and only cleans the foundry etherscan -// cache. This test is not isolated and modifies ~ so it is ignored -forgetest!( - #[ignore] - can_cache_clean_all_etherscan, - |_prj, cmd| { - let rpc_cache_dir = Config::foundry_rpc_cache_dir().unwrap(); - let etherscan_cache_dir = Config::foundry_etherscan_cache_dir().unwrap(); - let rpc_path = rpc_cache_dir.as_path(); - let etherscan_path = etherscan_cache_dir.as_path(); - fs::create_dir_all(rpc_path).unwrap(); - fs::create_dir_all(etherscan_path).unwrap(); - cmd.args(["cache", "clean", "all", "--etherscan"]); - cmd.assert_empty_stdout(); - - assert!(rpc_path.exists()); - assert!(!etherscan_path.exists()); - - Config::clean_foundry_cache().unwrap(); - } -); - -// checks that `cache clean ` can be invoked and cleans the chain cache -// this test is not isolated and modifies ~ so it is ignored -forgetest!( - #[ignore] - can_cache_clean_chain, - |_prj, cmd| { - let chain = Chain::mainnet(); - let cache_dir = Config::foundry_chain_cache_dir(chain).unwrap(); - let etherscan_cache_dir = Config::foundry_etherscan_chain_cache_dir(chain).unwrap(); - let path = cache_dir.as_path(); - let etherscan_path = etherscan_cache_dir.as_path(); - fs::create_dir_all(path).unwrap(); - fs::create_dir_all(etherscan_path).unwrap(); - cmd.args(["cache", "clean", "mainnet"]); - cmd.assert_empty_stdout(); - - assert!(!path.exists()); - assert!(!etherscan_path.exists()); - - Config::clean_foundry_cache().unwrap(); - } -); - -// checks that `cache clean --blocks 100,101` can be invoked and cleans the chain block -// caches this test is not isolated and modifies ~ so it is ignored -forgetest!( - #[ignore] - can_cache_clean_blocks, - |_prj, cmd| { - let chain = Chain::mainnet(); - let block1 = 100; - let block2 = 101; - let block3 = 102; - let block1_cache_dir = Config::foundry_block_cache_dir(chain, block1).unwrap(); - let block2_cache_dir = Config::foundry_block_cache_dir(chain, block2).unwrap(); - let block3_cache_dir = Config::foundry_block_cache_dir(chain, block3).unwrap(); - let etherscan_cache_dir = Config::foundry_etherscan_chain_cache_dir(chain).unwrap(); - let block1_path = block1_cache_dir.as_path(); - let block2_path = block2_cache_dir.as_path(); - let block3_path = block3_cache_dir.as_path(); - let etherscan_path = etherscan_cache_dir.as_path(); - fs::create_dir_all(block1_path).unwrap(); - fs::create_dir_all(block2_path).unwrap(); - fs::create_dir_all(block3_path).unwrap(); - fs::create_dir_all(etherscan_path).unwrap(); - cmd.args(["cache", "clean", "mainnet", "--blocks", "100,101"]); - cmd.assert_empty_stdout(); - - assert!(!block1_path.exists()); - assert!(!block2_path.exists()); - assert!(block3_path.exists()); - assert!(etherscan_path.exists()); - - Config::clean_foundry_cache().unwrap(); - } -); - -// checks that `cache clean --etherscan` can be invoked and cleans the etherscan chain cache -// this test is not isolated and modifies ~ so it is ignored -forgetest!( - #[ignore] - can_cache_clean_chain_etherscan, - |_prj, cmd| { - let cache_dir = Config::foundry_chain_cache_dir(Chain::mainnet()).unwrap(); - let etherscan_cache_dir = - Config::foundry_etherscan_chain_cache_dir(Chain::mainnet()).unwrap(); - let path = cache_dir.as_path(); - let etherscan_path = etherscan_cache_dir.as_path(); - fs::create_dir_all(path).unwrap(); - fs::create_dir_all(etherscan_path).unwrap(); - cmd.args(["cache", "clean", "mainnet", "--etherscan"]); - cmd.assert_empty_stdout(); - - assert!(path.exists()); - assert!(!etherscan_path.exists()); - - Config::clean_foundry_cache().unwrap(); - } -); +#[cfg(unix)] +#[forgetest] +fn can_cache_clean(cmd: _) { + let (home, cache) = isolated_foundry_cache(&mut cmd); + seed_cache_dirs(&[&cache.join("rpc").join("mainnet").join("100")]); + fs::create_dir_all(cache.join("etherscan").join("mainnet")).unwrap(); + + cmd.args(["cache", "clean"]).assert_empty_stdout(); + + assert!(!cache.exists()); + assert!(home.path().join(".foundry").exists()); +} + +// checks that `cache clean --etherscan` only cleans the foundry etherscan cache +#[cfg(unix)] +#[forgetest] +fn can_cache_clean_etherscan(cmd: _) { + let (_home, cache) = isolated_foundry_cache(&mut cmd); + let block = cache.join("rpc").join("mainnet").join("100"); + let etherscan = cache.join("etherscan"); + seed_cache_dirs(&[&block, ðerscan.join("mainnet")]); + + cmd.args(["cache", "clean", "--etherscan"]).assert_empty_stdout(); + + assert!(block.exists()); + assert!(!etherscan.exists()); +} + +// checks that `cache clean all --etherscan` only cleans the foundry etherscan cache +#[cfg(unix)] +#[forgetest] +fn can_cache_clean_all_etherscan(cmd: _) { + let (_home, cache) = isolated_foundry_cache(&mut cmd); + let block = cache.join("rpc").join("mainnet").join("100"); + let etherscan = cache.join("etherscan"); + seed_cache_dirs(&[&block, ðerscan.join("mainnet")]); + + cmd.args(["cache", "clean", "all", "--etherscan"]).assert_empty_stdout(); + + assert!(block.exists()); + assert!(!etherscan.exists()); +} + +// checks that `cache clean ` cleans the rpc and etherscan caches of that chain only +#[cfg(unix)] +#[forgetest] +fn can_cache_clean_chain(cmd: _) { + let (_home, cache) = isolated_foundry_cache(&mut cmd); + let rpc = cache.join("rpc"); + let etherscan = cache.join("etherscan"); + seed_cache_dirs(&[ + &rpc.join("mainnet").join("100"), + &rpc.join("sepolia").join("100"), + ðerscan.join("mainnet"), + ðerscan.join("sepolia"), + ]); + + cmd.args(["cache", "clean", "mainnet"]).assert_empty_stdout(); + + assert!(!rpc.join("mainnet").exists()); + assert!(!etherscan.join("mainnet").exists()); + assert!(rpc.join("sepolia").join("100").exists()); + assert!(etherscan.join("sepolia").exists()); +} + +// checks that `cache clean --blocks 100,101` only cleans those block caches +#[cfg(unix)] +#[forgetest] +fn can_cache_clean_blocks(cmd: _) { + let (_home, cache) = isolated_foundry_cache(&mut cmd); + let chain = cache.join("rpc").join("mainnet"); + let etherscan = cache.join("etherscan").join("mainnet"); + seed_cache_dirs(&[&chain.join("100"), &chain.join("101"), &chain.join("102"), ðerscan]); + + cmd.args(["cache", "clean", "mainnet", "--blocks", "100,101"]).assert_empty_stdout(); + + assert!(!chain.join("100").exists()); + assert!(!chain.join("101").exists()); + assert!(chain.join("102").exists()); + assert!(etherscan.exists()); +} + +// checks that `cache clean --etherscan` only cleans the etherscan cache of that chain +#[cfg(unix)] +#[forgetest] +fn can_cache_clean_chain_etherscan(cmd: _) { + let (_home, cache) = isolated_foundry_cache(&mut cmd); + let block = cache.join("rpc").join("mainnet").join("100"); + let etherscan = cache.join("etherscan"); + seed_cache_dirs(&[&block, ðerscan.join("mainnet"), ðerscan.join("sepolia")]); + + cmd.args(["cache", "clean", "mainnet", "--etherscan"]).assert_empty_stdout(); + + assert!(block.exists()); + assert!(!etherscan.join("mainnet").exists()); + assert!(etherscan.join("sepolia").exists()); +} // checks that init works -forgetest!(can_init_repo_with_config, |prj, cmd| { +#[forgetest] +fn can_init_repo_with_config(prj: _, cmd: _) { let foundry_toml = prj.root().join(Config::FILE_NAME); assert!(!foundry_toml.exists()); - cmd.args(["init", "--force"]) - .arg(prj.root()) - .assert_success() - .stdout_eq(str![[r#" + cmd.args(["init", "--force"]).arg(prj.root()).assert_success().stdout_eq(str![""]).stderr_eq( + str![[r#" +Warning: Target directory is not empty, but `--force` was specified Initializing [..]... -Installing forge-std in [..] (url: Some("https://github.com/foundry-rs/forge-std"), tag: None) +Installing forge-std in [..] (url: https://github.com/foundry-rs/forge-std, tag: None) +... Installed forge-std[..] Initialized forge project -"#]]) - .stderr_eq(str![[r#" -Warning: Target directory is not empty, but `--force` was specified -... - -"#]]); +"#]], + ); let s = read_string(&foundry_toml); let _config: BasicConfig = parse_with_profile(&s).unwrap().unwrap().1; -}); +} // Checks that a forge project fails to initialise if dir is already git repo and dirty -forgetest!(can_detect_dirty_git_status_on_init, |prj, cmd| { +#[forgetest] +fn can_detect_dirty_git_status_on_init(prj: _, cmd: _) { prj.wipe(); // initialize new git repo @@ -307,48 +325,78 @@ ignore them in the `.gitignore` file. // ensure nothing was emitted, dir is empty assert!(!nested.read_dir().map(|mut i| i.next().is_some()).unwrap_or_default()); -}); +} // Checks that a forge project can be initialized without creating a git repository -forgetest!(can_init_no_git, |prj, cmd| { +#[forgetest] +fn can_init_no_git(prj: _, cmd: _) { prj.wipe(); - cmd.arg("init").arg(prj.root()).arg("--no-git").assert_success().stdout_eq(str![[r#" + cmd.arg("init").arg(prj.root()).arg("--no-git").assert_success().stdout_eq(str![""]).stderr_eq( + str![[r#" Initializing [..]... -Installing forge-std in [..] (url: Some("https://github.com/foundry-rs/forge-std"), tag: None) +Installing forge-std in [..] (url: https://github.com/foundry-rs/forge-std, tag: None) +... Installed forge-std[..] Initialized forge project -"#]]); +"#]], + ); prj.assert_config_exists(); assert!(!prj.root().join(".git").exists()); assert!(prj.root().join("lib/forge-std").exists()); assert!(!prj.root().join("lib/forge-std/.git").exists()); -}); +} + +// Checks that `--no-commit` is accepted as a noop backwards-compatibility flag +#[forgetest] +fn can_init_with_no_commit(prj: _, cmd: _) { + prj.wipe(); + + cmd.arg("init") + .arg(prj.root()) + .arg("--no-commit") + .assert_success() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Initializing [..]... +Installing forge-std in [..] (url: https://github.com/foundry-rs/forge-std, tag: None) +... + Installed forge-std[..] + Initialized forge project + +"#]]); + prj.assert_config_exists(); +} // Checks that quiet mode does not print anything -forgetest!(can_init_quiet, |prj, cmd| { +#[forgetest] +fn can_init_quiet(prj: _, cmd: _) { prj.wipe(); cmd.arg("init").arg(prj.root()).arg("-q").assert_empty_stdout(); -}); +} // `forge init foobar` works with dir argument -forgetest!(can_init_with_dir, |prj, cmd| { +#[forgetest] +fn can_init_with_dir(prj: _, cmd: _) { prj.create_file("README.md", "non-empty dir"); cmd.args(["init", "foobar"]); cmd.assert_success(); assert!(prj.root().join("foobar").exists()); -}); +} // `forge init foobar --template [template]` works with dir argument -forgetest!(can_init_with_dir_and_template, |prj, cmd| { +#[forgetest] +fn can_init_with_dir_and_template(prj: _, cmd: _) { cmd.args(["init", "foobar", "--template", "foundry-rs/forge-template"]) .assert_success() - .stdout_eq(str![[r#" + .stdout_eq(str![""]) + .stderr_eq(str![[r#" Initializing [..] from https://github.com/foundry-rs/forge-template... +... Initialized forge project "#]]); @@ -360,10 +408,11 @@ Initializing [..] from https://github.com/foundry-rs/forge-template... assert!(prj.root().join("foobar/.git/modules").exists()); assert!(prj.root().join("foobar/src").exists()); assert!(prj.root().join("foobar/test").exists()); -}); +} // `forge init foobar --template [template] --branch [branch]` works with dir argument -forgetest!(can_init_with_dir_and_template_and_branch, |prj, cmd| { +#[forgetest] +fn can_init_with_dir_and_template_and_branch(prj: _, cmd: _) { cmd.args([ "init", "foobar", @@ -373,8 +422,10 @@ forgetest!(can_init_with_dir_and_template_and_branch, |prj, cmd| { "test/deployments", ]) .assert_success() - .stdout_eq(str![[r#" + .stdout_eq(str![""]) + .stderr_eq(str![[r#" Initializing [..] from https://github.com/foundry-rs/forge-template... +... Initialized forge project "#]]); @@ -385,10 +436,11 @@ Initializing [..] from https://github.com/foundry-rs/forge-template... assert!(prj.root().join("foobar/.git/modules").exists()); assert!(prj.root().join("foobar/src").exists()); assert!(prj.root().join("foobar/scripts").exists()); -}); +} // `forge init --force` works on non-empty dirs -forgetest!(can_init_non_empty, |prj, cmd| { +#[forgetest] +fn can_init_non_empty(prj: _, cmd: _) { prj.create_file("README.md", "non-empty dir"); cmd.arg("init").arg(prj.root()).assert_failure().stderr_eq(str![[r#" Error: Cannot run `init` on a non-empty directory. @@ -396,27 +448,23 @@ Run with the `--force` flag to initialize regardless. "#]]); - cmd.arg("--force") - .assert_success() - .stdout_eq(str![[r#" + cmd.arg("--force").assert_success().stdout_eq(str![""]).stderr_eq(str![[r#" +Warning: Target directory is not empty, but `--force` was specified Initializing [..]... -Installing forge-std in [..] (url: Some("https://github.com/foundry-rs/forge-std"), tag: None) +Installing forge-std in [..] (url: https://github.com/foundry-rs/forge-std, tag: None) +... Installed forge-std[..] Initialized forge project -"#]]) - .stderr_eq(str![[r#" -Warning: Target directory is not empty, but `--force` was specified -... - "#]]); assert!(prj.root().join(".git").exists()); assert!(prj.root().join("lib/forge-std").exists()); -}); +} // `forge init --force` works on already initialized git repository -forgetest!(can_init_in_empty_repo, |prj, cmd| { +#[forgetest] +fn can_init_in_empty_repo(prj: _, cmd: _) { let root = prj.root(); // initialize new git repo @@ -436,26 +484,22 @@ Run with the `--force` flag to initialize regardless. "#]]); - cmd.arg("--force") - .assert_success() - .stdout_eq(str![[r#" + cmd.arg("--force").assert_success().stdout_eq(str![""]).stderr_eq(str![[r#" +Warning: Target directory is not empty, but `--force` was specified Initializing [..]... -Installing forge-std in [..] (url: Some("https://github.com/foundry-rs/forge-std"), tag: None) +Installing forge-std in [..] (url: https://github.com/foundry-rs/forge-std, tag: None) +... Installed forge-std[..] Initialized forge project -"#]]) - .stderr_eq(str![[r#" -Warning: Target directory is not empty, but `--force` was specified -... - "#]]); assert!(root.join("lib/forge-std").exists()); -}); +} // `forge init --force` works on already initialized git repository -forgetest!(can_init_in_non_empty_repo, |prj, cmd| { +#[forgetest] +fn can_init_in_non_empty_repo(prj: _, cmd: _) { let root = prj.root(); // initialize new git repo @@ -478,40 +522,92 @@ Run with the `--force` flag to initialize regardless. "#]]); - cmd.arg("--force") - .assert_success() - .stdout_eq(str![[r#" + cmd.arg("--force").assert_success().stdout_eq(str![""]).stderr_eq(str![[r#" +Warning: Target directory is not empty, but `--force` was specified Initializing [..]... -Installing forge-std in [..] (url: Some("https://github.com/foundry-rs/forge-std"), tag: None) +Installing forge-std in [..] (url: https://github.com/foundry-rs/forge-std, tag: None) +... Installed forge-std[..] Initialized forge project -"#]]) +"#]]); + + assert!(root.join("lib/forge-std").exists()); + + // not overwritten + let gitignore = root.join(".gitignore"); + let gitignore = fs::read_to_string(gitignore).unwrap(); + assert_eq!(gitignore, "not foundry .gitignore"); +} + +// `forge init --use-parent-git` works on already initialized git repository +#[forgetest] +fn can_init_using_parent_repo(prj: _, cmd: _) { + let root = prj.root(); + + // initialize new git repo + let status = Command::new("git") + .arg("init") + .current_dir(root) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .expect("could not run git init"); + assert!(status.success()); + assert!(root.join(".git").exists()); + + prj.create_file("README.md", "non-empty dir"); + prj.create_file(".gitignore", "not foundry .gitignore"); + + let folder = "foundry-folder"; + cmd.arg("init") + .arg(folder) + .arg("--force") + .arg("--use-parent-git") + .assert_success() + .stdout_eq(str![""]) .stderr_eq(str![[r#" -Warning: Target directory is not empty, but `--force` was specified +Initializing [..]... +Installing forge-std in [..] (url: https://github.com/foundry-rs/forge-std, tag: None) ... + Installed forge-std[..] + Initialized forge project "#]]); - assert!(root.join("lib/forge-std").exists()); + assert!(root.join(folder).join("lib/forge-std").exists()); // not overwritten let gitignore = root.join(".gitignore"); let gitignore = fs::read_to_string(gitignore).unwrap(); assert_eq!(gitignore, "not foundry .gitignore"); -}); + + // submodules are registered at root + let gitmodules = root.join(".gitmodules"); + let gitmodules = fs::read_to_string(gitmodules).unwrap(); + assert!(gitmodules.contains( + " + path = foundry-folder/lib/forge-std + url = https://github.com/foundry-rs/forge-std +" + )); +} // Checks that remappings.txt and .vscode/settings.json is generated -forgetest!(can_init_vscode, |prj, cmd| { +#[forgetest] +fn can_init_vscode(prj: _, cmd: _) { prj.wipe(); - cmd.arg("init").arg(prj.root()).arg("--vscode").assert_success().stdout_eq(str![[r#" + cmd.arg("init").arg(prj.root()).arg("--vscode").assert_success().stdout_eq(str![""]).stderr_eq( + str![[r#" Initializing [..]... -Installing forge-std in [..] (url: Some("https://github.com/foundry-rs/forge-std"), tag: None) +Installing forge-std in [..] (url: https://github.com/foundry-rs/forge-std, tag: None) +... Installed forge-std[..] Initialized forge project -"#]]); +"#]], + ); let settings = prj.root().join(".vscode/settings.json"); assert!(settings.is_file()); @@ -528,17 +624,20 @@ Installing forge-std in [..] (url: Some("https://github.com/foundry-rs/forge-std assert!(remappings.is_file()); let content = std::fs::read_to_string(remappings).unwrap(); assert_eq!(content, "forge-std/=lib/forge-std/src/",); -}); +} // checks that forge can init with template -forgetest!(can_init_template, |prj, cmd| { +#[forgetest] +fn can_init_template(prj: _, cmd: _) { prj.wipe(); cmd.args(["init", "--template", "foundry-rs/forge-template"]) .arg(prj.root()) .assert_success() - .stdout_eq(str![[r#" + .stdout_eq(str![""]) + .stderr_eq(str![[r#" Initializing [..] from https://github.com/foundry-rs/forge-template... +... Initialized forge project "#]]); @@ -550,16 +649,19 @@ Initializing [..] from https://github.com/foundry-rs/forge-template... assert!(prj.root().join(".git/modules").exists()); assert!(prj.root().join("src").exists()); assert!(prj.root().join("test").exists()); -}); +} // checks that forge can init with template and branch -forgetest!(can_init_template_with_branch, |prj, cmd| { +#[forgetest] +fn can_init_template_with_branch(prj: _, cmd: _) { prj.wipe(); cmd.args(["init", "--template", "foundry-rs/forge-template", "--branch", "test/deployments"]) .arg(prj.root()) .assert_success() - .stdout_eq(str![[r#" + .stdout_eq(str![""]) + .stderr_eq(str![[r#" Initializing [..] from https://github.com/foundry-rs/forge-template... +... Initialized forge project "#]]); @@ -571,27 +673,31 @@ Initializing [..] from https://github.com/foundry-rs/forge-template... assert!(prj.root().join(".git/modules").exists()); assert!(prj.root().join("src").exists()); assert!(prj.root().join("scripts").exists()); -}); +} // checks that init fails when the provided template doesn't exist -forgetest!(fail_init_nonexistent_template, |prj, cmd| { +#[forgetest] +fn fail_init_nonexistent_template(prj: _, cmd: _) { prj.wipe(); cmd.args(["init", "--template", "a"]).arg(prj.root()).assert_failure().stderr_eq(str![[r#" -remote: Not Found -fatal: repository 'https://github.com/a/' not found +Initializing [..] from https://github.com/a... +... Error: git fetch exited with code 128 "#]]); -}); +} // checks that `forge init --template [template] works by default i.e without committing -forgetest!(can_init_template_with_no_commit, |prj, cmd| { +#[forgetest] +fn can_init_template_with_no_commit(prj: _, cmd: _) { prj.wipe(); cmd.args(["init", "--template", "foundry-rs/forge-template"]) .arg(prj.root()) .assert_success() - .stdout_eq(str![[r#" + .stdout_eq(str![""]) + .stderr_eq(str![[r#" Initializing [..] from https://github.com/foundry-rs/forge-template... +... Initialized forge project "#]]); @@ -607,10 +713,11 @@ Initializing [..] from https://github.com/foundry-rs/forge-template... !commit_message.starts_with("chore: init from foundry-rs/forge-template"), "Commit message should not start with 'chore: init from foundry-rs/forge-template'" ); -}); +} // checks that clone works -forgetest!(can_clone, |prj, cmd| { +#[forgetest] +fn flaky_can_clone(prj: _, cmd: _) { prj.wipe(); let foundry_toml = prj.root().join(Config::FILE_NAME); @@ -625,24 +732,67 @@ forgetest!(can_clone, |prj, cmd| { .arg(prj.root()) .assert_success() .stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +"#]]) + .stderr_eq(str![[r#" Downloading the source code of 0x044b75f554b886A065b9567891e45c79542d7357 from Etherscan... Initializing [..]... -Installing forge-std in [..] (url: Some("https://github.com/foundry-rs/forge-std"), tag: None) +Installing forge-std in [..] (url: https://github.com/foundry-rs/forge-std, tag: None) +... Installed forge-std[..] Initialized forge project Collecting the creation information of 0x044b75f554b886A065b9567891e45c79542d7357 from Etherscan... -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! "#]]); let s = read_string(&foundry_toml); let _config: BasicConfig = parse_with_profile(&s).unwrap().unwrap().1; -}); +} + +// Checks that clone follows the SparkLend USDS proxy to its AToken implementation. +#[forgetest] +fn flaky_can_clone_proxy_implementation(prj: _, cmd: _) { + prj.wipe(); + + cmd.args([ + "clone", + "--etherscan-api-key", + next_etherscan_api_key().as_str(), + "--implementation", + "0xC02aB1A5eaA8d1B114EF786D9bde108cD4364359", + ]) + .arg(prj.root()) + .assert_success(); + + let metadata: serde_json::Value = + serde_json::from_str(&read_string(prj.root().join(".clone.meta"))).unwrap(); + assert_eq!(metadata["targetContract"], "AToken"); + assert_eq!(metadata["address"], "0x6175ddec3b9b38c88157c10a01ed4a3fa8639cc6"); + assert!(prj.root().join(metadata["path"].as_str().unwrap()).exists()); +} + +// Checks that `--no-commit` is accepted as a noop backwards-compatibility flag for clone +#[forgetest] +fn flaky_can_clone_with_no_commit(prj: _, cmd: _) { + prj.wipe(); + + cmd.args([ + "clone", + "--etherscan-api-key", + next_etherscan_api_key().as_str(), + "--no-commit", + "0x044b75f554b886A065b9567891e45c79542d7357", + ]) + .arg(prj.root()) + .assert_success(); +} // Checks that quiet mode does not print anything for clone -forgetest!(can_clone_quiet, |prj, cmd| { +#[forgetest] +fn flaky_can_clone_quiet(prj: _, cmd: _) { prj.wipe(); cmd.args([ @@ -654,10 +804,43 @@ forgetest!(can_clone_quiet, |prj, cmd| { ]) .arg(prj.root()) .assert_empty_stdout(); -}); +} + +// checks that clone works with sourcify +#[forgetest] +fn flaky_can_clone_sourcify(prj: _, cmd: _) { + prj.wipe(); + + let foundry_toml = prj.root().join(Config::FILE_NAME); + assert!(!foundry_toml.exists()); + + cmd.args(["clone", "--source", "sourcify", "0xDb53f47aC61FE54F456A4eb3E09832D08Dd7BEec"]) + .arg(prj.root()) + .assert_success() + .stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +"#]]) + .stderr_eq(str![[r#" +Downloading the source code of 0xDb53f47aC61FE54F456A4eb3E09832D08Dd7BEec from Sourcify... +Initializing [..]... +Installing forge-std in [..] (url: https://github.com/foundry-rs/forge-std, tag: None) +... + Installed forge-std[..] + Initialized forge project +Collecting the creation information of 0xDb53f47aC61FE54F456A4eb3E09832D08Dd7BEec from Sourcify... + +"#]]); + + let s = read_string(&foundry_toml); + let _config: BasicConfig = parse_with_profile(&s).unwrap().unwrap().1; +} // checks that clone works with --no-remappings-txt -forgetest!(can_clone_no_remappings_txt, |prj, cmd| { +#[forgetest] +fn flaky_can_clone_no_remappings_txt(prj: _, cmd: _) { prj.wipe(); let foundry_toml = prj.root().join(Config::FILE_NAME); @@ -673,24 +856,30 @@ forgetest!(can_clone_no_remappings_txt, |prj, cmd| { .arg(prj.root()) .assert_success() .stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +"#]]) + .stderr_eq(str![[r#" Downloading the source code of 0x33e690aEa97E4Ef25F0d140F1bf044d663091DAf from Etherscan... Initializing [..]... -Installing forge-std in [..] (url: Some("https://github.com/foundry-rs/forge-std"), tag: None) +Installing forge-std in [..] (url: https://github.com/foundry-rs/forge-std, tag: None) +... Installed forge-std[..] Initialized forge project Collecting the creation information of 0x33e690aEa97E4Ef25F0d140F1bf044d663091DAf from Etherscan... -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! "#]]); let s = read_string(&foundry_toml); let _config: BasicConfig = parse_with_profile(&s).unwrap().unwrap().1; -}); +} // checks that clone works with --keep-directory-structure -forgetest!(can_clone_keep_directory_structure, |prj, cmd| { +#[forgetest] +#[expect(clippy::disallowed_macros, reason = "skips have to be visible in the test log")] +fn flaky_can_clone_keep_directory_structure(prj: _, cmd: _) { prj.wipe(); let foundry_toml = prj.root().join(Config::FILE_NAME); @@ -726,58 +915,228 @@ forgetest!(can_clone_keep_directory_structure, |prj, cmd| { let s = read_string(&foundry_toml); let _config: BasicConfig = parse_with_profile(&s).unwrap().unwrap().1; -}); +} -// checks that clone works with raw src containing `node_modules` -// -forgetest!(can_clone_with_node_modules, |prj, cmd| { +// checks that `forge init` works. +#[forgetest] +fn can_init_project(prj: _, cmd: _) { prj.wipe(); - let foundry_toml = prj.root().join(Config::FILE_NAME); - assert!(!foundry_toml.exists()); + cmd.args(["init"]).arg(prj.root()).assert_success().stdout_eq(str![""]).stderr_eq(str![[r#" +Initializing [..]... +Installing forge-std in [..] (url: https://github.com/foundry-rs/forge-std, tag: None) +... + Installed forge-std[..] + Initialized forge project - cmd.args([ - "clone", - "--etherscan-api-key", +"#]]); + + assert!(prj.root().join("foundry.toml").exists()); + assert!(prj.root().join("lib/forge-std").exists()); + + assert!(prj.root().join("src").exists()); + assert!(prj.root().join("src").join("Counter.sol").exists()); + + assert!(prj.root().join("test").exists()); + assert!(prj.root().join("test").join("Counter.t.sol").exists()); + + assert!(prj.root().join("script").exists()); + assert!(prj.root().join("script").join("Counter.s.sol").exists()); + + assert!(prj.root().join(".github").join("workflows").exists()); + assert!(prj.root().join(".github").join("workflows").join("test.yml").exists()); +} + +// checks that `forge init --vyper` works. +#[forgetest] +fn can_init_vyper_project(prj: _, cmd: _) { + prj.wipe(); + + cmd.args(["init", "--vyper"]).arg(prj.root()).assert_success().stdout_eq(str![""]).stderr_eq( + str![[r#" +Initializing [..]... +Installing forge-std in [..] (url: https://github.com/foundry-rs/forge-std, tag: None) +... + Installed forge-std[..] + Initialized forge project + +"#]], + ); + + assert!(prj.root().join("foundry.toml").exists()); + assert!(prj.root().join("lib/forge-std").exists()); + + assert!(prj.root().join("src").exists()); + assert!(prj.root().join("src").join("Counter.vy").exists()); + assert!(prj.root().join("src").join("ICounter.sol").exists()); + + assert!(prj.root().join("test").exists()); + assert!(prj.root().join("test").join("Counter.t.sol").exists()); + + assert!(prj.root().join("script").exists()); + assert!(prj.root().join("script").join("Counter.s.sol").exists()); + + assert!(prj.root().join(".github").join("workflows").exists()); + assert!(prj.root().join(".github").join("workflows").join("test.yml").exists()); +} + +// checks that `forge init --network tempo` works. +#[forgetest] +fn can_init_tempo_project(prj: _, cmd: _) { + prj.wipe(); + + cmd.args(["init", "--network", "tempo"]) + .arg(prj.root()) + .assert_success() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Initializing [..]... +... +Installing forge-std in [..] (url: https://github.com/foundry-rs/forge-std, tag: None) +... + Installed forge-std[..] +Installing tempo-std in [..] (url: https://github.com/tempoxyz/tempo-std, tag: None) +... + Installed tempo-std[..] + Initialized forge project + +"#]]); + + assert!(prj.root().join("foundry.toml").exists()); + + // Verify foundry.toml contains `network = "tempo"` so subsequent commands auto-detect the + // network. + let foundry_toml = std::fs::read_to_string(prj.root().join("foundry.toml")).unwrap(); + assert!( + foundry_toml.contains("network = \"tempo\""), + "foundry.toml should contain `network = \"tempo\"`, got:\n{foundry_toml}" + ); + assert!( + foundry_toml.contains("[rpc_endpoints]") + && foundry_toml.contains("tempo = \"https://rpc.mpp.tempo.xyz\"") + && foundry_toml.contains("moderato = \"https://rpc.mpp.moderato.tempo.xyz\""), + "foundry.toml should contain tempo rpc_endpoints, got:\n{foundry_toml}" + ); + + assert!(prj.root().join("lib/forge-std").exists()); + assert!(prj.root().join("lib/tempo-std").exists()); + + assert!(prj.root().join("src").exists()); + assert!(prj.root().join("src").join("Mail.sol").exists()); + + assert!(prj.root().join("test").exists()); + assert!(prj.root().join("test").join("Mail.t.sol").exists()); + + assert!(prj.root().join("script").exists()); + assert!(prj.root().join("script").join("Mail.s.sol").exists()); + + assert!(prj.root().join(".github").join("workflows").exists()); + assert!(prj.root().join(".github").join("workflows").join("test.yml").exists()); + + assert!(prj.root().join("README.md").exists()); +} + +// checks that `forge init --network tempo` correctly setup network key in config +#[forgetest] +fn can_execute_test_and_script_with_default_tempo_config(prj: _, cmd: _) { + prj.wipe(); + + // Initialize a Tempo project. + cmd.args(["init", "--network", "tempo"]).arg(prj.root()).assert_success(); + + // Run tests, Tempo EVM selection is made by reading foundry.toml config + cmd.forge_fuse().arg("test").arg("--root").arg(prj.root()).assert_success(); + + // Same for script + cmd.forge_fuse() + .arg("script") + .arg("script/Mail.s.sol") + .arg("temposalt") + .arg("--tempo") + .arg("--root") + .arg(prj.root()) + .assert_success(); +} + +// checks that `forge test --tempo` and `forge test -n tempo` are equivalent +#[forgetest] +fn network_flag_tempo_equivalent_to_legacy_tempo(prj: _, cmd: _) { + prj.wipe(); + cmd.args(["init", "--network", "tempo"]).arg(prj.root()).assert_success(); + + // --network tempo (new flag) + cmd.forge_fuse() + .args(["test", "--network", "tempo"]) + .arg("--root") + .arg(prj.root()) + .assert_success(); + + // -n tempo (short form) + cmd.forge_fuse().args(["test", "-n", "tempo"]).arg("--root").arg(prj.root()).assert_success(); + + // --tempo (legacy flag) + cmd.forge_fuse().args(["test", "--tempo"]).arg("--root").arg(prj.root()).assert_success(); +} + +// checks that clone works with raw src containing `node_modules` +// +#[forgetest] +fn flaky_can_clone_with_node_modules(prj: _, cmd: _) { + prj.wipe(); + + let foundry_toml = prj.root().join(Config::FILE_NAME); + assert!(!foundry_toml.exists()); + + cmd.args([ + "clone", + "--etherscan-api-key", next_etherscan_api_key().as_str(), "0xA3E217869460bEf59A1CfD0637e2875F9331e823", ]) .arg(prj.root()) .assert_success() .stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +"#]]) + .stderr_eq(str![[r#" Downloading the source code of 0xA3E217869460bEf59A1CfD0637e2875F9331e823 from Etherscan... Initializing [..]... -Installing forge-std in [..] (url: Some("https://github.com/foundry-rs/forge-std"), tag: None) +Installing forge-std in [..] (url: https://github.com/foundry-rs/forge-std, tag: None) +... Installed forge-std[..] Initialized forge project Collecting the creation information of 0xA3E217869460bEf59A1CfD0637e2875F9331e823 from Etherscan... -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! "#]]); -}); +} // checks that `clean` removes dapptools style paths -forgetest!(can_clean, |prj, cmd| { +#[forgetest] +fn can_clean(prj: _, cmd: _) { prj.assert_create_dirs_exists(); prj.assert_style_paths_exist(PathStyle::Dapptools); cmd.arg("clean"); cmd.assert_empty_stdout(); prj.assert_cleaned(); -}); +} // checks that `clean` removes hardhat style paths -forgetest!(can_clean_hardhat, PathStyle::HardHat, |prj, cmd| { +#[forgetest(PathStyle::HardHat)] +fn can_clean_hardhat(prj: _, cmd: _) { prj.assert_create_dirs_exists(); prj.assert_style_paths_exist(PathStyle::HardHat); cmd.arg("clean"); cmd.assert_empty_stdout(); prj.assert_cleaned(); -}); +} // checks that `clean` also works with the "out" value set in Config -forgetest_init!(can_clean_config, |prj, cmd| { +#[forgetest_init] +fn can_clean_config(prj: _, cmd: _) { + prj.initialize_default_contracts(); prj.update_config(|config| config.out = "custom-out".into()); cmd.arg("build").assert_success().stdout_eq(str![[r#" [COMPILING_FILES] with [SOLC_VERSION] @@ -792,10 +1151,12 @@ Compiler run successful! cmd.forge_fuse().arg("clean").assert_empty_stdout(); assert!(!artifact.exists()); -}); +} // checks that `clean` removes fuzz and invariant cache dirs -forgetest_init!(can_clean_test_cache, |prj, cmd| { +#[forgetest_init] +fn can_clean_test_cache(prj: _, cmd: _) { + prj.initialize_default_contracts(); prj.update_config(|config| { config.fuzz = FuzzConfig::new("cache/fuzz".into()); config.invariant = InvariantConfig::new("cache/invariant".into()); @@ -805,17 +1166,26 @@ forgetest_init!(can_clean_test_cache, |prj, cmd| { let _ = fs::create_dir(fuzz_cache_dir.clone()); let invariant_cache_dir = prj.root().join("cache/invariant"); let _ = fs::create_dir(invariant_cache_dir.clone()); + let frontier_cache_dir = prj.root().join("cache/frontiers"); + let _ = fs::create_dir(frontier_cache_dir.clone()); + prj.update_config(|config| { + config.fuzz.corpus.frontier_dir = Some("cache/frontiers".into()); + }); assert!(fuzz_cache_dir.exists()); assert!(invariant_cache_dir.exists()); + assert!(frontier_cache_dir.exists()); cmd.forge_fuse().arg("clean").assert_empty_stdout(); assert!(!fuzz_cache_dir.exists()); assert!(!invariant_cache_dir.exists()); -}); + assert!(!frontier_cache_dir.exists()); +} // checks that extra output works -forgetest_init!(can_emit_extra_output, |prj, cmd| { +#[forgetest_init] +fn can_emit_extra_output(prj: _, cmd: _) { + prj.initialize_default_contracts(); prj.clear(); cmd.args(["build", "--extra-output", "metadata"]).assert_success().stdout_eq(str![[r#" @@ -844,10 +1214,12 @@ Compiler run successful! let metadata_path = prj.paths().artifacts.join(format!("{TEMPLATE_CONTRACT_ARTIFACT_BASE}.metadata.json")); let _artifact: Metadata = foundry_compilers::utils::read_json_file(&metadata_path).unwrap(); -}); +} // checks that extra output works -forgetest_init!(can_emit_multiple_extra_output, |prj, cmd| { +#[forgetest_init] +fn can_emit_multiple_extra_output(prj: _, cmd: _) { + prj.initialize_default_contracts(); cmd.args([ "build", "--extra-output", @@ -908,9 +1280,10 @@ Compiler run successful! .artifacts .join(format!("{TEMPLATE_CONTRACT_ARTIFACT_BASE}.legacyAssembly.json")); std::fs::read_to_string(legacy_assembly).unwrap(); -}); +} -forgetest!(can_print_warnings, |prj, cmd| { +#[forgetest] +fn can_print_warnings(prj: _, cmd: _) { prj.add_source( "Foo", r" @@ -920,8 +1293,7 @@ contract Greeter { } } ", - ) - .unwrap(); + ); cmd.arg("build").assert_success().stdout_eq(str![[r#" [COMPILING_FILES] with [SOLC_VERSION] @@ -947,10 +1319,11 @@ Warning (2018): Function state mutability can be restricted to pure "#]]); -}); +} // Tests that direct import paths are handled correctly -forgetest!(can_handle_direct_imports_into_src, |prj, cmd| { +#[forgetest] +fn can_handle_direct_imports_into_src(prj: _, cmd: _) { prj.add_source( "Foo", r#" @@ -969,8 +1342,7 @@ contract Foo { } } "#, - ) - .unwrap(); + ); prj.add_source( "FooLib", @@ -981,8 +1353,7 @@ library FooLib { function check2(Foo f) public {} } "#, - ) - .unwrap(); + ); cmd.arg("build").assert_success().stdout_eq(str![[r#" [COMPILING_FILES] with [SOLC_VERSION] @@ -990,15 +1361,15 @@ library FooLib { Compiler run successful! "#]]); -}); +} // tests that the `inspect` command works correctly -forgetest!(can_execute_inspect_command, |prj, cmd| { +#[forgetest] +fn can_execute_inspect_command(prj: _, cmd: _) { let contract_name = "Foo"; - let path = prj - .add_source( - contract_name, - r#" + let path = prj.add_source( + contract_name, + r#" contract Foo { event log_string(string); function run() external { @@ -1006,8 +1377,7 @@ contract Foo { } } "#, - ) - .unwrap(); + ); cmd.arg("inspect").arg(contract_name).arg("bytecode").assert_success().stdout_eq(str![[r#" 0x60806040[..] @@ -1021,10 +1391,189 @@ contract Foo { "# ]]); -}); + + // `--json` wraps the bytecode hex as a JSON string + cmd.forge_fuse() + .args(["inspect", contract_name, "bytecode", "--json"]) + .assert_success() + .stdout_eq(str![[r#" +"0x60806040[..]" + +"#]]); +} + +// tests that `forge inspect transientStorageLayout` works +#[forgetest] +fn can_inspect_transient_storage_layout(prj: _, cmd: _) { + prj.add_source( + "Transient.sol", + r#" +contract Transient { + uint256 transient counter; + address transient owner; +} + "#, + ); + + cmd.arg("inspect").args(["Transient", "transientStorageLayout"]).assert_success().stdout_eq( + str![[r#" + +╭---------+---------+------+--------+-------+-----------------------------╮ +| Name | Type | Slot | Offset | Bytes | Contract | ++=========================================================================+ +| counter | uint256 | 0 | 0 | 32 | src/Transient.sol:Transient | +|---------+---------+------+--------+-------+-----------------------------| +| owner | address | 1 | 0 | 20 | src/Transient.sol:Transient | +╰---------+---------+------+--------+-------+-----------------------------╯ + + +"#]], + ); + + // `--json` prints the raw storage layout object. + cmd.forge_fuse() + .args(["inspect", "Transient", "transientStorageLayout", "--json"]) + .assert_success() + .stdout_eq( + str![[r#" +{ + "storage": [ + { + "astId": "{...}", + "contract": "src/Transient.sol:Transient", + "label": "counter", + "offset": 0, + "slot": "0", + "type": "t_uint256" + }, + { + "astId": "{...}", + "contract": "src/Transient.sol:Transient", + "label": "owner", + "offset": 0, + "slot": "1", + "type": "t_address" + } + ], + "types": { + "t_address": { + "encoding": "inplace", + "label": "address", + "numberOfBytes": "20" + }, + "t_uint256": { + "encoding": "inplace", + "label": "uint256", + "numberOfBytes": "32" + } + } +} + +"#]] + .is_json(), + ); +} + +#[forgetest] +fn can_inspect_linearization_markdown(prj: _, cmd: _) { + prj.add_source("A.sol", "contract A {}"); + prj.add_source("B.sol", r#"import {A} from "./A.sol"; contract B is A {}"#); + prj.add_source("C.sol", r#"import {B} from "./B.sol"; contract C is B {}"#); + + cmd.forge_fuse().args(["inspect", "C", "linearization", "--md"]).assert_success().stdout_eq( + str![[r#" + +| Order | Source | Contract | +|-------|-----------|----------| +| 0 | src/C.sol | C | +| 1 | src/B.sol | B | +| 2 | src/A.sol | A | + + +"#]], + ); +} + +#[forgetest] +fn can_inspect_linearization_json(prj: _, cmd: _) { + prj.add_source("A.sol", "contract A {}"); + prj.add_source("B.sol", r#"import {A} from "./A.sol"; contract B is A {}"#); + prj.add_source("C.sol", r#"import {B} from "./B.sol"; contract C is B {}"#); + + cmd.forge_fuse().args(["inspect", "C", "linearization", "--json"]).assert_success().stdout_eq( + str![[r#" +[ + { + "order": 0, + "source": "src/C.sol", + "contract": "C" + }, + { + "order": 1, + "source": "src/B.sol", + "contract": "B" + }, + { + "order": 2, + "source": "src/A.sol", + "contract": "A" + } +] + +"#]], + ); +} + +#[forgetest] +fn can_inspect_linearization_path_qualified_contract(prj: _, cmd: _) { + prj.add_source("one/Base.sol", "contract Base {}"); + prj.add_source( + "one/Target.sol", + r#"import {Base} from "./Base.sol"; contract Target is Base {}"#, + ); + + prj.add_source("two/Base.sol", "contract Base {}"); + prj.add_source( + "two/Target.sol", + r#"import {Base} from "./Base.sol"; contract Target is Base {}"#, + ); + + cmd.forge_fuse() + .args(["inspect", "src/two/Target.sol:Target", "linearization", "--json"]) + .assert_success() + .stdout_eq(str![[r#" +[ + { + "order": 0, + "source": "src/two/Target.sol", + "contract": "Target" + }, + { + "order": 1, + "source": "src/two/Base.sol", + "contract": "Base" + } +] + +"#]]); +} + +#[forgetest] +fn cannot_inspect_linearization_non_solidity_target(prj: _, cmd: _) { + prj.create_file("src/NotSol.vy", "x: uint256"); + + cmd.forge_fuse() + .args(["inspect", "src/NotSol.vy", "linearization"]) + .assert_failure() + .stderr_eq(str![[r#" +Error: linearization inspection is only supported for Solidity contracts (.sol targets) + +"#]]); +} // test that `forge snapshot` commands work -forgetest!(can_check_snapshot, |prj, cmd| { +#[forgetest] +fn can_check_snapshot(prj: _, cmd: _) { prj.insert_ds_test(); prj.add_source( @@ -1037,8 +1586,7 @@ contract ATest is DSTest { } } "#, - ) - .unwrap(); + ); cmd.args(["snapshot"]).assert_success().stdout_eq(str![[r#" [COMPILING_FILES] with [SOLC_VERSION] @@ -1063,11 +1611,155 @@ Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) "#]]); -}); +} + +#[forgetest] +fn snapshot_expands_invariant_campaign_predicates(prj: _, cmd: _) { + prj.add_test( + "InvariantSnapshot.t.sol", + r#" +contract InvariantSnapshotHandler { + uint256 public count; + + function increment() public { + count++; + } +} + +contract InvariantSnapshotTest { + InvariantSnapshotHandler internal handler; + + function setUp() public { + handler = new InvariantSnapshotHandler(); + } + + /// forge-config: default.invariant.runs = 2 + /// forge-config: default.invariant.depth = 4 + function invariant_count_is_non_negative() public view { + require(handler.count() >= 0); + } + + /// forge-config: default.invariant.runs = 2 + /// forge-config: default.invariant.depth = 4 + function invariant_count_is_not_max() public view { + require(handler.count() != type(uint256).max); + } +} + "#, + ); + + cmd.args(["snapshot"]).assert_success(); + + let snapshot = read_string(prj.root().join(".gas-snapshot")); + assert_eq!( + snapshot.lines().filter(|line| line.starts_with("InvariantSnapshotTest:")).count(), + 2 + ); + assert!(snapshot.contains("InvariantSnapshotTest:invariant_count_is_non_negative()")); + assert!(snapshot.contains("InvariantSnapshotTest:invariant_count_is_not_max()")); +} + +// tests that `forge snapshot --diff` keeps per-test diff rows on stdout but moves summary to stderr +#[forgetest] +fn snapshot_diff_summary_on_stderr(prj: _, cmd: _) { + prj.insert_ds_test(); + + prj.add_source( + "ATest.t.sol", + r#" +import "./test.sol"; +contract ATest is DSTest { + function testExample() public { + assertTrue(true); + } +} + "#, + ); + + // Baseline snapshot. + cmd.args(["snapshot"]).assert_success(); + + // Re-run with --diff so the per-test diff row is emitted. + let assert = cmd.forge_fuse().args(["snapshot", "--diff"]).assert_success(); + let output = assert.get_output(); + let stdout = output.stdout_lossy(); + let stderr = output.stderr_lossy(); + assert!( + stdout.contains("ATest::testExample()"), + "expected diff row on stdout, got stdout: {stdout}" + ); + assert!( + stderr.contains("Total tests:") && stderr.contains("Overall gas change:"), + "expected summary on stderr, got stderr: {stderr}" + ); + assert!( + !stdout.contains("Total tests:") && !stdout.contains("Overall gas change:"), + "summary leaked to stdout: {stdout}" + ); +} + +// tests that `forge snapshot --check` reports mismatches on stderr +#[forgetest] +fn snapshot_check_writes_diff_to_stderr(prj: _, cmd: _) { + prj.insert_ds_test(); + + prj.add_source( + "ATest.t.sol", + r#" +import "./test.sol"; +contract ATest is DSTest { + function testExample() public { + assertTrue(true); + } +} + "#, + ); + + // Pre-seed an empty snapshot so `--check` triggers the "No matching snapshot entry" path. + fs::write(prj.root().join(".gas-snapshot"), "").unwrap(); + + let assert = cmd.args(["snapshot", "--check"]).assert_failure(); + let output = assert.get_output(); + let stdout = output.stdout_lossy(); + let stderr = output.stderr_lossy(); + assert!( + stderr.contains("No matching snapshot entry found for \"ATest::testExample()\""), + "expected diff on stderr, got stderr: {stderr}\nstdout: {stdout}" + ); + assert!( + !stdout.contains("No matching snapshot entry"), + "diff prose leaked to stdout: {stdout}" + ); +} + +#[forgetest] +fn snapshot_reports_when_snap_file_is_not_written(prj: _, cmd: _) { + prj.insert_ds_test(); + + prj.add_source( + "FailingSnapshot.t.sol", + r#" +import "./test.sol"; +contract FailingSnapshotTest is DSTest { + function testSnapshotFailure() public { + assertTrue(false); + } +} + "#, + ); + + let assert = cmd.args(["snapshot", "--snap", "failed.snap"]).assert_failure(); + let output = assert.get_output(); + assert!(output.stderr_lossy().contains( + "Error: gas snapshot file \"failed.snap\" was not written because the test run failed" + )); + assert!(!prj.root().join("failed.snap").exists()); +} // test that `forge build` does not print `(with warnings)` if file path is ignored -forgetest!(can_compile_without_warnings_ignored_file_paths, |prj, cmd| { - // Ignoring path and setting empty error_codes as default would set would set some error codes +#[forgetest] +fn can_compile_without_warnings_ignored_file_paths(prj: _, cmd: _) { + // Ignoring path and setting empty error_codes as default would set some error codes prj.update_config(|config| { config.ignored_file_paths = vec![Path::new("src").to_path_buf()]; config.ignored_error_codes = vec![]; @@ -1081,8 +1773,7 @@ contract A { function testExample() public {} } ", - ) - .unwrap(); + ); cmd.args(["build", "--force"]).assert_success().stdout_eq(str![[r#" [COMPILING_FILES] with [SOLC_VERSION] @@ -1104,10 +1795,11 @@ Warning: SPDX license identifier not provided in source file. Before publishing, "#]]); -}); +} // test that `forge build` does not print `(with warnings)` if there aren't any -forgetest!(can_compile_without_warnings, |prj, cmd| { +#[forgetest] +fn can_compile_without_warnings(prj: _, cmd: _) { prj.update_config(|config| { config.ignored_error_codes = vec![SolidityErrorCode::SpdxLicenseNotProvided]; }); @@ -1119,8 +1811,7 @@ contract A { function testExample() public {} } ", - ) - .unwrap(); + ); cmd.args(["build", "--force"]).assert_success().stdout_eq(str![[r#" [COMPILING_FILES] with [SOLC_VERSION] @@ -1144,25 +1835,29 @@ Warning: SPDX license identifier not provided in source file. Before publishing, "#]]); -}); +} // test that `forge build` compiles when severity set to error, fails when set to warning, and // handles ignored error codes as an exception -forgetest!(can_fail_compile_with_warnings, |prj, cmd| { +#[forgetest] +fn can_fail_compile_with_warnings(prj: _, cmd: _) { prj.update_config(|config| { config.ignored_error_codes = vec![]; - config.deny_warnings = false; + config.deny = DenyLevel::Never; }); prj.add_raw_source( "A", r" pragma solidity *; contract A { - function testExample() public {} + event Example(); + + function testExample() public { + emit Example(); + } } ", - ) - .unwrap(); + ); // there are no errors cmd.args(["build", "--force"]).assert_success().stdout_eq(str![[r#" @@ -1179,7 +1874,7 @@ Warning: SPDX license identifier not provided in source file. Before publishing, // warning fails to compile prj.update_config(|config| { config.ignored_error_codes = vec![]; - config.deny_warnings = true; + config.deny = DenyLevel::Warnings; }); cmd.forge_fuse().args(["build", "--force"]).assert_failure().stderr_eq(str![[r#" @@ -1193,7 +1888,7 @@ Warning: SPDX license identifier not provided in source file. Before publishing, // ignores error code and compiles prj.update_config(|config| { config.ignored_error_codes = vec![SolidityErrorCode::SpdxLicenseNotProvided]; - config.deny_warnings = true; + config.deny = DenyLevel::Warnings; }); cmd.forge_fuse().args(["build", "--force"]).assert_success().stdout_eq(str![[r#" @@ -1202,10 +1897,57 @@ Warning: SPDX license identifier not provided in source file. Before publishing, Compiler run successful! "#]]); -}); +} + +// test that `forge build` ignores error codes only from matching path prefixes +#[forgetest] +fn can_compile_without_warnings_ignored_error_codes_from(prj: _, cmd: _) { + let contract = r" +pragma solidity *; +contract A {} + "; + prj.add_raw_source("A", contract); + prj.add_raw_test("A", contract); + + // suppressed for both src and test + prj.update_config(|config| { + config.ignored_error_codes = vec![]; + config.ignored_error_codes_from = vec![ + (std::path::PathBuf::from("src"), vec![SolidityErrorCode::SpdxLicenseNotProvided]), + (std::path::PathBuf::from("test"), vec![SolidityErrorCode::SpdxLicenseNotProvided]), + ]; + }); + + cmd.args(["build", "--force"]).assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +"#]]); + + // suppressed only for test, src warning still shows + prj.update_config(|config| { + config.ignored_error_codes_from = vec![( + std::path::PathBuf::from("test"), + vec![SolidityErrorCode::SpdxLicenseNotProvided], + )]; + }); + + cmd.forge_fuse().args(["build", "--force"]).assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful with warnings: +Warning (1878): SPDX license identifier not provided in source file. Before publishing, consider adding a comment containing "SPDX-License-Identifier: " to each source file. Use "SPDX-License-Identifier: UNLICENSED" for non-open-source code. Please see https://spdx.org for more information. +Warning: SPDX license identifier not provided in source file. Before publishing, consider adding a comment containing "SPDX-License-Identifier: " to each source file. Use "SPDX-License-Identifier: UNLICENSED" for non-open-source code. Please see https://spdx.org for more information. +[FILE] + + +"#]]); +} // test that a failing `forge build` does not impact followup builds -forgetest!(can_build_after_failure, |prj, cmd| { +#[forgetest] +fn can_build_after_failure(prj: _, cmd: _) { prj.insert_ds_test(); // Disable linting during build to avoid linting output interfering with test assertions @@ -1223,8 +1965,7 @@ contract ATest is DSTest { } } "#, - ) - .unwrap(); + ); prj.add_source( "BTest.t.sol", r#" @@ -1235,8 +1976,7 @@ contract BTest is DSTest { } } "#, - ) - .unwrap(); + ); cmd.arg("build").assert_success().stdout_eq(str![[r#" [COMPILING_FILES] with [SOLC_VERSION] @@ -1259,7 +1999,7 @@ contract CTest is DSTest { "#; // introduce contract with syntax error - prj.add_source("CTest.t.sol", syntax_err).unwrap(); + prj.add_source("CTest.t.sol", syntax_err); // `forge build --force` which should fail cmd.forge_fuse().args(["build", "--force"]).assert_failure().stderr_eq(str![[r#" @@ -1298,8 +2038,7 @@ contract CTest is DSTest { } } "#, - ) - .unwrap(); + ); cmd.forge_fuse().args(["build", "--force"]).assert_success().stdout_eq(str![[r#" [COMPILING_FILES] with [SOLC_VERSION] @@ -1315,7 +2054,7 @@ Compiler run successful! let cache = fs::read_to_string(prj.cache()).unwrap(); // introduce the error again but building without force - prj.add_source("CTest.t.sol", syntax_err).unwrap(); + prj.add_source("CTest.t.sol", syntax_err); cmd.forge_fuse().arg("build").assert_failure().stderr_eq(str![[r#" Error: Compiler run failed: Error (2314): Expected ';' but got identifier @@ -1329,210 +2068,7 @@ Error (2314): Expected ';' but got identifier // ensure unchanged cache file let cache_after = fs::read_to_string(prj.cache()).unwrap(); assert_eq!(cache, cache_after); -}); - -// test to check that install/remove works properly -forgetest!(can_install_and_remove, |prj, cmd| { - cmd.git_init(); - - let libs = prj.root().join("lib"); - let git_mod = prj.root().join(".git/modules/lib"); - let git_mod_file = prj.root().join(".gitmodules"); - - let forge_std = libs.join("forge-std"); - let forge_std_mod = git_mod.join("forge-std"); - - let install = |cmd: &mut TestCommand| { - cmd.forge_fuse().args(["install", "foundry-rs/forge-std"]).assert_success().stdout_eq( - str![[r#" -Installing forge-std in [..] (url: Some("https://github.com/foundry-rs/forge-std"), tag: None) - Installed forge-std[..] - -"#]], - ); - - assert!(forge_std.exists()); - assert!(forge_std_mod.exists()); - - let submods = read_string(&git_mod_file); - assert!(submods.contains("https://github.com/foundry-rs/forge-std")); - }; - - let remove = |cmd: &mut TestCommand, target: &str| { - // TODO: flaky behavior with URL, sometimes it is None, sometimes it is Some("https://github.com/lib/forge-std") - cmd.forge_fuse().args(["remove", "--force", target]).assert_success().stdout_eq(str![[ - r#" -Removing 'forge-std' in [..], (url: [..], tag: None) - -"# - ]]); - - assert!(!forge_std.exists()); - assert!(!forge_std_mod.exists()); - let submods = read_string(&git_mod_file); - assert!(!submods.contains("https://github.com/foundry-rs/forge-std")); - }; - - install(&mut cmd); - remove(&mut cmd, "forge-std"); - - // install again and remove via relative path - install(&mut cmd); - remove(&mut cmd, "lib/forge-std"); -}); - -// test to check we can run `forge install` in an empty dir -forgetest!(can_install_empty, |prj, cmd| { - // create - cmd.git_init(); - cmd.forge_fuse().args(["install"]); - cmd.assert_empty_stdout(); - - // create initial commit - fs::write(prj.root().join("README.md"), "Initial commit").unwrap(); - - cmd.git_add(); - cmd.git_commit("Initial commit"); - - cmd.forge_fuse().args(["install"]); - cmd.assert_empty_stdout(); -}); - -// test to check that package can be reinstalled after manually removing the directory -forgetest!(can_reinstall_after_manual_remove, |prj, cmd| { - cmd.git_init(); - - let libs = prj.root().join("lib"); - let git_mod = prj.root().join(".git/modules/lib"); - let git_mod_file = prj.root().join(".gitmodules"); - - let forge_std = libs.join("forge-std"); - let forge_std_mod = git_mod.join("forge-std"); - - let install = |cmd: &mut TestCommand| { - cmd.forge_fuse().args(["install", "foundry-rs/forge-std"]).assert_success().stdout_eq( - str![[r#" -Installing forge-std in [..] (url: Some("https://github.com/foundry-rs/forge-std"), tag: None) - Installed forge-std[..] - -"#]], - ); - - assert!(forge_std.exists()); - assert!(forge_std_mod.exists()); - - let submods = read_string(&git_mod_file); - assert!(submods.contains("https://github.com/foundry-rs/forge-std")); - }; - - install(&mut cmd); - fs::remove_dir_all(forge_std.clone()).expect("Failed to remove forge-std"); - - // install again - install(&mut cmd); -}); - -// test that we can repeatedly install the same dependency without changes -forgetest!(can_install_repeatedly, |_prj, cmd| { - cmd.git_init(); - - cmd.forge_fuse().args(["install", "foundry-rs/forge-std"]); - for _ in 0..3 { - cmd.assert_success(); - } -}); - -// test that by default we install the latest semver release tag -// -forgetest!(can_install_latest_release_tag, |prj, cmd| { - cmd.git_init(); - cmd.forge_fuse().args(["install", "openzeppelin/openzeppelin-contracts"]); - cmd.assert_success(); - - let dep = prj.paths().libraries[0].join("openzeppelin-contracts"); - assert!(dep.exists()); - - // the latest release at the time this test was written - let version: Version = "4.8.0".parse().unwrap(); - let out = Command::new("git").current_dir(&dep).args(["describe", "--tags"]).output().unwrap(); - let tag = String::from_utf8_lossy(&out.stdout); - let current: Version = tag.as_ref().trim_start_matches('v').trim().parse().unwrap(); - - assert!(current >= version); -}); - -// Tests that forge update doesn't break a working dependency by recursively updating nested -// dependencies -forgetest!( - #[cfg_attr(windows, ignore = "weird git fail")] - can_update_library_with_outdated_nested_dependency, - |prj, cmd| { - cmd.git_init(); - - let libs = prj.root().join("lib"); - let git_mod = prj.root().join(".git/modules/lib"); - let git_mod_file = prj.root().join(".gitmodules"); - - // get paths to check inside install fn - let package = libs.join("forge-5980-test"); - let package_mod = git_mod.join("forge-5980-test"); - - // install main dependency - cmd.forge_fuse() - .args(["install", "evalir/forge-5980-test"]) - .assert_success() - .stdout_eq(str![[r#" -Installing forge-5980-test in [..] (url: Some("https://github.com/evalir/forge-5980-test"), tag: None) - Installed forge-5980-test - -"#]]); - - // assert paths exist - assert!(package.exists()); - assert!(package_mod.exists()); - - let submods = read_string(git_mod_file); - assert!(submods.contains("https://github.com/evalir/forge-5980-test")); - - // try to update the top-level dependency; there should be no update for this dependency, - // but its sub-dependency has upstream (breaking) changes; forge should not attempt to - // update the sub-dependency - cmd.forge_fuse().args(["update", "lib/forge-5980-test"]).assert_empty_stdout(); - - // add explicit remappings for test file - prj.update_config(|config| { - config.remappings = vec![ - Remapping::from_str("forge-5980-test/=lib/forge-5980-test/src/").unwrap().into(), - // explicit remapping for sub-dependency seems necessary for some reason - Remapping::from_str( - "forge-5980-test-dep/=lib/forge-5980-test/lib/forge-5980-test-dep/src/", - ) - .unwrap() - .into(), - ]; - }); - - // create test file that uses the top-level dependency; if the sub-dependency is updated, - // compilation will fail - prj.add_source( - "CounterCopy", - r#" -import "forge-5980-test/Counter.sol"; -contract CounterCopy is Counter { } - "#, - ) - .unwrap(); - - // build and check output - cmd.forge_fuse().arg("build").assert_success().stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -"#]]); - } -); const GAS_REPORT_CONTRACTS: &str = r#" //SPDX-license-identifier: MIT @@ -1619,9 +2155,10 @@ contract ContractThreeTest is DSTest { } "#; -forgetest!(gas_report_all_contracts, |prj, cmd| { +#[forgetest] +fn gas_report_all_contracts(prj: _, cmd: _) { prj.insert_ds_test(); - prj.add_source("Contracts.sol", GAS_REPORT_CONTRACTS).unwrap(); + prj.add_source("Contracts.sol", GAS_REPORT_CONTRACTS); // report for all prj.update_config(|config| { @@ -1636,13 +2173,13 @@ forgetest!(gas_report_all_contracts, |prj, cmd| { +=============================================================================================+ | Deployment Cost | Deployment Size | | | | | |----------------------------------------+-----------------+-------+--------+-------+---------| -| 133027 | 394 | | | | | +| 133015 | 394 | | | | | |----------------------------------------+-----------------+-------+--------+-------+---------| | | | | | | | |----------------------------------------+-----------------+-------+--------+-------+---------| | Function Name | Min | Avg | Median | Max | # Calls | |----------------------------------------+-----------------+-------+--------+-------+---------| -| foo | 45656 | 45656 | 45656 | 45656 | 1 | +| foo | 45656 | 45656 | 45656 | 45656 | 1 | ╰----------------------------------------+-----------------+-------+--------+-------+---------╯ ╭------------------------------------------+-----------------+--------+--------+--------+---------╮ @@ -1650,13 +2187,13 @@ forgetest!(gas_report_all_contracts, |prj, cmd| { +=================================================================================================+ | Deployment Cost | Deployment Size | | | | | |------------------------------------------+-----------------+--------+--------+--------+---------| -| 133243 | 395 | | | | | +| 133219 | 395 | | | | | |------------------------------------------+-----------------+--------+--------+--------+---------| | | | | | | | |------------------------------------------+-----------------+--------+--------+--------+---------| | Function Name | Min | Avg | Median | Max | # Calls | |------------------------------------------+-----------------+--------+--------+--------+---------| -| baz | 287711 | 287711 | 287711 | 287711 | 1 | +| baz | 287711 | 287711 | 287711 | 287711 | 1 | ╰------------------------------------------+-----------------+--------+--------+--------+---------╯ ╭----------------------------------------+-----------------+-------+--------+-------+---------╮ @@ -1664,13 +2201,13 @@ forgetest!(gas_report_all_contracts, |prj, cmd| { +=============================================================================================+ | Deployment Cost | Deployment Size | | | | | |----------------------------------------+-----------------+-------+--------+-------+---------| -| 133027 | 394 | | | | | +| 133015 | 394 | | | | | |----------------------------------------+-----------------+-------+--------+-------+---------| | | | | | | | |----------------------------------------+-----------------+-------+--------+-------+---------| | Function Name | Min | Avg | Median | Max | # Calls | |----------------------------------------+-----------------+-------+--------+-------+---------| -| bar | 67683 | 67683 | 67683 | 67683 | 1 | +| bar | 67683 | 67683 | 67683 | 67683 | 1 | ╰----------------------------------------+-----------------+-------+--------+-------+---------╯ @@ -1683,7 +2220,7 @@ Ran 3 test suites [ELAPSED]: 3 tests passed, 0 failed, 0 skipped (3 total tests) { "contract": "src/Contracts.sol:ContractOne", "deployment": { - "gas": 133027, + "gas": 133015, "size": 394 }, "functions": { @@ -1699,7 +2236,7 @@ Ran 3 test suites [ELAPSED]: 3 tests passed, 0 failed, 0 skipped (3 total tests) { "contract": "src/Contracts.sol:ContractThree", "deployment": { - "gas": 133243, + "gas": 133219, "size": 395 }, "functions": { @@ -1715,7 +2252,7 @@ Ran 3 test suites [ELAPSED]: 3 tests passed, 0 failed, 0 skipped (3 total tests) { "contract": "src/Contracts.sol:ContractTwo", "deployment": { - "gas": 133027, + "gas": 133015, "size": 394 }, "functions": { @@ -1741,13 +2278,13 @@ Ran 3 test suites [ELAPSED]: 3 tests passed, 0 failed, 0 skipped (3 total tests) +=============================================================================================+ | Deployment Cost | Deployment Size | | | | | |----------------------------------------+-----------------+-------+--------+-------+---------| -| 133027 | 394 | | | | | +| 133015 | 394 | | | | | |----------------------------------------+-----------------+-------+--------+-------+---------| | | | | | | | |----------------------------------------+-----------------+-------+--------+-------+---------| | Function Name | Min | Avg | Median | Max | # Calls | |----------------------------------------+-----------------+-------+--------+-------+---------| -| foo | 45656 | 45656 | 45656 | 45656 | 1 | +| foo | 45656 | 45656 | 45656 | 45656 | 1 | ╰----------------------------------------+-----------------+-------+--------+-------+---------╯ ╭------------------------------------------+-----------------+--------+--------+--------+---------╮ @@ -1755,13 +2292,13 @@ Ran 3 test suites [ELAPSED]: 3 tests passed, 0 failed, 0 skipped (3 total tests) +=================================================================================================+ | Deployment Cost | Deployment Size | | | | | |------------------------------------------+-----------------+--------+--------+--------+---------| -| 133243 | 395 | | | | | +| 133219 | 395 | | | | | |------------------------------------------+-----------------+--------+--------+--------+---------| | | | | | | | |------------------------------------------+-----------------+--------+--------+--------+---------| | Function Name | Min | Avg | Median | Max | # Calls | |------------------------------------------+-----------------+--------+--------+--------+---------| -| baz | 287711 | 287711 | 287711 | 287711 | 1 | +| baz | 287711 | 287711 | 287711 | 287711 | 1 | ╰------------------------------------------+-----------------+--------+--------+--------+---------╯ ╭----------------------------------------+-----------------+-------+--------+-------+---------╮ @@ -1769,13 +2306,13 @@ Ran 3 test suites [ELAPSED]: 3 tests passed, 0 failed, 0 skipped (3 total tests) +=============================================================================================+ | Deployment Cost | Deployment Size | | | | | |----------------------------------------+-----------------+-------+--------+-------+---------| -| 133027 | 394 | | | | | +| 133015 | 394 | | | | | |----------------------------------------+-----------------+-------+--------+-------+---------| | | | | | | | |----------------------------------------+-----------------+-------+--------+-------+---------| | Function Name | Min | Avg | Median | Max | # Calls | |----------------------------------------+-----------------+-------+--------+-------+---------| -| bar | 67683 | 67683 | 67683 | 67683 | 1 | +| bar | 67683 | 67683 | 67683 | 67683 | 1 | ╰----------------------------------------+-----------------+-------+--------+-------+---------╯ @@ -1788,7 +2325,7 @@ Ran 3 test suites [ELAPSED]: 3 tests passed, 0 failed, 0 skipped (3 total tests) { "contract": "src/Contracts.sol:ContractOne", "deployment": { - "gas": 133027, + "gas": 133015, "size": 394 }, "functions": { @@ -1804,7 +2341,7 @@ Ran 3 test suites [ELAPSED]: 3 tests passed, 0 failed, 0 skipped (3 total tests) { "contract": "src/Contracts.sol:ContractThree", "deployment": { - "gas": 133243, + "gas": 133219, "size": 395 }, "functions": { @@ -1820,7 +2357,7 @@ Ran 3 test suites [ELAPSED]: 3 tests passed, 0 failed, 0 skipped (3 total tests) { "contract": "src/Contracts.sol:ContractTwo", "deployment": { - "gas": 133027, + "gas": 133015, "size": 394 }, "functions": { @@ -1846,13 +2383,13 @@ Ran 3 test suites [ELAPSED]: 3 tests passed, 0 failed, 0 skipped (3 total tests) +=============================================================================================+ | Deployment Cost | Deployment Size | | | | | |----------------------------------------+-----------------+-------+--------+-------+---------| -| 133027 | 394 | | | | | +| 133015 | 394 | | | | | |----------------------------------------+-----------------+-------+--------+-------+---------| | | | | | | | |----------------------------------------+-----------------+-------+--------+-------+---------| | Function Name | Min | Avg | Median | Max | # Calls | |----------------------------------------+-----------------+-------+--------+-------+---------| -| foo | 45656 | 45656 | 45656 | 45656 | 1 | +| foo | 45656 | 45656 | 45656 | 45656 | 1 | ╰----------------------------------------+-----------------+-------+--------+-------+---------╯ ╭------------------------------------------+-----------------+--------+--------+--------+---------╮ @@ -1860,13 +2397,13 @@ Ran 3 test suites [ELAPSED]: 3 tests passed, 0 failed, 0 skipped (3 total tests) +=================================================================================================+ | Deployment Cost | Deployment Size | | | | | |------------------------------------------+-----------------+--------+--------+--------+---------| -| 133243 | 395 | | | | | +| 133219 | 395 | | | | | |------------------------------------------+-----------------+--------+--------+--------+---------| | | | | | | | |------------------------------------------+-----------------+--------+--------+--------+---------| | Function Name | Min | Avg | Median | Max | # Calls | |------------------------------------------+-----------------+--------+--------+--------+---------| -| baz | 287711 | 287711 | 287711 | 287711 | 1 | +| baz | 287711 | 287711 | 287711 | 287711 | 1 | ╰------------------------------------------+-----------------+--------+--------+--------+---------╯ ╭----------------------------------------+-----------------+-------+--------+-------+---------╮ @@ -1874,13 +2411,13 @@ Ran 3 test suites [ELAPSED]: 3 tests passed, 0 failed, 0 skipped (3 total tests) +=============================================================================================+ | Deployment Cost | Deployment Size | | | | | |----------------------------------------+-----------------+-------+--------+-------+---------| -| 133027 | 394 | | | | | +| 133015 | 394 | | | | | |----------------------------------------+-----------------+-------+--------+-------+---------| | | | | | | | |----------------------------------------+-----------------+-------+--------+-------+---------| | Function Name | Min | Avg | Median | Max | # Calls | |----------------------------------------+-----------------+-------+--------+-------+---------| -| bar | 67683 | 67683 | 67683 | 67683 | 1 | +| bar | 67683 | 67683 | 67683 | 67683 | 1 | ╰----------------------------------------+-----------------+-------+--------+-------+---------╯ @@ -1893,7 +2430,7 @@ Ran 3 test suites [ELAPSED]: 3 tests passed, 0 failed, 0 skipped (3 total tests) { "contract": "src/Contracts.sol:ContractOne", "deployment": { - "gas": 133027, + "gas": 133015, "size": 394 }, "functions": { @@ -1909,7 +2446,7 @@ Ran 3 test suites [ELAPSED]: 3 tests passed, 0 failed, 0 skipped (3 total tests) { "contract": "src/Contracts.sol:ContractThree", "deployment": { - "gas": 133243, + "gas": 133219, "size": 395 }, "functions": { @@ -1925,7 +2462,7 @@ Ran 3 test suites [ELAPSED]: 3 tests passed, 0 failed, 0 skipped (3 total tests) { "contract": "src/Contracts.sol:ContractTwo", "deployment": { - "gas": 133027, + "gas": 133015, "size": 394 }, "functions": { @@ -1954,13 +2491,13 @@ Ran 3 test suites [ELAPSED]: 3 tests passed, 0 failed, 0 skipped (3 total tests) +=============================================================================================+ | Deployment Cost | Deployment Size | | | | | |----------------------------------------+-----------------+-------+--------+-------+---------| -| 133027 | 394 | | | | | +| 133015 | 394 | | | | | |----------------------------------------+-----------------+-------+--------+-------+---------| | | | | | | | |----------------------------------------+-----------------+-------+--------+-------+---------| | Function Name | Min | Avg | Median | Max | # Calls | |----------------------------------------+-----------------+-------+--------+-------+---------| -| foo | 45656 | 45656 | 45656 | 45656 | 1 | +| foo | 45656 | 45656 | 45656 | 45656 | 1 | ╰----------------------------------------+-----------------+-------+--------+-------+---------╯ ╭------------------------------------------+-----------------+--------+--------+--------+---------╮ @@ -1968,13 +2505,13 @@ Ran 3 test suites [ELAPSED]: 3 tests passed, 0 failed, 0 skipped (3 total tests) +=================================================================================================+ | Deployment Cost | Deployment Size | | | | | |------------------------------------------+-----------------+--------+--------+--------+---------| -| 133243 | 395 | | | | | +| 133219 | 395 | | | | | |------------------------------------------+-----------------+--------+--------+--------+---------| | | | | | | | |------------------------------------------+-----------------+--------+--------+--------+---------| | Function Name | Min | Avg | Median | Max | # Calls | |------------------------------------------+-----------------+--------+--------+--------+---------| -| baz | 287711 | 287711 | 287711 | 287711 | 1 | +| baz | 287711 | 287711 | 287711 | 287711 | 1 | ╰------------------------------------------+-----------------+--------+--------+--------+---------╯ ╭----------------------------------------+-----------------+-------+--------+-------+---------╮ @@ -1982,13 +2519,13 @@ Ran 3 test suites [ELAPSED]: 3 tests passed, 0 failed, 0 skipped (3 total tests) +=============================================================================================+ | Deployment Cost | Deployment Size | | | | | |----------------------------------------+-----------------+-------+--------+-------+---------| -| 133027 | 394 | | | | | +| 133015 | 394 | | | | | |----------------------------------------+-----------------+-------+--------+-------+---------| | | | | | | | |----------------------------------------+-----------------+-------+--------+-------+---------| | Function Name | Min | Avg | Median | Max | # Calls | |----------------------------------------+-----------------+-------+--------+-------+---------| -| bar | 67683 | 67683 | 67683 | 67683 | 1 | +| bar | 67683 | 67683 | 67683 | 67683 | 1 | ╰----------------------------------------+-----------------+-------+--------+-------+---------╯ @@ -2001,7 +2538,7 @@ Ran 3 test suites [ELAPSED]: 3 tests passed, 0 failed, 0 skipped (3 total tests) { "contract": "src/Contracts.sol:ContractOne", "deployment": { - "gas": 133027, + "gas": 133015, "size": 394 }, "functions": { @@ -2017,7 +2554,7 @@ Ran 3 test suites [ELAPSED]: 3 tests passed, 0 failed, 0 skipped (3 total tests) { "contract": "src/Contracts.sol:ContractThree", "deployment": { - "gas": 133243, + "gas": 133219, "size": 395 }, "functions": { @@ -2033,7 +2570,7 @@ Ran 3 test suites [ELAPSED]: 3 tests passed, 0 failed, 0 skipped (3 total tests) { "contract": "src/Contracts.sol:ContractTwo", "deployment": { - "gas": 133027, + "gas": 133015, "size": 394 }, "functions": { @@ -2050,11 +2587,12 @@ Ran 3 test suites [ELAPSED]: 3 tests passed, 0 failed, 0 skipped (3 total tests) "#]] .is_json(), ); -}); +} -forgetest!(gas_report_some_contracts, |prj, cmd| { +#[forgetest] +fn gas_report_some_contracts(prj: _, cmd: _) { prj.insert_ds_test(); - prj.add_source("Contracts.sol", GAS_REPORT_CONTRACTS).unwrap(); + prj.add_source("Contracts.sol", GAS_REPORT_CONTRACTS); // report for One prj.update_config(|config| config.gas_reports = vec!["ContractOne".to_string()]); @@ -2066,13 +2604,13 @@ forgetest!(gas_report_some_contracts, |prj, cmd| { +=============================================================================================+ | Deployment Cost | Deployment Size | | | | | |----------------------------------------+-----------------+-------+--------+-------+---------| -| 133027 | 394 | | | | | +| 133015 | 394 | | | | | |----------------------------------------+-----------------+-------+--------+-------+---------| | | | | | | | |----------------------------------------+-----------------+-------+--------+-------+---------| | Function Name | Min | Avg | Median | Max | # Calls | |----------------------------------------+-----------------+-------+--------+-------+---------| -| foo | 45656 | 45656 | 45656 | 45656 | 1 | +| foo | 45656 | 45656 | 45656 | 45656 | 1 | ╰----------------------------------------+-----------------+-------+--------+-------+---------╯ @@ -2085,7 +2623,7 @@ Ran 3 test suites [ELAPSED]: 3 tests passed, 0 failed, 0 skipped (3 total tests) { "contract": "src/Contracts.sol:ContractOne", "deployment": { - "gas": 133027, + "gas": 133015, "size": 394 }, "functions": { @@ -2113,13 +2651,13 @@ Ran 3 test suites [ELAPSED]: 3 tests passed, 0 failed, 0 skipped (3 total tests) +=============================================================================================+ | Deployment Cost | Deployment Size | | | | | |----------------------------------------+-----------------+-------+--------+-------+---------| -| 133027 | 394 | | | | | +| 133015 | 394 | | | | | |----------------------------------------+-----------------+-------+--------+-------+---------| | | | | | | | |----------------------------------------+-----------------+-------+--------+-------+---------| | Function Name | Min | Avg | Median | Max | # Calls | |----------------------------------------+-----------------+-------+--------+-------+---------| -| bar | 67683 | 67683 | 67683 | 67683 | 1 | +| bar | 67683 | 67683 | 67683 | 67683 | 1 | ╰----------------------------------------+-----------------+-------+--------+-------+---------╯ @@ -2132,7 +2670,7 @@ Ran 3 test suites [ELAPSED]: 3 tests passed, 0 failed, 0 skipped (3 total tests) { "contract": "src/Contracts.sol:ContractTwo", "deployment": { - "gas": 133027, + "gas": 133015, "size": 394 }, "functions": { @@ -2160,13 +2698,13 @@ Ran 3 test suites [ELAPSED]: 3 tests passed, 0 failed, 0 skipped (3 total tests) +=================================================================================================+ | Deployment Cost | Deployment Size | | | | | |------------------------------------------+-----------------+--------+--------+--------+---------| -| 133243 | 395 | | | | | +| 133219 | 395 | | | | | |------------------------------------------+-----------------+--------+--------+--------+---------| | | | | | | | |------------------------------------------+-----------------+--------+--------+--------+---------| | Function Name | Min | Avg | Median | Max | # Calls | |------------------------------------------+-----------------+--------+--------+--------+---------| -| baz | 287711 | 287711 | 287711 | 287711 | 1 | +| baz | 287711 | 287711 | 287711 | 287711 | 1 | ╰------------------------------------------+-----------------+--------+--------+--------+---------╯ @@ -2179,7 +2717,7 @@ Ran 3 test suites [ELAPSED]: 3 tests passed, 0 failed, 0 skipped (3 total tests) { "contract": "src/Contracts.sol:ContractThree", "deployment": { - "gas": 133243, + "gas": 133219, "size": 395 }, "functions": { @@ -2196,11 +2734,12 @@ Ran 3 test suites [ELAPSED]: 3 tests passed, 0 failed, 0 skipped (3 total tests) "#]] .is_json(), ); -}); +} -forgetest!(gas_report_ignore_some_contracts, |prj, cmd| { +#[forgetest] +fn gas_report_ignore_some_contracts(prj: _, cmd: _) { prj.insert_ds_test(); - prj.add_source("Contracts.sol", GAS_REPORT_CONTRACTS).unwrap(); + prj.add_source("Contracts.sol", GAS_REPORT_CONTRACTS); // ignore ContractOne prj.update_config(|config| { @@ -2215,13 +2754,13 @@ forgetest!(gas_report_ignore_some_contracts, |prj, cmd| { +=================================================================================================+ | Deployment Cost | Deployment Size | | | | | |------------------------------------------+-----------------+--------+--------+--------+---------| -| 133243 | 395 | | | | | +| 133219 | 395 | | | | | |------------------------------------------+-----------------+--------+--------+--------+---------| | | | | | | | |------------------------------------------+-----------------+--------+--------+--------+---------| | Function Name | Min | Avg | Median | Max | # Calls | |------------------------------------------+-----------------+--------+--------+--------+---------| -| baz | 287711 | 287711 | 287711 | 287711 | 1 | +| baz | 287711 | 287711 | 287711 | 287711 | 1 | ╰------------------------------------------+-----------------+--------+--------+--------+---------╯ ╭----------------------------------------+-----------------+-------+--------+-------+---------╮ @@ -2229,13 +2768,13 @@ forgetest!(gas_report_ignore_some_contracts, |prj, cmd| { +=============================================================================================+ | Deployment Cost | Deployment Size | | | | | |----------------------------------------+-----------------+-------+--------+-------+---------| -| 133027 | 394 | | | | | +| 133015 | 394 | | | | | |----------------------------------------+-----------------+-------+--------+-------+---------| | | | | | | | |----------------------------------------+-----------------+-------+--------+-------+---------| | Function Name | Min | Avg | Median | Max | # Calls | |----------------------------------------+-----------------+-------+--------+-------+---------| -| bar | 67683 | 67683 | 67683 | 67683 | 1 | +| bar | 67683 | 67683 | 67683 | 67683 | 1 | ╰----------------------------------------+-----------------+-------+--------+-------+---------╯ @@ -2248,7 +2787,7 @@ Ran 3 test suites [ELAPSED]: 3 tests passed, 0 failed, 0 skipped (3 total tests) { "contract": "src/Contracts.sol:ContractThree", "deployment": { - "gas": 133243, + "gas": 133219, "size": 395 }, "functions": { @@ -2264,7 +2803,7 @@ Ran 3 test suites [ELAPSED]: 3 tests passed, 0 failed, 0 skipped (3 total tests) { "contract": "src/Contracts.sol:ContractTwo", "deployment": { - "gas": 133027, + "gas": 133015, "size": 394 }, "functions": { @@ -2296,13 +2835,13 @@ Ran 3 test suites [ELAPSED]: 3 tests passed, 0 failed, 0 skipped (3 total tests) +=============================================================================================+ | Deployment Cost | Deployment Size | | | | | |----------------------------------------+-----------------+-------+--------+-------+---------| -| 133027 | 394 | | | | | +| 133015 | 394 | | | | | |----------------------------------------+-----------------+-------+--------+-------+---------| | | | | | | | |----------------------------------------+-----------------+-------+--------+-------+---------| | Function Name | Min | Avg | Median | Max | # Calls | |----------------------------------------+-----------------+-------+--------+-------+---------| -| foo | 45656 | 45656 | 45656 | 45656 | 1 | +| foo | 45656 | 45656 | 45656 | 45656 | 1 | ╰----------------------------------------+-----------------+-------+--------+-------+---------╯ ╭------------------------------------------+-----------------+--------+--------+--------+---------╮ @@ -2310,13 +2849,13 @@ Ran 3 test suites [ELAPSED]: 3 tests passed, 0 failed, 0 skipped (3 total tests) +=================================================================================================+ | Deployment Cost | Deployment Size | | | | | |------------------------------------------+-----------------+--------+--------+--------+---------| -| 133243 | 395 | | | | | +| 133219 | 395 | | | | | |------------------------------------------+-----------------+--------+--------+--------+---------| | | | | | | | |------------------------------------------+-----------------+--------+--------+--------+---------| | Function Name | Min | Avg | Median | Max | # Calls | |------------------------------------------+-----------------+--------+--------+--------+---------| -| baz | 287711 | 287711 | 287711 | 287711 | 1 | +| baz | 287711 | 287711 | 287711 | 287711 | 1 | ╰------------------------------------------+-----------------+--------+--------+--------+---------╯ @@ -2329,7 +2868,7 @@ Ran 3 test suites [ELAPSED]: 3 tests passed, 0 failed, 0 skipped (3 total tests) { "contract": "src/Contracts.sol:ContractOne", "deployment": { - "gas": 133027, + "gas": 133015, "size": 394 }, "functions": { @@ -2345,7 +2884,7 @@ Ran 3 test suites [ELAPSED]: 3 tests passed, 0 failed, 0 skipped (3 total tests) { "contract": "src/Contracts.sol:ContractThree", "deployment": { - "gas": 133243, + "gas": 133219, "size": 395 }, "functions": { @@ -2385,13 +2924,13 @@ Ran 3 test suites [ELAPSED]: 3 tests passed, 0 failed, 0 skipped (3 total tests) +=============================================================================================+ | Deployment Cost | Deployment Size | | | | | |----------------------------------------+-----------------+-------+--------+-------+---------| -| 133027 | 394 | | | | | +| 133015 | 394 | | | | | |----------------------------------------+-----------------+-------+--------+-------+---------| | | | | | | | |----------------------------------------+-----------------+-------+--------+-------+---------| | Function Name | Min | Avg | Median | Max | # Calls | |----------------------------------------+-----------------+-------+--------+-------+---------| -| foo | 45656 | 45656 | 45656 | 45656 | 1 | +| foo | 45656 | 45656 | 45656 | 45656 | 1 | ╰----------------------------------------+-----------------+-------+--------+-------+---------╯ ╭------------------------------------------+-----------------+--------+--------+--------+---------╮ @@ -2399,13 +2938,13 @@ Ran 3 test suites [ELAPSED]: 3 tests passed, 0 failed, 0 skipped (3 total tests) +=================================================================================================+ | Deployment Cost | Deployment Size | | | | | |------------------------------------------+-----------------+--------+--------+--------+---------| -| 133243 | 395 | | | | | +| 133219 | 395 | | | | | |------------------------------------------+-----------------+--------+--------+--------+---------| | | | | | | | |------------------------------------------+-----------------+--------+--------+--------+---------| | Function Name | Min | Avg | Median | Max | # Calls | |------------------------------------------+-----------------+--------+--------+--------+---------| -| baz | 287711 | 287711 | 287711 | 287711 | 1 | +| baz | 287711 | 287711 | 287711 | 287711 | 1 | ╰------------------------------------------+-----------------+--------+--------+--------+---------╯ ╭----------------------------------------+-----------------+-------+--------+-------+---------╮ @@ -2413,13 +2952,13 @@ Ran 3 test suites [ELAPSED]: 3 tests passed, 0 failed, 0 skipped (3 total tests) +=============================================================================================+ | Deployment Cost | Deployment Size | | | | | |----------------------------------------+-----------------+-------+--------+-------+---------| -| 133027 | 394 | | | | | +| 133015 | 394 | | | | | |----------------------------------------+-----------------+-------+--------+-------+---------| | | | | | | | |----------------------------------------+-----------------+-------+--------+-------+---------| | Function Name | Min | Avg | Median | Max | # Calls | |----------------------------------------+-----------------+-------+--------+-------+---------| -| bar | 67683 | 67683 | 67683 | 67683 | 1 | +| bar | 67683 | 67683 | 67683 | 67683 | 1 | ╰----------------------------------------+-----------------+-------+--------+-------+---------╯ @@ -2442,7 +2981,7 @@ Warning: ContractThree is listed in both 'gas_reports' and 'gas_reports_ignore'. { "contract": "src/Contracts.sol:ContractOne", "deployment": { - "gas": 133027, + "gas": 133015, "size": 394 }, "functions": { @@ -2458,7 +2997,7 @@ Warning: ContractThree is listed in both 'gas_reports' and 'gas_reports_ignore'. { "contract": "src/Contracts.sol:ContractThree", "deployment": { - "gas": 133243, + "gas": 133219, "size": 395 }, "functions": { @@ -2474,7 +3013,7 @@ Warning: ContractThree is listed in both 'gas_reports' and 'gas_reports_ignore'. { "contract": "src/Contracts.sol:ContractTwo", "deployment": { - "gas": 133027, + "gas": 133015, "size": 394 }, "functions": { @@ -2496,9 +3035,10 @@ Warning: ContractThree is listed in both 'gas_reports' and 'gas_reports_ignore'. Warning: ContractThree is listed in both 'gas_reports' and 'gas_reports_ignore'. ... "#]]); -}); +} -forgetest!(gas_report_flatten_multiple_selectors, |prj, cmd| { +#[forgetest] +fn gas_report_flatten_multiple_selectors(prj: _, cmd: _) { prj.insert_ds_test(); prj.add_source( "Counter.sol", @@ -2516,8 +3056,7 @@ contract Counter { } } "#, - ) - .unwrap(); + ); prj.add_source( "CounterTest.t.sol", @@ -2540,8 +3079,7 @@ contract CounterTest is DSTest { } } "#, - ) - .unwrap(); + ); cmd.arg("test").arg("--gas-report").assert_success().stdout_eq(str![[r#" ... @@ -2550,19 +3088,19 @@ contract CounterTest is DSTest { +=======================================================================================+ | Deployment Cost | Deployment Size | | | | | |----------------------------------+-----------------+-------+--------+-------+---------| -| 172107 | 578 | | | | | +| 172107 | 578 | | | | | |----------------------------------+-----------------+-------+--------+-------+---------| | | | | | | | |----------------------------------+-----------------+-------+--------+-------+---------| | Function Name | Min | Avg | Median | Max | # Calls | |----------------------------------+-----------------+-------+--------+-------+---------| -| a | 2402 | 2402 | 2402 | 2402 | 1 | +| a | 2402 | 2402 | 2402 | 2402 | 1 | |----------------------------------+-----------------+-------+--------+-------+---------| -| b | 2447 | 2447 | 2447 | 2447 | 1 | +| b | 2447 | 2447 | 2447 | 2447 | 1 | |----------------------------------+-----------------+-------+--------+-------+---------| -| setNumber(int256) | 23851 | 33807 | 33807 | 43763 | 2 | +| setNumber(int256) | 23851 | 33807 | 33807 | 43763 | 2 | |----------------------------------+-----------------+-------+--------+-------+---------| -| setNumber(uint256) | 23806 | 33762 | 33762 | 43718 | 2 | +| setNumber(uint256) | 23806 | 33762 | 33762 | 43718 | 2 | ╰----------------------------------+-----------------+-------+--------+-------+---------╯ @@ -2613,10 +3151,12 @@ Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) "#]] .is_json(), ); -}); +} // -forgetest_init!(gas_report_with_fallback, |prj, cmd| { +#[forgetest_init] +fn gas_report_with_fallback(prj: _, cmd: _) { + prj.initialize_default_contracts(); prj.add_test( "DelegateProxyTest.sol", r#" @@ -2665,8 +3205,7 @@ contract GasReportFallbackTest is Test { } } "#, - ) - .unwrap(); + ); cmd.args(["test", "--mt", "test_fallback_gas_report", "-vvvv", "--gas-report"]) .assert_success() @@ -2677,15 +3216,15 @@ contract GasReportFallbackTest is Test { +========================================================================================================+ | Deployment Cost | Deployment Size | | | | | |---------------------------------------------------+-----------------+-------+--------+-------+---------| -| 117171 | 471 | | | | | +| 117171 | 471 | | | | | |---------------------------------------------------+-----------------+-------+--------+-------+---------| | | | | | | | |---------------------------------------------------+-----------------+-------+--------+-------+---------| | Function Name | Min | Avg | Median | Max | # Calls | |---------------------------------------------------+-----------------+-------+--------+-------+---------| -| deposit | 21185 | 21185 | 21185 | 21185 | 1 | +| deposit | 21185 | 21185 | 21185 | 21185 | 1 | |---------------------------------------------------+-----------------+-------+--------+-------+---------| -| fallback | 29758 | 29758 | 29758 | 29758 | 1 | +| fallback | 29758 | 29758 | 29758 | 29758 | 1 | ╰---------------------------------------------------+-----------------+-------+--------+-------+---------╯ ╭-----------------------------------------------------+-----------------+------+--------+------+---------╮ @@ -2693,13 +3232,13 @@ contract GasReportFallbackTest is Test { +========================================================================================================+ | Deployment Cost | Deployment Size | | | | | |-----------------------------------------------------+-----------------+------+--------+------+---------| -| 153531 | 494 | | | | | +| 153531 | 494 | | | | | |-----------------------------------------------------+-----------------+------+--------+------+---------| | | | | | | | |-----------------------------------------------------+-----------------+------+--------+------+---------| | Function Name | Min | Avg | Median | Max | # Calls | |-----------------------------------------------------+-----------------+------+--------+------+---------| -| deposit | 3661 | 3661 | 3661 | 3661 | 1 | +| deposit | 3661 | 3661 | 3661 | 3661 | 1 | ╰-----------------------------------------------------+-----------------+------+--------+------+---------╯ @@ -2756,10 +3295,12 @@ Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) "#]] .is_json(), ); -}); +} // -forgetest_init!(gas_report_fallback_with_calldata, |prj, cmd| { +#[forgetest_init] +fn flaky_gas_report_fallback_with_calldata(prj: _, cmd: _) { + prj.initialize_default_contracts(); prj.add_test( "FallbackWithCalldataTest.sol", r#" @@ -2790,8 +3331,7 @@ contract CounterWithFallbackTest is Test { } } "#, - ) - .unwrap(); + ); cmd.args(["test", "--mt", "test_fallback_with_calldata", "-vvvv", "--gas-report"]) .assert_success() @@ -2815,13 +3355,13 @@ Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +=====================================================================================================================+ | Deployment Cost | Deployment Size | | | | | |----------------------------------------------------------------+-----------------+-------+--------+-------+---------| -| 132471 | 396 | | | | | +| 132471 | 396 | | | | | |----------------------------------------------------------------+-----------------+-------+--------+-------+---------| | | | | | | | |----------------------------------------------------------------+-----------------+-------+--------+-------+---------| | Function Name | Min | Avg | Median | Max | # Calls | |----------------------------------------------------------------+-----------------+-------+--------+-------+---------| -| fallback | 43461 | 43461 | 43461 | 43461 | 1 | +| fallback | 43461 | 43461 | 43461 | 43461 | 1 | ╰----------------------------------------------------------------+-----------------+-------+--------+-------+---------╯ @@ -2855,10 +3395,12 @@ Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) "#]] .is_json(), ); -}); +} // -forgetest_init!(gas_report_size_for_nested_create, |prj, cmd| { +#[forgetest_init] +fn gas_report_size_for_nested_create(prj: _, cmd: _) { + prj.initialize_default_contracts(); prj.add_test( "NestedDeployTest.sol", r#" @@ -2888,8 +3430,7 @@ contract NestedDeploy is Test { } } "#, - ) - .unwrap(); + ); cmd.args(["test", "--mt", "test_nested_create_gas_report", "--gas-report"]) .assert_success() @@ -2900,13 +3441,13 @@ contract NestedDeploy is Test { +======================================================================================================+ | Deployment Cost | Deployment Size | | | | | |-------------------------------------------------+-----------------+-------+--------+-------+---------| -| 0 | 132 | | | | | +| 0 | 132 | | | | | |-------------------------------------------------+-----------------+-------+--------+-------+---------| | | | | | | | |-------------------------------------------------+-----------------+-------+--------+-------+---------| | Function Name | Min | Avg | Median | Max | # Calls | |-------------------------------------------------+-----------------+-------+--------+-------+---------| -| w | 21185 | 21185 | 21185 | 21185 | 1 | +| w | 21185 | 21185 | 21185 | 21185 | 1 | ╰-------------------------------------------------+-----------------+-------+--------+-------+---------╯ ╭------------------------------------------+-----------------+------+--------+------+---------╮ @@ -2914,13 +3455,13 @@ contract NestedDeploy is Test { +=============================================================================================+ | Deployment Cost | Deployment Size | | | | | |------------------------------------------+-----------------+------+--------+------+---------| -| 0 | 731 | | | | | +| 0 | 731 | | | | | |------------------------------------------+-----------------+------+--------+------+---------| | | | | | | | |------------------------------------------+-----------------+------+--------+------+---------| | Function Name | Min | Avg | Median | Max | # Calls | |------------------------------------------+-----------------+------+--------+------+---------| -| child | 2681 | 2681 | 2681 | 2681 | 1 | +| child | 2681 | 2681 | 2681 | 2681 | 1 | ╰------------------------------------------+-----------------+------+--------+------+---------╯ ╭-------------------------------------------+-----------------+-----+--------+-----+---------╮ @@ -2928,13 +3469,13 @@ contract NestedDeploy is Test { +============================================================================================+ | Deployment Cost | Deployment Size | | | | | |-------------------------------------------+-----------------+-----+--------+-----+---------| -| 328961 | 1163 | | | | | +| 328949 | 1163 | | | | | |-------------------------------------------+-----------------+-----+--------+-----+---------| | | | | | | | |-------------------------------------------+-----------------+-----+--------+-----+---------| | Function Name | Min | Avg | Median | Max | # Calls | |-------------------------------------------+-----------------+-----+--------+-----+---------| -| child | 525 | 525 | 525 | 525 | 1 | +| child | 525 | 525 | 525 | 525 | 1 | ╰-------------------------------------------+-----------------+-----+--------+-----+---------╯ @@ -2983,7 +3524,7 @@ Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) { "contract": "test/NestedDeployTest.sol:Parent", "deployment": { - "gas": 328961, + "gas": 328949, "size": 1163 }, "functions": { @@ -3000,9 +3541,11 @@ Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) "#]] .is_json(), ); -}); +} -forgetest_init!(can_use_absolute_imports, |prj, cmd| { +#[forgetest_init] +fn can_use_absolute_imports(prj: _, cmd: _) { + prj.initialize_default_contracts(); prj.update_config(|config| { let remapping = prj.paths().libraries[0].join("myDependency"); config.remappings = vec![ @@ -3016,8 +3559,7 @@ forgetest_init!(can_use_absolute_imports, |prj, cmd| { interface IConfig {} ", - ) - .unwrap(); + ); prj.add_lib( "myDependency/src/Config.sol", @@ -3026,8 +3568,7 @@ forgetest_init!(can_use_absolute_imports, |prj, cmd| { contract Config is IConfig {} "#, - ) - .unwrap(); + ); prj.add_source( "Greeter", @@ -3038,8 +3579,7 @@ forgetest_init!(can_use_absolute_imports, |prj, cmd| { Config config; } "#, - ) - .unwrap(); + ); cmd.arg("build").assert_success().stdout_eq(str![[r#" [COMPILING_FILES] with [SOLC_VERSION] @@ -3047,17 +3587,18 @@ forgetest_init!(can_use_absolute_imports, |prj, cmd| { Compiler run successful! "#]]); -}); +} // -forgetest_init!(can_use_absolute_imports_from_test_and_script, |prj, cmd| { +#[forgetest_init] +fn can_use_absolute_imports_from_test_and_script(prj: _, cmd: _) { + prj.initialize_default_contracts(); prj.add_script( "IMyScript.sol", r" interface IMyScript {} ", - ) - .unwrap(); + ); prj.add_script( "MyScript.sol", @@ -3066,16 +3607,14 @@ import "script/IMyScript.sol"; contract MyScript is IMyScript {} "#, - ) - .unwrap(); + ); prj.add_test( "IMyTest.sol", r" interface IMyTest {} ", - ) - .unwrap(); + ); prj.add_test( "MyTest.sol", @@ -3084,8 +3623,7 @@ import "test/IMyTest.sol"; contract MyTest is IMyTest {} "#, - ) - .unwrap(); + ); cmd.arg("build").assert_success().stdout_eq(str![[r#" [COMPILING_FILES] with [SOLC_VERSION] @@ -3093,10 +3631,12 @@ contract MyTest is IMyTest {} Compiler run successful! "#]]); -}); +} // checks `forge inspect irOptimized works -forgetest_init!(can_inspect_ir_optimized, |_prj, cmd| { +#[forgetest_init] +fn can_inspect_ir_optimized(prj: _, cmd: _) { + prj.initialize_default_contracts(); cmd.args(["inspect", TEMPLATE_CONTRACT, "irOptimized"]); cmd.assert_success().stdout_eq(str![[r#" /// @use-src 0:"src/Counter.sol" @@ -3118,10 +3658,12 @@ object "Counter_21" { if callvalue() ... "#]]); -}); +} // checks `forge inspect irOptimized works -forgetest_init!(can_inspect_ir, |_prj, cmd| { +#[forgetest_init] +fn can_inspect_ir(prj: _, cmd: _) { + prj.initialize_default_contracts(); cmd.args(["inspect", TEMPLATE_CONTRACT, "ir"]); cmd.assert_success().stdout_eq(str![[r#" @@ -3143,24 +3685,45 @@ object "Counter_21" { if callvalue() { revert_error_ca66f745a3ce8ff40e2ccaf1ad45db7774001b90d25810abd9040049be7bf4bb() } ... "#]]); -}); + + // `--json` wraps the IR source as a JSON string (one quoted line) + let json_out = cmd + .forge_fuse() + .args(["inspect", TEMPLATE_CONTRACT, "ir", "--json"]) + .assert_success() + .get_output() + .stdout_lossy(); + let json_out = json_out.trim(); + assert!(json_out.starts_with('"'), "expected quoted JSON string, got {json_out:?}"); + assert!(json_out.ends_with('"'), "expected quoted JSON string, got {json_out:?}"); + let _: String = parse_json(json_out).expect("ir --json stdout should be a valid JSON string"); +} // checks forge bind works correctly on the default project -forgetest_init!(can_bind, |prj, cmd| { +#[forgetest_init] +fn can_bind(prj: _, cmd: _) { + prj.initialize_default_contracts(); prj.clear(); - cmd.arg("bind").assert_success().stdout_eq(str![[r#" + cmd.arg("bind") + .assert_success() + .stdout_eq(str![[r#" [COMPILING_FILES] with [SOLC_VERSION] [SOLC_VERSION] [ELAPSED] Compiler run successful! + +"#]]) + .stderr_eq(str![[r#" Generating bindings for [..] contracts Bindings have been generated to [..] "#]]); -}); +} // checks that extra output works -forgetest_init!(can_build_skip_contracts, |prj, cmd| { +#[forgetest_init] +fn can_build_skip_contracts(prj: _, cmd: _) { + prj.initialize_default_contracts(); prj.clear(); // Only builds the single template contract `src/*` @@ -3178,17 +3741,18 @@ Compiler run successful! No files changed, compilation skipped "#]]); -}); +} -forgetest_init!(can_build_skip_glob, |prj, cmd| { +#[forgetest_init] +fn can_build_skip_glob(prj: _, cmd: _) { + prj.initialize_default_contracts(); prj.add_test( "Foo", r" contract TestDemo { function test_run() external {} }", - ) - .unwrap(); + ); // only builds the single template contract `src/*` even if `*.t.sol` or `.s.sol` is absent prj.clear(); @@ -3210,34 +3774,31 @@ Compiler run successful! Compiler run successful! "#]]); -}); +} -forgetest_init!(can_build_specific_paths, |prj, cmd| { - prj.wipe(); +#[forgetest] +fn can_build_specific_paths(prj: _, cmd: _) { prj.add_source( "Counter.sol", r" contract Counter { function count() external {} }", - ) - .unwrap(); + ); prj.add_test( "Foo.sol", r" contract Foo { function test_foo() external {} }", - ) - .unwrap(); + ); prj.add_test( "Bar.sol", r" contract Bar { function test_bar() external {} }", - ) - .unwrap(); + ); // Build 2 files within test dir prj.clear(); @@ -3285,10 +3846,12 @@ Compiler run successful! Error: No source files found in specified build paths. "#]]); -}); +} // checks that build --sizes includes all contracts even if unchanged -forgetest_init!(can_build_sizes_repeatedly, |prj, cmd| { +#[forgetest_init] +fn can_build_sizes_repeatedly(prj: _, cmd: _) { + prj.initialize_default_contracts(); prj.clear_cache(); cmd.args(["build", "--sizes"]).assert_success().stdout_eq(str![[r#" @@ -3300,6 +3863,15 @@ forgetest_init!(can_build_sizes_repeatedly, |prj, cmd| { ╰----------+------------------+-------------------+--------------------+---------------------╯ +"#]]); + + cmd.forge_fuse().args(["build", "--sizes", "--md"]).assert_success().stdout_eq(str![[r#" +... +| Contract | Runtime Size (B) | Initcode Size (B) | Runtime Margin (B) | Initcode Margin (B) | +|----------|------------------|-------------------|--------------------|---------------------| +| Counter | 481 | 509 | 24,095 | 48,643 | + + "#]]); cmd.forge_fuse().args(["build", "--sizes", "--json"]).assert_success().stdout_eq( @@ -3315,10 +3887,12 @@ forgetest_init!(can_build_sizes_repeatedly, |prj, cmd| { "#]] .is_json(), ); -}); +} // checks that build --names includes all contracts even if unchanged -forgetest_init!(can_build_names_repeatedly, |prj, cmd| { +#[forgetest_init] +fn can_build_names_repeatedly(prj: _, cmd: _) { + prj.initialize_default_contracts(); prj.clear_cache(); cmd.args(["build", "--names"]).assert_success().stdout_eq(str![[r#" @@ -3335,9 +3909,11 @@ Compiler run successful! .args(["build", "--names", "--json"]) .assert_success() .stdout_eq(str![[r#""{...}""#]].is_json()); -}); +} -forgetest_init!(can_inspect_counter_pretty, |prj, cmd| { +#[forgetest_init] +fn can_inspect_counter_pretty(prj: _, cmd: _) { + prj.initialize_default_contracts(); cmd.args(["inspect", "src/Counter.sol:Counter", "abi"]).assert_success().stdout_eq(str![[r#" ╭----------+---------------------------------+------------╮ @@ -3352,7 +3928,7 @@ forgetest_init!(can_inspect_counter_pretty, |prj, cmd| { "#]]); -}); +} const CUSTOM_COUNTER: &str = r#" contract Counter { @@ -3414,8 +3990,9 @@ const ANOTHER_COUNTER: &str = r#" constructor(uint256 _number) Counter(_number) {} } "#; -forgetest!(inspect_custom_counter_abi, |prj, cmd| { - prj.add_source("Counter.sol", CUSTOM_COUNTER).unwrap(); +#[forgetest] +fn inspect_custom_counter_abi(prj: _, cmd: _) { + prj.add_source("Counter.sol", CUSTOM_COUNTER); cmd.args(["inspect", "Counter", "abi"]).assert_success().stdout_eq(str![[r#" @@ -3452,11 +4029,152 @@ forgetest!(inspect_custom_counter_abi, |prj, cmd| { ╰-------------+-----------------------------------------------+--------------------------------------------------------------------╯ -"#]]); -}); +"#]]); +} + +#[forgetest] +fn inspect_abi_does_not_write_artifacts(prj: _, cmd: _) { + prj.add_source("Counter.sol", CUSTOM_COUNTER); + + let artifact_path = prj.paths().artifacts.join("Counter.sol/Counter.json"); + + cmd.args(["inspect", "Counter", "abi", "--json"]).assert_success(); + assert!(!artifact_path.exists()); + + cmd.forge_fuse().arg("build").assert_success(); + let built = std::fs::read(&artifact_path).unwrap(); + let artifact: serde_json::Value = + foundry_compilers::utils::read_json_file(&artifact_path).unwrap(); + let bytecode = artifact["bytecode"]["object"] + .as_str() + .expect("build artifact should include creation bytecode"); + assert!(bytecode.starts_with("0x")); + assert!(bytecode.len() > 2); + + cmd.forge_fuse().args(["inspect", "Counter", "abi", "--json"]).assert_success(); + let inspected = std::fs::read(&artifact_path).unwrap(); + assert_eq!(built, inspected); +} + +#[forgetest] +fn inspect_output_cache_preserves_fields_and_artifacts(prj: _, cmd: _) { + prj.add_source( + "Dependency.sol", + "library Dependency { function value() internal pure returns (uint256) { return 1; } }", + ); + prj.add_source( + "Counter.sol", + r#" +import {Dependency} from "./Dependency.sol"; +contract Counter { + function value() external pure returns (uint256) { return Dependency.value(); } +} +"#, + ); + cmd.args(["build", "--no-lint"]).assert_success(); + let built = [ + prj.paths().artifacts.join("Counter.sol/Counter.json"), + prj.paths().artifacts.join("Dependency.sol/Dependency.json"), + prj.cache().clone(), + ] + .map(|path| { + let contents = fs::read(&path).unwrap(); + (path, contents) + }); + let abi_cache = prj.cache().with_extension("json.abi"); + let fields = ["ir", "irOptimized", "assembly"]; + let uncached = fields.map(|field| { + cmd.forge_fuse() + .args(["inspect", "Counter", field, "--json", "--no-cache"]) + .assert_success() + .get_output() + .stdout_lossy() + }); + assert!(!abi_cache.exists()); + + for (field, expected) in fields.iter().zip(&uncached) { + cmd.forge_fuse().args(["inspect", "Counter", field, "--json"]).assert_json_stdout(expected); + cmd.forge_fuse().args(["inspect", "Counter", field, "--json"]).assert_json_stdout(expected); + } + assert!(abi_cache.is_dir()); + // Switching fields must preserve output correctness even when a selection is recompiled. + for (field, expected) in fields.iter().zip(&uncached).rev() { + cmd.forge_fuse().args(["inspect", "Counter", field, "--json"]).assert_json_stdout(expected); + } + for (path, contents) in &built { + assert_eq!(fs::read(path).unwrap(), *contents); + } + + prj.add_source( + "Dependency.sol", + "library Dependency { function value() internal pure returns (uint256) { return 2; } }", + ); + for (field, previous) in fields.iter().zip(&uncached) { + let expected = cmd + .forge_fuse() + .args(["inspect", "Counter", field, "--json", "--no-cache"]) + .assert_success() + .get_output() + .stdout_lossy(); + assert_ne!(&expected, previous); + cmd.forge_fuse().args(["inspect", "Counter", field, "--json"]).assert_json_stdout(expected); + } + for (path, contents) in &built { + assert_eq!(fs::read(path).unwrap(), *contents); + } + + let marker = abi_cache.join("force-marker"); + fs::write(&marker, "").unwrap(); + cmd.forge_fuse().args(["inspect", "Counter", "ir", "--force"]).assert_success(); + assert!(!marker.exists()); + assert!(!abi_cache.exists()); + assert!(!prj.paths().artifacts.exists()); + assert!(!prj.cache().exists()); + cmd.forge_fuse().args(["inspect", "Counter", "ir"]).assert_success(); + assert!(abi_cache.is_dir()); + cmd.forge_fuse().arg("clean").assert_success(); + assert!(!abi_cache.exists()); +} + +#[forgetest] +fn inspect_output_cache_rejects_missing_ewasm(prj: _, cmd: _) { + prj.add_source("Counter.sol", "contract Counter {}"); + + for _ in 0..2 { + cmd.forge_fuse() + .args(["inspect", "Counter", "ewasm"]) + .assert_failure() + .stdout_eq("") + .stderr_eq( + "Error: EWASM output missing from artifact; this could be a spurious caching issue, \ + consider running `forge clean`\n", + ); + } +} + +#[forgetest] +fn inspect_output_cache_respects_warning_denial(prj: _, cmd: _) { + prj.add_source( + "Counter.sol", + "contract Counter { function value() external returns (uint256) { return 1; } }", + ); + cmd.args(["inspect", "Counter", "ir"]).assert_success(); + cmd.forge_fuse().args(["inspect", "Counter", "ir"]).assert_success(); + let expected = cmd + .forge_fuse() + .args(["inspect", "Counter", "ir", "--deny", "warnings", "--no-cache"]) + .assert_failure() + .get_output() + .stderr_lossy(); + cmd.forge_fuse() + .args(["inspect", "Counter", "ir", "--deny", "warnings"]) + .assert_failure() + .stderr_eq(expected.into_data().raw()); +} -forgetest!(inspect_custom_counter_events, |prj, cmd| { - prj.add_source("Counter.sol", CUSTOM_COUNTER).unwrap(); +#[forgetest] +fn inspect_custom_counter_events(prj: _, cmd: _) { + prj.add_source("Counter.sol", CUSTOM_COUNTER); cmd.args(["inspect", "Counter", "events"]).assert_success().stdout_eq(str![[r#" @@ -3470,10 +4188,11 @@ forgetest!(inspect_custom_counter_events, |prj, cmd| { "#]]); -}); +} -forgetest!(inspect_custom_counter_errors, |prj, cmd| { - prj.add_source("Counter.sol", CUSTOM_COUNTER).unwrap(); +#[forgetest] +fn inspect_custom_counter_errors(prj: _, cmd: _) { + prj.add_source("Counter.sol", CUSTOM_COUNTER); cmd.args(["inspect", "Counter", "errors"]).assert_success().stdout_eq(str![[r#" @@ -3487,10 +4206,70 @@ forgetest!(inspect_custom_counter_errors, |prj, cmd| { "#]]); -}); +} + +#[forgetest] +fn inspect_anonymous_event_has_no_selector(prj: _, cmd: _) { + prj.add_source( + "Counter.sol", + r#" +contract Counter { + event FakeEvent(uint256 indexed topic0) anonymous; + event NormalEvent(uint256 indexed value); + + function setNumber(uint256 newNumber) public { + emit FakeEvent(newNumber); + emit NormalEvent(newNumber); + } +} + "#, + ); + + // `abi`: anonymous event must render with an empty selector and an `anonymous` marker. + cmd.args(["inspect", "Counter", "abi"]).assert_success().stdout_eq(str![[r#" + +╭----------+-------------------------------+--------------------------------------------------------------------╮ +| Type | Signature | Selector | ++===============================================================================================================+ +| event | FakeEvent(uint256) anonymous | | +|----------+-------------------------------+--------------------------------------------------------------------| +| event | NormalEvent(uint256) | 0x2e58689f6eed514b3003e0a132a1284ba04042a9d59d11c5db973fdb520d10c1 | +|----------+-------------------------------+--------------------------------------------------------------------| +| function | setNumber(uint256) nonpayable | 0x3fb5c1cb | +╰----------+-------------------------------+--------------------------------------------------------------------╯ + + +"#]]); + + // `events`: anonymous event must render with an empty topic. + cmd.forge_fuse().args(["inspect", "Counter", "events"]).assert_success().stdout_eq(str![[r#" + +╭----------------------+--------------------------------------------------------------------╮ +| Event | Topic | ++===========================================================================================+ +| FakeEvent(uint256) | | +|----------------------+--------------------------------------------------------------------| +| NormalEvent(uint256) | 0x2e58689f6eed514b3003e0a132a1284ba04042a9d59d11c5db973fdb520d10c1 | +╰----------------------+--------------------------------------------------------------------╯ + + +"#]]); + + // `events --json`: anonymous event must serialize as `null`, not a fake hash. + cmd.forge_fuse().args(["inspect", "Counter", "events", "--json"]).assert_success().stdout_eq( + str![[r#" +{ + "FakeEvent(uint256)": null, + "NormalEvent(uint256)": "0x2e58689f6eed514b3003e0a132a1284ba04042a9d59d11c5db973fdb520d10c1" +} + +"#]], + ); +} -forgetest!(inspect_path_only_identifier, |prj, cmd| { - prj.add_source("Counter.sol", CUSTOM_COUNTER).unwrap(); +#[forgetest] +fn inspect_path_only_identifier(prj: _, cmd: _) { + prj.add_source("Counter.sol", CUSTOM_COUNTER); cmd.args(["inspect", "src/Counter.sol", "errors"]).assert_success().stdout_eq(str![[r#" @@ -3504,11 +4283,12 @@ forgetest!(inspect_path_only_identifier, |prj, cmd| { "#]]); -}); +} -forgetest!(test_inspect_contract_with_same_name, |prj, cmd| { +#[forgetest] +fn test_inspect_contract_with_same_name(prj: _, cmd: _) { let source = format!("{CUSTOM_COUNTER}\n{ANOTHER_COUNTER}"); - prj.add_source("Counter.sol", &source).unwrap(); + prj.add_source("Counter.sol", &source); cmd.args(["inspect", "src/Counter.sol", "errors"]).assert_failure().stderr_eq(str![[r#"Error: Multiple contracts found in the same file, please specify the target : or [..]"#]]); @@ -3524,19 +4304,19 @@ forgetest!(test_inspect_contract_with_same_name, |prj, cmd| { "#]]); -}); +} // -forgetest!(inspect_multiple_contracts_with_different_paths, |prj, cmd| { +#[forgetest] +fn inspect_multiple_contracts_with_different_paths(prj: _, cmd: _) { prj.add_source( "Source.sol", r#" contract Source { function foo() public {} - } + } "#, - ) - .unwrap(); + ); prj.add_source( "another/Source.sol", @@ -3545,8 +4325,7 @@ forgetest!(inspect_multiple_contracts_with_different_paths, |prj, cmd| { function bar() public {} } "#, - ) - .unwrap(); + ); cmd.args(["inspect", "src/another/Source.sol:Source", "methodIdentifiers"]) .assert_success() @@ -3560,10 +4339,53 @@ forgetest!(inspect_multiple_contracts_with_different_paths, |prj, cmd| { "#]]); -}); +} + +// +#[forgetest] +fn inspect_contracts_by_exact_input_path(prj: _, cmd: _) { + prj.add_test( + "InspectTarget.t.sol", + r#" +contract InspectTarget { + function testValue() external pure returns (uint256) { + return 1; + } +} +"#, + ); + prj.create_file( + "node_modules/example/Dependency.sol", + r#" +pragma solidity ^0.8.0; + +contract Dependency { + function dependencyValue() external pure returns (uint256) { + return 2; + } +} +"#, + ); + prj.update_config(|config| config.libs.push("node_modules".into())); + + for (target, function) in [ + ("test/InspectTarget.t.sol:InspectTarget", "testValue"), + ("node_modules/example/Dependency.sol:Dependency", "dependencyValue"), + ] { + let stdout = cmd + .forge_fuse() + .args(["inspect", target, "abi", "--json"]) + .assert_success() + .get_output() + .stdout_lossy(); + let abi: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert!(abi.as_array().unwrap().iter().any(|item| item["name"] == function)); + } +} -forgetest!(inspect_custom_counter_method_identifiers, |prj, cmd| { - prj.add_source("Counter.sol", CUSTOM_COUNTER).unwrap(); +#[forgetest] +fn inspect_custom_counter_method_identifiers(prj: _, cmd: _) { + prj.add_source("Counter.sol", CUSTOM_COUNTER); cmd.args(["inspect", "Counter", "method-identifiers"]).assert_success().stdout_eq(str![[r#" @@ -3587,9 +4409,57 @@ forgetest!(inspect_custom_counter_method_identifiers, |prj, cmd| { "#]]); -}); +} + +const CUSTOM_COUNTER_HUGE_METHOD_IDENTIFIERS: &str = r#" +contract Counter { + struct BigStruct { + uint256 a; + uint256 b; + uint256 c; + uint256 d; + uint256 e; + uint256 f; + } + + struct NestedBigStruct { + BigStruct a; + BigStruct b; + BigStruct c; + } + + function hugeIdentifier(NestedBigStruct[] calldata _bigStructs, NestedBigStruct calldata _bigStruct) external {} +} +"#; + +#[forgetest] +fn inspect_custom_counter_very_huge_method_identifiers_unwrapped(prj: _, cmd: _) { + prj.add_source("Counter.sol", CUSTOM_COUNTER_HUGE_METHOD_IDENTIFIERS); + + cmd.args(["inspect", "Counter", "method-identifiers"]).assert_success().stdout_eq(str![[r#" + +╭-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+------------╮ +| Method | Identifier | ++================================================================================================================================================================================================================================================================================================================================================+ +| hugeIdentifier(((uint256,uint256,uint256,uint256,uint256,uint256),(uint256,uint256,uint256,uint256,uint256,uint256),(uint256,uint256,uint256,uint256,uint256,uint256))[],((uint256,uint256,uint256,uint256,uint256,uint256),(uint256,uint256,uint256,uint256,uint256,uint256),(uint256,uint256,uint256,uint256,uint256,uint256))) | f38dafbb | +╰-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+------------╯ + + +"#]]); + + cmd.forge_fuse().args(["inspect", "Counter", "method-identifiers", "--md"]).assert_success().stdout_eq(str![[r#" + +| Method | Identifier | +|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------| +| hugeIdentifier(((uint256,uint256,uint256,uint256,uint256,uint256),(uint256,uint256,uint256,uint256,uint256,uint256),(uint256,uint256,uint256,uint256,uint256,uint256))[],((uint256,uint256,uint256,uint256,uint256,uint256),(uint256,uint256,uint256,uint256,uint256,uint256),(uint256,uint256,uint256,uint256,uint256,uint256))) | f38dafbb | + + +"#]]); +} -forgetest_init!(can_inspect_standard_json, |prj, cmd| { +#[forgetest_init] +fn can_inspect_standard_json(prj: _, cmd: _) { + prj.initialize_default_contracts(); cmd.args(["inspect", "src/Counter.sol:Counter", "standard-json"]).assert_success().stdout_eq(str![[r#" { "language": "Solidity", @@ -3627,42 +4497,202 @@ forgetest_init!(can_inspect_standard_json, |prj, cmd| { ] } }, - "evmVersion": "prague", + "evmVersion": "osaka", "viaIR": false, + "viaSSACFG": false, + "experimental": false, "libraries": {} } } "#]]); -}); + + prj.update_config(|config| config.solc = Some(OTHER_SOLC_VERSION.into())); + let expected = cmd + .forge_fuse() + .args(["inspect", "Counter", "standard-json", "--use", OTHER_SOLC_VERSION]) + .assert_success() + .get_output() + .stdout + .clone(); + let solc = + Solc::find_svm_installed_version(&OTHER_SOLC_VERSION.parse().unwrap()).unwrap().unwrap(); + for compiler in [format!("solc:{OTHER_SOLC_VERSION}"), solc.solc.display().to_string()] { + cmd.forge_fuse() + .args(["inspect", "Counter", "standard-json", "--use", &compiler]) + .assert_success() + .stdout_eq(expected.clone()); + } + cmd.forge_fuse() + .args(["inspect", "Counter", "standard-json"]) + .assert_success() + .stdout_eq(expected); +} + +#[forgetest] +fn can_inspect_artifact_json(prj: _, cmd: _) { + prj.add_source( + "Counter.sol", + r#" +contract Counter { + uint256 public number; + + function increment() public { + number++; + } +} + "#, + ); + + let stdout = + cmd.args(["inspect", "Counter", "artifact"]).assert_success().get_output().stdout_lossy(); + let artifact: serde_json::Value = + serde_json::from_str(stdout.trim()).expect("artifact stdout should be valid JSON"); + + let abi = artifact["abi"].as_array().expect("artifact should include an ABI array"); + assert!(abi.iter().any(|item| { + item.get("type").and_then(|value| value.as_str()) == Some("function") + && item.get("name").and_then(|value| value.as_str()) == Some("increment") + })); + + let bytecode = + artifact["bytecode"]["object"].as_str().expect("artifact should include creation bytecode"); + assert!(bytecode.starts_with("0x")); + + let deployed_bytecode = artifact["deployedBytecode"]["object"] + .as_str() + .expect("artifact should include deployed bytecode"); + assert!(deployed_bytecode.starts_with("0x")); +} + +#[forgetest] +fn can_inspect_artifact_json_with_custom_out_and_duplicate_contract_names(prj: _, cmd: _) { + prj.update_config(|config| config.out = "custom-out".into()); + + prj.add_source( + "Source.sol", + r#" +contract Source { + function foo() public {} +} + "#, + ); + prj.add_source( + "another/Source.sol", + r#" +contract Source { + function bar() public {} +} + "#, + ); + + let stdout = cmd + .args(["inspect", "src/another/Source.sol:Source", "output"]) + .assert_success() + .get_output() + .stdout_lossy(); + let artifact: serde_json::Value = + serde_json::from_str(stdout.trim()).expect("artifact stdout should be valid JSON"); + + let abi = artifact["abi"].as_array().expect("artifact should include an ABI array"); + assert!(abi.iter().any(|item| { + item.get("type").and_then(|value| value.as_str()) == Some("function") + && item.get("name").and_then(|value| value.as_str()) == Some("bar") + })); + assert!(!abi.iter().any(|item| { + item.get("type").and_then(|value| value.as_str()) == Some("function") + && item.get("name").and_then(|value| value.as_str()) == Some("foo") + })); + assert!(prj.root().join("custom-out/Source.sol/Source.json").exists()); +} + +#[forgetest_init] +fn can_inspect_libraries(prj: _, cmd: _) { + prj.initialize_default_contracts(); + prj.add_source( + "Source.sol", + r#" + import "./Lib.sol"; + + library Lib2 { + function foo() public {} + } + + contract Source { + function foo() public { + Lib.foo(); + Lib2.foo(); + } + }"#, + ); + + prj.add_source( + "Lib.sol", + r#" + library Lib { + function foo() public {} + } + "#, + ); + + cmd.args(["inspect", "Source", "libraries"]) + .assert_success() + .stdout_eq(str![[r#" +src/Lib.sol:Lib +src/Source.sol:Lib2 + +"#]]) + .stderr_eq(str![[r#" +Dynamically linked libraries: + +"#]]); + + cmd.forge_fuse().args(["inspect", "Source", "libraries", "--json"]).assert_success().stdout_eq( + str![[r#" +[ + "src/Lib.sol:Lib", + "src/Source.sol:Lib2" +] + +"#]], + ); +} // checks that `clean` also works with the "out" value set in Config -forgetest_init!(gas_report_include_tests, |prj, cmd| { +#[forgetest_init] +fn gas_report_include_tests(prj: _, cmd: _) { + prj.initialize_default_contracts(); prj.update_config(|config| { config.gas_reports_include_tests = true; config.fuzz.runs = 1; }); - cmd.args(["test", "--match-test", "test_Increment", "--gas-report"]) - .assert_success() - .stdout_eq(str![[r#" + cmd.args([ + "test", + "--match-test", + "test_Increment", + "--gas-report", + "--no-dynamic-test-linking", + ]) + .assert_success() + .stdout_eq(str![[r#" ... ╭----------------------------------+-----------------+-------+--------+-------+---------╮ | src/Counter.sol:Counter Contract | | | | | | +=======================================================================================+ | Deployment Cost | Deployment Size | | | | | |----------------------------------+-----------------+-------+--------+-------+---------| -| 156813 | 509 | | | | | +| 156801 | 509 | | | | | |----------------------------------+-----------------+-------+--------+-------+---------| | | | | | | | |----------------------------------+-----------------+-------+--------+-------+---------| | Function Name | Min | Avg | Median | Max | # Calls | |----------------------------------+-----------------+-------+--------+-------+---------| -| increment | 43482 | 43482 | 43482 | 43482 | 1 | +| increment | 43482 | 43482 | 43482 | 43482 | 1 | |----------------------------------+-----------------+-------+--------+-------+---------| -| number | 2424 | 2424 | 2424 | 2424 | 1 | +| number | 2424 | 2424 | 2424 | 2424 | 1 | |----------------------------------+-----------------+-------+--------+-------+---------| -| setNumber | 23784 | 23784 | 23784 | 23784 | 1 | +| setNumber | 23784 | 23784 | 23784 | 23784 | 1 | ╰----------------------------------+-----------------+-------+--------+-------+---------╯ ╭-----------------------------------------+-----------------+--------+--------+--------+---------╮ @@ -3670,15 +4700,15 @@ forgetest_init!(gas_report_include_tests, |prj, cmd| { +================================================================================================+ | Deployment Cost | Deployment Size | | | | | |-----------------------------------------+-----------------+--------+--------+--------+---------| -| 1545498 | 7578 | | | | | +| 1544498 | 7573 | | | | | |-----------------------------------------+-----------------+--------+--------+--------+---------| | | | | | | | |-----------------------------------------+-----------------+--------+--------+--------+---------| | Function Name | Min | Avg | Median | Max | # Calls | |-----------------------------------------+-----------------+--------+--------+--------+---------| -| setUp | 218902 | 218902 | 218902 | 218902 | 1 | +| setUp | 218890 | 218890 | 218890 | 218890 | 1 | |-----------------------------------------+-----------------+--------+--------+--------+---------| -| test_Increment | 54915 | 54915 | 54915 | 54915 | 1 | +| test_Increment | 51847 | 51847 | 51847 | 51847 | 1 | ╰-----------------------------------------+-----------------+--------+--------+--------+---------╯ @@ -3687,7 +4717,50 @@ Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) "#]]); cmd.forge_fuse() - .args(["test", "--mt", "test_Increment", "--gas-report", "--json"]) + .args([ + "test", + "--match-test", + "test_Increment", + "--gas-report", + "--md", + "--no-dynamic-test-linking", + ]) + .assert_success() + .stdout_eq(str![[r#" +... +| src/Counter.sol:Counter Contract | | | | | | +|----------------------------------|-----------------|-------|--------|-------|---------| +| Deployment Cost | Deployment Size | | | | | +| 156801 | 509 | | | | | +| | | | | | | +| Function Name | Min | Avg | Median | Max | # Calls | +| increment | 43482 | 43482 | 43482 | 43482 | 1 | +| number | 2424 | 2424 | 2424 | 2424 | 1 | +| setNumber | 23784 | 23784 | 23784 | 23784 | 1 | + +| test/Counter.t.sol:CounterTest Contract | | | | | | +|-----------------------------------------|-----------------|--------|--------|--------|---------| +| Deployment Cost | Deployment Size | | | | | +| 1544498 | 7573 | | | | | +| | | | | | | +| Function Name | Min | Avg | Median | Max | # Calls | +| setUp | 218890 | 218890 | 218890 | 218890 | 1 | +| test_Increment | 51847 | 51847 | 51847 | 51847 | 1 | + + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); + + cmd.forge_fuse() + .args([ + "test", + "--mt", + "test_Increment", + "--gas-report", + "--json", + "--no-dynamic-test-linking", + ]) .assert_success() .stdout_eq( str![[r#" @@ -3695,7 +4768,7 @@ Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) { "contract": "src/Counter.sol:Counter", "deployment": { - "gas": 156813, + "gas": 156801, "size": 509 }, "functions": { @@ -3725,23 +4798,23 @@ Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) { "contract": "test/Counter.t.sol:CounterTest", "deployment": { - "gas": 1545498, - "size": 7578 + "gas": 1544498, + "size": 7573 }, "functions": { "setUp()": { "calls": 1, - "min": 218902, - "mean": 218902, - "median": 218902, - "max": 218902 + "min": 218890, + "mean": 218890, + "median": 218890, + "max": 218890 }, "test_Increment()": { "calls": 1, - "min": 54915, - "mean": 54915, - "median": 54915, - "max": 54915 + "min": 51847, + "mean": 51847, + "median": 51847, + "max": 51847 } } } @@ -3749,9 +4822,10 @@ Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) "#]] .is_json(), ); -}); +} -forgetest_async!(gas_report_fuzz_invariant, |prj, cmd| { +#[forgetest] +async fn gas_report_fuzz_invariant(prj: _, cmd: _) { // speed up test by running with depth of 15 prj.update_config(|config| config.invariant.depth = 15); @@ -3785,14 +4859,15 @@ contract FooBarTest is DSTest { } } "#, - ) - .unwrap(); + ); cmd.args(["test", "--gas-report"]).assert_success(); -}); +} // -forgetest_init!(can_bind_enum_modules, |prj, cmd| { +#[forgetest_init] +fn can_bind_enum_modules(prj: _, cmd: _) { + prj.initialize_default_contracts(); prj.clear(); prj.add_source( @@ -3802,8 +4877,7 @@ forgetest_init!(can_bind_enum_modules, |prj, cmd| { enum MyEnum { A, B, C } } "#, - ) - .unwrap(); + ); prj.add_source( "UseEnum.sol", @@ -3812,33 +4886,94 @@ forgetest_init!(can_bind_enum_modules, |prj, cmd| { contract UseEnum { Enum.MyEnum public myEnum; }"#, - ) - .unwrap(); + ); - cmd.args(["bind", "--select", "^Enum$"]).assert_success().stdout_eq(str![[ - r#"[COMPILING_FILES] with [SOLC_VERSION] + cmd.args(["bind", "--select", "^Enum$"]) + .assert_success() + .stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] [SOLC_VERSION] [ELAPSED] Compiler run successful! + +"#]]) + .stderr_eq(str![[r#" Generating bindings for 1 contracts -Bindings have been generated to [..]"# - ]]); -}); +Bindings have been generated to [..] + +"#]]); +} // forge bind e2e -forgetest_init!(can_bind_e2e, |prj, cmd| { - cmd.args(["bind"]).assert_success().stdout_eq(str![[r#"No files changed, compilation skipped +#[forgetest_init] +fn can_bind_e2e(prj: _, cmd: _) { + prj.initialize_default_contracts(); + cmd.args(["bind"]) + .assert_success() + .stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +"#]]) + .stderr_eq(str![[r#" Generating bindings for 2 contracts -Bindings have been generated to [..]"#]]); +Bindings have been generated to [..] + +"#]]); let bindings_path = prj.root().join("out/bindings"); assert!(bindings_path.exists(), "Bindings directory should exist"); - let out = Command::new("cargo") - .arg("build") - .current_dir(&bindings_path) + let out = super::bind::bindings_cargo(&bindings_path, "build") .output() .expect("Failed to run cargo build"); - // RUn `cargo build` - assert!(out.status.success(), "Cargo build should succeed"); -}); + assert!( + out.status.success(), + "Cargo build should succeed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); +} + +// `forge flatten -o ` writes the file and emits its status string to stderr, +// keeping stdout empty so agents can pipe the command without diagnostics. +#[forgetest] +fn flatten_output_writes_status_to_stderr(prj: _, cmd: _) { + prj.add_source( + "Counter", + r#"contract Counter { uint256 public n; function inc() external { n += 1; } }"#, + ); + + let out = prj.root().join("flat.sol"); + cmd.args(["flatten", "src/Counter.sol", "-o"]) + .arg(&out) + .assert_success() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Flattened file written at [..]flat.sol + +"#]]); + + assert!(out.exists(), "flattened file should have been written"); +} + +// `forge init` writes its status prose to stderr and keeps stdout empty so agents +// can pipe the command without diagnostics. Uses `--offline` + `--no-git` to skip +// network and git side-effects that would otherwise add prose to stdout. +#[forgetest] +fn init_status_on_stderr(prj: _, cmd: _) { + prj.wipe(); + + cmd.args(["init", "--offline", "--no-git"]) + .arg(prj.root()) + .assert_success() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Initializing [..]... + Initialized forge project + +"#]]); + + assert!(prj.root().join("foundry.toml").exists()); +} diff --git a/crates/forge/tests/cli/compiler.rs b/crates/forge/tests/cli/compiler.rs index 20438dd2e8085..37eff89645120 100644 --- a/crates/forge/tests/cli/compiler.rs +++ b/crates/forge/tests/cli/compiler.rs @@ -1,6 +1,11 @@ //! Tests for the `forge compiler` command. -use foundry_test_utils::snapbox::IntoData; +use foundry_compilers::compilers::solc::Solc; +use foundry_test_utils::{ + snapbox::IntoData, + util::{SOLC_VERSION, get_vyper}, +}; +use path_slash::PathBufExt; const CONTRACT_A: &str = r#" // SPDX-license-identifier: MIT @@ -18,14 +23,14 @@ contract ContractB {} const CONTRACT_C: &str = r#" // SPDX-license-identifier: MIT -pragma solidity 0.8.30; +pragma solidity 0.8.33; contract ContractC {} "#; const CONTRACT_D: &str = r#" // SPDX-license-identifier: MIT -pragma solidity 0.8.30; +pragma solidity 0.8.33; contract ContractD {} "#; @@ -63,8 +68,9 @@ def increment() -> uint256: return self.number "#; -forgetest!(can_resolve_path, |prj, cmd| { - prj.add_source("ContractA", CONTRACT_A).unwrap(); +#[forgetest] +fn can_resolve_path(prj: _, cmd: _) { + prj.add_source("ContractA", CONTRACT_A); cmd.args(["compiler", "resolve", "--root", prj.root().to_str().unwrap()]) .assert_success() @@ -74,10 +80,11 @@ Solidity: "#]]); -}); +} -forgetest!(can_list_resolved_compiler_versions, |prj, cmd| { - prj.add_source("ContractA", CONTRACT_A).unwrap(); +#[forgetest] +fn can_list_resolved_compiler_versions(prj: _, cmd: _) { + prj.add_source("ContractA", CONTRACT_A); cmd.args(["compiler", "resolve"]).assert_success().stdout_eq(str![[r#" Solidity: @@ -85,10 +92,43 @@ Solidity: "#]]); -}); +} + +#[forgetest_init] +fn can_print_resolved_compiler_path(prj: _, cmd: _) { + prj.add_source("Contract", "contract Contract {}"); + let solc = Solc::find_svm_installed_version(&SOLC_VERSION.parse().unwrap()).unwrap().unwrap(); + + cmd.args(["compiler", "resolve", "--path"]) + .assert_success() + .stdout_eq(format!("{}\n", solc.solc.to_slash_lossy())); +} + +#[forgetest] +fn can_print_resolved_vyper_path(prj: _, cmd: _) { + let vyper = get_vyper(); + prj.add_raw_source("ICounter.vyi", VYPER_INTERFACE); + prj.add_raw_source("Counter.vy", VYPER_CONTRACT); + prj.update_config(|config| config.vyper.path = Some(vyper.path.clone())); + + cmd.args(["compiler", "resolve", "--path"]) + .assert_success() + .stdout_eq(format!("{}\n", vyper.path.display())); +} + +#[forgetest] +fn compiler_path_requires_single_version(prj: _, cmd: _) { + prj.add_source("ContractA", CONTRACT_A); + prj.add_source("ContractB", CONTRACT_B); -forgetest!(can_list_resolved_compiler_versions_json, |prj, cmd| { - prj.add_source("ContractA", CONTRACT_A).unwrap(); + cmd.args(["compiler", "resolve", "--path"]).assert_failure().stdout_eq("").stderr_eq( + "Error: multiple compilers resolved; use `forge compiler resolve` to inspect them\n", + ); +} + +#[forgetest] +fn can_list_resolved_compiler_versions_json(prj: _, cmd: _) { + prj.add_source("ContractA", CONTRACT_A); cmd.args(["compiler", "resolve", "--json"]).assert_success().stdout_eq( str![[r#" @@ -102,33 +142,35 @@ forgetest!(can_list_resolved_compiler_versions_json, |prj, cmd| { "#]] .is_json(), ); -}); +} -forgetest!(can_list_resolved_compiler_versions_verbose, |prj, cmd| { - prj.add_source("ContractC", CONTRACT_C).unwrap(); - prj.add_source("ContractD", CONTRACT_D).unwrap(); +#[forgetest] +fn can_list_resolved_compiler_versions_verbose(prj: _, cmd: _) { + prj.add_source("ContractC", CONTRACT_C); + prj.add_source("ContractD", CONTRACT_D); cmd.args(["compiler", "resolve", "-v"]).assert_success().stdout_eq(str![[r#" Solidity: -0.8.30: +0.8.33: ├── src/ContractC.sol └── src/ContractD.sol "#]]); -}); +} -forgetest!(can_list_resolved_compiler_versions_verbose_json, |prj, cmd| { - prj.add_source("ContractC", CONTRACT_C).unwrap(); - prj.add_source("ContractD", CONTRACT_D).unwrap(); +#[forgetest] +fn can_list_resolved_compiler_versions_verbose_json(prj: _, cmd: _) { + prj.add_source("ContractC", CONTRACT_C); + prj.add_source("ContractD", CONTRACT_D); cmd.args(["compiler", "resolve", "--json", "-v"]).assert_success().stdout_eq( str![[r#" { "Solidity": [ { - "version": "0.8.30", + "version": "0.8.33", "paths": [ "src/ContractC.sol", "src/ContractD.sol" @@ -139,36 +181,38 @@ forgetest!(can_list_resolved_compiler_versions_verbose_json, |prj, cmd| { "#]] .is_json(), ); -}); +} -forgetest!(can_list_resolved_multiple_compiler_versions, |prj, cmd| { - prj.add_source("ContractA", CONTRACT_A).unwrap(); - prj.add_source("ContractB", CONTRACT_B).unwrap(); - prj.add_source("ContractC", CONTRACT_C).unwrap(); - prj.add_source("ContractD", CONTRACT_D).unwrap(); - prj.add_raw_source("ICounter.vyi", VYPER_INTERFACE).unwrap(); - prj.add_raw_source("Counter.vy", VYPER_CONTRACT).unwrap(); +#[forgetest] +fn can_list_resolved_multiple_compiler_versions(prj: _, cmd: _) { + prj.add_source("ContractA", CONTRACT_A); + prj.add_source("ContractB", CONTRACT_B); + prj.add_source("ContractC", CONTRACT_C); + prj.add_source("ContractD", CONTRACT_D); + prj.add_raw_source("ICounter.vyi", VYPER_INTERFACE); + prj.add_raw_source("Counter.vy", VYPER_CONTRACT); cmd.args(["compiler", "resolve"]).assert_success().stdout_eq(str![[r#" Solidity: - 0.8.4 - 0.8.11 -- 0.8.30 +- 0.8.33 Vyper: - 0.4.3 "#]]); -}); +} -forgetest!(can_list_resolved_multiple_compiler_versions_skipped, |prj, cmd| { - prj.add_source("ContractA", CONTRACT_A).unwrap(); - prj.add_source("ContractB", CONTRACT_B).unwrap(); - prj.add_source("ContractC", CONTRACT_C).unwrap(); - prj.add_source("ContractD", CONTRACT_D).unwrap(); - prj.add_raw_source("ICounter.vyi", VYPER_INTERFACE).unwrap(); - prj.add_raw_source("Counter.vy", VYPER_CONTRACT).unwrap(); +#[forgetest] +fn can_list_resolved_multiple_compiler_versions_skipped(prj: _, cmd: _) { + prj.add_source("ContractA", CONTRACT_A); + prj.add_source("ContractB", CONTRACT_B); + prj.add_source("ContractC", CONTRACT_C); + prj.add_source("ContractD", CONTRACT_D); + prj.add_raw_source("ICounter.vyi", VYPER_INTERFACE); + prj.add_raw_source("Counter.vy", VYPER_CONTRACT); cmd.args(["compiler", "resolve", "--skip", ".sol", "-v"]).assert_success().stdout_eq(str![[ r#" @@ -181,15 +225,16 @@ Vyper: "# ]]); -}); +} -forgetest!(can_list_resolved_multiple_compiler_versions_skipped_json, |prj, cmd| { - prj.add_source("ContractA", CONTRACT_A).unwrap(); - prj.add_source("ContractB", CONTRACT_B).unwrap(); - prj.add_source("ContractC", CONTRACT_C).unwrap(); - prj.add_source("ContractD", CONTRACT_D).unwrap(); - prj.add_raw_source("ICounter.vyi", VYPER_INTERFACE).unwrap(); - prj.add_raw_source("Counter.vy", VYPER_CONTRACT).unwrap(); +#[forgetest] +fn can_list_resolved_multiple_compiler_versions_skipped_json(prj: _, cmd: _) { + prj.add_source("ContractA", CONTRACT_A); + prj.add_source("ContractB", CONTRACT_B); + prj.add_source("ContractC", CONTRACT_C); + prj.add_source("ContractD", CONTRACT_D); + prj.add_raw_source("ICounter.vyi", VYPER_INTERFACE); + prj.add_raw_source("Counter.vy", VYPER_CONTRACT); cmd.args(["compiler", "resolve", "--skip", "Contract(A|B|C)", "--json", "-v"]) .assert_success() @@ -198,7 +243,7 @@ forgetest!(can_list_resolved_multiple_compiler_versions_skipped_json, |prj, cmd| { "Solidity": [ { - "version": "0.8.30", + "version": "0.8.33", "paths": [ "src/ContractD.sol" ] @@ -217,15 +262,16 @@ forgetest!(can_list_resolved_multiple_compiler_versions_skipped_json, |prj, cmd| "#]] .is_json(), ); -}); +} -forgetest!(can_list_resolved_multiple_compiler_versions_verbose, |prj, cmd| { - prj.add_source("ContractA", CONTRACT_A).unwrap(); - prj.add_source("ContractB", CONTRACT_B).unwrap(); - prj.add_source("ContractC", CONTRACT_C).unwrap(); - prj.add_source("ContractD", CONTRACT_D).unwrap(); - prj.add_raw_source("ICounter.vyi", VYPER_INTERFACE).unwrap(); - prj.add_raw_source("Counter.vy", VYPER_CONTRACT).unwrap(); +#[forgetest] +fn can_list_resolved_multiple_compiler_versions_verbose(prj: _, cmd: _) { + prj.add_source("ContractA", CONTRACT_A); + prj.add_source("ContractB", CONTRACT_B); + prj.add_source("ContractC", CONTRACT_C); + prj.add_source("ContractD", CONTRACT_D); + prj.add_raw_source("ICounter.vyi", VYPER_INTERFACE); + prj.add_raw_source("Counter.vy", VYPER_CONTRACT); cmd.args(["compiler", "resolve", "-vv"]).assert_success().stdout_eq(str![[r#" Solidity: @@ -236,7 +282,7 @@ Solidity: 0.8.11 (<= london): └── src/ContractB.sol -0.8.30 (<= prague): +0.8.33 (<= prague): ├── src/ContractC.sol └── src/ContractD.sol @@ -248,15 +294,16 @@ Vyper: "#]]); -}); +} -forgetest!(can_list_resolved_multiple_compiler_versions_verbose_json, |prj, cmd| { - prj.add_source("ContractA", CONTRACT_A).unwrap(); - prj.add_source("ContractB", CONTRACT_B).unwrap(); - prj.add_source("ContractC", CONTRACT_C).unwrap(); - prj.add_source("ContractD", CONTRACT_D).unwrap(); - prj.add_raw_source("ICounter.vyi", VYPER_INTERFACE).unwrap(); - prj.add_raw_source("Counter.vy", VYPER_CONTRACT).unwrap(); +#[forgetest] +fn can_list_resolved_multiple_compiler_versions_verbose_json(prj: _, cmd: _) { + prj.add_source("ContractA", CONTRACT_A); + prj.add_source("ContractB", CONTRACT_B); + prj.add_source("ContractC", CONTRACT_C); + prj.add_source("ContractD", CONTRACT_D); + prj.add_raw_source("ICounter.vyi", VYPER_INTERFACE); + prj.add_raw_source("Counter.vy", VYPER_CONTRACT); cmd.args(["compiler", "resolve", "--json", "-vv"]).assert_success().stdout_eq( str![[r#" @@ -277,7 +324,7 @@ forgetest!(can_list_resolved_multiple_compiler_versions_verbose_json, |prj, cmd| ] }, { - "version": "0.8.30", + "version": "0.8.33", "evm_version": "[..]", "paths": [ "src/ContractC.sol", @@ -299,4 +346,4 @@ forgetest!(can_list_resolved_multiple_compiler_versions_verbose_json, |prj, cmd| "#]] .is_json(), ); -}); +} diff --git a/crates/forge/tests/cli/config.rs b/crates/forge/tests/cli/config.rs index a15a4ea3b7876..7b93728267ca1 100644 --- a/crates/forge/tests/cli/config.rs +++ b/crates/forge/tests/cli/config.rs @@ -1,14 +1,15 @@ //! Contains various tests for checking forge commands related to config values -use alloy_primitives::{Address, B256, U256}; +use alloy_primitives::{Address, B256, U256, address}; use foundry_cli::utils as forge_utils; use foundry_compilers::{ artifacts::{BytecodeHash, OptimizerDetails, RevertStrings, YulDetails}, solc::Solc, }; use foundry_config::{ - CompilationRestrictions, Config, FsPermissions, FuzzConfig, InvariantConfig, SettingsOverrides, - SolcReq, + CompilationRestrictions, Config, Eip1559FeeEstimatePreset, FsPermissions, FuzzConfig, + FuzzCorpusConfig, InvariantConfig, SettingsOverrides, SolcReq, SymbolicConfig, + SymbolicExplorationOrder, SymbolicStorageLayout, TracingConfig, cache::{CachedChains, CachedEndpoints, StorageCachingConfig}, filter::GlobMatcher, fs_permissions::{FsAccessPermission, PathPermission}, @@ -16,7 +17,7 @@ use foundry_config::{ use foundry_evm::opts::EvmOpts; use foundry_test_utils::{ foundry_compilers::artifacts::{EvmVersion, remappings::Remapping}, - util::{OTHER_SOLC_VERSION, OutputExt, TestCommand, pretty_err}, + util::{OTHER_SOLC_VERSION, OutputExt, SOLC_VERSION, TestCommand, pretty_err}, }; use path_slash::PathBufExt; use semver::VersionReq; @@ -26,23 +27,310 @@ use std::{ fs, path::{Path, PathBuf}, str::FromStr, + thread, }; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +const DEFAULT_CONFIG: &str = r#"[profile.default] +src = "src" +test = "test" +script = "script" +out = "out" +libs = ["lib"] +remappings = ["forge-std/=lib/forge-std/src/"] +auto_detect_remappings = true +libraries = [] +cache = true +cache_path = "cache" +dynamic_test_linking = true +snapshots = "snapshots" +gas_snapshot_check = false +gas_snapshot_emit = true +broadcast = "broadcast" +allow_paths = [] +include_paths = [] +skip = [] +force = false +evm_version = "osaka" +gas_reports = ["*"] +gas_reports_ignore = [] +gas_reports_include_tests = false +auto_detect_solc = true +offline = false +optimizer = false +optimizer_runs = 200 +verbosity = 0 +eth_rpc_accept_invalid_certs = false +eth_rpc_no_proxy = false +eth_rpc_curl = false +ignored_error_codes = [ + "license", + "code-size", + "init-code-size", + "transient-storage", + "transfer-deprecated", + "natspec-memory-safe-assembly-deprecated", +] +ignored_error_codes_from = [] +ignored_warnings_from = [] +deny = "never" +test_failures_file = "cache/test-failures" +mutation_dir = "cache/mutation" +show_progress = false +ffi = false +live_logs = false +allow_internal_expect_revert = false +always_use_create_2_factory = false +eip1559_fee_estimate = "market" +prompt_timeout = 120 +sender = "0x1804c8ab1f12e6bbf3894d4083f33e07309d1f38" +tx_origin = "0x1804c8ab1f12e6bbf3894d4083f33e07309d1f38" +initial_balance = "0xffffffffffffffffffffffff" +block_number = 1 +fork_state_by_number = false +gas_limit = 1073741824 +block_base_fee_per_gas = 0 +block_coinbase = "0x0000000000000000000000000000000000000000" +block_timestamp = 1 +block_difficulty = 0 +block_prevrandao = "0x0000000000000000000000000000000000000000000000000000000000000000" +memory_limit = 134217728 +extra_output = [] +extra_output_files = [] +names = false +sizes = false +via_ir = false +via_ssa_cfg = false +experimental = false +ast = false +no_storage_caching = false +no_fork_bal = false +no_rpc_rate_limit = false +use_literal_content = false +bytecode_hash = "ipfs" +cbor_metadata = true +sparse_mode = false +build_info = false +isolate = true +disable_block_gas_limit = false +enable_tx_gas_limit = false +unchecked_cheatcode_artifacts = false +decode_external_storage = false +create2_library_salt = "0x0000000000000000000000000000000000000000000000000000000000000000" +create2_deployer = "0x4e59b44847b379578588920ca78fbf26c0b4956c" +assertions_revert = true +legacy_assertions = false +celo = false +bypass_prevrandao = false +transaction_timeout = 120 +additional_compiler_profiles = [] +compilation_restrictions = [] +script_execution_protection = true + +[profile.default.rpc_storage_caching] +chains = "all" +endpoints = "all" + +[[profile.default.fs_permissions]] +access = "read" +path = "out" + +[fmt] +line_length = 120 +tab_width = 4 +style = "space" +bracket_spacing = false +int_types = "long" +multiline_func_header = "attributes_first" +quote_style = "double" +number_underscore = "preserve" +hex_underscore = "remove" +single_line_statement_blocks = "preserve" +override_spacing = false +wrap_comments = false +docs_style = "preserve" +ignore = [] +contract_new_lines = false +sort_imports = false +namespace_import_style = "prefer_plain" +pow_no_space = false +prefer_compact = "all" +single_line_imports = false + +[lint] +severity = [ + "high", + "medium", + "low", +] +exclude_lints = [] +ignore = [] +lint_on_build = true + +[lint.lint_specific] +mixed_case_exceptions = [ + "ERC", + "URI", + "ID", + "URL", + "API", + "JSON", + "XML", + "HTML", + "HTTP", + "HTTPS", +] +multi_contract_file_exceptions = [] + +[doc] +out = "docs" +title = "" +book = "book.toml" +homepage = "README.md" +ignore = [] + +[fuzz] +runs = 256 +fail_on_revert = true +max_test_rejects = 65536 +dictionary_weight = 40 +include_storage = true +include_push_bytes = true +max_fuzz_dictionary_addresses = 15728640 +max_fuzz_dictionary_values = 9830400 +max_fuzz_dictionary_literals = 6553600 +gas_report_samples = 256 +frontier_limit = 256 +corpus_gzip = true +corpus_min_mutations = 5 +corpus_min_size = 0 +show_edge_coverage = false +evm_edge_coverage_collision_free = true +evm_edge_coverage_include_call_depth = false +sancov_edges = false +sancov_trace_cmp = false +corpus_random_sequence_weight = 10 +payable_value_weight = 0 +mutation_weight_splice = 1 +mutation_weight_repeat = 1 +mutation_weight_interleave = 1 +mutation_weight_prefix = 1 +mutation_weight_suffix = 1 +mutation_weight_abi = 1 +mutation_weight_cmp = 1 +failure_persist_dir = "cache/fuzz" +show_logs = false + +[invariant] +runs = 256 +depth = 500 +min_depth = 1 +depth_mode = "fixed" +workers = "auto" +fail_on_revert = false +call_override = false +dictionary_weight = 80 +include_storage = true +include_push_bytes = true +max_fuzz_dictionary_addresses = 15728640 +max_fuzz_dictionary_values = 9830400 +max_fuzz_dictionary_literals = 6553600 +shrink_run_limit = 5000 +max_assume_rejects = 65536 +gas_report_samples = 256 +frontier_limit = 256 +corpus_gzip = true +corpus_min_mutations = 5 +corpus_min_size = 0 +show_edge_coverage = false +evm_edge_coverage_collision_free = true +evm_edge_coverage_include_call_depth = false +sancov_edges = false +sancov_trace_cmp = false +corpus_random_sequence_weight = 10 +payable_value_weight = 15 +mutation_weight_splice = 1 +mutation_weight_repeat = 1 +mutation_weight_interleave = 1 +mutation_weight_prefix = 1 +mutation_weight_suffix = 1 +mutation_weight_abi = 1 +mutation_weight_cmp = 1 +failure_persist_dir = "cache/invariant" +show_metrics = true +show_solidity = false +check_interval = 1 + +[symbolic] +enabled = false +seed_corpus = false +use_fuzz_corpus = false +corpus_seed_limit = 32 +use_fuzz_frontiers = false +check_invariant_frontiers = false +frontier_limit = 256 +solver = "z3" +timeout = 30 +max_depth = 10000 +max_paths = 1024 +invariant_depth = 10 +exploration_order = "bfs" +max_solver_queries = 10000 +default_dynamic_length = 2 +max_dynamic_length = 256 +array_lengths = [] +max_calldata_bytes = 4096 +symbolic_call_targets = false +dump_smt = false +storage_layout = "solidity" + +[coverage] +report = ["summary"] +lcov_version = "1.0.0" +ir_minimum = false +include_libs = false +exclude_tests = false +skip_files = [] + +[mutation] +include_operators = [] +exclude_operators = [] + +[tracing] +verbosity = 0 +disable_labels = false +compact_labels = false +decode_internal = false +external_identification_timeout = 5 + +[vyper] + +[bind_json] +out = "utils/JsonBindings.sol" +include = [] +exclude = [] + +"#; + // tests all config values that are in use -forgetest!(can_extract_config_values, |prj, cmd| { +#[forgetest] +fn can_extract_config_values(prj: _, cmd: _) { // explicitly set all values let input = Config { profile: Config::DEFAULT_PROFILE, // `profiles` is not serialized. profiles: vec![], root: ".".into(), + extends: None, src: "test-src".into(), test: "test-test".into(), script: "test-script".into(), out: "out-test".into(), libs: vec!["lib-test".into()], cache: true, - dynamic_test_linking: false, + dynamic_test_linking: true, cache_path: "test-cache".into(), snapshots: "snapshots".into(), gas_snapshot_check: false, @@ -50,6 +338,7 @@ forgetest!(can_extract_config_values, |prj, cmd| { broadcast: "broadcast".into(), force: true, evm_version: EvmVersion::Byzantium, + hardfork: None, gas_reports: vec!["Contract".to_string()], gas_reports_ignore: vec![], gas_reports_include_tests: false, @@ -77,6 +366,7 @@ forgetest!(can_extract_config_values, |prj, cmd| { path_pattern_inverse: None, coverage_pattern_inverse: None, test_failures_file: "test-cache/test-failures".into(), + mutation_dir: "test-cache/mutation".into(), threads: None, show_progress: false, fuzz: FuzzConfig { @@ -84,25 +374,67 @@ forgetest!(can_extract_config_values, |prj, cmd| { max_test_rejects: 100203, seed: Some(U256::from(1000)), failure_persist_dir: Some("test-cache/fuzz".into()), - failure_persist_file: Some("failures".to_string()), show_logs: false, ..Default::default() }, invariant: InvariantConfig { runs: 256, failure_persist_dir: Some("test-cache/fuzz".into()), - corpus_dir: Some("cache/invariant/corpus".into()), + corpus: FuzzCorpusConfig { + corpus_dir: Some("cache/invariant/corpus".into()), + ..Default::default() + }, ..Default::default() }, + symbolic: SymbolicConfig { + enabled: true, + seed_corpus: true, + use_fuzz_corpus: true, + corpus_seed_limit: 17, + use_fuzz_frontiers: true, + check_invariant_frontiers: true, + frontier_limit: 11, + frontier_ids: vec![4, 9], + frontier_pcs: vec![123, 456], + frontier_selectors: vec!["0x12345678".to_string(), "deadbeef".to_string()], + solver: "custom-z3".to_string(), + solver_command: None, + solver_portfolio: Vec::new(), + timeout: Some(7), + loop_bound: Some(64), + depth: Some(222), + width: Some(33), + max_depth: 123, + max_paths: 456, + invariant_depth: 5, + exploration_order: SymbolicExplorationOrder::Dfs, + max_solver_queries: 789, + default_dynamic_length: 3, + max_dynamic_length: 99, + array_lengths: vec![1, 2, 3], + dynamic_lengths: std::collections::BTreeMap::from([("data".to_string(), vec![4, 5])]), + default_array_lengths: vec![6, 7], + default_bytes_lengths: vec![8, 9], + max_calldata_bytes: 2048, + symbolic_call_targets: true, + dump_smt: true, + storage_layout: SymbolicStorageLayout::Generic, + }, + coverage: Default::default(), + mutation: Default::default(), + tracing: TracingConfig { verbosity: 2, compact_labels: true, ..Default::default() }, ffi: true, + live_logs: true, allow_internal_expect_revert: false, always_use_create_2_factory: false, + eip1559_fee_estimate: Eip1559FeeEstimatePreset::Market, prompt_timeout: 0, - sender: "00a329c0648769A73afAc7F9381D08FB43dBEA72".parse().unwrap(), - tx_origin: "00a329c0648769A73afAc7F9F81E08FB43dBEA72".parse().unwrap(), + sender: address!("00a329c0648769A73afAc7F9381D08FB43dBEA72"), + tx_origin: address!("00a329c0648769A73afAc7F9F81E08FB43dBEA72"), initial_balance: U256::from(0xffffffffffffffffffffffffu128), block_number: U256::from(10), fork_block_number: Some(200), + fork_state_by_number: true, chain: Some(9999.into()), gas_limit: 99_000_000u64.into(), code_size_limit: Some(100000), @@ -114,14 +446,16 @@ forgetest!(can_extract_config_values, |prj, cmd| { block_prevrandao: B256::random(), block_gas_limit: Some(100u64.into()), disable_block_gas_limit: false, + enable_tx_gas_limit: false, memory_limit: 1 << 27, eth_rpc_url: Some("localhost".to_string()), eth_rpc_accept_invalid_certs: false, + eth_rpc_no_proxy: false, eth_rpc_jwt: None, eth_rpc_timeout: None, eth_rpc_headers: None, + eth_rpc_curl: false, etherscan_api_key: None, - etherscan_api_version: None, etherscan: Default::default(), verbosity: 4, remappings: vec![Remapping::from_str("forge-std/=lib/forge-std/").unwrap().into()], @@ -129,7 +463,9 @@ forgetest!(can_extract_config_values, |prj, cmd| { "src/DssSpell.sol:DssExecLib:0x8De6DDbCd5053d32292AAA0D2105A32d108484a6".to_string(), ], ignored_error_codes: vec![], + ignored_error_codes_from: vec![], ignored_file_paths: vec![], + deny: foundry_config::DenyLevel::Never, deny_warnings: false, via_ir: true, ast: false, @@ -138,6 +474,7 @@ forgetest!(can_extract_config_values, |prj, cmd| { endpoints: CachedEndpoints::Remote, }, no_storage_caching: true, + no_fork_bal: true, no_rpc_rate_limit: true, use_literal_content: false, bytecode_hash: Default::default(), @@ -157,9 +494,11 @@ forgetest!(can_extract_config_values, |prj, cmd| { labels: Default::default(), isolate: true, unchecked_cheatcode_artifacts: false, + decode_external_storage: false, create2_library_salt: Config::DEFAULT_CREATE2_LIBRARY_SALT, create2_deployer: Config::DEFAULT_CREATE2_DEPLOYER, vyper: Default::default(), + external_compilers: vec![], skip: vec![], dependencies: Default::default(), soldeer: Default::default(), @@ -167,7 +506,9 @@ forgetest!(can_extract_config_values, |prj, cmd| { assertions_revert: true, legacy_assertions: false, extra_args: vec![], - odyssey: false, + experimental: false, + via_ssa_cfg: false, + networks: Default::default(), transaction_timeout: 120, additional_compiler_profiles: Default::default(), compilation_restrictions: Default::default(), @@ -177,21 +518,46 @@ forgetest!(can_extract_config_values, |prj, cmd| { prj.write_config(input.clone()); let config = cmd.config(); similar_asserts::assert_eq!(input, config); -}); +} // tests config gets printed to std out -forgetest!(can_show_config, |prj, cmd| { +#[forgetest] +fn can_show_config(prj: _, cmd: _) { let expected = Config::load_with_root(prj.root()).unwrap().to_string_pretty().unwrap().trim().to_string(); let output = cmd.arg("config").assert_success().get_output().stdout_lossy().trim().to_string(); assert_eq!(expected, output); -}); +} + +#[forgetest] +fn can_select_profile_with_cli(prj: _, cmd: _) { + prj.create_file( + Config::FILE_NAME, + r#" +[profile.default] +optimizer = false +optimizer_runs = 200 + +[profile.ci] +optimizer = true +optimizer_runs = 1 +"#, + ); + cmd.env("foundry_profile", "default"); + + let config = cmd.args(["--profile", "ci"]).config(); + + assert_eq!(config.optimizer, Some(true)); + assert_eq!(config.optimizer_runs, Some(1)); +} // checks that config works // - foundry.toml is properly generated // - paths are resolved properly // - config supports overrides from env, and cli -forgetest_init!(can_override_config, |prj, cmd| { +#[forgetest_init] +fn can_override_config(prj: _, cmd: _) { + prj.initialize_default_contracts(); cmd.set_current_dir(prj.root()); let foundry_toml = prj.root().join(Config::FILE_NAME); assert!(foundry_toml.exists()); @@ -255,9 +621,11 @@ forgetest_init!(can_override_config, |prj, cmd| { .trim() .to_string(); assert_eq!(expected, output); -}); +} -forgetest_init!(can_parse_remappings_correctly, |prj, cmd| { +#[forgetest_init] +fn can_parse_remappings_correctly(prj: _, cmd: _) { + prj.initialize_default_contracts(); cmd.set_current_dir(prj.root()); let foundry_toml = prj.root().join(Config::FILE_NAME); assert!(foundry_toml.exists()); @@ -276,11 +644,14 @@ forgetest_init!(can_parse_remappings_correctly, |prj, cmd| { assert_eq!(expected, output); let install = |cmd: &mut TestCommand, dep: &str| { - cmd.forge_fuse().args(["install", dep]).assert_success().stdout_eq(str![[r#" -Installing solmate in [..] (url: Some("https://github.com/transmissions11/solmate"), tag: None) + cmd.forge_fuse().args(["install", dep]).assert_success().stdout_eq(str![""]).stderr_eq( + str![[r#" +Installing solmate in [..] (url: https://github.com/transmissions11/solmate, tag: None) +... Installed solmate[..] -"#]]); +"#]], + ); }; install(&mut cmd, "transmissions11/solmate"); @@ -317,9 +688,11 @@ Installing solmate in [..] (url: Some("https://github.com/transmissions11/solmat .trim() .to_string(); assert_eq!(expected, output); -}); +} -forgetest_init!(can_detect_config_vals, |prj, _cmd| { +#[forgetest_init] +fn can_detect_config_vals(prj: _) { + prj.initialize_default_contracts(); let url = "http://127.0.0.1:8545"; let config = prj.config_from_output(["--no-auto-detect", "--rpc-url", url]); assert!(!config.auto_detect_solc); @@ -336,10 +709,12 @@ forgetest_init!(can_detect_config_vals, |prj, _cmd| { let config = prj.config_from_output(["--force"]); assert!(!config.auto_detect_solc); assert_eq!(config.eth_rpc_url, Some(url.to_string())); -}); +} // checks that `clean` removes dapptools style paths -forgetest_init!(can_get_evm_opts, |prj, _cmd| { +#[forgetest_init] +fn can_get_evm_opts(prj: _) { + prj.initialize_default_contracts(); let url = "http://127.0.0.1:8545"; let config = prj.config_from_output(["--rpc-url", url, "--ffi"]); assert_eq!(config.eth_rpc_url, Some(url.to_string())); @@ -354,26 +729,58 @@ forgetest_init!(can_get_evm_opts, |prj, _cmd| { unsafe { std::env::remove_var("FOUNDRY_ETH_RPC_URL"); } -}); +} + +// Regression test for : +// the bare `ETH_RPC_URL` env var must NOT cause `forge` commands to set +// `eth_rpc_url` (which would silently fork all `forge test` runs). +// Only `--rpc-url`, `foundry.toml`, the `FOUNDRY_ETH_RPC_URL` env var, or +// cheatcodes should configure forking. +#[forgetest_init] +fn eth_rpc_url_env_does_not_set_fork_url(prj: _) { + prj.initialize_default_contracts(); + let url = "http://127.0.0.1:8545"; + + let mut cmd = prj.forge_bin(); + cmd.arg("config") + .arg("--root") + .arg(prj.root()) + .arg("--json") + .env("ETH_RPC_URL", url) + // Make sure the figment-style env var is not set in the test environment. + .env_remove("FOUNDRY_ETH_RPC_URL"); + let output = cmd.output().unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let config: Config = serde_json::from_str(stdout.as_ref()).unwrap(); + assert_eq!( + config.eth_rpc_url, None, + "bare ETH_RPC_URL must not propagate to forge config (regression #14538)" + ); +} // checks that we can set various config values -forgetest_init!(can_set_config_values, |prj, _cmd| { - let config = prj.config_from_output(["--via-ir", "--no-metadata"]); +#[forgetest_init] +fn can_set_config_values(prj: _) { + prj.initialize_default_contracts(); + let config = + prj.config_from_output(["--via-ir", "--experimental", "--via-ssa-cfg", "--no-metadata"]); assert!(config.via_ir); + assert!(config.experimental); + assert!(config.via_ssa_cfg); assert_eq!(config.cbor_metadata, false); assert_eq!(config.bytecode_hash, BytecodeHash::None); -}); +} // tests that solc can be explicitly set -forgetest!(can_set_solc_explicitly, |prj, cmd| { +#[forgetest] +fn can_set_solc_explicitly(prj: _, cmd: _) { prj.add_source( "Foo", r" pragma solidity *; contract Greeter {} ", - ) - .unwrap(); + ); prj.update_config(|config| { config.solc = Some(OTHER_SOLC_VERSION.into()); @@ -385,18 +792,18 @@ contract Greeter {} Compiler run successful! "#]]); -}); +} // tests that `--use ` works -forgetest!(can_use_solc, |prj, cmd| { +#[forgetest] +fn can_use_solc(prj: _, cmd: _) { prj.add_raw_source( "Foo", r" pragma solidity *; contract Foo {} ", - ) - .unwrap(); + ); cmd.args(["build", "--use", OTHER_SOLC_VERSION]).assert_success().stdout_eq(str![[r#" [COMPILING_FILES] with [SOLC_VERSION] @@ -419,7 +826,7 @@ Compiler run successful! // fails to use solc that does not exist cmd.forge_fuse().args(["build", "--use", "this/solc/does/not/exist"]); cmd.assert_failure().stderr_eq(str![[r#" -Error: `solc` this/solc/does/not/exist does not exist +Error: `solc` "this/solc/does/not/exist" does not exist "#]]); @@ -427,7 +834,7 @@ Error: `solc` this/solc/does/not/exist does not exist let local_solc = Solc::find_or_install(&OTHER_SOLC_VERSION.parse().unwrap()).unwrap(); cmd.forge_fuse() .args(["build", "--force", "--use"]) - .arg(local_solc.solc) + .arg(&local_solc.solc) .root_arg() .assert_success() .stdout_eq(str![[r#" @@ -436,10 +843,25 @@ Error: `solc` this/solc/does/not/exist does not exist Compiler run successful! "#]]); -}); + + let bin_dir = prj.root().join("bin"); + fs::create_dir(&bin_dir).unwrap(); + let path_solc = bin_dir.join(format!("custom-solc{}", std::env::consts::EXE_SUFFIX)); + fs::copy(local_solc.solc, path_solc).unwrap(); + + cmd.forge_fuse(); + cmd.env("PATH", &bin_dir); + cmd.args(["build", "--force", "--use", "custom-solc"]).root_arg().assert_success(); + + prj.update_config(|config| config.solc = Some(SolcReq::Local("custom-solc".into()))); + cmd.forge_fuse(); + cmd.env("PATH", bin_dir); + cmd.args(["build", "--force"]).root_arg().assert_success(); +} // test to ensure yul optimizer can be set as intended -forgetest!(can_set_yul_optimizer, |prj, cmd| { +#[forgetest] +fn can_set_yul_optimizer(prj: _, cmd: _) { prj.update_config(|config| config.optimizer = Some(true)); prj.add_source( "foo.sol", @@ -452,8 +874,7 @@ contract Foo { } } ", - ) - .unwrap(); + ); cmd.arg("build").assert_failure().stderr_eq(str![[r#" Error: Compiler run failed: @@ -468,10 +889,11 @@ Error (6553): The msize instruction cannot be used when the Yul optimizer is act // disable yul optimizer explicitly prj.update_config(|config| config.optimizer_details.get_or_insert_default().yul = Some(false)); cmd.assert_success(); -}); +} // tests that the lib triple can be parsed -forgetest_init!(can_parse_dapp_libraries, |_prj, cmd| { +#[forgetest_init] +fn can_parse_dapp_libraries(cmd: _) { cmd.env( "DAPP_LIBRARIES", "src/DssSpell.sol:DssExecLib:0x8De6DDbCd5053d32292AAA0D2105A32d108484a6", @@ -481,10 +903,11 @@ forgetest_init!(can_parse_dapp_libraries, |_prj, cmd| { config.libraries, vec!["src/DssSpell.sol:DssExecLib:0x8De6DDbCd5053d32292AAA0D2105A32d108484a6".to_string(),] ); -}); +} // test that optimizer runs works -forgetest!(can_set_optimizer_runs, |prj, cmd| { +#[forgetest] +fn can_set_optimizer_runs(prj: _, cmd: _) { // explicitly set optimizer runs prj.update_config(|config| config.optimizer_runs = Some(1337)); @@ -493,10 +916,11 @@ forgetest!(can_set_optimizer_runs, |prj, cmd| { let config = prj.config_from_output(["--optimizer-runs", "300"]); assert_eq!(config.optimizer_runs, Some(300)); -}); +} // test that use_literal_content works -forgetest!(can_set_use_literal_content, |prj, cmd| { +#[forgetest] +fn can_set_use_literal_content(prj: _, cmd: _) { // explicitly set use_literal_content prj.update_config(|config| config.use_literal_content = false); @@ -505,28 +929,31 @@ forgetest!(can_set_use_literal_content, |prj, cmd| { let config = prj.config_from_output(["--use-literal-content"]); assert_eq!(config.use_literal_content, true); -}); +} // -forgetest!(enable_optimizer_when_runs_set, |prj, cmd| { +#[forgetest] +fn enable_optimizer_when_runs_set(prj: _, cmd: _) { // explicitly set optimizer runs prj.update_config(|config| config.optimizer_runs = Some(1337)); let config = cmd.config(); assert!(config.optimizer.unwrap()); -}); +} // test `optimizer_runs` set to 200 by default if optimizer enabled -forgetest!(optimizer_runs_default, |prj, cmd| { +#[forgetest] +fn optimizer_runs_default(prj: _, cmd: _) { // explicitly set optimizer prj.update_config(|config| config.optimizer = Some(true)); let config = cmd.config(); assert_eq!(config.optimizer_runs, Some(200)); -}); +} // test that gas_price can be set -forgetest!(can_set_gas_price, |prj, cmd| { +#[forgetest] +fn can_set_gas_price(prj: _, cmd: _) { // explicitly set gas_price prj.update_config(|config| config.gas_price = Some(1337)); @@ -535,10 +962,12 @@ forgetest!(can_set_gas_price, |prj, cmd| { let config = prj.config_from_output(["--gas-price", "300"]); assert_eq!(config.gas_price, Some(300)); -}); +} // test that we can detect remappings from foundry.toml -forgetest_init!(can_detect_lib_foundry_toml, |prj, cmd| { +#[forgetest_init] +fn can_detect_lib_foundry_toml(prj: _, cmd: _) { + prj.initialize_default_contracts(); let config = cmd.config(); let remappings = config.remappings.iter().cloned().map(Remapping::from).collect::>(); similar_asserts::assert_eq!( @@ -632,11 +1061,13 @@ forgetest_init!(can_detect_lib_foundry_toml, |prj, cmd| { "nested/=lib/nested-lib/lib/nested/".parse().unwrap(), ] ); -}); +} // test remappings with closer paths are prioritised // so that `dep/=lib/a/src` will take precedent over `dep/=lib/a/lib/b/src` -forgetest_init!(can_prioritise_closer_lib_remappings, |prj, cmd| { +#[forgetest_init] +fn can_prioritise_closer_lib_remappings(prj: _, cmd: _) { + prj.initialize_default_contracts(); let config = cmd.config(); // create a new lib directly in the `lib` folder with conflicting remapping `forge-std/` @@ -656,22 +1087,27 @@ forgetest_init!(can_prioritise_closer_lib_remappings, |prj, cmd| { "forge-std/=lib/forge-std/src/".parse().unwrap() ] ); -}); +} // Test that remappings within root of the project have priority over remappings of sub-projects. // E.g. `@utils/libraries` mapping from library shouldn't be added if project already has `@utils` // remapping. // See // Test that +// - single file remapping is properly added, see +// and // - project defined `@openzeppelin/contracts` remapping is added // - library defined `@openzeppelin/contracts-upgradeable` remapping is added // - library defined `@openzeppelin/contracts/upgradeable` remapping is not added as it conflicts // with project defined `@openzeppelin/contracts` remapping // See -forgetest_init!(can_prioritise_project_remappings, |prj, cmd| { +#[forgetest_init] +fn can_prioritise_project_remappings(prj: _, cmd: _) { + prj.initialize_default_contracts(); let mut config = cmd.config(); // Add `@utils/` remapping in project config. config.remappings = vec![ + Remapping::from_str("@utils/libraries/Contract.sol=src/Contract.sol").unwrap().into(), Remapping::from_str("@utils/=src/").unwrap().into(), Remapping::from_str("@openzeppelin/contracts=lib/openzeppelin-contracts/").unwrap().into(), ]; @@ -697,57 +1133,801 @@ forgetest_init!(can_prioritise_project_remappings, |prj, cmd| { let lib_toml_file = nested.join("foundry.toml"); pretty_err(&lib_toml_file, fs::write(&lib_toml_file, lib_config.to_string_pretty().unwrap())); - cmd.args(["remappings", "--pretty"]).assert_success().stdout_eq(str![[r#" + cmd.args(["remappings", "--pretty"]) + .assert_success() + .stdout_eq(str![[r#" +@utils/libraries/Contract.sol=src/Contract.sol +@utils/=src/ +@openzeppelin/contracts/=lib/openzeppelin-contracts/ +@openzeppelin/contracts-upgradeable/=lib/dep1/lib/openzeppelin-upgradeable/ +dep1/=lib/dep1/src/ +forge-std/=lib/forge-std/src/ + +"#]]) + .stderr_eq(str![[r#" Global: -- @utils/=src/ -- @openzeppelin/contracts/=lib/openzeppelin-contracts/ -- @openzeppelin/contracts-upgradeable/=lib/dep1/lib/openzeppelin-upgradeable/ -- dep1/=lib/dep1/src/ -- forge-std/=lib/forge-std/src/ "#]]); -}); +} -// test to check that foundry.toml libs section updates on install -forgetest!(can_update_libs_section, |prj, cmd| { - cmd.git_init(); +#[forgetest] +fn narrow_project_remapping_preserves_broad_dependency_fallback(prj: _, cmd: _) { + prj.update_config(|config| { + config.remappings = vec![Remapping::from_str("pkg/sub/=src/local/").unwrap().into()]; + }); + prj.add_source( + "Root.sol", + r#" +import {Dep} from "pkg/Dep.sol"; +import {Local} from "pkg/sub/Local.sol"; - // explicitly set gas_price - prj.update_config(|config| config.libs = vec!["node_modules".into()]); +contract Root is Dep, Local {} +"#, + ); + prj.add_source( + "local/Local.sol", + r#" +contract Local { + function localValue() public pure returns (uint256) { + return 1; + } +} +"#, + ); + let dependency = prj.root().join("lib/pkg/src"); + pretty_err(&dependency, fs::create_dir_all(&dependency)); + pretty_err( + dependency.join("Dep.sol"), + fs::write( + dependency.join("Dep.sol"), + r#" +contract Dep { + function dependencyValue() public pure returns (uint256) { + return 2; + } +} +"#, + ), + ); - cmd.args(["install", "foundry-rs/forge-std"]).assert_success().stdout_eq(str![[r#" -Installing forge-std in [..] (url: Some("https://github.com/foundry-rs/forge-std"), tag: None) - Installed forge-std[..] + cmd.args(["remappings"]).assert_success().stdout_eq(str![[r#" +pkg/sub/=src/local/ +pkg/=lib/pkg/src/ "#]]); + cmd.forge_fuse().args(["build"]).assert_success(); + // Forge lint resolves imports through Solar independently of the solc build. + cmd.forge_fuse().args(["lint"]).assert_success(); +} - let config = cmd.forge_fuse().config(); - // `lib` was added automatically - let expected = vec![PathBuf::from("node_modules"), PathBuf::from("lib")]; - assert_eq!(config.libs, expected); +#[forgetest] +fn nested_config_remapping_refines_auto_detected_package_root(prj: _, cmd: _) { + let outer = prj.paths().libraries[0].join("outer"); + let outer_other = prj.paths().libraries[0].join("outer-other"); + let inner = outer.join("lib/inner"); + prj.update_config(|config| { + config.remappings = + vec![Remapping::from_str("test/:inner/=src/unrelated/").unwrap().into()]; + }); + pretty_err(&outer, fs::create_dir_all(outer.join("src"))); + pretty_err(&outer_other, fs::create_dir_all(outer_other.join("src"))); + pretty_err(&inner, fs::create_dir_all(inner.join("contracts"))); + pretty_err(&outer, fs::write(outer.join("foundry.toml"), "[profile.default]\n")); + pretty_err(&outer, fs::write(outer.join("remappings.txt"), "inner/=lib/inner/contracts/\n")); + pretty_err(&inner, fs::write(inner.join("Marker.sol"), "contract Marker {}\n")); + pretty_err(&inner, fs::write(inner.join("contracts/I.sol"), "interface I {}\n")); + pretty_err( + &outer, + fs::write( + outer.join("src/Outer.sol"), + "import {I} from \"inner/I.sol\"; contract Outer is I {}\n", + ), + ); + pretty_err( + &outer_other, + fs::write( + outer_other.join("src/Other.sol"), + "import {Marker} from \"inner/Marker.sol\"; contract Other is Marker {}\n", + ), + ); + prj.add_source( + "UsesOuter.sol", + "import {Outer} from \"outer/Outer.sol\"; import {Other} from \"outer-other/Other.sol\"; import {Marker} from \"inner/Marker.sol\"; contract UsesOuter is Outer, Other {}\n", + ); - // additional install don't edit `libs` - cmd.forge_fuse().args(["install", "dapphub/ds-test"]).assert_success().stdout_eq(str![[r#" -Installing ds-test in [..] (url: Some("https://github.com/dapphub/ds-test"), tag: None) - Installed ds-test + cmd.args(["remappings"]).assert_success().stdout_eq(str![[r#" +test/:inner/=src/unrelated/ +lib/outer/:inner/=lib/outer/lib/inner/contracts/ +inner/=lib/outer/lib/inner/ +outer-other/=lib/outer-other/src/ +outer/=lib/outer/src/ "#]]); + cmd.forge_fuse().arg("build").assert_success(); + cmd.forge_fuse().arg("lint").assert_success(); +} + +#[forgetest] +fn duplicate_transitive_remappings_are_scoped_to_their_owners(prj: _, cmd: _) { + let a = prj.root().join("deps-a/a"); + let b = prj.root().join("deps-b/b"); + pretty_err(&a, fs::create_dir_all(a.join("src"))); + pretty_err(&a, fs::create_dir_all(a.join("lib/shared/src"))); + pretty_err(&b, fs::create_dir_all(b.join("src"))); + pretty_err(&b, fs::create_dir_all(b.join("lib/shared/src"))); + prj.update_config(|config| config.libs = vec!["deps-b".into(), "deps-a".into()]); + pretty_err( + &a, + fs::write( + a.join("src/A.sol"), + "import {VersionA} from \"shared/Version.sol\"; contract A is VersionA {}\n", + ), + ); + pretty_err(&a, fs::write(a.join("lib/shared/src/Version.sol"), "contract VersionA {}\n")); + pretty_err( + &b, + fs::write( + b.join("src/B.sol"), + "import {VersionB} from \"shared/Version.sol\"; contract B is VersionB {}\n", + ), + ); + pretty_err(&b, fs::write(b.join("lib/shared/src/Version.sol"), "contract VersionB {}\n")); + prj.add_source("UsesDependencies.sol", "import {A} from \"a/A.sol\"; import {B} from \"b/B.sol\"; import {VersionA} from \"shared/Version.sol\"; contract UsesDependencies { A a; B b; VersionA version; }\n"); + + let expected = str![[r#" +deps-a/a/:shared/=deps-a/a/lib/shared/src/ +deps-b/b/:shared/=deps-b/b/lib/shared/src/ +a/=deps-a/a/src/ +b/=deps-b/b/src/ +shared/=deps-a/a/lib/shared/src/ + +"#]]; + cmd.arg("remappings").assert_success().stdout_eq(expected.clone()); + cmd.forge_fuse().arg("build").assert_success(); + cmd.forge_fuse().arg("lint").assert_success(); + prj.update_config(|config| config.libs = vec!["deps-a".into(), "deps-b".into()]); + cmd.forge_fuse().arg("remappings").assert_success().stdout_eq(expected); +} + +#[forgetest] +fn scoped_npm_context_preserves_hoisted_sibling_fallback(prj: _, cmd: _) { + let owner = prj.root().join("node_modules/@bananapus/router-terminal-v6"); + let nested_v3 = owner.join("node_modules/@uniswap/v3-core/src"); + let hoisted_v3 = prj.root().join("node_modules/@uniswap/v3-core/src"); + let hoisted_v4 = prj.root().join("node_modules/@uniswap/v4-core/src"); + for dependency in [&owner.join("src"), &nested_v3, &hoisted_v3, &hoisted_v4] { + pretty_err(dependency, fs::create_dir_all(dependency)); + } + prj.update_config(|config| config.libs = vec!["node_modules".into()]); + pretty_err( + &owner, + fs::write( + owner.join("src/RouterTerminal.sol"), + "import {NestedV3} from \"@uniswap/v3-core/src/Version.sol\"; import {HoistedV4} from \"@uniswap/v4-core/src/Version.sol\"; contract RouterTerminal is NestedV3, HoistedV4 {}\n", + ), + ); + pretty_err(&nested_v3, fs::write(nested_v3.join("Version.sol"), "contract NestedV3 {}\n")); + pretty_err(&hoisted_v3, fs::write(hoisted_v3.join("Version.sol"), "contract HoistedV3 {}\n")); + pretty_err(&hoisted_v4, fs::write(hoisted_v4.join("Version.sol"), "contract HoistedV4 {}\n")); + prj.add_source( + "UsesRouterTerminal.sol", + "import {RouterTerminal} from \"@bananapus/router-terminal-v6/src/RouterTerminal.sol\"; import {HoistedV3} from \"@uniswap/v3-core/src/Version.sol\"; contract UsesRouterTerminal is RouterTerminal, HoistedV3 {}\n", + ); + + cmd.arg("remappings").assert_success().stdout_eq(str![[r#" +node_modules/@bananapus/router-terminal-v6/:@uniswap/v3-core/=node_modules/@bananapus/router-terminal-v6/node_modules/@uniswap/v3-core/ +@bananapus/=node_modules/@bananapus/ +@uniswap/=node_modules/@uniswap/ + +"#]]); + cmd.forge_fuse().arg("build").assert_success(); + cmd.forge_fuse().arg("lint").assert_success(); +} + +#[forgetest] +fn contextual_remapping_dedup_uses_context_and_name(prj: _, cmd: _) { + let a = prj.root().join("lib/a"); + let z = prj.root().join("lib/z"); + for dependency in [&a, &z] { + pretty_err(dependency, fs::create_dir_all(dependency.join("src"))); + pretty_err(dependency, fs::create_dir_all(dependency.join("lib/shared/src"))); + } + pretty_err(prj.root(), fs::create_dir_all(prj.root().join("src/collision"))); + prj.update_config(|config| { + config.remappings = + vec![Remapping::from_str("lib/z/shared/=src/collision/").unwrap().into()]; + }); + pretty_err( + &a, + fs::write( + a.join("src/A.sol"), + "import {VersionA} from \"shared/Version.sol\"; contract A is VersionA {}\n", + ), + ); + pretty_err( + &z, + fs::write( + z.join("src/Z.sol"), + "import {VersionZ} from \"shared/Version.sol\"; contract Z is VersionZ {}\n", + ), + ); + pretty_err(&a, fs::write(a.join("lib/shared/src/Version.sol"), "contract VersionA {}\n")); + pretty_err(&z, fs::write(z.join("lib/shared/src/Version.sol"), "contract VersionZ {}\n")); + prj.add_source( + "UsesDependencies.sol", + "import {A} from \"a/A.sol\"; import {Z} from \"z/Z.sol\"; contract UsesDependencies is A, Z {}\n", + ); + + cmd.arg("remappings").assert_success().stdout_eq(str![[r#" +lib/z/shared/=src/collision/ +lib/a/:shared/=lib/a/lib/shared/src/ +lib/z/:shared/=lib/z/lib/shared/src/ +a/=lib/a/src/ +shared/=lib/a/lib/shared/src/ +z/=lib/z/src/ + +"#]]); + cmd.forge_fuse().arg("build").assert_success(); + cmd.forge_fuse().arg("lint").assert_success(); +} + +#[forgetest] +fn contextual_auto_remapping_uses_configured_dependency_source(prj: _, cmd: _) { + let a = prj.root().join("lib/a"); + let b = prj.root().join("lib/b"); + let shared_a = a.join("lib/shared"); + let shared_b = b.join("lib/shared"); + pretty_err(&a, fs::create_dir_all(a.join("src"))); + pretty_err(&b, fs::create_dir_all(b.join("src"))); + pretty_err(&shared_a, fs::create_dir_all(shared_a.join("custom-source"))); + pretty_err(&shared_b, fs::create_dir_all(shared_b.join("src"))); + pretty_err(&a, fs::write(a.join("foundry.toml"), "[profile.default]\nlibs = [\"lib\"]\n")); + pretty_err( + &shared_a, + fs::write(shared_a.join("foundry.toml"), "[profile.default]\nsrc = \"custom-source\"\n"), + ); + pretty_err( + &a, + fs::write( + a.join("src/A.sol"), + "import {VersionA} from \"shared/Version.sol\"; contract A is VersionA {}\n", + ), + ); + pretty_err( + &shared_a, + fs::write(shared_a.join("custom-source/Version.sol"), "contract VersionA {}\n"), + ); + pretty_err( + &b, + fs::write( + b.join("src/B.sol"), + "import {VersionB} from \"shared/Version.sol\"; contract B is VersionB {}\n", + ), + ); + pretty_err(&shared_b, fs::write(shared_b.join("src/Version.sol"), "contract VersionB {}\n")); + prj.add_source("UsesDependencies.sol", "import {A} from \"a/A.sol\"; import {B} from \"b/B.sol\"; contract UsesDependencies { A a; B b; }\n"); + + cmd.arg("remappings").assert_success().stdout_eq(str![[r#" +lib/a/:shared/=lib/a/lib/shared/custom-source/ +lib/b/:shared/=lib/b/lib/shared/src/ +a/=lib/a/src/ +b/=lib/b/src/ +shared/=lib/b/lib/shared/src/ + +"#]]); + cmd.forge_fuse().arg("build").assert_success(); + cmd.forge_fuse().arg("lint").assert_success(); +} + +#[forgetest] +fn explicit_context_precedes_nested_auto_remapping(prj: _, cmd: _) { + let a = prj.root().join("lib/a"); + let shared_a = a.join("lib/shared/src"); + let x = a.join("lib/x"); + let shared_x = x.join("lib/shared/src"); + pretty_err(&a, fs::create_dir_all(a.join("src"))); + pretty_err(&shared_a, fs::create_dir_all(&shared_a)); + pretty_err(&x, fs::create_dir_all(x.join("src"))); + pretty_err(&shared_x, fs::create_dir_all(&shared_x)); + pretty_err(prj.root(), fs::create_dir_all(prj.root().join("src/override"))); + prj.update_config(|config| { + config.remappings = + vec![Remapping::from_str("lib/a/:shared/=src/override/").unwrap().into()]; + }); + pretty_err( + prj.root(), + fs::write(prj.root().join("src/override/Version.sol"), "contract Selected {}\n"), + ); + pretty_err(&shared_a, fs::write(shared_a.join("Version.sol"), "contract ShadowedA {}\n")); + pretty_err(&shared_x, fs::write(shared_x.join("Version.sol"), "contract ShadowedX {}\n")); + pretty_err( + &a, + fs::write( + a.join("src/A.sol"), + "import {Selected} from \"shared/Version.sol\"; import {X} from \"x/X.sol\"; contract A is Selected, X {}\n", + ), + ); + pretty_err( + &x, + fs::write( + x.join("src/X.sol"), + "import {Selected} from \"shared/Version.sol\"; contract X is Selected {}\n", + ), + ); + prj.add_source("UsesA.sol", "import {A} from \"a/A.sol\"; contract UsesA is A {}\n"); + cmd.arg("build").assert_success(); + cmd.forge_fuse().arg("lint").assert_success(); + + prj.update_config(|config| config.remappings.clear()); + let remapping = "lib/a/:shared/=src/override/"; + cmd.forge_fuse().args(["build", "--force", "--remappings", remapping]).assert_success(); + cmd.forge_fuse().args(["lint", "--remappings", remapping]).assert_success(); + + #[cfg(unix)] + { + let linked_parent = tempfile::tempdir().unwrap(); + let linked_root = linked_parent.path().join("linked-root"); + std::os::unix::fs::symlink(prj.root(), &linked_root).unwrap(); + let linked_context = linked_root.join("lib/a"); + let remapping = format!("{}:shared/=src/override/", linked_context.display()); + let root = linked_root.to_str().unwrap(); + cmd.forge_fuse() + .args(["build", "--force", "--root", root, "--remappings", &remapping]) + .assert_success(); + cmd.forge_fuse() + .args(["lint", "--root", root, "--remappings", &remapping]) + .assert_success(); + } +} + +#[forgetest] +fn slashless_context_is_a_lexical_prefix(prj: _, cmd: _) { + let abc = prj.root().join("lib/abc"); + let shared = abc.join("lib/shared/src"); + pretty_err(&abc, fs::create_dir_all(abc.join("src"))); + pretty_err(&shared, fs::create_dir_all(&shared)); + pretty_err(prj.root(), fs::create_dir_all(prj.root().join("src/override"))); + pretty_err( + prj.root(), + fs::write(prj.root().join("src/override/Version.sol"), "contract Selected {}\n"), + ); + pretty_err(&shared, fs::write(shared.join("Version.sol"), "contract Shadowed {}\n")); + pretty_err( + &abc, + fs::write( + abc.join("src/Abc.sol"), + "import {Selected} from \"shared/Version.sol\"; contract Abc is Selected {}\n", + ), + ); + prj.add_source( + "UsesAbc.sol", + "import {Abc} from \"abc/Abc.sol\"; contract UsesAbc is Abc {}\n", + ); + + let remapping = "lib/a:shared/=src/override/"; + prj.update_config(|config| { + config.remappings = vec![Remapping::from_str(remapping).unwrap().into()]; + }); + cmd.arg("build").assert_success(); + cmd.forge_fuse().arg("lint").assert_success(); + + prj.update_config(|config| config.remappings.clear()); + cmd.forge_fuse().args(["build", "--force", "--remappings", remapping]).assert_success(); + cmd.forge_fuse().args(["lint", "--remappings", remapping]).assert_success(); +} + +#[forgetest] +fn nested_auto_remapping_preserves_declared_precedence(prj: _, cmd: _) { + let a = prj.root().join("lib/a"); + let pinned_a = a.join("src/pinned"); + let shared_a = a.join("lib/shared/src"); + let x = a.join("lib/x"); + let shared_x = x.join("lib/shared"); + pretty_err(&a, fs::create_dir_all(a.join("src"))); + pretty_err(&pinned_a, fs::create_dir_all(&pinned_a)); + pretty_err(&shared_a, fs::create_dir_all(&shared_a)); + pretty_err(&x, fs::create_dir_all(x.join("src"))); + pretty_err(&shared_x, fs::create_dir_all(shared_x.join("src"))); + pretty_err(&a, fs::write(a.join("foundry.toml"), "[profile.default]\nlibs = [\"lib\"]\n")); + pretty_err(&a, fs::write(a.join("remappings.txt"), "shared/=src/pinned/\n")); + pretty_err(&pinned_a, fs::write(pinned_a.join("Marker.sol"), "contract Marker {}\n")); + pretty_err(&pinned_a, fs::write(pinned_a.join("Version.sol"), "contract VersionA {}\n")); + pretty_err(&shared_a, fs::write(shared_a.join("Version.sol"), "contract ShadowedA {}\n")); + pretty_err( + &a, + fs::write( + a.join("src/A.sol"), + "import {VersionA} from \"shared/Version.sol\"; import {X} from \"x/X.sol\"; contract A is VersionA, X {}\n", + ), + ); + pretty_err( + &x, + fs::write( + x.join("src/X.sol"), + "import {VersionX} from \"shared/Version.sol\"; contract X is VersionX {}\n", + ), + ); + pretty_err(&shared_x, fs::write(shared_x.join("src/Version.sol"), "contract VersionX {}\n")); + prj.add_source("UsesA.sol", "import {A} from \"a/A.sol\"; import {Marker} from \"shared/Marker.sol\"; contract UsesA is A, Marker {}\n"); + + cmd.arg("remappings").assert_success().stdout_eq(str![[r#" +lib/a/lib/x/:shared/=lib/a/lib/x/lib/shared/src/ +lib/a/:shared/=lib/a/src/pinned/ +a/=lib/a/src/ +shared/=lib/a/src/pinned/ +x/=lib/a/lib/x/src/ + +"#]]); + cmd.forge_fuse().arg("build").assert_success(); + cmd.forge_fuse().arg("lint").assert_success(); +} + +#[forgetest] +fn nested_contextual_remapping_precedes_auto_detection(prj: _, cmd: _) { + let a = prj.root().join("lib/a"); + let b = prj.root().join("lib/b"); + pretty_err(&a, fs::create_dir_all(a.join("src/pinned"))); + pretty_err(&a, fs::create_dir_all(a.join("lib/shared/src"))); + pretty_err(&b, fs::create_dir_all(b.join("src"))); + pretty_err(&b, fs::create_dir_all(b.join("lib/shared/src"))); + pretty_err( + &a, + fs::write( + a.join("foundry.toml"), + "[profile.default]\nremappings = [\"src/../src/:shared/=src/pinned/\"]\n", + ), + ); + pretty_err( + &a, + fs::write( + a.join("src/A.sol"), + "import {Selected} from \"shared/Version.sol\"; contract A is Selected {}\n", + ), + ); + pretty_err(&a, fs::write(a.join("src/pinned/Version.sol"), "contract Selected {}\n")); + pretty_err(&a, fs::write(a.join("lib/shared/src/Version.sol"), "contract ShadowedA {}\n")); + pretty_err( + &b, + fs::write( + b.join("src/B.sol"), + "import {VersionB} from \"shared/Version.sol\"; contract B is VersionB {}\n", + ), + ); + pretty_err(&b, fs::write(b.join("lib/shared/src/Version.sol"), "contract VersionB {}\n")); + prj.add_source( + "UsesDependencies.sol", + "import {A} from \"a/A.sol\"; import {B} from \"b/B.sol\"; contract UsesDependencies is A, B {}\n", + ); + + cmd.arg("remappings").assert_success().stdout_eq(str![[r#" +lib/a/src/:shared/=lib/a/src/pinned/ +lib/a/:shared/=lib/a/lib/shared/src/ +lib/b/:shared/=lib/b/lib/shared/src/ +a/=lib/a/src/ +b/=lib/b/src/ +shared/=lib/a/lib/shared/src/ + +"#]]); + cmd.forge_fuse().arg("build").assert_success(); + cmd.forge_fuse().arg("lint").assert_success(); +} + +#[forgetest] +fn external_dependency_uses_contextual_remapping(prj: _, cmd: _) { + let project = prj.root().join("utils"); + let dependency = prj.root().join("node_modules/dependency"); + let library = prj.root().join("node_modules/library/src"); + pretty_err(&project, fs::create_dir_all(project.join("src"))); + pretty_err(&dependency, fs::create_dir_all(dependency.join("src/internal"))); + pretty_err(&library, fs::create_dir_all(&library)); + pretty_err( + &project, + fs::write( + project.join("foundry.toml"), + r#" +[profile.default] +src = "src" +allow_paths = ["../"] +auto_detect_remappings = false +remappings = [ + "dependency/=../node_modules/dependency/src/", + "../node_modules/dependency/:library/=../node_modules/library/src/", + "library/=../node_modules/library/", +] +"#, + ), + ); + pretty_err( + &project, + fs::write( + project.join("src/Root.sol"), + r#" +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.0; + +import {Dep} from "dependency/Dep.sol"; +import {RootLibrary} from "library/src/RootLibrary.sol"; + +contract Root is Dep, RootLibrary {} +"#, + ), + ); + pretty_err( + &dependency, + fs::write( + dependency.join("src/Dep.sol"), + r#" +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.0; + +import {Core} from "./internal/Core.sol"; + +contract Dep is Core {} +"#, + ), + ); + pretty_err( + &dependency, + fs::write( + dependency.join("src/internal/Core.sol"), + r#" +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.0; + +import {DependencyLibrary} from "library/DependencyLibrary.sol"; + +contract Core is DependencyLibrary {} +"#, + ), + ); + pretty_err( + &library, + fs::write( + library.join("RootLibrary.sol"), + "// SPDX-License-Identifier: UNLICENSED\npragma solidity >=0.8.0;\ncontract RootLibrary {}\n", + ), + ); + pretty_err( + &library, + fs::write( + library.join("DependencyLibrary.sol"), + "// SPDX-License-Identifier: UNLICENSED\npragma solidity >=0.8.0;\ncontract DependencyLibrary {}\n", + ), + ); + + cmd.current_dir(project).args(["build", "--no-lint"]).assert_success(); +} + +#[forgetest] +fn cli_preserves_explicit_contextual_remapping_pair(prj: _, cmd: _) { + let dependency = prj.paths().libraries[0].join("dep"); + pretty_err(&dependency, fs::create_dir_all(dependency.join("src"))); + pretty_err(&dependency, fs::create_dir_all(dependency.join("pkg/contracts"))); + prj.update_config(|config| { + config.auto_detect_remappings = false; + config.remappings = + ["dep/=lib/dep/src/", "lib/dep/:pkg/=lib/dep/pkg/contracts/", "pkg/=lib/dep/pkg/"] + .map(|remapping| Remapping::from_str(remapping).unwrap().into()) + .into(); + }); + pretty_err( + &dependency, + fs::write( + dependency.join("src/Dep.sol"), + "import {Other} from \"pkg/Other.sol\"; contract Dep is Other {}\n", + ), + ); + pretty_err( + &dependency, + fs::write(dependency.join("pkg/contracts/Other.sol"), "contract Other {}\n"), + ); + prj.add_source( + "UsesDep.sol", + "import {Dep} from \"dep/Dep.sol\"; contract UsesDep is Dep {}\n", + ); + + cmd.args(["build", "--remappings", "pkg/sub/=src/local/"]).assert_success(); + + // A generated candidate that normalizes to the explicit contextual mapping must not cause the + // explicit mapping to be tagged as generated and removed by the CLI merge. + pretty_err(&dependency, fs::write(dependency.join("foundry.toml"), "[profile.default]\n")); + pretty_err(&dependency, fs::write(dependency.join("remappings.txt"), "pkg/=pkg/contracts/\n")); + prj.update_config(|config| config.auto_detect_remappings = true); + cmd.forge_fuse() + .args(["build", "--force", "--remappings", "pkg/sub/=src/local/"]) + .assert_success(); +} + +#[forgetest] +fn root_remapping_precedes_nested_refinement(prj: _, cmd: _) { + let outer = prj.paths().libraries[0].join("outer"); + let inner = outer.join("lib/inner"); + pretty_err(&outer, fs::create_dir_all(outer.join("src"))); + pretty_err(&inner, fs::create_dir_all(inner.join("contracts/sub"))); + pretty_err(prj.root(), fs::create_dir_all(prj.root().join("src/local"))); + prj.update_config(|config| { + config.remappings = vec![Remapping::from_str("inner/sub/=src/local/").unwrap().into()]; + }); + pretty_err(&outer, fs::write(outer.join("foundry.toml"), "[profile.default]\n")); + pretty_err(&outer, fs::write(outer.join("remappings.txt"), "inner/=lib/inner/contracts/\n")); + pretty_err(&inner, fs::write(inner.join("Marker.sol"), "contract Marker {}\n")); + pretty_err(&inner, fs::write(inner.join("contracts/I.sol"), "contract I {}\n")); + pretty_err( + prj.root(), + fs::write(prj.root().join("src/local/Selected.sol"), "contract Selected {}\n"), + ); + pretty_err( + &inner, + fs::write(inner.join("contracts/sub/Selected.sol"), "contract Shadowed {}\n"), + ); + pretty_err( + &outer, + fs::write( + outer.join("src/Outer.sol"), + "import {I} from \"inner/I.sol\"; import {Selected} from \"inner/sub/Selected.sol\"; contract Outer is I, Selected {}\n", + ), + ); + prj.add_source( + "UsesOuter.sol", + "import {Outer} from \"outer/Outer.sol\"; import {Marker} from \"inner/Marker.sol\"; contract UsesOuter is Outer, Marker {}\n", + ); + + cmd.arg("build").assert_success(); + + // CLI remappings are merged after dependency refinements are generated and must still retain + // root precedence. + prj.update_config(|config| config.remappings.clear()); + cmd.forge_fuse() + .args(["build", "--force", "--remappings", "inner/sub/=src/local/"]) + .assert_success(); +} + +#[forgetest] +fn broad_project_remapping_suppresses_narrow_dependency_override(prj: _, cmd: _) { + prj.update_config(|config| { + config.remappings = vec![Remapping::from_str("pkg/=src/local/").unwrap().into()]; + }); + prj.add_source( + "Root.sol", + r#" +import {Selected} from "pkg/sub/Selected.sol"; + +contract Root is Selected {} +"#, + ); + prj.add_source( + "local/sub/Selected.sol", + r#" +contract Selected { + function selectedValue() public pure returns (uint256) { + return 1; + } +} +"#, + ); + + let dependency = prj.root().join("lib/dep1"); + pretty_err(dependency.join("src/decoy"), fs::create_dir_all(dependency.join("src/decoy"))); + let mut dependency_config = Config::load_with_root(&dependency).unwrap(); + dependency_config.remappings = vec![Remapping::from_str("pkg/sub/=src/decoy/").unwrap().into()]; + pretty_err( + dependency.join("foundry.toml"), + fs::write(dependency.join("foundry.toml"), dependency_config.to_string_pretty().unwrap()), + ); + pretty_err( + dependency.join("src/decoy/Selected.sol"), + fs::write( + dependency.join("src/decoy/Selected.sol"), + r#" +contract DependencyDecoy {} +"#, + ), + ); + + cmd.args(["remappings"]).assert_success().stdout_eq(str![[r#" +pkg/=src/local/ +dep1/=lib/dep1/src/ + +"#]]); + cmd.forge_fuse().args(["build"]).assert_success(); + // Solar prefers the longest matching prefix, so this fails if the dependency override leaks. + cmd.forge_fuse().args(["lint"]).assert_success(); +} + +// Verifies the contract invariant: `forge remappings` and `forge remappings --pretty` emit +// identical stdout, even when remappings have contexts. The context prefix is part of the +// machine-readable value and must survive `--pretty` mode. +#[forgetest] +fn remappings_pretty_keeps_context_on_stdout(prj: _, cmd: _) { + prj.update_config(|config| { + config.auto_detect_remappings = false; + config.remappings = vec![ + Remapping::from_str("@global/=lib/global/").unwrap().into(), + Remapping::from_str("ctx-a:@scoped/=lib/a/").unwrap().into(), + Remapping::from_str("ctx-b:@scoped/=lib/b/").unwrap().into(), + ]; + }); + + cmd.args(["remappings"]).assert_success().stdout_eq(str![[r#" +@global/=lib/global/ +ctx-a:@scoped/=lib/a/ +ctx-b:@scoped/=lib/b/ + +"#]]); + + cmd.forge_fuse() + .args(["remappings", "--pretty"]) + .assert_success() + .stdout_eq(str![[r#" +@global/=lib/global/ +ctx-a:@scoped/=lib/a/ +ctx-b:@scoped/=lib/b/ + +"#]]) + .stderr_eq(str![[r#" +Global: + +Context: ctx-a + +Context: ctx-b + + +"#]]); +} + +// test to check that foundry.toml libs section updates on install +#[forgetest] +fn can_update_libs_section(prj: _, cmd: _) { + cmd.git_init(); + + // explicitly set gas_price + prj.update_config(|config| config.libs = vec!["node_modules".into()]); + + cmd.args(["install", "foundry-rs/forge-std"]).assert_success().stdout_eq(str![""]).stderr_eq( + str![[r#" +Installing forge-std in [..] (url: https://github.com/foundry-rs/forge-std, tag: None) +... + Installed forge-std[..] + +"#]], + ); let config = cmd.forge_fuse().config(); + // `lib` was added automatically + let expected = vec![PathBuf::from("node_modules"), PathBuf::from("lib")]; assert_eq!(config.libs, expected); -}); + + // additional install don't edit `libs` + cmd.forge_fuse() + .args(["install", "dapphub/ds-test"]) + .assert_success() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Installing ds-test in [..] (url: https://github.com/dapphub/ds-test, tag: None) +... + Installed ds-test + +"#]]); + + let config = cmd.forge_fuse().config(); + assert_eq!(config.libs, expected); +} // test to check that loading the config emits warnings on the root foundry.toml and // is silent for any libs -forgetest!(config_emit_warnings, |prj, cmd| { +#[forgetest] +fn config_emit_warnings(prj: _, cmd: _) { cmd.git_init(); - cmd.args(["install", "foundry-rs/forge-std"]).assert_success().stdout_eq(str![[r#" -Installing forge-std in [..] (url: Some("https://github.com/foundry-rs/forge-std"), tag: None) + cmd.args(["install", "foundry-rs/forge-std"]).assert_success().stdout_eq(str![""]).stderr_eq( + str![[r#" +Installing forge-std in [..] (url: https://github.com/foundry-rs/forge-std, tag: None) +... Installed forge-std[..] -"#]]); +"#]], + ); let faulty_toml = r"[default] src = 'src' @@ -763,9 +1943,11 @@ This notation for profiles has been deprecated and may result in the profile not Please use [profile.default] instead or run `forge config --fix`. "#]]); -}); +} -forgetest_init!(can_skip_remappings_auto_detection, |prj, cmd| { +#[forgetest_init] +fn can_skip_remappings_auto_detection(prj: _, cmd: _) { + prj.initialize_default_contracts(); // explicitly set remapping and libraries prj.update_config(|config| { config.remappings = vec![Remapping::from_str("remapping/=lib/remapping/").unwrap().into()]; @@ -777,18 +1959,21 @@ forgetest_init!(can_skip_remappings_auto_detection, |prj, cmd| { // only loads remappings from foundry.toml assert_eq!(config.remappings.len(), 1); assert_eq!("remapping/=lib/remapping/", config.remappings[0].to_string()); -}); +} -forgetest_init!(can_parse_default_fs_permissions, |_prj, cmd| { +#[forgetest_init] +fn can_parse_default_fs_permissions(cmd: _) { let config = cmd.config(); assert_eq!(config.fs_permissions.len(), 1); let permissions = config.fs_permissions.joined(Path::new("test")); let out_permission = permissions.find_permission(Path::new("test/out")).unwrap(); assert_eq!(FsAccessPermission::Read, out_permission); -}); +} -forgetest_init!(can_parse_custom_fs_permissions, |prj, cmd| { +#[forgetest_init] +fn can_parse_custom_fs_permissions(prj: _, cmd: _) { + prj.initialize_default_contracts(); // explicitly set fs permissions prj.update_config(|config| { config.fs_permissions = FsPermissions::new(vec![ @@ -819,10 +2004,12 @@ forgetest_init!(can_parse_custom_fs_permissions, |prj, cmd| { let permission = config.fs_permissions.find_permission(Path::new("./bogus")).unwrap_or_default(); assert_eq!(permission, FsAccessPermission::None); -}); +} #[cfg(not(target_os = "windows"))] -forgetest_init!(can_resolve_symlink_fs_permissions, |prj, cmd| { +#[forgetest_init] +fn can_resolve_symlink_fs_permissions(prj: _, cmd: _) { + prj.initialize_default_contracts(); // write config in packages/files/config.json let config_path = prj.root().join("packages").join("files"); fs::create_dir_all(&config_path).unwrap(); @@ -849,10 +2036,33 @@ forgetest_init!(can_resolve_symlink_fs_permissions, |prj, cmd| { // read permission to file should be granted through symlink let permission = fs_permissions.find_permission(&config_path.join("config.json")).unwrap(); assert_eq!(permission, FsAccessPermission::Read); -}); + + std::os::unix::fs::symlink("links/config.json", prj.root().join("config-link")).unwrap(); + prj.add_test( + "ReadLink.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract ReadLinkTest is Test { + function testReadLink() public view { + assertEq(vm.readLink("config-link"), "links/config.json"); + assertEq(vm.readLink(string.concat(vm.projectRoot(), "/config-link")), "links/config.json"); + } +} +"#, + ); + cmd.args(["test", "--match-contract", "ReadLinkTest"]).assert_success().stdout_eq(str![[r#" +... +Ran 1 test for test/ReadLink.t.sol:ReadLinkTest +[PASS] testReadLink() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} // tests if evm version is normalized for config output -forgetest!(normalize_config_evm_version, |_prj, cmd| { +#[forgetest] +fn normalize_config_evm_version(cmd: _) { let output = cmd .args(["config", "--use", "0.8.0", "--json"]) .assert_success() @@ -897,11 +2107,31 @@ forgetest!(normalize_config_evm_version, |_prj, cmd| { .stdout_lossy(); let config: Config = serde_json::from_str(&output).unwrap(); assert_eq!(config.evm_version, EvmVersion::Cancun); -}); + + let output = cmd + .forge_fuse() + .args(["config", "--use", "0.8.35", "--evm-version", "amsterdam", "--json"]) + .assert_success() + .get_output() + .stdout_lossy(); + let config: Config = serde_json::from_str(&output).unwrap(); + assert_eq!(config.evm_version, EvmVersion::Osaka); + + let output = cmd + .forge_fuse() + .args(["config", "--use", "0.8.36", "--evm-version", "amsterdam", "--json"]) + .assert_success() + .get_output() + .stdout_lossy(); + let config: Config = serde_json::from_str(&output).unwrap(); + assert_eq!(config.evm_version, EvmVersion::Amsterdam); +} // Tests that root paths are properly resolved even if submodule specifies remappings for them. // See -forgetest_init!(test_submodule_root_path_remappings, |prj, cmd| { +#[forgetest_init] +fn test_submodule_root_path_remappings(prj: _, cmd: _) { + prj.initialize_default_contracts(); prj.add_script( "BaseScript.sol", r#" @@ -910,8 +2140,7 @@ import "forge-std/Script.sol"; contract BaseScript is Script { } "#, - ) - .unwrap(); + ); prj.add_script( "MyScript.sol", r#" @@ -920,219 +2149,55 @@ import "script/BaseScript.sol"; contract MyScript is BaseScript { } "#, - ) - .unwrap(); - - let nested = prj.paths().libraries[0].join("another-dep"); - pretty_err(&nested, fs::create_dir_all(&nested)); - let mut lib_config = Config::load_with_root(&nested).unwrap(); - lib_config.remappings = vec![ - Remapping::from_str("test/=test/").unwrap().into(), - Remapping::from_str("script/=script/").unwrap().into(), - ]; - let lib_toml_file = nested.join("foundry.toml"); - pretty_err(&lib_toml_file, fs::write(&lib_toml_file, lib_config.to_string_pretty().unwrap())); - cmd.forge_fuse().args(["build"]).assert_success(); -}); - -// Tests that project remappings use config paths. -// For `src=src/contracts` config, remapping should be `src/contracts/ = src/contracts/`. -// For `src=src` config, remapping should be `src/ = src/`. -// -forgetest_init!(test_project_remappings, |prj, cmd| { - prj.update_config(|config| { - config.src = "src/contracts".into(); - config.remappings = vec![Remapping::from_str("contracts/=src/contracts/").unwrap().into()]; - }); - - // Add Counter.sol in `src/contracts` project dir. - let src_dir = &prj.root().join("src/contracts"); - pretty_err(src_dir, fs::create_dir_all(src_dir)); - pretty_err( - src_dir.join("Counter.sol"), - fs::write(src_dir.join("Counter.sol"), "contract Counter{}"), - ); - prj.add_test( - "CounterTest.sol", - r#" -import "contracts/Counter.sol"; - -contract CounterTest { -} - "#, - ) - .unwrap(); - cmd.forge_fuse().args(["build"]).assert_success(); -}); - -#[cfg(not(feature = "isolate-by-default"))] -forgetest_init!(test_default_config, |prj, cmd| { - prj.write_config(Config::default()); - cmd.forge_fuse().args(["config"]).assert_success().stdout_eq(str![[r#" -[profile.default] -src = "src" -test = "test" -script = "script" -out = "out" -libs = ["lib"] -remappings = ["forge-std/=lib/forge-std/src/"] -auto_detect_remappings = true -libraries = [] -cache = true -dynamic_test_linking = false -cache_path = "cache" -snapshots = "snapshots" -gas_snapshot_check = false -gas_snapshot_emit = true -broadcast = "broadcast" -allow_paths = [] -include_paths = [] -skip = [] -force = false -evm_version = "prague" -gas_reports = ["*"] -gas_reports_ignore = [] -gas_reports_include_tests = false -auto_detect_solc = true -offline = false -optimizer = false -optimizer_runs = 200 -verbosity = 0 -eth_rpc_accept_invalid_certs = false -ignored_error_codes = [ - "license", - "code-size", - "init-code-size", - "transient-storage", -] -ignored_warnings_from = [] -deny_warnings = false -test_failures_file = "cache/test-failures" -show_progress = false -ffi = false -allow_internal_expect_revert = false -always_use_create_2_factory = false -prompt_timeout = 120 -sender = "0x1804c8ab1f12e6bbf3894d4083f33e07309d1f38" -tx_origin = "0x1804c8ab1f12e6bbf3894d4083f33e07309d1f38" -initial_balance = "0xffffffffffffffffffffffff" -block_number = 1 -gas_limit = 1073741824 -block_base_fee_per_gas = 0 -block_coinbase = "0x0000000000000000000000000000000000000000" -block_timestamp = 1 -block_difficulty = 0 -block_prevrandao = "0x0000000000000000000000000000000000000000000000000000000000000000" -memory_limit = 134217728 -extra_output = [] -extra_output_files = [] -names = false -sizes = false -via_ir = false -ast = false -no_storage_caching = false -no_rpc_rate_limit = false -use_literal_content = false -bytecode_hash = "ipfs" -cbor_metadata = true -sparse_mode = false -build_info = false -isolate = false -disable_block_gas_limit = false -unchecked_cheatcode_artifacts = false -create2_library_salt = "0x0000000000000000000000000000000000000000000000000000000000000000" -create2_deployer = "0x4e59b44847b379578588920ca78fbf26c0b4956c" -assertions_revert = true -legacy_assertions = false -odyssey = false -transaction_timeout = 120 -additional_compiler_profiles = [] -compilation_restrictions = [] -script_execution_protection = true - -[profile.default.rpc_storage_caching] -chains = "all" -endpoints = "all" - -[[profile.default.fs_permissions]] -access = "read" -path = "out" - -[fmt] -line_length = 120 -tab_width = 4 -style = "space" -bracket_spacing = false -int_types = "long" -multiline_func_header = "attributes_first" -quote_style = "double" -number_underscore = "preserve" -hex_underscore = "remove" -single_line_statement_blocks = "preserve" -override_spacing = false -wrap_comments = false -ignore = [] -contract_new_lines = false -sort_imports = false - -[lint] -severity = [] -exclude_lints = [] -ignore = [] -lint_on_build = true - -[doc] -out = "docs" -title = "" -book = "book.toml" -homepage = "README.md" -ignore = [] - -[fuzz] -runs = 256 -fail_on_revert = true -max_test_rejects = 65536 -dictionary_weight = 40 -include_storage = true -include_push_bytes = true -max_fuzz_dictionary_addresses = 15728640 -max_fuzz_dictionary_values = 6553600 -gas_report_samples = 256 -failure_persist_dir = "cache/fuzz" -failure_persist_file = "failures" -show_logs = false - -[invariant] -runs = 256 -depth = 500 -fail_on_revert = false -call_override = false -dictionary_weight = 80 -include_storage = true -include_push_bytes = true -max_fuzz_dictionary_addresses = 15728640 -max_fuzz_dictionary_values = 6553600 -shrink_run_limit = 5000 -max_assume_rejects = 65536 -gas_report_samples = 256 -corpus_gzip = true -corpus_min_mutations = 5 -corpus_min_size = 0 -failure_persist_dir = "cache/invariant" -show_metrics = true -show_solidity = false + ); -[labels] + let nested = prj.paths().libraries[0].join("another-dep"); + pretty_err(&nested, fs::create_dir_all(&nested)); + let mut lib_config = Config::load_with_root(&nested).unwrap(); + lib_config.remappings = vec![ + Remapping::from_str("test/=test/").unwrap().into(), + Remapping::from_str("script/=script/").unwrap().into(), + ]; + let lib_toml_file = nested.join("foundry.toml"); + pretty_err(&lib_toml_file, fs::write(&lib_toml_file, lib_config.to_string_pretty().unwrap())); + cmd.forge_fuse().args(["build"]).assert_success(); +} -[vyper] +// Tests that project remappings use config paths. +// For `src=src/contracts` config, remapping should be `src/contracts/ = src/contracts/`. +// For `src=src` config, remapping should be `src/ = src/`. +// +#[forgetest_init] +fn test_project_remappings(prj: _, cmd: _) { + prj.initialize_default_contracts(); + prj.update_config(|config| { + config.src = "src/contracts".into(); + config.remappings = vec![Remapping::from_str("contracts/=src/contracts/").unwrap().into()]; + }); -[bind_json] -out = "utils/JsonBindings.sol" -include = [] -exclude = [] + // Add Counter.sol in `src/contracts` project dir. + let src_dir = &prj.root().join("src/contracts"); + pretty_err(src_dir, fs::create_dir_all(src_dir)); + pretty_err( + src_dir.join("Counter.sol"), + fs::write(src_dir.join("Counter.sol"), "contract Counter{}"), + ); + prj.add_test( + "CounterTest.sol", + r#" +import "contracts/Counter.sol"; +contract CounterTest { +} + "#, + ); + cmd.forge_fuse().args(["build"]).assert_success(); +} -"#]]); +#[forgetest_init] +fn test_default_config(prj: _, cmd: _) { + prj.write_config(Config::default()); + cmd.forge_fuse().args(["config"]).assert_success().stdout_eq(DEFAULT_CONFIG); cmd.forge_fuse().args(["config", "--json"]).assert_success().stdout_eq(str![[r#" { @@ -1149,8 +2214,8 @@ exclude = [] "auto_detect_remappings": true, "libraries": [], "cache": true, - "dynamic_test_linking": false, "cache_path": "cache", + "dynamic_test_linking": true, "snapshots": "snapshots", "gas_snapshot_check": false, "gas_snapshot_emit": true, @@ -1159,7 +2224,8 @@ exclude = [] "include_paths": [], "skip": [], "force": false, - "evm_version": "prague", + "evm_version": "osaka", + "hardfork": null, "gas_reports": [ "*" ], @@ -1175,19 +2241,23 @@ exclude = [] "verbosity": 0, "eth_rpc_url": null, "eth_rpc_accept_invalid_certs": false, + "eth_rpc_no_proxy": false, "eth_rpc_jwt": null, "eth_rpc_timeout": null, "eth_rpc_headers": null, + "eth_rpc_curl": false, "etherscan_api_key": null, - "etherscan_api_version": null, "ignored_error_codes": [ "license", "code-size", "init-code-size", - "transient-storage" + "transient-storage", + "transfer-deprecated", + "natspec-memory-safe-assembly-deprecated" ], + "ignored_error_codes_from": [], "ignored_warnings_from": [], - "deny_warnings": false, + "deny": "never", "match_test": null, "no_match_test": null, "match_contract": null, @@ -1196,10 +2266,13 @@ exclude = [] "no_match_path": null, "no_match_coverage": null, "test_failures_file": "cache/test-failures", + "mutation_dir": "cache/mutation", "threads": null, "show_progress": false, "fuzz": { "runs": 256, + "run": null, + "worker": null, "fail_on_revert": true, "max_test_rejects": 65536, "seed": null, @@ -1207,44 +2280,138 @@ exclude = [] "include_storage": true, "include_push_bytes": true, "max_fuzz_dictionary_addresses": 15728640, - "max_fuzz_dictionary_values": 6553600, + "max_fuzz_dictionary_values": 9830400, + "max_fuzz_dictionary_literals": 6553600, "gas_report_samples": 256, + "corpus_dir": null, + "frontier_dir": null, + "frontier_limit": 256, + "corpus_gzip": true, + "corpus_min_mutations": 5, + "corpus_min_size": 0, + "show_edge_coverage": false, + "evm_edge_coverage_collision_free": true, + "evm_edge_coverage_include_call_depth": false, + "sancov_edges": false, + "sancov_trace_cmp": false, + "corpus_random_sequence_weight": 10, + "payable_value_weight": 0, + "mutation_weight_splice": 1, + "mutation_weight_repeat": 1, + "mutation_weight_interleave": 1, + "mutation_weight_prefix": 1, + "mutation_weight_suffix": 1, + "mutation_weight_abi": 1, + "mutation_weight_cmp": 1, "failure_persist_dir": "cache/fuzz", - "failure_persist_file": "failures", "show_logs": false, "timeout": null }, "invariant": { "runs": 256, "depth": 500, + "min_depth": 1, + "depth_mode": "fixed", + "workers": "auto", "fail_on_revert": false, "call_override": false, "dictionary_weight": 80, "include_storage": true, "include_push_bytes": true, "max_fuzz_dictionary_addresses": 15728640, - "max_fuzz_dictionary_values": 6553600, + "max_fuzz_dictionary_values": 9830400, + "max_fuzz_dictionary_literals": 6553600, "shrink_run_limit": 5000, "max_assume_rejects": 65536, "gas_report_samples": 256, "corpus_dir": null, + "frontier_dir": null, + "frontier_limit": 256, "corpus_gzip": true, "corpus_min_mutations": 5, "corpus_min_size": 0, + "show_edge_coverage": false, + "evm_edge_coverage_collision_free": true, + "evm_edge_coverage_include_call_depth": false, + "sancov_edges": false, + "sancov_trace_cmp": false, + "corpus_random_sequence_weight": 10, + "payable_value_weight": 15, + "mutation_weight_splice": 1, + "mutation_weight_repeat": 1, + "mutation_weight_interleave": 1, + "mutation_weight_prefix": 1, + "mutation_weight_suffix": 1, + "mutation_weight_abi": 1, + "mutation_weight_cmp": 1, "failure_persist_dir": "cache/invariant", "show_metrics": true, "timeout": null, - "show_solidity": false + "show_solidity": false, + "max_time_delay": null, + "max_block_delay": null, + "check_interval": 1 + }, + "symbolic": { + "enabled": false, + "seed_corpus": false, + "use_fuzz_corpus": false, + "corpus_seed_limit": 32, + "use_fuzz_frontiers": false, + "check_invariant_frontiers": false, + "frontier_limit": 256, + "solver": "z3", + "timeout": 30, + "max_depth": 10000, + "max_paths": 1024, + "invariant_depth": 10, + "exploration_order": "bfs", + "max_solver_queries": 10000, + "default_dynamic_length": 2, + "max_dynamic_length": 256, + "array_lengths": [], + "max_calldata_bytes": 4096, + "symbolic_call_targets": false, + "dump_smt": false, + "storage_layout": "solidity" + }, + "coverage": { + "report": [ + "summary" + ], + "lcov_version": "1.0.0", + "ir_minimum": false, + "report_file": null, + "include_libs": false, + "exclude_tests": false, + "skip_files": [] + }, + "mutation": { + "include_operators": [], + "exclude_operators": [], + "timeout": null, + "optimizer_runs": null, + "via_ir": null + }, + "tracing": { + "verbosity": 0, + "disable_labels": false, + "compact_labels": false, + "decode_internal": false, + "external_identification_timeout": 5 }, "ffi": false, + "live_logs": false, "allow_internal_expect_revert": false, "always_use_create_2_factory": false, + "eip1559_fee_estimate": "market", "prompt_timeout": 120, "sender": "0x1804c8ab1f12e6bbf3894d4083f33e07309d1f38", "tx_origin": "0x1804c8ab1f12e6bbf3894d4083f33e07309d1f38", "initial_balance": "0xffffffffffffffffffffffff", "block_number": 1, "fork_block_number": null, + "fork_state_by_number": false, "chain_id": null, "gas_limit": 1073741824, "code_size_limit": null, @@ -1261,12 +2428,15 @@ exclude = [] "names": false, "sizes": false, "via_ir": false, + "via_ssa_cfg": false, + "experimental": false, "ast": false, "rpc_storage_caching": { "chains": "all", "endpoints": "all" }, "no_storage_caching": false, + "no_fork_bal": false, "no_rpc_rate_limit": false, "use_literal_content": false, "bytecode_hash": "ipfs", @@ -1288,15 +2458,39 @@ exclude = [] "single_line_statement_blocks": "preserve", "override_spacing": false, "wrap_comments": false, + "docs_style": "preserve", "ignore": [], "contract_new_lines": false, - "sort_imports": false + "sort_imports": false, + "namespace_import_style": "prefer_plain", + "pow_no_space": false, + "prefer_compact": "all", + "single_line_imports": false }, "lint": { - "severity": [], + "severity": [ + "high", + "medium", + "low" + ], "exclude_lints": [], "ignore": [], - "lint_on_build": true + "lint_on_build": true, + "lint_specific": { + "mixed_case_exceptions": [ + "ERC", + "URI", + "ID", + "URL", + "API", + "JSON", + "XML", + "HTML", + "HTTP", + "HTTPS" + ], + "multi_contract_file_exceptions": [] + } }, "doc": { "out": "docs", @@ -1316,10 +2510,11 @@ exclude = [] "path": "out" } ], - "isolate": false, + "isolate": true, "disable_block_gas_limit": false, - "labels": {}, + "enable_tx_gas_limit": false, "unchecked_cheatcode_artifacts": false, + "decode_external_storage": false, "create2_library_salt": "0x0000000000000000000000000000000000000000000000000000000000000000", "create2_deployer": "0x4e59b44847b379578588920ca78fbf26c0b4956c", "vyper": {}, @@ -1327,7 +2522,9 @@ exclude = [] "soldeer": null, "assertions_revert": true, "legacy_assertions": false, - "odyssey": false, + "network": null, + "celo": false, + "bypass_prevrandao": false, "transaction_timeout": 120, "additional_compiler_profiles": [], "compilation_restrictions": [], @@ -1335,9 +2532,11 @@ exclude = [] } "#]]); -}); +} -forgetest_init!(test_optimizer_config, |prj, cmd| { +#[forgetest_init] +fn test_optimizer_config(prj: _, cmd: _) { + prj.initialize_default_contracts(); // Default settings: optimizer disabled, optimizer runs 200. cmd.forge_fuse().args(["config"]).assert_success().stdout_eq(str![[r#" ... @@ -1408,9 +2607,11 @@ optimizer_runs = 0 ... "#]]); -}); +} -forgetest_init!(test_gas_snapshot_check_config, |prj, cmd| { +#[forgetest_init] +fn test_gas_snapshot_check_config(prj: _, cmd: _) { + prj.initialize_default_contracts(); // Default settings: gas_snapshot_check disabled. cmd.forge_fuse().args(["config"]).assert_success().stdout_eq(str![[r#" ... @@ -1434,8 +2635,7 @@ contract Flare { } } "#, - ) - .unwrap(); + ); let test_contract = |n: u32| { format!( @@ -1468,7 +2668,7 @@ contract GasSnapshotCheckTest is DSTest {{ }; // Assert that gas_snapshot_check is disabled by default. - prj.add_source("GasSnapshotCheckTest.sol", &test_contract(1)).unwrap(); + prj.add_source("GasSnapshotCheckTest.sol", &test_contract(1)); cmd.forge_fuse().args(["test"]).assert_success().stdout_eq(str![[r#" ... Ran 1 test for src/GasSnapshotCheckTest.sol:GasSnapshotCheckTest @@ -1487,7 +2687,7 @@ gas_snapshot_check = true "#]]); // Replace the test contract with a new one that will fail the gas snapshot check. - prj.add_source("GasSnapshotCheckTest.sol", &test_contract(2)).unwrap(); + prj.add_source("GasSnapshotCheckTest.sol", &test_contract(2)); cmd.forge_fuse().args(["test"]).assert_failure().stderr_eq(str![[r#" ... [GasSnapshotCheckTest] Failed to match snapshots: @@ -1497,6 +2697,23 @@ Error: Snapshots differ from previous run ... "#]]); + let snapshot_path = prj.root().join("snapshots/GasSnapshotCheckTest.json"); + let previous_snapshot = fs::read(&snapshot_path).unwrap(); + for format in ["--json", "--junit"] { + cmd.forge_fuse() + .args(["test", format, "--gas-snapshot-check=true"]) + .assert_failure() + .stderr_eq(str![[r#" +... +[GasSnapshotCheckTest] Failed to match snapshots: +- [testAssertGasExternal] [..] → [..] + +Error: Snapshots differ from previous run +... +"#]]); + assert_eq!(fs::read(&snapshot_path).unwrap(), previous_snapshot); + } + // Disable gas_snapshot_check, assert that running the test will pass. prj.update_config(|config| config.gas_snapshot_check = false); cmd.forge_fuse().args(["test"]).assert_success().stdout_eq(str![[r#" @@ -1519,7 +2736,7 @@ Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] "#]]); // Replace the test contract with a new one that will fail the gas_snapshot_check. - prj.add_source("GasSnapshotCheckTest.sol", &test_contract(3)).unwrap(); + prj.add_source("GasSnapshotCheckTest.sol", &test_contract(3)); cmd.forge_fuse().args(["test"]).assert_failure().stderr_eq(str![[r#" ... [GasSnapshotCheckTest] Failed to match snapshots: @@ -1544,7 +2761,7 @@ Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] // Enable using `FORGE_SNAPSHOT_CHECK` environment variable. // Assert that this will override the config file value. prj.update_config(|config| config.gas_snapshot_check = false); - prj.add_source("GasSnapshotCheckTest.sol", &test_contract(4)).unwrap(); + prj.add_source("GasSnapshotCheckTest.sol", &test_contract(4)); cmd.forge_fuse(); cmd.env("FORGE_SNAPSHOT_CHECK", "true"); cmd.args(["test"]).assert_failure().stderr_eq(str![[r#" @@ -1580,9 +2797,11 @@ Ran 1 test for src/GasSnapshotCheckTest.sol:GasSnapshotCheckTest Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] ... "#]]); -}); +} -forgetest_init!(test_gas_snapshot_emit_config, |prj, cmd| { +#[forgetest_init] +fn test_gas_snapshot_emit_config(prj: _, cmd: _) { + prj.initialize_default_contracts(); // Default settings: gas_snapshot_emit enabled. cmd.forge_fuse().args(["config"]).assert_success().stdout_eq(str![[r#" ... @@ -1612,17 +2831,25 @@ contract GasSnapshotEmitTest is DSTest { } } "#, - ) - .unwrap(); + ); // Assert that gas_snapshot_emit is enabled by default. cmd.forge_fuse().args(["test"]).assert_success(); // Assert that snapshots were emitted to disk. assert!(prj.root().join("snapshots/GasSnapshotEmitTest.json").exists()); + let snapshot_path = prj.root().join("snapshots/GasSnapshotEmitTest.json"); + let expected_snapshot = fs::read(&snapshot_path).unwrap(); + // Remove the snapshot file. fs::remove_file(prj.root().join("snapshots/GasSnapshotEmitTest.json")).unwrap(); + for format in ["--json", "--junit"] { + cmd.forge_fuse().args(["test", format, "--gas-snapshot-emit=true"]).assert_success(); + assert_eq!(fs::read(&snapshot_path).unwrap(), expected_snapshot); + fs::remove_file(&snapshot_path).unwrap(); + } + // Test that `--gas-snapshot-emit=false` flag can be used to disable writing snapshots. cmd.forge_fuse().args(["test", "--gas-snapshot-emit=false"]).assert_success(); // Assert that snapshots were not emitted to disk. @@ -1682,18 +2909,83 @@ contract GasSnapshotEmitTest is DSTest { // Assert that snapshots were not emitted to disk. assert!(!prj.root().join("snapshots/GasSnapshotEmitTest.json").exists()); -}); +} + +// Snapshot groups shared by several test contracts must be checked against the file on disk +// before any suite rewrites it. +#[forgetest] +fn test_gas_snapshot_check_shared_group(prj: _, cmd: _) { + prj.insert_ds_test(); + + let test_contract = |name: &str, value: u32| { + format!( + r#" +import "./test.sol"; + +interface Vm {{ + function snapshotValue(string calldata group, string calldata name, uint256 value) external; +}} + +contract {name} is DSTest {{ + Vm constant vm = Vm(HEVM_ADDRESS); + + function testSnapshotValue() public {{ + vm.snapshotValue("Shared", "{name}", {value}); + }} +}} +"# + ) + }; + + prj.add_source("AlphaTest.sol", &test_contract("AlphaTest", 1)); + prj.add_source("BetaTest.sol", &test_contract("BetaTest", 1)); + cmd.args(["test", "-j1"]).assert_success(); + + let snapshot_path = prj.root().join("snapshots/Shared.json"); + let previous_snapshot = fs::read(&snapshot_path).unwrap(); + + // With a single thread `AlphaTest` finishes first, so the changed value is only seen after + // another suite already contributed to the same group. + prj.add_source("BetaTest.sol", &test_contract("BetaTest", 2)); + cmd.forge_fuse() + .args(["test", "-j1", "--gas-snapshot-check=true"]) + .assert_failure() + .stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for src/AlphaTest.sol:AlphaTest +[PASS] testSnapshotValue() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for src/BetaTest.sol:BetaTest +[PASS] testSnapshotValue() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +"#]]) + .stderr_eq(str![[r#" + +[Shared] Failed to match snapshots: +- [BetaTest] 1 → 2 + +Error: Snapshots differ from previous run + +"#]]); + assert_eq!(fs::read(&snapshot_path).unwrap(), previous_snapshot); +} // Tests compilation restrictions enables optimizer if optimizer runs set to a value higher than 0. -forgetest_init!(test_additional_compiler_profiles, |prj, cmd| { +#[forgetest_init] +fn test_additional_compiler_profiles(prj: _, cmd: _) { + prj.initialize_default_contracts(); prj.add_source( "v1/Counter.sol", r#" contract Counter { } "#, - ) - .unwrap(); + ); prj.add_source( "v2/Counter.sol", @@ -1701,8 +2993,7 @@ contract Counter { contract Counter { } "#, - ) - .unwrap(); + ); prj.add_source( "v3/Counter.sol", @@ -1710,8 +3001,7 @@ contract Counter { contract Counter { } "#, - ) - .unwrap(); + ); // Additional profiles are defined with optimizer runs but without explicitly enabling // optimizer @@ -1733,7 +3023,7 @@ contract Counter { let v1_profile = SettingsOverrides { name: "v1".to_string(), via_ir: Some(true), - evm_version: Some(EvmVersion::Prague), + evm_version: Some(EvmVersion::Osaka), optimizer: None, optimizer_runs: Some(44444444), bytecode_hash: None, @@ -1819,19 +3109,19 @@ contract Counter { let (via_ir, evm_version, enabled, runs) = artifact_settings("Counter.sol/Counter.json"); assert_eq!(None, via_ir); - assert_eq!("\"prague\"", evm_version.unwrap().to_string()); + assert_eq!("\"osaka\"", evm_version.unwrap().to_string()); assert_eq!("false", enabled.unwrap().to_string()); assert_eq!("200", runs.unwrap().to_string()); let (via_ir, evm_version, enabled, runs) = artifact_settings("v1/Counter.sol/Counter.json"); assert_eq!("true", via_ir.unwrap().to_string()); - assert_eq!("\"prague\"", evm_version.unwrap().to_string()); + assert_eq!("\"osaka\"", evm_version.unwrap().to_string()); assert_eq!("true", enabled.unwrap().to_string()); assert_eq!("44444444", runs.unwrap().to_string()); let (via_ir, evm_version, enabled, runs) = artifact_settings("v2/Counter.sol/Counter.json"); assert_eq!("true", via_ir.unwrap().to_string()); - assert_eq!("\"prague\"", evm_version.unwrap().to_string()); + assert_eq!("\"osaka\"", evm_version.unwrap().to_string()); assert_eq!("true", enabled.unwrap().to_string()); assert_eq!("111", runs.unwrap().to_string()); @@ -1840,4 +3130,365 @@ contract Counter { assert_eq!("\"istanbul\"", evm_version.unwrap().to_string()); assert_eq!("true", enabled.unwrap().to_string()); assert_eq!("800", runs.unwrap().to_string()); -}); +} + +// +#[forgetest_init] +fn test_exclude_lints_config(prj: _, cmd: _) { + prj.initialize_default_contracts(); + prj.update_config(|config| { + config.lint.exclude_lints = vec![ + "asm-keccak256".to_string(), + "incorrect-shift".to_string(), + "divide-before-multiply".to_string(), + "mixed-case-variable".to_string(), + "mixed-case-function".to_string(), + "screaming-snake-case-const".to_string(), + "screaming-snake-case-immutable".to_string(), + "unwrapped-modifier-logic".to_string(), + ] + }); + cmd.args(["lint"]).assert_success().stdout_eq(""); +} + +// +#[forgetest_init] +fn test_fail_fast_config(prj: _, cmd: _) { + // Skip if we don't have at least 2 CPUs to run both tests in parallel. + if thread::available_parallelism().map_or(1, |n| n.get()) < 2 { + return; + } + + prj.update_config(|config| { + // Set large timeout for fuzzed tests so test campaign won't stop if fail fast not passed. + config.fuzz.timeout = Some(3600); + }); + prj.add_test( + "AnotherCounterTest.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract AnotherCounterTest is Test { + // This failure should stop all other tests. + function test_Failure() public pure { + require(false); + } + + function testFuzz_SetNumber(uint256 x) public { + } +} +"#, + ); + // The fuzz test can be skipped or interrupted after any number of runs. + cmd.args(["test", "--fail-fast"]).assert_failure().stdout_eq(str![[r#" +... +Ran [..] for test/AnotherCounterTest.sol:AnotherCounterTest +... +[FAIL: EvmError: Revert] test_Failure() ([GAS]) +Suite result: FAILED. 0 passed; 1 failed; [..] skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest] +fn config_deny_warnings_is_deprecated(prj: _, cmd: _) { + cmd.git_init(); + + let faulty_toml = DEFAULT_CONFIG.replace(r#"deny = "never""#, "deny_warnings = true"); + + fs::write(prj.root().join("foundry.toml"), faulty_toml).unwrap(); + cmd.forge_fuse().args(["config"]).assert_success().stderr_eq(str![[r#" +Warning: Key `deny_warnings` is being deprecated in favor of `deny = warnings`. It will be removed in future versions. + +"#]]); +} + +#[forgetest] +fn config_labels_is_deprecated(prj: _, cmd: _) { + cmd.git_init(); + + fs::write( + prj.root().join("foundry.toml"), + r#" +[labels] +0x0000000000000000000000000000000000000001 = "Alice" +"#, + ) + .unwrap(); + cmd.forge_fuse().args(["config"]).assert_success().stderr_eq(str![[r#" +Warning: Key `[labels]` is being deprecated in favor of `[tracing.labels]`. It will be removed in future versions. + +"#]]); +} + +// +#[forgetest] +fn no_warnings_on_external_sections(prj: _, cmd: _) { + cmd.git_init(); + + let toml = r"[profile.default] + src = 'src' + out = 'out' + + # Custom sections for other tools + [external.scopelint] + some_flag = 1 + + [external.forge_deploy] + another_setting = 123"; + + fs::write(prj.root().join("foundry.toml"), toml).unwrap(); + cmd.forge_fuse().args(["config"]).assert_success().stderr_eq(str![[r#" + +"#]]); +} + +// +#[forgetest] +fn config_warnings_on_unknown_keys(prj: _, cmd: _) { + cmd.git_init(); + + let faulty_toml = r"[profile.default] + src = 'src' + out = 'out' + solc_version = '0.8.18' + foo = 'unknown' + + [profile.another] + src = 'src' + out = 'out' + bar = 'another_unknown'"; + + fs::write(prj.root().join("foundry.toml"), faulty_toml).unwrap(); + cmd.forge_fuse().args(["config"]).assert_success().stderr_eq(str![[r#" +Warning: Found unknown `bar` config for profile `another` defined in foundry.toml. +Warning: Found unknown `foo` config for profile `default` defined in foundry.toml. + +"#]]); +} + +#[forgetest_init] +fn test_ignored_file_paths_normalization(prj: _, cmd: _) { + fn gen_contract(name: &str) -> String { + let fn_name = name.chars().next().unwrap().to_lowercase().to_string() + &name[1..]; + format!( + r#" +contract {name} {{ + function {fn_name}() public returns (bool) {{ return true; }} +}} +"# + ) + } + + // Update config to ignore warnings from specific files with various path formats + prj.update_config(|config| { + config.ignored_file_paths = vec![ + PathBuf::from("./test/IgnoredWithPrefix.sol"), // With "./" prefix + PathBuf::from("src/IgnoredNoPrefix.sol"), // Without "./" prefix + PathBuf::from("./src/nested/IgnoredNested.sol"), // Nested path with prefix + ]; + }); + + // Create contracts that will generate warnings + prj.add_source("IgnoredNoPrefix.sol", &gen_contract("IgnoredNoPrefix")); + prj.add_test("IgnoredWithPrefix.sol", &gen_contract("IgnoredWithPrefix")); + + fs::create_dir_all(prj.root().join("src/nested")).unwrap(); + fs::write(prj.root().join("src/nested/IgnoredNested.sol"), gen_contract("IgnoredNested")) + .unwrap(); + + prj.add_source("NotIgnored.sol", &gen_contract("NotIgnored")); + + // Verify the config loads paths as specified (before normalization) + let config = cmd.config(); + let raw_paths = vec![ + PathBuf::from("./test/IgnoredWithPrefix.sol"), + PathBuf::from("src/IgnoredNoPrefix.sol"), + PathBuf::from("./src/nested/IgnoredNested.sol"), + ]; + assert_eq!(config.ignored_file_paths, raw_paths); + + // Build and verify compilation succeeds with just 1 warning: + cmd.forge_fuse().args(["build"]).assert_success().stdout_eq( + r#"[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful with warnings: +Warning (2018): Function state mutability can be restricted to pure + [FILE]:5:5: + | +5 | function notIgnored() public returns (bool) { return true; } + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +"#, + ); +} + +#[forgetest_init] +fn test_failures_file_normalization(prj: _, cmd: _) { + // Update config with custom path containing "./" prefix + prj.update_config(|config| { + config.test_failures_file = PathBuf::from("./my-custom-failures"); + }); + + prj.add_test( + "MixedTests.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract MixedTests is Test { + function testPass() public pure { + require(1 == 1); + } + + function testFail() public pure { + require(1 == 2, "testFail failed"); + } +} +"#, + ); + + // Run test and verify test_failures_file is created at the correct location + cmd.args(["test"]).assert_failure(); + let failures_file = prj.root().join("my-custom-failures"); + assert!(failures_file.exists()); + assert!(fs::read_to_string(&failures_file).unwrap().contains("testFail")); + + // Verify --rerun works from subdirectory + let rerun_output = cmd + .forge_fuse() + .current_dir(prj.root().join("src")) + .args(["test", "--rerun"]) + .assert_failure() + .get_output() + .stdout_lossy(); + assert!(rerun_output.contains("Ran 1 test")); + assert!(rerun_output.contains("testFail()")); + assert!(!rerun_output.contains("[PASS] testPass()")); +} + +// Loading config must never execute a local `solc`, whether it was overridden by `--use`, the +// command doesn't compile, or it comes from a nested dependency config. +#[cfg(unix)] +#[forgetest] +fn config_loading_does_not_run_local_solc(prj: _, cmd: _) { + let fake_solc = prj.root().join("fake-solc"); + let marker = prj.root().join("fake-solc.invoked"); + fs::write( + &fake_solc, + r#"#!/bin/sh +echo "$@" >> "$0.invoked" +if [ "$1" = "--version" ]; then + echo "solc, the solidity compiler commandline interface" + echo "Version: 0.8.13+commit.abaa5c0e" + exit 0 +fi +exit 1 +"#, + ) + .unwrap(); + fs::set_permissions(&fake_solc, fs::Permissions::from_mode(0o755)).unwrap(); + prj.add_source("Counter.sol", "contract Counter {}"); + let assert_not_invoked = + || assert!(!marker.exists(), "{}", fs::read_to_string(&marker).unwrap()); + let write_config = |solc: &str| { + let config = format!( + "[profile.default]\nsrc = \"src\"\nout = \"out\"\nlibs = [\"lib\"]\nsolc = \"{solc}\"\n" + ); + fs::write(prj.root().join(Config::FILE_NAME), config).unwrap(); + }; + + // `evm_version` is unset, so only the `--use` compiler may determine it. + write_config(fake_solc.to_str().unwrap()); + cmd.args(["build", "--use", SOLC_VERSION]).assert_success(); + assert_not_invoked(); + let artifact: Value = + serde_json::from_slice(&fs::read(prj.root().join("out/Counter.sol/Counter.json")).unwrap()) + .unwrap(); + let default_evm_version = Config::default().evm_version; + assert_eq!(artifact["metadata"]["settings"]["evmVersion"], default_evm_version.to_string()); + + let output = cmd.forge_fuse().args(["config", "--json"]).assert_success(); + let config: Config = serde_json::from_slice(&output.get_output().stdout).unwrap(); + assert_eq!(config.evm_version, default_evm_version); + assert_not_invoked(); + + cmd.forge_fuse().arg("fmt").assert_success(); + assert_not_invoked(); + + // A dependency's `solc` is never used to compile the parent project. + let dep = prj.root().join("lib/dep"); + fs::create_dir_all(dep.join("src")).unwrap(); + fs::write(dep.join("src/Dep.sol"), "contract Dep {}").unwrap(); + fs::write( + dep.join(Config::FILE_NAME), + format!("[profile.default]\nsolc = \"{}\"\n", fake_solc.display()), + ) + .unwrap(); + write_config(SOLC_VERSION); + cmd.forge_fuse().args(["build", "--force"]).assert_success(); + assert_not_invoked(); +} + +// `--config-path` selects the project root, so combining it with `--root` is rejected. +#[forgetest] +fn config_path_conflicts_with_root(prj: _, cmd: _) { + let config_path = prj.root().join(Config::FILE_NAME); + cmd.arg("config") + .arg("--root") + .arg(prj.root()) + .arg("--config-path") + .arg(config_path) + .assert_failure() + .stderr_eq(str![[r#" +error: the argument '--root ' cannot be used with '--config-path ' +... +"#]]); +} + +#[forgetest] +fn contextual_remapping_bytecode_is_independent_of_project_root(prj: _, cmd: _) { + let mut artifacts = Vec::new(); + for name in ["first", "second"] { + let project = prj.root().join(name); + fs::create_dir_all(project.join("src")).unwrap(); + fs::create_dir_all(project.join("lib/dependency")).unwrap(); + fs::write( + project.join(Config::FILE_NAME), + r#" +[profile.default] +auto_detect_remappings = false +remappings = ["lib/dependency:unused/=lib/dependency/"] +"#, + ) + .unwrap(); + fs::write( + project.join("src/Counter.sol"), + "// SPDX-License-Identifier: MIT\npragma solidity >=0.8.0;\ncontract Counter { uint256 public number; }\n", + ) + .unwrap(); + cmd.forge_fuse() + .current_dir(&project) + .args(["build", "--no-lint", "--use", SOLC_VERSION]) + .assert_success(); + let artifact: Value = serde_json::from_slice( + &fs::read(project.join("out/Counter.sol/Counter.json")).unwrap(), + ) + .unwrap(); + assert_eq!( + artifact["metadata"]["settings"]["remappings"], + serde_json::json!(["lib/dependency:unused/=lib/dependency/"]) + ); + artifacts.push(artifact); + } + for field in ["bytecode", "deployedBytecode"] { + let bytecodes = artifacts + .iter() + .map(|artifact| { + let bytecode = artifact[field]["object"].as_str().expect("missing bytecode object"); + assert!(!bytecode.is_empty() && bytecode != "0x", "empty {field} object"); + bytecode + }) + .collect::>(); + assert_eq!(bytecodes[0], bytecodes[1], "{field} differs between project roots"); + } +} diff --git a/crates/forge/tests/cli/context.rs b/crates/forge/tests/cli/context.rs index 34a7598a37cd9..8724a745e5daf 100644 --- a/crates/forge/tests/cli/context.rs +++ b/crates/forge/tests/cli/context.rs @@ -1,4 +1,6 @@ //! Contains tests for checking forge execution context cheatcodes +use std::process::Command; + const FORGE_TEST_CONTEXT_CONTRACT: &str = r#" import "./test.sol"; interface Vm { @@ -44,38 +46,75 @@ contract ForgeContextTest is DSTest { "#; // tests that context properly set for `forge test` command -forgetest!(can_set_forge_test_standard_context, |prj, cmd| { +#[forgetest] +fn can_set_forge_test_standard_context(prj: _, cmd: _) { prj.insert_ds_test(); - prj.add_source("ForgeContextTest.t.sol", FORGE_TEST_CONTEXT_CONTRACT).unwrap(); - cmd.args(["test", "--match-test", "testForgeTestContext"]).assert_success(); -}); + prj.add_source("ForgeContextTest.t.sol", FORGE_TEST_CONTEXT_CONTRACT); + cmd.args(["test", "--match-test", "testForgeTestContext"]).assert_success().stdout_eq(str![[ + r#" +... +Ran 1 test for src/ForgeContextTest.t.sol:ForgeContextTest +[PASS] testForgeTestContext() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"# + ]]); +} // tests that context properly set for `forge snapshot` command -forgetest!(can_set_forge_test_snapshot_context, |prj, cmd| { +#[forgetest] +fn can_set_forge_test_snapshot_context(prj: _, cmd: _) { prj.insert_ds_test(); - prj.add_source("ForgeContextTest.t.sol", FORGE_TEST_CONTEXT_CONTRACT).unwrap(); + prj.add_source("ForgeContextTest.t.sol", FORGE_TEST_CONTEXT_CONTRACT); cmd.args(["snapshot", "--match-test", "testForgeSnapshotContext"]).assert_success(); -}); +} // tests that context properly set for `forge coverage` command -forgetest!(can_set_forge_test_coverage_context, |prj, cmd| { +#[forgetest] +fn can_set_forge_test_coverage_context(prj: _, cmd: _) { prj.insert_ds_test(); - prj.add_source("ForgeContextTest.t.sol", FORGE_TEST_CONTEXT_CONTRACT).unwrap(); + prj.add_source("ForgeContextTest.t.sol", FORGE_TEST_CONTEXT_CONTRACT); cmd.args(["coverage", "--match-test", "testForgeCoverageContext"]).assert_success(); -}); +} + +#[forgetest] +fn symbolic_uses_actual_forge_context(prj: _, cmd: _) { + if !Command::new("z3").arg("--version").output().is_ok_and(|output| output.status.success()) { + return; + } + prj.insert_ds_test(); + prj.add_source( + "ForgeContextTest.t.sol", + &FORGE_TEST_CONTEXT_CONTRACT.replace("testForge", "checkForge"), + ); + prj.update_config(|config| config.symbolic.enabled = true); + for (command, test) in [ + ("test", "checkForgeTestContext"), + ("coverage", "checkForgeCoverageContext"), + ("snapshot", "checkForgeSnapshotContext"), + ] { + cmd.forge_fuse().args([command, "--match-test", test]).assert_success().stdout_eq( + foundry_test_utils::str![[r#" +... +[PASS] checkForge[..] (paths: [..]) +... +"#]], + ); + } +} // tests that context properly set for `forge script` command -forgetest!(can_set_forge_script_dry_run_context, |prj, cmd| { +#[forgetest] +fn can_set_forge_script_dry_run_context(prj: _, cmd: _) { prj.insert_ds_test(); - let script = - prj.add_source("ForgeScriptContextTest.s.sol", FORGE_TEST_CONTEXT_CONTRACT).unwrap(); + let script = prj.add_source("ForgeScriptContextTest.s.sol", FORGE_TEST_CONTEXT_CONTRACT); cmd.arg("script").arg(script).args(["--sig", "runDryRun()"]).assert_success(); -}); +} // tests that context properly set for `forge script --broadcast` command -forgetest!(can_set_forge_script_broadcast_context, |prj, cmd| { +#[forgetest] +fn can_set_forge_script_broadcast_context(prj: _, cmd: _) { prj.insert_ds_test(); - let script = - prj.add_source("ForgeScriptContextTest.s.sol", FORGE_TEST_CONTEXT_CONTRACT).unwrap(); + let script = prj.add_source("ForgeScriptContextTest.s.sol", FORGE_TEST_CONTEXT_CONTRACT); cmd.arg("script").arg(script).args(["--broadcast", "--sig", "runBroadcast()"]).assert_success(); -}); +} diff --git a/crates/forge/tests/cli/coverage.rs b/crates/forge/tests/cli/coverage.rs index d1e6c9f4751d2..937a087873769 100644 --- a/crates/forge/tests/cli/coverage.rs +++ b/crates/forge/tests/cli/coverage.rs @@ -1,10 +1,48 @@ +use clap::CommandFactory; +use forge::cmd::coverage::CoverageArgs; use foundry_common::fs::{self, files_with_ext}; +use foundry_compilers::cache::SOLIDITY_FILES_CACHE_FILENAME; +use foundry_config::{CompilationRestrictions, Config, SettingsOverrides}; use foundry_test_utils::{ TestCommand, TestProject, - snapbox::{Data, IntoData}, + snapbox::{Data, IntoData, cmd::Command}, + util::OutputExt, }; +use serde_json::Value; use std::path::Path; +#[track_caller] +fn assert_lcov(cmd: &mut TestCommand, data: impl IntoData) { + cmd.args(["--report=lcov", "--report-file"]).assert_file_with( + |cmd, path| { + cmd.arg(path).assert_success(); + assert_genhtml(cmd.cmd().get_current_dir().unwrap(), path); + }, + data, + ); +} + +/// Validate reports with a real consumer when requested (required on Linux CI). +#[track_caller] +fn assert_genhtml(root: &Path, report: &Path) { + let Some(genhtml) = std::env::var_os("FOUNDRY_TEST_GENHTML").filter(|s| !s.is_empty()) else { + return; + }; + let output = tempfile::tempdir().unwrap(); + // Do not inherit user settings that could disable consistency checks or ignore errors. + let config = tempfile::NamedTempFile::new().unwrap(); + Command::new(genhtml) + .current_dir(root) + .arg(report) + .arg("--config-file") + .arg(config.path()) + .args(["--branch-coverage", "--output-directory"]) + .arg(output.path()) + .assert() + .success(); + assert!(output.path().join("index.html").is_file()); +} + fn basic_base(prj: TestProject, mut cmd: TestCommand) { cmd.args(["coverage", "--report=lcov", "--report=summary"]).assert_success().stdout_eq(str![[ r#" @@ -22,15 +60,15 @@ Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] Ran 1 test suite [ELAPSED]: 2 tests passed, 0 failed, 0 skipped (2 total tests) Wrote LCOV report. -╭----------------------+---------------+---------------+---------------+---------------╮ -| File | % Lines | % Statements | % Branches | % Funcs | -+======================================================================================+ -| script/Counter.s.sol | 0.00% (0/5) | 0.00% (0/3) | 100.00% (0/0) | 0.00% (0/2) | -|----------------------+---------------+---------------+---------------+---------------| -| src/Counter.sol | 100.00% (4/4) | 100.00% (2/2) | 100.00% (0/0) | 100.00% (2/2) | -|----------------------+---------------+---------------+---------------+---------------| -| Total | 44.44% (4/9) | 40.00% (2/5) | 100.00% (0/0) | 50.00% (2/4) | -╰----------------------+---------------+---------------+---------------+---------------╯ +╭----------------------+---------------+---------------+------------+---------------╮ +| File | % Lines | % Statements | % Branches | % Funcs | ++===================================================================================+ +| script/Counter.s.sol | 0.00% (0/5) | 0.00% (0/3) | N/A (0/0) | 0.00% (0/2) | +|----------------------+---------------+---------------+------------+---------------| +| src/Counter.sol | 100.00% (4/4) | 100.00% (2/2) | N/A (0/0) | 100.00% (2/2) | +|----------------------+---------------+---------------+------------+---------------| +| Total | 44.44% (4/9) | 40.00% (2/5) | N/A (0/0) | 50.00% (2/4) | +╰----------------------+---------------+---------------+------------+---------------╯ "# ]]); @@ -166,11 +204,15 @@ end_of_record ); } -forgetest_init!(basic, |prj, cmd| { +#[forgetest_init] +fn basic(prj: _, cmd: _) { + prj.initialize_default_contracts(); basic_base(prj, cmd); -}); +} -forgetest_init!(basic_crlf, |prj, cmd| { +#[forgetest_init] +fn basic_crlf(prj: _, cmd: _) { + prj.initialize_default_contracts(); // Manually replace `\n` with `\r\n` in the source file. let make_crlf = |path: &Path| { fs::write(path, fs::read_to_string(path).unwrap().replace('\n', "\r\n")).unwrap() @@ -180,9 +222,10 @@ forgetest_init!(basic_crlf, |prj, cmd| { // Should have identical stdout and lcov output. basic_base(prj, cmd); -}); +} -forgetest!(setup, |prj, cmd| { +#[forgetest] +fn setup(prj: _, cmd: _) { prj.insert_ds_test(); prj.add_source( "AContract.sol", @@ -199,8 +242,7 @@ contract AContract { } } "#, - ) - .unwrap(); + ); prj.add_source( "AContractTest.sol", @@ -221,24 +263,76 @@ contract AContractTest is DSTest { } } "#, - ) - .unwrap(); + ); // Assert 100% coverage (init function coverage called in setUp is accounted). cmd.arg("coverage").assert_success().stdout_eq(str![[r#" ... -╭-------------------+---------------+---------------+---------------+---------------╮ -| File | % Lines | % Statements | % Branches | % Funcs | -+===================================================================================+ -| src/AContract.sol | 100.00% (4/4) | 100.00% (2/2) | 100.00% (0/0) | 100.00% (2/2) | -|-------------------+---------------+---------------+---------------+---------------| -| Total | 100.00% (4/4) | 100.00% (2/2) | 100.00% (0/0) | 100.00% (2/2) | -╰-------------------+---------------+---------------+---------------+---------------╯ +╭-------------------+---------------+---------------+------------+---------------╮ +| File | % Lines | % Statements | % Branches | % Funcs | ++================================================================================+ +| src/AContract.sol | 100.00% (4/4) | 100.00% (2/2) | N/A (0/0) | 100.00% (2/2) | +|-------------------+---------------+---------------+------------+---------------| +| Total | 100.00% (4/4) | 100.00% (2/2) | N/A (0/0) | 100.00% (2/2) | +╰-------------------+---------------+---------------+------------+---------------╯ + +"#]]); +} + +#[forgetest] +fn setup_md(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.add_source( + "AContract.sol", + r#" +contract AContract { + int public i; + + function init() public { + i = 0; + } + + function foo() public { + i = 1; + } +} + "#, + ); + + prj.add_source( + "AContractTest.sol", + r#" +import "./test.sol"; +import {AContract} from "./AContract.sol"; + +contract AContractTest is DSTest { + AContract a; + + function setUp() public { + a = new AContract(); + a.init(); + } + + function testFoo() public { + a.foo(); + } +} + "#, + ); + + // Assert 100% coverage (init function coverage called in setUp is accounted). + cmd.arg("coverage").args(["--md"]).assert_success().stdout_eq(str![[r#" +... +| File | % Lines | % Statements | % Branches | % Funcs | +|-------------------|---------------|---------------|------------|---------------| +| src/AContract.sol | 100.00% (4/4) | 100.00% (2/2) | N/A (0/0) | 100.00% (2/2) | +| Total | 100.00% (4/4) | 100.00% (2/2) | N/A (0/0) | 100.00% (2/2) | "#]]); -}); +} -forgetest!(no_match, |prj, cmd| { +#[forgetest] +fn no_match(prj: _, cmd: _) { prj.insert_ds_test(); prj.add_source( "AContract.sol", @@ -255,8 +349,7 @@ contract AContract { } } "#, - ) - .unwrap(); + ); prj.add_source( "AContractTest.sol", @@ -277,8 +370,7 @@ contract AContractTest is DSTest { } } "#, - ) - .unwrap(); + ); prj.add_source( "BContract.sol", @@ -295,8 +387,7 @@ contract BContract { } } "#, - ) - .unwrap(); + ); prj.add_source( "BContractTest.sol", @@ -317,26 +408,128 @@ contract BContractTest is DSTest { } } "#, - ) - .unwrap(); + ); // Assert AContract is not included in report. cmd.arg("coverage").arg("--no-match-coverage=AContract").assert_success().stdout_eq(str![[ r#" ... -╭-------------------+---------------+---------------+---------------+---------------╮ -| File | % Lines | % Statements | % Branches | % Funcs | -+===================================================================================+ -| src/BContract.sol | 100.00% (4/4) | 100.00% (2/2) | 100.00% (0/0) | 100.00% (2/2) | -|-------------------+---------------+---------------+---------------+---------------| -| Total | 100.00% (4/4) | 100.00% (2/2) | 100.00% (0/0) | 100.00% (2/2) | -╰-------------------+---------------+---------------+---------------+---------------╯ +╭-------------------+---------------+---------------+------------+---------------╮ +| File | % Lines | % Statements | % Branches | % Funcs | ++================================================================================+ +| src/BContract.sol | 100.00% (4/4) | 100.00% (2/2) | N/A (0/0) | 100.00% (2/2) | +|-------------------+---------------+---------------+------------+---------------| +| Total | 100.00% (4/4) | 100.00% (2/2) | N/A (0/0) | 100.00% (2/2) | +╰-------------------+---------------+---------------+------------+---------------╯ "# ]]); -}); +} + +// `[profile.default.coverage] skip_files` should exclude matching sources from +// the coverage report just like `--no-match-coverage`, but using glob patterns +// from `foundry.toml`. +#[forgetest] +fn skip_files_via_config(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.add_source( + "AContract.sol", + r#" +contract AContract { + int public i; + + function init() public { + i = 0; + } + + function foo() public { + i = 1; + } +} + "#, + ); + + prj.add_source( + "AContractTest.sol", + r#" +import "./test.sol"; +import {AContract} from "./AContract.sol"; + +contract AContractTest is DSTest { + AContract a; + + function setUp() public { + a = new AContract(); + a.init(); + } + + function testFoo() public { + a.foo(); + } +} + "#, + ); + + prj.add_source( + "BContract.sol", + r#" +contract BContract { + int public i; + + function init() public { + i = 0; + } + + function foo() public { + i = 1; + } +} + "#, + ); + + prj.add_source( + "BContractTest.sol", + r#" +import "./test.sol"; +import {BContract} from "./BContract.sol"; + +contract BContractTest is DSTest { + BContract a; + + function setUp() public { + a = new BContract(); + a.init(); + } + + function testFoo() public { + a.foo(); + } +} + "#, + ); + + prj.update_config(|config| { + config.coverage.skip_files = + vec!["src/AContract.sol".into(), "src/AContractTest.sol".into()]; + }); + + // Assert AContract.sol is excluded via the config glob; BContract.sol still + // appears in the report. + cmd.arg("coverage").assert_success().stdout_eq(str![[r#" +... +╭-------------------+---------------+---------------+------------+---------------╮ +| File | % Lines | % Statements | % Branches | % Funcs | ++================================================================================+ +| src/BContract.sol | 100.00% (4/4) | 100.00% (2/2) | N/A (0/0) | 100.00% (2/2) | +|-------------------+---------------+---------------+------------+---------------| +| Total | 100.00% (4/4) | 100.00% (2/2) | N/A (0/0) | 100.00% (2/2) | +╰-------------------+---------------+---------------+------------+---------------╯ + +"#]]); +} -forgetest!(assert, |prj, cmd| { +#[forgetest] +fn assert(prj: _, cmd: _) { prj.insert_ds_test(); prj.add_source( "AContract.sol", @@ -348,8 +541,7 @@ contract AContract { } } "#, - ) - .unwrap(); + ); prj.add_source( "AContractTest.sol", @@ -363,33 +555,32 @@ interface Vm { contract AContractTest is DSTest { Vm constant vm = Vm(HEVM_ADDRESS); + AContract a = new AContract(); + function testAssertBranch() external { - AContract a = new AContract(); bool result = a.checkA(10); assertTrue(result); } function testAssertRevertBranch() external { - AContract a = new AContract(); vm.expectRevert(); a.checkA(1); } } "#, - ) - .unwrap(); + ); // Assert 50% statement coverage for assert failure (assert not considered a branch). cmd.arg("coverage").args(["--mt", "testAssertRevertBranch"]).assert_success().stdout_eq(str![ [r#" ... -╭-------------------+--------------+--------------+---------------+---------------╮ -| File | % Lines | % Statements | % Branches | % Funcs | -+=================================================================================+ -| src/AContract.sol | 66.67% (2/3) | 50.00% (1/2) | 100.00% (0/0) | 100.00% (1/1) | -|-------------------+--------------+--------------+---------------+---------------| -| Total | 66.67% (2/3) | 50.00% (1/2) | 100.00% (0/0) | 100.00% (1/1) | -╰-------------------+--------------+--------------+---------------+---------------╯ +╭-------------------+--------------+--------------+------------+---------------╮ +| File | % Lines | % Statements | % Branches | % Funcs | ++==============================================================================+ +| src/AContract.sol | 66.67% (2/3) | 50.00% (1/2) | N/A (0/0) | 100.00% (1/1) | +|-------------------+--------------+--------------+------------+---------------| +| Total | 66.67% (2/3) | 50.00% (1/2) | N/A (0/0) | 100.00% (1/1) | +╰-------------------+--------------+--------------+------------+---------------╯ "#] ]); @@ -398,19 +589,20 @@ contract AContractTest is DSTest { cmd.forge_fuse().arg("coverage").args(["--mt", "testAssertBranch"]).assert_success().stdout_eq( str![[r#" ... -╭-------------------+---------------+---------------+---------------+---------------╮ -| File | % Lines | % Statements | % Branches | % Funcs | -+===================================================================================+ -| src/AContract.sol | 100.00% (3/3) | 100.00% (2/2) | 100.00% (0/0) | 100.00% (1/1) | -|-------------------+---------------+---------------+---------------+---------------| -| Total | 100.00% (3/3) | 100.00% (2/2) | 100.00% (0/0) | 100.00% (1/1) | -╰-------------------+---------------+---------------+---------------+---------------╯ +╭-------------------+---------------+---------------+------------+---------------╮ +| File | % Lines | % Statements | % Branches | % Funcs | ++================================================================================+ +| src/AContract.sol | 100.00% (3/3) | 100.00% (2/2) | N/A (0/0) | 100.00% (1/1) | +|-------------------+---------------+---------------+------------+---------------| +| Total | 100.00% (3/3) | 100.00% (2/2) | N/A (0/0) | 100.00% (1/1) | +╰-------------------+---------------+---------------+------------+---------------╯ "#]], ); -}); +} -forgetest!(require, |prj, cmd| { +#[forgetest] +fn require(prj: _, cmd: _) { prj.insert_ds_test(); prj.add_source( "AContract.sol", @@ -421,8 +613,7 @@ contract AContract { } } "#, - ) - .unwrap(); + ); prj.add_source( "AContractTest.sol", @@ -436,20 +627,19 @@ interface Vm { contract AContractTest is DSTest { Vm constant vm = Vm(HEVM_ADDRESS); + AContract a = new AContract(); + function testRequireRevert() external { - AContract a = new AContract(); vm.expectRevert(abi.encodePacked("reverted")); a.checkRequire(false); } function testRequireNoRevert() external { - AContract a = new AContract(); a.checkRequire(true); } } "#, - ) - .unwrap(); + ); // Assert 50% branch coverage if only revert tested. cmd.arg("coverage").args(["--mt", "testRequireRevert"]).assert_success().stdout_eq(str![[r#" @@ -493,9 +683,10 @@ contract AContractTest is DSTest { ╰-------------------+---------------+---------------+---------------+---------------╯ "#]]); -}); +} -forgetest!(line_hit_not_doubled, |prj, cmd| { +#[forgetest] +fn line_hit_not_doubled(prj: _, cmd: _) { prj.insert_ds_test(); prj.add_source( "AContract.sol", @@ -508,8 +699,7 @@ contract AContract { } } "#, - ) - .unwrap(); + ); prj.add_source( "AContractTest.sol", @@ -518,14 +708,14 @@ import "./test.sol"; import {AContract} from "./AContract.sol"; contract AContractTest is DSTest { + AContract a = new AContract(); + function testFoo() public { - AContract a = new AContract(); a.foo(); } } "#, - ) - .unwrap(); + ); // We want to make sure DA:8,1 is added only once so line hit is not doubled. assert_lcov( @@ -547,9 +737,10 @@ end_of_record "#]], ); -}); +} -forgetest!(branch, |prj, cmd| { +#[forgetest] +fn branch(prj: _, cmd: _) { prj.insert_ds_test(); prj.add_source( "Foo.sol", @@ -644,8 +835,7 @@ contract Foo { } } "#, - ) - .unwrap(); + ); prj.add_source( "FooTest.sol", @@ -760,8 +950,7 @@ contract FooTest is DSTest { } } "#, - ) - .unwrap(); + ); // Assert no coverage for single path branch. 2 branches (parent and child) not covered. cmd.arg("coverage") @@ -808,9 +997,10 @@ contract FooTest is DSTest { ╰-------------+-----------------+-----------------+-----------------+---------------╯ "#]]); -}); +} -forgetest!(function_call, |prj, cmd| { +#[forgetest] +fn function_call(prj: _, cmd: _) { prj.insert_ds_test(); prj.add_source( "AContract.sol", @@ -851,8 +1041,7 @@ contract AContract { } } "#, - ) - .unwrap(); + ); prj.add_source( "AContractTest.sol", @@ -861,30 +1050,31 @@ import "./test.sol"; import {AContract} from "./AContract.sol"; contract AContractTest is DSTest { + AContract a = new AContract(); + function testTypeConversionCoverage() external { - AContract a = new AContract(); a.coverMe(); } } "#, - ) - .unwrap(); + ); // Assert 100% coverage. cmd.arg("coverage").assert_success().stdout_eq(str![[r#" ... -╭-------------------+-----------------+---------------+---------------+---------------╮ -| File | % Lines | % Statements | % Branches | % Funcs | -+=====================================================================================+ -| src/AContract.sol | 100.00% (14/14) | 100.00% (9/9) | 100.00% (0/0) | 100.00% (5/5) | -|-------------------+-----------------+---------------+---------------+---------------| -| Total | 100.00% (14/14) | 100.00% (9/9) | 100.00% (0/0) | 100.00% (5/5) | -╰-------------------+-----------------+---------------+---------------+---------------╯ +╭-------------------+-----------------+---------------+------------+---------------╮ +| File | % Lines | % Statements | % Branches | % Funcs | ++==================================================================================+ +| src/AContract.sol | 100.00% (14/14) | 100.00% (9/9) | N/A (0/0) | 100.00% (5/5) | +|-------------------+-----------------+---------------+------------+---------------| +| Total | 100.00% (14/14) | 100.00% (9/9) | N/A (0/0) | 100.00% (5/5) | +╰-------------------+-----------------+---------------+------------+---------------╯ "#]]); -}); +} -forgetest!(try_catch, |prj, cmd| { +#[forgetest] +fn try_catch(prj: _, cmd: _) { prj.insert_ds_test(); prj.add_source( "Foo.sol", @@ -935,8 +1125,7 @@ contract Bar { } } "#, - ) - .unwrap(); + ); prj.add_source( "FooTest.sol", @@ -975,8 +1164,7 @@ contract FooTest is DSTest { } } "#, - ) - .unwrap(); + ); // Assert coverage not 100% for happy paths only. cmd.arg("coverage").args(["--mt", "happy"]).assert_success().stdout_eq(str![[r#" @@ -1003,9 +1191,10 @@ contract FooTest is DSTest { ╰-------------+-----------------+-----------------+---------------+---------------╯ "#]]); -}); +} -forgetest!(yul, |prj, cmd| { +#[forgetest] +fn yul(prj: _, cmd: _) { prj.insert_ds_test(); prj.add_source( "Foo.sol", @@ -1073,8 +1262,7 @@ contract Foo { } } "#, - ) - .unwrap(); + ); prj.add_source( "FooTest.sol", @@ -1095,23 +1283,23 @@ contract FooTest is DSTest { } } "#, - ) - .unwrap(); + ); cmd.forge_fuse().arg("coverage").assert_success().stdout_eq(str![[r#" ... ╭-------------+-----------------+-----------------+---------------+---------------╮ | File | % Lines | % Statements | % Branches | % Funcs | +=================================================================================+ -| src/Foo.sol | 100.00% (30/30) | 100.00% (40/40) | 100.00% (1/1) | 100.00% (7/7) | +| src/Foo.sol | 100.00% (30/30) | 100.00% (40/40) | 100.00% (2/2) | 100.00% (7/7) | |-------------+-----------------+-----------------+---------------+---------------| -| Total | 100.00% (30/30) | 100.00% (40/40) | 100.00% (1/1) | 100.00% (7/7) | +| Total | 100.00% (30/30) | 100.00% (40/40) | 100.00% (2/2) | 100.00% (7/7) | ╰-------------+-----------------+-----------------+---------------+---------------╯ "#]]); -}); +} -forgetest!(misc, |prj, cmd| { +#[forgetest] +fn misc(prj: _, cmd: _) { prj.insert_ds_test(); prj.add_source( "Foo.sol", @@ -1137,7 +1325,7 @@ contract B { contract C { function create() public { B b = new B{value: 1}(2); - b = (new B{value: 1})(2); + b = new B{value: 1}(2); b = (new B){value: 1}(2); } } @@ -1152,8 +1340,7 @@ contract D { } } "#, - ) - .unwrap(); + ); prj.add_source( "FooTest.sol", @@ -1188,24 +1375,24 @@ contract FooTest is DSTest { } } "#, - ) - .unwrap(); + ); cmd.forge_fuse().arg("coverage").assert_success().stdout_eq(str![[r#" ... -╭-------------+-----------------+---------------+---------------+---------------╮ -| File | % Lines | % Statements | % Branches | % Funcs | -+===============================================================================+ -| src/Foo.sol | 100.00% (12/12) | 100.00% (9/9) | 100.00% (0/0) | 100.00% (4/4) | -|-------------+-----------------+---------------+---------------+---------------| -| Total | 100.00% (12/12) | 100.00% (9/9) | 100.00% (0/0) | 100.00% (4/4) | -╰-------------+-----------------+---------------+---------------+---------------╯ +╭-------------+-----------------+---------------+------------+---------------╮ +| File | % Lines | % Statements | % Branches | % Funcs | ++============================================================================+ +| src/Foo.sol | 100.00% (12/12) | 100.00% (9/9) | N/A (0/0) | 100.00% (4/4) | +|-------------+-----------------+---------------+------------+---------------| +| Total | 100.00% (12/12) | 100.00% (9/9) | N/A (0/0) | 100.00% (4/4) | +╰-------------+-----------------+---------------+------------+---------------╯ "#]]); -}); +} // https://github.com/foundry-rs/foundry/issues/8605 -forgetest!(single_statement, |prj, cmd| { +#[forgetest] +fn single_statement(prj: _, cmd: _) { prj.insert_ds_test(); prj.add_source( "AContract.sol", @@ -1214,17 +1401,20 @@ contract AContract { event IsTrue(bool isTrue); event IsFalse(bool isFalse); - function ifElseStatementIgnored(bool flag) external { + function ifElseStatementIgnored(bool flag) external returns (bool) { if (flag) emit IsTrue(true); else emit IsFalse(false); - if (flag) flag = true; - else flag = false; + bool flag2; + if (flag) flag2 = true; + else flag2 = false; + + if (flag2) return true; + else return false; } } "#, - ) - .unwrap(); + ); prj.add_source( "AContractTest.sol", @@ -1233,19 +1423,18 @@ import "./test.sol"; import {AContract} from "./AContract.sol"; contract AContractTest is DSTest { + AContract a = new AContract(); + function testTrueCoverage() external { - AContract a = new AContract(); a.ifElseStatementIgnored(true); } function testFalseCoverage() external { - AContract a = new AContract(); a.ifElseStatementIgnored(false); } } "#, - ) - .unwrap(); + ); // Assert 50% coverage for true branches. cmd.arg("coverage").args(["--mt", "testTrueCoverage"]).assert_success().stdout_eq(str![[r#" @@ -1253,9 +1442,9 @@ contract AContractTest is DSTest { ╭-------------------+--------------+--------------+--------------+---------------╮ | File | % Lines | % Statements | % Branches | % Funcs | +================================================================================+ -| src/AContract.sol | 60.00% (3/5) | 50.00% (2/4) | 50.00% (2/4) | 100.00% (1/1) | +| src/AContract.sol | 62.50% (5/8) | 57.14% (4/7) | 50.00% (3/6) | 100.00% (1/1) | |-------------------+--------------+--------------+--------------+---------------| -| Total | 60.00% (3/5) | 50.00% (2/4) | 50.00% (2/4) | 100.00% (1/1) | +| Total | 62.50% (5/8) | 57.14% (4/7) | 50.00% (3/6) | 100.00% (1/1) | ╰-------------------+--------------+--------------+--------------+---------------╯ "#]]); @@ -1267,13 +1456,13 @@ contract AContractTest is DSTest { .assert_success() .stdout_eq(str![[r#" ... -╭-------------------+--------------+--------------+--------------+---------------╮ -| File | % Lines | % Statements | % Branches | % Funcs | -+================================================================================+ -| src/AContract.sol | 60.00% (3/5) | 50.00% (2/4) | 50.00% (2/4) | 100.00% (1/1) | -|-------------------+--------------+--------------+--------------+---------------| -| Total | 60.00% (3/5) | 50.00% (2/4) | 50.00% (2/4) | 100.00% (1/1) | -╰-------------------+--------------+--------------+--------------+---------------╯ +╭-------------------+---------------+--------------+--------------+---------------╮ +| File | % Lines | % Statements | % Branches | % Funcs | ++=================================================================================+ +| src/AContract.sol | 100.00% (8/8) | 57.14% (4/7) | 50.00% (3/6) | 100.00% (1/1) | +|-------------------+---------------+--------------+--------------+---------------| +| Total | 100.00% (8/8) | 57.14% (4/7) | 50.00% (3/6) | 100.00% (1/1) | +╰-------------------+---------------+--------------+--------------+---------------╯ "#]]); @@ -1283,37 +1472,36 @@ contract AContractTest is DSTest { ╭-------------------+---------------+---------------+---------------+---------------╮ | File | % Lines | % Statements | % Branches | % Funcs | +===================================================================================+ -| src/AContract.sol | 100.00% (5/5) | 100.00% (4/4) | 100.00% (4/4) | 100.00% (1/1) | +| src/AContract.sol | 100.00% (8/8) | 100.00% (7/7) | 100.00% (6/6) | 100.00% (1/1) | |-------------------+---------------+---------------+---------------+---------------| -| Total | 100.00% (5/5) | 100.00% (4/4) | 100.00% (4/4) | 100.00% (1/1) | +| Total | 100.00% (8/8) | 100.00% (7/7) | 100.00% (6/6) | 100.00% (1/1) | ╰-------------------+---------------+---------------+---------------+---------------╯ "#]]); -}); +} -// https://github.com/foundry-rs/foundry/issues/8604 -forgetest!(branch_with_calldata_reads, |prj, cmd| { +#[forgetest] +fn single_statement_loop(prj: _, cmd: _) { + // TODO(dani): the specific case of `if (x) continue/break` is not properly covered. prj.insert_ds_test(); prj.add_source( "AContract.sol", r#" contract AContract { - event IsTrue(bool isTrue); - event IsFalse(bool isFalse); + function ifBreakContinueIgnored(bool flag) external returns (uint256 sum) { + for (uint256 i = 0; i < 5; i++) { + if (flag) continue; + sum += i; + } - function execute(bool[] calldata isTrue) external { - for (uint256 i = 0; i < isTrue.length; i++) { - if (isTrue[i]) { - emit IsTrue(isTrue[i]); - } else { - emit IsFalse(!isTrue[i]); - } + for (uint256 i = 0; i < 5; i++) { + if (flag) break; + sum += i; } } } "#, - ) - .unwrap(); + ); prj.add_source( "AContractTest.sol", @@ -1322,34 +1510,29 @@ import "./test.sol"; import {AContract} from "./AContract.sol"; contract AContractTest is DSTest { + AContract a = new AContract(); + function testTrueCoverage() external { - AContract a = new AContract(); - bool[] memory isTrue = new bool[](1); - isTrue[0] = true; - a.execute(isTrue); + a.ifBreakContinueIgnored(true); } function testFalseCoverage() external { - AContract a = new AContract(); - bool[] memory isFalse = new bool[](1); - isFalse[0] = false; - a.execute(isFalse); + a.ifBreakContinueIgnored(false); } } "#, - ) - .unwrap(); + ); // Assert 50% coverage for true branches. cmd.arg("coverage").args(["--mt", "testTrueCoverage"]).assert_success().stdout_eq(str![[r#" ... -╭-------------------+--------------+--------------+--------------+---------------╮ -| File | % Lines | % Statements | % Branches | % Funcs | -+================================================================================+ -| src/AContract.sol | 80.00% (4/5) | 80.00% (4/5) | 50.00% (1/2) | 100.00% (1/1) | -|-------------------+--------------+--------------+--------------+---------------| -| Total | 80.00% (4/5) | 80.00% (4/5) | 50.00% (1/2) | 100.00% (1/1) | -╰-------------------+--------------+--------------+--------------+---------------╯ +╭-------------------+--------------+---------------+---------------+---------------╮ +| File | % Lines | % Statements | % Branches | % Funcs | ++==================================================================================+ +| src/AContract.sol | 71.43% (5/7) | 70.00% (7/10) | 100.00% (2/2) | 100.00% (1/1) | +|-------------------+--------------+---------------+---------------+---------------| +| Total | 71.43% (5/7) | 70.00% (7/10) | 100.00% (2/2) | 100.00% (1/1) | +╰-------------------+--------------+---------------+---------------+---------------╯ "#]]); @@ -1360,65 +1543,53 @@ contract AContractTest is DSTest { .assert_success() .stdout_eq(str![[r#" ... -╭-------------------+--------------+--------------+--------------+---------------╮ -| File | % Lines | % Statements | % Branches | % Funcs | -+================================================================================+ -| src/AContract.sol | 60.00% (3/5) | 80.00% (4/5) | 50.00% (1/2) | 100.00% (1/1) | -|-------------------+--------------+--------------+--------------+---------------| -| Total | 60.00% (3/5) | 80.00% (4/5) | 50.00% (1/2) | 100.00% (1/1) | -╰-------------------+--------------+--------------+--------------+---------------╯ +╭-------------------+---------------+-----------------+---------------+---------------╮ +| File | % Lines | % Statements | % Branches | % Funcs | ++=====================================================================================+ +| src/AContract.sol | 100.00% (7/7) | 100.00% (10/10) | 100.00% (2/2) | 100.00% (1/1) | +|-------------------+---------------+-----------------+---------------+---------------| +| Total | 100.00% (7/7) | 100.00% (10/10) | 100.00% (2/2) | 100.00% (1/1) | +╰-------------------+---------------+-----------------+---------------+---------------╯ "#]]); // Assert 100% coverage (true/false branches properly covered). cmd.forge_fuse().arg("coverage").assert_success().stdout_eq(str![[r#" ... -╭-------------------+---------------+---------------+---------------+---------------╮ -| File | % Lines | % Statements | % Branches | % Funcs | -+===================================================================================+ -| src/AContract.sol | 100.00% (5/5) | 100.00% (5/5) | 100.00% (2/2) | 100.00% (1/1) | -|-------------------+---------------+---------------+---------------+---------------| -| Total | 100.00% (5/5) | 100.00% (5/5) | 100.00% (2/2) | 100.00% (1/1) | -╰-------------------+---------------+---------------+---------------+---------------╯ +╭-------------------+---------------+-----------------+---------------+---------------╮ +| File | % Lines | % Statements | % Branches | % Funcs | ++=====================================================================================+ +| src/AContract.sol | 100.00% (7/7) | 100.00% (10/10) | 100.00% (2/2) | 100.00% (1/1) | +|-------------------+---------------+-----------------+---------------+---------------| +| Total | 100.00% (7/7) | 100.00% (10/10) | 100.00% (2/2) | 100.00% (1/1) | +╰-------------------+---------------+-----------------+---------------+---------------╯ "#]]); -}); +} -forgetest!(identical_bytecodes, |prj, cmd| { +// https://github.com/foundry-rs/foundry/issues/8604 +#[forgetest] +fn branch_with_calldata_reads(prj: _, cmd: _) { prj.insert_ds_test(); prj.add_source( "AContract.sol", r#" contract AContract { - uint256 public number; - address public immutable usdc1; - address public immutable usdc2; - address public immutable usdc3; - address public immutable usdc4; - address public immutable usdc5; - address public immutable usdc6; - - constructor() { - address a = 0x176211869cA2b568f2A7D4EE941E073a821EE1ff; - usdc1 = a; - usdc2 = a; - usdc3 = a; - usdc4 = a; - usdc5 = a; - usdc6 = a; - } - - function setNumber(uint256 newNumber) public { - number = newNumber; - } + event IsTrue(bool isTrue); + event IsFalse(bool isFalse); - function increment() public { - number++; + function execute(bool[] calldata isTrue) external { + for (uint256 i = 0; i < isTrue.length; i++) { + if (isTrue[i]) { + emit IsTrue(isTrue[i]); + } else { + emit IsFalse(!isTrue[i]); + } + } } } "#, - ) - .unwrap(); + ); prj.add_source( "AContractTest.sol", @@ -1427,292 +1598,345 @@ import "./test.sol"; import {AContract} from "./AContract.sol"; contract AContractTest is DSTest { - AContract public counter; + AContract a = new AContract(); - function setUp() public { - counter = new AContract(); - counter.setNumber(0); + function testTrueCoverage() external { + bool[] memory isTrue = new bool[](1); + isTrue[0] = true; + a.execute(isTrue); } - function test_Increment() public { - counter.increment(); - assertEq(counter.number(), 1); + function testFalseCoverage() external { + bool[] memory isFalse = new bool[](1); + isFalse[0] = false; + a.execute(isFalse); } } "#, - ) - .unwrap(); + ); - cmd.arg("coverage").assert_success().stdout_eq(str![[r#" + // Assert 50% coverage for true branches. + cmd.arg("coverage").args(["--mt", "testTrueCoverage"]).assert_success().stdout_eq(str![[r#" ... -╭-------------------+-----------------+---------------+---------------+---------------╮ -| File | % Lines | % Statements | % Branches | % Funcs | -+=====================================================================================+ -| src/AContract.sol | 100.00% (12/12) | 100.00% (9/9) | 100.00% (0/0) | 100.00% (3/3) | -|-------------------+-----------------+---------------+---------------+---------------| -| Total | 100.00% (12/12) | 100.00% (9/9) | 100.00% (0/0) | 100.00% (3/3) | -╰-------------------+-----------------+---------------+---------------+---------------╯ +╭-------------------+--------------+--------------+--------------+---------------╮ +| File | % Lines | % Statements | % Branches | % Funcs | ++================================================================================+ +| src/AContract.sol | 80.00% (4/5) | 80.00% (4/5) | 50.00% (1/2) | 100.00% (1/1) | +|-------------------+--------------+--------------+--------------+---------------| +| Total | 80.00% (4/5) | 80.00% (4/5) | 50.00% (1/2) | 100.00% (1/1) | +╰-------------------+--------------+--------------+--------------+---------------╯ + +"#]]); + + // Assert 50% coverage for false branches. + cmd.forge_fuse() + .arg("coverage") + .args(["--mt", "testFalseCoverage"]) + .assert_success() + .stdout_eq(str![[r#" +... +╭-------------------+--------------+--------------+--------------+---------------╮ +| File | % Lines | % Statements | % Branches | % Funcs | ++================================================================================+ +| src/AContract.sol | 80.00% (4/5) | 80.00% (4/5) | 50.00% (1/2) | 100.00% (1/1) | +|-------------------+--------------+--------------+--------------+---------------| +| Total | 80.00% (4/5) | 80.00% (4/5) | 50.00% (1/2) | 100.00% (1/1) | +╰-------------------+--------------+--------------+--------------+---------------╯ + +"#]]); + + // Assert 100% coverage (true/false branches properly covered). + cmd.forge_fuse().arg("coverage").assert_success().stdout_eq(str![[r#" +... +╭-------------------+---------------+---------------+---------------+---------------╮ +| File | % Lines | % Statements | % Branches | % Funcs | ++===================================================================================+ +| src/AContract.sol | 100.00% (5/5) | 100.00% (5/5) | 100.00% (2/2) | 100.00% (1/1) | +|-------------------+---------------+---------------+---------------+---------------| +| Total | 100.00% (5/5) | 100.00% (5/5) | 100.00% (2/2) | 100.00% (1/1) | +╰-------------------+---------------+---------------+---------------+---------------╯ "#]]); -}); +} -forgetest!(constructors, |prj, cmd| { +// https://github.com/foundry-rs/foundry/issues/10792 +#[forgetest] +fn branch_with_storage_bytes_reads(prj: _, cmd: _) { prj.insert_ds_test(); prj.add_source( "AContract.sol", r#" contract AContract { - bool public active; - - constructor() { - active = true; - } -} + bytes public optA = bytes("optA"); + bytes public optB = bytes("optB"); -contract BContract { - bool public active; - - constructor() { - active = true; + function execute(uint16 value) external view { + bytes memory element; + if (value == 4) { + element = optA; + } else { + element = optB; + } } } "#, - ) - .unwrap(); + ); prj.add_source( "AContractTest.sol", r#" import "./test.sol"; -import "./AContract.sol"; +import {AContract} from "./AContract.sol"; contract AContractTest is DSTest { - function test_constructors() public { - AContract a = new AContract(); - BContract b = new BContract(); + AContract a = new AContract(); + + function testTrueCoverage() external view { + a.execute(4); + } + + function testFalseCoverage() external view { + a.execute(5); } } "#, - ) - .unwrap(); + ); - cmd.arg("coverage").assert_success().stdout_eq(str![[r#" + cmd.arg("coverage").args(["--mt", "testTrueCoverage"]).assert_success().stdout_eq(str![[r#" +... +╭-------------------+--------------+--------------+--------------+---------------╮ +| File | % Lines | % Statements | % Branches | % Funcs | ++================================================================================+ +| src/AContract.sol | 80.00% (4/5) | 75.00% (3/4) | 50.00% (1/2) | 100.00% (1/1) | +|-------------------+--------------+--------------+--------------+---------------| +| Total | 80.00% (4/5) | 75.00% (3/4) | 50.00% (1/2) | 100.00% (1/1) | +╰-------------------+--------------+--------------+--------------+---------------╯ + +"#]]); + + cmd.forge_fuse() + .arg("coverage") + .args(["--mt", "testFalseCoverage"]) + .assert_success() + .stdout_eq(str![[r#" +... +╭-------------------+--------------+--------------+--------------+---------------╮ +| File | % Lines | % Statements | % Branches | % Funcs | ++================================================================================+ +| src/AContract.sol | 80.00% (4/5) | 75.00% (3/4) | 50.00% (1/2) | 100.00% (1/1) | +|-------------------+--------------+--------------+--------------+---------------| +| Total | 80.00% (4/5) | 75.00% (3/4) | 50.00% (1/2) | 100.00% (1/1) | +╰-------------------+--------------+--------------+--------------+---------------╯ + +"#]]); + + cmd.forge_fuse().arg("coverage").assert_success().stdout_eq(str![[r#" ... ╭-------------------+---------------+---------------+---------------+---------------╮ | File | % Lines | % Statements | % Branches | % Funcs | +===================================================================================+ -| src/AContract.sol | 100.00% (4/4) | 100.00% (2/2) | 100.00% (0/0) | 100.00% (2/2) | +| src/AContract.sol | 100.00% (5/5) | 100.00% (4/4) | 100.00% (2/2) | 100.00% (1/1) | |-------------------+---------------+---------------+---------------+---------------| -| Total | 100.00% (4/4) | 100.00% (2/2) | 100.00% (0/0) | 100.00% (2/2) | +| Total | 100.00% (5/5) | 100.00% (4/4) | 100.00% (2/2) | 100.00% (1/1) | ╰-------------------+---------------+---------------+---------------+---------------╯ "#]]); -}); +} -// https://github.com/foundry-rs/foundry/issues/9270, https://github.com/foundry-rs/foundry/issues/9444 -// Test that special functions with no statements are not counted. -// TODO: We should support this, but for now just ignore them. -// See TODO in `visit_function_definition`: https://github.com/foundry-rs/foundry/issues/9458 -forgetest!(empty_functions, |prj, cmd| { +#[forgetest] +fn branch_with_code_free_else(prj: _, cmd: _) { prj.insert_ds_test(); prj.add_source( "AContract.sol", r#" contract AContract { - constructor() {} - - receive() external payable {} + uint256 public value; - function increment() public {} + function execute(bool condition) external { + if (condition) { + value = 1; + } else { + uint256 unused; + } + } } "#, - ) - .unwrap(); + ); prj.add_source( "AContractTest.sol", r#" import "./test.sol"; -import "./AContract.sol"; +import {AContract} from "./AContract.sol"; contract AContractTest is DSTest { - function test_constructors() public { - AContract a = new AContract(); - a.increment(); - (bool success,) = address(a).call{value: 1}(""); - require(success); + AContract a = new AContract(); + + function testCoverage() external { + a.execute(true); + a.execute(false); } } "#, - ) - .unwrap(); + ); - assert_lcov( - cmd.arg("coverage"), - str![[r#" -TN: -SF:src/AContract.sol -DA:9,1 -FN:9,AContract.increment -FNDA:1,AContract.increment -FNF:1 -FNH:1 -LF:1 -LH:1 -BRF:0 -BRH:0 -end_of_record + cmd.arg("coverage").assert_success().stdout_eq(str![[r#" +... +╭-------------------+--------------+--------------+---------------+---------------╮ +| File | % Lines | % Statements | % Branches | % Funcs | ++=================================================================================+ +| src/AContract.sol | 75.00% (3/4) | 50.00% (1/2) | 100.00% (2/2) | 100.00% (1/1) | +|-------------------+--------------+--------------+---------------+---------------| +| Total | 75.00% (3/4) | 50.00% (1/2) | 100.00% (2/2) | 100.00% (1/1) | +╰-------------------+--------------+--------------+---------------+---------------╯ -"#]], +"#]]); +} + +#[forgetest] +fn ternary_return(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.add_source( + "AContract.sol", + r#" +contract AContract { + function execute(bool condition) external pure returns (uint256) { + return condition ? 1 : 2; + } +} +"#, ); + prj.add_source( + "AContractTest.sol", + r#" +import "./test.sol"; +import {AContract} from "./AContract.sol"; - // Assert there's only one function (`increment`) reported. - cmd.forge_fuse().arg("coverage").assert_success().stdout_eq(str![[r#" +contract AContractTest is DSTest { + AContract a = new AContract(); + + function testCoverage() external { + a.execute(true); + } +} +"#, + ); + cmd.arg("coverage").assert_success().stdout_eq(str![[r#" ... -╭-------------------+---------------+---------------+---------------+---------------╮ -| File | % Lines | % Statements | % Branches | % Funcs | -+===================================================================================+ -| src/AContract.sol | 100.00% (1/1) | 100.00% (0/0) | 100.00% (0/0) | 100.00% (1/1) | -|-------------------+---------------+---------------+---------------+---------------| -| Total | 100.00% (1/1) | 100.00% (0/0) | 100.00% (0/0) | 100.00% (1/1) | -╰-------------------+---------------+---------------+---------------+---------------╯ +╭-------------------+---------------+---------------+--------------+---------------╮ +| File | % Lines | % Statements | % Branches | % Funcs | ++==================================================================================+ +| src/AContract.sol | 100.00% (2/2) | 100.00% (2/2) | 50.00% (1/2) | 100.00% (1/1) | +|-------------------+---------------+---------------+--------------+---------------| +| Total | 100.00% (2/2) | 100.00% (2/2) | 50.00% (1/2) | 100.00% (1/1) | +╰-------------------+---------------+---------------+--------------+---------------╯ "#]]); -}); +} -// Test coverage for `receive` functions. -forgetest!(receive, |prj, cmd| { +#[forgetest] +fn ternary_array_copy(prj: _, cmd: _) { prj.insert_ds_test(); prj.add_source( "AContract.sol", r#" contract AContract { - uint256 public counter = 0; + uint256[] values; constructor() { - counter = 1; + values.push(1); + values.push(2); } - receive() external payable { - counter = msg.value; + function execute(bool condition) external view returns (uint256[] memory) { + return condition ? new uint256[](0) : values; } } - "#, - ) - .unwrap(); - +"#, + ); prj.add_source( "AContractTest.sol", r#" import "./test.sol"; -import "./AContract.sol"; +import {AContract} from "./AContract.sol"; contract AContractTest is DSTest { - function test_constructors() public { - AContract a = new AContract(); - address(a).call{value: 5}(""); - require(a.counter() == 5); + AContract a = new AContract(); + + function testCoverage() external view { + a.execute(false); } } - "#, - ) - .unwrap(); - - // Assert both constructor and receive functions coverage reported and appear in LCOV. - assert_lcov( - cmd.arg("coverage"), - str![[r#" -TN: -SF:src/AContract.sol -DA:7,1 -FN:7,AContract.constructor -FNDA:1,AContract.constructor -DA:8,1 -DA:11,1 -FN:11,AContract.receive -FNDA:1,AContract.receive -DA:12,1 -FNF:2 -FNH:2 -LF:4 -LH:4 -BRF:0 -BRH:0 -end_of_record - -"#]], - ); - - cmd.forge_fuse().arg("coverage").assert_success().stdout_eq(str![[r#" -... -╭-------------------+---------------+---------------+---------------+---------------╮ -| File | % Lines | % Statements | % Branches | % Funcs | -+===================================================================================+ -| src/AContract.sol | 100.00% (4/4) | 100.00% (2/2) | 100.00% (0/0) | 100.00% (2/2) | -|-------------------+---------------+---------------+---------------+---------------| -| Total | 100.00% (4/4) | 100.00% (2/2) | 100.00% (0/0) | 100.00% (2/2) | -╰-------------------+---------------+---------------+---------------+---------------╯ +"#, + ); + cmd.arg("coverage").assert_success().stdout_eq(str![[r#" +... +╭-------------------+---------------+---------------+--------------+---------------╮ +| File | % Lines | % Statements | % Branches | % Funcs | ++==================================================================================+ +| src/AContract.sol | 100.00% (5/5) | 100.00% (4/4) | 50.00% (1/2) | 100.00% (2/2) | +|-------------------+---------------+---------------+--------------+---------------| +| Total | 100.00% (5/5) | 100.00% (4/4) | 50.00% (1/2) | 100.00% (2/2) | +╰-------------------+---------------+---------------+--------------+---------------╯ "#]]); -}); +} -// https://github.com/foundry-rs/foundry/issues/9322 -// Test coverage with `--ir-minimum` for solidity < 0.8.5. -forgetest!(ir_minimum_early, |prj, cmd| { +#[forgetest] +fn ternary_nested_partial(prj: _, cmd: _) { prj.insert_ds_test(); prj.add_source( "AContract.sol", r#" -pragma solidity 0.8.4; - contract AContract { - function isContract(address account) internal view returns (bool) { - bytes32 codehash; - bytes32 accountHash = 0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470; - assembly { - codehash := extcodehash(account) - } - return (codehash != accountHash && codehash != 0x0); + function execute(bool outer, bool inner) external pure returns (uint256) { + return outer ? (inner ? 1 : 2) : 3; } } - "#, - ) - .unwrap(); +"#, + ); + prj.add_source( + "AContractTest.sol", + r#" +import "./test.sol"; +import {AContract} from "./AContract.sol"; - // Assert coverage doesn't fail with `Error: Unknown key "inliner"`. - cmd.arg("coverage").arg("--ir-minimum").assert_success().stdout_eq(str![[r#" +contract AContractTest is DSTest { + AContract a = new AContract(); + + function testCoverage() external { + a.execute(true, true); + a.execute(true, false); + } +} +"#, + ); + cmd.arg("coverage").assert_success().stdout_eq(str![[r#" ... -╭-------------------+-------------+--------------+---------------+-------------╮ -| File | % Lines | % Statements | % Branches | % Funcs | -+==============================================================================+ -| src/AContract.sol | 0.00% (0/5) | 0.00% (0/4) | 100.00% (0/0) | 0.00% (0/1) | -|-------------------+-------------+--------------+---------------+-------------| -| Total | 0.00% (0/5) | 0.00% (0/4) | 100.00% (0/0) | 0.00% (0/1) | -╰-------------------+-------------+--------------+---------------+-------------╯ +╭-------------------+---------------+---------------+--------------+---------------╮ +| File | % Lines | % Statements | % Branches | % Funcs | ++==================================================================================+ +| src/AContract.sol | 100.00% (2/2) | 100.00% (2/2) | 75.00% (3/4) | 100.00% (1/1) | +|-------------------+---------------+---------------+--------------+---------------| +| Total | 100.00% (2/2) | 100.00% (2/2) | 75.00% (3/4) | 100.00% (1/1) | +╰-------------------+---------------+---------------+--------------+---------------╯ "#]]); -}); +} -forgetest!(no_artifacts_written, |prj, cmd| { +#[forgetest] +fn ternary_nested_outer_only(prj: _, cmd: _) { prj.insert_ds_test(); prj.add_source( "AContract.sol", r#" contract AContract { - int public i; - - function init() public { - i = 0; - } - - function foo() public { - i = 1; + function execute(bool outer, bool inner) external pure returns (uint256) { + return outer ? (inner ? 1 : 2) : 3; } } - "#, - ) - .unwrap(); - +"#, + ); prj.add_source( "AContractTest.sol", r#" @@ -1720,198 +1944,2635 @@ import "./test.sol"; import {AContract} from "./AContract.sol"; contract AContractTest is DSTest { - AContract a; - - function setUp() public { - a = new AContract(); - a.init(); - } + AContract a = new AContract(); - function testFoo() public { - a.foo(); + function testCoverage() external { + a.execute(false, false); } } - "#, - ) - .unwrap(); - - cmd.forge_fuse().arg("coverage").assert_success().stdout_eq(str![[r#" -... -╭-------------------+---------------+---------------+---------------+---------------╮ -| File | % Lines | % Statements | % Branches | % Funcs | -+===================================================================================+ -| src/AContract.sol | 100.00% (4/4) | 100.00% (2/2) | 100.00% (0/0) | 100.00% (2/2) | -|-------------------+---------------+---------------+---------------+---------------| -| Total | 100.00% (4/4) | 100.00% (2/2) | 100.00% (0/0) | 100.00% (2/2) | -╰-------------------+---------------+---------------+---------------+---------------╯ +"#, + ); + cmd.arg("coverage").assert_success().stdout_eq(str![[r#" ... -"#]]); - - // no artifacts are to be written - let files = files_with_ext(prj.artifacts(), "json").collect::>(); +╭-------------------+---------------+---------------+--------------+---------------╮ +| File | % Lines | % Statements | % Branches | % Funcs | ++==================================================================================+ +| src/AContract.sol | 100.00% (2/2) | 100.00% (2/2) | 25.00% (1/4) | 100.00% (1/1) | +|-------------------+---------------+---------------+--------------+---------------| +| Total | 100.00% (2/2) | 100.00% (2/2) | 25.00% (1/4) | 100.00% (1/1) | +╰-------------------+---------------+---------------+--------------+---------------╯ - assert!(files.is_empty()); -}); +"#]]); +} -// -forgetest!(constructor_with_args, |prj, cmd| { +#[forgetest] +fn ternary_nested_false_partial(prj: _, cmd: _) { prj.insert_ds_test(); prj.add_source( - "ArrayCondition.sol", + "AContract.sol", r#" -contract ArrayCondition { - uint8 public constant MAX_SIZE = 32; - error TooLarge(); - error EmptyArray(); - // Storage variable to ensure the constructor does something - uint256 private _arrayLength; - - constructor(uint256[] memory values) { - // Check for empty array - if (values.length == 0) { - revert EmptyArray(); - } - - if (values.length > MAX_SIZE) { - revert TooLarge(); - } - - // Store the array length - _arrayLength = values.length; - } - - function getArrayLength() external view returns (uint256) { - return _arrayLength; +contract AContract { + function execute(bool outer, bool inner) external pure returns (uint256) { + return outer ? 1 : (inner ? 2 : 3); } } - "#, - ) - .unwrap(); - +"#, + ); prj.add_source( - "ArrayConditionTest.sol", + "AContractTest.sol", r#" import "./test.sol"; -import {ArrayCondition} from "./ArrayCondition.sol"; +import {AContract} from "./AContract.sol"; -interface Vm { - function expectRevert(bytes4 revertData) external; +contract AContractTest is DSTest { + AContract a = new AContract(); + + function testCoverage() external { + a.execute(false, true); + a.execute(false, false); + } } +"#, + ); + cmd.arg("coverage").assert_success().stdout_eq(str![[r#" +... +╭-------------------+---------------+---------------+--------------+---------------╮ +| File | % Lines | % Statements | % Branches | % Funcs | ++==================================================================================+ +| src/AContract.sol | 100.00% (2/2) | 100.00% (2/2) | 75.00% (3/4) | 100.00% (1/1) | +|-------------------+---------------+---------------+--------------+---------------| +| Total | 100.00% (2/2) | 100.00% (2/2) | 75.00% (3/4) | 100.00% (1/1) | +╰-------------------+---------------+---------------+--------------+---------------╯ -contract ArrayConditionTest is DSTest { - Vm constant vm = Vm(HEVM_ADDRESS); +"#]]); +} - function testValidSize() public { - uint256[] memory values = new uint256[](10); - ArrayCondition condition = new ArrayCondition(values); - assertEq(condition.getArrayLength(), 10); +#[forgetest] +fn ternary_expression_contexts(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.add_source( + "AContract.sol", + r#" +contract AContract { + function bare(bool cond) external pure { + cond ? uint(1) : uint(2); } - - // Test with maximum array size (should NOT revert) - function testMaxSize() public { - uint256[] memory values = new uint256[](32); - ArrayCondition condition = new ArrayCondition(values); - assertEq(condition.getArrayLength(), 32); + function assignment(bool cond) external pure returns (uint x) { + x = cond ? 1 : 2; } - - // Test with too large array size (should revert) - function testTooLarge() public { - uint256[] memory values = new uint256[](33); - vm.expectRevert(ArrayCondition.TooLarge.selector); - new ArrayCondition(values); + function declaration(bool cond) external pure returns (uint) { + uint x = cond ? 1 : 2; + return x; } - - // Test with empty array (should revert) - function testEmptyArray() public { - uint256[] memory values = new uint256[](0); - vm.expectRevert(ArrayCondition.EmptyArray.selector); - new ArrayCondition(values); + function tuple(bool cond) external pure returns (uint) { + (uint x, uint y) = (cond ? 1 : 2, 0); + return x + y; } + function argument(bool cond) external pure returns (uint) { + return identity(cond ? 1 : 2); + } + function identity(uint x) internal pure returns (uint) { return x; } } - "#, - ) - .unwrap(); +"#, + ); + prj.add_source( + "AContractTest.sol", + r#" +import "./test.sol"; +import {AContract} from "./AContract.sol"; +contract AContractTest is DSTest { + AContract a = new AContract(); + + function testCoverage() external { + a.bare(true); + a.assignment(true); + a.declaration(true); + a.tuple(true); + a.argument(true); + } +} +"#, + ); cmd.arg("coverage").assert_success().stdout_eq(str![[r#" ... -╭------------------------+---------------+---------------+---------------+---------------╮ -| File | % Lines | % Statements | % Branches | % Funcs | -+========================================================================================+ -| src/ArrayCondition.sol | 100.00% (8/8) | 100.00% (6/6) | 100.00% (2/2) | 100.00% (2/2) | -|------------------------+---------------+---------------+---------------+---------------| -| Total | 100.00% (8/8) | 100.00% (6/6) | 100.00% (2/2) | 100.00% (2/2) | -╰------------------------+---------------+---------------+---------------+---------------╯ -... +╭-------------------+-----------------+-----------------+---------------+---------------╮ +| File | % Lines | % Statements | % Branches | % Funcs | ++=======================================================================================+ +| src/AContract.sol | 100.00% (13/13) | 100.00% (11/11) | 50.00% (5/10) | 100.00% (6/6) | +|-------------------+-----------------+-----------------+---------------+---------------| +| Total | 100.00% (13/13) | 100.00% (11/11) | 50.00% (5/10) | 100.00% (6/6) | +╰-------------------+-----------------+-----------------+---------------+---------------╯ + "#]]); -}); +} -// -// Test that line hits are properly recorded in lcov report. -forgetest!(do_while_lcov, |prj, cmd| { +#[forgetest] +fn ternary_modifier(prj: _, cmd: _) { prj.insert_ds_test(); prj.add_source( - "Counter.sol", + "AContract.sol", r#" -contract Counter { - uint256 public number = 21; - - function increment() public { - uint256 i = 0; - do { - number++; - if (number > 20) { - number -= 2; - } - } while (++i < 10); +contract AContract { + modifier check(bool cond) { + uint256 x = cond ? 1 : 2; + _; } + function a(bool cond) external check(cond) returns (uint256) { return 1; } + function b(bool cond) external check(cond) returns (uint256) { return 2; } } - "#, - ) - .unwrap(); - +"#, + ); prj.add_source( - "Counter.t.sol", + "AContractTest.sol", r#" import "./test.sol"; -import "./Counter.sol"; +import {AContract} from "./AContract.sol"; -contract CounterTest is DSTest { - function test_do_while() public { - Counter counter = new Counter(); - counter.increment(); +contract AContractTest is DSTest { + AContract a = new AContract(); + + function testCoverage() external { + a.a(true); + a.b(false); } } - "#, - ) - .unwrap(); +"#, + ); + cmd.arg("coverage").assert_success().stdout_eq(str![[r#" +... +╭-------------------+---------------+---------------+--------------+---------------╮ +| File | % Lines | % Statements | % Branches | % Funcs | ++==================================================================================+ +| src/AContract.sol | 100.00% (4/4) | 100.00% (4/4) | 50.00% (1/2) | 100.00% (3/3) | +|-------------------+---------------+---------------+--------------+---------------| +| Total | 100.00% (4/4) | 100.00% (4/4) | 50.00% (1/2) | 100.00% (3/3) | +╰-------------------+---------------+---------------+--------------+---------------╯ - assert_lcov( - cmd.arg("coverage"), - str![[r#" -TN: -SF:src/Counter.sol -DA:7,1 -FN:7,Counter.increment -FNDA:1,Counter.increment -DA:8,1 -DA:14,10 -DA:10,10 -DA:11,10 -BRDA:11,0,0,6 -DA:12,6 +"#]]); +} + +#[forgetest] +fn identical_bytecodes(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.add_source( + "AContract.sol", + r#" +contract AContract { + uint256 public number; + address public immutable usdc1; + address public immutable usdc2; + address public immutable usdc3; + address public immutable usdc4; + address public immutable usdc5; + address public immutable usdc6; + + constructor() { + address a = 0x176211869cA2b568f2A7D4EE941E073a821EE1ff; + usdc1 = a; + usdc2 = a; + usdc3 = a; + usdc4 = a; + usdc5 = a; + usdc6 = a; + } + + function setNumber(uint256 newNumber) public { + number = newNumber; + } + + function increment() public { + number++; + } +} + "#, + ); + + prj.add_source( + "AContractTest.sol", + r#" +import "./test.sol"; +import {AContract} from "./AContract.sol"; + +contract AContractTest is DSTest { + AContract public counter; + + function setUp() public { + counter = new AContract(); + counter.setNumber(0); + } + + function test_Increment() public { + counter.increment(); + assertEq(counter.number(), 1); + } +} + "#, + ); + + cmd.arg("coverage").assert_success().stdout_eq(str![[r#" +... +╭-------------------+-----------------+---------------+------------+---------------╮ +| File | % Lines | % Statements | % Branches | % Funcs | ++==================================================================================+ +| src/AContract.sol | 100.00% (12/12) | 100.00% (9/9) | N/A (0/0) | 100.00% (3/3) | +|-------------------+-----------------+---------------+------------+---------------| +| Total | 100.00% (12/12) | 100.00% (9/9) | N/A (0/0) | 100.00% (3/3) | +╰-------------------+-----------------+---------------+------------+---------------╯ + +"#]]); +} + +#[forgetest] +fn constructors(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.add_source( + "AContract.sol", + r#" +contract AContract { + bool public active; + + constructor() { + active = true; + } +} + +contract BContract { + bool public active; + + constructor() { + active = true; + } +} + "#, + ); + + prj.add_source( + "AContractTest.sol", + r#" +import "./test.sol"; +import "./AContract.sol"; + +contract AContractTest is DSTest { + function test_constructors() public { + AContract a = new AContract(); + BContract b = new BContract(); + } +} + "#, + ); + + cmd.arg("coverage").assert_success().stdout_eq(str![[r#" +... +╭-------------------+---------------+---------------+------------+---------------╮ +| File | % Lines | % Statements | % Branches | % Funcs | ++================================================================================+ +| src/AContract.sol | 100.00% (4/4) | 100.00% (2/2) | N/A (0/0) | 100.00% (2/2) | +|-------------------+---------------+---------------+------------+---------------| +| Total | 100.00% (4/4) | 100.00% (2/2) | N/A (0/0) | 100.00% (2/2) | +╰-------------------+---------------+---------------+------------+---------------╯ + +"#]]); +} + +// https://github.com/foundry-rs/foundry/issues/9270, +// https://github.com/foundry-rs/foundry/issues/9444, +// https://github.com/foundry-rs/foundry/issues/9458 +// Test coverage for functions with no statements. +#[forgetest] +fn empty_functions(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.add_source( + "AContract.sol", + r#" +contract AContract { + constructor() {} + + receive() external payable {} + + fallback() external {} + + function increment() public {} +} + "#, + ); + + prj.add_source( + "AContractTest.sol", + r#" +import "./test.sol"; +import "./AContract.sol"; + +contract AContractTest is DSTest { + function test_constructors() public { + AContract a = new AContract(); + a.increment(); + (bool success,) = address(a).call{value: 1}(""); + require(success); + (success,) = address(a).call(hex"deadbeef"); + require(success); + } +} + "#, + ); + + assert_lcov( + cmd.arg("coverage"), + str![[r#" +TN: +SF:src/AContract.sol +DA:5,1 +FN:5,AContract.constructor +FNDA:1,AContract.constructor +DA:7,1 +FN:7,AContract.receive +FNDA:1,AContract.receive +DA:9,1 +FN:9,AContract.fallback +FNDA:1,AContract.fallback +DA:11,1 +FN:11,AContract.increment +FNDA:1,AContract.increment +FNF:4 +FNH:4 +LF:4 +LH:4 +BRF:0 +BRH:0 +end_of_record + +"#]], + ); + + cmd.forge_fuse().arg("coverage").assert_success().stdout_eq(str![[r#" +... +╭-------------------+---------------+--------------+------------+---------------╮ +| File | % Lines | % Statements | % Branches | % Funcs | ++===============================================================================+ +| src/AContract.sol | 100.00% (4/4) | N/A (0/0) | N/A (0/0) | 100.00% (4/4) | +|-------------------+---------------+--------------+------------+---------------| +| Total | 100.00% (4/4) | N/A (0/0) | N/A (0/0) | 100.00% (4/4) | +╰-------------------+---------------+--------------+------------+---------------╯ + +"#]]); +} + +// Test empty shared-memory calldata used by nested calls with isolation disabled. +#[forgetest] +fn empty_shared_calldata(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.add_source( + "AContract.sol", + r#" +contract AContract { + receive() external payable {} +} + "#, + ); + + prj.add_source( + "AContractTest.sol", + r#" +import "./test.sol"; +import "./AContract.sol"; + +contract AContractTest is DSTest { + address internal target; + + function setUp() public { + target = address(new AContract()); + } + + function test_receive() public { + (bool success,) = target.call{gas: 100_000}(""); + require(success); + } +} + "#, + ); + + let expected = str![[r#" +TN: +SF:src/AContract.sol +DA:5,1 +FN:5,AContract.receive +FNDA:1,AContract.receive +FNF:1 +FNH:1 +LF:1 +LH:1 +BRF:0 +BRH:0 +end_of_record + +"#]]; + assert_lcov(cmd.arg("coverage").arg("--no-isolate"), expected.clone()); + assert_lcov(cmd.forge_fuse().arg("coverage").args(["--no-isolate", "--ir-minimum"]), expected); +} + +// Test that inherited empty constructors, receive functions, and fallbacks have distinct anchors. +#[forgetest] +fn empty_special_functions_are_distinct(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.add_source( + "AContract.sol", + r#" +contract Base { + constructor() {} + + receive() external payable {} + + fallback() external {} + + function increment() public {} +} + +contract AContract is Base {} + "#, + ); + + prj.add_source( + "AContractTest.sol", + r#" +import "./test.sol"; +import "./AContract.sol"; + +contract AContractTest is DSTest { + function test_receive() public { + AContract a = new AContract(); + (bool success,) = address(a).call{value: 1}(""); + require(success); + } +} + "#, + ); + + assert_lcov( + cmd.arg("coverage"), + str![[r#" +TN: +SF:src/AContract.sol +DA:5,1 +FN:5,Base.constructor +FNDA:1,Base.constructor +DA:7,1 +FN:7,Base.receive +FNDA:1,Base.receive +DA:9,0 +FN:9,Base.fallback +FNDA:0,Base.fallback +DA:11,0 +FN:11,Base.increment +FNDA:0,Base.increment +FNF:4 +FNH:2 +LF:4 +LH:2 +BRF:0 +BRH:0 +end_of_record + +"#]], + ); +} + +// Test that a fallback without a receive uses the same anchor for empty and non-empty calldata. +#[forgetest] +fn empty_fallback_without_receive(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.add_source( + "AContract.sol", + r#" +contract AContract { + fallback() external payable {} +} + "#, + ); + + prj.add_source( + "AContractTest.sol", + r#" +import "./test.sol"; +import "./AContract.sol"; + +contract AContractTest is DSTest { + function test_fallback() public { + AContract a = new AContract(); + (bool success,) = address(a).call{value: 1}(""); + require(success); + (success,) = address(a).call(hex"01"); + require(success); + (success,) = address(a).call(hex"deadbeef"); + require(success); + } +} + "#, + ); + + assert_lcov( + cmd.arg("coverage"), + str![[r#" +TN: +SF:src/AContract.sol +DA:5,3 +FN:5,AContract.fallback +FNDA:3,AContract.fallback +FNF:1 +FNH:1 +LF:1 +LH:1 +BRF:0 +BRH:0 +end_of_record + +"#]], + ); +} + +// Test that a nonpayable fallback is not covered by a rejected value-bearing call. +#[forgetest] +fn empty_nonpayable_fallback_rejects_value(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.add_source( + "AContract.sol", + r#" +contract AContract { + fallback() external {} +} + "#, + ); + + prj.add_source( + "AContractTest.sol", + r#" +import "./test.sol"; +import "./AContract.sol"; + +contract AContractTest is DSTest { + function test_rejected_fallback() public { + AContract a = new AContract(); + (bool success,) = address(a).call{value: 1}(hex"deadbeef"); + require(!success); + } +} + "#, + ); + + assert_lcov( + cmd.arg("coverage"), + str![[r#" +TN: +SF:src/AContract.sol +DA:5,0 +FN:5,AContract.fallback +FNDA:0,AContract.fallback +FNF:1 +FNH:0 +LF:1 +LH:0 +BRF:0 +BRH:0 +end_of_record + +"#]], + ); +} + +// Test coverage for `receive` functions. +#[forgetest] +fn receive(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.add_source( + "AContract.sol", + r#" +contract AContract { + uint256 public counter = 0; + + constructor() { + counter = 1; + } + + receive() external payable { + counter = msg.value; + } + + fallback() external {} +} + "#, + ); + + prj.add_source( + "AContractTest.sol", + r#" +import "./test.sol"; +import "./AContract.sol"; + +contract AContractTest is DSTest { + AContract a = new AContract(); + + function test_constructors() public { + address(a).call{value: 5}(""); + require(a.counter() == 5); + } +} + "#, + ); + + // Assert the constructor and receive are covered while the empty fallback is not. + assert_lcov( + cmd.arg("coverage"), + str![[r#" +TN: +SF:src/AContract.sol +DA:7,1 +FN:7,AContract.constructor +FNDA:1,AContract.constructor +DA:8,1 +DA:11,1 +FN:11,AContract.receive +FNDA:1,AContract.receive +DA:12,1 +DA:15,0 +FN:15,AContract.fallback +FNDA:0,AContract.fallback +FNF:3 +FNH:2 +LF:5 +LH:4 +BRF:0 +BRH:0 +end_of_record + +"#]], + ); + + cmd.forge_fuse().arg("coverage").assert_success().stdout_eq(str![[r#" +... +╭-------------------+--------------+---------------+------------+--------------╮ +| File | % Lines | % Statements | % Branches | % Funcs | ++==============================================================================+ +| src/AContract.sol | 80.00% (4/5) | 100.00% (2/2) | N/A (0/0) | 66.67% (2/3) | +|-------------------+--------------+---------------+------------+--------------| +| Total | 80.00% (4/5) | 100.00% (2/2) | N/A (0/0) | 66.67% (2/3) | +╰-------------------+--------------+---------------+------------+--------------╯ + +"#]]); +} + +// Test empty constructor coverage when via-IR source maps contain no matching span. +#[forgetest] +fn empty_constructor_ir_minimum(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.add_source( + "AContract.sol", + r#" +contract AContract { + constructor() {} +} + "#, + ); + + prj.add_source( + "AContractTest.sol", + r#" +import "./test.sol"; +import "./AContract.sol"; + +contract AContractTest is DSTest { + function test_constructor() public { + new AContract(); + } +} + "#, + ); + + assert_lcov( + cmd.arg("coverage").arg("--ir-minimum"), + str![[r#" +TN: +SF:src/AContract.sol +DA:5,1 +FN:5,AContract.constructor +FNDA:1,AContract.constructor +FNF:1 +FNH:1 +LF:1 +LH:1 +BRF:0 +BRH:0 +end_of_record + +"#]], + ); +} + +// https://github.com/foundry-rs/foundry/issues/9322 +// Test coverage with `--ir-minimum` for solidity < 0.8.5. +#[forgetest] +fn ir_minimum_early(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.add_source( + "AContract.sol", + r#" +pragma solidity 0.8.4; + +contract AContract { + function isContract(address account) internal view returns (bool) { + bytes32 codehash; + bytes32 accountHash = 0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470; + assembly { + codehash := extcodehash(account) + } + return (codehash != accountHash && codehash != 0x0); + } +} + "#, + ); + + // Assert coverage doesn't fail with `Error: Unknown key "inliner"`. + cmd.arg("coverage").arg("--ir-minimum").assert_success().stdout_eq(str![[r#" +... +╭-------------------+-------------+--------------+------------+-------------╮ +| File | % Lines | % Statements | % Branches | % Funcs | ++===========================================================================+ +| src/AContract.sol | 0.00% (0/5) | 0.00% (0/4) | N/A (0/0) | 0.00% (0/1) | +|-------------------+-------------+--------------+------------+-------------| +| Total | 0.00% (0/5) | 0.00% (0/4) | N/A (0/0) | 0.00% (0/1) | +╰-------------------+-------------+--------------+------------+-------------╯ + +"#]]); +} + +#[forgetest] +fn no_artifacts_written(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.add_source( + "AContract.sol", + r#" +contract AContract { + int public i; + + function init() public { + i = 0; + } + + function foo() public { + i = 1; + } +} + "#, + ); + + prj.add_source( + "AContractTest.sol", + r#" +import "./test.sol"; +import {AContract} from "./AContract.sol"; + +contract AContractTest is DSTest { + AContract a; + + function setUp() public { + a = new AContract(); + a.init(); + } + + function testFoo() public { + a.foo(); + } +} + "#, + ); + + cmd.forge_fuse().arg("coverage").assert_success().stdout_eq(str![[r#" +... +╭-------------------+---------------+---------------+------------+---------------╮ +| File | % Lines | % Statements | % Branches | % Funcs | ++================================================================================+ +| src/AContract.sol | 100.00% (4/4) | 100.00% (2/2) | N/A (0/0) | 100.00% (2/2) | +|-------------------+---------------+---------------+------------+---------------| +| Total | 100.00% (4/4) | 100.00% (2/2) | N/A (0/0) | 100.00% (2/2) | +╰-------------------+---------------+---------------+------------+---------------╯ +... +"#]]); + + // no artifacts are to be written + let files = files_with_ext(prj.artifacts(), "json").collect::>(); + + assert!(files.is_empty()); +} + +#[forgetest] +fn attribution_report(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.add_source( + "AContract.sol", + r#" +contract AContract { + int public i; + + function init() public { + i = 0; + } + + function foo() public { + i = 1; + } + + function bar() public { + i = 2; + } +} + "#, + ); + + prj.add_source( + "AContractTest.sol", + r#" +import "./test.sol"; +import {AContract} from "./AContract.sol"; + +contract AContractTest is DSTest { + AContract a; + + function setUp() public { + a = new AContract(); + a.init(); + } + + function testFoo() public { + a.foo(); + } + + function testBar() public { + a.bar(); + } +} + "#, + ); + + cmd.arg("coverage").args(["--report=attribution"]).assert_success(); + + let attribution = prj.root().join("coverage-attribution.json"); + assert!(attribution.exists(), "coverage attribution report was not created"); + + let custom_attribution = prj.root().join("custom-coverage-attribution.json"); + cmd.forge_fuse() + .arg("coverage") + .args(["--report=attribution", "--report-file", "custom-coverage-attribution.json"]) + .assert_success(); + assert!(custom_attribution.exists(), "custom coverage attribution report was not created"); + + let json: Value = serde_json::from_str(&std::fs::read_to_string(attribution).unwrap()).unwrap(); + let tests = json["tests"].as_array().unwrap(); + assert_eq!(tests.len(), 2); + + for test in tests { + assert_eq!(test["status"], "success"); + assert_eq!(test["kind"], "unit"); + + let covered = test["covered"].as_array().unwrap(); + assert!( + covered.iter().any(|item| item["source"] == "src/AContract.sol" + && item["kind"] == "function" + && item["function"] == "init"), + "setUp coverage should be attributed to each selected test" + ); + } + + let test_foo = tests.iter().find(|test| test["test"] == "testFoo()").unwrap(); + assert!(test_foo["covered"].as_array().unwrap().iter().any(|item| { + item["source"] == "src/AContract.sol" + && item["kind"] == "function" + && item["function"] == "foo" + })); + + let test_bar = tests.iter().find(|test| test["test"] == "testBar()").unwrap(); + assert!(test_bar["covered"].as_array().unwrap().iter().any(|item| { + item["source"] == "src/AContract.sol" + && item["kind"] == "function" + && item["function"] == "bar" + })); +} + +#[forgetest] +fn attribution_report_keeps_items_aligned_after_filtering_sources(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.add_source( + "sub/WContract.sol", + r#" +contract WContract { + function hit() public pure returns (uint256) { + return 2; + } +} + "#, + ); + prj.add_source( + "XContract.sol", + r#" +contract XContract { + function hit() public pure returns (uint256) { + return 4; + } +} + "#, + ); + prj.add_source( + "YContract.sol", + r#" +contract YContract { + function hit() public pure returns (uint256) { + return 6; + } +} + "#, + ); + prj.add_source( + "ZContract.sol", + r#" +contract ZContract { + function hit() public pure returns (uint256) { + return 8; + } +} + "#, + ); + + prj.add_source( + "AttributionFilterTest.sol", + r#" +import "./test.sol"; +import {WContract} from "./sub/WContract.sol"; +import {XContract} from "./XContract.sol"; +import {YContract} from "./YContract.sol"; +import {ZContract} from "./ZContract.sol"; + +contract AttributionFilterTest is DSTest { + function testW() public { + assertEq(new WContract().hit(), 2); + } + + function testX() public { + assertEq(new XContract().hit(), 4); + } + + function testY() public { + assertEq(new YContract().hit(), 6); + } + + function testZ() public { + assertEq(new ZContract().hit(), 8); + } +} + "#, + ); + + cmd.arg("coverage") + .args(["--report=attribution", "--no-match-coverage=YContract"]) + .assert_success(); + + let attribution = prj.root().join("coverage-attribution.json"); + let json: Value = serde_json::from_str(&std::fs::read_to_string(attribution).unwrap()).unwrap(); + let tests = json["tests"].as_array().unwrap(); + + assert!( + tests.iter().flat_map(|test| test["covered"].as_array().unwrap()).all(|item| { + item["source"].as_str().is_some_and(|source| !source.contains("YContract.sol")) + }), + "filtered source should not be present in attribution output" + ); + + for (test_name, contract_name) in + [("testW()", "WContract"), ("testX()", "XContract"), ("testZ()", "ZContract")] + { + let test = tests.iter().find(|test| test["test"] == test_name).unwrap(); + let covered = test["covered"].as_array().unwrap(); + assert!( + covered.iter().any(|item| { + item["source"] + .as_str() + .is_some_and(|source| source.ends_with(&format!("{contract_name}.sol"))) + && item["contract"] == contract_name + && item["kind"] == "function" + && item["function"] == "hit" + && item["hits"].as_u64().is_some_and(|hits| hits > 0) + }), + "{test_name} should retain coverage for {contract_name}.hit" + ); + } +} + +#[forgetest] +fn attribution_report_with_lcov_uses_default_paths(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.add_source( + "AContract.sol", + r#" +contract AContract { + int public i; + + function foo() public { + i = 1; + } +} + "#, + ); + + prj.add_source( + "AContractTest.sol", + r#" +import "./test.sol"; +import {AContract} from "./AContract.sol"; + +contract AContractTest is DSTest { + AContract a; + + function setUp() public { + a = new AContract(); + } + + function testFoo() public { + a.foo(); + } +} + "#, + ); + + let stderr = cmd + .arg("coverage") + .args(["--report=attribution", "--report=lcov", "--report-file", "combined.out"]) + .assert_success() + .get_output() + .stderr_lossy(); + + assert!( + stderr.contains("`--report-file` is ignored when multiple file reports are requested"), + "expected warning about ignored --report-file, got:\n{stderr}" + ); + + assert!(prj.root().join("coverage-attribution.json").exists()); + assert!(prj.root().join("lcov.info").exists()); + assert!(!prj.root().join("combined.out").exists()); +} + +// +#[forgetest] +fn constructor_with_args(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.add_source( + "ArrayCondition.sol", + r#" +contract ArrayCondition { + uint8 public constant MAX_SIZE = 32; + error TooLarge(); + error EmptyArray(); + // Storage variable to ensure the constructor does something + uint256 private _arrayLength; + + constructor(uint256[] memory values) { + // Check for empty array + if (values.length == 0) { + revert EmptyArray(); + } + + if (values.length > MAX_SIZE) { + revert TooLarge(); + } + + // Store the array length + _arrayLength = values.length; + } + + function getArrayLength() external view returns (uint256) { + return _arrayLength; + } +} + "#, + ); + + prj.add_source( + "ArrayConditionTest.sol", + r#" +import "./test.sol"; +import {ArrayCondition} from "./ArrayCondition.sol"; + +interface Vm { + function expectRevert(bytes4 revertData) external; +} + +contract ArrayConditionTest is DSTest { + Vm constant vm = Vm(HEVM_ADDRESS); + + function testValidSize() public { + uint256[] memory values = new uint256[](10); + ArrayCondition condition = new ArrayCondition(values); + assertEq(condition.getArrayLength(), 10); + } + + // Test with maximum array size (should NOT revert) + function testMaxSize() public { + uint256[] memory values = new uint256[](32); + ArrayCondition condition = new ArrayCondition(values); + assertEq(condition.getArrayLength(), 32); + } + + // Test with too large array size (should revert) + function testTooLarge() public { + uint256[] memory values = new uint256[](33); + vm.expectRevert(ArrayCondition.TooLarge.selector); + new ArrayCondition(values); + } + + // Test with empty array (should revert) + function testEmptyArray() public { + uint256[] memory values = new uint256[](0); + vm.expectRevert(ArrayCondition.EmptyArray.selector); + new ArrayCondition(values); + } +} + "#, + ); + + cmd.arg("coverage").assert_success().stdout_eq(str![[r#" +... +╭------------------------+---------------+---------------+---------------+---------------╮ +| File | % Lines | % Statements | % Branches | % Funcs | ++========================================================================================+ +| src/ArrayCondition.sol | 100.00% (8/8) | 100.00% (6/6) | 100.00% (2/2) | 100.00% (2/2) | +|------------------------+---------------+---------------+---------------+---------------| +| Total | 100.00% (8/8) | 100.00% (6/6) | 100.00% (2/2) | 100.00% (2/2) | +╰------------------------+---------------+---------------+---------------+---------------╯ +... +"#]]); +} + +// https://github.com/foundry-rs/foundry/issues/11432 +// Test coverage for linked libraries. +#[forgetest] +fn linked_library(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.add_source( + "Counter.sol", + r#" +library LibCounter { + function increment(uint256 number) external returns (uint256) { + return number + 1; + } +} + +contract Counter { + uint256 public number; + + function increment() public { + number = LibCounter.increment(number); + } +} + "#, + ); + + prj.add_source( + "CounterTest.sol", + r#" +import "./test.sol"; +import {Counter, LibCounter} from "./Counter.sol"; + +interface Vm { + function etch(address target, bytes calldata code) external; +} + +contract CounterTest is DSTest { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + function testIncrement() public { + Counter counter = new Counter(); + counter.increment(); + } + + function testRelinked() public { + Counter counter = new Counter(); + address original = address(LibCounter); + address replacement = address(0x1234567890123456789012345678901234567890); + vm.etch(replacement, original.code); + bytes memory code = address(counter).code; + bytes20 oldAddress = bytes20(original); + bytes20 newAddress = bytes20(replacement); + uint256 patches; + for (uint256 i; i + 20 <= code.length; ++i) { + bool matchAddress = true; + for (uint256 j; j < 20; ++j) { + if (code[i + j] != oldAddress[j]) matchAddress = false; + } + if (matchAddress) { + for (uint256 j; j < 20; ++j) code[i + j] = newAddress[j]; + ++patches; + i += 19; + } + } + require(patches == 1, "expected one library link"); + vm.etch(address(counter), code); + counter.increment(); + require(counter.number() == 1); + } +} + "#, + ); + + // Assert 100% coverage for both original and relocated library links. + for test in ["testIncrement", "testRelinked"] { + cmd.forge_fuse().args(["coverage", "--mt", test]).assert_success().stdout_eq(str![[r#" +... +╭-----------------+---------------+---------------+------------+---------------╮ +| File | % Lines | % Statements | % Branches | % Funcs | ++==============================================================================+ +| src/Counter.sol | 100.00% (4/4) | 100.00% (3/3) | N/A (0/0) | 100.00% (2/2) | +|-----------------+---------------+---------------+------------+---------------| +| Total | 100.00% (4/4) | 100.00% (3/3) | N/A (0/0) | 100.00% (2/2) | +╰-----------------+---------------+---------------+------------+---------------╯ +... +"#]]); + } +} + +// +// Test that line hits are properly recorded in lcov report. +#[forgetest] +fn do_while_lcov(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.add_source( + "Counter.sol", + r#" +contract Counter { + uint256 public number = 21; + + function increment() public { + uint256 i = 0; + do { + number++; + if (number > 20) { + number -= 2; + } + } while (++i < 10); + } +} + "#, + ); + + prj.add_source( + "Counter.t.sol", + r#" +import "./test.sol"; +import "./Counter.sol"; + +contract CounterTest is DSTest { + function test_do_while() public { + Counter counter = new Counter(); + counter.increment(); + } +} + "#, + ); + + assert_lcov( + cmd.arg("coverage"), + str![[r#" +TN: +SF:src/Counter.sol +DA:7,1 +FN:7,Counter.increment +FNDA:1,Counter.increment +DA:8,1 +DA:10,10 +DA:11,10 +BRDA:11,0,0,6 +DA:12,6 +DA:14,10 +FNF:1 +FNH:1 +LF:6 +LH:6 +BRF:1 +BRH:1 +end_of_record + +"#]], + ); +} + +// +// Test that overridden functions are disambiguated in the LCOV report. +#[forgetest] +fn disambiguate_functions(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.add_source( + "Counter.sol", + r#" +contract Counter { + uint256 public number; + + function increment() public { + number++; + } + function increment(uint256 amount) public { + number += amount; + } +} + "#, + ); + + prj.add_source( + "Counter.t.sol", + r#" +import "./test.sol"; +import "./Counter.sol"; + +contract CounterTest is DSTest { + function test_overridden() public { + Counter counter = new Counter(); + counter.increment(); + counter.increment(1); + counter.increment(2); + counter.increment(3); + assertEq(counter.number(), 7); + } +} + "#, + ); + + assert_lcov( + cmd.arg("coverage"), + str![[r#" +TN: +SF:src/Counter.sol +DA:7,1 +FN:7,Counter.increment.0 +FNDA:1,Counter.increment.0 +DA:8,1 +DA:10,3 +FN:10,Counter.increment.1 +FNDA:3,Counter.increment.1 +DA:11,3 +FNF:2 +FNH:2 +LF:4 +LH:4 +BRF:0 +BRH:0 +end_of_record + +"#]], + ); +} + +// Test that functions of abstract contracts and interfaces should not count in coverage report. +#[forgetest] +fn abstract_contract_and_interface(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.add_source( + "Counter.sol", + r#" +interface ContractIf { + function setNumber(uint256 newNumber) external; +} + +abstract contract AbstractCounter { + function _setNumber(uint256 newNumber) internal virtual; + + function _incrementNumber(uint256 newNumber) internal virtual returns (uint256 inc) { + inc = newNumber + 1; + } +} + +contract Counter is AbstractCounter, ContractIf { + uint256 public number; + + function setNumber(uint256 newNumber) public { + _setNumber(newNumber); + } + + function _setNumber(uint256 newNumber) internal override { + number = _incrementNumber(newNumber); + } + + function _incrementNumber(uint256 newNumber) internal override returns (uint256 inc) { + inc = super._incrementNumber(newNumber); + } +} + "#, + ); + prj.add_source( + "CounterTest.sol", + r#" +import "./test.sol"; +import {Counter} from "./Counter.sol"; + +contract CounterTest is DSTest { + function testCounter() public { + Counter counter = new Counter(); + counter.setNumber(0); + } +} + "#, + ); + + // Test there are 4 functions reported: + // - `setNumber`, `_setNumber` and `_incrementNumber` from `Counter` contract + // - `_incrementNumber` from `AbstractCounter` (virtual with implementation). `_setNumber` is + // excluded as it is not implemented. + cmd.arg("coverage").assert_success().stdout_eq(str![[r#" +... +╭-----------------+---------------+---------------+------------+---------------╮ +| File | % Lines | % Statements | % Branches | % Funcs | ++==============================================================================+ +| src/Counter.sol | 100.00% (8/8) | 100.00% (4/4) | N/A (0/0) | 100.00% (4/4) | +|-----------------+---------------+---------------+------------+---------------| +| Total | 100.00% (8/8) | 100.00% (4/4) | N/A (0/0) | 100.00% (4/4) | +╰-----------------+---------------+---------------+------------+---------------╯ +... +"#]]); +} + +// +// Test BRDA hit values follow LCOV spec: "-" when line never executed, "0" when line hit but +// branch not taken. +#[forgetest] +fn brda_lcov_consistency(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.add_source( + "Counter.sol", + r#" +contract Counter { + uint256 public number; + + function setPositive(uint256 newNumber) public { + if (newNumber > 0) { + number = newNumber; + } else { + number = 1; + } + } + + function neverCalled(uint256 x) public { + if (x > 100) { + number = x; + } else { + number = 100; + } + } +} + "#, + ); + + prj.add_source( + "Counter.t.sol", + r#" +import "./test.sol"; +import "./Counter.sol"; + +contract CounterTest is DSTest { + function test_only_positive_branch() public { + Counter counter = new Counter(); + counter.setPositive(42); + counter.setPositive(100); + } +} + "#, + ); + + // Verify BRDA values: + // - BRDA:8,0,0,2 - if branch taken 2 times + // - BRDA:8,0,1,0 - else branch NOT taken but line was hit (outputs "0", not "-") + // - BRDA:16,1,0,- - if branch NOT taken AND line never executed (outputs "-") + // - BRDA:16,1,1,- - else branch NOT taken AND line never executed (outputs "-") + assert_lcov( + cmd.arg("coverage"), + str![[r#" +TN: +SF:src/Counter.sol +DA:7,2 +FN:7,Counter.setPositive +FNDA:2,Counter.setPositive +DA:8,2 +BRDA:8,0,0,2 +BRDA:8,0,1,0 +DA:9,2 +DA:11,0 +DA:15,0 +FN:15,Counter.neverCalled +FNDA:0,Counter.neverCalled +DA:16,0 +BRDA:16,1,0,- +BRDA:16,1,1,- +DA:17,0 +DA:19,0 +FNF:2 +FNH:1 +LF:8 +LH:3 +BRF:4 +BRH:1 +end_of_record + +"#]], + ); +} + +// A false condition still executes its line, even when the true body is the first +// coverage item on that line. Unreached nested conditions must remain uncovered. +#[forgetest] +fn lcov_false_condition_lines(prj: _, cmd: _) { + prj.add_source( + "Branches.sol", + r#"contract Branches { + uint256 public value; + function run(bool outer, bool inner) public { + if (outer) { value = 6; + if (inner) { + value = 1; + } else { + value = 2; + } + } else { + value = 3; + } + } +} +"#, + ); + prj.add_source( + "Branches.t.sol", + r#" +import "./Branches.sol"; +contract BranchesTest { + function test_false_conditions() public { + Branches branches = new Branches(); + branches.run(false, true); + require(branches.value() == 3); + } +} +"#, + ); + assert_lcov( + cmd.arg("coverage"), + str![[r#" +TN: +SF:src/Branches.sol +DA:5,1 +FN:5,Branches.run +FNDA:1,Branches.run +DA:6,1 +BRDA:6,0,0,0 +BRDA:6,0,1,1 +DA:7,0 +BRDA:7,1,0,- +BRDA:7,1,1,- +DA:8,0 +DA:10,0 +DA:13,1 +FNF:1 +FNH:1 +LF:6 +LH:3 +BRF:4 +BRH:1 +end_of_record + +"#]], + ); +} + +// Keep no-else conditions snapshot-only until implicit false branches are reported: +// strict genhtml validation rejects hit lines with no evaluated branches. +#[forgetest] +fn lcov_false_condition_lines_without_else(prj: _, cmd: _) { + prj.add_source( + "Branches.sol", + r#"contract Branches { + uint256 public value; + function run(bool condition) public { + if (condition) value = 4; + if (condition) + { + value = 5; + } + } +} +"#, + ); + prj.add_source( + "Branches.t.sol", + r#" +import "./Branches.sol"; +contract BranchesTest { + function test_false_conditions() public { + Branches branches = new Branches(); + branches.run(false); + require(branches.value() == 0); + } +} +"#, + ); + cmd.args(["coverage", "--report=lcov", "--report-file"]).assert_file(str![[r#" +TN: +SF:src/Branches.sol +DA:5,1 +FN:5,Branches.run +FNDA:1,Branches.run +DA:6,1 +BRDA:6,0,0,0 +DA:7,1 +BRDA:7,1,0,0 +DA:9,0 +FNF:1 +FNH:1 +LF:4 +LH:3 +BRF:2 +BRH:0 +end_of_record + +"#]]); +} + +// A hit assembly condition must include the outcome that skips its body, even without via-IR. +#[forgetest] +fn yul_if_lcov(prj: _, cmd: _) { + prj.add_source( + "Guard.sol", + r#" +contract Guard { + function skipped(uint256 success) external pure returns (uint256) { + assembly { if iszero(success) { revert(0, 0) } } + return success; + } + + function taken(uint256 x) external pure returns (uint256 result) { + assembly { if x { result := 1 } } + } + + function mixed(uint256 x) external pure returns (uint256 result) { + assembly { if x { result := 1 } } + } + + function never(uint256 x) external pure returns (uint256 result) { + assembly { if x { result := 1 } } + } +} +"#, + ); + prj.add_test( + "Guard.t.sol", + r#" +import "../src/Guard.sol"; +contract GuardTest { + function testPaths() public { + Guard guard = new Guard(); + require(guard.skipped(1) == 1); + require(guard.taken(1) == 1); + require(guard.mixed(0) == 0); + require(guard.mixed(1) == 1); + require(guard.mixed(2) == 1); + } +} +"#, + ); + assert_lcov( + cmd.arg("coverage"), + str![[r#" +TN: +SF:src/Guard.sol +DA:5,1 +FN:5,Guard.skipped +FNDA:1,Guard.skipped +DA:6,1 +BRDA:6,0,0,0 +BRDA:6,0,1,1 +DA:7,1 +DA:10,1 +FN:10,Guard.taken +FNDA:1,Guard.taken +DA:11,1 +BRDA:11,1,0,1 +BRDA:11,1,1,0 +DA:14,3 +FN:14,Guard.mixed +FNDA:3,Guard.mixed +DA:15,3 +BRDA:15,2,0,2 +BRDA:15,2,1,1 +DA:18,0 +FN:18,Guard.never +FNDA:0,Guard.never +DA:19,0 +BRDA:19,3,0,- +BRDA:19,3,1,- +FNF:4 +FNH:3 +LF:9 +LH:7 +BRF:8 +BRH:4 +end_of_record + +"#]], + ); +} + +// Test that coverage files are written even when tests fail. +#[forgetest] +fn coverage_with_failing_tests(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.add_source( + "Counter.sol", + r#" +contract Counter { + uint256 public number; + + function setNumber(uint256 newNumber) public { + number = newNumber; + } + + function increment() public { + number++; + } +} + "#, + ); + + prj.add_source( + "CounterTest.sol", + r#" +import "./test.sol"; +import {Counter} from "./Counter.sol"; + +contract CounterTest is DSTest { + Counter public counter; + + function setUp() public { + counter = new Counter(); + counter.setNumber(0); + } + + function test_Increment() public { + counter.increment(); + assertEq(counter.number(), 1); + } + + function test_FailingTest() public { + counter.increment(); + // This assertion will fail + assertEq(counter.number(), 999); + } +} + "#, + ); + + // Run coverage - this should exit with error code 1 due to failing test, + // but the lcov file should still be written. + cmd.arg("coverage").args(["--report=lcov"]).assert_failure(); + + // Verify that the lcov.info file was created despite test failure + let lcov = prj.root().join("lcov.info"); + assert!(lcov.exists(), "lcov.info should be created even when tests fail"); + + // Verify the coverage data is valid and includes the counter contract + let lcov_content = std::fs::read_to_string(&lcov).unwrap(); + assert!(lcov_content.contains("SF:src/Counter.sol"), "Coverage should include Counter.sol"); + assert!(lcov_content.contains("FN:"), "Coverage should include function data"); + assert!(lcov_content.contains("DA:"), "Coverage should include line hit data"); +} + +#[forgetest_init] +fn coverage_rejects_mutation_mode_before_compile(prj: _, cmd: _) { + prj.add_source( + "Broken.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +contract Broken { + function broken() public pure returns (uint256) { + return; + } +} +"#, + ); + + let output = cmd.args(["coverage", "--mutate", "src/Broken.sol"]).assert_failure(); + let stderr = output.get_output().stderr_lossy(); + + assert!( + stderr.contains("`--mutate` cannot be combined with: coverage"), + "unexpected stderr:\n{stderr}" + ); + assert!( + !stderr.contains("Compiler run failed"), + "mutation/coverage conflict should be reported before compile errors:\n{stderr}" + ); +} + +#[forgetest_init] +fn coverage_rejects_test_only_modes_before_compile(prj: _, cmd: _) { + prj.add_source( + "Broken.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +contract Broken { + function broken() public pure returns (uint256) { + return; + } +} +"#, + ); + + for args in [ + &["--json"][..], + &["--junit"], + &["--list"], + &["--debug"], + &["--flamegraph"], + &["--flamechart"], + &["--evm-profile"], + &["--showmap-out", "showmap"], + &["--brutalize"], + &["--replay-symbolic-artifact", "counterexample.json"], + &["--watch", "--json"], + ] { + let output = cmd.forge_fuse().arg("coverage").args(args).assert_failure(); + let stderr = output.get_output().stderr_lossy(); + + assert!( + stderr.contains("`forge coverage` cannot be combined with:"), + "unexpected stderr for {args:?}:\n{stderr}" + ); + assert!( + !stderr.contains("Compiler run failed"), + "coverage conflict should be reported before compile errors for {args:?}:\n{stderr}" + ); + } +} + +#[forgetest_init] +fn coverage_match_path_compiles_selected_tests(prj: _, cmd: _) { + prj.add_test( + "Shared.t.sol", + r#" +abstract contract SharedTest { + function sharedValue() internal pure returns (uint256) { + return 1; + } +} +"#, + ); + prj.add_test( + "Selected.t.sol", + r#" +import {SharedTest} from "./Shared.t.sol"; + +contract SelectedTest is SharedTest { + function testSelected() public pure { + require(sharedValue() == 1); + } +} +"#, + ); + prj.add_test( + "Broken.t.sol", + r#" +contract BrokenTest { + function broken() public pure returns (uint256) { + return; + } +} +"#, + ); + prj.add_test( + "Unselected.t.sol", + r#" +contract UnselectedTest { + function testUnselected() public {} +} +"#, + ); + prj.add_script( + "Report.s.sol", + r#" +contract ReportScript { + function run() public {} +} +"#, + ); + + let output = cmd + .args(["coverage", "--match-path", "test/Selected.t.sol"]) + .assert_success() + .get_output() + .stdout_lossy(); + assert!(output.contains("testSelected()"), "selected test did not run:\n{output}"); + assert!( + output.contains("script/Report.s.sol"), + "scripts should remain in path-filtered coverage reports:\n{output}" + ); + assert!( + !output.contains("test/Unselected.t.sol"), + "unselected tests should be omitted from coverage reports:\n{output}" + ); +} + +// +#[forgetest] +fn coverage_separates_compiler_builds(prj: _, cmd: _) { + prj.update_config(|config| { + config.additional_compiler_profiles = vec![SettingsOverrides { + name: "via-ir".to_owned(), + via_ir: Some(true), + evm_version: None, + optimizer: None, + optimizer_runs: None, + bytecode_hash: None, + }]; + config.compilation_restrictions = vec![CompilationRestrictions { + paths: "src/A.sol".parse().unwrap(), + version: None, + via_ir: Some(true), + bytecode_hash: None, + min_optimizer_runs: None, + optimizer_runs: None, + max_optimizer_runs: None, + min_evm_version: None, + evm_version: None, + max_evm_version: None, + }]; + }); + + prj.add_source( + "A.sol", + "contract A { function value() external pure returns (uint256) { return 1; } }", + ); + prj.add_source( + "B.sol", + "contract B { function value() external pure returns (uint256) { return 2; } }", + ); + prj.add_test( + "Coverage.t.sol", + r#" +import {A} from "../src/A.sol"; +import {B} from "../src/B.sol"; +contract CoverageTest { + function testCoverage() public { + require(new A().value() == 1); + require(new B().value() == 2); + } +} +"#, + ); + + assert_lcov( + cmd.arg("coverage"), + str![[r#" +TN: +SF:src/A.sol +DA:3,1 +FN:3,A.value +FNDA:1,A.value FNF:1 FNH:1 -LF:3 -LH:3 -BRF:1 -BRH:1 +LF:1 +LH:1 +BRF:0 +BRH:0 +end_of_record +TN: +SF:src/B.sol +DA:3,1 +FN:3,B.value +FNDA:1,B.value +FNF:1 +FNH:1 +LF:1 +LH:1 +BRF:0 +BRH:0 end_of_record "#]], ); -}); +} -#[track_caller] -fn assert_lcov(cmd: &mut TestCommand, data: impl IntoData) { - cmd.args(["--report=lcov", "--report-file"]).assert_file(data.into_data()); +// A file-level (free) function declared after a contract must be attributed at file level, not +// leaked into the earlier contract's scope, and its deferred call must carry the same scope. +// Regression for https://github.com/foundry-rs/foundry/issues/16085 +#[forgetest] +fn coverage_reports_free_functions(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.add_source( + "Mixed.sol", + r#" +contract Counter { + function bump(uint256 x) public pure returns (uint256) { + return triple(x); + } +} + +function triple(uint256 x) pure returns (uint256) { + return x * 3; +} +"#, + ); + prj.add_source( + "MixedTest.sol", + r#" +import "./test.sol"; +import {Counter} from "./Mixed.sol"; + +contract MixedTest is DSTest { + function testMixed() public { + Counter c = new Counter(); + require(c.bump(2) == 6); + } +} +"#, + ); + + cmd.args(["coverage", "--report=lcov", "--report=attribution", "--lcov-version=2"]) + .assert_success(); + let lcov = std::fs::read_to_string(prj.root().join("lcov.info")).unwrap(); + let attribution = + std::fs::read_to_string(prj.root().join("coverage-attribution.json")).unwrap(); + let attribution: Value = serde_json::from_str(&attribution).unwrap(); + + assert!(lcov.contains("SF:src/Mixed.sol"), "coverage must include Mixed.sol:\n{lcov}"); + // The contract method keeps its contract scope. + assert!( + lcov.lines().any(|l| l.starts_with("FN") && l.ends_with(",Counter.bump")), + "contract function must be scoped as `Counter.bump`:\n{lcov}" + ); + // The free function is attributed at file level: bare `triple`, with no contract prefix + // (`Counter.triple`) and no malformed empty scope (`.triple`). + assert!( + lcov.lines().any(|l| l.starts_with("FN") && l.ends_with(",triple")), + "free function must be attributed as bare `triple`:\n{lcov}" + ); + assert!( + !lcov.contains(",Counter.triple"), + "free function must not leak the contract scope:\n{lcov}" + ); + assert!( + !lcov.contains(",.triple"), + "free function must not have a malformed empty scope:\n{lcov}" + ); + // Both functions were executed, so both record a nonzero hit count. + assert!( + lcov.lines() + .any(|l| l.starts_with("FNDA:") && !l.starts_with("FNDA:0,") && l.ends_with(",triple")), + "free function must record its hits:\n{lcov}" + ); + assert!( + lcov.lines().any(|l| l.starts_with("FNDA:") + && !l.starts_with("FNDA:0,") + && l.ends_with(",Counter.bump")), + "contract function must record its hits:\n{lcov}" + ); + let covered = attribution["tests"][0]["covered"].as_array().unwrap(); + assert_eq!( + covered + .iter() + .filter(|item| { + item["source"] == "src/Mixed.sol" + && item["contract"] == "Counter" + && item["kind"] == "statement" + }) + .count(), + 2, + "the return statement and its deferred call must retain the contract scope:\n{attribution}" + ); +} + +// A file whose only code is a file-level (free) function must still be reported, with the +// function named at file level. Before the fix such a file was dropped from coverage entirely. +// Regression for https://github.com/foundry-rs/foundry/issues/16085 +#[forgetest] +fn coverage_reports_free_only_functions(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.add_source( + "Free.sol", + r#" +function double(uint256 x) pure returns (uint256) { + return x * 2; +} +"#, + ); + prj.add_source( + "FreeTest.sol", + r#" +import "./test.sol"; +import {double} from "./Free.sol"; + +contract FreeTest is DSTest { + function testDouble() public { + require(double(3) == 6); + } +} +"#, + ); + + cmd.args(["coverage", "--report=lcov", "--lcov-version=2"]).assert_success(); + let lcov = std::fs::read_to_string(prj.root().join("lcov.info")).unwrap(); + + // Inspect the Free.sol record in isolation so FNF/FNH counts are unambiguous. + let free_block = lcov + .split("SF:") + .find(|b| b.starts_with("src/Free.sol")) + .expect("free-function-only file must have a coverage record"); + // The function is named `double`, not `.double`. + assert!( + free_block.lines().any(|l| l.starts_with("FN") && l.ends_with(",double")), + "free function must be reported as bare `double`:\n{free_block}" + ); + assert!(!free_block.contains(".double"), "no leading-dot empty scope:\n{free_block}"); + // Exactly one function, found and hit. + assert!(free_block.contains("FNF:1"), "exactly one function found:\n{free_block}"); + assert!(free_block.contains("FNH:1"), "exactly one function hit:\n{free_block}"); +} + +// A contract method and a file-level function that share a name live in different scopes, so +// neither should be renamed as if it were an overload of the other. +#[forgetest] +fn coverage_disambiguates_functions_by_scope(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.add_source( + "Same.sol", + r#" +contract Lib { + function value() public pure returns (uint256) { + return 1; + } +} + +function value() pure returns (uint256) { + return 2; +} +"#, + ); + prj.add_source( + "SameTest.sol", + r#" +import "./test.sol"; +import {Lib, value} from "./Same.sol"; + +contract SameTest is DSTest { + function testValue() public { + Lib lib = new Lib(); + require(lib.value() == 1); + require(value() == 2); + } +} +"#, + ); + + cmd.args(["coverage", "--report=lcov", "--lcov-version=2"]).assert_success(); + let lcov = std::fs::read_to_string(prj.root().join("lcov.info")).unwrap(); + + // The contract method stays scoped; the free function stays bare. + assert!( + lcov.lines().any(|l| l.starts_with("FN") && l.ends_with(",Lib.value")), + "contract method must be `Lib.value`:\n{lcov}" + ); + assert!( + lcov.lines().any(|l| l.starts_with("FN") && l.ends_with(",value")), + "free function must be bare `value`:\n{lcov}" + ); + // Neither is a real overload, so neither gets a `.0` / `.1` disambiguation suffix. + assert!( + !lcov.contains("value.0") && !lcov.contains("value.1"), + "same-named functions in different scopes must not be disambiguated as overloads:\n{lcov}" + ); +} + +#[test] +fn coverage_help_renders_notes() { + let help = CoverageArgs::command().render_long_help().to_string(); + + assert!(help.contains(concat!( + "Source attribution:\n", + " Coverage follows compiler source maps. Inherited modifier code is reported under" + ))); + assert!(help.contains("use `--include-libs` to include their coverage.")); + assert!(help.contains(concat!( + "Compatibility:\n", + " `forge coverage` supports test filters and `--watch`, but not test-only output" + ))); + assert!(!help.contains("\\n")); +} + +// Coverage must retain enum bounds and artifact paths across source edits and filtered requests. +#[forgetest] +fn coverage_cache_preserves_reports_and_analysis(prj: _, cmd: _) { + for dynamic_test_linking in [false, true] { + prj.clear_cache_dir(); + prj.update_config(|config| { + config.dynamic_test_linking = dynamic_test_linking; + config.fuzz.runs = 8; + }); + prj.add_source( + "A.sol", + "contract A { function value() external pure returns (uint256) { return 1; } }", + ); + prj.add_source("B.sol", "contract B { enum E { X, Y } function value(E e) external pure returns (uint256) { return uint256(e); } }"); + prj.add_source( + "Unused.sol", + "function unused(uint256 x) pure returns (uint256) { return x + 1; }", + ); + prj.add_test("A.t.sol", r#" +import {A} from "../src/A.sol"; +interface Vm { + function getArtifactPathByCode(bytes calldata code) external view returns (string memory); + function projectRoot() external view returns (string memory); +} +contract ATest { + function testValue() public { + require(new A().value() > 0); + } + function testArtifactPath() public view { + Vm vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + require(keccak256(bytes(vm.getArtifactPathByCode(type(A).creationCode))) == keccak256(bytes(string.concat(vm.projectRoot(), "/out/A.sol/A.json")))); + } +} +"#); + prj.add_test( + "B.t.sol", + r#" +import {B} from "../src/B.sol"; +contract BTest { + function testEnum(B.E e) public { require(new B().value(e) == uint256(e)); } +} +"#, + ); + for stage in 0..6 { + if stage == 1 { + prj.add_source( + "A.sol", + "contract A { function value() external pure returns (uint256) { return 2; } }", + ); + } else if stage == 2 { + fs::remove_file(prj.root().join("src/Unused.sol")).unwrap(); + } else if stage == 5 { + prj.update_config(|config| config.evm_version = "paris".parse().unwrap()); + } + let mut reference = None::; + for cached in [false, true, true] { + prj.update_config(|config| config.cache = cached); + cmd.forge_fuse().args(["coverage", "--report=lcov"]); + if stage == 3 { + cmd.args(["--match-path", "test/A.t.sol"]); + } + cmd.assert_success(); + let report = fs::read_to_string(prj.root().join("lcov.info")).unwrap(); + if let Some(expected) = &reference { + foundry_test_utils::snapbox::assert_data_eq!(report, expected.clone()); + } else { + reference = Some(report); + } + } + } + } +} + +// The compiler can prune a build containing only free functions because it has no contract +// artifacts. A warm coverage run must still report that source. +#[forgetest] +fn coverage_cache_preserves_free_only_builds(prj: _, cmd: _) { + prj.update_config(|config| { + config.additional_compiler_profiles = vec![SettingsOverrides { + name: "via-ir".to_owned(), + via_ir: Some(true), + evm_version: None, + optimizer: None, + optimizer_runs: None, + bytecode_hash: None, + }]; + config.compilation_restrictions = vec![CompilationRestrictions { + paths: "src/Free.sol".parse().unwrap(), + version: None, + via_ir: Some(true), + bytecode_hash: None, + min_optimizer_runs: None, + optimizer_runs: None, + max_optimizer_runs: None, + min_evm_version: None, + evm_version: None, + max_evm_version: None, + }]; + }); + prj.add_source("Free.sol", "function unused() pure returns (uint256) { return 42; }"); + prj.add_test("Coverage.t.sol", "contract CoverageTest { function testPass() public {} }"); + let mut reference = None::; + for cached in [false, true, true] { + prj.update_config(|config| config.cache = cached); + cmd.forge_fuse().args(["coverage", "--report=lcov"]).assert_success(); + let report = fs::read_to_string(prj.root().join("lcov.info")).unwrap(); + if let Some(expected) = &reference { + foundry_test_utils::snapbox::assert_data_eq!(report, expected.clone()); + } else { + reference = Some(report); + } + } +} + +#[forgetest] +fn coverage_cache_isolated_and_cleaned(prj: _, cmd: _) { + prj.update_config(|config| { + config.cache_path = "custom-cache".into(); + config.out = "custom-out".into(); + config.build_info = true; + config.build_info_path = Some("custom-build-info".into()); + }); + prj.add_source( + "A.sol", + "contract A { function value() external pure returns (uint256) { return 1; } }", + ); + prj.add_test("A.t.sol", "import {A} from '../src/A.sol'; contract ATest { function testValue() public { require(new A().value() == 1); } }"); + cmd.forge_fuse().arg("build").assert_success(); + let artifact = prj.root().join("custom-out/A.sol/A.json"); + let original = fs::read(&artifact).unwrap(); + let cache = prj.root().join("custom-cache/coverage"); + for _ in 0..2 { + cmd.forge_fuse().arg("coverage").assert_success(); + assert_eq!(fs::read(&artifact).unwrap(), original); + assert!(cache.is_dir()); + } + cmd.forge_fuse().arg("coverage").assert_success().stdout_eq(str![[r#" +No files changed, compilation skipped +... +"#]]); + let marker = cache.join("force-marker"); + fs::write(&marker, "old cache").unwrap(); + cmd.forge_fuse().args(["coverage", "--force"]).assert_success(); + assert!(!marker.exists()); + assert!(!artifact.exists()); + cmd.forge_fuse().arg("coverage").assert_success().stdout_eq(str![[r#" +No files changed, compilation skipped +... +"#]]); + cmd.forge_fuse().arg("clean").assert_success(); + assert!(!cache.exists()); + prj.update_config(|config| config.cache = false); + cmd.forge_fuse().arg("coverage").assert_success(); + assert!(!cache.exists()); +} + +#[forgetest] +fn coverage_cache_respects_warning_denial(prj: _, cmd: _) { + prj.add_test( + "Warning.t.sol", + "contract WarningTest { function testWarning() public { uint256 unused = 1; } }", + ); + cmd.forge_fuse().arg("coverage").assert_success(); + // Warning denial must compile again because cached artifacts do not retain diagnostics. + cmd.forge_fuse().args(["coverage", "--deny", "warnings"]).assert_failure(); +} + +#[forgetest] +fn coverage_cache_preserves_artifact_names_after_deletion(prj: _, cmd: _) { + prj.update_config(|config| config.out = "custom-out".into()); + prj.add_source( + "A.sol", + "contract A { function value() public pure returns (uint256) { return 1; } }", + ); + prj.add_source( + "nested/A.sol", + "contract A { function value() public pure returns (uint256) { return 2; } }", + ); + for (stage, expected) in ["nested/A.sol/A.json", "A.sol/A.json"].into_iter().enumerate() { + if stage == 1 { + fs::remove_file(prj.root().join("src/A.sol")).unwrap(); + } + prj.add_test("Names.t.sol", &format!(r#" +import {{A}} from "../src/nested/A.sol"; +interface Vm {{ + function getArtifactPathByCode(bytes calldata code) external view returns (string memory); + function getArtifactPathByDeployedCode(bytes calldata code) external view returns (string memory); + function projectRoot() external view returns (string memory); +}} +contract NamesTest {{ + function testNames() public view {{ + Vm vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + bytes32 expected = keccak256(bytes(string.concat(vm.projectRoot(), "/custom-out/{expected}"))); + require(keccak256(bytes(vm.getArtifactPathByCode(type(A).creationCode))) == expected); + require(keccak256(bytes(vm.getArtifactPathByDeployedCode(type(A).runtimeCode))) == expected); + }} +}} +"#)); + for cached in [false, true, true] { + prj.update_config(|config| config.cache = cached); + cmd.forge_fuse().arg("coverage").assert_success(); + } + } +} + +// Read-only restored cache files must not turn successful coverage into an error. +#[forgetest] +fn coverage_cache_read_only_files(prj: _, cmd: _) { + prj.add_source( + "A.sol", + "contract A { function value() public pure returns (uint256) { return 1; } }", + ); + prj.add_test("A.t.sol", "import {A} from '../src/A.sol'; contract ATest { function testValue() public { require(new A().value() > 0); } }"); + cmd.forge_fuse().args(["coverage", "--report=lcov"]).assert_success(); + let reference = fs::read_to_string(prj.root().join("lcov.info")).unwrap(); + let cache = prj.root().join("cache/coverage"); + let compiler_cache = cache.join(SOLIDITY_FILES_CACHE_FILENAME); + let permissions = std::fs::metadata(&compiler_cache).unwrap().permissions(); + let mut read_only = permissions.clone(); + read_only.set_readonly(true); + std::fs::set_permissions(&compiler_cache, read_only).unwrap(); + let output = cmd.forge_fuse().args(["coverage", "--report=lcov"]).assert(); + std::fs::set_permissions(&compiler_cache, permissions).unwrap(); + output.success(); + foundry_test_utils::snapbox::assert_data_eq!( + fs::read_to_string(prj.root().join("lcov.info")).unwrap(), + reference.clone() + ); + + let marker = cache.join(Config::COVERAGE_CACHE_MARKER); + for edited in [false, true] { + if edited { + prj.add_source( + "A.sol", + "contract A { function value() public pure returns (uint256) { return 2; } }", + ); + } + let permissions = std::fs::metadata(&marker).unwrap().permissions(); + let mut read_only = permissions.clone(); + read_only.set_readonly(true); + std::fs::set_permissions(&marker, read_only).unwrap(); + let output = cmd.forge_fuse().args(["coverage", "--report=lcov"]).assert(); + std::fs::set_permissions(&marker, permissions).unwrap(); + output.success(); + foundry_test_utils::snapbox::assert_data_eq!( + fs::read_to_string(prj.root().join("lcov.info")).unwrap(), + reference.clone() + ); + } + // The failed reset must leave the cache invalidated by its old fingerprint, so the next + // writable run recompiles before subsequent runs can skip compilation. + cmd.forge_fuse().arg("coverage").assert_success(); + cmd.forge_fuse().arg("coverage").assert_success().stdout_eq(str![[r#" +No files changed, compilation skipped +... +"#]]); +} + +#[forgetest] +fn coverage_cache_clean_respects_lock(prj: _, cmd: _) { + prj.add_test("A.t.sol", "contract ATest { function testPass() public {} }"); + cmd.forge_fuse().arg("coverage").assert_success(); + let cache = prj.root().join("cache/coverage"); + let lock = Config::lock_coverage_cache(&cache).unwrap(); + cmd.forge_fuse().arg("clean").assert_success().stderr_eq(str![[r#" +Warning: failed to remove coverage cache [..]: [..] +"#]]); + assert!(cache.join(Config::COVERAGE_CACHE_MARKER).is_file()); + // Coverage also falls back while another operation holds the lock. + cmd.forge_fuse().arg("coverage").assert_success(); + drop(lock); + cmd.forge_fuse().arg("coverage").assert_success().stdout_eq(str![[r#" +No files changed, compilation skipped +... +"#]]); + cmd.forge_fuse().arg("clean").assert_success().stderr_eq(""); + assert!(!cache.exists()); +} + +#[forgetest] +fn coverage_cache_prunes_obsolete_files(prj: _, cmd: _) { + prj.update_config(|config| config.build_info = true); + prj.add_source("Removed.sol", "contract Removed {}"); + prj.add_test("A.t.sol", "contract ATest { function testPass() public {} }"); + cmd.forge_fuse().arg("coverage").assert_success(); + let cache = prj.root().join("cache/coverage"); + let mut builds = files_with_ext(&cache.join("build-info"), "json").collect::>(); + assert_eq!(builds.len(), 1); + for edited in [false, true] { + if edited { + prj.add_test( + "A.t.sol", + "contract ATest { function testPass() public {} function testOther() public {} }", + ); + } else { + fs::remove_file(prj.root().join("src/Removed.sol")).unwrap(); + } + cmd.forge_fuse().arg("coverage").assert_success(); + assert!(builds.iter().all(|path| !path.exists())); + assert!(!cache.join("artifacts/Removed.sol/Removed.json").exists()); + builds = files_with_ext(&cache.join("build-info"), "json").collect(); + assert_eq!(builds.len(), 1); + } +} + +// Only bytecode matching an artifact is credited to its source; constructor data may vary. +#[forgetest] +fn coverage_credits_only_exact_bytecode(prj: _, cmd: _) { + prj.add_source( + "Target.sol", + r#" +contract Target { + uint256 public value; + + constructor(uint256 x) { + value = x; + } + + function live() external pure returns (uint256) { + return 0x1111; + } +} +"#, + ); + prj.add_test( + "Target.t.sol", + r#" +import {Target} from "../src/Target.sol"; + +interface Vm { + function etch(address target, bytes calldata newRuntimeBytecode) external; +} + +contract TargetTest { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + // Patches `PUSH2 0x1111` to `PUSH2 0x2222`. + function patch(bytes memory code) internal pure returns (bytes memory) { + for (uint256 i; i + 2 < code.length; ++i) { + if (code[i] == 0x61 && code[i + 1] == 0x11 && code[i + 2] == 0x11) { + code[i + 1] = 0x22; + code[i + 2] = 0x22; + return code; + } + } + revert("constant not found"); + } + + function deploy(bytes memory initCode) internal returns (Target created) { + assembly { + created := create(0, add(initCode, 0x20), mload(initCode)) + } + } + + function testModifiedCode() external { + address etched = address(0x1234); + vm.etch(etched, patch(type(Target).runtimeCode)); + require(Target(etched).live() == 0x2222); + + Target created = + deploy(patch(abi.encodePacked(type(Target).creationCode, abi.encode(uint256(1))))); + require(created.value() == 1 && created.live() == 0x2222); + } + + function testConstructorTrailingData() external { + Target created = deploy( + abi.encodePacked(type(Target).creationCode, abi.encode(uint256(42)), hex"ff") + ); + require(created.value() == 42); + } +} +"#, + ); + + cmd.args(["coverage", "--report=summary", "--mt", "testModifiedCode"]) + .assert_success() + .stdout_eq(str![[r#" +... +╭----------------+-------------+--------------+------------+-------------╮ +| File | % Lines | % Statements | % Branches | % Funcs | ++========================================================================+ +| src/Target.sol | 0.00% (0/4) | 0.00% (0/2) | N/A (0/0) | 0.00% (0/2) | +|----------------+-------------+--------------+------------+-------------| +| Total | 0.00% (0/4) | 0.00% (0/2) | N/A (0/0) | 0.00% (0/2) | +╰----------------+-------------+--------------+------------+-------------╯ + +"#]]) + .stderr_eq(str![[r#" +... +Warning: excluding executed [..] code from coverage: it resembles `src/Target.sol:Target` but does not match it exactly +Warning: excluding executed [..] code from coverage: it resembles `src/Target.sol:Target` but does not match it exactly + +"#]]); + + cmd.forge_fuse() + .args(["coverage", "--report=summary", "--mt", "testConstructorTrailingData"]) + .assert_success() + .stdout_eq(str![[r#" +... +╭----------------+--------------+--------------+------------+--------------╮ +| File | % Lines | % Statements | % Branches | % Funcs | ++==========================================================================+ +| src/Target.sol | 50.00% (2/4) | 50.00% (1/2) | N/A (0/0) | 50.00% (1/2) | +|----------------+--------------+--------------+------------+--------------| +| Total | 50.00% (2/4) | 50.00% (1/2) | N/A (0/0) | 50.00% (1/2) | +╰----------------+--------------+--------------+------------+--------------╯ + +"#]]); } diff --git a/crates/forge/tests/cli/create.rs b/crates/forge/tests/cli/create.rs index dd66694072134..2fc11408035cf 100644 --- a/crates/forge/tests/cli/create.rs +++ b/crates/forge/tests/cli/create.rs @@ -1,137 +1,31 @@ //! Contains various tests for checking the `forge create` subcommand -use crate::{ - constants::*, - utils::{self, EnvExternalities}, -}; -use alloy_primitives::{Address, hex}; +use crate::constants::*; +use alloy_primitives::hex; use anvil::{NodeConfig, spawn}; -use foundry_compilers::artifacts::{BytecodeHash, remappings::Remapping}; -use foundry_test_utils::{ - forgetest, forgetest_async, - snapbox::IntoData, - str, - util::{OutputExt, TestCommand, TestProject}, -}; -use std::str::FromStr; - -/// This will insert _dummy_ contract that uses a library -/// -/// **NOTE** This is intended to be linked against a random address and won't actually work. The -/// purpose of this is _only_ to make sure we can deploy contracts linked against addresses. -/// -/// This will create a library `remapping/MyLib.sol:MyLib` -/// -/// returns the contract argument for the create command -fn setup_with_simple_remapping(prj: &TestProject) -> String { - // explicitly set remapping and libraries - prj.update_config(|config| { - config.remappings = vec![Remapping::from_str("remapping/=lib/remapping/").unwrap().into()]; - config.libraries = vec![format!("remapping/MyLib.sol:MyLib:{:?}", Address::random())]; - }); - - prj.add_source( - "LinkTest", - r#" -import "remapping/MyLib.sol"; -contract LinkTest { - function foo() public returns (uint256) { - return MyLib.foobar(1); - } -} -"#, - ) - .unwrap(); - - prj.add_lib( - "remapping/MyLib", - r" -library MyLib { - function foobar(uint256 a) public view returns (uint256) { - return a * 100; - } -} -", - ) - .unwrap(); - - "src/LinkTest.sol:LinkTest".to_string() -} - -fn setup_oracle(prj: &TestProject) -> String { - prj.update_config(|c| { - c.libraries = vec![format!( - "./src/libraries/ChainlinkTWAP.sol:ChainlinkTWAP:{:?}", - Address::random() - )]; - }); - - prj.add_source( - "Contract", - r#" -import {ChainlinkTWAP} from "./libraries/ChainlinkTWAP.sol"; -contract Contract { - function getPrice() public view returns (int latest) { - latest = ChainlinkTWAP.getLatestPrice(0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE); - } -} -"#, - ) - .unwrap(); - - prj.add_source( - "libraries/ChainlinkTWAP", - r" -library ChainlinkTWAP { - function getLatestPrice(address base) public view returns (int256) { - return 0; - } -} -", - ) - .unwrap(); +use foundry_compilers::artifacts::BytecodeHash; +use foundry_test_utils::{forgetest, snapbox::IntoData, str, util::OutputExt}; +use std::{fs, time::Duration}; - "src/Contract.sol:Contract".to_string() -} +#[forgetest] +fn create_rejects_unsupported_remote_sponsor(cmd: _) { + cmd.args([ + "create", + "src/Counter.sol:Counter", + "--sponsor-url", + "https://sponsor.tempo.xyz/tp_test", + ]) + .assert_failure() + .stderr_eq(str![[r#" +Error: --sponsor-url is not supported by forge create; use --tempo.sponsor with --tempo.sponsor-signer or --tempo.sponsor-sig -/// configures the `TestProject` with the given closure and calls the `forge create` command -fn create_on_chain(info: Option, prj: TestProject, mut cmd: TestCommand, f: F) -where - F: FnOnce(&TestProject) -> String, -{ - if let Some(info) = info { - let contract_path = f(&prj); - - let output = cmd - .arg("create") - .args(info.create_args()) - .arg(contract_path) - .assert_success() - .get_output() - .stdout_lossy(); - let _address = utils::parse_deployed_address(output.as_str()) - .unwrap_or_else(|| panic!("Failed to parse deployer {output}")); - } +"#]]); } -// tests `forge` create on goerli if correct env vars are set -forgetest!(can_create_simple_on_goerli, |prj, cmd| { - create_on_chain(EnvExternalities::goerli(), prj, cmd, setup_with_simple_remapping); -}); - -// tests `forge` create on goerli if correct env vars are set -forgetest!(can_create_oracle_on_goerli, |prj, cmd| { - create_on_chain(EnvExternalities::goerli(), prj, cmd, setup_oracle); -}); - -// tests `forge` create on mumbai if correct env vars are set -forgetest!(can_create_oracle_on_mumbai, |prj, cmd| { - create_on_chain(EnvExternalities::mumbai(), prj, cmd, setup_oracle); -}); - // tests that we can deploy the template contract -forgetest_async!(can_create_template_contract, |prj, cmd| { - foundry_test_utils::util::initialize(prj.root()); +#[forgetest_init] +async fn can_create_template_contract(prj: _, cmd: _) { + prj.initialize_default_contracts(); let (_api, handle) = spawn(NodeConfig::test()).await; let rpc = handle.http_endpoint(); @@ -279,11 +173,240 @@ Deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3 [TX_HASH] "#]]); -}); +} + +// The deployment is only mined on the next interval tick. +#[forgetest_init] +async fn can_create_with_interval_mining(prj: _, cmd: _) { + prj.initialize_default_contracts(); + + let (_api, handle) = + spawn(NodeConfig::test().with_blocktime(Some(Duration::from_secs(1)))).await; + let rpc = handle.http_endpoint(); + let wallet = handle.dev_wallets().next().unwrap(); + let pk = hex::encode(wallet.credential().to_bytes()); + + cmd.forge_fuse() + .args([ + "create", + format!("./src/{TEMPLATE_CONTRACT}.sol:{TEMPLATE_CONTRACT}").as_str(), + "--rpc-url", + rpc.as_str(), + "--private-key", + pk.as_str(), + "--broadcast", + ]) + .assert_success() + .stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! +Deployer: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 +Deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3 +[TX_HASH] + +"#]]); +} + +#[forgetest_init] +async fn create_rejects_from_signer_mismatch(prj: _, cmd: _) { + prj.initialize_default_contracts(); + + let (api, handle) = spawn(NodeConfig::test()).await; + let rpc = handle.http_endpoint(); + let mut wallets = handle.dev_wallets(); + let from = wallets.next().unwrap(); + let from_pk = hex::encode(from.credential().to_bytes()); + let signer = wallets.next().unwrap(); + let signer_pk = hex::encode(signer.credential().to_bytes()); + let from = from.address().to_string(); + let contract = format!("./src/{TEMPLATE_CONTRACT}.sol:{TEMPLATE_CONTRACT}"); + let args = ["create", contract.as_str(), "--rpc-url", rpc.as_str(), "--broadcast"]; + + // A signer that does not match `--from` is rejected before anything is sent. + cmd.forge_fuse() + .args(args) + .args(["--from", &from, "--private-key", &signer_pk]) + .assert_failure() + .stderr_eq(str![[r#" +Error: the sender specified via `--from`/`ETH_FROM` (0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266) does not match the signer address (0x70997970C51812dc3A010C7d01b50e0d17dc79C8) + +"#]]); + assert!(api.transaction_count(signer.address(), None).await.unwrap().is_zero()); + + // A signer that matches `--from` deploys as usual. + cmd.forge_fuse() + .args(args) + .args(["--from", &from, "--private-key", &from_pk]) + .assert_success() + .stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! +Deployer: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 +Deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3 +[TX_HASH] + +"#]]); + + // Unlocked deployments are sent from `--from` and ignore the resolved signer. + cmd.forge_fuse() + .args(args) + .args(["--unlocked", "--from", &from, "--private-key", &signer_pk]) + .assert_success() + .stdout_eq(str![[r#" +No files changed, compilation skipped +Deployer: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 +Deployed to: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 +[TX_HASH] + +"#]]); + assert!(api.transaction_count(signer.address(), None).await.unwrap().is_zero()); +} + +#[forgetest_init] +async fn create_rejects_invalid_eip1559_fees_before_access_list(prj: _, cmd: _) { + prj.initialize_default_contracts(); + + let (_api, handle) = spawn(NodeConfig::test()).await; + let rpc = handle.http_endpoint(); + let wallet = handle.dev_wallets().next().unwrap(); + let pk = hex::encode(wallet.credential().to_bytes()); + + let stderr = cmd + .forge_fuse() + .args([ + "create", + format!("./src/{TEMPLATE_CONTRACT}.sol:{TEMPLATE_CONTRACT}").as_str(), + "--rpc-url", + rpc.as_str(), + "--private-key", + pk.as_str(), + "--access-list", + "--gas-price", + "1", + "--priority-gas-price", + "2", + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + + assert!( + stderr.contains("Error: max priority fee per gas (2) cannot exceed max fee per gas (1)"), + "{stderr}" + ); +} + +#[forgetest_init] +async fn create_resolves_tempo_expires_before_broadcast(prj: _, cmd: _) { + prj.initialize_default_contracts(); + + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let wallet = handle.dev_wallets().next().unwrap(); + let pk = hex::encode(wallet.credential().to_bytes()); + + // explicitly byte code hash for consistent checks + prj.update_config(|c| c.bytecode_hash = BytecodeHash::None); + + let assert = cmd + .forge_fuse() + .args([ + "create", + format!("./src/{TEMPLATE_CONTRACT}.sol:{TEMPLATE_CONTRACT}").as_str(), + "--rpc-url", + rpc.as_str(), + "--private-key", + pk.as_str(), + "--broadcast", + "--tempo.expires", + "30", + ]) + .assert_success(); + let output = assert.get_output(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!( + stderr.contains("Transaction expires at unix timestamp "), + "expected create to print resolved tempo expiry, got:\n{stderr}", + ); + assert!(stdout.contains("Deployed to:"), "{stdout}"); +} + +#[forgetest_init] +async fn create_broadcasts_with_local_tempo_sponsor(prj: _, cmd: _) { + prj.initialize_default_contracts(); + + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let wallets = handle.dev_wallets().take(2).collect::>(); + let sender_key = hex::encode(wallets[0].credential().to_bytes()); + let sponsor_key = + format!("private-key://{}", hex::encode_prefixed(wallets[1].credential().to_bytes())); + let sponsor = format!("{:?}", wallets[1].address()); + + prj.update_config(|config| config.bytecode_hash = BytecodeHash::None); + + let assert = cmd + .forge_fuse() + .args([ + "create", + format!("./src/{TEMPLATE_CONTRACT}.sol:{TEMPLATE_CONTRACT}").as_str(), + "--rpc-url", + &rpc, + "--private-key", + &sender_key, + "--broadcast", + "--tempo.fee-token", + "PathUSD", + "--tempo.sponsor", + &sponsor, + "--tempo.sponsor-signer", + &sponsor_key, + ]) + .assert_success(); + let output = assert.get_output(); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stdout.contains("Deployed to:"), "{stdout}"); + assert!(stderr.to_ascii_lowercase().contains(&format!("tempo sponsor: {sponsor}")), "{stderr}"); +} + +#[forgetest_init] +async fn create_rejects_tempo_access_key_before_broadcast(prj: _, cmd: _) { + prj.initialize_default_contracts(); + + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + + prj.update_config(|config| config.bytecode_hash = BytecodeHash::None); + let stderr = cmd + .forge_fuse() + .args([ + "create", + format!("./src/{TEMPLATE_CONTRACT}.sol:{TEMPLATE_CONTRACT}").as_str(), + "--rpc-url", + &rpc, + "--tempo.access-key", + "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", + "--tempo.root-account", + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "--broadcast", + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + + assert!(stderr.contains("Tempo access-key transactions cannot use CREATE"), "{stderr}"); +} // tests that we can deploy the template contract -forgetest_async!(can_create_using_unlocked, |prj, cmd| { - foundry_test_utils::util::initialize(prj.root()); +#[forgetest_init] +async fn can_create_using_unlocked(prj: _, cmd: _) { + prj.initialize_default_contracts(); let (_api, handle) = spawn(NodeConfig::test()).await; let rpc = handle.http_endpoint(); @@ -292,7 +415,32 @@ forgetest_async!(can_create_using_unlocked, |prj, cmd| { // explicitly byte code hash for consistent checks prj.update_config(|c| c.bytecode_hash = BytecodeHash::None); - cmd.forge_fuse().args([ + // A matching Tempo Accounts entry must not change an ordinary Ethereum deployment into a + // Tempo transaction. + let tempo_home = tempfile::tempdir().unwrap(); + let wallet_dir = tempo_home.path().join("wallet"); + fs::create_dir_all(&wallet_dir).unwrap(); + let store = serde_json::json!({ + "tempo-cli.store": { + "state": { + "activeAccount": 0, + "chainId": 31337, + "accounts": [{"address": format!("{dev:?}")}], + "accessKeys": [{ + "access": format!("{dev:?}"), + "address": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "chainId": 31337, + "keyType": "secp256k1", + "privateKey": "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", + }], + }, + }, + }); + fs::write(wallet_dir.join("store.json"), serde_json::to_vec(&store).unwrap()).unwrap(); + + cmd.forge_fuse(); + cmd.env("TEMPO_HOME", tempo_home.path()); + cmd.args([ "create", format!("./src/{TEMPLATE_CONTRACT}.sol:{TEMPLATE_CONTRACT}").as_str(), "--rpc-url", @@ -320,12 +468,11 @@ Deployed to: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 [TX_HASH] "#]]); -}); +} // tests that we can deploy with constructor args -forgetest_async!(can_create_with_constructor_args, |prj, cmd| { - foundry_test_utils::util::initialize(prj.root()); - +#[forgetest_init] +async fn can_create_with_constructor_args(prj: _, cmd: _) { let (_api, handle) = spawn(NodeConfig::test()).await; let rpc = handle.http_endpoint(); let wallet = handle.dev_wallets().next().unwrap(); @@ -345,8 +492,7 @@ contract ConstructorContract { } } "#, - ) - .unwrap(); + ); cmd.forge_fuse() .args([ @@ -383,8 +529,7 @@ contract TupleArrayConstructorContract { constructor(Point[] memory _points) {} } "#, - ) - .unwrap(); + ); cmd.forge_fuse() .args([ @@ -408,12 +553,11 @@ Deployed to: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 [TX_HASH] "#]]); -}); +} // -forgetest_async!(can_create_and_call, |prj, cmd| { - foundry_test_utils::util::initialize(prj.root()); - +#[forgetest_init] +async fn can_create_and_call(prj: _, cmd: _) { let (_api, handle) = spawn(NodeConfig::test()).await; let rpc = handle.http_endpoint(); let wallet = handle.dev_wallets().next().unwrap(); @@ -433,8 +577,7 @@ contract UniswapV2Swap { } "#, - ) - .unwrap(); + ); cmd.forge_fuse() .args([ @@ -462,10 +605,11 @@ Deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3 [TX_HASH] "#]]); -}); +} // -forgetest_async!(should_err_if_no_bytecode, |prj, cmd| { +#[forgetest] +async fn should_err_if_no_bytecode(prj: _, cmd: _) { let (_api, handle) = spawn(NodeConfig::test()).await; let rpc = handle.http_endpoint(); @@ -484,8 +628,7 @@ abstract contract AbstractCounter { } } "#, - ) - .unwrap(); + ); cmd.args([ "create", @@ -501,4 +644,44 @@ abstract contract AbstractCounter { Error: no bytecode found in bin object for AbstractCounter "#]]); -}); +} + +// Tests that `forge create` fails when the deployment transaction reverts +// +#[forgetest] +async fn flaky_should_fail_on_reverted_deployment(prj: _, cmd: _) { + let (_api, handle) = spawn(NodeConfig::test()).await; + let rpc = handle.http_endpoint(); + let wallet = handle.dev_wallets().next().unwrap(); + let pk = hex::encode(wallet.credential().to_bytes()); + + prj.add_source( + "RevertingContract.sol", + r#" +contract RevertingContract { + constructor() { + revert("deployment failed"); + } +} + "#, + ); + + // Use --gas-limit to bypass eth_estimateGas, which would reject the tx early. + // This simulates chains that mine reverted txs (e.g. when gas is manually specified). + cmd.args([ + "create", + "./src/RevertingContract.sol:RevertingContract", + "--rpc-url", + rpc.as_str(), + "--private-key", + pk.as_str(), + "--broadcast", + "--gas-limit", + "1000000", + ]) + .assert_failure() + .stderr_eq(str![[r#" +Error: deployment transaction failed (receipt status 0): [..] + +"#]]); +} diff --git a/crates/forge/tests/cli/debug.rs b/crates/forge/tests/cli/debug.rs index c217beeb501ff..bd87adf2931a3 100644 --- a/crates/forge/tests/cli/debug.rs +++ b/crates/forge/tests/cli/debug.rs @@ -1,29 +1,84 @@ use itertools::Itertools; use std::path::Path; +#[forgetest] +#[cfg(unix)] +fn debugger_selects_once_across_network_passes(prj: _) { + use rexpect::{Encoding, process::wait::WaitStatus, reader::Options, spawn_with_options}; + + const TIMEOUT_MS: u64 = 30_000; + + prj.add_test( + "MultiNetwork.t.sol", + r#" +contract MultiNetworkTest { + function testDefault() public {} + + /// forge-config: default.networks.network = "tempo" + function testTempo() public {} +} +"#, + ); + + let dump_path = prj.root().join("debug.json"); + let mut command = std::process::Command::new(env!("CARGO_BIN_EXE_forge")); + command + .current_dir(prj.root()) + .env_remove("CI") + .env("NO_COLOR", "1") + .env("TERM", "dumb") + .args(["test", "--debug", "--dump"]) + .arg(&dump_path); + + let mut session = spawn_with_options( + command, + Options { + timeout_ms: Some(TIMEOUT_MS), + strip_ansi_escape_codes: true, + encoding: Encoding::UTF8, + }, + ) + .unwrap(); + + session.exp_string("Select a test to debug:").unwrap(); + session.send("\x1b[B\x1b[B\r").unwrap(); + session.flush().unwrap(); + + let output = session.exp_eof().unwrap(); + assert!( + matches!(session.process.wait().unwrap(), WaitStatus::Exited(_, 0)), + "debug command failed: {output}" + ); + assert_eq!( + output.matches("Select a test to debug:").count(), + 1, + "selection picker repeated: {output}" + ); + assert!(output.contains("[PASS] testTempo()"), "selected test did not run: {output}"); + assert!(!output.contains("[PASS] testDefault()"), "unselected test also ran: {output}"); + assert!(dump_path.exists(), "debugger dump was not created"); + serde_json::from_slice::(&std::fs::read(dump_path).unwrap()).unwrap(); +} + // Sets up a debuggable test case. // Run with `cargo test-debugger`. -forgetest!( - #[ignore = "ran manually"] - manual_debug_setup, - |prj, cmd| { - cmd.args(["init", "--force"]) - .arg(prj.root()) - .assert_success() - .stdout_eq(str![[r#" +#[forgetest] +#[ignore = "ran manually"] +#[expect(clippy::disallowed_macros, reason = "prints the setup for a manual debug session")] +fn manual_debug_setup(prj: _, cmd: _) { + cmd.args(["init", "--force"]).arg(prj.root()).assert_success().stdout_eq(str![""]).stderr_eq( + str![[r#" +Warning: Target directory is not empty, but `--force` was specified Initializing [..]... -Installing forge-std in [..] (url: Some("https://github.com/foundry-rs/forge-std"), tag: None) +Installing forge-std in [..] (url: https://github.com/foundry-rs/forge-std, tag: None) +... Installed forge-std[..] Initialized forge project -"#]]) - .stderr_eq(str![[r#" -Warning: Target directory is not empty, but `--force` was specified -... - -"#]]); +"#]], + ); - prj.add_source("Counter2.sol", r#" + prj.add_source("Counter2.sol", r#" contract A { address public a; uint public b; @@ -51,10 +106,9 @@ contract A { a = _a; } }"#, - ) - .unwrap(); + ); - let script = prj.add_script("Counter.s.sol", r#" + let script = prj.add_script("Counter.s.sol", r#" import "../src/Counter2.sol"; import "forge-std/Script.sol"; import "forge-std/Test.sol"; @@ -84,23 +138,21 @@ contract Script0 is Script, Test { bytes32 _d2 = b.other().d(); } }"#, - ) - .unwrap(); - - cmd.forge_fuse().args(["build"]).assert_success(); - - cmd.args([ - "script", - script.to_str().unwrap(), - "--root", - prj.root().to_str().unwrap(), - "--tc=Script0", - "--debug", - ]); - eprintln!("root: {}", prj.root().display()); - let cmd_path = Path::new(cmd.cmd().get_program()).canonicalize().unwrap(); - let args = cmd.cmd().get_args().map(|s| s.to_str().unwrap()).format(" "); - eprintln!(" cmd: {} {args}", cmd_path.display()); - std::mem::forget(prj); - } -); + ); + + cmd.forge_fuse().args(["build"]).assert_success(); + + cmd.args([ + "script", + script.to_str().unwrap(), + "--root", + prj.root().to_str().unwrap(), + "--tc=Script0", + "--debug", + ]); + eprintln!("root: {}", prj.root().display()); + let cmd_path = Path::new(cmd.cmd().get_program()).canonicalize().unwrap(); + let args = cmd.cmd().get_args().map(|s| s.to_str().unwrap()).format(" "); + eprintln!(" cmd: {} {args}", cmd_path.display()); + std::mem::forget(prj); +} diff --git a/crates/forge/tests/cli/doc.rs b/crates/forge/tests/cli/doc.rs index 758ba80eada0d..a0a101d4dc649 100644 --- a/crates/forge/tests/cli/doc.rs +++ b/crates/forge/tests/cli/doc.rs @@ -1,8 +1,3537 @@ -use foundry_test_utils::util::{RemoteProject, setup_forge_remote}; +use foundry_test_utils::{ + TestProject, assert_data_eq, + snapbox::{Data, IntoData}, + str, + util::{RemoteProject, setup_forge_remote}, +}; +use std::fs; + +mod site; #[test] fn can_generate_solmate_docs() { let (prj, _) = setup_forge_remote(RemoteProject::new("transmissions11/solmate").set_build(false)); - prj.forge_command().args(["doc", "--build"]).assert_success(); + prj.forge_command().args(["doc"]).assert_success(); +} + +#[forgetest_init] +fn doc_does_not_write_artifacts(prj: _, cmd: _) { + prj.add_source( + "DocTarget.sol", + r#" +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +contract DocTarget { + /// @notice Returns a value. + function value() external pure returns (uint256) { + return 1; + } +} +"#, + ); + + let artifact = prj.root().join("out/DocTarget.sol/DocTarget.json"); + cmd.args(["doc"]).assert_success(); + assert!(!artifact.exists()); + + fs::create_dir_all(artifact.parent().unwrap()).unwrap(); + fs::write(&artifact, b"sentinel").unwrap(); + + cmd.forge_fuse().args(["doc"]).assert_success(); + let after = fs::read(&artifact).unwrap(); + assert_eq!(after, b"sentinel"); +} + +#[forgetest_init] +fn doc_supports_empty_projects(cmd: _) { + cmd.arg("doc").assert_success(); +} + +#[forgetest_init] +fn doc_supports_ignoring_all_sources(prj: _, cmd: _) { + prj.add_source("Ignored.sol", "contract Ignored {}"); + prj.update_config(|config| config.doc.ignore = vec!["src/**".to_string()]); + + cmd.arg("doc").assert_success(); + assert!(prj.root().join("docs/src/pages/.forge-doc-manifest").exists()); +} + +#[forgetest_init] +fn doc_uses_configured_commit_for_source_links(prj: _, cmd: _) { + prj.add_source( + "Revision.sol", + r#" +pragma solidity ^0.8.20; + +contract Revision {} +"#, + ); + prj.update_config(|config| { + config.doc.repository = Some("https://github.com/foundry-rs/foundry".to_string()); + config.doc.commit = Some("v1.2.3".to_string()); + }); + + cmd.arg("doc").assert_success(); + + prj.assert_doc_page( + "src/contract.Revision.mdx", + str![[r#" +... +[Git Source](https://github.com/foundry-rs/foundry/blob/v1.2.3/src/Revision.sol) +... +"#]], + ); +} + +#[forgetest] +fn doc_supports_mixed_solidity_versions(prj: _, cmd: _) { + prj.add_source( + "New.sol", + r#" +pragma solidity ^0.8.20; + +contract New {} +"#, + ); + prj.add_source( + "Old.sol", + r#" +pragma solidity 0.7.6; + +contract Old {} +"#, + ); + + cmd.arg("doc").assert_success(); + assert!(prj.root().join("docs/src/pages/src/contract.New.mdx").exists()); + assert!(prj.root().join("docs/src/pages/src/contract.Old.mdx").exists()); +} + +#[cfg(unix)] +#[forgetest_init] +fn doc_does_not_run_solc(prj: _, cmd: _) { + use std::os::unix::fs::PermissionsExt; + + prj.add_source( + "DocTarget.sol", + r#" +pragma solidity ^0.8.35; + +contract DocTarget { + /// @notice Returns a value. + function value() external pure returns (uint256) { + return 1; + } +} +"#, + ); + prj.add_source( + "Skipped.sol", + r#" +pragma solidity ^0.8.35; + +contract Skipped {} +"#, + ); + + let solc = prj.root().join("fake-solc"); + let invoked = prj.root().join("fake-solc.invoked"); + fs::write( + &solc, + r#"#!/bin/sh +if [ "$1" = "--version" ]; then + echo "solc, the solidity compiler commandline interface" + echo "Version: 0.8.35+commit.69074fbd" + exit 0 +fi +touch "$0.invoked" +exit 1 +"#, + ) + .unwrap(); + let mut permissions = fs::metadata(&solc).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&solc, permissions).unwrap(); + + prj.update_config(|config| { + config.solc = Some(foundry_config::SolcReq::Local(solc)); + config.skip = vec!["*Skipped*".parse().unwrap()]; + }); + + cmd.arg("doc").assert_success(); + assert!(!invoked.exists(), "forge doc invoked the configured solc binary"); + assert!(!prj.root().join("docs/src/pages/src/contract.Skipped.mdx").exists()); +} + +// Test that overloaded functions in interfaces inherit the correct NatSpec comments +// fixes +#[forgetest_init] +fn can_generate_docs_for_overloaded_functions(prj: _, cmd: _) { + prj.add_source( + "IExample.sol", + r#" +interface IExample { + /// @notice Deposit tokens into the vault + /// @param amount The amount to deposit + function deposit(uint256 amount) external; + + /// @notice Withdraw tokens from the vault + /// @param amount The amount to withdraw + function withdraw(uint256 amount) external; +} +"#, + ); + + prj.add_source( + "Example.sol", + r#" +import "./IExample.sol"; + +contract Example is IExample { + /// @inheritdoc IExample + function deposit(uint256 amount) external {} + + /// @inheritdoc IExample + function withdraw(uint256 amount) external {} +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + + let doc_path = prj.root().join("docs/src/pages/src/contract.Example.mdx"); + assert_data_eq!( + Data::read_from(&doc_path, None), + str![[r#" +... + + +### deposit + +Deposit tokens into the vault + +```solidity +function deposit(uint256 amount) external; +``` + +**Parameters** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| amount | `uint256` | The amount to deposit | + + + +### withdraw + +Withdraw tokens from the vault +... +"#]], + ); +} + +// Test that natspec is inherited implicitly from a base interface when the override carries +// no `@inheritdoc` tag. +// fixes +#[forgetest_init] +fn natspec_is_inherited_implicitly(prj: _, cmd: _) { + prj.add_source( + "IExample.sol", + r#" +interface IExample { + /// @notice Deposit tokens into the vault + /// @param amount The amount to deposit + /// @return shares The amount of shares minted + function deposit(uint256 amount) external returns (uint256 shares); +} +"#, + ); + + prj.add_source( + "Example.sol", + r#" +import "./IExample.sol"; + +contract Example is IExample { + function deposit(uint256 amount) external override returns (uint256 shares) {} +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + let doc_path = prj.root().join("docs/src/pages/src/contract.Example.mdx"); + assert_data_eq!( + Data::read_from(&doc_path, None), + str![[r#" +... + + +### deposit + +Deposit tokens into the vault + +```solidity +function deposit(uint256 amount) external override returns (uint256 shares); +``` + +**Parameters** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| amount | `uint256` | The amount to deposit | + +**Returns** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| shares | `uint256` | The amount of shares minted | +... +"#]], + ); +} + +#[forgetest_init] +fn inheritdoc_uses_effective_positional_natspec(prj: _, cmd: _) { + prj.add_source( + "IRoot.sol", + r#" +interface IRoot { + /// @notice Root notice + /// @dev Root dev + /// @param first Root first + /// @param second Root second + /// @return firstResult Root first result + /// @return secondResult Root second result + function run(uint256 first, uint256 second) + external + returns (uint256 firstResult, uint256 secondResult); +} +"#, + ); + prj.add_source( + "Effective.sol", + r#" +import {IRoot as RootAlias} from "./IRoot.sol"; + +interface IMid is RootAlias { + /// @inheritdoc RootAlias + /// @dev Mid dev + function run(uint256 left, uint256 right) + external + override + returns (uint256 leftResult, uint256 rightResult); +} + +contract Effective is IMid { + /// @inheritdoc IMid + /// @param currentLeft Local left + function run(uint256 currentLeft, uint256 currentRight) + external + override + returns (uint256 currentLeftResult, uint256 currentRightResult) + {} +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + prj.assert_doc_functions( + "src/contract.Effective.mdx", + str![[r#" + + +### run + +Root notice + + + +Mid dev + + + +```solidity +function run(uint256 currentLeft, uint256 currentRight) + external + override + returns (uint256 currentLeftResult, uint256 currentRightResult); +``` + +**Parameters** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| currentLeft | `uint256` | Local left | +| currentRight | `uint256` | | + +**Returns** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| currentLeftResult | `uint256` | Root first result | +| currentRightResult | `uint256` | Root second result | + + +"#]], + ); +} + +#[forgetest_init] +fn inheritdoc_documents_unnamed_parameters(prj: _, cmd: _) { + prj.add_source( + "Unnamed.sol", + r#" +interface IProcessor { + /// @param amount The amount to process + function single(uint256 amount) external; + + /// @param first The first value + /// @param third The third value + function sparse(uint256 first, address second, bytes32 third) external; + + /// @param first The named underscore + /// @param second The unnamed value + function underscoreCollision(uint256 first, uint256 second) external; + + /// @param first The named display value + /// @param second The custom-named value + function customNameCollision(uint256 first, uint256 second) external; +} + +contract Processor is IProcessor { + /// @inheritdoc IProcessor + function single(uint256) external override {} + + /// @inheritdoc IProcessor + function sparse(uint256, address, bytes32) external override {} + + /// @inheritdoc IProcessor + function underscoreCollision(uint256 _, uint256) external override {} + + /// @inheritdoc IProcessor + /// @custom:name display + function customNameCollision(uint256 display, uint256) external override {} +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + prj.assert_doc_page( + "src/contract.Processor.mdx", + str![[r#" +... +### single +... +| Name | Type | Description | +| ---- | ---- | ----------- | +| _ | `uint256` | The amount to process | +... +### sparse +... +| Name | Type | Description | +| ---- | ---- | ----------- | +| _ | `uint256` | The first value | +| _ | `address` | | +| _ | `bytes32` | The third value | +... +### underscoreCollision +... +| Name | Type | Description | +| ---- | ---- | ----------- | +| _ | `uint256` | The named underscore | +| _ | `uint256` | The unnamed value | +... +### customNameCollision +... +| Name | Type | Description | +| ---- | ---- | ----------- | +| display | `uint256` | The named display value | +| display | `uint256` | The custom-named value | +... +"#]], + ); +} + +#[forgetest_init] +fn inheritdoc_mapping_getter_uses_generated_signature(prj: _, cmd: _) { + prj.add_source( + "ExplicitGetter.sol", + r#" +interface IValues { + /// @notice Reads a value + /// @param key The lookup key + /// @return value The stored value + function values(uint256 key) external view returns (uint256 value); +} + +contract ExplicitGetter is IValues { + /// @inheritdoc IValues + mapping(uint256 => uint256) public override values; +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + prj.assert_doc_page( + "src/contract.ExplicitGetter.mdx", + str![[r#" +... +### values + +Reads a value + +```solidity +mapping(uint256 => uint256) public override values; +``` + +**Parameters** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <none> | `uint256` | The lookup key | + +**Returns** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <none> | `uint256` | The stored value | +... +"#]], + ); +} + +#[forgetest_init] +fn inheritdoc_does_not_skip_exact_custom_documentation(prj: _, cmd: _) { + prj.add_source( + "Exact.sol", + r#" +abstract contract Root { + /// @notice Must not leak through Mid + function run(uint256 value) public virtual {} +} + +abstract contract Mid is Root { + /// @custom:audit reviewed + function run(uint256 value) public virtual override {} +} + +contract Exact is Mid { + /// @inheritdoc Mid + function run(uint256 value) public override {} +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + prj.assert_doc_functions( + "src/contract.Exact.mdx", + str![[r#" + + +### run + +```solidity +function run(uint256 value) public override; +``` + +**Parameters** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| value | `uint256` | | + + +"#]], + ); +} + +#[forgetest_init] +fn implicit_inheritance_requires_compatible_override(prj: _, cmd: _) { + prj.add_source( + "Compatibility.sol", + r#" +abstract contract CompatibilityBase { + /// @notice Must not inherit from a non-virtual function + function nonVirtual() public {} + + /// @notice Must not inherit across visibility changes + function visibilityChange() public virtual {} + + /// @notice Must not inherit across mutability changes + function mutabilityChange() public view virtual {} + + /// @notice Must not inherit across return type changes + function returnChange() public virtual returns (uint256) {} +} + +contract Compatibility is CompatibilityBase { + function nonVirtual() public override {} + function visibilityChange() external override {} + function mutabilityChange() public override {} + function returnChange() public override returns (address) {} +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + prj.assert_doc_functions( + "src/contract.Compatibility.mdx", + str![[r#" + + +### nonVirtual + +```solidity +function nonVirtual() public override; +``` + + + +### visibilityChange + +```solidity +function visibilityChange() external override; +``` + + + +### mutabilityChange + +```solidity +function mutabilityChange() public override; +``` + + + +### returnChange + +```solidity +function returnChange() public override returns (address); +``` + +**Returns** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <none> | `address` | | + + +"#]], + ); +} + +#[forgetest_init] +fn inheritdoc_getter_handles_malformed_return_arity(prj: _, cmd: _) { + prj.add_source( + "MalformedGetter.sol", + r#" +interface IFlag { + /// @notice Reads the flag + function flag() external view; +} + +contract MalformedGetter is IFlag { + /// @inheritdoc IFlag + uint256 public override flag; +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + let rendered = + fs::read_to_string(prj.root().join("docs/src/pages/src/contract.MalformedGetter.mdx")) + .unwrap(); + assert!(rendered.contains("Reads the flag"), "{rendered}"); +} + +#[forgetest_init] +fn inheritdoc_uses_first_duplicate_target(prj: _, cmd: _) { + prj.add_source( + "DuplicateInheritdoc.sol", + r#" +abstract contract RootA { + /// @notice First target + function run() public virtual {} +} + +abstract contract A is RootA { + function run() public virtual override {} +} + +abstract contract RootB { + /// @notice Second target + function run() public virtual {} +} + +abstract contract B is RootB { + function run() public virtual override {} +} + +contract DuplicateInheritdoc is A, B { + /// @inheritdoc A + /// @inheritdoc B + function run() public override(A, B) {} +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + let rendered = + fs::read_to_string(prj.root().join("docs/src/pages/src/contract.DuplicateInheritdoc.mdx")) + .unwrap(); + assert!(rendered.contains("First target"), "{rendered}"); + assert!(!rendered.contains("Second target"), "{rendered}"); +} + +#[forgetest_init] +fn implicit_inheritance_matches_constant_getter_mutability(prj: _, cmd: _) { + prj.add_source( + "ConstantGetter.sol", + r#" +interface IConstant { + /// @notice The constant value + function VALUE() external pure returns (uint256); +} + +contract ConstantGetter is IConstant { + uint256 public constant override VALUE = 1; +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + let rendered = + fs::read_to_string(prj.root().join("docs/src/pages/src/contract.ConstantGetter.mdx")) + .unwrap(); + assert!(rendered.contains("The constant value"), "{rendered}"); +} + +#[forgetest_init] +fn implicit_inheritance_rejects_external_return_location_mismatch(prj: _, cmd: _) { + prj.add_source( + "ReturnLocation.sol", + r#" +abstract contract ReturnBase { + /// @notice Must not cross a return-location mismatch + function data() external view virtual returns (bytes memory); +} + +contract ReturnLocation is ReturnBase { + bytes private stored; + + function data() public view override returns (bytes storage value) { + value = stored; + } +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + prj.assert_doc_functions( + "src/contract.ReturnLocation.mdx", + str![[r#" + + +### data + +```solidity +function data() public view override returns (bytes storage value); +``` + +**Returns** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| value | `bytes` | | + + +"#]], + ); +} + +// NatSpec text must never reach the MDX page as executable ESM: MDX runs a line whose first +// token is `import`/`export` as code. The text can even be inherited from another contract +// through `@inheritdoc`, so a dependency's doc comment could inject into the derived page. +#[forgetest_init] +fn natspec_neutralizes_esm_statement_lines(prj: _, cmd: _) { + prj.add_source( + "EsmBase.sol", + r#" +interface IEsm { + /// @notice export const injected = 1 + function act(uint256 v) external; +} +"#, + ); + prj.add_source( + "EsmChild.sol", + r#" +import "./EsmBase.sol"; + +/// @notice import somesecret from the outside +contract EsmChild is IEsm { + /// @inheritdoc IEsm + function act(uint256 v) external override {} +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + prj.assert_doc_page( + "src/contract.EsmChild.mdx", + str![[r#" +--- +title: "EsmChild" +description: "import somesecret from the outside" +--- + +# EsmChild + +**Inherits:** [IEsm](/src/interface.IEsm) + +import somesecret from the outside + +## Functions + + + +### act + +export const injected = 1 + +```solidity +function act(uint256 v) external override; +``` + +**Parameters** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| v | `uint256` | | + + +"#]], + ); +} + +#[forgetest_init] +fn homepage_neutralizes_esm_statement_lines(prj: _, cmd: _) { + prj.add_source("Probe.sol", "contract Probe {}"); + fs::write( + prj.root().join("README.md"), + concat!( + "\u{feff}", + r#"import fs from "node:fs" + +# Probe + +export const generated = fs.writeFileSync("marker", "") + +Replace , see `wrapped +import span` here. + +```js +import inert from "fenced" +``` +"# + ), + ) + .unwrap(); + + cmd.args(["doc"]).assert_success(); + prj.assert_doc_page( + "index.mdx", + str![[r#" +import fs from "node:fs" + +# Probe + +export const generated = fs.writeFileSync("marker", "") + +Replace <TOKEN>, see `wrapped +import span` here. + +```js +import inert from "fenced" +``` + +"#]], + ); +} + +#[forgetest_init] +fn natspec_fences_are_limited_to_standalone_descriptions(prj: _, cmd: _) { + prj.add_source( + "FenceScope.sol", + r#" +interface IFenced { + /// @dev Example: + /// ```solidity + /** + * if (value < 1) { + * ``` + * Outside < and { + */ + /// @param value Parameter example: + /// ~~~solidity + /// if (value < 1) { + /// ~~~ + function inspect(uint256 value) external; +} + +contract Child is IFenced { + /// @inheritdoc IFenced + function inspect(uint256 value) external override {} +} + +/** + * @title Metadata + * ~~~ + * @author Author + * ``` + * @custom:note Note + * ~~~~ + * @notice ~~~ + * {1+1} + * ~~~ + */ +contract Metadata { + /// @custom:name {1+1} + /// @custom:name + function inspect(uint256, uint256) external {} +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + for page in ["interface.IFenced.mdx", "contract.Child.mdx"] { + assert_data_eq!( + Data::read_from(&prj.root().join("docs/src/pages/src").join(page), None), + str![[r#" +... +### inspect + + + +Example: +```solidity +if (value < 1) { +``` +Outside < and { + + + +... +**Parameters** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| value | `uint256` | Parameter example:
~~~solidity
if (value < 1) {
~~~ | +... +"#]], + ); + } + prj.assert_doc_page( + "src/contract.Metadata.mdx", + str![[r#" +... +# Metadata + +**Title:** Metadata +~~~ + +**Author:** Author +``` + +~~~ +{1+1} +~~~ + +**Note:** + +- **note:** Note +~~~~ + +... +### inspect + +... +| `1+1` | `uint256` | | +| <name> | `uint256` | | +... +"#]], + ); +} + +#[forgetest_init] +fn multiline_notice_populates_frontmatter_description(prj: _, cmd: _) { + prj.add_source( + "Vault.sol", + r#" +/// @notice Stores deposited assets for users +/// and enforces withdrawal limits. +contract Vault {} +"#, + ); + + cmd.args(["doc"]).assert_success(); + prj.assert_doc_page( + "src/contract.Vault.mdx", + str![[r#" +--- +title: "Vault" +description: "Stores deposited assets for users and enforces withdrawal limits." +--- + +# Vault + +Stores deposited assets for users +and enforces withdrawal limits. + + +"#]], + ); +} + +// An override inherits the base overload with the matching signature, continuing past a nearer +// base that declares a different same-name overload. +#[forgetest_init] +fn implicit_inheritance_matches_the_overload_signature(prj: _, cmd: _) { + prj.add_source( + "Bases.sol", + r#" +interface INear { + function g(address a) external returns (bool); +} + +interface IFar { + /// @notice Far documents g(uint256) + function g(uint256 n) external returns (bool); +} +"#, + ); + + prj.add_source( + "Impl.sol", + r#" +import "./Bases.sol"; + +contract Impl is INear, IFar { + function g(uint256 n) external override(IFar) returns (bool) {} + function g(address a) external override(INear) returns (bool) {} +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + + prj.assert_doc_functions( + "src/contract.Impl.mdx", + str![[r#" + + +### g + +Far documents g(uint256) + +```solidity +function g(uint256 n) external override(IFar) returns (bool); +``` + +**Parameters** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| n | `uint256` | | + +**Returns** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <none> | `bool` | | + +
+ +### g + +```solidity +function g(address a) external override(INear) returns (bool); +``` + +**Parameters** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| a | `address` | | + +**Returns** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <none> | `bool` | | + + +"#]], + ); +} + +// A public mapping variable inherits the NatSpec of the interface getter it implements, matched +// through the getter's generated signature (`balanceOf(address)`). +#[forgetest_init] +fn implicit_inheritance_matches_mapping_getter_signature(prj: _, cmd: _) { + prj.add_source( + "IERC.sol", + r#" +interface IERC { + /// @notice The balance of an account + function balanceOf(address account) external view returns (uint256); +} +"#, + ); + + prj.add_source( + "Token.sol", + r#" +import "./IERC.sol"; + +contract Token is IERC { + mapping(address owner => uint256 amount) public override balanceOf; +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + + let doc_path = prj.root().join("docs/src/pages/src/contract.Token.mdx"); + let rendered = fs::read_to_string(&doc_path).unwrap(); + assert!(rendered.contains("The balance of an account"), "{rendered}"); +} + +// A public mapping with a `string` key inherits through its synthetic getter: the getter's +// generated signature matches the interface function with the location normalized. +#[forgetest_init] +fn implicit_inheritance_matches_string_key_getter(prj: _, cmd: _) { + prj.add_source( + "IRegistry.sol", + r#" +interface IRegistry { + /// @notice The balance registered for a name + function balances(string memory name) external view returns (uint256); +} +"#, + ); + + prj.add_source( + "Registry.sol", + r#" +import "./IRegistry.sol"; + +contract Registry is IRegistry { + mapping(string => uint256) public override balances; +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + + let doc_path = prj.root().join("docs/src/pages/src/contract.Registry.mdx"); + let rendered = fs::read_to_string(&doc_path).unwrap(); + assert!(rendered.contains("The balance registered for a name"), "{rendered}"); +} + +// `calldata` in a base member and `memory` in the override are the same signature: locations +// are normalized before comparison and the NatSpec is inherited. +#[forgetest_init] +fn implicit_inheritance_normalizes_calldata_location(prj: _, cmd: _) { + prj.add_source( + "Base.sol", + r#" +interface Base { + /// @notice Configures the value + function configure(bytes calldata data) external; +} +"#, + ); + + prj.add_source( + "Child.sol", + r#" +import "./Base.sol"; + +contract Child is Base { + function configure(bytes memory data) public override {} +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + + let doc_path = prj.root().join("docs/src/pages/src/contract.Child.mdx"); + let rendered = fs::read_to_string(&doc_path).unwrap(); + assert!(rendered.contains("Configures the value"), "{rendered}"); +} + +// Implicit inheritance normalizes divergent mapping spellings, but must not document a +// different mapping overload even when the base has a single same-name declaration. +#[forgetest_init] +fn implicit_inheritance_rejects_non_abi_overload_mismatch(prj: _, cmd: _) { + prj.add_source( + "Store.sol", + r#" +abstract contract Store { + /// @notice Configures the store + function configure(mapping(uint => uint) storage store_) internal virtual; +} +"#, + ); + + prj.add_source( + "MyStore.sol", + r#" +import "./Store.sol"; + +contract MyStore is Store { + function configure(mapping(uint=>uint) storage store_) internal override {} + function configure(mapping(address => address) storage other) internal {} +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + + prj.assert_doc_functions( + "src/contract.MyStore.mdx", + str![[r#" + + +### configure + +Configures the store + +```solidity +function configure(mapping(uint=>uint) storage store_) internal override; +``` + +**Parameters** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| store_ | `mapping(uint=>uint)` | | + + + +### configure + +```solidity +function configure(mapping(address => address) storage other) internal; +``` + +**Parameters** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| other | `mapping(address => address)` | | + + +"#]], + ); +} +// Point 2 (mablr review): names are compared at every level. A leaf cannot jump across an +// intermediate rename just because it restores the original name. +#[forgetest_init] +fn implicit_inheritance_requires_matching_param_names(prj: _, cmd: _) { + prj.add_source( + "Rename.sol", + r#" +contract Base { + /// @notice Deposits into the vault + function deposit(uint256 amount) public virtual returns (uint256) {} +} + +contract Mid is Base { + function deposit(uint256 shares) public virtual override returns (uint256) {} +} + +contract Leaf is Mid { + function deposit(uint256 amount) public override returns (uint256) {} +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + for contract in ["Mid", "Leaf"] { + let rendered = fs::read_to_string( + prj.root().join(format!("docs/src/pages/src/contract.{contract}.mdx")), + ) + .unwrap(); + assert!(!rendered.contains("Deposits into the vault"), "{rendered}"); + } +} + +// Point 3 (mablr review): the target needs a public getter, and the source needs to be an +// external function implemented by that getter. A same-name base variable is not a source. +#[forgetest_init] +fn implicit_inheritance_requires_public_getter_and_function_source(prj: _, cmd: _) { + prj.add_source( + "Variables.sol", + r#" +contract Base { + /// @notice Must not reach a private target + uint256 private privateTarget; + + /// @notice A variable is not a getter function + uint256 private variableSource; +} + +contract Child is Base { + uint256 private privateTarget; + uint256 public variableSource; +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + let rendered = + fs::read_to_string(prj.root().join("docs/src/pages/src/contract.Child.mdx")).unwrap(); + assert!(!rendered.contains("Must not reach a private target"), "{rendered}"); + assert!(!rendered.contains("A variable is not a getter function"), "{rendered}"); +} + +// Point 1 (mablr review): automatic inheritance needs one semantic base function. Distinct +// declarations on separate branches are ambiguous; a declaration shared by both branches is not. +#[forgetest_init] +fn implicit_inheritance_resolves_base_ambiguity_per_branch(prj: _, cmd: _) { + prj.add_source( + "Ambiguity.sol", + r#" +interface IAlpha { + /// @notice From IAlpha + function direct(uint256 x) external; +} + +interface IBeta { + /// @notice From IBeta + function direct(uint256 x) external; +} + +contract Direct is IAlpha, IBeta { + function direct(uint256 x) external override(IAlpha, IBeta) {} +} + +contract Root { + /// @notice Root branch doc + function act(uint256 x) public virtual {} +} + +contract A is Root { + /// @notice A branch doc + function act(uint256 x) public virtual override {} +} + +contract B is Root {} + +contract Asymmetric is A, B { + function act(uint256 x) public virtual override(A, Root) {} +} + +contract Leaf is Asymmetric { + function act(uint256 x) public override {} +} + +contract SharedRoot { + /// @notice Shared root doc + function shared(uint256 x) public virtual {} +} + +contract Left is SharedRoot {} +contract Right is SharedRoot {} + +contract Shared is Left, Right { + function shared(uint256 x) public override {} +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + prj.assert_doc_page( + "src/contract.Direct.mdx", + str![[r#" +... +### direct + +```solidity +function direct(uint256 x) external override(IAlpha, IBeta); +``` +... +"#]], + ); + prj.assert_doc_page( + "src/contract.Asymmetric.mdx", + str![[r#" +... +### act + +```solidity +function act(uint256 x) public virtual override(A, Root); +``` +... +"#]], + ); + prj.assert_doc_page( + "src/contract.Leaf.mdx", + str![[r#" +... +### act + +```solidity +function act(uint256 x) public override; +``` +... +"#]], + ); + prj.assert_doc_page( + "src/contract.Shared.mdx", + str![[r#" +... +### shared + +Shared root doc + +```solidity +function shared(uint256 x) public override; +``` +... +"#]], + ); +} + +// Point 5 (mablr review): any local NatSpec item suppresses automatic inheritance. A leaf +// cannot reach around an intermediate override carrying only a custom tag. +#[forgetest_init] +fn implicit_inheritance_skips_custom_tagged_members(prj: _, cmd: _) { + prj.add_source( + "Tagged.sol", + r#" +contract Base { + /// @notice Base notice + function run(uint256 amount) public virtual returns (uint256) {} +} + +contract Mid is Base { + /// @custom:audit reviewed + function run(uint256 amount) public virtual override returns (uint256) {} +} + +contract Leaf is Mid { + function run(uint256 amount) public override returns (uint256) {} +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + for contract in ["Mid", "Leaf"] { + let rendered = fs::read_to_string( + prj.root().join(format!("docs/src/pages/src/contract.{contract}.mdx")), + ) + .unwrap(); + assert!(!rendered.contains("Base notice"), "{rendered}"); + } +} + +// Implicit inheritance only runs when the override has no NatSpec of its own: a local `@notice` +// keeps the base `@param`/`@return` from being pulled in. +#[forgetest_init] +fn implicit_inheritance_skips_documented_members(prj: _, cmd: _) { + prj.add_source( + "IExample.sol", + r#" +interface IExample { + /// @notice Base notice + /// @param amount base amount doc + /// @return shares base shares doc + function deposit(uint256 amount) external returns (uint256 shares); +} +"#, + ); + + prj.add_source( + "Example.sol", + r#" +import "./IExample.sol"; + +contract Example is IExample { + /// @notice Local notice only + function deposit(uint256 amount) external override returns (uint256 shares) {} +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + + let doc_path = prj.root().join("docs/src/pages/src/contract.Example.mdx"); + let rendered = fs::read_to_string(&doc_path).unwrap(); + // The local notice is kept. + assert!(rendered.contains("Local notice only"), "{rendered}"); + // The base param and return docs are not pulled in, since the override is documented. + assert!(!rendered.contains("base amount doc"), "{rendered}"); + assert!(!rendered.contains("base shares doc"), "{rendered}"); +} + +// Point 4 (mablr review): render every parameter and return of the implemented getter. A +// missing parameter tag leaves its own row empty instead of borrowing another description. +#[forgetest_init] +fn inherited_getter_renders_param_and_return(prj: _, cmd: _) { + prj.add_source( + "Entries.sol", + r#" +struct Entry { + uint256 amount; + bool active; +} + +interface IEntries { + /// @notice The entry for an account + /// @param account the account to query + /// @return amount the stored amount + /// @return active whether the entry is active + function entries(address account, uint256 tokenId) + external + view + returns (uint256 amount, bool active); +} + +contract Entries is IEntries { + mapping(address owner => mapping(uint256 id => Entry entry)) public override entries; +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + let rendered = + fs::read_to_string(prj.root().join("docs/src/pages/src/contract.Entries.mdx")).unwrap(); + assert!(rendered.contains("| owner | `address` | the account to query |"), "{rendered}"); + assert!(rendered.contains("| id | `uint256` | |"), "{rendered}"); + assert!(rendered.contains("| amount | `uint256` | the stored amount |"), "{rendered}"); + assert!(rendered.contains("| active | `bool` | whether the entry is active |"), "{rendered}"); +} + +// steven review: an intermediate override's `@inheritdoc` is resolved and merged, not treated +// as terminal, so documentation propagates through it. A (documented) -> B (@inheritdoc A) -> +// C (undocumented): C receives A's documentation through B. +#[forgetest_init] +fn implicit_inheritance_resolves_intermediate_inheritdoc(prj: _, cmd: _) { + prj.add_source( + "Chain.sol", + r#" +interface IChainBase { + /// @notice Documented on the interface + function act(uint256 amount) external; +} + +abstract contract ChainMid is IChainBase { + /// @inheritdoc IChainBase + function act(uint256 amount) public virtual override {} +} + +contract ChainLeaf is ChainMid { + function act(uint256 amount) public override {} +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + let rendered = + fs::read_to_string(prj.root().join("docs/src/pages/src/contract.ChainLeaf.mdx")).unwrap(); + assert!(rendered.contains("Documented on the interface"), "{rendered}"); +} + +// steven review: an inherited `@return` maps positionally onto a renamed override's return +// slot, instead of gluing the base return name into the description. +#[forgetest_init] +fn implicit_inheritance_remaps_renamed_returns(prj: _, cmd: _) { + prj.add_source( + "Renamed.sol", + r#" +interface IRenamed { + /// @notice Reads a value + /// @return first the first result + function take(uint256 v) external returns (uint256 first); +} + +contract Renamed is IRenamed { + function take(uint256 v) external override returns (uint256 renamedFirst) {} +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + let rendered = + fs::read_to_string(prj.root().join("docs/src/pages/src/contract.Renamed.mdx")).unwrap(); + assert!(rendered.contains("| renamedFirst | `uint256` | the first result |"), "{rendered}"); + assert!(!rendered.contains("first the first result"), "{rendered}"); +} + +// Regression: return-name resolution for the implicit path must not leak into the explicit +// `@inheritdoc` path. With a partial local `@return` over a named-return override, the local +// description must win and the base's other returns must not be injected (matches master). +#[forgetest_init] +fn explicit_inheritdoc_partial_return_keeps_local_and_skips_base(prj: _, cmd: _) { + prj.add_source( + "PartialReturn.sol", + r#" +interface IPartial { + /// @notice Base notice + /// @return a base A-text + /// @return b base B-text + function f() external view returns (uint256 a, uint256 b); +} + +contract Partial is IPartial { + /// @inheritdoc IPartial + /// @return a local A-text + function f() external view override returns (uint256 a, uint256 b) {} +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + prj.assert_doc_functions( + "src/contract.Partial.mdx", + str![[r#" + + +### f + +Base notice + +```solidity +function f() external view override returns (uint256 a, uint256 b); +``` + +**Returns** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| a | `uint256` | local A-text | +| b | `uint256` | | + + +"#]], + ); +} + +// A public state variable's generated getter inherits implicitly through an interface chain: +// a base function redeclared without NatSpec still propagates its ancestor's documentation, like +// solc (Impl.data() resolves to IRoot's `@notice` through the undocumented IMid redeclaration). +#[forgetest_init] +fn implicit_getter_inherits_through_interface_chain(prj: _, cmd: _) { + prj.add_source( + "GetterChain.sol", + r#" +interface IRoot { + /// @notice Root getter doc + /// @return value the stored value + function data() external view returns (uint256 value); +} + +interface IMid is IRoot { + function data() external view override returns (uint256 value); +} + +contract Impl is IMid { + uint256 public override data; +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + let rendered = + fs::read_to_string(prj.root().join("docs/src/pages/src/contract.Impl.mdx")).unwrap(); + assert!(rendered.contains("Root getter doc"), "{rendered}"); + assert!(rendered.contains("the stored value"), "{rendered}"); +} + +// A private base function is not overridden by a same-signature child function and cannot donate +// its documentation to it. +// `forge doc` can render parseable sources that Solidity would reject later. A private +// same-signature declaration is still not a valid override source for inherited docs. +#[forgetest_init] +fn implicit_inheritance_rejects_private_base(prj: _, cmd: _) { + prj.add_source( + "PrivateBase.sol", + r#" +contract PrivateBase { + /// @notice Must not escape a private declaration + function privateCandidate(uint256 value) private {} +} + +contract PrivateLeaf is PrivateBase { + function privateCandidate(uint256 value) public {} +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + prj.assert_doc_functions( + "src/contract.PrivateLeaf.mdx", + str![[r#" + + +### privateCandidate + +```solidity +function privateCandidate(uint256 value) public; +``` + +**Parameters** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| value | `uint256` | | + + +"#]], + ); +} + +// A lowered Yul helper is not part of Solidity's override frontier. It must not shadow the real +// Solidity declaration in the next ancestor. Solar lowers Yul helpers as private, so this pins the +// effective boundary instead of proving `is_yul` independently from private visibility. +#[forgetest_init] +fn implicit_inheritance_ignores_yul_shadow(prj: _, cmd: _) { + prj.add_source( + "YulShadow.sol", + r#" +contract YulRoot { + /// @notice Must pass through the Yul-only intermediate declaration + function yulCandidate(uint256 value) public virtual {} +} + +contract YulMid is YulRoot { + function helper(uint256 input) public pure returns (uint256 output) { + assembly { + function yulCandidate(shadow) -> result { result := shadow } + output := yulCandidate(input) + } + } +} + +contract YulMid2 is YulMid { + function helper2(uint256 input) public pure returns (uint256 output) { + assembly { + function yulCandidate(shadow) -> result { result := shadow } + output := yulCandidate(input) + } + } +} + +contract YulLeaf is YulMid2 { + function yulCandidate(uint256 value) public override {} +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + let rendered = + fs::read_to_string(prj.root().join("docs/src/pages/src/contract.YulLeaf.mdx")).unwrap(); + assert!(rendered.contains("Must pass through"), "{rendered}"); +} + +// A generated getter is not an ordinary function declaration on the override frontier. +#[forgetest_init] +fn implicit_inheritance_rejects_generated_getter_base(prj: _, cmd: _) { + prj.add_source( + "GetterBase.sol", + r#" +contract GetterBase { + /// @notice Must not escape a generated getter + uint256 public getterCandidate; +} + +contract GetterLeaf is GetterBase { + function getterCandidate() public {} +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + prj.assert_doc_functions( + "src/contract.GetterLeaf.mdx", + str![[r#" + + +### getterCandidate + +```solidity +function getterCandidate() public; +``` + + +"#]], + ); +} + +// `forge doc` lowers parseable sources without running Solidity's full override validation. +// Even for an invalid cross-domain collision, it must not copy modifier docs onto a function. +#[forgetest_init] +fn implicit_inheritance_keeps_function_modifier_domains_separate(prj: _, cmd: _) { + prj.add_source( + "FunctionModifier.sol", + r#" +contract ModifierBase { + /// @notice A modifier is not a function override + modifier sameSpelling() { _; } +} + +contract FunctionLeaf is ModifierBase { + function sameSpelling() public {} +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + prj.assert_doc_functions( + "src/contract.FunctionLeaf.mdx", + str![[r#" + + +### sameSpelling + +```solidity +function sameSpelling() public; +``` + + +"#]], + ); +} + +// Fallback and receive have no AST header name, but they still take part in explicit and +// implicit NatSpec inheritance through their HIR function kinds. +#[forgetest_init] +fn inheritance_supports_fallback_and_receive(prj: _, cmd: _) { + prj.add_source( + "SpecialFunctions.sol", + r#" +abstract contract SpecialBase { + /// @notice Base fallback documentation + fallback() external virtual {} + + /// @notice Base receive documentation + receive() external payable virtual {} +} + +contract SpecialImplicit is SpecialBase { + fallback() external override {} + receive() external payable override {} +} + +contract SpecialExplicit is SpecialBase { + /// @inheritdoc SpecialBase + fallback(bytes calldata input) external override returns (bytes memory output) { + input; + return output; + } + + /// @inheritdoc SpecialBase + receive() external payable override {} +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + for contract in ["SpecialImplicit", "SpecialExplicit"] { + let rendered = fs::read_to_string( + prj.root().join(format!("docs/src/pages/src/contract.{contract}.mdx")), + ) + .unwrap(); + let fallback_start = rendered.find("### fallback").unwrap(); + let receive_start = rendered.find("### receive").unwrap(); + let fallback = &rendered[fallback_start..receive_start]; + let receive = &rendered[receive_start..]; + assert!(fallback.contains("Base fallback documentation"), "{rendered}"); + assert!(!fallback.contains("Base receive documentation"), "{rendered}"); + assert!(receive.contains("Base receive documentation"), "{rendered}"); + assert!(!receive.contains("Base fallback documentation"), "{rendered}"); + } +} + +// Return descriptions are remapped at each override hop before a generated getter consumes +// them. The final rows use the getter field names, not either interface's return names. +#[forgetest_init] +fn implicit_getter_remaps_returns_at_every_hop(prj: _, cmd: _) { + prj.add_source( + "ReturnChain.sol", + r#" +struct Pair { + uint256 getterFirst; + uint256 getterSecond; +} + +interface IRootPair { + /// @return originalFirst first value documentation + /// @return originalSecond second value documentation + function pair(uint256 key) + external + view + returns (uint256 originalFirst, uint256 originalSecond); +} + +interface IMiddlePair is IRootPair { + function pair(uint256 key) + external + view + override + returns (uint256 middleFirst, uint256 middleSecond); +} + +contract PairStore is IMiddlePair { + mapping(uint256 key => Pair value) public override pair; +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + let rendered = + fs::read_to_string(prj.root().join("docs/src/pages/src/contract.PairStore.mdx")).unwrap(); + assert!( + rendered.contains("| getterFirst | `uint256` | first value documentation |"), + "{rendered}" + ); + assert!( + rendered.contains("| getterSecond | `uint256` | second value documentation |"), + "{rendered}" + ); +} + +// An explicit `@inheritdoc` relay remaps inherited return names before the getter consumes them. +#[forgetest_init] +fn implicit_getter_remaps_returns_after_inheritdoc_relay(prj: _, cmd: _) { + prj.add_source( + "ExplicitReturnChain.sol", + r#" +struct ExplicitPair { + uint256 getterFirst; + uint256 getterSecond; +} + +interface IExplicitRoot { + /// @return originalFirst first relayed value + /// @return originalSecond second relayed value + function relayedPair(uint256 key) + external + view + returns (uint256 originalFirst, uint256 originalSecond); +} + +interface IExplicitMiddle is IExplicitRoot { + /// @inheritdoc IExplicitRoot + /// @notice Relayed through the middle interface + function relayedPair(uint256 key) + external + view + override + returns (uint256 middleFirst, uint256 middleSecond); +} + +contract ExplicitPairStore is IExplicitMiddle { + mapping(uint256 key => ExplicitPair value) public override relayedPair; +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + prj.assert_doc_page( + "src/contract.ExplicitPairStore.mdx", + str![[r#" +... +### relayedPair + +Relayed through the middle interface +... +**Returns** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| getterFirst | `uint256` | first relayed value | +| getterSecond | `uint256` | second relayed value | +... +"#]], + ); +} + +// Getter tables use the same NatSpec sanitizer as ordinary functions, including the escaped +// placeholder for an unnamed generated return. +#[forgetest_init] +fn inherited_getter_sanitizes_mdx_and_unnamed_returns(prj: _, cmd: _) { + prj.add_source( + "UnsafeGetter.sol", + r#" +interface IUnsafeGetter { + /// @param key Locate with {Reference} + /// @return Result from {Reference} + function values(uint256 key) external view returns (uint256); +} + +contract UnsafeGetter is IUnsafeGetter { + mapping(uint256 => uint256) public override values; +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + prj.assert_doc_page( + "src/contract.UnsafeGetter.mdx", + str![[r#" +... +**Parameters** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <none> | `uint256` | Locate <amount> with `Reference` | + +**Returns** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <none> | `uint256` | Result <amount> from `Reference` | +... +"#]], + ); +} + +// Test that {Ident} cross-references resolve to root-relative vocs links. +// fixes +#[forgetest_init] +fn hyperlinks_use_relative_paths(prj: _, cmd: _) { + prj.add_source( + "IBase.sol", + r#" +interface IBase { + function baseFunction() external; +} +"#, + ); + + prj.add_source( + "Derived.sol", + r#" +import "./IBase.sol"; + +/// @dev Inherits: {IBase} +contract Derived is IBase { + function baseFunction() external override {} +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + + prj.assert_doc_page( + "src/contract.Derived.mdx", + str![[r#" +... +Inherits: [IBase](/src/interface.IBase) +... +"#]], + ); +} + +// Keep constants-only, state-only, and mixed layouts covered independently. +#[forgetest_init] +fn variable_sections(prj: _, cmd: _) { + let constants = "uint256 public constant FOO = 1;\nuint256 public immutable BAR;"; + let state = "uint256 public baz;"; + let constructor = "constructor() { BAR = 2; }"; + let increment = "function increment() public { baz++; }"; + let constants_page = r#"## Constants + +### FOO + +```solidity +uint256 public constant FOO = 1; +``` + +### BAR + +```solidity +uint256 public immutable BAR; +``` + +"#; + let state_page = r#"## State Variables + +### baz + +```solidity +uint256 public baz; +``` + +"#; + let constructor_page = r#" + +### constructor + +```solidity +constructor(); +``` + +"#; + let increment_page = r#" + +### increment + +```solidity +function increment() public; +``` + +"#; + let cases = [ + ( + "CounterConstants", + constants.to_string(), + constructor.to_string(), + constants_page.to_string(), + constructor_page.to_string(), + ), + ( + "CounterStateVariables", + state.to_string(), + increment.to_string(), + state_page.to_string(), + increment_page.to_string(), + ), + ( + "CounterMixedVariables", + format!("{constants}\n{state}"), + format!("{constructor}\n{increment}"), + format!("{constants_page}{state_page}"), + format!("{constructor_page}{increment_page}"), + ), + ]; + for (name, declarations, functions, _, _) in &cases { + prj.add_source( + &format!("{name}.sol"), + &format!( + "pragma solidity >=0.8.19;\ncontract {name} {{\n{declarations}\n{functions}\n}}" + ), + ); + } + cmd.arg("doc").assert_success(); + for (name, _, _, sections, function_sections) in cases { + prj.assert_doc_page( + &format!("src/contract.{name}.mdx"), + format!( + "---\ntitle: \"{name}\"\n---\n\n# {name}\n\n{sections}## Functions\n\n{function_sections}" + ), + ); + } +} + +// Test that MDX-unsafe content coming through @inheritdoc is still escaped, and that +// unnamed return values are rendered as `<none>`. +#[forgetest_init] +fn inheritdoc_mdx_safety_and_unnamed_returns(prj: _, cmd: _) { + prj.add_source( + "IUnsafe.sol", + r#" +interface IUnsafe { + /// @notice Transfer tokens using {magic} spell + /// @param amount The value { in wei } + /// @return The new balance + function transfer(uint256 amount) external returns (uint256); +} +"#, + ); + + prj.add_source( + "Safe.sol", + r#" +import "./IUnsafe.sol"; + +contract Safe is IUnsafe { + /// @inheritdoc IUnsafe + function transfer(uint256 amount) external returns (uint256) {} + + /// @return First local result + /// @return Second local result + function localResults() external pure returns (uint256, address) { + return (1, address(0)); + } +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + + prj.assert_doc_page( + "src/contract.Safe.mdx", + str![[r#" +... +### transfer + +Transfer <amount> tokens using `magic` spell + +```solidity +function transfer(uint256 amount) external returns (uint256); +``` + +**Parameters** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| amount | `uint256` | The value ` in wei ` | + +**Returns** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <none> | `uint256` | The new balance | +... +### localResults +... +| Name | Type | Description | +| ---- | ---- | ----------- | +| <none> | `uint256` | First local result | +| <none> | `address` | Second local result | +... +"#]], + ); +} + +// Test that inline-link labels containing MDX-sensitive characters are escaped. +#[forgetest_init] +fn inline_link_label_safety(prj: _, cmd: _) { + prj.add_source( + "Token.sol", + r#" +contract Token { + function transfer(uint256 amount) external {} +} +"#, + ); + + prj.add_source( + "Vault.sol", + r#" +import "./Token.sol"; + +/// @dev See {Token}[Token ] for details +contract Vault { + function deposit() external {} +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + + prj.assert_doc_page( + "src/contract.Vault.mdx", + str![[r#" +... +See [Token <contract>](/src/contract.Token) for details +... +"#]], + ); +} + +// Test that the removed `--serve` flag prints a helpful migration message instead of a raw +// clap parse error. +#[forgetest_init] +fn serve_flag_prints_migration_message(cmd: _) { + cmd.args(["doc", "--serve"]).assert_failure().stderr_eq(str![[r#" +Error: `--serve` has been removed. Generate the docs with `forge doc`, then run `npm run dev` from the generated docs directory. + +"#]]); +} + +// Test that MDX-unsafe characters in NatSpec are properly escaped in the generated output. +#[forgetest_init] +fn mdx_safety_escaping(prj: _, cmd: _) { + prj.add_source( + "Escaping.sol", + r#" +/// @notice Contains a bare < angle bracket and a bare { brace. +/// @dev Reference to {UnresolvableRef} should become inline code. +contract Escaping { + /// @notice Transfer tokens to recipient < address + /// @param amount The amount { in wei } + function transfer(uint256 amount) external {} +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + + prj.assert_doc_page( + "src/contract.Escaping.mdx", + str![[r#" +... +Contains a bare < angle bracket and a bare { brace. + + + +Reference to `UnresolvableRef` should become inline code. +... +### transfer + +Transfer tokens to recipient < address + +```solidity +function transfer(uint256 amount) external; +``` + +**Parameters** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| amount | `uint256` | The amount ` in wei ` | + + +"#]], + ); +} + +// Test that multiline @param and @return descriptions (continuation lines) are preserved. +#[forgetest_init] +fn param_return_multiline_continuation(prj: _, cmd: _) { + prj.add_source( + "Multiline.sol", + r#" +interface IMultiline { + /// @notice Do something + /// @param value The first line of the description. + /// Second line of the param description. + /// @return result The first line of return. + /// Second line of return description. + function action(uint256 value) external returns (uint256 result); +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + + prj.assert_doc_page( + "src/interface.IMultiline.mdx", + str![[r#" +... +**Parameters** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| value | `uint256` | The first line of the description.
Second line of the param description. | + +**Returns** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| result | `uint256` | The first line of return.
Second line of return description. | +... +"#]], + ); +} + +#[forgetest_init] +fn inheritdoc_multiline_param_preserves_inherited_notice(prj: _, cmd: _) { + prj.add_source( + "MultilineInheritdoc.sol", + r#" +interface Root { + /// @notice Runs the operation + /// @param value The input value + function run(uint256 value) external; +} + +interface Mid is Root { + function run(uint256 value) external override; +} + +contract Child is Mid { + /// @inheritdoc Mid + /// @param value Local first line. + /// Local second line. + function run(uint256 value) external override {} +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + prj.assert_doc_page( + "src/contract.Child.mdx", + str![[r#" +... +### run + +Runs the operation +... +| value | `uint256` | Local first line.
Local second line. | +... +"#]], + ); +} + +// Inherited multiline NatSpec retains continuation lines and strips block-comment decorations. +#[forgetest_init] +fn inherited_param_return_multiline_continuation(prj: _, cmd: _) { + prj.add_source( + "InheritedMultiline.sol", + r#" +interface IInheritedMultiline { + /// @param value The first explicit parameter line. + /// The second explicit parameter line. + /// @return result The first explicit return line. + /// The second explicit return line. + function explicitAction(uint256 value) external returns (uint256 result); + + /// @param value The inherited parameter line. + /// The inherited parameter continuation. + function explicitActionWithLocalNotice(uint256 value) external; + + /** + * @param value The first implicit parameter line. + * The second implicit parameter line. + * @return result The first implicit return line. + * The second implicit return line. + */ + function implicitAction(uint256 value) external returns (uint256 result); + + /// An untagged inherited notice. + function untaggedNotice() external; + + /// @param value The parameter description. + /// + /// A separate inherited notice. + function separatedNotice(uint256 value) external; + + /// @param value The base parameter line. + /// The base parameter continuation. + function replacedParameter(uint256 value) external; + + /** + * @param value Run this code: + * value += 1; + */ + function indentedBlock(uint256 value) external; + + /// @param value *important* + function markdown(uint256 value) external; +} + +contract InheritedMultiline is IInheritedMultiline { + /// @inheritdoc IInheritedMultiline + function explicitAction(uint256 value) external override returns (uint256 result) {} + + /// @inheritdoc IInheritedMultiline + /// @notice A local notice. + function explicitActionWithLocalNotice(uint256 value) external override {} + + function implicitAction(uint256 value) external override returns (uint256 result) {} + + function untaggedNotice() external override {} + + function separatedNotice(uint256 value) external override {} + + /// @inheritdoc IInheritedMultiline + /// @param value The local parameter description. + function replacedParameter(uint256 value) external override {} + + function indentedBlock(uint256 value) external override {} + + function markdown(uint256 value) external override {} +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + + prj.assert_doc_page( + "src/contract.InheritedMultiline.mdx", + str![[r#" +... +### explicitAction +... +| value | `uint256` | The first explicit parameter line.
The second explicit parameter line. | +... +| result | `uint256` | The first explicit return line.
The second explicit return line. | +... +### explicitActionWithLocalNotice + +A local notice. +... +| value | `uint256` | The inherited parameter line.
The inherited parameter continuation. | +... +### implicitAction +... +| value | `uint256` | The first implicit parameter line.
The second implicit parameter line. | +... +| result | `uint256` | The first implicit return line.
The second implicit return line. | +... +### untaggedNotice + +An untagged inherited notice. +... +### separatedNotice + +A separate inherited notice. +... +| value | `uint256` | The parameter description. | +... +### replacedParameter + +```solidity +function replacedParameter(uint256 value) external override; +``` + +**Parameters** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| value | `uint256` | The local parameter description. | + + + +### indentedBlock +... +| value | `uint256` | Run this code:
value += 1; | +... +### markdown +... +| value | `uint256` | *important* | +... +"#]], + ); +} + +// Explicit inheritance must select the correct overload across canonical type spellings, +// qualified enums, and non-ABI-printable mapping parameters. +#[forgetest_init] +fn inheritdoc_overload_matching(prj: _, cmd: _) { + for (base, child, expected) in [ + // uint_alias. + ( + r#" +interface I { + /// @notice Configure by amount. + /// @param amount The configured amount + function configure(uint amount) external; + + /// @notice Configure by account. + /// @param account The configured account + function configure(address account) external; +} +"#, + r#" +import "./I.sol"; + +contract C is I { + /// @inheritdoc I + function configure(uint256 amount) external override {} + + /// @inheritdoc I + function configure(address account) external override {} +} +"#, + str![[r#" +... + + +### configure + +Configure by amount. + +```solidity +function configure(uint256 amount) external override; +``` + +**Parameters** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| amount | `uint256` | The configured amount | + + + +### configure + +Configure by account. +... +"#]], + ), + // uint_array_alias. + ( + r#" +interface I { + /// @notice Batch values. + /// @param values The input array + function batch(uint[] calldata values) external; + + /// @notice Batch accounts. + /// @param accounts The account array + function batch(address[] calldata accounts) external; +} +"#, + r#" +import "./I.sol"; + +contract C is I { + /// @inheritdoc I + function batch(uint256[] calldata values) external override {} + + /// @inheritdoc I + function batch(address[] calldata accounts) external override {} +} +"#, + str![[r#" +... + + +### batch + +Batch values. + +```solidity +function batch(uint256[] calldata values) external override; +``` + +**Parameters** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| values | `uint256[]` | The input array | + + + +### batch + +Batch accounts. +... +"#]], + ), + // qualified_enum_alias. + ( + r#" +interface I { + enum Status { Inactive, Active } + + /// @notice Sets the account status. + /// @param s the new status + function configure(I.Status s) external; + + /// @notice Configures by raw id. + /// @param id the raw id + function configure(uint256 id) external; +} +"#, + r#" +import "./I.sol"; + +contract C is I { + /// @inheritdoc I + function configure(Status s) external override {} + + /// @inheritdoc I + function configure(uint256 id) external override {} +} +"#, + str![[r#" +... + + +### configure + +Sets the account status. + +```solidity +function configure(Status s) external override; +``` + +**Parameters** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| s | `Status` | the new status | + + + +### configure + +Configures by raw id. +... +"#]], + ), + // mapping_fallback. + ( + r#" +abstract contract Base { + /// @notice Configure by store. + /// @param store The storage mapping + function configure(mapping(uint256 => uint256) storage store) internal virtual; + + /// @notice Configure by account. + /// @param account The configured account + function configure(address account) internal virtual; +} +"#, + r#" +import "./I.sol"; + +contract C is Base { + /// @inheritdoc Base + function configure(mapping(uint256 => uint256) storage store) internal override {} + + /// @inheritdoc Base + function configure(address account) internal override {} +} +"#, + str![[r#" +... + + +### configure + +Configure by store. + +```solidity +function configure(mapping(uint256 => uint256) storage store) internal override; +``` + +**Parameters** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| store | `mapping(uint256 => uint256)` | The storage mapping | + + + +### configure + +Configure by account. +... +"#]], + ), + ] { + prj.add_source("I.sol", base); + prj.add_source("C.sol", child); + cmd.forge_fuse().args(["doc"]).assert_success(); + prj.assert_doc_page("src/contract.C.mdx", expected); + } +} + +// Test that @inheritdoc parameter descriptions are matched when an implementation +// prefixes or suffixes interface parameter names with underscores. +#[forgetest_init] +fn inheritdoc_matches_underscore_wrapped_param_names(prj: _, cmd: _) { + prj.add_source( + "I.sol", + r#" +interface I { + /// @notice Mints tokens. + /// @param recipient The account receiving minted tokens. + /// @param amount The number of tokens to mint. + function mint(address recipient, uint256 amount) external; +} +"#, + ); + + prj.add_source( + "C.sol", + r#" +import "./I.sol"; + +contract C is I { + /// @inheritdoc I + function mint(address recipient_, uint256 _amount) external override {} +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + + prj.assert_doc_page( + "src/contract.C.mdx", + str![[r#" +... +### mint + +Mints tokens. + +```solidity +function mint(address recipient_, uint256 _amount) external override; +``` + +**Parameters** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| recipient_ | `address` | The account receiving minted tokens. | +| _amount | `uint256` | The number of tokens to mint. | +... +"#]], + ); +} + +// Explicit inheritance maps parameters positionally, even when an override renames one to a name +// that would have been ambiguous under the old fuzzy name matching. +#[forgetest_init] +fn inheritdoc_maps_ambiguous_renames_positionally(prj: _, cmd: _) { + prj.add_source( + "I.sol", + r#" +interface I { + /// @notice Updates values. + /// @param amount Docs for first param. + /// @param _amount Docs for second param. + function update(uint256 amount, uint256 _amount) external; +} +"#, + ); + + prj.add_source( + "C.sol", + r#" +import "./I.sol"; + +contract C is I { + /// @inheritdoc I + function update(uint256 other, uint256 _amount) external override {} +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + + prj.assert_doc_page( + "src/contract.C.mdx", + str![[r#" +... +### update + +Updates values. + +```solidity +function update(uint256 other, uint256 _amount) external override; +``` + +**Parameters** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| other | `uint256` | Docs for first param. | +| _amount | `uint256` | Docs for second param. | +... +"#]], + ); +} + +// Test that @inheritdoc resolves docs from a deeply inherited chain +// (Base inherits from an interface without redeclaring NatSpec). +#[forgetest_init] +fn inheritdoc_resolves_deep_chain(prj: _, cmd: _) { + prj.add_source( + "IBase.sol", + r#" +interface IBase { + /// @notice Perform the action + /// @param value The input value + function action(uint256 value) external; +} +"#, + ); + + prj.add_source( + "Base.sol", + r#" +import "./IBase.sol"; + +abstract contract Base is IBase { + // No NatSpec redeclaration, inherits from IBase + function action(uint256 value) external virtual {} +} +"#, + ); + + prj.add_source( + "Derived.sol", + r#" +import "./Base.sol"; + +contract Derived is Base { + /// @inheritdoc Base + function action(uint256 value) external override {} +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + + prj.assert_doc_page( + "src/contract.Derived.mdx", + str![[r#" +... +### action + +Perform the action + +```solidity +function action(uint256 value) external override; +``` + +**Parameters** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| value | `uint256` | The input value | +... +"#]], + ); +} + +// Test two rendering behaviors together: +// 1. /** */ block comments are stripped of their ` * ` line decoration. +// 2. `@dev` paragraphs are wrapped in `...` so multi-paragraph content and embedded lists +// render as italic without breaking block-level markdown. +#[forgetest_init] +fn block_comments_strip_star_and_dev_renders_italic(prj: _, cmd: _) { + prj.add_source( +"ECDSA.sol", + r#" +/** + * @notice Library for verifying ECDSA signatures. + * @dev Elliptic Curve Digital Signature Algorithm (ECDSA) operations. + * + * These functions can be used to verify that a message was signed by the holder + * of the private keys of a given address. + */ +library ECDSA { + /** + * @notice Recover the signer address from a signed message hash. + * @dev Returns the address that signed a hashed message (`hash`) with + * `signature` or error string. + * + * The `ecrecover` EVM opcode allows for malleable (non-unique) signatures: + * this function rejects them by requiring the `s` value to be in the lower + * half order, and the `v` value to be either 27 or 28. + * + * @param hash The hash of the signed message. + * @return signer The recovered signer address. + */ + function tryRecover(bytes32 hash, bytes memory signature) internal pure returns (address signer) {} + + /** + * @notice Recover the signer address from `v`, `r`, `s` components. + * @dev Overload of {xref-ECDSA-tryRecover-bytes32-bytes-}[ECDSA.tryRecover] that receives the `v`, + * `r` and `s` signature fields separately. + * + * Documentation for signature generation: + * + * - with https://web3js.readthedocs.io/en/v1.3.4/web3-eth-accounts.html#sign[Web3.js] + * - with https://docs.ethers.io/v5/api/signer/#Signer-signMessage[ethers] + */ + function tryRecover(bytes32 hash, uint8 v, bytes32 r, bytes32 s) internal pure returns (address signer) {} +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + + prj.assert_doc_page( + "src/library.ECDSA.mdx", + str![[r#" +--- +title: "ECDSA" +description: "Library for verifying ECDSA signatures." +--- + +# ECDSA + +Library for verifying ECDSA signatures. + + + +Elliptic Curve Digital Signature Algorithm (ECDSA) operations. + +These functions can be used to verify that a message was signed by the holder +of the private keys of a given address. + + + +## Functions + + + +### tryRecover + +Recover the signer address from a signed message hash. + + + +Returns the address that signed a hashed message (`hash`) with +`signature` or error string. + +The `ecrecover` EVM opcode allows for malleable (non-unique) signatures: +this function rejects them by requiring the `s` value to be in the lower +half order, and the `v` value to be either 27 or 28. + + + +```solidity +function tryRecover(bytes32 hash, bytes memory signature) internal pure returns (address signer); +``` + +**Parameters** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| hash | `bytes32` | The hash of the signed message. | +| signature | `bytes` | | + +**Returns** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| signer | `address` | The recovered signer address. | + + + +### tryRecover + +Recover the signer address from `v`, `r`, `s` components. + + + +Overload of [ECDSA.tryRecover](#tryrecover-bytes32-bytes) that receives the `v`, +`r` and `s` signature fields separately. + +Documentation for signature generation: + +- with https://web3js.readthedocs.io/en/v1.3.4/web3-eth-accounts.html#sign[Web3.js] +- with https://docs.ethers.io/v5/api/signer/#Signer-signMessage[ethers] + + + +```solidity +function tryRecover(bytes32 hash, uint8 v, bytes32 r, bytes32 s) internal pure returns (address signer); +``` + +**Parameters** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| hash | `bytes32` | | +| v | `uint8` | | +| r | `bytes32` | | +| s | `bytes32` | | + +**Returns** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| signer | `address` | | + + +"#]], + ); +} + +// Test that @inheritdoc on a public state variable resolves docs from the interface getter +// function (e.g. ERC20's `totalSupply()`). +// fixes +#[forgetest_init] +fn inheritdoc_variable_resolves_interface_getter(prj: _, cmd: _) { + prj.add_source( + "IERC20.sol", + r#" +interface IERC20 { + /// @notice Returns the total token supply. + /// @return The total supply. + function totalSupply() external view returns (uint256); +} +"#, + ); + + prj.add_source( + "ERC20.sol", + r#" +import "./IERC20.sol"; + +contract ERC20 is IERC20 { + /// @inheritdoc IERC20 + uint256 public totalSupply; +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + + prj.assert_doc_page( + "src/contract.ERC20.mdx", + str![[r#" +... +### totalSupply + +Returns the total token supply. + +```solidity +uint256 public totalSupply; +``` + +**Returns** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <none> | `uint256` | The total supply. | +... +"#]], + ); +} + +// Test that `**Inherits:**` links resolve to the actually-inherited contract even +// when another contract with the same name lives in a directory closer to the +// consumer. Without exact-id resolution, the proximity heuristic in +// `resolve_page` would (wrongly) link to the same-directory namesake. +// Test that references naming a member of the current contract resolve to anchor-only +// links on the same page ({member} and {Contract-member} self-references), and that +// same-file inheritance links to the same-file base instead of a same-named decoy. +// fixes +#[forgetest_init] +fn same_contract_references_resolve_to_anchors(prj: _, cmd: _) { + // Decoys: same-named library and interface in a sibling directory that sorts + // first; references in `external/OlympusERC20.sol` must not resolve to them. + prj.add_source( + "decoys/Decoys.sol", + r#" +library ECDSA { + function tryRecover(bytes32 hash) internal pure returns (address) {} +} + +interface IERC20 { + function balanceOf(address owner) external view returns (uint256); +} +"#, + ); + + prj.add_source( + "external/OlympusERC20.sol", + r#" +library ECDSA { + /// @dev A safe way to ensure this is by receiving a hash of the original + /// message and then calling {toEthSignedMessageHash} on it. + function recover(bytes32 hash) internal pure returns (address) {} + + /// @dev Overload of {ECDSA-tryRecover-bytes32-bytes32}; not {ECDSA-tryRecover-address}. + function tryRecover(bytes32 hash, bytes32 r) internal pure returns (address) {} + + function toEthSignedMessageHash(bytes32 hash) internal pure returns (bytes32) {} +} + +interface IERC20 { + function totalSupply() external view returns (uint256); +} + +interface IOHM is IERC20 { + function mint(address account_) external; +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + + // Same-contract member references become anchor-only links. + prj.assert_doc_page( + "src/external/library.ECDSA.mdx", + str![[r#" +--- +title: "ECDSA" +--- + +# ECDSA + +## Functions + + + +### recover + + + +A safe way to ensure this is by receiving a hash of the original +message and then calling [toEthSignedMessageHash](#toethsignedmessagehash) on it. + + + +```solidity +function recover(bytes32 hash) internal pure returns (address); +``` + +**Parameters** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| hash | `bytes32` | | + +**Returns** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <none> | `address` | | + + + +### tryRecover + + + +Overload of [ECDSA.tryRecover-bytes32-bytes32](#tryrecover-bytes32-bytes32); not `ECDSA`. + + + +```solidity +function tryRecover(bytes32 hash, bytes32 r) internal pure returns (address); +``` + +**Parameters** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| hash | `bytes32` | | +| r | `bytes32` | | + +**Returns** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <none> | `address` | | + + + +### toEthSignedMessageHash + +```solidity +function toEthSignedMessageHash(bytes32 hash) internal pure returns (bytes32); +``` + +**Parameters** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| hash | `bytes32` | | + +**Returns** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <none> | `bytes32` | | + + +"#]], + ); + + // Same-file inheritance links to the same-file interface, not the decoy. + prj.assert_doc_page( + "src/external/interface.IOHM.mdx", + str![[r#" +--- +title: "IOHM" +--- + +# IOHM + +**Inherits:** [IERC20](/src/external/interface.IERC20) + +## Functions + + + +### mint + +```solidity +function mint(address account_) external; +``` + +**Parameters** + +| Name | Type | Description | +| ---- | ---- | ----------- | +| account_ | `address` | | + + +"#]], + ); +} + +#[forgetest_init] +fn inherited_member_references_resolve_to_base_page(prj: _, cmd: _) { + prj.add_source( + "base/A.sol", + r#" +contract A { + struct Payload { + uint256 value; + } + + uint256 public balance$raw; + uint256 private secret; + + error Failure(); + event Fired(); + enum State { Ready } + + function foo() external {} + function overloaded(uint256 value) external {} + function hidden() private {} + + function withAssembly() external pure { + assembly { + function helper() {} + } + } +} +"#, + ); + prj.add_source( + "consumer/A.sol", + r#" +contract A { + function foo() external {} +} + +contract Utility { + function work() external {} +} +"#, + ); + prj.add_source( + "consumer/B.sol", + r#" +import {A as BaseA} from "../base/A.sol"; + +contract B is BaseA { + /// @notice See {foo} or {A-foo}. + /// Also see {Payload}, {Failure}, {Fired}, {State}, and {balance$raw}. + /// The Yul function {helper} has no documentation heading. + /// Private members {hidden} and {secret} are not inherited. + /// The qualified Yul function {A-helper} has no documentation heading. + /// Exact overload {A-overloaded-uint256}; missing overload {A-overloaded-address}. + /// Non-inherited qualified reference {Utility-work} still resolves globally. + function bar() external {} +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + + prj.assert_doc_page( + "src/consumer/contract.B.mdx", + str![[r#" +... +See [foo](/src/base/contract.A#foo) or [A.foo](/src/base/contract.A#foo). +Also see [Payload](/src/base/contract.A#payload), [Failure](/src/base/contract.A#failure), [Fired](/src/base/contract.A#fired), [State](/src/base/contract.A#state), and [balance$raw](/src/base/contract.A#balanceraw). +The Yul function `helper` has no documentation heading. +Private members `hidden` and `secret` are not inherited. +The qualified Yul function `A` has no documentation heading. +Exact overload [A.overloaded-uint256](/src/base/contract.A#overloaded-uint256); missing overload `A`. +Non-inherited qualified reference [Utility.work](/src/consumer/contract.Utility#work) still resolves globally. +... +"#]], + ); +} + +#[forgetest_init] +fn unrendered_override_does_not_link_to_ancestor(prj: _, cmd: _) { + prj.add_source( + "ancestor/A.sol", + r#" +contract A { + function foo() public virtual {} +} +"#, + ); + prj.add_source( + "hidden/Middle.sol", + r#" +import {A} from "../ancestor/A.sol"; + +contract Middle is A { + function foo() public virtual override {} +} +"#, + ); + prj.add_source( + "Middle.sol", + r#" +contract Middle { + function foo() public {} +} +"#, + ); + prj.add_source( + "Child.sol", + r#" +import {Middle} from "./hidden/Middle.sol"; + +contract Child is Middle { + /// @notice See {foo} and {Middle-foo}. + function bar() external {} +} +"#, + ); + prj.update_config(|config| config.doc.ignore = vec!["src/hidden/Middle.sol".to_string()]); + + cmd.args(["doc"]).assert_success(); + + prj.assert_doc_page( + "src/contract.Child.mdx", + str![[r#" +... +See `foo` and `Middle`. +... +"#]], + ); +} + +#[forgetest_init] +fn ambiguous_inherited_contract_name_does_not_link(prj: _, cmd: _) { + prj.add_source( + "left/A.sol", + r#" +contract A { + function left() external {} +} +"#, + ); + prj.add_source( + "right/A.sol", + r#" +contract A { + function right() external {} +} +"#, + ); + prj.add_source( + "Child.sol", + r#" +import {A as LeftA} from "./left/A.sol"; +import {A as RightA} from "./right/A.sol"; + +contract Child is LeftA, RightA { + /// @notice See {A-right}. + function child() external {} +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + + prj.assert_doc_page( + "src/contract.Child.mdx", + str![[r#" +... +See `A`. +... +"#]], + ); +} + +#[forgetest_init] +fn inherited_special_function_links_use_declaring_page(prj: _, cmd: _) { + prj.add_source( + "Special.sol", + r#" +contract A { + constructor() {} + fallback() external payable {} + receive() external payable {} +} + +contract Middle is A {} + +contract Child is Middle { + /// @notice Bare {constructor}, {fallback}, and {receive}. + /// Middle {Middle-constructor}, {Middle-fallback}, and {Middle-receive}. + /// A {A-constructor}, {A-fallback}, and {A-receive}. + function child() external {} +} +"#, + ); + + cmd.args(["doc"]).assert_success(); + + prj.assert_doc_page( + "src/contract.Child.mdx", + str![[r#" +... +Bare `constructor`, [fallback](/src/contract.A#fallback), and [receive](/src/contract.A#receive). +Middle `Middle`, `Middle`, and `Middle`. +A [A.constructor](/src/contract.A#constructor), [A.fallback](/src/contract.A#fallback), and [A.receive](/src/contract.A#receive). +... +"#]], + ); +} + +#[forgetest_init] +fn inheritance_links_use_exact_base_id(prj: _, cmd: _) { + // Two unrelated `Token` contracts in sibling directories. + prj.add_source( + "a/Token.sol", + r#" +contract Token {} +"#, + ); + prj.add_source( + "b/Token.sol", + r#" +contract Token {} +"#, + ); + + // Consumer lives next to `a/Token.sol` but explicitly inherits from `b/Token`. + prj.add_source( + "a/Consumer.sol", + r#" +import {Token} from "../b/Token.sol"; + +contract Consumer is Token {} +"#, + ); + + cmd.args(["doc"]).assert_success(); + + prj.assert_doc_page( + "src/a/contract.Consumer.mdx", + str![[r#" +--- +title: "Consumer" +--- + +# Consumer + +**Inherits:** [Token](/src/b/contract.Token) + + +"#]], + ); +} + +trait DocProject { + fn assert_doc_page(&self, page: &str, expected: impl IntoData); + fn assert_doc_functions(&self, page: &str, expected: impl IntoData); +} + +impl DocProject for TestProject { + #[track_caller] + fn assert_doc_page(&self, page: &str, expected: impl IntoData) { + assert_data_eq!( + fs::read_to_string(self.root().join("docs/src/pages").join(page)).unwrap(), + expected + ); + } + + #[track_caller] + fn assert_doc_functions(&self, page: &str, expected: impl IntoData) { + let page = fs::read_to_string(self.root().join("docs/src/pages").join(page)).unwrap(); + let (_, functions) = page.split_once("## Functions\n\n").expect("missing function section"); + assert_data_eq!(functions, expected); + } } diff --git a/crates/forge/tests/cli/doc/site.rs b/crates/forge/tests/cli/doc/site.rs new file mode 100644 index 0000000000000..ddd784caa9b48 --- /dev/null +++ b/crates/forge/tests/cli/doc/site.rs @@ -0,0 +1,145 @@ +use super::*; +use path_slash::PathExt; + +#[forgetest_init] +fn regeneration_preserves_user_files_and_prunes_owned_pages(prj: _, cmd: _) { + prj.add_source("Owned.sol", "contract Owned {}"); + let docs = prj.root().join("docs"); + let pages = docs.join("src/pages"); + let readme = prj.root().join("README.md"); + // A user page predating the manifest must survive both the first generation and pruning. + let user_page = pages.join("src/overview.mdx"); + fs::create_dir_all(user_page.parent().unwrap()).unwrap(); + fs::write(&user_page, "# Overview\n\nHand-written page.\n").unwrap(); + fs::write(&readme, "# First homepage\n").unwrap(); + cmd.arg("doc").assert_success(); + + assert!(pages.join("src/contract.Owned.mdx").is_file()); + let manifest = pages.join(".forge-doc-manifest"); + assert_data_eq!(fs::read_to_string(&manifest).unwrap(), "src/contract.Owned.mdx\n"); + assert_data_eq!(fs::read_to_string(&user_page).unwrap(), "# Overview\n\nHand-written page.\n"); + for file in ["vocs.config.ts", "package.json", ".gitignore"] { + assert!(docs.join(file).is_file()); + } + let edited = ["vocs.config.ts", "package.json", ".gitignore", "src/pages/guide.mdx"]; + for path in edited { + fs::write(docs.join(path), "user content\n").unwrap(); + } + // Unsafe manifest entries must never grant ownership of files outside pages/. + let outside = docs.join("src/outside.mdx"); + fs::write(&outside, "outside content\n").unwrap(); + let previous = fs::read_to_string(&manifest).unwrap(); + fs::write(&manifest, format!("{previous}\n../outside.mdx\n{}\n", outside.display())).unwrap(); + fs::remove_file(prj.root().join("src/Owned.sol")).unwrap(); + prj.add_source("Replacement.sol", "contract Replacement {}"); + fs::write(&readme, "# Second homepage\n").unwrap(); + cmd.forge_fuse().arg("doc").assert_success(); + + for path in edited { + assert_data_eq!(fs::read_to_string(docs.join(path)).unwrap(), "user content\n"); + } + assert_data_eq!(fs::read_to_string(outside).unwrap(), "outside content\n"); + assert_data_eq!(fs::read_to_string(user_page).unwrap(), "# Overview\n\nHand-written page.\n"); + assert!(!pages.join("src/contract.Owned.mdx").exists()); + assert!(pages.join("src/contract.Replacement.mdx").exists()); + assert_data_eq!(fs::read_to_string(manifest).unwrap(), "src/contract.Replacement.mdx\n"); + prj.assert_doc_page("index.mdx", "# Second homepage\n"); + assert_data_eq!( + fs::read_to_string(docs.join("vocs.sidebar.ts")).unwrap(), + str![[r#" +// This file is generated by forge doc. Do not edit manually. +// Re-run `forge doc` to update. + +export const sidebar = [ + { + text: "src", + items: [ + { text: "Replacement", link: "/src/contract.Replacement" }, + ], + }, +] + +"#]] + ); +} + +#[forgetest_init] +fn outside_root_library_links_match_generated_pages(prj: _, cmd: _) { + let library = tempfile::tempdir().unwrap(); + // Avoid verbatim Windows prefixes, whose backslashes become Solidity string escapes. + let library_path = dunce::canonicalize(library.path()).unwrap(); + let source = library_path.join("vendor/package/External.sol"); + fs::create_dir_all(source.parent().unwrap()).unwrap(); + fs::write(&source, "pragma solidity ^0.8.0; contract External {}\n").unwrap(); + prj.update_config(|config| config.libs = vec![library_path]); + prj.add_source( + "Consumer.sol", + &format!( + "import {{External}} from \"{}\"; contract Consumer is External {{}}", + source.to_slash_lossy() + ), + ); + cmd.args(["doc", "--include-libraries"]).assert_success().stderr_eq(str![""]); + assert!(prj.root().join("docs/src/pages/lib/vendor/package/contract.External.mdx").is_file()); + prj.assert_doc_page( + "src/contract.Consumer.mdx", + str![[r#" +--- +title: "Consumer" +--- + +# Consumer + +**Inherits:** [External](/lib/vendor/package/contract.External) + + +"#]], + ); +} + +#[forgetest_init] +fn deployments_only_apply_to_matching_concrete_contract(prj: _, cmd: _) { + prj.add_source("Target.sol", "contract Target {} contract Sibling {}"); + prj.add_source("API.sol", "interface API {}"); + prj.add_source("Abstract.sol", "abstract contract Abstract {}"); + prj.add_source("Lib.sol", "library Lib {}"); + let deployments = prj.root().join("deployments/local"); + fs::create_dir_all(&deployments).unwrap(); + for name in ["Target", "Sibling", "API", "Abstract", "Lib"] { + fs::write( + deployments.join(format!("{name}.json")), + r#"{"address":"0x0000000000000000000000000000000000000001"}"#, + ) + .unwrap(); + } + cmd.args(["doc", "--deployments"]).assert_success(); + prj.assert_doc_page( + "src/contract.Target.mdx", + str![[r#" +--- +title: "Target" +--- + +# Target + +**Deployments** + +| Network | Address | +| ------- | ------- | +| local | `0x0000000000000000000000000000000000000001` | + + +"#]], + ); + for (kind, name) in [ + ("contract", "Sibling"), + ("interface", "API"), + ("abstract", "Abstract"), + ("library", "Lib"), + ] { + prj.assert_doc_page( + &format!("src/{kind}.{name}.mdx"), + format!("---\ntitle: \"{name}\"\n---\n\n# {name}\n\n"), + ); + } +} diff --git a/crates/forge/tests/cli/eip712.rs b/crates/forge/tests/cli/eip712.rs index bc251b5c8ff6d..1abc6f80d23ae 100644 --- a/crates/forge/tests/cli/eip712.rs +++ b/crates/forge/tests/cli/eip712.rs @@ -1,10 +1,10 @@ use foundry_config::fs_permissions::PathPermission; -forgetest!(test_eip712, |prj, cmd| { - let path = prj - .add_source( - "Structs", - r#" +#[forgetest] +fn test_eip712(prj: _, cmd: _) { + let path = prj.add_test( + "Structs.sol", + r#" library Structs { struct Foo { Bar bar; @@ -51,12 +51,25 @@ library Structs2 { Structs.Rec rec; } } + +contract DummyTest { + function testDummy() public pure { + revert("test"); + } +} "#, - ) - .unwrap(); + ); - cmd.forge_fuse().args(["eip712", path.to_string_lossy().as_ref()]).assert_success().stdout_eq( - str![[r#" + cmd.forge_fuse() + .args(["eip712", path.to_string_lossy().as_ref()]) + .assert_success() + .stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +"#]]) + .stderr_eq(str![[r#" Structs.sol > Structs > Foo: - type: Foo(Bar bar)Art(uint256 id)Bar(Art art) - hash: 0x6d9b732373bd999fde4072274c752e03f7437067dd75521eb406d8edf1d30f7d @@ -94,8 +107,7 @@ Structs.sol > Structs2 > FooBar: - hash: 0xce88f333fe5b5d4901ceb2569922ffe741cda3afc383a63d34ed2c3d565e42d8 -"#]], - ); +"#]]); cmd.forge_fuse().args(["eip712", path.to_string_lossy().as_ref(), "--json"]).assert_success().stdout_eq( str![[r#" @@ -149,13 +161,36 @@ Structs.sol > Structs2 > FooBar: "#]], ); -}); -forgetest!(test_eip712_free_standing_structs, |prj, cmd| { - let path = prj - .add_source( - "FreeStandingStructs.sol", - r#" + // Testing `solar_project` doesn't mess up cache. + cmd.forge_fuse().arg("test").assert_failure().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/Structs.sol:DummyTest +[FAIL: test] testDummy() ([GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/Structs.sol:DummyTest +[FAIL: test] testDummy() ([GAS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test +Tip: Run `forge test --debug --match-test ` to inspect one failing test in the debugger + +"#]]); +} + +#[forgetest] +fn test_eip712_free_standing_structs(prj: _, cmd: _) { + let path = prj.add_source( + "FreeStandingStructs.sol", + r#" // free-standing struct (outside a contract and lib) struct FreeStanding { uint256 id; @@ -174,11 +209,18 @@ library InsideLibrary { } } "#, - ) - .unwrap(); + ); - cmd.forge_fuse().args(["eip712", path.to_string_lossy().as_ref()]).assert_success().stdout_eq( - str![[r#" + cmd.forge_fuse() + .args(["eip712", path.to_string_lossy().as_ref()]) + .assert_success() + .stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +"#]]) + .stderr_eq(str![[r#" FreeStanding: - type: FreeStanding(uint256 id,string name) - hash: 0xfb3c934b2382873277133498bde6eb3914ab323e3bef8b373ebcd423969bf1a2 @@ -192,11 +234,11 @@ FreeStandingStructs.sol > InsideLibrary > LibraryStruct: - hash: 0x81d6d25f4d37549244d76a68f23ecdcbf3ae81e5a361ed6c492b6a2e126a2843 -"#]], - ); -}); +"#]]); +} -forgetest!(test_eip712_cheatcode_simple, |prj, cmd| { +#[forgetest] +fn test_eip712_cheatcode_simple(prj: _, cmd: _) { prj.insert_ds_test(); prj.insert_vm(); prj.insert_console(); @@ -213,8 +255,7 @@ contract Eip712Structs { } } "#, - ) - .unwrap(); + ); prj.add_source("Eip712Cheat.sol", r#" import "./test.sol"; @@ -243,8 +284,7 @@ contract Eip712Test is DSTest { } } "#, - ) - .unwrap(); + ); cmd.forge_fuse().args(["bind-json"]).assert_success(); @@ -256,8 +296,7 @@ contract Eip712Test is DSTest { [r#" [COMPILING_FILES] with [SOLC_VERSION] [SOLC_VERSION] [ELAPSED] -Compiler run successful! - +... Ran 1 test for src/Eip712Cheat.sol:Eip712Test [PASS] testEip712HashType() ([GAS]) Logs: @@ -269,9 +308,10 @@ Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) "#] ]); -}); +} -forgetest!(test_eip712_cheatcode_nested, |prj, cmd| { +#[forgetest] +fn test_eip712_cheatcode_nested(prj: _, cmd: _) { prj.insert_ds_test(); prj.insert_vm(); prj.insert_console(); @@ -295,8 +335,7 @@ contract Eip712Structs { } } "#, - ) - .unwrap(); + ); prj.add_source("Eip712Cheat.sol", r#" import "./test.sol"; @@ -341,8 +380,7 @@ contract Eip712Test is DSTest { } } "#, - ) - .unwrap(); + ); // cheatcode by type definition can run without bindings cmd.forge_fuse() @@ -370,6 +408,9 @@ Encountered 1 failing test in src/Eip712Cheat.sol:Eip712Test Encountered a total of 1 failing tests, 0 tests succeeded +Tip: Run `forge test --rerun` to retry only the 1 failed test +Tip: Run `forge test --debug --match-test ` to inspect one failing test in the debugger + "#]]); cmd.forge_fuse().args(["bind-json"]).assert_success(); @@ -414,6 +455,9 @@ Encountered 1 failing test in src/Eip712Cheat.sol:Eip712Test Encountered a total of 1 failing tests, 0 tests succeeded +Tip: Run `forge test --rerun` to retry only the 1 failed test +Tip: Run `forge test --debug --match-test ` to inspect one failing test in the debugger + "#]]); cmd.forge_fuse().args(["bind-json", "utils/CustomJsonBindings.sol"]).assert_success(); @@ -428,10 +472,18 @@ Encountered a total of 1 failing tests, 0 tests succeeded "--match-test", "testEip712HashType_byCustomPathAndTypeName", ]) - .assert_success(); -}); + .assert_success() + .stdout_eq(str![[r#" +... +Ran 1 test for src/Eip712Cheat.sol:Eip712Test +[PASS] testEip712HashType_byCustomPathAndTypeName() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} -forgetest!(test_eip712_hash_struct_simple, |prj, cmd| { +#[forgetest] +fn test_eip712_hash_struct_simple(prj: _, cmd: _) { prj.insert_ds_test(); prj.insert_vm(); prj.insert_console(); @@ -482,13 +534,23 @@ contract Eip712HashStructDomainTest is DSTest { } } "#, - ) - .unwrap(); + ); - cmd.forge_fuse().args(["test", "--mc", "Eip712HashStructDomainTest", "-vvvv"]).assert_success(); -}); + cmd.forge_fuse() + .args(["test", "--mc", "Eip712HashStructDomainTest", "-vvvv"]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 1 test for src/Eip712HashStructDomainTest.sol:Eip712HashStructDomainTest +[PASS] testHashEIP712Domain() ([GAS]) +... +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} -forgetest!(test_eip712_hash_struct_complex, |prj, cmd| { +#[forgetest] +fn test_eip712_hash_struct_complex(prj: _, cmd: _) { prj.insert_ds_test(); prj.insert_vm(); prj.insert_console(); @@ -530,8 +592,7 @@ library PermitHash { } } "#, - ) - .unwrap(); + ); prj.add_source( "Eip712Transaction.sol", @@ -586,8 +647,7 @@ library TransactionHash { } } "#, - ) - .unwrap(); + ); let bindings = prj.root().join("utils").join("JsonBindings.sol"); prj.update_config(|config| config.fs_permissions.add(PathPermission::read(&bindings))); @@ -714,8 +774,7 @@ contract Eip712HashStructTest is DSTest { } } "#, - ) - .unwrap(); + ); cmd.forge_fuse() .args(["test", "--mc", "Eip712HashStructTest", "-vv"]) @@ -733,9 +792,10 @@ Logs: 0x3ed744fdcea02b6b9ad45a9db6e648bf6f18c221909f9ee425191f2a02f9e4a8 ... "#]]); -}); +} -forgetest!(test_eip712_hash_typed_data, |prj, cmd| { +#[forgetest] +fn test_eip712_hash_typed_data(prj: _, cmd: _) { prj.insert_ds_test(); prj.insert_vm(); prj.insert_console(); @@ -760,8 +820,106 @@ contract Eip712HashTypedDataTest is DSTest { } } "#, - ) - .unwrap(); + ); + + cmd.forge_fuse().args(["test", "--mc", "Eip712HashTypedDataTest"]).assert_success().stdout_eq( + str![[r#" +... +Ran 1 test for src/Eip712HashTypedData.sol:Eip712HashTypedDataTest +[PASS] testHashEIP712Message() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]], + ); +} + +// repro: +#[forgetest] +fn test_eip712_hash_typed_data_repro(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.insert_vm(); + prj.insert_console(); - cmd.forge_fuse().args(["test", "--mc", "Eip712HashTypedDataTest"]).assert_success(); -}); + prj.add_source( + "Eip712HashTypedData.sol", + r#" +import "./Vm.sol"; +import "./test.sol"; +import "./console.sol"; +contract CounterStrike { + bytes32 public constant ATTACK_TYPEHASH = keccak256("Attack(address player,uint128 x,uint128 y,uint40 shootTime)"); + bytes32 public constant DOMAIN_TYPEHASH = + keccak256("EIP712Domain(string name,uint256 chainId,address verifyingContract)"); + string public constant PROTOCOL_NAME = "CounterStrike"; +} + +contract CounterStrike_Test is DSTest { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + struct EIP712Domain { + string name; + uint256 chainId; + address verifyingContract; + } + + struct Attack { + address player; + uint128 x; + uint128 y; + uint40 shootTime; + } + + string constant SCHEMA_EIP712_DOMAIN = "EIP712Domain(string name,uint256 chainId,address verifyingContract)"; + string constant SCHEMA_ATTACK = "Attack(address player,uint128 x,uint128 y,uint40 shootTime)"; + + CounterStrike public counterStrike; + address public player; + uint256 public playerPrivateKey; + uint128 public x = 10_000e18; + uint128 public y = 20_000e18; + uint40 public shootTime = 12_345_678; + + function setUp() public { + counterStrike = new CounterStrike(); + } + + function test_Attack() public view { + string memory domainJson = vm.serializeJsonType( + SCHEMA_EIP712_DOMAIN, + abi.encode( + EIP712Domain({ + name: counterStrike.PROTOCOL_NAME(), + chainId: block.chainid, + verifyingContract: address(counterStrike) + }) + ) + ); + string memory messageJson = vm.serializeJsonType( + SCHEMA_ATTACK, abi.encode(Attack({ player: player, x: x, y: y, shootTime: shootTime })) + ); + + string memory typesJson = string.concat( + '{"EIP712Domain":[{"name":"name","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}],"Attack":[{"name":"player","type":"address"},{"name":"x","type":"uint128"},{"name":"y","type":"uint128"},{"name":"shootTime","type":"uint40"}]}' + ); + string memory primaryType = '"Attack"'; + string memory typedDataJson = string.concat( + '{"types":', + typesJson, + ',"primaryType":', + primaryType, + ',"domain":', + domainJson, + ',"message":', + messageJson, + "}" + ); + + bytes32 digest = vm.eip712HashTypedData(typedDataJson); + console.logBytes32(digest); + } +} +"#, + ); + + cmd.forge_fuse().args(["test", "-vvv"]).assert_success(); +} diff --git a/crates/forge/tests/cli/ext_integration.rs b/crates/forge/tests/cli/ext_integration.rs index 1fe23ae0ff90e..085b583ef849e 100644 --- a/crates/forge/tests/cli/ext_integration.rs +++ b/crates/forge/tests/cli/ext_integration.rs @@ -1,14 +1,15 @@ use foundry_test_utils::util::ExtTester; // Actively maintained tests -// Last updated: June 19th 2025 +// Last updated: April 29th 2026 // #[test] fn forge_std() { - ExtTester::new("foundry-rs", "forge-std", "60acb7aaadcce2d68e52986a0a66fe79f07d138f") + ExtTester::new("foundry-rs", "forge-std", "8987040ede9553cea20c95ad40d0455930f9c8e0") // Skip fork tests. .args(["--nmc", "Fork"]) + .verbosity(2) .run(); } @@ -16,7 +17,7 @@ fn forge_std() { #[test] #[cfg_attr(windows, ignore = "Windows cannot find installed programs")] fn prb_math() { - ExtTester::new("PaulRBerg", "prb-math", "aad73cfc6cdc2c9b660199b5b1e9db391ea48640") + ExtTester::new("PaulRBerg", "prb-math", "82e5ed5561d0a1c43a3a59edbf4291c8de26479e") .install_command(&["bun", "install", "--prefer-offline"]) // Try npm if bun fails / is not installed. .install_command(&["npm", "install", "--prefer-offline"]) @@ -38,23 +39,21 @@ fn prb_proxy() { #[test] #[cfg_attr(windows, ignore = "Windows cannot find installed programs")] fn sablier_v2_core() { + // Keep the standalone v1.2.0 fixture; newer revisions use a different monorepo layout. let mut tester = - ExtTester::new("sablier-labs", "v2-core", "d85521f5615f6c19612ff250ee89c57b9afa6aa2") + ExtTester::new("sablier-labs", "v2-core", "73356945b53e8dd4112f34f3e2c63c278c4a5239") // Skip fork tests. .args(["--nmc", "Fork"]) // Increase the gas limit: https://github.com/sablier-labs/v2-core/issues/956 - .args(["--gas-limit", u64::MAX.to_string().as_str()]) + .args(["--gas-limit", &u64::MAX.to_string()]) // Run tests without optimizations. .env("FOUNDRY_PROFILE", "lite") - .install_command(&["bun", "install", "--prefer-offline"]) - // Try npm if bun fails / is not installed. - .install_command(&["npm", "install", "--prefer-offline"]); + .install_command(&["bun", "install", "--frozen-lockfile"]) + .verbosity(2); // This test reverts due to memory limit without isolation. This revert is not reached with // isolation because memory is divided between separate EVMs created by inner calls. - if cfg!(feature = "isolate-by-default") { - tester = tester.args(["--nmt", "test_RevertWhen_LoopCalculationOverflowsBlockGasLimit"]); - } + tester = tester.args(["--nmt", "test_RevertWhen_LoopCalculationOverflowsBlockGasLimit"]); tester.run(); } @@ -62,25 +61,32 @@ fn sablier_v2_core() { // #[test] fn solady() { - ExtTester::new("Vectorized", "solady", "701406e8126cfed931645727b274df303fbcd94d").run(); + let mut tester = + ExtTester::new("Vectorized", "solady", "90db92ce173856605d24a554969f2c67cadbc7e9"); + + // This test expects the mover contract created via CREATE2 to be selfdestructed within the + // same transaction. In isolation mode, each top-level call runs as a separate transaction + // context, so the selfdestruct doesn't clear the code as expected by the test. + tester = tester.args(["--nmt", "testSafeMoveETHViaMover"]); + + tester.run(); } // #[test] #[cfg_attr(windows, ignore = "Windows cannot find installed programs")] -#[cfg(not(feature = "isolate-by-default"))] fn snekmate() { - ExtTester::new("pcaversaccio", "snekmate", "601031d244475b160a00f73053532528bf665cc3") - .install_command(&["pnpm", "install", "--prefer-offline"]) - // Try npm if pnpm fails / is not installed. - .install_command(&["npm", "install", "--prefer-offline"]) + ExtTester::new("pcaversaccio", "snekmate", "df2816d6a5ecdb8dcc1257e22089422825fd5e27") + .fuzz_runs(256) + .python_package("git+https://github.com/vyperlang/vyper@v0.5.0a3") + .install_command(&["pnpm", "install", "--frozen-lockfile"]) .run(); } // #[test] fn mds1_multicall3() { - ExtTester::new("mds1", "multicall", "5f90062160aedb7c807fadca469ac783a0557b57").run(); + ExtTester::new("mds1", "multicall", "b667d67ecfa5361a81e8f110234ce242613b0012").run(); } // Legacy tests diff --git a/crates/forge/tests/cli/failure_assertions.rs b/crates/forge/tests/cli/failure_assertions.rs index d3c3143b49d64..1a2d6399bda7d 100644 --- a/crates/forge/tests/cli/failure_assertions.rs +++ b/crates/forge/tests/cli/failure_assertions.rs @@ -1,6 +1,7 @@ // Tests in which we want to assert failures. -forgetest!(test_fail_deprecation, |prj, cmd| { +#[forgetest] +fn test_fail_deprecation(prj: _, cmd: _) { prj.insert_ds_test(); prj.add_source( @@ -17,26 +18,35 @@ forgetest!(test_fail_deprecation, |prj, cmd| { } } "#, - ) - .unwrap(); + ); - cmd.forge_fuse().args(["test", "--mc", "DeprecationTestFail"]).assert_failure().stdout_eq( - r#"[COMPILING_FILES] with [SOLC_VERSION] + cmd.forge_fuse() + .args(["test", "--mc", "DeprecationTestFail"]) + .assert_failure() + .stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] [SOLC_VERSION] [ELAPSED] ... -[FAIL: `testFail*` has been removed. Consider changing to test_Revert[If|When]_Condition and expecting a revert] Found 2 instances: testFail_deprecated, testFail_deprecated2 ([GAS]) -Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] -... -"#, - ); -}); +Failing tests: +Encountered 2 failing tests in src/DeprecationTestFail.t.sol:DeprecationTestFail +[FAIL: `testFail*` has been removed. Consider changing to test_Revert[If|When]_Condition and expecting a revert] testFail_deprecated() ([GAS]) +[FAIL: `testFail*` has been removed. Consider changing to test_Revert[If|When]_Condition and expecting a revert] testFail_deprecated2() ([GAS]) -forgetest!(expect_revert_tests_should_fail, |prj, cmd| { +Encountered a total of 2 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 2 failed tests +Tip: Run `forge test --debug --match-test ` to inspect one failing test in the debugger + +"#]]); +} + +#[forgetest] +fn expect_revert_tests_should_fail(prj: _, cmd: _) { prj.insert_ds_test(); prj.insert_vm(); let expect_revert_failure_tests = include_str!("../fixtures/ExpectRevertFailures.t.sol"); - prj.add_source("ExpectRevertFailures.sol", expect_revert_failure_tests).unwrap(); + prj.add_source("ExpectRevertFailures.sol", expect_revert_failure_tests); cmd.forge_fuse() .args(["test", "--mc", "ExpectRevertFailureTest"]) @@ -63,8 +73,13 @@ Suite result: FAILED. 0 passed; 7 failed; 0 skipped; [ELAPSED] .stdout_eq( r#"No files changed, compilation skipped ... +[FAIL: Reverter != expected reverter: [..] != 0x000000000000000000000000000000000000dEaD] testShouldFailExpectPartialRevertWrongReverterTopLevelCreate() ([GAS]) +[FAIL: Reverter != expected reverter: [..] != [..]] testShouldFailExpectRevertNestedCreateOuterAddress() ([GAS]) +[FAIL: Reverter != expected reverter: [..] != 0x000000000000000000000000000000000000dEaD] testShouldFailExpectRevertWithBytesWrongReverterTopLevelCreate() ([GAS]) +[FAIL: Reverter != expected reverter: [..] != 0x000000000000000000000000000000000000dEaD] testShouldFailExpectRevertWrongReverterNestedCreate() ([GAS]) +[FAIL: Reverter != expected reverter: [..] != 0x000000000000000000000000000000000000dEaD] testShouldFailExpectRevertWrongReverterTopLevelCreate() ([GAS]) [FAIL: next call did not revert as expected] testShouldFailExpectRevertsNotOnImmediateNextCall() ([GAS]) -Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +Suite result: FAILED. 0 passed; 6 failed; 0 skipped; [ELAPSED] ... "#, ); @@ -100,30 +115,33 @@ Suite result: FAILED. 0 passed; 6 failed; 0 skipped; [ELAPSED] Suite result: FAILED. 0 passed; 6 failed; 0 skipped; [ELAPSED] ... "#); -}); +} -forgetest!(expect_call_tests_should_fail, |prj, cmd| { +#[forgetest] +fn expect_call_tests_should_fail(prj: _, cmd: _) { prj.insert_ds_test(); prj.insert_vm(); let expect_call_failure_tests = include_str!("../fixtures/ExpectCallFailures.t.sol"); - prj.add_source("ExpectCallFailures.sol", expect_call_failure_tests).unwrap(); + prj.add_source("ExpectCallFailures.sol", expect_call_failure_tests); cmd.forge_fuse().args(["test", "--mc", "ExpectCallFailureTest"]).assert_failure().stdout_eq( r#"[COMPILING_FILES] with [SOLC_VERSION] [SOLC_VERSION] [ELAPSED] ... [FAIL: expected call to 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f with data 0xc290d6910000000000000000000000000000000000000000000000000000000000000002, value 1 to be called 1 time, but was called 0 times] testShouldFailExpectCallValue() ([GAS]) +[FAIL: expected call to 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f with data 0x771602f700000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000002 to be called 1 time, but the call reverted instead; ensure you're testing the happy path when using `expectCall`] testShouldFailExpectCallWhenTestHalts() ([GAS]) [FAIL: expected call to 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f with data 0x771602f700000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000002 to be called 1 time, but was called 0 times] testShouldFailExpectCallWithData() ([GAS]) [FAIL: expected call to 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f with data 0x771602f7000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000030000000000000000000000000000000000000000000000000000000000000003 to be called 1 time, but was called 0 times] testShouldFailExpectCallWithMoreParameters() ([GAS]) [FAIL: expected call to 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f with data 0x771602f700000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001, value 0, gas 25000 to be called 1 time, but was called 0 times] testShouldFailExpectCallWithNoValueAndWrongGas() ([GAS]) [FAIL: expected call to 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f with data 0x771602f700000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001, value 0, minimum gas 50001 to be called 1 time, but was called 0 times] testShouldFailExpectCallWithNoValueAndWrongMinGas() ([GAS]) [FAIL: next call did not revert as expected] testShouldFailExpectCallWithRevertDisallowed() ([GAS]) +[FAIL: expected call to 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f with data 0x771602f700000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000002, call type DelegateCall to be called 1 time, but was called 0 times] testShouldFailExpectDelegateCallWithCall() ([GAS]) [FAIL: expected call to 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f with data 0x3fc7c698 to be called 1 time, but was called 0 times] testShouldFailExpectInnerCall() ([GAS]) [FAIL: expected call to 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f with data 0x771602f700000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000002 to be called 3 times, but was called 2 times] testShouldFailExpectMultipleCallsWithDataAdditive() ([GAS]) [FAIL: expected call to 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f with data 0x771602f7 to be called 1 time, but was called 0 times] testShouldFailExpectSelectorCall() ([GAS]) -Suite result: FAILED. 0 passed; 9 failed; 0 skipped; [ELAPSED] +Suite result: FAILED. 0 passed; 11 failed; 0 skipped; [ELAPSED] ... "#, ); @@ -157,15 +175,16 @@ Suite result: FAILED. 0 passed; 3 failed; 0 skipped; [ELAPSED] ... "#, ); -}); +} -forgetest!(expect_create_tests_should_fail, |prj, cmd| { +#[forgetest] +fn expect_create_tests_should_fail(prj: _, cmd: _) { prj.insert_ds_test(); prj.insert_vm(); let expect_create_failures = include_str!("../fixtures/ExpectCreateFailures.t.sol"); - prj.add_source("ExpectCreateFailures.t.sol", expect_create_failures).unwrap(); + prj.add_source("ExpectCreateFailures.t.sol", expect_create_failures); cmd.forge_fuse().args(["test", "--mc", "ExpectCreateFailureTest"]).assert_failure().stdout_eq(str![[r#" ... @@ -181,30 +200,34 @@ Suite result: FAILED. 0 passed; 8 failed; 0 skipped; [ELAPSED] ... "#]]); -}); +} -forgetest!(expect_emit_tests_should_fail, |prj, cmd| { +#[forgetest] +fn flaky_expect_emit_tests_should_fail(prj: _, cmd: _) { prj.insert_ds_test(); prj.insert_vm(); let expect_emit_failure_tests = include_str!("../fixtures/ExpectEmitFailures.t.sol"); - prj.add_source("ExpectEmitFailures.sol", expect_emit_failure_tests).unwrap(); + prj.add_source("ExpectEmitFailures.sol", expect_emit_failure_tests); + + cmd.forge_fuse().arg("build").assert_success(); + cmd.forge_fuse().args(["selectors", "cache"]).assert_success(); - cmd.forge_fuse().args(["test", "--mc", "ExpectEmitFailureTest"]).assert_failure().stdout_eq(str![[r#" + cmd.forge_fuse().args(["test", "--mc", "ExpectEmitFailureTest"]).assert_failure().stdout_eq(str![[r#"No files changed, compilation skipped ... -[FAIL: log != expected log] testShouldFailCanMatchConsecutiveEvents() ([GAS]) -[FAIL: log != expected log] testShouldFailDifferentIndexedParameters() ([GAS]) +[FAIL: E != expected A] testShouldFailCanMatchConsecutiveEvents() ([GAS]) +[FAIL: SomethingElse indexed topic count mismatch: expected 1, got 0] testShouldFailDifferentIndexedParameters() ([GAS]) [FAIL: log != expected log] testShouldFailEmitOnlyAppliesToNextCall() ([GAS]) [FAIL: next call did not revert as expected] testShouldFailEmitWindowWithRevertDisallowed() ([GAS]) -[FAIL: log != expected log] testShouldFailEventsOnTwoCalls() ([GAS]) -[FAIL: log != expected log; counterexample: calldata=[..] args=[..]] testShouldFailExpectEmit(bool,bool,bool,bool,uint128,uint128,uint128,uint128) (runs: 0, [AVG_GAS]) -[FAIL: log != expected log] testShouldFailExpectEmitAddress() ([GAS]) -[FAIL: log != expected log] testShouldFailExpectEmitAddressWithArgs() ([GAS]) -[FAIL: log != expected log] testShouldFailExpectEmitCanMatchWithoutExactOrder() ([GAS]) +[FAIL: E != expected A] testShouldFailEventsOnTwoCalls() ([GAS]) +[FAIL: Something param mismatch at [..]: expected=[..], got=[..]; counterexample: calldata=[..] args=[..]] testShouldFailExpectEmit(bool,bool,bool,bool,uint128,uint128,uint128,uint128) (runs: 0, [AVG_GAS]) +[FAIL: log emitter mismatch: expected=[..], got=[..]] testShouldFailExpectEmitAddress() ([GAS]) +[FAIL: log emitter mismatch: expected=[..], got=[..]] testShouldFailExpectEmitAddressWithArgs() ([GAS]) +[FAIL: Something != expected SomethingElse] testShouldFailExpectEmitCanMatchWithoutExactOrder() ([GAS]) [FAIL: expected an emit, but no logs were emitted afterwards. you might have mismatched events or not enough events were emitted] testShouldFailExpectEmitDanglingNoReference() ([GAS]) [FAIL: expected an emit, but no logs were emitted afterwards. you might have mismatched events or not enough events were emitted] testShouldFailExpectEmitDanglingWithReference() ([GAS]) -[FAIL: log != expected log; counterexample: calldata=[..] args=[..]] testShouldFailExpectEmitNested(bool,bool,bool,bool,uint128,uint128,uint128,uint128) (runs: 0, [AVG_GAS]) +[FAIL: Something param mismatch at [..]: expected=[..], got=[..]; counterexample: calldata=[..] args=[..]] testShouldFailExpectEmitNested(bool,bool,bool,bool,uint128,uint128,uint128,uint128) (runs: 0, [AVG_GAS]) [FAIL: log != expected log] testShouldFailLowLevelWithoutEmit() ([GAS]) [FAIL: log != expected log] testShouldFailMatchRepeatedEventsOutOfOrder() ([GAS]) [FAIL: log != expected log] testShouldFailNoEmitDirectlyOnNextCall() ([GAS]) @@ -220,55 +243,185 @@ Suite result: FAILED. 0 passed; 15 failed; 0 skipped; [ELAPSED] ... [FAIL: log != expected log] testShouldFailCountEmitsFromAddress() ([GAS]) [FAIL: log != expected log] testShouldFailCountLessEmits() ([GAS]) -[FAIL: log != expected log] testShouldFailEmitSomethingElse() ([GAS]) -[FAIL: log emitted 1 times, expected 0] testShouldFailNoEmit() ([GAS]) -[FAIL: log emitted 1 times, expected 0] testShouldFailNoEmitFromAddress() ([GAS]) +[FAIL: SomethingElse != expected Something] testShouldFailEmitSomethingElse() ([GAS]) +[FAIL: log emitted but expected 0 times] testShouldFailNoEmit() ([GAS]) +[FAIL: log emitted but expected 0 times] testShouldFailNoEmitFromAddress() ([GAS]) Suite result: FAILED. 0 passed; 5 failed; 0 skipped; [ELAPSED] ... "#, ); -}); +} -forgetest!(mem_safety_test_should_fail, |prj, cmd| { +#[forgetest] +fn flaky_expect_emit_params_tests_should_fail(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.insert_vm(); + prj.update_config(|config| { + config.fuzz.dictionary.max_fuzz_dictionary_literals = 0; + }); + + let expect_emit_failure_src = include_str!("../fixtures/ExpectEmitParamHarness.sol"); + let expect_emit_failure_tests = include_str!("../fixtures/ExpectEmitParamFailures.t.sol"); + + prj.add_source("ExpectEmitParamHarness.sol", expect_emit_failure_src); + prj.add_source("ExpectEmitParamFailures.sol", expect_emit_failure_tests); + + cmd.forge_fuse().arg("build").assert_success(); + + cmd.forge_fuse().args(["test", "--mc", "ExpectEmitParamFailures"]).assert_failure().stdout_eq( + r#"No files changed, compilation skipped +... +[PASS] testSelectiveChecks() ([GAS]) +Suite result: FAILED. 1 passed; 8 failed; 0 skipped; [ELAPSED] +... +[FAIL: anonymous log mismatch at param 0: expected=0x0000000000000000000000000000000000000000000000000000000000000064, got=0x00000000000000000000000000000000000000000000000000000000000003e7] testAnonymousEventMismatch() ([GAS]) +[FAIL: ComplexEvent != expected SimpleEvent] testCompletelyDifferentEvent() ([GAS]) +[FAIL: SimpleEvent param mismatch at b: expected=200, got=999] testIndexedParamMismatch() ([GAS]) +[FAIL: ManyParams param mismatch at a: expected=100, got=111, b: expected=200, got=222, c: expected=300, got=333, d: expected=400, got=444, e: expected=500, got=555] testManyParameterMismatches() ([GAS]) +[FAIL: SimpleEvent param mismatch at c: expected=300, got=999] testMixedEventNonIndexedMismatch() ([GAS]) +[FAIL: SimpleEvent param mismatch at a: expected=100, got=999, b: expected=200, got=888, c: expected=300, got=777] testMultipleMismatches() ([GAS]) +[FAIL: SimpleEvent param mismatch at c: expected=300, got=999] testNonIndexedParamMismatch() ([GAS]) +[FAIL: MixedEventNumbering param mismatch at param2: expected=300, got=999] testParameterNumbering() ([GAS]) + +Encountered a total of 8 failing tests, 1 tests succeeded +... +"#, + ); +} + +#[forgetest] +fn expect_emit_params_decode_project_abi_without_selector_cache(prj: _, cmd: _) { + prj.insert_vm(); + + prj.add_source( + "ExpectEmitProjectAbiFailure.sol", + r#" +import "./Vm.sol"; + +contract CodexProjectAbiEmitter10342 { + event CodexExpectEmitProjectAbi10342(uint256 indexed topicValue, uint256 dataValue); + + function emitEvent(uint256 topicValue, uint256 dataValue) external { + emit CodexExpectEmitProjectAbi10342(topicValue, dataValue); + } +} + +contract ExpectEmitProjectAbiFailureTest { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + CodexProjectAbiEmitter10342 emitter = new CodexProjectAbiEmitter10342(); + + event CodexExpectEmitProjectAbi10342(uint256 indexed topicValue, uint256 dataValue); + + function testMismatch() public { + vm.expectEmit(true, true, true, true); + emit CodexExpectEmitProjectAbi10342(1, 2); + emitter.emitEvent(1, 3); + } +} +"#, + ); + + cmd.forge_fuse() + .args(["test", "--mc", "ExpectEmitProjectAbiFailureTest"]) + .assert_failure() + .stdout_eq(str![[r#"[COMPILING_FILES] with [SOLC_VERSION] +... +[FAIL: CodexExpectEmitProjectAbi10342 param mismatch at dataValue: expected=2, got=3] testMismatch() ([GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest] +fn expect_emit_decodes_stable_project_abi_collision(prj: _, cmd: _) { + prj.insert_vm(); + + prj.add_source( + "AExpectEmitIndexedCollision.t.sol", + r#" +import "./Vm.sol"; +import "./ZIndexedCollisionEmitter.sol"; + +contract AExpectEmitIndexedCollisionTest { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + ZIndexedCollisionEmitter emitter = new ZIndexedCollisionEmitter(); + + event CodexExpectEmitIndexedCollision(uint256 indexed marker, uint256 value); + + function testMismatch() public { + vm.expectEmit(true, true, false, true); + emit CodexExpectEmitIndexedCollision(69, 420); + emitter.emitEvent(421, 69); + } +} +"#, + ); + prj.add_source( + "ZIndexedCollisionEmitter.sol", + r#" +contract ZIndexedCollisionEmitter { + event CodexExpectEmitIndexedCollision(uint256 value, uint256 indexed marker); + + function emitEvent(uint256 value, uint256 marker) external { + emit CodexExpectEmitIndexedCollision(value, marker); + } +} +"#, + ); + + cmd.forge_fuse() + .args(["test", "--mc", "AExpectEmitIndexedCollisionTest"]) + .assert_failure() + .stdout_eq(str![[r#"[COMPILING_FILES] with [SOLC_VERSION] +... +[FAIL: CodexExpectEmitIndexedCollision param mismatch at value: expected=420, got=421] testMismatch() ([GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest] +fn mem_safety_test_should_fail(prj: _, cmd: _) { prj.insert_ds_test(); prj.insert_vm(); let mem_safety_failure_tests = include_str!("../fixtures/MemSafetyFailures.t.sol"); - prj.add_source("MemSafetyFailures.sol", mem_safety_failure_tests).unwrap(); + prj.add_source("MemSafetyFailures.sol", mem_safety_failure_tests); cmd.forge_fuse().args(["test", "--mc", "MemSafetyFailureTest"]).assert_failure().stdout_eq( r#"[COMPILING_FILES] with [SOLC_VERSION] [SOLC_VERSION] [ELAPSED] ... [FAIL: Expected call to fail] testShouldFailExpectSafeMemoryCall() ([GAS]) -[FAIL: memory write at offset 0x100 of size 0x60 not allowed; safe range: (0x00, 0x60] U (0x80, 0x100]] testShouldFailExpectSafeMemory_CALL() ([GAS]) -[FAIL: memory write at offset 0x100 of size 0x60 not allowed; safe range: (0x00, 0x60] U (0x80, 0x100]] testShouldFailExpectSafeMemory_CALLCODE() ([GAS]) -[FAIL: memory write at offset 0xA0 of size 0x20 not allowed; safe range: (0x00, 0x60] U (0x80, 0xA0]; counterexample: calldata=[..] args=[..]] testShouldFailExpectSafeMemory_CALLDATACOPY(uint256) (runs: 0, [AVG_GAS]) -[FAIL: memory write at offset 0x80 of size [..] not allowed; safe range: (0x00, 0x60] U (0x80, 0xA0]] testShouldFailExpectSafeMemory_CODECOPY() ([GAS]) -[FAIL: memory write at offset 0x100 of size 0x20 not allowed; safe range: (0x00, 0x60] U (0x80, 0x100]] testShouldFailExpectSafeMemory_CREATE() ([GAS]) -[FAIL: memory write at offset 0x100 of size 0x20 not allowed; safe range: (0x00, 0x60] U (0x80, 0x100]] testShouldFailExpectSafeMemory_CREATE2() ([GAS]) -[FAIL: memory write at offset 0x100 of size 0x60 not allowed; safe range: (0x00, 0x60] U (0x80, 0x100]] testShouldFailExpectSafeMemory_DELEGATECALL() ([GAS]) -[FAIL: memory write at offset 0xA0 of size 0x20 not allowed; safe range: (0x00, 0x60] U (0x80, 0xA0]] testShouldFailExpectSafeMemory_EXTCODECOPY() ([GAS]) -[FAIL: memory write at offset 0x100 of size 0x20 not allowed; safe range: (0x00, 0x60] U (0x80, 0x100]] testShouldFailExpectSafeMemory_LOG0() ([GAS]) -[FAIL: memory write at offset 0x100 of size 0x20 not allowed; safe range: (0x00, 0x60] U (0x80, 0x100]] testShouldFailExpectSafeMemory_MLOAD() ([GAS]) -[FAIL: memory write at offset 0x81 of size 0x01 not allowed; safe range: (0x00, 0x60] U (0x80, 0x81]] testShouldFailExpectSafeMemory_MSTORE8_High() ([GAS]) -[FAIL: memory write at offset 0x60 of size 0x01 not allowed; safe range: (0x00, 0x60] U (0x80, 0x81]] testShouldFailExpectSafeMemory_MSTORE8_Low() ([GAS]) -[FAIL: memory write at offset 0xA0 of size 0x20 not allowed; safe range: (0x00, 0x60] U (0x80, 0xA0]] testShouldFailExpectSafeMemory_MSTORE_High() ([GAS]) -[FAIL: memory write at offset 0x60 of size 0x20 not allowed; safe range: (0x00, 0x60] U (0x80, 0xA0]] testShouldFailExpectSafeMemory_MSTORE_Low() ([GAS]) -[FAIL: memory write at offset 0x100 of size 0x20 not allowed; safe range: (0x00, 0x60] U (0x80, 0x100]] testShouldFailExpectSafeMemory_RETURN() ([GAS]) -[FAIL: memory write at offset 0x100 of size 0x60 not allowed; safe range: (0x00, 0x60] U (0x80, 0x100]] testShouldFailExpectSafeMemory_RETURNDATACOPY() ([GAS]) +[FAIL: memory write at offset 0x100 of size 0x60 not allowed; safe range: [0x00, 0x60) U [0x80, 0x100)] testShouldFailExpectSafeMemory_CALL() ([GAS]) +[FAIL: memory write at offset 0x100 of size 0x60 not allowed; safe range: [0x00, 0x60) U [0x80, 0x100)] testShouldFailExpectSafeMemory_CALLCODE() ([GAS]) +[FAIL: memory write at offset 0xA0 of size 0x20 not allowed; safe range: [0x00, 0x60) U [0x80, 0xA0); counterexample: calldata=[..] args=[..]] testShouldFailExpectSafeMemory_CALLDATACOPY(uint256) (runs: 0, [AVG_GAS]) +[FAIL: memory write at offset 0x80 of size [..] not allowed; safe range: [0x00, 0x60) U [0x80, 0xA0)] testShouldFailExpectSafeMemory_CODECOPY() ([GAS]) +[FAIL: memory write at offset 0x100 of size 0x20 not allowed; safe range: [0x00, 0x60) U [0x80, 0x100)] testShouldFailExpectSafeMemory_CREATE() ([GAS]) +[FAIL: memory write at offset 0x100 of size 0x20 not allowed; safe range: [0x00, 0x60) U [0x80, 0x100)] testShouldFailExpectSafeMemory_CREATE2() ([GAS]) +[FAIL: memory write at offset 0x100 of size 0x60 not allowed; safe range: [0x00, 0x60) U [0x80, 0x100)] testShouldFailExpectSafeMemory_DELEGATECALL() ([GAS]) +[FAIL: memory write at offset 0xA0 of size 0x20 not allowed; safe range: [0x00, 0x60) U [0x80, 0xA0)] testShouldFailExpectSafeMemory_EXTCODECOPY() ([GAS]) +[FAIL: memory write at offset 0x100 of size 0x20 not allowed; safe range: [0x00, 0x60) U [0x80, 0x100)] testShouldFailExpectSafeMemory_LOG0() ([GAS]) +[FAIL: memory write at offset 0x100 of size 0x20 not allowed; safe range: [0x00, 0x60) U [0x80, 0x100)] testShouldFailExpectSafeMemory_MLOAD() ([GAS]) +[FAIL: memory write at offset 0x81 of size 0x01 not allowed; safe range: [0x00, 0x60) U [0x80, 0x81)] testShouldFailExpectSafeMemory_MSTORE8_High() ([GAS]) +[FAIL: memory write at offset 0x60 of size 0x01 not allowed; safe range: [0x00, 0x60) U [0x80, 0x81)] testShouldFailExpectSafeMemory_MSTORE8_Low() ([GAS]) +[FAIL: memory write at offset 0xA0 of size 0x20 not allowed; safe range: [0x00, 0x60) U [0x80, 0xA0)] testShouldFailExpectSafeMemory_MSTORE_High() ([GAS]) +[FAIL: memory write at offset 0x60 of size 0x20 not allowed; safe range: [0x00, 0x60) U [0x80, 0xA0)] testShouldFailExpectSafeMemory_MSTORE_Low() ([GAS]) +[FAIL: memory write at offset 0x100 of size 0x20 not allowed; safe range: [0x00, 0x60) U [0x80, 0x100)] testShouldFailExpectSafeMemory_RETURN() ([GAS]) +[FAIL: memory write at offset 0x100 of size 0x60 not allowed; safe range: [0x00, 0x60) U [0x80, 0x100)] testShouldFailExpectSafeMemory_RETURNDATACOPY() ([GAS]) [FAIL: EvmError: Revert] testShouldFailExpectSafeMemory_REVERT() ([GAS]) -[FAIL: memory write at offset 0x100 of size 0x20 not allowed; safe range: (0x00, 0x60] U (0x80, 0x100]] testShouldFailExpectSafeMemory_SHA3() ([GAS]) -[FAIL: memory write at offset 0x100 of size 0x60 not allowed; safe range: (0x00, 0x60] U (0x80, 0x100]] testShouldFailExpectSafeMemory_STATICCALL() ([GAS]) -[FAIL: memory write at offset 0xA0 of size 0x20 not allowed; safe range: (0x00, 0x60] U (0x80, 0xA0]] testShouldFailStopExpectSafeMemory() ([GAS]) +[FAIL: memory write at offset 0x100 of size 0x20 not allowed; safe range: [0x00, 0x60) U [0x80, 0x100)] testShouldFailExpectSafeMemory_SHA3() ([GAS]) +[FAIL: memory write at offset 0x100 of size 0x60 not allowed; safe range: [0x00, 0x60) U [0x80, 0x100)] testShouldFailExpectSafeMemory_STATICCALL() ([GAS]) +[FAIL: memory write at offset 0xA0 of size 0x20 not allowed; safe range: [0x00, 0x60) U [0x80, 0xA0)] testShouldFailStopExpectSafeMemory() ([GAS]) Suite result: FAILED. 0 passed; 21 failed; 0 skipped; [ELAPSED] ... "#, ); -}); +} -forgetest!(ds_style_test_failing, |prj, cmd| { +#[forgetest] +fn ds_style_test_failing(prj: _, cmd: _) { prj.insert_ds_test(); prj.add_source( @@ -286,8 +439,7 @@ forgetest!(ds_style_test_failing, |prj, cmd| { } } "#, - ) - .unwrap(); + ); cmd.forge_fuse().args(["test", "--mc", "DSStyleTest", "-vv"]).assert_failure().stdout_eq( r#"[COMPILING_FILES] with [SOLC_VERSION] @@ -309,9 +461,10 @@ Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] ... "#, ); -}); +} -forgetest!(failing_setup, |prj, cmd| { +#[forgetest] +fn failing_setup(prj: _, cmd: _) { prj.insert_ds_test(); prj.add_source( @@ -332,8 +485,7 @@ contract FailingSetupTest is DSTest { } } "#, - ) - .unwrap(); + ); cmd.args(["test", "--mc", "FailingSetupTest"]).assert_failure().stdout_eq(str![[ r#"[COMPILING_FILES] with [SOLC_VERSION] @@ -344,9 +496,10 @@ Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] ... "# ]]); -}); +} -forgetest!(multiple_after_invariants, |prj, cmd| { +#[forgetest] +fn multiple_after_invariants(prj: _, cmd: _) { prj.insert_ds_test(); prj.add_source( @@ -367,8 +520,7 @@ contract MultipleAfterInvariant is DSTest { } } "#, - ) - .unwrap(); + ); cmd.args(["test", "--mc", "MultipleAfterInvariant"]).assert_failure().stdout_eq(str![[ r#"[COMPILING_FILES] with [SOLC_VERSION] @@ -379,15 +531,16 @@ Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] ... "# ]]); -}); +} -forgetest!(multiple_setups, |prj, cmd| { +#[forgetest] +fn multiple_setups(prj: _, cmd: _) { prj.insert_ds_test(); prj.add_source( "MultipleSetupsTest.t.sol", r#" - + import "./test.sol"; contract MultipleSetup is DSTest { @@ -401,8 +554,7 @@ contract MultipleSetup is DSTest { } "#, - ) - .unwrap(); + ); cmd.forge_fuse().args(["test", "--mc", "MultipleSetup"]).assert_failure().stdout_eq(str![[ r#"[COMPILING_FILES] with [SOLC_VERSION] @@ -411,9 +563,10 @@ contract MultipleSetup is DSTest { Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] ..."# ]]); -}); +} -forgetest!(emit_diff_anonymous, |prj, cmd| { +#[forgetest] +fn emit_diff_anonymous(prj: _, cmd: _) { prj.insert_ds_test(); prj.insert_vm(); prj.add_source( @@ -447,8 +600,7 @@ forgetest!(emit_diff_anonymous, |prj, cmd| { } } "#, - ) - .unwrap(); + ); cmd.forge_fuse().args(["test", "--mc", "EmitDiffAnonymousTest"]).assert_failure().stdout_eq( str![[r#"[COMPILING_FILES] with [SOLC_VERSION] @@ -459,4 +611,51 @@ Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] ... "#]], ); -}); +} + +// An `assumeNoRevert` partial-match reason shorter than a selector must not match revert data +// that is also shorter than 4 bytes; the revert should surface as a failure instead of being +// discarded as anticipated. +#[forgetest_init] +fn assume_no_revert_short_partial_should_fail(prj: _, cmd: _) { + prj.add_test( + "AssumeShortPartial.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {VmSafe} from "forge-std/Vm.sol"; + +contract ShortReverter { + function revertShort() external pure { + assembly { + mstore(0x00, shl(240, 0xffff)) + revert(0x00, 0x02) + } + } +} + +contract AssumeShortPartialTest is Test { + ShortReverter reverter; + + function setUp() public { + reverter = new ShortReverter(); + } + + function testShortPartialDoesNotMatch(uint256) public view { + vm.assumeNoRevert( + VmSafe.PotentialRevert({revertData: hex"ff", partialMatch: true, reverter: address(0)}) + ); + reverter.revertShort(); + } +} +"#, + ); + + cmd.args(["test", "--match-contract", "AssumeShortPartialTest"]).assert_failure().stdout_eq( + str![[r#" +... +[FAIL: EvmError: Revert; counterexample: calldata=[..] args=[..]] testShortPartialDoesNotMatch(uint256) (runs: 0, [AVG_GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]], + ); +} diff --git a/crates/forge/tests/cli/fmt.rs b/crates/forge/tests/cli/fmt.rs new file mode 100644 index 0000000000000..243ad61e78882 --- /dev/null +++ b/crates/forge/tests/cli/fmt.rs @@ -0,0 +1,449 @@ +//! Integration tests for `forge fmt` command + +use foundry_test_utils::{forgetest, snapbox::IntoData}; + +const UNFORMATTED: &str = r#"// SPDX-License-Identifier: MIT +pragma solidity =0.8.33 ; + +contract Test { + uint256 public value ; + function setValue ( uint256 _value ) public { + value = _value ; + } +}"#; + +const FORMATTED: &str = r#"// SPDX-License-Identifier: MIT +pragma solidity =0.8.33; + +contract Test { + uint256 public value; + + function setValue(uint256 _value) public { + value = _value; + } +} +"#; + +#[forgetest] +fn fmt_exclude_libs_in_recursion(prj: _, cmd: _) { + prj.update_config(|config| config.fmt.ignore = vec!["src/ignore/".to_string()]); + + prj.add_lib("SomeLib.sol", UNFORMATTED); + prj.add_raw_source("ignore/IgnoredContract.sol", UNFORMATTED); + cmd.args(["fmt", ".", "--check"]); + cmd.assert_success(); + + cmd.forge_fuse().args(["fmt", "lib/SomeLib.sol", "--check"]); + cmd.assert_failure(); +} + +// Test that fmt can format a simple contract file +#[forgetest] +fn fmt_file(prj: _, cmd: _) { + prj.add_raw_source("FmtTest.sol", UNFORMATTED); + cmd.arg("fmt").arg("src/FmtTest.sol"); + cmd.assert_success().stdout_eq(str![""]).stderr_eq(str![[r#" +Formatted [..]/src/FmtTest.sol + +"#]]); + assert_data_eq!( + std::fs::read_to_string(prj.root().join("src/FmtTest.sol")).unwrap(), + FORMATTED, + ); +} + +// Test that fmt can format from stdin +#[forgetest] +fn fmt_stdin(cmd: _) { + cmd.args(["fmt", "-", "--raw"]); + cmd.stdin(UNFORMATTED.as_bytes()); + cmd.assert_success().stdout_eq(FORMATTED); + + // Already formatted stdin is returned unchanged. + // + cmd.stdin(FORMATTED.as_bytes()); + cmd.assert_success().stdout_eq(FORMATTED.as_bytes()); + + // stdin with `--check` and without `--raw`returns diff + cmd.forge_fuse().args(["fmt", "-", "--check"]); + cmd.assert_success().stdout_eq(""); +} + +#[forgetest] +fn fmt_check_mode(prj: _, cmd: _) { + // Run fmt --check on a well-formatted file + prj.add_raw_source("Test.sol", FORMATTED); + cmd.arg("fmt").arg("--check").arg("src/Test.sol"); + cmd.assert_success().stderr_eq("").stdout_eq(""); + + // Run fmt --check on a mal-formatted file + prj.add_raw_source("Test2.sol", UNFORMATTED); + cmd.forge_fuse().arg("fmt").arg("--check").arg("src/Test2.sol"); + cmd.assert_failure(); +} + +#[forgetest] +fn fmt_check_mode_stdin(cmd: _) { + // Run fmt --check with well-formatted stdin input + cmd.arg("fmt").arg("-").arg("--check"); + cmd.stdin(FORMATTED.as_bytes()); + cmd.assert_success().stderr_eq("").stdout_eq(""); + + // Run fmt --check with mal-formatted stdin input + cmd.stdin(UNFORMATTED.as_bytes()); + cmd.assert_failure().stderr_eq("").stdout_eq(str![[r#" +Diff in stdin: +1 1 | // SPDX-License-Identifier: MIT +2 |-pragma solidity =0.8.33 ; + 2 |+pragma solidity =0.8.33; +... +4 |-contract Test { +5 |- uint256 public value ; +6 |- function setValue ( uint256 _value ) public { +7 |- value = _value ; + 4 |+contract Test { + 5 |+ uint256 public value; +... + 7 |+ function setValue(uint256 _value) public { + 8 |+ value = _value; +8 9 | } +9 |-} + 10 |+} + +"#]]); +} + +// Test that fmt can format a simple contract file +#[forgetest] +fn fmt_file_config_parms_first(prj: _, cmd: _) { + prj.create_file( + "foundry.toml", + r#" +[fmt] +multiline_func_header = 'params_first' +"#, + ); + prj.add_raw_source("FmtTest.sol", FORMATTED); + cmd.forge_fuse().args(["fmt", "--check"]).arg("src/FmtTest.sol"); + cmd.assert_failure().stdout_eq(str![[r#" +Diff in src/FmtTest.sol: +... +7 |- function setValue(uint256 _value) public { + 7 |+ function setValue( + 8 |+ uint256 _value + 9 |+ ) public { +... + +"#]]); +} + +// +#[forgetest] +fn fmt_uses_nearest_config(prj: _, cmd: _) { + let source = r#"contract Test { + function test(uint256 a, uint256 b) public returns (uint256) { return a + b; } +} +"#; + let first = prj.create_file("first/src/Test.sol", source); + let second = prj.create_file("second/src/Test.sol", source); + let ignored = prj.create_file("first/src/Ignored.sol", source); + prj.create_file( + "foundry.toml", + r#"[fmt] +ignore = ["first/src/Test.sol", "["] +"#, + ); + prj.create_file( + "first/foundry.toml", + r#"[fmt] +multiline_func_header = "params_first" +tab_width = 2 +ignore = ["src/Ignored.sol"] +"#, + ); + prj.create_file( + "second/foundry.toml", + r#"[fmt] +multiline_func_header = "attributes_first" +tab_width = 6 +"#, + ); + + let output = cmd.args(["fmt", "--nearest", "--check", "."]).assert_failure(); + let stdout = String::from_utf8_lossy(&output.get_output().stdout).replace('\\', "/"); + assert!(stdout.contains("Diff in first/src/Test.sol"), "{stdout}"); + assert!(stdout.contains("|+ function test("), "{stdout}"); + assert!(stdout.contains("Diff in second/src/Test.sol"), "{stdout}"); + assert!(stdout.contains("|+ function test(uint256 a, uint256 b)"), "{stdout}"); + + cmd.forge_fuse().args(["fmt", "--nearest", "."]).assert_success(); + + assert!(std::fs::read_to_string(first).unwrap().contains(" function test(\n")); + assert!( + std::fs::read_to_string(second) + .unwrap() + .contains(" function test(uint256 a, uint256 b)") + ); + assert_eq!(std::fs::read_to_string(ignored).unwrap(), source); +} + +#[forgetest] +fn fmt_without_nearest_uses_invocation_config(prj: _, cmd: _) { + let file = prj.create_file( + "nested/src/Test.sol", + r#"contract Test { + function test(uint256 a, uint256 b) public returns (uint256) { return a + b; } +} +"#, + ); + prj.create_file( + "foundry.toml", + r#"[fmt] +multiline_func_header = "params_first" +tab_width = 4 +"#, + ); + prj.create_file( + "nested/foundry.toml", + r#"[fmt] +multiline_func_header = "attributes_first" +tab_width = 2 +"#, + ); + + cmd.args(["fmt", "nested/src/Test.sol"]).assert_success(); + + assert!(std::fs::read_to_string(file).unwrap().contains(" function test(\n")); +} + +#[forgetest] +fn fmt_nearest_config_emits_nested_warnings(prj: _, cmd: _) { + prj.create_file("nested/src/Test.sol", "contract Test {}\n"); + prj.create_file( + "nested/foundry.toml", + r#"[default] +src = "src" +"#, + ); + + cmd.args(["fmt", "--nearest", "nested/src/Test.sol"]) + .assert_success() + .stderr_eq(str![[r#" +Warning: Found unknown config section in nested/foundry.toml: [default] +This notation for profiles has been deprecated and may result in the profile not being registered in future versions. +Please use [profile.default] instead or run `forge config --fix`. + +"#]]); +} + +#[forgetest] +fn fmt_nearest_config_rejects_config_env(prj: _, cmd: _) { + prj.create_file("src/Test.sol", "contract Test {}\n"); + cmd.env("FOUNDRY_CONFIG", "custom.toml"); + cmd.args(["fmt", "--nearest", "src/Test.sol"]).assert_failure().stderr_eq(str![[r#" +Error: `--nearest` cannot be used when `FOUNDRY_CONFIG` is set + +"#]]); +} + +// https://github.com/foundry-rs/foundry/issues/12000 +#[forgetest] +fn fmt_only_cmnts_file(prj: _, cmd: _) { + // Only line breaks + prj.add_raw_source("FmtTest.sol", "\n\n"); + + cmd.forge_fuse().args(["fmt", "src/FmtTest.sol"]); + cmd.assert_success(); + assert_data_eq!(std::fs::read_to_string(prj.root().join("src/FmtTest.sol")).unwrap(), "",); + cmd.forge_fuse().args(["fmt", "--check", "src/FmtTest.sol"]); + cmd.assert_success(); + + // Only cmnts + prj.add_raw_source("FmtTest.sol", "\n\n// this is a cmnt"); + + cmd.forge_fuse().args(["fmt", "src/FmtTest.sol"]); + cmd.assert_success(); + assert_data_eq!( + std::fs::read_to_string(prj.root().join("src/FmtTest.sol")).unwrap(), + "// this is a cmnt\n", + ); + cmd.forge_fuse().args(["fmt", "--check", "src/FmtTest.sol"]); + cmd.assert_success(); +} + +// +#[forgetest] +fn fmt_keeps_disable_directive_in_every_file(prj: _, cmd: _) { + const NAMES: [&str; 4] = ["A", "B", "C", "D"]; + const SOURCE: &str = "// forgefmt: disable-next-line\ncontract Disabled {}\n"; + + for name in NAMES { + prj.add_raw_source(&format!("Fmt{name}.sol"), SOURCE); + } + + cmd.args(["fmt", "src"]).assert_success(); + + // Only the first file in the source map used to keep its directive. + for name in NAMES { + assert_data_eq!( + std::fs::read_to_string(prj.root().join(format!("src/Fmt{name}.sol"))).unwrap(), + SOURCE, + ); + } +} + +// Symlinks found while walking directories must not make `forge fmt` write outside of the project. +#[cfg(unix)] +#[forgetest] +fn fmt_skips_symlinks_outside_project(prj: _, cmd: _) { + let outside = tempfile::tempdir().unwrap(); + let outside_file = outside.path().join("Outside.sol"); + let outside_dir_file = outside.path().join("dir/Dir.sol"); + std::fs::create_dir(outside.path().join("dir")).unwrap(); + std::fs::write(&outside_file, UNFORMATTED).unwrap(); + std::fs::write(&outside_dir_file, UNFORMATTED).unwrap(); + + let own = prj.add_raw_source("Own.sol", UNFORMATTED); + std::os::unix::fs::symlink(&outside_file, prj.root().join("src/Link.sol")).unwrap(); + std::fs::create_dir_all(prj.root().join("test")).unwrap(); + std::os::unix::fs::symlink(outside.path().join("dir"), prj.root().join("test/linked")).unwrap(); + + cmd.arg("fmt").assert_success().stderr_eq(str![[r#" +Warning: Skipping [..]/src/Link.sol: it resolves outside of the project root. +HINT: Pass the path explicitly to format it: `forge fmt ` +Warning: Skipping [..]/test/linked/Dir.sol: it resolves outside of the project root. +HINT: Pass the path explicitly to format it: `forge fmt ` +Formatted [..]/src/Own.sol + +"#]]); + assert_data_eq!(std::fs::read_to_string(own).unwrap(), FORMATTED); + assert_data_eq!(std::fs::read_to_string(&outside_file).unwrap(), UNFORMATTED); + assert_data_eq!(std::fs::read_to_string(&outside_dir_file).unwrap(), UNFORMATTED); + + // Walking an explicit project directory applies the same rule. + cmd.forge_fuse().args(["fmt", "test"]).assert_success().stderr_eq(str![[r#" +Warning: Skipping test/linked/Dir.sol: it resolves outside of the project root. +HINT: Pass the path explicitly to format it: `forge fmt ` +Warning: Nothing to format. +HINT: If you are working outside of the project, try providing paths to your source files: `forge fmt ` + +"#]]); + assert_data_eq!(std::fs::read_to_string(&outside_dir_file).unwrap(), UNFORMATTED); + + // Explicit paths remain an opt-in. + cmd.forge_fuse().args(["fmt", "src/Link.sol", "test/linked"]).assert_success(); + assert_data_eq!(std::fs::read_to_string(&outside_file).unwrap(), FORMATTED); + assert_data_eq!(std::fs::read_to_string(&outside_dir_file).unwrap(), FORMATTED); +} + +#[forgetest] +fn fmt_external_configured_directories(prj: _, cmd: _) { + let outside = tempfile::tempdir().unwrap(); + for name in ["src", "test", "script"] { + let dir = outside.path().join(name); + std::fs::create_dir(&dir).unwrap(); + std::fs::write(dir.join("External.sol"), UNFORMATTED).unwrap(); + } + prj.update_config(|config| { + config.src = outside.path().join("src"); + config.test = outside.path().join("test"); + config.script = outside.path().join("script"); + }); + + cmd.args(["fmt", "--check"]).assert_failure().stderr_eq(""); + for name in ["src", "test", "script"] { + assert_data_eq!( + std::fs::read_to_string(outside.path().join(name).join("External.sol")).unwrap(), + UNFORMATTED, + ); + } + + cmd.forge_fuse().arg("fmt").assert_success().stdout_eq("").stderr_eq( + str![[r#" +Formatted [..]/src/External.sol +Formatted [..]/test/External.sol +Formatted [..]/script/External.sol + +"#]] + .unordered(), + ); + for name in ["src", "test", "script"] { + assert_data_eq!( + std::fs::read_to_string(outside.path().join(name).join("External.sol")).unwrap(), + FORMATTED, + ); + } + cmd.forge_fuse().args(["fmt", "--check"]).assert_success().stdout_eq("").stderr_eq(""); +} + +#[forgetest] +fn fmt_configured_files_keep_default_filters(prj: _, cmd: _) { + for dir in ["lib", "src"] { + let files = + ["Vendor.sol", "Vendor.t.sol", "Vendor.s.sol"].map(|name| format!("{dir}/{name}")); + for file in &files { + prj.create_file(file, UNFORMATTED); + } + prj.update_config(|config| { + config.src = files[0].clone().into(); + config.test = files[1].clone().into(); + config.script = files[2].clone().into(); + config.fmt.ignore = vec!["src".to_string()]; + }); + + for args in [vec!["fmt"], vec!["fmt", "--check"]] { + cmd.forge_fuse().args(args).assert_success().stdout_eq("").stderr_eq(str![[r#" +Warning: Nothing to format. +HINT: If you are working outside of the project, try providing paths to your source files: `forge fmt ` + +"#]]); + for file in &files { + assert_data_eq!( + std::fs::read_to_string(prj.root().join(file)).unwrap(), + UNFORMATTED + ); + } + } + + // Explicit CLI files still opt in to libraries and ignored directories. + cmd.forge_fuse().arg("fmt").args(&files).assert_success().stdout_eq("").stderr_eq( + str![[r#" +Formatted [..]/Vendor.sol +Formatted [..]/Vendor.t.sol +Formatted [..]/Vendor.s.sol + +"#]] + .unordered(), + ); + for file in &files { + assert_data_eq!(std::fs::read_to_string(prj.root().join(file)).unwrap(), FORMATTED); + } + } +} + +#[forgetest] +fn fmt_configured_files(prj: _, cmd: _) { + let files = ["Source.sol", "Test.sol", "Script.sol"]; + for file in files { + prj.create_file(file, UNFORMATTED); + } + prj.update_config(|config| { + config.src = files[0].into(); + config.test = files[1].into(); + config.script = files[2].into(); + }); + + cmd.arg("fmt").assert_success().stdout_eq("").stderr_eq( + str![[r#" +Formatted [..]/Source.sol +Formatted [..]/Test.sol +Formatted [..]/Script.sol + +"#]] + .unordered(), + ); + for file in files { + assert_data_eq!(std::fs::read_to_string(prj.root().join(file)).unwrap(), FORMATTED); + } + cmd.forge_fuse().args(["fmt", "--check"]).assert_success().stdout_eq("").stderr_eq(""); +} diff --git a/crates/forge/tests/cli/fmt_integration.rs b/crates/forge/tests/cli/fmt_integration.rs new file mode 100644 index 0000000000000..92c133263d955 --- /dev/null +++ b/crates/forge/tests/cli/fmt_integration.rs @@ -0,0 +1,27 @@ +use foundry_test_utils::util::ExtTester; + +/// Test `forge fmt` immutability. +/// TODO: make sure original fmt is not changed after projects format and rev available. +macro_rules! fmt_test { + ($name:ident, $org:expr, $repo:expr, $commit:expr) => { + #[test] + fn $name() { + let (_, mut cmd) = ExtTester::new($org, $repo, $commit).setup_forge_prj(false); + cmd.arg("fmt").assert_success(); + cmd.arg("--check").assert_success(); + } + }; +} + +fmt_test!(fmt_ithaca_account, "ithacaxyz", "account", "213c04ee1808784c18609607d85feba7730538fd"); + +fmt_test!(fmt_univ4_core, "Uniswap", "v4-core", "59d3ecf53afa9264a16bba0e38f4c5d2231f80bc"); + +fmt_test!( + fmt_evk_periphery, + "euler-xyz", + "evk-periphery", + "e41f2b9b7ed677ca03ff7bd7221a4e2fdd55504f" +); + +fmt_test!(fmt_0x_settler, "0xProject", "0x-settler", "a388c8251ab6c4bedce1641b31027d7b1136daef"); diff --git a/crates/forge/tests/cli/geiger.rs b/crates/forge/tests/cli/geiger.rs deleted file mode 100644 index fd21656284744..0000000000000 --- a/crates/forge/tests/cli/geiger.rs +++ /dev/null @@ -1,92 +0,0 @@ -forgetest!(call, |prj, cmd| { - prj.add_source( - "call.sol", - r#" - contract A is Test { - function do_ffi() public { - string[] memory inputs = new string[](1); - vm.ffi(inputs); - } - } - "#, - ) - .unwrap(); - - cmd.arg("geiger").assert_code(1).stderr_eq(str![[r#" -error: usage of unsafe cheatcode `vm.ffi` - [FILE]:7:20 - | -7 | vm.ffi(inputs); - | ^^^ - | - - -"#]]); -}); - -forgetest!(assignment, |prj, cmd| { - prj.add_source( - "assignment.sol", - r#" - contract A is Test { - function do_ffi() public { - string[] memory inputs = new string[](1); - bytes stuff = vm.ffi(inputs); - } - } - "#, - ) - .unwrap(); - - cmd.arg("geiger").assert_code(1).stderr_eq(str![[r#" -error: usage of unsafe cheatcode `vm.ffi` - [FILE]:7:34 - | -7 | bytes stuff = vm.ffi(inputs); - | ^^^ - | - - -"#]]); -}); - -forgetest!(exit_code, |prj, cmd| { - prj.add_source( - "multiple.sol", - r#" - contract A is Test { - function do_ffi() public { - vm.ffi(inputs); - vm.ffi(inputs); - vm.ffi(inputs); - } - } - "#, - ) - .unwrap(); - - cmd.arg("geiger").assert_code(3).stderr_eq(str![[r#" -error: usage of unsafe cheatcode `vm.ffi` - [FILE]:6:20 - | -6 | vm.ffi(inputs); - | ^^^ - | - -error: usage of unsafe cheatcode `vm.ffi` - [FILE]:7:20 - | -7 | vm.ffi(inputs); - | ^^^ - | - -error: usage of unsafe cheatcode `vm.ffi` - [FILE]:8:20 - | -8 | vm.ffi(inputs); - | ^^^ - | - - -"#]]); -}); diff --git a/crates/forge/tests/cli/inline_config.rs b/crates/forge/tests/cli/inline_config.rs index cbcfb20b11a3d..2b8a582bcdf61 100644 --- a/crates/forge/tests/cli/inline_config.rs +++ b/crates/forge/tests/cli/inline_config.rs @@ -1,4 +1,5 @@ -forgetest!(runs, |prj, cmd| { +#[forgetest] +fn runs(prj: _, cmd: _) { prj.add_test( "inline.sol", " @@ -11,8 +12,7 @@ forgetest!(runs, |prj, cmd| { function test2(bool) public {} } ", - ) - .unwrap(); + ); cmd.arg("test").assert_success().stdout_eq(str![[r#" [COMPILING_FILES] with [SOLC_VERSION] @@ -43,16 +43,17 @@ Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] Ran 1 test suite [ELAPSED]: 2 tests passed, 0 failed, 0 skipped (2 total tests) -╭-------+---------------+---------------+---------------+---------------╮ -| File | % Lines | % Statements | % Branches | % Funcs | -+=======================================================================+ -| Total | 100.00% (0/0) | 100.00% (0/0) | 100.00% (0/0) | 100.00% (0/0) | -╰-------+---------------+---------------+---------------+---------------╯ +╭-------+-----------+--------------+------------+-----------╮ +| File | % Lines | % Statements | % Branches | % Funcs | ++===========================================================+ +| Total | N/A (0/0) | N/A (0/0) | N/A (0/0) | N/A (0/0) | +╰-------+-----------+--------------+------------+-----------╯ "#]]); -}); +} -forgetest!(invalid_profile, |prj, cmd| { +#[forgetest] +fn invalid_profile(prj: _, cmd: _) { prj.add_test( "inline.sol", " @@ -61,83 +62,16 @@ forgetest!(invalid_profile, |prj, cmd| { function test(bool) public {} } ", - ) - .unwrap(); + ); cmd.arg("test").assert_failure().stderr_eq(str![[r#" -Error: Inline config error at test/inline.sol:80:123:0: invalid profile `unknown.fuzz.runs = 2`; valid profiles: default - -"#]]); -}); - -// TODO: Uncomment once this done for normal config too. -/* -forgetest!(invalid_key, |prj, cmd| { - prj.add_test( - "inline.sol", - " - /** forge-config: default.fuzzz.runs = 2 */ - contract Inline { - function test(bool) public {} - } - ", - ) - .unwrap(); - - cmd.arg("test").assert_failure().stderr_eq(str![[]]).stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 1 test for test/inline.sol:Inline -[FAIL: failed to get inline configuration: unknown config section `default`] test(bool) ([GAS]) -Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) - -Failing tests: -Encountered 1 failing test in test/inline.sol:Inline -[FAIL: failed to get inline configuration: unknown config section `default`] test(bool) ([GAS]) - -Encountered a total of 1 failing tests, 0 tests succeeded +Error: Inline config error at test/inline.sol:4:9: invalid profile `unknown.fuzz.runs = 2`; valid profiles: default "#]]); -}); +} -forgetest!(invalid_key_2, |prj, cmd| { - prj.add_test( - "inline.sol", - " -/** forge-config: default.fuzz.runss = 2 */ - contract Inline { - function test(bool) public {} - } - ", - ) - .unwrap(); - - cmd.arg("test").assert_failure().stderr_eq(str![[]]).stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 1 test for test/inline.sol:Inline -[FAIL: failed to get inline configuration: unknown config section `default`] test(bool) ([GAS]) -Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) - -Failing tests: -Encountered 1 failing test in test/inline.sol:Inline -[FAIL: failed to get inline configuration: unknown config section `default`] test(bool) ([GAS]) - -Encountered a total of 1 failing tests, 0 tests succeeded - -"#]]); -}); -*/ - -forgetest!(invalid_value, |prj, cmd| { +#[forgetest] +fn invalid_value(prj: _, cmd: _) { prj.add_test( "inline.sol", " @@ -146,8 +80,7 @@ forgetest!(invalid_value, |prj, cmd| { function test(bool) public {} } ", - ) - .unwrap(); + ); cmd.arg("test").assert_failure().stderr_eq(str![[]]).stdout_eq(str![[r#" [COMPILING_FILES] with [SOLC_VERSION] @@ -166,10 +99,14 @@ Encountered 1 failing test in test/inline.sol:Inline Encountered a total of 1 failing tests, 0 tests succeeded +Tip: Run `forge test --rerun` to retry only the 1 failed test +Tip: Run `forge test --debug --match-test ` to inspect one failing test in the debugger + "#]]); -}); +} -forgetest!(invalid_value_2, |prj, cmd| { +#[forgetest] +fn invalid_value_2(prj: _, cmd: _) { prj.add_test( "inline.sol", " @@ -178,8 +115,7 @@ forgetest!(invalid_value_2, |prj, cmd| { function test(bool) public {} } ", - ) - .unwrap(); + ); cmd.arg("test").assert_failure().stderr_eq(str![[]]).stdout_eq(str![[r#" [COMPILING_FILES] with [SOLC_VERSION] @@ -198,15 +134,17 @@ Encountered 1 failing test in test/inline.sol:Inline Encountered a total of 1 failing tests, 0 tests succeeded +Tip: Run `forge test --rerun` to retry only the 1 failed test +Tip: Run `forge test --debug --match-test ` to inspect one failing test in the debugger + "#]]); -}); +} -#[cfg(not(feature = "isolate-by-default"))] -forgetest_init!(config_inline_isolate, |prj, cmd| { +#[forgetest_init] +fn config_inline_isolate(prj: _, cmd: _) { use serde::{Deserialize, Deserializer}; use std::{fs, path::Path}; - prj.wipe_contracts(); prj.add_test( "inline.sol", r#" @@ -227,21 +165,23 @@ forgetest_init!(config_inline_isolate, |prj, cmd| { dummy = new Dummy(); } - /// forge-config: default.isolate = true - function test_isolate() public { - vm.startSnapshotGas("testIsolatedFunction"); + /// forge-config: default.isolate = false + function test_non_isolate() public { + assertFalse(vm.isIsolateMode()); + vm.startSnapshotGas("testNonIsolatedFunction"); dummy.setNumber(1); vm.stopSnapshotGas(); } - function test_non_isolate() public { - vm.startSnapshotGas("testNonIsolatedFunction"); - dummy.setNumber(2); + function test_isolate() public { + assertTrue(vm.isIsolateMode()); + vm.startSnapshotGas("testIsolatedFunction"); + dummy.setNumber(1); vm.stopSnapshotGas(); } } - /// forge-config: default.isolate = true + /// forge-config: default.isolate = false contract ContractConfig is Test { Dummy dummy; @@ -250,14 +190,14 @@ forgetest_init!(config_inline_isolate, |prj, cmd| { } function test_non_isolate() public { - vm.startSnapshotGas("testIsolatedContract"); - dummy.setNumber(3); + assertFalse(vm.isIsolateMode()); + vm.startSnapshotGas("testNonIsolatedContract"); + dummy.setNumber(1); vm.stopSnapshotGas(); } } "#, - ) - .unwrap(); + ); cmd.args(["test", "-j1"]).assert_success().stdout_eq(str![[r#" [COMPILING_FILES] with [SOLC_VERSION] @@ -294,7 +234,7 @@ Ran 2 test suites [ELAPSED]: 3 tests passed, 0 failed, 0 skipped (3 total tests) #[serde(rename_all = "camelCase")] struct ContractConfig { #[serde(deserialize_with = "string_to_u64")] - test_isolated_contract: u64, + test_non_isolated_contract: u64, } fn string_to_u64<'de, D>(deserializer: D) -> Result @@ -319,21 +259,109 @@ Ran 2 test suites [ELAPSED]: 3 tests passed, 0 failed, 0 skipped (3 total tests) let contract_config: ContractConfig = read_snapshot(&prj.root().join("snapshots/ContractConfig.json")); - // FunctionConfig { - // test_isolated_function: 48926, - // test_non_isolated_function: 27722, - // } + assert!( + function_config.test_isolated_function > function_config.test_non_isolated_function, + "isolated gas ({}) should be greater than non-isolated gas ({})", + function_config.test_isolated_function, + function_config.test_non_isolated_function + ); + assert_eq!( + function_config.test_non_isolated_function, + contract_config.test_non_isolated_contract + ); +} + +#[forgetest_init] +fn is_isolate_mode_uses_effective_isolation(prj: _, cmd: _) { + prj.update_config(|config| config.isolate = false); + prj.add_test( + "effective_isolation.sol", + r#" + import {Test} from "forge-std/Test.sol"; + + contract EffectiveIsolationTest is Test { + function test_isolate_mode_disabled_by_config() public view { + assertFalse(vm.isIsolateMode()); + } - // ContractConfig { - // test_isolated_contract: 48926, - // } + function test_gas_report_enables_isolate_mode() public view { + assertTrue(vm.isIsolateMode()); + } + } + "#, + ); - assert!(function_config.test_isolated_function > function_config.test_non_isolated_function); - assert_eq!(function_config.test_isolated_function, contract_config.test_isolated_contract); -}); + cmd.args(["test", "--match-test", "test_isolate_mode_disabled_by_config"]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/effective_isolation.sol:EffectiveIsolationTest +[PASS] test_isolate_mode_disabled_by_config() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); + cmd.forge_fuse() + .args(["test", "--gas-report", "--match-test", "test_gas_report_enables_isolate_mode"]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/effective_isolation.sol:EffectiveIsolationTest +[PASS] test_gas_report_enables_isolate_mode() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} -forgetest_init!(config_inline_evm_version, |prj, cmd| { - prj.wipe_contracts(); +#[forgetest_init] +fn inline_isolate_inherits_default_fs_permissions_for_non_default_profile(prj: _, cmd: _) { + std::fs::write( + prj.root().join("foundry.toml"), + r#" + [profile.default] + src = "src" + out = "out" + libs = ["lib"] + fs_permissions = [{ access = "read-write", path = "./data" }] + + [profile.test] + "#, + ) + .unwrap(); + prj.add_test( + "inline_isolate_fs_permissions.sol", + r#" + import {Test} from "forge-std/Test.sol"; + + contract InlineIsolateFsPermissionsTest is Test { + function setUp() public { + if (!vm.exists("./data")) { + vm.createDir("./data", true); + } + } + + /// forge-config: default.isolate = true + function testInlineIsolateCanCreateFile() public { + vm.writeFile("./data/new.txt", "hello"); + vm.removeFile("./data/new.txt"); + } + } + "#, + ); + + cmd.env("FOUNDRY_PROFILE", "test"); + cmd.args(["test", "--match-test", "testInlineIsolateCanCreateFile"]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/inline_isolate_fs_permissions.sol:InlineIsolateFsPermissionsTest +[PASS] testInlineIsolateCanCreateFile() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn config_inline_evm_version(prj: _, cmd: _) { prj.add_test( "inline.sol", r#" @@ -382,8 +410,7 @@ forgetest_init!(config_inline_evm_version, |prj, cmd| { } } "#, - ) - .unwrap(); + ); cmd.args(["test", "--evm-version=cancun", "-j1"]).assert_success().stdout_eq(str![[r#" ... @@ -400,4 +427,354 @@ Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] Ran 2 test suites [ELAPSED]: 4 tests passed, 0 failed, 0 skipped (4 total tests) "#]]); -}); +} + +#[forgetest_init] +fn config_inline_hardfork_same_network_family(prj: _, cmd: _) { + prj.write_config(foundry_config::Config { + hardfork: Some("tempo:T2".parse::().unwrap()), + ..foundry_config::Config::default() + }); + prj.add_test( + "inline.sol", + r#" + import {Test} from "forge-std/Test.sol"; + + contract InlineHardfork is Test { + /// forge-config: default.hardfork = "tempo:T3" + function test_inline_hardfork() public { + assertTrue(true); + } + } + "#, + ); + + cmd.arg("test").assert_success().stdout_eq(str![[r#" +... +Ran 1 test for test/inline.sol:InlineHardfork +[PASS] test_inline_hardfork() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +} + +// Checks that tests annotated with `forge-config: default.networks.network` run on the correct +// EVM network, and that unannotated tests run on the globally configured network. +// +// Each test makes a real call to the Tempo `TipFeeManager` precompile at +// `0xfeec000000000000000000000000000000000000` (a Tempo-only contract that exists on the +// Moderato testnet and is auto-injected by the in-memory Tempo EVM): +// +// * The default-network test asserts the precompile has no code (it does not exist on Ethereum). +// * The Tempo-network test asserts the precompile has code and `userTokens(address)` returns the +// unset zero-address sentinel, proving the Tempo network was actually selected for that test and +// the Tempo genesis state was loaded. +#[forgetest] +fn per_test_network_routing(prj: _, cmd: _) { + prj.add_test( + "inline.sol", + r#" + address constant TIP_FEE_MANAGER = 0xfeEC000000000000000000000000000000000000; + + contract DefaultNetwork { + // No annotation -> runs on the globally selected network (Ethereum by default). + // The Tempo FeeManager precompile must NOT exist here. + function test_fee_manager_absent_on_ethereum() public view { + require( + TIP_FEE_MANAGER.code.length == 0, + "TipFeeManager should not exist on Ethereum" + ); + } + } + + contract TempoNetwork { + /// forge-config: default.networks.network = "tempo" + function test_fee_manager_callable_on_tempo() public view { + // Sentinel bytecode (0xef) is injected at every Tempo precompile address. + require( + TIP_FEE_MANAGER.code.length > 0, + "TipFeeManager must be deployed on Tempo" + ); + + // Call a Tempo-only method: `userTokens(address)` returns the user's preferred + // fee token, or the zero address when none is set. + (bool ok, bytes memory ret) = TIP_FEE_MANAGER.staticcall( + abi.encodeWithSignature("userTokens(address)", address(0)) + ); + require(ok, "userTokens call to TipFeeManager failed"); + require(ret.length == 32, "unexpected return data length"); + address token = abi.decode(ret, (address)); + require(token == address(0), "expected unset user fee token"); + } + } + + // Mixed contract: one function annotated with Tempo, one unannotated (runs on Ethereum). + contract MixedNetwork { + // No annotation -> runs on Ethereum; precompile must be absent. + function test_fee_manager_absent_on_ethereum() public view { + require( + TIP_FEE_MANAGER.code.length == 0, + "TipFeeManager should not exist on Ethereum" + ); + } + + /// forge-config: default.networks.network = "tempo" + function test_fee_manager_callable_on_tempo() public view { + require( + TIP_FEE_MANAGER.code.length > 0, + "TipFeeManager must be deployed on Tempo" + ); + + (bool ok, bytes memory ret) = TIP_FEE_MANAGER.staticcall( + abi.encodeWithSignature("userTokens(address)", address(0)) + ); + require(ok, "userTokens call to TipFeeManager failed"); + require(ret.length == 32, "unexpected return data length"); + address token = abi.decode(ret, (address)); + require(token == address(0), "expected unset user fee token"); + } + } + "#, + ); + + cmd.arg("test").assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/inline.sol:[..]Network +[PASS] test_fee_manager_absent_on_[..]() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/inline.sol:[..]Network +[PASS] test_fee_manager_absent_on_[..]() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/inline.sol:[..]Network +[PASS] test_fee_manager_callable_on_[..]() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/inline.sol:[..]Network +[PASS] test_fee_manager_callable_on_[..]() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 3 test suites [ELAPSED]: 4 tests passed, 0 failed, 0 skipped (4 total tests) + +"#]]); +} + +// Contract-level network configuration must not override the execution profile selected for each +// multi-network pass. Function-level configuration can still select a different pass. +#[forgetest] +fn contract_network_preserves_pass_profile(prj: _, cmd: _) { + prj.add_test( + "inline.sol", + r#" + address constant TIP_FEE_MANAGER = 0xfeEC000000000000000000000000000000000000; + + /// forge-config: default.networks.network = "tempo" + contract ContractNetwork { + /// forge-config: default.networks.network = "ethereum" + function test_fee_manager_absent_on_ethereum() public view { + require( + TIP_FEE_MANAGER.code.length == 0, + "TipFeeManager should not exist on Ethereum" + ); + } + + function test_fee_manager_callable_on_tempo() public view { + require( + TIP_FEE_MANAGER.code.length > 0, + "TipFeeManager must be deployed on Tempo" + ); + } + } + "#, + ); + + cmd.arg("test").assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/inline.sol:ContractNetwork +[PASS] test_fee_manager_absent_on_ethereum() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/inline.sol:ContractNetwork +[PASS] test_fee_manager_callable_on_tempo() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 2 tests passed, 0 failed, 0 skipped (2 total tests) + +"#]]); +} + +// TIP20 calls made in an inline Tempo pass should use the token name loaded from Tempo state in +// rendered traces. +#[forgetest] +fn tempo_pass_traces_tip20_name(prj: _, cmd: _) { + prj.add_test( + "inline.sol", + r#" + interface ITIP20 { + function balanceOf(address account) external view returns (uint256); + } + + contract TempoTraceLabels { + address constant ALPHA_USD = 0x20C0000000000000000000000000000000000001; + + /// forge-config: default.networks.network = "tempo" + function test_tip20_trace_uses_name() public view { + ITIP20(ALPHA_USD).balanceOf(address(this)); + } + } + "#, + ); + + cmd.args(["test", "--mt", "test_tip20_trace_uses_name", "-vvvv"]).assert_success().stdout_eq( + str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/inline.sol:TempoTraceLabels +[PASS] test_tip20_trace_uses_name() ([GAS]) +Traces: + [..] TempoTraceLabels::test_tip20_trace_uses_name() + ├─ [..] AlphaUSD::balanceOf([..]) [staticcall] + │ └─ ← [Return] [..] + └─ ← [Stop] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]], + ); +} + +// Suites with no selected tests in a network pass must be skipped before setup. +#[forgetest] +fn network_pass_skips_unselected_setup(prj: _, cmd: _) { + prj.add_test( + "inline.sol", + r#" + /// forge-config: default.networks.network = "tempo" + /// forge-config: default.hardfork = "tempo:T3" + contract TempoNetwork { + bool ready; + + function setUp() public { + ready = true; + } + + function test_contract_tempo() public view { + require(ready, "setup must run in the selected pass"); + } + } + + /// forge-config: default.networks.network = "ethereum" + contract EthereumNetwork { + function test_ethereum() public {} + } + + contract FunctionNetwork { + bool ready; + + function setUp() public { + require( + address(0xfeEC000000000000000000000000000000000000).code.length > 0, + "setup must only run on Tempo" + ); + ready = true; + } + + /// forge-config: default.networks.network = "tempo" + function test_tempo() public view { + require(ready, "setup must run in the selected pass"); + } + } + "#, + ); + + cmd.arg("test").assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/inline.sol:[..]Network +[PASS] test_[..]() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/inline.sol:[..]Network +[PASS] test_[..]() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/inline.sol:[..]Network +[PASS] test_[..]() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 3 test suites [ELAPSED]: 3 tests passed, 0 failed, 0 skipped (3 total tests) + +"#]]); +} + +// Inline config must not move the project root, which anchors `fs_permissions` and the +// `foundry.toml` write guard. +#[forgetest] +fn inline_root_is_ignored(prj: _, cmd: _) { + prj.update_config(|_| {}); + let foundry_toml = prj.root().join("foundry.toml"); + let original = std::fs::read_to_string(&foundry_toml).unwrap(); + prj.add_test( + "inline.sol", + &r#" + interface Vm { + function projectRoot() external view returns (string memory); + function writeFile(string calldata path, string calldata data) external; + } + + /// forge-config: default.root = "/" + /// forge-config: default.fs_permissions = [{ access = "read-write", path = "." }] + contract InlineRoot { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + function test_root_unchanged() public view { + require(keccak256(bytes(vm.projectRoot())) != keccak256("/"), "root moved"); + } + + function test_write_foundry_toml() public { + vm.writeFile("FOUNDRY_TOML", "ffi = true"); + } + } + "# + .replace("FOUNDRY_TOML", &foundry_toml.to_string_lossy().replace('\\', "/")), + ); + + cmd.args(["test", "--mt", "test_root_unchanged"]).assert_success(); + cmd.forge_fuse().args(["test", "--mt", "test_write_foundry_toml"]).assert_failure().stdout_eq( + str![[r#" +No files changed, compilation skipped + +Ran 1 test for test/inline.sol:InlineRoot +[FAIL: vm.writeFile: access to `foundry.toml` is not allowed] test_write_foundry_toml() ([GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/inline.sol:InlineRoot +[FAIL: vm.writeFile: access to `foundry.toml` is not allowed] test_write_foundry_toml() ([GAS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test +Tip: Run `forge test --debug --match-test ` to inspect one failing test in the debugger + +"#]], + ); + assert_eq!(std::fs::read_to_string(&foundry_toml).unwrap(), original); +} diff --git a/crates/forge/tests/cli/install.rs b/crates/forge/tests/cli/install.rs index c842224cb41af..ec3c51b22f242 100644 --- a/crates/forge/tests/cli/install.rs +++ b/crates/forge/tests/cli/install.rs @@ -5,7 +5,7 @@ use foundry_cli::utils::{Git, Submodules}; use foundry_compilers::artifacts::Remapping; use foundry_config::Config; use foundry_test_utils::util::{ - ExtTester, FORGE_STD_REVISION, TestCommand, pretty_err, read_string, + ExtTester, FORGE_STD_REVISION, OutputExt, TestCommand, pretty_err, read_string, }; use semver::Version; use std::{ @@ -14,14 +14,21 @@ use std::{ process::Command, str::FromStr, }; +use url::Url; + +#[cfg(unix)] +use std::os::unix::fs::symlink; fn lockfile_get(root: &Path, dep_path: &Path) -> Option { let mut l = Lockfile::new(root); l.read().unwrap(); l.get(dep_path).cloned() } + // checks missing dependencies are auto installed -forgetest_init!(can_install_missing_deps_build, |prj, cmd| { +#[forgetest_init] +fn can_install_missing_deps_build(prj: _, cmd: _) { + prj.initialize_default_contracts(); prj.clear(); // wipe forge-std @@ -29,14 +36,18 @@ forgetest_init!(can_install_missing_deps_build, |prj, cmd| { pretty_err(&forge_std_dir, fs::remove_dir_all(&forge_std_dir)); // Build the project - cmd.arg("build").assert_success().stdout_eq(str![[r#" -Missing dependencies found. Installing now... - -[UPDATING_DEPENDENCIES] + cmd.arg("build") + .assert_success() + .stdout_eq(str![[r#" [COMPILING_FILES] with [SOLC_VERSION] [SOLC_VERSION] [ELAPSED] Compiler run successful! +"#]]) + .stderr_eq(str![[r#" +Missing dependencies found. Installing now... +[UPDATING_DEPENDENCIES] +... "#]]); // assert lockfile @@ -48,20 +59,21 @@ Compiler run successful! No files changed, compilation skipped "#]]); -}); +} // checks missing dependencies are auto installed -forgetest_init!(can_install_missing_deps_test, |prj, cmd| { +#[forgetest_init] +fn can_install_missing_deps_test(prj: _, cmd: _) { + prj.initialize_default_contracts(); prj.clear(); // wipe forge-std let forge_std_dir = prj.root().join("lib/forge-std"); pretty_err(&forge_std_dir, fs::remove_dir_all(&forge_std_dir)); - cmd.arg("test").assert_success().stdout_eq(str![[r#" -Missing dependencies found. Installing now... - -[UPDATING_DEPENDENCIES] + cmd.arg("test") + .assert_success() + .stdout_eq(str![[r#" [COMPILING_FILES] with [SOLC_VERSION] [SOLC_VERSION] [ELAPSED] Compiler run successful! @@ -73,15 +85,42 @@ Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] Ran 1 test suite [ELAPSED]: 2 tests passed, 0 failed, 0 skipped (2 total tests) +"#]]) + .stderr_eq(str![[r#" +Missing dependencies found. Installing now... +[UPDATING_DEPENDENCIES] +... "#]]); // assert lockfile let forge_std = lockfile_get(prj.root(), &PathBuf::from("lib/forge-std")).unwrap(); assert_eq!(forge_std.rev(), FORGE_STD_REVISION); -}); +} + +// Checks missing dependencies are auto installed. +#[forgetest_init] +fn can_install_missing_deps_lint(prj: _, cmd: _) { + prj.initialize_default_contracts(); + prj.clear(); + + // Wipe forge-std. + let forge_std_dir = prj.root().join("lib/forge-std"); + pretty_err(&forge_std_dir, fs::remove_dir_all(&forge_std_dir)); + + cmd.arg("lint").assert_success().stdout_eq(str![""]).stderr_eq(str![[r#" +Missing dependencies found. Installing now... +[UPDATING_DEPENDENCIES] +... +"#]]); + + // Assert lockfile. + let forge_std = lockfile_get(prj.root(), &PathBuf::from("lib/forge-std")).unwrap(); + assert_eq!(forge_std.rev(), FORGE_STD_REVISION); +} // test to check that install/remove works properly -forgetest!(can_install_and_remove, |prj, cmd| { +#[forgetest] +fn can_install_and_remove(prj: _, cmd: _) { cmd.git_init(); let libs = prj.root().join("lib"); @@ -92,13 +131,16 @@ forgetest!(can_install_and_remove, |prj, cmd| { let forge_std_mod = git_mod.join("forge-std"); let install = |cmd: &mut TestCommand| { - cmd.forge_fuse().args(["install", "foundry-rs/forge-std"]).assert_success().stdout_eq( - str![[r#" -Installing forge-std in [..] (url: Some("https://github.com/foundry-rs/forge-std"), tag: None) + cmd.forge_fuse() + .args(["install", "foundry-rs/forge-std"]) + .assert_success() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Installing forge-std in [..] (url: https://github.com/foundry-rs/forge-std, tag: None) +... Installed forge-std[..] -"#]], - ); +"#]]); assert!(forge_std.exists()); assert!(forge_std_mod.exists()); @@ -108,13 +150,14 @@ Installing forge-std in [..] (url: Some("https://github.com/foundry-rs/forge-std }; let remove = |cmd: &mut TestCommand, target: &str| { - // TODO: flaky behavior with URL, sometimes it is None, sometimes it is Some("https://github.com/lib/forge-std") - cmd.forge_fuse().args(["remove", "--force", target]).assert_success().stdout_eq(str![[ - r#" -Removing 'forge-std' in [..], (url: [..], tag: None) + cmd.forge_fuse() + .args(["remove", "--force", target]) + .assert_success() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Removing 'forge-std' in [..], (url: https://github.com/foundry-rs/forge-std, tag: None) -"# - ]]); +"#]]); assert!(!forge_std.exists()); assert!(!forge_std_mod.exists()); @@ -132,10 +175,331 @@ Removing 'forge-std' in [..], (url: [..], tag: None) // install again and remove via relative path install(&mut cmd); remove(&mut cmd, "lib/forge-std"); -}); +} + +// https://github.com/foundry-rs/foundry/issues/6790 +#[forgetest] +fn failed_install_leaves_repository_clean(prj: _, cmd: _) { + cmd.git_init(); + let git = Git::new(prj.root()); + assert!(git.is_clean().unwrap()); + + cmd.forge_fuse() + .args(["install", "vectorized/solady@this-tag-does-not-exist"]) + .assert_failure() + .stderr_eq(str![[r#" +Installing solady in [..] (url: https://github.com/vectorized/solady, tag: this-tag-does-not-exist) +... +Error: Tag: "this-tag-does-not-exist" not found for repo "https://github.com/vectorized/solady"! + +"#]]); + + assert!(git.is_clean().unwrap()); + assert!(!prj.root().join(".gitmodules").exists()); + assert!(!prj.root().join("lib/solady").exists()); + assert!(!git.absolute_git_dir().unwrap().join("modules/lib/solady").exists()); + assert!(git.submodule_url(Path::new("lib/solady")).unwrap_or(None).is_none()); +} + +#[forgetest] +fn install_rejects_assume_unchanged_orphan_gitlink(prj: _, cmd: _) { + cmd.git_init(); + fs::write(prj.root().join("README.md"), "baseline").unwrap(); + cmd.git_add(); + cmd.git_commit("baseline"); + let head = Command::new("git") + .current_dir(prj.root()) + .args(["rev-parse", "HEAD"]) + .output() + .unwrap() + .stdout; + let head = String::from_utf8(head).unwrap(); + let git = |args: &[&str]| { + assert!(Command::new("git").current_dir(prj.root()).args(args).status().unwrap().success()); + }; + git(&["update-index", "--add", "--cacheinfo", "160000", head.trim(), "lib/solady"]); + git(&["update-index", "--assume-unchanged", "lib/solady"]); + let index = fs::read(prj.root().join(".git/index")).unwrap(); + let status = Command::new("git") + .current_dir(prj.root()) + .args(["status", "--porcelain=v2"]) + .output() + .unwrap() + .stdout; + + cmd.forge_fuse().args(["install", "vectorized/solady"]).assert_failure(); + + assert_eq!(fs::read(prj.root().join(".git/index")).unwrap(), index); + assert_eq!( + Command::new("git") + .current_dir(prj.root()) + .args(["status", "--porcelain=v2"]) + .output() + .unwrap() + .stdout, + status + ); + assert!(!prj.root().join(".gitmodules").exists()); + assert!(!prj.root().join("lib/solady").exists()); +} + +#[forgetest] +fn failed_install_with_wildcard_alias_preserves_sibling(prj: _, cmd: _) { + cmd.git_init(); + cmd.forge_fuse().args(["install", "foundry-rs/forge-std"]).assert_success(); + let sibling = prj.root().join("lib/forge-std"); + assert!( + Command::new("git") + .current_dir(&sibling) + .args(["checkout", "HEAD^"]) + .output() + .unwrap() + .status + .success() + ); + let index = || { + Command::new("git") + .current_dir(prj.root()) + .args(["ls-files", "--stage", "-v", "-z"]) + .output() + .unwrap() + .stdout + }; + let index_before = index(); + + cmd.forge_fuse() + .args(["install", "*=vectorized/solady@this-tag-does-not-exist"]) + .assert_failure(); + + assert_eq!(index(), index_before); + assert!(sibling.join(".git").exists()); + assert!(Git::new(prj.root()).is_gitlink(Path::new("lib/forge-std")).unwrap()); +} + +#[forgetest] +fn install_rejects_non_normal_alias(prj: _, cmd: _) { + cmd.git_init(); + let git = Git::new(prj.root()); + + cmd.forge_fuse() + .args(["install", "foo/../solady=vectorized/solady@this-tag-does-not-exist"]) + .assert_failure(); + + assert!(git.is_clean().unwrap()); + assert!(!prj.root().join(".gitmodules").exists()); + assert!(!prj.root().join("lib/solady").exists()); + assert!(!git.absolute_git_dir().unwrap().join("modules/lib/solady").exists()); + assert!(!git.has_submodule_config(Path::new("lib/solady")).unwrap()); +} + +#[forgetest] +fn failed_install_preserves_gitmodules(prj: _, cmd: _) { + cmd.git_init(); + let gitmodules = prj.root().join(".gitmodules"); + let original = r#"[submodule "existing"] + path = lib/existing + url = https://github.com/example/existing +"#; + fs::write(&gitmodules, original).unwrap(); + cmd.git_add(); + cmd.git_commit("add gitmodules"); + + cmd.forge_fuse() + .args(["install", "vectorized/solady@this-tag-does-not-exist"]) + .assert_failure(); + + assert!(Git::new(prj.root()).is_clean().unwrap()); + assert_eq!(read_string(&gitmodules), original); +} + +#[forgetest] +fn failed_submodule_add_leaves_repository_clean(prj: _, cmd: _) { + cmd.git_init(); + let git = Git::new(prj.root()); + let git_dir = git.absolute_git_dir().unwrap(); + let index_lock = git_dir.join("index.lock"); + fs::write(&index_lock, []).unwrap(); + + let output = cmd + .forge_fuse() + .args(["install", "vectorized/solady@this-tag-does-not-exist"]) + .assert_failure(); + assert!(output.get_output().stderr_lossy().contains("index.lock")); + + assert!(index_lock.exists()); + assert!(git.is_clean().unwrap()); + assert!(!prj.root().join(".gitmodules").exists()); + assert!(!prj.root().join("lib/solady").exists()); + assert!(!git_dir.join("modules/lib/solady").exists()); + assert!(!git.has_submodule_config(Path::new("lib/solady")).unwrap()); +} + +#[forgetest] +fn install_rejects_dirty_gitmodules(prj: _, cmd: _) { + cmd.git_init(); + let git = Git::new(prj.root()); + let gitmodules = prj.root().join(".gitmodules"); + let original = r#"[submodule "existing"] + path = lib/existing + url = https://github.com/example/existing +"#; + fs::write(&gitmodules, original).unwrap(); + cmd.git_add(); + cmd.git_commit("add gitmodules"); + + let modified = format!("{original}# local edit\n"); + fs::write(&gitmodules, &modified).unwrap(); + let status = || { + Command::new("git") + .current_dir(prj.root()) + .args(["status", "--porcelain", "--", ".gitmodules"]) + .output() + .unwrap() + .stdout + }; + + let unstaged_status = status(); + let output = cmd + .forge_fuse() + .args(["install", "vectorized/solady@this-tag-does-not-exist"]) + .assert_failure(); + assert!( + output.get_output().stderr_lossy().contains("target or .gitmodules has existing changes") + ); + assert_eq!(status(), unstaged_status); + assert_eq!(read_string(&gitmodules), modified); + + cmd.git_add(); + let staged_status = status(); + cmd.forge_fuse() + .args(["install", "vectorized/solady@this-tag-does-not-exist"]) + .assert_failure(); + assert_eq!(status(), staged_status); + assert_eq!(read_string(&gitmodules), modified); + + assert!(!prj.root().join("lib/solady").exists()); + assert!(!git.absolute_git_dir().unwrap().join("modules/lib/solady").exists()); + assert!(!git.has_submodule_config(Path::new("lib/solady")).unwrap()); +} + +#[forgetest] +fn install_rejects_hidden_gitmodules_changes(prj: _, cmd: _) { + cmd.git_init(); + let gitmodules = prj.root().join(".gitmodules"); + fs::write( + &gitmodules, + "[submodule \"existing\"]\n\tpath = lib/existing\n\turl = https://example.com\n", + ) + .unwrap(); + cmd.git_add(); + cmd.git_commit("add gitmodules"); + + fs::write(&gitmodules, "preserve\n").unwrap(); + assert!( + Command::new("git") + .current_dir(prj.root()) + .args(["update-index", "--assume-unchanged", ".gitmodules"]) + .output() + .unwrap() + .status + .success() + ); + let index = fs::read(prj.root().join(".git/index")).unwrap(); + + let output = cmd + .forge_fuse() + .args(["install", "vectorized/solady@this-tag-does-not-exist"]) + .assert_failure(); + assert!( + output.get_output().stderr_lossy().contains("target or .gitmodules has existing changes") + ); + assert_eq!(read_string(&gitmodules), "preserve\n"); + assert_eq!(fs::read(prj.root().join(".git/index")).unwrap(), index); + assert!(!prj.root().join("lib/solady").exists()); +} + +#[cfg(unix)] +#[forgetest] +fn install_rejects_dangling_gitmodules_symlink(prj: _, cmd: _) { + cmd.git_init(); + fs::write(prj.root().join(".gitignore"), ".gitmodules\n").unwrap(); + cmd.git_add(); + cmd.git_commit("ignore gitmodules"); + + let gitmodules = prj.root().join(".gitmodules"); + symlink("gitmodules-target", &gitmodules).unwrap(); + let index = fs::read(prj.root().join(".git/index")).unwrap(); + + cmd.forge_fuse() + .args(["install", "vectorized/solady@this-tag-does-not-exist"]) + .assert_failure(); + + assert_eq!(fs::read_link(&gitmodules).unwrap(), Path::new("gitmodules-target")); + assert!(!prj.root().join("gitmodules-target").exists()); + assert_eq!(fs::read(prj.root().join(".git/index")).unwrap(), index); + assert!(!prj.root().join("lib/solady").exists()); +} + +#[forgetest] +fn install_rejects_ignored_gitmodules(prj: _, cmd: _) { + cmd.git_init(); + let git = Git::new(prj.root()); + fs::write(prj.root().join(".gitignore"), ".gitmodules\n").unwrap(); + cmd.git_add(); + cmd.git_commit("ignore gitmodules"); + + let gitmodules = prj.root().join(".gitmodules"); + let original = r#"[submodule "existing"] + path = lib/existing + url = https://github.com/example/existing +"#; + fs::write(&gitmodules, original).unwrap(); + assert!(git.is_clean().unwrap()); + + let output = cmd + .forge_fuse() + .args(["install", "vectorized/solady@this-tag-does-not-exist"]) + .assert_failure(); + assert!( + output.get_output().stderr_lossy().contains("target or .gitmodules has existing changes") + ); + + assert!(git.is_clean().unwrap()); + assert_eq!(read_string(&gitmodules), original); + assert!(!prj.root().join("lib/solady").exists()); + assert!(!git.absolute_git_dir().unwrap().join("modules/lib/solady").exists()); + assert!(!git.has_submodule_config(Path::new("lib/solady")).unwrap()); +} + +#[forgetest] +fn install_rejects_existing_submodule_path(prj: _, cmd: _) { + cmd.git_init(); + let git = Git::new(prj.root()); + let gitmodules = prj.root().join(".gitmodules"); + let original = r#"[submodule "different-name"] + path = lib/solady + url = https://github.com/example/solady +"#; + fs::write(&gitmodules, original).unwrap(); + cmd.git_add(); + cmd.git_commit("add orphan submodule path"); + + let output = cmd + .forge_fuse() + .args(["install", "vectorized/solady@this-tag-does-not-exist"]) + .assert_failure(); + assert!(output.get_output().stderr_lossy().contains("already contains a matching submodule")); + + assert!(git.is_clean().unwrap()); + assert_eq!(read_string(&gitmodules), original); + assert!(!prj.root().join("lib/solady").exists()); + assert!(!git.absolute_git_dir().unwrap().join("modules/lib/solady").exists()); + assert!(!git.has_submodule_config(Path::new("lib/solady")).unwrap()); +} // test to check we can run `forge install` in an empty dir -forgetest!(can_install_empty, |prj, cmd| { +#[forgetest] +fn can_install_empty(prj: _, cmd: _) { // create cmd.git_init(); cmd.forge_fuse().args(["install"]); @@ -149,10 +513,47 @@ forgetest!(can_install_empty, |prj, cmd| { cmd.forge_fuse().args(["install"]); cmd.assert_empty_stdout(); -}); +} + +// +#[forgetest] +fn install_from_nested_git_repo_uses_project_root(prj: _, cmd: _) { + prj.update_config(|config| config.libs = vec![PathBuf::from("dependencies")]); + cmd.git_init(); + + let nested = prj.root().join("vendor/dependency"); + fs::create_dir_all(&nested).unwrap(); + Git::new(&nested).init().unwrap(); + + cmd.forge_fuse().current_dir(&nested).args(["install", "--no-git"]).assert_success(); + + assert!(prj.root().join("dependencies").is_dir()); + assert!(!nested.join("lib").exists()); +} + +#[forgetest] +fn install_from_nested_foundry_project_uses_nested_root(prj: _, cmd: _) { + prj.update_config(|config| config.libs = vec![PathBuf::from("outer-dependencies")]); + cmd.git_init(); + + let nested = prj.root().join("vendor/dependency"); + fs::create_dir_all(&nested).unwrap(); + Git::new(&nested).init().unwrap(); + fs::write( + nested.join(Config::FILE_NAME), + "[profile.default]\nlibs = [\"nested-dependencies\"]\n", + ) + .unwrap(); + + cmd.forge_fuse().current_dir(&nested).args(["install", "--no-git"]).assert_success(); + + assert!(nested.join("nested-dependencies").is_dir()); + assert!(!prj.root().join("outer-dependencies").exists()); +} // test to check that package can be reinstalled after manually removing the directory -forgetest!(can_reinstall_after_manual_remove, |prj, cmd| { +#[forgetest] +fn can_reinstall_after_manual_remove(prj: _, cmd: _) { cmd.git_init(); let libs = prj.root().join("lib"); @@ -163,11 +564,15 @@ forgetest!(can_reinstall_after_manual_remove, |prj, cmd| { let forge_std_mod = git_mod.join("forge-std"); let install = |cmd: &mut TestCommand| { - cmd.forge_fuse().args(["install", "foundry-rs/forge-std"]).assert_success().stdout_eq( - str![[r#" -Installing forge-std in [..] (url: Some("https://github.com/foundry-rs/forge-std"), tag: None) - Installed forge-std tag=[..]"#]], - ); + cmd.forge_fuse() + .args(["install", "foundry-rs/forge-std"]) + .assert_success() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Installing forge-std in [..] (url: https://github.com/foundry-rs/forge-std, tag: None) +... + Installed forge-std tag=[..] +"#]]); assert!(forge_std.exists()); assert!(forge_std_mod.exists()); @@ -185,21 +590,81 @@ Installing forge-std in [..] (url: Some("https://github.com/foundry-rs/forge-std install(&mut cmd); let forge_std_lock = lockfile_get(prj.root(), &PathBuf::from("lib/forge-std")).unwrap(); assert!(matches!(forge_std_lock, DepIdentifier::Tag { .. })); -}); +} + +// https://github.com/foundry-rs/foundry/issues/4353 +#[forgetest] +fn can_reinit_submodules(prj: _, cmd: _) { + cmd.git_init(); + + let source = tempfile::tempdir().unwrap(); + let source_git = Git::new(source.path()); + source_git.init().unwrap(); + fs::write(source.path().join("source.txt"), "first revision\n").unwrap(); + source_git.add(["source.txt"]).unwrap(); + source_git.commit("first revision").unwrap(); + let first_rev = source_git.head().unwrap(); + + fs::write(source.path().join("source.txt"), "second revision\n").unwrap(); + source_git.add(["source.txt"]).unwrap(); + source_git.commit("second revision").unwrap(); + let second_rev = source_git.head().unwrap(); + + let output = Command::new("git") + .current_dir(prj.root()) + .args(["-c", "protocol.file.allow=always", "submodule", "add", "--"]) + .arg(source.path()) + .arg("lib/dep") + .output() + .unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + + let dependency = prj.root().join("lib/dep"); + let dependency_git = Git::new(&dependency); + dependency_git.checkout(false, &first_rev).unwrap(); + cmd.git_add(); + cmd.git_commit("add dependency"); + + dependency_git.checkout(false, &second_rev).unwrap(); + Git::new(prj.root()).add(["lib/dep"]).unwrap(); + cmd.git_commit("advance dependency"); + + dependency_git.checkout(false, &first_rev).unwrap(); + fs::write(dependency.join("source.txt"), "local edit\n").unwrap(); + + cmd.forge_fuse(); + cmd.env("GIT_ALLOW_PROTOCOL", "file"); + cmd.arg("reinit").assert_success(); + assert_eq!(dependency_git.head().unwrap(), second_rev); + assert_eq!( + read_string(dependency.join("source.txt")).replace("\r\n", "\n"), + "second revision\n" + ); +} // test that we can repeatedly install the same dependency without changes -forgetest!(can_install_repeatedly, |_prj, cmd| { +#[forgetest] +fn can_install_repeatedly(cmd: _) { cmd.git_init(); cmd.forge_fuse().args(["install", "foundry-rs/forge-std"]); for _ in 0..3 { cmd.assert_success(); } -}); +} + +#[forgetest] +fn can_install_multiple_submodules(cmd: _) { + cmd.git_init(); + cmd.forge_fuse() + .args(["install", "foundry-rs/forge-std", "vectorized/solady"]) + .assert_success(); +} // test that by default we install the latest semver release tag // -forgetest!(can_install_latest_release_tag, |prj, cmd| { +#[forgetest] +fn can_install_latest_release_tag(prj: _, cmd: _) { cmd.git_init(); cmd.forge_fuse().args(["install", "openzeppelin/openzeppelin-contracts"]); cmd.assert_success(); @@ -217,9 +682,10 @@ forgetest!(can_install_latest_release_tag, |prj, cmd| { let current: Version = tag.as_ref().trim_start_matches('v').trim().parse().unwrap(); assert!(current >= version); -}); +} -forgetest!(can_update_and_retain_tag_revs, |prj, cmd| { +#[forgetest] +fn can_update_and_retain_tag_revs(prj: _, cmd: _) { cmd.git_init(); // Installs oz at release tag @@ -249,9 +715,241 @@ forgetest!(can_update_and_retain_tag_revs, |prj, cmd| { let solady_update = lockfile_get(prj.root(), &PathBuf::from("lib/solady")).unwrap(); assert_eq!(oz_init, oz_update); assert_eq!(solady_init, solady_update); -}); +} + +#[forgetest] +fn update_rejects_lockfile_paths_outside_submodules(prj: _, cmd: _) { + cmd.git_init(); + let parent_git = Git::new(prj.root()); + let state = prj.root().join("parent-state.txt"); + fs::write(&state, "old\n").unwrap(); + parent_git.add(["parent-state.txt"]).unwrap(); + parent_git.commit("old").unwrap(); + fs::write(&state, "new\n").unwrap(); + parent_git.add(["parent-state.txt"]).unwrap(); + parent_git.commit("new").unwrap(); + let parent_head = parent_git.head().unwrap(); + + let project = prj.root().join("project"); + fs::create_dir(&project).unwrap(); + let project_git = Git::new(&project); + project_git.init().unwrap(); + fs::write(project.join("foundry.toml"), "[profile.default]\n").unwrap(); + fs::write(project.join("foundry.lock"), r#"{"..":{"rev":"HEAD^"}}"#).unwrap(); + + let dependency = tempfile::tempdir().unwrap(); + let dependency_git = Git::new(dependency.path()); + dependency_git.init().unwrap(); + fs::write(dependency.path().join("file"), "content\n").unwrap(); + dependency_git.add(["file"]).unwrap(); + dependency_git.commit("initial").unwrap(); + let dependency_rev = dependency_git.head().unwrap(); + // Windows Git rejects newlines in paths; retain the spoofed status entry on Unix. + let submodule_path = if cfg!(windows) { + "lib/decoy" + } else { + "lib/decoy\n0123456789012345678901234567890123456789 .." + }; + fs::write( + project.join(".gitmodules"), + format!( + "[submodule \"decoy\"]\n\tpath = \"{}\"\n\turl = {}\n", + submodule_path.replace('\n', "\\n"), + Url::from_file_path(dependency.path()).unwrap() + ), + ) + .unwrap(); + project_git.add([".gitmodules"]).unwrap(); + let output = Command::new("git") + .current_dir(&project) + .args(["update-index", "--add", "--cacheinfo"]) + .arg(format!("160000,{dependency_rev},{submodule_path}")) + .output() + .unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + + cmd.forge_fuse() + .arg("update") + .arg("--root") + .arg(&project) + .assert_failure() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Error: foundry.lock entry `..` does not match an installed Git submodule + +"#]]); + + assert_eq!(parent_git.head().unwrap(), parent_head); + assert_eq!(fs::read_to_string(state).unwrap(), "new\n"); +} + +#[forgetest] +fn update_rejects_uninitialized_submodule_worktrees(prj: _, cmd: _) { + cmd.git_init(); + let git = Git::new(prj.root()); + + let dependency = tempfile::tempdir().unwrap(); + let dependency_git = Git::new(dependency.path()); + dependency_git.init().unwrap(); + fs::write(dependency.path().join("file"), "content\n").unwrap(); + dependency_git.add(["file"]).unwrap(); + dependency_git.commit("initial").unwrap(); + let dependency_rev = dependency_git.head().unwrap(); + + fs::write( + prj.root().join(".gitmodules"), + format!( + "[submodule \"lib/skipped\"]\n\tpath = lib/skipped\n\turl = {}\n\tupdate = none\n", + Url::from_file_path(dependency.path()).unwrap() + ), + ) + .unwrap(); + fs::write(prj.root().join("parent-state.txt"), "old\n").unwrap(); + git.add([".gitmodules", "parent-state.txt"]).unwrap(); + let output = Command::new("git") + .current_dir(prj.root()) + .args(["update-index", "--add", "--cacheinfo"]) + .arg(format!("160000,{dependency_rev},lib/skipped")) + .output() + .unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + git.commit("old").unwrap(); + fs::write(prj.root().join("parent-state.txt"), "new\n").unwrap(); + git.add(["parent-state.txt"]).unwrap(); + git.commit("new").unwrap(); + let parent_head = git.head().unwrap(); + + fs::create_dir_all(prj.root().join("lib/skipped")).unwrap(); + fs::write(prj.root().join("foundry.lock"), r#"{"lib/skipped":{"rev":"HEAD^"}}"#).unwrap(); + + cmd.forge_fuse().arg("update").assert_failure().stdout_eq(str![""]).stderr_eq(str![[r#" +Submodule 'lib/skipped' ([..]) registered for path 'lib/skipped' +Skipping submodule 'lib/skipped' +Error: Dependency at `lib/skipped` is not an initialized Git submodule worktree + +"#]]); + + assert_eq!(git.head().unwrap(), parent_head); + assert_eq!(fs::read_to_string(prj.root().join("parent-state.txt")).unwrap(), "new\n"); +} + +#[forgetest] +fn update_initializes_uninitialized_submodule_worktrees(prj: _, cmd: _) { + cmd.git_init(); + let dependency = tempfile::tempdir().unwrap(); + let dependency_git = Git::new(dependency.path()); + dependency_git.init().unwrap(); + fs::write(dependency.path().join("file"), "content\n").unwrap(); + dependency_git.add(["file"]).unwrap(); + dependency_git.commit("initial").unwrap(); + let dependency_rev = dependency_git.head().unwrap(); + + let output = Command::new("git") + .current_dir(prj.root()) + .args(["-c", "protocol.file.allow=always", "submodule", "add", "--"]) + .arg(dependency.path()) + .arg("lib/dep") + .output() + .unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + let mut lock = Lockfile::new(prj.root()); + lock.insert( + PathBuf::from("lib/dep"), + DepIdentifier::Rev { rev: dependency_rev.clone(), r#override: false }, + ); + lock.write().unwrap(); + cmd.git_add(); + cmd.git_commit("add dependency"); + + let output = Command::new("git") + .current_dir(prj.root()) + .args(["submodule", "deinit", "--force", "--", "lib/dep"]) + .output() + .unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + + cmd.forge_fuse(); + cmd.env("GIT_ALLOW_PROTOCOL", "file"); + cmd.arg("update").assert_success(); + let dependency_path = prj.root().join("lib/dep"); + let dependency_git = Git::new(&dependency_path); + assert!(dependency_git.is_repo_root().unwrap()); + assert_eq!(dependency_git.head().unwrap(), dependency_rev); +} -forgetest!(can_override_tag_in_update, |prj, cmd| { +#[forgetest] +fn can_update_only_selected_dependencies(prj: _, cmd: _) { + cmd.git_init(); + + let source = tempfile::tempdir().unwrap(); + let source_git = Git::new(source.path()); + source_git.init().unwrap(); + fs::write(source.path().join("source.txt"), "first revision\n").unwrap(); + source_git.add(["source.txt"]).unwrap(); + source_git.commit("first revision").unwrap(); + let (first, branch) = source_git.current_rev_branch(source.path()).unwrap(); + fs::write(source.path().join("source.txt"), "second revision\n").unwrap(); + source_git.add(["source.txt"]).unwrap(); + source_git.commit("second revision").unwrap(); + let second = source_git.head().unwrap(); + + let mut lock = Lockfile::new(prj.root()); + for name in ["dep-a", "dep-b", "dep-c", "dep-pin"] { + let path = PathBuf::from(format!("lib/{name}")); + let output = Command::new("git") + .current_dir(prj.root()) + .args(["-c", "protocol.file.allow=always", "submodule", "add", "--"]) + .arg(source.path()) + .arg(&path) + .output() + .unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + Git::new(&prj.root().join(&path)).checkout(false, &first).unwrap(); + let dep = if name == "dep-pin" { + DepIdentifier::Rev { rev: first.clone(), r#override: false } + } else { + Git::new(prj.root()).set_submodule_branch(&path, &branch).unwrap(); + DepIdentifier::Branch { name: branch.clone(), rev: first.clone(), r#override: false } + }; + lock.insert(path, dep); + } + lock.write().unwrap(); + cmd.git_add(); + cmd.git_commit("pin dependency fixtures"); + + let assert_revisions = |expected: [&str; 4]| { + for (name, rev) in ["dep-a", "dep-b", "dep-c", "dep-pin"].into_iter().zip(expected) { + let path = PathBuf::from(format!("lib/{name}")); + assert_eq!(Git::new(&prj.root().join(&path)).head().unwrap(), rev, "{name}"); + assert_eq!(lockfile_get(prj.root(), &path).unwrap().rev(), rev, "{name}"); + } + }; + + // A pinned selection must not become Git's empty-path update of every dependency. + cmd.forge_fuse(); + cmd.env("GIT_ALLOW_PROTOCOL", "file"); + cmd.args(["update", "dep-pin"]).assert_success(); + assert_revisions([&first, &first, &first, &first]); + + cmd.forge_fuse(); + cmd.env("GIT_ALLOW_PROTOCOL", "file"); + cmd.args(["update", "dep-a"]).assert_success(); + assert_revisions([&second, &first, &first, &first]); + + // Unqualified branches must still update when another selection has an explicit ref. + cmd.forge_fuse(); + cmd.env("GIT_ALLOW_PROTOCOL", "file"); + cmd.args(["update", "dep-b", &format!("fixture/dep-pin@{second}")]).assert_success(); + assert_revisions([&second, &second, &first, &second]); + + cmd.forge_fuse(); + cmd.env("GIT_ALLOW_PROTOCOL", "file"); + cmd.arg("update").assert_success(); + assert_revisions([&second, &second, &second, &second]); +} + +#[forgetest] +fn can_override_tag_in_update(prj: _, cmd: _) { cmd.git_init(); // Installs oz at release tag @@ -291,11 +989,13 @@ forgetest!(can_override_tag_in_update, |prj, cmd| { assert_ne!(oz_init_lock, oz_update_lock); assert_eq!(oz_update_lock.name(), "v5.1.0"); + assert_eq!(submodules_update.0[0].rev(), oz_update_lock.rev()); assert_eq!(solady_init_lock, solady_update_lock); -}); +} // Ref: https://github.com/foundry-rs/foundry/pull/9522#pullrequestreview-2494431518 -forgetest!(should_not_update_tagged_deps, |prj, cmd| { +#[forgetest] +fn should_not_update_tagged_deps(prj: _, cmd: _) { cmd.git_init(); // Installs oz at release tag @@ -324,9 +1024,10 @@ forgetest!(should_not_update_tagged_deps, |prj, cmd| { let halmos_path = prj.paths().libraries[0].join("openzeppelin-contracts/lib/halmos-cheatcodes"); assert!(!halmos_path.exists()); -}); +} -forgetest!(can_remove_dep_from_foundry_lock, |prj, cmd| { +#[forgetest] +fn can_remove_dep_from_foundry_lock(prj: _, cmd: _) { cmd.git_init(); cmd.forge_fuse() @@ -341,117 +1042,111 @@ forgetest!(can_remove_dep_from_foundry_lock, |prj, cmd| { lock.read().unwrap(); assert!(lock.get(&PathBuf::from("lib/openzeppelin-contracts")).is_none()); -}); +} -forgetest!( - #[cfg_attr(windows, ignore = "weird git fail")] - can_sync_foundry_lock, - |prj, cmd| { - cmd.git_init(); +#[forgetest] +#[cfg_attr(windows, ignore = "weird git fail")] +fn can_sync_foundry_lock(prj: _, cmd: _) { + cmd.git_init(); - cmd.forge_fuse().args(["install", "foundry-rs/forge-std@master"]).assert_success(); + cmd.forge_fuse().args(["install", "foundry-rs/forge-std@master"]).assert_success(); - cmd.forge_fuse().args(["install", "vectorized/solady"]).assert_success(); + cmd.forge_fuse().args(["install", "vectorized/solady"]).assert_success(); - fs::remove_file(prj.root().join("foundry.lock")).unwrap(); + fs::remove_file(prj.root().join("foundry.lock")).unwrap(); - // sync submodules and write foundry.lock - cmd.forge_fuse().arg("install").assert_success(); + // sync submodules and write foundry.lock + cmd.forge_fuse().arg("install").assert_success(); - let mut lock = forge::Lockfile::new(prj.root()); - lock.read().unwrap(); + let mut lock = forge::Lockfile::new(prj.root()); + lock.read().unwrap(); - assert!(matches!( - lock.get(&PathBuf::from("lib/forge-std")).unwrap(), - &DepIdentifier::Branch { .. } - )); - assert!(matches!( - lock.get(&PathBuf::from("lib/solady")).unwrap(), - &DepIdentifier::Rev { .. } - )); - } -); + assert!(matches!( + lock.get(&PathBuf::from("lib/forge-std")).unwrap(), + &DepIdentifier::Branch { .. } + )); + assert!(matches!(lock.get(&PathBuf::from("lib/solady")).unwrap(), &DepIdentifier::Rev { .. })); +} // Tests that forge update doesn't break a working dependency by recursively updating nested // dependencies -forgetest!( - #[cfg_attr(windows, ignore = "weird git fail")] - can_update_library_with_outdated_nested_dependency, - |prj, cmd| { - cmd.git_init(); +#[forgetest] +#[cfg_attr(windows, ignore = "weird git fail")] +fn can_update_library_with_outdated_nested_dependency(prj: _, cmd: _) { + cmd.git_init(); - let libs = prj.root().join("lib"); - let git_mod = prj.root().join(".git/modules/lib"); - let git_mod_file = prj.root().join(".gitmodules"); + let libs = prj.root().join("lib"); + let git_mod = prj.root().join(".git/modules/lib"); + let git_mod_file = prj.root().join(".gitmodules"); - // get paths to check inside install fn - let package = libs.join("forge-5980-test"); - let package_mod = git_mod.join("forge-5980-test"); + // get paths to check inside install fn + let package = libs.join("forge-5980-test"); + let package_mod = git_mod.join("forge-5980-test"); - // install main dependency - cmd.forge_fuse() - .args(["install", "evalir/forge-5980-test"]) - .assert_success() - .stdout_eq(str![[r#" -Installing forge-5980-test in [..] (url: Some("https://github.com/evalir/forge-5980-test"), tag: None) + // install main dependency + cmd.forge_fuse() + .args(["install", "evalir/forge-5980-test"]) + .assert_success() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Installing forge-5980-test in [..] (url: https://github.com/evalir/forge-5980-test, tag: None) +... Installed forge-5980-test "#]]); - // assert paths exist - assert!(package.exists()); - assert!(package_mod.exists()); - - let submods = read_string(git_mod_file); - assert!(submods.contains("https://github.com/evalir/forge-5980-test")); - - // try to update the top-level dependency; there should be no update for this dependency, - // but its sub-dependency has upstream (breaking) changes; forge should not attempt to - // update the sub-dependency - cmd.forge_fuse().args(["update", "lib/forge-5980-test"]).assert_empty_stdout(); - - // add explicit remappings for test file - let config = Config { - remappings: vec![ - Remapping::from_str("forge-5980-test/=lib/forge-5980-test/src/").unwrap().into(), - // explicit remapping for sub-dependency seems necessary for some reason - Remapping::from_str( - "forge-5980-test-dep/=lib/forge-5980-test/lib/forge-5980-test-dep/src/", - ) - .unwrap() - .into(), - ], - ..Default::default() - }; - prj.write_config(config); + // assert paths exist + assert!(package.exists()); + assert!(package_mod.exists()); + + let submods = read_string(git_mod_file); + assert!(submods.contains("https://github.com/evalir/forge-5980-test")); + + // try to update the top-level dependency; there should be no update for this dependency, + // but its sub-dependency has upstream (breaking) changes; forge should not attempt to + // update the sub-dependency + cmd.forge_fuse().args(["update", "lib/forge-5980-test"]).assert_empty_stdout(); + + // add explicit remappings for test file + let config = Config { + remappings: vec![ + Remapping::from_str("forge-5980-test/=lib/forge-5980-test/src/").unwrap().into(), + // explicit remapping for sub-dependency seems necessary for some reason + Remapping::from_str( + "forge-5980-test-dep/=lib/forge-5980-test/lib/forge-5980-test-dep/src/", + ) + .unwrap() + .into(), + ], + ..Default::default() + }; + prj.write_config(config); - // create test file that uses the top-level dependency; if the sub-dependency is updated, - // compilation will fail - prj.add_source( - "CounterCopy", - r#" + // create test file that uses the top-level dependency; if the sub-dependency is updated, + // compilation will fail + prj.add_source( + "CounterCopy", + r#" import "forge-5980-test/Counter.sol"; contract CounterCopy is Counter { } "#, - ) - .unwrap(); + ); - // build and check output - cmd.forge_fuse().arg("build").assert_success().stdout_eq(str![[r#" + // build and check output + cmd.forge_fuse().arg("build").assert_success().stdout_eq(str![[r#" [COMPILING_FILES] with [SOLC_VERSION] [SOLC_VERSION] [ELAPSED] Compiler run successful! "#]]); - } -); +} #[tokio::test] async fn uni_v4_core_sync_foundry_lock() { let (prj, mut cmd) = ExtTester::new("Uniswap", "v4-core", "e50237c43811bd9b526eff40f26772152a42daba") - .setup_forge_prj(); + .setup_forge_prj(true); assert!(!prj.root().join(FOUNDRY_LOCK).exists()); @@ -505,7 +1200,7 @@ async fn oz_contracts_sync_foundry_lock() { "openzeppelin-contracts", "840c974028316f3c8172c1b8e5ed67ad95e255ca", ) - .setup_forge_prj(); + .setup_forge_prj(true); assert!(!prj.root().join(FOUNDRY_LOCK).exists()); @@ -525,7 +1220,7 @@ async fn oz_contracts_sync_foundry_lock() { cmd.arg("install").assert_success(); let forge_std = lockfile_get(prj.root(), &PathBuf::from("lib/forge-std")).unwrap(); - assert!(matches!(forge_std, DepIdentifier::Branch { .. })); + assert!(forge_std.is_branch()); assert_eq!(forge_std.rev(), submod_forge_std.rev()); assert_eq!(forge_std.name(), "v1"); let erc4626_tests = lockfile_get(prj.root(), &PathBuf::from("lib/erc4626-tests")).unwrap(); @@ -543,7 +1238,7 @@ async fn oz_contracts_sync_foundry_lock() { cmd.forge_fuse().arg("update").assert_success(); let forge_std = lockfile_get(prj.root(), &PathBuf::from("lib/forge-std")).unwrap(); - assert!(matches!(forge_std, DepIdentifier::Branch { .. })); + assert!(forge_std.is_branch()); // assert_eq!(forge_std.rev(), submod_forge_std.rev()); // This can fail, as forge-std will get // updated to the latest commit on master. assert_eq!(forge_std.name(), "v1"); // But it stays locked on the same master @@ -562,7 +1257,7 @@ async fn correctly_sync_dep_with_multiple_version() { "sync-lockfile-multi-version-dep", "1ca47e73a168e54f8f7761862dbd0c603856c5c8", ) - .setup_forge_prj(); + .setup_forge_prj(true); assert!(!prj.root().join(FOUNDRY_LOCK).exists()); @@ -590,3 +1285,339 @@ async fn correctly_sync_dep_with_multiple_version() { assert!(matches!(solday_v_245, DepIdentifier::Rev { .. })); assert_eq!(solday_v_245.rev(), submod_solday_v_245.rev()); } + +// Regression test: `forge install --no-git` should clean up nested submodule contents +// when installing a tag that does not use submodules for its dependencies. +// https://github.com/foundry-rs/foundry/issues/13688 +#[forgetest] +fn flaky_install_no_git_cleans_nested_submodules(prj: _, cmd: _) { + cmd.git_init(); + + // Install openzeppelin-contracts-upgradeable at v4.7.3 with --no-git. + // The default branch has submodules in lib/ (e.g. openzeppelin-contracts, erc4626-tests), + // but v4.7.3 does not use submodules for dependencies. + cmd.forge_fuse() + .args(["install", "--no-git", "OpenZeppelin/openzeppelin-contracts-upgradeable@v4.7.3"]) + .assert_success(); + + let dep_dir = prj.root().join("lib").join("openzeppelin-contracts-upgradeable"); + assert!(dep_dir.exists(), "dependency should be installed"); + + // The nested lib/ directory should either not exist or be empty — v4.7.3 does not use + // submodules so there should be no leftover submodule contents from the default branch. + let nested_lib = dep_dir.join("lib"); + if nested_lib.exists() { + let entries: Vec<_> = fs::read_dir(&nested_lib).unwrap().collect(); + assert!( + entries.is_empty(), + "nested lib/ should be empty after --no-git install at v4.7.3, found: {entries:?}" + ); + } + + // There should be no .git file or directory anywhere under the installed dependency. + fn assert_no_git(dir: &Path) { + for entry in fs::read_dir(dir).unwrap() { + let entry = entry.unwrap(); + let path = entry.path(); + assert!( + path.file_name() != Some(".git".as_ref()), + "found leftover .git at {}", + path.display() + ); + if path.is_dir() { + assert_no_git(&path); + } + } + } + assert_no_git(&dep_dir); +} + +#[forgetest_init] +fn sync_on_forge_update(prj: _, cmd: _) { + let git = Git::new(prj.root()); + + let submodules = git.submodules().unwrap(); + assert!(submodules.0.iter().any(|s| s.rev() == FORGE_STD_REVISION)); + + let mut lockfile = Lockfile::new(prj.root()); + lockfile.read().unwrap(); + + let forge_std = lockfile.get(&PathBuf::from("lib/forge-std")).unwrap(); + assert!(forge_std.rev() == FORGE_STD_REVISION); + + // cd into the forge-std submodule + let forge_std_path = prj.root().join("lib/forge-std"); + let git = Git::new(&forge_std_path); + + // Ensure we're on the release tag first (known starting point) + git.checkout(false, forge_std.name()).unwrap(); + assert_eq!(git.head().unwrap(), forge_std.rev(), "Forge std should be at the release tag"); + + // Make sure origin/master is up to date, then resolve its commit hash deterministically. + git.fetch(false, "origin", Some("master")).unwrap(); + let origin_master_head = git.get_rev("refs/remotes/origin/master", &forge_std_path).unwrap(); + + // Run update and assert the output matches the dynamically resolved hash. + let expected_output = format!( + "Updated dep at 'lib/forge-std', (from: tag={}@{}, to: branch=master@{})\n", + forge_std.name(), + forge_std.rev(), + origin_master_head + ); + + cmd.forge_fuse() + .args(["update", "foundry-rs/forge-std@master"]) + .assert_success() + .stdout_eq(str![""]) + .stderr_eq(expected_output); + + let git = Git::new(&forge_std_path); + assert_eq!( + git.head().unwrap(), + origin_master_head, + "Submodule HEAD should match resolved origin/master after update" + ); + + let root_git = Git::new(prj.root()); + let submodules_after = root_git.submodules().unwrap(); + let forge_sm = submodules_after + .0 + .iter() + .find(|s| s.path().as_path() == Path::new("lib/forge-std")) + .expect("forge-std submodule should exist"); + assert_eq!( + forge_sm.rev(), + origin_master_head, + "Root submodule status should match resolved origin/master after update" + ); + + let mut lockfile = Lockfile::new(prj.root()); + lockfile.read().unwrap(); + let forge_std_after = lockfile.get(&PathBuf::from("lib/forge-std")).unwrap(); + assert_eq!( + forge_std_after.rev(), + origin_master_head, + "Lockfile rev should match resolved origin/master after update" + ); +} + +// Checks that `--no-commit` is accepted as a noop backwards-compatibility flag +#[forgetest_init] +fn can_install_with_no_commit(cmd: _) { + cmd.args(["install", "--no-commit"]).assert_success(); +} + +#[forgetest] +fn install_no_git_cleans_failed_recursive_clone_and_retries(prj: _, cmd: _) { + let repositories = tempfile::tempdir().unwrap(); + let parent = repositories.path().join("parent"); + let child = repositories.path().join("child"); + for path in [&parent, &child] { + init_local_install_source(path); + } + let output = Command::new("git") + .current_dir(&parent) + .args(["-c", "protocol.file.allow=always", "submodule", "add", "--"]) + .arg(&child) + .arg("lib/child") + .output() + .unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + Git::new(&parent).commit("add child").unwrap(); + + let unavailable_child = repositories.path().join("unavailable-child"); + fs::rename(&child, &unavailable_child).unwrap(); + let sibling = prj.root().join("lib/existing/source.txt"); + fs::create_dir_all(sibling.parent().unwrap()).unwrap(); + fs::write(&sibling, "existing dependency\n").unwrap(); + + configure_local_install(&mut cmd, &parent); + cmd.args(["install", "--no-git", "fixture/parent"]) + .assert_failure() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Installing parent in [..] (url: https://github.com/fixture/parent, tag: None) +Cloning into '[..]'... +... +Failed to clone 'lib/child' a second time, aborting +Error: git clone exited with code 1 + +"#]]); + + let installed = prj.root().join("lib/parent"); + assert!(!installed.exists(), "failed installation must remove the partial dependency"); + assert_eq!(fs::read_to_string(&sibling).unwrap(), "existing dependency\n"); + + fs::rename(&unavailable_child, &child).unwrap(); + cmd.assert_success(); + + for path in [&installed, &installed.join("lib/child")] { + assert_eq!( + fs::read_to_string(path.join("source.txt")).unwrap().replace("\r\n", "\n"), + "dependency source\n" + ); + assert!(!path.join(".git").exists(), "successful installation must remove Git artifacts"); + } + assert_eq!(fs::read_to_string(sibling).unwrap(), "existing dependency\n"); +} + +#[forgetest] +fn install_no_git_preserves_existing_targets(prj: _, cmd: _) { + let source = tempfile::tempdir().unwrap(); + init_local_install_source(source.path()); + let lib = prj.root().join("lib"); + fs::create_dir_all(&lib).unwrap(); + fs::create_dir(lib.join("empty")).unwrap(); + fs::create_dir(lib.join("nonempty")).unwrap(); + fs::write(lib.join("nonempty/source.txt"), "existing dependency\n").unwrap(); + fs::write(lib.join("file"), "existing file\n").unwrap(); + + for name in ["empty", "nonempty", "file"] { + configure_local_install(&mut cmd, source.path()); + cmd.args(["install", "--no-git", &format!("{name}=fixture/parent")]) + .assert_failure() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Installing parent in [..] (url: https://github.com/fixture/parent, tag: None) +Error: failed to create dir "[..]": [..] + +"#]]); + } + + assert_eq!(fs::read_dir(lib.join("empty")).unwrap().count(), 0); + assert_eq!(fs::read_dir(lib.join("nonempty")).unwrap().count(), 1); + assert_eq!( + fs::read_to_string(lib.join("nonempty/source.txt")).unwrap(), + "existing dependency\n" + ); + assert_eq!(fs::read_to_string(lib.join("file")).unwrap(), "existing file\n"); +} + +#[cfg(unix)] +#[forgetest] +fn install_no_git_preserves_existing_symlinks(prj: _, cmd: _) { + let source = tempfile::tempdir().unwrap(); + init_local_install_source(source.path()); + let targets = tempfile::tempdir().unwrap(); + fs::create_dir(targets.path().join("empty")).unwrap(); + fs::create_dir(targets.path().join("nonempty")).unwrap(); + fs::write(targets.path().join("nonempty/source.txt"), "existing dependency\n").unwrap(); + let lib = prj.root().join("lib"); + fs::create_dir_all(&lib).unwrap(); + + for name in ["empty", "nonempty", "dangling"] { + let target = targets.path().join(name); + let link = lib.join(name); + symlink(&target, &link).unwrap(); + configure_local_install(&mut cmd, source.path()); + cmd.args(["install", "--no-git", &format!("{name}=fixture/parent")]) + .assert_failure() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Installing parent in [..] (url: https://github.com/fixture/parent, tag: None) +Error: failed to create dir "[..]": [..] + +"#]]); + assert_eq!(fs::read_link(link).unwrap(), target); + } + + assert_eq!(fs::read_dir(targets.path().join("empty")).unwrap().count(), 0); + assert_eq!(fs::read_dir(targets.path().join("nonempty")).unwrap().count(), 1); + assert_eq!( + fs::read_to_string(targets.path().join("nonempty/source.txt")).unwrap(), + "existing dependency\n" + ); + assert!(!targets.path().join("dangling").exists()); +} + +#[forgetest] +fn install_no_git_cleans_failed_checkout_with_nested_alias(prj: _, cmd: _) { + let source = tempfile::tempdir().unwrap(); + init_local_install_source(source.path()); + configure_local_install(&mut cmd, source.path()); + cmd.args(["install", "--no-git", "nested/parent=fixture/parent@missing-tag"]) + .assert_failure() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Installing parent in [..] (url: https://github.com/fixture/parent, tag: missing-tag) +... +Error: Tag: "missing-tag" not found for repo "https://github.com/fixture/parent"! + +"#]]); + + let installed = prj.root().join("lib/nested/parent"); + assert!(!installed.exists(), "failed checkout must remove the dependency"); + + configure_local_install(&mut cmd, source.path()); + cmd.args(["install", "--no-git", "nested/parent=fixture/parent"]).assert_success(); + assert_eq!( + fs::read_to_string(installed.join("source.txt")).unwrap().replace("\r\n", "\n"), + "dependency source\n" + ); + assert!(!installed.join(".git").exists()); +} + +#[forgetest] +fn install_no_git_cleans_failed_initial_clone(prj: _, cmd: _) { + let source = tempfile::tempdir().unwrap(); + configure_local_install(&mut cmd, &source.path().join("missing")); + cmd.args(["install", "--no-git", "fixture/parent"]) + .assert_failure() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Installing parent in [..] (url: https://github.com/fixture/parent, tag: None) +Cloning into '[..]'... +... +Error: git clone exited with code 128 + +"#]]); + + assert!(!prj.root().join("lib/parent").exists()); +} + +#[forgetest] +fn install_fails_on_nested_soldeer_failure(prj: _, cmd: _) { + let source = tempfile::tempdir().unwrap(); + init_local_install_source(source.path()); + fs::write( + source.path().join("foundry.toml"), + r#"[dependencies] +bad = { version = "1.0.0", url = "http://127.0.0.1:1/bad.zip" } +"#, + ) + .unwrap(); + fs::write(source.path().join("soldeer.lock"), "version = 2\n\ndependencies = []\n").unwrap(); + let git = Git::new(source.path()); + git.add(["foundry.toml", "soldeer.lock"]).unwrap(); + git.commit("add soldeer").unwrap(); + + configure_local_install(&mut cmd, source.path()); + cmd.args(["install", "--no-git", "fixture/parent"]).assert_failure().stderr_eq(str![[r#" +... +Error: Failed to install soldeer dependencies for parent: Failed to run soldeer install: [..] +Run `forge soldeer install` in [..]/lib/parent to retry. + +"#]]); + + // The git dependency itself stays installed. + assert!(prj.root().join("lib/parent/source.txt").exists()); +} + +fn init_local_install_source(path: &Path) { + fs::create_dir_all(path).unwrap(); + let git = Git::new(path); + git.init().unwrap(); + fs::write(path.join("source.txt"), "dependency source\n").unwrap(); + git.add(["source.txt"]).unwrap(); + git.commit("initial").unwrap(); +} + +fn configure_local_install(cmd: &mut TestCommand, source: &Path) { + cmd.forge_fuse(); + cmd.env("LC_ALL", "C"); + cmd.env("GIT_ALLOW_PROTOCOL", "file"); + cmd.env("GIT_CONFIG_COUNT", "1"); + let source_url = Url::from_directory_path(source).unwrap(); + cmd.env("GIT_CONFIG_KEY_0", format!("url.{source_url}.insteadOf")); + cmd.env("GIT_CONFIG_VALUE_0", "https://github.com/fixture/parent"); +} diff --git a/crates/forge/tests/cli/json.rs b/crates/forge/tests/cli/json.rs new file mode 100644 index 0000000000000..3767f733bdc73 --- /dev/null +++ b/crates/forge/tests/cli/json.rs @@ -0,0 +1,67 @@ +// tests enhanced `vm.parseJson` and `vm.serializeJson` cheatcodes, which are not constrained to +// alphabetical ordering of struct keys, but rather respect the Solidity struct definition. +#[forgetest_init] +fn test_parse_json(prj: _, cmd: _) { + prj.add_test( + "JsonCheats", + r#" +import {Test} from "forge-std/Test.sol"; + +// Definition order: color, sweetness, sourness +// Alphabetical order: color, sourness, sweetness +struct Apple { + string color; + uint8 sweetness; + uint8 sourness; +} + +// Definition order: name, apples +// Alphabetical order: apples, name +struct FruitStall { + string name; + Apple[] apples; +} + +contract SimpleJsonCheatsTest is Test { + function testJsonParseAndSerialize() public { + // Initial JSON has keys in a custom order, different from definition and alphabetical. + string memory originalJson = + '{"name":"Fresh Fruit","apples":[{"sweetness":7,"sourness":3,"color":"Red"},{"sweetness":5,"sourness":5,"color":"Green"}]}'; + + // Parse the original JSON. The parser should correctly handle the unordered keys. + bytes memory decoded = vm.parseJson(originalJson); + FruitStall memory originalType = abi.decode(decoded, (FruitStall)); + + // Assert initial parsing is correct + assertEq(originalType.name, "Fresh Fruit"); + assertEq(originalType.apples[0].color, "Red"); + assertEq(originalType.apples[0].sweetness, 7); + assertEq(originalType.apples[1].sourness, 5); + + // Serialize the struct back to JSON. `vm.serializeJson` should respect the order for all keys. + string memory serializedJson = vm.serializeJsonType( + "FruitStall(Apple[] apples,string name)Apple(string color,uint8 sourness,uint8 sweetness)", + abi.encode(originalType) + ); + + // The expected JSON should have keys ordered according to the struct definitions. + string memory expectedJson = + '{"name":"Fresh Fruit","apples":[{"color":"Red","sweetness":7,"sourness":3},{"color":"Green","sweetness":5,"sourness":5}]}'; + assertEq(serializedJson, expectedJson); + + // Parse the newly serialized JSON to complete the cycle. + bytes memory redecoded = vm.parseJson(serializedJson); + FruitStall memory finalType = abi.decode(redecoded, (FruitStall)); + + // Assert that the struct from the full cycle is identical to the original parsed struct. + assertEq(keccak256(abi.encode(finalType)), keccak256(abi.encode(originalType))); + } +} +"#, + ); + + // Directly run the test. No `bind-json` or type schemas are needed. + cmd.forge_fuse().args(["test"]).assert_success(); + // Should still work when the project is not compiled. + cmd.forge_fuse().args(["test"]).assert_success(); +} diff --git a/crates/forge/tests/cli/lint.rs b/crates/forge/tests/cli/lint.rs index f11450bcd63f1..e3781a4d8a299 100644 --- a/crates/forge/tests/cli/lint.rs +++ b/crates/forge/tests/cli/lint.rs @@ -1,5 +1,12 @@ -use forge_lint::{linter::Lint, sol::med::REGISTERED_LINTS}; -use foundry_config::{LintSeverity, LinterConfig}; +use forge_lint::{ + linter::Lint, + sol::{self, SolLint}, +}; +use foundry_config::{ + DenyLevel, LintSeverity, LinterConfig, SolidityErrorCode, lint::LintSpecificConfig, +}; + +mod geiger; const CONTRACT: &str = r#" // SPDX-License-Identifier: MIT @@ -11,9 +18,9 @@ uint256 constant screaming_snake_case_info = 0; contract ContractWithLints { uint256 VARIABLE_MIXED_CASE_INFO; - function incorrectShiftHigh() public { - uint256 localValue = 50; - uint256 result = 8 >> localValue; + function incorrectShiftHigh() public pure { + uint256 result = 8; + assembly { result := shr(result, 8) } } function divideBeforeMultiplyMedium() public { (1 / 2) * 3; @@ -21,26 +28,202 @@ contract ContractWithLints { function unoptimizedHashGas(uint256 a, uint256 b) public view { keccak256(abi.encodePacked(a, b)); } - function FUNCTION_MIXED_CASE_INFO() public {} + function FUNCTION_MIXED_CASE_INFO() public { VARIABLE_MIXED_CASE_INFO = 1; } } - "#; +"#; const OTHER_CONTRACT: &str = r#" - // SPDX-License-Identifier: MIT - pragma solidity ^0.8.0; +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +// forge-lint: disable-next-line +import { ContractWithLints } from "./ContractWithLints.sol"; + +contract OtherContractWithLints { + function functionMIXEDCaseInfo() public { uint256 x = 1; } +} +"#; + +const ONLY_IMPORTS: &str = r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +// forge-lint: disable-next-line +import { ContractWithLints } from "./ContractWithLints.sol"; + +import { _PascalCaseInfo } from "./ContractWithLints.sol"; +import "./ContractWithLints.sol"; + +contract Dummy { + bool foo; +} +"#; + +const DEFAULT_INFO_LINTS_IMPORT: &str = r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract UnusedSymbol {} +"#; + +const DEFAULT_INFO_LINTS: &str = r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import { UnusedSymbol } from "./DefaultInfoLintsImport.sol"; + +contract DefaultInfoLints { + function BAD_CASE() public { uint256 x = 1; } +} +"#; + +const COUNTER_A: &str = r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract CounterA { + uint256 public CounterA_Fail_Lint; +} +"#; + +const COUNTER_B: &str = r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract CounterB { + uint256 public CounterB_Fail_Lint; +} +"#; + +const COUNTER_WITH_CONST: &str = r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +uint256 constant MAX = 1000000; + +contract Counter { + uint256 public number; + + function setNumber(uint256 newNumber) public { + number = newNumber; + } + + function increment() public { + number++; + } +} +"#; + +const COUNTER_TEST_WITH_CONST: &str = r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import { Counter, MAX } from "../src/Counter.sol"; + +contract CounterTest { + Counter public counter; + + function setUp() public { + counter = new Counter(); + } + + function testFuzz_setNumber(uint256[MAX] calldata numbers) public { + for (uint256 i = 0; i < numbers.length; ++i) { + counter.setNumber(numbers[i]); + } + } +} +"#; + +const MULTI_CONTRACT_FILE: &str = r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +interface IToken { + function transfer(address to, uint256 amount) external returns (bool); +} + +library MathLib { + function add(uint256 a, uint256 b) internal pure returns (uint256) { + return a + b; + } +} + +contract FirstContract { + uint256 public value; + + function setValue(uint256 _value) public { + value = _value; + } +} + +abstract contract BaseContract { + function baseFunction() public virtual; +} - // forge-lint: disable-next-line - import { ContractWithLints } from "./ContractWithLints.sol"; +interface IERC20 { + function balanceOf(address account) external view returns (uint256); +} + +contract SecondContract { + address public owner; + + constructor() { + owner = msg.sender; + } +} - contract OtherContractWithLints { - uint256 VARIABLE_MIXED_CASE_INFO; +library StringLib { + function toUpperCase(string memory str) internal pure returns (string memory) { + return str; } - "#; +} + +abstract contract AbstractStorage { + mapping(address => uint256) internal balances; + + function getBalance(address account) public view virtual returns (uint256); +} + +interface Token { + function transfer(address to, uint256 amount) external returns (bool); +} +"#; + +const SOLO_INTERFACES: &str = r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +interface ERC20 { + function balanceOf(address account) external view returns (uint256); +} + +interface IToken { + function transfer(address to, uint256 amount) external returns (bool); +} +"#; -forgetest!(can_use_config, |prj, cmd| { - prj.wipe_contracts(); - prj.add_source("ContractWithLints", CONTRACT).unwrap(); - prj.add_source("OtherContractWithLints", OTHER_CONTRACT).unwrap(); +#[forgetest] +fn lint_does_not_write_artifacts(prj: _, cmd: _) { + use std::fs; + + prj.add_source("LintTarget", "contract LintTarget {}"); + let artifact = prj.root().join("out/LintTarget.sol/LintTarget.json"); + + cmd.arg("lint").assert_success(); + assert!(!artifact.exists()); + + fs::create_dir_all(artifact.parent().unwrap()).unwrap(); + fs::write(&artifact, b"sentinel").unwrap(); + + cmd.forge_fuse().arg("lint").assert_success(); + assert_eq!(fs::read(artifact).unwrap(), b"sentinel"); +} + +#[forgetest] +fn can_use_config(prj: _, cmd: _) { + prj.add_source("ContractWithLints", CONTRACT); + prj.add_source("OtherContractWithLints", OTHER_CONTRACT); // Check config for `severity` and `exclude` prj.update_config(|config| { @@ -49,25 +232,26 @@ forgetest!(can_use_config, |prj, cmd| { exclude_lints: vec!["incorrect-shift".into()], ignore: vec![], lint_on_build: true, + ..Default::default() }; }); cmd.arg("lint").assert_success().stderr_eq(str![[r#" -warning[divide-before-multiply]: multiplication should occur before division to avoid loss of precision - [FILE]:16:9 - | -16 | (1 / 2) * 3; - | ----------- - | - = help: https://book.getfoundry.sh/reference/forge/forge-lint#divide-before-multiply +warning[divide-before-multiply]: division before multiplication may lose precision + [FILE]:16:9 + │ +16 │ (1 / 2) * 3; + │ ━━━━━━━━━━━ + │ + ╰ help: https://getfoundry.sh/forge/linting/divide-before-multiply "#]]); -}); +} -forgetest!(can_use_config_ignore, |prj, cmd| { - prj.wipe_contracts(); - prj.add_source("ContractWithLints", CONTRACT).unwrap(); - prj.add_source("OtherContract", OTHER_CONTRACT).unwrap(); +#[forgetest] +fn can_use_config_ignore(prj: _, cmd: _) { + prj.add_source("ContractWithLints", CONTRACT); + prj.add_source("OtherContract", OTHER_CONTRACT); // Check config for `ignore` prj.update_config(|config| { @@ -76,16 +260,17 @@ forgetest!(can_use_config_ignore, |prj, cmd| { exclude_lints: vec![], ignore: vec!["src/ContractWithLints.sol".into()], lint_on_build: true, + ..Default::default() }; }); cmd.arg("lint").assert_success().stderr_eq(str![[r#" -note[mixed-case-variable]: mutable variables should use mixedCase - [FILE]:9:17 - | -9 | uint256 VARIABLE_MIXED_CASE_INFO; - | ------------------------ - | - = help: https://book.getfoundry.sh/reference/forge/forge-lint#mixed-case-variable +note[mixed-case-function]: function name is not `mixedCase` + [FILE]:9:14 + │ +9 │ function functionMIXEDCaseInfo() public { uint256 x = 1; } + │ ━━━━━━━━━━━━━━━━━━━━━ help: consider using: `functionMixedCaseInfo` + │ + ╰ help: https://getfoundry.sh/forge/linting/mixed-case-function "#]]); @@ -97,318 +282,2289 @@ note[mixed-case-variable]: mutable variables should use mixedCase exclude_lints: vec![], ignore: vec!["src/ContractWithLints.sol".into(), "src/OtherContract.sol".into()], lint_on_build: true, + ..Default::default() }; }); - cmd.arg("lint").assert_success().stderr_eq(str![[""]]); -}); + cmd.forge_fuse().arg("lint").assert_success().stderr_eq(str![[r#" +nothing to lint -forgetest!(can_override_config_severity, |prj, cmd| { - prj.wipe_contracts(); - prj.add_source("ContractWithLints", CONTRACT).unwrap(); - prj.add_source("OtherContractWithLints", OTHER_CONTRACT).unwrap(); +"#]]); - // Override severity + // Check config again, ignoring a directory via the documented `dir/**` glob form prj.update_config(|config| { config.lint = LinterConfig { - severity: vec![LintSeverity::High, LintSeverity::Med], + severity: vec![], exclude_lints: vec![], - ignore: vec!["src/ContractWithLints.sol".into()], + ignore: vec!["src/**".into()], lint_on_build: true, + ..Default::default() }; }); - cmd.arg("lint").args(["--severity", "info"]).assert_success().stderr_eq(str![[r#" -note[mixed-case-variable]: mutable variables should use mixedCase - [FILE]:9:17 - | -9 | uint256 VARIABLE_MIXED_CASE_INFO; - | ------------------------ - | - = help: https://book.getfoundry.sh/reference/forge/forge-lint#mixed-case-variable - + cmd.forge_fuse().arg("lint").assert_success().stderr_eq(str![[r#" +nothing to lint "#]]); -}); - -forgetest!(can_override_config_path, |prj, cmd| { - prj.wipe_contracts(); - prj.add_source("ContractWithLints", CONTRACT).unwrap(); - prj.add_source("OtherContractWithLints", OTHER_CONTRACT).unwrap(); - // Override excluded files + // Check config again, ignoring a bare directory path prj.update_config(|config| { config.lint = LinterConfig { - severity: vec![LintSeverity::High, LintSeverity::Med], - exclude_lints: vec!["incorrect-shift".into()], - ignore: vec!["src/ContractWithLints.sol".into()], + severity: vec![], + exclude_lints: vec![], + ignore: vec!["src".into()], lint_on_build: true, + ..Default::default() }; }); - cmd.arg("lint").arg("src/ContractWithLints.sol").assert_success().stderr_eq(str![[r#" -warning[divide-before-multiply]: multiplication should occur before division to avoid loss of precision - [FILE]:16:9 - | -16 | (1 / 2) * 3; - | ----------- - | - = help: https://book.getfoundry.sh/reference/forge/forge-lint#divide-before-multiply - + cmd.forge_fuse().arg("lint").assert_success().stderr_eq(str![[r#" +nothing to lint "#]]); -}); - -forgetest!(can_override_config_lint, |prj, cmd| { - prj.wipe_contracts(); - prj.add_source("ContractWithLints", CONTRACT).unwrap(); - prj.add_source("OtherContractWithLints", OTHER_CONTRACT).unwrap(); +} - // Override excluded lints - prj.update_config(|config| { - config.lint = LinterConfig { - severity: vec![LintSeverity::High, LintSeverity::Med], - exclude_lints: vec!["incorrect-shift".into()], - ignore: vec![], - lint_on_build: true, - }; - }); - cmd.arg("lint").args(["--only-lint", "incorrect-shift"]).assert_success().stderr_eq(str![[ +#[forgetest] +fn inline_config_suppresses_lint_in_inherited_source(prj: _, cmd: _) { + prj.add_source( + "Base", r#" -warning[incorrect-shift]: the order of args in a shift operation is incorrect - [FILE]:13:26 - | -13 | uint256 result = 8 >> localValue; - | --------------- - | - = help: https://book.getfoundry.sh/reference/forge/forge-lint#incorrect-shift +abstract contract Base { + uint256 public value; +} +"#, + ); + prj.add_source( + "Concrete", + r#" +import {Base} from "./Base.sol"; +contract Concrete is Base { + function readValue() external view returns (uint256) { + return value; + } +} +"#, + ); -"# - ]]); -}); + cmd.args(["lint", "--only-lint", "uninitialized-state", "-D", "warnings"]).assert_failure(); -forgetest!(build_runs_linter_by_default, |prj, cmd| { - prj.wipe_contracts(); - prj.add_source("ContractWithLints", CONTRACT).unwrap(); + prj.add_source( + "Base", + r#" +abstract contract Base { + // forge-lint: disable-next-line(uninitialized-state) + uint256 public value; +} +"#, + ); + cmd.forge_fuse() + .args(["lint", "--only-lint", "uninitialized-state", "-D", "warnings"]) + .assert_success() + .stderr_eq(""); +} - // Configure linter to show only medium severity lints - prj.update_config(|config| { - config.lint = LinterConfig { - severity: vec![LintSeverity::Med], - exclude_lints: vec!["incorrect-shift".into()], - ignore: vec![], - lint_on_build: true, - }; - }); +#[forgetest] +fn ignored_inherited_source_does_not_receive_lints(prj: _, cmd: _) { + prj.add_source( + "Base", + r#" +abstract contract Base { + uint256 public value; +} +"#, + ); + prj.add_source( + "Concrete", + r#" +import {Base} from "./Base.sol"; - // Run forge build and expect linting output before compilation - cmd.arg("build").assert_success().stderr_eq(str![[r#" -warning[divide-before-multiply]: multiplication should occur before division to avoid loss of precision - [FILE]:16:9 - | -16 | (1 / 2) * 3; - | ----------- - | - = help: https://book.getfoundry.sh/reference/forge/forge-lint#divide-before-multiply +contract Concrete is Base { + function readValue() external view returns (uint256) { + return value; + } +} +"#, + ); + prj.update_config(|config| config.lint.ignore = vec!["src/Base.sol".into()]); + cmd.args(["lint", "--only-lint", "uninitialized-state"]).assert_success().stderr_eq(""); +} -"#]]).stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful with warnings: -Warning (2072): Unused local variable. - [FILE]:13:9: - | -13 | uint256 result = 8 >> localValue; - | ^^^^^^^^^^^^^^ +#[forgetest] +fn span_owner_activates_lint_for_inherited_source(prj: _, cmd: _) { + prj.add_source( + "Base", + r#" +contract Base { + uint256 public value = 1; +} +"#, + ); + prj.add_test( + "Concrete", + r#" +import {Base} from "../src/Base.sol"; -Warning (6133): Statement has no effect. - [FILE]:16:9: - | -16 | (1 / 2) * 3; - | ^^^^^^^^^^^ +contract ConcreteTest is Base {} +"#, + ); -Warning (2018): Function state mutability can be restricted to pure - [FILE]:11:5: - | -11 | function incorrectShiftHigh() public { - | ^ (Relevant source part starts here and spans across multiple lines). + cmd.args(["lint", "--only-lint", "could-be-constant", "-D", "notes"]).assert_failure(); +} -Warning (2018): Function state mutability can be restricted to pure - [FILE]:15:5: - | -15 | function divideBeforeMultiplyMedium() public { - | ^ (Relevant source part starts here and spans across multiple lines). +#[forgetest] +fn default_lint_severity_excludes_info(prj: _, cmd: _) { + prj.add_source("DefaultInfoLintsImport", DEFAULT_INFO_LINTS_IMPORT); + prj.add_source("DefaultInfoLints", DEFAULT_INFO_LINTS); -Warning (2018): Function state mutability can be restricted to pure - [FILE]:18:5: - | -18 | function unoptimizedHashGas(uint256 a, uint256 b) public view { - | ^ (Relevant source part starts here and spans across multiple lines). + cmd.arg("lint").assert_success().stderr_eq(""); + cmd.forge_fuse().args(["lint", "--severity", "info"]).assert_success().stderr_eq(str![[r#" +note[mixed-case-function]: function name is not `mixedCase` + [FILE]:8:14 + │ +8 │ function BAD_CASE() public { uint256 x = 1; } + │ ━━━━━━━━ help: consider using: `badCase` + │ + ╰ help: https://getfoundry.sh/forge/linting/mixed-case-function + +note[unused-import]: unused import + [FILE]:5:10 + │ +5 │ import { UnusedSymbol } from "./DefaultInfoLintsImport.sol"; + │ ━━━━━━━━━━━━ + │ + ╰ help: https://getfoundry.sh/forge/linting/unused-import "#]]); -}); +} -forgetest!(build_respects_quiet_flag_for_linting, |prj, cmd| { - prj.wipe_contracts(); - prj.add_source("ContractWithLints", CONTRACT).unwrap(); +#[forgetest] +fn skip_test_and_script_lints(prj: _, cmd: _) { + let fixture = r#" +contract Initializer { + address bob = makeAddr("bob"); - // Configure linter to show medium severity lints - prj.update_config(|config| { - config.lint = LinterConfig { - severity: vec![LintSeverity::Med], - exclude_lints: vec!["incorrect-shift".into()], - ignore: vec![], - lint_on_build: true, - }; - }); + function makeAddr(string memory name) internal returns (address) { + return address(uint160(uint256(keccak256(bytes(name))))); + } - // Run forge build with --quiet flag - should not show linting output - cmd.arg("build").arg("--quiet").assert_success().stderr_eq(str![[""]]).stdout_eq(str![[""]]); -}); + function incorrectShift(uint256 value) public pure { + assembly { pop(shr(value, 1)) } + } +} +"#; + prj.add_test("Initializer", fixture); + prj.add_script("Initializer", fixture); -forgetest!(build_with_json_uses_json_linter_output, |prj, cmd| { - prj.wipe_contracts(); - prj.add_source("ContractWithLints", CONTRACT).unwrap(); + cmd.args(["build"]).assert_success().stderr_eq(""); + cmd.forge_fuse().arg("lint").assert_success().stderr_eq(""); + cmd.forge_fuse() + .args(["lint", "--severity", "high", "med", "low", "info", "gas", "code-size"]) + .assert_success() + .stderr_eq(""); + cmd.forge_fuse() + .args(["lint", "--only-lint", "function-init-state", "incorrect-shift"]) + .assert_success() + .stderr_eq(""); - // Configure linter to show medium severity lints + std::fs::rename(prj.root().join("test"), prj.root().join("checks")).unwrap(); + std::fs::rename(prj.root().join("script"), prj.root().join("deploy")).unwrap(); prj.update_config(|config| { - config.lint = LinterConfig { - severity: vec![LintSeverity::Med], - exclude_lints: vec!["incorrect-shift".into()], - ignore: vec![], - lint_on_build: true, - }; + config.test = "checks".into(); + config.script = "deploy".into(); }); + cmd.forge_fuse() + .args(["lint", "--only-lint", "function-init-state", "incorrect-shift"]) + .assert_success() + .stderr_eq(""); - // Run forge build with --json flag - should use JSON formatter for linting - let output = cmd.arg("build").arg("--json").assert_success(); + prj.add_source("Initializer", fixture); + cmd.forge_fuse() + .args(["lint", "--only-lint", "function-init-state"]) + .assert_success() + .stderr_eq(str![[r#" +note[function-init-state]: state variable initializer depends on a non-pure function or another state variable + [FILE]:5:5 + │ +5 │ address bob = makeAddr("bob"); + │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + │ + ╰ help: https://getfoundry.sh/forge/linting/function-init-state - // Should contain JSON linting output - let stderr = String::from_utf8_lossy(&output.get_output().stderr); - assert!(stderr.contains("\"code\"")); - assert!(stderr.contains("divide-before-multiply")); - // Should also contain JSON compilation output - let stdout = String::from_utf8_lossy(&output.get_output().stdout); - assert!(stdout.contains("\"errors\"")); - assert!(stdout.contains("\"sources\"")); -}); +"#]]); +} -forgetest!(build_respects_lint_on_build_false, |prj, cmd| { - prj.wipe_contracts(); - prj.add_source("ContractWithLints", CONTRACT).unwrap(); +#[forgetest] +fn unsafe_cheatcode_lints_tests_and_scripts(prj: _, cmd: _) { + let fixture = r#" +interface Vm { + function readFile(string calldata path) external returns (string memory); +} + +contract Unsafe { + function read(Vm vm) external { + vm.readFile("input.txt"); + } +} +"#; + let test = prj.add_test("Unsafe", fixture); + let script = prj.add_script("Unsafe", fixture); + + cmd.arg("lint").assert_success().stderr_eq(""); + for path in [test, script] { + cmd.forge_fuse() + .arg("lint") + .arg(path) + .args(["--only-lint", "unsafe-cheatcode"]) + .assert_success() + .stderr_eq(str![[r#" +note[unsafe-cheatcode]: usage of unsafe cheatcodes that can perform dangerous operations + [FILE]:10:12 + │ +10 │ vm.readFile("input.txt"); + │ ━━━━━━━━ + │ + ╰ help: https://getfoundry.sh/forge/linting/unsafe-cheatcode + + +"#]]); + } - // Configure linter with medium severity lints but disable lint_on_build prj.update_config(|config| { - config.lint = LinterConfig { - severity: vec![LintSeverity::Med], - exclude_lints: vec!["incorrect-shift".into()], - ignore: vec![], - lint_on_build: false, - }; + config.lint.severity = vec![LintSeverity::Info]; + config.lint.exclude_lints = vec!["unsafe-cheatcode".into()]; }); + cmd.forge_fuse().arg("lint").assert_success().stderr_eq(""); +} - // Run forge build - should NOT show linting output because lint_on_build is false - cmd.arg("build").assert_success().stderr_eq(str![[""]]).stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful with warnings: -Warning (2072): Unused local variable. - [FILE]:13:9: - | -13 | uint256 result = 8 >> localValue; - | ^^^^^^^^^^^^^^ +const BLOCK_ENVIRONMENT_CAPTURE: &str = r#" +interface Vm { + function roll(uint256 height) external; + function warp(uint256 time) external; +} +contract EnvironmentCapture { + Vm constant vm = Vm(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D); + function capture() public returns (uint256, uint256) { + uint256 height = block.number; + uint256 time = block.timestamp; + vm.roll(200); + vm.warp(200); + return (height, time); + } +} +"#; -Warning (6133): Statement has no effect. - [FILE]:16:9: - | -16 | (1 / 2) * 3; - | ^^^^^^^^^^^ +#[forgetest] +fn block_environment_lints_tests_and_scripts(prj: _, cmd: _) { + prj.add_test("EnvironmentCapture.t.sol", BLOCK_ENVIRONMENT_CAPTURE); + let expected = str![[r#" +warning[environment-read-across-mutation]: `block.number` may be reused across `vm.roll` + [FILE]:11:26 + │ +11 │ uint256 height = block.number; + │ ━━━━━━━━━━━━ +12 │ uint256 time = block.timestamp; +13 │ vm.roll(200); + │ ──────────── `vm.roll` changes this environment here + │ + ├ help: capture it with `vm.getBlockNumber()` instead + ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation -Warning (2018): Function state mutability can be restricted to pure - [FILE]:11:5: - | -11 | function incorrectShiftHigh() public { - | ^ (Relevant source part starts here and spans across multiple lines). +warning[environment-read-across-mutation]: `block.timestamp` may be reused across `vm.warp` + [FILE]:12:24 + │ +12 │ uint256 time = block.timestamp; + │ ━━━━━━━━━━━━━━━ +13 │ vm.roll(200); +14 │ vm.warp(200); + │ ──────────── `vm.warp` changes this environment here + │ + ├ help: capture it with `vm.getBlockTimestamp()` instead + ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation -Warning (2018): Function state mutability can be restricted to pure - [FILE]:15:5: - | -15 | function divideBeforeMultiplyMedium() public { - | ^ (Relevant source part starts here and spans across multiple lines). -Warning (2018): Function state mutability can be restricted to pure +"#]]; + cmd.arg("lint").assert_success().stderr_eq(expected.clone()); + cmd.forge_fuse().args(["lint", "--deny", "warnings"]).assert_failure(); + + let script = prj.add_script("EnvironmentCapture.s.sol", BLOCK_ENVIRONMENT_CAPTURE); + cmd.forge_fuse().arg("lint").arg(script).assert_success().stderr_eq(expected); + + prj.update_config(|config| { + config.lint.exclude_lints = vec!["environment-read-across-mutation".into()]; + }); + cmd.forge_fuse().arg("lint").assert_success().stderr_eq(""); + prj.update_config(|config| { + config.lint.exclude_lints.clear(); + config.lint.severity = vec![LintSeverity::Info]; + }); + cmd.forge_fuse().arg("lint").assert_success().stderr_eq(""); +} + +#[forgetest] +fn block_environment_build_is_bytecode_neutral(prj: _, cmd: _) { + prj.add_test("EnvironmentCapture.t.sol", BLOCK_ENVIRONMENT_CAPTURE); + prj.update_config(|config| { + config.optimizer = Some(true); + config.via_ir = true; + }); + cmd.args(["build", "--no-lint"]).assert_success().stderr_eq(""); + let artifact = prj.artifacts().join("EnvironmentCapture.t.sol/EnvironmentCapture.json"); + let without_lints = std::fs::read(&artifact).unwrap(); + + cmd.forge_fuse().args(["build", "--force"]).assert_success().stderr_eq(str![[r#" +warning[environment-read-across-mutation]: `block.number` may be reused across `vm.roll` + [FILE]:11:26 + │ +11 │ uint256 height = block.number; + │ ━━━━━━━━━━━━ +12 │ uint256 time = block.timestamp; +13 │ vm.roll(200); + │ ──────────── `vm.roll` changes this environment here + │ + ├ help: capture it with `vm.getBlockNumber()` instead + ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation + +warning[environment-read-across-mutation]: `block.timestamp` may be reused across `vm.warp` + [FILE]:12:24 + │ +12 │ uint256 time = block.timestamp; + │ ━━━━━━━━━━━━━━━ +13 │ vm.roll(200); +14 │ vm.warp(200); + │ ──────────── `vm.warp` changes this environment here + │ + ├ help: capture it with `vm.getBlockTimestamp()` instead + ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation + + +"#]]); + assert_eq!( + without_lints, + std::fs::read(artifact).unwrap(), + "linting changed the build artifact" + ); +} + +#[forgetest] +fn block_environment_mutation_secondary_span(prj: _, cmd: _) { + let capture = r#" +abstract contract Capture { + function readTime() internal view returns (uint256) { + return block.timestamp; + } +} +"#; + let clock = r#" +interface ClockVm { function warp(uint256 time) external; } +abstract contract Clock { + ClockVm constant clock = ClockVm(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D); + function advance() internal { + // Suppressions belong on the original read, not this secondary span. + // forge-lint: disable-next-line(environment-read-across-mutation) + clock.warp(200); + } +} +"#; + prj.add_source("Capture", capture); + prj.add_source("Clock", clock); + prj.add_test( + "SecondarySpan.t.sol", + r#" +import {Capture} from "../src/Capture.sol"; +import {Clock} from "../src/Clock.sol"; +contract SecondarySpan is Capture, Clock { + function capture() public returns (uint256) { + uint256 saved = readTime(); + advance(); + return saved; + } +} +"#, + ); + let output = cmd + .args(["lint", "--only-lint", "environment-read-across-mutation", "--json"]) + .assert_success(); + let diagnostics = serde_json::Deserializer::from_slice(&output.get_output().stdout) + .into_iter::() + .collect::, _>>() + .unwrap(); + assert_eq!(diagnostics.len(), 1); + let diagnostic = &diagnostics[0]; + assert_eq!(diagnostic["message"], "`block.timestamp` may be reused across `vm.warp`"); + assert_eq!(diagnostic["children"][0]["level"], "help"); + assert_eq!( + diagnostic["children"][0]["message"], + "capture it with `vm.getBlockTimestamp()` instead" + ); + let spans = diagnostic["spans"].as_array().unwrap(); + assert_eq!(spans.len(), 2); + for (primary, file, snippet) in + [(true, "Capture.sol", "block.timestamp"), (false, "Clock.sol", "clock.warp(200)")] + { + let span = spans.iter().find(|span| span["is_primary"] == primary).unwrap(); + assert!(span["file_name"].as_str().unwrap().ends_with(file)); + let start = span["byte_start"].as_u64().unwrap() as usize; + let end = span["byte_end"].as_u64().unwrap() as usize; + let source = std::fs::read_to_string(prj.root().join("src").join(file)).unwrap(); + assert_eq!(&source[start..end], snippet); + if !primary { + assert_eq!(span["label"], "`vm.warp` changes this environment here"); + } + } + + prj.add_source("Capture", &capture.replace("return block.timestamp;", + "// forge-lint: disable-next-line(environment-read-across-mutation)\n return block.timestamp;")); + cmd.forge_fuse() + .args(["lint", "--only-lint", "environment-read-across-mutation"]) + .assert_success() + .stderr_eq(""); + prj.add_source("Capture", capture); + prj.update_config(|config| config.lint.ignore = vec!["src/Capture.sol".into()]); + cmd.forge_fuse() + .args(["lint", "--only-lint", "environment-read-across-mutation"]) + .assert_success() + .stderr_eq(""); +} + +#[forgetest] +fn block_environment_getters_materialize_captures(prj: _, cmd: _) { + prj.add_test( + "EnvironmentGetters.t.sol", + r#" +interface Vm { + function roll(uint256 height) external; + function warp(uint256 time) external; + function getBlockNumber() external view returns (uint256); + function getBlockTimestamp() external view returns (uint256); +} +contract EnvironmentGetters { + Vm constant vm = Vm(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D); + function testCapture() public { + vm.roll(100); + vm.warp(100); + uint256 height = vm.getBlockNumber(); + uint256 time = vm.getBlockTimestamp(); + vm.roll(200); + vm.warp(200); + vm.roll(height); + vm.warp(time); + require(vm.getBlockNumber() == 100, "height was not captured"); + require(vm.getBlockTimestamp() == 100, "time was not captured"); + } +} +"#, + ); + for via_ir in [false, true] { + for optimizer in [false, true] { + prj.update_config(|config| { + config.via_ir = via_ir; + config.optimizer = Some(optimizer); + }); + cmd.forge_fuse().args(["test", "--force"]).assert_success().stderr_eq(""); + } + } +} + +#[forgetest] +fn skip_test_and_script_project_lints(prj: _, cmd: _) { + prj.add_test("Test", "pragma solidity ^0.8.0; contract Test {}"); + prj.add_script("Script", "pragma solidity >=0.8.0; contract Script {}"); + + cmd.args(["lint", "--only-lint", "pragma-inconsistent"]).assert_success().stderr_eq(""); +} + +// +#[forgetest] +fn skip_reentrancy_events_for_expect_emit(prj: _, cmd: _) { + let fixture = r#" +interface Vm { + function expectEmit(bool, bool, bool, bool, address) external; + function prank(address) external; +} + +interface Governance { + event Rejected(uint256 taskId, address owner); + function removeOwner(address owner) external; + function vetoRemoveOwner(address owner) external; +} + +contract EventExpectation { + function checkEvent(Vm vm, Governance harness, address alice, address bob) external { + vm.prank(bob); + harness.removeOwner(bob); + vm.expectEmit(true, true, false, false, address(harness)); + emit Governance.Rejected(1, alice); + vm.prank(alice); + harness.vetoRemoveOwner(bob); + } +} +"#; + prj.add_test("EventExpectation", fixture); + prj.add_script("EventExpectation", fixture); + + cmd.arg("lint").assert_success().stderr_eq(""); + cmd.forge_fuse() + .args(["lint", "--only-lint", "reentrancy-events"]) + .assert_success() + .stderr_eq(""); + + prj.add_source("EventExpectation", fixture); + cmd.forge_fuse() + .args(["lint", "--only-lint", "reentrancy-events"]) + .assert_success() + .stderr_eq(str![[r#" +warning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on + [FILE]:20:9 + │ +20 │ emit Governance.Rejected(1, alice); + │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + │ + ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events + + +"#]]); +} + +#[forgetest] +fn can_use_config_mixed_case_exception(prj: _, cmd: _) { + prj.add_source("ContractWithLints", CONTRACT); + prj.add_source("OtherContract", OTHER_CONTRACT); + + // Check config for `ignore` + prj.update_config(|config| { + config.lint = LinterConfig { + severity: vec![], + exclude_lints: vec![], + ignore: vec!["src/ContractWithLints.sol".into()], + lint_on_build: true, + lint_specific: LintSpecificConfig { + mixed_case_exceptions: vec!["MIXED".to_string()], + ..Default::default() + }, + }; + }); + cmd.arg("lint").assert_success().stderr_eq(str![[""]]); +} + +#[forgetest] +fn multi_contract_file_no_exceptions(prj: _, cmd: _) { + prj.add_source("MixedFile", MULTI_CONTRACT_FILE); + + // Without exceptions, should flag all 8 contract-like items + prj.update_config(|config| { + config.lint = LinterConfig { + lint_on_build: true, + severity: vec![ + LintSeverity::High, + LintSeverity::Med, + LintSeverity::Low, + LintSeverity::Info, + ], + ..Default::default() + }; + }); + + let output = cmd.arg("lint").assert_success(); + let stderr = String::from_utf8_lossy(&output.get_output().stderr); + + // Should see 9 instances of multi-contract-file lint + assert_eq!(stderr.matches("note[multi-contract-file]").count(), 9); + assert!(stderr.contains("IToken")); + assert!(stderr.contains("IERC20")); + assert!(stderr.contains("Token")); + assert!(stderr.contains("MathLib")); + assert!(stderr.contains("StringLib")); + assert!(stderr.contains("BaseContract")); + assert!(stderr.contains("AbstractStorage")); + assert!(stderr.contains("FirstContract")); + assert!(stderr.contains("SecondContract")); +} + +#[forgetest] +fn multi_contract_file_interface_exception(prj: _, cmd: _) { + use foundry_config::lint::ContractException; + + prj.add_source("MixedFile", MULTI_CONTRACT_FILE); + + // With interface exception, should flag 6 items + prj.update_config(|config| { + config.lint = LinterConfig { + lint_on_build: true, + severity: vec![ + LintSeverity::High, + LintSeverity::Med, + LintSeverity::Low, + LintSeverity::Info, + ], + exclude_lints: vec!["interface-naming".into()], + lint_specific: LintSpecificConfig { + multi_contract_file_exceptions: vec![ContractException::Interface], + ..Default::default() + }, + ..Default::default() + }; + }); + + let output = cmd.arg("lint").assert_success(); + let stderr = String::from_utf8_lossy(&output.get_output().stderr); + + // Should see 6 instances (3 interfaces excluded: IToken, IERC20, Token) + assert_eq!(stderr.matches("note[multi-contract-file]").count(), 6); + assert!(!stderr.contains("IToken")); + assert!(!stderr.contains("IERC20")); + assert!(!stderr.contains("Token")); + assert!(stderr.contains("MathLib")); + assert!(stderr.contains("FirstContract")); +} + +#[forgetest] +fn multi_contract_file_library_exception(prj: _, cmd: _) { + use foundry_config::lint::ContractException; + + prj.add_source("MixedFile", MULTI_CONTRACT_FILE); + + // With library exception, should flag 7 items + prj.update_config(|config| { + config.lint = LinterConfig { + lint_on_build: true, + severity: vec![ + LintSeverity::High, + LintSeverity::Med, + LintSeverity::Low, + LintSeverity::Info, + ], + lint_specific: LintSpecificConfig { + multi_contract_file_exceptions: vec![ContractException::Library], + ..Default::default() + }, + ..Default::default() + }; + }); + + let output = cmd.arg("lint").assert_success(); + let stderr = String::from_utf8_lossy(&output.get_output().stderr); + + // Should see 7 instances (2 libraries excluded) + assert_eq!(stderr.matches("note[multi-contract-file]").count(), 7); + assert!(stderr.contains("IToken")); + assert!(!stderr.contains("MathLib")); + assert!(!stderr.contains("StringLib")); + assert!(stderr.contains("FirstContract")); +} + +#[forgetest] +fn multi_contract_file_abstract_exception(prj: _, cmd: _) { + use foundry_config::lint::ContractException; + + prj.add_source("MixedFile", MULTI_CONTRACT_FILE); + + // With abstract contract exception, should flag 7 items + prj.update_config(|config| { + config.lint = LinterConfig { + lint_on_build: true, + severity: vec![ + LintSeverity::High, + LintSeverity::Med, + LintSeverity::Low, + LintSeverity::Info, + ], + lint_specific: LintSpecificConfig { + multi_contract_file_exceptions: vec![ContractException::AbstractContract], + ..Default::default() + }, + ..Default::default() + }; + }); + + let output = cmd.arg("lint").assert_success(); + let stderr = String::from_utf8_lossy(&output.get_output().stderr); + + // Should see 7 instances (2 abstract contracts excluded) + assert_eq!(stderr.matches("note[multi-contract-file]").count(), 7); + assert!(stderr.contains("IToken")); + assert!(stderr.contains("MathLib")); + assert!(stderr.contains("FirstContract")); + assert!(!stderr.contains("BaseContract")); + assert!(!stderr.contains("AbstractStorage")); +} + +#[forgetest] +fn multi_contract_file_multiple_exceptions(prj: _, cmd: _) { + use foundry_config::lint::ContractException; + + prj.add_source("MixedFile", MULTI_CONTRACT_FILE); + + // With interface + library exceptions, should flag 4 items + prj.update_config(|config| { + config.lint = LinterConfig { + lint_on_build: true, + severity: vec![ + LintSeverity::High, + LintSeverity::Med, + LintSeverity::Low, + LintSeverity::Info, + ], + exclude_lints: vec!["interface-naming".into()], + lint_specific: LintSpecificConfig { + multi_contract_file_exceptions: vec![ + ContractException::Interface, + ContractException::Library, + ], + ..Default::default() + }, + ..Default::default() + }; + }); + + let output = cmd.arg("lint").assert_success(); + let stderr = String::from_utf8_lossy(&output.get_output().stderr); + + // Should see 4 instances (3 interfaces + 2 libraries excluded) + assert_eq!(stderr.matches("note[multi-contract-file]").count(), 4); + assert!(!stderr.contains("IToken")); + assert!(!stderr.contains("IERC20")); + assert!(!stderr.contains("Token")); + assert!(!stderr.contains("MathLib")); + assert!(stderr.contains("BaseContract")); + assert!(stderr.contains("FirstContract")); +} + +#[forgetest] +fn multi_contract_file_all_exceptions(prj: _, cmd: _) { + use foundry_config::lint::ContractException; + + prj.add_source("MixedFile", MULTI_CONTRACT_FILE); + + // With all exceptions, should still flag 2 regular contracts + prj.update_config(|config| { + config.lint = LinterConfig { + lint_on_build: true, + severity: vec![ + LintSeverity::High, + LintSeverity::Med, + LintSeverity::Low, + LintSeverity::Info, + ], + lint_specific: LintSpecificConfig { + multi_contract_file_exceptions: vec![ + ContractException::Interface, + ContractException::Library, + ContractException::AbstractContract, + ], + ..Default::default() + }, + ..Default::default() + }; + }); + + let output = cmd.arg("lint").assert_success(); + let stderr = String::from_utf8_lossy(&output.get_output().stderr); + + // Should see 2 instances (only the 2 regular contracts) + assert_eq!(stderr.matches("note[multi-contract-file]").count(), 2); + assert!(!stderr.contains("IToken")); + assert!(!stderr.contains("MathLib")); + assert!(!stderr.contains("BaseContract")); + assert!(stderr.contains("FirstContract")); + assert!(stderr.contains("SecondContract")); +} + +#[forgetest] +fn multi_contract_file_invalid_toml_value(prj: _, cmd: _) { + use std::fs; + + prj.add_source("Simple", "contract Simple {}"); + + // Write invalid TOML config with invalid enum value + let config_path = prj.root().join("foundry.toml"); + let invalid_config = r#" +[profile.default] +src = "src" +out = "out" +libs = ["lib"] + +[profile.default.lint.lint_specific] +multi_contract_file_exceptions = ["interface", "bad_contract_type", "library"] +"#; + + fs::write(&config_path, invalid_config).unwrap(); + + // Should fail with deserialization error + let output = cmd.arg("lint").assert_failure(); + let stderr = String::from_utf8_lossy(&output.get_output().stderr); + + // Assert specific error message for invalid enum variant + assert!(stderr.contains("unknown variant")); + assert!(stderr.contains("expected `one of `interface`, `library`, `abstract_contract`")); +} + +#[forgetest] +fn multi_contract_file_valid_toml_values(prj: _, cmd: _) { + use std::fs; + + prj.add_source("MixedFile", MULTI_CONTRACT_FILE); + + // Write valid TOML config with all valid enum values + let config_path = prj.root().join("foundry.toml"); + let valid_config = r#" +[profile.default] +src = "src" +out = "out" +libs = ["lib"] + +[profile.default.lint] +lint_on_build = true +severity = ["high", "medium", "low", "info"] + +[profile.default.lint.lint_specific] +multi_contract_file_exceptions = ["interface", "library", "abstract_contract"] +"#; + + fs::write(&config_path, valid_config).unwrap(); + + // Should succeed and only flag the 2 regular contracts + let output = cmd.arg("lint").assert_success(); + let stderr = String::from_utf8_lossy(&output.get_output().stderr); + + assert_eq!(stderr.matches("note[multi-contract-file]").count(), 2); + assert!(stderr.contains("FirstContract")); + assert!(stderr.contains("SecondContract")); +} + +#[forgetest] +fn interface_naming_fails_for_non_prefixed(prj: _, cmd: _) { + prj.add_source("MixedFile", MULTI_CONTRACT_FILE); + + prj.update_config(|config| { + config.lint = LinterConfig { + severity: vec![], + exclude_lints: vec!["multi-contract-file".into()], + ignore: vec![], + lint_on_build: true, + ..Default::default() + }; + }); + + let output = cmd.arg("lint").assert_success(); + let stderr = String::from_utf8_lossy(&output.get_output().stderr); + + // Should flag only the interface that doesn't start with 'I': Token + assert_eq!(stderr.matches("note[interface-naming]").count(), 1); + assert!(stderr.contains("Token")); +} + +#[forgetest] +fn interface_file_naming_fails_for_non_prefixed_file(prj: _, cmd: _) { + prj.add_source("SoloInterfaces", SOLO_INTERFACES); + + prj.update_config(|config| { + config.lint = LinterConfig { + severity: vec![], + exclude_lints: vec!["multi-contract-file".into()], + ignore: vec![], + lint_on_build: true, + ..Default::default() + }; + }); + + let output = cmd.arg("lint").assert_success(); + let stderr = String::from_utf8_lossy(&output.get_output().stderr); + + // File name "SoloInterfaces" doesn't start with 'I', so interface-file-naming should trigger + assert_eq!(stderr.matches("note[interface-file-naming]").count(), 1); + // ERC20 is not prefixed with 'I', so interface-naming should trigger + assert_eq!(stderr.matches("note[interface-naming]").count(), 1); + assert!(stderr.contains("ERC20")); +} + +#[forgetest] +fn can_override_config_severity(prj: _, cmd: _) { + prj.add_source("ContractWithLints", CONTRACT); + prj.add_source("OtherContractWithLints", OTHER_CONTRACT); + + // Override severity + prj.update_config(|config| { + config.lint = LinterConfig { + severity: vec![LintSeverity::High, LintSeverity::Med], + exclude_lints: vec![], + ignore: vec!["src/ContractWithLints.sol".into()], + lint_on_build: true, + ..Default::default() + }; + }); + cmd.arg("lint").args(["--severity", "info"]).assert_success().stderr_eq(str![[r#" +note[mixed-case-function]: function name is not `mixedCase` + [FILE]:9:14 + │ +9 │ function functionMIXEDCaseInfo() public { uint256 x = 1; } + │ ━━━━━━━━━━━━━━━━━━━━━ help: consider using: `functionMixedCaseInfo` + │ + ╰ help: https://getfoundry.sh/forge/linting/mixed-case-function + + +"#]]); +} + +#[forgetest] +fn can_override_config_path(prj: _, cmd: _) { + prj.add_source("ContractWithLints", CONTRACT); + prj.add_source("OtherContractWithLints", OTHER_CONTRACT); + + // Override excluded files + prj.update_config(|config| { + config.lint = LinterConfig { + severity: vec![LintSeverity::High, LintSeverity::Med], + exclude_lints: vec!["incorrect-shift".into()], + ignore: vec!["src/ContractWithLints.sol".into()], + lint_on_build: true, + ..Default::default() + }; + }); + cmd.arg("lint").arg("src/ContractWithLints.sol").assert_success().stderr_eq(str![[r#" +warning[divide-before-multiply]: division before multiplication may lose precision + [FILE]:16:9 + │ +16 │ (1 / 2) * 3; + │ ━━━━━━━━━━━ + │ + ╰ help: https://getfoundry.sh/forge/linting/divide-before-multiply + + +"#]]); +} + +#[forgetest] +fn can_override_config_lint(prj: _, cmd: _) { + prj.add_source("ContractWithLints", CONTRACT); + prj.add_source("OtherContractWithLints", OTHER_CONTRACT); + + // Override excluded lints + prj.update_config(|config| { + config.lint = LinterConfig { + severity: vec![LintSeverity::High, LintSeverity::Med], + exclude_lints: vec!["incorrect-shift".into()], + ignore: vec![], + lint_on_build: true, + ..Default::default() + }; + }); + cmd.arg("lint").args(["--only-lint", "incorrect-shift"]).assert_success().stderr_eq(str![[ + r#" +warning[incorrect-shift]: the order of args in a shift operation is incorrect + [FILE]:13:30 + │ +13 │ assembly { result := shr(result, 8) } + │ ━━━━━━━━━━━━━━ + │ + ╰ help: https://getfoundry.sh/forge/linting/incorrect-shift + + +"# + ]]); +} + +#[forgetest] +fn build_runs_linter_by_default(prj: _, cmd: _) { + prj.add_source("ContractWithLints", CONTRACT); + + // Configure linter to show only medium severity lints + prj.update_config(|config| { + config.lint = LinterConfig { + severity: vec![LintSeverity::Med], + exclude_lints: vec!["incorrect-shift".into()], + ignore: vec![], + lint_on_build: true, + ..Default::default() + }; + }); + + // Run forge build and expect linting output before compilation + cmd.arg("build") + .assert_success() + .stderr_eq(str![[r#" +warning[divide-before-multiply]: division before multiplication may lose precision + [FILE]:16:9 + │ +16 │ (1 / 2) * 3; + │ ━━━━━━━━━━━ + │ + ╰ help: https://getfoundry.sh/forge/linting/divide-before-multiply + + +"#]]) + .stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful with warnings: +Warning (6133): Statement has no effect. + [FILE]:16:9: + | +16 | (1 / 2) * 3; + | ^^^^^^^^^^^ + +Warning (2018): Function state mutability can be restricted to pure + [FILE]:15:5: + | +15 | function divideBeforeMultiplyMedium() public { + | ^ (Relevant source part starts here and spans across multiple lines). + +Warning (2018): Function state mutability can be restricted to pure + [FILE]:18:5: + | +18 | function unoptimizedHashGas(uint256 a, uint256 b) public view { + | ^ (Relevant source part starts here and spans across multiple lines). + + +"#]]); +} + +#[forgetest] +fn build_respects_quiet_flag_for_linting(prj: _, cmd: _) { + prj.add_source("ContractWithLints", CONTRACT); + + // Configure linter to show medium severity lints + prj.update_config(|config| { + config.lint = LinterConfig { + severity: vec![LintSeverity::Med], + exclude_lints: vec!["incorrect-shift".into()], + ignore: vec![], + lint_on_build: true, + ..Default::default() + }; + }); + + // Run forge build with --quiet flag - should not show linting output + cmd.arg("build").arg("--quiet").assert_success().stderr_eq(str![[""]]).stdout_eq(str![[""]]); +} + +#[forgetest] +fn build_with_json_uses_json_linter_output(prj: _, cmd: _) { + prj.add_source("ContractWithLints", CONTRACT); + + // Configure linter to show medium severity lints + prj.update_config(|config| { + config.lint = LinterConfig { + severity: vec![LintSeverity::Med], + exclude_lints: vec!["incorrect-shift".into()], + ignore: vec![], + lint_on_build: true, + ..Default::default() + }; + }); + + // Run forge build with --json flag - should use JSON formatter for linting + let output = cmd.arg("build").arg("--json").assert_success(); + + // Should contain JSON linting output + let stderr = String::from_utf8_lossy(&output.get_output().stderr); + assert!(stderr.contains("\"code\"")); + assert!(stderr.contains("divide-before-multiply")); + + // Should also contain JSON compilation output + let stdout = String::from_utf8_lossy(&output.get_output().stdout); + assert!(stdout.contains("\"errors\"")); + assert!(stdout.contains("\"sources\"")); +} + +#[forgetest] +fn build_respects_lint_on_build_false(prj: _, cmd: _) { + prj.add_source("ContractWithLints", CONTRACT); + + // Configure linter with medium severity lints but disable lint_on_build + prj.update_config(|config| { + config.lint = LinterConfig { + severity: vec![LintSeverity::Med], + exclude_lints: vec!["incorrect-shift".into()], + ignore: vec![], + lint_on_build: false, + ..Default::default() + }; + }); + + // Run forge build - should NOT show linting output because lint_on_build is false + cmd.arg("build").assert_success().stderr_eq(str![[""]]).stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful with warnings: +Warning (6133): Statement has no effect. + [FILE]:16:9: + | +16 | (1 / 2) * 3; + | ^^^^^^^^^^^ + +Warning (2018): Function state mutability can be restricted to pure + [FILE]:15:5: + | +15 | function divideBeforeMultiplyMedium() public { + | ^ (Relevant source part starts here and spans across multiple lines). + +Warning (2018): Function state mutability can be restricted to pure + [FILE]:18:5: + | +18 | function unoptimizedHashGas(uint256 a, uint256 b) public view { + | ^ (Relevant source part starts here and spans across multiple lines). + + +"#]]); +} + +// Lint diagnostics produced during `forge build` must stream to stderr through the emitter +// installed inside `SolidityLinter::lint`. +#[forgetest] +fn build_emits_lint_diagnostics(prj: _, cmd: _) { + prj.add_source("CounterAWithLints", COUNTER_A); + + prj.update_config(|config| { + config.lint.severity = vec![LintSeverity::Info]; + }); + + cmd.arg("build").assert_success().stderr_eq(str![[r#" +note[mixed-case-variable]: mutable variable name is not `mixedCase` + [FILE]:6:20 + │ +6 │ uint256 public CounterA_Fail_Lint; + │ ━━━━━━━━━━━━━━━━━━ help: consider using: `counterAFailLint` + │ + ╰ help: https://getfoundry.sh/forge/linting/mixed-case-variable + + +"#]]); +} + +#[forgetest] +fn build_lint_resolves_imports_with_explicit_root(prj: _, cmd: _) { + prj.add_source("Imported", "contract Imported {}"); + prj.add_source( + "RelativeImporter", + r#" +import {Imported} from "./Imported.sol"; + +contract RelativeImporter is Imported {} +"#, + ); + prj.add_source( + "Importer", + r#" +import {RelativeImporter} from "src/RelativeImporter.sol"; + +contract Importer is RelativeImporter {} +"#, + ); + + let root = prj.root(); + cmd.current_dir(root.parent().unwrap()) + .args(["build", "--force", "--no-cache", "--root"]) + .arg(root.file_name().unwrap()) + .assert_success(); +} + +#[forgetest] +fn build_no_lint_flag_skips_lint(prj: _, cmd: _) { + prj.add_source("ContractWithLints", CONTRACT); + + // Configure linter with medium severity lints and ensure lint_on_build is enabled + // so the only thing skipping the lint step is the `--no-lint` flag. + prj.update_config(|config| { + config.lint = LinterConfig { + severity: vec![LintSeverity::Med], + exclude_lints: vec!["incorrect-shift".into()], + ignore: vec![], + lint_on_build: true, + ..Default::default() + }; + }); + + cmd.args(["build", "--no-lint"]).assert_success().stderr_eq(str![[r#""#]]).stdout_eq(str![[ + r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful with warnings: +Warning (6133): Statement has no effect. + [FILE]:16:9: + | +16 | (1 / 2) * 3; + | ^^^^^^^^^^^ + +Warning (2018): Function state mutability can be restricted to pure + [FILE]:15:5: + | +15 | function divideBeforeMultiplyMedium() public { + | ^ (Relevant source part starts here and spans across multiple lines). + +Warning (2018): Function state mutability can be restricted to pure [FILE]:18:5: | 18 | function unoptimizedHashGas(uint256 a, uint256 b) public view { | ^ (Relevant source part starts here and spans across multiple lines). +"# + ]]); +} + +// Denied lint diagnostics are expected failures and must not be presented as internal errors. +#[forgetest] +fn build_denied_lints_do_not_emit_internal_failure_notice(prj: _, cmd: _) { + prj.add_source("CounterAWithLints", COUNTER_A); + + prj.update_config(|config| { + config.lint.severity = vec![LintSeverity::Info]; + config.deny = DenyLevel::Notes; + }); + + cmd.arg("build").assert_failure().stderr_eq(str![[r#" +note[mixed-case-variable]: mutable variable name is not `mixedCase` + [FILE]:6:20 + │ +6 │ uint256 public CounterA_Fail_Lint; + │ ━━━━━━━━━━━━━━━━━━ help: consider using: `counterAFailLint` + │ + ╰ help: https://getfoundry.sh/forge/linting/mixed-case-variable + +Error: post-build lint step failed + +Context: +- aborting due to 1 linter note(s) + +"#]]); +} + +// Solar currently rejects enum `@param` tags while solc accepts them. This recoverable frontend +// diagnostic must not prevent type-dependent late lints from running. +#[forgetest] +fn build_lints_after_recoverable_solar_diagnostic(prj: _, cmd: _) { + prj.add_source( + "RecoverableSolarDiagnostic", + r#" +/// @param First The first choice. +enum Choice { First } + +contract RecoverableSolarDiagnostic { + function chainId() public view returns (uint64) { + return uint64(block.chainid); + } +} +"#, + ); + + prj.update_config(|config| { + config.lint.severity = vec![LintSeverity::Med]; + config.deny = DenyLevel::Warnings; + }); + + cmd.arg("build").assert_failure().stderr_eq(str![[r#" +warning[unsafe-typecast]: typecast can truncate values + [FILE]:9:16 + │ +9 │ return uint64(block.chainid); + │ ━━━━━━━━━━━━━━━━━━━━━ + │ + ├ note: consider disabling this lint if you're certain the cast is safe + │ [..] + │ // casting to 'uint64' is safe because [explain why] + │ // forge-lint: disable-next-line(unsafe-typecast) + │ [..] + │ [..] + ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast + +Error: post-build lint step failed + +Context: +- aborting due to 1 linter warning(s) + +"#]]); +} + +// Same setup as above, but `--no-lint` skips the lint step so the failure notice never fires. +#[forgetest] +fn build_no_lint_flag_does_not_emit_lint_failure_notice(prj: _, cmd: _) { + prj.add_source("CounterAWithLints", COUNTER_A); + + prj.update_config(|config| { + config.lint.severity = vec![LintSeverity::Info]; + config.deny = DenyLevel::Notes; + }); + + cmd.args(["build", "--no-lint"]).assert_success().stderr_eq(str![[r#""#]]); +} + +#[forgetest] +fn can_process_inline_config_regardless_of_input_order(prj: _, cmd: _) { + prj.add_source("ContractWithLints", CONTRACT); + prj.add_source("OtherContractWithLints", OTHER_CONTRACT); + cmd.arg("lint").assert_success(); + + prj.add_source("OtherContractWithLints", OTHER_CONTRACT); + prj.add_source("ContractWithLints", CONTRACT); + cmd.arg("lint").assert_success(); +} + +// +#[forgetest] +fn can_use_only_lint_with_multilint_passes(prj: _, cmd: _) { + prj.add_source("ContractWithLints", CONTRACT); + prj.add_source("OnlyImports", ONLY_IMPORTS); + cmd.arg("lint").args(["--only-lint", "unused-import"]).assert_success().stderr_eq(str![[r#" +note[unused-import]: unused import + [FILE]:8:10 + │ +8 │ import { _PascalCaseInfo } from "./ContractWithLints.sol"; + │ ━━━━━━━━━━━━━━━ + │ + ╰ help: https://getfoundry.sh/forge/linting/unused-import + + +"#]]); +} + +// +#[forgetest] +fn can_lint_only_built_files(prj: _, cmd: _) { + prj.add_source("CounterAWithLints", COUNTER_A); + prj.add_source("CounterBWithLints", COUNTER_B); + + prj.update_config(|config| { + config.lint.severity = vec![LintSeverity::Info]; + }); + + // Both contracts should be linted on build. Redact contract as order is not guaranteed. + cmd.forge_fuse().args(["build"]).assert_success().stderr_eq(str![[r#" +note[mixed-case-variable]: mutable variable name is not `mixedCase` +... +note[mixed-case-variable]: mutable variable name is not `mixedCase` +... "#]]); -}); -forgetest!(can_process_inline_config_regardless_of_input_order, |prj, cmd| { - prj.wipe_contracts(); - prj.add_source("ContractWithLints", CONTRACT).unwrap(); - prj.add_source("OtherContractWithLints", OTHER_CONTRACT).unwrap(); - cmd.arg("lint").assert_success(); + // Only contract CounterBWithLints that we build should be linted. + let args = ["build", "src/CounterBWithLints.sol"]; + cmd.forge_fuse().args(args).assert_success().stderr_eq(str![[r#" +note[mixed-case-variable]: mutable variable name is not `mixedCase` + [FILE]:6:20 + │ +6 │ uint256 public CounterB_Fail_Lint; + │ ━━━━━━━━━━━━━━━━━━ help: consider using: `counterBFailLint` + │ + ╰ help: https://getfoundry.sh/forge/linting/mixed-case-variable - prj.wipe_contracts(); - prj.add_source("OtherContractWithLints", OTHER_CONTRACT).unwrap(); - prj.add_source("ContractWithLints", CONTRACT).unwrap(); - cmd.arg("lint").assert_success(); -}); - -#[tokio::test] -async fn ensure_lint_rule_docs() { - const FOUNDRY_BOOK_LINT_PAGE_URL: &str = - "https://book.getfoundry.sh/reference/forge/forge-lint"; - - // Fetch the content of the lint reference - let content = match reqwest::get(FOUNDRY_BOOK_LINT_PAGE_URL).await { - Ok(resp) => { - if !resp.status().is_success() { - panic!( - "Failed to fetch Foundry Book lint page ({FOUNDRY_BOOK_LINT_PAGE_URL}). Status: {status}", - status = resp.status() - ); - } - match resp.text().await { - Ok(text) => text, - Err(e) => { - panic!("Failed to read response text: {e}"); - } + +"#]]); +} + +// +#[forgetest] +fn can_lint_param_constants(prj: _, cmd: _) { + prj.add_source("Counter", COUNTER_WITH_CONST); + prj.add_test("CounterTest", COUNTER_TEST_WITH_CONST); + + cmd.forge_fuse().args(["build"]).assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +"#]]); +} + +// +#[forgetest] +fn lint_json_output_no_ansi_escape_codes(prj: _, cmd: _) { + prj.add_source( + "UnwrappedModifierTest", + r#" + // SPDX-License-Identifier: MIT + pragma solidity ^0.8.0; + + contract UnwrappedModifierTest { + mapping(address => bool) isOwner; + + modifier onlyOwner() { + require(isOwner[msg.sender], "Not owner"); + require(msg.sender != address(0), "Zero address"); + _; } + + function doSomething() public onlyOwner {} } - Err(e) => { - panic!("Failed to fetch Foundry Book lint page ({FOUNDRY_BOOK_LINT_PAGE_URL}): {e}",); + "#, + ); + + prj.update_config(|config| { + config.lint = LinterConfig { + severity: vec![LintSeverity::CodeSize], + exclude_lints: vec![], + ignore: vec![], + lint_on_build: true, + ..Default::default() + }; + }); + + // should produce clean JSON without ANSI escape sequences (for the url nor the snippets) + cmd.arg("lint") + .arg("--json") + .assert_json_stdout_with_status( + true, + str![[r#" +{ + "$message_type": "diagnostic", + "message": "modifier logic can be wrapped to reduce code size", + "code": { + "code": "unwrapped-modifier-logic", + "explanation": null + }, + "level": "note", + "spans": [ + { + "file_name": "src/UnwrappedModifierTest.sol", + "byte_start": 174, + "byte_end": 355, + "line_start": 8, + "line_end": 12, + "column_start": 13, + "column_end": 14, + "is_primary": true, + "text": [ + { + "text": " modifier onlyOwner() {", + "highlight_start": 13, + "highlight_end": 35 + }, + { + "text": " require(isOwner[msg.sender], \"Not owner\");", + "highlight_start": 1, + "highlight_end": 59 + }, + { + "text": " require(msg.sender != address(0), \"Zero address\");", + "highlight_start": 1, + "highlight_end": 67 + }, + { + "text": " _;", + "highlight_start": 1, + "highlight_end": 19 + }, + { + "text": " }", + "highlight_start": 1, + "highlight_end": 14 } - }; - - // Ensure no missing lints - let mut missing_lints = Vec::new(); - for lint in REGISTERED_LINTS { - let selector = format!("#{}", lint.id()); - if !content.contains(&selector) { - missing_lints.push(lint.id()); + ], + "label": null, + "suggested_replacement": null, + "suggestion_applicability": null, + "expansion": null + } + ], + "children": [ + { + "message": "https://getfoundry.sh/forge/linting/unwrapped-modifier-logic", + "code": null, + "level": "help", + "spans": [], + "children": [], + "rendered": null + }, + { + "message": "wrap modifier logic to reduce code size", + "code": null, + "level": "help", + "spans": [ + { + "file_name": "src/UnwrappedModifierTest.sol", + "byte_start": 174, + "byte_end": 355, + "line_start": 8, + "line_end": 12, + "column_start": 13, + "column_end": 14, + "is_primary": true, + "text": [ + { + "text": " modifier onlyOwner() {", + "highlight_start": 13, + "highlight_end": 35 + }, + { + "text": " require(isOwner[msg.sender], \"Not owner\");", + "highlight_start": 1, + "highlight_end": 59 + }, + { + "text": " require(msg.sender != address(0), \"Zero address\");", + "highlight_start": 1, + "highlight_end": 67 + }, + { + "text": " _;", + "highlight_start": 1, + "highlight_end": 19 + }, + { + "text": " }", + "highlight_start": 1, + "highlight_end": 14 + } + ], + "label": null, + "suggested_replacement": "modifier onlyOwner() {\n _onlyOwner();\n _;\n }\n\n function _onlyOwner() internal {\n require(isOwner[msg.sender], \"Not owner\");\n require(msg.sender != address(0), \"Zero address\");\n }", + "suggestion_applicability": "MachineApplicable", + "expansion": null } + ], + "children": [], + "rendered": null + } + ], + "rendered": "note[unwrapped-modifier-logic]: modifier logic can be wrapped to reduce code size\n\nhelp: wrap modifier logic to reduce code size\n 9 + _onlyOwner();\n10 + _;\n11 + }\n12 + \n13 + function _onlyOwner() internal {\n14 + require(isOwner[msg.sender], \"Not owner\");\n15 + require(msg.sender != address(0), \"Zero address\");\n16 + }\n ╭▸ src/UnwrappedModifierTest.sol:8:13\n │\n 8 │ ┏ modifier onlyOwner() {\n 9 │ ┃ require(isOwner[msg.sender], \"Not owner\");\n10 │ ┃ require(msg.sender != address(0), \"Zero address\");\n11 │ ┃ _;\n12 │ ┃ }\n │ ┗━━━━━━━━━━━━━┛\n │\n ╰ help: https://getfoundry.sh/forge/linting/unwrapped-modifier-logic\n ╭╴\n 8 ± modifier onlyOwner() {\n ╰╴\n" +} +"#]], + ) + .stderr_eq(""); +} + +#[forgetest] +fn lint_json_compiler_error(prj: _, cmd: _) { + prj.add_source( + "Broken", + r#"pragma solidity ^0.8.30; + +contract Broken { + function f() public { + uint256 value = missing; } +} +"#, + ); - if !missing_lints.is_empty() { - let mut msg = String::from( - "Foundry Book lint validation failed. The following lints must be added to the docs:\n", - ); - for lint in missing_lints { - msg.push_str(&format!(" - {lint}\n")); + cmd.args(["lint", "--json"]) + .assert_json_stdout_with_status(false, str![[r#" +{ + "$message_type": "diagnostic", + "message": "unresolved symbol `missing`", + "code": null, + "level": "error", + "spans": [ + { + "file_name": "src/Broken.sol", + "byte_start": 140, + "byte_end": 147, + "line_start": 6, + "line_end": 6, + "column_start": 25, + "column_end": 32, + "is_primary": true, + "text": [ + { + "text": " uint256 value = missing;", + "highlight_start": 25, + "highlight_end": 32 } - msg.push_str("Please open a PR: https://github.com/foundry-rs/book"); - panic!("{msg}"); + ], + "label": null, + "suggested_replacement": null, + "suggestion_applicability": null, + "expansion": null } + ], + "children": [], + "rendered": "error: unresolved symbol `missing`\n ╭▸ src/Broken.sol:6:25\n │\n6 │ uint256 value = missing;\n ╰╴ ━━━━━━━\n\n" +} +"#]]) + .stderr_eq(""); +} + +#[forgetest] +fn can_fail_on_lints(prj: _, cmd: _) { + prj.add_source("ContractWithLints", CONTRACT); + + // -- LINT ALL SEVERITIES [OUTPUT: WARN + NOTE] ---------------------------- + + cmd.forge_fuse().arg("lint").assert_success(); // DenyLevel::Never (default) + + prj.update_config(|config| { + config.deny = DenyLevel::Warnings; + }); + cmd.forge_fuse().arg("lint").assert_failure(); + + prj.update_config(|config| { + config.deny = DenyLevel::Notes; + }); + cmd.forge_fuse().arg("lint").assert_failure(); + + // cmd flags can override config + prj.update_config(|config| { + config.deny = DenyLevel::Never; + }); + cmd.forge_fuse().args(["lint", "--deny warnings"]).assert_failure(); + cmd.forge_fuse().args(["lint", "--deny notes"]).assert_failure(); + + // usage of `--deny-warnings` flag works, but emits a warning + cmd.forge_fuse().args(["lint", "--deny-warnings"]).assert_failure().stderr_eq(str![[r#" +Warning: `--deny-warnings` is being deprecated in favor of `--deny warnings`. +... + +"#]]); + + // usage of `deny_warnings` config works, but emits a warning + prj.create_file( + "foundry.toml", + r#" +[profile.default] +deny_warnings = true +"#, + ); + cmd.forge_fuse().arg("lint").assert_failure().stderr_eq(str![[r#" +Warning: Key `deny_warnings` is being deprecated in favor of `deny = warnings`. It will be removed in future versions. +... + +"#]]); + + // -- ONLY LINT LOW SEVERITIES [OUTPUT: NOTE] ------------------------------ + + prj.update_config(|config| { + config.deny_warnings = false; + config.deny = DenyLevel::Never; + config.lint.severity = vec![LintSeverity::Info, LintSeverity::Gas, LintSeverity::CodeSize]; + }); + cmd.forge_fuse().arg("lint").assert_success(); + + prj.update_config(|config| { + config.deny = DenyLevel::Warnings; + }); + cmd.forge_fuse().arg("lint").assert_success(); + + prj.update_config(|config| { + config.deny = DenyLevel::Notes; + }); + cmd.forge_fuse().arg("lint").assert_failure(); + + // cmd flags can override config + prj.update_config(|config| { + config.deny = DenyLevel::Never; + }); + cmd.forge_fuse().args(["lint", "--deny notes"]).assert_failure(); } +// ------------------------------------------------------------------------------------------------ + #[test] fn ensure_no_privileged_lint_id() { - for lint in REGISTERED_LINTS { + for lint in registered_lints() { assert_ne!(lint.id(), "all", "lint-id 'all' is reserved. Please use a different id"); } } + +fn registered_lints() -> impl Iterator { + sol::high::REGISTERED_LINTS + .iter() + .chain(sol::med::REGISTERED_LINTS) + .chain(sol::low::REGISTERED_LINTS) + .chain(sol::info::REGISTERED_LINTS) + .chain(sol::gas::REGISTERED_LINTS) + .chain(sol::codesize::REGISTERED_LINTS) +} + +// +#[forgetest] +fn dependency_warnings_do_not_affect_lint_exit_code(prj: _, cmd: _) { + // Library with code that triggers a solc warning (unused local variable) + const LIB_WITH_WARNING: &str = r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +library LibWithWarning { + function foo() internal pure returns (uint256) { + uint256 unusedVar = 42; + return 1; + } +} +"#; + + // Clean contract that imports the library but has no lint issues + const CLEAN_CONTRACT: &str = r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import { LibWithWarning } from "../lib/LibWithWarning.sol"; + +contract CleanContract { + function bar() public pure returns (uint256) { + return LibWithWarning.foo(); + } +} +"#; + + prj.add_lib("LibWithWarning", LIB_WITH_WARNING); + prj.add_source("CleanContract", CLEAN_CONTRACT); + + // Ignore the solc warning so compilation succeeds, but it still gets counted in diagnostics + prj.update_config(|config| { + config.ignored_error_codes = vec![SolidityErrorCode::UnusedLocalVariable]; + }); + + // Clear cache to force recompilation during lint + prj.clear_cache(); + + // Lint with deny = notes via CLI flag. + // Should succeed because the linter only counts lint diagnostics, not build-phase warnings. + cmd.args(["lint", "-D", "notes"]).assert_success(); +} + +#[forgetest] +fn skips_linting_for_old_solidity_versions(prj: _, cmd: _) { + const OLD_CONTRACT: &str = r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.7.0; + +contract OldContract { + uint256 VARIABLE_MIXED_CASE_INFO; + + function FUNCTION_MIXED_CASE_INFO() public {} +} +"#; + + // Add a contract with Solidity 0.7.x which has lint issues + prj.add_source("OldContract", OLD_CONTRACT); + prj.update_config(|config| { + config.lint = LinterConfig { + severity: vec![], + exclude_lints: vec![], + ignore: vec![], + lint_on_build: true, + ..Default::default() + }; + }); + + // Run forge build - should SUCCEED without linting + cmd.arg("build").assert_success().stderr_eq(str![[ + r#"Warning: unable to lint. Solar only supports Solidity versions >=0.8.0 + +"# + ]]); + + // Run forge lint - should FAIL + cmd.forge_fuse().arg("lint").assert_failure().stderr_eq(str![[ + r#"Error: unable to lint. Solar only supports Solidity versions >=0.8.0 + +"# + ]]); +} + +const PRAGMA_INCONSISTENT_ALPHA: &str = r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +contract Alpha {} +"#; + +const PRAGMA_INCONSISTENT_BETA: &str = r#" +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +contract Beta {} +"#; + +#[forgetest] +fn pragma_inconsistent_cross_file(prj: _, cmd: _) { + prj.add_source("Alpha", PRAGMA_INCONSISTENT_ALPHA); + prj.add_source("Beta", PRAGMA_INCONSISTENT_BETA); + + cmd.arg("lint").args(["--only-lint", "pragma-inconsistent"]).assert_success().stderr_eq(str![ + [r#" +note[pragma-inconsistent]: 2 different Solidity pragma version requirements are used: `0.8.20`, `^0.8.20` + [FILE]:3:1 + │ +3 │ pragma solidity ^0.8.20; + │ ━━━━━━━━━━━━━━━━━━━━━━━━ + │ + ╰ help: https://getfoundry.sh/forge/linting/pragma-inconsistent + + +"#] + ]); +} + +const PRAGMA_EXACT_A: &str = r#" +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +contract A {} +"#; + +const PRAGMA_EXACT_B: &str = r#" +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +contract B {} +"#; + +const PRAGMA_EXACT_C: &str = r#" +// SPDX-License-Identifier: MIT +pragma solidity 0.8.20; + +contract C {} +"#; + +const PRAGMA_CARET_A: &str = r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +contract A {} +"#; + +const PRAGMA_CARET_B: &str = r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +contract B {} +"#; + +const PRAGMA_CARET_C: &str = r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +contract C {} +"#; + +const NO_PRAGMA_C: &str = r#" +// SPDX-License-Identifier: MIT + +contract C {} +"#; + +// Multiple files all using the exact same pragma must NOT warn. +#[forgetest] +fn pragma_inconsistent_consistent_exact_no_warning(prj: _, cmd: _) { + prj.add_source("A", PRAGMA_EXACT_A); + prj.add_source("B", PRAGMA_EXACT_B); + prj.add_source("C", PRAGMA_EXACT_C); + + cmd.arg("lint") + .args(["--only-lint", "pragma-inconsistent"]) + .assert_success() + .stderr_eq(str![[r#""#]]); +} + +// Multiple files all using the exact same caret pragma must NOT warn. +#[forgetest] +fn pragma_inconsistent_consistent_caret_no_warning(prj: _, cmd: _) { + prj.add_source("A", PRAGMA_CARET_A); + prj.add_source("B", PRAGMA_CARET_B); + + cmd.arg("lint") + .args(["--only-lint", "pragma-inconsistent"]) + .assert_success() + .stderr_eq(str![[r#""#]]); +} + +// A single file in the project cannot conflict with itself. +#[forgetest] +fn pragma_inconsistent_single_file_no_warning(prj: _, cmd: _) { + prj.add_source("A", PRAGMA_CARET_A); + + cmd.arg("lint") + .args(["--only-lint", "pragma-inconsistent"]) + .assert_success() + .stderr_eq(str![[r#""#]]); +} + +// Even files that share a requirement still emit when ANY other variant exists. +// Two files with `0.8.20` plus one file with `^0.8.20` => 3 emits total. +#[forgetest] +fn pragma_inconsistent_duplicates_among_conflict(prj: _, cmd: _) { + prj.add_source("A", PRAGMA_EXACT_A); + prj.add_source("B", PRAGMA_EXACT_B); + prj.add_source("C", PRAGMA_CARET_C); + + cmd.arg("lint").args(["--only-lint", "pragma-inconsistent"]).assert_success().stderr_eq(str![ + [r#" +note[pragma-inconsistent]: 2 different Solidity pragma version requirements are used: `0.8.20`, `^0.8.20` + [FILE]:3:1 + │ +3 │ pragma solidity 0.8.20; + │ ━━━━━━━━━━━━━━━━━━━━━━━ + │ + ╰ help: https://getfoundry.sh/forge/linting/pragma-inconsistent + + +"#] + ]); +} + +// Files without a `pragma solidity` directive must not affect the conflict computation. +// Note: `add_raw_source` is used here to bypass the helper that would otherwise inject a default +// `pragma solidity =;` for files that omit one. +#[forgetest] +fn pragma_inconsistent_files_without_pragma(prj: _, cmd: _) { + prj.add_raw_source("A", PRAGMA_EXACT_A); + prj.add_raw_source("B", PRAGMA_CARET_B); + // C has no pragma at all; should be ignored by the cross-file check. + prj.add_raw_source("C", NO_PRAGMA_C); + + cmd.arg("lint").args(["--only-lint", "pragma-inconsistent"]).assert_success().stderr_eq(str![ + [r#" +note[pragma-inconsistent]: 2 different Solidity pragma version requirements are used: `0.8.20`, `^0.8.20` + [FILE]:3:1 + │ +3 │ pragma solidity 0.8.20; + │ ━━━━━━━━━━━━━━━━━━━━━━━ + │ + ╰ help: https://getfoundry.sh/forge/linting/pragma-inconsistent + + +"#] + ]); +} + +// A suppression that does not suppress any diagnostic is only reported with the opt-in flag, at the +// directive's own location. +#[forgetest] +fn report_unused_suppressions_reports_unused(prj: _, cmd: _) { + prj.add_source( + "Unused", + r#" +contract Unused { + function f() public pure returns (uint256) { + // forge-lint: disable-next-line(divide-before-multiply) + return 42; + } +} +"#, + ); + + // Without the flag nothing is reported. + cmd.args(["lint", "--only-lint", "divide-before-multiply"]).assert_success().stderr_eq(""); + + cmd.forge_fuse() + .args(["lint", "--only-lint", "divide-before-multiply", "--report-unused-suppressions"]) + .assert_success() + .stderr_eq(str![[r#" +warning: unused lint suppression for 'divide-before-multiply' + [FILE]:6:9 + │ +6 │ // forge-lint: disable-next-line(divide-before-multiply) + ╰╴ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + +"#]]); +} + +// A suppression that actually silences a diagnostic is not reported as unused. +#[forgetest] +fn report_unused_suppressions_ignores_used(prj: _, cmd: _) { + prj.add_source( + "Used", + r#" +contract Used { + function f() public pure returns (uint256) { + // forge-lint: disable-next-line(divide-before-multiply) + return (1 / 2) * 3; + } +} +"#, + ); + + cmd.args(["lint", "--only-lint", "divide-before-multiply", "--report-unused-suppressions"]) + .assert_success() + .stderr_eq(""); +} + +// For a directive listing several ids, only the ids that suppressed nothing are reported. +#[forgetest] +fn report_unused_suppressions_reports_individual_ids(prj: _, cmd: _) { + prj.add_source( + "Partial", + r#" +contract Partial { + function f() public pure returns (uint256) { + // forge-lint: disable-next-line(divide-before-multiply, incorrect-shift) + return (1 / 2) * 3; + } +} +"#, + ); + + cmd.args([ + "lint", + "--only-lint", + "divide-before-multiply", + "incorrect-shift", + "--report-unused-suppressions", + ]) + .assert_success() + .stderr_eq(str![[r#" +warning: unused lint suppression for 'incorrect-shift' + [FILE]:6:9 + │ +6 │ // forge-lint: disable-next-line(divide-before-multiply, incorrect-shift) + ╰╴ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + +"#]]); +} + +// Every supported directive kind receives credit when it suppresses a diagnostic, including all +// overlapping ranges. +#[forgetest] +fn report_unused_suppressions_supports_all_directive_kinds(prj: _, cmd: _) { + prj.add_source( + "DirectiveKinds", + r#" +contract DirectiveKinds { + function line() public pure returns (uint256) { + return (1 / 2) * 3; // forge-lint: disable-line(divide-before-multiply) + } + + function nextLine() public pure returns (uint256) { + // forge-lint: disable-next-line(all, divide-before-multiply) + return (1 / 2) * 3; + } + + // forge-lint: disable-next-item(divide-before-multiply) + function nextItem() public pure returns (uint256) { + return (1 / 2) * 3; + } + + // forge-lint: disable-start(all) + // forge-lint: disable-start(divide-before-multiply) + function blockRange() public pure returns (uint256) { + return (1 / 2) * 3; + } + // forge-lint: disable-end(divide-before-multiply) + // forge-lint: disable-end(all) +} +"#, + ); + + cmd.args(["lint", "--only-lint", "divide-before-multiply", "--report-unused-suppressions"]) + .assert_success() + .stderr_eq(""); +} + +// Nested blocks retain separate directive identities, so an unused inner block is reported even +// when the outer block suppresses a diagnostic. +#[forgetest] +fn report_unused_suppressions_reports_unused_nested_block(prj: _, cmd: _) { + prj.add_source( + "Nested", + r#" +contract Nested { + // forge-lint: disable-start(divide-before-multiply) + function usedOuter() public pure returns (uint256) { + return (1 / 2) * 3; + } + // forge-lint: disable-start(divide-before-multiply) + function unusedInner() public pure returns (uint256) { + return 42; + } + // forge-lint: disable-end(divide-before-multiply) + // forge-lint: disable-end(divide-before-multiply) +} +"#, + ); + + cmd.args(["lint", "--only-lint", "divide-before-multiply", "--report-unused-suppressions"]) + .assert_success() + .stderr_eq(str![[r#" +warning: unused lint suppression for 'divide-before-multiply' + [FILE]:9:5 + │ +9 │ // forge-lint: disable-start(divide-before-multiply) + ╰╴ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + +"#]]); +} + +// Directives without a target are still tracked and reported as unused. +#[forgetest] +fn report_unused_suppressions_reports_missing_targets(prj: _, cmd: _) { + prj.add_source( + "MissingTargets", + concat!( + "contract MissingTargets {}\n", + "// forge-lint: disable-next-item(divide-before-multiply)\n", + "// forge-lint: disable-next-line(divide-before-multiply)", + ), + ); + + cmd.args(["lint", "--only-lint", "divide-before-multiply", "--report-unused-suppressions"]) + .assert_success() + .stderr_eq(str![[r#" +warning: unused lint suppression for 'divide-before-multiply' + [FILE]:4:1 + │ +4 │ // forge-lint: disable-next-item(divide-before-multiply) + ╰╴━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +warning: unused lint suppression for 'divide-before-multiply' + [FILE]:5:1 + │ +5 │ // forge-lint: disable-next-line(divide-before-multiply) + ╰╴━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + +"#]]); +} + +// Catch-all suppressions are not assessed in a source where the selected lint is ineligible. +#[forgetest] +fn report_unused_suppressions_ignores_sources_without_active_lints(prj: _, cmd: _) { + let script = prj.add_script( + "NoActiveLints", + r#" +contract NoActiveLints { + function f() public pure returns (uint256) { + // forge-lint: disable-next-line(all) + return 42; + } +} +"#, + ); + + cmd.args([ + "lint", + script.to_str().unwrap(), + "--only-lint", + "divide-before-multiply", + "--report-unused-suppressions", + ]) + .assert_success() + .stderr_eq(""); +} + +// Only suppressions for rules active in the current run are assessed, while import diagnostics +// still credit the importing file's directive. +#[forgetest] +fn report_unused_suppressions_respects_active_lints_and_import_spans(prj: _, cmd: _) { + prj.add_source("Imported", "contract Imported {}\n"); + prj.add_source( + "Importer", + r#" +// forge-lint: disable-next-line(unused-import) +import { Imported } from "./Imported.sol"; + +contract Importer { + // forge-lint: disable-next-line(unsafe-typecast) + function f() public pure returns (uint256) { return 42; } +} +"#, + ); + + cmd.args(["lint", "--only-lint", "unused-import", "--report-unused-suppressions"]) + .assert_success() + .stderr_eq(""); +} + +// Repeated paths share one suppression state and cannot create a false unused warning. +#[forgetest] +fn report_unused_suppressions_deduplicates_input_paths(prj: _, cmd: _) { + let source = prj.add_source( + "Duplicate", + r#" +contract Duplicate { + function f() public pure returns (uint256) { + // forge-lint: disable-next-line(divide-before-multiply) + return (1 / 2) * 3; + } +} +"#, + ); + let source = source.to_str().unwrap(); + + cmd.args([ + "lint", + source, + source, + "--only-lint", + "divide-before-multiply", + "--report-unused-suppressions", + ]) + .assert_success() + .stderr_eq(""); +} + +// Unused suppression warnings use the regular diagnostic emitter and participate in deny handling. +#[forgetest] +fn report_unused_suppressions_supports_json_and_deny(prj: _, cmd: _) { + prj.add_source( + "Json", + r#" +contract Json { + // forge-lint: disable-next-item(divide-before-multiply) + function f() public pure returns (uint256) { return 42; } +} +"#, + ); + + cmd.args([ + "lint", + "--only-lint", + "divide-before-multiply", + "--report-unused-suppressions", + "--deny", + "warnings", + ]) + .assert_failure() + .stderr_eq(str![[r#" +warning: unused lint suppression for 'divide-before-multiply' + [FILE]:5:5 + │ +5 │ // forge-lint: disable-next-item(divide-before-multiply) + ╰╴ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Error: aborting due to 1 linter warning(s) + +"#]]); + + cmd.forge_fuse() + .args([ + "lint", + "--only-lint", + "divide-before-multiply", + "--report-unused-suppressions", + "--json", + "--deny", + "warnings", + ]) + .assert_json_stdout_with_status( + false, + str![[r#" +{ + "$message_type": "diagnostic", + "message": "unused lint suppression for 'divide-before-multiply'", + "code": null, + "level": "warning", + "spans": [ + { + "file_name": "src/Json.sol", + "byte_start": 92, + "byte_end": 148, + "line_start": 5, + "line_end": 5, + "column_start": 5, + "column_end": 61, + "is_primary": true, + "text": [ + { + "text": " // forge-lint: disable-next-item(divide-before-multiply)", + "highlight_start": 5, + "highlight_end": 61 + } + ], + "label": null, + "suggested_replacement": null, + "suggestion_applicability": null, + "expansion": null + } + ], + "children": [], + "rendered": "warning: unused lint suppression for 'divide-before-multiply'\n ╭▸ src/Json.sol:5:5\n │\n5 │ // forge-lint: disable-next-item(divide-before-multiply)\n ╰╴ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n" +} +"#]], + ) + .stderr_eq(str![[r#" +Error: aborting due to 1 linter warning(s) + +"#]]); +} diff --git a/crates/forge/tests/cli/lint/geiger.rs b/crates/forge/tests/cli/lint/geiger.rs new file mode 100644 index 0000000000000..7a3d5df3df7bd --- /dev/null +++ b/crates/forge/tests/cli/lint/geiger.rs @@ -0,0 +1,111 @@ +#[forgetest_init] +fn call(prj: _, cmd: _) { + prj.add_test( + "call.t.sol", + r#" + import {Test} from "forge-std/Test.sol"; + + contract A is Test { + function do_ffi() public { + string[] memory inputs = new string[](1); + vm.ffi(inputs); + } + } + "#, + ); + + cmd.arg("geiger").assert_failure().stderr_eq(str![[r#" +... +note[unsafe-cheatcode]: usage of unsafe cheatcodes that can perform dangerous operations + [FILE]:9:20 + │ +9 │ vm.ffi(inputs); + │ ━━━ + │ + ╰ help: https://getfoundry.sh/forge/linting/unsafe-cheatcode + +Error: aborting due to 1 linter note(s) +... +"#]]); +} + +#[forgetest_init] +fn assignment(prj: _, cmd: _) { + prj.add_test( + "assignment.t.sol", + r#" + import {Test} from "forge-std/Test.sol"; + + contract A is Test { + function do_ffi() public returns (bytes memory) { + string[] memory inputs = new string[](1); + bytes memory stuff = vm.ffi(inputs); + return stuff; + } + } + "#, + ); + + cmd.arg("geiger").assert_failure().stderr_eq(str![[r#" +... +note[unsafe-cheatcode]: usage of unsafe cheatcodes that can perform dangerous operations + [FILE]:9:41 + │ +9 │ bytes memory stuff = vm.ffi(inputs); + │ ━━━ + │ + ╰ help: https://getfoundry.sh/forge/linting/unsafe-cheatcode + +Error: aborting due to 1 linter note(s) +... +"#]]); +} + +#[forgetest_init] +fn exit_code(prj: _, cmd: _) { + prj.add_test( + "multiple.t.sol", + r#" + import {Test} from "forge-std/Test.sol"; + + contract A is Test { + function do_ffi() public { + string[] memory inputs = new string[](1); + vm.ffi(inputs); + vm.ffi(inputs); + vm.ffi(inputs); + } + } + "#, + ); + + cmd.arg("geiger").assert_failure().stderr_eq(str![[r#" +... +note[unsafe-cheatcode]: usage of unsafe cheatcodes that can perform dangerous operations + [FILE]:9:20 + │ +9 │ vm.ffi(inputs); + │ ━━━ + │ + ╰ help: https://getfoundry.sh/forge/linting/unsafe-cheatcode + +note[unsafe-cheatcode]: usage of unsafe cheatcodes that can perform dangerous operations + [FILE]:10:20 + │ +10 │ vm.ffi(inputs); + │ ━━━ + │ + ╰ help: https://getfoundry.sh/forge/linting/unsafe-cheatcode + +note[unsafe-cheatcode]: usage of unsafe cheatcodes that can perform dangerous operations + [FILE]:11:20 + │ +11 │ vm.ffi(inputs); + │ ━━━ + │ + ╰ help: https://getfoundry.sh/forge/linting/unsafe-cheatcode + +Error: aborting due to 3 linter note(s) +... +"#]]); +} diff --git a/crates/forge/tests/cli/lsp.rs b/crates/forge/tests/cli/lsp.rs new file mode 100644 index 0000000000000..ee11ca3d127a0 --- /dev/null +++ b/crates/forge/tests/cli/lsp.rs @@ -0,0 +1,823 @@ +use super::lsp_client::{LspClient, request}; +use async_lsp::{ + LanguageServer, + lsp_types::{ + ClientCapabilities, DiagnosticSeverity, DidChangeTextDocumentParams, + DidChangeWatchedFilesParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams, + DocumentFormattingParams, FileChangeType, FileEvent, FormattingOptions, + GotoDefinitionParams, GotoDefinitionResponse, InitializeParams, InitializedParams, + Location, OneOf, Position, Range, ReferenceContext, ReferenceParams, RenameParams, + TextDocumentContentChangeEvent, TextDocumentIdentifier, TextDocumentItem, + TextDocumentPositionParams, TextEdit, Url, VersionedTextDocumentIdentifier, WorkspaceEdit, + WorkspaceFolder, WorkspaceSymbolParams, WorkspaceSymbolResponse, + }, +}; +use foundry_common::fs::canonicalize_path; +use std::{ + fs, thread, + time::{Duration, Instant}, +}; + +#[cfg(unix)] +use foundry_test_utils::snapbox::{IntoData, data::DataFormat}; +#[cfg(unix)] +use rexpect::{Encoding, process::wait::WaitStatus, reader::Options, spawn_with_options}; +#[cfg(unix)] +use std::{ + os::unix::fs::{PermissionsExt, symlink}, + path::Path, + process::Command, +}; + +const SYMBOL_TIMEOUT: Duration = Duration::from_secs(10); + +#[cfg(unix)] +#[forgetest] +fn lsp_vscode_opens_current_project_with_bundled_extension(prj: _, cmd: _) { + let home = tempfile::tempdir().unwrap(); + let executables = tempfile::tempdir().unwrap(); + let project = canonicalize_path(prj.root()).unwrap(); + let forge = executables.path().join("standalone forge"); + fs::hard_link(env!("CARGO_BIN_EXE_forge"), &forge).unwrap(); + let forge = canonicalize_path(forge).unwrap(); + let code = executables.path().join("code"); + fs::write( + &code, + r#"#!/bin/sh +printf '%s\n' "$@" > "$FORGE_LSP_TEST_ARGS" +printf '%s\n' "$FOUNDRY_PROFILE" "$FOUNDRY_LSP_FORGE" > "$FORGE_LSP_TEST_PROFILE" +printf '%s\n' "${VSCODE_APPDATA-unset}" "${VSCODE_EXTENSIONS-unset}" "${VSCODE_PORTABLE-unset}" "${VSCODE_IPC_HOOK_CLI-unset}" > "$FORGE_LSP_TEST_ENV" +"#, + ) + .unwrap(); + fs::set_permissions(&code, fs::Permissions::from_mode(0o755)).unwrap(); + let captured_args = executables.path().join("args"); + let captured_profile = executables.path().join("profile"); + let captured_env = executables.path().join("env"); + let mut standalone = Command::new(&forge); + standalone.current_dir(&project).env("NO_COLOR", "1"); + cmd.set_cmd(standalone); + cmd.env("HOME", home.path()); + cmd.env("XDG_DATA_HOME", home.path().join("data")); + cmd.env("PATH", executables.path()); + cmd.env("FORGE_LSP_TEST_ARGS", &captured_args); + cmd.env("FORGE_LSP_TEST_PROFILE", &captured_profile); + cmd.env("FORGE_LSP_TEST_ENV", &captured_env); + cmd.env("VSCODE_APPDATA", "/stale/appdata"); + cmd.env("VSCODE_EXTENSIONS", "/stale/extensions"); + cmd.env("VSCODE_PORTABLE", "/stale/portable"); + cmd.env("VSCODE_IPC_HOOK_CLI", "/stale/ipc-hook"); + cmd.args(["lsp", "--vscode", "--profile", "editor", "--code-path"]).arg(&code); + cmd.assert_empty_stdout(); + + let captured = fs::read_to_string(&captured_args).unwrap(); + let arguments = captured.lines().collect::>(); + assert_eq!(arguments.last().copied(), project.to_str()); + assert!(arguments.contains(&"--new-window")); + let extension = arguments + .windows(2) + .find(|pair| pair[0] == "--extensionDevelopmentPath") + .map(|pair| pair[1]) + .expect("VS Code must load the bundled extension"); + assert!(Path::new(extension).is_absolute()); + assert!(Path::new(extension).join("package.json").is_file()); + assert!(Path::new(extension).join("out/extension.js").is_file()); + assert!(Path::new(extension).join("syntaxes/solidity.json").is_file()); + for (name, expected) in [ + ("LICENSE-MIT", include_bytes!("../../../../LICENSE-MIT").as_slice()), + ("LICENSE-APACHE", include_bytes!("../../../../LICENSE-APACHE").as_slice()), + ] { + assert_eq!(fs::read(Path::new(extension).join(name)).unwrap(), expected, "{name}"); + } + let user_data = arguments + .windows(2) + .find(|pair| pair[0] == "--user-data-dir") + .map(|pair| Path::new(pair[1])) + .expect("VS Code must use a dedicated profile"); + let session = user_data.parent().unwrap(); + let durable_session = canonicalize_path(session).unwrap(); + let data_home = if cfg!(target_os = "macos") { + home.path().join("Library/Application Support") + } else { + home.path().join("data") + }; + assert_eq!( + durable_session.parent().unwrap(), + canonicalize_path(data_home.join("foundry/lsp/vscode")).unwrap() + ); + assert!(fs::symlink_metadata(session).unwrap().file_type().is_symlink()); + let extensions = arguments + .windows(2) + .find(|pair| pair[0] == "--extensions-dir") + .map(|pair| Path::new(pair[1])) + .expect("VS Code must preserve installed extensions in the managed profile"); + assert_data_eq!( + fs::read_to_string(user_data.join("User/settings.json")) + .unwrap() + .into_data() + .is(DataFormat::Json), + serde_json::to_string(&serde_json::json!({ + "solarLsp.forgePath": forge, + "workbench.startupEditor": "none", + })) + .unwrap() + .into_data() + .is(DataFormat::Json), + ); + assert_eq!( + fs::read_to_string(&captured_profile).unwrap(), + format!("editor\n{}\n", forge.display()) + ); + assert_eq!( + fs::read_to_string(&captured_env).unwrap(), + format!("unset\nunset\n{}\nunset\n", user_data.parent().unwrap().display()) + ); + assert!(!project.join(".vscode").exists()); + + // Cache and temporary-link cleanup must preserve the managed profile's editor state. + let settings = user_data.join("User/settings.json"); + let custom_settings = "{\"editor.fontSize\":17}\n"; + fs::write(&settings, custom_settings).unwrap(); + let history = user_data.join("User/History/entry.sol"); + fs::create_dir_all(history.parent().unwrap()).unwrap(); + fs::write(&history, "contract PreviousVersion {}\n").unwrap(); + let installed_extension = extensions.join("installed-extension.txt"); + fs::write(&installed_extension, "user-installed extension\n").unwrap(); + cmd.forge_fuse(); + cmd.env("HOME", home.path()); + cmd.env("XDG_DATA_HOME", home.path().join("data")); + cmd.args(["cache", "clean", "all"]).assert_empty_stdout(); + assert!(!Path::new(extension).exists()); + fs::remove_file(session).unwrap(); + assert_eq!( + fs::read_to_string(durable_session.join("user-data/User/settings.json")).unwrap(), + custom_settings + ); + + // A terminal needs only `forge lsp`, and reopening restores the short profile link. + let mut terminal = Command::new(&forge); + terminal + .current_dir(&project) + .env("HOME", home.path()) + .env("XDG_DATA_HOME", home.path().join("data")) + .env("PATH", executables.path()) + .env("FOUNDRY_PROFILE", "editor") + .env("FORGE_LSP_TEST_ARGS", &captured_args) + .env("FORGE_LSP_TEST_PROFILE", &captured_profile) + .env("FORGE_LSP_TEST_ENV", &captured_env) + .arg("lsp"); + let mut terminal = spawn_with_options( + terminal, + Options { + timeout_ms: Some(30_000), + strip_ansi_escape_codes: true, + encoding: Encoding::UTF8, + }, + ) + .unwrap(); + terminal.exp_eof().unwrap(); + assert!(matches!(terminal.process.wait().unwrap(), WaitStatus::Exited(_, 0))); + assert_eq!(fs::read_to_string(captured_args).unwrap(), captured); + assert_eq!(fs::read_to_string(settings).unwrap(), custom_settings); + assert_eq!(fs::read_to_string(history).unwrap(), "contract PreviousVersion {}\n"); + assert_eq!(fs::read_to_string(installed_extension).unwrap(), "user-installed extension\n"); + assert_eq!(dunce::canonicalize(session).unwrap(), durable_session); + assert!(Path::new(extension).join("out/extension.js").is_file()); +} + +#[cfg(unix)] +#[forgetest] +fn lsp_vscode_sessions_follow_resolved_editor(prj: _, cmd: _) { + let home = tempfile::tempdir().unwrap(); + let editors = prj.root().join("editors"); + let captured_session = editors.join("session"); + let stable = editors.join("stable/code"); + let insiders = editors.join("insiders/code"); + for code in [&stable, &insiders] { + fs::create_dir_all(code.parent().unwrap()).unwrap(); + fs::write( + code, + "#!/bin/sh\nprintf '%s' \"$VSCODE_PORTABLE\" > \"$FORGE_LSP_TEST_SESSION\"\n", + ) + .unwrap(); + fs::set_permissions(code, fs::Permissions::from_mode(0o755)).unwrap(); + } + + let mut launch = |code: &Path, search_path: &Path| { + cmd.forge_fuse(); + cmd.env("HOME", home.path()); + cmd.env("XDG_DATA_HOME", home.path().join("data")); + cmd.env("PATH", search_path); + cmd.env("FORGE_LSP_TEST_SESSION", &captured_session); + cmd.args(["lsp", "--code-path"]).arg(code).assert_empty_stdout(); + canonicalize_path(fs::read_to_string(&captured_session).unwrap()).unwrap() + }; + let stable_path = stable.parent().unwrap(); + let insiders_path = insiders.parent().unwrap(); + let stable_session = launch(&stable, stable_path); + let insiders_session = launch(&insiders, insiders_path); + assert_ne!(stable_session, insiders_session); + assert_eq!(launch(&stable, insiders_path), stable_session); + assert_eq!(launch(Path::new("code"), stable_path), stable_session); + assert_eq!(launch(Path::new("code"), insiders_path), insiders_session); + assert_eq!(launch(Path::new("editors/stable/code"), insiders_path), stable_session); + assert!(!prj.root().join(".vscode").exists()); +} + +#[cfg(unix)] +#[forgetest] +fn lsp_vscode_preserves_symlink_launchers(prj: _, cmd: _) { + let home = tempfile::tempdir().unwrap(); + let dispatcher = prj.root().join("dispatcher"); + let captured_launcher = prj.root().join("launcher"); + let captured_session = prj.root().join("session"); + fs::write( + &dispatcher, + r#"#!/bin/sh +printf '%s' "$0" > "$FORGE_LSP_TEST_LAUNCHER" +printf '%s' "$VSCODE_PORTABLE" > "$FORGE_LSP_TEST_SESSION" +"#, + ) + .unwrap(); + fs::set_permissions(&dispatcher, fs::Permissions::from_mode(0o755)).unwrap(); + let stable = prj.root().join("code"); + let insiders = prj.root().join("code-insiders"); + symlink(&dispatcher, &stable).unwrap(); + symlink(&dispatcher, &insiders).unwrap(); + + let mut launch = |code: &Path| { + cmd.forge_fuse(); + cmd.env("HOME", home.path()); + cmd.env("XDG_DATA_HOME", home.path().join("data")); + cmd.env("FORGE_LSP_TEST_LAUNCHER", &captured_launcher); + cmd.env("FORGE_LSP_TEST_SESSION", &captured_session); + cmd.args(["lsp", "--code-path"]).arg(code).assert_empty_stdout(); + assert_eq!(fs::read_to_string(&captured_launcher).unwrap(), code.to_str().unwrap()); + canonicalize_path(fs::read_to_string(&captured_session).unwrap()).unwrap() + }; + let stable_session = launch(&stable); + let insiders_session = launch(&insiders); + assert_ne!(stable_session, insiders_session); + assert_eq!(launch(&stable), stable_session); + + // Repointing the same launcher must not inherit the previous editor's profile. + let other_dispatcher = prj.root().join("other dispatcher"); + fs::copy(&dispatcher, &other_dispatcher).unwrap(); + fs::remove_file(&stable).unwrap(); + symlink(&other_dispatcher, &stable).unwrap(); + assert_ne!(launch(&stable), stable_session); + assert_eq!(launch(&insiders), insiders_session); + assert!(stable_session.join("user-data/User/settings.json").is_file()); +} + +#[forgetest] +fn lsp_stdio_rejects_editor_launch_options(cmd: _) { + cmd.args(["lsp", "--stdio", "--vscode"]).assert_code(2).stdout_eq(str![""]).stderr_eq(str![[ + r#" +error: the argument '--stdio' cannot be used with '--vscode' + +Usage: forge[..] lsp --stdio [PATH] + +For more information, try '--help'. + +"# + ]]); +} + +#[cfg(unix)] +#[forgetest] +fn lsp_code_path_reports_missing_editor(prj: _, cmd: _) { + let home = tempfile::tempdir().unwrap(); + cmd.env("HOME", home.path()); + cmd.env("XDG_DATA_HOME", home.path().join("data")); + cmd.args(["lsp", "--code-path"]).arg(prj.root().join("missing-vscode")); + cmd.assert_failure().stdout_eq(str![""]).stderr_eq(str![[r#" +Error: Could not launch VS Code using [..]/missing-vscode. Install VS Code and its `code` command, or pass --code-path to the VS Code CLI. Use `forge lsp --stdio` for another editor. + +Context: +- cannot find binary path + +"#]]); + assert!(!prj.root().join(".vscode").exists()); +} + +fn wait_for_workspace_symbols(client: &mut LspClient, expected: &str, unexpected: &str) { + let deadline = Instant::now() + SYMBOL_TIMEOUT; + let mut last_names = Vec::new(); + while Instant::now() < deadline { + let response = request( + &client.runtime, + client.server.symbol(WorkspaceSymbolParams { + query: String::new(), + ..WorkspaceSymbolParams::default() + }), + ); + last_names = match response { + None => Vec::new(), + Some(WorkspaceSymbolResponse::Flat(symbols)) => { + symbols.into_iter().map(|symbol| symbol.name).collect() + } + Some(WorkspaceSymbolResponse::Nested(symbols)) => { + symbols.into_iter().map(|symbol| symbol.name).collect() + } + }; + if last_names.iter().any(|name| name == expected) + && last_names.iter().all(|name| name != unexpected) + { + return; + } + thread::sleep(Duration::from_millis(20)); + } + + panic!( + "expected workspace symbol `{expected}` without `{unexpected}`; observed: {last_names:?}" + ); +} + +#[forgetest] +fn lsp_indexes_closed_tests_and_scripts(prj: _) { + // Test both default directories and directories resolved from the selected profile. + for (profile, sources, tests, scripts) in + [("default", "src", "test", "script"), ("custom", "contracts", "checks", "deployments")] + { + prj.create_file( + "foundry.toml", + "[profile.default]\n[profile.custom]\nsrc = \"contracts\"\ntest = \"checks\"\nscript = \"deployments\"\n", + ); + let files = [ + ( + format!("{sources}/Counter.sol"), + "// SPDX-License-Identifier: MIT\npragma solidity >=0.8.0;\ncontract CoverageCounter {\n function increment() public {}\n}\n".to_owned(), + ), + ( + format!("{tests}/Counter.t.sol"), + format!("// SPDX-License-Identifier: MIT\npragma solidity >=0.8.0;\nimport {{CoverageCounter}} from \"../{sources}/Counter.sol\";\ncontract CoverageTest {{\n function check(CoverageCounter counter) public {{ counter.increment(); }}\n}}\n"), + ), + ( + format!("{scripts}/Counter.s.sol"), + format!("// SPDX-License-Identifier: MIT\npragma solidity >=0.8.0;\nimport {{CoverageCounter}} from \"../{sources}/Counter.sol\";\ncontract CoverageScript {{\n function run(CoverageCounter counter) public {{ counter.increment(); }}\n}}\n"), + ), + ]; + for (path, source) in &files { + prj.create_file(path, source); + } + let root = canonicalize_path(prj.root()).unwrap(); + let locations: Vec<_> = files + .iter() + .map(|(path, source)| { + let (line, column) = source + .lines() + .enumerate() + .find_map(|(line, text)| text.find("increment").map(|column| (line, column))) + .unwrap(); + Location { + uri: Url::from_file_path(root.join(path)).unwrap(), + range: Range::new( + Position::new(line as u32, column as u32), + Position::new(line as u32, (column + "increment".len()) as u32), + ), + } + }) + .collect(); + let empty_path = tempfile::tempdir().unwrap(); + let mut client = + LspClient::spawn(&root, empty_path.path(), &["lsp", "--stdio", "--profile", profile]); + request( + &client.runtime, + client.server.initialize(InitializeParams { + capabilities: ClientCapabilities::default(), + workspace_folders: Some(vec![WorkspaceFolder { + uri: Url::from_directory_path(&root).unwrap(), + name: "fixture".into(), + }]), + ..InitializeParams::default() + }), + ); + client.server.initialized(InitializedParams {}).unwrap(); + client.wait_for_log_message(); + client + .server + .did_open(DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri: locations[0].uri.clone(), + language_id: "solidity".into(), + version: 1, + text: files[0].1.clone(), + }, + }) + .unwrap(); + + for state in ["never opened", "opened", "closed again"] { + for (location, (_, source)) in locations[1..].iter().zip(&files[1..]) { + match state { + "opened" => client + .server + .did_open(DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri: location.uri.clone(), + language_id: "solidity".into(), + version: 1, + text: source.clone(), + }, + }) + .unwrap(), + "closed again" => client + .server + .did_close(DidCloseTextDocumentParams { + text_document: TextDocumentIdentifier { uri: location.uri.clone() }, + }) + .unwrap(), + _ => {} + } + } + let position = TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: locations[0].uri.clone() }, + position: locations[0].range.start, + }; + // References and rename wait for the latest analysis, including didClose. + let mut references = request( + &client.runtime, + client.server.references(ReferenceParams { + text_document_position: position.clone(), + context: ReferenceContext { include_declaration: true }, + work_done_progress_params: Default::default(), + partial_result_params: Default::default(), + }), + ) + .unwrap(); + let mut expected = locations.clone(); + references.sort_by(|a, b| a.uri.cmp(&b.uri)); + expected.sort_by(|a, b| a.uri.cmp(&b.uri)); + assert_eq!(references, expected, "{profile}: {state}"); + + let edits = request( + &client.runtime, + client.server.rename(RenameParams { + text_document_position: position, + new_name: "increase".into(), + work_done_progress_params: Default::default(), + }), + ) + .unwrap(); + assert_eq!( + edits, + WorkspaceEdit { + changes: Some( + locations + .iter() + .map(|location| ( + location.uri.clone(), + vec![TextEdit { + range: location.range, + new_text: "increase".into() + }], + )) + .collect() + ), + ..WorkspaceEdit::default() + }, + "{profile}: {state}" + ); + + let symbols = request( + &client.runtime, + client.server.symbol(WorkspaceSymbolParams { + query: "Coverage".into(), + ..WorkspaceSymbolParams::default() + }), + ) + .unwrap(); + let mut names: Vec<_> = match symbols { + WorkspaceSymbolResponse::Flat(symbols) => { + symbols.into_iter().map(|symbol| symbol.name).collect() + } + WorkspaceSymbolResponse::Nested(symbols) => { + symbols.into_iter().map(|symbol| symbol.name).collect() + } + }; + names.sort(); + assert_eq!( + names, + ["CoverageCounter", "CoverageScript", "CoverageTest"], + "{profile}: {state}" + ); + } + client.shutdown(); + } +} + +#[test] +fn lsp_profile_selects_workspace_sources() { + let project = tempfile::tempdir().unwrap(); + let project_root = dunce::canonicalize(project.path()).unwrap(); + fs::write( + project_root.join("foundry.toml"), + "[profile.default]\nsrc = \"default-src\"\n[profile.custom]\nextends = \"base.toml\"\n", + ) + .unwrap(); + fs::write(project_root.join("base.toml"), "[profile.custom]\nsrc = \"custom-src\"\n").unwrap(); + fs::create_dir_all(project_root.join("default-src")).unwrap(); + fs::create_dir_all(project_root.join("custom-src")).unwrap(); + fs::write(project_root.join("default-src/Default.sol"), "contract DefaultContract {}\n") + .unwrap(); + fs::write(project_root.join("custom-src/Custom.sol"), "contract CustomContract {}\n").unwrap(); + + let empty_path = tempfile::tempdir().unwrap(); + let mut client = LspClient::spawn( + &project_root, + empty_path.path(), + &["lsp", "--stdio", "--profile", "custom"], + ); + let initialize = request( + &client.runtime, + client.server.initialize(InitializeParams { + capabilities: ClientCapabilities::default(), + workspace_folders: Some(vec![WorkspaceFolder { + uri: Url::from_directory_path(&project_root).unwrap(), + name: "fixture".into(), + }]), + ..InitializeParams::default() + }), + ); + assert!(initialize.capabilities.workspace_symbol_provider.is_some()); + client.server.initialized(InitializedParams {}).unwrap(); + client.wait_for_log_message(); + + wait_for_workspace_symbols(&mut client, "CustomContract", "DefaultContract"); + client.shutdown(); +} + +#[test] +fn lsp_reloads_host_resolved_config_after_manifest_change() { + let project = tempfile::tempdir().unwrap(); + let project_root = dunce::canonicalize(project.path()).unwrap(); + let manifest = project_root.join("foundry.toml"); + fs::write(&manifest, "[profile.default]\nsrc = \"old-src\"\n").unwrap(); + fs::create_dir_all(project_root.join("old-src")).unwrap(); + fs::create_dir_all(project_root.join("new-src")).unwrap(); + fs::write(project_root.join("old-src/Old.sol"), "contract OldContract {}\n").unwrap(); + fs::write(project_root.join("new-src/New.sol"), "contract NewContract {}\n").unwrap(); + + let empty_path = tempfile::tempdir().unwrap(); + let mut client = LspClient::spawn(&project_root, empty_path.path(), &["lsp", "--stdio"]); + let initialize = request( + &client.runtime, + client.server.initialize(InitializeParams { + capabilities: ClientCapabilities::default(), + workspace_folders: Some(vec![WorkspaceFolder { + uri: Url::from_directory_path(&project_root).unwrap(), + name: "fixture".into(), + }]), + ..InitializeParams::default() + }), + ); + assert!(initialize.capabilities.workspace_symbol_provider.is_some()); + client.server.initialized(InitializedParams {}).unwrap(); + client.wait_for_log_message(); + wait_for_workspace_symbols(&mut client, "OldContract", "NewContract"); + + fs::write(&manifest, "[profile.default]\nsrc = \"new-src\"\n").unwrap(); + client + .server + .did_change_watched_files(DidChangeWatchedFilesParams { + changes: vec![FileEvent { + uri: Url::from_file_path(&manifest).unwrap(), + typ: FileChangeType::CHANGED, + }], + }) + .unwrap(); + wait_for_workspace_symbols(&mut client, "NewContract", "OldContract"); + client.shutdown(); +} + +#[cfg(unix)] +#[test] +fn lsp_preserves_aliased_workspace_root() { + let project = tempfile::tempdir().unwrap(); + let project_root = dunce::canonicalize(project.path()).unwrap(); + fs::write(project_root.join("foundry.toml"), "[profile.default]\nsrc = \"src\"\n").unwrap(); + fs::create_dir_all(project_root.join("src")).unwrap(); + fs::write(project_root.join("src/Alias.sol"), "contract AliasContract {}\n").unwrap(); + + let alias_parent = tempfile::tempdir().unwrap(); + let alias_root = alias_parent.path().join("project-alias"); + symlink(&project_root, &alias_root).unwrap(); + + let empty_path = tempfile::tempdir().unwrap(); + let mut client = LspClient::spawn(&alias_root, empty_path.path(), &["lsp", "--stdio"]); + let initialize = request( + &client.runtime, + client.server.initialize(InitializeParams { + capabilities: ClientCapabilities::default(), + workspace_folders: Some(vec![WorkspaceFolder { + uri: Url::from_directory_path(&alias_root).unwrap(), + name: "fixture".into(), + }]), + ..InitializeParams::default() + }), + ); + assert!(initialize.capabilities.workspace_symbol_provider.is_some()); + client.server.initialized(InitializedParams {}).unwrap(); + client.wait_for_log_message(); + + wait_for_workspace_symbols(&mut client, "AliasContract", "MissingContract"); + client.shutdown(); +} + +#[test] +fn lsp_stdio_handshake_uses_only_lsp_stdout() { + let project = tempfile::tempdir().unwrap(); + fs::write(project.path().join(".env"), "FOUNDRY_PROFILE=default\n").unwrap(); + fs::write(project.path().join("foundry.toml"), "[profile.default]\nevm_version = \"cancun\"\n") + .unwrap(); + fs::create_dir_all(project.path().join("src")).unwrap(); + fs::write(project.path().join("src/Example.sol"), "contract Example {}\n").unwrap(); + + let empty_path = tempfile::tempdir().unwrap(); + for args in [&["lsp"][..], &["lsp", "--stdio"][..]] { + let mut client = LspClient::spawn(project.path(), empty_path.path(), args); + let initialize = request( + &client.runtime, + client.server.initialize(InitializeParams { + capabilities: ClientCapabilities::default(), + workspace_folders: Some(vec![WorkspaceFolder { + uri: Url::from_directory_path(project.path()).unwrap(), + name: "fixture".into(), + }]), + ..InitializeParams::default() + }), + ); + assert!(initialize.capabilities.workspace_symbol_provider.is_some()); + client.server.initialized(InitializedParams {}).unwrap(); + client.wait_for_log_message(); + client.shutdown(); + } +} + +#[forgetest] +fn lsp_reports_unsaved_diagnostics_and_resolves_definition(prj: _) { + prj.create_file("foundry.toml", "[profile.default]\nsrc = \"src\"\n"); + let saved = "contract Saved {}\n"; + prj.create_file("src/Example.sol", saved); + let project_root = canonicalize_path(prj.root()).unwrap(); + let path = project_root.join("src/Example.sol"); + let uri = Url::from_file_path(&path).unwrap(); + let empty_path = tempfile::tempdir().unwrap(); + let mut client = LspClient::spawn(&project_root, empty_path.path(), &["lsp", "--stdio"]); + let initialize = request( + &client.runtime, + client.server.initialize(InitializeParams { + capabilities: ClientCapabilities::default(), + workspace_folders: Some(vec![WorkspaceFolder { + uri: Url::from_directory_path(&project_root).unwrap(), + name: "fixture".into(), + }]), + ..InitializeParams::default() + }), + ); + assert!(initialize.capabilities.definition_provider.is_some()); + client.server.initialized(InitializedParams {}).unwrap(); + client.wait_for_log_message(); + client + .server + .did_open(DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri: uri.clone(), + language_id: "solidity".into(), + version: 1, + text: "contract Example { function read() public { missing(); } }".into(), + }, + }) + .unwrap(); + client.wait_for_diagnostics(&uri, |params| { + params.diagnostics.iter().any(|diag| diag.severity == Some(DiagnosticSeverity::ERROR)) + }); + + let corrected = r#"// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0; +contract Example { + function read() public pure returns (uint256) { return 42; } + function caller() public pure returns (uint256) { + return read(); + } +} +"#; + client + .server + .did_change(DidChangeTextDocumentParams { + text_document: VersionedTextDocumentIdentifier { uri: uri.clone(), version: 2 }, + content_changes: vec![TextDocumentContentChangeEvent { + range: None, + range_length: None, + text: corrected.into(), + }], + }) + .unwrap(); + client.wait_for_diagnostics(&uri, |params| params.diagnostics.is_empty()); + let definition = request( + &client.runtime, + client.server.definition(GotoDefinitionParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + position: Position::new(5, 15), + }, + work_done_progress_params: Default::default(), + partial_result_params: Default::default(), + }), + ) + .expect("the unsaved function call should resolve"); + let locations = match definition { + GotoDefinitionResponse::Scalar(location) => vec![location], + GotoDefinitionResponse::Array(locations) => locations, + GotoDefinitionResponse::Link(links) => links + .into_iter() + .map(|link| Location { uri: link.target_uri, range: link.target_selection_range }) + .collect(), + }; + assert_eq!( + locations, + vec![Location { uri, range: Range::new(Position::new(3, 13), Position::new(3, 17)) }] + ); + assert_eq!(fs::read_to_string(path).unwrap(), saved); + client.shutdown(); +} + +#[forgetest] +fn lsp_formats_unsaved_document_with_nested_foundry_config(prj: _) { + prj.create_file("foundry.toml", "[profile.default]\nsrc = \"src\"\n[fmt]\ntab_width = 6\n"); + prj.create_file( + "nested/foundry.toml", + "[profile.default]\nsrc = \"src\"\n[fmt]\ntab_width = 2\n", + ); + let saved = "contract Saved {}\n"; + prj.create_file("nested/src/Example.sol", saved); + let project_root = canonicalize_path(prj.root()).unwrap(); + let path = project_root.join("nested/src/Example.sol"); + let uri = Url::from_file_path(&path).unwrap(); + let empty_path = tempfile::tempdir().unwrap(); + let mut client = LspClient::spawn(&project_root, empty_path.path(), &["lsp", "--stdio"]); + let initialize = request( + &client.runtime, + client.server.initialize(InitializeParams { + capabilities: ClientCapabilities::default(), + workspace_folders: Some(vec![WorkspaceFolder { + uri: Url::from_directory_path(&project_root).unwrap(), + name: "fixture".into(), + }]), + ..InitializeParams::default() + }), + ); + assert_eq!(initialize.capabilities.document_formatting_provider, Some(OneOf::Left(true))); + client.server.initialized(InitializedParams {}).unwrap(); + client.wait_for_log_message(); + client + .server + .did_open(DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri: uri.clone(), + language_id: "solidity".into(), + version: 1, + text: saved.into(), + }, + }) + .unwrap(); + let unsaved = + "contract Example{uint256 public value;function set(uint256 next) public{value=next;}}"; + client + .server + .did_change(DidChangeTextDocumentParams { + text_document: VersionedTextDocumentIdentifier { uri: uri.clone(), version: 2 }, + content_changes: vec![TextDocumentContentChangeEvent { + range: None, + range_length: None, + text: unsaved.into(), + }], + }) + .unwrap(); + let edits = request( + &client.runtime, + client.server.formatting(DocumentFormattingParams { + text_document: TextDocumentIdentifier { uri }, + options: FormattingOptions { tab_size: 8, insert_spaces: true, ..Default::default() }, + work_done_progress_params: Default::default(), + }), + ) + .expect("Forge should format the unsaved document"); + assert_eq!( + edits, + vec![TextEdit { + range: Range::new(Position::new(0, 0), Position::new(0, unsaved.len() as u32)), + new_text: r#"contract Example { + uint256 public value; + + function set(uint256 next) public { + value = next; + } +} +"# + .into(), + }] + ); + assert_eq!(fs::read_to_string(path).unwrap(), saved); + client.shutdown(); +} diff --git a/crates/forge/tests/cli/main.rs b/crates/forge/tests/cli/main.rs index 4a59aa2b51e63..f9204149601cc 100644 --- a/crates/forge/tests/cli/main.rs +++ b/crates/forge/tests/cli/main.rs @@ -4,6 +4,11 @@ extern crate foundry_test_utils; pub mod constants; pub mod utils; +#[path = "../support/lsp_client.rs"] +mod lsp_client; + +mod backtrace; +mod bind; mod bind_json; mod build; mod cache; @@ -17,18 +22,25 @@ mod debug; mod doc; mod eip712; mod failure_assertions; -mod geiger; mod inline_config; mod install; +mod lsp; + +mod json; mod lint; mod multi_script; +mod precompiles; mod script; +mod selectors; mod soldeer; mod svm; mod test_cmd; mod verify; mod verify_bytecode; mod version; +mod watch; mod ext_integration; +mod fmt; +mod fmt_integration; mod test_optimizer; diff --git a/crates/forge/tests/cli/multi_script.rs b/crates/forge/tests/cli/multi_script.rs index 22c35282c5a08..82554d5151881 100644 --- a/crates/forge/tests/cli/multi_script.rs +++ b/crates/forge/tests/cli/multi_script.rs @@ -1,9 +1,24 @@ //! Contains various tests related to forge script + +use crate::utils::KillOnDrop; +use alloy_primitives::{Address, B256, hex, keccak256}; +use alloy_provider::Provider; use anvil::{NodeConfig, spawn}; +use foundry_test_utils::{ + ScriptOutcome, ScriptTester, + rpc::{ + spawn_rpc_proxy_blocking_first_submission, spawn_rpc_proxy_recording_method, + spawn_rpc_proxy_rejecting_method_when_enabled, + }, +}; +use serde_json::Value; +use std::{collections::HashSet, sync::atomic::Ordering, time::Duration}; -use foundry_test_utils::{ScriptOutcome, ScriptTester}; +#[cfg(unix)] +use std::os::unix::process::ExitStatusExt; -forgetest_async!(can_deploy_multi_chain_script_without_lib, |prj, cmd| { +#[forgetest] +async fn can_deploy_multi_chain_script_without_lib(prj: _, cmd: _) { let (api1, handle1) = spawn(NodeConfig::test()).await; let (api2, handle2) = spawn(NodeConfig::test()).await; let mut tester = ScriptTester::new_broadcast_without_endpoint(cmd, prj.root()); @@ -20,9 +35,10 @@ forgetest_async!(can_deploy_multi_chain_script_without_lib, |prj, cmd| { assert_eq!(api2.transaction_count(tester.accounts_pub[0], None).await.unwrap().to::(), 2); assert_eq!(api2.transaction_count(tester.accounts_pub[1], None).await.unwrap().to::(), 3); -}); +} -forgetest_async!(can_not_deploy_multi_chain_script_with_lib, |prj, cmd| { +#[forgetest] +async fn can_not_deploy_multi_chain_script_with_lib(prj: _, cmd: _) { let (_, handle1) = spawn(NodeConfig::test()).await; let (_, handle2) = spawn(NodeConfig::test()).await; let mut tester = ScriptTester::new_broadcast_without_endpoint(cmd, prj.root()); @@ -34,9 +50,10 @@ forgetest_async!(can_not_deploy_multi_chain_script_with_lib, |prj, cmd| { .add_sig("MultiChainBroadcastLink", "deploy(string memory,string memory)") .args(&[&handle1.http_endpoint(), &handle2.http_endpoint()]) .broadcast(ScriptOutcome::UnsupportedLibraries); -}); +} -forgetest_async!(can_not_change_fork_during_broadcast, |prj, cmd| { +#[forgetest] +async fn can_not_change_fork_during_broadcast(prj: _, cmd: _) { let (_, handle1) = spawn(NodeConfig::test()).await; let (_, handle2) = spawn(NodeConfig::test()).await; let mut tester = ScriptTester::new_broadcast_without_endpoint(cmd, prj.root()); @@ -48,9 +65,10 @@ forgetest_async!(can_not_change_fork_during_broadcast, |prj, cmd| { .add_sig("MultiChainBroadcastNoLink", "deployError(string memory,string memory)") .args(&[&handle1.http_endpoint(), &handle2.http_endpoint()]) .broadcast(ScriptOutcome::ErrorSelectForkOnBroadcast); -}); +} -forgetest_async!(can_resume_multi_chain_script, |prj, cmd| { +#[forgetest] +async fn can_resume_multi_chain_script(prj: _, cmd: _) { let (_, handle1) = spawn(NodeConfig::test()).await; let (_, handle2) = spawn(NodeConfig::test()).await; let mut tester = ScriptTester::new_broadcast_without_endpoint(cmd, prj.root()); @@ -63,4 +81,253 @@ forgetest_async!(can_resume_multi_chain_script, |prj, cmd| { .await .arg("--multi") .resume(ScriptOutcome::OkBroadcast); -}); +} + +#[forgetest] +async fn resume_multi_chain_does_not_replay_completed_chain(prj: _, cmd: _) { + let (api1, handle1) = spawn(NodeConfig::test()).await; + let (api2, handle2) = spawn(NodeConfig::test()).await; + let (rpc1, chain1_submissions) = + spawn_rpc_proxy_recording_method(handle1.http_endpoint(), "eth_sendRawTransaction").await; + let (recording_rpc2, chain2_submissions) = + spawn_rpc_proxy_recording_method(handle2.http_endpoint(), "eth_sendRawTransaction").await; + let (rpc2, reject_chain2) = + spawn_rpc_proxy_rejecting_method_when_enabled(recording_rpc2, "eth_sendRawTransaction") + .await; + reject_chain2.store(true, Ordering::SeqCst); + + let mut tester = ScriptTester::new_broadcast_without_endpoint(cmd, prj.root()); + tester + .load_private_keys(&[0, 1]) + .await + .add_sig("MultiChainBroadcastNoLink", "deploy(string memory,string memory)") + .args(&[&rpc1, &rpc2]) + .arg("--broadcast"); + let stderr = + String::from_utf8_lossy(&tester.cmd.assert_failure().get_output().stderr).into_owned(); + assert!(stderr.contains("method is not allowed"), "{stderr}"); + + assert_eq!(api1.transaction_count(tester.accounts_pub[0], None).await.unwrap().to::(), 1); + assert_eq!(api1.transaction_count(tester.accounts_pub[1], None).await.unwrap().to::(), 1); + assert_eq!(api2.transaction_count(tester.accounts_pub[0], None).await.unwrap().to::(), 0); + assert_eq!(api2.transaction_count(tester.accounts_pub[1], None).await.unwrap().to::(), 0); + assert_eq!(chain1_submissions.lock().unwrap().len(), 2); + assert!(chain2_submissions.lock().unwrap().is_empty()); + + let path = foundry_common::fs::json_files(&prj.root().join("broadcast/multi")) + .find(|path| path.to_string_lossy().contains("-latest")) + .expect("no latest multi-chain broadcast artifact"); + let sequence: Value = foundry_common::fs::read_json_file(&path).unwrap(); + let deployments = sequence["deployments"].as_array().unwrap(); + assert_eq!(deployments.len(), 2); + assert_eq!(deployments[0]["receipts"].as_array().unwrap().len(), 2); + assert!(deployments[1]["receipts"].as_array().unwrap().is_empty()); + let completed_operations = deployments[0]["transactions"] + .as_array() + .unwrap() + .iter() + .map(|transaction| { + ( + transaction["hash"].clone(), + transaction["contractAddress"].clone(), + transaction["transaction"]["from"].clone(), + transaction["transaction"]["input"].clone(), + transaction["transaction"]["nonce"].clone(), + transaction["transaction"]["chainId"].clone(), + ) + }) + .collect::>(); + let completed_receipts = deployments[0]["receipts"].clone(); + + reject_chain2.store(false, Ordering::SeqCst); + tester.clear(); + tester + .load_private_keys(&[0, 1]) + .await + .add_sig("MultiChainBroadcastNoLink", "deploy(string memory,string memory)") + .args(&[&rpc1, &rpc2]) + .arg("--multi") + .arg("--resume"); + tester.cmd.assert_success(); + + assert_eq!(api1.transaction_count(tester.accounts_pub[0], None).await.unwrap().to::(), 1); + assert_eq!(api1.transaction_count(tester.accounts_pub[1], None).await.unwrap().to::(), 1); + assert_eq!(api2.transaction_count(tester.accounts_pub[0], None).await.unwrap().to::(), 2); + assert_eq!(api2.transaction_count(tester.accounts_pub[1], None).await.unwrap().to::(), 3); + assert_eq!(chain1_submissions.lock().unwrap().len(), 2); + assert_eq!(chain2_submissions.lock().unwrap().len(), 5); + + let sequence: Value = foundry_common::fs::read_json_file(&path).unwrap(); + let deployments = sequence["deployments"].as_array().unwrap(); + let resumed_completed_operations = deployments[0]["transactions"] + .as_array() + .unwrap() + .iter() + .map(|transaction| { + ( + transaction["hash"].clone(), + transaction["contractAddress"].clone(), + transaction["transaction"]["from"].clone(), + transaction["transaction"]["input"].clone(), + transaction["transaction"]["nonce"].clone(), + transaction["transaction"]["chainId"].clone(), + ) + }) + .collect::>(); + assert_eq!(resumed_completed_operations, completed_operations); + assert_eq!(deployments[0]["receipts"], completed_receipts); + assert_eq!(deployments[0]["receipts"].as_array().unwrap().len(), 2); + assert_eq!(deployments[1]["receipts"].as_array().unwrap().len(), 5); + assert!( + deployments.iter().all(|deployment| deployment["pending"].as_array().unwrap().is_empty()) + ); + for (deployment, handle) in deployments.iter().zip([&handle1, &handle2]) { + let provider = handle.http_provider(); + for transaction in deployment["transactions"].as_array().unwrap() { + let address = transaction["contractAddress"] + .as_str() + .expect("deployment transaction is missing its contract address") + .parse::
() + .unwrap(); + assert!(!provider.get_code_at(address).await.unwrap().is_empty()); + } + } +} + +#[forgetest] +async fn resume_multi_chain_after_lost_submission_response(prj: _, cmd: _) { + let (api1, handle1) = spawn(NodeConfig::test()).await; + let (api2, handle2) = spawn(NodeConfig::test()).await; + let (rpc1, chain1_submissions) = + spawn_rpc_proxy_recording_method(handle1.http_endpoint(), "eth_sendRawTransaction").await; + let (rpc2, chain2_submissions, reached, release) = spawn_rpc_proxy_blocking_first_submission( + handle2.http_endpoint(), + "eth_sendRawTransaction", + true, + ) + .await; + let planned_operations = |sequence: &Value| { + sequence["deployments"] + .as_array() + .unwrap() + .iter() + .map(|deployment| { + deployment["transactions"] + .as_array() + .unwrap() + .iter() + .map(|transaction| { + ( + transaction["contractAddress"].clone(), + transaction["transaction"]["from"].clone(), + transaction["transaction"]["input"].clone(), + transaction["transaction"]["nonce"].clone(), + transaction["transaction"]["chainId"].clone(), + ) + }) + .collect::>() + }) + .collect::>() + }; + + let mut tester = ScriptTester::new_broadcast_without_endpoint(cmd, prj.root()); + tester + .load_private_keys(&[0, 1]) + .await + .add_sig("MultiChainBroadcastNoLink", "deploy(string memory,string memory)") + .args(&[&rpc1, &rpc2]) + .arg("--broadcast"); + let mut child = KillOnDrop::spawn(tester.cmd.cmd()); + tokio::time::timeout(Duration::from_secs(60), reached.notified()) + .await + .expect("Forge did not submit to chain 2"); + + // Chain 2 accepted and mined the first submission, but Forge never received the response. + let accepted = chain2_submissions.lock().unwrap()[0][0].clone(); + let accepted_hash = keccak256(hex::decode(accepted.as_str().unwrap()).unwrap()); + tokio::time::timeout(Duration::from_secs(30), async { + while handle2 + .http_provider() + .get_transaction_receipt(accepted_hash) + .await + .unwrap() + .is_none() + { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("chain 2 did not mine the accepted submission"); + assert_eq!(chain1_submissions.lock().unwrap().len(), 2); + let recovery_path = foundry_common::fs::json_files(&prj.root().join("cache")) + .find(|path| path.to_string_lossy().ends_with(".recovery.json")) + .expect("no authoritative recovery snapshot"); + let recovery: Value = foundry_common::fs::read_json_file(&recovery_path).unwrap(); + let attempt = &recovery["deployments"][1]["attempts"][0]; + assert_eq!(attempt["kind"]["kind"], "signed"); + assert_eq!(attempt["kind"]["payload"]["payload"], accepted); + let path = foundry_common::fs::json_files(&prj.root().join("broadcast/multi")) + .find(|path| path.to_string_lossy().contains("-latest")) + .expect("no latest multi-chain broadcast artifact"); + let sequence: Value = foundry_common::fs::read_json_file(&path).unwrap(); + let planned = planned_operations(&sequence); + assert_eq!(planned.iter().map(Vec::len).collect::>(), [2, 5]); + // Forge is still waiting for the first chain 2 response and has not recorded its outcome. + assert_eq!(chain2_submissions.lock().unwrap().len(), 1); + assert!(sequence["deployments"][1]["transactions"][0]["hash"].is_null()); + assert!(sequence["deployments"][1]["pending"].as_array().unwrap().is_empty()); + assert!(sequence["deployments"][1]["receipts"].as_array().unwrap().is_empty()); + + assert!(child.is_running(), "Forge exited before it could be interrupted"); + let output = child.kill_and_wait(); + assert!(!output.status.success(), "Forge unexpectedly succeeded"); + #[cfg(unix)] + assert_eq!(output.status.signal(), Some(9), "Forge was not terminated by SIGKILL"); + release.notify_one(); + + tester.clear(); + tester + .load_private_keys(&[0, 1]) + .await + .add_sig("MultiChainBroadcastNoLink", "deploy(string memory,string memory)") + .args(&[&rpc1, &rpc2]) + .arg("--multi") + .arg("--resume"); + tester.cmd.assert_success(); + + // Chain 1 is not resubmitted, and chain 2 reconciles its mined operation instead of resending + // it, so exactly one payload is submitted per operation. + assert_eq!(chain1_submissions.lock().unwrap().len(), 2); + assert_eq!(chain2_submissions.lock().unwrap().len(), 5); + let chain2_payloads = chain2_submissions + .lock() + .unwrap() + .iter() + .map(|params| params[0].as_str().unwrap().to_string()) + .collect::>(); + assert_eq!(chain2_payloads.len(), 5); + assert_eq!(api1.transaction_count(tester.accounts_pub[0], None).await.unwrap().to::(), 1); + assert_eq!(api1.transaction_count(tester.accounts_pub[1], None).await.unwrap().to::(), 1); + assert_eq!(api2.transaction_count(tester.accounts_pub[0], None).await.unwrap().to::(), 2); + assert_eq!(api2.transaction_count(tester.accounts_pub[1], None).await.unwrap().to::(), 3); + + let sequence: Value = foundry_common::fs::read_json_file(&path).unwrap(); + assert_eq!(planned_operations(&sequence), planned); + let deployments = sequence["deployments"].as_array().unwrap(); + assert_eq!( + deployments[1]["transactions"][0]["hash"].as_str().unwrap().parse::().unwrap(), + accepted_hash + ); + assert_eq!(deployments[0]["receipts"].as_array().unwrap().len(), 2); + assert_eq!(deployments[1]["receipts"].as_array().unwrap().len(), 5); + assert!( + deployments.iter().all(|deployment| deployment["pending"].as_array().unwrap().is_empty()) + ); + for (deployment, handle) in deployments.iter().zip([&handle1, &handle2]) { + let provider = handle.http_provider(); + for transaction in deployment["transactions"].as_array().unwrap() { + let address = transaction["contractAddress"].as_str().unwrap().parse().unwrap(); + assert!(!provider.get_code_at(address).await.unwrap().is_empty()); + } + } +} diff --git a/crates/forge/tests/cli/precompiles.rs b/crates/forge/tests/cli/precompiles.rs new file mode 100644 index 0000000000000..67995aa33e245 --- /dev/null +++ b/crates/forge/tests/cli/precompiles.rs @@ -0,0 +1,842 @@ +//! Contains various tests for `forge test` with precompiles. + +use alloy_chains::NamedChain; +use foundry_evm_networks::NetworkConfigs; +use foundry_test_utils::{rpc::next_rpc_endpoint, str, util::OutputExt}; + +#[forgetest_init] +fn precompile_trace_decoding(prj: _, cmd: _) { + prj.add_test( + "PrecompileTrace.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; + +contract PrecompileCaller { + constructor() { + // 0x01 - ECRECOVER + { + bytes32 hash = keccak256("test message"); + uint8 v = 27; + bytes32 r = bytes32(uint256(1)); + bytes32 s = bytes32(uint256(2)); + address(0x01).staticcall(abi.encode(hash, v, r, s)); + } + + // 0x02 - SHA256 + address(0x02).staticcall(abi.encodePacked("hello")); + + // 0x03 - RIPEMD160 + address(0x03).staticcall(abi.encodePacked("hello")); + + // 0x04 - IDENTITY (datacopy) + address(0x04).staticcall(abi.encodePacked("hello")); + + // 0x05 - MODEXP: compute 2^3 mod 5 = 3 + { + bytes memory modexpInput = abi.encodePacked( + uint256(1), // base length + uint256(1), // exponent length + uint256(1), // modulus length + uint8(2), // base = 2 + uint8(3), // exponent = 3 + uint8(5) // modulus = 5 + ); + address(0x05).staticcall(modexpInput); + } + + // 0x06 - BN254 ADD (ecadd): P + O = P + { + uint256 g1x = 1; + uint256 g1y = 2; + uint256 zerox = 0; + uint256 zeroy = 0; + address(0x06).staticcall(abi.encode(g1x, g1y, zerox, zeroy)); + } + + // 0x07 - BN254 MUL (ecmul): 1 * G = G + { + uint256 g1x = 1; + uint256 g1y = 2; + uint256 scalar = 1; + address(0x07).staticcall(abi.encode(g1x, g1y, scalar)); + } + + // 0x08 - BN254 PAIRING: empty input returns success (1) + address(0x08).staticcall(""); + + // 0x09 - BLAKE2F + { + bytes memory blake2fInput = new bytes(213); + blake2fInput[3] = 0x0c; // 12 rounds + bytes8[8] memory iv = [ + bytes8(0x6a09e667f3bcc908), + bytes8(0xbb67ae8584caa73b), + bytes8(0x3c6ef372fe94f82b), + bytes8(0xa54ff53a5f1d36f1), + bytes8(0x510e527fade682d1), + bytes8(0x9b05688c2b3e6c1f), + bytes8(0x1f83d9abfb41bd6b), + bytes8(0x5be0cd19137e2179) + ]; + for (uint256 i = 0; i < 8; i++) { + for (uint256 j = 0; j < 8; j++) { + blake2fInput[4 + i * 8 + j] = iv[i][j]; + } + } + blake2fInput[212] = 0x01; + address(0x09).staticcall(blake2fInput); + } + + // 0x0B - BLS12-381 G1 ADD (two points at infinity) + address(0x0B).staticcall(new bytes(256)); + + // 0x0C - BLS12-381 G1 MSM + address(0x0C).staticcall(new bytes(160)); + + // 0x0D - BLS12-381 G2 ADD (two points at infinity) + address(0x0D).staticcall(new bytes(512)); + + // 0x0E - BLS12-381 G2 MSM + address(0x0E).staticcall(new bytes(288)); + + // 0x0F - BLS12-381 PAIRING (G1 + G2 infinity points) + address(0x0F).staticcall(new bytes(384)); + + // 0x10 - BLS12-381 MAP FP TO G1 + address(0x10).staticcall(new bytes(64)); + + // 0x11 - BLS12-381 MAP FP2 TO G2 + address(0x11).staticcall(new bytes(128)); + + // 0x100 - P256VERIFY (secp256r1) + address(0x100).staticcall(new bytes(160)); + } +} + +contract OrdinaryCode { + function ordinaryCode() external pure returns (bool) { + return true; + } +} + +contract PrecompileTraceTest is Test { + function test_precompile_traces() public { + new PrecompileCaller(); + } + + function test_inactive_p256_address() public { + OrdinaryCode implementation = new OrdinaryCode(); + vm.etch(address(0x100), address(implementation).code); + assertTrue(OrdinaryCode(address(0x100)).ordinaryCode()); + } + + function test_mocked_p256_address() public { + OrdinaryCode implementation = new OrdinaryCode(); + vm.mockFunction( + address(0x100), + address(implementation), + abi.encodeCall(OrdinaryCode.ordinaryCode, ()) + ); + assertTrue(OrdinaryCode(address(0x100)).ordinaryCode()); + } + + function test_p256_redirected_to_other_precompile() public { + bytes memory callData = abi.encodeCall(OrdinaryCode.ordinaryCode, ()); + vm.mockFunction(address(0x100), address(0x02), callData); + (bool success, bytes memory result) = address(0x100).call(callData); + assertTrue(success); + assertEq(result, abi.encodePacked(sha256(callData))); + } + + function test_mocked_p256_call() public { + bytes memory callData = abi.encodeCall(OrdinaryCode.ordinaryCode, ()); + vm.mockCall(address(0x100), callData, abi.encode(true)); + (bool success, bytes memory result) = address(0x100).call(callData); + assertTrue(success); + assertTrue(abi.decode(result, (bool))); + } + + function test_isolated_p256_call() public { + (bool success,) = address(0x100).call(new bytes(160)); + assertTrue(success); + } + + function test_isolated_inactive_p256_address() public { + OrdinaryCode implementation = new OrdinaryCode(); + vm.etch(address(0x100), address(implementation).code); + (bool success, bytes memory result) = + address(0x100).call(abi.encodeCall(OrdinaryCode.ordinaryCode, ())); + assertTrue(success); + assertTrue(abi.decode(result, (bool))); + } +} + "#, + ); + + cmd.args(["test", "--mt", "test_precompile_traces", "-vvvv", "--evm-version", "osaka"]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/PrecompileTrace.t.sol:PrecompileTraceTest +[PASS] test_precompile_traces() ([GAS]) +Traces: + [..] PrecompileTraceTest::test_precompile_traces() + ├─ [..] → new PrecompileCaller@[..] + │ ├─ [..] PRECOMPILES::ecrecover(0xea83cdcdd06bf61e414054115a551e23133711d0507dcbc07a4bab7dc4581935, 27, 1, 2) [staticcall] + │ │ └─ ← [Return] 0xBe038042508C42Df7b2A529cd4Cc0a9447c7D2b6 + │ ├─ [..] PRECOMPILES::sha256(0x68656c6c6f) [staticcall] + │ │ └─ ← [Return] 0x2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 + │ ├─ [..] PRECOMPILES::ripemd(0x68656c6c6f) [staticcall] + │ │ └─ ← [Return] 0x000000000000000000000000108f07b838241261 + │ ├─ [..] PRECOMPILES::identity(0x68656c6c6f) [staticcall] + │ │ └─ ← [Return] 0x68656c6c6f + │ ├─ [..] PRECOMPILES::modexp(1, 1, 1, 0x02, 0x03, 0x05) [staticcall] + │ │ └─ ← [Return] 0x03 + │ ├─ [..] PRECOMPILES::ecadd(1, 2, 0, 0) [staticcall] + │ │ └─ ← [Return] (1, 2) + │ ├─ [..] PRECOMPILES::ecmul(1, 2, 1) [staticcall] + │ │ └─ ← [Return] (1, 2) + │ ├─ [..] PRECOMPILES::ecpairing() [staticcall] + │ │ └─ ← [Return] true + │ ├─ [..] PRECOMPILES::blake2f(12, [633244976228469098, 4298627039875721147, 3168446158426304060, 17381112106731261861, 15096882533739138641, 2264253069420660123, 7763433881832358687, 8728396173323133019], [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0], 1) [staticcall] + │ │ └─ ← [Return] 0x1a48bfec594a1b13bb024be345656b8af895d662ccbc3f39fb5ecf2ef05942b5acace594cb81cdff6044b5bfaabfea105168676ce5753f6bb559ce3f92ad4850 + │ ├─ [..] PRECOMPILES::bls12G1Add(0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000, 0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000) [staticcall] + │ │ └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 + │ ├─ [..] PRECOMPILES::bls12G1Msm(0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000) [staticcall] + │ │ └─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 + │ ├─ [..] PRECOMPILES::bls12G2Add(0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000, 0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000) [staticcall] + │ │ └─ ← [Return] 0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 + │ ├─ [..] PRECOMPILES::bls12G2Msm(0x000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000) [staticcall] + │ │ └─ ← [Return] 0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 + │ ├─ [..] PRECOMPILES::bls12PairingCheck(0x000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000) [staticcall] + │ │ └─ ← [Return] true + │ ├─ [..] PRECOMPILES::bls12MapFpToG1(0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000) [staticcall] + │ │ └─ ← [Return] 0x0000000000000000000000000000000011a9a0372b8f332d5c30de9ad14e50372a73fa4c45d5f2fa5097f2d6fb93bcac592f2e1711ac43db0519870c7d0ea41500000000000000000000000000000000092c0f994164a0719f51c24ba3788de240ff926b55f58c445116e8bc6a47cd63392fd4e8e22bdf9feaa96ee773222133 + │ ├─ [..] PRECOMPILES::bls12MapFp2ToG2(0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000) [staticcall] + │ │ └─ ← [Return] 0x00000000000000000000000000000000018320896ec9eef9d5e619848dc29ce266f413d02dd31d9b9d44ec0c79cd61f18b075ddba6d7bd20b7ff27a4b324bfce000000000000000000000000000000000a67d12118b5a35bb02d2e86b3ebfa7e23410db93de39fb06d7025fa95e96ffa428a7a27c3ae4dd4b40bd251ac658892000000000000000000000000000000000260e03644d1a2c321256b3246bad2b895cad13890cbe6f85df55106a0d334604fb143c7a042d878006271865bc359410000000000000000000000000000000004c69777a43f0bda07679d5805e63f18cf4e0e7c6112ac7f70266d199b4f76ae27c6269a3ceebdae30806e9a76aadf5c + │ ├─ [..] PRECOMPILES::p256Verify(0x0000000000000000000000000000000000000000000000000000000000000000, 0, 0, 0, 0) [staticcall] + │ │ └─ ← [Return] 0x + │ └─ ← [Return] 62 bytes of code + └─ ← [Stop] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); + + cmd.forge_fuse() + .args(["test", "--mt", "test_inactive_p256_address", "-vvvv", "--evm-version", "prague"]) + .assert_success() + .stdout_eq(str![[r#" +... +Traces: + [..] PrecompileTraceTest::test_inactive_p256_address() + ├─ [..] → new OrdinaryCode@[..] + │ └─ ← [Return] 177 bytes of code + ├─ [0] VM::etch(0x0000000000000000000000000000000000000100, 0x[..]) + │ └─ ← [Return] + ├─ [..] 0x0000000000000000000000000000000000000100::ordinaryCode() [staticcall] + │ └─ ← [Return] true + └─ ← [Stop] + +... +"#]]); + + cmd.forge_fuse() + .args(["test", "--mt", "test_mocked_p256_address", "-vvvv", "--evm-version", "osaka"]) + .assert_success() + .stdout_eq(str![[r#" +... +Traces: + [..] PrecompileTraceTest::test_mocked_p256_address() + ├─ [..] → new OrdinaryCode@[..] + │ └─ ← [Return] 177 bytes of code + ├─ [0] VM::mockFunction(0x0000000000000000000000000000000000000100, OrdinaryCode: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f], 0xf4eb3bbd) + │ └─ ← [Return] + ├─ [..] 0x0000000000000000000000000000000000000100::ordinaryCode() [staticcall] + │ └─ ← [Return] true + └─ ← [Stop] + +... +"#]]); + + cmd.forge_fuse() + .args([ + "test", + "--mt", + "test_p256_redirected_to_other_precompile", + "-vvvv", + "--evm-version", + "osaka", + ]) + .assert_success() + .stdout_eq(str![[r#" +... + ├─ [..] 0x0000000000000000000000000000000000000100::ordinaryCode() + │ └─ ← [Return] true +... +"#]]); + + cmd.forge_fuse() + .args(["test", "--mt", "test_mocked_p256_call", "-vvvv", "--evm-version", "osaka"]) + .assert_success() + .stdout_eq(str![[r#" +... + ├─ [..] 0x0000000000000000000000000000000000000100::ordinaryCode() + │ └─ ← [Return] true +... +"#]]); + + cmd.forge_fuse() + .args([ + "test", + "--mt", + "test_isolated_p256_call", + "-vvvv", + "--evm-version", + "osaka", + "--isolate", + ]) + .assert_success() + .stdout_eq(str![[r#" +... +Traces: + [..] PrecompileTraceTest::test_isolated_p256_call() + ├─ [..] PRECOMPILES::p256Verify(0x0000000000000000000000000000000000000000000000000000000000000000, 0, 0, 0, 0) + │ └─ ← [Return] 0x + └─ ← [Stop] + +... +"#]]); + + cmd.forge_fuse() + .args([ + "test", + "--mt", + "test_isolated_inactive_p256_address", + "-vvvv", + "--evm-version", + "prague", + "--isolate", + ]) + .assert_success() + .stdout_eq(str![[r#" +... + ├─ [..] 0x0000000000000000000000000000000000000100::ordinaryCode() + │ └─ ← [Return] true +... +"#]]); +} + +#[forgetest_init] +fn precompile_cheatcode_load_is_read_only(prj: _, cmd: _) { + prj.add_test( + "PrecompileCheatcodeLoad.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; + +contract PrecompileCheatcodeLoadTest is Test { + address constant ECRECOVER = address(0x01); + bytes32 constant SLOT = bytes32(uint256(1)); + + function test_load_allows_precompile_target() public view { + assertEq(vm.load(ECRECOVER, SLOT), bytes32(0)); + } + + function test_mutation_cheatcodes_reject_precompile_target() public { + (bool storeSuccess,) = address(this).call(abi.encodeCall(this.storePrecompileSlot, ())); + assertFalse(storeSuccess); + + (bool etchSuccess,) = address(this).call(abi.encodeCall(this.etchPrecompile, ())); + assertFalse(etchSuccess); + } + + function storePrecompileSlot() external { + vm.store(ECRECOVER, SLOT, bytes32(uint256(1))); + } + + function etchPrecompile() external { + vm.etch(ECRECOVER, hex"00"); + } +} + "#, + ); + + cmd.args(["test", "--match-contract", "PrecompileCheatcodeLoadTest"]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 2 tests for test/PrecompileCheatcodeLoad.t.sol:PrecompileCheatcodeLoadTest +[PASS] test_load_allows_precompile_target() ([GAS]) +[PASS] test_mutation_cheatcodes_reject_precompile_target() ([GAS]) +Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn tempo_t5_hardfork_precompile_smoke(prj: _, cmd: _) { + prj.update_config(|config| { + config.networks = NetworkConfigs::with_tempo(); + config.hardfork = Some("tempo:T5".parse::().unwrap()); + }); + + prj.add_test( + "TempoT5PrecompileSmoke.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; + +interface IAddressRegistry { + function isImplicitlyApproved(address precompile) external view returns (bool); +} + +interface ITIP20ChannelReserve { + function domainSeparator() external view returns (bytes32); +} + +contract TempoT5PrecompileSmokeTest is Test { + address constant ADDRESS_REGISTRY = address(bytes20(hex"FDC0000000000000000000000000000000000000")); + address constant FEE_MANAGER = address(bytes20(hex"feec000000000000000000000000000000000000")); + address constant STABLECOIN_DEX = address(bytes20(hex"dec0000000000000000000000000000000000000")); + address constant TIP20_CHANNEL_RESERVE = address(bytes20(hex"4D50500000000000000000000000000000000000")); + + function test_t5_hardfork_precompile_smoke() public { + assertGt(TIP20_CHANNEL_RESERVE.code.length, 0); + + IAddressRegistry registry = IAddressRegistry(ADDRESS_REGISTRY); + assertTrue(registry.isImplicitlyApproved(FEE_MANAGER)); + assertTrue(registry.isImplicitlyApproved(STABLECOIN_DEX)); + assertTrue(registry.isImplicitlyApproved(TIP20_CHANNEL_RESERVE)); + + bytes32 separator = ITIP20ChannelReserve(TIP20_CHANNEL_RESERVE).domainSeparator(); + assertTrue(separator != bytes32(0)); + } +} + "#, + ); + + let stdout = cmd + .args(["test", "--mt", "test_t5_hardfork_precompile_smoke", "-vvvv"]) + .assert_success() + .get_output() + .stdout_lossy(); + assert!(stdout.contains("AddressRegistry::isImplicitlyApproved"), "{stdout}"); + assert!(stdout.contains("TIP20ChannelReserve::domainSeparator"), "{stdout}"); +} + +#[forgetest_init] +fn tempo_t6_keychain_helpers_and_decoding(prj: _, cmd: _) { + prj.update_config(|config| { + config.networks = NetworkConfigs::with_tempo(); + config.hardfork = Some("tempo:T6".parse::().unwrap()); + }); + + prj.add_test( + "TempoT6KeychainHelpers.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; + +interface IAccountKeychain { + function isAdminKey(address account, address keyId) external view returns (bool); +} + +interface ISignatureVerifier { + function verifyKeychain(address account, bytes32 hash, bytes calldata signature) external view returns (bool); + function verifyKeychainAdmin(address account, bytes32 hash, bytes calldata signature) external view returns (bool); +} + +interface ITIP403Registry { + function validateReceivePolicy( + address token, + address sender, + address receiver + ) external view returns (bool authorized, uint8 blockedReason); +} + +interface IReceivePolicyGuard { + function balanceOf(bytes calldata receipt) external view returns (uint256 amount); +} + +interface TempoVm { + function signKeychain(uint256 privateKey, address account, bytes32 digest) + external + pure + returns (bytes memory signature); + function signKeychainAdmin(uint256 privateKey, address account, bytes32 digest) + external + pure + returns (bytes memory signature); + function expectKeychainVerified(address account, bytes32 digest, bytes calldata signature) external; + function expectKeychainAdminVerified(address account, bytes32 digest, bytes calldata signature) external; +} + +contract TempoT6KeychainHelpersTest is Test { + TempoVm constant tempoVm = TempoVm(address(bytes20(uint160(uint256(keccak256("hevm cheat code")))))); + + address constant ACCOUNT_KEYCHAIN = address(bytes20(hex"aaaaaaaa00000000000000000000000000000000")); + address constant SIGNATURE_VERIFIER = address(bytes20(hex"5165300000000000000000000000000000000000")); + address constant TIP403_REGISTRY = address(bytes20(hex"403c000000000000000000000000000000000000")); + address constant RECEIVE_POLICY_GUARD = address(bytes20(hex"b10c000000000000000000000000000000000000")); + address constant PATH_USD = address(bytes20(hex"20c0000000000000000000000000000000000000")); + + uint256 constant ROOT_PK = 0xA11CE; + uint256 constant ACCESS_PK = 0xB0B; + + IAccountKeychain constant keychain = IAccountKeychain(ACCOUNT_KEYCHAIN); + ISignatureVerifier constant verifier = ISignatureVerifier(SIGNATURE_VERIFIER); + ITIP403Registry constant registry = ITIP403Registry(TIP403_REGISTRY); + IReceivePolicyGuard constant guard = IReceivePolicyGuard(RECEIVE_POLICY_GUARD); + + address root; + address accessKey; + bytes32 digest; + + function setUp() public { + root = vm.addr(ROOT_PK); + accessKey = vm.addr(ACCESS_PK); + digest = keccak256("tempo t6 forge keychain"); + } + + function test_sign_keychain_signature_shape_and_missing_key_fails() public { + bytes memory signature = tempoVm.signKeychain(ACCESS_PK, root, digest); + + assertEq(signature.length, 86); + assertEq(uint8(signature[0]), 4); + assertEq(_embeddedAccount(signature), root); + assertFalse(verifier.verifyKeychain(root, digest, signature)); + assertFalse(verifier.verifyKeychain(address(0xbeef), digest, signature)); + } + + function test_keychain_admin_signature_verifies_root_key_and_rejects_non_admin() public { + bytes32 adminDigest = _adminDigest("admin"); + bytes memory rootSignature = tempoVm.signKeychainAdmin(ROOT_PK, root, adminDigest); + bytes memory nonAdminSignature = tempoVm.signKeychainAdmin(ACCESS_PK, root, adminDigest); + + assertTrue(keychain.isAdminKey(root, root)); + assertFalse(keychain.isAdminKey(root, accessKey)); + assertTrue(verifier.verifyKeychainAdmin(root, adminDigest, rootSignature)); + assertFalse(verifier.verifyKeychainAdmin(root, adminDigest, nonAdminSignature)); + assertFalse(verifier.verifyKeychainAdmin(address(0xbeef), adminDigest, rootSignature)); + } + + function test_expect_helpers_match_signature_verifier_calls() public { + bytes memory signature = tempoVm.signKeychain(ACCESS_PK, root, digest); + tempoVm.expectKeychainVerified(root, digest, signature); + verifier.verifyKeychain(root, digest, signature); + + bytes32 adminDigest = _adminDigest("expect-admin"); + bytes memory rootSignature = tempoVm.signKeychainAdmin(ROOT_PK, root, adminDigest); + tempoVm.expectKeychainAdminVerified(root, adminDigest, rootSignature); + verifier.verifyKeychainAdmin(root, adminDigest, rootSignature); + } + + function test_malformed_keychain_signature_reverts() public { + vm.expectRevert(); + verifier.verifyKeychain(root, digest, hex"04"); + } + + function test_receive_policy_interfaces_are_callable() public { + (bool authorized, uint8 blockedReason) = registry.validateReceivePolicy(PATH_USD, root, address(0xbeef)); + assertTrue(authorized); + assertEq(blockedReason, 0); + + bytes memory receipt = abi.encode( + uint8(1), + PATH_USD, + address(0), + root, + address(0xbeef), + uint64(block.timestamp), + uint64(1), + uint8(1), + uint8(0), + bytes32("forge") + ); + assertEq(guard.balanceOf(receipt), 0); + } + + function _adminDigest(string memory label) internal view returns (bytes32) { + return keccak256(abi.encode(block.chainid, address(this), root, label)); + } + + function _embeddedAccount(bytes memory signature) internal pure returns (address account) { + assembly { + account := shr(96, mload(add(signature, 0x21))) + } + } +} + "#, + ); + + let stdout = cmd + .args(["test", "--mc", "TempoT6KeychainHelpersTest", "-vvvv"]) + .assert_success() + .get_output() + .stdout_lossy(); + assert!(stdout.contains("AccountKeychain::isAdminKey"), "{stdout}"); + assert!(stdout.contains("SignatureVerifier::verifyKeychain"), "{stdout}"); + assert!(stdout.contains("SignatureVerifier::verifyKeychainAdmin"), "{stdout}"); + assert!(stdout.contains("TIP403Registry::validateReceivePolicy"), "{stdout}"); + assert!(stdout.contains("ReceivePolicyGuard::balanceOf"), "{stdout}"); +} + +#[forgetest_init] +fn tempo_t8_current_committee_decoding(prj: _, cmd: _) { + prj.update_config(|config| { + config.networks = NetworkConfigs::with_tempo(); + config.hardfork = Some("tempo:T8".parse::().unwrap()); + }); + + prj.add_test( + "TempoT8CurrentCommittee.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; + +interface ICurrentCommittee { + error Unauthorized(); + + function getCommitteeMembers() + external + view + returns (uint64 epoch, bytes32[] memory publicKeys); + + function setCommitteeMembers(uint64 epoch, bytes32[] calldata publicKeys) external; +} + +contract TempoT8CurrentCommitteeTest is Test { + ICurrentCommittee constant committee = + ICurrentCommittee(0xC077e00000000000000000000000000000000000); + + function test_get_current_committee() public view { + (uint64 epoch, bytes32[] memory publicKeys) = committee.getCommitteeMembers(); + assertEq(epoch, 0); + assertEq(publicKeys.length, 0); + } + + function test_set_current_committee_is_system_only() public { + bytes32[] memory publicKeys = new bytes32[](1); + publicKeys[0] = bytes32(uint256(0x11)); + + vm.expectRevert(ICurrentCommittee.Unauthorized.selector); + committee.setCommitteeMembers(1, publicKeys); + } +} + "#, + ); + + let stdout = cmd + .args(["test", "--mc", "TempoT8CurrentCommitteeTest", "-vvvv"]) + .assert_success() + .get_output() + .stdout_lossy(); + assert!(stdout.contains("CurrentCommittee::getCommitteeMembers"), "{stdout}"); + assert!(stdout.contains("← [Return] 0, []"), "{stdout}"); + assert!(stdout.contains("CurrentCommittee::setCommitteeMembers(1"), "{stdout}"); + assert!(stdout.contains("← [Revert] Unauthorized()"), "{stdout}"); +} + +// tests transfer using celo precompile. +// +#[forgetest_init] +fn celo_transfer(prj: _, cmd: _) { + prj.update_config(|config| { + config.networks = NetworkConfigs::with_celo(); + }); + + prj.add_test( + "CeloTransfer.t.sol", + r#" +import "forge-std/Test.sol"; + +interface IERC20 { + function balanceOf(address account) external view returns (uint256); + function transfer(address to, uint256 amount) external returns (bool); +} + +contract CeloTransferTest is Test { + IERC20 celo = IERC20(0x471EcE3750Da237f93B8E339c536989b8978a438); + IERC20 usdc = IERC20(0xcebA9300f2b948710d2653dD7B07f33A8B32118C); + IERC20 usdt = IERC20(0x48065fbBE25f71C9282ddf5e1cD6D6A887483D5e); + + address binanceAccount = 0xf6436829Cf96EA0f8BC49d300c536FCC4f84C4ED; + address recipient = makeAddr("recipient"); + + function setUp() public { + vm.createSelectFork("https://forno.celo.org"); + } + + function testCeloBalance() external { + console2.log("recipient balance before", celo.balanceOf(recipient)); + vm.prank(binanceAccount); + celo.transfer(recipient, 100); + console2.log("recipient balance after", celo.balanceOf(recipient)); + assertEq(celo.balanceOf(recipient), 100); + } +} + "#, + ); + + cmd.args(["test", "--mt", "testCeloBalance", "-vvv"]).assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/CeloTransfer.t.sol:CeloTransferTest +[PASS] testCeloBalance() ([GAS]) +Logs: + recipient balance before 0 + recipient balance after 100 + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +} + +#[forgetest_init] +fn flaky_arbitrum_fork_arbsys_arb_block_number(prj: _, cmd: _) { + prj.add_test( + "ArbitrumArbSys.t.sol", + &r#" +import "forge-std/Test.sol"; + +interface ArbSys { + function arbBlockNumber() external view returns (uint256); +} + +contract ArbitrumArbSysTest is Test { + function test_arbitrum_fork_arbsys_arb_block_number() public { + vm.createSelectFork("", 75219831); + + assertEq(ArbSys(address(0x64)).arbBlockNumber(), 75219831); + assertLt(block.number, 75219831); + + (bool success,) = address(0x64).staticcall{gas: 2}( + abi.encodeWithSelector(ArbSys.arbBlockNumber.selector) + ); + assertFalse(success); + + vm.rollFork(75219832); + assertEq(ArbSys(address(0x64)).arbBlockNumber(), 75219832); + } +} + "# + .replace("", &next_rpc_endpoint(NamedChain::Arbitrum)), + ); + + cmd.args(["test", "--mt", "test_arbitrum_fork_arbsys_arb_block_number"]).assert_success(); +} + +// Nitro serves ArbSys as a precompile, so calls to it pay the warm account access cost, and +// `arbBlockNumber()` charges 803 gas: 800 to open the ArbOS state and 3 to copy the result. +// Without a fork it reports the current block, following `vm.roll`. +#[forgetest_init] +fn arbitrum_arbsys_arb_block_number_gas(prj: _, cmd: _) { + prj.add_test( + "ArbSysGas.t.sol", + r#" +import "forge-std/Test.sol"; + +function callArbBlockNumber() view returns (uint256 blockNumber, uint256 gasUsed) { + assembly { + mstore(0, shl(224, 0xa3b1b31d)) + let before := gas() + let success := staticcall(gas(), 0x64, 0, 4, 0, 32) + gasUsed := sub(before, gas()) + if iszero(success) { revert(0, 0) } + blockNumber := mload(0) + } +} + +contract ArbSysCaller { + uint256 calls; + + function arbBlockNumber() external returns (uint256 blockNumber, uint256 gasUsed) { + calls++; + return callArbBlockNumber(); + } +} + +contract ArbSysConstructor { + uint256 public blockNumber; + uint256 public gasUsed; + + constructor() { + (blockNumber, gasUsed) = callArbBlockNumber(); + } +} + +contract ArbSysGasTest is Test { + function test_arbsys_arb_block_number_gas() public { + (uint256 blockNumber, uint256 gasUsed) = callArbBlockNumber(); + assertEq(blockNumber, block.number); + // 100 for the warm access and 803 inside ArbSys, plus the surrounding stack operations. A + // cold access alone would cost 2600. + assertGe(gasUsed, 903); + assertLt(gasUsed, 1000); + + vm.roll(1234); + (blockNumber,) = callArbBlockNumber(); + assertEq(blockNumber, 1234); + } + + function test_arbsys_isolated_call_gas() public { + ArbSysCaller caller = new ArbSysCaller(); + (uint256 blockNumber, uint256 gasUsed) = caller.arbBlockNumber(); + + assertEq(blockNumber, block.number); + assertGe(gasUsed, 903); + assertLt(gasUsed, 1000); + } + + function test_arbsys_isolated_create_gas() public { + ArbSysConstructor created = new ArbSysConstructor(); + + assertEq(created.blockNumber(), block.number); + assertGe(created.gasUsed(), 903); + assertLt(created.gasUsed(), 1000); + } + + function test_arbsys_other_selector_uses_code() public { + vm.etch(address(0x64), hex"602a60005260206000f3"); + + (bool success, bytes memory output) = address(0x64).staticcall(hex"deadbeef"); + assertTrue(success); + assertEq(abi.decode(output, (uint256)), 42); + + (uint256 blockNumber,) = callArbBlockNumber(); + assertEq(blockNumber, block.number); + } +} +"#, + ); + + cmd.env("FOUNDRY_CHAIN_ID", "42161"); + cmd.args(["test", "--mt", "test_arbsys_", "--isolate"]).assert_success().stdout_eq(str![[r#" +... +Ran 4 tests for test/ArbSysGas.t.sol:ArbSysGasTest +[PASS] test_arbsys_arb_block_number_gas() ([GAS]) +[PASS] test_arbsys_isolated_call_gas() ([GAS]) +[PASS] test_arbsys_isolated_create_gas() ([GAS]) +[PASS] test_arbsys_other_selector_uses_code() ([GAS]) +Suite result: ok. 4 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} diff --git a/crates/forge/tests/cli/script.rs b/crates/forge/tests/cli/script.rs index 1339a6ec9791e..c5678f071b85d 100644 --- a/crates/forge/tests/cli/script.rs +++ b/crates/forge/tests/cli/script.rs @@ -1,55 +1,315 @@ //! Contains various tests related to `forge script`. -use crate::constants::TEMPLATE_CONTRACT; +use crate::{ + constants::TEMPLATE_CONTRACT, + utils::{KillOnDrop, assert_debug_dump_identifies_contract, generate_large_runtime_contract}, +}; +use alloy_consensus::{SignableTransaction, TxEip1559, TxEip7702, TxEnvelope}; use alloy_hardforks::EthereumHardfork; -use alloy_primitives::{Address, Bytes, address, hex}; +use alloy_network::{ + Ethereum, ReceiptResponse, TransactionBuilder, TxSignerSync, eip2718::Encodable2718, +}; +use alloy_primitives::{Address, B256, Bytes, TxKind, U256, address, bytes, hex, keccak256}; +use alloy_provider::{Provider, ProviderBuilder}; +use alloy_rpc_types::{AccessList, AccessListItem, Authorization, TransactionRequest}; +use alloy_signer::SignerSync; use anvil::{NodeConfig, spawn}; +use axum::{Router, body::Bytes as BodyBytes, http::StatusCode, response::IntoResponse}; use forge_script_sequence::ScriptSequence; +use foundry_compilers::{PathStyle, artifacts::EvmVersion}; +use foundry_evm::{constants::CALLER, hardfork::TempoHardfork}; use foundry_test_utils::{ ScriptOutcome, ScriptTester, - rpc::{self, next_http_archive_rpc_url}, + rpc::{ + self, next_http_archive_rpc_url, spawn_rpc_proxy_blocking_first_submission, + spawn_rpc_proxy_mapping_method, spawn_rpc_proxy_recording_method, + spawn_rpc_proxy_rejecting_method_after_when_enabled, + }, snapbox::IntoData, - util::{OTHER_SOLC_VERSION, SOLC_VERSION}, + util::{OTHER_SOLC_VERSION, SOLC_VERSION, TestCommand, TestProject}, }; use regex::Regex; +use revm::bytecode::opcode; use serde_json::Value; -use std::{env, fs, path::PathBuf}; - -// Tests that fork cheat codes can be used in script -forgetest_init!( - #[ignore] - can_use_fork_cheat_codes_in_script, - |prj, cmd| { - let script = prj - .add_source( - "Foo", - r#" -import "forge-std/Script.sol"; +use std::{ + env, fs, + path::{Path, PathBuf}, + sync::{ + Arc, + atomic::{AtomicBool, AtomicUsize, Ordering}, + }, + time::Duration, +}; +use tempo_alloy::TempoNetwork; -contract ContractScript is Script { - function setUp() public {} +#[cfg(unix)] +use std::os::unix::process::ExitStatusExt; - function run() public { - uint256 fork = vm.activeFork(); - vm.rollFork(11469702); +#[forgetest] +fn script_slow_help_explains_presimulation(cmd: _) { + cmd.args(["script", "--help"]).assert_success().stderr_eq(str![""]).stdout_eq(str![[r#" +... + --slow + Makes sure a transaction is sent, only after its previous one has been confirmed and + succeeded. +[..] + Transactions are prepared during local script execution, before broadcasting. This flag + does not re-run the script or update transaction destinations and calldata derived from + simulated return values. +[..] + State changes or front-running can make those values stale, even with this flag. + +... +"#]]); +} + +fn latest_dry_run_sequence(root: &Path) -> ScriptSequence { + let path = foundry_common::fs::json_files(&root.join("broadcast")) + .find(|path| path.ends_with("dry-run/run-latest.json")) + .unwrap(); + foundry_common::fs::read_json_file(&path).unwrap() +} + +#[forgetest] +async fn script_debug_dump_identifies_contracts_loaded_from_fork(prj: _, cmd: _) { + prj.add_source( + "ScriptForkDebugTarget.sol", + r#" +contract ScriptForkDebugTarget { + uint256 public value; + + function set(uint256 newValue) public { + value = newValue; } } - "#, - ) - .unwrap(); +"#, + ); + + let (_api, handle) = spawn(NodeConfig::test()).await; + let rpc = handle.http_endpoint(); + let pk = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + + let create_output = cmd + .forge_fuse() + .args([ + "create", + "./src/ScriptForkDebugTarget.sol:ScriptForkDebugTarget", + "--rpc-url", + rpc.as_str(), + "--private-key", + pk, + "--broadcast", + "--json", + ]) + .assert_success() + .get_output() + .stdout + .clone(); + let create_output: Value = serde_json::from_slice(&create_output).unwrap(); + let deployed = create_output["deployedTo"].as_str().unwrap().to_owned(); + + prj.add_script( + "DebugRemote.s.sol", + &format!( + r#" +interface IScriptForkDebugTarget {{ + function set(uint256 newValue) external; + function value() external view returns (uint256); +}} + +contract DebugRemote {{ + IScriptForkDebugTarget private target = IScriptForkDebugTarget({deployed}); + + function setUp() public {{ + target.set(7); + require(target.value() == 7, "setup value"); + }} + + function run() public {{ + target.set(19); + require(target.value() == 19, "value"); + }} +}} +"# + ), + ); + + let dump_path = prj.root().join("script_dump.json"); + cmd.forge_fuse().args([ + "script", + "script/DebugRemote.s.sol:DebugRemote", + "--fork-url", + rpc.as_str(), + "--debug", + "--dump", + dump_path.to_str().unwrap(), + ]); + cmd.assert_success(); + assert_debug_dump_identifies_contract(&dump_path, &deployed, "ScriptForkDebugTarget"); +} + +#[cfg(feature = "monad")] +#[forgetest] +async fn monad_simulation_advances_transaction_context(prj: _, cmd: _) { + let (api, handle) = spawn(NodeConfig::test()).await; + let sender = handle.dev_wallets().next().unwrap().address(); + api.anvil_set_balance(sender, U256::from(12_000_000_000_000_000_000u128)).await.unwrap(); + // Payable runtime calls `dippedIntoReserve()` and reverts when it returns true. + api.anvil_set_code( + address!("0x000000000000000000000000000000000000bEEF"), + bytes!("633a61584e5f5260205f6004601c5f6110015af1505f5115601e575f5ffd5b00"), + ) + .await + .unwrap(); + + prj.add_script( + "SequentialMonadContext.s.sol", + r#" +interface Vm { + function broadcast(uint256 privateKey) external; + function deal(address account, uint256 newBalance) external; +} + +contract SequentialMonadContextScript { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + uint256 constant FIRST_KEY = 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80; + uint256 constant SECOND_KEY = 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d; + uint256 constant THIRD_KEY = 0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a; + address constant FIRST = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266; + address payable constant RECIPIENT = payable(address(0xBEEF)); + + function send(uint256 privateKey, uint256 value) internal { + vm.broadcast(privateKey); + (bool success,) = RECIPIENT.call{value: value}(""); + require(success, "sender dipped unexpectedly"); + } + + function run() external { + // This only affects local script execution, not the separate on-chain simulation runner. + vm.deal(FIRST, 100 ether); + + send(FIRST_KEY, 1 wei); + send(SECOND_KEY, 1 wei); + send(THIRD_KEY, 1 wei); + send(FIRST_KEY, 3 ether); + } +} +"#, + ); + prj.update_config(|config| { + config.hardfork = Some("monad:MonadNine".parse().unwrap()); + }); + + let endpoint = handle.http_endpoint(); + let common = [ + "script", + "SequentialMonadContextScript", + "--rpc-url", + endpoint.as_str(), + "--network", + "monad", + "--non-interactive", + ]; - let rpc = foundry_test_utils::rpc::next_http_rpc_endpoint(); + // Batched simulation keeps all four transactions in one block. The final sender therefore + // cannot dip into reserve after already appearing earlier in that block. + cmd.forge_fuse().args(common).assert_failure(); - cmd.arg("script").arg(script).args(["--fork-url", rpc.as_str(), "-vvvvv"]).assert_success(); + // Slow simulation places one transaction in each block. By the fourth transaction, the first + // sender is outside Monad's parent/grandparent window and may dip again. + cmd.forge_fuse().args(common).arg("--slow").assert_success(); +} + +#[cfg(feature = "monad")] +#[forgetest] +async fn monad_multi_rpc_sequence_uses_per_fork_decoder(prj: _, cmd: _) { + let (monad_eight_api, monad_eight) = + spawn(NodeConfig::test_monad().with_hardfork(Some("monad:MonadEight".parse().unwrap()))) + .await; + let (monad_nine_api, monad_nine) = + spawn(NodeConfig::test_monad().with_hardfork(Some("monad:MonadNine".parse().unwrap()))) + .await; + monad_eight_api.mine_one().await.unwrap(); + monad_nine_api.mine_one().await.unwrap(); + let monad_eight_rpc = monad_eight.http_endpoint(); + let monad_nine_rpc = monad_nine.http_endpoint(); + + let script = r#" +interface Vm { + function createSelectFork(string calldata urlOrAlias) external returns (uint256 forkId); + function startBroadcast() external; + function stopBroadcast() external; +} + +contract PerForkMetadataScript { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + address constant RESERVE_BALANCE = 0x0000000000000000000000000000000000001001; + + function recordReserveCall() internal { + vm.startBroadcast(); + (bool success,) = RESERVE_BALANCE.call(abi.encodeWithSignature("dippedIntoReserve()")); + require(success, "reserve call failed"); + vm.stopBroadcast(); } -); + + function run() external { + vm.createSelectFork(""); + recordReserveCall(); + + vm.createSelectFork(""); + recordReserveCall(); + } +} +"# + .replace("", &monad_eight_rpc) + .replace("", &monad_nine_rpc); + prj.add_script("PerForkMetadata.s.sol", &script); + + let common = [ + "script", + "PerForkMetadataScript", + "--rpc-url", + monad_eight_rpc.as_str(), + "--network", + "monad", + "--non-interactive", + ]; + + let assert_metadata = || { + let run_latest = foundry_common::fs::json_files(&prj.root().join("broadcast")) + .find(|path| path.to_string_lossy().contains("-latest") && path.ends_with("run.json")) + .expect("No multi-RPC broadcast artifact"); + let sequence: Value = foundry_common::fs::read_json_file(&run_latest).unwrap(); + let deployments = sequence["deployments"].as_array().expect("multi-RPC deployments"); + assert_eq!(deployments.len(), 2); + + let monad_eight_tx = &deployments[0]["transactions"][0]; + assert!(monad_eight_tx["function"].is_null()); + assert!(monad_eight_tx["functionAbi"].is_null()); + assert!(monad_eight_tx["arguments"].is_null()); + + let monad_nine_tx = &deployments[1]["transactions"][0]; + assert_eq!(monad_nine_tx["function"], "dippedIntoReserve()"); + assert_eq!( + monad_nine_tx["functionAbi"], + "function dippedIntoReserve() returns (bool dipped)" + ); + assert_eq!(monad_nine_tx["arguments"], serde_json::json!([])); + }; + + cmd.forge_fuse().args(common).assert_success(); + assert_metadata(); + + cmd.forge_fuse().args(common).arg("--skip-simulation").assert_success(); + assert_metadata(); +} // Tests that the `run` command works correctly -forgetest!(can_execute_script_command2, |prj, cmd| { - let script = prj - .add_source( - "Foo", - r#" +#[forgetest] +fn can_execute_script_command2(prj: _, cmd: _) { + let script = prj.add_source( + "Foo", + r#" contract Demo { event log_string(string); function run() external { @@ -57,8 +317,7 @@ contract Demo { } } "#, - ) - .unwrap(); + ); cmd.arg("script").arg(script).assert_success().stdout_eq(str![[r#" [COMPILING_FILES] with [SOLC_VERSION] @@ -71,14 +330,57 @@ Script ran successfully. script ran "#]]); -}); +} + +#[forgetest_init] +fn verbosity_five_shows_script_storage_changes(prj: _, cmd: _) { + let script = prj.add_script( + "StorageChanges", + r#" +import "forge-std/Script.sol"; + +contract Counter { + uint256 public number; + + function setNumber(uint256 newNumber) external { + number = newNumber; + } +} + +contract StorageChanges is Script { + Counter public counter = new Counter(); + + function run() external { + counter.setNumber(1); + } +} + "#, + ); + + let target = format!("{}:StorageChanges", script.display()); + cmd.args(["script", &target, "-vvvvv"]).assert_success().stdout_eq(str![[r#" +... +Traces: + [..] StorageChanges::run() + ├─ [..] Counter::setNumber(1) + │ ├─ storage changes: + │ │ @ 0: 0 → 1 + │ └─ ← [Stop] + └─ ← [Stop] + + +Script ran successfully. +[GAS] + +"#]]); +} // Tests that the `run` command works correctly when path *and* script name is specified -forgetest!(can_execute_script_command_fqn, |prj, cmd| { - let script = prj - .add_source( - "Foo", - r#" +#[forgetest] +fn can_execute_script_command_fqn(prj: _, cmd: _) { + let script = prj.add_source( + "Foo", + r#" contract Demo { event log_string(string); function run() external { @@ -86,8 +388,7 @@ contract Demo { } } "#, - ) - .unwrap(); + ); cmd.arg("script").arg(format!("{}:Demo", script.display())).assert_success().stdout_eq(str![[ r#" @@ -100,14 +401,14 @@ Script ran successfully. ... "# ]]); -}); +} // Tests that the run command can run arbitrary functions -forgetest!(can_execute_script_command_with_sig, |prj, cmd| { - let script = prj - .add_source( - "Foo", - r#" +#[forgetest] +fn can_execute_script_command_with_sig(prj: _, cmd: _) { + let script = prj.add_source( + "Foo", + r#" contract Demo { event log_string(string); function myFunction() external { @@ -115,8 +416,7 @@ contract Demo { } } "#, - ) - .unwrap(); + ); cmd.arg("script").arg(script).arg("--sig").arg("myFunction()").assert_success().stdout_eq( str![[r#" @@ -131,7 +431,7 @@ Script ran successfully. "#]], ); -}); +} static FAILING_SCRIPT: &str = r#" import "forge-std/Script.sol"; @@ -143,86 +443,82 @@ contract FailingScript is Script { } "#; -// Tests that execution throws upon encountering a revert in the script. -forgetest_async!(assert_exit_code_error_on_failure_script, |prj, cmd| { - foundry_test_utils::util::initialize(prj.root()); - let script = prj.add_source("FailingScript", FAILING_SCRIPT).unwrap(); +static OUT_OF_GAS_SCRIPT: &str = r#" +import "forge-std/Script.sol"; - // set up command - cmd.arg("script").arg(script); +contract OutOfGasScript is Script { + function run() external { + uint256 i; + while (true) { + unchecked { + ++i; + } + } + } +} +"#; - // run command and assert error exit code - cmd.assert_failure().stderr_eq(str![[r#" -Error: script failed: failed +static MULTI_DEPLOY_SCRIPT: &str = r#" +import "forge-std/Script.sol"; -"#]]); -}); +contract A { function ping() external pure returns (uint256) { return 1; } } +contract B { address public a; constructor(address _a) { a = _a; } } +contract C { bytes32 public tag; constructor(bytes32 _t) { tag = _t; } } -// Tests that execution throws upon encountering a revert in the script with --json option. -// -forgetest_async!(assert_exit_code_error_on_failure_script_with_json, |prj, cmd| { - foundry_test_utils::util::initialize(prj.root()); - let script = prj.add_source("FailingScript", FAILING_SCRIPT).unwrap(); +contract MultiDeploy is Script { + function run() external { + vm.startBroadcast(); + A a = new A(); + new B(address(a)); + new C(bytes32(uint256(0x1234))); + vm.stopBroadcast(); + } +} +"#; - // set up command - cmd.arg("script").arg(script).arg("--json"); +// Tests that execution throws upon encountering a revert in the script. +#[forgetest_init] +async fn assert_exit_code_error_on_failure_script(prj: _, cmd: _) { + let script = prj.add_source("FailingScript", FAILING_SCRIPT); + + for json in [false, true] { + cmd.forge_fuse().arg("script").arg(&script); + if json { + cmd.arg("--json"); + } - // run command and assert error exit code - cmd.assert_failure().stderr_eq(str![[r#" + cmd.assert_failure().stderr_eq(str![[r#" Error: script failed: failed "#]]); -}); - -// Tests that the manually specified gas limit is used when using the --unlocked option -forgetest_async!(can_execute_script_command_with_manual_gas_limit_unlocked, |prj, cmd| { - foundry_test_utils::util::initialize(prj.root()); - let deploy_script = prj - .add_source( - "Foo", - r#" -import "forge-std/Script.sol"; - -contract GasWaster { - function wasteGas(uint256 minGas) public { - require(gasleft() >= minGas, "Gas left needs to be higher"); - } -} -contract DeployScript is Script { - function run() external { - vm.startBroadcast(); - GasWaster gasWaster = new GasWaster(); - gasWaster.wasteGas{gas: 500000}(200000); } } - "#, - ) - .unwrap(); - let deploy_contract = deploy_script.display().to_string() + ":DeployScript"; +// Tests that script failures surface halt reasons for empty revert data. +#[forgetest_init] +async fn assert_exit_code_error_on_out_of_gas_script(prj: _, cmd: _) { + let script = prj.add_source("OutOfGasScript", OUT_OF_GAS_SCRIPT); + + for json in [false, true] { + // Use a small block gas limit so the infinite loop exhausts gas in milliseconds rather than + // >25s at the default ~1B limit, which often exceeds the nextest slow-timeout window on CI. + cmd.forge_fuse().arg("script").arg(&script).args(["--block-gas-limit", "1000000"]); + if json { + cmd.arg("--json"); + } - let node_config = NodeConfig::test().with_eth_rpc_url(Some(rpc::next_http_archive_rpc_url())); - let (_api, handle) = spawn(node_config).await; - let dev = handle.dev_accounts().next().unwrap(); - cmd.set_current_dir(prj.root()); + cmd.assert_failure().stderr_eq(str![[r#" +Error: script failed: EvmError: OutOfGas - cmd.args([ - "script", - &deploy_contract, - "--root", - prj.root().to_str().unwrap(), - "--fork-url", - &handle.http_endpoint(), - "--sender", - format!("{dev:?}").as_str(), - "-vvvvv", - "--slow", - "--broadcast", - "--unlocked", - "--ignored-error-codes=2018", // `wasteGas` can be restricted to view - ]) - .assert_success() - .stdout_eq(str![[r#" +"#]]); + } +} + +// Tests that the manually specified gas limit is used when using the --unlocked option +#[forgetest_init] +async fn can_execute_script_command_with_manual_gas_limit_unlocked(prj: _, cmd: _) { + let _handle = prepare_manual_gas_limit(&prj, &mut cmd, true).await; + cmd.assert_success().stdout_eq(str![[r#" [COMPILING_FILES] with [SOLC_VERSION] [SOLC_VERSION] [ELAPSED] Compiler run successful! @@ -254,7 +550,9 @@ Simulated On-chain Traces: Chain 1 -[ESTIMATED_GAS_PRICE] +[ESTIMATED_MAX_FEE_PER_GAS] +[ESTIMATED_BASE_FEE_PER_GAS] +[ESTIMATED_PRIORITY_FEE_PER_GAS] [ESTIMATED_TOTAL_GAS_USED] @@ -273,64 +571,21 @@ ONCHAIN EXECUTION COMPLETE & SUCCESSFUL. "#]]); -}); +} // Tests that the manually specified gas limit is used. -forgetest_async!(can_execute_script_command_with_manual_gas_limit, |prj, cmd| { - foundry_test_utils::util::initialize(prj.root()); - let deploy_script = prj - .add_source( - "Foo", - r#" -import "forge-std/Script.sol"; - -contract GasWaster { - function wasteGas(uint256 minGas) public { - require(gasleft() >= minGas, "Gas left needs to be higher"); - } -} -contract DeployScript is Script { - function run() external { - vm.startBroadcast(); - GasWaster gasWaster = new GasWaster(); - gasWaster.wasteGas{gas: 500000}(200000); - } -} - "#, - ) - .unwrap(); - - let deploy_contract = deploy_script.display().to_string() + ":DeployScript"; - - let node_config = NodeConfig::test().with_eth_rpc_url(Some(rpc::next_http_archive_rpc_url())); - let (_api, handle) = spawn(node_config).await; - let private_key = - "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80".to_string(); - cmd.set_current_dir(prj.root()); - - cmd.args([ - "script", - &deploy_contract, - "--root", - prj.root().to_str().unwrap(), - "--fork-url", - &handle.http_endpoint(), - "-vvvvv", - "--slow", - "--broadcast", - "--private-key", - &private_key, - ]) - .assert_success() - .stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful with warnings: -Warning (2018): Function state mutability can be restricted to view - [FILE]:7:5: - | -7 | function wasteGas(uint256 minGas) public { - | ^ (Relevant source part starts here and spans across multiple lines). +#[forgetest_init] +async fn can_execute_script_command_with_manual_gas_limit(prj: _, cmd: _) { + let _handle = prepare_manual_gas_limit(&prj, &mut cmd, false).await; + cmd.assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful with warnings: +Warning (2018): Function state mutability can be restricted to view + [FILE]:7:5: + | +7 | function wasteGas(uint256 minGas) public { + | ^ (Relevant source part starts here and spans across multiple lines). Traces: [..] DeployScript::run() @@ -360,7 +615,9 @@ Simulated On-chain Traces: Chain 1 -[ESTIMATED_GAS_PRICE] +[ESTIMATED_MAX_FEE_PER_GAS] +[ESTIMATED_BASE_FEE_PER_GAS] +[ESTIMATED_PRIORITY_FEE_PER_GAS] [ESTIMATED_TOTAL_GAS_USED] @@ -379,14 +636,14 @@ ONCHAIN EXECUTION COMPLETE & SUCCESSFUL. "#]]); -}); +} // Tests that the run command can run functions with arguments -forgetest!(can_execute_script_command_with_args, |prj, cmd| { - let script = prj - .add_source( - "Foo", - r#" +#[forgetest] +fn can_execute_script_command_with_args(prj: _, cmd: _) { + let script = prj.add_source( + "Foo", + r#" contract Demo { event log_string(string); event log_uint(uint); @@ -397,8 +654,7 @@ contract Demo { } } "#, - ) - .unwrap(); + ); cmd.arg("script") .arg(script) @@ -420,14 +676,48 @@ Script ran successfully. 2 "#]]); -}); +} + +// Tests that the run command can run functions with arguments without specifying the signature +// +#[forgetest] +fn can_execute_script_command_with_args_no_sig(prj: _, cmd: _) { + let script = prj.add_source( + "Foo", + r#" +contract Demo { + event log_string(string); + event log_uint(uint); + function run(uint256 a, uint256 b) external { + emit log_string("script ran"); + emit log_uint(a); + emit log_uint(b); + } +} + "#, + ); + + cmd.arg("script").arg(script).arg("1").arg("2").assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! +Script ran successfully. +[GAS] + +== Logs == + script ran + 1 + 2 + +"#]]); +} // Tests that the run command can run functions with return values -forgetest!(can_execute_script_command_with_returned, |prj, cmd| { - let script = prj - .add_source( - "Foo", - r#" +#[forgetest] +fn can_execute_script_command_with_returned(prj: _, cmd: _) { + let script = prj.add_source( + "Foo", + r#" contract Demo { event log_string(string); function run() external returns (uint256 result, uint8) { @@ -435,8 +725,7 @@ contract Demo { return (255, 3); } }"#, - ) - .unwrap(); + ); cmd.arg("script").arg(script).assert_success().stdout_eq(str![[r#" [COMPILING_FILES] with [SOLC_VERSION] @@ -453,15 +742,14 @@ result: uint256 255 script ran "#]]); -}); +} -forgetest_async!(can_broadcast_script_skipping_simulation, |prj, cmd| { - foundry_test_utils::util::initialize(prj.root()); +#[forgetest_init] +async fn can_broadcast_script_skipping_simulation(prj: _, cmd: _) { // This example script would fail in on-chain simulation - let deploy_script = prj - .add_source( - "DeployScript", - r#" + let deploy_script = prj.add_source( + "DeployScript", + r#" import "forge-std/Script.sol"; contract HashChecker { @@ -486,8 +774,7 @@ contract DeployScript is Script { hashChecker = new HashChecker(); } }"#, - ) - .unwrap(); + ); let deploy_contract = deploy_script.display().to_string() + ":DeployScript"; @@ -522,6 +809,8 @@ Traces: │ └─ ← [Return] ├─ [..] → new HashChecker@[..] │ └─ ← [Return] 718 bytes of code + ├─ storage changes: + │ @ 12: 65537 → [..] └─ ← [Stop] @@ -571,7 +860,7 @@ contract RunScript is Script { }"# .replace("CONTRACT_ADDRESS", contract_address); - let run_script = prj.add_source("RunScript", &run_code).unwrap(); + let run_script = prj.add_source("RunScript", &run_code); let run_contract = run_script.display().to_string() + ":RunScript"; cmd.forge_fuse() @@ -605,51 +894,69 @@ Traces: ├─ [0] VM::roll([..]) │ └─ ← [Return] ├─ [..] [..]::update() + │ ├─ storage changes: + │ │ @ 0: [..] │ └─ ← [Stop] ├─ [..] [..]::checkLastHash() [staticcall] │ └─ ← [Stop] ├─ [0] VM::roll([..]) │ └─ ← [Return] ├─ [..] [..]::update() + │ ├─ storage changes: + │ │ @ 0: [..] │ └─ ← [Stop] ├─ [..] [..]::checkLastHash() [staticcall] │ └─ ← [Stop] ├─ [0] VM::roll([..]) │ └─ ← [Return] ├─ [..] [..]::update() + │ ├─ storage changes: + │ │ @ 0: [..] │ └─ ← [Stop] ├─ [..] [..]::checkLastHash() [staticcall] │ └─ ← [Stop] ├─ [0] VM::roll([..]) │ └─ ← [Return] ├─ [..] [..]::update() + │ ├─ storage changes: + │ │ @ 0: [..] │ └─ ← [Stop] ├─ [..] [..]::checkLastHash() [staticcall] │ └─ ← [Stop] ├─ [0] VM::roll([..]) │ └─ ← [Return] ├─ [..] [..]::update() + │ ├─ storage changes: + │ │ @ 0: [..] │ └─ ← [Stop] ├─ [..] [..]::checkLastHash() [staticcall] │ └─ ← [Stop] ├─ [0] VM::roll([..]) │ └─ ← [Return] ├─ [..] [..]::update() + │ ├─ storage changes: + │ │ @ 0: [..] │ └─ ← [Stop] ├─ [..] [..]::checkLastHash() [staticcall] │ └─ ← [Stop] ├─ [0] VM::roll([..]) │ └─ ← [Return] ├─ [..] [..]::update() + │ ├─ storage changes: + │ │ @ 0: [..] │ └─ ← [Stop] ├─ [..] [..]::checkLastHash() [staticcall] │ └─ ← [Stop] ├─ [0] VM::roll([..]) │ └─ ← [Return] ├─ [..] [..]::update() + │ ├─ storage changes: + │ │ @ 0: [..] │ └─ ← [Stop] ├─ [..] [..]::checkLastHash() [staticcall] │ └─ ← [Stop] + ├─ storage changes: + │ @ 12: 65537 → [..] └─ ← [Stop] @@ -668,9 +975,10 @@ ONCHAIN EXECUTION COMPLETE & SUCCESSFUL. "#]]); -}); +} -forgetest_async!(can_deploy_script_without_lib, |prj, cmd| { +#[forgetest] +async fn can_deploy_script_without_lib(prj: _, cmd: _) { let (_api, handle) = spawn(NodeConfig::test()).await; let mut tester = ScriptTester::new_broadcast(cmd, &handle.http_endpoint(), prj.root()); @@ -682,9 +990,10 @@ forgetest_async!(can_deploy_script_without_lib, |prj, cmd| { .broadcast(ScriptOutcome::OkBroadcast) .assert_nonce_increment(&[(0, 1), (1, 2)]) .await; -}); +} -forgetest_async!(can_deploy_script_with_lib, |prj, cmd| { +#[forgetest] +async fn can_deploy_script_with_lib(prj: _, cmd: _) { let (_api, handle) = spawn(NodeConfig::test()).await; let mut tester = ScriptTester::new_broadcast(cmd, &handle.http_endpoint(), prj.root()); @@ -696,9 +1005,10 @@ forgetest_async!(can_deploy_script_with_lib, |prj, cmd| { .broadcast(ScriptOutcome::OkBroadcast) .assert_nonce_increment(&[(0, 2), (1, 1)]) .await; -}); +} -forgetest_async!(can_deploy_script_private_key, |prj, cmd| { +#[forgetest] +async fn can_deploy_script_private_key(prj: _, cmd: _) { let (_api, handle) = spawn(NodeConfig::test()).await; let mut tester = ScriptTester::new_broadcast(cmd, &handle.http_endpoint(), prj.root()); @@ -713,21 +1023,217 @@ forgetest_async!(can_deploy_script_private_key, |prj, cmd| { 3, )]) .await; -}); +} -forgetest_async!(can_deploy_unlocked, |prj, cmd| { +#[forgetest] +async fn can_deploy_unlocked(prj: _, cmd: _) { let (_api, handle) = spawn(NodeConfig::test()).await; let mut tester = ScriptTester::new_broadcast(cmd, &handle.http_endpoint(), prj.root()); tester - .sender("0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266".parse().unwrap()) + .sender(address!("0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266")) .unlocked() .add_sig("BroadcastTest", "deployOther()") .simulate(ScriptOutcome::OkSimulation) .broadcast(ScriptOutcome::OkBroadcast); -}); +} + +#[forgetest] +async fn delegated_transport_error_is_not_retried(prj: _, cmd: _) { + let (_api, handle) = spawn(NodeConfig::test()).await; + let upstream = handle.http_endpoint(); + let client = reqwest::Client::new(); + let submissions = std::sync::Arc::new(AtomicUsize::new(0)); + let submitted_request = std::sync::Arc::new(std::sync::Mutex::new(None)); + let recorded_submissions = submissions.clone(); + let recorded_request = submitted_request.clone(); + let app = Router::new().fallback(move |body: BodyBytes| { + let upstream = upstream.clone(); + let client = client.clone(); + let submissions = recorded_submissions.clone(); + let submitted_request = recorded_request.clone(); + async move { + let request: Value = serde_json::from_slice(&body).unwrap(); + let is_submission = + request.get("method").and_then(Value::as_str) == Some("eth_sendTransaction"); + if is_submission { + submissions.fetch_add(1, Ordering::Relaxed); + *submitted_request.lock().unwrap() = Some(request["params"][0].clone()); + } + let response = client + .post(upstream) + .header("content-type", "application/json") + .body(body) + .send() + .await + .unwrap() + .bytes() + .await + .unwrap(); + if is_submission { + (StatusCode::SERVICE_UNAVAILABLE, response).into_response() + } else { + response.into_response() + } + } + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + let _proxy = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + let mut tester = ScriptTester::new_broadcast(cmd, &endpoint, prj.root()); + tester + .sender(address!("0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266")) + .unlocked() + .add_sig("BroadcastTest", "deployOther()") + .arg("--broadcast"); + tester.cmd.assert_failure().stderr_eq(str![[r#" +Error: submission outcome for delegated operation 0 is unknown; refusing to risk a duplicate transaction + +"#]]); + assert_eq!(submissions.load(Ordering::Relaxed), 1); + let recovery_path = foundry_common::fs::json_files(&prj.root().join("cache")) + .find(|path| path.to_string_lossy().ends_with(".recovery.json")) + .unwrap(); + let recovery: Value = foundry_common::fs::read_json_file(&recovery_path).unwrap(); + assert_eq!( + recovery["deployments"][0]["attempts"][0]["kind"]["request"], + submitted_request.lock().unwrap().clone().unwrap() + ); + let attempt = recovery["deployments"][0]["attempts"][0]["id"].as_str().unwrap(); + + tester.clear(); + tester + .sender(address!("0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266")) + .unlocked() + .add_sig("BroadcastTest", "deployOther()") + .arg("--resume"); + tester.cmd.assert_failure().stderr_eq(format!( + "Error: submission outcome for delegated attempt {attempt} on chain 31337 is unknown; target it with --resume-attempt and provide --resume-tx-hash, or use --resume-retry only after proving it was not submitted\n" + )); + assert_eq!(submissions.load(Ordering::Relaxed), 1); +} + +#[forgetest_init] +async fn resume_replays_dropped_signed_checkpoint(prj: _, cmd: _) { + let script = prj.add_script( + "InterruptedResume.s.sol", + r#" +import "forge-std/Script.sol"; + +contract InterruptedResumeTarget {} + +contract InterruptedResume is Script { + function run() external { + vm.startBroadcast(); + new InterruptedResumeTarget(); + new InterruptedResumeTarget(); + vm.stopBroadcast(); + } +} +"#, + ); + let (api, handle) = spawn(NodeConfig::test()).await; + api.anvil_set_auto_mine(false).await.unwrap(); + let (submission_rpc, submissions) = + spawn_rpc_proxy_recording_method(handle.http_endpoint(), "eth_sendRawTransaction").await; + let (rpc, receipt_requests) = + spawn_rpc_proxy_recording_method(submission_rpc, "eth_getTransactionReceipt").await; + let private_key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + let sender = handle.dev_accounts().next().unwrap(); + let path = prj.root().join("broadcast/InterruptedResume.s.sol/31337/run-latest.json"); + let sensitive_path = prj.root().join("cache/InterruptedResume.s.sol/31337/run-latest.json"); + let recovery_path = + prj.root().join("cache/InterruptedResume.s.sol/31337/run-latest.json.recovery.json"); + + cmd.arg("script").arg(&script).args([ + "--tc", + "InterruptedResume", + "--rpc-url", + &rpc, + "--private-key", + private_key, + "--broadcast", + "--slow", + ]); + let mut child = KillOnDrop::spawn(cmd.cmd()); + let sequence = tokio::time::timeout(Duration::from_secs(30), async { + loop { + if let Ok(sequence) = foundry_common::fs::read_json_file::(&path) + && sequence["pending"].as_array().is_some_and(|pending| pending.len() == 1) + && sequence["transactions"][0]["hash"].is_string() + { + break sequence; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("first transaction was not checkpointed"); + let first_hash = sequence["transactions"][0]["hash"].clone(); + let first_address = sequence["transactions"][0]["contractAddress"] + .as_str() + .unwrap() + .parse::
() + .unwrap(); + assert!(sequence["transactions"][1]["hash"].is_null()); + assert!(sequence["receipts"].as_array().unwrap().is_empty()); + assert_eq!(submissions.lock().unwrap().len(), 1); + tokio::time::timeout(Duration::from_secs(30), async { + loop { + if receipt_requests + .lock() + .unwrap() + .iter() + .any(|params| params.get(0) == Some(&first_hash)) + { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("forge did not poll for the checkpointed transaction receipt"); + assert!(child.is_running(), "forge exited before it could be interrupted"); + let output = child.kill_and_wait(); + assert!(!output.status.success(), "forge unexpectedly succeeded"); + #[cfg(unix)] + assert_eq!(output.status.signal(), Some(9), "forge was not terminated by SIGKILL"); + assert!(recovery_path.exists(), "authoritative recovery snapshot was not checkpointed"); + std::fs::remove_file(&path).unwrap(); + std::fs::remove_file(&sensitive_path).unwrap(); + + api.anvil_drop_transaction(first_hash.as_str().unwrap().parse().unwrap()).await.unwrap(); + api.anvil_set_auto_mine(true).await.unwrap(); + prj.update_config(|config| config.transaction_timeout = 1); + cmd.forge_fuse().arg("script").arg(&script).args([ + "--tc", + "InterruptedResume", + "--rpc-url", + &rpc, + "--private-key", + private_key, + "--resume", + "--slow", + ]); + cmd.assert_success(); + + let sequence: Value = foundry_common::fs::read_json_file(&path).unwrap(); + assert_eq!(sequence["transactions"][0]["hash"], first_hash); + assert!(sequence["transactions"][1]["hash"].is_string()); + assert_eq!(sequence["receipts"].as_array().unwrap().len(), 2); + assert!(sequence["pending"].as_array().unwrap().is_empty()); + assert_eq!(submissions.lock().unwrap().len(), 3); + let provider = handle.http_provider(); + assert_eq!(provider.get_transaction_count(sender).await.unwrap(), 2); + assert!(!provider.get_code_at(first_address).await.unwrap().is_empty()); + let second_address = + sequence["transactions"][1]["contractAddress"].as_str().unwrap().parse().unwrap(); + assert!(!provider.get_code_at(second_address).await.unwrap().is_empty()); +} -forgetest_async!(can_deploy_script_remember_key, |prj, cmd| { +#[forgetest] +async fn can_deploy_script_remember_key(prj: _, cmd: _) { let (_api, handle) = spawn(NodeConfig::test()).await; let mut tester = ScriptTester::new_broadcast(cmd, &handle.http_endpoint(), prj.root()); @@ -742,9 +1248,10 @@ forgetest_async!(can_deploy_script_remember_key, |prj, cmd| { 2, )]) .await; -}); +} -forgetest_async!(can_deploy_script_remember_key_and_resume, |prj, cmd| { +#[forgetest] +async fn can_deploy_script_remember_key_and_resume(prj: _, cmd: _) { let (_api, handle) = spawn(NodeConfig::test()).await; let mut tester = ScriptTester::new_broadcast(cmd, &handle.http_endpoint(), prj.root()); @@ -766,9 +1273,10 @@ forgetest_async!(can_deploy_script_remember_key_and_resume, |prj, cmd| { .await .assert_nonce_increment(&[(0, 2)]) .await; -}); +} -forgetest_async!(can_resume_script, |prj, cmd| { +#[forgetest] +async fn can_resume_script(prj: _, cmd: _) { let (_api, handle) = spawn(NodeConfig::test()).await; let mut tester = ScriptTester::new_broadcast(cmd, &handle.http_endpoint(), prj.root()); @@ -784,9 +1292,10 @@ forgetest_async!(can_resume_script, |prj, cmd| { .run(ScriptOutcome::OkBroadcast) .assert_nonce_increment(&[(0, 2), (1, 1)]) .await; -}); +} -forgetest_async!(can_deploy_broadcast_wrap, |prj, cmd| { +#[forgetest] +async fn can_deploy_broadcast_wrap(prj: _, cmd: _) { let (_api, handle) = spawn(NodeConfig::test()).await; let mut tester = ScriptTester::new_broadcast(cmd, &handle.http_endpoint(), prj.root()); @@ -799,9 +1308,10 @@ forgetest_async!(can_deploy_broadcast_wrap, |prj, cmd| { .broadcast(ScriptOutcome::OkBroadcast) .assert_nonce_increment(&[(0, 4), (1, 4), (2, 1)]) .await; -}); +} -forgetest_async!(panic_no_deployer_set, |prj, cmd| { +#[forgetest] +async fn panic_no_deployer_set(prj: _, cmd: _) { let (_api, handle) = spawn(NodeConfig::test()).await; let mut tester = ScriptTester::new_broadcast(cmd, &handle.http_endpoint(), prj.root()); @@ -811,9 +1321,10 @@ forgetest_async!(panic_no_deployer_set, |prj, cmd| { .add_sig("BroadcastTest", "deployOther()") .simulate(ScriptOutcome::WarnSpecifyDeployer) .broadcast(ScriptOutcome::MissingSender); -}); +} -forgetest_async!(can_deploy_no_arg_broadcast, |prj, cmd| { +#[forgetest] +async fn can_deploy_no_arg_broadcast(prj: _, cmd: _) { let (_api, handle) = spawn(NodeConfig::test()).await; let mut tester = ScriptTester::new_broadcast(cmd, &handle.http_endpoint(), prj.root()); @@ -826,9 +1337,10 @@ forgetest_async!(can_deploy_no_arg_broadcast, |prj, cmd| { .broadcast(ScriptOutcome::OkBroadcast) .assert_nonce_increment(&[(0, 3)]) .await; -}); +} -forgetest_async!(can_deploy_with_create2, |prj, cmd| { +#[forgetest] +async fn can_deploy_with_create2(prj: _, cmd: _) { let (api, handle) = spawn(NodeConfig::test()).await; let mut tester = ScriptTester::new_broadcast(cmd, &handle.http_endpoint(), prj.root()); @@ -851,9 +1363,10 @@ forgetest_async!(can_deploy_with_create2, |prj, cmd| { .await // Running again results in error, since we're repeating the salt passed to CREATE2 .run(ScriptOutcome::ScriptFailed); -}); +} -forgetest_async!(can_deploy_with_custom_create2, |prj, cmd| { +#[forgetest] +async fn can_deploy_with_custom_create2(prj: _, cmd: _) { let (api, handle) = spawn(NodeConfig::test()).await; let mut tester = ScriptTester::new_broadcast(cmd, &handle.http_endpoint(), prj.root()); let create2 = address!("0x0000000000000000000000000000000000b4956c"); @@ -877,9 +1390,10 @@ forgetest_async!(can_deploy_with_custom_create2, |prj, cmd| { .broadcast(ScriptOutcome::OkBroadcast) .assert_nonce_increment(&[(0, 2)]) .await; -}); +} -forgetest_async!(can_deploy_with_custom_create2_notmatched_bytecode, |prj, cmd| { +#[forgetest] +async fn can_deploy_with_custom_create2_notmatched_bytecode(prj: _, cmd: _) { let (api, handle) = spawn(NodeConfig::test()).await; let mut tester = ScriptTester::new_broadcast(cmd, &handle.http_endpoint(), prj.root()); let create2 = address!("0x0000000000000000000000000000000000b4956c"); @@ -887,7 +1401,7 @@ forgetest_async!(can_deploy_with_custom_create2_notmatched_bytecode, |prj, cmd| // Prepare CREATE2 Deployer api.anvil_set_code( create2, - Bytes::from_static(&hex!("7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe03601600081602082378035828234f58015156039578182fd5b8082525050506014600cef")), + bytes!("7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe03601600081602082378035828234f58015156039578182fd5b8082525050506014600cef"), ) .await .unwrap(); @@ -900,9 +1414,10 @@ forgetest_async!(can_deploy_with_custom_create2_notmatched_bytecode, |prj, cmd| .add_sig("BroadcastTestNoLinking", "deployCreate2()") .simulate(ScriptOutcome::ScriptFailed) .broadcast(ScriptOutcome::ScriptFailed); -}); +} -forgetest_async!(cannot_deploy_with_nonexist_create2, |prj, cmd| { +#[forgetest] +async fn cannot_deploy_with_nonexist_create2(prj: _, cmd: _) { let (_api, handle) = spawn(NodeConfig::test()).await; let mut tester = ScriptTester::new_broadcast(cmd, &handle.http_endpoint(), prj.root()); let create2 = address!("0x0000000000000000000000000000000000b4956c"); @@ -915,9 +1430,10 @@ forgetest_async!(cannot_deploy_with_nonexist_create2, |prj, cmd| { .add_sig("BroadcastTestNoLinking", "deployCreate2()") .simulate(ScriptOutcome::ScriptFailed) .broadcast(ScriptOutcome::ScriptFailed); -}); +} -forgetest_async!(can_deploy_and_simulate_25_txes_concurrently, |prj, cmd| { +#[forgetest] +async fn can_deploy_and_simulate_25_txes_concurrently(prj: _, cmd: _) { let (_api, handle) = spawn(NodeConfig::test()).await; let mut tester = ScriptTester::new_broadcast(cmd, &handle.http_endpoint(), prj.root()); @@ -929,9 +1445,252 @@ forgetest_async!(can_deploy_and_simulate_25_txes_concurrently, |prj, cmd| { .broadcast(ScriptOutcome::OkBroadcast) .assert_nonce_increment(&[(0, 25)]) .await; -}); +} + +// . +#[forgetest] +async fn can_deploy_25_txes_concurrently_across_full_blocks(prj: _, cmd: _) { + // Only a few of the deployments fit into one block, the rest must be mined in later blocks. + let (_api, handle) = spawn(NodeConfig::test().with_gas_limit(Some(1_000_000))).await; + let mut tester = ScriptTester::new_broadcast(cmd, &handle.http_endpoint(), prj.root()); + + tester + .load_private_keys(&[0]) + .await + .add_sig("BroadcastTestNoLinking", "deployMany()") + .simulate(ScriptOutcome::OkSimulation) + .broadcast(ScriptOutcome::OkBroadcast) + .assert_nonce_increment(&[(0, 25)]) + .await; +} + +#[forgetest] +async fn can_deploy_25_txes_concurrently_with_interval_mining(prj: _, cmd: _) { + // The batch is only mined on the next interval tick. + let (_api, handle) = + spawn(NodeConfig::test().with_blocktime(Some(Duration::from_secs(1)))).await; + let mut tester = ScriptTester::new_broadcast(cmd, &handle.http_endpoint(), prj.root()); + + tester + .load_private_keys(&[0]) + .await + .add_sig("BroadcastTestNoLinking", "deployMany()") + .simulate(ScriptOutcome::OkSimulation) + .broadcast(ScriptOutcome::OkBroadcast) + .assert_nonce_increment(&[(0, 25)]) + .await; +} + +#[forgetest] +async fn can_deploy_25_txes_concurrently_with_mixed_mining(prj: _, cmd: _) { + // The chain keeps advancing on the interval while the batch is mined instantly. + let (_api, handle) = + spawn(NodeConfig::test().with_mixed_mining(true, Some(Duration::from_secs(1)))).await; + let mut tester = ScriptTester::new_broadcast(cmd, &handle.http_endpoint(), prj.root()); + + tester + .load_private_keys(&[0]) + .await + .add_sig("BroadcastTestNoLinking", "deployMany()") + .simulate(ScriptOutcome::OkSimulation) + .broadcast(ScriptOutcome::OkBroadcast) + .assert_nonce_increment(&[(0, 25)]) + .await; +} + +// . +#[forgetest] +async fn fork_nested_broadcast_nonces(prj: _, cmd: _) { + prj.add_script( + "NestedBroadcast.s.sol", + r#" +interface Vm { + function startBroadcast(address sender) external; + function stopBroadcast() external; +} + +contract Counter { + uint256 public value; + + function set(uint256 newValue) external { + value = newValue; + } +} + +contract SubScript { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + function run(address sender) external { + vm.startBroadcast(sender); + Counter counter = new Counter{salt: bytes32(uint256(1))}(); + counter.set(7); + new Counter(); + vm.stopBroadcast(); + } +} + +contract NestedBroadcast { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + function run() external { + address sender = msg.sender; + vm.startBroadcast(sender); + Counter counter = new Counter(); + vm.stopBroadcast(); + + new SubScript().run(sender); + + vm.startBroadcast(sender); + counter.set(9); + new Counter(); + vm.stopBroadcast(); + } +} +"#, + ); + + for isolate in [true, false] { + prj.update_config(|config| config.isolate = isolate); + let (_api, handle) = spawn(NodeConfig::test().with_auto_impersonate(true)).await; + let sender = handle.dev_accounts().next().unwrap(); + cmd.forge_fuse() + .args([ + "script", + "script/NestedBroadcast.s.sol:NestedBroadcast", + "--rpc-url", + &handle.http_endpoint(), + "--broadcast", + "--slow", + "--unlocked", + "--sender", + &sender.to_string(), + ]) + .assert_success(); + + let path = prj.root().join("broadcast/NestedBroadcast.s.sol/31337/run-latest.json"); + let sequence: ScriptSequence = foundry_common::fs::read_json_file(&path).unwrap(); + assert_eq!(sequence.transactions.len(), 6); + assert_eq!(sequence.receipts.len(), 6); + for (nonce, transaction) in sequence.transactions.iter().enumerate() { + assert_eq!(transaction.transaction.nonce(), Some(nonce as u64)); + } + let provider = handle.http_provider(); + assert_eq!(provider.get_transaction_count(sender).await.unwrap(), 6); + assert!(!provider.get_code_at(sender.create(3)).await.unwrap().is_empty()); + } +} + +#[forgetest] +async fn broadcast_records_hashes_in_submission_order(prj: _, cmd: _) { + let (_api, handle) = spawn(NodeConfig::test()).await; + let upstream = handle.http_endpoint(); + let client = reqwest::Client::new(); + let submissions = std::sync::Arc::new(AtomicUsize::new(0)); + let release_first = std::sync::Arc::new(tokio::sync::Notify::new()); + let app = Router::new().fallback(move |body: BodyBytes| { + let upstream = upstream.clone(); + let client = client.clone(); + let submissions = submissions.clone(); + let release_first = release_first.clone(); + async move { + let request: Value = serde_json::from_slice(&body).unwrap(); + let position = (request["method"] == "eth_sendRawTransaction") + .then(|| submissions.fetch_add(1, Ordering::SeqCst)); + let response = client + .post(upstream) + .header("content-type", "application/json") + .body(body) + .send() + .await + .unwrap() + .bytes() + .await + .unwrap(); + if position == Some(0) { + release_first.notified().await; + // Let the later submission's response reach the broadcaster first. + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } else if position == Some(1) { + release_first.notify_one(); + } + response + } + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + let proxy = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + ScriptTester::new_broadcast(cmd, &endpoint, prj.root()) + .load_private_keys(&[0]) + .await + .add_sig("BroadcastTestNoLinking", "deployMany()") + .simulate(ScriptOutcome::OkSimulation) + .broadcast(ScriptOutcome::OkBroadcast); + let path = prj.root().join("broadcast/Broadcast.t.sol/31337/deployMany-latest.json"); + let sequence: ScriptSequence = foundry_common::fs::read_json_file(&path).unwrap(); + assert_eq!(sequence.transactions.len(), 25); + let client = reqwest::Client::new(); + for (nonce, transaction) in sequence.transactions.iter().enumerate() { + let response: Value = client + .post(handle.http_endpoint()) + .json(&serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "method": "eth_getTransactionByHash", + "params": [transaction.hash.unwrap()] + })) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(response["result"]["nonce"], format!("0x{nonce:x}")); + } + proxy.abort(); +} + +#[forgetest] +async fn fork_script_reuses_chain_ids(prj: _, cmd: _) { + static CHAIN_ID_REQUESTS: AtomicUsize = AtomicUsize::new(0); + CHAIN_ID_REQUESTS.store(0, Ordering::Relaxed); + + let (_api, handle) = spawn(NodeConfig::test()).await; + let upstream = handle.http_endpoint(); + let client = reqwest::Client::new(); + let app = Router::new().fallback(move |body: BodyBytes| { + let upstream = upstream.clone(); + let client = client.clone(); + async move { + let request: Value = serde_json::from_slice(&body).unwrap(); + if request.get("method").and_then(Value::as_str) == Some("eth_chainId") { + CHAIN_ID_REQUESTS.fetch_add(1, Ordering::Relaxed); + } + client + .post(upstream) + .header("content-type", "application/json") + .body(body) + .send() + .await + .unwrap() + .bytes() + .await + .unwrap() + } + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + let _proxy = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + ScriptTester::new_broadcast(cmd, &endpoint, prj.root()) + .add_deployer(0) + .add_sig("ScriptAdditionalContracts", "run()") + .args(&["--chain", "1"]) + .simulate(ScriptOutcome::OkSimulation); + + assert_eq!(CHAIN_ID_REQUESTS.load(Ordering::Relaxed), 2); + assert_eq!(latest_dry_run_sequence(prj.root()).chain, 31337); +} -forgetest_async!(can_deploy_and_simulate_mixed_broadcast_modes, |prj, cmd| { +#[forgetest] +async fn can_deploy_and_simulate_mixed_broadcast_modes(prj: _, cmd: _) { let (_api, handle) = spawn(NodeConfig::test()).await; let mut tester = ScriptTester::new_broadcast(cmd, &handle.http_endpoint(), prj.root()); @@ -943,9 +1702,10 @@ forgetest_async!(can_deploy_and_simulate_mixed_broadcast_modes, |prj, cmd| { .broadcast(ScriptOutcome::OkBroadcast) .assert_nonce_increment(&[(0, 15)]) .await; -}); +} -forgetest_async!(deploy_with_setup, |prj, cmd| { +#[forgetest] +async fn deploy_with_setup(prj: _, cmd: _) { let (_api, handle) = spawn(NodeConfig::test()).await; let mut tester = ScriptTester::new_broadcast(cmd, &handle.http_endpoint(), prj.root()); @@ -957,9 +1717,10 @@ forgetest_async!(deploy_with_setup, |prj, cmd| { .broadcast(ScriptOutcome::OkBroadcast) .assert_nonce_increment(&[(0, 6)]) .await; -}); +} -forgetest_async!(fail_broadcast_staticcall, |prj, cmd| { +#[forgetest] +async fn fail_broadcast_staticcall(prj: _, cmd: _) { let (_api, handle) = spawn(NodeConfig::test()).await; let mut tester = ScriptTester::new_broadcast(cmd, &handle.http_endpoint(), prj.root()); @@ -968,9 +1729,10 @@ forgetest_async!(fail_broadcast_staticcall, |prj, cmd| { .await .add_sig("BroadcastTestNoLinking", "errorStaticCall()") .simulate(ScriptOutcome::StaticCallNotAllowed); -}); +} -forgetest_async!(check_broadcast_log, |prj, cmd| { +#[forgetest] +async fn check_broadcast_log(prj: _, cmd: _) { let (api, handle) = spawn(NodeConfig::test()).await; let mut tester = ScriptTester::new_broadcast(cmd, &handle.http_endpoint(), prj.root()); @@ -1020,8 +1782,8 @@ forgetest_async!(check_broadcast_log, |prj, cmd| { // ); // Check sensitive logs - // Ignore port number since it can change in between runs - let re = Regex::new(r":[0-9]+").unwrap(); + // Ignore port number and recovery generation since they can change in between runs. + let re = Regex::new(r#":[0-9]+|0x[0-9a-f]{64}"#).unwrap(); let fixtures_log = std::fs::read_to_string( PathBuf::from(env!("CARGO_MANIFEST_DIR")) @@ -1034,14 +1796,14 @@ forgetest_async!(check_broadcast_log, |prj, cmd| { let run_log = re.replace_all(&run_log, ""); // Clean up carriage return OS differences - let re = Regex::new(r"\r\n").unwrap(); - let fixtures_log = re.replace_all(&fixtures_log, "\n"); - let run_log = re.replace_all(&run_log, "\n"); + let fixtures_log = fixtures_log.replace("\r\n", "\n"); + let run_log = run_log.replace("\r\n", "\n"); - similar_asserts::assert_eq!(fixtures_log, run_log); -}); + similar_asserts::assert_eq!(fixtures_log.trim_end(), run_log.trim_end()); +} -forgetest_async!(test_default_sender_balance, |prj, cmd| { +#[forgetest] +async fn test_default_sender_balance(prj: _, cmd: _) { let (_api, handle) = spawn(NodeConfig::test()).await; let mut tester = ScriptTester::new_broadcast(cmd, &handle.http_endpoint(), prj.root()); @@ -1049,9 +1811,10 @@ forgetest_async!(test_default_sender_balance, |prj, cmd| { tester .add_sig("TestInitialBalance", "runDefaultSender()") .simulate(ScriptOutcome::OkSimulation); -}); +} -forgetest_async!(test_custom_sender_balance, |prj, cmd| { +#[forgetest] +async fn test_custom_sender_balance(prj: _, cmd: _) { let (_api, handle) = spawn(NodeConfig::test()).await; let mut tester = ScriptTester::new_broadcast(cmd, &handle.http_endpoint(), prj.root()); @@ -1060,7 +1823,66 @@ forgetest_async!(test_custom_sender_balance, |prj, cmd| { .add_deployer(0) .add_sig("TestInitialBalance", "runCustomSender()") .simulate(ScriptOutcome::OkSimulation); -}); +} + +// +#[forgetest_init] +async fn broadcast_log_includes_full_function_abi(prj: _, cmd: _) { + let (_api, handle) = spawn(NodeConfig::test()).await; + let script = prj.add_script( + "FullSignature.s.sol", + r#" +interface Vm { + function startBroadcast() external; + function stopBroadcast() external; +} + +contract Target { + uint256 configuredAmount; + address configuredRecipient; + + function configure(uint256 amount, address recipient) + external + returns (bool accepted) + { + configuredAmount = amount; + configuredRecipient = recipient; + return amount > 0 && recipient != address(0); + } +} + +contract SignatureScript { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + function run() external { + vm.startBroadcast(); + Target target = new Target(); + target.configure(1, address(1)); + vm.stopBroadcast(); + } +} +"#, + ); + + cmd.forge_fuse() + .arg("script") + .arg(script) + .args(["--tc", "SignatureScript", "--rpc-url", &handle.http_endpoint()]) + .assert_success(); + + let run_latest = foundry_common::fs::json_files(&prj.root().join("broadcast")) + .find(|path| path.ends_with("run-latest.json")) + .expect("No broadcast artifacts"); + let sequence: ScriptSequence = + foundry_common::fs::read_json_file(&run_latest).unwrap(); + + assert_eq!(sequence.transactions.len(), 2); + assert_eq!(sequence.transactions[1].function.as_deref(), Some("configure(uint256,address)")); + assert_eq!( + sequence.transactions[1].function_abi.as_deref(), + Some("function configure(uint256 amount, address recipient) returns (bool accepted)") + ); +} #[derive(serde::Deserialize)] struct Transactions { @@ -1073,22 +1895,19 @@ struct Transaction { } // test we output arguments -forgetest_async!(can_execute_script_with_arguments, |prj, cmd| { - cmd.args(["init", "--force"]) - .arg(prj.root()) - .assert_success() - .stdout_eq(str![[r#" +#[forgetest] +async fn can_execute_script_with_arguments(prj: _, cmd: _) { + cmd.args(["init", "--force"]).arg(prj.root()).assert_success().stdout_eq(str![""]).stderr_eq( + str![[r#" +Warning: Target directory is not empty, but `--force` was specified Initializing [..]... -Installing forge-std in [..] (url: Some("https://github.com/foundry-rs/forge-std"), tag: None) +Installing forge-std in [..] (url: https://github.com/foundry-rs/forge-std, tag: None) +... Installed forge-std[..] Initialized forge project -"#]]) - .stderr_eq(str![[r#" -Warning: Target directory is not empty, but `--force` was specified -... - -"#]]); +"#]], + ); let (_api, handle) = spawn(NodeConfig::test()).await; let script = prj.add_script( @@ -1131,8 +1950,7 @@ contract Script0 is Script { } } "#, - ) - .unwrap(); + ); cmd .forge_fuse() @@ -1160,7 +1978,9 @@ Script ran successfully. Chain 31337 -[ESTIMATED_GAS_PRICE] +[ESTIMATED_MAX_FEE_PER_GAS] +[ESTIMATED_BASE_FEE_PER_GAS] +[ESTIMATED_PRIORITY_FEE_PER_GAS] [ESTIMATED_TOTAL_GAS_USED] @@ -1199,31 +2019,27 @@ SIMULATION COMPLETE. To broadcast these transactions, add --broadcast and wallet "hello".to_string(), ] ); -}); +} // test we output arguments -forgetest_async!(can_execute_script_with_arguments_nested_deploy, |prj, cmd| { - cmd.args(["init", "--force"]) - .arg(prj.root()) - .assert_success() - .stdout_eq(str![[r#" +#[forgetest] +async fn can_execute_script_with_arguments_nested_deploy(prj: _, cmd: _) { + cmd.args(["init", "--force"]).arg(prj.root()).assert_success().stdout_eq(str![""]).stderr_eq( + str![[r#" +Warning: Target directory is not empty, but `--force` was specified Initializing [..]... -Installing forge-std in [..] (url: Some("https://github.com/foundry-rs/forge-std"), tag: None) +Installing forge-std in [..] (url: https://github.com/foundry-rs/forge-std, tag: None) +... Installed forge-std[..] Initialized forge project -"#]]) - .stderr_eq(str![[r#" -Warning: Target directory is not empty, but `--force` was specified -... - -"#]]); +"#]], + ); let (_api, handle) = spawn(NodeConfig::test()).await; - let script = prj - .add_script( - "Counter.s.sol", - r#" + let script = prj.add_script( + "Counter.s.sol", + r#" import "forge-std/Script.sol"; contract A { @@ -1259,8 +2075,7 @@ contract Script0 is Script { } } "#, - ) - .unwrap(); + ); cmd .forge_fuse() @@ -1288,7 +2103,9 @@ Script ran successfully. Chain 31337 -[ESTIMATED_GAS_PRICE] +[ESTIMATED_MAX_FEE_PER_GAS] +[ESTIMATED_BASE_FEE_PER_GAS] +[ESTIMATED_PRIORITY_FEE_PER_GAS] [ESTIMATED_TOTAL_GAS_USED] @@ -1326,14 +2143,14 @@ SIMULATION COMPLETE. To broadcast these transactions, add --broadcast and wallet "hello".to_string(), ] ); -}); +} // checks that skipping build -forgetest_init!(can_execute_script_and_skip_contracts, |prj, cmd| { - let script = prj - .add_source( - "Foo", - r#" +#[forgetest_init] +fn can_execute_script_and_skip_contracts(prj: _, cmd: _) { + let script = prj.add_source( + "Foo", + r#" contract Demo { event log_string(string); function run() external returns (uint256 result, uint8) { @@ -1341,8 +2158,7 @@ contract Demo { return (255, 3); } }"#, - ) - .unwrap(); + ); cmd.arg("script") .arg(script) .args(["--skip", "tests", "--skip", TEMPLATE_CONTRACT]) @@ -1362,41 +2178,39 @@ result: uint256 255 script ran "#]]); -}); +} -forgetest_async!(can_run_script_with_empty_setup, |prj, cmd| { +#[forgetest] +async fn can_run_script_with_empty_setup(prj: _, cmd: _) { let mut tester = ScriptTester::new_broadcast_without_endpoint(cmd, prj.root()); tester.add_sig("BroadcastEmptySetUp", "run()").simulate(ScriptOutcome::OkNoEndpoint); -}); +} -forgetest_async!(does_script_override_correctly, |prj, cmd| { +#[forgetest] +async fn does_script_override_correctly(prj: _, cmd: _) { let mut tester = ScriptTester::new_broadcast_without_endpoint(cmd, prj.root()); tester.add_sig("CheckOverrides", "run()").simulate(ScriptOutcome::OkNoEndpoint); -}); +} -forgetest_async!(assert_tx_origin_is_not_overwritten, |prj, cmd| { - cmd.args(["init", "--force"]) - .arg(prj.root()) - .assert_success() - .stdout_eq(str![[r#" +#[forgetest] +async fn assert_tx_origin_is_not_overwritten(prj: _, cmd: _) { + cmd.args(["init", "--force"]).arg(prj.root()).assert_success().stdout_eq(str![""]).stderr_eq( + str![[r#" +Warning: Target directory is not empty, but `--force` was specified Initializing [..]... -Installing forge-std in [..] (url: Some("https://github.com/foundry-rs/forge-std"), tag: None) +Installing forge-std in [..] (url: https://github.com/foundry-rs/forge-std, tag: None) +... Installed forge-std[..] Initialized forge project -"#]]) - .stderr_eq(str![[r#" -Warning: Target directory is not empty, but `--force` was specified -... - -"#]]); +"#]], + ); - let script = prj - .add_script( - "ScriptTxOrigin.s.sol", - r#" + let script = prj.add_script( + "ScriptTxOrigin.s.sol", + r#" import { Script } from "forge-std/Script.sol"; contract ScriptTxOrigin is Script { @@ -1444,8 +2258,7 @@ contract ContractC { } } "#, - ) - .unwrap(); + ); cmd.forge_fuse() .arg("script") @@ -1462,29 +2275,25 @@ Script ran successfully. If you wish to simulate on-chain transactions pass a RPC URL. "#]]); -}); +} -forgetest_async!(assert_can_create_multiple_contracts_with_correct_nonce, |prj, cmd| { - cmd.args(["init", "--force"]) - .arg(prj.root()) - .assert_success() - .stdout_eq(str![[r#" +#[forgetest] +async fn assert_can_create_multiple_contracts_with_correct_nonce(prj: _, cmd: _) { + cmd.args(["init", "--force"]).arg(prj.root()).assert_success().stdout_eq(str![""]).stderr_eq( + str![[r#" +Warning: Target directory is not empty, but `--force` was specified Initializing [..]... -Installing forge-std in [..] (url: Some("https://github.com/foundry-rs/forge-std"), tag: None) +Installing forge-std in [..] (url: https://github.com/foundry-rs/forge-std, tag: None) +... Installed forge-std[..] Initialized forge project -"#]]) - .stderr_eq(str![[r#" -Warning: Target directory is not empty, but `--force` was specified -... - -"#]]); +"#]], + ); - let script = prj - .add_script( - "ScriptTxOrigin.s.sol", - r#" + let script = prj.add_script( + "ScriptTxOrigin.s.sol", + r#" import {Script, console} from "forge-std/Script.sol"; contract Contract { @@ -1517,8 +2326,7 @@ contract NestedCreate is Script { } } "#, - ) - .unwrap(); + ); cmd.forge_fuse() .arg("script") @@ -1540,21 +2348,20 @@ Script ran successfully. If you wish to simulate on-chain transactions pass a RPC URL. "#]]); -}); +} -forgetest_async!(assert_can_detect_target_contract_with_interfaces, |prj, cmd| { - let script = prj - .add_script( - "ScriptWithInterface.s.sol", - r#" +#[forgetest] +async fn assert_can_detect_target_contract_with_interfaces(prj: _, cmd: _) { + let script = prj.add_script( + "ScriptWithInterface.s.sol", + r#" contract Script { function run() external {} } interface Interface {} "#, - ) - .unwrap(); + ); cmd.arg("script").arg(script).assert_success().stdout_eq(str![[r#" [COMPILING_FILES] with [SOLC_VERSION] @@ -1564,13 +2371,13 @@ Script ran successfully. [GAS] "#]]); -}); +} -forgetest_async!(assert_can_detect_unlinked_target_with_libraries, |prj, cmd| { - let script = prj - .add_script( - "ScriptWithExtLib.s.sol", - r#" +#[forgetest] +async fn assert_can_detect_unlinked_target_with_libraries(prj: _, cmd: _) { + let script = prj.add_script( + "ScriptWithExtLib.s.sol", + r#" library Lib { function f() public {} } @@ -1581,8 +2388,7 @@ contract Script { } } "#, - ) - .unwrap(); + ); cmd.arg("script").arg(script).assert_success().stdout_eq(str![[r#" [COMPILING_FILES] with [SOLC_VERSION] @@ -1594,9 +2400,10 @@ Script ran successfully. If you wish to simulate on-chain transactions pass a RPC URL. "#]]); -}); +} -forgetest_async!(assert_can_resume_with_additional_contracts, |prj, cmd| { +#[forgetest] +async fn assert_can_resume_with_additional_contracts(prj: _, cmd: _) { let (_api, handle) = spawn(NodeConfig::test()).await; let mut tester = ScriptTester::new_broadcast(cmd, &handle.http_endpoint(), prj.root()); @@ -1607,11 +2414,10 @@ forgetest_async!(assert_can_resume_with_additional_contracts, |prj, cmd| { .load_private_keys(&[0]) .await .resume(ScriptOutcome::OkBroadcast); -}); - -forgetest_async!(can_detect_contract_when_multiple_versions, |prj, cmd| { - foundry_test_utils::util::initialize(prj.root()); +} +#[forgetest_init] +async fn can_detect_contract_when_multiple_versions(prj: _, cmd: _) { prj.add_script( "A.sol", &format!( @@ -1622,8 +2428,7 @@ import "./B.sol"; contract ScriptA {{}} "# ), - ) - .unwrap(); + ); prj.add_script( "B.sol", @@ -1640,8 +2445,7 @@ contract ScriptB is Script {{ }} "# ), - ) - .unwrap(); + ); prj.add_script( "C.sol", @@ -1653,26 +2457,25 @@ import "./B.sol"; contract ScriptC {{}} "# ), - ) - .unwrap(); + ); let mut tester = ScriptTester::new(cmd, None, prj.root(), "script/B.sol"); tester.cmd.forge_fuse().args(["script", "script/B.sol"]); tester.simulate(ScriptOutcome::OkNoEndpoint); -}); - -forgetest_async!(can_sign_with_script_wallet_single, |prj, cmd| { - foundry_test_utils::util::initialize(prj.root()); +} +#[forgetest_init] +async fn can_sign_with_script_wallet_single(prj: _, cmd: _) { let mut tester = ScriptTester::new_broadcast_without_endpoint(cmd, prj.root()); tester .add_sig("ScriptSign", "run()") .load_private_keys(&[0]) .await .simulate(ScriptOutcome::OkNoEndpoint); -}); +} -forgetest_async!(can_sign_with_script_wallet_multiple, |prj, cmd| { +#[forgetest] +async fn can_sign_with_script_wallet_multiple(prj: _, cmd: _) { let mut tester = ScriptTester::new_broadcast_without_endpoint(cmd, prj.root()); let acc = tester.accounts_pub[0].to_checksum(None); tester @@ -1681,50 +2484,45 @@ forgetest_async!(can_sign_with_script_wallet_multiple, |prj, cmd| { .load_private_keys(&[0, 1, 2]) .await .simulate(ScriptOutcome::OkRun); -}); +} -forgetest_async!(fails_with_function_name_and_overloads, |prj, cmd| { - let script = prj - .add_script( - "Script.s.sol", - r#" +#[forgetest] +async fn fails_with_function_name_and_overloads(prj: _, cmd: _) { + let script = prj.add_script( + "Script.s.sol", + r#" contract Script { function run() external {} function run(address,uint256) external {} } "#, - ) - .unwrap(); + ); cmd.arg("script").args([&script.to_string_lossy(), "--sig", "run"]); cmd.assert_failure().stderr_eq(str![[r#" Error: Multiple functions with the same name `run` found in the ABI "#]]); -}); +} -forgetest_async!(can_decode_custom_errors, |prj, cmd| { - cmd.args(["init", "--force"]) - .arg(prj.root()) - .assert_success() - .stdout_eq(str![[r#" +#[forgetest] +async fn can_decode_custom_errors(prj: _, cmd: _) { + cmd.args(["init", "--force"]).arg(prj.root()).assert_success().stdout_eq(str![""]).stderr_eq( + str![[r#" +Warning: Target directory is not empty, but `--force` was specified Initializing [..]... -Installing forge-std in [..] (url: Some("https://github.com/foundry-rs/forge-std"), tag: None) +Installing forge-std in [..] (url: https://github.com/foundry-rs/forge-std, tag: None) +... Installed forge-std[..] Initialized forge project -"#]]) - .stderr_eq(str![[r#" -Warning: Target directory is not empty, but `--force` was specified -... - -"#]]); +"#]], + ); - let script = prj - .add_script( - "CustomErrorScript.s.sol", - r#" + let script = prj.add_script( + "CustomErrorScript.s.sol", + r#" import { Script } from "forge-std/Script.sol"; contract ContractWithCustomError { @@ -1743,19 +2541,18 @@ contract CustomErrorScript is Script { } } "#, - ) - .unwrap(); + ); cmd.forge_fuse().arg("script").arg(script).args(["--tc", "CustomErrorScript"]); cmd.assert_failure().stderr_eq(str![[r#" Error: script failed: CustomError() "#]]); -}); +} // https://github.com/foundry-rs/foundry/issues/7620 -forgetest_async!(can_run_zero_base_fee, |prj, cmd| { - foundry_test_utils::util::initialize(prj.root()); +#[forgetest_init] +async fn can_run_zero_base_fee(prj: _, cmd: _) { prj.add_script( "Foo", r#" @@ -1768,8 +2565,7 @@ contract SimpleScript is Script { } } "#, - ) - .unwrap(); + ); let node_config = NodeConfig::test().with_base_fee(Some(0)); let (_api, handle) = spawn(node_config).await; @@ -1860,7 +2656,9 @@ success: bool true Chain 31337 -[ESTIMATED_GAS_PRICE] +[ESTIMATED_MAX_FEE_PER_GAS] +[ESTIMATED_BASE_FEE_PER_GAS] +[ESTIMATED_PRIORITY_FEE_PER_GAS] [ESTIMATED_TOTAL_GAS_USED] @@ -1882,11 +2680,11 @@ ONCHAIN EXECUTION COMPLETE & SUCCESSFUL. Warning: Script contains a transaction to 0x0000000000000000000000000000000000000000 which does not contain any code. "#]]); -}); +} // Asserts that the script runs with expected non-output using `--quiet` flag -forgetest_async!(adheres_to_quiet_flag, |prj, cmd| { - foundry_test_utils::util::initialize(prj.root()); +#[forgetest_init] +async fn adheres_to_quiet_flag(prj: _, cmd: _) { prj.add_script( "Foo", r#" @@ -1899,8 +2697,7 @@ contract SimpleScript is Script { } } "#, - ) - .unwrap(); + ); let (_api, handle) = spawn(NodeConfig::test()).await; @@ -1917,11 +2714,15 @@ contract SimpleScript is Script { "--quiet", ]) .assert_empty_stdout(); -}); +} // Asserts that the script runs with expected non-output using `--quiet` flag -forgetest_async!(adheres_to_json_flag, |prj, cmd| { - foundry_test_utils::util::initialize(prj.root()); +#[forgetest_init] +async fn adheres_to_json_flag(prj: _, cmd: _) { + prj.update_config(|config| { + config.tracing.verbosity = 4; + config.tracing.trace_depth = Some(1); + }); prj.add_script( "Foo", r#" @@ -1934,8 +2735,7 @@ contract SimpleScript is Script { } } "#, - ) - .unwrap(); + ); let (_api, handle) = spawn(NodeConfig::test()).await; @@ -1953,61 +2753,206 @@ contract SimpleScript is Script { ]) .assert_success() .stdout_eq(str![[r#" -{"logs":[],"returns":{"success":{"internal_type":"bool","value":"true"}},"success":true,"raw_logs":[],"traces":[["Deployment",{"arena":[{"parent":null,"children":[],"idx":0,"trace":{"depth":0,"success":true,"caller":"0x1804c8ab1f12e6bbf3894d4083f33e07309d1f38","address":"0x5b73c5498c1e3b4dba84de0f1833c4a029d90519","maybe_precompile":false,"selfdestruct_address":null,"selfdestruct_refund_target":null,"selfdestruct_transferred_value":null,"kind":"CREATE","value":"0x0","data":"[..]","output":"[..]","gas_used":"{...}","gas_limit":"{...}","status":"Return","steps":[],"decoded":{"label":"SimpleScript","return_data":null,"call_data":null}},"logs":[],"ordering":[]}]}],["Execution",{"arena":[{"parent":null,"children":[1,2],"idx":0,"trace":{"depth":0,"success":true,"caller":"0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266","address":"0x5b73c5498c1e3b4dba84de0f1833c4a029d90519","maybe_precompile":null,"selfdestruct_address":null,"selfdestruct_refund_target":null,"selfdestruct_transferred_value":null,"kind":"CALL","value":"0x0","data":"0xc0406226","output":"0x0000000000000000000000000000000000000000000000000000000000000001","gas_used":"{...}","gas_limit":1073720760,"status":"Return","steps":[],"decoded":{"label":"SimpleScript","return_data":"true","call_data":{"signature":"run()","args":[]}}},"logs":[],"ordering":[{"Call":0},{"Call":1}]},{"parent":0,"children":[],"idx":1,"trace":{"depth":1,"success":true,"caller":"0x5b73c5498c1e3b4dba84de0f1833c4a029d90519","address":"0x7109709ecfa91a80626ff3989d68f67f5b1dd12d","maybe_precompile":null,"selfdestruct_address":null,"selfdestruct_refund_target":null,"selfdestruct_transferred_value":null,"kind":"CALL","value":"0x0","data":"0x7fb5297f","output":"0x","gas_used":"{...}","gas_limit":1056940999,"status":"Return","steps":[],"decoded":{"label":"VM","return_data":null,"call_data":{"signature":"startBroadcast()","args":[]}}},"logs":[],"ordering":[]},{"parent":0,"children":[],"idx":2,"trace":{"depth":1,"success":true,"caller":"0x5b73c5498c1e3b4dba84de0f1833c4a029d90519","address":"0x0000000000000000000000000000000000000000","maybe_precompile":null,"selfdestruct_address":null,"selfdestruct_refund_target":null,"selfdestruct_transferred_value":null,"kind":"CALL","value":"0x0","data":"0x","output":"0x","gas_used":"{...}","gas_limit":1056940650,"status":"Stop","steps":[],"decoded":{"label":null,"return_data":null,"call_data":null}},"logs":[],"ordering":[]}]}]],"gas_used":"{...}","labeled_addresses":{},"returned":"0x0000000000000000000000000000000000000000000000000000000000000001","address":null} -{"chain":31337,"estimated_gas_price":"{...}","estimated_total_gas_used":"{...}","estimated_amount_required":"{...}","token_symbol":"ETH"} +{"logs":[],"returns":{"success":{"internal_type":"bool","value":"true"}},"success":true,"raw_logs":[],"traces":[["Deployment",{"arena":[{"parent":null,"children":[],"idx":0,"trace":{"depth":0,"success":true,"caller":"0x1804c8ab1f12e6bbf3894d4083f33e07309d1f38","address":"0x5b73c5498c1e3b4dba84de0f1833c4a029d90519","maybe_precompile":false,"selfdestruct_address":null,"selfdestruct_refund_target":null,"selfdestruct_transferred_value":null,"kind":"CREATE","value":"0x0","data":"[..]","output":"[..]","bytecode":null,"gas_used":"{...}","gas_limit":"{...}","gas_refund_counter":0,"status":"Return","steps":[],"step_deltas":[],"decoded":{"label":"SimpleScript","return_data":null,"call_data":null}},"logs":[],"ordering":[]}]}],["Execution",{"arena":[{"parent":null,"children":[1,2],"idx":0,"trace":{"depth":0,"success":true,"caller":"0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266","address":"0x5b73c5498c1e3b4dba84de0f1833c4a029d90519","maybe_precompile":null,"selfdestruct_address":null,"selfdestruct_refund_target":null,"selfdestruct_transferred_value":null,"kind":"CALL","value":"0x0","data":"0xc0406226","output":"0x0000000000000000000000000000000000000000000000000000000000000001","bytecode":null,"gas_used":"{...}","gas_limit":1073720760,"gas_refund_counter":0,"status":"Return","steps":[],"step_deltas":[],"decoded":{"label":"SimpleScript","return_data":"true","call_data":{"signature":"run()","args":[]}}},"logs":[],"ordering":[{"Call":0},{"Call":1}]},{"parent":0,"children":[],"idx":1,"trace":{"depth":1,"success":true,"caller":"0x5b73c5498c1e3b4dba84de0f1833c4a029d90519","address":"0x7109709ecfa91a80626ff3989d68f67f5b1dd12d","maybe_precompile":null,"selfdestruct_address":null,"selfdestruct_refund_target":null,"selfdestruct_transferred_value":null,"kind":"CALL","value":"0x0","data":"0x7fb5297f","output":"0x","bytecode":null,"gas_used":"{...}","gas_limit":1056940999,"gas_refund_counter":0,"status":"Return","steps":[],"step_deltas":[],"decoded":{"label":"VM","return_data":null,"call_data":{"signature":"startBroadcast()","args":[]}}},"logs":[],"ordering":[]},{"parent":0,"children":[],"idx":2,"trace":{"depth":1,"success":true,"caller":"0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266","address":"0x0000000000000000000000000000000000000000","maybe_precompile":null,"selfdestruct_address":null,"selfdestruct_refund_target":null,"selfdestruct_transferred_value":null,"kind":"CALL","value":"0x0","data":"0x","output":"0x","bytecode":null,"gas_used":"{...}","gas_limit":1056940650,"gas_refund_counter":0,"status":"Stop","steps":[],"step_deltas":[],"decoded":{"label":null,"return_data":null,"call_data":null}},"logs":[],"ordering":[]}]}]],"gas_used":"{...}","labeled_addresses":{},"returned":"0x0000000000000000000000000000000000000000000000000000000000000001","address":null} +{"chain":31337,"estimated_gas_price":"{...}","estimated_total_gas_used":"{...}","estimated_amount_required":"{...}","token_symbol":"ETH","estimated_max_fee_per_gas":"{...}","estimated_base_fee_per_gas":"{...}","estimated_max_priority_fee_per_gas":"{...}"} {"chain":"anvil-hardhat","status":"success","tx_hash":"0x4f78afe915fceb282c7625a68eb350bc0bf78acb59ad893e5c62b710a37f3156","contract_address":null,"block_number":1,"gas_used":"{...}","gas_price":"{...}"} {"status":"success","transactions":"[..]/broadcast/Foo.sol/31337/run-latest.json","sensitive":"[..]/cache/Foo.sol/31337/run-latest.json"} "#]].is_jsonlines()); -}); +} -// https://github.com/foundry-rs/foundry/pull/7742 -forgetest_async!(unlocked_no_sender, |prj, cmd| { - foundry_test_utils::util::initialize(prj.root()); +#[forgetest] +fn script_json_trace_depth_removes_nested_nodes(prj: _, cmd: _) { + prj.update_config(|config| { + config.verbosity = 0; + config.tracing.verbosity = 4; + config.tracing.trace_depth = Some(0); + }); prj.add_script( - "Foo", + "DepthScript", r#" -import "forge-std/Script.sol"; +contract Child { + function call() external {} +} -contract SimpleScript is Script { - function run() external returns (bool success) { - vm.startBroadcast(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266); - (success, ) = address(0).call(""); +contract DepthScript { + function run() external { + Child child = new Child(); + child.call(); } } "#, - ) - .unwrap(); - - let (_api, handle) = spawn(NodeConfig::test()).await; + ); - cmd.args([ - "script", - "SimpleScript", - "--fork-url", - &handle.http_endpoint(), - "--broadcast", - "--unlocked", - "--non-interactive", - ]) - .assert_success() - .stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! -... -Script ran successfully. + let output = + cmd.args(["script", "DepthScript", "--json"]).assert_success().get_output().stdout.clone(); + let output: Value = serde_json::from_slice(&output).unwrap(); + let traces = output["traces"].as_array().unwrap(); + assert!(!traces.is_empty()); -== Return == -success: bool true + for trace in traces { + let arena = trace[1]["arena"].as_array().unwrap(); + assert_eq!(arena.len(), 1); + assert!(arena[0]["children"].as_array().unwrap().is_empty()); + } +} -## Setting up 1 EVM. +// https://github.com/foundry-rs/foundry/issues/10050 +#[forgetest_init] +fn json_trace_uses_pranked_caller(prj: _, cmd: _) { + prj.add_source( + "Target", + r#" +contract Target { + address public observedCaller; -========================== + function increment() external { + observedCaller = msg.sender; + } +} + "#, + ); + prj.add_script( + "PrankTrace", + r#" +import "forge-std/Script.sol"; +import {Target} from "../src/Target.sol"; -Chain 31337 +contract PrankTrace is Script { + function run() external { + vm.startPrank(address(0x1234)); + Target target = Target(vm.deployCode("src/Target.sol:Target")); + target.increment(); + require(target.observedCaller() == address(0x1234)); + vm.stopPrank(); + } +} + "#, + ); -[ESTIMATED_GAS_PRICE] + let output = cmd + .args(["script", "PrankTrace", "-vvvv", "--json"]) + .assert_success() + .get_output() + .stdout + .clone(); + let output: Value = serde_json::from_slice(&output).unwrap(); + let execution = + output["traces"].as_array().unwrap().iter().find(|trace| trace[0] == "Execution").unwrap(); + let increment = execution[1]["arena"] + .as_array() + .unwrap() + .iter() + .find(|node| node["trace"]["data"] == "0xd09de08a") + .unwrap(); + let deployment = execution[1]["arena"] + .as_array() + .unwrap() + .iter() + .find(|node| node["trace"]["kind"] == "CREATE") + .unwrap(); + + assert_eq!(increment["trace"]["caller"], "0x0000000000000000000000000000000000001234"); + assert_eq!(deployment["trace"]["caller"], "0x0000000000000000000000000000000000001234"); +} + +#[forgetest_init] +fn json_trace_uses_pranked_create_caller(prj: _, cmd: _) { + prj.add_script( + "PrankCreateTrace", + r#" +import "forge-std/Script.sol"; + +contract Target { + address public creator = msg.sender; +} + +contract PrankCreateTrace is Script { + function run() external { + vm.startPrank(address(0x1234)); + Target created = new Target(); + Target created2 = new Target{salt: bytes32(uint256(1))}(); + require(created.creator() == address(0x1234)); + require(created2.creator() == address(0x1234)); + vm.stopPrank(); + } +} + "#, + ); + + let output = cmd + .args(["script", "PrankCreateTrace", "-vvvv", "--json"]) + .assert_success() + .get_output() + .stdout + .clone(); + let output: Value = serde_json::from_slice(&output).unwrap(); + let execution = + output["traces"].as_array().unwrap().iter().find(|trace| trace[0] == "Execution").unwrap(); + let deployments = execution[1]["arena"] + .as_array() + .unwrap() + .iter() + .filter(|node| matches!(node["trace"]["kind"].as_str(), Some("CREATE" | "CREATE2"))) + .collect::>(); + + assert_eq!(deployments.len(), 2); + assert!(deployments.iter().any(|node| node["trace"]["kind"] == "CREATE2")); + assert!( + deployments + .iter() + .all(|node| node["trace"]["caller"] == "0x0000000000000000000000000000000000001234") + ); +} + +// https://github.com/foundry-rs/foundry/pull/7742 +#[forgetest_init] +async fn unlocked_no_sender(prj: _, cmd: _) { + prj.add_script( + "Foo", + r#" +import "forge-std/Script.sol"; + +contract SimpleScript is Script { + function run() external returns (bool success) { + vm.startBroadcast(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266); + (success, ) = address(0).call(""); + } +} + "#, + ); + + let (_api, handle) = spawn(NodeConfig::test()).await; + + cmd.args([ + "script", + "SimpleScript", + "--fork-url", + &handle.http_endpoint(), + "--broadcast", + "--unlocked", + "--non-interactive", + ]) + .assert_success() + .stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! +... +Script ran successfully. + +== Return == +success: bool true + +## Setting up 1 EVM. + +========================== + +Chain 31337 + +[ESTIMATED_MAX_FEE_PER_GAS] +[ESTIMATED_BASE_FEE_PER_GAS] +[ESTIMATED_PRIORITY_FEE_PER_GAS] [ESTIMATED_TOTAL_GAS_USED] @@ -2029,14 +2974,14 @@ ONCHAIN EXECUTION COMPLETE & SUCCESSFUL. Warning: Script contains a transaction to 0x0000000000000000000000000000000000000000 which does not contain any code. "#]]); -}); +} // https://github.com/foundry-rs/foundry/issues/7833 -forgetest_async!(error_no_create2, |prj, cmd| { +#[forgetest_init] +async fn error_no_create2(prj: _, cmd: _) { let (_api, handle) = spawn(NodeConfig::test().with_disable_default_create2_deployer(true)).await; - foundry_test_utils::util::initialize(prj.root()); prj.add_script( "Foo", r#" @@ -2051,8 +2996,7 @@ contract SimpleScript is Script { } } "#, - ) - .unwrap(); + ); cmd.args([ "script", @@ -2067,13 +3011,13 @@ contract SimpleScript is Script { Error: script failed: missing CREATE2 deployer: 0x4e59b44847b379578588920cA78FbF26c0B4956C "#]]); -}); +} -forgetest_async!(can_switch_forks_in_setup, |prj, cmd| { +#[forgetest_init] +async fn can_switch_forks_in_setup(prj: _, cmd: _) { let (_api, handle) = spawn(NodeConfig::test().with_disable_default_create2_deployer(true)).await; - foundry_test_utils::util::initialize(prj.root()); let url = handle.http_endpoint(); prj.add_script( @@ -2094,8 +3038,7 @@ contract SimpleScript is Script { } "# .replace("", &url), - ) - .unwrap(); + ); cmd.args([ "script", @@ -2119,10 +3062,11 @@ Warning (2018): Function state mutability can be restricted to view Script ran successfully. "#]]); -}); +} // Asserts that running the same script twice only deploys library once. -forgetest_async!(can_deploy_library_create2, |prj, cmd| { +#[forgetest] +async fn can_deploy_library_create2(prj: _, cmd: _) { let (_api, handle) = spawn(NodeConfig::test()).await; let mut tester = ScriptTester::new_broadcast(cmd, &handle.http_endpoint(), prj.root()); @@ -2146,11 +3090,12 @@ forgetest_async!(can_deploy_library_create2, |prj, cmd| { .broadcast(ScriptOutcome::OkBroadcast) .assert_nonce_increment(&[(0, 1), (1, 1)]) .await; -}); +} // Asserts that running the same script twice only deploys library once when using different // senders. -forgetest_async!(can_deploy_library_create2_different_sender, |prj, cmd| { +#[forgetest] +async fn can_deploy_library_create2_different_sender(prj: _, cmd: _) { let (_api, handle) = spawn(NodeConfig::test()).await; let mut tester = ScriptTester::new_broadcast(cmd, &handle.http_endpoint(), prj.root()); @@ -2175,13 +3120,13 @@ forgetest_async!(can_deploy_library_create2_different_sender, |prj, cmd| { .broadcast(ScriptOutcome::OkBroadcast) .assert_nonce_increment(&[(2, 2)]) .await; -}); +} // -forgetest_async!(test_broadcast_raw_create2_deployer, |prj, cmd| { +#[forgetest_init] +async fn test_broadcast_raw_create2_deployer(prj: _, cmd: _) { let (api, handle) = spawn(NodeConfig::test().with_disable_default_create2_deployer(true)).await; - foundry_test_utils::util::initialize(prj.root()); prj.add_script( "Foo", r#" @@ -2199,8 +3144,7 @@ contract SimpleScript is Script { } } "#, - ) - .unwrap(); + ); cmd.args([ "script", @@ -2225,7 +3169,9 @@ Script ran successfully. Chain 31337 -[ESTIMATED_GAS_PRICE] +[ESTIMATED_MAX_FEE_PER_GAS] +[ESTIMATED_BASE_FEE_PER_GAS] +[ESTIMATED_PRIORITY_FEE_PER_GAS] [ESTIMATED_TOTAL_GAS_USED] @@ -2251,27 +3197,417 @@ ONCHAIN EXECUTION COMPLETE & SUCCESSFUL. .unwrap() .is_empty() ); -}); +} -forgetest_init!(can_get_script_wallets, |prj, cmd| { - let script = prj - .add_source( - "Foo", +// A raw transaction signed with too little gas must fail the on-chain simulation instead of being +// broadcast and running out of gas on-chain. +#[forgetest_init] +async fn test_broadcast_raw_transaction_tight_gas_fails_simulation(prj: _, cmd: _) { + let (_api, handle) = spawn(NodeConfig::test()).await; + + // The raw transaction is an EIP-1559 transaction on chain 31337, signed by anvil account 1 + // with nonce 0. It calls `Sink.set()` at the address of the first deployment from account 0 + // with a gas limit of 30,000, below the ~43,000 the call needs. + prj.add_script( + "TightGas", + r#" +import "forge-std/Script.sol"; + +contract Sink { + uint256 public x; + + function set() external { + x = 1; + } +} + +contract TightGasScript is Script { + function run() external { + vm.startBroadcast(); + new Sink(); + vm.broadcastRawTransaction( + hex"02f86c827a6980018477359401827530945fbdb2315678afecb367f032d93f642f64180aa38084b8e010dec001a0cad6fbbb0b7a55a6db6fc622dcd6630ef848367bb5977fac9d6c3a464e893dd1a051f767f64247e57f7e3756d08f15383d3e0f7da8551abadeacf0d8d23998f1e5" + ); + vm.stopBroadcast(); + } +} +"#, + ); + + cmd.args([ + "script", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--rpc-url", + &handle.http_endpoint(), + "--broadcast", + "TightGasScript", + ]); + + cmd.assert_failure().stderr_eq(str![[r#" +Error: Simulated execution failed. + +"#]]); + + let provider = handle.http_provider(); + for sender in [ + address!("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"), + address!("0x70997970C51812dc3A010C7d01b50e0d17dc79C8"), + ] { + assert_eq!(provider.get_transaction_count(sender).await.unwrap(), 0); + } +} + +// A transaction's fixed gas limit must not replace the block gas limit observed by contracts. +#[forgetest] +async fn broadcast_raw_transaction_preserves_block_gas_limit_fork(prj: _, cmd: _) { + let (_api, handle) = spawn(NodeConfig::test().with_gas_limit(Some(30_000_000))).await; + let sender = handle.dev_wallets().next().unwrap().address(); + let signer = handle.dev_wallets().nth(1).unwrap(); + let target = sender.create(0); + let mut transaction = TxEip1559 { + chain_id: 31337, + gas_limit: 500_000, + max_fee_per_gas: 2_000_000_001, + max_priority_fee_per_gas: 1, + to: TxKind::Call(target), + input: keccak256("check()")[..4].to_vec().into(), + ..Default::default() + }; + let signature = signer.sign_transaction_sync(&mut transaction).unwrap(); + let transaction = transaction.into_signed(signature); + let hash = *transaction.hash(); + let mut raw = Vec::new(); + transaction.eip2718_encode(&mut raw); + + prj.add_script( + "BlockGasLimit.s.sol", + &format!( r#" +interface Vm {{ + function startBroadcast() external; + function stopBroadcast() external; + function broadcastRawTransaction(bytes calldata data) external; +}} + +contract BlockGasLimit {{ + uint256 public observed; + + function check() external {{ + require(block.gaslimit > 1_000_000, "block gas too low"); + observed = block.gaslimit; + }} +}} + +contract BlockGasLimitScript {{ + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + function run() external {{ + vm.startBroadcast(); + new BlockGasLimit(); + vm.broadcastRawTransaction(hex"{}"); + vm.stopBroadcast(); + }} +}} +"#, + hex::encode(raw) + ), + ); + + cmd.args([ + "script", + "BlockGasLimitScript", + "--rpc-url", + &handle.http_endpoint(), + "--unlocked", + "--sender", + &sender.to_string(), + "--broadcast", + ]) + .assert_success() + .stderr_eq(str![""]) + .stdout_eq(str![[r#" +... +ONCHAIN EXECUTION COMPLETE & SUCCESSFUL. +... +"#]]); + + let provider = handle.http_provider(); + let receipt = provider.get_transaction_receipt(hash).await.unwrap().unwrap(); + assert!(receipt.status()); + assert_eq!(provider.get_transaction_count(sender).await.unwrap(), 1); + assert_eq!(provider.get_transaction_count(signer.address()).await.unwrap(), 1); + assert_eq!(provider.get_storage_at(target, U256::ZERO).await.unwrap(), U256::from(30_000_000)); +} + +#[forgetest] +async fn broadcast_raw_transaction_preserves_access_list_fork(prj: _, cmd: _) { + assert_raw_transaction_access_list(&prj, &mut cmd, NodeConfig::test()).await; +} + +#[cfg(feature = "monad")] +#[forgetest] +async fn monad_broadcast_raw_transaction_preserves_access_list_fork(prj: _, cmd: _) { + prj.update_config(|config| { + config.networks = foundry_evm_networks::NetworkConfigs::with_monad(); + config.hardfork = Some("monad:MonadNine".parse().unwrap()); + }); + assert_raw_transaction_access_list( + &prj, + &mut cmd, + NodeConfig::test_monad().with_hardfork(Some("monad:MonadNine".parse().unwrap())), + ) + .await; +} + +async fn assert_raw_transaction_access_list( + prj: &TestProject, + cmd: &mut TestCommand, + config: NodeConfig, +) { + let (api, handle) = spawn(config.with_gas_limit(Some(30_000_000))).await; + let provider = handle.http_provider(); + let signer = handle.dev_wallets().nth(1).unwrap(); + let endpoint = handle.http_endpoint(); + let sender = handle.dev_wallets().next().unwrap().address().to_string(); + add_raw_transaction_script(prj); + let common = [ + "script", + "RawTransactionScript", + "--rpc-url", + endpoint.as_str(), + "--unlocked", + "--sender", + sender.as_str(), + "--broadcast", + ]; + + // Read 400 distinct slots, then record successful execution in slot 400. The same code + // serves as a call's runtime or a creation's initcode, returning a one-byte STOP runtime. + let mut code = Vec::new(); + for slot in 0u16..400 { + code.push(opcode::PUSH2); + code.extend_from_slice(&slot.to_be_bytes()); + code.extend_from_slice(&[opcode::SLOAD, opcode::POP]); + } + code.extend_from_slice(&[opcode::PUSH1, 1, opcode::PUSH2]); + code.extend_from_slice(&400u16.to_be_bytes()); + code.extend_from_slice(&[opcode::SSTORE, opcode::PUSH1, 1, opcode::PUSH0, opcode::RETURN]); + + for create in [false, true] { + let target = + if create { signer.address().create(0) } else { Address::with_last_byte(0x42) }; + if !create { + api.anvil_set_code(target, code.clone().into()).await.unwrap(); + } + let access_list = AccessList(vec![AccessListItem { + address: target, + storage_keys: (0..400).map(|slot| B256::from(U256::from(slot))).collect(), + }]); + for transaction_type in [1, 2] { + let sign = |gas_limit, access_list: AccessList| -> TxEnvelope { + let to = if create { TxKind::Create } else { TxKind::Call(target) }; + let input = if create { code.clone().into() } else { Bytes::new() }; + let mut transaction = TransactionRequest { + transaction_type: Some(transaction_type), + chain_id: Some(31337), + nonce: Some(0), + gas: Some(gas_limit), + gas_price: (transaction_type == 1).then_some(2_000_000_001), + max_fee_per_gas: (transaction_type == 2).then_some(2_000_000_001), + max_priority_fee_per_gas: (transaction_type == 2).then_some(1), + to: Some(to), + input: input.into(), + access_list: Some(access_list), + ..Default::default() + } + .build_typed_tx() + .unwrap(); + let signature = signer.sign_transaction_sync(&mut transaction).unwrap(); + transaction.into_signed(signature).into() + }; + + // Measure real execution before choosing a tight cap. Missing the access list must + // make this exact cap insufficient, for both CALL and CREATE and both typed envelopes. + let snapshot = api.evm_snapshot().await.unwrap(); + let transaction = sign(2_000_000, access_list.clone()); + let receipt = provider + .send_raw_transaction(&transaction.encoded_2718()) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert!(receipt.status()); + let gas_limit = receipt.gas_used() + 1_000; + assert!(api.evm_revert(snapshot).await.unwrap()); + + let snapshot = api.evm_snapshot().await.unwrap(); + let without_list = sign(gas_limit, AccessList::default()); + let receipt = provider + .send_raw_transaction(&without_list.encoded_2718()) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert!(!receipt.status()); + assert_eq!(receipt.gas_used(), gas_limit); + assert!(api.evm_revert(snapshot).await.unwrap()); + + let failure = cmd + .forge_fuse() + .args(common) + .args(["--sig", "run(bytes)", &hex::encode_prefixed(without_list.encoded_2718())]) + .assert_failure(); + if create { + failure.stderr_eq(str![[r#" +Error: Failed with `EvmError: OutOfGas`: + +Error: Simulated execution failed. + +"#]]); + } else { + failure.stderr_eq(str![[r#" +Error: Simulated execution failed. + +"#]]); + } + assert_eq!(provider.get_transaction_count(signer.address()).await.unwrap(), 0); + assert_eq!(provider.get_storage_at(target, U256::from(400)).await.unwrap(), U256::ZERO); + + let snapshot = api.evm_snapshot().await.unwrap(); + let transaction = sign(gas_limit, access_list.clone()); + cmd.forge_fuse() + .args(common) + .args(["--sig", "run(bytes)", &hex::encode_prefixed(transaction.encoded_2718())]) + .assert_success() + .stderr_eq(str![""]) + .stdout_eq(str![[r#" +... +ONCHAIN EXECUTION COMPLETE & SUCCESSFUL. +... +"#]]); + let receipt = + provider.get_transaction_receipt(*transaction.tx_hash()).await.unwrap().unwrap(); + assert!(receipt.status()); + assert!(receipt.gas_used() < gas_limit); + assert_eq!(provider.get_transaction_count(signer.address()).await.unwrap(), 1); + assert_eq!( + provider.get_storage_at(target, U256::from(400)).await.unwrap(), + U256::from(1) + ); + if create { + assert_eq!(receipt.contract_address(), Some(target)); + assert_eq!(provider.get_code_at(target).await.unwrap(), bytes!("00")); + } + assert!(api.evm_revert(snapshot).await.unwrap()); + } + } +} + +#[forgetest] +async fn broadcast_raw_transaction_preserves_authorization_and_access_list_fork(prj: _, cmd: _) { + let (api, handle) = spawn(NodeConfig::test()).await; + let signer = handle.dev_wallets().nth(1).unwrap(); + let authority = handle.dev_wallets().nth(2).unwrap(); + let delegate = Address::with_last_byte(0x42); + // Delegated execution increments slot zero in the authority's storage. + api.anvil_set_code(delegate, bytes!("5f546001015f5500")).await.unwrap(); + let authorization = Authorization { chain_id: U256::from(31337), address: delegate, nonce: 0 }; + let signature = authority.sign_hash_sync(&authorization.signature_hash()).unwrap(); + let mut transaction = TxEip7702 { + chain_id: 31337, + gas_limit: 150_000, + max_fee_per_gas: 2_000_000_001, + max_priority_fee_per_gas: 1, + to: authority.address(), + access_list: AccessList(vec![AccessListItem { + address: authority.address(), + storage_keys: vec![B256::ZERO], + }]), + authorization_list: vec![authorization.into_signed(signature)], + ..Default::default() + }; + let signature = signer.sign_transaction_sync(&mut transaction).unwrap(); + let transaction = TxEnvelope::from(transaction.into_signed(signature)); + add_raw_transaction_script(&prj); + cmd.args([ + "script", + "RawTransactionScript", + "--rpc-url", + &handle.http_endpoint(), + "--unlocked", + "--sender", + &handle.dev_wallets().next().unwrap().address().to_string(), + "--broadcast", + "--sig", + "run(bytes)", + &hex::encode_prefixed(transaction.encoded_2718()), + ]) + .assert_success() + .stderr_eq(str![""]) + .stdout_eq(str![[r#" +... +ONCHAIN EXECUTION COMPLETE & SUCCESSFUL. +... +"#]]); + + let provider = handle.http_provider(); + let receipt = provider.get_transaction_receipt(*transaction.tx_hash()).await.unwrap().unwrap(); + assert!(receipt.status()); + assert_eq!(provider.get_transaction_count(signer.address()).await.unwrap(), 1); + assert_eq!(provider.get_transaction_count(authority.address()).await.unwrap(), 1); + assert_eq!( + provider.get_storage_at(authority.address(), U256::ZERO).await.unwrap(), + U256::from(1) + ); + let mut delegation = hex!("ef0100").to_vec(); + delegation.extend_from_slice(delegate.as_slice()); + assert_eq!(provider.get_code_at(authority.address()).await.unwrap(), Bytes::from(delegation)); +} + +fn add_raw_transaction_script(prj: &TestProject) { + prj.add_script( + "RawTransaction.s.sol", + r#" +interface Vm { + function startBroadcast() external; + function stopBroadcast() external; + function broadcastRawTransaction(bytes calldata data) external; +} + +contract RawTransactionScript { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + function run(bytes calldata transaction) external { + vm.startBroadcast(); + vm.broadcastRawTransaction(transaction); + vm.stopBroadcast(); + } +} +"#, + ); +} + +#[forgetest_init] +fn can_get_script_wallets(prj: _, cmd: _) { + let script = prj.add_source( + "Foo", + r#" import "forge-std/Script.sol"; interface Vm { - function getWallets() external returns (address[] memory wallets); + function getWallets() external view returns (address[] memory wallets); } contract WalletScript is Script { - function run() public { + function run() public view { address[] memory wallets = Vm(address(vm)).getWallets(); console.log(wallets[0]); } }"#, - ) - .unwrap(); + ); cmd.arg("script") .arg(script) .args([ @@ -2291,9 +3627,10 @@ Script ran successfully. 0xa0Ee7A142d267C1f36714E4a8F75612F20a79720 "#]]); -}); +} -forgetest_init!(can_remember_keys, |prj, cmd| { +#[forgetest_init] +fn can_remember_keys(prj: _, cmd: _) { let script = prj .add_source( "Foo", @@ -2314,8 +3651,7 @@ contract WalletScript is Script { } } }"#, - ) - .unwrap(); + ); cmd.arg("script").arg(script).assert_success().stdout_eq(str![[r#" [COMPILING_FILES] with [SOLC_VERSION] [SOLC_VERSION] [ELAPSED] @@ -2329,12 +3665,17 @@ Script ran successfully. 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC "#]]); -}); +} -forgetest_async!(can_simulate_with_default_sender, |prj, cmd| { +#[forgetest_init] +async fn can_simulate_with_default_sender(prj: _, cmd: _) { let (_api, handle) = spawn(NodeConfig::test()).await; - foundry_test_utils::util::initialize(prj.root()); + prj.update_config(|config| { + config.verbosity = 0; + config.tracing.verbosity = 4; + config.tracing.trace_depth = Some(0); + }); prj.add_script( "Script.s.sol", r#" @@ -2357,24 +3698,15 @@ contract SimpleScript is Script { } } "#, - ) - .unwrap(); + ); - cmd.arg("script").args(["SimpleScript", "--fork-url", &handle.http_endpoint(), "-vvvv"]); + cmd.arg("script").args(["SimpleScript", "--fork-url", &handle.http_endpoint()]); cmd.assert_success().stdout_eq(str![[r#" [COMPILING_FILES] with [SOLC_VERSION] [SOLC_VERSION] [ELAPSED] Compiler run successful! Traces: [..] SimpleScript::run() - ├─ [0] VM::startBroadcast() - │ └─ ← [Return] - ├─ [..] → new A@0x5b73C5498c1E3b4dbA84de0F1833c4a029d90519 - │ └─ ← [Return] 175 bytes of code - ├─ [..] → new B@0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496 - │ ├─ [..] A::getValue() [staticcall] - │ │ └─ ← [Return] 100 - │ └─ ← [Return] 62 bytes of code └─ ← [Stop] @@ -2388,17 +3720,15 @@ Simulated On-chain Traces: └─ ← [Return] 175 bytes of code [..] → new B@0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496 - ├─ [..] A::getValue() [staticcall] - │ └─ ← [Return] 100 └─ ← [Return] 62 bytes of code ... "#]]); -}); +} -forgetest_async!(should_detect_additional_contracts, |prj, cmd| { +#[forgetest_init] +async fn should_detect_additional_contracts(prj: _, cmd: _) { let (_api, handle) = spawn(NodeConfig::test()).await; - foundry_test_utils::util::initialize(prj.root()); prj.add_source( "Foo", r#" @@ -2420,8 +3750,7 @@ contract ContractScript is Script { } } "#, - ) - .unwrap(); + ); cmd.arg("script") .args([ "ContractScript", @@ -2436,15 +3765,16 @@ contract ContractScript is Script { .find(|file| file.ends_with("run-latest.json")) .expect("No broadcast artifacts"); - let sequence: ScriptSequence = foundry_common::fs::read_json_file(&run_latest).unwrap(); + let sequence: ScriptSequence = + foundry_common::fs::read_json_file(&run_latest).unwrap(); assert_eq!(sequence.transactions.len(), 2); assert_eq!(sequence.transactions[1].additional_contracts.len(), 1); -}); +} // -forgetest_async!(should_set_correct_sender_nonce_via_cli, |prj, cmd| { - foundry_test_utils::util::initialize(prj.root()); +#[forgetest_init] +async fn should_set_correct_sender_nonce_via_cli(prj: _, cmd: _) { prj.add_script( "MyScript.s.sol", r#" @@ -2456,8 +3786,7 @@ forgetest_async!(should_set_correct_sender_nonce_via_cli, |prj, cmd| { } } "#, - ) - .unwrap(); + ); let rpc_url = next_http_archive_rpc_url(); @@ -2480,12 +3809,88 @@ forgetest_async!(should_set_correct_sender_nonce_via_cli, |prj, cmd| { ... == Logs == sender nonce 1124703[..]"#]]); -}); +} -forgetest_async!(dryrun_without_broadcast, |prj, cmd| { - let (_api, handle) = spawn(NodeConfig::test()).await; +#[forgetest_init] +async fn should_override_sender_nonce_via_cli(prj: _, cmd: _) { + let (_api, handle) = + spawn(NodeConfig::test().with_disable_default_create2_deployer(true)).await; + + prj.add_script( + "MyScript.s.sol", + r#" + import {Script} from "forge-std/Script.sol"; + + library Lib { + function value() public pure returns (uint256) { + return 42; + } + } + + contract UsesLib { + uint256 public immutable value; + + constructor() { + value = Lib.value(); + } + } + + contract MyScript is Script { + function run() public { + vm.startBroadcast(); + new UsesLib(); + vm.stopBroadcast(); + } + } + "#, + ); + + cmd.args([ + "script", + "MyScript", + "--sender", + "0x1000000000000000000000000000000000000000", + "--sender-nonce", + "7", + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success() + .stdout_eq(str![[r#"[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! +Script ran successfully. + +## Setting up 1 EVM. + +========================== + +Chain 31337 + +[ESTIMATED_MAX_FEE_PER_GAS] +[ESTIMATED_BASE_FEE_PER_GAS] +[ESTIMATED_PRIORITY_FEE_PER_GAS] + +[ESTIMATED_TOTAL_GAS_USED] + +[ESTIMATED_AMOUNT_REQUIRED] + +========================== + +SIMULATION COMPLETE. To broadcast these transactions, add --broadcast and wallet configuration(s) to the previous command. See forge script --help for more. + +[SAVED_TRANSACTIONS] + +[SAVED_SENSITIVE_VALUES] + + +"#]]); +} + +#[forgetest_init] +async fn dryrun_without_broadcast(prj: _, cmd: _) { + let (_api, handle) = spawn(NodeConfig::test()).await; - foundry_test_utils::util::initialize(prj.root()); prj.add_source( "Foo", r#" @@ -2495,10 +3900,11 @@ contract Called { event log_string(string); uint256 public x; uint256 public y; - function run(uint256 _x, uint256 _y) external { + function run(uint256 _x, uint256 _y) external returns (uint256 result) { x = _x; y = _y; emit log_string("script ran"); + return _x + _y; } } @@ -2510,8 +3916,7 @@ contract DryRunTest is Script { } } "#, - ) - .unwrap(); + ); cmd.arg("script") .args([ @@ -2532,10 +3937,10 @@ Traces: ├─ [0] VM::startBroadcast() │ └─ ← [Return] ├─ [..] → new Called@0x5FbDB2315678afecb367f032d93F642f64180aa3 - │ └─ ← [Return] 567 bytes of code + │ └─ ← [Return] 700 bytes of code ├─ [..] Called::run(123, 456) │ ├─ emit log_string(val: "script ran") - │ └─ ← [Stop] + │ └─ ← [Return] 579 └─ ← [Stop] @@ -2548,19 +3953,21 @@ Script ran successfully. ========================== Simulated On-chain Traces: - [113557] → new Called@0x5FbDB2315678afecb367f032d93F642f64180aa3 - └─ ← [Return] 567 bytes of code + [140181] → new Called@0x5FbDB2315678afecb367f032d93F642f64180aa3 + └─ ← [Return] 700 bytes of code - [46595] Called::run(123, 456) + [46966] Called::run(123, 456) ├─ emit log_string(val: "script ran") - └─ ← [Stop] + └─ ← [Return] 579 ========================== Chain 31337 -[ESTIMATED_GAS_PRICE] +[ESTIMATED_MAX_FEE_PER_GAS] +[ESTIMATED_BASE_FEE_PER_GAS] +[ESTIMATED_PRIORITY_FEE_PER_GAS] [ESTIMATED_TOTAL_GAS_USED] @@ -2585,14 +3992,14 @@ maxFeePerGas maxPriorityFeePerGas nonce 0 to -type 0 +type EIP-1559 value 0 ### Transaction 2 ### accessList [] chainId 31337 -gasLimit 93856 +gasLimit 99920 gasPrice input 0x7357f5d2000000000000000000000000000000000000000000000000000000000000007b00000000000000000000000000000000000000000000000000000000000001c8 maxFeePerBlobGas @@ -2600,10 +4007,10 @@ maxFeePerGas maxPriorityFeePerGas nonce 1 to 0x5FbDB2315678afecb367f032d93F642f64180aa3 -type 0 +type EIP-1559 value 0 contract: Called(0x5FbDB2315678afecb367f032d93F642f64180aa3) -data (decoded): run(uint256,uint256)( +data (decoded): run( 123, 456 ) @@ -2617,11 +4024,216 @@ SIMULATION COMPLETE. To broadcast these transactions, add --broadcast and wallet "#]]); -}); +} + +#[forgetest_init] +async fn unused_libraries_conditional_6215(prj: _, cmd: _) { + let (_api, handle) = spawn(NodeConfig::test()).await; + prj.add_source( + "UnusedLibraries", + r#" +import "forge-std/Script.sol"; +library Lib1 { function value() external pure returns (uint256) { return 1; } } +library Lib2 { function value() external pure returns (uint256) { return 2; } } +contract ContractUsingLib1 { function value() external view returns (uint256) { return Lib1.value(); } } +contract ContractUsingLib2 { function value() external view returns (uint256) { return Lib2.value(); } } +contract UnusedLibraries is Script { + function run(uint256 which) external { + vm.startBroadcast(); + if (which == 1) new ContractUsingLib1(); + else new ContractUsingLib2(); + vm.stopBroadcast(); + } +} +"#, + ); + cmd.arg("script") + .args(["UnusedLibraries", "--sig", "run(uint256)", "1", "--rpc-url"]) + .arg(handle.http_endpoint()) + .assert_success(); + let sequence = latest_dry_run_sequence(prj.root()); + let names = sequence + .transactions + .iter() + .filter_map(|tx| tx.contract_name.as_deref()) + .collect::>(); + assert_eq!(names, ["Lib1", "ContractUsingLib1"]); + assert_eq!(sequence.libraries.len(), 1); + assert!(sequence.libraries[0].contains(":Lib1:")); +} + +#[forgetest_init] +async fn wallet_signing_skips_library_optimization(prj: _, cmd: _) { + let (_api, handle) = spawn(NodeConfig::test()).await; + prj.add_source( + "WalletSigningLibraries", + r#" +import "forge-std/Script.sol"; +library SignLib1 { function value() external pure returns (uint256) { return 1; } } +library SignLib2 { function value() external pure returns (uint256) { return 2; } } +contract UsesSignLib1 { function value() external view returns (uint256) { return SignLib1.value(); } } +contract UsesSignLib2 { function value() external view returns (uint256) { return SignLib2.value(); } } +contract WalletSigningLibraries is Script { + function run(uint256 overload) external { + bytes32 digest = keccak256("digest"); + address signer = vm.rememberKey(1); + bytes32 r; + if (overload == 0) (, r,) = vm.sign(digest); + else if (overload == 1) (, r,) = vm.sign(signer, digest); + else if (overload == 2) (r,) = vm.signCompact(digest); + else (r,) = vm.signCompact(signer, digest); + require(r != bytes32(0)); + + vm.startBroadcast(1); + if (overload < 4) new UsesSignLib1(); + else new UsesSignLib2(); + vm.stopBroadcast(); + } +} +"#, + ); + for overload in 0..4 { + cmd.forge_fuse() + .arg("script") + .args(["WalletSigningLibraries", "--sig", "run(uint256)"]) + .arg(overload.to_string()) + .args([ + "--rpc-url", + handle.http_endpoint().as_str(), + "--sender", + "0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf", + ]) + .assert_success(); + let sequence = latest_dry_run_sequence(prj.root()); + let names = sequence + .transactions + .iter() + .filter_map(|tx| tx.contract_name.as_deref()) + .collect::>(); + assert_eq!(names, ["SignLib1", "SignLib2", "UsesSignLib1"], "overload {overload}"); + } +} + +#[forgetest_init] +async fn candidate_rpc_side_effect_is_blocked(prj: _, cmd: _) { + let (api, handle) = spawn(NodeConfig::test()).await; + assert_eq!( + foundry_common::LIBRARY_DEPLOYER.create(0), + address!("0x5F65cD7D792E9746EF82929D60de9a1C526f93A5") + ); + prj.add_source( + "CandidateRpcSideEffect", + r#" +import "forge-std/Script.sol"; +library LocalLib { function value() external pure returns (uint256) { return 2; } } +library RequiredLib { function value() external pure returns (uint256) { return 1; } } +contract UsesRequiredLib { function value() external view returns (uint256) { return RequiredLib.value(); } } +contract CandidateRpcSideEffect is Script { + address constant LOCAL_LIB = 0x5F65cD7D792E9746EF82929D60de9a1C526f93A5; + address constant MUTATED = 0x0000000000000000000000000000000000001234; + + function run() external { + if (address(LocalLib) == LOCAL_LIB) { + vm.rpc("anvil_setBalance", string.concat("[\"", vm.toString(MUTATED), "\", \"0x1\"]")); + } + vm.startBroadcast(); + new UsesRequiredLib(); + vm.stopBroadcast(); + } +} +"#, + ); + cmd.arg("script") + .args(["CandidateRpcSideEffect", "--rpc-url"]) + .arg(handle.http_endpoint()) + .assert_success(); + + let sequence = latest_dry_run_sequence(prj.root()); + let names = sequence + .transactions + .iter() + .filter_map(|tx| tx.contract_name.as_deref()) + .collect::>(); + assert_eq!(names, ["LocalLib", "RequiredLib", "UsesRequiredLib"]); + let mutated = address!("0x0000000000000000000000000000000000001234"); + assert_eq!(api.balance(mutated, None).await.unwrap(), U256::ZERO); +} + +#[forgetest_init] +async fn library_optimization_skips_changed_fork_block(prj: _, cmd: _) { + let (_api, handle) = spawn(NodeConfig::test()).await; + prj.add_source( + "ChangedForkLibraries", + r#" +import "forge-std/Script.sol"; +library ForkLib1 { function value() external pure returns (uint256) { return 1; } } +library ForkLib2 { function value() external pure returns (uint256) { return 2; } } +contract UsesForkLib1 { function value() external view returns (uint256) { return ForkLib1.value(); } } +contract UsesForkLib2 { function value() external view returns (uint256) { return ForkLib2.value(); } } +contract ChangedForkLibraries is Script { + function run(uint256 which) external { + vm.rollFork(block.number); + vm.startBroadcast(); + if (which == 1) new UsesForkLib1(); + else new UsesForkLib2(); + vm.stopBroadcast(); + } +} +"#, + ); + cmd.arg("script") + .args(["ChangedForkLibraries", "--sig", "run(uint256)", "1", "--rpc-url"]) + .arg(handle.http_endpoint()) + .assert_success(); + let sequence = latest_dry_run_sequence(prj.root()); + let names = sequence + .transactions + .iter() + .filter_map(|tx| tx.contract_name.as_deref()) + .collect::>(); + assert_eq!(names, ["ForkLib1", "ForkLib2", "UsesForkLib1"]); +} + +#[forgetest_init] +async fn unused_library_called_locally_before_direct_create(prj: _, cmd: _) { + let (_api, handle) = spawn(NodeConfig::test()).await; + prj.add_source( + "OutsideLibrary", + r#" +import "forge-std/Script.sol"; +library OutsideLib { function value() external pure returns (uint256) { return 1; } } +library RequiredLib { function value() external pure returns (uint256) { return 2; } } +contract UsesRequiredLib { function value() external view returns (uint256) { return RequiredLib.value(); } } +contract OutsideLibrary is Script { + function run() external { + OutsideLib.value(); + vm.startBroadcast(); + new UsesRequiredLib(); + vm.stopBroadcast(); + } +} +"#, + ); + cmd.arg("script") + .args(["OutsideLibrary", "--rpc-url"]) + .arg(handle.http_endpoint()) + .assert_success(); + let sequence = latest_dry_run_sequence(prj.root()); + let names = sequence + .transactions + .iter() + .filter_map(|tx| tx.contract_name.as_deref()) + .collect::>(); + assert_eq!(names, ["RequiredLib", "UsesRequiredLib"]); + assert_eq!(sequence.libraries.len(), 1); + assert!(sequence.libraries[0].contains(":RequiredLib:")); +} // Tests warn when artifact source file no longer exists. // -forgetest_init!(should_warn_if_artifact_source_no_longer_exists, |prj, cmd| { +#[forgetest_init] +fn should_warn_if_artifact_source_no_longer_exists(prj: _, cmd: _) { + prj.initialize_default_contracts(); cmd.args(["script", "script/Counter.s.sol"]).assert_success().stdout_eq(str![[r#" ... Script ran successfully. @@ -2646,10 +4258,11 @@ Script ran successfully. ... "#]]); -}); +} // Tests that script reverts if it uses `address(this)`. -forgetest_init!(should_revert_on_address_opcode, |prj, cmd| { +#[forgetest_init] +fn should_revert_on_address_opcode(prj: _, cmd: _) { prj.add_script( "ScriptWithAddress.s.sol", r#" @@ -2661,8 +4274,7 @@ forgetest_init!(should_revert_on_address_opcode, |prj, cmd| { } } "#, - ) - .unwrap(); + ); cmd.arg("script").arg("ScriptWithAddress").assert_failure().stderr_eq(str![[r#" Error: script failed: Usage of `address(this)` detected in script contract. Script contracts are ephemeral and their addresses should not be relied upon. @@ -2679,13 +4291,176 @@ Script ran successfully. ... "#]]); -}); +} + +// Protect both broadcast overloads and durations, including calldata/constructor arguments. +#[forgetest_init] +fn script_broadcast_sender_mismatch(prj: _, cmd: _) { + for broadcast in [ + "vm.startBroadcast(address(0x1337))", + "vm.broadcast(address(0x1337))", + "vm.startBroadcast(uint256(1))", + "vm.broadcast(uint256(1))", + ] { + prj.add_script( + "SenderMismatch.s.sol", + &format!( + r#" +import {{Script}} from "forge-std/Script.sol"; + +contract Recipient {{ + address public owner; + constructor(address owner_) {{ owner = owner_; }} +}} + +contract SenderMismatch is Script {{ + function run() public {{ + {broadcast}; + new Recipient(msg.sender); + }} +}} +"# + ), + ); + prj.update_config(|config| config.script_execution_protection = true); + cmd.forge_fuse().args(["script", "SenderMismatch"]).assert_failure().stderr_eq(str![[r#" +Error: script failed: Usage of `msg.sender` inside a `broadcast` in script contract detected. `msg.sender` is `0x1804c8ab1f12e6bbf3894d4083f33e07309d1f38`, not the broadcast sender `[..]`. Use the `--sender` flag or pass the deployer address directly instead. + +"#]]); + + prj.update_config(|config| config.script_execution_protection = false); + cmd.forge_fuse().args(["script", "SenderMismatch"]).assert_success(); + } +} + +// Explicit and inferred senders, and the default no-argument broadcast, must remain usable. +#[forgetest_init] +fn script_broadcast_sender_matching(prj: _, cmd: _) { + prj.add_script( + "SenderMatching.s.sol", + r#" +import {Script, console} from "forge-std/Script.sol"; + +contract SenderMatching is Script { + function run() public { + vm.startBroadcast(); + console.log(msg.sender); + vm.stopBroadcast(); + } + + function explicitSender() public { + vm.startBroadcast(address(0x1337)); + console.log(msg.sender); + vm.stopBroadcast(); + } +} +"#, + ); + cmd.forge_fuse().args(["script", "SenderMatching"]).assert_success(); + cmd.forge_fuse() + .args([ + "script", + "SenderMatching", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + ]) + .assert_success(); + cmd.forge_fuse() + .args([ + "script", + "SenderMatching", + "--sig", + "explicitSender()", + "--sender", + "0x0000000000000000000000000000000000001337", + ]) + .assert_success(); +} + +// Outgoing calls and callbacks have their own msg.sender; only the broadcasting frame is guarded. +#[forgetest_init] +fn script_broadcast_sender_scope(prj: _, cmd: _) { + prj.add_script( + "SenderScope.s.sol", + r#" +import {Script} from "forge-std/Script.sol"; + +contract Callback { + address public creator; + constructor() { creator = msg.sender; } + + function check(SenderScope script) external { + require(msg.sender == address(0x1337)); + require(script.callback() == address(this)); + } +} + +contract SenderScope is Script { + // The address guard is installed after the script constructor runs. + SenderScope private self = SenderScope(address(this)); + + function callback() external view returns (address) { + return msg.sender; + } + + function run() public { + address caller = msg.sender; + vm.startBroadcast(address(0x1337)); + require(tx.origin == caller); + Callback target = new Callback(); + require(target.creator() == address(0x1337)); + target.check(self); + vm.stopBroadcast(); + require(msg.sender == caller); + } +} +"#, + ); + cmd.args(["script", "SenderScope"]).assert_success(); +} + +// An external script helper's caller can differ from tx.origin in either direction. +#[forgetest_init] +fn script_broadcast_sender_uses_frame_caller(prj: _, cmd: _) { + prj.add_script( + "FrameCaller.s.sol", + r#" +import {Script, console} from "forge-std/Script.sol"; + +contract FrameCaller is Script { + FrameCaller private self = FrameCaller(address(this)); + + function run() public { + self.helper(address(self)); + } + + function mismatch() public { + self.helper(0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38); + } + + function helper(address sender) external { + vm.startBroadcast(sender); + console.log(msg.sender); + vm.stopBroadcast(); + } +} +"#, + ); + cmd.forge_fuse().args(["script", "FrameCaller"]).assert_success(); + cmd.forge_fuse() + .args(["script", "FrameCaller", "--sig", "mismatch()"]) + .assert_failure() + .stderr_eq(str![[r#" +Error: script failed: Usage of `msg.sender` inside a `broadcast` in script contract detected. `msg.sender` is `[..]`, not the broadcast sender `0x1804c8ab1f12e6bbf3894d4083f33e07309d1f38`. Use the `--sender` flag or pass the deployer address directly instead. + +"#]]); +} // Tests that script warns if no tx to broadcast. // -forgetest_async!(warns_if_no_transactions_to_broadcast, |prj, cmd| { +#[forgetest_init] +async fn warns_if_no_transactions_to_broadcast(prj: _, cmd: _) { let (_api, handle) = spawn(NodeConfig::test()).await; - foundry_test_utils::util::initialize(prj.root()); prj.add_script( "NoTxScript.s.sol", r#" @@ -2699,8 +4474,7 @@ forgetest_async!(warns_if_no_transactions_to_broadcast, |prj, cmd| { } } "#, - ) - .unwrap(); + ); cmd.args([ "script", @@ -2718,11 +4492,12 @@ Warning: No transactions to broadcast. "# ]); -}); +} // Tests EIP-7702 broadcast -forgetest_async!(can_broadcast_txes_with_signed_auth, |prj, cmd| { - foundry_test_utils::util::initialize(prj.root()); +#[forgetest_init] +async fn can_broadcast_txes_with_signed_auth(prj: _, cmd: _) { + prj.initialize_default_contracts(); prj.add_script( "EIP7702Script.s.sol", r#" @@ -2749,8 +4524,7 @@ contract EIP7702Script is Script { } } "#, - ) - .unwrap(); + ); let node_config = NodeConfig::test().with_hardfork(Some(EthereumHardfork::Prague.into())); let (_api, handle) = spawn(node_config).await; @@ -2788,12 +4562,18 @@ Traces: ├─ [0] VM::signAndAttachDelegation(0x5FbDB2315678afecb367f032d93F642f64180aa3, "") │ └─ ← [Return] (0, 0xd4301eb9f82f747137a5f2c3dc3a5c2d253917cf99ecdc0d49f7bb85313c3159, 0x786d354f0bbd456f44116ddd3aa50475e989d72d8396005e5b3a12cede83fb68, 4, 0x5FbDB2315678afecb367f032d93F642f64180aa3) ├─ [..] 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266::increment() + │ ├─ storage changes: + │ │ @ 0: 0 → 1 │ └─ ← [Stop] ├─ [..] 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266::increment() + │ ├─ storage changes: + │ │ @ 0: 1 → 2 │ └─ ← [Stop] ├─ [0] VM::signAndAttachDelegation(0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512, "") │ └─ ← [Return] (0, 0xaba9128338f7ff036a0d2ecb96d4f4376389005cd565f87aba33b312570af962, 0x69acbe0831fb8ca95338bc4b908dcfebaf7b81b0f770a12c073ceb07b89fbdf3, 7, 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512) ├─ [..] 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266::setNumber(0) + │ ├─ storage changes: + │ │ @ 0: 2 → 0 │ └─ ← [Stop] ├─ [0] VM::signAndAttachDelegation(0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0, "") │ └─ ← [Return] (1, 0x3a3427b66e589338ce7ea06135650708f9152e93e257b4a5ec6eb86a3e09a2ce, 0x444651c354c89fd3312aafb05948e12c0a16220827a5e467705253ab4d8aa8d3, 9, 0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0) @@ -2819,16 +4599,22 @@ Simulated On-chain Traces: [..] → new Counter@0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0 └─ ← [Return] 481 bytes of code - [0] 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266::increment() + [..] 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266::increment() + ├─ storage changes: + │ @ 0: 0 → 1 └─ ← [Stop] - [0] 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266::increment() + [..] 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266::increment() + ├─ storage changes: + │ @ 0: 1 → 2 └─ ← [Stop] - [0] 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266::setNumber(0) + [..] 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266::setNumber(0) + ├─ storage changes: + │ @ 0: 2 → 0 └─ ← [Stop] - [0] 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266::setNumber(0) + [..] 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266::setNumber(0) └─ ← [Stop] @@ -2836,7 +4622,9 @@ Simulated On-chain Traces: Chain 31337 -[ESTIMATED_GAS_PRICE] +[ESTIMATED_MAX_FEE_PER_GAS] +[ESTIMATED_BASE_FEE_PER_GAS] +[ESTIMATED_PRIORITY_FEE_PER_GAS] [ESTIMATED_TOTAL_GAS_USED] @@ -2855,12 +4643,12 @@ ONCHAIN EXECUTION COMPLETE & SUCCESSFUL. "#]]); -}); +} // Tests EIP-7702 with multiple auth // Alice sends 5 ETH from Bob to Receiver1 and 1 ETH to Receiver2 -forgetest_async!(can_broadcast_txes_with_multiple_auth, |prj, cmd| { - foundry_test_utils::util::initialize(prj.root()); +#[forgetest_init] +async fn can_broadcast_txes_with_multiple_auth(prj: _, cmd: _) { prj.add_source( "BatchCallDelegation.sol", r#" @@ -2883,8 +4671,7 @@ contract BatchCallDelegation { } } "#, - ) - .unwrap(); + ); prj.add_script( "BatchCallDelegationScript.s.sol", @@ -2930,8 +4717,7 @@ contract BatchCallDelegationScript is Script { } } "#, - ) - .unwrap(); + ); let node_config = NodeConfig::test().with_hardfork(Some(EthereumHardfork::Prague.into())); let (api, handle) = spawn(node_config).await; @@ -2960,7 +4746,9 @@ Script ran successfully. Chain 31337 -[ESTIMATED_GAS_PRICE] +[ESTIMATED_MAX_FEE_PER_GAS] +[ESTIMATED_BASE_FEE_PER_GAS] +[ESTIMATED_PRIORITY_FEE_PER_GAS] [ESTIMATED_TOTAL_GAS_USED] @@ -3008,4 +4796,3269 @@ ONCHAIN EXECUTION COMPLETE & SUCCESSFUL. .unwrap(); assert_eq!(receiver2.nonce, 0); assert_eq!(receiver2.balance.to_string(), "101000000000000000000"); -}); +} + +// Tests that sponsored EIP-7702 authorities have their nonce bumped after a broadcast +// transaction consumes their authorization. +// Bob sponsors two delegations for Alice in separate transactions, then Alice broadcasts herself. +#[forgetest_init] +async fn can_broadcast_txes_with_sponsored_auth_nonces(prj: _, cmd: _) { + prj.add_source( + "Implementation.sol", + r#" +contract Implementation { + event Called(uint256 id); + + uint256 immutable id; + + constructor(uint256 id_) { + id = id_; + } + + function ping() external returns (uint256) { + emit Called(id); + return id; + } +} + "#, + ); + + prj.add_script( + "SponsoredDelegationScript.s.sol", + r#" +import {Script} from "forge-std/Script.sol"; +import {Vm} from "forge-std/Vm.sol"; +import {Implementation} from "../src/Implementation.sol"; + +contract SponsoredDelegationScript is Script { + address constant ALICE_ADDRESS = 0x70997970C51812dc3A010C7d01b50e0d17dc79C8; + uint256 constant ALICE_PK = 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d; + uint256 constant BOB_PK = 0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a; + + function run() public { + vm.startBroadcast(BOB_PK); + Implementation implementation1 = new Implementation(1); + Implementation implementation2 = new Implementation(2); + + Vm.SignedDelegation memory first = vm.signAndAttachDelegation(address(implementation1), ALICE_PK); + require(first.nonce == 0, "first auth nonce"); + require(Implementation(ALICE_ADDRESS).ping() == 1, "first local call"); + + Vm.SignedDelegation memory second = vm.signAndAttachDelegation(address(implementation2), ALICE_PK); + require(second.nonce == 1, "second auth nonce"); + require(Implementation(ALICE_ADDRESS).ping() == 2, "second local call"); + vm.stopBroadcast(); + + vm.startBroadcast(ALICE_PK); + new Implementation(3); + vm.stopBroadcast(); + } +} + "#, + ); + + let node_config = NodeConfig::test().with_hardfork(Some(EthereumHardfork::Prague.into())); + let (api, handle) = spawn(node_config).await; + + cmd.args([ + "script", + "script/SponsoredDelegationScript.s.sol", + "--rpc-url", + &handle.http_endpoint(), + "--non-interactive", + "--slow", + "--broadcast", + "--evm-version", + "prague", + ]) + .assert_success() + .stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! +Script ran successfully. + +## Setting up 1 EVM. + +========================== + +Chain 31337 + +[ESTIMATED_MAX_FEE_PER_GAS] +[ESTIMATED_BASE_FEE_PER_GAS] +[ESTIMATED_PRIORITY_FEE_PER_GAS] + +[ESTIMATED_TOTAL_GAS_USED] + +[ESTIMATED_AMOUNT_REQUIRED] + +========================== + + +========================== + +ONCHAIN EXECUTION COMPLETE & SUCCESSFUL. + +[SAVED_TRANSACTIONS] + +[SAVED_SENSITIVE_VALUES] + + +"#]]); + + let alice = address!("0x70997970C51812dc3A010C7d01b50e0d17dc79C8"); + let bob = address!("0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"); + + // Alice should be delegated to the second implementation. + let mut expected_code = hex!("ef0100").to_vec(); + expected_code.extend_from_slice(bob.create(1).as_slice()); + assert_eq!(api.get_code(alice, None).await.unwrap(), Bytes::from(expected_code)); + + // Alice nonce should be 3 (two auths and one tx sent). + assert_eq!(api.get_account(alice, None).await.unwrap().nonce, 3); + assert!(!api.get_code(alice.create(2), None).await.unwrap().is_empty()); +} + +// +#[forgetest_init] +async fn check_broadcast_log_with_additional_contracts(prj: _, cmd: _) { + prj.add_source( + "Counter.sol", + r#" +contract Counter { + uint256 public number; + + function setNumber(uint256 newNumber) public { + number = newNumber; + } + + function increment() public { + number++; + } +} + "#, + ); + prj.add_source( + "Factory.sol", + r#" +import {Counter} from "./Counter.sol"; + +contract Factory { + function deployCounter() public returns (Counter) { + return new Counter(); + } +} + "#, + ); + let deploy_script = prj.add_script( + "Factory.s.sol", + r#" +import "forge-std/Script.sol"; +import {Factory} from "../src/Factory.sol"; +import {Counter} from "../src/Counter.sol"; + +contract FactoryScript is Script { + Factory public factory; + Counter public counter; + + function setUp() public {} + + function run() public { + vm.startBroadcast(); + + factory = new Factory(); + counter = factory.deployCounter(); + + vm.stopBroadcast(); + } +} + "#, + ); + + let deploy_contract = deploy_script.display().to_string() + ":FactoryScript"; + let (_api, handle) = spawn(NodeConfig::test()).await; + cmd.args([ + "script", + &deploy_contract, + "--root", + prj.root().to_str().unwrap(), + "--fork-url", + &handle.http_endpoint(), + "--slow", + "--broadcast", + "--private-key", + "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + ]) + .assert_success(); + + let broadcast_log = prj.root().join("broadcast/Factory.s.sol/31337/run-latest.json"); + let script_sequence: ScriptSequence = serde_json::from_reader( + fs::File::open(prj.artifacts().join(broadcast_log)).expect("no broadcast log"), + ) + .expect("no script sequence"); + + let counter_contract = script_sequence + .transactions + .get(1) + .expect("no tx") + .additional_contracts + .first() + .expect("no Counter contract"); + assert_eq!(counter_contract.contract_name, Some("Counter".to_string())); +} + +// +#[forgetest_init] +async fn call_to_non_contract_address_does_not_panic(prj: _, cmd: _) { + let endpoint = rpc::next_http_archive_rpc_url(); + + prj.add_source( + "Counter.sol", + r#" +contract Counter { + uint256 public number; + + function setNumber(uint256 newNumber) public { + number = newNumber; + } + + function increment() public { + number++; + } +} + "#, + ); + + let deploy_script = prj.add_script( + "Counter.s.sol", + &r#" +import "forge-std/Script.sol"; +import {Counter} from "../src/Counter.sol"; + +contract CounterScript is Script { + Counter public counter; + function setUp() public {} + function run() public { + vm.createSelectFork(""); + vm.startBroadcast(); + counter = new Counter(); + vm.stopBroadcast(); + + vm.createSelectFork(""); + vm.startBroadcast(); + counter.increment(); + vm.stopBroadcast(); + } +} + "# + .replace("", &endpoint), + ); + + let (_api, handle) = spawn(NodeConfig::test()).await; + cmd.args([ + "script", + &deploy_script.display().to_string(), + "--root", + prj.root().to_str().unwrap(), + "--fork-url", + &handle.http_endpoint(), + "--slow", + "--broadcast", + "--private-key", + "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + ]) + .assert_failure() + .stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! +Traces: + [..] → new CounterScript@[..] + └─ ← [Return] 2162 bytes of code + + [..] CounterScript::setUp() + └─ ← [Stop] + + [..] CounterScript::run() + ├─ [..] VM::createSelectFork("") + │ └─ ← [Return] 1 + ├─ [..] VM::startBroadcast() + │ └─ ← [Return] + ├─ [..] → new Counter@[..] + │ └─ ← [Return] 481 bytes of code + ├─ [..] VM::stopBroadcast() + │ └─ ← [Return] + ├─ [..] VM::createSelectFork("") + │ └─ ← [Return] 2 + ├─ [..] VM::startBroadcast() + │ └─ ← [Return] + └─ ← [Revert] call to non-contract address [..] + + + +"#]]) + .stderr_eq(str![[r#" +Error: script failed: EvmError: Revert + +"#]]); +} + +// Test that --verify without --broadcast fails with a clear error message +#[forgetest] +fn verify_without_broadcast_fails(prj: _, cmd: _) { + let script = prj.add_source( + "Counter", + r#" +import "forge-std/Script.sol"; + +contract CounterScript is Script { + function run() external { + // Simple script that does nothing + } +} + "#, + ); + + cmd.args([ + "script", + script.to_str().unwrap(), + "--verify", + "--rpc-url", + "https://sepolia.infura.io/v3/test", + ]) + .assert_failure() + .stderr_eq(str![[r#" +error: the following required arguments were not provided: + --broadcast + +Usage: [..] script --broadcast --verify --rpc-url [ARGS]... + +For more information, try '--help'. + +"#]]); +} + +// +#[forgetest_init] +async fn can_broadcast_from_deploy_code_cheatcode(prj: _, cmd: _) { + prj.initialize_default_contracts(); + prj.add_script( + "Counter.s.sol", + r#" +import "forge-std/Script.sol"; +import {Vm} from "forge-std/Vm.sol"; +import {Counter} from "../src/Counter.sol"; +contract CounterScript is Script { + function run() public { + vm.startBroadcast(); + address addr1 = vm.deployCode("src/Counter.sol:Counter"); + Counter(addr1).increment(); + vm.stopBroadcast(); + } +} + "#, + ); + + let node_config = NodeConfig::test().with_hardfork(Some(EthereumHardfork::Prague.into())); + let (_api, handle) = spawn(node_config).await; + + cmd.args([ + "script", + "script/Counter.s.sol:CounterScript", + "--rpc-url", + &handle.http_endpoint(), + "-vvvv", + "--broadcast", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + ]) + .assert_success() + .stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! +Traces: + [..] CounterScript::run() + ├─ [0] VM::startBroadcast() + │ └─ ← [Return] + ├─ [0] VM::deployCode("src/Counter.sol:Counter") + │ ├─ [..] → new Counter@0x5FbDB2315678afecb367f032d93F642f64180aa3 + │ │ └─ ← [Return] 481 bytes of code + │ └─ ← [Return] Counter: [0x5FbDB2315678afecb367f032d93F642f64180aa3] + ├─ [..] Counter::increment() + │ └─ ← [Stop] + ├─ [0] VM::stopBroadcast() + │ └─ ← [Return] + └─ ← [Stop] + + +Script ran successfully. + +## Setting up 1 EVM. +========================== +Simulated On-chain Traces: + + [..] → new Counter@0x5FbDB2315678afecb367f032d93F642f64180aa3 + └─ ← [Return] 481 bytes of code + + [..] Counter::increment() + └─ ← [Stop] + + +========================== + +Chain 31337 + +[ESTIMATED_MAX_FEE_PER_GAS] +[ESTIMATED_BASE_FEE_PER_GAS] +[ESTIMATED_PRIORITY_FEE_PER_GAS] + +[ESTIMATED_TOTAL_GAS_USED] + +[ESTIMATED_AMOUNT_REQUIRED] + +========================== + + +========================== + +ONCHAIN EXECUTION COMPLETE & SUCCESSFUL. + +[SAVED_TRANSACTIONS] + +[SAVED_SENSITIVE_VALUES] + + +"#]]); +} + +// Regression: Salted `new Foo{salt: ...}(args)` in scripts must NOT be rewritten to +// `vm.deployCode(...)` by the dynamic test linking preprocessor. +// +// NOTE: `dynamic_test_linking` config option is not yet taken into account by `forge script`. +// The config is set explicitly here so the test fails if there is a regression. +#[forgetest_init] +async fn can_broadcast_salted_create2_in_script(prj: _, cmd: _) { + prj.update_config(|c| c.dynamic_test_linking = true); + prj.add_source( + "Token.sol", + r#" +contract Token { + string public name; + constructor(string memory _name) { name = _name; } +} + "#, + ); + prj.add_script( + "Salted.s.sol", + r#" +import "forge-std/Script.sol"; +import {Token} from "../src/Token.sol"; +contract SaltedScript is Script { + function run() external { + vm.startBroadcast(); + new Token{salt: bytes32(uint256(0xDEADBEEF))}("SaltedTok"); + vm.stopBroadcast(); + } +} + "#, + ); + + let (_api, handle) = spawn(NodeConfig::test()).await; + cmd.args([ + "script", + "script/Salted.s.sol:SaltedScript", + "--rpc-url", + &handle.http_endpoint(), + "--broadcast", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + ]) + .assert_success(); + + let run_latest = foundry_common::fs::json_files(&prj.root().join("broadcast")) + .find(|path| path.ends_with("run-latest.json")) + .expect("No broadcast artifacts"); + let content = foundry_common::fs::read_to_string(run_latest).unwrap(); + let json: Value = serde_json::from_str(&content).unwrap(); + let tx = &json["transactions"][0]; + + assert_eq!( + tx["transactionType"], "CREATE2", + "salted broadcast must be recorded as CREATE2, got: {tx}" + ); + assert_eq!(tx["contractName"], "Token"); + assert_eq!(tx["arguments"][0], "SaltedTok"); +} + +// Regression: same as above but the script contract lives under `src/` (not the configured +// script directory). The preprocessor must still detect it as a script via inheritance +// (`is Script`) and leave salted new-expressions untouched. +#[forgetest_init] +async fn can_broadcast_salted_create2_in_src_script(prj: _, cmd: _) { + prj.update_config(|c| c.dynamic_test_linking = true); + prj.add_source( + "Token.sol", + r#" +contract Token { + string public name; + constructor(string memory _name) { name = _name; } +} + "#, + ); + prj.add_source( + "SaltedSrc.s.sol", + r#" +import "forge-std/Script.sol"; +import {Token} from "./Token.sol"; +contract SaltedSrcScript is Script { + function run() external { + vm.startBroadcast(); + new Token{salt: bytes32(uint256(0xDEADBEEF))}("SaltedSrc"); + vm.stopBroadcast(); + } +} + "#, + ); + + let (_api, handle) = spawn(NodeConfig::test()).await; + cmd.args([ + "script", + "src/SaltedSrc.s.sol:SaltedSrcScript", + "--rpc-url", + &handle.http_endpoint(), + "--broadcast", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + ]) + .assert_success(); + + let run_latest = foundry_common::fs::json_files(&prj.root().join("broadcast")) + .find(|path| path.ends_with("run-latest.json")) + .expect("No broadcast artifacts"); + let content = foundry_common::fs::read_to_string(run_latest).unwrap(); + let json: Value = serde_json::from_str(&content).unwrap(); + let tx = &json["transactions"][0]; + + assert_eq!( + tx["transactionType"], "CREATE2", + "salted broadcast must be recorded as CREATE2, got: {tx}" + ); + assert_eq!(tx["contractName"], "Token"); + assert_eq!(tx["arguments"][0], "SaltedSrc"); +} + +// Regression: salted `vm.deployCode` must be broadcast through the CREATE2 factory so the +// on-chain address matches the address returned to the script, and must consume a single-call +// `vm.broadcast()`. +#[forgetest_init] +async fn can_broadcast_salted_deploy_code_in_script(prj: _, cmd: _) { + prj.add_source( + "Token.sol", + r#" +contract Child { + string public name; + constructor(string memory _name) { name = _name; } + function setName(string memory _name) external { name = _name; } +} + +contract Token { + string public name; + address public child; + constructor(string memory _name) payable { + name = _name; + child = address(new Child(_name)); + Child(child).setName("Child"); + } + function setName(string memory _name) external { name = _name; } +} + "#, + ); + prj.add_script( + "SaltedDeployCode.s.sol", + r#" +import "forge-std/Script.sol"; +import {Token} from "../src/Token.sol"; +contract SaltedDeployCodeScript is Script { + function run() external returns (address first, address second) { + vm.broadcast(); + first = vm.deployCode("src/Token.sol:Token", abi.encode("First"), 1, bytes32(uint256(1))); + Token(first).setName("Local"); + + vm.startBroadcast(); + Token(first).setName("Updated"); + second = vm.deployCode("src/Token.sol:Token", abi.encode("Second"), bytes32(uint256(2))); + vm.stopBroadcast(); + } +} + "#, + ); + + for isolate in [false, true] { + prj.update_config(|config| config.isolate = isolate); + let (api, handle) = spawn(NodeConfig::test()).await; + cmd.forge_fuse() + .args([ + "script", + "script/SaltedDeployCode.s.sol:SaltedDeployCodeScript", + "--rpc-url", + &handle.http_endpoint(), + "--broadcast", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + ]) + .assert_success(); + + let run_latest = foundry_common::fs::json_files(&prj.root().join("broadcast")) + .find(|path| path.ends_with("run-latest.json")) + .expect("No broadcast artifacts"); + let json: Value = foundry_common::fs::read_json_file(&run_latest).unwrap(); + let returned = |name: &str| -> Address { + json["returns"][name]["value"].as_str().unwrap().parse().unwrap() + }; + let (first, second) = (returned("first"), returned("second")); + let transactions = json["transactions"].as_array().unwrap(); + let transaction_types = transactions + .iter() + .map(|tx| tx["transactionType"].as_str().unwrap()) + .collect::>(); + assert_eq!(transaction_types, ["CREATE2", "CALL", "CREATE2"]); + assert_eq!(transactions[0]["contractAddress"], first.to_string().to_lowercase()); + assert_eq!(transactions[1]["transaction"]["to"], first.to_string().to_lowercase()); + assert_eq!(transactions[2]["contractAddress"], second.to_string().to_lowercase()); + for (nonce, transaction) in transactions.iter().enumerate() { + let recorded_nonce = + transaction["transaction"]["nonce"].as_str().unwrap().parse::().unwrap(); + assert_eq!(recorded_nonce, U256::from(nonce)); + } + + for deployed in [first, second] { + assert!(!api.get_code(deployed, None).await.unwrap().is_empty()); + } + assert_eq!(api.balance(first, None).await.unwrap(), U256::ONE); + } +} + +// Regression: `type(Foo).creationCode` in scripts must not be rewritten to `vm.getCode(...)`. +// The injected cheatcode is `view`, so using it in a `pure` script helper breaks compilation. +#[forgetest_init] +fn can_build_script_creation_code_in_pure_function(prj: _, cmd: _) { + prj.update_config(|c| c.dynamic_test_linking = true); + prj.add_source( + "Token.sol", + r#" +contract Token { + string public name; + constructor(string memory _name) { name = _name; } +} + "#, + ); + prj.add_script( + "CreationCode.s.sol", + r#" +import "forge-std/Script.sol"; +import {Token} from "../src/Token.sol"; + +contract CreationCodeScript is Script { + function run() public { + vm.broadcast(); + (bool ok,) = address(0).call(_getCreationCode()); + ok; + } + + function _getCreationCode() internal pure returns (bytes memory) { + return type(Token).creationCode; + } +} + "#, + ); + + cmd.args(["build"]).assert_success(); +} + +// Regression: tests and scripts must link shared libraries to the same CREATE2 address so that +// `type(Consumer).creationCode` is stable across both commands. +#[forgetest_init] +fn test_and_script_use_same_create2_library_linking(prj: _, cmd: _) { + prj.update_config(|c| { + c.dynamic_test_linking = false; + c.create2_library_salt = alloy_primitives::B256::with_last_byte(0x42); + }); + prj.add_source( + "Consumer.sol", + r#" +library ExternalLibrary { + function value() external pure returns (uint256) { return 42; } +} + +contract Consumer { + function value() external pure returns (uint256) { return ExternalLibrary.value(); } +} + "#, + ); + prj.add_test( + "CreationCode.t.sol", + r#" +import {Test, console2} from "forge-std/Test.sol"; +import {Consumer} from "../src/Consumer.sol"; + +contract CreationCodeTest is Test { + function testCreationCodeHash() external { + console2.log("CREATION_CODE_HASH", vm.toString(keccak256(type(Consumer).creationCode))); + assertEq(new Consumer().value(), 42); + } +} + "#, + ); + prj.add_script( + "CreationCode.s.sol", + r#" +import {Script, console2} from "forge-std/Script.sol"; +import {Consumer} from "../src/Consumer.sol"; + +contract CreationCodeScript is Script { + function run() external { + console2.log("CREATION_CODE_HASH", vm.toString(keccak256(type(Consumer).creationCode))); + require(new Consumer().value() == 42); + } +} + "#, + ); + + fn creation_code_hash(output: &[u8]) -> String { + let output = String::from_utf8_lossy(output); + let marker = "CREATION_CODE_HASH "; + let start = output.find(marker).expect("missing creation code hash marker") + marker.len(); + output[start..].split_whitespace().next().unwrap().to_owned() + } + + let test_output = cmd + .forge_fuse() + .args(["test", "--match-test", "testCreationCodeHash", "-vv"]) + .assert_success() + .get_output() + .stdout + .clone(); + let script_output = cmd + .forge_fuse() + .args(["script", "script/CreationCode.s.sol:CreationCodeScript", "-vv"]) + .assert_success() + .get_output() + .stdout + .clone(); + + assert_eq!(creation_code_hash(&test_output), creation_code_hash(&script_output)); +} + +#[forgetest_init] +async fn flaky_can_deploy_with_broadcast_in_setup(prj: _, cmd: _) { + prj.add_script( + "Deploy.s.sol", + r#" +import "forge-std/Script.sol"; +import {Vm} from "forge-std/Vm.sol"; +contract DeployScript is Script { + function setUp() public { + vm.startBroadcast(); + } + + function run() public { + payable(address(0)).transfer(1 ether); + + vm.stopBroadcast(); + } +} + "#, + ); + + let node_config = NodeConfig::test().with_hardfork(Some(EthereumHardfork::Prague.into())); + let (_api, handle) = spawn(node_config).await; + + cmd.args([ + "script", + "script/Deploy.s.sol:DeployScript", + "--rpc-url", + &handle.http_endpoint(), + "-vvvv", + "--broadcast", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + ]) + .assert_success() + .stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! +Traces: + [12182] DeployScript::run() + ├─ [2300] 0x0000000000000000000000000000000000000000::receive{value: 1000000000000000000}() + │ └─ ← [Stop] + ├─ [0] VM::stopBroadcast() + │ └─ ← [Return] + └─ ← [Stop] + + +Script ran successfully. + +## Setting up 1 EVM. +========================== +Simulated On-chain Traces: + + [0] 0x0000000000000000000000000000000000000000::receive{value: 1000000000000000000}() + └─ ← [Stop] + + +========================== + +Chain 31337 + +[ESTIMATED_MAX_FEE_PER_GAS] +[ESTIMATED_BASE_FEE_PER_GAS] +[ESTIMATED_PRIORITY_FEE_PER_GAS] + +[ESTIMATED_TOTAL_GAS_USED] + +[ESTIMATED_AMOUNT_REQUIRED] + +========================== + + +========================== + +ONCHAIN EXECUTION COMPLETE & SUCCESSFUL. + +[SAVED_TRANSACTIONS] + +[SAVED_SENSITIVE_VALUES] + + +"#]]); +} + +// +#[forgetest_init] +async fn can_execute_script_with_createx_and_via_ir(prj: _, cmd: _) { + prj.update_config(|config| { + config.optimizer = Some(true); + config.via_ir = true; + }); + prj.add_script("CreateXScript.s.sol", include_str!("../fixtures/CreateXScript.sol")); + + let (_api, handle) = spawn(NodeConfig::test().with_auto_impersonate(true)).await; + cmd.cast_fuse() + .args([ + "send", + "0xeD456e05CaAb11d66C4c797dD6c1D6f9A7F352b5", + "--value", + "1000000000000000000", + "--from", + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "--unlocked", + "--rpc-url", + &handle.http_endpoint(), + ]) + .assert_success(); + cmd.cast_fuse() + .args(["publish", "0xf92f698085174876e800832dc6c08080b92f1660a06040523060805234801561001457600080fd5b50608051612e3e6100d860003960008181610603015281816107050152818161082b015281816108d50152818161127f01528181611375015281816113e00152818161141f015281816114a7015281816115b3015281816117d20152818161183d0152818161187c0152818161190401528181611ac501528181611c7801528181611ce301528181611d2201528181611daa01528181611fe901528181612206015281816122f20152818161244d015281816124a601526125820152612e3e6000f3fe60806040526004361061018a5760003560e01c806381503da1116100d6578063d323826a1161007f578063e96deee411610059578063e96deee414610395578063f5745aba146103a8578063f9664498146103bb57600080fd5b8063d323826a1461034f578063ddda0acb1461036f578063e437252a1461038257600080fd5b80639c36a286116100b05780639c36a28614610316578063a7db93f214610329578063c3fe107b1461033c57600080fd5b806381503da1146102d0578063890c283b146102e357806398e810771461030357600080fd5b80632f990e3f116101385780636cec2536116101125780636cec25361461027d57806374637a7a1461029d5780637f565360146102bd57600080fd5b80632f990e3f1461023757806331a7c8c81461024a57806342d654fc1461025d57600080fd5b806327fe18221161016957806327fe1822146101f15780632852527a1461020457806328ddd0461461021757600080fd5b8062d84acb1461018f57806326307668146101cb57806326a32fc7146101de575b600080fd5b6101a261019d366004612915565b6103ce565b60405173ffffffffffffffffffffffffffffffffffffffff909116815260200160405180910390f35b6101a26101d9366004612994565b6103e6565b6101a26101ec3660046129db565b610452565b6101a26101ff3660046129db565b6104de565b6101a2610212366004612a39565b610539565b34801561022357600080fd5b506101a2610232366004612a90565b6106fe565b6101a2610245366004612aa9565b61072a565b6101a2610258366004612aa9565b6107bb565b34801561026957600080fd5b506101a2610278366004612b1e565b6107c9565b34801561028957600080fd5b506101a2610298366004612a90565b610823565b3480156102a957600080fd5b506101a26102b8366004612b4a565b61084f565b6101a26102cb3660046129db565b611162565b6101a26102de366004612b74565b6111e8565b3480156102ef57600080fd5b506101a26102fe366004612bac565b611276565b6101a2610311366004612bce565b6112a3565b6101a2610324366004612994565b611505565b6101a2610337366004612c49565b6116f1565b6101a261034a366004612aa9565b611964565b34801561035b57600080fd5b506101a261036a366004612cd9565b6119ed565b6101a261037d366004612c49565b611a17565b6101a2610390366004612bce565b611e0c565b6101a26103a3366004612915565b611e95565b6101a26103b6366004612bce565b611ea4565b6101a26103c9366004612b74565b611f2d565b60006103dd8585858533611a17565b95945050505050565b6000806103f2846120db565b90508083516020850134f59150610408826123d3565b604051819073ffffffffffffffffffffffffffffffffffffffff8416907fb8fda7e00c6b06a2b54e58521bc5894fee35f1090e5a3bb6390bfe2b98b497f790600090a35092915050565b60006104d86104d260408051437fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe08101406020830152419282019290925260608101919091524260808201524460a08201524660c08201523360e08201526000906101000160405160208183030381529060405280519060200120905090565b836103e6565b92915050565b600081516020830134f090506104f3816123d3565b60405173ffffffffffffffffffffffffffffffffffffffff8216907f4db17dd5e4732fb6da34a148104a592783ca119a1e7bb8829eba6cbadef0b51190600090a2919050565b600080610545856120db565b905060008460601b90506040517f3d602d80600a3d3981f3363d3d373d3d3d363d7300000000000000000000000081528160148201527f5af43d82803e903d91602b57fd5bf300000000000000000000000000000000006028820152826037826000f593505073ffffffffffffffffffffffffffffffffffffffff8316610635576040517fc05cee7a00000000000000000000000000000000000000000000000000000000815273ffffffffffffffffffffffffffffffffffffffff7f00000000000000000000000000000000000000000000000000000000000000001660048201526024015b60405180910390fd5b604051829073ffffffffffffffffffffffffffffffffffffffff8516907fb8fda7e00c6b06a2b54e58521bc5894fee35f1090e5a3bb6390bfe2b98b497f790600090a36000808473ffffffffffffffffffffffffffffffffffffffff1634876040516106a19190612d29565b60006040518083038185875af1925050503d80600081146106de576040519150601f19603f3d011682016040523d82523d6000602084013e6106e3565b606091505b50915091506106f382828961247d565b505050509392505050565b60006104d87f00000000000000000000000000000000000000000000000000000000000000008361084f565b60006107b36107aa60408051437fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe08101406020830152419282019290925260608101919091524260808201524460a08201524660c08201523360e08201526000906101000160405160208183030381529060405280519060200120905090565b85858533611a17565b949350505050565b60006107b3848484336112a3565b60006040518260005260ff600b53836020527f21c35dbe1b344a2488cf3321d6ce542f8e9f305544ff09e4993a62319a497c1f6040526055600b20601452806040525061d694600052600160345350506017601e20919050565b60006104d8827f00000000000000000000000000000000000000000000000000000000000000006107c9565b600060607f9400000000000000000000000000000000000000000000000000000000000000610887600167ffffffffffffffff612d45565b67ffffffffffffffff16841115610902576040517f3c55ab3b00000000000000000000000000000000000000000000000000000000815273ffffffffffffffffffffffffffffffffffffffff7f000000000000000000000000000000000000000000000000000000000000000016600482015260240161062c565b836000036109c7576040517fd60000000000000000000000000000000000000000000000000000000000000060208201527fff00000000000000000000000000000000000000000000000000000000000000821660218201527fffffffffffffffffffffffffffffffffffffffff000000000000000000000000606087901b1660228201527f800000000000000000000000000000000000000000000000000000000000000060368201526037015b6040516020818303038152906040529150611152565b607f8411610a60576040517fd60000000000000000000000000000000000000000000000000000000000000060208201527fff0000000000000000000000000000000000000000000000000000000000000080831660218301527fffffffffffffffffffffffffffffffffffffffff000000000000000000000000606088901b16602283015260f886901b1660368201526037016109b1565b60ff8411610b1f576040517fd70000000000000000000000000000000000000000000000000000000000000060208201527fff0000000000000000000000000000000000000000000000000000000000000080831660218301527fffffffffffffffffffffffffffffffffffffffff000000000000000000000000606088901b1660228301527f8100000000000000000000000000000000000000000000000000000000000000603683015260f886901b1660378201526038016109b1565b61ffff8411610bff576040517fd80000000000000000000000000000000000000000000000000000000000000060208201527fff00000000000000000000000000000000000000000000000000000000000000821660218201527fffffffffffffffffffffffffffffffffffffffff000000000000000000000000606087901b1660228201527f820000000000000000000000000000000000000000000000000000000000000060368201527fffff00000000000000000000000000000000000000000000000000000000000060f086901b1660378201526039016109b1565b62ffffff8411610ce0576040517fd90000000000000000000000000000000000000000000000000000000000000060208201527fff00000000000000000000000000000000000000000000000000000000000000821660218201527fffffffffffffffffffffffffffffffffffffffff000000000000000000000000606087901b1660228201527f830000000000000000000000000000000000000000000000000000000000000060368201527fffffff000000000000000000000000000000000000000000000000000000000060e886901b166037820152603a016109b1565b63ffffffff8411610dc2576040517fda0000000000000000000000000000000000000000000000000000000000000060208201527fff00000000000000000000000000000000000000000000000000000000000000821660218201527fffffffffffffffffffffffffffffffffffffffff000000000000000000000000606087901b1660228201527f840000000000000000000000000000000000000000000000000000000000000060368201527fffffffff0000000000000000000000000000000000000000000000000000000060e086901b166037820152603b016109b1565b64ffffffffff8411610ea5576040517fdb0000000000000000000000000000000000000000000000000000000000000060208201527fff00000000000000000000000000000000000000000000000000000000000000821660218201527fffffffffffffffffffffffffffffffffffffffff000000000000000000000000606087901b1660228201527f850000000000000000000000000000000000000000000000000000000000000060368201527fffffffffff00000000000000000000000000000000000000000000000000000060d886901b166037820152603c016109b1565b65ffffffffffff8411610f89576040517fdc0000000000000000000000000000000000000000000000000000000000000060208201527fff00000000000000000000000000000000000000000000000000000000000000821660218201527fffffffffffffffffffffffffffffffffffffffff000000000000000000000000606087901b1660228201527f860000000000000000000000000000000000000000000000000000000000000060368201527fffffffffffff000000000000000000000000000000000000000000000000000060d086901b166037820152603d016109b1565b66ffffffffffffff841161106e576040517fdd0000000000000000000000000000000000000000000000000000000000000060208201527fff00000000000000000000000000000000000000000000000000000000000000821660218201527fffffffffffffffffffffffffffffffffffffffff000000000000000000000000606087901b1660228201527f870000000000000000000000000000000000000000000000000000000000000060368201527fffffffffffffff0000000000000000000000000000000000000000000000000060c886901b166037820152603e016109b1565b6040517fde0000000000000000000000000000000000000000000000000000000000000060208201527fff00000000000000000000000000000000000000000000000000000000000000821660218201527fffffffffffffffffffffffffffffffffffffffff000000000000000000000000606087901b1660228201527f880000000000000000000000000000000000000000000000000000000000000060368201527fffffffffffffffff00000000000000000000000000000000000000000000000060c086901b166037820152603f0160405160208183030381529060405291505b5080516020909101209392505050565b60006104d86111e260408051437fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe08101406020830152419282019290925260608101919091524260808201524460a08201524660c08201523360e08201526000906101000160405160208183030381529060405280519060200120905090565b83611505565b600061126f61126860408051437fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe08101406020830152419282019290925260608101919091524260808201524460a08201524660c08201523360e08201526000906101000160405160208183030381529060405280519060200120905090565b8484610539565b9392505050565b600061126f83837f00000000000000000000000000000000000000000000000000000000000000006119ed565b60008451602086018451f090506112b9816123d3565b60405173ffffffffffffffffffffffffffffffffffffffff8216907f4db17dd5e4732fb6da34a148104a592783ca119a1e7bb8829eba6cbadef0b51190600090a26000808273ffffffffffffffffffffffffffffffffffffffff168560200151876040516113279190612d29565b60006040518083038185875af1925050503d8060008114611364576040519150601f19603f3d011682016040523d82523d6000602084013e611369565b606091505b5091509150816113c9577f0000000000000000000000000000000000000000000000000000000000000000816040517fa57ca23900000000000000000000000000000000000000000000000000000000815260040161062c929190612d94565b73ffffffffffffffffffffffffffffffffffffffff7f00000000000000000000000000000000000000000000000000000000000000001631156114fb578373ffffffffffffffffffffffffffffffffffffffff167f000000000000000000000000000000000000000000000000000000000000000073ffffffffffffffffffffffffffffffffffffffff163160405160006040518083038185875af1925050503d8060008114611495576040519150601f19603f3d011682016040523d82523d6000602084013e61149a565b606091505b509092509050816114fb577f0000000000000000000000000000000000000000000000000000000000000000816040517fc2b3f44500000000000000000000000000000000000000000000000000000000815260040161062c929190612d94565b5050949350505050565b600080611511846120db565b905060006040518060400160405280601081526020017f67363d3d37363d34f03d5260086018f30000000000000000000000000000000081525090506000828251602084016000f5905073ffffffffffffffffffffffffffffffffffffffff81166115e0576040517fc05cee7a00000000000000000000000000000000000000000000000000000000815273ffffffffffffffffffffffffffffffffffffffff7f000000000000000000000000000000000000000000000000000000000000000016600482015260240161062c565b604051839073ffffffffffffffffffffffffffffffffffffffff8316907f2feea65dd4e9f9cbd86b74b7734210c59a1b2981b5b137bd0ee3e208200c906790600090a361162c83610823565b935060008173ffffffffffffffffffffffffffffffffffffffff1634876040516116569190612d29565b60006040518083038185875af1925050503d8060008114611693576040519150601f19603f3d011682016040523d82523d6000602084013e611698565b606091505b505090506116a681866124ff565b60405173ffffffffffffffffffffffffffffffffffffffff8616907f4db17dd5e4732fb6da34a148104a592783ca119a1e7bb8829eba6cbadef0b51190600090a25050505092915050565b6000806116fd876120db565b9050808651602088018651f59150611714826123d3565b604051819073ffffffffffffffffffffffffffffffffffffffff8416907fb8fda7e00c6b06a2b54e58521bc5894fee35f1090e5a3bb6390bfe2b98b497f790600090a36000808373ffffffffffffffffffffffffffffffffffffffff168660200151886040516117849190612d29565b60006040518083038185875af1925050503d80600081146117c1576040519150601f19603f3d011682016040523d82523d6000602084013e6117c6565b606091505b509150915081611826577f0000000000000000000000000000000000000000000000000000000000000000816040517fa57ca23900000000000000000000000000000000000000000000000000000000815260040161062c929190612d94565b73ffffffffffffffffffffffffffffffffffffffff7f0000000000000000000000000000000000000000000000000000000000000000163115611958578473ffffffffffffffffffffffffffffffffffffffff167f000000000000000000000000000000000000000000000000000000000000000073ffffffffffffffffffffffffffffffffffffffff163160405160006040518083038185875af1925050503d80600081146118f2576040519150601f19603f3d011682016040523d82523d6000602084013e6118f7565b606091505b50909250905081611958577f0000000000000000000000000000000000000000000000000000000000000000816040517fc2b3f44500000000000000000000000000000000000000000000000000000000815260040161062c929190612d94565b50505095945050505050565b60006107b36119e460408051437fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe08101406020830152419282019290925260608101919091524260808201524460a08201524660c08201523360e08201526000906101000160405160208183030381529060405280519060200120905090565b858585336116f1565b6000604051836040820152846020820152828152600b8101905060ff815360559020949350505050565b600080611a23876120db565b905060006040518060400160405280601081526020017f67363d3d37363d34f03d5260086018f30000000000000000000000000000000081525090506000828251602084016000f5905073ffffffffffffffffffffffffffffffffffffffff8116611af2576040517fc05cee7a00000000000000000000000000000000000000000000000000000000815273ffffffffffffffffffffffffffffffffffffffff7f000000000000000000000000000000000000000000000000000000000000000016600482015260240161062c565b604051839073ffffffffffffffffffffffffffffffffffffffff8316907f2feea65dd4e9f9cbd86b74b7734210c59a1b2981b5b137bd0ee3e208200c906790600090a3611b3e83610823565b935060008173ffffffffffffffffffffffffffffffffffffffff1687600001518a604051611b6c9190612d29565b60006040518083038185875af1925050503d8060008114611ba9576040519150601f19603f3d011682016040523d82523d6000602084013e611bae565b606091505b50509050611bbc81866124ff565b60405173ffffffffffffffffffffffffffffffffffffffff8616907f4db17dd5e4732fb6da34a148104a592783ca119a1e7bb8829eba6cbadef0b51190600090a260608573ffffffffffffffffffffffffffffffffffffffff1688602001518a604051611c299190612d29565b60006040518083038185875af1925050503d8060008114611c66576040519150601f19603f3d011682016040523d82523d6000602084013e611c6b565b606091505b50909250905081611ccc577f0000000000000000000000000000000000000000000000000000000000000000816040517fa57ca23900000000000000000000000000000000000000000000000000000000815260040161062c929190612d94565b73ffffffffffffffffffffffffffffffffffffffff7f0000000000000000000000000000000000000000000000000000000000000000163115611dfe578673ffffffffffffffffffffffffffffffffffffffff167f000000000000000000000000000000000000000000000000000000000000000073ffffffffffffffffffffffffffffffffffffffff163160405160006040518083038185875af1925050503d8060008114611d98576040519150601f19603f3d011682016040523d82523d6000602084013e611d9d565b606091505b50909250905081611dfe577f0000000000000000000000000000000000000000000000000000000000000000816040517fc2b3f44500000000000000000000000000000000000000000000000000000000815260040161062c929190612d94565b505050505095945050505050565b60006103dd611e8c60408051437fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe08101406020830152419282019290925260608101919091524260808201524460a08201524660c08201523360e08201526000906101000160405160208183030381529060405280519060200120905090565b868686866116f1565b60006103dd85858585336116f1565b60006103dd611f2460408051437fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe08101406020830152419282019290925260608101919091524260808201524460a08201524660c08201523360e08201526000906101000160405160208183030381529060405280519060200120905090565b86868686611a17565b6000808360601b90506040517f3d602d80600a3d3981f3363d3d373d3d3d363d7300000000000000000000000081528160148201527f5af43d82803e903d91602b57fd5bf3000000000000000000000000000000000060288201526037816000f092505073ffffffffffffffffffffffffffffffffffffffff8216612016576040517fc05cee7a00000000000000000000000000000000000000000000000000000000815273ffffffffffffffffffffffffffffffffffffffff7f000000000000000000000000000000000000000000000000000000000000000016600482015260240161062c565b60405173ffffffffffffffffffffffffffffffffffffffff8316907f4db17dd5e4732fb6da34a148104a592783ca119a1e7bb8829eba6cbadef0b51190600090a26000808373ffffffffffffffffffffffffffffffffffffffff1634866040516120809190612d29565b60006040518083038185875af1925050503d80600081146120bd576040519150601f19603f3d011682016040523d82523d6000602084013e6120c2565b606091505b50915091506120d282828861247d565b50505092915050565b60008060006120e9846125b3565b9092509050600082600281111561210257612102612e02565b1480156121205750600081600281111561211e5761211e612e02565b145b1561215e57604080513360208201524691810191909152606081018590526080016040516020818303038152906040528051906020012092506123cc565b600082600281111561217257612172612e02565b1480156121905750600181600281111561218e5761218e612e02565b145b156121b0576121a9338560009182526020526040902090565b92506123cc565b60008260028111156121c4576121c4612e02565b03612233576040517f13b3a2a100000000000000000000000000000000000000000000000000000000815273ffffffffffffffffffffffffffffffffffffffff7f000000000000000000000000000000000000000000000000000000000000000016600482015260240161062c565b600182600281111561224757612247612e02565b1480156122655750600081600281111561226357612263612e02565b145b1561227e576121a9468560009182526020526040902090565b600182600281111561229257612292612e02565b1480156122b0575060028160028111156122ae576122ae612e02565b145b1561231f576040517f13b3a2a100000000000000000000000000000000000000000000000000000000815273ffffffffffffffffffffffffffffffffffffffff7f000000000000000000000000000000000000000000000000000000000000000016600482015260240161062c565b61239a60408051437fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe08101406020830152419282019290925260608101919091524260808201524460a08201524660c08201523360e08201526000906101000160405160208183030381529060405280519060200120905090565b84036123a657836123c9565b604080516020810186905201604051602081830303815290604052805190602001205b92505b5050919050565b73ffffffffffffffffffffffffffffffffffffffff8116158061240b575073ffffffffffffffffffffffffffffffffffffffff81163b155b1561247a576040517fc05cee7a00000000000000000000000000000000000000000000000000000000815273ffffffffffffffffffffffffffffffffffffffff7f000000000000000000000000000000000000000000000000000000000000000016600482015260240161062c565b50565b82158061249f575073ffffffffffffffffffffffffffffffffffffffff81163b155b156124fa577f0000000000000000000000000000000000000000000000000000000000000000826040517fa57ca23900000000000000000000000000000000000000000000000000000000815260040161062c929190612d94565b505050565b811580612520575073ffffffffffffffffffffffffffffffffffffffff8116155b80612540575073ffffffffffffffffffffffffffffffffffffffff81163b155b156125af576040517fc05cee7a00000000000000000000000000000000000000000000000000000000815273ffffffffffffffffffffffffffffffffffffffff7f000000000000000000000000000000000000000000000000000000000000000016600482015260240161062c565b5050565b600080606083901c3314801561261057508260141a60f81b7effffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff19167f0100000000000000000000000000000000000000000000000000000000000000145b1561262057506000905080915091565b606083901c3314801561265a57507fff00000000000000000000000000000000000000000000000000000000000000601484901a60f81b16155b1561266b5750600090506001915091565b33606084901c036126825750600090506002915091565b606083901c1580156126db57508260141a60f81b7effffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff19167f0100000000000000000000000000000000000000000000000000000000000000145b156126ec5750600190506000915091565b606083901c15801561272557507fff00000000000000000000000000000000000000000000000000000000000000601484901a60f81b16155b1561273557506001905080915091565b606083901c61274a5750600190506002915091565b8260141a60f81b7effffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff19167f0100000000000000000000000000000000000000000000000000000000000000036127a55750600290506000915091565b8260141a60f81b7effffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff19166000036127e15750600290506001915091565b506002905080915091565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b600082601f83011261282c57600080fd5b813567ffffffffffffffff80821115612847576128476127ec565b604051601f83017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0908116603f0116810190828211818310171561288d5761288d6127ec565b816040528381528660208588010111156128a657600080fd5b836020870160208301376000602085830101528094505050505092915050565b6000604082840312156128d857600080fd5b6040516040810181811067ffffffffffffffff821117156128fb576128fb6127ec565b604052823581526020928301359281019290925250919050565b60008060008060a0858703121561292b57600080fd5b84359350602085013567ffffffffffffffff8082111561294a57600080fd5b6129568883890161281b565b9450604087013591508082111561296c57600080fd5b506129798782880161281b565b92505061298986606087016128c6565b905092959194509250565b600080604083850312156129a757600080fd5b82359150602083013567ffffffffffffffff8111156129c557600080fd5b6129d18582860161281b565b9150509250929050565b6000602082840312156129ed57600080fd5b813567ffffffffffffffff811115612a0457600080fd5b6107b38482850161281b565b803573ffffffffffffffffffffffffffffffffffffffff81168114612a3457600080fd5b919050565b600080600060608486031215612a4e57600080fd5b83359250612a5e60208501612a10565b9150604084013567ffffffffffffffff811115612a7a57600080fd5b612a868682870161281b565b9150509250925092565b600060208284031215612aa257600080fd5b5035919050565b600080600060808486031215612abe57600080fd5b833567ffffffffffffffff80821115612ad657600080fd5b612ae28783880161281b565b94506020860135915080821115612af857600080fd5b50612b058682870161281b565b925050612b1585604086016128c6565b90509250925092565b60008060408385031215612b3157600080fd5b82359150612b4160208401612a10565b90509250929050565b60008060408385031215612b5d57600080fd5b612b6683612a10565b946020939093013593505050565b60008060408385031215612b8757600080fd5b612b9083612a10565b9150602083013567ffffffffffffffff8111156129c557600080fd5b60008060408385031215612bbf57600080fd5b50508035926020909101359150565b60008060008060a08587031215612be457600080fd5b843567ffffffffffffffff80821115612bfc57600080fd5b612c088883890161281b565b95506020870135915080821115612c1e57600080fd5b50612c2b8782880161281b565b935050612c3b86604087016128c6565b915061298960808601612a10565b600080600080600060c08688031215612c6157600080fd5b85359450602086013567ffffffffffffffff80821115612c8057600080fd5b612c8c89838a0161281b565b95506040880135915080821115612ca257600080fd5b50612caf8882890161281b565b935050612cbf87606088016128c6565b9150612ccd60a08701612a10565b90509295509295909350565b600080600060608486031215612cee57600080fd5b8335925060208401359150612b1560408501612a10565b60005b83811015612d20578181015183820152602001612d08565b50506000910152565b60008251612d3b818460208701612d05565b9190910192915050565b67ffffffffffffffff828116828216039080821115612d8d577f4e487b7100000000000000000000000000000000000000000000000000000000600052601160045260246000fd5b5092915050565b73ffffffffffffffffffffffffffffffffffffffff831681526040602082015260008251806040840152612dcf816060850160208701612d05565b601f017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe016919091016060019392505050565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052602160045260246000fdfea164736f6c6343000817000a1ca005f70bf8a1493291468f36ef23b05eb3a4f1807f6b4022942a4104b7537bfc36a029528c0c29546c81e7d78b0277ef87031541bdc96427b246ecedb6d74cd3ed62", "--rpc-url", &handle.http_endpoint()]) + .assert_success(); + cmd.forge_fuse() + .args([ + "script", + "script/CreateXScript.s.sol:CreateXScript", + "--rpc-url", + &handle.http_endpoint(), + "--slow", + "--sender", + "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--broadcast", + ]) + .assert_success(); +} + +#[forgetest_init] +async fn script_can_run_with_live_logs_flag(prj: _, cmd: _) { + prj.add_script( + "Foo.s.sol", + r#" +import {Script, console} from "forge-std/Script.sol"; + +contract Foo is Script { + function setUp() pure public { + console.log("Setup"); + } + + function run() pure public { + console.log("Run %d", uint256(1)); + } +} + "#, + ); + + cmd.forge_fuse() + .args(["script", "script/Foo.s.sol", "--live-logs"]) + .assert_success() + .stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! +Setup +Run 1 +Script ran successfully. +[GAS] + +"#]]); +} + +#[forgetest_init] +async fn script_can_run_with_live_logs_config(prj: _, cmd: _) { + prj.update_config(|config| { + config.live_logs = true; + }); + + prj.add_script( + "Foo.s.sol", + r#" +import {Script, console} from "forge-std/Script.sol"; + +contract Foo is Script { + function setUp() pure public { + console.log("Setup"); + } + + function run() pure public { + console.log("Run %d", uint256(1)); + } +} + "#, + ); + + cmd.forge_fuse().args(["script", "script/Foo.s.sol"]).assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! +Setup +Run 1 +Script ran successfully. +[GAS] + +"#]]); +} + +// Regression test for https://github.com/foundry-rs/foundry/issues/13576 +// On Arbitrum, `block.number` is remapped to the L1 block number. Previously, +// fork block pinning used the remapped L1 block number, causing the fork to +// fetch state from an ancient block where contracts did not exist. +#[forgetest_init] +fn flaky_can_call_arbitrum_contract_in_script(prj: _, cmd: _) { + let script = prj.add_source( + "ArbScript", + r#" +import "forge-std/Script.sol"; + +interface IERC20 { + function name() external view returns (string memory); +} + +contract ArbScript is Script { + function run() external view { + // USDC on Arbitrum — a contract with zero ETH balance. + // Before the fix, the fork pinned to the L1 block number, fetching state from + // an ancient block (Sept 2022) where USDC did not exist, causing + // "call to non-contract address". + IERC20 usdc = IERC20(0xaf88d065e77c8cC2239327C5EDb3A432268e5831); + string memory n = usdc.name(); + require(bytes(n).length > 0, "name should not be empty"); + } +} + "#, + ); + + let rpc = foundry_test_utils::rpc::next_rpc_endpoint(alloy_chains::NamedChain::Arbitrum); + + cmd.arg("script").arg(script).args(["--fork-url", rpc.as_str(), "-vvvv"]).assert_success(); +} + +#[forgetest] +fn script_rejects_unsupported_remote_sponsor(cmd: _) { + cmd.args([ + "script", + "src/Counter.s.sol:CounterScript", + "--sponsor-url", + "https://sponsor.tempo.xyz/tp_test", + ]) + .assert_failure() + .stderr_eq(str![[r#" +Error: --sponsor-url is not supported by forge script; use --tempo.sponsor with --tempo.sponsor-signer or --tempo.sponsor-sig + +"#]]); +} + +#[forgetest_init] +async fn script_batch_rejects_non_tempo_network(prj: _, cmd: _) { + let script = prj.add_source( + "BatchOnly", + r#" +import "forge-std/Script.sol"; +contract BatchOnly is Script { + function run() external { + vm.startBroadcast(); + vm.stopBroadcast(); + } +} +"#, + ); + + let (_api, handle) = spawn(NodeConfig::test()).await; + + cmd.arg("script") + .arg(script) + .args([ + "--rpc-url", + handle.http_endpoint().as_str(), + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--broadcast", + "--batch", + ]) + .assert_failure() + .stderr_eq(str![[r#" +Error: --batch mode is only supported on Tempo networks + +"#]]); +} + +/// Asserts that the dry-run sequence under `root` rewrote every CREATE to a unique CREATE2 +/// call targeting the Arachnid factory. +fn assert_create2_rewrite_dry_run(root: &std::path::Path) { + let run_latest = foundry_common::fs::json_files(&root.join("broadcast")) + .find(|file| file.ends_with("dry-run/run-latest.json")) + .expect("no dry-run broadcast artifact found"); + + let json: Value = foundry_common::fs::read_json_file(&run_latest).unwrap(); + let txs = json["transactions"].as_array().unwrap(); + assert_eq!(txs.len(), 3, "three CREATE attempts in script"); + + let factory = foundry_evm::constants::DEFAULT_CREATE2_DEPLOYER.to_string().to_lowercase(); + for tx in txs { + assert_eq!(tx["transactionType"], "CREATE2", "plain CREATE was rewritten"); + assert_eq!(tx["transaction"]["to"].as_str().unwrap().to_lowercase(), factory); + assert_eq!(tx["isFixedGasLimit"], false, "rewritten CREATE gas must be estimated"); + } + + let addrs: std::collections::HashSet<_> = + txs.iter().map(|t| t["contractAddress"].as_str().unwrap().to_owned()).collect(); + assert_eq!(addrs.len(), 3, "rewritten CREATE2 addresses should be unique"); +} + +// Dry-run against a local anvil to verify CREATE→CREATE2 rewriting. +#[forgetest_init] +async fn script_batch_rewrites_creates_to_create2(prj: _, cmd: _) { + let script = prj.add_source("MultiDeploy", MULTI_DEPLOY_SCRIPT); + + // Tempo mode pre-deploys the Arachnid CREATE2 factory and PATH_USD fee token. + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let dev = handle.dev_accounts().next().unwrap(); + + cmd.arg("script").arg(script).args([ + "--tc", + "MultiDeploy", + "--rpc-url", + &handle.http_endpoint(), + "--sender", + format!("{dev:?}").as_str(), + "--batch", + "--network", + "tempo", + ]); + cmd.assert_success(); + + assert_create2_rewrite_dry_run(prj.root()); +} + +#[forgetest_init] +async fn tempo_batch_resume_reuses_signed_payload(prj: _, cmd: _) { + let source = MULTI_DEPLOY_SCRIPT.replace( + "vm.startBroadcast();", + "vm.envUint(\"BATCH_EXECUTION_REQUIRED\"); vm.startBroadcast();", + ); + let script = prj.add_source("MultiDeploy", &source); + let (api, handle) = spawn(NodeConfig::test_tempo()).await; + api.anvil_set_auto_mine(false).await.unwrap(); + let (rpc, submissions, reached, release) = spawn_rpc_proxy_blocking_first_submission( + handle.http_endpoint(), + "eth_sendRawTransaction", + false, + ) + .await; + let private_key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + let sender = handle.dev_accounts().next().unwrap(); + cmd.env("BATCH_EXECUTION_REQUIRED", "1"); + + cmd.arg("script").arg(&script).args([ + "--tc", + "MultiDeploy", + "--rpc-url", + &rpc, + "--private-key", + private_key, + "--broadcast", + "--batch", + "--network", + "tempo", + ]); + let mut child = KillOnDrop::spawn(cmd.cmd()); + tokio::time::timeout(Duration::from_secs(30), reached.notified()) + .await + .expect("Forge did not reach the blocked batch submission"); + + let path = latest_broadcast_path(prj.root()); + let sequence: Value = foundry_common::fs::read_json_file(&path).unwrap(); + let transactions = sequence["transactions"].as_array().unwrap(); + assert_eq!(transactions.len(), 3); + assert!(transactions.iter().all(|tx| tx["hash"].is_null())); + assert!(sequence["pending"].as_array().unwrap().is_empty()); + assert!(sequence["receipts"].as_array().unwrap().is_empty()); + let deployed = transactions + .iter() + .map(|tx| tx["contractAddress"].as_str().unwrap().parse::
().unwrap()) + .collect::>(); + let recovery_path = foundry_common::fs::json_files(&prj.root().join("cache")) + .find(|path| path.to_string_lossy().ends_with(".recovery.json")) + .expect("no authoritative recovery snapshot"); + let recovery: Value = foundry_common::fs::read_json_file(&recovery_path).unwrap(); + let attempt = &recovery["deployments"][0]["attempts"][0]; + assert_eq!(attempt["members"].as_array().unwrap().len(), 3); + assert_eq!(attempt["kind"]["kind"], "signed"); + assert!(attempt["kind"]["payload"]["payload"].is_string()); + assert!(child.is_running(), "Forge exited before it could be interrupted"); + let output = child.kill_and_wait(); + assert!(!output.status.success(), "Forge unexpectedly succeeded"); + #[cfg(unix)] + assert_eq!(output.status.signal(), Some(9), "Forge was not terminated by SIGKILL"); + release.notify_one(); + + api.anvil_set_auto_mine(true).await.unwrap(); + cmd.forge_fuse(); + cmd.unset_env("BATCH_EXECUTION_REQUIRED"); + cmd.arg("script").arg(&script).args([ + "--tc", + "MultiDeploy", + "--rpc-url", + &rpc, + "--resume", + "--batch", + "--network", + "tempo", + ]); + cmd.assert_success(); + + let sequence: Value = foundry_common::fs::read_json_file(&path).unwrap(); + assert!(sequence["pending"].as_array().unwrap().is_empty()); + let receipts = sequence["receipts"].as_array().unwrap(); + assert_eq!(receipts.len(), 3); + let hash = receipts[0]["transactionHash"].as_str().unwrap(); + assert!(receipts.iter().all(|receipt| receipt["transactionHash"] == hash)); + let provider = handle.http_provider(); + assert_eq!(provider.get_transaction_count(sender).await.unwrap(), 1); + { + let submissions = submissions.lock().unwrap(); + assert_eq!(submissions.len(), 2); + assert_eq!(submissions[0], submissions[1]); + } + for address in &deployed { + assert!(!provider.get_code_at(*address).await.unwrap().is_empty()); + } + assert_eq!( + provider.get_storage_at(deployed[1], U256::ZERO).await.unwrap(), + U256::from_be_slice(deployed[0].as_slice()) + ); + assert_eq!(provider.get_storage_at(deployed[2], U256::ZERO).await.unwrap(), U256::from(0x1234)); +} + +#[forgetest_init] +async fn tempo_batch_unlocked_crash_blocks_resubmission(prj: _, cmd: _) { + let script = prj.add_source("MultiDeploy", MULTI_DEPLOY_SCRIPT); + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let (rpc, submissions, reached, release) = spawn_rpc_proxy_blocking_first_submission( + handle.http_endpoint(), + "eth_sendTransaction", + false, + ) + .await; + let sender = handle.dev_accounts().next().unwrap(); + + cmd.arg("script").arg(&script).args([ + "--tc", + "MultiDeploy", + "--rpc-url", + &rpc, + "--sender", + &sender.to_string(), + "--unlocked", + "--broadcast", + "--batch", + "--network", + "tempo", + ]); + let mut child = KillOnDrop::spawn(cmd.cmd()); + tokio::time::timeout(Duration::from_secs(30), reached.notified()) + .await + .expect("Forge did not reach the blocked delegated batch submission"); + + let path = latest_broadcast_path(prj.root()); + let sequence: Value = foundry_common::fs::read_json_file(&path).unwrap(); + assert!(sequence["transactions"].as_array().unwrap().iter().all(|tx| tx["hash"].is_null())); + assert!(sequence["pending"].as_array().unwrap().is_empty()); + let recovery_path = foundry_common::fs::json_files(&prj.root().join("cache")) + .find(|path| path.to_string_lossy().ends_with(".recovery.json")) + .expect("no authoritative recovery snapshot"); + let recovery: Value = foundry_common::fs::read_json_file(&recovery_path).unwrap(); + let attempt = &recovery["deployments"][0]["attempts"][0]; + assert_eq!(attempt["members"].as_array().unwrap().len(), 3); + assert_eq!(attempt["kind"]["kind"], "delegated"); + assert_eq!(attempt["kind"]["status"]["status"], "prepared"); + let attempt_id = attempt["id"].as_str().unwrap().to_owned(); + + assert!(child.is_running(), "Forge exited before it could be interrupted"); + let output = child.kill_and_wait(); + assert!(!output.status.success(), "Forge unexpectedly succeeded"); + #[cfg(unix)] + assert_eq!(output.status.signal(), Some(9), "Forge was not terminated by SIGKILL"); + release.notify_one(); + + cmd.forge_fuse().arg("script").arg(&script).args([ + "--tc", + "MultiDeploy", + "--rpc-url", + &rpc, + "--resume", + "--batch", + "--network", + "tempo", + ]); + let stderr = String::from_utf8_lossy(&cmd.assert_failure().get_output().stderr).into_owned(); + assert!(stderr.contains(&format!("delegated attempt {attempt_id}")), "{stderr}"); + assert_eq!(submissions.lock().unwrap().len(), 1); +} + +#[forgetest_init] +async fn tempo_batch_reconciles_accepted_signed_submission_error(prj: _, cmd: _) { + let script = prj.add_source("MultiDeploy", MULTI_DEPLOY_SCRIPT); + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let upstream = handle.http_endpoint(); + let client = reqwest::Client::new(); + let submissions = Arc::new(std::sync::Mutex::new(Vec::new())); + let recorded_submissions = submissions.clone(); + let app = Router::new().fallback(move |body: BodyBytes| { + let upstream = upstream.clone(); + let client = client.clone(); + let submissions = recorded_submissions.clone(); + async move { + let request: Value = serde_json::from_slice(&body).unwrap(); + let is_submission = + request.get("method").and_then(Value::as_str) == Some("eth_sendRawTransaction"); + if is_submission { + submissions + .lock() + .unwrap() + .push(request.get("params").cloned().unwrap_or(Value::Null)); + } + let response = client + .post(upstream) + .header("content-type", "application/json") + .body(body) + .send() + .await + .unwrap() + .bytes() + .await + .unwrap(); + if is_submission { + (StatusCode::SERVICE_UNAVAILABLE, response).into_response() + } else { + response.into_response() + } + } + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let rpc = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + cmd.arg("script").arg(&script).args([ + "--tc", + "MultiDeploy", + "--rpc-url", + &rpc, + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--broadcast", + "--batch", + "--network", + "tempo", + ]); + cmd.assert_success(); + let submissions = submissions.lock().unwrap(); + assert!(!submissions.is_empty()); + assert!(submissions.iter().all(|submission| submission == &submissions[0])); + + let path = latest_broadcast_path(prj.root()); + let sequence: Value = foundry_common::fs::read_json_file(&path).unwrap(); + assert!(sequence["pending"].as_array().unwrap().is_empty()); + assert_eq!(sequence["receipts"].as_array().unwrap().len(), 3); +} + +#[forgetest_init] +async fn tempo_batch_unlocked_ambiguous_submission_resolves_hash(prj: _, cmd: _) { + prj.update_config(|config| config.transaction_timeout = 1); + let script = prj.add_source("MultiDeploy", MULTI_DEPLOY_SCRIPT); + let (api, handle) = spawn(NodeConfig::test_tempo()).await; + api.anvil_set_auto_mine(false).await.unwrap(); + let upstream = handle.http_endpoint(); + let client = reqwest::Client::new(); + let submissions = Arc::new(AtomicUsize::new(0)); + let accepted_hash = Arc::new(std::sync::Mutex::new(None)); + let recorded_submissions = submissions.clone(); + let recorded_hash = accepted_hash.clone(); + let app = Router::new().fallback(move |body: BodyBytes| { + let upstream = upstream.clone(); + let client = client.clone(); + let submissions = recorded_submissions.clone(); + let accepted_hash = recorded_hash.clone(); + async move { + let request: Value = serde_json::from_slice(&body).unwrap(); + let is_submission = + request.get("method").and_then(Value::as_str) == Some("eth_sendTransaction"); + let response = client + .post(upstream) + .header("content-type", "application/json") + .body(body) + .send() + .await + .unwrap() + .bytes() + .await + .unwrap(); + if is_submission { + submissions.fetch_add(1, Ordering::Relaxed); + let response_json: Value = serde_json::from_slice(&response).unwrap(); + *accepted_hash.lock().unwrap() = + Some(response_json["result"].as_str().unwrap().parse::().unwrap()); + (StatusCode::SERVICE_UNAVAILABLE, response).into_response() + } else { + response.into_response() + } + } + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let rpc = format!("http://{}", listener.local_addr().unwrap()); + let _proxy = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let sender = handle.dev_accounts().next().unwrap(); + + cmd.arg("script").arg(&script).args([ + "--tc", + "MultiDeploy", + "--rpc-url", + &rpc, + "--sender", + &sender.to_string(), + "--unlocked", + "--broadcast", + "--batch", + "--network", + "tempo", + ]); + let initial_stderr = + String::from_utf8_lossy(&cmd.assert_failure().get_output().stderr).into_owned(); + assert!(initial_stderr.contains("HTTP error 503"), "{initial_stderr}"); + assert_eq!(submissions.load(Ordering::Relaxed), 1); + let recovery_path = foundry_common::fs::json_files(&prj.root().join("cache")) + .find(|path| path.to_string_lossy().ends_with(".recovery.json")) + .unwrap(); + let recovery: Value = foundry_common::fs::read_json_file(&recovery_path).unwrap(); + let attempt = recovery["deployments"][0]["attempts"][0]["id"].as_str().unwrap().to_owned(); + assert!(initial_stderr.contains(&attempt), "{initial_stderr}"); + + cmd.forge_fuse().arg("script").arg(&script).args([ + "--tc", + "MultiDeploy", + "--rpc-url", + &rpc, + "--sender", + &sender.to_string(), + "--unlocked", + "--resume", + "--batch", + "--network", + "tempo", + ]); + let stderr = String::from_utf8_lossy(&cmd.assert_failure().get_output().stderr).into_owned(); + assert!(stderr.contains(&format!("delegated attempt {attempt}")), "{stderr}"); + assert_eq!(submissions.load(Ordering::Relaxed), 1); + + let accepted_hash = accepted_hash.lock().unwrap().unwrap(); + cmd.forge_fuse().arg("script").arg(&script).args([ + "--tc", + "MultiDeploy", + "--rpc-url", + &rpc, + "--resume", + "--resume-attempt", + &attempt, + "--resume-tx-hash", + &accepted_hash.to_string(), + "--batch", + "--network", + "tempo", + ]); + let stderr = String::from_utf8_lossy(&cmd.assert_failure().get_output().stderr).into_owned(); + assert!(stderr.contains("Timeout waiting for batch transaction receipt"), "{stderr}"); + assert_eq!(submissions.load(Ordering::Relaxed), 1); + + api.mine_one().await.unwrap(); + cmd.forge_fuse().arg("script").arg(&script).args([ + "--tc", + "MultiDeploy", + "--rpc-url", + &rpc, + "--resume", + "--batch", + "--network", + "tempo", + ]); + cmd.assert_success(); + assert_eq!(submissions.load(Ordering::Relaxed), 1); +} + +#[forgetest_init] +async fn tempo_batch_resume_waits_for_pending_hash(prj: _, cmd: _) { + prj.update_config(|config| config.transaction_timeout = 1); + let script = prj.add_source("MultiDeploy", MULTI_DEPLOY_SCRIPT); + let (api, handle) = spawn(NodeConfig::test_tempo()).await; + api.anvil_set_auto_mine(false).await.unwrap(); + let null_transaction = Arc::new(AtomicBool::new(false)); + let null_transaction_once = null_transaction.clone(); + let lookup_rpc = spawn_rpc_proxy_mapping_method( + handle.http_endpoint(), + "eth_getTransactionByHash", + move |_, result| { + if null_transaction_once.swap(false, Ordering::SeqCst) { Value::Null } else { result } + }, + ) + .await; + let (lookup_rpc, lookups) = + spawn_rpc_proxy_recording_method(lookup_rpc, "eth_getTransactionByHash").await; + let null_receipt = Arc::new(AtomicBool::new(false)); + let null_receipt_once = null_receipt.clone(); + let receipt_rpc = spawn_rpc_proxy_mapping_method( + lookup_rpc, + "eth_getTransactionReceipt", + move |_, result| { + if null_receipt_once.swap(false, Ordering::SeqCst) { Value::Null } else { result } + }, + ) + .await; + let (receipt_rpc, receipt_requests) = + spawn_rpc_proxy_recording_method(receipt_rpc, "eth_getTransactionReceipt").await; + let (rpc, submissions) = + spawn_rpc_proxy_recording_method(receipt_rpc, "eth_sendTransaction").await; + let sender = handle.dev_accounts().next().unwrap(); + + cmd.arg("script").arg(&script).args([ + "--tc", + "MultiDeploy", + "--rpc-url", + &rpc, + "--sender", + &sender.to_string(), + "--unlocked", + "--broadcast", + "--batch", + "--network", + "tempo", + ]); + let stderr = cmd.assert_failure().get_output().stderr.clone(); + assert!( + String::from_utf8_lossy(&stderr).contains("Timeout waiting for batch transaction receipt"), + "{}", + String::from_utf8_lossy(&stderr) + ); + assert_eq!(submissions.lock().unwrap().len(), 1); + let path = latest_broadcast_path(prj.root()); + let mut sequence: Value = foundry_common::fs::read_json_file(&path).unwrap(); + let pending = sequence["pending"].as_array().unwrap(); + assert_eq!(pending.len(), 1); + let hash = pending[0].clone(); + for transaction in sequence["transactions"].as_array_mut().unwrap() { + transaction["hash"] = Value::Null; + } + sequence["pending"] = Value::Array(Vec::new()); + foundry_common::fs::write_pretty_json_file(&path, &sequence).unwrap(); + prj.update_config(|config| config.transaction_timeout = 30); + null_receipt.store(true, Ordering::SeqCst); + null_transaction.store(true, Ordering::SeqCst); + cmd.forge_fuse().arg("script").arg(&script).args([ + "--tc", + "MultiDeploy", + "--rpc-url", + &rpc, + "--resume", + "--batch", + "--network", + "tempo", + ]); + let stderr = String::from_utf8_lossy(&cmd.assert_failure().get_output().stderr).into_owned(); + assert!(stderr.contains("pending hash remains checkpointed"), "{stderr}"); + assert!(!receipt_requests.lock().unwrap().is_empty()); + assert!(!lookups.lock().unwrap().is_empty()); + assert_eq!(submissions.lock().unwrap().len(), 1); + + api.mine_one().await.unwrap(); + cmd.forge_fuse().arg("script").arg(&script).args([ + "--tc", + "MultiDeploy", + "--rpc-url", + &rpc, + "--resume", + "--batch", + "--network", + "tempo", + ]); + cmd.assert_success(); + assert_eq!(submissions.lock().unwrap().len(), 1); + assert_eq!(handle.http_provider().get_transaction_count(sender).await.unwrap(), 1); + let sequence: Value = foundry_common::fs::read_json_file(&path).unwrap(); + assert!(sequence["pending"].as_array().unwrap().is_empty()); + let receipts = sequence["receipts"].as_array().unwrap(); + assert_eq!(receipts.len(), 3); + assert!(receipts.iter().all(|receipt| receipt["transactionHash"] == hash)); +} + +// Tests that `forge script` works in Tempo mode without CreateCollision. +// Tempo genesis pre-deploys the Arachnid CREATE2 factory at the same address as the default +// CREATE2 deployer, so `deploy_create2_deployer` must be skipped to avoid a collision. +#[forgetest] +fn can_execute_script_command_with_tempo(prj: _, cmd: _) { + prj.wipe(); + + // Initialize a Tempo project (installs forge-std, tempo-std, generates Mail template). + cmd.args(["init", "--network", "tempo"]).arg(prj.root()).assert_success(); + + // Run the generated Mail.s.sol script with a salt argument. + cmd.forge_fuse() + .arg("script") + .arg("script/Mail.s.sol") + .arg("temposalt") + .arg("--tempo") + .arg("--root") + .arg(prj.root()) + .assert_success(); +} + +#[forgetest_init] +async fn tempo_script_runs_with_zero_fee_token_balance(prj: _, cmd: _) { + let script = prj.add_script( + "TempoScript.s.sol", + r#" +import "forge-std/Script.sol"; + +contract TempoScript is Script { + uint256 public value; + + constructor() { + value = 1; + } + + function setUp() external { + require(value == 1); + value = 2; + } + + function run() external { + require(value == 2); + value = 3; + } +} +"#, + ); + let (api, handle) = spawn(NodeConfig::test_tempo()).await; + let fee_token = address!("0x20c0000000000000000000000000000000000000"); + let fee_manager = address!("0xfeec000000000000000000000000000000000000"); + // Clear both balances so synthetic execution cannot accidentally succeed through fee + // accounting. + api.anvil_deal_tip20(CALLER, fee_token, U256::ZERO).await.unwrap(); + api.anvil_deal_tip20(fee_manager, fee_token, U256::ZERO).await.unwrap(); + cmd.arg("script").arg(script).args([ + "--rpc-url", + &handle.http_endpoint(), + "--network", + "tempo", + "--tempo.fee-token", + "0x20c0000000000000000000000000000000000000", + "--with-gas-price", + "600000000", + "--block-gas-limit", + "18446744073709551615", + ]); + cmd.assert_success(); +} + +#[forgetest_init] +async fn tempo_aa_script_broadcast_deploys_with_fee_token(prj: _, cmd: _) { + prj.add_source( + "TempoCodeDeployment", + r#" +contract TempoCodeDeployment {} +"#, + ); + let script = prj.add_script( + "DeployTempoAA.s.sol", + r#" +import "forge-std/Script.sol"; + +contract TempoAADeployment { + function ping() external {} +} + +contract DeployTempoAA is Script { + function run() external { + vm.startBroadcast(); + TempoAADeployment deployment = new TempoAADeployment(); + deployment.ping(); + vm.deployCode("src/TempoCodeDeployment.sol:TempoCodeDeployment"); + vm.deployCode("src/TempoCodeDeployment.sol:TempoCodeDeployment", bytes32(uint256(1))); + vm.stopBroadcast(); + } +} +"#, + ); + + let (api, handle) = spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let private_key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + let sender = handle.dev_accounts().next().unwrap(); + let path_usd = address!("0x20c0000000000000000000000000000000000000"); + let alpha_usd = address!("0x20c0000000000000000000000000000000000001"); + api.anvil_deal_tip20(sender, path_usd, U256::ZERO).await.unwrap(); + api.anvil_deal_tip20(sender, alpha_usd, U256::from(u64::MAX)).await.unwrap(); + + cmd.arg("script").arg(script).args([ + "--rpc-url", + &rpc, + "--private-key", + private_key, + "--tc", + "DeployTempoAA", + "--broadcast", + "--tempo.fee-token", + "0x20c0000000000000000000000000000000000001", + ]); + cmd.assert_success(); + + let run_latest = foundry_common::fs::json_files(&prj.root().join("broadcast")) + .find(|path| path.ends_with("run-latest.json")) + .expect("no broadcast artifact found"); + let json: Value = foundry_common::fs::read_json_file(&run_latest).unwrap(); + let transactions = json["transactions"].as_array().unwrap(); + let transaction_types = + transactions.iter().map(|tx| tx["transactionType"].as_str().unwrap()).collect::>(); + assert_eq!(transaction_types, ["CREATE", "CALL", "CREATE", "CREATE2"]); + for transaction in transactions { + assert_eq!(transaction["transaction"]["feeToken"], alpha_usd.to_string().to_lowercase()); + } +} + +// A local Tempo node runs on chain 31337. Broadcasts must still resolve the sender's stored fee +// token, with and without `--batch`. +#[forgetest_init] +async fn tempo_script_resolves_fee_token_on_local_chain_id(prj: _, cmd: _) { + let script = prj.add_script( + "TempoFeeToken.s.sol", + r#" +import "forge-std/Script.sol"; + +contract TempoFeeTokenTarget { + uint256 public value; + + function set(uint256 newValue) external { + value = newValue; + } +} + +contract TempoFeeToken is Script { + function run() external { + vm.startBroadcast(); + TempoFeeTokenTarget target = new TempoFeeTokenTarget(); + target.set(7); + vm.stopBroadcast(); + } +} +"#, + ); + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let private_key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + let alpha_usd = "0x20c0000000000000000000000000000000000001"; + let broadcast = prj.root().join("broadcast"); + let receipts = || { + let run_latest = foundry_common::fs::json_files(&broadcast) + .find(|path| { + path.ends_with("run-latest.json") && !path.to_string_lossy().contains("dry-run") + }) + .expect("no broadcast artifact found"); + let json: Value = foundry_common::fs::read_json_file(&run_latest).unwrap(); + json["receipts"] + .as_array() + .unwrap() + .iter() + .map(|receipt| { + (receipt["type"].as_str().unwrap().to_owned(), receipt["feeToken"].clone()) + }) + .collect::>() + }; + + cmd.arg("script").arg(&script).args([ + "--tc", + "TempoFeeToken", + "--rpc-url", + &rpc, + "--private-key", + private_key, + "--broadcast", + ]); + cmd.assert_success().stderr_eq(str![[r#" +Paying gas in AlphaUSD (0x20C0000000000000000000000000000000000001) + +"#]]); + // CREATE requests stay Ethereum transactions; the protocol still charges the stored token. + assert_eq!( + receipts(), + [("0x2".to_owned(), Value::from(alpha_usd)), ("0x76".to_owned(), Value::from(alpha_usd))] + ); + + cmd.forge_fuse().arg("script").arg(&script).args([ + "--tc", + "TempoFeeToken", + "--rpc-url", + &rpc, + "--private-key", + private_key, + "--broadcast", + "--batch", + ]); + cmd.assert_success().stderr_eq(str![[r#" +Warning: --batch rewrites CREATE → CREATE2 via the Arachnid factory; deployed addresses follow the CREATE2 formula and constructor msg.sender is the factory, not the EOA. +Paying gas in AlphaUSD (0x20C0000000000000000000000000000000000001) + +"#]]); + assert_eq!( + receipts(), + [("0x76".to_owned(), Value::from(alpha_usd)), ("0x76".to_owned(), Value::from(alpha_usd))] + ); +} + +// Bundle estimation must preserve script-specified gas and still estimate ordinary transactions. +#[forgetest_init] +async fn tempo_script_preserves_fixed_gas_limit(prj: _, cmd: _) { + let script = prj.add_script( + "TempoFixedGas.s.sol", + r#" +import "forge-std/Script.sol"; + +contract TempoFixedGas is Script { + function run() external { + vm.startBroadcast(); + (bool fixedSuccess,) = address(0xBEEF).call{gas: 500000}(""); + require(fixedSuccess); + (bool estimatedSuccess,) = address(0xBEEF).call(""); + require(estimatedSuccess); + vm.stopBroadcast(); + } +} +"#, + ); + let (api, handle) = spawn(NodeConfig::test_tempo()).await; + // Both calls must be estimable before any transaction has been broadcast. + api.anvil_set_code(address!("000000000000000000000000000000000000beef"), hex!("00").into()) + .await + .unwrap(); + let rpc = spawn_rpc_proxy_mapping_method(handle.http_endpoint(), "eth_estimateGas", |_, _| { + Value::from("0x186a0") + }) + .await; + let (rpc, estimates) = spawn_rpc_proxy_recording_method(rpc, "eth_estimateGas").await; + let gas_limits = |dry_run| { + let path = foundry_common::fs::json_files(&prj.root().join("broadcast")) + .find(|path| { + path.ends_with("run-latest.json") + && path.components().any(|part| part.as_os_str() == "dry-run") == dry_run + }) + .expect("no script artifact found"); + let sequence = + foundry_common::fs::read_json_file::>(&path).unwrap(); + sequence + .transactions + .iter() + .map(|tx| (tx.is_fixed_gas_limit, tx.tx().gas().unwrap())) + .collect::>() + }; + cmd.arg("script").arg(&script).args([ + "--rpc-url", + &rpc, + "--network", + "tempo", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--gas-estimate-multiplier", + "130", + ]); + cmd.assert_success(); + assert_eq!(gas_limits(true), [(true, 500000), (false, 130000)]); + assert_eq!(estimates.lock().unwrap().len(), 1); + + cmd.arg("--broadcast").assert_success(); + assert_eq!(gas_limits(false), [(true, 500000), (false, 130000)]); + // The second run estimates the ordinary call during bundling and again before sending. + assert_eq!(estimates.lock().unwrap().len(), 3); +} + +#[forgetest_init] +async fn tempo_batch_broadcasts_deploy_code_via_create2(prj: _, cmd: _) { + prj.add_source( + "TempoBatchCodeDeployment", + r#" +contract TempoBatchCodeDeployment { + uint256 public calls; + function ping() external { calls++; } +} +"#, + ); + let script = prj.add_script( + "DeployTempoBatch.s.sol", + r#" +import "forge-std/Script.sol"; + +interface ITempoBatchCodeDeployment { + function ping() external; +} + +contract DeployTempoBatch is Script { + function run() external returns (address first, address second, address salted) { + vm.startBroadcast(); + first = vm.deployCode("src/TempoBatchCodeDeployment.sol:TempoBatchCodeDeployment"); + second = vm.deployCode("src/TempoBatchCodeDeployment.sol:TempoBatchCodeDeployment"); + salted = vm.deployCode( + "src/TempoBatchCodeDeployment.sol:TempoBatchCodeDeployment", bytes32(uint256(1)) + ); + ITempoBatchCodeDeployment(first).ping(); + vm.stopBroadcast(); + } +} +"#, + ); + + let (api, handle) = spawn(NodeConfig::test_tempo()).await; + cmd.arg("script").arg(script).args([ + "--rpc-url", + &handle.http_endpoint(), + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--tc", + "DeployTempoBatch", + "--broadcast", + "--batch", + "--network", + "tempo", + ]); + cmd.assert_success(); + + let run_latest = latest_broadcast_path(prj.root()); + let json: Value = foundry_common::fs::read_json_file(&run_latest).unwrap(); + let returned = |name: &str| -> Address { + json["returns"][name]["value"].as_str().unwrap().parse().unwrap() + }; + let deployed = [returned("first"), returned("second"), returned("salted")]; + assert_ne!(deployed[0], deployed[1]); + + let transactions = json["transactions"].as_array().unwrap(); + let transaction_types = + transactions.iter().map(|tx| tx["transactionType"].as_str().unwrap()).collect::>(); + assert_eq!(transaction_types, ["CREATE2", "CREATE2", "CREATE2", "CALL"]); + let factory = foundry_evm::constants::DEFAULT_CREATE2_DEPLOYER.to_string().to_lowercase(); + for (transaction, address) in transactions.iter().zip(deployed) { + assert_eq!(transaction["transaction"]["to"].as_str().unwrap().to_lowercase(), factory); + assert_eq!(transaction["contractAddress"], address.to_string().to_lowercase()); + assert!(!api.get_code(address, None).await.unwrap().is_empty()); + } + assert_eq!(transactions[3]["transaction"]["to"], deployed[0].to_string().to_lowercase()); + assert_eq!( + handle.http_provider().get_storage_at(deployed[0], U256::ZERO).await.unwrap(), + U256::ONE + ); +} + +#[forgetest_init] +async fn tempo_script_resume_preserves_completed_prefix(prj: _, cmd: _) { + let script = prj.add_script( + "TempoResume.s.sol", + r#" +import "forge-std/Script.sol"; + +contract TempoResumeTarget { + uint256 public value; + + function set(uint256 newValue) external { + value = newValue; + } +} + +contract TempoResume is Script { + function run() external { + vm.startBroadcast(); + TempoResumeTarget target = new TempoResumeTarget(); + target.set(7); + vm.stopBroadcast(); + } +} +"#, + ); + let (api, handle) = spawn(NodeConfig::test_tempo()).await; + let (recording_rpc, submissions) = + spawn_rpc_proxy_recording_method(handle.http_endpoint(), "eth_sendRawTransaction").await; + let (rpc, reject_after_first) = spawn_rpc_proxy_rejecting_method_after_when_enabled( + recording_rpc, + "eth_sendRawTransaction", + 1, + ) + .await; + let private_key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + let sender = handle.dev_accounts().next().unwrap(); + let fee_token = "0x20c0000000000000000000000000000000000001"; + api.anvil_deal_tip20(sender, fee_token.parse::
().unwrap(), U256::from(u64::MAX)) + .await + .unwrap(); + + cmd.arg("script").arg(&script).args([ + "--tc", + "TempoResume", + "--rpc-url", + &rpc, + "--private-key", + private_key, + "--broadcast", + "--network", + "tempo", + "--tempo.fee-token", + fee_token, + ]); + let stderr = String::from_utf8_lossy(&cmd.assert_failure().get_output().stderr).into_owned(); + assert!(stderr.contains("method is not allowed"), "{stderr}"); + + let path = latest_broadcast_path(prj.root()); + let sequence: Value = foundry_common::fs::read_json_file(&path).unwrap(); + let transactions = sequence["transactions"].as_array().unwrap(); + assert_eq!(transactions.len(), 2); + assert_eq!(sequence["receipts"].as_array().unwrap().len(), 1); + assert!(sequence["pending"].as_array().unwrap().is_empty()); + assert_eq!(submissions.lock().unwrap().len(), 1); + let first_hash = transactions[0]["hash"].as_str().unwrap().to_owned(); + let remaining_payload = transactions[1]["transaction"].clone(); + assert!(transactions[1]["hash"].is_null()); + assert!(transactions.iter().all(|tx| tx["transaction"]["feeToken"] == fee_token)); + + reject_after_first.store(false, Ordering::SeqCst); + cmd.forge_fuse().arg("script").arg(&script).args([ + "--tc", + "TempoResume", + "--rpc-url", + &rpc, + "--private-key", + private_key, + "--resume", + "--network", + "tempo", + "--tempo.fee-token", + fee_token, + ]); + cmd.assert_success(); + + let sequence: Value = foundry_common::fs::read_json_file(&path).unwrap(); + let transactions = sequence["transactions"].as_array().unwrap(); + assert_eq!(transactions[0]["hash"], first_hash); + assert!(transactions[1]["hash"].is_string()); + assert_eq!(transactions[1]["transaction"], remaining_payload); + assert_eq!(sequence["receipts"].as_array().unwrap().len(), 2); + assert!(sequence["pending"].as_array().unwrap().is_empty()); + let provider = handle.http_provider(); + assert_eq!(provider.get_transaction_count(sender).await.unwrap(), 2); + assert_eq!(submissions.lock().unwrap().len(), 2); + let target = transactions[0]["contractAddress"].as_str().unwrap().parse::
().unwrap(); + assert!(!provider.get_code_at(target).await.unwrap().is_empty()); + assert_eq!(provider.get_storage_at(target, U256::ZERO).await.unwrap(), U256::from(7)); +} + +#[forgetest_init] +async fn tempo_aa_script_broadcasts_with_local_sponsor(prj: _, cmd: _) { + let script = prj.add_script( + "DeploySponsoredTempoAA.s.sol", + r#" +import "forge-std/Script.sol"; + +contract SponsoredTempoAADeployment {} + +contract DeploySponsoredTempoAA is Script { + function run() external { + vm.startBroadcast(); + new SponsoredTempoAADeployment(); + vm.stopBroadcast(); + } +} +"#, + ); + + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let rpc = handle.http_endpoint(); + let wallets = handle.dev_wallets().take(2).collect::>(); + let sender_key = hex::encode_prefixed(wallets[0].credential().to_bytes()); + let sponsor_key = + format!("private-key://{}", hex::encode_prefixed(wallets[1].credential().to_bytes())); + let sponsor = format!("{:?}", wallets[1].address()); + + let assert = cmd + .arg("script") + .arg(script) + .args([ + "--rpc-url", + &rpc, + "--private-key", + &sender_key, + "--broadcast", + "--tempo.fee-token", + "PathUSD", + "--tempo.sponsor", + &sponsor, + "--tempo.sponsor-signer", + &sponsor_key, + ]) + .assert_success(); + let output = assert.get_output(); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Estimated amount required:"), "{stdout}"); + assert!(stdout.contains(" PathUSD"), "{stdout}"); + assert!(!stdout.contains(" ETH"), "{stdout}"); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.to_ascii_lowercase().contains(&format!("tempo sponsor: {sponsor}")), "{stderr}"); +} + +const TEMPO_IDENTICAL_TRANSFERS_SCRIPT: &str = r#" +import "forge-std/Script.sol"; + +interface ITIP20Transfer { + function transfer(address to, uint256 amount) external returns (bool); +} + +contract IdenticalTransfers is Script { + function run() external { + ITIP20Transfer token = ITIP20Transfer(0x20C0000000000000000000000000000000000000); + // The recipient is a funded dev account, so both transfers also cost the same gas. + address recipient = 0x70997970C51812dc3A010C7d01b50e0d17dc79C8; + vm.startBroadcast(); + token.transfer(recipient, 1); + token.transfer(recipient, 1); + vm.stopBroadcast(); + } +} +"#; + +// TIP-1106: from T12 each expiring nonce transaction carries its complete-plan ordinal, +// which keeps identical transactions distinct. +#[forgetest_init] +async fn tempo_script_assigns_expiring_nonce_discriminators(prj: _, cmd: _) { + let script = prj.add_script("IdenticalTransfers.s.sol", TEMPO_IDENTICAL_TRANSFERS_SCRIPT); + let (_api, handle) = + spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T12.into()))).await; + let provider = handle.http_provider(); + + cmd.arg("script").arg(script).args([ + "--rpc-url", + &handle.http_endpoint(), + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--broadcast", + "--tempo.expires", + "30", + ]); + cmd.assert_success(); + + let run_latest = foundry_common::fs::json_files(&prj.root().join("broadcast")) + .find(|path| path.ends_with("run-latest.json")) + .expect("no broadcast artifact found"); + let json: Value = foundry_common::fs::read_json_file(&run_latest).unwrap(); + let mut transactions = Vec::new(); + for transaction in json["transactions"].as_array().unwrap() { + let hash = transaction["hash"].as_str().unwrap().to_string(); + transactions.push( + provider + .raw_request::<_, Value>("eth_getTransactionByHash".into(), (hash,)) + .await + .unwrap(), + ); + } + let nonces = transactions.iter().map(|tx| tx["nonce"].clone()).collect::>(); + assert_eq!(nonces, ["0x0", "0x1"]); + + // Only the discriminator, and what is derived from it, tells the two transactions apart. + for transaction in &mut transactions { + assert_eq!(transaction["nonceKey"], format!("{:#x}", U256::MAX)); + let fields = transaction.as_object_mut().unwrap(); + for derived in [ + "nonce", + "hash", + "signature", + "blockHash", + "blockNumber", + "blockTimestamp", + "transactionIndex", + ] { + fields.remove(derived); + } + } + assert_eq!(transactions[0], transactions[1]); +} + +// Before T12 the nonce must be 0, so identical expiring nonce transactions are one transaction. +#[forgetest_init] +async fn tempo_script_rejects_identical_expiring_nonce_transactions_before_t12(prj: _, cmd: _) { + let script = prj.add_script("IdenticalTransfers.s.sol", TEMPO_IDENTICAL_TRANSFERS_SCRIPT); + let (_api, handle) = + spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T11.into()))).await; + + cmd.arg("script").arg(script).args([ + "--rpc-url", + &handle.http_endpoint(), + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--broadcast", + "--tempo.expires", + "30", + ]); + cmd.assert_failure().stderr_eq(str![[r#" +Paying gas in AlphaUSD (0x20C0000000000000000000000000000000000001) +Paying gas in AlphaUSD (0x20C0000000000000000000000000000000000001) +Error: transaction 1 is identical to an earlier transaction of this script ([..]) and would be rejected as a replay; identical Tempo expiring nonce transactions are only distinct from the T12 hardfork (TIP-1106) + +"#]]); +} + +#[forgetest_init] +async fn tempo_expiring_create_resume_rejects_changed_account_nonce(prj: _, cmd: _) { + let script = prj.add_script( + "ExpiringCreate.s.sol", + r#" +import "forge-std/Script.sol"; + +contract ExpiringCreateTarget {} + +contract ExpiringCreate is Script { + function run() external { + vm.startBroadcast(); + new ExpiringCreateTarget(); + vm.stopBroadcast(); + } +} +"#, + ); + let (_api, handle) = + spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T12.into()))).await; + let provider = handle.http_provider(); + let sender = handle.dev_accounts().next().unwrap(); + cmd.arg("script").arg(&script).args([ + "--rpc-url", + &handle.http_endpoint(), + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + ]); + cmd.assert_success(); + + // An intervening transaction invalidates the deployment address saved by the dry-run. + provider + .send_transaction(TransactionRequest::default().with_from(sender).with_to(sender).into()) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + let nonce = provider.get_transaction_count(sender).await.unwrap(); + assert_eq!(nonce, 1); + cmd.args(["--resume", "--tempo.expires", "30"]); + cmd.assert_failure().stderr_eq(str![[r#" +Error: CREATE address changed unexpectedly while sending transactions. Expected 0x5FbDB2315678afecb367f032d93F642f64180aa3 got 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 from provider nonce. + +"#]]); + assert_eq!(provider.get_transaction_count(sender).await.unwrap(), nonce); + assert!(provider.get_code_at(sender.create(0)).await.unwrap().is_empty()); + assert!(provider.get_code_at(sender.create(1)).await.unwrap().is_empty()); +} + +#[forgetest_init] +async fn tempo_presigned_expiring_create_uses_account_nonce(prj: _, cmd: _) { + let (_api, handle) = + spawn(NodeConfig::test_tempo().with_hardfork(Some(TempoHardfork::T12.into()))).await; + let provider = handle.http_provider(); + let sender = handle.dev_accounts().next().unwrap(); + let block = provider.get_block_by_number("latest".parse().unwrap()).await.unwrap().unwrap(); + let payload = provider + .raw_request::<_, Bytes>( + "eth_signTransaction".into(), + (serde_json::json!({ + "from": sender, + "type": "0x76", + "nonceKey": U256::MAX, + "nonce": "0x7", + "gas": "0x1e8480", + "maxFeePerGas": "0x6fc23ac00", + "maxPriorityFeePerGas": "0x3b9aca00", + "validBefore": block.header.timestamp + 25, + "calls": [{"to": null, "value": "0x0", "input": "0x6001600c60003960016000f300"}], + }),), + ) + .await + .unwrap(); + let script = prj.add_script( + "PresignedExpiringCreate.s.sol", + &format!( + r#" +import "forge-std/Script.sol"; + +contract PresignedExpiringCreate is Script {{ + function run() external {{ + vm.startBroadcast(); + vm.broadcastRawTransaction(hex"{}"); + vm.stopBroadcast(); + }} +}} +"#, + hex::encode(payload), + ), + ); + cmd.arg("script").arg(script).args([ + "--rpc-url", + &handle.http_endpoint(), + "--network", + "tempo", + "--broadcast", + ]); + cmd.assert_success(); + assert_eq!(provider.get_code_at(sender.create(0)).await.unwrap(), bytes!("00")); + assert!(provider.get_code_at(sender.create(7)).await.unwrap().is_empty()); +} + +#[forgetest_init] +async fn tempo_sponsored_resume_needs_no_credentials(prj: _, cmd: _) { + let script = prj.add_script( + "DeploySponsoredTempoAA.s.sol", + r#" +import "forge-std/Script.sol"; + +contract SponsoredTempoAADeployment {} + +contract DeploySponsoredTempoAA is Script { + function run() external { + vm.startBroadcast(); + new SponsoredTempoAADeployment(); + vm.stopBroadcast(); + } +} +"#, + ); + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let (rpc, submissions, reached, release) = spawn_rpc_proxy_blocking_first_submission( + handle.http_endpoint(), + "eth_sendRawTransaction", + true, + ) + .await; + let wallets = handle.dev_wallets().take(2).collect::>(); + let sender = wallets[0].address(); + let sender_key = hex::encode_prefixed(wallets[0].credential().to_bytes()); + let sponsor_key = + format!("private-key://{}", hex::encode_prefixed(wallets[1].credential().to_bytes())); + let sponsor = format!("{:?}", wallets[1].address()); + let path = prj.root().join("broadcast/DeploySponsoredTempoAA.s.sol/31337/run-latest.json"); + + cmd.arg("script").arg(&script).args([ + "--rpc-url", + &rpc, + "--private-key", + &sender_key, + "--broadcast", + "--tempo.fee-token", + "PathUSD", + "--tempo.sponsor", + &sponsor, + "--tempo.sponsor-signer", + &sponsor_key, + ]); + let child = KillOnDrop::spawn(cmd.cmd()); + tokio::time::timeout(Duration::from_secs(60), reached.notified()) + .await + .expect("forge did not submit the sponsored transaction"); + + // The fully sponsored and signed attempt is durable before its response is lost. + let accepted = submissions.lock().unwrap()[0][0].clone(); + let accepted_hash = keccak256(hex::decode(accepted.as_str().unwrap()).unwrap()); + let recovery_path = foundry_common::fs::json_files(&prj.root().join("cache")) + .find(|path| path.to_string_lossy().ends_with(".recovery.json")) + .expect("no authoritative recovery snapshot"); + let recovery: Value = foundry_common::fs::read_json_file(&recovery_path).unwrap(); + let attempt = &recovery["deployments"][0]["attempts"][0]; + assert_eq!(attempt["kind"]["kind"], "signed"); + assert_eq!(attempt["kind"]["payload"]["payload"], accepted); + let planned: Value = foundry_common::fs::read_json_file(&path).unwrap(); + let planned = planned["transactions"][0].clone(); + child.kill_and_wait(); + release.notify_one(); + let provider = handle.http_provider(); + tokio::time::timeout(Duration::from_secs(30), async { + while provider.get_transaction_receipt(accepted_hash).await.unwrap().is_none() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("the sponsored transaction was not mined"); + + // Another operator resumes with no sender key, sponsor, fee token, or signing credentials. + let tempo_home = tempfile::tempdir().unwrap(); + cmd.forge_fuse().arg("script").arg(&script).args([ + "--rpc-url", + &rpc, + "--resume", + "--network", + "tempo", + ]); + for var in [ + "ETH_FROM", + "ETH_KEYSTORE", + "ETH_KEYSTORE_ACCOUNT", + "ETH_PASSWORD", + "TEMPO_ACCESS_KEY", + "TEMPO_ROOT_ACCOUNT", + "TEMPO_SESSION_ID", + "TEMPO_SPONSOR_URL", + ] { + cmd.unset_env(var); + } + cmd.env("TEMPO_HOME", tempo_home.path()); + cmd.assert_success(); + + // Any rebroadcast reuses the saved bytes; no different transaction is ever sent. + assert!(submissions.lock().unwrap().iter().all(|params| params[0] == accepted)); + let sequence: Value = foundry_common::fs::read_json_file(&path).unwrap(); + assert!(sequence["pending"].as_array().unwrap().is_empty()); + let transaction = &sequence["transactions"][0]; + for field in ["from", "to", "nonce", "input", "value", "gas", "chainId", "feeToken"] { + assert_eq!(transaction["transaction"][field], planned["transaction"][field], "{field}"); + } + assert_eq!(transaction["contractAddress"], planned["contractAddress"]); + let receipts = sequence["receipts"].as_array().unwrap(); + assert_eq!(receipts.len(), 1); + let receipt = &receipts[0]; + assert_eq!( + receipt["transactionHash"].as_str().unwrap().parse::().unwrap(), + accepted_hash + ); + assert_eq!(receipt["status"], "0x1"); + assert_eq!(receipt["from"].as_str().unwrap().parse::
().unwrap(), sender); + assert_eq!(receipt["feePayer"].as_str().unwrap().to_lowercase(), sponsor.to_lowercase()); + assert_eq!( + receipt["feeToken"].as_str().unwrap().to_lowercase(), + planned["transaction"]["feeToken"].as_str().unwrap().to_lowercase() + ); + let address = planned["contractAddress"].as_str().unwrap().parse::
().unwrap(); + assert_eq!(receipt["contractAddress"].as_str().unwrap().parse::
().unwrap(), address); + assert_eq!(provider.get_transaction_count(sender).await.unwrap(), 1); + assert!(!provider.get_code_at(address).await.unwrap().is_empty()); +} + +// Helper: write a script that deploys `LargeRuntime` with runtime > default limit via +// `vm.startBroadcast`. +fn write_large_runtime_deploy_script(prj: &foundry_test_utils::TestProject, runtime_bytes: usize) { + prj.add_source("LargeRuntime.sol", generate_large_runtime_contract(runtime_bytes).as_str()); + prj.add_source( + "DeployLarge.s.sol", + r#" +import "forge-std/Script.sol"; +import "./LargeRuntime.sol"; + +contract DeployLarge is Script { + function run() external { + vm.startBroadcast(); + new LargeRuntime(); + } +} +"#, + ); +} + +// Tests that `forge script` reports a contract-size violation against the default runtime limit +// when neither the CLI nor foundry.toml override `code_size_limit`. +#[forgetest_init] +async fn script_check_contract_sizes_warns_at_default_limit(prj: _, cmd: _) { + write_large_runtime_deploy_script(&prj, 30_000); + + let (_api, handle) = spawn(NodeConfig::test()).await; + cmd.set_current_dir(prj.root()); + for var in ["FOUNDRY_CODE_SIZE_LIMIT", "DAPP_CODE_SIZE_LIMIT", "DAPP_TEST_CODE_SIZE_LIMIT"] { + cmd.unset_env(var); + } + cmd.args(["script", "DeployLarge", "--rpc-url", &handle.http_endpoint()]) + .assert_success() + .stderr_eq(str![[r#" +Error: `LargeRuntime` is above the contract size limit ([..] > 24576). + +"#]]); +} + +#[forgetest_init] +async fn script_check_contract_sizes_uses_amsterdam_code_size_limit(prj: _, cmd: _) { + write_large_runtime_deploy_script(&prj, 50_000); + prj.update_config(|config| { + config.evm_version = EvmVersion::Amsterdam; + }); + + let (_api, handle) = spawn(NodeConfig::test().with_gas_limit(Some(1_000_000_000))).await; + cmd.set_current_dir(prj.root()); + for var in ["FOUNDRY_CODE_SIZE_LIMIT", "DAPP_CODE_SIZE_LIMIT", "DAPP_TEST_CODE_SIZE_LIMIT"] { + cmd.unset_env(var); + } + let assert = cmd + .args([ + "script", + "DeployLarge", + "--rpc-url", + &handle.http_endpoint(), + "--gas-limit", + "1000000000", + ]) + .assert_success(); + let stderr = String::from_utf8_lossy(&assert.get_output().stderr); + assert!(!stderr.contains("above the contract size limit"), "{stderr}"); +} + +#[forgetest_init] +async fn script_check_contract_sizes_uses_network_specific_spec(prj: _, cmd: _) { + write_large_runtime_deploy_script(&prj, 50_000); + + let (_api, handle) = spawn(NodeConfig::test().with_gas_limit(Some(1_000_000_000))).await; + let (_tempo_api, tempo_handle) = + spawn(NodeConfig::test_tempo().with_gas_limit(Some(1_000_000_000))).await; + let rpc = handle.http_endpoint(); + let tempo_rpc = tempo_handle.http_endpoint(); + cmd.set_current_dir(prj.root()); + for (networks, hardfork, rpc_url, is_tempo) in [ + (foundry_evm_networks::NetworkConfigs::with_optimism(), None, rpc.as_str(), false), + (Default::default(), Some("optimism:karst"), rpc.as_str(), false), + (foundry_evm_networks::NetworkConfigs::with_tempo(), None, tempo_rpc.as_str(), true), + (Default::default(), Some("tempo:T8"), tempo_rpc.as_str(), true), + ] { + prj.update_config(|config| { + config.evm_version = EvmVersion::Amsterdam; + config.networks = networks; + config.hardfork = hardfork.map(|hardfork| hardfork.parse().unwrap()); + }); + + cmd.forge_fuse(); + for var in ["FOUNDRY_CODE_SIZE_LIMIT", "DAPP_CODE_SIZE_LIMIT", "DAPP_TEST_CODE_SIZE_LIMIT"] + { + cmd.unset_env(var); + } + cmd.args(["script", "DeployLarge", "--rpc-url", rpc_url, "--gas-limit", "1000000000"]); + if is_tempo { + cmd.args([ + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--tempo.fee-token", + "0x20c0000000000000000000000000000000000000", + ]); + } + let assert = cmd.assert_success(); + let stderr = String::from_utf8_lossy(&assert.get_output().stderr); + assert!( + stderr.contains("above the contract size limit (50308 > 24576)"), + "missing size warning for {hardfork:?}: {stderr}" + ); + } +} + +// Tests that `forge script` honors `code_size_limit` configured via foundry.toml +// (the bug fix: previously only the CLI flag was honored). +#[forgetest_init] +async fn script_check_contract_sizes_honors_config_limit(prj: _, cmd: _) { + write_large_runtime_deploy_script(&prj, 30_000); + prj.update_config(|config| { + config.code_size_limit = Some(64_000); + }); + + let (_api, handle) = spawn(NodeConfig::test()).await; + cmd.set_current_dir(prj.root()); + for var in ["FOUNDRY_CODE_SIZE_LIMIT", "DAPP_CODE_SIZE_LIMIT", "DAPP_TEST_CODE_SIZE_LIMIT"] { + cmd.unset_env(var); + } + cmd.args(["script", "DeployLarge", "--rpc-url", &handle.http_endpoint()]) + .assert_success() + .stderr_eq(str![[r#""#]]); +} + +// Regression test for https://github.com/foundry-rs/foundry/issues/15207: the broadcast simulation +// must observe an `anvil_setBalance` done via `vm.rpc` earlier in the same script. +#[forgetest_init] +async fn can_broadcast_with_vm_rpc_set_balance_on_fork(prj: _, cmd: _) { + // Default genesis balance is 100 ETH; fund above the broadcast amount so the send only succeeds + // if the simulation observes the mutation. + prj.add_script( + "FundViaRpc.s.sol", + r#" +import {Script} from "forge-std/Script.sol"; + +contract FundViaRpc is Script { + address constant SENDER = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266; + address constant RECIPIENT = 0x000000000000000000000000000000000000dEaD; + + function run() external { + uint256 fundedAmount = 1000 ether; + uint256 sendAmount = 500 ether; + + vm.deal(SENDER, fundedAmount); + + // Fund the account on the forked node directly, bypassing the fork cache. + vm.rpc( + "anvil_setBalance", + string.concat("[\"", vm.toString(SENDER), "\", \"", vm.toString(fundedAmount), "\"]") + ); + + vm.startBroadcast(SENDER); + (bool ok,) = RECIPIENT.call{value: sendAmount}(""); + require(ok, "send failed"); + vm.stopBroadcast(); + } +} +"#, + ); + + let (api, handle) = spawn(NodeConfig::test()).await; + + cmd.arg("script") + .args([ + "FundViaRpc", + "--rpc-url", + &handle.http_endpoint(), + "--sender", + "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", + "--broadcast", + "--unlocked", + ]) + .assert_success(); + + // The broadcast transaction must actually have landed on-chain. + let recipient = address!("0x000000000000000000000000000000000000dEaD"); + let balance = api.balance(recipient, None).await.unwrap(); + assert_eq!(balance, U256::from(500) * U256::from(10).pow(U256::from(18))); +} + +// The on-chain simulation replays only the broadcast transactions, so cheatcode state from script +// execution must not reach it, with or without isolation. +#[forgetest_init] +async fn simulation_ignores_execution_only_state_on_fork(prj: _, cmd: _) { + prj.add_script( + "ExecutionOnlyState.s.sol", + r#" +import {Script} from "forge-std/Script.sol"; + +contract Vault { + function withdraw() external { + payable(msg.sender).transfer(1 ether); + } +} + +contract ExecutionOnlyState is Script { + function run() external { + vm.broadcast(); + Vault vault = new Vault(); + + // Funds the vault for script execution only; this is not a broadcast transaction. The + // extra ether stays unspent, so leaked execution state would let the withdrawal succeed. + vm.deal(address(vault), 2 ether); + + vm.broadcast(); + vault.withdraw(); + } +} +"#, + ); + + let (_api, handle) = spawn(NodeConfig::test()).await; + let endpoint = handle.http_endpoint(); + + for isolation in ["--isolate", "--no-isolate"] { + cmd.forge_fuse() + .args([ + "script", + "ExecutionOnlyState", + "--rpc-url", + endpoint.as_str(), + "--sender", + "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", + "--unlocked", + isolation, + ]) + .assert_failure() + .stdout_eq(str![[r#" +... +Script ran successfully. +... + [..] Vault::withdraw() + ├─ [0] 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266::receive{value: 1000000000000000000}() + │ └─ ← [OutOfFunds] EvmError: OutOfFunds + └─ ← [Revert] EvmError: Revert +... +"#]]) + .stderr_eq(str![[r#" +Error: Simulated execution failed. + +"#]]); + } +} + +// Regression test for https://github.com/foundry-rs/foundry/issues/13312: an account loaded before +// `anvil_setCode` must be refreshed before the next call in the same script execution. +#[forgetest] +async fn can_call_contract_after_vm_rpc_set_code_on_fork(prj: _, cmd: _) { + prj.add_script( + "SetCodeViaRpc.s.sol", + r#" +interface Vm { + function deal(address account, uint256 newBalance) external; + function rpc(string calldata method, string calldata params) external returns (bytes memory); + function toString(address value) external pure returns (string memory); +} + +interface ITarget { + function value() external view returns (uint256); +} + +contract SetCodeViaRpc { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + address constant TARGET = 0x0000000000000000000000000000000000001331; + + function setUp() external { + vm.deal(TARGET, 123); + } + + function run() external { + require(TARGET.code.length == 0, "target already has code"); + require(TARGET.balance == 123, "setup balance missing"); + vm.deal(TARGET, 456); + + vm.rpc( + "anvil_setCode", + string.concat("[\"", vm.toString(TARGET), "\", \"0x602a60005260206000f3\"]") + ); + + require(ITarget(TARGET).value() == 42, "unexpected value"); + require(TARGET.balance == 456, "local balance was lost"); + } +} +"#, + ); + + let (api, handle) = spawn(NodeConfig::test()).await; + + cmd.arg("script") + .args(["SetCodeViaRpc", "--rpc-url", &handle.http_endpoint()]) + .assert_success(); + + let target = address!("0x0000000000000000000000000000000000001331"); + assert_eq!(api.get_code(target, None).await.unwrap(), bytes!("602a60005260206000f3")); +} + +// An out-of-band storage mutation must replace the same locally modified slot. +#[forgetest] +async fn vm_rpc_set_storage_overrides_local_fork_slot(prj: _, cmd: _) { + prj.add_script( + "SetStorageViaRpc.s.sol", + r#" +interface Vm { + function load(address target, bytes32 slot) external view returns (bytes32); + function rpc(string calldata method, string calldata params) external returns (bytes memory); + function store(address target, bytes32 slot, bytes32 value) external; + function toString(address value) external pure returns (string memory); + function toString(bytes32 value) external pure returns (string memory); +} + +contract SetStorageViaRpc { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + address constant TARGET = 0x0000000000000000000000000000000000001331; + bytes32 constant SLOT = bytes32(0); + + function setUp() external { + vm.store(TARGET, SLOT, bytes32(uint256(1))); + } + + function run() external { + require(vm.load(TARGET, SLOT) == bytes32(uint256(1)), "setup value missing"); + + vm.rpc( + "anvil_setStorageAt", + string.concat( + "[\"", vm.toString(TARGET), "\", \"", vm.toString(SLOT), "\", \"", + vm.toString(bytes32(uint256(42))), "\"]" + ) + ); + + require(vm.load(TARGET, SLOT) == bytes32(uint256(42)), "storage stayed stale"); + } +} +"#, + ); + + let (_api, handle) = spawn(NodeConfig::test()).await; + + cmd.arg("script") + .args(["SetStorageViaRpc", "--rpc-url", &handle.http_endpoint()]) + .assert_success(); +} + +// tests that `--unlocked` cannot be combined with any remote signer +#[forgetest] +fn script_unlocked_conflicts_with_remote_signers(cmd: _) { + for signer in ["--aws", "--gcp", "--turnkey"] { + cmd.forge_fuse() + .args(["script", "Foo", "--unlocked", "--sender"]) + .args(["0x0000000000000000000000000000000000000001", signer]) + .assert_failure() + .stderr_eq(format!( + "error: the argument '--unlocked' cannot be used with '{signer}'\n\n\ + Usage: forge[..] script --unlocked --sender
[ARGS]...\n\n\ + For more information, try '--help'.\n" + )); + } +} + +// Another operator resumes from a fresh checkout that received only the authoritative recovery +// snapshot, without the broadcast and cache exports, build artifacts, or signing credentials. +#[forgetest_init] +async fn resume_hands_off_recovery_snapshot_to_fresh_checkout(prj: _, cmd: _) { + let script = prj.add_script( + "HandoffResume.s.sol", + r#" +import "forge-std/Script.sol"; + +contract HandoffResumeTarget {} + +contract HandoffResume is Script { + function run() external { + vm.startBroadcast(); + new HandoffResumeTarget(); + new HandoffResumeTarget(); + vm.stopBroadcast(); + } +} +"#, + ); + let (_api, handle) = spawn(NodeConfig::test()).await; + // The first submission is accepted but its response is lost; the second is recorded. + let (rpc, submissions, reached, release) = spawn_rpc_proxy_blocking_first_submission( + handle.http_endpoint(), + "eth_sendRawTransaction", + true, + ) + .await; + let private_key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + let sender = handle.dev_accounts().next().unwrap(); + let provider = handle.http_provider(); + let broadcast = "broadcast/HandoffResume.s.sol/31337/run-latest.json"; + let sensitive = "cache/HandoffResume.s.sol/31337/run-latest.json"; + let snapshot = "cache/HandoffResume.s.sol/31337/run-latest.json.recovery.json"; + + cmd.arg("script").arg(&script).args([ + "--tc", + "HandoffResume", + "--rpc-url", + &rpc, + "--private-key", + private_key, + "--broadcast", + ]); + let mut child = KillOnDrop::spawn(cmd.cmd()); + tokio::time::timeout(Duration::from_secs(60), reached.notified()) + .await + .expect("forge did not submit the first transaction"); + let hashes = tokio::time::timeout(Duration::from_secs(60), async { + loop { + let submitted = submissions.lock().unwrap().clone(); + if submitted.len() == 2 { + break submitted + .iter() + .map(|params| keccak256(hex::decode(params[0].as_str().unwrap()).unwrap())) + .collect::>(); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("forge did not submit the second transaction"); + for hash in &hashes { + tokio::time::timeout(Duration::from_secs(30), async { + while provider.get_transaction_receipt(*hash).await.unwrap().is_none() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("a submitted transaction was not mined"); + } + // Mining does not guarantee Forge has finished checkpointing the second submission. + let (recovery, planned) = tokio::time::timeout(Duration::from_secs(30), async { + let second_hash = serde_json::to_value(hashes[1]).unwrap(); + loop { + if let Ok(recovery) = + foundry_common::fs::read_json_file::(&prj.root().join(snapshot)) + && recovery["data"]["sequence"]["transactions"][1]["hash"] == second_hash + && let Ok(planned) = + foundry_common::fs::read_json_file::(&prj.root().join(broadcast)) + && planned["transactions"][1]["hash"] == second_hash + && !prj.root().join(snapshot).with_extension("pending").exists() + { + break (recovery, planned); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("second submission was not checkpointed in the snapshot and broadcast export"); + assert!(child.is_running(), "forge exited before it could be interrupted"); + drop(child.kill_and_wait()); + release.notify_one(); + + // Both operations have durable signed attempts, so resuming needs no signer. + let attempts = recovery["deployments"][0]["attempts"].as_array().unwrap(); + assert_eq!(attempts.len(), 2); + assert!(attempts.iter().all(|attempt| attempt["kind"]["kind"] == "signed")); + assert!(!prj.root().join(snapshot).with_extension("pending").exists()); + let planned_addresses = planned["transactions"] + .as_array() + .unwrap() + .iter() + .map(|tx| tx["contractAddress"].as_str().unwrap().parse::
().unwrap()) + .collect::>(); + + // The fresh checkout has sources only, plus the transferred snapshot. + let fresh = TestProject::new("handoff-fresh-checkout", PathStyle::Dapptools); + prj.copy_to(fresh.root()); + assert!(!fresh.root().join(broadcast).exists()); + assert!(!fresh.root().join(sensitive).exists()); + fs::create_dir_all(fresh.root().join(snapshot).parent().unwrap()).unwrap(); + fs::copy(prj.root().join(snapshot), fresh.root().join(snapshot)).unwrap(); + + let mut cmd = fresh.forge_command(); + cmd.arg("script").arg(fresh.root().join("script/HandoffResume.s.sol")).args([ + "--tc", + "HandoffResume", + "--rpc-url", + &rpc, + "--resume", + ]); + for var in ["ETH_FROM", "ETH_KEYSTORE", "ETH_KEYSTORE_ACCOUNT", "ETH_PASSWORD"] { + cmd.unset_env(var); + } + cmd.assert_success(); + + // Both mined attempts are reconciled without sending anything, and the exports are rebuilt in + // the fresh checkout with the original identities. + assert_eq!(submissions.lock().unwrap().len(), 2); + let sequence: Value = + foundry_common::fs::read_json_file(&fresh.root().join(broadcast)).unwrap(); + assert!(sequence["pending"].as_array().unwrap().is_empty()); + let transactions = sequence["transactions"].as_array().unwrap(); + let receipts = sequence["receipts"].as_array().unwrap(); + assert_eq!(transactions.len(), 2); + assert_eq!(receipts.len(), 2); + for (index, transaction) in transactions.iter().enumerate() { + for field in ["from", "nonce", "input", "value", "gas", "chainId"] { + assert_eq!( + transaction["transaction"][field], + planned["transactions"][index]["transaction"][field], + "{field}" + ); + } + let address = transaction["contractAddress"].as_str().unwrap().parse::
().unwrap(); + assert_eq!(address, planned_addresses[index]); + assert!(!provider.get_code_at(address).await.unwrap().is_empty()); + } + let mut receipt_hashes = receipts + .iter() + .map(|receipt| receipt["transactionHash"].as_str().unwrap().parse::().unwrap()) + .collect::>(); + receipt_hashes.sort(); + let mut expected = hashes.clone(); + expected.sort(); + assert_eq!(receipt_hashes, expected); + assert!(receipts.iter().all(|receipt| receipt["status"] == "0x1")); + assert!(fresh.root().join(sensitive).exists()); + assert_eq!(provider.get_transaction_count(sender).await.unwrap(), 2); +} + +const DELEGATED_RESUME_SCRIPT: &str = r#" +import "forge-std/Script.sol"; + +contract DelegatedResumeTarget {} + +contract DelegatedResume is Script { + function run() external { + vm.startBroadcast(); + new DelegatedResumeTarget(); + vm.stopBroadcast(); + } +} +"#; + +// Runs an unlocked broadcast whose only submission fails at the RPC, and returns the id of the +// delegated attempt that is left with an unknown outcome. +async fn interrupt_delegated_submission( + prj: &TestProject, + cmd: &mut TestCommand, + script: &Path, + rpc: &str, + sender: Address, + reached: &tokio::sync::Notify, + release: &tokio::sync::Notify, +) -> String { + cmd.arg("script").arg(script).args([ + "--tc", + "DelegatedResume", + "--rpc-url", + rpc, + "--sender", + &sender.to_string(), + "--unlocked", + "--broadcast", + ]); + let mut child = KillOnDrop::spawn(cmd.cmd()); + tokio::time::timeout(Duration::from_secs(60), reached.notified()) + .await + .expect("forge did not submit the delegated transaction"); + release.notify_one(); + tokio::time::timeout(Duration::from_secs(60), async { + while child.is_running() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("forge did not stop after the ambiguous submission"); + let output = child.kill_and_wait(); + assert!(!output.status.success()); + assert!( + String::from_utf8_lossy(&output.stderr).contains( + "submission outcome for delegated operation 0 is unknown; refusing to risk a duplicate transaction" + ), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + + let recovery: Value = foundry_common::fs::read_json_file( + &prj.root().join("cache/DelegatedResume.s.sol/31337/run-latest.json.recovery.json"), + ) + .unwrap(); + let attempt = &recovery["deployments"][0]["attempts"][0]; + assert_eq!(attempt["kind"]["kind"], "delegated"); + assert_eq!(attempt["kind"]["status"]["status"], "outcomeUnknown"); + attempt["id"].as_str().unwrap().to_string() +} + +// A delegated submission that reached the node but returned an error is resolved by the operator +// with its transaction hash, after an unrelated transaction is rejected. +#[forgetest_init] +async fn resume_resolves_unknown_delegated_outcome_with_tx_hash(prj: _, cmd: _) { + let script = prj.add_script("DelegatedResume.s.sol", DELEGATED_RESUME_SCRIPT); + let (_api, handle) = spawn(NodeConfig::test()).await; + let (rpc, submissions, reached, release) = spawn_rpc_proxy_blocking_first_submission( + handle.http_endpoint(), + "eth_sendTransaction", + true, + ) + .await; + let mut accounts = handle.dev_accounts(); + let sender = accounts.next().unwrap(); + let other_sender = accounts.next().unwrap(); + let provider = handle.http_provider(); + + let attempt = + interrupt_delegated_submission(&prj, &mut cmd, &script, &rpc, sender, &reached, &release) + .await; + let block = tokio::time::timeout(Duration::from_secs(30), async { + loop { + if let Some(block) = provider.get_block_by_number(1.into()).await.unwrap() { + break block; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("the accepted delegated transaction was not mined"); + let hashes = block.transactions.hashes().collect::>(); + assert_eq!(hashes.len(), 1); + let accepted = hashes[0]; + assert_eq!(provider.get_transaction_count(sender).await.unwrap(), 1); + + // A transaction that exists but does not match the saved request is rejected. + let other = ProviderBuilder::new().connect_http(handle.http_endpoint().parse().unwrap()); + let unrelated = TransactionRequest::default() + .with_from(other_sender) + .with_to(other_sender) + .with_value(U256::from(1)); + let unrelated = *other.send_transaction(unrelated).await.unwrap().tx_hash(); + let resume = |cmd: &mut TestCommand, hash: B256| { + cmd.forge_fuse().arg("script").arg(&script).args([ + "--tc", + "DelegatedResume", + "--rpc-url", + &rpc, + "--sender", + &sender.to_string(), + "--unlocked", + "--resume", + "--resume-attempt", + &attempt, + "--resume-tx-hash", + &hash.to_string(), + ]); + }; + resume(&mut cmd, unrelated); + cmd.assert_failure().stderr_eq(str![[r#" +Error: resolved transaction does not match its delegated submission attempt + +"#]]); + + // The accepted transaction completes the operation without another submission. + resume(&mut cmd, accepted); + cmd.assert_success(); + assert_eq!(submissions.lock().unwrap().len(), 1); + let sequence: Value = foundry_common::fs::read_json_file( + &prj.root().join("broadcast/DelegatedResume.s.sol/31337/run-latest.json"), + ) + .unwrap(); + assert!(sequence["pending"].as_array().unwrap().is_empty()); + let receipts = sequence["receipts"].as_array().unwrap(); + assert_eq!(receipts.len(), 1); + assert_eq!(receipts[0]["transactionHash"].as_str().unwrap().parse::().unwrap(), accepted); + let address = sequence["transactions"][0]["contractAddress"].as_str().unwrap(); + assert!(!provider.get_code_at(address.parse().unwrap()).await.unwrap().is_empty()); + assert_eq!(provider.get_transaction_count(sender).await.unwrap(), 1); +} + +// A resolved delegated hash survives interruption during receipt waiting, so plain resume +// reconciles the transaction without submitting it again. +#[forgetest_init] +async fn resume_resolves_pending_delegated_outcome_across_interruption(prj: _, cmd: _) { + prj.update_config(|config| config.transaction_timeout = 120); + let script = prj.add_script("DelegatedResume.s.sol", DELEGATED_RESUME_SCRIPT); + let (api, handle) = spawn(NodeConfig::test()).await; + api.anvil_set_auto_mine(false).await.unwrap(); + let (receipt_rpc, receipt_requests) = + spawn_rpc_proxy_recording_method(handle.http_endpoint(), "eth_getTransactionReceipt").await; + let (rpc, submissions, reached, release) = + spawn_rpc_proxy_blocking_first_submission(receipt_rpc, "eth_sendTransaction", true).await; + let sender = handle.dev_accounts().next().unwrap(); + let provider = handle.http_provider(); + + let attempt = + interrupt_delegated_submission(&prj, &mut cmd, &script, &rpc, sender, &reached, &release) + .await; + let block = provider.get_block_by_number("pending".parse().unwrap()).await.unwrap().unwrap(); + let hashes = block.transactions.hashes().collect::>(); + assert_eq!(hashes.len(), 1); + let accepted = hashes[0]; + assert!(provider.get_transaction_receipt(accepted).await.unwrap().is_none()); + assert_eq!(provider.get_transaction_count(sender).await.unwrap(), 0); + receipt_requests.lock().unwrap().clear(); + + cmd.forge_fuse().arg("script").arg(&script).args([ + "--tc", + "DelegatedResume", + "--rpc-url", + &rpc, + "--sender", + &sender.to_string(), + "--unlocked", + "--resume", + "--resume-attempt", + &attempt, + "--resume-tx-hash", + &accepted.to_string(), + ]); + let mut child = KillOnDrop::spawn(cmd.cmd()); + let snapshot = + prj.root().join("cache/DelegatedResume.s.sol/31337/run-latest.json.recovery.json"); + let accepted_json = serde_json::to_value(accepted).unwrap(); + tokio::time::timeout(Duration::from_secs(60), async { + loop { + if let Ok(recovery) = foundry_common::fs::read_json_file::(&snapshot) + && recovery["deployments"][0]["attempts"][0]["kind"]["status"] + == serde_json::json!({ "status": "pending", "hash": accepted_json }) + && !snapshot.with_extension("pending").exists() + && !receipt_requests.lock().unwrap().is_empty() + { + break; + } + assert!(child.is_running(), "forge exited before checkpointing the resolved hash"); + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("forge did not checkpoint the resolved hash and start waiting for its receipt"); + assert!(child.is_running(), "forge exited before it could be interrupted"); + drop(child.kill_and_wait()); + assert_eq!(submissions.lock().unwrap().len(), 1); + assert!(provider.get_transaction_receipt(accepted).await.unwrap().is_none()); + + api.mine_one().await.unwrap(); + // The attempt is already resolved; no resolution flags are needed on the next invocation. + cmd.forge_fuse().arg("script").arg(&script).args([ + "--tc", + "DelegatedResume", + "--rpc-url", + &rpc, + "--sender", + &sender.to_string(), + "--unlocked", + "--resume", + ]); + cmd.assert_success(); + + assert_eq!(submissions.lock().unwrap().len(), 1); + let sequence: Value = foundry_common::fs::read_json_file( + &prj.root().join("broadcast/DelegatedResume.s.sol/31337/run-latest.json"), + ) + .unwrap(); + assert!(sequence["pending"].as_array().unwrap().is_empty()); + let receipts = sequence["receipts"].as_array().unwrap(); + assert_eq!(receipts.len(), 1); + assert_eq!(receipts[0]["transactionHash"], accepted_json); + assert_eq!(receipts[0]["status"], "0x1"); + let address = sequence["transactions"][0]["contractAddress"].as_str().unwrap(); + assert!(!provider.get_code_at(address.parse().unwrap()).await.unwrap().is_empty()); + assert_eq!(provider.get_transaction_count(sender).await.unwrap(), 1); +} + +// A delegated submission that never reached the node is retried once after the operator +// explicitly allows it. +#[forgetest_init] +async fn resume_retries_unsubmitted_delegated_outcome(prj: _, cmd: _) { + let script = prj.add_script("DelegatedResume.s.sol", DELEGATED_RESUME_SCRIPT); + let (_api, handle) = spawn(NodeConfig::test()).await; + let (rpc, submissions, reached, release) = spawn_rpc_proxy_blocking_first_submission( + handle.http_endpoint(), + "eth_sendTransaction", + false, + ) + .await; + let sender = handle.dev_accounts().next().unwrap(); + let provider = handle.http_provider(); + + let attempt = + interrupt_delegated_submission(&prj, &mut cmd, &script, &rpc, sender, &reached, &release) + .await; + assert_eq!(provider.get_transaction_count(sender).await.unwrap(), 0); + + cmd.forge_fuse().arg("script").arg(&script).args([ + "--tc", + "DelegatedResume", + "--rpc-url", + &rpc, + "--sender", + &sender.to_string(), + "--unlocked", + "--resume", + "--resume-attempt", + &attempt, + "--resume-retry", + ]); + cmd.assert_success(); + + assert_eq!(submissions.lock().unwrap().len(), 2); + let sequence: Value = foundry_common::fs::read_json_file( + &prj.root().join("broadcast/DelegatedResume.s.sol/31337/run-latest.json"), + ) + .unwrap(); + assert!(sequence["pending"].as_array().unwrap().is_empty()); + let receipts = sequence["receipts"].as_array().unwrap(); + assert_eq!(receipts.len(), 1); + assert_eq!(receipts[0]["status"], "0x1"); + let address = sequence["transactions"][0]["contractAddress"].as_str().unwrap(); + assert!(!provider.get_code_at(address.parse().unwrap()).await.unwrap().is_empty()); + assert_eq!(provider.get_transaction_count(sender).await.unwrap(), 1); +} + +async fn prepare_manual_gas_limit( + prj: &TestProject, + cmd: &mut TestCommand, + unlocked: bool, +) -> anvil::NodeHandle { + let deploy_script = prj.add_source( + "Foo", + r#" +import "forge-std/Script.sol"; + +contract GasWaster { + function wasteGas(uint256 minGas) public { + require(gasleft() >= minGas, "Gas left needs to be higher"); + } +} +contract DeployScript is Script { + function run() external { + vm.startBroadcast(); + GasWaster gasWaster = new GasWaster(); + gasWaster.wasteGas{gas: 500000}(200000); + } +} + "#, + ); + + let deploy_contract = deploy_script.display().to_string() + ":DeployScript"; + + let node_config = NodeConfig::test().with_eth_rpc_url(Some(rpc::next_http_archive_rpc_url())); + let (_api, handle) = spawn(node_config).await; + let dev = handle.dev_accounts().next().unwrap(); + cmd.set_current_dir(prj.root()); + + cmd.args([ + "script", + &deploy_contract, + "--root", + prj.root().to_str().unwrap(), + "--fork-url", + &handle.http_endpoint(), + "-vvvvv", + "--slow", + "--broadcast", + ]); + if unlocked { + cmd.args(["--unlocked", "--sender", &dev.to_string(), "--ignored-error-codes=2018"]); + } else { + cmd.args([ + "--private-key", + "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + ]); + } + handle +} + +fn latest_broadcast_path(root: &Path) -> PathBuf { + foundry_common::fs::json_files(&root.join("broadcast")) + .find(|path| { + path.ends_with("run-latest.json") && !path.to_string_lossy().contains("dry-run") + }) + .expect("no latest broadcast artifact") +} diff --git a/crates/forge/tests/cli/selectors.rs b/crates/forge/tests/cli/selectors.rs new file mode 100644 index 0000000000000..7b33268a8aea3 --- /dev/null +++ b/crates/forge/tests/cli/selectors.rs @@ -0,0 +1,233 @@ +use foundry_compilers::artifacts::output_selection::ContractOutputSelection; +use foundry_config::DenyLevel; +use foundry_test_utils::{forgetest, snapbox::IntoData, str, util::OutputExt}; +use std::fs; + +#[forgetest] +fn collision_cache_preserves_artifacts_and_invalidates_imports(prj: _, cmd: _) { + prj.add_source( + "Base.sol", + "contract Base { function shared() public pure returns (uint256) { return 1; } }", + ); + prj.add_source("First.sol", "import {Base} from './Base.sol'; contract First is Base {}"); + prj.add_source( + "Second.sol", + "contract Second { function shared() public pure returns (uint256) { return 2; } }", + ); + let expected = str![[r#" +1 collisions found: + +| Selector | First | Second | +|----------|----------|----------| +| 7126be5f | shared() | shared() | + + +"#]]; + let args = ["selectors", "collision", "First", "Second", "--md"]; + let cache = prj.cache().with_extension("json.abi"); + cmd.args(args).arg("--no-cache").assert_success().stdout_eq(expected.clone()); + assert!(!cache.exists()); + for _ in 0..2 { + cmd.forge_fuse().args(args).assert_success().stdout_eq(expected.clone()); + } + assert!(cache.is_dir()); + assert!(fs::read_dir(&prj.paths().artifacts).unwrap().next().is_none()); + assert!(!prj.cache().exists()); + + let qualified = + ["selectors", "collision", "src/First.sol:First", "src/Second.sol:Second", "--md"]; + cmd.forge_fuse().args(qualified).arg("--no-cache").assert_success().stdout_eq(expected.clone()); + cmd.forge_fuse().args(qualified).assert_success().stdout_eq(expected.clone()); + + cmd.forge_fuse().args(["build", "--no-lint"]).assert_success(); + let built = [ + prj.paths().artifacts.join("First.sol/First.json"), + prj.paths().artifacts.join("Second.sol/Second.json"), + prj.cache().clone(), + ] + .map(|path| { + let contents = fs::read(&path).unwrap(); + (path, contents) + }); + cmd.forge_fuse().args(args).assert_success().stdout_eq(expected); + + prj.add_source( + "Base.sol", + "contract Base { function changed() public pure returns (uint256) { return 1; } }", + ); + for args in [args, qualified] { + cmd.forge_fuse().args(args).assert_success().stdout_eq(str![[r#" +No colliding method selectors between the two contracts. + +"#]]); + } + for (path, contents) in built { + assert_eq!(fs::read(path).unwrap(), contents); + } + + cmd.forge_fuse().args(args).arg("--force").assert_success(); + assert!(!cache.exists()); + assert!(!prj.paths().artifacts.exists()); + assert!(!prj.cache().exists()); +} + +#[forgetest] +fn collision_cache_respects_warning_denial(prj: _, cmd: _) { + prj.add_source( + "Counter.sol", + "contract Counter { function value() external returns (uint256) { return 1; } }", + ); + let args = ["selectors", "collision", "Counter", "Counter"]; + for _ in 0..2 { + cmd.forge_fuse().args(args).assert_success(); + } + let expected = cmd + .forge_fuse() + .args(args) + .args(["--deny", "warnings", "--no-cache"]) + .assert_failure() + .get_output() + .stderr_lossy(); + cmd.forge_fuse() + .args(args) + .args(["--deny", "warnings"]) + .assert_failure() + .stderr_eq(expected.into_data().raw()); +} + +#[forgetest] +fn collision_cache_preserves_ambiguous_name_selection(prj: _, cmd: _) { + prj.add_source("A.sol", "contract First { function a() external {} }"); + prj.add_source("Z.sol", "contract First { function b() external {} }"); + prj.add_source("Second.sol", "contract Second { function a() external {} }"); + let args = ["selectors", "collision", "First", "Second", "--md"]; + let expected = cmd.args(args).arg("--no-cache").assert_success().get_output().stdout_lossy(); + for _ in 0..2 { + cmd.forge_fuse().args(args).assert_success().stdout_eq(&expected); + } + prj.add_source("Z.sol", "contract First { function c() external {} }"); + cmd.forge_fuse().args(args).assert_success().stdout_eq(&expected); +} + +#[forgetest] +fn collision_cache_preserves_explicit_outputs(prj: _, cmd: _) { + prj.add_source( + "Counter.sol", + "contract Counter { function value() external pure returns (uint256) { return 1; } }", + ); + let cache = prj.cache().with_extension("json.abi"); + for flags in [ + vec!["--extra-output", "metadata"], + vec!["--extra-output-files", "metadata"], + vec!["--build-info"], + ] { + cmd.forge_fuse().arg("clean").assert_success(); + cmd.forge_fuse() + .args(["selectors", "collision", "Counter", "Counter"]) + .args(flags) + .assert_success(); + assert!(prj.paths().artifacts.join("Counter.sol/Counter.json").is_file()); + assert!(prj.cache().is_file()); + assert!(!cache.exists()); + } +} + +#[forgetest] +fn find_cache_preserves_artifacts_and_invalidates_imports(prj: _, cmd: _) { + prj.add_source("Base.sol", "contract Base { function shared() external pure {} }"); + prj.add_source("First.sol", "import './Base.sol'; contract First is Base {}"); + let args = ["selectors", "find", "7126be5f", "--md"]; + let expected = str![[r#" + +| Type | Signature | Selector | Contract | +|----------|-----------|------------|----------| +| Function | shared() | 0x7126be5f | Base | +| Function | shared() | 0x7126be5f | First | + + +"#]]; + let cache = prj.cache().with_extension("json.abi"); + prj.update_config(|config| config.cache = false); + cmd.args(args).assert_success().stdout_eq(expected.clone()); + assert!(!cache.exists()); + prj.update_config(|config| config.cache = true); + for _ in 0..2 { + cmd.forge_fuse().args(args).assert_success().stdout_eq(expected.clone()); + } + assert!(cache.is_dir()); + assert!(!prj.cache().exists()); + assert!(fs::read_dir(&prj.paths().artifacts).unwrap().next().is_none()); + + cmd.forge_fuse().args(["build", "--no-lint"]).assert_success(); + let built = [ + prj.paths().artifacts.join("Base.sol/Base.json"), + prj.paths().artifacts.join("First.sol/First.json"), + prj.cache().clone(), + ] + .map(|path| { + let contents = fs::read(&path).unwrap(); + (path, contents) + }); + cmd.forge_fuse().args(args).assert_success().stdout_eq(expected.clone()); + + prj.add_source("Base.sol", "contract Base { function changed() external pure {} }"); + for _ in 0..2 { + cmd.forge_fuse().args(args).assert_failure().stderr_eq( + "Searching for selector \"7126be5f\" in the project...\n\ + Error: Selector not found in the project.\n", + ); + } + for (path, contents) in built { + assert_eq!(fs::read(path).unwrap(), contents); + } + + // Force rebuilds must discard both primary and secondary cached output. + prj.add_source("Base.sol", "contract Base { function shared() external pure {} }"); + prj.update_config(|config| config.force = true); + cmd.forge_fuse().args(args).assert_success().stdout_eq(expected); + assert!(!prj.cache().exists()); + assert!(!prj.paths().artifacts.exists()); +} + +#[forgetest] +fn find_cache_respects_warning_denial(prj: _, cmd: _) { + prj.add_source( + "Counter.sol", + "contract Counter { function shared() external returns (uint256) { return 1; } }", + ); + let args = ["selectors", "find", "7126be5f"]; + for _ in 0..2 { + cmd.forge_fuse().args(args).assert_success(); + } + prj.update_config(|config| { + config.deny = DenyLevel::Warnings; + config.cache = false; + }); + let expected = cmd.forge_fuse().args(args).assert_failure().get_output().stderr_lossy(); + prj.update_config(|config| config.cache = true); + cmd.forge_fuse().args(args).assert_failure().stderr_eq(expected.into_data().raw()); +} + +#[forgetest] +fn find_cache_preserves_explicit_outputs(prj: _, cmd: _) { + prj.add_source("Counter.sol", "contract Counter { function shared() external pure {} }"); + let args = ["selectors", "find", "7126be5f"]; + let cache = prj.cache().with_extension("json.abi"); + prj.update_config(|config| { + config.extra_output_files = vec![ContractOutputSelection::Metadata]; + }); + cmd.args(args).assert_success(); + assert!(!cache.exists()); + assert!(!prj.cache().exists()); + assert!(fs::read_dir(&prj.paths().artifacts).unwrap().next().is_none()); + + prj.update_config(|config| { + config.extra_output_files.clear(); + config.build_info = true; + }); + cmd.forge_fuse().args(args).assert_success(); + assert!(!cache.exists()); + assert!(prj.cache().is_file()); + assert!(prj.paths().artifacts.join("Counter.sol/Counter.json").is_file()); + assert!(fs::read_dir(prj.paths().artifacts.join("build-info")).unwrap().next().is_some()); +} diff --git a/crates/forge/tests/cli/soldeer.rs b/crates/forge/tests/cli/soldeer.rs index 7245f7d696f1f..3f5ecedfa7e55 100644 --- a/crates/forge/tests/cli/soldeer.rs +++ b/crates/forge/tests/cli/soldeer.rs @@ -2,8 +2,8 @@ use std::{fs, path::Path}; -use foundry_test_utils::forgesoldeer; -forgesoldeer!(install_dependency, |prj, cmd| { +#[forgetest_init] +fn install_dependency(prj: _, cmd: _) { let command = "install"; let dependency = "forge-std~1.8.1"; @@ -46,9 +46,10 @@ forge-std = "1.8.1" "#; assert_data_eq!(read_file_to_string(&foundry_file), foundry_contents); -}); +} -forgesoldeer!(install_dependency_git, |prj, cmd| { +#[forgetest_init] +fn install_dependency_git(prj: _, cmd: _) { let command = "install"; let dependency = "forge-std~1.8.1"; let git_arg = "--git"; @@ -92,9 +93,10 @@ forge-std = { version = "1.8.1", git = "https://gitlab.com/mario4582928/Mario.gi "#; assert_data_eq!(read_file_to_string(&foundry_file), foundry_contents); -}); +} -forgesoldeer!(install_dependency_git_commit, |prj, cmd| { +#[forgetest_init] +fn install_dependency_git_commit(prj: _, cmd: _) { let command = "install"; let dependency = "forge-std~1.8.1"; let git_arg = "--git"; @@ -141,9 +143,10 @@ forge-std = { version = "1.8.1", git = "https://gitlab.com/mario4582928/Mario.gi "#; assert_data_eq!(read_file_to_string(&foundry_file), foundry_contents); -}); +} -forgesoldeer!(update_dependencies, |prj, cmd| { +#[forgetest_init] +fn update_dependencies(prj: _, cmd: _) { let command = "update"; // We need to write this into the foundry.toml to make the update install the dependency @@ -207,9 +210,10 @@ mario-custom-tag = { version = "1.0", git = "https://gitlab.com/mario4582928/Mar mario-custom-branch = { version = "1.0", git = "https://gitlab.com/mario4582928/Mario.git", tag = "custom-branch" } "#; assert_data_eq!(read_file_to_string(&foundry_file), foundry_contents); -}); +} -forgesoldeer!(update_dependencies_simple_version, |prj, cmd| { +#[forgetest_init] +fn update_dependencies_simple_version(prj: _, cmd: _) { let command = "update"; // We need to write this into the foundry.toml to make the update install the dependency, this @@ -254,9 +258,10 @@ forge-std = "1.8.1" "#; assert_data_eq!(read_file_to_string(&foundry_file), foundry_contents); -}); +} -forgesoldeer!(install_dependency_with_remappings_config, |prj, cmd| { +#[forgetest_init] +fn install_dependency_with_remappings_config(prj: _, cmd: _) { let command = "install"; let dependency = "forge-std~1.8.1"; let foundry_updates = r#"[profile.default] @@ -311,9 +316,10 @@ forge-std = "1.8.1" "#; assert_data_eq!(read_file_to_string(&foundry_file), foundry_contents); -}); +} -forgesoldeer!(install_dependency_with_remappings_txt, |prj, cmd| { +#[forgetest_init] +fn install_dependency_with_remappings_txt(prj: _, cmd: _) { let command = "install"; let dependency = "forge-std~1.8.1"; let foundry_updates = r#" @@ -347,13 +353,70 @@ remappings_regenerate = true "#; let remappings_file = prj.root().join("remappings.txt"); assert_data_eq!(read_file_to_string(&remappings_file), remappings_content); -}); +} -forgesoldeer!(login, |prj, cmd| { +#[forgetest_init] +fn login(cmd: _) { let command = "login"; let _ = cmd.arg("soldeer").arg(command).assert_failure(); -}); +} + +#[forgetest_init] +fn clean(prj: _, cmd: _) { + let dependency = "forge-std~1.8.1"; + let foundry_contents = r#"[profile.default] +src = "src" +out = "out" +libs = ["lib", "dependencies"] + +[dependencies] +"#; + let foundry_file = prj.root().join("foundry.toml"); + fs::write(&foundry_file, foundry_contents).unwrap(); + + cmd.args(["soldeer", "install", dependency]).assert_success(); + cmd.forge_fuse(); // reset command + + // Making sure the path was created to the dependency and that foundry.toml exists + // meaning that the dependencies were installed correctly + let path_dep_forge = + prj.root().join("dependencies").join("forge-std-1.8.1").join("foundry.toml"); + assert!(path_dep_forge.exists()); + + let command = "clean"; + cmd.arg("soldeer").args([command]).assert_success(); + // Dependencies should have been removed from disk + assert!(!prj.root().join("dependencies").exists()); +} + +#[forgetest_init] +fn detect_project_root(prj: _, cmd: _) { + let command = "update"; + + let foundry_updates = r#"[profile.default] +src = "src" +out = "out" +libs = ["lib", "dependencies"] + +# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options + +[dependencies] +forge-std = "1.8.1" +"#; + let foundry_file = prj.root().join("foundry.toml"); + + fs::write(&foundry_file, foundry_updates).unwrap(); + + // run command from sub-directory + cmd.set_current_dir(prj.root().join("src")); + cmd.arg("soldeer").arg(command).assert_success(); + // Making sure the path was created to the dependency and that foundry.toml exists + // meaning that the dependencies were installed correctly + let path_dep_forge = + prj.root().join("dependencies").join("forge-std-1.8.1").join("foundry.toml"); + assert!(path_dep_forge.exists()); +} fn read_file_to_string(path: &Path) -> String { let contents: String = fs::read_to_string(path).unwrap_or_default(); diff --git a/crates/forge/tests/cli/svm.rs b/crates/forge/tests/cli/svm.rs index 5fe82f5670cc2..86e0c31c46f1c 100644 --- a/crates/forge/tests/cli/svm.rs +++ b/crates/forge/tests/cli/svm.rs @@ -11,7 +11,7 @@ use svm::Platform; /// 3. svm bumped in foundry-compilers /// 4. foundry-compilers update with any breaking changes /// 5. upgrade the `LATEST_SOLC` -const LATEST_SOLC: Version = Version::new(0, 8, 30); +const LATEST_SOLC: Version = Version::new(0, 8, 37); macro_rules! ensure_svm_releases { ($($test:ident => $platform:ident),* $(,)?) => {$( @@ -42,9 +42,13 @@ ensure_svm_releases!( ); // Ensures we can always test with the latest solc build -forgetest_init!(can_test_with_latest_solc, |prj, cmd| { - let src = format!( - r#" +#[forgetest_init] +fn can_test_with_latest_solc(prj: _, cmd: _) { + prj.initialize_default_contracts(); + prj.add_test( + "Counter.2.t.sol", + &format!( + r#" pragma solidity ={LATEST_SOLC}; import "forge-std/Test.sol"; @@ -55,8 +59,8 @@ contract CounterTest is Test {{ }} }} "# + ), ); - prj.add_test("Counter", &src).unwrap(); // we need to remove the pinned solc version for this prj.update_config(|c| { @@ -65,7 +69,7 @@ contract CounterTest is Test {{ cmd.arg("test").assert_success().stdout_eq(str![[r#" ... -Ran 1 test for test/Counter.sol:CounterTest +Ran 1 test for test/Counter.2.t.sol:CounterTest [PASS] testAssert() ([GAS]) Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] ... @@ -77,4 +81,252 @@ Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] Ran 2 test suites [ELAPSED]: 3 tests passed, 0 failed, 0 skipped (3 total tests) "#]]); -}); +} + +#[forgetest_init] +fn can_test_with_solc_0_8_37_amsterdam(prj: _, cmd: _) { + prj.initialize_default_contracts(); + prj.add_test( + "StateGas.t.sol", + r#" +pragma solidity =0.8.37; + +import "forge-std/Test.sol"; + +interface VmGas { + struct Gas { + uint64 gasLimit; + uint64 gasTotalUsed; + uint64 gasMemoryUsed; + int64 gasRefunded; + uint64 gasRemaining; + int64 gasStateUsed; + } + + function lastCallGas() external view returns (Gas memory gas); + function lastFrameGas() external view returns (Gas memory gas); + function snapshotGasLastCall(string calldata name) external returns (uint256 gasUsed); + function snapshotGasLastFrame(string calldata name) external returns (uint256 gasUsed); + function startSnapshotGas(string calldata name) external; + function stopSnapshotGas() external returns (uint256 gasUsed); +} + +contract StateGasTarget { + uint256 value; + + function setValue() external { + value = 1; + } + + function setValueAndRevert() external { + value = 1; + revert(); + } + + function setValueWithData(bytes calldata) external { + value = 1; + } +} + +contract LargeRuntime { + constructor() { + assembly { + return(0, 12000) + } + } +} + +contract StateGasRefundTarget { + uint256 value; + uint256 cleared = 1; + + function setValueAndClear() external { + value = 1; + cleared = 0; + } +} + +contract StateGasTest is Test { + uint64 constant STORAGE_SET_STATE_GAS = 64 * 1530; + uint64 constant LARGE_RUNTIME_STATE_GAS = 12_000 * 1530; + uint256 constant CALLDATA_SIZE = 20_000; + uint256 constant CALL_FLOOR_BASE_GAS = 15_000; + uint256 constant CALLDATA_FLOOR_GAS_PER_BYTE = 64; + VmGas constant VM_GAS = VmGas(address(uint160(uint256(keccak256("hevm cheat code"))))); + + function testReportsStateGas() public { + StateGasTarget target = new StateGasTarget(); + VmGas.Gas memory createGas = VM_GAS.lastFrameGas(); + // Like every isolated frame, the snapshot is the deployment's receipt gas, which also + // includes the account-creation state gas that the CREATE opcode charges outside the frame. + assertGt( + VM_GAS.snapshotGasLastFrame("stateGasCreate"), + createGas.gasLimit - createGas.gasRemaining, + "create snapshot excludes account creation" + ); + target.setValue(); + + VmGas.Gas memory callGas = VM_GAS.lastCallGas(); + VmGas.Gas memory frameGas = VM_GAS.lastFrameGas(); + assertStorageWriteGas(callGas); + assertStorageWriteGas(frameGas); + assertEq( + VM_GAS.snapshotGasLastCall("stateGasCall"), + callGas.gasLimit - callGas.gasRemaining, + "last call snapshot changed" + ); + assertEq( + VM_GAS.snapshotGasLastFrame("stateGasFrame"), + frameGas.gasLimit - frameGas.gasRemaining, + "last frame snapshot changed" + ); + + StateGasTarget revertingTarget = new StateGasTarget(); + (bool success,) = address(revertingTarget).call( + abi.encodeCall(revertingTarget.setValueAndRevert, ()) + ); + assertFalse(success, "state gas call should revert"); + VmGas.Gas memory revertedGas = VM_GAS.lastFrameGas(); + assertEq(revertedGas.gasStateUsed, 0, "reverted frame used state gas"); + assertEq( + VM_GAS.snapshotGasLastFrame("stateGasRevert"), + revertedGas.gasLimit - revertedGas.gasRemaining, + "reverted frame snapshot changed" + ); + } + + function testLargeContractDeployment() public { + LargeRuntime deployed = new LargeRuntime(); + assertEq(address(deployed).code.length, 12_000); + assertEq( + VM_GAS.lastFrameGas().gasStateUsed, + int64(LARGE_RUNTIME_STATE_GAS), + "wrong code deposit state gas" + ); + } + + /// forge-config: default.isolate = true + function testCalldataFloorPreservesRegularGas() public { + bytes memory data = new bytes(CALLDATA_SIZE); + StateGasTarget target = new StateGasTarget(); + bytes memory callData = abi.encodeCall(target.setValueWithData, (data)); + uint256 floorGas = CALL_FLOOR_BASE_GAS + callData.length * CALLDATA_FLOOR_GAS_PER_BYTE; + (bool success,) = address(target).call(callData); + assertTrue(success, "state gas call failed"); + + VmGas.Gas memory gas = VM_GAS.lastFrameGas(); + assertEq(gas.gasTotalUsed, floorGas, "wrong calldata floor gas"); + assertEq(gas.gasStateUsed, int64(STORAGE_SET_STATE_GAS), "wrong state gas"); + } + + /// forge-config: default.isolate = true + function testValueTransferWithLowGas() public { + uint256 section = snapshotValueTransfer(address(0xBEEF1), 1_000_000); + uint256 snapshot = VM_GAS.snapshotGasLastCall("valueTransfer"); + + // Creating the recipient charges state gas, which must not turn a low gas limit or the + // 2300 gas stipend into a zero snapshot or a region refund. + assertEq(snapshotValueTransfer(address(0xBEEF2), 50_000), section, "low gas region changed"); + assertEq(VM_GAS.snapshotGasLastCall("lowGasValueTransfer"), snapshot, "low gas snapshot changed"); + assertEq(snapshotValueTransfer(address(0xBEEF3), 0), section, "stipend region changed"); + assertEq(VM_GAS.snapshotGasLastCall("stipendValueTransfer"), snapshot, "stipend snapshot changed"); + } + + /// forge-config: default.isolate = true + function testStateGasSnapshotIncludesRefund() public { + StateGasRefundTarget target = new StateGasRefundTarget(); + target.setValueAndClear(); + + VmGas.Gas memory gas = VM_GAS.lastCallGas(); + assertEq(gas.gasStateUsed, int64(STORAGE_SET_STATE_GAS), "wrong state gas"); + assertGt(gas.gasRefunded, 0, "storage clear was not refunded"); + assertEq( + VM_GAS.snapshotGasLastCall("stateGasRefund"), + gas.gasTotalUsed + uint64(gas.gasStateUsed) - uint64(gas.gasRefunded), + "snapshot ignored the refund" + ); + } + + /// forge-config: default.isolate = true + function testCalldataFloorWithLowGas() public { + bytes memory data = new bytes(CALLDATA_SIZE); + uint256 floorGas = CALL_FLOOR_BASE_GAS + data.length * CALLDATA_FLOOR_GAS_PER_BYTE; + // The calldata floor exceeds both the forwarded gas and the intrinsic gas. + (bool success,) = address(0xCA11).call{gas: 1_000}(data); + assertTrue(success, "low gas call failed"); + assertEq(VM_GAS.snapshotGasLastCall("calldataFloorLowGas"), floorGas, "wrong calldata floor gas"); + } + + function snapshotValueTransfer(address to, uint256 gasLimit) internal returns (uint256 section) { + VM_GAS.startSnapshotGas("valueTransferSection"); + (bool success,) = to.call{value: 1, gas: gasLimit}(""); + section = VM_GAS.stopSnapshotGas(); + assertTrue(success, "value transfer failed"); + } + + function assertStorageWriteGas(VmGas.Gas memory gas) internal pure { + assertGt(gas.gasTotalUsed, 0, "regular gas was not recorded"); + assertLt(gas.gasTotalUsed, STORAGE_SET_STATE_GAS, "state gas counted as regular gas"); + assertEq(gas.gasStateUsed, int64(STORAGE_SET_STATE_GAS), "wrong state gas"); + } +} +"#, + ); + + // Amsterdam is an experimental EVM version in solc 0.8.37. + let args = ["test", "--use", "0.8.37", "--evm-version", "amsterdam", "--experimental"]; + cmd.args(args).assert_success(); + // Exercise the individual and combined CLI flags. + cmd.forge_fuse().args(args).arg("--enable-tx-gas-limit").assert_success(); + cmd.forge_fuse().args(args).arg("--isolate").assert_success(); + cmd.forge_fuse().args(args).args(["--isolate", "--enable-tx-gas-limit"]).assert_success(); + + // Exercise the equivalent foundry.toml settings. + prj.update_config(|config| { + config.isolate = true; + config.enable_tx_gas_limit = true; + }); + cmd.forge_fuse().args(args).assert_success(); +} + +#[forgetest_init] +fn can_test_slot_number_amsterdam(prj: _, cmd: _) { + prj.add_test( + "SlotNumber.t.sol", + r#" +pragma solidity =0.8.37; + +import "forge-std/Test.sol"; + +interface VmSlot { + function getSlotNumber() external view returns (uint64); + function rollSlot(uint64 newSlotNumber) external; +} + +contract SlotNumberTest is Test { + VmSlot constant slots = VmSlot(address(vm)); + + function readSlot() external view returns (uint256) { + return block.slotnum; + } + + function testSlotNumber(uint64 first, uint64 second) public { + uint256 number = vm.getBlockNumber(); + uint256 timestamp = vm.getBlockTimestamp(); + slots.rollSlot(first); + assertEq(slots.getSlotNumber(), first); + assertEq(this.readSlot(), first); + slots.rollSlot(second); + assertEq(slots.getSlotNumber(), second); + assertEq(this.readSlot(), second); + assertEq(vm.getBlockNumber(), number); + assertEq(vm.getBlockTimestamp(), timestamp); + } +} +"#, + ); + let args = ["test", "--use", "0.8.37", "--evm-version", "amsterdam", "--experimental"]; + cmd.args(args).assert_success(); + cmd.forge_fuse().args(args).args(["--optimize", "--via-ir"]).assert_success(); + cmd.forge_fuse().args(args).arg("--isolate").assert_success(); +} diff --git a/crates/forge/tests/cli/test_cmd.rs b/crates/forge/tests/cli/test_cmd.rs deleted file mode 100644 index 7e2fdcd51e4c2..0000000000000 --- a/crates/forge/tests/cli/test_cmd.rs +++ /dev/null @@ -1,3946 +0,0 @@ -//! Contains various tests for `forge test`. - -use alloy_primitives::U256; -use anvil::{NodeConfig, spawn}; -use foundry_test_utils::{ - rpc, str, - util::{OTHER_SOLC_VERSION, OutputExt, SOLC_VERSION}, -}; -use similar_asserts::assert_eq; -use std::{path::PathBuf, str::FromStr}; - -// tests that test filters are handled correctly -forgetest!(can_set_filter_values, |prj, cmd| { - let patt = regex::Regex::new("test*").unwrap(); - let glob = globset::Glob::from_str("foo/bar/baz*").unwrap(); - - // explicitly set patterns - prj.update_config(|config| { - config.test_pattern = Some(patt.clone().into()); - config.test_pattern_inverse = None; - config.contract_pattern = Some(patt.clone().into()); - config.contract_pattern_inverse = None; - config.path_pattern = Some(glob.clone()); - config.path_pattern_inverse = None; - config.coverage_pattern_inverse = None; - }); - - let config = cmd.config(); - - assert_eq!(config.test_pattern.unwrap().as_str(), patt.as_str()); - assert_eq!(config.test_pattern_inverse, None); - assert_eq!(config.contract_pattern.unwrap().as_str(), patt.as_str()); - assert_eq!(config.contract_pattern_inverse, None); - assert_eq!(config.path_pattern.unwrap(), glob); - assert_eq!(config.path_pattern_inverse, None); - assert_eq!(config.coverage_pattern_inverse, None); -}); - -// tests that warning is displayed when there are no tests in project -forgetest!(warn_no_tests, |prj, cmd| { - prj.add_source( - "dummy", - r" -contract Dummy {} -", - ) - .unwrap(); - // set up command - cmd.args(["test"]); - - // run command and assert - cmd.assert_failure().stdout_eq(str![[r#" -No tests found in project! Forge looks for functions that starts with `test`. - -"#]]); -}); - -// tests that warning is displayed with pattern when no tests match -forgetest!(warn_no_tests_match, |prj, cmd| { - prj.add_source( - "dummy", - r" -contract Dummy {} -", - ) - .unwrap(); - - // set up command - cmd.args(["test", "--match-test", "testA.*", "--no-match-test", "testB.*"]); - cmd.args(["--match-contract", "TestC.*", "--no-match-contract", "TestD.*"]); - cmd.args(["--match-path", "*TestE*", "--no-match-path", "*TestF*"]); - - // run command and assert - cmd.assert_failure().stdout_eq(str![[r#" -No tests match the provided pattern: - match-test: `testA.*` - no-match-test: `testB.*` - match-contract: `TestC.*` - no-match-contract: `TestD.*` - match-path: `*TestE*` - no-match-path: `*TestF*` - -"#]]); -}); - -// tests that suggestion is provided with pattern when no tests match -forgetest!(suggest_when_no_tests_match, |prj, cmd| { - // set up project - prj.add_source( - "TestE.t.sol", - r" -contract TestC { - function test1() public { - } -} - ", - ) - .unwrap(); - - // set up command - cmd.args(["test", "--match-test", "testA.*", "--no-match-test", "testB.*"]); - cmd.args(["--match-contract", "TestC.*", "--no-match-contract", "TestD.*"]); - cmd.args(["--match-path", "*TestE*", "--no-match-path", "*TestF*"]); - - // run command and assert - cmd.assert_failure().stdout_eq(str![[r#" -No tests match the provided pattern: - match-test: `testA.*` - no-match-test: `testB.*` - match-contract: `TestC.*` - no-match-contract: `TestD.*` - match-path: `*TestE*` - no-match-path: `*TestF*` - -Did you mean `test1`? - -"#]]); -}); - -// tests that direct import paths are handled correctly -forgetest!(can_fuzz_array_params, |prj, cmd| { - prj.insert_ds_test(); - - prj.add_source( - "ATest.t.sol", - r#" -import "./test.sol"; -contract ATest is DSTest { - function testArray(uint64[2] calldata) external { - assertTrue(true); - } -} - "#, - ) - .unwrap(); - - cmd.arg("test").assert_success().stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 1 test for src/ATest.t.sol:ATest -[PASS] testArray(uint64[2]) (runs: 256, [AVG_GAS]) -Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) - -"#]]); -}); - -// tests that `bytecode_hash` will be sanitized -forgetest!(can_test_pre_bytecode_hash, |prj, cmd| { - prj.insert_ds_test(); - - prj.add_source( - "ATest.t.sol", - r#" -// pre bytecode hash version, was introduced in 0.6.0 -pragma solidity 0.5.17; -import "./test.sol"; -contract ATest is DSTest { - function testArray(uint64[2] calldata) external { - assertTrue(true); - } -} - "#, - ) - .unwrap(); - - cmd.arg("test").assert_success().stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 1 test for src/ATest.t.sol:ATest -[PASS] testArray(uint64[2]) (runs: 256, [AVG_GAS]) -Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) - -"#]]); -}); - -// tests that using the --match-path option only runs files matching the path -forgetest!(can_test_with_match_path, |prj, cmd| { - prj.insert_ds_test(); - - prj.add_source( - "ATest.t.sol", - r#" -import "./test.sol"; -contract ATest is DSTest { - function testPass() external { - assertTrue(true); - } -} - "#, - ) - .unwrap(); - - prj.add_source( - "FailTest.t.sol", - r#" -import "./test.sol"; -contract FailTest is DSTest { - function testNothing() external { - assertTrue(false); - } -} - "#, - ) - .unwrap(); - - cmd.args(["test", "--match-path", "*src/ATest.t.sol"]).assert_success().stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 1 test for src/ATest.t.sol:ATest -[PASS] testPass() ([GAS]) -Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) - -"#]]); -}); - -// tests that using the --match-path option works with absolute paths -forgetest!(can_test_with_match_path_absolute, |prj, cmd| { - prj.insert_ds_test(); - - prj.add_source( - "ATest.t.sol", - r#" -import "./test.sol"; -contract ATest is DSTest { - function testPass() external { - assertTrue(true); - } -} - "#, - ) - .unwrap(); - - prj.add_source( - "FailTest.t.sol", - r#" -import "./test.sol"; -contract FailTest is DSTest { - function testNothing() external { - assertTrue(false); - } -} - "#, - ) - .unwrap(); - - let test_path = prj.root().join("src/ATest.t.sol"); - let test_path = test_path.to_string_lossy(); - - cmd.args(["test", "--match-path", test_path.as_ref()]).assert_success().stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 1 test for src/ATest.t.sol:ATest -[PASS] testPass() ([GAS]) -Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) - -"#]]); -}); - -const SIMPLE_CONTRACT: &str = r#" -import "./test.sol"; -import "./console.sol"; - -contract SimpleContract { - uint256 public num; - - function setValues(uint256 _num) public { - num = _num; - } -} - -contract SimpleContractTest is DSTest { - function test() public { - SimpleContract c = new SimpleContract(); - c.setValues(100); - console.logUint(100); - } -} - "#; - -forgetest!(can_run_test_with_json_output_verbose, |prj, cmd| { - prj.insert_ds_test(); - prj.insert_console(); - - prj.add_source("Simple.t.sol", SIMPLE_CONTRACT).unwrap(); - - // Assert that with verbose output the json output includes the traces - cmd.args(["test", "-vvv", "--json"]) - .assert_success() - .stdout_eq(file!["../fixtures/SimpleContractTestVerbose.json": Json]); -}); - -forgetest!(can_run_test_with_json_output_non_verbose, |prj, cmd| { - prj.insert_ds_test(); - prj.insert_console(); - - prj.add_source("Simple.t.sol", SIMPLE_CONTRACT).unwrap(); - - // Assert that without verbose output the json output does not include the traces - cmd.args(["test", "--json"]) - .assert_success() - .stdout_eq(file!["../fixtures/SimpleContractTestNonVerbose.json": Json]); -}); - -// tests that `forge test` will pick up tests that are stored in the `test = ` config value -forgetest!(can_run_test_in_custom_test_folder, |prj, cmd| { - prj.insert_ds_test(); - - // explicitly set the test folder - prj.update_config(|config| config.test = "nested/forge-tests".into()); - - let config = cmd.config(); - assert_eq!(config.test, PathBuf::from("nested/forge-tests")); - - prj.add_source( - "nested/forge-tests/MyTest.t.sol", - r#" -import "../../test.sol"; -contract MyTest is DSTest { - function testTrue() public { - assertTrue(true); - } -} - "#, - ) - .unwrap(); - - cmd.arg("test").assert_success().stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 1 test for src/nested/forge-tests/MyTest.t.sol:MyTest -[PASS] testTrue() ([GAS]) -Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) - -"#]]); -}); - -// checks that forge test repeatedly produces the same output -#[cfg(not(feature = "isolate-by-default"))] -forgetest_init!(can_test_repeatedly, |prj, cmd| { - prj.clear(); - - cmd.arg("test").assert_success().stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 2 tests for test/Counter.t.sol:CounterTest -[PASS] testFuzz_SetNumber(uint256) (runs: 256, [AVG_GAS]) -[PASS] test_Increment() ([GAS]) -Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 2 tests passed, 0 failed, 0 skipped (2 total tests) - -"#]]); - - for _ in 0..5 { - cmd.assert_success().stdout_eq(str![[r#" -No files changed, compilation skipped - -Ran 2 tests for test/Counter.t.sol:CounterTest -[PASS] testFuzz_SetNumber(uint256) (runs: 256, [AVG_GAS]) -[PASS] test_Increment() ([GAS]) -Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 2 tests passed, 0 failed, 0 skipped (2 total tests) - -"#]]); - } -}); - -// tests that `forge test` will run a test only once after changing the version -forgetest!(runs_tests_exactly_once_with_changed_versions, |prj, cmd| { - prj.insert_ds_test(); - - prj.add_source( - "Contract.t.sol", - r#" -pragma solidity *; - -import "./test.sol"; - -contract ContractTest is DSTest { - function setUp() public {} - - function testExample() public { - assertTrue(true); - } -} - "#, - ) - .unwrap(); - - // pin version - prj.update_config(|config| { - config.solc = Some(SOLC_VERSION.into()); - }); - - cmd.arg("test").assert_success().stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 1 test for src/Contract.t.sol:ContractTest -[PASS] testExample() ([GAS]) -Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) - -"#]]); - - // pin version - prj.update_config(|config| { - config.solc = Some(OTHER_SOLC_VERSION.into()); - }); - - cmd.forge_fuse().arg("test").assert_success().stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 1 test for src/Contract.t.sol:ContractTest -[PASS] testExample() ([GAS]) -Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) - -"#]]); -}); - -// tests that libraries are handled correctly in multiforking mode -#[cfg(not(feature = "isolate-by-default"))] -forgetest_init!(can_use_libs_in_multi_fork, |prj, cmd| { - prj.wipe_contracts(); - - prj.add_source( - "Contract.sol", - r" -library Library { - function f(uint256 a, uint256 b) public pure returns (uint256) { - return a + b; - } -} - -contract Contract { - uint256 c; - - constructor() { - c = Library.f(1, 2); - } -} - ", - ) - .unwrap(); - - let endpoint = rpc::next_http_archive_rpc_url(); - - prj.add_test( - "Contract.t.sol", - &r#" -import "forge-std/Test.sol"; -import "src/Contract.sol"; - -contract ContractTest is Test { - function setUp() public { - vm.createSelectFork(""); - } - - function test() public { - new Contract(); - } -} - "# - .replace("", &endpoint), - ) - .unwrap(); - - cmd.arg("test").assert_success().stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 1 test for test/Contract.t.sol:ContractTest -[PASS] test() ([GAS]) -Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) - -"#]]); -}); - -static FAILING_TEST: &str = r#" -import "forge-std/Test.sol"; - -contract FailingTest is Test { - function testShouldFail() public { - assertTrue(false); - } -} -"#; - -forgetest_init!(exit_code_error_on_fail_fast, |prj, cmd| { - prj.wipe_contracts(); - prj.add_source("failing_test", FAILING_TEST).unwrap(); - - // set up command - cmd.args(["test", "--fail-fast"]); - - // run command and assert error exit code - cmd.assert_empty_stderr(); -}); - -forgetest_init!(exit_code_error_on_fail_fast_with_json, |prj, cmd| { - prj.wipe_contracts(); - - prj.add_source("failing_test", FAILING_TEST).unwrap(); - // set up command - cmd.args(["test", "--fail-fast", "--json"]); - - // run command and assert error exit code - cmd.assert_empty_stderr(); -}); - -// https://github.com/foundry-rs/foundry/pull/6531 -forgetest_init!(fork_traces, |prj, cmd| { - prj.wipe_contracts(); - - let endpoint = rpc::next_http_archive_rpc_url(); - - prj.add_test( - "Contract.t.sol", - &r#" -import {Test} from "forge-std/Test.sol"; - -interface IERC20 { - function name() external view returns (string memory); -} - -contract USDTCallingTest is Test { - function test() public { - vm.createSelectFork(""); - IERC20(0xdAC17F958D2ee523a2206206994597C13D831ec7).name(); - } -} - "# - .replace("", &endpoint), - ) - .unwrap(); - - cmd.args(["test", "-vvvv"]).assert_success().stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 1 test for test/Contract.t.sol:USDTCallingTest -[PASS] test() ([GAS]) -Traces: - [..] USDTCallingTest::test() - ├─ [0] VM::createSelectFork("[..]") - │ └─ ← [Return] 0 - ├─ [3110] 0xdAC17F958D2ee523a2206206994597C13D831ec7::name() [staticcall] - │ └─ ← [Return] "Tether USD" - └─ ← [Stop] - -Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) - -"#]]); -}); - -// https://github.com/foundry-rs/foundry/issues/6579 -forgetest_init!(include_custom_types_in_traces, |prj, cmd| { - prj.wipe_contracts(); - - prj.add_test( - "Contract.t.sol", - r#" -import {Test} from "forge-std/Test.sol"; - -error PoolNotInitialized(); -event MyEvent(uint256 a); - -contract CustomTypesTest is Test { - function testErr() public pure { - revert PoolNotInitialized(); - } - function testEvent() public { - emit MyEvent(100); - } -} - "#, - ) - .unwrap(); - - cmd.args(["test", "-vvvv"]).assert_failure().stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 2 tests for test/Contract.t.sol:CustomTypesTest -[FAIL: PoolNotInitialized()] testErr() ([GAS]) -Traces: - [247] CustomTypesTest::testErr() - └─ ← [Revert] PoolNotInitialized() - -[PASS] testEvent() ([GAS]) -Traces: - [1524] CustomTypesTest::testEvent() - ├─ emit MyEvent(a: 100) - └─ ← [Stop] - -Suite result: FAILED. 1 passed; 1 failed; 0 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 1 tests passed, 1 failed, 0 skipped (2 total tests) - -Failing tests: -Encountered 1 failing test in test/Contract.t.sol:CustomTypesTest -[FAIL: PoolNotInitialized()] testErr() ([GAS]) - -Encountered a total of 1 failing tests, 1 tests succeeded - -"#]]); -}); - -forgetest_init!(can_test_transient_storage_with_isolation, |prj, cmd| { - prj.wipe_contracts(); - - prj.add_test( - "Contract.t.sol", - r#" -import {Test} from "forge-std/Test.sol"; - -contract TransientTester { - function locked() public view returns (bool isLocked) { - assembly { - isLocked := tload(0) - } - } - - modifier lock() { - require(!locked(), "locked"); - assembly { - tstore(0, 1) - } - _; - } - - function maybeReentrant(address target, bytes memory data) public lock { - (bool success, bytes memory ret) = target.call(data); - if (!success) { - // forwards revert reason - assembly { - let ret_size := mload(ret) - revert(add(32, ret), ret_size) - } - } - } -} - -contract TransientTest is Test { - function test() public { - TransientTester t = new TransientTester(); - vm.expectRevert(bytes("locked")); - t.maybeReentrant(address(t), abi.encodeCall(TransientTester.maybeReentrant, (address(0), new bytes(0)))); - - t.maybeReentrant(address(0), new bytes(0)); - assertEq(t.locked(), false); - } -} - "#, - ) - .unwrap(); - - cmd.args(["test", "-vvvv", "--isolate", "--evm-version", "cancun"]).assert_success(); -}); - -forgetest_init!( - #[ignore = "Too slow"] - can_disable_block_gas_limit, - |prj, cmd| { - prj.wipe_contracts(); - - let endpoint = rpc::next_http_archive_rpc_url(); - - prj.add_test( - "Contract.t.sol", - &r#" -import {Test} from "forge-std/Test.sol"; - -contract C is Test {} - -contract GasWaster { - function waste() public { - for (uint256 i = 0; i < 100; i++) { - new C(); - } - } -} - -contract GasLimitTest is Test { - function test() public { - vm.createSelectFork(""); - - GasWaster waster = new GasWaster(); - waster.waste(); - } -} - "# - .replace("", &endpoint), - ) - .unwrap(); - - cmd.args(["test", "-vvvv", "--isolate", "--disable-block-gas-limit"]).assert_success(); - } -); - -forgetest!(test_match_path, |prj, cmd| { - prj.add_source( - "dummy", - r" -contract Dummy { - function testDummy() public {} -} -", - ) - .unwrap(); - - cmd.args(["test", "--match-path", "src/dummy.sol"]); - cmd.assert_success(); -}); - -forgetest_init!(should_not_shrink_fuzz_failure, |prj, cmd| { - prj.wipe_contracts(); - - // deterministic test so we always have 54 runs until test fails with overflow - prj.update_config(|config| { - config.fuzz.runs = 256; - config.fuzz.seed = Some(U256::from(100)); - }); - - prj.add_test( - "CounterFuzz.t.sol", - r#" -import {Test} from "forge-std/Test.sol"; - -contract Counter { - uint256 public number = 0; - - function addOne(uint256 x) external pure returns (uint256) { - return x + 100_000_000; - } -} - -contract CounterTest is Test { - Counter public counter; - - function setUp() public { - counter = new Counter(); - } - - function testAddOne(uint256 x) public view { - assertEq(counter.addOne(x), x + 100_000_000); - } -} - "#, - ) - .unwrap(); - - // make sure there are only 61 runs (with proptest shrinking same test results in 298 runs) - cmd.args(["test"]).assert_failure().stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 1 test for test/CounterFuzz.t.sol:CounterTest -[FAIL: panic: arithmetic underflow or overflow (0x11); counterexample: calldata=0xa76d58f5fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffd args=[115792089237316195423570985008687907853269984665640564039457584007913129639933 [1.157e77]]] testAddOne(uint256) (runs: 84, [AVG_GAS]) -Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) - -Failing tests: -Encountered 1 failing test in test/CounterFuzz.t.sol:CounterTest -[FAIL: panic: arithmetic underflow or overflow (0x11); counterexample: calldata=0xa76d58f5fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffd args=[115792089237316195423570985008687907853269984665640564039457584007913129639933 [1.157e77]]] testAddOne(uint256) (runs: 84, [AVG_GAS]) - -Encountered a total of 1 failing tests, 0 tests succeeded - -"#]]); -}); - -forgetest_init!(should_exit_early_on_invariant_failure, |prj, cmd| { - prj.wipe_contracts(); - prj.add_test( - "CounterInvariant.t.sol", - r#" -import {Test} from "forge-std/Test.sol"; - -contract Counter { - uint256 public number = 0; - - function inc() external { - number += 1; - } -} - -contract CounterTest is Test { - Counter public counter; - - function setUp() public { - counter = new Counter(); - } - - function invariant_early_exit() public view { - assertTrue(counter.number() == 10, "wrong count"); - } -} - "#, - ) - .unwrap(); - - // make sure invariant test exit early with 0 runs - cmd.args(["test"]).assert_failure().stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 1 test for test/CounterInvariant.t.sol:CounterTest -[FAIL: failed to set up invariant testing environment: wrong count] invariant_early_exit() (runs: 0, calls: 0, reverts: 0) -Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) - -Failing tests: -Encountered 1 failing test in test/CounterInvariant.t.sol:CounterTest -[FAIL: failed to set up invariant testing environment: wrong count] invariant_early_exit() (runs: 0, calls: 0, reverts: 0) - -Encountered a total of 1 failing tests, 0 tests succeeded - -"#]]); -}); - -forgetest_init!(should_replay_failures_only, |prj, cmd| { - prj.wipe_contracts(); - prj.add_test( - "ReplayFailures.t.sol", - r#" -import {Test} from "forge-std/Test.sol"; - -contract ReplayFailuresTest is Test { - function testA() public pure { - require(2 > 1); - } - - function testB() public pure { - require(1 > 2, "testB failed"); - } - - function testC() public pure { - require(2 > 1); - } - - function testD() public pure { - require(1 > 2, "testD failed"); - } -} - "#, - ) - .unwrap(); - - cmd.args(["test"]).assert_failure().stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 4 tests for test/ReplayFailures.t.sol:ReplayFailuresTest -[PASS] testA() ([GAS]) -[FAIL: testB failed] testB() ([GAS]) -[PASS] testC() ([GAS]) -[FAIL: testD failed] testD() ([GAS]) -Suite result: FAILED. 2 passed; 2 failed; 0 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 2 tests passed, 2 failed, 0 skipped (4 total tests) - -Failing tests: -Encountered 2 failing tests in test/ReplayFailures.t.sol:ReplayFailuresTest -[FAIL: testB failed] testB() ([GAS]) -[FAIL: testD failed] testD() ([GAS]) - -Encountered a total of 2 failing tests, 2 tests succeeded - -"#]]); - - // Test failure filter should be persisted. - assert!(prj.root().join("cache/test-failures").exists()); - - // Perform only the 2 failing tests from last run. - cmd.forge_fuse().args(["test", "--rerun"]).assert_failure().stdout_eq(str![[r#" -No files changed, compilation skipped - -Ran 2 tests for test/ReplayFailures.t.sol:ReplayFailuresTest -[FAIL: testB failed] testB() ([GAS]) -[FAIL: testD failed] testD() ([GAS]) -Suite result: FAILED. 0 passed; 2 failed; 0 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 0 tests passed, 2 failed, 0 skipped (2 total tests) - -Failing tests: -Encountered 2 failing tests in test/ReplayFailures.t.sol:ReplayFailuresTest -[FAIL: testB failed] testB() ([GAS]) -[FAIL: testD failed] testD() ([GAS]) - -Encountered a total of 2 failing tests, 0 tests succeeded - -"#]]); -}); - -// -forgetest_init!(should_not_record_setup_failures, |prj, cmd| { - prj.add_test( - "ReplayFailures.t.sol", - r#" -import {Test} from "forge-std/Test.sol"; - -contract SetupFailureTest is Test { - function setUp() public { - require(2 > 1); - } - - function testA() public pure { - } -} - "#, - ) - .unwrap(); - - cmd.args(["test"]).assert_success(); - // Test failure filter should not be persisted if `setUp` failed. - assert!(!prj.root().join("cache/test-failures").exists()); -}); - -// https://github.com/foundry-rs/foundry/issues/7530 -forgetest_init!(should_show_precompile_labels, |prj, cmd| { - prj.wipe_contracts(); - - prj.add_test( - "Contract.t.sol", - r#" -import {Test} from "forge-std/Test.sol"; - -contract PrecompileLabelsTest is Test { - function testPrecompileLabels() public { - vm.deal(address(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D), 1 ether); - vm.deal(address(0x000000000000000000636F6e736F6c652e6c6f67), 1 ether); - vm.deal(address(0x4e59b44847b379578588920cA78FbF26c0B4956C), 1 ether); - vm.deal(address(0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38), 1 ether); - vm.deal(address(0xb4c79daB8f259C7Aee6E5b2Aa729821864227e84), 1 ether); - vm.deal(address(1), 1 ether); - vm.deal(address(2), 1 ether); - vm.deal(address(3), 1 ether); - vm.deal(address(4), 1 ether); - vm.deal(address(5), 1 ether); - vm.deal(address(6), 1 ether); - vm.deal(address(7), 1 ether); - vm.deal(address(8), 1 ether); - vm.deal(address(9), 1 ether); - vm.deal(address(10), 1 ether); - } -} - "#, - ) - .unwrap(); - - cmd.args(["test", "-vvvv"]).assert_success().stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 1 test for test/Contract.t.sol:PrecompileLabelsTest -[PASS] testPrecompileLabels() ([GAS]) -Traces: - [..] PrecompileLabelsTest::testPrecompileLabels() - ├─ [0] VM::deal(VM: [0x7109709ECfa91a80626fF3989D68f67F5b1DD12D], 1000000000000000000 [1e18]) - │ └─ ← [Return] - ├─ [0] VM::deal(console: [0x000000000000000000636F6e736F6c652e6c6f67], 1000000000000000000 [1e18]) - │ └─ ← [Return] - ├─ [0] VM::deal(Create2Deployer: [0x4e59b44847b379578588920cA78FbF26c0B4956C], 1000000000000000000 [1e18]) - │ └─ ← [Return] - ├─ [0] VM::deal(DefaultSender: [0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38], 1000000000000000000 [1e18]) - │ └─ ← [Return] - ├─ [0] VM::deal(DefaultTestContract: [0xb4c79daB8f259C7Aee6E5b2Aa729821864227e84], 1000000000000000000 [1e18]) - │ └─ ← [Return] - ├─ [0] VM::deal(ECRecover: [0x0000000000000000000000000000000000000001], 1000000000000000000 [1e18]) - │ └─ ← [Return] - ├─ [0] VM::deal(SHA-256: [0x0000000000000000000000000000000000000002], 1000000000000000000 [1e18]) - │ └─ ← [Return] - ├─ [0] VM::deal(RIPEMD-160: [0x0000000000000000000000000000000000000003], 1000000000000000000 [1e18]) - │ └─ ← [Return] - ├─ [0] VM::deal(Identity: [0x0000000000000000000000000000000000000004], 1000000000000000000 [1e18]) - │ └─ ← [Return] - ├─ [0] VM::deal(ModExp: [0x0000000000000000000000000000000000000005], 1000000000000000000 [1e18]) - │ └─ ← [Return] - ├─ [0] VM::deal(ECAdd: [0x0000000000000000000000000000000000000006], 1000000000000000000 [1e18]) - │ └─ ← [Return] - ├─ [0] VM::deal(ECMul: [0x0000000000000000000000000000000000000007], 1000000000000000000 [1e18]) - │ └─ ← [Return] - ├─ [0] VM::deal(ECPairing: [0x0000000000000000000000000000000000000008], 1000000000000000000 [1e18]) - │ └─ ← [Return] - ├─ [0] VM::deal(Blake2F: [0x0000000000000000000000000000000000000009], 1000000000000000000 [1e18]) - │ └─ ← [Return] - ├─ [0] VM::deal(PointEvaluation: [0x000000000000000000000000000000000000000A], 1000000000000000000 [1e18]) - │ └─ ← [Return] - └─ ← [Stop] - -Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) - -"#]]); -}); - -// tests that `forge test` with config `show_logs: true` for fuzz tests will -// display `console.log` info -forgetest_init!(should_show_logs_when_fuzz_test, |prj, cmd| { - prj.wipe_contracts(); - - // run fuzz test 3 times - prj.update_config(|config| { - config.fuzz.runs = 3; - config.fuzz.show_logs = true; - }); - let config = cmd.config(); - assert_eq!(config.fuzz.runs, 3); - - prj.add_test( - "ContractFuzz.t.sol", - r#" -import {Test, console} from "forge-std/Test.sol"; - -contract ContractFuzz is Test { - function testFuzzConsoleLog(uint256 x) public pure { - console.log("inside fuzz test, x is:", x); - } -} - "#, - ) - .unwrap(); - cmd.args(["test", "-vv"]).assert_success().stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 1 test for test/ContractFuzz.t.sol:ContractFuzz -[PASS] testFuzzConsoleLog(uint256) (runs: 3, [AVG_GAS]) -Logs: - inside fuzz test, x is: [..] - inside fuzz test, x is: [..] - inside fuzz test, x is: [..] - -Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) - -"#]]); -}); - -// tests that `forge test` with inline config `show_logs = true` for fuzz tests will -// display `console.log` info -forgetest_init!(should_show_logs_when_fuzz_test_inline_config, |prj, cmd| { - prj.wipe_contracts(); - - // run fuzz test 3 times - prj.update_config(|config| { - config.fuzz.runs = 3; - }); - let config = cmd.config(); - assert_eq!(config.fuzz.runs, 3); - - prj.add_test( - "ContractFuzz.t.sol", - r#" -import {Test, console} from "forge-std/Test.sol"; - -contract ContractFuzz is Test { - /// forge-config: default.fuzz.show-logs = true - function testFuzzConsoleLog(uint256 x) public pure { - console.log("inside fuzz test, x is:", x); - } -} - "#, - ) - .unwrap(); - cmd.args(["test", "-vv"]).assert_success().stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 1 test for test/ContractFuzz.t.sol:ContractFuzz -[PASS] testFuzzConsoleLog(uint256) (runs: 3, [AVG_GAS]) -Logs: - inside fuzz test, x is: [..] - inside fuzz test, x is: [..] - inside fuzz test, x is: [..] - -Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) - -"#]]); -}); - -// tests that `forge test` with config `show_logs: false` for fuzz tests will not display -// `console.log` info -forgetest_init!(should_not_show_logs_when_fuzz_test, |prj, cmd| { - prj.wipe_contracts(); - - // run fuzz test 3 times - prj.update_config(|config| { - config.fuzz.runs = 3; - config.fuzz.show_logs = false; - }); - let config = cmd.config(); - assert_eq!(config.fuzz.runs, 3); - - prj.add_test( - "ContractFuzz.t.sol", - r#" - import {Test, console} from "forge-std/Test.sol"; - contract ContractFuzz is Test { - - function testFuzzConsoleLog(uint256 x) public pure { - console.log("inside fuzz test, x is:", x); - } - } - "#, - ) - .unwrap(); - cmd.args(["test", "-vv"]).assert_success().stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 1 test for test/ContractFuzz.t.sol:ContractFuzz -[PASS] testFuzzConsoleLog(uint256) (runs: 3, [AVG_GAS]) -Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) - -"#]]); -}); - -// tests that `forge test` with inline config `show_logs = false` for fuzz tests will not -// display `console.log` info -forgetest_init!(should_not_show_logs_when_fuzz_test_inline_config, |prj, cmd| { - prj.wipe_contracts(); - - // run fuzz test 3 times - prj.update_config(|config| { - config.fuzz.runs = 3; - }); - let config = cmd.config(); - assert_eq!(config.fuzz.runs, 3); - - prj.add_test( - "ContractFuzz.t.sol", - r#" -import {Test, console} from "forge-std/Test.sol"; - -contract ContractFuzz is Test { - /// forge-config: default.fuzz.show-logs = false - function testFuzzConsoleLog(uint256 x) public pure { - console.log("inside fuzz test, x is:", x); - } -} - "#, - ) - .unwrap(); - cmd.args(["test", "-vv"]).assert_success().stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 1 test for test/ContractFuzz.t.sol:ContractFuzz -[PASS] testFuzzConsoleLog(uint256) (runs: 3, [AVG_GAS]) -Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) - -"#]]); -}); - -// tests internal functions trace -#[cfg(not(feature = "isolate-by-default"))] -forgetest_init!(internal_functions_trace, |prj, cmd| { - prj.wipe_contracts(); - prj.clear(); - - prj.add_test( - "Simple", - r#" -import {Test, console} from "forge-std/Test.sol"; - -contract SimpleContract { - uint256 public num; - address public addr; - - function _setNum(uint256 _num) internal returns(uint256 prev) { - prev = num; - num = _num; - } - - function _setAddr(address _addr) internal returns(address prev) { - prev = addr; - addr = _addr; - } - - function increment() public { - _setNum(num + 1); - } - - function setValues(uint256 _num, address _addr) public { - _setNum(_num); - _setAddr(_addr); - } -} - -contract SimpleContractTest is Test { - function test() public { - SimpleContract c = new SimpleContract(); - c.increment(); - c.setValues(100, address(0x123)); - } -} - "#, - ) - .unwrap(); - cmd.args(["test", "-vvvv", "--decode-internal"]).assert_success().stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 1 test for test/Simple.sol:SimpleContractTest -[PASS] test() ([GAS]) -Traces: - [..] SimpleContractTest::test() - ├─ [165406] → new SimpleContract@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f - │ └─ ← [Return] 826 bytes of code - ├─ [22630] SimpleContract::increment() - │ ├─ [20147] SimpleContract::_setNum(1) - │ │ └─ ← 0 - │ └─ ← [Stop] - ├─ [23204] SimpleContract::setValues(100, 0x0000000000000000000000000000000000000123) - │ ├─ [247] SimpleContract::_setNum(100) - │ │ └─ ← 1 - │ ├─ [22336] SimpleContract::_setAddr(0x0000000000000000000000000000000000000123) - │ │ └─ ← 0x0000000000000000000000000000000000000000 - │ └─ ← [Stop] - └─ ← [Stop] - -Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) - -"#]]); -}); - -// tests internal functions trace with memory decoding -#[cfg(not(feature = "isolate-by-default"))] -forgetest_init!(internal_functions_trace_memory, |prj, cmd| { - prj.wipe_contracts(); - prj.clear(); - - prj.add_test( - "Simple", - r#" -import {Test, console} from "forge-std/Test.sol"; - -contract SimpleContract { - string public str = "initial value"; - - function _setStr(string memory _str) internal returns(string memory prev) { - prev = str; - str = _str; - } - - function setStr(string memory _str) public { - _setStr(_str); - } -} - -contract SimpleContractTest is Test { - function test() public { - SimpleContract c = new SimpleContract(); - c.setStr("new value"); - } -} - "#, - ) - .unwrap(); - cmd.args(["test", "-vvvv", "--decode-internal"]).assert_success().stdout_eq(str![[r#" -... -Traces: - [..] SimpleContractTest::test() - ├─ [370554] → new SimpleContract@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f - │ └─ ← [Return] 1737 bytes of code - ├─ [2511] SimpleContract::setStr("new value") - │ ├─ [1588] SimpleContract::_setStr("new value") - │ │ └─ ← "initial value" - │ └─ ← [Stop] - └─ ← [Stop] -... -"#]]); -}); - -// tests that `forge test` with a seed produces deterministic random values for uint and addresses. -forgetest_init!(deterministic_randomness_with_seed, |prj, cmd| { - prj.wipe_contracts(); - prj.add_test( - "DeterministicRandomnessTest.t.sol", - r#" -import {Test, console} from "forge-std/Test.sol"; - -contract DeterministicRandomnessTest is Test { - - function testDeterministicRandomUint() public { - console.log(vm.randomUint()); - console.log(vm.randomUint()); - console.log(vm.randomUint()); - } - - function testDeterministicRandomUintRange() public { - uint256 min = 0; - uint256 max = 1000000000; - console.log(vm.randomUint(min, max)); - console.log(vm.randomUint(min, max)); - console.log(vm.randomUint(min, max)); - } - - function testDeterministicRandomAddress() public { - console.log(vm.randomAddress()); - console.log(vm.randomAddress()); - console.log(vm.randomAddress()); - } -} -"#, - ) - .unwrap(); - - // Extracts the test result section from the DeterministicRandomnessTest contract output. - fn extract_test_result(out: &str) -> &str { - let start = out - .find("for test/DeterministicRandomnessTest.t.sol:DeterministicRandomnessTest") - .unwrap(); - let end = out.find("Suite result: ok.").unwrap(); - &out[start..end] - } - - // Run the test twice with the same seed and verify the outputs are the same. - let seed1 = "0xa1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"; - let out1 = cmd - .args(["test", "--fuzz-seed", seed1, "-vv"]) - .assert_success() - .get_output() - .stdout_lossy(); - let res1 = extract_test_result(&out1); - - let out2 = cmd - .forge_fuse() - .args(["test", "--fuzz-seed", seed1, "-vv"]) - .assert_success() - .get_output() - .stdout_lossy(); - let res2 = extract_test_result(&out2); - - assert_eq!(res1, res2); - - // Run the test with another seed and verify the output differs. - let seed2 = "0xb1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"; - let out3 = cmd - .forge_fuse() - .args(["test", "--fuzz-seed", seed2, "-vv"]) - .assert_success() - .get_output() - .stdout_lossy(); - let res3 = extract_test_result(&out3); - assert_ne!(res3, res1); - - // Run the test without a seed and verify the outputs differs once again. - cmd.forge_fuse(); - let out4 = cmd.args(["test", "-vv"]).assert_success().get_output().stdout_lossy(); - let res4 = extract_test_result(&out4); - assert_ne!(res4, res1); - assert_ne!(res4, res3); -}); - -// Tests that `pauseGasMetering` used at the end of test does not produce meaningless values. -// https://github.com/foundry-rs/foundry/issues/5491 -forgetest_init!(gas_metering_pause_last_call, |prj, cmd| { - prj.wipe_contracts(); - - prj.add_test( - "ATest.t.sol", - r#" -import {Test} from "forge-std/Test.sol"; - -contract ATest is Test { - function testWeirdGas1() public { - vm.pauseGasMetering(); - } - - function testWeirdGas2() public { - uint256 a = 1; - uint256 b = a + 1; - require(b == 2, "b is not 2"); - vm.pauseGasMetering(); - } - - function testNormalGas() public { - vm.pauseGasMetering(); - vm.resumeGasMetering(); - } - - function testWithAssembly() public { - vm.pauseGasMetering(); - assembly { - return(0, 0) - } - } -} - "#, - ) - .unwrap(); - - cmd.args(["test"]).with_no_redact().assert_success().stdout_eq(str![[r#" -... -[PASS] testNormalGas() (gas: 3148) -[PASS] testWeirdGas1() (gas: 2986) -[PASS] testWeirdGas2() (gas: 3213) -[PASS] testWithAssembly() (gas: 3029) -... -"#]]); -}); - -// https://github.com/foundry-rs/foundry/issues/5564 -forgetest_init!(gas_metering_expect_revert, |prj, cmd| { - prj.wipe_contracts(); - - prj.add_test( - "ATest.t.sol", - r#" -import {Test} from "forge-std/Test.sol"; -contract ATest is Test { - error MyError(); - function testSelfMeteringRevert() public { - vm.pauseGasMetering(); - vm.expectRevert(MyError.selector); - this.selfReverts(); - } - function selfReverts() external { - vm.resumeGasMetering(); - revert MyError(); - } -} - "#, - ) - .unwrap(); - - cmd.args(["test"]).assert_success().stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 1 test for test/ATest.t.sol:ATest -[PASS] testSelfMeteringRevert() ([GAS]) -Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) - -"#]]); -}); - -// https://github.com/foundry-rs/foundry/issues/4523 -forgetest_init!(gas_metering_gasleft, |prj, cmd| { - prj.wipe_contracts(); - - prj.add_test( - "ATest.t.sol", - r#" -import "forge-std/Test.sol"; - -contract ATest is Test { - mapping(uint256 => bytes32) map; - - function test_GasMeter() public { - vm.pauseGasMetering(); - consumeGas(); - vm.resumeGasMetering(); - - consumeGas(); - } - - function test_GasLeft() public { - consumeGas(); - - uint256 start = gasleft(); - consumeGas(); - console.log("Gas cost:", start - gasleft()); - } - - function consumeGas() private { - for (uint256 i = 0; i < 100; i++) { - map[i] = keccak256(abi.encode(i)); - } - } -} - "#, - ) - .unwrap(); - - // Log and test gas cost should be similar. - cmd.args(["test", "-vvvv"]).with_no_redact().assert_success().stdout_eq(str![[r#" -... -Logs: - Gas cost: 50068 - -Traces: - [2303684] ATest::test_GasLeft() - ├─ [0] console::log("Gas cost:", 50068 [5.006e4]) [staticcall] - │ └─ ← [Stop] - └─ ← [Stop] - -[PASS] test_GasMeter() (gas: 53097) -Traces: - [53097] ATest::test_GasMeter() - ├─ [0] VM::pauseGasMetering() - │ └─ ← [Return] - ├─ [0] VM::resumeGasMetering() - │ └─ ← [Return] - └─ ← [Stop] -... -"#]]); -}); - -// https://github.com/foundry-rs/foundry/issues/4370 -forgetest_init!(pause_gas_metering_with_delete, |prj, cmd| { - prj.wipe_contracts(); - - prj.add_test( - "ATest.t.sol", - r#" -import {Test} from "forge-std/Test.sol"; -contract ATest is Test { - uint a; - function test_negativeGas () public { - vm.pauseGasMetering(); - a = 100; - vm.resumeGasMetering(); - delete a; - } -} - "#, - ) - .unwrap(); - - cmd.args(["test"]).with_no_redact().assert_success().stdout_eq(str![[r#" -... -[PASS] test_negativeGas() (gas: 96) -... -"#]]); -}); - -// tests `pauseTracing` and `resumeTracing` functions -#[cfg(not(feature = "isolate-by-default"))] -forgetest_init!(pause_tracing, |prj, cmd| { - prj.wipe_contracts(); - prj.insert_ds_test(); - prj.insert_vm(); - prj.clear(); - - prj.add_source( - "Pause.t.sol", - r#" -import {Vm} from "./Vm.sol"; -import {DSTest} from "./test.sol"; -contract TraceGenerator is DSTest { - Vm vm = Vm(HEVM_ADDRESS); - event DummyEvent(uint256 i); - function call(uint256 i) public { - emit DummyEvent(i); - } - function generate() public { - for (uint256 i = 0; i < 10; i++) { - if (i == 3) { - vm.pauseTracing(); - } - this.call(i); - if (i == 7) { - vm.resumeTracing(); - } - } - } -} -contract PauseTracingTest is DSTest { - Vm vm = Vm(HEVM_ADDRESS); - event DummyEvent(uint256 i); - function setUp() public { - emit DummyEvent(1); - vm.pauseTracing(); - emit DummyEvent(2); - } - function test() public { - emit DummyEvent(3); - TraceGenerator t = new TraceGenerator(); - vm.resumeTracing(); - t.generate(); - } -} - "#, - ) - .unwrap(); - cmd.args(["test", "-vvvvv"]).assert_success().stdout_eq(str![[r#" -... -Traces: - [7757] PauseTracingTest::setUp() - ├─ emit DummyEvent(i: 1) - ├─ [0] VM::pauseTracing() [staticcall] - │ └─ ← [Return] - └─ ← [Stop] - - [449649] PauseTracingTest::test() - ├─ [0] VM::resumeTracing() [staticcall] - │ └─ ← [Return] - ├─ [22896] TraceGenerator::generate() - │ ├─ [1589] TraceGenerator::call(0) - │ │ ├─ emit DummyEvent(i: 0) - │ │ └─ ← [Stop] - │ ├─ [1589] TraceGenerator::call(1) - │ │ ├─ emit DummyEvent(i: 1) - │ │ └─ ← [Stop] - │ ├─ [1589] TraceGenerator::call(2) - │ │ ├─ emit DummyEvent(i: 2) - │ │ └─ ← [Stop] - │ ├─ [0] VM::pauseTracing() [staticcall] - │ │ └─ ← [Return] - │ ├─ [0] VM::resumeTracing() [staticcall] - │ │ └─ ← [Return] - │ ├─ [1589] TraceGenerator::call(8) - │ │ ├─ emit DummyEvent(i: 8) - │ │ └─ ← [Stop] - │ ├─ [1589] TraceGenerator::call(9) - │ │ ├─ emit DummyEvent(i: 9) - │ │ └─ ← [Stop] - │ └─ ← [Stop] - └─ ← [Stop] -... -"#]]); -}); - -forgetest_init!(gas_metering_reset, |prj, cmd| { - prj.wipe_contracts(); - prj.insert_ds_test(); - prj.insert_vm(); - prj.clear(); - - prj.add_source( - "ATest.t.sol", - r#" -import {Vm} from "./Vm.sol"; -import {DSTest} from "./test.sol"; -contract B { - function a() public returns (uint256) { - return 100; - } -} -contract ATest is DSTest { - Vm vm = Vm(HEVM_ADDRESS); - B b; - uint256 a; - - function testResetGas() public { - vm.resetGasMetering(); - } - - function testResetGas1() public { - vm.resetGasMetering(); - b = new B(); - vm.resetGasMetering(); - } - - function testResetGas2() public { - b = new B(); - b = new B(); - vm.resetGasMetering(); - } - - function testResetGas3() public { - vm.resetGasMetering(); - b = new B(); - b = new B(); - } - - function testResetGas4() public { - vm.resetGasMetering(); - b = new B(); - vm.resetGasMetering(); - b = new B(); - } - - function testResetGas5() public { - vm.resetGasMetering(); - b = new B(); - vm.resetGasMetering(); - b = new B(); - vm.resetGasMetering(); - } - - function testResetGas6() public { - vm.resetGasMetering(); - b = new B(); - b = new B(); - _reset(); - vm.resetGasMetering(); - } - - function testResetGas7() public { - vm.resetGasMetering(); - b = new B(); - b = new B(); - _reset(); - } - - function testResetGas8() public { - this.resetExternal(); - } - - function testResetGas9() public { - this.resetExternal(); - vm.resetGasMetering(); - } - - function testResetNegativeGas() public { - a = 100; - vm.resetGasMetering(); - - delete a; - } - - function _reset() internal { - vm.resetGasMetering(); - } - - function resetExternal() external { - b = new B(); - b = new B(); - vm.resetGasMetering(); - } -} - "#, - ) - .unwrap(); - - cmd.args(["test"]).with_no_redact().assert_success().stdout_eq(str![[r#" -... -[PASS] testResetGas() (gas: 96) -[PASS] testResetGas1() (gas: 96) -[PASS] testResetGas2() (gas: 96) -[PASS] testResetGas3() (gas: [..]) -[PASS] testResetGas4() (gas: [..]) -[PASS] testResetGas5() (gas: 96) -[PASS] testResetGas6() (gas: 96) -[PASS] testResetGas7() (gas: 96) -[PASS] testResetGas8() (gas: [..]) -[PASS] testResetGas9() (gas: 96) -[PASS] testResetNegativeGas() (gas: 96) -... -"#]]); -}); - -// https://github.com/foundry-rs/foundry/issues/8705 -forgetest_init!(test_expect_revert_decode, |prj, cmd| { - prj.wipe_contracts(); - - prj.add_test( - "Counter.t.sol", - r#" -import {Test} from "forge-std/Test.sol"; -contract Counter { - uint256 public number; - error NumberNotEven(uint256 number); - error RandomError(); - function setNumber(uint256 newNumber) public { - if (newNumber % 2 != 0) { - revert NumberNotEven(newNumber); - } - number = newNumber; - } -} -contract CounterTest is Test { - Counter public counter; - function setUp() public { - counter = new Counter(); - counter.setNumber(0); - } - function test_decode() public { - vm.expectRevert(Counter.RandomError.selector); - counter.setNumber(1); - } - function test_decode_with_args() public { - vm.expectRevert(abi.encodePacked(Counter.NumberNotEven.selector, uint(2))); - counter.setNumber(1); - } -} - "#, - ) - .unwrap(); - - cmd.args(["test"]).assert_failure().stdout_eq(str![[r#" -... -[FAIL: Error != expected error: NumberNotEven(1) != RandomError()] test_decode() ([GAS]) -[FAIL: Error != expected error: NumberNotEven(1) != NumberNotEven(2)] test_decode_with_args() ([GAS]) -... -"#]]); -}); - -// Tests that `expectPartialRevert` cheatcode partially matches revert data. -forgetest_init!(test_expect_partial_revert, |prj, cmd| { - prj.wipe_contracts(); - prj.insert_ds_test(); - prj.insert_vm(); - prj.clear(); - - prj.add_source( - "Counter.t.sol", - r#" -import {Vm} from "./Vm.sol"; -import {DSTest} from "./test.sol"; -contract Counter { - error WrongNumber(uint256 number); - function count() public pure { - revert WrongNumber(0); - } -} -contract CounterTest is DSTest { - Vm vm = Vm(HEVM_ADDRESS); - function testExpectPartialRevertWithSelector() public { - Counter counter = new Counter(); - vm.expectPartialRevert(Counter.WrongNumber.selector); - counter.count(); - } - function testExpectPartialRevertWith4Bytes() public { - Counter counter = new Counter(); - vm.expectPartialRevert(bytes4(0x238ace70)); - counter.count(); - } - function testExpectRevert() public { - Counter counter = new Counter(); - vm.expectRevert(Counter.WrongNumber.selector); - counter.count(); - } -} - "#, - ) - .unwrap(); - - cmd.args(["test"]).assert_failure().stdout_eq(str![[r#" -... -[PASS] testExpectPartialRevertWith4Bytes() ([GAS]) -[PASS] testExpectPartialRevertWithSelector() ([GAS]) -[FAIL: Error != expected error: WrongNumber(0) != custom error 0x238ace70] testExpectRevert() ([GAS]) -... -"#]]); -}); - -forgetest_init!(test_assume_no_revert, |prj, cmd| { - prj.wipe_contracts(); - prj.insert_ds_test(); - prj.insert_vm(); - prj.clear(); - - prj.update_config(|config| { - config.fuzz.runs = 100; - config.fuzz.seed = Some(U256::from(100)); - }); - - prj.add_source( - "Counter.t.sol", - r#" -import {Vm} from "./Vm.sol"; -import {DSTest} from "./test.sol"; -contract CounterWithRevert { - error CountError(); - error CheckError(); - - function count(uint256 a) public pure returns (uint256) { - if (a > 1000 || a < 10) { - revert CountError(); - } - return 99999999; - } - function check(uint256 a) public pure { - if (a == 99999999) { - revert CheckError(); - } - } - function dummy() public pure {} -} - -contract CounterRevertTest is DSTest { - Vm vm = Vm(HEVM_ADDRESS); - - function test_assume_no_revert_pass(uint256 a) public { - CounterWithRevert counter = new CounterWithRevert(); - vm.assumeNoRevert(); - a = counter.count(a); - assertEq(a, 99999999); - } - function test_assume_no_revert_fail_assert(uint256 a) public { - CounterWithRevert counter = new CounterWithRevert(); - vm.assumeNoRevert(); - a = counter.count(a); - // Test should fail on next assertion. - assertEq(a, 1); - } - function test_assume_no_revert_fail_in_2nd_call(uint256 a) public { - CounterWithRevert counter = new CounterWithRevert(); - vm.assumeNoRevert(); - a = counter.count(a); - // Test should revert here (not in scope of `assumeNoRevert` cheatcode). - counter.check(a); - assertEq(a, 99999999); - } - function test_assume_no_revert_fail_in_3rd_call(uint256 a) public { - CounterWithRevert counter = new CounterWithRevert(); - vm.assumeNoRevert(); - a = counter.count(a); - // Test `assumeNoRevert` applied to non reverting call should not be available for next reverting call. - vm.assumeNoRevert(); - counter.dummy(); - // Test will revert here (not in scope of `assumeNoRevert` cheatcode). - counter.check(a); - assertEq(a, 99999999); - } -} - "#, - ) - .unwrap(); - - cmd.args(["test"]).with_no_redact().assert_failure().stdout_eq(str![[r#" -... -[FAIL; counterexample: [..]] test_assume_no_revert_fail_assert(uint256) [..] -[FAIL: CheckError(); counterexample: [..]] test_assume_no_revert_fail_in_2nd_call(uint256) [..] -[FAIL: CheckError(); counterexample: [..]] test_assume_no_revert_fail_in_3rd_call(uint256) [..] -[PASS] test_assume_no_revert_pass(uint256) [..] -... -"#]]); -}); - -forgetest_init!(skip_output, |prj, cmd| { - prj.wipe_contracts(); - prj.insert_ds_test(); - prj.insert_vm(); - prj.clear(); - - prj.add_source( - "Counter.t.sol", - r#" - import {Vm} from "./Vm.sol"; - import {DSTest} from "./test.sol"; - - contract Skips is DSTest { - Vm constant vm = Vm(HEVM_ADDRESS); - - function test_skipUnit() public { - vm.skip(true); - } - function test_skipUnitReason() public { - vm.skip(true, "unit"); - } - - function test_skipFuzz(uint) public { - vm.skip(true); - } - function test_skipFuzzReason(uint) public { - vm.skip(true, "fuzz"); - } - - function invariant_skipInvariant() public { - vm.skip(true); - } - function invariant_skipInvariantReason() public { - vm.skip(true, "invariant"); - } - } - "#, - ) - .unwrap(); - - cmd.arg("test").assert_success().stdout_eq(str![[r#" -... -Ran 6 tests for src/Counter.t.sol:Skips -[SKIP] invariant_skipInvariant() (runs: 1, calls: 1, reverts: 1) -[SKIP: invariant] invariant_skipInvariantReason() (runs: 1, calls: 1, reverts: 1) -[SKIP] test_skipFuzz(uint256) (runs: 0, [AVG_GAS]) -[SKIP: fuzz] test_skipFuzzReason(uint256) (runs: 0, [AVG_GAS]) -[SKIP] test_skipUnit() ([GAS]) -[SKIP: unit] test_skipUnitReason() ([GAS]) -Suite result: ok. 0 passed; 0 failed; 6 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 0 tests passed, 0 failed, 6 skipped (6 total tests) - -"#]]); -}); - -forgetest_init!(skip_setup, |prj, cmd| { - prj.add_test( - "Counter.t.sol", - r#" -import "forge-std/Test.sol"; - -contract SkipCounterSetup is Test { - - function setUp() public { - vm.skip(true, "skip counter test"); - } - - function test_require1() public pure { - require(1 > 2); - } - - function test_require2() public pure { - require(1 > 2); - } - - function test_require3() public pure { - require(1 > 2); - } -} - "#, - ) - .unwrap(); - - cmd.args(["test", "--mc", "SkipCounterSetup"]).assert_success().stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 1 test for test/Counter.t.sol:SkipCounterSetup -[SKIP: skipped: skip counter test] setUp() ([GAS]) -Suite result: ok. 0 passed; 0 failed; 1 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 0 tests passed, 0 failed, 1 skipped (1 total tests) - -"#]]); -}); - -forgetest_init!(should_generate_junit_xml_report, |prj, cmd| { - prj.wipe_contracts(); - prj.insert_ds_test(); - prj.insert_vm(); - prj.clear(); - - prj.add_source( - "JunitReportTest.t.sol", - r#" - import {Vm} from "./Vm.sol"; - import {DSTest} from "./test.sol"; - - contract AJunitReportTest is DSTest { - function test_junit_assert_fail() public { - assert(1 > 2); - } - - function test_junit_revert_fail() public { - require(1 > 2, "Revert"); - } - } - - contract BJunitReportTest is DSTest { - Vm constant vm = Vm(HEVM_ADDRESS); - function test_junit_pass() public { - require(1 < 2, "Revert"); - } - - function test_junit_skip() public { - vm.skip(true); - } - - function test_junit_skip_with_message() public { - vm.skip(true, "skipped test"); - } - - function test_junit_pass_fuzz(uint256 a) public { - } - } - "#, - ) - .unwrap(); - - cmd.args(["test", "--junit"]).assert_failure().stdout_eq(str![[r#" - - - - - - [FAIL: panic: assertion failed (0x01)] test_junit_assert_fail() ([GAS]) - - - - [FAIL: Revert] test_junit_revert_fail() ([GAS]) - - Suite result: FAILED. 0 passed; 2 failed; 0 skipped; [ELAPSED] - - - - [PASS] test_junit_pass() ([GAS]) - - - [PASS] test_junit_pass_fuzz(uint256) (runs: 256, [AVG_GAS]) - - - - [SKIP] test_junit_skip() ([GAS]) - - - - [SKIP: skipped test] test_junit_skip_with_message() ([GAS]) - - Suite result: ok. 2 passed; 0 failed; 2 skipped; [ELAPSED] - - - - -"#]]); -}); - -forgetest_init!(should_generate_junit_xml_report_with_logs, |prj, cmd| { - prj.wipe_contracts(); - prj.add_source( - "JunitReportTest.t.sol", - r#" -import "forge-std/Test.sol"; -contract JunitReportTest is Test { - function test_junit_with_logs() public { - console.log("Step1"); - console.log("Step2"); - console.log("Step3"); - assert(2 > 1); - } -} - "#, - ) - .unwrap(); - - cmd.args(["test", "--junit", "-vvvv"]).assert_success().stdout_eq(str![[r#" - - - - - [PASS] test_junit_with_logs() ([GAS])/nLogs:/n Step1/n Step2/n Step3/n - - Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] - - - - -"#]]); -}); - -forgetest_init!( - // Enable this if no cheatcodes are deprecated. - // #[ignore = "no cheatcodes are deprecated"] - test_deprecated_cheatcode_warning, - |prj, cmd| { - prj.add_test( - "DeprecatedCheatcodeTest.t.sol", - r#" - import "forge-std/Test.sol"; - contract DeprecatedCheatcodeTest is Test { - function test_deprecated_cheatcode() public view { - vm.keyExists('{"a": 123}', ".a"); - vm.keyExists('{"a": 123}', ".a"); - } - } - - contract DeprecatedCheatcodeFuzzTest is Test { - function test_deprecated_cheatcode(uint256 a) public view { - vm.keyExists('{"a": 123}', ".a"); - } - } - - contract Counter { - uint256 a; - - function count() public { - a++; - } - } - - contract DeprecatedCheatcodeInvariantTest is Test { - function setUp() public { - Counter counter = new Counter(); - } - - /// forge-config: default.invariant.runs = 1 - function invariant_deprecated_cheatcode() public { - vm.keyExists('{"a": 123}', ".a"); - } - } - "#, - ) - .unwrap(); - - // Tests deprecated cheatcode warning for unit tests. - cmd.args(["test", "--mc", "DeprecatedCheatcodeTest"]).assert_success().stderr_eq(str![[ - r#" -Warning: the following cheatcode(s) are deprecated and will be removed in future versions: - keyExists(string,string): replaced by `keyExistsJson` - -"# - ]]); - - // Tests deprecated cheatcode warning for fuzz tests. - cmd.forge_fuse() - .args(["test", "--mc", "DeprecatedCheatcodeFuzzTest"]) - .assert_success() - .stderr_eq(str![[r#" -Warning: the following cheatcode(s) are deprecated and will be removed in future versions: - keyExists(string,string): replaced by `keyExistsJson` - -"#]]); - - // Tests deprecated cheatcode warning for invariant tests. - cmd.forge_fuse() - .args(["test", "--mc", "DeprecatedCheatcodeInvariantTest"]) - .assert_success() - .stderr_eq(str![[r#" -Warning: the following cheatcode(s) are deprecated and will be removed in future versions: - keyExists(string,string): replaced by `keyExistsJson` - -"#]]); - } -); - -forgetest_init!(requires_single_test, |prj, cmd| { - cmd.args(["test", "--debug"]).assert_failure().stderr_eq(str![[r#" -Error: 2 tests matched your criteria, but exactly 1 test must match in order to run the debugger. - -Use --match-contract and --match-path to further limit the search. - -"#]]); - cmd.forge_fuse().args(["test", "--flamegraph"]).assert_failure().stderr_eq(str![[r#" -Error: 2 tests matched your criteria, but exactly 1 test must match in order to generate a flamegraph. - -Use --match-contract and --match-path to further limit the search. - -"#]]); - cmd.forge_fuse().args(["test", "--flamechart"]).assert_failure().stderr_eq(str![[r#" -Error: 2 tests matched your criteria, but exactly 1 test must match in order to generate a flamechart. - -Use --match-contract and --match-path to further limit the search. - -"#]]); -}); - -// Test a script that calls vm.rememberKeys -forgetest_init!(script_testing, |prj, cmd| { - prj - .add_source( - "Foo", - r#" -import "forge-std/Script.sol"; - -interface Vm { -function rememberKeys(string calldata mnemonic, string calldata derivationPath, uint32 count) external returns (address[] memory keyAddrs); -} - -contract WalletScript is Script { -function run() public { - string memory mnemonic = "test test test test test test test test test test test junk"; - string memory derivationPath = "m/44'/60'/0'/0/"; - address[] memory wallets = Vm(address(vm)).rememberKeys(mnemonic, derivationPath, 3); - for (uint256 i = 0; i < wallets.length; i++) { - console.log(wallets[i]); - } -} -} - -contract FooTest { - WalletScript public script; - - - function setUp() public { - script = new WalletScript(); - } - - function testWalletScript() public { - script.run(); - } -} - -"#, - ) - .unwrap(); - - cmd.args(["test", "--mt", "testWalletScript", "-vvv"]).assert_success().stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 1 test for src/Foo.sol:FooTest -[PASS] testWalletScript() ([GAS]) -Logs: - 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 - 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 - 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC -... -"#]]); -}); - -// -forgetest_init!(metadata_bytecode_traces, |prj, cmd| { - prj.add_source( - "ParentProxy.sol", - r#" -import {Counter} from "./Counter.sol"; - -abstract contract ParentProxy { - Counter impl; - bytes data; - - constructor(Counter _implementation, bytes memory _data) { - impl = _implementation; - data = _data; - } -} - "#, - ) - .unwrap(); - prj.add_source( - "Proxy.sol", - r#" -import {ParentProxy} from "./ParentProxy.sol"; -import {Counter} from "./Counter.sol"; - -contract Proxy is ParentProxy { - constructor(Counter _implementation, bytes memory _data) - ParentProxy(_implementation, _data) - {} -} - "#, - ) - .unwrap(); - - prj.add_test( - "MetadataTraceTest.t.sol", - r#" -import {Counter} from "src/Counter.sol"; -import {Proxy} from "src/Proxy.sol"; - -import {Test} from "forge-std/Test.sol"; - -contract MetadataTraceTest is Test { - function test_proxy_trace() public { - Counter counter = new Counter(); - new Proxy(counter, ""); - } -} - "#, - ) - .unwrap(); - - cmd.args(["test", "--mt", "test_proxy_trace", "-vvvv"]).assert_success().stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 1 test for test/MetadataTraceTest.t.sol:MetadataTraceTest -[PASS] test_proxy_trace() ([GAS]) -Traces: - [..] MetadataTraceTest::test_proxy_trace() - ├─ [..] → new Counter@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f - │ └─ ← [Return] 481 bytes of code - ├─ [..] → new Proxy@0x2e234DAe75C793f67A35089C9d99245E1C58470b - │ └─ ← [Return] 62 bytes of code - └─ ← [Stop] - -Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) - -"#]]); - - // Check consistent traces for running with no metadata. - cmd.forge_fuse() - .args(["test", "--mt", "test_proxy_trace", "-vvvv", "--no-metadata"]) - .assert_success() - .stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 1 test for test/MetadataTraceTest.t.sol:MetadataTraceTest -[PASS] test_proxy_trace() ([GAS]) -Traces: - [..] MetadataTraceTest::test_proxy_trace() - ├─ [..] → new Counter@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f - │ └─ ← [Return] 427 bytes of code - ├─ [..] → new Proxy@0x2e234DAe75C793f67A35089C9d99245E1C58470b - │ └─ ← [Return] 8 bytes of code - └─ ← [Stop] - -Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) - -"#]]); -}); - -// Tests if dump of execution was created. -forgetest!(test_debug_with_dump, |prj, cmd| { - prj.add_source( - "dummy", - r" -contract Dummy { - function testDummy() public {} -} -", - ) - .unwrap(); - - let dump_path = prj.root().join("dump.json"); - - cmd.args(["test", "--mt", "testDummy", "--debug", "--dump", dump_path.to_str().unwrap()]); - cmd.assert_success(); - - assert!(dump_path.exists()); -}); - -forgetest_init!(test_assume_no_revert_with_data, |prj, cmd| { - prj.update_config(|config| { - config.fuzz.seed = Some(U256::from(100)); - }); - - prj.add_source( - "AssumeNoRevertTest.t.sol", - r#" -import {Test} from "forge-std/Test.sol"; - -interface Vm { - struct PotentialRevert { - address reverter; - bool partialMatch; - bytes revertData; - } - function expectRevert() external; - function assumeNoRevert() external pure; - function assumeNoRevert(PotentialRevert calldata revertData) external pure; - function assumeNoRevert(PotentialRevert[] calldata revertData) external pure; - function expectRevert(bytes4 revertData, uint64 count) external; - function assume(bool condition) external pure; -} - -contract ReverterB { - /// @notice has same error selectors as contract below to test the `reverter` param - error MyRevert(); - error SpecialRevertWithData(uint256 x); - - function revertIf2(uint256 x) public pure returns (bool) { - if (x == 2) { - revert MyRevert(); - } - return true; - } - - function revertWithData() public pure returns (bool) { - revert SpecialRevertWithData(2); - } -} - -contract Reverter { - error MyRevert(); - error RevertWithData(uint256 x); - error UnusedError(); - error ExpectedRevertCountZero(); - - ReverterB public immutable subReverter; - - constructor() { - subReverter = new ReverterB(); - } - - function myFunction() public pure returns (bool) { - revert MyRevert(); - } - - function revertIf2(uint256 value) public pure returns (bool) { - if (value == 2) { - revert MyRevert(); - } - return true; - } - - function revertWithDataIf2(uint256 value) public pure returns (bool) { - if (value == 2) { - revert RevertWithData(2); - } - return true; - } - - function twoPossibleReverts(uint256 x) public pure returns (bool) { - if (x == 2) { - revert MyRevert(); - } else if (x == 3) { - revert RevertWithData(3); - } - return true; - } - - function revertIf2Or3ExpectedRevertZero(uint256 x) public pure returns (bool) { - if (x == 2) { - revert ExpectedRevertCountZero(); - } else if (x == 3) { - revert MyRevert(); - } - return true; - } -} - -contract ReverterTest is Test { - Reverter reverter; - Vm _vm = Vm(VM_ADDRESS); - - function setUp() public { - reverter = new Reverter(); - } - - /// @dev Test that `assumeNoRevert` does not reject an unanticipated error selector - function testAssume_wrongSelector_fails(uint256 x) public view { - _vm.assumeNoRevert(Vm.PotentialRevert({revertData: abi.encodeWithSelector(Reverter.UnusedError.selector), partialMatch: false, reverter: address(0)})); - reverter.revertIf2(x); - } - - /// @dev Test that `assumeNoRevert` does not reject an unanticipated error with extra data - function testAssume_wrongData_fails(uint256 x) public view { - _vm.assumeNoRevert(Vm.PotentialRevert({revertData: abi.encodeWithSelector(Reverter.RevertWithData.selector, 3), partialMatch: false, reverter: address(0)})); - reverter.revertWithDataIf2(x); - } - - /// @dev Test that `assumeNoRevert` correctly rejects an error selector from a different contract - function testAssumeWithReverter_fails(uint256 x) public view { - ReverterB subReverter = (reverter.subReverter()); - _vm.assumeNoRevert(Vm.PotentialRevert({revertData: abi.encodeWithSelector(Reverter.MyRevert.selector), partialMatch: false, reverter: address(reverter)})); - subReverter.revertIf2(x); - } - - /// @dev Test that `assumeNoRevert` correctly rejects one of two different error selectors when supplying a specific reverter - function testMultipleAssumes_OneWrong_fails(uint256 x) public view { - Vm.PotentialRevert[] memory revertData = new Vm.PotentialRevert[](2); - revertData[0] = Vm.PotentialRevert({revertData: abi.encodeWithSelector(Reverter.MyRevert.selector), partialMatch: false, reverter: address(reverter)}); - revertData[1] = Vm.PotentialRevert({revertData: abi.encodeWithSelector(Reverter.RevertWithData.selector, 4), partialMatch: false, reverter: address(reverter)}); - _vm.assumeNoRevert(revertData); - reverter.twoPossibleReverts(x); - } - - /// @dev Test that `assumeNoRevert` assumptions are cleared after the first non-cheatcode external call - function testMultipleAssumesClearAfterCall_fails(uint256 x) public view { - _vm.assume(x != 3); - Vm.PotentialRevert[] memory revertData = new Vm.PotentialRevert[](2); - revertData[0] = Vm.PotentialRevert({revertData: abi.encodeWithSelector(Reverter.MyRevert.selector), partialMatch: false, reverter: address(0)}); - revertData[1] = Vm.PotentialRevert({revertData: abi.encodeWithSelector(Reverter.RevertWithData.selector, 4), partialMatch: false, reverter: address(reverter)}); - _vm.assumeNoRevert(revertData); - reverter.twoPossibleReverts(x); - - reverter.twoPossibleReverts(2); - } - - /// @dev Test that `assumeNoRevert` correctly rejects a generic assumeNoRevert call after any specific reason is provided - function testMultipleAssumes_ThrowOnGenericNoRevert_AfterSpecific_fails(bytes4 selector) public view { - _vm.assumeNoRevert(Vm.PotentialRevert({revertData: abi.encode(selector), partialMatch: false, reverter: address(0)})); - _vm.assumeNoRevert(); - reverter.twoPossibleReverts(2); - } - - function testAssumeThenExpectCountZeroFails(uint256 x) public { - _vm.assumeNoRevert( - Vm.PotentialRevert({ - revertData: abi.encodeWithSelector(Reverter.MyRevert.selector), - partialMatch: false, - reverter: address(0) - }) - ); - _vm.expectRevert(Reverter.ExpectedRevertCountZero.selector, 0); - reverter.revertIf2Or3ExpectedRevertZero(x); - } - - function testExpectCountZeroThenAssumeFails(uint256 x) public { - _vm.expectRevert(Reverter.ExpectedRevertCountZero.selector, 0); - _vm.assumeNoRevert( - Vm.PotentialRevert({ - revertData: abi.encodeWithSelector(Reverter.MyRevert.selector), - partialMatch: false, - reverter: address(0) - }) - ); - reverter.revertIf2Or3ExpectedRevertZero(x); - } - -}"#, - ) - .unwrap(); - cmd.args(["test", "--mc", "ReverterTest"]).assert_failure().stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 8 tests for src/AssumeNoRevertTest.t.sol:ReverterTest -[FAIL: call reverted with 'FOUNDRY::ASSUME' when it was expected not to revert; counterexample: [..] testAssumeThenExpectCountZeroFails(uint256) (runs: [..], [AVG_GAS]) -[FAIL: MyRevert(); counterexample: calldata=[..]] testAssumeWithReverter_fails(uint256) (runs: [..], [AVG_GAS]) -[FAIL: RevertWithData(2); counterexample: [..]] testAssume_wrongData_fails(uint256) (runs: [..], [AVG_GAS]) -[FAIL: MyRevert(); counterexample: [..]] testAssume_wrongSelector_fails(uint256) (runs: [..], [AVG_GAS]) -[FAIL: call reverted with 'FOUNDRY::ASSUME' when it was expected not to revert; counterexample: [..]] testExpectCountZeroThenAssumeFails(uint256) (runs: [..], [AVG_GAS]) -[FAIL: MyRevert(); counterexample: [..]] testMultipleAssumesClearAfterCall_fails(uint256) (runs: 0, [AVG_GAS]) -[FAIL: RevertWithData(3); counterexample: [..]] testMultipleAssumes_OneWrong_fails(uint256) (runs: [..], [AVG_GAS]) -[FAIL: vm.assumeNoRevert: you must make another external call prior to calling assumeNoRevert again; counterexample: [..]] testMultipleAssumes_ThrowOnGenericNoRevert_AfterSpecific_fails(bytes4) (runs: [..], [AVG_GAS]) -... - -"#]]); -}); - -forgetest_async!(can_get_broadcast_txs, |prj, cmd| { - foundry_test_utils::util::initialize(prj.root()); - - let (_api, handle) = spawn(NodeConfig::test().silent()).await; - - prj.insert_vm(); - prj.insert_ds_test(); - prj.insert_console(); - - prj.add_source( - "Counter.sol", - r#" - contract Counter { - uint256 public number; - - function setNumber(uint256 newNumber) public { - number = newNumber; - } - - function increment() public { - number++; - } -} - "#, - ) - .unwrap(); - - prj.add_script( - "DeployCounter", - r#" - import "forge-std/Script.sol"; - import "src/Counter.sol"; - - contract DeployCounter is Script { - function run() public { - vm.startBroadcast(); - - Counter counter = new Counter(); - - counter.increment(); - - counter.setNumber(10); - - vm.stopBroadcast(); - } - } - "#, - ) - .unwrap(); - - prj.add_script( - "DeployCounterWithCreate2", - r#" - import "forge-std/Script.sol"; - import "src/Counter.sol"; - - contract DeployCounterWithCreate2 is Script { - function run() public { - vm.startBroadcast(); - - bytes32 salt = bytes32(uint256(1337)); - Counter counter = new Counter{salt: salt}(); - - counter.increment(); - - counter.setNumber(20); - - vm.stopBroadcast(); - } - } - "#, - ) - .unwrap(); - - let test = r#" - import {Vm} from "../src/Vm.sol"; - import {DSTest} from "../src/test.sol"; - import {console} from "../src/console.sol"; - - contract GetBroadcastTest is DSTest { - Vm constant vm = Vm(HEVM_ADDRESS); - - function test_getLatestBroadcast() external { - // Gets the latest create - Vm.BroadcastTxSummary memory broadcast = vm.getBroadcast( - "Counter", - 31337, - Vm.BroadcastTxType.Create - ); - - console.log("latest create"); - console.log(broadcast.blockNumber); - - assertEq(broadcast.blockNumber, 1); - - // Gets the latest create2 - Vm.BroadcastTxSummary memory broadcast2 = vm.getBroadcast( - "Counter", - 31337, - Vm.BroadcastTxType.Create2 - ); - - console.log("latest create2"); - console.log(broadcast2.blockNumber); - assertEq(broadcast2.blockNumber, 4); - - // Gets the latest call - Vm.BroadcastTxSummary memory broadcast3 = vm.getBroadcast( - "Counter", - 31337, - Vm.BroadcastTxType.Call - ); - - console.log("latest call"); - assertEq(broadcast3.blockNumber, 6); - } - - function test_getBroadcasts() public { - // Gets all calls - Vm.BroadcastTxSummary[] memory broadcasts = vm.getBroadcasts( - "Counter", - 31337, - Vm.BroadcastTxType.Call - ); - - assertEq(broadcasts.length, 4); - } - - function test_getAllBroadcasts() public { - // Gets all broadcasts - Vm.BroadcastTxSummary[] memory broadcasts2 = vm.getBroadcasts( - "Counter", - 31337 - ); - - assertEq(broadcasts2.length, 6); - } - - function test_getLatestDeployment() public { - address deployedAddress = vm.getDeployment( - "Counter", - 31337 - ); - - assertEq(deployedAddress, address(0xD32c10E38A626Db0b0978B1A5828eb2957665668)); - } - - function test_getDeployments() public { - address[] memory deployments = vm.getDeployments( - "Counter", - 31337 - ); - - assertEq(deployments.length, 2); - assertEq(deployments[0], address(0xD32c10E38A626Db0b0978B1A5828eb2957665668)); // Create2 address - latest deployment - assertEq(deployments[1], address(0x5FbDB2315678afecb367f032d93F642f64180aa3)); // Create address - oldest deployment - } -} - "#; - - prj.add_test("GetBroadcast", test).unwrap(); - - let sender = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; - - cmd.args([ - "script", - "DeployCounter", - "--rpc-url", - &handle.http_endpoint(), - "--sender", - sender, - "--unlocked", - "--broadcast", - "--slow", - ]) - .assert_success(); - - cmd.forge_fuse() - .args([ - "script", - "DeployCounterWithCreate2", - "--rpc-url", - &handle.http_endpoint(), - "--sender", - sender, - "--unlocked", - "--broadcast", - "--slow", - ]) - .assert_success(); - - let broadcast_path = prj.root().join("broadcast"); - - // Check if the broadcast folder exists - assert!(broadcast_path.exists() && broadcast_path.is_dir()); - - cmd.forge_fuse().args(["test", "--mc", "GetBroadcastTest", "-vvv"]).assert_success(); -}); - -// See -forgetest_init!( - #[ignore = "RPC Service Unavailable"] - test_roll_scroll_fork_with_cancun, - |prj, cmd| { - prj.add_test( - "ScrollForkTest.t.sol", - r#" - -import {Test} from "forge-std/Test.sol"; - -contract ScrollForkTest is Test { - function test_roll_scroll_fork_to_tx() public { - vm.createSelectFork("https://scroll-mainnet.chainstacklabs.com/"); - bytes32 targetTxHash = 0xf94774a1f69bba76892141190293ffe85dd8d9ac90a0a2e2b114b8c65764014c; - vm.rollFork(targetTxHash); - } -} - "#, - ) - .unwrap(); - - cmd.args(["test", "--mt", "test_roll_scroll_fork_to_tx", "--evm-version", "cancun"]) - .assert_success(); - } -); - -// Test that only provider is included in failed fork error. -forgetest_init!(test_display_provider_on_error, |prj, cmd| { - prj.add_test( - "ForkTest.t.sol", - r#" -import {Test} from "forge-std/Test.sol"; - -contract ForkTest is Test { - function test_fork_err_message() public { - vm.createSelectFork("https://eth-mainnet.g.alchemy.com/v2/DUMMY_KEY"); - } -} - "#, - ) - .unwrap(); - - cmd.args(["test", "--mt", "test_fork_err_message"]).assert_failure().stdout_eq(str![[r#" -... -Ran 1 test for test/ForkTest.t.sol:ForkTest -[FAIL: vm.createSelectFork: could not instantiate forked environment with provider eth-mainnet.g.alchemy.com; failed to get latest block number; [..]] test_fork_err_message() ([GAS]) -Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] -... - -"#]]); -}); - -// Tests that test traces display state changes when running with verbosity. -#[cfg(not(feature = "isolate-by-default"))] -forgetest_init!(should_show_state_changes, |prj, cmd| { - cmd.args(["test", "--mt", "test_Increment", "-vvvvv"]).assert_success().stdout_eq(str![[r#" -... -Ran 1 test for test/Counter.t.sol:CounterTest -[PASS] test_Increment() ([GAS]) -Traces: - [137242] CounterTest::setUp() - ├─ [96345] → new Counter@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f - │ └─ ← [Return] 481 bytes of code - ├─ [2592] Counter::setNumber(0) - │ └─ ← [Stop] - └─ ← [Stop] - - [31851] CounterTest::test_Increment() - ├─ [22418] Counter::increment() - │ ├─ storage changes: - │ │ @ 0: 0 → 1 - │ └─ ← [Stop] - ├─ [424] Counter::number() [staticcall] - │ └─ ← [Return] 1 - ├─ [0] VM::assertEq(1, 1) [staticcall] - │ └─ ← [Return] - └─ ← [Stop] - -Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) - -"#]]); -}); - -// Tests that chained errors are properly displayed. -// -forgetest!(displays_chained_error, |prj, cmd| { - prj.add_test( - "Foo.t.sol", - r#" -contract ContractTest { - function test_anything(uint) public {} -} - "#, - ) - .unwrap(); - - cmd.arg("test").arg("--gas-limit=100").assert_failure().stdout_eq(str![[r#" -... -Failing tests: -Encountered 1 failing test in test/Foo.t.sol:ContractTest -[FAIL: EVM error; transaction validation error: call [GAS_COST] exceeds the [GAS_LIMIT]] setUp() ([GAS]) - -Encountered a total of 1 failing tests, 0 tests succeeded - -"#]]); -}); - -// Tests that `start/stopAndReturn` debugTraceRecording does not panic when running with -// verbosity > 3. -forgetest_init!(should_not_panic_on_debug_trace_verbose, |prj, cmd| { - prj.add_test( - "DebugTraceRecordingTest.t.sol", - r#" -import "forge-std/Test.sol"; -import {Counter} from "../src/Counter.sol"; - -contract DebugTraceRecordingTest is Test { - function test_start_stop_recording() public { - vm.startDebugTraceRecording(); - Counter counter = new Counter(); - counter.increment(); - vm.stopAndReturnDebugTraceRecording(); - } -} - "#, - ) - .unwrap(); - - cmd.args(["test", "--mt", "test_start_stop_recording", "-vvvv"]).assert_success().stdout_eq( - str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 1 test for test/DebugTraceRecordingTest.t.sol:DebugTraceRecordingTest -[PASS] test_start_stop_recording() ([GAS]) -Traces: - [..] DebugTraceRecordingTest::test_start_stop_recording() - └─ ← [Stop] - -Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) - -"#]], - ); -}); - -#[cfg(not(feature = "isolate-by-default"))] -forgetest_init!(colored_traces, |prj, cmd| { - cmd.args(["test", "--mt", "test_Increment", "--color", "always", "-vvvvv"]) - .assert_success() - .stdout_eq(file!["../fixtures/colored_traces.svg": TermSvg]); -}); - -// Tests that traces for successful tests can be suppressed by using `-s` flag. -// -#[cfg(not(feature = "isolate-by-default"))] -forgetest_init!(should_only_show_failed_tests_trace, |prj, cmd| { - prj.add_test( - "SuppressTracesTest.t.sol", - r#" -import "forge-std/Test.sol"; -import {Counter} from "../src/Counter.sol"; - -contract SuppressTracesTest is Test { - Counter public counter; - - function setUp() public { - counter = new Counter(); - counter.setNumber(0); - } - - function test_increment_success() public { - console.log("test increment success"); - counter.increment(); - assertEq(counter.number(), 1); - } - - function test_increment_failure() public { - console.log("test increment failure"); - counter.increment(); - assertEq(counter.number(), 100); - } -} - "#, - ) - .unwrap(); - - // Show traces and logs for failed test only. - cmd.args(["test", "--mc", "SuppressTracesTest", "-vvvv", "-s"]).assert_failure().stdout_eq( - str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 2 tests for test/SuppressTracesTest.t.sol:SuppressTracesTest -[FAIL: assertion failed: 1 != 100] test_increment_failure() ([GAS]) -Logs: - test increment failure - -Traces: - [137242] SuppressTracesTest::setUp() - ├─ [96345] → new Counter@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f - │ └─ ← [Return] 481 bytes of code - ├─ [2592] Counter::setNumber(0) - │ └─ ← [Stop] - └─ ← [Stop] - - [35178] SuppressTracesTest::test_increment_failure() - ├─ [0] console::log("test increment failure") [staticcall] - │ └─ ← [Stop] - ├─ [22418] Counter::increment() - │ └─ ← [Stop] - ├─ [424] Counter::number() [staticcall] - │ └─ ← [Return] 1 - ├─ [0] VM::assertEq(1, 100) [staticcall] - │ └─ ← [Revert] assertion failed: 1 != 100 - └─ ← [Revert] assertion failed: 1 != 100 - -[PASS] test_increment_success() ([GAS]) -Suite result: FAILED. 1 passed; 1 failed; 0 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 1 tests passed, 1 failed, 0 skipped (2 total tests) - -Failing tests: -Encountered 1 failing test in test/SuppressTracesTest.t.sol:SuppressTracesTest -[FAIL: assertion failed: 1 != 100] test_increment_failure() ([GAS]) - -Encountered a total of 1 failing tests, 1 tests succeeded - -"#]], - ); - - // Show traces and logs for all tests. - cmd.forge_fuse() - .args(["test", "--mc", "SuppressTracesTest", "-vvvv"]) - .assert_failure() - .stdout_eq(str![[r#" -No files changed, compilation skipped - -Ran 2 tests for test/SuppressTracesTest.t.sol:SuppressTracesTest -[FAIL: assertion failed: 1 != 100] test_increment_failure() ([GAS]) -Logs: - test increment failure - -Traces: - [137242] SuppressTracesTest::setUp() - ├─ [96345] → new Counter@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f - │ └─ ← [Return] 481 bytes of code - ├─ [2592] Counter::setNumber(0) - │ └─ ← [Stop] - └─ ← [Stop] - - [35178] SuppressTracesTest::test_increment_failure() - ├─ [0] console::log("test increment failure") [staticcall] - │ └─ ← [Stop] - ├─ [22418] Counter::increment() - │ └─ ← [Stop] - ├─ [424] Counter::number() [staticcall] - │ └─ ← [Return] 1 - ├─ [0] VM::assertEq(1, 100) [staticcall] - │ └─ ← [Revert] assertion failed: 1 != 100 - └─ ← [Revert] assertion failed: 1 != 100 - -[PASS] test_increment_success() ([GAS]) -Logs: - test increment success - -Traces: - [35229] SuppressTracesTest::test_increment_success() - ├─ [0] console::log("test increment success") [staticcall] - │ └─ ← [Stop] - ├─ [22418] Counter::increment() - │ └─ ← [Stop] - ├─ [424] Counter::number() [staticcall] - │ └─ ← [Return] 1 - ├─ [0] VM::assertEq(1, 1) [staticcall] - │ └─ ← [Return] - └─ ← [Stop] - -Suite result: FAILED. 1 passed; 1 failed; 0 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 1 tests passed, 1 failed, 0 skipped (2 total tests) - -Failing tests: -Encountered 1 failing test in test/SuppressTracesTest.t.sol:SuppressTracesTest -[FAIL: assertion failed: 1 != 100] test_increment_failure() ([GAS]) - -Encountered a total of 1 failing tests, 1 tests succeeded - -"#]]); -}); - -forgetest_init!(catch_test_deployment_failure, |prj, cmd| { - prj.add_test( - "TestDeploymentFailure.t.sol", - r#" -import "forge-std/Test.sol"; -contract TestDeploymentFailure is Test { - - constructor() { - require(false); - } - - function setUp() public { - require(true); - } - - function test_something() public { - require(1 == 1); - } -} - "#, - ) - .unwrap(); - - cmd.args(["t", "--mt", "test_something"]).assert_failure().stdout_eq(str![[r#" -... -Failing tests: -Encountered 1 failing test in test/TestDeploymentFailure.t.sol:TestDeploymentFailure -[FAIL: EvmError: Revert] constructor() ([GAS]) -..."#]]); -}); - -// -forgetest_init!(state_diff_recording_with_revert, |prj, cmd| { - prj.add_test( - "TestStateDiffRevertFailure.t.sol", - r#" -import "forge-std/Test.sol"; -contract StateDiffRevertAtSameDepthTest is Test { - function test_something() public { - CounterTestA counter = new CounterTestA(); - counter.doSomething(); - } -} - -contract CounterTestA is Test { - function doSomething() public { - vm.startStateDiffRecording(); - require(1 > 2); - } -} - "#, - ) - .unwrap(); - - cmd.args(["t", "--mt", "test_something"]).assert_failure(); -}); - -// -forgetest_init!(should_apply_pranks_per_recorded_depth, |prj, cmd| { - prj.add_test( - "Counter.t.sol", - r#" -import "forge-std/Test.sol"; -contract CounterTest is Test { - function test_stackPrank() public { - address player = makeAddr("player"); - SenderLogger senderLogger = new SenderLogger(); - Contract c = new Contract(); - - senderLogger.log(); // Log(ContractTest, DefaultSender) - vm.startPrank(player, player); - senderLogger.log(); // Log(player, player) - c.f(); // vm.startPrank(player) - senderLogger.log(); // Log(ContractTest, player) <- ContractTest should be player - vm.stopPrank(); - } -} - -contract Contract { - Vm public constant vm = Vm(address(bytes20(uint160(uint256(keccak256("hevm cheat code")))))); - - function f() public { - vm.startPrank(msg.sender); - } -} - -contract SenderLogger { - event Log(address, address); - - function log() public { - emit Log(msg.sender, tx.origin); - } -} - "#, - ) - .unwrap(); - // Emits - // Log(: player: [], : player: []) instead - // Log(: ContractTest: [], : player: []) - cmd.args(["test", "--mt", "test_stackPrank", "-vvvv"]).assert_success().stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 1 test for test/Counter.t.sol:CounterTest -[PASS] test_stackPrank() ([GAS]) -Traces: - [..] CounterTest::test_stackPrank() - ├─ [..] VM::addr() [staticcall] - │ └─ ← [Return] player: [0x44E97aF4418b7a17AABD8090bEA0A471a366305C] - ├─ [..] VM::label(player: [0x44E97aF4418b7a17AABD8090bEA0A471a366305C], "player") - │ └─ ← [Return] - ├─ [..] → new SenderLogger@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f - │ └─ ← [Return] 255 bytes of code - ├─ [..] → new Contract@0x2e234DAe75C793f67A35089C9d99245E1C58470b - │ └─ ← [Return] 542 bytes of code - ├─ [..] SenderLogger::log() - │ ├─ emit Log(: CounterTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], : DefaultSender: [0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38]) - │ └─ ← [Stop] - ├─ [..] VM::startPrank(player: [0x44E97aF4418b7a17AABD8090bEA0A471a366305C], player: [0x44E97aF4418b7a17AABD8090bEA0A471a366305C]) - │ └─ ← [Return] - ├─ [..] SenderLogger::log() - │ ├─ emit Log(: player: [0x44E97aF4418b7a17AABD8090bEA0A471a366305C], : player: [0x44E97aF4418b7a17AABD8090bEA0A471a366305C]) - │ └─ ← [Stop] - ├─ [..] Contract::f() - │ ├─ [..] VM::startPrank(player: [0x44E97aF4418b7a17AABD8090bEA0A471a366305C]) - │ │ └─ ← [Return] - │ └─ ← [Stop] - ├─ [..] SenderLogger::log() - │ ├─ emit Log(: player: [0x44E97aF4418b7a17AABD8090bEA0A471a366305C], : player: [0x44E97aF4418b7a17AABD8090bEA0A471a366305C]) - │ └─ ← [Stop] - ├─ [..] VM::stopPrank() - │ └─ ← [Return] - └─ ← [Stop] - -Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) - -"#]]); -}); - -// -forgetest_init!(should_redact_pk_in_sign_delegation, |prj, cmd| { - prj.add_test( - "Counter.t.sol", - r#" -import "forge-std/Test.sol"; -contract CounterTest is Test { - function testCheckDelegation() external { - (address alice, uint256 key) = makeAddrAndKey("alice"); - vm.signDelegation(address(0), key); - vm.signAndAttachDelegation(address(0), key); - } -} - "#, - ) - .unwrap(); - - cmd.args(["test", "--mt", "testCheckDelegation", "-vvvv"]).assert_success().stdout_eq(str![[r#" -... -Ran 1 test for test/Counter.t.sol:CounterTest -[PASS] testCheckDelegation() ([GAS]) -Traces: - [..] CounterTest::testCheckDelegation() - ├─ [0] VM::addr() [staticcall] - │ └─ ← [Return] alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6] - ├─ [0] VM::label(alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6], "alice") - │ └─ ← [Return] - ├─ [0] VM::signDelegation(0x0000000000000000000000000000000000000000, "") - │ └─ ← [Return] (0, 0x3d6ad67cc3dc94101a049f85f96937513a05485ae0f8b27545d25c4f71b12cf9, 0x3c0f2d62834f59d6ef0209e8a935f80a891a236eb18ac0e3700dd8f7ac8ae279, 0, 0x0000000000000000000000000000000000000000) - ├─ [0] VM::signAndAttachDelegation(0x0000000000000000000000000000000000000000, "") - │ └─ ← [Return] (0, 0x3d6ad67cc3dc94101a049f85f96937513a05485ae0f8b27545d25c4f71b12cf9, 0x3c0f2d62834f59d6ef0209e8a935f80a891a236eb18ac0e3700dd8f7ac8ae279, 0, 0x0000000000000000000000000000000000000000) - └─ ← [Stop] -... - -"#]]); -}); - -// -forgetest_init!(can_upload_selectors_with_path, |prj, cmd| { - prj.add_source( - "CounterV1.sol", - r#" -contract Counter { - uint256 public number; - - function setNumberV1(uint256 newNumber) public { - number = newNumber; - } - - function incrementV1() public { - number++; - } -} - "#, - ) - .unwrap(); - - prj.add_source( - "CounterV2.sol", - r#" -contract CounterV2 { - uint256 public number; - - function setNumberV2(uint256 newNumber) public { - number = newNumber; - } - - function incrementV2() public { - number++; - } -} - "#, - ) - .unwrap(); - - // Upload Counter without path fails as there are multiple contracts with same name. - cmd.args(["selectors", "upload", "Counter"]).assert_failure().stderr_eq(str![[r#" -... -Error: Multiple contracts found with the name `Counter` -... - -"#]]); - - // Upload without contract name should fail. - cmd.forge_fuse().args(["selectors", "upload", "src/Counter.sol"]).assert_failure().stderr_eq( - str![[r#" -... -Error: No contract name provided. -... - -"#]], - ); - - // Upload single CounterV2. - cmd.forge_fuse().args(["selectors", "upload", "CounterV2"]).assert_success().stdout_eq(str![[ - r#" -... -Uploading selectors for CounterV2... -... -Selectors successfully uploaded to OpenChain -... - -"# - ]]); - - // Upload CounterV1 with path. - cmd.forge_fuse() - .args(["selectors", "upload", "src/CounterV1.sol:Counter"]) - .assert_success() - .stdout_eq(str![[r#" -... -Uploading selectors for Counter... -... -Selectors successfully uploaded to OpenChain -... - -"#]]); - - // Upload Counter with path. - cmd.forge_fuse() - .args(["selectors", "upload", "src/Counter.sol:Counter"]) - .assert_success() - .stdout_eq(str![[r#" -... -Uploading selectors for Counter... -... -Selectors successfully uploaded to OpenChain -... - -"#]]); -}); - -// tests `interceptInitcode` function -forgetest_init!(intercept_initcode, |prj, cmd| { - prj.wipe_contracts(); - prj.insert_ds_test(); - prj.insert_vm(); - prj.clear(); - - prj.add_source( - "InterceptInitcode.t.sol", - r#" -import {Vm} from "./Vm.sol"; -import {DSTest} from "./test.sol"; - -contract SimpleContract { - uint256 public value; - constructor(uint256 _value) { - value = _value; - } -} - -contract InterceptInitcodeTest is DSTest { - Vm vm = Vm(HEVM_ADDRESS); - - function testInterceptRegularCreate() public { - // Set up interception - vm.interceptInitcode(); - - // Try to create a contract - this should revert with the initcode - bytes memory initcode; - try new SimpleContract(42) { - assert(false); - } catch (bytes memory interceptedInitcode) { - initcode = interceptedInitcode; - } - - // Verify the initcode contains the constructor argument - assertTrue(initcode.length > 0, "initcode should not be empty"); - - // The constructor argument is encoded as a 32-byte value at the end of the initcode - // We need to convert the last 32 bytes to uint256 - uint256 value; - assembly { - value := mload(add(add(initcode, 0x20), sub(mload(initcode), 32))) - } - assertEq(value, 42, "initcode should contain constructor arg"); - } - - function testInterceptCreate2() public { - // Set up interception - vm.interceptInitcode(); - - // Try to create a contract with CREATE2 - this should revert with the initcode - bytes memory initcode; - try new SimpleContract(1337) { - assert(false); - } catch (bytes memory interceptedInitcode) { - initcode = interceptedInitcode; - } - - // Verify the initcode contains the constructor argument - assertTrue(initcode.length > 0, "initcode should not be empty"); - - // The constructor argument is encoded as a 32-byte value at the end of the initcode - uint256 value; - assembly { - value := mload(add(add(initcode, 0x20), sub(mload(initcode), 32))) - } - assertEq(value, 1337, "initcode should contain constructor arg"); - } - - function testInterceptMultiple() public { - // First interception - vm.interceptInitcode(); - bytes memory initcode1; - try new SimpleContract(1) { - assert(false); - } catch (bytes memory interceptedInitcode) { - initcode1 = interceptedInitcode; - } - - // Second interception - vm.interceptInitcode(); - bytes memory initcode2; - try new SimpleContract(2) { - assert(false); - } catch (bytes memory interceptedInitcode) { - initcode2 = interceptedInitcode; - } - - // Verify different initcodes - assertTrue(initcode1.length > 0, "first initcode should not be empty"); - assertTrue(initcode2.length > 0, "second initcode should not be empty"); - - // Extract constructor arguments from both initcodes - uint256 value1; - uint256 value2; - assembly { - value1 := mload(add(add(initcode1, 0x20), sub(mload(initcode1), 32))) - value2 := mload(add(add(initcode2, 0x20), sub(mload(initcode2), 32))) - } - assertEq(value1, 1, "first initcode should contain first arg"); - assertEq(value2, 2, "second initcode should contain second arg"); - } -} - "#, - ) - .unwrap(); - cmd.args(["test", "-vvvvv"]).assert_success(); -}); - -// -// -forgetest_init!(should_preserve_fork_state_setup, |prj, cmd| { - prj.wipe_contracts(); - prj.add_test( - "Counter.t.sol", - r#" -import "forge-std/Test.sol"; -import {StdChains} from "forge-std/StdChains.sol"; - -contract CounterTest is Test { - struct Domain { - StdChains.Chain chain; - uint256 forkId; - } - - struct Bridge { - Domain source; - Domain destination; - uint256 someVal; - } - - struct SomeStruct { - Domain domain; - Bridge[] bridges; - } - - mapping(uint256 => SomeStruct) internal data; - - function setUp() public { - // Temporary workaround for `https://eth.llamarpc.com/` being down - setChain("mainnet", ChainData({ - name: "mainnet", - rpcUrl: "https://reth-ethereum.ithaca.xyz/rpc", - chainId: 1 - })); - - StdChains.Chain memory chain1 = getChain("mainnet"); - StdChains.Chain memory chain2 = getChain("base"); - Domain memory domain1 = Domain(chain1, vm.createFork(chain1.rpcUrl, 22253716)); - Domain memory domain2 = Domain(chain2, vm.createFork(chain2.rpcUrl, 28839981)); - data[1].domain = domain1; - data[2].domain = domain2; - - vm.selectFork(domain1.forkId); - - data[2].bridges.push(Bridge(domain1, domain2, 123)); - vm.selectFork(data[2].domain.forkId); - vm.selectFork(data[1].domain.forkId); - data[2].bridges.push(Bridge(domain1, domain2, 456)); - - assertEq(data[2].bridges.length, 2); - } - - function test_assert_storage() public { - vm.selectFork(data[2].domain.forkId); - assertEq(data[2].bridges.length, 2); - } - - function test_modify_and_storage() public { - data[3].domain = Domain(getChain("base"), vm.createFork(getChain("base").rpcUrl, 28839981)); - data[3].bridges.push(Bridge(data[1].domain, data[2].domain, 123)); - data[3].bridges.push(Bridge(data[1].domain, data[2].domain, 456)); - - vm.selectFork(data[2].domain.forkId); - assertEq(data[3].bridges.length, 2); - } -} - "#, - ) - .unwrap(); - - cmd.args(["test", "--mc", "CounterTest"]).assert_success().stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 2 tests for test/Counter.t.sol:CounterTest -[PASS] test_assert_storage() ([GAS]) -[PASS] test_modify_and_storage() ([GAS]) -Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 2 tests passed, 0 failed, 0 skipped (2 total tests) - -"#]]); -}); - -// -forgetest_init!(should_not_panic_on_cool, |prj, cmd| { - prj.add_test( - "Counter.t.sol", - r#" -import "forge-std/Test.sol"; -import {Counter} from "../src/Counter.sol"; - -contract CounterTest is Test { - Counter counter = new Counter(); - - function testCoolPanic() public { - address alice = makeAddr("alice"); - vm.deal(alice, 10000 ether); - counter.setNumber(1); - vm.cool(address(counter)); - vm.prank(alice); - payable(address(counter)).transfer(1 ether); - } -} - "#, - ) - .unwrap(); - - cmd.args(["test", "--mc", "CounterTest"]).assert_failure().stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 1 test for test/Counter.t.sol:CounterTest -[FAIL: EvmError: Revert] testCoolPanic() ([GAS]) -Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) - -Failing tests: -Encountered 1 failing test in test/Counter.t.sol:CounterTest -[FAIL: EvmError: Revert] testCoolPanic() ([GAS]) - -Encountered a total of 1 failing tests, 0 tests succeeded - -"#]]); -}); - -#[cfg(not(feature = "isolate-by-default"))] -forgetest_init!(detailed_revert_when_calling_non_contract_address, |prj, cmd| { - prj.add_test( - "NonContractCallRevertTest.t.sol", - r#" -import "forge-std/Test.sol"; -import {Counter} from "../src/Counter.sol"; - -interface ICounter { - function increment() external; - function number() external returns (uint256); - function random() external returns (uint256); -} - -contract NonContractCallRevertTest is Test { - Counter public counter; - address constant ADDRESS = 0xdEADBEeF00000000000000000000000000000000; - - function setUp() public { - counter = new Counter(); - counter.setNumber(1); - } - - function test_non_supported_selector_call_failure() public { - console.log("test non supported fn selector call failure"); - ICounter(address(counter)).random(); - } - - function test_non_contract_call_failure() public { - console.log("test non contract call failure"); - ICounter(ADDRESS).number(); - } - - function test_non_contract_void_call_failure() public { - console.log("test non contract (void) call failure"); - ICounter(ADDRESS).increment(); - } -} - "#, - ) - .unwrap(); - - cmd.args(["test", "--mc", "NonContractCallRevertTest", "-vvv"]) - .assert_failure() - .stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 3 tests for test/NonContractCallRevertTest.t.sol:NonContractCallRevertTest -[FAIL: call to non-contract address 0xdEADBEeF00000000000000000000000000000000] test_non_contract_call_failure() ([GAS]) -Logs: - test non contract call failure - -Traces: - [6350] NonContractCallRevertTest::test_non_contract_call_failure() - ├─ [0] console::log("test non contract call failure") [staticcall] - │ └─ ← [Stop] - ├─ [0] 0xdEADBEeF00000000000000000000000000000000::number() - │ └─ ← [Stop] - └─ ← [Revert] call to non-contract address 0xdEADBEeF00000000000000000000000000000000 - -[FAIL: call to non-contract address 0xdEADBEeF00000000000000000000000000000000] test_non_contract_void_call_failure() ([GAS]) -Logs: - test non contract (void) call failure - -Traces: - [6215] NonContractCallRevertTest::test_non_contract_void_call_failure() - ├─ [0] console::log("test non contract (void) call failure") [staticcall] - │ └─ ← [Stop] - └─ ← [Revert] call to non-contract address 0xdEADBEeF00000000000000000000000000000000 - -[FAIL: EvmError: Revert] test_non_supported_selector_call_failure() ([GAS]) -Logs: - test non supported fn selector call failure - -Traces: - [8620] NonContractCallRevertTest::test_non_supported_selector_call_failure() - ├─ [0] console::log("test non supported fn selector call failure") [staticcall] - │ └─ ← [Stop] - ├─ [145] Counter::random() - │ └─ ← [Revert] unrecognized function selector 0x5ec01e4d for contract 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f, which has no fallback function. - └─ ← [Revert] EvmError: Revert - -Suite result: FAILED. 0 passed; 3 failed; 0 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 0 tests passed, 3 failed, 0 skipped (3 total tests) - -Failing tests: -Encountered 3 failing tests in test/NonContractCallRevertTest.t.sol:NonContractCallRevertTest -[FAIL: call to non-contract address 0xdEADBEeF00000000000000000000000000000000] test_non_contract_call_failure() ([GAS]) -[FAIL: call to non-contract address 0xdEADBEeF00000000000000000000000000000000] test_non_contract_void_call_failure() ([GAS]) -[FAIL: EvmError: Revert] test_non_supported_selector_call_failure() ([GAS]) - -Encountered a total of 3 failing tests, 0 tests succeeded - -"#]]); -}); - -#[cfg(not(feature = "isolate-by-default"))] -forgetest_init!(detailed_revert_when_delegatecalling_unlinked_library, |prj, cmd| { - prj.add_test( - "NonContractDelegateCallRevertTest.t.sol", - r#" -import "forge-std/Test.sol"; - -library TestLibrary { - function foo(uint256 a) public pure returns (uint256) { - return a * 2; - } -} - -contract LibraryCaller { - address public lib; - - constructor(address _lib) { - lib = _lib; - } - - function foobar(uint256 val) public returns (uint256) { - (bool success, bytes memory data) = lib.delegatecall( - abi.encodeWithSelector(TestLibrary.foo.selector, val) - ); - - assert(success); - return abi.decode(data, (uint256)); - } -} - -contract NonContractDelegateCallRevertTest is Test { - function test_unlinked_library_call_failure() public { - console.log("Test: Simulating call to unlinked library"); - LibraryCaller caller = new LibraryCaller(0xdEADBEeF00000000000000000000000000000000); - - caller.foobar(10); - } -} - "#, - ) - .unwrap(); - - cmd.args(["test", "--mc", "NonContractDelegateCallRevertTest", "-vvv"]) - .assert_failure() - .stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 1 test for test/NonContractDelegateCallRevertTest.t.sol:NonContractDelegateCallRevertTest -[FAIL: delegatecall to non-contract address 0xdEADBEeF00000000000000000000000000000000 (usually an unliked library)] test_unlinked_library_call_failure() ([GAS]) -Logs: - Test: Simulating call to unlinked library - -Traces: - [255303] NonContractDelegateCallRevertTest::test_unlinked_library_call_failure() - ├─ [0] console::log("Test: Simulating call to unlinked library") [staticcall] - │ └─ ← [Stop] - ├─ [214746] → new LibraryCaller@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f - │ └─ ← [Return] 960 bytes of code - ├─ [3896] LibraryCaller::foobar(10) - │ ├─ [0] 0xdEADBEeF00000000000000000000000000000000::foo(10) [delegatecall] - │ │ └─ ← [Stop] - │ └─ ← [Revert] delegatecall to non-contract address 0xdEADBEeF00000000000000000000000000000000 (usually an unliked library) - └─ ← [Revert] delegatecall to non-contract address 0xdEADBEeF00000000000000000000000000000000 (usually an unliked library) - -Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) - -Failing tests: -Encountered 1 failing test in test/NonContractDelegateCallRevertTest.t.sol:NonContractDelegateCallRevertTest -[FAIL: delegatecall to non-contract address 0xdEADBEeF00000000000000000000000000000000 (usually an unliked library)] test_unlinked_library_call_failure() ([GAS]) - -Encountered a total of 1 failing tests, 0 tests succeeded - -"#]]); -}); - -// This test is a copy of `error_event_decode_with_cache` in cast/tests/cli/selectors.rs -// but it uses `forge build` to check that the project selectors are cached by default. -forgetest_init!(build_with_selectors_cache, |prj, cmd| { - prj.add_source( - "LocalProjectContract", - r#" -contract ContractWithCustomError { - error AnotherValueTooHigh(uint256, address); - event MyUniqueEventWithinLocalProject(uint256 a, address b); -} - "#, - ) - .unwrap(); - // Build and cache project selectors. - cmd.forge_fuse().args(["build"]).assert_success(); - - // Assert cast can decode custom error with local cache. - cmd.cast_fuse() - .args(["decode-error", "0x7191bc6200000000000000000000000000000000000000000000000000000000000000650000000000000000000000000000000000000000000000000000000000D0004F"]) - .assert_success() - .stdout_eq(str![[r#" -AnotherValueTooHigh(uint256,address) -101 -0x0000000000000000000000000000000000D0004F - -"#]]); - // Assert cast can decode event with local cache. - cmd.cast_fuse() - .args(["decode-event", "0xbd3699995dcc867b64dbb607be2c33be38df9134bef1178df13bfb9446e73104000000000000000000000000000000000000000000000000000000000000004e00000000000000000000000000000000000000000000000000000dd00000004e"]) - .assert_success() - .stdout_eq(str![[r#" -MyUniqueEventWithinLocalProject(uint256,address) -78 -0x00000000000000000000000000000DD00000004e - -"#]]); -}); - -// -forgetest_init!(revm_27_prank_bug_fix, |prj, cmd| { - prj.add_test( - "PrankBug.t.sol", - r#" -import {Test} from "forge-std/Test.sol"; -import {Counter} from "../src/Counter.sol"; - -contract PrankTest is Test { - Counter public counter; - - function setUp() public { - vm.startPrank(address(0x123)); - counter = new Counter(); - vm.stopPrank(); - } - - function test_Increment() public { - vm.startPrank(address(0x123)); - counter = new Counter(); - vm.stopPrank(); - - counter.increment(); - assertEq(counter.number(), 1); - } -} -"#, - ) - .unwrap(); - - cmd.args(["test", "--mc", "PrankTest", "-vvvvv"]).assert_success().stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 1 test for test/PrankBug.t.sol:PrankTest -[PASS] test_Increment() ([GAS]) -Traces: - [137218] PrankTest::setUp() - ├─ [0] VM::startPrank(0x0000000000000000000000000000000000000123) - │ └─ ← [Return] - ├─ [96345] → new Counter@0x6cdBd1b486b8FBD4140e8cd6daAED05bE13eD914 - │ └─ ← [Return] 481 bytes of code - ├─ [0] VM::stopPrank() - │ └─ ← [Return] - └─ ← [Stop] - - [161909] PrankTest::test_Increment() - ├─ [0] VM::startPrank(0x0000000000000000000000000000000000000123) - │ └─ ← [Return] - ├─ [96345] → new Counter@0xc4B957Cd61beB9b9afD76204b30683EDAaaB51Ec - │ └─ ← [Return] 481 bytes of code - ├─ [0] VM::stopPrank() - │ └─ ← [Return] - ├─ [22418] Counter::increment() - │ ├─ storage changes: - │ │ @ 0: 0 → 1 - │ └─ ← [Stop] - ├─ [424] Counter::number() [staticcall] - │ └─ ← [Return] 1 - ├─ [0] VM::assertEq(1, 1) [staticcall] - │ └─ ← [Return] - ├─ storage changes: - │ @ 31: 0x00000000000000000000006cdbd1b486b8fbd4140e8cd6daaed05be13ed91401 → 0x0000000000000000000000c4b957cd61beb9b9afd76204b30683edaaab51ec01 - └─ ← [Stop] - -Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) - -"#]]); -}); diff --git a/crates/forge/tests/cli/test_cmd/base.rs b/crates/forge/tests/cli/test_cmd/base.rs new file mode 100644 index 0000000000000..bfeb4c868f097 --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/base.rs @@ -0,0 +1,734 @@ +use alloy_consensus::{SignableTransaction, TxEip1559, transaction::SignerRecoverable}; +use alloy_network::{ReceiptResponse, TxSignerSync, eip2718::Encodable2718}; +use alloy_primitives::{Address, B256, Bytes, TxKind, U256, hex}; +use alloy_provider::Provider; +use alloy_signer::SignerSync; +use alloy_signer_local::PrivateKeySigner; +use anvil::{NodeConfig, spawn}; +use base_common_consensus::BaseTxEnvelope; +use foundry_evm::hardforks::BaseUpgrade; +use foundry_test_utils::util::OutputExt; +use serde_json::json; + +#[forgetest] +fn base_azul_excludes_beryl_precompiles(prj: _, cmd: _) { + prj.add_test("BaseEvm.t.sol", include_str!("../../fixtures/BaseEvm.t.sol")); + + cmd.args([ + "test", + "--network", + "base", + "--hardfork", + "base:Azul", + "--chain-id", + "8453", + "--match-test", + "test_azul_excludes_beryl_precompiles", + ]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/BaseEvm.t.sol:BaseEvmTest +[PASS] test_azul_excludes_beryl_precompiles() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest] +fn base_defaults_to_azul(prj: _, cmd: _) { + prj.add_test("BaseEvm.t.sol", include_str!("../../fixtures/BaseEvm.t.sol")); + + cmd.args([ + "test", + "--network", + "base", + "--chain-id", + "8453", + "--match-test", + "test_azul_excludes_beryl_precompiles", + ]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/BaseEvm.t.sol:BaseEvmTest +[PASS] test_azul_excludes_beryl_precompiles() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest] +fn base_beryl_precompiles_and_nested_evm(prj: _, cmd: _) { + prj.add_test("BaseEvm.t.sol", include_str!("../../fixtures/BaseEvm.t.sol")); + + let stdout = cmd + .args([ + "test", + "--network", + "base", + "--hardfork", + "base:Beryl", + "--chain-id", + "8453", + "--match-test", + "test_beryl", + "-vvvv", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + assert!(stdout.contains("ActivationRegistry"), "{stdout}"); + assert!(stdout.contains("B20Factory"), "{stdout}"); +} + +#[forgetest] +fn base_list_accepts_base_network(prj: _, cmd: _) { + prj.add_test("BaseEvm.t.sol", include_str!("../../fixtures/BaseEvm.t.sol")); + + cmd.args([ + "test", + "--network", + "base", + "--hardfork", + "base:Beryl", + "--chain-id", + "8453", + "--list", + ]) + .assert_success(); +} + +// Stateful Base precompile calls must work against a forked endpoint, not just locally: read-only +// ActivationRegistry/B20 calls already passed while `activate`/`createB20` reverted. +#[forgetest] +async fn base_fork_allows_stateful_precompile_writes(prj: _, cmd: _) { + let (_api, handle) = + spawn(NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Beryl.into()))).await; + + prj.add_test("BaseForkWrites.t.sol", include_str!("../../fixtures/BaseForkWrites.t.sol")); + + cmd.args([ + "test", + "--network", + "base", + "--hardfork", + "base:Beryl", + "--fork-url", + &handle.http_endpoint(), + "--match-contract", + "BaseForkWritesTest", + "-vvvv", + ]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 2 tests for test/BaseForkWrites.t.sol:BaseForkWritesTest +[PASS] test_factory_is_code_less_and_still_callable() ([GAS]) +... +[PASS] test_fork_activation_write() ([GAS]) +... +Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest] +fn base_local_allows_stateful_precompile_writes(prj: _, cmd: _) { + prj.add_test("BaseForkWrites.t.sol", include_str!("../../fixtures/BaseForkWrites.t.sol")); + + cmd.args([ + "test", + "--network", + "base", + "--hardfork", + "base:Beryl", + "--chain-id", + "8453", + "--match-contract", + "BaseForkWritesTest", + "-vvvv", + ]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 2 tests for test/BaseForkWrites.t.sol:BaseForkWritesTest +[PASS] test_factory_is_code_less_and_still_callable() ([GAS]) +... +[PASS] test_fork_activation_write() ([GAS]) +... +Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest] +fn base_script_uses_native_network(prj: _, cmd: _) { + let script = prj.add_script( + "BaseScript.s.sol", + r#" +interface IActivationRegistry { + function admin() external view returns (address); +} + +contract BaseScript { + address constant ACTIVATION_REGISTRY = 0x8453000000000000000000000000000000000001; + address constant MAINNET_BERYL_ADMIN = 0xcE3a3bEE7E72E2A24079f3c0Cb3b97740ED425A9; + + function run() external view { + require( + IActivationRegistry(ACTIVATION_REGISTRY).admin() == MAINNET_BERYL_ADMIN, + "Base EVM not selected" + ); + } +} + "#, + ); + + cmd.arg("script") + .arg(script) + .args(["--network", "base", "--hardfork", "base:Beryl", "--chain-id", "8453"]) + .assert_success(); +} + +#[forgetest] +fn base_execute_transaction_rejects_eip8130(prj: _, cmd: _) { + let signer = PrivateKeySigner::from_bytes(&B256::with_last_byte(1)).unwrap(); + let mut envelope = json!({ + "type": "0x79", + "tx": { + "chainId": 8453, "sender": null, "payer": null, + "nonceKey": "0x0", "nonceSequence": 0, + "validAfter": 0, "validBefore": 0, + "maxPriorityFeePerGas": "0x0", "maxFeePerGas": "0x3b9aca00", + "gasLimit": 200000, "accountChanges": [], "calls": [], "metadata": "0x" + }, + "senderAuth": "0x", "payerAuth": "0x" + }); + let unsigned = serde_json::from_value::(envelope.clone()).unwrap(); + let signature = signer + .sign_hash_sync(&unsigned.as_eip8130().unwrap().tx().sender_signature_hash()) + .unwrap(); + envelope["senderAuth"] = json!(Bytes::from(signature.as_bytes())); + let signed = serde_json::from_value::(envelope).unwrap(); + assert_eq!(signed.recover_signer().unwrap(), signer.address()); + let mut raw = Vec::new(); + signed.network_encode(&mut raw); + let raw = hex::encode(raw); + let sender = signer.address(); + prj.add_test( + "BaseExecuteTransaction.t.sol", + &format!( + r#" +interface Vm {{ + function deal(address account, uint256 balance) external; + function getNonce(address account) external view returns (uint64); + function _expectCheatcodeRevert(bytes calldata reason) external; + function executeTransaction(bytes calldata rawTx) external; +}} + +contract BaseExecuteTransactionTest {{ + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + function test_rejects_eip8130_without_state_changes() public {{ + address sender = {sender}; + vm.deal(sender, 1 ether); + uint64 nonce = vm.getNonce(sender); + vm._expectCheatcodeRevert("EIP-8130 transactions are not supported by vm.executeTransaction"); + vm.executeTransaction(hex"{raw}"); + require(sender.balance == 1 ether, "sender balance changed"); + require(vm.getNonce(sender) == nonce, "sender nonce changed"); + require(block.chainid == 8453, "execution chain changed"); + }} +}} +"# + ), + ); + cmd.args([ + "test", + "--network", + "base", + "--hardfork", + "base:Zenith", + "--chain-id", + "8453", + "--match-contract", + "BaseExecuteTransactionTest", + ]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/BaseExecuteTransaction.t.sol:BaseExecuteTransactionTest +[PASS] test_rejects_eip8130_without_state_changes() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest] +fn base_isolated_calls_do_not_charge_callers(prj: _, cmd: _) { + prj.add_test( + "BaseIsolatedFees.t.sol", + r#" +interface Vm { + function deal(address account, uint256 balance) external; + function prank(address sender) external; + function store(address target, bytes32 slot, bytes32 value) external; +} + +contract Sink { + uint256 public hits; + + fallback() external payable { + ++hits; + } +} + +contract BaseIsolatedFeesTest { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + address constant L1_BLOCK = 0x4200000000000000000000000000000000000015; + address constant BASE_FEE_VAULT = 0x4200000000000000000000000000000000000019; + address constant L1_FEE_VAULT = 0x420000000000000000000000000000000000001A; + address constant OPERATOR_FEE_VAULT = 0x420000000000000000000000000000000000001b; + Sink sink; + + function setUp() public { + sink = new Sink(); + + vm.store(L1_BLOCK, bytes32(uint256(1)), bytes32(uint256(0x28f53a5b))); + vm.store( + L1_BLOCK, + bytes32(uint256(3)), + bytes32(uint256(0x08dd00101c120000000000000002)) + ); + vm.store(L1_BLOCK, bytes32(uint256(7)), bytes32(uint256(0x0240f4f5))); + vm.store(L1_BLOCK, bytes32(uint256(8)), bytes32((uint256(1_000_000) << 64) | 7)); + } + + function test_zero_balance_caller_succeeds() public { + address caller = address(uint160(uint256(keccak256("caller")))); + + vm.prank(caller); + (bool success,) = address(sink).call(hex"deadbeef"); + + require(success, "call failed"); + require(sink.hits() == 1, "target was not called"); + } + + function test_funded_caller_balance_is_unchanged() public { + address caller = address(uint160(uint256(keccak256("caller")))); + vm.deal(caller, 1 ether); + uint256 baseFeeVaultBalance = BASE_FEE_VAULT.balance; + uint256 l1FeeVaultBalance = L1_FEE_VAULT.balance; + uint256 operatorFeeVaultBalance = OPERATOR_FEE_VAULT.balance; + + vm.prank(caller); + (bool success,) = address(sink).call(hex"deadbeef"); + + require(success, "call failed"); + require(caller.balance == 1 ether, "caller was charged"); + require(BASE_FEE_VAULT.balance == baseFeeVaultBalance, "base fee vault was credited"); + require(L1_FEE_VAULT.balance == l1FeeVaultBalance, "L1 fee vault was credited"); + require( + OPERATOR_FEE_VAULT.balance == operatorFeeVaultBalance, + "operator fee vault was credited" + ); + } + + function test_create_does_not_credit_fee_vaults() public { + uint256 baseFeeVaultBalance = BASE_FEE_VAULT.balance; + uint256 l1FeeVaultBalance = L1_FEE_VAULT.balance; + uint256 operatorFeeVaultBalance = OPERATOR_FEE_VAULT.balance; + + new Sink(); + + require(BASE_FEE_VAULT.balance == baseFeeVaultBalance, "base fee vault was credited"); + require(L1_FEE_VAULT.balance == l1FeeVaultBalance, "L1 fee vault was credited"); + require( + OPERATOR_FEE_VAULT.balance == operatorFeeVaultBalance, + "operator fee vault was credited" + ); + } +} +"#, + ); + + cmd.args([ + "test", + "--network", + "base", + "--hardfork", + "base:Azul", + "--chain-id", + "8453", + "--match-contract", + "BaseIsolatedFeesTest", + ]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 3 tests for test/BaseIsolatedFees.t.sol:BaseIsolatedFeesTest +[PASS] test_create_does_not_credit_fee_vaults() ([GAS]) +[PASS] test_funded_caller_balance_is_unchanged() ([GAS]) +[PASS] test_zero_balance_caller_succeeds() ([GAS]) +Suite result: ok. 3 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest] +fn base_isolated_snapshot_does_not_disable_broadcast_fees(prj: _, cmd: _) { + let signer = PrivateKeySigner::from_bytes(&B256::with_last_byte(1)).unwrap(); + let recipient = Address::with_last_byte(0x43); + let mut transaction = TxEip1559 { + chain_id: 8453, + gas_limit: 21_000, + max_fee_per_gas: 1_000_000_000, + max_priority_fee_per_gas: 1_000_000, + to: TxKind::Call(recipient), + value: U256::from(23), + ..Default::default() + }; + let signature = signer.sign_transaction_sync(&mut transaction).unwrap(); + let mut raw = Vec::new(); + transaction.into_signed(signature).eip2718_encode(&mut raw); + + prj.add_test( + "BaseIsolatedSnapshotFees.t.sol", + &format!( + r#" +interface Vm {{ + function broadcastRawTransaction(bytes calldata data) external; + function deal(address account, uint256 balance) external; + function fee(uint256 newBasefee) external; + function revertToState(uint256 snapshotId) external returns (bool); + function revertToStateAndDelete(uint256 snapshotId) external returns (bool); + function snapshotState() external returns (uint256); + function store(address target, bytes32 slot, bytes32 value) external; +}} + +contract BaseIsolatedSnapshotFeesTest {{ + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + address constant L1_BLOCK = 0x4200000000000000000000000000000000000015; + address constant BASE_FEE_VAULT = 0x4200000000000000000000000000000000000019; + address constant L1_FEE_VAULT = 0x420000000000000000000000000000000000001A; + address constant OPERATOR_FEE_VAULT = 0x420000000000000000000000000000000000001b; + + function setUp() public {{ + vm.fee(1 gwei); + vm.store(L1_BLOCK, bytes32(uint256(1)), bytes32(uint256(0x28f53a5b))); + vm.store( + L1_BLOCK, + bytes32(uint256(3)), + bytes32(uint256(0x08dd00101c120000000000000002)) + ); + vm.store(L1_BLOCK, bytes32(uint256(7)), bytes32(uint256(0x0240f4f5))); + vm.store(L1_BLOCK, bytes32(uint256(8)), bytes32((uint256(1_000_000) << 64) | 7)); + }} + + function test_snapshot_from_isolated_helper_does_not_disable_broadcast_fees() public {{ + address sender = {sender}; + address recipient = {recipient}; + vm.deal(sender, 1 ether); + + uint256 snapshot = this.snapshotInHelper(); + require(vm.revertToState(snapshot), "snapshot revert failed"); + vm.broadcastRawTransaction(hex"{raw}"); + + require(recipient.balance == 23, "transaction value not transferred"); + require(sender.balance < 1 ether - 23, "transaction fees not charged"); + }} + + function snapshotInHelper() external returns (uint256) {{ + return vm.snapshotState(); + }} + + function test_revert_inside_isolated_helper_does_not_credit_fee_vaults() public {{ + assertRevertDoesNotCreditFeeVaults(false); + }} + + function test_revert_and_delete_inside_isolated_helper_does_not_credit_fee_vaults() public {{ + assertRevertDoesNotCreditFeeVaults(true); + }} + + function assertRevertDoesNotCreditFeeVaults(bool deleteSnapshot) internal {{ + this.setFeeInHelper(2 gwei); + require(block.basefee == 2 gwei, "fee override not set"); + uint256 baseFeeVaultBalance = BASE_FEE_VAULT.balance; + uint256 l1FeeVaultBalance = L1_FEE_VAULT.balance; + uint256 operatorFeeVaultBalance = OPERATOR_FEE_VAULT.balance; + uint256 snapshot = vm.snapshotState(); + + this.revertInHelper(snapshot, deleteSnapshot); + + require(block.basefee == 2 gwei, "fee override not preserved after helper"); + require(BASE_FEE_VAULT.balance == baseFeeVaultBalance, "base fee vault was credited"); + require(L1_FEE_VAULT.balance == l1FeeVaultBalance, "L1 fee vault was credited"); + require( + OPERATOR_FEE_VAULT.balance == operatorFeeVaultBalance, + "operator fee vault was credited" + ); + }} + + function setFeeInHelper(uint256 basefee) external {{ + vm.fee(basefee); + }} + + function revertInHelper(uint256 snapshot, bool deleteSnapshot) external {{ + bool success = deleteSnapshot + ? vm.revertToStateAndDelete(snapshot) + : vm.revertToState(snapshot); + require(success, "snapshot revert failed"); + require(block.basefee == 2 gwei, "fee override not preserved inside helper"); + }} +}} +"#, + sender = signer.address(), + recipient = recipient, + raw = hex::encode(raw), + ), + ); + + cmd.args([ + "test", + "--network", + "base", + "--hardfork", + "base:Azul", + "--chain-id", + "8453", + "--match-contract", + "BaseIsolatedSnapshotFeesTest", + ]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 3 tests for test/BaseIsolatedSnapshotFees.t.sol:BaseIsolatedSnapshotFeesTest +[PASS] test_revert_and_delete_inside_isolated_helper_does_not_credit_fee_vaults() ([GAS]) +[PASS] test_revert_inside_isolated_helper_does_not_credit_fee_vaults() ([GAS]) +[PASS] test_snapshot_from_isolated_helper_does_not_disable_broadcast_fees() ([GAS]) +Suite result: ok. 3 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest] +async fn base_fork_isolated_snapshot_fee_tracks_roll(prj: _, cmd: _) { + let (api, handle) = + spawn(NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Azul.into()))).await; + let provider = handle.http_provider(); + + api.anvil_set_next_block_base_fee_per_gas(U256::from(1_000_000_000)).await.unwrap(); + api.mine_one().await.unwrap(); + let first_block = provider.get_block_number().await.unwrap(); + api.anvil_set_next_block_base_fee_per_gas(U256::from(2_000_000_000)).await.unwrap(); + api.mine_one().await.unwrap(); + let second_block = provider.get_block_number().await.unwrap(); + + prj.add_test( + "BaseForkIsolatedSnapshotFee.t.sol", + &format!( + r#" +interface Vm {{ + function createSelectFork(string calldata url, uint256 blockNumber) external returns (uint256); + function fee(uint256 newBasefee) external; + function revertToState(uint256 snapshotId) external returns (bool); + function revertToStateAndDelete(uint256 snapshotId) external returns (bool); + function rollFork(uint256 blockNumber) external; + function snapshotState() external returns (uint256); +}} + +contract BaseForkIsolatedSnapshotFeeTest {{ + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + string constant RPC = "{rpc}"; + uint256 constant FIRST_BLOCK = {first_block}; + uint256 constant SECOND_BLOCK = {second_block}; + + function test_implicit_fee_follows_fork_roll() public {{ + vm.createSelectFork(RPC, FIRST_BLOCK); + for (uint256 i; i < 2; ++i) {{ + uint256 snapshot = vm.snapshotState(); + this.revertInHelper(snapshot, i == 1); + require(block.basefee == 1 gwei, "snapshot base fee not restored"); + vm.rollFork(SECOND_BLOCK); + require(block.basefee == 2 gwei, "old base fee pinned after fork roll"); + vm.rollFork(FIRST_BLOCK); + }} + }} + + function test_explicit_fee_survives_fork_roll() public {{ + vm.createSelectFork(RPC, FIRST_BLOCK); + vm.fee(3 gwei); + for (uint256 i; i < 2; ++i) {{ + uint256 snapshot = vm.snapshotState(); + this.revertInHelper(snapshot, i == 1); + vm.rollFork(SECOND_BLOCK); + require(block.basefee == 3 gwei, "explicit fee lost after fork roll"); + vm.rollFork(FIRST_BLOCK); + }} + }} + + function test_repeated_restore_keeps_implicit_fee_fork() public {{ + assertRepeatedRestore(false); + }} + + function test_repeated_restore_and_delete_keeps_implicit_fee_fork() public {{ + assertRepeatedRestore(true); + }} + + function assertRepeatedRestore(bool deleteSnapshot) internal {{ + vm.createSelectFork(RPC, FIRST_BLOCK); + uint256 firstSnapshot = vm.snapshotState(); + vm.rollFork(SECOND_BLOCK); + this.revertInHelper(firstSnapshot, deleteSnapshot); + require(block.basefee == 1 gwei, "first snapshot base fee not restored"); + + uint256 secondSnapshot = vm.snapshotState(); + this.revertInHelper(secondSnapshot, deleteSnapshot); + require(block.basefee == 1 gwei, "implicit base fee overwritten on second revert"); + }} + + function revertInHelper(uint256 snapshot, bool deleteSnapshot) external {{ + bool success = deleteSnapshot + ? vm.revertToStateAndDelete(snapshot) + : vm.revertToState(snapshot); + require(success, "snapshot revert failed"); + }} +}} +"#, + rpc = handle.http_endpoint(), + ), + ); + + cmd.args([ + "test", + "--isolate", + "--network", + "base", + "--hardfork", + "base:Azul", + "--chain-id", + "8453", + "--match-contract", + "BaseForkIsolatedSnapshotFeeTest", + ]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 4 tests for test/BaseForkIsolatedSnapshotFee.t.sol:BaseForkIsolatedSnapshotFeeTest +[PASS] test_explicit_fee_survives_fork_roll() ([GAS]) +[PASS] test_implicit_fee_follows_fork_roll() ([GAS]) +[PASS] test_repeated_restore_and_delete_keeps_implicit_fee_fork() ([GAS]) +[PASS] test_repeated_restore_keeps_implicit_fee_fork() ([GAS]) +Suite result: ok. 4 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest] +async fn base_fork_isolated_inactive_hash_roll_charges_replayed_fees(prj: _, cmd: _) { + let (api, handle) = + spawn(NodeConfig::test_base().with_hardfork(Some(BaseUpgrade::Azul.into()))).await; + let provider = handle.http_provider(); + let wallets = handle.dev_wallets().collect::>(); + let sender = wallets[0].address(); + let fresh_block = provider.get_block_number().await.unwrap(); + + let mut marker = TxEip1559 { + chain_id: 8453, + gas_limit: 21_000, + max_fee_per_gas: 3_000_000_000, + max_priority_fee_per_gas: 1_000_000_000, + to: TxKind::Call(Address::with_last_byte(0x44)), + value: U256::ONE, + ..Default::default() + }; + let signature = wallets[0].sign_transaction_sync(&mut marker).unwrap(); + let mut marker_raw = Vec::new(); + marker.into_signed(signature).eip2718_encode(&mut marker_raw); + + let mut target = TxEip1559 { + chain_id: 8453, + gas_limit: 21_000, + max_fee_per_gas: 3_000_000_000, + max_priority_fee_per_gas: 1_000_000_000, + to: TxKind::Call(Address::with_last_byte(0x45)), + value: U256::ONE, + ..Default::default() + }; + let signature = wallets[1].sign_transaction_sync(&mut target).unwrap(); + let mut target_raw = Vec::new(); + target.into_signed(signature).eip2718_encode(&mut target_raw); + + api.anvil_set_auto_mine(false).await.unwrap(); + let marker_pending = provider.send_raw_transaction(&marker_raw).await.unwrap(); + let target_pending = provider.send_raw_transaction(&target_raw).await.unwrap(); + let target_hash = *target_pending.tx_hash(); + api.mine_one().await.unwrap(); + let marker_receipt = marker_pending.get_receipt().await.unwrap(); + let target_receipt = target_pending.get_receipt().await.unwrap(); + api.anvil_set_auto_mine(true).await.unwrap(); + assert_eq!(marker_receipt.transaction_index(), Some(0)); + assert_eq!(target_receipt.transaction_index(), Some(1)); + let expected_sender_balance = provider.get_balance(sender).await.unwrap(); + + prj.add_test( + "BaseForkIsolatedReplayFees.t.sol", + &format!( + r#" +interface Vm {{ + function createFork(string calldata url, uint256 blockNumber) external returns (uint256); + function createSelectFork(string calldata url, uint256 blockNumber) + external + returns (uint256); + function rollFork(uint256 forkId, bytes32 transaction) external; + function selectFork(uint256 forkId) external; +}} + +contract BaseForkIsolatedReplayFeesTest {{ + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + string constant RPC = "{rpc}"; + address constant SENDER = {sender}; + uint256 constant FRESH_BLOCK = {fresh_block}; + bytes32 constant TARGET_HASH = {target_hash}; + uint256 constant EXPECTED_SENDER_BALANCE = {expected_sender_balance}; + + function test_fork_inactive_hash_roll_from_isolated_helper_charges_replay_fees() public {{ + vm.createSelectFork(RPC, FRESH_BLOCK); + uint256 inactive = vm.createFork(RPC, FRESH_BLOCK); + + this.rollInactiveFork(inactive); + vm.selectFork(inactive); + + require(SENDER.balance == EXPECTED_SENDER_BALANCE, "replay did not charge sender fees"); + }} + + function rollInactiveFork(uint256 forkId) external {{ + vm.rollFork(forkId, TARGET_HASH); + }} +}} +"#, + rpc = handle.http_endpoint(), + ), + ); + + cmd.args([ + "test", + "--network", + "base", + "--hardfork", + "base:Azul", + "--chain-id", + "8453", + "--match-contract", + "BaseForkIsolatedReplayFeesTest", + ]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/BaseForkIsolatedReplayFees.t.sol:BaseForkIsolatedReplayFeesTest +[PASS] test_fork_inactive_hash_roll_from_isolated_helper_charges_replay_fees() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} diff --git a/crates/forge/tests/cli/test_cmd/brutalize.rs b/crates/forge/tests/cli/test_cmd/brutalize.rs new file mode 100644 index 0000000000000..fd17eb12e5073 --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/brutalize.rs @@ -0,0 +1,1112 @@ +// CLI integration tests for `forge test --brutalize` + +use foundry_compilers::artifacts::remappings::Remapping; +use foundry_config::fs_permissions::PathPermission; +use foundry_test_utils::{str, util::OutputExt}; +use std::{fs, str::FromStr}; + +#[cfg(unix)] +use std::os::unix::fs::symlink; + +// Robust contract with casts + assembly passes under brutalization +#[forgetest_init] +fn brutalize_robust_contract_passes(prj: _, cmd: _) { + prj.add_source( + "Robust.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +contract Robust { + function toUint8(uint256 x) external pure returns (uint8) { + return uint8(x); + } + + function toAddress(uint160 x) external pure returns (address) { + return address(x); + } + + function addressThis() external view returns (address) { + return address(this); + } + + function addressToUint160(address x) external pure returns (uint160) { + return uint160(x); + } + + function negativeToInt16() external pure returns (int16) { + return int16(-1); + } + + function uint32ToBytes4(uint32 x) external pure returns (bytes4) { + return bytes4(x); + } + + function bytes32ToBytes31(bytes32 x) external pure returns (bytes31) { + return bytes31(x); + } + + function transferTo(address x) external { + payable(x).transfer(0); + } + + function dirtyInt16MinusOne() external pure returns (int16 value, uint256 raw) { + value = int16(-1); + assembly { + raw := value + } + } + + function asmAdd(uint256 a, uint256 b) external pure returns (uint256 result) { + assembly { result := add(a, b) } + } + + function hashPair(uint256 a, uint256 b) external pure returns (bytes32 result) { + assembly { + mstore(0x00, a) + mstore(0x20, b) + result := keccak256(0x00, 0x40) + } + } + + // Mixed Solidity + assembly: Solidity manages memory, assembly reads it. + // Robust because it uses abi.encodePacked (which properly allocates via FMP) + // then reads back through the pointer Solidity returned. + function mixedHash(uint256 a, uint256 b) external pure returns (bytes32) { + bytes memory packed = abi.encodePacked(a, b); + bytes32 result; + assembly { + result := keccak256(add(packed, 0x20), mload(packed)) + } + return result; + } + + // Assembly allocates properly via FMP, then Solidity uses the result. + function asmAllocThenSolidity(uint256 val) external pure returns (bytes32) { + bytes32 hash; + assembly { + let ptr := mload(0x40) + mstore(ptr, val) + hash := keccak256(ptr, 0x20) + mstore(0x40, add(ptr, 0x20)) + } + return keccak256(abi.encodePacked(hash)); + } +} +"#, + ); + + prj.add_test( + "Robust.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; +import "../src/Robust.sol"; + +contract RobustTest is Test { + Robust public robust; + + function setUp() public { + robust = new Robust(); + } + + function test_toUint8() public view { + assertEq(robust.toUint8(256), 0); + assertEq(robust.toUint8(255), 255); + } + + function test_toAddress() public view { + assertEq(robust.toAddress(1), address(1)); + } + + function test_addressThis() public view { + assertEq(robust.addressThis(), address(robust)); + } + + function test_addressToUint160() public view { + assertEq(robust.addressToUint160(address(1)), 1); + } + + function test_negativeToInt16() public view { + assertEq(robust.negativeToInt16(), -1); + } + + function test_uint32ToBytes4() public view { + assertEq(robust.uint32ToBytes4(0x12345678), bytes4(uint32(0x12345678))); + } + + function test_bytes32ToBytes31() public view { + bytes32 value = hex"ffffffffffffffffffffffffffffffffffffffffffffffff00000000000000aa"; + assertEq(robust.bytes32ToBytes31(value), bytes31(value)); + } + + function test_transferTo() public { + robust.transferTo(address(0xBEEF)); + } + + function test_dirtyInt16MinusOne() public view { + (int16 value, uint256 raw) = robust.dirtyInt16MinusOne(); + assertEq(value, -1); + assertTrue(raw != type(uint256).max); + } + + function test_asmAdd() public view { + assertEq(robust.asmAdd(2, 3), 5); + } + + function test_hashPair() public view { + bytes32 expected = keccak256(abi.encodePacked(uint256(1), uint256(2))); + assertEq(robust.hashPair(1, 2), expected); + } + + function test_mixedHash() public view { + bytes32 expected = keccak256(abi.encodePacked(uint256(10), uint256(20))); + assertEq(robust.mixedHash(10, 20), expected); + } + + function test_asmAllocThenSolidity() public view { + bytes32 inner = keccak256(abi.encodePacked(uint256(42))); + bytes32 expected = keccak256(abi.encodePacked(inner)); + assertEq(robust.asmAllocThenSolidity(42), expected); + } +} +"#, + ); + + cmd.args(["test", "--brutalize"]); + cmd.assert_success().stdout_eq(str![[r#" +... +Suite result: ok. [..] passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn brutalize_copies_fs_permission_fixtures(prj: _, cmd: _) { + let fixtures = prj.root().join("fixtures"); + fs::create_dir_all(&fixtures).unwrap(); + fs::write(fixtures.join("data.txt"), "fixture-data").unwrap(); + prj.update_config(|config| config.fs_permissions.add(PathPermission::read("./fixtures"))); + + prj.add_test( + "FixtureRead.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; + +contract FixtureReadTest is Test { + function test_readFixture() public view { + assertEq(vm.readFile("./fixtures/data.txt"), "fixture-data"); + } +} +"#, + ); + + cmd.args(["test", "--brutalize", "--mt", "test_readFixture"]); + cmd.assert_success().stdout_eq(str![[r#" +... +Ran 1 test for test/FixtureRead.t.sol:FixtureReadTest +[PASS] test_readFixture() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn brutalize_creates_write_permission_dirs(prj: _, cmd: _) { + let writes = prj.root().join("writes"); + fs::create_dir_all(&writes).unwrap(); + prj.update_config(|config| config.fs_permissions.add(PathPermission::write("./writes"))); + + prj.add_test( + "FixtureWrite.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; + +contract FixtureWriteTest is Test { + function test_writeFixture() public { + vm.writeFile("./writes/output.txt", "fixture-data"); + } +} +"#, + ); + + cmd.args(["test", "--mt", "test_writeFixture"]); + cmd.assert_success().stdout_eq(str![[r#" +... +Ran 1 test for test/FixtureWrite.t.sol:FixtureWriteTest +[PASS] test_writeFixture() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); + + cmd.forge_fuse().args(["test", "--brutalize", "--mt", "test_writeFixture"]); + cmd.assert_success().stdout_eq(str![[r#" +... +Ran 1 test for test/FixtureWrite.t.sol:FixtureWriteTest +[PASS] test_writeFixture() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn brutalize_creates_write_permission_file_parents(prj: _, cmd: _) { + fs::create_dir_all(prj.root().join("logs/sub")).unwrap(); + prj.update_config(|config| { + config.fs_permissions.add(PathPermission::write("./logs/sub/a.txt")) + }); + + prj.add_test( + "FixtureWriteFile.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; + +contract FixtureWriteFileTest is Test { + function test_writeFixtureFile() public { + vm.writeFile("./logs/sub/a.txt", "fixture-data"); + } +} +"#, + ); + + cmd.args(["test", "--mt", "test_writeFixtureFile"]); + cmd.assert_success().stdout_eq(str![[r#" +... +Ran 1 test for test/FixtureWriteFile.t.sol:FixtureWriteFileTest +[PASS] test_writeFixtureFile() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); + + cmd.forge_fuse().args(["test", "--brutalize", "--mt", "test_writeFixtureFile"]); + cmd.assert_success().stdout_eq(str![[r#" +... +Ran 1 test for test/FixtureWriteFile.t.sol:FixtureWriteFileTest +[PASS] test_writeFixtureFile() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn brutalize_creates_read_write_permission_file_parents(prj: _, cmd: _) { + fs::create_dir_all(prj.root().join("logs/sub")).unwrap(); + prj.update_config(|config| { + config.fs_permissions.add(PathPermission::read_write("./logs/sub/a.txt")) + }); + + prj.add_test( + "FixtureReadWriteFile.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; + +contract FixtureReadWriteFileTest is Test { + function test_readWriteFixtureFile() public { + assertFalse(vm.exists("./logs/sub/a.txt")); + vm.writeFile("./logs/sub/a.txt", "fixture-data"); + assertEq(vm.readFile("./logs/sub/a.txt"), "fixture-data"); + } +} +"#, + ); + + cmd.args(["test", "--brutalize", "--mt", "test_readWriteFixtureFile"]); + cmd.assert_success().stdout_eq(str![[r#" +... +Ran 1 test for test/FixtureReadWriteFile.t.sol:FixtureReadWriteFileTest +[PASS] test_readWriteFixtureFile() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[cfg(not(target_os = "windows"))] +#[forgetest_init] +fn brutalize_copies_in_root_symlinked_source_dirs(prj: _, cmd: _) { + fs::create_dir_all(prj.root().join(".shared/src")).unwrap(); + fs::create_dir_all(prj.root().join(".shared/test")).unwrap(); + fs::create_dir_all(prj.root().join("src")).unwrap(); + fs::create_dir_all(prj.root().join("test")).unwrap(); + std::os::unix::fs::symlink(std::path::Path::new("../.shared/src"), prj.root().join("src/core")) + .unwrap(); + std::os::unix::fs::symlink( + std::path::Path::new("../.shared/test"), + prj.root().join("test/suite"), + ) + .unwrap(); + + fs::write( + prj.root().join(".shared/src/SymlinkTarget.sol"), + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +contract SymlinkTarget { + function readScratch() external pure returns (uint256 result) { + assembly { + result := mload(0x00) + } + } +} +"#, + ) + .unwrap(); + + fs::write( + prj.root().join(".shared/test/SymlinkTarget.t.sol"), + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "../../src/core/SymlinkTarget.sol"; + +contract SymlinkTargetTest { + function test_symlinkedSourceIsBrutalized() external { + require(new SymlinkTarget().readScratch() != 0, "source was not brutalized"); + } +} +"#, + ) + .unwrap(); + + let stdout = cmd + .args(["test", "--brutalize", "--mt", "test_symlinkedSourceIsBrutalized"]) + .assert_success() + .get_output() + .stdout_lossy(); + + assert!(stdout.contains("[PASS] test_symlinkedSourceIsBrutalized()"), "{stdout}"); +} + +// --brutalize and --mutate are mutually exclusive +#[forgetest_init] +fn brutalize_conflicts_with_mutate(prj: _, cmd: _) { + prj.add_source( + "Dummy.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; +contract Dummy { function f() external pure returns (uint256) { return 1; } } +"#, + ); + + cmd.args(["test", "--brutalize", "--mutate", "src/Dummy.sol"]); + cmd.assert_failure().stderr_eq(str![[r#" +... +error: the argument '--brutalize' cannot be used with '--mutate [...]' +... +"#]]); +} + +// Catches uninitialized memory: assembly reads past FMP assuming zero +#[forgetest_init] +fn brutalize_catches_uninitialized_memory_read(prj: _, cmd: _) { + prj.add_source( + "MemVuln.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +contract MemVuln { + function allocAndRead() external pure returns (uint256 result) { + assembly { + let ptr := mload(0x40) + result := mload(add(ptr, 0x200)) + mstore(0x40, add(ptr, 0x220)) + } + } +} +"#, + ); + + prj.add_test( + "MemVuln.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; +import "../src/MemVuln.sol"; + +contract MemVulnTest is Test { + MemVuln public c; + + function setUp() public { + c = new MemVuln(); + } + + function test_AllocAndRead() public view { + assertEq(c.allocAndRead(), 0); + } +} +"#, + ); + + // Normal test passes — freshly allocated memory is zero + cmd.args(["test", "--mc", "MemVulnTest"]); + cmd.assert_success(); + + // Brutalized test fails — memory past FMP is filled with junk + cmd.forge_fuse().args(["test", "--brutalize", "--mc", "MemVulnTest"]); + cmd.assert_failure().stdout_eq(str![[r#" +... +Ran 1 test for test/MemVuln.t.sol:MemVulnTest +[FAIL: assertion failed: 73484773151519862809260092521038281836648321512035004033319242713382556117868 != 0] test_AllocAndRead() ([GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +// Catches dirty scratch space: reading 0x00 without writing first +#[forgetest_init] +fn brutalize_catches_dirty_scratch_space(prj: _, cmd: _) { + prj.add_source( + "ScratchVuln.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +contract ScratchVuln { + function readScratch() external pure returns (uint256 result) { + assembly { + result := mload(0x00) + } + } +} +"#, + ); + + prj.add_test( + "ScratchVuln.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; +import "../src/ScratchVuln.sol"; + +contract ScratchVulnTest is Test { + ScratchVuln public c; + + function setUp() public { + c = new ScratchVuln(); + } + + function test_readScratch() public view { + assertEq(c.readScratch(), 0); + } +} +"#, + ); + + cmd.args(["test", "--mc", "ScratchVulnTest"]); + cmd.assert_success().stdout_eq(str![[r#" +... +Ran 1 test for test/ScratchVuln.t.sol:ScratchVulnTest +[PASS] test_readScratch() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); + + cmd.forge_fuse().args(["test", "--brutalize", "--mc", "ScratchVulnTest"]); + cmd.assert_failure().stdout_eq(str![[r#" +... +Ran 1 test for test/ScratchVuln.t.sol:ScratchVulnTest +[FAIL: assertion failed: 51504957236947836658897895340570754371932386611139037127264554620328832223715 != 0] test_readScratch() ([GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +// Catches dirty scratch space in the high half of the scratch word. +#[forgetest_init] +fn brutalize_catches_dirty_scratch_space_high_half(prj: _, cmd: _) { + prj.add_source( + "ScratchHighVuln.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +contract ScratchHighVuln { + function readScratchHighHalf() external pure returns (uint256 result) { + assembly { + result := shr(128, mload(0x00)) + } + } +} +"#, + ); + + prj.add_test( + "ScratchHighVuln.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; +import "../src/ScratchHighVuln.sol"; + +contract ScratchHighVulnTest is Test { + ScratchHighVuln public c; + + function setUp() public { + c = new ScratchHighVuln(); + } + + function test_readScratchHighHalf() public view { + assertEq(c.readScratchHighHalf(), 0); + } +} +"#, + ); + + cmd.args(["test", "--mc", "ScratchHighVulnTest"]); + cmd.assert_success().stdout_eq(str![[r#" +... +Ran 1 test for test/ScratchHighVuln.t.sol:ScratchHighVulnTest +[PASS] test_readScratchHighHalf() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); + + cmd.forge_fuse().args(["test", "--brutalize", "--mc", "ScratchHighVulnTest"]); + cmd.assert_failure().stdout_eq(str![[r#" +... +Ran 1 test for test/ScratchHighVuln.t.sol:ScratchHighVulnTest +[FAIL: assertion failed: 14726357621369198564833851212186037415 != 0] test_readScratchHighHalf() ([GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +// Catches dirty upper bits from narrow value casts. +#[forgetest_init] +fn brutalize_catches_dirty_value_bits(prj: _, cmd: _) { + prj.add_source( + "ValueBitsVuln.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +contract ValueBitsVuln { + function rawBytes4(uint32 x) external pure returns (uint256 raw) { + bytes4 y = bytes4(x); + assembly { + raw := y + } + } +} +"#, + ); + + prj.add_test( + "ValueBitsVuln.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; +import "../src/ValueBitsVuln.sol"; + +contract ValueBitsVulnTest is Test { + ValueBitsVuln public c; + + function setUp() public { + c = new ValueBitsVuln(); + } + + function test_rawBytes4IsClean() public view { + bytes4 expected = bytes4(uint32(0x12345678)); + assertEq(c.rawBytes4(0x12345678), uint256(bytes32(expected))); + } +} +"#, + ); + + cmd.args(["test", "--mc", "ValueBitsVulnTest"]); + cmd.assert_success().stdout_eq(str![[r#" +... +Ran 1 test for test/ValueBitsVuln.t.sol:ValueBitsVulnTest +[PASS] test_rawBytes4IsClean() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); + + cmd.forge_fuse().args(["test", "--brutalize", "--mc", "ValueBitsVulnTest"]); + cmd.assert_failure().stdout_eq(str![[r#" +... +Ran 1 test for test/ValueBitsVuln.t.sol:ValueBitsVulnTest +[FAIL: assertion failed: 8234104107246695022420661102507966550300666591269321702966366938159159807320 != 8234104107246695022420661102507966550300666591269321702959126607540084801536] test_rawBytes4IsClean() ([GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn brutalize_warns_via_ir_value_bits_are_ineffective(prj: _, cmd: _) { + prj.add_source( + "ValueBitsVuln.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +contract ValueBitsVuln { + function rawBytes4(uint32 x) external pure returns (uint256 raw) { + bytes4 y = bytes4(x); + assembly { + raw := y + } + } +} +"#, + ); + + prj.add_test( + "ValueBitsVuln.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; +import "../src/ValueBitsVuln.sol"; + +contract ValueBitsVulnTest is Test { + ValueBitsVuln public c; + + function setUp() public { + c = new ValueBitsVuln(); + } + + function test_rawBytes4IsClean() public view { + bytes4 expected = bytes4(uint32(0x12345678)); + assertEq(c.rawBytes4(0x12345678), uint256(bytes32(expected))); + } +} +"#, + ); + + let output = cmd + .args(["test", "--brutalize", "--via-ir", "--mc", "ValueBitsVulnTest"]) + .assert_success() + .get_output() + .stderr_lossy(); + assert!( + output.contains("--brutalize value cast dirty-bits checks are ineffective with via-IR"), + "{output}" + ); +} + +// With src = ".", brutalization should skip tests, scripts, and libraries. +#[forgetest_init] +fn brutalize_flat_layout_only_brutalizes_sources(prj: _, cmd: _) { + prj.update_config(|config| { + config.src = ".".into(); + config.test = ".".into(); + }); + + fs::write( + prj.root().join("FlatTarget.sol"), + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +contract FlatTarget { + function readScratch() external pure returns (uint256 result) { + assembly { + result := mload(0x00) + } + } +} +"#, + ) + .unwrap(); + + fs::write( + prj.root().join("FlatTarget.t.sol"), + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "./FlatTarget.sol"; + +contract FlatTargetTest { + function test_readScratch() external { + assembly { + let scratch := mload(0x00) + pop(scratch) + } + require(new FlatTarget().readScratch() != 0, "source was not brutalized"); + } +} +"#, + ) + .unwrap(); + + for dep_dir in ["dependencies", "node_modules"] { + fs::create_dir_all(prj.root().join(dep_dir)).unwrap(); + fs::write( + prj.root().join(dep_dir).join("DependencyTarget.sol"), + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +contract DependencyTarget { + function readScratch() external pure returns (uint256 result) { + assembly { + result := mload(0x00) + } + } +} +"#, + ) + .unwrap(); + } + + let stderr = cmd + .args(["test", "--brutalize", "--mt", "test_readScratch"]) + .assert_success() + .get_output() + .stderr_lossy(); + + assert!(stderr.contains("Brutalized 1 source files"), "{stderr}"); +} + +// --brutalize works with --match-test filter (regression for .sanitized() config fix) +#[forgetest_init] +fn brutalize_with_filter(prj: _, cmd: _) { + prj.add_source( + "FilterTarget.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +contract FilterTarget { + function add(uint256 a, uint256 b) external pure returns (uint256) { + return a + b; + } +} +"#, + ); + + prj.add_test( + "FilterTarget.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; +import "../src/FilterTarget.sol"; + +contract FilterTargetTest is Test { + FilterTarget public c; + + function setUp() public { + c = new FilterTarget(); + } + + function test_add() public view { + assertEq(c.add(1, 2), 3); + } + + function test_addZero() public view { + assertEq(c.add(0, 0), 0); + } +} +"#, + ); + + cmd.args(["test", "--brutalize", "--mt", "test_add"]); + cmd.assert_success().stdout_eq(str![[r#" +... +Ran 2 tests for test/FilterTarget.t.sol:FilterTargetTest +[PASS] test_add() ([GAS]) +[PASS] test_addZero() ([GAS]) +Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +// --brutalize --rerun must read the original project's persisted failure list. +#[forgetest_init] +fn brutalize_rerun_uses_original_failure_cache(prj: _, cmd: _) { + prj.add_source( + "RerunTarget.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +contract RerunTarget { + function ok() external pure returns (uint256) { + return 1; + } +} +"#, + ); + + prj.add_test( + "RerunTarget.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +contract RerunTargetTest { + function test_fail() public pure { + require(false, "record me"); + } + + function test_pass() public pure { + require(true, "do not rerun"); + } +} +"#, + ); + + cmd.args(["test"]).assert_failure(); + assert!(prj.root().join("cache/test-failures").exists()); + + let stdout = cmd + .forge_fuse() + .args(["test", "--brutalize", "--rerun"]) + .assert_failure() + .get_output() + .stdout_lossy(); + + assert!(stdout.contains("[FAIL: record me] test_fail()"), "{stdout}"); + assert!(!stdout.contains("[PASS] test_pass()"), "{stdout}"); +} + +#[forgetest_init] +fn brutalize_persists_original_failure_cache(prj: _, cmd: _) { + prj.add_test( + "BrutalizeFailureCache.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +contract BrutalizeFailureCacheTest { + function test_failBrutalizedRun() public pure { + require(false, "record brutalized failure"); + } +} +"#, + ); + + cmd.args(["test", "--brutalize", "--mt", "test_failBrutalizedRun"]).assert_failure(); + + let failures = fs::read_to_string(prj.root().join("cache/test-failures")).unwrap(); + assert!(failures.contains("test_failBrutalizedRun"), "{failures}"); +} + +// --brutalize must preserve CLI/config overrides when compiling from the temp workspace. +#[forgetest_init] +fn brutalize_preserves_fuzz_runs_override(prj: _, cmd: _) { + prj.add_source( + "FuzzTarget.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +contract FuzzTarget { + function identity(uint256 x) external pure returns (uint256) { + return x; + } +} +"#, + ); + + prj.add_test( + "FuzzTarget.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; +import "../src/FuzzTarget.sol"; + +contract FuzzTargetTest is Test { + FuzzTarget public c; + + function setUp() public { + c = new FuzzTarget(); + } + + function test_fuzzIdentity(uint256 x) public view { + assertEq(c.identity(x), x); + } +} +"#, + ); + + let stdout = cmd + .args(["test", "--brutalize", "--fuzz-runs", "1", "--mt", "test_fuzzIdentity"]) + .assert_success() + .get_output() + .stdout_lossy(); + + assert!(stdout.contains("test_fuzzIdentity(uint256) (runs: 1,"), "{stdout}"); +} + +// --brutalize status output must not corrupt JUnit XML on stdout. +#[forgetest_init] +fn brutalize_junit_stdout_is_xml(prj: _, cmd: _) { + prj.add_source( + "JUnitTarget.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +contract JUnitTarget { + function add(uint256 a, uint256 b) external pure returns (uint256) { + return a + b; + } +} +"#, + ); + + prj.add_test( + "JUnitTarget.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; +import "../src/JUnitTarget.sol"; + +contract JUnitTargetTest is Test { + function test_add() public { + assertEq(new JUnitTarget().add(1, 2), 3); + } +} +"#, + ); + + let stdout = + cmd.args(["test", "--brutalize", "--junit"]).assert_success().get_output().stdout_lossy(); + assert!(stdout.trim_start().starts_with("` to inspect one failing test in the debugger + +"#]]); +} + +#[forgetest_init] +fn legacy_assertions(prj: _, cmd: _) { + prj.add_test( + "LegacyAssertions.t.sol", + r#" +import "forge-std/Test.sol"; + +contract NoAssertionsRevertTest is Test { + function testMultipleAssertFailures() public { + vm.assertEq(uint256(1), uint256(2)); + vm.assertLt(uint256(5), uint256(4)); + } +} + +/// forge-config: default.legacy_assertions = true +contract LegacyAssertionsTest { + bool public failed; + + function setFailed() external { + failed = true; + } + + function testFlagNotSetSuccess() public {} + + function testFlagSetFailure() public { + failed = true; + } + + function testFlagSetInCallFailure() public { + this.setFailed(); + } +} + +/// forge-config: default.legacy_assertions = true +/// forge-config: default.isolate = false +contract LegacyAssertionsNonIsolatedTest is LegacyAssertionsTest {} + +// Non-view on purpose: calls into it must be CALLs, which run isolated. +contract Asserter { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + function fail() external { + vm.assertTrue(false); + } + + function failThenRevert() external { + vm.assertTrue(false); + revert(); + } +} + +/// forge-config: default.assertions_revert = false +contract NonRevertingAssertionsTest is Test { + Asserter asserter = new Asserter(); + + function testBodyFailure() public { + vm.assertTrue(false); + } + + function testCallFailure() public { + asserter.fail(); + } + + function testRevertedCallFailureIsDropped() public { + try asserter.failThenRevert() {} catch {} + } +} + +/// forge-config: default.assertions_revert = false +/// forge-config: default.isolate = false +contract NonRevertingAssertionsNonIsolatedTest is NonRevertingAssertionsTest {} +"#, + ); + + cmd.args(["test", "-j1"]).assert_failure().stdout_eq(str![[r#" +... +Ran 3 tests for test/LegacyAssertions.t.sol:LegacyAssertionsNonIsolatedTest +[PASS] testFlagNotSetSuccess() ([GAS]) +[FAIL] testFlagSetFailure() ([GAS]) +[FAIL] testFlagSetInCallFailure() ([GAS]) +Suite result: FAILED. 1 passed; 2 failed; 0 skipped; [ELAPSED] + +Ran 3 tests for test/LegacyAssertions.t.sol:LegacyAssertionsTest +[PASS] testFlagNotSetSuccess() ([GAS]) +[FAIL] testFlagSetFailure() ([GAS]) +[FAIL] testFlagSetInCallFailure() ([GAS]) +Suite result: FAILED. 1 passed; 2 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/LegacyAssertions.t.sol:NoAssertionsRevertTest +[FAIL: assertion failed: 1 != 2] testMultipleAssertFailures() ([GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 3 tests for test/LegacyAssertions.t.sol:NonRevertingAssertionsNonIsolatedTest +[FAIL] testBodyFailure() ([GAS]) +[FAIL] testCallFailure() ([GAS]) +[PASS] testRevertedCallFailureIsDropped() ([GAS]) +Suite result: FAILED. 1 passed; 2 failed; 0 skipped; [ELAPSED] + +Ran 3 tests for test/LegacyAssertions.t.sol:NonRevertingAssertionsTest +[FAIL] testBodyFailure() ([GAS]) +[FAIL] testCallFailure() ([GAS]) +[PASS] testRevertedCallFailureIsDropped() ([GAS]) +Suite result: FAILED. 1 passed; 2 failed; 0 skipped; [ELAPSED] + +Ran 5 test suites [ELAPSED]: 4 tests passed, 9 failed, 0 skipped (13 total tests) + +Failing tests: +Encountered 2 failing tests in test/LegacyAssertions.t.sol:LegacyAssertionsNonIsolatedTest +[FAIL] testFlagSetFailure() ([GAS]) +[FAIL] testFlagSetInCallFailure() ([GAS]) + +Encountered 2 failing tests in test/LegacyAssertions.t.sol:LegacyAssertionsTest +[FAIL] testFlagSetFailure() ([GAS]) +[FAIL] testFlagSetInCallFailure() ([GAS]) + +Encountered 1 failing test in test/LegacyAssertions.t.sol:NoAssertionsRevertTest +[FAIL: assertion failed: 1 != 2] testMultipleAssertFailures() ([GAS]) + +Encountered 2 failing tests in test/LegacyAssertions.t.sol:NonRevertingAssertionsNonIsolatedTest +[FAIL] testBodyFailure() ([GAS]) +[FAIL] testCallFailure() ([GAS]) + +Encountered 2 failing tests in test/LegacyAssertions.t.sol:NonRevertingAssertionsTest +[FAIL] testBodyFailure() ([GAS]) +[FAIL] testCallFailure() ([GAS]) + +Encountered a total of 9 failing tests, 4 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 9 failed tests +Tip: Run `forge test --debug --match-test ` to inspect one failing test in the debugger + +"#]]); +} + +#[forgetest_init] +fn evm_profile_no_open_writes_profile_and_exits(prj: _, cmd: _) { + prj.add_test( + "EvmProfileNoOpen.t.sol", + r#" +contract EvmProfileNoOpenTest { + function testProfile() public {} +} +"#, + ); + + cmd.args(["test", "--match-test", "testProfile", "--evm-profile", "--no-open"]) + .assert_success() + .stdout_eq(str![[r#" +... +Profile saved to cache/evm_profile_EvmProfileNoOpenTest_testProfile.json + +"#]]); + + let profile_path = prj.root().join("cache/evm_profile_EvmProfileNoOpenTest_testProfile.json"); + let profile: Value = serde_json::from_str(&std::fs::read_to_string(profile_path).unwrap()) + .expect("profile should be valid JSON"); + assert_eq!(profile["exporter"], "foundry"); + assert_eq!(profile["profiles"][0]["type"], "evented"); +} + +#[forgetest_init] +fn evm_profile_conflicts_with_early_return_outputs(cmd: _) { + cmd.args(["test", "--evm-profile", "--json"]).assert_failure().stderr_eq(str![[r#" +error: the argument '--evm-profile []' cannot be used with '--json' + +Usage: forge[..] test --evm-profile [] [PATH] + +For more information, try '--help'. + +"#]]); + + cmd.forge_fuse().args(["test", "--evm-profile", "--junit"]).assert_failure().stderr_eq(str![[ + r#" +error: the argument '--evm-profile []' cannot be used with '--junit' + +Usage: forge[..] test --evm-profile [] [PATH] + +For more information, try '--help'. + +"# + ]]); + + cmd.forge_fuse().args(["test", "--evm-profile", "--list"]).assert_failure().stderr_eq(str![[ + r#" +error: the argument '--evm-profile []' cannot be used with '--list' + +Usage: forge[..] test --evm-profile [] [PATH] + +For more information, try '--help'. + +"# + ]]); +} + +#[forgetest_init] +fn flame_outputs_conflict_with_early_return_outputs(cmd: _) { + cmd.args(["test", "--flamegraph", "--json"]).assert_failure().stderr_eq(str![[r#" +error: the argument '--flamegraph' cannot be used with '--json' + +Usage: forge[..] test --flamegraph [PATH] + +For more information, try '--help'. + +"#]]); + + cmd.forge_fuse().args(["test", "--flamechart", "--list"]).assert_failure().stderr_eq(str![[ + r#" +error: the argument '--flamechart' cannot be used with '--list' + +Usage: forge[..] test --flamechart [PATH] + +For more information, try '--help'. + +"# + ]]); +} + +#[forgetest_init] +fn test_list_outputs_matching_tests(prj: _, cmd: _) { + prj.add_test( + "ListTests.t.sol", + r#" +contract ListTests { + function test_alpha() public pure {} + function test_beta() public pure {} + function testFuzz_value(uint256 value) public pure { + value; + } +} +"#, + ); + prj.add_test( + "ConstructorArgListTests.t.sol", + r#" +contract ConstructorArgListTests { + constructor(uint256 value) { + value; + } + + function test_constructor_arg() public pure {} +} +"#, + ); + + cmd.args(["test", "--list", "--match-test", "test_"]).assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! +test/ListTests.t.sol + ListTests + test_alpha + test_beta + + +"#]]); + + // Path-filtered listing should not compile unrelated sources. + prj.add_test("Broken.t.sol", "contract"); + cmd.forge_fuse() + .args(["test", "--list", "--match-test", "test_alpha", "--json"]) + .arg("test/ListTests.t.sol") + .assert_success() + .stdout_eq("{\"test/ListTests.t.sol\":{\"ListTests\":[\"test_alpha\"]}}\n"); +} + +// Listing tests must not write ABI-only artifacts that later cached builds treat as fresh. +#[forgetest] +fn test_list_does_not_poison_build_cache(prj: _, cmd: _) { + let artifact = prj.root().join("out/ListCache.t.sol/ListCacheTest.json"); + let cache = prj.root().join("cache/solidity-files-cache.json"); + // Extra output files bypass the ABI cache and exercise the uncached fallback. + for extra_output_files in [vec![], vec![ContractOutputSelection::Metadata]] { + prj.update_config(|config| config.extra_output_files = extra_output_files.clone()); + prj.add_test( + "ListCache.t.sol", + "contract ListCacheTest { function test_value() public pure { require(1 == 1); } }", + ); + cmd.forge_fuse().arg("build").assert_success(); + let artifact_before = std::fs::read_to_string(&artifact).unwrap(); + let cache_before = std::fs::read_to_string(&cache).unwrap(); + + prj.add_test( + "ListCache.t.sol", + "contract ListCacheTest { function test_value() public pure { require(1 == 2); } }", + ); + cmd.forge_fuse().args(["test", "--list"]).assert_success(); + assert_eq!(std::fs::read_to_string(&artifact).unwrap(), artifact_before); + assert_eq!(std::fs::read_to_string(&cache).unwrap(), cache_before); + cmd.forge_fuse().arg("test").assert_failure().stdout_eq(str![[r#" +... +Ran 1 test for test/ListCache.t.sol:ListCacheTest +[FAIL: EvmError: Revert] test_value() ([GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) +... +"#]]); + } +} + +#[forgetest_init] +fn evm_profile_requires_execution_trace(prj: _, cmd: _) { + prj.add_test( + "EvmProfileNoExecutionTrace.t.sol", + r#" +contract EvmProfileNoExecutionTraceTest { + function setUp() public { + revert("setUp failed"); + } + + function testProfile() public {} +} +"#, + ); + + cmd.args(["test", "--match-test", "testProfile", "--evm-profile", "--no-open"]) + .assert_failure() + .stderr_eq(str![[r#" +Error: cannot generate EVM profile for EvmProfileNoExecutionTraceTest::setUp: no execution trace (test may have failed in setUp/constructor or been skipped) + +"#]]); +} + +#[forgetest_init] +fn evm_profile_errors_when_no_tests_match(prj: _, cmd: _) { + prj.add_test( + "EvmProfileNoMatch.t.sol", + r#" +contract EvmProfileNoMatchTest { + function testProfile() public {} +} +"#, + ); + + cmd.args(["test", "--match-test", "missing", "--evm-profile", "--no-open"]) + .assert_failure() + .stderr_eq(str![[r#" +... +Error: cannot generate EVM profile: no tests were executed + +"#]]); +} + +#[forgetest_init] +fn flamegraph_requires_execution_trace(prj: _, cmd: _) { + prj.add_test( + "FlamegraphNoExecutionTrace.t.sol", + r#" +contract FlamegraphNoExecutionTraceTest { + function setUp() public { + revert("setUp failed"); + } + + function testProfile() public {} +} +"#, + ); + + cmd.args(["test", "--match-test", "testProfile", "--flamegraph"]) + .assert_failure() + .stderr_eq(str![[r#" +Error: cannot generate flamegraph for FlamegraphNoExecutionTraceTest::setUp: no execution trace (test may have failed in setUp/constructor or been skipped) + +"#]]); +} + +#[forgetest_init] +fn flame_outputs_profile_test_after_before_test_setup(prj: _, cmd: _) { + prj.add_test( + "FlameBeforeTestSetup.t.sol", + r#" +contract FlameBeforeTestSetupTest { + function beforeTestSetup(bytes4 testSelector) + public + view + returns (bytes[] memory beforeTestCalldata) + { + if (testSelector == this.testProfile.selector) { + beforeTestCalldata = new bytes[](1); + beforeTestCalldata[0] = abi.encodeCall(this.beforeOnly, ()); + } + } + + function beforeOnly() public {} + + function testProfile() public {} +} +"#, + ); + + cmd.args(["test", "--match-test", "testProfile", "--flamegraph", "--no-open"]).assert_success(); + let flamegraph = std::fs::read_to_string( + prj.root().join("cache/flamegraph_FlameBeforeTestSetupTest_testProfile.svg"), + ) + .unwrap(); + assert!(flamegraph.contains("FlameBeforeTestSetupTest.testProfile()")); + assert!(!flamegraph.contains("FlameBeforeTestSetupTest.beforeOnly()")); + + cmd.forge_fuse() + .args(["test", "--match-test", "testProfile", "--flamechart", "--no-open"]) + .assert_success(); + let flamechart = std::fs::read_to_string( + prj.root().join("cache/flamechart_FlameBeforeTestSetupTest_testProfile.svg"), + ) + .unwrap(); + assert!(flamechart.contains("FlameBeforeTestSetupTest.testProfile()")); + assert!(!flamechart.contains("FlameBeforeTestSetupTest.beforeOnly()")); +} + +#[forgetest_init] +fn payment_failure(prj: _, cmd: _) { + prj.add_test( + "PaymentFailure.t.sol", + r#" +import "forge-std/Test.sol"; + +contract Payable { + function pay() public payable {} +} + +contract PaymentFailureTest is Test { + function testCantPay() public { + Payable target = new Payable(); + vm.prank(address(1)); + target.pay{value: 1}(); + } +} +"#, + ); + + cmd.arg("test").assert_failure().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/PaymentFailure.t.sol:PaymentFailureTest +[FAIL: EvmError: Revert] testCantPay() ([GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/PaymentFailure.t.sol:PaymentFailureTest +[FAIL: EvmError: Revert] testCantPay() ([GAS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test +Tip: Run `forge test --debug --match-test ` to inspect one failing test in the debugger + +"#]]); +} + +#[forgetest_init] +fn rerun_filters_same_named_tests_by_contract(prj: _, cmd: _) { + prj.add_test( + "RerunSameName.t.sol", + r#" +import "forge-std/Test.sol"; + +contract FailingSameNameTest is Test { + function testSharedName() public { + assertTrue(false); + } +} + +contract PassingSameNameTest is Test { + function testSharedName() public { + assertTrue(true); + } +} +"#, + ); + + cmd.args(["test", "-j1"]).assert_failure().stdout_eq(str![[r#" +... +Ran 1 test for test/RerunSameName.t.sol:FailingSameNameTest +[FAIL: assertion failed] testSharedName() ([GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/RerunSameName.t.sol:PassingSameNameTest +[PASS] testSharedName() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 2 test suites [ELAPSED]: 1 tests passed, 1 failed, 0 skipped (2 total tests) +... +"#]]); + + cmd.forge_fuse().args(["test", "--rerun", "-j1"]).assert_failure().stdout_eq(str![[r#" +No files changed, compilation skipped + +Ran 1 test for test/RerunSameName.t.sol:FailingSameNameTest +[FAIL: assertion failed] testSharedName() ([GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) +... +"#]]); +} + +#[forgetest_init] +fn rerun_with_only_setup_failure_runs_all_tests(prj: _, cmd: _) { + prj.add_test( + "RerunSetupFail.t.sol", + r#" +import "forge-std/Test.sol"; + +contract OnlySetupFails is Test { + function setUp() public { + assertTrue(false); + } + + function testA() public { + assertTrue(true); + } +} + +contract HealthyContract is Test { + function testC() public { + assertTrue(true); + } +} +"#, + ); + + cmd.args(["test", "-j1"]).assert_failure(); + + // With no replayable failures recorded, `--rerun` falls back to a regular run instead of + // selecting zero tests. + cmd.forge_fuse().args(["test", "--rerun", "-j1"]).assert_failure().stdout_eq(str![[r#" +No files changed, compilation skipped + +Ran 1 test for test/RerunSetupFail.t.sol:HealthyContract +[PASS] testC() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/RerunSetupFail.t.sol:OnlySetupFails +[FAIL: assertion failed] setUp() ([GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 2 test suites [ELAPSED]: 1 tests passed, 1 failed, 0 skipped (2 total tests) +... +"#]]); +} + +#[forgetest_init] +fn rerun_cache_tracks_completed_invocation(prj: _, cmd: _) { + let failures_file = prj.root().join("cache/test-failures"); + let recorded_failure = r#"{"version":1,"failures":[{"contract":"test/RerunLifecycle.t.sol:RerunLifecycleTest","test":"testBroken"}]}"#; + let passing_test = r#" +contract RerunLifecycleTest { + function testBroken() public {} + function testOther() public {} +} +"#; + + // A compilation error does not complete a test invocation and must preserve the last record. + std::fs::create_dir_all(failures_file.parent().unwrap()).unwrap(); + std::fs::write(&failures_file, recorded_failure).unwrap(); + prj.add_test("RerunLifecycle.t.sol", "contract Broken {"); + cmd.args(["test", "-j1"]).assert_failure(); + assert_eq!(std::fs::read_to_string(&failures_file).unwrap(), recorded_failure); + + // Passing runs clear the record in both human-readable and serialized output modes. + for output_args in + [vec![], vec!["--rerun"], vec!["--rerun", "--json"], vec!["--rerun", "--junit"]] + { + prj.add_test("RerunLifecycle.t.sol", passing_test); + std::fs::write(&failures_file, recorded_failure).unwrap(); + cmd.forge_fuse().args(["test", "-j1"]).args(output_args).assert_success(); + assert!(!failures_file.exists()); + } + + // With no recorded failures, rerun falls back to the full test suite. + cmd.forge_fuse().args(["test", "--rerun", "-j1"]).assert_success().stdout_eq(str![[r#" +... +Ran 2 tests for test/RerunLifecycle.t.sol:RerunLifecycleTest +[PASS] testBroken() ([GAS]) +[PASS] testOther() ([GAS]) +Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 2 tests passed, 0 failed, 0 skipped (2 total tests) + +"#]]); + + // A stale recorded identity that no longer matches a renamed test clears the cache. + std::fs::write(&failures_file, recorded_failure).unwrap(); + prj.add_test( + "RerunLifecycle.t.sol", + r#" +contract RerunLifecycleTest { + function testRenamed() public {} +} +"#, + ); + cmd.forge_fuse().args(["test", "--rerun", "-j1"]).assert_success(); + assert!(!failures_file.exists()); +} + +#[forgetest_init] +fn rerun_cache_merges_network_pass_failures(prj: _, cmd: _) { + prj.add_test( + "RerunNetworks.t.sol", + r#" +contract RerunNetworksTest { + function testDefaultFailure() public pure { + require(false, "default failure"); + } + + /// forge-config: default.networks.network = "tempo" + function testTempoSuccess() public pure {} +} +"#, + ); + + cmd.args(["test", "-j1"]).assert_failure(); + let failures_file = prj.root().join("cache/test-failures"); + let failures: Value = + serde_json::from_str(&std::fs::read_to_string(&failures_file).unwrap()).unwrap(); + assert_eq!(failures["failures"].as_array().unwrap().len(), 1); + assert_eq!(failures["failures"][0]["test"], "testDefaultFailure"); + + prj.add_test( + "RerunNetworks.t.sol", + r#" +contract RerunNetworksTest { + function testDefaultFailure() public pure { + require(false, "default failure"); + } + + /// forge-config: default.networks.network = "tempo" + function testTempoFailure() public pure { + require(false, "tempo failure"); + } +} +"#, + ); + cmd.forge_fuse().args(["test", "-j1"]).assert_failure(); + let failures: Value = + serde_json::from_str(&std::fs::read_to_string(&failures_file).unwrap()).unwrap(); + let failed_tests = failures["failures"] + .as_array() + .unwrap() + .iter() + .map(|failure| failure["test"].as_str().unwrap()) + .collect::>(); + assert_eq!(failed_tests.len(), 2); + assert!(failed_tests.contains(&"testDefaultFailure")); + assert!(failed_tests.contains(&"testTempoFailure")); +} diff --git a/crates/forge/tests/cli/test_cmd/exact_fork.rs b/crates/forge/tests/cli/test_cmd/exact_fork.rs new file mode 100644 index 0000000000000..a7d252dfc4cc1 --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/exact_fork.rs @@ -0,0 +1,189 @@ +use super::*; +use axum::{Json, Router, body::Bytes}; +use serde_json::{Value, json}; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; + +async fn rpc(endpoint: &str, method: &str, params: Value) -> Value { + reqwest::Client::new() + .post(endpoint) + .json(&json!({"jsonrpc": "2.0", "id": 1, "method": method, "params": params})) + .send() + .await + .unwrap() + .json::() + .await + .unwrap()["result"] + .clone() +} + +#[forgetest] +async fn fork_execution_uses_exact_ancestry_after_reorg(prj: _, cmd: _) { + let (_api, anvil) = spawn(NodeConfig::test()).await; + let upstream = anvil.http_endpoint(); + let initial = rpc(&upstream, "eth_getBlockByNumber", json!(["latest", false])).await; + let initial_timestamp = + u64::from_str_radix(initial["timestamp"].as_str().unwrap().trim_start_matches("0x"), 16) + .unwrap(); + let snapshot = rpc(&upstream, "evm_snapshot", json!([])).await; + + rpc(&upstream, "evm_setNextBlockTimestamp", json!([initial_timestamp + 10])).await; + rpc(&upstream, "evm_mine", json!([])).await; + rpc(&upstream, "evm_setNextBlockTimestamp", json!([initial_timestamp + 20])).await; + rpc(&upstream, "evm_mine", json!([])).await; + let anchor = rpc(&upstream, "eth_getBlockByNumber", json!(["latest", false])).await; + let anchor_hash = anchor["hash"].as_str().unwrap().to_string(); + let anchor_number = anchor["number"].as_str().unwrap().to_string(); + let anchor_parent = anchor["parentHash"].as_str().unwrap().to_string(); + let parent = rpc(&upstream, "eth_getBlockByHash", json!([anchor_parent, false])).await; + + std::assert_eq!(rpc(&upstream, "evm_revert", json!([snapshot])).await, true); + rpc(&upstream, "evm_setNextBlockTimestamp", json!([initial_timestamp + 30])).await; + rpc(&upstream, "evm_mine", json!([])).await; + rpc(&upstream, "evm_setNextBlockTimestamp", json!([initial_timestamp + 40])).await; + rpc(&upstream, "evm_mine", json!([])).await; + let replacement = rpc(&upstream, "eth_getBlockByNumber", json!(["latest", false])).await; + std::assert_ne!(replacement["hash"], anchor["hash"]); + std::assert_ne!(replacement["parentHash"], anchor["parentHash"]); + + let exact_state_read = Arc::new(AtomicBool::new(false)); + let serve_orphan_by_number = Arc::new(AtomicBool::new(true)); + let app = Router::new().fallback({ + let upstream = upstream.clone(); + let anchor = anchor.clone(); + let anchor_hash = anchor_hash.clone(); + let anchor_number = anchor_number.clone(); + let anchor_parent = anchor_parent.clone(); + let parent = parent.clone(); + let exact_state_read = Arc::clone(&exact_state_read); + let serve_orphan_by_number = Arc::clone(&serve_orphan_by_number); + move |body: Bytes| { + let upstream = upstream.clone(); + let anchor = anchor.clone(); + let anchor_hash = anchor_hash.clone(); + let anchor_number = anchor_number.clone(); + let anchor_parent = anchor_parent.clone(); + let parent = parent.clone(); + let exact_state_read = Arc::clone(&exact_state_read); + let serve_orphan_by_number = Arc::clone(&serve_orphan_by_number); + async move { + let mut request: Value = serde_json::from_slice(&body).unwrap(); + let id = request["id"].clone(); + let method = request["method"].as_str().unwrap(); + if method == "eth_getBlockByNumber" && request["params"][0] == "latest" { + return Json(json!({"jsonrpc": "2.0", "id": id, "result": anchor})); + } + if method == "eth_getBlockByNumber" + && request["params"][0] == anchor_number + && serve_orphan_by_number.load(Ordering::Relaxed) + { + return Json(json!({"jsonrpc": "2.0", "id": id, "result": anchor})); + } + if method == "eth_getBlockByHash" && request["params"][0] == anchor_hash { + return Json(json!({"jsonrpc": "2.0", "id": id, "result": anchor})); + } + if method == "eth_getBlockByHash" && request["params"][0] == anchor_parent { + return Json(json!({"jsonrpc": "2.0", "id": id, "result": parent})); + } + let state_block_hash = if matches!( + method, + "eth_getBalance" + | "eth_getTransactionCount" + | "eth_getCode" + | "eth_getStorageAt" + ) { + request["params"] + .as_array() + .and_then(|params| params.last()) + .and_then(|block| block.get("blockHash").unwrap_or(block).as_str()) + } else { + None + }; + let exact_state = state_block_hash == Some(&anchor_hash); + if exact_state + && method == "eth_getBalance" + && request["params"][0] == "0x0000000000000000000000000000000000000100" + { + exact_state_read.store(true, Ordering::Relaxed); + return Json(json!({"jsonrpc": "2.0", "id": id, "result": "0x2a"})); + } + if exact_state { + let params = request["params"].as_array_mut().unwrap(); + *params.last_mut().unwrap() = Value::String(anchor_number); + } + let response = reqwest::Client::new() + .post(upstream) + .json(&request) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + Json(response) + } + } + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + let _server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + let anchor_number_value = + u64::from_str_radix(anchor_number.trim_start_matches("0x"), 16).unwrap(); + let (fork_api, fork_handle) = spawn( + NodeConfig::test() + .with_eth_rpc_url(Some(endpoint.clone())) + .with_fork_block_number(Some(anchor_number_value)) + .with_fork_chain_id(Some(U256::from(31337))), + ) + .await; + serve_orphan_by_number.store(false, Ordering::Relaxed); + let orphan_hash = fork_api.backend.get_fork().unwrap().block_hash(); + let reset_number = fork_api.backend.get_fork().unwrap().block_number(); + let canonical = + rpc(&upstream, "eth_getBlockByNumber", json!([format!("0x{reset_number:x}"), false])).await; + std::assert_ne!(canonical["hash"], orphan_hash.to_string()); + fork_api.anvil_reset(Some(Default::default())).await.unwrap(); + std::assert_eq!( + fork_api.backend.get_fork().unwrap().block_hash().to_string(), + canonical["hash"] + ); + drop(fork_handle); + + prj.add_test( + "ExactFork.t.sol", + &format!( + r#" +contract ExactForkTest {{ + function testExactForkAncestry() public {{ + require(address(0x100).balance == 42, "wrong state"); + require(blockhash(block.number - 1) == bytes32({anchor_parent}), "wrong ancestry"); + }} +}} +"# + ), + ); + + cmd.args(["test", "--fork-url", &endpoint, "--match-test", "testExactForkAncestry"]) + .assert_success(); + #[cfg(feature = "monad")] + { + let mut monad_cmd = prj.forge_command(); + monad_cmd + .args([ + "test", + "--network", + "monad", + "--fork-url", + &endpoint, + "--match-test", + "testExactForkAncestry", + "--threads", + "1", + ]) + .assert_success(); + } + assert!(exact_state_read.load(Ordering::Relaxed)); +} diff --git a/crates/forge/tests/cli/test_cmd/fork_bal.rs b/crates/forge/tests/cli/test_cmd/fork_bal.rs new file mode 100644 index 0000000000000..90b29cb067b92 --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/fork_bal.rs @@ -0,0 +1,747 @@ +//! Transaction-hash fork cache warming against a locally mined parent and transaction prefix. + +use alloy_primitives::{Address, B256, U256, address, bytes}; +use anvil::{EthereumHardfork, NodeConfig, NodeHandle, spawn}; +use axum::{Json, Router, routing::post}; +use foundry_test_utils::TestCommand; +use parking_lot::Mutex; +use serde_json::{Value, json}; +use std::{ + sync::Arc, + time::{Duration, Instant}, +}; +use tokio::task::JoinHandle; + +const COUNTER: Address = address!("000000000000000000000000000000000000ba10"); +const BAL_METHOD: &str = "eth_getBlockAccessList"; + +async fn rpc(endpoint: &str, method: &str, params: Value) -> Value { + let response = reqwest::Client::new() + .post(endpoint) + .json(&json!({"jsonrpc": "2.0", "id": 1, "method": method, "params": params})) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + assert!(response.get("error").is_none(), "{method}: {response}"); + response["result"].clone() +} + +struct Fixture { + handle: NodeHandle, + parent: Value, + transactions: [B256; 3], + pending: B256, +} + +impl Fixture { + async fn new() -> Self { + // Amsterdam makes Anvil produce native BALs and their block-header commitments. + let (api, handle) = spawn( + NodeConfig::test() + .with_chain_id(Some(1u64)) + .with_hardfork(Some(EthereumHardfork::Amsterdam.into())) + .with_genesis_timestamp(Some(1_800_000_000u64)) + .with_no_mining(true), + ) + .await; + // Every transaction reads slot one and increments slot zero. + api.anvil_set_code(COUNTER, bytes!("6001545060005460010160005500")).await.unwrap(); + api.anvil_set_storage_at(COUNTER, U256::ZERO, B256::with_last_byte(6)).await.unwrap(); + api.anvil_set_storage_at(COUNTER, U256::ONE, B256::with_last_byte(19)).await.unwrap(); + let endpoint = handle.http_endpoint(); + let sender = handle.dev_wallets().next().unwrap().address(); + let send = |nonce| { + json!([{"from": sender, "to": COUNTER, "nonce": format!("0x{nonce:x}"), + "gas": "0x30d40", "gasPrice": "0x77359400"}]) + }; + rpc(&endpoint, "eth_sendTransaction", send(0)).await; + api.mine_one().await.unwrap(); + let parent = rpc(&endpoint, "eth_getBlockByNumber", json!(["latest", false])).await; + assert!(parent["blockAccessListHash"].is_string(), "missing native BAL commitment"); + assert_eq!( + rpc(&endpoint, "eth_getStorageAt", json!([COUNTER, "0x0", "latest"])).await, + json!(B256::with_last_byte(7)), + ); + let mut transactions = [B256::ZERO; 3]; + for (index, hash) in transactions.iter_mut().enumerate() { + *hash = serde_json::from_value( + rpc(&endpoint, "eth_sendTransaction", send(index + 1)).await, + ) + .unwrap(); + } + api.mine_one().await.unwrap(); + let block = rpc(&endpoint, "eth_getBlockByNumber", json!(["latest", false])).await; + assert_eq!(block["transactions"], json!(transactions)); + assert_eq!(block["parentHash"], parent["hash"]); + let pending = + serde_json::from_value(rpc(&endpoint, "eth_sendTransaction", send(4)).await).unwrap(); + Self { handle, parent, transactions, pending } + } +} + +#[derive(Clone, Copy, Debug)] +enum Response { + Native, + Unsupported, + InvalidCode, + Timeout, + Anvil, + CumulativeTimeout, + InvalidPrefix, + NullOnce, +} + +struct Proxy { + endpoint: String, + requests: Arc>>, + task: JoinHandle<()>, +} + +impl Proxy { + async fn new(fixture: &Fixture, mode: Response) -> Self { + let upstream = fixture.handle.http_endpoint(); + let client = reqwest::Client::new(); + let requests = Arc::new(Mutex::new(Vec::::new())); + let recorded = Arc::clone(&requests); + let parent_hash = fixture.parent["hash"].clone(); + let app = Router::new().route( + "/", + post(move |Json(request): Json| { + let upstream = upstream.clone(); + let client = client.clone(); + let recorded = Arc::clone(&recorded); + let parent_hash = parent_hash.clone(); + async move { + let after_bal = { + let mut requests = recorded.lock(); + let after_bal = + requests.iter().any(|request| request["method"] == BAL_METHOD); + requests.push(request.clone()); + after_bal + }; + let method = request["method"].as_str().unwrap(); + if matches!(method, "anvil_nodeInfo" | "anvil_metadata") { + if matches!(mode, Response::CumulativeTimeout) && after_bal { + tokio::time::sleep(Duration::from_millis(300)).await; + } + // Hide the local-node identity while leaving native block data intact. + if !matches!(mode, Response::Anvil) { + return Json(json!({"jsonrpc": "2.0", "id": request["id"], + "error": {"code": -32601, "message": "method not found"}})); + } + } + let bal_method = method == BAL_METHOD; + if bal_method && matches!(mode, Response::Unsupported) { + return Json(json!({"jsonrpc": "2.0", "id": request["id"], + "error": {"code": -32601, "message": "method not found"}})); + } + if bal_method { + if matches!(mode, Response::CumulativeTimeout) { + tokio::time::sleep(Duration::from_millis(300)).await; + } + if matches!(mode, Response::Timeout) { + return futures::future::pending().await; + } + if matches!(mode, Response::NullOnce) && !after_bal { + return Json( + json!({"jsonrpc": "2.0", "id": request["id"], "result": null}), + ); + } + if matches!(mode, Response::InvalidCode) { + // Valid storage must not enter the cache if a later account is invalid. + let bal = json!([{ + "address": COUNTER, + "storageChanges": [{"key": "0x0", "changes": [ + {"index": "0x1", "value": "0x7"} + ]}], + "storageReads": ["0x1"], + "balanceChanges": [], "nonceChanges": [], "codeChanges": [] + }, { + "address": "0x000000000000000000000000000000000000ba11", + "storageChanges": [], "storageReads": [], + "balanceChanges": [], "nonceChanges": [], + "codeChanges": [ + {"index": "0x0", "code": "0xef0100"}, + {"index": "0x1", "code": "0x"} + ] + }]); + return Json( + json!({"jsonrpc": "2.0", "id": request["id"], "result": bal}), + ); + } + } + let mut response = client + .post(upstream) + .json(&request) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + if matches!(method, "eth_getBlockByHash" | "eth_getBlockByNumber") { + // Exercise bytecode validation without an earlier commitment mismatch. + if matches!(mode, Response::InvalidCode) { + response["result"] + .as_object_mut() + .unwrap() + .remove("blockAccessListHash"); + } + if matches!(mode, Response::InvalidPrefix) + && response["result"]["parentHash"] == parent_hash + && request["params"][1] == true + { + response["result"]["transactions"][0]["gas"] = json!("0x0"); + } + } + Json(response) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + let task = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + Self { endpoint, requests, task } + } + + fn count(&self, method: &str) -> usize { + self.requests.lock().iter().filter(|request| request["method"] == method).count() + } + + fn slot_reads(&self, slot: U256) -> usize { + self.requests + .lock() + .iter() + .filter(|request| { + request["method"] == "eth_getStorageAt" + && request["params"][0] == json!(COUNTER) + && serde_json::from_value::(request["params"][1].clone()).unwrap() == slot + }) + .count() + } + + fn assert_parent_bal(&self, fixture: &Fixture) { + let requests = self.requests.lock(); + let calls = + requests.iter().filter(|request| request["method"] == BAL_METHOD).collect::>(); + assert!(!calls.is_empty(), "parent BAL was never requested"); + for request in calls { + assert_eq!(request["params"], json!([fixture.parent["hash"]])); + } + } + + fn clear(&self) { + self.requests.lock().clear(); + } +} + +impl Drop for Proxy { + fn drop(&mut self) { + self.task.abort(); + } +} + +const TEST: &str = r#" +interface Vm { + function envString(string calldata) external returns (string memory); + function envUint(string calldata) external returns (uint256); + function envBytes32(string calldata) external returns (bytes32); + function createFork(string calldata, uint256) external returns (uint256); + function createFork(string calldata, bytes32) external returns (uint256); + function createSelectFork(string calldata, uint256) external returns (uint256); + function createSelectFork(string calldata, bytes32) external returns (uint256); + function selectFork(uint256) external; + function activeFork() external view returns (uint256); + function rollFork(bytes32) external; + function rollFork(uint256, bytes32) external; + function load(address, bytes32) external view returns (bytes32); + function store(address, bytes32, bytes32) external; + function makePersistent(address) external; + function snapshotState() external returns (uint256); + function revertToState(uint256) external returns (bool); + function _expectCheatcodeRevert() external; +} + +contract ForkBalTest { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + address constant counter = address(0xba10); + + function value() internal view returns (uint256) { + return uint256(vm.load(counter, bytes32(0))); + } + + function testForkBal() public { + string memory url = vm.envString("BAL_RPC_URL"); + bytes32 target = vm.envBytes32("BAL_TARGET"); + uint256 parent = vm.envUint("BAL_PARENT"); + uint256 mode = vm.envUint("BAL_MODE"); + if (mode == 0) { + vm.selectFork(vm.createFork(url, target)); + } else if (mode == 1) { + vm.createSelectFork(url, target); + } else if (mode == 2) { + vm.createSelectFork(url, parent); + vm.store(address(0xba11), bytes32(0), bytes32(uint256(70))); + vm.rollFork(target); + require(vm.load(address(0xba11), bytes32(0)) == bytes32(0), "active roll retained local state"); + } else { + uint256 active = vm.createSelectFork(url, parent); + uint256 inactive = vm.createFork(url, parent); + vm.rollFork(inactive, target); + require(vm.activeFork() == active, "inactive roll selected the fork"); + require(value() == 7, "inactive roll changed active state"); + vm.selectFork(inactive); + } + require(value() == vm.envUint("BAL_EXPECTED"), "wrong prefix state"); + require(uint256(vm.load(counter, bytes32(uint256(1)))) == 19, "lost read-only slot"); + } + + function testForkBalLifecycle() public { + string memory url = vm.envString("BAL_RPC_URL"); + bytes32 target = vm.envBytes32("BAL_TARGET"); + uint256 ordinary = vm.createFork(url, vm.envUint("BAL_PARENT")); + uint256 first = vm.createSelectFork(url, target); + require(value() == 9, "prefix missing"); + uint256 snapshot = vm.snapshotState(); + (bool ok,) = counter.call(""); + require(ok && value() == 10, "local transaction missing"); + require(vm.revertToState(snapshot) && value() == 9, "snapshot lost"); + vm.store(counter, bytes32(0), bytes32(uint256(90))); + address persistent = address(0xba11); + vm.store(persistent, bytes32(0), bytes32(uint256(42))); + vm.makePersistent(persistent); + uint256 second = vm.createSelectFork(url, target); + require(value() == 9, "new fork inherited local write"); + require(uint256(vm.load(persistent, bytes32(0))) == 42, "persistent state lost"); + vm.store(counter, bytes32(0), bytes32(uint256(80))); + vm.selectFork(first); + require(value() == 90, "first fork local write lost"); + vm.selectFork(second); + require(value() == 80, "second fork local write lost"); + vm.selectFork(ordinary); + require(value() == 7, "ordinary fork inherited prefix state"); + } + + function testForkBalRejectsInvalidPrefix() public { + string memory url = vm.envString("BAL_RPC_URL"); + bytes32 target = vm.envBytes32("BAL_TARGET"); + vm._expectCheatcodeRevert(); + vm.createSelectFork(url, target); + } + + function testForkBalOrdinary() public { + vm.createSelectFork(vm.envString("BAL_RPC_URL"), vm.envUint("BAL_PARENT")); + require(value() == 7, "wrong ordinary fork"); + } + + function testForkBalRepeated() public { + string memory url = vm.envString("BAL_RPC_URL"); + bytes32 target = vm.envBytes32("BAL_TARGET"); + for (uint256 i; i < 3; ++i) { + vm.createSelectFork(url, target); + require(value() == 9, "wrong repeated prefix state"); + vm.store(counter, bytes32(0), bytes32(uint256(90))); + } + } + + function testForkBalSeparateSources() public { + string memory first = vm.envString("BAL_RPC_URL"); + string memory second = vm.envString("BAL_OTHER_RPC_URL"); + bytes32 target = vm.envBytes32("BAL_TARGET"); + for (uint256 i; i < 2; ++i) { + vm.createSelectFork(first, target); + require(value() == 9, "wrong first source prefix state"); + vm.createSelectFork(second, target); + require(value() == 9, "wrong second source prefix state"); + } + } +} +"#; + +fn command<'a>( + cmd: &'a mut TestCommand, + fixture: &Fixture, + proxy: &Proxy, + target: B256, + expected: u64, + mode: u64, + test: &str, +) -> &'a mut TestCommand { + let parent = u64::from_str_radix( + fixture.parent["number"].as_str().unwrap().trim_start_matches("0x"), + 16, + ) + .unwrap(); + cmd.forge_fuse(); + cmd.cmd().env_remove("FOUNDRY_NO_FORK_BAL"); + cmd.env("BAL_RPC_URL", &proxy.endpoint); + cmd.env("BAL_TARGET", target.to_string()); + cmd.env("BAL_PARENT", parent.to_string()); + cmd.env("BAL_MODE", mode.to_string()); + cmd.env("BAL_EXPECTED", expected.to_string()); + cmd.env("FOUNDRY_NO_STORAGE_CACHING", "true"); + cmd.env("FOUNDRY_DISABLE_NIGHTLY_WARNING", "true"); + cmd.args(["test", "--match-test", test, "--evm-version", "cancun"]) +} + +fn assert_test(cmd: &mut TestCommand, name: &str) -> u64 { + let output = cmd.assert_success().stdout_eq(format!( + "...\nRan 1 test for test/ForkBal.t.sol:ForkBalTest\n[PASS] {name}() ([GAS])\nSuite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED]\n\nRan 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests)\n", + )); + let stdout = std::str::from_utf8(&output.get_output().stdout).unwrap(); + let prefix = format!("[PASS] {name}() (gas: "); + stdout + .lines() + .find_map(|line| line.strip_prefix(&prefix).and_then(|gas| gas.strip_suffix(')'))) + .expect("unit test gas is reported") + .parse() + .unwrap() +} + +#[forgetest] +async fn fork_bal_parent_cache_preserves_prefix_boundaries(prj: _, cmd: _) { + let fixture = Fixture::new().await; + let proxy = Proxy::new(&fixture, Response::Native).await; + prj.add_test("ForkBal.t.sol", TEST); + // Mode 1 covers empty and multi-transaction prefixes. The other modes exercise their fork + // lifecycle with the final transaction, which is also the path used by the other tests. + for (mode, index) in [(1, 0), (1, 2), (0, 2), (2, 2), (3, 2)] { + let mut block_reads = Vec::new(); + let mut gas_used = Vec::new(); + for disabled in [false, true] { + proxy.clear(); + command( + &mut cmd, + &fixture, + &proxy, + fixture.transactions[index], + 7 + index as u64, + mode, + r"^testForkBal\(\)$", + ); + if disabled { + cmd.arg("--no-fork-bal"); + } + gas_used.push(assert_test(&mut cmd, "testForkBal")); + block_reads.push(proxy.count("eth_getBlockByHash")); + if disabled { + assert_eq!(proxy.count(BAL_METHOD), 0); + assert!(proxy.slot_reads(U256::ZERO) > 0); + } else { + proxy.assert_parent_bal(&fixture); + assert_eq!(proxy.slot_reads(U256::ZERO), 0, "mode={mode}, index={index}"); + } + assert!(proxy.slot_reads(U256::ONE) > 0, "read-only slots need RPC fallback"); + } + assert_eq!(block_reads[0], block_reads[1], "BAL fetched an extra block: mode={mode}"); + assert_eq!(gas_used[0], gas_used[1], "BAL changed gas: mode={mode}, index={index}"); + } +} + +#[forgetest] +async fn fork_bal_keeps_local_writes_snapshots_and_persistent_accounts(prj: _, cmd: _) { + let fixture = Fixture::new().await; + let proxy = Proxy::new(&fixture, Response::Native).await; + prj.add_test("ForkBal.t.sol", TEST); + let mut gas_used = Vec::new(); + let mut probes = Vec::new(); + for disabled in [false, true] { + proxy.clear(); + command( + &mut cmd, + &fixture, + &proxy, + fixture.transactions[2], + 9, + 1, + r"^testForkBalLifecycle\(\)$", + ); + if disabled { + cmd.arg("--no-fork-bal"); + } + gas_used.push(assert_test(&mut cmd, "testForkBalLifecycle")); + probes.push(proxy.count("anvil_nodeInfo")); + if disabled { + assert_eq!(proxy.count(BAL_METHOD), 0); + } else { + proxy.assert_parent_bal(&fixture); + assert_eq!(proxy.count(BAL_METHOD), 1, "the same parent cache was prewarmed twice"); + assert_eq!(proxy.slot_reads(U256::ZERO), 0); + } + } + assert_eq!(gas_used[0], gas_used[1], "BAL changed local execution gas"); + // Only the first seed adds source probes; ordinary identity checks remain on reuse. + assert!(probes[1] > 0); + assert_eq!(probes[0], probes[1] + 2); +} + +#[forgetest] +async fn fork_bal_config_and_environment_control_runtime_requests(prj: _, cmd: _) { + let fixture = Fixture::new().await; + let proxy = Proxy::new(&fixture, Response::Native).await; + prj.add_test("ForkBal.t.sol", TEST); + prj.update_config(|config| config.no_fork_bal = true); + for (environment, enabled) in [(None, false), (Some("false"), true)] { + proxy.clear(); + command(&mut cmd, &fixture, &proxy, fixture.transactions[2], 9, 1, r"^testForkBal\(\)$"); + if let Some(environment) = environment { + cmd.env("FOUNDRY_NO_FORK_BAL", environment); + } + assert_test(&mut cmd, "testForkBal"); + if enabled { + proxy.assert_parent_bal(&fixture); + assert_eq!(proxy.slot_reads(U256::ZERO), 0); + } else { + assert_eq!(proxy.count(BAL_METHOD), 0); + assert!(proxy.slot_reads(U256::ZERO) > 0); + } + } +} + +#[forgetest] +async fn fork_bal_unusable_responses_fall_back_to_replay(prj: _, cmd: _) { + let fixture = Fixture::new().await; + prj.add_test("ForkBal.t.sol", TEST); + let baseline = Proxy::new(&fixture, Response::Native).await; + command(&mut cmd, &fixture, &baseline, fixture.transactions[2], 9, 1, r"^testForkBal\(\)$") + .arg("--no-fork-bal"); + let gas_used = assert_test(&mut cmd, "testForkBal"); + let block_reads = baseline.count("eth_getBlockByHash"); + assert_eq!(baseline.count(BAL_METHOD), 0); + assert!(baseline.slot_reads(U256::ZERO) > 0); + // Core BAL tests cover the remaining validation and source-eligibility combinations. + for mode in [ + Response::Unsupported, + Response::InvalidCode, + Response::Timeout, + Response::CumulativeTimeout, + ] { + let proxy = Proxy::new(&fixture, mode).await; + command(&mut cmd, &fixture, &proxy, fixture.transactions[2], 9, 1, r"^testForkBal\(\)$"); + let started = Instant::now(); + assert_eq!(assert_test(&mut cmd, "testForkBal"), gas_used, "BAL changed gas: {mode:?}"); + assert!( + started.elapsed() < Duration::from_secs(20), + "optional BAL request delayed the fork: {mode:?}" + ); + proxy.assert_parent_bal(&fixture); + assert!(proxy.slot_reads(U256::ZERO) > 0, "invalid BAL was used: {mode:?}"); + assert_eq!(proxy.count("eth_getBlockByHash"), block_reads, "extra block read: {mode:?}"); + } +} + +#[forgetest] +async fn fork_bal_skips_ineligible_ordinary_and_pending_forks(prj: _, cmd: _) { + let fixture = Fixture::new().await; + prj.add_test("ForkBal.t.sol", TEST); + let proxy = Proxy::new(&fixture, Response::Anvil).await; + command(&mut cmd, &fixture, &proxy, fixture.transactions[0], 7, 1, r"^testForkBal\(\)$"); + assert_test(&mut cmd, "testForkBal"); + assert_eq!(proxy.count(BAL_METHOD), 0, "mutable Anvil source"); + let proxy = Proxy::new(&fixture, Response::Native).await; + command( + &mut cmd, + &fixture, + &proxy, + fixture.transactions[2], + 7, + 1, + r"^testForkBalOrdinary\(\)$", + ); + assert_test(&mut cmd, "testForkBalOrdinary"); + assert_eq!(proxy.count(BAL_METHOD), 0); + proxy.clear(); + command(&mut cmd, &fixture, &proxy, fixture.pending, 10, 1, r"^testForkBal\(\)$"); + assert_test(&mut cmd, "testForkBal"); + assert_eq!(proxy.count(BAL_METHOD), 0); +} + +#[forgetest] +async fn fork_bal_preserves_prefix_transaction_validation(prj: _, cmd: _) { + let fixture = Fixture::new().await; + let proxy = Proxy::new(&fixture, Response::InvalidPrefix).await; + prj.add_test("ForkBal.t.sol", TEST); + command( + &mut cmd, + &fixture, + &proxy, + fixture.transactions[2], + 9, + 1, + r"^testForkBalRejectsInvalidPrefix\(\)$", + ); + assert_test(&mut cmd, "testForkBalRejectsInvalidPrefix"); + proxy.assert_parent_bal(&fixture); +} + +#[forgetest] +async fn fork_bal_retries_unavailable_parent_seed(prj: _, cmd: _) { + let fixture = Fixture::new().await; + let proxy = Proxy::new(&fixture, Response::NullOnce).await; + prj.add_test("ForkBal.t.sol", TEST); + command( + &mut cmd, + &fixture, + &proxy, + fixture.transactions[2], + 9, + 1, + r"^testForkBalRepeated\(\)$", + ); + assert_test(&mut cmd, "testForkBalRepeated"); + proxy.assert_parent_bal(&fixture); + assert_eq!(proxy.count(BAL_METHOD), 2, "unavailable BAL must retry, then reuse its success"); +} + +#[forgetest] +async fn fork_bal_reuses_parent_seed_only_for_the_same_source(prj: _, cmd: _) { + let fixture = Fixture::new().await; + let first = Proxy::new(&fixture, Response::Native).await; + let second = Proxy::new(&fixture, Response::Native).await; + prj.add_test("ForkBal.t.sol", TEST); + command( + &mut cmd, + &fixture, + &first, + fixture.transactions[2], + 9, + 1, + r"^testForkBalSeparateSources\(\)$", + ) + .env("BAL_OTHER_RPC_URL", &second.endpoint); + assert_test(&mut cmd, "testForkBalSeparateSources"); + for proxy in [&first, &second] { + proxy.assert_parent_bal(&fixture); + assert_eq!(proxy.count(BAL_METHOD), 1, "each source must prepare its own parent seed"); + assert_eq!(proxy.slot_reads(U256::ZERO), 0); + } +} + +#[forgetest] +async fn fork_bal_inline_config_controls_runtime_requests(prj: _, cmd: _) { + let fixture = Fixture::new().await; + let proxy = Proxy::new(&fixture, Response::Native).await; + for (config_disabled, contract_override, function_override) in [ + (false, None, Some(true)), + (false, Some(true), None), + (true, None, Some(false)), + (true, Some(false), None), + (false, Some(true), Some(false)), + (true, Some(false), Some(true)), + ] { + prj.update_config(|config| config.no_fork_bal = config_disabled); + let mut source = TEST.to_owned(); + if let Some(disabled) = contract_override { + source = source.replace( + "contract ForkBalTest {", + &format!( + "/// forge-config: default.no_fork_bal = {disabled}\ncontract ForkBalTest {{" + ), + ); + } + if let Some(disabled) = function_override { + source = source.replace( + " function testForkBal() public {", + &format!( + " /// forge-config: default.no_fork_bal = {disabled}\n function testForkBal() public {{" + ), + ); + } + prj.add_test("ForkBal.t.sol", &source); + let disabled = function_override.or(contract_override).unwrap_or(config_disabled); + for mode in 0..=3 { + proxy.clear(); + command( + &mut cmd, + &fixture, + &proxy, + fixture.transactions[2], + 9, + mode, + r"^testForkBal\(\)$", + ); + assert_test(&mut cmd, "testForkBal"); + assert_eq!( + proxy.count(BAL_METHOD), + usize::from(!disabled), + "config={config_disabled}, contract={contract_override:?}, function={function_override:?}, mode={mode}" + ); + if disabled { + assert!(proxy.slot_reads(U256::ZERO) > 0); + } else { + proxy.assert_parent_bal(&fixture); + assert_eq!(proxy.slot_reads(U256::ZERO), 0); + } + } + } +} + +#[forgetest] +async fn fork_bal_setup_forks_keep_creation_policy_on_roll(prj: _, cmd: _) { + let fixture = Fixture::new().await; + let proxy = Proxy::new(&fixture, Response::Native).await; + for disabled in [false, true] { + prj.update_config(|config| config.no_fork_bal = disabled); + let source = TEST.replace( + " function testForkBal() public {", + &format!( + r#" + uint256 setupActive; + uint256 setupInactive; + + function setUp() public {{ + string memory url = vm.envString("BAL_RPC_URL"); + uint256 parent = vm.envUint("BAL_PARENT"); + setupActive = vm.createSelectFork(url, parent); + setupInactive = vm.createFork(url, parent); + }} + + /// forge-config: default.no_fork_bal = {} + function testForkBalSetupRoll() public {{ + bytes32 target = vm.envBytes32("BAL_TARGET"); + if (vm.envUint("BAL_MODE") == 2) {{ + vm.rollFork(target); + }} else {{ + vm.rollFork(setupInactive, target); + require(vm.activeFork() == setupActive, "inactive roll selected the fork"); + require(value() == 7, "inactive roll changed active state"); + vm.selectFork(setupInactive); + }} + require(value() == 9, "wrong prefix state"); + require(uint256(vm.load(counter, bytes32(uint256(1)))) == 19, "lost read-only slot"); + }} + + function testForkBal() public {{"#, + !disabled + ), + ); + prj.add_test("ForkBal.t.sol", &source); + for mode in [2, 3] { + proxy.clear(); + command( + &mut cmd, + &fixture, + &proxy, + fixture.transactions[2], + 9, + mode, + r"^testForkBalSetupRoll\(\)$", + ); + assert_test(&mut cmd, "testForkBalSetupRoll"); + assert_eq!( + proxy.count(BAL_METHOD), + usize::from(!disabled), + "disabled={disabled}, mode={mode}" + ); + if disabled { + assert!(proxy.slot_reads(U256::ZERO) > 0); + } else { + proxy.assert_parent_bal(&fixture); + assert_eq!(proxy.slot_reads(U256::ZERO), 0); + } + } + } +} diff --git a/crates/forge/tests/cli/test_cmd/fork_state_by_number.rs b/crates/forge/tests/cli/test_cmd/fork_state_by_number.rs new file mode 100644 index 0000000000000..ead8d27727d2d --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/fork_state_by_number.rs @@ -0,0 +1,307 @@ +//! Fork state compatibility with RPC endpoints that reject EIP-1898 block objects. + +use alloy_primitives::{B256, U256, address, bytes}; +use anvil::{NodeConfig, spawn}; +use axum::{Json, Router, extract::Path, routing::post}; +use foundry_config::{RpcEndpointUrl, RpcEndpoints}; +use parking_lot::Mutex; +use serde_json::{Value, json}; +use std::sync::Arc; + +#[forgetest] +async fn fork_state_by_number_script_and_test(prj: _, cmd: _) { + let (api, anvil) = spawn(NodeConfig::test().with_no_mining(true)).await; + let target = address!("0000000000000000000000000000000000001234"); + api.anvil_set_balance(target, U256::from(42)).await.unwrap(); + api.anvil_set_code(target, bytes!("00")).await.unwrap(); + api.anvil_set_storage_at(target, U256::ZERO, B256::from(U256::from(7))).await.unwrap(); + api.mine_one().await.unwrap(); + let sender = anvil.dev_wallets().next().unwrap().address().to_string(); + let upstream = anvil.http_endpoint(); + let requests = Arc::new(Mutex::new(Vec::new())); + let recorded = Arc::clone(&requests); + let app = Router::new().route( + "/{mode}", + post(move |Path(mode): Path, Json(request): Json| { + let upstream = upstream.clone(); + let recorded = Arc::clone(&recorded); + async move { + let method = request["method"].as_str().unwrap(); + // Exercise the path used by public RPC providers rather than Anvil discovery. + if matches!(method, "anvil_nodeInfo" | "anvil_metadata") { + return Json(json!({"jsonrpc": "2.0", "id": request["id"], + "error": {"code": -32601, "message": "method not found"}})); + } + if matches!( + method, + "eth_getBalance" + | "eth_getTransactionCount" + | "eth_getCode" + | "eth_getStorageAt" + ) { + recorded.lock().push(request.clone()); + if mode == "number" + && !request["params"].as_array().unwrap().last().unwrap().is_string() + { + return Json(json!({"jsonrpc": "2.0", "id": request["id"], + "error": {"code": -32602, "message": "block objects unsupported"}})); + } + } + Json( + reqwest::Client::new() + .post(upstream) + .json(&request) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(), + ) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("http://{}/number", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + prj.add_source( + "Probe.sol", + r#" +interface Vm { + function load(address, bytes32) external view returns (bytes32); + function startBroadcast() external; + function stopBroadcast() external; + function createSelectFork(string calldata, uint256) external returns (uint256); + function createSelectFork(string calldata, bytes32) external returns (uint256); + function rollFork(bytes32) external; + function envBytes32(string calldata) external view returns (bytes32); + function envString(string calldata) external view returns (string memory); + function rollFork(uint256) external; + function rpcUrl(string calldata) external view returns (string memory); + function expectRevert(bytes calldata) external; + function transact(bytes32) external; + function transact(uint256, bytes32) external; + function createFork(string calldata, bytes32) external returns (uint256); + function selectFork(uint256) external; +} +contract Counter { + uint256 public n; + function inc() external { n++; } +} +contract Probe { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + function checkState() internal view { + require(address(0x1234).balance == 42, "balance"); + require(address(0x1234).code.length == 1, "code"); + require(uint256(vm.load(address(0x1234), bytes32(0))) == 7, "storage"); + } + function testForkState() public view { checkState(); } + function testForkCheatcodes() public { + vm.createSelectFork(vm.rpcUrl("relay"), 1); + checkState(); + vm.rollFork(1); + checkState(); + } + function testForkReplayRejectsNumberState() public { + vm.expectRevert(bytes("vm.transact: transaction replay requires hash-addressed state; create a transaction-targeted fork or disable fork_state_by_number")); + this.replay(); + } + function replay() external { vm.transact(bytes32(0)); } + function run() public { + checkState(); + vm.startBroadcast(); + new Counter().inc(); + vm.stopBroadcast(); + } +} +"#, + ); + prj.update_config(|config| { + config.no_storage_caching = true; + config.rpc_endpoints = + RpcEndpoints::new([("relay", RpcEndpointUrl::Url(endpoint.clone()))]); + }); + // The default still uses block hashes, which this relay rejects. + cmd.args(["script", "src/Probe.sol:Probe", "--rpc-url", &endpoint, "--sender", &sender]) + .assert_failure(); + assert!(requests.lock().iter().any(|request| { + request["method"] == "eth_getTransactionCount" && request["params"][1].is_object() + })); + for configured in [false, true] { + prj.update_config(|config| config.fork_state_by_number = configured); + for script in [false, true] { + requests.lock().clear(); + let mut command = prj.forge_command(); + if script { + command.args(["script", "src/Probe.sol:Probe", "--sender", &sender]); + } else { + command.args(["test", "--match-contract", "Probe"]); + } + command.args(["--rpc-url", &endpoint]); + if configured { + command.args(["--fork-block-number", "1"]); + } + if !configured { + command.arg("--fork-state-by-number"); + } + command.assert_success(); + let requests = requests.lock(); + for method in + ["eth_getBalance", "eth_getTransactionCount", "eth_getCode", "eth_getStorageAt"] + { + assert!( + requests.iter().any(|request| request["method"] == method), + "missing {method}" + ); + } + for request in requests.iter() { + assert_eq!( + request["params"].as_array().unwrap().last().unwrap(), + "0x1", + "{request}" + ); + } + } + } + // Also inherit the setting when the first fork is created by a cheatcode. + prj.forge_command().args(["test", "--match-test", "testForkCheatcodes"]).assert_success(); + // Inline config controls cheatcode-created forks in both directions. + let hash_endpoint = endpoint.replace("/number", "/hash"); + for (configured, inline) in [(false, true), (true, false)] { + prj.update_config(|config| config.fork_state_by_number = configured); + prj.add_test( + "Inline.t.sol", + &format!( + r#" +import {{Probe}} from "../src/Probe.sol"; +contract InlineProbe is Probe {{ + /// forge-config: default.fork_state_by_number = {inline} + function testForkInlineConfig() public {{ + vm.createSelectFork(vm.envString("HASH_RPC_URL"), 1); + checkState(); + }} +}} +"# + ), + ); + requests.lock().clear(); + let mut command = prj.forge_command(); + command.env("HASH_RPC_URL", &hash_endpoint); + command.args(["test", "--match-test", "testForkInlineConfig"]).assert_success(); + let reads = requests.lock(); + let target_reads = reads + .iter() + .filter(|request| request["params"][0] == json!(target)) + .collect::>(); + assert!(!target_reads.is_empty()); + for request in target_reads { + let block = request["params"].as_array().unwrap().last().unwrap(); + assert_eq!(block.is_string(), inline, "configured={configured}, {request}"); + } + } + prj.update_config(|config| config.fork_state_by_number = true); + // Numbered rolls must move between blocks with different state, not stay pinned or follow head. + // Anvil applies state overrides to the latest block, so mine first: block 2 = 9, head = 11. + api.mine_one().await.unwrap(); + api.anvil_set_storage_at(target, U256::from(1), B256::from(U256::from(9))).await.unwrap(); + api.mine_one().await.unwrap(); + api.anvil_set_storage_at(target, U256::from(1), B256::from(U256::from(11))).await.unwrap(); + prj.add_test( + "Roll.t.sol", + r#" +import {Probe} from "../src/Probe.sol"; +contract RollProbe is Probe { + function slot1() internal view returns (uint256) { + return uint256(vm.load(address(0x1234), bytes32(uint256(1)))); + } + function testForkRollCrossesBlocks() public { + vm.createSelectFork(vm.rpcUrl("relay"), 1); + checkState(); + require(slot1() == 0, "pinned block"); + vm.rollFork(2); + checkState(); + require(slot1() == 9, "rolled forward"); + vm.rollFork(1); + require(slot1() == 0, "rolled back"); + } +} +"#, + ); + requests.lock().clear(); + prj.forge_command() + .args(["test", "--match-test", "testForkRollCrossesBlocks"]) + .assert_success(); + let blocks = requests + .lock() + .iter() + .filter(|request| request["method"] == "eth_getStorageAt") + .map(|request| request["params"].as_array().unwrap().last().unwrap().clone()) + .collect::>(); + assert!(blocks.contains(&json!("0x1")) && blocks.contains(&json!("0x2")), "{blocks:?}"); + assert!(blocks.iter().all(|block| block == "0x1" || block == "0x2"), "{blocks:?}"); + // Transaction-targeted creation and rolling still use hashes even with the opt-in enabled. + let transaction = api + .send_transaction( + serde_json::from_value(json!({ + "from": sender, "to": target, "gas": "0x186a0", "gasPrice": "0x77359400" + })) + .unwrap(), + ) + .await + .unwrap(); + api.mine_one().await.unwrap(); + prj.add_test( + "Replay.t.sol", + r#" +import {Probe} from "../src/Probe.sol"; +contract ReplayProbe is Probe { + function testForkTransactionTargetsUseHashes() public { + string memory url = vm.envString("HASH_RPC_URL"); + bytes32 transaction = vm.envBytes32("TARGET_TRANSACTION"); + vm.createSelectFork(url, transaction); + checkState(); + vm.transact(transaction); + vm.createSelectFork(url, 1); + vm.rollFork(transaction); + checkState(); + } + function testForkTransactTargetsExplicitFork() public { + bytes32 transaction = vm.envBytes32("TARGET_TRANSACTION"); + uint256 hashFork = vm.createFork(vm.envString("HASH_RPC_URL"), transaction); + uint256 numberFork = vm.createSelectFork(vm.rpcUrl("relay"), 1); + // The explicit hash-addressed target is accepted while a number-addressed fork is active. + vm.transact(hashFork, transaction); + vm.selectFork(hashFork); + vm.expectRevert(bytes("vm.transact: transaction replay requires hash-addressed state; create a transaction-targeted fork or disable fork_state_by_number")); + this.replayOn(numberFork, transaction); + } + function replayOn(uint256 forkId, bytes32 transaction) external { + vm.transact(forkId, transaction); + } +} +"#, + ); + requests.lock().clear(); + let mut command = prj.forge_command(); + command.env("HASH_RPC_URL", &hash_endpoint); + command.env("TARGET_TRANSACTION", transaction.to_string()); + command.args(["test", "--match-test", "testForkTransactionTargetsUseHashes"]).assert_success(); + let reads = requests.lock(); + let target_reads = + reads.iter().filter(|request| request["params"][0] == json!(target)).collect::>(); + assert!(!target_reads.is_empty()); + assert!( + target_reads.iter().all(|request| request["params"] + .as_array() + .unwrap() + .last() + .unwrap() + .is_object()) + ); + drop(reads); + let mut command = prj.forge_command(); + command.env("HASH_RPC_URL", &hash_endpoint); + command.env("TARGET_TRANSACTION", transaction.to_string()); + command.args(["test", "--match-test", "testForkTransactTargetsExplicitFork"]).assert_success(); + server.abort(); +} diff --git a/crates/forge/tests/cli/test_cmd/fuzz.rs b/crates/forge/tests/cli/test_cmd/fuzz.rs new file mode 100644 index 0000000000000..15a7eb228d42b --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/fuzz.rs @@ -0,0 +1,6320 @@ +use alloy_dyn_abi::{DynSolValue, JsonAbiExt}; +use alloy_json_abi::JsonAbi; +use alloy_primitives::{Address, B256, U256, bytes, hex, keccak256}; +use anvil::{NodeConfig, spawn}; +use foundry_config::fs_permissions::PathPermission; +use foundry_evm::fuzz::BaseCounterExample; +use foundry_test_utils::{TestCommand, forgetest_init, str}; +use regex::Regex; +use serde_json::Value; +use std::{collections::BTreeSet, path::Path}; + +const DEFAULT_SENDER: &str = "0x0000000000000000000000000000000000000001"; +const DEFAULT_TEST_TARGET: &str = "0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496"; + +fn find_first_json(root: &std::path::Path) -> std::path::PathBuf { + find_json_files(root) + .into_iter() + .next() + .unwrap_or_else(|| panic!("no json corpus entry under {}", root.display())) +} + +fn find_json_files(root: &std::path::Path) -> Vec { + let mut dirs = vec![root.to_path_buf()]; + let mut files = Vec::new(); + while let Some(dir) = dirs.pop() { + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + dirs.push(path); + } else if path.extension().is_some_and(|extension| extension == "json") { + files.push(path); + } + } + } + files.sort(); + files +} + +fn artifact_abi(root: &Path, artifact: &str) -> JsonAbi { + let artifact = std::fs::read_to_string(root.join(artifact)).unwrap(); + let artifact: Value = serde_json::from_str(&artifact).unwrap(); + serde_json::from_value(artifact["abi"].clone()).unwrap() +} + +fn calldata_for(abi: &JsonAbi, function_name: &str, arg: u64) -> String { + let function = abi.functions().find(|function| function.name == function_name).unwrap(); + format!("{:#x}{:064x}", function.selector(), arg) +} + +fn calldata_for_args(abi: &JsonAbi, function_name: &str, args: &[DynSolValue]) -> String { + let function = abi.functions().find(|function| function.name == function_name).unwrap(); + hex::encode_prefixed(function.abi_encode_input(args).unwrap()) +} + +fn output_calldata_args( + root: &Path, + output: &str, + abi: &JsonAbi, + function_name: &str, +) -> Vec { + let output: Value = + serde_json::from_str(&std::fs::read_to_string(root.join(output)).unwrap()).unwrap(); + let calldata = output[0]["calldata"].as_str().unwrap().trim_start_matches("0x"); + let calldata = hex::decode(calldata).unwrap(); + let function = abi.functions().find(|function| function.name == function_name).unwrap(); + function.abi_decode_input(&calldata[4..]).unwrap() +} + +fn corpus_entry(calldata: &str) -> String { + corpus_sequence_entry(&[calldata]) +} + +fn corpus_sequence_entry(calldatas: &[&str]) -> String { + let entries = calldatas + .iter() + .map(|calldata| { + format!( + r#"{{ + "sender":"{DEFAULT_SENDER}", + "target":"{DEFAULT_TEST_TARGET}", + "calldata":"{calldata}", + "value":"0x0" +}}"# + ) + }) + .collect::>() + .join(","); + format!( + r#"[ +{entries} +]"# + ) +} + +fn write_corpus_entry(corpus: &Path, name: &str, calldata: &str) { + std::fs::write(corpus.join(name), corpus_entry(calldata)).unwrap(); +} + +fn write_corpus_sequence_entry(corpus: &Path, name: &str, calldatas: &[&str]) { + std::fs::write(corpus.join(name), corpus_sequence_entry(calldatas)).unwrap(); +} + +fn has_regular_file(root: &Path) -> bool { + root.exists() + && std::fs::read_dir(root).unwrap().any(|entry| { + let path = entry.unwrap().path(); + path.is_file() || (path.is_dir() && has_regular_file(&path)) + }) +} + +fn regular_file_count(root: &Path) -> usize { + if !root.exists() { + return 0; + } + std::fs::read_dir(root) + .unwrap() + .map(|entry| entry.unwrap().path()) + .map(|path| if path.is_dir() { regular_file_count(&path) } else { 1 }) + .sum() +} + +fn showmap_edge_ids(root: &Path) -> BTreeSet { + fn visit(path: &Path, out: &mut BTreeSet) { + if path.is_dir() { + for entry in std::fs::read_dir(path).unwrap() { + visit(&entry.unwrap().path(), out); + } + return; + } + if path.extension().is_none_or(|extension| extension != "txt") { + return; + } + let body = std::fs::read_to_string(path).unwrap(); + for line in body.lines() { + let (id, _) = line.split_once(':').unwrap_or_else(|| panic!("malformed {line}")); + out.insert(id.to_string()); + } + } + + let mut edges = BTreeSet::new(); + visit(root, &mut edges); + edges +} + +#[forgetest_init] +fn test_can_scrape_bytecode(prj: _, cmd: _) { + prj.update_config(|config| config.optimizer = Some(true)); + prj.add_source( + "FuzzerDict.sol", + r#" +// https://github.com/foundry-rs/foundry/issues/1168 +contract FuzzerDict { + // Immutables should get added to the dictionary. + address public immutable immutableOwner; + // Regular storage variables should also get added to the dictionary. + address public storageOwner; + + constructor(address _immutableOwner, address _storageOwner) { + immutableOwner = _immutableOwner; + storageOwner = _storageOwner; + } +} + "#, + ); + + prj.add_test( + "FuzzerDictTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import "src/FuzzerDict.sol"; + +contract FuzzerDictTest is Test { + FuzzerDict fuzzerDict; + + function setUp() public { + fuzzerDict = new FuzzerDict(address(100), address(200)); + } + + /// forge-config: default.fuzz.runs = 2000 + function testImmutableOwner(address who) public { + assertTrue(who != fuzzerDict.immutableOwner()); + } + + /// forge-config: default.fuzz.runs = 2000 + function testStorageOwner(address who) public { + assertTrue(who != fuzzerDict.storageOwner()); + } +} + "#, + ); + + // Test that immutable address is used as fuzzed input, causing test to fail. + cmd.args(["test", "--fuzz-seed", "119", "--mt", "testImmutableOwner"]).assert_failure(); + // Test that storage address is used as fuzzed input, causing test to fail. + cmd.forge_fuse() + .args(["test", "--fuzz-seed", "119", "--mt", "testStorageOwner"]) + .assert_failure().stdout_eq(str![[r#" +... +Ran 1 test for test/FuzzerDictTest.t.sol:FuzzerDictTest +[FAIL: assertion failed; counterexample: calldata=0x5f9789a200000000000000000000000000000000000000000000000000000000000000c8 args=[0x00000000000000000000000000000000000000C8]] testStorageOwner(address) (runs: [..], [AVG_GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn forge_fuzz_run_skips_unit_tests(prj: _, cmd: _) { + prj.add_test( + "ForgeFuzzRun.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract ForgeFuzzRunTest is Test { + function test_unit() public pure {} + + /// forge-config: default.fuzz.runs = 2 + function testFuzz_value(uint256 value) public pure { + value; + } +} + "#, + ); + + cmd.args(["fuzz", "run", "--mc", "ForgeFuzzRunTest"]).assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 2 tests for test/ForgeFuzzRun.t.sol:ForgeFuzzRunTest +[PASS] testFuzz_value(uint256) (runs: 2, [AVG_GAS]) +[SKIP: not runnable in fuzz mode] test_unit() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 1 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 1 skipped (2 total tests) + +"#]]); +} + +#[forgetest_init] +fn does_not_evaluate_unused_fuzz_fixtures_for_unit_test_filter(prj: _, cmd: _) { + let marker = prj.root().join("fixture-called.txt"); + prj.update_config(|config| config.fs_permissions.add(PathPermission::write(prj.root()))); + prj.add_test( + "UnusedFuzzFixtures.t.sol", + r#" +interface Vm { + function projectRoot() external view returns (string memory path); + function writeFile(string calldata path, string calldata data) external; +} + +contract UnusedFuzzFixturesTest { + Vm internal constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + function fixtureAmount() public returns (uint256[] memory values) { + vm.writeFile(string.concat(vm.projectRoot(), "/fixture-called.txt"), "called"); + values = new uint256[](1); + values[0] = 1; + } + + function testUnit() public pure {} + + /// forge-config: default.fuzz.runs = 1 + function testFuzzUsesFixture(uint256 amount) public pure { + amount; + } +} + "#, + ); + + cmd.args(["test", "--match-test", "testUnit", "-q"]).assert_success(); + assert!(!marker.exists(), "unit-only run evaluated fuzz fixture"); + + cmd.forge_fuse(); + cmd.args(["test", "--match-test", "testFuzzUsesFixture", "-q"]).assert_success(); + assert!(marker.exists(), "fuzz run did not evaluate fuzz fixture"); +} + +#[forgetest_init] +fn forge_fuzz_skips_unit_only_failing_setup(prj: _, cmd: _) { + prj.add_test( + "ForgeFuzzUnitOnly.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract ForgeFuzzUnitOnlyTest is Test { + function setUp() public pure { + revert("setUp should not run"); + } + + function test_unit() public pure {} +} + "#, + ); + + let run = + cmd.forge_fuse().args(["fuzz", "run", "--mc", "ForgeFuzzUnitOnlyTest"]).assert_success(); + let stdout = String::from_utf8(run.get_output().stdout.clone()).unwrap(); + assert!(!stdout.contains("setUp should not run"), "{stdout}"); + + let replay = + cmd.forge_fuse().args(["fuzz", "replay", "--mc", "ForgeFuzzUnitOnlyTest"]).assert_success(); + let stdout = String::from_utf8(replay.get_output().stdout.clone()).unwrap(); + assert!(!stdout.contains("setUp should not run"), "{stdout}"); +} + +#[forgetest_init] +fn forge_fuzz_replay_reports_missing_corpus(prj: _, cmd: _) { + prj.add_test( + "ForgeFuzzReplay.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract ForgeFuzzReplayTest is Test { + function test_unit() public pure {} + + function testFuzz_value(uint256 value) public pure { + value; + } +} + "#, + ); + + cmd.args(["fuzz", "replay", "--mc", "ForgeFuzzReplayTest"]).assert_success().stdout_eq(str![[ + r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 2 tests for test/ForgeFuzzReplay.t.sol:ForgeFuzzReplayTest +[SKIP: no persisted fuzz failure found at cache/fuzz/failures/ForgeFuzzReplayTest/testFuzz_value] testFuzz_value(uint256) (runs: 0, [AVG_GAS]) +[SKIP: not runnable in replay mode] test_unit() ([GAS]) +Suite result: ok. 0 passed; 0 failed; 2 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 0 failed, 2 skipped (2 total tests) + +"# + ]]); +} + +#[forgetest_init] +fn overloaded_fuzz_tests_use_distinct_paths(prj: _, cmd: _) { + let corpus_root = prj.root().join("overloaded-corpus"); + prj.update_config(|config| { + config.fuzz.runs = 1; + config.fuzz.seed = Some(U256::ONE); + config.fuzz.corpus.corpus_dir = Some(corpus_root.clone()); + }); + prj.add_test( + "OverloadedFuzz.t.sol", + r#" +contract OverloadedFuzzTest { + function testFuzz_collision(address) external pure { + revert("ADDRESS_BUG"); + } + + function testFuzz_collision(uint256) external pure { + revert("UINT_BUG"); + } +} + "#, + ); + + cmd.args(["test", "--mc", "OverloadedFuzzTest", "-j1"]).assert_failure(); + + let failure_root = prj.root().join("cache/fuzz/failures/OverloadedFuzzTest"); + let address_name = format!( + "testFuzz_collision-{}", + hex::encode(&keccak256("testFuzz_collision(address)")[..4]) + ); + let uint_name = format!( + "testFuzz_collision-{}", + hex::encode(&keccak256("testFuzz_collision(uint256)")[..4]) + ); + let address_failure = failure_root.join(&address_name); + let uint_failure = failure_root.join(&uint_name); + assert!(address_failure.exists()); + assert!(uint_failure.exists()); + assert!(!failure_root.join("testFuzz_collision").exists()); + assert!(corpus_root.join("OverloadedFuzzTest").join(&address_name).exists()); + assert!(corpus_root.join("OverloadedFuzzTest").join(&uint_name).exists()); + + let address_corpus = corpus_root.join("OverloadedFuzzTest").join(&address_name); + let legacy_corpus = corpus_root.join("OverloadedFuzzTest/testFuzz_collision"); + std::fs::rename(&address_corpus, &legacy_corpus).unwrap(); + + let legacy_showmap_root = prj.root().join("legacy-showmap"); + cmd.forge_fuse() + .args([ + "test", + "--mc", + "OverloadedFuzzTest", + "--mt", + r"^testFuzz_collision\(address\)$", + "--showmap-out", + "legacy-showmap", + "--showmap-trial", + "legacy", + "-j1", + ]) + .assert_success(); + let showmap_prefix = "replay__test_OverloadedFuzz.t.sol_OverloadedFuzzTest__"; + assert!( + legacy_showmap_root.join(format!("{showmap_prefix}{address_name}/legacy.txt")).exists() + ); + + let override_corpus = prj.root().join("override-corpus/OverloadedFuzzTest/testFuzz_collision"); + std::fs::create_dir_all(override_corpus.parent().unwrap()).unwrap(); + std::fs::rename(&legacy_corpus, &override_corpus).unwrap(); + cmd.forge_fuse() + .args([ + "test", + "--mc", + "OverloadedFuzzTest", + "--mt", + r"^testFuzz_collision\(address\)$", + "--showmap-out", + "override-showmap", + "--showmap-corpus-dir", + "override-corpus", + "--showmap-trial", + "override", + "-j1", + ]) + .assert_success(); + assert!( + prj.root() + .join("override-showmap") + .join(format!("{showmap_prefix}{address_name}/override.txt")) + .exists() + ); + std::fs::rename(&override_corpus, &legacy_corpus).unwrap(); + + cmd.forge_fuse() + .args([ + "test", + "--mc", + "OverloadedFuzzTest", + "--mt", + r"^testFuzz_collision\(address\)$", + "-j1", + ]) + .assert_failure(); + assert!(address_corpus.exists()); + assert!(legacy_corpus.exists()); + + let showmap_root = prj.root().join("overloaded-showmap"); + cmd.forge_fuse() + .args([ + "test", + "--mc", + "OverloadedFuzzTest", + "--mt", + "testFuzz_collision", + "--showmap-out", + "overloaded-showmap", + "--showmap-trial", + "overloaded", + "-j1", + ]) + .assert_success(); + assert!(showmap_root.join(format!("{showmap_prefix}{address_name}/overloaded.txt")).exists()); + assert!(showmap_root.join(format!("{showmap_prefix}{uint_name}/overloaded.txt")).exists()); + std::fs::remove_dir_all(&legacy_corpus).unwrap(); + + let address_replay = cmd + .forge_fuse() + .args([ + "fuzz", + "replay", + "--mc", + "OverloadedFuzzTest", + "--mt", + r"^testFuzz_collision\(address\)$", + ]) + .assert_failure(); + let stdout = String::from_utf8_lossy(&address_replay.get_output().stdout); + assert!(stdout.contains("[FAIL: ADDRESS_BUG; counterexample:"), "{stdout}"); + + let uint_replay = cmd + .forge_fuse() + .args([ + "fuzz", + "replay", + "--mc", + "OverloadedFuzzTest", + "--mt", + r"^testFuzz_collision\(uint256\)$", + ]) + .assert_failure(); + let stdout = String::from_utf8_lossy(&uint_replay.get_output().stdout); + assert!(stdout.contains("[FAIL: UINT_BUG; counterexample:"), "{stdout}"); + + prj.add_test( + "OverloadedFuzz.t.sol", + r#" +contract OverloadedFuzzTest { + function testFuzz_collision(address) external pure { + revert("ADDRESS_BUG"); + } +} + "#, + ); + let address_replay = cmd + .forge_fuse() + .args(["fuzz", "replay", "--mc", "OverloadedFuzzTest", "--mt", "testFuzz_collision"]) + .assert_failure(); + let stdout = String::from_utf8_lossy(&address_replay.get_output().stdout); + assert!(stdout.contains("[FAIL: ADDRESS_BUG; counterexample:"), "{stdout}"); + assert!(address_failure.exists()); + assert!(!failure_root.join("testFuzz_collision").exists()); + assert!(corpus_root.join("OverloadedFuzzTest").join(&address_name).exists()); + assert!(!corpus_root.join("OverloadedFuzzTest/testFuzz_collision").exists()); + + prj.add_test( + "OverloadedFuzz.t.sol", + r#" +contract OverloadedFuzzTest { + function testFuzz_collision(address) external pure { + revert("ADDRESS_BUG"); + } + + function testFuzz_collision(uint256) external pure { + revert("UINT_BUG"); + } +} + "#, + ); + std::fs::rename(&address_failure, failure_root.join("testFuzz_collision")).unwrap(); + std::fs::remove_file(&uint_failure).unwrap(); + + let mismatched_legacy_replay = cmd + .forge_fuse() + .args([ + "fuzz", + "replay", + "--mc", + "OverloadedFuzzTest", + "--mt", + r"^testFuzz_collision\(uint256\)$", + ]) + .assert_success(); + let stdout = String::from_utf8_lossy(&mismatched_legacy_replay.get_output().stdout); + assert!(stdout.contains("no persisted fuzz failure found"), "{stdout}"); + + let legacy_replay = cmd + .forge_fuse() + .args([ + "fuzz", + "replay", + "--mc", + "OverloadedFuzzTest", + "--mt", + r"^testFuzz_collision\(address\)$", + ]) + .assert_failure(); + let stdout = String::from_utf8_lossy(&legacy_replay.get_output().stdout); + assert!(stdout.contains("[FAIL: ADDRESS_BUG; counterexample:"), "{stdout}"); +} + +#[forgetest_init] +fn forge_fuzz_replays_explicit_failure_file(prj: _, cmd: _) { + prj.add_test( + "ForgeExplicitFuzzReplay.t.sol", + r#" +library ForgeExplicitFuzzReplayLibrary { + function fail() external pure { + revert("boom"); + } +} + +contract ForgeExplicitFuzzReplayTest { + function testFuzz_failure(uint256) public pure { + ForgeExplicitFuzzReplayLibrary.fail(); + } + + function testFuzz_other(uint256) public pure { + revert("other boom"); + } +} + "#, + ); + + cmd.args([ + "test", + "--match-contract", + "ForgeExplicitFuzzReplayTest", + "--match-test", + "testFuzz_failure", + "--fuzz-runs", + "1", + "--fuzz-seed", + "1", + ]) + .assert_failure(); + + let canonical = + prj.root().join("cache/fuzz/failures/ForgeExplicitFuzzReplayTest/testFuzz_failure"); + let explicit = prj.root().join("explicit-fuzz-failure.json"); + std::fs::rename(&canonical, &explicit).unwrap(); + let original = std::fs::read(&explicit).unwrap(); + let persisted: BaseCounterExample = serde_json::from_slice(&original).unwrap(); + let failing_value = U256::from_be_slice(&persisted.calldata[4..36]); + let mut other_failure = persisted.clone(); + let mut other_calldata = other_failure.calldata.to_vec(); + other_calldata[..4].copy_from_slice(&keccak256("testFuzz_other(uint256)").as_slice()[..4]); + other_failure.calldata = other_calldata.into(); + std::fs::write( + prj.root().join("cache/fuzz/failures/ForgeExplicitFuzzReplayTest/testFuzz_other"), + serde_json::to_vec(&other_failure).unwrap(), + ) + .unwrap(); + + let missing = cmd + .forge_fuse() + .args([ + "fuzz", + "replay", + "--match-contract", + "ForgeExplicitFuzzReplayTest", + "--match-test", + "testFuzz_failure", + ]) + .assert_success(); + let stdout = String::from_utf8_lossy(&missing.get_output().stdout); + assert!(stdout.contains("no persisted fuzz failure found"), "{stdout}"); + + let replay = cmd + .forge_fuse() + .args([ + "fuzz", + "replay", + "--match-contract", + "ForgeExplicitFuzzReplayTest", + "--match-test", + "testFuzz_failure", + "--fuzz-input-file", + "explicit-fuzz-failure.json", + ]) + .assert_failure(); + let stdout = String::from_utf8_lossy(&replay.get_output().stdout); + assert!(stdout.contains("[FAIL: boom; counterexample:"), "{stdout}"); + + let mismatch = cmd + .forge_fuse() + .args([ + "fuzz", + "replay", + "--match-contract", + "ForgeExplicitFuzzReplayTest", + "--match-test", + "testFuzz_other", + "--fuzz-input-file", + ]) + .arg(&explicit) + .assert_failure(); + let stderr = String::from_utf8_lossy(&mismatch.get_output().stderr); + assert!(stderr.contains("does not match any selected stateless fuzz test"), "{stderr}"); + + let short = prj.root().join("short-fuzz-failure.json"); + let mut short_failure = persisted; + short_failure.calldata = bytes!("12"); + std::fs::write(&short, serde_json::to_vec(&short_failure).unwrap()).unwrap(); + let output = cmd + .forge_fuse() + .args([ + "fuzz", + "replay", + "--match-contract", + "ForgeExplicitFuzzReplayTest", + "--match-test", + "testFuzz_failure", + "--fuzz-input-file", + ]) + .arg(&short) + .assert_failure(); + let stderr = String::from_utf8_lossy(&output.get_output().stderr); + assert!(stderr.contains("calldata shorter than a 4-byte selector"), "{stderr}"); + + for args in [ + &["test", "--list", "--fuzz-input-file"][..], + &["fuzz", "replay", "--list", "--fuzz-input-file"][..], + ] { + let output = cmd.forge_fuse().args(args).arg(&explicit).assert_failure(); + let stderr = String::from_utf8_lossy(&output.get_output().stderr); + assert!( + stderr.contains("cannot be used with") + && stderr.contains("--list") + && stderr.contains("--fuzz-input-file"), + "{stderr}" + ); + } + + prj.add_test( + "ForgeExplicitFuzzReplayDuplicate.t.sol", + r#" +contract ForgeExplicitFuzzReplayDuplicateTest { + function testFuzz_failure(uint256 value) public pure { + value; + } +} + "#, + ); + let output = cmd + .forge_fuse() + .args(["fuzz", "replay", "--match-test", "testFuzz_failure", "--fuzz-input-file"]) + .arg(&explicit) + .assert_failure(); + let stderr = String::from_utf8_lossy(&output.get_output().stderr); + assert!( + stderr.contains("matches 2 selected stateless fuzz tests; replay requires exactly one"), + "{stderr}" + ); + + let broad = cmd + .forge_fuse() + .args([ + "fuzz", + "replay", + "--match-contract", + "ForgeExplicitFuzzReplayTest", + "--fuzz-input-file", + ]) + .arg(&explicit) + .assert_failure(); + let stdout = String::from_utf8_lossy(&broad.get_output().stdout); + assert!(stdout.contains("[FAIL: boom; counterexample:"), "{stdout}"); + assert!(stdout.contains("[FAIL: other boom; counterexample:"), "{stdout}"); + + prj.add_test( + "ForgeExplicitFuzzReplay.t.sol", + &format!( + r#" +contract ForgeExplicitFuzzReplayTest {{ + function testFuzz_failure(uint256 value) public pure {{ + require(value != {failing_value}, "boom"); + }} + + function testFuzz_other(uint256 value) public pure {{ + value; + }} +}} + "# + ), + ); + + cmd.forge_fuse() + .args([ + "test", + "--match-contract", + "ForgeExplicitFuzzReplayTest", + "--match-test", + "testFuzz_failure", + "--fuzz-runs", + "1", + "--fuzz-seed", + "2", + "--fuzz-input-file", + ]) + .arg(&explicit) + .assert_failure(); + assert_eq!(std::fs::read(&explicit).unwrap(), original); + assert!(canonical.is_file()); + + let missing_explicit = prj.root().join("missing-fuzz-failure.json"); + let output = cmd + .forge_fuse() + .args([ + "fuzz", + "replay", + "--match-contract", + "ForgeExplicitFuzzReplayTest", + "--match-test", + "testFuzz_failure", + "--fuzz-input-file", + ]) + .arg(&missing_explicit) + .assert_failure(); + let stderr = String::from_utf8_lossy(&output.get_output().stderr); + let stderr = stderr.replace('\\', "/").replace("//", "/"); + assert!( + stderr.contains(&missing_explicit.display().to_string().replace('\\', "/")), + "{stderr}" + ); + + let malformed = prj.root().join("malformed-fuzz-failure.json"); + std::fs::write(&malformed, "not json").unwrap(); + let output = cmd + .forge_fuse() + .args([ + "fuzz", + "replay", + "--match-contract", + "ForgeExplicitFuzzReplayTest", + "--match-test", + "testFuzz_failure", + "--fuzz-input-file", + ]) + .arg(&malformed) + .assert_failure(); + let stderr = String::from_utf8_lossy(&output.get_output().stderr); + let stderr = stderr.replace('\\', "/").replace("//", "/"); + assert!(stderr.contains(&malformed.display().to_string().replace('\\', "/")), "{stderr}"); + + let output = cmd + .forge_fuse() + .args(["test", "--fuzz-run", "1", "--fuzz-input-file"]) + .arg(&explicit) + .assert_failure(); + let stderr = String::from_utf8_lossy(&output.get_output().stderr); + assert!(stderr.contains("cannot be used with '--fuzz-input-file"), "{stderr}"); + + prj.update_config(|config| config.fuzz.run = Some(1)); + let output = cmd + .forge_fuse() + .args([ + "test", + "--match-contract", + "ForgeExplicitFuzzReplayTest", + "--match-test", + "testFuzz_failure", + "--fuzz-input-file", + ]) + .arg(&explicit) + .assert_failure(); + let stdout = String::from_utf8_lossy(&output.get_output().stdout); + assert!(stdout.contains("cannot be combined with `fuzz.run`"), "{stdout}"); + + prj.update_config(|config| config.fuzz.run = None); + std::fs::write( + prj.root().join("cache/test-failures"), + r#"{"version":1,"failures":[{"contract":"test/ForgeExplicitFuzzReplay.t.sol:ForgeExplicitFuzzReplayTest","test":"testFuzz_other(uint256)"}]}"#, + ) + .unwrap(); + let output = cmd + .forge_fuse() + .args(["test", "--rerun", "--fuzz-input-file"]) + .arg(&explicit) + .assert_failure(); + let stderr = String::from_utf8_lossy(&output.get_output().stderr); + assert!(stderr.contains("does not match any selected stateless fuzz test"), "{stderr}"); +} + +#[forgetest_init] +fn forge_fuzz_replay_rejects_multiple_input_sources(cmd: _) { + let output = cmd + .args(["fuzz", "replay", "--corpus-dir", "corpus", "--fuzz-input-file", "failure.json"]) + .assert_failure(); + let stderr = String::from_utf8_lossy(&output.get_output().stderr); + assert!(stderr.contains("cannot be combined with `--corpus-dir`"), "{stderr}"); +} + +#[forgetest_init] +fn forge_fuzz_replay_rejects_watch(cmd: _) { + let output = cmd.args(["fuzz", "replay", "--watch"]).assert_failure(); + let stderr = String::from_utf8(output.get_output().stderr.clone()).unwrap(); + assert!(stderr.contains("unexpected argument '--watch'"), "{stderr}"); +} + +#[forgetest_init] +fn forge_fuzz_run_honors_configured_invariant_workers(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 4; + config.invariant.depth = 1; + config.invariant.workers = + foundry_config::InvariantWorkers::Fixed(std::num::NonZeroUsize::new(4).unwrap()); + config.fuzz.seed = Some(U256::ZERO); + }); + prj.add_test( + "ForgeFuzzRunInvariantWorkers.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract ForgeFuzzRunInvariantWorkersTarget { + uint256 public counter; + + function bump() public { + counter++; + } +} + +contract ForgeFuzzRunInvariantWorkersTest is Test { + ForgeFuzzRunInvariantWorkersTarget target; + + function setUp() public { + target = new ForgeFuzzRunInvariantWorkersTarget(); + targetContract(address(target)); + } + + function invariant_break() public view { + require(target.counter() == 0, "broken"); + } +} + "#, + ); + + let output = cmd.args(["fuzz", "run", "--json", "--mt", "invariant_break"]).assert_failure(); + let json: serde_json::Value = serde_json::from_slice(&output.get_output().stdout).unwrap(); + let suite = json.as_object().unwrap().values().next().unwrap(); + let tests = suite["test_results"].as_object().unwrap(); + let result = tests.values().next().unwrap(); + let kind = &result["kind"]["Invariant"]; + assert_eq!(kind["workers"], 4, "{json}"); +} + +// `forge fuzz replay` (without `--corpus-dir`) must not start a fresh invariant +// campaign when there is no persisted failure to replay; it should skip instead. +#[forgetest_init] +fn forge_fuzz_replay_invariant_skips_without_persisted_failure(prj: _, cmd: _) { + prj.add_test( + "ForgeFuzzReplayInvariant.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract ForgeFuzzReplayInvariantTest is Test { + uint256 total; + + function setUp() public { + targetContract(address(this)); + } + + function doThing(uint256 x) public { + total += x; + } + + function invariant_holds() public view { + assertGe(total, 0); + } +} + "#, + ); + + cmd.args(["fuzz", "replay", "--mc", "ForgeFuzzReplayInvariantTest"]) + .assert_success() + .stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/ForgeFuzzReplayInvariant.t.sol:ForgeFuzzReplayInvariantTest +[SKIP: no persisted invariant failure reproduced for invariant_holds] invariant_holds() ([GAS]) +Suite result: ok. 0 passed; 0 failed; 1 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 0 failed, 1 skipped (1 total tests) + +"#]]); +} + +#[forgetest_init] +fn forge_fuzz_replay_replays_persisted_fuzz_failure(prj: _, cmd: _) { + prj.update_config(|config| { + config.fuzz.runs = 32; + config.fuzz.seed = Some(U256::from(100u32)); + }); + prj.add_test( + "ForgeFuzzReplayFailure.t.sol", + r#" +contract ForgeFuzzReplayFailureTest { + function test_unit() public pure {} + + function testFuzz_reverts(uint256 value) public pure { + require(value > 200); + } +} + "#, + ); + + cmd.args(["fuzz", "run", "--mc", "ForgeFuzzReplayFailureTest", "-q"]).assert_failure(); + + // Legacy stateless failure artifacts only stored calldata and decoded arguments. + let failure_path = + prj.root().join("cache/fuzz/failures/ForgeFuzzReplayFailureTest/testFuzz_reverts"); + let mut failure: Value = + serde_json::from_str(&std::fs::read_to_string(&failure_path).unwrap()).unwrap(); + let persisted = serde_json::from_value::(failure.clone()).unwrap(); + let persisted_value = U256::from_be_slice(&persisted.calldata[4..]); + let failure = failure.as_object_mut().unwrap(); + failure.remove("sender"); + failure.remove("addr"); + failure.remove("value"); + std::fs::write(&failure_path, serde_json::to_vec_pretty(failure).unwrap()).unwrap(); + + cmd.forge_fuse() + .args(["fuzz", "replay", "--mc", "ForgeFuzzReplayFailureTest", "-vvv"]) + .assert_failure() + .stdout_eq(format!( + r#"... +[FAIL: EvmError: Revert; counterexample: calldata={calldata} args=[{persisted_value}]] testFuzz_reverts(uint256) (runs: 0, [AVG_GAS]) +Traces: + [[..]] ForgeFuzzReplayFailureTest::testFuzz_reverts({persisted_value}) +... +[SKIP: not runnable in replay mode] test_unit() ([GAS]) +... +"#, + calldata = persisted.calldata, + )); +} + +#[forgetest_init] +fn stateless_fuzz_does_not_persist_skips(prj: _, cmd: _) { + let corpus_root = prj.root().join("fuzz_corpus"); + prj.update_config(|config| { + config.fuzz.runs = 1; + config.fuzz.seed = Some(U256::ONE); + config.fuzz.corpus.corpus_dir = Some(corpus_root.clone()); + }); + prj.add_test( + "StatelessSkip.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract StatelessSkipTest is Test { + function testFuzz_skip(uint256) public { + vm.skip(true); + } +} + "#, + ); + + cmd.args(["test", "--mt", "testFuzz_skip", "-q"]).assert_success(); + + assert!(!has_regular_file(&corpus_root)); +} + +#[forgetest_init] +fn stateless_fuzz_does_not_persist_assume_rejects(prj: _, cmd: _) { + let corpus_root = prj.root().join("fuzz_corpus"); + prj.update_config(|config| { + config.fuzz.runs = 1; + config.fuzz.max_test_rejects = 1; + config.fuzz.seed = Some(U256::ONE); + config.fuzz.corpus.corpus_dir = Some(corpus_root.clone()); + }); + prj.add_test( + "StatelessAssumeReject.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract StatelessAssumeRejectTest is Test { + function testFuzz_assume(uint256) public view { + vm.assume(false); + } +} + "#, + ); + + cmd.args(["test", "--mt", "testFuzz_assume", "-q"]).assert_failure(); + + assert!(!has_regular_file(&corpus_root)); +} + +#[forgetest_init] +fn stateless_fuzz_preserves_payable_value(prj: _, cmd: _) { + let corpus_root = prj.root().join("fuzz_corpus"); + let frontier_root = prj.root().join("fuzz_frontiers"); + prj.update_config(|config| { + config.initial_balance = U256::from(7); + config.fuzz.runs = 1; + config.fuzz.seed = Some(U256::from(42)); + config.fuzz.corpus.payable_value_weight = 100; + config.fuzz.corpus.corpus_dir = Some(corpus_root.clone()); + config.fuzz.corpus.frontier_dir = Some(frontier_root.clone()); + }); + prj.add_test( + "StatelessPayableValue.t.sol", + r#" +contract StatelessPayableValueTest { + function testFuzz_payable(uint256 value) public payable { + value; + require(msg.value == 0, "received value"); + } +} + "#, + ); + + cmd.args(["test", "--mt", "testFuzz_payable", "-q"]).assert_failure(); + + let failure_path = + prj.root().join("cache/fuzz/failures/StatelessPayableValueTest/testFuzz_payable"); + let failure: BaseCounterExample = + serde_json::from_str(&std::fs::read_to_string(failure_path).unwrap()).unwrap(); + assert_eq!(failure.value, Some(U256::from(7))); + let sender = failure.sender.unwrap(); + let target = failure.addr.unwrap(); + + let corpus_path = find_first_json( + &corpus_root.join("StatelessPayableValueTest/testFuzz_payable/worker0/corpus"), + ); + let corpus: Value = + serde_json::from_str(&std::fs::read_to_string(corpus_path).unwrap()).unwrap(); + let corpus_tx = &corpus[0]; + assert_eq!(corpus_tx["value"], "0x7"); + assert_eq!(corpus_tx["sender"].as_str().unwrap().parse(), Ok(sender)); + assert_eq!(corpus_tx["target"].as_str().unwrap().parse(), Ok(target)); + assert_eq!(corpus_tx["calldata"], failure.calldata.to_string()); + + let frontier_path = + frontier_root.join("StatelessPayableValueTest/testFuzz_payable/branch-frontiers.json"); + let frontier: Value = + serde_json::from_str(&std::fs::read_to_string(frontier_path).unwrap()).unwrap(); + let frontier_tx = &frontier["frontiers"][0]["sequence"][0]; + assert_eq!(frontier_tx["value"], "0x7"); + assert_eq!(frontier_tx["sender"].as_str().unwrap().parse(), Ok(sender)); + assert_eq!(frontier_tx["target"].as_str().unwrap().parse(), Ok(target)); + assert_eq!(frontier_tx["calldata"], failure.calldata.to_string()); + + let replay = cmd + .forge_fuse() + .args(["fuzz", "replay", "--mt", "testFuzz_payable", "-vvv"]) + .assert_failure(); + let stdout = String::from_utf8(replay.get_output().stdout.clone()).unwrap(); + assert!(stdout.contains("received value"), "{stdout}"); +} + +#[forgetest_init] +fn forge_fuzz_show_marks_selector_ambiguous_contracts(prj: _, cmd: _) { + prj.add_source( + "SelectorTwins.sol", + r#" +contract Alpha { + function collide(uint256 value) external pure returns (uint256) { + return value; + } +} + +contract Beta { + uint256 public stored; + + function collide(uint256 value) external view { + require(stored != value); + } +} + "#, + ); + cmd.args(["build", "-q"]).assert_success(); + + let alpha_abi = artifact_abi(prj.root(), "out/SelectorTwins.sol/Alpha.json"); + let calldata = calldata_for(&alpha_abi, "collide", 42); + let corpus = prj.root().join("corpus"); + std::fs::create_dir_all(&corpus).unwrap(); + write_corpus_entry(&corpus, "00000000-0000-0000-0000-00000000be7a-1.json", &calldata); + + let show = cmd.forge_fuse().args(["fuzz", "show", "corpus"]).assert_success(); + let stdout = String::from_utf8(show.get_output().stdout.clone()).unwrap(); + assert!(stdout.contains(" 0: collide(42) "), "{stdout}"); + assert!(stdout.contains(" ambiguous=[Alpha,Beta]"), "{stdout}"); + assert!(!stdout.contains("Alpha.collide(42)"), "{stdout}"); + assert!(!stdout.contains("Beta.collide(42)"), "{stdout}"); + + let json = + cmd.forge_fuse().args(["fuzz", "show", "corpus", "--format", "json"]).assert_success(); + let stdout = String::from_utf8(json.get_output().stdout.clone()).unwrap(); + let entries: Value = serde_json::from_str(&stdout).unwrap(); + let decoded = &entries[0]["sequence"][0]["decoded"]; + assert!(decoded.get("contract").is_none(), "{decoded}"); + assert_eq!(decoded["signature"], "collide(uint256)"); + assert_eq!(decoded["call"], "collide(42)"); + assert_eq!(decoded["ambiguous_contracts"], serde_json::json!(["Alpha", "Beta"])); +} + +#[forgetest_init] +fn forge_fuzz_replay_does_not_fuzz_after_assume_reject(prj: _, cmd: _) { + prj.update_config(|config| { + config.fuzz.runs = 32; + config.fuzz.seed = Some(U256::from(100u32)); + }); + prj.add_test( + "ForgeFuzzReplayAssumeReject.t.sol", + r#" +contract ForgeFuzzReplayAssumeRejectTest { + function testFuzz_reverts(uint256 value) public pure { + require(value > 200); + } +} + "#, + ); + + cmd.args(["fuzz", "run", "--mc", "ForgeFuzzReplayAssumeRejectTest", "-q"]).assert_failure(); + + let failure_path = + prj.root().join("cache/fuzz/failures/ForgeFuzzReplayAssumeRejectTest/testFuzz_reverts"); + let failure = + serde_json::from_slice::(&std::fs::read(failure_path).unwrap()) + .unwrap(); + let persisted_value = U256::from_be_slice(&failure.calldata[4..]); + + prj.add_test( + "ForgeFuzzReplayAssumeReject.t.sol", + &format!( + r#" +interface Vm {{ + function assume(bool) external; +}} + +contract ForgeFuzzReplayAssumeRejectTest {{ + Vm internal constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + function testFuzz_reverts(uint256 value) public {{ + vm.assume(value != {persisted_value}); + require(false, "fresh unrelated failure"); + }} +}} + "# + ), + ); + + cmd.forge_fuse() + .args(["fuzz", "replay", "--mc", "ForgeFuzzReplayAssumeRejectTest"]) + .assert_success() + .stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/ForgeFuzzReplayAssumeReject.t.sol:ForgeFuzzReplayAssumeRejectTest +[SKIP: persisted fuzz failure rejected by `vm.assume`] testFuzz_reverts(uint256) (runs: 0, [AVG_GAS]) +Suite result: ok. 0 passed; 0 failed; 1 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 0 failed, 1 skipped (1 total tests) + +"#]]); +} + +#[forgetest_init] +fn forge_fuzz_replay_treats_persisted_skip_as_skip(prj: _, cmd: _) { + prj.update_config(|config| { + config.fuzz.runs = 32; + config.fuzz.seed = Some(U256::from(100u32)); + }); + prj.add_test( + "ForgeFuzzReplaySkip.t.sol", + r#" +contract ForgeFuzzReplaySkipTest { + function testFuzz_reverts(uint256 value) public pure { + require(value > 200); + } +} + "#, + ); + + cmd.args(["fuzz", "run", "--mc", "ForgeFuzzReplaySkipTest", "-q"]).assert_failure(); + + prj.add_test( + "ForgeFuzzReplaySkip.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract ForgeFuzzReplaySkipTest is Test { + function testFuzz_reverts(uint256 value) public { + value; + vm.skip(true, "disabled"); + } +} + "#, + ); + + let replay = cmd + .forge_fuse() + .args(["fuzz", "replay", "--mc", "ForgeFuzzReplaySkipTest"]) + .assert_success(); + let stdout = String::from_utf8(replay.get_output().stdout.clone()).unwrap(); + assert!(stdout.contains("[SKIP: disabled] testFuzz_reverts(uint256)"), "{stdout}"); + assert!(!stdout.contains("[FAIL"), "{stdout}"); +} + +#[forgetest_init] +fn forge_fuzz_replay_does_not_treat_user_skip_payload_as_skip(prj: _, cmd: _) { + prj.update_config(|config| { + config.fuzz.runs = 32; + config.fuzz.seed = Some(U256::from(100u32)); + }); + prj.add_test( + "ForgeFuzzReplayUserSkipPayload.t.sol", + r#" +contract ForgeFuzzReplayUserSkipPayloadTest { + function testFuzz_reverts(uint256 value) public pure { + require(value > 200); + } +} + "#, + ); + + cmd.args(["fuzz", "run", "--mc", "ForgeFuzzReplayUserSkipPayloadTest", "-q"]).assert_failure(); + + prj.add_test( + "ForgeFuzzReplayUserSkipPayload.t.sol", + r#" +contract ForgeFuzzReplayUserSkipPayloadTest { + function testFuzz_reverts(uint256 value) public pure { + value; + bytes memory reason = bytes("FOUNDRY::SKIPnot cheatcode"); + assembly { + revert(add(reason, 32), mload(reason)) + } + } +} + "#, + ); + + let replay = cmd + .forge_fuse() + .args(["fuzz", "replay", "--mc", "ForgeFuzzReplayUserSkipPayloadTest"]) + .assert_failure(); + let stdout = String::from_utf8(replay.get_output().stdout.clone()).unwrap(); + assert!(stdout.contains("[FAIL:"), "{stdout}"); + assert!(!stdout.contains("[SKIP: not cheatcode]"), "{stdout}"); +} + +#[forgetest_init] +fn forge_fuzz_junit_output_stays_xml_only_on_failure(prj: _, cmd: _) { + prj.update_config(|config| { + config.fuzz.runs = 1; + config.fuzz.seed = Some(U256::from(100u32)); + }); + prj.add_test( + "ForgeFuzzJunitFailure.t.sol", + r#" +contract ForgeFuzzJunitFailureTest { + function testFuzz_reverts(uint256 value) public pure { + value; + require(false); + } +} + "#, + ); + + let run = cmd + .forge_fuse() + .args(["fuzz", "run", "--junit", "--mc", "ForgeFuzzJunitFailureTest"]) + .assert_failure(); + let stdout = String::from_utf8(run.get_output().stdout.clone()).unwrap(); + assert!(stdout.contains(" call_index, "{frontier:#}"); + } + + prj.update_config(|config| config.invariant.call_override = true); + let output = cmd + .forge_fuse() + .args([ + "fuzz", + "run", + "--match-contract", + "ForgeFuzzRunStatefulFrontiersTest", + "--match-test", + "invariant", + "--runs", + "1", + "--depth", + "1", + "--threads", + "1", + "--frontier-dir", + "override_frontiers", + ]) + .assert_success() + .get_output() + .clone(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains( + "Invariant frontier capture does not support `invariant.call_override`; running the \ + campaign without writing frontier artifacts." + ), + "stdout={stdout}\nstderr={stderr}" + ); + assert!(!prj.root().join("override_frontiers").exists()); +} + +#[forgetest_init] +fn forge_fuzz_run_isolates_unmerged_invariant_frontiers(prj: _, cmd: _) { + prj.add_test( + "ForgeFuzzRunIsolatedFrontiers.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract IsolatedFrontierTarget { + uint256 public marker; + + function step(uint256 value) external { + marker = value < 100 ? 1 : 2; + } +} + +contract ForgeFuzzRunIsolatedFrontiersTest is Test { + function setUp() public { + targetContract(address(new IsolatedFrontierTarget())); + } + + function invariant_one() public pure {} + + /// forge-config: default.invariant.depth = 1 + function invariant_two() public pure {} +} +"#, + ); + + cmd.forge_fuse() + .args([ + "fuzz", + "run", + "--match-contract", + "ForgeFuzzRunIsolatedFrontiersTest", + "--runs", + "1", + "--depth", + "2", + "--seed", + "0x1234", + "--threads", + "2", + "--frontier-dir", + "isolated_frontiers", + ]) + .assert_success(); + + let frontier_paths = find_json_files(&prj.root().join("isolated_frontiers")); + assert_eq!(frontier_paths.len(), 2); + let mut captured = BTreeSet::new(); + for frontier_path in frontier_paths { + let artifact: Value = serde_json::from_slice( + &std::fs::read(&frontier_path) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", frontier_path.display())), + ) + .unwrap(); + captured.insert(artifact["test"].as_str().unwrap().to_string()); + } + assert_eq!( + captured, + BTreeSet::from(["invariant_one()".to_string(), "invariant_two()".to_string()]) + ); + + cmd.forge_fuse() + .args([ + "fuzz", + "run", + "--match-contract", + "ForgeFuzzRunIsolatedFrontiersTest", + "--match-test", + "invariant_two", + "--runs", + "1", + "--depth", + "2", + "--seed", + "0x1234", + "--threads", + "1", + "--frontier-dir", + "filtered_isolated_frontiers", + ]) + .assert_success(); + let filtered_paths = find_json_files(&prj.root().join("filtered_isolated_frontiers")); + assert_eq!(filtered_paths.len(), 1); + let artifact: Value = + serde_json::from_slice(&std::fs::read(&filtered_paths[0]).unwrap()).unwrap(); + assert_eq!(artifact["test"], "invariant_two()"); +} + +#[forgetest_init] +fn forge_fuzz_run_frontiers_keep_reverted_environment_prefix(prj: _, cmd: _) { + prj.add_test( + "ForgeFuzzRunRevertedFrontier.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract ForgeFuzzRunRevertedFrontierTest is Test { + uint256 public marker; + + function setUp() public { + vm.warp(1); + targetContract(address(this)); + targetSender(address(this)); + } + + function advance(uint256 value) external { + if (block.timestamp < 1000) { + vm.warp(1000); + revert("advance timestamp"); + } + marker = value < 100 ? 1 : 2; + } + + function invariant_ok() public pure {} +} +"#, + ); + + cmd.forge_fuse() + .args([ + "fuzz", + "run", + "--match-contract", + "ForgeFuzzRunRevertedFrontierTest", + "--match-test", + "invariant", + "--runs", + "1", + "--depth", + "2", + "--seed", + "0x4321", + "--threads", + "1", + "--frontier-dir", + "reverted_frontiers", + "--corpus-dir", + "reverted_corpus", + ]) + .assert_success(); + + let frontier_path = find_first_json(&prj.root().join("reverted_frontiers")); + let artifact: Value = serde_json::from_slice( + &std::fs::read(&frontier_path) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", frontier_path.display())), + ) + .unwrap(); + let frontier = artifact["frontiers"] + .as_array() + .unwrap() + .iter() + .find(|frontier| frontier["call_index"] == 1) + .unwrap_or_else(|| panic!("missing post-revert frontier in {artifact:#}")); + let sequence_index = frontier["sequence_index"].as_u64().unwrap() as usize; + assert_eq!(artifact["sequences"][sequence_index].as_array().unwrap().len(), 2); + + cmd.forge_fuse() + .args([ + "fuzz", + "run", + "--match-contract", + "ForgeFuzzRunRevertedFrontierTest", + "--match-test", + "invariant", + "--runs", + "1", + "--depth", + "2", + "--seed", + "0x4321", + "--threads", + "1", + "--corpus-dir", + "baseline_corpus", + ]) + .assert_success(); + + let read_corpus = |name: &str| { + let dir = prj.root().join(name).join("ForgeFuzzRunRevertedFrontierTest/worker0/corpus"); + let mut entries = find_json_files(&dir) + .into_iter() + .map(|entry| std::fs::read_to_string(entry).unwrap()) + .collect::>(); + entries.sort(); + entries + }; + let captured = read_corpus("reverted_corpus"); + assert!(!captured.is_empty()); + assert_eq!(captured, read_corpus("baseline_corpus")); +} + +#[forgetest_init] +fn forge_fuzz_run_runs_sets_invariant_runs(prj: _, cmd: _) { + prj.add_test( + "ForgeFuzzRunInvariantRuns.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract ForgeFuzzRunInvariantRunsTest is Test { + function setUp() public { + targetContract(address(this)); + } + + function invariant_ok() public pure {} + + function targetTouch(uint256 value) external pure { + value; + } +} + "#, + ); + + let output = cmd + .forge_fuse() + .args([ + "fuzz", + "run", + "--match-contract", + "ForgeFuzzRunInvariantRunsTest", + "--match-test", + "invariant", + "--runs", + "2", + "--depth", + "1", + ]) + .assert_success(); + let stdout = String::from_utf8(output.get_output().stdout.clone()).unwrap(); + assert!(stdout.contains("[PASS] invariant_ok() (runs: 2"), "{stdout}"); +} + +#[forgetest_init] +fn forge_fuzz_run_does_not_warn_when_both_engines_match(prj: _, cmd: _) { + prj.add_test( + "ForgeFuzzRunBothEngines.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract ForgeFuzzRunBothEnginesTest is Test { + function setUp() public { + targetContract(address(this)); + } + + function testFuzz_value(uint256 value) public pure { + value; + } + + function invariant_ok() public pure {} + + function targetTouch(uint256 value) external pure { + value; + } +} + "#, + ); + + let output = cmd + .forge_fuse() + .args([ + "fuzz", + "run", + "--match-contract", + "ForgeFuzzRunBothEnginesTest", + "--runs", + "1", + "--depth", + "1", + "--seed", + "1", + ]) + .assert_success(); + let stderr = String::from_utf8(output.get_output().stderr.clone()).unwrap(); + assert!(!stderr.contains("only applies"), "{stderr}"); +} + +#[forgetest_init] +fn forge_fuzz_run_positional_path_still_filters(prj: _, cmd: _) { + prj.add_test( + "ForgeFuzzRunPath.t.sol", + r#" +contract ForgeFuzzRunPathTest { + function testFuzz_value(uint256 value) public pure { + value; + } +} + "#, + ); + prj.add_test( + "ForgeFuzzRunOtherPath.t.sol", + r#" +contract ForgeFuzzRunOtherPathTest { + function testFuzz_other(uint256 value) public pure { + value; + } +} + "#, + ); + + let output = cmd + .forge_fuse() + .args(["fuzz", "run", "test/ForgeFuzzRunPath.t.sol", "--list"]) + .assert_success(); + let stdout = String::from_utf8(output.get_output().stdout.clone()).unwrap(); + assert!(stdout.contains("test/ForgeFuzzRunPath.t.sol"), "{stdout}"); + assert!(stdout.contains("testFuzz_value"), "{stdout}"); + assert!(!stdout.contains("ForgeFuzzRunOtherPath"), "{stdout}"); +} + +#[forgetest_init] +fn forge_showmap_skips_symbolic_tests(prj: _, cmd: _) { + prj.add_test( + "ForgeShowmapSymbolic.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract ForgeShowmapSymbolicTest is Test { + function check_symbolic(uint256 value) public pure { + value; + } +} + "#, + ); + + let assert = cmd + .args([ + "test", + "--symbolic", + "--showmap-out", + "showmap", + "--mc", + "ForgeShowmapSymbolicTest", + ]) + .assert_success(); + let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + assert!( + stdout.contains("[SKIP: not runnable in showmap mode] check_symbolic(uint256)"), + "{stdout}" + ); +} + +#[forgetest_init] +fn forge_fuzz_show_corpus_files(prj: _, cmd: _) { + prj.add_test( + "ForgeFuzzShowTarget.t.sol", + r#" +contract ForgeFuzzShowTargetTest { + function testFuzz_setNumber(uint256 value) public pure { + value; + } +} + "#, + ); + cmd.args(["build", "-q"]).assert_success(); + + let corpus = prj.root().join("corpus"); + std::fs::create_dir_all(&corpus).unwrap(); + let calldata = "0x938872f7000000000000000000000000000000000000000000000000000000000000002a"; + write_corpus_entry(&corpus, "00000000-0000-0000-0000-000000000001-1.json", calldata); + write_corpus_entry(&corpus, "00000000-0000-0000-0000-000000000002-2.json", calldata); + + cmd.forge_fuse() + .args(["fuzz", "show", "corpus"]) + .assert_success() + .stdout_eq(str![[r#" +corpus/00000000-0000-0000-0000-000000000001-1.json (1 txs) + 0: ForgeFuzzShowTargetTest.testFuzz_setNumber(42) sender=0x0000000000000000000000000000000000000001 target=0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496 value=0 +corpus/00000000-0000-0000-0000-000000000002-2.json (1 txs) + 0: ForgeFuzzShowTargetTest.testFuzz_setNumber(42) sender=0x0000000000000000000000000000000000000001 target=0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496 value=0 + +"#]]); + + let replay = cmd + .forge_fuse() + .args(["fuzz", "replay", "--mc", "ForgeFuzzShowTargetTest", "--corpus-dir", "corpus"]) + .assert_success(); + let stdout = String::from_utf8(replay.get_output().stdout.clone()).unwrap(); + assert!(stdout.contains("[PASS] testFuzz_setNumber(uint256) (replay: 2 entries"), "{stdout}"); +} + +#[forgetest_init] +fn forge_fuzz_cmin_keeps_coverage_adding_entries(prj: _, cmd: _) { + prj.add_test( + "ForgeFuzzCminTarget.t.sol", + r#" +contract ForgeFuzzCminTargetTest { + uint256 value; + + function testFuzz_branch(uint256 input) public { + if (input == 1) { + value = 1; + } + if (input == 2) { + value = 2; + } + if (input == 3) { + revert("boom"); + } + } +} + "#, + ); + cmd.args(["build", "-q"]).assert_success(); + + let abi = + artifact_abi(prj.root(), "out/ForgeFuzzCminTarget.t.sol/ForgeFuzzCminTargetTest.json"); + let one = calldata_for(&abi, "testFuzz_branch", 1); + let two = calldata_for(&abi, "testFuzz_branch", 2); + let three = calldata_for(&abi, "testFuzz_branch", 3); + let corpus = prj.root().join("corpus"); + std::fs::create_dir_all(&corpus).unwrap(); + write_corpus_entry(&corpus, "00000000-0000-0000-0000-000000000001-1.json", &one); + write_corpus_entry(&corpus, "00000000-0000-0000-0000-000000000002-2.json", &one); + write_corpus_entry(&corpus, "00000000-0000-0000-0000-000000000003-3.json", &two); + write_corpus_entry(&corpus, "00000000-0000-0000-0000-000000000004-4.json", &three); + std::fs::write(corpus.join("00000000-0000-0000-0000-000000000005-5.json"), "[]").unwrap(); + + let assert = cmd + .forge_fuse() + .args([ + "fuzz", + "cmin", + "--mc", + "ForgeFuzzCminTargetTest", + "--mt", + "testFuzz_branch", + "corpus", + "--corpus-out", + "min-corpus", + ]) + .assert_success(); + let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + let stderr = String::from_utf8(assert.get_output().stderr.clone()).unwrap(); + assert!(stdout.contains("minimized corpus: kept 2/5 entries in min-corpus"), "{stdout}"); + assert!(stderr.contains("skipped 2 entries or txs"), "{stderr}"); + assert_eq!(regular_file_count(&prj.root().join("min-corpus")), 2); + + cmd.forge_fuse() + .args([ + "fuzz", + "cmin", + "--mc", + "ForgeFuzzCminTargetTest", + "--mt", + "testFuzz_branch", + "corpus", + "--corpus-out", + "min-corpus", + ]) + .assert_failure(); + + let preserve_corpus = prj.root().join("preserve-corpus"); + std::fs::create_dir_all(&preserve_corpus).unwrap(); + write_corpus_entry(&preserve_corpus, "00000000-0000-0000-0000-000000000001-1.json", &one); + write_corpus_entry(&preserve_corpus, "00000000-0000-0000-0000-000000000002-2.json", &one); + write_corpus_entry(&preserve_corpus, "00000000-0000-0000-0000-000000000003-3.json", &two); + cmd.forge_fuse() + .args([ + "test", + "--mc", + "ForgeFuzzCminTargetTest", + "--mt", + "testFuzz_branch", + "--showmap-out", + "showmap-before-cmin", + "--showmap-corpus-dir", + "preserve-corpus", + "--showmap-trial", + "t", + ]) + .assert_success(); + cmd.forge_fuse() + .args([ + "fuzz", + "cmin", + "--mc", + "ForgeFuzzCminTargetTest", + "--mt", + "testFuzz_branch", + "preserve-corpus", + "--corpus-out", + "min-preserve-corpus", + ]) + .assert_success(); + cmd.forge_fuse() + .args([ + "test", + "--mc", + "ForgeFuzzCminTargetTest", + "--mt", + "testFuzz_branch", + "--showmap-out", + "showmap-after-cmin", + "--showmap-corpus-dir", + "min-preserve-corpus", + "--showmap-trial", + "t", + ]) + .assert_success(); + assert_eq!( + showmap_edge_ids(&prj.root().join("showmap-before-cmin")), + showmap_edge_ids(&prj.root().join("showmap-after-cmin")) + ); +} + +#[forgetest_init] +fn forge_fuzz_cmin_keeps_hit_count_bucket_increases(prj: _, cmd: _) { + prj.add_test( + "ForgeFuzzCminBucketTarget.t.sol", + r#" +contract ForgeFuzzCminBucketTargetTest { + uint256 value; + + function testFuzz_reps(uint256 input) public { + uint256 n = input % 5; + for (uint256 i = 0; i < n; i++) { + value = value + i + 1; + } + } +} + "#, + ); + cmd.args(["build", "-q"]).assert_success(); + + let abi = artifact_abi( + prj.root(), + "out/ForgeFuzzCminBucketTarget.t.sol/ForgeFuzzCminBucketTargetTest.json", + ); + let one = calldata_for(&abi, "testFuzz_reps", 1); + let four = calldata_for(&abi, "testFuzz_reps", 4); + let corpus = prj.root().join("corpus"); + std::fs::create_dir_all(&corpus).unwrap(); + write_corpus_entry(&corpus, "00000000-0000-0000-0000-000000000001-1.json", &one); + write_corpus_entry(&corpus, "00000000-0000-0000-0000-000000000002-2.json", &four); + + let assert = cmd + .forge_fuse() + .args([ + "fuzz", + "cmin", + "--mc", + "ForgeFuzzCminBucketTargetTest", + "--mt", + "testFuzz_reps", + "corpus", + "--corpus-out", + "min-corpus", + ]) + .assert_success(); + let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + assert!(stdout.contains("minimized corpus: kept 2/2 entries in min-corpus"), "{stdout}"); + assert_eq!(regular_file_count(&prj.root().join("min-corpus")), 2); +} + +#[forgetest_init] +fn forge_fuzz_cmin_handles_multiple_matched_targets(prj: _, cmd: _) { + prj.add_test( + "ForgeFuzzCminMultiTarget.t.sol", + r#" +contract ForgeFuzzCminMultiTargetTest { + uint256 left; + uint256 right; + + function testFuzz_left(uint256 input) public { + if (input == 1) { + left = 1; + } + } + + function testFuzz_right(uint256 input) public { + if (input == 2) { + right = 2; + } + } +} + "#, + ); + cmd.args(["build", "-q"]).assert_success(); + + let abi = artifact_abi( + prj.root(), + "out/ForgeFuzzCminMultiTarget.t.sol/ForgeFuzzCminMultiTargetTest.json", + ); + let left = calldata_for(&abi, "testFuzz_left", 1); + let right = calldata_for(&abi, "testFuzz_right", 2); + let corpus = prj.root().join("multi-target-corpus"); + std::fs::create_dir_all(&corpus).unwrap(); + write_corpus_entry(&corpus, "00000000-0000-0000-0000-000000000001-1.json", &left); + write_corpus_entry(&corpus, "00000000-0000-0000-0000-000000000002-2.json", &right); + + let assert = cmd + .forge_fuse() + .args([ + "fuzz", + "cmin", + "--mc", + "ForgeFuzzCminMultiTargetTest", + "multi-target-corpus", + "--corpus-out", + "min-multi-target-corpus", + ]) + .assert_success(); + let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + assert!( + stdout.contains("minimized corpus: kept 2/2 entries in min-multi-target-corpus"), + "{stdout}" + ); + assert_eq!(regular_file_count(&prj.root().join("min-multi-target-corpus")), 2); +} + +#[forgetest_init] +fn forge_fuzz_cmin_namespaces_coverage_by_matched_target(prj: _, cmd: _) { + prj.add_test( + "ForgeFuzzCminNamespacedTargets.t.sol", + r#" +contract ForgeFuzzCminNamespacedAlphaTest { + uint256 value; + + function testFuzz_shared(uint256 input) public { + if (input == 1) { + value = 1; + } + } +} + +contract ForgeFuzzCminNamespacedBetaTest { + uint256 value; + + function testFuzz_shared(uint256 input) public { + if (input == 2) { + value = 2; + } + } +} + "#, + ); + cmd.args(["build", "-q"]).assert_success(); + + let abi = artifact_abi( + prj.root(), + "out/ForgeFuzzCminNamespacedTargets.t.sol/ForgeFuzzCminNamespacedAlphaTest.json", + ); + let one = calldata_for(&abi, "testFuzz_shared", 1); + let two = calldata_for(&abi, "testFuzz_shared", 2); + let corpus = prj.root().join("namespaced-target-corpus"); + std::fs::create_dir_all(&corpus).unwrap(); + write_corpus_entry(&corpus, "00000000-0000-0000-0000-000000000001-1.json", &one); + write_corpus_entry(&corpus, "00000000-0000-0000-0000-000000000002-2.json", &two); + + let assert = cmd + .forge_fuse() + .args([ + "fuzz", + "cmin", + "--mc", + "ForgeFuzzCminNamespaced(Alpha|Beta)Test", + "--mt", + "testFuzz_shared", + "namespaced-target-corpus", + "--corpus-out", + "min-namespaced-target-corpus", + ]) + .assert_success(); + let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + assert!( + stdout.contains("minimized corpus: kept 2/2 entries in min-namespaced-target-corpus"), + "{stdout}" + ); + assert_eq!(regular_file_count(&prj.root().join("min-namespaced-target-corpus")), 2); +} + +#[forgetest_init] +fn forge_fuzz_cmin_keeps_entry_when_one_target_fails(prj: _, cmd: _) { + prj.add_test( + "ForgeFuzzCminTargetFailure.t.sol", + r#" +contract ForgeFuzzCminTargetFailureAlphaTest { + uint256 value; + + function testFuzz_shared(uint256 input) public { + if (input == 1) { + value = 1; + } + } +} + +contract ForgeFuzzCminTargetFailureBetaTest { + function testFuzz_shared(uint256 input) public pure { + if (input == 1) { + revert("beta rejects"); + } + } +} + "#, + ); + cmd.args(["build", "-q"]).assert_success(); + + let abi = artifact_abi( + prj.root(), + "out/ForgeFuzzCminTargetFailure.t.sol/ForgeFuzzCminTargetFailureAlphaTest.json", + ); + let one = calldata_for(&abi, "testFuzz_shared", 1); + let corpus = prj.root().join("target-failure-corpus"); + std::fs::create_dir_all(&corpus).unwrap(); + write_corpus_entry(&corpus, "00000000-0000-0000-0000-000000000001-1.json", &one); + + let assert = cmd + .forge_fuse() + .args([ + "fuzz", + "cmin", + "--mc", + "ForgeFuzzCminTargetFailure(Alpha|Beta)Test", + "--mt", + "testFuzz_shared", + "target-failure-corpus", + "--corpus-out", + "min-target-failure-corpus", + ]) + .assert_success(); + let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + assert!( + stdout.contains("minimized corpus: kept 1/1 entries in min-target-failure-corpus"), + "{stdout}" + ); + assert_eq!(regular_file_count(&prj.root().join("min-target-failure-corpus")), 1); +} + +#[forgetest_init] +fn forge_fuzz_cmin_counts_zero_replay_entries_once_per_corpus_entry(prj: _, cmd: _) { + prj.add_test( + "ForgeFuzzCminZeroReplayMultiTarget.t.sol", + r#" +contract ForgeFuzzCminZeroReplayAlphaTest { + function testFuzz_shared(uint256 input) public pure { + if (input == 1) { + revert("alpha rejects"); + } + } +} + +contract ForgeFuzzCminZeroReplayBetaTest { + function testFuzz_shared(uint256 input) public pure { + if (input == 1) { + revert("beta rejects"); + } + } +} + "#, + ); + cmd.args(["build", "-q"]).assert_success(); + + let abi = artifact_abi( + prj.root(), + "out/ForgeFuzzCminZeroReplayMultiTarget.t.sol/ForgeFuzzCminZeroReplayAlphaTest.json", + ); + let one = calldata_for(&abi, "testFuzz_shared", 1); + let corpus = prj.root().join("zero-replay-multi-target-corpus"); + std::fs::create_dir_all(&corpus).unwrap(); + write_corpus_entry(&corpus, "00000000-0000-0000-0000-000000000001-1.json", &one); + + let assert = cmd + .forge_fuse() + .args([ + "fuzz", + "cmin", + "--mc", + "ForgeFuzzCminZeroReplay(Alpha|Beta)Test", + "--mt", + "testFuzz_shared", + "zero-replay-multi-target-corpus", + "--corpus-out", + "min-zero-replay-multi-target-corpus", + ]) + .assert_failure(); + let stderr = String::from_utf8(assert.get_output().stderr.clone()).unwrap(); + assert!(stderr.contains("1 corpus entries failed during replay"), "{stderr}"); + assert!(!stderr.contains("2 corpus entries failed during replay"), "{stderr}"); + + let assert = cmd + .forge_fuse() + .args([ + "fuzz", + "cmin", + "--mc", + "ForgeFuzzCminZeroReplay(Alpha|Beta)Test", + "--mt", + "testFuzz_shared", + "--sender", + "0x000000000000000000000000000000000000dEaD", + "zero-replay-multi-target-corpus", + "--corpus-out", + "min-zero-replay-stale-multi-target-corpus", + ]) + .assert_failure(); + let stderr = String::from_utf8(assert.get_output().stderr.clone()).unwrap(); + assert!(stderr.contains("1 transactions did not match the test"), "{stderr}"); + assert!(!stderr.contains("2 transactions did not match the test"), "{stderr}"); +} + +#[forgetest_init] +fn forge_fuzz_cmin_rejects_stale_stateless_target(prj: _, cmd: _) { + prj.add_test( + "ForgeFuzzCminStaleTarget.t.sol", + r#" +contract ForgeFuzzCminStaleTargetTest { + uint256 value; + + function testFuzz_branch(uint256 input) public { + if (input == 1) { + value = 1; + } + } +} + "#, + ); + cmd.args(["build", "-q"]).assert_success(); + + let abi = artifact_abi( + prj.root(), + "out/ForgeFuzzCminStaleTarget.t.sol/ForgeFuzzCminStaleTargetTest.json", + ); + let one = calldata_for(&abi, "testFuzz_branch", 1); + let corpus = prj.root().join("stale-target-corpus"); + std::fs::create_dir_all(&corpus).unwrap(); + write_corpus_entry(&corpus, "00000000-0000-0000-0000-000000000001-1.json", &one); + + cmd.forge_fuse() + .args([ + "fuzz", + "cmin", + "--mc", + "ForgeFuzzCminStaleTargetTest", + "--mt", + "testFuzz_branch", + "stale-target-corpus", + "--corpus-out", + "min-stale-target-corpus", + ]) + .assert_success(); + + let assert = cmd + .forge_fuse() + .args([ + "fuzz", + "cmin", + "--mc", + "ForgeFuzzCminStaleTargetTest", + "--mt", + "testFuzz_branch", + "--sender", + "0x000000000000000000000000000000000000dEaD", + "stale-target-corpus", + "--corpus-out", + "min-wrong-sender-corpus", + ]) + .assert_failure(); + let stderr = String::from_utf8(assert.get_output().stderr.clone()).unwrap(); + assert!(stderr.contains("replayed 0 transactions from stale-target-corpus"), "{stderr}"); + assert!(stderr.contains("replay-critical options"), "{stderr}"); + assert!(!prj.root().join("min-wrong-sender-corpus").exists()); +} + +#[forgetest_init] +fn forge_fuzz_cmin_reports_zero_replay_reasons(prj: _, cmd: _) { + prj.add_test( + "ForgeFuzzCminZeroReplay.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract ForgeFuzzCminZeroReplayTest is Test { + function testFuzz_assumeAlways(uint256 input) public { + input; + vm.assume(false); + } +} + "#, + ); + cmd.args(["build", "-q"]).assert_success(); + + let abi = artifact_abi( + prj.root(), + "out/ForgeFuzzCminZeroReplay.t.sol/ForgeFuzzCminZeroReplayTest.json", + ); + let assume = calldata_for(&abi, "testFuzz_assumeAlways", 1); + let assume_corpus = prj.root().join("assume-corpus"); + std::fs::create_dir_all(&assume_corpus).unwrap(); + write_corpus_entry(&assume_corpus, "00000000-0000-0000-0000-000000000001-1.json", &assume); + + let assert = cmd + .forge_fuse() + .args([ + "fuzz", + "cmin", + "--mc", + "ForgeFuzzCminZeroReplayTest", + "--mt", + "testFuzz_assumeAlways", + "assume-corpus", + "--corpus-out", + "min-assume-corpus", + ]) + .assert_failure(); + let stderr = String::from_utf8(assert.get_output().stderr.clone()).unwrap(); + assert!(stderr.contains("1 transactions were rejected by vm.assume or vm.skip"), "{stderr}"); + assert!(!stderr.contains("replay-critical options"), "{stderr}"); + assert!(!prj.root().join("min-assume-corpus").exists()); + + let empty_corpus = prj.root().join("empty-corpus"); + std::fs::create_dir_all(&empty_corpus).unwrap(); + std::fs::write(empty_corpus.join("00000000-0000-0000-0000-000000000002-2.json"), "[]").unwrap(); + + let assert = cmd + .forge_fuse() + .args([ + "fuzz", + "cmin", + "--mc", + "ForgeFuzzCminZeroReplayTest", + "--mt", + "testFuzz_assumeAlways", + "empty-corpus", + "--corpus-out", + "min-empty-corpus", + ]) + .assert_failure(); + let stderr = String::from_utf8(assert.get_output().stderr.clone()).unwrap(); + assert!(stderr.contains("corpus entries were empty"), "{stderr}"); + assert!(!stderr.contains("replay-critical options"), "{stderr}"); + assert!(!prj.root().join("min-empty-corpus").exists()); +} + +#[forgetest_init] +fn forge_fuzz_cmin_minimizes_invariant_corpus(prj: _, cmd: _) { + prj.add_test( + "ForgeFuzzCminInvariantTarget.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract ForgeFuzzCminInvariantTargetTest is Test { + uint256 value; + + function setUp() public { + targetContract(address(this)); + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = this.touch.selector; + targetSelector(FuzzSelector({addr: address(this), selectors: selectors})); + } + + function touch(uint256 input) external { + if (input == 1) { + value = 1; + } + if (input == 2) { + value = 2; + } + } + + function invariant_ok() public view { + assertTrue(value <= 2); + } +} + "#, + ); + cmd.args(["build", "-q"]).assert_success(); + + let abi = artifact_abi( + prj.root(), + "out/ForgeFuzzCminInvariantTarget.t.sol/ForgeFuzzCminInvariantTargetTest.json", + ); + let one = calldata_for(&abi, "touch", 1); + let two = calldata_for(&abi, "touch", 2); + let corpus = prj.root().join("invariant-corpus"); + std::fs::create_dir_all(&corpus).unwrap(); + write_corpus_entry(&corpus, "00000000-0000-0000-0000-000000000001-1.json", &one); + write_corpus_entry(&corpus, "00000000-0000-0000-0000-000000000002-2.json", &one); + write_corpus_entry(&corpus, "00000000-0000-0000-0000-000000000003-3.json", &two); + + let assert = cmd + .forge_fuse() + .args([ + "fuzz", + "cmin", + "--mc", + "ForgeFuzzCminInvariantTargetTest", + "--mt", + "invariant_ok", + "invariant-corpus", + "--corpus-out", + "min-invariant-corpus", + ]) + .assert_success(); + let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + assert!( + stdout.contains("minimized corpus: kept 2/3 entries in min-invariant-corpus"), + "{stdout}" + ); + assert_eq!(regular_file_count(&prj.root().join("min-invariant-corpus")), 2); +} + +#[forgetest_init] +fn forge_fuzz_cmin_minimizes_broken_invariant_corpus(prj: _, cmd: _) { + prj.add_test( + "ForgeFuzzCminBrokenInvariant.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract ForgeFuzzCminBrokenInvariantTest is Test { + bool broken; + uint256 left; + uint256 right; + + function setUp() public { + targetContract(address(this)); + bytes4[] memory selectors = new bytes4[](2); + selectors[0] = this.breakInvariant.selector; + selectors[1] = this.laterBranch.selector; + targetSelector(FuzzSelector({addr: address(this), selectors: selectors})); + } + + function breakInvariant(uint256) external { + broken = true; + } + + function laterBranch(uint256 input) external { + if (input == 1) { + left = 1; + } else { + right = 1; + } + } + + function invariant_canary() public view { + assertFalse(broken); + } +} + "#, + ); + cmd.args(["build", "-q"]).assert_success(); + + let abi = artifact_abi( + prj.root(), + "out/ForgeFuzzCminBrokenInvariant.t.sol/ForgeFuzzCminBrokenInvariantTest.json", + ); + let break_invariant = calldata_for(&abi, "breakInvariant", 0); + let one = calldata_for(&abi, "laterBranch", 1); + let two = calldata_for(&abi, "laterBranch", 2); + let corpus = prj.root().join("broken-invariant-corpus"); + std::fs::create_dir_all(&corpus).unwrap(); + write_corpus_sequence_entry( + &corpus, + "00000000-0000-0000-0000-000000000001-1.json", + &[&break_invariant, &one], + ); + write_corpus_sequence_entry( + &corpus, + "00000000-0000-0000-0000-000000000002-2.json", + &[&break_invariant, &one], + ); + write_corpus_sequence_entry( + &corpus, + "00000000-0000-0000-0000-000000000003-3.json", + &[&break_invariant, &two], + ); + + cmd.forge_fuse() + .args([ + "test", + "--mc", + "ForgeFuzzCminBrokenInvariantTest", + "--mt", + "invariant_canary", + "--showmap-out", + "showmap-before-broken-invariant-cmin", + "--showmap-corpus-dir", + "broken-invariant-corpus", + "--showmap-trial", + "t", + ]) + .assert_success(); + + let assert = cmd + .forge_fuse() + .args([ + "fuzz", + "cmin", + "--mc", + "ForgeFuzzCminBrokenInvariantTest", + "--mt", + "invariant_canary", + "broken-invariant-corpus", + "--corpus-out", + "min-broken-invariant-corpus", + ]) + .assert_success(); + let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + assert!( + stdout.contains("minimized corpus: kept 2/3 entries in min-broken-invariant-corpus"), + "{stdout}" + ); + assert_eq!(regular_file_count(&prj.root().join("min-broken-invariant-corpus")), 2); + + cmd.forge_fuse() + .args([ + "test", + "--mc", + "ForgeFuzzCminBrokenInvariantTest", + "--mt", + "invariant_canary", + "--showmap-out", + "showmap-after-broken-invariant-cmin", + "--showmap-corpus-dir", + "min-broken-invariant-corpus", + "--showmap-trial", + "t", + ]) + .assert_success(); + assert_eq!( + showmap_edge_ids(&prj.root().join("showmap-before-broken-invariant-cmin")), + showmap_edge_ids(&prj.root().join("showmap-after-broken-invariant-cmin")) + ); +} + +#[forgetest_init] +fn forge_fuzz_cmin_rejects_masked_handler_failure(prj: _, cmd: _) { + prj.update_config(|config| config.invariant.check_interval = 0); + prj.add_test( + "ForgeFuzzCminMaskedHandler.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract ForgeFuzzCminMaskedHandlerTest is Test { + bool broken; + + function setUp() public { + targetContract(address(this)); + bytes4[] memory selectors = new bytes4[](2); + selectors[0] = this.breakInvariant.selector; + selectors[1] = this.assertHandler.selector; + targetSelector(FuzzSelector({addr: address(this), selectors: selectors})); + } + + function breakInvariant(uint256) external { + broken = true; + } + + function assertHandler(uint256) external { + assertTrue(false); + } + + function invariant_canary() public view { + assertFalse(broken); + } +} + "#, + ); + cmd.args(["build", "-q"]).assert_success(); + + let abi = artifact_abi( + prj.root(), + "out/ForgeFuzzCminMaskedHandler.t.sol/ForgeFuzzCminMaskedHandlerTest.json", + ); + let break_invariant = calldata_for(&abi, "breakInvariant", 0); + let assert_handler = calldata_for(&abi, "assertHandler", 0); + let corpus = prj.root().join("masked-handler-corpus"); + std::fs::create_dir_all(&corpus).unwrap(); + write_corpus_sequence_entry( + &corpus, + "00000000-0000-0000-0000-000000000001-1.json", + &[&break_invariant, &assert_handler], + ); + + let assert = cmd + .forge_fuse() + .args([ + "fuzz", + "cmin", + "--mc", + "ForgeFuzzCminMaskedHandlerTest", + "--mt", + "invariant_canary", + "masked-handler-corpus", + "--corpus-out", + "min-masked-handler-corpus", + ]) + .assert_failure(); + let stderr = String::from_utf8(assert.get_output().stderr.clone()).unwrap(); + assert!(stderr.contains("1 corpus entries failed during replay"), "{stderr}"); +} + +#[forgetest_init] +fn forge_fuzz_tmin_removes_redundant_transactions(prj: _, cmd: _) { + prj.add_test( + "ForgeFuzzTminRemoveTarget.t.sol", + r#" +contract ForgeFuzzTminRemoveTargetTest { + uint256 value; + + function testFuzz_samePath(uint256 input) public { + if (input < 100) { + value = 1; + } + } +} + "#, + ); + cmd.args(["build", "-q"]).assert_success(); + + let abi = artifact_abi( + prj.root(), + "out/ForgeFuzzTminRemoveTarget.t.sol/ForgeFuzzTminRemoveTargetTest.json", + ); + let calldata = calldata_for(&abi, "testFuzz_samePath", 42); + let corpus = prj.root().join("tmin-remove-corpus"); + std::fs::create_dir_all(&corpus).unwrap(); + write_corpus_sequence_entry( + &corpus, + "00000000-0000-0000-0000-000000000001-1.json", + &[&calldata, &calldata], + ); + + let assert = cmd + .forge_fuse() + .args([ + "fuzz", + "tmin", + "--mc", + "ForgeFuzzTminRemoveTargetTest", + "--mt", + "testFuzz_samePath", + "tmin-remove-corpus/00000000-0000-0000-0000-000000000001-1.json", + "--corpus-out", + "tmin-remove-output.json", + ]) + .assert_success(); + let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + let stderr = String::from_utf8(assert.get_output().stderr.clone()).unwrap(); + assert!( + stdout.contains("minimized entry: 2 txs -> 1 txs in tmin-remove-output.json"), + "{stdout}" + ); + assert!(!stdout.contains("attempted"), "{stdout}"); + assert!(stderr.contains("attempted"), "{stderr}"); + + let output: Value = serde_json::from_str( + &std::fs::read_to_string(prj.root().join("tmin-remove-output.json")).unwrap(), + ) + .unwrap(); + assert_eq!(output.as_array().unwrap().len(), 1); +} + +#[forgetest_init] +fn forge_fuzz_tmin_gates_predicate_after_handler_failure(prj: _, cmd: _) { + prj.update_config(|config| { + config.assertions_revert = false; + config.invariant.check_interval = 0; + }); + prj.add_test( + "ForgeFuzzTminHandlerGate.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract ForgeFuzzTminHandlerGateTest is Test { + bool broken; + + function setUp() public { + targetContract(address(this)); + bytes4[] memory selectors = new bytes4[](2); + selectors[0] = this.breakInvariant.selector; + selectors[1] = this.assertHandler.selector; + targetSelector(FuzzSelector({addr: address(this), selectors: selectors})); + } + + function breakInvariant(uint256) external { + broken = true; + } + + function assertHandler(uint256) external { + assertTrue(false); + } + + function invariant_ok() public view { + assertFalse(broken); + } +} + "#, + ); + cmd.args(["build", "-q"]).assert_success(); + + let abi = artifact_abi( + prj.root(), + "out/ForgeFuzzTminHandlerGate.t.sol/ForgeFuzzTminHandlerGateTest.json", + ); + let break_invariant = calldata_for(&abi, "breakInvariant", 0); + let assert_handler = calldata_for(&abi, "assertHandler", 0); + let corpus = prj.root().join("tmin-handler-gate-corpus"); + std::fs::create_dir_all(&corpus).unwrap(); + write_corpus_sequence_entry( + &corpus, + "00000000-0000-0000-0000-000000000001-1.json", + &[&break_invariant, &assert_handler], + ); + + cmd.forge_fuse() + .args([ + "fuzz", + "tmin", + "--mc", + "ForgeFuzzTminHandlerGateTest", + "--mt", + "invariant_ok", + "tmin-handler-gate-corpus/00000000-0000-0000-0000-000000000001-1.json", + "--corpus-out", + "tmin-handler-gate-output.json", + ]) + .assert_success(); + + let output: Value = serde_json::from_str( + &std::fs::read_to_string(prj.root().join("tmin-handler-gate-output.json")).unwrap(), + ) + .unwrap(); + assert_eq!(output.as_array().unwrap().len(), 1); + assert_eq!(output[0]["calldata"].as_str().unwrap(), assert_handler); +} + +#[forgetest_init] +fn forge_fuzz_tmin_preserves_after_invariant_and_handler_failure(prj: _, cmd: _) { + prj.update_config(|config| { + config.assertions_revert = false; + config.invariant.check_interval = 0; + }); + prj.add_test( + "ForgeFuzzTminAfterInvariant.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract ForgeFuzzTminAfterInvariantTest is Test { + bool afterFails; + + function setUp() public { + targetContract(address(this)); + bytes4[] memory selectors = new bytes4[](2); + selectors[0] = this.armAfterInvariant.selector; + selectors[1] = this.assertHandler.selector; + targetSelector(FuzzSelector({addr: address(this), selectors: selectors})); + } + + function armAfterInvariant(uint256) external { + afterFails = true; + } + + function assertHandler(uint256) external { + assertTrue(false); + } + + function invariant_ok() public pure {} + + function afterInvariant() external view { + if (afterFails) { + revert("after"); + } + } +} + "#, + ); + cmd.args(["build", "-q"]).assert_success(); + + let abi = artifact_abi( + prj.root(), + "out/ForgeFuzzTminAfterInvariant.t.sol/ForgeFuzzTminAfterInvariantTest.json", + ); + let arm_after_invariant = calldata_for(&abi, "armAfterInvariant", 0); + let assert_handler = calldata_for(&abi, "assertHandler", 0); + let corpus = prj.root().join("tmin-after-invariant-corpus"); + std::fs::create_dir_all(&corpus).unwrap(); + write_corpus_sequence_entry( + &corpus, + "00000000-0000-0000-0000-000000000001-1.json", + &[&arm_after_invariant, &assert_handler], + ); + + cmd.forge_fuse() + .args([ + "fuzz", + "tmin", + "--mc", + "ForgeFuzzTminAfterInvariantTest", + "--mt", + "invariant_ok", + "tmin-after-invariant-corpus/00000000-0000-0000-0000-000000000001-1.json", + "--corpus-out", + "tmin-after-invariant-output.json", + ]) + .assert_success(); + + let output: Value = serde_json::from_str( + &std::fs::read_to_string(prj.root().join("tmin-after-invariant-output.json")).unwrap(), + ) + .unwrap(); + assert_eq!(output.as_array().unwrap().len(), 2); + assert_eq!(output[0]["calldata"].as_str().unwrap(), arm_after_invariant); + assert_eq!(output[1]["calldata"].as_str().unwrap(), assert_handler); +} + +#[forgetest_init] +fn forge_fuzz_tmin_simplifies_abi_calldata(prj: _, cmd: _) { + prj.add_test( + "ForgeFuzzTminAbiTarget.t.sol", + r#" +contract ForgeFuzzTminAbiTargetTest { + uint256 value; + + function testFuzz_small(uint256 input) public { + if (input < 100) { + value = 1; + } + } +} + "#, + ); + cmd.args(["build", "-q"]).assert_success(); + + let abi = artifact_abi( + prj.root(), + "out/ForgeFuzzTminAbiTarget.t.sol/ForgeFuzzTminAbiTargetTest.json", + ); + let calldata = calldata_for(&abi, "testFuzz_small", 42); + let corpus = prj.root().join("tmin-abi-corpus"); + std::fs::create_dir_all(&corpus).unwrap(); + write_corpus_entry(&corpus, "00000000-0000-0000-0000-000000000001-1.json", &calldata); + + cmd.forge_fuse() + .args([ + "fuzz", + "tmin", + "--mc", + "ForgeFuzzTminAbiTargetTest", + "--mt", + "testFuzz_small", + "tmin-abi-corpus/00000000-0000-0000-0000-000000000001-1.json", + "--corpus-out", + "tmin-abi-output.json", + ]) + .assert_success(); + + let output: Value = serde_json::from_str( + &std::fs::read_to_string(prj.root().join("tmin-abi-output.json")).unwrap(), + ) + .unwrap(); + let minimized = output[0]["calldata"].as_str().unwrap(); + assert!( + minimized.ends_with("0000000000000000000000000000000000000000000000000000000000000000") + || minimized + .ends_with("0000000000000000000000000000000000000000000000000000000000000001"), + "{minimized}" + ); + assert!( + !minimized.ends_with("000000000000000000000000000000000000000000000000000000000000002a") + ); +} + +#[forgetest_init] +fn forge_fuzz_tmin_rejects_extra_coverage_edges(prj: _, cmd: _) { + prj.add_test( + "ForgeFuzzTminExactEdgesTarget.t.sol", + r#" +contract ForgeFuzzTminExactEdgesTargetTest { + uint256 value; + + function testFuzz_exactEdges(uint256 input) public { + if (input < 100) { + value = 1; + } + if (input == 0) { + value = 2; + } + } +} + "#, + ); + cmd.args(["build", "-q"]).assert_success(); + + let abi = artifact_abi( + prj.root(), + "out/ForgeFuzzTminExactEdgesTarget.t.sol/ForgeFuzzTminExactEdgesTargetTest.json", + ); + let calldata = calldata_for(&abi, "testFuzz_exactEdges", 42); + let corpus = prj.root().join("tmin-exact-edges-corpus"); + std::fs::create_dir_all(&corpus).unwrap(); + write_corpus_entry(&corpus, "00000000-0000-0000-0000-000000000001-1.json", &calldata); + + cmd.forge_fuse() + .args([ + "fuzz", + "tmin", + "--mc", + "ForgeFuzzTminExactEdgesTargetTest", + "--mt", + "testFuzz_exactEdges", + "tmin-exact-edges-corpus/00000000-0000-0000-0000-000000000001-1.json", + "--corpus-out", + "tmin-exact-edges-output.json", + ]) + .assert_success(); + + let args = output_calldata_args( + prj.root(), + "tmin-exact-edges-output.json", + &abi, + "testFuzz_exactEdges", + ); + assert_eq!(args, vec![DynSolValue::Uint(U256::ONE, 256)]); +} + +#[forgetest_init] +fn forge_fuzz_tmin_keeps_multiple_args_simplified(prj: _, cmd: _) { + prj.add_test( + "ForgeFuzzTminMultiArgTarget.t.sol", + r#" +contract ForgeFuzzTminMultiArgTargetTest { + uint256 value; + + function testFuzz_multi(uint256 left, uint256 right) public { + if (left < 100) { + value = 1; + } + if (right < 100) { + value = 2; + } + } +} + "#, + ); + cmd.args(["build", "-q"]).assert_success(); + + let abi = artifact_abi( + prj.root(), + "out/ForgeFuzzTminMultiArgTarget.t.sol/ForgeFuzzTminMultiArgTargetTest.json", + ); + let calldata = calldata_for_args( + &abi, + "testFuzz_multi", + &[DynSolValue::Uint(U256::from(42), 256), DynSolValue::Uint(U256::from(43), 256)], + ); + let corpus = prj.root().join("tmin-multi-arg-corpus"); + std::fs::create_dir_all(&corpus).unwrap(); + write_corpus_entry(&corpus, "00000000-0000-0000-0000-000000000001-1.json", &calldata); + + cmd.forge_fuse() + .args([ + "fuzz", + "tmin", + "--mc", + "ForgeFuzzTminMultiArgTargetTest", + "--mt", + "testFuzz_multi", + "tmin-multi-arg-corpus/00000000-0000-0000-0000-000000000001-1.json", + "--corpus-out", + "tmin-multi-arg-output.json", + ]) + .assert_success(); + + let args = + output_calldata_args(prj.root(), "tmin-multi-arg-output.json", &abi, "testFuzz_multi"); + assert_eq!(args, vec![DynSolValue::Uint(U256::ZERO, 256), DynSolValue::Uint(U256::ZERO, 256)]); +} + +#[forgetest_init] +fn forge_fuzz_tmin_keeps_array_length_reduction(prj: _, cmd: _) { + prj.add_test( + "ForgeFuzzTminArrayTarget.t.sol", + r#" +contract ForgeFuzzTminArrayTargetTest { + uint256 value; + + function testFuzz_array(uint256[] memory inputs) public { + if (inputs.length <= 4) { + value = 1; + } + } +} + "#, + ); + cmd.args(["build", "-q"]).assert_success(); + + let abi = artifact_abi( + prj.root(), + "out/ForgeFuzzTminArrayTarget.t.sol/ForgeFuzzTminArrayTargetTest.json", + ); + let calldata = calldata_for_args( + &abi, + "testFuzz_array", + &[DynSolValue::Array(vec![ + DynSolValue::Uint(U256::from(10), 256), + DynSolValue::Uint(U256::from(11), 256), + DynSolValue::Uint(U256::from(12), 256), + DynSolValue::Uint(U256::from(13), 256), + ])], + ); + let corpus = prj.root().join("tmin-array-corpus"); + std::fs::create_dir_all(&corpus).unwrap(); + write_corpus_entry(&corpus, "00000000-0000-0000-0000-000000000001-1.json", &calldata); + + cmd.forge_fuse() + .args([ + "fuzz", + "tmin", + "--mc", + "ForgeFuzzTminArrayTargetTest", + "--mt", + "testFuzz_array", + "tmin-array-corpus/00000000-0000-0000-0000-000000000001-1.json", + "--corpus-out", + "tmin-array-output.json", + ]) + .assert_success(); + + let args = output_calldata_args(prj.root(), "tmin-array-output.json", &abi, "testFuzz_array"); + let [DynSolValue::Array(values)] = args.as_slice() else { + panic!("expected one array argument, got {args:?}"); + }; + assert!(values.len() < 4, "{values:?}"); +} + +#[forgetest_init] +fn forge_fuzz_tmin_preserves_fuzz_failure_identity(prj: _, cmd: _) { + prj.add_test( + "ForgeFuzzTminFailureTarget.t.sol", + r#" +contract ForgeFuzzTminFailureTargetTest { + function testFuzz_failure(uint256 input) public pure { + if (input == 0) { + revert("zero"); + } + if (input == 1) { + revert("one"); + } + } +} + "#, + ); + cmd.args(["build", "-q"]).assert_success(); + + let abi = artifact_abi( + prj.root(), + "out/ForgeFuzzTminFailureTarget.t.sol/ForgeFuzzTminFailureTargetTest.json", + ); + let calldata = calldata_for(&abi, "testFuzz_failure", 1); + let corpus = prj.root().join("tmin-failure-corpus"); + std::fs::create_dir_all(&corpus).unwrap(); + write_corpus_entry(&corpus, "00000000-0000-0000-0000-000000000001-1.json", &calldata); + + cmd.forge_fuse() + .args([ + "fuzz", + "tmin", + "--mc", + "ForgeFuzzTminFailureTargetTest", + "--mt", + "testFuzz_failure", + "tmin-failure-corpus/00000000-0000-0000-0000-000000000001-1.json", + "--corpus-out", + "tmin-failure-output.json", + ]) + .assert_success(); + + let output: Value = serde_json::from_str( + &std::fs::read_to_string(prj.root().join("tmin-failure-output.json")).unwrap(), + ) + .unwrap(); + let minimized = output[0]["calldata"].as_str().unwrap(); + assert!( + minimized.ends_with("0000000000000000000000000000000000000000000000000000000000000001"), + "{minimized}" + ); +} + +#[forgetest_init] +fn forge_fuzz_tmin_preserves_handler_and_predicate_failures(prj: _, cmd: _) { + prj.update_config(|config| config.invariant.check_interval = 1); + prj.add_test( + "ForgeFuzzTminHandlerAndPredicate.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract ForgeFuzzTminHandlerAndPredicateTest is Test { + bool broken; + + function setUp() public { + targetContract(address(this)); + bytes4[] memory selectors = new bytes4[](2); + selectors[0] = this.assertHandler.selector; + selectors[1] = this.breakInvariant.selector; + targetSelector(FuzzSelector({addr: address(this), selectors: selectors})); + } + + function assertHandler() external { + assertTrue(false); + } + + function breakInvariant() external { + broken = true; + } + + function invariant_canary() public view { + assertFalse(broken); + } +} + "#, + ); + cmd.args(["build", "-q"]).assert_success(); + + let abi = artifact_abi( + prj.root(), + "out/ForgeFuzzTminHandlerAndPredicate.t.sol/ForgeFuzzTminHandlerAndPredicateTest.json", + ); + let assert_handler = calldata_for_args(&abi, "assertHandler", &[]); + let break_invariant = calldata_for_args(&abi, "breakInvariant", &[]); + let corpus = prj.root().join("tmin-handler-predicate-corpus"); + std::fs::create_dir_all(&corpus).unwrap(); + write_corpus_sequence_entry( + &corpus, + "00000000-0000-0000-0000-000000000001-1.json", + &[&assert_handler, &break_invariant], + ); + + cmd.forge_fuse() + .args([ + "fuzz", + "tmin", + "--mc", + "ForgeFuzzTminHandlerAndPredicateTest", + "--mt", + "invariant_canary", + "tmin-handler-predicate-corpus/00000000-0000-0000-0000-000000000001-1.json", + "--corpus-out", + "tmin-handler-predicate-output.json", + ]) + .assert_success(); + + let output: Value = serde_json::from_str( + &std::fs::read_to_string(prj.root().join("tmin-handler-predicate-output.json")).unwrap(), + ) + .unwrap(); + assert_eq!(output.as_array().unwrap().len(), 2, "{output}"); +} + +#[forgetest_init] +fn forge_fuzz_tmin_deduplicates_handler_failure_paths(prj: _, cmd: _) { + prj.update_config(|config| config.assertions_revert = false); + prj.add_test( + "ForgeFuzzTminHandlerPaths.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract ForgeFuzzTminHandlerPathsTest is Test { + bool alternatePath; + + function setUp() public { + targetContract(address(this)); + bytes4[] memory selectors = new bytes4[](2); + selectors[0] = this.enableAlternatePath.selector; + selectors[1] = this.assertHandler.selector; + targetSelector(FuzzSelector({addr: address(this), selectors: selectors})); + } + + function enableAlternatePath() external { + alternatePath = true; + } + + function assertHandler() external view { + if (alternatePath) { + assertTrue(false); + } else { + assertTrue(false); + } + } + + function invariant_ok() public pure {} +} + "#, + ); + cmd.args(["build", "-q"]).assert_success(); + + let abi = artifact_abi( + prj.root(), + "out/ForgeFuzzTminHandlerPaths.t.sol/ForgeFuzzTminHandlerPathsTest.json", + ); + let enable_alternate_path = calldata_for_args(&abi, "enableAlternatePath", &[]); + let assert_handler = calldata_for_args(&abi, "assertHandler", &[]); + let corpus = prj.root().join("tmin-handler-paths-corpus"); + std::fs::create_dir_all(&corpus).unwrap(); + write_corpus_sequence_entry( + &corpus, + "00000000-0000-0000-0000-000000000001-1.json", + &[&enable_alternate_path, &assert_handler], + ); + + cmd.forge_fuse() + .args([ + "fuzz", + "tmin", + "--mc", + "ForgeFuzzTminHandlerPathsTest", + "--mt", + "invariant_ok", + "tmin-handler-paths-corpus/00000000-0000-0000-0000-000000000001-1.json", + "--corpus-out", + "tmin-handler-paths-output.json", + ]) + .assert_success(); + + let output: Value = serde_json::from_str( + &std::fs::read_to_string(prj.root().join("tmin-handler-paths-output.json")).unwrap(), + ) + .unwrap(); + assert_eq!(output.as_array().unwrap().len(), 1, "{output}"); + assert_eq!(output[0]["calldata"].as_str().unwrap(), assert_handler); +} + +#[forgetest_init] +fn forge_fuzz_tmin_stops_after_all_predicates_fail(prj: _, cmd: _) { + prj.update_config(|config| { + config.assertions_revert = false; + config.invariant.check_interval = 1; + }); + prj.add_test( + "ForgeFuzzTminAllPredicates.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract ForgeFuzzTminAllPredicatesTest is Test { + bool broken; + + function setUp() public { + targetContract(address(this)); + bytes4[] memory selectors = new bytes4[](2); + selectors[0] = this.breakBothPredicates.selector; + selectors[1] = this.assertHandler.selector; + targetSelector(FuzzSelector({addr: address(this), selectors: selectors})); + } + + function breakBothPredicates() external { + broken = true; + } + + function assertHandler() external { + assertTrue(false); + } + + function invariant_first() public view { + assertFalse(broken); + } + + function invariant_second() public view { + assertFalse(broken); + } +} + "#, + ); + cmd.args(["build", "-q"]).assert_success(); + + let abi = artifact_abi( + prj.root(), + "out/ForgeFuzzTminAllPredicates.t.sol/ForgeFuzzTminAllPredicatesTest.json", + ); + let break_both_predicates = calldata_for_args(&abi, "breakBothPredicates", &[]); + let assert_handler = calldata_for_args(&abi, "assertHandler", &[]); + let corpus = prj.root().join("tmin-all-predicates-corpus"); + std::fs::create_dir_all(&corpus).unwrap(); + write_corpus_sequence_entry( + &corpus, + "00000000-0000-0000-0000-000000000001-1.json", + &[&break_both_predicates, &assert_handler], + ); + + cmd.forge_fuse() + .args([ + "fuzz", + "tmin", + "--mc", + "ForgeFuzzTminAllPredicatesTest", + "--mt", + "invariant_", + "tmin-all-predicates-corpus/00000000-0000-0000-0000-000000000001-1.json", + "--corpus-out", + "tmin-all-predicates-output.json", + ]) + .assert_success(); + + let output: Value = serde_json::from_str( + &std::fs::read_to_string(prj.root().join("tmin-all-predicates-output.json")).unwrap(), + ) + .unwrap(); + assert_eq!(output.as_array().unwrap().len(), 1, "{output}"); + assert_eq!(output[0]["calldata"].as_str().unwrap(), break_both_predicates); +} + +#[forgetest_init] +fn forge_fuzz_tmin_preserves_mixed_fail_on_revert_suffix(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.check_interval = 1; + config.invariant.fail_on_revert = false; + }); + prj.add_test( + "ForgeFuzzTminMixedFailOnRevert.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract ForgeFuzzTminMixedFailOnRevertTest is Test { + bool thirdBroken; + + function setUp() public { + targetContract(address(this)); + bytes4[] memory selectors = new bytes4[](2); + selectors[0] = this.revertHandler.selector; + selectors[1] = this.breakThird.selector; + targetSelector(FuzzSelector({addr: address(this), selectors: selectors})); + } + + function revertHandler() external pure { + revert("handler"); + } + + function breakThird() external { + thirdBroken = true; + } + + /// forge-config: default.invariant.fail-on-revert = true + function invariant_first() public pure {} + + /// forge-config: default.invariant.fail-on-revert = true + function invariant_second() public pure {} + + function invariant_third() public view { + assertFalse(thirdBroken); + } +} + "#, + ); + cmd.args(["build", "-q"]).assert_success(); + + let abi = artifact_abi( + prj.root(), + "out/ForgeFuzzTminMixedFailOnRevert.t.sol/ForgeFuzzTminMixedFailOnRevertTest.json", + ); + let revert_handler = calldata_for_args(&abi, "revertHandler", &[]); + let break_third = calldata_for_args(&abi, "breakThird", &[]); + let corpus = prj.root().join("tmin-mixed-fail-on-revert-corpus"); + std::fs::create_dir_all(&corpus).unwrap(); + write_corpus_sequence_entry( + &corpus, + "00000000-0000-0000-0000-000000000001-1.json", + &[&revert_handler, &break_third], + ); + + cmd.forge_fuse() + .args([ + "fuzz", + "tmin", + "--mc", + "ForgeFuzzTminMixedFailOnRevertTest", + "--mt", + "invariant_", + "tmin-mixed-fail-on-revert-corpus/00000000-0000-0000-0000-000000000001-1.json", + "--corpus-out", + "tmin-mixed-fail-on-revert-output.json", + ]) + .assert_success(); + + let output: Value = serde_json::from_str( + &std::fs::read_to_string(prj.root().join("tmin-mixed-fail-on-revert-output.json")).unwrap(), + ) + .unwrap(); + assert_eq!(output.as_array().unwrap().len(), 2, "{output}"); + assert_eq!(output[0]["calldata"].as_str().unwrap(), revert_handler); + assert_eq!(output[1]["calldata"].as_str().unwrap(), break_third); +} + +#[forgetest_init] +fn forge_fuzz_tmin_preserves_multiple_predicate_failures(prj: _, cmd: _) { + prj.update_config(|config| config.invariant.check_interval = 1); + prj.add_test( + "ForgeFuzzTminMultiplePredicates.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract ForgeFuzzTminMultiplePredicatesTest is Test { + bool firstBroken; + bool secondBroken; + + function setUp() public { + targetContract(address(this)); + bytes4[] memory selectors = new bytes4[](2); + selectors[0] = this.breakFirst.selector; + selectors[1] = this.breakSecond.selector; + targetSelector(FuzzSelector({addr: address(this), selectors: selectors})); + } + + function breakFirst() external { + firstBroken = true; + } + + function breakSecond() external { + secondBroken = true; + } + + function invariant_first() public view { + assertFalse(firstBroken); + } + + function invariant_second() public view { + assertFalse(secondBroken); + } +} + "#, + ); + cmd.args(["build", "-q"]).assert_success(); + + let abi = artifact_abi( + prj.root(), + "out/ForgeFuzzTminMultiplePredicates.t.sol/ForgeFuzzTminMultiplePredicatesTest.json", + ); + let break_first = calldata_for_args(&abi, "breakFirst", &[]); + let break_second = calldata_for_args(&abi, "breakSecond", &[]); + let corpus = prj.root().join("tmin-multiple-predicates-corpus"); + std::fs::create_dir_all(&corpus).unwrap(); + write_corpus_sequence_entry( + &corpus, + "00000000-0000-0000-0000-000000000001-1.json", + &[&break_first, &break_second], + ); + + cmd.forge_fuse() + .args([ + "fuzz", + "tmin", + "--mc", + "ForgeFuzzTminMultiplePredicatesTest", + "--mt", + "invariant_", + "tmin-multiple-predicates-corpus/00000000-0000-0000-0000-000000000001-1.json", + "--corpus-out", + "tmin-multiple-predicates-output.json", + ]) + .assert_success(); + + let output: Value = serde_json::from_str( + &std::fs::read_to_string(prj.root().join("tmin-multiple-predicates-output.json")).unwrap(), + ) + .unwrap(); + assert_eq!(output.as_array().unwrap().len(), 2, "{output}"); +} + +#[forgetest_init] +fn forge_fuzz_tmin_preserves_after_invariant_after_final_predicate_check(prj: _, cmd: _) { + prj.update_config(|config| config.invariant.check_interval = 0); + prj.add_test( + "ForgeFuzzTminAfterFinalPredicates.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract ForgeFuzzTminAfterFinalPredicatesTest is Test { + bool afterFails; + + function setUp() public { + targetContract(address(this)); + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = this.setAfterInvariantFailure.selector; + targetSelector(FuzzSelector({addr: address(this), selectors: selectors})); + } + + function setAfterInvariantFailure(bool value) external { + afterFails = value; + } + + function invariant_ok() public pure {} + + function afterInvariant() external view { + if (afterFails) { + revert("after"); + } + } +} + "#, + ); + cmd.args(["build", "-q"]).assert_success(); + + let abi = artifact_abi( + prj.root(), + "out/ForgeFuzzTminAfterFinalPredicates.t.sol/ForgeFuzzTminAfterFinalPredicatesTest.json", + ); + let arm_after_invariant = calldata_for(&abi, "setAfterInvariantFailure", 1); + let corpus = prj.root().join("tmin-after-final-predicates-corpus"); + std::fs::create_dir_all(&corpus).unwrap(); + write_corpus_sequence_entry( + &corpus, + "00000000-0000-0000-0000-000000000001-1.json", + &[&arm_after_invariant], + ); + + cmd.forge_fuse() + .args([ + "fuzz", + "tmin", + "--mc", + "ForgeFuzzTminAfterFinalPredicatesTest", + "--mt", + "invariant_ok", + "tmin-after-final-predicates-corpus/00000000-0000-0000-0000-000000000001-1.json", + "--corpus-out", + "tmin-after-final-predicates-output.json", + ]) + .assert_success(); + + let output: Value = serde_json::from_str( + &std::fs::read_to_string(prj.root().join("tmin-after-final-predicates-output.json")) + .unwrap(), + ) + .unwrap(); + assert_eq!(output.as_array().unwrap().len(), 1, "{output}"); + assert_eq!(output[0]["calldata"].as_str().unwrap(), arm_after_invariant); +} + +#[forgetest_init] +fn forge_fuzz_tmin_rejects_assume_and_skip_candidates(prj: _, cmd: _) { + prj.add_test( + "ForgeFuzzTminRejectedCandidateTarget.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract ForgeFuzzTminRejectedCandidateTargetTest is Test { + uint256 value; + + function testFuzz_rejectedCandidates(uint256 input) public { + if (input == 0) { + vm.assume(false); + } + if (input == 1) { + vm.skip(true, "skip one"); + } + if (input == 2) { + value = 1; + } + } +} + "#, + ); + cmd.args(["build", "-q"]).assert_success(); + + let abi = artifact_abi( + prj.root(), + "out/ForgeFuzzTminRejectedCandidateTarget.t.sol/ForgeFuzzTminRejectedCandidateTargetTest.json", + ); + let calldata = calldata_for(&abi, "testFuzz_rejectedCandidates", 2); + let corpus = prj.root().join("tmin-rejected-candidates-corpus"); + std::fs::create_dir_all(&corpus).unwrap(); + write_corpus_entry(&corpus, "00000000-0000-0000-0000-000000000001-1.json", &calldata); + + cmd.forge_fuse() + .args([ + "fuzz", + "tmin", + "--mc", + "ForgeFuzzTminRejectedCandidateTargetTest", + "--mt", + "testFuzz_rejectedCandidates", + "tmin-rejected-candidates-corpus/00000000-0000-0000-0000-000000000001-1.json", + "--corpus-out", + "tmin-rejected-candidates-output.json", + ]) + .assert_success(); + + let args = output_calldata_args( + prj.root(), + "tmin-rejected-candidates-output.json", + &abi, + "testFuzz_rejectedCandidates", + ); + assert_eq!(args, vec![DynSolValue::Uint(U256::from(2), 256)]); +} + +#[forgetest_init] +fn forge_fuzz_tmin_rejects_existing_output(prj: _, cmd: _) { + prj.add_test( + "ForgeFuzzTminExistingOutput.t.sol", + r#" +contract ForgeFuzzTminExistingOutputTest { + function testFuzz_value(uint256 input) public pure { + input; + } +} + "#, + ); + cmd.args(["build", "-q"]).assert_success(); + + let abi = artifact_abi( + prj.root(), + "out/ForgeFuzzTminExistingOutput.t.sol/ForgeFuzzTminExistingOutputTest.json", + ); + let calldata = calldata_for(&abi, "testFuzz_value", 1); + let corpus = prj.root().join("tmin-existing-corpus"); + std::fs::create_dir_all(&corpus).unwrap(); + write_corpus_entry(&corpus, "00000000-0000-0000-0000-000000000001-1.json", &calldata); + std::fs::write(prj.root().join("tmin-existing-output.json"), "keep").unwrap(); + + let assert = cmd + .forge_fuse() + .args([ + "fuzz", + "tmin", + "--mc", + "ForgeFuzzTminExistingOutputTest", + "--mt", + "testFuzz_value", + "tmin-existing-corpus/00000000-0000-0000-0000-000000000001-1.json", + "--corpus-out", + "tmin-existing-output.json", + ]) + .assert_failure(); + let stderr = String::from_utf8(assert.get_output().stderr.clone()).unwrap(); + assert!(stderr.contains("output corpus file already exists"), "{stderr}"); + assert_eq!( + std::fs::read_to_string(prj.root().join("tmin-existing-output.json")).unwrap(), + "keep" + ); +} + +#[forgetest_init] +fn forge_fuzz_tmin_minimizes_corpus_directory(prj: _, cmd: _) { + prj.add_test( + "ForgeFuzzTminDirectoryTarget.t.sol", + r#" +contract ForgeFuzzTminDirectoryTargetTest { + uint256 value; + + function testFuzz_directory(uint256 input) public { + if (input < 100) { + value = 1; + } else { + value = 2; + } + } +} + "#, + ); + cmd.args(["build", "-q"]).assert_success(); + + let abi = artifact_abi( + prj.root(), + "out/ForgeFuzzTminDirectoryTarget.t.sol/ForgeFuzzTminDirectoryTargetTest.json", + ); + let small = calldata_for(&abi, "testFuzz_directory", 42); + let large = calldata_for(&abi, "testFuzz_directory", 142); + let corpus = prj.root().join("tmin-dir-corpus/Target/testFuzz_directory/worker0/corpus"); + std::fs::create_dir_all(&corpus).unwrap(); + write_corpus_entry(&corpus, "00000000-0000-0000-0000-000000000001-1.json", &small); + write_corpus_entry(&corpus, "00000000-0000-0000-0000-000000000002-2.json", &large); + std::fs::write(corpus.join("00000000-0000-0000-0000-000000000003-3.json"), "[]").unwrap(); + std::fs::write(corpus.join("00000000-0000-0000-0000-000000000004-4.json"), "not json").unwrap(); + + let assert = cmd + .forge_fuse() + .args([ + "fuzz", + "tmin", + "--mc", + "ForgeFuzzTminDirectoryTargetTest", + "--mt", + "testFuzz_directory", + "tmin-dir-corpus", + "--corpus-out", + "tmin-dir-output", + ]) + .assert_success(); + let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + let stderr = String::from_utf8(assert.get_output().stderr.clone()).unwrap(); + assert!(stdout.contains("minimized corpus: 2 entries"), "{stdout}"); + assert!(!stdout.contains("attempted"), "{stdout}"); + assert!(stderr.contains("attempted"), "{stderr}"); + assert!(stderr.contains("skipped 2 entries"), "{stderr}"); + assert_eq!(regular_file_count(&prj.root().join("tmin-dir-output")), 2); + + let show = cmd.forge_fuse().args(["fuzz", "show", "tmin-dir-output"]).assert_success(); + let stdout = String::from_utf8(show.get_output().stdout.clone()).unwrap(); + assert!(stdout.contains("ForgeFuzzTminDirectoryTargetTest.testFuzz_directory"), "{stdout}"); +} + +#[forgetest_init] +fn forge_fuzz_tmin_writes_gzip_output(prj: _, cmd: _) { + prj.add_test( + "ForgeFuzzTminGzipTarget.t.sol", + r#" +contract ForgeFuzzTminGzipTargetTest { + uint256 value; + + function testFuzz_gzip(uint256 input) public { + if (input < 100) { + value = 1; + } + } +} + "#, + ); + cmd.args(["build", "-q"]).assert_success(); + + let abi = artifact_abi( + prj.root(), + "out/ForgeFuzzTminGzipTarget.t.sol/ForgeFuzzTminGzipTargetTest.json", + ); + let calldata = calldata_for(&abi, "testFuzz_gzip", 42); + let corpus = prj.root().join("tmin-gzip-corpus"); + std::fs::create_dir_all(&corpus).unwrap(); + write_corpus_entry(&corpus, "00000000-0000-0000-0000-000000000001-1.json", &calldata); + + cmd.forge_fuse() + .args([ + "fuzz", + "tmin", + "--mc", + "ForgeFuzzTminGzipTargetTest", + "--mt", + "testFuzz_gzip", + "tmin-gzip-corpus/00000000-0000-0000-0000-000000000001-1.json", + "--corpus-out", + "tmin-gzip-output.json.gz", + ]) + .assert_success(); + + let show = cmd.forge_fuse().args(["fuzz", "show", "tmin-gzip-output.json.gz"]).assert_success(); + let stdout = String::from_utf8(show.get_output().stdout.clone()).unwrap(); + assert!(stdout.contains("ForgeFuzzTminGzipTargetTest.testFuzz_gzip"), "{stdout}"); +} + +#[forgetest_init] +fn forge_fuzz_tmin_rejects_zero_attempt_budget(prj: _, cmd: _) { + prj.add_test( + "ForgeFuzzTminBudgetTarget.t.sol", + r#" +contract ForgeFuzzTminBudgetTargetTest { + function testFuzz_budget(uint256 input) public pure { + input; + } +} + "#, + ); + cmd.args(["build", "-q"]).assert_success(); + + let abi = artifact_abi( + prj.root(), + "out/ForgeFuzzTminBudgetTarget.t.sol/ForgeFuzzTminBudgetTargetTest.json", + ); + let calldata = calldata_for(&abi, "testFuzz_budget", 42); + let corpus = prj.root().join("tmin-budget-corpus"); + std::fs::create_dir_all(&corpus).unwrap(); + write_corpus_entry(&corpus, "00000000-0000-0000-0000-000000000001-1.json", &calldata); + + let assert = cmd + .forge_fuse() + .args([ + "fuzz", + "tmin", + "--mc", + "ForgeFuzzTminBudgetTargetTest", + "--mt", + "testFuzz_budget", + "tmin-budget-corpus/00000000-0000-0000-0000-000000000001-1.json", + "--corpus-out", + "tmin-budget-output.json", + "--max-attempts", + "0", + ]) + .assert_failure(); + let stderr = String::from_utf8(assert.get_output().stderr.clone()).unwrap(); + assert!(stderr.contains("--max-attempts must be greater than 0"), "{stderr}"); + assert!(!prj.root().join("tmin-budget-output.json").exists()); +} + +#[forgetest_init] +fn forge_fuzz_tmin_rejects_unreplayable_entry(prj: _, cmd: _) { + prj.add_test( + "ForgeFuzzTminUnreplayableTarget.t.sol", + r#" +contract ForgeFuzzTminUnreplayableTargetTest { + function testFuzz_value(uint256 input) public pure { + input; + } + + function testFuzz_other(uint256 input) public pure { + input; + } +} + "#, + ); + cmd.args(["build", "-q"]).assert_success(); + + let abi = artifact_abi( + prj.root(), + "out/ForgeFuzzTminUnreplayableTarget.t.sol/ForgeFuzzTminUnreplayableTargetTest.json", + ); + let calldata = calldata_for(&abi, "testFuzz_other", 1); + let corpus = prj.root().join("tmin-unreplayable-corpus"); + std::fs::create_dir_all(&corpus).unwrap(); + write_corpus_entry(&corpus, "00000000-0000-0000-0000-000000000001-1.json", &calldata); + + let assert = cmd + .forge_fuse() + .args([ + "fuzz", + "tmin", + "--mc", + "ForgeFuzzTminUnreplayableTargetTest", + "--mt", + "testFuzz_value", + "tmin-unreplayable-corpus/00000000-0000-0000-0000-000000000001-1.json", + "--corpus-out", + "tmin-unreplayable-output.json", + ]) + .assert_failure(); + let stderr = String::from_utf8(assert.get_output().stderr.clone()).unwrap(); + assert!(stderr.contains("replayed 0 transactions"), "{stderr}"); + assert!(!prj.root().join("tmin-unreplayable-output.json").exists()); +} + +#[forgetest_init] +fn forge_fuzz_replay_invariant_fail_on_revert(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.fail_on_revert = true; + }); + prj.add_test( + "ForgeFuzzInvariantFailOnRevertReplay.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract ForgeFuzzInvariantFailOnRevertReplayTest is Test { + bool ok = true; + + function setUp() public { + targetContract(address(this)); + bytes4[] memory selectors = new bytes4[](2); + selectors[0] = this.revertHandler.selector; + selectors[1] = this.breakInvariant.selector; + targetSelector(FuzzSelector({addr: address(this), selectors: selectors})); + } + + function revertHandler(uint256 value) external pure { + value; + revert("boom"); + } + + function breakInvariant() external { + ok = false; + } + + function invariant_ok() public view { + assertTrue(ok); + } +} + "#, + ); + cmd.args(["build", "-q"]).assert_success(); + + let abi = artifact_abi( + prj.root(), + "out/ForgeFuzzInvariantFailOnRevertReplay.t.sol/ForgeFuzzInvariantFailOnRevertReplayTest.json", + ); + let revert_handler = calldata_for(&abi, "revertHandler", 1); + let break_invariant = hex::encode_prefixed( + abi.functions().find(|function| function.name == "breakInvariant").unwrap().selector(), + ); + let corpus = prj.root().join("invariant_corpus"); + std::fs::create_dir_all(&corpus).unwrap(); + std::fs::write( + corpus.join("00000000-0000-0000-0000-000000000001-1.json"), + format!( + r#"[{{ + "sender":"{DEFAULT_SENDER}", + "target":"{DEFAULT_TEST_TARGET}", + "calldata":"{revert_handler}", + "value":"0x0" +}},{{ + "sender":"{DEFAULT_SENDER}", + "target":"{DEFAULT_TEST_TARGET}", + "calldata":"{break_invariant}", + "value":"0x0" +}}]"# + ), + ) + .unwrap(); + + let replay = cmd + .forge_fuse() + .args([ + "fuzz", + "replay", + "--mc", + "ForgeFuzzInvariantFailOnRevertReplayTest", + "--mt", + "invariant_ok", + "--corpus-dir", + "invariant_corpus", + ]) + .assert_failure(); + let stdout = String::from_utf8(replay.get_output().stdout.clone()).unwrap(); + assert!( + stdout.contains("failed during replay: invariant `invariant_ok` failed on handler "), + "{stdout}" + ); + + let showmap = cmd + .forge_fuse() + .args([ + "test", + "--mc", + "ForgeFuzzInvariantFailOnRevertReplayTest", + "--mt", + "invariant_ok", + "--showmap-out", + "showmap", + "--showmap-corpus-dir", + "invariant_corpus", + ]) + .assert_success(); + let stdout = String::from_utf8(showmap.get_output().stdout.clone()).unwrap(); + assert!(stdout.contains("[PASS] invariant_ok() (replay: 1 entries"), "{stdout}"); + + let cmin = cmd + .forge_fuse() + .args([ + "fuzz", + "cmin", + "--mc", + "ForgeFuzzInvariantFailOnRevertReplayTest", + "--mt", + "invariant_ok", + "invariant_corpus", + "--corpus-out", + "min-invariant-fail-on-revert-corpus", + ]) + .assert_failure(); + let stderr = String::from_utf8(cmin.get_output().stderr.clone()).unwrap(); + assert!(stderr.contains("1 corpus entries failed during replay"), "{stderr}"); +} + +#[forgetest_init] +fn forge_fuzz_replay_invariant_sequence_checks(prj: _, cmd: _) { + prj.update_config(|config| { + config.fuzz.seed = Some(U256::ONE); + config.invariant.runs = 1; + config.invariant.depth = 1; + config.invariant.check_interval = 0; + config.invariant.corpus.corpus_dir = Some("seed_corpus".into()); + config.invariant.corpus.corpus_gzip = false; + }); + prj.add_test( + "ForgeFuzzInvariantReplaySequence.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract ForgeFuzzInvariantReplaySequenceTest is Test { + bool checksEnabled; + bool ok = true; + bool afterFails; + + function setUp() public { + targetContract(address(this)); + bytes4[] memory selectors = new bytes4[](3); + selectors[0] = this.enableChecks.selector; + selectors[1] = this.setOk.selector; + selectors[2] = this.armAfterInvariant.selector; + targetSelector(FuzzSelector({addr: address(this), selectors: selectors})); + } + + function enableChecks() external { + checksEnabled = true; + } + + function setOk(bool value) external { + ok = value; + } + + function armAfterInvariant(uint256 value) external { + if (value == 0xdeadbeef) { + afterFails = true; + } + } + + function invariant_ok() public view { + if (checksEnabled) { + assertTrue(ok); + } + } + + function afterInvariant() external view { + if (checksEnabled && !ok) { + revert("after"); + } + if (afterFails) { + revert("after"); + } + } +} + "#, + ); + cmd.args([ + "test", + "--mc", + "ForgeFuzzInvariantReplaySequenceTest", + "--mt", + "invariant_ok", + "-q", + ]) + .assert_success(); + + let seed_entry = + std::fs::read_to_string(find_first_json(&prj.root().join("seed_corpus"))).unwrap(); + let seed_entry: Value = serde_json::from_str(&seed_entry).unwrap(); + let tx = &seed_entry.as_array().unwrap()[0]; + let sender = tx["sender"].as_str().unwrap(); + let target = tx["target"].as_str().unwrap(); + let abi = artifact_abi( + prj.root(), + "out/ForgeFuzzInvariantReplaySequence.t.sol/ForgeFuzzInvariantReplaySequenceTest.json", + ); + let selector = |name: &str| { + let function = abi.functions().find(|function| function.name == name).unwrap(); + hex::encode(function.selector()) + }; + let enable_checks = format!("0x{}", selector("enableChecks")); + let set_ok_false = format!("0x{}{:064x}", selector("setOk"), 0); + let set_ok_true = format!("0x{}{:064x}", selector("setOk"), 1); + let arm_after_invariant = format!("0x{}{:064x}", selector("armAfterInvariant"), 0xdeadbeefu64); + let corpus = prj.root().join("corpus"); + std::fs::create_dir_all(&corpus).unwrap(); + let entry = format!( + r#"[{{ + "sender":"{sender}", + "target":"{target}", + "calldata":"{enable_checks}", + "value":"0x0" +}},{{ + "sender":"{sender}", + "target":"{target}", + "calldata":"{set_ok_false}", + "value":"0x0" +}},{{ + "sender":"{sender}", + "target":"{target}", + "calldata":"{set_ok_true}", + "value":"0x0" +}}]"# + ); + std::fs::write(corpus.join("check-interval.json"), entry).unwrap(); + let entry = format!( + r#"[{{ + "sender":"{sender}", + "target":"{target}", + "calldata":"{arm_after_invariant}", + "value":"0x0" +}}]"# + ); + std::fs::write(corpus.join("after-invariant.json"), entry).unwrap(); + + let replay = cmd + .forge_fuse() + .args([ + "fuzz", + "replay", + "--mc", + "ForgeFuzzInvariantReplaySequenceTest", + "--mt", + "invariant_ok", + "--corpus-dir", + "corpus/check-interval.json", + ]) + .assert_success(); + let stdout = String::from_utf8(replay.get_output().stdout.clone()).unwrap(); + assert!(stdout.contains("[PASS] invariant_ok() (replay: 1 entries"), "{stdout}"); + + let replay = cmd + .forge_fuse() + .args([ + "fuzz", + "replay", + "--mc", + "ForgeFuzzInvariantReplaySequenceTest", + "--mt", + "invariant_ok", + "--corpus-dir", + "corpus/after-invariant.json", + ]) + .assert_failure(); + let stdout = String::from_utf8(replay.get_output().stdout.clone()).unwrap(); + assert!(stdout.contains("failed during replay: afterInvariant broken"), "{stdout}"); +} + +#[forgetest_init] +fn forge_fuzz_corpus_subcommands_dedup_worker_entries(prj: _, cmd: _) { + let worker0 = prj.root().join("corpus/worker0/corpus"); + let worker1 = prj.root().join("corpus/worker1/corpus"); + std::fs::create_dir_all(&worker0).unwrap(); + std::fs::create_dir_all(&worker1).unwrap(); + let entry = r#"[{ + "sender":"0x0000000000000000000000000000000000000001", + "target":"0x0000000000000000000000000000000000000002", + "calldata":"0x12345678" +}]"#; + let name = "00000000-0000-0000-0000-000000000001-1.json"; + std::fs::write(worker0.join(name), entry).unwrap(); + std::fs::write(worker1.join(name), entry).unwrap(); + + let show = cmd.args(["fuzz", "show", "corpus"]).assert_success(); + let stdout = String::from_utf8(show.get_output().stdout.clone()).unwrap(); + assert_eq!( + stdout.matches(&format!("corpus{}worker", std::path::MAIN_SEPARATOR)).count(), + 1, + "{stdout}" + ); +} + +#[forgetest_init] +fn forge_fuzz_replay_error_on_zero_replay(prj: _, cmd: _) { + prj.add_test( + "ForgeFuzzZeroReplay.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract ForgeFuzzZeroReplayTest is Test { + function testFuzz_branch(uint256 value) public pure { + value; + } + + function testFuzz_assumeAlways(uint256 value) public { + value; + vm.assume(false); + } +} + "#, + ); + cmd.args(["build", "-q"]).assert_success(); + + let corpus = prj.root().join("corpus"); + std::fs::create_dir_all(&corpus).unwrap(); + write_corpus_entry( + &corpus, + "00000000-0000-0000-0000-000000000001-1.json", + "0x003919a00000000000000000000000000000000000000000000000000000000000000001", + ); + + let wrong_corpus = prj.root().join("wrong-corpus"); + std::fs::create_dir_all(&wrong_corpus).unwrap(); + write_corpus_entry( + &wrong_corpus, + "00000000-0000-0000-0000-000000000002-2.json", + "0xdeadbeef0000000000000000000000000000000000000000000000000000000000000001", + ); + + let zero_replay = cmd + .forge_fuse() + .args([ + "fuzz", + "replay", + "--mc", + "ForgeFuzzZeroReplayTest", + "--mt", + "testFuzz_branch", + "--corpus-dir", + "wrong-corpus", + ]) + .assert_success(); + let stdout = String::from_utf8(zero_replay.get_output().stdout.clone()).unwrap(); + assert!(stdout.contains("[SKIP: replayed 0 corpus entries from wrong-corpus]"), "{stdout}"); + assert!(!stdout.contains("[PASS] testFuzz_branch(uint256) (replay: 0 entries"), "{stdout}"); + + let abi = + artifact_abi(prj.root(), "out/ForgeFuzzZeroReplay.t.sol/ForgeFuzzZeroReplayTest.json"); + let assume_corpus = prj.root().join("assume-corpus"); + std::fs::create_dir_all(&assume_corpus).unwrap(); + write_corpus_entry( + &assume_corpus, + "00000000-0000-0000-0000-000000000003-3.json", + &calldata_for(&abi, "testFuzz_assumeAlways", 1), + ); + let all_assume_replay = cmd + .forge_fuse() + .args([ + "fuzz", + "replay", + "--mc", + "ForgeFuzzZeroReplayTest", + "--mt", + "testFuzz_assumeAlways", + "--corpus-dir", + "assume-corpus", + ]) + .assert_success(); + let stdout = String::from_utf8(all_assume_replay.get_output().stdout.clone()).unwrap(); + assert!(stdout.contains("[SKIP: replayed 0 corpus entries from assume-corpus]"), "{stdout}"); + assert!( + !stdout.contains("[PASS] testFuzz_assumeAlways(uint256) (replay: 1 entries"), + "{stdout}" + ); + let all_assume_showmap = cmd + .forge_fuse() + .args([ + "test", + "--mc", + "ForgeFuzzZeroReplayTest", + "--mt", + "testFuzz_assumeAlways", + "--showmap-out", + "assume-showmap", + "--showmap-corpus-dir", + "assume-corpus", + "--showmap-per-input", + ]) + .assert_success(); + let stdout = String::from_utf8(all_assume_showmap.get_output().stdout.clone()).unwrap(); + assert!(stdout.contains("(replay: 0 entries, 0 files, 1 skipped)"), "{stdout}"); + assert!(!has_regular_file(&prj.root().join("assume-showmap"))); + + prj.add_test( + "ForgeFuzzSkipReplay.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract ForgeFuzzSkipReplayTest is Test { + function testFuzz_skipEven(uint256 value) public { + if (value % 2 == 0) { + vm.skip(true, "even"); + } + } +} + "#, + ); + cmd.forge_fuse().args(["build", "-q"]).assert_success(); + + let abi = + artifact_abi(prj.root(), "out/ForgeFuzzSkipReplay.t.sol/ForgeFuzzSkipReplayTest.json"); + let skip_corpus = prj.root().join("skip-corpus"); + std::fs::create_dir_all(&skip_corpus).unwrap(); + write_corpus_entry( + &skip_corpus, + "00000000-0000-0000-0000-000000000004-4.json", + &calldata_for(&abi, "testFuzz_skipEven", 8), + ); + let all_skip_replay = cmd + .forge_fuse() + .args([ + "fuzz", + "replay", + "--mc", + "ForgeFuzzSkipReplayTest", + "--mt", + "testFuzz_skipEven", + "--corpus-dir", + "skip-corpus", + ]) + .assert_success(); + let stdout = String::from_utf8(all_skip_replay.get_output().stdout.clone()).unwrap(); + assert!(stdout.contains("[SKIP: replayed 0 corpus entries from skip-corpus]"), "{stdout}"); + assert!(!stdout.contains("corpus replay failed"), "{stdout}"); + + let malformed_corpus = prj.root().join("malformed-corpus"); + std::fs::create_dir_all(&malformed_corpus).unwrap(); + std::fs::write(malformed_corpus.join("00000000-0000-0000-0000-000000000004-4.json"), "{") + .unwrap(); + write_corpus_entry( + &malformed_corpus, + "00000000-0000-0000-0000-000000000005-5.json", + "0xdeadbeef0000000000000000000000000000000000000000000000000000000000000001", + ); + let malformed_replay = cmd + .forge_fuse() + .args([ + "fuzz", + "replay", + "--mc", + "ForgeFuzzZeroReplayTest", + "--mt", + "testFuzz_branch", + "--corpus-dir", + "malformed-corpus", + ]) + .assert_failure(); + let stdout = String::from_utf8(malformed_replay.get_output().stdout.clone()).unwrap(); + assert!(stdout.contains("failed to read 1 corpus entries from malformed-corpus"), "{stdout}"); +} + +#[forgetest_init] +fn forge_fuzz_commands_read_generated_corpus_roots(prj: _, cmd: _) { + prj.initialize_default_contracts(); + prj.update_config(|config| { + config.fuzz.runs = 8; + config.fuzz.corpus.corpus_dir = Some("fuzz_corpus".into()); + config.fuzz.corpus.corpus_gzip = false; + config.invariant.runs = 4; + config.invariant.depth = 4; + config.invariant.corpus.corpus_dir = Some("invariant_corpus".into()); + config.invariant.corpus.corpus_gzip = false; + }); + prj.add_test( + "ForgeFuzzGeneratedCorpus.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {Counter} from "../src/Counter.sol"; + +contract ForgeFuzzGeneratedCorpusTest is Test { + Counter public counter; + + function setUp() public { + counter = new Counter(); + } + + function test_unit() public pure {} + + function testFuzz_SetNumber(uint256 value) public { + counter.setNumber(value); + assertEq(counter.number(), value); + } + + function invariant_counter_is_reachable() public view {} +} + "#, + ); + + cmd.args(["fuzz", "run", "--mc", "ForgeFuzzGeneratedCorpusTest"]).assert_success(); + + let show = cmd.forge_fuse().args(["fuzz", "show", "fuzz_corpus"]).assert_success(); + let show_stdout = String::from_utf8(show.get_output().stdout.clone()).unwrap(); + let fuzz_corpus_path = ["fuzz_corpus", "ForgeFuzzGeneratedCorpusTest", "testFuzz_SetNumber"] + .join(std::path::MAIN_SEPARATOR_STR); + assert!(show_stdout.contains(&fuzz_corpus_path), "{show_stdout}"); + + let replay = cmd + .forge_fuse() + .args([ + "fuzz", + "replay", + "--mc", + "ForgeFuzzGeneratedCorpusTest", + "--corpus-dir", + "fuzz_corpus", + ]) + .assert_success(); + let replay_stdout = String::from_utf8(replay.get_output().stdout.clone()).unwrap(); + assert!( + replay_stdout.contains("[PASS] testFuzz_SetNumber(uint256) (replay:"), + "{replay_stdout}" + ); + assert!( + !replay_stdout.contains("[PASS] testFuzz_SetNumber(uint256) (replay: 0 entries"), + "{replay_stdout}" + ); + + let invariant_show = + cmd.forge_fuse().args(["fuzz", "show", "invariant_corpus"]).assert_success(); + let invariant_stdout = String::from_utf8(invariant_show.get_output().stdout.clone()).unwrap(); + let invariant_corpus_path = + ["invariant_corpus", "ForgeFuzzGeneratedCorpusTest", "worker0", "corpus"] + .join(std::path::MAIN_SEPARATOR_STR); + assert!(invariant_stdout.contains(&invariant_corpus_path), "{invariant_stdout}"); +} + +#[forgetest_init] +fn forge_fuzz_run_keeps_invariant_trace_seeding_opt_in(prj: _, cmd: _) { + let marker = prj.root().join("unit-trace-seed-ran.txt"); + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 1; + config.invariant.corpus.corpus_gzip = false; + config.fs_permissions.add(PathPermission::write(prj.root())); + }); + prj.add_test( + "ForgeFuzzAutoCorpusSeed.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract ForgeFuzzAutoCorpusHandler { + uint256 public touched; + + function touch(uint256 value) external { + touched = value; + } +} + +contract ForgeFuzzAutoCorpusSeedTest is Test { + ForgeFuzzAutoCorpusHandler handler; + + function setUp() public { + handler = new ForgeFuzzAutoCorpusHandler(); + targetContract(address(handler)); + } + + function test_seedCorpusFromUnitTestTrace() public { + vm.writeFile(string.concat(vm.projectRoot(), "/unit-trace-seed-ran.txt"), "ran"); + handler.touch(0x1234); + } + + function invariant_ok() public view {} +} + "#, + ); + + cmd.args(["fuzz", "run", "--mc", "ForgeFuzzAutoCorpusSeedTest", "-q"]).assert_success(); + + let corpus_root = prj + .root() + .join("cache") + .join("invariant") + .join("corpus") + .join("ForgeFuzzAutoCorpusSeedTest") + .join("worker0") + .join("corpus"); + assert!(!has_regular_file(&corpus_root)); + assert!(!marker.exists()); +} + +#[forgetest_init] +fn fuzz_branch_frontiers_capture_comparison_for_symbolic_followup(prj: _, cmd: _) { + prj.add_test( + "ForgeFuzzFrontier.t.sol", + r#" +contract ForgeFuzzFrontierTest { + function testFuzz_frontier(uint64 amount, uint256 feeMultiplier) public pure { + uint256 credited; + unchecked { + credited = uint256(amount) + (feeMultiplier - 100); + } + + if (feeMultiplier < 100) { + assert(credited <= amount); + } + } +} + "#, + ); + + cmd.forge_fuse() + .args([ + "test", + "--match-test", + "testFuzz_frontier", + "--fuzz-runs", + "8", + "--fuzz-seed", + "0x1234", + "--threads", + "1", + "--fuzz-frontier-dir", + "fuzz_frontiers", + ]) + .assert_success(); + + let assert_frontier_artifact = |frontier_dir: &str, expect_new_coverage: bool| { + let frontier_path = prj + .root() + .join(frontier_dir) + .join("ForgeFuzzFrontierTest") + .join("testFuzz_frontier") + .join("branch-frontiers.json"); + let artifact: Value = serde_json::from_slice( + &std::fs::read(&frontier_path) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", frontier_path.display())), + ) + .unwrap(); + assert_eq!(artifact["schema"], "foundry:fuzz.branch-frontiers@v1"); + assert_eq!(artifact["version"], 1); + assert_eq!(artifact["test"], "testFuzz_frontier(uint64,uint256)"); + assert_eq!(artifact["limit"], 256); + + let frontiers = artifact["frontiers"].as_array().unwrap(); + let frontier = frontiers + .iter() + .find(|frontier| { + frontier["site"]["opcode_name"] == "LT" + && frontier["operands"]["lhs"] == "0x64" + && frontier["operands"]["rhs"] == "0x64" + && frontier["operands"]["result"] == false + }) + .unwrap_or_else(|| panic!("missing missed fee multiplier frontier in {artifact:#}")); + assert!(frontier["id"].as_u64().is_some(), "{frontier:#}"); + assert_eq!(frontier["call_index"], 0); + assert_eq!(frontier["site"]["opcode_name"], "LT"); + assert!(frontier["site"]["pc"].as_u64().is_some(), "{frontier:#}"); + assert_eq!(frontier["operands"]["operand_delta"], "0x0"); + assert_eq!(frontier["operands"]["result"], false); + + // `new_coverage` is only recorded when edge coverage is collected; frontier-only capture + // omits it rather than reporting an always-false value. + if expect_new_coverage { + assert!(frontier["new_coverage"].is_boolean(), "{frontier:#}"); + } else { + assert!(frontier.get("new_coverage").is_none(), "{frontier:#}"); + } + + let sequence = frontier["sequence"].as_array().unwrap(); + assert_eq!(sequence.len(), 1); + assert!( + sequence[0]["target"].as_str().unwrap().eq_ignore_ascii_case(DEFAULT_TEST_TARGET), + "{frontier:#}" + ); + let calldata = sequence[0]["calldata"].as_str().unwrap(); + let expected_selector = + hex::encode(&alloy_primitives::keccak256(b"testFuzz_frontier(uint64,uint256)")[..4]); + assert!(calldata.starts_with(&format!("0x{expected_selector}")), "{calldata}"); + assert!(calldata.ends_with(&format!("{:064x}{:064x}", 100u64, 100u64)), "{calldata}"); + }; + assert_frontier_artifact("fuzz_frontiers", false); + + prj.update_config(|config| { + config.fuzz.corpus.sancov_edges = true; + }); + cmd.forge_fuse() + .args([ + "test", + "--match-test", + "testFuzz_frontier", + "--fuzz-runs", + "8", + "--fuzz-seed", + "0x1234", + "--threads", + "1", + "--fuzz-frontier-dir", + "sancov_fuzz_frontiers", + ]) + .assert_success(); + assert_frontier_artifact("sancov_fuzz_frontiers", true); +} + +#[forgetest_init] +fn forge_fuzz_replay_scopes_generated_corpus_root_to_target(prj: _, cmd: _) { + prj.add_test( + "ForgeFuzzGeneratedRootScope.t.sol", + r#" +contract GeneratedCorpusATest { + function testFuzz_same(uint256 value) public pure { + require(value == 1); + } +} + +contract GeneratedCorpusBTest { + function testFuzz_same(uint256 value) public pure { + require(value == 2); + } +} + "#, + ); + cmd.args(["build", "-q"]).assert_success(); + + let abi = + artifact_abi(prj.root(), "out/ForgeFuzzGeneratedRootScope.t.sol/GeneratedCorpusBTest.json"); + let selector = calldata_for(&abi, "testFuzz_same", 0); + let selector = &selector[..10]; + + let corpus_root = prj.root().join("fuzz_corpus"); + let a_corpus = corpus_root.join("GeneratedCorpusATest/testFuzz_same/worker0/corpus"); + let b_corpus = corpus_root.join("GeneratedCorpusBTest/testFuzz_same/worker0/corpus"); + std::fs::create_dir_all(&a_corpus).unwrap(); + std::fs::create_dir_all(&b_corpus).unwrap(); + write_corpus_entry( + &a_corpus, + "00000000-0000-0000-0000-000000000001-1.json", + &format!("{selector}{:064x}", 1), + ); + write_corpus_entry( + &b_corpus, + "00000000-0000-0000-0000-000000000002-2.json", + &format!("{selector}{:064x}", 2), + ); + + let replay = cmd + .forge_fuse() + .args(["fuzz", "replay", "--mc", "GeneratedCorpusBTest", "--corpus-dir", "fuzz_corpus"]) + .assert_success(); + let stdout = String::from_utf8(replay.get_output().stdout.clone()).unwrap(); + assert!(stdout.contains("[PASS] testFuzz_same(uint256) (replay: 1 entries"), "{stdout}"); + assert!(!stdout.contains("corpus replay failed"), "{stdout}"); +} + +#[forgetest_init] +fn forge_fuzz_replay_scopes_generated_invariant_root_to_target(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.fail_on_revert = true; + }); + prj.add_test( + "ForgeFuzzGeneratedInvariantRootScope.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract GeneratedInvariantCorpusATest is Test { + function setUp() public { + targetContract(address(this)); + } + + function setValue(uint256 value) external { + require(value == 1); + } + + function invariant_ok() public pure {} +} + +contract GeneratedInvariantCorpusBTest is Test { + function setUp() public { + targetContract(address(this)); + } + + function setValue(uint256 value) external { + require(value == 2); + } + + function invariant_ok() public pure {} +} + "#, + ); + cmd.args(["build", "-q"]).assert_success(); + + let abi = artifact_abi( + prj.root(), + "out/ForgeFuzzGeneratedInvariantRootScope.t.sol/GeneratedInvariantCorpusBTest.json", + ); + let corpus_root = prj.root().join("invariant_corpus"); + let a_corpus = corpus_root.join("GeneratedInvariantCorpusATest/worker0/corpus"); + let b_corpus = corpus_root.join("GeneratedInvariantCorpusBTest/worker0/corpus"); + std::fs::create_dir_all(&a_corpus).unwrap(); + std::fs::create_dir_all(&b_corpus).unwrap(); + write_corpus_entry( + &a_corpus, + "00000000-0000-0000-0000-000000000001-1.json", + &calldata_for(&abi, "setValue", 1), + ); + write_corpus_entry( + &b_corpus, + "00000000-0000-0000-0000-000000000002-2.json", + &calldata_for(&abi, "setValue", 2), + ); + + let replay = cmd + .forge_fuse() + .args([ + "fuzz", + "replay", + "--mc", + "GeneratedInvariantCorpusBTest", + "--mt", + "invariant_ok", + "--corpus-dir", + "invariant_corpus", + ]) + .assert_success(); + let stdout = String::from_utf8(replay.get_output().stdout.clone()).unwrap(); + assert!(stdout.contains("[PASS] invariant_ok() (replay: 1 entries"), "{stdout}"); + assert!(!stdout.contains("corpus replay failed"), "{stdout}"); +} + +// tests that inline max-test-rejects config is properly applied +#[forgetest_init] +fn test_inline_max_test_rejects(prj: _, cmd: _) { + prj.add_test( + "Contract.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract InlineMaxRejectsTest is Test { + /// forge-config: default.fuzz.max-test-rejects = 1 + function test_fuzz_bound(uint256 a) public { + vm.assume(false); + } +} + "#, + ); + + cmd.args(["test"]).assert_failure().stdout_eq(str![[r#" +... +[FAIL: `vm.assume` rejected too many inputs (1 allowed)] test_fuzz_bound(uint256) (runs: 0, [AVG_GAS]) +... +"#]]); +} + +// Tests that test timeout config is properly applied. +// If test doesn't timeout after one second, then test will fail with `rejected too many inputs`. +#[forgetest_init] +fn test_fuzz_timeout(prj: _, cmd: _) { + prj.add_test( + "Contract.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract FuzzTimeoutTest is Test { + /// forge-config: default.fuzz.max-test-rejects = 0 + /// forge-config: default.fuzz.timeout = 1 + function test_fuzz_bound(uint256 a) public pure { + vm.assume(a == 0); + } +} + "#, + ); + + cmd.args(["test", "-j2"]).assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/Contract.t.sol:FuzzTimeoutTest +[PASS] test_fuzz_bound(uint256) (runs: [..], [AVG_GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +} + +#[forgetest_init] +fn test_fuzz_fail_on_revert(prj: _, cmd: _) { + prj.update_config(|config| { + config.fuzz.fail_on_revert = false; + config.fuzz.seed = Some(U256::from(100u32)); + }); + prj.add_source( + "Counter.sol", + r#" +contract Counter { + uint256 public number; + + function setNumber(uint256 newNumber) public { + require(number > 10000000000, "low number"); + number = newNumber; + } +} + "#, + ); + + prj.add_test( + "CounterTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import "src/Counter.sol"; + +contract CounterTest is Test { + Counter public counter; + + function setUp() public { + counter = new Counter(); + } + + function testFuzz_SetNumberRequire(uint256 x) public { + counter.setNumber(x); + require(counter.number() == 1); + } + + function testFuzz_SetNumberAssert(uint256 x) public { + counter.setNumber(x); + assertEq(counter.number(), 1); + } +} + "#, + ); + + // Tests should not fail as revert happens in Counter contract. + cmd.args(["test", "--mc", "CounterTest"]).assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 2 tests for test/CounterTest.t.sol:CounterTest +[PASS] testFuzz_SetNumberAssert(uint256) (runs: 256, [AVG_GAS]) +[PASS] testFuzz_SetNumberRequire(uint256) (runs: 256, [AVG_GAS]) +Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 2 tests passed, 0 failed, 0 skipped (2 total tests) + +"#]]); + + // Tested contract does not revert. + prj.add_source( + "Counter.sol", + r#" +contract Counter { + uint256 public number; + + function setNumber(uint256 newNumber) public { + number = newNumber; + } +} + "#, + ); + + // Tests should fail as revert happens in cheatcode (assert) and test (require) contract. + cmd.args(["-j1"]).assert_failure().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 2 tests for test/CounterTest.t.sol:CounterTest +[FAIL: assertion failed: [..]] testFuzz_SetNumberAssert(uint256) (runs: 0, [AVG_GAS]) +[FAIL: EvmError: Revert; [..]] testFuzz_SetNumberRequire(uint256) (runs: 0, [AVG_GAS]) +Suite result: FAILED. 0 passed; 2 failed; 0 skipped; [ELAPSED] +... + +Ran 1 test suite [ELAPSED]: 0 tests passed, 2 failed, 0 skipped (2 total tests) + +Failing tests: +Encountered 2 failing tests in test/CounterTest.t.sol:CounterTest +[FAIL: assertion failed: [..]; counterexample: calldata=[..] args=[..]] testFuzz_SetNumberAssert(uint256) (runs: 0, [AVG_GAS]) +[FAIL: EvmError: Revert; counterexample: calldata=[..] args=[..]] testFuzz_SetNumberRequire(uint256) (runs: 0, [AVG_GAS]) + +Encountered a total of 2 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 2 failed tests +Tip: Run `forge test --debug --match-test ` to inspect one failing test in the debugger + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]]); +} + +#[cfg(feature = "monad")] +#[forgetest_init] +fn test_fuzz_monad_cheatcode_revert_is_failure(prj: _, cmd: _) { + prj.update_config(|config| { + config.fuzz.fail_on_revert = false; + config.fuzz.runs = 1; + }); + prj.add_test( + "MonadCheatcodeRevert.t.sol", + r#" +pragma solidity ^0.8.0; + +interface StaleMonadVm { + function removedCheatcode(uint256 value) external; +} + +contract MonadCheatcodeRevertTest { + StaleMonadVm constant MONAD_VM = + StaleMonadVm(0xc0FFeeCD43A10e1C2b0De63c6CDCFe5B7d0e0CEA); + + function testFuzz_monadVmRevertMustFail(uint256 value) public { + MONAD_VM.removedCheatcode(value); + } +} + "#, + ); + + cmd.args(["test", "--network", "monad", "--mc", "MonadCheatcodeRevertTest"]) + .assert_failure() + .stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/MonadCheatcodeRevert.t.sol:MonadCheatcodeRevertTest +[FAIL: unknown monad cheatcode with selector 0x1c0b1af1; check that your MonadVm interface matches this forge version; counterexample: calldata=[..] args=[..]] testFuzz_monadVmRevertMustFail(uint256) (runs: 0, [AVG_GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/MonadCheatcodeRevert.t.sol:MonadCheatcodeRevertTest +[FAIL: unknown monad cheatcode with selector 0x1c0b1af1; check that your MonadVm interface matches this forge version; counterexample: calldata=[..] args=[..]] testFuzz_monadVmRevertMustFail(uint256) (runs: 0, [AVG_GAS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test +Tip: Run `forge test --debug --match-test ` to inspect one failing test in the debugger + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]]); +} + +#[forgetest_init] +fn forge_fuzz_replay_respects_fuzz_fail_on_revert(prj: _, cmd: _) { + prj.update_config(|config| { + config.fuzz.fail_on_revert = true; + }); + prj.add_source( + "Reverter.sol", + r#" +contract Reverter { + function boom(uint256 value) public pure { + value; + revert("boom"); + } +} + "#, + ); + prj.add_test( + "ForgeFuzzReplayFailOnRevert.t.sol", + r#" +import {Reverter} from "../src/Reverter.sol"; + +contract ForgeFuzzReplayFailOnRevertTest { + Reverter reverter; + + function setUp() public { + reverter = new Reverter(); + } + + function testFuzz_callsReverter(uint256 value) public view { + reverter.boom(value); + } +} + "#, + ); + cmd.args(["build", "-q"]).assert_success(); + + let abi = artifact_abi( + prj.root(), + "out/ForgeFuzzReplayFailOnRevert.t.sol/ForgeFuzzReplayFailOnRevertTest.json", + ); + let calldata = calldata_for(&abi, "testFuzz_callsReverter", 1); + let corpus = prj.root().join("corpus"); + std::fs::create_dir_all(&corpus).unwrap(); + write_corpus_entry(&corpus, "00000000-0000-0000-0000-000000000001-1.json", &calldata); + + let replay = cmd + .forge_fuse() + .args([ + "fuzz", + "replay", + "--mc", + "ForgeFuzzReplayFailOnRevertTest", + "--corpus-dir", + "corpus", + ]) + .assert_failure(); + let stdout = String::from_utf8(replay.get_output().stdout.clone()).unwrap(); + assert!(stdout.contains("failed during replay: fuzz call"), "{stdout}"); +} + +#[forgetest_init] +fn forge_fuzz_replay_replays_persisted_handler_failures(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 10; + config.invariant.fail_on_revert = false; + }); + prj.add_source( + "AlwaysAssert.sol", + r#" +contract AlwaysAssert { + function boom() external { assert(false); } +} + "#, + ); + prj.add_test( + "ForgeFuzzReplayHandlerFailure.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {AlwaysAssert} from "../src/AlwaysAssert.sol"; + +contract ForgeFuzzReplayHandlerFailureTest is Test { + AlwaysAssert h; + + function setUp() public { + h = new AlwaysAssert(); + targetContract(address(h)); + } + + function invariant_ok() public view {} +} + "#, + ); + + cmd.args(["test", "--mc", "ForgeFuzzReplayHandlerFailureTest", "--mt", "invariant_ok"]) + .assert_failure(); + + let replay = cmd + .forge_fuse() + .args([ + "fuzz", + "replay", + "--mc", + "ForgeFuzzReplayHandlerFailureTest", + "--mt", + "invariant_ok", + ]) + .assert_failure(); + let stdout = String::from_utf8(replay.get_output().stdout.clone()).unwrap(); + assert!(stdout.contains("Assertion Tests: 1 assertion bug(s) found"), "{stdout}"); + assert!(!stdout.contains("[SKIP: no persisted invariant failure reproduced"), "{stdout}"); +} + +#[forgetest_init] +fn forge_fuzz_replay_replays_non_anchor_invariant_failure(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 1; + }); + prj.add_test( + "ForgeFuzzReplayNonAnchorInvariant.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract ForgeFuzzReplayNonAnchorInvariantTest is Test { + uint256 count; + + function setUp() public { + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = this.tick.selector; + targetSelector(FuzzSelector({addr: address(this), selectors: selectors})); + } + + function tick() external { + count++; + } + + function invariant_a_first() public view { + require(count >= 0, "a"); + } + + function invariant_b_middle() public view { + require(count < 1, "middle broken"); + } + + function invariant_c_last() public view { + require(count >= 0, "c"); + } +} + "#, + ); + + cmd.args(["test", "--mc", "ForgeFuzzReplayNonAnchorInvariantTest", "-q"]).assert_failure(); + let replay = cmd + .forge_fuse() + .args(["fuzz", "replay", "--mc", "ForgeFuzzReplayNonAnchorInvariantTest"]) + .assert_failure(); + let stdout = String::from_utf8(replay.get_output().stdout.clone()).unwrap(); + assert!(stdout.contains("[FAIL: middle broken]"), "{stdout}"); + assert!(!stdout.contains("[SKIP: no persisted invariant failure reproduced"), "{stdout}"); +} + +// Test 256 runs regardless number of test rejects. +// +#[forgetest_init] +fn test_fuzz_runs_with_rejects(prj: _, cmd: _) { + prj.add_test( + "FuzzWithRejectsTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract FuzzWithRejectsTest is Test { + function testFuzzWithRejects(uint256 x) public pure { + vm.assume(x < 1_000_000); + } +} + "#, + ); + + // Tests should not fail as revert happens in Counter contract. + cmd.args(["test", "--mc", "FuzzWithRejectsTest"]).assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/FuzzWithRejectsTest.t.sol:FuzzWithRejectsTest +[PASS] testFuzzWithRejects(uint256) (runs: 256, [AVG_GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +} + +// Test that counterexample is not replayed if test changes. +// +#[forgetest_init] +fn test_fuzz_replay_with_changed_test(prj: _, cmd: _) { + prj.update_config(|config| config.fuzz.seed = Some(U256::from(100u32))); + prj.add_test( + "Counter.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract CounterTest is Test { + function testFuzz_SetNumber(uint256 x) public pure { + require(x > 200); + } +} + "#, + ); + // Tests should fail and record counterexample with value 200. + cmd.args(["test", "-j1"]).assert_failure().stdout_eq(str![[r#" +... +Failing tests: +Encountered 1 failing test in test/Counter.t.sol:CounterTest +[FAIL: EvmError: Revert; counterexample: calldata=0x5c7f60d700000000000000000000000000000000000000000000000000000000000000c8 args=[200]] testFuzz_SetNumber(uint256) (runs: 6, [AVG_GAS]) +... + +"#]]); + + // Change test to assume counterexample 2 is discarded. + prj.add_test( + "Counter.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract CounterTest is Test { + function testFuzz_SetNumber(uint256 x) public pure { + vm.assume(x != 200); + } +} + "#, + ); + // Test should pass when replay failure with changed assume logic. + cmd.forge_fuse().args(["test"]).assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/Counter.t.sol:CounterTest +[PASS] testFuzz_SetNumber(uint256) (runs: 256, [AVG_GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); + + // Change test signature. + prj.add_test( + "Counter.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract CounterTest is Test { + function testFuzz_SetNumber(uint8 x) public pure { + } +} + "#, + ); + // Test should pass when replay failure with changed function signature. + cmd.forge_fuse().args(["test"]).assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/Counter.t.sol:CounterTest +[PASS] testFuzz_SetNumber(uint8) (runs: 256, [AVG_GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); + + // Change test back to the original one that produced the counterexample. + prj.add_test( + "Counter.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract CounterTest is Test { + function testFuzz_SetNumber(uint256 x) public pure { + require(x > 200); + } +} + "#, + ); + // Test should fail with replayed counterexample 200 (0 runs). + cmd.forge_fuse().args(["test", "-j1"]).assert_failure().stdout_eq(str![[r#" +... +Failing tests: +Encountered 1 failing test in test/Counter.t.sol:CounterTest +[FAIL: EvmError: Revert; counterexample: calldata=0x5c7f60d700000000000000000000000000000000000000000000000000000000000000c8 args=[200]] testFuzz_SetNumber(uint256) (runs: 0, [AVG_GAS]) +... + +"#]]); +} + +#[forgetest_init] +fn test_fuzz_stale_success_does_not_consume_run(prj: _, cmd: _) { + prj.update_config(|config| { + config.fuzz.runs = 1; + config.fuzz.seed = Some(U256::ONE); + config.fuzz.dictionary.dictionary_weight = 0; + }); + prj.add_test( + "StaleFailure.t.sol", + r#" +contract StaleFailureTest { + error Nonzero(uint256 value); + + function testFuzz_slot(uint256 value) public pure { + if (value != 0) revert Nonzero(value); + } +} + "#, + ); + + cmd.args(["test", "--mc", "StaleFailureTest", "-j1"]).assert_failure(); + + let failure_file = prj.root().join("cache/fuzz/failures/StaleFailureTest/testFuzz_slot"); + let mut failure: BaseCounterExample = + serde_json::from_slice(&std::fs::read(&failure_file).unwrap()).unwrap(); + let generated_calldata = failure.calldata.clone(); + assert_ne!(U256::from_be_slice(&generated_calldata[4..]), U256::ZERO); + + let selector = generated_calldata[..4].to_vec(); + failure.calldata = [selector.as_slice(), &[0; 32]].concat().into(); + failure.args = Some("0".to_string()); + failure.raw_args = Some("0".to_string()); + std::fs::write(&failure_file, serde_json::to_vec(&failure).unwrap()).unwrap(); + + cmd.forge_fuse().args(["test", "--mc", "StaleFailureTest", "-j1"]).assert_failure(); + + let failure: BaseCounterExample = + serde_json::from_slice(&std::fs::read(&failure_file).unwrap()).unwrap(); + assert_eq!(failure.calldata, generated_calldata); + assert_eq!(failure.fuzz.seed, Some(U256::ONE)); + assert_eq!(failure.fuzz.run, Some(1)); + assert_eq!(failure.fuzz.worker, Some(0)); + + cmd.forge_fuse() + .args([ + "test", + "--mc", + "StaleFailureTest", + "--fuzz-seed", + "1", + "--fuzz-run", + "1", + "--fuzz-worker", + "0", + "-j1", + ]) + .assert_failure(); + + let failure: BaseCounterExample = + serde_json::from_slice(&std::fs::read(&failure_file).unwrap()).unwrap(); + assert_eq!(failure.calldata, generated_calldata); +} + +#[forgetest_init] +fn fuzz_basic(prj: _, cmd: _) { + prj.add_test( + "Fuzz.t.sol", + r#" +import "forge-std/Test.sol"; + +contract FuzzTest is Test { + constructor() { + emit log("constructor"); + } + + function setUp() public { + emit log("setUp"); + } + + function testShouldFailFuzz(uint8 x) public { + emit log("testFailFuzz"); + require(x > 128, "should revert"); + } + + function testSuccessfulFuzz(uint128 a, uint128 b) public { + emit log("testSuccessfulFuzz"); + assertEq(uint256(a) + uint256(b), uint256(a) + uint256(b)); + } + + function testToStringFuzz(bytes32 data) public { + vm.toString(data); + } +} + "#, + ); + + cmd.args(["test"]).assert_failure().stdout_eq(str![[r#" +... +Ran 3 tests for test/Fuzz.t.sol:FuzzTest +[FAIL: should revert; counterexample: calldata=[..] args=[..]] testShouldFailFuzz(uint8) (runs: [..], [AVG_GAS]) +[PASS] testSuccessfulFuzz(uint128,uint128) (runs: 256, [AVG_GAS]) +[PASS] testToStringFuzz(bytes32) (runs: 256, [AVG_GAS]) +Suite result: FAILED. 2 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 2 tests passed, 1 failed, 0 skipped (3 total tests) + +Failing tests: +Encountered 1 failing test in test/Fuzz.t.sol:FuzzTest +[FAIL: should revert; counterexample: calldata=[..] args=[..]] testShouldFailFuzz(uint8) (runs: [..], [AVG_GAS]) + +Encountered a total of 1 failing tests, 2 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test +Tip: Run `forge test --debug --match-test ` to inspect one failing test in the debugger + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]]); +} + +#[forgetest_init] +fn fuzz_failure_persist(prj: _, cmd: _) { + let persist_dir = prj.cache().parent().unwrap().join("persist"); + assert!(!persist_dir.exists()); + prj.update_config(|config| { + config.fuzz.failure_persist_dir = Some(persist_dir.clone()); + }); + + prj.add_test( + "FuzzFailurePersist.t.sol", + r#" +import "forge-std/Test.sol"; + +struct TestTuple { + address user; + uint256 amount; +} + +contract FuzzFailurePersistTest is Test { + function test_persist_fuzzed_failure( + uint256 x, + int256 y, + address addr, + bool cond, + string calldata test, + TestTuple calldata tuple, + address[] calldata addresses + ) public { + // dummy assume to trigger runs + vm.assume(x > 1 && x < 1111111111111111111111111111); + vm.assume(y > 1 && y < 1111111111111111111111111111); + require(false); + } +} + "#, + ); + + let mut calldata = None; + let expected = str![[r#" +... +Ran 1 test for test/FuzzFailurePersist.t.sol:FuzzFailurePersistTest +[FAIL: EvmError: Revert; counterexample: calldata=[..] args=[..]] test_persist_fuzzed_failure(uint256,int256,address,bool,string,(address,uint256),address[]) (runs: 0, [AVG_GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]; + let mut check = |cmd: &mut TestCommand, same: bool| { + let assert = cmd.assert_failure(); + let output = assert.get_output(); + let stdout = String::from_utf8_lossy(&output.stdout); + let calldata = calldata.get_or_insert_with(|| { + let re = Regex::new(r"calldata=(0x[0-9a-fA-F]+)").unwrap(); + re.captures(&stdout).unwrap().get(1).unwrap().as_str().to_string() + }); + assert_eq!(stdout.contains(calldata.as_str()), same, "\n{stdout}"); + assert.stdout_eq(expected.clone()); + }; + + cmd.args(["test", "-j1"]); + + // Run several times, asserting that the failure persists and is the same. + for _ in 0..3 { + check(&mut cmd, true); + assert!(persist_dir.exists()); + } + + // Change dir and run again, asserting that the failure persists. It should be a new failure. + let new_persist_dir = prj.cache().parent().unwrap().join("persist2"); + assert!(!new_persist_dir.exists()); + prj.update_config(|config| { + config.fuzz.failure_persist_dir = Some(new_persist_dir.clone()); + }); + check(&mut cmd, false); + assert!(new_persist_dir.exists()); +} + +#[forgetest_init] +fn zero_fuzz_runs_rejected(prj: _, cmd: _) { + prj.update_config(|config| config.fuzz.runs = 0); + prj.add_test( + "ZeroFuzzRuns.t.sol", + r#" +contract ZeroFuzzRunsTest { + function testFuzz_value(uint256) external pure {} +} + "#, + ); + + let output = cmd.arg("test").assert_failure(); + let stderr = String::from_utf8_lossy(&output.get_output().stderr); + assert!(stderr.contains("`fuzz.runs` must be greater than 0"), "{stderr}"); + + prj.update_config(|config| config.fuzz.runs = 1); + prj.add_test( + "ZeroFuzzRuns.t.sol", + r#" +contract ZeroFuzzRunsTest { + /// forge-config: default.fuzz.runs = 0 + function testFuzz_value(uint256) external pure {} +} + "#, + ); + let output = cmd.forge_fuse().arg("test").assert_failure(); + let stdout = String::from_utf8_lossy(&output.get_output().stdout); + assert!(stdout.contains("`fuzz.runs` must be greater than 0"), "{stdout}"); +} + +// https://github.com/foundry-rs/foundry/pull/735 behavior changed with https://github.com/foundry-rs/foundry/issues/3521 +// random values (instead edge cases) are generated if no fixtures defined +#[forgetest_init] +fn fuzz_int(prj: _, cmd: _) { + prj.add_test( + "FuzzInt.t.sol", + r#" +import "forge-std/Test.sol"; + +contract FuzzNumbersTest is Test { + function testPositive(int256) public { + assertTrue(true); + } + + function testNegativeHalf(int256 val) public { + assertTrue(val < 2 ** 128 - 1); + } + + function testNegative0(int256 val) public { + assertTrue(val == 0); + } + + function testNegative1(int256 val) public { + assertTrue(val == -1); + } + + function testNegative2(int128 val) public { + assertTrue(val == 1); + } + + function testNegativeMax0(int256 val) public { + assertTrue(val == type(int256).max); + } + + function testNegativeMax1(int256 val) public { + assertTrue(val == type(int256).max - 2); + } + + function testNegativeMin0(int256 val) public { + assertTrue(val == type(int256).min); + } + + function testNegativeMin1(int256 val) public { + assertTrue(val == type(int256).min + 2); + } + + function testEquality(int256 x, int256 y) public { + int256 xy; + + unchecked { + xy = x * y; + } + + if ((x != 0 && xy / x != y)) { + return; + } + + assertEq(((xy - 1) / 1e18) + 1, (xy - 1) / (1e18 + 1)); + } +} + "#, + ); + + cmd.args(["test"]).assert_failure().stdout_eq(str![[r#" +... +Ran 10 tests for test/FuzzInt.t.sol:FuzzNumbersTest +[FAIL: assertion failed[..]] testEquality(int256,int256) (runs: [..], [AVG_GAS]) +[FAIL: assertion failed[..]] testNegative0(int256) (runs: [..], [AVG_GAS]) +[FAIL: assertion failed[..]] testNegative1(int256) (runs: [..], [AVG_GAS]) +[FAIL: assertion failed[..]] testNegative2(int128) (runs: [..], [AVG_GAS]) +[FAIL: assertion failed[..]] testNegativeHalf(int256) (runs: [..], [AVG_GAS]) +[FAIL: assertion failed[..]] testNegativeMax0(int256) (runs: [..], [AVG_GAS]) +[FAIL: assertion failed[..]] testNegativeMax1(int256) (runs: [..], [AVG_GAS]) +[FAIL: assertion failed[..]] testNegativeMin0(int256) (runs: [..], [AVG_GAS]) +[FAIL: assertion failed[..]] testNegativeMin1(int256) (runs: [..], [AVG_GAS]) +[PASS] testPositive(int256) (runs: 256, [AVG_GAS]) +Suite result: FAILED. 1 passed; 9 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 9 failed, 0 skipped (10 total tests) +... +"#]]); +} + +#[forgetest_init] +fn fuzz_positive(prj: _, cmd: _) { + prj.add_test( + "FuzzPositive.t.sol", + r#" +import "forge-std/Test.sol"; + +contract FuzzPositive is Test { + function testSuccessChecker(uint256 val) public { + assertTrue(true); + } + + function testSuccessChecker2(int256 val) public { + assert(val == val); + } + + function testSuccessChecker3(uint32 val) public { + assert(val + 0 == val); + } +} + "#, + ); + + cmd.args(["test"]).assert_success().stdout_eq(str![[r#" +... +Ran 3 tests for test/FuzzPositive.t.sol:FuzzPositive +[PASS] testSuccessChecker(uint256) (runs: 256, [AVG_GAS]) +[PASS] testSuccessChecker2(int256) (runs: 256, [AVG_GAS]) +[PASS] testSuccessChecker3(uint32) (runs: 256, [AVG_GAS]) +Suite result: ok. 3 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 3 tests passed, 0 failed, 0 skipped (3 total tests) + +"#]]); +} + +// https://github.com/foundry-rs/foundry/pull/735 behavior changed with https://github.com/foundry-rs/foundry/issues/3521 +// random values (instead edge cases) are generated if no fixtures defined +#[forgetest_init] +fn fuzz_uint(prj: _, cmd: _) { + prj.update_config(|config| { + config.fuzz.seed = Some(U256::from(100u32)); + }); + prj.add_test( + "FuzzUint.t.sol", + r#" +import "forge-std/Test.sol"; + +contract FuzzNumbersTest is Test { + function testPositive(uint256) public { + assertTrue(true); + } + + function testNegativeHalf(uint256 val) public { + assertTrue(val < 2 ** 128 - 1); + } + + function testNegative0(uint256 val) public { + assertTrue(val == 0); + } + + function testNegative2(uint256 val) public { + assertTrue(val == 2); + } + + function testNegative2Max(uint256 val) public { + assertTrue(val == type(uint256).max - 2); + } + + function testNegativeMax(uint256 val) public { + assertTrue(val == type(uint256).max); + } + + function testEquality(uint256 x, uint256 y) public { + uint256 xy; + + unchecked { + xy = x * y; + } + + if ((x != 0 && xy / x != y)) { + return; + } + + assertEq(((xy - 1) / 1e18) + 1, (xy - 1) / (1e18 + 1)); + } +} + "#, + ); + + cmd.args(["test"]).assert_failure().stdout_eq(str![[r#" +... +Ran 7 tests for test/FuzzUint.t.sol:FuzzNumbersTest +[FAIL: assertion failed[..]] testEquality(uint256,uint256) (runs: [..], [AVG_GAS]) +[FAIL: assertion failed[..]] testNegative0(uint256) (runs: [..], [AVG_GAS]) +[FAIL: assertion failed[..]] testNegative2(uint256) (runs: [..], [AVG_GAS]) +[FAIL: assertion failed[..]] testNegative2Max(uint256) (runs: [..], [AVG_GAS]) +[FAIL: assertion failed[..]] testNegativeHalf(uint256) (runs: [..], [AVG_GAS]) +[FAIL: assertion failed[..]] testNegativeMax(uint256) (runs: [..], [AVG_GAS]) +[PASS] testPositive(uint256) (runs: 256, [AVG_GAS]) +Suite result: FAILED. 1 passed; 6 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn should_fuzz_literals(prj: _, cmd: _) { + // Add a source with magic (literal) values + prj.add_source( + "Magic.sol", + r#" + contract Magic { + // plain literals + address constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F; + uint64 constant MAGIC_NUMBER = 1122334455; + int32 constant MAGIC_INT = -777; + bytes32 constant MAGIC_WORD = "abcd1234"; + bytes constant MAGIC_BYTES = hex"deadbeef"; + string constant MAGIC_STRING = "xyzzy"; + + function checkAddr(address v) external pure { assert(v != DAI); } + function checkWord(bytes32 v) external pure { assert(v != MAGIC_WORD); } + function checkNumber(uint64 v) external pure { assert(v != MAGIC_NUMBER); } + function checkInteger(int32 v) external pure { assert(v != MAGIC_INT); } + function checkString(string memory v) external pure { assert(keccak256(abi.encodePacked(v)) != keccak256(abi.encodePacked(MAGIC_STRING))); } + function checkBytesFromHex(bytes memory v) external pure { assert(keccak256(v) != keccak256(MAGIC_BYTES)); } + function checkBytesFromString(bytes memory v) external pure { assert(keccak256(v) != keccak256(abi.encodePacked(MAGIC_STRING))); } + } + "#, + ); + + prj.add_test( + "MagicFuzz.t.sol", + r#" + import {Test} from "forge-std/Test.sol"; + import {Magic} from "src/Magic.sol"; + + contract MagicTest is Test { + Magic public magic; + function setUp() public { magic = new Magic(); } + + function testFuzz_Addr(address v) public view { magic.checkAddr(v); } + function testFuzz_Number(uint64 v) public view { magic.checkNumber(v); } + function testFuzz_Integer(int32 v) public view { magic.checkInteger(v); } + function testFuzz_Word(bytes32 v) public view { magic.checkWord(v); } + function testFuzz_String(string memory v) public view { magic.checkString(v); } + function testFuzz_BytesFromHex(bytes memory v) public view { magic.checkBytesFromHex(v); } + function testFuzz_BytesFromString(bytes memory v) public view { magic.checkBytesFromString(v); } + } + "#, + ); + + // Helper to create expected output for a test failure + let expected_fail = |test_name: &str, type_sig: &str, value: &str| -> String { + format!( + r#"No files changed, compilation skipped + +Ran 1 test for test/MagicFuzz.t.sol:MagicTest +[FAIL: panic: assertion failed (0x01); counterexample: calldata=[..] args=[{value}]] {test_name}({type_sig}) (runs: [..], [AVG_GAS]) +[..] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +... +Encountered a total of 1 failing tests, 0 tests succeeded +... +"# + ) + }; + + // Test address literal fuzzing + let mut test_literal = |seed: u32, + test_name: &'static str, + type_sig: &'static str, + expected_value: &'static str| { + // the fuzzer is UNABLE to find a breaking input (fast) when NOT seeding from the AST + prj.update_config(|config| { + config.fuzz.runs = 100; + config.fuzz.dictionary.max_fuzz_dictionary_literals = 0; + config.fuzz.seed = Some(U256::from(seed)); + }); + cmd.forge_fuse().args(["test", "--match-test", test_name, "-j1"]).assert_success(); + + // the fuzzer is ABLE to find a breaking input when seeding from the AST + prj.update_config(|config| { + config.fuzz.dictionary.max_fuzz_dictionary_literals = 10_000; + }); + + let expected_output = expected_fail(test_name, type_sig, expected_value); + cmd.forge_fuse() + .args(["test", "--match-test", test_name, "-j1"]) + .assert_failure() + .stdout_eq(expected_output); + }; + + test_literal(100, "testFuzz_Addr", "address", "0x6B175474E89094C44Da98b954EedeAC495271d0F"); + test_literal(200, "testFuzz_Number", "uint64", "1122334455 [1.122e9]"); + test_literal(300, "testFuzz_Integer", "int32", "-777"); + test_literal( + 400, + "testFuzz_Word", + "bytes32", + "0x6162636431323334000000000000000000000000000000000000000000000000", /* bytes32("abcd1234") */ + ); + test_literal(500, "testFuzz_BytesFromHex", "bytes", "0xdeadbeef"); + test_literal(600, "testFuzz_String", "string", "\"xyzzy\""); + test_literal(999, "testFuzz_BytesFromString", "bytes", "0x78797a7a79"); // abi.encodePacked("xyzzy") +} + +// Tests that `vm.randomUint()` produces different values across fuzz runs. +// Regression test for https://github.com/foundry-rs/foundry/issues/12817 +// +// The issue was that `vm.randomUint()` would produce the same sequence of values +// in every fuzz run because the RNG was seeded identically for each run. +// This test verifies that with many fuzz runs and a small range, we eventually +// hit value 0, which proves the RNG varies across runs. +#[forgetest_init] +fn test_fuzz_random_uint_varies_across_runs(prj: _, cmd: _) { + prj.add_test( + "RandomFuzzTest.t.sol", + r#" +pragma solidity >=0.8.0; + +import {Test} from "forge-std/Test.sol"; + +contract RandomFuzzTest is Test { + function testFuzz_randomUint_shouldFail(uint256) public { + uint256 rand = vm.randomUint(0, 4); + assertTrue(rand != 0, "hit value 0"); + } +} + "#, + ); + + cmd.args(["test", "--fuzz-seed", "1", "--mt", "testFuzz_randomUint_shouldFail"]) + .assert_failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/RandomFuzzTest.t.sol:RandomFuzzTest +[FAIL: hit value 0; counterexample: [..]] testFuzz_randomUint_shouldFail(uint256) (runs: [..], [AVG_GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) +... +"#]]); +} + +#[forgetest_init] +fn test_fuzz_run_replays_random_uint_failure(prj: _, cmd: _) { + prj.add_test( + "RandomFuzzTest.t.sol", + r#" +pragma solidity >=0.8.0; + +import {Test} from "forge-std/Test.sol"; + +contract RandomFuzzTest is Test { + function testFuzz_randomUint_shouldFail(uint256) public { + uint256 rand = vm.randomUint(0, 4); + assertTrue(rand != 0, "hit value 0"); + } +} + "#, + ); + + let expected_output = str![[r#" +... +Ran 1 test for test/RandomFuzzTest.t.sol:RandomFuzzTest +[FAIL: hit value 0; counterexample: [..]] testFuzz_randomUint_shouldFail(uint256) (runs: [..], [AVG_GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]; + + cmd.args(["test", "--fuzz-seed", "1", "--mt", "testFuzz_randomUint_shouldFail", "-j1"]) + .assert_failure() + .stdout_eq(expected_output.clone()); + + let failure_file = + prj.root().join("cache/fuzz/failures/RandomFuzzTest/testFuzz_randomUint_shouldFail"); + let persisted_failure: BaseCounterExample = + serde_json::from_slice(&std::fs::read(&failure_file).unwrap()).unwrap(); + assert_eq!(persisted_failure.fuzz.seed, Some(U256::ONE)); + assert_eq!(persisted_failure.fuzz.worker, Some(0)); + let fuzz_run = persisted_failure.fuzz.run.unwrap().to_string(); + + cmd.forge_fuse() + .args([ + "test", + "--fuzz-seed", + "1", + "--fuzz-run", + &fuzz_run, + "--mt", + "testFuzz_randomUint_shouldFail", + "-j1", + ]) + .assert_failure() + .stdout_eq(expected_output.clone()); + + cmd.forge_fuse().args(["test", "--rerun", "-j1"]).assert_failure().stdout_eq(expected_output); +} + +#[forgetest_init] +fn test_fuzz_run_replays_calldata_failure_after_rejects(prj: _, cmd: _) { + prj.add_test( + "FuzzReplayTest.t.sol", + r#" +pragma solidity >=0.8.0; + +import {Test} from "forge-std/Test.sol"; + +contract FuzzReplayTest is Test { + function testFuzz_replayAfterReject(uint256 x) public pure { + vm.assume(x != 0); + assert(x != 1); + } +} + "#, + ); + + cmd.args(["test", "--fuzz-seed", "1", "--mt", "testFuzz_replayAfterReject", "-j1"]) + .assert_failure(); + + let failure_file = + prj.root().join("cache/fuzz/failures/FuzzReplayTest/testFuzz_replayAfterReject"); + let persisted_failure: BaseCounterExample = + serde_json::from_slice(&std::fs::read(&failure_file).unwrap()).unwrap(); + assert_eq!(persisted_failure.fuzz.run, Some(4)); + + cmd.forge_fuse() + .args([ + "test", + "--fuzz-seed", + "1", + "--fuzz-run", + "2", + "--mt", + "testFuzz_replayAfterReject", + "-j1", + ]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/FuzzReplayTest.t.sol:FuzzReplayTest +[PASS] testFuzz_replayAfterReject(uint256) (runs: 1, [AVG_GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); + + cmd.forge_fuse() + .args([ + "test", + "--fuzz-seed", + "1", + "--fuzz-run", + "4", + "--mt", + "testFuzz_replayAfterReject", + "-j1", + ]) + .assert_failure().stdout_eq(str![[r#" +... +Ran 1 test for test/FuzzReplayTest.t.sol:FuzzReplayTest +[FAIL: panic: assertion failed (0x01); counterexample: calldata=0xffdf48cd0000000000000000000000000000000000000000000000000000000000000001 args=[1]] testFuzz_replayAfterReject(uint256) (runs: 0, [AVG_GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn test_fuzz_rerun_replays_random_uint_failure_without_seed(prj: _, cmd: _) { + prj.add_test( + "RandomFuzzTest.t.sol", + r#" +pragma solidity >=0.8.0; + +import {Test} from "forge-std/Test.sol"; + +contract RandomFuzzTest is Test { + error Random(uint256 value); + + function testFuzz_randomUint_shouldFail(uint256) public { + revert Random(vm.randomUint()); + } +} + "#, + ); + + let expected_output = str![[r#" +... +Ran 1 test for test/RandomFuzzTest.t.sol:RandomFuzzTest +[FAIL: Random([..]); counterexample: [..]] testFuzz_randomUint_shouldFail(uint256) (runs: [..], [AVG_GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +Tip: Run `forge test --rerun` to retry only the 1 failed test +Tip: Run `forge test --debug --match-test ` to inspect one failing test in the debugger + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]]; + + let assert = cmd + .args(["test", "--mt", "testFuzz_randomUint_shouldFail", "-j1"]) + .assert_failure() + .stdout_eq(expected_output.clone()); + let stdout = String::from_utf8_lossy(&assert.get_output().stdout); + let reason = random_failure_reason(&stdout); + + let failure_file = + prj.root().join("cache/fuzz/failures/RandomFuzzTest/testFuzz_randomUint_shouldFail"); + let persisted_failure: BaseCounterExample = + serde_json::from_slice(&std::fs::read(&failure_file).unwrap()).unwrap(); + let fuzz_seed = format!("{:#x}", persisted_failure.fuzz.seed.unwrap()); + let fuzz_run = persisted_failure.fuzz.run.unwrap().to_string(); + + let assert = cmd + .forge_fuse() + .args([ + "test", + "--fuzz-seed", + &fuzz_seed, + "--fuzz-run", + &fuzz_run, + "--mt", + "testFuzz_randomUint_shouldFail", + "-j1", + ]) + .assert_failure() + .stdout_eq(expected_output.clone()); + let stdout = String::from_utf8_lossy(&assert.get_output().stdout); + assert_eq!(random_failure_reason(&stdout), reason, "{stdout}"); + + let assert = cmd + .forge_fuse() + .args(["test", "--rerun", "-j1"]) + .assert_failure() + .stdout_eq(expected_output); + let stdout = String::from_utf8_lossy(&assert.get_output().stdout); + assert_eq!(random_failure_reason(&stdout), reason, "{stdout}"); + + let assert = cmd.forge_fuse().args(["test", "--rerun", "-j1"]).assert_failure(); + let stdout = String::from_utf8_lossy(&assert.get_output().stdout); + assert_eq!(random_failure_reason(&stdout), reason, "{stdout}"); +} + +// Fuzzed enum inputs must stay within `0..variant_count`, else the contract rejects them with +// `Panic(0x21)` when decoding, before the test body runs. https://github.com/foundry-rs/foundry/issues/6623 +#[forgetest_init] +fn fuzz_bounds_enum_inputs(prj: _, cmd: _) { + // File-level enum in a non-test source, used as a struct field below, to exercise enum + // collection from outside the test file. + prj.add_source( + "LibEnum.sol", + r#" +enum LibEnumVal { L0, L1 } + "#, + ); + + prj.add_test( + "FuzzEnum.t.sol", + r#" +import "forge-std/Test.sol"; +import {LibEnumVal} from "src/LibEnum.sol"; + +contract FuzzEnum is Test { + enum EnumVal { VAL_0, VAL_1, VAL_2 } + + struct WithEnum { + EnumVal e; + uint8 raw; + LibEnumVal lib; + } + + function testScalarEnum(EnumVal val) public pure { + assert(uint8(val) < 3); + } + + function testEnumArray(EnumVal[] memory vals) public pure { + for (uint256 i; i < vals.length; ++i) { + assert(uint8(vals[i]) < 3); + } + } + + function testEnumInStruct(WithEnum memory s) public pure { + assert(uint8(s.e) < 3); + assert(uint8(s.lib) < 2); + } + + function testEnumInStructArray(WithEnum[] memory xs) public pure { + for (uint256 i; i < xs.length; ++i) { + assert(uint8(xs[i].e) < 3); + assert(uint8(xs[i].lib) < 2); + } + } +} + "#, + ); + + cmd.args(["test", "--mc", "FuzzEnum", "--fuzz-runs", "512"]).assert_success().stdout_eq(str![ + [r#" +... +Ran 4 tests for test/FuzzEnum.t.sol:FuzzEnum +[PASS] testEnumArray(uint8[]) (runs: 512, [AVG_GAS]) +[PASS] testEnumInStruct((uint8,uint8,uint8)) (runs: 512, [AVG_GAS]) +[PASS] testEnumInStructArray((uint8,uint8,uint8)[]) (runs: 512, [AVG_GAS]) +[PASS] testScalarEnum(uint8) (runs: 512, [AVG_GAS]) +Suite result: ok. 4 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 4 tests passed, 0 failed, 0 skipped (4 total tests) + +"#] + ]); +} + +#[forgetest_init] +fn fuzz_mutations_preserve_enum_bounds(prj: _, cmd: _) { + let corpus_dir = prj.root().join("enum-corpus"); + prj.update_config(|config| { + config.fuzz.runs = 256; + config.fuzz.seed = Some(U256::ONE); + config.fuzz.corpus.corpus_dir = Some(corpus_dir.clone()); + config.fuzz.corpus.corpus_random_sequence_weight = 0; + let weights = &mut config.fuzz.corpus.mutation_weights; + weights.mutation_weight_splice = 0; + weights.mutation_weight_repeat = 0; + weights.mutation_weight_interleave = 0; + weights.mutation_weight_prefix = 0; + weights.mutation_weight_suffix = 0; + weights.mutation_weight_abi = 1; + weights.mutation_weight_cmp = 0; + }); + prj.add_test( + "FuzzEnumMutation.t.sol", + r#" +contract FuzzEnumMutation { + enum Choice { A, B, C } + + function testEnum(Choice) public pure {} +} + "#, + ); + + cmd.args(["test", "--mc", "FuzzEnumMutation", "-j1"]).assert_success().stdout_eq(str![[r#" +... +Ran 1 test for test/FuzzEnumMutation.t.sol:FuzzEnumMutation +[PASS] testEnum(uint8) (runs: 256, [AVG_GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); + + std::fs::remove_dir_all(&corpus_dir).unwrap(); + prj.clear_cache_dir(); + prj.update_config(|config| { + let weights = &mut config.fuzz.corpus.mutation_weights; + weights.mutation_weight_abi = 0; + weights.mutation_weight_cmp = 1; + }); + prj.add_test( + "FuzzEnumMutation.t.sol", + r#" +contract FuzzEnumMutation { + enum Choice { A, B, C } + + function testEnum(Choice) public pure { + uint256 raw; + assembly { + raw := calldataload(4) + } + require(raw != 255, "unreachable for a valid enum"); + } +} + "#, + ); + + cmd.forge_fuse().args(["test", "--mc", "FuzzEnumMutation", "-j1"]).assert_success().stdout_eq( + str![[r#" +... +Ran 1 test for test/FuzzEnumMutation.t.sol:FuzzEnumMutation +[PASS] testEnum(uint8) (runs: 256, [AVG_GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]], + ); +} + +const FUZZ_CASE_ISOLATION: &str = r#" +import {Test} from "forge-std/Test.sol"; + +abstract contract FuzzCaseIsolation is Test { + address constant TARGET = address(0x10000); + address constant PRANK = address(0x20000); + uint256 counter; + + function setUp() public virtual { + counter = 7; + vm.warp(123); + vm.roll(456); + } + + function sender() external view returns (address) { + return msg.sender; + } + + function testFuzzAcceptedCaseIsolation(uint256) public { + checkAndMutate(); + } + + function testFuzzRejectedCaseIsolation(bool accept) public { + checkAndMutate(); + // Rejection must discard both EVM writes and cheatcode environment and prank changes. + vm.assume(accept); + } + + function checkAndMutate() internal { + require(counter == 7, "case storage leaked"); + require(block.timestamp == 123, "case timestamp leaked"); + require(block.number == 456, "case block leaked"); + require(this.sender() == address(this), "case prank leaked"); + (bool ok, bytes memory output) = TARGET.staticcall(""); + require(ok && abi.decode(output, (uint256)) == 41, "backing storage leaked"); + + counter = 8; + vm.store(TARGET, bytes32(0), bytes32(uint256(99))); + (ok, output) = TARGET.staticcall(""); + require(ok && abi.decode(output, (uint256)) == 99, "case write missing"); + vm.warp(124); + vm.roll(457); + vm.startPrank(PRANK); + require(this.sender() == PRANK, "case prank missing"); + // Leave the prank and environment changes active at the end of the case. + } +} + +contract LocalFuzzCaseIsolationTest is FuzzCaseIsolation { + function setUp() public override { + super.setUp(); + // Returns storage slot zero for any calldata. + vm.etch(TARGET, hex"60005460005260206000f3"); + vm.store(TARGET, bytes32(0), bytes32(uint256(41))); + } +} + +// The fork supplies TARGET's code and storage. +contract ForkFuzzCaseIsolationTest is FuzzCaseIsolation {} +"#; + +// Each stateless fuzz case starts from the post-setup state, whether the previous case was +// accepted or rejected by `vm.assume`. +#[forgetest_init] +fn fuzz_cases_start_from_setup_state(prj: _, cmd: _) { + prj.add_test("FuzzCaseIsolation.t.sol", FUZZ_CASE_ISOLATION); + + cmd.args([ + "test", + "--mc", + "^LocalFuzzCaseIsolationTest$", + "--fuzz-runs", + "16", + "--fuzz-seed", + "1", + ]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 2 tests for test/FuzzCaseIsolation.t.sol:LocalFuzzCaseIsolationTest +[PASS] testFuzzAcceptedCaseIsolation(uint256) (runs: 16, [AVG_GAS]) +[PASS] testFuzzRejectedCaseIsolation(bool) (runs: 16, [AVG_GAS]) +Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 2 tests passed, 0 failed, 0 skipped (2 total tests) + +"#]]); +} + +// Same as `fuzz_cases_start_from_setup_state`, with the target's code and storage read from a +// fork. +#[forgetest_init] +async fn fuzz_cases_start_from_setup_state_fork(prj: _, cmd: _) { + let (api, handle) = spawn(NodeConfig::test()).await; + let target = Address::from_word(B256::from(U256::from(0x10000))); + api.anvil_set_code(target, bytes!("60005460005260206000f3")).await.unwrap(); + api.anvil_set_storage_at(target, U256::ZERO, B256::from(U256::from(41))).await.unwrap(); + api.anvil_mine(Some(U256::ONE), None).await.unwrap(); + + prj.add_test("FuzzCaseIsolation.t.sol", FUZZ_CASE_ISOLATION); + + cmd.args([ + "test", + "--mc", + "^ForkFuzzCaseIsolationTest$", + "--fuzz-runs", + "16", + "--fuzz-seed", + "1", + "--fork-url", + &handle.http_endpoint(), + "--fork-block-number", + "1", + ]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 2 tests for test/FuzzCaseIsolation.t.sol:ForkFuzzCaseIsolationTest +[PASS] testFuzzAcceptedCaseIsolation(uint256) (runs: 16, [AVG_GAS]) +[PASS] testFuzzRejectedCaseIsolation(bool) (runs: 16, [AVG_GAS]) +Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 2 tests passed, 0 failed, 0 skipped (2 total tests) + +"#]]); +} + +fn random_failure_reason(stdout: &str) -> String { + Regex::new(r"\[FAIL: (Random\([^)]+\))") + .unwrap() + .captures(stdout) + .unwrap_or_else(|| panic!("{stdout}"))[1] + .to_string() +} diff --git a/crates/forge/tests/cli/test_cmd/halts.rs b/crates/forge/tests/cli/test_cmd/halts.rs new file mode 100644 index 0000000000000..9f7081d36d172 --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/halts.rs @@ -0,0 +1,299 @@ +//! Snapshots of engine-derived halt reasons. + +use foundry_test_utils::str; + +#[forgetest_init] +fn halts_trace(prj: _, cmd: _) { + prj.update_config(|config| config.code_size_limit = Some(24_576)); + prj.add_test( + "Halts.t.sol", + r#" +pragma solidity ^0.8.18; +import "forge-std/Test.sol"; + +contract HaltsTest is Test { + function testHalts() public { + vm.etch(address(0x1000), hex"fe"); + vm.etch(address(0x1001), hex"600056"); + vm.etch(address(0x1002), hex"0c"); + vm.etch(address(0x1003), hex"01"); + vm.etch(address(0x1004), hex"6001600055"); + vm.etch(address(0x1005), hex"5b600056"); + address(0x1000).call{gas: 50_000}(""); + address(0x1001).call{gas: 50_000}(""); + address(0x1002).call{gas: 50_000}(""); + address(0x1003).call{gas: 50_000}(""); + address(0x1004).staticcall{gas: 50_000}(""); + address(0x1005).call{gas: 50_000}(""); + // Return 24,577 bytes of runtime code. + bytes memory initcode = hex"6160016000f3"; + assembly { pop(create(0, add(initcode, 32), mload(initcode))) } + } +} +"#, + ); + cmd.args(["build"]).assert_success(); + cmd.forge_fuse().args(["test", "-vvvv"]).assert_success().stdout_eq(str![[r#" +No files changed, compilation skipped + +Ran 1 test for test/Halts.t.sol:HaltsTest +[PASS] testHalts() ([GAS]) +Traces: + [..] HaltsTest::testHalts() + ├─ [..] VM::etch(0x0000000000000000000000000000000000001000, 0xfe) + │ └─ ← [Return] + ├─ [..] VM::etch(0x0000000000000000000000000000000000001001, 0x600056) + │ └─ ← [Return] + ├─ [..] VM::etch(0x0000000000000000000000000000000000001002, 0x0c) + │ └─ ← [Return] + ├─ [..] VM::etch(0x0000000000000000000000000000000000001003, 0x01) + │ └─ ← [Return] + ├─ [..] VM::etch(0x0000000000000000000000000000000000001004, 0x6001600055) + │ └─ ← [Return] + ├─ [..] VM::etch(0x0000000000000000000000000000000000001005, 0x5b600056) + │ └─ ← [Return] + ├─ [..] 0x0000000000000000000000000000000000001000::fallback() + │ └─ ← [InvalidFEOpcode] EvmError: InvalidFEOpcode + ├─ [..] 0x0000000000000000000000000000000000001001::fallback() + │ └─ ← [InvalidJump] EvmError: InvalidJump + ├─ [..] 0x0000000000000000000000000000000000001002::fallback() + │ └─ ← [OpcodeNotFound] EvmError: OpcodeNotFound + ├─ [..] 0x0000000000000000000000000000000000001003::fallback() + │ └─ ← [StackUnderflow] EvmError: StackUnderflow + ├─ [..] 0x0000000000000000000000000000000000001004::fallback() [staticcall] + │ └─ ← [StateChangeDuringStaticCall] EvmError: StateChangeDuringStaticCall + ├─ [..] 0x0000000000000000000000000000000000001005::fallback() + │ └─ ← [OutOfGas] EvmError: OutOfGas + ├─ [..] → new @0xa0Cb889707d426A7A386870A03bc70d1b0697598 + │ └─ ← [CreateContractSizeLimit] + └─ ← [Stop] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +} + +#[forgetest_init] +fn halts_evm_error(prj: _, cmd: _) { + prj.update_config(|config| config.gas_limit = 1_000_000.into()); + prj.add_test( + "HaltErrors.t.sol", + r#" +pragma solidity ^0.8.18; +contract HaltErrorsTest { + function testInvalid() public pure { assembly { invalid() } } + function testOutOfGas() public pure { assembly { for {} 1 {} {} } } +} +"#, + ); + cmd.args(["test"]).assert_failure().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 2 tests for test/HaltErrors.t.sol:HaltErrorsTest +[FAIL: EvmError: InvalidFEOpcode] testInvalid() ([GAS]) +[FAIL: EvmError: OutOfGas] testOutOfGas() ([GAS]) +Suite result: FAILED. 0 passed; 2 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 2 failed, 0 skipped (2 total tests) + +Failing tests: +Encountered 2 failing tests in test/HaltErrors.t.sol:HaltErrorsTest +[FAIL: EvmError: InvalidFEOpcode] testInvalid() ([GAS]) +[FAIL: EvmError: OutOfGas] testOutOfGas() ([GAS]) + +Encountered a total of 2 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 2 failed tests +Tip: Run `forge test --debug --match-test ` to inspect one failing test in the debugger + +"#]]); +} + +#[forgetest_init] +fn halts_json(prj: _, cmd: _) { + prj.add_test( + "HaltJson.t.sol", + r#" +pragma solidity ^0.8.18; +import "forge-std/Test.sol"; +contract HaltJsonTest is Test { + function testInvalidChild() public { + vm.etch(address(0x1000), hex"fe"); + address(0x1000).call{gas: 50_000}(""); + } +} +"#, + ); + cmd.args(["test", "--json", "-vvvv"]).assert_json_stdout(str![[r#" +{ + "test/HaltJson.t.sol:HaltJsonTest": { + "duration": "{...}", + "test_results": { + "testInvalidChild()": { + "status": "Success", + "reason": null, + "counterexample": null, + "logs": [], + "decoded_logs": [], + "kind": { + "Unit": { + "gas": "{...}" + } + }, + "traces": [ + [ + "Deployment", + { + "arena": [ + { + "parent": null, + "children": [], + "idx": 0, + "trace": { + "depth": 0, + "success": true, + "caller": "0x1804c8ab1f12e6bbf3894d4083f33e07309d1f38", + "address": "0x7fa9385be102ac3eac297483dd6233d62b3e1496", + "maybe_precompile": false, + "selfdestruct_address": null, + "selfdestruct_refund_target": null, + "selfdestruct_transferred_value": null, + "kind": "CREATE", + "value": "0x0", + "data": "{...}", + "output": "{...}", + "bytecode": null, + "gas_used": "{...}", + "gas_limit": "{...}", + "gas_refund_counter": "{...}", + "status": "Return", + "steps": [], + "step_deltas": [], + "decoded": null + }, + "logs": [], + "ordering": [] + } + ] + } + ], + [ + "Execution", + { + "arena": [ + { + "parent": null, + "children": [ + 1, + 2 + ], + "idx": 0, + "trace": { + "depth": 0, + "success": true, + "caller": "0x1804c8ab1f12e6bbf3894d4083f33e07309d1f38", + "address": "0x7fa9385be102ac3eac297483dd6233d62b3e1496", + "maybe_precompile": null, + "selfdestruct_address": null, + "selfdestruct_refund_target": null, + "selfdestruct_transferred_value": null, + "kind": "CALL", + "value": "0x0", + "data": "0xd8699570", + "output": "0x", + "bytecode": null, + "gas_used": "{...}", + "gas_limit": "{...}", + "gas_refund_counter": "{...}", + "status": "Stop", + "steps": [], + "step_deltas": [], + "decoded": null + }, + "logs": [], + "ordering": [ + { + "Call": 0 + }, + { + "Call": 1 + } + ] + }, + { + "parent": 0, + "children": [], + "idx": 1, + "trace": { + "depth": 1, + "success": true, + "caller": "0x7fa9385be102ac3eac297483dd6233d62b3e1496", + "address": "0x7109709ecfa91a80626ff3989d68f67f5b1dd12d", + "maybe_precompile": null, + "selfdestruct_address": null, + "selfdestruct_refund_target": null, + "selfdestruct_transferred_value": null, + "kind": "CALL", + "value": "0x0", + "data": "0xb4d6c782000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000001fe00000000000000000000000000000000000000000000000000000000000000", + "output": "0x", + "bytecode": null, + "gas_used": "{...}", + "gas_limit": "{...}", + "gas_refund_counter": "{...}", + "status": "Return", + "steps": [], + "step_deltas": [], + "decoded": null + }, + "logs": [], + "ordering": [] + }, + { + "parent": 0, + "children": [], + "idx": 2, + "trace": { + "depth": 1, + "success": false, + "caller": "0x7fa9385be102ac3eac297483dd6233d62b3e1496", + "address": "0x0000000000000000000000000000000000001000", + "maybe_precompile": null, + "selfdestruct_address": null, + "selfdestruct_refund_target": null, + "selfdestruct_transferred_value": null, + "kind": "CALL", + "value": "0x0", + "data": "0x", + "output": "0x", + "bytecode": null, + "gas_used": "{...}", + "gas_limit": "{...}", + "gas_refund_counter": "{...}", + "status": "InvalidFEOpcode", + "steps": [], + "step_deltas": [], + "decoded": null + }, + "logs": [], + "ordering": [] + } + ] + } + ] + ], + "labeled_addresses": {}, + "duration": "{...}", + "breakpoints": {}, + "gas_snapshots": {} + } + }, + "warnings": [] + } +} +"#]]); +} diff --git a/crates/forge/tests/cli/test_cmd/invariant/common.rs b/crates/forge/tests/cli/test_cmd/invariant/common.rs new file mode 100644 index 0000000000000..41fe6396a7097 --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/invariant/common.rs @@ -0,0 +1,3468 @@ +use super::*; +use foundry_compilers::artifacts::EvmVersion; + +#[forgetest] +fn invariant_after_invariant(prj: _, cmd: _) { + prj.insert_vm(); + prj.insert_ds_test(); + + prj.add_test( + "InvariantAfterInvariant.t.sol", + r#" +import { DSTest as Test } from "src/test.sol"; + +struct FuzzSelector { + address addr; + bytes4[] selectors; +} + +contract AfterInvariantHandler { + uint256 public count; + + function inc() external { + count += 1; + } +} + +contract InvariantAfterInvariantTest is Test { + AfterInvariantHandler handler; + + function setUp() public { + handler = new AfterInvariantHandler(); + } + + function targetSelectors() public returns (FuzzSelector[] memory) { + FuzzSelector[] memory targets = new FuzzSelector[](1); + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = handler.inc.selector; + targets[0] = FuzzSelector(address(handler), selectors); + return targets; + } + + function afterInvariant() public { + require(handler.count() < 10, "afterInvariant failure"); + } + + /// forge-config: default.invariant.runs = 1 + /// forge-config: default.invariant.depth = 11 + function invariant_after_invariant_failure() public view { + require(handler.count() < 20, "invariant after invariant failure"); + } + + /// forge-config: default.invariant.runs = 1 + /// forge-config: default.invariant.depth = 11 + function invariant_failure() public view { + require(handler.count() < 9, "invariant failure"); + } + + /// forge-config: default.invariant.runs = 1 + /// forge-config: default.invariant.depth = 5 + function invariant_success() public view { + require(handler.count() < 11, "invariant should not fail"); + } +} +"#, + ); + + assert_invariant(cmd.args(["test"])).failure().stdout_eq(str![[r#" +... +Ran 3 tests for test/InvariantAfterInvariant.t.sol:InvariantAfterInvariantTest +[FAIL: afterInvariant failure] + [SEQUENCE] + invariant_after_invariant_failure() ([RUNS]) + +[STATS] + +[FAIL: invariant failure] + [SEQUENCE] + invariant_failure() ([RUNS]) + +[STATS] + +[PASS] invariant_success() ([RUNS]) + +[STATS] + +Suite result: FAILED. 1 passed; 2 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 2 failed, 0 skipped (3 total tests) + +Failing tests: +Encountered 2 failing tests in test/InvariantAfterInvariant.t.sol:InvariantAfterInvariantTest +[FAIL: afterInvariant failure] + [SEQUENCE] + invariant_after_invariant_failure() ([RUNS]) +[FAIL: invariant failure] + [SEQUENCE] + invariant_failure() ([RUNS]) + +Encountered a total of 2 failing tests, 1 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 2 failed tests + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]]); +} + +#[forgetest_init] +fn invariant_assume(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 10; + // Should not treat vm.assume as revert. + config.invariant.fail_on_revert = true; + }); + + prj.add_test( + "InvariantAssume.t.sol", + r#" +import "forge-std/Test.sol"; + +contract Handler is Test { + uint256 public count; + function doSomething(uint256 param) public { + vm.assume(param == 0); + count++; + } +} + +contract InvariantAssume is Test { + Handler handler; + + function setUp() public { + handler = new Handler(); + } + + function invariant_dummy() public {} +} +"#, + ); + + assert_invariant(cmd.args(["test"])).success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/InvariantAssume.t.sol:InvariantAssume +[PASS] invariant_dummy() ([RUNS]) + +[STATS] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); + + // Test that max_assume_rejects is respected. + prj.update_config(|config| { + config.invariant.max_assume_rejects = 1; + }); + + assert_invariant(&mut cmd).failure().stdout_eq(str![[r#" +No files changed, compilation skipped + +Ran 1 test for test/InvariantAssume.t.sol:InvariantAssume +[FAIL: `vm.assume` rejected too many inputs (1 allowed)] invariant_dummy() ([RUNS]) + +[STATS] + +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/InvariantAssume.t.sol:InvariantAssume +[FAIL: `vm.assume` rejected too many inputs (1 allowed)] invariant_dummy() ([RUNS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]]); +} + +// https://github.com/foundry-rs/foundry/issues/5868 +#[forgetest] +fn invariant_calldata_dictionary(prj: _, cmd: _) { + prj.insert_utils(); + prj.update_config(|config| { + config.fuzz.seed = Some(U256::ONE); + config.invariant.runs = 1000; + config.invariant.depth = 20; + }); + + prj.add_test( + "InvariantCalldataDictionary.t.sol", + r#" +import "./utils/Test.sol"; + +struct FuzzSelector { + address addr; + bytes4[] selectors; +} + +contract Owned { + address public owner; + address private ownerCandidate; + + constructor() { + owner = msg.sender; + } + + modifier onlyOwner() { + require(msg.sender == owner); + _; + } + + modifier onlyOwnerCandidate() { + require(msg.sender == ownerCandidate); + _; + } + + function transferOwnership(address candidate) external onlyOwner { + ownerCandidate = candidate; + } + + function acceptOwnership() external onlyOwnerCandidate { + owner = ownerCandidate; + } +} + +contract Handler is Test { + Owned owned; + + constructor(Owned _owned) { + owned = _owned; + } + + function transferOwnership(address sender, address candidate) external { + vm.assume(sender != address(0)); + vm.prank(sender); + owned.transferOwnership(candidate); + } + + function acceptOwnership(address sender) external { + vm.assume(sender != address(0)); + vm.prank(sender); + owned.acceptOwnership(); + } +} + +contract InvariantCalldataDictionary is Test { + address owner; + Owned owned; + Handler handler; + address[] actors; + + function setUp() public { + owner = address(this); + owned = new Owned(); + handler = new Handler(owned); + actors.push(owner); + actors.push(address(777)); + } + + function targetSelectors() public returns (FuzzSelector[] memory) { + FuzzSelector[] memory targets = new FuzzSelector[](1); + bytes4[] memory selectors = new bytes4[](2); + selectors[0] = handler.transferOwnership.selector; + selectors[1] = handler.acceptOwnership.selector; + targets[0] = FuzzSelector(address(handler), selectors); + return targets; + } + + function fixtureSender() external returns (address[] memory) { + return actors; + } + + function fixtureCandidate() external returns (address[] memory) { + return actors; + } + + function invariant_owner_never_changes() public { + assertEq(owned.owner(), owner); + } +} +"#, + ); + + assert_invariant(cmd.args(["test"])).failure().stdout_eq(str![[r#" +... +Ran 1 test for test/InvariantCalldataDictionary.t.sol:InvariantCalldataDictionary +[FAIL: assertion failed] + [SEQUENCE] + invariant_owner_never_changes() ([RUNS]) + +... + +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/InvariantCalldataDictionary.t.sol:InvariantCalldataDictionary +[FAIL: assertion failed] + [SEQUENCE] + invariant_owner_never_changes() ([RUNS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]]); +} + +#[forgetest_init] +fn invariant_custom_error(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.depth = 10; + config.invariant.fail_on_revert = true; + }); + + prj.add_test( + "InvariantCustomError.t.sol", + r#" +import "forge-std/Test.sol"; + +contract ContractWithCustomError { + error InvariantCustomError(uint256, string); + + function revertWithInvariantCustomError() external { + revert InvariantCustomError(111, "custom"); + } +} + +contract Handler is Test { + ContractWithCustomError target; + + constructor() { + target = new ContractWithCustomError(); + } + + function revertTarget() external { + target.revertWithInvariantCustomError(); + } +} + +contract InvariantCustomError is Test { + Handler handler; + + function setUp() external { + handler = new Handler(); + } + + function invariant_decode_error() public {} +} +"#, + ); + + assert_invariant(cmd.args(["test"])).failure().stdout_eq(str![[r#" +... +Ran 1 test for test/InvariantCustomError.t.sol:InvariantCustomError +[FAIL: InvariantCustomError(111, "custom")] + [SEQUENCE] + invariant_decode_error() ([RUNS]) + +[STATS] + +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/InvariantCustomError.t.sol:InvariantCustomError +[FAIL: InvariantCustomError(111, "custom")] + [SEQUENCE] + invariant_decode_error() ([RUNS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]]); +} + +#[forgetest_init] +fn invariant_excluded_senders(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.depth = 10; + config.invariant.fail_on_revert = true; + }); + + prj.add_test( + "InvariantExcludedSenders.t.sol", + r#" +import "forge-std/Test.sol"; + +contract InvariantSenders { + uint256 public count; + function checkSender() external { + require(msg.sender != 0x7109709ECfa91a80626fF3989D68f67F5b1DD12D, "sender cannot be cheatcode address"); + require(msg.sender != 0x000000000000000000636F6e736F6c652e6c6f67, "sender cannot be console address"); + require(msg.sender != 0x4e59b44847b379578588920cA78FbF26c0B4956C, "sender cannot be CREATE2 deployer"); + count++; + } +} + +contract InvariantExcludedSendersTest is Test { + InvariantSenders target; + + function setUp() public { + target = new InvariantSenders(); + } + + function invariant_check_sender() public view {} +} +"#, + ); + + assert_invariant(cmd.args(["test"])).success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/InvariantExcludedSenders.t.sol:InvariantExcludedSendersTest +[PASS] invariant_check_sender() ([RUNS]) + +[STATS] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +} + +#[forgetest_init] +fn invariant_fixtures(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 100; + config.fuzz.seed = Some(U256::ONE); + // disable literals to test fixtures + config.invariant.dictionary.max_fuzz_dictionary_literals = 0; + config.fuzz.dictionary.max_fuzz_dictionary_literals = 0; + }); + + prj.add_test( + "InvariantFixtures.t.sol", + r#" +import "forge-std/Test.sol"; + +contract Target { + bool ownerFound; + bool amountFound; + bool magicFound; + bool keyFound; + bool backupFound; + bool extraStringFound; + + function fuzzWithFixtures( + address owner_, + uint256 _amount, + int32 magic, + bytes32 key, + bytes memory backup, + string memory extra + ) external { + if (owner_ == address(0x6B175474E89094C44Da98b954EedeAC495271d0F)) { + ownerFound = true; + } + if (_amount == 1122334455) amountFound = true; + if (magic == -777) magicFound = true; + if (key == "abcd1234") keyFound = true; + if (keccak256(backup) == keccak256("qwerty1234")) backupFound = true; + if (keccak256(abi.encodePacked(extra)) == keccak256(abi.encodePacked("112233aabbccdd"))) { + extraStringFound = true; + } + } + + function isCompromised() public view returns (bool) { + return ownerFound && amountFound && magicFound && keyFound && backupFound && extraStringFound; + } +} + +/// Try to compromise target contract by finding all accepted values using fixtures. +contract InvariantFixtures is Test { + Target target; + address[] public fixture_owner_ = [address(0x6B175474E89094C44Da98b954EedeAC495271d0F)]; + uint256[] public fixture_amount = [1, 2, 1122334455]; + + function setUp() public { + target = new Target(); + } + + function fixtureMagic() external returns (int32[2] memory) { + int32[2] memory magic; + magic[0] = -777; + magic[1] = 777; + return magic; + } + + function fixtureKey() external pure returns (bytes32[] memory) { + bytes32[] memory keyFixture = new bytes32[](1); + keyFixture[0] = "abcd1234"; + return keyFixture; + } + + function fixtureBackup() external pure returns (bytes[] memory) { + bytes[] memory backupFixture = new bytes[](1); + backupFixture[0] = "qwerty1234"; + return backupFixture; + } + + function fixtureExtra() external pure returns (string[] memory) { + string[] memory extraFixture = new string[](1); + extraFixture[0] = "112233aabbccdd"; + return extraFixture; + } + + function invariant_target_not_compromised() public { + assertEq(target.isCompromised(), false); + } +} +"#, + ); + + assert_invariant(cmd.args(["test"])).failure().stdout_eq(str![[r#" +... +Ran 1 test for test/InvariantFixtures.t.sol:InvariantFixtures +[FAIL: assertion failed: true != false] + [SEQUENCE] + invariant_target_not_compromised() ([RUNS]) + +[STATS] + +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/InvariantFixtures.t.sol:InvariantFixtures +[FAIL: assertion failed: true != false] + [SEQUENCE] + invariant_target_not_compromised() ([RUNS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]]); +} + +#[forgetest_init] +fn invariant_breaks_without_fixtures(prj: _, cmd: _) { + prj.update_config(|config| { + config.fuzz.seed = Some(U256::ONE); + config.invariant.runs = 1; + config.invariant.depth = 100; + }); + + prj.add_test( + "InvariantLiterals.t.sol", + r#" +import "forge-std/Test.sol"; + +contract Target { + bool ownerFound; + bool amountFound; + bool magicFound; + bool keyFound; + bool backupFound; + bool extraStringFound; + + function fuzzWithoutFixtures( + address owner_, + uint256 _amount, + int32 magic, + bytes32 key, + bytes memory backup, + string memory extra + ) external { + if (owner_ == address(0x6B175474E89094C44Da98b954EedeAC495271d0F)) { + ownerFound = true; + } + if (_amount == 1122334455) amountFound = true; + if (magic == -777) magicFound = true; + if (key == "abcd1234") keyFound = true; + if (keccak256(backup) == keccak256("qwerty1234")) backupFound = true; + if (keccak256(abi.encodePacked(extra)) == keccak256(abi.encodePacked("112233aabbccdd"))) { + extraStringFound = true; + } + } + + function isCompromised() public view returns (bool) { + return ownerFound && amountFound && magicFound && keyFound && backupFound && extraStringFound; + } +} + +/// Try to compromise target contract by finding all accepted values without using fixtures. +contract InvariantLiterals is Test { + Target target; + + function setUp() public { + target = new Target(); + } + + function invariant_target_not_compromised() public { + assertEq(target.isCompromised(), false); + } +} +"#, + ); + + assert_invariant(cmd.args(["test"])).failure().stdout_eq(str![[r#" +... +Ran 1 test for test/InvariantLiterals.t.sol:InvariantLiterals +[FAIL: assertion failed: true != false] + [SEQUENCE] + invariant_target_not_compromised() ([RUNS]) + +[STATS] + +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/InvariantLiterals.t.sol:InvariantLiterals +[FAIL: assertion failed: true != false] + [SEQUENCE] + invariant_target_not_compromised() ([RUNS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]]); +} + +#[forgetest] +fn invariant_handler_failure(prj: _, cmd: _) { + prj.insert_utils(); + prj.update_config(|config| { + config.invariant.fail_on_revert = true; + config.invariant.runs = 1; + config.invariant.depth = 10; + }); + + prj.add_test( + "InvariantHandlerFailure.t.sol", + r#" +import "./utils/Test.sol"; + +struct FuzzSelector { + address addr; + bytes4[] selectors; +} + +contract Handler is Test { + function doSomething() public { + require(false, "failed on revert"); + } +} + +contract InvariantHandlerFailure is Test { + bytes4[] internal selectors; + + Handler handler; + + function targetSelectors() public returns (FuzzSelector[] memory) { + FuzzSelector[] memory targets = new FuzzSelector[](1); + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = handler.doSomething.selector; + targets[0] = FuzzSelector(address(handler), selectors); + return targets; + } + + function setUp() public { + handler = new Handler(); + } + + function statefulFuzz_BrokenInvariant() public {} +} +"#, + ); + + assert_invariant(cmd.args(["test"])).failure().stdout_eq(str![[r#" +... +Ran 1 test for test/InvariantHandlerFailure.t.sol:InvariantHandlerFailure +[FAIL: failed on revert] + [SEQUENCE] + statefulFuzz_BrokenInvariant() ([RUNS]) + +[STATS] + +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/InvariantHandlerFailure.t.sol:InvariantHandlerFailure +[FAIL: failed on revert] + [SEQUENCE] + statefulFuzz_BrokenInvariant() ([RUNS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]]); +} + +// Here we test that the fuzz engine can include a contract created during the fuzz +// in its fuzz dictionary and eventually break the invariant. +// Specifically, can Judas, a created contract from Jesus, break Jesus contract +// by revealing his identity. +#[forgetest_init] +#[cfg_attr(windows, ignore = "for some reason there's different rng")] +fn invariant_inner_contract(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.depth = 10; + }); + + prj.add_test( + "InvariantInnerContract.t.sol", + r#" +import "forge-std/Test.sol"; + +contract Jesus { + address fren; + bool public identity_revealed; + + function create_fren() public { + fren = address(new Judas()); + } + + function kiss() public { + require(msg.sender == fren); + identity_revealed = true; + } +} + +contract Judas { + Jesus jesus; + + constructor() { + jesus = Jesus(msg.sender); + } + + function betray() public { + jesus.kiss(); + } +} + +contract InvariantInnerContract is Test { + Jesus jesus; + + function setUp() public { + jesus = new Jesus(); + } + + function invariantHideJesus() public { + require(jesus.identity_revealed() == false, "jesus betrayed"); + } +} +"#, + ); + + assert_invariant(cmd.args(["test"])).failure().stdout_eq(str![[r#" +... +Ran 1 test for test/InvariantInnerContract.t.sol:InvariantInnerContract +[FAIL: jesus betrayed] + [SEQUENCE] + invariantHideJesus() ([RUNS]) + +[STATS] + +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/InvariantInnerContract.t.sol:InvariantInnerContract +[FAIL: jesus betrayed] + [SEQUENCE] + invariantHideJesus() ([RUNS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]]); + + // `fuzz_seed` at 119 makes this sequence shrinkable from 4 to 2. + prj.update_config(|config| { + config.fuzz.seed = Some(U256::from(119u32)); + // Disable persisted failures for rerunning the test. + config.invariant.failure_persist_dir = Some( + config + .invariant + .failure_persist_dir + .as_ref() + .unwrap() + .parent() + .unwrap() + .join("persistence2"), + ); + }); + assert_invariant(&mut cmd).failure().stdout_eq(str![[r#" +No files changed, compilation skipped + +Ran 1 test for test/InvariantInnerContract.t.sol:InvariantInnerContract +[FAIL: jesus betrayed] + [SEQUENCE] + invariantHideJesus() ([RUNS]) +... +"#]]); +} + +// https://github.com/foundry-rs/foundry/issues/7219 +#[forgetest] +fn invariant_preserve_state(prj: _, cmd: _) { + prj.insert_utils(); + prj.update_config(|config| { + config.invariant.depth = 10; + config.invariant.fail_on_revert = true; + }); + + prj.add_test( + "InvariantPreserveState.t.sol", + r#" +import "./utils/Test.sol"; + +struct FuzzSelector { + address addr; + bytes4[] selectors; +} + +contract Handler is Test { + function thisFunctionReverts() external { + if (block.number < 10) {} else { + revert(); + } + } + + function advanceTime(uint256 blocks) external { + blocks = blocks % 10; + vm.roll(block.number + blocks); + vm.warp(block.timestamp + blocks * 12); + } +} + +contract InvariantPreserveState is Test { + Handler handler; + + function setUp() public { + handler = new Handler(); + } + + function targetSelectors() public returns (FuzzSelector[] memory) { + FuzzSelector[] memory targets = new FuzzSelector[](1); + bytes4[] memory selectors = new bytes4[](2); + selectors[0] = handler.thisFunctionReverts.selector; + selectors[1] = handler.advanceTime.selector; + targets[0] = FuzzSelector(address(handler), selectors); + return targets; + } + + function invariant_preserve_state() public { + assertTrue(true); + } +} +"#, + ); + + assert_invariant(cmd.args(["test"])).failure().stdout_eq(str![[r#" +... +Ran 1 test for test/InvariantPreserveState.t.sol:InvariantPreserveState +[FAIL: EvmError: Revert] + [SEQUENCE] + invariant_preserve_state() ([RUNS]) + +[STATS] + +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/InvariantPreserveState.t.sol:InvariantPreserveState +[FAIL: EvmError: Revert] + [SEQUENCE] + invariant_preserve_state() ([RUNS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]]); +} + +// add code so contract is accounted as valid sender +// see https://github.com/foundry-rs/foundry/issues/4245 +#[forgetest] +fn invariant_reentrancy(prj: _, cmd: _) { + prj.insert_utils(); + prj.update_config(|config| { + config.invariant.depth = 10; + config.invariant.fail_on_revert = false; + config.invariant.call_override = true; + }); + + prj.add_test( + "InvariantReentrancy.t.sol", + r#" +import "./utils/Test.sol"; + +contract Malicious { + function world() public { + payable(msg.sender).call(""); + } +} + +contract Vulnerable { + bool public open_door = false; + bool public stolen = false; + Malicious mal; + + constructor(address _mal) { + mal = Malicious(_mal); + } + + function hello() public { + open_door = true; + mal.world(); + open_door = false; + } + + function backdoor() public { + require(open_door, ""); + stolen = true; + } +} + +contract InvariantReentrancy is Test { + Vulnerable vuln; + Malicious mal; + + function setUp() public { + mal = new Malicious(); + vuln = new Vulnerable(address(mal)); + } + + // do not include `mal` in identified contracts + // see https://github.com/foundry-rs/foundry/issues/4245 + function targetContracts() public view returns (address[] memory) { + address[] memory targets = new address[](1); + targets[0] = address(vuln); + return targets; + } + + function invariantNotStolen() public { + require(vuln.stolen() == false, "stolen"); + } +} +"#, + ); + + assert_invariant(cmd.args(["test"])).failure().stdout_eq(str![[r#" +... +Ran 1 test for test/InvariantReentrancy.t.sol:InvariantReentrancy +[FAIL: stolen] + [SEQUENCE] + invariantNotStolen() ([RUNS]) + +[STATS] + +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/InvariantReentrancy.t.sol:InvariantReentrancy +[FAIL: stolen] + [SEQUENCE] + invariantNotStolen() ([RUNS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]]); +} + +// Tests that call_override detects the classic DAO-style reentrancy vulnerability +// in EtherStore where balances are updated AFTER the external call. +#[forgetest] +fn invariant_reentrancy_ether_store(prj: _, cmd: _) { + prj.insert_utils(); + prj.update_config(|config| { + config.invariant.depth = 15; + config.invariant.fail_on_revert = false; + config.invariant.call_override = true; + }); + + prj.add_test( + "InvariantReentrancyEtherStore.t.sol", + r#" +import "./utils/Test.sol"; + +struct FuzzSelector { + address addr; + bytes4[] selectors; +} + +// Classic reentrancy-vulnerable contract +contract EtherStore { + mapping(address => uint256) public balances; + + function deposit() public payable { + balances[msg.sender] += msg.value; + } + + function withdraw() public { + uint256 bal = balances[msg.sender]; + require(bal > 0); + // BUG: External call before state update + (bool sent,) = msg.sender.call{value: bal}(""); + require(sent, "Failed to send Ether"); + balances[msg.sender] = 0; + } +} + +contract InvariantReentrancyEtherStore is Test { + EtherStore store; + address attacker; + + function setUp() public { + store = new EtherStore(); + attacker = address(0x1337); + + vm.deal(address(this), 10 ether); + store.deposit{value: 5 ether}(); + + // Attacker gets 2 ether, deposits 1 ether, keeps 1 ether in wallet. + // After withdrawing their deposit, attacker wallet balance cannot exceed 2 ether. + vm.deal(attacker, 2 ether); + vm.prank(attacker); + store.deposit{value: 1 ether}(); + } + + function targetContracts() public view returns (address[] memory) { + address[] memory targets = new address[](1); + targets[0] = address(store); + return targets; + } + + function targetSenders() public view returns (address[] memory) { + address[] memory senders = new address[](1); + senders[0] = attacker; + return senders; + } + + function targetSelectors() public view returns (FuzzSelector[] memory) { + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = EtherStore.withdraw.selector; + FuzzSelector[] memory targets = new FuzzSelector[](1); + targets[0] = FuzzSelector(address(store), selectors); + return targets; + } + + // Attacker should never have more than 2 ether (1 kept + 1 withdrawn) + function invariantSolvency() public view { + require(attacker.balance <= 2 ether, "reentrancy: attacker wallet > 2 ether"); + } +} +"#, + ); + + assert_invariant(cmd.args(["test"])).failure().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +... +Ran 1 test for test/InvariantReentrancyEtherStore.t.sol:InvariantReentrancyEtherStore +[FAIL: reentrancy: attacker wallet > 2 ether] + [SEQUENCE] + invariantSolvency() ([RUNS]) + +[STATS] + +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/InvariantReentrancyEtherStore.t.sol:InvariantReentrancyEtherStore +[FAIL: reentrancy: attacker wallet > 2 ether] + [SEQUENCE] + invariantSolvency() ([RUNS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]]); +} + +#[forgetest_init] +fn invariant_roll_fork(prj: _, cmd: _) { + prj.add_rpc_endpoints(); + prj.update_config(|config| { + config.fuzz.seed = Some(U256::from(119u32)); + config.invariant.shrink_run_limit = 0; + }); + + prj.add_test( + "InvariantRollFork.t.sol", + r#" +import "forge-std/Test.sol"; + +interface IERC20 { + function totalSupply() external view returns (uint256 supply); +} + +contract RollForkHandler is Test { + uint256 public totalSupply; + + function work() external { + vm.rollFork(block.number + 1); + totalSupply = IERC20(0x6B175474E89094C44Da98b954EedeAC495271d0F).totalSupply(); + } +} + +contract InvariantRollForkBlockTest is Test { + RollForkHandler forkHandler; + + function setUp() public { + vm.createSelectFork("mainnet", 19812632); + forkHandler = new RollForkHandler(); + } + + /// forge-config: default.invariant.runs = 2 + /// forge-config: default.invariant.depth = 4 + function invariant_fork_handler_block() public view { + require(block.number < 19812634, "too many blocks mined"); + } +} + +contract InvariantRollForkStateTest is Test { + RollForkHandler forkHandler; + + function setUp() public { + vm.createSelectFork("mainnet", 19812632); + forkHandler = new RollForkHandler(); + } + + /// forge-config: default.invariant.runs = 1 + function invariant_fork_handler_state() public view { + require(forkHandler.totalSupply() < 3254378807384273078310283461, "wrong supply"); + } +} +"#, + ); + + assert_invariant(cmd.args(["test", "-j1"])).failure().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/InvariantRollFork.t.sol:InvariantRollForkBlockTest +[FAIL: too many blocks mined] + [SEQUENCE] + invariant_fork_handler_block() (block: 19812634) ([RUNS]) + +[STATS] + +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/InvariantRollFork.t.sol:InvariantRollForkStateTest +[FAIL: wrong supply] + [SEQUENCE] + invariant_fork_handler_state() (block: 19812633) ([RUNS]) + +[STATS] + +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 2 test suites [ELAPSED]: 0 tests passed, 2 failed, 0 skipped (2 total tests) + +Failing tests: +Encountered 1 failing test in test/InvariantRollFork.t.sol:InvariantRollForkBlockTest +[FAIL: too many blocks mined] + [SEQUENCE] + invariant_fork_handler_block() (block: 19812634) ([RUNS]) + +Encountered 1 failing test in test/InvariantRollFork.t.sol:InvariantRollForkStateTest +[FAIL: wrong supply] + [SEQUENCE] + invariant_fork_handler_state() (block: 19812633) ([RUNS]) + +Encountered a total of 2 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 2 failed tests + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]]); +} + +#[forgetest_init] +fn invariant_roll_inactive_fork_preserves_active_block(prj: _, cmd: _) { + prj.add_rpc_endpoints(); + prj.update_config(|config| { + config.fuzz.seed = Some(U256::from(119u32)); + config.invariant.shrink_run_limit = 0; + }); + + prj.add_test( + "InvariantInactiveRollFork.t.sol", + r#" +import "forge-std/Test.sol"; + +contract InactiveRollForkHandler is Test { + uint256 immutable inactiveFork; + uint256 public calls; + + constructor(uint256 inactiveFork_) { + inactiveFork = inactiveFork_; + } + + function work() external { + calls++; + if (calls == 1) { + vm.rollFork(block.number + 1); + } else { + vm.rollFork(inactiveFork, block.number + 1); + } + } +} + +contract InvariantInactiveRollForkTest is Test { + InactiveRollForkHandler forkHandler; + + function setUp() public { + vm.createSelectFork("mainnet", 19812632); + uint256 inactiveFork = vm.createFork("mainnet", 19812632); + forkHandler = new InactiveRollForkHandler(inactiveFork); + targetContract(address(forkHandler)); + } + + /// forge-config: default.invariant.runs = 1 + /// forge-config: default.invariant.depth = 2 + function invariant_inactive_roll_preserves_active_block() public view { + require(forkHandler.calls() < 2, "inactive fork rolled"); + } +} +"#, + ); + + assert_invariant(cmd.args(["test", "-j1"])).failure().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/InvariantInactiveRollFork.t.sol:InvariantInactiveRollForkTest +[FAIL: inactive fork rolled] + [SEQUENCE] + invariant_inactive_roll_preserves_active_block() (block: 19812633) ([RUNS]) + +[STATS] + +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/InvariantInactiveRollFork.t.sol:InvariantInactiveRollForkTest +[FAIL: inactive fork rolled] + [SEQUENCE] + invariant_inactive_roll_preserves_active_block() (block: 19812633) ([RUNS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]]); +} + +#[forgetest_init] +fn invariant_scrape_values(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.depth = 10; + config.fuzz.seed = Some(U256::from(100u32)); + }); + + prj.add_test( + "InvariantScrapeValues.t.sol", + r#" +import "forge-std/Test.sol"; + +contract FindFromReturnValue { + bool public found = false; + + function seed() public returns (int256) { + int256 mystery = 13337; + return (1337 + mystery); + } + + function find(int256 i) public { + int256 mystery = 13337; + if (i == 1337 + mystery) { + found = true; + } + } +} + +contract FindFromReturnValueTest is Test { + FindFromReturnValue target; + + function setUp() public { + target = new FindFromReturnValue(); + } + + /// forge-config: default.invariant.runs = 50 + /// forge-config: default.invariant.depth = 300 + /// forge-config: default.invariant.fail-on-revert = true + function invariant_value_not_found() public view { + require(!target.found(), "value from return found"); + } +} + +contract FindFromLogValue { + event FindFromLog(int256 indexed mystery, bytes32 rand); + + bool public found = false; + + function seed() public { + int256 mystery = 13337; + emit FindFromLog(1337 + mystery, keccak256(abi.encodePacked("mystery"))); + } + + function find(int256 i) public { + int256 mystery = 13337; + if (i == 1337 + mystery) { + found = true; + } + } +} + +contract FindFromLogValueTest is Test { + FindFromLogValue target; + + function setUp() public { + target = new FindFromLogValue(); + } + + /// forge-config: default.invariant.runs = 50 + /// forge-config: default.invariant.depth = 300 + /// forge-config: default.invariant.fail-on-revert = true + function invariant_value_not_found() public view { + require(!target.found(), "value from logs found"); + } +} +"#, + ); + + assert_invariant(cmd.args(["test", "-j1"])).failure().stdout_eq(str![[r#" +... +Ran 2 test suites [ELAPSED]: 0 tests passed, 2 failed, 0 skipped (2 total tests) + +Failing tests: +Encountered 1 failing test in test/InvariantScrapeValues.t.sol:FindFromLogValueTest +[FAIL: value from logs found] + [SEQUENCE] + invariant_value_not_found() ([RUNS]) + +Encountered 1 failing test in test/InvariantScrapeValues.t.sol:FindFromReturnValueTest +[FAIL: value from return found] + [SEQUENCE] + invariant_value_not_found() ([RUNS]) + +Encountered a total of 2 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 2 failed tests + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]]); +} + +#[forgetest_init] +fn invariant_sequence_no_reverts(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.depth = 15; + config.invariant.fail_on_revert = false; + config.invariant.corpus.corpus_random_sequence_weight = 10; + // Use original counterexample to test sequence len. + config.invariant.shrink_run_limit = 0; + }); + + prj.add_test( + "InvariantSequenceNoReverts.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SequenceNoReverts { + uint256 public count; + + function work(uint256 x) public { + x; + count++; + } +} + +contract SequenceNoRevertsTest is Test { + SequenceNoReverts target; + + function setUp() public { + target = new SequenceNoReverts(); + } + + function invariant_no_reverts() public view { + require(target.count() < 10, "condition met"); + } +} +"#, + ); + + // ensure original counterexample len is 10 (even without shrinking) + cmd.args(["test"]).assert_failure().stdout_eq(str![[r#" +... +Ran 1 test for test/InvariantSequenceNoReverts.t.sol:SequenceNoRevertsTest +[FAIL: condition met] + [Sequence] (original: 10, shrunk: 10) +... + invariant_no_reverts() ([..]) +... +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) +... +"#]]); +} + +#[forgetest_init] +#[cfg_attr(windows, ignore = "for some reason there's different rng")] +fn invariant_shrink_big_sequence(prj: _, cmd: _) { + prj.update_config(|config| { + config.fuzz.seed = Some(U256::from(119u32)); + config.invariant.runs = 1; + config.invariant.depth = 1000; + config.invariant.shrink_run_limit = 425; + }); + + prj.add_test( + "InvariantShrinkBigSequence.t.sol", + r#" +import "forge-std/Test.sol"; + +contract ShrinkBigSequence { + uint256 cond; + + function work(uint256 x) public { + if (x % 2 != 0 && x < 9000) { + cond++; + } + } + + function checkCond() public view { + require(cond < 77, "condition met"); + } +} + +contract ShrinkBigSequenceTest is Test { + ShrinkBigSequence target; + + function setUp() public { + target = new ShrinkBigSequence(); + } + + function invariant_shrink_big_sequence() public view { + target.checkCond(); + } +} +"#, + ); + + // ensure shrinks to same sequence of 77 + cmd.args(["test"]).assert_failure().stdout_eq(str![[r#" +... +Ran 1 test for test/InvariantShrinkBigSequence.t.sol:ShrinkBigSequenceTest +[FAIL: condition met] + [Sequence] (original: [..], shrunk: 77) +... +"#]]); + cmd.assert_failure().stdout_eq(str![[r#" +... +Ran 1 test for test/InvariantShrinkBigSequence.t.sol:ShrinkBigSequenceTest +[FAIL: condition met] + [Sequence] (original: [..], shrunk: 77) +... +"#]]); +} + +#[forgetest_init] +fn invariant_shrink_fail_on_revert(prj: _, cmd: _) { + prj.update_config(|config| { + config.fuzz.seed = Some(U256::from(119u32)); + config.invariant.fail_on_revert = true; + config.invariant.runs = 1; + config.invariant.depth = 200; + }); + + prj.add_test( + "InvariantShrinkFailOnRevert.t.sol", + r#" +import "forge-std/Test.sol"; + +contract ShrinkFailOnRevert { + uint256 cond; + + function work(uint256 x) public { + if (x % 2 != 0 && x < 9000) { + cond++; + } + require(cond < 10, "condition met"); + } +} + +contract ShrinkFailOnRevertTest is Test { + ShrinkFailOnRevert target; + + function setUp() public { + target = new ShrinkFailOnRevert(); + } + + function invariant_shrink_fail_on_revert() public view {} +} +"#, + ); + + cmd.args(["test"]).assert_failure().stdout_eq(str![[r#" +... +Ran 1 test for test/InvariantShrinkFailOnRevert.t.sol:ShrinkFailOnRevertTest +[FAIL: condition met] + [Sequence] (original: [..], shrunk: 10) +... +"#]]); +} + +#[forgetest_init] +fn invariant_fail_on_assert_panic(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 10; + config.invariant.fail_on_revert = false; + }); + + prj.add_test( + "InvariantFailOnAssertPanic.t.sol", + r#" +import "forge-std/Test.sol"; + +contract AssertHandler { + uint256 public calls; + + function alwaysAssert() external { + calls++; + assert(false); + } +} + +contract InvariantFailOnAssertPanic is Test { + AssertHandler handler; + + function setUp() public { + handler = new AssertHandler(); + targetContract(address(handler)); + } + + function invariant_fail_on_assert_panic() public view {} +} +"#, + ); + + assert_invariant(cmd.args(["test"])).failure().stdout_eq(str![[r#" +... +Ran 1 test for test/InvariantFailOnAssertPanic.t.sol:InvariantFailOnAssertPanic +Assertion Tests: 1 assertion bug(s) found +[FAIL: panic: assertion failed (0x01)][..] +... + invariant_fail_on_assert_panic() ([RUNS]) +... +"#]]); +} + +#[forgetest_init] +fn invariant_ignore_assert_panic_when_flag_off(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 10; + config.invariant.fail_on_revert = false; + }); + + prj.add_test( + "InvariantIgnoreAssertWhenFlagOff.t.sol", + r#" +import "forge-std/Test.sol"; + +contract AssertHandler { + uint256 public calls; + + function alwaysAssert() external { + calls++; + assert(false); + } +} + +contract InvariantIgnoreAssertWhenFlagOff is Test { + AssertHandler handler; + + function setUp() public { + handler = new AssertHandler(); + targetContract(address(handler)); + } + + function invariant_assert_discarded() public view {} +} +"#, + ); + + assert_invariant(cmd.args(["test"])).failure().stdout_eq(str![[r#" +... +Ran 1 test for test/InvariantIgnoreAssertWhenFlagOff.t.sol:InvariantIgnoreAssertWhenFlagOff +Assertion Tests: 1 assertion bug(s) found +[FAIL: panic: assertion failed (0x01)][..] +... + invariant_assert_discarded() ([RUNS]) +... +"#]]); +} + +#[forgetest_init] +fn invariant_fail_on_assert_ignores_non_assert_panic(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 10; + config.invariant.fail_on_revert = false; + }); + + prj.add_test( + "InvariantIgnoreNonAssertPanic.t.sol", + r#" +import "forge-std/Test.sol"; + +contract OverflowHandler { + uint256 public calls; + + function alwaysOverflow() external { + calls++; + uint256 x = type(uint256).max; + x = x + 1; + } +} + +contract InvariantIgnoreNonAssertPanic is Test { + OverflowHandler handler; + + function setUp() public { + handler = new OverflowHandler(); + targetContract(address(handler)); + } + + function invariant_non_assert_panic_discarded() public view {} +} +"#, + ); + + assert_invariant(cmd.args(["test"])).success().stdout_eq(str![[r#" +... +[PASS] invariant_non_assert_panic_discarded() ([RUNS]) +... +"#]]); +} + +#[forgetest_init] +fn invariant_fail_on_assert_ignores_require_revert(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 10; + config.invariant.fail_on_revert = false; + }); + + prj.add_test( + "InvariantIgnoreRequireRevert.t.sol", + r#" +import "forge-std/Test.sol"; + +contract RequireHandler { + uint256 public calls; + + function alwaysRequire() external { + calls++; + require(false, "require failed"); + } +} + +contract InvariantIgnoreRequireRevert is Test { + RequireHandler handler; + + function setUp() public { + handler = new RequireHandler(); + targetContract(address(handler)); + } + + function invariant_require_revert_discarded() public view {} +} +"#, + ); + + assert_invariant(cmd.args(["test"])).success().stdout_eq(str![[r#" +... +[PASS] invariant_require_revert_discarded() ([RUNS]) +... +"#]]); +} + +#[forgetest_init] +fn invariant_replay_fail_on_assert(prj: _, cmd: _) { + prj.update_config(|config| { + config.fuzz.seed = Some(U256::from(119u32)); + config.invariant.fail_on_revert = false; + config.invariant.runs = 1; + config.invariant.depth = 200; + }); + + prj.add_test( + "InvariantReplayFailOnAssert.t.sol", + r#" +import "forge-std/Test.sol"; + +contract ReplayAssertHandler { + uint256 public calls; + + function alwaysAssert() external { + calls++; + assert(false); + } +} + +contract ReplayFailOnAssertTest is Test { + ReplayAssertHandler handler; + + function setUp() public { + handler = new ReplayAssertHandler(); + targetContract(address(handler)); + } + + function invariant_replay_fail_on_assert() public view {} +} +"#, + ); + + cmd.args(["test"]).assert_failure().stdout_eq(str![[r#" +... +Ran 1 test for test/InvariantReplayFailOnAssert.t.sol:ReplayFailOnAssertTest +Assertion Tests: 1 assertion bug(s) found +[FAIL: panic: assertion failed (0x01)][..] +... +"#]]); + + cmd.assert_failure().stdout_eq(str![[r#" +... +Ran 1 test for test/InvariantReplayFailOnAssert.t.sol:ReplayFailOnAssertTest +Assertion Tests: 1 assertion bug(s) found +[FAIL: panic: assertion failed (0x01)][..] +... +"#]]); +} + +#[forgetest_init] +fn invariant_fail_on_vm_assert_revert(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 10; + config.invariant.fail_on_revert = false; + }); + + prj.add_test( + "InvariantFailOnVmAssertRevert.t.sol", + r#" +import "forge-std/Test.sol"; + +contract VmAssertHandler { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + uint256 public calls; + + function alwaysVmAssert() external { + calls++; + vm.assertEq(uint256(1), uint256(2)); + } +} + +contract InvariantFailOnVmAssertRevert is Test { + VmAssertHandler handler; + + function setUp() public { + handler = new VmAssertHandler(); + targetContract(address(handler)); + } + + function invariant_fail_on_vm_assert_revert() public view {} +} +"#, + ); + + assert_invariant(cmd.args(["test"])).failure().stdout_eq(str![[r#" +... +Ran 1 test for test/InvariantFailOnVmAssertRevert.t.sol:InvariantFailOnVmAssertRevert +Assertion Tests: 1 assertion bug(s) found +[FAIL: assertion failed: 1 != 2][..] +... + invariant_fail_on_vm_assert_revert() ([RUNS]) +... +"#]]); +} + +#[forgetest_init] +fn invariant_ignore_vm_assert_when_flag_off(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 10; + config.invariant.fail_on_revert = false; + }); + + prj.add_test( + "InvariantIgnoreVmAssertWhenFlagOff.t.sol", + r#" +import "forge-std/Test.sol"; + +contract VmAssertHandler { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + uint256 public calls; + + function alwaysVmAssert() external { + calls++; + vm.assertEq(uint256(1), uint256(2)); + } +} + +contract InvariantIgnoreVmAssertWhenFlagOff is Test { + VmAssertHandler handler; + + function setUp() public { + handler = new VmAssertHandler(); + targetContract(address(handler)); + } + + function invariant_vm_assert_discarded() public view {} +} +"#, + ); + + assert_invariant(cmd.args(["test"])).failure().stdout_eq(str![[r#" +... +Ran 1 test for test/InvariantIgnoreVmAssertWhenFlagOff.t.sol:InvariantIgnoreVmAssertWhenFlagOff +Assertion Tests: 1 assertion bug(s) found +[FAIL: assertion failed: 1 != 2][..] +... + invariant_vm_assert_discarded() ([RUNS]) +... +"#]]); +} + +#[forgetest_init] +fn invariant_fail_on_vm_assert_global_flag(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 10; + config.invariant.fail_on_revert = false; + config.assertions_revert = false; + }); + + prj.add_test( + "InvariantFailOnVmAssertGlobalFlag.t.sol", + r#" +import "forge-std/Test.sol"; + +contract VmAssertHandler { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + uint256 public calls; + + function alwaysVmAssert() external { + calls++; + vm.assertEq(uint256(1), uint256(2)); + } +} + +contract InvariantFailOnVmAssertGlobalFlag is Test { + VmAssertHandler handler; + + function setUp() public { + handler = new VmAssertHandler(); + targetContract(address(handler)); + } + + function invariant_fail_on_vm_assert_global_flag() public view {} +} +"#, + ); + + assert_invariant(cmd.args(["test"])).failure().stdout_eq(str![[r#" +... +Ran 1 test for test/InvariantFailOnVmAssertGlobalFlag.t.sol:InvariantFailOnVmAssertGlobalFlag +Assertion Tests: 1 assertion bug(s) found +[FAIL: assertion failed][..] +... + invariant_fail_on_vm_assert_global_flag() ([RUNS]) +... +"#]]); +} + +#[forgetest_init] +fn invariant_ignore_vm_assert_global_flag_when_flag_off(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 10; + config.invariant.fail_on_revert = false; + config.assertions_revert = false; + }); + + prj.add_test( + "InvariantIgnoreVmAssertGlobalFlagWhenFlagOff.t.sol", + r#" +import "forge-std/Test.sol"; + +contract VmAssertHandler { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + uint256 public calls; + + function alwaysVmAssert() external { + calls++; + vm.assertEq(uint256(1), uint256(2)); + } +} + +contract InvariantIgnoreVmAssertGlobalFlagWhenFlagOff is Test { + VmAssertHandler handler; + + function setUp() public { + handler = new VmAssertHandler(); + targetContract(address(handler)); + } + + function invariant_vm_assert_global_flag_discarded() public view {} +} +"#, + ); + + assert_invariant(cmd.args(["test"])).failure().stdout_eq(str![[r#" +... +Ran 1 test for test/InvariantIgnoreVmAssertGlobalFlagWhenFlagOff.t.sol:InvariantIgnoreVmAssertGlobalFlagWhenFlagOff +Assertion Tests: 1 assertion bug(s) found +[FAIL: assertion failed][..] +... + invariant_vm_assert_global_flag_discarded() ([RUNS]) +... +"#]]); +} + +#[forgetest_init] +fn invariant_shrink_with_assert(prj: _, cmd: _) { + prj.update_config(|config| { + config.fuzz.seed = Some(U256::from(100u32)); + config.invariant.runs = 1; + config.invariant.depth = 15; + }); + + prj.add_test( + "InvariantShrinkWithAssert.t.sol", + r#" +import "forge-std/Test.sol"; + +contract Counter { + uint256 public number; + + function increment() public { + number++; + } + + function decrement() public { + number--; + } +} + +contract InvariantShrinkWithAssert is Test { + Counter public counter; + + function setUp() public { + counter = new Counter(); + } + + function invariant_with_assert() public { + assertTrue(counter.number() < 2, "wrong counter assert"); + } + + function invariant_with_require() public { + require(counter.number() < 2, "wrong counter require"); + } +} +"#, + ); + + assert_invariant(cmd.args(["test"])).failure().stdout_eq(str![[r#" +... +Ran 1 test for test/InvariantShrinkWithAssert.t.sol:InvariantShrinkWithAssert +[FAIL: wrong counter assert] invariant_with_assert + [SEQUENCE] +... +[FAIL: wrong counter require] invariant_with_require + [SEQUENCE] + +InvariantShrinkWithAssert invariants: 2/2 invariants broken +... +[FAIL: wrong counter assert] invariant_with_assert +[FAIL: wrong counter require] invariant_with_require +... + InvariantShrinkWithAssert invariants ([RUNS]) +... +"#]]); +} + +#[forgetest_init] +fn invariant_replay_keeps_assertion_failure_from_invariant_function(prj: _, cmd: _) { + prj.update_config(|config| { + config.fuzz.seed = Some(U256::from(100u32)); + config.invariant.runs = 1; + config.invariant.depth = 15; + }); + + prj.add_test( + "InvariantReplayKeepsInvariantAssertion.t.sol", + r#" +import "forge-std/Test.sol"; + +contract Counter { + uint256 public number; + + function increment() public { + number++; + } + + function decrement() public { + number--; + } +} + +contract InvariantReplayKeepsInvariantAssertion is Test { + Counter public counter; + + function setUp() public { + counter = new Counter(); + } + + function invariant_with_assert() public view { + assertTrue(counter.number() < 2, "wrong counter assert"); + } +} +"#, + ); + + cmd.args(["test"]).assert_failure().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/InvariantReplayKeepsInvariantAssertion.t.sol:InvariantReplayKeepsInvariantAssertion +[FAIL: wrong counter assert] +... + invariant_with_assert() ([..]) +... +"#]]); + + cmd.assert_failure().stdout_eq(str![[r#" +... +Ran 1 test for test/InvariantReplayKeepsInvariantAssertion.t.sol:InvariantReplayKeepsInvariantAssertion +[FAIL: wrong counter assert] + [Sequence] (original: 2, shrunk: 2) +... + invariant_with_assert() ([..]) +... +"#]]); +} + +#[forgetest_init] +fn invariant_replay_keeps_assertion_failure_from_after_invariant(prj: _, cmd: _) { + prj.update_config(|config| { + config.fuzz.seed = Some(U256::from(119u32)); + config.invariant.runs = 1; + config.invariant.depth = 2; + }); + + prj.add_test( + "InvariantReplayKeepsAfterInvariantAssertion.t.sol", + r#" +import "forge-std/Test.sol"; + +contract AfterInvariantAssertHandler { + uint256 public count; + + function inc() external { + count += 1; + } +} + +contract InvariantReplayKeepsAfterInvariantAssertion is Test { + AfterInvariantAssertHandler handler; + + function setUp() public { + handler = new AfterInvariantAssertHandler(); + targetContract(address(handler)); + } + + function afterInvariant() public view { + assertTrue(handler.count() < 2, "afterInvariant assertion"); + } + + function invariant_success() public view { + require(handler.count() < 10, "invariant should not fail"); + } +} +"#, + ); + + assert_invariant(cmd.args(["test"])).failure().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/InvariantReplayKeepsAfterInvariantAssertion.t.sol:InvariantReplayKeepsAfterInvariantAssertion +[FAIL: afterInvariant assertion] + [SEQUENCE] + invariant_success() ([RUNS]) +... +"#]]); + + assert_invariant(&mut cmd).failure().stdout_eq(str![[r#" +No files changed, compilation skipped + +Ran 1 test for test/InvariantReplayKeepsAfterInvariantAssertion.t.sol:InvariantReplayKeepsAfterInvariantAssertion +[FAIL: afterInvariant assertion] + [SEQUENCE] + invariant_success() ([RUNS]) +... +"#]]); +} + +#[forgetest_init] +fn invariant_replay_persists_initial_after_invariant_failure(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 2; + }); + + prj.add_test( + "InvariantReplayInitialAfterInvariantFailure.t.sol", + r#" +import "forge-std/Test.sol"; + +contract InitialAfterInvariantHandler { + uint256 public count; + + function inc() external { + count += 1; + } +} + +contract InvariantReplayInitialAfterInvariantFailure is Test { + InitialAfterInvariantHandler handler; + + function setUp() public { + handler = new InitialAfterInvariantHandler(); + handler.inc(); + handler.inc(); + targetContract(address(handler)); + } + + function afterInvariant() public view { + assertTrue(handler.count() < 2, "afterInvariant initial assertion"); + } + + function invariant_success() public view { + require(handler.count() < 10, "invariant should not fail"); + } +} +"#, + ); + + assert_invariant(cmd.args(["test"])).failure().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/InvariantReplayInitialAfterInvariantFailure.t.sol:InvariantReplayInitialAfterInvariantFailure +[FAIL: afterInvariant initial assertion] + [SEQUENCE] + invariant_success() ([RUNS]) +... +"#]]); + + cmd.forge_fuse().args(["test", "--rerun"]).assert_failure().stdout_eq(str![[r#" +No files changed, compilation skipped + +Ran 1 test for test/InvariantReplayInitialAfterInvariantFailure.t.sol:InvariantReplayInitialAfterInvariantFailure +[FAIL: afterInvariant initial assertion] + [Sequence] (original: 1, shrunk: 1) + sender=[..] addr=[..] calldata=inc() args=[] + invariant_success() (runs: 1, calls: 1, reverts: 0) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/InvariantReplayInitialAfterInvariantFailure.t.sol:InvariantReplayInitialAfterInvariantFailure +[FAIL: afterInvariant initial assertion] + [Sequence] (original: 1, shrunk: 1) + sender=[..] addr=[..] calldata=inc() args=[] + invariant_success() (runs: 1, calls: 1, reverts: 0) +... +"#]]); +} + +#[forgetest_init] +fn invariant_replay_uses_full_persisted_sequence_after_depth_decrease(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 20; + config.invariant.depth = 20; + }); + + prj.add_test( + "InvariantReplayFullSequence.t.sol", + r#" +import "forge-std/Test.sol"; + +contract FullSequenceHandler { + uint256 public count; + + function inc() external { + count += 1; + } +} + +contract InvariantReplayFullSequence is Test { + FullSequenceHandler handler; + + function setUp() public { + handler = new FullSequenceHandler(); + targetContract(address(handler)); + } + + function invariant_count_below_five() public view { + require(handler.count() < 5, "count reached five"); + } +} +"#, + ); + + // Five calls are needed to break the invariant; record that sequence. + assert_invariant(cmd.args(["test"])).failure().stdout_eq(str![[r#" +... +[FAIL: count reached five] + [SEQUENCE] + invariant_count_below_five() ([RUNS]) +... +"#]]); + + // A depth of 3 can never reach five calls on its own, so only the persisted sequence can + // still fail the test. It must be replayed in full rather than cut to the new depth. + prj.update_config(|config| { + config.invariant.runs = 20; + config.invariant.depth = 3; + }); + cmd.forge_fuse().args(["test"]).assert_failure().stdout_eq(str![[r#" +... +[FAIL: count reached five] + [Sequence] (original: 5, shrunk: 5) + sender=[..] addr=[..] calldata=inc() args=[] + sender=[..] addr=[..] calldata=inc() args=[] + sender=[..] addr=[..] calldata=inc() args=[] + sender=[..] addr=[..] calldata=inc() args=[] + sender=[..] addr=[..] calldata=inc() args=[] + invariant_count_below_five() (runs: 1, calls: 5, reverts: 0) +... +"#]]); +} + +#[forgetest_init] +fn invariant_test1(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.depth = 10; + }); + + prj.add_test( + "InvariantTest1.t.sol", + r#" +import "forge-std/Test.sol"; + +contract InvariantBreaker { + bool public flag0 = true; + bool public flag1 = true; + + function set0(int256 val) public returns (bool) { + if (val % 100 == 0) { + flag0 = false; + } + return flag0; + } + + function set1(int256 val) public returns (bool) { + if (val % 10 == 0 && !flag0) { + flag1 = false; + } + return flag1; + } +} + +contract InvariantTest is Test { + InvariantBreaker inv; + + function setUp() public { + inv = new InvariantBreaker(); + } + + function invariant_neverFalse() public { + require(inv.flag1(), "false"); + } + + function statefulFuzz_neverFalseWithInvariantAlias() public { + require(inv.flag1(), "false"); + } +} +"#, + ); + + assert_invariant(cmd.args(["test"])).failure().stdout_eq(str![[r#" +... +Ran 1 test for test/InvariantTest1.t.sol:InvariantTest +[FAIL: false] invariant_neverFalse + [SEQUENCE] + +[FAIL: false] statefulFuzz_neverFalseWithInvariantAlias + [SEQUENCE] + +InvariantTest invariants: 2/2 invariants broken +[FAIL: false] invariant_neverFalse +[FAIL: false] statefulFuzz_neverFalseWithInvariantAlias +2 invariant failure(s) persisted to cache/invariant/failures/InvariantTest — rerun to shrink + InvariantTest invariants ([RUNS]) + +[STATS] + +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/InvariantTest1.t.sol:InvariantTest +[FAIL: false] invariant_neverFalse + [SEQUENCE] + +[FAIL: false] statefulFuzz_neverFalseWithInvariantAlias + [SEQUENCE] + +InvariantTest invariants: 2/2 invariants broken +[FAIL: false] invariant_neverFalse +[FAIL: false] statefulFuzz_neverFalseWithInvariantAlias +2 invariant failure(s) persisted to cache/invariant/failures/InvariantTest — rerun to shrink + InvariantTest invariants ([RUNS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]]); +} + +#[forgetest_init] +fn invariant_warp_and_roll(prj: _, cmd: _) { + 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; + }); + + prj.add_test( + "InvariantWarpAndRoll.t.sol", + r#" +import "forge-std/Test.sol"; + +contract Counter { + uint256 public number; + + function setNumber(uint256 newNumber) public { + number = newNumber; + } + + function increment() public { + number++; + } +} + +contract InvariantWarpAndRoll { + Counter public counter; + + function setUp() public { + counter = new Counter(); + } + + function invariant_warp() public view { + require(block.timestamp < 500000, "max timestamp"); + } + + /// forge-config: default.invariant.show_solidity = true + function invariant_roll() public view { + require(block.number < 200000, "max block"); + } +} +"#, + ); + + assert_invariant(cmd.args(["test", "--mt", "invariant_warp"])).failure().stdout_eq(str![[r#" +... +[FAIL: max timestamp] + [SEQUENCE] + invariant_warp() ([RUNS]) +... + +"#]]); + + assert_invariant(cmd.forge_fuse().args(["test", "--mt", "invariant_roll"])) + .failure() + .stdout_eq(str![[r#" +... +[FAIL: max block] + [SEQUENCE] + invariant_roll() ([RUNS]) +... + +"#]]); + + // Test that time and block advance in target contract as well. + prj.update_config(|config| { + config.invariant.fail_on_revert = true; + }); + prj.add_test( + "HandlerWarpAndRoll.t.sol", + r#" +import "forge-std/Test.sol"; + +contract Counter { + uint256 public number; + function setNumber(uint256 newNumber) public { + require(block.number < 200000, "max block"); + number = newNumber; + } + + function increment() public { + require(block.timestamp < 500000, "max timestamp"); + number++; + } +} + +contract HandlerWarpAndRoll { + Counter public counter; + + function setUp() public { + counter = new Counter(); + } + + function invariant_handler() public view { + } +} +"#, + ); + + assert_invariant(cmd.forge_fuse().args(["test", "--mt", "invariant_handler"])) + .failure() + .stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/HandlerWarpAndRoll.t.sol:HandlerWarpAndRoll +[FAIL: max timestamp] + [SEQUENCE] + invariant_handler() ([RUNS]) + +... + +"#]]); +} + +// Test that state is preserved across calls during invariant replay. +// Regression test for commit 0584a581b which changed replay_run to use execute_tx +// (which uses call_raw) instead of transact_raw, but forgot to add the commit() call. +#[forgetest_init] +fn invariant_replay_state_preserved(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 5; + }); + + prj.add_test( + "InvariantReplayState.t.sol", + r#" +import "forge-std/Test.sol"; + +contract Handler is Test { + uint256 public counter; + + function increment(uint256 amount) public { + uint256 before = counter; + counter += 1; + console.log("before:", before, "after:", counter); + } +} + +contract InvariantReplayStateTest is Test { + Handler handler; + + function setUp() public { + handler = new Handler(); + targetContract(address(handler)); + } + + function invariant_counter_increases() public view { + assertTrue(true); + } +} +"#, + ); + + // With -vvv we see logs from replay. The "before" value of each call should + // match the "after" value from the previous call, proving state persists. + cmd.args(["test", "-vvv"]).assert_success().stdout_eq(str![[r#" +... +[PASS] invariant_counter_increases() (runs: 1, calls: 5, reverts: 0) +... +Logs: + before: 0 after: 1 + before: 1 after: 2 + before: 2 after: 3 + before: 3 after: 4 + before: 4 after: 5 +... +"#]]); +} + +// Test optimization mode for invariant testing. +// When an invariant function returns int256, it becomes an optimization target. +// The fuzzer maximizes the return value instead of checking for failures. +#[forgetest] +fn invariant_optimization_mode(prj: _, cmd: _) { + prj.insert_vm(); + prj.insert_ds_test(); + + prj.add_test( + "InvariantOptimize.t.sol", + r#" +import { DSTest as Test } from "src/test.sol"; + +struct FuzzSelector { + address addr; + bytes4[] selectors; +} + +contract OptimizationHandler { + int256 public value; + + // Each call adds exactly 10 to value + function increment() external { + value += 10; + } +} + +contract InvariantOptimizeTest is Test { + OptimizationHandler handler; + + function setUp() public { + handler = new OptimizationHandler(); + } + + function targetSelectors() public returns (FuzzSelector[] memory) { + FuzzSelector[] memory targets = new FuzzSelector[](1); + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = handler.increment.selector; + targets[0] = FuzzSelector(address(handler), selectors); + return targets; + } + + /// forge-config: default.invariant.runs = 1 + /// forge-config: default.invariant.depth = 5 + /// @notice Optimization mode: returns int256 to maximize. + /// With depth=5 and only increment(), max value should be 50. + function invariant_optimize_value() public view returns (int256) { + return handler.value(); + } +} +"#, + ); + + // Optimization mode: best value shown first, should reach 50 (5 calls * 10 each) + // Shows [Best sequence] with calls in output + cmd.args(["test", "-vvv"]).assert_success().stdout_eq(str![[r#" +... +[PASS] + [Best sequence] [..] +[..]calldata=increment()[..] +[..]calldata=increment()[..] +[..]calldata=increment()[..] +[..]calldata=increment()[..] +[..]calldata=increment()[..] + invariant_optimize_value() (best: 50, runs: 1, calls: 5) +... +"#]]); +} + +// Test that optimization mode works with negative values (finding max of negative range). +#[forgetest] +fn invariant_optimization_negative_values(prj: _, cmd: _) { + prj.insert_vm(); + prj.insert_ds_test(); + + prj.add_test( + "InvariantOptimizeNegative.t.sol", + r#" +import { DSTest as Test } from "src/test.sol"; + +struct FuzzSelector { + address addr; + bytes4[] selectors; +} + +contract NegativeHandler { + int256 public value = -100; + + // Each call adds exactly 25 to value + function increase() external { + value += 25; + } +} + +contract InvariantOptimizeNegativeTest is Test { + NegativeHandler handler; + + function setUp() public { + handler = new NegativeHandler(); + } + + function targetSelectors() public returns (FuzzSelector[] memory) { + FuzzSelector[] memory targets = new FuzzSelector[](1); + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = handler.increase.selector; + targets[0] = FuzzSelector(address(handler), selectors); + return targets; + } + + /// forge-config: default.invariant.runs = 1 + /// forge-config: default.invariant.depth = 4 + /// Starting at -100, 4 calls of +25 each = -100 + 100 = 0 + function invariant_optimize_negative() public view returns (int256) { + return handler.value(); + } +} +"#, + ); + + // Optimization should reach 0: starting at -100, 4 calls * +25 = 0 + // Shows [Best sequence] with calls in output + cmd.args(["test", "-vvv"]).assert_success().stdout_eq(str![[r#" +... +[PASS] + [Best sequence] [..] +[..]calldata=increase()[..] +[..]calldata=increase()[..] +[..]calldata=increase()[..] +[..]calldata=increase()[..] + invariant_optimize_negative() (best: 0, runs: 1, calls: 4) +... +"#]]); +} + +// Test that optimization mode: +// 1. Evaluates at every prefix regardless of check_interval (finds true max, not just last-call +// value) +// 2. Persists the best value across runs via corpus directory +#[forgetest_init] +fn invariant_optimization_check_interval_and_persistence(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 5; + config.invariant.check_interval = 0; + config.invariant.corpus.corpus_dir = Some("opt_corpus".into()); + }); + prj.add_test( + "InvariantOptimize.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract PeakHandler { + int256 public value; + + // Value peaks at call 3, then drops. + function step() external { + value++; + if (value > 3) { + value = -100; + } + } +} + +contract InvariantOptimizeTest is Test { + PeakHandler handler; + + function setUp() public { + handler = new PeakHandler(); + targetContract(address(handler)); + } + + /// forge-config: default.invariant.runs = 1 + /// forge-config: default.invariant.depth = 5 + /// forge-config: default.invariant.check_interval = 0 + function invariant_optimize_peak() public view returns (int256) { + return handler.value(); + } +} +"#, + ); + + // Run 1: best should be 3 (prefix len 3), NOT -100 (depth 5). + // Validates check_interval=0 doesn't skip optimization sampling. + cmd.args(["test", "-vvv"]).assert_success().stdout_eq(str![[r#" +... +[PASS] + [Best sequence] [..] +[..]calldata=step()[..] +[..]calldata=step()[..] +[..]calldata=step()[..] + invariant_optimize_peak() (best: 3, runs: 1, calls: 5) +... +"#]]); + + // Run 2: persisted best should survive across runs. + cmd.forge_fuse().args(["test", "-vvv"]).assert_success().stdout_eq(str![[r#" +... + invariant_optimize_peak() (best: 3, runs: 1, calls: 5) +... +"#]]); +} + +#[forgetest_init] +fn invariant_optimization_discards_stale_persisted_best(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 3; + config.invariant.corpus.corpus_dir = Some("opt_corpus".into()); + }); + let source = r#" +import {Test} from "forge-std/Test.sol"; + +contract OptimizationHandler { + int256 public value; + + function step() external { + value += 1; + } +} + +contract OptimizationTest is Test { + OptimizationHandler handler; + + function setUp() public { + handler = new OptimizationHandler(); + targetContract(address(handler)); + } + + function invariant_optimize_value() public view returns (int256) { + return handler.value(); + } +} +"#; + prj.add_test("OptimizationTest.t.sol", source); + cmd.args(["test"]).assert_success().stdout_eq(str![[r#" +... +[PASS] +... + invariant_optimize_value() (best: 3, runs: 1, calls: 3) +... +"#]]); + + prj.add_test("OptimizationTest.t.sol", &source.replace("value += 1;", "value -= 1;")); + cmd.forge_fuse().args(["test"]).assert_success().stdout_eq(str![[r#" +... +[PASS] +... + invariant_optimize_value() (best: -1, runs: 1, calls: 3) +... +"#]]); +} + +#[forgetest_init] +fn invariant_zero_delays_are_disabled(prj: _, cmd: _) { + prj.add_test( + "InvariantZeroDelay.t.sol", + r#" +contract InvariantZeroDelay { + Target target; + + function setUp() public { + target = new Target(); + } + + /// forge-config: default.invariant.runs = 1 + /// forge-config: default.invariant.depth = 1 + /// forge-config: default.invariant.max_time_delay = 0 + function invariant_zeroTimeDelay() public pure {} + + /// forge-config: default.invariant.runs = 1 + /// forge-config: default.invariant.depth = 1 + /// forge-config: default.invariant.max_block_delay = 0 + function invariant_zeroBlockDelay() public pure {} +} + +contract Target { + function touch() public {} +} +"#, + ); + + cmd.args(["test", "--mt", "invariant_zeroTimeDelay"]).assert_success().stdout_eq(str![[r#" +... +Ran 1 test for test/InvariantZeroDelay.t.sol:InvariantZeroDelay +[PASS] invariant_zeroTimeDelay() (runs: 1, calls: 1, reverts: 0) + +╭----------+----------+-------+---------+----------╮ +| Contract | Selector | Calls | Reverts | Discards | ++==================================================+ +| Target | touch | 1 | 0 | 0 | +╰----------+----------+-------+---------+----------╯ + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); + cmd.forge_fuse().args(["test", "--mt", "invariant_zeroBlockDelay"]).assert_success().stdout_eq( + str![[r#" +... +Ran 1 test for test/InvariantZeroDelay.t.sol:InvariantZeroDelay +[PASS] invariant_zeroBlockDelay() (runs: 1, calls: 1, reverts: 0) + +╭----------+----------+-------+---------+----------╮ +| Contract | Selector | Calls | Reverts | Discards | ++==================================================+ +| Target | touch | 1 | 0 | 0 | +╰----------+----------+-------+---------+----------╯ + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]], + ); +} + +// Test optimization mode with time-dependent logic using warp and fixed seed for reproducibility. +// This test ensures warp values are correctly accumulated during shrinking. +#[forgetest_init] +fn invariant_optimization_with_warp(prj: _, cmd: _) { + prj.add_test( + "InvariantOptimizeWarp.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract InvariantOptimizeWarpTest is Test { + int256 public maxValue; + + function setUp() public { + targetContract(address(this)); + } + + // Simulates time-dependent pricing with warp. Higher timestamp = higher value. + function updateValue(uint256 multiplier) public { + if (multiplier == 0 || multiplier > 100) revert(); + // Value depends on block.timestamp which can be warped + int256 newValue = int256(block.timestamp * multiplier / 1000); + if (newValue > maxValue) { + maxValue = newValue; + } + } + + /// forge-config: default.invariant.runs = 10 + /// forge-config: default.invariant.depth = 15 + /// forge-config: default.invariant.max_time_delay = 604800 + function invariant_optimize_max_value() public view returns (int256) { + return maxValue; + } +} +"#, + ); + + // Use fixed seed for deterministic output. The optimizer finds sequences that + // maximize value through time manipulation (warp). Shrinking reduces to 1 call. + cmd.args(["test", "-vvv", "--fuzz-seed", "12345"]).assert_success().stdout_eq(str![[r#" +... +[PASS] + [Best sequence] [..] +[..]calldata=updateValue(uint256) args=[100] + invariant_optimize_max_value() (best: [..], runs: 10, calls: 150) +... +"#]]); +} + +// Regression test for delay-aware shrinking in check mode. +// Removed calls may still contribute warp/roll, so the final shrunk sequence must preserve those +// values in the remaining call. +#[forgetest_init] +fn invariant_shrink_preserves_warp_roll(prj: _, cmd: _) { + prj.add_test( + "InvariantRollWarpShrink.t.sol", + r#" +import "forge-std/Test.sol"; + +contract Roll { + uint256 public number; + + function increment() public { + require(block.number > 50000, "wrong block"); + number++; + } +} + +contract Warp { + uint256 public number; + + function increment() public { + require(block.timestamp > 500000, "wrong timestamp"); + number++; + } +} + +contract InvariantRoll is Test { + Roll public roll; + + function setUp() public { + roll = new Roll(); + } + + /// forge-config: default.fuzz.seed = "119" + /// forge-config: default.invariant.max_block_delay = 60480 + /// forge-config: default.invariant.show_solidity = true + function invariant_roll() public view { + require(roll.number() == 0, "number is not zero"); + } +} + +contract InvariantWarp is Test { + Warp public warp; + + function setUp() public { + warp = new Warp(); + } + + /// forge-config: default.fuzz.seed = "119" + /// forge-config: default.invariant.max_time_delay = 604800 + /// forge-config: default.invariant.show_solidity = true + function invariant_warp() public view { + require(warp.number() == 0, "max time"); + } +} +"#, + ); + + cmd.args(["test", "--mt", "invariant_roll"]).assert_failure().stdout_eq(str![[r#" +... +[FAIL: number is not zero] + [Sequence] (original: 3, shrunk: 1) + vm.roll(block.number + [..]); + vm.prank([..]); + Roll(0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f).increment(); + invariant_roll() (runs: 1, calls: 3, reverts: 2) +... + +"#]]); + + cmd.forge_fuse().args(["test", "--mt", "invariant_warp"]).assert_failure().stdout_eq(str![[ + r#" +... +[FAIL: max time] + [Sequence] (original: [..], shrunk: 1) + vm.warp(block.timestamp + [..]); + vm.prank([..]); + Warp(0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f).increment(); + invariant_warp() (runs: 1, calls: [..], reverts: [..]) +... + +"# + ]]); +} + +// Test that invariant fuzzer generates random msg.value for payable functions. +// Based on the example from https://github.com/foundry-rs/foundry/pull/8644 +#[forgetest_init] +fn invariant_msg_value(prj: _, cmd: _) { + prj.update_config(|config| { + config.fuzz.seed = Some(U256::from(42u32)); + config.invariant.runs = 200; + config.invariant.depth = 20; + }); + + prj.add_test( + "InvariantMsgValue.t.sol", + r#" +import "forge-std/Test.sol"; + +contract ValueTarget { + bool public valueReceived; + + // Payable function that tracks if any value was received + function deposit() external payable { + if (msg.value > 0) { + valueReceived = true; + } + } +} + +contract InvariantMsgValue is Test { + ValueTarget target; + address sender1; + address sender2; + + function setUp() public { + target = new ValueTarget(); + // Create and fund specific senders + sender1 = makeAddr("sender1"); + sender2 = makeAddr("sender2"); + vm.deal(sender1, 1000 ether); + vm.deal(sender2, 1000 ether); + // Target only these funded senders + targetSender(sender1); + targetSender(sender2); + // Target only the ValueTarget contract + targetContract(address(target)); + } + + function invariant_value_never_received() public view { + require(!target.valueReceived(), "Value was received"); + } +} +"#, + ); + + // The knob should be able to disable payable value generation completely. + cmd.forge_fuse() + .args([ + "test", + "--mt", + "invariant_value_never_received", + "--invariant-payable-value-weight", + "0", + ]) + .assert_success(); + cmd.forge_fuse().arg("clean").assert_success(); + + // The default invariant weight should fail because the fuzzer generates msg.value > 0 for + // payable functions. + // First check regular output format shows value=X + cmd.forge_fuse() + .args(["test", "--mt", "invariant_value_never_received"]) + .assert_failure() + .stdout_eq(str![[r#" +... +[FAIL: Value was received] + [Sequence] (original: [..], shrunk: 1) + sender=[..] addr=[test/InvariantMsgValue.t.sol:ValueTarget][..] value=[..] calldata=deposit() args=[] +... +"#]]); + + // Now check solidity output format shows proper {value: X} syntax + cmd.forge_fuse().arg("clean").assert_success(); + prj.update_config(|config| { + config.invariant.show_solidity = true; + }); + cmd.forge_fuse() + .args(["test", "--mt", "invariant_value_never_received"]) + .assert_failure() + .stdout_eq(str![[r#" +... +[FAIL: Value was received] + [Sequence] (original: [..], shrunk: 1) + vm.prank([..]); + ValueTarget([..]).deposit{value: [..]}(); +... +"#]]); +} + +#[forgetest_init] +fn invariant_call_override_skips_storage_hook_callbacks(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 16; + config.invariant.depth = 8; + config.invariant.call_override = true; + config.invariant.fail_on_revert = true; + }); + + prj.add_test( + "InvariantStorageHooks.t.sol", + r#" +import "forge-std/Test.sol"; + +interface StorageHookVm { + function registerSstoreHook(address target, bytes4 callback) external; +} + +contract StorageHookTarget { + uint256 public value; + + function store(uint256 newValue) external { + value = newValue; + } +} + +contract StorageHookHelper { + function copy(uint256 value) external pure returns (uint256) { + if (value == 7) { + return 7; + } + return value; + } +} + +contract StorageHookHandler { + StorageHookVm constant hookVm = + StorageHookVm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + StorageHookTarget public immutable target; + StorageHookHelper public immutable helper; + uint256 public ghostValue; + + constructor(StorageHookTarget target_) { + target = target_; + helper = new StorageHookHelper(); + hookVm.registerSstoreHook(address(target_), StorageHookHandler.onStore.selector); + } + + function mutate(uint256 newValue) external { + target.store(newValue); + } + + function onStore(address, bytes32, bytes32, bytes32 newValue) external { + require(msg.sender == address(hookVm), "only storage hook"); + ghostValue = helper.copy(uint256(newValue)); + } +} + +contract InvariantStorageHooks is Test { + StorageHookTarget target; + StorageHookHandler handler; + + function setUp() public { + target = new StorageHookTarget(); + handler = new StorageHookHandler(target); + } + + function invariant_hook_tracks_target() public view { + assertEq(handler.ghostValue(), target.value()); + } +} +"#, + ); + + assert_invariant(cmd.args(["test"])).success(); +} + +#[forgetest_init] +fn invariant_test_trace_seed_preserves_time_advances(prj: _, cmd: _) { + prj.update_config(|config| { + config.evm_version = EvmVersion::Prague; + config.isolate = true; + config.invariant.runs = 1; + config.invariant.depth = 2; + config.invariant.shrink_run_limit = 0; + config.invariant.workers = + foundry_config::InvariantWorkers::Fixed(std::num::NonZeroUsize::new(1).unwrap()); + config.invariant.corpus.corpus_dir = Some("invariant_corpus".into()); + config.invariant.corpus.mutation_weights = foundry_config::FuzzCorpusMutationWeights { + mutation_weight_splice: 0, + mutation_weight_repeat: 0, + mutation_weight_interleave: 0, + mutation_weight_prefix: 0, + mutation_weight_suffix: 0, + mutation_weight_abi: 1, + mutation_weight_cmp: 0, + }; + }); + + prj.add_test( + "TimedTraceSeed.t.sol", + r#" +import "forge-std/Test.sol"; + +contract TimedHandler { + uint256 immutable expectedOpenTime; + uint256 immutable expectedOpenBlock; + bool opened; + bool public finished; + + constructor(uint256 expectedOpenTime_, uint256 expectedOpenBlock_) { + expectedOpenTime = expectedOpenTime_; + expectedOpenBlock = expectedOpenBlock_; + } + + function open() external { + opened = block.timestamp == expectedOpenTime && block.number == expectedOpenBlock; + } + + function finish() external { + if ( + opened && block.timestamp == expectedOpenTime + 1 days + && block.number == expectedOpenBlock + 5 + ) finished = true; + } +} + +contract TimedTraceSeedTest is Test { + TimedHandler handler; + + function setUp() public { + vm.setEvmVersion("cancun"); + vm.warp(block.timestamp + 30 days); + vm.roll(block.number + 100); + handler = new TimedHandler(block.timestamp, block.number); + targetContract(address(handler)); + } + + function test_seedTimedLifecycle() public { + handler.open(); + vm.warp(block.timestamp + 1 days); + vm.roll(block.number + 5); + handler.finish(); + } + + function invariant_notFinished() public view { + assertFalse(handler.finished()); + } +} +"#, + ); + + assert_invariant(cmd.args([ + "test", + "--match-test", + "invariant_notFinished", + "--fuzz-seed", + "0x1", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/TimedTraceSeed.t.sol:TimedTraceSeedTest +[FAIL: assertion failed] + [SEQUENCE] + invariant_notFinished() ([RUNS]) + +[STATS] + +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/TimedTraceSeed.t.sol:TimedTraceSeedTest +[FAIL: assertion failed] + [SEQUENCE] + invariant_notFinished() ([RUNS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]]); +} + +#[forgetest_init] +fn invariant_test_trace_seed_skips_prague_rolls(prj: _, cmd: _) { + prj.update_config(|config| { + config.evm_version = EvmVersion::Prague; + config.isolate = true; + config.invariant.runs = 1; + config.invariant.depth = 1; + config.invariant.fail_on_revert = true; + config.invariant.workers = + foundry_config::InvariantWorkers::Fixed(std::num::NonZeroUsize::new(1).unwrap()); + config.invariant.corpus.corpus_dir = Some("invariant_corpus".into()); + config.invariant.corpus.mutation_weights = foundry_config::FuzzCorpusMutationWeights { + mutation_weight_splice: 0, + mutation_weight_repeat: 0, + mutation_weight_interleave: 0, + mutation_weight_prefix: 0, + mutation_weight_suffix: 0, + mutation_weight_abi: 1, + mutation_weight_cmp: 0, + }; + }); + + prj.add_test( + "HistoryTraceSeed.t.sol", + r#" +import "forge-std/Test.sol"; + +contract HistoryHandler { + uint256 public checks; + + function checkHistory() external { + checks++; + if (block.number != 1001) return; + (bool ok, bytes memory result) = + address(0x0000F90827F1C53a10cb7A02335B175320002935).staticcall(abi.encode(uint256(1000))); + require(ok && abi.decode(result, (bytes32)) == bytes32(uint256(42)), "missing history"); + } +} + +contract HistoryTraceSeedTest is Test { + HistoryHandler handler; + + function setUp() public { + vm.roll(1000); + vm.setBlockhash(1000, bytes32(uint256(42))); + handler = new HistoryHandler(); + targetContract(address(handler)); + } + + function test_seedHistory() public { + vm.roll(1001); + handler.checkHistory(); + } + + function invariant_history() public pure {} +} +"#, + ); + + assert_invariant(cmd.args(["test", "--fuzz-seed", "0x1"])).success().stdout_eq(str![[r#" +... +Ran 2 tests for test/HistoryTraceSeed.t.sol:HistoryTraceSeedTest +[PASS] invariant_history() ([RUNS]) + +[STATS] + +[PASS] test_seedHistory() ([GAS]) +Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 2 tests passed, 0 failed, 0 skipped (2 total tests) + +"#]]); +} + +// Handler calls are committed within a run, while invariant predicate writes are discarded and +// each run starts again from the post-setup state. +#[forgetest] +fn invariant_checks_do_not_commit_state(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 4; + config.invariant.depth = 4; + config.invariant.fail_on_revert = true; + }); + + prj.add_test( + "InvariantCheckState.t.sol", + r#" +struct FuzzSelector { + address addr; + bytes4[] selectors; +} + +contract Handler { + uint256 public count; + bool public poisoned; + + function step() external { + require(!poisoned, "predicate write leaked"); + require(count < 4, "run baseline leaked"); + count++; + } + + function poison() external { + poisoned = true; + } +} + +contract InvariantCheckStateTest { + Handler handler; + + function setUp() public { + handler = new Handler(); + } + + function targetSelectors() public view returns (FuzzSelector[] memory targets) { + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = handler.step.selector; + targets = new FuzzSelector[](1); + targets[0] = FuzzSelector(address(handler), selectors); + } + + function invariant_poison() public { + handler.poison(); + } + + function afterInvariant() public view { + require(handler.count() == 4, "handler state not retained"); + require(!handler.poisoned(), "predicate write leaked at end"); + } +} +"#, + ); + + cmd.args(["test"]).assert_success().stdout_eq(str![[r#" +... +Ran 1 test for test/InvariantCheckState.t.sol:InvariantCheckStateTest +[PASS] invariant_poison() (runs: 4, calls: 16, reverts: 0) + +╭----------+----------+-------+---------+----------╮ +| Contract | Selector | Calls | Reverts | Discards | ++==================================================+ +| Handler | step | 16 | 0 | 0 | +╰----------+----------+-------+---------+----------╯ + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +} diff --git a/crates/forge/tests/cli/test_cmd/invariant/handler.rs b/crates/forge/tests/cli/test_cmd/invariant/handler.rs new file mode 100644 index 0000000000000..0fff414adeeaa --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/invariant/handler.rs @@ -0,0 +1,1144 @@ +//! E2E tests for handler-side assertion bugs: dedup by `(reverter, selector)` site, +//! persistence under `failures//handlers/.json`, +//! replay/shrink semantics, and stale-file cleanup. Distinct from invariant predicate +//! failures. + +use alloy_primitives::address; +use foundry_test_utils::{forgetest_init, str}; + +// Handler `assert(false)` surfaces under `Assertion Tests:`, not as a live invariant failure. +#[forgetest_init] +fn handler_assertion_routed_to_handler_section(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 10; + config.invariant.fail_on_revert = false; + }); + prj.add_source( + "AssertHandler.sol", + r#" +contract AssertHandler { + uint256 public calls; + + function alwaysAssert() external { + calls++; + assert(false); + } +} + "#, + ); + prj.add_test( + "AssertAllAssertTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {AssertHandler} from "../src/AssertHandler.sol"; + +contract AssertAllAssertTest is Test { + AssertHandler handler; + + function setUp() public { + handler = new AssertHandler(); + targetContract(address(handler)); + } + + function invariant_a() public view {} + + function invariant_b() public view {} +} + "#, + ); + + let output = cmd.args(["test", "--mt", "invariant_a"]).assert_failure(); + let stdout = String::from_utf8_lossy(&output.get_output().stdout); + assert!(stdout.contains("Ran 1 test for test/AssertAllAssertTest.t.sol:AssertAllAssertTest")); + assert!(stdout.contains("Assertion Tests: 1 assertion bug(s) found"), "{stdout}"); + assert!( + stdout.contains( + "[FAIL: panic: assertion failed (0x01)] src/AssertHandler.sol:AssertHandler::alwaysAssert" + ), + "{stdout}" + ); + assert!(stdout.contains(" invariant_a() (runs: 1, calls:"), "{stdout}"); + assert!(!stdout.contains("Invariant/Property Tests:"), "{stdout}"); + assert!(!stdout.contains("invariant_b"), "{stdout}"); +} + +// Site-granular dedup: 4 distinct paths through one selector all share the same +// `(reverter, selector)` site → 1 persisted bug, shrunk to its anchor. Echidna/Medusa +// semantics: one bug per `(handler, function)`, regardless of which code path reached it. +// If the handler is patched to no longer assert, the persisted file is deleted on replay. +#[forgetest_init] +fn handler_assertion_dedupes_by_site(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 200; + config.invariant.fail_on_revert = false; + config.invariant.corpus.corpus_dir = Some("inv_corpus".into()); + }); + prj.add_source( + "MultiPathHandler.sol", + r#" +contract MultiPathHandler { + uint256 public state; + + // Filler so fuzzing produces a prefix to shrink. + function noop() external {} + + // Four branches, one selector — all asserting via the same `(reverter, selector)` + // site. Under site-granular dedup these collapse into a single persisted bug + // (Echidna/Medusa semantics). + function maybeAssert(uint8 path) external { + if (path < 64) { + state = 1; + assert(false); + } else if (path < 128) { + state = 2; + assert(false); + } else if (path < 192) { + state = 3; + assert(false); + } else { + state = 4; + assert(false); + } + } +} + "#, + ); + prj.add_test( + "MultiPathTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {MultiPathHandler} from "../src/MultiPathHandler.sol"; + +contract MultiPathTest is Test { + MultiPathHandler handler; + + function setUp() public { + handler = new MultiPathHandler(); + targetContract(address(handler)); + } + + function invariant_ok() public view {} +} + "#, + ); + + cmd.args(["test", "--mt", "invariant_ok", "--fuzz-seed", "119"]).assert_failure().stdout_eq( + str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/MultiPathTest.t.sol:MultiPathTest +Assertion Tests: 1 assertion bug(s) found +[FAIL: panic: assertion failed (0x01)] src/MultiPathHandler.sol:MultiPathHandler::maybeAssert + [Sequence] (original: [..], shrunk: 1) + sender=[..] addr=[src/MultiPathHandler.sol:MultiPathHandler]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=maybeAssert(uint8) args=[..] + invariant_ok() (runs: 1, calls: 200, reverts: [..]) + +╭------------------+-------------+-------+---------+----------╮ +| Contract | Selector | Calls | Reverts | Discards | ++=============================================================+ +| MultiPathHandler | maybeAssert | [..] | [..] | 0 | +|------------------+-------------+-------+---------+----------| +| MultiPathHandler | noop | [..] | 0 | 0 | +╰------------------+-------------+-------+---------+----------╯ + +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/MultiPathTest.t.sol:MultiPathTest +Assertion Tests: 1 assertion bug(s) found +[FAIL: panic: assertion failed (0x01)] src/MultiPathHandler.sol:MultiPathHandler::maybeAssert + [Sequence] (original: [..], shrunk: 1) + sender=[..] addr=[src/MultiPathHandler.sol:MultiPathHandler]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=maybeAssert(uint8) args=[..] + invariant_ok() (runs: 1, calls: 200, reverts: [..]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]], + ); + + let handlers_dir = prj + .root() + .join("cache") + .join("invariant") + .join("failures") + .join("MultiPathTest") + .join("handlers"); + let before: Vec<_> = std::fs::read_dir(&handlers_dir) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().is_some_and(|x| x == "json")) + .collect(); + assert_eq!( + before.len(), + 1, + "expected 1 persisted handler bug after site-granular dedup, got {before:?}", + ); + + // Stale-file deletion: patch the handler so `maybeAssert` no longer asserts. On the + // next replay the persisted reproducer's anchor stops asserting → the stale file is + // deleted in place. + prj.add_source( + "MultiPathHandler.sol", + r#" +contract MultiPathHandler { + uint256 public state; + function noop() external {} + function maybeAssert(uint8 path) external { state = uint256(path) % 4 + 1; } +} + "#, + ); + prj.update_config(|config| { + config.invariant.runs = 0; + }); + // With `runs=0` no new fuzzing happens; the only work is replaying the persisted + // reproducer, which now no longer asserts → file deleted → no handler bug → success. + cmd.forge_fuse().args(["test", "--mt", "invariant_ok", "--fuzz-seed", "119"]).assert_success(); + + let after: Vec<_> = std::fs::read_dir(&handlers_dir) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().is_some_and(|x| x == "json")) + .collect(); + assert!(after.is_empty(), "stale handler-bug file should be deleted, got {after:?}"); +} + +// Bug persists to `failures//handlers/.json`, replays from disk, +// and is deleted when stale (handler patched to no-op, or anchor stops asserting while the +// invariant breaks instead). +#[forgetest_init] +fn handler_assertion_persisted_to_disk(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 10; + config.invariant.fail_on_revert = false; + config.invariant.corpus.corpus_dir = Some("inv_corpus".into()); + config.invariant.corpus.evm_edge_coverage_collision_free = false; + config.invariant.corpus.evm_edge_coverage_include_call_depth = true; + }); + prj.add_source( + "AlwaysAssert.sol", + r#" +contract AlwaysAssert { + function ping() external pure {} + function boom() external { this.ping(); assert(false); } +} + "#, + ); + prj.add_test( + "AlwaysAssertTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {AlwaysAssert} from "../src/AlwaysAssert.sol"; + +contract AlwaysAssertTest is Test { + AlwaysAssert h; + function setUp() public { h = new AlwaysAssert(); targetContract(address(h)); } + function invariant_ok() public view {} +} + "#, + ); + + cmd.args(["test", "--mt", "invariant_ok"]).assert_failure(); + + let handlers_dir = prj + .root() + .join("cache") + .join("invariant") + .join("failures") + .join("AlwaysAssertTest") + .join("handlers"); + assert!(handlers_dir.exists(), "handlers dir not created: {handlers_dir:?}"); + let entries: Vec<_> = std::fs::read_dir(&handlers_dir) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().is_some_and(|x| x == "json")) + .collect(); + assert!(!entries.is_empty(), "no handler failure files written"); + + // Re-run with `runs = 0` so the campaign cannot rediscover the bug; the failure must + // come from replaying the persisted file. Then prove the stale-file deletion path by + // pointing the test at a new contract (different selector → no match) and confirming + // the orphaned file is removed. + prj.update_config(|config| { + config.invariant.runs = 0; + config.invariant.corpus.corpus_dir = None; + config.invariant.corpus.evm_edge_coverage_collision_free = true; + config.invariant.corpus.evm_edge_coverage_include_call_depth = false; + }); + cmd.forge_fuse().args(["fuzz", "replay", "--mt", "invariant_ok"]).assert_failure().stderr_eq( + str![[r#" +... +Warning: Replayed handler-side assertion bug from [..] +... +"#]], + ); + + cmd.forge_fuse().args(["fuzz", "replay", "--mt", "invariant_ok"]).assert_failure(); + + // Sanity check: persisted file is still there after a successful replay. + let entries_after: Vec<_> = std::fs::read_dir(&handlers_dir) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().is_some_and(|x| x == "json")) + .collect(); + assert_eq!(entries_after.len(), entries.len(), "replayed file should be preserved"); + + // Legacy edge fingerprints lack capture provenance and may be impossible to reproduce. A + // handler-site match retains the artifact instead of deleting or silently migrating it. + let legacy_path = entries_after[0].path(); + let mut legacy: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&legacy_path).unwrap()).unwrap(); + legacy.as_object_mut().unwrap().remove("fingerprint_provenance"); + legacy["failure_site"]["fingerprint"] = + serde_json::Value::String(format!("0x{}", "11".repeat(32))); + std::fs::write(&legacy_path, serde_json::to_vec_pretty(&legacy).unwrap()).unwrap(); + + cmd.forge_fuse().args(["fuzz", "replay", "--mt", "invariant_ok"]).assert_failure(); + let retained: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&legacy_path).unwrap()).unwrap(); + assert_eq!(retained["failure_site"]["fingerprint"], legacy["failure_site"]["fingerprint"]); + assert!(retained.get("fingerprint_provenance").is_none()); + + // Replace the asserting handler with a no-op so the persisted sequence no longer + // reproduces. The replay step must delete the stale file in place. + prj.add_source( + "AlwaysAssert.sol", + r#" +contract AlwaysAssert { + function boom() external {} +} + "#, + ); + cmd.forge_fuse().args(["test", "--mt", "invariant_ok"]).assert_success(); + let entries_stale: Vec<_> = std::fs::read_dir(&handlers_dir) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().is_some_and(|x| x == "json")) + .collect(); + assert!(entries_stale.is_empty(), "stale handler failure file should be deleted"); + + // A stale handler file co-existing with a primary invariant failure must NOT be + // mis-identified as the handler bug that's still reproducing. Re-persist a handler bug + // first, then patch the handler to no-op AND make `invariant_ok` assert. Replay must + // delete the (now stale) handler file and surface the failure as a primary invariant + // failure, not under `Assertion Tests:`. + prj.add_source( + "AlwaysAssert.sol", + r#" +contract AlwaysAssert { + function ping() external pure {} + function boom() external { this.ping(); assert(false); } +} + "#, + ); + prj.update_config(|config| { + config.invariant.runs = 1; + }); + cmd.forge_fuse().args(["test", "--mt", "invariant_ok"]).assert_failure(); + assert!(handlers_dir.read_dir().unwrap().next().is_some(), "handler bug should re-persist"); + + prj.add_source( + "AlwaysAssert.sol", + r#" +contract AlwaysAssert { + function boom() external {} +} + "#, + ); + prj.add_test( + "AlwaysAssertTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {AlwaysAssert} from "../src/AlwaysAssert.sol"; + +contract AlwaysAssertTest is Test { + AlwaysAssert h; + function setUp() public { h = new AlwaysAssert(); targetContract(address(h)); } + function invariant_ok() public { assert(false); } +} + "#, + ); + prj.update_config(|config| { + config.invariant.runs = 0; + }); + cmd.forge_fuse().args(["test", "--mt", "invariant_ok"]).assert_failure().stdout_eq(str![[r#" +... +[FAIL: panic: assertion failed (0x01)] invariant_ok()[..] +... +"#]]); + let entries_after: Vec<_> = std::fs::read_dir(&handlers_dir) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().is_some_and(|x| x == "json")) + .collect(); + assert!(entries_after.is_empty(), "stale handler file must be deleted, got {entries_after:?}"); +} + +#[forgetest_init] +fn nested_handler_assertion_replays_from_disk(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 30; + config.invariant.fail_on_revert = false; + }); + prj.add_source( + "NestedAssertion.sol", + r#" +pragma solidity ^0.8.20; + +contract NestedAssertion { + function boom() external pure { assert(false); } +} + +contract OuterHandler { + bool primed; + NestedAssertion nested = new NestedAssertion(); + function prime() external { primed = true; } + function noop() external {} + function step() external { if (primed) nested.boom(); } +} + "#, + ); + prj.add_test( + "NestedAssertionTest.t.sol", + r#" +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {OuterHandler} from "../src/NestedAssertion.sol"; + +contract NestedAssertionTest is Test { + OuterHandler handler; + function setUp() public { handler = new OuterHandler(); targetContract(address(handler)); } + function invariant_ok() public view {} +} + "#, + ); + + let output = cmd + .args(["test", "--mt", "invariant_ok", "--fuzz-seed", "2"]) + .assert_failure() + .get_output() + .clone(); + let stdout = String::from_utf8_lossy(&output.stdout); + let original = stdout + .split_once("[Sequence] (original: ") + .unwrap_or_else(|| panic!("{stdout}\n{}", String::from_utf8_lossy(&output.stderr))) + .1 + .split_once(',') + .unwrap() + .0 + .parse::() + .unwrap(); + assert!(original > 2, "expected a removable call: {stdout}"); + + let handlers_dir = prj + .root() + .join("cache") + .join("invariant") + .join("failures") + .join("NestedAssertionTest") + .join("handlers"); + let file = std::fs::read_dir(&handlers_dir) + .unwrap() + .filter_map(|entry| entry.ok()) + .find(|entry| entry.path().extension().is_some_and(|extension| extension == "json")) + .expect("persisted nested handler failure"); + let persisted: serde_json::Value = + serde_json::from_reader(std::fs::File::open(file.path()).unwrap()).unwrap(); + assert_eq!(persisted["call_sequence"].as_array().unwrap().len(), 2); + + prj.update_config(|config| { + config.invariant.runs = 0; + }); + cmd.forge_fuse().args(["test", "--mt", "invariant_ok"]).assert_failure().stderr_eq(str![[r#" +... +Warning: Replayed handler-side assertion bug from [..] +... +"#]]); + assert!(file.path().exists(), "replayed nested handler failure should be preserved"); +} + +#[forgetest_init] +fn handler_vm_assert_replays_from_disk(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 1; + config.invariant.fail_on_revert = false; + }); + prj.add_test( + "HandlerVmAssertTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract VmAssertHandler is Test { + function step() external { vm.assertEq(uint256(1), uint256(2)); } +} + +contract HandlerVmAssertTest is Test { + VmAssertHandler handler; + function setUp() public { handler = new VmAssertHandler(); targetContract(address(handler)); } + function invariant_ok() public view {} +} + "#, + ); + + cmd.args(["test", "--mt", "invariant_ok"]).assert_failure(); + + let handlers_dir = prj + .root() + .join("cache") + .join("invariant") + .join("failures") + .join("HandlerVmAssertTest") + .join("handlers"); + let file = std::fs::read_dir(&handlers_dir) + .unwrap() + .filter_map(|entry| entry.ok()) + .find(|entry| entry.path().extension().is_some_and(|extension| extension == "json")) + .expect("persisted vm.assert handler failure"); + + prj.update_config(|config| { + config.invariant.runs = 0; + }); + cmd.forge_fuse().args(["test", "--mt", "invariant_ok"]).assert_failure().stderr_eq(str![[r#" +... +Warning: Replayed handler-side assertion bug from [..] +... +"#]]); + assert!(file.path().exists(), "replayed vm.assert handler failure should be preserved"); + + // Simulate a symbolic handler artifact produced before handler identities were canonicalized: + // it stored the cheatcode reverter rather than the top-level handler target. Migration keeps + // the legacy record until normal result persistence has written its canonical replacement. + let mut legacy: serde_json::Value = + serde_json::from_reader(std::fs::File::open(file.path()).unwrap()).unwrap(); + let legacy_reverter = address!("0x7109709ecfa91a80626ff3989d68f67f5b1dd12d"); + let selector: alloy_primitives::Selector = + legacy["failure_site"]["selector"].as_str().unwrap().parse().unwrap(); + let mut identity = [0u8; 24]; + identity[..20].copy_from_slice(legacy_reverter.as_slice()); + identity[20..].copy_from_slice(selector.as_slice()); + legacy["failure_site"]["target"] = serde_json::Value::String(legacy_reverter.to_string()); + legacy["failure_site"]["fingerprint"] = + serde_json::Value::String(format!("{:#x}", alloy_primitives::keccak256(identity))); + let legacy_path = handlers_dir.join("legacy.json"); + std::fs::write(&legacy_path, serde_json::to_vec_pretty(&legacy).unwrap()).unwrap(); + std::fs::remove_file(file.path()).unwrap(); + + cmd.forge_fuse().args(["test", "--mt", "invariant_ok"]).assert_failure(); + assert!(legacy_path.exists(), "legacy failure must remain until canonical persistence"); + assert_eq!(handlers_dir.read_dir().unwrap().count(), 2); + + // A malformed file at the canonical path is not proof that migration completed. Replay repairs + // it from the legacy record but retains that record until the canonical copy itself replays. + let canonical_path = handlers_dir + .read_dir() + .unwrap() + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path()) + .find(|path| path != &legacy_path) + .expect("canonical handler failure"); + std::fs::write(&canonical_path, b"{").unwrap(); + cmd.forge_fuse().args(["test", "--mt", "invariant_ok"]).assert_failure(); + assert!(legacy_path.exists(), "malformed canonical file must not authorize legacy deletion"); + + cmd.forge_fuse().args(["test", "--mt", "invariant_ok"]).assert_failure(); + assert!(!legacy_path.exists(), "legacy failure should be removed after safe migration"); + assert_eq!(handlers_dir.read_dir().unwrap().count(), 1); +} + +#[forgetest_init] +fn handler_replay_uses_full_persisted_sequence_after_depth_decrease(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 20; + config.invariant.fail_on_revert = false; + }); + prj.add_source( + "DelayedAssert.sol", + r#" +contract DelayedAssert { + uint256 public calls; + + function inc() external { + calls++; + assert(calls < 5); + } +} + "#, + ); + prj.add_test( + "DelayedAssertTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {DelayedAssert} from "../src/DelayedAssert.sol"; + +contract DelayedAssertTest is Test { + DelayedAssert handler; + + function setUp() public { + handler = new DelayedAssert(); + targetContract(address(handler)); + } + + function invariant_ok() public view {} +} + "#, + ); + + cmd.args(["test", "--mt", "invariant_ok"]).assert_failure(); + + let handlers_dir = prj + .root() + .join("cache") + .join("invariant") + .join("failures") + .join("DelayedAssertTest") + .join("handlers"); + let file = std::fs::read_dir(&handlers_dir) + .unwrap() + .filter_map(|entry| entry.ok()) + .find(|entry| entry.path().extension().is_some_and(|extension| extension == "json")) + .expect("persisted handler file"); + let json: serde_json::Value = + serde_json::from_reader(std::fs::File::open(file.path()).unwrap()).unwrap(); + assert_eq!(json["call_sequence"].as_array().unwrap().len(), 5); + + // No depth-three campaign could reach the assertion, and runs = 0 prevents rediscovery. + // The failure must therefore come from replaying all five calls in the persisted sequence. + prj.update_config(|config| { + config.invariant.runs = 0; + config.invariant.depth = 3; + }); + cmd.forge_fuse().args(["test", "--mt", "invariant_ok"]).assert_failure().stderr_eq(str![[r#" +... +Warning: Replayed handler-side assertion bug from [..] +... +"#]]); + assert!(file.path().exists(), "replayed handler file should be preserved"); +} + +// Two distinct handler contracts → two persisted files, both reported. +#[forgetest_init] +fn multi_handler_bugs_each_persist_independently(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 20; + config.invariant.fail_on_revert = false; + }); + prj.add_source( + "HandlerA.sol", + r#" +contract HandlerA { + function boomA() external { assert(false); } +} + "#, + ); + prj.add_source( + "HandlerB.sol", + r#" +contract HandlerB { + function boomB() external { assert(false); } +} + "#, + ); + prj.add_test( + "MultiHandlerTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {HandlerA} from "../src/HandlerA.sol"; +import {HandlerB} from "../src/HandlerB.sol"; + +contract MultiHandlerTest is Test { + HandlerA a; + HandlerB b; + + function setUp() public { + a = new HandlerA(); + b = new HandlerB(); + targetContract(address(a)); + targetContract(address(b)); + } + + function invariant_ok() public view {} +} + "#, + ); + + cmd.args(["test", "--mt", "invariant_ok"]).assert_failure().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful with warnings: +Warning (2018): Function state mutability can be restricted to pure + [FILE]:5:5: + | +5 | function boomA() external { assert(false); } + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Warning (2018): Function state mutability can be restricted to pure + [FILE]:5:5: + | +5 | function boomB() external { assert(false); } + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + + +Ran 1 test for test/MultiHandlerTest.t.sol:MultiHandlerTest +Assertion Tests: 2 assertion bug(s) found +[FAIL: panic: assertion failed (0x01)] src/HandlerB.sol:HandlerB::boomB + [Sequence] (original: 1, shrunk: 1) + sender=[..] addr=[src/HandlerB.sol:HandlerB]0x2e234DAe75C793f67A35089C9d99245E1C58470b calldata=boomB() args=[] +[FAIL: panic: assertion failed (0x01)] src/HandlerA.sol:HandlerA::boomA + [Sequence] (original: 1, shrunk: 1) + sender=[..] addr=[src/HandlerA.sol:HandlerA]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=boomA() args=[] + invariant_ok() (runs: 1, calls: 20, reverts: 20) + +╭----------+----------+-------+---------+----------╮ +| Contract | Selector | Calls | Reverts | Discards | ++==================================================+ +| HandlerA | boomA | [..] | [..] | 0 | +|----------+----------+-------+---------+----------| +| HandlerB | boomB | [..] | [..] | 0 | +╰----------+----------+-------+---------+----------╯ + +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/MultiHandlerTest.t.sol:MultiHandlerTest +Assertion Tests: 2 assertion bug(s) found +[FAIL: panic: assertion failed (0x01)] src/HandlerB.sol:HandlerB::boomB + [Sequence] (original: 1, shrunk: 1) + sender=[..] addr=[src/HandlerB.sol:HandlerB]0x2e234DAe75C793f67A35089C9d99245E1C58470b calldata=boomB() args=[] +[FAIL: panic: assertion failed (0x01)] src/HandlerA.sol:HandlerA::boomA + [Sequence] (original: 1, shrunk: 1) + sender=[..] addr=[src/HandlerA.sol:HandlerA]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=boomA() args=[] + invariant_ok() (runs: 1, calls: 20, reverts: 20) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]]); + + let handlers_dir = prj + .root() + .join("cache") + .join("invariant") + .join("failures") + .join("MultiHandlerTest") + .join("handlers"); + let entries: Vec<_> = std::fs::read_dir(&handlers_dir) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().is_some_and(|x| x == "json")) + .collect(); + assert_eq!(entries.len(), 2, "expected one persisted file per handler bug"); +} + +// Persisted file holds the post-shrink sequence; replay renders `(original: M, shrunk: M)` +// instead of regrowing. +#[forgetest_init] +fn handler_bug_replay_is_idempotent_after_shrink(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 50; + config.invariant.fail_on_revert = false; + }); + prj.add_source( + "ShrinkableHandler.sol", + r#" +contract ShrinkableHandler { + // Filler so the campaign produces a prefix that has to be shrunk away. + function noop() external {} + function boom() external { assert(false); } +} + "#, + ); + prj.add_test( + "ShrinkReplayTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {ShrinkableHandler} from "../src/ShrinkableHandler.sol"; + +contract ShrinkReplayTest is Test { + ShrinkableHandler h; + function setUp() public { h = new ShrinkableHandler(); targetContract(address(h)); } + function invariant_ok() public view {} +} + "#, + ); + + cmd.args(["test", "--mt", "invariant_ok"]).assert_failure(); + + let handlers_dir = prj + .root() + .join("cache") + .join("invariant") + .join("failures") + .join("ShrinkReplayTest") + .join("handlers"); + let file = std::fs::read_dir(&handlers_dir) + .unwrap() + .filter_map(|e| e.ok()) + .find(|e| e.path().extension().is_some_and(|x| x == "json")) + .expect("persisted handler file"); + let json: serde_json::Value = + serde_json::from_reader(std::fs::File::open(file.path()).unwrap()).unwrap(); + let persisted_len = json["call_sequence"].as_array().unwrap().len(); + assert_eq!(persisted_len, 1, "persisted file must hold the shrunk (anchor-only) sequence"); + + // Re-run with runs = 0 so the failure must come from replay; assert the rendered + // (original: N, shrunk: M) shows N == M == 1, proving replay is idempotent. + prj.update_config(|config| { + config.invariant.runs = 0; + }); + cmd.forge_fuse().args(["test", "--mt", "invariant_ok"]).assert_failure().stdout_eq(str![[r#" +No files changed, compilation skipped + +Ran 1 test for test/ShrinkReplayTest.t.sol:ShrinkReplayTest +Assertion Tests: 1 assertion bug(s) found +[FAIL: panic: assertion failed (0x01)] src/ShrinkableHandler.sol:ShrinkableHandler::boom + [Sequence] (original: 1, shrunk: 1) + sender=[..] addr=[src/ShrinkableHandler.sol:ShrinkableHandler]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=boom() args=[] + invariant_ok() (runs: 0, calls: 0, reverts: 0) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/ShrinkReplayTest.t.sol:ShrinkReplayTest +Assertion Tests: 1 assertion bug(s) found +[FAIL: panic: assertion failed (0x01)] src/ShrinkableHandler.sol:ShrinkableHandler::boom + [Sequence] (original: 1, shrunk: 1) + sender=[..] addr=[src/ShrinkableHandler.sol:ShrinkableHandler]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=boom() args=[] + invariant_ok() (runs: 0, calls: 0, reverts: 0) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]]); +} + +// `InvariantSettings` change between runs → persisted file is skipped with a warning and +// left intact for a future compatible run. +#[forgetest_init] +fn handler_persisted_failure_skipped_on_settings_change(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 10; + config.invariant.fail_on_revert = false; + }); + prj.add_source( + "AlwaysAssert.sol", + r#" +contract AlwaysAssert { + function boom() external { assert(false); } +} + "#, + ); + prj.add_test( + "SettingsChangeTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {AlwaysAssert} from "../src/AlwaysAssert.sol"; + +contract SettingsChangeTest is Test { + AlwaysAssert h; + function setUp() public { h = new AlwaysAssert(); targetContract(address(h)); } + function invariant_ok() public view {} +} + "#, + ); + + cmd.args(["test", "--mt", "invariant_ok"]).assert_failure(); + + let handlers_dir = prj + .root() + .join("cache") + .join("invariant") + .join("failures") + .join("SettingsChangeTest") + .join("handlers"); + let entries_before: Vec<_> = std::fs::read_dir(&handlers_dir) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().is_some_and(|x| x == "json")) + .collect(); + assert_eq!(entries_before.len(), 1, "expected one persisted handler file"); + + // Flip a tracked InvariantSettings field (`fail_on_revert`) so the persisted file is + // incompatible. Combined with runs = 0, no campaign runs and no replay fires, so the + // test must pass. + prj.update_config(|config| { + config.invariant.fail_on_revert = true; + config.invariant.runs = 0; + }); + cmd.forge_fuse().args(["test", "--mt", "invariant_ok"]).assert_success().stderr_eq(str![[r#" +... +Warning: Failure from [..] file was ignored because invariant test settings have changed: [..] +... +"#]]); + + // Settings-mismatched files must be left intact (only stale-but-compatible files are + // deleted), so a future run with the original settings can still pick them up. + let entries_after: Vec<_> = std::fs::read_dir(&handlers_dir) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().is_some_and(|x| x == "json")) + .collect(); + assert_eq!(entries_after.len(), 1, "settings-incompatible file should be preserved"); +} + +// Pre-anchor assertion = different bug: replay must delete the file, not keep it. +#[forgetest_init] +fn handler_persisted_failure_deleted_when_earlier_call_now_asserts(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 50; + config.invariant.depth = 20; + config.invariant.fail_on_revert = false; + }); + // `boom` only asserts after `setup` was called → persisted sequence must contain both + // calls (`setup` cannot be shrunk away without losing the assertion). + prj.add_source( + "TwoStep.sol", + r#" +contract TwoStep { + bool primed; + function setup() external { primed = true; } + function boom() external { if (primed) assert(false); } +} + "#, + ); + prj.add_test( + "TwoStepTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {TwoStep} from "../src/TwoStep.sol"; + +contract TwoStepTest is Test { + TwoStep h; + function setUp() public { h = new TwoStep(); targetContract(address(h)); } + function invariant_ok() public view {} +} + "#, + ); + cmd.args(["test", "--mt", "invariant_ok"]).assert_failure(); + + let handlers_dir = prj + .root() + .join("cache") + .join("invariant") + .join("failures") + .join("TwoStepTest") + .join("handlers"); + let file = std::fs::read_dir(&handlers_dir) + .unwrap() + .filter_map(|e| e.ok()) + .find(|e| e.path().extension().is_some_and(|x| x == "json")) + .expect("persisted handler file"); + let json: serde_json::Value = + serde_json::from_reader(std::fs::File::open(file.path()).unwrap()).unwrap(); + let len = json["call_sequence"].as_array().unwrap().len(); + assert!(len >= 2, "sequence must include both setup() and boom() calls, got {len}"); + + // Patch `setup` to assert. Replay now sees a pre-anchor assertion → file is stale. + prj.add_source( + "TwoStep.sol", + r#" +contract TwoStep { + bool primed; + function setup() external { assert(false); } + function boom() external { if (primed) assert(false); } +} + "#, + ); + prj.update_config(|config| { + config.invariant.runs = 0; + }); + cmd.forge_fuse().args(["test", "--mt", "invariant_ok"]).assert_success(); + + let entries_after: Vec<_> = std::fs::read_dir(&handlers_dir) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().is_some_and(|x| x == "json")) + .collect(); + assert!( + entries_after.is_empty(), + "stale file (earlier call asserts) must be deleted, got {entries_after:?}" + ); +} + +// Two branches that both assert in the same handler function collapse to a single +// persisted bug under site-granular dedup, regardless of whether one branch requires +// a setup call. The shrinker minimizes the kept reproducer to whichever branch is +// reachable with the fewest calls (typically the no-setup-required branch). +#[forgetest_init] +fn handler_two_branches_same_function_collapse_to_one_bug(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 100; + config.invariant.depth = 20; + config.invariant.fail_on_revert = false; + config.invariant.corpus.corpus_dir = Some("inv_corpus".into()); + }); + prj.add_source( + "TwoBranch.sol", + r#" +contract TwoBranch { + bool routed; + function route() external { routed = true; } + function check() external { + if (routed) { + assert(false); + } else { + assert(false); + } + } +} + "#, + ); + prj.add_test( + "TwoBranchTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {TwoBranch} from "../src/TwoBranch.sol"; + +contract TwoBranchTest is Test { + TwoBranch h; + function setUp() public { h = new TwoBranch(); targetContract(address(h)); } + function invariant_ok() public view {} +} + "#, + ); + cmd.args(["test", "--mt", "invariant_ok"]).assert_failure(); + + let handlers_dir = prj + .root() + .join("cache") + .join("invariant") + .join("failures") + .join("TwoBranchTest") + .join("handlers"); + let entries: Vec<_> = std::fs::read_dir(&handlers_dir) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().is_some_and(|x| x == "json")) + .collect(); + // Both branches share `(reverter, check_selector)` → 1 persisted bug under + // site-granular dedup. + assert_eq!( + entries.len(), + 1, + "expected 1 persisted handler bug for two branches in same function, got {entries:?}", + ); +} + +// Regression: with `assertions_revert = false`, a non-reverting `vm.assert*` writes +// `GLOBAL_FAIL_SLOT = 1` and that committed slot was never cleared, poisoning every +// subsequent `handlers_succeeded` check and silently suppressing later `assert_invariants` +// evaluations. Asserts on call #1, then bumps a counter on call #2/#3 to trip a real +// predicate — both the handler bug AND the predicate failure must be reported. +#[forgetest_init] +fn handler_vm_assert_global_flag_does_not_poison_invariant_checks(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 5; + config.invariant.fail_on_revert = false; + config.assertions_revert = false; + }); + prj.add_test( + "HandlerVmAssertPoisonTest.t.sol", + r#" +import "forge-std/Test.sol"; + +contract OncePoisonHandler is Test { + uint256 public counter; + bool public asserted; + + // First call sets GLOBAL_FAIL_SLOT via the non-reverting cheatcode path; later + // calls don't re-touch the slot, so invariants only keep getting checked if the + // committed slot is cleared after recording the handler bug. + function step() external { + counter++; + if (!asserted) { + asserted = true; + vm.assertEq(uint256(1), uint256(2)); + } + } +} + +contract HandlerVmAssertPoisonTest is Test { + OncePoisonHandler handler; + + function setUp() public { + handler = new OncePoisonHandler(); + targetContract(address(handler)); + } + + // Holds at counter ∈ {1, 2}, breaks at counter == 3. + function invariant_counter_below_three() public view { + require(handler.counter() < 3, "counter reached 3"); + } +} +"#, + ); + + cmd.args(["test", "--mt", "invariant_counter_below_three"]).assert_failure().stdout_eq(str![[r#" +... +Ran 1 test for test/HandlerVmAssertPoisonTest.t.sol:HandlerVmAssertPoisonTest +[FAIL: counter reached 3] + [Sequence] (original: 3, shrunk: 1) + sender=[..] addr=[test/HandlerVmAssertPoisonTest.t.sol:OncePoisonHandler]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=step() args=[] + +Assertion Tests: 1 assertion bug(s) found +[FAIL: assertion failed] test/HandlerVmAssertPoisonTest.t.sol:OncePoisonHandler::step + [Sequence] (original: 1, shrunk: 1) + sender=[..] addr=[test/HandlerVmAssertPoisonTest.t.sol:OncePoisonHandler]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=step() args=[] + invariant_counter_below_three() (runs: 1, calls: 3, reverts: 0) + +╭-------------------+----------+-------+---------+----------╮ +| Contract | Selector | Calls | Reverts | Discards | ++===========================================================+ +| OncePoisonHandler | step | 3 | 0 | 0 | +╰-------------------+----------+-------+---------+----------╯ + +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/HandlerVmAssertPoisonTest.t.sol:HandlerVmAssertPoisonTest +[FAIL: counter reached 3] + [Sequence] (original: 3, shrunk: 1) + sender=[..] addr=[test/HandlerVmAssertPoisonTest.t.sol:OncePoisonHandler]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=step() args=[] + +Assertion Tests: 1 assertion bug(s) found +[FAIL: assertion failed] test/HandlerVmAssertPoisonTest.t.sol:OncePoisonHandler::step + [Sequence] (original: 1, shrunk: 1) + sender=[..] addr=[test/HandlerVmAssertPoisonTest.t.sol:OncePoisonHandler]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=step() args=[] + invariant_counter_below_three() (runs: 1, calls: 3, reverts: 0) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]]); +} diff --git a/crates/forge/tests/cli/test_cmd/invariant/mod.rs b/crates/forge/tests/cli/test_cmd/invariant/mod.rs new file mode 100644 index 0000000000000..b4f3ab564511f --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/invariant/mod.rs @@ -0,0 +1,3640 @@ +use alloy_primitives::{U256, keccak256}; +use foundry_test_utils::{ + TestCommand, forgetest_init, snapbox::cmd::OutputAssert, str, util::OutputExt, +}; +use std::{ + process::Stdio, + thread, + time::{Duration, Instant}, +}; + +mod common; +mod handler; +mod storage; +mod target; + +fn assert_invariant(cmd: &mut TestCommand) -> OutputAssert { + cmd.assert_with(&[ + ("[RUNS]", r"runs: \d+, calls: \d+, reverts: \d+"), + ("[SEQUENCE]", r"\[Sequence\].*(\n\t\t.*)*"), + ("[STATS]", r"╭[\s\S]*?╰.*"), + ]) +} + +#[forgetest_init] +fn warns_for_ignored_bool_invariant_return(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 2; + config.invariant.depth = 2; + }); + prj.add_test( + "InvariantReturns.t.sol", + r#" +contract Handler { + uint256 public value; + + function increment() external { + value++; + } +} + +contract BoolInvariantTest { + Handler handler; + + function setUp() public { + handler = new Handler(); + } + + function invariant_returnsFalse() public pure returns (bool) { + return false; + } +} + +contract NoReturnInvariantTest { + Handler handler; + + function setUp() public { + handler = new Handler(); + } + + function invariant_noReturn() public pure {} +} + +contract OptimizationInvariantTest { + Handler handler; + + function setUp() public { + handler = new Handler(); + } + + function invariant_optimization() public view returns (int256) { + return int256(handler.value()); + } +} + "#, + ); + + assert_invariant(cmd.args(["test", "--mc", "BoolInvariantTest"])) + .success() + .stdout_eq(str![[r#" +... +[PASS] invariant_returnsFalse() ([RUNS]) +... +"#]]) + .stderr_eq(str![[r#" +Warning: Invariant function `invariant_returnsFalse()` returns `bool`, but its return value is ignored; use assertions or revert to indicate failure. + +"#]]); + + cmd.forge_fuse() + .args(["test", "--mc", "NoReturnInvariantTest"]) + .assert_success() + .stderr_eq(str![""]); + + cmd.forge_fuse() + .args(["test", "--mc", "OptimizationInvariantTest"]) + .assert_success() + .stderr_eq(str![""]); +} + +// Tests that a persisted failure doesn't fail due to assume revert if test driver is changed. +#[forgetest_init] +fn should_not_fail_replay_assume(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.fail_on_revert = true; + config.invariant.max_assume_rejects = 10; + }); + + // Add initial test that breaks invariant. + prj.add_test( + "AssumeTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract AssumeHandler is Test { + function fuzzMe(uint256 a) public { + require(false, "Invariant failure"); + } +} + +contract AssumeTest is Test { + function setUp() public { + AssumeHandler handler = new AssumeHandler(); + } + function invariant_assume() public {} +} + "#, + ); + + cmd.args(["test", "--mt", "invariant_assume"]).assert_failure().stdout_eq(str![[r#" +... +[FAIL: Invariant failure] +... +"#]]); + + // Change test to use assume instead require. Same test should fail with too many inputs + // rejected message instead persisted failure revert. + prj.add_test( + "AssumeTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract AssumeHandler is Test { + function fuzzMe(uint256 a) public { + vm.assume(false); + } +} + +contract AssumeTest is Test { + function setUp() public { + AssumeHandler handler = new AssumeHandler(); + } + function invariant_assume() public {} +} + "#, + ); + + cmd.assert_failure().stdout_eq(str![[r#" +... +[FAIL: `vm.assume` rejected too many inputs (10 allowed)] invariant_assume() (runs: 0, calls: 0, reverts: 0) +... +"#]]); +} + +// Test too many inputs rejected for `assumePrecompile`/`assumeForgeAddress`. +// +#[forgetest_init] +fn should_revert_with_assume_code(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.fail_on_revert = true; + config.invariant.max_assume_rejects = 10; + config.fuzz.seed = Some(U256::from(100u32)); + }); + + // Add initial test that breaks invariant. + prj.add_test( + "AssumeTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract BalanceTestHandler is Test { + address public ref = address(1412323); + address alice; + + constructor(address _alice) { + alice = _alice; + } + + function increment(uint256 amount_, address addr) public { + assumeNotPrecompile(addr); + assumeNotForgeAddress(addr); + assertEq(alice.balance, 100_000 ether); + } +} + +contract BalanceAssumeTest is Test { + function setUp() public { + address alice = makeAddr("alice"); + vm.deal(alice, 100_000 ether); + targetSender(alice); + BalanceTestHandler handler = new BalanceTestHandler(alice); + targetContract(address(handler)); + } + + function invariant_balance() public {} +} + "#, + ); + + cmd.args(["test", "--mt", "invariant_balance"]).assert_failure().stdout_eq(str![[r#" +... +[FAIL: `vm.assume` rejected too many inputs (10 allowed)] invariant_balance() (runs: [..], calls: [..], reverts: 0) +... +"#]]); +} + +// Test proper message displayed if `targetSelector`/`excludeSelector` called with empty selectors. +// +#[forgetest_init] +fn should_not_panic_if_no_selectors(prj: _, cmd: _) { + prj.add_test( + "NoSelectorTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract TestHandler is Test {} + +contract NoSelectorTest is Test { + bytes4[] selectors; + + function setUp() public { + TestHandler handler = new TestHandler(); + targetSelector(FuzzSelector({addr: address(handler), selectors: selectors})); + excludeSelector(FuzzSelector({addr: address(handler), selectors: selectors})); + } + + function invariant_panic() public {} +} + "#, + ); + + cmd.args(["test", "--mt", "invariant_panic"]).assert_failure().stdout_eq(str![[r#" +... +[FAIL: failed to set up invariant testing environment: No contracts to fuzz.] invariant_panic() (runs: 0, calls: 0, reverts: 0) +... +"#]]); +} + +#[forgetest_init] +fn should_not_panic_if_selectors_are_targeted_and_excluded(prj: _, cmd: _) { + prj.add_test( + "ContradictorySelectorTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract Target { + function foo() public {} +} + +contract ContradictorySelectorTest is Test { + Target target; + + function setUp() public { + target = new Target(); + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = target.foo.selector; + targetSelector(FuzzSelector({addr: address(target), selectors: selectors})); + excludeSelector(FuzzSelector({addr: address(target), selectors: selectors})); + } + + function invariant_panic() public {} +} + "#, + ); + + cmd.args(["test", "--mt", "invariant_panic"]).assert_failure().stdout_eq(str![[r#" +... +[FAIL: failed to set up invariant testing environment: No functions to fuzz.] invariant_panic() (runs: 0, calls: 0, reverts: 0) +... +"#]]); +} + +// +#[forgetest_init] +fn should_show_invariant_metrics(prj: _, cmd: _) { + prj.add_test( + "SelectorMetricsTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract CounterTest is Test { + function setUp() public { + CounterHandler handler = new CounterHandler(); + AnotherCounterHandler handler1 = new AnotherCounterHandler(); + // targetContract(address(handler1)); + } + + /// forge-config: default.invariant.runs = 10 + /// forge-config: default.invariant.show-metrics = true + function invariant_counter() public {} + + /// forge-config: default.invariant.runs = 10 + /// forge-config: default.invariant.show-metrics = true + function invariant_counter2() public {} +} + +contract CounterHandler is Test { + function doSomething(uint256 a) public { + vm.assume(a < 10_000_000); + require(a < 100_000); + } + + function doAnotherThing(uint256 a) public { + vm.assume(a < 10_000_000); + require(a < 100_000); + } +} + +contract AnotherCounterHandler is Test { + function doWork(uint256 a) public { + vm.assume(a < 10_000_000); + require(a < 100_000); + } + + function doWorkThing(uint256 a) public { + vm.assume(a < 10_000_000); + require(a < 100_000); + } +} + "#, + ); + + cmd.args(["test", "--mt", "invariant_"]).assert_success().stdout_eq(str![[r#" +... +[PASS] +CounterTest invariants: +[PASS] invariant_counter +[PASS] invariant_counter2 + CounterTest invariants (runs: 10, calls: 5000, reverts: [..]) + +╭-----------------------+----------------+-------+---------+----------╮ +| Contract | Selector | Calls | Reverts | Discards | ++=====================================================================+ +| AnotherCounterHandler | doWork | [..] | [..] | [..] | +|-----------------------+----------------+-------+---------+----------| +| AnotherCounterHandler | doWorkThing | [..] | [..] | [..] | +|-----------------------+----------------+-------+---------+----------| +| CounterHandler | doAnotherThing | [..] | [..] | [..] | +|-----------------------+----------------+-------+---------+----------| +| CounterHandler | doSomething | [..] | [..] | [..] | +╰-----------------------+----------------+-------+---------+----------╯ + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +} + +// Tests that invariant exists with success after configured timeout. +#[forgetest_init] +fn should_apply_configured_timeout(prj: _, cmd: _) { + // Add initial test that breaks invariant. + prj.add_test( + "TimeoutTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract TimeoutHandler is Test { + uint256 public count; + + function increment() public { + count++; + } +} + +contract TimeoutTest is Test { + TimeoutHandler handler; + + function setUp() public { + handler = new TimeoutHandler(); + } + + /// forge-config: default.invariant.runs = 10000 + /// forge-config: default.invariant.depth = 20000 + /// forge-config: default.invariant.timeout = 1 + function invariant_counter_timeout() public view { + // Invariant will fail if more than 10000 increments. + // Make sure test timeouts after one second and remaining runs are canceled. + require(handler.count() < 10000); + } +} + "#, + ); + + cmd.args(["test", "--mt", "invariant_counter_timeout"]).assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/TimeoutTest.t.sol:TimeoutTest +[PASS] invariant_counter_timeout() (runs: 0, calls: 0, reverts: 0) + +╭----------------+-----------+-------+---------+----------╮ +| Contract | Selector | Calls | Reverts | Discards | ++=========================================================+ +| TimeoutHandler | increment | [..]| [..]| [..]| +╰----------------+-----------+-------+---------+----------╯ + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +} + +// Tests that selector hits are uniformly distributed +// +#[forgetest_init] +fn invariant_selectors_weight(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 10; + }); + prj.add_source( + "InvariantHandlers.sol", + r#" +contract HandlerOne { + uint256 public hit1; + + function selector1() external { + hit1 += 1; + } +} + +contract HandlerTwo { + uint256 public hit2; + uint256 public hit3; + uint256 public hit4; + uint256 public hit5; + + function selector2() external { + hit2 += 1; + } + + function selector3() external { + hit3 += 1; + } + + function selector4() external { + hit4 += 1; + } + + function selector5() external { + hit5 += 1; + } +} + "#, + ); + + prj.add_test( + "InvariantSelectorsWeightTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import "src/InvariantHandlers.sol"; + +contract InvariantSelectorsWeightTest is Test { + HandlerOne handlerOne; + HandlerTwo handlerTwo; + + function setUp() public { + handlerOne = new HandlerOne(); + handlerTwo = new HandlerTwo(); + } + + function afterInvariant() public { + assertEq(handlerOne.hit1(), 2); + assertEq(handlerTwo.hit2(), 2); + assertEq(handlerTwo.hit3(), 3); + assertEq(handlerTwo.hit4(), 2); + assertEq(handlerTwo.hit5(), 1); + } + + function invariant_selectors_weight() public view {} +} + "#, + ); + + cmd.args(["test", "--fuzz-seed", "119", "--mt", "invariant_selectors_weight"]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/InvariantSelectorsWeightTest.t.sol:InvariantSelectorsWeightTest +[PASS] invariant_selectors_weight() (runs: 1, calls: 10, reverts: 0) + +╭------------+-----------+-------+---------+----------╮ +| Contract | Selector | Calls | Reverts | Discards | ++=====================================================+ +| HandlerOne | selector1 | 2 | 0 | 0 | +|------------+-----------+-------+---------+----------| +| HandlerTwo | selector2 | 2 | 0 | 0 | +|------------+-----------+-------+---------+----------| +| HandlerTwo | selector3 | 3 | 0 | 0 | +|------------+-----------+-------+---------+----------| +| HandlerTwo | selector4 | 2 | 0 | 0 | +|------------+-----------+-------+---------+----------| +| HandlerTwo | selector5 | 1 | 0 | 0 | +╰------------+-----------+-------+---------+----------╯ + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +// Tests original and new counterexample lengths are displayed on failure. +// Tests switch from regular sequence output to solidity. +#[forgetest_init] +fn invariant_sequence_len(prj: _, cmd: _) { + prj.initialize_default_contracts(); + prj.update_config(|config| { + config.fuzz.seed = Some(U256::from(10u32)); + }); + + prj.add_test( + "InvariantSequenceLenTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import "src/Counter.sol"; + +contract InvariantSequenceLenTest is Test { + Counter public counter; + + function setUp() public { + counter = new Counter(); + targetContract(address(counter)); + } + + function invariant_increment() public { + require(counter.number() / 2 < 100000000000000000000000000000000, "invariant increment failure"); + } +} + "#, + ); + + cmd.args(["test", "--mt", "invariant_increment", "--no-dynamic-test-linking"]) + .assert_failure() + .stdout_eq(str![[r#" +... +[FAIL: invariant increment failure] + [Sequence] (original: [..], shrunk: 1) +... +"#]]); + + // Check regular sequence output. Shrink disabled to show several lines. + cmd.forge_fuse().arg("clean").assert_success(); + prj.update_config(|config| { + config.invariant.shrink_run_limit = 0; + }); + assert_invariant(cmd.forge_fuse().args([ + "test", + "--mt", + "invariant_increment", + "--no-dynamic-test-linking", + ])) + .failure() + .stdout_eq(str![[r#" +... +Failing tests: +Encountered 1 failing test in test/InvariantSequenceLenTest.t.sol:InvariantSequenceLenTest +[FAIL: invariant increment failure] + [SEQUENCE] + invariant_increment() ([RUNS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]]); + + // Check solidity sequence output on same failure. + cmd.forge_fuse().arg("clean").assert_success(); + prj.update_config(|config| { + config.invariant.show_solidity = true; + }); + assert_invariant(cmd.forge_fuse().args([ + "test", + "--mt", + "invariant_increment", + "--no-dynamic-test-linking", + ])) + .failure() + .stdout_eq(str![[r#" +... +Failing tests: +Encountered 1 failing test in test/InvariantSequenceLenTest.t.sol:InvariantSequenceLenTest +[FAIL: invariant increment failure] + [SEQUENCE] + invariant_increment() ([RUNS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]]); + + // Persisted failures should be able to switch output. + prj.update_config(|config| { + config.invariant.show_solidity = false; + }); + assert_invariant(cmd.forge_fuse().args([ + "test", + "--mt", + "invariant_increment", + "--no-dynamic-test-linking", + ])) + .failure() + .stdout_eq(str![[r#" +... +Failing tests: +Encountered 1 failing test in test/InvariantSequenceLenTest.t.sol:InvariantSequenceLenTest +[FAIL: invariant increment failure] + [SEQUENCE] + invariant_increment() ([RUNS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]]); +} + +// Tests that persisted failure is discarded if test contract was modified. +// +#[forgetest_init] +fn invariant_replay_with_different_bytecode(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 5; + config.invariant.depth = 5; + }); + prj.add_source( + "Ownable.sol", + r#" +contract Ownable { + address public owner = address(777); + + function backdoor(address _owner) external { + owner = address(888); + } + + function changeOwner(address _owner) external { + } +} + "#, + ); + prj.add_test( + "OwnableTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import "src/Ownable.sol"; + +contract OwnableTest is Test { + Ownable ownable; + + function setUp() public { + ownable = new Ownable(); + } + + function invariant_never_owner() public { + require(ownable.owner() != address(888), "never owner"); + } +} + "#, + ); + + cmd.args(["test", "--mt", "invariant_never_owner"]).assert_failure().stdout_eq(str![[r#" +... +[FAIL: never owner] +... +"#]]); + + // Should replay failure if same test. + cmd.assert_failure().stdout_eq(str![[r#" +... +[FAIL: never owner] +... +"#]]); + + // Different test driver that should not fail the invariant. + prj.add_test( + "OwnableTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import "src/Ownable.sol"; + +contract OwnableTest is Test { + Ownable ownable; + + function setUp() public { + ownable = new Ownable(); + // Ignore selector that fails invariant. + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = Ownable.changeOwner.selector; + targetSelector(FuzzSelector({addr: address(ownable), selectors: selectors})); + } + + function invariant_never_owner() public { + require(ownable.owner() != address(888), "never owner"); + } +} + "#, + ); + cmd.assert_with(&[( + "[FAILURE_PATH]", + r#""[^"]*/cache/invariant/failures/OwnableTest/invariants/invariant_never_owner""#, + )]) + .success() + .stderr_eq(str![[r#" +Warning: Failure from [FAILURE_PATH] file was ignored because invariant test settings have changed: target selectors changed + +"#]]) + .stdout_eq(str![[r#" +... +[PASS] invariant_never_owner() (runs: 5, calls: 25, reverts: 0) +... +"#]]); +} + +#[forgetest_init] +fn invariant_replay_preserves_fail_reason(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 1; + }); + prj.add_test( + "InvariantReplayFailReason.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract InvariantReplayFailReason is Test { + function setUp() public { + targetContract(address(this)); + } + + function callTarget(uint256) external {} + + function invariant_fail_reason() public { + fail(); + } +} + "#, + ); + + cmd.args(["test", "--mt", "invariant_fail_reason"]).assert_failure().stdout_eq(str![[r#" +... +[FAIL: assertion failed] invariant_fail_reason() (runs: 1, calls: 1, reverts: 0) +... +"#]]); + + // Replay should preserve failure reason instead of generic replay message. + cmd.assert_failure().stdout_eq(str![[r#" +... +[FAIL: assertion failed] invariant_fail_reason() (runs: 1, calls: 1, reverts: 0) +... +"#]]); +} + +#[forgetest_init] +fn invariant_replay_preserves_custom_error_reason(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 1; + config.invariant.fail_on_revert = true; + }); + prj.add_test( + "InvariantReplayCustomError.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract CustomErrorTarget { + error InvariantCustomError(uint256, string); + + function breakInvariant() external { + revert InvariantCustomError(111, "custom"); + } +} + +contract CustomErrorHandler is Test { + CustomErrorTarget target; + + constructor() { + target = new CustomErrorTarget(); + } + + function callTarget() external { + target.breakInvariant(); + } +} + +contract InvariantReplayCustomError is Test { + CustomErrorHandler handler; + + function setUp() public { + handler = new CustomErrorHandler(); + targetContract(address(handler)); + } + + function invariant_custom_error_reason() public view {} +} + "#, + ); + + cmd.args(["test", "--mt", "invariant_custom_error_reason"]).assert_failure().stdout_eq(str![[ + r#" +... +[FAIL: [..]custom[..]][..] +... +"# + ]]); + + // Replay should preserve custom error string too. + cmd.assert_failure().stdout_eq(str![[r#" +... +[FAIL: [..]custom[..]][..] +... +"#]]); +} + +#[forgetest_init] +fn invariant_replay_preserves_invariant_custom_error_reason(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 1; + }); + prj.add_test( + "InvariantReplayInvariantCustomError.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract InvariantReplayInvariantCustomError is Test { + error InvariantCustomError(uint256, string); + + function setUp() public { + targetContract(address(this)); + } + + function touch(uint256) external {} + + function invariant_custom_error_reason_from_invariant() public pure { + revert InvariantCustomError(222, "invariant custom"); + } +} + "#, + ); + + cmd.args(["test", "--mt", "invariant_custom_error_reason_from_invariant"]) + .assert_failure() + .stdout_eq(str![[r#" +... +[FAIL: InvariantCustomError(222, "invariant custom")] invariant_custom_error_reason_from_invariant() (runs: 1, calls: 1, reverts: 0) +... +"#]]); + + // Replay should preserve invariant-level custom error string too. + cmd.assert_failure().stdout_eq(str![[r#" +... +[FAIL: InvariantCustomError(222, "invariant custom")] invariant_custom_error_reason_from_invariant() (runs: 1, calls: 1, reverts: 0) +... +"#]]); +} + +// +#[forgetest_init] +fn invariant_test_target(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 5; + config.invariant.depth = 5; + }); + prj.add_test( + "InvariantTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract InvariantTest is Test { + uint256 count; + + function setCount(uint256 _count) public { + count = _count; + } + + function setUp() public { + } + + function invariant_check_count() public { + } +} + "#, + ); + + cmd.args(["test", "--mt", "invariant_check_count"]).assert_failure().stdout_eq(str![[r#" +... +[FAIL: failed to set up invariant testing environment: No contracts to fuzz.] invariant_check_count() (runs: 0, calls: 0, reverts: 0) +... +"#]]); + + prj.add_test( + "InvariantTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract InvariantTest is Test { + uint256 count; + + function setCount(uint256 _count) public { + count = _count; + } + + function setUp() public { + targetContract(address(this)); + } + + function invariant_check_count() public { + } +} + "#, + ); + + cmd.forge_fuse().args(["test", "--mt", "invariant_check_count"]).assert_success().stdout_eq( + str![[r#" +... +[PASS] invariant_check_count() (runs: 5, calls: 25, reverts: 0) +... +"#]], + ); +} + +// Tests that reserved test functions are not fuzzed when test is set as target. +// +#[forgetest_init] +fn invariant_target_test_contract_selectors(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 10; + config.invariant.depth = 100; + }); + prj.add_test( + "InvariantTargetTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract InvariantTargetTest is Test { + bool fooCalled; + bool testSanityCalled; + bool testTableCalled; + uint256 invariantCalledNum; + uint256 setUpCalledNum; + + function setUp() public { + targetContract(address(this)); + } + + function beforeTestSetup() public { + } + + // Only this selector should be targeted. + function foo() public { + fooCalled = true; + } + + function fixtureCalled() public returns (bool[] memory) { + } + + function table_sanity(bool called) public { + testTableCalled = called; + } + + function test_sanity() public { + testSanityCalled = true; + } + + function afterInvariant() public { + } + + function invariant_foo_called() public view { + } + + function invariant_testSanity_considered_target() public { + } + + function invariant_setUp_considered_target() public { + setUpCalledNum++; + } + + function invariant_considered_target() public { + invariantCalledNum++; + } +} + "#, + ); + + cmd.args(["test", "--mc", "InvariantTargetTest", "--mt", "invariant"]) + .assert_success() + .stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/InvariantTargetTest.t.sol:InvariantTargetTest +[PASS] +InvariantTargetTest invariants: +[PASS] invariant_considered_target +[PASS] invariant_foo_called +[PASS] invariant_setUp_considered_target +[PASS] invariant_testSanity_considered_target + InvariantTargetTest invariants (runs: 10, calls: 1000, reverts: 0) + +╭---------------------+----------+-------+---------+----------╮ +| Contract | Selector | Calls | Reverts | Discards | ++=============================================================+ +| InvariantTargetTest | foo | 1000 | 0 | 0 | +╰---------------------+----------+-------+---------+----------╯ + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +} + +// Tests that `targetSelector` and `excludeSelector` applied on test contract selectors are +// applied. +// +#[forgetest_init] +fn invariant_target_test_include_exclude_selectors(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 10; + config.invariant.depth = 100; + }); + prj.add_test( + "InvariantTargetTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract InvariantTargetIncludeTest is Test { + bool include = true; + function setUp() public { + targetContract(address(this)); + bytes4[] memory selectors = new bytes4[](2); + selectors[0] = this.shouldInclude1.selector; + selectors[1] = this.shouldInclude2.selector; + targetSelector(FuzzSelector({addr: address(this), selectors: selectors})); + } + + function shouldExclude1() public { + include = false; + } + + function shouldInclude1() public { + include = true; + } + + function shouldExclude2() public { + include = false; + } + + function shouldInclude2() public { + include = true; + } + + function invariant_include() public view { + require(include, "does not include"); + } +} + +contract InvariantTargetExcludeTest is Test { + bool include = true; + function setUp() public { + targetContract(address(this)); + bytes4[] memory selectors = new bytes4[](2); + selectors[0] = this.shouldExclude1.selector; + selectors[1] = this.shouldExclude2.selector; + excludeSelector(FuzzSelector({addr: address(this), selectors: selectors})); + } + + function shouldExclude1() public { + include = false; + } + + function shouldInclude1() public { + include = true; + } + + function shouldExclude2() public { + include = false; + } + + function shouldInclude2() public { + include = true; + } + + function invariant_exclude() public view { + require(include, "does not include"); + } +} + "#, + ); + + cmd.args(["test", "--mt", "invariant_include"]).assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/InvariantTargetTest.t.sol:InvariantTargetIncludeTest +[PASS] invariant_include() (runs: 10, calls: 1000, reverts: 0) + +╭----------------------------+----------------+-------+---------+----------╮ +| Contract | Selector | Calls | Reverts | Discards | ++==========================================================================+ +| InvariantTargetIncludeTest | shouldInclude1 | [..] | 0 | 0 | +|----------------------------+----------------+-------+---------+----------| +| InvariantTargetIncludeTest | shouldInclude2 | [..] | 0 | 0 | +╰----------------------------+----------------+-------+---------+----------╯ + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); + + cmd.forge_fuse().args(["test", "--mt", "invariant_exclude"]).assert_success().stdout_eq(str![ + [r#" +No files changed, compilation skipped + +Ran 1 test for test/InvariantTargetTest.t.sol:InvariantTargetExcludeTest +[PASS] invariant_exclude() (runs: 10, calls: 1000, reverts: 0) + +╭----------------------------+----------------+-------+---------+----------╮ +| Contract | Selector | Calls | Reverts | Discards | ++==========================================================================+ +| InvariantTargetExcludeTest | shouldInclude1 | [..] | 0 | 0 | +|----------------------------+----------------+-------+---------+----------| +| InvariantTargetExcludeTest | shouldInclude2 | [..] | 0 | 0 | +╰----------------------------+----------------+-------+---------+----------╯ + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#] + ]); + + cmd.forge_fuse() + .args(["test", "--mt", "invariant_include", "--md"]) + .assert_success() + .stdout_eq(str![[r#" +No files changed, compilation skipped + +Ran 1 test for test/InvariantTargetTest.t.sol:InvariantTargetIncludeTest +[PASS] invariant_include() (runs: 10, calls: 1000, reverts: 0) + +| Contract | Selector | Calls | Reverts | Discards | +|----------------------------|----------------|-------|---------|----------| +| InvariantTargetIncludeTest | shouldInclude1 | [..] | 0 | 0 | +| InvariantTargetIncludeTest | shouldInclude2 | [..] | 0 | 0 | + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); + + cmd.forge_fuse() + .args(["test", "--mt", "invariant_exclude", "--md"]) + .assert_success() + .stdout_eq(str![[r#" +No files changed, compilation skipped + +Ran 1 test for test/InvariantTargetTest.t.sol:InvariantTargetExcludeTest +[PASS] invariant_exclude() (runs: 10, calls: 1000, reverts: 0) + +| Contract | Selector | Calls | Reverts | Discards | +|----------------------------|----------------|-------|---------|----------| +| InvariantTargetExcludeTest | shouldInclude1 | [..] | 0 | 0 | +| InvariantTargetExcludeTest | shouldInclude2 | [..] | 0 | 0 | + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +} + +#[forgetest_init] +fn invariant_selector_focus_worker_exercises_targeted_selector(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 2; + config.invariant.depth = 1; + config.invariant.workers = + foundry_config::InvariantWorkers::Fixed(std::num::NonZeroUsize::new(2).unwrap()); + config.fuzz.seed = Some(U256::ZERO); + }); + prj.add_test( + "InvariantSelectorFocusTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract FocusTarget { + bool public broken; + + function aaaBreak() public { + broken = true; + } + + function zzzSafe() public {} +} + +contract InvariantSelectorFocusTest is Test { + FocusTarget target; + + function setUp() public { + target = new FocusTarget(); + bytes4[] memory selectors = new bytes4[](2); + selectors[0] = target.aaaBreak.selector; + selectors[1] = target.zzzSafe.selector; + targetSelector(FuzzSelector({addr: address(target), selectors: selectors})); + } + + function invariant_focus() public view { + require(!target.broken(), "focused"); + } +} + "#, + ); + + let output = cmd.args(["test", "--mt", "invariant_focus"]).assert_failure(); + let stdout = output.get_output().stdout_lossy(); + assert!(stdout.contains("[FAIL: focused]"), "{stdout}"); + assert!(stdout.contains("invariant_focus()"), "{stdout}"); + assert!(stdout.contains("aaaBreak"), "{stdout}"); +} + +#[forgetest_init] +fn invariant_selector_focus_workers_respect_user_filters(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 2; + config.invariant.depth = 4; + config.invariant.workers = + foundry_config::InvariantWorkers::Fixed(std::num::NonZeroUsize::new(2).unwrap()); + config.fuzz.seed = Some(U256::ONE); + }); + prj.add_test( + "InvariantSelectorFocusFiltersTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract FocusFilterTarget { + bool public broken; + + function aaaBreak() public { + broken = true; + } + + function yyySafe() public {} + + function zzzSafe() public {} +} + +contract InvariantSelectorFocusAllowlistTest is Test { + FocusFilterTarget target; + + function setUp() public { + target = new FocusFilterTarget(); + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = target.zzzSafe.selector; + targetSelector(FuzzSelector({addr: address(target), selectors: selectors})); + } + + function invariant_allowlist_focus() public view { + require(!target.broken(), "focused"); + } +} + +contract InvariantSelectorFocusBlocklistTest is Test { + FocusFilterTarget target; + + function setUp() public { + target = new FocusFilterTarget(); + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = target.aaaBreak.selector; + excludeSelector(FuzzSelector({addr: address(target), selectors: selectors})); + } + + function invariant_blocklist_focus() public view { + require(!target.broken(), "focused"); + } +} + "#, + ); + + let output = cmd.args(["test", "--mt", "invariant_allowlist_focus"]).assert_success(); + let stdout = output.get_output().stdout_lossy(); + assert!(!stdout.contains("aaaBreak"), "{stdout}"); + + let output = + cmd.forge_fuse().args(["test", "--mt", "invariant_blocklist_focus"]).assert_success(); + let stdout = output.get_output().stdout_lossy(); + assert!(!stdout.contains("aaaBreak"), "{stdout}"); +} + +// +#[forgetest_init] +fn corpus_dir(prj: _, cmd: _) { + prj.initialize_default_contracts(); + prj.update_config(|config| { + config.invariant.runs = 10; + config.invariant.depth = 10; + config.invariant.corpus.corpus_dir = Some("invariant_corpus".into()); + + config.fuzz.runs = 10; + config.fuzz.corpus.corpus_dir = Some("fuzz_corpus".into()); + }); + prj.add_test( + "CounterTests.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {Counter} from "../src/Counter.sol"; + +contract Counter1Test is Test { + Counter public counter; + + function setUp() public { + counter = new Counter(); + counter.setNumber(0); + } + + function testFuzz_SetNumber(uint256 x) public { + counter.setNumber(x); + assertEq(counter.number(), x); + } + + function invariant_counter_called() public view { + } +} + +contract Counter2Test is Test { + Counter public counter; + + function setUp() public { + counter = new Counter(); + counter.setNumber(0); + } + + function testFuzz_SetNumber(uint256 x) public { + counter.setNumber(x); + assertEq(counter.number(), x); + } + + function invariant_counter_called() public view { + } +} + "#, + ); + + cmd.args(["test"]).assert_success().stdout_eq(str![[r#" +... +Ran 3 test suites [ELAPSED]: 6 tests passed, 0 failed, 0 skipped (6 total tests) + +"#]]); + + assert!(prj.root().join("invariant_corpus").join("Counter1Test").exists()); + assert!(prj.root().join("invariant_corpus").join("Counter2Test").exists()); + assert!( + !prj.root() + .join("invariant_corpus") + .join("Counter1Test") + .join("invariant_counter_called") + .exists() + ); + assert!( + !prj.root() + .join("invariant_corpus") + .join("Counter2Test") + .join("invariant_counter_called") + .exists() + ); + assert!( + prj.root().join("fuzz_corpus").join("Counter1Test").join("testFuzz_SetNumber").exists() + ); + assert!( + prj.root().join("fuzz_corpus").join("Counter2Test").join("testFuzz_SetNumber").exists() + ); +} + +#[forgetest_init] +fn invariant_corpus_retains_coverage_winning_reverts(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 1; + config.invariant.workers = + foundry_config::InvariantWorkers::Fixed(std::num::NonZeroUsize::new(1).unwrap()); + config.invariant.corpus.corpus_dir = Some("invariant_corpus".into()); + config.invariant.corpus.corpus_gzip = false; + }); + prj.add_test( + "RevertingCorpusTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract RevertingCorpusHandler { + uint256 public credit; + + function spend(uint256 amount) external { + require(amount <= credit, "over credit"); + require(amount > credit, "under credit"); + credit = amount; + } +} + +contract RevertingCorpusTest is Test { + function setUp() public { + RevertingCorpusHandler handler = new RevertingCorpusHandler(); + targetContract(address(handler)); + } + + function invariant_ok() public pure {} +} + "#, + ); + + cmd.args(["test", "--mc", "RevertingCorpusTest", "--fuzz-seed", "0x574"]).assert_success(); + + let corpus_dir = prj.root().join("invariant_corpus/RevertingCorpusTest/worker0/corpus"); + let selector = &keccak256("spend(uint256)")[..4]; + let retained = std::fs::read_dir(corpus_dir) + .unwrap() + .flatten() + .map(|entry| std::fs::read_to_string(entry.path()).unwrap()) + .map(|contents| { + serde_json::from_str::>(&contents).unwrap() + }) + .any(|sequence| { + sequence.len() == 1 && sequence[0].call_details.calldata.starts_with(selector) + }); + assert!(retained, "coverage-winning reverted call was not retained in the corpus"); +} + +#[forgetest_init] +fn invariant_corpus_ends_at_last_coverage_call(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 3; + config.invariant.workers = + foundry_config::InvariantWorkers::Fixed(std::num::NonZeroUsize::new(1).unwrap()); + config.invariant.corpus.corpus_dir = Some("invariant_corpus".into()); + config.invariant.corpus.corpus_gzip = false; + }); + prj.add_test( + "CoveragePrefixTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract CoveragePrefixHandler { + function touch() external {} +} + +contract CoveragePrefixTest is Test { + function setUp() public { + CoveragePrefixHandler handler = new CoveragePrefixHandler(); + targetContract(address(handler)); + } + + function invariant_ok() public pure {} +} + "#, + ); + + cmd.args(["test", "--mc", "CoveragePrefixTest", "--fuzz-seed", "0x574"]).assert_success(); + + let corpus_dir = prj.root().join("invariant_corpus/CoveragePrefixTest/worker0/corpus"); + let entry = std::fs::read_dir(corpus_dir).unwrap().next().unwrap().unwrap(); + let contents = std::fs::read_to_string(entry.path()).unwrap(); + let sequence = + serde_json::from_str::>(&contents).unwrap(); + assert_eq!(sequence.len(), 1); +} + +#[forgetest_init] +fn invariant_corpus_reuses_comparison_hints(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 10; + config.invariant.depth = 1; + config.invariant.workers = + foundry_config::InvariantWorkers::Fixed(std::num::NonZeroUsize::new(1).unwrap()); + config.invariant.dictionary.dictionary_weight = 0; + config.invariant.dictionary.include_storage = false; + config.invariant.dictionary.include_push_bytes = false; + config.invariant.corpus.corpus_dir = Some("invariant_corpus".into()); + config.invariant.corpus.corpus_random_sequence_weight = 0; + config.invariant.corpus.mutation_weights = foundry_config::FuzzCorpusMutationWeights { + mutation_weight_splice: 0, + mutation_weight_repeat: 0, + mutation_weight_interleave: 0, + mutation_weight_prefix: 0, + mutation_weight_suffix: 0, + mutation_weight_abi: 0, + mutation_weight_cmp: 1, + }; + }); + prj.add_test( + "ComparisonCorpusTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract ComparisonCorpusHandler { + bool public reached; + + function compare(uint256 value) external { + if (value >= type(uint256).max - 100) reached = true; + } +} + +contract ComparisonCorpusTest is Test { + ComparisonCorpusHandler handler; + + function setUp() public { + handler = new ComparisonCorpusHandler(); + targetContract(address(handler)); + } + + function invariant_comparison_is_reached() public view { + assertFalse(handler.reached()); + } +} + "#, + ); + + cmd.args(["test", "--mc", "ComparisonCorpusTest", "--fuzz-seed", "0x574"]) + .assert_failure() + .stdout_eq(str![[r#" +... +[FAIL: assertion failed] +... +"#]]); +} + +#[forgetest_init] +fn parallel_invariant_corpus_uses_worker_dirs(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 2; + config.invariant.depth = 2; + config.invariant.workers = + foundry_config::InvariantWorkers::Fixed(std::num::NonZeroUsize::new(2).unwrap()); + config.invariant.corpus.corpus_dir = Some("invariant_corpus".into()); + }); + prj.add_test( + "ContractCorpusTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract ContractCorpusHandler { + function touch(uint256) external {} +} + +contract ContractCorpusTest is Test { + ContractCorpusHandler handler; + + function setUp() public { + handler = new ContractCorpusHandler(); + targetContract(address(handler)); + } + + function invariant_a() public pure {} + + function invariant_b() public pure {} +} + "#, + ); + + cmd.args(["test"]).assert_success().stdout_eq(str![[r#" +... +Ran 1 test for test/ContractCorpusTest.t.sol:ContractCorpusTest +... +"#]]); + + let contract_dir = prj.root().join("invariant_corpus").join("ContractCorpusTest"); + assert!(contract_dir.exists()); + assert!(!contract_dir.join("invariant_a").exists()); + assert!(!contract_dir.join("invariant_b").exists()); + for worker in ["worker0", "worker1"] { + let worker_corpus = contract_dir.join(worker).join("corpus"); + assert!( + std::fs::read_dir(&worker_corpus).is_ok_and(|mut entries| entries.next().is_some()), + "expected {worker} to persist corpus entries during the campaign" + ); + } +} + +#[forgetest_init] +fn parallel_invariant_corpus_survives_external_termination(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = u32::MAX; + config.invariant.depth = 64; + config.invariant.workers = + foundry_config::InvariantWorkers::Fixed(std::num::NonZeroUsize::new(2).unwrap()); + config.invariant.corpus.corpus_dir = Some("invariant_corpus".into()); + config.invariant.corpus.corpus_gzip = false; + }); + prj.add_test( + "InterruptedCorpusTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract InterruptedCorpusHandler { + uint256 public value; + + function set(uint256 next) external { + value = next; + } +} + +contract InterruptedCorpusTest is Test { + InterruptedCorpusHandler handler; + + function setUp() public { + handler = new InterruptedCorpusHandler(); + targetContract(address(handler)); + } + + function invariant_ok() public pure {} +} + "#, + ); + + // Finish compilation before starting the process that will be terminated. + cmd.args(["build", "-q"]).assert_success(); + cmd.forge_fuse().args([ + "test", + "--mc", + "InterruptedCorpusTest", + "--mt", + "invariant_ok", + "--fuzz-seed", + "0x574", + "-q", + ]); + cmd.cmd().stdout(Stdio::null()).stderr(Stdio::null()); + let mut child = cmd.cmd().spawn().unwrap(); + + let contract_dir = prj.root().join("invariant_corpus").join("InterruptedCorpusTest"); + let worker_dirs = + [contract_dir.join("worker0").join("corpus"), contract_dir.join("worker1").join("corpus")]; + let deadline = Instant::now() + Duration::from_secs(30); + let mut persisted = false; + while Instant::now() < deadline { + persisted = worker_dirs.iter().any(|dir| { + std::fs::read_dir(dir).is_ok_and(|mut entries| { + entries.any(|entry| { + entry.is_ok_and(|entry| { + entry.path().extension().is_some_and(|ext| ext == "json") + }) + }) + }) + }); + if persisted { + break; + } + thread::sleep(Duration::from_millis(25)); + } + + child.kill().unwrap(); + child.wait().unwrap(); + assert!(persisted, "parallel invariant campaign did not persist corpus before termination"); + + let entries = worker_dirs + .iter() + .flat_map(|dir| std::fs::read_dir(dir).into_iter().flatten().flatten()) + .map(|entry| entry.path()) + .filter(|path| path.extension().is_some_and(|ext| ext == "json")) + .collect::>(); + assert!(!entries.is_empty()); + for entry in entries { + let contents = std::fs::read_to_string(&entry).unwrap(); + serde_json::from_str::>(&contents).unwrap(); + } + + // Exercise normal startup discovery and EVM replay against the corpus retained by the killed + // campaign, rather than only checking that the files contain valid JSON. + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 1; + }); + let replay = cmd + .forge_fuse() + .args([ + "test", + "--mc", + "InterruptedCorpusTest", + "--mt", + "invariant_ok", + "--fuzz-seed", + "0x574", + ]) + .assert_success(); + let stdout = replay.get_output().stdout_lossy(); + assert!(!stdout.contains("failed corpus replays"), "{stdout}"); +} + +#[forgetest_init] +fn parallel_invariant_worker_error_stops_campaign(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = u32::MAX; + config.invariant.depth = 64; + config.invariant.workers = + foundry_config::InvariantWorkers::Fixed(std::num::NonZeroUsize::new(2).unwrap()); + config.invariant.corpus.corpus_dir = Some("invariant_corpus".into()); + }); + prj.add_test( + "CorpusSetupFailureTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract CorpusSetupFailureHandler { + uint256 public value; + + function set(uint256 next) external { + value = next; + } +} + +contract CorpusSetupFailureTest is Test { + CorpusSetupFailureHandler handler; + + function setUp() public { + handler = new CorpusSetupFailureHandler(); + targetContract(address(handler)); + } + + function invariant_ok() public pure {} +} + "#, + ); + + // Finish compilation before timing how promptly a worker setup error stops its sibling. + cmd.args(["build", "-q"]).assert_success(); + let worker1 = + prj.root().join("invariant_corpus").join("CorpusSetupFailureTest").join("worker1"); + std::fs::create_dir_all(worker1.parent().unwrap()).unwrap(); + std::fs::write(&worker1, b"not a directory").unwrap(); + + cmd.forge_fuse().args([ + "test", + "--mc", + "CorpusSetupFailureTest", + "--mt", + "invariant_ok", + "--fuzz-seed", + "0x574", + "-q", + ]); + cmd.cmd().stdout(Stdio::null()).stderr(Stdio::null()); + let mut child = cmd.cmd().spawn().unwrap(); + + let deadline = Instant::now() + Duration::from_secs(10); + let status = loop { + if let Some(status) = child.try_wait().unwrap() { + break Some(status); + } + if Instant::now() >= deadline { + child.kill().unwrap(); + child.wait().unwrap(); + break None; + } + thread::sleep(Duration::from_millis(25)); + }; + + let status = status.expect("worker corpus setup error did not stop the parallel campaign"); + assert!(!status.success(), "worker corpus setup error unexpectedly succeeded"); +} + +#[forgetest_init] +fn optimization_invariants_use_function_level_corpus_dir(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 2; + config.invariant.corpus.corpus_dir = Some("opt_corpus".into()); + }); + prj.add_test( + "OptimizationCorpusTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract OptimizationCorpusHandler { + int256 public x; + + function bump() external { + x++; + } +} + +contract OptimizationCorpusTest is Test { + OptimizationCorpusHandler handler; + + function setUp() public { + handler = new OptimizationCorpusHandler(); + targetContract(address(handler)); + } + + function invariant_optimize_a() public view returns (int256) { + return handler.x(); + } + + function invariant_optimize_b() public view returns (int256) { + return handler.x() * 10; + } +} + "#, + ); + + cmd.args(["test"]).assert_success().stdout_eq(str![[r#" +... +Ran 2 tests for test/OptimizationCorpusTest.t.sol:OptimizationCorpusTest +... +"#]]); + + let contract_dir = prj.root().join("opt_corpus").join("OptimizationCorpusTest"); + assert!(contract_dir.join("invariant_optimize_a").join("optimization_best.json").exists()); + assert!(contract_dir.join("invariant_optimize_b").join("optimization_best.json").exists()); + assert!(!contract_dir.join("optimization_best.json").exists()); +} + +#[forgetest_init] +fn json_reports_invariant_predicate_results(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 4; + config.invariant.depth = 2; + }); + prj.add_test( + "JsonInvariantReport.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract JsonInvariantHandler { + uint256 public counter; + + function inc() external { + counter++; + } +} + +contract JsonInvariantReportTest is Test { + JsonInvariantHandler handler; + + function setUp() public { + handler = new JsonInvariantHandler(); + targetContract(address(handler)); + } + + function invariant_break() public view { + require(handler.counter() < 1, "broken"); + } + + function invariant_safe() public pure {} +} + "#, + ); + + let output = cmd.args(["test", "--json"]).assert_failure(); + let json: serde_json::Value = serde_json::from_slice(&output.get_output().stdout).unwrap(); + let suite = json.as_object().unwrap().values().next().unwrap(); + let tests = suite["test_results"].as_object().unwrap(); + assert_eq!(tests.len(), 1); + let result = tests.values().next().unwrap(); + let kind = &result["kind"]["Invariant"]; + assert_eq!(kind["runs"], 4); + assert_eq!(kind["workers"], 1); + assert!(result.get("invariant_workers").is_none()); + + cmd.forge_fuse().args(["clean"]).assert_success(); + + let output = + cmd.forge_fuse().args(["test", "--json", "--invariant-workers", "4"]).assert_failure(); + let json: serde_json::Value = serde_json::from_slice(&output.get_output().stdout).unwrap(); + let suite = json.as_object().unwrap().values().next().unwrap(); + let tests = suite["test_results"].as_object().unwrap(); + assert_eq!(tests.len(), 1); + let result = tests.values().next().unwrap(); + let kind = &result["kind"]["Invariant"]; + assert_eq!(kind["runs"], 4); + assert_eq!(kind["workers"], 4); + assert!(result.get("invariant_workers").is_none()); + let predicates = result["invariant_predicate_results"].as_array().unwrap(); + assert_eq!(predicates.len(), 2); + + let broken = + predicates.iter().find(|predicate| predicate["name"] == "invariant_break").unwrap(); + assert_eq!(broken["status"], "Failure"); + assert_eq!(broken["reason"], "broken"); + + let safe = predicates.iter().find(|predicate| predicate["name"] == "invariant_safe").unwrap(); + assert_eq!(safe["status"], "Success"); + assert!(safe.get("reason").is_none()); +} + +#[forgetest_init] +fn forge_test_defaults_invariant_workers_to_auto(prj: _, cmd: _) { + prj.add_test( + "AutoInvariantWorkers.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract AutoInvariantWorkersHandler { + uint256 public counter; + + function inc() external { + counter++; + } +} + +contract AutoInvariantWorkersTest is Test { + AutoInvariantWorkersHandler handler; + + function setUp() public { + handler = new AutoInvariantWorkersHandler(); + targetContract(address(handler)); + } + + function invariant_break() public view { + require(handler.counter() == 0, "broken"); + } +} + "#, + ); + + prj.update_config(|config| { + config.invariant.workers = foundry_config::InvariantWorkers::default(); + }); + cmd.unset_env("FOUNDRY_INVARIANT_WORKERS"); + cmd.env("FOUNDRY_INVARIANT_RUNS", "20000"); + cmd.env("FOUNDRY_INVARIANT_DEPTH", "500"); + cmd.env("FOUNDRY_INVARIANT_SHRINK_RUN_LIMIT", "0"); + let output = cmd.args(["test", "--json", "--threads", "2"]).assert_failure(); + let json: serde_json::Value = serde_json::from_slice(&output.get_output().stdout).unwrap(); + let suite = json.as_object().unwrap().values().next().unwrap(); + let result = suite["test_results"].as_object().unwrap().values().next().unwrap(); + assert_eq!(result["kind"]["Invariant"]["workers"], 2, "{json}"); +} + +#[forgetest_init] +fn invariant_campaign_reports_secondary_skip(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 2; + }); + prj.add_test( + "SkipPredicateReport.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract SkipPredicateHandler { + uint256 public counter; + + function inc() external { + counter++; + } +} + +contract SkipPredicateReportTest is Test { + SkipPredicateHandler handler; + + function setUp() public { + handler = new SkipPredicateHandler(); + targetContract(address(handler)); + } + + function invariant_live() public view { + require(handler.counter() < 10, "live broken"); + } + + function invariant_skipped() public { + vm.skip(true, "secondary"); + } +} + "#, + ); + + let output = cmd.args(["test", "--mt", "invariant_"]).assert_success(); + let stdout = String::from_utf8_lossy(&output.get_output().stdout); + assert!( + stdout.contains("Ran 2 tests for test/SkipPredicateReport.t.sol:SkipPredicateReportTest"), + "{stdout}" + ); + assert!(stdout.contains("[PASS] invariant_live"), "{stdout}"); + assert!(stdout.contains("[SKIP: secondary] invariant_skipped"), "{stdout}"); + assert!(stdout.contains(" SkipPredicateReportTest invariants (runs:"), "{stdout}"); + assert!(!stdout.contains(" invariant_live() (runs:"), "{stdout}"); + assert!(stdout.contains("Suite result: ok. 1 passed; 0 failed; 1 skipped;"), "{stdout}"); + + let output = cmd + .forge_fuse() + .args(["test", "--mt", "invariant_", "--show-progress", "-j1"]) + .assert_success(); + let stdout = String::from_utf8_lossy(&output.get_output().stdout); + assert!(stdout.contains("SkipPredicateReportTest invariants"), "{stdout}"); + assert!(!stdout.contains(" invariant_live() (runs:"), "{stdout}"); + + cmd.forge_fuse().args(["test", "--mt", "invariant_", "--summary"]).assert_success().stdout_eq( + str![[r#" +... +╭-------------------------+--------+--------+---------╮ +| Test Suite | Passed | Failed | Skipped | ++=====================================================+ +| SkipPredicateReportTest | 1 | 0 | 1 | +╰-------------------------+--------+--------+---------╯ + + +"#]], + ); + + cmd.forge_fuse() + .args(["test", "--mt", "invariant_", "--summary", "--json"]) + .assert_json_stdout(str![[r#" +{ + "results": [ + { + "suite": "SkipPredicateReportTest", + "passed": 1, + "failed": 0, + "skipped": 1 + } + ] +} +"#]]); +} + +#[forgetest_init] +fn junit_reports_invariant_predicates_and_handler_failures(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 2; + config.invariant.fail_on_revert = false; + }); + prj.add_source( + "JunitAssertHandler.sol", + r#" +contract JunitAssertHandler { + function alwaysAssert() external { + assert(false); + } +} + "#, + ); + prj.add_test( + "JunitInvariantReport.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {JunitAssertHandler} from "../src/JunitAssertHandler.sol"; + +contract JunitInvariantReportTest is Test { + JunitAssertHandler handler; + + function setUp() public { + handler = new JunitAssertHandler(); + targetContract(address(handler)); + } + + function invariant_a() public pure {} + + function invariant_b() public pure {} +} + "#, + ); + + let output = cmd.args(["test", "--junit", "--mt", "invariant_a"]).assert_failure(); + let stdout = String::from_utf8_lossy(&output.get_output().stdout); + assert!(stdout.contains(r#" 5. + // With check_interval=1, it should fail on call 6. + function invariant_counter_le_five() public view { + require(handler.counter() <= 5, "counter > 5"); + } +} + "#, + ); + + assert_invariant(cmd.args(["test", "--mt", "invariant_counter"])).failure().stdout_eq(str![[ + r#" +... +[FAIL: counter > 5] + [SEQUENCE] +... +"# + ]]); +} + +// Tests that check_interval=N checks every N calls AND always on the last call. +#[forgetest_init] +fn check_interval_n_checks_every_n_calls(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 20; + config.invariant.check_interval = 5; + }); + prj.add_test( + "CheckIntervalTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract CounterHandler { + uint256 public counter; + + function increment() public { + counter++; + } +} + +contract CheckIntervalTest is Test { + CounterHandler handler; + + function setUp() public { + handler = new CounterHandler(); + targetContract(address(handler)); + } + + // With check_interval=5 and depth=20, invariant is checked at calls 5,10,15,20. + // This passes because 5,10,15,20 are all multiples of 5. + function invariant_counter_multiple_of_five() public view { + require(handler.counter() % 5 == 0, "not multiple of 5"); + } +} + "#, + ); + + cmd.args(["test", "--mt", "invariant_counter"]).assert_success().stdout_eq(str![[r#" +... +[PASS] invariant_counter_multiple_of_five() (runs: 1, calls: 20, reverts: 0) +... +"#]]); +} + +// Tests check_interval via inline config annotation. +#[forgetest_init] +fn check_interval_inline_config(prj: _, cmd: _) { + prj.add_test( + "CheckIntervalInlineTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract CounterHandler { + uint256 public counter; + + function increment() public { + counter++; + } +} + +contract CheckIntervalInlineTest is Test { + CounterHandler handler; + + function setUp() public { + handler = new CounterHandler(); + targetContract(address(handler)); + } + + /// forge-config: default.invariant.runs = 1 + /// forge-config: default.invariant.depth = 10 + /// forge-config: default.invariant.check_interval = 0 + function invariant_only_last_checked() public view { + // Only passes when counter is 0 or 10. With check_interval=0, only last call is checked. + require(handler.counter() == 0 || handler.counter() == 10, "not at boundary"); + } +} + "#, + ); + + cmd.args(["test", "--mt", "invariant_only_last_checked"]).assert_success().stdout_eq(str![[ + r#" +... +[PASS] invariant_only_last_checked() (runs: 1, calls: 10, reverts: 0) +... +"# + ]]); +} + +#[forgetest_init] +fn invariant_campaign_merges_selected_predicates(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 10; + config.invariant.depth = 100; + }); + 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"); + } +} + "#, + ); + + // A wide filter that includes the contract's canonical predicate runs one contract-level + // campaign and reports each selected predicate separately. + cmd.args(["test", "--mt", "invariant_cond[1-4]"]).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 1 met] invariant_cond1 + [Sequence] (original: [..], shrunk: [..]) +... + +[FAIL: condition 2 met] invariant_cond2 + [Sequence] (original: [..], shrunk: [..]) +... +[FAIL: condition 3 met] invariant_cond3 + [Sequence] (original: [..], shrunk: [..]) +... + +CounterTest invariants: 3/4 invariants broken +[FAIL: condition 1 met] invariant_cond1 +... +"#]]); + + // Re-running a single target replays cond3's persisted counterexample and exits without + // running a fresh campaign — only the primary block, no secondary [FAIL]s, no + // persisted-failures footer, no campaign roll-up. A stderr warning calls out + // the other selected predicate with a persisted failure if it would otherwise be included. + 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: 5, reverts: 0) +... +"# + ]]); +} + +#[forgetest_init] +fn invariant_campaign_keeps_contract_boundary_with_no_match_test(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 5; + config.invariant.depth = 20; + }); + prj.add_source( + "Counter.sol", + r#" +contract Counter { + uint256 public cond; + + function inc() public { + cond++; + } +} + "#, + ); + prj.add_test( + "FilteredInvariantTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {Counter} from "../src/Counter.sol"; + +contract FilteredInvariantTest is Test { + Counter public counter; + + function setUp() public { + counter = new Counter(); + targetContract(address(counter)); + } + + function invariant_a_excluded_by_filter() public view { + require(counter.cond() < 1, "a broken"); + } + + function invariant_b_checked() public view { + require(counter.cond() < 2, "b broken"); + } + + function invariant_c_checked() public view { + require(counter.cond() < 3, "c broken"); + } +} + "#, + ); + + let output = cmd.args(["test", "--nmt", "invariant_a_excluded_by_filter"]).assert_failure(); + let stdout = String::from_utf8_lossy(&output.get_output().stdout); + assert!( + stdout.contains("Ran 1 test for test/FilteredInvariantTest.t.sol:FilteredInvariantTest"), + "{stdout}" + ); + assert!(stdout.contains("FilteredInvariantTest invariants: 2/2 invariants broken"), "{stdout}"); + assert!(stdout.contains("[FAIL: b broken] invariant_b_checked"), "{stdout}"); + assert!(stdout.contains("[FAIL: c broken] invariant_c_checked"), "{stdout}"); + assert!(!stdout.contains("invariant_a_excluded_by_filter"), "{stdout}"); +} + +// Verifies that a single selected predicate keeps the output compact: no secondary failure +// blocks and no persisted-failures footer. +#[forgetest_init] +fn invariant_campaign_single_predicate_stays_clean(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 5; + config.invariant.depth = 50; + }); + 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_other_breakable() public view { + require(counter.cond() < 4, "other broken"); + } + + function invariant_safe() public view { + require(counter.cond() < 1000000, "should never break"); + } +} + "#, + ); + + // Only the selected predicate runs: a single [FAIL] block, no suite roll-up, and the + // never-selected `invariant_safe` produces no output. + let output = cmd.args(["test", "--mt", "invariant_breakable"]).assert_failure(); + let stdout = String::from_utf8_lossy(&output.get_output().stdout); + assert!(stdout.contains("Ran 1 test for test/CounterTest.t.sol:CounterTest"), "{stdout}"); + assert!(stdout.contains("[FAIL: primary broken]"), "{stdout}"); + assert!(stdout.contains(" invariant_breakable() (runs:"), "{stdout}"); + assert!(!stdout.contains("Invariant/Property Tests:"), "{stdout}"); + assert!(!stdout.contains("invariant_other_breakable"), "{stdout}"); + assert!(!stdout.contains("invariant_safe"), "{stdout}"); + + // The same isolation applies when the selected predicate is not the contract's first + // invariant function. + let output = + cmd.forge_fuse().args(["test", "--mt", "invariant_other_breakable"]).assert_failure(); + let stdout = String::from_utf8_lossy(&output.get_output().stdout); + assert!(stdout.contains("[FAIL: other broken]"), "{stdout}"); + assert!(stdout.contains(" invariant_other_breakable() (runs:"), "{stdout}"); + assert!(!stdout.contains("Invariant/Property Tests:"), "{stdout}"); + assert!(!stdout.contains("invariant_breakable"), "{stdout}"); + assert!(!stdout.contains("invariant_safe"), "{stdout}"); +} + +// A handler `assert(false)` is routed to the dedicated `Assertion Tests:` section instead of +// being attributed to every live invariant. The live invariants stay green; the campaign keeps +// running for its full budget. See also `handler::handler_assertion_routed_to_handler_section`. +#[forgetest_init] +fn handler_assertion_is_reported_separately(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 10; + config.invariant.fail_on_revert = false; + }); + prj.add_source( + "AssertHandler.sol", + r#" +contract AssertHandler { + uint256 public calls; + + function alwaysAssert() external { + calls++; + assert(false); + } +} + "#, + ); + prj.add_test( + "AssertAllAssertTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {AssertHandler} from "../src/AssertHandler.sol"; + +contract AssertAllAssertTest is Test { + AssertHandler handler; + + function setUp() public { + handler = new AssertHandler(); + targetContract(address(handler)); + } + + function invariant_a() public view {} + + function invariant_b() public view {} +} + "#, + ); + + let output = cmd.args(["test", "--mt", "invariant_a"]).assert_failure(); + let stdout = String::from_utf8_lossy(&output.get_output().stdout); + assert!(stdout.contains("Ran 1 test for test/AssertAllAssertTest.t.sol:AssertAllAssertTest")); + assert!(stdout.contains("Assertion Tests: 1 assertion bug(s) found"), "{stdout}"); + assert!( + stdout.contains( + "[FAIL: panic: assertion failed (0x01)] src/AssertHandler.sol:AssertHandler::alwaysAssert" + ), + "{stdout}" + ); + assert!(stdout.contains(" invariant_a() (runs: 1, calls: 10, reverts: 10)"), "{stdout}"); + assert!(!stdout.contains("Invariant/Property Tests:"), "{stdout}"); + assert!(!stdout.contains("invariant_b"), "{stdout}"); +} + +// Verifies an explicitly selected optimization invariant runs as its own campaign. Boolean +// predicates in the same contract are not mixed into optimization mode; when unfiltered they run +// as a separate boolean campaign (covered below). +#[forgetest_init] +fn selected_optimization_runs_separately(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 5; + }); + prj.add_source( + "OptHandler.sol", + r#" +contract OptHandler { + uint256 public x; + function bump(uint256 v) public { x += v % 100; } +} + "#, + ); + prj.add_test( + "OptTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {OptHandler} from "../src/OptHandler.sol"; + +contract OptTest is Test { + OptHandler h; + function setUp() public { h = new OptHandler(); targetContract(address(h)); } + + /// @notice Optimization invariant — primary maximizes int256. + function invariant_maximize() public view returns (int256) { + return int256(h.x()); + } + + function invariant_boolean_one() public view { + require(h.x() < 1000000, "should not exceed 1M"); + } + + function invariant_boolean_two() public view { + require(h.x() != 42, "magic value not allowed"); + } +} + "#, + ); + + cmd.args(["test", "--mt", "invariant_maximize"]).assert_success().stdout_eq(str![[r#" +... +[PASS] + [Best sequence] [..] +... + invariant_maximize() (best: [..], runs: 1, calls: 5) +... +"#]]); +} + +#[forgetest_init] +fn optimization_does_not_suppress_boolean_invariants(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 2; + }); + prj.add_test( + "MixedInvariantTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract MixedInvariantHandler { + uint256 public counter; + + function inc() public { + counter++; + } +} + +contract MixedInvariantTest is Test { + MixedInvariantHandler handler; + + function setUp() public { + handler = new MixedInvariantHandler(); + targetContract(address(handler)); + } + + function invariant_maximize() public view returns (int256) { + return int256(handler.counter()); + } + + function invariant_boolean() public view { + require(handler.counter() < 1, "boolean broken"); + } +} + "#, + ); + + assert_invariant(cmd.args(["test"])).failure().stdout_eq(str![[r#" +... +Ran 2 tests for test/MixedInvariantTest.t.sol:MixedInvariantTest +... +[FAIL: boolean broken] + [SEQUENCE] +... + invariant_boolean() ([RUNS]) +... +[PASS] +... + invariant_maximize() (best: [..], runs: 1, calls: 2) +... +Suite result: FAILED. 1 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +// Verifies that the `afterInvariant` hook keeps running on later runs even after an earlier +// invariant has already broken. +#[forgetest_init] +fn after_invariant_runs_after_earlier_failure(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 5; + config.invariant.depth = 20; + }); + prj.add_source( + "Counter.sol", + r#" +contract Counter { + uint256 public cond; + + function inc() public { + cond++; + } +} + "#, + ); + prj.add_test( + "AfterInvariantTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {Counter} from "../src/Counter.sol"; + +contract AfterInvariantTest is Test { + Counter public counter; + + function setUp() public { + counter = new Counter(); + targetContract(address(counter)); + } + + // Breaks early in run 1. + function invariant_first() public view { + require(counter.cond() < 2, "first broken"); + } + + // Never breaks; keeps the campaign alive past run 1. + function invariant_second() public view { + require(counter.cond() < 1000000, "second broken"); + } + + // Always reverts; only reached on later runs if the hook isn't gated campaign-wide. + function afterInvariant() public pure { + require(false, "after_invariant_marker"); + } +} + "#, + ); + + cmd.args(["test", "--mt", "invariant_"]).assert_failure().stdout_eq(str![[r#" +... +[FAIL: after_invariant_marker] invariant_first +... +"#]]); +} + +// Verifies a stale persisted secondary failure (settings have changed since it was written) is +// not silently dropped from the campaign — the secondary is re-evaluated instead. +#[forgetest_init] +fn secondary_persisted_revalidates_on_settings_change(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 5; + config.invariant.depth = 50; + config.invariant.fail_on_revert = false; + }); + prj.add_source( + "Counter.sol", + r#" +contract Counter { + uint256 public cond; + + function inc() public { + cond++; + } +} + "#, + ); + prj.add_test( + "StaleSecondaryTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {Counter} from "../src/Counter.sol"; + +contract StaleSecondaryTest is Test { + Counter public counter; + + function setUp() public { + counter = new Counter(); + targetContract(address(counter)); + } + + function invariant_first() public view { + require(counter.cond() < 2, "first broken"); + } + + function invariant_second() public view { + require(counter.cond() < 3, "second broken"); + } +} + "#, + ); + + // First run: both invariants break in the shared campaign and persist their counterexamples + // under the current settings (fail_on_revert = false). + cmd.args(["test", "--mt", "invariant_"]).assert_failure(); + + // Flip a tracked InvariantSettings field so the persisted secondary cache is now stale. + prj.update_config(|config| { + config.invariant.fail_on_revert = true; + }); + + // Re-run the shared campaign. With the fix, the stale secondary cache is rejected + // and `invariant_second` is re-evaluated — the suite roll-up shows 2/2 broken. With the + // bug, the bare `.exists()` check filtered the secondary out and only the primary block + // would render (no roll-up). + cmd.forge_fuse().args(["test", "--mt", "invariant_"]).assert_failure().stdout_eq(str![[r#" +No files changed, compilation skipped + +Ran 1 test for test/StaleSecondaryTest.t.sol:StaleSecondaryTest +[FAIL: first broken] invariant_first + [Sequence] (original: 2, shrunk: 2) + sender=[..] addr=[src/Counter.sol:Counter]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=inc() args=[] + sender=[..] addr=[src/Counter.sol:Counter]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=inc() args=[] + +[FAIL: second broken] invariant_second + [Sequence] (original: 3, shrunk: 3) + sender=[..] addr=[src/Counter.sol:Counter]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=inc() args=[] + sender=[..] addr=[src/Counter.sol:Counter]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=inc() args=[] + sender=[..] addr=[src/Counter.sol:Counter]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=inc() args=[] + +StaleSecondaryTest invariants: 2/2 invariants broken +[FAIL: first broken] invariant_first +[FAIL: second broken] invariant_second +2 invariant failure(s) persisted to [..]/cache/invariant/failures/StaleSecondaryTest — rerun to shrink + StaleSecondaryTest invariants (runs: 1, calls: 3, reverts: 0) + +╭----------+----------+-------+---------+----------╮ +| Contract | Selector | Calls | Reverts | Discards | ++==================================================+ +| Counter | inc | 3 | 0 | 0 | +╰----------+----------+-------+---------+----------╯ + +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/StaleSecondaryTest.t.sol:StaleSecondaryTest +[FAIL: first broken] invariant_first + [Sequence] (original: 2, shrunk: 2) + sender=[..] addr=[src/Counter.sol:Counter]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=inc() args=[] + sender=[..] addr=[src/Counter.sol:Counter]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=inc() args=[] + +[FAIL: second broken] invariant_second + [Sequence] (original: 3, shrunk: 3) + sender=[..] addr=[src/Counter.sol:Counter]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=inc() args=[] + sender=[..] addr=[src/Counter.sol:Counter]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=inc() args=[] + sender=[..] addr=[src/Counter.sol:Counter]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=inc() args=[] + +StaleSecondaryTest invariants: 2/2 invariants broken +[FAIL: first broken] invariant_first +[FAIL: second broken] invariant_second +2 invariant failure(s) persisted to [..]/cache/invariant/failures/StaleSecondaryTest — rerun to shrink + StaleSecondaryTest invariants (runs: 1, calls: 3, reverts: 0) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]]); +} + +// Verifies that a persisted secondary failure does not suppress the current predicate after its +// implementation changes. +#[forgetest_init] +fn secondary_persisted_revalidates_after_code_change(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 3; + config.invariant.shrink_run_limit = 0; + config.invariant.show_metrics = false; + }); + prj.add_source( + "Counter.sol", + r#" +contract Counter { + uint256 public cond; + + function inc() public { + cond++; + } +} + "#, + ); + let test = r#" +import {Test} from "forge-std/Test.sol"; +import {Counter} from "../src/Counter.sol"; + +contract PersistedSecondaryTest is Test { + Counter public counter; + + function setUp() public { + counter = new Counter(); + targetContract(address(counter)); + } + + function invariant_anchor() public pure { + require(true); + } + + function invariant_secondary() public view { + require(counter.cond() < 2, "old secondary"); + } +} + "#; + prj.add_test("PersistedSecondaryTest.t.sol", test); + + cmd.args(["test", "--mt", "invariant_"]).assert_failure(); + let persisted = prj + .root() + .join("cache/invariant/failures/PersistedSecondaryTest/invariants/invariant_secondary"); + let persisted_json: serde_json::Value = + serde_json::from_slice(&std::fs::read(&persisted).unwrap()).unwrap(); + assert_eq!(persisted_json["call_sequence"].as_array().unwrap().len(), 2); + + prj.add_test( + "PersistedSecondaryTest.t.sol", + &test.replace( + "counter.cond() < 2, \"old secondary\"", + "counter.cond() != 3, \"new secondary\"", + ), + ); + + cmd.forge_fuse().args(["test", "--mt", "invariant_"]).assert_failure().stdout_eq(str![[r#" +... +[FAIL: new secondary] invariant_secondary +... +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); + + let persisted_json: serde_json::Value = + serde_json::from_slice(&std::fs::read(persisted).unwrap()).unwrap(); + assert_eq!(persisted_json["call_sequence"].as_array().unwrap().len(), 3); +} + +// Verifies that a compatible persisted secondary failure is replayed even when the fresh +// campaign has no budget to rediscover it. +#[forgetest_init] +fn secondary_persisted_replays_without_fresh_runs(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 2; + config.invariant.shrink_run_limit = 0; + config.invariant.show_metrics = false; + }); + let test = r#" +import {Test} from "forge-std/Test.sol"; + +contract ReplayCounter { + uint256 public value; + + function inc() public { + value++; + } +} + +contract PersistedSecondaryReplayTest is Test { + ReplayCounter public counter; + + function setUp() public { + counter = new ReplayCounter(); + targetContract(address(counter)); + } + + function invariant_anchor() public pure {} + + function invariant_secondary() public view { + require(counter.value() < 2, "secondary still broken"); + } +} + "#; + prj.add_test("PersistedSecondaryReplayTest.t.sol", test); + + cmd.args(["test", "--mt", "invariant_"]).assert_failure(); + + prj.update_config(|config| { + config.invariant.runs = 0; + }); + cmd.forge_fuse().args(["test", "--mt", "invariant_"]).assert_failure().stdout_eq(str![[r#" +... +[FAIL: secondary still broken] invariant_secondary +... +PersistedSecondaryReplayTest invariants: 1/2 invariants broken +... + PersistedSecondaryReplayTest invariants (runs: 0, calls: 0, reverts: 0) +... +"#]]); + + // The same sequence now asserts in the handler while the predicate passes. It is stale as a + // predicate failure and must not be attributed to `invariant_secondary`. + prj.add_test( + "PersistedSecondaryReplayTest.t.sol", + &test.replace("value++;", "assert(false);").replace( + "require(counter.value() < 2, \"secondary still broken\");", + "require(true, \"secondary still broken\");", + ), + ); + let output = cmd.forge_fuse().args(["test", "--mt", "invariant_"]).assert_success(); + let stdout = String::from_utf8_lossy(&output.get_output().stdout); + assert!(!stdout.contains("[FAIL: secondary still broken] invariant_secondary"), "{stdout}"); +} + +// Verifies that a cached secondary sequence-call revert is attributed to that predicate when +// fail-on-revert is enabled and the fresh campaign has no budget. +#[forgetest_init] +fn secondary_persisted_replays_fail_on_revert(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 1; + config.invariant.shrink_run_limit = 0; + config.invariant.show_metrics = false; + }); + prj.add_test( + "PersistedSecondaryRevertTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract RevertingTarget { + uint256 public calls; + + function fail() public { + calls++; + revert("secondary revert"); + } +} + +contract PersistedSecondaryRevertTest is Test { + RevertingTarget public target; + + function setUp() public { + target = new RevertingTarget(); + targetContract(address(target)); + } + + /// forge-config: default.invariant.fail-on-revert = true + function invariant_anchor() public pure {} + + /// forge-config: default.invariant.fail-on-revert = true + function invariant_secondary() public pure {} +} + "#, + ); + + cmd.args(["test", "--mt", "invariant_"]).assert_failure(); + let persisted = prj.root().join( + "cache/invariant/failures/PersistedSecondaryRevertTest/invariants/invariant_secondary", + ); + assert!(persisted.is_file()); + std::fs::remove_file(persisted.with_file_name("invariant_anchor")).unwrap(); + + prj.update_config(|config| { + config.invariant.runs = 0; + }); + cmd.forge_fuse().args(["test", "--mt", "invariant_"]).assert_failure().stdout_eq(str![[r#" +... +[FAIL: secondary revert] invariant_secondary +... +PersistedSecondaryRevertTest invariants: 1/2 invariants broken +... + PersistedSecondaryRevertTest invariants (runs: 0, calls: 0, reverts: 0) +... +"#]]); +} + +// A nested callee can be recorded as the reverter for a terminal predicate failure. The +// predicate selector, rather than the reverter address, identifies the cached secondary. +#[forgetest_init] +fn secondary_persisted_replays_nested_revert(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 1; + config.invariant.shrink_run_limit = 0; + config.invariant.show_metrics = false; + }); + prj.add_test( + "PersistedSecondaryNestedRevertTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract NestedReverter { + function fail() external pure { + revert("nested predicate revert"); + } +} + +contract NestedRevertTarget { + bool public broken; + + function breakInvariant() external { + broken = true; + } +} + +contract PersistedSecondaryNestedRevertTest is Test { + NestedReverter public reverter; + NestedRevertTarget public target; + + function setUp() public { + reverter = new NestedReverter(); + target = new NestedRevertTarget(); + targetContract(address(target)); + } + + function invariant_anchor() public pure {} + + function invariant_secondary() public view { + if (target.broken()) reverter.fail(); + } +} + "#, + ); + + cmd.args(["test", "--mt", "invariant_"]).assert_failure(); + let persisted = prj.root().join( + "cache/invariant/failures/PersistedSecondaryNestedRevertTest/invariants/invariant_secondary", + ); + assert!(persisted.is_file()); + + prj.update_config(|config| { + config.invariant.runs = 0; + }); + cmd.forge_fuse().args(["test", "--mt", "invariant_"]).assert_failure().stdout_eq(str![[r#" +... +[FAIL: nested predicate revert] invariant_secondary +... +PersistedSecondaryNestedRevertTest invariants: 1/2 invariants broken +... + PersistedSecondaryNestedRevertTest invariants (runs: 0, calls: 0, reverts: 0) +... +"#]]); +} + +// Cached secondaries have already been validated against their persisted failure site. A generic +// re-shrink must not replace that predicate failure with a handler assertion. +#[forgetest_init] +fn secondary_persisted_skips_generic_reshrink(prj: _, cmd: _) { + prj.update_config(|config| { + config.fuzz.seed = Some(U256::ONE); + config.invariant.runs = 100; + config.invariant.depth = 10; + config.invariant.shrink_run_limit = 0; + config.invariant.show_metrics = false; + }); + prj.add_test( + "PersistedSecondaryShrinkTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract SiteChangingTarget { + bool public armed; + bool public broken; + + function arm() external { + armed = true; + } + + function trigger() external { + assert(armed); + broken = true; + } +} + +contract PersistedSecondaryShrinkTest is Test { + SiteChangingTarget public target; + + function setUp() public { + target = new SiteChangingTarget(); + targetContract(address(target)); + } + + function invariant_anchor() public pure {} + + function invariant_secondary() public view { + require(!target.broken(), "secondary predicate"); + } +} + "#, + ); + + cmd.args(["test", "--mt", "invariant_"]).assert_failure(); + let failure_root = prj.root().join("cache/invariant/failures/PersistedSecondaryShrinkTest"); + let persisted = failure_root.join("invariants/invariant_secondary"); + let mut persisted_json: serde_json::Value = + serde_json::from_slice(&std::fs::read(&persisted).unwrap()).unwrap(); + let calls = persisted_json["call_sequence"].as_array().unwrap(); + let arm = calls.iter().find(|call| call["func_name"] == "arm").unwrap().clone(); + let trigger = calls.iter().find(|call| call["func_name"] == "trigger").unwrap().clone(); + persisted_json["call_sequence"] = serde_json::json!([arm, trigger]); + // Legacy persisted entries did not identify their failure site. Their confirmed replay must + // still bypass generic shrinking so the predicate reason, trace, and sequence stay aligned. + persisted_json.as_object_mut().unwrap().remove("failure_site"); + std::fs::write(&persisted, serde_json::to_vec_pretty(&persisted_json).unwrap()).unwrap(); + let _ = std::fs::remove_file(persisted.with_file_name("invariant_anchor")); + let _ = std::fs::remove_dir_all(failure_root.join("handlers")); + + prj.update_config(|config| { + config.invariant.runs = 0; + config.invariant.shrink_run_limit = 100; + }); + let stdout = cmd + .forge_fuse() + .args(["test", "--mt", "invariant_"]) + .assert_failure() + .get_output() + .stdout_lossy(); + assert!(stdout.contains("[FAIL: secondary predicate] invariant_secondary"), "{stdout}"); + assert!(stdout.contains("[Sequence] (original: 2, shrunk: 2)"), "{stdout}"); + assert!(stdout.contains("calldata=arm()"), "{stdout}"); + assert!(stdout.contains("calldata=trigger()"), "{stdout}"); +} + +// Verifies that a compatible persisted secondary remains in the report while the campaign +// continues to discover another predicate failure. +#[forgetest_init] +fn secondary_persisted_continues_campaign(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 2; + config.invariant.depth = 3; + config.invariant.shrink_run_limit = 0; + config.invariant.show_metrics = false; + }); + let test = r#" +import {Test} from "forge-std/Test.sol"; + +contract Counter { + uint256 public cond; + + function inc() public { + cond++; + } +} + +contract PersistedSecondaryContinuationTest is Test { + Counter public counter; + + function setUp() public { + counter = new Counter(); + targetContract(address(counter)); + } + + function invariant_anchor() public pure { + require(true); + } + + function invariant_secondary() public view { + require(counter.cond() < 2, "secondary broken"); + } + + function invariant_later() public view { + require(counter.cond() < 100, "later broken"); + } +} + "#; + prj.add_test("PersistedSecondaryContinuationTest.t.sol", test); + + cmd.args(["test", "--mt", "invariant_"]).assert_failure(); + let persisted = prj.root().join( + "cache/invariant/failures/PersistedSecondaryContinuationTest/invariants/invariant_secondary", + ); + assert!(persisted.is_file()); + + prj.add_test( + "PersistedSecondaryContinuationTest.t.sol", + &test.replace( + "counter.cond() < 100, \"later broken\"", + "counter.cond() != 3, \"later broken\"", + ), + ); + + cmd.forge_fuse().args(["test", "--mt", "invariant_"]).assert_failure().stdout_eq(str![[r#" +... +[FAIL: secondary broken] invariant_secondary +... +[FAIL: later broken] invariant_later +... +PersistedSecondaryContinuationTest invariants: 2/3 invariants broken +... +"#]]); +} + +// Verifies that when the campaign anchor passes but another selected predicate fails, the report +// doesn't render a hollow `[FAIL]` header for the primary and the suite roll-up counts only the +// actually-broken invariants. +#[forgetest_init] +fn secondary_only_failure_no_hollow_fail(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 5; + config.invariant.depth = 50; + }); + prj.add_source( + "Counter.sol", + r#" +contract Counter { + uint256 public cond; + + function inc() public { + cond++; + } +} + "#, + ); + prj.add_test( + "SecondaryOnlyTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {Counter} from "../src/Counter.sol"; + +contract SecondaryOnlyTest is Test { + Counter public counter; + + function setUp() public { + counter = new Counter(); + targetContract(address(counter)); + } + + // Campaign anchor; never breaks. + function invariant_anchor_safe() public view { + require(counter.cond() < 1000000, "safe broken"); + } + + // Another selected predicate; breaks within the first run. + function invariant_secondary_breakable() public view { + require(counter.cond() < 2, "breakable broken"); + } + + function invariant_third_safe() public view { + require(counter.cond() < 1000000, "third safe broken"); + } + + function invariant_fourth_safe() public view { + require(counter.cond() < 1000000, "fourth safe broken"); + } +} + "#, + ); + + let output = cmd.args(["test", "--mt", "invariant_"]).assert_failure(); + let stdout = String::from_utf8_lossy(&output.get_output().stdout); + assert!(stdout.contains("[FAIL: breakable broken] invariant_secondary_breakable"), "{stdout}"); + assert!(stdout.contains("SecondaryOnlyTest invariants: 1/4 invariants broken"), "{stdout}"); + assert!(stdout.contains("[PASS] invariant_anchor_safe"), "{stdout}"); + assert!(stdout.contains("[PASS] invariant_third_safe"), "{stdout}"); + assert!(stdout.contains("[PASS] invariant_fourth_safe"), "{stdout}"); + assert!(stdout.contains(" SecondaryOnlyTest invariants (runs: 5, calls: 250, reverts: 0)")); + assert_eq!( + stdout.matches("| Contract | Selector | Calls | Reverts | Discards |").count(), + 1, + "{stdout}" + ); + assert!(!stdout.contains(" invariant_anchor_safe() (runs:"), "{stdout}"); + assert!(!stdout.contains("[FAIL: safe broken] invariant_anchor_safe"), "{stdout}"); +} + +// Verifies `forge test --rerun` records the predicate that actually failed inside a merged +// campaign, not just the campaign anchor. Otherwise a secondary-only failure would be rerun as +// the passing anchor and incorrectly succeed. +#[forgetest_init] +fn rerun_replays_non_anchor_invariant_failure(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 5; + config.invariant.depth = 50; + }); + prj.add_source( + "Counter.sol", + r#" +contract Counter { + uint256 public cond; + + function inc() public { + cond++; + } +} + "#, + ); + prj.add_test( + "RerunSecondaryOnlyTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {Counter} from "../src/Counter.sol"; + +contract RerunSecondaryOnlyTest is Test { + Counter public counter; + + function setUp() public { + counter = new Counter(); + targetContract(address(counter)); + } + + function invariant_anchor_safe() public view { + require(counter.cond() < 1000000, "safe broken"); + } + + function invariant_secondary_breakable() public view { + require(counter.cond() < 2, "breakable broken"); + } +} + "#, + ); + + cmd.args(["test", "--mt", "invariant_"]).assert_failure(); + + let test_failures = std::fs::read_to_string(prj.root().join("cache/test-failures")).unwrap(); + assert!(test_failures.contains("invariant_secondary_breakable"), "{test_failures}"); + assert!(!test_failures.contains("invariant_anchor_safe"), "{test_failures}"); + + let output = cmd.forge_fuse().args(["test", "--rerun"]).assert_failure(); + let stdout = String::from_utf8_lossy(&output.get_output().stdout); + assert!(stdout.contains("[FAIL: breakable broken]"), "{stdout}"); + assert!(stdout.contains(" invariant_secondary_breakable() (runs:"), "{stdout}"); + assert!(!stdout.contains("invariant_anchor_safe"), "{stdout}"); +} + +// Verifies the structured JSON failure event emitted at campaign end attributes the broken +// invariant in declaration order (deterministic) instead of using arbitrary HashMap iteration. +#[forgetest_init] +fn failure_event_uses_declaration_order(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 5; + }); + prj.add_source( + "Counter.sol", + r#" +contract Counter { + uint256 public cond; + + function inc() public { + cond++; + } +} + "#, + ); + prj.add_test( + "FailureEventTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {Counter} from "../src/Counter.sol"; + +contract FailureEventTest is Test { + Counter public counter; + + function setUp() public { + counter = new Counter(); + targetContract(address(counter)); + } + + // Declaration-order: a, b, c. All break on the same call. + function invariant_a() public view { + require(counter.cond() < 1, "a broken"); + } + + function invariant_b() public view { + require(counter.cond() < 1, "b broken"); + } + + function invariant_c() public view { + require(counter.cond() < 1, "c broken"); + } +} + "#, + ); + + // The event must name `invariant_a` (first declared broken invariant) with its matching + // reason — not whichever entry HashMap iteration surfaces. + cmd.args(["test", "--mt", "invariant_"]).assert_failure().stderr_eq(str![[r#" +... +{"timestamp":[..],"event":"failure","invariant":"invariant_a","target":"test/FailureEventTest.t.sol:FailureEventTest","reason":"a broken"} +... +"#]]); +} + +// Persisted corpus entries must not replay calls the current invariant settings no longer allow. +#[forgetest_init] +fn invariant_corpus_respects_current_senders_and_selectors(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 32; + config.invariant.depth = 20; + config.invariant.workers = + foundry_config::InvariantWorkers::Fixed(std::num::NonZeroUsize::new(1).unwrap()); + config.invariant.corpus.corpus_dir = Some("invariant_corpus".into()); + config.invariant.corpus.corpus_gzip = false; + }); + let add_test = |sender: &str, selector: &str| { + let touch_targeted = selector == "touch"; + prj.add_test( + "CorpusPolicyTest.t.sol", + &format!( + r#" +import {{Test}} from "forge-std/Test.sol"; + +contract CorpusPolicyTarget {{ + address public unexpected; + + function touch() external {{ + if (msg.sender != {sender} || !{touch_targeted}) unexpected = msg.sender; + }} + + function other() external {{ + if (msg.sender != {sender} || {touch_targeted}) unexpected = msg.sender; + }} +}} + +contract CorpusPolicyTest is Test {{ + CorpusPolicyTarget target; + + function setUp() public {{ + target = new CorpusPolicyTarget(); + targetSender({sender}); + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = CorpusPolicyTarget.{selector}.selector; + targetSelector(FuzzSelector({{addr: address(target), selectors: selectors}})); + }} + + function invariant_only_current_policy() public view {{ + require(target.unexpected() == address(0), "stale corpus call"); + }} +}} + "# + ), + ); + }; + + add_test("address(0xA11CE)", "touch"); + cmd.args(["test", "--mc", "CorpusPolicyTest"]).assert_success(); + + // Changing only the sender must not replay calls from the previous sender. + add_test("address(0xB0B)", "touch"); + cmd.forge_fuse().args(["test", "--mc", "CorpusPolicyTest"]).assert_success(); + + // Changing only the selector must not replay calls to the previous selector. + add_test("address(0xB0B)", "other"); + cmd.forge_fuse().args(["test", "--mc", "CorpusPolicyTest"]).assert_success().stdout_eq(str![[ + r#" +... +Ran 1 test for test/CorpusPolicyTest.t.sol:CorpusPolicyTest +[PASS] invariant_only_current_policy() (runs: 32, calls: 640, reverts: 0, failed corpus replays: 2) + +╭--------------------+----------+-------+---------+----------╮ +| Contract | Selector | Calls | Reverts | Discards | ++============================================================+ +| CorpusPolicyTarget | other | 640 | 0 | 0 | +╰--------------------+----------+-------+---------+----------╯ + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"# + ]]); +} + +// Calls hoisted from handler sub-calls must respect `targetSenders`. +#[forgetest_init] +fn invariant_corpus_hoisting_respects_target_senders(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.runs = 32; + config.invariant.depth = 20; + config.invariant.workers = + foundry_config::InvariantWorkers::Fixed(std::num::NonZeroUsize::new(1).unwrap()); + config.invariant.corpus.corpus_dir = Some("invariant_corpus".into()); + }); + prj.add_test( + "HoistSenderTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract HoistSenderToken { + address public unexpected; + + function touch() external { + if (tx.origin != address(0xA11CE)) unexpected = tx.origin; + } +} + +contract HoistSenderHandler { + HoistSenderToken token; + + constructor(HoistSenderToken _token) { + token = _token; + } + + function poke() external { + token.touch(); + } +} + +contract HoistSenderTest is Test { + HoistSenderToken token; + + function setUp() public { + token = new HoistSenderToken(); + targetContract(address(token)); + targetContract(address(new HoistSenderHandler(token))); + targetSender(address(0xA11CE)); + } + + function invariant_only_target_sender() public view { + require(token.unexpected() == address(0), "unexpected sender"); + } +} + "#, + ); + + cmd.args(["test", "--mc", "HoistSenderTest"]).assert_success().stdout_eq(str![[r#" +... +Ran 1 test for test/HoistSenderTest.t.sol:HoistSenderTest +[PASS] invariant_only_target_sender() (runs: 32, calls: 640, reverts: 0) +... +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} diff --git a/crates/forge/tests/cli/test_cmd/invariant/storage.rs b/crates/forge/tests/cli/test_cmd/invariant/storage.rs new file mode 100644 index 0000000000000..04d11939fdc58 --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/invariant/storage.rs @@ -0,0 +1,128 @@ +use super::*; + +#[forgetest_init] +fn storage(prj: _, cmd: _) { + prj.add_test( + "name", + r#" +import "forge-std/Test.sol"; + +contract Contract { + address public addr = address(0xbeef); + string public str = "hello"; + uint256 public num = 1337; + uint256 public pushNum; + + function changeAddress(address _addr) public { + if (_addr == addr) { + addr = address(0); + } + } + + function changeString(string memory _str) public { + if (keccak256(bytes(_str)) == keccak256(bytes(str))) { + str = ""; + } + } + + function changeUint(uint256 _num) public { + if (_num == num) { + num = 0; + } + } + + function push(uint256 _num) public { + if (_num == 68) { + pushNum = 69; + } + } +} + +contract InvariantStorageTest is Test { + Contract c; + + function setUp() public { + c = new Contract(); + } + + function invariantChangeAddress() public view { + require(c.addr() == address(0xbeef), "changedAddr"); + } + + function invariantChangeString() public view { + require(keccak256(bytes(c.str())) == keccak256(bytes("hello")), "changedStr"); + } + + function invariantChangeUint() public view { + require(c.num() == 1337, "changedUint"); + } + + function invariantPush() public view { + require(c.pushNum() == 0, "pushUint"); + } +} +"#, + ); + + assert_invariant(cmd.args(["test"])).failure().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/name.sol:InvariantStorageTest +[FAIL: changedAddr] invariantChangeAddress + [SEQUENCE] + +[FAIL: changedStr] invariantChangeString + [SEQUENCE] + +[FAIL: changedUint] invariantChangeUint + [SEQUENCE] + +[FAIL: pushUint] invariantPush + [SEQUENCE] + +InvariantStorageTest invariants: 4/4 invariants broken +[FAIL: changedAddr] invariantChangeAddress +[FAIL: changedStr] invariantChangeString +[FAIL: changedUint] invariantChangeUint +[FAIL: pushUint] invariantPush +4 invariant failure(s) persisted to cache/invariant/failures/InvariantStorageTest — rerun to shrink + InvariantStorageTest invariants ([RUNS]) + +[STATS] + +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/name.sol:InvariantStorageTest +[FAIL: changedAddr] invariantChangeAddress + [SEQUENCE] + +[FAIL: changedStr] invariantChangeString + [SEQUENCE] + +[FAIL: changedUint] invariantChangeUint + [SEQUENCE] + +[FAIL: pushUint] invariantPush + [SEQUENCE] + +InvariantStorageTest invariants: 4/4 invariants broken +[FAIL: changedAddr] invariantChangeAddress +[FAIL: changedStr] invariantChangeString +[FAIL: changedUint] invariantChangeUint +[FAIL: pushUint] invariantPush +4 invariant failure(s) persisted to cache/invariant/failures/InvariantStorageTest — rerun to shrink + InvariantStorageTest invariants ([RUNS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]]); +} diff --git a/crates/forge/tests/cli/test_cmd/invariant/target.rs b/crates/forge/tests/cli/test_cmd/invariant/target.rs new file mode 100644 index 0000000000000..2cc21a5086902 --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/invariant/target.rs @@ -0,0 +1,867 @@ +use super::*; + +#[forgetest] +fn filters(prj: _, cmd: _) { + prj.insert_vm(); + prj.insert_ds_test(); + prj.update_config(|config| { + config.invariant.runs = 50; + config.invariant.depth = 10; + }); + + prj.add_test( + "ExcludeContracts.t.sol", + r#" +import { DSTest as Test } from "src/test.sol"; + +contract Hello { + bool public world = true; + + function change() public { + world = false; + } +} + +contract ExcludeContracts is Test { + Hello hello; + + function setUp() public { + hello = new Hello(); + new Hello(); + } + + function excludeContracts() public view returns (address[] memory) { + address[] memory addrs = new address[](1); + addrs[0] = address(hello); + return addrs; + } + + function invariantTrueWorld() public { + require(hello.world() == true, "false world"); + } +} +"#, + ); + + prj.add_test( + "ExcludeSelectors.t.sol", + r#" +import { DSTest as Test } from "src/test.sol"; + +struct FuzzSelector { + address addr; + bytes4[] selectors; +} + +contract Hello { + bool public world = false; + + function change() public { + world = true; + } + + function real_change() public { + world = false; + } +} + +contract ExcludeSelectors is Test { + Hello hello; + + function setUp() public { + hello = new Hello(); + } + + function excludeSelectors() public view returns (FuzzSelector[] memory) { + FuzzSelector[] memory targets = new FuzzSelector[](1); + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = Hello.change.selector; + targets[0] = FuzzSelector(address(hello), selectors); + return targets; + } + + function invariantFalseWorld() public { + require(hello.world() == false, "true world"); + } +} +"#, + ); + + prj.add_test( + "ExcludeSenders.t.sol", + r#" +import { DSTest as Test } from "src/test.sol"; + +contract Hello { + address seed_address = address(0xdeadbeef); + bool public world = true; + + function changeBeef() public { + require(msg.sender == address(0xdeadbeef)); + world = false; + } + + // address(0) should be automatically excluded + function change0() public { + require(msg.sender == address(0)); + world = false; + } +} + +contract ExcludeSenders is Test { + Hello hello; + + function setUp() public { + hello = new Hello(); + } + + function excludeSenders() public view returns (address[] memory) { + address[] memory addrs = new address[](1); + addrs[0] = address(0xdeadbeef); + return addrs; + } + + // Tests clashing. Exclusion takes priority. + function targetSenders() public view returns (address[] memory) { + address[] memory addrs = new address[](1); + addrs[0] = address(0xdeadbeef); + return addrs; + } + + function invariantTrueWorld() public { + require(hello.world() == true, "false world"); + } +} +"#, + ); + + prj.add_test( + "TargetContracts.t.sol", + r#" +import { DSTest as Test } from "src/test.sol"; + +contract Hello { + bool public world = true; + + function change() public { + world = false; + } +} + +contract TargetContracts is Test { + Hello hello1; + Hello hello2; + + function setUp() public { + hello1 = new Hello(); + hello2 = new Hello(); + } + + function targetContracts() public view returns (address[] memory) { + address[] memory addrs = new address[](1); + addrs[0] = address(hello1); + return addrs; + } + + function invariantTrueWorld() public { + require(hello2.world() == true, "false world"); + } +} +"#, + ); + + prj.add_test( + "TargetInterfaces.t.sol", + r#" +import { DSTest as Test } from "src/test.sol"; + +struct FuzzInterface { + address target; + string[] artifacts; +} + +contract Hello { + bool public world; + + function changeWorld() external { + world = true; + } +} + +interface IHello { + function world() external view returns (bool); + function changeWorld() external; +} + +contract HelloProxy { + address internal immutable _implementation; + + constructor(address implementation_) { + _implementation = implementation_; + } + + function _delegate(address implementation) internal { + assembly { + calldatacopy(0, 0, calldatasize()) + + let result := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0) + + returndatacopy(0, 0, returndatasize()) + + switch result + case 0 { revert(0, returndatasize()) } + default { return(0, returndatasize()) } + } + } + + fallback() external payable { + _delegate(_implementation); + } +} + +contract TargetWorldInterfaces is Test { + IHello proxy; + + function setUp() public { + Hello hello = new Hello(); + proxy = IHello(address(new HelloProxy(address(hello)))); + } + + function targetInterfaces() public view returns (FuzzInterface[] memory) { + FuzzInterface[] memory targets = new FuzzInterface[](1); + + string[] memory artifacts = new string[](1); + artifacts[0] = "IHello"; + + targets[0] = FuzzInterface(address(proxy), artifacts); + + return targets; + } + + function invariantTrueWorld() public { + require(proxy.world() == false, "false world"); + } +} +"#, + ); + + prj.add_test( + "TargetSelectors.t.sol", + r#" +import { DSTest as Test } from "src/test.sol"; + +struct FuzzSelector { + address addr; + bytes4[] selectors; +} + +contract Hello { + bool public world = true; + + function change() public { + world = true; + } + + function real_change() public { + world = false; + } +} + +contract TargetSelectors is Test { + Hello hello; + + function setUp() public { + hello = new Hello(); + } + + function targetSelectors() public view returns (FuzzSelector[] memory) { + FuzzSelector[] memory targets = new FuzzSelector[](1); + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = Hello.change.selector; + targets[0] = FuzzSelector(address(hello), selectors); + return targets; + } + + function invariantTrueWorld() public { + require(hello.world() == true, "false world"); + } +} +"#, + ); + + prj.add_test( + "TargetSenders.t.sol", + r#" +import { DSTest as Test } from "src/test.sol"; + +contract Hello { + bool public world = true; + + function change() public { + require(msg.sender == address(0xdeadbeef)); + world = false; + } +} + +contract TargetSenders is Test { + Hello hello; + + function setUp() public { + hello = new Hello(); + } + + function targetSenders() public view returns (address[] memory) { + address[] memory addrs = new address[](1); + addrs[0] = address(0xdeadbeef); + return addrs; + } + + function invariantTrueWorld() public { + require(hello.world() == true, "false world"); + } +} +"#, + ); + + prj.add_test( + "ExcludeArtifacts.t.sol", + r#" +import { DSTest as Test } from "src/test.sol"; + +// Will get automatically excluded. Otherwise it would throw error. +contract NoMutFunctions { + function no_change() public pure {} +} + +contract Excluded { + bool public world = true; + + function change() public { + world = false; + } +} + +contract Hello { + bool public world = true; + + function change() public { + world = false; + } +} + +contract ExcludeArtifacts is Test { + Excluded excluded; + + function setUp() public { + excluded = new Excluded(); + new Hello(); + new NoMutFunctions(); + } + + function excludeArtifacts() public returns (string[] memory) { + string[] memory abis = new string[](1); + abis[0] = "test/ExcludeArtifacts.t.sol:Excluded"; + return abis; + } + + function invariantShouldPass() public { + require(excluded.world() == true, "false world"); + } +} +"#, + ); + + prj.add_test( + "TargetArtifactSelectors2.t.sol", + r#" +import { DSTest as Test } from "src/test.sol"; + +struct FuzzArtifactSelector { + string artifact; + bytes4[] selectors; +} + +contract Parent { + bool public should_be_true = true; + address public child; + + function change() public { + child = msg.sender; + should_be_true = false; + } + + function create() public { + new Child(); + } +} + +contract Child { + Parent parent; + bool public changed = false; + + constructor() { + parent = Parent(msg.sender); + } + + function change_parent() public { + parent.change(); + } + + function tracked_change_parent() public { + parent.change(); + } +} + +contract TargetArtifactSelectors2 is Test { + Parent parent; + + function setUp() public { + parent = new Parent(); + } + + function targetArtifactSelectors() public returns (FuzzArtifactSelector[] memory) { + FuzzArtifactSelector[] memory targets = new FuzzArtifactSelector[](2); + bytes4[] memory selectors_child = new bytes4[](1); + + selectors_child[0] = Child.change_parent.selector; + targets[0] = FuzzArtifactSelector( + "test/TargetArtifactSelectors2.t.sol:Child", selectors_child + ); + + bytes4[] memory selectors_parent = new bytes4[](1); + selectors_parent[0] = Parent.create.selector; + targets[1] = FuzzArtifactSelector( + "test/TargetArtifactSelectors2.t.sol:Parent", selectors_parent + ); + return targets; + } + + function invariantShouldFail() public { + if (!parent.should_be_true()) { + require(!Child(address(parent.child())).changed(), "should have not happened"); + } + require(parent.should_be_true() == true, "it's false"); + } +} +"#, + ); + + prj.add_test( + "TargetArtifactSelectors.t.sol", + r#" +import { DSTest as Test } from "src/test.sol"; + +struct FuzzArtifactSelector { + string artifact; + bytes4[] selectors; +} + +contract Hi { + bool public world = true; + + function no_change() public { + world = true; + } + + function change() public { + world = false; + } +} + +contract TargetArtifactSelectors is Test { + Hi hello; + + function setUp() public { + hello = new Hi(); + } + + function targetArtifactSelectors() public returns (FuzzArtifactSelector[] memory) { + FuzzArtifactSelector[] memory targets = new FuzzArtifactSelector[](1); + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = Hi.no_change.selector; + targets[0] = + FuzzArtifactSelector("test/TargetArtifactSelectors.t.sol:Hi", selectors); + return targets; + } + + function invariantShouldPass() public { + require(hello.world() == true, "false world"); + } +} +"#, + ); + + prj.add_test( + "TargetArtifacts.t.sol", + r#" +import { DSTest as Test } from "src/test.sol"; + +contract Targeted { + bool public world = true; + + function change() public { + world = false; + } +} + +contract Hello { + bool public world = true; + + function no_change() public {} +} + +contract TargetArtifacts is Test { + Targeted target1; + Targeted target2; + Hello hello; + + function setUp() public { + target1 = new Targeted(); + target2 = new Targeted(); + hello = new Hello(); + } + + function targetArtifacts() public returns (string[] memory) { + string[] memory abis = new string[](1); + abis[0] = "test/TargetArtifacts.t.sol:Targeted"; + return abis; + } + + function invariantShouldPass() public { + require(target2.world() == true || target1.world() == true || hello.world() == true, "false world"); + } + + function invariantShouldFail() public { + require(target2.world() == true || target1.world() == true, "false world"); + } +} +"#, + ); + + // Test ExcludeContracts + assert_invariant(cmd.forge_fuse().args(["test", "--mc", "ExcludeContracts"])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/ExcludeContracts.t.sol:ExcludeContracts +[PASS] invariantTrueWorld() ([RUNS]) + +[STATS] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); + + // Test ExcludeSelectors + assert_invariant(cmd.forge_fuse().args(["test", "--mc", "ExcludeSelectors"])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/ExcludeSelectors.t.sol:ExcludeSelectors +[PASS] invariantFalseWorld() ([RUNS]) + +[STATS] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); + + // Test ExcludeSenders + assert_invariant(cmd.forge_fuse().args(["test", "--mc", "ExcludeSenders"])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/ExcludeSenders.t.sol:ExcludeSenders +[PASS] invariantTrueWorld() ([RUNS]) + +[STATS] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); + + // Test TargetContracts + assert_invariant(cmd.forge_fuse().args(["test", "--mc", "TargetContracts"])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/TargetContracts.t.sol:TargetContracts +[PASS] invariantTrueWorld() ([RUNS]) + +[STATS] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); + + // Test TargetInterfaces (should fail) + assert_invariant(cmd.forge_fuse().args(["test", "--mc", "TargetWorldInterfaces"])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/TargetInterfaces.t.sol:TargetWorldInterfaces +[FAIL: false world] + [SEQUENCE] + invariantTrueWorld() ([RUNS]) + +[STATS] + +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/TargetInterfaces.t.sol:TargetWorldInterfaces +[FAIL: false world] + [SEQUENCE] + invariantTrueWorld() ([RUNS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]]); + + // Test TargetSelectors + assert_invariant(cmd.forge_fuse().args(["test", "--mc", "TargetSelectors"])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/TargetSelectors.t.sol:TargetSelectors +[PASS] invariantTrueWorld() ([RUNS]) + +[STATS] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); + + // Test TargetSenders (should fail) + assert_invariant(cmd.forge_fuse().args(["test", "--mc", "TargetSenders"])).failure().stdout_eq( + str![[r#" +... +Ran 1 test for test/TargetSenders.t.sol:TargetSenders +[FAIL: false world] + [SEQUENCE] + invariantTrueWorld() ([RUNS]) + +[STATS] + +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/TargetSenders.t.sol:TargetSenders +[FAIL: false world] + [SEQUENCE] + invariantTrueWorld() ([RUNS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]], + ); + + // Test ExcludeArtifacts + assert_invariant(cmd.forge_fuse().args(["test", "--mc", "ExcludeArtifacts"])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/ExcludeArtifacts.t.sol:ExcludeArtifacts +[PASS] invariantShouldPass() ([RUNS]) + +[STATS] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); + + // Test TargetArtifactSelectors2 (should fail) + assert_invariant(cmd.forge_fuse().args(["test", "--mc", "TargetArtifactSelectors2"])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/TargetArtifactSelectors2.t.sol:TargetArtifactSelectors2 +[FAIL: it's false] + [SEQUENCE] + invariantShouldFail() ([RUNS]) + +[STATS] + +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/TargetArtifactSelectors2.t.sol:TargetArtifactSelectors2 +[FAIL: it's false] + [SEQUENCE] + invariantShouldFail() ([RUNS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]]); + + // Test TargetArtifactSelectors + assert_invariant(cmd.forge_fuse().args(["test", "--mc", "^TargetArtifactSelectors$"])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/TargetArtifactSelectors.t.sol:TargetArtifactSelectors +[PASS] invariantShouldPass() ([RUNS]) + +[STATS] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); + + // Test TargetArtifacts + assert_invariant(cmd.forge_fuse().args(["test", "--mc", "^TargetArtifacts$"])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/TargetArtifacts.t.sol:TargetArtifacts +[FAIL: false world] invariantShouldFail + [SEQUENCE] + +TargetArtifacts invariants: 1/2 invariants broken +[FAIL: false world] invariantShouldFail +[PASS] invariantShouldPass + TargetArtifacts invariants ([RUNS]) + +[STATS] + +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/TargetArtifacts.t.sol:TargetArtifacts +[FAIL: false world] invariantShouldFail + [SEQUENCE] + +TargetArtifacts invariants: 1/2 invariants broken +[FAIL: false world] invariantShouldFail +[PASS] invariantShouldPass + TargetArtifacts invariants ([RUNS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]]); +} + +// https://github.com/foundry-rs/foundry/issues/5625 +// https://github.com/foundry-rs/foundry/issues/6166 +// `Target.wrongSelector` is not called when handler added as `targetContract` +// `Target.wrongSelector` is called (and test fails) when no `targetContract` set +#[forgetest] +fn fuzzed_selected_targets(prj: _, cmd: _) { + prj.insert_vm(); + prj.insert_ds_test(); + prj.update_config(|config| { + config.invariant.depth = 10; + config.invariant.fail_on_revert = true; + }); + + prj.add_test( + "FuzzedTargetContracts.t.sol", + r#" +import { DSTest as Test } from "src/test.sol"; +import "src/Vm.sol"; + +contract Target { + uint256 count; + + function wrongSelector() external { + revert("wrong target selector called"); + } + + function goodSelector() external { + count++; + } +} + +contract Handler is Test { + function increment() public { + Target(0x6B175474E89094C44Da98b954EedeAC495271d0F).goodSelector(); + } +} + +contract ExplicitTargetContract is Test { + Vm constant vm = Vm(HEVM_ADDRESS); + Handler handler; + + function setUp() public { + Target target = new Target(); + bytes memory targetCode = address(target).code; + vm.etch(address(0x6B175474E89094C44Da98b954EedeAC495271d0F), targetCode); + + handler = new Handler(); + } + + function targetContracts() public view returns (address[] memory) { + address[] memory addrs = new address[](1); + addrs[0] = address(handler); + return addrs; + } + + function invariant_explicit_target() public {} +} + +contract DynamicTargetContract is Test { + Vm constant vm = Vm(HEVM_ADDRESS); + Handler handler; + + function setUp() public { + Target target = new Target(); + bytes memory targetCode = address(target).code; + vm.etch(address(0x6B175474E89094C44Da98b954EedeAC495271d0F), targetCode); + + handler = new Handler(); + } + + function invariant_dynamic_targets() public {} +} +"#, + ); + + assert_invariant(cmd.args(["test", "-j1"])).failure().stdout_eq(str![[r#" +... +[PASS] invariant_explicit_target() ([RUNS]) +... +[FAIL: wrong target selector called] + [SEQUENCE] + invariant_dynamic_targets() ([RUNS]) +... + +"#]]); +} diff --git a/crates/forge/tests/cli/test_cmd/logs.rs b/crates/forge/tests/cli/test_cmd/logs.rs new file mode 100644 index 0000000000000..1f19d0b5b35c7 --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/logs.rs @@ -0,0 +1,901 @@ +//! Tests for various logging functionality + +use foundry_test_utils::str; + +#[forgetest_init] +fn debug_logs(prj: _, cmd: _) { + prj.add_test( + "DebugLogs.t.sol", + r#" +import "forge-std/Test.sol"; + +contract DebugLogsTest is Test { + constructor() { + emit log_uint(0); + } + + function setUp() public { + emit log_uint(1); + } + + function test1() public { + emit log_uint(2); + } + + function test2() public { + emit log_uint(3); + } + + function testRevertIfWithRevert() public { + Fails fails = new Fails(); + emit log_uint(4); + vm.expectRevert(); + fails.failure(); + } + + /// forge-config: default.allow_internal_expect_revert = true + function testRevertIfWithRequire() public { + emit log_uint(5); + vm.expectRevert(); + require(false); + } + + function testLog() public { + emit log("Error: Assertion Failed"); + } + + function testLogs() public { + emit logs(bytes("abcd")); + } + + function testLogAddress() public { + emit log_address(address(1)); + } + + function testLogBytes32() public { + emit log_bytes32(bytes32("abcd")); + } + + function testLogInt() public { + emit log_int(int256(-31337)); + } + + function testLogBytes() public { + emit log_bytes(bytes("abcd")); + } + + function testLogString() public { + emit log_string("here"); + } + + function testLogNamedAddress() public { + emit log_named_address("address", address(1)); + } + + function testLogNamedBytes32() public { + emit log_named_bytes32("abcd", bytes32("abcd")); + } + + function testLogNamedDecimalInt() public { + emit log_named_decimal_int("amount", int256(-31337), uint256(18)); + } + + function testLogNamedDecimalUint() public { + emit log_named_decimal_uint("amount", uint256(1 ether), uint256(18)); + } + + function testLogNamedInt() public { + emit log_named_int("amount", int256(-31337)); + } + + function testLogNamedUint() public { + emit log_named_uint("amount", uint256(1 ether)); + } + + function testLogNamedBytes() public { + emit log_named_bytes("abcd", bytes("abcd")); + } + + function testLogNamedString() public { + emit log_named_string("key", "val"); + } +} + +contract Fails is Test { + function failure() public { + emit log_uint(100); + revert(); + } +} +"#, + ); + + cmd.args(["test", "-vv"]).assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 19 tests for test/DebugLogs.t.sol:DebugLogsTest +[PASS] test1() ([GAS]) +Logs: + 0 + 1 + 2 + +[PASS] test2() ([GAS]) +Logs: + 0 + 1 + 3 + +[PASS] testLog() ([GAS]) +Logs: + 0 + 1 + Error: Assertion Failed + +[PASS] testLogAddress() ([GAS]) +Logs: + 0 + 1 + 0x0000000000000000000000000000000000000001 + +[PASS] testLogBytes() ([GAS]) +Logs: + 0 + 1 + 0x61626364 + +[PASS] testLogBytes32() ([GAS]) +Logs: + 0 + 1 + 0x6162636400000000000000000000000000000000000000000000000000000000 + +[PASS] testLogInt() ([GAS]) +Logs: + 0 + 1 + -31337 + +[PASS] testLogNamedAddress() ([GAS]) +Logs: + 0 + 1 + address: 0x0000000000000000000000000000000000000001 + +[PASS] testLogNamedBytes() ([GAS]) +Logs: + 0 + 1 + abcd: 0x61626364 + +[PASS] testLogNamedBytes32() ([GAS]) +Logs: + 0 + 1 + abcd: 0x6162636400000000000000000000000000000000000000000000000000000000 + +[PASS] testLogNamedDecimalInt() ([GAS]) +Logs: + 0 + 1 + amount: -0.000000000000031337 + +[PASS] testLogNamedDecimalUint() ([GAS]) +Logs: + 0 + 1 + amount: 1.000000000000000000 + +[PASS] testLogNamedInt() ([GAS]) +Logs: + 0 + 1 + amount: -31337 + +[PASS] testLogNamedString() ([GAS]) +Logs: + 0 + 1 + key: val + +[PASS] testLogNamedUint() ([GAS]) +Logs: + 0 + 1 + amount: 1000000000000000000 + +[PASS] testLogString() ([GAS]) +Logs: + 0 + 1 + here + +[PASS] testLogs() ([GAS]) +Logs: + 0 + 1 + 0x61626364 + +[PASS] testRevertIfWithRequire() ([GAS]) +Logs: + 0 + 1 + 5 + +[PASS] testRevertIfWithRevert() ([GAS]) +Logs: + 0 + 1 + 4 + 100 + +Suite result: ok. 19 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 19 tests passed, 0 failed, 0 skipped (19 total tests) + +"#]]); +} + +#[forgetest_init] +fn hardhat_logs(prj: _, cmd: _) { + prj.add_test( + "HardhatLogs.t.sol", + r#" +import "forge-std/console.sol"; + +contract HardhatLogsTest { + constructor() { + console.log("constructor"); + } + + string testStr; + int256 testInt; + uint256 testUint; + bool testBool; + address testAddr; + bytes testBytes; + + function setUp() public { + testStr = "test"; + testInt = -31337; + testUint = 1; + testBool = false; + testAddr = 0x0000000000000000000000000000000000000001; + testBytes = "a"; + } + + function testInts() public view { + console.log(uint256(0)); + console.log(uint256(1)); + console.log(uint256(2)); + console.log(uint256(3)); + } + + function testStrings() public view { + console.log("testStrings"); + } + + function testMisc() public view { + console.log("testMisc", address(1)); + console.log("testMisc", uint256(42)); + } + + function testConsoleLog() public view { + console.log(testStr); + } + + function testLogInt() public view { + console.logInt(testInt); + } + + function testLogUint() public view { + console.logUint(testUint); + } + + function testLogString() public view { + console.logString(testStr); + } + + function testLogBool() public view { + console.logBool(testBool); + } + + function testLogAddress() public view { + console.logAddress(testAddr); + } + + function testLogBytes() public view { + console.logBytes(testBytes); + } + + function testLogBytes1() public view { + console.logBytes1(bytes1(testBytes)); + } + + function testLogBytes2() public view { + console.logBytes2(bytes2(testBytes)); + } + + function testLogBytes3() public view { + console.logBytes3(bytes3(testBytes)); + } + + function testLogBytes4() public view { + console.logBytes4(bytes4(testBytes)); + } + + function testLogBytes5() public view { + console.logBytes5(bytes5(testBytes)); + } + + function testLogBytes6() public view { + console.logBytes6(bytes6(testBytes)); + } + + function testLogBytes7() public view { + console.logBytes7(bytes7(testBytes)); + } + + function testLogBytes8() public view { + console.logBytes8(bytes8(testBytes)); + } + + function testLogBytes9() public view { + console.logBytes9(bytes9(testBytes)); + } + + function testLogBytes10() public view { + console.logBytes10(bytes10(testBytes)); + } + + function testLogBytes11() public view { + console.logBytes11(bytes11(testBytes)); + } + + function testLogBytes12() public view { + console.logBytes12(bytes12(testBytes)); + } + + function testLogBytes13() public view { + console.logBytes13(bytes13(testBytes)); + } + + function testLogBytes14() public view { + console.logBytes14(bytes14(testBytes)); + } + + function testLogBytes15() public view { + console.logBytes15(bytes15(testBytes)); + } + + function testLogBytes16() public view { + console.logBytes16(bytes16(testBytes)); + } + + function testLogBytes17() public view { + console.logBytes17(bytes17(testBytes)); + } + + function testLogBytes18() public view { + console.logBytes18(bytes18(testBytes)); + } + + function testLogBytes19() public view { + console.logBytes19(bytes19(testBytes)); + } + + function testLogBytes20() public view { + console.logBytes20(bytes20(testBytes)); + } + + function testLogBytes21() public view { + console.logBytes21(bytes21(testBytes)); + } + + function testLogBytes22() public view { + console.logBytes22(bytes22(testBytes)); + } + + function testLogBytes23() public view { + console.logBytes23(bytes23(testBytes)); + } + + function testLogBytes24() public view { + console.logBytes24(bytes24(testBytes)); + } + + function testLogBytes25() public view { + console.logBytes25(bytes25(testBytes)); + } + + function testLogBytes26() public view { + console.logBytes26(bytes26(testBytes)); + } + + function testLogBytes27() public view { + console.logBytes27(bytes27(testBytes)); + } + + function testLogBytes28() public view { + console.logBytes28(bytes28(testBytes)); + } + + function testLogBytes29() public view { + console.logBytes29(bytes29(testBytes)); + } + + function testLogBytes30() public view { + console.logBytes30(bytes30(testBytes)); + } + + function testLogBytes31() public view { + console.logBytes31(bytes31(testBytes)); + } + + function testLogBytes32() public view { + console.logBytes32(bytes32(testBytes)); + } + + function testConsoleLogUint() public view { + console.log(testUint); + } + + function testConsoleLogString() public view { + console.log(testStr); + } + + function testConsoleLogBool() public view { + console.log(testBool); + } + + function testConsoleLogAddress() public view { + console.log(testAddr); + } + + function testConsoleLogFormatString() public view { + console.log("formatted log str=%s", testStr); + } + + function testConsoleLogFormatUint() public view { + console.log("formatted log uint=%s", testUint); + } + + function testConsoleLogFormatAddress() public view { + console.log("formatted log addr=%s", testAddr); + } + + function testConsoleLogFormatMulti() public view { + console.log("formatted log str=%s uint=%d", testStr, testUint); + } + + function testConsoleLogFormatEscape() public view { + console.log("formatted log %% %s", testStr); + } + + function testConsoleLogFormatSpill() public view { + console.log("formatted log %s", testStr, testUint); + } +} +"#, + ); + + cmd.args(["test", "-vv"]).assert_success().stdout_eq(str![[r#" +... +Ran 52 tests for test/HardhatLogs.t.sol:HardhatLogsTest +[PASS] testConsoleLog() ([GAS]) +Logs: + constructor + test + +[PASS] testConsoleLogAddress() ([GAS]) +Logs: + constructor + 0x0000000000000000000000000000000000000001 + +[PASS] testConsoleLogBool() ([GAS]) +Logs: + constructor + false + +[PASS] testConsoleLogFormatAddress() ([GAS]) +Logs: + constructor + formatted log addr=0x0000000000000000000000000000000000000001 + +[PASS] testConsoleLogFormatEscape() ([GAS]) +Logs: + constructor + formatted log % test + +[PASS] testConsoleLogFormatMulti() ([GAS]) +Logs: + constructor + formatted log str=test uint=1 + +[PASS] testConsoleLogFormatSpill() ([GAS]) +Logs: + constructor + formatted log test 1 + +[PASS] testConsoleLogFormatString() ([GAS]) +Logs: + constructor + formatted log str=test + +[PASS] testConsoleLogFormatUint() ([GAS]) +Logs: + constructor + formatted log uint=1 + +[PASS] testConsoleLogString() ([GAS]) +Logs: + constructor + test + +[PASS] testConsoleLogUint() ([GAS]) +Logs: + constructor + 1 + +[PASS] testInts() ([GAS]) +Logs: + constructor + 0 + 1 + 2 + 3 + +[PASS] testLogAddress() ([GAS]) +Logs: + constructor + 0x0000000000000000000000000000000000000001 + +[PASS] testLogBool() ([GAS]) +Logs: + constructor + false + +[PASS] testLogBytes() ([GAS]) +Logs: + constructor + 0x61 + +[PASS] testLogBytes1() ([GAS]) +Logs: + constructor + 0x61 + +[PASS] testLogBytes10() ([GAS]) +Logs: + constructor + 0x61000000000000000000 + +[PASS] testLogBytes11() ([GAS]) +Logs: + constructor + 0x6100000000000000000000 + +[PASS] testLogBytes12() ([GAS]) +Logs: + constructor + 0x610000000000000000000000 + +[PASS] testLogBytes13() ([GAS]) +Logs: + constructor + 0x61000000000000000000000000 + +[PASS] testLogBytes14() ([GAS]) +Logs: + constructor + 0x6100000000000000000000000000 + +[PASS] testLogBytes15() ([GAS]) +Logs: + constructor + 0x610000000000000000000000000000 + +[PASS] testLogBytes16() ([GAS]) +Logs: + constructor + 0x61000000000000000000000000000000 + +[PASS] testLogBytes17() ([GAS]) +Logs: + constructor + 0x6100000000000000000000000000000000 + +[PASS] testLogBytes18() ([GAS]) +Logs: + constructor + 0x610000000000000000000000000000000000 + +[PASS] testLogBytes19() ([GAS]) +Logs: + constructor + 0x61000000000000000000000000000000000000 + +[PASS] testLogBytes2() ([GAS]) +Logs: + constructor + 0x6100 + +[PASS] testLogBytes20() ([GAS]) +Logs: + constructor + 0x6100000000000000000000000000000000000000 + +[PASS] testLogBytes21() ([GAS]) +Logs: + constructor + 0x610000000000000000000000000000000000000000 + +[PASS] testLogBytes22() ([GAS]) +Logs: + constructor + 0x61000000000000000000000000000000000000000000 + +[PASS] testLogBytes23() ([GAS]) +Logs: + constructor + 0x6100000000000000000000000000000000000000000000 + +[PASS] testLogBytes24() ([GAS]) +Logs: + constructor + 0x610000000000000000000000000000000000000000000000 + +[PASS] testLogBytes25() ([GAS]) +Logs: + constructor + 0x61000000000000000000000000000000000000000000000000 + +[PASS] testLogBytes26() ([GAS]) +Logs: + constructor + 0x6100000000000000000000000000000000000000000000000000 + +[PASS] testLogBytes27() ([GAS]) +Logs: + constructor + 0x610000000000000000000000000000000000000000000000000000 + +[PASS] testLogBytes28() ([GAS]) +Logs: + constructor + 0x61000000000000000000000000000000000000000000000000000000 + +[PASS] testLogBytes29() ([GAS]) +Logs: + constructor + 0x6100000000000000000000000000000000000000000000000000000000 + +[PASS] testLogBytes3() ([GAS]) +Logs: + constructor + 0x610000 + +[PASS] testLogBytes30() ([GAS]) +Logs: + constructor + 0x610000000000000000000000000000000000000000000000000000000000 + +[PASS] testLogBytes31() ([GAS]) +Logs: + constructor + 0x61000000000000000000000000000000000000000000000000000000000000 + +[PASS] testLogBytes32() ([GAS]) +Logs: + constructor + 0x6100000000000000000000000000000000000000000000000000000000000000 + +[PASS] testLogBytes4() ([GAS]) +Logs: + constructor + 0x61000000 + +[PASS] testLogBytes5() ([GAS]) +Logs: + constructor + 0x6100000000 + +[PASS] testLogBytes6() ([GAS]) +Logs: + constructor + 0x610000000000 + +[PASS] testLogBytes7() ([GAS]) +Logs: + constructor + 0x61000000000000 + +[PASS] testLogBytes8() ([GAS]) +Logs: + constructor + 0x6100000000000000 + +[PASS] testLogBytes9() ([GAS]) +Logs: + constructor + 0x610000000000000000 + +[PASS] testLogInt() ([GAS]) +Logs: + constructor + -31337 + +[PASS] testLogString() ([GAS]) +Logs: + constructor + test + +[PASS] testLogUint() ([GAS]) +Logs: + constructor + 1 + +[PASS] testMisc() ([GAS]) +Logs: + constructor + testMisc 0x0000000000000000000000000000000000000001 + testMisc 42 + +[PASS] testStrings() ([GAS]) +Logs: + constructor + testStrings + +Suite result: ok. 52 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 52 tests passed, 0 failed, 0 skipped (52 total tests) + +"#]]); +} + +#[forgetest_init] +fn test_can_run_with_live_logs_flag(prj: _, cmd: _) { + prj.add_test( + "Foo.t.sol", + r#" +import {Test, console} from "forge-std/Test.sol"; + +contract Foo is Test { + function setUp() pure public { + console.log("Setup"); + } + + function test1() pure public { + console.log("Test 1"); + } +} + "#, + ); + + cmd.forge_fuse() + .args(["test", "--live-logs", "--match-test", "test1"]) + .assert_success() + .stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! +Setup +Test 1 + +Ran 1 test for test/Foo.t.sol:Foo +[PASS] test1() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +} + +#[forgetest_init] +fn test_can_run_with_live_logs_config(prj: _, cmd: _) { + prj.update_config(|config| { + config.live_logs = true; + }); + + prj.add_test( + "Foo.t.sol", + r#" +import {Test, console} from "forge-std/Test.sol"; + +contract Foo is Test { + function setUp() pure public { + console.log("Setup"); + } + + function test1() pure public { + console.log("Test 1"); + } +} + "#, + ); + + cmd.forge_fuse().args(["test", "--match-test", "test1"]).assert_success().stdout_eq(str![[ + r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! +Setup +Test 1 + +Ran 1 test for test/Foo.t.sol:Foo +[PASS] test1() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"# + ]]); +} + +#[forgetest_init] +fn test_can_run_with_live_logs_flag_race_condition(prj: _, cmd: _) { + prj.add_test( + "Foo.t.sol", + r#" +import {Test, console} from "forge-std/Test.sol"; + +contract Foo is Test { + function setUp() pure public { + console.log("Setup"); + } + + function test1() pure public { + console.log("Test 1"); + } + + function test2() pure public { + console.log("Test 2"); + } +} + "#, + ); + + // Two threads. Inconsistent printing order. + cmd.forge_fuse().args(["test", "--live-logs", "--threads", "2"]).assert_success().stdout_eq( + str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! +Setup +Test [..] +Test [..] + +Ran 2 tests for test/Foo.t.sol:Foo +[PASS] test1() ([GAS]) +[PASS] test2() ([GAS]) +Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 2 tests passed, 0 failed, 0 skipped (2 total tests) + +"#]], + ); + + // Single thread. Deterministic printing order. + + for _ in 0..10 { + cmd.forge_fuse() + .args(["test", "--live-logs", "--threads", "1"]) + .assert_success() + .stdout_eq(str![[r#" +No files changed, compilation skipped +Setup +Test 1 +Test 2 + +Ran 2 tests for test/Foo.t.sol:Foo +[PASS] test1() ([GAS]) +[PASS] test2() ([GAS]) +Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 2 tests passed, 0 failed, 0 skipped (2 total tests) + +"#]]); + } +} diff --git a/crates/forge/tests/cli/test_cmd/mod.rs b/crates/forge/tests/cli/test_cmd/mod.rs new file mode 100644 index 0000000000000..2134d73f751ec --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/mod.rs @@ -0,0 +1,8245 @@ +//! Contains various tests for `forge test`. + +use crate::utils::assert_debug_dump_identifies_contract; +use alloy_primitives::{Address, B256, Bytes, U256, address}; +use alloy_provider::Provider; +use anvil::{EthereumHardfork, NodeConfig, spawn}; +use foundry_common::LIBRARY_DEPLOYER; +use foundry_config::{CompilationRestrictions, SettingsOverrides, filter::GlobMatcher}; +use foundry_test_utils::{ + TestCommand, assert_data_eq, + rpc::{self, next_etherscan_api_key, rpc_endpoints}, + snapbox::IntoData, + str, + util::{OTHER_SOLC_VERSION, OutputExt, SOLC_VERSION, get_vyper}, +}; +use similar_asserts::assert_eq; +use std::{io::Write, path::PathBuf, str::FromStr}; + +#[cfg(unix)] +use std::fs; + +#[cfg(feature = "base")] +mod base; +mod brutalize; +mod core; +mod exact_fork; +mod fork_bal; +mod fork_state_by_number; +mod fuzz; +mod halts; +mod invariant; +mod logs; +mod mutation; +mod repros; +mod showmap; +mod spec; +mod symbolic; +mod symbolic_calls; +mod symbolic_cheatcodes; +mod symbolic_conformance; +mod symbolic_creates; +mod symbolic_engine_capabilities; +mod symbolic_false_pass_prevention; +mod symbolic_helpers; +mod symbolic_invariant; +mod symbolic_limits; +mod symbolic_memory; +mod symbolic_opcodes; +mod symbolic_parity; +mod symbolic_precompiles; +mod symbolic_storage; +mod table; +mod trace; + +/// Sets up a [`TestCommand`] to run `forge test` on the `/testdata` directory with RPC +/// endpoints written to a `.env` file. +fn setup_testdata_cmd(cmd: &mut TestCommand) { + let testdata = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../testdata").canonicalize().unwrap(); + cmd.current_dir(&testdata); + + let mut dotenv = std::fs::File::create(testdata.join(".env")).unwrap(); + for (name, endpoint) in rpc_endpoints().iter() { + if let Some(url) = endpoint.endpoint.as_url() { + let key = format!("RPC_{}", name.to_uppercase()); + writeln!(dotenv, "{key}={url}").unwrap(); + } + } + drop(dotenv); +} + +fn collect_debug_dump_values<'a, T>( + value: &'a serde_json::Value, + collected: &mut Vec, + collect_from_object: &mut impl FnMut(&'a serde_json::Map, &mut Vec), +) { + match value { + serde_json::Value::Array(values) => { + for value in values { + collect_debug_dump_values(value, collected, collect_from_object); + } + } + serde_json::Value::Object(map) => { + collect_from_object(map, collected); + for value in map.values() { + collect_debug_dump_values(value, collected, collect_from_object); + } + } + _ => {} + } +} + +fn collect_debug_dump_internal_calls<'a>( + value: &'a serde_json::Value, + calls: &mut Vec<&'a serde_json::Value>, +) { + collect_debug_dump_values(value, calls, &mut |map, calls| { + if let Some(call) = map + .get("InternalCall") + .and_then(|value| value.as_array()) + .and_then(|values| values.first()) + { + calls.push(call); + } + }); +} + +fn collect_debug_dump_decoded_lines<'a>(value: &'a serde_json::Value, lines: &mut Vec<&'a str>) { + collect_debug_dump_values(value, lines, &mut |map, lines| { + if let Some(line) = map.get("Line").and_then(|value| value.as_str()) { + lines.push(line); + } + }); +} + +fn collect_debug_dump_storage_changes<'a>( + value: &'a serde_json::Value, + changes: &mut Vec<&'a serde_json::Value>, +) { + collect_debug_dump_values(value, changes, &mut |map, changes| { + if let Some(change) = map.get("storage_change") + && !change.is_null() + { + changes.push(change); + } + }); +} + +/// Contracts excluded from the main `testdata` run because they depend on flaky external RPCs. +/// These are run separately by the `flaky_testdata` test below. +/// Format: pipe-separated regex alternation, e.g. `"Foo|Bar|Baz"`. +const FLAKY_TESTDATA_CONTRACTS: &str = "Issue4232Test|Issue4640Test|Issue14212Test"; + +// Issue4232Test depends on the public Moonbeam RPC, while Issue14212Test depends on Base +// transaction lookups that are not reliably served by the public Base RPC endpoint used in CI. +const FLAKY_TESTDATA_RUN_CONTRACTS: &str = "Issue4232Test|Issue4640Test"; + +// Run `forge test` on `/testdata`. +#[forgetest] +fn testdata(cmd: _) { + setup_testdata_cmd(&mut cmd); + + let mut args = vec!["test"]; + let nmc = format!("--nmc=({FLAKY_TESTDATA_CONTRACTS})"); + args.push(&nmc); + + let orig_assert = cmd.args(args).assert(); + if orig_assert.get_output().status.success() { + return; + } + // Only test failures are retried: a crash writes no `--rerun` failures, so a retry would + // either rerun everything or only unrelated flaky failures and hide the crash. + if orig_assert.get_output().status.code() != Some(1) { + orig_assert.success(); + return; + } + let stdout = orig_assert.get_output().stdout_lossy(); + if let Some(i) = stdout.rfind("Suite result:") { + test_debug!("--- short stdout ---\n\n{}\n\n---", &stdout[i..]); + } + + // Retry failed tests. + cmd.args(["--rerun"]); + let n = 3; + for i in 1..=n { + test_debug!("retrying failed tests... ({i}/{n})"); + let assert = cmd.assert(); + if assert.get_output().status.success() { + return; + } + } + + orig_assert.success(); +} + +#[cfg(feature = "monad")] +#[forgetest] +fn monad_testdata(cmd: _) { + setup_testdata_cmd(&mut cmd); + cmd.args(["test", "--network", "monad", "--mc=(MonadStakingTest|MonadReserveBalanceTest)"]) + .assert_success(); +} + +// Run flaky testdata contracts excluded from the main `testdata` test above. +// Picked up by the nightly `test-flaky` workflow via `cargo nextest run --profile flaky`. +#[forgetest] +fn flaky_testdata(cmd: _) { + setup_testdata_cmd(&mut cmd); + let mc = format!("--mc=({FLAKY_TESTDATA_RUN_CONTRACTS})"); + cmd.args(["test", &mc]).assert_success(); +} + +// Ensures `vm.deployCode` works with the optimism network family active, covering the OP EVM's +// nested frame execution path which is only reachable through this cheatcode. +#[forgetest_init] +fn deploy_code_cheatcode_on_optimism_network(prj: _, cmd: _) { + prj.update_config(|config| { + config.networks = foundry_evm_networks::NetworkConfigs::with_optimism(); + }); + + prj.add_source( + "OpCounter.sol", + r#" +contract OpCounter { + uint256 public number; + + constructor(uint256 initial) { + number = initial; + } + + function increment() external { + number++; + } +} +"#, + ); + + prj.add_test( + "OpDeployCode.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {OpCounter} from "../src/OpCounter.sol"; + +contract OpDeployCodeTest is Test { + function testDeployCodeOnOptimism() public { + address deployed = vm.deployCode("src/OpCounter.sol:OpCounter", abi.encode(41)); + assertGt(deployed.code.length, 0); + + OpCounter counter = OpCounter(deployed); + assertEq(counter.number(), 41); + counter.increment(); + assertEq(counter.number(), 42); + } +} +"#, + ); + + cmd.args(["test", "--match-contract", "OpDeployCodeTest"]).assert_success().stdout_eq(str![[ + r#" +... +Ran 1 test for test/OpDeployCode.t.sol:OpDeployCodeTest +[PASS] testDeployCodeOnOptimism() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"# + ]]); +} + +#[forgetest_init] +fn broadcast_deploy_code_cleans_up_after_revert(prj: _, cmd: _) { + prj.add_source( + "DeployCodeCleanup.sol", + r#" +contract DeployCodeCleanup { + address public caller; + + constructor(bool shouldRevert) { + require(!shouldRevert, "constructor reverted"); + caller = msg.sender; + } + + function recordCaller() external { + caller = msg.sender; + } +} +"#, + ); + + prj.add_test( + "DeployCodeCleanup.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {DeployCodeCleanup} from "../src/DeployCodeCleanup.sol"; + +contract DeployCodeCleanupTest is Test { + function testBroadcastCleanupAfterRevert() public { + DeployCodeCleanup local = new DeployCodeCleanup(false); + + vm.broadcast(address(0xA11CE)); + try vm.deployCode( + "src/DeployCodeCleanup.sol:DeployCodeCleanup", + abi.encode(true), + bytes32(uint256(1)) + ) returns (address) { + fail(); + } catch {} + + local.recordCaller(); + assertEq(local.caller(), address(this)); + + vm.broadcast(address(0xB0B)); + address deployed = vm.deployCode( + "src/DeployCodeCleanup.sol:DeployCodeCleanup", + abi.encode(false), + bytes32(uint256(2)) + ); + assertGt(deployed.code.length, 0); + + vm.broadcast(address(0xB0B)); + try vm.deployCode( + "src/DeployCodeCleanup.sol:DeployCodeCleanup", + abi.encode(false), + bytes32(uint256(2)) + ) returns (address) { + fail(); + } catch {} + + local.recordCaller(); + assertEq(local.caller(), address(this)); + + vm.broadcast(address(0xCAFE)); + address finalDeployment = vm.deployCode( + "src/DeployCodeCleanup.sol:DeployCodeCleanup", + abi.encode(false), + bytes32(uint256(3)) + ); + assertGt(finalDeployment.code.length, 0); + } +} +"#, + ); + + cmd.args(["test", "--match-contract", "DeployCodeCleanupTest"]).assert_success().stdout_eq( + str![[r#" +... +Ran 1 test for test/DeployCodeCleanup.t.sol:DeployCodeCleanupTest +[PASS] testBroadcastCleanupAfterRevert() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]], + ); +} + +// tests that test filters are handled correctly +#[forgetest] +fn can_set_filter_values(prj: _, cmd: _) { + let patt = regex::Regex::new("test*").unwrap(); + let glob = globset::Glob::from_str("foo/bar/baz*").unwrap(); + + // explicitly set patterns + prj.update_config(|config| { + config.test_pattern = Some(patt.clone().into()); + config.test_pattern_inverse = None; + config.contract_pattern = Some(patt.clone().into()); + config.contract_pattern_inverse = None; + config.path_pattern = Some(glob.clone()); + config.path_pattern_inverse = None; + config.coverage_pattern_inverse = None; + }); + + let config = cmd.config(); + + assert_eq!(config.test_pattern.unwrap().as_str(), patt.as_str()); + assert_eq!(config.test_pattern_inverse, None); + assert_eq!(config.contract_pattern.unwrap().as_str(), patt.as_str()); + assert_eq!(config.contract_pattern_inverse, None); + assert_eq!(config.path_pattern.unwrap(), glob); + assert_eq!(config.path_pattern_inverse, None); + assert_eq!(config.coverage_pattern_inverse, None); +} + +fn dummy_test_filter(cmd: &mut TestCommand) { + cmd.args(["test", "--match-test", "testA.*", "--no-match-test", "testB.*"]); + cmd.args(["--match-contract", "TestC.*", "--no-match-contract", "TestD.*"]); + cmd.args(["--match-path", "*TestE*", "--no-match-path", "*TestF*"]); +} + +// tests that a warning is displayed when there are no tests in project, regardless of filters +#[forgetest] +fn warn_no_tests(prj: _, cmd: _) { + // Must add at least one source to not fail earlier. + prj.add_source( + "dummy", + r" +contract Dummy {} +", + ); + + cmd.arg("test").assert_success().stderr_eq(str![[r#" +Warning: No tests found in project! Forge looks for functions that start with `test` + +"#]]); + + cmd.forge_fuse(); + dummy_test_filter(&mut cmd); + cmd.assert_success().stderr_eq(str![[r#" +Warning: No tests found in project! Forge looks for functions that start with `test` + +"#]]); +} + +// Test that `--decode-external-storage` decodes storage layouts of external contracts +// fetched from Etherscan when using state diff recording on a fork. +// Uses 1inch token (non-proxy, Solidity 0.6.12) which supports storageLayout output. +#[forgetest_init] +fn decode_external_storage_on_fork(prj: _, cmd: _) { + let endpoint = rpc::next_http_archive_rpc_url(); + let etherscan_api_key = next_etherscan_api_key(); + + prj.add_test( + "DecodeExternalStorage.t.sol", + &r#" +import {Test} from "forge-std/Test.sol"; + +interface IERC20 { + function transfer(address to, uint256 amount) external returns (bool); +} + +contract DecodeExternalStorageTest is Test { + // 1inch token on mainnet (non-proxy, compiled with Solidity 0.6.12) + address constant ONE_INCH = 0x111111111117dC0aa78b770fA6A738034120C302; + // A large 1inch holder + address constant WHALE = 0xF977814e90dA44bFA03b6295A0616a897441aceC; + + function test_externalStorageDecoding() public { + vm.createSelectFork(""); + + vm.prank(WHALE); + + vm.startStateDiffRecording(); + IERC20(ONE_INCH).transfer(address(this), 1 ether); + string memory diff = vm.getStateDiffJson(); + + // When external storage decoding is enabled, the JSON should contain + // the decoded mapping label "_balances" from the 1inch token's storage layout. + assertTrue(vm.contains(diff, "_balances"), "expected decoded '_balances' label in state diff"); + } +} + "# + .replace("", &endpoint), + ); + + cmd.args([ + "test", + "-vvvv", + "--mt", + "test_externalStorageDecoding", + "--decode-external-storage", + "--etherscan-api-key", + ðerscan_api_key, + ]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/DecodeExternalStorage.t.sol:DecodeExternalStorageTest +[PASS] test_externalStorageDecoding() ([GAS]) +... +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +// Test that `--decode-external-storage` correctly resolves proxy contracts +// by fetching the implementation's storage layout (e.g., USDC is an EIP-1967 proxy). +#[forgetest_init] +fn decode_external_storage_proxy_on_fork(prj: _, cmd: _) { + let endpoint = rpc::next_http_archive_rpc_url(); + let etherscan_api_key = next_etherscan_api_key(); + + prj.add_test( + "DecodeExternalStorageProxy.t.sol", + &r#" +import {Test} from "forge-std/Test.sol"; + +interface IUSDC { + function transfer(address to, uint256 amount) external returns (bool); + function balanceOf(address account) external view returns (uint256); +} + +contract DecodeExternalStorageProxyTest is Test { + // USDC on mainnet (EIP-1967 proxy -> FiatTokenV2_2 implementation) + address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + // A large USDC holder (Circle/Centre) + address constant USDC_WHALE = 0x55FE002aefF02F77364de339a1292923A15844B8; + + function test_externalStorageDecodingProxy() public { + vm.createSelectFork(""); + + // Impersonate a whale to perform a transfer + vm.prank(USDC_WHALE); + + vm.startStateDiffRecording(); + IUSDC(USDC).transfer(address(this), 1_000_000); // 1 USDC (6 decimals) + string memory diff = vm.getStateDiffJson(); + + // The implementation contract (FiatTokenV2_2) has a `balanceAndBlacklistStates` mapping. + // If proxy resolution works, the decoded JSON should contain the label + // from the implementation's storage layout, not raw hex slots. + assertTrue(vm.contains(diff, "balanceAndBlacklistStates"), "expected decoded 'balanceAndBlacklistStates' label from implementation storage layout"); + } +} + "# + .replace("", &endpoint), + ); + + cmd.args([ + "test", + "-vvvv", + "--mt", + "test_externalStorageDecodingProxy", + "--decode-external-storage", + "--etherscan-api-key", + ðerscan_api_key, + ]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/DecodeExternalStorageProxy.t.sol:DecodeExternalStorageProxyTest +[PASS] test_externalStorageDecodingProxy() ([GAS]) +... +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +// A local proxy artifact must not override the layout of the bytecode that executed a delegated +// storage write. +#[forgetest_init] +fn decode_external_storage_prefers_delegatecall_layout(prj: _, cmd: _) { + prj.add_test( + "DecodeDelegatecallStorage.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract Implementation { + uint256 public implementationValue; + bytes32 public constant IMPLEMENTATION_SLOT = + 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; + + function setValue(uint256 value) external { + implementationValue = value; + } +} + +contract TransparentUpgradeableProxy { + uint256 public guessedProxyValue; +} + +contract Proxy { + uint256 public misleadingProxyValue; + bytes32 private constant IMPLEMENTATION_SLOT = + 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; + + constructor(address implementation) { + bytes32 slot = IMPLEMENTATION_SLOT; + assembly { + sstore(slot, implementation) + } + } + + fallback() external payable { + bytes32 slot = IMPLEMENTATION_SLOT; + assembly { + let implementation := sload(slot) + calldatacopy(0, 0, calldatasize()) + let success := delegatecall(gas(), implementation, 0, calldatasize(), 0, 0) + returndatacopy(0, 0, returndatasize()) + if iszero(success) { revert(0, returndatasize()) } + return(0, returndatasize()) + } + } +} + +contract DecodeDelegatecallStorageTest is Test { + function test_usesRecordedImplementationLayout() public { + Implementation implementation = new Implementation(); + Proxy proxy = new Proxy(address(implementation)); + + vm.startStateDiffRecording(); + Implementation(address(proxy)).setValue(42); + string memory diff = vm.getStateDiffJson(); + + assertTrue(vm.contains(diff, "implementationValue")); + assertFalse(vm.contains(diff, "misleadingProxyValue")); + assertFalse(vm.contains(diff, "guessedProxyValue")); + + vm.startStateDiffRecording(); + Implementation(address(proxy)).setValue(43); + vm.chainId(1); + string memory priorChainDiff = vm.getStateDiffJson(); + + // The current journal can no longer prove the code identity recorded on the prior chain. + assertFalse(vm.contains(priorChainDiff, "implementationValue")); + assertFalse(vm.contains(priorChainDiff, "misleadingProxyValue")); + assertFalse(vm.contains(priorChainDiff, "guessedProxyValue")); + } +} +"#, + ); + + cmd.args([ + "test", + "--mt", + "test_usesRecordedImplementationLayout", + "--decode-external-storage", + "--extra-output", + "storageLayout", + ]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/DecodeDelegatecallStorage.t.sol:DecodeDelegatecallStorageTest +[PASS] test_usesRecordedImplementationLayout() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +// tests that a warning is displayed if there are tests but none match a non-empty filter +#[forgetest] +fn suggest_when_no_tests_match(prj: _, cmd: _) { + prj.add_source( + "TestE.t.sol", + r" +contract TestC { + function test1() public { + } +} + ", + ); + + dummy_test_filter(&mut cmd); + cmd.assert_success().stderr_eq(str![[r#" +Warning: no tests match the provided pattern: + match-test: `testA.*` + no-match-test: `testB.*` + match-contract: `TestC.*` + no-match-contract: `TestD.*` + match-path: `*TestE*` + no-match-path: `*TestF*` + +Did you mean `test1`? + +"#]]); +} + +// Tests that ABI discovery preserves diagnostics for excluded test contracts. +#[forgetest] +fn warn_when_filtered_tests_are_not_compiled(prj: _, cmd: _) { + prj.add_source("Dummy.sol", "contract Dummy {}"); + prj.add_test("Filtered.t.sol", "contract Filtered { function testFoo(uint256) public {} }"); + + for (dynamic_test_linking, cache) in + [(false, true), (true, true), (false, false), (true, false)] + { + prj.update_config(|config| { + config.dynamic_test_linking = dynamic_test_linking; + config.cache = cache; + }); + cmd.forge_fuse().args(["test", "--mt", "testFoo$"]).assert_success().stderr_eq(str![[r#" +Warning: no tests match the provided pattern: + match-test: `testFoo$` + +Did you mean `testFoo`? + +"#]]); + cmd.forge_fuse().args(["test", "--mc", "Missing"]).assert_success().stderr_eq(str![[r#" +Warning: no tests match the provided pattern: + match-contract: `Missing` + + +"#]]); + } +} + +#[forgetest] +fn do_not_count_non_runnable_tests(prj: _, cmd: _) { + prj.add_source("Dummy.sol", "contract Dummy {}"); + prj.add_test( + "NotRunnable.t.sol", + r#" +interface TestInterface { function testInterface() external; } +abstract contract AbstractTest { function testAbstract() public {} } +contract ConstructorTest { + constructor(uint256) {} + function testConstructor() public {} +} +"#, + ); + + cmd.args(["test", "--mc", "Missing"]).assert_success().stderr_eq(str![[r#" +Warning: No tests found in project! Forge looks for functions that start with `test` + +"#]]); + + prj.add_test("Library.t.sol", "library LibraryTest { function testLibrary() public pure {} }"); + cmd.forge_fuse().args(["test", "--mc", "Missing"]).assert_success().stderr_eq(str![[r#" +Warning: no tests match the provided pattern: + match-contract: `Missing` + + +"#]]); +} + +// tests that direct import paths are handled correctly +#[forgetest] +fn can_fuzz_array_params(prj: _, cmd: _) { + prj.insert_ds_test(); + + prj.add_source( + "ATest.t.sol", + r#" +import "./test.sol"; +contract ATest is DSTest { + function testArray(uint64[2] calldata) external { + assertTrue(true); + } +} + "#, + ); + + cmd.arg("test").assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for src/ATest.t.sol:ATest +[PASS] testArray(uint64[2]) (runs: 256, [AVG_GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +} + +// tests that `bytecode_hash` will be sanitized +#[forgetest] +fn can_test_pre_bytecode_hash(prj: _, cmd: _) { + prj.insert_ds_test(); + + prj.add_source( + "ATest.t.sol", + r#" +// pre bytecode hash version, was introduced in 0.6.0 +pragma solidity 0.5.17; +import "./test.sol"; +contract ATest is DSTest { + function testArray(uint64[2] calldata) external { + assertTrue(true); + } +} + "#, + ); + + cmd.arg("test").assert_success().stdout_eq(str![[r#" +... +Compiler run successful! + +Ran 1 test for src/ATest.t.sol:ATest +[PASS] testArray(uint64[2]) (runs: 256, [AVG_GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +} + +// tests that using the --match-path option only runs files matching the path +#[forgetest] +fn can_test_with_match_path(prj: _, cmd: _) { + prj.insert_ds_test(); + + prj.add_source( + "ATest.t.sol", + r#" +import "./test.sol"; +contract ATest is DSTest { + function testPass() external { + assertTrue(true); + } +} + "#, + ); + + prj.add_source( + "FailTest.t.sol", + r#" +import "./test.sol"; +contract FailTest is DSTest { + function testNothing() external { + assertTrue(false); + } +} + "#, + ); + + cmd.args(["test", "--match-path", "*src/ATest.t.sol"]).assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for src/ATest.t.sol:ATest +[PASS] testPass() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +} + +// tests that using the --match-path option works with absolute paths +#[forgetest] +fn can_test_with_match_path_absolute(prj: _, cmd: _) { + prj.insert_ds_test(); + + prj.add_source( + "ATest.t.sol", + r#" +import "./test.sol"; +contract ATest is DSTest { + function testPass() external { + assertTrue(true); + } +} + "#, + ); + + prj.add_source( + "FailTest.t.sol", + r#" +import "./test.sol"; +contract FailTest is DSTest { + function testNothing() external { + assertTrue(false); + } +} + "#, + ); + + let test_path = prj.root().join("src/ATest.t.sol"); + let test_path = test_path.to_string_lossy(); + + cmd.args(["test", "--match-path", test_path.as_ref()]).assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for src/ATest.t.sol:ATest +[PASS] testPass() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +} + +const SIMPLE_CONTRACT: &str = r#" +import "./test.sol"; +import "./console.sol"; + +contract SimpleContract { + uint256 public num; + + function setValues(uint256 _num) public { + num = _num; + } +} + +contract SimpleContractTest is DSTest { + function test() public { + SimpleContract c = new SimpleContract(); + c.setValues(100); + console.logUint(100); + } +} + "#; + +#[forgetest] +fn can_run_test_with_json_output_verbose(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.insert_console(); + + prj.add_source("Simple.t.sol", SIMPLE_CONTRACT); + + // Assert that with verbose output the json output includes the traces + cmd.args(["test", "-vvvvv", "--json"]) + .assert_success() + .stdout_eq(file!["../../fixtures/SimpleContractTestVerbose.json": Json]); +} + +#[forgetest] +fn test_json_trace_depth_removes_nested_nodes(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.insert_console(); + prj.add_source("Simple.t.sol", SIMPLE_CONTRACT); + + let output = cmd + .args(["test", "-vvvvv", "--json", "--trace-depth", "0"]) + .assert_success() + .get_output() + .stdout + .clone(); + let output: serde_json::Value = serde_json::from_slice(&output).unwrap(); + let traces = output["src/Simple.t.sol:SimpleContractTest"]["test_results"]["test()"]["traces"] + .as_array() + .unwrap(); + assert!(!traces.is_empty()); + + for trace in traces { + let arena = trace[1]["arena"].as_array().unwrap(); + assert_eq!(arena.len(), 1); + assert!(arena[0]["children"].as_array().unwrap().is_empty()); + } +} + +#[forgetest] +fn can_run_test_with_json_output_non_verbose(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.insert_console(); + + prj.add_source("Simple.t.sol", SIMPLE_CONTRACT); + + // Assert that without verbose output the json output does not include the traces + cmd.args(["test", "--json"]) + .assert_success() + .stdout_eq(file!["../../fixtures/SimpleContractTestNonVerbose.json": Json]); +} + +#[forgetest] +fn json_and_junit_emit_empty_documents_without_matches(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.insert_console(); + prj.add_source("Simple.t.sol", SIMPLE_CONTRACT); + + // A filter that matches nothing must still produce a parseable document on stdout. + cmd.args(["test", "--json", "--match-test", "testNoSuch"]).assert_success().stdout_eq(str![[ + r#" +{} + +"# + ]]); + + cmd.forge_fuse().args(["test", "--junit", "--match-test", "testNoSuch"]).assert_success().stdout_eq(str![[ + r#" + + + + + +"# + ]]); +} + +#[forgetest] +fn can_write_json_results_without_changing_stdout(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.insert_console(); + prj.add_source("Simple.t.sol", SIMPLE_CONTRACT); + + let json_path = prj.root().join("test-results.json"); + cmd.forge_fuse().args(["test", "--json-file"]).arg(&json_path).assert_success().stdout_eq( + str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for src/Simple.t.sol:SimpleContractTest +[PASS] test() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]], + ); + + let results: serde_json::Value = + serde_json::from_slice(&std::fs::read(&json_path).unwrap()).unwrap(); + let result = &results["src/Simple.t.sol:SimpleContractTest"]["test_results"]["test()"]; + assert_eq!(result["status"], "Success"); + assert_eq!(result["logs"], serde_json::json!([])); + + let json_stdout = cmd + .forge_fuse() + .args(["test", "-vvvv", "--json"]) + .assert_success() + .get_output() + .stdout + .clone(); + cmd.forge_fuse().args(["test", "-vvvv", "--json-file"]).arg(&json_path).assert_success(); + let mut stdout_results: serde_json::Value = serde_json::from_slice(&json_stdout).unwrap(); + let mut file_results: serde_json::Value = + serde_json::from_slice(&std::fs::read(&json_path).unwrap()).unwrap(); + for results in [&mut stdout_results, &mut file_results] { + let suite = &mut results["src/Simple.t.sol:SimpleContractTest"]; + suite.as_object_mut().unwrap().remove("duration"); + suite["test_results"]["test()"].as_object_mut().unwrap().remove("duration"); + } + assert_eq!(file_results, stdout_results); + + prj.add_test( + "Failing.t.sol", + r#" +contract FailingTest { + function testFail() public pure { + require(false, "boom"); + } +} +"#, + ); + cmd.forge_fuse() + .args(["test", "--match-test", "testFail", "--json-file"]) + .arg(&json_path) + .assert_failure(); + let failed_results: serde_json::Value = + serde_json::from_slice(&std::fs::read(json_path).unwrap()).unwrap(); + assert_eq!( + failed_results["test/Failing.t.sol:FailingTest"]["test_results"]["testFail()"]["status"], + "Failure" + ); +} + +#[forgetest] +fn json_file_fail_fast_preserves_completed_suites(prj: _, cmd: _) { + prj.add_test( + "Failing.t.sol", + r#" +interface VmFail { + function sleep(uint256 milliseconds) external; +} + +contract FailingTest { + VmFail constant vm = VmFail(address(uint160(uint256(keccak256("hevm cheat code"))))); + + function testBreaks() public { + // Let both suites start, but finish this one first. + vm.sleep(100); + require(false, "boom"); + } +} +"#, + ); + prj.add_test( + "Passing.t.sol", + r#" +interface VmPass { + function sleep(uint256 milliseconds) external; +} + +contract PassingTest { + VmPass constant vm = VmPass(address(uint160(uint256(keccak256("hevm cheat code"))))); + + function testPass() public { + // Complete after the failing suite has stopped console output. + vm.sleep(500); + } +} +"#, + ); + + let json_path = prj.root().join("test-results.json"); + let output = cmd + .args(["test", "--fail-fast", "-j", "2", "--json-file"]) + .arg(&json_path) + .assert_failure(); + + assert!( + json_path.exists(), + "stdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.get_output().stdout), + String::from_utf8_lossy(&output.get_output().stderr) + ); + let stdout = String::from_utf8_lossy(&output.get_output().stdout); + assert!(!stdout.contains("Ran 1 test for test/Passing.t.sol:PassingTest")); + assert!(stdout.contains("Encountered a total of 1 failing tests, 0 tests succeeded")); + + let results: serde_json::Value = + serde_json::from_slice(&std::fs::read(json_path).unwrap()).unwrap(); + assert_eq!( + results["test/Failing.t.sol:FailingTest"]["test_results"]["testBreaks()"]["status"], + "Failure" + ); + assert_eq!( + results["test/Passing.t.sol:PassingTest"]["test_results"]["testPass()"]["status"], + "Success" + ); +} + +// tests that `forge test` will pick up tests that are stored in the `test = ` config value +#[forgetest] +fn can_run_test_in_custom_test_folder(prj: _, cmd: _) { + prj.insert_ds_test(); + + // explicitly set the test folder + prj.update_config(|config| config.test = "nested/forge-tests".into()); + + let config = cmd.config(); + assert_eq!(config.test, PathBuf::from("nested/forge-tests")); + + prj.add_source( + "nested/forge-tests/MyTest.t.sol", + r#" +import "../../test.sol"; +contract MyTest is DSTest { + function testTrue() public { + assertTrue(true); + } +} + "#, + ); + + cmd.arg("test").assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for src/nested/forge-tests/MyTest.t.sol:MyTest +[PASS] testTrue() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +} + +// checks that forge test repeatedly produces the same output +#[forgetest_init] +fn can_test_repeatedly(prj: _, cmd: _) { + prj.initialize_default_contracts(); + prj.clear(); + + cmd.arg("test").assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 2 tests for test/Counter.t.sol:CounterTest +[PASS] testFuzz_SetNumber(uint256) (runs: 256, [AVG_GAS]) +[PASS] test_Increment() ([GAS]) +Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 2 tests passed, 0 failed, 0 skipped (2 total tests) + +"#]]); + + for _ in 0..5 { + cmd.assert_success().stdout_eq(str![[r#" +No files changed, compilation skipped + +Ran 2 tests for test/Counter.t.sol:CounterTest +[PASS] testFuzz_SetNumber(uint256) (runs: 256, [AVG_GAS]) +[PASS] test_Increment() ([GAS]) +Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 2 tests passed, 0 failed, 0 skipped (2 total tests) + +"#]]); + } +} + +// tests that `forge test` will run a test only once after changing the version +#[forgetest] +fn runs_tests_exactly_once_with_changed_versions(prj: _, cmd: _) { + prj.insert_ds_test(); + + prj.add_source( + "Contract.t.sol", + r#" +pragma solidity *; + +import "./test.sol"; + +contract ContractTest is DSTest { + function setUp() public {} + + function testExample() public { + assertTrue(true); + } +} + "#, + ); + + // pin version + prj.update_config(|config| { + config.solc = Some(SOLC_VERSION.into()); + }); + + cmd.arg("test").assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for src/Contract.t.sol:ContractTest +[PASS] testExample() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); + + // pin version + prj.update_config(|config| { + config.solc = Some(OTHER_SOLC_VERSION.into()); + }); + + cmd.forge_fuse().arg("test").assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for src/Contract.t.sol:ContractTest +[PASS] testExample() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +} + +#[forgetest_init] +fn links_library_artifacts_across_versions(prj: _, cmd: _) { + prj.wipe_contracts(); + prj.update_config(|config| config.solc = None); + + prj.add_source( + "Lib.sol", + r#" +pragma solidity >=0.8.0; + +library Lib { + function identity(uint256 value) external pure returns (uint256) { + return value; + } +} +"#, + ); + prj.add_test( + "New.t.sol", + &format!( + r#" +pragma solidity {SOLC_VERSION}; + +import "src/Lib.sol"; + +contract NewTest {{ + function testIdentity() public {{ + require(Lib.identity(1) == 1); + }} +}} +"# + ), + ); + prj.add_test( + "Old.t.sol", + &format!( + r#" +pragma solidity {OTHER_SOLC_VERSION}; + +import "src/Lib.sol"; + +contract OldTest {{ + function testIdentity() public {{ + require(Lib.identity(1) == 1); + }} +}} +"# + ), + ); + + cmd.arg("test").assert_success(); + + prj.update_config(|config| config.create2_deployer = Address::ZERO); + cmd.forge_fuse().arg("test").assert_success(); +} + +#[forgetest_init] +fn links_library_artifacts_across_compiler_profiles(prj: _, cmd: _) { + prj.wipe_contracts(); + prj.add_source( + "Lib.sol", + r#" +pragma solidity >=0.8.0; + +library Lib { + function identity(uint256 value) external pure returns (uint256) { + return value; + } +} +"#, + ); + prj.add_source( + "Prod.sol", + r#" +pragma solidity >=0.8.0; + +import "src/Lib.sol"; + +contract Prod { + function identity(uint256 value) external view returns (uint256) { + return Lib.identity(value); + } +} +"#, + ); + prj.add_test( + "Profiles.t.sol", + r#" +pragma solidity >=0.8.0; + +import "src/Lib.sol"; +import "src/Prod.sol"; + +contract ProfilesTest { + function testProfiles() public { + require(Lib.identity(1) == 1); + require(new Prod().identity(2) == 2); + } +} +"#, + ); + prj.update_config(|config| { + config.additional_compiler_profiles = vec![SettingsOverrides { + name: "prod".to_string(), + via_ir: Some(true), + evm_version: None, + optimizer: Some(true), + optimizer_runs: Some(1), + bytecode_hash: None, + }]; + config.compilation_restrictions = vec![CompilationRestrictions { + paths: GlobMatcher::from_str("src/Prod.sol").unwrap(), + version: None, + via_ir: Some(true), + bytecode_hash: None, + min_optimizer_runs: None, + optimizer_runs: Some(1), + max_optimizer_runs: None, + min_evm_version: None, + evm_version: None, + max_evm_version: None, + }]; + }); + + cmd.arg("test").assert_success(); + assert!(prj.artifacts().join("Lib.sol/Lib.json").exists()); + assert!(prj.artifacts().join("Lib.sol/Lib.prod.json").exists()); + + prj.add_source( + "Prod.sol", + r#" +pragma solidity >=0.8.0; + +import "src/Lib.sol"; + +contract Prod { + function identity(uint256 value) external view returns (uint256) { + return Lib.identity(value + 0); + } +} +"#, + ); + cmd.forge_fuse().arg("test").assert_success(); + + prj.update_config(|config| config.create2_deployer = Address::ZERO); + cmd.forge_fuse().arg("test").assert_success(); +} + +#[cfg(unix)] +#[forgetest_init] +fn links_libraries_through_workspace_symlinks(prj: _, cmd: _) { + let workspace = prj.root().join("workspace"); + let airdrops = workspace.join("airdrops"); + let libraries = workspace.join("library/src/libraries"); + let package_scope = workspace.join("node_modules/@workspace"); + fs::create_dir_all(airdrops.join("src")).unwrap(); + fs::create_dir_all(airdrops.join("test")).unwrap(); + fs::create_dir_all(&libraries).unwrap(); + fs::create_dir_all(&package_scope).unwrap(); + std::os::unix::fs::symlink("../../library", package_scope.join("library")).unwrap(); + + fs::write( + airdrops.join("foundry.toml"), + r#" +[profile.default] +allow_paths = ["../"] +src = "src" +test = "test" +out = "out" +remappings = ["@workspace/=../node_modules/@workspace/"] +"#, + ) + .unwrap(); + fs::write( + libraries.join("Math.sol"), + r#" +pragma solidity >=0.8.0; + +library Math { + function increment(uint256 value) external pure returns (uint256) { + return value + 1; + } +} +"#, + ) + .unwrap(); + fs::write( + libraries.join("Helpers.sol"), + r#" +pragma solidity >=0.8.0; + +import {Math} from "./Math.sol"; + +library Helpers { + function addTwo(uint256 value) external pure returns (uint256) { + return Math.increment(Math.increment(value)); + } +} +"#, + ) + .unwrap(); + fs::write( + airdrops.join("src/Consumer.sol"), + r#" +pragma solidity >=0.8.0; + +import {Helpers} from "@workspace/library/src/libraries/Helpers.sol"; + +contract Consumer { + function addTwo(uint256 value) external pure returns (uint256) { + return Helpers.addTwo(value); + } +} +"#, + ) + .unwrap(); + fs::write( + airdrops.join("test/Consumer.t.sol"), + r#" +pragma solidity >=0.8.0; + +import {Consumer} from "../src/Consumer.sol"; + +contract ConsumerTest { + function testAddTwo() external { + Consumer consumer = new Consumer(); + require(consumer.addTwo(1) == 3); + } +} +"#, + ) + .unwrap(); + cmd.current_dir(&airdrops).arg("build").assert_success(); + cmd.forge_fuse().current_dir(&airdrops).arg("test").assert_success(); + cmd.forge_fuse() + .current_dir(&airdrops) + .args(["test", "--create2-deployer", "0x0000000000000000000000000000000000000000"]) + .assert_success(); + writeln!( + fs::OpenOptions::new().append(true).open(airdrops.join("foundry.toml")).unwrap(), + "libraries = [\"../library/src/libraries/Helpers.sol:Helpers:0x1111111111111111111111111111111111111111\"]" + ) + .unwrap(); + let stdout = cmd + .forge_fuse() + .current_dir(&airdrops) + .args(["test", "-vvvv"]) + .assert_failure() + .get_output() + .stdout_lossy(); + assert!(stdout.contains("0x1111111111111111111111111111111111111111"), "{stdout}"); +} + +#[forgetest_init] +fn create2_factory_is_installed_after_constructor_when_no_libraries(prj: _, cmd: _) { + prj.wipe_contracts(); + prj.add_test( + "Factory.t.sol", + r#" +pragma solidity >=0.8.0; + +contract FactoryTest { + address constant CREATE2_FACTORY = 0x4e59b44847b379578588920cA78FbF26c0B4956C; + + constructor() { + require(CREATE2_FACTORY.code.length == 0, "factory installed before constructor"); + } + + function testFactoryInstalledAfterConstructor() public view { + require(CREATE2_FACTORY.code.length > 0, "factory not installed after constructor"); + } +} +"#, + ); + + cmd.arg("test").assert_success(); +} + +// tests that libraries are handled correctly in multiforking mode +#[forgetest_init] +fn can_use_libs_in_multi_fork(prj: _, cmd: _) { + prj.add_source( + "Contract.sol", + r" +library Library { + function f(uint256 a, uint256 b) public pure returns (uint256) { + return a + b; + } +} + +contract Contract { + uint256 c; + + constructor() { + c = Library.f(1, 2); + } +} + ", + ); + + let endpoint = rpc::next_http_archive_rpc_url(); + + prj.add_test( + "Contract.t.sol", + &r#" +import "forge-std/Test.sol"; +import "src/Contract.sol"; + +contract ContractTest is Test { + function setUp() public { + vm.createSelectFork(""); + } + + function test() public { + new Contract(); + } +} + "# + .replace("", &endpoint), + ); + + cmd.arg("test").assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/Contract.t.sol:ContractTest +[PASS] test() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +} + +static FAILING_TEST: &str = r#" +import "forge-std/Test.sol"; + +contract FailingTest is Test { + function testShouldFail() public { + assertTrue(false); + } +} +"#; + +#[forgetest_init] +fn exit_code_error_on_fail_fast(prj: _, cmd: _) { + prj.add_source("failing_test", FAILING_TEST); + + cmd.args(["test", "--fail-fast"]); + + cmd.assert_empty_stderr(); +} + +#[forgetest_init] +fn exit_code_error_on_fail_fast_with_json(prj: _, cmd: _) { + prj.add_source("failing_test", FAILING_TEST); + cmd.args(["test", "--fail-fast", "--json"]); + + cmd.assert_empty_stderr(); +} + +// Verify that --show-progress doesn't stop tests after first failure +#[forgetest_init] +fn show_progress_runs_all_tests(prj: _, cmd: _) { + prj.add_test( + "MultiTest.t.sol", + r#" +import "forge-std/Test.sol"; + +contract MultiTest is Test { + function test_1_Fail() public { + assertTrue(false); + } + + function test_2_Pass() public { + assertTrue(true); + } + + function test_3_Pass() public { + assertTrue(true); + } +} +"#, + ); + + // With --show-progress, all 3 tests should run despite first one failing + let output = cmd.args(["test", "--show-progress", "-j1"]).assert_failure(); + let stdout = String::from_utf8_lossy(&output.get_output().stdout); + + // Verify all 3 tests were executed + assert!(stdout.contains("test_1_Fail"), "test_1_Fail should run"); + assert!(stdout.contains("test_2_Pass"), "test_2_Pass should run"); + assert!(stdout.contains("test_3_Pass"), "test_3_Pass should run"); + assert!(stdout.contains("2 passed; 1 failed"), "Should show 2 passed and 1 failed"); +} + +// Verify that --show-progress with --fail-fast DOES stop after first failure +#[forgetest_init] +fn show_progress_with_fail_fast_exits_early(prj: _, cmd: _) { + prj.add_test( + "MultiTest.t.sol", + r#" +import "forge-std/Test.sol"; + +contract MultiTest is Test { + function test_1_Fail() public { + assertTrue(false); + } + + function test_2_SlowPass() public { + vm.sleep(60000); // Sleep for 60 seconds to ensure fail-fast stops before this completes + assertTrue(true); + } + + function test_3_SlowPass() public { + vm.sleep(60000); // Sleep for 60 seconds to ensure fail-fast stops before this completes + assertTrue(true); + } +} +"#, + ); + + // With both --show-progress and --fail-fast, should stop after first failure + let output = cmd.args(["test", "--show-progress", "--fail-fast", "-j1"]).assert_failure(); + let stdout = String::from_utf8_lossy(&output.get_output().stdout); + + // Verify first test ran and failed + assert!(stdout.contains("test_1_Fail"), "test_1_Fail should run"); + + // With -j1 (sequential execution) and fail-fast, the slow tests should not run + // since test_1_Fail will fail first + let slow_tests_count = (if stdout.contains("test_2_SlowPass") { 1 } else { 0 }) + + (if stdout.contains("test_3_SlowPass") { 1 } else { 0 }); + + assert!( + slow_tests_count < 2, + "With --fail-fast and sequential execution, not all slow tests should run after first failure" + ); +} + +// Regression: a `setUp()` failure in one suite must trip the global fail-fast flag so that +// long-running invariant campaigns in sibling suites (dispatched in parallel via rayon) exit +// at their next run boundary instead of running to their configured timeout. +#[forgetest_init] +fn fail_fast_cancels_invariant_after_setup_failure(prj: _, cmd: _) { + // Need at least 2 worker threads so the two suites run concurrently. + if std::thread::available_parallelism().map_or(1, |n| n.get()) < 2 { + return; + } + + // Use a very large invariant timeout so the with-fix vs without-fix behavior is + // dramatically different: with the fix the campaign sees `should_stop()` and exits within + // one fuzz sequence (~seconds); without the fix it would run for the full timeout. Same + // canary pattern as `test_fail_fast_config`. + prj.update_config(|config| { + config.invariant.timeout = Some(3600); + }); + + prj.add_test( + "BrokenSetup.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract BrokenSetupTest is Test { + function setUp() public pure { + require(false, "setUp failure"); + } + + function test_Noop() public pure {} +} +"#, + ); + + prj.add_test( + "LongInvariant.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract Target { + uint256 public x; + function bump() external { x += 1; } +} + +contract LongInvariantTest is Test { + Target internal target; + + function setUp() public { + target = new Target(); + targetContract(address(target)); + } + + function invariant_alwaysTrue() public view { + require(true); + } +} +"#, + ); + + let started = std::time::Instant::now(); + cmd.args(["test", "--fail-fast", "--mc", "BrokenSetupTest|LongInvariantTest"]).assert_failure(); + let elapsed = started.elapsed(); + + // The two suites run in parallel; the broken `setUp()` reverts within milliseconds and must + // cancel the in-flight invariant campaign almost immediately. The 1h invariant timeout above + // gives us a huge margin: with the fix elapsed is ~seconds; without it elapsed approaches + // 1h. 60s is generous slack for CI but still catches the regression unambiguously. + assert!( + elapsed < std::time::Duration::from_secs(60), + "fail-fast did not cancel the in-flight invariant campaign: elapsed {elapsed:?}", + ); +} + +// Fuzz, table and invariant campaigns cut short by fail-fast must not be reported as passing, +// while a timed campaign that already finished its planned runs still passes. +#[forgetest] +fn fail_fast_reports_interrupted_campaigns_as_skipped(prj: _, cmd: _) { + prj.update_config(|config| { + config.fuzz.runs = 50; + config.invariant.runs = 50; + config.invariant.depth = 1; + config.invariant.show_metrics = false; + }); + + prj.add_test( + "Interrupted.t.sol", + r#" +interface Vm { + function sleep(uint256 milliseconds) external; +} + +contract SlowTarget { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + function bump() external { + vm.sleep(200); + } +} + +contract InterruptedTest { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + SlowTarget target; + uint256[] public fixtureX = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]; + + function setUp() public { + target = new SlowTarget(); + } + + function invariant_slow() public view {} + + function invariant_slowToo() public view {} + + function testFuzz_slow(uint256) public { + vm.sleep(200); + } + + /// forge-config: default.fuzz.runs = 1 + /// forge-config: default.fuzz.timeout = 3600 + function testFuzz_completes(uint256) public { + vm.sleep(2000); + } + + function tableSlow(uint256 x) public { + vm.sleep(200 + x); + } + + function test_fails() public { + vm.sleep(1000); + revert("boom"); + } +} +"#, + ); + + // One thread per test so all campaigns are in flight when `test_fails` trips fail-fast. + cmd.args(["test", "--fail-fast", "-j5"]).assert_failure().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 6 tests for test/Interrupted.t.sol:InterruptedTest +[SKIP] +InterruptedTest invariants: +[SKIP: interrupted] invariant_slow +[SKIP: interrupted] invariant_slowToo + InterruptedTest invariants (runs: [..], calls: [..], reverts: 0) +[SKIP: interrupted] tableSlow(uint256) (runs: [..], [AVG_GAS]) +[PASS] testFuzz_completes(uint256) (runs: 1, [AVG_GAS]) +[SKIP: interrupted] testFuzz_slow(uint256) (runs: [..], [AVG_GAS]) +[FAIL: boom] test_fails() ([GAS]) +Suite result: FAILED. 1 passed; 1 failed; 4 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 1 failed, 4 skipped (6 total tests) + +Failing tests: +Encountered 1 failing test in test/Interrupted.t.sol:InterruptedTest +[FAIL: boom] test_fails() ([GAS]) + +Encountered a total of 1 failing tests, 1 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test +Tip: Run `forge test --debug --match-test ` to inspect one failing test in the debugger + +"#]]); +} + +// https://github.com/foundry-rs/foundry/pull/6531 +#[forgetest_init] +fn fork_traces(prj: _, cmd: _) { + let endpoint = rpc::next_http_archive_rpc_url(); + + prj.add_test( + "Contract.t.sol", + &r#" +import {Test} from "forge-std/Test.sol"; + +interface IERC20 { + function name() external view returns (string memory); +} + +contract USDTCallingTest is Test { + function test() public { + vm.createSelectFork(""); + IERC20(0xdAC17F958D2ee523a2206206994597C13D831ec7).name(); + } +} + "# + .replace("", &endpoint), + ); + + cmd.args(["test", "-vvvv"]).assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/Contract.t.sol:USDTCallingTest +[PASS] test() ([GAS]) +Traces: + [..] USDTCallingTest::test() + ├─ [0] VM::createSelectFork("[..]") + │ └─ ← [Return] 0 + ├─ [3110] 0xdAC17F958D2ee523a2206206994597C13D831ec7::name() [staticcall] + │ └─ ← [Return] "Tether USD" + └─ ← [Stop] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +} + +// +#[forgetest] +async fn fork_endpoint_without_anvil_node_info(prj: _, cmd: _) { + let (api, handle) = spawn(NodeConfig::test().with_chain_id(Some(1u64))).await; + api.anvil_mine(Some(U256::ONE), None).await.unwrap(); + let endpoint = + rpc::spawn_rpc_proxy_rejecting_method_after(handle.http_endpoint(), "anvil_nodeInfo", 0) + .await; + + prj.add_test( + "NonAnvilFork.t.sol", + r#" +contract NonAnvilForkTest { + function testFork() external view { + require(block.chainid == 1, "wrong chain"); + require(block.number == 1, "wrong block"); + } +} +"#, + ); + + cmd.args(["test", "--fork-url", &endpoint, "--match-test", "testFork"]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/NonAnvilFork.t.sol:NonAnvilForkTest +[PASS] testFork() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +// A cheatcode fork whose alias auth differs from the `--fork-url` credentials must not reuse the +// endpoint identity discovered for the same URL, since the credentials may reach another backend. +#[forgetest] +async fn fork_alias_auth_rediscovers_same_url_endpoint(prj: _, cmd: _) { + let (_, anonymous) = spawn(NodeConfig::test().with_chain_id(Some(1u64))).await; + let anonymous = + rpc::spawn_rpc_proxy_rejecting_method_after(anonymous.http_endpoint(), "anvil_nodeInfo", 0) + .await; + let (_, authenticated) = spawn(NodeConfig::test().with_chain_id(Some(31337u64))).await; + let authenticated = authenticated.http_endpoint(); + let client = reqwest::Client::new(); + let router = axum::Router::new().route( + "/", + axum::routing::post( + move |headers: axum::http::HeaderMap, + axum::Json(request): axum::Json| { + let target = if headers.contains_key("authorization") { + authenticated.clone() + } else { + anonymous.clone() + }; + let client = client.clone(); + async move { + let response = client.post(target).json(&request).send().await.unwrap(); + axum::Json(response.json::().await.unwrap()) + } + }, + ), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + std::fs::write( + prj.config(), + format!( + r#"[rpc_endpoints] +authenticated = {{ endpoint = "{endpoint}", auth = "Bearer secret" }} +"# + ), + ) + .unwrap(); + + prj.add_test( + "ForkAliasAuth.t.sol", + r#" +interface Vm { + function createSelectFork(string calldata urlOrAlias) external returns (uint256 forkId); +} + +contract ForkAliasAuthTest { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + function testForkAliasAuth() external { + require(block.chainid == 1, "wrong base chain"); + vm.createSelectFork("authenticated"); + require(block.chainid == 31337, "wrong alias chain"); + } +} +"#, + ); + + cmd.args(["test", "--fork-url", &endpoint, "--match-test", "testForkAliasAuth"]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/ForkAliasAuth.t.sol:ForkAliasAuthTest +[PASS] testForkAliasAuth() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +// +#[forgetest] +async fn failed_fork_test_reports_block_number(prj: _, cmd: _) { + let (api, handle) = spawn(NodeConfig::test()).await; + api.anvil_mine(Some(U256::from(7)), None).await.unwrap(); + let endpoint = handle.http_endpoint(); + + prj.add_test( + "ForkBlock.t.sol", + &format!( + r#" +interface Vm {{ + function createSelectFork(string calldata url, uint256 blockNumber) + external + returns (uint256 forkId); + function roll(uint256 newHeight) external; + function rollFork(uint256 blockNumber) external; + function snapshotState() external returns (uint256 snapshotId); + function revertToState(uint256 snapshotId) external returns (bool success); +}} + +contract ForkBlockTest {{ + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + function testForkFailure() public {{ + vm.createSelectFork("{endpoint}", 7); + require(false, "fork failure"); + }} + + function testForkFailureAfterRoll() public {{ + vm.createSelectFork("{endpoint}", 7); + vm.roll(99); + require(false, "fork failure after roll"); + }} + + function testForkFailureAfterRevert() public {{ + vm.createSelectFork("{endpoint}", 7); + uint256 snapshot = vm.snapshotState(); + vm.rollFork(6); + vm.revertToState(snapshot); + require(false, "fork failure after revert"); + }} + + function testForkSuccess() public {{ + vm.createSelectFork("{endpoint}", 7); + }} + + function testLocalFailure() public pure {{ + require(false, "local failure"); + }} + + function testLocalSuccess() public pure {{}} +}} +"# + ), + ); + + cmd.arg("test").assert_failure().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 6 tests for test/ForkBlock.t.sol:ForkBlockTest +[FAIL: fork failure] testForkFailure() (block: 7) ([GAS]) +[FAIL: fork failure after revert] testForkFailureAfterRevert() (block: 7) ([GAS]) +[FAIL: fork failure after roll] testForkFailureAfterRoll() (block: 7) ([GAS]) +[PASS] testForkSuccess() ([GAS]) +[FAIL: local failure] testLocalFailure() ([GAS]) +[PASS] testLocalSuccess() ([GAS]) +Suite result: FAILED. 2 passed; 4 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 2 tests passed, 4 failed, 0 skipped (6 total tests) + +Failing tests: +Encountered 4 failing tests in test/ForkBlock.t.sol:ForkBlockTest +[FAIL: fork failure] testForkFailure() (block: 7) ([GAS]) +[FAIL: fork failure after revert] testForkFailureAfterRevert() (block: 7) ([GAS]) +[FAIL: fork failure after roll] testForkFailureAfterRoll() (block: 7) ([GAS]) +[FAIL: local failure] testLocalFailure() ([GAS]) + +Encountered a total of 4 failing tests, 2 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 4 failed tests +Tip: Run `forge test --debug --match-test ` to inspect one failing test in the debugger + +"#]]); +} + +// Validates BPO1 blob gas price calculation during fork transaction replay. +// Block 24127158 has a blob tx at index 0, target tx at index 1. +// Forking at the target tx replays the blob tx with correct BPO1 blob base fee calculation. +#[forgetest_init] +fn fork_tx_replay_bpo1_blob_base_fee(prj: _, cmd: _) { + let endpoint = rpc::next_http_archive_rpc_url(); + + prj.add_test( + "BlobFork.t.sol", + &r#" +import {Test} from "forge-std/Test.sol"; + +contract BlobForkTest is Test { + function test_fork_with_blob_replay() public { + // Fork at tx index 1 in block 24127158, which replays blob tx at index 0 + bytes32 txHash = 0xa0f349b16e0f338ee760a9954ff5dbf2a402cff3320f3fe2c3755aee8babc335; + vm.createSelectFork("", txHash); + // If we get here, blob tx replay succeeded + assertTrue(true); + } +} + "# + .replace("", &endpoint), + ); + + cmd.args(["test", "-vvvv"]).assert_success(); +} + +// https://github.com/foundry-rs/foundry/issues/10689 +#[forgetest_init] +fn flaky_roll_fork_arbitrum_priority_fee_above_max_fee(prj: _, cmd: _) { + let endpoint = "https://arb-mainnet.g.alchemy.com/public"; + + prj.add_test( + "ArbitrumFork.t.sol", + &r#" +import {Test} from "forge-std/Test.sol"; + +contract ArbitrumForkTest is Test { + function test_rollFork() public { + uint256 forkId = vm.createFork(""); + bytes32 txHash = 0x2e43e9ececcbb9cd08ce061edc3b4d39ca2b0ba480034e5f4650ba0065bf6b62; + vm.rollFork(forkId, txHash); + } +} + "# + .replace("", endpoint), + ); + + cmd.arg("test").assert_success(); +} + +// https://github.com/foundry-rs/foundry/issues/6579 +#[forgetest_init] +fn include_custom_types_in_traces(prj: _, cmd: _) { + prj.add_test( + "Contract.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +error PoolNotInitialized(); +event MyEvent(uint256 a); + +contract CustomTypesTest is Test { + function testErr() public pure { + revert PoolNotInitialized(); + } + function testEvent() public { + emit MyEvent(100); + } +} + "#, + ); + + cmd.args(["test", "-vvvvv"]).assert_failure().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 2 tests for test/Contract.t.sol:CustomTypesTest +[FAIL: PoolNotInitialized()] testErr() ([GAS]) +Traces: + [247] CustomTypesTest::testErr() + └─ ← [Revert] PoolNotInitialized() + +Backtrace: + at CustomTypesTest.testErr (test/Contract.t.sol:[..]:[..]) + +[PASS] testEvent() ([GAS]) +Traces: + [1524] CustomTypesTest::testEvent() + ├─ emit MyEvent(a: 100) + └─ ← [Stop] + +Suite result: FAILED. 1 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 1 failed, 0 skipped (2 total tests) + +Failing tests: +Encountered 1 failing test in test/Contract.t.sol:CustomTypesTest +[FAIL: PoolNotInitialized()] testErr() ([GAS]) + +Encountered a total of 1 failing tests, 1 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test +Tip: Run `forge test --debug --match-test ` to inspect one failing test in the debugger + +"#]]); +} + +#[forgetest_init] +fn can_test_transient_storage_with_isolation(prj: _, cmd: _) { + prj.add_test( + "Contract.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract TransientTester { + function locked() public view returns (bool isLocked) { + assembly { + isLocked := tload(0) + } + } + + modifier lock() { + require(!locked(), "locked"); + assembly { + tstore(0, 1) + } + _; + } + + function maybeReentrant(address target, bytes memory data) public lock { + (bool success, bytes memory ret) = target.call(data); + if (!success) { + // forwards revert reason + assembly { + let ret_size := mload(ret) + revert(add(32, ret), ret_size) + } + } + } +} + +contract TransientTest is Test { + function test() public { + TransientTester t = new TransientTester(); + vm.expectRevert(bytes("locked")); + t.maybeReentrant(address(t), abi.encodeCall(TransientTester.maybeReentrant, (address(0), new bytes(0)))); + + t.maybeReentrant(address(0), new bytes(0)); + assertEq(t.locked(), false); + } +} + "#, + ); + + cmd.args(["test", "-vvvv", "--isolate", "--evm-version", "cancun"]).assert_success(); +} + +#[forgetest_init] +fn eip2935_history_storage_in_prague_tests(prj: _, cmd: _) { + prj.add_test( + "EIP2935.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract EIP2935Test is Test { + address constant HISTORY = 0x0000F90827F1C53a10cb7A02335B175320002935; + + function firstAccessGas(address target) internal view returns (uint256 used, bytes32 codehash) { + assembly { + let gasBefore := gas() + codehash := extcodehash(target) + used := sub(gasBefore, gas()) + } + } + + function historyReadGas(uint256 blockNumber) internal view returns (uint256 used) { + bytes memory data = abi.encodePacked(bytes32(blockNumber)); + bool ok; + bytes memory ret; + uint256 gasBefore = gasleft(); + (ok, ret) = HISTORY.staticcall(data); + used = gasBefore - gasleft(); + assertTrue(ok, "history call failed"); + assertEq(ret.length, 32, "history return length"); + } + + function testHistoryContractDeployed() public { + assertGt(HISTORY.code.length, 0, "history contract missing"); + } + + function testInitialHistoryWindowSeeded() public { + bytes32 expected = blockhash(99); + + (bool ok, bytes memory ret) = HISTORY.staticcall(abi.encodePacked(bytes32(uint256(99)))); + assertTrue(ok, "history call failed"); + assertEq(ret.length, 32, "history return length"); + assertEq(bytes32(ret), expected, "initial history hash"); + } + + function testRollStoresParentBlockHash() public { + vm.roll(1000); + bytes32 expected = keccak256("parent"); + vm.setBlockhash(1000, expected); + + vm.roll(1001); + + (bool ok, bytes memory ret) = HISTORY.staticcall(abi.encodePacked(bytes32(uint256(1000)))); + assertTrue(ok, "history call failed"); + assertEq(ret.length, 32, "history return length"); + assertEq(bytes32(ret), expected, "stored parent hash"); + } + + function testSetBlockhashPopulatesHistory() public { + vm.roll(2000); + bytes32 expected = keccak256("history"); + vm.setBlockhash(1999, expected); + + assertEq(blockhash(1999), expected); + + (bool ok, bytes memory ret) = HISTORY.staticcall(abi.encodePacked(bytes32(uint256(1999)))); + assertTrue(ok, "history call failed"); + assertEq(ret.length, 32, "history return length"); + assertEq(bytes32(ret), expected, "stored history hash"); + } + + function testSetBlockhashUpdatesCachedHistorySlot() public { + vm.roll(2000); + bytes32 expected = keccak256("cached history"); + + (bool ok, bytes memory ret) = HISTORY.staticcall(abi.encodePacked(bytes32(uint256(1998)))); + assertTrue(ok, "history call failed"); + assertEq(ret.length, 32, "history return length"); + assertNotEq(bytes32(ret), expected, "history precondition"); + + vm.setBlockhash(1998, expected); + + (ok, ret) = HISTORY.staticcall(abi.encodePacked(bytes32(uint256(1998)))); + assertTrue(ok, "history call failed"); + assertEq(ret.length, 32, "history return length"); + assertEq(bytes32(ret), expected, "cached history hash"); + } + + function testLargeRollBackfillsHistoryWindow() public { + vm.roll(1000); + vm.setBlockhash(1000, keccak256("block 1000")); + + vm.roll(2000); + + (bool ok, bytes memory ret) = HISTORY.staticcall(abi.encodePacked(bytes32(uint256(1000)))); + assertTrue(ok, "history call failed"); + assertEq(ret.length, 32, "history return length"); + assertEq(bytes32(ret), keccak256("block 1000"), "block 1000 hash"); + } + + function testRollDoesNotWarmHistoryAccountOrSlot() public { + vm.roll(3000); + + (uint256 accountGas, bytes32 codehash) = firstAccessGas(HISTORY); + assertNotEq(codehash, bytes32(0), "history code hash"); + assertGt(accountGas, 2000, "history account warm after roll"); + uint256 firstRead = historyReadGas(2999); + assertGt(firstRead, 2000, "history slot warm after roll"); + } + + function testSetBlockhashDoesNotCorruptHistoryRing() public { + vm.roll(20000); + bytes32 ancient = keccak256("ancient"); + vm.setBlockhash(0, ancient); + + (bool ok, bytes memory ret) = HISTORY.staticcall(abi.encodePacked(bytes32(uint256(16382)))); + assertTrue(ok, "history call failed"); + assertEq(ret.length, 32, "history return length"); + assertNotEq(bytes32(ret), ancient, "ring collision"); + } + + function testSetBlockhashCurrentBlockDoesNotCorruptOldestSlot() public { + vm.roll(8192); + bytes32 oldest = keccak256("oldest"); + bytes32 current = keccak256("current"); + vm.setBlockhash(1, oldest); + + vm.setBlockhash(8192, current); + + (bool ok, bytes memory ret) = HISTORY.staticcall(abi.encodePacked(bytes32(uint256(1)))); + assertTrue(ok, "history call failed"); + assertEq(ret.length, 32, "history return length"); + assertEq(bytes32(ret), oldest, "oldest slot corrupted"); + } + + function testSetBlockhashDoesNotWarmHistoryAccountOrSlot() public { + vm.roll(4000); + vm.setBlockhash(3999, keccak256("warmth")); + + (uint256 accountGas, bytes32 codehash) = firstAccessGas(HISTORY); + assertNotEq(codehash, bytes32(0), "history code hash"); + assertGt(accountGas, 2000, "history account warm after setBlockhash"); + uint256 firstRead = historyReadGas(3999); + assertGt(firstRead, 2000, "history slot warm after setBlockhash"); + } + + function testRollDoesNotPopulateReplacedHistoryContract() public { + vm.etch(HISTORY, hex"00"); + + uint256 parent = 3000; + vm.roll(parent + 1); + + assertEq(vm.load(HISTORY, bytes32(parent % 8191)), bytes32(0), "replaced history slot"); + } + + function testRollDoesNotExposeSeededHistorySlotAfterEtch() public { + vm.etch(HISTORY, hex"00"); + + uint256 parent = 8191 * 1000 + 12; + vm.roll(parent + 1); + + assertEq(vm.load(HISTORY, bytes32(parent % 8191)), bytes32(0), "replaced seeded history slot"); + } + + function testSetBlockhashDoesNotPopulateReplacedHistoryContract() public { + vm.etch(HISTORY, hex"00"); + + uint256 blockNumber = 3999; + vm.roll(blockNumber + 1); + vm.setBlockhash(blockNumber, keccak256("replaced")); + + assertEq(blockhash(blockNumber), keccak256("replaced")); + assertEq(vm.load(HISTORY, bytes32(blockNumber % 8191)), bytes32(0), "replaced history slot"); + } +} +"#, + ); + + cmd.args(["test", "--evm-version", "prague", "--block-number", "100"]).assert_success(); +} + +// Replacing the history contract clears its ring buffer; reverts must restore that storage. +#[forgetest_init] +fn eip2935_history_storage_etch_rollback(prj: _, cmd: _) { + prj.add_test( + "EIP2935EtchRollback.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract EIP2935EtchRollbackTest is Test { + address constant HISTORY = 0x0000F90827F1C53a10cb7A02335B175320002935; + // Seeded ring slot that is read before the etch. + bytes32 constant LOADED = bytes32(uint256(1)); + // Seeded ring slot that is never read before the etch. + bytes32 constant UNLOADED = bytes32(uint256(50)); + // Ring slot written by the test. + bytes32 constant WRITTEN = bytes32(uint256(7000)); + // Slot outside the ring buffer. + bytes32 constant OUTSIDE = bytes32(uint256(9000)); + + bytes original; + bytes32 loadedValue; + uint64 nonce; + + function setUp() public { + original = HISTORY.code; + nonce = vm.getNonce(HISTORY); + loadedValue = vm.load(HISTORY, LOADED); + vm.store(HISTORY, WRITTEN, bytes32(uint256(17))); + vm.store(HISTORY, OUTSIDE, bytes32(uint256(18))); + } + + function child(bool fail) external { + vm.etch(HISTORY, hex"00"); + assertEq(vm.load(HISTORY, LOADED), bytes32(0), "loaded slot not cleared"); + assertEq(vm.load(HISTORY, UNLOADED), bytes32(0), "unloaded slot not cleared"); + assertEq(vm.load(HISTORY, WRITTEN), bytes32(0), "written slot not cleared"); + vm.store(HISTORY, WRITTEN, bytes32(uint256(0x22))); + if (fail) revert("child"); + } + + function parent(bool childFail, bool parentFail) external { + vm.store(HISTORY, WRITTEN, bytes32(uint256(0x44))); + if (childFail) { + try this.child(true) { + revert("child did not revert"); + } catch Error(string memory reason) { + assertEq(reason, "child"); + } + } else { + this.child(false); + } + if (parentFail) revert("parent"); + } + + function assertRestored() internal view { + assertEq(keccak256(HISTORY.code), keccak256(original), "code not restored"); + assertEq(vm.getNonce(HISTORY), nonce, "nonce changed"); + assertNotEq(loadedValue, bytes32(0), "loaded slot not seeded"); + assertEq(vm.load(HISTORY, LOADED), loadedValue, "loaded slot not restored"); + assertEq(vm.load(HISTORY, UNLOADED), blockhash(50), "unloaded slot not restored"); + assertEq(vm.load(HISTORY, OUTSIDE), bytes32(uint256(18)), "outside slot changed"); + } + + function testChildRevertRestoresHistoryStorage() public { + this.parent(true, false); + assertRestored(); + assertEq(vm.load(HISTORY, WRITTEN), bytes32(uint256(0x44)), "parent write lost"); + } + + function testParentRevertRestoresHistoryStorage() public { + try this.parent(false, true) { + revert("parent did not revert"); + } catch Error(string memory reason) { + assertEq(reason, "parent"); + } + assertRestored(); + assertEq(vm.load(HISTORY, WRITTEN), bytes32(uint256(17)), "written slot not restored"); + } + + function testSuccessfulEtchClearsHistoryStorage() public { + this.parent(false, false); + assertEq(HISTORY.code, hex"00", "code not replaced"); + assertEq(vm.load(HISTORY, LOADED), bytes32(0), "loaded slot not cleared"); + assertEq(vm.load(HISTORY, UNLOADED), bytes32(0), "unloaded slot not cleared"); + assertEq(vm.load(HISTORY, WRITTEN), bytes32(uint256(0x22)), "child write lost"); + assertEq(vm.load(HISTORY, OUTSIDE), bytes32(uint256(18)), "outside slot changed"); + + // Replacing a replacement keeps its storage. + vm.etch(HISTORY, hex"01"); + assertEq(vm.load(HISTORY, WRITTEN), bytes32(uint256(0x22)), "replacement storage lost"); + + // Reinstalling the history contract and replacing it again clears the ring buffer again. + vm.etch(HISTORY, original); + vm.store(HISTORY, LOADED, bytes32(uint256(0x55))); + vm.etch(HISTORY, hex"00"); + assertEq(vm.load(HISTORY, LOADED), bytes32(0), "ring buffer not cleared again"); + assertEq(vm.load(HISTORY, WRITTEN), bytes32(0), "written slot not cleared again"); + } + + function testStateSnapshotsAroundHistoryEtch() public { + uint256 beforeEtch = vm.snapshotState(); + vm.etch(HISTORY, hex"00"); + uint256 afterEtch = vm.snapshotState(); + vm.store(HISTORY, WRITTEN, bytes32(uint256(0x22))); + + vm.revertToState(afterEtch); + assertEq(vm.load(HISTORY, WRITTEN), bytes32(0), "post-etch snapshot not restored"); + + vm.revertToState(beforeEtch); + assertRestored(); + assertEq(vm.load(HISTORY, WRITTEN), bytes32(uint256(17)), "pre-etch snapshot not restored"); + } +} + +contract EIP2935EtchInSetUpTest is Test { + address constant HISTORY = 0x0000F90827F1C53a10cb7A02335B175320002935; + + function setUp() public { + vm.etch(HISTORY, hex"00"); + vm.store(HISTORY, bytes32(uint256(7000)), bytes32(uint256(0x22))); + } + + function testHistoryEtchInSetUpPersists() public view { + assertEq(HISTORY.code, hex"00", "code not replaced"); + assertEq(vm.load(HISTORY, bytes32(uint256(50))), bytes32(0), "seeded slot exposed"); + assertEq(vm.load(HISTORY, bytes32(uint256(7000))), bytes32(uint256(0x22)), "write lost"); + } +} +"#, + ); + + cmd.args(["test", "--evm-version", "prague", "--block-number", "100"]).assert_success(); +} + +// On a fork the history storage is the chain's real storage, so replacing the contract keeps it. +#[forgetest] +async fn eip2935_history_storage_etch_keeps_fork_storage(prj: _, cmd: _) { + let (api, handle) = + spawn(NodeConfig::test().with_hardfork(Some(EthereumHardfork::Prague.into()))).await; + api.anvil_mine(Some(U256::from(10)), None).await.unwrap(); + let endpoint = handle.http_endpoint(); + + prj.add_test( + "EIP2935EtchForkStorage.t.sol", + r#" +interface Vm { + function etch(address target, bytes calldata newRuntimeBytecode) external; + function load(address target, bytes32 slot) external view returns (bytes32 data); +} + +contract EIP2935EtchForkStorageTest { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + address constant HISTORY = 0x0000F90827F1C53a10cb7A02335B175320002935; + bytes32 constant SLOT = bytes32(uint256(5)); + + function child() external { + vm.etch(HISTORY, hex"00"); + require(vm.load(HISTORY, SLOT) == blockhash(5), "fork slot cleared"); + revert("child"); + } + + function testForkHistoryEtchKeepsStorage() public { + require(blockhash(5) != bytes32(0), "block hash unavailable"); + vm.etch(HISTORY, hex"00"); + require(keccak256(HISTORY.code) == keccak256(hex"00"), "code not replaced"); + require(vm.load(HISTORY, SLOT) == blockhash(5), "fork slot cleared"); + } + + function testForkHistoryEtchRevertRestoresCode() public { + bytes32 codehash = HISTORY.codehash; + require(blockhash(5) != bytes32(0), "block hash unavailable"); + try this.child() { + revert("child did not revert"); + } catch Error(string memory reason) { + require(keccak256(bytes(reason)) == keccak256("child"), reason); + } + require(HISTORY.codehash == codehash, "code not restored"); + require(vm.load(HISTORY, SLOT) == blockhash(5), "fork slot changed"); + } +} +"#, + ); + + cmd.args(["test", "--fork-url", &endpoint, "--evm-version", "prague"]).assert_success(); +} + +#[forgetest_init] +fn eip2935_history_storage_not_deployed_before_prague(prj: _, cmd: _) { + prj.add_test( + "EIP2935.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract EIP2935Test is Test { + address constant HISTORY = 0x0000F90827F1C53a10cb7A02335B175320002935; + + function testHistoryContractNotDeployed() public { + assertEq(HISTORY.code.length, 0, "history contract deployed before Prague"); + } +} +"#, + ); + + cmd.args(["test", "--evm-version", "cancun"]).assert_success(); +} + +#[forgetest_init] +fn setup_selfdestruct_deletes_same_tx_created_contracts(prj: _, cmd: _) { + prj.add_test( + "SetupSelfdestruct.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract A { + function codeLength() public view returns (uint256) { + return address(this).code.length; + } + + function kill() public { + selfdestruct(payable(address(0))); + } +} + +contract B { + uint256 private x; + + constructor(uint256 x_) { + x = x_; + } +} + +contract Factory { + function helloA() public returns (address) { + return address(new A()); + } + + function helloB() public returns (address) { + return address(new B(1337)); + } + + function kill() public { + selfdestruct(payable(address(0))); + } +} + +contract SetupSelfdestructTest is Test { + A private a; + B private b; + Factory private factory; + + function setUp() public { + factory = new Factory{salt: keccak256(abi.encode("evil"))}(); + a = A(factory.helloA()); + + (bool success, bytes memory data) = address(a).staticcall(abi.encodeCall(A.codeLength, ())); + assertTrue(success); + assertEq(abi.decode(data, (uint256)), address(a).code.length); + + a.kill(); + factory.kill(); + } + + function testMorphingContract() public { + assertEq(address(a).code.length, 0); + assertEq(address(factory).code.length, 0); + + factory = new Factory{salt: keccak256(abi.encode("evil"))}(); + b = B(factory.helloB()); + assertEq(address(a), address(b)); + } +} + "#, + ); + + cmd.args(["test", "--match-path", "test/SetupSelfdestruct.t.sol", "--evm-version", "cancun"]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SetupSelfdestruct.t.sol:SetupSelfdestructTest +[PASS] testMorphingContract() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn selfdestruct_keeps_setup_created_contract_in_test_body(prj: _, cmd: _) { + prj.add_test( + "SetupThenTestSelfdestruct.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract A { + function kill() public { + selfdestruct(payable(address(0))); + } +} + +contract SetupThenTestSelfdestructTest is Test { + A private a; + + function setUp() public { + a = new A(); + } + + function testCodePersistsAcrossSetupBoundary() public { + a.kill(); + assertGt(address(a).code.length, 0); + } +} + "#, + ); + + cmd.args([ + "test", + "--match-path", + "test/SetupThenTestSelfdestruct.t.sol", + "--evm-version", + "cancun", + ]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SetupThenTestSelfdestruct.t.sol:SetupThenTestSelfdestructTest +[PASS] testCodePersistsAcrossSetupBoundary() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +// `waste()` spends more gas than the forked block's gas limit, so it only succeeds with +// `--disable-block-gas-limit`. +#[forgetest_init] +async fn can_disable_block_gas_limit_on_fork(prj: _, cmd: _) { + let (_api, handle) = spawn(NodeConfig::test().with_gas_limit(Some(30_000_000))).await; + + prj.add_test( + "Contract.t.sol", + &r#" +import {Test} from "forge-std/Test.sol"; + +contract C is Test {} + +contract GasWaster { + function waste() public { + for (uint256 i = 0; i < 100; i++) { + new C(); + } + } +} + +contract GasLimitTest is Test { + function test() public { + vm.createSelectFork(""); + + GasWaster waster = new GasWaster(); + waster.waste(); + } +} + "# + .replace("", &handle.http_endpoint()), + ); + + cmd.args(["test", "--isolate"]).assert_failure().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/Contract.t.sol:GasLimitTest +[FAIL: EvmError: Revert] test() (block: 0) ([GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/Contract.t.sol:GasLimitTest +[FAIL: EvmError: Revert] test() (block: 0) ([GAS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test +Tip: Run `forge test --debug --match-test ` to inspect one failing test in the debugger + +"#]]); + + cmd.forge_fuse() + .args(["test", "--isolate", "--disable-block-gas-limit"]) + .assert_success() + .stdout_eq(str![[r#" +No files changed, compilation skipped + +Ran 1 test for test/Contract.t.sol:GasLimitTest +[PASS] test() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +} + +#[forgetest] +fn test_match_path(prj: _, cmd: _) { + prj.add_source( + "dummy", + r" +contract Dummy { + function testDummy() public {} +} +", + ); + + cmd.args(["test", "--match-path", "src/dummy.sol"]); + cmd.assert_success().stdout_eq(str![[r#" +... +Ran 1 test for src/dummy.sol:Dummy +[PASS] testDummy() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn should_not_shrink_fuzz_failure(prj: _, cmd: _) { + // deterministic test so we always have 54 runs until test fails with overflow + prj.update_config(|config| { + config.fuzz.runs = 256; + config.fuzz.seed = Some(U256::from(100)); + }); + + prj.add_test( + "CounterFuzz.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract Counter { + uint256 public number = 0; + + function addOne(uint256 x) external pure returns (uint256) { + return x + 100_000_000; + } +} + +contract CounterTest is Test { + Counter public counter; + + function setUp() public { + counter = new Counter(); + } + + function testAddOne(uint256 x) public view { + assertEq(counter.addOne(x), x + 100_000_000); + } +} + "#, + ); + + // make sure there are only 61 runs (with proptest shrinking same test results in 298 runs) + cmd.args(["test"]).assert_failure().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/CounterFuzz.t.sol:CounterTest +[FAIL: panic: arithmetic underflow or overflow (0x11); counterexample: calldata=[..] args=[..]] testAddOne(uint256) (runs: [..], [AVG_GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/CounterFuzz.t.sol:CounterTest +[FAIL: panic: arithmetic underflow or overflow (0x11); counterexample: calldata=[..] args=[..]] testAddOne(uint256) (runs: [..], [AVG_GAS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test +Tip: Run `forge test --debug --match-test ` to inspect one failing test in the debugger + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]]); +} + +#[forgetest_init] +fn should_exit_early_on_invariant_failure(prj: _, cmd: _) { + prj.add_test( + "CounterInvariant.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract Counter { + uint256 public number = 0; + + function inc() external { + number += 1; + } +} + +contract CounterTest is Test { + Counter public counter; + + function setUp() public { + counter = new Counter(); + } + + function invariant_early_exit() public view { + assertTrue(counter.number() == 10, "wrong count"); + } +} + "#, + ); + + // make sure a pre-run invariant failure exits after the first campaign call and keeps the + // predicate failure attribution. + cmd.args(["test"]).assert_failure().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/CounterInvariant.t.sol:CounterTest +[FAIL: wrong count] invariant_early_exit() (runs: 1, calls: 1, reverts: 0) + +╭----------+----------+-------+---------+----------╮ +| Contract | Selector | Calls | Reverts | Discards | ++==================================================+ +| Counter | inc | 1 | 0 | 0 | +╰----------+----------+-------+---------+----------╯ + +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/CounterInvariant.t.sol:CounterTest +[FAIL: wrong count] invariant_early_exit() (runs: 1, calls: 1, reverts: 0) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]]); +} + +#[forgetest_init] +fn should_replay_failures_only(prj: _, cmd: _) { + prj.add_test( + "ReplayFailures.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract ReplayFailuresTest is Test { + function testA() public pure { + require(2 > 1); + } + + function testB() public pure { + require(1 > 2, "testB failed"); + } + + function testC() public pure { + require(2 > 1); + } + + function testD() public pure { + require(1 > 2, "testD failed"); + } +} + "#, + ); + + cmd.args(["test"]).assert_failure().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 4 tests for test/ReplayFailures.t.sol:ReplayFailuresTest +[PASS] testA() ([GAS]) +[FAIL: testB failed] testB() ([GAS]) +[PASS] testC() ([GAS]) +[FAIL: testD failed] testD() ([GAS]) +Suite result: FAILED. 2 passed; 2 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 2 tests passed, 2 failed, 0 skipped (4 total tests) + +Failing tests: +Encountered 2 failing tests in test/ReplayFailures.t.sol:ReplayFailuresTest +[FAIL: testB failed] testB() ([GAS]) +[FAIL: testD failed] testD() ([GAS]) + +Encountered a total of 2 failing tests, 2 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 2 failed tests +Tip: Run `forge test --debug --match-test ` to inspect one failing test in the debugger + +"#]]); + + // Test failure filter should be persisted. + assert!(prj.root().join("cache/test-failures").exists()); + + // Perform only the 2 failing tests from last run. + cmd.forge_fuse().args(["test", "--rerun"]).assert_failure().stdout_eq(str![[r#" +No files changed, compilation skipped + +Ran 2 tests for test/ReplayFailures.t.sol:ReplayFailuresTest +[FAIL: testB failed] testB() ([GAS]) +[FAIL: testD failed] testD() ([GAS]) +Suite result: FAILED. 0 passed; 2 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 2 failed, 0 skipped (2 total tests) + +Failing tests: +Encountered 2 failing tests in test/ReplayFailures.t.sol:ReplayFailuresTest +[FAIL: testB failed] testB() ([GAS]) +[FAIL: testD failed] testD() ([GAS]) + +Encountered a total of 2 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 2 failed tests +Tip: Run `forge test --debug --match-test ` to inspect one failing test in the debugger + +"#]]); +} + +// +#[forgetest_init] +fn should_not_record_setup_failures(prj: _, cmd: _) { + prj.add_test( + "ReplayFailures.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract SetupFailureTest is Test { + function setUp() public { + require(2 > 1); + } + + function testA() public pure { + } +} + "#, + ); + + cmd.args(["test"]).assert_success(); + // Test failure filter should not be persisted if `setUp` failed. + assert!(!prj.root().join("cache/test-failures").exists()); +} + +// https://github.com/foundry-rs/foundry/issues/7530 +#[forgetest_init] +fn should_show_precompile_labels(prj: _, cmd: _) { + prj.add_test( + "Contract.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract PrecompileLabelsTest is Test { + function testPrecompileLabels() public { + vm.deal(address(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D), 1 ether); + vm.deal(address(0x000000000000000000636F6e736F6c652e6c6f67), 1 ether); + vm.deal(address(0x4e59b44847b379578588920cA78FbF26c0B4956C), 1 ether); + vm.deal(address(0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38), 1 ether); + vm.deal(address(0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496), 1 ether); + vm.deal(address(1), 1 ether); + vm.deal(address(2), 1 ether); + vm.deal(address(3), 1 ether); + vm.deal(address(4), 1 ether); + vm.deal(address(5), 1 ether); + vm.deal(address(6), 1 ether); + vm.deal(address(7), 1 ether); + vm.deal(address(8), 1 ether); + vm.deal(address(9), 1 ether); + vm.deal(address(10), 1 ether); + } +} + "#, + ); + + cmd.args(["test", "-vvvv"]).assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/Contract.t.sol:PrecompileLabelsTest +[PASS] testPrecompileLabels() ([GAS]) +Traces: + [..] PrecompileLabelsTest::testPrecompileLabels() + ├─ [0] VM::deal(VM: [0x7109709ECfa91a80626fF3989D68f67F5b1DD12D], 1000000000000000000 [1e18]) + │ └─ ← [Return] + ├─ [0] VM::deal(console: [0x000000000000000000636F6e736F6c652e6c6f67], 1000000000000000000 [1e18]) + │ └─ ← [Return] + ├─ [0] VM::deal(Create2Deployer: [0x4e59b44847b379578588920cA78FbF26c0B4956C], 1000000000000000000 [1e18]) + │ └─ ← [Return] + ├─ [0] VM::deal(DefaultSender: [0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38], 1000000000000000000 [1e18]) + │ └─ ← [Return] + ├─ [0] VM::deal(PrecompileLabelsTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], 1000000000000000000 [1e18]) + │ └─ ← [Return] + ├─ [0] VM::deal(ECRecover: [0x0000000000000000000000000000000000000001], 1000000000000000000 [1e18]) + │ └─ ← [Return] + ├─ [0] VM::deal(SHA-256: [0x0000000000000000000000000000000000000002], 1000000000000000000 [1e18]) + │ └─ ← [Return] + ├─ [0] VM::deal(RIPEMD-160: [0x0000000000000000000000000000000000000003], 1000000000000000000 [1e18]) + │ └─ ← [Return] + ├─ [0] VM::deal(Identity: [0x0000000000000000000000000000000000000004], 1000000000000000000 [1e18]) + │ └─ ← [Return] + ├─ [0] VM::deal(ModExp: [0x0000000000000000000000000000000000000005], 1000000000000000000 [1e18]) + │ └─ ← [Return] + ├─ [0] VM::deal(ECAdd: [0x0000000000000000000000000000000000000006], 1000000000000000000 [1e18]) + │ └─ ← [Return] + ├─ [0] VM::deal(ECMul: [0x0000000000000000000000000000000000000007], 1000000000000000000 [1e18]) + │ └─ ← [Return] + ├─ [0] VM::deal(ECPairing: [0x0000000000000000000000000000000000000008], 1000000000000000000 [1e18]) + │ └─ ← [Return] + ├─ [0] VM::deal(Blake2F: [0x0000000000000000000000000000000000000009], 1000000000000000000 [1e18]) + │ └─ ← [Return] + ├─ [0] VM::deal(PointEvaluation: [0x000000000000000000000000000000000000000A], 1000000000000000000 [1e18]) + │ └─ ← [Return] + └─ ← [Stop] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +} + +// tests that `forge test` with config `show_logs: true` for fuzz tests will +// display `console.log` info +#[forgetest_init] +fn should_show_logs_when_fuzz_test(prj: _, cmd: _) { + // run fuzz test 3 times + prj.update_config(|config| { + config.fuzz.runs = 3; + config.fuzz.show_logs = true; + }); + let config = cmd.config(); + assert_eq!(config.fuzz.runs, 3); + + prj.add_test( + "ContractFuzz.t.sol", + r#" +import {Test, console} from "forge-std/Test.sol"; + +contract ContractFuzz is Test { + function testFuzzConsoleLog(uint256 x) public pure { + console.log("inside fuzz test, x is:", x); + } +} + "#, + ); + cmd.args(["test", "-vv"]).assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/ContractFuzz.t.sol:ContractFuzz +[PASS] testFuzzConsoleLog(uint256) (runs: 3, [AVG_GAS]) +Logs: + inside fuzz test, x is: [..] + inside fuzz test, x is: [..] + inside fuzz test, x is: [..] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +} + +// tests that `forge test` with inline config `show_logs = true` for fuzz tests will +// display `console.log` info +#[forgetest_init] +fn should_show_logs_when_fuzz_test_inline_config(prj: _, cmd: _) { + // run fuzz test 3 times + prj.update_config(|config| { + config.fuzz.runs = 3; + }); + let config = cmd.config(); + assert_eq!(config.fuzz.runs, 3); + + prj.add_test( + "ContractFuzz.t.sol", + r#" +import {Test, console} from "forge-std/Test.sol"; + +contract ContractFuzz is Test { + /// forge-config: default.fuzz.show-logs = true + function testFuzzConsoleLog(uint256 x) public pure { + console.log("inside fuzz test, x is:", x); + } +} + "#, + ); + cmd.args(["test", "-vv"]).assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/ContractFuzz.t.sol:ContractFuzz +[PASS] testFuzzConsoleLog(uint256) (runs: 3, [AVG_GAS]) +Logs: + inside fuzz test, x is: [..] + inside fuzz test, x is: [..] + inside fuzz test, x is: [..] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +} + +// tests that `forge test` with config `show_logs: false` for fuzz tests will +// still display `console.log` from the last run at verbosity >= 2 (issue #11039) +#[forgetest_init] +fn should_not_show_logs_when_fuzz_test(prj: _, cmd: _) { + // run fuzz test 3 times + prj.update_config(|config| { + config.fuzz.runs = 3; + config.fuzz.show_logs = false; + }); + let config = cmd.config(); + assert_eq!(config.fuzz.runs, 3); + + prj.add_test( + "ContractFuzz.t.sol", + r#" + import {Test, console} from "forge-std/Test.sol"; + contract ContractFuzz is Test { + + function testFuzzConsoleLog(uint256 x) public pure { + console.log("inside fuzz test, x is:", x); + } + } + "#, + ); + // At verbosity >= 2, logs from the last run should be shown even when show_logs is false + cmd.args(["test", "-vv"]).assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/ContractFuzz.t.sol:ContractFuzz +[PASS] testFuzzConsoleLog(uint256) (runs: 3, [AVG_GAS]) +Logs: + inside fuzz test, x is: [..] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +} + +#[forgetest_init] +fn failed_fuzz_test_shows_only_failure_logs(prj: _, cmd: _) { + let persist_dir = prj.cache().parent().unwrap().join("persist"); + prj.update_config(|config| { + config.fuzz.runs = 5; + config.fuzz.show_logs = false; + config.fuzz.failure_persist_dir = Some(persist_dir); + }); + prj.add_test( + "FuzzLogs.t.sol", + r#" +contract FuzzLogsTest { + event log_named_uint(string key, uint256 value); + + function testFuzzLogs(uint256 value) public { + if (value == 154) { + emit log_named_uint("FAILING CASE", value); + revert("target value"); + } + emit log_named_uint("SUCCESSFUL CASE", value); + } +} +"#, + ); + + cmd.args([ + "test", + "--match-test", + "testFuzzLogs", + "--fuzz-seed", + "1", + "--threads", + "1", + "-vv", + ]) + .assert_failure() + .stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/FuzzLogs.t.sol:FuzzLogsTest +[FAIL: target value; counterexample: calldata=[..] args=[154]] testFuzzLogs(uint256) (runs: 3, [AVG_GAS]) +Logs: + FAILING CASE: 154 + +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/FuzzLogs.t.sol:FuzzLogsTest +[FAIL: target value; counterexample: calldata=[..] args=[154]] testFuzzLogs(uint256) (runs: 3, [AVG_GAS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test +Tip: Run `forge test --debug --match-test ` to inspect one failing test in the debugger + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]]); + + prj.update_config(|config| config.fuzz.runs = 128); + prj.add_test( + "FuzzLogs.t.sol", + r#" +interface Vm { + function sleep(uint256 duration) external; +} + +contract FuzzLogsTest { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + event log_named_uint(string key, uint256 value); + + function testFuzzLogs(uint256 value) public { + if (value == 154) { + emit log_named_uint("FAILING CASE", value); + vm.sleep(100); + revert("target value"); + } + emit log_named_uint("SUCCESSFUL CASE", value); + } +} +"#, + ); + + cmd.forge_fuse() + .args([ + "test", + "--match-test", + "testFuzzLogs", + "--fuzz-seed", + "1", + "--threads", + "2", + "-vv", + ]) + .assert_failure() + .stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/FuzzLogs.t.sol:FuzzLogsTest +[FAIL: target value; counterexample: calldata=[..] args=[154]] testFuzzLogs(uint256) (runs: [..], [AVG_GAS]) +Logs: + FAILING CASE: 154 + +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/FuzzLogs.t.sol:FuzzLogsTest +[FAIL: target value; counterexample: calldata=[..] args=[154]] testFuzzLogs(uint256) (runs: [..], [AVG_GAS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test +Tip: Run `forge test --debug --match-test ` to inspect one failing test in the debugger + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]]); +} + +// tests that `forge test` with inline config `show_logs = false` for fuzz tests will +// still display `console.log` from the last run at verbosity >= 2 (issue #11039) +#[forgetest_init] +fn should_not_show_logs_when_fuzz_test_inline_config(prj: _, cmd: _) { + // run fuzz test 3 times + prj.update_config(|config| { + config.fuzz.runs = 3; + }); + let config = cmd.config(); + assert_eq!(config.fuzz.runs, 3); + + prj.add_test( + "ContractFuzz.t.sol", + r#" +import {Test, console} from "forge-std/Test.sol"; + +contract ContractFuzz is Test { + /// forge-config: default.fuzz.show-logs = false + function testFuzzConsoleLog(uint256 x) public pure { + console.log("inside fuzz test, x is:", x); + } +} + "#, + ); + // At verbosity >= 2, logs from the last run should be shown even when show_logs is false + cmd.args(["test", "-vv"]).assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/ContractFuzz.t.sol:ContractFuzz +[PASS] testFuzzConsoleLog(uint256) (runs: 3, [AVG_GAS]) +Logs: + inside fuzz test, x is: [..] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +} + +// tests internal functions trace +#[forgetest_init] +fn internal_functions_trace(prj: _, cmd: _) { + prj.clear(); + + prj.add_test( + "Simple", + r#" +import {Test, console} from "forge-std/Test.sol"; + +contract SimpleContract { + uint256 public num; + address public addr; + + function _setNum(uint256 _num) internal returns(uint256 prev) { + prev = num; + num = _num; + } + + function _setAddr(address _addr) internal returns(address prev) { + prev = addr; + addr = _addr; + } + + function increment() public { + _setNum(num + 1); + } + + function setValues(uint256 _num, address _addr) public { + _setNum(_num); + _setAddr(_addr); + } +} + +contract SimpleContractTest is Test { + function test() public { + SimpleContract c = new SimpleContract(); + c.increment(); + c.setValues(100, address(0x123)); + } +} + "#, + ); + cmd.args(["test", "-vvvv", "--decode-internal"]).assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/Simple.sol:SimpleContractTest +[PASS] test() ([GAS]) +Traces: + [..] SimpleContractTest::test() + ├─ [231452] → new SimpleContract@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f + │ └─ ← [Return] 826 bytes of code + ├─ [43694] SimpleContract::increment() + │ ├─ [20147] SimpleContract::_setNum(uint256)(1) + │ │ └─ ← 0 + │ └─ ← [Stop] + ├─ [49360] SimpleContract::setValues(100, 0x0000000000000000000000000000000000000123) + │ ├─ [5047] SimpleContract::_setNum(uint256)(100) + │ │ └─ ← 1 + │ ├─ [22336] SimpleContract::_setAddr(address)(0x0000000000000000000000000000000000000123) + │ │ └─ ← 0x0000000000000000000000000000000000000000 + │ └─ ← [Stop] + └─ ← [Stop] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +} + +// tests internal functions trace with memory decoding +#[forgetest_init] +fn internal_functions_trace_memory(prj: _, cmd: _) { + prj.clear(); + + prj.add_test( + "Simple", + r#" +import {Test, console} from "forge-std/Test.sol"; + +contract SimpleContract { + string public str = "initial value"; + + function _setStr(string memory _str) internal returns(string memory prev) { + prev = str; + str = _str; + } + + function setStr(string memory _str) public { + _setStr(_str); + } +} + +contract SimpleContractTest is Test { + function test() public { + SimpleContract c = new SimpleContract(); + c.setStr("new value"); + } +} + "#, + ); + cmd.args(["test", "-vvvv", "--decode-internal"]).assert_success().stdout_eq(str![[r#" +... +Traces: + [..] SimpleContractTest::test() + ├─ [..] → new SimpleContract@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f + │ └─ ← [Return] [..] bytes of code + ├─ [..] SimpleContract::setStr("new value") + │ ├─ [..] SimpleContract::_setStr(string)("new value") + │ │ └─ ← "initial value" + │ └─ ← [Stop] + └─ ← [Stop] +... +"#]]); +} + +// +#[forgetest_init] +fn internal_functions_trace_calldata_bytes(prj: _, cmd: _) { + prj.clear(); + + prj.add_test( + "DecodeInternalCalldata", + r#" +contract DecodeInternalCalldataTraceTest { + function test_decodeInternalCalldataBytes() public { + DecodeInternalTarget target = new DecodeInternalTarget(); + + bytes[] memory wrappedSignatures = new bytes[](1); + wrappedSignatures[0] = hex"11223344556677889900"; + + bytes32 expectedDigest = keccak256("digest that should appear in the internal trace"); + + bool ok = target.outer(wrappedSignatures, expectedDigest); + require(ok, "target returned false"); + } +} + +contract DecodeInternalTarget { + function outer(bytes[] calldata signatures, bytes32 digest) external pure returns (bool) { + bytes calldata signature = signatures[0]; + return _internalValidate(digest, signature); + } + + function _internalValidate(bytes32 digest, bytes calldata signature) + internal + pure + returns (bool) + { + return digest == keccak256("digest that should appear in the internal trace") + && signature.length == 10; + } +} + "#, + ); + + let stdout = cmd + .args(["test", "-vvvv", "--decode-internal"]) + .assert_success() + .get_output() + .stdout_lossy(); + assert!( + stdout.contains( + "DecodeInternalTarget::_internalValidate(bytes32,bytes)(\ + 0x9a707a44f6e6038d2b03f3947e82f6403612c5d5b923868f6a759e9c38e16a28, \ + 0x11223344556677889900)" + ), + "{stdout}" + ); +} + +// tests that `forge test` with a seed produces deterministic random values for uint and addresses. +#[forgetest_init] +fn deterministic_randomness_with_seed(prj: _, cmd: _) { + prj.add_test( + "DeterministicRandomnessTest.t.sol", + r#" +import {Test, console} from "forge-std/Test.sol"; + +contract DeterministicRandomnessTest is Test { + + function testDeterministicRandomUint() public { + console.log(vm.randomUint()); + console.log(vm.randomUint()); + console.log(vm.randomUint()); + } + + function testDeterministicRandomUintRange() public { + uint256 min = 0; + uint256 max = 1000000000; + console.log(vm.randomUint(min, max)); + console.log(vm.randomUint(min, max)); + console.log(vm.randomUint(min, max)); + } + + function testDeterministicRandomAddress() public { + console.log(vm.randomAddress()); + console.log(vm.randomAddress()); + console.log(vm.randomAddress()); + } +} +"#, + ); + + // Extracts the test result section from the DeterministicRandomnessTest contract output. + fn extract_test_result(out: &str) -> &str { + let start = out + .find("for test/DeterministicRandomnessTest.t.sol:DeterministicRandomnessTest") + .unwrap(); + let end = out.find("Suite result: ok.").unwrap(); + &out[start..end] + } + + // Run the test twice with the same seed and verify the outputs are the same. + let seed1 = "0xa1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"; + let out1 = cmd + .args(["test", "--fuzz-seed", seed1, "-vv"]) + .assert_success() + .get_output() + .stdout_lossy(); + let res1 = extract_test_result(&out1); + + let out2 = cmd + .forge_fuse() + .args(["test", "--fuzz-seed", seed1, "-vv"]) + .assert_success() + .get_output() + .stdout_lossy(); + let res2 = extract_test_result(&out2); + + assert_eq!(res1, res2); + + // Run the test with another seed and verify the output differs. + let seed2 = "0xb1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"; + let out3 = cmd + .forge_fuse() + .args(["test", "--fuzz-seed", seed2, "-vv"]) + .assert_success() + .get_output() + .stdout_lossy(); + let res3 = extract_test_result(&out3); + assert_ne!(res3, res1); + + // Run the test without a seed and verify the outputs differs once again. + cmd.forge_fuse(); + let out4 = cmd.args(["test", "-vv"]).assert_success().get_output().stdout_lossy(); + let res4 = extract_test_result(&out4); + assert_ne!(res4, res1); + assert_ne!(res4, res3); +} + +// Tests that `pauseGasMetering` used at the end of test does not produce meaningless values. +// https://github.com/foundry-rs/foundry/issues/5491 +#[forgetest_init] +fn gas_metering_pause_last_call(prj: _, cmd: _) { + prj.add_test( + "ATest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract ATest is Test { + function testWeirdGas1() public { + vm.pauseGasMetering(); + } + + function testWeirdGas2() public { + uint256 a = 1; + uint256 b = a + 1; + require(b == 2, "b is not 2"); + vm.pauseGasMetering(); + } + + function testNormalGas() public { + vm.pauseGasMetering(); + vm.resumeGasMetering(); + } + + function testWithAssembly() public { + vm.pauseGasMetering(); + assembly { + return(0, 0) + } + } +} + "#, + ); + + cmd.args(["test"]).with_no_redact().assert_success().stdout_eq(str![[r#" +... +[PASS] testNormalGas() (gas: 3148) +[PASS] testWeirdGas1() (gas: 2986) +[PASS] testWeirdGas2() (gas: 3213) +[PASS] testWithAssembly() (gas: 3029) +... +"#]]); +} + +// https://github.com/foundry-rs/foundry/issues/5564 +#[forgetest_init] +fn gas_metering_expect_revert(prj: _, cmd: _) { + prj.add_test( + "ATest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +contract ATest is Test { + error MyError(); + function testSelfMeteringRevert() public { + vm.pauseGasMetering(); + vm.expectRevert(MyError.selector); + this.selfReverts(); + } + function selfReverts() external { + vm.resumeGasMetering(); + revert MyError(); + } +} + "#, + ); + + cmd.args(["test"]).assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/ATest.t.sol:ATest +[PASS] testSelfMeteringRevert() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +} + +// https://github.com/foundry-rs/foundry/issues/4523 +#[forgetest_init] +fn gas_metering_gasleft(prj: _, cmd: _) { + prj.add_test( + "ATest.t.sol", + r#" +import "forge-std/Test.sol"; + +contract ATest is Test { + mapping(uint256 => bytes32) map; + + function test_GasMeter() public { + vm.pauseGasMetering(); + consumeGas(); + vm.resumeGasMetering(); + + consumeGas(); + } + + function test_GasLeft() public { + consumeGas(); + + uint256 start = gasleft(); + consumeGas(); + console.log("Gas cost:", start - gasleft()); + } + + function consumeGas() private { + for (uint256 i = 0; i < 100; i++) { + map[i] = keccak256(abi.encode(i)); + } + } +} + "#, + ); + + // Log and test gas cost should be similar. + cmd.args(["test", "-vvvv"]).with_no_redact().assert_success().stdout_eq(str![[r#" +... +Logs: + Gas cost: 50068 + +Traces: + [2303684] ATest::test_GasLeft() + ├─ [0] console::log("Gas cost:", 50068 [5.006e4]) [staticcall] + │ └─ ← [Stop] + └─ ← [Stop] + +[PASS] test_GasMeter() (gas: 53097) +Traces: + [53097] ATest::test_GasMeter() + ├─ [0] VM::pauseGasMetering() + │ └─ ← [Return] + ├─ [0] VM::resumeGasMetering() + │ └─ ← [Return] + └─ ← [Stop] +... +"#]]); +} + +// https://github.com/foundry-rs/foundry/issues/4370 +#[forgetest_init] +fn pause_gas_metering_with_delete(prj: _, cmd: _) { + prj.add_test( + "ATest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +contract ATest is Test { + uint a; + function test_negativeGas () public { + vm.pauseGasMetering(); + a = 100; + vm.resumeGasMetering(); + delete a; + } +} + "#, + ); + + cmd.args(["test"]).with_no_redact().assert_success().stdout_eq(str![[r#" +... +[PASS] test_negativeGas() (gas: 96) +... +"#]]); +} + +// tests `pauseTracing` and `resumeTracing` functions +#[forgetest_init] +fn pause_tracing(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.insert_vm(); + prj.clear(); + + prj.add_source( + "Pause.t.sol", + r#" +import {Vm} from "./Vm.sol"; +import {DSTest} from "./test.sol"; +contract TraceGenerator is DSTest { + Vm vm = Vm(HEVM_ADDRESS); + event DummyEvent(uint256 i); + function call(uint256 i) public { + emit DummyEvent(i); + } + function generate() public { + for (uint256 i = 0; i < 10; i++) { + if (i == 3) { + vm.pauseTracing(); + } + this.call(i); + if (i == 7) { + vm.resumeTracing(); + } + } + } +} +contract PauseTracingTest is DSTest { + Vm vm = Vm(HEVM_ADDRESS); + event DummyEvent(uint256 i); + function setUp() public { + emit DummyEvent(1); + vm.pauseTracing(); + emit DummyEvent(2); + } + function test() public { + emit DummyEvent(3); + TraceGenerator t = new TraceGenerator(); + vm.resumeTracing(); + t.generate(); + } +} + "#, + ); + cmd.args(["test", "-vvvvv"]).assert_success().stdout_eq(str![[r#" +... +Traces: + [7757] PauseTracingTest::setUp() + ├─ emit DummyEvent(i: 1) + ├─ [0] VM::pauseTracing() [staticcall] + │ └─ ← [Return] + └─ ← [Stop] + + [558957] PauseTracingTest::test() + ├─ [0] VM::resumeTracing() [staticcall] + │ └─ ← [Return] + ├─ [48460] TraceGenerator::generate() + │ ├─ [1589] TraceGenerator::call(0) + │ │ ├─ emit DummyEvent(i: 0) + │ │ └─ ← [Stop] + │ ├─ [1589] TraceGenerator::call(1) + │ │ ├─ emit DummyEvent(i: 1) + │ │ └─ ← [Stop] + │ ├─ [1589] TraceGenerator::call(2) + │ │ ├─ emit DummyEvent(i: 2) + │ │ └─ ← [Stop] + │ ├─ [0] VM::pauseTracing() [staticcall] + │ │ └─ ← [Return] + │ ├─ [0] VM::resumeTracing() [staticcall] + │ │ └─ ← [Return] + │ ├─ [1589] TraceGenerator::call(8) + │ │ ├─ emit DummyEvent(i: 8) + │ │ └─ ← [Stop] + │ ├─ [1589] TraceGenerator::call(9) + │ │ ├─ emit DummyEvent(i: 9) + │ │ └─ ← [Stop] + │ └─ ← [Stop] + └─ ← [Stop] +... +"#]]); +} + +#[forgetest_init] +fn gas_metering_reset(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.insert_vm(); + prj.clear(); + + prj.add_source( + "ATest.t.sol", + r#" +import {Vm} from "./Vm.sol"; +import {DSTest} from "./test.sol"; +contract B { + function a() public returns (uint256) { + return 100; + } +} +contract ATest is DSTest { + Vm vm = Vm(HEVM_ADDRESS); + B b; + uint256 a; + + function testResetGas() public { + vm.resetGasMetering(); + } + + function testResetGas1() public { + vm.resetGasMetering(); + b = new B(); + vm.resetGasMetering(); + } + + function testResetGas2() public { + b = new B(); + b = new B(); + vm.resetGasMetering(); + } + + function testResetGas3() public { + vm.resetGasMetering(); + b = new B(); + b = new B(); + } + + function testResetGas4() public { + vm.resetGasMetering(); + b = new B(); + vm.resetGasMetering(); + b = new B(); + } + + function testResetGas5() public { + vm.resetGasMetering(); + b = new B(); + vm.resetGasMetering(); + b = new B(); + vm.resetGasMetering(); + } + + function testResetGas6() public { + vm.resetGasMetering(); + b = new B(); + b = new B(); + _reset(); + vm.resetGasMetering(); + } + + function testResetGas7() public { + vm.resetGasMetering(); + b = new B(); + b = new B(); + _reset(); + } + + function testResetGas8() public { + this.resetExternal(); + } + + function testResetGas9() public { + this.resetExternal(); + vm.resetGasMetering(); + } + + function testResetNegativeGas() public { + a = 100; + vm.resetGasMetering(); + + delete a; + } + + function _reset() internal { + vm.resetGasMetering(); + } + + function resetExternal() external { + b = new B(); + b = new B(); + vm.resetGasMetering(); + } + + // https://github.com/foundry-rs/foundry/issues/12474 + function testMemoryOnReset(uint8[1] memory x) public { + uint8[1] memory z; + z[0] = x[0]; + assertEq(z[0], x[0]); + vm.resetGasMetering(); + assertEq(x[0], z[0]); + } +} + "#, + ); + + cmd.args(["test"]).with_no_redact().assert_success().stdout_eq(str![[r#" +... +[PASS] testResetGas() (gas: 96) +[PASS] testResetGas1() (gas: 96) +[PASS] testResetGas2() (gas: 96) +[PASS] testResetGas3() (gas: [..]) +[PASS] testResetGas4() (gas: [..]) +[PASS] testResetGas5() (gas: 96) +[PASS] testResetGas6() (gas: 96) +[PASS] testResetGas7() (gas: 96) +[PASS] testResetGas8() (gas: [..]) +[PASS] testResetGas9() (gas: 96) +[PASS] testResetNegativeGas() (gas: 96) +... +"#]]); +} + +// https://github.com/foundry-rs/foundry/issues/8705 +#[forgetest_init] +fn test_expect_revert_decode(prj: _, cmd: _) { + prj.add_test( + "Counter.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +contract Counter { + uint256 public number; + error NumberNotEven(uint256 number); + error RandomError(); + function setNumber(uint256 newNumber) public { + if (newNumber % 2 != 0) { + revert NumberNotEven(newNumber); + } + number = newNumber; + } + function revertWithData(bytes memory data) public pure { + assembly ("memory-safe") { + revert(add(data, 0x20), mload(data)) + } + } +} +contract CounterTest is Test { + Counter public counter; + function setUp() public { + counter = new Counter(); + counter.setNumber(0); + } + function test_decode() public { + vm.expectRevert(Counter.RandomError.selector); + counter.setNumber(1); + } + function test_decode_with_args() public { + vm.expectRevert(abi.encodePacked(Counter.NumberNotEven.selector, uint(2))); + counter.setNumber(1); + } + function test_raw_message_matches_error_with_trailing_data() public { + vm.expectRevert(bytes("reason")); + counter.revertWithData( + abi.encodePacked(abi.encodeWithSignature("Error(string)", "reason"), uint256(2)) + ); + } + function test_rejects_trailing_expected_error_data() public { + vm.expectRevert( + abi.encodePacked(abi.encodeWithSignature("Error(string)", "reason"), uint256(2)) + ); + counter.revertWithData(abi.encodeWithSignature("Error(string)", "reason")); + } + // https://github.com/foundry-rs/foundry/issues/16424 + function test_rejects_trailing_expected_custom_error_data() public { + vm.expectRevert( + abi.encodeWithSelector(Counter.NumberNotEven.selector, uint256(1), uint256(2)) + ); + counter.setNumber(1); + } + function test_rejects_bare_string_actual() public { + vm.expectRevert(abi.encodeWithSignature("Error(string)", "reason")); + counter.revertWithData(bytes("reason")); + } + function test_rejects_padded_custom_error_data() public { + vm.expectRevert(abi.encodeWithSelector(Counter.NumberNotEven.selector, uint256(1))); + counter.revertWithData( + abi.encodePacked(abi.encodeWithSelector(Counter.NumberNotEven.selector, uint256(1)), bytes28(0)) + ); + } +} + "#, + ); + + cmd.args(["test"]).assert_failure().stdout_eq(str![[r#" +... +[FAIL: Error != expected error: NumberNotEven(1) != RandomError()] test_decode() ([GAS]) +[FAIL: Error != expected error: NumberNotEven(1) != NumberNotEven(2)] test_decode_with_args() ([GAS]) +[PASS] test_raw_message_matches_error_with_trailing_data() ([GAS]) +[FAIL: Error != expected error: reason (raw 0x726561736f6e) != reason (raw 0x08c379a000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000006726561736f6e0000000000000000000000000000000000000000000000000000)] test_rejects_bare_string_actual() ([GAS]) +[FAIL: Error != expected error: NumberNotEven(1) (raw 0xeb598fa3000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000) != NumberNotEven(1) (raw 0xeb598fa30000000000000000000000000000000000000000000000000000000000000001)] test_rejects_padded_custom_error_data() ([GAS]) +[FAIL: Error != expected error: NumberNotEven(1) (raw 0xeb598fa30000000000000000000000000000000000000000000000000000000000000001) != NumberNotEven(1) (raw 0xeb598fa300000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000002)] test_rejects_trailing_expected_custom_error_data() ([GAS]) +[FAIL: Error != expected error: reason (raw 0x08c379a000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000006726561736f6e0000000000000000000000000000000000000000000000000000) != reason (raw 0x08c379a000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000006726561736f6e00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002)] test_rejects_trailing_expected_error_data() ([GAS]) +... +"#]]); +} + +// Tests that `expectPartialRevert` cheatcode partially matches revert data. +#[forgetest_init] +fn test_expect_partial_revert(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.insert_vm(); + prj.clear(); + + prj.add_source( + "Counter.t.sol", + r#" +import {Vm} from "./Vm.sol"; +import {DSTest} from "./test.sol"; +contract Counter { + error WrongNumber(uint256 number); + function count() public pure { + revert WrongNumber(0); + } +} +contract CounterTest is DSTest { + Vm vm = Vm(HEVM_ADDRESS); + function testExpectPartialRevertWithSelector() public { + Counter counter = new Counter(); + vm.expectPartialRevert(Counter.WrongNumber.selector); + counter.count(); + } + function testExpectPartialRevertWith4Bytes() public { + Counter counter = new Counter(); + vm.expectPartialRevert(bytes4(0x238ace70)); + counter.count(); + } + function testExpectRevert() public { + Counter counter = new Counter(); + vm.expectRevert(Counter.WrongNumber.selector); + counter.count(); + } +} + "#, + ); + + cmd.args(["test"]).assert_failure().stdout_eq(str![[r#" +... +[PASS] testExpectPartialRevertWith4Bytes() ([GAS]) +[PASS] testExpectPartialRevertWithSelector() ([GAS]) +[FAIL: Error != expected error: WrongNumber(0) != custom error 0x238ace70] testExpectRevert() ([GAS]) +... +"#]]); +} + +#[forgetest_init] +fn test_assume_no_revert(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.insert_vm(); + prj.clear(); + + prj.update_config(|config| { + config.fuzz.runs = 100; + config.fuzz.seed = Some(U256::from(100)); + }); + + prj.add_source( + "Counter.t.sol", + r#" +import {Vm} from "./Vm.sol"; +import {DSTest} from "./test.sol"; +contract CounterWithRevert { + error CountError(); + error CheckError(); + + function count(uint256 a) public pure returns (uint256) { + if (a > 1000 || a < 10) { + revert CountError(); + } + return 99999999; + } + function check(uint256 a) public pure { + if (a == 99999999) { + revert CheckError(); + } + } + function dummy() public pure {} +} + +contract CounterRevertTest is DSTest { + Vm vm = Vm(HEVM_ADDRESS); + + function test_assume_no_revert_pass(uint256 a) public { + CounterWithRevert counter = new CounterWithRevert(); + vm.assumeNoRevert(); + a = counter.count(a); + assertEq(a, 99999999); + } + function test_assume_no_revert_fail_assert(uint256 a) public { + CounterWithRevert counter = new CounterWithRevert(); + vm.assumeNoRevert(); + a = counter.count(a); + // Test should fail on next assertion. + assertEq(a, 1); + } + function test_assume_no_revert_fail_in_2nd_call(uint256 a) public { + CounterWithRevert counter = new CounterWithRevert(); + vm.assumeNoRevert(); + a = counter.count(a); + // Test should revert here (not in scope of `assumeNoRevert` cheatcode). + counter.check(a); + assertEq(a, 99999999); + } + function test_assume_no_revert_fail_in_3rd_call(uint256 a) public { + CounterWithRevert counter = new CounterWithRevert(); + vm.assumeNoRevert(); + a = counter.count(a); + // Test `assumeNoRevert` applied to non reverting call should not be available for next reverting call. + vm.assumeNoRevert(); + counter.dummy(); + // Test will revert here (not in scope of `assumeNoRevert` cheatcode). + counter.check(a); + assertEq(a, 99999999); + } +} + "#, + ); + + cmd.args(["test"]).with_no_redact().assert_failure().stdout_eq(str![[r#" +... +[FAIL; counterexample: [..]] test_assume_no_revert_fail_assert(uint256) [..] +[FAIL: CheckError(); counterexample: [..]] test_assume_no_revert_fail_in_2nd_call(uint256) [..] +[FAIL: CheckError(); counterexample: [..]] test_assume_no_revert_fail_in_3rd_call(uint256) [..] +[PASS] test_assume_no_revert_pass(uint256) [..] +... +"#]]); +} + +#[forgetest_init] +fn skip_output(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.insert_vm(); + prj.clear(); + + prj.add_source( + "Counter.t.sol", + r#" + import {Vm} from "./Vm.sol"; + import {DSTest} from "./test.sol"; + + contract Skips is DSTest { + Vm constant vm = Vm(HEVM_ADDRESS); + + function test_skipUnit() public { + vm.skip(true); + } + function test_skipUnitReason() public { + vm.skip(true, "unit"); + } + + function test_skipFuzz(uint) public { + vm.skip(true); + } + function test_skipFuzzReason(uint) public { + vm.skip(true, "fuzz"); + } + + function invariant_skipInvariant() public { + vm.skip(true); + } + function invariant_skipInvariantReason() public { + vm.skip(true, "invariant"); + } + } + "#, + ); + + cmd.arg("test").assert_success().stdout_eq(str![[r#" +... +Ran 6 tests for src/Counter.t.sol:Skips +[SKIP] +Skips invariants: +[SKIP] invariant_skipInvariant +[SKIP: invariant] invariant_skipInvariantReason + Skips invariants (runs: 1, calls: 1, reverts: 1) +[SKIP] test_skipFuzz(uint256) (runs: 0, [AVG_GAS]) +[SKIP: fuzz] test_skipFuzzReason(uint256) (runs: 0, [AVG_GAS]) +[SKIP] test_skipUnit() ([GAS]) +[SKIP: unit] test_skipUnitReason() ([GAS]) +Suite result: ok. 0 passed; 0 failed; 6 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 0 failed, 6 skipped (6 total tests) + +"#]]); +} + +#[forgetest_init] +fn skip_setup(prj: _, cmd: _) { + prj.add_test( + "Counter.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SkipCounterSetup is Test { + + function setUp() public { + vm.skip(true, "skip counter test"); + } + + function test_require1() public pure { + require(1 > 2); + } + + function test_require2() public pure { + require(1 > 2); + } + + function test_require3() public pure { + require(1 > 2); + } +} + "#, + ); + + cmd.args(["test", "--mc", "SkipCounterSetup"]).assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/Counter.t.sol:SkipCounterSetup +[SKIP: skipped: skip counter test] setUp() ([GAS]) +Suite result: ok. 0 passed; 0 failed; 1 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 0 failed, 1 skipped (1 total tests) + +"#]]); +} + +// +#[forgetest_init] +fn skip_setup_after_caught_revert(prj: _, cmd: _) { + prj.add_test( + "SkipAfterCaughtRevert.t.sol", + r#" +import "forge-std/Test.sol"; + +contract Reverter { + fallback() external { + revert("caught"); + } +} + +contract SkipAfterCaughtRevert is Test { + function setUp() public { + (bool success,) = address(new Reverter()).call(""); + require(!success); + vm.skip(true, "skip after caught revert"); + } + + function test_neverRuns() public pure {} +} + "#, + ); + + cmd.args(["test", "--isolate", "--mc", "SkipAfterCaughtRevert"]).assert_success().stdout_eq( + str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/SkipAfterCaughtRevert.t.sol:SkipAfterCaughtRevert +[SKIP: skipped: skip after caught revert] setUp() ([GAS]) +Suite result: ok. 0 passed; 0 failed; 1 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 0 failed, 1 skipped (1 total tests) + +"#]], + ); +} + +#[forgetest_init] +fn forged_skip_payload_fails_setup(prj: _, cmd: _) { + prj.add_test( + "ForgedSkip.t.sol", + r#" +import "forge-std/Test.sol"; + +contract ForgedSkip is Test { + uint256 internal marker; + + function setUp() public { + marker = 1; + bytes memory reason = bytes("FOUNDRY::SKIPnot a real skip"); + assembly { + revert(add(reason, 32), mload(reason)) + } + } + + function test_neverRuns() public pure {} +} + "#, + ); + + cmd.args(["test", "--mc", "ForgedSkip"]).assert_failure().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/ForgedSkip.t.sol:ForgedSkip +[FAIL: FOUNDRY::SKIPnot a real skip] setUp() ([GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/ForgedSkip.t.sol:ForgedSkip +[FAIL: FOUNDRY::SKIPnot a real skip] setUp() ([GAS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test +Tip: Run `forge test --debug --match-test ` to inspect one failing test in the debugger + +"#]]); +} + +#[forgetest_init] +fn forged_skip_after_caught_skip_fails_setup(prj: _, cmd: _) { + prj.add_test( + "ForgedSkipAfterCaughtSkip.t.sol", + r#" +import "forge-std/Test.sol"; + +contract ForgedSkipAfterCaughtSkip is Test { + function setUp() public { + // Catch a genuine skip so a payload is recorded, then revert with different skip bytes. + (bool success,) = address(vm).call( + abi.encodeWithSignature("skip(bool,string)", true, "genuine") + ); + require(!success); + + bytes memory reason = bytes("FOUNDRY::SKIPforged"); + assembly { + revert(add(reason, 32), mload(reason)) + } + } + + function test_neverRuns() public pure {} +} + "#, + ); + + cmd.args(["test", "--mc", "ForgedSkipAfterCaughtSkip"]).assert_failure().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/ForgedSkipAfterCaughtSkip.t.sol:ForgedSkipAfterCaughtSkip +[FAIL: FOUNDRY::SKIPforged] setUp() ([GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/ForgedSkipAfterCaughtSkip.t.sol:ForgedSkipAfterCaughtSkip +[FAIL: FOUNDRY::SKIPforged] setUp() ([GAS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test +Tip: Run `forge test --debug --match-test ` to inspect one failing test in the debugger + +"#]]); +} + +// A caught genuine skip that is re-raised byte-identically still counts as a skip: the payload +// provenance is byte equality with what the skip cheatcode minted, not the revert call chain. +#[forgetest_init] +fn caught_skip_reraised_identical_is_skipped(prj: _, cmd: _) { + prj.add_test( + "ReraisedSkip.t.sol", + r#" +import "forge-std/Test.sol"; + +contract ReraisedSkip is Test { + function setUp() public { + (bool success, bytes memory data) = address(vm).call( + abi.encodeWithSignature("skip(bool,string)", true, "reraised") + ); + require(!success); + assembly { + revert(add(data, 32), mload(data)) + } + } + + function test_neverRuns() public pure {} +} + "#, + ); + + cmd.args(["test", "--mc", "ReraisedSkip"]).assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/ReraisedSkip.t.sol:ReraisedSkip +[SKIP: skipped: reraised] setUp() ([GAS]) +Suite result: ok. 0 passed; 0 failed; 1 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 0 failed, 1 skipped (1 total tests) + +"#]]); +} + +#[forgetest_init] +fn should_generate_junit_xml_report(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.insert_vm(); + prj.clear(); + + prj.add_source( + "JunitReportTest.t.sol", + r#" + import {Vm} from "./Vm.sol"; + import {DSTest} from "./test.sol"; + + contract AJunitReportTest is DSTest { + function test_junit_assert_fail() public { + assert(1 > 2); + } + + function test_junit_revert_fail() public { + require(1 > 2, "Revert"); + } + } + + contract BJunitReportTest is DSTest { + Vm constant vm = Vm(HEVM_ADDRESS); + function test_junit_pass() public { + require(1 < 2, "Revert"); + } + + function test_junit_skip() public { + vm.skip(true); + } + + function test_junit_skip_with_message() public { + vm.skip(true, "skipped test"); + } + + function test_junit_pass_fuzz(uint256 a) public { + } + } + "#, + ); + + cmd.args(["test", "--junit"]).assert_failure().stdout_eq(str![[r#" + + + + + + [FAIL: panic: assertion failed (0x01)] test_junit_assert_fail() ([GAS]) + + + + [FAIL: Revert] test_junit_revert_fail() ([GAS]) + + Suite result: FAILED. 0 passed; 2 failed; 0 skipped; [ELAPSED] + + + + [PASS] test_junit_pass() ([GAS]) + + + [PASS] test_junit_pass_fuzz(uint256) (runs: 256, [AVG_GAS]) + + + + [SKIP] test_junit_skip() ([GAS]) + + + + [SKIP: skipped test] test_junit_skip_with_message() ([GAS]) + + Suite result: ok. 2 passed; 0 failed; 2 skipped; [ELAPSED] + + + + +"#]]); +} + +#[forgetest_init] +fn should_generate_junit_xml_report_with_logs(prj: _, cmd: _) { + prj.add_source( + "JunitReportTest.t.sol", + r#" +import "forge-std/Test.sol"; +contract JunitReportTest is Test { + function test_junit_with_logs() public { + console.log("Step1"); + console.log("Step2"); + console.log("Step3"); + assert(2 > 1); + } +} + "#, + ); + + cmd.args(["test", "--junit", "-vvvv"]).assert_success().stdout_eq(str![[r#" + + + + + [PASS] test_junit_with_logs() ([GAS])/nLogs:/n Step1/n Step2/n Step3/n + + Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + + + + +"#]]); +} + +// Enable this if no cheatcodes are deprecated. +// #[ignore = "no cheatcodes are deprecated"] +#[forgetest_init] +fn test_deprecated_cheatcode_warning(prj: _, cmd: _) { + prj.add_test( + "DeprecatedCheatcodeTest.t.sol", + r#" + import "forge-std/Test.sol"; + contract DeprecatedCheatcodeTest is Test { + function test_deprecated_cheatcode() public view { + vm.keyExists('{"a": 123}', ".a"); + vm.keyExists('{"a": 123}', ".a"); + } + } + + contract DeprecatedCheatcodeFuzzTest is Test { + function test_deprecated_cheatcode(uint256 a) public view { + vm.keyExists('{"a": 123}', ".a"); + } + } + + contract Counter { + uint256 a; + + function count() public { + a++; + } + } + + contract DeprecatedCheatcodeInvariantTest is Test { + function setUp() public { + Counter counter = new Counter(); + } + + /// forge-config: default.invariant.runs = 1 + function invariant_deprecated_cheatcode() public { + vm.keyExists('{"a": 123}', ".a"); + } + } + "#, + ); + + // Tests deprecated cheatcode warning for unit tests. + cmd.args(["test", "--mc", "DeprecatedCheatcodeTest"]).assert_success().stderr_eq(str![[r#" +Warning: the following cheatcode(s) are deprecated and will be removed in future versions: + keyExists(string,string): replaced by `keyExistsJson` + +"#]]); + + // Tests deprecated cheatcode warning for fuzz tests. + cmd.forge_fuse() + .args(["test", "--mc", "DeprecatedCheatcodeFuzzTest"]) + .assert_success() + .stderr_eq(str![[r#" +Warning: the following cheatcode(s) are deprecated and will be removed in future versions: + keyExists(string,string): replaced by `keyExistsJson` + +"#]]); + + // Tests deprecated cheatcode warning for invariant tests. + cmd.forge_fuse() + .args(["test", "--mc", "DeprecatedCheatcodeInvariantTest"]) + .assert_success() + .stderr_eq(str![[r#" +Warning: the following cheatcode(s) are deprecated and will be removed in future versions: + keyExists(string,string): replaced by `keyExistsJson` + +"#]]); +} + +#[forgetest_init] +fn requires_single_test(prj: _, cmd: _) { + prj.initialize_default_contracts(); + cmd.args(["test", "--debug"]).assert_failure().stderr_eq(str![[r#" +Error: 2 tests matched your criteria, but exactly 1 test must match in order to run the debugger. + +Matching tests: + test/Counter.t.sol:CounterTest.testFuzz_SetNumber + test/Counter.t.sol:CounterTest.test_Increment + +Use --match-test , --match-contract, and --match-path to further limit the search. + +"#]]); + cmd.forge_fuse().args(["test", "--flamegraph"]).assert_failure().stderr_eq(str![[r#" +Error: 2 tests matched your criteria, but exactly 1 test must match in order to generate a flamegraph. + +Use --match-contract and --match-path to further limit the search. + +"#]]); + cmd.forge_fuse().args(["test", "--flamechart"]).assert_failure().stderr_eq(str![[r#" +Error: 2 tests matched your criteria, but exactly 1 test must match in order to generate a flamechart. + +Use --match-contract and --match-path to further limit the search. + +"#]]); + cmd.forge_fuse().args(["test", "--evm-profile"]).assert_failure().stderr_eq(str![[r#" +Error: 2 tests matched your criteria, but exactly 1 test must match in order to generate an EVM profile. + +Use --match-contract and --match-path to further limit the search. + +"#]]); +} + +// Test a script that calls vm.rememberKeys +#[forgetest_init] +fn script_testing(prj: _, cmd: _) { + prj + .add_source( + "Foo", + r#" +import "forge-std/Script.sol"; + +interface Vm { +function rememberKeys(string calldata mnemonic, string calldata derivationPath, uint32 count) external returns (address[] memory keyAddrs); +} + +contract WalletScript is Script { +function run() public { + string memory mnemonic = "test test test test test test test test test test test junk"; + string memory derivationPath = "m/44'/60'/0'/0/"; + address[] memory wallets = Vm(address(vm)).rememberKeys(mnemonic, derivationPath, 3); + for (uint256 i = 0; i < wallets.length; i++) { + console.log(wallets[i]); + } +} +} + +contract FooTest { + WalletScript public script; + + + function setUp() public { + script = new WalletScript(); + } + + function testWalletScript() public { + script.run(); + } +} + +"#, + ); + + cmd.args(["test", "--mt", "testWalletScript", "-vvv"]).assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for src/Foo.sol:FooTest +[PASS] testWalletScript() ([GAS]) +Logs: + 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 + 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 + 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC +... +"#]]); +} + +// +#[forgetest_init] +fn metadata_bytecode_traces(prj: _, cmd: _) { + prj.initialize_default_contracts(); + prj.add_source( + "ParentProxy.sol", + r#" +import {Counter} from "./Counter.sol"; + +abstract contract ParentProxy { + Counter impl; + bytes data; + + constructor(Counter _implementation, bytes memory _data) { + impl = _implementation; + data = _data; + } +} + "#, + ); + prj.add_source( + "Proxy.sol", + r#" +import {ParentProxy} from "./ParentProxy.sol"; +import {Counter} from "./Counter.sol"; + +contract Proxy is ParentProxy { + constructor(Counter _implementation, bytes memory _data) + ParentProxy(_implementation, _data) + {} +} + "#, + ); + + prj.add_test( + "MetadataTraceTest.t.sol", + r#" +import {Counter} from "src/Counter.sol"; +import {Proxy} from "src/Proxy.sol"; + +import {Test} from "forge-std/Test.sol"; + +contract MetadataTraceTest is Test { + function test_proxy_trace() public { + Counter counter = new Counter(); + new Proxy(counter, ""); + } +} + "#, + ); + + cmd.args(["test", "--mt", "test_proxy_trace", "-vvvv", "--no-dynamic-test-linking"]) + .assert_success() + .stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/MetadataTraceTest.t.sol:MetadataTraceTest +[PASS] test_proxy_trace() ([GAS]) +Traces: + [..] MetadataTraceTest::test_proxy_trace() + ├─ [..] → new Counter@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f + │ └─ ← [Return] 481 bytes of code + ├─ [..] → new Proxy@0x2e234DAe75C793f67A35089C9d99245E1C58470b + │ └─ ← [Return] 62 bytes of code + └─ ← [Stop] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); + + // Check consistent traces for running with no metadata. + cmd.forge_fuse() + .args([ + "test", + "--mt", + "test_proxy_trace", + "-vvvv", + "--no-metadata", + "--no-dynamic-test-linking", + ]) + .assert_success() + .stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/MetadataTraceTest.t.sol:MetadataTraceTest +[PASS] test_proxy_trace() ([GAS]) +Traces: + [..] MetadataTraceTest::test_proxy_trace() + ├─ [..] → new Counter@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f + │ └─ ← [Return] 427 bytes of code + ├─ [..] → new Proxy@0x2e234DAe75C793f67A35089C9d99245E1C58470b + │ └─ ← [Return] 8 bytes of code + └─ ← [Stop] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +} + +// Tests if dump of execution was created. +#[forgetest] +fn test_debug_with_dump(prj: _, cmd: _) { + prj.add_source( + "dummy", + r" +contract Dummy { + function testDummy() public {} +} +", + ); + + let dump_path = prj.root().join("dump.json"); + + cmd.args(["test", "--mt", "testDummy", "--debug", "--dump", dump_path.to_str().unwrap()]); + cmd.assert_success(); + + assert!(dump_path.exists()); +} + +#[forgetest] +fn debug_dump_marks_precompile_call_steps(prj: _, cmd: _) { + prj.add_test( + "PrecompileDebug.t.sol", + r#" +contract PrecompileDebugTest { + function testSha256PrecompileDebug() public { + (bool ok, bytes memory output) = address(0x02).staticcall(hex"68656c6c6f"); + require(ok, "sha256 precompile failed"); + require(output.length == 32, "bad sha256 output"); + } +} +"#, + ); + + let dump_path = prj.root().join("precompile_dump.json"); + + cmd.args([ + "test", + "--mt", + "testSha256PrecompileDebug", + "--debug", + "--dump", + dump_path.to_str().unwrap(), + ]); + cmd.assert_success(); + + let dump: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(dump_path).unwrap()).unwrap(); + let mut lines = Vec::new(); + collect_debug_dump_decoded_lines(&dump, &mut lines); + + assert!( + lines.iter().any(|line| { + *line == "precompile: PRECOMPILES::sha256(0x68656c6c6f) -> 0x2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + }), + "missing decoded precompile line in debugger dump: {lines:?}" + ); +} + +#[forgetest] +fn debug_dump_marks_tempo_precompile_call_steps(prj: _, cmd: _) { + prj.update_config(|config| { + config.networks = foundry_evm_networks::NetworkConfigs::with_tempo(); + config.hardfork = Some("tempo:T5".parse::().unwrap()); + }); + + prj.add_test( + "TempoPrecompileDebug.t.sol", + r#" +contract TempoPrecompileDebugTest { + address constant TIP_FEE_MANAGER = 0xfeEC000000000000000000000000000000000000; + + function testTempoFeeManagerPrecompileDebug() public view { + (bool ok, bytes memory output) = TIP_FEE_MANAGER.staticcall( + abi.encodeWithSignature("userTokens(address)", address(0)) + ); + require(ok, "FeeManager precompile failed"); + require(output.length == 32, "bad FeeManager output"); + } +} +"#, + ); + + let dump_path = prj.root().join("tempo_precompile_dump.json"); + + cmd.args([ + "test", + "--mt", + "testTempoFeeManagerPrecompileDebug", + "--debug", + "--dump", + dump_path.to_str().unwrap(), + ]); + cmd.assert_success(); + + let dump: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(dump_path).unwrap()).unwrap(); + let mut lines = Vec::new(); + collect_debug_dump_decoded_lines(&dump, &mut lines); + + assert!( + lines.iter().any(|line| line.contains("precompile: FeeManager::userTokens(")), + "missing decoded Tempo precompile line in debugger dump: {lines:?}" + ); +} + +#[forgetest] +fn debug_dump_disambiguates_overloaded_internal_functions(prj: _, cmd: _) { + prj.add_source( + "DebugVars", + r" +contract DebugVarsTest { + function testOverloadedInternalDebugVars() public { + uint256 amount = foo(uint256(42)); + address who = foo(address(0x000000000000000000000000000000000000bEEF)); + + require(amount == 43, 'bad amount'); + require(who == address(0x000000000000000000000000000000000000bEEF), 'bad address'); + } + + function foo(uint256 amount) internal pure returns (uint256 out) { + uint256 next = amount + 1; + return next; + } + + function foo(address who) internal pure returns (address out) { + address seen = who; + return seen; + } +} +", + ); + + let dump_path = prj.root().join("overloads_dump.json"); + + cmd.args([ + "test", + "--mt", + "testOverloadedInternalDebugVars", + "--debug", + "--dump", + dump_path.to_str().unwrap(), + ]); + cmd.assert_success(); + + let dump: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(dump_path).unwrap()).unwrap(); + let mut calls = Vec::new(); + collect_debug_dump_internal_calls(&dump, &mut calls); + + let uint_call = calls + .iter() + .find(|call| call["func_name"] == "DebugVarsTest::foo(uint256)") + .expect("missing uint256 overload in debugger dump"); + assert_eq!(uint_call["args"], serde_json::json!(["42"])); + assert_eq!(uint_call["return_data"], serde_json::json!(["43"])); + + let address_call = calls + .iter() + .find(|call| call["func_name"] == "DebugVarsTest::foo(address)") + .expect("missing address overload in debugger dump"); + assert_eq!( + address_call["args"], + serde_json::json!(["0x000000000000000000000000000000000000bEEF"]) + ); + assert_eq!( + address_call["return_data"], + serde_json::json!(["0x000000000000000000000000000000000000bEEF"]) + ); +} + +// Progresses : debugger storage view. +#[forgetest] +fn debug_dump_includes_storage_changes(prj: _, cmd: _) { + prj.add_source( + "DebugStorage", + r" +contract DebugStorageTest { + uint256 value; + uint256 untouched; + + function testStorage() public { + value = 42; + uint256 loaded = value; + uint256 coldLoaded = untouched; + require(loaded == 42 && coldLoaded == 0); + } +} +", + ); + + let dump_path = prj.root().join("storage_dump.json"); + + cmd.args(["test", "--mt", "testStorage", "--debug", "--dump", dump_path.to_str().unwrap()]); + cmd.assert_success(); + + let dump: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(dump_path).unwrap()).unwrap(); + let mut storage_changes = Vec::new(); + collect_debug_dump_storage_changes(&dump, &mut storage_changes); + + assert!( + storage_changes.iter().any(|change| change["reason"] == "SSTORE"), + "debugger dump should include an SSTORE storage change: {storage_changes:#?}" + ); + assert!( + storage_changes.iter().any(|change| change["reason"] == "SLOAD"), + "debugger dump should include an SLOAD storage access: {storage_changes:#?}" + ); +} + +// Resolve the executed contract before decoding internal calls in tests and scripts. +#[forgetest] +fn debug_dump_identifies_etched_code_by_executed_bytecode(prj: _, cmd: _) { + prj.update_config(|config| { + config.bytecode_hash = "none".parse().unwrap(); + config.optimizer = Some(false); + config.tracing.decode_internal = true; + }); + prj.add_test( + "EtchDebug.t.sol", + r#" +interface Vm { + function etch(address target, bytes calldata newRuntimeBytecode) external; +} + +contract Trusted { + function trustedOnly() external pure returns (uint256) { return trustedInternal(); } + function trustedInternal() internal pure returns (uint256) { return 1; } +} + +contract Payload { + function attackerEntry() external pure returns (uint256) { return payloadInternal(); } + function payloadInternal() internal pure returns (uint256) { return 42; } +} + +contract A { + uint256 private stored; + constructor() { stored = 1; } + function value() external pure returns (uint256) { return 7; } +} + +contract B { + uint256 private stored; + constructor() { stored = 2; } + function value() external pure returns (uint256) { return 7; } +} + +contract WithImmutable { + uint256 private immutable stored; + constructor(uint256 value_) { stored = value_; } + function value() external view returns (uint256) { return stored; } +} + +contract WithConstant { + function value() external pure returns (uint256) { return 65534; } +} + +contract EtchDebugTest { + Vm constant vm = Vm(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D); + address constant PREEXISTING = address(0xBEEF); + Trusted trusted; + B b; + WithImmutable initialized; + + function setUp() public { + trusted = new Trusted(); + b = new B(); + initialized = new WithImmutable(65535); + vm.etch(PREEXISTING, type(Trusted).runtimeCode); + } + + function testEtchDeployed() public { + require(trusted.trustedOnly() == 1); + vm.etch(address(trusted), type(Payload).runtimeCode); + require(Payload(address(trusted)).attackerEntry() == 42); + } + + function testEtchPreexisting() public { + vm.etch(PREEXISTING, type(Payload).runtimeCode); + require(Payload(PREEXISTING).attackerEntry() == 42); + } + + function testIdenticalRuntime() public view { + require(keccak256(type(A).runtimeCode) == keccak256(type(B).runtimeCode)); + require(b.value() == 7); + } + + function testEtchImmutable() public { + vm.etch(address(trusted), address(initialized).code); + require(WithImmutable(address(trusted)).value() == 65535); + } + + function testEtchUnknown() public { + bytes memory code = type(Trusted).runtimeCode; + // Change the internal function's return value without changing its jump structure. + bool changed; + for (uint256 i; i + 1 < code.length; ++i) { + if (code[i] == 0x60 && code[i + 1] == 0x01) { + code[i + 1] = 0x02; + changed = true; + break; + } + } + require(changed); + vm.etch(address(trusted), code); + (bool ok, bytes memory output) = address(trusted).call(abi.encodeCall(Trusted.trustedOnly, ())); + require(ok && abi.decode(output, (uint256)) == 2); + } +} +"#, + ); + + let dump_path = prj.root().join("etch_dump.json"); + for (test, expected, expected_decoded) in [ + ( + "testEtchDeployed", + str![[r#" +[ + ["Trusted", ["Trusted::trustedInternal()"]], + ["Payload", ["Payload::payloadInternal()"]] +] +"#]], + str![[r#"[["trustedOnly()", "Trusted"], ["attackerEntry()", "Payload"]]"#]], + ), + ( + "testEtchPreexisting", + str![[r#" +[["Payload", ["Payload::payloadInternal()"]]] +"#]], + str![[r#"[["attackerEntry()", "Payload"]]"#]], + ), + ("testIdenticalRuntime", str![[r#"[["B", []]]"#]], str![[r#"[["value()", "B"]]"#]]), + ( + "testEtchImmutable", + str![[r#"[["WithImmutable", []]]"#]], + str![[r#"[["value()", "WithImmutable"]]"#]], + ), + ("testEtchUnknown", str![[r#"[[null, []]]"#]], str![[r#"[["trustedOnly()", null]]"#]]), + ] { + for command in ["test", "script"] { + let args = if command == "test" { + vec!["test".to_string(), "--mt".to_string(), test.to_string()] + } else { + vec![ + "script".to_string(), + "test/EtchDebug.t.sol:EtchDebugTest".to_string(), + "--sig".to_string(), + format!("{test}()"), + ] + }; + cmd.forge_fuse() + .args(args) + .args(["--debug", "--dump", dump_path.to_str().unwrap()]) + .assert_success(); + let dump: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&dump_path).unwrap()).unwrap(); + let nodes = dump["debug_arena"] + .as_array() + .unwrap() + .iter() + .filter(|node| node["contract_name"] != "EtchDebugTest") + .collect::>(); + let frames = nodes + .iter() + .map(|node| { + let mut calls = Vec::new(); + collect_debug_dump_internal_calls(node, &mut calls); + let functions = calls.iter().map(|call| &call["func_name"]).collect::>(); + serde_json::json!([node["contract_name"], functions]) + }) + .collect::>(); + assert_data_eq!( + serde_json::to_string(&frames).unwrap().is_json(), + expected.clone().is_json() + ); + if command == "test" { + let decoded = nodes + .iter() + .map(|node| { + serde_json::json!([ + node["decoded"]["call_data"]["signature"], + node["decoded"]["label"] + ]) + }) + .collect::>(); + assert_data_eq!( + serde_json::to_string(&decoded).unwrap().is_json(), + expected_decoded.clone().is_json() + ); + } + } + } +} + +// Metadata-exact matches outrank the old address identity after an etch. +#[forgetest] +fn debug_dump_prefers_exact_metadata(prj: _, cmd: _) { + for name in ["Trusted", "Payload"] { + prj.add_source( + name, + &format!( + "contract {name} {{ function value() external pure returns (uint256) {{ return 7; }} }}" + ), + ); + } + prj.add_test( + "MetadataDebug.t.sol", + r#" +import "../src/Trusted.sol"; +import "../src/Payload.sol"; + +interface Vm { + function etch(address target, bytes calldata code) external; +} + +contract MetadataDebugTest { + Vm constant vm = Vm(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D); + Trusted target; + + function setUp() public { target = new Trusted(); } + + function testMetadata() public { + require(keccak256(type(Trusted).runtimeCode) != keccak256(type(Payload).runtimeCode)); + require(target.value() == 7); + vm.etch(address(target), type(Payload).runtimeCode); + require(Payload(address(target)).value() == 7); + } +} +"#, + ); + let dump_path = prj.root().join("metadata_dump.json"); + cmd.args(["test", "--mt", "testMetadata", "--debug", "--dump", dump_path.to_str().unwrap()]) + .assert_success(); + let dump: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(dump_path).unwrap()).unwrap(); + let frames = dump["debug_arena"] + .as_array() + .unwrap() + .iter() + .filter(|node| node["contract_name"] != "MetadataDebugTest") + .map(|node| serde_json::json!([node["contract_name"], node["decoded"]["label"]])) + .collect::>(); + assert_data_eq!( + serde_json::to_string(&frames).unwrap().is_json(), + str![[r#"[["Trusted", "Trusted"], ["Payload", "Payload"]]"#]].is_json() + ); +} + +// Vyper runtime artifacts omit immutable data, but changes to the runtime must still be rejected. +#[forgetest] +fn debug_dump_identifies_vyper_immutables(prj: _, cmd: _) { + prj.update_config(|config| config.vyper.path = Some(get_vyper().path)); + for (name, value, result) in [("Immutable", 42, "value"), ("Replacement", 99, "value + 1")] { + prj.add_raw_source( + &format!("{name}.vy"), + &format!( + r#" +value: immutable(uint256) + +@deploy +def __init__(): + value = {value} + +@external +@view +def read() -> uint256: + return {result} +"# + ), + ); + } + prj.add_test( + "VyperDebug.t.sol", + r#" +interface Vm { + function getCode(string calldata artifact) external view returns (bytes memory); + function etch(address target, bytes calldata code) external; +} +interface IImmutable { function read() external view returns (uint256); } + +contract VyperDebugTest { + Vm constant vm = Vm(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D); + address target; + address replacement; + + function setUp() public { + target = deploy("Immutable.vy"); + replacement = deploy("Replacement.vy"); + } + + function deploy(string memory artifact) internal returns (address deployed) { + bytes memory code = vm.getCode(artifact); + assembly { deployed := create(0, add(code, 32), mload(code)) } + require(deployed != address(0)); + } + + function testImmutables() public { + require(IImmutable(target).read() == 42); + vm.etch(target, replacement.code); + require(IImmutable(target).read() == 100); + bytes memory unknown = replacement.code; + unknown[0] = 0x00; + vm.etch(target, unknown); + (bool ok,) = target.call(abi.encodeCall(IImmutable.read, ())); + require(ok); + } +} +"#, + ); + let dump_path = prj.root().join("vyper_dump.json"); + cmd.args(["test", "--mt", "testImmutables", "--debug", "--dump", dump_path.to_str().unwrap()]) + .assert_success(); + let dump: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(dump_path).unwrap()).unwrap(); + let frames = dump["debug_arena"] + .as_array() + .unwrap() + .iter() + .filter(|node| node["contract_name"] != "VyperDebugTest") + .map(|node| serde_json::json!([node["contract_name"], node["decoded"]["label"]])) + .collect::>(); + assert_data_eq!( + serde_json::to_string(&frames).unwrap().is_json(), + str![[r#"[["Immutable", "Immutable"], ["Replacement", "Replacement"], [null, null]]"#]] + .is_json() + ); +} + +// +#[forgetest] +fn test_debug_with_dump_setup_revert(prj: _, cmd: _) { + prj.add_test( + "SetupRevertDebug.t.sol", + r#" +contract SetupRevertDebugTest { + function setUp() public { + revert("setUp failed"); + } + + function testDummy() public {} +} +"#, + ); + + let dump_path = prj.root().join("setup_revert_dump.json"); + + let out = cmd + .args(["test", "--mt", "testDummy", "--debug", "--dump", dump_path.to_str().unwrap()]) + .execute(); + + assert!(out.status.success(), "debug run with --dump should complete even when setUp reverts"); + assert!(dump_path.exists(), "debugger dump should still be generated"); + assert!( + !out.stderr_lossy().contains("debug arena is empty"), + "debugger should not fail with an empty arena on setUp revert" + ); + assert!( + out.stdout_lossy().contains("[FAIL: setUp failed] setUp()"), + "expected setup failure output in forge test report" + ); +} + +// +#[forgetest] +fn test_debug_with_dump_setup_revert_invariant(prj: _, cmd: _) { + prj.add_test( + "SetupRevertInvariantDebug.t.sol", + r#" +contract SetupRevertInvariantDebugTest { + function setUp() public { + revert("setUp failed"); + } + + function invariant_dummy() public {} +} +"#, + ); + + let dump_path = prj.root().join("setup_revert_invariant_dump.json"); + + let out = cmd + .args(["test", "--mt", "invariant_dummy", "--debug", "--dump", dump_path.to_str().unwrap()]) + .execute(); + + assert!( + out.status.success(), + "debug run with --dump should complete even when invariant setUp reverts" + ); + assert!(dump_path.exists(), "debugger dump should still be generated"); + assert!( + !out.stderr_lossy().contains("debug arena is empty"), + "debugger should not fail with an empty arena on invariant setUp revert" + ); + assert!( + out.stdout_lossy().contains("[FAIL: setUp failed] setUp()"), + "expected setup failure output in forge test report" + ); +} + +#[forgetest] +async fn debug_dump_identifies_contracts_loaded_from_selected_fork(prj: _, cmd: _) { + prj.add_source( + "ForkDebugTarget.sol", + r#" +contract ForkDebugTarget { + uint256 public value; + + function set(uint256 newValue) public { + value = newValue; + } +} +"#, + ); + + let (_api, handle) = spawn(NodeConfig::test()).await; + let rpc = handle.http_endpoint(); + let pk = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + + cmd.forge_fuse() + .args([ + "create", + "./src/ForkDebugTarget.sol:ForkDebugTarget", + "--rpc-url", + rpc.as_str(), + "--private-key", + pk, + "--broadcast", + ]) + .assert_success(); + let deployed = address!("0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266").create(0); + let deployed = deployed.to_string(); + + prj.add_test( + "ForkDebug.t.sol", + &format!( + r#" +interface Vm {{ + function createSelectFork(string calldata url) external returns (uint256 forkId); +}} + +interface IForkDebugTarget {{ + function set(uint256 newValue) external; + function value() external view returns (uint256); +}} + +contract ForkDebugTest {{ + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + uint256[] public fixtureNewValue = [7]; + + function testDebugForkTarget() public {{ + vm.createSelectFork("{rpc}"); + IForkDebugTarget target = IForkDebugTarget({deployed}); + target.set(7); + require(target.value() == 7, "value"); + }} + + /// forge-config: default.fuzz.runs = 1 + function testDebugForkTargetFuzz(uint256 newValue) public {{ + vm.createSelectFork("{rpc}"); + IForkDebugTarget target = IForkDebugTarget({deployed}); + target.set(newValue); + require(target.value() == newValue, "value"); + }} + + function tableDebugForkTarget(uint256 newValue) public {{ + vm.createSelectFork("{rpc}"); + IForkDebugTarget target = IForkDebugTarget({deployed}); + target.set(newValue); + require(target.value() == newValue, "value"); + }} +}} +"# + ), + ); + + let dump_path = prj.root().join("dump.json"); + cmd.forge_fuse().args([ + "test", + "--mt", + "^testDebugForkTarget\\(\\)$", + "--debug", + "--dump", + dump_path.to_str().unwrap(), + ]); + cmd.assert_success(); + assert_debug_dump_identifies_contract(&dump_path, &deployed, "ForkDebugTarget"); + + let fuzz_dump_path = prj.root().join("fuzz_dump.json"); + cmd.forge_fuse().args([ + "test", + "--mt", + "^testDebugForkTargetFuzz\\(uint256\\)$", + "--debug", + "--dump", + fuzz_dump_path.to_str().unwrap(), + ]); + cmd.assert_success(); + assert_debug_dump_identifies_contract(&fuzz_dump_path, &deployed, "ForkDebugTarget"); + + let table_dump_path = prj.root().join("table_dump.json"); + cmd.forge_fuse().args([ + "test", + "--mt", + "^tableDebugForkTarget\\(uint256\\)$", + "--debug", + "--dump", + table_dump_path.to_str().unwrap(), + ]); + cmd.assert_success(); + assert_debug_dump_identifies_contract(&table_dump_path, &deployed, "ForkDebugTarget"); +} + +#[forgetest_init] +fn test_assume_no_revert_with_data(prj: _, cmd: _) { + prj.update_config(|config| { + config.fuzz.seed = Some(U256::from(111)); + config.fuzz.dictionary.max_fuzz_dictionary_literals = 0; + }); + + prj.add_source( + "AssumeNoRevertTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +interface Vm { + struct PotentialRevert { + address reverter; + bool partialMatch; + bytes revertData; + } + function expectRevert() external; + function assumeNoRevert() external pure; + function assumeNoRevert(PotentialRevert calldata revertData) external pure; + function assumeNoRevert(PotentialRevert[] calldata revertData) external pure; + function expectRevert(bytes4 revertData, uint64 count) external; + function assume(bool condition) external pure; +} + +contract ReverterB { + /// @notice has same error selectors as contract below to test the `reverter` param + error MyRevert(); + error SpecialRevertWithData(uint256 x); + + function revertIf2(uint256 x) public pure returns (bool) { + if (x == 2) { + revert MyRevert(); + } + return true; + } + + function revertWithData() public pure returns (bool) { + revert SpecialRevertWithData(2); + } +} + +contract Reverter { + error MyRevert(); + error RevertWithData(uint256 x); + error UnusedError(); + error ExpectedRevertCountZero(); + + ReverterB public immutable subReverter; + + constructor() { + subReverter = new ReverterB(); + } + + function myFunction() public pure returns (bool) { + revert MyRevert(); + } + + function revertIf2(uint256 value) public pure returns (bool) { + if (value == 2) { + revert MyRevert(); + } + return true; + } + + function revertWithDataIf2(uint256 value) public pure returns (bool) { + if (value == 2) { + revert RevertWithData(2); + } + return true; + } + + function twoPossibleReverts(uint256 x) public pure returns (bool) { + if (x == 2) { + revert MyRevert(); + } else if (x == 3) { + revert RevertWithData(3); + } + return true; + } + + function revertIf2Or3ExpectedRevertZero(uint256 x) public pure returns (bool) { + if (x == 2) { + revert ExpectedRevertCountZero(); + } else if (x == 3) { + revert MyRevert(); + } + return true; + } +} + +contract ReverterTest is Test { + Reverter reverter; + Vm _vm = Vm(VM_ADDRESS); + + function setUp() public { + reverter = new Reverter(); + } + + /// @dev Test that `assumeNoRevert` does not reject an unanticipated error selector + function testAssume_wrongSelector_fails(uint256 x) public view { + _vm.assumeNoRevert(Vm.PotentialRevert({revertData: abi.encodeWithSelector(Reverter.UnusedError.selector), partialMatch: false, reverter: address(0)})); + reverter.revertIf2(x); + } + + /// @dev Test that `assumeNoRevert` does not reject an unanticipated error with extra data + function testAssume_wrongData_fails(uint256 x) public view { + _vm.assumeNoRevert(Vm.PotentialRevert({revertData: abi.encodeWithSelector(Reverter.RevertWithData.selector, 3), partialMatch: false, reverter: address(0)})); + reverter.revertWithDataIf2(x); + } + + /// @dev Test that `assumeNoRevert` correctly rejects an error selector from a different contract + function testAssumeWithReverter_fails(uint256 x) public view { + ReverterB subReverter = (reverter.subReverter()); + _vm.assumeNoRevert(Vm.PotentialRevert({revertData: abi.encodeWithSelector(Reverter.MyRevert.selector), partialMatch: false, reverter: address(reverter)})); + subReverter.revertIf2(x); + } + + /// @dev Test that `assumeNoRevert` correctly rejects one of two different error selectors when supplying a specific reverter + function testMultipleAssumes_OneWrong_fails(uint256 x) public view { + Vm.PotentialRevert[] memory revertData = new Vm.PotentialRevert[](2); + revertData[0] = Vm.PotentialRevert({revertData: abi.encodeWithSelector(Reverter.MyRevert.selector), partialMatch: false, reverter: address(reverter)}); + revertData[1] = Vm.PotentialRevert({revertData: abi.encodeWithSelector(Reverter.RevertWithData.selector, 4), partialMatch: false, reverter: address(reverter)}); + _vm.assumeNoRevert(revertData); + reverter.twoPossibleReverts(x); + } + + /// @dev Test that `assumeNoRevert` assumptions are cleared after the first non-cheatcode external call + function testMultipleAssumesClearAfterCall_fails(uint256 x) public view { + _vm.assume(x != 3); + Vm.PotentialRevert[] memory revertData = new Vm.PotentialRevert[](2); + revertData[0] = Vm.PotentialRevert({revertData: abi.encodeWithSelector(Reverter.MyRevert.selector), partialMatch: false, reverter: address(0)}); + revertData[1] = Vm.PotentialRevert({revertData: abi.encodeWithSelector(Reverter.RevertWithData.selector, 4), partialMatch: false, reverter: address(reverter)}); + _vm.assumeNoRevert(revertData); + reverter.twoPossibleReverts(x); + + reverter.twoPossibleReverts(2); + } + + /// @dev Test that `assumeNoRevert` correctly rejects a generic assumeNoRevert call after any specific reason is provided + function testMultipleAssumes_ThrowOnGenericNoRevert_AfterSpecific_fails(bytes4 selector) public view { + _vm.assumeNoRevert(Vm.PotentialRevert({revertData: abi.encode(selector), partialMatch: false, reverter: address(0)})); + _vm.assumeNoRevert(); + reverter.twoPossibleReverts(2); + } + + function testAssumeThenExpectCountZeroFails(uint256 x) public { + _vm.assumeNoRevert( + Vm.PotentialRevert({ + revertData: abi.encodeWithSelector(Reverter.MyRevert.selector), + partialMatch: false, + reverter: address(0) + }) + ); + _vm.expectRevert(Reverter.ExpectedRevertCountZero.selector, 0); + reverter.revertIf2Or3ExpectedRevertZero(x); + } + + function testExpectCountZeroThenAssumeFails(uint256 x) public { + _vm.expectRevert(Reverter.ExpectedRevertCountZero.selector, 0); + _vm.assumeNoRevert( + Vm.PotentialRevert({ + revertData: abi.encodeWithSelector(Reverter.MyRevert.selector), + partialMatch: false, + reverter: address(0) + }) + ); + reverter.revertIf2Or3ExpectedRevertZero(x); + } + +}"#, + ); + cmd.args(["test", "--mc", "ReverterTest"]).assert_failure().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 8 tests for src/AssumeNoRevertTest.t.sol:ReverterTest +[FAIL: call reverted with 'FOUNDRY::ASSUME' when it was expected not to revert; counterexample: [..] testAssumeThenExpectCountZeroFails(uint256) (runs: [..], [AVG_GAS]) +[FAIL: MyRevert(); counterexample: calldata=[..]] testAssumeWithReverter_fails(uint256) (runs: [..], [AVG_GAS]) +[FAIL: RevertWithData(2); counterexample: [..]] testAssume_wrongData_fails(uint256) (runs: [..], [AVG_GAS]) +[FAIL: MyRevert(); counterexample: [..]] testAssume_wrongSelector_fails(uint256) (runs: [..], [AVG_GAS]) +[FAIL: call reverted with 'FOUNDRY::ASSUME' when it was expected not to revert; counterexample: [..]] testExpectCountZeroThenAssumeFails(uint256) (runs: [..], [AVG_GAS]) +[FAIL: MyRevert(); counterexample: [..]] testMultipleAssumesClearAfterCall_fails(uint256) (runs: 0, [AVG_GAS]) +[FAIL: RevertWithData(3); counterexample: [..]] testMultipleAssumes_OneWrong_fails(uint256) (runs: [..], [AVG_GAS]) +[FAIL: vm.assumeNoRevert: you must make another external call prior to calling assumeNoRevert again; counterexample: [..]] testMultipleAssumes_ThrowOnGenericNoRevert_AfterSpecific_fails(bytes4) (runs: [..], [AVG_GAS]) +... + +"#]]); +} + +#[forgetest_init] +async fn flaky_can_get_broadcast_txs(prj: _, cmd: _) { + let (_api, handle) = spawn(NodeConfig::test().silent()).await; + + prj.insert_vm(); + prj.insert_ds_test(); + prj.insert_console(); + + prj.add_source( + "Counter.sol", + r#" + contract Counter { + uint256 public number; + + function setNumber(uint256 newNumber) public { + number = newNumber; + } + + function increment() public { + number++; + } +} + "#, + ); + + prj.add_script( + "DeployCounter", + r#" + import "forge-std/Script.sol"; + import "src/Counter.sol"; + + contract DeployCounter is Script { + function run() public { + vm.startBroadcast(); + + Counter counter = new Counter(); + + counter.increment(); + + counter.setNumber(10); + + vm.stopBroadcast(); + } + } + "#, + ); + + prj.add_script( + "DeployCounterWithCreate2", + r#" + import "forge-std/Script.sol"; + import "src/Counter.sol"; + + contract DeployCounterWithCreate2 is Script { + function run() public { + vm.startBroadcast(); + + bytes32 salt = bytes32(uint256(1337)); + Counter counter = new Counter{salt: salt}(); + + counter.increment(); + + counter.setNumber(20); + + vm.stopBroadcast(); + } + } + "#, + ); + + let test = r#" + import {Vm} from "../src/Vm.sol"; + import {DSTest} from "../src/test.sol"; + import {console} from "../src/console.sol"; + + contract GetBroadcastTest is DSTest { + Vm constant vm = Vm(HEVM_ADDRESS); + + function test_getLatestBroadcast() external { + // Gets the latest create + Vm.BroadcastTxSummary memory broadcast = vm.getBroadcast( + "Counter", + 31337, + Vm.BroadcastTxType.Create + ); + + console.log("latest create"); + console.log(broadcast.blockNumber); + + assertEq(broadcast.blockNumber, 1); + + // Gets the latest create2 + Vm.BroadcastTxSummary memory broadcast2 = vm.getBroadcast( + "Counter", + 31337, + Vm.BroadcastTxType.Create2 + ); + + console.log("latest create2"); + console.log(broadcast2.blockNumber); + assertEq(broadcast2.blockNumber, 4); + + // Gets the latest call + Vm.BroadcastTxSummary memory broadcast3 = vm.getBroadcast( + "Counter", + 31337, + Vm.BroadcastTxType.Call + ); + + console.log("latest call"); + assertEq(broadcast3.blockNumber, 6); + } + + function test_getBroadcasts() public { + // Gets all calls + Vm.BroadcastTxSummary[] memory broadcasts = vm.getBroadcasts( + "Counter", + 31337, + Vm.BroadcastTxType.Call + ); + + assertEq(broadcasts.length, 4); + } + + function test_getAllBroadcasts() public { + // Gets all broadcasts + Vm.BroadcastTxSummary[] memory broadcasts2 = vm.getBroadcasts( + "Counter", + 31337 + ); + + assertEq(broadcasts2.length, 6); + } + + function test_getLatestDeployment() public { + address deployedAddress = vm.getDeployment( + "Counter", + 31337 + ); + + assertGt(uint160(deployedAddress), 0); + } + + function test_getDeployments() public { + address[] memory deployments = vm.getDeployments( + "Counter", + 31337 + ); + + assertEq(deployments.length, 2); + // Verify valid addresses returned and they're different (CREATE vs CREATE2) + assertGt(uint160(deployments[0]), 0); + assertGt(uint160(deployments[1]), 0); + assertTrue(deployments[0] != deployments[1]); + } +} + "#; + + prj.add_test("GetBroadcast", test); + + let sender = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; + + cmd.args([ + "script", + "DeployCounter", + "--rpc-url", + &handle.http_endpoint(), + "--sender", + sender, + "--unlocked", + "--broadcast", + "--slow", + ]) + .assert_success(); + + cmd.forge_fuse() + .args([ + "script", + "DeployCounterWithCreate2", + "--rpc-url", + &handle.http_endpoint(), + "--sender", + sender, + "--unlocked", + "--broadcast", + "--slow", + ]) + .assert_success(); + + let broadcast_path = prj.root().join("broadcast"); + + // Check if the broadcast folder exists + assert!(broadcast_path.exists() && broadcast_path.is_dir()); + + cmd.forge_fuse().args(["test", "--mc", "GetBroadcastTest", "-vvv"]).assert_success().stdout_eq( + str![[r#" +... +Ran 5 tests for test/GetBroadcast.sol:GetBroadcastTest +[PASS] test_getAllBroadcasts() ([GAS]) +[PASS] test_getBroadcasts() ([GAS]) +[PASS] test_getDeployments() ([GAS]) +[PASS] test_getLatestBroadcast() ([GAS]) +Logs: + latest create + 1 + latest create2 + 4 + latest call + +[PASS] test_getLatestDeployment() ([GAS]) +Suite result: ok. 5 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]], + ); +} + +// Test that failed fork errors still surface the provider hostname. +#[forgetest_init] +fn test_display_provider_on_error(prj: _, cmd: _) { + prj.add_test( + "ForkTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract ForkTest is Test { + function test_fork_err_message() public { + vm.createSelectFork("https://eth-mainnet.g.alchemy.com/v2/DUMMY_KEY"); + } +} + "#, + ); + + cmd.args(["test", "--mt", "test_fork_err_message"]).assert_failure().stdout_eq(str![[r#" +... +Ran 1 test for test/ForkTest.t.sol:ForkTest +[FAIL: vm.createSelectFork: could not instantiate forked environment with provider eth-mainnet.g.alchemy.com; [..] + +... + +"#]]); +} + +// Tests that test traces display state changes when running with verbosity. +#[forgetest_init] +fn should_show_state_changes(prj: _, cmd: _) { + prj.initialize_default_contracts(); + cmd.args(["test", "--mt", "test_Increment", "-vvvvv", "--no-dynamic-test-linking"]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/Counter.t.sol:CounterTest +[PASS] test_Increment() ([GAS]) +Traces: + [218890] CounterTest::setUp() + ├─ [156801] → new Counter@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f + │ └─ ← [Return] 481 bytes of code + ├─ [23784] Counter::setNumber(0) + │ └─ ← [Stop] + └─ ← [Stop] + + [51847] CounterTest::test_Increment() + ├─ [43482] Counter::increment() + │ ├─ storage changes: + │ │ @ 0: 0 → 1 + │ └─ ← [Stop] + ├─ [2424] Counter::number() [staticcall] + │ └─ ← [Return] 1 + └─ ← [Stop] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +} + +#[forgetest_init] +fn tracing_verbosity_shows_state_changes_independently(prj: _, cmd: _) { + prj.initialize_default_contracts(); + prj.update_config(|config| { + config.verbosity = 0; + config.tracing.verbosity = 5; + config.tracing.labels.insert( + address!("0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f"), + "ConfiguredCounter".to_string(), + ); + }); + + cmd.args(["test", "--mt", "test_Increment", "--no-dynamic-test-linking"]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/Counter.t.sol:CounterTest +[PASS] test_Increment() ([GAS]) +Traces: + [218890] CounterTest::setUp() + ├─ [156801] → new ConfiguredCounter@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f + │ └─ ← [Return] 481 bytes of code + ├─ [23784] ConfiguredCounter::setNumber(0) + │ └─ ← [Stop] + └─ ← [Stop] + + [51847] CounterTest::test_Increment() + ├─ [43482] ConfiguredCounter::increment() + │ ├─ storage changes: + │ │ @ 0: 0 → 1 + │ └─ ← [Stop] + ├─ [2424] ConfiguredCounter::number() [staticcall] + │ └─ ← [Return] 1 + └─ ← [Stop] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +} + +// Tests that test traces display opcodes when verbosity level is 5 +#[forgetest_init] +fn should_show_opcodes(prj: _, cmd: _) { + prj.initialize_default_contracts(); + cmd.args([ + "test", + "--mt", + "test_Increment", + "-vvvvv", + "--opcodes", + "SLOAD,MLOAD", + "--no-dynamic-test-linking", + ]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/Counter.t.sol:CounterTest +[PASS] test_Increment() ([GAS]) +Traces: + [..] CounterTest::setUp() + ├─ [..] → new Counter@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f + │ └─ ← [Return] 481 bytes of code + ├─ [..] Counter::setNumber(0) + │ └─ ← [Stop] + └─ ← [Stop] + + [..] CounterTest::test_Increment() + ├─ [..] SLOAD 0x1f → (0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f01) + ├─ [..] MLOAD + ├─ [..] MLOAD + ├─ [..] Counter::increment() + │ ├─ [..] SLOAD 0x0 → (0x0) + │ ├─ storage changes: + │ │ @ 0: 0 → 1 + │ └─ ← [Stop] + ├─ [..] SLOAD + ├─ [..] MLOAD + ├─ [..] MLOAD + ├─ [..] Counter::number() [staticcall] + │ ├─ [..] SLOAD 0x0 → (0x1) + │ ├─ [..] MLOAD + │ ├─ [..] MLOAD + │ └─ ← [Return] 1 + ├─ [..] MLOAD + ├─ [..] MLOAD + └─ ← [Stop] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +} + +// Tests that --opcodes properly errors (fails early) with wrong opcode names +#[forgetest_init] +fn opcodes_invalid_name(prj: _, cmd: _) { + prj.initialize_default_contracts(); + cmd.args(["test", "--mt", "test_Increment", "-vvvvv", "--opcodes", "BOGUS"]) + .assert_failure() + .stderr_eq(str![[r#" +... +error: invalid value 'BOGUS' for '--opcodes ': invalid opcode: BOGUS + +For more information, try '--help'. + +"#]]); +} + +// Tests that --opcodes errors when not provided with -vvvvv +#[forgetest_init] +fn opcodes_not_enough_verbosity(prj: _, cmd: _) { + prj.initialize_default_contracts(); + cmd.args(["test", "--mt", "test_Increment", "-vvv", "--opcodes", "ADD"]) + .assert_failure() + .stderr_eq(str![[r#" +... +Error: Not enough verbosity. Use -vvvvv to show opcodes. + +"#]]); +} + +#[forgetest_init] +fn opcodes_conflict_with_non_trace_outputs(prj: _, cmd: _) { + prj.initialize_default_contracts(); + for flag in ["--json", "--junit", "--list", "--debug"] { + let output = + cmd.forge_fuse().args(["test", "-vvvvv", "--opcodes", "SLOAD", flag]).assert_failure(); + let stderr = String::from_utf8_lossy(&output.get_output().stderr); + assert!(stderr.contains("--opcodes"), "stderr should mention --opcodes: {stderr}"); + assert!(stderr.contains(flag), "stderr should mention {flag}: {stderr}"); + assert!(stderr.contains("cannot be used with"), "stderr should explain conflict: {stderr}"); + } +} + +// Tests that the test file path is not swallowed by the --opcodes flag +#[forgetest_init] +fn opcodes_path_after_flag(prj: _, cmd: _) { + prj.initialize_default_contracts(); + cmd.args([ + "test", + "--mt", + "test_Increment", + "-vvvvv", + "--opcodes", + "SSTORE", + "test/Counter.t.sol", + "--no-dynamic-test-linking", + ]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/Counter.t.sol:CounterTest +[PASS] test_Increment() ([GAS]) +Traces: + [..] CounterTest::setUp() + ├─ [..] → new Counter@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f + │ └─ ← [Return] 481 bytes of code + ├─ [..] Counter::setNumber(0) + │ └─ ← [Stop] + └─ ← [Stop] + + [..] CounterTest::test_Increment() + ├─ [..] Counter::increment() + │ ├─ [..] SSTORE 0x0: 0x0 → 0x1 + │ └─ ← [Stop] + ├─ [..] Counter::number() [staticcall] + │ └─ ← [Return] 1 + └─ ← [Stop] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +} + +// Tests that chained errors are properly displayed. +// +#[forgetest] +fn displays_chained_error(prj: _, cmd: _) { + prj.add_test( + "Foo.t.sol", + r#" +contract ContractTest { + function test_anything(uint) public {} +} + "#, + ); + + cmd.arg("test").arg("--gas-limit=100").assert_failure().stdout_eq(str![[r#" +... +Failing tests: +Encountered 1 failing test in test/Foo.t.sol:ContractTest +[FAIL: EVM error; transaction validation error: call [GAS_COST] exceeds the [GAS_LIMIT]] setUp() ([GAS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test +Tip: Run `forge test --debug --match-test ` to inspect one failing test in the debugger + +"#]]); +} + +// Tests that `start/stopAndReturn` debugTraceRecording does not panic when running with +// verbosity > 3. +#[forgetest_init] +fn should_not_panic_on_debug_trace_verbose(prj: _, cmd: _) { + prj.initialize_default_contracts(); + prj.add_test( + "DebugTraceRecordingTest.t.sol", + r#" +import "forge-std/Test.sol"; +import {Counter} from "../src/Counter.sol"; + +contract DebugTraceRecordingTest is Test { + function test_start_stop_recording() public { + vm.startDebugTraceRecording(); + Counter counter = new Counter(); + counter.increment(); + vm.stopAndReturnDebugTraceRecording(); + } +} + "#, + ); + + cmd.args(["test", "--mt", "test_start_stop_recording", "-vvvv"]).assert_success().stdout_eq( + str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/DebugTraceRecordingTest.t.sol:DebugTraceRecordingTest +[PASS] test_start_stop_recording() ([GAS]) +Traces: + [..] DebugTraceRecordingTest::test_start_stop_recording() +... + └─ ← [Stop] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]], + ); +} + +#[forgetest_init] +fn colored_traces(prj: _, cmd: _) { + prj.initialize_default_contracts(); + cmd.args([ + "test", + "--mt", + "test_Increment", + "--color", + "always", + "-vvvvv", + "--no-dynamic-test-linking", + ]) + .assert_success() + .stdout_eq(file!["../../fixtures/colored_traces.svg": TermSvg]); +} + +// Tests that traces for successful tests can be suppressed by using `-s` flag. +// +#[forgetest_init] +fn should_only_show_failed_tests_trace(prj: _, cmd: _) { + prj.initialize_default_contracts(); + prj.add_test( + "SuppressTracesTest.t.sol", + r#" +import "forge-std/Test.sol"; +import {Counter} from "../src/Counter.sol"; + +contract SuppressTracesTest is Test { + Counter public counter; + + function setUp() public { + counter = new Counter(); + counter.setNumber(0); + } + + function test_increment_success() public { + console.log("test increment success"); + counter.increment(); + assertEq(counter.number(), 1); + } + + function test_increment_failure() public { + console.log("test increment failure"); + counter.increment(); + assertEq(counter.number(), 100); + } +} + "#, + ); + + // Show traces and logs for failed test only. + cmd.args(["test", "--mc", "SuppressTracesTest", "-vvvvv", "-s", "--no-dynamic-test-linking"]) + .assert_failure() + .stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 2 tests for test/SuppressTracesTest.t.sol:SuppressTracesTest +[FAIL: assertion failed: 1 != 100] test_increment_failure() ([GAS]) +Logs: + test increment failure + +Traces: + [218890] SuppressTracesTest::setUp() + ├─ [156801] → new Counter@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f + │ └─ ← [Return] 481 bytes of code + ├─ [23784] Counter::setNumber(0) + │ └─ ← [Stop] + └─ ← [Stop] + + [58264] SuppressTracesTest::test_increment_failure() + ├─ [0] console::log("test increment failure") [staticcall] + │ └─ ← [Stop] + ├─ [43482] Counter::increment() + │ ├─ storage changes: + │ │ @ 0: 0 → 1 + │ └─ ← [Stop] + ├─ [2424] Counter::number() [staticcall] + │ └─ ← [Return] 1 + ├─ [0] VM::assertEq(1, 100) [staticcall] + │ └─ ← [Revert] assertion failed: 1 != 100 + └─ ← [Revert] assertion failed: 1 != 100 + +Backtrace: + at VM.assertEq + at SuppressTracesTest.test_increment_failure (lib/forge-std/src/StdAssertions.sol:[..]:[..]) + +[PASS] test_increment_success() ([GAS]) +Suite result: FAILED. 1 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 1 failed, 0 skipped (2 total tests) + +Failing tests: +Encountered 1 failing test in test/SuppressTracesTest.t.sol:SuppressTracesTest +[FAIL: assertion failed: 1 != 100] test_increment_failure() ([GAS]) + +Encountered a total of 1 failing tests, 1 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test +Tip: Run `forge test --debug --match-test ` to inspect one failing test in the debugger + +"#]]); + + // Show traces and logs for all tests. + cmd.forge_fuse() + .args(["test", "--mc", "SuppressTracesTest", "-vvvv", "--no-dynamic-test-linking"]) + .assert_failure() + .stdout_eq(str![[r#" +No files changed, compilation skipped + +Ran 2 tests for test/SuppressTracesTest.t.sol:SuppressTracesTest +[FAIL: assertion failed: 1 != 100] test_increment_failure() ([GAS]) +Logs: + test increment failure + +Traces: + [218890] SuppressTracesTest::setUp() + ├─ [156801] → new Counter@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f + │ └─ ← [Return] 481 bytes of code + ├─ [23784] Counter::setNumber(0) + │ └─ ← [Stop] + └─ ← [Stop] + + [58264] SuppressTracesTest::test_increment_failure() + ├─ [0] console::log("test increment failure") [staticcall] + │ └─ ← [Stop] + ├─ [43482] Counter::increment() + │ └─ ← [Stop] + ├─ [2424] Counter::number() [staticcall] + │ └─ ← [Return] 1 + ├─ [0] VM::assertEq(1, 100) [staticcall] + │ └─ ← [Revert] assertion failed: 1 != 100 + └─ ← [Revert] assertion failed: 1 != 100 + +Backtrace: + at VM.assertEq + at SuppressTracesTest.test_increment_failure + +[PASS] test_increment_success() ([GAS]) +Logs: + test increment success + +Traces: + [55228] SuppressTracesTest::test_increment_success() + ├─ [0] console::log("test increment success") [staticcall] + │ └─ ← [Stop] + ├─ [43482] Counter::increment() + │ └─ ← [Stop] + ├─ [2424] Counter::number() [staticcall] + │ └─ ← [Return] 1 + └─ ← [Stop] + +Suite result: FAILED. 1 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 1 failed, 0 skipped (2 total tests) + +Failing tests: +Encountered 1 failing test in test/SuppressTracesTest.t.sol:SuppressTracesTest +[FAIL: assertion failed: 1 != 100] test_increment_failure() ([GAS]) + +Encountered a total of 1 failing tests, 1 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test +Tip: Run `forge test --debug --match-test ` to inspect one failing test in the debugger + +"#]]); +} + +#[forgetest_init] +fn catch_test_deployment_failure(prj: _, cmd: _) { + prj.add_test( + "TestDeploymentFailure.t.sol", + r#" +import "forge-std/Test.sol"; +contract TestDeploymentFailure is Test { + + constructor() { + require(false); + } + + function setUp() public { + require(true); + } + + function test_something() public { + require(1 == 1); + } +} + "#, + ); + + cmd.args(["t", "--mt", "test_something"]).assert_failure().stdout_eq(str![[r#" +... +Failing tests: +Encountered 1 failing test in test/TestDeploymentFailure.t.sol:TestDeploymentFailure +[FAIL: EvmError: Revert] constructor() ([GAS]) +..."#]]); +} + +// +#[forgetest_init] +fn state_diff_recording_with_revert(prj: _, cmd: _) { + prj.add_test( + "TestStateDiffRevertFailure.t.sol", + r#" +import "forge-std/Test.sol"; +contract StateDiffRevertAtSameDepthTest is Test { + function test_something() public { + CounterTestA counter = new CounterTestA(); + counter.doSomething(); + } +} + +contract CounterTestA is Test { + function doSomething() public { + vm.startStateDiffRecording(); + require(1 > 2); + } +} + "#, + ); + + cmd.args(["t", "--mt", "test_something"]).assert_failure(); +} + +// +#[forgetest_init] +fn should_apply_pranks_per_recorded_depth(prj: _, cmd: _) { + prj.add_test( + "Counter.t.sol", + r#" +import "forge-std/Test.sol"; +contract CounterTest is Test { + function test_stackPrank() public { + address player = makeAddr("player"); + SenderLogger senderLogger = new SenderLogger(); + Contract c = new Contract(); + + senderLogger.log(); // Log(ContractTest, DefaultSender) + vm.startPrank(player, player); + senderLogger.log(); // Log(player, player) + c.f(); // vm.startPrank(player) + senderLogger.log(); // Log(ContractTest, player) <- ContractTest should be player + vm.stopPrank(); + } +} + +contract Contract { + Vm public constant vm = Vm(address(bytes20(uint160(uint256(keccak256("hevm cheat code")))))); + + function f() public { + vm.startPrank(msg.sender); + } +} + +contract SenderLogger { + event Log(address, address); + + function log() public { + emit Log(msg.sender, tx.origin); + } +} + "#, + ); + // Emits + // Log(: player: [], : player: []) instead + // Log(: ContractTest: [], : player: []) + cmd.args(["test", "--mt", "test_stackPrank", "-vvvv"]).assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/Counter.t.sol:CounterTest +[PASS] test_stackPrank() ([GAS]) +Traces: + [..] CounterTest::test_stackPrank() + ├─ [..] VM::addr() [staticcall] + │ └─ ← [Return] player: [0x44E97aF4418b7a17AABD8090bEA0A471a366305C] + ├─ [..] VM::label(player: [0x44E97aF4418b7a17AABD8090bEA0A471a366305C], "player") + │ └─ ← [Return] + ├─ [..] → new SenderLogger@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f + │ └─ ← [Return] 255 bytes of code + ├─ [..] → new Contract@0x2e234DAe75C793f67A35089C9d99245E1C58470b + │ └─ ← [Return] 542 bytes of code + ├─ [..] SenderLogger::log() + │ ├─ emit Log(: CounterTest: [0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496], : DefaultSender: [0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38]) + │ └─ ← [Stop] + ├─ [..] VM::startPrank(player: [0x44E97aF4418b7a17AABD8090bEA0A471a366305C], player: [0x44E97aF4418b7a17AABD8090bEA0A471a366305C]) + │ └─ ← [Return] + ├─ [..] SenderLogger::log() + │ ├─ emit Log(: player: [0x44E97aF4418b7a17AABD8090bEA0A471a366305C], : player: [0x44E97aF4418b7a17AABD8090bEA0A471a366305C]) + │ └─ ← [Stop] + ├─ [..] Contract::f() + │ ├─ [..] VM::startPrank(player: [0x44E97aF4418b7a17AABD8090bEA0A471a366305C]) + │ │ └─ ← [Return] + │ └─ ← [Stop] + ├─ [..] SenderLogger::log() + │ ├─ emit Log(: player: [0x44E97aF4418b7a17AABD8090bEA0A471a366305C], : player: [0x44E97aF4418b7a17AABD8090bEA0A471a366305C]) + │ └─ ← [Stop] + ├─ [..] VM::stopPrank() + │ └─ ← [Return] + └─ ← [Stop] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +} + +// +#[forgetest_init] +fn should_redact_pk_in_sign_delegation(prj: _, cmd: _) { + prj.add_test( + "Counter.t.sol", + r#" +import "forge-std/Test.sol"; +contract CounterTest is Test { + function testCheckDelegation() external { + (address alice, uint256 key) = makeAddrAndKey("alice"); + vm.signDelegation(address(0), key); + vm.signAndAttachDelegation(address(0), key); + } +} + "#, + ); + + cmd.args(["test", "--mt", "testCheckDelegation", "-vvvv"]).assert_success().stdout_eq(str![[r#" +... +Ran 1 test for test/Counter.t.sol:CounterTest +[PASS] testCheckDelegation() ([GAS]) +Traces: + [..] CounterTest::testCheckDelegation() + ├─ [0] VM::addr() [staticcall] + │ └─ ← [Return] alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6] + ├─ [0] VM::label(alice: [0x328809Bc894f92807417D2dAD6b7C998c1aFdac6], "alice") + │ └─ ← [Return] + ├─ [0] VM::signDelegation(0x0000000000000000000000000000000000000000, "") + │ └─ ← [Return] (0, 0x3d6ad67cc3dc94101a049f85f96937513a05485ae0f8b27545d25c4f71b12cf9, 0x3c0f2d62834f59d6ef0209e8a935f80a891a236eb18ac0e3700dd8f7ac8ae279, 0, 0x0000000000000000000000000000000000000000) + ├─ [0] VM::signAndAttachDelegation(0x0000000000000000000000000000000000000000, "") + │ └─ ← [Return] (0, 0x3d6ad67cc3dc94101a049f85f96937513a05485ae0f8b27545d25c4f71b12cf9, 0x3c0f2d62834f59d6ef0209e8a935f80a891a236eb18ac0e3700dd8f7ac8ae279, 0, 0x0000000000000000000000000000000000000000) + └─ ← [Stop] +... + +"#]]); +} + +// +#[forgetest_init] +fn flaky_can_upload_selectors_with_path(prj: _, cmd: _) { + prj.initialize_default_contracts(); + prj.add_source( + "CounterV1.sol", + r#" +contract Counter { + uint256 public number; + + function setNumberV1(uint256 newNumber) public { + number = newNumber; + } + + function incrementV1() public { + number++; + } +} + "#, + ); + + prj.add_source( + "CounterV2.sol", + r#" +contract CounterV2 { + uint256 public number; + + function setNumberV2(uint256 newNumber) public { + number = newNumber; + } + + function incrementV2() public { + number++; + } +} + "#, + ); + + // Upload Counter without path fails as there are multiple contracts with same name. + cmd.args(["selectors", "upload", "Counter"]).assert_failure().stderr_eq(str![[r#" +... +Error: Multiple contracts found with the name `Counter` +... + +"#]]); + + // Upload without contract name should fail. + cmd.forge_fuse().args(["selectors", "upload", "src/Counter.sol"]).assert_failure().stderr_eq( + str![[r#" +... +Error: No contract name provided. +... + +"#]], + ); + + // Upload single CounterV2. + cmd.forge_fuse() + .args(["selectors", "upload", "CounterV2"]) + .assert_success() + .stdout_eq(str![[r#" +... +Selectors successfully uploaded to OpenChain +... + +"#]]) + .stderr_eq(str![[r#" +Uploading selectors for CounterV2... + +"#]]); + + // Upload CounterV1 with path. + cmd.forge_fuse() + .args(["selectors", "upload", "src/CounterV1.sol:Counter"]) + .assert_success() + .stdout_eq(str![[r#" +... +Selectors successfully uploaded to OpenChain +... + +"#]]) + .stderr_eq(str![[r#" +Uploading selectors for Counter... + +"#]]); + + // Upload Counter with path. + cmd.forge_fuse() + .args(["selectors", "upload", "src/Counter.sol:Counter"]) + .assert_success() + .stdout_eq(str![[r#" +... +Selectors successfully uploaded to OpenChain +... + +"#]]) + .stderr_eq(str![[r#" +Uploading selectors for Counter... + +"#]]); +} + +#[forgetest_init] +fn selectors_collision_does_not_write_artifacts(prj: _, cmd: _) { + prj.add_source( + "First.sol", + r" +contract First { + function burn(uint256 value) public pure { + value; + } +} + ", + ); + prj.add_source( + "Second.sol", + r" +contract Second { + function collate_propagate_storage(bytes16 value) public pure { + value; + } +} + ", + ); + + let first_artifact = prj.paths().artifacts.join("First.sol/First.json"); + let second_artifact = prj.paths().artifacts.join("Second.sol/Second.json"); + + let output = cmd.args(["selectors", "collision", "First", "Second"]).assert_success(); + let stdout = output.get_output().stdout_lossy(); + + assert!(stdout.contains("1 collisions found:"), "unexpected stdout:\n{stdout}"); + assert!(stdout.contains("42966c68"), "unexpected stdout:\n{stdout}"); + assert!(stdout.contains("burn(uint256)"), "unexpected stdout:\n{stdout}"); + assert!(stdout.contains("collate_propagate_storage(bytes16)"), "unexpected stdout:\n{stdout}"); + assert!(!first_artifact.exists()); + assert!(!second_artifact.exists()); +} + +#[forgetest_init] +fn selectors_list_does_not_write_artifacts(prj: _, cmd: _) { + prj.add_source( + "Counter.sol", + r" +contract Counter { + uint256 public number; + + function setNumber(uint256 newNumber) public { + number = newNumber; + } +} + ", + ); + prj.add_source("Broken.sol", "contract Broken { function broken() public { missing(); } }"); + + let artifact = prj.paths().artifacts.join("Counter.sol/Counter.json"); + let output = cmd.args(["selectors", "list", "Counter"]).assert_success(); + let stdout = output.get_output().stdout_lossy(); + + assert!(stdout.contains("Counter"), "unexpected stdout:\n{stdout}"); + assert!(stdout.contains("setNumber(uint256)"), "unexpected stdout:\n{stdout}"); + assert!(!artifact.exists()); +} + +#[forgetest_init] +fn selectors_list_cmd(prj: _, cmd: _) { + prj.add_source( + "Counter.sol", + r" +contract Counter { + uint256 public number; + event Incremented(uint256 newNumber); + error IncrementError(); + + function setNumber(uint256 newNumber) public { + number = newNumber; + } + + function increment() public { + number++; + } +} + ", + ); + + prj.add_source( + "CounterV2.sol", + r" +contract CounterV2 { + uint256 public number; + + function setNumberV2(uint256 newNumber) public { + number = newNumber; + } + + function incrementV2() public { + number++; + } +} + ", + ); + + cmd.args(["selectors", "list"]) + .assert_success() + .stdout_eq(str![[r#" +Counter + +╭----------+----------------------+--------------------------------------------------------------------╮ +| Type | Signature | Selector | ++======================================================================================================+ +| Function | increment() | 0xd09de08a | +|----------+----------------------+--------------------------------------------------------------------| +| Function | number() | 0x8381f58a | +|----------+----------------------+--------------------------------------------------------------------| +| Function | setNumber(uint256) | 0x3fb5c1cb | +|----------+----------------------+--------------------------------------------------------------------| +| Event | Incremented(uint256) | 0x20d8a6f5a693f9d1d627a598e8820f7a55ee74c183aa8f1a30e8d4e8dd9a8d84 | +|----------+----------------------+--------------------------------------------------------------------| +| Error | IncrementError() | 0x46544c04 | +╰----------+----------------------+--------------------------------------------------------------------╯ + +CounterV2 + +╭----------+----------------------+------------╮ +| Type | Signature | Selector | ++==============================================+ +| Function | incrementV2() | 0x49365a69 | +|----------+----------------------+------------| +| Function | number() | 0x8381f58a | +|----------+----------------------+------------| +| Function | setNumberV2(uint256) | 0xb525b68c | +╰----------+----------------------+------------╯ + +"#]]) + .stderr_eq(str![[r#" +Listing selectors for contracts in the project... + +"#]]); + + cmd.forge_fuse() + .args(["selectors", "list", "--no-group"]) + .assert_success() + .stdout_eq(str![[r#" + +╭----------+----------------------+--------------------------------------------------------------------+-----------╮ +| Type | Signature | Selector | Contract | ++==================================================================================================================+ +| Function | increment() | 0xd09de08a | Counter | +|----------+----------------------+--------------------------------------------------------------------+-----------| +| Function | number() | 0x8381f58a | Counter | +|----------+----------------------+--------------------------------------------------------------------+-----------| +| Function | setNumber(uint256) | 0x3fb5c1cb | Counter | +|----------+----------------------+--------------------------------------------------------------------+-----------| +| Event | Incremented(uint256) | 0x20d8a6f5a693f9d1d627a598e8820f7a55ee74c183aa8f1a30e8d4e8dd9a8d84 | Counter | +|----------+----------------------+--------------------------------------------------------------------+-----------| +| Error | IncrementError() | 0x46544c04 | Counter | +|----------+----------------------+--------------------------------------------------------------------+-----------| +| Function | incrementV2() | 0x49365a69 | CounterV2 | +|----------+----------------------+--------------------------------------------------------------------+-----------| +| Function | number() | 0x8381f58a | CounterV2 | +|----------+----------------------+--------------------------------------------------------------------+-----------| +| Function | setNumberV2(uint256) | 0xb525b68c | CounterV2 | +╰----------+----------------------+--------------------------------------------------------------------+-----------╯ + +"#]]) + .stderr_eq(str![[r#" +Listing selectors for contracts in the project... + +"#]]); +} + +#[forgetest_init] +fn selectors_list_cmd_md(prj: _, cmd: _) { + prj.add_source( + "Counter.sol", + r" +contract Counter { + uint256 public number; + event Incremented(uint256 newNumber); + error IncrementError(); + + function setNumber(uint256 newNumber) public { + number = newNumber; + } + + function increment() public { + number++; + } +} + ", + ); + + prj.add_source( + "CounterV2.sol", + r" +contract CounterV2 { + uint256 public number; + + function setNumberV2(uint256 newNumber) public { + number = newNumber; + } + + function incrementV2() public { + number++; + } +} + ", + ); + + cmd.args(["selectors", "list", "--md"]) + .assert_success() + .stdout_eq(str![[r#" +Counter + +| Type | Signature | Selector | +|----------|----------------------|--------------------------------------------------------------------| +| Function | increment() | 0xd09de08a | +| Function | number() | 0x8381f58a | +| Function | setNumber(uint256) | 0x3fb5c1cb | +| Event | Incremented(uint256) | 0x20d8a6f5a693f9d1d627a598e8820f7a55ee74c183aa8f1a30e8d4e8dd9a8d84 | +| Error | IncrementError() | 0x46544c04 | + +CounterV2 + +| Type | Signature | Selector | +|----------|----------------------|------------| +| Function | incrementV2() | 0x49365a69 | +| Function | number() | 0x8381f58a | +| Function | setNumberV2(uint256) | 0xb525b68c | + +"#]]) + .stderr_eq(str![[r#" +Listing selectors for contracts in the project... + +"#]]); + + cmd.forge_fuse() + .args(["selectors", "list", "--no-group", "--md"]) + .assert_success() + .stdout_eq(str![[r#" + +| Type | Signature | Selector | Contract | +|----------|----------------------|--------------------------------------------------------------------|-----------| +| Function | increment() | 0xd09de08a | Counter | +| Function | number() | 0x8381f58a | Counter | +| Function | setNumber(uint256) | 0x3fb5c1cb | Counter | +| Event | Incremented(uint256) | 0x20d8a6f5a693f9d1d627a598e8820f7a55ee74c183aa8f1a30e8d4e8dd9a8d84 | Counter | +| Error | IncrementError() | 0x46544c04 | Counter | +| Function | incrementV2() | 0x49365a69 | CounterV2 | +| Function | number() | 0x8381f58a | CounterV2 | +| Function | setNumberV2(uint256) | 0xb525b68c | CounterV2 | + +"#]]) + .stderr_eq(str![[r#" +Listing selectors for contracts in the project... + +"#]]); +} + +// tests `interceptInitcode` function +#[forgetest_init] +fn intercept_initcode(prj: _, cmd: _) { + prj.insert_ds_test(); + prj.insert_vm(); + prj.clear(); + + prj.add_source( + "InterceptInitcode.t.sol", + r#" +import {Vm} from "./Vm.sol"; +import {DSTest} from "./test.sol"; + +contract SimpleContract { + uint256 public value; + constructor(uint256 _value) { + value = _value; + } +} + +contract InterceptInitcodeTest is DSTest { + Vm vm = Vm(HEVM_ADDRESS); + + function testInterceptRegularCreate() public { + // Set up interception + vm.interceptInitcode(); + + // Try to create a contract - this should revert with the initcode + bytes memory initcode; + try new SimpleContract(42) { + assert(false); + } catch (bytes memory interceptedInitcode) { + initcode = interceptedInitcode; + } + + // Verify the initcode contains the constructor argument + assertTrue(initcode.length > 0, "initcode should not be empty"); + + // The constructor argument is encoded as a 32-byte value at the end of the initcode + // We need to convert the last 32 bytes to uint256 + uint256 value; + assembly { + value := mload(add(add(initcode, 0x20), sub(mload(initcode), 32))) + } + assertEq(value, 42, "initcode should contain constructor arg"); + } + + function testInterceptCreate2() public { + // Set up interception + vm.interceptInitcode(); + + // Try to create a contract with CREATE2 - this should revert with the initcode + bytes memory initcode; + try new SimpleContract(1337) { + assert(false); + } catch (bytes memory interceptedInitcode) { + initcode = interceptedInitcode; + } + + // Verify the initcode contains the constructor argument + assertTrue(initcode.length > 0, "initcode should not be empty"); + + // The constructor argument is encoded as a 32-byte value at the end of the initcode + uint256 value; + assembly { + value := mload(add(add(initcode, 0x20), sub(mload(initcode), 32))) + } + assertEq(value, 1337, "initcode should contain constructor arg"); + } + + function testInterceptMultiple() public { + // First interception + vm.interceptInitcode(); + bytes memory initcode1; + try new SimpleContract(1) { + assert(false); + } catch (bytes memory interceptedInitcode) { + initcode1 = interceptedInitcode; + } + + // Second interception + vm.interceptInitcode(); + bytes memory initcode2; + try new SimpleContract(2) { + assert(false); + } catch (bytes memory interceptedInitcode) { + initcode2 = interceptedInitcode; + } + + // Verify different initcodes + assertTrue(initcode1.length > 0, "first initcode should not be empty"); + assertTrue(initcode2.length > 0, "second initcode should not be empty"); + + // Extract constructor arguments from both initcodes + uint256 value1; + uint256 value2; + assembly { + value1 := mload(add(add(initcode1, 0x20), sub(mload(initcode1), 32))) + value2 := mload(add(add(initcode2, 0x20), sub(mload(initcode2), 32))) + } + assertEq(value1, 1, "first initcode should contain first arg"); + assertEq(value2, 2, "second initcode should contain second arg"); + } +} + "#, + ); + cmd.args(["test", "-vvvvv"]).assert_success(); +} + +// +// +#[forgetest_init] +fn should_preserve_fork_state_setup(prj: _, cmd: _) { + prj.add_test( + "Counter.t.sol", + &r#" +import "forge-std/Test.sol"; +import {StdChains} from "forge-std/StdChains.sol"; + +contract CounterTest is Test { + struct Domain { + StdChains.Chain chain; + uint256 forkId; + } + + struct Bridge { + Domain source; + Domain destination; + uint256 someVal; + } + + struct SomeStruct { + Domain domain; + Bridge[] bridges; + } + + mapping(uint256 => SomeStruct) internal data; + + function setUp() public { + // Temporary workaround for `https://eth.llamarpc.com/` being down + setChain("mainnet", ChainData({ + name: "mainnet", + rpcUrl: "", + chainId: 1 + })); + + StdChains.Chain memory chain1 = getChain("mainnet"); + StdChains.Chain memory chain2 = getChain("base"); + Domain memory domain1 = Domain(chain1, vm.createFork(chain1.rpcUrl, 22253716)); + Domain memory domain2 = Domain(chain2, vm.createFork(chain2.rpcUrl, 28839981)); + data[1].domain = domain1; + data[2].domain = domain2; + + vm.selectFork(domain1.forkId); + + data[2].bridges.push(Bridge(domain1, domain2, 123)); + vm.selectFork(data[2].domain.forkId); + vm.selectFork(data[1].domain.forkId); + data[2].bridges.push(Bridge(domain1, domain2, 456)); + + assertEq(data[2].bridges.length, 2); + } + + function test_assert_storage() public { + vm.selectFork(data[2].domain.forkId); + assertEq(data[2].bridges.length, 2); + } + + function test_modify_and_storage() public { + data[3].domain = Domain(getChain("base"), vm.createFork(getChain("base").rpcUrl, 28839981)); + data[3].bridges.push(Bridge(data[1].domain, data[2].domain, 123)); + data[3].bridges.push(Bridge(data[1].domain, data[2].domain, 456)); + + vm.selectFork(data[2].domain.forkId); + assertEq(data[3].bridges.length, 2); + } +} + "# + .replace("", &rpc::next_http_archive_rpc_url()), + ); + + cmd.args(["test", "--mc", "CounterTest"]).assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 2 tests for test/Counter.t.sol:CounterTest +[PASS] test_assert_storage() ([GAS]) +[PASS] test_modify_and_storage() ([GAS]) +Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 2 tests passed, 0 failed, 0 skipped (2 total tests) + +"#]]); +} + +// +#[forgetest_init] +fn flaky_should_not_panic_on_cool(prj: _, cmd: _) { + prj.initialize_default_contracts(); + prj.add_test( + "Counter.t.sol", + r#" +import "forge-std/Test.sol"; +import {Counter} from "../src/Counter.sol"; + +contract CounterTest is Test { + Counter counter = new Counter(); + + function testCoolPanic() public { + address alice = makeAddr("alice"); + vm.deal(alice, 10000 ether); + counter.setNumber(1); + vm.cool(address(counter)); + vm.prank(alice); + payable(address(counter)).transfer(1 ether); + } +} + "#, + ); + + cmd.args(["test", "--mc", "CounterTest"]).assert_failure().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/Counter.t.sol:CounterTest +[FAIL: EvmError: Revert] testCoolPanic() ([GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/Counter.t.sol:CounterTest +[FAIL: EvmError: Revert] testCoolPanic() ([GAS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test +Tip: Run `forge test --debug --match-test ` to inspect one failing test in the debugger + +"#]]); +} + +#[forgetest_init] +fn detailed_revert_when_calling_non_contract_address(prj: _, cmd: _) { + prj.initialize_default_contracts(); + prj.add_test( + "NonContractCallRevertTest.t.sol", + r#" +import "forge-std/Test.sol"; +import {Counter} from "../src/Counter.sol"; + +interface ICounter { + function increment() external; + function number() external returns (uint256); + function random() external returns (uint256); +} + +contract NonContractCallRevertTest is Test { + Counter public counter; + address constant ADDRESS = 0xdEADBEeF00000000000000000000000000000000; + + function setUp() public { + counter = new Counter(); + counter.setNumber(1); + } + + function test_non_supported_selector_call_failure() public { + console.log("test non supported fn selector call failure"); + ICounter(address(counter)).random(); + } + + function test_non_contract_call_failure() public { + console.log("test non contract call failure"); + ICounter(ADDRESS).number(); + } + + function test_non_contract_void_call_failure() public { + console.log("test non contract (void) call failure"); + ICounter(ADDRESS).increment(); + } + + function test_revert_data_exact_diagnostic() public { + vm.expectRevert( + abi.encodePacked("call to non-contract address ", vm.toString(ADDRESS)) + ); + this.call_non_contract(ADDRESS); + } + + function call_non_contract(address target) external { + ICounter(target).number(); + } +} + "#, + ); + + cmd.args([ + "test", + "--mc", + "NonContractCallRevertTest", + "--no-match-test", + "test_revert_data_", + "-vvvvv", + "--no-dynamic-test-linking", + ]) + .assert_failure() + .stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 3 tests for test/NonContractCallRevertTest.t.sol:NonContractCallRevertTest +[FAIL: EvmError: Revert] test_non_contract_call_failure() ([GAS]) +Logs: + test non contract call failure + +Traces: + [238803] NonContractCallRevertTest::setUp() + ├─ [156801] → new Counter@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f + │ └─ ← [Return] 481 bytes of code + ├─ [43696] Counter::setNumber(1) + │ └─ ← [Stop] + └─ ← [Stop] + + [27488] NonContractCallRevertTest::test_non_contract_call_failure() + ├─ [0] console::log("test non contract call failure") [staticcall] + │ └─ ← [Stop] + ├─ [21160] 0xdEADBEeF00000000000000000000000000000000::number() + │ └─ ← [Stop] + └─ ← [Revert] call to non-contract address 0xdEADBEeF00000000000000000000000000000000 + +Backtrace: + at NonContractCallRevertTest.test_non_contract_call_failure + +[FAIL: EvmError: Revert] test_non_contract_void_call_failure() ([GAS]) +Logs: + test non contract (void) call failure + +Traces: + [238803] NonContractCallRevertTest::setUp() + ├─ [156801] → new Counter@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f + │ └─ ← [Return] 481 bytes of code + ├─ [43696] Counter::setNumber(1) + │ └─ ← [Stop] + └─ ← [Stop] + + [6303] NonContractCallRevertTest::test_non_contract_void_call_failure() + ├─ [0] console::log("test non contract (void) call failure") [staticcall] + │ └─ ← [Stop] + └─ ← [Revert] call to non-contract address 0xdEADBEeF00000000000000000000000000000000 + +Backtrace: + at NonContractCallRevertTest.test_non_contract_void_call_failure (test/NonContractCallRevertTest.t.sol:[..]:[..]) + +[FAIL: EvmError: Revert] test_non_supported_selector_call_failure() ([GAS]) +Logs: + test non supported fn selector call failure + +Traces: + [238803] NonContractCallRevertTest::setUp() + ├─ [156801] → new Counter@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f + │ └─ ← [Return] 481 bytes of code + ├─ [43696] Counter::setNumber(1) + │ └─ ← [Stop] + └─ ← [Stop] + + [29684] NonContractCallRevertTest::test_non_supported_selector_call_failure() + ├─ [0] console::log("test non supported fn selector call failure") [staticcall] + │ └─ ← [Stop] + ├─ [21209] Counter::random() + │ └─ ← [Revert] unrecognized function selector 0x5ec01e4d for contract 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f, which has no fallback function. + └─ ← [Revert] EvmError: Revert + +Backtrace: + at Counter.random (src/Counter.sol:[..]:[..]) + at NonContractCallRevertTest.test_non_supported_selector_call_failure (test/NonContractCallRevertTest.t.sol:[..]:[..]) + +Suite result: FAILED. 0 passed; 3 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 3 failed, 0 skipped (3 total tests) + +Failing tests: +Encountered 3 failing tests in test/NonContractCallRevertTest.t.sol:NonContractCallRevertTest +[FAIL: EvmError: Revert] test_non_contract_call_failure() ([GAS]) +[FAIL: EvmError: Revert] test_non_contract_void_call_failure() ([GAS]) +[FAIL: EvmError: Revert] test_non_supported_selector_call_failure() ([GAS]) + +Encountered a total of 3 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 3 failed tests +Tip: Run `forge test --debug --match-test ` to inspect one failing test in the debugger + +"#]]); + + cmd.forge_fuse() + .args([ + "test", + "--mc", + "NonContractCallRevertTest", + "--mt", + "test_revert_data_exact_diagnostic", + "-vv", + "--no-dynamic-test-linking", + ]) + .assert_failure() + .stdout_eq(str![[r#" +No files changed, compilation skipped + +Ran 1 test for test/NonContractCallRevertTest.t.sol:NonContractCallRevertTest +[FAIL: call reverted as expected, but without data] test_revert_data_exact_diagnostic() ([GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/NonContractCallRevertTest.t.sol:NonContractCallRevertTest +[FAIL: call reverted as expected, but without data] test_revert_data_exact_diagnostic() ([GAS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test +Tip: Run `forge test --debug --match-test ` to inspect one failing test in the debugger + +"#]]); + + cmd.forge_fuse() + .args([ + "test", + "--mc", + "NonContractCallRevertTest", + "--mt", + "test_revert_data_exact_diagnostic", + "-vvvv", + "--no-dynamic-test-linking", + ]) + .assert_failure() + .stdout_eq(str![[r#" +No files changed, compilation skipped + +Ran 1 test for test/NonContractCallRevertTest.t.sol:NonContractCallRevertTest +[FAIL: call reverted as expected, but without data] test_revert_data_exact_diagnostic() ([GAS]) +Traces: + [238803] NonContractCallRevertTest::setUp() + ├─ [156801] → new Counter@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f + │ └─ ← [Return] 481 bytes of code + ├─ [43696] Counter::setNumber(1) + │ └─ ← [Stop] + └─ ← [Stop] + + [30284] NonContractCallRevertTest::test_revert_data_exact_diagnostic() + ├─ [0] VM::toString(0xdEADBEeF00000000000000000000000000000000) [staticcall] + │ └─ ← [Return] "0xdEADBEeF00000000000000000000000000000000" + ├─ [0] VM::expectRevert(call to non-contract address 0xdEADBEeF00000000000000000000000000000000) + │ └─ ← [Return] + ├─ [24558] NonContractCallRevertTest::call_non_contract(0xdEADBEeF00000000000000000000000000000000) + │ ├─ [0] 0xdEADBEeF00000000000000000000000000000000::number() + │ │ └─ ← [Stop] + │ └─ ← [Revert] call to non-contract address 0xdEADBEeF00000000000000000000000000000000 + └─ ← [Revert] call reverted as expected, but without data + +Backtrace: + at NonContractCallRevertTest.call_non_contract + at NonContractCallRevertTest.test_revert_data_exact_diagnostic + +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/NonContractCallRevertTest.t.sol:NonContractCallRevertTest +[FAIL: call reverted as expected, but without data] test_revert_data_exact_diagnostic() ([GAS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test +Tip: Run `forge test --debug --match-test ` to inspect one failing test in the debugger + +"#]]); + + let output = cmd + .forge_fuse() + .args([ + "test", + "--mc", + "NonContractCallRevertTest", + "--mt", + "test_revert_data_exact_diagnostic", + "-vvvv", + "--json", + "--no-dynamic-test-linking", + ]) + .assert_failure(); + let json: serde_json::Value = serde_json::from_slice(&output.get_output().stdout).unwrap(); + let result = &json.as_object().unwrap().values().next().unwrap()["test_results"]["test_revert_data_exact_diagnostic()"]; + let traces = result["traces"].as_array().unwrap(); + assert!(traces.iter().all(|trace| trace[1].get("diagnostics").is_none())); + + let diagnostic_nodes = traces + .iter() + .flat_map(|trace| trace[1]["arena"].as_array().unwrap()) + .filter(|node| { + node["trace"]["decoded"]["return_data"] + == "call to non-contract address 0xdEADBEeF00000000000000000000000000000000" + }) + .collect::>(); + assert_eq!(diagnostic_nodes.len(), 1); + assert_eq!(diagnostic_nodes[0]["trace"]["output"], "0x"); +} + +#[forgetest_init] +fn detailed_revert_when_delegatecalling_unlinked_library(prj: _, cmd: _) { + prj.add_test( + "NonContractDelegateCallRevertTest.t.sol", + r#" +import "forge-std/Test.sol"; + +library TestLibrary { + function foo(uint256 a) public pure returns (uint256) { + return a * 2; + } +} + +contract LibraryCaller { + address public lib; + + constructor(address _lib) { + lib = _lib; + } + + function foobar(uint256 val) public returns (uint256) { + (bool success, bytes memory data) = lib.delegatecall( + abi.encodeWithSelector(TestLibrary.foo.selector, val) + ); + + assert(success); + return abi.decode(data, (uint256)); + } +} + +contract NonContractDelegateCallRevertTest is Test { + function test_unlinked_library_call_failure() public { + console.log("Test: Simulating call to unlinked library"); + LibraryCaller caller = new LibraryCaller(0xdEADBEeF00000000000000000000000000000000); + + caller.foobar(10); + } +} + "#, + ); + + cmd.args(["test", "--mc", "NonContractDelegateCallRevertTest", "-vvvvv"]) + .assert_failure() + .stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/NonContractDelegateCallRevertTest.t.sol:NonContractDelegateCallRevertTest +[FAIL: EvmError: Revert] test_unlinked_library_call_failure() ([GAS]) +Logs: + Test: Simulating call to unlinked library + +Traces: + [350661] NonContractDelegateCallRevertTest::test_unlinked_library_call_failure() + ├─ [0] console::log("Test: Simulating call to unlinked library") [staticcall] + │ └─ ← [Stop] + ├─ [286918] → new LibraryCaller@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f + │ ├─ storage changes: + │ │ @ 0: 0 → 0x000000000000000000000000deadbeef00000000000000000000000000000000 + │ └─ ← [Return] 960 bytes of code + ├─ [27100] LibraryCaller::foobar(10) + │ ├─ [0] 0xdEADBEeF00000000000000000000000000000000::foo(10) [delegatecall] + │ │ └─ ← [Stop] + │ └─ ← [Revert] delegatecall to non-contract address 0xdEADBEeF00000000000000000000000000000000 (usually an unliked library) + └─ ← [Revert] EvmError: Revert + +Backtrace: + at LibraryCaller.foobar + at NonContractDelegateCallRevertTest.test_unlinked_library_call_failure (test/NonContractDelegateCallRevertTest.t.sol:[..]:[..]) + +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/NonContractDelegateCallRevertTest.t.sol:NonContractDelegateCallRevertTest +[FAIL: EvmError: Revert] test_unlinked_library_call_failure() ([GAS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test +Tip: Run `forge test --debug --match-test ` to inspect one failing test in the debugger + +"#]]); +} + +// This test is a copy of `error_event_decode_with_cache` in cast/tests/cli/selectors.rs +// but it uses `forge build` to check that the project selectors are cached by default. +#[forgetest_init] +fn flaky_build_with_selectors_cache(prj: _, cmd: _) { + prj.initialize_default_contracts(); + prj.add_source( + "LocalProjectContract", + r#" +contract ContractWithCustomError { + error AnotherValueTooHigh(uint256, address); + event MyUniqueEventWithinLocalProject(uint256 a, address b); +} + "#, + ); + // Build and cache project selectors. + cmd.forge_fuse().args(["build", "--force"]).assert_success(); + + // Assert cast can decode custom error with local cache. + cmd.cast_fuse() + .args(["decode-error", "0x7191bc6200000000000000000000000000000000000000000000000000000000000000650000000000000000000000000000000000000000000000000000000000D0004F"]) + .assert_success() + .stdout_eq(str![[r#" +AnotherValueTooHigh(uint256,address) +101 +0x0000000000000000000000000000000000D0004F + +"#]]); + // Assert cast can decode event with local cache. + cmd.cast_fuse() + .args(["decode-event", "0xbd3699995dcc867b64dbb607be2c33be38df9134bef1178df13bfb9446e73104000000000000000000000000000000000000000000000000000000000000004e00000000000000000000000000000000000000000000000000000dd00000004e"]) + .assert_success() + .stdout_eq(str![[r#" +MyUniqueEventWithinLocalProject(uint256,address) +78 +0x00000000000000000000000000000DD00000004e + +"#]]); +} + +// +#[forgetest_init] +fn revm_27_prank_bug_fix(prj: _, cmd: _) { + prj.initialize_default_contracts(); + prj.add_test( + "PrankBug.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {Counter} from "../src/Counter.sol"; + +contract PrankTest is Test { + Counter public counter; + + function setUp() public { + vm.startPrank(address(0x123)); + counter = new Counter(); + vm.stopPrank(); + } + + function test_Increment() public { + vm.startPrank(address(0x123)); + counter = new Counter(); + vm.stopPrank(); + + counter.increment(); + assertEq(counter.number(), 1); + } +} +"#, + ); + + cmd.args(["test", "--mc", "PrankTest", "-vvvvv", "--no-dynamic-test-linking"]) + .assert_success() + .stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/PrankBug.t.sol:PrankTest +[PASS] test_Increment() ([GAS]) +Traces: + [..] PrankTest::setUp() + ├─ [0] VM::startPrank(0x0000000000000000000000000000000000000123) + │ └─ ← [Return] + ├─ [..] → new Counter@0x6cdBd1b486b8FBD4140e8cd6daAED05bE13eD914 + │ └─ ← [Return] 481 bytes of code + ├─ [0] VM::stopPrank() + │ └─ ← [Return] + └─ ← [Stop] + + [..] PrankTest::test_Increment() + ├─ [0] VM::startPrank(0x0000000000000000000000000000000000000123) + │ └─ ← [Return] + ├─ [..] → new Counter@0xc4B957Cd61beB9b9afD76204b30683EDAaaB51Ec + │ └─ ← [Return] 481 bytes of code + ├─ [0] VM::stopPrank() + │ └─ ← [Return] + ├─ [..] Counter::increment() + │ ├─ storage changes: + │ │ @ 0: 0 → 1 + │ └─ ← [Stop] + ├─ [..] Counter::number() [staticcall] + │ └─ ← [Return] 1 + ├─ storage changes: + │ @ 31: 0x00000000000000000000006cdbd1b486b8fbd4140e8cd6daaed05be13ed91401 → 0x0000000000000000000000c4b957cd61beb9b9afd76204b30683edaaab51ec01 + └─ ← [Stop] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +} + +// tests proper reverts in fork mode for contracts with non-existent linked libraries. +// +#[forgetest_init] +fn can_fork_test_with_non_existent_linked_library(prj: _, cmd: _) { + prj.update_config(|config| { + config.libraries = + vec!["src/Counter.sol:LibCounter:0x530008d2b058137d9c475b1b7d83984f1fcf1dd0".into()]; + }); + prj.add_source( + "Counter.sol", + r" +library LibCounter { + function dummy() external pure returns (uint) { + return 1; + } +} + +contract Counter { + uint256 public number; + + constructor() { + LibCounter.dummy(); + } + + function setNumber(uint256 newNumber) public { + number = newNumber; + } + + function increment() public { + number++; + } + + function dummy() external pure returns (uint) { + return LibCounter.dummy(); + } +} + ", + ); + + let endpoint = rpc::next_http_archive_rpc_url(); + + prj.add_test( + "Counter.t.sol", + &r#" +import "forge-std/Test.sol"; +import "src/Counter.sol"; + +contract CounterTest is Test { + function test_select_fork() public { + vm.createSelectFork(""); + new Counter(); + } + + function test_roll_fork() public { + vm.rollFork(block.number - 100); + new Counter(); + } +} + "# + .replace("", &endpoint), + ); + + for dynamic_test_linking in [false, true] { + prj.update_config(|config| config.dynamic_test_linking = dynamic_test_linking); + cmd.forge_fuse() + .args(["test", "--force", "--fork-url", &endpoint]) + .assert_failure() + .stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 2 tests for test/Counter.t.sol:CounterTest +[FAIL: EvmError: Revert] test_roll_fork() (block: [..]) ([GAS]) +[FAIL: EvmError: Revert] test_select_fork() (block: [..]) ([GAS]) +Suite result: FAILED. 0 passed; 2 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 2 failed, 0 skipped (2 total tests) + +Failing tests: +Encountered 2 failing tests in test/Counter.t.sol:CounterTest +[FAIL: EvmError: Revert] test_roll_fork() (block: [..]) ([GAS]) +[FAIL: EvmError: Revert] test_select_fork() (block: [..]) ([GAS]) + +Encountered a total of 2 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 2 failed tests +Tip: Run `forge test --debug --match-test ` to inspect one failing test in the debugger + +"#]]); + } +} + +// Nonce-linked libraries must deploy at their linked addresses even if the fork reports a nonzero +// library deployer nonce. +#[forgetest] +async fn fork_test_ignores_library_deployer_nonce(prj: _, cmd: _) { + let (api, handle) = spawn(NodeConfig::test()).await; + api.anvil_set_nonce(LIBRARY_DEPLOYER, U256::ONE).await.unwrap(); + + prj.add_source( + "Lib.sol", + r" +library Lib { + function answer() external pure returns (uint256) { + return 42; + } +} +", + ); + prj.add_test( + "Lib.t.sol", + r#" +import {Lib} from "src/Lib.sol"; + +contract LibTest { + function testLinkedLibrary() public pure { + require(Lib.answer() == 42); + } +} +"#, + ); + + cmd.args([ + "test", + "--fork-url", + &handle.http_endpoint(), + "--create2-deployer", + &Address::ZERO.to_string(), + ]) + .assert_success() + .stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/Lib.t.sol:LibTest +[PASS] testLinkedLibrary() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +} + +// +#[forgetest_init] +fn invariant_consistent_output(prj: _, cmd: _) { + prj.update_config(|config| { + config.fuzz.seed = Some(U256::from(100u32)); + config.invariant.runs = 10; + config.invariant.depth = 100; + config.invariant.show_metrics = false; + }); + prj.add_test( + "InvariantOutputTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract InvariantOutputTest is Test { + uint256 count; + + function setCond(uint256 cond) public { + if (cond > type(uint256).max / 2) { + count++; + } + } + + function setUp() public { + targetContract(address(this)); + } + + function invariant_check_count() public view { + require(count < 2, "failed invariant"); + } +} + "#, + ); + + cmd.args(["test", "--mt", "invariant_check_count", "--color", "always"]) + .assert_failure() + .stdout_eq(file!["../../fixtures/invariant_traces.svg": TermSvg]); +} + +#[forgetest_init] +fn memory_limit(prj: _, cmd: _) { + prj.wipe_contracts(); + prj.update_config(|config| { + config.memory_limit = 500 * 32; + }); + prj.add_test( + "MemoryLimit.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract Memory { + function allocate(uint256 n) external pure returns (uint256[] memory) { + return new uint256[](n); + } +} + +contract MemoryLimitTest is Test { + Memory public m = new Memory(); + + function test_inBounds() public { + m.allocate(100); + } + + function test_oom() public { + m.allocate(1000); + } +} +"#, + ); + + cmd.arg("test").assert_failure().stdout_eq(str![[r#" +... +Ran 2 tests for test/MemoryLimit.t.sol:MemoryLimitTest +[PASS] test_inBounds() ([GAS]) +[FAIL: EvmError: Revert] test_oom() ([GAS]) +Suite result: FAILED. 1 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 1 failed, 0 skipped (2 total tests) + +Failing tests: +Encountered 1 failing test in test/MemoryLimit.t.sol:MemoryLimitTest +[FAIL: EvmError: Revert] test_oom() ([GAS]) + +Encountered a total of 1 failing tests, 1 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test +Tip: Run `forge test --debug --match-test ` to inspect one failing test in the debugger + +"#]]); +} + +#[forgetest_init] +fn zero_invariant_runs(prj: _, cmd: _) { + prj.wipe_contracts(); + prj.add_test( + "ZeroRuns.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract Handler is Test { + function doSomething(uint256 param) public { + revert("unreachable"); + } +} + +contract ZeroRuns is Test { + Handler handler = new Handler(); + + /// forge-config: default.invariant.runs = 0 + function invariant_zeroRuns() public {} + + /// forge-config: default.invariant.depth = 0 + function invariant_zeroDepth() public {} +} +"#, + ); + + cmd.args(["test"]).assert_success().stdout_eq(str![[r#" +... +Ran 2 tests for test/ZeroRuns.t.sol:ZeroRuns +[PASS] invariant_zeroDepth() (runs: 256, calls: 0, reverts: 0) +[PASS] invariant_zeroRuns() (runs: 0, calls: 0, reverts: 0) +Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 2 tests passed, 0 failed, 0 skipped (2 total tests) + +"#]]); +} + +// +#[forgetest] +async fn fork_eth_get_proof(prj: _, cmd: _) { + let (api, handle) = spawn(NodeConfig::test()).await; + let target = Address::repeat_byte(0x42); + let slot = B256::with_last_byte(1); + + api.anvil_set_code(target, Bytes::from_static(&[0x00])).await.unwrap(); + api.anvil_set_balance(target, U256::from(1337)).await.unwrap(); + api.anvil_set_storage_at(target, slot.into(), B256::with_last_byte(0xaa)).await.unwrap(); + api.anvil_mine(Some(U256::ONE), None).await.unwrap(); + + let provider = handle.http_provider(); + let historical_block = provider.get_block_number().await.unwrap(); + let historical_state_root = + provider.get_block(historical_block.into()).await.unwrap().unwrap().header.state_root; + + api.anvil_mine(Some(U256::ONE), None).await.unwrap(); + api.anvil_set_balance(target, U256::from(7331)).await.unwrap(); + api.anvil_set_storage_at(target, slot.into(), B256::with_last_byte(0xbb)).await.unwrap(); + api.anvil_mine(Some(U256::ONE), None).await.unwrap(); + let fork_block = provider.get_block_number().await.unwrap(); + + let (_, fork_handle) = spawn( + NodeConfig::test() + .with_eth_rpc_url(Some(handle.http_endpoint())) + .with_fork_block_number(Some(fork_block)), + ) + .await; + let endpoint = rpc::spawn_rpc_proxy_requiring_header( + fork_handle.http_endpoint(), + "authorization", + "Bearer secret", + ) + .await; + std::fs::write( + prj.config(), + format!( + r#"[rpc_endpoints] +authenticated = {{ endpoint = "{endpoint}", auth = "Bearer secret" }} +"# + ), + ) + .unwrap(); + + prj.add_test( + "EthGetProofFork.t.sol", + &format!( + r#" +interface Vm {{ + struct EthStorageProof {{ bytes32 key; uint256 value; bytes[] proof; }} + struct EthGetProof {{ address account; uint256 balance; bytes32 codeHash; uint64 nonce; bytes32 storageHash; bytes[] accountProof; EthStorageProof[] storageProof; }} + function createSelectFork(string calldata urlOrAlias) external returns (uint256 forkId); + function deal(address account, uint256 newBalance) external; + function eth_getProof(address target, bytes32[] calldata slots, uint256 blockNumber) external view returns (EthGetProof memory proof); + function store(address target, bytes32 slot, bytes32 value) external; +}} + +contract EthGetProofForkTest {{ + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + function testForkEthGetProof() public {{ + vm.createSelectFork("authenticated"); + address target = {target}; + bytes32[] memory slots = new bytes32[](2); + slots[0] = {slot}; + slots[1] = bytes32(uint256(2)); + vm.deal(target, 9999); + vm.store(target, slots[0], bytes32(uint256(0xcc))); + + Vm.EthGetProof memory proof = vm.eth_getProof(target, slots, {historical_block}); + + require(proof.account == target, "account"); + require(proof.balance == 1337, "balance"); + require(proof.codeHash == target.codehash, "code hash"); + require(proof.nonce == 0, "nonce"); + require(keccak256(proof.accountProof[0]) == {historical_state_root}, "account proof root"); + + require(proof.storageProof.length == 2, "storage proof length"); + require(proof.storageProof[0].key == slots[0], "first key"); + require(proof.storageProof[0].value == 0xaa, "first value"); + require(keccak256(proof.storageProof[0].proof[0]) == proof.storageHash, "storage proof root"); + require(proof.storageProof[1].key == slots[1], "second key"); + require(proof.storageProof[1].value == 0, "second value"); + + proof = vm.eth_getProof(target, slots, {fork_block}); + require(proof.balance == 7331, "current balance"); + require(proof.storageProof[0].value == 0xbb, "current storage"); + }} + + function testForkEthGetProofUnknownBlock() public {{ + vm.createSelectFork("authenticated"); + try vm.eth_getProof({target}, new bytes32[](0), {fork_block} + 100) {{ + revert("expected an unknown block to fail"); + }} catch {{}} + }} +}} +"# + ), + ); + + cmd.args(["test", "--match-test", "testForkEthGetProof"]).assert_success().stdout_eq(str![[ + r#" +... +Ran 2 tests for test/EthGetProofFork.t.sol:EthGetProofForkTest +[PASS] testForkEthGetProof() ([GAS]) +[PASS] testForkEthGetProofUnknownBlock() ([GAS]) +Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 2 tests passed, 0 failed, 0 skipped (2 total tests) + +"# + ]]); +} + +// `eth_getLogs` and `getRawBlockHeader` must reach the fork with its configured auth. +#[forgetest] +async fn fork_eth_get_logs_and_raw_block_header_with_auth(prj: _, cmd: _) { + let (api, handle) = spawn(NodeConfig::test()).await; + let target = Address::repeat_byte(0x42); + let topic = B256::repeat_byte(0x11); + + // PUSH32 topic, PUSH1 0, PUSH1 0, LOG1, STOP + let code = [&[0x7f][..], topic.as_slice(), &[0x60, 0x00, 0x60, 0x00, 0xa1, 0x00]].concat(); + api.anvil_set_code(target, Bytes::from(code)).await.unwrap(); + + let provider = handle.http_provider(); + let from = handle.dev_accounts().next().unwrap(); + let tx_hash: B256 = provider + .raw_request( + "eth_sendTransaction".into(), + (serde_json::json!({ "from": from, "to": target }),), + ) + .await + .unwrap(); + api.anvil_mine(Some(U256::ONE), None).await.unwrap(); + let block = + provider.get_transaction_receipt(tx_hash).await.unwrap().unwrap().block_number.unwrap(); + let block_hash = provider.get_block(block.into()).await.unwrap().unwrap().header.hash; + + let endpoint = rpc::spawn_rpc_proxy_requiring_header( + handle.http_endpoint(), + "authorization", + "Bearer secret", + ) + .await; + std::fs::write( + prj.config(), + format!( + r#"[rpc_endpoints] +authenticated = {{ endpoint = "{endpoint}", auth = "Bearer secret" }} +"# + ), + ) + .unwrap(); + + prj.add_test( + "ForkAuth.t.sol", + &format!( + r#" +interface Vm {{ + struct EthGetLogs {{ address emitter; bytes32[] topics; bytes data; bytes32 blockHash; uint64 blockNumber; bytes32 transactionHash; uint64 transactionIndex; uint256 logIndex; bool removed; }} + function createSelectFork(string calldata urlOrAlias) external returns (uint256 forkId); + function eth_getLogs(uint256 fromBlock, uint256 toBlock, address target, bytes32[] calldata topics) external view returns (EthGetLogs[] memory logs); + function getRawBlockHeader(uint256 blockNumber) external view returns (bytes memory rlpHeader); +}} + +contract ForkAuthTest {{ + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + function testForkEthGetLogs() public {{ + vm.createSelectFork("authenticated"); + bytes32[] memory topics = new bytes32[](1); + topics[0] = {topic}; + + Vm.EthGetLogs[] memory logs = vm.eth_getLogs({block}, {block}, {target}, topics); + + require(logs.length == 1, "logs length"); + require(logs[0].emitter == {target}, "emitter"); + require(logs[0].topics[0] == {topic}, "topic"); + require(logs[0].blockNumber == {block}, "block number"); + }} + + function testForkGetRawBlockHeader() public {{ + vm.createSelectFork("authenticated"); + require(keccak256(vm.getRawBlockHeader({block})) == {block_hash}, "block hash"); + }} +}} +"# + ), + ); + + cmd.args(["test", "--match-contract", "ForkAuthTest"]).assert_success().stdout_eq(str![[r#" +... +Ran 2 tests for test/ForkAuth.t.sol:ForkAuthTest +[PASS] testForkEthGetLogs() ([GAS]) +[PASS] testForkGetRawBlockHeader() ([GAS]) +Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 2 tests passed, 0 failed, 0 skipped (2 total tests) + +"#]]); +} diff --git a/crates/forge/tests/cli/test_cmd/mutation.rs b/crates/forge/tests/cli/test_cmd/mutation.rs new file mode 100644 index 0000000000000..4bc80e6b30b16 --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/mutation.rs @@ -0,0 +1,2260 @@ +// CLI integration tests for mutation testing + +use alloy_primitives::{U256, address}; +use anvil::{NodeConfig, spawn}; +use foundry_compilers::artifacts::Remapping; +use foundry_test_utils::{rpc::spawn_rpc_proxy_recording_method, str, util::OutputExt}; +use std::{fs, str::FromStr}; + +fn mutation_summary(stdout: &str) -> serde_json::Value { + serde_json::from_str::(stdout.trim()).unwrap()["summary"].clone() +} + +#[forgetest_init] +async fn mutation_fork_backend_is_shared_by_baseline_and_workers(prj: _, cmd: _) { + let (api, handle) = spawn(NodeConfig::test().with_chain_id(Some(1u64))).await; + api.anvil_set_balance(address!("0000000000000000000000000000000000001234"), U256::from(42)) + .await + .unwrap(); + api.anvil_mine(Some(U256::from(1)), None).await.unwrap(); + let (endpoint, blocks) = + spawn_rpc_proxy_recording_method(handle.http_endpoint(), "eth_getBlockByNumber").await; + prj.add_source( + "Counter.sol", + r#" +pragma solidity ^0.8.13; +contract Counter { + uint256 public number; + function increment() public { number++; } +} +"#, + ); + prj.add_test( + "Counter.t.sol", + r#" +pragma solidity ^0.8.13; +import "../src/Counter.sol"; +contract CounterTest { + function testIncrement() public { + require(address(0x1234).balance == 42, "wrong remote state"); + Counter counter = new Counter(); + counter.increment(); + assert(counter.number() == 1); + } +} +"#, + ); + cmd.args([ + "test", + "--fork-url", + &endpoint, + "--mutate", + "src/Counter.sol", + "--mutation-jobs", + "4", + "--threads", + "1", + ]) + .assert_success() + .stdout_eq(str![[r#" +... +Running mutation tests with 4 parallel workers... +... +3 mutants killed +... +"#]]); + assert_eq!( + blocks.lock().unwrap().len(), + 1, + "baseline and mutants must use one prepared backend" + ); +} + +#[forgetest_init] +fn can_run_mutation_testing(prj: _, cmd: _) { + prj.add_source( + "Counter.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +contract Counter { + uint256 public number; + + function setNumber(uint256 newNumber) public { + number = newNumber; + } + + function increment() public { + number++; + } +} +"#, + ); + + prj.add_test( + "Counter.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "../src/Counter.sol"; + +contract CounterTest { + Counter public counter; + + function setUp() public { + counter = new Counter(); + } + + function test_Increment() public { + counter.increment(); + assert(counter.number() == 1); + } + + function test_SetNumber() public { + counter.setNumber(42); + assert(counter.number() == 42); + } +} +"#, + ); + + // Run mutation testing + cmd.args(["test", "--mutate", "src/Counter.sol", "--mutation-jobs", "1"]); + cmd.assert_success().stdout_eq(str![[r#" +... +Running mutation tests with 1 parallel workers... +... +════════════════════════════════════════════════════════════ +MUTATION TESTING RESULTS +════════════════════════════════════════════════════════════ + +╭──────────┬───────────┬────────────╮ +│ Status ┆ # Mutants ┆ % of Total │ +╞══════════╪═══════════╪════════════╡ +│ Survived ┆ 1 ┆ 20.0% │ +├╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Killed ┆ 4 ┆ 80.0% │ +├╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Invalid ┆ 0 ┆ 0.0% │ +├╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Skipped ┆ 0 ┆ 0.0% │ +╰──────────┴───────────┴────────────╯ + +Legend: + Survived - tests did not catch the mutation + Killed - tests caught the mutation + Invalid - mutation produced a compilation error + Skipped - redundant mutation on the same expression + Timed out - compile/test exceeded the configured timeout + +Mutation Score: 80.0% (4/5 mutants killed); [ELAPSED] + +──────────────────────────────────────────────────────────── +Survived mutants +──────────────────────────────────────────────────────────── + +... + number++; + Mutation: + - number++ + + ++number +... +──────────────────────────────────────────────────────────── +4 mutants killed + +════════════════════════════════════════════════════════════ + +"#]]); + + // Run mutation testing with --json - verify the output contains valid mutation JSON + cmd.forge_fuse().args(["test", "--mutate", "src/Counter.sol", "--mutation-jobs", "1", "--json"]).assert_success().stdout_eq(str![[r#" +{"summary":{"total":5,"killed":4,"survived":1,"invalid":0,"skipped":0,"timed_out":0,"mutation_score":80.0,"duration_secs":[..]},"survived_mutants":{"src/Counter.sol":[{"line":13,"column":9,"original":"number++","mutant":"++number"}]}} + +"#]]); +} + +#[forgetest_init] +fn mutation_testing_retains_gas_distinct_bounds(prj: _, cmd: _) { + prj.add_source( + "Boundary.sol", + r#" +pragma solidity ^0.8.13; + +contract Boundary { + function isZero(uint256 value) public pure returns (bool) { + return value == 0; + } +} +"#, + ); + + prj.add_test( + "Boundary.t.sol", + r#" +pragma solidity ^0.8.13; + +import "../src/Boundary.sol"; + +contract BoundaryTest { + Boundary private boundary = new Boundary(); + + function testZero() public view { + assert(boundary.isZero(0)); + } + + function testNonzero() public view { + assert(!boundary.isZero(1)); + } +} +"#, + ); + + fs::write( + prj.root().join("foundry.toml"), + r#" +[mutation] +exclude_operators = [ + "assembly", + "assignment", + "delete-expression", + "elim-delegate", + "require", + "unary-op", +] +"#, + ) + .unwrap(); + + cmd.args(["test", "--mutate", "src/Boundary.sol", "--mutation-jobs", "1", "--json"]) + .assert_success() + .stdout_eq(str![[r#" +{"summary":{"total":5,"killed":4,"survived":1,"invalid":0,"skipped":0,"timed_out":0,"mutation_score":80.0,"duration_secs":[..]},"survived_mutants":{"src/Boundary.sol":[{"line":7,"column":16,"original":"value == 0","mutant":"value <= 0"}]}} + +"#]]); +} + +#[forgetest_init] +fn mutation_testing_retains_storage_push_lvalue_mutants(prj: _, cmd: _) { + prj.add_source( + "StoragePush.sol", + r#" +pragma solidity ^0.8.13; + +contract StoragePush { + int256[] private values; + + function mutate() public returns (int256) { + return -values.push(); + } + + function value() public view returns (int256) { + return values[0]; + } +} +"#, + ); + + prj.add_test( + "StoragePush.t.sol", + r#" +pragma solidity ^0.8.13; + +import "../src/StoragePush.sol"; + +contract StoragePushTest { + function testMutate() public { + StoragePush target = new StoragePush(); + assert(target.mutate() == 0); + assert(target.value() == 0); + } +} +"#, + ); + + fs::write( + prj.root().join("foundry.toml"), + r#" +[mutation] +exclude_operators = [ + "assembly", + "assignment", + "binary-op", + "delete-expression", + "elim-delegate", + "require", +] +"#, + ) + .unwrap(); + + let output = cmd + .args(["test", "--mutate", "src/StoragePush.sol", "--mutation-jobs", "1", "--json"]) + .assert_success(); + let summary = mutation_summary(&output.get_output().stdout_lossy()); + + assert_eq!(summary["total"], 5); + assert_eq!(summary["killed"], 5); + assert_eq!(summary["invalid"], 0); + assert_eq!(summary["skipped"], 0); +} + +#[forgetest_init] +fn mutation_testing_filters_invalid_compound_assignments(prj: _, cmd: _) { + prj.add_source( + "CompoundAssignments.sol", + r#" +pragma solidity ^0.8.13; + +contract CompoundAssignments { + uint256 public unsignedTotal; + int256 public signedTotal; + + function update(uint256 unsignedAmount, int256 signedAmount) public { + unsignedTotal += unsignedAmount; + signedTotal += signedAmount; + } + + function shift(int256 value, uint8 amount) public pure returns (int256) { + value <<= amount; + return value; + } + + function shiftLeftLiteral(int256 value) public pure returns (int256) { + value <<= 2; + return value; + } + + function shiftRightLiteral(int256 value) public pure returns (int256) { + value >>= 3; + return value; + } +} +"#, + ); + + prj.add_test( + "CompoundAssignments.t.sol", + r#" +pragma solidity ^0.8.13; + +import "../src/CompoundAssignments.sol"; + +contract CompoundAssignmentsTest { + CompoundAssignments private target = new CompoundAssignments(); + + function testUpdate() public { + target.update(3, 4); + assert(target.unsignedTotal() == 3); + assert(target.signedTotal() == 4); + } + + function testShift() public view { + assert(target.shift(1, 2) == 4); + } + + function testShiftLiterals() public view { + assert(target.shiftLeftLiteral(32) == 128); + assert(target.shiftRightLiteral(32) == 4); + } +} +"#, + ); + + fs::write( + prj.root().join("foundry.toml"), + r#" +[mutation] +exclude_operators = [ + "assembly", + "binary-op", + "delete-expression", + "elim-delegate", + "require", + "unary-op", +] +"#, + ) + .unwrap(); + + let output = cmd + .args(["test", "--mutate", "src/CompoundAssignments.sol", "--mutation-jobs", "1", "--json"]) + .assert_success(); + let summary = mutation_summary(&output.get_output().stdout_lossy()); + + assert_eq!(summary["total"], 6); + assert_eq!(summary["killed"], 6); + assert_eq!(summary["survived"], 0); + assert_eq!(summary["invalid"], 0); + assert_eq!(summary["skipped"], 0); +} + +#[forgetest_init] +fn mutation_testing_filters_invalid_fixed_bytes_unary_mutants(prj: _, cmd: _) { + prj.add_source( + "TypedUnary.sol", + r#" +pragma solidity ^0.8.13; + +contract TypedUnary { + function complementWord(bytes32 word) public pure returns (bytes32) { + return ~word; + } + + function complementNumber(uint256 number) public pure returns (uint256) { + return ~number; + } +} +"#, + ); + + prj.add_test( + "TypedUnary.t.sol", + r#" +pragma solidity ^0.8.13; + +import "../src/TypedUnary.sol"; + +contract TypedUnaryTest { + TypedUnary private target = new TypedUnary(); + + function testComplementWord() public view { + assert(target.complementWord(bytes32(uint256(3))) == ~bytes32(uint256(3))); + } + + function testComplementNumber() public view { + assert(target.complementNumber(3) == ~uint256(3)); + } +} +"#, + ); + + fs::write( + prj.root().join("foundry.toml"), + r#" +[mutation] +exclude_operators = [ + "assembly", + "assignment", + "binary-op", + "delete-expression", + "elim-delegate", + "require", +] +"#, + ) + .unwrap(); + + let output = cmd + .args(["test", "--mutate", "src/TypedUnary.sol", "--mutation-jobs", "1", "--json"]) + .assert_success(); + let summary = mutation_summary(&output.get_output().stdout_lossy()); + + assert_eq!(summary["total"], 4); + assert_eq!(summary["killed"], 4); + assert_eq!(summary["survived"], 0); + assert_eq!(summary["invalid"], 0); + assert_eq!(summary["skipped"], 0); +} + +#[forgetest_init] +fn mutation_testing_comparison_type_matrix(prj: _, cmd: _) { + prj.add_source( + "Comparisons.sol", + r#" +pragma solidity ^0.8.13; + +contract Comparisons { + function numericEq(uint256 left, uint256 right) public pure returns (bool) { + return left == right; + } + + function booleanEq(bool left, bool right) public pure returns (bool) { + return left == right; + } + + function conjunction(bool left, bool right) public pure returns (bool) { + return left && right; + } +} +"#, + ); + + prj.add_test( + "Comparisons.t.sol", + r#" +pragma solidity ^0.8.13; + +import "../src/Comparisons.sol"; + +contract ComparisonsTest { + Comparisons private comparisons = new Comparisons(); + + function testNumericEq() public view { + assert(comparisons.numericEq(1, 1)); + assert(!comparisons.numericEq(1, 2)); + assert(!comparisons.numericEq(2, 1)); + } + + function testBooleanEq() public view { + assert(comparisons.booleanEq(false, false)); + assert(!comparisons.booleanEq(false, true)); + assert(!comparisons.booleanEq(true, false)); + assert(comparisons.booleanEq(true, true)); + } + + function testConjunction() public view { + assert(!comparisons.conjunction(false, false)); + assert(!comparisons.conjunction(false, true)); + assert(!comparisons.conjunction(true, false)); + assert(comparisons.conjunction(true, true)); + } +} +"#, + ); + + fs::write( + prj.root().join("foundry.toml"), + r#" +[mutation] +exclude_operators = [ + "assembly", + "assignment", + "delete-expression", + "elim-delegate", + "require", + "unary-op", +] +"#, + ) + .unwrap(); + + let output = cmd + .args(["test", "--mutate", "src/Comparisons.sol", "--mutation-jobs", "1", "--json"]) + .assert_success(); + let summary = mutation_summary(&output.get_output().stdout_lossy()); + + assert_eq!(summary["total"], 11); + assert_eq!(summary["killed"], 11); + assert_eq!(summary["survived"], 0); + assert_eq!(summary["invalid"], 0); + assert_eq!(summary["mutation_score"], 100.0); +} + +#[forgetest] +fn mutation_testing_rejects_list_mode_before_compile(prj: _, cmd: _) { + prj.add_source( + "Broken.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +contract Broken { + function broken() public pure returns (uint256) { + return; + } +} +"#, + ); + + cmd.args(["test", "--mutate", "src/Broken.sol", "--list"]) + .assert_failure() + .stdout_eq("") + .stderr_eq(str![[r#" +Error: `--mutate` cannot be combined with: --list. Re-run without those flags to use mutation testing. + +"#]]); +} + +#[forgetest_init] +fn mutation_testing_rejects_all_skipped_baseline(prj: _, cmd: _) { + prj.add_source( + "Counter.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +contract Counter { + uint256 public number; + + function increment() public { + number++; + } +} +"#, + ); + + prj.add_test( + "Counter.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; +import "../src/Counter.sol"; + +contract CounterTest is Test { + Counter public counter; + + function setUp() public { + vm.skip(true); + counter = new Counter(); + } + + function test_Increment() public { + counter.increment(); + assertEq(counter.number(), 1); + } +} +"#, + ); + + let output = cmd.args(["test", "--mutate", "src/Counter.sol"]).assert_failure(); + let stderr = output.get_output().stderr_lossy(); + + assert!( + stderr.contains("Mutation testing requires at least one passing baseline test"), + "unexpected stderr:\n{stderr}" + ); +} + +#[forgetest_init] +fn mutation_testing_validates_mutation_compiler_profile(prj: _, cmd: _) { + fs::write(prj.root().join("foundry.toml"), "[profile.default]\nvia_ir = true\n").unwrap(); + + prj.add_source( + "StackTooDeep.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +contract StackTooDeep { + function manyLocals(uint256 x) public pure returns (uint256) { + uint256 a00 = x + 0; + uint256 a01 = x + 1; + uint256 a02 = x + 2; + uint256 a03 = x + 3; + uint256 a04 = x + 4; + uint256 a05 = x + 5; + uint256 a06 = x + 6; + uint256 a07 = x + 7; + uint256 a08 = x + 8; + uint256 a09 = x + 9; + uint256 a10 = x + 10; + uint256 a11 = x + 11; + uint256 a12 = x + 12; + uint256 a13 = x + 13; + uint256 a14 = x + 14; + uint256 a15 = x + 15; + uint256 a16 = x + 16; + uint256 a17 = x + 17; + uint256 a18 = x + 18; + uint256 a19 = x + 19; + return a00 + a01 + a02 + a03 + a04 + a05 + a06 + a07 + a08 + a09 + + a10 + a11 + a12 + a13 + a14 + a15 + a16 + a17 + a18 + a19; + } +} +"#, + ); + + prj.add_test( + "StackTooDeep.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "../src/StackTooDeep.sol"; + +contract StackTooDeepTest { + function test_manyLocals() public { + StackTooDeep target = new StackTooDeep(); + assert(target.manyLocals(1) == 210); + } +} +"#, + ); + + let output = cmd + .args(["test", "--mutate", "src/StackTooDeep.sol", "--mutation-via-ir", "false", "--json"]) + .assert_failure(); + let stdout = output.get_output().stdout_lossy(); + let stderr = output.get_output().stderr_lossy(); + + assert!(stdout.is_empty(), "unexpected stdout:\n{stdout}"); + + assert!( + stderr.contains( + "Mutation testing compiler profile failed to compile before applying mutations" + ), + "unexpected stderr:\n{stderr}" + ); +} + +#[forgetest_init] +fn mutation_testing_uses_mutation_profile_for_initial_compile(prj: _, cmd: _) { + fs::write(prj.root().join("foundry.toml"), "[profile.default]\nvia_ir = false\n").unwrap(); + + prj.add_source( + "StackTooDeep.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +contract StackTooDeep { + function manyLocals(uint256 x) public pure returns (uint256) { + uint256 a00 = x + 0; + uint256 a01 = x + 1; + uint256 a02 = x + 2; + uint256 a03 = x + 3; + uint256 a04 = x + 4; + uint256 a05 = x + 5; + uint256 a06 = x + 6; + uint256 a07 = x + 7; + uint256 a08 = x + 8; + uint256 a09 = x + 9; + uint256 a10 = x + 10; + uint256 a11 = x + 11; + uint256 a12 = x + 12; + uint256 a13 = x + 13; + uint256 a14 = x + 14; + uint256 a15 = x + 15; + uint256 a16 = x + 16; + uint256 a17 = x + 17; + uint256 a18 = x + 18; + uint256 a19 = x + 19; + return a00 + a01 + a02 + a03 + a04 + a05 + a06 + a07 + a08 + a09 + + a10 + a11 + a12 + a13 + a14 + a15 + a16 + a17 + a18 + a19; + } +} +"#, + ); + + prj.add_test( + "StackTooDeep.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "../src/StackTooDeep.sol"; + +contract StackTooDeepTest { + function test_manyLocals() public { + StackTooDeep target = new StackTooDeep(); + assert(target.manyLocals(1) == 210); + } +} +"#, + ); + + let output = cmd + .args([ + "test", + "--mutate", + "src/StackTooDeep.sol", + "--mutation-via-ir", + "true", + "--mutation-jobs", + "1", + "--json", + ]) + .assert_success(); + let summary = mutation_summary(&output.get_output().stdout_lossy()); + + assert!(summary["total"].as_u64().unwrap() > 0, "unexpected summary:\n{summary}"); +} + +#[forgetest_init] +fn mutation_testing_rerun_preserves_exact_failure_filter(prj: _) { + prj.add_source( + "Counter.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +contract Counter { + uint256 public number; + + function increment() public { + number++; + } +} +"#, + ); + + prj.add_test( + "Counter.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +import "../src/Counter.sol"; + +contract WeakTest { + function test_increment() public { + Counter target = new Counter(); + target.increment(); + assert(target.number() >= 0); + } +} + +contract StrongTest { + function test_increment() public { + Counter target = new Counter(); + target.increment(); + assert(target.number() == 1); + } +} +"#, + ); + + let mut weak_cmd = prj.forge_command(); + let weak_stdout = weak_cmd + .args([ + "test", + "--mutate", + "src/Counter.sol", + "--mutation-jobs", + "1", + "--match-contract", + "WeakTest", + "--match-test", + "test_increment", + "--json", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + let weak_summary = mutation_summary(&weak_stdout); + + let failures_file = prj.root().join("cache/test-failures"); + fs::create_dir_all(failures_file.parent().unwrap()).unwrap(); + fs::write( + failures_file, + r#"{"version":1,"failures":[{"contract":"test/Counter.t.sol:WeakTest","test":"test_increment()"}]}"#, + ) + .unwrap(); + + let mut rerun_cmd = prj.forge_command(); + let rerun_stdout = rerun_cmd + .args(["test", "--mutate", "src/Counter.sol", "--mutation-jobs", "1", "--rerun", "--json"]) + .assert_success() + .get_output() + .stdout_lossy(); + let rerun_summary = mutation_summary(&rerun_stdout); + + for key in ["total", "killed", "survived", "invalid", "skipped", "timed_out"] { + assert_eq!( + rerun_summary[key], weak_summary[key], + "expected --rerun to match the exact WeakTest baseline for `{key}`: weak={weak_summary}, rerun={rerun_summary}", + ); + } +} + +#[forgetest_init] +fn mutation_testing_rejects_empty_mutate_path_selection(prj: _, cmd: _) { + prj.add_source( + "Counter.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +contract Counter { + uint256 public number; + + function increment() public { + number++; + } +} +"#, + ); + + prj.add_test( + "Counter.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "../src/Counter.sol"; + +contract CounterTest { + function test_Increment() public { + Counter counter = new Counter(); + counter.increment(); + assert(counter.number() == 1); + } +} +"#, + ); + + let output = + cmd.args(["test", "--mutate", "--mutate-path", "src/Missing*.sol"]).assert_failure(); + let stderr = output.get_output().stderr_lossy(); + + assert!( + stderr.contains("no source matched --mutate-path pattern"), + "unexpected stderr:\n{stderr}" + ); +} + +#[forgetest_init] +fn mutation_testing_rejects_empty_mutate_contract_selection(prj: _, cmd: _) { + prj.add_source( + "Counter.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +contract Counter { + uint256 public number; + + function increment() public { + number++; + } +} +"#, + ); + + prj.add_test( + "Counter.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "../src/Counter.sol"; + +contract CounterTest { + function test_Increment() public { + Counter counter = new Counter(); + counter.increment(); + assert(counter.number() == 1); + } +} +"#, + ); + + let output = cmd.args(["test", "--mutate", "--mutate-contract", "Missing"]).assert_failure(); + let stderr = output.get_output().stderr_lossy(); + + assert!( + stderr.contains("no source matched --mutate-contract pattern"), + "unexpected stderr:\n{stderr}" + ); +} + +#[forgetest_init] +fn mutation_testing_with_parallel_workers(prj: _, cmd: _) { + prj.add_source( + "Simple.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +contract Simple { + function add(uint256 a, uint256 b) public pure returns (uint256) { + return a + b; + } +} +"#, + ); + + prj.add_test( + "Simple.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "../src/Simple.sol"; + +contract SimpleTest { + Simple public simple; + + function setUp() public { + simple = new Simple(); + } + + function test_Add() public { + assert(simple.add(1, 2) == 3); + } +} +"#, + ); + + // Run mutation testing with 4 workers + cmd.args(["test", "--mutate", "src/Simple.sol", "--mutation-jobs", "4"]); + cmd.assert_success().stdout_eq(str![[r#" +... +Running mutation tests with 4 parallel workers... +... +════════════════════════════════════════════════════════════ +MUTATION TESTING RESULTS +════════════════════════════════════════════════════════════ + +╭──────────┬───────────┬────────────╮ +│ Status ┆ # Mutants ┆ % of Total │ +╞══════════╪═══════════╪════════════╡ +│ Survived ┆ 2 ┆ 20.0% │ +├╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Killed ┆ 8 ┆ 80.0% │ +├╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Invalid ┆ 0 ┆ 0.0% │ +├╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Skipped ┆ 0 ┆ 0.0% │ +╰──────────┴───────────┴────────────╯ +... +Mutation Score: 80.0% (8/10 mutants killed); [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn mutation_testing_with_show_progress(prj: _, cmd: _) { + prj.add_source( + "Simple.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +contract Simple { + function add(uint256 a, uint256 b) public pure returns (uint256) { + return a + b; + } +} +"#, + ); + + prj.add_test( + "Simple.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "../src/Simple.sol"; + +contract SimpleTest { + Simple public simple; + + function setUp() public { + simple = new Simple(); + } + + function test_Add() public { + assert(simple.add(1, 2) == 3); + } +} +"#, + ); + + // Run mutation testing with progress display (use 4 workers like parallel test for consistency) + cmd.args(["test", "--mutate", "src/Simple.sol", "--show-progress", "--mutation-jobs", "4"]); + cmd.assert_success().stdout_eq(str![[r#" +... +════════════════════════════════════════════════════════════ +MUTATION TESTING RESULTS +════════════════════════════════════════════════════════════ + +╭──────────┬───────────┬────────────╮ +│ Status ┆ # Mutants ┆ % of Total │ +╞══════════╪═══════════╪════════════╡ +│ Survived ┆ 2 ┆ 20.0% │ +├╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Killed ┆ 8 ┆ 80.0% │ +├╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Invalid ┆ 0 ┆ 0.0% │ +├╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Skipped ┆ 0 ┆ 0.0% │ +╰──────────┴───────────┴────────────╯ + +Legend: + Survived - tests did not catch the mutation + Killed - tests caught the mutation + Invalid - mutation produced a compilation error + Skipped - redundant mutation on the same expression + Timed out - compile/test exceeded the configured timeout + +Mutation Score: 80.0% (8/10 mutants killed); [ELAPSED] + +──────────────────────────────────────────────────────────── +Survived mutants +──────────────────────────────────────────────────────────── + +... + return a + b; + Mutation: + - a + b +... + return a + b; + Mutation: + - a + b +... +──────────────────────────────────────────────────────────── +8 mutants killed + +════════════════════════════════════════════════════════════ + +"#]]); +} + +#[forgetest_init] +fn mutation_result_cache_invalidates_when_tests_change(prj: _) { + prj.add_source( + "Calculator.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +contract Calculator { + function add(uint256 a, uint256 b) public pure returns (uint256) { + return a + b; + } +} +"#, + ); + + prj.add_test( + "Calculator.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "../src/Calculator.sol"; + +contract CalculatorTest { + Calculator public calculator; + + function setUp() public { + calculator = new Calculator(); + } + + function test_Add() public view { + calculator.add(1, 2); + } +} +"#, + ); + + let mut weak_cmd = prj.forge_command(); + let weak_stdout = weak_cmd + .args(["test", "--mutate", "src/Calculator.sol", "--mutation-jobs", "1", "--json"]) + .assert_success() + .get_output() + .stdout_lossy(); + let weak_summary = mutation_summary(&weak_stdout); + + prj.add_test( + "Calculator.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "../src/Calculator.sol"; + +contract CalculatorTest { + Calculator public calculator; + + function setUp() public { + calculator = new Calculator(); + } + + function test_Add() public view { + assert(calculator.add(1, 2) == 3); + } +} +"#, + ); + + let mut strong_cmd = prj.forge_command(); + let strong_stdout = strong_cmd + .args(["test", "--mutate", "src/Calculator.sol", "--mutation-jobs", "1", "--json"]) + .assert_success() + .get_output() + .stdout_lossy(); + let strong_summary = mutation_summary(&strong_stdout); + + assert_eq!(weak_summary["total"], strong_summary["total"]); + assert!( + strong_summary["killed"].as_u64().unwrap() > weak_summary["killed"].as_u64().unwrap(), + "expected changed tests to invalidate cached mutation results: weak={weak_summary}, strong={strong_summary}", + ); +} + +#[forgetest_init] +fn mutation_result_cache_invalidates_when_match_test_changes(prj: _) { + prj.add_source( + "Calculator.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +contract Calculator { + function add(uint256 a, uint256 b) public pure returns (uint256) { + return a + b; + } +} +"#, + ); + + prj.add_test( + "Calculator.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "../src/Calculator.sol"; + +contract CalculatorTest { + Calculator public calculator; + + function setUp() public { + calculator = new Calculator(); + } + + function test_Weak() public view { + calculator.add(1, 2); + } + + function test_Strong() public view { + assert(calculator.add(1, 2) == 3); + } +} +"#, + ); + + let mut weak_cmd = prj.forge_command(); + let weak_stdout = weak_cmd + .args([ + "test", + "--mutate", + "src/Calculator.sol", + "--mutation-jobs", + "1", + "--match-test", + "test_Weak", + "--json", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + let weak_summary = mutation_summary(&weak_stdout); + + let mut strong_cmd = prj.forge_command(); + let strong_stdout = strong_cmd + .args([ + "test", + "--mutate", + "src/Calculator.sol", + "--mutation-jobs", + "1", + "--match-test", + "test_Strong", + "--json", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + let strong_summary = mutation_summary(&strong_stdout); + + assert_eq!(weak_summary["total"], strong_summary["total"]); + assert!( + strong_summary["killed"].as_u64().unwrap() > weak_summary["killed"].as_u64().unwrap(), + "expected --match-test to invalidate cached mutation results: weak={weak_summary}, strong={strong_summary}", + ); +} + +#[forgetest_init] +fn mutation_result_cache_invalidates_when_match_path_changes(prj: _) { + prj.add_source( + "Calculator.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +contract Calculator { + function add(uint256 a, uint256 b) public pure returns (uint256) { + return a + b; + } +} +"#, + ); + + prj.add_test( + "Weak.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "../src/Calculator.sol"; + +contract WeakTest { + Calculator public calculator; + + function setUp() public { + calculator = new Calculator(); + } + + function test_Weak() public view { + calculator.add(1, 2); + } +} +"#, + ); + + prj.add_test( + "Strong.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "../src/Calculator.sol"; + +contract StrongTest { + Calculator public calculator; + + function setUp() public { + calculator = new Calculator(); + } + + function test_Strong() public view { + assert(calculator.add(1, 2) == 3); + } +} +"#, + ); + + let mut weak_cmd = prj.forge_command(); + let weak_stdout = weak_cmd + .args([ + "test", + "--mutate", + "src/Calculator.sol", + "--mutation-jobs", + "1", + "--match-path", + "test/Weak.t.sol", + "--json", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + let weak_summary = mutation_summary(&weak_stdout); + + let mut strong_cmd = prj.forge_command(); + let strong_stdout = strong_cmd + .args([ + "test", + "--mutate", + "src/Calculator.sol", + "--mutation-jobs", + "1", + "--match-path", + "test/Strong.t.sol", + "--json", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + let strong_summary = mutation_summary(&strong_stdout); + + assert_eq!(weak_summary["total"], strong_summary["total"]); + assert!( + strong_summary["killed"].as_u64().unwrap() > weak_summary["killed"].as_u64().unwrap(), + "expected --match-path to invalidate cached mutation results: weak={weak_summary}, strong={strong_summary}", + ); +} + +#[forgetest_init] +fn mutation_honors_match_path_at_compile_time(prj: _, cmd: _) { + prj.add_source( + "Foo.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +contract Foo { + function add(uint256 a, uint256 b) public pure returns (uint256) { + return a + b; + } +} +"#, + ); + + prj.add_test( + "FooSelected.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "../src/Foo.sol"; + +contract FooSelectedTest { + Foo internal foo; + + function setUp() public { + foo = new Foo(); + } + + function test_Add() public view { + assert(foo.add(2, 3) == 5); + } +} +"#, + ); + + prj.add_test( + "FooBroken.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "../src/Foo.sol"; + +contract FooBrokenTest { + function test_Broken() public pure { + NonExistent x = NonExistent(0); + x.doSomething(); + } +} +"#, + ); + + cmd.forge_fuse().args(["test", "--match-path", "test/FooSelected.t.sol"]).assert_success(); + + cmd.forge_fuse().args([ + "test", + "--mutate", + "src/Foo.sol", + "--match-path", + "test/FooSelected.t.sol", + "--mutation-jobs", + "1", + "--json", + ]); + + let out = cmd.assert_success().get_output().stdout_lossy(); + let summary = mutation_summary(&out); + + let total = summary["total"].as_u64().unwrap_or(0); + let invalid = summary["invalid"].as_u64().unwrap_or(u64::MAX); + let killed = summary["killed"].as_u64().unwrap_or(0); + let survived = summary["survived"].as_u64().unwrap_or(0); + + assert!( + invalid < total, + "filtered-out FooBroken.t.sol must not make every mutant Invalid; summary={summary}" + ); + assert!( + killed + survived >= 1, + "expected at least one Killed/Survived mutant from arithmetic ops; summary={summary}" + ); +} + +#[cfg(unix)] +#[forgetest_init] +fn mutation_uses_canonical_temp_root_for_filtered_sources(prj: _, cmd: _) { + let temp_parent = tempfile::tempdir().unwrap(); + let real_temp = temp_parent.path().join("real"); + let aliased_temp = temp_parent.path().join("alias"); + fs::create_dir(&real_temp).unwrap(); + std::os::unix::fs::symlink(&real_temp, &aliased_temp).unwrap(); + assert_ne!(aliased_temp, dunce::canonicalize(&aliased_temp).unwrap()); + + // Preserve the failing import chain: selected test -> root-style helper -> remapped dependency. + let dependency_dir = prj.root().join("lib/local-dependency/src"); + fs::create_dir_all(&dependency_dir).unwrap(); + fs::write( + dependency_dir.join("Dependency.sol"), + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +library Dependency { + function expected() internal pure returns (uint256) { + return 2; + } +} +"#, + ) + .unwrap(); + + prj.update_config(|config| { + config.remappings = + vec![Remapping::from_str("dep/=lib/local-dependency/src/").unwrap().into()]; + }); + + prj.add_source( + "Target.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +contract Target { + function addOne(uint256 value) public pure returns (uint256) { + return value + 1; + } +} +"#, + ); + + prj.add_test( + "helpers/Helper.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {Dependency} from "dep/Dependency.sol"; + +library Helper { + function expected() internal pure returns (uint256) { + return Dependency.expected(); + } +} +"#, + ); + + prj.add_test( + "Selected.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {Target} from "../src/Target.sol"; +import {Helper} from "test/helpers/Helper.sol"; + +contract SelectedTest { + function test_AddOne() public { + Target target = new Target(); + assert(target.addOne(1) == Helper.expected()); + } +} +"#, + ); + + cmd.env("TMPDIR", &aliased_temp); + let out = cmd + .args([ + "test", + "--mutate", + "src/Target.sol", + "--match-path", + "test/Selected.t.sol", + "--mutation-jobs", + "1", + "--json", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + let summary = mutation_summary(&out); + let total = summary["total"].as_u64().unwrap_or(0); + let invalid = summary["invalid"].as_u64().unwrap_or(u64::MAX); + let killed = summary["killed"].as_u64().unwrap_or(0); + let survived = summary["survived"].as_u64().unwrap_or(0); + + assert!(total > 0, "expected mutation testing to generate mutants: summary={summary}"); + assert!( + invalid < total, + "aliased temp root must not make every mutant Invalid: summary={summary}" + ); + assert!( + killed + survived > 0, + "expected at least one Killed/Survived mutant: summary={summary}" + ); + assert!( + fs::read_dir(&real_temp).unwrap().all(|entry| !entry + .unwrap() + .file_name() + .to_string_lossy() + .starts_with("forge_mutation_")), + "mutation workspaces should be removed after the run" + ); +} + +#[forgetest_init] +fn mutation_compiles_dynamic_linking_artifacts_for_selected_tests(prj: _, cmd: _) { + prj.add_source( + "Target.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +contract Target { + function add(uint256 a, uint256 b) public pure returns (uint256) { + return a + b; + } +} +"#, + ); + + prj.add_test( + "helpers/LinkedHelper.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +library LinkedHelper { + function adjust(uint256 value) public pure returns (uint256) { + return value + 1; + } +} +"#, + ); + + prj.add_test( + "BaseLinked.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "../src/Target.sol"; +import "./helpers/LinkedHelper.sol"; + +contract BaseLinkedTest { + Target internal target; + uint256 internal helperValue; + + function setUp() public { + target = new Target(); + helperValue = LinkedHelper.adjust(1); + } +} +"#, + ); + + prj.add_test( + "SelectedLinked.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "./BaseLinked.t.sol"; + +contract SelectedLinkedTest is BaseLinkedTest { + function test_AddsHelperValue() public view { + assert(target.add(helperValue, 1) == 3); + } +} +"#, + ); + + let out = cmd + .args([ + "test", + "--mutate", + "src/Target.sol", + "--match-contract", + "SelectedLinkedTest", + "--mutation-jobs", + "1", + "--json", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + let summary = mutation_summary(&out); + + let total = summary["total"].as_u64().unwrap_or(0); + let invalid = summary["invalid"].as_u64().unwrap_or(u64::MAX); + let killed = summary["killed"].as_u64().unwrap_or(0); + let survived = summary["survived"].as_u64().unwrap_or(0); + + assert!(total > 0, "expected mutation testing to generate mutants: summary={summary}"); + assert!( + invalid < total, + "dynamic-linking helper artifacts should compile inside mutant workspaces: summary={summary}" + ); + assert!( + killed + survived >= 1, + "expected at least one Killed/Survived mutant from arithmetic ops; summary={summary}" + ); +} + +#[forgetest_init] +fn mutation_workspace_copies_include_paths(prj: _, cmd: _) { + let include_dir = prj.root().join("include"); + fs::create_dir_all(&include_dir).unwrap(); + fs::write( + include_dir.join("Shared.sol"), + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +library Shared { + function value() internal pure returns (uint256) { + return 1; + } +} +"#, + ) + .unwrap(); + + prj.update_config(|config| { + config.include_paths = vec![include_dir.clone()]; + }); + + prj.add_source( + "UsesShared.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "Shared.sol"; + +contract UsesShared { + function value() public pure returns (uint256) { + return Shared.value() + 1; + } +} +"#, + ); + + prj.add_test( + "UsesShared.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "../src/UsesShared.sol"; + +contract UsesSharedTest { + function test_Value() public { + UsesShared usesShared = new UsesShared(); + assert(usesShared.value() == 2); + } +} +"#, + ); + + let out = cmd + .args(["test", "--mutate", "src/UsesShared.sol", "--mutation-jobs", "1", "--json"]) + .assert_success() + .get_output() + .stdout_lossy(); + let summary = mutation_summary(&out); + let total = summary["total"].as_u64().unwrap_or(0); + let invalid = summary["invalid"].as_u64().unwrap_or(u64::MAX); + + assert!(total > 0, "expected mutation testing to generate mutants: summary={summary}"); + assert!( + invalid < total, + "include_paths imports should compile inside mutant workspaces: summary={summary}" + ); +} + +#[forgetest_init] +fn mutation_workspace_preserves_external_read_only_remappings(prj: _, cmd: _) { + let shared_dir = prj.root().parent().unwrap().join("shared-solidity"); + fs::create_dir_all(&shared_dir).unwrap(); + fs::write( + shared_dir.join("Shared.sol"), + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +library Shared { + function value() internal pure returns (uint256) { + return 1; + } +} +"#, + ) + .unwrap(); + + prj.update_config(|config| { + config.allow_paths = vec!["../shared-solidity".into()]; + config.remappings = + vec![Remapping::from_str("shared/=../shared-solidity/").unwrap().into()]; + }); + + prj.add_source( + "UsesShared.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "shared/Shared.sol"; + +contract UsesShared { + function value() public pure returns (uint256) { + return Shared.value() + 1; + } +} +"#, + ); + + prj.add_test( + "UsesShared.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "../src/UsesShared.sol"; + +contract UsesSharedTest { + function test_Value() public { + UsesShared usesShared = new UsesShared(); + assert(usesShared.value() == 2); + } +} +"#, + ); + + let out = cmd + .args(["test", "--mutate", "src/UsesShared.sol", "--mutation-jobs", "1", "--json"]) + .assert_success() + .get_output() + .stdout_lossy(); + let summary = mutation_summary(&out); + let total = summary["total"].as_u64().unwrap_or(0); + let invalid = summary["invalid"].as_u64().unwrap_or(u64::MAX); + + assert!(total > 0, "expected mutation testing to generate mutants: summary={summary}"); + assert!( + invalid < total, + "external read-only remapping should compile inside mutant workspaces: summary={summary}" + ); +} + +// Test require/assert mutation for security-critical patterns +#[forgetest_init] +fn mutation_testing_require_mutator(prj: _, cmd: _) { + // A contract with security-critical require checks (access control, input validation) + prj.add_source( + "Vault.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +contract Vault { + address public owner; + mapping(address => uint256) public balances; + bool public paused; + + constructor() { + owner = msg.sender; + } + + modifier onlyOwner() { + require(msg.sender == owner, "Not owner"); + _; + } + + function deposit() public payable { + require(!paused, "Contract paused"); + require(msg.value > 0, "Must send ETH"); + balances[msg.sender] += msg.value; + } + + function withdraw(uint256 amount) public { + require(!paused, "Contract paused"); + require(balances[msg.sender] >= amount, "Insufficient balance"); + balances[msg.sender] -= amount; + payable(msg.sender).transfer(amount); + } + + function pause() public onlyOwner { + paused = true; + } + + function unpause() public onlyOwner { + paused = false; + } +} +"#, + ); + + prj.add_test( + "Vault.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "forge-std/Test.sol"; +import "../src/Vault.sol"; + +contract VaultTest is Test { + Vault public vault; + + function setUp() public { + vault = new Vault(); + } + + // === DEPOSIT TESTS === + + function test_DepositRequiresValue() public { + vm.expectRevert("Must send ETH"); + vault.deposit(); + } + + function test_DepositWithValue() public { + vault.deposit{value: 1 ether}(); + assertEq(vault.balances(address(this)), 1 ether); + } + + function test_DepositWhenPausedReverts() public { + vault.pause(); + vm.expectRevert("Contract paused"); + vault.deposit{value: 1 ether}(); + } + + // === WITHDRAW TESTS === + + function test_WithdrawRequiresBalance() public { + vm.expectRevert("Insufficient balance"); + vault.withdraw(1 ether); + } + + function test_WithdrawExactBalance() public { + // Kills >= -> > and >= -> != mutations + vault.deposit{value: 1 ether}(); + vault.withdraw(1 ether); + assertEq(vault.balances(address(this)), 0); + } + + function test_WithdrawPartialBalance() public { + vault.deposit{value: 1 ether}(); + vault.withdraw(0.5 ether); + assertEq(vault.balances(address(this)), 0.5 ether); + } + + function test_WithdrawWhenPausedReverts() public { + vault.deposit{value: 1 ether}(); + vault.pause(); + vm.expectRevert("Contract paused"); + vault.withdraw(0.5 ether); + } + + // === PAUSE/UNPAUSE TESTS === + + function test_OnlyOwnerCanPause() public { + vault.pause(); + assertTrue(vault.paused()); + } + + function test_NonOwnerCannotPause() public { + vm.prank(address(1)); + vm.expectRevert("Not owner"); + vault.pause(); + } + + function test_OnlyOwnerCanUnpause() public { + vault.pause(); + vault.unpause(); + assertFalse(vault.paused()); + } + + function test_NonOwnerCannotUnpause() public { + vault.pause(); + vm.prank(address(1)); + vm.expectRevert("Not owner"); + vault.unpause(); + } + + function test_UnpauseAllowsDeposit() public { + vault.pause(); + vault.unpause(); + vault.deposit{value: 1 ether}(); + assertEq(vault.balances(address(this)), 1 ether); + } + + // Kills >= mutation on address comparison + function test_HigherAddressCannotPause() public { + vm.prank(address(type(uint160).max)); + vm.expectRevert("Not owner"); + vault.pause(); + } + + receive() external payable {} +} +"#, + ); + + // The gas-distinct msg.value > 0 -> msg.value != 0 mutant remains. + cmd.args(["test", "--mutate", "src/Vault.sol", "--mutation-jobs", "2"]); + cmd.assert_success().stdout_eq(str![[r#" +... +Running mutation tests with 2 parallel workers... +... +════════════════════════════════════════════════════════════ +MUTATION TESTING RESULTS +════════════════════════════════════════════════════════════ + +╭──────────┬───────────┬────────────╮ +│ Status ┆ # Mutants ┆ % of Total │ +╞══════════╪═══════════╪════════════╡ +│ Survived ┆ 3 ┆ 5.7% │ +├╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Killed ┆ 50 ┆ 94.3% │ +├╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Invalid ┆ 0 ┆ 0.0% │ +├╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌╌╌┤ +│ Skipped ┆ 0 ┆ 0.0% │ +╰──────────┴───────────┴────────────╯ +... +Mutation Score: 94.3% (50/53 mutants killed); [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn mutation_testing_assembly_code(prj: _, cmd: _) { + prj.add_source( + "AsmMath.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +library AsmMath { + function add(uint256 a, uint256 b) internal pure returns (uint256 result) { + assembly { + result := add(a, b) + } + } + + function sub(uint256 a, uint256 b) internal pure returns (uint256 result) { + assembly { + result := sub(a, b) + } + } +} +"#, + ); + + prj.add_test( + "AsmMath.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "../src/AsmMath.sol"; + +contract AsmMathTest { + using AsmMath for uint256; + + function test_Add() public pure { + assert(uint256(2).add(3) == 5); + assert(uint256(0).add(0) == 0); + } + + function test_Sub() public pure { + assert(uint256(5).sub(3) == 2); + } +} +"#, + ); + + cmd.args(["test", "--mutate", "src/AsmMath.sol", "--mutation-jobs", "1"]); + cmd.assert_success().stdout_eq(str![[r#" +... +Running mutation tests with 1 parallel workers... +... +════════════════════════════════════════════════════════════ +MUTATION TESTING RESULTS +════════════════════════════════════════════════════════════ +... +"#]]); +} + +// Mutants on nested and shared spans must give the same results for every worker count and +// schedule. +#[forgetest_init] +fn mutation_testing_results_do_not_depend_on_worker_count(prj: _, cmd: _) { + prj.add_source( + "Nested.sol", + r#" +pragma solidity ^0.8.13; + +contract Nested { + function mix(uint256 a, uint256 b) public pure returns (uint256) { + return (a + b) * (a > b ? a - b : b - a) + (a * 3) / (b + 1); + } + + function clamp(uint256 x, uint256 lo, uint256 hi) public pure returns (uint256) { + if (x < lo) return lo + (hi - lo) * 0; + if (x > hi) return hi - (hi - lo) * 0; + return x; + } + + function score(uint256 x) public pure returns (uint256) { + return (x % 7 == 0 ? x / 7 : x * 2) + (x > 100 ? 1 : 0); + } +} +"#, + ); + prj.add_test( + "Nested.t.sol", + r#" +pragma solidity ^0.8.13; + +import "../src/Nested.sol"; + +contract NestedTest { + Nested private nested = new Nested(); + + function testWeak() public view { + assert(nested.clamp(5, 1, 10) == 5); + assert(nested.mix(2, 2) > 0); + } +} +"#, + ); + + let mut run = |jobs: &str| { + let _ = fs::remove_dir_all(prj.root().join("cache")); + let output = cmd + .forge_fuse() + .args(["test", "--mutate", "src/Nested.sol", "--mutation-jobs", jobs, "--json"]) + .assert_success() + .get_output() + .stdout_lossy(); + let mut result = serde_json::from_str::(output.trim()).unwrap(); + result["summary"].as_object_mut().unwrap().remove("duration_secs"); + result + }; + let serial = run("1"); + assert!(serial["summary"]["survived"].as_u64().unwrap() > 0, "{serial}"); + assert_eq!(run("4"), serial); + assert_eq!(run("8"), serial); +} + +// Mutant paths must be relative to the project root, also if a parent directory of the project +// has a well-known name such as `src`. +#[forgetest] +fn mutation_paths_are_relative_to_project_root(prj: _, cmd: _) { + let root = prj.root().join("src/project"); + fs::create_dir_all(root.join("src")).unwrap(); + fs::create_dir_all(root.join("test")).unwrap(); + fs::write(root.join("foundry.toml"), "[profile.default]\n").unwrap(); + fs::write( + root.join("src/Counter.sol"), + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +contract Counter { + uint256 public number; + + function increment() public { + number++; + } +} +"#, + ) + .unwrap(); + fs::write( + root.join("test/Counter.t.sol"), + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import "../src/Counter.sol"; + +contract CounterTest { + function test_Increment() public { + Counter counter = new Counter(); + counter.increment(); + assert(counter.number() == 1); + } +} +"#, + ) + .unwrap(); + + cmd.current_dir(&root) + .args(["test", "--mutate", "src/Counter.sol", "--mutation-jobs", "1", "--json"]) + .assert_success() + .stdout_eq(str![[r#" +{"summary":{"total":4,"killed":3,"survived":1,"invalid":0,"skipped":0,"timed_out":0,"mutation_score":75.0,"duration_secs":[..]},"survived_mutants":{"src/Counter.sol":[{"line":9,"column":9,"original":"number++","mutant":"++number"}]}} + +"#]]); +} + +// A per-mutant timeout must not turn a run-limited invariant campaign into a time-based one. +#[forgetest] +fn mutation_timeout_keeps_invariant_run_limit(prj: _, cmd: _) { + prj.add_source( + "Counter.sol", + r#" +pragma solidity ^0.8.13; + +contract Counter { + uint256 public number; + + function increment() public { + number = number + 1; + } +} +"#, + ); + prj.add_test( + "Counter.t.sol", + r#" +pragma solidity ^0.8.13; + +import "../src/Counter.sol"; + +contract Handler { + Counter public counter; + uint256 public calls; + + constructor(Counter counter_) { + counter = counter_; + } + + function increment() public { + counter.increment(); + calls++; + } +} + +contract CounterInvariantTest { + Counter counter; + Handler handler; + + function setUp() public { + counter = new Counter(); + handler = new Handler(counter); + } + + function targetContracts() public view returns (address[] memory targets) { + targets = new address[](1); + targets[0] = address(handler); + } + + /// forge-config: default.invariant.runs = 4 + /// forge-config: default.invariant.depth = 4 + /// forge-config: default.invariant.shrink_run_limit = 0 + function invariant_countsCalls() public view { + require(counter.number() == handler.calls(), "count mismatch"); + } +} +"#, + ); + + let mut run = |args: &[&str]| { + let _ = fs::remove_dir_all(prj.root().join("cache")); + let output = cmd + .forge_fuse() + .args(["test", "--mutate", "src/Counter.sol", "--mutation-jobs", "4", "--json"]) + .args(args) + .assert_success() + .get_output() + .stdout_lossy(); + let mut summary = mutation_summary(&output); + summary.as_object_mut().unwrap().remove("duration_secs"); + summary + }; + let unbounded = run(&[]); + assert_eq!( + unbounded, + serde_json::json!({ + "total": 10, + "killed": 9, + "survived": 1, + "invalid": 0, + "skipped": 0, + "timed_out": 0, + "mutation_score": 90.0, + }) + ); + assert_eq!(run(&["--mutation-timeout", "5"]), unbounded); +} diff --git a/crates/forge/tests/cli/test_cmd/repros.rs b/crates/forge/tests/cli/test_cmd/repros.rs new file mode 100644 index 0000000000000..58d816b4f553c --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/repros.rs @@ -0,0 +1,1438 @@ +//! Regression tests for specific GitHub issues + +use anvil::{NodeConfig, spawn}; +use foundry_test_utils::str; + +// https://github.com/foundry-rs/foundry/issues/3055 +#[forgetest_init] +fn issue_3055(prj: _, cmd: _) { + prj.add_test( + "Issue3055.t.sol", + r#" +import "forge-std/Test.sol"; + +/// forge-config: default.assertions_revert = false +contract Issue3055Test is Test { + function test_snapshot() external { + uint256 snapshotId = vm.snapshotState(); + assertEq(uint256(0), uint256(1)); + vm.revertToState(snapshotId); + } + + function test_snapshot2() public { + uint256 snapshotId = vm.snapshotState(); + assertTrue(false); + vm.revertToState(snapshotId); + assertTrue(true); + } + + function test_snapshot3(uint256) public { + vm.expectRevert(); + // Call exposed_snapshot3() using this to perform an external call, + // so we can properly test for reverts. + this.exposed_snapshot3(); + } + + function exposed_snapshot3() public { + uint256 snapshotId = vm.snapshotState(); + assertTrue(false); + vm.revertToState(snapshotId); + } +} + +/// forge-config: default.assertions_revert = false +/// forge-config: default.isolate = false +contract Issue3055NonIsolatedTest is Issue3055Test {} +"#, + ); + + cmd.args(["test", "-j1"]).assert_failure().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 3 tests for test/Issue3055.t.sol:Issue3055NonIsolatedTest +[FAIL] test_snapshot() ([GAS]) +[FAIL] test_snapshot2() ([GAS]) +[FAIL: next call did not revert as expected; counterexample: calldata=[..] args=[..] test_snapshot3(uint256) (runs: 0, [AVG_GAS]) +Suite result: FAILED. 0 passed; 3 failed; 0 skipped; [ELAPSED] + +Ran 3 tests for test/Issue3055.t.sol:Issue3055Test +[FAIL] test_snapshot() ([GAS]) +[FAIL] test_snapshot2() ([GAS]) +[FAIL: next call did not revert as expected; counterexample: calldata=[..] args=[..] test_snapshot3(uint256) (runs: 0, [AVG_GAS]) +Suite result: FAILED. 0 passed; 3 failed; 0 skipped; [ELAPSED] + +Ran 2 test suites [ELAPSED]: 0 tests passed, 6 failed, 0 skipped (6 total tests) + +Failing tests: +Encountered 3 failing tests in test/Issue3055.t.sol:Issue3055NonIsolatedTest +[FAIL] test_snapshot() ([GAS]) +[FAIL] test_snapshot2() ([GAS]) +[FAIL: next call did not revert as expected; counterexample: calldata=[..] args=[..] test_snapshot3(uint256) (runs: 0, [AVG_GAS]) + +Encountered 3 failing tests in test/Issue3055.t.sol:Issue3055Test +[FAIL] test_snapshot() ([GAS]) +[FAIL] test_snapshot2() ([GAS]) +[FAIL: next call did not revert as expected; counterexample: calldata=[..] args=[..] test_snapshot3(uint256) (runs: 0, [AVG_GAS]) + +Encountered a total of 6 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 6 failed tests +Tip: Run `forge test --debug --match-test ` to inspect one failing test in the debugger + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]]); +} + +#[forgetest_init] +fn isolated_snapshot_enclosing_revert(prj: _, cmd: _) { + prj.add_test( + "IsolatedSnapshotEnclosingRevert.t.sol", + r#" +import "forge-std/Test.sol"; + +/// forge-config: default.isolate = true +contract IsolatedSnapshotEnclosingRevertTest is Test { + uint256 value; + + function test_revert_after_isolated_restore() public { + uint256 snapshotId = vm.snapshotState(); + value = 2; + vm.deal(address(0xBEEF), 5 ether); + + require(this.restore(snapshotId)); + revert("expected test revert"); + } + + function test_revert_after_isolated_restore_and_write() public { + uint256 snapshotId = vm.snapshotState(); + value = 2; + + this.restoreAndWrite(snapshotId); + assertEq(value, 4); + revert("expected post-restore revert"); + } + + function test_reverted_isolated_restore_does_not_escape() public { + value = 1; + uint256 snapshotId = vm.snapshotState(); + value = 2; + + vm.expectRevert("expected call revert"); + this.restoreThenRevert(snapshotId); + + assertEq(value, 2); + } + + function test_caught_nested_restore_revert_does_not_escape() public { + value = 1; + uint256 snapshotId = vm.snapshotState(); + value = 2; + + this.catchRestoreRevert(snapshotId); + + assertEq(value, 4); + } + + function test_successful_restore_survives_reverted_sibling() public { + this.runRevertedSiblingCase(); + } + + function test_reverted_constructor_preserves_nonce() public { + this.runRevertedConstructorCase(); + } + + function test_execute_transaction_restore_does_not_escape_reverted_isolated_call() public { + value = 1; + bytes memory rawTx = signedTransaction(abi.encodeCall(this.snapshotAndRestore, ())); + + vm.expectRevert("outer failed"); + this.outerExecute(rawTx); + + assertEq(value, 1); + } + + function restore(uint256 snapshotId) external returns (bool) { + return vm.revertToState(snapshotId); + } + + function restoreAndWrite(uint256 snapshotId) external { + require(vm.revertToState(snapshotId)); + value = 4; + } + + function restoreThenRevert(uint256 snapshotId) external { + value = 3; + require(vm.revertToState(snapshotId)); + revert("expected call revert"); + } + + function catchRestoreRevert(uint256 snapshotId) external { + try this.restoreThenRevert(snapshotId) {} catch {} + assertEq(value, 2); + value = 4; + } + + function runRevertedSiblingCase() external { + this.restoreLocally(); + value = 4; + + try this.unrelatedRevert() {} catch {} + + assertEq(value, 4); + } + + function restoreLocally() external { + value = 1; + uint256 snapshotId = vm.snapshotState(); + value = 2; + require(vm.revertToState(snapshotId)); + } + + function unrelatedRevert() external pure { + revert("unrelated revert"); + } + + function runRevertedConstructorCase() external { + uint64 nonce = vm.getNonce(address(this)); + try new RevertingSnapshotConstructor() {} catch {} + assertEq(vm.getNonce(address(this)), nonce + 1); + + address expected = vm.computeCreateAddress(address(this), nonce + 1); + SuccessfulDeployment deployed = new SuccessfulDeployment(); + assertEq(address(deployed), expected); + } + + function outerExecute(bytes calldata rawTx) external { + value = 2; + vm.executeTransaction(rawTx); + revert("outer failed"); + } + + function snapshotAndRestore() external { + uint256 snapshotId = vm.snapshotState(); + require(vm.revertToState(snapshotId)); + } + + function signedTransaction(bytes memory data) internal returns (bytes memory) { + uint256 privateKey = 1; + vm.chainId(1); + vm.deal(vm.addr(privateKey), 1 ether); + + bytes[] memory unsigned = new bytes[](9); + unsigned[1] = hex"01"; + unsigned[2] = hex"030d40"; + unsigned[3] = abi.encodePacked(address(this)); + unsigned[5] = data; + unsigned[6] = hex"01"; + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, keccak256(vm.toRlp(unsigned))); + bytes[] memory signed = new bytes[](9); + for (uint256 i; i < 6; i++) { + signed[i] = unsigned[i]; + } + signed[6] = abi.encodePacked(v + 10); + signed[7] = trimLeadingZeros(r); + signed[8] = trimLeadingZeros(s); + return vm.toRlp(signed); + } + + function trimLeadingZeros(bytes32 value_) internal pure returns (bytes memory out) { + uint256 offset; + while (offset < 32 && value_[offset] == bytes1(0)) { + offset++; + } + out = new bytes(32 - offset); + for (uint256 i; i < out.length; i++) { + out[i] = value_[offset + i]; + } + } +} + +contract RevertingSnapshotConstructor is Test { + constructor() { + uint256 snapshotId = vm.snapshotState(); + vm.deal(address(0xBEEF), 1 ether); + require(vm.revertToState(snapshotId)); + revert("expected constructor revert"); + } +} + +contract SuccessfulDeployment {} +"#, + ); + + cmd.arg("test").assert_failure().stdout_eq(str![[r#" +... +[PASS] test_caught_nested_restore_revert_does_not_escape() ([GAS]) +[PASS] test_execute_transaction_restore_does_not_escape_reverted_isolated_call() ([GAS]) +[FAIL: expected test revert] test_revert_after_isolated_restore() ([GAS]) +[FAIL: expected post-restore revert] test_revert_after_isolated_restore_and_write() ([GAS]) +[PASS] test_reverted_constructor_preserves_nonce() ([GAS]) +[PASS] test_reverted_isolated_restore_does_not_escape() ([GAS]) +[PASS] test_successful_restore_survives_reverted_sibling() ([GAS]) +... +"#]]); +} + +// https://github.com/foundry-rs/foundry/issues/3189 +#[forgetest_init] +fn issue_3189(prj: _, cmd: _) { + prj.add_test( + "Issue3189.t.sol", + r#" +import "forge-std/Test.sol"; + +contract MyContract { + function foo(uint256 arg) public returns (uint256) { + return arg + 2; + } +} + +contract MyContractUser is Test { + MyContract immutable myContract; + + constructor() { + myContract = new MyContract(); + } + + function foo(uint256 arg) public returns (uint256 ret) { + ret = myContract.foo(arg); + assertEq(ret, arg + 1, "Invariant failed"); + } +} + +contract Issue3189Test is Test { + function testFoo() public { + MyContractUser user = new MyContractUser(); + user.foo(123); + } +} +"#, + ); + + cmd.arg("test").assert_failure().stdout_eq(str![[r#" +... +Ran 1 test for test/Issue3189.t.sol:Issue3189Test +[FAIL: Invariant failed: 125 != 124] testFoo() ([GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/Issue3189.t.sol:Issue3189Test +[FAIL: Invariant failed: 125 != 124] testFoo() ([GAS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test +Tip: Run `forge test --debug --match-test ` to inspect one failing test in the debugger + +"#]]); +} + +// https://github.com/foundry-rs/foundry/issues/3596 +#[forgetest_init] +fn issue_3596(prj: _, cmd: _) { + prj.add_test( + "Issue3596.t.sol", + r#" +import "forge-std/Test.sol"; + +contract Issue3596Test is Test { + function testDealTransfer() public { + address addr = vm.addr(1337); + vm.startPrank(addr); + vm.deal(addr, 20000001 ether); + payable(address(this)).transfer(20000000 ether); + + Nested nested = new Nested(); + nested.doStuff(); + vm.stopPrank(); + } +} + +contract Nested { + function doStuff() public { + doRevert(); + } + + function doRevert() public { + revert("This fails"); + } +} +"#, + ); + + cmd.arg("test").assert_failure().stdout_eq(str![[r#" +... +Ran 1 test for test/Issue3596.t.sol:Issue3596Test +[FAIL: EvmError: Revert] testDealTransfer() ([GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/Issue3596.t.sol:Issue3596Test +[FAIL: EvmError: Revert] testDealTransfer() ([GAS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test +Tip: Run `forge test --debug --match-test ` to inspect one failing test in the debugger + +"#]]); +} + +// https://github.com/foundry-rs/foundry/issues/2851 +#[forgetest_init] +fn issue_2851(prj: _, cmd: _) { + prj.add_test( + "Issue2851.t.sol", + r#" +import "forge-std/Test.sol"; + +contract Backdoor { + uint256 public number = 1; + + function backdoor(uint256 newNumber) public payable { + uint256 x = newNumber - 1; + if (x == 6912213124124531) { + number = 0; + } + } +} + +contract Issue2851Test is Test { + Backdoor back; + + function setUp() public { + back = new Backdoor(); + } + + /// forge-config: default.fuzz.seed = "111" + function invariantNotZero() public { + assertEq(back.number(), 1); + } +} +"#, + ); + + cmd.arg("test").assert_failure().stdout_eq(str![[r#" +... +Ran 1 test for test/Issue2851.t.sol:Issue2851Test +[FAIL: assertion failed: 0 != 1] +... + invariantNotZero() ([..]) +... +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) +... +"#]]); +} + +// https://github.com/foundry-rs/foundry/issues/6170 +#[forgetest_init] +fn issue_6170(prj: _, cmd: _) { + prj.add_test( + "Issue6170.t.sol", + r#" +import "forge-std/Test.sol"; + +contract Emitter { + event Values(uint256 indexed a, uint256 indexed b); + + function plsEmit(uint256 a, uint256 b) external { + emit Values(a, b); + } +} + +contract Issue6170Test is Test { + event Values(uint256 indexed a, uint256 b); + + Emitter e = new Emitter(); + + function test() public { + vm.expectEmit(true, true, false, true); + emit Values(69, 420); + e.plsEmit(69, 420); + } +} +"#, + ); + + cmd.arg("test").assert_failure().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/Issue6170.t.sol:Issue6170Test +[FAIL: Values indexed topic count mismatch: expected 1, got 2] test() ([GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/Issue6170.t.sol:Issue6170Test +[FAIL: Values indexed topic count mismatch: expected 1, got 2] test() ([GAS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test +Tip: Run `forge test --debug --match-test ` to inspect one failing test in the debugger + +"#]]); +} + +// https://github.com/foundry-rs/foundry/issues/6355 +#[forgetest_init] +fn issue_6355(prj: _, cmd: _) { + prj.add_test( + "Issue6355.t.sol", + r#" +import "forge-std/Test.sol"; + +contract Issue6355Test is Test { + uint256 snapshotId; + Target targ; + + function setUp() public { + snapshotId = vm.snapshotState(); + targ = new Target(); + } + + // this non-deterministically fails sometimes and passes sometimes + function test_shouldPass() public { + assertEq(2, targ.num()); + } + + // always fails + function test_shouldFailWithRevertToState() public { + assertEq(3, targ.num()); + vm.revertToState(snapshotId); + } + + // always fails + function test_shouldFail() public { + assertEq(3, targ.num()); + } +} + +contract Target { + function num() public pure returns (uint256) { + return 2; + } +} +"#, + ); + + cmd.arg("test").assert_failure().stdout_eq(str![[r#" +... +Ran 3 tests for test/Issue6355.t.sol:Issue6355Test +[FAIL: assertion failed: 3 != 2] test_shouldFail() ([GAS]) +[FAIL: assertion failed: 3 != 2] test_shouldFailWithRevertToState() ([GAS]) +[PASS] test_shouldPass() ([GAS]) +Suite result: FAILED. 1 passed; 2 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 2 failed, 0 skipped (3 total tests) + +Failing tests: +Encountered 2 failing tests in test/Issue6355.t.sol:Issue6355Test +[FAIL: assertion failed: 3 != 2] test_shouldFail() ([GAS]) +[FAIL: assertion failed: 3 != 2] test_shouldFailWithRevertToState() ([GAS]) + +Encountered a total of 2 failing tests, 1 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 2 failed tests +Tip: Run `forge test --debug --match-test ` to inspect one failing test in the debugger + +"#]]); +} + +// https://github.com/foundry-rs/foundry/issues/3347 +#[forgetest_init] +fn issue_3347(prj: _, cmd: _) { + prj.add_test( + "Issue3347.t.sol", + r#" +import "forge-std/Test.sol"; + +contract Issue3347Test is Test { + event log2(uint256, uint256); + + function test() public { + emit log2(1, 2); + } +} +"#, + ); + + cmd.args(["test", "-vvvv"]).assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/Issue3347.t.sol:Issue3347Test +[PASS] test() ([GAS]) +Traces: + [..] Issue3347Test::test() + ├─ emit log2(: 1, : 2) + └─ ← [Stop] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +} + +// https://github.com/foundry-rs/foundry/issues/6501 +// Make sure we decode Hardhat-style `console.log`s correctly, in both logs and traces. +#[forgetest_init] +fn issue_6501(prj: _, cmd: _) { + prj.add_test( + "Issue6501.t.sol", + r#" +import "forge-std/Test.sol"; + +contract Issue6501Test is Test { + function test_hhLogs() public { + console.log("a"); + console.log(uint256(1)); + console.log("b", uint256(2)); + } +} +"#, + ); + + cmd.args(["test", "-vvvv"]).assert_success().stdout_eq(str![[r#" +... +Ran 1 test for test/Issue6501.t.sol:Issue6501Test +[PASS] test_hhLogs() ([GAS]) +Logs: + a + 1 + b 2 + +Traces: + [..] Issue6501Test::test_hhLogs() + ├─ [0] console::log("a") [staticcall] + │ └─ ← [Stop] + ├─ [0] console::log(1) [staticcall] + │ └─ ← [Stop] + ├─ [0] console::log("b", 2) [staticcall] + │ └─ ← [Stop] + └─ ← [Stop] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +} + +// https://github.com/foundry-rs/foundry/issues/8383 +#[forgetest_init] +fn issue_8383(prj: _, cmd: _) { + prj.update_config(|config| { + config.optimizer = Some(true); + config.optimizer_runs = Some(200); + }); + prj.add_test( + "Issue8383.t.sol", + r#" +import "forge-std/Test.sol"; + +contract Issue8383Test is Test { + address internal _verifier; + + mapping(bytes32 => bool) internal _vectorTested; + mapping(bytes32 => bool) internal _vectorResult; + + function setUp() public { + _verifier = address(new P256Verifier()); + } + + function _verifyViaVerifier(bytes32 hash, uint256 r, uint256 s, uint256 x, uint256 y) internal returns (bool) { + return _verifyViaVerifier(hash, bytes32(r), bytes32(s), bytes32(x), bytes32(y)); + } + + function _verifyViaVerifier(bytes32 hash, bytes32 r, bytes32 s, bytes32 x, bytes32 y) internal returns (bool) { + bytes memory payload = abi.encode(hash, r, s, x, y); + if (uint256(y) & 0xff == 0) { + bytes memory truncatedPayload = abi.encodePacked(hash, r, s, x, bytes31(y)); + _verifierCall(truncatedPayload); + } + if (uint256(keccak256(abi.encode(payload, "1"))) & 0x1f == 0) { + uint256 r = uint256(keccak256(abi.encode(payload, "2"))); + payload = abi.encodePacked(payload, new bytes(r & 0xff)); + } + bytes32 payloadHash = keccak256(payload); + if (_vectorTested[payloadHash]) return _vectorResult[payloadHash]; + _vectorTested[payloadHash] = true; + return (_vectorResult[payloadHash] = _verifierCall(payload)); + } + + function _verifierCall(bytes memory payload) internal returns (bool) { + (bool success, bytes memory result) = _verifier.call(payload); + return abi.decode(result, (bool)); + } + + function testP256VerifyOutOfBounds() public { + vm.pauseGasMetering(); + uint256 p = 0xFFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF; + _verifyViaVerifier(bytes32(0), 1, 1, 1, 1); + _verifyViaVerifier(bytes32(0), 1, 1, 0, 1); + _verifyViaVerifier(bytes32(0), 1, 1, 1, 0); + _verifyViaVerifier(bytes32(0), 1, 1, 1, p); + _verifyViaVerifier(bytes32(0), 1, 1, p, 1); + _verifyViaVerifier(bytes32(0), 1, 1, p - 1, 1); + vm.resumeGasMetering(); + } +} + +contract P256Verifier { + uint256 private constant GX = 0x6B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296; + uint256 private constant GY = 0x4FE342E2FE1A7F9B8EE7EB4A7C0F9E162BCE33576B315ECECBB6406837BF51F5; + uint256 private constant P = 0xFFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF; // `A = P - 3`. + uint256 private constant N = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551; + uint256 private constant B = 0x5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B; + + fallback() external payable { + assembly { + // For this implementation, we will use the memory without caring about + // the free memory pointer or zero pointer. + // The slots `0x00`, `0x20`, `0x40`, `0x60`, will not be accessed for the `Points[16]` array, + // and can be used for storing other variables. + + mstore(0x40, P) // Set `0x40` to `P`. + + function jAdd(x1, y1, z1, x2, y2, z2) -> x3, y3, z3 { + if iszero(z1) { + x3 := x2 + y3 := y2 + z3 := z2 + leave + } + if iszero(z2) { + x3 := x1 + y3 := y1 + z3 := z1 + leave + } + let p := mload(0x40) + let zz1 := mulmod(z1, z1, p) + let zz2 := mulmod(z2, z2, p) + let u1 := mulmod(x1, zz2, p) + let u2 := mulmod(x2, zz1, p) + let s1 := mulmod(y1, mulmod(zz2, z2, p), p) + let s2 := mulmod(y2, mulmod(zz1, z1, p), p) + let h := addmod(u2, sub(p, u1), p) + let hh := mulmod(h, h, p) + let hhh := mulmod(h, hh, p) + let r := addmod(s2, sub(p, s1), p) + x3 := addmod(addmod(mulmod(r, r, p), sub(p, hhh), p), sub(p, mulmod(2, mulmod(u1, hh, p), p)), p) + y3 := addmod(mulmod(r, addmod(mulmod(u1, hh, p), sub(p, x3), p), p), sub(p, mulmod(s1, hhh, p)), p) + z3 := mulmod(h, mulmod(z1, z2, p), p) + } + + function setJPoint(i, x, y, z) { + // We will multiply by `0x80` (i.e. `shl(7, i)`) instead + // since the memory expansion costs are cheaper than doing `mul(0x60, i)`. + // Also help combine the lookup expression for `u1` and `u2` in `jMultShamir`. + i := shl(7, i) + mstore(i, x) + mstore(add(i, returndatasize()), y) + mstore(add(i, 0x40), z) + } + + function setJPointDouble(i, j) { + j := shl(7, j) + let x := mload(j) + let y := mload(add(j, returndatasize())) + let z := mload(add(j, 0x40)) + let p := mload(0x40) + let yy := mulmod(y, y, p) + let zz := mulmod(z, z, p) + let s := mulmod(4, mulmod(x, yy, p), p) + let m := addmod(mulmod(3, mulmod(x, x, p), p), mulmod(mload(returndatasize()), mulmod(zz, zz, p), p), p) + let x2 := addmod(mulmod(m, m, p), sub(p, mulmod(2, s, p)), p) + let y2 := addmod(mulmod(m, addmod(s, sub(p, x2), p), p), sub(p, mulmod(8, mulmod(yy, yy, p), p)), p) + let z2 := mulmod(2, mulmod(y, z, p), p) + setJPoint(i, x2, y2, z2) + } + + function setJPointAdd(i, j, k) { + j := shl(7, j) + k := shl(7, k) + let x, y, z := + jAdd( + mload(j), + mload(add(j, returndatasize())), + mload(add(j, 0x40)), + mload(k), + mload(add(k, returndatasize())), + mload(add(k, 0x40)) + ) + setJPoint(i, x, y, z) + } + + let r := calldataload(0x20) + let n := N + + { + let s := calldataload(0x40) + if lt(shr(1, n), s) { s := sub(n, s) } + + // Perform `modExp(s, N - 2, N)`. + // After which, we can abuse `returndatasize()` to get `0x20`. + mstore(0x800, 0x20) + mstore(0x820, 0x20) + mstore(0x840, 0x20) + mstore(0x860, s) + mstore(0x880, sub(n, 2)) + mstore(0x8a0, n) + + let p := mload(0x40) + mstore(0x20, xor(3, p)) // Set `0x20` to `A`. + let Qx := calldataload(0x60) + let Qy := calldataload(0x80) + + if iszero( + and( // The arguments of `and` are evaluated last to first. + and( + and(gt(calldatasize(), 0x9f), and(lt(iszero(r), lt(r, n)), lt(iszero(s), lt(s, n)))), + eq( + mulmod(Qy, Qy, p), + addmod(mulmod(addmod(mulmod(Qx, Qx, p), mload(returndatasize()), p), Qx, p), B, p) + ) + ), + and( + // We need to check that the `returndatasize` is indeed 32, + // so that we can return false if the chain does not have the modexp precompile. + eq(returndatasize(), 0x20), + staticcall(gas(), 0x05, 0x800, 0xc0, returndatasize(), 0x20) + ) + ) + ) { + // POC Note: + // Changing this to `return(0x80, 0x20)` fixes it. + // Alternatively, adding `if mload(0x8c0) { invalid() }` just before the return also fixes it. + return(0x8c0, 0x20) + } + + setJPoint(0x01, Qx, Qy, 1) + setJPoint(0x04, GX, GY, 1) + setJPointDouble(0x02, 0x01) + setJPointDouble(0x08, 0x04) + setJPointAdd(0x03, 0x01, 0x02) + setJPointAdd(0x05, 0x01, 0x04) + setJPointAdd(0x06, 0x02, 0x04) + setJPointAdd(0x07, 0x03, 0x04) + setJPointAdd(0x09, 0x01, 0x08) + setJPointAdd(0x0a, 0x02, 0x08) + setJPointAdd(0x0b, 0x03, 0x08) + setJPointAdd(0x0c, 0x04, 0x08) + setJPointAdd(0x0d, 0x01, 0x0c) + setJPointAdd(0x0e, 0x02, 0x0c) + setJPointAdd(0x0f, 0x03, 0x0c) + } + + let i := 0 + let u1 := mulmod(calldataload(0x00), mload(0x00), n) + let u2 := mulmod(r, mload(0x00), n) + let y := 0 + let z := 0 + let x := 0 + let p := mload(0x40) + for {} 1 {} { + if z { + let yy := mulmod(y, y, p) + let zz := mulmod(z, z, p) + let s := mulmod(4, mulmod(x, yy, p), p) + let m := + addmod(mulmod(3, mulmod(x, x, p), p), mulmod(mload(returndatasize()), mulmod(zz, zz, p), p), p) + let x2 := addmod(mulmod(m, m, p), sub(p, mulmod(2, s, p)), p) + let y2 := addmod(mulmod(m, addmod(s, sub(p, x2), p), p), sub(p, mulmod(8, mulmod(yy, yy, p), p)), p) + let z2 := mulmod(2, mulmod(y, z, p), p) + yy := mulmod(y2, y2, p) + zz := mulmod(z2, z2, p) + s := mulmod(4, mulmod(x2, yy, p), p) + m := addmod( + mulmod(3, mulmod(x2, x2, p), p), + mulmod(mload(returndatasize()), mulmod(zz, zz, p), p), + p + ) + x := addmod(mulmod(m, m, p), sub(p, mulmod(2, s, p)), p) + z := mulmod(2, mulmod(y2, z2, p), p) + y := addmod(mulmod(m, addmod(s, sub(p, x), p), p), sub(p, mulmod(8, mulmod(yy, yy, p), p)), p) + } + for { let o := or(and(shr(245, shl(i, u1)), 0x600), and(shr(247, shl(i, u2)), 0x180)) } o {} { + let z2 := mload(add(o, 0x40)) + if iszero(z2) { break } + if iszero(z) { + x := mload(o) + y := mload(add(o, returndatasize())) + z := z2 + break + } + let zz1 := mulmod(z, z, p) + let zz2 := mulmod(z2, z2, p) + let u1_ := mulmod(x, zz2, p) + let s1 := mulmod(y, mulmod(zz2, z2, p), p) + let h := addmod(mulmod(mload(o), zz1, p), sub(p, u1_), p) + let hh := mulmod(h, h, p) + let hhh := mulmod(h, hh, p) + let r_ := addmod(mulmod(mload(add(o, returndatasize())), mulmod(zz1, z, p), p), sub(p, s1), p) + x := addmod(addmod(mulmod(r_, r_, p), sub(p, hhh), p), sub(p, mulmod(2, mulmod(u1_, hh, p), p)), p) + y := addmod(mulmod(r_, addmod(mulmod(u1_, hh, p), sub(p, x), p), p), sub(p, mulmod(s1, hhh, p)), p) + z := mulmod(h, mulmod(z, z2, p), p) + break + } + // Just unroll twice. Fully unrolling will only save around 1% to 2% gas, but make the + // bytecode very bloated, which may incur more runtime costs after Verkle. + // See: https://notes.ethereum.org/%40vbuterin/verkle_tree_eip + // It's very unlikely that Verkle will come before the P256 precompile. But who knows? + if z { + let yy := mulmod(y, y, p) + let zz := mulmod(z, z, p) + let s := mulmod(4, mulmod(x, yy, p), p) + let m := + addmod(mulmod(3, mulmod(x, x, p), p), mulmod(mload(returndatasize()), mulmod(zz, zz, p), p), p) + let x2 := addmod(mulmod(m, m, p), sub(p, mulmod(2, s, p)), p) + let y2 := addmod(mulmod(m, addmod(s, sub(p, x2), p), p), sub(p, mulmod(8, mulmod(yy, yy, p), p)), p) + let z2 := mulmod(2, mulmod(y, z, p), p) + yy := mulmod(y2, y2, p) + zz := mulmod(z2, z2, p) + s := mulmod(4, mulmod(x2, yy, p), p) + m := addmod( + mulmod(3, mulmod(x2, x2, p), p), + mulmod(mload(returndatasize()), mulmod(zz, zz, p), p), + p + ) + x := addmod(mulmod(m, m, p), sub(p, mulmod(2, s, p)), p) + z := mulmod(2, mulmod(y2, z2, p), p) + y := addmod(mulmod(m, addmod(s, sub(p, x), p), p), sub(p, mulmod(8, mulmod(yy, yy, p), p)), p) + } + for { let o := or(and(shr(243, shl(i, u1)), 0x600), and(shr(245, shl(i, u2)), 0x180)) } o {} { + let z2 := mload(add(o, 0x40)) + if iszero(z2) { break } + if iszero(z) { + x := mload(o) + y := mload(add(o, returndatasize())) + z := z2 + break + } + let zz1 := mulmod(z, z, p) + let zz2 := mulmod(z2, z2, p) + let u1_ := mulmod(x, zz2, p) + let s1 := mulmod(y, mulmod(zz2, z2, p), p) + let h := addmod(mulmod(mload(o), zz1, p), sub(p, u1_), p) + let hh := mulmod(h, h, p) + let hhh := mulmod(h, hh, p) + let r_ := addmod(mulmod(mload(add(o, returndatasize())), mulmod(zz1, z, p), p), sub(p, s1), p) + x := addmod(addmod(mulmod(r_, r_, p), sub(p, hhh), p), sub(p, mulmod(2, mulmod(u1_, hh, p), p)), p) + y := addmod(mulmod(r_, addmod(mulmod(u1_, hh, p), sub(p, x), p), p), sub(p, mulmod(s1, hhh, p)), p) + z := mulmod(h, mulmod(z, z2, p), p) + break + } + i := add(i, 4) + if eq(i, 256) { break } + } + + if iszero(z) { + mstore(returndatasize(), iszero(r)) + return(returndatasize(), 0x20) + } + + // Perform `modExp(z, P - 2, P)`. + // `0x800`, `0x820, `0x840` are still set to `0x20`. + mstore(0x860, z) + mstore(0x880, sub(p, 2)) + mstore(0x8a0, p) + + mstore( + returndatasize(), + and( // The arguments of `and` are evaluated last to first. + eq(mod(mulmod(x, mulmod(mload(returndatasize()), mload(returndatasize()), p), p), n), r), + staticcall(gas(), 0x05, 0x800, 0xc0, returndatasize(), returndatasize()) + ) + ) + return(returndatasize(), returndatasize()) + } + } +} +"#, + ); + + cmd.arg("test").with_no_redact().assert_success().stdout_eq(str![[r#" +... +Ran 1 test for test/Issue8383.t.sol:Issue8383Test +[PASS] testP256VerifyOutOfBounds() (gas: 3139) +... +"#]]); +} + +// https://github.com/foundry-rs/foundry/issues/9272 +#[forgetest_init] +fn issue_9272(prj: _, cmd: _) { + prj.update_config(|config| { + config.allow_paths.push("..".into()); + }); + + prj.add_source( + "Contract.sol", + r#" +pragma solidity ^0.8.0; +import '../Missing.sol'; +contract Contract {} +"#, + ); + + // We expect a compilation error due to the missing import + cmd.arg("build").assert_failure().stderr_eq(str![[r#" +Error: Compiler run failed: +Error (6275): Source "Missing.sol" not found: File not found. Searched the following locations: "[..]". +ParserError: Source "Missing.sol" not found: File not found. Searched the following locations: "[..]". + [FILE]:4:1: + | +4 | import '../Missing.sol'; + | ^^^^^^^^^^^^^^^^^^^^^^^^ + +"#]]); +} + +// https://github.com/foundry-rs/foundry/issues/10463 +#[forgetest_init] +fn issue_10463(prj: _, cmd: _) { + prj.add_test( + "Issue10463.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract Issue10463Test is Test { + event Foo(); + + error CustomError(uint256 code); + + function revertingBefore(bool shouldRevert) external { + if (shouldRevert) revert(); + emit Foo(); + } + + function revertingWithReason() external pure { + revert("revert reason"); + } + + function revertingWithCustomError() external pure { + revert CustomError(42); + } + + function testExpectEmitPreservesRevertWhenCallRevertsBeforeLog() public { + vm.expectEmit(); + emit Foo(); + + this.revertingBefore(true); + } + + function testExpectEmitPreservesRevertReason() public { + vm.expectEmit(); + emit Foo(); + + this.revertingWithReason(); + } + + function testExpectEmitPreservesCustomError() public { + vm.expectEmit(); + emit Foo(); + + this.revertingWithCustomError(); + } +} +"#, + ); + + cmd.arg("test").assert_failure().stdout_eq(str![[r#" +... +Ran 3 tests for test/Issue10463.t.sol:Issue10463Test +[FAIL: CustomError(42)] testExpectEmitPreservesCustomError() ([GAS]) +[FAIL: revert reason] testExpectEmitPreservesRevertReason() ([GAS]) +[FAIL: EvmError: Revert] testExpectEmitPreservesRevertWhenCallRevertsBeforeLog() ([GAS]) +Suite result: FAILED. 0 passed; 3 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +// https://github.com/foundry-rs/foundry/issues/12803 +// Test gas underflow prevention on Cancun (no EIP-7702 gas floor) +#[forgetest_init] +fn issue_12803_cancun(prj: _, cmd: _) { + prj.add_test( + "Issue12803.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract Issue12803Test is Test { + uint a; + function test_negativeGas() public { + vm.pauseGasMetering(); + a = 100; + vm.resumeGasMetering(); + delete a; + } +} +"#, + ); + + cmd.args(["test", "--evm-version=cancun"]).with_no_redact().assert_success().stdout_eq(str![[ + r#" +... +Ran 1 test for test/Issue12803.t.sol:Issue12803Test +[PASS] test_negativeGas() (gas: 0) +... +"# + ]]); +} + +// https://github.com/foundry-rs/foundry/issues/12803 +// Test gas underflow prevention on Shanghai (also no EIP-7702 gas floor) +#[forgetest_init] +fn issue_12803_shanghai(prj: _, cmd: _) { + prj.add_test( + "Issue12803.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract Issue12803Test is Test { + uint a; + function test_negativeGas() public { + vm.pauseGasMetering(); + a = 100; + vm.resumeGasMetering(); + delete a; + } +} +"#, + ); + + cmd.args(["test", "--evm-version=shanghai"]).with_no_redact().assert_success().stdout_eq(str![ + [r#" +... +Ran 1 test for test/Issue12803.t.sol:Issue12803Test +[PASS] test_negativeGas() (gas: 0) +... +"#] + ]); +} + +// https://github.com/foundry-rs/foundry/issues/13766 +// vm.expectRevert(bytes("")) should not panic when actual revert has data. +// https://github.com/foundry-rs/foundry/issues/15545 +// An expected reason shorter than 4 bytes (e.g. bytes("C38")) must not panic +// when it cannot be decoded as an `Error(string)`; it should report a mismatch. +#[forgetest_init] +fn issue_13766(prj: _, cmd: _) { + prj.add_test( + "Issue13766.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract Reverter { + error CustomError(); + function revertWithData() public pure { revert CustomError(); } + function revertWithMessage(string memory message) public pure { revert(message); } +} + +contract Issue13766Test is Test { + function test_expectRevertEmptyBytes() public { + Reverter r = new Reverter(); + vm.expectRevert(bytes("")); + r.revertWithData(); + } + + function test_expectRevertShortReason() public { + Reverter r = new Reverter(); + vm.expectRevert(bytes("C38")); + r.revertWithMessage("some other message"); + } +} +"#, + ); + + cmd.arg("test").assert_failure().stdout_eq(str![[r#" +... +[FAIL: Error != expected error: CustomError() != EvmError: Revert] test_expectRevertEmptyBytes() ([GAS]) +... +[FAIL: Error != expected error: some other message != C38] test_expectRevertShortReason() ([GAS]) +... +"#]]); +} + +// https://github.com/foundry-rs/foundry/issues/12803 +// Test multiple storage deletions (higher refund) don't cause underflow +#[forgetest_init] +fn issue_12803_multiple_deletes(prj: _, cmd: _) { + prj.add_test( + "Issue12803Multi.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract Issue12803MultiTest is Test { + uint a; + uint b; + uint c; + function test_multipleDeletes() public { + vm.pauseGasMetering(); + a = 100; + b = 200; + c = 300; + vm.resumeGasMetering(); + delete a; + delete b; + delete c; + } +} +"#, + ); + + cmd.args(["test", "--evm-version=cancun"]).with_no_redact().assert_success().stdout_eq(str![[ + r#" +... +Ran 1 test for test/Issue12803Multi.t.sol:Issue12803MultiTest +[PASS] test_multipleDeletes() (gas: 0) +... +"# + ]]); +} + +#[forgetest] +async fn revert_in_memory_snapshot_clears_active_fork(prj: _, cmd: _) { + let (_api, handle) = spawn(NodeConfig::test().with_chain_id(Some(4242u64))).await; + let rpc = handle.http_endpoint(); + + prj.add_test( + "RevertInMemorySnapshot.t.sol", + &r#" +interface Vm { + function activeFork() external view returns (uint256); + function chainId(uint256) external; + function createSelectFork(string calldata) external returns (uint256); + function revertToState(uint256) external returns (bool); + function selectFork(uint256) external; + function snapshotState() external returns (uint256); +} + +contract RevertInMemorySnapshotTest { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + address constant ANVIL_DEFAULT_ACCOUNT = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266; + + function test_revertInMemorySnapshotClearsActiveFork() public { + vm.chainId(7777); + uint256 snapshotId = vm.snapshotState(); + + uint256 forkId = vm.createSelectFork(""); + require(vm.activeFork() == forkId, "fork was not selected"); + require(block.chainid == 4242, "fork chain ID was not installed"); + require(ANVIL_DEFAULT_ACCOUNT.balance > 0, "fork account was not funded"); + + require(vm.revertToState(snapshotId), "snapshot revert failed"); + require(block.chainid == 7777, "local chain ID was not restored"); + (bool hasActiveFork,) = address(vm).call(abi.encodeWithSignature("activeFork()")); + require(!hasActiveFork, "fork remained active"); + require(ANVIL_DEFAULT_ACCOUNT.balance == 0, "read still used fork database"); + + vm.selectFork(forkId); + require(vm.activeFork() == forkId, "fork could not be reselected"); + require(ANVIL_DEFAULT_ACCOUNT.balance > 0, "reselected fork lost state"); + } +} +"# + .replace("", &rpc), + ); + + cmd.arg("test").assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/RevertInMemorySnapshot.t.sol:RevertInMemorySnapshotTest +[PASS] test_revertInMemorySnapshotClearsActiveFork() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +} + +// Regression: `revertToState` / `revertToStateAndDelete` taken before any +// `vm.blobhashes` override must exercise the `None` arm of +// `sync_tx_after_env_override_restore` (pre_override_blob_hashes path). +// The snapshot is taken while env_overrides.blob_hashes is None; after the +// override is applied and reverted the hashes must return to empty. +// +// NOTE: Testing restoration of *non-empty* native blob hashes (EIP-4844 fork +// mode where tx.blob_hashes is non-empty without a cheatcode) is not reachable +// from Solidity. +#[forgetest_init] +fn issue_blobhashes_pre_override_snapshot(prj: _, cmd: _) { + prj.add_test( + "BlobhashesPreOverride.t.sol", + r#" +import "forge-std/Test.sol"; + +contract BlobhashesPreOverrideSnapshotTest is Test { + function test_blobhashes_none_arm_revertToState() public { + assertEq(vm.getBlobhashes().length, 0, "no hashes before override"); + + uint256 id = vm.snapshotState(); + + bytes32[] memory h = new bytes32[](2); + h[0] = bytes32(uint256(0xAABB)); + h[1] = bytes32(uint256(0xCCDD)); + vm.blobhashes(h); + assertEq(vm.getBlobhashes().length, 2, "override visible"); + + vm.revertToState(id); + assertEq(vm.getBlobhashes().length, 0, "None arm: hashes cleared after revert"); + } + + function test_blobhashes_none_arm_revertToStateAndDelete() public { + assertEq(vm.getBlobhashes().length, 0, "no hashes before override"); + + uint256 id = vm.snapshotState(); + + bytes32[] memory h = new bytes32[](1); + h[0] = bytes32(uint256(0xDEAD)); + vm.blobhashes(h); + assertEq(vm.getBlobhashes().length, 1, "override visible"); + + vm.revertToStateAndDelete(id); + assertEq(vm.getBlobhashes().length, 0, "None arm: hashes cleared after revertAndDelete"); + } +} +"#, + ); + + cmd.args(["test", "--evm-version=cancun"]).assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 2 tests for test/BlobhashesPreOverride.t.sol:BlobhashesPreOverrideSnapshotTest +[PASS] test_blobhashes_none_arm_revertToState() ([GAS]) +[PASS] test_blobhashes_none_arm_revertToStateAndDelete() ([GAS]) +Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 2 tests passed, 0 failed, 0 skipped (2 total tests) + +"#]]); +} + +// Regression: `revertToState` taken before `vm.txGasPrice` must restore the +// configured pre override gas price, not zero. +#[forgetest_init] +fn issue_txgasprice_pre_override_snapshot(prj: _, cmd: _) { + prj.update_config(|config| { + config.gas_price = Some(10_000_000_000); // 10 gwei + }); + prj.add_test( + "TxGasPricePreOverride.t.sol", + r#" +import "forge-std/Test.sol"; + +contract TxGasPricePreOverrideSnapshotTest is Test { + function test_pre_override_gas_price_restored_after_revert() public { + uint256 pre_override = tx.gasprice; + assertEq(pre_override, 10 gwei, "pre override should be 10 gwei from config"); + + uint256 id = vm.snapshotState(); + vm.txGasPrice(222 gwei); + assertEq(tx.gasprice, 222 gwei, "override should be visible"); + + vm.revertToState(id); + assertEq(tx.gasprice, pre_override, "should restore pre override gas price after revert"); + } + + function test_pre_override_gas_price_restored_after_revertAndDelete() public { + uint256 pre_override = tx.gasprice; + assertEq(pre_override, 10 gwei, "pre override should be 10 gwei from config"); + + uint256 id = vm.snapshotState(); + vm.txGasPrice(333 gwei); + assertEq(tx.gasprice, 333 gwei, "override should be visible"); + + vm.revertToStateAndDelete(id); + assertEq(tx.gasprice, pre_override, "should restore pre override gas price after revertAndDelete"); + } +} +"#, + ); + + cmd.args(["test", "--evm-version=cancun"]).assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 2 tests for test/TxGasPricePreOverride.t.sol:TxGasPricePreOverrideSnapshotTest +[PASS] test_pre_override_gas_price_restored_after_revert() ([GAS]) +[PASS] test_pre_override_gas_price_restored_after_revertAndDelete() ([GAS]) +Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 2 tests passed, 0 failed, 0 skipped (2 total tests) + +"#]]); +} + +// https://github.com/foundry-rs/foundry/issues/16197 +#[forgetest_init] +fn issue_16197(prj: _, cmd: _) { + prj.add_test( + "Issue16197.t.sol", + r#" +import "forge-std/Test.sol"; + +contract Deployment { + function ping() external pure { + revert("deployment probe"); + } +} + +// Mirrors the shape of the issue: an inherited base `setUp` performs substantial setup work +// whose internals catch a revert before the test's own `setUp` calls `vm.skip`. +contract CommonBase is Test { + Deployment internal deployment; + + function setUp() public virtual { + deployment = new Deployment(); + (bool success,) = address(deployment).call(abi.encodeWithSignature("ping()")); + require(!success, "probe call should revert"); + } +} + +contract Issue16197Test is CommonBase { + function setUp() public override { + super.setUp(); + vm.skip(true, "probe after super"); + } + + function test_probe_succeeds() public pure {} +} + "#, + ); + + cmd.args(["test", "--mc", "Issue16197Test"]).assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/Issue16197.t.sol:Issue16197Test +[SKIP: skipped: probe after super] setUp() ([GAS]) +Suite result: ok. 0 passed; 0 failed; 1 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 0 failed, 1 skipped (1 total tests) + +"#]]); +} diff --git a/crates/forge/tests/cli/test_cmd/showmap.rs b/crates/forge/tests/cli/test_cmd/showmap.rs new file mode 100644 index 0000000000000..2bd964981278d --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/showmap.rs @@ -0,0 +1,471 @@ +//! Tests for AFL-`afl-showmap`-style corpus replay (`forge test --showmap-out`). + +use std::collections::{BTreeMap, BTreeSet}; + +use foundry_test_utils::str; + +// Locate a per-test approach dir by suffix (suite/test ids include project-dependent paths). +fn find_approach_dir(out: &std::path::Path, suffix: &str) -> std::path::PathBuf { + std::fs::read_dir(out) + .unwrap_or_else(|e| panic!("read_dir {}: {e}", out.display())) + .filter_map(|e| e.ok().map(|e| e.path())) + .find(|p| { + p.is_dir() + && p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.ends_with(suffix)) + }) + .unwrap_or_else(|| panic!("no dir ending with {suffix} in {}", out.display())) +} + +fn write_stateless_corpus_entry(corpus: &std::path::Path, name: &str, calldata: &str) { + std::fs::write( + corpus.join(name), + format!( + r#"[ +{{ + "sender":"0x0000000000000000000000000000000000000001", + "target":"0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496", + "calldata":"{calldata}", + "value":"0x0" +}} +]"# + ), + ) + .unwrap(); +} + +fn showmap_counts(path: &std::path::Path) -> BTreeMap { + std::fs::read_to_string(path) + .unwrap_or_else(|e| panic!("read {}: {e}", path.display())) + .lines() + .map(|line| { + let (id, count) = line.split_once(':').expect("missing colon"); + (id.to_owned(), count.parse().expect("count parses")) + }) + .collect() +} + +// Generate a corpus by running an invariant + fuzz test, then replay it via +// `--showmap-out` and verify that showmap files are produced under the +// expected `____/.txt` layout with hex-prefixed IDs. +#[forgetest_init] +fn showmap_replay_emits_files(prj: _, cmd: _) { + prj.initialize_default_contracts(); + prj.update_config(|config| { + config.invariant.runs = 5; + config.invariant.depth = 5; + config.invariant.corpus.corpus_dir = Some("invariant_corpus".into()); + + config.fuzz.runs = 5; + config.fuzz.corpus.corpus_dir = Some("fuzz_corpus".into()); + }); + prj.add_test( + "ShowmapCounter.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {Counter} from "../src/Counter.sol"; + +contract ShowmapCounterTest is Test { + Counter public counter; + + function setUp() public { + counter = new Counter(); + counter.setNumber(0); + } + + function testFuzz_SetNumber(uint256 x) public { + counter.setNumber(x); + assertEq(counter.number(), x); + } + + function invariant_counter_called() public view {} +} + "#, + ); + + // Phase 1: build a corpus. + cmd.args(["test", "--mc", "ShowmapCounterTest"]).assert_success(); + + // Phase 2: replay it through showmap with an explicit trial id. + cmd.forge_fuse() + .args([ + "test", + "--mc", + "ShowmapCounterTest", + "--showmap-out", + "showmap_out", + "--showmap-approach", + "replay", + "--showmap-trial", + "t1", + ]) + .assert_success(); + + // Verify files were produced. Both fuzz and invariant tests get a per-(anchor) + // function approach dir so contracts with multiple campaigns don't collide. + let out = prj.root().join("showmap_out"); + let invariant_dir = find_approach_dir(&out, "ShowmapCounterTest__invariant_counter_called"); + let fuzz_dir = find_approach_dir(&out, "ShowmapCounterTest__testFuzz_SetNumber"); + let invariant_file = invariant_dir.join("t1.txt"); + let fuzz_file = fuzz_dir.join("t1.txt"); + assert!(invariant_file.exists(), "missing {}", invariant_file.display()); + assert!(fuzz_file.exists(), "missing {}", fuzz_file.display()); + + // Sanity-check format: every line is `evm__:` with count > 0. + for f in [&invariant_file, &fuzz_file] { + let body = std::fs::read_to_string(f).unwrap(); + assert!(!body.is_empty(), "{} is empty", f.display()); + for line in body.lines() { + let (id, count) = line.split_once(':').expect("missing colon"); + assert!(id.starts_with("evm_"), "unexpected id in {}: {line}", f.display()); + // expect three underscore-separated parts: prefix, hash16, pc + let parts: Vec<_> = id.splitn(3, '_').collect(); + assert_eq!(parts.len(), 3, "malformed id in {}: {line}", f.display()); + assert_eq!( + parts[1].len(), + 16, + "hash prefix should be 16 hex chars in {}: {line}", + f.display() + ); + let n: u64 = count.parse().expect("count parses"); + assert!(n > 0, "zero count in {}: {line}", f.display()); + } + } + + // Showmap mode reports replay results, not regular test results. + cmd.forge_fuse() + .args(["test", "--mc", "ShowmapCounterTest", "--showmap-out", "showmap_out2"]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 2 tests for test/ShowmapCounter.t.sol:ShowmapCounterTest +[PASS] invariant_counter_called() (replay: [..] entries, [..] files) +[PASS] testFuzz_SetNumber(uint256) (replay: [..] entries, [..] files) +Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 2 tests passed, 0 failed, 0 skipped (2 total tests) + +"#]]); +} + +// Per-input mode emits one file per corpus entry. +#[forgetest_init] +fn showmap_replay_per_input_emits_one_file_per_entry(prj: _, cmd: _) { + prj.initialize_default_contracts(); + prj.update_config(|config| { + config.invariant.runs = 5; + config.invariant.depth = 5; + config.invariant.corpus.corpus_dir = Some("invariant_corpus".into()); + }); + prj.add_test( + "ShowmapCounter.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {Counter} from "../src/Counter.sol"; + +contract ShowmapCounterTest is Test { + Counter public counter; + + function setUp() public { + counter = new Counter(); + } + + function invariant_counter_called() public view {} +} + "#, + ); + + cmd.args(["test", "--mc", "ShowmapCounterTest"]).assert_success(); + + cmd.forge_fuse() + .args([ + "test", + "--mc", + "ShowmapCounterTest", + "--showmap-out", + "showmap_out", + "--showmap-per-input", + ]) + .assert_success(); + + // Per-input mode writes one file per corpus entry inside the test's approach dir. + let out = prj.root().join("showmap_out"); + let approach_dir = find_approach_dir(&out, "ShowmapCounterTest__invariant_counter_called"); + let entries: Vec<_> = std::fs::read_dir(&approach_dir) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("txt")) + .collect(); + assert!(!entries.is_empty(), "expected per-entry files in {}", approach_dir.display()); +} + +#[forgetest_init] +fn showmap_replay_merges_unsynced_stateless_worker_corpora(prj: _, cmd: _) { + const WORKERS: usize = 3; + + prj.add_test( + "ShowmapParallelWorkers.t.sol", + r#" +contract ShowmapParallelWorkersTest { + uint256 public value; + + function testFuzz_value(uint256 x) public { + if (x == 1) { + value = 1; + } else if (x == 2) { + value = 2; + } else { + value = 3; + } + } +} + "#, + ); + cmd.args(["build", "-q"]).assert_success(); + + let test_dir = prj + .root() + .join("parallel_corpus") + .join("ShowmapParallelWorkersTest") + .join("testFuzz_value"); + // testFuzz_value(uint256) + let selector = "0x4c39e060"; + for value in 1..=WORKERS { + let worker = test_dir.join(format!("worker{}", value - 1)).join("corpus"); + std::fs::create_dir_all(&worker).unwrap(); + write_stateless_corpus_entry( + &worker, + &format!("00000000-0000-0000-0000-{value:012}-1.json"), + &format!("{selector}{value:064x}"), + ); + } + + let result = cmd + .forge_fuse() + .args([ + "test", + "--mc", + "ShowmapParallelWorkersTest", + "--mt", + "testFuzz_value", + "--showmap-out", + "showmap_out", + "--showmap-corpus-dir", + "parallel_corpus", + "--showmap-trial", + "per-input", + "--showmap-per-input", + ]) + .assert_success(); + let stdout = String::from_utf8(result.get_output().stdout.clone()).unwrap(); + assert!( + stdout.contains(&format!( + "[PASS] testFuzz_value(uint256) (replay: {WORKERS} entries, {WORKERS} files)" + )), + "{stdout}" + ); + + let out = prj.root().join("showmap_out"); + let approach_dir = find_approach_dir(&out, "ShowmapParallelWorkersTest__testFuzz_value"); + let per_input_files: Vec<_> = std::fs::read_dir(&approach_dir) + .unwrap() + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .filter(|path| path.extension().and_then(|extension| extension.to_str()) == Some("txt")) + .collect(); + assert_eq!(per_input_files.len(), WORKERS, "unexpected files in {}", approach_dir.display()); + let per_input_counts: Vec<_> = + per_input_files.iter().map(|path| showmap_counts(path)).collect(); + assert!( + per_input_counts.iter().all(|counts| !counts.is_empty()), + "expected non-empty per-input coverage in {}", + approach_dir.display() + ); + let union: BTreeSet<_> = + per_input_counts.iter().flat_map(|counts| counts.keys().cloned()).collect(); + assert!( + per_input_counts.iter().any(|counts| counts.len() < union.len()), + "worker inputs should contribute distinct coverage: {per_input_counts:?}" + ); + let mut merged_counts = BTreeMap::new(); + for counts in per_input_counts { + for (id, count) in counts { + *merged_counts.entry(id).or_insert(0) += count; + } + } + + let result = cmd + .forge_fuse() + .args([ + "test", + "--mc", + "ShowmapParallelWorkersTest", + "--mt", + "testFuzz_value", + "--showmap-out", + "showmap_out", + "--showmap-corpus-dir", + "parallel_corpus", + "--showmap-trial", + "workers", + ]) + .assert_success(); + let stdout = String::from_utf8(result.get_output().stdout.clone()).unwrap(); + assert!( + stdout.contains(&format!( + "[PASS] testFuzz_value(uint256) (replay: {WORKERS} entries, 1 files)" + )), + "{stdout}" + ); + + let aggregate_file = approach_dir.join("workers.txt"); + assert!(aggregate_file.exists(), "missing {}", aggregate_file.display()); + let aggregate_counts = showmap_counts(&aggregate_file); + assert_eq!( + aggregate_counts, merged_counts, + "aggregate showmap should contain the summed worker coverage" + ); +} + +// Reruns with distinct `--showmap-trial` values must accumulate side-by-side +// instead of overwriting each other. +#[forgetest_init] +fn showmap_replay_distinct_trials_accumulate(prj: _, cmd: _) { + prj.initialize_default_contracts(); + prj.update_config(|config| { + config.invariant.runs = 5; + config.invariant.depth = 5; + config.invariant.corpus.corpus_dir = Some("invariant_corpus".into()); + }); + prj.add_test( + "ShowmapCounter.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {Counter} from "../src/Counter.sol"; + +contract ShowmapCounterTest is Test { + Counter public counter; + + function setUp() public { + counter = new Counter(); + } + + function invariant_counter_called() public view {} +} + "#, + ); + + // Build a corpus first. + cmd.args(["test", "--mc", "ShowmapCounterTest"]).assert_success(); + + // Two reruns with distinct trial ids must produce two distinct files. + for trial in ["t1", "t2"] { + cmd.forge_fuse() + .args([ + "test", + "--mc", + "ShowmapCounterTest", + "--showmap-out", + "showmap_out", + "--showmap-trial", + trial, + ]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/ShowmapCounter.t.sol:ShowmapCounterTest +[PASS] invariant_counter_called() (replay: 1 entries, 1 files) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); + } + + // Distinct trials become side-by-side files inside the same per-test approach dir. + let out = prj.root().join("showmap_out"); + let approach_dir = find_approach_dir(&out, "ShowmapCounterTest__invariant_counter_called"); + let t1 = approach_dir.join("t1.txt"); + let t2 = approach_dir.join("t2.txt"); + assert!(t1.exists(), "missing trial 1 file {}", t1.display()); + assert!(t2.exists(), "missing trial 2 file {}", t2.display()); + + let before = std::fs::read_to_string(&t1).unwrap(); + let retry = cmd + .forge_fuse() + .args([ + "test", + "--mc", + "ShowmapCounterTest", + "--showmap-out", + "showmap_out", + "--showmap-trial", + "t1", + ]) + .assert_failure(); + let stdout = String::from_utf8(retry.get_output().stdout.clone()).unwrap(); + assert!(stdout.contains("pick a different --showmap-trial"), "{stdout}"); + assert_eq!(std::fs::read_to_string(&t1).unwrap(), before); +} + +#[forgetest_init] +fn showmap_replay_rejects_path_component_names(prj: _, cmd: _) { + prj.add_test( + "ShowmapCounter.t.sol", + r#" +contract ShowmapCounterTest { + function invariant_counter_called() public view {} +} + "#, + ); + + for args in [ + vec![ + "test", + "--mc", + "ShowmapCounterTest", + "--showmap-out", + "showmap_out", + "--showmap-approach", + "../outside", + ], + vec![ + "test", + "--mc", + "ShowmapCounterTest", + "--showmap-out", + "showmap_out", + "--showmap-trial", + "../../victim", + ], + ] { + let result = cmd.forge_fuse().args(args).assert_failure(); + let stderr = String::from_utf8(result.get_output().stderr.clone()).unwrap(); + assert!(stderr.contains("expected a single file-name component"), "{stderr}"); + } +} + +#[forgetest_init] +fn showmap_replay_rejects_empty_corpus_dir(prj: _, cmd: _) { + prj.add_test( + "ShowmapCounter.t.sol", + r#" +contract ShowmapCounterTest { + function testFuzz_value(uint256 value) public pure { + value; + } +} + "#, + ); + std::fs::create_dir_all(prj.root().join("empty_corpus")).unwrap(); + + let result = cmd + .args([ + "test", + "--mc", + "ShowmapCounterTest", + "--showmap-out", + "showmap_out", + "--showmap-corpus-dir", + "empty_corpus", + ]) + .assert_failure(); + let stdout = String::from_utf8(result.get_output().stdout.clone()).unwrap(); + assert!(stdout.contains("corpus directory not found: empty_corpus"), "{stdout}"); +} diff --git a/crates/forge/tests/cli/test_cmd/spec.rs b/crates/forge/tests/cli/test_cmd/spec.rs new file mode 100644 index 0000000000000..9cafb23d1ccb4 --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/spec.rs @@ -0,0 +1,2341 @@ +use foundry_compilers::artifacts::EvmVersion; +use foundry_evm::hardforks::{FoundryHardfork, TempoHardfork}; +use foundry_test_utils::{rpc, util::OTHER_SOLC_VERSION}; + +#[cfg(feature = "monad")] +use alloy_primitives::{Address, B256}; +#[cfg(feature = "monad")] +use alloy_rpc_types::TransactionRequest; + +#[cfg(feature = "monad")] +async fn rpc_request(endpoint: &str, method: &str, params: serde_json::Value) -> serde_json::Value { + reqwest::Client::new() + .post(endpoint) + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + })) + .send() + .await + .unwrap() + .json() + .await + .unwrap() +} + +#[cfg(feature = "monad")] +fn monad_staking_reward_input(block_author: alloy_primitives::Address) -> Vec { + let mut input = alloy_primitives::keccak256("syscallReward(address)")[..4].to_vec(); + input.extend_from_slice(&[0u8; 12]); + input.extend_from_slice(block_author.as_slice()); + input +} + +#[cfg(feature = "monad")] +fn monad_staking_validator_id_key(address: alloy_primitives::Address) -> alloy_primitives::U256 { + let mut key = [0u8; 32]; + key[0] = 0x06; + key[1..21].copy_from_slice(address.as_slice()); + alloy_primitives::U256::from_be_bytes(key) +} + +#[cfg(feature = "monad")] +fn monad_staking_validator_key( + namespace: u8, + validator_id: u64, + offset: u8, +) -> alloy_primitives::U256 { + let mut key = [0u8; 32]; + key[0] = namespace; + key[1..9].copy_from_slice(&validator_id.to_be_bytes()); + alloy_primitives::U256::from_be_bytes(key) + alloy_primitives::U256::from(offset) +} + +#[cfg(feature = "monad")] +fn left_aligned_u64(value: u64) -> alloy_primitives::U256 { + let mut bytes = [0u8; 32]; + bytes[..8].copy_from_slice(&value.to_be_bytes()); + alloy_primitives::U256::from_be_bytes(bytes) +} + +#[cfg(feature = "monad")] +fn address_and_flags(address: alloy_primitives::Address, flags: u64) -> alloy_primitives::U256 { + let mut bytes = [0u8; 32]; + bytes[..20].copy_from_slice(address.as_slice()); + bytes[20..28].copy_from_slice(&flags.to_be_bytes()); + alloy_primitives::U256::from_be_bytes(bytes) +} + +#[cfg(feature = "monad")] +fn override_rpc_transaction_chain_id(value: &mut serde_json::Value, target: &str, chain_id: &str) { + match value { + serde_json::Value::Array(values) => { + for value in values { + override_rpc_transaction_chain_id(value, target, chain_id); + } + } + serde_json::Value::Object(object) => { + if object.get("hash").and_then(serde_json::Value::as_str) == Some(target) { + object + .insert("chainId".to_string(), serde_json::Value::String(chain_id.to_string())); + } + for value in object.values_mut() { + override_rpc_transaction_chain_id(value, target, chain_id); + } + } + _ => {} + } +} + +// Test evm version switch during tests / scripts. +// +// +#[forgetest_init] +fn test_set_evm_version(prj: _, cmd: _) { + let endpoint = rpc::next_http_archive_rpc_url(); + prj.add_test( + "TestEvmVersion.t.sol", + &r#" +import {Test} from "forge-std/Test.sol"; + +interface EvmVm { + function getEvmVersion() external pure returns (string memory evm); + function setEvmVersion(string calldata evm) external; +} + +interface ICreate2Deployer { + function computeAddress(bytes32 salt, bytes32 codeHash) external view returns (address); +} + +contract TestEvmVersion is Test { + function test_evm_version() public { + EvmVm evm = EvmVm(address(bytes20(uint160(uint256(keccak256("hevm cheat code")))))); + vm.createSelectFork(""); + + evm.setEvmVersion("istanbul"); + evm.getEvmVersion(); + + // revert with NotActivated for istanbul + vm.expectRevert(); + compute(); + + evm.setEvmVersion("shanghai"); + evm.getEvmVersion(); + compute(); + + // switch to Paris, expect revert with NotActivated + evm.setEvmVersion("paris"); + vm.expectRevert(); + compute(); + } + + function compute() internal view { + ICreate2Deployer(0x35Da41c476fA5c6De066f20556069096A1F39364).computeAddress(bytes32(0), bytes32(0)); + } +} + "#.replace("", &endpoint), + ); + + cmd.args(["test", "--mc", "TestEvmVersion", "-vvvv"]).assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/TestEvmVersion.t.sol:TestEvmVersion +[PASS] test_evm_version() ([GAS]) +Traces: + [..] TestEvmVersion::test_evm_version() + ├─ [0] VM::createSelectFork("") + │ └─ ← [Return] 0 + ├─ [0] VM::setEvmVersion("istanbul") + │ └─ ← [Return] + ├─ [0] VM::getEvmVersion() [staticcall] + │ └─ ← [Return] "istanbul" + ├─ [0] VM::expectRevert(custom error 0xf4844814) + │ └─ ← [Return] + ├─ [..] 0x35Da41c476fA5c6De066f20556069096A1F39364::computeAddress(0x0000000000000000000000000000000000000000000000000000000000000000, 0x0000000000000000000000000000000000000000000000000000000000000000) [staticcall] + │ └─ ← [NotActivated] EvmError: NotActivated + ├─ [0] VM::setEvmVersion("shanghai") + │ └─ ← [Return] + ├─ [0] VM::getEvmVersion() [staticcall] + │ └─ ← [Return] "shanghai" + ├─ [..] 0x35Da41c476fA5c6De066f20556069096A1F39364::computeAddress(0x0000000000000000000000000000000000000000000000000000000000000000, 0x0000000000000000000000000000000000000000000000000000000000000000) [staticcall] + │ └─ ← [Return] 0x0f40d7B7669e3a6683EaB25358318fd42a9F2342 + ├─ [0] VM::setEvmVersion("paris") + │ └─ ← [Return] + ├─ [0] VM::expectRevert(custom error 0xf4844814) + │ └─ ← [Return] + ├─ [..] 0x35Da41c476fA5c6De066f20556069096A1F39364::computeAddress(0x0000000000000000000000000000000000000000000000000000000000000000, 0x0000000000000000000000000000000000000000000000000000000000000000) [staticcall] + │ └─ ← [NotActivated] EvmError: NotActivated + └─ ← [Stop] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); + + // Test evm version set in `setUp` is accounted in test. + prj.add_test( + "TestSetupEvmVersion.t.sol", + &r#" +import {Test} from "forge-std/Test.sol"; + +interface EvmVm { + function getEvmVersion() external pure returns (string memory evm); + function setEvmVersion(string calldata evm) external; +} + +interface ICreate2Deployer { + function computeAddress(bytes32 salt, bytes32 codeHash) external view returns (address); +} + +EvmVm constant evm = EvmVm(address(bytes20(uint160(uint256(keccak256("hevm cheat code")))))); + +contract TestSetupEvmVersion is Test { + function setUp() public { + evm.setEvmVersion("istanbul"); + } + + function test_evm_version_in_setup() public { + vm.createSelectFork(""); + // revert with NotActivated for istanbul + ICreate2Deployer(0x35Da41c476fA5c6De066f20556069096A1F39364).computeAddress(bytes32(0), bytes32(0)); + } +} + "#.replace("", &endpoint), + ); + cmd.forge_fuse() + .args(["test", "--mc", "TestSetupEvmVersion", "-vvvv"]) + .assert_failure() + .stdout_eq(str![[r#" +... +[FAIL: EvmError: NotActivated] test_evm_version_in_setup() ([GAS]) +Traces: + [..] TestSetupEvmVersion::setUp() + ├─ [0] VM::setEvmVersion("istanbul") + │ └─ ← [Return] + └─ ← [Stop] + + [..] TestSetupEvmVersion::test_evm_version_in_setup() + └─ ← [NotActivated] EvmError: NotActivated +... + +"#]]); + + // Test evm version set in constructor is accounted in test. + prj.add_test( + "TestConstructorEvmVersion.t.sol", + &r#" +import {Test} from "forge-std/Test.sol"; + +interface EvmVm { + function getEvmVersion() external pure returns (string memory evm); + function setEvmVersion(string calldata evm) external; +} + +interface ICreate2Deployer { + function computeAddress(bytes32 salt, bytes32 codeHash) external view returns (address); +} + +EvmVm constant evm = EvmVm(address(bytes20(uint160(uint256(keccak256("hevm cheat code")))))); + +contract TestConstructorEvmVersion is Test { + constructor() { + evm.setEvmVersion("istanbul"); + } + + function test_evm_version_in_constructor() public { + vm.createSelectFork(""); + // revert with NotActivated for istanbul + ICreate2Deployer(0x35Da41c476fA5c6De066f20556069096A1F39364).computeAddress(bytes32(0), bytes32(0)); + } +} + "#.replace("", &endpoint), + ); + cmd.forge_fuse() + .args(["test", "--mc", "TestConstructorEvmVersion", "-vvvv"]) + .assert_failure() + .stdout_eq(str![[r#" +... +[FAIL: EvmError: NotActivated] test_evm_version_in_constructor() ([GAS]) +Traces: + [..] TestConstructorEvmVersion::test_evm_version_in_constructor() + └─ ← [NotActivated] EvmError: NotActivated +... + +"#]]); +} + +#[cfg(feature = "monad")] +#[forgetest_init] +fn test_set_evm_version_monad_hardfork(prj: _, cmd: _) { + prj.add_test( + "MonadEvmVersion.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +interface EvmVm { + function getEvmVersion() external pure returns (string memory evm); + function setEvmVersion(string calldata evm) external; +} + +contract ModexpGasProbe { + function measure() external view returns (uint256) { + bytes memory input = abi.encodePacked( + bytes32(uint256(32)), + bytes32(uint256(32)), + bytes32(uint256(32)), + bytes32(type(uint256).max), + bytes32(type(uint256).max), + bytes32(type(uint256).max) + ); + uint256 gasBefore = gasleft(); + (bool ok,) = address(5).staticcall(input); + uint256 gasUsed = gasBefore - gasleft(); + require(ok, "MODEXP probe should succeed"); + return gasUsed; + } +} + +contract StorageGasProbe { + function measureRead(uint256 secondSlot) + external + view + returns (uint256 gasUsed, uint256 checksum) + { + assembly { + let gasBefore := gas() + let first := sload(0) + let second := sload(secondSlot) + gasUsed := sub(gasBefore, gas()) + checksum := add(first, second) + } + } + + function measureWrite(uint256 secondSlot) external returns (uint256 gasUsed) { + assembly { + let gasBefore := gas() + sstore(0, 1) + sstore(secondSlot, 1) + gasUsed := sub(gasBefore, gas()) + } + } +} + +contract MonadEvmVersionTest is Test { + EvmVm constant evm = EvmVm(address(bytes20(uint160(uint256(keccak256("hevm cheat code")))))); + address constant CLZ_TARGET = address(uint160(0x0c17)); + address constant MEMORY_TARGET = address(uint160(0x3e3)); + address constant RESERVE_TARGET = address(uint160(0x1001)); + ModexpGasProbe immutable modexpGasProbe = new ModexpGasProbe(); + + function test_set_monad_evm_version() public { + assertEq(evm.getEvmVersion(), "monadten"); + assertMip8Active(); + + evm.setEvmVersion("MonadEight"); + vm.etch(CLZ_TARGET, hex"60011e60005260206000f3"); + + assertEq(evm.getEvmVersion(), "monadeight"); + assertEq(memoryExpansionGasDelta(), 897, "MonadEight should use Ethereum memory pricing"); + uint256 monadEightModexpGas = modexpGasProbe.measure(); + assertFalse(reservePrecompileActive(), "MonadEight should not expose the reserve precompile"); + (bool ok,) = CLZ_TARGET.staticcall(hex""); + assertFalse(ok, "CLZ should be unavailable on MonadEight"); + + evm.setEvmVersion("MonadNine"); + assertEq(evm.getEvmVersion(), "monadnine"); + assertEq(memoryExpansionGasDelta(), 128, "MonadNine should use MIP-3 memory pricing"); + uint256 monadNineModexpGas = modexpGasProbe.measure(); + assertGt(monadNineModexpGas, monadEightModexpGas, "MonadNine should use MIP-3 MODEXP pricing"); + assertTrue(reservePrecompileActive(), "MonadNine should expose the reserve precompile"); + bytes memory output; + (ok, output) = CLZ_TARGET.staticcall(hex""); + assertTrue(ok, "CLZ should be available on MonadNine"); + assertEq(abi.decode(output, (uint256)), 255); + assertMip8Inactive(); + + evm.setEvmVersion("monad:MonadTen"); + assertEq(evm.getEvmVersion(), "monadten"); + assertMip8Active(); + + evm.setEvmVersion("monad:MonadEight"); + assertEq(evm.getEvmVersion(), "monadeight"); + assertEq(memoryExpansionGasDelta(), 897, "MonadEight memory pricing should be restored"); + assertEq( + modexpGasProbe.measure(), + monadEightModexpGas, + "MonadEight MODEXP pricing should be restored exactly" + ); + assertFalse( + reservePrecompileActive(), "MonadEight should remove the reserve precompile again" + ); + (ok,) = CLZ_TARGET.staticcall(hex""); + assertFalse(ok, "CLZ should be disabled after switching back to MonadEight"); + } + + function assertMip8Active() internal { + assertEq(storageReadGas(128) - storageReadGas(127), 8_000, "MIP-8 page read cost"); + assertEq(storageWriteGas(128) - storageWriteGas(1), 10_800, "MIP-8 page write cost"); + } + + function assertMip8Inactive() internal { + assertEq(storageReadGas(128), storageReadGas(127), "legacy slot read cost"); + assertEq(storageWriteGas(128), storageWriteGas(1), "legacy slot write cost"); + } + + function storageReadGas(uint256 secondSlot) internal returns (uint256 gasUsed) { + uint256 checksum; + (gasUsed, checksum) = new StorageGasProbe().measureRead(secondSlot); + assertEq(checksum, 0); + } + + function storageWriteGas(uint256 secondSlot) internal returns (uint256) { + return new StorageGasProbe().measureWrite(secondSlot); + } + + function memoryExpansionGasDelta() internal returns (uint256) { + // The probe measures gas around MSTORE at offsets 0 and 0x2000. + uint256 base = memoryGas(hex"5a5f610000525a90035f5260205ff3"); + uint256 expanded = memoryGas(hex"5a5f612000525a90035f5260205ff3"); + return expanded - base; + } + + function memoryGas(bytes memory code) internal returns (uint256) { + vm.etch(MEMORY_TARGET, code); + (bool ok, bytes memory output) = MEMORY_TARGET.staticcall(hex""); + assertTrue(ok, "memory gas probe should succeed"); + return abi.decode(output, (uint256)); + } + + function reservePrecompileActive() internal returns (bool) { + (bool ok, bytes memory output) = RESERVE_TARGET.call(hex"3a61584e"); + assertTrue(ok, "reserve probe should succeed"); + if (output.length == 0) return false; + assertEq(output.length, 32, "reserve probe returned malformed output"); + assertFalse(abi.decode(output, (bool)), "fresh execution should not dip into reserve"); + return true; + } +} + "#, + ); + + cmd.args(["test", "--network", "monad", "--mc", "MonadEvmVersionTest"]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/MonadEvmVersion.t.sol:MonadEvmVersionTest +[PASS] test_set_monad_evm_version() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[cfg(feature = "monad")] +#[forgetest] +async fn fork_resolves_monad_hardfork_from_timestamp(prj: _, cmd: _) { + let monad_nine_activation = + foundry_evm::hardforks::MonadHardfork::MonadNine.mainnet_activation_timestamp().unwrap(); + let mainnet_activation = + foundry_evm::hardforks::MonadHardfork::MonadTen.mainnet_activation_timestamp().unwrap(); + let testnet_activation = + foundry_evm::hardforks::MonadHardfork::MonadTen.testnet_activation_timestamp().unwrap(); + prj.add_test( + "MonadForkHardfork.t.sol", + r#" +interface EvmVm { + function getEvmVersion() external pure returns (string memory evm); +} + +contract MonadForkHardforkTest { + EvmVm constant evm = EvmVm(address(bytes20(uint160(uint256(keccak256("hevm cheat code")))))); + + function test_monad_eight() public { + require(block.chainid == 1, "expected CHAINID override"); + require( + keccak256(bytes(evm.getEvmVersion())) == keccak256("monadeight"), + "expected MonadEight" + ); + } + + function test_monad_nine() public { + require(block.chainid == 1, "expected CHAINID override"); + require( + keccak256(bytes(evm.getEvmVersion())) == keccak256("monadnine"), + "expected MonadNine" + ); + } + + function test_monad_ten() public { + require(block.chainid == 1, "expected CHAINID override"); + require( + keccak256(bytes(evm.getEvmVersion())) == keccak256("monadten"), + "expected MonadTen" + ); + } +} + "#, + ); + + let (_api, before) = anvil::spawn( + anvil::NodeConfig::test() + .with_chain_id(Some(143u64)) + .with_genesis_timestamp(Some(monad_nine_activation - 1)), + ) + .await; + cmd.args([ + "test", + "--network", + "monad", + "--fork-url", + &before.http_endpoint(), + "--chain-id", + "1", + "--mt", + "test_monad_eight", + ]) + .assert_success(); + + let (_api, after) = anvil::spawn( + anvil::NodeConfig::test() + .with_chain_id(Some(143u64)) + .with_genesis_timestamp(Some(monad_nine_activation)), + ) + .await; + cmd.forge_fuse() + .args([ + "test", + "--network", + "monad", + "--fork-url", + &after.http_endpoint(), + "--chain-id", + "1", + "--mt", + "test_monad_nine", + ]) + .assert_success(); + + let (_api, before) = anvil::spawn( + anvil::NodeConfig::test() + .with_chain_id(Some(143u64)) + .with_genesis_timestamp(Some(mainnet_activation - 1)), + ) + .await; + cmd.forge_fuse() + .args([ + "test", + "--network", + "monad", + "--fork-url", + &before.http_endpoint(), + "--chain-id", + "1", + "--mt", + "test_monad_nine", + ]) + .assert_success(); + + let (_api, after) = anvil::spawn( + anvil::NodeConfig::test() + .with_chain_id(Some(143u64)) + .with_genesis_timestamp(Some(mainnet_activation)), + ) + .await; + cmd.forge_fuse() + .args([ + "test", + "--network", + "monad", + "--fork-url", + &after.http_endpoint(), + "--chain-id", + "1", + "--mt", + "test_monad_ten", + ]) + .assert_success(); + + let (_api, before) = anvil::spawn( + anvil::NodeConfig::test() + .with_chain_id(Some(10143u64)) + .with_genesis_timestamp(Some(testnet_activation - 1)), + ) + .await; + cmd.forge_fuse() + .args([ + "test", + "--network", + "monad", + "--fork-url", + &before.http_endpoint(), + "--chain-id", + "1", + "--mt", + "test_monad_nine", + ]) + .assert_success(); + + let (_api, after) = anvil::spawn( + anvil::NodeConfig::test() + .with_chain_id(Some(10143u64)) + .with_genesis_timestamp(Some(testnet_activation)), + ) + .await; + cmd.forge_fuse() + .args([ + "test", + "--network", + "monad", + "--fork-url", + &after.http_endpoint(), + "--chain-id", + "1", + "--mt", + "test_monad_ten", + ]) + .assert_success(); + + let (_api, overridden) = anvil::spawn( + anvil::NodeConfig::test_monad() + .with_chain_id(Some(143u64)) + .with_genesis_timestamp(Some(mainnet_activation)) + .with_hardfork(Some(foundry_evm::hardforks::MonadHardfork::MonadNine.into())), + ) + .await; + cmd.forge_fuse() + .args([ + "test", + "--fork-url", + &overridden.http_endpoint(), + "--chain-id", + "1", + "--mt", + "test_monad_nine", + ]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/MonadForkHardfork.t.sol:MonadForkHardforkTest +[PASS] test_monad_nine() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[cfg(feature = "monad")] +#[forgetest_init] +fn test_monad_memory_limit(prj: _, cmd: _) { + prj.update_config(|config| { + config.hardfork = Some("monad:MonadNine".parse().unwrap()); + config.memory_limit = 128 * 1024 * 1024; + }); + prj.add_test( + "MonadMemoryLimit.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract MonadMemoryLimitTest is Test { + function test_memory_ending_at_limit() public { + uint256 value; + assembly { + // The stored word ends exactly at 8 MiB. + mstore(0x7fffe0, 1) + value := mload(0x7fffe0) + } + assertEq(value, 1); + } + + function test_memory_ending_above_limit() public { + uint256 value; + assembly { + // The stored word starts at 8 MiB and ends one word above the limit. + mstore(0x800000, 1) + value := mload(0x800000) + } + assertEq(value, 1); + } +} + "#, + ); + + cmd.args([ + "test", + "--network", + "monad", + "--mc", + "MonadMemoryLimitTest", + "--mt", + "test_memory_ending_at_limit", + ]) + .assert_success(); + + cmd.forge_fuse() + .args([ + "test", + "--network", + "monad", + "--mc", + "MonadMemoryLimitTest", + "--mt", + "test_memory_ending_above_limit", + ]) + .assert_failure() + .stdout_eq(str![[r#" +... +[FAIL: EvmError: MemoryLimitOOG] test_memory_ending_above_limit() ([GAS]) +... +"#]]); +} + +#[cfg(feature = "monad")] +#[forgetest] +async fn execute_transaction_uses_monad_fork_context(prj: _, cmd: _) { + use alloy_consensus::SignableTransaction as _; + use alloy_network::TxSignerSync as _; + use alloy_provider::Provider as _; + + const CHAIN_ID: u64 = 31_337; + const GAS_LIMIT: u64 = 100_000; + const MAX_FEE_PER_GAS: u128 = 2_000_000_000; + const MAX_PRIORITY_FEE_PER_GAS: u128 = 1_000_000_000; + + let (_api, handle) = anvil::spawn(anvil::NodeConfig::test()).await; + let provider = handle.http_provider(); + let wallets = handle.dev_wallets().collect::>(); + let ancestor = wallets[0].address(); + let control = wallets[1].address(); + let tracked = wallets[3].address(); + let nested_only = wallets[5].address(); + let probe = alloy_primitives::Address::with_last_byte(0x20); + let receiver = alloy_primitives::Address::with_last_byte(0x21); + + // Mine the ancestor in the block Forge will fork. The synthetic transaction should execute + // in a child of this block, making this sender ineligible to dip into its reserve. + let mut ancestor_marker = alloy_consensus::TxEip1559 { + chain_id: CHAIN_ID, + gas_limit: 21_000, + max_fee_per_gas: MAX_FEE_PER_GAS, + max_priority_fee_per_gas: MAX_PRIORITY_FEE_PER_GAS, + to: alloy_primitives::TxKind::Call(wallets[2].address()), + value: alloy_primitives::U256::ONE, + ..Default::default() + }; + let signature = wallets[0].sign_transaction_sync(&mut ancestor_marker).unwrap(); + let mut encoded = Vec::new(); + ancestor_marker.into_signed(signature).eip2718_encode(&mut encoded); + provider.send_raw_transaction(&encoded).await.unwrap().get_receipt().await.unwrap(); + + let value = alloy_primitives::U256::from(3_000_000_000_000_000_000u128); + let mut ancestor_tx = alloy_consensus::TxEip1559 { + chain_id: CHAIN_ID, + nonce: 1, + gas_limit: GAS_LIMIT, + max_fee_per_gas: MAX_FEE_PER_GAS, + max_priority_fee_per_gas: MAX_PRIORITY_FEE_PER_GAS, + to: alloy_primitives::TxKind::Call(probe), + value, + ..Default::default() + }; + let signature = wallets[0].sign_transaction_sync(&mut ancestor_tx).unwrap(); + let mut ancestor_raw = Vec::new(); + ancestor_tx.into_signed(signature).eip2718_encode(&mut ancestor_raw); + + let mut control_tx = alloy_consensus::TxEip1559 { + chain_id: CHAIN_ID, + nonce: 0, + gas_limit: GAS_LIMIT, + max_fee_per_gas: MAX_FEE_PER_GAS, + max_priority_fee_per_gas: MAX_PRIORITY_FEE_PER_GAS, + to: alloy_primitives::TxKind::Call(probe), + value, + ..Default::default() + }; + let signature = wallets[1].sign_transaction_sync(&mut control_tx).unwrap(); + let mut control_raw = Vec::new(); + control_tx.into_signed(signature).eip2718_encode(&mut control_raw); + + let mut credit_tx = alloy_consensus::TxEip1559 { + chain_id: CHAIN_ID, + nonce: 0, + gas_limit: GAS_LIMIT, + max_fee_per_gas: MAX_FEE_PER_GAS, + max_priority_fee_per_gas: MAX_PRIORITY_FEE_PER_GAS, + to: alloy_primitives::TxKind::Call(tracked), + value, + ..Default::default() + }; + let signature = wallets[2].sign_transaction_sync(&mut credit_tx).unwrap(); + let mut credit_raw = Vec::new(); + credit_tx.into_signed(signature).eip2718_encode(&mut credit_raw); + + let mut preserve_tx = alloy_consensus::TxEip1559 { + chain_id: CHAIN_ID, + nonce: 0, + gas_limit: GAS_LIMIT, + max_fee_per_gas: MAX_FEE_PER_GAS, + max_priority_fee_per_gas: MAX_PRIORITY_FEE_PER_GAS, + to: alloy_primitives::TxKind::Call(receiver), + value: alloy_primitives::U256::ONE, + ..Default::default() + }; + let signature = wallets[4].sign_transaction_sync(&mut preserve_tx).unwrap(); + let mut preserve_raw = Vec::new(); + preserve_tx.into_signed(signature).eip2718_encode(&mut preserve_raw); + + let nested_only_balance = provider.get_balance(nested_only).await.unwrap(); + let nested_only_remaining = alloy_primitives::U256::from(7_000_000_000_000_000_000u128); + let mut nested_only_tx = alloy_consensus::TxEip1559 { + chain_id: CHAIN_ID, + nonce: 0, + gas_limit: GAS_LIMIT, + max_fee_per_gas: MAX_FEE_PER_GAS, + max_priority_fee_per_gas: MAX_PRIORITY_FEE_PER_GAS, + to: alloy_primitives::TxKind::Call(receiver), + value: nested_only_balance - nested_only_remaining, + ..Default::default() + }; + let signature = wallets[5].sign_transaction_sync(&mut nested_only_tx).unwrap(); + let mut nested_only_raw = Vec::new(); + nested_only_tx.into_signed(signature).eip2718_encode(&mut nested_only_raw); + + let source = r#" +interface Vm { + function createSelectFork(string calldata url) external returns (uint256 forkId); + function deal(address account, uint256 newBalance) external; + function etch(address target, bytes calldata newRuntimeBytecode) external; + function executeTransaction(bytes calldata rawTx) external returns (bytes memory); + function prank(address msgSender) external; +} + +interface IReserveBalance { + function dippedIntoReserve() external returns (bool); +} + +contract ExecuteTransactionMonadContextTest { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + IReserveBalance constant RESERVE_BALANCE = IReserveBalance(address(0x1001)); + address constant ANCESTOR = ; + address constant CONTROL = ; + address constant TRACKED = ; + address constant NESTED_ONLY = ; + address constant PROBE = ; + address constant RECEIVER = ; + + function test_execute_transaction_uses_ancestor_context() public { + vm.createSelectFork(""); + vm.deal(ANCESTOR, 12 ether); + vm.deal(CONTROL, 12 ether); + + // Calls dippedIntoReserve() after receiving value, then returns the result. + vm.etch(PROBE, hex"633a61584e5f5260205f6004601c5f6110015af15060205ff3"); + + bytes memory ancestorResult = vm.executeTransaction(hex""); + require(abi.decode(ancestorResult, (bool)), "ancestor sender must preserve reserve"); + require(ANCESTOR.balance == 9 ether, "unexpected ancestor balance"); + + // The nested transaction's tracker must not replace the outer transaction's tracker. + require(!RESERVE_BALANCE.dippedIntoReserve(), "nested tracker leaked into parent"); + + bytes memory controlResult = vm.executeTransaction(hex""); + require(!abi.decode(controlResult, (bool)), "fresh sender should be allowed to dip"); + require(CONTROL.balance == 9 ether, "unexpected control balance"); + } + + function test_execute_transaction_credit_clears_outer_violation() public { + vm.createSelectFork(""); + violateReserve(TRACKED); + + vm.executeTransaction(hex""); + + require(TRACKED.balance == 12 ether, "unexpected credited balance"); + require(!RESERVE_BALANCE.dippedIntoReserve(), "credited violation was not cleared"); + } + + function test_execute_transaction_preserves_unaffected_outer_violation() public { + vm.createSelectFork(""); + violateReserve(TRACKED); + + vm.executeTransaction(hex""); + + require(RESERVE_BALANCE.dippedIntoReserve(), "unaffected violation was cleared"); + } + + function test_execute_transaction_does_not_track_nested_only_account() public { + vm.createSelectFork(""); + + vm.executeTransaction(hex""); + + require(NESTED_ONLY.balance == 7 ether, "unexpected nested sender balance"); + require(!RESERVE_BALANCE.dippedIntoReserve(), "nested-only account became tracked"); + } + + function violateReserve(address account) internal { + vm.deal(account, 12 ether); + vm.prank(account); + (bool success,) = payable(RECEIVER).call{value: 3 ether}(""); + require(success, "debit failed"); + require(RESERVE_BALANCE.dippedIntoReserve(), "expected reserve violation"); + } +} +"# + .replace("", &ancestor.to_string()) + .replace("", &control.to_string()) + .replace("", &tracked.to_string()) + .replace("", &nested_only.to_string()) + .replace("", &probe.to_string()) + .replace("", &receiver.to_string()) + .replace("", &handle.http_endpoint()) + .replace("", &alloy_primitives::hex::encode(ancestor_raw)) + .replace("", &alloy_primitives::hex::encode(control_raw)) + .replace("", &alloy_primitives::hex::encode(credit_raw)) + .replace("", &alloy_primitives::hex::encode(preserve_raw)) + .replace("", &alloy_primitives::hex::encode(nested_only_raw)); + prj.add_test("ExecuteTransactionMonadContext.t.sol", &source); + prj.update_config(|config| { + config.hardfork = Some("monad:MonadNine".parse().unwrap()); + }); + + cmd.args(["test", "--network", "monad", "--mc", "ExecuteTransactionMonadContextTest"]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 4 tests for test/ExecuteTransactionMonadContext.t.sol:ExecuteTransactionMonadContextTest +[PASS] test_execute_transaction_credit_clears_outer_violation() ([GAS]) +[PASS] test_execute_transaction_does_not_track_nested_only_account() ([GAS]) +[PASS] test_execute_transaction_preserves_unaffected_outer_violation() ([GAS]) +[PASS] test_execute_transaction_uses_ancestor_context() ([GAS]) +Suite result: ok. 4 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[cfg(feature = "monad")] +#[forgetest] +async fn transaction_fork_excludes_future_monad_participants(prj: _, cmd: _) { + use alloy_consensus::SignableTransaction as _; + use alloy_network::{ReceiptResponse as _, TxSignerSync as _}; + use alloy_provider::Provider as _; + + const CHAIN_ID: u64 = 31_337; + const GAS_LIMIT: u64 = 100_000; + const MAX_FEE_PER_GAS: u128 = 3_000_000_000; + + let (api, handle) = anvil::spawn(anvil::NodeConfig::test()).await; + let provider = handle.http_provider(); + let wallets = handle.dev_wallets().collect::>(); + let target_sender = wallets[3].address(); + let future_sender = wallets[0].address(); + let probe = alloy_primitives::Address::with_last_byte(0x21); + let target_recipient = alloy_primitives::Address::with_last_byte(0x22); + let future_recipient = alloy_primitives::Address::with_last_byte(0x23); + let parent_block = provider.get_block_number().await.unwrap(); + + let mut target_tx = alloy_consensus::TxEip1559 { + chain_id: CHAIN_ID, + gas_limit: 21_000, + max_fee_per_gas: MAX_FEE_PER_GAS, + max_priority_fee_per_gas: 2_000_000_000, + to: alloy_primitives::TxKind::Call(target_recipient), + value: alloy_primitives::U256::ONE, + ..Default::default() + }; + let signature = wallets[3].sign_transaction_sync(&mut target_tx).unwrap(); + let mut target_raw = Vec::new(); + target_tx.into_signed(signature).eip2718_encode(&mut target_raw); + + let mut future_marker = alloy_consensus::TxEip1559 { + chain_id: CHAIN_ID, + gas_limit: 21_000, + max_fee_per_gas: MAX_FEE_PER_GAS, + max_priority_fee_per_gas: 1_000_000_000, + to: alloy_primitives::TxKind::Call(future_recipient), + value: alloy_primitives::U256::ONE, + ..Default::default() + }; + let signature = wallets[0].sign_transaction_sync(&mut future_marker).unwrap(); + let mut future_marker_raw = Vec::new(); + future_marker.into_signed(signature).eip2718_encode(&mut future_marker_raw); + + let mut future_probe = alloy_consensus::TxEip1559 { + chain_id: CHAIN_ID, + gas_limit: GAS_LIMIT, + max_fee_per_gas: MAX_FEE_PER_GAS, + max_priority_fee_per_gas: 1_000_000_000, + to: alloy_primitives::TxKind::Call(probe), + value: alloy_primitives::U256::from(3_000_000_000_000_000_000u128), + ..Default::default() + }; + let signature = wallets[0].sign_transaction_sync(&mut future_probe).unwrap(); + let mut future_probe_raw = Vec::new(); + future_probe.into_signed(signature).eip2718_encode(&mut future_probe_raw); + + let mut target_probe = alloy_consensus::TxEip1559 { + chain_id: CHAIN_ID, + nonce: 1, + gas_limit: GAS_LIMIT, + max_fee_per_gas: MAX_FEE_PER_GAS, + max_priority_fee_per_gas: 1_000_000_000, + to: alloy_primitives::TxKind::Call(probe), + value: alloy_primitives::U256::from(3_000_000_000_000_000_000u128), + ..Default::default() + }; + let signature = wallets[3].sign_transaction_sync(&mut target_probe).unwrap(); + let mut target_probe_raw = Vec::new(); + target_probe.into_signed(signature).eip2718_encode(&mut target_probe_raw); + + let mut replayed_future_probe = alloy_consensus::TxEip1559 { + chain_id: CHAIN_ID, + nonce: 1, + gas_limit: GAS_LIMIT, + max_fee_per_gas: MAX_FEE_PER_GAS, + max_priority_fee_per_gas: 1_000_000_000, + to: alloy_primitives::TxKind::Call(probe), + value: alloy_primitives::U256::from(3_000_000_000_000_000_000u128), + ..Default::default() + }; + let signature = wallets[0].sign_transaction_sync(&mut replayed_future_probe).unwrap(); + let mut replayed_future_probe_raw = Vec::new(); + replayed_future_probe.into_signed(signature).eip2718_encode(&mut replayed_future_probe_raw); + + api.anvil_set_auto_mine(false).await.unwrap(); + let target_pending = provider.send_raw_transaction(&target_raw).await.unwrap(); + let target_hash = *target_pending.tx_hash(); + let future_pending = provider.send_raw_transaction(&future_marker_raw).await.unwrap(); + let future_hash = *future_pending.tx_hash(); + api.mine_one().await.unwrap(); + let target_receipt = target_pending.get_receipt().await.unwrap(); + let future_receipt = future_pending.get_receipt().await.unwrap(); + assert_eq!(target_receipt.block_number(), future_receipt.block_number()); + assert_eq!(target_receipt.block_number(), Some(parent_block + 1)); + assert_eq!(target_receipt.transaction_index(), Some(0)); + assert_eq!(future_receipt.transaction_index(), Some(1)); + + let source = r#" +interface Vm { + function createSelectFork(string calldata url) external returns (uint256 forkId); + function createSelectFork(string calldata url, bytes32 transaction) external returns (uint256); + function deal(address account, uint256 newBalance) external; + function etch(address target, bytes calldata newRuntimeBytecode) external; + function executeTransaction(bytes calldata rawTx) external returns (bytes memory); + function getNonce(address account) external view returns (uint64 nonce); + function rollFork(uint256 forkId, uint256 blockNumber) external; + function transact(uint256 forkId, bytes32 txHash) external; +} + +interface IReserveBalance { + function dippedIntoReserve() external returns (bool); +} + +contract TransactionForkMonadContextTest { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + address constant TARGET_SENDER = ; + address constant FUTURE_SENDER = ; + address constant PROBE = ; + address constant TARGET_RECIPIENT = ; + address constant FUTURE_RECIPIENT = ; + bytes32 constant TARGET_HASH = ; + bytes32 constant FUTURE_HASH = ; + uint256 constant PARENT_BLOCK = ; + + function test_future_transaction_is_not_an_ancestor() public { + vm.createSelectFork("", TARGET_HASH); + vm.deal(FUTURE_SENDER, 12 ether); + + // Calls dippedIntoReserve() after receiving value, then returns the result. + vm.etch(PROBE, hex"633a61584e5f5260205f6004601c5f6110015af15060205ff3"); + bytes memory result = vm.executeTransaction(hex""); + require(!abi.decode(result, (bool)), "future sender must be allowed to dip"); + require(FUTURE_SENDER.balance == 9 ether, "unexpected future sender balance"); + } + + function test_parent_block_transact_advances_to_next_transaction() public { + uint256 forkId = vm.createSelectFork(""); + vm.rollFork(forkId, PARENT_BLOCK); + uint64 targetNonce = vm.getNonce(TARGET_SENDER); + uint256 targetRecipientBalance = TARGET_RECIPIENT.balance; + uint256 futureRecipientBalance = FUTURE_RECIPIENT.balance; + + vm.transact(forkId, TARGET_HASH); + + require(vm.getNonce(TARGET_SENDER) == targetNonce + 1, "target nonce was not advanced"); + require(TARGET_RECIPIENT.balance == targetRecipientBalance + 1, "target was not committed"); + require(FUTURE_RECIPIENT.balance == futureRecipientBalance, "future tx was committed"); + + vm.deal(TARGET_SENDER, 12 ether); + vm.deal(FUTURE_SENDER, 12 ether); + vm.etch(PROBE, hex"633a61584e5f5260205f6004601c5f6110015af15060205ff3"); + + bytes memory targetResult = vm.executeTransaction(hex""); + require(abi.decode(targetResult, (bool)), "replayed sender was treated as fresh"); + require(TARGET_SENDER.balance == 9 ether, "unexpected replayed sender balance"); + + bytes memory futureResult = vm.executeTransaction(hex""); + require(!abi.decode(futureResult, (bool)), "future sender became an ancestor"); + require(FUTURE_SENDER.balance == 9 ether, "unexpected future sender balance"); + } + + function test_sequential_transacts_advance_past_block() public { + uint256 forkId = vm.createSelectFork(""); + vm.rollFork(forkId, PARENT_BLOCK); + uint64 targetNonce = vm.getNonce(TARGET_SENDER); + uint64 futureNonce = vm.getNonce(FUTURE_SENDER); + uint256 targetRecipientBalance = TARGET_RECIPIENT.balance; + uint256 futureRecipientBalance = FUTURE_RECIPIENT.balance; + + vm.transact(forkId, TARGET_HASH); + vm.transact(forkId, FUTURE_HASH); + + require(vm.getNonce(TARGET_SENDER) == targetNonce + 1, "target nonce was not advanced"); + require(vm.getNonce(FUTURE_SENDER) == futureNonce + 1, "future nonce was not advanced"); + require(TARGET_RECIPIENT.balance == targetRecipientBalance + 1, "target was not committed"); + require(FUTURE_RECIPIENT.balance == futureRecipientBalance + 1, "future was not committed"); + + vm.deal(FUTURE_SENDER, 12 ether); + vm.etch(PROBE, hex"633a61584e5f5260205f6004601c5f6110015af15060205ff3"); + bytes memory result = vm.executeTransaction(hex""); + require(abi.decode(result, (bool)), "last replayed sender was treated as fresh"); + require(FUTURE_SENDER.balance == 9 ether, "unexpected last sender balance"); + } + + function test_non_immediate_transact_rejects_without_mutation() public { + uint256 forkId = vm.createSelectFork(""); + vm.rollFork(forkId, PARENT_BLOCK); + uint64 targetNonce = vm.getNonce(TARGET_SENDER); + uint64 futureNonce = vm.getNonce(FUTURE_SENDER); + uint256 targetRecipientBalance = TARGET_RECIPIENT.balance; + uint256 futureRecipientBalance = FUTURE_RECIPIENT.balance; + + bool reverted; + try vm.transact(forkId, FUTURE_HASH) { + reverted = false; + } catch { + reverted = true; + } + + require(reverted, "non-immediate transaction was replayed"); + require(vm.getNonce(TARGET_SENDER) == targetNonce, "target nonce changed"); + require(vm.getNonce(FUTURE_SENDER) == futureNonce, "future nonce changed"); + require(TARGET_RECIPIENT.balance == targetRecipientBalance, "target balance changed"); + require(FUTURE_RECIPIENT.balance == futureRecipientBalance, "future balance changed"); + + vm.transact(forkId, TARGET_HASH); + require(vm.getNonce(TARGET_SENDER) == targetNonce + 1, "cursor changed on rejection"); + require(TARGET_RECIPIENT.balance == targetRecipientBalance + 1, "target was not committed"); + require(vm.getNonce(FUTURE_SENDER) == futureNonce, "future nonce changed after target"); + require( + FUTURE_RECIPIENT.balance == futureRecipientBalance, + "future balance changed after target" + ); + + vm.transact(forkId, FUTURE_HASH); + require(vm.getNonce(FUTURE_SENDER) == futureNonce + 1, "future nonce was not advanced"); + require( + FUTURE_RECIPIENT.balance == futureRecipientBalance + 1, + "future was not committed" + ); + } +} +"# + .replace("", &target_sender.to_string()) + .replace("", &future_sender.to_string()) + .replace("", &probe.to_string()) + .replace("", &target_recipient.to_string()) + .replace("", &future_recipient.to_string()) + .replace("", &handle.http_endpoint()) + .replace("", &target_hash.to_string()) + .replace("", &future_hash.to_string()) + .replace("", &parent_block.to_string()) + .replace("", &alloy_primitives::hex::encode(future_probe_raw)) + .replace("", &alloy_primitives::hex::encode(target_probe_raw)) + .replace( + "", + &alloy_primitives::hex::encode(replayed_future_probe_raw), + ); + prj.add_test("TransactionForkMonadContext.t.sol", &source); + prj.update_config(|config| { + config.hardfork = Some("monad:MonadNine".parse().unwrap()); + }); + + cmd.args(["test", "--network", "monad", "--mc", "TransactionForkMonadContextTest"]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 4 tests for test/TransactionForkMonadContext.t.sol:TransactionForkMonadContextTest +[PASS] test_future_transaction_is_not_an_ancestor() ([GAS]) +[PASS] test_non_immediate_transact_rejects_without_mutation() ([GAS]) +[PASS] test_parent_block_transact_advances_to_next_transaction() ([GAS]) +[PASS] test_sequential_transacts_advance_past_block() ([GAS]) +Suite result: ok. 4 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[cfg(feature = "monad")] +#[forgetest] +async fn monad_fork_aux_lifecycle_tracks_outer_context(prj: _, cmd: _) { + use alloy_consensus::SignableTransaction as _; + use alloy_network::{ReceiptResponse as _, TxSignerSync as _}; + use alloy_provider::Provider as _; + + const CHAIN_ID: u64 = 31_337; + const MAX_FEE_PER_GAS: u128 = 3_000_000_000; + + let (api, handle) = anvil::spawn(anvil::NodeConfig::test()).await; + let provider = handle.http_provider(); + let wallets = handle.dev_wallets().collect::>(); + let sender = wallets[0].address(); + let spender = wallets[3].address(); + let unrelated = wallets[2].address(); + let marker_recipient = wallets[4].address(); + let target_recipient = wallets[5].address(); + let receiver = wallets[6].address(); + let later_marker_recipient = alloy_primitives::Address::with_last_byte(0x30); + let later_replay_recipient = alloy_primitives::Address::with_last_byte(0x31); + let later_target_recipient = alloy_primitives::Address::with_last_byte(0x32); + let fresh_block = provider.get_block_number().await.unwrap(); + + let mut marker_tx = alloy_consensus::TxEip1559 { + chain_id: CHAIN_ID, + gas_limit: 21_000, + max_fee_per_gas: MAX_FEE_PER_GAS, + max_priority_fee_per_gas: 2_000_000_000, + to: alloy_primitives::TxKind::Call(marker_recipient), + value: alloy_primitives::U256::ONE, + ..Default::default() + }; + let signature = wallets[0].sign_transaction_sync(&mut marker_tx).unwrap(); + let mut marker_raw = Vec::new(); + marker_tx.into_signed(signature).eip2718_encode(&mut marker_raw); + + let mut target_tx = alloy_consensus::TxEip1559 { + chain_id: CHAIN_ID, + gas_limit: 21_000, + max_fee_per_gas: MAX_FEE_PER_GAS, + max_priority_fee_per_gas: 1_000_000_000, + to: alloy_primitives::TxKind::Call(target_recipient), + value: alloy_primitives::U256::ONE, + ..Default::default() + }; + let signature = wallets[1].sign_transaction_sync(&mut target_tx).unwrap(); + let mut target_raw = Vec::new(); + target_tx.into_signed(signature).eip2718_encode(&mut target_raw); + + let mut credit_tx = alloy_consensus::TxEip1559 { + chain_id: CHAIN_ID, + gas_limit: 21_000, + max_fee_per_gas: MAX_FEE_PER_GAS, + max_priority_fee_per_gas: 1_000_000_000, + to: alloy_primitives::TxKind::Call(spender), + value: alloy_primitives::U256::from(3_000_000_000_000_000_000u128), + ..Default::default() + }; + let signature = wallets[2].sign_transaction_sync(&mut credit_tx).unwrap(); + let mut credit_raw = Vec::new(); + credit_tx.into_signed(signature).eip2718_encode(&mut credit_raw); + + api.anvil_set_auto_mine(false).await.unwrap(); + let marker_pending = provider.send_raw_transaction(&marker_raw).await.unwrap(); + let marker_hash = *marker_pending.tx_hash(); + let target_pending = provider.send_raw_transaction(&target_raw).await.unwrap(); + let target_hash = *target_pending.tx_hash(); + api.mine_one().await.unwrap(); + let marker_receipt = marker_pending.get_receipt().await.unwrap(); + let target_receipt = target_pending.get_receipt().await.unwrap(); + + let restricted_block = marker_receipt.block_number().unwrap(); + assert_eq!(restricted_block, fresh_block + 1); + assert_eq!(marker_receipt.transaction_index(), Some(0)); + assert_eq!(target_receipt.block_number(), Some(restricted_block)); + assert_eq!(target_receipt.transaction_index(), Some(1)); + + let mut later_marker_tx = alloy_consensus::TxEip1559 { + chain_id: CHAIN_ID, + gas_limit: 21_000, + max_fee_per_gas: MAX_FEE_PER_GAS, + max_priority_fee_per_gas: 2_000_000_000, + to: alloy_primitives::TxKind::Call(later_marker_recipient), + value: alloy_primitives::U256::ONE, + ..Default::default() + }; + let signature = wallets[7].sign_transaction_sync(&mut later_marker_tx).unwrap(); + let mut later_marker_raw = Vec::new(); + later_marker_tx.into_signed(signature).eip2718_encode(&mut later_marker_raw); + + let mut later_replay_tx = alloy_consensus::TxEip1559 { + chain_id: CHAIN_ID, + gas_limit: 21_000, + max_fee_per_gas: MAX_FEE_PER_GAS, + max_priority_fee_per_gas: 1_000_000_000, + to: alloy_primitives::TxKind::Call(later_replay_recipient), + value: alloy_primitives::U256::ONE, + ..Default::default() + }; + let signature = wallets[8].sign_transaction_sync(&mut later_replay_tx).unwrap(); + let mut later_replay_raw = Vec::new(); + later_replay_tx.into_signed(signature).eip2718_encode(&mut later_replay_raw); + + let mut later_target_tx = alloy_consensus::TxEip1559 { + chain_id: CHAIN_ID, + gas_limit: 21_000, + max_fee_per_gas: MAX_FEE_PER_GAS, + max_priority_fee_per_gas: 1_000_000_000, + to: alloy_primitives::TxKind::Call(later_target_recipient), + value: alloy_primitives::U256::ONE, + ..Default::default() + }; + let signature = wallets[9].sign_transaction_sync(&mut later_target_tx).unwrap(); + let mut later_target_raw = Vec::new(); + later_target_tx.into_signed(signature).eip2718_encode(&mut later_target_raw); + + let later_marker_pending = provider.send_raw_transaction(&later_marker_raw).await.unwrap(); + let later_replay_pending = provider.send_raw_transaction(&later_replay_raw).await.unwrap(); + let later_replay_hash = *later_replay_pending.tx_hash(); + let later_target_pending = provider.send_raw_transaction(&later_target_raw).await.unwrap(); + let later_target_hash = *later_target_pending.tx_hash(); + api.mine_one().await.unwrap(); + let later_marker_receipt = later_marker_pending.get_receipt().await.unwrap(); + let later_replay_receipt = later_replay_pending.get_receipt().await.unwrap(); + let later_target_receipt = later_target_pending.get_receipt().await.unwrap(); + api.anvil_set_auto_mine(true).await.unwrap(); + + assert_eq!(later_marker_receipt.block_number(), Some(restricted_block + 1)); + assert_eq!(later_marker_receipt.transaction_index(), Some(0)); + assert_eq!(later_replay_receipt.block_number(), Some(restricted_block + 1)); + assert_eq!(later_replay_receipt.transaction_index(), Some(1)); + assert_eq!(later_target_receipt.block_number(), Some(restricted_block + 1)); + assert_eq!(later_target_receipt.transaction_index(), Some(2)); + + let upstream = handle.http_endpoint(); + let failing_replay_hash_string = later_replay_hash.to_string(); + let block_requests_armed = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let block_requests = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let corrupt_replay_chain_id = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let client = reqwest::Client::new(); + let proxy_armed = std::sync::Arc::clone(&block_requests_armed); + let proxy_requests = std::sync::Arc::clone(&block_requests); + let proxy_corrupt_chain_id = std::sync::Arc::clone(&corrupt_replay_chain_id); + let app = axum::Router::new().fallback(move |body: axum::body::Bytes| { + let upstream = upstream.clone(); + let failing_replay_hash = failing_replay_hash_string.clone(); + let client = client.clone(); + let armed = std::sync::Arc::clone(&proxy_armed); + let block_requests = std::sync::Arc::clone(&proxy_requests); + let corrupt_chain_id = std::sync::Arc::clone(&proxy_corrupt_chain_id); + async move { + let request: serde_json::Value = serde_json::from_slice(&body).unwrap(); + match request.get("method").and_then(serde_json::Value::as_str) { + Some("test_armBlockRequests") => { + armed.store(true, std::sync::atomic::Ordering::SeqCst) + } + Some("test_corruptReplayChainId") => { + corrupt_chain_id.store(true, std::sync::atomic::Ordering::SeqCst) + } + Some("test_restoreReplayChainId") => { + corrupt_chain_id.store(false, std::sync::atomic::Ordering::SeqCst) + } + _ => { + if armed.load(std::sync::atomic::Ordering::SeqCst) { + let count = request.as_array().map_or_else( + || { + usize::from( + request + .get("method") + .and_then(serde_json::Value::as_str) + .is_some_and(|method| method.starts_with("eth_getBlockBy")), + ) + }, + |requests| { + requests + .iter() + .filter(|request| { + request + .get("method") + .and_then(serde_json::Value::as_str) + .is_some_and(|method| { + method.starts_with("eth_getBlockBy") + }) + }) + .count() + }, + ); + block_requests.fetch_add(count, std::sync::atomic::Ordering::SeqCst); + } + + let response = client + .post(upstream) + .header("content-type", "application/json") + .body(body) + .send() + .await + .unwrap() + .bytes() + .await + .unwrap(); + if !corrupt_chain_id.load(std::sync::atomic::Ordering::SeqCst) { + return response; + } + + let mut response: serde_json::Value = + serde_json::from_slice(&response).unwrap(); + override_rpc_transaction_chain_id(&mut response, &failing_replay_hash, "0x1"); + return axum::body::Bytes::from(serde_json::to_vec(&response).unwrap()); + } + } + + axum::body::Bytes::from( + serde_json::to_vec(&serde_json::json!({ + "jsonrpc": "2.0", + "id": request.get("id").cloned().unwrap_or(serde_json::Value::Null), + "result": "0x", + })) + .unwrap(), + ) + } + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let rpc_endpoint = format!("http://{}", listener.local_addr().unwrap()); + let _proxy = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + let source = r#" +interface Vm { + function activeFork() external view returns (uint256); + function broadcastRawTransaction(bytes calldata data) external; + function createFork(string calldata url, uint256 blockNumber) external returns (uint256); + function createSelectFork(string calldata url, uint256 blockNumber) + external + returns (uint256); + function createSelectFork(string calldata url, bytes32 transaction) + external + returns (uint256); + function deal(address account, uint256 newBalance) external; + function makePersistent(address account) external; + function prank(address msgSender) external; + function revertToState(uint256 snapshotId) external returns (bool); + function rpc(string calldata method, string calldata params) external returns (bytes memory); + function rollFork(uint256 blockNumber) external; + function rollFork(bytes32 transaction) external; + function rollFork(uint256 forkId, uint256 blockNumber) external; + function rollFork(uint256 forkId, bytes32 transaction) external; + function selectFork(uint256 forkId) external; + function snapshotState() external returns (uint256); + function transact(bytes32 txHash) external; + function transact(uint256 forkId, bytes32 txHash) external; +} + +interface IReserveBalance { + function dippedIntoReserve() external returns (bool); +} + +contract MonadForkAuxLifecycleTest { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + IReserveBalance constant RESERVE_BALANCE = IReserveBalance(address(0x1001)); + string constant RPC = ""; + address constant SENDER = ; + address constant SPENDER = ; + address constant UNRELATED = ; + address constant MARKER_RECIPIENT = ; + address constant LATER_MARKER_RECIPIENT = ; + address constant RECEIVER = ; + uint256 constant FRESH_BLOCK = ; + uint256 constant RESTRICTED_BLOCK = ; + bytes32 constant MARKER_HASH = ; + bytes32 constant TARGET_HASH = ; + bytes32 constant LATER_TARGET_HASH = ; + + function test_fork_create_select_block_refreshes_fresh_to_restricted() public { + vm.createSelectFork(RPC, FRESH_BLOCK); + vm.createSelectFork(RPC, RESTRICTED_BLOCK); + assertSenderReserve(true); + } + + function test_fork_create_select_block_refreshes_restricted_to_fresh() public { + vm.createSelectFork(RPC, RESTRICTED_BLOCK); + vm.createSelectFork(RPC, FRESH_BLOCK); + assertSenderReserve(false); + } + + function test_fork_create_select_hash_refreshes_fresh_to_restricted() public { + vm.createSelectFork(RPC, MARKER_HASH); + vm.createSelectFork(RPC, TARGET_HASH); + assertSenderReserve(true); + } + + function test_fork_create_select_hash_refreshes_restricted_to_fresh() public { + vm.createSelectFork(RPC, TARGET_HASH); + vm.createSelectFork(RPC, MARKER_HASH); + assertSenderReserve(false); + } + + function test_fork_select_refreshes_fresh_to_restricted() public { + uint256 fresh = vm.createFork(RPC, FRESH_BLOCK); + uint256 restricted = vm.createFork(RPC, RESTRICTED_BLOCK); + vm.selectFork(fresh); + vm.selectFork(restricted); + assertSenderReserve(true); + } + + function test_fork_select_refreshes_restricted_to_fresh() public { + uint256 fresh = vm.createFork(RPC, FRESH_BLOCK); + uint256 restricted = vm.createFork(RPC, RESTRICTED_BLOCK); + vm.selectFork(restricted); + vm.selectFork(fresh); + assertSenderReserve(false); + } + + function test_fork_select_same_id_preserves_tracker() public { + uint256 restricted = vm.createSelectFork(RPC, RESTRICTED_BLOCK); + debit(SENDER); + require(RESERVE_BALANCE.dippedIntoReserve(), "expected initial violation"); + + vm.rpc("test_armBlockRequests", "[]"); + vm.selectFork(restricted); + + require(RESERVE_BALANCE.dippedIntoReserve(), "same-fork select changed tracker"); + } + + function test_fork_roll_block_refreshes_fresh_to_restricted() public { + uint256 active = vm.createSelectFork(RPC, FRESH_BLOCK); + vm.rollFork(active, RESTRICTED_BLOCK); + assertSenderReserve(true); + } + + function test_fork_roll_block_refreshes_restricted_to_fresh() public { + vm.createSelectFork(RPC, RESTRICTED_BLOCK); + vm.rollFork(FRESH_BLOCK); + assertSenderReserve(false); + } + + function test_fork_roll_hash_refreshes_fresh_to_restricted() public { + uint256 active = vm.createSelectFork(RPC, MARKER_HASH); + vm.rollFork(active, TARGET_HASH); + assertSenderReserve(true); + } + + function test_fork_roll_hash_refreshes_restricted_to_fresh() public { + vm.createSelectFork(RPC, TARGET_HASH); + vm.rollFork(MARKER_HASH); + assertSenderReserve(false); + } + + function test_fork_inactive_roll_does_not_change_active_context() public { + vm.createSelectFork(RPC, FRESH_BLOCK); + uint256 inactive = vm.createFork(RPC, FRESH_BLOCK); + + vm.rollFork(inactive, RESTRICTED_BLOCK); + vm.rollFork(inactive, TARGET_HASH); + + assertSenderReserve(false); + } + + function test_fork_inactive_hash_roll_rebases_state_and_preserves_active_context() public { + vm.createSelectFork(RPC, FRESH_BLOCK); + uint256 inactive = vm.createFork(RPC, FRESH_BLOCK); + violateMarkerRecipient(); + + vm.rollFork(inactive, TARGET_HASH); + + require( + MARKER_RECIPIENT.balance > 10 ether - 1, + "inactive replay state was not merged" + ); + require(!RESERVE_BALANCE.dippedIntoReserve(), "merged credit left stale violation"); + assertSenderReserve(false); + } + + function test_fork_active_transact_advances_outer_context() public { + vm.createSelectFork(RPC, FRESH_BLOCK); + vm.transact(MARKER_HASH); + assertSenderReserve(true); + } + + function test_fork_explicit_active_transact_advances_outer_context() public { + uint256 active = vm.createSelectFork(RPC, FRESH_BLOCK); + vm.transact(active, MARKER_HASH); + assertSenderReserve(true); + } + + function test_fork_inactive_transact_rebases_state_and_preserves_active_context() public { + vm.createSelectFork(RPC, FRESH_BLOCK); + uint256 inactive = vm.createFork(RPC, FRESH_BLOCK); + violateMarkerRecipient(); + + vm.transact(inactive, MARKER_HASH); + + require( + MARKER_RECIPIENT.balance > 10 ether - 1, + "inactive replay state was not merged" + ); + require(!RESERVE_BALANCE.dippedIntoReserve(), "merged credit left stale violation"); + assertSenderReserve(false); + } + + function violateMarkerRecipient() internal { + vm.deal(MARKER_RECIPIENT, 10 ether); + vm.prank(MARKER_RECIPIENT); + (bool success,) = payable(RECEIVER).call{value: 1}(""); + require(success, "debit failed"); + require(RESERVE_BALANCE.dippedIntoReserve(), "expected recipient violation"); + } + + function test_fork_failed_transact_preserves_outer_context() public { + vm.createSelectFork(RPC, FRESH_BLOCK); + + bool reverted; + try vm.transact(TARGET_HASH) { + reverted = false; + } catch { + reverted = true; + } + + require(reverted, "non-immediate transaction was replayed"); + assertSenderReserve(false); + + // The failed target must not advance the cursor or block the immediate transaction. + vm.transact(MARKER_HASH); + assertSenderReserve(true); + } + + function test_fork_failed_hash_roll_is_atomic() public { + vm.createSelectFork(RPC, FRESH_BLOCK); + uint256 laterMarkerBalance = LATER_MARKER_RECIPIENT.balance; + debit(SPENDER); + require(RESERVE_BALANCE.dippedIntoReserve(), "expected initial violation"); + + bool reverted = attemptCorruptedHashRoll(0, false); + + require(reverted, "invalid replay transaction succeeded"); + require(block.number == FRESH_BLOCK, "failed roll changed block environment"); + require( + LATER_MARKER_RECIPIENT.balance == laterMarkerBalance, + "partial replay state leaked" + ); + require(RESERVE_BALANCE.dippedIntoReserve(), "failed roll changed tracker"); + + // The failed roll must leave the original cursor able to execute its immediate target. + vm.transact(MARKER_HASH); + assertSenderReserve(true); + } + + function test_fork_failed_hash_roll_retries_without_partial_replay() public { + uint256 active = vm.createSelectFork(RPC, FRESH_BLOCK); + uint256 laterMarkerBalance = LATER_MARKER_RECIPIENT.balance; + + require(attemptCorruptedHashRoll(active, true), "invalid replay transaction succeeded"); + require( + LATER_MARKER_RECIPIENT.balance == laterMarkerBalance, + "partial replay state leaked" + ); + + vm.rollFork(active, LATER_TARGET_HASH); + + require( + LATER_MARKER_RECIPIENT.balance == laterMarkerBalance + 1, + "successful retry did not replay predecessor exactly once" + ); + } + + function test_fork_failed_inactive_hash_roll_is_atomic() public { + uint256 active = vm.createSelectFork(RPC, FRESH_BLOCK); + uint256 inactive = vm.createFork(RPC, FRESH_BLOCK); + uint256 laterMarkerBalance = LATER_MARKER_RECIPIENT.balance; + debit(SPENDER); + require(RESERVE_BALANCE.dippedIntoReserve(), "expected initial violation"); + + require(attemptCorruptedHashRoll(inactive, true), "invalid replay transaction succeeded"); + require(vm.activeFork() == active, "failed roll changed active fork"); + require(block.number == FRESH_BLOCK, "failed roll changed block environment"); + require( + LATER_MARKER_RECIPIENT.balance == laterMarkerBalance, + "inactive partial replay state leaked" + ); + require(RESERVE_BALANCE.dippedIntoReserve(), "failed roll changed tracker"); + + vm.selectFork(inactive); + vm.transact(MARKER_HASH); + assertSenderReserve(true); + } + + function attemptCorruptedHashRoll(uint256 forkId, bool explicitFork) + internal + returns (bool reverted) + { + vm.rpc("test_corruptReplayChainId", "[]"); + if (explicitFork) { + try vm.rollFork(forkId, LATER_TARGET_HASH) { + reverted = false; + } catch { + reverted = true; + } + } else { + try vm.rollFork(LATER_TARGET_HASH) { + reverted = false; + } catch { + reverted = true; + } + } + vm.rpc("test_restoreReplayChainId", "[]"); + } + + function test_broadcast_raw_transaction_rebases_without_advancing_cursor() public { + vm.createSelectFork(RPC, FRESH_BLOCK); + debit(SPENDER); + require(RESERVE_BALANCE.dippedIntoReserve(), "expected initial violation"); + + vm.broadcastRawTransaction(hex""); + + require(SPENDER.balance == 12 ether, "unexpected credited balance"); + require(!RESERVE_BALANCE.dippedIntoReserve(), "credited violation was not cleared"); + + vm.transact(MARKER_HASH); + assertSenderReserve(true); + } + + function test_fork_persistent_violation_survives_select() public { + uint256 first = vm.createFork(RPC, FRESH_BLOCK); + uint256 second = vm.createFork(RPC, FRESH_BLOCK); + vm.selectFork(first); + vm.makePersistent(SPENDER); + debit(SPENDER); + require(RESERVE_BALANCE.dippedIntoReserve(), "expected persistent violation"); + + vm.selectFork(second); + + require(RESERVE_BALANCE.dippedIntoReserve(), "persistent violation was dropped"); + } + + function test_fork_nonpersistent_violation_drops_on_select() public { + uint256 first = vm.createFork(RPC, FRESH_BLOCK); + uint256 second = vm.createFork(RPC, FRESH_BLOCK); + vm.selectFork(first); + debit(SPENDER); + require(RESERVE_BALANCE.dippedIntoReserve(), "expected old-fork violation"); + + vm.selectFork(second); + + require(!RESERVE_BALANCE.dippedIntoReserve(), "old-fork violation leaked"); + } + + function test_fork_unrelated_loaded_account_remains_untracked() public { + uint256 first = vm.createFork(RPC, FRESH_BLOCK); + uint256 second = vm.createFork(RPC, FRESH_BLOCK); + vm.selectFork(first); + vm.makePersistent(UNRELATED); + vm.deal(UNRELATED, 9 ether); + + vm.selectFork(second); + + require(!RESERVE_BALANCE.dippedIntoReserve(), "loaded account became tracked"); + } + + function test_fork_snapshot_restores_chain_and_tracker() public { + uint256 fresh = vm.createFork(RPC, FRESH_BLOCK); + uint256 restricted = vm.createFork(RPC, RESTRICTED_BLOCK); + vm.selectFork(restricted); + debit(SENDER); + require(RESERVE_BALANCE.dippedIntoReserve(), "expected snapshotted violation"); + uint256 snapshot = vm.snapshotState(); + + vm.selectFork(fresh); + require(!RESERVE_BALANCE.dippedIntoReserve(), "fresh context retained violation"); + + require(vm.revertToState(snapshot), "snapshot revert failed"); + require(RESERVE_BALANCE.dippedIntoReserve(), "snapshot did not restore tracker"); + } + + function assertSenderReserve(bool expected) internal { + debit(SENDER); + require( + RESERVE_BALANCE.dippedIntoReserve() == expected, + "unexpected sender reserve state" + ); + } + + function debit(address account) internal { + vm.deal(account, 12 ether); + vm.prank(account); + (bool success,) = payable(RECEIVER).call{value: 3 ether}(""); + require(success, "debit failed"); + } +} +"# + .replace("", &rpc_endpoint) + .replace("", &sender.to_string()) + .replace("", &spender.to_string()) + .replace("", &unrelated.to_string()) + .replace("", &marker_recipient.to_string()) + .replace("", &later_marker_recipient.to_string()) + .replace("", &receiver.to_string()) + .replace("", &fresh_block.to_string()) + .replace("", &restricted_block.to_string()) + .replace("", &marker_hash.to_string()) + .replace("", &target_hash.to_string()) + .replace("", &later_target_hash.to_string()) + .replace("", &alloy_primitives::hex::encode(credit_raw)); + prj.add_test("MonadForkAuxLifecycle.t.sol", &source); + prj.update_config(|config| { + config.hardfork = Some("monad:MonadNine".parse().unwrap()); + config.sender = sender; + }); + + cmd.args(["test", "--network", "monad", "--mc", "MonadForkAuxLifecycleTest", "--threads", "1"]) + .assert_success(); + + block_requests_armed.store(false, std::sync::atomic::Ordering::SeqCst); + block_requests.store(0, std::sync::atomic::Ordering::SeqCst); + cmd.forge_fuse() + .args([ + "test", + "--network", + "monad", + "--mc", + "MonadForkAuxLifecycleTest", + "--mt", + "test_fork_select_same_id_preserves_tracker", + ]) + .assert_success(); + assert_eq!( + block_requests.load(std::sync::atomic::Ordering::SeqCst), + 0, + "same-active select fetched a block" + ); + + cmd.forge_fuse() + .args([ + "test", + "--network", + "monad", + "--mc", + "MonadForkAuxLifecycleTest", + "--mt", + "test_fork_active_transact_advances_outer_context", + "--isolate", + ]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/MonadForkAuxLifecycle.t.sol:MonadForkAuxLifecycleTest +[PASS] test_fork_active_transact_advances_outer_context() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[cfg(feature = "monad")] +#[forgetest] +async fn transact_replays_monad_protocol_system_target_forks(prj: _, cmd: _) { + use alloy_network::{ReceiptResponse as _, TransactionBuilder as _}; + use alloy_provider::Provider as _; + + const SYSTEM_ADDRESS: alloy_primitives::Address = + alloy_primitives::address!("0x6f49a8F621353f12378d0046E7d7e4b9B249DC9e"); + const STAKING_ADDRESS: alloy_primitives::Address = + alloy_primitives::address!("0x0000000000000000000000000000000000001000"); + const BLOCK_AUTHOR: alloy_primitives::Address = Address::repeat_byte(0x11); + const VALIDATOR_AUTH: alloy_primitives::Address = Address::repeat_byte(0x22); + const UNKNOWN_BLOCK_AUTHOR: alloy_primitives::Address = Address::repeat_byte(0x33); + const VALIDATOR_ID: u64 = 7; + + let (api, handle) = anvil::spawn(anvil::NodeConfig::test()).await; + let provider = handle.http_provider(); + let mon = alloy_primitives::U256::from(1_000_000_000_000_000_000u128); + let reward = alloy_primitives::U256::from(25) * mon; + let initial_system_balance = alloy_primitives::U256::from(100) * mon; + let initial_staking_balance = alloy_primitives::U256::from(3) * mon; + api.anvil_impersonate_account(SYSTEM_ADDRESS).await.unwrap(); + api.anvil_set_nonce(SYSTEM_ADDRESS, alloy_primitives::U256::from(11)).await.unwrap(); + api.anvil_set_balance(SYSTEM_ADDRESS, initial_system_balance).await.unwrap(); + api.anvil_set_balance(STAKING_ADDRESS, initial_staking_balance).await.unwrap(); + api.anvil_set_storage_at( + STAKING_ADDRESS, + monad_staking_validator_id_key(BLOCK_AUTHOR), + left_aligned_u64(VALIDATOR_ID).into(), + ) + .await + .unwrap(); + api.anvil_set_storage_at( + STAKING_ADDRESS, + monad_staking_validator_key(0x04, VALIDATOR_ID, 0), + (alloy_primitives::U256::from(100) * mon).into(), + ) + .await + .unwrap(); + api.anvil_set_storage_at( + STAKING_ADDRESS, + monad_staking_validator_key(0x04, VALIDATOR_ID, 1), + B256::ZERO, + ) + .await + .unwrap(); + api.anvil_set_storage_at( + STAKING_ADDRESS, + monad_staking_validator_key(0x09, VALIDATOR_ID, 6), + address_and_flags(VALIDATOR_AUTH, 0).into(), + ) + .await + .unwrap(); + + api.mine_one().await.unwrap(); + let parent_block = provider.get_block_number().await.unwrap(); + + let request = TransactionRequest::default() + .with_from(SYSTEM_ADDRESS) + .with_to(STAKING_ADDRESS) + .with_value(reward) + .with_input(monad_staking_reward_input(BLOCK_AUTHOR)) + .with_gas_limit(1_000_000) + .with_gas_price(2_000_000_000); + let receipt = + provider.send_transaction(request.into()).await.unwrap().get_receipt().await.unwrap(); + assert!(receipt.status()); + assert_eq!(receipt.block_number(), Some(parent_block + 1)); + + let target_hash = receipt.transaction_hash; + let endpoint = foundry_test_utils::rpc::spawn_canonical_monad_system_rpc( + handle.http_endpoint(), + target_hash, + ) + .await; + let transaction = + rpc_request(&endpoint, "eth_getTransactionByHash", serde_json::json!([target_hash])).await; + assert_eq!(transaction["result"]["gas"], "0x0"); + assert_eq!(transaction["result"]["gasPrice"], "0x0"); + assert_ne!(transaction["result"]["r"], "0x0"); + assert_ne!(transaction["result"]["s"], "0x0"); + assert_eq!(transaction["result"]["type"], "0x0"); + assert_ne!(transaction["result"]["v"], "0x0"); + assert_eq!(transaction["result"]["value"], format!("{reward:#x}")); + + let canonical_receipt = + rpc_request(&endpoint, "eth_getTransactionReceipt", serde_json::json!([target_hash])).await; + assert_eq!(canonical_receipt["result"]["status"], "0x1"); + assert_eq!(canonical_receipt["result"]["gasUsed"], "0x0"); + assert_eq!(canonical_receipt["result"]["effectiveGasPrice"], "0x0"); + + let target_block = rpc_request( + &endpoint, + "eth_getBlockByNumber", + serde_json::json!([format!("{:#x}", parent_block + 1), true]), + ) + .await; + assert_ne!(target_block["result"]["baseFeePerGas"], "0x0"); + assert_eq!(target_block["result"]["transactions"][0]["hash"], target_hash.to_string()); + assert_eq!(target_block["result"]["transactions"][0]["gas"], "0x0"); + assert_eq!(target_block["result"]["transactions"][0]["gasPrice"], "0x0"); + assert_eq!(target_block["result"]["transactions"][0]["r"], transaction["result"]["r"]); + assert_eq!(target_block["result"]["transactions"][0]["s"], transaction["result"]["s"]); + assert_eq!(target_block["result"]["transactions"][0]["v"], transaction["result"]["v"]); + + let (failed_api, failed_handle) = anvil::spawn(anvil::NodeConfig::test()).await; + let failed_provider = failed_handle.http_provider(); + failed_api.anvil_impersonate_account(SYSTEM_ADDRESS).await.unwrap(); + failed_api.anvil_set_nonce(SYSTEM_ADDRESS, alloy_primitives::U256::from(11)).await.unwrap(); + failed_api.anvil_set_balance(SYSTEM_ADDRESS, initial_system_balance).await.unwrap(); + failed_api.anvil_set_balance(STAKING_ADDRESS, initial_staking_balance).await.unwrap(); + failed_api.mine_one().await.unwrap(); + let failed_parent_block = failed_provider.get_block_number().await.unwrap(); + let failed_request = TransactionRequest::default() + .with_from(SYSTEM_ADDRESS) + .with_to(STAKING_ADDRESS) + .with_value(reward) + .with_input(monad_staking_reward_input(UNKNOWN_BLOCK_AUTHOR)) + .with_gas_limit(1_000_000) + .with_gas_price(2_000_000_000); + let failed_receipt = failed_provider + .send_transaction(failed_request.into()) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + assert!(failed_receipt.status()); + assert_eq!(failed_receipt.block_number(), Some(failed_parent_block + 1)); + let failed_target_hash = failed_receipt.transaction_hash; + let failed_endpoint = foundry_test_utils::rpc::spawn_canonical_monad_system_rpc( + failed_handle.http_endpoint(), + failed_target_hash, + ) + .await; + + let source = r#" +interface Vm { + struct Log { + bytes32[] topics; + bytes data; + address emitter; + } + + function createSelectFork(string calldata url, bytes32 txHash) external returns (uint256 forkId); + function createSelectFork(string calldata url, uint256 blockNumber) external returns (uint256 forkId); + function recordLogs() external; + function getRecordedLogs() external returns (Log[] memory entries); + function getNonce(address account) external view returns (uint64 nonce); + function transact(bytes32 txHash) external; +} + +interface IMonadStaking { + function getProposerValId() external returns (uint64 validatorId); + function getValidator(uint64 validatorId) external; +} + +contract MonadProtocolSystemTargetTest { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + address constant SYSTEM = 0x6f49a8F621353f12378d0046E7d7e4b9B249DC9e; + IMonadStaking constant STAKING = IMonadStaking(address(0x1000)); + bytes32 constant TARGET_HASH = ; + bytes32 constant FAILED_TARGET_HASH = ; + uint256 constant PARENT_BLOCK = ; + uint256 constant FAILED_PARENT_BLOCK = ; + uint64 constant VALIDATOR_ID = 7; + uint256 constant REWARD = 25 ether; + + function test_reward_target_from_transaction_hash_fork() public { + vm.createSelectFork("", TARGET_HASH); + _assertRewardReplay(); + } + + function test_reward_target_from_parent_block_fork() public { + vm.createSelectFork("", PARENT_BLOCK); + _assertRewardReplay(); + } + + function test_gas_paying_system_sender_target_fork_is_rejected() public { + vm.createSelectFork("", TARGET_HASH); + uint256 systemBalanceBefore = SYSTEM.balance; + uint256 stakingBalanceBefore = address(STAKING).balance; + uint64 nonceBefore = vm.getNonce(SYSTEM); + + bool reverted; + try vm.transact(TARGET_HASH) { + reverted = false; + } catch { + reverted = true; + } + + require(reverted, "noncanonical system envelope was replayed"); + require(SYSTEM.balance == systemBalanceBefore, "protocol caller balance changed"); + require(address(STAKING).balance == stakingBalanceBefore, "staking balance changed"); + require(vm.getNonce(SYSTEM) == nonceBefore, "protocol caller nonce changed"); + } + + function test_failed_reward_target_from_transaction_hash_fork_rolls_back() public { + vm.createSelectFork("", FAILED_TARGET_HASH); + _assertFailedRewardRollback(); + } + + function test_failed_reward_target_from_parent_block_fork_rolls_back() public { + vm.createSelectFork("", FAILED_PARENT_BLOCK); + _assertFailedRewardRollback(); + } + + function _assertFailedRewardRollback() internal { + uint256 systemBalanceBefore = SYSTEM.balance; + uint256 stakingBalanceBefore = address(STAKING).balance; + uint64 nonceBefore = vm.getNonce(SYSTEM); + + bool reverted; + try vm.transact(FAILED_TARGET_HASH) { + reverted = false; + } catch { + reverted = true; + } + + require(reverted, "invalid reward target was replayed"); + require(SYSTEM.balance == systemBalanceBefore, "protocol caller balance changed"); + require(address(STAKING).balance == stakingBalanceBefore, "reward mint was committed"); + require(vm.getNonce(SYSTEM) == nonceBefore, "protocol caller nonce was committed"); + } + + function _assertRewardReplay() internal { + uint256 systemBalanceBefore = SYSTEM.balance; + uint256 stakingBalanceBefore = address(STAKING).balance; + uint64 nonceBefore = vm.getNonce(SYSTEM); + (uint256 accumulatorBefore, uint256 unclaimedBefore) = _validatorRewards(); + require(nonceBefore == 11, "unexpected protocol caller nonce"); + require(stakingBalanceBefore == 3 ether, "unexpected staking prestate balance"); + require(accumulatorBefore == 0, "unexpected reward accumulator"); + require(unclaimedBefore == 0, "unexpected unclaimed rewards"); + + vm.recordLogs(); + vm.transact(TARGET_HASH); + Vm.Log[] memory logs = vm.getRecordedLogs(); + + require(SYSTEM.balance == systemBalanceBefore, "protocol caller paid gas or value"); + require(vm.getNonce(SYSTEM) == nonceBefore + 1, "protocol caller nonce was not advanced"); + require(address(STAKING).balance == stakingBalanceBefore + REWARD, "reward was not minted"); + require(STAKING.getProposerValId() == VALIDATOR_ID, "proposer validator was not updated"); + + (uint256 accumulatorAfter, uint256 unclaimedAfter) = _validatorRewards(); + require(accumulatorAfter > accumulatorBefore, "reward accumulator was not updated"); + require(unclaimedAfter == unclaimedBefore + REWARD, "validator reward was not credited"); + + require(logs.length == 1, "unexpected reward log count"); + require(logs[0].emitter == address(STAKING), "unexpected reward log emitter"); + require(logs[0].topics.length == 3, "unexpected reward log topics"); + require( + logs[0].topics[0] == keccak256("ValidatorRewarded(uint64,address,uint256,uint64)"), + "unexpected reward event" + ); + require(uint256(logs[0].topics[1]) == uint256(VALIDATOR_ID), "unexpected validator topic"); + require( + logs[0].topics[2] == bytes32(uint256(uint160(SYSTEM))), + "unexpected reward sender topic" + ); + (uint256 amount, uint64 epoch) = abi.decode(logs[0].data, (uint256, uint64)); + require(amount == REWARD, "unexpected logged reward"); + require(epoch == 0, "unexpected reward epoch"); + } + + function _validatorRewards() internal returns (uint256 accumulator, uint256 unclaimed) { + (bool ok, bytes memory result) = address(STAKING).call( + abi.encodeWithSelector(IMonadStaking.getValidator.selector, VALIDATOR_ID) + ); + require(ok && result.length >= 192, "failed to read validator"); + assembly { + accumulator := mload(add(result, 128)) + unclaimed := mload(add(result, 192)) + } + } +} +"# + .replace("", &endpoint) + .replace("", &failed_endpoint) + .replace("", &handle.http_endpoint()) + .replace("", &target_hash.to_string()) + .replace("", &failed_target_hash.to_string()) + .replace("", &parent_block.to_string()) + .replace("", &failed_parent_block.to_string()); + prj.add_test("MonadProtocolSystemTarget.t.sol", &source); + prj.update_config(|config| { + config.hardfork = Some("monad:MonadNine".parse().unwrap()); + }); + + cmd.args(["test", "--network", "monad", "--mc", "MonadProtocolSystemTargetTest"]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 5 tests for test/MonadProtocolSystemTarget.t.sol:MonadProtocolSystemTargetTest +[PASS] test_failed_reward_target_from_parent_block_fork_rolls_back() ([GAS]) +[PASS] test_failed_reward_target_from_transaction_hash_fork_rolls_back() ([GAS]) +[PASS] test_gas_paying_system_sender_target_fork_is_rejected() ([GAS]) +[PASS] test_reward_target_from_parent_block_fork() ([GAS]) +[PASS] test_reward_target_from_transaction_hash_fork() ([GAS]) +Suite result: ok. 5 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn test_set_evm_version_tempo_hardfork(prj: _, cmd: _) { + prj.update_config(|config| { + config.solc = Some(OTHER_SOLC_VERSION.into()); + }); + + prj.add_test( + "TempoEvmVersion.t.sol", + r#" +pragma solidity >=0.8.20; + +import {Test} from "forge-std/Test.sol"; + +interface EvmVm { + function getEvmVersion() external pure returns (string memory evm); + function setEvmVersion(string calldata evm) external; +} + +contract TempoEvmVersionTest is Test { + EvmVm constant evm = EvmVm(address(bytes20(uint160(uint256(keccak256("hevm cheat code")))))); + + function test_set_tempo_evm_version() public { + evm.setEvmVersion("T3"); + assertEq(evm.getEvmVersion(), "t3"); + + evm.setEvmVersion("tempo:T2"); + assertEq(evm.getEvmVersion(), "t2"); + } +} + "#, + ); + + cmd.args(["test", "--network", "tempo", "--mc", "TempoEvmVersionTest"]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/TempoEvmVersion.t.sol:TempoEvmVersionTest +[PASS] test_set_tempo_evm_version() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn test_set_evm_version_tempo_gas(prj: _, cmd: _) { + prj.update_config(|config| { + config.solc = Some(OTHER_SOLC_VERSION.into()); + }); + prj.add_test( + "TempoEvmVersionGas.t.sol", + include_str!("../../fixtures/TempoEvmVersionGas.t.sol"), + ); + for hardfork in ["tempo:T3", "tempo:T7", "tempo:T14"] { + cmd.forge_fuse() + .args([ + "test", + "--network", + "tempo", + "--hardfork", + hardfork, + "--mc", + "TempoEvmVersionGasTest", + ]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 5 tests for test/TempoEvmVersionGas.t.sol:TempoEvmVersionGasTest +[PASS] testCodeDepositGas() ([GAS]) +[PASS] testEmptyAccountGas() ([GAS]) +[PASS] testLegacyEthereumAliasGas() ([GAS]) +[PASS] testLegacyNativeRevisionChange() ([GAS]) +[PASS] testNewStorageSlotGas() ([GAS]) +Suite result: ok. 5 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); + } +} + +#[forgetest_init] +fn test_network_tempo_defaults_to_latest_hardfork(prj: _, cmd: _) { + prj.update_config(|config| { + config.solc = Some(OTHER_SOLC_VERSION.into()); + }); + + let expected = + foundry_evm::hardforks::latest_active_tempo_hardfork().to_string().to_lowercase(); + prj.add_test( + "TempoDefaultEvmVersion.t.sol", + &format!( + r#" +pragma solidity >=0.8.20; + +import {{Test}} from "forge-std/Test.sol"; + +interface EvmVm {{ + function getEvmVersion() external pure returns (string memory evm); +}} + +contract TempoDefaultEvmVersionTest is Test {{ + EvmVm constant evm = EvmVm(address(bytes20(uint160(uint256(keccak256("hevm cheat code")))))); + + function test_network_tempo_defaults_to_latest_hardfork() public {{ + assertEq(evm.getEvmVersion(), "{expected}"); + }} +}} + "# + ), + ); + + cmd.args(["test", "--network", "tempo", "--mc", "TempoDefaultEvmVersionTest"]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/TempoDefaultEvmVersion.t.sol:TempoDefaultEvmVersionTest +[PASS] test_network_tempo_defaults_to_latest_hardfork() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +// Validates T5 implicit-approval wiring: the cheatcodes, the AddressRegistry selector, +// unchanged standard approve/transferFrom behavior, an implicit pull through StablecoinDEX, +// and a non-implicit spender control case. +#[forgetest_init] +fn test_tempo_implicit_approval_t5(prj: _, cmd: _) { + prj.update_config(|config| { + config.solc = Some(OTHER_SOLC_VERSION.into()); + // The precompile registry snapshots `cfg.spec` at EVM construction, so pinning T5 + // here is what activates the T5 precompiles and selectors. `vm.setEvmVersion` only + // flips the cheatcode-visible spec. + config.hardfork = Some(FoundryHardfork::Tempo(TempoHardfork::T5)); + }); + + let fixture = include_str!("../../fixtures/TempoImplicitApproval.t.sol"); + prj.add_test("TempoImplicitApproval.t.sol", fixture); + + cmd.args(["test", "--network", "tempo", "--mc", "TempoImplicitApprovalTest"]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 7 tests for test/TempoImplicitApproval.t.sol:TempoImplicitApprovalTest +[PASS] test_assumeImplicitApproval_positive_t5() ([GAS]) +[PASS] test_cheatcode_isImplicitlyApproved_reports_pre_t5() ([GAS]) +[PASS] test_cheatcode_matches_registry_selector_t5() ([GAS]) +[PASS] test_implicit_spender_pulls_without_approve_t5() ([GAS]) +[PASS] test_isImplicitlyApproved_t5() ([GAS]) +[PASS] test_non_implicit_spender_requires_approve_t5() ([GAS]) +[PASS] test_standard_approve_flow_unchanged_t5() ([GAS]) +Suite result: ok. 7 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +// Regression test for : +// configured evm_version must be preserved after createSelectFork / rollFork. +#[forgetest_init] +fn test_fork_preserves_evm_version(prj: _, cmd: _) { + let endpoint = rpc::next_http_archive_rpc_url(); + + prj.update_config(|config| { + config.evm_version = EvmVersion::Cancun; + }); + + prj.add_test( + "ForkEvmVersion.t.sol", + &r#" +import {Test} from "forge-std/Test.sol"; + +contract ForkEvmVersionTest is Test { + function test_evm_version_preserved_after_fork() public { + assertEq(vm.getEvmVersion(), "cancun", "before fork"); + uint256 forkId = vm.createSelectFork("", 21000000); + assertEq(vm.getEvmVersion(), "cancun", "after createSelectFork"); + vm.rollFork(21000001); + assertEq(vm.getEvmVersion(), "cancun", "after rollFork"); + } +} + "# + .replace("", &endpoint), + ); + + cmd.args(["test", "--mc", "ForkEvmVersionTest", "-vvvv"]).assert_success().stdout_eq(str![[ + r#" +... +Ran 1 test for test/ForkEvmVersion.t.sol:ForkEvmVersionTest +[PASS] test_evm_version_preserved_after_fork() ([GAS]) +Traces: + [12097] ForkEvmVersionTest::test_evm_version_preserved_after_fork() + ├─ [0] VM::getEvmVersion() [staticcall] + │ └─ ← [Return] "cancun" + ├─ [0] VM::assertEq("cancun", "cancun", "before fork") [staticcall] + │ └─ ← [Return] + ├─ [0] VM::createSelectFork("", 21000000 [2.1e7]) + │ └─ ← [Return] 0 + ├─ [0] VM::getEvmVersion() [staticcall] + │ └─ ← [Return] "cancun" + ├─ [0] VM::assertEq("cancun", "cancun", "after createSelectFork") [staticcall] + │ └─ ← [Return] + ├─ [0] VM::rollFork(21000001 [2.1e7]) + │ └─ ← [Return] + ├─ [0] VM::getEvmVersion() [staticcall] + │ └─ ← [Return] "cancun" + ├─ [0] VM::assertEq("cancun", "cancun", "after rollFork") [staticcall] + │ └─ ← [Return] + └─ ← [Stop] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"# + ]]); +} diff --git a/crates/forge/tests/cli/test_cmd/symbolic.rs b/crates/forge/tests/cli/test_cmd/symbolic.rs new file mode 100644 index 0000000000000..103293b2b4cf1 --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/symbolic.rs @@ -0,0 +1,7521 @@ +use crate::skip_unless_z3; +use alloy_primitives::{U256, hex, keccak256}; +use foundry_common::sh_eprintln; +use foundry_test_utils::{ + forgetest_init, + snapbox::IntoData, + str, + util::{OutputExt, SOLC_VERSION}, +}; +use serde_json::Value; +use std::{ + path::{Path, PathBuf}, + process::Command, +}; + +use super::symbolic_helpers::{ + assert_symbolic, assert_symbolic_witness, json_test_result, read_artifact_ref, +}; + +fn z3_available() -> bool { + Command::new("z3").arg("--version").output().is_ok_and(|output| output.status.success()) +} + +fn read_artifact(symbolic: &Value) -> Value { + read_artifact_ref(&symbolic["artifact"]) +} + +fn frontier_artifact_path(root: &Path, contract: &str, test: &str) -> PathBuf { + root.join("fuzz_frontiers").join(contract).join(test).join("branch-frontiers.json") +} + +fn find_stateful_frontier_artifact(root: &Path) -> PathBuf { + let mut pending = vec![root.to_path_buf()]; + let mut matches = Vec::new(); + while let Some(dir) = pending.pop() { + for entry in std::fs::read_dir(&dir) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", dir.display())) + { + let path = entry.unwrap().path(); + if path.is_dir() { + pending.push(path); + } else if path.file_name().is_some_and(|name| name == "branch-frontiers.json") { + matches.push(path); + } + } + } + assert_eq!( + matches.len(), + 1, + "expected one stateful frontier artifact under {}", + root.display() + ); + matches.pop().unwrap() +} + +fn keep_only_matching_frontier( + frontier_path: &Path, + missing: &str, + mut matches: impl FnMut(&Value) -> bool, +) -> Value { + let mut artifact: Value = serde_json::from_slice( + &std::fs::read(frontier_path) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", frontier_path.display())), + ) + .unwrap(); + let target_frontier = artifact["frontiers"] + .as_array() + .unwrap() + .iter() + .find(|frontier| matches(frontier)) + .cloned() + .unwrap_or_else(|| panic!("missing {missing} frontier in {artifact}")); + *artifact["frontiers"].as_array_mut().unwrap() = vec![target_frontier.clone()]; + std::fs::write(frontier_path, serde_json::to_vec_pretty(&artifact).unwrap()) + .unwrap_or_else(|err| panic!("failed to write {}: {err}", frontier_path.display())); + target_frontier +} + +fn matching_frontier( + root: &Path, + contract: &str, + test: &str, + missing: &str, + mut matches: impl FnMut(&Value) -> bool, +) -> Value { + let frontier_path = frontier_artifact_path(root, contract, test); + let artifact: Value = serde_json::from_slice( + &std::fs::read(&frontier_path) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", frontier_path.display())), + ) + .unwrap(); + artifact["frontiers"] + .as_array() + .unwrap() + .iter() + .find(|frontier| matches(frontier)) + .cloned() + .unwrap_or_else(|| panic!("missing {missing} frontier in {artifact}")) +} + +fn single_uint_corpus_values( + root: &Path, + contract: &str, + test: &str, + signature: &str, +) -> Vec { + let corpus_dir = + root.join("fuzz_corpus").join(contract).join(test).join("worker0").join("corpus"); + let expected_selector = hex::encode_prefixed(&keccak256(signature.as_bytes())[..4]); + let mut values = Vec::new(); + for entry in std::fs::read_dir(&corpus_dir) + .unwrap_or_else(|err| panic!("failed to read corpus dir {}: {err}", corpus_dir.display())) + { + let entry = entry.unwrap(); + let corpus: Value = serde_json::from_slice(&std::fs::read(entry.path()).unwrap()).unwrap(); + for tx in corpus.as_array().unwrap() { + let calldata = tx["calldata"].as_str().expect("seed calldata"); + if calldata.starts_with(&expected_selector) { + values.push(U256::from_be_slice(&hex::decode(&calldata[10..74]).unwrap())); + } + } + } + values +} + +#[forgetest_init] +fn symbolic_tests_are_ignored_without_flag(prj: _, cmd: _) { + prj.add_test( + "SymbolicIgnored.t.sol", + r#" +contract SymbolicIgnored { + function checkWouldFail(uint256 x) public pure { + assert(x != 42); + } +} +"#, + ); + + cmd.args(["test", "--match-test", "checkWouldFail"]).assert_success().stderr_eq(str![[r#" +... +Warning: No tests found in project! Forge looks for functions that start with `test` +... +"#]]); +} + +#[forgetest_init] +fn symbolic_contract_inline_config_enables_check_entrypoints(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_contract_inline_config_enables_check_entrypoints because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicContractInlineConfig.t.sol", + r#" +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.13; + +/// forge-config: default.symbolic.enabled = true +contract SymbolicContractInlineConfig { + function checkEnabledByContractConfig(uint256 x) public pure { + assert(x != 42); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args(["test", "--match-test", "checkEnabledByContractConfig"])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicContractInlineConfig.t.sol:SymbolicContractInlineConfig +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkEnabledByContractConfig(uint256) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_single_call_artifact_replay_honors_env_fields(prj: _, cmd: _) { + prj.add_test( + "SymbolicSingleCallArtifactEnv.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicSingleCallArtifactEnv is Test { + address constant BOB = address(0xB0B); + + function setUp() public { + vm.warp(1000); + vm.roll(2000); + vm.deal(BOB, 2 ether); + } + + function checkEnv() public payable { + if ( + msg.sender == BOB + && msg.value == 2 ether + && block.timestamp == 1007 + && block.number == 2011 + ) { + revert("artifact env replayed"); + } + } +} +"#, + ); + + let artifact_path = prj.root().join("single-call-env-artifact.json"); + let selector = keccak256(b"checkEnv()"); + let artifact = serde_json::json!({ + "schema_version": 1, + "schema": "foundry:symbolic.counterexample@v1", + "kind": "single_call", + "test": { + "contract": "test/SymbolicSingleCallArtifactEnv.t.sol:SymbolicSingleCallArtifactEnv", + "test": "checkEnv()" + }, + "replay": { + "required": true, + "status": "confirmed", + "reason": null + }, + "replay_semantics": { + "fail_on_revert": false + }, + "bounds": { + "timeout_seconds": null, + "loop_bound": null, + "max_depth": 0, + "max_paths": 0, + "invariant_depth": 0, + "exploration_order": "bfs", + "max_solver_queries": 0, + "default_dynamic_length": 0, + "max_dynamic_length": 0, + "array_lengths": [], + "dynamic_lengths": {}, + "default_array_lengths": [], + "default_bytes_lengths": [], + "max_calldata_bytes": 0, + "symbolic_call_targets": false, + "storage_layout": "solidity" + }, + "solver": { + "name": "manual", + "command": null, + "portfolio": [], + "stats": { + "paths": 0, + "solver_queries": 0, + "smt_queries": 0, + "sat_queries": 0, + "model_queries": 0, + "sat_cache_hits": 0, + "model_cache_hits": 0, + "heuristic_witnesses": 0, + "solver_time_ms": 0, + "smt_input_bytes": 0, + "smt_max_query_bytes": 0, + "smt_build_time_ms": 0, + "smt_max_query_time_ms": 0 + } + }, + "assumptions": [], + "call_trace": { + "available": false, + "source": null, + "format": null + }, + "calls": [{ + "warp": "0x7", + "roll": "0xb", + "sender": "0x0000000000000000000000000000000000000b0b", + "target": "0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496", + "calldata": hex::encode_prefixed(&selector[..4]), + "value": format!("{:#x}", 3_000_000_000_000_000_000u128), + "contract_name": "SymbolicSingleCallArtifactEnv", + "function_name": "checkEnv", + "signature": "checkEnv()", + "args": "", + "raw_args": "" + }] + }); + std::fs::write(&artifact_path, serde_json::to_vec_pretty(&artifact).unwrap()).unwrap(); + + let stdout = cmd + .forge_fuse() + .args(["test", "--replay-symbolic-artifact", artifact_path.to_str().unwrap()]) + .assert_failure() + .get_output() + .stdout_lossy(); + + assert!(stdout.contains("artifact env replayed"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_emits_stateless_solidity_regression(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_emits_stateless_solidity_regression because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicRegressionSingle.t.sol", + r#" +contract SymbolicRegressionSingle { + function checkBoom(uint256 x) public pure { + assert(x != 42); + } +} +"#, + ); + + let output = cmd + .args(["test", "--symbolic", "--emit-regression", "--match-test", "checkBoom"]) + .assert_failure() + .get_output() + .clone(); + let stderr = output.stderr_lossy(); + assert!(stderr.contains("Regression test:"), "{stderr}"); + + let regression = prj + .root() + .join("test/regressions/SymbolicRegressionSingle_checkBoom_SymbolicRegression.t.sol"); + assert!(regression.exists(), "missing regression {}", regression.display()); + + let stdout = cmd + .forge_fuse() + .args(["test", "--match-test", "test_regression_checkBoom_symbolic"]) + .assert_failure() + .get_output() + .stdout_lossy(); + assert!(stdout.contains("test_regression_checkBoom_symbolic()"), "{stdout}"); + assert!(stdout.contains("assertion failed"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_emits_regression_for_contract_with_receive(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_emits_regression_for_contract_with_receive because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicRegressionReceive.t.sol", + r#" +contract SymbolicRegressionReceive { + receive() external payable {} + + function checkReceiveRegression(uint256 x) public pure { + assert(x != 42); + } +} +"#, + ); + + cmd.args(["test", "--symbolic", "--emit-regression", "--match-test", "checkReceiveRegression"]) + .assert_failure(); + + let regression = prj.root().join( + "test/regressions/SymbolicRegressionReceive_checkReceiveRegression_SymbolicRegression.t.sol", + ); + let generated = std::fs::read_to_string(®ression) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", regression.display())); + assert!(generated.contains("pragma solidity >=0.8.0;"), "{generated}"); + assert!(!generated.contains("receive() external payable"), "{generated}"); + + let stdout = cmd + .forge_fuse() + .args(["test", "--match-test", "test_regression_checkReceiveRegression_symbolic"]) + .assert_failure() + .get_output() + .stdout_lossy(); + assert!(stdout.contains("test_regression_checkReceiveRegression_symbolic()"), "{stdout}"); + assert!(stdout.contains("assertion failed"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_emit_regression_rerun_reuses_existing_file(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_emit_regression_rerun_reuses_existing_file because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicRegressionRerun.t.sol", + r#" +contract SymbolicRegressionRerun { + function checkRerun(uint256 x) public pure { + assert(x != 42); + } +} +"#, + ); + + cmd.args(["test", "--symbolic", "--emit-regression", "--match-test", "checkRerun"]) + .assert_failure(); + + let regression = prj + .root() + .join("test/regressions/SymbolicRegressionRerun_checkRerun_SymbolicRegression.t.sol"); + let generated = std::fs::read_to_string(®ression) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", regression.display())); + + let output = cmd + .forge_fuse() + .args(["test", "--symbolic", "--emit-regression", "--match-test", "checkRerun"]) + .assert_failure() + .get_output() + .clone(); + let stderr = output.stderr_lossy(); + assert!(!stderr.contains("already exists"), "{stderr}"); + assert_eq!( + std::fs::read_to_string(®ression) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", regression.display())), + generated + ); + + let nested = prj.root().join( + "test/regressions/SymbolicRegressionRerun_checkRerun_SymbolicRegression_checkRerun_SymbolicRegression.t.sol", + ); + assert!(!nested.exists(), "unexpected nested regression {}", nested.display()); +} + +#[forgetest_init] +fn symbolic_emits_regression_under_custom_test_dir(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_emits_regression_under_custom_test_dir because z3 is not available" + ); + return; + } + + prj.update_config(|config| config.test = "tests".into()); + let tests_dir = prj.root().join("tests"); + std::fs::create_dir_all(&tests_dir).unwrap(); + std::fs::write( + tests_dir.join("SymbolicRegressionCustomDir.t.sol"), + format!( + r#"// SPDX-License-Identifier: UNLICENSED +pragma solidity ={SOLC_VERSION}; + +contract SymbolicRegressionCustomDir {{ + function checkCustomDir(uint256 x) public pure {{ + assert(x != 42); + }} +}} +"# + ), + ) + .unwrap(); + + cmd.args(["test", "--symbolic", "--emit-regression", "--match-test", "checkCustomDir"]) + .assert_failure(); + + let regression = prj.root().join( + "tests/regressions/SymbolicRegressionCustomDir_checkCustomDir_SymbolicRegression.t.sol", + ); + assert!(regression.exists(), "missing regression {}", regression.display()); + + let stdout = cmd + .forge_fuse() + .args(["test", "--match-test", "test_regression_checkCustomDir_symbolic"]) + .assert_failure() + .get_output() + .stdout_lossy(); + assert!(stdout.contains("test_regression_checkCustomDir_symbolic()"), "{stdout}"); + assert!(stdout.contains("assertion failed"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_json_reports_solidity_regression(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_json_reports_solidity_regression because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicRegressionJson.t.sol", + r#" +contract SymbolicRegressionJson { + function checkJsonRegression(uint256 x) public pure { + assert(x != 42); + } +} +"#, + ); + + let output = cmd + .args([ + "test", + "--symbolic", + "--emit-regression", + "--json", + "--match-test", + "checkJsonRegression", + ]) + .assert_failure() + .get_output() + .stdout + .clone(); + let result = json_test_result(&output, "checkJsonRegression(uint256)"); + let regressions = result["symbolic_regressions"].as_array().expect("symbolic regressions"); + assert_eq!(regressions.len(), 1); + assert!(regressions[0]["artifact"].is_string()); + let path = regressions[0]["path"].as_str().expect("regression path"); + assert!(path.ends_with( + "test/regressions/SymbolicRegressionJson_checkJsonRegression_SymbolicRegression.t.sol" + )); + assert!(std::path::Path::new(path).exists()); +} + +// Basic path handling: no-op tests pass, plain reverts and `vm.assume` prune paths, and implicit +// STOP succeeds. +#[forgetest_init] +fn symbolic_basic_path_outcomes(prj: _, cmd: _) { + skip_unless_z3!("symbolic_basic_path_outcomes"); + + prj.add_test( + "SymbolicPass.t.sol", + r#" +contract SymbolicPass { + function checkNoop(uint256) public pure {} +} +"#, + ); + + prj.add_test( + "SymbolicRequire.t.sol", + r#" +contract SymbolicRequire { + function checkRequire(uint256 x) public pure { + require(x != 42, "hit"); + } +} +"#, + ); + + prj.add_test( + "SymbolicAssume.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicAssume is Test { + function checkAssume(uint256 x) public { + vm.assume(x != 42); + assert(x != 42); + } +} +"#, + ); + + prj.add_test( + "SymbolicImplicitStop.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicImplicitStop is Test { + function setUp() public { + // A single JUMPDEST falls off the end of the code without an explicit STOP. + vm.etch(address(this), hex"5b"); + } + + function checkImplicitStop() public {} +} +"#, + ); + + assert_symbolic(cmd.args([ + "test", + "--symbolic", + "--match-test", + "^(checkNoop|checkRequire|checkAssume|checkImplicitStop)\\(", + ])) + .success() + .stdout_eq( + str![[r#" +... +Ran 1 test for test/SymbolicRequire.t.sol:SymbolicRequire +[PASS] checkRequire(uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicPass.t.sol:SymbolicPass +[PASS] checkNoop(uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicAssume.t.sol:SymbolicAssume +[PASS] checkAssume(uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicImplicitStop.t.sol:SymbolicImplicitStop +[PASS] checkImplicitStop() ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]] + .unordered(), + ); +} + +#[forgetest_init] +fn symbolic_cli_accepts_zero_init_storage_layout(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_cli_accepts_zero_init_storage_layout because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicZeroInit.t.sol", + r#" +contract SymbolicZeroInit { + function checkNoop(uint256) public pure {} +} +"#, + ); + + let output = cmd + .args([ + "test", + "--symbolic", + "--symbolic-storage-layout", + "zero_init", + "--json", + "--match-test", + "checkNoop", + ]) + .assert_success() + .get_output() + .stdout + .clone(); + + let result = json_test_result(&output, "checkNoop(uint256)"); + assert_eq!(result["symbolic"]["status"], "pass"); + assert_eq!(result["symbolic"]["bounds"]["storage_layout"], "zero_init"); +} + +#[forgetest_init] +fn symbolic_proves_bounded_carry_after_shift(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_proves_bounded_carry_after_shift because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicBoundedCarry.t.sol", + r#" +contract SymbolicBoundedCarry { + function checkCarryBounds(uint248 limb, uint8 carry) public pure { + if (carry < 58) { + uint256 accumulator = uint256(limb) * 58 + uint256(carry); + assert((accumulator >> 248) < 58); + } + } + + function checkCarryBoundary(uint248 limb, uint8 carry) public pure { + if (limb == type(uint248).max && carry == 58) { + uint256 accumulator = uint256(limb) * 58 + uint256(carry); + assert((accumulator >> 248) < 58); + } + } + + function checkMulDivBoundary(uint256 value) public pure { + uint256 factor = 58; + uint256 boundary = type(uint256).max / factor; + if (value == boundary) { + unchecked { + assert(value * factor / factor == value); + } + } + } + + function checkMulDivPastBoundary(uint256 value) public pure { + uint256 factor = 58; + uint256 boundary = type(uint256).max / factor; + if (value == boundary + 1) { + unchecked { + assert(value * factor / factor == value); + } + } + } +} +"#, + ); + + let output = cmd + .forge_fuse() + .args([ + "test", + "--symbolic", + "--json", + "--optimize", + "--optimizer-runs", + "1000", + "--evm-version", + "paris", + "--match-test", + "checkCarryBounds", + ]) + .assert_success() + .get_output() + .stdout + .clone(); + + let result = json_test_result(&output, "checkCarryBounds(uint248,uint8)"); + assert_eq!(result["symbolic"]["status"], "pass"); + assert_eq!(result["symbolic"]["solver"]["stats"]["heuristic_witnesses"], 0); + + let output = cmd + .forge_fuse() + .args(["test", "--symbolic", "--json", "--optimize", "--match-test", "checkCarryBoundary"]) + .assert_failure() + .get_output() + .stdout + .clone(); + let result = json_test_result(&output, "checkCarryBoundary(uint248,uint8)"); + assert_eq!(result["symbolic"]["status"], "fail_counterexample"); + assert_eq!(result["symbolic"]["replay"]["status"], "confirmed"); + + let output = cmd + .forge_fuse() + .args(["test", "--symbolic", "--json", "--optimize", "--match-test", "checkMulDivBoundary"]) + .assert_success() + .get_output() + .stdout + .clone(); + let result = json_test_result(&output, "checkMulDivBoundary(uint256)"); + assert_eq!(result["symbolic"]["status"], "pass"); + + let output = cmd + .forge_fuse() + .args([ + "test", + "--symbolic", + "--json", + "--optimize", + "--match-test", + "checkMulDivPastBoundary", + ]) + .assert_failure() + .get_output() + .stdout + .clone(); + let result = json_test_result(&output, "checkMulDivPastBoundary(uint256)"); + assert_eq!(result["symbolic"]["status"], "fail_counterexample"); + assert_eq!(result["symbolic"]["replay"]["status"], "confirmed"); +} + +#[forgetest_init] +fn symbolic_proves_fixed_point_fee_bounds(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_proves_fixed_point_fee_bounds because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicFixedPointFee.t.sol", + r#" +library FixedPointMathLib { + function mulWad(uint256 x, uint256 y) internal pure returns (uint256 z) { + assembly { + if gt(x, div(not(0), y)) { + if y { revert(0, 0) } + } + z := div(mul(x, y), 1000000000000000000) + } + } +} + +contract SymbolicFixedPointFee { + uint256 constant WAD = 1e18; + uint256 constant RATE = 3e15; + + function checkMinimumFee(uint128 amount, uint128 minimum) public pure { + uint256 fee = FixedPointMathLib.mulWad(amount, RATE); + if (fee >= minimum) { + unchecked { + assert(uint256(amount) * RATE >= uint256(minimum) * WAD); + } + } + } + + function checkMaximumFee(uint128 amount, uint128 maximum) public pure { + uint256 fee = FixedPointMathLib.mulWad(amount, RATE); + if (fee <= maximum) { + unchecked { + assert(uint256(amount) * RATE < (uint256(maximum) + 1) * WAD); + } + } + } +} +"#, + ); + + let output = cmd + .args(["test", "--symbolic", "--json", "--match-contract", "SymbolicFixedPointFee"]) + .assert_success() + .get_output() + .stdout + .clone(); + + for signature in ["checkMinimumFee(uint128,uint128)", "checkMaximumFee(uint128,uint128)"] { + let result = json_test_result(&output, signature); + assert_eq!(result["symbolic"]["status"], "pass"); + assert_eq!(result["symbolic"]["solver"]["stats"]["heuristic_witnesses"], 0); + } +} + +#[forgetest_init] +fn symbolic_proves_quadratic_quote_domain(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_proves_quadratic_quote_domain because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicQuadraticQuote.t.sol", + r#" +library FixedPointMathLib { + function mulWad(uint256 x, uint256 y) internal pure returns (uint256 z) { + assembly { + if gt(x, div(not(0), y)) { + if y { revert(0, 0) } + } + z := div(mul(x, y), 1000000000000000000) + } + } +} + +contract SymbolicQuadraticQuote { + function quote(uint256 value) external pure returns (uint256) { + return FixedPointMathLib.mulWad(value, value); + } + + function checkQuoteDomain(uint256 value) external { + (bool success,) = address(this).staticcall(abi.encodeCall(this.quote, (value))); + assert(success == (value <= type(uint128).max)); + } +} +"#, + ); + + let output = cmd + .args(["test", "--symbolic", "--json", "--match-contract", "SymbolicQuadraticQuote"]) + .assert_success() + .get_output() + .stdout + .clone(); + let result = json_test_result(&output, "checkQuoteDomain(uint256)"); + assert_eq!(result["symbolic"]["status"], "pass"); + assert_eq!(result["symbolic"]["solver"]["stats"]["heuristic_witnesses"], 0); +} + +#[forgetest_init] +fn symbolic_retries_deferred_nested_arithmetic(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_retries_deferred_nested_arithmetic because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicDeferredExternalCall.t.sol", + r#" +interface Vm { + function assume(bool condition) external pure; + function unixTime() external returns (uint256); +} + +contract SymbolicConversionTarget { + function convert(uint256 base, uint256 coefficient) external pure returns (uint256) { + // A square cannot equal two, but interval bounds alone cannot establish that. + coefficient; + require(base * base != 2); + return base; + } +} + +contract SymbolicCreatedConversion { + uint256 public value; + + constructor(uint256 base, uint256 coefficient) { + coefficient; + require(base * base != 2); + value = base; + } +} + +contract SymbolicDeferredExternalCall { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + SymbolicConversionTarget target; + + function setUp() public { + target = new SymbolicConversionTarget(); + } + + /// forge-config: default.symbolic.timeout = 5 + function checkExternalConversion(uint256 base, uint256 coefficient) external view { + vm.assume(base > 0 && base < 1_000); + vm.assume(coefficient >= 10 && coefficient <= 100); + assert(target.convert(base, coefficient) > 0); + } + + /// forge-config: default.symbolic.timeout = 5 + function checkCreatedConversion(uint256 base, uint256 coefficient) external { + vm.assume(base > 0 && base < 1_000); + vm.assume(coefficient >= 10 && coefficient <= 100); + SymbolicCreatedConversion created = new SymbolicCreatedConversion(base, coefficient); + assert(created.value() > 0); + } + + /// forge-config: default.symbolic.timeout = 5 + function checkNondeterministicExternalConversion(uint256 base, uint256 coefficient) external { + vm.unixTime(); + vm.assume(base > 0 && base < 1_000); + vm.assume(coefficient >= 10 && coefficient <= 100); + assert(target.convert(base, coefficient) > 0); + } + + /// forge-config: default.symbolic.max_solver_queries = 10 + function checkQueryLimitedExternalConversion(uint256 base, uint256 coefficient) external view { + vm.assume(base > 0 && base < 1_000); + vm.assume(coefficient >= 10 && coefficient <= 100); + assert(target.convert(base, coefficient) > 0); + } +} +"#, + ); + + for (test, signature) in [ + ("checkExternalConversion", "checkExternalConversion(uint256,uint256)"), + ("checkCreatedConversion", "checkCreatedConversion(uint256,uint256)"), + ] { + let output = cmd + .forge_fuse() + .args(["test", "--symbolic", "--json", "--optimize", "--match-test", test]) + .assert_success() + .get_output() + .stdout + .clone(); + let result = json_test_result(&output, signature); + assert_eq!(result["symbolic"]["status"], "pass"); + assert!(result["symbolic"]["solver"]["stats"]["smt_queries"].as_u64().unwrap() > 0); + } + + let output = cmd + .forge_fuse() + .args([ + "test", + "--symbolic", + "--json", + "--optimize", + "--match-test", + "checkNondeterministicExternalConversion", + ]) + .assert_failure() + .get_output() + .stdout + .clone(); + let result = + json_test_result(&output, "checkNondeterministicExternalConversion(uint256,uint256)"); + assert_eq!(result["symbolic"]["status"], "incomplete"); + assert!( + result["symbolic"]["incomplete"]["reason"] + .as_str() + .unwrap() + .contains("nested hard arithmetic") + ); + + let output = cmd + .forge_fuse() + .args([ + "test", + "--symbolic", + "--json", + "--optimize", + "--match-test", + "checkQueryLimitedExternalConversion", + ]) + .assert_failure() + .get_output() + .stdout + .clone(); + let result = json_test_result(&output, "checkQueryLimitedExternalConversion(uint256,uint256)"); + assert_eq!(result["symbolic"]["status"], "incomplete"); + assert!( + result["symbolic"]["incomplete"]["reason"] + .as_str() + .unwrap() + .contains("solver query limit exceeded (10)") + ); + assert_eq!(result["symbolic"]["solver"]["stats"]["solver_queries"], 10); + assert!(result["symbolic"]["solver"]["stats"]["paths"].as_u64().unwrap() > 2); +} + +#[forgetest_init] +fn symbolic_preserves_completed_external_call_counterexamples(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_preserves_completed_external_call_counterexamples because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicDeferredExternalPriority.t.sol", + r#" +interface SymbolicDeferredPriorityVm { + function assume(bool condition) external pure; + function unixTime() external returns (uint256); +} + +contract SymbolicDeferredPriorityTarget { + function evaluate(uint256 x, uint256 y) external pure returns (uint256) { + unchecked { + uint256 value = (x * y + 9) / 10; + if (value == 37) return 1; + if (value == 38) return 2; + } + return 0; + } +} + +contract SymbolicDeferredExternalPriority { + SymbolicDeferredPriorityVm constant vm = SymbolicDeferredPriorityVm( + address(uint160(uint256(keccak256("hevm cheat code")))) + ); + SymbolicDeferredPriorityTarget target; + + function setUp() public { + target = new SymbolicDeferredPriorityTarget(); + } + + /// forge-config: default.symbolic.max_solver_queries = 16 + function checkCompletedCallBfs(uint256 x, uint256 y) external view { + vm.assume(x >= 11 && x <= 18); + vm.assume(y >= 19 && y <= 30); + assert(target.evaluate(x, y) == 0); + } + + /// forge-config: default.symbolic.max_solver_queries = 16 + /// forge-config: default.symbolic.exploration_order = "dfs" + function checkCompletedCallDfs(uint256 x, uint256 y) external view { + vm.assume(x >= 11 && x <= 18); + vm.assume(y >= 19 && y <= 30); + assert(target.evaluate(x, y) == 0); + } + + function checkDeferredHostRead(uint256 x, uint256 y) external { + vm.assume(x >= 11 && x <= 18); + vm.assume(y >= 19 && y <= 30); + uint256 value = target.evaluate(x, y); + if (value != 0) vm.unixTime(); + assert(value <= 2); + } +} +"#, + ); + + let output = cmd + .args([ + "test", + "--symbolic", + "--json", + "--optimize", + "--match-contract", + "SymbolicDeferredExternalPriority", + ]) + .assert_failure() + .get_output() + .stdout + .clone(); + for signature in + ["checkCompletedCallBfs(uint256,uint256)", "checkCompletedCallDfs(uint256,uint256)"] + { + let result = json_test_result(&output, signature); + assert_eq!(result["symbolic"]["status"], "fail_counterexample"); + assert_eq!(result["symbolic"]["replay"]["status"], "confirmed"); + assert!(result["symbolic"]["solver"]["stats"]["smt_queries"].as_u64().unwrap() > 0); + } + + let result = json_test_result(&output, "checkDeferredHostRead(uint256,uint256)"); + assert_eq!(result["symbolic"]["status"], "incomplete"); + assert!( + result["symbolic"]["incomplete"]["reason"] + .as_str() + .unwrap() + .contains("nested hard arithmetic") + ); +} + +#[forgetest_init] +fn symbolic_proves_saturating_mul_equivalence(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_proves_saturating_mul_equivalence because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicSaturatingMul.t.sol", + r#" +library SaturatingMath { + function saturatingMul(uint256 x, uint256 y) internal pure returns (uint256 z) { + assembly { + z := or(sub(or(iszero(x), eq(div(mul(x, y), x), y)), 1), mul(x, y)) + } + } +} + +contract SymbolicSaturatingMul { + function mul(uint256 x, uint256 y) external pure returns (uint256) { + return x * y; + } + + function checkSaturatingMul(uint256 x, uint256 y) public view { + (bool success,) = address(this).staticcall(abi.encodeCall(this.mul, (x, y))); + uint256 expected = success ? x * y : type(uint256).max; + assert(SaturatingMath.saturatingMul(x, y) == expected); + } + + function checkWrongOverflowResult(uint256 x, uint256 y) public pure { + if (x == type(uint256).max && y == 2) { + assert(SaturatingMath.saturatingMul(x, y) == 0); + } + } +} +"#, + ); + + let output = cmd + .forge_fuse() + .args(["test", "--symbolic", "--json", "--optimize", "--match-test", "checkSaturatingMul"]) + .assert_success() + .get_output() + .stdout + .clone(); + + let result = json_test_result(&output, "checkSaturatingMul(uint256,uint256)"); + assert_eq!(result["symbolic"]["status"], "pass"); + assert_eq!(result["symbolic"]["solver"]["stats"]["smt_queries"], 0); + assert_eq!(result["symbolic"]["solver"]["stats"]["heuristic_witnesses"], 0); + + let output = cmd + .forge_fuse() + .args([ + "test", + "--symbolic", + "--json", + "--optimize", + "--match-test", + "checkWrongOverflowResult", + ]) + .assert_failure() + .get_output() + .stdout + .clone(); + let result = json_test_result(&output, "checkWrongOverflowResult(uint256,uint256)"); + assert_eq!(result["symbolic"]["status"], "fail_counterexample"); + assert_eq!(result["symbolic"]["replay"]["status"], "confirmed"); +} + +#[forgetest_init] +fn symbolic_proves_p256_normalization(prj: _, cmd: _) { + if !z3_available() { + let _ = + sh_eprintln!("skipping symbolic_proves_p256_normalization because z3 is not available"); + return; + } + + prj.add_test( + "SymbolicP256.t.sol", + r#" +contract SymbolicP256 { + uint256 constant N = + 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551; + uint256 constant HALF_N = + 0x7fffffff800000007fffffffffffffffde737d56d38bcf4279dce5617e3192a8; + + function checkP256Normalized(uint256 s) public pure { + uint256 result; + assembly { + result := xor(s, mul(xor(sub(N, s), s), gt(s, HALF_N))) + } + unchecked { + uint256 expected = s > N / 2 ? N - s : s; + assert(result == expected); + } + } +} +"#, + ); + + let output = cmd + .args(["test", "--symbolic", "--json", "--optimize", "--match-test", "checkP256Normalized"]) + .assert_success() + .get_output() + .stdout + .clone(); + + let result = json_test_result(&output, "checkP256Normalized(uint256)"); + assert_eq!(result["symbolic"]["status"], "pass"); + assert_eq!(result["symbolic"]["solver"]["stats"]["heuristic_witnesses"], 0); +} + +#[forgetest_init] +fn symbolic_proves_branchless_operation_state(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_proves_branchless_operation_state because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicOperationState.t.sol", + r#" +contract SymbolicOperationState { + function checkOperationState(uint256 packed, uint256 time) public pure { + if (!isReadyOriginal(packed ^ 1, time) && isDoneOriginal(packed, time)) { + packed ^= 1; + assert(!isReadyOriginal(packed, time)); + } + uint256 optimized; + assembly { + optimized := mul( + iszero(iszero(packed)), + add(and(packed, 1), sub(2, lt(time, shr(1, packed)))) + ) + } + assert(optimized == original(packed, time)); + } + + function isReadyOriginal(uint256 packed, uint256 time) internal pure returns (bool) { + return original(packed, time) == 2; + } + + function isDoneOriginal(uint256 packed, uint256 time) internal pure returns (bool) { + return original(packed, time) == 3; + } + + function original(uint256 packed, uint256 time) internal pure returns (uint256) { + if (packed == 0) return 0; + if (packed & 1 == 1) return 3; + if (packed >> 1 > time) return 1; + return 2; + } +} +"#, + ); + + let output = cmd + .args(["test", "--symbolic", "--json", "--optimize", "--match-test", "checkOperationState"]) + .assert_success() + .get_output() + .stdout + .clone(); + + let result = json_test_result(&output, "checkOperationState(uint256,uint256)"); + assert_eq!(result["symbolic"]["status"], "pass"); + assert_eq!(result["symbolic"]["solver"]["stats"]["heuristic_witnesses"], 0); +} + +#[forgetest_init] +fn symbolic_json_schema_reports_pass(prj: _, cmd: _) { + if !z3_available() { + let _ = + sh_eprintln!("skipping symbolic_json_schema_reports_pass because z3 is not available"); + return; + } + + prj.add_test( + "SymbolicJsonPass.t.sol", + r#" +contract SymbolicJsonPass { + function checkNoop(uint256) public pure {} +} +"#, + ); + + let output = cmd + .args(["test", "--symbolic", "--json", "--match-test", "checkNoop"]) + .assert_success() + .get_output() + .stdout + .clone(); + + let result = json_test_result(&output, "checkNoop(uint256)"); + let symbolic = &result["symbolic"]; + assert_eq!(symbolic["schema_version"], 1); + assert_eq!(symbolic["status"], "pass"); + assert!(symbolic["incomplete"].is_null()); + assert_eq!(symbolic["replay"]["required"], false); + assert_eq!(symbolic["replay"]["status"], "not_required"); + assert!(symbolic["counterexample"].is_null()); + assert_eq!(symbolic["bounds"]["max_paths"], 1024); + assert_eq!(symbolic["solver"]["name"], "z3"); + assert!(symbolic["solver"]["stats"]["paths"].as_u64().unwrap() >= 1); + assert_eq!(symbolic["assumptions"][0]["kind"], "bounded_exploration"); +} + +#[forgetest_init] +fn symbolic_loop_bound_limits_symbolic_unrolling(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_loop_bound_limits_symbolic_unrolling because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicLoopBound.t.sol", + r#" +contract SymbolicLoopBound { + /// forge-config: default.symbolic.loop = 2 + function checkLoopBound(uint8 n) public pure { + uint256 i; + while (i < n) { + ++i; + } + assert(i <= 2); + } +} +"#, + ); + + let stdout = + assert_symbolic_witness(cmd.args(["test", "--symbolic", "--match-test", "checkLoopBound"])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicLoopBound.t.sol:SymbolicLoopBound +[PASS] checkLoopBound(uint8) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic depth limit exceeded"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_finds_assert_counterexample(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_finds_assert_counterexample because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicAssert.t.sol", + r#" +contract SymbolicAssert { + function checkRejectsFortyTwo(uint256 x) public pure { + assert(x != 42); + } +} +"#, + ); + + let stderr = + assert_symbolic(cmd.args(["test", "--symbolic", "--match-test", "checkRejectsFortyTwo"])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicAssert.t.sol:SymbolicAssert +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] calldata=0xf24fa427000000000000000000000000000000000000000000000000000000000000002a args=[42]] checkRejectsFortyTwo(uint256) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stderr_lossy(); + + assert!(stderr.contains("Counterexample artifact:"), "{stderr}"); + assert!(stderr.contains("cache/symbolic/"), "{stderr}"); +} + +#[forgetest_init] +fn symbolic_json_schema_reports_replayed_counterexample(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_json_schema_reports_replayed_counterexample because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicJsonCounterexample.t.sol", + r#" +contract SymbolicJsonCounterexample { + function checkRejectsFortyTwo(uint256 x) public pure { + assert(x != 42); + } +} +"#, + ); + prj.add_test( + "SymbolicJsonCounterexampleDuplicate.t.sol", + r#" +contract SymbolicJsonCounterexample { + function checkRejectsFortyTwo(uint256) public pure {} +} +"#, + ); + + let output = cmd + .args([ + "test", + "--symbolic", + "--json", + "--match-test", + "checkRejectsFortyTwo", + "--match-path", + "test/SymbolicJsonCounterexample.t.sol", + ]) + .assert_failure() + .get_output() + .stdout + .clone(); + + let result = json_test_result(&output, "checkRejectsFortyTwo(uint256)"); + let symbolic = &result["symbolic"]; + assert_eq!(symbolic["status"], "fail_counterexample"); + assert!(symbolic["incomplete"].is_null()); + assert_eq!(symbolic["replay"]["required"], true); + assert_eq!(symbolic["replay"]["status"], "confirmed"); + assert!(symbolic["counterexample"]["calldata"].as_str().unwrap().starts_with("0x")); + assert_eq!(symbolic["counterexample"]["args"], "42"); + assert_eq!(symbolic["counterexample"]["raw_args"], "42"); + assert_eq!(symbolic["artifact"]["schema"], "foundry:symbolic.counterexample@v1"); + assert_eq!(result["counterexample_artifacts"].as_array().unwrap().len(), 2); + assert_eq!(result["counterexample_artifacts"][0], symbolic["artifact"]); + assert_eq!(symbolic["minimization"]["minimized"], symbolic["artifact"]); + assert_eq!(symbolic["minimization"]["accepted"], 0); + assert_eq!(symbolic["minimization"]["original_calldata_bytes"], 36); + assert_eq!(symbolic["minimization"]["minimized_calldata_bytes"], 36); + let original_artifact = read_artifact_ref(&symbolic["minimization"]["original"]); + assert_eq!(original_artifact["calls"][0]["args"], "42"); + let artifact_path = symbolic["artifact"]["path"].as_str().unwrap().to_string(); + let artifact = read_artifact(symbolic); + assert_eq!(artifact["schema_version"], 1); + assert_eq!(artifact["schema"], "foundry:symbolic.counterexample@v1"); + assert_eq!(artifact["kind"], "single_call"); + assert_eq!(artifact["test"]["test"], "checkRejectsFortyTwo(uint256)"); + assert_eq!(artifact["replay"]["status"], "confirmed"); + assert_eq!(artifact["calls"].as_array().unwrap().len(), 1); + assert_eq!(original_artifact["calls"][0]["sender"], artifact["calls"][0]["sender"]); + assert!(artifact["calls"][0]["calldata"].as_str().unwrap().starts_with("0x")); + assert_eq!(artifact["calls"][0]["args"], "42"); + assert_eq!(artifact["calls"][0]["raw_args"], "42"); + assert!(symbolic["solver"]["stats"]["model_queries"].as_u64().unwrap() >= 1); + + let replay_stdout = assert_symbolic(cmd.forge_fuse().args([ + "test", + "--replay-symbolic-artifact", + &artifact_path, + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicJsonCounterexample.t.sol:SymbolicJsonCounterexample +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] calldata=0xf24fa427000000000000000000000000000000000000000000000000000000000000002a args=[42]] checkRejectsFortyTwo(uint256) ([GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + assert!( + !replay_stdout.contains("SymbolicJsonCounterexampleDuplicate.t.sol"), + "{replay_stdout}" + ); + + prj.add_test( + "SymbolicJsonCounterexample.t.sol", + r#" +contract SymbolicJsonCounterexample { + function checkRejectsFortyTwo(uint256) public pure {} +} +"#, + ); + cmd.forge_fuse().args(["test", "--replay-symbolic-artifact", &artifact_path]).assert_success(); + + prj.add_test( + "SymbolicJsonCounterexample.t.sol", + r#" +contract SymbolicJsonCounterexample { + function checkRenamed(uint256) public pure {} +} +"#, + ); + let stale_stderr = cmd + .forge_fuse() + .args(["test", "--replay-symbolic-artifact", &artifact_path]) + .assert_failure() + .get_output() + .stderr_lossy(); + assert!(stale_stderr.contains("symbolic artifact target"), "{stale_stderr}"); + assert!( + stale_stderr.contains("checkRejectsFortyTwo(uint256)` was not found"), + "{stale_stderr}" + ); +} + +#[forgetest_init] +fn symbolic_minimizes_replayed_counterexample_artifact(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_minimizes_replayed_counterexample_artifact because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicMinimizeCounterexample.t.sol", + r#" +contract SymbolicMinimizeCounterexample { + /// forge-config: default.symbolic.array_lengths = [33] + function checkMinimize(uint256 x, bytes memory data) public pure { + if ((x & 0x2a) == 0x2a && data.length >= 2 && data[1] == 0x42) { + assert(false); + } + } +} +"#, + ); + + let output = cmd + .args(["test", "--symbolic", "--json", "--match-test", "checkMinimize"]) + .assert_failure() + .get_output() + .stdout + .clone(); + + let result = json_test_result(&output, "checkMinimize(uint256,bytes)"); + let symbolic = &result["symbolic"]; + assert_eq!(symbolic["status"], "fail_counterexample"); + assert_eq!(result["counterexample_artifacts"].as_array().unwrap().len(), 2); + assert_eq!(symbolic["minimization"]["minimized"], symbolic["artifact"]); + assert!( + symbolic["minimization"]["attempts"].as_u64().unwrap() + > symbolic["minimization"]["accepted"].as_u64().unwrap() + ); + assert!(symbolic["minimization"]["accepted"].as_u64().unwrap() > 0); + assert!( + symbolic["minimization"]["minimized_calldata_bytes"].as_u64().unwrap() + < symbolic["minimization"]["original_calldata_bytes"].as_u64().unwrap() + ); + + let original = read_artifact_ref(&symbolic["minimization"]["original"]); + let minimized = read_artifact(symbolic); + assert_eq!(original["replay"]["status"], "confirmed"); + assert_eq!(minimized["replay"]["status"], "confirmed"); + assert_ne!(original["calls"][0]["calldata"], minimized["calls"][0]["calldata"]); + assert_eq!(minimized["calls"][0]["args"], "42, 0x0042"); + + assert_symbolic(cmd.forge_fuse().args([ + "test", + "--replay-symbolic-artifact", + symbolic["artifact"]["path"].as_str().unwrap(), + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicMinimizeCounterexample.t.sol:SymbolicMinimizeCounterexample +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] calldata=0x29e50d14000000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000020042000000000000000000000000000000000000000000000000000000000000 args=[42, 0x0042]] checkMinimize(uint256,bytes) ([GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_minimizer_skips_reasonless_failure_flag(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_minimizer_skips_reasonless_failure_flag because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicMinimizeFailureFlag.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicMinimizeFailureFlag is Test { + function checkFailureFlag(uint256 x) public { + if (x == 0) revert("candidate-revert"); + if (x == 42) fail(); + } +} +"#, + ); + + let output = cmd + .args(["test", "--symbolic", "--json", "--match-test", "checkFailureFlag"]) + .assert_failure() + .get_output() + .stdout + .clone(); + + let result = json_test_result(&output, "checkFailureFlag(uint256)"); + let symbolic = &result["symbolic"]; + assert_eq!(symbolic["status"], "fail_counterexample"); + assert_eq!(symbolic["replay"]["status"], "confirmed"); + assert_eq!(symbolic["counterexample"]["raw_args"], "42"); + assert!(symbolic["minimization"].is_null()); + assert_eq!(result["counterexample_artifacts"].as_array().unwrap().len(), 1); + + let artifact = read_artifact(symbolic); + assert_eq!(artifact["replay"]["status"], "confirmed"); + assert_eq!(artifact["calls"][0]["raw_args"], "42"); + + assert_symbolic(cmd.forge_fuse().args([ + "test", + "--replay-symbolic-artifact", + symbolic["artifact"]["path"].as_str().unwrap(), + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicMinimizeFailureFlag.t.sol:SymbolicMinimizeFailureFlag +[FAIL: ; counterexample: [SENDER] [SENDER] calldata=0x04f6a46a000000000000000000000000000000000000000000000000000000000000002a args=[42]] checkFailureFlag(uint256) ([GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_minimizes_echidna_address_array_duplicate_fixture(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_minimizes_echidna_address_array_duplicate_fixture because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicMinimizeAddressArrayDuplicate.t.sol", + r#" +library AddressArrayUtilsBug { + function hasDuplicate(address[] memory xs) internal pure returns (bool) { + for (uint256 i = 0; i < xs.length; i++) { + for (uint256 j = i + 1; j < xs.length; j++) { + if (xs[i] == xs[j]) return true; + } + } + return false; + } +} + +contract SymbolicMinimizeAddressArrayDuplicate { + /// forge-config: default.symbolic.array_lengths = [6] + function checkNoDuplicate(address[] memory xs) public pure { + assert(!AddressArrayUtilsBug.hasDuplicate(xs)); + } +} +"#, + ); + + let output = cmd + .args(["test", "--symbolic", "--json", "--match-test", "checkNoDuplicate"]) + .assert_failure() + .get_output() + .stdout + .clone(); + + let result = json_test_result(&output, "checkNoDuplicate(address[])"); + let symbolic = &result["symbolic"]; + assert_eq!(symbolic["status"], "fail_counterexample"); + assert_eq!(result["counterexample_artifacts"].as_array().unwrap().len(), 2); + assert!(symbolic["minimization"]["accepted"].as_u64().unwrap() > 0); + assert_eq!(symbolic["minimization"]["original_calldata_bytes"], 260); + assert_eq!(symbolic["minimization"]["minimized_calldata_bytes"], 132); + + let original = read_artifact_ref(&symbolic["minimization"]["original"]); + let minimized = read_artifact(symbolic); + assert_eq!(original["replay"]["status"], "confirmed"); + assert_eq!(minimized["replay"]["status"], "confirmed"); + assert_ne!(original["calls"][0]["calldata"], minimized["calls"][0]["calldata"]); + assert_eq!( + minimized["calls"][0]["args"], + "[0x0000000000000000000000000000000000000000, 0x0000000000000000000000000000000000000000]" + ); + + assert_symbolic(cmd.forge_fuse().args([ + "test", + "--replay-symbolic-artifact", + symbolic["artifact"]["path"].as_str().unwrap(), + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicMinimizeAddressArrayDuplicate.t.sol:SymbolicMinimizeAddressArrayDuplicate +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] calldata=0x788be8fd0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 args=[[0x0000000000000000000000000000000000000000, 0x0000000000000000000000000000000000000000]]] checkNoDuplicate(address[]) ([GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_json_schema_reports_replay_skip(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_json_schema_reports_replay_skip because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicJsonReplaySkip.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicJsonReplaySkip is Test { + function checkReplaySkip(uint256 x) public { + uint256 startedAt = vm.unixTime(); + vm.sleep(500); + vm.skip(vm.unixTime() >= startedAt + 250, "replay slept"); + assert(x != 42); + } +} +"#, + ); + + let output = cmd + .args(["test", "--symbolic", "--json", "--match-test", "checkReplaySkip"]) + .assert_success() + .get_output() + .stdout + .clone(); + + let result = json_test_result(&output, "checkReplaySkip(uint256)"); + assert_eq!(result["status"], "Skipped"); + assert_eq!(result["reason"], "replay slept"); + assert!(result["counterexample"].is_null()); + + let symbolic = &result["symbolic"]; + assert_eq!(symbolic["status"], "incomplete"); + assert_eq!(symbolic["incomplete"]["kind"], "error"); + assert_eq!(symbolic["replay"]["required"], true); + assert_eq!(symbolic["replay"]["status"], "skipped"); + assert!( + symbolic["replay"]["reason"].as_str().unwrap().contains("vm.skip during concrete replay") + ); + assert_eq!(symbolic["counterexample"]["args"], "42"); + assert!(symbolic["artifact"].is_null()); +} + +#[forgetest_init] +fn symbolic_json_schema_reports_incomplete(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_json_schema_reports_incomplete because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicJsonIncomplete.t.sol", + r#" +contract SymbolicJsonIncomplete { + function checkWidth(uint8 x) public pure { + uint256 acc; + if ((x & 0x01) != 0) acc += 1; else acc += 2; + if ((x & 0x02) != 0) acc += 4; else acc += 8; + assert(acc != 0); + } +} +"#, + ); + + let output = cmd + .args([ + "test", + "--symbolic", + "--json", + "--symbolic-width", + "1", + "--match-test", + "checkWidth", + ]) + .assert_failure() + .get_output() + .stdout + .clone(); + + let result = json_test_result(&output, "checkWidth(uint8)"); + let symbolic = &result["symbolic"]; + assert_eq!(symbolic["status"], "incomplete"); + assert_eq!(symbolic["incomplete"]["kind"], "stuck"); + assert!(symbolic["incomplete"]["reason"].as_str().unwrap().contains("path limit")); + assert_eq!(symbolic["bounds"]["max_paths"], 1); + assert_eq!(symbolic["replay"]["status"], "not_required"); + assert!(symbolic["counterexample"].is_null()); +} + +#[forgetest_init] +fn symbolic_finds_wrapping_arithmetic_riddle_counterexample(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_finds_wrapping_arithmetic_riddle_counterexample because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicRiddle.t.sol", + r#" +contract SymbolicRiddle { + function check_riddle(uint256 x) external pure { + uint256 msgSender = uint160(0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38); + + unchecked { + require(x * x < msgSender); + } + + require(x > msgSender); + require(x & 0x800 != 0); + require(x & 0x10000 == 0); + + assert(false); + } +} +"#, + ); + + let stdout = + assert_symbolic_witness(cmd.args(["test", "--symbolic", "--match-test", "check_riddle"])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicRiddle.t.sol:SymbolicRiddle +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] check_riddle(uint256) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("unsupported symbolic execution feature"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_requires_successful_path(prj: _, cmd: _) { + crate::skip_unless_z3!("symbolic_requires_successful_path"); + + prj.add_test( + "SymbolicEmptyDomain.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicEmptyDomain is Test { + function checkConstantAssumption() public { + vm.assume(false); + } + + function checkContradictoryAssumptions(uint256 x) public { + vm.assume(x > 10); + vm.assume(x < 5); + } + + function checkRejectedAndReverted(bool reject) public { + if (reject) vm.assume(false); + revert(); + } + + function checkSomeAccepted(bool accept) public { + vm.assume(accept); + assert(accept); + } + + function checkSkipped() public { + vm.skip(true); + } +} +"#, + ); + + assert_symbolic(cmd.args(["test", "--symbolic", "--match-contract", "SymbolicEmptyDomain"])) + .failure() + .stdout_eq(str![[r#" +... +Ran 5 tests for test/SymbolicEmptyDomain.t.sol:SymbolicEmptyDomain +[FAIL: incomplete symbolic execution (Stuck): no successful symbolic paths] checkConstantAssumption() ([METRICS]) +[FAIL: incomplete symbolic execution (Stuck): no successful symbolic paths] checkContradictoryAssumptions(uint256) ([METRICS]) +[FAIL: incomplete symbolic execution (RevertAll): all symbolic paths reverted] checkRejectedAndReverted(bool) ([METRICS]) +[FAIL: incomplete symbolic execution (Stuck): no successful symbolic paths] checkSkipped() ([METRICS]) +[PASS] checkSomeAccepted(bool) ([METRICS]) +... +"#]]); +} + +#[forgetest_init] +fn symbolic_finds_bytes_counterexample_with_native_inline_config(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_finds_bytes_counterexample_with_native_inline_config because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicBytes.t.sol", + r#" +contract SymbolicBytes { + /// forge-config: default.symbolic.array_lengths = [3] + function checkBytes(bytes memory data) public pure { + if (data[1] == 0x42) { + assert(false); + } + } +} +"#, + ); + + assert_symbolic_witness(cmd.args(["test", "--symbolic", "--match-test", "checkBytes"])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicBytes.t.sol:SymbolicBytes +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkBytes(bytes) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_replays_string_counterexample(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_replays_string_counterexample because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicString.t.sol", + r#" +contract SymbolicString { + /// forge-config: default.symbolic.array_lengths = [3] + function checkString(string memory value) public pure { + bytes memory data = bytes(value); + if (data[0] == bytes1(uint8(0x41))) { + assert(false); + } + } +} +"#, + ); + + assert_symbolic_witness(cmd.args(["test", "--symbolic", "--match-test", "checkString"])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicString.t.sol:SymbolicString +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkString(string) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_uses_native_array_lengths(prj: _, cmd: _) { + if !z3_available() { + let _ = + sh_eprintln!("skipping symbolic_uses_native_array_lengths because z3 is not available"); + return; + } + + prj.add_test( + "SymbolicNativeArrayLengths.t.sol", + r#" +contract SymbolicNativeArrayLengths { + /// forge-config: default.symbolic.array_lengths = [3] + function checkArray(uint256[] memory values) public pure { + assert(values.length == 3); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args(["test", "--symbolic", "--match-test", "checkArray"])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicNativeArrayLengths.t.sol:SymbolicNativeArrayLengths +[PASS] checkArray(uint256[]) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_handles_array_assertions_from_symbolic_memory(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_handles_array_assertions_from_symbolic_memory because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicArrayAssertions.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicArrayAssertions is Test { + function checkArrayCopy(uint256[] memory values) public pure { + uint256[] memory copy = new uint256[](values.length); + for (uint256 i; i < values.length; ++i) copy[i] = values[i]; + assertEq(values, copy); + } + + function checkCorruptedArrayCopy(uint256[] memory values) public pure { + uint256[] memory copy = new uint256[](values.length); + for (uint256 i; i < values.length; ++i) copy[i] = values[i]; + if (copy.length != 0) copy[0] ^= 1; + assertEq(values, copy); + } + + function checkConcretePointerWithSymbolicSize(bool padded) public view { + assembly { + mstore(0x80, shl(224, 0x975d5a12)) + mstore(0x84, 0x40) + mstore(0xa4, 0x60) + mstore(0xc4, 0) + mstore(0xe4, 0) + let size := add(0x84, shl(5, padded)) + if iszero(staticcall(gas(), 0x7109709ECfa91a80626fF3989D68f67F5b1DD12D, 0x80, size, 0, 0)) { + revert(0, 0) + } + } + } + + function checkArrayCallResultTracksInputSize(bool complete, uint256 value) public view { + bool success; + assembly { + mstore(0x80, shl(224, 0x975d5a12)) + mstore(0x84, 0x40) + mstore(0xa4, 0x80) + mstore(0xc4, 1) + mstore(0xe4, value) + mstore(0x104, 1) + mstore(0x124, value) + let size := add(4, mul(0xc0, complete)) + success := staticcall(gas(), 0x7109709ECfa91a80626fF3989D68f67F5b1DD12D, 0x80, size, 0, 0) + } + assertTrue(success); + } + + function checkNoncanonicalArrayEncoding(bool shifted) public view { + bool success; + assembly { + let start := add(0x80, shl(5, shifted)) + mstore(start, shl(224, 0x975d5a12)) + mstore(add(start, 4), 0x60) + mstore(add(start, 0x24), 0x60) + mstore(add(start, 0x64), 1) + mstore(add(start, 0x84), 7) + success := staticcall(gas(), 0x7109709ECfa91a80626fF3989D68f67F5b1DD12D, start, 0xa4, 0, 0) + } + assertTrue(success); + } + + function checkZeroLengthMcopy(uint256 dest, uint256 size) public view { + vm.assume(size <= 2); + uint256 byteAtZero; + assembly { + mstore8(0, 0xaa) + mstore8(0x20, 0xbb) + mcopy(dest, 0x1f, size) + byteAtZero := byte(0, mload(0)) + } + assert(size != 0 || byteAtZero == 0xaa); + } +} +"#, + ); + + let args = [ + "test", + "--symbolic", + "--symbolic-max-paths", + "20", + "--symbolic-max-solver-queries", + "50", + "--symbolic-max-depth", + "5000", + "--symbolic-timeout", + "1", + ]; + assert_symbolic( + cmd.args(args) + .args(["--symbolic-array-lengths", "1"]) + .args(["--match-test", "^checkArrayCopy\\("]), + ) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicArrayAssertions.t.sol:SymbolicArrayAssertions +[PASS] checkArrayCopy(uint256[]) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); + + assert_symbolic( + cmd.forge_fuse() + .args(args) + .args(["--symbolic-array-lengths", "1"]) + .args(["--match-test", "^checkCorruptedArrayCopy\\("]), + ) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicArrayAssertions.t.sol:SymbolicArrayAssertions +[FAIL: assertion failed: [0] != [1]; counterexample: [SENDER] [SENDER] calldata=0x0ec64af5000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000 args=[[0]]] checkCorruptedArrayCopy(uint256[]) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); + + assert_symbolic( + cmd.forge_fuse() + .args(args) + .args(["--match-test", "^checkConcretePointerWithSymbolicSize\\("]), + ) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicArrayAssertions.t.sol:SymbolicArrayAssertions +[PASS] checkConcretePointerWithSymbolicSize(bool) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); + + assert_symbolic( + cmd.forge_fuse() + .args(args) + .args(["--match-test", "^checkArrayCallResultTracksInputSize\\("]), + ) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicArrayAssertions.t.sol:SymbolicArrayAssertions +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: symbolic array assertion CALL input size] checkArrayCallResultTracksInputSize(bool,uint256) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); + + let output = cmd + .forge_fuse() + .args(args) + .args(["--json", "--match-test", "^checkNoncanonicalArrayEncoding\\("]) + .assert_success() + .get_output() + .stdout + .clone(); + let result = json_test_result(&output, "checkNoncanonicalArrayEncoding(bool)"); + assert_eq!(result["symbolic"]["status"], "pass"); + + let output = cmd + .forge_fuse() + .args(args) + .args(["--json", "--match-test", "^checkZeroLengthMcopy\\("]) + .assert_success() + .get_output() + .stdout + .clone(); + let result = json_test_result(&output, "checkZeroLengthMcopy(uint256,uint256)"); + assert_eq!(result["symbolic"]["status"], "pass"); +} + +#[forgetest_init] +fn symbolic_uses_legacy_halmos_array_lengths(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_uses_legacy_halmos_array_lengths because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicHalmosLengths.t.sol", + r#" +contract SymbolicHalmosLengths { + /// @custom:halmos --array-lengths 3 + function checkArray(uint256[] memory values) public pure { + assert(values.length == 3); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args(["test", "--symbolic", "--match-test", "checkArray"])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicHalmosLengths.t.sol:SymbolicHalmosLengths +[PASS] checkArray(uint256[]) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +// Dynamic calldata shapes: nested structs and shorter variants with positional inner lengths. +#[forgetest_init] +fn symbolic_dynamic_input_shapes(prj: _, cmd: _) { + skip_unless_z3!("symbolic_dynamic_input_shapes"); + + prj.add_test( + "SymbolicNestedStruct.t.sol", + r#" +contract SymbolicNestedStruct { + struct Payload { + uint256[] values; + bytes note; + } + + /// forge-config: default.symbolic.array_lengths = [2, 3] + function checkStruct(Payload memory payload) public pure { + assert(payload.values.length == 2); + assert(payload.note.length == 3); + } +} +"#, + ); + + prj.add_test( + "SymbolicMixedLengthSets.t.sol", + r#" +contract SymbolicMixedLengthSets { + /// forge-config: default.symbolic.default_array_lengths = [1, 2] + /// forge-config: default.symbolic.array_lengths = [4, 4] + function checkBatch(bytes[] memory items) public pure { + assert(items.length == 1 || items.length == 2); + for (uint256 i; i < items.length; i++) { + assert(items[i].length == 4); + } + } +} +"#, + ); + + assert_symbolic(cmd.args([ + "test", + "--symbolic", + "--match-test", + "^(checkStruct|checkBatch)\\(", + ])) + .success() + .stdout_eq( + str![[r#" +... +Ran 1 test for test/SymbolicMixedLengthSets.t.sol:SymbolicMixedLengthSets +[PASS] checkBatch(bytes[]) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicNestedStruct.t.sol:SymbolicNestedStruct +[PASS] checkStruct((uint256[],bytes)) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]] + .unordered(), + ); +} + +#[forgetest_init] +fn symbolic_reports_calldata_variant_width_exhaustion(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_reports_calldata_variant_width_exhaustion because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicVariantLimit.t.sol", + r#" +contract SymbolicVariantLimit { + /// forge-config: default.symbolic.width = 2 + /// forge-config: default.symbolic.default_array_lengths = [1, 2] + /// forge-config: default.symbolic.default_bytes_lengths = [1, 2] + function checkVariants(bytes[] memory items) public pure { + items; + } +} +"#, + ); + + assert_symbolic(cmd.args(["test", "--symbolic", "--match-test", "checkVariants"])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicVariantLimit.t.sol:SymbolicVariantLimit +[FAIL: incomplete symbolic execution (Stuck): symbolic calldata variant limit exceeded (2)] checkVariants(bytes[]) ([METRICS]) +... +"#]]); +} + +#[forgetest_init] +fn symbolic_rejects_malformed_halmos_array_lengths(prj: _, cmd: _) { + prj.add_test( + "SymbolicMalformedHalmos.t.sol", + r#" +contract SymbolicMalformedHalmos { + /// forge-config: default.symbolic.default_dynamic_length = 2 + /// @custom:halmos --array-lengths nope + function checkBytes(bytes memory data) public pure { + data; + } +} +"#, + ); + + cmd.args(["test", "--symbolic", "--match-test", "checkBytes"]).assert_failure().stderr_eq( + str![[r#" +... +Error: Inline config error at test/SymbolicMalformedHalmos.t.sol:5:5: invalid @custom:halmos annotation: invalid length `nope` in --array-lengths `nope` +... +"#]], + ); +} + +#[forgetest_init] +fn symbolic_selfdestruct_cancun_self_beneficiary_halts(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_selfdestruct_cancun_self_beneficiary_halts because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicSelfdestructCancun.t.sol", + r#" +import "forge-std/Test.sol"; + +/// forge-config: default.evm_version = "cancun" + +contract SymbolicSelfdestructCancun is Test { + function checkSelfdestructCancun(uint256) public { + selfdestruct(payable(address(this))); + + assert(false); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkSelfdestructCancun", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicSelfdestructCancun.t.sol:SymbolicSelfdestructCancun +[PASS] checkSelfdestructCancun(uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("SELFDESTRUCT/EIP-6780 not modeled"), "{stdout}"); +} +#[forgetest_init] +fn symbolic_invariant_finds_single_step_counterexample(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_invariant_finds_single_step_counterexample because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicInvariantSingle.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicCounterTarget { + uint256 public value; + + function set(uint256 x) external { + if (x == 7) { + value = 11; + } + } +} + +contract SymbolicInvariantSingle is Test { + SymbolicCounterTarget target; + + function setUp() public { + target = new SymbolicCounterTarget(); + targetContract(address(target)); + } + + function invariant_counterNeverEleven() public view { + assert(target.value() != 11); + } +} +"#, + ); + + let output = assert_symbolic(cmd.args([ + "test", + "--symbolic", + "--match-test", + "invariant_counterNeverEleven", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicInvariantSingle.t.sol:SymbolicInvariantSingle +[FAIL: panic: assertion failed (0x01)] + [Sequence] (original: 1, shrunk: 1) + [SENDER] [SENDER] calldata=set(uint256) args=[7] + invariant_counterNeverEleven() ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .clone(); + let stdout = output.stdout_lossy(); + let stderr = output.stderr_lossy(); + + assert!(stderr.contains("Counterexample artifact:"), "{stderr}"); + assert!(stderr.contains("cache/symbolic/"), "{stderr}"); + assert!(!stdout.contains("No contracts to fuzz"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_json_reports_sequence_counterexample_artifact(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_json_reports_sequence_counterexample_artifact because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicInvariantSequenceArtifact.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicArtifactTarget { + uint256 public value; + + function set(uint256 x) external { + if (x == 7) { + value = 11; + } + } +} + +contract SymbolicInvariantSequenceArtifact is Test { + SymbolicArtifactTarget target; + + function setUp() public { + target = new SymbolicArtifactTarget(); + targetContract(address(target)); + } + + function invariant_counterNeverEleven() public view { + assert(target.value() != 11); + } +} +"#, + ); + + let output = cmd + .args(["test", "--symbolic", "--json", "--match-test", "invariant_counterNeverEleven"]) + .assert_failure() + .get_output() + .stdout + .clone(); + + let result = json_test_result(&output, "invariant_counterNeverEleven()"); + let failures = result["invariant_failures"].as_array().expect("invariant failures"); + let failure = failures.first().expect("invariant failure"); + assert_eq!(failure["artifact"]["schema"], "foundry:symbolic.counterexample@v1"); + let minimization = &failure["minimization"]; + assert_eq!(failure["artifact"], minimization["minimized"]); + assert_eq!(minimization["original_sequence_len"], 1); + assert_eq!(minimization["minimized_sequence_len"], 1); + assert_eq!(minimization["accepted"], 0); + assert_eq!(minimization["original_calldata_bytes"], minimization["minimized_calldata_bytes"]); + let artifacts = result["counterexample_artifacts"].as_array().unwrap(); + assert_eq!(artifacts.len(), 2); + assert_eq!(artifacts[0], failure["artifact"]); + assert_eq!(artifacts[1], minimization["original"]); + let artifact_path = failure["artifact"]["path"].as_str().unwrap().to_string(); + + let original = read_artifact_ref(&minimization["original"]); + assert_eq!(original["replay"]["status"], "confirmed"); + assert_eq!(original["calls"].as_array().unwrap().len(), 1); + let artifact = read_artifact_ref(&failure["artifact"]); + assert_eq!(artifact["schema_version"], 1); + assert_eq!(artifact["schema"], "foundry:symbolic.counterexample@v1"); + assert_eq!(artifact["kind"], "sequence"); + assert_eq!(artifact["test"]["test"], "invariant_counterNeverEleven()"); + assert_eq!(artifact["replay"]["status"], "confirmed"); + assert_eq!(artifact["calls"].as_array().unwrap().len(), 1); + assert_eq!(artifact["calls"][0]["function_name"], "set"); + assert_eq!(artifact["calls"][0]["args"], "7"); + + assert_symbolic(cmd.forge_fuse().args(["test", "--replay-symbolic-artifact", &artifact_path])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicInvariantSequenceArtifact.t.sol:SymbolicInvariantSequenceArtifact +[FAIL: panic: assertion failed (0x01)] + [Sequence] (original: 1, shrunk: 1) + [SENDER] [SENDER] calldata=set(uint256) args=[7] + invariant_counterNeverEleven() ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_emits_stateful_solidity_regression(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_emits_stateful_solidity_regression because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicRegressionSequence.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicRegressionSequenceTarget { + uint256 public value; + + function set(uint256 x) external { + if (x == 7) { + value = 11; + } + } +} + +contract SymbolicRegressionSequence is Test { + SymbolicRegressionSequenceTarget target; + + function setUp() public { + target = new SymbolicRegressionSequenceTarget(); + targetContract(address(target)); + } + + function invariant_counterNeverEleven() public view { + require(msg.sender == 0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38, "unexpected invariant sender"); + assert(target.value() != 11); + } +} +"#, + ); + + let output = cmd + .args([ + "test", + "--symbolic", + "--emit-regression", + "--match-test", + "invariant_counterNeverEleven", + ]) + .assert_failure() + .get_output() + .clone(); + let stderr = output.stderr_lossy(); + assert!(stderr.contains("Regression test:"), "{stderr}"); + + let regression = prj.root().join( + "test/regressions/SymbolicRegressionSequence_invariant_counterNeverEleven_SymbolicRegression.t.sol", + ); + assert!(regression.exists(), "missing regression {}", regression.display()); + + cmd.forge_fuse() + .args(["test", "--json", "--match-test", "test_regression_invariant_counterNeverEleven_symbolic"]) + .assert_json_stdout_with_status( + false, + str![[r#" +{ + "test/regressions/SymbolicRegressionSequence_invariant_counterNeverEleven_SymbolicRegression.t.sol:SymbolicRegressionSequence_invariant_counterNeverEleven_SymbolicRegression": { + "test_results": { + "test_regression_invariant_counterNeverEleven_symbolic()": { + "status": "Failure", + "reason": "panic: assertion failed (0x01)", + "...": "{...}" + } + }, + "...": "{...}" + } +} +"#]], + ); +} + +#[forgetest_init] +fn symbolic_emits_stateful_regression_with_after_invariant(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_emits_stateful_regression_with_after_invariant because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicRegressionAfterInvariant.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicRegressionAfterInvariantTarget { + uint256 public value; + + function set(uint256 x) external { + if (x == 7) { + value = 1; + } + } +} + +contract SymbolicRegressionAfterInvariant is Test { + SymbolicRegressionAfterInvariantTarget target; + + function setUp() public { + target = new SymbolicRegressionAfterInvariantTarget(); + targetContract(address(target)); + } + + function invariant_ok() public view { + require(msg.sender == 0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38, "unexpected invariant sender"); + } + + function afterInvariant() public view { + require(msg.sender == 0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38, "unexpected afterInvariant sender"); + require(target.value() != 1, "afterInvariant failure"); + } +} +"#, + ); + + let output = cmd + .args([ + "test", + "--symbolic", + "--emit-regression", + "--match-test", + "invariant_ok", + "--symbolic-invariant-depth", + "1", + ]) + .assert_failure() + .get_output() + .clone(); + let stderr = output.stderr_lossy(); + assert!(stderr.contains("Regression test:"), "{stderr}"); + + let regression = prj.root().join( + "test/regressions/SymbolicRegressionAfterInvariant_invariant_ok_SymbolicRegression.t.sol", + ); + assert!(regression.exists(), "missing regression {}", regression.display()); + let generated = std::fs::read_to_string(®ression) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", regression.display())); + assert!(generated.contains(r#"hex"93969ddf""#), "{generated}"); + + cmd.forge_fuse() + .args(["test", "--json", "--match-test", "test_regression_invariant_ok_symbolic"]) + .assert_json_stdout_with_status( + false, + str![[r#" +{ + "test/regressions/SymbolicRegressionAfterInvariant_invariant_ok_SymbolicRegression.t.sol:SymbolicRegressionAfterInvariant_invariant_ok_SymbolicRegression": { + "test_results": { + "test_regression_invariant_ok_symbolic()": { + "status": "Failure", + "reason": "afterInvariant failure", + "...": "{...}" + } + }, + "...": "{...}" + } +} +"#]], + ); +} + +#[forgetest_init] +fn symbolic_emits_regression_with_setup_storage(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_emits_regression_with_setup_storage because z3 is not available" + ); + return; + } + prj.update_config(|config| config.invariant.runs = 0); + + prj.add_test( + "StorageRegression.t.sol", + r#" +import "forge-std/Test.sol"; + +interface StorageVm { + function setArbitraryStorage(address target) external; + function setArbitraryStorage(address target, bool overwrite) external; + function copyStorage(address from, address to) external; +} + +contract RegressionStore { + uint256 public value; + uint256 public zero; + + constructor(uint256 initial) { + value = initial; + zero = initial; + } +} + +contract StorageRegressionTarget { + RegressionStore source; + RegressionStore store; + bool public hit; + + constructor(RegressionStore source_, RegressionStore store_) { + source = source_; + store = store_; + } + + function useStore() external { + require(store.value() == 42 && store.zero() == 0); + require(source.value() == 42 && source.zero() == 0); + hit = true; + } +} + +abstract contract StorageRegressionBase is Test { + StorageRegressionTarget target; + + function initTarget(RegressionStore source, RegressionStore store) internal { + target = new StorageRegressionTarget(source, store); + targetContract(address(target)); + } + + function invariant_notHit() public view { + require(!target.hit(), "hit"); + } +} + +contract DirectStorageRegression is StorageRegressionBase { + function setUp() public { + RegressionStore store = new RegressionStore(0); + StorageVm(address(vm)).setArbitraryStorage(address(store)); + initTarget(store, store); + } +} + +contract CopiedStorageRegression is StorageRegressionBase { + function setUp() public { + RegressionStore source = new RegressionStore(0); + RegressionStore copy = new RegressionStore(0); + StorageVm(address(vm)).setArbitraryStorage(address(source)); + StorageVm(address(vm)).copyStorage(address(source), address(copy)); + initTarget(source, copy); + } +} + +contract OverwriteStorageRegression is StorageRegressionBase { + function setUp() public { + RegressionStore store = new RegressionStore(1); + StorageVm(address(vm)).setArbitraryStorage(address(store), true); + initTarget(store, store); + } +} +"#, + ); + + cmd.args([ + "test", + "--symbolic", + "--emit-regression", + "--match-test", + "invariant_notHit", + "--symbolic-invariant-depth", + "1", + ]) + .assert_failure(); + + cmd.forge_fuse() + .args(["test", "--json", "--match-test", "test_regression_invariant_notHit_symbolic"]) + .assert_json_stdout_with_status( + false, + str![[r#" +{ + "test/regressions/DirectStorageRegression_invariant_notHit_SymbolicRegression.t.sol:DirectStorageRegression_invariant_notHit_SymbolicRegression": { + "test_results": { + "test_regression_invariant_notHit_symbolic()": { + "status": "Failure", + "reason": "hit", + "...": "{...}" + } + }, + "...": "{...}" + }, + "test/regressions/CopiedStorageRegression_invariant_notHit_SymbolicRegression.t.sol:CopiedStorageRegression_invariant_notHit_SymbolicRegression": { + "test_results": { + "test_regression_invariant_notHit_symbolic()": { + "status": "Failure", + "reason": "hit", + "...": "{...}" + } + }, + "...": "{...}" + }, + "test/regressions/OverwriteStorageRegression_invariant_notHit_SymbolicRegression.t.sol:OverwriteStorageRegression_invariant_notHit_SymbolicRegression": { + "test_results": { + "test_regression_invariant_notHit_symbolic()": { + "status": "Failure", + "reason": "hit", + "...": "{...}" + } + }, + "...": "{...}" + } +} +"#]], + ); +} + +#[forgetest_init] +fn symbolic_emits_handler_assertion_regression(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_emits_handler_assertion_regression because z3 is not available" + ); + return; + } + + prj.update_config(|config| { + config.invariant.fail_on_revert = false; + }); + prj.add_test( + "SymbolicRegressionHandler.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicRegressionHandlerTarget { + uint256 sink; + + function boom(uint256 x) external { + sink = x; + if (x == 7) { + assert(false); + } + } +} + +contract SymbolicRegressionHandler is Test { + SymbolicRegressionHandlerTarget target; + + function setUp() public { + target = new SymbolicRegressionHandlerTarget(); + targetContract(address(target)); + } + + function invariant_ok() public pure {} +} +"#, + ); + + let output = cmd + .args([ + "test", + "--symbolic", + "--emit-regression", + "--match-test", + "invariant_ok", + "--symbolic-invariant-depth", + "1", + ]) + .assert_failure() + .get_output() + .clone(); + let stdout = output.stdout_lossy(); + let stderr = output.stderr_lossy(); + assert!(stdout.contains("Assertion Tests: 1 assertion bug(s) found"), "{stdout}"); + assert!(stderr.contains("Regression test:"), "{stderr}"); + + let regression = prj + .root() + .join("test/regressions/SymbolicRegressionHandler_invariant_ok_SymbolicRegression.t.sol"); + assert!(regression.exists(), "missing regression {}", regression.display()); + let generated = std::fs::read_to_string(®ression) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", regression.display())); + assert!(generated.contains("true);"), "{generated}"); + + let stdout = cmd + .forge_fuse() + .args(["test", "--match-test", "test_regression_invariant_ok_symbolic"]) + .assert_failure() + .get_output() + .stdout_lossy(); + assert!(stdout.contains("test_regression_invariant_ok_symbolic()"), "{stdout}"); + assert!(stdout.contains("assertion failed"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_emits_multiple_handler_assertion_regressions(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_emits_multiple_handler_assertion_regressions because z3 is not available" + ); + return; + } + + prj.update_config(|config| { + config.invariant.fail_on_revert = false; + }); + prj.add_test( + "SymbolicRegressionMultipleHandlers.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicRegressionMultipleHandlersFirst { + uint256 sink; + + function boomFirst(uint256 x) external { + sink = x; + if (x == 7) { + assert(false); + } + } +} + +contract SymbolicRegressionMultipleHandlersSecond { + uint256 sink; + + function boomSecond(uint256 x) external { + sink = x; + if (x == 11) { + assert(false); + } + } +} + +contract SymbolicRegressionMultipleHandlers is Test { + SymbolicRegressionMultipleHandlersFirst first; + SymbolicRegressionMultipleHandlersSecond second; + + function setUp() public { + first = new SymbolicRegressionMultipleHandlersFirst(); + second = new SymbolicRegressionMultipleHandlersSecond(); + targetContract(address(first)); + targetContract(address(second)); + } + + function invariant_ok() public pure {} +} +"#, + ); + + let output = cmd + .args([ + "test", + "--symbolic", + "--emit-regression", + "--match-test", + "invariant_ok", + "--symbolic-invariant-depth", + "1", + ]) + .assert_failure() + .get_output() + .clone(); + let stdout = output.stdout_lossy(); + let stderr = output.stderr_lossy(); + assert!(stdout.contains("Assertion Tests: 2 assertion bug(s) found"), "{stdout}"); + assert_eq!(stderr.matches("Regression test:").count(), 2, "{stderr}"); + + let regressions_dir = prj.root().join("test/regressions"); + let regressions = std::fs::read_dir(®ressions_dir) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", regressions_dir.display())) + .map(|entry| entry.unwrap().path()) + .filter(|path| path.extension().is_some_and(|ext| ext == "sol")) + .collect::>(); + assert_eq!(regressions.len(), 2, "{regressions:?}"); + assert!( + regressions.iter().any(|path| path + .file_name() + .unwrap() + .to_string_lossy() + .contains("_handler_")), + "{regressions:?}" + ); +} + +#[forgetest_init] +fn symbolic_emit_regression_stale_file_preserves_json_output(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_emit_regression_stale_file_preserves_json_output because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicRegressionStaleJson.t.sol", + r#" +contract SymbolicRegressionStaleJson { + function checkStaleJson(uint256 x) public pure { + assert(x != 42); + } +} +"#, + ); + + cmd.args(["test", "--symbolic", "--emit-regression", "--match-test", "checkStaleJson"]) + .assert_failure(); + + let regression = prj.root().join( + "test/regressions/SymbolicRegressionStaleJson_checkStaleJson_SymbolicRegression.t.sol", + ); + let mut generated = std::fs::read_to_string(®ression) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", regression.display())); + generated = generated.replacen("UNLICENSED", "MIT", 1); + std::fs::write(®ression, generated) + .unwrap_or_else(|err| panic!("failed to write {}: {err}", regression.display())); + + let output = cmd + .forge_fuse() + .args([ + "test", + "--symbolic", + "--emit-regression", + "--json", + "--match-test", + "checkStaleJson", + ]) + .assert_failure() + .get_output() + .clone(); + let stdout = output.stdout.clone(); + let stderr = output.stderr_lossy(); + assert!(stderr.contains("already exists; skipping"), "{stderr}"); + + let result = json_test_result(&stdout, "checkStaleJson(uint256)"); + assert!(result.get("symbolic_regressions").is_none(), "{result}"); +} + +#[forgetest_init] +fn symbolic_regression_contract_runs_only_generated_tests(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_regression_contract_runs_only_generated_tests because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicRegressionCollection.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicRegressionCollectionTarget { + uint256 public value; + + function set(uint256 x) external { + if (x == 7) { + value = 11; + } + } +} + +contract SymbolicRegressionCollection is Test { + SymbolicRegressionCollectionTarget target; + + function setUp() public { + target = new SymbolicRegressionCollectionTarget(); + targetContract(address(target)); + } + + function invariant_counterNeverEleven() public view { + assert(target.value() != 11); + } +} +"#, + ); + + cmd.args([ + "test", + "--symbolic", + "--emit-regression", + "--match-test", + "invariant_counterNeverEleven", + "--symbolic-invariant-depth", + "1", + ]) + .assert_failure(); + + let stdout = cmd + .forge_fuse() + .args([ + "test", + "--match-contract", + "SymbolicRegressionCollection_invariant_counterNeverEleven_SymbolicRegression", + ]) + .assert_failure() + .get_output() + .stdout_lossy(); + assert!(stdout.contains("Ran 1 test"), "{stdout}"); + assert!(stdout.contains("test_regression_invariant_counterNeverEleven_symbolic()"), "{stdout}"); + assert!(!stdout.contains(" invariant_counterNeverEleven()"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_regression_suffix_user_contract_runs_tests(prj: _, cmd: _) { + prj.add_test( + "RealSymbolicRegression.t.sol", + r#" +import "forge-std/Test.sol"; + +contract Real_SymbolicRegression is Test { + function test_fails() public pure { + assert(false); + } +} +"#, + ); + + let stdout = cmd + .args(["test", "--match-contract", "Real_SymbolicRegression"]) + .assert_failure() + .get_output() + .stdout_lossy(); + assert!(stdout.contains("test_fails()"), "{stdout}"); + assert!(stdout.contains("assertion failed"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_emit_regression_rejects_explicit_output_collision(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_emit_regression_rejects_explicit_output_collision because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicRegressionCollisionFile.t.sol", + r#" +contract SymbolicRegressionCollisionFileFirst { + function checkFirst(uint256 x) public pure { + assert(x != 42); + } +} + +contract SymbolicRegressionCollisionFileSecond { + function checkSecond(uint256 x) public pure { + assert(x != 11); + } +} +"#, + ); + + let collision_path = prj.root().join("test/onlyone.sol"); + let stderr = cmd + .args([ + "test", + "--symbolic", + "--emit-regression", + "--regression-out", + "test/onlyone.sol", + "--regression-overwrite", + "--match-test", + "check(First|Second)", + ]) + .assert_failure() + .get_output() + .stderr_lossy(); + assert!(stderr.contains("multiple symbolic regressions resolve to"), "{stderr}"); + assert!(stderr.contains("checkFirst(uint256)"), "{stderr}"); + assert!(stderr.contains("checkSecond(uint256)"), "{stderr}"); + assert!(!collision_path.exists(), "unexpected collision file {}", collision_path.display()); +} + +#[forgetest_init] +fn symbolic_emit_regression_rejects_default_path_collision(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_emit_regression_rejects_default_path_collision because z3 is not available" + ); + return; + } + + let test_dir = prj.root().join("test"); + std::fs::create_dir_all(test_dir.join("a")).unwrap(); + std::fs::create_dir_all(test_dir.join("b")).unwrap(); + std::fs::write( + test_dir.join("a/SameName.t.sol"), + r#" +contract SameName { + function checkSame(uint256 x) public pure { + assert(x != 42); + } +} +"#, + ) + .unwrap(); + std::fs::write( + test_dir.join("b/SameName.t.sol"), + r#" +contract SameName { + function checkSame(uint256 x) public pure { + assert(x != 11); + } +} +"#, + ) + .unwrap(); + + let collision_path = + prj.root().join("test/regressions/SameName_checkSame_SymbolicRegression.t.sol"); + let stderr = cmd + .args(["test", "--symbolic", "--emit-regression", "--match-test", "checkSame"]) + .assert_failure() + .get_output() + .stderr_lossy(); + assert!(stderr.contains("multiple symbolic regressions resolve to"), "{stderr}"); + assert!(stderr.contains("test/a/SameName.t.sol:SameName::checkSame(uint256)"), "{stderr}"); + assert!(stderr.contains("test/b/SameName.t.sol:SameName::checkSame(uint256)"), "{stderr}"); + assert!(!collision_path.exists(), "unexpected collision file {}", collision_path.display()); +} + +#[forgetest_init] +fn symbolic_json_reports_minimized_sequence_counterexample(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_json_reports_minimized_sequence_counterexample because z3 is not available" + ); + return; + } + + // `check_interval = 0` makes the symbolic engine check the invariant only at the terminal + // depth, so the symbolic sequence budget is kept at two calls to stay within `max_paths`. + prj.add_test( + "SymbolicInvariantSequenceMinimize.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicSequenceMinTarget { + bool primed; + bool fired; + + function prime(uint256 x) external { + if (x > 40) { + primed = true; + } + } + + function fire(uint256 y) external { + if (y > 100) { + fired = true; + } + } + + function broken() external view returns (bool) { + return primed && fired; + } +} + +contract SymbolicInvariantSequenceMinimize is Test { + SymbolicSequenceMinTarget target; + uint256[] public fixture_x = [1000]; + uint256[] public fixture_y = [5000]; + + function setUp() public { + target = new SymbolicSequenceMinTarget(); + bytes4[] memory selectors = new bytes4[](2); + selectors[0] = SymbolicSequenceMinTarget.prime.selector; + selectors[1] = SymbolicSequenceMinTarget.fire.selector; + targetSelector(FuzzSelector({addr: address(target), selectors: selectors})); + } + + /// forge-config: default.symbolic.invariant_depth = 2 + /// forge-config: default.invariant.runs = 1 + /// forge-config: default.invariant.depth = 20 + /// forge-config: default.invariant.check_interval = 0 + /// forge-config: default.invariant.shrink_run_limit = 5000 + function invariant_targetNeverBroken() public view { + assertEq(target.broken(), false); + } +} +"#, + ); + + let output = cmd + .args([ + "test", + "--symbolic", + "--json", + "--match-test", + "invariant_targetNeverBroken", + "--fuzz-seed", + "1", + ]) + .assert_failure() + .get_output() + .stdout + .clone(); + + let result = json_test_result(&output, "invariant_targetNeverBroken()"); + let failures = result["invariant_failures"].as_array().expect("invariant failures"); + let failure = failures.first().expect("invariant failure"); + let minimization = &failure["minimization"]; + assert_eq!(failure["artifact"], minimization["minimized"]); + assert_eq!(minimization["original_sequence_len"], 2); + assert_eq!(minimization["minimized_sequence_len"], 2); + assert_eq!(minimization["original_calldata_bytes"], minimization["minimized_calldata_bytes"]); + let artifacts = result["counterexample_artifacts"].as_array().unwrap(); + assert_eq!(artifacts.len(), 2); + assert!(artifacts.contains(&failure["artifact"])); + assert!(artifacts.contains(&minimization["original"])); + + let original = read_artifact_ref(&minimization["original"]); + let minimized = read_artifact_ref(&minimization["minimized"]); + assert_eq!(original["replay"]["status"], "confirmed"); + assert_eq!(minimized["replay"]["status"], "confirmed"); + assert_eq!(original["calls"].as_array().unwrap().len(), 2); + assert_eq!(minimized["calls"].as_array().unwrap().len(), 2); + if minimization["accepted"].as_u64().unwrap() == 0 { + assert_eq!(original["calls"], minimized["calls"]); + } else { + assert_ne!(original["calls"], minimized["calls"]); + } + + let calls = minimized["calls"].as_array().unwrap(); + let prime = calls.iter().find(|call| call["function_name"] == "prime").unwrap(); + let fire = calls.iter().find(|call| call["function_name"] == "fire").unwrap(); + assert_eq!(prime["args"], "41"); + assert_eq!(fire["args"], "101"); + + assert_symbolic_witness(cmd.forge_fuse().args([ + "test", + "--replay-symbolic-artifact", + minimization["minimized"]["path"].as_str().unwrap(), + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicInvariantSequenceMinimize.t.sol:SymbolicInvariantSequenceMinimize +[FAIL: assertion failed: true != false] + [Sequence] (original: 2, shrunk: 2) + [SENDER] [SENDER] calldata=prime(uint256) [ARGS] + [SENDER] [SENDER] calldata=fire(uint256) [ARGS] + invariant_targetNeverBroken() ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_seed_corpus_persists_non_failing_fuzz_input(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_seed_corpus_persists_non_failing_fuzz_input because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicFuzzCorpusSeed.t.sol", + r#" +contract SymbolicFuzzCorpusSeed { + function testHybridFindsBug(uint256 x, uint256 y) public pure { + unchecked { + if (x * 7 != 1) return; + } + if (y != 0) return; + assert(y == 0); + } +} +"#, + ); + + cmd.forge_fuse() + .args([ + "test", + "--match-test", + "testHybridFindsBug", + "--symbolic-seed-corpus", + "--fuzz-corpus-dir", + "fuzz_corpus", + ]) + .assert_success(); + + let corpus_dir = prj + .root() + .join("fuzz_corpus") + .join("SymbolicFuzzCorpusSeed") + .join("testHybridFindsBug") + .join("worker0") + .join("corpus"); + let mut entries = std::fs::read_dir(&corpus_dir) + .unwrap_or_else(|err| panic!("failed to read corpus dir {}: {err}", corpus_dir.display())) + .collect::, _>>() + .unwrap(); + entries.sort_by_key(|entry| entry.path()); + let expected_selector = &keccak256(b"testHybridFindsBug(uint256,uint256)")[..4]; + let expected_x = "6db6db6db6db6db6db6db6db6db6db6db6db6db6db6db6db6db6db6db6db6db7"; + let expected_y = format!("{:064x}", 0); + let expected_x_decimal = + "49625181101706940895816136432294817651401421999560241731196107431962769845687"; + let mut found_symbolic_seed = false; + for entry in &entries { + let corpus: Value = serde_json::from_slice(&std::fs::read(entry.path()).unwrap()).unwrap(); + assert_eq!(corpus.as_array().unwrap().len(), 1); + let calldata = corpus[0]["calldata"].as_str().expect("seed calldata"); + assert_eq!(&hex::decode(&calldata[2..10]).unwrap(), expected_selector); + if calldata[10..74] == *expected_x && calldata[74..138] == expected_y { + found_symbolic_seed = true; + } else { + std::fs::remove_file(entry.path()).unwrap(); + } + } + assert!(found_symbolic_seed); + + prj.add_test( + "SymbolicFuzzCorpusSeed.t.sol", + r#" +contract SymbolicFuzzCorpusSeed { + function testHybridFindsBug(uint256 x, uint256 y) public pure { + unchecked { + if (x * 7 != 1) return; + } + if (y == 0) return; + assert(false); + } + + function checkHybridFindsBug(uint256 x, uint256 y) public pure { + unchecked { + if (x * 7 != 1) return; + } + if (y != 0) return; + assert(y == 0); + } +} +"#, + ); + + cmd.forge_fuse() + .args(["test", "--match-test", "testHybridFindsBug", "--threads", "1", "--fuzz-runs", "8"]) + .assert_success(); + + assert_symbolic(cmd.forge_fuse().args([ + "test", + "--symbolic", + "--match-test", + "checkHybridFindsBug", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicFuzzCorpusSeed.t.sol:SymbolicFuzzCorpusSeed +[PASS] checkHybridFindsBug(uint256,uint256) ([METRICS]) +... +"#]]); + + let stdout = cmd + .forge_fuse() + .args([ + "test", + "--match-test", + "testHybridFindsBug", + "--fuzz-corpus-dir", + "fuzz_corpus", + "--threads", + "1", + "--fuzz-corpus-random-sequence-weight", + "0", + "--fuzz-mutation-weight-splice", + "0", + "--fuzz-mutation-weight-repeat", + "0", + "--fuzz-mutation-weight-interleave", + "0", + "--fuzz-mutation-weight-prefix", + "0", + "--fuzz-mutation-weight-suffix", + "0", + "--fuzz-mutation-weight-abi", + "1", + "--fuzz-mutation-weight-cmp", + "0", + "--fuzz-runs", + "256", + ]) + .assert_failure() + .get_output() + .stdout_lossy(); + + assert!(stdout.contains(expected_x_decimal), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_seed_corpus_is_best_effort_for_symbolic_incomplete(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_seed_corpus_is_best_effort_for_symbolic_incomplete because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicFuzzCorpusBestEffort.t.sol", + r#" +contract SymbolicFuzzCorpusBestEffort { + function testFuzz_symbolicHostile(uint256) public pure { + for (uint256 i; i < 10001; ++i) {} + } +} +"#, + ); + + let stdout = cmd + .forge_fuse() + .args([ + "test", + "--match-test", + "testFuzz_symbolicHostile", + "--symbolic-seed-corpus", + "--fuzz-corpus-dir", + "fuzz_corpus", + "--fuzz-runs", + "1", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + + assert!(stdout.contains("[PASS] testFuzz_symbolicHostile(uint256)"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_fuzz_frontier_seeding_persists_branch_flipping_input(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_fuzz_frontier_seeding_persists_branch_flipping_input because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicFuzzFrontierSeed.t.sol", + r#" +contract SymbolicFuzzFrontierSeed { + function testFuzz_frontier(uint64 amount, uint256 feeMultiplier) public pure { + uint256 credited; + unchecked { + credited = uint256(amount) + (feeMultiplier - 100); + } + + if (feeMultiplier < 100) { + assert(credited <= amount); + } + } +} +"#, + ); + + cmd.forge_fuse() + .args([ + "test", + "--match-test", + "testFuzz_frontier", + "--fuzz-runs", + "8", + "--fuzz-seed", + "0x1234", + "--threads", + "1", + "--fuzz-frontier-dir", + "fuzz_frontiers", + ]) + .assert_success(); + + let target_frontier = matching_frontier( + prj.root(), + "SymbolicFuzzFrontierSeed", + "testFuzz_frontier", + "missed fee multiplier", + |frontier| { + frontier["site"]["opcode_name"] == "LT" + && (frontier["operands"]["lhs"] == "0x64" || frontier["operands"]["rhs"] == "0x64") + }, + ); + let target_frontier_id = target_frontier["id"].as_u64().unwrap().to_string(); + let target_frontier_pc = target_frontier["site"]["pc"].as_u64().unwrap().to_string(); + let target_frontier_selector = + target_frontier["sequence"][0]["calldata"].as_str().unwrap()[..10].to_string(); + + let output = cmd + .forge_fuse() + .args([ + "test", + "--match-test", + "testFuzz_frontier", + "--fuzz-runs", + "1", + "--fuzz-seed", + "0x1234", + "--threads", + "1", + "--fuzz-frontier-dir", + "fuzz_frontiers", + "--fuzz-corpus-dir", + "fuzz_corpus", + "--symbolic-use-fuzz-frontiers", + "--symbolic-frontier-limit", + "1", + "--symbolic-frontier-ids", + &target_frontier_id, + "--symbolic-frontier-pcs", + &target_frontier_pc, + "--symbolic-frontier-selectors", + &target_frontier_selector, + ]) + .assert_success() + .get_output() + .clone(); + let stdout = output.stdout_lossy(); + let stderr = output.stderr_lossy(); + assert!(stdout.contains("testFuzz_frontier(uint64,uint256)"), "{stdout}"); + assert!( + stderr.contains("Symbolic frontier selection for testFuzz_frontier(uint64,uint256)"), + "{stderr}" + ); + + let corpus_dir = prj + .root() + .join("fuzz_corpus") + .join("SymbolicFuzzFrontierSeed") + .join("testFuzz_frontier") + .join("worker0") + .join("corpus"); + let expected_selector = hex::encode(&keccak256(b"testFuzz_frontier(uint64,uint256)")[..4]); + let mut found_branch_flipping_seed = false; + for entry in std::fs::read_dir(&corpus_dir) + .unwrap_or_else(|err| panic!("failed to read corpus dir {}: {err}", corpus_dir.display())) + { + let entry = entry.unwrap(); + let corpus: Value = serde_json::from_slice(&std::fs::read(entry.path()).unwrap()).unwrap(); + for tx in corpus.as_array().unwrap() { + let calldata = tx["calldata"].as_str().expect("seed calldata"); + if !calldata.starts_with(&format!("0x{expected_selector}")) { + continue; + } + let fee_multiplier = U256::from_be_slice(&hex::decode(&calldata[74..138]).unwrap()); + if fee_multiplier < U256::from(100) { + found_branch_flipping_seed = true; + } + } + } + assert!(found_branch_flipping_seed); + + let replay_output = cmd + .forge_fuse() + .args([ + "fuzz", + "replay", + "--match-contract", + "SymbolicFuzzFrontierSeed", + "--match-test", + "testFuzz_frontier", + "--corpus-dir", + "fuzz_corpus", + ]) + .assert_failure() + .get_output() + .stdout_lossy(); + assert!(replay_output.contains("corpus replay failed"), "{replay_output}"); +} + +#[forgetest_init] +fn symbolic_fuzz_frontier_seeding_ignores_pre_target_counterexamples(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_fuzz_frontier_seeding_ignores_pre_target_counterexamples because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicFuzzFrontierTargetGate.t.sol", + r#" +/// forge-config: default.symbolic.exploration_order = "dfs" +contract SymbolicFuzzFrontierTargetGate { + event TargetHit(); + + function testFuzz_targetGate(uint256 value) public { + if (value == 13) { + assert(false); + } + + if (value < 777) { + emit TargetHit(); + } + } +} +"#, + ); + + cmd.forge_fuse() + .args([ + "test", + "--match-test", + "testFuzz_targetGate", + "--fuzz-runs", + "1", + "--fuzz-seed", + "0x1234", + "--threads", + "1", + "--fuzz-frontier-dir", + "fuzz_frontiers", + ]) + .assert_success(); + + keep_only_matching_frontier( + &frontier_artifact_path( + prj.root(), + "SymbolicFuzzFrontierTargetGate", + "testFuzz_targetGate", + ), + "value < 777", + |frontier| { + frontier["site"]["opcode_name"] == "LT" + && (frontier["operands"]["lhs"] == "0x309" + || frontier["operands"]["rhs"] == "0x309") + }, + ); + + cmd.forge_fuse() + .args([ + "test", + "--match-test", + "testFuzz_targetGate", + "--fuzz-runs", + "1", + "--fuzz-seed", + "0x1234", + "--threads", + "1", + "--fuzz-frontier-dir", + "fuzz_frontiers", + "--fuzz-corpus-dir", + "fuzz_corpus", + "--symbolic-use-fuzz-frontiers", + "--symbolic-frontier-limit", + "1", + ]) + .assert_success(); + + let values = single_uint_corpus_values( + prj.root(), + "SymbolicFuzzFrontierTargetGate", + "testFuzz_targetGate", + "testFuzz_targetGate(uint256)", + ); + assert!(values.iter().any(|value| *value < U256::from(777))); + assert!(!values.iter().any(|value| *value == U256::from(13))); +} + +#[forgetest_init] +fn symbolic_fuzz_frontier_seeding_keeps_deploy_code_target_progress(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_fuzz_frontier_seeding_keeps_deploy_code_target_progress because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicFuzzDeployCodeFrontier.t.sol", + r#" +import "forge-std/Test.sol"; + +interface SymbolicDeployCodeVm { + function randomUint(uint256 min, uint256 max) external view returns (uint256); +} + +contract DeployCodeFrontierCtor { + SymbolicDeployCodeVm constant VM = + SymbolicDeployCodeVm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + event TargetHit(); + + constructor() { + uint256 value = VM.randomUint(0, 1000); + if (value < 777) { + emit TargetHit(); + } + } +} + +contract SymbolicFuzzDeployCodeFrontier is Test { + string constant TARGET = "test/SymbolicFuzzDeployCodeFrontier.t.sol"; + + function testFuzz_deployCode(uint256 marker) public { + marker; + vm.deployCode(string.concat(TARGET, ":DeployCodeFrontierCtor")); + } +} +"#, + ); + + cmd.forge_fuse() + .args([ + "test", + "--match-test", + "testFuzz_deployCode", + "--fuzz-runs", + "1", + "--fuzz-seed", + "0x1234", + "--threads", + "1", + "--fuzz-frontier-dir", + "fuzz_frontiers", + ]) + .assert_success(); + + let target_frontier = keep_only_matching_frontier( + &frontier_artifact_path( + prj.root(), + "SymbolicFuzzDeployCodeFrontier", + "testFuzz_deployCode", + ), + "deployCode constructor value < 777", + |frontier| { + frontier["site"]["opcode_name"] == "LT" + && (frontier["operands"]["lhs"] == "0x309" + || frontier["operands"]["rhs"] == "0x309") + }, + ); + let frontier_calldata = target_frontier["sequence"][0]["calldata"].as_str().unwrap(); + let frontier_marker = U256::from_be_slice(&hex::decode(&frontier_calldata[10..74]).unwrap()); + assert_ne!(frontier_marker, U256::ZERO); + + cmd.forge_fuse() + .args([ + "test", + "--match-test", + "testFuzz_deployCode", + "--fuzz-runs", + "1", + "--fuzz-seed", + "0x1234", + "--threads", + "1", + "--fuzz-frontier-dir", + "fuzz_frontiers", + "--fuzz-corpus-dir", + "fuzz_corpus", + "--symbolic-use-fuzz-frontiers", + "--symbolic-frontier-limit", + "1", + ]) + .assert_success(); + + let values = single_uint_corpus_values( + prj.root(), + "SymbolicFuzzDeployCodeFrontier", + "testFuzz_deployCode", + "testFuzz_deployCode(uint256)", + ); + assert!(values.contains(&U256::ZERO), "target_frontier={target_frontier}, values={values:?}"); +} + +#[forgetest_init] +fn symbolic_fuzz_frontier_seeding_keeps_callee_target_progress(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_fuzz_frontier_seeding_keeps_callee_target_progress because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicFuzzCalleeFrontierSeed.t.sol", + r#" +contract SymbolicFuzzCalleeTarget { + function crossed(uint256 value) external pure returns (bool) { + return value == 777; + } +} + +contract SymbolicFuzzCalleeFrontierSeed { + SymbolicFuzzCalleeTarget target = new SymbolicFuzzCalleeTarget(); + + function testFuzz_callee(uint256 value) public view { + target.crossed(value); + } +} +"#, + ); + + cmd.forge_fuse() + .args([ + "test", + "--match-test", + "testFuzz_callee", + "--fuzz-runs", + "8", + "--fuzz-seed", + "0x1234", + "--threads", + "1", + "--fuzz-frontier-dir", + "fuzz_frontiers", + ]) + .assert_success(); + + cmd.forge_fuse() + .args([ + "test", + "--match-test", + "testFuzz_callee", + "--fuzz-runs", + "1", + "--fuzz-seed", + "0x1234", + "--threads", + "1", + "--fuzz-frontier-dir", + "fuzz_frontiers", + "--fuzz-corpus-dir", + "fuzz_corpus", + "--symbolic-use-fuzz-frontiers", + "--symbolic-frontier-limit", + "32", + ]) + .assert_success(); + + let corpus_dir = prj + .root() + .join("fuzz_corpus") + .join("SymbolicFuzzCalleeFrontierSeed") + .join("testFuzz_callee") + .join("worker0") + .join("corpus"); + let expected_selector = hex::encode(&keccak256(b"testFuzz_callee(uint256)")[..4]); + let mut found_branch_flipping_seed = false; + for entry in std::fs::read_dir(&corpus_dir) + .unwrap_or_else(|err| panic!("failed to read corpus dir {}: {err}", corpus_dir.display())) + { + let entry = entry.unwrap(); + let corpus: Value = serde_json::from_slice(&std::fs::read(entry.path()).unwrap()).unwrap(); + for tx in corpus.as_array().unwrap() { + let calldata = tx["calldata"].as_str().expect("seed calldata"); + if !calldata.starts_with(&format!("0x{expected_selector}")) { + continue; + } + let value = U256::from_be_slice(&hex::decode(&calldata[10..74]).unwrap()); + if value == U256::from(777) { + found_branch_flipping_seed = true; + } + } + } + assert!(found_branch_flipping_seed); +} + +#[forgetest_init] +fn symbolic_invariant_frontier_seeding_solves_stalled_handler_call(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_invariant_frontier_seeding_solves_stalled_handler_call because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicInvariantFrontierSeed.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicInvariantFrontierTarget { + uint256 public phase; + bool public broken; + + function advance(uint256 value) external { + if (phase == 0) { + phase = 1; + } else if (phase == 1) { + if (value >= 123456789) { + broken = true; + } + phase = 2; + } + } +} + +contract SymbolicInvariantFrontierSeed is Test { + SymbolicInvariantFrontierTarget target; + + function setUp() public { + target = new SymbolicInvariantFrontierTarget(); + targetContract(address(target)); + } + + function invariant_notBroken() public view { + assertFalse(target.broken()); + } + + function invariant_alsoNotBroken() public view { + assertFalse(target.broken()); + } +} +"#, + ); + + cmd.forge_fuse() + .args([ + "fuzz", + "run", + "--match-contract", + "SymbolicInvariantFrontierSeed", + "--runs", + "1", + "--depth", + "2", + "--seed", + "0x5678", + "--threads", + "1", + "--frontier-dir", + "invariant_frontiers", + ]) + .assert_success(); + + let frontier_root = prj.root().join("invariant_frontiers"); + let frontier_path = find_stateful_frontier_artifact(&frontier_root); + let frontier_relative = frontier_path.strip_prefix(&frontier_root).unwrap().to_path_buf(); + let mut artifact: Value = serde_json::from_slice( + &std::fs::read(&frontier_path) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", frontier_path.display())), + ) + .unwrap(); + assert_eq!(artifact["test"], "invariant_alsoNotBroken()"); + let target_frontier = artifact["frontiers"] + .as_array() + .unwrap() + .iter() + .find(|frontier| { + frontier["call_index"] == 1 + && frontier["site"]["opcode_name"] == "LT" + && (frontier["operands"]["lhs"] == "0x75bcd15" + || frontier["operands"]["rhs"] == "0x75bcd15") + }) + .cloned() + .unwrap_or_else(|| panic!("missing value >= 123456789 frontier in {artifact}")); + let target_frontier_id = target_frontier["id"].as_u64().unwrap().to_string(); + *artifact["frontiers"].as_array_mut().unwrap() = vec![target_frontier.clone()]; + std::fs::write(&frontier_path, serde_json::to_vec_pretty(&artifact).unwrap()) + .unwrap_or_else(|err| panic!("failed to write {}: {err}", frontier_path.display())); + + let covered_frontier_path = + prj.root().join("covered_invariant_frontiers").join(&frontier_relative); + std::fs::create_dir_all(covered_frontier_path.parent().unwrap()).unwrap(); + let mut covered_artifact = artifact.clone(); + let mut opposite_frontier = target_frontier.clone(); + let result = opposite_frontier["operands"]["result"].as_bool().unwrap(); + opposite_frontier["id"] = Value::from(target_frontier["id"].as_u64().unwrap() + 1); + opposite_frontier["operands"]["result"] = Value::Bool(!result); + covered_artifact["frontiers"].as_array_mut().unwrap().push(opposite_frontier); + std::fs::write(&covered_frontier_path, serde_json::to_vec_pretty(&covered_artifact).unwrap()) + .unwrap_or_else(|err| panic!("failed to write {}: {err}", covered_frontier_path.display())); + + let payable_frontier_path = + prj.root().join("payable_invariant_frontiers").join(&frontier_relative); + std::fs::create_dir_all(payable_frontier_path.parent().unwrap()).unwrap(); + let mut payable_artifact = artifact.clone(); + let sequence_index = target_frontier["sequence_index"].as_u64().unwrap() as usize; + let call_index = target_frontier["call_index"].as_u64().unwrap() as usize; + payable_artifact["sequences"][sequence_index][call_index]["value"] = + Value::String("0x1".to_string()); + std::fs::write(&payable_frontier_path, serde_json::to_vec_pretty(&payable_artifact).unwrap()) + .unwrap_or_else(|err| panic!("failed to write {}: {err}", payable_frontier_path.display())); + + cmd.forge_fuse(); + cmd.env("FOUNDRY_INVARIANT_RUNS", "0"); + cmd.env("FOUNDRY_INVARIANT_FAILURE_PERSIST_DIR", "covered_invariant_failures"); + cmd.args([ + "test", + "--match-contract", + "SymbolicInvariantFrontierSeed", + "--invariant-depth", + "2", + "--threads", + "1", + "--invariant-frontier-dir", + "covered_invariant_frontiers", + "--invariant-corpus-dir", + "covered_invariant_corpus", + "--symbolic-use-fuzz-frontiers", + "--symbolic-frontier-limit", + "1", + "--symbolic-frontier-ids", + &target_frontier_id, + ]) + .assert_failure(); + cmd.forge_fuse() + .args([ + "fuzz", + "replay", + "--match-contract", + "SymbolicInvariantFrontierSeed", + "--match-test", + "invariant_notBroken", + "--corpus-dir", + "covered_invariant_corpus", + ]) + .assert_failure(); + + cmd.forge_fuse(); + cmd.env("FOUNDRY_INVARIANT_RUNS", "0"); + cmd.args([ + "test", + "--match-contract", + "SymbolicInvariantFrontierSeed", + "--invariant-depth", + "2", + "--threads", + "1", + "--invariant-frontier-dir", + "payable_invariant_frontiers", + "--invariant-corpus-dir", + "payable_invariant_corpus", + "--symbolic-use-fuzz-frontiers", + "--symbolic-frontier-limit", + "1", + "--symbolic-frontier-ids", + &target_frontier_id, + ]) + .assert_success(); + let payable_corpus = prj + .root() + .join("payable_invariant_corpus") + .join("SymbolicInvariantFrontierSeed") + .join("worker0") + .join("corpus"); + assert!( + !payable_corpus.exists() || payable_corpus.read_dir().unwrap().next().is_none(), + "nonzero-value frontier unexpectedly produced a corpus seed" + ); + + for (frontier_dir, corpus_dir, forbidden_call_index) in [ + ("forbidden_prefix_frontiers", "forbidden_prefix_corpus", 0), + ("forbidden_suffix_frontiers", "forbidden_suffix_corpus", call_index), + ] { + let forbidden_frontier_path = prj.root().join(frontier_dir).join(&frontier_relative); + std::fs::create_dir_all(forbidden_frontier_path.parent().unwrap()).unwrap(); + let mut forbidden_artifact = artifact.clone(); + forbidden_artifact["sequences"][sequence_index][forbidden_call_index]["sender"] = + Value::String("0x0000000000000000000000000000000000000000".to_string()); + std::fs::write( + &forbidden_frontier_path, + serde_json::to_vec_pretty(&forbidden_artifact).unwrap(), + ) + .unwrap_or_else(|err| { + panic!("failed to write {}: {err}", forbidden_frontier_path.display()) + }); + + cmd.forge_fuse(); + cmd.env("FOUNDRY_INVARIANT_RUNS", "0"); + cmd.args([ + "test", + "--match-contract", + "SymbolicInvariantFrontierSeed", + "--invariant-depth", + "2", + "--threads", + "1", + "--invariant-frontier-dir", + frontier_dir, + "--invariant-corpus-dir", + corpus_dir, + "--symbolic-use-fuzz-frontiers", + "--symbolic-frontier-limit", + "1", + ]) + .assert_success(); + let corpus = prj + .root() + .join(corpus_dir) + .join("SymbolicInvariantFrontierSeed") + .join("worker0") + .join("corpus"); + assert!( + !corpus.exists() || corpus.read_dir().unwrap().next().is_none(), + "forbidden-sender frontier unexpectedly produced a corpus seed" + ); + } + + cmd.forge_fuse(); + cmd.env("FOUNDRY_INVARIANT_RUNS", "0"); + cmd.env("FOUNDRY_INVARIANT_FAILURE_PERSIST_DIR", "invariant_failures"); + cmd.args([ + "test", + "--match-contract", + "SymbolicInvariantFrontierSeed", + "--invariant-depth", + "2", + "--threads", + "1", + "--invariant-frontier-dir", + "invariant_frontiers", + "--invariant-corpus-dir", + "invariant_corpus", + "--symbolic-use-fuzz-frontiers", + "--symbolic-frontier-limit", + "1", + ]) + .assert_failure(); + + let output = cmd + .forge_fuse() + .args([ + "fuzz", + "replay", + "--match-contract", + "SymbolicInvariantFrontierSeed", + "--match-test", + "invariant_notBroken", + "--corpus-dir", + "invariant_corpus", + ]) + .assert_failure() + .get_output() + .clone(); + let stdout = output.stdout_lossy(); + let stderr = output.stderr_lossy(); + assert!( + stdout.contains("[FAIL:") && stdout.contains("invariant_notBroken"), + "stdout={stdout}\nstderr={stderr}" + ); + + cmd.forge_fuse(); + cmd.env("FOUNDRY_INVARIANT_RUNS", "0"); + cmd.env("FOUNDRY_INVARIANT_FAILURE_PERSIST_DIR", "filtered_invariant_failures"); + cmd.args([ + "test", + "--match-contract", + "SymbolicInvariantFrontierSeed", + "--invariant-depth", + "2", + "--threads", + "1", + "--invariant-frontier-dir", + "invariant_frontiers", + "--invariant-corpus-dir", + "filtered_invariant_corpus", + "--symbolic-use-fuzz-frontiers", + "--symbolic-frontier-limit", + "1", + "--symbolic-frontier-ids", + &target_frontier_id, + ]) + .assert_failure(); + cmd.forge_fuse() + .args([ + "fuzz", + "replay", + "--match-contract", + "SymbolicInvariantFrontierSeed", + "--match-test", + "invariant_notBroken", + "--corpus-dir", + "filtered_invariant_corpus", + ]) + .assert_failure(); + + cmd.forge_fuse(); + cmd.env("FOUNDRY_INVARIANT_RUNS", "0"); + cmd.env("FOUNDRY_INVARIANT_FAILURE_PERSIST_DIR", "alternate_anchor_failures"); + cmd.args([ + "test", + "--match-contract", + "SymbolicInvariantFrontierSeed", + "--match-test", + "invariant_notBroken", + "--invariant-depth", + "2", + "--threads", + "1", + "--invariant-frontier-dir", + "invariant_frontiers", + "--invariant-corpus-dir", + "alternate_anchor_corpus", + "--symbolic-use-fuzz-frontiers", + "--symbolic-frontier-limit", + "1", + "--symbolic-frontier-ids", + &target_frontier_id, + ]) + .assert_failure(); + let alternate_anchor_corpus = prj + .root() + .join("alternate_anchor_corpus") + .join("SymbolicInvariantFrontierSeed") + .join("worker0") + .join("corpus"); + assert!( + alternate_anchor_corpus.is_dir() + && alternate_anchor_corpus.read_dir().unwrap().next().is_some(), + "alternate-anchor import did not seed the corpus" + ); + let output = cmd + .forge_fuse() + .args([ + "fuzz", + "replay", + "--match-contract", + "SymbolicInvariantFrontierSeed", + "--match-test", + "invariant_notBroken", + "--corpus-dir", + "alternate_anchor_corpus", + ]) + .assert_failure() + .get_output() + .clone(); + let stdout = output.stdout_lossy(); + let stderr = output.stderr_lossy(); + assert!( + stdout.contains("[FAIL:") && stdout.contains("invariant_notBroken"), + "stdout={stdout}\nstderr={stderr}" + ); +} + +#[forgetest_init] +fn symbolic_invariant_frontier_seeding_replays_reverted_prefix(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_invariant_frontier_seeding_replays_reverted_prefix because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicInvariantRevertedPrefix.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicInvariantRevertedPrefixTarget is Test { + bool public broken; + + function advance(uint256 value) external { + if (block.timestamp < 1000) { + vm.warp(1000); + revert("advance timestamp"); + } + if (value >= 123456789) { + broken = true; + } + } +} + +contract SymbolicInvariantRevertedPrefixTest is Test { + SymbolicInvariantRevertedPrefixTarget target; + + function setUp() public { + vm.warp(1); + target = new SymbolicInvariantRevertedPrefixTarget(); + targetContract(address(target)); + } + + function invariant_notBroken() public view { + assertFalse(target.broken()); + } +} +"#, + ); + + cmd.forge_fuse() + .args([ + "fuzz", + "run", + "--match-contract", + "SymbolicInvariantRevertedPrefixTest", + "--runs", + "1", + "--depth", + "2", + "--seed", + "0x4321", + "--threads", + "1", + "--frontier-dir", + "reverted_prefix_frontiers", + ]) + .assert_success(); + + let frontier_path = + find_stateful_frontier_artifact(&prj.root().join("reverted_prefix_frontiers")); + keep_only_matching_frontier(&frontier_path, "value >= 123456789", |frontier| { + frontier["call_index"] == 1 + && frontier["site"]["opcode_name"] == "LT" + && (frontier["operands"]["lhs"] == "0x75bcd15" + || frontier["operands"]["rhs"] == "0x75bcd15") + }); + + cmd.forge_fuse(); + cmd.env("FOUNDRY_INVARIANT_RUNS", "0"); + cmd.args([ + "test", + "--match-contract", + "SymbolicInvariantRevertedPrefixTest", + "--threads", + "1", + "--invariant-frontier-dir", + "reverted_prefix_frontiers", + "--invariant-corpus-dir", + "reverted_prefix_corpus", + "--symbolic-check-invariant-frontiers", + "--symbolic-frontier-limit", + "1", + ]) + .assert_failure(); + + cmd.forge_fuse() + .args([ + "fuzz", + "replay", + "--match-contract", + "SymbolicInvariantRevertedPrefixTest", + "--match-test", + "invariant_notBroken", + "--corpus-dir", + "reverted_prefix_corpus", + ]) + .assert_failure(); +} + +#[forgetest_init] +fn symbolic_invariant_frontier_seeding_keeps_fail_on_revert_branch(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_invariant_frontier_seeding_keeps_fail_on_revert_branch because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicInvariantRevertSeed.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicInvariantRevertTarget { + uint256 sink; + + function act(uint256 value) external { + if (value > 777) { + revert("target revert"); + } else { + sink = value; + } + } +} + +contract SymbolicInvariantRevertSeed is Test { + SymbolicInvariantRevertTarget target; + + function setUp() public { + target = new SymbolicInvariantRevertTarget(); + targetContract(address(target)); + } + + function invariant_ok() public pure {} +} +"#, + ); + + cmd.forge_fuse() + .args([ + "fuzz", + "run", + "--match-contract", + "SymbolicInvariantRevertSeed", + "--runs", + "1", + "--depth", + "1", + "--seed", + "0x1234", + "--threads", + "1", + "--frontier-dir", + "revert_frontiers", + ]) + .assert_success(); + + let frontier_path = find_stateful_frontier_artifact(&prj.root().join("revert_frontiers")); + let target_frontier = keep_only_matching_frontier(&frontier_path, "value > 777", |frontier| { + frontier["call_index"] == 0 + && frontier["site"]["opcode_name"] == "GT" + && frontier["operands"]["result"] == false + && (frontier["operands"]["lhs"] == "0x309" || frontier["operands"]["rhs"] == "0x309") + }); + let target_frontier_id = target_frontier["id"].as_u64().unwrap().to_string(); + + cmd.forge_fuse(); + cmd.env("FOUNDRY_INVARIANT_RUNS", "0"); + cmd.args([ + "test", + "--match-contract", + "SymbolicInvariantRevertSeed", + "--threads", + "1", + "--invariant-frontier-dir", + "revert_frontiers", + "--invariant-corpus-dir", + "ignored_revert_corpus", + "--symbolic-use-fuzz-frontiers", + "--symbolic-frontier-limit", + "1", + "--symbolic-frontier-ids", + &target_frontier_id, + ]) + .assert_success(); + let ignored_corpus_dir = prj + .root() + .join("ignored_revert_corpus") + .join("SymbolicInvariantRevertSeed") + .join("worker0") + .join("corpus"); + assert!( + !ignored_corpus_dir.exists() || ignored_corpus_dir.read_dir().unwrap().next().is_none(), + "ordinary revert seed ignored by policy was persisted" + ); + + cmd.forge_fuse(); + cmd.env("FOUNDRY_INVARIANT_FAIL_ON_REVERT", "true"); + cmd.env("FOUNDRY_INVARIANT_RUNS", "0"); + cmd.env("RUST_LOG", "forge::runner=debug"); + let output = cmd + .args([ + "test", + "--match-contract", + "SymbolicInvariantRevertSeed", + "--threads", + "1", + "--invariant-frontier-dir", + "revert_frontiers", + "--invariant-corpus-dir", + "revert_corpus", + "--symbolic-use-fuzz-frontiers", + "--symbolic-frontier-limit", + "1", + "--symbolic-frontier-ids", + &target_frontier_id, + ]) + .assert_success() + .get_output() + .clone(); + + let corpus_dir = prj + .root() + .join("revert_corpus") + .join("SymbolicInvariantRevertSeed") + .join("worker0") + .join("corpus"); + assert!( + corpus_dir.exists() && corpus_dir.read_dir().unwrap().next().is_some(), + "missing replayable fail-on-revert corpus seed for {target_frontier}: stdout={} stderr={}", + output.stdout_lossy(), + output.stderr_lossy() + ); + + let seed_path = corpus_dir.read_dir().unwrap().next().unwrap().unwrap().path(); + let seed: Value = serde_json::from_slice(&std::fs::read(&seed_path).unwrap()).unwrap(); + let calldata = + hex::decode(seed[0]["calldata"].as_str().unwrap().trim_start_matches("0x")).unwrap(); + assert!(U256::from_be_slice(&calldata[4..]) > U256::from(777)); +} + +#[forgetest_init] +fn symbolic_invariant_frontier_seeding_prefers_assertion_candidate(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_invariant_frontier_seeding_prefers_assertion_candidate because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicInvariantAssertionSeed.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicInvariantAssertionTarget { + uint256 sink; + + function act(uint256 value, bool fail) external { + if (value > 777) { + if (fail) assert(false); + sink = value; + } + } +} + +contract SymbolicInvariantAssertionSeed is Test { + SymbolicInvariantAssertionTarget target; + + function setUp() public { + target = new SymbolicInvariantAssertionTarget(); + targetContract(address(target)); + } + + function invariant_ok() public pure {} +} +"#, + ); + + cmd.forge_fuse() + .args([ + "fuzz", + "run", + "--match-contract", + "SymbolicInvariantAssertionSeed", + "--runs", + "1", + "--depth", + "1", + "--seed", + "0x1234", + "--threads", + "1", + "--frontier-dir", + "assertion_frontiers", + ]) + .assert_success(); + + let frontier_path = find_stateful_frontier_artifact(&prj.root().join("assertion_frontiers")); + let target_frontier = keep_only_matching_frontier(&frontier_path, "value > 777", |frontier| { + frontier["call_index"] == 0 + && frontier["site"]["opcode_name"] == "GT" + && frontier["operands"]["result"] == false + && (frontier["operands"]["lhs"] == "0x309" || frontier["operands"]["rhs"] == "0x309") + }); + let target_frontier_id = target_frontier["id"].as_u64().unwrap().to_string(); + + cmd.forge_fuse(); + cmd.env("FOUNDRY_INVARIANT_RUNS", "0"); + cmd.args([ + "test", + "--match-contract", + "SymbolicInvariantAssertionSeed", + "--threads", + "1", + "--invariant-frontier-dir", + "assertion_frontiers", + "--invariant-corpus-dir", + "assertion_corpus", + "--symbolic-use-fuzz-frontiers", + "--symbolic-frontier-limit", + "1", + "--symbolic-frontier-ids", + &target_frontier_id, + ]) + .assert_success(); + + let corpus_dir = prj + .root() + .join("assertion_corpus") + .join("SymbolicInvariantAssertionSeed") + .join("worker0") + .join("corpus"); + let seed_path = corpus_dir.read_dir().unwrap().next().unwrap().unwrap().path(); + let seed: Value = serde_json::from_slice(&std::fs::read(&seed_path).unwrap()).unwrap(); + let calldata = + hex::decode(seed[0]["calldata"].as_str().unwrap().trim_start_matches("0x")).unwrap(); + assert!(U256::from_be_slice(&calldata[4..36]) > U256::from(777)); + assert_eq!(U256::from_be_slice(&calldata[36..]), U256::ONE); + + cmd.forge_fuse() + .args([ + "fuzz", + "replay", + "--match-contract", + "SymbolicInvariantAssertionSeed", + "--match-test", + "invariant_ok", + "--corpus-dir", + "assertion_corpus", + ]) + .assert_failure(); +} + +#[forgetest_init] +fn symbolic_invariant_frontier_seeding_retains_property_breaking_candidate(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_invariant_frontier_seeding_retains_property_breaking_candidate because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicInvariantCandidateSeed.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicInvariantCandidateTarget { + bool public broken; + + function act(uint256 value) external { + if (value < 10 && value == 7) broken = true; + } +} + +contract SymbolicInvariantCandidateSeed is Test { + SymbolicInvariantCandidateTarget target; + + function setUp() public { + target = new SymbolicInvariantCandidateTarget(); + targetContract(address(target)); + } + + function invariant_anchor() public view { + assertFalse(target.broken()); + } + + function invariant_notBroken() public view { + assertFalse(target.broken()); + } + + function afterInvariant() public view { + assertFalse(target.broken()); + } +} +"#, + ); + + cmd.forge_fuse() + .args([ + "fuzz", + "run", + "--match-contract", + "SymbolicInvariantCandidateSeed", + "--runs", + "1", + "--depth", + "1", + "--seed", + "0x1234", + "--dictionary-weight", + "0", + "--threads", + "1", + "--frontier-dir", + "candidate_frontiers", + ]) + .assert_success(); + + let frontier_path = find_stateful_frontier_artifact(&prj.root().join("candidate_frontiers")); + let target_frontier = keep_only_matching_frontier(&frontier_path, "value < 10", |frontier| { + frontier["call_index"] == 0 + && frontier["site"]["opcode_name"] == "LT" + && frontier["operands"]["result"] == false + && (frontier["operands"]["lhs"] == "0xa" || frontier["operands"]["rhs"] == "0xa") + }); + let target_frontier_id = target_frontier["id"].as_u64().unwrap().to_string(); + let artifact: Value = serde_json::from_slice(&std::fs::read(&frontier_path).unwrap()).unwrap(); + + let failure_dir = prj.root().join("unwritable_failures"); + std::fs::write(&failure_dir, b"not a directory").unwrap(); + cmd.forge_fuse(); + cmd.env("FOUNDRY_INVARIANT_RUNS", "0"); + cmd.env("FOUNDRY_INVARIANT_FAILURE_PERSIST_DIR", failure_dir); + cmd.env("FOUNDRY_INVARIANT_DEPTH", "2"); + cmd.env("FOUNDRY_INVARIANT_CHECK_INTERVAL", "0"); + let nonterminal_output = cmd + .args([ + "test", + "--match-contract", + "SymbolicInvariantCandidateSeed", + "--threads", + "1", + "--invariant-frontier-dir", + "candidate_frontiers", + "--invariant-corpus-dir", + "nonterminal_candidate_corpus", + "--symbolic-use-fuzz-frontiers", + "--symbolic-check-invariant-frontiers", + "--symbolic-frontier-limit", + "1", + "--symbolic-frontier-ids", + &target_frontier_id, + ]) + .assert_success() + .get_output() + .clone(); + let nonterminal_corpus = prj + .root() + .join("nonterminal_candidate_corpus") + .join("SymbolicInvariantCandidateSeed") + .join("worker0") + .join("corpus"); + assert!( + std::fs::read_dir(&nonterminal_corpus).is_ok_and(|mut entries| entries.next().is_some()), + "empty {}\nstdout={}\nstderr={}", + nonterminal_corpus.display(), + nonterminal_output.stdout_lossy(), + nonterminal_output.stderr_lossy() + ); + + cmd.env("FOUNDRY_INVARIANT_DEPTH", "1"); + let output = cmd + .forge_fuse() + .args([ + "test", + "--match-contract", + "SymbolicInvariantCandidateSeed", + "--json", + "--threads", + "1", + "--invariant-frontier-dir", + "candidate_frontiers", + "--invariant-corpus-dir", + "candidate_corpus", + "--symbolic-use-fuzz-frontiers", + "--symbolic-frontier-limit", + "1", + "--symbolic-frontier-ids", + &target_frontier_id, + ]) + .assert_failure() + .get_output() + .stdout + .clone(); + let result = json_test_result(&output, "invariant_anchor()"); + let failures = result["invariant_failures"].as_array().unwrap(); + assert_eq!(failures[0]["name"], "invariant_anchor"); + assert_eq!(failures[1]["name"], "invariant_notBroken"); + + let broken_worker = prj + .root() + .join("broken_candidate_corpus") + .join("SymbolicInvariantCandidateSeed") + .join("worker0"); + std::fs::create_dir_all(broken_worker.parent().unwrap()).unwrap(); + std::fs::write(&broken_worker, b"not a directory").unwrap(); + let output = cmd + .forge_fuse() + .args([ + "test", + "--match-contract", + "SymbolicInvariantCandidateSeed", + "--json", + "--threads", + "1", + "--invariant-frontier-dir", + "candidate_frontiers", + "--invariant-corpus-dir", + "broken_candidate_corpus", + "--symbolic-use-fuzz-frontiers", + "--symbolic-frontier-limit", + "1", + "--symbolic-frontier-ids", + &target_frontier_id, + ]) + .assert_failure() + .get_output() + .stdout + .clone(); + let result = json_test_result(&output, "invariant_anchor()"); + let failures = result["invariant_failures"].as_array().unwrap(); + assert_eq!(failures[0]["name"], "invariant_anchor"); + assert_eq!(failures[1]["name"], "invariant_notBroken"); + + let output = cmd + .forge_fuse() + .args([ + "fuzz", + "replay", + "--match-contract", + "SymbolicInvariantCandidateSeed", + "--match-test", + "invariant_notBroken", + "--corpus-dir", + "candidate_corpus", + ]) + .assert_failure() + .get_output() + .clone(); + assert!( + output.stdout_lossy().contains("invariant_notBroken"), + "stdout={}\nstderr={}", + output.stdout_lossy(), + output.stderr_lossy() + ); + + cmd.env("FOUNDRY_INVARIANT_DEPTH", "2"); + cmd.env("FOUNDRY_INVARIANT_DEPTH_MODE", "random"); + cmd.env("FOUNDRY_INVARIANT_MIN_DEPTH", "1"); + let output = cmd + .forge_fuse() + .args([ + "test", + "--match-contract", + "SymbolicInvariantCandidateSeed", + "--json", + "--threads", + "1", + "--invariant-frontier-dir", + "candidate_frontiers", + "--invariant-corpus-dir", + "random_depth_candidate_corpus", + "--symbolic-use-fuzz-frontiers", + "--symbolic-check-invariant-frontiers", + "--symbolic-frontier-limit", + "1", + "--symbolic-frontier-ids", + &target_frontier_id, + ]) + .assert_failure() + .get_output() + .stdout + .clone(); + let result = json_test_result(&output, "invariant_anchor()"); + assert_eq!(result["invariant_failures"].as_array().unwrap().len(), 2); + + cmd.env("FOUNDRY_INVARIANT_DEPTH", "0"); + cmd.env("FOUNDRY_INVARIANT_MIN_DEPTH", "0"); + cmd.forge_fuse() + .args([ + "test", + "--match-contract", + "SymbolicInvariantCandidateSeed", + "--threads", + "1", + "--invariant-frontier-dir", + "candidate_frontiers", + "--invariant-corpus-dir", + "zero_depth_candidate_corpus", + "--symbolic-use-fuzz-frontiers", + "--symbolic-check-invariant-frontiers", + "--symbolic-frontier-limit", + "1", + "--symbolic-frontier-ids", + &target_frontier_id, + ]) + .assert_failure(); + + cmd.env("FOUNDRY_INVARIANT_DEPTH", "2"); + cmd.env("FOUNDRY_INVARIANT_DEPTH_MODE", "fixed"); + let mut sequenced_artifact = artifact; + let second_call = sequenced_artifact["sequences"][0][0].clone(); + sequenced_artifact["sequences"][0].as_array_mut().unwrap().push(second_call); + let mut terminal_frontier = target_frontier.clone(); + terminal_frontier["id"] = Value::from(target_frontier["id"].as_u64().unwrap() + 1); + terminal_frontier["call_index"] = Value::from(1); + let terminal_frontier_id = terminal_frontier["id"].as_u64().unwrap().to_string(); + sequenced_artifact["frontiers"].as_array_mut().unwrap().push(terminal_frontier); + std::fs::write(&frontier_path, serde_json::to_vec_pretty(&sequenced_artifact).unwrap()) + .unwrap(); + let frontier_ids = format!("{target_frontier_id},{terminal_frontier_id}"); + let output = cmd + .forge_fuse() + .args([ + "test", + "--match-contract", + "SymbolicInvariantCandidateSeed", + "--json", + "--threads", + "1", + "--invariant-frontier-dir", + "candidate_frontiers", + "--invariant-corpus-dir", + "sequenced_candidate_corpus", + "--symbolic-use-fuzz-frontiers", + "--symbolic-check-invariant-frontiers", + "--symbolic-frontier-limit", + "2", + "--symbolic-frontier-ids", + &frontier_ids, + ]) + .assert_failure() + .get_output() + .stdout + .clone(); + let result = json_test_result(&output, "invariant_anchor()"); + let failures = result["invariant_failures"].as_array().unwrap(); + assert_eq!(failures[0]["name"], "invariant_anchor"); + assert_eq!(failures[1]["name"], "invariant_notBroken"); +} + +#[forgetest_init] +fn symbolic_invariant_frontier_seeding_checks_hook_after_objective_revert(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_invariant_frontier_seeding_checks_hook_after_objective_revert because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicInvariantOptimizationSeed.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicInvariantOptimizationTarget { + uint256 public stored = 100; + + function act(uint256 value) external { + if (value < 10) stored = value; + } +} + +contract SymbolicInvariantOptimizationSeed is Test { + SymbolicInvariantOptimizationTarget target; + + function setUp() public { + target = new SymbolicInvariantOptimizationTarget(); + targetContract(address(target)); + } + + function invariant_objective() public view returns (int256) { + if (target.stored() < 10) revert("no objective"); + return int256(target.stored()); + } + + function afterInvariant() public view { + require(target.stored() >= 10, "hook failure"); + } +} +"#, + ); + + cmd.forge_fuse() + .args([ + "fuzz", + "run", + "--match-contract", + "SymbolicInvariantOptimizationSeed", + "--runs", + "1", + "--depth", + "1", + "--seed", + "0x1234", + "--dictionary-weight", + "0", + "--threads", + "1", + "--frontier-dir", + "optimization_frontiers", + ]) + .assert_success(); + + let frontier_path = find_stateful_frontier_artifact(&prj.root().join("optimization_frontiers")); + let target_frontier = keep_only_matching_frontier(&frontier_path, "value < 10", |frontier| { + frontier["call_index"] == 0 + && frontier["site"]["opcode_name"] == "LT" + && frontier["operands"]["result"] == false + && (frontier["operands"]["lhs"] == "0xa" || frontier["operands"]["rhs"] == "0xa") + }); + let target_frontier_id = target_frontier["id"].as_u64().unwrap().to_string(); + + cmd.forge_fuse(); + cmd.env("FOUNDRY_INVARIANT_RUNS", "0"); + let output = cmd + .args([ + "test", + "--json", + "--match-contract", + "SymbolicInvariantOptimizationSeed", + "--invariant-depth", + "1", + "--threads", + "1", + "--invariant-frontier-dir", + "optimization_frontiers", + "--invariant-corpus-dir", + "optimization_corpus", + "--symbolic-use-fuzz-frontiers", + "--symbolic-check-invariant-frontiers", + "--symbolic-frontier-limit", + "1", + "--symbolic-frontier-ids", + &target_frontier_id, + ]) + .assert_failure() + .get_output() + .stdout + .clone(); + let result = json_test_result(&output, "invariant_objective()"); + assert_eq!(result["invariant_failures"][0]["reason"], "hook failure"); + + let corpus_dir = prj + .root() + .join("optimization_corpus") + .join("SymbolicInvariantOptimizationSeed") + .join("invariant_objective") + .join("worker0") + .join("corpus"); + assert!(std::fs::read_dir(corpus_dir).unwrap().next().is_some()); +} + +#[forgetest_init] +fn symbolic_invariant_frontier_seeding_checks_property_from_prefix(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_invariant_frontier_seeding_checks_property_from_prefix because z3 is not available" + ); + return; + } + + prj.update_config(|config| config.assertions_revert = false); + prj.add_test( + "SymbolicInvariantPropertySeed.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicInvariantPropertyTarget { + uint256 public phase; + uint256 public stored; + + function account(uint256 value) external { + if (phase == 0) { + phase = 1; + return; + } + stored = value; + } +} + +contract SymbolicInvariantPropertySeed is Test { + SymbolicInvariantPropertyTarget target; + uint256 predicateTouches; + + function setUp() public { + target = new SymbolicInvariantPropertyTarget(); + targetContract(address(target)); + } + + function invariant_aMutationDoesNotLeak() public { + predicateTouches++; + assertEq(predicateTouches, 1); + } + + function invariant_bFirstStoredValue() public view { + assertEq(predicateTouches, 0); + assertNotEq(target.stored(), 123456789); + } + + function invariant_cPartialOutcomeIsRetained() public view { + if (target.stored() != 555555555) { + if (target.stored() == 777777777) vm.lastCallGas(); + return; + } + revert("partial candidate"); + } + + function invariant_dSecondStoredValue() public view { + assertEq(predicateTouches, 0); + assertNotEq(target.stored(), 987654321); + } + + function invariant_eBooleanReturnIsIgnored() public pure returns (bool) { + return false; + } +} +"#, + ); + + cmd.forge_fuse() + .args([ + "fuzz", + "run", + "--match-contract", + "SymbolicInvariantPropertySeed", + "--runs", + "1", + "--depth", + "2", + "--seed", + "0x9abc", + "--threads", + "1", + "--frontier-dir", + "property_frontiers", + ]) + .assert_success(); + + let frontier_path = find_stateful_frontier_artifact(&prj.root().join("property_frontiers")); + let mut artifact: Value = serde_json::from_slice( + &std::fs::read(&frontier_path) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", frontier_path.display())), + ) + .unwrap(); + let second_call = artifact["sequences"][0][0].clone(); + artifact["sequences"][0].as_array_mut().unwrap().push(second_call); + let mut target_frontier = artifact["frontiers"] + .as_array() + .unwrap() + .iter() + .find(|frontier| { + frontier["call_index"] == 0 + && frontier["site"]["opcode_name"] == "ISZERO" + && frontier["operands"]["lhs"] == "0x0" + && frontier["operands"]["rhs"] == "0x0" + }) + .cloned() + .unwrap_or_else(|| panic!("missing phase frontier in {artifact}")); + target_frontier["call_index"] = Value::from(1); + target_frontier["operands"]["lhs"] = Value::from("0x1"); + target_frontier["operands"]["result"] = Value::from(false); + let target_frontier_id = target_frontier["id"].as_u64().unwrap().to_string(); + *artifact["frontiers"].as_array_mut().unwrap() = vec![target_frontier]; + std::fs::write(&frontier_path, serde_json::to_vec_pretty(&artifact).unwrap()) + .unwrap_or_else(|err| panic!("failed to write {}: {err}", frontier_path.display())); + + cmd.forge_fuse(); + cmd.env("FOUNDRY_INVARIANT_RUNS", "0"); + cmd.args([ + "test", + "--match-contract", + "SymbolicInvariantPropertySeed", + "--invariant-depth", + "2", + "--threads", + "1", + "--invariant-frontier-dir", + "property_frontiers", + "--invariant-corpus-dir", + "branch_only_corpus", + "--symbolic-use-fuzz-frontiers", + "--symbolic-frontier-limit", + "1", + "--symbolic-frontier-ids", + &target_frontier_id, + ]) + .assert_success(); + cmd.forge_fuse() + .args([ + "fuzz", + "replay", + "--match-contract", + "SymbolicInvariantPropertySeed", + "--match-test", + "invariant_bFirstStoredValue", + "--corpus-dir", + "branch_only_corpus", + ]) + .assert_success(); + + cmd.forge_fuse(); + cmd.env("FOUNDRY_INVARIANT_RUNS", "0"); + cmd.env("FOUNDRY_INVARIANT_FAILURE_PERSIST_DIR", "property_failures"); + cmd.env("RUST_LOG", "forge::runner=debug"); + let output = cmd + .args([ + "test", + "--match-contract", + "SymbolicInvariantPropertySeed", + "--invariant-depth", + "2", + "--threads", + "1", + "--invariant-frontier-dir", + "property_frontiers", + "--invariant-corpus-dir", + "property_corpus", + "--symbolic-use-fuzz-frontiers", + "--symbolic-check-invariant-frontiers", + "--symbolic-frontier-limit", + "1", + "--symbolic-frontier-ids", + &target_frontier_id, + ]) + .assert_failure() + .get_output() + .clone(); + let stderr = output.stderr_lossy(); + assert!( + stderr.contains("symbolic invariant frontier candidate search incomplete") + && stderr.contains("lastCallGas"), + "stdout={}\nstderr={}", + output.stdout_lossy(), + stderr + ); + + for invariant in [ + "invariant_bFirstStoredValue", + "invariant_cPartialOutcomeIsRetained", + "invariant_dSecondStoredValue", + ] { + let output = cmd + .forge_fuse() + .args([ + "fuzz", + "replay", + "--match-contract", + "SymbolicInvariantPropertySeed", + "--match-test", + invariant, + "--corpus-dir", + "property_corpus", + ]) + .assert_failure() + .get_output() + .clone(); + assert!( + output.stdout_lossy().contains(invariant), + "stdout={}\nstderr={}", + output.stdout_lossy(), + output.stderr_lossy() + ); + } + + for invariant in ["invariant_aMutationDoesNotLeak", "invariant_eBooleanReturnIsIgnored"] { + cmd.forge_fuse() + .args([ + "fuzz", + "replay", + "--match-contract", + "SymbolicInvariantPropertySeed", + "--match-test", + invariant, + "--corpus-dir", + "property_corpus", + ]) + .assert_success(); + } + + let symbolic_cmd = cmd.forge_fuse(); + symbolic_cmd.env("FOUNDRY_INVARIANT_FAILURE_PERSIST_DIR", "symbolic_failures"); + let output = symbolic_cmd + .args([ + "test", + "--symbolic", + "--json", + "--match-contract", + "SymbolicInvariantPropertySeed", + "--match-test", + "invariant_cPartialOutcomeIsRetained", + "--symbolic-invariant-depth", + "2", + ]) + .assert_failure() + .get_output() + .stdout + .clone(); + let result = json_test_result(&output, "invariant_cPartialOutcomeIsRetained()"); + assert_eq!(result["symbolic"]["status"], "fail_counterexample"); + assert_eq!(result["symbolic"]["replay"]["status"], "confirmed"); +} + +#[forgetest_init] +fn symbolic_invariant_frontier_seeding_defers_hook(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_invariant_frontier_seeding_defers_hook because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicInvariantHookSeed.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicInvariantHookTarget { + uint256 public stored; + + function set(uint256 value) external { + if (value == 42) return; + stored = value; + } +} + +contract SymbolicInvariantHookSeed is Test { + SymbolicInvariantHookTarget target; + uint256 predicateTouches; + + function setUp() public { + target = new SymbolicInvariantHookTarget(); + targetContract(address(target)); + } + + function invariant_anchor() public returns (bool) { + predicateTouches++; + return false; + } + + function afterInvariant() public view { + require(msg.sender == 0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38, "caller"); + if (predicateTouches == 0) assert(target.stored() != 123456789); + } +} +"#, + ); + + cmd.forge_fuse() + .args([ + "fuzz", + "run", + "--match-contract", + "SymbolicInvariantHookSeed", + "--runs", + "1", + "--depth", + "2", + "--seed", + "0xdef0", + "--threads", + "1", + "--sender", + "0x0000000000000000000000000000000000000002", + "--frontier-dir", + "hook_frontiers", + ]) + .assert_success(); + + let frontier_path = find_stateful_frontier_artifact(&prj.root().join("hook_frontiers")); + let mut artifact: Value = serde_json::from_slice( + &std::fs::read(&frontier_path) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", frontier_path.display())), + ) + .unwrap(); + let frontier = artifact["frontiers"] + .as_array() + .unwrap() + .iter() + .find(|frontier| frontier["call_index"] == 0) + .cloned() + .unwrap_or_else(|| panic!("missing handler frontier in {artifact}")); + *artifact["frontiers"].as_array_mut().unwrap() = vec![frontier]; + std::fs::write(&frontier_path, serde_json::to_vec_pretty(&artifact).unwrap()) + .unwrap_or_else(|err| panic!("failed to write {}: {err}", frontier_path.display())); + + cmd.forge_fuse(); + cmd.env("FOUNDRY_INVARIANT_RUNS", "0"); + let seed_output = cmd + .args([ + "test", + "--match-contract", + "SymbolicInvariantHookSeed", + "--sender", + "0x0000000000000000000000000000000000000002", + "--threads", + "1", + "--invariant-frontier-dir", + "hook_frontiers", + "--invariant-corpus-dir", + "hook_corpus", + "--symbolic-use-fuzz-frontiers", + "--symbolic-check-invariant-frontiers", + "--symbolic-frontier-limit", + "1", + ]) + .assert_success() + .get_output() + .clone(); + let corpus_path = prj + .root() + .join("hook_corpus") + .join("SymbolicInvariantHookSeed") + .join("worker0") + .join("corpus"); + let corpus_entries = std::fs::read_dir(&corpus_path).map_or(0, |entries| entries.count()); + assert!( + corpus_entries > 0, + "empty {}\nstdout={}\nstderr={}", + corpus_path.display(), + seed_output.stdout_lossy(), + seed_output.stderr_lossy() + ); + cmd.forge_fuse() + .args([ + "fuzz", + "replay", + "--match-contract", + "SymbolicInvariantHookSeed", + "--match-test", + "invariant_anchor", + "--sender", + "0x0000000000000000000000000000000000000002", + "--corpus-dir", + "hook_corpus", + ]) + .assert_failure(); +} + +#[forgetest_init] +fn symbolic_invariant_frontier_seeding_tracks_checkpoint_failures(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_invariant_frontier_seeding_tracks_checkpoint_failures because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicInvariantCheckpointSeed.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicInvariantCheckpointTarget { + uint256 public stored; + uint256 public marker; + + function set(uint256 value) external { + stored = value; + if (value == 2) marker = 1; + } +} + +contract SymbolicInvariantCheckpointSeed is Test { + SymbolicInvariantCheckpointTarget target; + + function setUp() public { + target = new SymbolicInvariantCheckpointTarget(); + targetContract(address(target)); + } + + function invariant_anchor() public view { + require(target.stored() != 1, "predicate failure"); + } + + function afterInvariant() public view { + require(target.stored() != 2, "hook failure"); + } +} +"#, + ); + + cmd.forge_fuse() + .args([ + "fuzz", + "run", + "--match-contract", + "SymbolicInvariantCheckpointSeed", + "--runs", + "1", + "--depth", + "1", + "--seed", + "0x1234", + "--dictionary-weight", + "0", + "--threads", + "1", + "--frontier-dir", + "checkpoint_frontiers", + ]) + .assert_success(); + + let frontier_path = find_stateful_frontier_artifact(&prj.root().join("checkpoint_frontiers")); + let mut artifact: Value = + serde_json::from_slice(&std::fs::read(&frontier_path).unwrap()).unwrap(); + let mut frontier = artifact["frontiers"] + .as_array() + .unwrap() + .iter() + .filter(|frontier| frontier["call_index"] == 0 && frontier["site"]["opcode_name"] == "EQ") + .max_by_key(|frontier| frontier["site"]["pc"].as_u64()) + .cloned() + .unwrap_or_else(|| panic!("missing target comparison frontier in {artifact}")); + frontier["call_index"] = Value::from(1); + let frontier_id = frontier["id"].as_u64().unwrap().to_string(); + *artifact["frontiers"].as_array_mut().unwrap() = vec![frontier.clone()]; + + let mut first_call = artifact["sequences"][0][0].clone(); + let selector = &keccak256(b"set(uint256)")[..4]; + first_call["calldata"] = Value::from(format!("{}{:064x}", hex::encode_prefixed(selector), 1)); + artifact["sequences"][0].as_array_mut().unwrap().insert(0, first_call); + std::fs::write(&frontier_path, serde_json::to_vec_pretty(&artifact).unwrap()).unwrap(); + + cmd.forge_fuse(); + cmd.env("FOUNDRY_INVARIANT_RUNS", "0"); + cmd.env("FOUNDRY_INVARIANT_CHECK_INTERVAL", "1"); + let output = cmd + .args([ + "test", + "--json", + "--match-contract", + "SymbolicInvariantCheckpointSeed", + "--invariant-depth", + "2", + "--threads", + "1", + "--invariant-frontier-dir", + "checkpoint_frontiers", + "--invariant-corpus-dir", + "checkpoint_corpus", + "--symbolic-use-fuzz-frontiers", + "--symbolic-check-invariant-frontiers", + "--symbolic-frontier-limit", + "1", + "--symbolic-frontier-ids", + &frontier_id, + ]) + .assert_failure() + .get_output() + .clone(); + assert!( + !output.stdout.is_empty(), + "missing JSON output\nstdout={}\nstderr={}", + output.stdout_lossy(), + output.stderr_lossy() + ); + let result = json_test_result(&output.stdout, "invariant_anchor()"); + assert_eq!(result["invariant_failures"][0]["reason"], "predicate failure"); +} + +#[forgetest_init] +fn symbolic_import_fuzz_corpus_guides_bounded_symbolic_path(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_import_fuzz_corpus_guides_bounded_symbolic_path because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicImportFuzzCorpus.t.sol", + r#" +contract SymbolicImportFuzzCorpus { + function testGuided(uint256 x) public pure { + if (x != 7) return; + assert(false); + } +} +"#, + ); + + let empty_output = cmd + .forge_fuse() + .args([ + "test", + "--match-test", + "testGuided", + "--symbolic-use-fuzz-corpus", + "--fuzz-corpus-dir", + "empty_fuzz_corpus", + "--symbolic-width", + "1", + "--json", + ]) + .assert_failure() + .get_output() + .stdout + .clone(); + let empty_result = json_test_result(&empty_output, "testGuided(uint256)"); + let empty_symbolic = &empty_result["symbolic"]; + assert_eq!(empty_symbolic["status"], "incomplete"); + assert_eq!(empty_symbolic["corpus_seeds"]["used"].as_array().unwrap().len(), 0); + + let selector = &keccak256(b"testGuided(uint256)")[..4]; + let calldata = format!("{}{:064x}", hex::encode_prefixed(selector), 7); + let corpus_dir = prj + .root() + .join("fuzz_corpus") + .join("SymbolicImportFuzzCorpus") + .join("testGuided") + .join("worker0") + .join("corpus"); + std::fs::create_dir_all(&corpus_dir).unwrap(); + let seed_path = corpus_dir.join("00000000-0000-0000-0000-000000000001-1.json"); + let seed = serde_json::json!([ + { + "sender": "0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38", + "target": "0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496", + "calldata": calldata + } + ]); + std::fs::write(&seed_path, serde_json::to_vec_pretty(&seed).unwrap()).unwrap(); + + let output = cmd + .forge_fuse() + .args([ + "test", + "--match-test", + "testGuided", + "--symbolic-use-fuzz-corpus", + "--fuzz-corpus-dir", + "fuzz_corpus", + "--symbolic-width", + "1", + "--json", + ]) + .assert_failure() + .get_output() + .stdout + .clone(); + let result = json_test_result(&output, "testGuided(uint256)"); + let symbolic = &result["symbolic"]; + assert_eq!(symbolic["status"], "fail_counterexample"); + assert_eq!(symbolic["counterexample"]["raw_args"], "7"); + assert_eq!(symbolic["corpus_seeds"]["loaded"], 1); + assert_eq!(symbolic["corpus_seeds"]["skipped"], 0); + let used = symbolic["corpus_seeds"]["used"].as_array().unwrap(); + assert_eq!(used.len(), 1); + assert_eq!(used[0]["calldata"], calldata); + assert_eq!(std::path::PathBuf::from(used[0]["path"].as_str().unwrap()), seed_path); +} + +#[forgetest_init] +fn symbolic_import_fuzz_corpus_prioritizes_seeded_calldata_variant(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_import_fuzz_corpus_prioritizes_seeded_calldata_variant because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicImportFuzzCorpusVariants.t.sol", + r#" +contract SymbolicImportFuzzCorpusVariants { + /// forge-config: default.symbolic.default_bytes_lengths = [1, 2] + function testGuidedBytes(bytes memory data) public pure { + if (data.length == 1) { + if (data[0] == 0x11) return; + return; + } + if (data.length != 2) return; + assert(false); + } +} +"#, + ); + + let selector = &keccak256(b"testGuidedBytes(bytes)")[..4]; + let calldata = format!( + "{}{:064x}{:064x}{:0<64}", + hex::encode_prefixed(selector), + 32, + 2, + hex::encode([0xaa, 0xbb]) + ); + let unmodeled_calldata = format!( + "{}{:064x}{:064x}{:0<64}", + hex::encode_prefixed(selector), + 32, + 3, + hex::encode([0xcc, 0xdd, 0xee]) + ); + let corpus_dir = prj + .root() + .join("fuzz_corpus") + .join("SymbolicImportFuzzCorpusVariants") + .join("testGuidedBytes") + .join("worker0") + .join("corpus"); + std::fs::create_dir_all(&corpus_dir).unwrap(); + let seed_path = corpus_dir.join("00000000-0000-0000-0000-000000000001-1.json"); + let unmodeled_seed_path = corpus_dir.join("00000000-0000-0000-0000-000000000002-1.json"); + for (path, calldata) in + [(&seed_path, calldata.as_str()), (&unmodeled_seed_path, unmodeled_calldata.as_str())] + { + let seed = serde_json::json!([ + { + "sender": "0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38", + "target": "0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496", + "calldata": calldata + } + ]); + std::fs::write(path, serde_json::to_vec_pretty(&seed).unwrap()).unwrap(); + } + + let output = cmd + .forge_fuse() + .args([ + "test", + "--match-test", + "testGuidedBytes", + "--symbolic-use-fuzz-corpus", + "--fuzz-corpus-dir", + "fuzz_corpus", + "--symbolic-width", + "2", + "--json", + ]) + .assert_failure() + .get_output() + .stdout + .clone(); + let result = json_test_result(&output, "testGuidedBytes(bytes)"); + let symbolic = &result["symbolic"]; + assert_eq!(symbolic["status"], "fail_counterexample"); + assert_eq!(symbolic["corpus_seeds"]["loaded"], 2); + assert_eq!(symbolic["corpus_seeds"]["skipped"], 0); + let used = symbolic["corpus_seeds"]["used"].as_array().unwrap(); + assert_eq!(used.len(), 1); + assert_eq!(used[0]["calldata"], calldata); + assert_eq!(std::path::PathBuf::from(used[0]["path"].as_str().unwrap()), seed_path); +} + +#[forgetest_init] +fn symbolic_import_fuzz_corpus_honors_function_inline_config(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_import_fuzz_corpus_honors_function_inline_config because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicInlineImportFuzzCorpus.t.sol", + r#" +contract SymbolicInlineImportFuzzCorpus { + /// forge-config: default.symbolic.use_fuzz_corpus = true + function testFuzz_inline(uint256 x) public pure { + if (x != 7) return; + assert(false); + } +} +"#, + ); + + let selector = &keccak256(b"testFuzz_inline(uint256)")[..4]; + let calldata = format!("{}{:064x}", hex::encode_prefixed(selector), 7); + let corpus_dir = prj + .root() + .join("fuzz_corpus") + .join("SymbolicInlineImportFuzzCorpus") + .join("testFuzz_inline") + .join("worker0") + .join("corpus"); + std::fs::create_dir_all(&corpus_dir).unwrap(); + let seed_path = corpus_dir.join("00000000-0000-0000-0000-000000000001-1.json"); + let seed = serde_json::json!([ + { + "sender": "0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38", + "target": "0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496", + "calldata": calldata + } + ]); + std::fs::write(&seed_path, serde_json::to_vec_pretty(&seed).unwrap()).unwrap(); + + let output = cmd + .forge_fuse() + .args([ + "test", + "--match-test", + "testFuzz_inline", + "--fuzz-corpus-dir", + "fuzz_corpus", + "--symbolic-width", + "1", + "--json", + ]) + .assert_failure() + .get_output() + .stdout + .clone(); + let result = json_test_result(&output, "testFuzz_inline(uint256)"); + let symbolic = &result["symbolic"]; + assert_eq!(symbolic["status"], "fail_counterexample"); + assert_eq!(symbolic["counterexample"]["raw_args"], "7"); + assert_eq!(symbolic["corpus_seeds"]["loaded"], 1); + let used = symbolic["corpus_seeds"]["used"].as_array().unwrap(); + assert_eq!(used.len(), 1); + assert_eq!(used[0]["calldata"], calldata); +} + +#[forgetest_init] +fn symbolic_seed_corpus_warns_without_corpus_dir(prj: _, cmd: _) { + prj.add_test( + "SymbolicFuzzCorpusNoDir.t.sol", + r#" +contract SymbolicFuzzCorpusNoDir { + function testFuzz_noop(uint256) public pure {} +} +"#, + ); + + let output = cmd + .forge_fuse() + .args([ + "test", + "--match-test", + "testFuzz_noop", + "--symbolic-seed-corpus", + "--fuzz-runs", + "1", + ]) + .assert_success() + .get_output() + .clone(); + let stderr = output.stderr_lossy(); + + assert!( + stderr + .contains("`--symbolic-seed-corpus` requires `--fuzz-corpus-dir` or `fuzz.corpus_dir`"), + "{stderr}" + ); +} + +#[forgetest_init] +fn symbolic_artifact_replay_uses_stored_fail_on_revert(prj: _, cmd: _) { + prj.update_config(|config| { + config.invariant.fail_on_revert = false; + }); + prj.add_test( + "SymbolicArtifactFailOnRevert.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicArtifactFailOnRevert is Test { + uint256 ignored; + + function setUp() public { + targetContract(address(this)); + } + + function step() external { + ignored = 1; + revert("boom"); + } + + function skip() external { + vm.assume(false); + } + + function invariant_noop() public pure {} +} +"#, + ); + + let artifact_path = prj.root().join("fail-on-revert-artifact.json"); + let artifact = serde_json::json!({ + "schema_version": 1, + "schema": "foundry:symbolic.counterexample@v1", + "kind": "sequence", + "test": { + "contract": "test/SymbolicArtifactFailOnRevert.t.sol:SymbolicArtifactFailOnRevert", + "test": "invariant_noop()" + }, + "replay": { + "required": true, + "status": "confirmed", + "reason": "boom" + }, + "replay_semantics": { + "fail_on_revert": true + }, + "bounds": { + "timeout_seconds": null, + "loop_bound": null, + "max_depth": 0, + "max_paths": 0, + "invariant_depth": 1, + "exploration_order": "bfs", + "max_solver_queries": 0, + "default_dynamic_length": 0, + "max_dynamic_length": 0, + "array_lengths": [], + "dynamic_lengths": {}, + "default_array_lengths": [], + "default_bytes_lengths": [], + "max_calldata_bytes": 0, + "symbolic_call_targets": false, + "storage_layout": "solidity" + }, + "solver": { + "name": "manual", + "command": null, + "portfolio": [], + "stats": { + "paths": 0, + "solver_queries": 0, + "smt_queries": 0, + "sat_queries": 0, + "model_queries": 0, + "sat_cache_hits": 0, + "model_cache_hits": 0, + "heuristic_witnesses": 0, + "solver_time_ms": 0, + "smt_input_bytes": 0, + "smt_max_query_bytes": 0, + "smt_build_time_ms": 0, + "smt_max_query_time_ms": 0 + } + }, + "assumptions": [], + "call_trace": { + "available": false, + "source": null, + "format": null + }, + "calls": [ + { + "warp": null, + "roll": null, + "sender": "0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38", + "target": "0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496", + "calldata": "0x1d2aa5b3", + "value": null, + "contract_name": "SymbolicArtifactFailOnRevert", + "function_name": "skip", + "signature": "skip()", + "args": "", + "raw_args": "" + }, + { + "warp": null, + "roll": null, + "sender": "0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38", + "target": "0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496", + "calldata": "0xe25fe175", + "value": null, + "contract_name": "SymbolicArtifactFailOnRevert", + "function_name": "step", + "signature": "step()", + "args": "", + "raw_args": "" + } + ] + }); + std::fs::write(&artifact_path, serde_json::to_vec_pretty(&artifact).unwrap()).unwrap(); + + let mut bare_contract_artifact = artifact.clone(); + bare_contract_artifact["test"]["contract"] = serde_json::json!("SymbolicArtifactFailOnRevert"); + std::fs::write(&artifact_path, serde_json::to_vec_pretty(&bare_contract_artifact).unwrap()) + .unwrap(); + let stderr = cmd + .forge_fuse() + .args(["test", "--replay-symbolic-artifact", artifact_path.to_str().unwrap()]) + .assert_failure() + .get_output() + .stderr_lossy(); + assert!(stderr.contains("test.contract must be `path:Contract`"), "{stderr}"); + std::fs::write(&artifact_path, serde_json::to_vec_pretty(&artifact).unwrap()).unwrap(); + + let output = cmd + .forge_fuse() + .args(["test", "--json", "--replay-symbolic-artifact", artifact_path.to_str().unwrap()]) + .assert_failure() + .get_output() + .stdout + .clone(); + let result = json_test_result(&output, "invariant_noop()"); + assert_eq!(result["status"], "Failure"); + assert!(result["kind"].get("Invariant").is_some(), "{result}"); + assert_eq!(result["kind"]["Invariant"]["runs"], 1); + assert_eq!(result["kind"]["Invariant"]["calls"], 2); + assert_eq!(result["kind"]["Invariant"]["reverts"], 1); + assert!(result["kind"].get("Unit").is_none(), "{result}"); + + let fixed_artifact_path = prj.root().join("fixed-artifact.json"); + let mut fixed_artifact = artifact; + fixed_artifact["replay_semantics"]["fail_on_revert"] = serde_json::json!(false); + std::fs::write(&fixed_artifact_path, serde_json::to_vec_pretty(&fixed_artifact).unwrap()) + .unwrap(); + + let output = cmd + .forge_fuse() + .args([ + "test", + "--json", + "--replay-symbolic-artifact", + fixed_artifact_path.to_str().unwrap(), + ]) + .assert_success() + .get_output() + .stdout + .clone(); + let result = json_test_result(&output, "invariant_noop()"); + assert_eq!(result["status"], "Success"); + assert!(result["kind"].get("Invariant").is_some(), "{result}"); + assert_eq!(result["kind"]["Invariant"]["runs"], 1); + assert_eq!(result["kind"]["Invariant"]["calls"], 2); + assert_eq!(result["kind"]["Invariant"]["reverts"], 1); + assert!(result["kind"].get("Unit").is_none(), "{result}"); +} + +#[forgetest_init] +fn symbolic_artifact_replay_matches_bracketed_path(prj: _, cmd: _) { + prj.add_test( + "SymbolicArtifact[Replay].t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicArtifactBracketPath is Test { + function setUp() public { + targetContract(address(this)); + } + + function step() external { + revert("boom"); + } + + function invariant_noop() public pure {} +} +"#, + ); + + let artifact_path = prj.root().join("bracketed-path-artifact.json"); + let artifact = serde_json::json!({ + "schema_version": 1, + "schema": "foundry:symbolic.counterexample@v1", + "kind": "sequence", + "test": { + "contract": "test/SymbolicArtifact[Replay].t.sol:SymbolicArtifactBracketPath", + "test": "invariant_noop()" + }, + "replay": { + "required": true, + "status": "confirmed", + "reason": "boom" + }, + "replay_semantics": { + "fail_on_revert": true + }, + "bounds": { + "timeout_seconds": null, + "loop_bound": null, + "max_depth": 0, + "max_paths": 0, + "invariant_depth": 1, + "exploration_order": "bfs", + "max_solver_queries": 0, + "default_dynamic_length": 0, + "max_dynamic_length": 0, + "array_lengths": [], + "dynamic_lengths": {}, + "default_array_lengths": [], + "default_bytes_lengths": [], + "max_calldata_bytes": 0, + "symbolic_call_targets": false, + "storage_layout": "solidity" + }, + "solver": { + "name": "manual", + "command": null, + "portfolio": [], + "stats": { + "paths": 0, + "solver_queries": 0, + "smt_queries": 0, + "sat_queries": 0, + "model_queries": 0, + "sat_cache_hits": 0, + "model_cache_hits": 0, + "heuristic_witnesses": 0, + "solver_time_ms": 0, + "smt_input_bytes": 0, + "smt_max_query_bytes": 0, + "smt_build_time_ms": 0, + "smt_max_query_time_ms": 0 + } + }, + "assumptions": [], + "call_trace": { + "available": false, + "source": null, + "format": null + }, + "calls": [ + { + "warp": null, + "roll": null, + "sender": "0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38", + "target": "0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496", + "calldata": "0xe25fe175", + "value": null, + "contract_name": "SymbolicArtifactBracketPath", + "function_name": "step", + "signature": "step()", + "args": "", + "raw_args": "" + } + ] + }); + std::fs::write(&artifact_path, serde_json::to_vec_pretty(&artifact).unwrap()).unwrap(); + + let stdout = cmd + .forge_fuse() + .args(["test", "--replay-symbolic-artifact", artifact_path.to_str().unwrap()]) + .assert_failure() + .get_output() + .stdout_lossy(); + assert!(stdout.contains("invariant_noop()"), "{stdout}"); + assert!(stdout.contains("boom"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_artifact_replay_rejects_stale_sequence_target(prj: _, cmd: _) { + prj.add_test( + "SymbolicArtifactStaleTarget.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicArtifactStaleTarget is Test { + function setUp() public { + targetContract(address(this)); + } + + function step() external {} + + function invariant_noop() public pure {} +} +"#, + ); + + let artifact_path = prj.root().join("stale-sequence-selector-artifact.json"); + let mut artifact = serde_json::json!({ + "schema_version": 1, + "schema": "foundry:symbolic.counterexample@v1", + "kind": "sequence", + "test": { + "contract": "test/SymbolicArtifactStaleTarget.t.sol:SymbolicArtifactStaleTarget", + "test": "invariant_noop()" + }, + "replay": { + "required": true, + "status": "confirmed", + "reason": null + }, + "replay_semantics": { + "fail_on_revert": false + }, + "bounds": { + "timeout_seconds": null, + "loop_bound": null, + "max_depth": 0, + "max_paths": 0, + "invariant_depth": 1, + "exploration_order": "bfs", + "max_solver_queries": 0, + "default_dynamic_length": 0, + "max_dynamic_length": 0, + "array_lengths": [], + "dynamic_lengths": {}, + "default_array_lengths": [], + "default_bytes_lengths": [], + "max_calldata_bytes": 0, + "symbolic_call_targets": false, + "storage_layout": "solidity" + }, + "solver": { + "name": "manual", + "command": null, + "portfolio": [], + "stats": { + "paths": 0, + "solver_queries": 0, + "smt_queries": 0, + "sat_queries": 0, + "model_queries": 0, + "sat_cache_hits": 0, + "model_cache_hits": 0, + "heuristic_witnesses": 0, + "solver_time_ms": 0, + "smt_input_bytes": 0, + "smt_max_query_bytes": 0, + "smt_build_time_ms": 0, + "smt_max_query_time_ms": 0 + } + }, + "assumptions": [], + "call_trace": { + "available": false, + "source": null, + "format": null + }, + "calls": [{ + "warp": null, + "roll": null, + "sender": "0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38", + "target": "0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496", + "calldata": "0xffffffff", + "value": null, + "contract_name": "SymbolicArtifactStaleTarget", + "function_name": "step", + "signature": "step()", + "args": "", + "raw_args": "" + }] + }); + std::fs::write(&artifact_path, serde_json::to_vec_pretty(&artifact).unwrap()).unwrap(); + + let stdout = cmd + .forge_fuse() + .args(["test", "--replay-symbolic-artifact", artifact_path.to_str().unwrap()]) + .assert_failure() + .get_output() + .stdout_lossy(); + + assert!(stdout.contains("targets unknown function"), "{stdout}"); + + let stale_target_artifact_path = prj.root().join("stale-sequence-target-artifact.json"); + artifact["calls"][0]["target"] = + serde_json::json!("0x0000000000000000000000000000000000000000"); + artifact["calls"][0]["calldata"] = serde_json::json!("0xe25fe175"); + std::fs::write(&stale_target_artifact_path, serde_json::to_vec_pretty(&artifact).unwrap()) + .unwrap(); + + let stdout = cmd + .forge_fuse() + .args(["test", "--replay-symbolic-artifact", stale_target_artifact_path.to_str().unwrap()]) + .assert_failure() + .get_output() + .stdout_lossy(); + + assert!(stdout.contains("targets unknown function"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_artifact_replay_rejects_forbidden_sequence_sender(prj: _, cmd: _) { + prj.add_test( + "SymbolicArtifactForbiddenSender.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicArtifactForbiddenSender is Test { + bool drained; + address constant BOB = address(0xB0B); + + function setUp() public { + targetContract(address(this)); + excludeSender(BOB); + } + + function step() external { + if (msg.sender == BOB) { + drained = true; + } + } + + function invariant_notDrained() public view { + assert(!drained); + } +} +"#, + ); + + let artifact_path = prj.root().join("forbidden-sequence-sender-artifact.json"); + let artifact = serde_json::json!({ + "schema_version": 1, + "schema": "foundry:symbolic.counterexample@v1", + "kind": "sequence", + "test": { + "contract": "test/SymbolicArtifactForbiddenSender.t.sol:SymbolicArtifactForbiddenSender", + "test": "invariant_notDrained()" + }, + "replay": { + "required": true, + "status": "confirmed", + "reason": null + }, + "replay_semantics": { + "fail_on_revert": false + }, + "bounds": { + "timeout_seconds": null, + "loop_bound": null, + "max_depth": 0, + "max_paths": 0, + "invariant_depth": 1, + "exploration_order": "bfs", + "max_solver_queries": 0, + "default_dynamic_length": 0, + "max_dynamic_length": 0, + "array_lengths": [], + "dynamic_lengths": {}, + "default_array_lengths": [], + "default_bytes_lengths": [], + "max_calldata_bytes": 0, + "symbolic_call_targets": false, + "storage_layout": "solidity" + }, + "solver": { + "name": "manual", + "command": null, + "portfolio": [], + "stats": { + "paths": 0, + "solver_queries": 0, + "smt_queries": 0, + "sat_queries": 0, + "model_queries": 0, + "sat_cache_hits": 0, + "model_cache_hits": 0, + "heuristic_witnesses": 0, + "solver_time_ms": 0, + "smt_input_bytes": 0, + "smt_max_query_bytes": 0, + "smt_build_time_ms": 0, + "smt_max_query_time_ms": 0 + } + }, + "assumptions": [], + "call_trace": { + "available": false, + "source": null, + "format": null + }, + "calls": [{ + "warp": null, + "roll": null, + "sender": "0x0000000000000000000000000000000000000b0b", + "target": "0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496", + "calldata": "0xe25fe175", + "value": null, + "contract_name": "SymbolicArtifactForbiddenSender", + "function_name": "step", + "signature": "step()", + "args": "", + "raw_args": "" + }] + }); + std::fs::write(&artifact_path, serde_json::to_vec_pretty(&artifact).unwrap()).unwrap(); + + let stdout = cmd + .forge_fuse() + .args(["test", "--replay-symbolic-artifact", artifact_path.to_str().unwrap()]) + .assert_failure() + .get_output() + .stdout_lossy(); + + assert!(stdout.contains("uses forbidden sender"), "{stdout}"); + + let zero_sender_artifact_path = prj.root().join("zero-sequence-sender-artifact.json"); + let mut zero_sender_artifact = artifact; + zero_sender_artifact["calls"][0]["sender"] = + serde_json::json!("0x0000000000000000000000000000000000000000"); + std::fs::write( + &zero_sender_artifact_path, + serde_json::to_vec_pretty(&zero_sender_artifact).unwrap(), + ) + .unwrap(); + + let stdout = cmd + .forge_fuse() + .args(["test", "--replay-symbolic-artifact", zero_sender_artifact_path.to_str().unwrap()]) + .assert_failure() + .get_output() + .stdout_lossy(); + + assert!(stdout.contains("uses forbidden sender"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_artifact_replay_accepts_created_sequence_target(prj: _, cmd: _) { + prj.add_test( + "SymbolicArtifactCreatedTarget.t.sol", + r#" +import "forge-std/Test.sol"; + +contract CreatedTarget { + SymbolicArtifactCreatedTarget invariantTest; + + constructor(SymbolicArtifactCreatedTarget _invariantTest) { + invariantTest = _invariantTest; + } + + function step() external { + invariantTest.trip(); + } +} + +contract Spawner { + SymbolicArtifactCreatedTarget invariantTest; + + constructor(SymbolicArtifactCreatedTarget _invariantTest) { + invariantTest = _invariantTest; + } + + function step() external { + new CreatedTarget(invariantTest); + } +} + +contract SymbolicArtifactCreatedTarget is Test { + bool tripped; + + function setUp() public { + new Spawner(this); + } + + function trip() external { + tripped = true; + } + + function invariant_notTripped() public view { + assert(!tripped); + } +} +"#, + ); + + let artifact_path = prj.root().join("created-sequence-target-artifact.json"); + let artifact = serde_json::json!({ + "schema_version": 1, + "schema": "foundry:symbolic.counterexample@v1", + "kind": "sequence", + "test": { + "contract": "test/SymbolicArtifactCreatedTarget.t.sol:SymbolicArtifactCreatedTarget", + "test": "invariant_notTripped()" + }, + "replay": { + "required": true, + "status": "confirmed", + "reason": null + }, + "replay_semantics": { + "fail_on_revert": false + }, + "bounds": { + "timeout_seconds": null, + "loop_bound": null, + "max_depth": 0, + "max_paths": 0, + "invariant_depth": 2, + "exploration_order": "bfs", + "max_solver_queries": 0, + "default_dynamic_length": 0, + "max_dynamic_length": 0, + "array_lengths": [], + "dynamic_lengths": {}, + "default_array_lengths": [], + "default_bytes_lengths": [], + "max_calldata_bytes": 0, + "symbolic_call_targets": false, + "storage_layout": "solidity" + }, + "solver": { + "name": "manual", + "command": null, + "portfolio": [], + "stats": { + "paths": 0, + "solver_queries": 0, + "smt_queries": 0, + "sat_queries": 0, + "model_queries": 0, + "sat_cache_hits": 0, + "model_cache_hits": 0, + "heuristic_witnesses": 0, + "solver_time_ms": 0, + "smt_input_bytes": 0, + "smt_max_query_bytes": 0, + "smt_build_time_ms": 0, + "smt_max_query_time_ms": 0 + } + }, + "assumptions": [], + "call_trace": { + "available": false, + "source": null, + "format": null + }, + "calls": [ + { + "warp": null, + "roll": null, + "sender": "0x0000000000000000000000000000000000000b0b", + "target": "0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f", + "calldata": "0xe25fe175", + "value": null, + "contract_name": "Spawner", + "function_name": "step", + "signature": "step()", + "args": "", + "raw_args": "" + }, + { + "warp": null, + "roll": null, + "sender": "0x0000000000000000000000000000000000000b0b", + "target": "0x104fBc016F4bb334D775a19E8A6510109AC63E00", + "calldata": "0xe25fe175", + "value": null, + "contract_name": "CreatedTarget", + "function_name": "step", + "signature": "step()", + "args": "", + "raw_args": "" + } + ] + }); + std::fs::write(&artifact_path, serde_json::to_vec_pretty(&artifact).unwrap()).unwrap(); + + let stdout = cmd + .forge_fuse() + .args(["test", "--replay-symbolic-artifact", artifact_path.to_str().unwrap()]) + .assert_failure() + .get_output() + .stdout_lossy(); + + assert!(stdout.contains("panic: assertion failed"), "{stdout}"); + assert!(!stdout.contains("targets unknown function"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_artifact_replay_ignores_non_target_network_passes(prj: _, cmd: _) { + prj.add_test( + "SymbolicArtifactNetworkReplay.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicArtifactNetworkReplay is Test { + function setUp() public { + targetContract(address(this)); + } + + function step() external {} + + function invariant_noop() public pure {} + + /// forge-config: default.networks.network = "tempo" + function test_tempo_marker() public pure {} +} +"#, + ); + + let artifact_path = prj.root().join("network-replay-artifact.json"); + let artifact = serde_json::json!({ + "schema_version": 1, + "schema": "foundry:symbolic.counterexample@v1", + "kind": "sequence", + "test": { + "contract": "test/SymbolicArtifactNetworkReplay.t.sol:SymbolicArtifactNetworkReplay", + "test": "invariant_noop()" + }, + "replay": { + "required": true, + "status": "confirmed", + "reason": null + }, + "replay_semantics": { + "fail_on_revert": false + }, + "bounds": { + "timeout_seconds": null, + "loop_bound": null, + "max_depth": 0, + "max_paths": 0, + "invariant_depth": 1, + "exploration_order": "bfs", + "max_solver_queries": 0, + "default_dynamic_length": 0, + "max_dynamic_length": 0, + "array_lengths": [], + "dynamic_lengths": {}, + "default_array_lengths": [], + "default_bytes_lengths": [], + "max_calldata_bytes": 0, + "symbolic_call_targets": false, + "storage_layout": "solidity" + }, + "solver": { + "name": "manual", + "command": null, + "portfolio": [], + "stats": { + "paths": 0, + "solver_queries": 0, + "smt_queries": 0, + "sat_queries": 0, + "model_queries": 0, + "sat_cache_hits": 0, + "model_cache_hits": 0, + "heuristic_witnesses": 0, + "solver_time_ms": 0, + "smt_input_bytes": 0, + "smt_max_query_bytes": 0, + "smt_build_time_ms": 0, + "smt_max_query_time_ms": 0 + } + }, + "assumptions": [], + "call_trace": { + "available": false, + "source": null, + "format": null + }, + "calls": [{ + "warp": null, + "roll": null, + "sender": "0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38", + "target": "0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496", + "calldata": "0xe25fe175", + "value": null, + "contract_name": "SymbolicArtifactNetworkReplay", + "function_name": "step", + "signature": "step()", + "args": "", + "raw_args": "" + }] + }); + std::fs::write(&artifact_path, serde_json::to_vec_pretty(&artifact).unwrap()).unwrap(); + + let stdout = cmd + .forge_fuse() + .args(["test", "--replay-symbolic-artifact", artifact_path.to_str().unwrap()]) + .assert_success() + .get_output() + .stdout_lossy(); + assert!(stdout.contains("[PASS] invariant_noop()"), "{stdout}"); + assert!(!stdout.contains("was not found"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_invariant_respects_sequence_depth(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_invariant_respects_sequence_depth because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicInvariantDepth.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicDepthTarget { + uint256 public value; + + function arm(uint256 x) external { + if (x == 1) { + value = 1; + } + } + + function trip(uint256 x) external { + if (value == 1 && x == 2) { + value = 2; + } + } +} + +contract SymbolicInvariantDepth is Test { + SymbolicDepthTarget target; + + function setUp() public { + target = new SymbolicDepthTarget(); + targetContract(address(target)); + } + + /// forge-config: default.symbolic.invariant_depth = 2 + function invariant_valueBelowTwo() public view { + assert(target.value() < 2); + } +} +"#, + ); + + assert_symbolic(cmd.args(["test", "--symbolic", "--match-test", "invariant_valueBelowTwo"])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicInvariantDepth.t.sol:SymbolicInvariantDepth +[FAIL: panic: assertion failed (0x01)] + [Sequence] (original: 2, shrunk: 2) + [SENDER] [SENDER] calldata=arm(uint256) args=[1] + [SENDER] [SENDER] calldata=trip(uint256) args=[2] + invariant_valueBelowTwo() ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_invariant_uses_target_sender(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_invariant_uses_target_sender because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicInvariantSender.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicSenderTarget { + address public lastSender; + + function touch(uint256 x) external { + if (x == 3) { + lastSender = msg.sender; + } + } +} + +contract SymbolicInvariantSender is Test { + SymbolicSenderTarget target; + address constant BOB = address(0xB0B); + + function setUp() public { + target = new SymbolicSenderTarget(); + targetContract(address(target)); + targetSender(BOB); + } + + function invariant_senderIsNotBob() public view { + assert(target.lastSender() != BOB); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "invariant_senderIsNotBob", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicInvariantSender.t.sol:SymbolicInvariantSender +[FAIL: panic: assertion failed (0x01)] + [Sequence] (original: 1, shrunk: 1) + [SENDER] [SENDER] calldata=touch(uint256) [ARGS] + invariant_senderIsNotBob() ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!( + stdout.to_lowercase().contains("sender=0x0000000000000000000000000000000000000b0b"), + "{stdout}" + ); +} +#[forgetest_init] +fn symbolic_soundness_hardening_regressions(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_soundness_hardening_regressions because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicSoundnessHardening.t.sol", + r#" +import "forge-std/Test.sol"; + +interface SvmDynamic { + function createUint(uint256 bits, string calldata name) external returns (uint256); + function createInt(uint256 bits, string calldata name) external returns (int256); +} + +contract DelegateTarget { + function noop() external {} +} + +contract SymbolicSoundnessHardening is Test { + address constant SVM_ADDRESS = address(0xF3993A62377BCd56AE39D773740A5390411E8BC9); + + mapping(uint256 => uint256) values; + DelegateTarget target; + + function setUp() public { + target = new DelegateTarget(); + } + + function checkConstrainedStorageKeyUsesConcreteSlot(uint256 key) public { + values[7] = 0xbeef; + vm.assume(key == 7); + assertEq(values[key], 0xbeef); + } + + function checkRandomUintRejectsOversizedBits() public { + vm.randomUint(257); + } + + function checkCreateUintRejectsOversizedBits() public { + SvmDynamic(SVM_ADDRESS).createUint(257, "too-wide"); + } + + function checkCreateIntRejectsOversizedBits() public { + SvmDynamic(SVM_ADDRESS).createInt(300, "too-wide"); + } + + function checkPrankDelegatecallReportsUnsupported() public { + vm.prank(address(0xB0B)); + (bool ok,) = address(target).delegatecall(abi.encodeWithSignature("noop()")); + assertTrue(ok); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-contract", + "SymbolicSoundnessHardening", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 5 tests for test/SymbolicSoundnessHardening.t.sol:SymbolicSoundnessHardening +[PASS] checkConstrainedStorageKeyUsesConcreteSlot(uint256) ([METRICS]) +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: symbolic svm.create integer bits] checkCreateIntRejectsOversizedBits() ([METRICS]) +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: symbolic svm.create integer bits] checkCreateUintRejectsOversizedBits() ([METRICS]) +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: symbolic prank delegatecall] checkPrankDelegatecallReportsUnsupported() ([METRICS]) +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: symbolic randomUint bits] checkRandomUintRejectsOversizedBits() ([METRICS]) +Suite result: FAILED. 1 passed; 4 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_invalid_jumps_revert_current_frame(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_invalid_jumps_revert_current_frame because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicInvalidJump.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicInvalidJump is Test { + address constant INVALID_JUMP = address(0x1000); + address constant INVALID_JUMPI = address(0x2000); + address constant SYMBOLIC_DESTINATION = address(0x3000); + + function setUp() public { + vm.etch(INVALID_JUMP, hex"6001600055600856"); + vm.etch(INVALID_JUMPI, hex"60003560085700"); + vm.etch(SYMBOLIC_DESTINATION, hex"6020356000355700"); + } + + function checkInvalidJumpRevertsFrame(uint256) public { + (bool ok, bytes memory data) = INVALID_JUMP.call(""); + assertFalse(ok); + assertEq(data.length, 0); + assertEq(vm.load(INVALID_JUMP, bytes32(0)), bytes32(0)); + } + + function checkUntakenInvalidJumpiFallsThrough(uint256) public { + (bool ok, bytes memory data) = INVALID_JUMPI.call(abi.encode(uint256(0))); + assertTrue(ok); + assertEq(data.length, 0); + } + + function checkTakenInvalidJumpiRevertsFrame(uint256 condition) public { + vm.assume(condition != 0); + (bool ok, bytes memory data) = INVALID_JUMPI.call(abi.encode(condition)); + assertFalse(ok); + assertEq(data.length, 0); + } + + function checkSymbolicInvalidJumpiBranches(uint256 condition) public { + (bool ok, bytes memory data) = INVALID_JUMPI.call(abi.encode(condition)); + assertEq(ok, condition == 0); + assertEq(data.length, 0); + } + + function checkUntakenJumpiIgnoresSymbolicDestination(uint256 destination) public { + (bool ok, bytes memory data) = + SYMBOLIC_DESTINATION.call(abi.encode(destination, uint256(0))); + assertTrue(ok); + assertEq(data.length, 0); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-contract", + "SymbolicInvalidJump", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 5 tests for test/SymbolicInvalidJump.t.sol:SymbolicInvalidJump +[PASS] checkInvalidJumpRevertsFrame(uint256) ([METRICS]) +[PASS] checkSymbolicInvalidJumpiBranches(uint256) ([METRICS]) +[PASS] checkTakenInvalidJumpiRevertsFrame(uint256) ([METRICS]) +[PASS] checkUntakenInvalidJumpiFallsThrough(uint256) ([METRICS]) +[PASS] checkUntakenJumpiIgnoresSymbolicDestination(uint256) ([METRICS]) +Suite result: ok. 5 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_bounded_fixed_point_round_trip(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_bounded_fixed_point_round_trip because z3 is not available" + ); + return; + } + prj.add_test( + "FixedPointRoundTrip.t.sol", + r#" +interface Vm { + function assume(bool condition) external pure; + function prank(address sender) external; + function setArbitraryStorage(address target, bool overwrite) external; + function store(address target, bytes32 slot, bytes32 value) external; +} + +contract OptInTarget { + uint256 public cpt; + uint256 public rebasingCredits; + uint256 public nonRebasingSupply; + mapping(address => uint256) public credits; + mapping(address => uint256) public fixedCpt; + mapping(address => uint8) public state; + mapping(address => address) public yieldFrom; + + function balanceOf(address account) public view returns (uint256) { + uint8 accountState = state[account]; + require(accountState <= 4); + if (accountState == 3) return credits[account]; + uint256 rate = fixedCpt[account]; + uint256 balance = credits[account] * 1e18 / (rate == 0 ? cpt : rate); + if (accountState == 4) return balance - credits[yieldFrom[account]]; + return balance; + } + + function toInt(uint256 value) internal pure returns (int256) { + require(value <= uint256(type(int256).max)); + return int256(value); + } + + function toUint(int256 value) internal pure returns (uint256) { + require(value >= 0); + return uint256(value); + } + + function optOut() external { + require(fixedCpt[msg.sender] == 0); + require(state[msg.sender] == 0 || state[msg.sender] == 2); + uint256 oldCredits = credits[msg.sender]; + uint256 balance = balanceOf(msg.sender); + credits[msg.sender] = balance; + fixedCpt[msg.sender] = 1e18; + state[msg.sender] = 1; + int256 creditDiff = -toInt(oldCredits); + int256 supplyDiff = toInt(balance); + if (creditDiff != 0) rebasingCredits = toUint(toInt(rebasingCredits) + creditDiff); + if (supplyDiff != 0) nonRebasingSupply = toUint(toInt(nonRebasingSupply) + supplyDiff); + } + + function optIn() external { + uint256 balance = balanceOf(msg.sender); + require(fixedCpt[msg.sender] > 0 || credits[msg.sender] == 0); + require(state[msg.sender] == 0 || state[msg.sender] == 1); + uint256 newCredits = (balance * cpt + 1e18 - 1) / 1e18; + credits[msg.sender] = newCredits; + fixedCpt[msg.sender] = 0; + state[msg.sender] = 2; + int256 creditDiff = toInt(newCredits); + int256 supplyDiff = -toInt(balance); + if (creditDiff != 0) rebasingCredits = toUint(toInt(rebasingCredits) + creditDiff); + if (supplyDiff != 0) nonRebasingSupply = toUint(toInt(nonRebasingSupply) + supplyDiff); + } +} + +contract FixedPointRoundTripTest { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + OptInTarget target; + + function setUp() public { + target = new OptInTarget(); + vm.setArbitraryStorage(address(target), true); + } + + function checkFullWidthRoundTrip(uint256 balance, uint256 cpt) external pure { + require(cpt >= 1e18); + uint256 credits = (balance * cpt + 1e18 - 1) / 1e18; + assert(credits * 1e18 / cpt == balance); + } + + function checkFullWidthOptIn(address account) external { + vm.assume(target.cpt() >= 1e18); + uint256 fixedCpt = target.fixedCpt(account); + vm.assume(fixedCpt == 0 || fixedCpt == 1e18); + uint256 balance = target.balanceOf(account); + vm.prank(account); + target.optIn(); + assert(target.balanceOf(account) == balance); + assert(target.fixedCpt(account) == 0); + assert(target.state(account) == 2); + } + + function checkFullWidthOptOut(address account) external { + vm.assume(target.cpt() >= 1e18); + uint256 balance = target.balanceOf(account); + vm.prank(account); + target.optOut(); + assert(target.balanceOf(account) == balance); + assert(target.fixedCpt(account) == 1e18); + assert(target.state(account) == 1); + } + + function testOptOutConcreteWitnessAtCreditLimit() external { + address account = address(0xB0B); + uint256 credits = type(uint256).max / 1e18; + uint256 rate = 1e18 + 1; + uint256 balance = credits * 1e18 / rate; + vm.store(address(target), bytes32(uint256(0)), bytes32(rate)); + vm.store(address(target), bytes32(uint256(1)), bytes32(credits)); + vm.store(address(target), bytes32(uint256(2)), bytes32(uint256(0))); + vm.store(address(target), keccak256(abi.encode(account, uint256(3))), bytes32(credits)); + vm.store(address(target), keccak256(abi.encode(account, uint256(4))), bytes32(uint256(0))); + vm.store(address(target), keccak256(abi.encode(account, uint256(5))), bytes32(uint256(2))); + this.checkFullWidthOptOut(account); + assert(target.credits(account) == balance); + assert(target.rebasingCredits() == 0); + assert(target.nonRebasingSupply() == balance); + } + + function testOptInConcreteWitnessAboveUint128() external { + address account = address(0xB0B); + uint256 balance = uint256(type(uint128).max) + 1; + vm.store(address(target), bytes32(uint256(0)), bytes32(uint256(1e18))); + vm.store(address(target), bytes32(uint256(1)), bytes32(uint256(0))); + vm.store(address(target), bytes32(uint256(2)), bytes32(balance)); + vm.store(address(target), keccak256(abi.encode(account, uint256(3))), bytes32(balance)); + vm.store(address(target), keccak256(abi.encode(account, uint256(4))), bytes32(uint256(1e18))); + vm.store(address(target), keccak256(abi.encode(account, uint256(5))), bytes32(uint256(1))); + this.checkFullWidthOptIn(account); + assert(target.credits(account) == balance); + assert(target.rebasingCredits() == balance); + assert(target.nonRebasingSupply() == 0); + } + + function checkUncheckedRounding(uint256 cpt) external pure { + require(cpt >= 1e18); + unchecked { + uint256 credits = (cpt + 1e18 - 1) / 1e18; + assert(credits * 1e18 / cpt == 1); + } + } + + function testOptInConcreteWitness() external { + address account = address(0xB0B); + vm.store(address(target), bytes32(uint256(0)), bytes32(uint256(1e18 + 1))); + vm.store(address(target), bytes32(uint256(1)), bytes32(uint256(100))); + vm.store(address(target), bytes32(uint256(2)), bytes32(uint256(1))); + vm.store(address(target), keccak256(abi.encode(account, uint256(3))), bytes32(uint256(1))); + vm.store(address(target), keccak256(abi.encode(account, uint256(4))), bytes32(uint256(1e18))); + vm.store(address(target), keccak256(abi.encode(account, uint256(5))), bytes32(uint256(1))); + this.checkFullWidthOptIn(account); + assert(target.credits(account) == 2); + assert(target.rebasingCredits() == 102); + assert(target.nonRebasingSupply() == 0); + } + + function checkLowRate(uint128 balance) external pure { + require(balance > 0 && balance < 100); + uint256 credits = (uint256(balance) * 1 + 2 - 1) / 2; + assert(credits * 2 / 1 == balance); + } + + function checkUncheckedConstantProduct(uint256 value) external pure { + unchecked { + assert(value * 1e18 / 1e18 == value); + } + } + + function checkWrapping(uint256 balance) external pure { + require(balance >= 1 << 255); + unchecked { + uint256 credits = (balance * 2 + 2 - 1) / 2; + assert(credits * 2 / 2 == balance); + } + } +} +"#, + ); + for (test, signature) in [ + ("checkFullWidthRoundTrip", "checkFullWidthRoundTrip(uint256,uint256)"), + ("checkFullWidthOptIn", "checkFullWidthOptIn(address)"), + ("checkFullWidthOptOut", "checkFullWidthOptOut(address)"), + ] { + let output = cmd + .forge_fuse() + .args([ + "test", + "--symbolic", + "--json", + "--optimize", + "--symbolic-timeout", + "30", + "--match-test", + test, + ]) + .assert_success() + .get_output() + .stdout + .clone(); + let result = json_test_result(&output, signature); + assert_eq!(result["symbolic"]["status"], "pass"); + } + cmd.forge_fuse() + .args(["test", "--optimize", "--match-test", "testOpt(In|Out)ConcreteWitness"]) + .assert_success(); + + for (test, signature) in [ + ("checkLowRate", "checkLowRate(uint128)"), + ("checkWrapping", "checkWrapping(uint256)"), + ("checkUncheckedRounding", "checkUncheckedRounding(uint256)"), + ("checkUncheckedConstantProduct", "checkUncheckedConstantProduct(uint256)"), + ] { + let output = cmd + .forge_fuse() + .args([ + "test", + "--symbolic", + "--json", + "--optimize", + "--symbolic-timeout", + "30", + "--match-test", + test, + ]) + .assert_failure() + .get_output() + .stdout + .clone(); + let result = json_test_result(&output, signature); + assert_eq!(result["symbolic"]["status"], "fail_counterexample"); + } +} + +#[forgetest_init] +fn symbolic_independently_bounded_fixed_point_round_trip(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_independently_bounded_fixed_point_round_trip because z3 is not available" + ); + return; + } + prj.add_test( + "FixedPointRoundTrip.t.sol", + r#" +contract FixedPointRoundTripTest { + function checkRoundTrip(uint128 balance, uint256 rate) external pure { + require(balance > 0 && balance < type(uint128).max); + require(rate >= 1e18 && rate <= 1e27); + unchecked { + uint256 rounded = (uint256(balance) * rate + 1e18 - 1) / 1e18; + assert(rounded * 1e18 / rate == balance); + } + } + +} +"#, + ); + + cmd.args(["test", "--symbolic", "--match-test", "checkRoundTrip"]).assert_success(); +} + +#[forgetest_init] +fn symbolic_rounded_product_relations(prj: _, cmd: _) { + if !z3_available() { + let _ = + sh_eprintln!("skipping symbolic_rounded_product_relations because z3 is not available"); + return; + } + prj.add_test( + "RoundedProduct.t.sol", + r#" +contract RoundedProductTest { + function checkFloorError(uint256 value) external pure { + uint256 rounded = value / 37 * 37; + assert(rounded <= value); + assert(value - rounded < 37); + } + + function checkDividendRelation(uint256 value) external pure { + require(value <= type(uint256).max - 36); + uint256 dividend = value + 36; + uint256 rounded = dividend / 37 * 37; + assert(rounded <= dividend); + assert(dividend - rounded < 37); + } + + function checkWrappingDividendRelation(uint256 value) external pure { + unchecked { + uint256 dividend = value + 36; + uint256 rounded = dividend / 37 * 37; + assert(rounded <= dividend); + assert(dividend - rounded < 37); + } + } + + function checkCeilingError(uint256 value) external pure { + uint256 rounded = (value + 36) / 37 * 37; + assert(rounded >= value); + assert(rounded - value < 37); + } + + function checkCeilingRoundTrip(uint128 value, uint128 rate) external pure { + require(rate >= 37); + uint256 rounded = (uint256(value) * rate + 36) / 37 * 37; + assert(rounded / rate == value); + } + + function checkCeilingIsNotExact(uint256 value) external pure { + require(value < 100); + uint256 rounded = (value + 36) / 37 * 37; + assert(rounded == value); + } + + function checkWrappingCeiling(uint256 value) external pure { + unchecked { + uint256 rounded = (value + 36) / 37 * 37; + assert(rounded >= value); + } + } + + function checkReversedFloorError(uint256 value) external pure { + unchecked { + uint256 rounded = value / 37 * 37; + assert(rounded - value < 37); + } + } +} +"#, + ); + for optimized in [false, true] { + prj.update_config(|config| config.optimizer = Some(optimized)); + cmd.forge_fuse().args([ + "test", + "--symbolic", + "--symbolic-timeout", + "30", + "--match-test", + "check(FloorError|DividendRelation|WrappingDividendRelation|CeilingError|CeilingRoundTrip)", + ]) + .assert_success(); + let output = cmd + .forge_fuse() + .args([ + "test", + "--symbolic", + "--json", + "--symbolic-timeout", + "30", + "--match-test", + "check(CeilingIsNotExact|WrappingCeiling|ReversedFloorError)", + ]) + .assert_failure() + .get_output() + .stdout + .clone(); + for test in ["checkCeilingIsNotExact", "checkWrappingCeiling", "checkReversedFloorError"] { + let signature = format!("{test}(uint256)"); + let result = json_test_result(&output, &signature); + assert_eq!( + result["symbolic"]["status"], "fail_counterexample", + "{test}, optimized={optimized}" + ); + } + } +} + +// Each property is falsifiable only on a wrapping, signed, or unbounded path, so a normalizer +// rewrite that silently assumed the missing bound would turn the replay-confirmed counterexample +// into a false PASS. +#[forgetest_init] +fn symbolic_normalizer_keeps_boundary_counterexamples(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_normalizer_keeps_boundary_counterexamples because z3 is not available" + ); + return; + } + prj.add_test( + "NormalizerBoundaries.t.sol", + r#" +contract NormalizerBoundariesTest { + function checkMaskOutsideBoundedBranch(uint256 value) external pure { + if (value < 1 << 160) { + assert(value & type(uint160).max == value); + } + assert(value & type(uint160).max == value); + } + + function checkScaledQuotientOutsideBoundedBranch(uint256 numerator, uint256 threshold) + external + pure + { + if (threshold <= type(uint128).max) { + assert(numerator / 1e18 <= threshold || numerator >= (threshold + 1) * 1e18); + } + if (numerator / 1e18 <= threshold) { + unchecked { + assert(numerator <= threshold * 1e18 + (1e18 - 1)); + } + } + } + + function checkSymbolicDivisorCeilingWraps(uint256 value, uint256 divisor) external pure { + require(divisor == 37); + unchecked { + uint256 rounded = (value + 36) / divisor * divisor; + if (rounded <= 100) { + assert(value <= rounded); + } + } + } + + function checkSignedCeilingOrder(uint256 value) external pure { + unchecked { + uint256 rounded = (value + 31) / 32 * 32; + assert(int256(rounded) >= int256(value)); + } + } + + function checkWrappedRoundedConversion(uint256 balance, uint256 rate) external pure { + require(rate == 2); + require(balance != 0); + unchecked { + require(balance * rate == 0); + assert((balance * rate + 1) / 2 * 2 / rate == balance); + } + } + + function checkWrappedScaledDivision(uint256 value) external pure { + require(value != 0); + unchecked { + require(value * 2 == 0); + assert(value * 2 / 2 == value); + } + } + + function checkWrappedRoundUpDivision(uint256 value) external pure { + require(value != 0); + unchecked { + require(value * 2 + 1 <= 1); + assert((value * 2 + 1) / 2 == value); + } + } + + function checkRoundTripWithoutRateLowerBound(uint128 balance, uint256 rate) external pure { + require(rate != 0); + require(rate <= 1e27); + uint256 rounded = (uint256(balance) * rate + 1e18 - 1) / 1e18; + assert(rounded * 1e18 / rate == balance); + } + + function checkNonnegativeSignedAdditionOverflows(int256 x, int256 y) external pure { + require(x >= 0 && y >= 0); + unchecked { + assert(x + y >= x); + } + } +} +"#, + ); + let signatures = [ + "checkMaskOutsideBoundedBranch(uint256)", + "checkScaledQuotientOutsideBoundedBranch(uint256,uint256)", + "checkSymbolicDivisorCeilingWraps(uint256,uint256)", + "checkSignedCeilingOrder(uint256)", + "checkWrappedRoundedConversion(uint256,uint256)", + "checkWrappedScaledDivision(uint256)", + "checkWrappedRoundUpDivision(uint256)", + "checkRoundTripWithoutRateLowerBound(uint128,uint256)", + "checkNonnegativeSignedAdditionOverflows(int256,int256)", + ]; + for optimized in [false, true] { + prj.update_config(|config| config.optimizer = Some(optimized)); + let output = cmd + .forge_fuse() + .args(["test", "--symbolic", "--json", "--symbolic-timeout", "30"]) + .args(["--match-contract", "NormalizerBoundariesTest"]) + .assert_failure() + .get_output() + .stdout + .clone(); + let statuses = signatures.map(|signature| { + (signature, json_test_result(&output, signature)["symbolic"]["status"].clone()) + }); + assert!( + statuses.iter().all(|(_, status)| status == "fail_counterexample"), + "optimized={optimized}: {statuses:?}" + ); + } +} diff --git a/crates/forge/tests/cli/test_cmd/symbolic_calls.rs b/crates/forge/tests/cli/test_cmd/symbolic_calls.rs new file mode 100644 index 0000000000000..cf78a4b45a5c4 --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/symbolic_calls.rs @@ -0,0 +1,1528 @@ +use super::symbolic_helpers::{assert_symbolic, assert_symbolic_witness, z3_available}; +use crate::skip_unless_z3; +use foundry_common::sh_eprintln; +use foundry_test_utils::{forgetest_init, snapbox::IntoData, str, util::OutputExt}; + +#[forgetest_init] +fn symbolic_call_contains_invalid_child_halt(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_call_contains_invalid_child_halt because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicInvalidChildCall.t.sol", + r#" +contract InvalidChild { + fallback() external { + assembly ("memory-safe") { + invalid() + } + } +} + +contract SymbolicInvalidChildCall { + uint256 marker; + + function checkInvalidChildCall() public { + InvalidChild child = new InvalidChild(); + marker = 17; + (bool success, bytes memory output) = address(child).call(""); + assert(!success); + assert(output.length == 0); + assert(marker == 17); + } +} +"#, + ); + + cmd.args(["test", "--symbolic", "--match-test", "checkInvalidChildCall"]).assert_success(); +} + +#[forgetest_init] +fn symbolic_assume_no_revert_does_not_prune_invalid_child_halt(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_assume_no_revert_does_not_prune_invalid_child_halt because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicAssumeNoRevertInvalidChild.t.sol", + r#" +import "forge-std/Test.sol"; + +contract InvalidAssumeNoRevertChild { + fallback() external { + assembly ("memory-safe") { + invalid() + } + } +} + +contract SymbolicAssumeNoRevertInvalidChild is Test { + function checkAssumeNoRevertInvalidChild() public { + InvalidAssumeNoRevertChild child = new InvalidAssumeNoRevertChild(); + vm.assumeNoRevert(); + (bool success,) = address(child).call(""); + assertTrue(success); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkAssumeNoRevertInvalidChild", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicAssumeNoRevertInvalidChild.t.sol:SymbolicAssumeNoRevertInvalidChild +[FAIL: assertion failed; counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkAssumeNoRevertInvalidChild() ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +// CALLDATALOAD and CALLDATACOPY with symbolic offsets, destination and size are modeled. +#[forgetest_init] +fn symbolic_calldata_ops_accept_symbolic_operands(prj: _, cmd: _) { + skip_unless_z3!("symbolic_calldata_ops_accept_symbolic_operands"); + + prj.add_test( + "SymbolicCalldataLoad.t.sol", + r#" +contract SymbolicCalldataLoad { + function checkSymbolicCalldataLoad(uint16 offset, uint256 marker) public pure { + uint256 loaded; + assembly { + loaded := calldataload(offset) + } + + if (offset == 36) { + assert(loaded == marker); + } + if (offset >= msg.data.length) { + assert(loaded == 0); + } + } +} +"#, + ); + + prj.add_test( + "SymbolicCalldataCopy.t.sol", + r#" +contract SymbolicCalldataCopy { + function checkSymbolicCalldataCopy(uint16 offset, uint256 marker) public pure { + uint256 copied; + assembly { + calldatacopy(0, offset, 32) + copied := mload(0) + } + + if (offset == 36) { + assert(copied == marker); + } + if (offset >= msg.data.length) { + assert(copied == 0); + } + } +} +"#, + ); + + prj.add_test( + "SymbolicCalldataCopyDest.t.sol", + r#" +contract SymbolicCalldataCopyDest { + function checkSymbolicCalldataCopyDest(uint16 dest, uint256 marker) public pure { + uint256 copied; + assembly { + calldatacopy(dest, 36, 32) + copied := mload(0x80) + } + + if (dest == 0x80) { + assert(copied == marker); + } + } +} +"#, + ); + + prj.add_test( + "SymbolicCalldataCopySize.t.sol", + r#" +contract SymbolicCalldataCopySize { + function checkSymbolicCalldataCopySize(uint8 n) public pure { + uint256 size = uint256(n & 3); + bytes memory copied = hex"aaaaaaaa"; + bytes4 selector = bytes4(keccak256("checkSymbolicCalldataCopySize(uint8)")); + + assembly { + calldatacopy(add(copied, 0x20), 0, size) + } + + if (size == 0) assert(copied[0] == bytes1(0xaa)); + if (size > 0) assert(copied[0] == selector[0]); + if (size <= 1) assert(copied[1] == bytes1(0xaa)); + if (size > 1) assert(copied[1] == selector[1]); + if (size <= 2) assert(copied[2] == bytes1(0xaa)); + if (size > 2) assert(copied[2] == selector[2]); + assert(copied[3] == bytes1(0xaa)); + } +} +"#, + ); + + prj.add_test( + "SymbolicCalldataCopyDestAndSize.t.sol", + r#" +contract SymbolicCalldataCopyDestAndSize { + function checkCalldataCopyDestAndSize(uint8 rawDest, uint8 rawSize, bytes32 marker) public { + uint256 dest = 0x80 + uint256(rawDest); + uint256 size = uint256(rawSize); + require(size <= 32); + + bytes32 copied; + assembly { + calldatacopy(dest, 68, size) + copied := mload(0xa0) + } + + if (dest == 0xa0 && size == 32) { + assert(copied == marker); + } + } +} +"#, + ); + + let stdout = assert_symbolic(cmd.args([ + "test", + "--symbolic", + "--match-test", + "^(checkSymbolicCalldataLoad|checkSymbolicCalldataCopy|checkSymbolicCalldataCopyDest|checkSymbolicCalldataCopySize|checkCalldataCopyDestAndSize)\\(", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicCalldataCopySize.t.sol:SymbolicCalldataCopySize +[PASS] checkSymbolicCalldataCopySize(uint8) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicCalldataLoad.t.sol:SymbolicCalldataLoad +[PASS] checkSymbolicCalldataLoad(uint16,uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicCalldataCopyDest.t.sol:SymbolicCalldataCopyDest +[PASS] checkSymbolicCalldataCopyDest(uint16,uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicCalldataCopy.t.sol:SymbolicCalldataCopy +[PASS] checkSymbolicCalldataCopy(uint16,uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicCalldataCopyDestAndSize.t.sol:SymbolicCalldataCopyDestAndSize +[PASS] checkCalldataCopyDestAndSize(uint8,uint8,bytes32) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]].unordered()) + .get_output() + .stdout_lossy(); + for reason in [ + "symbolic CALLDATACOPY dest", + "symbolic CALLDATACOPY offset", + "symbolic CALLDATACOPY size", + "symbolic CALLDATALOAD offset", + ] { + assert!(!stdout.contains(reason), "{stdout}"); + } +} + +// CALL with a symbolic input offset and bounded symbolic input and output sizes is modeled. +#[forgetest_init] +fn symbolic_call_accepts_symbolic_memory_operands(prj: _, cmd: _) { + skip_unless_z3!("symbolic_call_accepts_symbolic_memory_operands"); + + prj.add_test( + "SymbolicCallInputOffset.t.sol", + r#" +contract SymbolicCallInputOffsetHelper { + function echo(uint256 value) external pure returns (uint256) { + return value; + } +} + +contract SymbolicCallInputOffset { + SymbolicCallInputOffsetHelper helper; + + function setUp() public { + helper = new SymbolicCallInputOffsetHelper(); + } + + function checkSymbolicCallInputOffset(uint16 offset, uint256 marker) public view { + bytes4 selector = SymbolicCallInputOffsetHelper.echo.selector; + bool ok; + uint256 out; + address target = address(helper); + assembly { + mstore(0x80, selector) + mstore(0x84, marker) + ok := staticcall(gas(), target, offset, 36, 0, 32) + out := mload(0) + } + + if (offset == 0x80) { + assert(ok); + assert(out == marker); + } + } +} +"#, + ); + + prj.add_test( + "SymbolicCallOutputSize.t.sol", + r#" +contract SymbolicCallOutputSizeHelper { + function marker() external pure returns (uint256) { + return 0x1234; + } +} + +contract SymbolicCallOutputSize { + SymbolicCallOutputSizeHelper helper; + + function setUp() public { + helper = new SymbolicCallOutputSizeHelper(); + } + + function checkSymbolicCallOutputSize(uint8 rawSize) public view { + bytes4 selector = SymbolicCallOutputSizeHelper.marker.selector; + uint256 size = uint256(rawSize & 32); + bool ok; + uint256 out; + address target = address(helper); + assembly { + mstore(0x80, selector) + ok := staticcall(gas(), target, 0x80, 4, 0xa0, size) + out := mload(0xa0) + } + + assert(ok); + if (size == 32) { + assert(out == 0x1234); + } + } +} +"#, + ); + + prj.add_test( + "SymbolicCallInputSize.t.sol", + r#" +contract SymbolicCallInputSizeHelper { + fallback() external { + assembly { + mstore(0, calldatasize()) + return(0, 32) + } + } +} + +contract SymbolicCallInputSize { + SymbolicCallInputSizeHelper helper; + + function setUp() public { + helper = new SymbolicCallInputSizeHelper(); + } + + function checkSymbolicCallInputSize(uint8 rawSize, uint256 marker) public view { + uint256 size = uint256(rawSize & 32); + bool ok; + uint256 out; + address target = address(helper); + assembly { + mstore(0x80, marker) + ok := staticcall(gas(), target, 0x80, size, 0xa0, 32) + out := mload(0xa0) + } + + assert(ok); + assert(out == size); + } +} +"#, + ); + + let stdout = assert_symbolic(cmd.args([ + "test", + "--symbolic", + "--match-test", + "^(checkSymbolicCallInputOffset|checkSymbolicCallOutputSize|checkSymbolicCallInputSize)\\(", + ])) + .success() + .stdout_eq( + str![[r#" +... +Ran 1 test for test/SymbolicCallInputSize.t.sol:SymbolicCallInputSize +[PASS] checkSymbolicCallInputSize(uint8,uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicCallOutputSize.t.sol:SymbolicCallOutputSize +[PASS] checkSymbolicCallOutputSize(uint8) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicCallInputOffset.t.sol:SymbolicCallInputOffset +[PASS] checkSymbolicCallInputOffset(uint16,uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]] + .unordered(), + ) + .get_output() + .stdout_lossy(); + for reason in + ["symbolic CALL input offset", "symbolic CALL input size", "symbolic CALL output size"] + { + assert!(!stdout.contains(reason), "{stdout}"); + } +} + +#[forgetest_init] +fn symbolic_executes_typed_external_call(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_executes_typed_external_call because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicExternalCall.t.sol", + r#" +contract Helper { + function inc(uint256 x) external returns (uint256) { + return x + 1; + } +} + +contract SymbolicExternalCall { + Helper helper; + + function setUp() public { + helper = new Helper(); + } + + function checkExternal(uint256 x) public { + assert(helper.inc(x) != 43); + } +} +"#, + ); + + let stdout = + assert_symbolic_witness(cmd.args(["test", "--symbolic", "--match-test", "checkExternal"])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicExternalCall.t.sol:SymbolicExternalCall +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkExternal(uint256) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("unsupported symbolic execution feature: external CALL"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_executes_low_level_external_call(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_executes_low_level_external_call because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicLowLevelCall.t.sol", + r#" +contract Helper { + function inc(uint256 x) external returns (uint256) { + return x + 1; + } +} + +contract SymbolicLowLevelCall { + Helper helper; + + function setUp() public { + helper = new Helper(); + } + + function checkLowLevel(uint256 x) public { + (bool ok, bytes memory ret) = + address(helper).call(abi.encodeWithSelector(Helper.inc.selector, x)); + require(ok); + uint256 value = abi.decode(ret, (uint256)); + assert(value != 7); + } +} +"#, + ); + + let stdout = + assert_symbolic_witness(cmd.args(["test", "--symbolic", "--match-test", "checkLowLevel"])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicLowLevelCall.t.sol:SymbolicLowLevelCall +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkLowLevel(uint256) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("unsupported symbolic execution feature: external CALL"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_external_call_with_symbolic_selector_finds_backdoor(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_external_call_with_symbolic_selector_finds_backdoor because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicSelectorBackdoor.t.sol", + r#" +contract SelectorTarget { + function innocent(uint256) external pure returns (bool) { + return false; + } + + function backdoor(uint256 x) external pure returns (bool) { + return x == 7; + } +} + +contract SymbolicSelectorBackdoor { + SelectorTarget target; + + function setUp() public { + target = new SelectorTarget(); + } + + function checkNoBackdoor(bytes4 selector, uint256 x) public { + (bool ok, bytes memory ret) = address(target).call( + abi.encodeWithSelector(selector, x) + ); + if (ok && abi.decode(ret, (bool))) { + assert(false); + } + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkNoBackdoor", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicSelectorBackdoor.t.sol:SymbolicSelectorBackdoor +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkNoBackdoor(bytes4,uint256) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic external CALL selector"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_external_call_with_symbolic_target_finds_backdoor(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_external_call_with_symbolic_target_finds_backdoor because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicTargetBackdoor.t.sol", + r#" +import "forge-std/Test.sol"; + +contract GoodTarget { + function check(uint256) external pure returns (bool) { + return true; + } +} + +contract BadTarget { + function check(uint256 x) external pure returns (bool) { + return x != 7; + } +} + +contract SymbolicTargetBackdoor is Test { + GoodTarget good; + BadTarget bad; + + function setUp() public { + good = new GoodTarget(); + bad = new BadTarget(); + } + + /// forge-config: default.symbolic.symbolic_call_targets = true + function checkNoBadTarget(address target, uint256 x) public { + vm.assume(target == address(good) || target == address(bad)); + (bool ok, bytes memory ret) = target.call( + abi.encodeWithSignature("check(uint256)", x) + ); + require(ok); + assert(abi.decode(ret, (bool))); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkNoBadTarget", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicTargetBackdoor.t.sol:SymbolicTargetBackdoor +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkNoBadTarget(address,uint256) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic CALL target outside known contracts"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_call_target_explores_mock_mismatch(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_call_target_explores_mock_mismatch because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicTargetMockMismatch.t.sol", + r#" +import "forge-std/Test.sol"; + +contract RealToken { + function balanceOf(address) external pure returns (uint256) { + return 7; + } +} + +contract FiveToken { + function balanceOf(address) external pure returns (uint256) { + return 5; + } +} + +contract SymbolicTargetMockMismatch is Test { + RealToken real; + FiveToken five; + + function setUp() public { + real = new RealToken(); + five = new FiveToken(); + } + + // The mock only covers `balanceOf(user)`; for `user != this` the real code answers 7. + function checkMockedSymbolicTargetMayMiss(address callee, address user) public { + vm.assume(callee == address(real) || callee == address(five)); + vm.mockCall( + address(real), + abi.encodeWithSelector(RealToken.balanceOf.selector, user), + abi.encode(uint256(5)) + ); + assert(RealToken(callee).balanceOf(address(this)) == 5); + } + + function checkMockedSymbolicTargetAlwaysHits(address callee) public { + vm.assume(callee == address(real) || callee == address(five)); + vm.mockCall( + address(real), + abi.encodeWithSelector(RealToken.balanceOf.selector, address(this)), + abi.encode(uint256(5)) + ); + assert(RealToken(callee).balanceOf(address(this)) == 5); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-contract", + "SymbolicTargetMockMismatch", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 2 tests for test/SymbolicTargetMockMismatch.t.sol:SymbolicTargetMockMismatch +[PASS] checkMockedSymbolicTargetAlwaysHits(address) ([METRICS]) +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkMockedSymbolicTargetMayMiss(address,address) ([METRICS]) +Suite result: FAILED. 1 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +// Call targets: known and symbolic targets, unknown selectors and failing callees. +#[forgetest_init] +fn symbolic_call_targets_and_dispatch(prj: _, cmd: _) { + skip_unless_z3!("symbolic_call_targets_and_dispatch"); + + prj.add_test( + "SymbolicTargetDefaultAuto.t.sol", + r#" +import "forge-std/Test.sol"; + +contract OnlyTarget { + function check(uint256) external pure returns (bool) { + return true; + } +} + +contract SymbolicTargetDefaultAuto is Test { + OnlyTarget onlyTarget; + + function setUp() public { + onlyTarget = new OnlyTarget(); + } + + function checkTarget(address target, uint256 x) public { + vm.assume(target == address(onlyTarget)); + (bool ok, bytes memory ret) = target.call( + abi.encodeWithSignature("check(uint256)", x) + ); + require(ok); + assert(abi.decode(ret, (bool))); + } +} +"#, + ); + + prj.add_test( + "SymbolicDelegateTarget.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SafeDelegateTarget { + function ok(uint256) external pure returns (bool) { + return true; + } +} + +contract OtherDelegateTarget { + function ok(uint256) external pure returns (bool) { + return true; + } +} + +contract SymbolicDelegateTarget is Test { + SafeDelegateTarget safe; + OtherDelegateTarget other; + + function setUp() public { + safe = new SafeDelegateTarget(); + other = new OtherDelegateTarget(); + } + + /// forge-config: default.symbolic.symbolic_call_targets = true + function checkDelegateTarget(address target, uint256 x) public { + vm.assume(target == address(safe) || target == address(other)); + (bool ok, bytes memory ret) = target.delegatecall( + abi.encodeWithSignature("ok(uint256)", x) + ); + require(ok); + assert(abi.decode(ret, (bool))); + } +} +"#, + ); + + prj.add_test( + "SymbolicUnknownSelector.t.sol", + r#" +contract OneSelectorTarget { + function ping() external pure returns (uint256) { + return 1; + } +} + +contract SymbolicUnknownSelector { + OneSelectorTarget target; + + function setUp() public { + target = new OneSelectorTarget(); + } + + function checkUnknownSelector(bytes4 selector) public { + (bool ok,) = address(target).call(abi.encodeWithSelector(selector)); + if (selector == OneSelectorTarget.ping.selector) { + assert(ok); + } else { + assert(!ok); + } + } +} +"#, + ); + + prj.add_test( + "SymbolicExternalRequire.t.sol", + r#" +contract Helper { + function rejectFortyTwo(uint256 x) external pure returns (bool) { + require(x != 42, "hit"); + return true; + } +} + +contract SymbolicExternalRequire { + Helper helper; + + function setUp() public { + helper = new Helper(); + } + + function checkRequireCall(uint256 x) public { + (bool ok,) = address(helper).call( + abi.encodeWithSelector(Helper.rejectFortyTwo.selector, x) + ); + if (x == 42) { + assert(!ok); + } else { + assert(ok); + } + } +} +"#, + ); + + let stdout = assert_symbolic(cmd.args([ + "test", + "--symbolic", + "--match-test", + "^(checkTarget|checkDelegateTarget|checkUnknownSelector|checkRequireCall)\\(", + ])) + .success() + .stdout_eq( + str![[r#" +... +Ran 1 test for test/SymbolicTargetDefaultAuto.t.sol:SymbolicTargetDefaultAuto +[PASS] checkTarget(address,uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicExternalRequire.t.sol:SymbolicExternalRequire +[PASS] checkRequireCall(uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicDelegateTarget.t.sol:SymbolicDelegateTarget +[PASS] checkDelegateTarget(address,uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicUnknownSelector.t.sol:SymbolicUnknownSelector +[PASS] checkUnknownSelector(bytes4) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]] + .unordered(), + ) + .get_output() + .stdout_lossy(); + for reason in [ + "symbolic CALL target", + "symbolic external CALL selector", + "unsupported symbolic execution feature: external CALL", + ] { + assert!(!stdout.contains(reason), "{stdout}"); + } +} + +#[forgetest_init] +fn symbolic_external_call_with_unbounded_symbolic_target_requires_config(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_external_call_with_unbounded_symbolic_target_requires_config because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicTargetDefaultOff.t.sol", + r#" +contract SymbolicTargetDefaultOff { + function checkTarget(address target) public { + (bool ok,) = target.call(""); + require(ok); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args(["test", "--symbolic", "--match-test", "checkTarget"])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicTargetDefaultOff.t.sol:SymbolicTargetDefaultOff +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: symbolic CALL target] checkTarget(address) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_external_call_with_empty_unknown_target_is_modeled(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_external_call_with_empty_unknown_target_is_modeled because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicUnboundedTarget.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicUnboundedTarget is Test { + /// forge-config: default.symbolic.symbolic_call_targets = true + function checkUnbounded(address target) public { + if (uint160(target) <= 9 || target == address(this)) { + return; + } + bool ok; + assembly { + ok := call(gas(), target, 0, 0, 0, 0, 0) + } + require(ok); + } + + /// forge-config: default.symbolic.symbolic_call_targets = true + function checkUnboundedInsufficientValue(address target) public { + vm.assume(uint160(target) > 9 && target != address(this)); + vm.deal(address(this), 0); + bool ok; + assembly { + ok := call(gas(), target, 1, 0, 0, 0, 0) + } + assert(!ok); + } +} +"#, + ); + + let stdout = + assert_symbolic_witness(cmd.args(["test", "--symbolic", "--match-test", "checkUnbounded"])) + .success() + .stdout_eq(str![[r#" +... +Ran 2 tests for test/SymbolicUnboundedTarget.t.sol:SymbolicUnboundedTarget +[PASS] checkUnbounded(address) ([METRICS]) +[PASS] checkUnboundedInsufficientValue(address) ([METRICS]) +Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic CALL target outside known contracts"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_svm_create_bytes4_can_drive_selector_dispatch(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_svm_create_bytes4_can_drive_selector_dispatch because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicSvmBytes4Selector.t.sol", + r#" +interface Svm { + function createBytes4(string calldata name) external returns (bytes4); +} + +contract OneSelectorTarget { + function ping() external pure returns (uint256) { + return 1; + } +} + +contract SymbolicSvmBytes4Selector { + address constant SVM_ADDRESS = address(0xF3993A62377BCd56AE39D773740A5390411E8BC9); + OneSelectorTarget target; + + function setUp() public { + target = new OneSelectorTarget(); + } + + function checkSvmBytes4Selector() public { + bytes4 selector = Svm(SVM_ADDRESS).createBytes4("selector"); + (bool ok,) = address(target).call(abi.encodeWithSelector(selector)); + if (selector == OneSelectorTarget.ping.selector) { + assert(ok); + } else { + assert(!ok); + } + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkSvmBytes4Selector", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicSvmBytes4Selector.t.sol:SymbolicSvmBytes4Selector +[PASS] checkSvmBytes4Selector() ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic external CALL selector"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_svm_create_calldata_generates_bounded_dispatch(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_svm_create_calldata_generates_bounded_dispatch because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicSvmCreateCalldata.t.sol", + r#" +interface Svm { + function createCalldata(string calldata name) external returns (bytes memory); +} + +contract OneSelectorTarget { + function ping() external pure returns (uint256) { + return 1; + } +} + +contract SymbolicSvmCreateCalldata { + address constant SVM_ADDRESS = address(0xF3993A62377BCd56AE39D773740A5390411E8BC9); + OneSelectorTarget target; + + function setUp() public { + target = new OneSelectorTarget(); + } + + /// forge-config: default.symbolic.max_calldata_bytes = 4 + function checkSvmCreateCalldata() public { + bytes memory data = Svm(SVM_ADDRESS).createCalldata("data"); + assert(data.length <= 4); + (bool ok,) = address(target).call(data); + + bytes4 selector; + assembly { + selector := mload(add(data, 0x20)) + } + if (selector == OneSelectorTarget.ping.selector) { + assert(ok); + } else { + assert(!ok); + } + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkSvmCreateCalldata", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicSvmCreateCalldata.t.sol:SymbolicSvmCreateCalldata +[PASS] checkSvmCreateCalldata() ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic external CALL selector"), "{stdout}"); +} + +// STATICCALL and DELEGATECALL context rules. +#[forgetest_init] +fn symbolic_call_context_semantics(prj: _, cmd: _) { + skip_unless_z3!("symbolic_call_context_semantics"); + + prj.add_test( + "SymbolicStaticCall.t.sol", + r#" +contract Writer { + uint256 value; + + function set(uint256 x) external { + value = x; + } +} + +contract SymbolicStaticCall { + Writer writer; + + function setUp() public { + writer = new Writer(); + } + + function checkStatic(uint256 x) public view { + (bool ok,) = address(writer).staticcall( + abi.encodeWithSelector(Writer.set.selector, x) + ); + assert(!ok); + } +} +"#, + ); + + prj.add_test( + "SymbolicStaticCallValue.t.sol", + r#" +import "forge-std/Test.sol"; + +contract StaticValueSink { + receive() external payable {} +} + +contract StaticValueHelper { + function callWithValue(address target, uint256 amount) external returns (bool ok) { + assembly { + ok := call(gas(), target, amount, 0, 0, 0, 0) + } + } +} + +contract SymbolicStaticCallValue is Test { + StaticValueSink sink; + StaticValueHelper helper; + + function setUp() public { + sink = new StaticValueSink(); + helper = new StaticValueHelper(); + } + + function checkStaticCallValue(uint256 amount) public { + vm.assume(amount <= 1); + (bool ok,) = address(helper).staticcall( + abi.encodeCall(StaticValueHelper.callWithValue, (address(sink), amount)) + ); + assertEq(ok, amount == 0); + } +} +"#, + ); + + prj.add_test( + "SymbolicDelegateCall.t.sol", + r#" +contract DelegateTarget { + uint256 public value; + + function set(uint256 x) external { + value = x; + } +} + +contract SymbolicDelegateCall { + uint256 public value; + DelegateTarget target; + + function setUp() public { + target = new DelegateTarget(); + } + + function checkDelegate(uint256 x) public { + (bool ok,) = address(target).delegatecall( + abi.encodeWithSelector(DelegateTarget.set.selector, x) + ); + require(ok); + assert(value == x); + } +} +"#, + ); + + assert_symbolic(cmd.args([ + "test", + "--symbolic", + "--match-test", + "^(checkStatic|checkStaticCallValue|checkDelegate)\\(", + ])) + .success() + .stdout_eq( + str![[r#" +... +Ran 1 test for test/SymbolicDelegateCall.t.sol:SymbolicDelegateCall +[PASS] checkDelegate(uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicStaticCall.t.sol:SymbolicStaticCall +[PASS] checkStatic(uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicStaticCallValue.t.sol:SymbolicStaticCallValue +[PASS] checkStaticCallValue(uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]] + .unordered(), + ); +} + +// CALL with concrete and insufficient symbolic value. +#[forgetest_init] +fn symbolic_call_value_transfers(prj: _, cmd: _) { + skip_unless_z3!("symbolic_call_value_transfers"); + + prj.add_test( + "SymbolicValueCall.t.sol", + r#" +import "forge-std/Test.sol"; + +contract Sink { + receive() external payable {} +} + +contract SymbolicValueCall is Test { + Sink sink; + + function setUp() public { + sink = new Sink(); + } + + function checkValueTransfer() public { + vm.deal(address(this), 1); + (bool ok,) = address(sink).call{value: 1}(""); + assert(ok); + assert(address(this).balance == 0); + assert(address(sink).balance == 1); + + (bool second,) = address(sink).call{value: 2}(""); + assert(!second); + assert(address(sink).balance == 1); + } +} +"#, + ); + + prj.add_test( + "SymbolicInsufficientValueCall.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicValueSink { + receive() external payable {} +} + +contract SymbolicInsufficientValueCall is Test { + SymbolicValueSink sink; + + function setUp() public { + sink = new SymbolicValueSink(); + } + + function checkSymbolicInsufficientValue(uint256 amount) public { + vm.assume(amount <= 2); + vm.deal(address(this), 1); + + (bool ok,) = address(sink).call{value: amount}(""); + + assert(ok == (amount <= 1)); + assertEq(address(sink).balance, ok ? amount : 0); + assertEq(address(this).balance, ok ? 1 - amount : 1); + } +} +"#, + ); + + let stdout = assert_symbolic(cmd.args([ + "test", + "--symbolic", + "--match-test", + "^(checkValueTransfer|checkSymbolicInsufficientValue)\\(", + ])) + .success() + .stdout_eq( + str![[r#" +... +Ran 1 test for test/SymbolicValueCall.t.sol:SymbolicValueCall +[PASS] checkValueTransfer() ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicInsufficientValueCall.t.sol:SymbolicInsufficientValueCall +[PASS] checkSymbolicInsufficientValue(uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]] + .unordered(), + ) + .get_output() + .stdout_lossy(); + assert!(!stdout.contains("symbolic external CALL value"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_call_accepts_symbolic_value(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_call_accepts_symbolic_value because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicValueCall.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicValueReceiver { + uint256 public last; + + function receiveValue() external payable returns (uint256) { + last = msg.value; + return msg.value; + } +} + +contract SymbolicValueCall is Test { + SymbolicValueReceiver receiver; + + function setUp() public { + receiver = new SymbolicValueReceiver(); + } + + function checkSymbolicValueTransfer(uint256 amount) public { + vm.assume(amount <= 5); + vm.deal(address(this), 5); + + (bool ok, bytes memory ret) = address(receiver).call{value: amount}( + abi.encodeWithSelector(SymbolicValueReceiver.receiveValue.selector) + ); + + assert(ok); + assertEq(abi.decode(ret, (uint256)), amount); + assertEq(receiver.last(), amount); + assertEq(address(receiver).balance, amount); + assertEq(address(this).balance, 5 - amount); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkSymbolicValueTransfer", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicValueCall.t.sol:SymbolicValueCall +[PASS] checkSymbolicValueTransfer(uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic external CALL value"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_callcode_accepts_symbolic_value(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_callcode_accepts_symbolic_value because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicCallcodeValue.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicCallcodeValueTarget { + function echoValue() external payable returns (uint256) { + return msg.value; + } +} + +contract SymbolicCallcodeValue is Test { + SymbolicCallcodeValueTarget target; + + function setUp() public { + target = new SymbolicCallcodeValueTarget(); + } + + function checkSymbolicCallcodeValue(uint256 amount) public { + vm.assume(amount <= 8); + vm.deal(address(this), 7); + + bytes memory input = abi.encodeWithSelector(SymbolicCallcodeValueTarget.echoValue.selector); + uint256 echoed; + bool ok; + address callTarget = address(target); + assembly { + ok := callcode(gas(), callTarget, amount, add(input, 0x20), mload(input), 0x80, 0x20) + echoed := mload(0x80) + } + + assertEq(ok, amount <= 7); + if (ok) { + assertEq(echoed, amount); + } + } + + function checkPrankedCallcodeValue(uint256 amount) public { + vm.assume(amount <= 1); + address caller = address(0xBEEF); + vm.deal(address(this), 0); + vm.deal(caller, 1); + vm.prank(caller); + + bytes memory input = abi.encodeWithSelector(SymbolicCallcodeValueTarget.echoValue.selector); + uint256 echoed; + bool ok; + address callTarget = address(target); + assembly { + ok := callcode(gas(), callTarget, amount, add(input, 0x20), mload(input), 0x80, 0x20) + echoed := mload(0x80) + } + + assert(ok); + assertEq(echoed, amount); + assertEq(caller.balance, 1 - amount); + assertEq(address(this).balance, amount); + } + + function checkPrankedCallcodeOverflow() public { + address caller = address(0xBEEF); + vm.deal(address(this), type(uint256).max); + vm.deal(caller, 1); + vm.prank(caller); + + bool ok; + address callTarget = address(target); + assembly { + ok := callcode(gas(), callTarget, 1, 0, 0, 0, 0) + } + + assert(!ok); + assertEq(caller.balance, 1); + assertEq(address(this).balance, type(uint256).max); + } + + function checkMockedPrankedCallcodeValue() public { + address caller = address(0xBEEF); + bytes memory input = abi.encodeWithSelector(SymbolicCallcodeValueTarget.echoValue.selector); + vm.mockCall(address(target), 1, input, abi.encode(uint256(99))); + vm.deal(address(this), 0); + vm.deal(caller, 1); + vm.prank(caller); + + uint256 echoed; + bool ok; + address callTarget = address(target); + assembly { + ok := callcode(gas(), callTarget, 1, add(input, 0x20), mload(input), 0x80, 0x20) + echoed := mload(0x80) + } + + assert(ok); + assertEq(echoed, 99); + assertEq(caller.balance, 0); + assertEq(address(this).balance, 1); + + vm.deal(caller, 0); + vm.prank(caller); + assembly { + ok := callcode(gas(), callTarget, 1, add(input, 0x20), mload(input), 0, 0) + } + assert(!ok); + assertEq(caller.balance, 0); + assertEq(address(this).balance, 1); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "check.*Callcode", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 4 tests for test/SymbolicCallcodeValue.t.sol:SymbolicCallcodeValue +[PASS] checkMockedPrankedCallcodeValue() ([METRICS]) +[PASS] checkPrankedCallcodeOverflow() ([METRICS]) +[PASS] checkPrankedCallcodeValue(uint256) ([METRICS]) +[PASS] checkSymbolicCallcodeValue(uint256) ([METRICS]) +Suite result: ok. 4 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic CALLCODE value"), "{stdout}"); +} diff --git a/crates/forge/tests/cli/test_cmd/symbolic_cheatcodes.rs b/crates/forge/tests/cli/test_cmd/symbolic_cheatcodes.rs new file mode 100644 index 0000000000000..08b77ba11502e --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/symbolic_cheatcodes.rs @@ -0,0 +1,6074 @@ +use super::symbolic_helpers::{ + assert_symbolic, assert_symbolic_witness, json_test_result, z3_available, +}; +use foundry_common::sh_eprintln; +use foundry_test_utils::{forgetest_init, snapbox::IntoData, str, util::OutputExt}; + +use crate::skip_unless_z3; + +#[forgetest_init] +fn symbolic_cheatcodes_accept_symbolic_address_targets(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_cheatcodes_accept_symbolic_address_targets because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicAddressCheatcodes.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicAddressCheatcodes is Test { + function checkSymbolicDealStoreLoadAndNonce(address who, bytes32 value) public { + vm.assume(who != address(0)); + vm.assume(who != address(this)); + vm.assume(who != address(vm)); + + vm.deal(who, 11); + assertEq(who.balance, 11); + + bytes32 slot = bytes32(uint256(1)); + vm.store(who, slot, value); + assertEq(vm.load(who, slot), value); + + assertEq(vm.getNonce(who), 0); + vm.setNonceUnsafe(who, 5); + assertEq(vm.getNonce(who), 5); + vm.resetNonce(who); + assertEq(vm.getNonce(who), 0); + } + + function checkSymbolicEtch(address who) public { + vm.assume(who != address(0)); + vm.assume(who != address(this)); + vm.assume(who != address(vm)); + + vm.etch(who, hex"00"); + assertEq(who.code.length, 1); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-contract", + "SymbolicAddressCheatcodes", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 2 tests for test/SymbolicAddressCheatcodes.t.sol:SymbolicAddressCheatcodes +[PASS] checkSymbolicDealStoreLoadAndNonce(address,bytes32) ([METRICS]) +[PASS] checkSymbolicEtch(address) ([METRICS]) +Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic vm.deal target"), "{stdout}"); + assert!(!stdout.contains("symbolic vm.store target"), "{stdout}"); + assert!(!stdout.contains("symbolic EXTCODESIZE target"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_prank_accepts_symbolic_sender(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_prank_accepts_symbolic_sender because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicPrankSender.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SenderTarget { + function sender() external view returns (address) { + return msg.sender; + } + + function origin() external view returns (address) { + return tx.origin; + } + + function context() external view returns (address, address) { + return (msg.sender, tx.origin); + } +} + +contract SymbolicPrankSender is Test { + SenderTarget target; + + function setUp() public { + target = new SenderTarget(); + } + + function checkSymbolicPrank(address who) public { + vm.assume(who != address(0)); + vm.assume(who != address(this)); + vm.assume(who != address(vm)); + + vm.prank(who); + assertEq(target.sender(), who); + } + + function checkSymbolicStartPrank(address who) public { + vm.assume(who != address(0)); + vm.assume(who != address(this)); + vm.assume(who != address(vm)); + + vm.startPrank(who); + assertEq(target.sender(), who); + assertEq(target.sender(), who); + vm.stopPrank(); + } + + function checkSymbolicPrankOrigin(address who, address origin) public { + vm.assume(who != address(0)); + vm.assume(who != address(this)); + vm.assume(who != address(vm)); + + vm.prank(who, origin); + (address actualSender, address actualOrigin) = target.context(); + assertEq(actualSender, who); + assertEq(actualOrigin, origin); + } + + function checkSymbolicStartPrankOrigin(address who, address origin) public { + vm.assume(who != address(0)); + vm.assume(who != address(this)); + vm.assume(who != address(vm)); + + vm.startPrank(who, origin); + assertEq(target.sender(), who); + assertEq(target.origin(), origin); + assertEq(target.sender(), who); + assertEq(target.origin(), origin); + vm.stopPrank(); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-contract", + "SymbolicPrankSender", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 4 tests for test/SymbolicPrankSender.t.sol:SymbolicPrankSender +[PASS] checkSymbolicPrank(address) ([METRICS]) +[PASS] checkSymbolicPrankOrigin(address,address) ([METRICS]) +[PASS] checkSymbolicStartPrank(address) ([METRICS]) +[PASS] checkSymbolicStartPrankOrigin(address,address) ([METRICS]) +Suite result: ok. 4 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic vm.prank"), "{stdout}"); + assert!(!stdout.contains("symbolic vm.startPrank"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_balance_accepts_symbolic_target(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_balance_accepts_symbolic_target because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicBalance.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicBalance is Test { + function checkSymbolicBalance(address who) public { + address funded = address(0xBEEF); + vm.deal(funded, 123); + + uint256 expected = who == funded ? 123 : 0; + assertEq(who.balance, expected); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-contract", + "SymbolicBalance", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicBalance.t.sol:SymbolicBalance +[PASS] checkSymbolicBalance(address) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic BALANCE target"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_extcodesize_accepts_symbolic_target(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_extcodesize_accepts_symbolic_target because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicExtcodeSize.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicExtcodeSize is Test { + function checkSymbolicCodeLength(address who) public { + address coded = address(0xC0DE); + vm.etch(coded, hex"60006000"); + + uint256 expected = who == coded ? 4 : 0; + assertEq(who.code.length, expected); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-contract", + "SymbolicExtcodeSize", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicExtcodeSize.t.sol:SymbolicExtcodeSize +[PASS] checkSymbolicCodeLength(address) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic EXTCODESIZE target"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_extcodehash_accepts_symbolic_target(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_extcodehash_accepts_symbolic_target because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicExtcodeHash.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicExtcodeHash is Test { + function checkSymbolicCodeHash(address who) public { + address coded = address(0xC0DE); + vm.etch(coded, hex"60006000"); + + bytes32 expected = who == coded ? keccak256(hex"60006000") : bytes32(0); + assertEq(who.codehash, expected); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-contract", + "SymbolicExtcodeHash", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicExtcodeHash.t.sol:SymbolicExtcodeHash +[PASS] checkSymbolicCodeHash(address) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic EXTCODEHASH target"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_extcodecopy_accepts_symbolic_target(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_extcodecopy_accepts_symbolic_target because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicExtcodeCopy.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicExtcodeCopy is Test { + function checkSymbolicExtcodeCopy(address who) public { + address coded = address(0xC0DE); + vm.etch(coded, hex"60016002"); + + bytes32 copied; + assembly { + extcodecopy(who, 0x80, 0, 4) + copied := mload(0x80) + } + + bytes32 expected = who == coded ? bytes32(hex"6001600200000000000000000000000000000000000000000000000000000000") : bytes32(0); + assertEq(copied, expected); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-contract", + "SymbolicExtcodeCopy", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicExtcodeCopy.t.sol:SymbolicExtcodeCopy +[PASS] checkSymbolicExtcodeCopy(address) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic EXTCODECOPY target"), "{stdout}"); +} + +// Cheatcodes that shape external calls: prank, expectRevert, mockFunction and assumeNoRevert. +#[forgetest_init] +fn symbolic_call_shaping_cheatcodes(prj: _, cmd: _) { + skip_unless_z3!("symbolic_call_shaping_cheatcodes"); + + prj.add_test( + "SymbolicPrank.t.sol", + r#" +import "forge-std/Test.sol"; + +contract CallerProbe { + function callers() external view returns (address, address) { + return (msg.sender, tx.origin); + } +} + +contract SymbolicPrank is Test { + CallerProbe probe; + + function setUp() public { + probe = new CallerProbe(); + } + + function checkPrank(uint256) public { + address alice = address(0xA11CE); + address bob = address(0xB0B); + + vm.prank(alice); + (address sender,) = probe.callers(); + assertEq(sender, alice); + + (sender,) = probe.callers(); + assertEq(sender, address(this)); + + vm.startPrank(alice, bob); + address origin; + (sender, origin) = probe.callers(); + assertEq(sender, alice); + assertEq(origin, bob); + + vm.stopPrank(); + (sender,) = probe.callers(); + assertEq(sender, address(this)); + } +} +"#, + ); + + prj.add_test( + "SymbolicExpectRevert.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicExpectedReverter { + error Custom(uint256 value); + + function failWithCustom(uint256 value) external pure { + revert Custom(value); + } + + function failPanic() external pure { + assert(false); + } +} + +contract SymbolicExpectRevert is Test { + SymbolicExpectedReverter helper; + + function setUp() public { + helper = new SymbolicExpectedReverter(); + } + + function checkExpectRevert(uint256) public { + vm.expectRevert(SymbolicExpectedReverter.Custom.selector); + helper.failWithCustom(7); + + vm.expectRevert(abi.encodeWithSelector(SymbolicExpectedReverter.Custom.selector, uint256(9))); + helper.failWithCustom(9); + + vm.expectRevert(bytes4(0x4e487b71)); + helper.failPanic(); + } +} +"#, + ); + + prj.add_test( + "SymbolicMockFunction.t.sol", + r#" +import "forge-std/Test.sol"; + +interface IFunctionMock { + function value(uint256 input) external returns (uint256); + function who(uint256 input) external returns (address); +} + +contract FunctionCallee { + function value(uint256 input) external pure returns (uint256) { + return input + 1; + } + + function who(uint256) external view returns (address) { + return address(this); + } +} + +contract FunctionTarget { + function value(uint256 input) external pure returns (uint256) { + return input ^ 0x55; + } + + function who(uint256) external view returns (address) { + return address(this); + } +} + +contract SymbolicMockFunction is Test { + FunctionCallee callee; + FunctionTarget target; + + function setUp() public { + callee = new FunctionCallee(); + target = new FunctionTarget(); + } + + function checkMockFunction(uint256 input) public { + vm.mockFunction( + address(callee), + address(target), + abi.encodePacked(IFunctionMock.value.selector) + ); + assertEq(IFunctionMock(address(callee)).value(input), input ^ 0x55); + + vm.mockFunction( + address(callee), + address(target), + abi.encodePacked(IFunctionMock.who.selector) + ); + assertEq(IFunctionMock(address(callee)).who(input), address(callee)); + } +} +"#, + ); + + prj.add_test( + "SymbolicAssumeNoRevert.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicAssumeNoRevertTarget { + function maybeRevert(uint256 x) external pure { + require(x != 7, "seven"); + } +} + +contract SymbolicAssumeNoRevert is Test { + SymbolicAssumeNoRevertTarget target; + + function setUp() public { + target = new SymbolicAssumeNoRevertTarget(); + } + + function checkAssumeNoRevertPrunes(uint256 x) public { + vm.assumeNoRevert(); + (bool ok,) = address(target).call(abi.encodeWithSelector(target.maybeRevert.selector, x)); + assertTrue(ok); + } +} +"#, + ); + + let stdout = assert_symbolic(cmd.args([ + "test", + "--symbolic", + "--match-test", + "^(checkPrank|checkExpectRevert|checkMockFunction|checkAssumeNoRevertPrunes)\\(", + ])) + .success() + .stdout_eq( + str![[r#" +... +Ran 1 test for test/SymbolicMockFunction.t.sol:SymbolicMockFunction +[PASS] checkMockFunction(uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicPrank.t.sol:SymbolicPrank +[PASS] checkPrank(uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicExpectRevert.t.sol:SymbolicExpectRevert +[PASS] checkExpectRevert(uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicAssumeNoRevert.t.sol:SymbolicAssumeNoRevert +[PASS] checkAssumeNoRevertPrunes(uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]] + .unordered(), + ) + .get_output() + .stdout_lossy(); + for reason in ["symbolic Foundry cheatcode", "symbolic vm.assumeNoRevert"] { + assert!(!stdout.contains(reason), "{stdout}"); + } +} + +#[forgetest_init] +fn symbolic_vm_assert_cheatcodes_find_counterexample(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_vm_assert_cheatcodes_find_counterexample because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicVmAssert.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicVmAssert is Test { + function checkVmAssert(uint256 x) public { + vm.assertNotEq(x, uint256(42)); + } +} +"#, + ); + + let stdout = assert_symbolic(cmd.args(["test", "--symbolic", "--match-test", "checkVmAssert"])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicVmAssert.t.sol:SymbolicVmAssert +[FAIL: assertion failed: 42 == 42; counterexample: [SENDER] [SENDER] calldata=0xdaaeb166000000000000000000000000000000000000000000000000000000000000002a args=[42]] checkVmAssert(uint256) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("counterexample did not replay"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_vm_recorded_logs_round_trip(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_vm_recorded_logs_round_trip because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicRecordedLogs.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicLogEmitter { + event Helper(uint256 indexed topic, bytes data); + + function ok(uint256 topic, bytes memory data) external { + emit Helper(topic, data); + } + + function fail(uint256 topic, bytes memory data) external { + emit Helper(topic, data); + revert(); + } +} + +contract SymbolicRevertingLogConstructor { + event ConstructorLog(); + + constructor() { + emit ConstructorLog(); + revert(); + } +} + +contract SymbolicRecordedLogs is Test { + event Local(uint256 indexed topic, bytes data); + + SymbolicLogEmitter emitter; + + function setUp() public { + emitter = new SymbolicLogEmitter(); + } + + /// forge-config: default.symbolic.array_lengths = [2] + function checkRecordedLogs(uint256 topic, bytes memory data) public { + vm.recordLogs(); + + emit Local(topic, data); + emitter.ok(topic + 1, data); + try emitter.fail(topic + 2, data) {} catch {} + + Vm.Log[] memory logs = vm.getRecordedLogs(); + assertEq(logs.length, 3); + + assertEq(logs[0].topics.length, 2); + assertEq(logs[0].topics[0], keccak256("Local(uint256,bytes)")); + assertEq(logs[0].topics[1], bytes32(topic)); + assertEq(logs[0].emitter, address(this)); + + bytes memory localData = abi.decode(logs[0].data, (bytes)); + assert(keccak256(localData) == keccak256(data)); + + assertEq(logs[1].topics.length, 2); + assertEq(logs[1].topics[0], keccak256("Helper(uint256,bytes)")); + assertEq(logs[1].topics[1], bytes32(topic + 1)); + assertEq(logs[1].emitter, address(emitter)); + + assertEq(logs[2].topics.length, 2); + assertEq(logs[2].topics[0], keccak256("Helper(uint256,bytes)")); + assertEq(logs[2].topics[1], bytes32(topic + 2)); + assertEq(logs[2].emitter, address(emitter)); + + Vm.Log[] memory drained = vm.getRecordedLogs(); + assertEq(drained.length, 0); + } + + /// forge-config: default.symbolic.array_lengths = [2] + function checkRecordedLogsJson(uint256 topic, bytes memory data) public { + vm.recordLogs(); + emit Local(topic, data); + + string memory json = vm.getRecordedLogsJson(); + assert(bytes(json).length > 0); + + Vm.Log[] memory drained = vm.getRecordedLogs(); + assertEq(drained.length, 0); + } + + function checkRecordedRevertedCreate() public { + vm.recordLogs(); + try new SymbolicRevertingLogConstructor() {} catch {} + + Vm.Log[] memory logs = vm.getRecordedLogs(); + assertEq(logs.length, 1); + assertEq(logs[0].topics[0], keccak256("ConstructorLog()")); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-contract", + "SymbolicRecordedLogs", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 3 tests for test/SymbolicRecordedLogs.t.sol:SymbolicRecordedLogs +[PASS] checkRecordedLogs(uint256,bytes) ([METRICS]) +[PASS] checkRecordedLogsJson(uint256,bytes) ([METRICS]) +[PASS] checkRecordedRevertedCreate() ([METRICS]) +Suite result: ok. 3 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic Foundry cheatcode"), "{stdout}"); + assert!(!stdout.contains("symbolic vm.getRecordedLogsJson"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_vm_env_crypto_and_console_helpers(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_vm_env_crypto_and_console_helpers because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicEnvCryptoConsole.t.sol", + r#" +import "forge-std/Test.sol"; +import "forge-std/console2.sol"; + +contract SymbolicEnvCryptoConsole is Test { + function checkEnvCryptoConsole(uint256 x) public { + assertTrue(vm.envExists("FOUNDRY_SYMBOLIC_ENV_PRESENT")); + assertEq(vm.envUint("FOUNDRY_SYMBOLIC_ENV_UINT"), 42); + assertEq(vm.envOr("FOUNDRY_SYMBOLIC_ENV_MISSING", uint256(7)), 7); + assertEq(vm.envString("FOUNDRY_SYMBOLIC_ENV_STRING"), "hello"); + + uint256[] memory values = vm.envUint("FOUNDRY_SYMBOLIC_ENV_UINTS", ","); + assertEq(values.length, 3); + assertEq(values[0], 1); + assertEq(values[2], 3); + + string[] memory words = vm.envString("FOUNDRY_SYMBOLIC_ENV_STRINGS", ","); + assertEq(words.length, 2); + assertEq(words[1], "beta"); + + bytes[] memory blobs = vm.envBytes("FOUNDRY_SYMBOLIC_ENV_BYTES_ARRAY", ","); + assertEq(blobs.length, 2); + assertEq(blobs[1], hex"cafe"); + + uint256[] memory defaultValues = new uint256[](2); + defaultValues[0] = 5; + defaultValues[1] = 6; + uint256[] memory missing = vm.envOr("FOUNDRY_SYMBOLIC_ENV_MISSING_ARRAY", ",", defaultValues); + assertEq(missing.length, 2); + assertEq(missing[1], 6); + + address keyAddress = vm.addr(1); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(1, keccak256("foundry-symbolic")); + assertTrue(keyAddress != address(0)); + assertTrue(v == 27 || v == 28); + assertTrue(r != bytes32(0)); + assertTrue(s != bytes32(0)); + + console2.log("symbolic", x); + } + + function checkKeyUtilities() public { + address keyAddress = vm.addr(1); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(1, keccak256("foundry-symbolic")); + assertTrue(keyAddress != address(0)); + assertTrue(v == 27 || v == 28); + assertTrue(r != bytes32(0)); + assertTrue(s != bytes32(0)); + (bytes32 compactR, bytes32 vs) = vm.signCompact(1, keccak256("foundry-symbolic")); + assertEq(compactR, r); + uint8 compactV = uint8(27 + (uint256(vs) >> 255)); + bytes32 compactS = bytes32(uint256(vs) & (type(uint256).max >> 1)); + assertEq(ecrecover(keccak256("foundry-symbolic"), compactV, compactR, compactS), keyAddress); + address remembered = vm.rememberKey(2); + assertEq(remembered, vm.addr(2)); + address[] memory wallets = vm.getWallets(); + assertEq(wallets.length, 1); + assertEq(wallets[0], remembered); + uint256 derived = vm.deriveKey("test test test test test test test test test test test junk", uint32(0)); + assertEq(vm.addr(derived), 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266); + address[] memory derivedWallets = vm.rememberKeys( + "test test test test test test test test test test test junk", + "m/44'/60'/0'/0/", + uint32(3) + ); + assertEq(derivedWallets.length, 3); + assertEq(derivedWallets[0], vm.addr(derived)); + } + + function checkBase64Utilities() public { + assertEq(vm.toBase64(bytes("hello")), "aGVsbG8="); + assertEq(vm.toBase64URL(hex"ffff"), "__8="); + } + + function checkParseToStringUtilities() public { + assertEq(vm.parseBytes("0x1234"), hex"1234"); + assertEq(vm.parseAddress(vm.toString(address(0xBEEF))), address(0xBEEF)); + assertEq(vm.parseUint(vm.toString(uint256(123))), 123); + assertEq(vm.parseInt(vm.toString(int256(-5))), -5); + assertEq(vm.parseBytes32(vm.toString(bytes32(uint256(0x12)))), bytes32(uint256(0x12))); + assertTrue(vm.parseBool(vm.toString(true))); + } + + function checkStringUtilities() public { + assertEq(vm.toLowercase("AbC"), "abc"); + assertEq(vm.toUppercase("AbC"), "ABC"); + assertEq(vm.trim(" foundry "), "foundry"); + assertEq(vm.replace("hello forge", "forge", "symbolic"), "hello symbolic"); + string[] memory parts = vm.split("a,b,c", ","); + assertEq(parts.length, 3); + assertEq(parts[1], "b"); + assertEq(vm.indexOf("foundry", "dry"), 4); + assertTrue(vm.contains("foundry", "ound")); + } +} +"#, + ); + + cmd.env("FOUNDRY_SYMBOLIC_ENV_PRESENT", "1"); + cmd.env("FOUNDRY_SYMBOLIC_ENV_UINT", "42"); + cmd.env("FOUNDRY_SYMBOLIC_ENV_STRING", "hello"); + cmd.env("FOUNDRY_SYMBOLIC_ENV_UINTS", "1,2,3"); + cmd.env("FOUNDRY_SYMBOLIC_ENV_STRINGS", "alpha,beta"); + cmd.env("FOUNDRY_SYMBOLIC_ENV_BYTES_ARRAY", "12,cafe"); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-contract", + "SymbolicEnvCryptoConsole", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 5 tests for test/SymbolicEnvCryptoConsole.t.sol:SymbolicEnvCryptoConsole +[PASS] checkBase64Utilities() ([METRICS]) +[PASS] checkEnvCryptoConsole(uint256) ([METRICS]) +[PASS] checkKeyUtilities() ([METRICS]) +[PASS] checkParseToStringUtilities() ([METRICS]) +[PASS] checkStringUtilities() ([METRICS]) +Suite result: ok. 5 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic Foundry cheatcode"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_vm_ffi_is_config_gated(prj: _, cmd: _) { + if !z3_available() { + let _ = + sh_eprintln!("skipping symbolic_vm_ffi_is_config_gated because z3 is not available"); + return; + } + + prj.add_test( + "SymbolicFfiDisabled.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicFfiDisabled is Test { + function checkFfiDisabled(uint256) public { + string[] memory input = new string[](1); + input[0] = "true"; + vm.ffi(input); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args(["test", "--symbolic", "--match-test", "checkFfiDisabled"])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicFfiDisabled.t.sol:SymbolicFfiDisabled +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: symbolic ffi disabled] checkFfiDisabled(uint256) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_vm_ffi_success_when_enabled(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_vm_ffi_success_when_enabled because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicFfiEnabled.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicFfiEnabled is Test { + function checkFfiEnabled(uint256) public { + string[] memory input = new string[](3); + input[0] = "sh"; + input[1] = "-c"; + input[2] = "printf 0x1234"; + + bytes memory output = vm.ffi(input); + assertEq(output.length, 2); + assertEq(uint8(output[0]), 0x12); + assertEq(uint8(output[1]), 0x34); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--ffi", + "--match-test", + "checkFfiEnabled", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicFfiEnabled.t.sol:SymbolicFfiEnabled +[PASS] checkFfiEnabled(uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic ffi disabled"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_vm_etch_and_get_deployed_code(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_vm_etch_and_get_deployed_code because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicEtch.t.sol", + r#" +import "forge-std/Test.sol"; + +interface IEtchedHelper { + function value() external pure returns (uint256); +} + +contract EtchedHelper { + function value() external pure returns (uint256) { + return 99; + } +} + +contract SymbolicEtch is Test { + function checkEtch(uint256) public { + address target = address(0xBEEF); + bytes memory code = vm.getDeployedCode("SymbolicEtch.t.sol:EtchedHelper"); + vm.etch(target, code); + + assertGt(target.code.length, 0); + assertEq(IEtchedHelper(target).value(), 99); + } + + function checkEtchSymbolicBytes(uint8 value) public { + address target = address(0xCAFE); + bytes memory code = abi.encodePacked(bytes1(0x60), bytes1(value), bytes1(0x00)); + vm.etch(target, code); + + assertEq(target.code.length, 3); + + bytes memory copied = new bytes(3); + assembly { + extcodecopy(target, add(copied, 0x20), 0, 3) + } + + assertEq(copied[0], bytes1(0x60)); + assertEq(copied[1], bytes1(value)); + assertEq(copied[2], bytes1(0x00)); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-contract", + "SymbolicEtch", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 2 tests for test/SymbolicEtch.t.sol:SymbolicEtch +[PASS] checkEtch(uint256) ([METRICS]) +[PASS] checkEtchSymbolicBytes(uint8) ([METRICS]) +Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic vm.etch"), "{stdout}"); + assert!(!stdout.contains("symbolic vm.getCode artifact"), "{stdout}"); + assert!(!stdout.contains("symbolic Foundry cheatcode"), "{stdout}"); +} + +// EXTCODEHASH, CODECOPY and EXTCODECOPY with symbolic offsets and bounded sizes are modeled. +#[forgetest_init] +fn symbolic_code_ops_accept_symbolic_operands(prj: _, cmd: _) { + skip_unless_z3!("symbolic_code_ops_accept_symbolic_operands"); + + prj.add_test( + "SymbolicCodeHash.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicCodeHash is Test { + function checkCodeHash(uint256) public { + address target = address(0xBEEF); + vm.etch(target, hex""); + + bytes32 emptyHash = keccak256(new bytes(0)); + assert(target.code.length == 0); + assert(target.codehash == emptyHash); + assert(address(0xCAFE).codehash == bytes32(0)); + } +} +"#, + ); + + prj.add_test( + "SymbolicExtcodeCopy.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicExtcodeCopy is Test { + function checkExtcodeCopy(uint256) public { + address target = address(0xBEEF); + vm.etch(target, hex"010203"); + + bytes memory copied = new bytes(5); + assembly { + extcodecopy(target, add(copied, 0x20), 1, 5) + } + + assert(uint8(copied[0]) == 2); + assert(uint8(copied[1]) == 3); + assert(uint8(copied[2]) == 0); + assert(uint8(copied[3]) == 0); + assert(uint8(copied[4]) == 0); + } +} +"#, + ); + + prj.add_test( + "SymbolicCodeCopy.t.sol", + r#" +contract SymbolicCodeCopy { + function checkCodeCopy(uint16 offset) public pure { + uint256 copied; + uint256 size; + assembly { + size := codesize() + codecopy(0, offset, 1) + copied := mload(0) + } + + if (offset >= size) { + assert(copied == 0); + } + } +} +"#, + ); + + prj.add_test( + "SymbolicExtcodeCopyOffset.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicExtcodeCopyOffset is Test { + function checkExtcodeCopyOffset(uint16 offset) public { + address target = address(0xBEEF); + vm.etch(target, hex"010203"); + + bytes memory copied = new bytes(1); + assembly { + extcodecopy(target, add(copied, 0x20), offset, 1) + } + + if (offset == 1) { + assert(uint8(copied[0]) == 2); + } + if (offset >= 3) { + assert(uint8(copied[0]) == 0); + } + } +} +"#, + ); + + prj.add_test( + "SymbolicCodeCopySize.t.sol", + r#" +contract SymbolicCodeCopySize { + function checkCodeCopySize(uint8 rawSize) public pure { + uint256 size = uint256(rawSize & 1); + uint256 first; + uint256 copied; + assembly { + codecopy(0x80, 0, 1) + first := byte(0, mload(0x80)) + codecopy(0xa0, 0, size) + copied := byte(0, mload(0xa0)) + } + + if (size == 1) { + assert(copied == first); + } + } +} +"#, + ); + + prj.add_test( + "SymbolicExtcodeCopySize.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicExtcodeCopySize is Test { + function checkExtcodeCopySize(uint8 rawSize) public { + address target = address(0xBEEF); + vm.etch(target, hex"010203"); + uint256 size = uint256(rawSize & 3); + + bytes memory copied = new bytes(3); + assembly { + extcodecopy(target, add(copied, 0x20), 0, size) + } + + if (size == 3) { + assertEq(uint8(copied[0]), 1); + assertEq(uint8(copied[1]), 2); + assertEq(uint8(copied[2]), 3); + } + } +} +"#, + ); + + let stdout = assert_symbolic(cmd.args([ + "test", + "--symbolic", + "--match-test", + "^(checkCodeHash|checkExtcodeCopy|checkCodeCopy|checkExtcodeCopyOffset|checkCodeCopySize|checkExtcodeCopySize)\\(", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicExtcodeCopy.t.sol:SymbolicExtcodeCopy +[PASS] checkExtcodeCopy(uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicExtcodeCopySize.t.sol:SymbolicExtcodeCopySize +[PASS] checkExtcodeCopySize(uint8) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicCodeHash.t.sol:SymbolicCodeHash +[PASS] checkCodeHash(uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicCodeCopySize.t.sol:SymbolicCodeCopySize +[PASS] checkCodeCopySize(uint8) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicExtcodeCopyOffset.t.sol:SymbolicExtcodeCopyOffset +[PASS] checkExtcodeCopyOffset(uint16) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicCodeCopy.t.sol:SymbolicCodeCopy +[PASS] checkCodeCopy(uint16) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]].unordered()) + .get_output() + .stdout_lossy(); + for reason in [ + "symbolic CODECOPY offset", + "symbolic CODECOPY size", + "symbolic EXTCODECOPY offset", + "symbolic EXTCODECOPY size", + ] { + assert!(!stdout.contains(reason), "{stdout}"); + } +} + +#[forgetest_init] +fn symbolic_selfdestruct_updates_account_overlay(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_selfdestruct_updates_account_overlay because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicSelfdestruct.t.sol", + r#" +import "forge-std/Test.sol"; + +/// forge-config: default.evm_version = "shanghai" + +contract Killable { + receive() external payable {} + + function die(address payable beneficiary) external { + selfdestruct(beneficiary); + } +} + +contract SymbolicSelfdestruct is Test { + Killable killable; + address payable beneficiary = payable(address(0xB0B)); + + function setUp() public { + killable = new Killable(); + } + + function checkSelfdestruct(uint256) public { + vm.deal(address(killable), 7); + + killable.die(beneficiary); + + assert(address(killable).balance == 0); + assert(beneficiary.balance == 7); + assert(address(killable).code.length == 0); + assert(address(killable).codehash == bytes32(0)); + assert(vm.getNonce(address(killable)) == 1); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--evm-version", + "shanghai", + "--match-test", + "checkSelfdestruct", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicSelfdestruct.t.sol:SymbolicSelfdestruct +[PASS] checkSelfdestruct(uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("SELFDESTRUCT/EIP-6780 not modeled"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_selfdestruct_cancun_symbolic_beneficiary_reports_incomplete(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_selfdestruct_cancun_symbolic_beneficiary_reports_incomplete because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicSelfdestructBeneficiary.t.sol", + r#" +import "forge-std/Test.sol"; + +/// forge-config: default.evm_version = "cancun" + +contract Killable { + receive() external payable {} + + function die(address payable beneficiary) external { + selfdestruct(beneficiary); + } +} + +contract SymbolicSelfdestructBeneficiary is Test { + Killable killable; + + function setUp() public { + killable = new Killable(); + } + + function checkSelfdestructBeneficiary(address payable beneficiary) public { + vm.assume(beneficiary != address(killable)); + vm.deal(address(killable), 7); + + killable.die(beneficiary); + + assert(address(killable).balance == 0); + assert(beneficiary.balance == 7); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkSelfdestructBeneficiary", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicSelfdestructBeneficiary.t.sol:SymbolicSelfdestructBeneficiary +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: symbolic SELFDESTRUCT beneficiary] checkSelfdestructBeneficiary(address) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("SELFDESTRUCT/EIP-6780 not modeled"), "{stdout}"); + assert!(!stdout.contains("symbolic BALANCE target"), "{stdout}"); +} + +// SELFDESTRUCT under Cancun keeps existing accounts and deletes same-transaction ones. +#[forgetest_init] +fn symbolic_selfdestruct_cancun_semantics(prj: _, cmd: _) { + skip_unless_z3!("symbolic_selfdestruct_cancun_semantics"); + + prj.add_test( + "SymbolicSelfdestructCancunExisting.t.sol", + r#" +import "forge-std/Test.sol"; + +/// forge-config: default.evm_version = "cancun" + +contract KillableCancun { + uint256 value = 42; + + receive() external payable {} + + function die(address payable beneficiary) external { + selfdestruct(beneficiary); + } + + function get() external view returns (uint256) { + return value; + } +} + +contract SymbolicSelfdestructCancunExisting is Test { + KillableCancun killable; + address payable beneficiary = payable(address(0xB0B)); + + function setUp() public { + killable = new KillableCancun(); + } + + function checkCancunSelfdestructExisting(uint256) public { + vm.deal(address(killable), 7); + + killable.die(beneficiary); + + assertEq(address(killable).balance, 0); + assertEq(beneficiary.balance, 7); + assertGt(address(killable).code.length, 0); + assertEq(killable.get(), 42); + } +} +"#, + ); + + prj.add_test( + "SymbolicSelfdestructCancunSameTx.t.sol", + r#" +import "forge-std/Test.sol"; + +/// forge-config: default.evm_version = "cancun" + +contract KillableCancunSameTx { + receive() external payable {} + + function die(address payable beneficiary) external { + selfdestruct(beneficiary); + } +} + +contract SymbolicSelfdestructCancunSameTx is Test { + address payable beneficiary = payable(address(0xB0B)); + + function checkCancunSelfdestructSameTransaction(uint256) public { + KillableCancunSameTx killable = new KillableCancunSameTx(); + vm.deal(address(killable), 7); + + killable.die(beneficiary); + + assertEq(address(killable).balance, 0); + assertEq(beneficiary.balance, 7); + assertEq(address(killable).code.length, 0); + } +} +"#, + ); + + let stdout = assert_symbolic(cmd.args([ + "test", + "--symbolic", + "--match-test", + "^(checkCancunSelfdestructExisting|checkCancunSelfdestructSameTransaction)\\(", + ])) + .success() + .stdout_eq( + str![[r#" +... +Ran 1 test for test/SymbolicSelfdestructCancunExisting.t.sol:SymbolicSelfdestructCancunExisting +[PASS] checkCancunSelfdestructExisting(uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicSelfdestructCancunSameTx.t.sol:SymbolicSelfdestructCancunSameTx +[PASS] checkCancunSelfdestructSameTransaction(uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]] + .unordered(), + ) + .get_output() + .stdout_lossy(); + assert!(!stdout.contains("SELFDESTRUCT/EIP-6780 not modeled"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_selfdestruct_cancun_wrong_delete_assertion_fails(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_selfdestruct_cancun_wrong_delete_assertion_fails because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicSelfdestructCancunWrongDelete.t.sol", + r#" +import "forge-std/Test.sol"; + +/// forge-config: default.evm_version = "cancun" + +contract KillableCancunWrongDelete { + receive() external payable {} + + function die(address payable beneficiary) external { + selfdestruct(beneficiary); + } +} + +contract SymbolicSelfdestructCancunWrongDelete is Test { + KillableCancunWrongDelete killable; + address payable beneficiary = payable(address(0xB0B)); + + function setUp() public { + killable = new KillableCancunWrongDelete(); + } + + function checkCancunSelfdestructDoesNotDeleteExisting(uint256) public { + killable.die(beneficiary); + + assertEq(address(killable).code.length, 0); + } +} +"#, + ); + + let stdout = cmd + .args([ + "test", + "--symbolic", + "--match-test", + "checkCancunSelfdestructDoesNotDeleteExisting", + ]) + .assert_failure() + .get_output() + .stdout_lossy(); + + assert!(stdout.contains("[FAIL"), "{stdout}"); + assert!(stdout.contains("counterexample"), "{stdout}"); + assert!(stdout.contains("checkCancunSelfdestructDoesNotDeleteExisting"), "{stdout}"); + assert!(!stdout.contains("[PASS] checkCancunSelfdestructDoesNotDeleteExisting"), "{stdout}"); + assert!(!stdout.contains("incomplete symbolic execution"), "{stdout}"); + assert!(!stdout.contains("SELFDESTRUCT/EIP-6780 not modeled"), "{stdout}"); +} + +// BLOCKHASH with symbolic block numbers and `vm.setBlockhash` with concrete and symbolic hashes. +#[forgetest_init] +fn symbolic_blockhash_cheatcodes(prj: _, cmd: _) { + skip_unless_z3!("symbolic_blockhash_cheatcodes"); + + prj.add_test( + "SymbolicBlockhash.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicBlockhash is Test { + function checkSetBlockhash(uint256) public { + bytes32 previousHash = bytes32(uint256(0x1234)); + vm.roll(300); + vm.setBlockhash(299, previousHash); + vm.setBlockhash(300, bytes32(uint256(0xdead))); + vm.setBlockhash(43, bytes32(uint256(0xbeef))); + + assertEq(blockhash(299), previousHash); + assertEq(blockhash(300), bytes32(0)); + assertEq(blockhash(43), bytes32(0)); + } +} +"#, + ); + + prj.add_test( + "SymbolicBlockhashNumber.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicBlockhashNumber is Test { + function checkSymbolicBlockhashNumber(uint256 blockNumber) public { + bytes32 previousHash = bytes32(uint256(0x1234)); + vm.roll(300); + vm.setBlockhash(299, previousHash); + + bytes32 hash = blockhash(blockNumber); + if (hash == previousHash) { + assertEq(blockNumber, 299); + } + } +} +"#, + ); + + prj.add_test( + "SymbolicBlockhashValue.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicBlockhashValue is Test { + function checkSymbolicBlockhashValue(bytes32 blockHash) public { + vm.roll(300); + vm.setBlockhash(299, blockHash); + + assertEq(blockhash(299), blockHash); + } +} +"#, + ); + + let stdout = assert_symbolic(cmd.args([ + "test", + "--symbolic", + "--match-test", + "^(checkSetBlockhash|checkSymbolicBlockhashNumber|checkSymbolicBlockhashValue)\\(", + ])) + .success() + .stdout_eq( + str![[r#" +... +Ran 1 test for test/SymbolicBlockhashValue.t.sol:SymbolicBlockhashValue +[PASS] checkSymbolicBlockhashValue(bytes32) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicBlockhash.t.sol:SymbolicBlockhash +[PASS] checkSetBlockhash(uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicBlockhashNumber.t.sol:SymbolicBlockhashNumber +[PASS] checkSymbolicBlockhashNumber(uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]] + .unordered(), + ) + .get_output() + .stdout_lossy(); + for reason in ["symbolic BLOCKHASH number", "symbolic vm.setBlockhash hash"] { + assert!(!stdout.contains(reason), "{stdout}"); + } +} + +// Block environment cheatcodes and the prepared executor environment. +#[forgetest_init] +fn symbolic_block_environment_cheatcodes(prj: _, cmd: _) { + skip_unless_z3!("symbolic_block_environment_cheatcodes"); + + prj.add_test( + "SymbolicBlockEnvironment.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicBlockEnvironment is Test { + function checkBlockEnvironment(uint256) public { + bytes32 randomness = bytes32(uint256(0xabc)); + + vm.warp(123); + assertEq(vm.getBlockTimestamp(), 123); + assertEq(block.timestamp, 123); + + vm.txGasPrice(7); + assertEq(tx.gasprice, 7); + + vm.prevrandao(randomness); + assertEq(block.prevrandao, uint256(randomness)); + + vm.blobBaseFee(11); + assertEq(block.blobbasefee, 11); + assertEq(vm.getBlobBaseFee(), 11); + + bytes32[] memory hashes = new bytes32[](2); + hashes[0] = bytes32(uint256(0x1111)); + hashes[1] = bytes32(uint256(0x2222)); + vm.blobhashes(hashes); + + assertEq(blobhash(0), hashes[0]); + assertEq(blobhash(1), hashes[1]); + assertEq(blobhash(2), bytes32(0)); + + bytes32[] memory got = vm.getBlobhashes(); + assertEq(got.length, 2); + assertEq(got[0], hashes[0]); + assertEq(got[1], hashes[1]); + } +} +"#, + ); + + prj.add_test( + "SymbolicPreparedEnvironment.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicPreparedEnvironment is Test { + function setUp() public { + vm.chainId(424242); + vm.roll(12345); + vm.warp(67890); + vm.fee(77); + vm.prevrandao(bytes32(uint256(99))); + vm.coinbase(address(0xBEEF)); + vm.txGasPrice(66); + } + + function checkPreparedEnvironment(uint256 x) public { + if (x > 1) return; + + assertEq(block.chainid, 424242); + assertEq(block.number, 12345); + assertEq(block.timestamp, 67890); + assertEq(block.basefee, 77); + assertEq(block.prevrandao, 99); + assertEq(block.coinbase, address(0xBEEF)); + assertEq(tx.gasprice, 66); + } +} +"#, + ); + + assert_symbolic(cmd.args([ + "test", + "--symbolic", + "--match-test", + "^(checkBlockEnvironment|checkPreparedEnvironment)\\(", + ])) + .success() + .stdout_eq( + str![[r#" +... +Ran 1 test for test/SymbolicBlockEnvironment.t.sol:SymbolicBlockEnvironment +[PASS] checkBlockEnvironment(uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicPreparedEnvironment.t.sol:SymbolicPreparedEnvironment +[PASS] checkPreparedEnvironment(uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]] + .unordered(), + ); +} + +#[forgetest_init] +fn symbolic_vm_state_snapshots(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!("skipping symbolic_vm_state_snapshots because z3 is not available"); + return; + } + + prj.add_test( + "SymbolicStateSnapshots.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicStateSnapshots is Test { + uint256 value; + + function checkStateSnapshots(uint256) public { + value = 1; + vm.deal(address(this), 5 ether); + + uint256 snapshotId = vm.snapshotState(); + value = 2; + vm.deal(address(this), 8 ether); + + assertTrue(vm.revertToState(snapshotId)); + assertEq(value, 1); + assertEq(address(this).balance, 5 ether); + + assertTrue(vm.deleteStateSnapshot(snapshotId)); + assertFalse(vm.revertToState(snapshotId)); + + uint256 legacySnapshot = vm.snapshot(); + value = 3; + + assertTrue(vm.revertToAndDelete(legacySnapshot)); + assertEq(value, 1); + assertFalse(vm.revertTo(legacySnapshot)); + + uint256 deletedSnapshot = vm.snapshotState(); + assertTrue(vm.deleteSnapshot(deletedSnapshot)); + assertFalse(vm.revertToState(deletedSnapshot)); + + uint256 clearedSnapshot = vm.snapshotState(); + vm.deleteStateSnapshots(); + assertFalse(vm.revertToState(clearedSnapshot)); + } + + receive() external payable {} +} +"#, + ); + + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkStateSnapshots", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicStateSnapshots.t.sol:SymbolicStateSnapshots +[PASS] checkStateSnapshots(uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +// `vm.randomBytes` with concrete and bounded symbolic lengths. +#[forgetest_init] +fn symbolic_vm_random_bytes_cheatcodes(prj: _, cmd: _) { + skip_unless_z3!("symbolic_vm_random_bytes_cheatcodes"); + + prj.add_test( + "SymbolicRandomBytes.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicRandomBytes is Test { + function checkRandomBytes(uint256) public { + bytes memory data = vm.randomBytes(3); + + assertEq(data.length, 3); + vm.assume(data[0] == bytes1(0x11)); + vm.assume(data[1] == bytes1(0x22)); + vm.assume(data[2] == bytes1(0x33)); + + assertTrue(data[0] == bytes1(0x11)); + assertTrue(data[1] == bytes1(0x22)); + assertTrue(data[2] == bytes1(0x33)); + } +} +"#, + ); + + prj.add_test( + "SymbolicRandomBytesLength.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicRandomBytesLength is Test { + function checkRandomBytesSymbolicLength(uint8 n) public { + uint256 len = uint256(n); + vm.assume(len <= 3); + + bytes memory data = vm.randomBytes(len); + + assertEq(data.length, len); + } +} +"#, + ); + + let stdout = assert_symbolic(cmd.args([ + "test", + "--symbolic", + "--match-test", + "^(checkRandomBytes|checkRandomBytesSymbolicLength)\\(", + ])) + .success() + .stdout_eq( + str![[r#" +... +Ran 1 test for test/SymbolicRandomBytes.t.sol:SymbolicRandomBytes +[PASS] checkRandomBytes(uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicRandomBytesLength.t.sol:SymbolicRandomBytesLength +[PASS] checkRandomBytesSymbolicLength(uint8) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]] + .unordered(), + ) + .get_output() + .stdout_lossy(); + for reason in ["symbolic randomBytes len", "symbolic randomBytes length"] { + assert!(!stdout.contains(reason), "{stdout}"); + } +} + +#[forgetest_init] +fn symbolic_cheatcodes_accept_constrained_scalar_args(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_cheatcodes_accept_constrained_scalar_args because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicConstrainedCheatcodes.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicConstrainedCheatcodes is Test { + function checkConstrainedDeal(address target, uint256 amount) public { + vm.assume(target == address(0xbeef)); + vm.assume(amount == 7); + + vm.deal(target, amount); + + assertEq(address(0xbeef).balance, 7); + } + + function checkSymbolicDealValueFundsCall(uint256 amount) public { + address recipient = address(0xbeef); + + vm.deal(address(this), amount); + assertEq(address(this).balance, amount); + + (bool ok,) = recipient.call{value: amount}(""); + + assertTrue(ok); + assertEq(recipient.balance, amount); + assertEq(address(this).balance, 0); + } + + function checkSymbolicDealInsufficientFunds(uint256 amount) public { + vm.assume(amount < type(uint256).max); + address recipient = address(0xbeef); + + vm.deal(address(this), amount); + + (bool ok,) = recipient.call{value: amount + 1}(""); + + assertFalse(ok); + assertEq(address(this).balance, amount); + assertEq(recipient.balance, 0); + } + + function checkConstrainedRandomBytes(uint16 len) public { + vm.assume(len == 3); + + bytes memory data = vm.randomBytes(len); + + assertEq(data.length, 3); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-contract", + "SymbolicConstrainedCheatcodes", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 4 tests for test/SymbolicConstrainedCheatcodes.t.sol:SymbolicConstrainedCheatcodes +[PASS] checkConstrainedDeal(address,uint256) ([METRICS]) +[PASS] checkConstrainedRandomBytes(uint16) ([METRICS]) +[PASS] checkSymbolicDealInsufficientFunds(uint256) ([METRICS]) +[PASS] checkSymbolicDealValueFundsCall(uint256) ([METRICS]) +Suite result: ok. 4 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic vm.deal target"), "{stdout}"); + assert!(!stdout.contains("symbolic vm.deal value"), "{stdout}"); + assert!(!stdout.contains("symbolic randomBytes len"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_address_inputs_may_alias(prj: _, cmd: _) { + skip_unless_z3!("symbolic_address_inputs_may_alias"); + + prj.add_test( + "SymbolicAddressAlias.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicAddressAlias is Test { + // Fails concretely for any a == b: the second deal overwrites the first. + function checkDealsMayTargetOneAccount(address a, address b) public { + vm.deal(a, 10 ether); + vm.deal(b, 0); + assert(a.balance + b.balance == 10 ether); + } + + function checkDistinctDealsAreIndependent(address a, address b) public { + vm.assume(a != b); + vm.deal(a, 10 ether); + vm.deal(b, 0); + assert(a.balance + b.balance == 10 ether); + } +} +"#, + ); + + assert_symbolic(cmd.args(["test", "--symbolic", "--match-contract", "SymbolicAddressAlias"])) + .failure() + .stdout_eq(str![[r#" +... +Ran 2 tests for test/SymbolicAddressAlias.t.sol:SymbolicAddressAlias +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] calldata=0x5b3b1d1100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 args=[0x0000000000000000000000000000000000000000, 0x0000000000000000000000000000000000000000]] checkDealsMayTargetOneAccount(address,address) ([METRICS]) +[PASS] checkDistinctDealsAreIndependent(address,address) ([METRICS]) +Suite result: FAILED. 1 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_cheatcode_state_survives_reverting_call(prj: _, cmd: _) { + skip_unless_z3!("symbolic_cheatcode_state_survives_reverting_call"); + + prj.add_test( + "SymbolicRevertKeepsCheatcodes.t.sol", + r#" +import "forge-std/Test.sol"; + +contract Token { + function balanceOf(address) external pure returns (uint256) { + return 7; + } +} + +contract SymbolicRevertKeepsCheatcodes is Test { + uint256 constant DEADLINE = 1000; + Token token; + + function setUp() public { + token = new Token(); + } + + function warpThenRevert(uint256 t) external { + vm.warp(t); + revert("boom"); + } + + function mockThenRevert(address user) external { + vm.mockCall( + address(token), + abi.encodeWithSelector(Token.balanceOf.selector, user), + abi.encode(uint256(5)) + ); + revert("boom"); + } + + // Concretely the warp outlives the revert, so any t >= DEADLINE breaks this. + function checkWarpSurvivesRevertingCall(uint256 t) public { + try this.warpThenRevert(t) {} catch {} + assert(block.timestamp < DEADLINE); + } + + // Concretely the mock outlives the revert, so the mocked value is observed. + function checkMockSurvivesRevertingCall(address user) public { + try this.mockThenRevert(user) {} catch {} + assert(token.balanceOf(user) == 7); + } +} +"#, + ); + + let stdout = assert_symbolic(cmd.args([ + "test", + "--symbolic", + "--match-contract", + "SymbolicRevertKeepsCheatcodes", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 2 tests for test/SymbolicRevertKeepsCheatcodes.t.sol:SymbolicRevertKeepsCheatcodes +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] calldata=0x9389e44d0000000000000000000000000000000000000000000000000000000000000000 args=[0x0000000000000000000000000000000000000000]] checkMockSurvivesRevertingCall(address) ([METRICS]) +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] calldata=0xe9b7d25400000000000000000000000000000000000000000000000000000000000003e8 args=[1000]] checkWarpSurvivesRevertingCall(uint256) ([METRICS]) +Suite result: FAILED. 0 passed; 2 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("[PASS]"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_expect_call_follows_function_mock_redirect(prj: _, cmd: _) { + skip_unless_z3!("symbolic_expect_call_follows_function_mock_redirect"); + + prj.add_test( + "SymbolicExpectCallRedirect.t.sol", + r#" +import "forge-std/Test.sol"; + +contract Target { + function ping() external pure returns (uint256) { + return 1; + } +} + +contract Redirect { + function ping() external pure returns (uint256) { + return 2; + } +} + +contract SymbolicExpectCallRedirect is Test { + Target target; + Redirect redirect; + + function setUp() public { + target = new Target(); + redirect = new Redirect(); + } + + // The redirected call runs `redirect`'s code, so an expectation on `target` is never met. + function checkExpectCallOnRedirectedSource() public { + vm.mockFunction(address(target), address(redirect), abi.encodeWithSelector(Target.ping.selector)); + vm.expectCall(address(target), abi.encodeWithSelector(Target.ping.selector)); + target.ping(); + } + + function checkExpectCallOnRedirectTarget() public { + vm.mockFunction(address(target), address(redirect), abi.encodeWithSelector(Target.ping.selector)); + vm.expectCall(address(redirect), abi.encodeWithSelector(Target.ping.selector)); + assertEq(target.ping(), 2); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-contract", + "SymbolicExpectCallRedirect", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 2 tests for test/SymbolicExpectCallRedirect.t.sol:SymbolicExpectCallRedirect +[PASS] checkExpectCallOnRedirectTarget() ([METRICS]) +[FAIL: expected call to 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f with data 0x5c36b186 to be called 1 time, but was called 0 times; counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkExpectCallOnRedirectedSource() ([METRICS]) +Suite result: FAILED. 1 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_cheatcodes_reject_gas_deal_value(prj: _, cmd: _) { + skip_unless_z3!("symbolic_cheatcodes_reject_gas_deal_value"); + + prj.add_test( + "SymbolicDealGasValue.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicDealGasValue is Test { + function checkGasDealValue() public { + vm.deal(address(this), gasleft()); + } + + function checkDerivedGasDealValue() public { + vm.deal(address(this), gasleft() + 1); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args(["test", "--symbolic", "--match-test", "checkGasDealValue"])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicDealGasValue.t.sol:SymbolicDealGasValue +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: GAS/gasleft() not modeled] checkGasDealValue() ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_cheatcodes_reject_derived_gas_deal_value(prj: _, cmd: _) { + skip_unless_z3!("symbolic_cheatcodes_reject_derived_gas_deal_value"); + + prj.add_test( + "SymbolicDerivedDealGasValue.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicDerivedDealGasValue is Test { + function checkDerivedGasDealValue() public { + vm.deal(address(this), gasleft() + 1); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkDerivedGasDealValue", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicDerivedDealGasValue.t.sol:SymbolicDerivedDealGasValue +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: GAS/gasleft() not modeled] checkDerivedGasDealValue() ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_cheatcodes_accept_bounded_symbolic_input_size(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_cheatcodes_accept_bounded_symbolic_input_size because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicCheatcodeInputSize.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicCheatcodeInputSize is Test { + function checkLowLevelAssumeSize(uint256 size, bool condition) public { + vm.assume(size >= 36); + vm.assume(size <= 68); + + bytes memory data = abi.encodeWithSelector(bytes4(keccak256("assume(bool)")), condition); + address cheatcode = address(vm); + bool ok; + assembly { + ok := call(gas(), cheatcode, 0, add(data, 32), size, 0, 0) + } + + assert(ok); + assertTrue(condition); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkLowLevelAssumeSize", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicCheatcodeInputSize.t.sol:SymbolicCheatcodeInputSize +[PASS] checkLowLevelAssumeSize(uint256,bool) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic cheatcode CALL input size"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_svm_creator_breadth(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!("skipping symbolic_svm_creator_breadth because z3 is not available"); + return; + } + + prj.add_test( + "SymbolicSvmCreators.t.sol", + r#" +interface Svm { + function createUint8(string calldata name) external returns (uint8); + function createInt16(string calldata name) external returns (int16); + function createBytes2(string calldata name) external returns (bytes2); + function createBytes(string calldata name) external returns (bytes memory); + function createBytes(uint256 len, string calldata name) external returns (bytes memory); + function createString(string calldata name) external returns (string memory); + function createString(uint256 len, string calldata name) external returns (string memory); +} + +contract SymbolicSvmCreators { + address constant SVM_ADDRESS = address(0xF3993A62377BCd56AE39D773740A5390411E8BC9); + + function checkSvmCreators(uint256) public { + uint8 small = Svm(SVM_ADDRESS).createUint8("small"); + int16 signed = Svm(SVM_ADDRESS).createInt16("signed"); + bytes2 fixedBytes = Svm(SVM_ADDRESS).createBytes2("fixedBytes"); + bytes memory data = Svm(SVM_ADDRESS).createBytes("data"); + bytes memory sizedData = Svm(SVM_ADDRESS).createBytes(5, "sizedData"); + string memory text = Svm(SVM_ADDRESS).createString("text"); + string memory sizedText = Svm(SVM_ADDRESS).createString(3, "sizedText"); + + assert(uint256(small) < 256); + assert(signed == signed); + assert(data.length == 2); + assert(sizedData.length == 5); + assert(bytes(text).length == 2); + assert(bytes(sizedText).length == 3); + assert(fixedBytes == fixedBytes); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkSvmCreators", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicSvmCreators.t.sol:SymbolicSvmCreators +[PASS] checkSvmCreators(uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic Halmos compatibility cheatcode"), "{stdout}"); +} +#[forgetest_init] +fn symbolic_vm_expect_revert_double_registration_is_rejected(prj: _, cmd: _) { + skip_unless_z3!("symbolic_vm_expect_revert_double_registration_is_rejected"); + + prj.add_test( + "DoubleExpectRevert.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicExpectedReverter { + error Custom(uint256 value); + + function failPanic() external pure { + assert(false); + } +} + +contract DoubleExpectRevert is Test { + SymbolicExpectedReverter helper; + + function setUp() public { + helper = new SymbolicExpectedReverter(); + } + + function checkDoubleExpectRevert(uint256) public { + vm.expectRevert(SymbolicExpectedReverter.Custom.selector); + vm.expectRevert(bytes4(0x4e487b71)); + helper.failPanic(); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkDoubleExpectRevert", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/DoubleExpectRevert.t.sol:DoubleExpectRevert +[FAIL: incomplete symbolic execution (RevertAll): all symbolic paths reverted] checkDoubleExpectRevert(uint256) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_vm_expect_revert_missing_is_counterexample(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_vm_expect_revert_missing_is_counterexample because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicExpectRevertMissing.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicExpectedNoop { + function noFail() external pure {} +} + +contract SymbolicExpectRevertMissing is Test { + SymbolicExpectedNoop helper; + + function setUp() public { + helper = new SymbolicExpectedNoop(); + } + + function checkMissingExpectedRevert(uint256) public { + vm.expectRevert(); + helper.noFail(); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkMissingExpectedRevert", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicExpectRevertMissing.t.sol:SymbolicExpectRevertMissing +[FAIL: next call did not revert as expected; counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkMissingExpectedRevert(uint256) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_vm_expect_revert_mismatch_is_counterexample(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_vm_expect_revert_mismatch_is_counterexample because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicExpectRevertMismatch.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicExpectedMismatchReverter { + error Custom(uint256 value); + + function failWithCustom(uint256 value) external pure { + revert Custom(value); + } +} + +contract SymbolicExpectRevertMismatch is Test { + SymbolicExpectedMismatchReverter helper; + + function setUp() public { + helper = new SymbolicExpectedMismatchReverter(); + } + + function checkMismatchedExpectedRevert(uint256) public { + vm.expectRevert(abi.encodeWithSelector(SymbolicExpectedMismatchReverter.Custom.selector, uint256(1))); + helper.failWithCustom(2); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkMismatchedExpectedRevert", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicExpectRevertMismatch.t.sol:SymbolicExpectRevertMismatch +[FAIL: Error != expected error: Custom(2) != Custom(1); counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkMismatchedExpectedRevert(uint256) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_vm_expect_revert_accepts_symbolic_data(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_vm_expect_revert_accepts_symbolic_data because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicExpectRevertSymbolicData.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicExpectedSymbolicReverter { + error Custom(uint256 value); + + function failWithCustom(uint256 value) external pure { + revert Custom(value); + } + + function failWithSelector(bytes4 selector) external pure { + assembly { + mstore(0, selector) + revert(0, 4) + } + } +} + +contract SymbolicExpectRevertSymbolicData is Test { + SymbolicExpectedSymbolicReverter helper; + + function setUp() public { + helper = new SymbolicExpectedSymbolicReverter(); + } + + function checkSymbolicExpectedRevertPayload(uint256 value) public { + vm.expectRevert(abi.encodeWithSelector(SymbolicExpectedSymbolicReverter.Custom.selector, value)); + helper.failWithCustom(value); + } + + function checkSymbolicExpectedRevertSelector(bytes4 selector) public { + vm.expectRevert(selector); + helper.failWithSelector(selector); + } + + function checkSymbolicExpectedReverter(address reverter) public { + vm.assume(reverter == address(helper)); + vm.expectRevert(reverter); + helper.failWithCustom(9); + } + + function checkSymbolicExpectedRevertMismatch(uint256 value) public { + vm.expectRevert(abi.encodeWithSelector(SymbolicExpectedSymbolicReverter.Custom.selector, uint256(7))); + helper.failWithCustom(value); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd + .args([ + "test", + "--symbolic", + "--match-contract", + "SymbolicExpectRevertSymbolicData", + "--match-test", + "checkSymbolicExpectedRevertPayload|checkSymbolicExpectedRevertSelector|checkSymbolicExpectedReverter", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 3 tests for test/SymbolicExpectRevertSymbolicData.t.sol:SymbolicExpectRevertSymbolicData +[PASS] checkSymbolicExpectedRevertPayload(uint256) ([METRICS]) +[PASS] checkSymbolicExpectedRevertSelector(bytes4) ([METRICS]) +[PASS] checkSymbolicExpectedReverter(address) ([METRICS]) +Suite result: ok. 3 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic vm.expectRevert"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_vm_expect_revert_symbolic_data_mismatch_fails(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_vm_expect_revert_symbolic_data_mismatch_fails because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicExpectRevertSymbolicMismatch.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicExpectedSymbolicMismatchReverter { + error Custom(uint256 value); + + function failWithCustom(uint256 value) external pure { + revert Custom(value); + } +} + +contract SymbolicExpectRevertSymbolicMismatch is Test { + SymbolicExpectedSymbolicMismatchReverter helper; + + function setUp() public { + helper = new SymbolicExpectedSymbolicMismatchReverter(); + } + + function checkSymbolicExpectedRevertMismatch(uint256 value) public { + vm.expectRevert(abi.encodeWithSelector(SymbolicExpectedSymbolicMismatchReverter.Custom.selector, uint256(7))); + helper.failWithCustom(value); + } + + function checkSymbolicExpectedReverterMismatch(address reverter) public { + vm.expectRevert(reverter); + helper.failWithCustom(7); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkSymbolicExpectedRevertMismatch", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicExpectRevertSymbolicMismatch.t.sol:SymbolicExpectRevertSymbolicMismatch +[FAIL: Error != expected error: Custom(0) != Custom(7); counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkSymbolicExpectedRevertMismatch(uint256) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic expected revert data"), "{stdout}"); + + let stdout = assert_symbolic_witness(prj.forge_command().args([ + "test", + "--symbolic", + "--match-test", + "checkSymbolicExpectedReverterMismatch", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicExpectRevertSymbolicMismatch.t.sol:SymbolicExpectRevertSymbolicMismatch +[FAIL: Reverter != expected reverter: 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f != 0xFFfFfFffFFfffFFfFFfFFFFFffFFFffffFfFFFfF; counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkSymbolicExpectedReverterMismatch(address) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic vm.expectRevert"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_vm_expect_emit_matches_external_logs(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_vm_expect_emit_matches_external_logs because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicExpectEmit.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicEmitter { + event Seen(address indexed who, uint256 indexed id, uint256 value); + + function fire(address who, uint256 id, uint256 value) external { + emit Seen(who, id, value); + } + + function fireTwice(address who, uint256 id, uint256 value) external { + emit Seen(who, id, value); + emit Seen(who, id, value); + } +} + +contract SymbolicExpectEmit is Test { + event Seen(address indexed who, uint256 indexed id, uint256 value); + + SymbolicEmitter emitter; + + function setUp() public { + emitter = new SymbolicEmitter(); + } + + function checkExpectEmit(uint256) public { + vm.expectEmit(true, true, false, true, address(emitter)); + emit Seen(address(0xB0B), 7, 9); + emitter.fire(address(0xB0B), 7, 9); + } + + function checkExpectEmitSymbolicEmitter(address expectedEmitter) public { + vm.assume(expectedEmitter == address(emitter)); + vm.expectEmit(true, true, false, true, expectedEmitter); + emit Seen(address(0xB0B), 7, 9); + emitter.fire(address(0xB0B), 7, 9); + } + + function checkExpectEmitCountOverloads(uint256) public { + vm.expectEmit(uint64(2)); + emit Seen(address(0xB0B), 7, 9); + emitter.fireTwice(address(0xB0B), 7, 9); + + vm.expectEmit(address(emitter), uint64(2)); + emit Seen(address(0xB0B), 7, 9); + emitter.fireTwice(address(0xB0B), 7, 9); + + vm.expectEmit(true, true, false, true, uint64(2)); + emit Seen(address(0xB0B), 7, 9); + emitter.fireTwice(address(0xB0B), 7, 9); + + vm.expectEmit(true, true, false, true, address(emitter), uint64(2)); + emit Seen(address(0xB0B), 7, 9); + emitter.fireTwice(address(0xB0B), 7, 9); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkExpectEmit", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 3 tests for test/SymbolicExpectEmit.t.sol:SymbolicExpectEmit +[PASS] checkExpectEmit(uint256) ([METRICS]) +[PASS] checkExpectEmitCountOverloads(uint256) ([METRICS]) +[PASS] checkExpectEmitSymbolicEmitter(address) ([METRICS]) +Suite result: ok. 3 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic vm.expectEmit"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_vm_expect_emit_mismatch_is_counterexample(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_vm_expect_emit_mismatch_is_counterexample because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicExpectEmitMismatch.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicMismatchEmitter { + event Seen(address indexed who, uint256 indexed id, uint256 value); + + function fire(address who, uint256 id, uint256 value) external { + emit Seen(who, id, value); + } +} + +contract SymbolicExpectEmitMismatch is Test { + event Seen(address indexed who, uint256 indexed id, uint256 value); + + SymbolicMismatchEmitter emitter; + + function setUp() public { + emitter = new SymbolicMismatchEmitter(); + } + + function checkMismatchedExpectEmit(uint256) public { + vm.expectEmit(true, true, false, true, address(emitter)); + emit Seen(address(0xB0B), 7, 9); + emitter.fire(address(0xB0B), 8, 9); + } + + function checkMismatchedExpectEmitSymbolicEmitter(address expectedEmitter) public { + vm.expectEmit(true, true, false, true, expectedEmitter); + emit Seen(address(0xB0B), 7, 9); + emitter.fire(address(0xB0B), 7, 9); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkMismatchedExpectEmit", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 2 tests for test/SymbolicExpectEmitMismatch.t.sol:SymbolicExpectEmitMismatch +[FAIL: Seen param mismatch at id: expected=7, got=8; counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkMismatchedExpectEmit(uint256) ([METRICS]) +[FAIL: log emitter mismatch: expected=0xffffffffffffffffffffffffffffffffffffffff, got=0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f; counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkMismatchedExpectEmitSymbolicEmitter(address) ([METRICS]) +Suite result: FAILED. 0 passed; 2 failed; 0 skipped; [ELAPSED] +... +"#]]); + + let stdout = assert_symbolic_witness(prj.forge_command().args([ + "test", + "--symbolic", + "--match-test", + "checkMismatchedExpectEmitSymbolicEmitter", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicExpectEmitMismatch.t.sol:SymbolicExpectEmitMismatch +[FAIL: log emitter mismatch: expected=0xffffffffffffffffffffffffffffffffffffffff, got=0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f; counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkMismatchedExpectEmitSymbolicEmitter(address) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic vm.expectEmit"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_vm_expect_call_matches_and_reports_missing(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_vm_expect_call_matches_and_reports_missing because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicExpectCall.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicExpectedCallTarget { + function ping(uint256 value) external pure returns (uint256) { + return value + 1; + } +} + +contract SymbolicExpectCall is Test { + SymbolicExpectedCallTarget target; + + function setUp() public { + target = new SymbolicExpectedCallTarget(); + } + + function checkExpectCallMatches(uint256) public { + vm.expectCall( + address(target), + abi.encodeWithSelector(SymbolicExpectedCallTarget.ping.selector, uint256(7)) + ); + assertEq(target.ping(7), 8); + + vm.expectCall(address(target), 0, abi.encodeWithSelector(SymbolicExpectedCallTarget.ping.selector, uint256(9)), 1); + assertEq(target.ping(9), 10); + } + + function checkExpectCallGasUnsupported(uint256) public { + vm.expectCall( + address(target), + 0, + uint64(50000), + abi.encodeWithSelector(SymbolicExpectedCallTarget.ping.selector, uint256(13)) + ); + assertEq(target.ping(13), 14); + + vm.expectCallMinGas( + address(target), + 0, + uint64(25000), + abi.encodeWithSelector(SymbolicExpectedCallTarget.ping.selector, uint256(14)) + ); + assertEq(target.ping(14), 15); + } + + function checkExpectCallSymbolicCallee(address expectedCallee) public { + vm.assume(expectedCallee == address(target)); + vm.expectCall( + expectedCallee, + abi.encodeWithSelector(SymbolicExpectedCallTarget.ping.selector, uint256(7)) + ); + assertEq(target.ping(7), 8); + } + + function checkExpectCallMissing(uint256) public { + vm.expectCall( + address(target), + abi.encodeWithSelector(SymbolicExpectedCallTarget.ping.selector, uint256(11)) + ); + } + + function checkSymbolicCalleeExpectedCallMismatch(address expectedCallee) public { + vm.expectCall( + expectedCallee, + abi.encodeWithSelector(SymbolicExpectedCallTarget.ping.selector, uint256(7)) + ); + assertEq(target.ping(7), 8); + } + + function checkExpectCallMinGasMissing(uint256) public { + vm.expectCallMinGas( + address(target), + 0, + uint64(60000), + abi.encodeWithSelector(SymbolicExpectedCallTarget.ping.selector, uint256(15)) + ); + assertEq(target.ping{gas: 50000}(15), 16); + } + + function checkExpectCallAdditive(uint256) public { + bytes memory data = + abi.encodeWithSelector(SymbolicExpectedCallTarget.ping.selector, uint256(2)); + vm.expectCall(address(target), data); + vm.expectCall(address(target), data); + assertEq(target.ping(2), 3); + assertEq(target.ping(2), 3); + } + + function checkExpectCallCountedDuplicateReverts(uint256) public { + bytes memory data = + abi.encodeWithSelector(SymbolicExpectedCallTarget.ping.selector, uint256(3)); + vm.expectCall(address(target), data, 1); + (bool ok, bytes memory ret) = address(vm).call( + abi.encodeWithSignature("expectCall(address,bytes,uint64)", address(target), data, uint64(1)) + ); + assertFalse(ok); + assertEq( + keccak256(ret), + keccak256( + abi.encodeWithSelector( + bytes4(keccak256("CheatcodeError(string)")), + "counted expected calls can only bet set once" + ) + ) + ); + assertEq(target.ping(3), 4); + } + + function checkExpectCallNonCountedOverCountedReverts(uint256) public { + bytes memory data = + abi.encodeWithSelector(SymbolicExpectedCallTarget.ping.selector, uint256(4)); + vm.expectCall(address(target), data, 1); + (bool ok, bytes memory ret) = address(vm).call( + abi.encodeWithSignature("expectCall(address,bytes)", address(target), data) + ); + assertFalse(ok); + assertEq( + keccak256(ret), + keccak256( + abi.encodeWithSelector( + bytes4(keccak256("CheatcodeError(string)")), + "cannot overwrite a counted expectCall with a non-counted expectCall" + ) + ) + ); + assertEq(target.ping(4), 5); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkExpectCallMatches", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicExpectCall.t.sol:SymbolicExpectCall +[PASS] checkExpectCallMatches(uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); + + assert_symbolic_witness(prj.forge_command().args([ + "test", + "--symbolic", + "--match-test", + "checkExpectCallGasUnsupported", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicExpectCall.t.sol:SymbolicExpectCall +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: symbolic expected call gas] checkExpectCallGasUnsupported(uint256) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); + + let stdout = assert_symbolic_witness(prj.forge_command().args([ + "test", + "--symbolic", + "--match-test", + "checkExpectCallSymbolicCallee", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicExpectCall.t.sol:SymbolicExpectCall +[PASS] checkExpectCallSymbolicCallee(address) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic vm.expectCall"), "{stdout}"); + + assert_symbolic_witness(prj.forge_command().args([ + "test", + "--symbolic", + "--match-test", + "checkExpectCallMissing", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicExpectCall.t.sol:SymbolicExpectCall +[FAIL: expected call to 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f with data 0x773acdef000000000000000000000000000000000000000000000000000000000000000b to be called 1 time, but was called 0 times; counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkExpectCallMissing(uint256) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); + + let stdout = assert_symbolic_witness(prj.forge_command().args([ + "test", + "--symbolic", + "--match-test", + "checkSymbolicCalleeExpectedCallMismatch", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicExpectCall.t.sol:SymbolicExpectCall +[FAIL: expected call to 0xFFfFfFffFFfffFFfFFfFFFFFffFFFffffFfFFFfF with data 0x773acdef0000000000000000000000000000000000000000000000000000000000000007 to be called 1 time, but was called 0 times; counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkSymbolicCalleeExpectedCallMismatch(address) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic vm.expectCall"), "{stdout}"); + + assert_symbolic_witness(prj.forge_command().args([ + "test", + "--symbolic", + "--match-test", + "checkExpectCallMinGasMissing", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicExpectCall.t.sol:SymbolicExpectCall +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: explicit CALL gas limit not modeled] checkExpectCallMinGasMissing(uint256) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); + + assert_symbolic_witness(prj.forge_command().args([ + "test", + "--symbolic", + "--match-test", + "checkExpectCallAdditive", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicExpectCall.t.sol:SymbolicExpectCall +[PASS] checkExpectCallAdditive(uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); + + assert_symbolic_witness(prj.forge_command().args([ + "test", + "--symbolic", + "--match-test", + "^(checkExpectCallCountedDuplicateReverts|checkExpectCallNonCountedOverCountedReverts)\\(", + ])) + .success() + .stdout_eq( + str![[r#" +... +Ran 2 tests for test/SymbolicExpectCall.t.sol:SymbolicExpectCall +[PASS] checkExpectCallCountedDuplicateReverts(uint256) ([METRICS]) +[PASS] checkExpectCallNonCountedOverCountedReverts(uint256) ([METRICS]) +Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]] + .unordered(), + ); +} + +#[forgetest_init] +fn symbolic_vm_mock_call_returns_and_reverts(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_vm_mock_call_returns_and_reverts because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicMockCall.t.sol", + r#" +import "forge-std/Test.sol"; + +interface IMockedTarget { + function value(uint256 input) external returns (uint256); +} + +contract SymbolicMockCall is Test { + function checkMockCall(uint256) public { + address target = address(0x1234); + + vm.mockCall( + target, + abi.encodeWithSelector(IMockedTarget.value.selector, uint256(1)), + abi.encode(uint256(99)) + ); + assertEq(IMockedTarget(target).value(1), 99); + + vm.mockCallRevert( + target, + abi.encodeWithSelector(IMockedTarget.value.selector, uint256(2)), + abi.encodeWithSignature("Error(string)", "mocked") + ); + (bool ok, bytes memory data) = + target.call(abi.encodeWithSelector(IMockedTarget.value.selector, uint256(2))); + assertFalse(ok); + assertGt(data.length, 0); + } + + function checkMockCallSymbolicCallee(address mocked) public { + address target = address(0x1234); + vm.assume(mocked == target); + + vm.mockCall( + mocked, + abi.encodeWithSelector(IMockedTarget.value.selector, uint256(1)), + abi.encode(uint256(99)) + ); + assertEq(IMockedTarget(target).value(1), 99); + } + + function checkSymbolicCalleeMockMismatch(address mocked) public { + address target = address(0x1234); + + vm.mockCall( + mocked, + abi.encodeWithSelector(IMockedTarget.value.selector, uint256(1)), + abi.encode(uint256(99)) + ); + (bool ok, bytes memory data) = + target.call(abi.encodeWithSelector(IMockedTarget.value.selector, uint256(1))); + uint256 value = data.length == 32 ? abi.decode(data, (uint256)) : 0; + assertTrue(ok); + assertEq(value, 99); + } + + function checkSelectorMockCallsAndClear(uint256 input) public { + address target = address(0x4567); + bytes[] memory returnValues = new bytes[](2); + returnValues[0] = abi.encode(uint256(100)); + returnValues[1] = abi.encode(uint256(200)); + + vm.mockCalls(target, abi.encodePacked(IMockedTarget.value.selector), returnValues); + assertEq(IMockedTarget(target).value(input), 100); + assertEq(IMockedTarget(target).value(1), 200); + assertEq(IMockedTarget(target).value(2), 200); + + vm.clearMockedCalls(); + (bool ok, bytes memory data) = + target.call(abi.encodeWithSelector(IMockedTarget.value.selector, input)); + assertTrue(ok); + assertEq(data.length, 0); + } + + function checkMockCallsAcceptsSymbolicData(address mocked, uint256 input) public { + address target = address(0x4567); + vm.assume(mocked == target); + vm.assume(input < type(uint256).max - 2); + + bytes[] memory returnValues = new bytes[](2); + returnValues[0] = abi.encode(input + 1); + returnValues[1] = abi.encode(input + 2); + + vm.mockCalls( + mocked, + abi.encodeWithSelector(IMockedTarget.value.selector, input), + returnValues + ); + assertEq(IMockedTarget(target).value(input), input + 1); + assertEq(IMockedTarget(target).value(input), input + 2); + assertEq(IMockedTarget(target).value(input), input + 2); + } + + function checkMockCallRemockReplacesStaleValue(uint256) public { + address target = address(0x1234); + + vm.mockCall( + target, + abi.encodeWithSelector(IMockedTarget.value.selector, uint256(1)), + abi.encode(uint256(10)) + ); + assertEq(IMockedTarget(target).value(1), 10); + + // Re-registering the same mock replaces its return value. + vm.mockCall( + target, + abi.encodeWithSelector(IMockedTarget.value.selector, uint256(1)), + abi.encode(uint256(20)) + ); + assertEq(IMockedTarget(target).value(1), 20); + } +} +"#, + ); + + let stdout = + assert_symbolic_witness(cmd.args(["test", "--symbolic", "--match-test", "checkMockCall"])) + .success() + .stdout_eq(str![[r#" +... +Ran 4 tests for test/SymbolicMockCall.t.sol:SymbolicMockCall +[PASS] checkMockCall(uint256) ([METRICS]) +[PASS] checkMockCallRemockReplacesStaleValue(uint256) ([METRICS]) +[PASS] checkMockCallSymbolicCallee(address) ([METRICS]) +[PASS] checkMockCallsAcceptsSymbolicData(address,uint256) ([METRICS]) +Suite result: ok. 4 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic Foundry cheatcode"), "{stdout}"); + assert!(!stdout.contains("symbolic vm.mockCall"), "{stdout}"); + + let stdout = assert_symbolic_witness(prj.forge_command().args([ + "test", + "--symbolic", + "--match-test", + "checkSelectorMockCallsAndClear", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicMockCall.t.sol:SymbolicMockCall +[PASS] checkSelectorMockCallsAndClear(uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic Foundry cheatcode"), "{stdout}"); + + let stdout = assert_symbolic_witness(prj.forge_command().args([ + "test", + "--symbolic", + "--match-test", + "checkMockCallsAcceptsSymbolicData", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicMockCall.t.sol:SymbolicMockCall +[PASS] checkMockCallsAcceptsSymbolicData(address,uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic Foundry cheatcode"), "{stdout}"); + assert!(!stdout.contains("symbolic vm.mockCalls"), "{stdout}"); + + let stdout = assert_symbolic_witness(prj.forge_command().args([ + "test", + "--symbolic", + "--match-test", + "checkSymbolicCalleeMockMismatch", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicMockCall.t.sol:SymbolicMockCall +[FAIL: assertion failed: 0 != 99; counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkSymbolicCalleeMockMismatch(address) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic vm.mockCall"), "{stdout}"); + + let stdout = assert_symbolic_witness(prj.forge_command().args([ + "test", + "--symbolic", + "--match-test", + "checkMockCallRemockReplacesStaleValue", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicMockCall.t.sol:SymbolicMockCall +[PASS] checkMockCallRemockReplacesStaleValue(uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic Foundry cheatcode"), "{stdout}"); + assert!(!stdout.contains("symbolic vm.mockCall"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_vm_call_expectations_allow_symbolic_value_when_unpinned(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_vm_call_expectations_allow_symbolic_value_when_unpinned because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicUnpinnedCallValue.t.sol", + r#" +import "forge-std/Test.sol"; + +interface IValueTarget { + function ping(uint256 input) external payable returns (uint256); +} + +contract ValueTarget { + function ping(uint256 input) external payable returns (uint256) { + return input + msg.value; + } +} + +contract SymbolicUnpinnedCallValue is Test { + ValueTarget target; + + function setUp() public { + target = new ValueTarget(); + } + + function checkExpectCallAllowsSymbolicValue(uint8 amount) public { + vm.assume(amount <= 1); + vm.deal(address(this), 1); + + vm.expectCall( + address(target), + abi.encodeWithSelector(ValueTarget.ping.selector, uint256(7)) + ); + + assertEq(target.ping{value: amount}(7), 7 + amount); + } + + function checkMockCallAllowsSymbolicValue(uint8 amount) public { + vm.assume(amount <= 1); + address mocked = address(0xBEEF); + + vm.mockCall( + mocked, + abi.encodeWithSelector(IValueTarget.ping.selector, uint256(3)), + abi.encode(uint256(44)) + ); + + assertEq(IValueTarget(mocked).ping{value: amount}(3), 44); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-contract", + "SymbolicUnpinnedCallValue", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 2 tests for test/SymbolicUnpinnedCallValue.t.sol:SymbolicUnpinnedCallValue +[PASS] checkExpectCallAllowsSymbolicValue(uint8) ([METRICS]) +[PASS] checkMockCallAllowsSymbolicValue(uint8) ([METRICS]) +Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic expected call value"), "{stdout}"); + assert!(!stdout.contains("symbolic mocked call value"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_vm_call_expectations_branch_symbolic_pinned_value(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_vm_call_expectations_branch_symbolic_pinned_value because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicPinnedCallValue.t.sol", + r#" +import "forge-std/Test.sol"; + +interface IPinnedValueTarget { + function ping(uint256 input) external payable returns (uint256); +} + +contract PinnedValueTarget { + function ping(uint256 input) external payable returns (uint256) { + return input + msg.value; + } +} + +contract SymbolicPinnedCallValue is Test { + PinnedValueTarget target; + + function setUp() public { + target = new PinnedValueTarget(); + } + + function checkExpectCallPinnedValueFindsMismatch(uint8 amount) public { + vm.assume(amount <= 1); + vm.deal(address(this), 1); + + vm.expectCall( + address(target), + uint256(1), + abi.encodeWithSelector(PinnedValueTarget.ping.selector, uint256(7)), + 1 + ); + + assertEq(target.ping{value: amount}(7), 7 + amount); + } + + function checkMockCallPinnedValueMatches(uint8 amount) public { + vm.assume(amount == 1); + vm.deal(address(this), 1); + address mocked = address(0xCAFE); + + vm.mockCall( + mocked, + uint256(1), + abi.encodeWithSelector(IPinnedValueTarget.ping.selector, uint256(3)), + abi.encode(uint256(44)) + ); + + assertEq(IPinnedValueTarget(mocked).ping{value: amount}(3), 44); + } + + function checkMockCallPinnedValueFindsMismatch(uint8 amount) public { + vm.assume(amount <= 1); + vm.deal(address(this), 1); + address mocked = address(0xBEEF); + + vm.mockCall( + mocked, + uint256(1), + abi.encodeWithSelector(IPinnedValueTarget.ping.selector, uint256(3)), + abi.encode(uint256(44)) + ); + + (bool ok, bytes memory data) = mocked.call{value: amount}( + abi.encodeWithSelector(IPinnedValueTarget.ping.selector, uint256(3)) + ); + assertTrue(ok); + assertEq(data.length, 32); + assertEq(abi.decode(data, (uint256)), 44); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkExpectCallPinnedValueFindsMismatch", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicPinnedCallValue.t.sol:SymbolicPinnedCallValue +[FAIL: expected call to 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f with data 0x773acdef0000000000000000000000000000000000000000000000000000000000000007, value 1 to be called 1 time, but was called 0 times; counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkExpectCallPinnedValueFindsMismatch(uint8) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic expected call value"), "{stdout}"); + + let stdout = assert_symbolic_witness(prj.forge_command().args([ + "test", + "--symbolic", + "--match-test", + "checkMockCallPinnedValueMatches", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicPinnedCallValue.t.sol:SymbolicPinnedCallValue +[PASS] checkMockCallPinnedValueMatches(uint8) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic mocked call value"), "{stdout}"); + + let stdout = assert_symbolic_witness(prj.forge_command().args([ + "test", + "--symbolic", + "--match-test", + "checkMockCallPinnedValueFindsMismatch", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicPinnedCallValue.t.sol:SymbolicPinnedCallValue +[FAIL: assertion failed: 0 != 32; counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkMockCallPinnedValueFindsMismatch(uint8) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic mocked call value"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_vm_expect_and_mock_call_accept_symbolic_data(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_vm_expect_and_mock_call_accept_symbolic_data because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicCallDataCheatcodes.t.sol", + r#" +import "forge-std/Test.sol"; + +interface ISymbolicDataTarget { + function value(uint256 input) external returns (uint256); +} + +contract SymbolicDataTarget { + function value(uint256 input) external pure returns (uint256) { + return input; + } +} + +contract SymbolicFunctionMockTarget { + function value(uint256 input) external pure returns (uint256) { + return input + 10; + } +} + +contract SymbolicCallDataCheatcodes is Test { + SymbolicDataTarget target; + SymbolicFunctionMockTarget functionTarget; + + function setUp() public { + target = new SymbolicDataTarget(); + functionTarget = new SymbolicFunctionMockTarget(); + } + + function checkExpectCallAcceptsSymbolicData(uint256 input) public { + vm.expectCall( + address(target), + abi.encodeWithSelector(SymbolicDataTarget.value.selector, input) + ); + + assertEq(target.value(input), input); + } + + function checkMockCallAcceptsSymbolicDataAndReturn(uint256 input) public { + address mocked = address(0xDADA); + + vm.mockCall( + mocked, + abi.encodeWithSelector(ISymbolicDataTarget.value.selector, input), + abi.encode(input + 1) + ); + + assertEq(ISymbolicDataTarget(mocked).value(input), input + 1); + } + + function checkMockCallAcceptsSymbolicBytes4Selector(bytes4 selector) public { + vm.assume(selector == ISymbolicDataTarget.value.selector); + address mocked = address(0xFACE); + + vm.mockCall(mocked, selector, abi.encode(uint256(99))); + + assertEq(ISymbolicDataTarget(mocked).value(1), 99); + } + + function checkMockFunctionAcceptsSymbolicData(uint256 input) public { + address mocked = address(0xF00D); + + vm.mockFunction( + mocked, + address(functionTarget), + abi.encodeWithSelector(ISymbolicDataTarget.value.selector, input) + ); + + assertEq(ISymbolicDataTarget(mocked).value(input), input + 10); + } + + function checkMockFunctionAcceptsSymbolicCallee(address mocked, uint256 input) public { + address actual = address(0xF00D); + vm.assume(mocked == actual); + + vm.mockFunction( + mocked, + address(functionTarget), + abi.encodeWithSelector(ISymbolicDataTarget.value.selector, input) + ); + + assertEq(ISymbolicDataTarget(actual).value(input), input + 10); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-contract", + "SymbolicCallDataCheatcodes", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 5 tests for test/SymbolicCallDataCheatcodes.t.sol:SymbolicCallDataCheatcodes +[PASS] checkExpectCallAcceptsSymbolicData(uint256) ([METRICS]) +[PASS] checkMockCallAcceptsSymbolicBytes4Selector(bytes4) ([METRICS]) +[PASS] checkMockCallAcceptsSymbolicDataAndReturn(uint256) ([METRICS]) +[PASS] checkMockFunctionAcceptsSymbolicCallee(address,uint256) ([METRICS]) +[PASS] checkMockFunctionAcceptsSymbolicData(uint256) ([METRICS]) +Suite result: ok. 5 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic vm.expectCall"), "{stdout}"); + assert!(!stdout.contains("symbolic vm.mockCall"), "{stdout}"); + assert!(!stdout.contains("symbolic vm.mockFunction"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_vm_call_data_match_branches_find_mismatch(prj: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_vm_call_data_match_branches_find_mismatch because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicCallDataMismatch.t.sol", + r#" +import "forge-std/Test.sol"; + +interface ISymbolicCallDataMismatchTarget { + function value(uint256 input) external returns (uint256); +} + +contract SymbolicCallDataMismatchTarget { + function value(uint256 input) external pure returns (uint256) { + return input; + } +} + +contract SymbolicFunctionMockMismatchTarget { + function value(uint256 input) external pure returns (uint256) { + return input + 10; + } +} + +contract SymbolicCallDataMismatch is Test { + SymbolicCallDataMismatchTarget target; + SymbolicFunctionMockMismatchTarget functionTarget; + + function setUp() public { + target = new SymbolicCallDataMismatchTarget(); + functionTarget = new SymbolicFunctionMockMismatchTarget(); + } + + function checkExpectCallSymbolicDataFindsMismatch(uint256 expected, uint256 actual) public { + vm.expectCall( + address(target), + abi.encodeWithSelector(SymbolicCallDataMismatchTarget.value.selector, expected) + ); + + target.value(actual); + } + + function checkMockCallSymbolicDataFindsMismatch(uint256 expected, uint256 actual) public { + address mocked = address(0xDADA); + + vm.mockCall( + mocked, + abi.encodeWithSelector(ISymbolicCallDataMismatchTarget.value.selector, expected), + abi.encode(uint256(99)) + ); + + (bool ok, bytes memory data) = + mocked.call(abi.encodeWithSelector(ISymbolicCallDataMismatchTarget.value.selector, actual)); + assertTrue(ok); + assertEq(data.length, 32); + assertEq(abi.decode(data, (uint256)), 99); + } + + function checkMockFunctionSymbolicDataFindsMismatch(uint256 expected, uint256 actual) public { + address mocked = address(0xF00D); + + vm.mockFunction( + mocked, + address(functionTarget), + abi.encodeWithSelector(ISymbolicCallDataMismatchTarget.value.selector, expected) + ); + + (bool ok, bytes memory data) = + mocked.call(abi.encodeWithSelector(ISymbolicCallDataMismatchTarget.value.selector, actual)); + assertTrue(ok); + assertEq(data.length, 32); + assertEq(abi.decode(data, (uint256)), actual + 10); + } + + function checkMockFunctionSymbolicCalleeFindsMismatch(address mocked) public { + address actual = address(0xF00D); + + vm.mockFunction( + mocked, + address(functionTarget), + abi.encodeWithSelector(ISymbolicCallDataMismatchTarget.value.selector, uint256(1)) + ); + + (bool ok, bytes memory data) = + actual.call(abi.encodeWithSelector(ISymbolicCallDataMismatchTarget.value.selector, uint256(1))); + assertTrue(ok); + assertEq(data.length, 32); + assertEq(abi.decode(data, (uint256)), uint256(11)); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(prj.forge_command().args([ + "test", + "--symbolic", + "--match-test", + "^(checkExpectCallSymbolicDataFindsMismatch|checkMockCallSymbolicDataFindsMismatch|checkMockFunctionSymbolicDataFindsMismatch|checkMockFunctionSymbolicCalleeFindsMismatch)\\(", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 4 tests for test/SymbolicCallDataMismatch.t.sol:SymbolicCallDataMismatch +[FAIL: expected call to 0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f with data 0xc5a46ee60000000000000000000000000000000000000000000000000000000000000001 to be called 1 time, but was called 0 times; counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkExpectCallSymbolicDataFindsMismatch(uint256,uint256) ([METRICS]) +[FAIL: assertion failed: 0 != 32; counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkMockCallSymbolicDataFindsMismatch(uint256,uint256) ([METRICS]) +[FAIL: assertion failed: 0 != 32; counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkMockFunctionSymbolicCalleeFindsMismatch(address) ([METRICS]) +[FAIL: assertion failed: 0 != 32; counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkMockFunctionSymbolicDataFindsMismatch(uint256,uint256) ([METRICS]) +Suite result: FAILED. 0 passed; 4 failed; 0 skipped; [ELAPSED] +... +"#]].unordered()) + .get_output() + .stdout_lossy(); + for reason in ["symbolic vm.expectCall", "symbolic vm.mockCall", "symbolic vm.mockFunction"] { + assert!(!stdout.contains(reason), "{stdout}"); + } +} + +#[forgetest_init] +fn symbolic_vm_record_accesses_tracks_symbolic_slots(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_vm_record_accesses_tracks_symbolic_slots because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicRecordAccesses.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicRevertingStorageTarget { + function storeThenRevert(bytes32 slot, bytes32 stored) external { + assembly { + sstore(slot, stored) + } + revert(); + } +} + +contract SymbolicRecordAccesses is Test { + function checkRecordAccesses(bytes32 slot, bytes32 stored) public { + vm.record(); + + bytes32 loadedSlot; + assembly { + sstore(slot, stored) + loadedSlot := sload(slot) + } + + (bytes32[] memory reads, bytes32[] memory writes) = vm.accesses(address(this)); + assertEq(loadedSlot, stored); + assertEq(reads.length, 1); + assertEq(writes.length, 1); + assertEq(reads[0], slot); + assertEq(writes[0], slot); + + vm.stopRecord(); + } + + function checkRecordAccessesSymbolicTarget(address target, bytes32 slot, bytes32 stored) public { + vm.assume(target == address(this)); + vm.record(); + + bytes32 loadedSlot; + assembly { + sstore(slot, stored) + loadedSlot := sload(slot) + } + + (bytes32[] memory reads, bytes32[] memory writes) = vm.accesses(target); + assertEq(loadedSlot, stored); + assertEq(reads.length, 1); + assertEq(writes.length, 1); + assertEq(reads[0], slot); + assertEq(writes[0], slot); + + vm.stopRecord(); + } + + function checkRecordAccessesSymbolicTargetBranches(address target, bytes32 slot, bytes32 stored) public { + address other = address(0xBEEF); + vm.assume(target == address(this) || target == other); + vm.record(); + + bytes32 loadedSlot; + assembly { + sstore(slot, stored) + loadedSlot := sload(slot) + } + + (bytes32[] memory reads, bytes32[] memory writes) = vm.accesses(target); + assertEq(loadedSlot, stored); + if (target == address(this)) { + assertEq(reads.length, 1); + assertEq(writes.length, 1); + assertEq(reads[0], slot); + assertEq(writes[0], slot); + } else { + assertEq(reads.length, 0); + assertEq(writes.length, 0); + } + + vm.stopRecord(); + } + + function testRecordRevertedChildAccesses() public { + recordRevertedChildAccesses(); + } + + function checkRecordAccessesRevertedChild() public { + recordRevertedChildAccesses(); + } + + function recordRevertedChildAccesses() internal { + SymbolicRevertingStorageTarget target = new SymbolicRevertingStorageTarget(); + bytes32 slot = bytes32(uint256(7)); + + vm.record(); + (bool ok,) = address(target).call( + abi.encodeCall(target.storeThenRevert, (slot, bytes32(uint256(1)))) + ); + assertFalse(ok); + + (bytes32[] memory reads, bytes32[] memory writes) = vm.accesses(address(target)); + assertEq(reads.length, 1); + assertEq(writes.length, 1); + assertEq(reads[0], slot); + assertEq(writes[0], slot); + } +} +"#, + ); + + cmd.args(["test", "--match-test", "testRecordRevertedChildAccesses"]).assert_success(); + cmd.forge_fuse(); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkRecordAccesses", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 4 tests for test/SymbolicRecordAccesses.t.sol:SymbolicRecordAccesses +[PASS] checkRecordAccesses(bytes32,bytes32) ([METRICS]) +[PASS] checkRecordAccessesRevertedChild() ([METRICS]) +[PASS] checkRecordAccessesSymbolicTarget(address,bytes32,bytes32) ([METRICS]) +[PASS] checkRecordAccessesSymbolicTargetBranches(address,bytes32,bytes32) ([METRICS]) +Suite result: ok. 4 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!( + stdout + .contains("[PASS] checkRecordAccessesSymbolicTargetBranches(address,bytes32,bytes32)"), + "{stdout}" + ); + assert!(!stdout.contains("symbolic Foundry cheatcode"), "{stdout}"); + assert!(!stdout.contains("symbolic vm.accesses address"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_vm_bound_skip_and_gas_noops(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_vm_bound_skip_and_gas_noops because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicBoundSkip.t.sol", + r#" +import "forge-std/Test.sol"; + +interface SymbolicVmCompat { + enum CallerMode { + None, + Broadcast, + RecurrentBroadcast, + Prank, + RecurrentPrank + } + + enum ForgeContext { + TestGroup, + Test, + Coverage, + Snapshot, + ScriptGroup, + ScriptDryRun, + ScriptBroadcast, + ScriptResume, + Unknown + } + + function readCallers() external view returns (CallerMode callerMode, address msgSender, address txOrigin); + function isContext(ForgeContext context) external view returns (bool result); +} + +contract SymbolicBoundSkip is Test { + function externalNoop() external {} + + function checkBoundSkipAndGasNoops(uint256 x, int256 y) public { + vm.pauseGasMetering(); + vm.resumeGasMetering(); + vm.resetGasMetering(); + vm.assume(x >= 10 && x <= 12); + uint256 bounded = vm.bound(x, 10, 12); + assertGe(bounded, 10); + assertLe(bounded, 12); + + vm.assume(y >= -3 && y <= 3); + int256 signedBounded = vm.bound(y, -3, 3); + assertGe(signedBounded, -3); + assertLe(signedBounded, 3); + + vm.skip(x == 42); + assertTrue(x != 42); + } + + function checkVmCompatibilityTail() public { + SymbolicVmCompat compat = SymbolicVmCompat(address(vm)); + SymbolicVmCompat.CallerMode mode; + address sender; + (mode,,) = compat.readCallers(); + assertEq(uint256(mode), uint256(SymbolicVmCompat.CallerMode.None)); + + vm.prank(address(0xB0B)); + (mode, sender,) = compat.readCallers(); + assertEq(uint256(mode), uint256(SymbolicVmCompat.CallerMode.Prank)); + assertEq(sender, address(0xB0B)); + vm.stopPrank(); + + vm.startPrank(address(0xCAFE)); + (mode, sender,) = compat.readCallers(); + assertEq(uint256(mode), uint256(SymbolicVmCompat.CallerMode.RecurrentPrank)); + assertEq(sender, address(0xCAFE)); + vm.stopPrank(); + + vm.allowCheatcodes(address(this)); + vm.makePersistent(address(this)); + assertTrue(vm.isPersistent(address(this))); + address[] memory accounts = new address[](1); + accounts[0] = address(0xBEEF); + vm.makePersistent(accounts); + assertTrue(vm.isPersistent(address(0xBEEF))); + vm.revokePersistent(address(this)); + vm.revokePersistent(accounts); + assertFalse(vm.isPersistent(address(this))); + assertFalse(vm.isPersistent(address(0xBEEF))); + + vm.label(address(this), "self"); + assertEq(vm.getLabel(address(this)), "self"); + vm.snapshotValue("value", 1); + vm.snapshotValue("group", "value", 1); + vm.cool(address(this)); + vm.warmSlot(address(this), bytes32(uint256(1))); + vm.coolSlot(address(this), bytes32(uint256(1))); + vm.noAccessList(); + assertEq(vm.getChainId(), block.chainid); + assertTrue(bytes(vm.projectRoot()).length != 0); + assertTrue(vm.unixTime() != 0); + assertTrue(compat.isContext(SymbolicVmCompat.ForgeContext.TestGroup)); + assertTrue(compat.isContext(SymbolicVmCompat.ForgeContext.Test)); + assertFalse(compat.isContext(SymbolicVmCompat.ForgeContext.ScriptGroup)); + assertTrue(vm.isIsolateMode()); + } + + function checkRuntimeNoopsAndArrayAssertions() public { + vm.breakpoint("symbolic"); + vm.breakpoint("symbolic", true); + assertTrue(bytes(vm.getFoundryVersion()).length != 0); + vm.sleep(0); + Vm.AccessListItem[] memory access = new Vm.AccessListItem[](0); + vm.accessList(access); + + uint256[] memory left = new uint256[](1); + uint256[] memory right = new uint256[](1); + left[0] = 1; + right[0] = 1; + assertEq(left, right); + right[0] = 2; + assertNotEq(left, right); + + string[] memory words = new string[](1); + string[] memory sameWords = new string[](1); + words[0] = "foundry"; + sameWords[0] = "foundry"; + assertEq(words, sameWords); + + assertEqDecimal(uint256(1e18), uint256(1e18), 18); + assertEqDecimal(int256(-1e18), int256(-1e18), 18); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-contract", + "SymbolicBoundSkip", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 3 tests for test/SymbolicBoundSkip.t.sol:SymbolicBoundSkip +[PASS] checkBoundSkipAndGasNoops(uint256,int256) ([METRICS]) +[PASS] checkRuntimeNoopsAndArrayAssertions() ([METRICS]) +[PASS] checkVmCompatibilityTail() ([METRICS]) +Suite result: ok. 3 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic Foundry cheatcode"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_vm_bound_invalid_range_fails_without_stuck(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_vm_bound_invalid_range_fails_without_stuck because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicBoundInvalid.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicBoundInvalid is Test { + function checkInvalidUnsignedBound(uint256 x) public { + vm.bound(x, 12, 10); + } + + function checkInvalidSignedBound(int256 x) public { + vm.bound(x, 3, -3); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-contract", + "SymbolicBoundInvalid", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 2 tests for test/SymbolicBoundInvalid.t.sol:SymbolicBoundInvalid +[FAIL: vm.bound: cannot bound 0 in [3, -3] range; counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkInvalidSignedBound(int256) ([METRICS]) +[FAIL: vm.bound: cannot bound 0 in [12, 10] range; counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkInvalidUnsignedBound(uint256) ([METRICS]) +Suite result: FAILED. 0 passed; 2 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic vm.bound range"), "{stdout}"); + assert!(!stdout.contains("symbolic Foundry cheatcode"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_vm_assume_no_revert_filters_revert_matches(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_vm_assume_no_revert_filters_revert_matches because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicAssumeNoRevertFilters.t.sol", + r#" +import "forge-std/Test.sol"; + +interface SymbolicVm { + struct PotentialRevert { + address reverter; + bool partialMatch; + bytes revertData; + } + + function assume(bool condition) external pure; + function assumeNoRevert(PotentialRevert calldata potentialRevert) external pure; + function assumeNoRevert(PotentialRevert[] calldata potentialReverts) external pure; +} + +error Expected(uint256 value); +error Other(uint256 value); + +contract SymbolicAssumeNoRevertFilterTarget { + function onlyExpected(uint256 x) external pure { + if (x == 7) revert Expected(7); + } + + function twoReverts(uint256 x) external pure { + if (x == 7) revert Expected(x); + if (x == 9) revert Other(x); + } +} + +contract SymbolicAssumeNoRevertOtherTarget { + function onlyExpected(uint256 x) external pure { + if (x == 7) revert Expected(7); + } +} + +contract SymbolicAssumeNoRevertFilters is Test { + SymbolicAssumeNoRevertFilterTarget target; + SymbolicAssumeNoRevertOtherTarget other; + SymbolicVm symbolicVm = SymbolicVm(VM_ADDRESS); + + function setUp() public { + target = new SymbolicAssumeNoRevertFilterTarget(); + other = new SymbolicAssumeNoRevertOtherTarget(); + } + + function checkAssumeNoRevertExactFilterPrunes(uint256 x) public { + symbolicVm.assumeNoRevert(SymbolicVm.PotentialRevert({ + reverter: address(target), + partialMatch: false, + revertData: abi.encodeWithSelector(Expected.selector, uint256(7)) + })); + + (bool ok,) = address(target).call(abi.encodeWithSelector(target.onlyExpected.selector, x)); + assertTrue(ok); + } + + function checkAssumeNoRevertArrayFilterPrunes(uint256 x) public { + SymbolicVm.PotentialRevert[] memory filters = new SymbolicVm.PotentialRevert[](2); + filters[0] = SymbolicVm.PotentialRevert({ + reverter: address(target), + partialMatch: true, + revertData: abi.encodeWithSelector(Expected.selector) + }); + filters[1] = SymbolicVm.PotentialRevert({ + reverter: address(target), + partialMatch: false, + revertData: abi.encodeWithSelector(Other.selector, uint256(9)) + }); + symbolicVm.assumeNoRevert(filters); + + (bool ok,) = address(target).call(abi.encodeWithSelector(target.twoReverts.selector, x)); + assertTrue(ok); + } + + function checkAssumeNoRevertWrongDataFails(uint256 x) public { + symbolicVm.assume(x == 9); + symbolicVm.assumeNoRevert(SymbolicVm.PotentialRevert({ + reverter: address(target), + partialMatch: false, + revertData: abi.encodeWithSelector(Other.selector, uint256(8)) + })); + + (bool ok,) = address(target).call(abi.encodeWithSelector(target.twoReverts.selector, x)); + assertTrue(ok); + } + + function checkAssumeNoRevertWrongReverterFails(uint256 x) public { + symbolicVm.assume(x == 7); + symbolicVm.assumeNoRevert(SymbolicVm.PotentialRevert({ + reverter: address(other), + partialMatch: true, + revertData: abi.encodeWithSelector(Expected.selector) + })); + + (bool ok,) = address(target).call(abi.encodeWithSelector(target.onlyExpected.selector, x)); + assertTrue(ok); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkAssumeNoRevert.*Prunes", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 2 tests for test/SymbolicAssumeNoRevertFilters.t.sol:SymbolicAssumeNoRevertFilters +[PASS] checkAssumeNoRevertArrayFilterPrunes(uint256) ([METRICS]) +[PASS] checkAssumeNoRevertExactFilterPrunes(uint256) ([METRICS]) +Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic vm.assumeNoRevert"), "{stdout}"); + assert!(!stdout.contains("symbolic Foundry cheatcode"), "{stdout}"); + + let stdout = assert_symbolic_witness(cmd.forge_fuse().args([ + "test", + "--symbolic", + "--match-test", + "^(checkAssumeNoRevertWrongDataFails|checkAssumeNoRevertWrongReverterFails)\\(", + ])) + .failure() + .stdout_eq( + str![[r#" +... +Ran 2 tests for test/SymbolicAssumeNoRevertFilters.t.sol:SymbolicAssumeNoRevertFilters +[FAIL: assertion failed; counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkAssumeNoRevertWrongDataFails(uint256) ([METRICS]) +[FAIL: assertion failed; counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkAssumeNoRevertWrongReverterFails(uint256) ([METRICS]) +Suite result: FAILED. 0 passed; 2 failed; 0 skipped; [ELAPSED] +... +"#]] + .unordered(), + ) + .get_output() + .stdout_lossy(); + for reason in ["symbolic vm.assumeNoRevert", "symbolic Foundry cheatcode"] { + assert!(!stdout.contains(reason), "{stdout}"); + } +} + +// The `vm.prank(address, bool delegateCall)` overload diverges from concrete +// Forge semantics when `delegateCall == true`: the engine does not model +// pranking through a delegatecall frame, so this branch must fail closed as +// Unsupported rather than silently behaving like the address-only overload. +#[forgetest_init] +fn symbolic_vm_prank_delegatecall_overload_reports_unsupported(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_vm_prank_delegatecall_overload_reports_unsupported because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicPrankDelegateCall.t.sol", + r#" +import "forge-std/Test.sol"; + +contract Probe { + function sender() external view returns (address) { + return msg.sender; + } +} + +contract SymbolicPrankDelegateCall is Test { + Probe probe; + + function setUp() public { + probe = new Probe(); + } + + function checkPrankDelegateCall(address who) public { + vm.prank(who, true); + probe.sender(); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkPrankDelegateCall", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicPrankDelegateCall.t.sol:SymbolicPrankDelegateCall +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: symbolic vm.prank delegatecall] checkPrankDelegateCall(address) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_vm_deploy_code_models_constructor_outcomes(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_vm_deploy_code_models_constructor_outcomes because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicDeployCodeCheatcode.t.sol", + r#" +import "forge-std/Test.sol"; + +/// forge-config: default.evm_version = "cancun" + +interface SymbolicDeployCodeVm { + function randomBool() external view returns (bool); +} + +contract OkDeployCodeCtor { + uint256 public value = 1; +} + +contract RevertingDeployCodeCtor { + event ConstructorLog(); + + constructor() { + emit ConstructorLog(); + require(false, "ctor"); + } +} + +contract EnvBranchingDeployCodeCtor { + SymbolicDeployCodeVm constant VM = + SymbolicDeployCodeVm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + constructor() { + if (VM.randomBool()) { + revert("branch"); + } + } +} + +contract SelfDestructDeployCodeCtor { + constructor() payable { + selfdestruct(payable(msg.sender)); + } +} + +contract SymbolicDeployCodeCheatcode is Test { + string constant TARGET = "test/SymbolicDeployCodeCheatcode.t.sol"; + + function checkDeployCodeExpectedRevert() public { + vm.recordLogs(); + vm.expectRevert(); + vm.deployCode(string.concat(TARGET, ":RevertingDeployCodeCtor")); + + Vm.Log[] memory logs = vm.getRecordedLogs(); + assertEq(logs.length, 1); + assertEq(logs[0].topics[0], keccak256("ConstructorLog()")); + } + + function checkDeployCodeBranchingConstructor() public { + vm.deployCode(string.concat(TARGET, ":EnvBranchingDeployCodeCtor")); + } + + function checkDeployCodeStaticContext() public { + (bool ok,) = address(this).staticcall(abi.encodeCall(this.helperDeployCode, ())); + assertFalse(ok); + } + + function helperDeployCode() external { + vm.deployCode(string.concat(TARGET, ":OkDeployCodeCtor")); + } + + function checkDeployCodeSelfDestructConstructor() public { + address deployed = vm.deployCode(string.concat(TARGET, ":SelfDestructDeployCodeCtor")); + assertEq(deployed.code.length, 0); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-contract", + "SymbolicDeployCodeCheatcode", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 4 tests for test/SymbolicDeployCodeCheatcode.t.sol:SymbolicDeployCodeCheatcode +[PASS] checkDeployCodeBranchingConstructor() ([METRICS]) +[PASS] checkDeployCodeExpectedRevert() ([METRICS]) +[PASS] checkDeployCodeSelfDestructConstructor() ([METRICS]) +[PASS] checkDeployCodeStaticContext() ([METRICS]) +Suite result: ok. 4 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic vm.deployCode"), "{stdout}"); + assert!(!stdout.contains("symbolic Foundry cheatcode"), "{stdout}"); +} + +#[forgetest_init] +fn storage_hook_cheatcodes_concrete_and_symbolic(prj: _, cmd: _) { + prj.add_test( + "StorageHooks.t.sol", + r#" +import "forge-std/Test.sol"; + +interface StorageHookVm { + function registerSloadHook(address target, bytes4 callback) external; + function registerSstoreHook(address target, bytes4 callback) external; +} + +contract StorageHookTarget { + event Stored(uint256 value); + + uint256 public value; + mapping(address => uint256) public balances; + + function store(uint256 newValue) external { + value = newValue; + } + + function storeTwice(uint256 first, uint256 second) external { + value = first; + value = second; + } + + function storeAndRevert(uint256 newValue) external { + value = newValue; + revert("target revert"); + } + + function setBalance(address account, uint256 newValue) external { + balances[account] = newValue; + } + + function storeAndEmit(uint256 newValue) external { + value = newValue; + emit Stored(newValue); + } + + function storeAfterReturningCall(address returner, uint256 newValue) + external + returns (uint256 returnDataSize) + { + (bool ok,) = returner.staticcall(abi.encodeWithSignature("answer()")); + require(ok); + assembly { + sstore(0, newValue) + returnDataSize := returndatasize() + } + } +} + +contract StorageHookReturner { + function answer() external pure returns (uint256) { + return 42; + } +} + +contract StorageHookGasProbe { + function readCost(StorageHookTarget target) external view returns (uint256) { + uint256 gasBefore = gasleft(); + target.value(); + return gasBefore - gasleft(); + } +} + +contract StorageHookDepthTarget { + uint256 public value; + + function recurse(uint256 remaining) external returns (bool) { + if (remaining == 0) { + return value == 0; + } + (bool ok, bytes memory data) = + address(this).call(abi.encodeCall(this.recurse, (remaining - 1))); + return ok && abi.decode(data, (bool)); + } +} + +contract StorageHookCaller { + function store(StorageHookTarget target, uint256 newValue) external { + target.store(newValue); + } +} + +contract StorageHookImplementation { + function store(uint256 newValue) external { + assembly { + sstore(0, newValue) + } + } +} + +contract StorageHookProxy { + address immutable implementation; + + constructor(address implementation_) { + implementation = implementation_; + } + + function store(uint256 newValue) external { + (bool ok, bytes memory data) = + implementation.delegatecall(abi.encodeCall(StorageHookImplementation.store, (newValue))); + if (!ok) { + assembly { + revert(add(data, 32), mload(data)) + } + } + } +} + +contract ConstructorStorageHook { + StorageHookVm constant hookVm = + StorageHookVm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + uint256 public ghostValue; + + constructor(address target) { + hookVm.registerSstoreHook(target, ConstructorStorageHook.onStore.selector); + } + + function onStore(address, bytes32, bytes32, bytes32 newValue) external { + require(msg.sender == address(hookVm), "only storage hook"); + ghostValue = uint256(newValue); + } + + function registerThenRevert(address target) external { + hookVm.registerSstoreHook(target, ConstructorStorageHook.onStore.selector); + revert("after registration"); + } +} + +contract ConstructorStoreTarget { + uint256 public value; + + constructor(uint256 newValue) { + value = newValue; + } +} + +contract StorageHooksTest is Test { + event Stored(uint256 value); + event CallbackObserved(uint256 value); + + StorageHookVm constant hookVm = + StorageHookVm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + address constant FINAL_OPCODE_TARGET = address(0xBEEF); + address constant TRACE_SUCCESS_TARGET = address(0xA11CE); + address constant TRACE_REVERT_TARGET = address(0xB0B); + address constant EXISTING_ACCOUNT = address(0xCAFE); + + StorageHookTarget target; + StorageHookTarget mappingTarget; + StorageHookTarget baseStateTarget; + StorageHookTarget unregisteredTarget; + StorageHookCaller caller; + StorageHookProxy proxy; + StorageHookReturner returner; + StorageHookTarget coldReadTarget; + + uint256 ghostValue; + bytes32 lastSlot; + address lastAccount; + uint256 loadCount; + uint256 recursiveHookCalls; + uint256 mappingGhost; + uint256 baseStateGhost; + + modifier onlyStorageHook() { + require(msg.sender == address(hookVm), "only storage hook"); + _; + } + + function setUp() public { + target = new StorageHookTarget(); + mappingTarget = new StorageHookTarget(); + baseStateTarget = new StorageHookTarget(); + unregisteredTarget = new StorageHookTarget(); + caller = new StorageHookCaller(); + proxy = new StorageHookProxy(address(new StorageHookImplementation())); + returner = new StorageHookReturner(); + hookVm.registerSloadHook(address(target), this.onLoad.selector); + hookVm.registerSstoreHook(address(target), this.onStore.selector); + hookVm.registerSstoreHook(address(mappingTarget), this.onMappingStore.selector); + hookVm.registerSstoreHook(address(baseStateTarget), this.onBaseStateStore.selector); + baseStateTarget.setBalance(EXISTING_ACCOUNT, 5); + hookVm.registerSstoreHook(address(proxy), this.onStore.selector); + vm.etch(FINAL_OPCODE_TARGET, hex"600035600055"); + hookVm.registerSstoreHook(FINAL_OPCODE_TARGET, this.branchingStoreHook.selector); + vm.etch(TRACE_SUCCESS_TARGET, hex"6001600055600260015500"); + vm.etch(TRACE_REVERT_TARGET, hex"6001600055600260015500"); + hookVm.registerSstoreHook(TRACE_SUCCESS_TARGET, this.noopStoreHook.selector); + hookVm.registerSstoreHook(TRACE_REVERT_TARGET, this.revertingStoreHook.selector); + } + + function onLoad(address account, bytes32 slot, bytes32 value) external onlyStorageHook { + lastAccount = account; + lastSlot = slot; + ghostValue = uint256(value); + loadCount++; + } + + function onLoadReadingColdSlot(address, bytes32, bytes32) external view onlyStorageHook { + coldReadTarget.value(); + } + + function onStore(address account, bytes32 slot, bytes32, bytes32 newValue) + external + onlyStorageHook + { + lastAccount = account; + lastSlot = slot; + ghostValue = uint256(newValue); + } + + function onStoreWithInstrumentation(address, bytes32, bytes32, bytes32 newValue) + external + onlyStorageHook + { + emit CallbackObserved(uint256(newValue)); + ghostValue = returner.answer(); + } + + function onStoreWithRecording(address, bytes32, bytes32, bytes32 newValue) + external + onlyStorageHook + { + emit CallbackObserved(uint256(newValue)); + ghostValue = 42; + } + + function onMappingStore(address, bytes32, bytes32 oldValue, bytes32 newValue) + external + onlyStorageHook + { + unchecked { + mappingGhost = mappingGhost - uint256(oldValue) + uint256(newValue); + } + } + + function onBaseStateStore(address, bytes32, bytes32 oldValue, bytes32 newValue) + external + onlyStorageHook + { + unchecked { + baseStateGhost = baseStateGhost - uint256(oldValue) + uint256(newValue); + } + } + + function testConcreteArgumentsAndRollback() public { + target.store(7); + assertEq(ghostValue, 7); + assertEq(lastAccount, address(target)); + assertEq(lastSlot, bytes32(0)); + + assertEq(target.value(), 7); + assertEq(loadCount, 1); + assertEq(ghostValue, 7); + + (bool ok,) = address(target).call(abi.encodeCall(target.storeAndRevert, (11))); + assertFalse(ok); + assertEq(ghostValue, 7); + assertEq(target.value(), 7); + + target.storeTwice(9, 12); + assertEq(ghostValue, 12); + + caller.store(target, 15); + assertEq(ghostValue, 15); + + unregisteredTarget.store(99); + assertEq(ghostValue, 15); + + ghostValue = 0; + proxy.store(21); + assertEq(lastAccount, address(proxy)); + assertEq(ghostValue, 21); + } + + function testConcreteReplacementAndRevertPropagation() public { + hookVm.registerSstoreHook(address(target), this.revertingStoreHook.selector); + vm.expectRevert("replacement hook"); + target.store(1); + assertEq(target.value(), 0); + } + + function testConcreteFinalOpcodeSstoreCallbacks() public { + (bool ok,) = FINAL_OPCODE_TARGET.call(abi.encode(uint256(6))); + assertTrue(ok); + assertEq(uint256(vm.load(FINAL_OPCODE_TARGET, bytes32(0))), 6); + + (ok,) = FINAL_OPCODE_TARGET.call(abi.encode(uint256(7))); + assertFalse(ok); + assertEq(uint256(vm.load(FINAL_OPCODE_TARGET, bytes32(0))), 6); + } + + function testConcreteConstructorSstoreCallbacks() public { + uint256 nonce = vm.getNonce(address(this)); + address constructorTarget = vm.computeCreateAddress(address(this), nonce); + hookVm.registerSstoreHook(constructorTarget, this.onStore.selector); + + ConstructorStoreTarget deployed = new ConstructorStoreTarget(23); + assertEq(address(deployed), constructorTarget); + assertEq(deployed.value(), 23); + assertEq(ghostValue, 23); + + nonce = vm.getNonce(address(this)); + constructorTarget = vm.computeCreateAddress(address(this), nonce); + hookVm.registerSstoreHook(constructorTarget, this.revertingStoreHook.selector); + try new ConstructorStoreTarget(24) { + fail(); + } catch Error(string memory reason) { + assertEq(reason, "replacement hook"); + } + assertEq(constructorTarget.code.length, 0); + assertEq(ghostValue, 23); + } + + function testConcreteRegistrationSurvivesRevert() public { + ConstructorStorageHook hook = new ConstructorStorageHook(address(unregisteredTarget)); + (bool ok,) = address(hook).call( + abi.encodeCall(ConstructorStorageHook.registerThenRevert, (address(unregisteredTarget))) + ); + assertFalse(ok); + + unregisteredTarget.store(31); + assertEq(hook.ghostValue(), 31); + } + + function testIsolateEnclosingRevertRollsBackTargetAndGhost() public { + (bool ok,) = address(this).call(abi.encodeCall(this.storeAndRevert, (37))); + assertFalse(ok); + assertEq(ghostValue, 0); + assertEq(target.value(), 0); + } + + function storeAndRevert(uint256 newValue) external { + target.store(newValue); + revert("enclosing revert"); + } + + function testConcreteCallbackBypassesCallMocks() public { + bytes memory callback = abi.encodeWithSelector( + this.onStore.selector, address(target), bytes32(0), bytes32(0), bytes32(uint256(17)) + ); + vm.mockCall(address(this), callback, bytes("")); + + target.store(17); + + assertEq(ghostValue, 17); + } + + function testConcreteCallbackPanicRevertsTargetCall() public { + hookVm.registerSstoreHook(address(target), this.panickingStoreHook.selector); + + (bool ok,) = address(target).call(abi.encodeCall(target.store, (1))); + + assertFalse(ok); + assertEq(ghostValue, 0); + assertEq(target.value(), 0); + } + + function testConcreteCallbackPreservesReturnData() public { + assertEq(target.storeAfterReturningCall(address(returner), 1), 32); + } + + function testConcreteCallbackDoesNotWarmAccesses() public { + StorageHookTarget baselineReadTarget = new StorageHookTarget(); + StorageHookGasProbe probe = new StorageHookGasProbe(); + coldReadTarget = new StorageHookTarget(); + hookVm.registerSloadHook(address(target), this.onLoadReadingColdSlot.selector); + vm.cool(address(coldReadTarget)); + vm.coolSlot(address(coldReadTarget), bytes32(0)); + vm.cool(address(baselineReadTarget)); + vm.coolSlot(address(baselineReadTarget), bytes32(0)); + + target.value(); + + assertEq(probe.readCost(coldReadTarget), probe.readCost(baselineReadTarget)); + } + + function testConcreteCallbackInspectorStateIsIsolated() public { + hookVm.registerSstoreHook(address(target), this.onStoreWithInstrumentation.selector); + bytes memory helperCall = abi.encodeCall(returner.answer, ()); + vm.mockCall(address(returner), helperCall, abi.encode(uint256(99))); + vm.expectCall(address(returner), helperCall, 1); + vm.record(); + vm.recordLogs(); + + target.store(1); + + Vm.Log[] memory logs = vm.getRecordedLogs(); + (, bytes32[] memory callbackWrites) = vm.accesses(address(this)); + assertEq(logs.length, 0); + assertEq(callbackWrites.length, 0); + assertEq(ghostValue, 42); + assertEq(returner.answer(), 99); + } + + function testConcreteCallbackCanWriteUnderStaticcall() public { + uint256 initialLoadCount = loadCount; + + assertEq(target.value(), 0); + assertEq(loadCount, initialLoadCount + 1); + + (bool ok,) = address(target).staticcall(abi.encodeCall(target.store, (1))); + assertFalse(ok); + + (ok,) = address(this).call(abi.encodeCall(this.loadAndRevert, ())); + assertFalse(ok); + assertEq(loadCount, initialLoadCount + 1); + } + + function loadAndRevert() external view { + target.value(); + revert("after load"); + } + + function testConcreteCallbackCannotBeSpoofed() public { + vm.expectRevert("only storage hook"); + this.onStore(address(target), bytes32(0), bytes32(0), bytes32(uint256(99))); + assertEq(ghostValue, 0); + } + + function testConcreteFullStackSloadPreservesResult() public { + address fullStackTarget = address(0xF011); + bytes memory code = new bytes(1028); + for (uint256 i; i < 1024; i++) { + code[i] = bytes1(uint8(0x5f)); + } + code[1024] = bytes1(uint8(0x54)); + code[1025] = bytes1(uint8(0x56)); + code[1026] = bytes1(uint8(0x5b)); + code[1027] = bytes1(uint8(0x00)); + vm.etch(fullStackTarget, code); + vm.store(fullStackTarget, bytes32(0), bytes32(uint256(1026))); + hookVm.registerSloadHook(fullStackTarget, this.noopLoadHook.selector); + + (bool ok,) = fullStackTarget.call(""); + + assertTrue(ok); + } + + function testConcreteStorageHookConsumesCallDepth() public { + StorageHookDepthTarget baseline = new StorageHookDepthTarget(); + StorageHookDepthTarget hooked = new StorageHookDepthTarget(); + hookVm.registerSloadHook(address(hooked), this.noopLoadHook.selector); + + // Without isolation the test contract's frame takes one more call-depth slot. + uint256 depth = vm.isIsolateMode() ? 1024 : 1023; + assertTrue(baseline.recurse(depth)); + assertFalse(hooked.recurse(depth)); + } + + function testConcreteTraceSuccess() public { + (bool ok,) = TRACE_SUCCESS_TARGET.call(""); + assertTrue(ok); + } + + function testConcreteTraceRevert() public { + (bool ok,) = TRACE_REVERT_TARGET.call(""); + assertFalse(ok); + } + + function noopLoadHook(address, bytes32, bytes32) external view onlyStorageHook {} + + function noopStoreHook(address, bytes32, bytes32, bytes32) external view onlyStorageHook {} + + function revertingStoreHook(address, bytes32, bytes32, bytes32) + external + onlyStorageHook + { + ghostValue = 99; + revert("replacement hook"); + } + + function panickingStoreHook(address, bytes32, bytes32, bytes32) + external + onlyStorageHook + { + ghostValue = 99; + assert(false); + } + + function recursiveStoreHook(address, bytes32, bytes32, bytes32) external onlyStorageHook { + recursiveHookCalls++; + } + + function branchingStoreHook(address, bytes32, bytes32, bytes32 newValue) + external + view + onlyStorageHook + { + if (uint256(newValue) == 7) { + revert("seven"); + } + } + + function checkSymbolicMapping(address account, uint256 newValue) public { + target.setBalance(account, newValue); + bytes32 expectedSlot = keccak256(abi.encode(account, uint256(1))); + assertEq(lastAccount, address(target)); + assertEq(lastSlot, expectedSlot); + assertEq(ghostValue, newValue); + } + + /// forge-config: default.symbolic.storage_layout = "zero_init" + function checkSymbolicAliasedMappingWrites( + address first, + address second, + uint256 firstValue, + uint256 secondValue + ) public { + vm.assume(first == second); + mappingTarget.setBalance(first, firstValue); + mappingTarget.setBalance(second, secondValue); + + assertEq(mappingGhost, mappingTarget.balances(first)); + } + + function checkSymbolicPreexistingMappingWrites(uint256 firstValue, uint256 secondValue) public { + baseStateTarget.setBalance(EXISTING_ACCOUNT, firstValue); + baseStateTarget.setBalance(EXISTING_ACCOUNT, secondValue); + + assertEq(baseStateGhost, secondValue); + } + + function checkSymbolicLoad(uint256 newValue) public { + target.store(newValue); + assertEq(target.value(), newValue); + assertEq(lastAccount, address(target)); + assertEq(lastSlot, bytes32(0)); + assertEq(loadCount, 1); + assertEq(ghostValue, newValue); + } + + function checkSymbolicMultipleWritesAndNestedCall(uint256 first, uint256 second) public { + target.storeTwice(first, second); + assertEq(ghostValue, second); + caller.store(target, first); + assertEq(ghostValue, first); + unregisteredTarget.store(second); + assertEq(ghostValue, first); + } + + function checkSymbolicDelegatecall(uint256 newValue) public { + proxy.store(newValue); + assertEq(lastAccount, address(proxy)); + assertEq(lastSlot, bytes32(0)); + assertEq(ghostValue, newValue); + } + + function checkSymbolicRollback(uint256 newValue) public { + (bool ok,) = address(target).call(abi.encodeCall(target.storeAndRevert, (newValue))); + assertFalse(ok); + assertEq(ghostValue, 0); + assertEq(target.value(), 0); + } + + function checkSymbolicCallbackRevert(uint256 newValue) public { + hookVm.registerSstoreHook(address(target), this.revertingStoreHook.selector); + (bool ok,) = address(target).call(abi.encodeCall(target.store, (newValue))); + assertFalse(ok); + assertEq(ghostValue, 0); + assertEq(target.value(), 0); + } + + function checkSymbolicCallbackPanic(uint256 newValue) public { + hookVm.registerSstoreHook(address(target), this.panickingStoreHook.selector); + (bool ok,) = address(target).call(abi.encodeCall(target.store, (newValue))); + assertFalse(ok); + assertEq(ghostValue, 0); + assertEq(target.value(), 0); + } + + function checkSymbolicCallbackSuppressesRecursiveHooks(uint256 newValue) public { + hookVm.registerSstoreHook(address(this), this.recursiveStoreHook.selector); + + target.store(newValue); + + assertEq(ghostValue, newValue); + assertEq(recursiveHookCalls, 0); + } + + function checkSymbolicCallbackPreservesPendingExpectations(uint256 newValue) public { + vm.expectEmit(address(target)); + emit Stored(newValue); + + target.storeAndEmit(newValue); + + assertEq(ghostValue, newValue); + } + + function checkSymbolicCallbackPreservesReturnData(uint256 newValue) public { + assertEq(target.storeAfterReturningCall(address(returner), newValue), 32); + } + + function checkSymbolicCallbackInspectorStateIsIsolated(uint256 newValue) public { + hookVm.registerSstoreHook(address(target), this.onStoreWithRecording.selector); + vm.record(); + vm.recordLogs(); + + target.store(newValue); + + Vm.Log[] memory logs = vm.getRecordedLogs(); + (, bytes32[] memory callbackWrites) = vm.accesses(address(this)); + assertEq(logs.length, 0); + assertEq(callbackWrites.length, 0); + assertEq(ghostValue, 42); + } + + function checkSymbolicCallbackCanWriteUnderStaticcall(uint256 newValue) public { + target.store(newValue); + uint256 initialLoadCount = loadCount; + + assertEq(target.value(), newValue); + assertEq(loadCount, initialLoadCount + 1); + + (bool ok,) = address(target).staticcall(abi.encodeCall(target.store, (newValue))); + assertFalse(ok); + + (ok,) = address(this).call(abi.encodeCall(this.loadAndRevert, ())); + assertFalse(ok); + assertEq(loadCount, initialLoadCount + 1); + } + + function checkSymbolicConstructorRegistration(uint256 newValue) public { + StorageHookTarget constructorTarget = new StorageHookTarget(); + ConstructorStorageHook hook = new ConstructorStorageHook(address(constructorTarget)); + + constructorTarget.store(newValue); + + assertEq(hook.ghostValue(), newValue); + } + + function checkSymbolicFinalOpcodeCallbackBranch(uint256 newValue) public { + (bool ok,) = FINAL_OPCODE_TARGET.call(abi.encode(newValue)); + assertEq(ok, newValue != 7); + } +} +"#, + ); + + let args = [ + "test", + "--match-contract", + "StorageHooksTest", + "--match-test", + "test(Concrete|Isolate)", + "--gas-limit", + "10000000000000", + "--disable-block-gas-limit", + ]; + cmd.forge_fuse().args(args).arg("--isolate").assert_success(); + cmd.forge_fuse().args(args).arg("--no-isolate").assert_success(); + + for isolate in ["--isolate", "--no-isolate"] { + let output = cmd + .forge_fuse() + .args([ + "test", + "--match-contract", + "StorageHooksTest", + "--match-test", + "testConcreteTrace", + "-vvvvv", + "--json", + ]) + .arg(isolate) + .assert_success() + .get_output() + .stdout + .clone(); + let output: serde_json::Value = serde_json::from_slice(&output).unwrap(); + let suite = output.as_object().unwrap().values().next().unwrap(); + + let success = &suite["test_results"]["testConcreteTraceSuccess()"]; + let success_target = success["traces"] + .as_array() + .unwrap() + .iter() + .flat_map(|trace| trace[1]["arena"].as_array().unwrap()) + .find(|node| { + node["trace"]["address"].as_str().is_some_and(|address| { + address.ends_with("00000000000000000000000000000000000a11ce") + }) + }) + .unwrap(); + let resumed_step = success_target["trace"]["steps"] + .as_array() + .unwrap() + .iter() + .find(|step| step["pc"] == 5) + .unwrap(); + let sstore_step = success_target["trace"]["steps"] + .as_array() + .unwrap() + .iter() + .find(|step| step["pc"] == 4) + .unwrap(); + assert_eq!( + resumed_step["gas_remaining"].as_u64().unwrap(), + sstore_step["gas_remaining"].as_u64().unwrap() + - sstore_step["gas_cost"].as_u64().unwrap() + ); + assert_eq!(resumed_step["gas_cost"], 3); + + let reverted = &suite["test_results"]["testConcreteTraceRevert()"]; + let reverted_target = reverted["traces"] + .as_array() + .unwrap() + .iter() + .flat_map(|trace| trace[1]["arena"].as_array().unwrap()) + .find(|node| { + node["trace"]["address"].as_str().is_some_and(|address| { + address.ends_with("0000000000000000000000000000000000000b0b") + }) + }) + .unwrap(); + assert!( + reverted_target["trace"]["steps"] + .as_array() + .unwrap() + .iter() + .all(|step| step["pc"] != 5) + ); + } + + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic half of storage_hook_cheatcodes_concrete_and_symbolic because z3 is not available" + ); + return; + } + + assert_symbolic_witness(cmd.forge_fuse().args([ + "test", + "--symbolic", + "--match-contract", + "StorageHooksTest", + "--match-test", + "checkSymbolic", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 16 tests for test/StorageHooks.t.sol:StorageHooksTest +[PASS] checkSymbolicAliasedMappingWrites(address,address,uint256,uint256) ([METRICS]) +[PASS] checkSymbolicCallbackCanWriteUnderStaticcall(uint256) ([METRICS]) +[PASS] checkSymbolicCallbackInspectorStateIsIsolated(uint256) ([METRICS]) +[PASS] checkSymbolicCallbackPanic(uint256) ([METRICS]) +[PASS] checkSymbolicCallbackPreservesPendingExpectations(uint256) ([METRICS]) +[PASS] checkSymbolicCallbackPreservesReturnData(uint256) ([METRICS]) +[PASS] checkSymbolicCallbackRevert(uint256) ([METRICS]) +[PASS] checkSymbolicCallbackSuppressesRecursiveHooks(uint256) ([METRICS]) +[PASS] checkSymbolicConstructorRegistration(uint256) ([METRICS]) +[PASS] checkSymbolicDelegatecall(uint256) ([METRICS]) +[PASS] checkSymbolicFinalOpcodeCallbackBranch(uint256) ([METRICS]) +[PASS] checkSymbolicLoad(uint256) ([METRICS]) +[PASS] checkSymbolicMapping(address,uint256) ([METRICS]) +[PASS] checkSymbolicMultipleWritesAndNestedCall(uint256,uint256) ([METRICS]) +[PASS] checkSymbolicPreexistingMappingWrites(uint256,uint256) ([METRICS]) +[PASS] checkSymbolicRollback(uint256) ([METRICS]) +Suite result: ok. 16 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn storage_hook_callbacks_do_not_leak_fuzz_guidance(prj: _, cmd: _) { + prj.update_config(|config| { + config.fuzz.runs = 32; + config.fuzz.corpus.corpus_dir = Some("fuzz_corpus".into()); + }); + prj.add_test( + "StorageHookFuzzGuidance.t.sol", + r#" +import "forge-std/Test.sol"; + +interface StorageHookVm { + function registerSstoreHook(address target, bytes4 callback) external; +} + +contract StorageHookFuzzTarget { + uint256 public value; + + function store(uint256 newValue) external { + value = newValue; + } +} + +contract StorageHookFuzzHelper { + function observe(uint256 value) external pure returns (uint256) { + if (value == 7) { + return 7; + } + return value; + } +} + +contract StorageHookFuzzCallback { + StorageHookVm constant hookVm = + StorageHookVm(address(uint160(uint256(keccak256("hevm cheat code"))))); + address constant HELPER = address(0x3000); + + uint256 public hits; + + function register(address target) external { + hookVm.registerSstoreHook(target, this.onStore.selector); + } + + function onStore(address, bytes32, bytes32, bytes32 newValue) external { + require(msg.sender == address(hookVm), "only storage hook"); + hits++; + assertEq(StorageHookFuzzHelper(HELPER).observe(uint256(newValue)), uint256(newValue)); + } + + function assertEq(uint256 left, uint256 right) internal pure { + require(left == right); + } +} + +contract StorageHookFuzzGuidanceTest is Test { + address constant TARGET = address(0x1000); + address constant CALLBACK = address(0x2000); + address constant HELPER = address(0x3000); + + function setUp() public { + StorageHookFuzzTarget target = new StorageHookFuzzTarget(); + StorageHookFuzzCallback callback = new StorageHookFuzzCallback(); + StorageHookFuzzHelper helper = new StorageHookFuzzHelper(); + vm.etch(TARGET, address(target).code); + vm.etch(CALLBACK, address(callback).code); + vm.etch(HELPER, address(helper).code); + StorageHookFuzzCallback(CALLBACK).register(TARGET); + } + + function testFuzz_hookGuidance(uint256 value) public { + StorageHookFuzzTarget(TARGET).store(value); + assertEq(uint256(vm.load(CALLBACK, bytes32(0))), 1); + if (value == 42) { + assertEq(StorageHookFuzzTarget(TARGET).value(), 42); + } + } +} +"#, + ); + + cmd.forge_fuse() + .args([ + "test", + "--match-test", + "testFuzz_hookGuidance", + "--fuzz-seed", + "0x1234", + "--threads", + "1", + "--fuzz-frontier-dir", + "fuzz_frontiers", + ]) + .assert_success(); + + let frontier_path = prj + .root() + .join("fuzz_frontiers") + .join("StorageHookFuzzGuidanceTest") + .join("testFuzz_hookGuidance") + .join("branch-frontiers.json"); + let artifact: serde_json::Value = + serde_json::from_slice(&std::fs::read(frontier_path).unwrap()).unwrap(); + let frontiers = artifact["frontiers"].as_array().unwrap(); + assert!(!frontiers.is_empty(), "missing user-code frontiers in {artifact:#}"); + let instrumentation_addresses = [ + "0x0000000000000000000000000000000000002000", + "0x0000000000000000000000000000000000003000", + ]; + assert!( + frontiers.iter().all(|frontier| { + let address = frontier["site"]["address"].as_str().unwrap(); + !instrumentation_addresses + .iter() + .any(|candidate| address.eq_ignore_ascii_case(candidate)) + }), + "{artifact:#}" + ); + + cmd.forge_fuse() + .args([ + "test", + "--match-test", + "testFuzz_hookGuidance", + "--showmap-out", + "showmap", + "--showmap-corpus-dir", + "fuzz_corpus", + "--showmap-trial", + "hook", + ]) + .assert_success(); + + let runtime_hash_prefix = |artifact: &str| { + let artifact: serde_json::Value = + serde_json::from_slice(&std::fs::read(prj.root().join(artifact)).unwrap()).unwrap(); + let bytecode = artifact["deployedBytecode"]["object"].as_str().unwrap(); + let bytecode = alloy_primitives::hex::decode(bytecode.trim_start_matches("0x")).unwrap(); + alloy_primitives::hex::encode(&alloy_primitives::keccak256(bytecode)[..8]) + }; + let callback_hash = + runtime_hash_prefix("out/StorageHookFuzzGuidance.t.sol/StorageHookFuzzCallback.json"); + let helper_hash = + runtime_hash_prefix("out/StorageHookFuzzGuidance.t.sol/StorageHookFuzzHelper.json"); + let target_hash = + runtime_hash_prefix("out/StorageHookFuzzGuidance.t.sol/StorageHookFuzzTarget.json"); + let mut pending = vec![prj.root().join("showmap")]; + let mut showmap_files = 0; + let mut saw_target_coverage = false; + while let Some(path) = pending.pop() { + for entry in std::fs::read_dir(path).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + pending.push(path); + continue; + } + let body = std::fs::read_to_string(&path).unwrap(); + assert!(!body.is_empty(), "empty showmap file: {}", path.display()); + showmap_files += 1; + saw_target_coverage |= body.contains(&format!("evm_{target_hash}_")); + assert!(!body.contains(&format!("evm_{callback_hash}_")), "{body}"); + assert!(!body.contains(&format!("evm_{helper_hash}_")), "{body}"); + } + } + assert!(showmap_files > 0, "no showmap files were produced"); + assert!(saw_target_coverage, "showmap did not contain target coverage"); +} + +#[forgetest_init] +fn symbolic_mapping_storage_hooks(prj: _, cmd: _) { + skip_unless_z3!("symbolic_mapping_storage_hooks"); + prj.update_config(|config| config.invariant.runs = 0); + prj.add_test( + "SymbolicMappingStorageHooks.t.sol", + r#" +import "forge-std/Test.sol"; + +interface IHookVm { + function registerSstoreHook(address, bytes4) external; + function registerMappingSstoreHook(address, bytes32, bytes4) external; +} + +contract MappingTarget { + bytes32 public constant SLOT_42 = + 0x64d962e4eec2a0d2e4053fc69d3b480f61c5923c09e4bad52cdeec343ff95073; + + mapping(uint256 => uint256) values; + mapping(uint256 => mapping(uint256 => uint256)) nested; + function set(uint256 key, uint256 value) external { values[key] = value; } + function setNested(uint256 outer, uint256 inner, uint256 value) external { + nested[outer][inner] = value; + } + function get(uint256 key) external view returns (uint256) { return values[key]; } + function computedSlot(uint256 key) external pure returns (bytes32) { + return keccak256(abi.encode(key, uint256(0))); + } + function computedSlot(uint256 key, uint256 root) external pure returns (bytes32) { + return keccak256(abi.encode(key, root)); + } + function directStore(bytes32 slot, uint256 value) external { + assembly { sstore(slot, value) } + } + function equivalentStore(uint256 key, uint256 value, uint256 delta) external { + bytes32 slot = keccak256(abi.encode(key, uint256(0))); + assembly { + slot := sub(add(slot, delta), delta) + sstore(slot, value) + } + } + function conditionalStore(uint256 key, uint256 value, uint256 control) external { + bytes32 slot = keccak256(abi.encode(key, uint256(0))); + assembly { sstore(add(slot, and(control, 1)), value) } + } + function constrainedConstantStore(uint256 key, uint256 value) external { + bytes32 observed = keccak256(abi.encode(key, uint256(0))); + require(key == 1); + bytes32 slot = keccak256(abi.encode(uint256(1), uint256(0))); + assembly { sstore(slot, value) } + require(observed == slot); + } + function foldedConstantStore(uint256 key, uint256 value) external { + bytes32 observed = keccak256(abi.encode(key, uint256(0))); + require(observed == SLOT_42); + bytes32 slot = SLOT_42; + assembly { sstore(slot, value) } + } + function constrainedRootStore(uint256 key, uint256 root, uint256 value) external { + require(root == 0); + bytes32 slot = keccak256(abi.encode(key, root)); + assembly { sstore(slot, value) } + } + function constrainedNestedRootStore( + uint256 outer, + uint256 inner, + uint256 root, + uint256 value + ) external { + require(root == 1); + bytes32 parent = keccak256(abi.encode(outer, root)); + bytes32 slot = keccak256(abi.encode(inner, parent)); + assembly { sstore(slot, value) } + } + function symbolicSizeStore(uint256 key, uint256 value, uint256 size) external { + require(size <= 64); + assembly { + mstore(0, key) + mstore(32, 0) + sstore(keccak256(0, size), value) + } + } + function offsetStore(uint256 key, uint256 value) external { + bytes32 slot = bytes32(uint256(keccak256(abi.encode(key, uint256(0)))) + 1); + assembly { sstore(slot, value) } + } + function incompleteStore(uint256 key, uint256 value) external { + bytes32 slot = keccak256(abi.encodePacked(key)); + assembly { sstore(slot, value) } + } + function setThenRevert(uint256 key, uint256 value) external { + values[key] = value; + revert("target rollback"); + } +} + +contract MappingHashHelper { + function computedSlot(uint256 key) external pure returns (bytes32) { + return keccak256(abi.encode(key, uint256(0))); + } +} + +contract DelegateImplementation { + function set(uint256 key, uint256 value) external { + bytes32 slot = keccak256(abi.encode(key, uint256(41))); + assembly { sstore(slot, value) } + } +} + +contract DelegateProxy { + function run(address implementation, uint256 key, uint256 value) external { + (bool ok,) = implementation.delegatecall(abi.encodeCall(DelegateImplementation.set, (key, value))); + require(ok); + } +} + +contract RevertingDelegateHelper { + function computeThenRevert(uint256 key) external pure { + bytes32 slot = keccak256(abi.encode(key, uint256(51))); + assembly { mstore(0, slot) revert(0, 32) } + } +} + +contract RevertCatcher { + function run(address helper, uint256 key, uint256 value) external { + (bool ok, bytes memory data) = helper.delegatecall(abi.encodeCall(RevertingDelegateHelper.computeThenRevert, (key))); + require(!ok); + bytes32 slot = abi.decode(data, (bytes32)); + assembly { sstore(slot, value) } + } +} + +contract ConstructorTarget { + constructor() { + bytes32 slot = keccak256(abi.encode(uint256(62), uint256(61))); + assembly { sstore(slot, 63) } + } +} + +contract StaleHandler { + MappingTarget target; + bytes32 slot; + + constructor(MappingTarget target_) { target = target_; } + function produce(uint256 key) external { slot = target.computedSlot(key); } + function consume(uint256 value) external { target.directStore(slot, value); } +} + +contract MappingRegistrationHandler { + IHookVm constant hookVm = IHookVm(address(uint160(uint256(keccak256("hevm cheat code"))))); + uint256 public calls; + + modifier onlyStorageHook() { + require(msg.sender == address(hookVm), "only storage hook"); + _; + } + + function registerThenRevert(address target) external { + hookVm.registerMappingSstoreHook(target, bytes32(0), this.onStore.selector); + revert("after registration"); + } + + function onStore(address, bytes32, bytes32, bytes32[] calldata, bytes32, bytes32) + external + onlyStorageHook + { + calls++; + } +} + +contract AccountingToken { + mapping(address => uint256) public balances; + mapping(address => mapping(address => uint256)) public allowances; + uint256 public totalSupply; + + function mint(address to, uint256 amount) external { + balances[to] += amount; + totalSupply += amount; + } + + function transfer(address from, address to, uint256 amount) external { + require(balances[from] >= amount, "balance"); + balances[from] -= amount; + balances[to] += amount; + } + + function burn(address from, uint256 amount) external { + require(balances[from] >= amount, "balance"); + balances[from] -= amount; + totalSupply -= amount; + } + + function approve(address owner, address spender, uint256 amount) external { + allowances[owner][spender] = amount; + } + + function mintThenRevert(address to, uint256 amount) external { + balances[to] += amount; + totalSupply += amount; + revert("token rollback"); + } +} + +contract SymbolicMappingStorageHooks is Test { + IHookVm constant hookVm = IHookVm(address(uint160(uint256(keccak256("hevm cheat code"))))); + MappingTarget target; + MappingTarget rawTarget; + AccountingToken token; + uint256 seenKey; + uint256 seenOuterKey; + uint256 seenInnerKey; + uint256 seenOld; + uint256 seenNew; + bytes32 seenSlot; + bytes32 callbackSlot; + uint256 calls; + uint256 nestedCalls; + uint256 unexpectedCalls; + uint256 implementationCalls; + uint256 ghostSum; + address expectedAccount; + address seenBalanceHolder; + + modifier onlyStorageHook() { + require(msg.sender == address(hookVm), "only storage hook"); + _; + } + + function setUp() public { + target = new MappingTarget(); + rawTarget = new MappingTarget(); + token = new AccountingToken(); + hookVm.registerMappingSstoreHook(address(target), bytes32(0), this.onStore.selector); + hookVm.registerMappingSstoreHook(address(target), bytes32(uint256(1)), this.onNestedStore.selector); + hookVm.registerMappingSstoreHook(address(token), bytes32(0), this.onBalanceStore.selector); + hookVm.registerSstoreHook(address(rawTarget), this.onRawStore.selector); + } + + /// forge-config: default.symbolic.storage_layout = "zero_init" + function checkMappingDispatch(uint256 key, uint256 first, uint256 second) public { + target.set(key, first); + assertEq(seenSlot, keccak256(abi.encode(key, uint256(0)))); + assertEq(seenKey, key); + assertEq(seenOld, 0); + assertEq(seenNew, first); + target.set(key, second); + assertEq(seenOld, first); + assertEq(seenNew, second); + assertEq(target.get(key), second); + assertEq(seenKey, key); + } + + function checkNestedMappingRootAndKeys(uint256 inner, uint256 value) public { + uint256 outer = 11; + bytes32 intermediateRoot = keccak256(abi.encode(outer, uint256(1))); + hookVm.registerMappingSstoreHook( + address(target), intermediateRoot, this.onUnexpectedStore.selector + ); + target.setNested(outer, inner, value); + + assertEq(seenOuterKey, outer); + assertEq(seenInnerKey, inner); + assertEq(seenSlot, keccak256(abi.encode(inner, intermediateRoot))); + assertEq(nestedCalls, 1); + assertEq(unexpectedCalls, 0); + } + + /// forge-config: default.symbolic.storage_layout = "zero_init" + function checkErc20BalanceAccounting( + address from, + address to, + address third, + uint128 minted, + uint128 moved, + uint128 burned, + uint128 allowance + ) public { + vm.assume(from != to); + vm.assume(third != from && third != to); + vm.assume(moved <= minted); + vm.assume(burned <= moved); + + token.mint(from, minted); + assertEq(seenBalanceHolder, from); + assertAccounting(from, to); + + token.transfer(from, to, moved); + assertEq(seenBalanceHolder, to); + assertAccounting(from, to); + + uint256 ghostBeforeApproval = ghostSum; + token.approve(from, to, allowance); + assertEq(ghostSum, ghostBeforeApproval); + assertAccounting(from, to); + + token.burn(to, burned); + assertEq(seenBalanceHolder, to); + assertAccounting(from, to); + + assertRevertsDoNotChangeAccounting(from, to); + + token.mint(third, 1); + assertEq(seenBalanceHolder, third); + assertEq(ghostSum, token.totalSupply()); + assertNotEq(ghostSum, token.balances(from) + token.balances(to)); + } + + function assertRevertsDoNotChangeAccounting(address from, address to) internal { + uint256 fromBeforeRollback = token.balances(from); + uint256 supplyBeforeRollback = token.totalSupply(); + uint256 ghostBeforeRollback = ghostSum; + (bool rollbackOk,) = address(token).call( + abi.encodeCall(token.mintThenRevert, (from, uint256(1))) + ); + assertFalse(rollbackOk); + assertEq(token.balances(from), fromBeforeRollback); + assertEq(token.totalSupply(), supplyBeforeRollback); + assertEq(ghostSum, ghostBeforeRollback); + + uint256 fromBefore = token.balances(from); + uint256 toBefore = token.balances(to); + uint256 supplyBefore = token.totalSupply(); + uint256 ghostBeforeRevert = ghostSum; + (bool ok,) = address(token).call( + abi.encodeCall(token.transfer, (from, to, fromBefore + 1)) + ); + assertFalse(ok); + assertEq(token.balances(from), fromBefore); + assertEq(token.balances(to), toBefore); + assertEq(token.totalSupply(), supplyBefore); + assertEq(ghostSum, ghostBeforeRevert); + } + + function assertAccounting(address first, address second) internal view { + assertEq(ghostSum, token.totalSupply()); + assertEq(ghostSum, token.balances(first) + token.balances(second)); + } + + function checkOrdinaryCallProvenance(uint256 key, uint256 value) public { + bytes32 slot = target.computedSlot(key); + target.directStore(slot, value); + assertEq(calls, 1); + assertEq(seenKey, key); + assertEq(seenSlot, slot); + } + + function checkConstraintEquivalentProvenance(uint256 key, uint256 value, uint256 delta) public { + target.equivalentStore(key, value, delta); + assertEq(calls, 1); + assertEq(seenKey, key); + assertEq(seenSlot, keccak256(abi.encode(key, uint256(0)))); + } + + function checkConditionalProvenance(uint256 key, uint256 value, uint256 control) public { + target.conditionalStore(key, value, control); + if (control & 1 == 0) { + assertEq(calls, 1); + assertEq(seenKey, key); + } else { + assertEq(calls, 0); + } + } + + function checkConstraintEquivalentConstantSlot(uint256 key, uint256 value) public { + target.constrainedConstantStore(key, value); + assertEq(calls, 1); + assertEq(seenKey, 1); + } + + function checkConstraintEquivalentFoldedSlot(uint256 key, uint256 value) public { + target.foldedConstantStore(key, value); + assertEq(calls, 1); + assertEq(seenKey, key); + assertEq(seenSlot, target.SLOT_42()); + } + + function checkConstraintEquivalentKeccakShape( + uint256 key, + uint256 equivalentKey, + uint256 value + ) public { + MappingHashHelper helper = new MappingHashHelper(); + bytes32 slot = target.computedSlot(key); + bytes32 equivalentSlot = helper.computedSlot(equivalentKey); + vm.assume(key == equivalentKey); + target.directStore(equivalentSlot, value); + assertEq(slot, equivalentSlot); + assertEq(calls, 1); + assertEq(seenKey, key); + } + + function checkConstraintEquivalentRoot(uint256 key, uint256 root, uint256 value) public { + target.constrainedRootStore(key, root, value); + assertEq(calls, 1); + assertEq(seenKey, key); + } + + function checkConstraintEquivalentNestedRoot( + uint256 outer, + uint256 inner, + uint256 root, + uint256 value + ) public { + target.constrainedNestedRootStore(outer, inner, root, value); + assertEq(nestedCalls, 1); + assertEq(seenOuterKey, outer); + assertEq(seenInnerKey, inner); + } + + function checkCallbackSubtreeSuppression(uint256 key, uint256 value) public { + hookVm.registerMappingSstoreHook(address(target), bytes32(uint256(71)), this.onUnexpectedStore.selector); + target.set(key, value); + assertEq(calls, 1); + assertEq(callbackSlot, keccak256(abi.encode(key, uint256(71)))); + assertEq(target.get(key), value); + assertEq(calls, 1); + assertEq(unexpectedCalls, 0); + } + + function checkLateRootRegistrationClearsProvenance(uint256 key, uint256 value) public { + bytes32 root = bytes32(uint256(72)); + bytes32 slot = target.computedSlot(key, uint256(root)); + hookVm.registerMappingSstoreHook(address(target), root, this.onUnexpectedStore.selector); + + target.directStore(slot, value); + + assertEq(unexpectedCalls, 0); + } + + function checkCallbackRegistrationClearsParentProvenance( + uint256 key, + uint256 triggerKey, + uint256 value + ) public { + hookVm.registerMappingSstoreHook(address(target), bytes32(0), this.onRegisterRoot.selector); + bytes32 staleSlot = target.computedSlot(key, 73); + + target.set(triggerKey, value); + target.directStore(staleSlot, value); + + assertEq(unexpectedCalls, 0); + } + + function checkOffsetsAndIncompleteHashesDoNotDispatch(uint256 key, uint256 value) public { + target.offsetStore(key, value); + target.incompleteStore(key, value); + assertEq(calls, 0); + } + + function checkDelegatecallUsesProxyAccount(uint256 key, uint256 value) public { + DelegateImplementation implementation = new DelegateImplementation(); + DelegateProxy proxy = new DelegateProxy(); + expectedAccount = address(proxy); + hookVm.registerMappingSstoreHook(address(proxy), bytes32(uint256(41)), this.onProxyStore.selector); + hookVm.registerMappingSstoreHook(address(implementation), bytes32(uint256(41)), this.onImplementationStore.selector); + + proxy.run(address(implementation), key, value); + assertEq(calls, 1); + assertEq(implementationCalls, 0); + } + + function checkCaughtRevertingDelegatecallKeepsProvenance(uint256 key) public { + RevertingDelegateHelper helper = new RevertingDelegateHelper(); + RevertCatcher catcher = new RevertCatcher(); + expectedAccount = address(catcher); + hookVm.registerMappingSstoreHook(address(catcher), bytes32(uint256(51)), this.onProxyStore.selector); + catcher.run(address(helper), key, 1); + assertEq(calls, 1); + } + + function checkConstructorProvenance() public { + bytes32 salt = bytes32(uint256(0xC0DE)); + address predicted = vm.computeCreate2Address(salt, keccak256(type(ConstructorTarget).creationCode), address(this)); + expectedAccount = predicted; + hookVm.registerMappingSstoreHook(predicted, bytes32(uint256(61)), this.onProxyStore.selector); + ConstructorTarget created = new ConstructorTarget{salt: salt}(); + assertEq(address(created), predicted); + assertEq(calls, 1); + } + + function checkConflictsAreCatchable() public { + (bool storeOk,) = address(hookVm).call(abi.encodeCall(hookVm.registerSstoreHook, (address(target), this.onRawStore.selector))); + assertFalse(storeOk); + (storeOk,) = address(hookVm).call(abi.encodeCall(hookVm.registerMappingSstoreHook, (address(rawTarget), bytes32(0), this.onStore.selector))); + assertFalse(storeOk); + } + + /// forge-config: default.symbolic.storage_layout = "zero_init" + function checkRegistrationAndRollbackLifecycle(uint256 key, uint256 value) public { + MappingTarget registeredTarget = new MappingTarget(); + MappingRegistrationHandler handler = new MappingRegistrationHandler(); + (bool registrationOk,) = address(handler).call( + abi.encodeCall(handler.registerThenRevert, (address(registeredTarget))) + ); + assertFalse(registrationOk); + registeredTarget.set(key, value); + assertEq(handler.calls(), 1); + + (bool targetOk,) = address(target).call( + abi.encodeCall(target.setThenRevert, (key, value)) + ); + assertFalse(targetOk); + assertEq(calls, 0); + assertEq(target.get(key), 0); + + hookVm.registerMappingSstoreHook(address(target), bytes32(0), this.onRevertingStore.selector); + (bool callbackOk,) = address(target).call(abi.encodeCall(target.set, (key, value))); + assertFalse(callbackOk); + assertEq(target.get(key), 0); + } + + function onStore(address account, bytes32 slot, bytes32 root, bytes32[] calldata keys, bytes32 oldValue, bytes32 newValue) + external + onlyStorageHook + { + assertEq(account, address(target)); + assertEq(root, bytes32(0)); + assertEq(keys.length, 1); + seenKey = uint256(keys[0]); + seenSlot = slot; + seenOld = uint256(oldValue); + seenNew = uint256(newValue); + calls++; + callbackSlot = target.computedSlot(uint256(keys[0]), uint256(71)); + target.directStore(callbackSlot, 1); + } + function onNestedStore(address account, bytes32 slot, bytes32 root, bytes32[] calldata keys, bytes32, bytes32) + external + onlyStorageHook + { + assertEq(account, address(target)); + assertEq(root, bytes32(uint256(1))); + assertEq(keys.length, 2); + seenOuterKey = uint256(keys[0]); + seenInnerKey = uint256(keys[1]); + seenSlot = slot; + nestedCalls++; + } + function onBalanceStore(address account, bytes32, bytes32 root, bytes32[] calldata keys, bytes32 oldValue, bytes32 newValue) + external + onlyStorageHook + { + assertEq(account, address(token)); + assertEq(root, bytes32(0)); + assertEq(keys.length, 1); + seenBalanceHolder = address(uint160(uint256(keys[0]))); + ghostSum = ghostSum - uint256(oldValue) + uint256(newValue); + } + function onRawStore(address, bytes32, bytes32, bytes32) external onlyStorageHook {} + function onUnexpectedStore(address, bytes32, bytes32, bytes32[] calldata, bytes32, bytes32) + external + onlyStorageHook + { + unexpectedCalls++; + } + function onRegisterRoot(address, bytes32, bytes32, bytes32[] calldata, bytes32, bytes32) + external + onlyStorageHook + { + hookVm.registerMappingSstoreHook( + address(target), bytes32(uint256(73)), this.onUnexpectedStore.selector + ); + } + function onRevertingStore(address, bytes32, bytes32, bytes32[] calldata, bytes32, bytes32) + external + onlyStorageHook + { + revert("hook rollback"); + } + function onProxyStore(address account, bytes32, bytes32, bytes32[] calldata, bytes32, bytes32) + external + onlyStorageHook + { + assertEq(account, expectedAccount); + calls++; + } + function onImplementationStore(address, bytes32, bytes32, bytes32[] calldata, bytes32, bytes32) + external + onlyStorageHook + { + implementationCalls++; + } +} + +contract SymbolicMappingStorageHooksStale is Test { + IHookVm constant hookVm = IHookVm(address(uint160(uint256(keccak256("hevm cheat code"))))); + MappingTarget target; + uint256 calls; + + modifier onlyStorageHook() { + require(msg.sender == address(hookVm), "only storage hook"); + _; + } + + function setUp() public { + target = new MappingTarget(); + hookVm.registerMappingSstoreHook(address(target), bytes32(0), this.onStore.selector); + StaleHandler handler = new StaleHandler(target); + targetContract(address(handler)); + targetSender(address(this)); + } + + /// forge-config: default.symbolic.invariant_depth = 2 + function invariant_staleProvenanceNeverDispatches() public view { assertEq(calls, 0); } + function onStore(address, bytes32, bytes32, bytes32[] calldata, bytes32, bytes32) + external + onlyStorageHook + { + calls++; + } +} + +contract SymbolicMappingStorageHooksSymbolicSize is Test { + IHookVm constant hookVm = IHookVm(address(uint160(uint256(keccak256("hevm cheat code"))))); + MappingTarget target; + uint256 calls; + + function setUp() public { + target = new MappingTarget(); + hookVm.registerMappingSstoreHook(address(target), bytes32(0), this.onStore.selector); + } + + function checkSymbolicKeccakSize(uint256 key, uint256 value, uint256 size) public { + target.symbolicSizeStore(key, value, size); + assertEq(calls, 0); + } + + function testConcreteKeccakSize() public { + target.symbolicSizeStore(1, 2, 64); + assertEq(calls, 1); + } + + function onStore(address, bytes32, bytes32, bytes32[] calldata, bytes32, bytes32) external { + require(msg.sender == address(hookVm), "only storage hook"); + calls++; + } +} +"#, + ); + cmd.args(["test", "--symbolic", "--match-contract", "^SymbolicMappingStorageHooks$"]) + .assert_success(); + + cmd.forge_fuse(); + let output = cmd + .args([ + "test", + "--symbolic", + "--json", + "--match-contract", + "^SymbolicMappingStorageHooksStale$", + ]) + .assert_success() + .get_output() + .stdout + .clone(); + let result = json_test_result(&output, "invariant_staleProvenanceNeverDispatches()"); + assert_eq!(result["symbolic"]["status"], "pass", "{}", result["symbolic"]); + + cmd.forge_fuse(); + cmd.args([ + "test", + "--match-contract", + "^SymbolicMappingStorageHooksSymbolicSize$", + "--match-test", + "testConcreteKeccakSize", + ]) + .assert_success(); + + cmd.forge_fuse(); + let output = cmd + .args([ + "test", + "--symbolic", + "--json", + "--match-contract", + "^SymbolicMappingStorageHooksSymbolicSize$", + ]) + .assert_failure() + .get_output() + .stdout + .clone(); + let result = json_test_result(&output, "checkSymbolicKeccakSize(uint256,uint256,uint256)"); + assert_eq!(result["symbolic"]["status"], "incomplete", "{}", result["symbolic"]); + assert_eq!( + result["symbolic"]["incomplete"]["reason"], + "unsupported symbolic execution feature: symbolic KECCAK256 size may conceal mapping provenance", + "{}", + result["symbolic"] + ); +} diff --git a/crates/forge/tests/cli/test_cmd/symbolic_conformance.rs b/crates/forge/tests/cli/test_cmd/symbolic_conformance.rs new file mode 100644 index 0000000000000..9849224c6d1e8 --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/symbolic_conformance.rs @@ -0,0 +1,1261 @@ +use super::symbolic_helpers::{assert_symbolic, assert_symbolic_witness}; +use foundry_common::sh_eprintln; +use foundry_test_utils::{forgetest_init, snapbox::IntoData, str, util::OutputExt}; +use std::{env, process::Command}; + +fn symbolic_conformance_enabled() -> bool { + env::var_os("SYMBOLIC_CONFORMANCE").is_some() +} + +fn z3_available() -> bool { + Command::new("z3").arg("--version").output().is_ok_and(|output| output.status.success()) +} + +fn should_skip() -> bool { + if !symbolic_conformance_enabled() { + let _ = sh_eprintln!( + "skipping symbolic conformance test because SYMBOLIC_CONFORMANCE is not set" + ); + return true; + } + if !z3_available() { + let _ = sh_eprintln!("skipping symbolic conformance test because z3 is not available"); + return true; + } + false +} + +#[forgetest_init] +fn symbolic_conformance_block_cheatcodes(prj: _, cmd: _) { + if should_skip() { + return; + } + + prj.add_test( + "SymbolicConformanceBlock.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicConformanceBlock is Test { + function checkBlock(uint256 timestamp, uint256 number, uint256 chain) public { + vm.warp(timestamp); + vm.roll(number); + vm.chainId(chain); + + assert(block.timestamp == timestamp); + assert(block.number == number); + assert(block.chainid == chain); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args(["test", "--symbolic", "--match-test", "checkBlock"])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicConformanceBlock.t.sol:SymbolicConformanceBlock +[PASS] checkBlock(uint256,uint256,uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_conformance_halmos_feature_matrix(prj: _) { + if should_skip() { + return; + } + + prj.add_test( + "SymbolicConformanceHalmosFeatureMatrix.t.sol", + r#" +import "forge-std/Test.sol"; + +interface MatrixSvm { + function createCalldata(string calldata name) external returns (bytes memory); +} + +struct MatrixPacket { + bytes payload; + string label; + uint256[] numbers; +} + +contract MatrixToken { + mapping(address => uint256) public balanceOf; + uint256 public totalSupply; + + constructor(address owner) { + totalSupply = 100; + balanceOf[owner] = 100; + } + + function transfer(address from, address to, uint256 amount) external { + if (balanceOf[from] >= amount) { + unchecked { + balanceOf[from] -= amount; + } + balanceOf[to] += amount; + } + } + + function mintBackdoor(address to, uint256 amount) external { + if (amount == 9) { + balanceOf[to] += amount; + } + } +} + +contract MatrixNft { + mapping(uint256 => address) public ownerOf; + mapping(uint256 => address) public getApproved; + + constructor(address owner) { + ownerOf[1] = owner; + } + + function approve(address spender, uint256 id) external { + if (msg.sender == ownerOf[id]) { + getApproved[id] = spender; + } + } + + function transferFrom(address from, address to, uint256 id) external { + if (ownerOf[id] == from && to != address(0) && (msg.sender == from || getApproved[id] == msg.sender)) { + ownerOf[id] = to; + } + } +} + +contract MatrixDispatcher { + bool public hit; + + function trip(uint256 value) external { + if (value == 42) { + hit = true; + } + } + + fallback() external payable {} +} + +contract SymbolicConformanceHalmosFeatureMatrix is Test { + address constant SVM_ADDRESS = address(0xF3993A62377BCd56AE39D773740A5390411E8BC9); + address constant ALICE = address(0xA11CE); + address constant BOB = address(0xB0B); + + MatrixToken token; + MatrixNft nft; + MatrixDispatcher dispatcher; + + function setUp() public { + token = new MatrixToken(ALICE); + nft = new MatrixNft(ALICE); + dispatcher = new MatrixDispatcher(); + vm.deal(address(this), 1 ether); + } + + /// forge-config: default.symbolic.array_lengths = [2, 2, 2, 2, 2] + function checkMatrixDynamicAbi(bytes memory data, string memory text, MatrixPacket memory packet) public pure { + assert(data.length <= 2); + assert(bytes(text).length <= 2); + assert(packet.payload.length <= 2); + assert(bytes(packet.label).length <= 2); + assert(packet.numbers.length <= 2); + } + + function checkMatrixErc20SupplyAccounting(uint256 amount, address to) public { + vm.assume(to != address(0) && to != ALICE && to != BOB); + + token.transfer(ALICE, BOB, amount); + token.mintBackdoor(to, amount); + + uint256 sum = token.balanceOf(ALICE) + token.balanceOf(BOB) + token.balanceOf(to); + assert(sum <= token.totalSupply()); + } + + function checkMatrixErc721ClearsApproval(address spender, address buyer) public { + vm.assume(spender != address(0)); + vm.assume(buyer != address(0) && buyer != ALICE); + + vm.prank(ALICE); + nft.approve(spender, 1); + + vm.prank(ALICE); + nft.transferFrom(ALICE, buyer, 1); + + assert(nft.getApproved(1) == address(0)); + } + + /// forge-config: default.symbolic.max_calldata_bytes = 36 + function checkMatrixCreateCalldataDispatch() public { + bytes memory data = MatrixSvm(SVM_ADDRESS).createCalldata("matrix-dispatch"); + (bool ok,) = address(dispatcher).call(data); + ok; + + assert(!dispatcher.hit()); + } + + function checkMatrixEmptyUnknownTarget(uint256 value) public { + address target = address(0xBEEF); + (bool ok, bytes memory out) = target.call(abi.encodeWithSignature("missing(uint256)", value)); + + assert(ok); + assert(out.length == 0); + } + + function checkMatrixRevertAll(uint256 x) public pure { + require(x != x, "impossible"); + } +} +"#, + ); + + assert_symbolic_witness(prj.forge_command().args([ + "test", + "--symbolic", + "--match-test", + "^(checkMatrixDynamicAbi|checkMatrixErc20SupplyAccounting|checkMatrixErc721ClearsApproval|checkMatrixCreateCalldataDispatch|checkMatrixEmptyUnknownTarget|checkMatrixRevertAll)\\(", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 6 tests for test/SymbolicConformanceHalmosFeatureMatrix.t.sol:SymbolicConformanceHalmosFeatureMatrix +[PASS] checkMatrixCreateCalldataDispatch() ([METRICS]) +[PASS] checkMatrixDynamicAbi(bytes,string,(bytes,string,uint256[])) ([METRICS]) +[PASS] checkMatrixEmptyUnknownTarget(uint256) ([METRICS]) +[FAIL: incomplete symbolic execution (Error): symbolic counterexample did not replay] checkMatrixErc20SupplyAccounting(uint256,address) ([METRICS]) +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkMatrixErc721ClearsApproval(address,address) ([METRICS]) +[FAIL: incomplete symbolic execution (RevertAll): all symbolic paths reverted] checkMatrixRevertAll(uint256) ([METRICS]) +Suite result: FAILED. 3 passed; 3 failed; 0 skipped; [ELAPSED] +... +"#]].unordered()); +} + +#[forgetest_init] +fn symbolic_conformance_halmos_simple_total_price(prj: _, cmd: _) { + if should_skip() { + return; + } + + prj.add_test( + "SymbolicConformanceHalmosTotalPrice.t.sol", + r#" +contract SymbolicConformanceHalmosTotalPrice { + function buggyTotal(uint96 price, uint32 quantity) internal pure returns (uint128) { + unchecked { + uint120 wrapped = uint120(price) * uint120(quantity); + return uint128(wrapped); + } + } + + function fixedTotal(uint96 price, uint32 quantity) internal pure returns (uint128) { + return uint128(price) * uint128(quantity); + } + + function checkBuggyTotal(uint96 price, uint32 quantity) public pure { + uint128 total = buggyTotal(price, quantity); + assert(quantity == 0 || total >= price); + } + + function checkFixedTotal(uint96 price, uint32 quantity) public pure { + uint128 total = fixedTotal(price, quantity); + assert(quantity == 0 || total >= price); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkBuggyTotal", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicConformanceHalmosTotalPrice.t.sol:SymbolicConformanceHalmosTotalPrice +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkBuggyTotal(uint96,uint32) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("Stuck"), "{stdout}"); + assert!(!stdout.contains("RevertAll"), "{stdout}"); + + assert_symbolic_witness(prj.forge_command().args([ + "test", + "--symbolic", + "--match-test", + "checkFixedTotal", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicConformanceHalmosTotalPrice.t.sol:SymbolicConformanceHalmosTotalPrice +[PASS] checkFixedTotal(uint96,uint32) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_conformance_halmos_simple_power_of_two_loop(prj: _, cmd: _) { + if should_skip() { + return; + } + + prj.add_test( + "SymbolicConformanceHalmosPowerOfTwo.t.sol", + r#" +contract SymbolicConformanceHalmosPowerOfTwo { + function bitHack(uint256 x) internal pure returns (bool) { + unchecked { + return x != 0 && (x & (x - 1)) == 0; + } + } + + function loopSpec(uint256 x) internal pure returns (bool) { + for (uint256 i = 0; i < 256; i++) { + if (x == (uint256(1) << i)) { + return true; + } + } + return false; + } + + /// forge-config: default.symbolic.loop = 256 + function checkPowerOfTwoLoop(uint256 x) public pure { + assert(bitHack(x) == loopSpec(x)); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkPowerOfTwoLoop", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicConformanceHalmosPowerOfTwo.t.sol:SymbolicConformanceHalmosPowerOfTwo +[PASS] checkPowerOfTwoLoop(uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic loop bound exceeded"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_conformance_halmos_simple_vault_share_price(prj: _, cmd: _) { + if should_skip() { + return; + } + + prj.add_test( + "SymbolicConformanceHalmosVault.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicConformanceHalmosVaultTarget { + uint256 public totalAssets; + uint256 public totalShares; + + function setTotals(uint256 assets, uint256 shares) external { + totalAssets = assets; + totalShares = shares; + } + + function deposit(uint256 assets) external { + uint256 shares = (assets * totalShares) / totalAssets; + totalAssets += assets; + totalShares += shares; + } + + function mint(uint256 shares) external { + uint256 assets = (shares * totalAssets) / totalShares; + totalAssets += assets; + totalShares += shares; + } +} + +contract SymbolicConformanceHalmosVault is Test { + SymbolicConformanceHalmosVaultTarget vault; + + function setUp() public { + vault = new SymbolicConformanceHalmosVaultTarget(); + } + + function checkDepositPreservesSharePrice() public { + vault.setTotals(1, 1); + + vault.deposit(7); + + assertEq(vault.totalAssets(), 8); + assertEq(vault.totalShares(), 8); + } + + function checkMintCanDiluteSharePrice() public { + vault.setTotals(3, 2); + + vault.mint(1); + + assert(uint256(3) * vault.totalShares() <= vault.totalAssets() * uint256(2)); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkDepositPreservesSharePrice", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicConformanceHalmosVault.t.sol:SymbolicConformanceHalmosVault +[PASS] checkDepositPreservesSharePrice() ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); + + let stdout = assert_symbolic_witness(prj.forge_command().args([ + "test", + "--symbolic", + "--match-test", + "checkMintCanDiluteSharePrice", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicConformanceHalmosVault.t.sol:SymbolicConformanceHalmosVault +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkMintCanDiluteSharePrice() ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("Stuck"), "{stdout}"); + assert!(!stdout.contains("RevertAll"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_conformance_halmos_simple_fork_style_setup(prj: _, cmd: _) { + if should_skip() { + return; + } + + prj.add_test( + "SymbolicConformanceHalmosForkSetup.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicConformanceRuntimeCounter { + uint256 public total; + mapping(address => uint256) public seen; + + function increment(address user) external { + seen[user]++; + total++; + } +} + +contract SymbolicConformanceEmpty {} + +contract SymbolicConformanceHalmosForkSetup is Test { + SymbolicConformanceRuntimeCounter counter; + + function setUp() public { + counter = SymbolicConformanceRuntimeCounter(address(new SymbolicConformanceEmpty())); + vm.etch(address(counter), type(SymbolicConformanceRuntimeCounter).runtimeCode); + vm.store(address(counter), bytes32(uint256(0)), bytes32(uint256(12))); + vm.store(address(counter), keccak256(abi.encode(address(0x1001), uint256(1))), bytes32(uint256(7))); + vm.store(address(counter), keccak256(abi.encode(address(0x1002), uint256(1))), bytes32(uint256(5))); + } + + function checkEtchedStorageInvariant(address user) public view { + // An unwritten mapping slot read at a symbolic key is a fresh symbolic value, so only the + // keys written in `setUp` can be proven against `total`. + vm.assume(user == address(0x1001) || user == address(0x1002)); + assertEq(counter.total(), 12); + assertEq(counter.seen(address(0x1001)), 7); + assertEq(counter.seen(address(0x1002)), 5); + assertLe(counter.seen(user), counter.total()); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkEtchedStorageInvariant", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicConformanceHalmosForkSetup.t.sol:SymbolicConformanceHalmosForkSetup +[PASS] checkEtchedStorageInvariant(address) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic vm.etch"), "{stdout}"); + assert!(!stdout.contains("symbolic SLOAD key"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_conformance_halmos_simple_symbolic_signature_replay(prj: _, cmd: _) { + if should_skip() { + return; + } + + prj.add_test( + "SymbolicConformanceHalmosElection.t.sol", + r#" +import "forge-std/Test.sol"; + +interface SymbolicConformanceSvm { + function createBytes(uint256 len, string calldata name) external returns (bytes memory); +} + +contract SymbolicConformanceElection { + mapping(uint256 => uint256) public votesFor; + mapping(bytes32 => bool) public usedSignature; + + function signatureVoter(bytes memory signature) public pure returns (address voter) { + if (signature.length != 64 && signature.length != 65) { + return address(0); + } + bytes32 word; + assembly { + word := mload(add(signature, 32)) + } + voter = address(uint160(uint256(word >> 96))); + } + + function vote(uint256 proposalId, bool support, address voter, bytes memory signature) external { + require(support); + require(signatureVoter(signature) == voter); + bytes32 signatureId = keccak256(signature); + require(!usedSignature[signatureId]); + usedSignature[signatureId] = true; + votesFor[proposalId]++; + } +} + +contract SymbolicConformanceHalmosElection is Test { + address constant SVM_ADDRESS = address(0xF3993A62377BCd56AE39D773740A5390411E8BC9); + SymbolicConformanceElection election; + + function setUp() public { + election = new SymbolicConformanceElection(); + } + + function checkCannotVoteTwiceWithAlternateSignature(uint256 proposalId, address voter) public { + vm.assume(voter != address(0)); + + bytes memory original = + abi.encodePacked(bytes20(voter), bytes32(uint256(1)), bytes13(uint104(0))); + bytes memory alternate = SymbolicConformanceSvm(SVM_ADDRESS).createBytes(64, "alternate"); + + vm.assume(election.signatureVoter(alternate) == voter); + + election.vote(proposalId, true, voter, original); + assertEq(election.votesFor(proposalId), 1); + + election.vote(proposalId, true, voter, alternate); + assertEq(election.votesFor(proposalId), 1); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkCannotVoteTwiceWithAlternateSignature", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicConformanceHalmosElection.t.sol:SymbolicConformanceHalmosElection +[FAIL: EvmError: Revert; counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkCannotVoteTwiceWithAlternateSignature(uint256,address) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic Halmos compatibility cheatcode"), "{stdout}"); + assert!(!stdout.contains("Stuck"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_conformance_halmos_multicaller_batched_calls(prj: _, cmd: _) { + if should_skip() { + return; + } + + prj.add_test( + "SymbolicConformanceHalmosMulticaller.t.sol", + r#" +contract SymbolicConformanceMulticallTarget { + uint256 public total; + + function add(uint256 value) external returns (uint256) { + total += value; + return total; + } + + function echo(bytes calldata data) external pure returns (bytes memory) { + return data; + } + + function maybeRevert(uint256 value) external pure returns (uint256) { + require(value != 13, "unlucky"); + return value + 1; + } +} + +contract SymbolicConformanceMulticaller { + struct Call { + address target; + uint256 value; + bytes data; + } + + function multicall(Call[] memory calls) external returns (bool[] memory successes, bytes[] memory results) { + successes = new bool[](calls.length); + results = new bytes[](calls.length); + + for (uint256 i; i < calls.length; i++) { + (successes[i], results[i]) = calls[i].target.call{value: calls[i].value}(calls[i].data); + } + } +} + +contract SymbolicConformanceHalmosMulticaller { + SymbolicConformanceMulticallTarget target; + SymbolicConformanceMulticaller multicaller; + + function setUp() public { + target = new SymbolicConformanceMulticallTarget(); + multicaller = new SymbolicConformanceMulticaller(); + } + + /// forge-config: default.symbolic.array_lengths = [2] + function checkMulticallReturndata(uint64 value, bytes memory payload) public { + SymbolicConformanceMulticaller.Call[] memory calls = new SymbolicConformanceMulticaller.Call[](2); + calls[0] = SymbolicConformanceMulticaller.Call({ + target: address(target), + value: 0, + data: abi.encodeCall(target.add, (uint256(value))) + }); + calls[1] = SymbolicConformanceMulticaller.Call({ + target: address(target), + value: 0, + data: abi.encodeCall(target.echo, (payload)) + }); + + (bool[] memory successes, bytes[] memory results) = multicaller.multicall(calls); + + assert(successes[0]); + assert(successes[1]); + assert(abi.decode(results[0], (uint256)) == uint256(value)); + + bytes memory echoed = abi.decode(results[1], (bytes)); + assert(echoed.length == 2); + assert(payload.length == 2); + assert(echoed[0] == payload[0]); + assert(echoed[1] == payload[1]); + } + + function checkMulticallFindsFailedCall(uint64 value) public { + SymbolicConformanceMulticaller.Call[] memory calls = new SymbolicConformanceMulticaller.Call[](1); + calls[0] = SymbolicConformanceMulticaller.Call({ + target: address(target), + value: 0, + data: abi.encodeCall(target.maybeRevert, (uint256(value))) + }); + + (bool[] memory successes,) = multicaller.multicall(calls); + + assert(successes[0]); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkMulticallReturndata", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicConformanceHalmosMulticaller.t.sol:SymbolicConformanceHalmosMulticaller +[PASS] checkMulticallReturndata(uint64,bytes) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("unsupported external CALL"), "{stdout}"); + assert!(!stdout.contains("Stuck"), "{stdout}"); + + let stdout = assert_symbolic(prj.forge_command().args([ + "test", + "--symbolic", + "--match-test", + "checkMulticallFindsFailedCall", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicConformanceHalmosMulticaller.t.sol:SymbolicConformanceHalmosMulticaller +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] calldata=0xc298ec3b000000000000000000000000000000000000000000000000000000000000000d args=[13]] checkMulticallFindsFailedCall(uint64) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("unsupported external CALL"), "{stdout}"); + assert!(!stdout.contains("RevertAll"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_conformance_halmos_invariant_reentrancy_exploit(prj: _, cmd: _) { + if should_skip() { + return; + } + + prj.add_test( + "SymbolicConformanceHalmosReentrancy.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicConformanceVulnerableVault { + mapping(address => uint256) public balanceOf; + + function deposit() external payable { + balanceOf[msg.sender] += msg.value; + } + + function withdraw(uint256 amount) external { + require(balanceOf[msg.sender] >= amount, "balance"); + + (bool ok,) = msg.sender.call{value: amount}(""); + require(ok, "send"); + + unchecked { + balanceOf[msg.sender] -= amount; + } + } +} + +contract SymbolicConformanceReentrancyAttacker { + SymbolicConformanceVulnerableVault public vault; + bool internal entered; + + constructor(SymbolicConformanceVulnerableVault target) { + vault = target; + } + + receive() external payable { + if (!entered) { + entered = true; + vault.withdraw(1 ether); + entered = false; + } + } + + function depositOne() external { + vault.deposit{value: 1 ether}(); + } + + function withdrawOne() external { + vault.withdraw(1 ether); + } +} + +contract SymbolicConformanceHalmosReentrancy is Test { + SymbolicConformanceVulnerableVault vault; + SymbolicConformanceReentrancyAttacker attacker; + + function setUp() public { + vault = new SymbolicConformanceVulnerableVault(); + attacker = new SymbolicConformanceReentrancyAttacker(vault); + vm.deal(address(vault), 5 ether); + vm.deal(address(attacker), 1 ether); + targetContract(address(attacker)); + } + + /// forge-config: default.symbolic.invariant_depth = 2 + function invariant_vaultBacksAttackerAccounting() public view { + assertGe(address(vault).balance, vault.balanceOf(address(attacker))); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "invariant_vaultBacksAttackerAccounting", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicConformanceHalmosReentrancy.t.sol:SymbolicConformanceHalmosReentrancy +[FAIL: assertion failed: 4000000000000000000 < 115792089237316195423570985008687907853269984665640564039456584007913129639936] + [Sequence] (original: 2, shrunk: 2) + [SENDER] [SENDER] calldata=depositOne() [ARGS] + [SENDER] [SENDER] calldata=withdrawOne() [ARGS] + invariant_vaultBacksAttackerAccounting() ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("Stuck"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_conformance_stateless_arithmetic_and_opcodes(prj: _, cmd: _) { + if should_skip() { + return; + } + + prj.add_test( + "SymbolicConformanceArithmetic.t.sol", + r#" +contract SymbolicConformanceArithmetic { + function checkArithmetic(uint256 x, int8 signed, bytes32 word) public pure { + assert(x / 1 == x); + assert(x % 1 == 0); + + if (x == 9) { + assert(x ** 2 == 81); + } + + int256 extended = signed; + assert(extended >= -128); + assert(extended <= 127); + + bytes1 high = word[0]; + assert(uint8(high) == uint8(uint256(word >> 248))); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args(["test", "--symbolic", "--match-test", "checkArithmetic"])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicConformanceArithmetic.t.sol:SymbolicConformanceArithmetic +[PASS] checkArithmetic(uint256,int8,bytes32) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_conformance_dynamic_abi_matrix(prj: _, cmd: _) { + if should_skip() { + return; + } + + prj.add_test( + "SymbolicConformanceDynamicAbi.t.sol", + r#" +contract SymbolicConformanceDynamicAbi { + struct Pair { + bytes left; + uint256[] right; + } + + /// forge-config: default.symbolic.array_lengths = [3, 2, 4, 1, 2] + function checkDynamic(bytes memory data, string memory text, uint256[] memory values, Pair memory pair) public pure { + assert(data.length == 3); + assert(bytes(text).length == 2); + assert(values.length == 4); + assert(pair.left.length == 1); + assert(pair.right.length == 2); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args(["test", "--symbolic", "--match-test", "checkDynamic"])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicConformanceDynamicAbi.t.sol:SymbolicConformanceDynamicAbi +[PASS] checkDynamic(bytes,string,uint256[],(bytes,uint256[])) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_conformance_symbolic_selector_backdoor(prj: _, cmd: _) { + if should_skip() { + return; + } + + prj.add_test( + "SymbolicConformanceSelector.t.sol", + r#" +contract SymbolicConformanceSelectorTarget { + uint256 public drained; + + function safe(uint256) external {} + + function drain(uint256 amount) external { + if (amount == 42) { + drained = 1; + } + } +} + +contract SymbolicConformanceSelector { + SymbolicConformanceSelectorTarget target; + + function setUp() public { + target = new SymbolicConformanceSelectorTarget(); + } + + function checkNoBackdoor(bytes4 selector, uint256 amount) public { + address(target).call(abi.encodeWithSelector(selector, amount)); + assert(target.drained() == 0); + } +} +"#, + ); + + assert_symbolic(cmd.args(["test", "--symbolic", "--match-test", "checkNoBackdoor"])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicConformanceSelector.t.sol:SymbolicConformanceSelector +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] calldata=0x8267e2eef6b19c7400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002a args=[0xf6b19c74, 42]] checkNoBackdoor(bytes4,uint256) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_conformance_stateful_erc20_invariant(prj: _, cmd: _) { + if should_skip() { + return; + } + + prj.add_test( + "SymbolicConformanceErc20Invariant.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicConformanceMiniErc20 { + mapping(address => uint256) public balanceOf; + uint256 public totalSupply = 100; + + constructor() { + balanceOf[msg.sender] = 100; + } + + function transfer(address to, uint256 amount) external { + if (balanceOf[msg.sender] >= amount) { + balanceOf[msg.sender] -= amount; + balanceOf[to] += amount; + } + } +} + +contract SymbolicConformanceErc20Invariant is Test { + SymbolicConformanceMiniErc20 token; + + function setUp() public { + token = new SymbolicConformanceMiniErc20(); + targetContract(address(token)); + targetSender(address(this)); + targetSender(address(0xB0B)); + } + + /// forge-config: default.symbolic.invariant_depth = 3 + function invariant_totalSupplyConstant() public view { + assert(token.totalSupply() == 100); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "invariant_totalSupplyConstant", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicConformanceErc20Invariant.t.sol:SymbolicConformanceErc20Invariant +[PASS] invariant_totalSupplyConstant() ([METRICS]) + +╭------------------------------+----------+--------+---------+----------╮ +| Contract | Selector | Calls | Reverts | Discards | ++=======================================================================+ +| SymbolicConformanceMiniErc20 | transfer | [..] +╰------------------------------+----------+--------+---------+----------╯ + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_conformance_stateful_erc721_invariant(prj: _, cmd: _) { + if should_skip() { + return; + } + + prj.add_test( + "SymbolicConformanceErc721Invariant.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicConformanceMiniErc721 { + mapping(uint256 => address) public ownerOf; + mapping(uint256 => address) public getApproved; + mapping(address => mapping(address => bool)) public isApprovedForAll; + + constructor(address initialOwner) { + ownerOf[1] = initialOwner; + } + + function approve(address spender, uint256 id) external { + address owner = ownerOf[id]; + if (msg.sender == owner || isApprovedForAll[owner][msg.sender]) { + getApproved[id] = spender; + } + } + + function setApprovalForAll(address operator, bool approved) external { + isApprovedForAll[msg.sender][operator] = approved; + } + + function transferFrom(address from, address to, uint256 id) external { + address owner = ownerOf[id]; + if ( + owner == from && + to != address(0) && + (msg.sender == owner || getApproved[id] == msg.sender || isApprovedForAll[owner][msg.sender]) + ) { + ownerOf[id] = to; + getApproved[id] = address(0); + } + } +} + +contract SymbolicConformanceErc721Handler { + SymbolicConformanceMiniErc721 internal nft; + Vm internal constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + address internal constant OWNER = address(0xA11CE); + address internal constant BOB = address(0xB0B); + address internal constant OPERATOR = address(0xCAFE); + + constructor(SymbolicConformanceMiniErc721 target) { + nft = target; + } + + function approveBobFromOwner() external { + vm.prank(OWNER); + nft.approve(BOB, 1); + } + + function setOperatorForOwner(bool approved) external { + vm.prank(OWNER); + nft.setApprovalForAll(OPERATOR, approved); + } + + function transferOwnerToBob() external { + vm.prank(OWNER); + nft.transferFrom(OWNER, BOB, 1); + } + + function transferBobToOwner() external { + vm.prank(BOB); + nft.transferFrom(BOB, OWNER, 1); + } +} + +contract SymbolicConformanceErc721Invariant is Test { + SymbolicConformanceMiniErc721 nft; + SymbolicConformanceErc721Handler handler; + address constant OWNER = address(0xA11CE); + + function setUp() public { + nft = new SymbolicConformanceMiniErc721(OWNER); + handler = new SymbolicConformanceErc721Handler(nft); + targetContract(address(handler)); + } + + /// forge-config: default.symbolic.invariant_depth = 2 + function invariant_mintedOwnerIsNeverZero() public view { + assert(nft.ownerOf(1) != address(0)); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "invariant_mintedOwnerIsNeverZero", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicConformanceErc721Invariant.t.sol:SymbolicConformanceErc721Invariant +[PASS] invariant_mintedOwnerIsNeverZero() ([METRICS]) + +╭----------------------------------+---------------------+-------+---------+----------╮ +| Contract | Selector | Calls | Reverts | Discards | ++=====================================================================================+ +| SymbolicConformanceErc721Handler | approveBobFromOwner | [..] +|----------------------------------+---------------------+-------+---------+----------| +| SymbolicConformanceErc721Handler | setOperatorForOwner | [..] +|----------------------------------+---------------------+-------+---------+----------| +| SymbolicConformanceErc721Handler | transferBobToOwner | [..] +|----------------------------------+---------------------+-------+---------+----------| +| SymbolicConformanceErc721Handler | transferOwnerToBob | [..] +╰----------------------------------+---------------------+-------+---------+----------╯ + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_conformance_stateful_reentrancy_sequence(prj: _, cmd: _) { + if should_skip() { + return; + } + + prj.add_test( + "SymbolicConformanceReentrancyInvariant.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicConformanceVault { + mapping(address => uint256) public balanceOf; + bool internal locked; + + function deposit() external payable { + balanceOf[msg.sender] += msg.value; + } + + function withdraw(uint256 amount) external { + if (balanceOf[msg.sender] >= amount && !locked) { + locked = true; + balanceOf[msg.sender] -= amount; + (bool ok,) = msg.sender.call{value: amount}(""); + ok; + locked = false; + } + } +} + +contract SymbolicConformanceReentrantHandler { + SymbolicConformanceVault public vault; + bool internal entered; + + constructor(SymbolicConformanceVault target) { + vault = target; + } + + receive() external payable { + if (!entered) { + entered = true; + vault.withdraw(1 ether); + entered = false; + } + } + + function depositOne() external { + vault.deposit{value: 1 ether}(); + } + + function withdrawOne() external { + vault.withdraw(1 ether); + } +} + +contract SymbolicConformanceReentrancyInvariant is Test { + SymbolicConformanceVault vault; + SymbolicConformanceReentrantHandler handler; + + function setUp() public { + vault = new SymbolicConformanceVault(); + handler = new SymbolicConformanceReentrantHandler(vault); + vm.deal(address(handler), 3 ether); + targetContract(address(handler)); + } + + /// forge-config: default.symbolic.invariant_depth = 3 + function invariant_vaultBacksHandlerBalance() public view { + assert(address(vault).balance >= vault.balanceOf(address(handler))); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "invariant_vaultBacksHandlerBalance", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicConformanceReentrancyInvariant.t.sol:SymbolicConformanceReentrancyInvariant +[PASS] invariant_vaultBacksHandlerBalance() ([METRICS]) + +╭-------------------------------------+-------------+-------+---------+----------╮ +| Contract | Selector | Calls | Reverts | Discards | ++================================================================================+ +| SymbolicConformanceReentrantHandler | depositOne | [..] +|-------------------------------------+-------------+-------+---------+----------| +| SymbolicConformanceReentrantHandler | withdrawOne | [..] +╰-------------------------------------+-------------+-------+---------+----------╯ + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} diff --git a/crates/forge/tests/cli/test_cmd/symbolic_creates.rs b/crates/forge/tests/cli/test_cmd/symbolic_creates.rs new file mode 100644 index 0000000000000..77b790be2e012 --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/symbolic_creates.rs @@ -0,0 +1,1286 @@ +use super::symbolic_helpers::{ + assert_symbolic, assert_symbolic_engine_witness, assert_symbolic_witness, z3_available, +}; +use crate::skip_unless_z3; +use foundry_common::sh_eprintln; +use foundry_test_utils::{forgetest_init, snapbox::IntoData, str, util::OutputExt}; + +#[forgetest_init] +fn symbolic_create_contains_invalid_initcode_halt(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_create_contains_invalid_initcode_halt because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicInvalidInitcode.t.sol", + r#" +contract SymbolicInvalidInitcode { + uint256 marker; + + function checkInvalidInitcode() public { + marker = 19; + address created; + uint256 returnSize; + assembly ("memory-safe") { + mstore8(0, 0xfe) + created := create(0, 0, 1) + returnSize := returndatasize() + } + assert(created == address(0)); + assert(returnSize == 0); + assert(marker == 19); + } +} +"#, + ); + + cmd.args(["test", "--symbolic", "--match-test", "checkInvalidInitcode"]).assert_success(); +} + +#[forgetest_init] +fn symbolic_create_respects_configured_runtime_code_limit(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_create_respects_configured_runtime_code_limit because z3 is not available" + ); + return; + } + + prj.update_config(|config| config.code_size_limit = Some(24_576)); + prj.add_test( + "SymbolicCreateCodeLimit.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicCreateCodeLimit is Test { + function checkRuntimeCodeLimit() public { + address created; + assembly ("memory-safe") { + mstore(0, shl(208, 0x6160016000f3)) + created := create(0, 0, 6) + } + assert(created == address(0)); + assert(created.code.length == 0); + } + + function checkConfiguredRuntimeCodeLimit() public { + address created; + assembly ("memory-safe") { + mstore(0, shl(208, 0x6160006000f3)) + created := create(0, 0, 6) + } + assert(created == address(0)); + assert(created.code.length == 0); + } + + function checkExpectRevertRuntimeCodeLimit() public { + vm.expectRevert(); + new OversizedRuntime(); + + vm.expectRevert(); + new OversizedRuntime{salt: bytes32(uint256(1))}(); + } +} + +contract OversizedRuntime { + constructor() { + assembly ("memory-safe") { + return(0, 24577) + } + } +} +"#, + ); + + cmd.args(["test", "--symbolic", "--match-test", "checkRuntimeCodeLimit"]).assert_success(); + + cmd.forge_fuse(); + cmd.args(["test", "--symbolic", "--match-test", "checkExpectRevertRuntimeCodeLimit"]) + .assert_success(); + + prj.update_config(|config| config.code_size_limit = Some(24_575)); + cmd.forge_fuse(); + cmd.args(["test", "--symbolic", "--match-test", "checkConfiguredRuntimeCodeLimit"]) + .assert_success(); +} + +#[forgetest_init] +fn symbolic_create_deploys_and_calls_helper(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_create_deploys_and_calls_helper because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicCreate.t.sol", + r#" +contract CreatedHelper { + function inc(uint256 x) external pure returns (uint256) { + return x + 1; + } +} + +contract SymbolicCreate { + function checkCreate(uint256 x) public { + CreatedHelper helper = new CreatedHelper(); + assert(helper.inc(x) != 9); + } +} +"#, + ); + + let stdout = + assert_symbolic_witness(cmd.args(["test", "--symbolic", "--match-test", "checkCreate"])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicCreate.t.sol:SymbolicCreate +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkCreate(uint256) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("unsupported opcode: 0xf0"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_create_respects_eip3541_runtime_prefix(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_create_respects_eip3541_runtime_prefix because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicCreateEip3541.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicCreateEip3541 is Test { + function checkRejectedPrefix() public { + assert(deployCreate(0xef) == address(0)); + assert(deployCreate2(0xef) == address(0)); + } + + function checkRejectedPrefixPreservesWarp() public { + bytes memory initcode = abi.encodePacked(type(WarpThenReject).creationCode, abi.encode(address(this))); + address created; + assembly ("memory-safe") { + created := create(0, add(initcode, 32), mload(initcode)) + } + assert(created == address(0)); + assert(block.timestamp == 123); + } + + function checkRejectedPrefixPreservesMockProgress() public { + checkMockProgress(address(0x1001), false); + checkMockProgress(address(0x1002), true); + } + + function checkRejectedPrefixExpectedCallsAndRevert() public { + checkExpectedCallAndRevert(address(0x2001), false); + checkExpectedCallAndRevert(address(0x2002), true); + } + + function warp(uint256 timestamp) external { + vm.warp(timestamp); + } + + function checkMockProgress(address target, bool useCreate2) internal { + bytes[] memory returnValues = new bytes[](2); + returnValues[0] = abi.encode(uint256(1)); + returnValues[1] = abi.encode(uint256(2)); + vm.mockCalls(target, abi.encodeCall(IMockSequenceTarget.value, ()), returnValues); + + bytes memory initcode = abi.encodePacked(type(ConsumeMockThenReject).creationCode, abi.encode(target)); + address created; + if (useCreate2) { + assembly ("memory-safe") { + created := create2(0, add(initcode, 32), mload(initcode), 1) + } + } else { + assembly ("memory-safe") { + created := create(0, add(initcode, 32), mload(initcode)) + } + } + assert(created == address(0)); + assertEq(IMockSequenceTarget(target).value(), 2); + } + + function checkExpectedCallAndRevert(address target, bool useCreate2) internal { + bytes memory callData = abi.encodeCall(IMockSequenceTarget.value, ()); + vm.mockCall(target, callData, abi.encode(uint256(1))); + vm.expectCall(target, callData); + bytes memory rejectedRuntime = hex"ef"; + vm.expectRevert(rejectedRuntime); + if (useCreate2) { + new ConsumeMockThenReject{salt: bytes32(uint256(2))}(IMockSequenceTarget(target)); + } else { + new ConsumeMockThenReject(IMockSequenceTarget(target)); + } + } + + function checkAllowedPrefixBeforeLondon() public { + address created = deployCreate(0xef); + address created2 = deployCreate2(0xef); + assert(created != address(0)); + assert(created2 != address(0)); + assert(created.code.length == 1); + assert(created2.code.length == 1); + } + + function checkAdjacentPrefixStillAllowed() public { + address created = deployCreate(0xee); + address created2 = deployCreate2(0xee); + assert(created != address(0)); + assert(created2 != address(0)); + assert(created.code.length == 1); + assert(created2.code.length == 1); + } + + function deployCreate(uint256 runtimeByte) internal returns (address created) { + assembly ("memory-safe") { + mstore(0, shl(176, or(0x600060005360016000f3, shl(64, runtimeByte)))) + created := create(0, 0, 10) + } + } + + function deployCreate2(uint256 runtimeByte) internal returns (address created) { + assembly ("memory-safe") { + mstore(0, shl(176, or(0x600060005360016000f3, shl(64, runtimeByte)))) + created := create2(0, 0, 10, 123) + } + } +} + +contract WarpThenReject { + constructor(SymbolicCreateEip3541 test) { + test.warp(123); + assembly ("memory-safe") { + mstore(0, shl(248, 0xef)) + return(0, 1) + } + } +} + +interface IMockSequenceTarget { + function value() external returns (uint256); +} + +contract ConsumeMockThenReject { + constructor(IMockSequenceTarget target) { + require(target.value() == 1); + assembly ("memory-safe") { + mstore(0, shl(248, 0xef)) + return(0, 1) + } + } +} +"#, + ); + + cmd.args(["test", "--symbolic", "--match-test", "checkRejectedPrefix"]).assert_success(); + + cmd.forge_fuse(); + cmd.args(["test", "--symbolic", "--match-test", "checkRejectedPrefixPreservesWarp"]) + .assert_success(); + + cmd.forge_fuse(); + cmd.args(["test", "--symbolic", "--match-test", "checkRejectedPrefixPreservesMockProgress"]) + .assert_success(); + + cmd.forge_fuse(); + cmd.args(["test", "--symbolic", "--match-test", "checkRejectedPrefixExpectedCallsAndRevert"]) + .assert_success(); + + cmd.forge_fuse(); + cmd.args(["test", "--symbolic", "--match-test", "checkAdjacentPrefixStillAllowed"]) + .assert_success(); + + cmd.forge_fuse(); + cmd.args([ + "test", + "--symbolic", + "--evm-version", + "berlin", + "--match-test", + "checkAllowedPrefixBeforeLondon", + ]) + .assert_success(); +} + +// CREATE and CREATE2 with symbolic constructor args, initcode offset or size, and salt are modeled. +#[forgetest_init] +fn symbolic_create_accepts_symbolic_operands(prj: _, cmd: _) { + skip_unless_z3!("symbolic_create_accepts_symbolic_operands"); + + prj.add_test( + "SymbolicCreateConstructorArgs.t.sol", + r#" +contract CreatedStore { + uint256 public value; + + constructor(uint256 x) { + value = x; + } +} + +contract SymbolicCreateConstructorArgs { + function checkCreateConstructorArg(uint256 x) public { + CreatedStore store = new CreatedStore(x); + assert(store.value() == x); + } +} +"#, + ); + + prj.add_test( + "SymbolicCreateInitcodeOffset.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicCreateInitcodeOffset is Test { + function checkCreateInitcodeOffset(uint16 offset) public { + vm.assume(offset == 0x80); + + address created; + uint256 size; + assembly { + mstore(0x80, 0x6001600c60003960016000f30000000000000000000000000000000000000000) + created := create(0, offset, 13) + size := extcodesize(created) + } + + assert(created != address(0)); + assert(size == 1); + } +} +"#, + ); + + prj.add_test( + "SymbolicCreateInitcodeSize.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicCreateInitcodeSize is Test { + function checkCreateInitcodeSize(uint256 size) public { + vm.assume(size == 0 || size == 13); + bytes memory code = hex"6001600c60003960016000f300"; + + address created; + assembly { + created := create(0, add(code, 0x20), size) + } + + assert(created != address(0)); + assertEq(created.code.length, size == 13 ? 1 : 0); + } +} +"#, + ); + + prj.add_test( + "SymbolicCreate2Args.t.sol", + r#" +contract CreatedImmutable { + uint256 immutable value; + + constructor(uint256 value_) { + value = value_; + } + + function get() external view returns (uint256) { + return value; + } +} + +contract SymbolicCreate2Args { + function checkCreate2ConstructorArg(uint256 x) public { + CreatedImmutable created = new CreatedImmutable{salt: bytes32(uint256(7))}(x); + assert(created.get() == x); + assert(address(created).code.length > 0); + } +} +"#, + ); + + prj.add_test( + "SymbolicCreate2SelfAddress.t.sol", + r#" +contract CreatedSelfAddress { + address public constructorSelf; + + constructor() { + constructorSelf = address(this); + } + + function runtimeSelf() external view returns (address) { + return address(this); + } +} + +contract SymbolicCreate2SelfAddress { + function checkCreate2SelfAddress(uint256 salt) public { + CreatedSelfAddress created = new CreatedSelfAddress{salt: bytes32(salt)}(); + assert(created.constructorSelf() == address(created)); + assert(created.runtimeSelf() == address(created)); + } +} +"#, + ); + + let stdout = assert_symbolic(cmd.args([ + "test", + "--symbolic", + "--match-test", + "^(checkCreateConstructorArg|checkCreateInitcodeOffset|checkCreateInitcodeSize|checkCreate2ConstructorArg|checkCreate2SelfAddress)\\(", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicCreateConstructorArgs.t.sol:SymbolicCreateConstructorArgs +[PASS] checkCreateConstructorArg(uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicCreate2SelfAddress.t.sol:SymbolicCreate2SelfAddress +[PASS] checkCreate2SelfAddress(uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicCreateInitcodeSize.t.sol:SymbolicCreateInitcodeSize +[PASS] checkCreateInitcodeSize(uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicCreateInitcodeOffset.t.sol:SymbolicCreateInitcodeOffset +[PASS] checkCreateInitcodeOffset(uint16) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicCreate2Args.t.sol:SymbolicCreate2Args +[PASS] checkCreate2ConstructorArg(uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]].unordered()) + .get_output() + .stdout_lossy(); + for reason in [ + "symbolic CALL target", + "symbolic CREATE initcode offset", + "symbolic CREATE initcode size", + "symbolic CREATE2 initcode", + "symbolic CREATE2 salt", + "symbolic bytecode opcode", + "unsupported symbolic execution feature: symbolic CREATE initcode", + ] { + assert!(!stdout.contains(reason), "{stdout}"); + } +} + +#[forgetest_init] +fn symbolic_create_size_respects_path_width(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_create_size_respects_path_width because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicCreateSize.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicCreateSize is Test { + function checkCreateSizeRespectsPathWidth(uint256 size) public { + vm.assume(size <= 2); + bytes memory code = hex"5b5b"; + address created; + assembly { + created := create(0, add(code, 0x20), size) + } + assert(created != address(0)); + } +} +"#, + ); + + let stdout = assert_symbolic_engine_witness(cmd.args([ + "test", + "--symbolic", + "--symbolic-width", + "2", + "--match-test", + "checkCreateSizeRespectsPathWidth", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicCreateSize.t.sol:SymbolicCreateSize +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: symbolic path limit exceeded] checkCreateSizeRespectsPathWidth(uint256) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + // `[METRICS]` hides the path count that `--symbolic-width 2` must cap. + assert!(stdout.contains("(paths: 2,"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_create2_deploys_and_calls_helper(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_create2_deploys_and_calls_helper because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicCreate2.t.sol", + r#" +contract CreatedHelper { + function inc(uint256 x) external pure returns (uint256) { + return x + 1; + } +} + +contract SymbolicCreate2 { + function checkCreate2(uint256 x) public { + CreatedHelper helper = new CreatedHelper{salt: bytes32(uint256(123))}(); + assert(helper.inc(x) != 11); + } +} +"#, + ); + + let stdout = + assert_symbolic_witness(cmd.args(["test", "--symbolic", "--match-test", "checkCreate2"])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicCreate2.t.sol:SymbolicCreate2 +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkCreate2(uint256) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("unsupported opcode: 0xf5"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_vm_expect_create_matches_and_reports_missing(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_vm_expect_create_matches_and_reports_missing because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicExpectCreate.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicExpectedCreateTarget { + function ping() external pure returns (uint256) { + return 1; + } +} + +contract SymbolicExpectCreate is Test { + function checkCreateExpectation(uint256) public { + vm.expectCreate(type(SymbolicExpectedCreateTarget).runtimeCode, address(this)); + SymbolicExpectedCreateTarget target = new SymbolicExpectedCreateTarget(); + assertEq(target.ping(), 1); + } + + function checkCreate2Expectation(uint256) public { + vm.expectCreate2(type(SymbolicExpectedCreateTarget).runtimeCode, address(this)); + SymbolicExpectedCreateTarget target = new SymbolicExpectedCreateTarget{salt: bytes32(uint256(99))}(); + assertEq(target.ping(), 1); + } + + function checkSymbolicCreateExpectation(address deployer) public { + vm.assume(deployer == address(this)); + vm.expectCreate(type(SymbolicExpectedCreateTarget).runtimeCode, deployer); + SymbolicExpectedCreateTarget target = new SymbolicExpectedCreateTarget(); + assertEq(target.ping(), 1); + } + + function checkMismatchedSymbolicCreateExpectation(address deployer) public { + vm.assume(deployer != address(this)); + vm.expectCreate(type(SymbolicExpectedCreateTarget).runtimeCode, deployer); + new SymbolicExpectedCreateTarget(); + } + + function checkMissingCreateExpectation(uint256) public { + vm.expectCreate(type(SymbolicExpectedCreateTarget).runtimeCode, address(this)); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkCreateExpectation", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicExpectCreate.t.sol:SymbolicExpectCreate +[PASS] checkCreateExpectation(uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); + + assert_symbolic_witness(prj.forge_command().args([ + "test", + "--symbolic", + "--match-test", + "checkCreate2Expectation", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicExpectCreate.t.sol:SymbolicExpectCreate +[PASS] checkCreate2Expectation(uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); + + assert_symbolic_witness(prj.forge_command().args([ + "test", + "--symbolic", + "--match-test", + "checkSymbolicCreateExpectation", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicExpectCreate.t.sol:SymbolicExpectCreate +[PASS] checkSymbolicCreateExpectation(address) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); + + assert_symbolic_witness(prj.forge_command().args([ + "test", + "--symbolic", + "--match-test", + "checkMismatchedSymbolicCreateExpectation", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicExpectCreate.t.sol:SymbolicExpectCreate +[FAIL: expected CREATE call by address 0xffffffffffffffffffffffffffffffffffffffff for bytecode 0x[..] but not found; counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkMismatchedSymbolicCreateExpectation(address) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); + + assert_symbolic_witness(prj.forge_command().args([ + "test", + "--symbolic", + "--match-test", + "checkMissingCreateExpectation", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicExpectCreate.t.sol:SymbolicExpectCreate +[FAIL: expected CREATE call by address 0x7fa9385be102ac3eac297483dd6233d62b3e1496 for bytecode 0x[..] but not found; counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkMissingCreateExpectation(uint256) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +// Failed creations: CREATE2 collisions, nonce bumps and revert data. +#[forgetest_init] +fn symbolic_create_failure_semantics(prj: _, cmd: _) { + skip_unless_z3!("symbolic_create_failure_semantics"); + + prj.add_test( + "SymbolicCreate2Collision.t.sol", + r#" +import "forge-std/Test.sol"; + +contract CreatedHelper {} + +contract SymbolicCreate2Collision is Test { + function checkCreate2Collision() public { + uint64 beforeNonce = vm.getNonce(address(this)); + bytes memory code = type(CreatedHelper).creationCode; + address first; + address second; + assembly { + first := create2(0, add(code, 0x20), mload(code), 1) + second := create2(0, add(code, 0x20), mload(code), 1) + } + assert(first != address(0)); + assert(second == address(0)); + assert(vm.getNonce(address(this)) == beforeNonce + 2); + } +} +"#, + ); + + prj.add_test( + "SymbolicCreateFailureNonce.t.sol", + r#" +import "forge-std/Test.sol"; + +contract RevertingCreate { + constructor() { + revert(); + } +} + +contract SymbolicCreateFailureNonce is Test { + function checkCreateFailureNonce(uint256) public { + uint64 beforeNonce = vm.getNonce(address(this)); + try new RevertingCreate() { + assert(false); + } catch {} + assert(vm.getNonce(address(this)) == beforeNonce + 1); + } +} +"#, + ); + + prj.add_test( + "SymbolicCreateRevertData.t.sol", + r#" +contract SymbolicCreateRevertData { + function checkCreateRevertData() public { + bytes memory initcode = hex"61123460005260206000fd"; + address created; + uint256 size; + uint256 payload; + assembly { + created := create(0, add(initcode, 0x20), mload(initcode)) + size := returndatasize() + returndatacopy(0x80, 0, size) + payload := mload(0x80) + } + assert(created == address(0)); + assert(size == 32); + assert(payload == 0x1234); + } +} +"#, + ); + + assert_symbolic(cmd.args([ + "test", + "--symbolic", + "--match-test", + "^(checkCreate2Collision|checkCreateFailureNonce|checkCreateRevertData)\\(", + ])) + .success() + .stdout_eq( + str![[r#" +... +Ran 1 test for test/SymbolicCreateRevertData.t.sol:SymbolicCreateRevertData +[PASS] checkCreateRevertData() ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicCreate2Collision.t.sol:SymbolicCreate2Collision +[PASS] checkCreate2Collision() ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicCreateFailureNonce.t.sol:SymbolicCreateFailureNonce +[PASS] checkCreateFailureNonce(uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]] + .unordered(), + ); +} + +#[forgetest_init] +fn symbolic_compute_create_address_cheatcodes(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_compute_create_address_cheatcodes because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicComputeCreateAddresses.t.sol", + r#" +import "forge-std/Test.sol"; + +contract CreatedHelper {} + +contract SymbolicComputeCreateAddresses is Test { + address constant DEFAULT_CREATE2_DEPLOYER = 0x4e59b44847b379578588920cA78FbF26c0B4956C; + + function checkComputeCreateAddress(uint256) public { + uint64 nonce = vm.getNonce(address(this)); + address expected = vm.computeCreateAddress(address(this), nonce); + CreatedHelper created = new CreatedHelper(); + assert(address(created) == expected); + } + + function checkSymbolicComputeCreateAddress(uint64 nonce) public { + address first = vm.computeCreateAddress(address(this), nonce); + address second = vm.computeCreateAddress(address(this), nonce); + + assert(first == second); + assert(uint160(first) <= type(uint160).max); + } + + function checkSymbolicComputeCreateAddressDeployer(address deployer, uint64 nonce) public { + address first = vm.computeCreateAddress(deployer, nonce); + address second = vm.computeCreateAddress(deployer, nonce); + + assert(first == second); + assert(uint160(first) <= type(uint160).max); + } + + function checkComputeCreate2Address(uint256 saltValue) public { + bytes32 salt = bytes32(saltValue); + bytes memory code = type(CreatedHelper).creationCode; + address expected = vm.computeCreate2Address(salt, keccak256(code), address(this)); + address created; + assembly { + created := create2(0, add(code, 0x20), mload(code), salt) + } + assert(created == expected); + } + + function checkSymbolicComputeCreate2Address(bytes32 salt, bytes32 initCodeHash) public { + address first = vm.computeCreate2Address(salt, initCodeHash, address(this)); + address second = vm.computeCreate2Address(salt, initCodeHash, address(this)); + + assert(first == second); + assert(uint160(first) <= type(uint160).max); + } + + function checkSymbolicComputeCreate2AddressDeployer( + address deployer, + bytes32 salt, + bytes32 initCodeHash + ) public { + address first = vm.computeCreate2Address(salt, initCodeHash, deployer); + address second = vm.computeCreate2Address(salt, initCodeHash, deployer); + + assert(first == second); + assert(uint160(first) <= type(uint160).max); + } + + function checkComputeCreate2DefaultDeployer() public { + bytes memory code = type(CreatedHelper).creationCode; + bytes32 salt = bytes32(uint256(1)); + bytes32 initCodeHash = keccak256(code); + address expected = vm.computeCreate2Address(salt, initCodeHash); + address manual = address(uint160(uint256(keccak256(abi.encodePacked( + bytes1(0xff), DEFAULT_CREATE2_DEPLOYER, salt, initCodeHash + ))))); + assert(expected == manual); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-contract", + "SymbolicComputeCreateAddresses", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 7 tests for test/SymbolicComputeCreateAddresses.t.sol:SymbolicComputeCreateAddresses +[PASS] checkComputeCreate2Address(uint256) ([METRICS]) +[PASS] checkComputeCreate2DefaultDeployer() ([METRICS]) +[PASS] checkComputeCreateAddress(uint256) ([METRICS]) +[PASS] checkSymbolicComputeCreate2Address(bytes32,bytes32) ([METRICS]) +[PASS] checkSymbolicComputeCreate2AddressDeployer(address,bytes32,bytes32) ([METRICS]) +[PASS] checkSymbolicComputeCreateAddress(uint64) ([METRICS]) +[PASS] checkSymbolicComputeCreateAddressDeployer(address,uint64) ([METRICS]) +Suite result: ok. 7 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!( + stdout + .contains("[PASS] checkSymbolicComputeCreate2AddressDeployer(address,bytes32,bytes32)"), + "{stdout}" + ); + assert!(!stdout.contains("symbolic vm.computeCreateAddress nonce"), "{stdout}"); + assert!(!stdout.contains("symbolic vm.computeCreate2Address init code hash"), "{stdout}"); + assert!(!stdout.contains("symbolic vm.computeCreateAddress deployer"), "{stdout}"); + assert!(!stdout.contains("symbolic vm.computeCreate2Address deployer"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_vm_nonce_cheatcodes(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!("skipping symbolic_vm_nonce_cheatcodes because z3 is not available"); + return; + } + + prj.add_test( + "SymbolicNonceCheatcodes.t.sol", + r#" +import "forge-std/Test.sol"; + +contract NonceTarget {} + +contract SymbolicNonceCheatcodes is Test { + function checkSetNonceCheatcodes(uint256) public { + address account = address(0x1234); + assertEq(vm.getNonce(account), 0); + + vm.setNonce(account, 7); + assertEq(vm.getNonce(account), 7); + + vm.setNonceUnsafe(account, 2); + assertEq(vm.getNonce(account), 2); + } + + function checkResetNonceCheatcode(uint256) public { + address account = address(0xbeef); + vm.setNonce(account, 3); + vm.resetNonce(account); + assertEq(vm.getNonce(account), 0); + + NonceTarget target = new NonceTarget(); + vm.setNonce(address(target), 9); + vm.resetNonce(address(target)); + assertEq(vm.getNonce(address(target)), 1); + } + +} +"#, + ); + + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkSetNonceCheatcodes|checkResetNonceCheatcode", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 2 tests for test/SymbolicNonceCheatcodes.t.sol:SymbolicNonceCheatcodes +[PASS] checkResetNonceCheatcode(uint256) ([METRICS]) +[PASS] checkSetNonceCheatcodes(uint256) ([METRICS]) +Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_vm_set_nonce_rejects_decrement(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_vm_set_nonce_rejects_decrement because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicSetNonceRejectsDecrement.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicSetNonceRejectsDecrement is Test { + function checkSetNonceRejectsDecrement(uint256) public { + address account = address(0xcafe); + vm.setNonce(account, 4); + vm.setNonce(account, 3); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkSetNonceRejectsDecrement", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicSetNonceRejectsDecrement.t.sol:SymbolicSetNonceRejectsDecrement +[FAIL: vm.setNonce: new nonce (3) must be strictly equal to or higher than the account's current nonce (4); counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkSetNonceRejectsDecrement(uint256) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +// CREATE and CREATE2 with concrete, symbolic and insufficient value. +#[forgetest_init] +fn symbolic_create_value_transfers(prj: _, cmd: _) { + skip_unless_z3!("symbolic_create_value_transfers"); + + prj.add_test( + "SymbolicCreateValue.t.sol", + r#" +import "forge-std/Test.sol"; + +contract PayableCreated { + constructor() payable {} +} + +contract SymbolicCreateValue is Test { + function checkCreateValue() public { + vm.deal(address(this), 1); + bytes memory code = type(PayableCreated).creationCode; + address first; + address second; + assembly { + first := create(1, add(code, 0x20), mload(code)) + second := create(2, add(code, 0x20), mload(code)) + } + assert(first != address(0)); + assert(first.balance == 1); + assert(second == address(0)); + } +} +"#, + ); + + prj.add_test( + "SymbolicCreateSymbolicValue.t.sol", + r#" +import "forge-std/Test.sol"; + +contract PayableCreatedWithValue { + uint256 public paid; + + constructor() payable { + paid = msg.value; + } +} + +contract SymbolicCreateSymbolicValue is Test { + function checkCreateSymbolicValue(uint256 amount) public { + vm.assume(amount <= 5); + vm.deal(address(this), 5); + + PayableCreatedWithValue created = new PayableCreatedWithValue{value: amount}(); + + assertEq(created.paid(), amount); + assertEq(address(created).balance, amount); + assertEq(address(this).balance, 5 - amount); + } +} +"#, + ); + + prj.add_test( + "SymbolicCreateInsufficientValue.t.sol", + r#" +import "forge-std/Test.sol"; + +contract PayableCreatedForInsufficient { + constructor() payable {} +} + +contract SymbolicCreateInsufficientValue is Test { + function checkCreateInsufficientValue(uint256 amount) public { + vm.assume(amount <= 2); + vm.deal(address(this), 1); + bytes memory code = type(PayableCreatedForInsufficient).creationCode; + + address created; + assembly { + created := create(amount, add(code, 0x20), mload(code)) + } + + bool ok = created != address(0); + assertEq(ok, amount <= 1); + assertEq(address(this).balance, ok ? 1 - amount : 1); + if (ok) { + assertEq(created.balance, amount); + } + } +} +"#, + ); + + prj.add_test( + "SymbolicCreate2SymbolicValue.t.sol", + r#" +import "forge-std/Test.sol"; + +contract PayableCreate2WithValue { + uint256 public paid; + + constructor() payable { + paid = msg.value; + } +} + +contract SymbolicCreate2SymbolicValue is Test { + function checkCreate2SymbolicValue(uint256 amount) public { + vm.assume(amount <= 5); + vm.deal(address(this), 5); + + PayableCreate2WithValue created = + new PayableCreate2WithValue{salt: bytes32(uint256(0x1234)), value: amount}(); + + assertEq(created.paid(), amount); + assertEq(address(created).balance, amount); + assertEq(address(this).balance, 5 - amount); + } +} +"#, + ); + + let stdout = assert_symbolic(cmd.args([ + "test", + "--symbolic", + "--match-test", + "^(checkCreateValue|checkCreateSymbolicValue|checkCreateInsufficientValue|checkCreate2SymbolicValue)\\(", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicCreateValue.t.sol:SymbolicCreateValue +[PASS] checkCreateValue() ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicCreate2SymbolicValue.t.sol:SymbolicCreate2SymbolicValue +[PASS] checkCreate2SymbolicValue(uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicCreateSymbolicValue.t.sol:SymbolicCreateSymbolicValue +[PASS] checkCreateSymbolicValue(uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicCreateInsufficientValue.t.sol:SymbolicCreateInsufficientValue +[PASS] checkCreateInsufficientValue(uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]].unordered()) + .get_output() + .stdout_lossy(); + for reason in ["symbolic CREATE balance", "symbolic CREATE value"] { + assert!(!stdout.contains(reason), "{stdout}"); + } +} + +#[forgetest_init] +fn symbolic_staticcall_rejects_create(prj: _, cmd: _) { + if !z3_available() { + let _ = + sh_eprintln!("skipping symbolic_staticcall_rejects_create because z3 is not available"); + return; + } + + prj.add_test( + "SymbolicStaticCreate.t.sol", + r#" +contract CreatedHelper {} + +contract Creator { + function deploy() external returns (address created) { + bytes memory code = type(CreatedHelper).creationCode; + assembly { + created := create(0, add(code, 0x20), mload(code)) + } + } +} + +contract SymbolicStaticCreate { + Creator creator; + + function setUp() public { + creator = new Creator(); + } + + function checkStaticCreate() public view { + (bool ok,) = address(creator).staticcall(abi.encodeWithSelector(Creator.deploy.selector)); + assert(!ok); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args(["test", "--symbolic", "--match-test", "checkStaticCreate"])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicStaticCreate.t.sol:SymbolicStaticCreate +[PASS] checkStaticCreate() ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +// CREATE whose constructor returns a symbolic-length runtime image must fail +// closed as Unsupported instead of silently installing a max-length padded +// bytecode (which would corrupt EXTCODESIZE, selector dispatch, and later +// execution). The constructor below returns `len` bytes; with symbolic `len` +// the engine must report the unsupported feature. +#[forgetest_init] +fn symbolic_create_with_symbolic_runtime_size_reports_unsupported(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_create_with_symbolic_runtime_size_reports_unsupported because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicCreateRuntimeLen.t.sol", + r#" +contract VariableLengthCtor { + constructor(uint256 len) { + assembly { + // Write a STOP byte at memory 0 so the returned data is well-formed, + // then return `len` bytes — a symbolic-length runtime image. + mstore8(0, 0x00) + return(0, len) + } + } +} + +contract SymbolicCreateRuntimeLen { + function checkCreateSymbolicRuntimeLen(uint256 len) public { + new VariableLengthCtor(len); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkCreateSymbolicRuntimeLen", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicCreateRuntimeLen.t.sol:SymbolicCreateRuntimeLen +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: symbolic RETURN size] checkCreateSymbolicRuntimeLen(uint256) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); + + // The engine fails closed at the constructor's RETURN with symbolic size + // (upstream of the CREATE installation step). Either failure mode proves + // the runtime image is never silently installed as max-length bytecode. +} diff --git a/crates/forge/tests/cli/test_cmd/symbolic_engine_capabilities.rs b/crates/forge/tests/cli/test_cmd/symbolic_engine_capabilities.rs new file mode 100644 index 0000000000000..6b006c552e62d --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/symbolic_engine_capabilities.rs @@ -0,0 +1,702 @@ +use super::symbolic_helpers::{assert_symbolic, assert_symbolic_witness}; +use crate::skip_unless_z3; +use foundry_test_utils::{forgetest_init, str}; + +// --------------------------------------------------------------------------- +// Classic DAO-style reentrancy. +// --------------------------------------------------------------------------- +// Vault that updates state AFTER the external call. An Attacker contract +// reenters `withdraw` from its `receive` function and drains more than its +// deposit. The symbolic engine must follow the cross-contract call stack. +#[forgetest_init] +fn reentrancy_dao_classic(prj: _, cmd: _) { + skip_unless_z3!("reentrancy_dao_classic"); + + prj.add_test( + "ReentrancyDao.t.sol", + r#" +import "forge-std/Test.sol"; + +contract Vault { + mapping(address => uint256) public balances; + + function deposit() external payable { + balances[msg.sender] += msg.value; + } + + function withdraw(uint256 amount) external { + require(balances[msg.sender] >= amount); + (bool ok,) = msg.sender.call{value: amount}(""); + require(ok); + // BUG: state update after external call. + balances[msg.sender] -= amount; + } +} + +contract Attacker { + Vault v; + uint256 public stolen; + + constructor(Vault _v) payable { + v = _v; + v.deposit{value: msg.value}(); + } + + function attack() external { + v.withdraw(1); + } + + receive() external payable { + stolen += msg.value; + if (address(v).balance >= 1) { + v.withdraw(1); + } + } +} + +contract ReentrancyDao is Test { + /// forge-config: default.symbolic.depth = 4096 + function checkVaultCannotBeDrained() public { + Vault v = new Vault(); + vm.deal(address(this), 3); + v.deposit{value: 1}(); + Attacker a = new Attacker{value: 1}(v); + a.attack(); + // The attacker deposited 1 wei; should never recover more. + assert(a.stolen() <= 1); + } +} +"#, + ); + + // No symbolic args — fully deterministic. + assert_symbolic(cmd.args(["test", "--symbolic", "--match-test", "checkVaultCannotBeDrained"])) + .failure() + .stdout_eq(str![[r#" +... +Failing tests: +Encountered 1 failing test in test/ReentrancyDao.t.sol:ReentrancyDao +[FAIL: incomplete symbolic execution (RevertAll): all symbolic paths reverted] checkVaultCannotBeDrained() ([METRICS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +"#]]); +} + +// --------------------------------------------------------------------------- +// tx.origin auth bypass. +// --------------------------------------------------------------------------- +// A contract gates a sensitive action on `tx.origin == owner` instead of +// `msg.sender == owner`. Any intermediary contract called by the owner can +// trigger the action — symbolic execution must find that path. +#[forgetest_init] +fn tx_origin_auth_bypass(prj: _, cmd: _) { + skip_unless_z3!("tx_origin_auth_bypass"); + + prj.add_test( + "TxOriginBypass.t.sol", + r#" +import "forge-std/Test.sol"; + +contract Protected { + address public owner; + bool public triggered; + + constructor(address _owner) { + owner = _owner; + } + + function sensitive() external { + // BUG: tx.origin check is bypassable by a malicious intermediary. + require(tx.origin == owner, "not owner"); + triggered = true; + } +} + +contract Relay { + function pull(Protected p) external { + p.sensitive(); + } +} + +contract TxOriginBypass is Test { + function checkOnlyOwnerCanTrigger(address attacker, address owner) public { + vm.assume(attacker != owner); + vm.assume(attacker != address(0)); + vm.assume(owner != address(0)); + + Protected p = new Protected(owner); + Relay r = new Relay(); + + // attacker calls relay, but tx.origin is owner — bug triggers. + vm.prank(attacker, owner); + r.pull(p); + + // Property: only owner should ever be able to set triggered. + // BUG: the assertion fires. + assert(!p.triggered()); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkOnlyOwnerCanTrigger", + ])) + .failure() + .stdout_eq(str![[r#" +... +Failing tests: +Encountered 1 failing test in test/TxOriginBypass.t.sol:TxOriginBypass +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkOnlyOwnerCanTrigger(address,address) ([METRICS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +"#]]); +} + +// --------------------------------------------------------------------------- +// ecrecover basic modeling — does the engine support symbolic ecrecover? +// --------------------------------------------------------------------------- +// Asks the solver to produce a signature that recovers to a specific address. +// If the engine models ecrecover symbolically, it should find a witness; if it +// abstracts it, this test will instead Stuck. Either way, the assertion is +// designed so a correct symbolic ecrecover produces a [FAIL] counterexample. +#[forgetest_init] +fn ecrecover_basic_modeling(prj: _, cmd: _) { + skip_unless_z3!("ecrecover_basic_modeling"); + + prj.add_test( + "EcrecoverBasic.t.sol", + r#" +contract EcrecoverBasic { + function checkEcrecoverNeverHitsZero(bytes32 h, uint8 v, bytes32 r, bytes32 s) public pure { + address signer = ecrecover(h, v, r, s); + // Property: a "valid" signature should never recover the zero address. + // ecrecover returns 0 on malformed inputs, so a counterexample exists + // trivially (e.g. v outside {27, 28}) — the engine must find one. + assert(signer != address(0)); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkEcrecoverNeverHitsZero", + ])) + .failure() + .stdout_eq(str![[r#" +... +Failing tests: +Encountered 1 failing test in test/EcrecoverBasic.t.sol:EcrecoverBasic +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkEcrecoverNeverHitsZero(bytes32,uint8,bytes32,bytes32) ([METRICS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +"#]]); +} + +// --------------------------------------------------------------------------- +// Solady-style `min`/`max` identities — linear small-library proof. +// --------------------------------------------------------------------------- +// Asserts that a ternary-based `min`/`max` over uint256 satisfies the +// expected ordering identities for all symbolic inputs. Linear/bitwise-only +// so Z3 proves it instantly. We deliberately avoid `mulDiv`-style nonlinear +// equivalence, which causes Z3 to return `unknown` even on uint8 inputs — +// that's a separate engine/solver capability to track. +#[forgetest_init] +fn solady_min_max_identities_pass(prj: _, cmd: _) { + skip_unless_z3!("solady_min_max_identities_pass"); + + prj.add_test( + "SoladyMinMax.t.sol", + r#" +contract SoladyMinMax { + function min(uint256 a, uint256 b) internal pure returns (uint256) { + return a < b ? a : b; + } + + function max(uint256 a, uint256 b) internal pure returns (uint256) { + return a > b ? a : b; + } + + function checkMinMaxIdentities(uint256 a, uint256 b) public pure { + uint256 lo = min(a, b); + uint256 hi = max(a, b); + + assert(lo <= a); + assert(lo <= b); + assert(hi >= a); + assert(hi >= b); + assert(lo == a || lo == b); + assert(hi == a || hi == b); + assert(lo + hi == a + b); + } +} +"#, + ); + + assert_symbolic(cmd.args(["test", "--symbolic", "--match-test", "checkMinMaxIdentities"])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SoladyMinMax.t.sol:SoladyMinMax +[PASS] checkMinMaxIdentities(uint256,uint256) ([METRICS]) +... +"#]]); +} + +// --------------------------------------------------------------------------- +// EVM word-ring distributivity — nonlinear proof without overflow assumptions. +// --------------------------------------------------------------------------- +// Addition and multiplication form a ring modulo 2^256. This identity must hold even when any +// intermediate operation wraps; proving it exercises exact nonlinear algebra rather than an +// unbounded-integer approximation. +#[forgetest_init] +fn word_ring_distributivity_passes(prj: _, cmd: _) { + skip_unless_z3!("word_ring_distributivity_passes"); + + prj.add_test( + "WordRingDistributivity.t.sol", + r#" +import "forge-std/Test.sol"; + +contract WordRingDistributivity is Test { + function checkDistributivity(uint256 x, uint256 y, uint256 z) public pure { + unchecked { + assertEq((x + y) * z, x * z + y * z); + } + } +} +"#, + ); + + assert_symbolic(cmd.args(["test", "--symbolic", "--match-test", "checkDistributivity"])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/WordRingDistributivity.t.sol:WordRingDistributivity +[PASS] checkDistributivity(uint256,uint256,uint256) ([METRICS]) +... +"#]]); +} + +// --------------------------------------------------------------------------- +// Cancun transient storage (TLOAD / TSTORE). +// --------------------------------------------------------------------------- +// Verifies symbolic semantics: `TSTORE` is visible within a transaction but +// invisible across calls' state (in this minimal harness we just round-trip +// within one external call). +#[forgetest_init] +fn cancun_transient_storage(prj: _, cmd: _) { + skip_unless_z3!("cancun_transient_storage"); + + prj.add_test( + "CancunTransient.t.sol", + r#" +contract CancunTransient { + function checkTloadAfterTstore(uint256 value) public { + assembly { + tstore(0x1234, value) + } + uint256 readBack; + assembly { + readBack := tload(0x1234) + } + assert(readBack == value); + } +} +"#, + ); + + assert_symbolic(cmd.args(["test", "--symbolic", "--match-test", "checkTloadAfterTstore"])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/CancunTransient.t.sol:CancunTransient +[PASS] checkTloadAfterTstore(uint256) ([METRICS]) +... +"#]]); +} + +// --------------------------------------------------------------------------- +// Shanghai PUSH0 opcode. +// --------------------------------------------------------------------------- +// Trivial sanity check that a contract using `PUSH0` is executable under the +// symbolic engine. +#[forgetest_init] +fn push0_shanghai(prj: _, cmd: _) { + skip_unless_z3!("push0_shanghai"); + + prj.add_test( + "Push0Shanghai.t.sol", + r#" +contract Push0Shanghai { + function checkZeroIsZero() public pure { + uint256 zero; + assembly { + // PUSH0 places a single zero on the stack. + zero := 0 + } + assert(zero == 0); + } +} +"#, + ); + + assert_symbolic(cmd.args(["test", "--symbolic", "--match-test", "checkZeroIsZero"])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/Push0Shanghai.t.sol:Push0Shanghai +[PASS] checkZeroIsZero() ([METRICS]) +... +"#]]); +} + +// --------------------------------------------------------------------------- +// Byteswap involution — small-library bitwise proof. +// --------------------------------------------------------------------------- +// `swap(swap(x)) == x` for uint16, exercising shift + or composition under +// symbolic uint16. Linear/bitwise, so Z3 proves instantly. Replacement for +// the originally-proposed Solady log2 bit-hack, which is nonlinear and times +// out the solver in the same way mulDiv equivalence does. +#[forgetest_init] +fn byteswap_uint16_involution_passes(prj: _, cmd: _) { + skip_unless_z3!("byteswap_uint16_involution_passes"); + + prj.add_test( + "ByteswapInvolution.t.sol", + r#" +contract ByteswapInvolution { + function swap(uint16 x) internal pure returns (uint16) { + return (x >> 8) | (x << 8); + } + + function checkInvolution(uint16 x) public pure { + assert(swap(swap(x)) == x); + } +} +"#, + ); + + assert_symbolic(cmd.args(["test", "--symbolic", "--match-test", "checkInvolution"])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/ByteswapInvolution.t.sol:ByteswapInvolution +[PASS] checkInvolution(uint16) ([METRICS]) +... +"#]]); +} + +// --------------------------------------------------------------------------- +// Cancun MCOPY — symbolic memory copy. +// --------------------------------------------------------------------------- +// Verifies that MCOPY (Cancun-era memory-to-memory copy opcode) round-trips +// a symbolic word through scratch memory correctly. +#[forgetest_init] +fn mcopy_cancun_roundtrip(prj: _, cmd: _) { + skip_unless_z3!("mcopy_cancun_roundtrip"); + + prj.add_test( + "McopyCancun.t.sol", + r#" +contract McopyCancun { + function checkMcopyRoundtrip(bytes32 word) public pure { + bytes32 read; + assembly { + let src := mload(0x40) + let dst := add(src, 0x20) + mstore(src, word) + mcopy(dst, src, 32) + read := mload(dst) + } + assert(read == word); + } +} +"#, + ); + + assert_symbolic(cmd.args(["test", "--symbolic", "--match-test", "checkMcopyRoundtrip"])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/McopyCancun.t.sol:McopyCancun +[PASS] checkMcopyRoundtrip(bytes32) ([METRICS]) +... +"#]]); +} + +// --------------------------------------------------------------------------- +// Cancun BLOBHASH / BLOBBASEFEE — opcode accessibility. +// --------------------------------------------------------------------------- +// Sanity check that both Cancun blob opcodes execute under symbolic +// execution. We don't assert specific values (those depend on context); +// only that they're not rejected as unsupported. +#[forgetest_init] +fn cancun_blob_opcodes_accessible(prj: _, cmd: _) { + skip_unless_z3!("cancun_blob_opcodes_accessible"); + + prj.add_test( + "CancunBlobOps.t.sol", + r#" +contract CancunBlobOps { + // The engine currently treats a symbolic BLOBHASH index as unsupported + // (`Stuck`), so we hard-code the index. This still exercises the opcode + // and the BLOBBASEFEE read. + function checkBlobOpcodes() public view { + bytes32 h; + uint256 fee; + assembly { + h := blobhash(0) + fee := blobbasefee() + } + // No semantic assertion; just keep the values live so the engine has + // to actually execute the opcodes. + assert(uint256(h) | fee | 1 != 0); + } +} +"#, + ); + + assert_symbolic(cmd.args(["test", "--symbolic", "--match-test", "checkBlobOpcodes"])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/CancunBlobOps.t.sol:CancunBlobOps +[PASS] checkBlobOpcodes() ([METRICS]) +... +"#]]); +} + +// --------------------------------------------------------------------------- +// Istanbul CHAINID / SELFBALANCE — opcode accessibility. +// --------------------------------------------------------------------------- +#[forgetest_init] +fn istanbul_chainid_selfbalance(prj: _, cmd: _) { + skip_unless_z3!("istanbul_chainid_selfbalance"); + + prj.add_test( + "IstanbulOps.t.sol", + r#" +contract IstanbulOps { + function checkChainIdAndSelfBalance() public view { + uint256 chain; + uint256 bal; + assembly { + chain := chainid() + bal := selfbalance() + } + // Same self-balance equivalence the EVM guarantees. + assert(bal == address(this).balance); + assert(chain == block.chainid); + } +} +"#, + ); + + assert_symbolic(cmd.args(["test", "--symbolic", "--match-test", "checkChainIdAndSelfBalance"])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/IstanbulOps.t.sol:IstanbulOps +[PASS] checkChainIdAndSelfBalance() ([METRICS]) +... +"#]]); +} + +// --------------------------------------------------------------------------- +// SDIV signed-overflow corner case — `MIN_INT256 / -1` returns `MIN_INT256`. +// --------------------------------------------------------------------------- +// Per EVM spec, signed division has no real overflow: dividing the most- +// negative int256 by -1 wraps back to itself. Symbolic engine must model +// this exactly. +#[forgetest_init] +fn sdiv_min_int_overflow_semantics(prj: _, cmd: _) { + skip_unless_z3!("sdiv_min_int_overflow_semantics"); + + prj.add_test( + "SdivMinInt.t.sol", + r#" +contract SdivMinInt { + function checkSdivMinByNegOne() public pure { + int256 a = type(int256).min; + int256 b = -1; + int256 r; + assembly { + r := sdiv(a, b) + } + // EVM spec: SDIV(MIN_INT, -1) == MIN_INT. + assert(r == type(int256).min); + } +} +"#, + ); + + assert_symbolic(cmd.args(["test", "--symbolic", "--match-test", "checkSdivMinByNegOne"])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SdivMinInt.t.sol:SdivMinInt +[PASS] checkSdivMinByNegOne() ([METRICS]) +... +"#]]); +} + +// --------------------------------------------------------------------------- +// EXP small bounded — engine handles modular exponentiation. +// --------------------------------------------------------------------------- +// We deliberately keep base and exponent both bounded so the engine can +// concretely unroll. This is the smallest non-trivial EXP check that won't +// time out the solver (full nonlinear EXP equivalence is out of scope, the +// same way mulDiv equivalence is). +#[forgetest_init] +fn exp_small_bounded(prj: _, cmd: _) { + skip_unless_z3!("exp_small_bounded"); + + prj.add_test( + "ExpSmallBounded.t.sol", + r#" +import "forge-std/Test.sol"; + +contract ExpSmallBounded is Test { + function checkExpSmall(uint8 e) public pure { + vm.assume(e <= 4); + uint256 r; + assembly { + r := exp(2, e) + } + // 2 ** e for e in [0, 4] enumerates {1, 2, 4, 8, 16}. + assert(r == (uint256(1) << e)); + } +} +"#, + ); + + assert_symbolic(cmd.args(["test", "--symbolic", "--match-test", "checkExpSmall"])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/ExpSmallBounded.t.sol:ExpSmallBounded +[PASS] checkExpSmall(uint8) ([METRICS]) +... +"#]]); +} + +// --------------------------------------------------------------------------- +// Pranked CALL value transfers debit the effective caller. +// --------------------------------------------------------------------------- +#[forgetest_init] +fn pranked_value_transfer_roundtrip(prj: _, cmd: _) { + skip_unless_z3!("pranked_value_transfer_roundtrip"); + + prj.add_test( + "PrankedValueTransfer.t.sol", + r#" +import "forge-std/Test.sol"; + +contract WrappedEther { + mapping(address => uint256) public balanceOf; + + function deposit() external payable { + balanceOf[msg.sender] += msg.value; + } + + function withdraw(uint256 amount) external { + balanceOf[msg.sender] -= amount; + (bool success,) = payable(msg.sender).call{value: amount}(""); + require(success); + } +} + +contract PrankedValueTransfer is Test { + address constant ALICE = address(0xA11CE); + WrappedEther weth; + + function setUp() public { + weth = new WrappedEther(); + } + + function checkDepositWithdrawRoundtrip(uint96 amount) public { + vm.deal(ALICE, amount); + uint256 preEthBalance = ALICE.balance; + + vm.prank(ALICE); + weth.deposit{value: amount}(); + + vm.prank(ALICE); + weth.withdraw(amount); + + assertEq(ALICE.balance, preEthBalance); + } +} +"#, + ); + + assert_symbolic(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkDepositWithdrawRoundtrip", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/PrankedValueTransfer.t.sol:PrankedValueTransfer +[PASS] checkDepositWithdrawRoundtrip(uint96) ([METRICS]) +... +"#]]); +} + +#[forgetest_init] +fn pranked_self_value_transfer(prj: _, cmd: _) { + skip_unless_z3!("pranked_self_value_transfer"); + + prj.add_test( + "PrankedSelfValueTransfer.t.sol", + r#" +import "forge-std/Test.sol"; + +contract PrankedSelfValueTransfer is Test { + address constant ALICE = address(0xA11CE); + + function checkSelfTransferPreservesBalance(uint96 amount) public { + vm.deal(ALICE, amount); + + vm.prank(ALICE); + (bool ok,) = ALICE.call{value: amount}(""); + + assertTrue(ok); + assertEq(ALICE.balance, amount); + } +} +"#, + ); + + assert_symbolic(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkSelfTransferPreservesBalance", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/PrankedSelfValueTransfer.t.sol:PrankedSelfValueTransfer +[PASS] checkSelfTransferPreservesBalance(uint96) ([METRICS]) +... +"#]]); +} diff --git a/crates/forge/tests/cli/test_cmd/symbolic_false_pass_prevention.rs b/crates/forge/tests/cli/test_cmd/symbolic_false_pass_prevention.rs new file mode 100644 index 0000000000000..e5b0bd36abcf0 --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/symbolic_false_pass_prevention.rs @@ -0,0 +1,176 @@ +use super::symbolic_helpers::assert_symbolic_witness; +use crate::skip_unless_z3; +use foundry_test_utils::{forgetest_init, str, util::OutputExt}; + +#[forgetest_init] +fn symbolic_false_pass_prevention(prj: _, cmd: _) { + skip_unless_z3!("symbolic_false_pass_prevention"); + + prj.add_test( + "SymbolicFalsePassPrevention.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicFalsePassPrevention is Test { + function checkConcreteAddmodMulmodUseUnboundedIntermediate() public pure { + uint256 max = type(uint256).max; + uint256 addResult; + uint256 mulResult; + + assembly { + addResult := addmod(max, 1, max) + mulResult := mulmod(shl(255, 1), 2, max) + } + + assert(addResult == 1); + assert(mulResult == 1); + } + + function checkSymbolicAddmodFailsClosed(uint256 a) public { + vm.assume(a == type(uint256).max); + + uint256 result; + assembly { + result := addmod(a, 1, not(0)) + } + + assert(result == 0); + } + + function checkSymbolicMulmodFailsClosed(uint256 a) public { + vm.assume(a == (uint256(1) << 255)); + + uint256 result; + assembly { + result := mulmod(a, 2, not(0)) + } + + assert(result == 0); + } + + function checkKzgPrecompileInvalidWitnessCounterexample(bytes32 z) public { + bytes memory input = abi.encodePacked( + hex"01e798154708fe7789429634053cbf9f99b619f9f084048927333fce637f549b", + z, + hex"1522a4a7f34e1ea350ae07c29c96c7e79655aa926122e95fe69fcbd932ca49e9", + hex"8f59a8d2a1a625a17f3fea0fe5eb8c896db3764f3185481bc22f91b4aaffcca25f26936857bc3a7c2539ea8ec3a952b7", + hex"a62ad71d14c5719385c0686f1871430475bf3a00f0aa3f7b8dd99a9abc2160744faf0070725e00b60ad9a026a15b1a8c" + ); + + (bool ok,) = address(0x0a).staticcall(input); + assert(ok); + } + + function checkBn254AddPrecompileFailsClosed(uint256 x) public view { + (bool ok,) = address(0x06).staticcall(abi.encode(x, uint256(0), uint256(0), uint256(0))); + assert(ok); + } + + function checkBn254MulPrecompileFailsClosed(uint256 x) public view { + (bool ok,) = address(0x07).staticcall(abi.encode(x, uint256(0), uint256(0))); + assert(ok); + } + + function checkBn254PairingPrecompileFailsClosed(uint256 x) public view { + bytes memory input = new bytes(192); + assembly { + mstore(add(input, 32), x) + } + (bool ok,) = address(0x08).staticcall(input); + assert(ok); + } + + function checkBlake2fFinalFlagFailsClosed(uint8 flag) public { + vm.assume(flag > 1); + bytes memory input = new bytes(213); + input[212] = bytes1(flag); + + (bool ok,) = address(0x09).staticcall(input); + assert(ok); + } + + function checkSetEvmVersionFailsClosed() public { + vm.setEvmVersion("london"); + } + + function checkGetEvmVersionFailsClosed() public view { + assertEq(vm.getEvmVersion(), "cancun"); + } + + function checkExpectSafeMemoryFailsClosed() public { + vm.expectSafeMemory(0x80, 0xA0); + } + + function checkExpectSafeMemoryCallFailsClosed() public { + vm.expectSafeMemoryCall(0x80, 0xA0); + } + + function checkStopExpectSafeMemoryFailsClosed() public { + vm.stopExpectSafeMemory(); + } + + function checkLastCallGasFailsClosed() public view { + Vm.Gas memory gas = vm.lastCallGas(); + assert(gas.gasTotalUsed == 0); + } + + function checkLastFrameGasFailsClosed() public view { + (bool success, bytes memory data) = address(vm).staticcall(abi.encodeWithSignature("lastFrameGas()")); + assert(success && data.length != 0); + } + + function checkSnapshotGasLastCallFailsClosed() public { + uint256 gasUsed = vm.snapshotGasLastCall("name"); + assert(gasUsed == 0); + } + + function checkSnapshotGasLastFrameFailsClosed() public { + (bool success, bytes memory data) = + address(vm).call(abi.encodeWithSignature("snapshotGasLastFrame(string)", "name")); + assert(success && data.length != 0); + } + + function checkStopSnapshotGasFailsClosed() public { + uint256 gasUsed = vm.stopSnapshotGas(); + assert(gasUsed == 0); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-contract", + "SymbolicFalsePassPrevention", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 18 tests for test/SymbolicFalsePassPrevention.t.sol:SymbolicFalsePassPrevention +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: symbolic blake2f precompile final flag not modeled] checkBlake2fFinalFlagFailsClosed(uint8) ([METRICS]) +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: symbolic bn254 precompile validity not modeled] checkBn254AddPrecompileFailsClosed(uint256) ([METRICS]) +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: symbolic bn254 precompile validity not modeled] checkBn254MulPrecompileFailsClosed(uint256) ([METRICS]) +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: symbolic bn254 precompile validity not modeled] checkBn254PairingPrecompileFailsClosed(uint256) ([METRICS]) +[PASS] checkConcreteAddmodMulmodUseUnboundedIntermediate() ([METRICS]) +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: symbolic vm.expectSafeMemoryCall not modeled] checkExpectSafeMemoryCallFailsClosed() ([METRICS]) +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: symbolic vm.expectSafeMemory not modeled] checkExpectSafeMemoryFailsClosed() ([METRICS]) +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: symbolic vm.getEvmVersion not modeled] checkGetEvmVersionFailsClosed() ([METRICS]) +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkKzgPrecompileInvalidWitnessCounterexample(bytes32) ([METRICS]) +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: symbolic vm.lastCallGas not modeled] checkLastCallGasFailsClosed() ([METRICS]) +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: symbolic vm.lastFrameGas not modeled] checkLastFrameGasFailsClosed() ([METRICS]) +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: symbolic vm.setEvmVersion not modeled] checkSetEvmVersionFailsClosed() ([METRICS]) +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: symbolic vm.snapshotGasLastCall not modeled] checkSnapshotGasLastCallFailsClosed() ([METRICS]) +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: symbolic vm.snapshotGasLastFrame not modeled] checkSnapshotGasLastFrameFailsClosed() ([METRICS]) +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: symbolic vm.stopExpectSafeMemory not modeled] checkStopExpectSafeMemoryFailsClosed() ([METRICS]) +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: symbolic vm.stopSnapshotGas not modeled] checkStopSnapshotGasFailsClosed() ([METRICS]) +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkSymbolicAddmodFailsClosed(uint256) ([METRICS]) +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkSymbolicMulmodFailsClosed(uint256) ([METRICS]) +Suite result: FAILED. 1 passed; 17 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic KZG point-evaluation precompile"), "{stdout}"); +} diff --git a/crates/forge/tests/cli/test_cmd/symbolic_helpers.rs b/crates/forge/tests/cli/test_cmd/symbolic_helpers.rs new file mode 100644 index 0000000000000..00071113da31c --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/symbolic_helpers.rs @@ -0,0 +1,104 @@ +use foundry_test_utils::{ + TestCommand, + snapbox::{Data, IntoData, assert_data_eq, cmd::OutputAssert}, +}; +use serde_json::Value; +use std::{path::PathBuf, process::Command}; + +pub fn z3_available() -> bool { + Command::new("z3").arg("--version").output().is_ok_and(|output| output.status.success()) +} + +#[macro_export] +macro_rules! skip_unless_z3 { + ($name:literal) => { + if !$crate::test_cmd::symbolic_helpers::z3_available() { + let _ = foundry_common::sh_eprintln!("skipping {} because z3 is not available", $name); + return; + } + }; +} + +/// Run a symbolic test with redactions that mask solver-dependent / wall-clock +/// noise so the snapshot is stable across solver versions and runs. +/// +/// - `[METRICS]` — symbolic metrics line suffix (engine internal metrics change with solver +/// heuristic / engine path-pruning changes). +/// - `[SENDER]` — rendered symbolic senders / target addresses, which the solver picks freely from +/// an unconstrained address pool. +pub fn assert_symbolic(cmd: &mut TestCommand) -> OutputAssert { + cmd.assert_with(&[ + ("[METRICS]", r"(?:paths: \d+, queries: \d+(?:, smt: \d+, sat: \d+ \(\d+ cached\), models: \d+ \(\d+ cached\), hard-arith: \d+, solver: \d+ms)?|runs: \d+, calls: \d+, reverts: \d+)"), + ("[SENDER]", r"(?:sender=|addr=(?:\[[^\]]+\])?)0x[0-9a-fA-F]{40}"), + ]) +} + +/// Run a symbolic test while requiring symbolic engine metrics rather than invariant fuzz metrics. +pub fn assert_symbolic_engine(cmd: &mut TestCommand) -> OutputAssert { + cmd.assert_with(&[ + ("[METRICS]", r"paths: \d+, queries: \d+(?:, smt: \d+, sat: \d+ \(\d+ cached\), models: \d+ \(\d+ cached\), hard-arith: \d+, solver: \d+ms)?"), + ("[SENDER]", r"(?:sender=|addr=(?:\[[^\]]+\])?)0x[0-9a-fA-F]{40}"), + ]) +} + +/// Same as [`assert_symbolic`], plus redactions for counterexample witnesses +/// whose exact values Z3 chooses freely (calldata bytes, args list, raw +/// addresses inside args). Use for tests whose property only asserts that +/// *some* counterexample exists, not what it is. +pub fn assert_symbolic_witness(cmd: &mut TestCommand) -> OutputAssert { + cmd.assert_with(&[ + ("[METRICS]", r"(?:paths: \d+, queries: \d+(?:, smt: \d+, sat: \d+ \(\d+ cached\), models: \d+ \(\d+ cached\), hard-arith: \d+, solver: \d+ms)?|runs: \d+, calls: \d+, reverts: \d+)"), + ("[SENDER]", r"(?:sender=|addr=(?:\[[^\]]+\])?)0x[0-9a-fA-F]{40}"), + ("[CALLDATA]", r"calldata=0x[0-9a-fA-F]+"), + // `args=[...]` may contain nested scientific-notation brackets like + // `args=[1234 [1.2e3], 5678 [5.6e3]]`, so allow one level of nesting. + ("[ARGS]", r"args=\[(?:[^\[\]]|\[[^\]]*\])*\]"), + ]) +} + +/// Same as [`assert_symbolic_witness`], but requires symbolic engine metrics rather than +/// accepting ordinary invariant fuzz campaign metrics. +pub fn assert_symbolic_engine_witness(cmd: &mut TestCommand) -> OutputAssert { + cmd.assert_with(&[ + ("[METRICS]", r"paths: \d+, queries: \d+(?:, smt: \d+, sat: \d+ \(\d+ cached\), models: \d+ \(\d+ cached\), hard-arith: \d+, solver: \d+ms)?"), + ("[SENDER]", r"(?:sender=|addr=(?:\[[^\]]+\])?)0x[0-9a-fA-F]{40}"), + ("[CALLDATA]", r"calldata=0x[0-9a-fA-F]+"), + ("[ARGS]", r"args=\[(?:[^\[\]]|\[[^\]]*\])*\]"), + ]) +} + +pub fn json_test_result(stdout: &[u8], signature: &str) -> Value { + let json: Value = serde_json::from_slice(stdout).expect("forge test --json output"); + let suites = json.as_object().expect("top-level suites object"); + for suite in suites.values() { + if let Some(result) = suite["test_results"].get(signature) { + return result.clone(); + } + } + panic!("missing JSON test result for {signature}: {json}"); +} + +pub fn read_artifact_ref(artifact_ref: &Value) -> Value { + let artifact_path = artifact_ref["path"].as_str().expect("symbolic artifact path"); + let artifact_path = PathBuf::from(artifact_path); + let artifact = std::fs::read_to_string(&artifact_path) + .unwrap_or_else(|err| panic!("failed to read artifact {}: {err}", artifact_path.display())); + serde_json::from_str(&artifact).expect("symbolic counterexample artifact") +} + +pub fn assert_relevant_lines(stdout: &str, expected: impl IntoData) { + let expected = expected.into_data(); + let expected_lines = expected.to_string(); + let mut actual = String::new(); + + for expected_line in expected_lines.lines().filter(|line| !line.is_empty()) { + stdout + .lines() + .find(|line| line.contains(expected_line)) + .unwrap_or_else(|| panic!("missing line `{expected_line}` in stdout:\n{stdout}")); + actual.push_str(expected_line); + actual.push('\n'); + } + + assert_data_eq!(Data::from(actual.trim_end_matches('\n')), expected); +} diff --git a/crates/forge/tests/cli/test_cmd/symbolic_invariant.rs b/crates/forge/tests/cli/test_cmd/symbolic_invariant.rs new file mode 100644 index 0000000000000..1eecd9e4581f8 --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/symbolic_invariant.rs @@ -0,0 +1,2282 @@ +use super::symbolic_helpers::{ + assert_symbolic, assert_symbolic_engine, assert_symbolic_engine_witness, + assert_symbolic_witness, json_test_result, read_artifact_ref, +}; +use crate::skip_unless_z3; +use foundry_test_utils::{forgetest_init, str, util::OutputExt}; + +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +#[forgetest_init] +fn symbolic_invariant_runs_before_fuzz_campaign(prj: _, cmd: _) { + skip_unless_z3!("symbolic_invariant_runs_before_fuzz_campaign"); + prj.update_config(|config| config.invariant.runs = 0); + + prj.add_test( + "SymbolicInvariantRuns.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicInvariantTarget { + uint256 public counter; + + function bump(uint8 amount) external { + if (amount == 7) { + counter = 1; + } + } +} + +contract SymbolicInvariantRuns is Test { + SymbolicInvariantTarget target; + + function setUp() public { + target = new SymbolicInvariantTarget(); + targetContract(address(target)); + } + + /// forge-config: default.symbolic.invariant_depth = 1 + function invariant_counterStaysZero() public view { + assertEq(target.counter(), 0); + } +} +"#, + ); + + assert_symbolic_engine_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "invariant_counterStaysZero", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicInvariantRuns.t.sol:SymbolicInvariantRuns +[FAIL: assertion failed: 1 != 0] + [Sequence] (original: 1, shrunk: 1) + [SENDER] [SENDER] calldata=bump(uint8) [ARGS] + invariant_counterStaysZero() (runs: 1, calls: 1, reverts: 0) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/SymbolicInvariantRuns.t.sol:SymbolicInvariantRuns +... +"#]]); +} + +#[forgetest_init] +fn symbolic_invariant_ignores_bool_return(prj: _, cmd: _) { + skip_unless_z3!("symbolic_invariant_ignores_bool_return"); + prj.update_config(|config| config.invariant.runs = 0); + + prj.add_test( + "SymbolicInvariantBoolReturn.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicBoolReturnTarget { + uint256 public counter; + + function bump() external { + counter = 1; + } +} + +contract SymbolicInvariantBoolReturn is Test { + function setUp() public { + targetContract(address(new SymbolicBoolReturnTarget())); + targetSender(address(this)); + } + + /// forge-config: default.symbolic.invariant_depth = 1 + function invariant_alwaysFalseButNeverAsserts() public pure returns (bool) { + return false; + } +} +"#, + ); + + let output = cmd + .args([ + "test", + "--symbolic", + "--json", + "--match-test", + "invariant_alwaysFalseButNeverAsserts", + ]) + .assert_success() + .get_output() + .stdout + .clone(); + let result = json_test_result(&output, "invariant_alwaysFalseButNeverAsserts()"); + assert_eq!(result["status"], "Success"); + assert_eq!(result["symbolic"]["status"], "pass", "{}", result["symbolic"]["incomplete"]); +} + +#[forgetest_init] +fn symbolic_invariant_safe_still_runs_fuzz_campaign(prj: _, cmd: _) { + skip_unless_z3!("symbolic_invariant_safe_still_runs_fuzz_campaign"); + + prj.add_test( + "SymbolicInvariantSafeRunsFuzz.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicSafeThenFuzzTarget { + uint256 public count; + + function step() external { + count++; + } +} + +contract SymbolicInvariantSafeRunsFuzz is Test { + SymbolicSafeThenFuzzTarget target; + + function setUp() public { + target = new SymbolicSafeThenFuzzTarget(); + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = target.step.selector; + targetSelector(FuzzSelector({addr: address(target), selectors: selectors})); + targetContract(address(target)); + targetSender(address(this)); + } + + // Symbolic checks the one-call prefix; invariant fuzzing must still run the two-call case. + /// forge-config: default.symbolic.invariant_depth = 1 + /// forge-config: default.invariant.runs = 1 + /// forge-config: default.invariant.depth = 2 + function invariant_countBelowTwo() public view { + assertLt(target.count(), 2); + } +} +"#, + ); + + let output = cmd + .args(["test", "--symbolic", "--json", "--match-test", "invariant_countBelowTwo"]) + .assert_failure() + .get_output() + .stdout + .clone(); + let result = json_test_result(&output, "invariant_countBelowTwo()"); + assert_eq!(result["status"], "Failure"); + assert_eq!(result["symbolic"]["status"], "pass"); + assert_eq!(result["kind"]["Invariant"]["runs"], 1); + assert_eq!(result["kind"]["Invariant"]["calls"], 2); + assert_eq!(result["kind"]["Invariant"]["reverts"], 0); +} + +#[forgetest_init] +fn symbolic_invariant_replays_setup_arbitrary_storage(prj: _, cmd: _) { + skip_unless_z3!("symbolic_invariant_replays_setup_arbitrary_storage"); + prj.update_config(|config| config.invariant.runs = 0); + + prj.add_test( + "SymbolicInvariantSetupStorage.t.sol", + r#" +import "forge-std/Test.sol"; + +interface ArbitraryStorageVm { + function setArbitraryStorage(address target) external; +} + +contract ExternalStore { + uint256 public value; +} + +contract StorageBackedTarget { + ExternalStore store; + bool public hit; + + constructor(ExternalStore store_) { + store = store_; + } + + function useStore() external { + require(store.value() == 42); + hit = true; + } +} + +contract SymbolicInvariantSetupStorage is Test { + ExternalStore store; + StorageBackedTarget target; + + function setUp() public { + store = new ExternalStore(); + target = new StorageBackedTarget(store); + ArbitraryStorageVm(address(vm)).setArbitraryStorage(address(store)); + targetContract(address(target)); + } + + /// forge-config: default.symbolic.invariant_depth = 1 + function invariant_notHit() public view { + require(!target.hit(), "hit"); + } +} +"#, + ); + + let output = cmd + .args(["test", "--symbolic", "--json", "--match-test", "invariant_notHit"]) + .assert_failure() + .get_output() + .stdout + .clone(); + let result = json_test_result(&output, "invariant_notHit()"); + assert_eq!(result["status"], "Failure"); + assert_eq!(result["symbolic"]["status"], "fail_counterexample"); + + let failures = result["invariant_failures"].as_array().expect("invariant failures"); + let failure = failures.first().expect("invariant failure"); + let artifact_ref = &failure["artifact"]; + let artifact = read_artifact_ref(artifact_ref); + assert_eq!(artifact["invariant_failure"]["kind"], "predicate"); + assert_eq!(artifact["invariant_failure"]["name"], "invariant_notHit"); + assert!(artifact["invariant_failure"]["site"].is_object()); + assert_eq!(artifact["storage"].as_array().expect("storage assignments").len(), 1); + assert_eq!(artifact["storage"][0]["value"], "0x2a"); + + let persisted_failure_path = prj + .root() + .join("cache/invariant/failures/SymbolicInvariantSetupStorage/invariants/invariant_notHit"); + + assert_symbolic_witness(cmd.forge_fuse().args([ + "fuzz", + "replay", + "--mc", + "SymbolicInvariantSetupStorage", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicInvariantSetupStorage.t.sol:SymbolicInvariantSetupStorage +[FAIL: hit] + [Sequence] (original: 1, shrunk: 1) + [SENDER] [SENDER] calldata=useStore() [ARGS] + invariant_notHit() ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); + let persisted_failure: serde_json::Value = + serde_json::from_slice(&std::fs::read(&persisted_failure_path).unwrap()).unwrap(); + assert_eq!(persisted_failure["failure_site"], artifact["invariant_failure"]["site"]); + + cmd.forge_fuse().args(["test", "--rerun"]); + assert_symbolic_witness(&mut cmd).failure().stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicInvariantSetupStorage.t.sol:SymbolicInvariantSetupStorage +[FAIL: hit] + [Sequence] (original: 1, shrunk: 1) + [SENDER] [SENDER] calldata=useStore() [ARGS] + invariant_notHit() ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); + + let artifact_path = artifact_ref["path"].as_str().expect("artifact path").to_string(); + assert_symbolic_witness(cmd.forge_fuse().args([ + "test", + "--replay-symbolic-artifact", + &artifact_path, + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicInvariantSetupStorage.t.sol:SymbolicInvariantSetupStorage +[FAIL: hit] + [Sequence] (original: 1, shrunk: 1) + [SENDER] [SENDER] calldata=useStore() [ARGS] + invariant_notHit() ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_secondary_replay_preserves_storage(prj: _, cmd: _) { + skip_unless_z3!("symbolic_secondary_replay_preserves_storage"); + + prj.add_test( + "SymbolicSecondaryStorage.t.sol", + r#" +import "forge-std/Test.sol"; + +interface ArbitraryStorageVm { + function setArbitraryStorage(address target) external; +} + +contract SecondaryExternalStore { + uint256 public value; +} + +contract SecondaryStorageTarget { + SecondaryExternalStore store; + bool public hit; + bool public anchorHit; + + constructor(SecondaryExternalStore store_) { + store = store_; + } + + function useStore() external { + require(store.value() == 42); + hit = true; + } + + function breakAnchor() external { + anchorHit = true; + } +} + +contract SymbolicSecondaryStorage is Test { + SecondaryExternalStore store; + SecondaryStorageTarget target; + + function setUp() public { + store = new SecondaryExternalStore(); + target = new SecondaryStorageTarget(store); + ArbitraryStorageVm(address(vm)).setArbitraryStorage(address(store)); + targetContract(address(target)); + } + + function invariant_anchor() public view { + require(!target.anchorHit(), "anchor hit"); + } + + /// forge-config: default.symbolic.invariant_depth = 1 + function invariant_secondary() public view { + require(!target.hit(), "hit"); + } +} +"#, + ); + + let output = cmd + .args(["test", "--symbolic", "--json", "--match-test", "invariant_secondary"]) + .assert_failure() + .get_output() + .stdout + .clone(); + let result = json_test_result(&output, "invariant_secondary()"); + let artifact_ref = &result["invariant_failures"][0]["artifact"]; + let original_artifact = read_artifact_ref(artifact_ref); + assert_eq!(original_artifact["invariant_failure"]["kind"], "predicate"); + assert_eq!(original_artifact["invariant_failure"]["name"], "invariant_secondary"); + assert!(original_artifact["invariant_failure"]["site"].is_object()); + + let persisted = prj + .root() + .join("cache/invariant/failures/SymbolicSecondaryStorage/invariants/invariant_secondary"); + let persisted_anchor = prj + .root() + .join("cache/invariant/failures/SymbolicSecondaryStorage/invariants/invariant_anchor"); + let original: serde_json::Value = + serde_json::from_slice(&std::fs::read(&persisted).unwrap()).unwrap(); + assert_eq!(original["storage"].as_array().expect("storage assignments").len(), 1); + assert_eq!(original["storage"][0]["value"], "0x2a"); + + prj.update_config(|config| { + config.invariant.runs = 0; + }); + for _ in 0..2 { + let output = cmd + .forge_fuse() + .args(["test", "--symbolic", "--json", "--match-test", "invariant_"]) + .assert_failure() + .get_output() + .stdout + .clone(); + let result = json_test_result(&output, "invariant_anchor()"); + assert_eq!(result["symbolic"]["status"], "fail_counterexample"); + let failures = result["invariant_failures"].as_array().expect("invariant failures"); + assert_eq!(failures.len(), 2); + assert_eq!(failures[0]["name"], "invariant_anchor"); + assert_eq!(failures[0]["reason"], "anchor hit"); + assert_eq!(failures[1]["name"], "invariant_secondary"); + assert_eq!(failures[1]["reason"], "hit"); + assert_eq!(failures[1]["counterexample"]["Sequence"][1][0]["func_name"], "useStore"); + + let replayed: serde_json::Value = + serde_json::from_slice(&std::fs::read(&persisted).unwrap()).unwrap(); + assert_eq!(replayed, original); + + let replayed_artifact = read_artifact_ref(artifact_ref); + assert_eq!(replayed_artifact["invariant_failure"], original_artifact["invariant_failure"]); + + std::fs::remove_file(&persisted_anchor).unwrap(); + } +} + +#[forgetest_init] +fn symbolic_predicate_artifact_rejects_handler_assertion_replay(prj: _, cmd: _) { + skip_unless_z3!("symbolic_predicate_artifact_rejects_handler_assertion_replay"); + prj.update_config(|config| config.invariant.runs = 0); + + let test_path = prj.add_test( + "SymbolicPredicateArtifactOrigin.t.sol", + r#" +import "forge-std/Test.sol"; + +contract PredicateArtifactTarget { + bool public hit; + + function trigger() external { + hit = true; + } +} + +contract SymbolicPredicateArtifactOrigin is Test { + PredicateArtifactTarget target; + + function setUp() public { + target = new PredicateArtifactTarget(); + targetContract(address(target)); + targetSender(address(this)); + } + + /// forge-config: default.symbolic.invariant_depth = 1 + function invariant_notHit() public view { + require(!target.hit(), "hit"); + } +} +"#, + ); + + let output = cmd + .args(["test", "--symbolic", "--json", "--match-test", "invariant_notHit"]) + .assert_failure() + .get_output() + .stdout + .clone(); + let result = json_test_result(&output, "invariant_notHit()"); + assert_eq!(result["symbolic"]["status"], "fail_counterexample"); + let artifact_path = result["invariant_failures"][0]["artifact"]["path"] + .as_str() + .expect("predicate artifact path") + .to_string(); + let artifact = read_artifact_ref(&result["invariant_failures"][0]["artifact"]); + + let mut missing_site_artifact = artifact.clone(); + missing_site_artifact["invariant_failure"] + .as_object_mut() + .expect("predicate failure object") + .remove("site"); + std::fs::write(&artifact_path, serde_json::to_vec_pretty(&missing_site_artifact).unwrap()) + .unwrap(); + let missing_site_output = cmd + .forge_fuse() + .args(["test", "--json", "--replay-symbolic-artifact", &artifact_path]) + .assert_failure() + .get_output() + .stdout + .clone(); + let missing_site_result = json_test_result(&missing_site_output, "invariant_notHit()"); + assert!( + missing_site_result["reason"] + .as_str() + .expect("missing-site replay mismatch reason") + .contains("does not identify an exact predicate failure site"), + "{missing_site_result}" + ); + std::fs::write(&artifact_path, serde_json::to_vec_pretty(&artifact).unwrap()).unwrap(); + + std::fs::write( + test_path, + r#" +import "forge-std/Test.sol"; + +contract PredicateArtifactTarget { + bool public hit; + + function trigger() external { + hit = !hit; + assert(false); + } +} + +contract SymbolicPredicateArtifactOrigin is Test { + PredicateArtifactTarget target; + + function setUp() public { + target = new PredicateArtifactTarget(); + targetContract(address(target)); + targetSender(address(this)); + } + + function invariant_notHit() public view { + require(!target.hit(), "hit"); + } +} +"#, + ) + .unwrap(); + + let replay_output = cmd + .forge_fuse() + .args(["test", "--json", "--replay-symbolic-artifact", &artifact_path]) + .assert_failure() + .get_output() + .stdout + .clone(); + let replay_result = json_test_result(&replay_output, "invariant_notHit()"); + assert!( + replay_result["reason"] + .as_str() + .expect("replay mismatch reason") + .contains("different failure origin than the stored predicate"), + "{replay_result}" + ); +} + +#[forgetest_init] +fn symbolic_invariant_handler_failure_stays_handler(prj: _, cmd: _) { + skip_unless_z3!("symbolic_invariant_handler_failure_stays_handler"); + prj.update_config(|config| { + config.invariant.runs = 0; + config.invariant.fail_on_revert = false; + config.invariant.corpus.corpus_dir = Some("inv_corpus".into()); + }); + prj.add_test( + "SymbolicInvariantHandlerFailure.t.sol", + r#" +pragma solidity ^0.8.20; + +import "forge-std/Test.sol"; + +interface ArbitraryStorageVm { + function setArbitraryStorage(address target) external; +} + +contract HandlerStore { + uint256 public value; +} + +contract NestedHandlerAssertion { + function boom() external pure { + assert(false); + } +} + +contract SymbolicInvariantHandlerFailureTarget { + HandlerStore store; + NestedHandlerAssertion nested; + uint256 sink; + + constructor(HandlerStore store_) { + store = store_; + nested = new NestedHandlerAssertion(); + } + + function boom(uint8 x) external { + sink = x; + if (x == 7 && store.value() == 42) { + nested.boom(); + } + } +} + +contract SymbolicInvariantHandlerFailure is Test { + HandlerStore store; + SymbolicInvariantHandlerFailureTarget target; + + function setUp() public { + store = new HandlerStore(); + target = new SymbolicInvariantHandlerFailureTarget(store); + ArbitraryStorageVm(address(vm)).setArbitraryStorage(address(store)); + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = target.boom.selector; + targetSelector(FuzzSelector({addr: address(target), selectors: selectors})); + targetContract(address(target)); + } + + /// forge-config: default.symbolic.invariant_depth = 1 + function invariant_ok() public pure {} +} +"#, + ); + + let output = cmd + .args(["test", "--symbolic", "--json", "--match-test", "invariant_ok"]) + .assert_failure() + .get_output() + .stdout + .clone(); + let result = json_test_result(&output, "invariant_ok()"); + assert_eq!(result["status"], "Failure"); + assert_eq!(result["symbolic"]["status"], "fail_counterexample"); + assert!(result["kind"].get("Invariant").is_some()); + assert!(result["kind"].get("Symbolic").is_none()); + assert!( + result + .get("invariant_failures") + .and_then(|value| value.as_array()) + .is_none_or(Vec::is_empty) + ); + + let handler_failures = + result["invariant_handler_failures"].as_array().expect("handler failures"); + assert_eq!(handler_failures.len(), 1); + let handler_failure = &handler_failures[0]; + assert_eq!(handler_failure["kind"], "handler"); + assert!( + handler_failure["name"] + .as_str() + .expect("handler name") + .ends_with("SymbolicInvariantHandlerFailureTarget::boom"), + "{handler_failure}" + ); + assert!( + handler_failure["reason"].as_str().expect("handler reason").contains("assertion failed"), + "{handler_failure}" + ); + let artifact_ref = &handler_failure["artifact"]; + let artifact_path = artifact_ref["path"].as_str().expect("artifact path").to_string(); + let artifact = read_artifact_ref(artifact_ref); + assert_eq!(artifact["invariant_failure"]["kind"], "handler"); + assert!( + artifact["invariant_failure"]["name"] + .as_str() + .expect("artifact handler name") + .ends_with("SymbolicInvariantHandlerFailureTarget::boom"), + "{artifact}" + ); + assert_eq!(artifact["storage"].as_array().expect("storage assignments").len(), 1); + assert_eq!(artifact["storage"][0]["value"], "0x2a"); + + let rerun_output = cmd + .forge_fuse() + .args(["test", "--json", "--rerun"]) + .assert_failure() + .get_output() + .stdout + .clone(); + let rerun_result = json_test_result(&rerun_output, "invariant_ok()"); + assert!( + rerun_result + .get("invariant_failures") + .and_then(|value| value.as_array()) + .is_none_or(Vec::is_empty), + "{rerun_result}" + ); + let rerun_handler_failures = + rerun_result["invariant_handler_failures"].as_array().expect("rerun handler failures"); + assert_eq!(rerun_handler_failures.len(), 1); + assert_eq!(rerun_handler_failures[0]["kind"], "handler"); + + let replay_output = cmd + .forge_fuse() + .args(["test", "--json", "--replay-symbolic-artifact", &artifact_path]) + .assert_failure() + .get_output() + .stdout + .clone(); + let replay_result = json_test_result(&replay_output, "invariant_ok()"); + assert!( + replay_result + .get("invariant_failures") + .and_then(|value| value.as_array()) + .is_none_or(Vec::is_empty), + "{replay_result}" + ); + let replay_handler_failures = + replay_result["invariant_handler_failures"].as_array().expect("replay handler failures"); + assert_eq!(replay_handler_failures.len(), 1); + assert_eq!(replay_handler_failures[0]["kind"], "handler"); + + // Artifacts produced before handler identities were canonicalized stored the innermost + // reverter. They remain replayable when that exact legacy reverter is observed again. + let mut legacy_artifact = artifact; + let outer: alloy_primitives::Address = legacy_artifact["calls"][0]["target"] + .as_str() + .expect("outer handler target") + .parse() + .unwrap(); + let selector: alloy_primitives::Selector = + legacy_artifact["invariant_failure"]["selector"].as_str().unwrap().parse().unwrap(); + let legacy_reverter = outer.create(1); + let mut identity = [0u8; 24]; + identity[..20].copy_from_slice(legacy_reverter.as_slice()); + identity[20..].copy_from_slice(selector.as_slice()); + legacy_artifact["invariant_failure"]["reverter"] = + serde_json::Value::String(legacy_reverter.to_string()); + legacy_artifact["invariant_failure"]["fingerprint"] = + serde_json::Value::String(format!("{:#x}", alloy_primitives::keccak256(identity))); + std::fs::write(&artifact_path, serde_json::to_vec_pretty(&legacy_artifact).unwrap()).unwrap(); + + let legacy_replay_output = cmd + .forge_fuse() + .args(["test", "--json", "--replay-symbolic-artifact", &artifact_path]) + .assert_failure() + .get_output() + .stdout + .clone(); + let legacy_replay_result = json_test_result(&legacy_replay_output, "invariant_ok()"); + let legacy_handler_failures = legacy_replay_result["invariant_handler_failures"] + .as_array() + .expect("legacy handler failures"); + assert_eq!(legacy_handler_failures.len(), 1); + assert!( + legacy_handler_failures[0]["name"] + .as_str() + .expect("legacy handler name") + .ends_with("SymbolicInvariantHandlerFailureTarget::boom") + ); + + let unrelated_reverter = outer.create(2); + identity[..20].copy_from_slice(unrelated_reverter.as_slice()); + legacy_artifact["invariant_failure"]["reverter"] = + serde_json::Value::String(unrelated_reverter.to_string()); + legacy_artifact["invariant_failure"]["fingerprint"] = + serde_json::Value::String(format!("{:#x}", alloy_primitives::keccak256(identity))); + std::fs::write(&artifact_path, serde_json::to_vec_pretty(&legacy_artifact).unwrap()).unwrap(); + let unrelated_output = cmd + .forge_fuse() + .args(["test", "--json", "--replay-symbolic-artifact", &artifact_path]) + .assert_failure() + .get_output() + .stdout + .clone(); + let unrelated_result = json_test_result(&unrelated_output, "invariant_ok()"); + assert!( + unrelated_result["reason"].as_str().unwrap().contains("different handler failure site"), + "{unrelated_result}" + ); +} + +#[forgetest_init] +fn symbolic_invariant_omits_unchecked_predicate_pass_rows(prj: _, cmd: _) { + skip_unless_z3!("symbolic_invariant_omits_unchecked_predicate_pass_rows"); + prj.update_config(|config| config.invariant.runs = 0); + + prj.add_test( + "SymbolicInvariantMultiPredicate.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicInvariantMultiPredicateTarget { + uint256 public value; + + function set(uint8 x) external { + if (x == 7) { + value = 1; + } + } +} + +contract SymbolicInvariantMultiPredicate is Test { + SymbolicInvariantMultiPredicateTarget target; + + function setUp() public { + target = new SymbolicInvariantMultiPredicateTarget(); + targetContract(address(target)); + } + + /// forge-config: default.symbolic.invariant_depth = 1 + function invariant_anchorBreak() public view { + assertEq(target.value(), 0); + } + + function invariant_neverCheckedBySymbolicFastFail() public pure { + assertTrue(true); + } +} +"#, + ); + + let output = cmd + .args([ + "test", + "--symbolic", + "--json", + "--match-contract", + "SymbolicInvariantMultiPredicate", + ]) + .assert_failure() + .get_output() + .stdout + .clone(); + let result = json_test_result(&output, "invariant_anchorBreak()"); + assert_eq!(result["status"], "Failure"); + assert_eq!(result["symbolic"]["status"], "fail_counterexample"); + assert_eq!(result["invariant_count"], 2); + + let predicates = result["invariant_predicate_results"].as_array().unwrap(); + assert_eq!(predicates.len(), 1); + assert_eq!(predicates[0]["name"], "invariant_anchorBreak"); + assert_eq!(predicates[0]["status"], "Failure"); + assert!(predicates.iter().all(|predicate| predicate["status"] != "Success")); +} + +#[forgetest_init] +fn symbolic_invariant_replays_copied_arbitrary_storage(prj: _, cmd: _) { + skip_unless_z3!("symbolic_invariant_replays_copied_arbitrary_storage"); + prj.update_config(|config| config.invariant.runs = 0); + + prj.add_test( + "SymbolicInvariantCopiedStorage.t.sol", + r#" +import "forge-std/Test.sol"; + +interface ArbitraryStorageVm { + function setArbitraryStorage(address target) external; + function copyStorage(address from, address to) external; +} + +contract ExternalStore { + uint256 public value; +} + +contract StorageBackedTarget { + ExternalStore store; + bool public hit; + + constructor(ExternalStore store_) { + store = store_; + } + + function useStore() external { + require(store.value() == 42); + hit = true; + } +} + +contract SymbolicInvariantCopiedStorage is Test { + ExternalStore source; + ExternalStore copy; + StorageBackedTarget target; + + function setUp() public { + source = new ExternalStore(); + copy = new ExternalStore(); + target = new StorageBackedTarget(copy); + ArbitraryStorageVm(address(vm)).setArbitraryStorage(address(source)); + ArbitraryStorageVm(address(vm)).copyStorage(address(source), address(copy)); + targetContract(address(target)); + } + + /// forge-config: default.symbolic.invariant_depth = 1 + function invariant_notHit() public view { + require(!target.hit(), "hit"); + } +} +"#, + ); + + let stdout = assert_symbolic_engine_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "invariant_notHit", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicInvariantCopiedStorage.t.sol:SymbolicInvariantCopiedStorage +[FAIL: hit] + [Sequence] (original: 1, shrunk: 1) + [SENDER] [SENDER] calldata=useStore() [ARGS] + invariant_notHit() (runs: 1, calls: 1, reverts: 0) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic invariant counterexample did not replay"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_invariant_replays_initial_state_failure(prj: _, cmd: _) { + skip_unless_z3!("symbolic_invariant_replays_initial_state_failure"); + prj.update_config(|config| config.invariant.runs = 0); + + prj.add_test( + "SymbolicInvariantInitialState.t.sol", + r#" +import "forge-std/Test.sol"; + +contract NoopTarget { + function touch() external {} +} + +contract SymbolicInvariantInitialState is Test { + NoopTarget target; + uint256 x; + + function setUp() public { + target = new NoopTarget(); + targetContract(address(target)); + } + + /// forge-config: default.symbolic.invariant_depth = 1 + function invariant_xIsOne() public view { + assertEq(x, 1); + } +} +"#, + ); + + let output = assert_symbolic_engine(cmd.args([ + "test", + "--symbolic", + "--json", + "--match-test", + "invariant_xIsOne", + ])) + .failure() + .get_output() + .stdout + .clone(); + let result = json_test_result(&output, "invariant_xIsOne()"); + assert_eq!(result["status"], "Failure"); + assert_eq!(result["symbolic"]["status"], "fail_counterexample"); + assert_eq!(result["invariant_failures"][0]["counterexample"]["Sequence"][0], 1); + assert!( + result["invariant_failures"][0]["counterexample"]["Sequence"][1] + .as_array() + .unwrap() + .is_empty() + ); + + let artifact_ref = &result["invariant_failures"][0]["artifact"]; + assert_eq!(result["symbolic"]["artifact"], *artifact_ref); + let artifact_path = artifact_ref["path"].as_str().unwrap(); + assert!(result["symbolic"].get("minimization").is_none()); + assert!(result["invariant_failures"][0].get("minimization").is_none()); + let artifact = read_artifact_ref(artifact_ref); + assert_eq!(artifact["calls"].as_array().unwrap().len(), 1); + + let replay_output = cmd + .forge_fuse() + .args(["test", "--json", "--replay-symbolic-artifact", artifact_path]) + .assert_failure() + .get_output() + .stdout + .clone(); + let replay_result = json_test_result(&replay_output, "invariant_xIsOne()"); + assert_eq!(replay_result["status"], "Failure"); + assert!( + replay_result["reason"].as_str().unwrap().contains("assertion failed"), + "{replay_result}" + ); +} + +#[forgetest_init] +fn symbolic_after_invariant_runs_only_at_terminal_depth(prj: _, cmd: _) { + skip_unless_z3!("symbolic_after_invariant_runs_only_at_terminal_depth"); + prj.update_config(|config| config.invariant.runs = 0); + + prj.add_test( + "SymbolicAfterInvariantTerminal.t.sol", + r#" +import "forge-std/Test.sol"; + +contract ToggleTarget { + bool public active; + + function step() external { + active = !active; + } +} + +contract SymbolicAfterInvariantTerminal is Test { + ToggleTarget target; + + function setUp() public { + target = new ToggleTarget(); + targetContract(address(target)); + targetSender(address(this)); + } + + /// forge-config: default.symbolic.invariant_depth = 2 + /// forge-config: default.invariant.check_interval = 1 + function invariant_ok() public pure {} + + function afterInvariant() public view { + assertFalse(target.active()); + } +} +"#, + ); + + let output = cmd + .args(["test", "--symbolic", "--json", "--match-test", "invariant_ok"]) + .assert_success() + .get_output() + .stdout + .clone(); + let result = json_test_result(&output, "invariant_ok()"); + assert_eq!(result["status"], "Success"); + assert_eq!(result["symbolic"]["status"], "pass"); +} + +#[forgetest_init] +fn symbolic_invariant_checks_preserve_storage_hook_registration(prj: _, cmd: _) { + skip_unless_z3!("symbolic_invariant_checks_preserve_storage_hook_registration"); + prj.update_config(|config| config.invariant.runs = 0); + + prj.add_test( + "SymbolicInvariantStorageHookRegistration.t.sol", + r#" +import "forge-std/Test.sol"; + +interface StorageHookVm { + function registerSstoreHook(address target, bytes4 callback) external; + function registerMappingSstoreHook(address target, bytes32 root, bytes4 callback) external; +} + +contract MappingHookRegistrationTarget { + mapping(uint256 => uint256) public values; + + function step(uint256 newValue) external { + values[newValue] = newValue; + } +} + +contract StorageHookRegistrationTarget { + uint256 public value; + uint256 public steps; + MappingHookRegistrationTarget public mappingTarget; + + constructor(MappingHookRegistrationTarget mappingTarget_) { + mappingTarget = mappingTarget_; + } + + function step(uint256 newValue) external { + value = newValue; + steps++; + mappingTarget.step(newValue); + } +} + +contract SymbolicInvariantStorageHookRegistration is Test { + StorageHookVm constant hookVm = + StorageHookVm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + StorageHookRegistrationTarget target; + MappingHookRegistrationTarget mappingTarget; + uint256 ghost; + uint256 mappingGhost; + + modifier onlyStorageHook() { + require(msg.sender == address(hookVm), "only storage hook"); + _; + } + + function setUp() public { + mappingTarget = new MappingHookRegistrationTarget(); + target = new StorageHookRegistrationTarget(mappingTarget); + hookVm.registerSstoreHook(address(target), this.initialHook.selector); + hookVm.registerMappingSstoreHook(address(mappingTarget), bytes32(0), this.initialMappingHook.selector); + targetContract(address(target)); + targetSender(address(this)); + } + + function initialHook(address, bytes32, bytes32, bytes32) external onlyStorageHook { + ghost = 1; + } + + function replacementHook(address, bytes32, bytes32, bytes32) external onlyStorageHook { + ghost = 2; + } + + function afterInvariantHook(address, bytes32, bytes32, bytes32) external onlyStorageHook { + ghost = 3; + } + + function initialMappingHook(address, bytes32, bytes32, bytes32[] calldata, bytes32, bytes32) + external + onlyStorageHook + { + mappingGhost = 1; + } + + function replacementMappingHook(address, bytes32, bytes32, bytes32[] calldata, bytes32, bytes32) + external + onlyStorageHook + { + mappingGhost = 2; + } + + function afterInvariantMappingHook(address, bytes32, bytes32, bytes32[] calldata, bytes32, bytes32) + external + onlyStorageHook + { + mappingGhost = 3; + } + + /// forge-config: default.symbolic.invariant_depth = 2 + /// forge-config: default.invariant.check_interval = 1 + /// forge-config: default.invariant.runs = 1 + /// forge-config: default.invariant.depth = 1 + function invariant_hookReplacementPersists() public { + if (target.steps() == 2) { + assertEq(ghost, 2); + assertEq(mappingGhost, 2); + } + hookVm.registerSstoreHook(address(target), this.replacementHook.selector); + hookVm.registerMappingSstoreHook( + address(mappingTarget), bytes32(0), this.replacementMappingHook.selector + ); + } + + function afterInvariant() public { + hookVm.registerSstoreHook(address(target), this.afterInvariantHook.selector); + hookVm.registerMappingSstoreHook( + address(mappingTarget), bytes32(0), this.afterInvariantMappingHook.selector + ); + target.step(3); + assertEq(ghost, 3); + assertEq(mappingGhost, 3); + } +} +"#, + ); + + let output = cmd + .args(["test", "--symbolic", "--json", "--match-test", "invariant_hookReplacementPersists"]) + .assert_success() + .get_output() + .stdout + .clone(); + let result = json_test_result(&output, "invariant_hookReplacementPersists()"); + assert_eq!(result["status"], "Success"); + assert_eq!(result["symbolic"]["status"], "pass"); +} + +#[forgetest_init] +fn symbolic_invariant_respects_excluded_fallback_sender(prj: _, cmd: _) { + skip_unless_z3!("symbolic_invariant_respects_excluded_fallback_sender"); + prj.update_config(|config| config.invariant.runs = 0); + + prj.add_test( + "SymbolicExcludedFallbackSender.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SenderTarget { + address public excluded; + bool public broken; + + constructor(address excluded_) { + excluded = excluded_; + } + + function breakIfExcluded() external { + if (msg.sender == excluded) { + broken = true; + } + } +} + +contract SymbolicExcludedFallbackSender is Test { + SenderTarget target; + + function setUp() public { + address excluded = msg.sender; + target = new SenderTarget(excluded); + targetContract(address(target)); + excludeSender(excluded); + } + + /// forge-config: default.symbolic.invariant_depth = 1 + function invariant_notBroken() public view { + assertFalse(target.broken()); + } +} +"#, + ); + + let output = cmd + .args(["test", "--symbolic", "--json", "--match-test", "invariant_notBroken"]) + .assert_success() + .get_output() + .stdout + .clone(); + let result = json_test_result(&output, "invariant_notBroken()"); + assert_eq!(result["status"], "Success"); + assert_eq!(result["symbolic"]["status"], "incomplete"); +} + +#[forgetest_init] +fn symbolic_invariant_replay_mismatch_falls_back_to_fuzz(prj: _, cmd: _) { + skip_unless_z3!("symbolic_invariant_replay_mismatch_falls_back_to_fuzz"); + + prj.add_test( + "SymbolicInvariantReplayMismatch.t.sol", + r#" +import "forge-std/Test.sol"; + +interface ArbitraryStorageVm { + function setArbitraryStorage(address target) external; +} + +contract TokenLike { + mapping(address => uint256) public balanceOf; +} + +contract MappingBackedTarget { + TokenLike token; + bool public hit; + + constructor(TokenLike token_) { + token = token_; + } + + function useIt(address who) external { + require(token.balanceOf(who) == 777); + hit = true; + } +} + +contract SymbolicInvariantReplayMismatch is Test { + TokenLike token; + MappingBackedTarget target; + + function setUp() public { + token = new TokenLike(); + target = new MappingBackedTarget(token); + ArbitraryStorageVm(address(vm)).setArbitraryStorage(address(token)); + targetContract(address(target)); + } + + /// forge-config: default.symbolic.invariant_depth = 1 + /// forge-config: default.invariant.runs = 1 + /// forge-config: default.invariant.depth = 1 + function invariant_notHit() public view { + require(!target.hit(), "hit"); + } +} +"#, + ); + + let output = cmd + .args(["test", "--symbolic", "--json", "--match-test", "invariant_notHit"]) + .assert_success() + .get_output() + .stdout + .clone(); + let result = json_test_result(&output, "invariant_notHit()"); + assert_eq!(result["status"], "Success"); + assert_eq!(result["symbolic"]["status"], "incomplete"); + assert_eq!(result["symbolic"]["replay"]["status"], "mismatch"); + assert_eq!(result["kind"]["Invariant"]["runs"], 1); + assert_eq!(result["kind"]["Invariant"]["calls"], 1); +} + +#[forgetest_init] +fn symbolic_invariant_incomplete_still_runs_fuzz_campaign(prj: _, cmd: _) { + skip_unless_z3!("symbolic_invariant_incomplete_still_runs_fuzz_campaign"); + + prj.add_test( + "SymbolicInvariantIncompleteRunsFuzz.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicIncompleteThenFuzzTarget { + uint256 public count; + + function step() external { + count++; + } +} + +contract SymbolicInvariantIncompleteRunsFuzz is Test { + SymbolicIncompleteThenFuzzTarget target; + + function setUp() public { + target = new SymbolicIncompleteThenFuzzTarget(); + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = target.step.selector; + targetSelector(FuzzSelector({addr: address(target), selectors: selectors})); + targetContract(address(target)); + } + + /// forge-config: default.symbolic.invariant_depth = 1 + /// forge-config: default.symbolic.max_paths = 0 + /// forge-config: default.invariant.runs = 1 + /// forge-config: default.invariant.depth = 2 + function invariant_countBelowTwo() public view { + assertLt(target.count(), 2); + } +} +"#, + ); + + let output = cmd + .args(["test", "--symbolic", "--json", "--match-test", "invariant_countBelowTwo"]) + .assert_failure() + .get_output() + .stdout + .clone(); + let result = json_test_result(&output, "invariant_countBelowTwo()"); + assert_eq!(result["status"], "Failure"); + assert_eq!(result["symbolic"]["status"], "incomplete"); + assert_eq!(result["symbolic"]["incomplete"]["kind"], "stuck"); + assert_eq!(result["kind"]["Invariant"]["runs"], 1); + assert_eq!(result["kind"]["Invariant"]["calls"], 2); +} + +// EIP-1153 transient storage is per-transaction scratch space. The symbolic +// invariant runner must clear `state.world.transient_storage` at the boundary +// of every top-level sequence step. The target below has two entry points: +// `poke` writes a symbolic sentinel into transient slot 0, and `peek` reverts +// if transient slot 0 is non-zero. Because each call is a fresh top-level +// transaction, `peek` must always observe zero — regardless of how many +// `poke(sentinel)` calls preceded it. +#[forgetest_init] +fn symbolic_transient_storage_clears_between_sequence_steps(prj: _, cmd: _) { + skip_unless_z3!("symbolic_transient_storage_clears_between_sequence_steps"); + + prj.add_test( + "SymbolicTransientStorageInvariant.t.sol", + r#" +import "forge-std/Test.sol"; + +contract TransientTarget { + function poke(uint256 sentinel) external { + assembly { tstore(0, sentinel) } + } + + function peek() external view { + uint256 v; + assembly { v := tload(0) } + // Must hold at the start of every top-level call: transient storage + // from a prior step must have been cleared. + require(v == 0, "transient storage leaked across top-level steps"); + } +} + +contract SymbolicTransientStorageInvariant is Test { + TransientTarget target; + + function setUp() public { + target = new TransientTarget(); + targetContract(address(target)); + } + + /// forge-config: default.symbolic.invariant_depth = 2 + function invariant_transientClearsBetweenSteps() public view { + target.peek(); + } +} +"#, + ); + + assert_symbolic(cmd.args([ + "test", + "--symbolic", + "--match-test", + "invariant_transientClearsBetweenSteps", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicTransientStorageInvariant.t.sol:SymbolicTransientStorageInvariant +[PASS] invariant_transientClearsBetweenSteps() ([METRICS]) +... +"#]]); +} + +// A target function that branches symbolically into a revert path and a +// state-mutating path. With `fail_on_revert = false` and `invariant_depth = 2`, +// the engine must continue exploring non-reverting symbolic branches even when +// other branches of the same function revert; otherwise it would silently +// under-approximate and miss the counter increment below. +#[forgetest_init] +fn symbolic_revert_branches_do_not_swallow_non_revert_paths(prj: _, cmd: _) { + skip_unless_z3!("symbolic_revert_branches_do_not_swallow_non_revert_paths"); + + prj.add_test( + "SymbolicRevertBranchInvariant.t.sol", + r#" +import "forge-std/Test.sol"; + +contract RevertBranchTarget { + uint256 public counter; + + function step(uint8 mode) external { + if (mode == 0) { + revert("symbolic revert branch"); + } + unchecked { counter += 1; } + } +} + +contract SymbolicRevertBranchInvariant is Test { + RevertBranchTarget target; + + function setUp() public { + target = new RevertBranchTarget(); + targetContract(address(target)); + } + + /// forge-config: default.symbolic.invariant_depth = 2 + /// forge-config: default.invariant.fail_on_revert = false + function invariant_counterStaysZero() public view { + assertEq(target.counter(), 0); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "invariant_counterStaysZero", + ])) + .failure() + .stdout_eq(str![[r#" +... +Failing tests: +Encountered 1 failing test in test/SymbolicRevertBranchInvariant.t.sol:SymbolicRevertBranchInvariant +[FAIL: assertion failed: 1 != 0] +... + invariant_counterStaysZero() ([METRICS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]]); +} + +// Reverted calls still consume invariant depth. With end-only checking, the +// symbolic runner must carry the reverted branch forward and check the unchanged +// state at the configured sequence end. +#[forgetest_init] +fn symbolic_revert_branch_preserves_end_only_invariant_check(prj: _, cmd: _) { + skip_unless_z3!("symbolic_revert_branch_preserves_end_only_invariant_check"); + prj.update_config(|config| config.invariant.runs = 0); + + prj.add_test( + "SymbolicTerminalRevertInvariant.t.sol", + r#" +import "forge-std/Test.sol"; + +contract TerminalRevertTarget { + bool public broken; + + function breakIt() external { + require(!broken, "already broken"); + broken = true; + } + + function alwaysRevert() external pure { + revert("always"); + } +} + +contract SymbolicTerminalRevertInvariant is Test { + TerminalRevertTarget target; + + function setUp() public { + target = new TerminalRevertTarget(); + bytes4[] memory selectors = new bytes4[](2); + selectors[0] = target.breakIt.selector; + selectors[1] = target.alwaysRevert.selector; + targetSelector(FuzzSelector({addr: address(target), selectors: selectors})); + targetContract(address(target)); + targetSender(address(this)); + } + + /// forge-config: default.symbolic.invariant_depth = 2 + /// forge-config: default.invariant.check_interval = 0 + function invariant_neverBroken() public view { + require(!target.broken(), "broken"); + } +} +"#, + ); + + let output = cmd + .args(["test", "--symbolic", "--json", "--match-test", "invariant_neverBroken"]) + .assert_failure() + .get_output() + .stdout + .clone(); + let result = json_test_result(&output, "invariant_neverBroken()"); + assert_eq!(result["status"], "Failure"); + assert_eq!(result["symbolic"]["status"], "fail_counterexample"); +} + +// Foundry cheatcode effects are not journaled with EVM state. A top-level revert +// therefore rolls back contract storage but keeps effects such as `vm.mockCall` +// for the next invariant call. +#[forgetest_init] +fn symbolic_reverted_handler_effect_replays_counterexample(prj: _, cmd: _) { + skip_unless_z3!("symbolic_reverted_handler_effect_replays_counterexample"); + + prj.add_test( + "SymbolicRevertedCheatcodeEffects.t.sol", + r#" +import "forge-std/Test.sol"; + +contract MockedValue { + function read() external pure returns (uint256) { + return 0; + } +} + +contract SymbolicRevertedCheatcodeEffects is Test { + MockedValue source; + bool broken; + + function setUp() public { + source = new MockedValue(); + bytes4[] memory selectors = new bytes4[](2); + selectors[0] = this.mockAndRevert.selector; + selectors[1] = this.breakIfMocked.selector; + targetSelector(FuzzSelector({addr: address(this), selectors: selectors})); + targetSender(address(this)); + } + + function mockAndRevert() external { + vm.mockCall(address(source), abi.encodeCall(source.read, ()), abi.encode(uint256(42))); + revert("after mock"); + } + + function breakIfMocked() external { + if (source.read() == 42) broken = true; + } + + /// forge-config: default.symbolic.invariant_depth = 2 + /// forge-config: default.invariant.fail_on_revert = false + function invariant_notBroken() public view { + require(!broken, "broken after reverted mock"); + } +} +"#, + ); + + // Concrete confirmation must preserve the mock installed by the reverted handler call. + let output = cmd + .args(["test", "--symbolic", "--json", "--match-test", "invariant_notBroken"]) + .assert_failure() + .get_output() + .stdout + .clone(); + let result = json_test_result(&output, "invariant_notBroken()"); + assert_eq!(result["status"], "Failure"); + assert_eq!(result["symbolic"]["status"], "fail_counterexample", "{result}"); + assert_eq!(result["symbolic"]["replay"]["status"], "confirmed", "{result}"); +} + +#[forgetest_init] +fn symbolic_invariant_does_not_inherit_prank_into_nested_call(prj: _, cmd: _) { + skip_unless_z3!("symbolic_invariant_does_not_inherit_prank_into_nested_call"); + + prj.add_test( + "SymbolicSetupMappingStorageInvariant.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SetupToken { + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + function setBalance(address account, uint256 amount) external { + balanceOf[account] = amount; + } + + function approve(address spender, uint256 amount) external { + allowance[msg.sender][spender] = amount; + } + + function transferFrom(address from, address to, uint256 amount) external returns (bool) { + require(allowance[from][msg.sender] >= amount, "insufficient allowance"); + require(balanceOf[from] >= amount, "insufficient balance"); + + allowance[from][msg.sender] -= amount; + balanceOf[from] -= amount; + balanceOf[to] += amount; + return true; + } +} + +contract SetupSpender { + function pull(SetupToken token, address from, uint8 amount) external { + token.transferFrom(from, address(this), amount); + } +} + +contract SymbolicSetupMappingStorageInvariant is Test { + SetupToken token; + SetupSpender spender; + + function setUp() public { + token = new SetupToken(); + spender = new SetupSpender(); + token.setBalance(address(this), 10); + token.approve(address(spender), type(uint256).max); + targetContract(address(this)); + } + + function pull(uint8 amount) external { + if (amount == 7) { + vm.startPrank(address(this)); + spender.pull(token, address(this), amount); + vm.stopPrank(); + } + } + + /// forge-config: default.symbolic.invariant_depth = 1 + function invariant_balanceStaysInitialized() public view { + assertEq(token.balanceOf(address(this)), 10); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "invariant_balanceStaysInitialized", + ])) + .failure() + .stdout_eq(str![[r#" +... +Failing tests: +Encountered 1 failing test in test/SymbolicSetupMappingStorageInvariant.t.sol:SymbolicSetupMappingStorageInvariant +[FAIL: assertion failed: 3 != 10] + [Sequence] (original: 1, shrunk: 1) + [SENDER] [SENDER] calldata=pull(uint8) [ARGS] + invariant_balanceStaysInitialized() ([METRICS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]]); +} + +#[forgetest_init] +fn symbolic_invariant_zeroes_created_account_storage(prj: _, cmd: _) { + skip_unless_z3!("symbolic_invariant_zeroes_created_account_storage"); + prj.update_config(|config| config.invariant.runs = 0); + + prj.add_test( + "SymbolicCreatedStorageInvariant.t.sol", + r#" +import "forge-std/Test.sol"; + +contract FreshStorage { + function read(uint256 slot) external view returns (uint256 value) { + assembly { + value := sload(slot) + } + } +} + +contract SymbolicCreatedStorageTarget { + FreshStorage public fresh; + bool public impossible; + + function deployFresh() external { + fresh = new FreshStorage(); + } + + function readFresh(uint256 slot) external { + require(address(fresh) != address(0), "missing fresh"); + if (fresh.read(slot) != 0) { + impossible = true; + } + } +} + +contract SymbolicCreatedStorageInvariant is Test { + SymbolicCreatedStorageTarget target; + + function setUp() public { + target = new SymbolicCreatedStorageTarget(); + bytes4[] memory selectors = new bytes4[](2); + selectors[0] = target.deployFresh.selector; + selectors[1] = target.readFresh.selector; + targetSelector(FuzzSelector({addr: address(target), selectors: selectors})); + targetContract(address(target)); + targetSender(address(this)); + } + + /// forge-config: default.symbolic.invariant_depth = 2 + function invariant_noImpossibleCreatedStorage() public view { + assertFalse(target.impossible()); + } +} +"#, + ); + + let output = cmd + .args([ + "test", + "--symbolic", + "--json", + "--match-test", + "invariant_noImpossibleCreatedStorage", + ]) + .assert_success() + .get_output() + .stdout + .clone(); + let result = json_test_result(&output, "invariant_noImpossibleCreatedStorage()"); + assert_eq!(result["status"], "Success"); + assert_eq!(result["symbolic"]["status"], "pass"); +} + +#[forgetest_init] +fn symbolic_invariant_aliases_concrete_mapping_write_to_symbolic_read(prj: _, cmd: _) { + skip_unless_z3!("symbolic_invariant_aliases_concrete_mapping_write_to_symbolic_read"); + prj.update_config(|config| config.invariant.runs = 0); + + prj.add_test( + "SymbolicMappingAliasInvariant.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicMappingAliasTarget { + mapping(uint8 => uint256) public messages; + bool public accepted; + + function seedZeroRoot() external { + messages[0] = 1; + } + + function accept(uint8 root) external { + if (messages[root] != 0) { + accepted = true; + } + } +} + +contract SymbolicMappingAliasInvariant is Test { + SymbolicMappingAliasTarget target; + + function setUp() public { + target = new SymbolicMappingAliasTarget(); + bytes4[] memory selectors = new bytes4[](2); + selectors[0] = target.seedZeroRoot.selector; + selectors[1] = target.accept.selector; + targetSelector(FuzzSelector({addr: address(target), selectors: selectors})); + targetContract(address(target)); + } + + /// forge-config: default.symbolic.invariant_depth = 2 + /// forge-config: default.symbolic.storage_layout = "zero_init" + function invariant_notAccepted() public view { + assertFalse(target.accepted()); + } +} +"#, + ); + + let output = cmd + .args(["test", "--symbolic", "--json", "--match-test", "invariant_notAccepted"]) + .assert_failure() + .get_output() + .stdout + .clone(); + let result = json_test_result(&output, "invariant_notAccepted()"); + assert_eq!(result["status"], "Failure"); + assert_eq!(result["symbolic"]["status"], "fail_counterexample"); +} + +#[forgetest_init] +fn symbolic_invariant_solves_multicall_hard_arithmetic(prj: _, cmd: _) { + skip_unless_z3!("symbolic_invariant_solves_multicall_hard_arithmetic"); + + prj.add_test( + "SymbolicInvariantHardArithmetic.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicHardArithmeticMarket { + bool public borrowed; + uint256 public supplyAssets; + uint256 public supplyShares; + uint256 public collateralAssets; + uint256 public borrowAssets; + uint256 public borrowShares; + + function supply(uint8 assets) external { + require(assets != 0, "zero supply"); + uint256 shares = mulDivDown(assets, supplyShares + 1_000_000, supplyAssets + 1); + supplyAssets += assets; + supplyShares += shares; + } + + function supplyCollateral(uint8 assets) external { + require(assets != 0, "zero collateral"); + collateralAssets += assets; + } + + function borrow(uint8 assets) external { + require(assets != 0, "zero borrow"); + uint256 shares = mulDivUp(assets, borrowShares + 1_000_000, borrowAssets + 1); + borrowAssets += assets; + borrowShares += shares; + require(borrowAssets <= supplyAssets, "insufficient liquidity"); + require(borrowAssets <= collateralAssets, "insufficient collateral"); + borrowed = true; + } + + function mulDivDown(uint256 x, uint256 y, uint256 d) internal pure returns (uint256) { + return (x * y) / d; + } + + function mulDivUp(uint256 x, uint256 y, uint256 d) internal pure returns (uint256) { + return (x * y + (d - 1)) / d; + } +} + +contract SymbolicInvariantHardArithmetic is Test { + SymbolicHardArithmeticMarket target; + + function setUp() public { + target = new SymbolicHardArithmeticMarket(); + bytes4[] memory selectors = new bytes4[](3); + selectors[0] = target.supply.selector; + selectors[1] = target.supplyCollateral.selector; + selectors[2] = target.borrow.selector; + targetSelector(FuzzSelector({addr: address(target), selectors: selectors})); + targetContract(address(target)); + } + + /// forge-config: default.symbolic.invariant_depth = 3 + function invariant_notBorrowed() public view { + assertEq(target.borrowed(), false); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "invariant_notBorrowed", + ])) + .failure() + .stdout_eq(str![[r#" +... +Failing tests: +Encountered 1 failing test in test/SymbolicInvariantHardArithmetic.t.sol:SymbolicInvariantHardArithmetic +[FAIL: assertion failed: true != false] + [Sequence] (original: 3, shrunk: 3) + [SENDER] [SENDER] calldata=supply(uint8) [ARGS] + [SENDER] [SENDER] calldata=supplyCollateral(uint8) [ARGS] + [SENDER] [SENDER] calldata=borrow(uint8) [ARGS] + invariant_notBorrowed() ([METRICS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]]); +} + +#[cfg(unix)] +#[forgetest_init] +fn symbolic_invariant_checks_easy_path_before_deferred_sibling(prj: _, cmd: _) { + let solver = prj.root().join("slow-solver"); + std::fs::write( + &solver, + r#"#!/bin/sh +if [ "$1" = "--version" ]; then + printf 'mock solver\n' + exit 0 +fi +cat >"$0.query" +printf 'unknown\n' +"#, + ) + .unwrap(); + let mut permissions = std::fs::metadata(&solver).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&solver, permissions).unwrap(); + + prj.update_config(|config| { + config.invariant.runs = 0; + config.symbolic.solver_command = Some(solver.display().to_string()); + config.symbolic.timeout = Some(30); + }); + prj.add_test( + "SymbolicInvariantDeferredSibling.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicDeferredSiblingTarget { + uint256 public marker; + + function handler(uint256 value) external { + if (value == 0) { + assert(false); + return; + } + if (value * value == 2) { + marker = 1; + } + } +} + +contract SymbolicInvariantDeferredSibling is Test { + SymbolicDeferredSiblingTarget target; + + function setUp() public { + target = new SymbolicDeferredSiblingTarget(); + bytes4[] memory selectors = new bytes4[](1); + selectors[0] = target.handler.selector; + targetSelector(FuzzSelector({addr: address(target), selectors: selectors})); + targetContract(address(target)); + } + + /// forge-config: default.symbolic.invariant_depth = 1 + /// forge-config: default.symbolic.exploration_order = "dfs" + function invariant_markerIsBounded() public pure {} +} +"#, + ); + + let output = cmd + .args(["test", "--symbolic", "--json", "--match-test", "invariant_markerIsBounded"]) + .assert_failure() + .get_output() + .clone(); + let result = json_test_result(&output.stdout, "invariant_markerIsBounded()"); + assert_eq!(result["symbolic"]["status"], "fail_counterexample"); + assert_eq!(result["symbolic"]["replay"]["status"], "confirmed"); + assert!(!solver.with_extension("query").exists()); +} + +// Top-level invariant sequence calls must look up code through the symbolic +// world overlay so that prior-step `vm.etch` writes are visible. The target +// below deploys a `Counter` whose `value()` returns 0, then exposes an +// `etchCounter` step that overwrites that address with bytecode that returns +// 42 for any call. If the engine fetched code from the backend instead of the +// overlay, the etch effect would be invisible at the next step and the +// (intentionally false) invariant would silently hold. +#[forgetest_init] +fn symbolic_invariant_sees_etched_code_via_overlay(prj: _, cmd: _) { + skip_unless_z3!("symbolic_invariant_sees_etched_code_via_overlay"); + + prj.add_test( + "SymbolicOverlayCodeInvariant.t.sol", + r#" +import "forge-std/Test.sol"; + +contract Counter { + function value() external pure returns (uint256) { + return 0; + } +} + +contract AlwaysReturns42 { + fallback() external { + assembly { + mstore(0, 42) + return(0, 32) + } + } +} + +contract OverlayTarget is Test { + Counter public c; + address etched; + + constructor() { + c = new Counter(); + etched = address(new AlwaysReturns42()); + } + + function etchCounter() external { + vm.etch(address(c), etched.code); + } + + function callCounter() external view returns (uint256) { + return c.value(); + } +} + +contract SymbolicOverlayCodeInvariant is Test { + OverlayTarget t; + + function setUp() public { + t = new OverlayTarget(); + targetContract(address(t)); + } + + /// forge-config: default.symbolic.invariant_depth = 2 + function invariant_counterAlwaysReturnsZero() public view { + assertEq(t.callCounter(), 0); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "invariant_counterAlwaysReturnsZero", + ])) + .failure() + .stdout_eq(str![[r#" +... +Failing tests: +Encountered 1 failing test in test/SymbolicOverlayCodeInvariant.t.sol:SymbolicOverlayCodeInvariant +[FAIL: assertion failed: 42 != 0] + [Sequence] (original: 1, shrunk: 1) + [SENDER] [SENDER] calldata=etchCounter() [ARGS] + invariant_counterAlwaysReturnsZero() ([METRICS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]]); +} + +#[forgetest_init] +fn symbolic_predicate_assumptions_report_incomplete(prj: _, cmd: _) { + skip_unless_z3!("symbolic_predicate_assumptions_report_incomplete"); + prj.update_config(|config| { + config.invariant.runs = 0; + config.symbolic.invariant_depth = 1; + }); + + prj.add_test( + "SymbolicPredicateAssumptions.t.sol", + r#" +import "forge-std/Test.sol"; + +contract AssumptionTarget { + bool public value; + + function set(bool next) external { + value = next; + } +} + +abstract contract PredicateAssumptionBase is Test { + AssumptionTarget internal target; + + function setUp() public { + target = new AssumptionTarget(); + targetContract(address(target)); + targetSender(address(this)); + } +} + +contract ConstantPredicateAssumption is PredicateAssumptionBase { + function invariant_condition() public { + vm.assume(false); + } +} + +contract MixedPredicateAssumption is PredicateAssumptionBase { + function invariant_condition() public { + vm.assume(!target.value()); + } +} + +contract BranchedPredicateAssumption is PredicateAssumptionBase { + function invariant_condition() public { + if (target.value()) vm.assume(false); + } +} + +contract CaughtPredicateAssumption is PredicateAssumptionBase { + function reject() external { + vm.assume(false); + } + + function invariant_condition() public { + try this.reject() {} catch {} + } +} + +contract AfterPredicateAssumption is PredicateAssumptionBase { + function invariant_condition() public pure {} + + function afterInvariant() public { + vm.assume(!target.value()); + } +} +"#, + ); + + cmd.args(["test", "--symbolic", "--json", "--match-test", "invariant_condition"]) + .assert_json_stdout_with_status( + false, + str![[r#" +{ + "test/SymbolicPredicateAssumptions.t.sol:ConstantPredicateAssumption": { + "test_results": { + "invariant_condition()": { + "status": "Failure", + "symbolic": { + "status": "incomplete", + "incomplete": { + "kind": "stuck", + "reason": "unsupported symbolic execution feature: vm.assume may reject an invariant predicate" + }, + "...": "{...}" + }, + "...": "{...}" + } + }, + "...": "{...}" + }, + "test/SymbolicPredicateAssumptions.t.sol:MixedPredicateAssumption": { + "test_results": { + "invariant_condition()": { + "status": "Success", + "symbolic": { + "status": "incomplete", + "incomplete": { + "kind": "stuck", + "reason": "unsupported symbolic execution feature: vm.assume may reject an invariant predicate" + }, + "...": "{...}" + }, + "...": "{...}" + } + }, + "...": "{...}" + }, + "test/SymbolicPredicateAssumptions.t.sol:BranchedPredicateAssumption": { + "test_results": { + "invariant_condition()": { + "status": "Success", + "symbolic": { + "status": "incomplete", + "incomplete": { + "kind": "stuck", + "reason": "unsupported symbolic execution feature: vm.assume may reject an invariant predicate" + }, + "...": "{...}" + }, + "...": "{...}" + } + }, + "...": "{...}" + }, + "test/SymbolicPredicateAssumptions.t.sol:CaughtPredicateAssumption": { + "test_results": { + "invariant_condition()": { + "status": "Success", + "symbolic": { + "status": "incomplete", + "incomplete": { + "kind": "stuck", + "reason": "unsupported symbolic execution feature: vm.assume may reject an invariant predicate" + }, + "...": "{...}" + }, + "...": "{...}" + } + }, + "...": "{...}" + }, + "test/SymbolicPredicateAssumptions.t.sol:AfterPredicateAssumption": { + "test_results": { + "invariant_condition()": { + "status": "Success", + "symbolic": { + "status": "incomplete", + "incomplete": { + "kind": "stuck", + "reason": "unsupported symbolic execution feature: vm.assume may reject an invariant predicate" + }, + "...": "{...}" + }, + "...": "{...}" + } + }, + "...": "{...}" + } +} +"#]], + ); +} + +#[forgetest_init] +fn symbolic_predicate_assumptions_preserve_handler_filtering(prj: _, cmd: _) { + skip_unless_z3!("symbolic_predicate_assumptions_preserve_handler_filtering"); + prj.update_config(|config| { + config.invariant.runs = 0; + config.symbolic.invariant_depth = 2; + }); + + prj.add_test( + "SymbolicHandlerAssumptions.t.sol", + r#" +import "forge-std/Test.sol"; + +contract FilteredAssumptionTarget is Test { + uint256 public value; + + function set(uint256 next) external { + vm.assume(next < 10); + value = next; + } +} + +contract SymbolicHandlerAssumptions is Test { + FilteredAssumptionTarget internal target; + + function setUp() public { + target = new FilteredAssumptionTarget(); + targetContract(address(target)); + targetSender(address(this)); + } + + function acceptBound() external { + vm.assume(target.value() < 10); + } + + function invariant_condition() public { + this.acceptBound(); + } + + function afterInvariant() public { + this.acceptBound(); + } +} +"#, + ); + + cmd.args(["test", "--symbolic", "--json", "--match-test", "invariant_condition"]) + .assert_json_stdout(str![[r#" +{ + "test/SymbolicHandlerAssumptions.t.sol:SymbolicHandlerAssumptions": { + "test_results": { + "invariant_condition()": { + "status": "Success", + "symbolic": { + "status": "pass", + "incomplete": null, + "...": "{...}" + }, + "...": "{...}" + } + }, + "...": "{...}" + } +} +"#]]); +} diff --git a/crates/forge/tests/cli/test_cmd/symbolic_limits.rs b/crates/forge/tests/cli/test_cmd/symbolic_limits.rs new file mode 100644 index 0000000000000..1f7bc42264581 --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/symbolic_limits.rs @@ -0,0 +1,186 @@ +use super::symbolic_helpers::assert_symbolic_witness; +use foundry_common::sh_eprintln; +use foundry_test_utils::{forgetest_init, str}; +use std::{env, process::Command}; + +fn symbolic_limits_enabled() -> bool { + env::var_os("SYMBOLIC_LIMITS").is_some() +} + +fn z3_available() -> bool { + Command::new("z3").arg("--version").output().is_ok_and(|output| output.status.success()) +} + +fn should_skip(test: &str) -> bool { + if !symbolic_limits_enabled() { + let _ = sh_eprintln!("skipping {test} because SYMBOLIC_LIMITS is not set"); + return true; + } + if !z3_available() { + let _ = sh_eprintln!("skipping {test} because z3 is not available"); + return true; + } + false +} + +#[forgetest_init] +fn symbolic_limits_reports_execution_depth_exhaustion(prj: _, cmd: _) { + if should_skip("symbolic_limits_reports_execution_depth_exhaustion") { + return; + } + + prj.add_test( + "SymbolicLimitsDepth.t.sol", + r#" +contract SymbolicLimitsDepth { + function checkDepth(uint256 x) public pure { + uint256 y = x; + y += 1; + y += 2; + y += 3; + y += 4; + y += 5; + y += 6; + y += 7; + y += 8; + assert(y != type(uint256).max); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--symbolic-depth", + "8", + "--match-test", + "checkDepth", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicLimitsDepth.t.sol:SymbolicLimitsDepth +[FAIL: incomplete symbolic execution (Stuck): symbolic depth limit exceeded (8)] checkDepth(uint256) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_limits_reports_calldata_budget_exhaustion(prj: _, cmd: _) { + if should_skip("symbolic_limits_reports_calldata_budget_exhaustion") { + return; + } + + prj.add_test( + "SymbolicLimitsCalldataBudget.t.sol", + r#" +contract SymbolicLimitsCalldataBudget { + /// forge-config: default.symbolic.array_lengths = [64] + /// forge-config: default.symbolic.max_calldata_bytes = 96 + function checkCalldataBudget(bytes memory data) public pure { + assert(data.length == 64); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkCalldataBudget", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicLimitsCalldataBudget.t.sol:SymbolicLimitsCalldataBudget +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: symbolic calldata size exceeds configured max] checkCalldataBudget(bytes) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_limits_invariant_depth_changes_result(prj: _) { + if should_skip("symbolic_limits_invariant_depth_changes_result") { + return; + } + + // `--symbolic` still runs the concrete invariant campaign, so keep it at depth 1 to let the + // symbolic depth decide whether the two-call sequence is found. + prj.update_config(|config| config.invariant.depth = 1); + prj.add_test( + "SymbolicLimitsInvariantDepth.t.sol", + r#" +import "forge-std/Test.sol"; + +contract LimitsCounter { + uint256 public value; + + function inc() external { + value++; + } +} + +contract SymbolicLimitsInvariantDepth is Test { + LimitsCounter counter; + + function setUp() public { + counter = new LimitsCounter(); + targetContract(address(counter)); + } + + function invariant_valueNeverTwo() public view { + assertTrue(counter.value() != 2); + } +} +"#, + ); + + assert_symbolic_witness(prj.forge_command().args([ + "test", + "--symbolic", + "--symbolic-invariant-depth", + "1", + "--match-test", + "invariant_valueNeverTwo", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicLimitsInvariantDepth.t.sol:SymbolicLimitsInvariantDepth +[PASS] invariant_valueNeverTwo() ([METRICS]) + +╭---------------+----------+-------+---------+----------╮ +| Contract | Selector | Calls | Reverts | Discards | ++=======================================================+ +| LimitsCounter | inc | 256 | 0 | 0 | +╰---------------+----------+-------+---------+----------╯ + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); + + assert_symbolic_witness(prj.forge_command().args([ + "test", + "--symbolic", + "--symbolic-invariant-depth", + "2", + "--match-test", + "invariant_valueNeverTwo", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicLimitsInvariantDepth.t.sol:SymbolicLimitsInvariantDepth +[FAIL: assertion failed] + [Sequence] (original: 2, shrunk: 2) + [SENDER] [SENDER] calldata=inc() [ARGS] + [SENDER] [SENDER] calldata=inc() [ARGS] + invariant_valueNeverTwo() ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} diff --git a/crates/forge/tests/cli/test_cmd/symbolic_memory.rs b/crates/forge/tests/cli/test_cmd/symbolic_memory.rs new file mode 100644 index 0000000000000..6db18b033bef4 --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/symbolic_memory.rs @@ -0,0 +1,1386 @@ +use super::symbolic_helpers::{assert_symbolic, assert_symbolic_witness, z3_available}; +use foundry_common::sh_eprintln; +use foundry_test_utils::{forgetest_init, snapbox::IntoData, str, util::OutputExt}; + +use crate::skip_unless_z3; + +// MLOAD, MSTORE and MSIZE with symbolic offsets are modeled instead of reported as Stuck. +#[forgetest_init] +fn symbolic_memory_word_ops_accept_symbolic_offsets(prj: _, cmd: _) { + skip_unless_z3!("symbolic_memory_word_ops_accept_symbolic_offsets"); + + prj.add_test( + "SymbolicMload.t.sol", + r#" +contract SymbolicMload { + function checkSymbolicMload(uint16 offset, uint256 marker) public pure { + uint256 loaded; + assembly { + mstore(0x80, marker) + loaded := mload(offset) + } + + if (offset == 0x80) { + assert(loaded == marker); + } + if (offset >= 0xa0) { + assert(loaded == 0); + } + } +} +"#, + ); + + prj.add_test( + "SymbolicMstoreConstrained.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicMstoreConstrained is Test { + function checkConstrainedMstore(uint16 offset, uint256 marker) public { + vm.assume(offset == 0x80); + + uint256 loaded; + assembly { + mstore(offset, marker) + loaded := mload(0x80) + } + + assertEq(loaded, marker); + } +} +"#, + ); + + prj.add_test( + "SymbolicMsizeAfterWrite.t.sol", + r#" +contract SymbolicMsizeAfterWrite { + function checkSymbolicMsize(uint16 offset, uint256 marker) public pure { + uint256 size; + assembly { + mstore(offset, marker) + size := msize() + } + + assert(size != 0); + } +} +"#, + ); + + // Dynamic-offset memory read must respect write-epoch ordering: if a later + // concrete MSTORE has written to an offset, a subsequent symbolic-offset MLOAD + // that aliases that offset must see the later value, not the stale earlier + // symbolic write. If epoch ordering regressed, Z3 could pick `symKey == 0x80` + // and the symbolic MLOAD would surface `0xdeadbeef` instead of `0x1234`, + // flipping the assertion below into a counterexample. + prj.add_test( + "SymbolicMemoryEpochOrdering.t.sol", + r#" +contract SymbolicMemoryEpochOrdering { + function checkLaterConcreteWriteWins(uint256 symKey, uint256 readKey) public pure { + uint256 v; + assembly { + // Earlier symbolic-offset write. + mstore(symKey, 0xdeadbeef) + // Later concrete-offset write — must be visible at slot 0x80 + // regardless of what `symKey` was. + mstore(0x80, 0x1234) + // Dynamic-offset read. + v := mload(readKey) + } + if (readKey == 0x80) { + assert(v == 0x1234); + } + } +} +"#, + ); + + let stdout = assert_symbolic(cmd.args([ + "test", + "--symbolic", + "--match-test", + "^(checkSymbolicMload|checkConstrainedMstore|checkSymbolicMsize|checkLaterConcreteWriteWins)\\(", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicMsizeAfterWrite.t.sol:SymbolicMsizeAfterWrite +[PASS] checkSymbolicMsize(uint16,uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicMemoryEpochOrdering.t.sol:SymbolicMemoryEpochOrdering +[PASS] checkLaterConcreteWriteWins(uint256,uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicMstoreConstrained.t.sol:SymbolicMstoreConstrained +[PASS] checkConstrainedMstore(uint16,uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicMload.t.sol:SymbolicMload +[PASS] checkSymbolicMload(uint16,uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]].unordered()) + .get_output() + .stdout_lossy(); + for reason in [ + "symbolic MLOAD offset", + "symbolic MSIZE after symbolic memory write", + "symbolic MSTORE offset", + ] { + assert!(!stdout.contains(reason), "{stdout}"); + } +} + +#[forgetest_init] +fn symbolic_fixed_memory_access_rejects_oversized_offset(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_fixed_memory_access_rejects_oversized_offset because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicOversizedMemoryOffset.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicOversizedMemoryOffset is Test { + function load(uint256 offset) external pure { + assembly { + pop(mload(offset)) + } + } + + function store(uint256 offset) external pure { + assembly { + mstore(offset, 1) + } + } + + function store8(uint256 offset) external pure { + assembly { + mstore8(offset, 1) + } + } + + function checkOversizedFixedMemoryAccesses() public { + uint256 offset = type(uint256).max; + (bool loadOk,) = address(this).call(abi.encodeCall(this.load, (offset))); + (bool storeOk,) = address(this).call(abi.encodeCall(this.store, (offset))); + (bool store8Ok,) = address(this).call(abi.encodeCall(this.store8, (offset))); + assertFalse(loadOk); + assertFalse(storeOk); + assertFalse(store8Ok); + } + + function checkConstrainedOversizedMemoryAccess(uint256 offset) public { + vm.assume(offset == type(uint256).max); + (bool ok,) = address(this).call(abi.encodeCall(this.store, (offset))); + assertFalse(ok); + } + + function checkMixedMemoryOffsetExploresValidSibling(uint256 offset) public { + bool endpoint; + assembly { + endpoint := or(iszero(offset), eq(offset, not(0))) + } + vm.assume(endpoint); + (bool ok,) = address(this).call(abi.encodeCall(this.store, (offset))); + assertFalse(ok); + } + + function createWithOversizedSize() external { + assembly { + pop(create(0, 0, not(0))) + } + } + + function create2WithOversizedSize() external { + assembly { + pop(create2(0, 0, not(0), 0)) + } + } + + function checkOversizedCreateRanges() public { + (bool createOk,) = address(this).call(abi.encodeCall(this.createWithOversizedSize, ())); + (bool create2Ok,) = address(this).call(abi.encodeCall(this.create2WithOversizedSize, ())); + assertFalse(createOk); + assertFalse(create2Ok); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args(["test", "--symbolic", "--match-test", "check.*Oversized"])) + .success() + .stdout_eq(str![[r#" +... +Ran 3 tests for test/SymbolicOversizedMemoryOffset.t.sol:SymbolicOversizedMemoryOffset +[PASS] checkConstrainedOversizedMemoryAccess(uint256) ([METRICS]) +[PASS] checkOversizedCreateRanges() ([METRICS]) +[PASS] checkOversizedFixedMemoryAccesses() ([METRICS]) +Suite result: ok. 3 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); + + cmd.forge_fuse(); + let stdout = assert_symbolic(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkMixedMemoryOffsetExploresValidSibling", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicOversizedMemoryOffset.t.sol:SymbolicOversizedMemoryOffset +[FAIL: assertion failed; counterexample: [SENDER] [SENDER] calldata=0x8ba9bb5b0000000000000000000000000000000000000000000000000000000000000000 args=[0]] checkMixedMemoryOffsetExploresValidSibling(uint256) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("counterexample did not replay"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_variable_memory_access_rejects_oversized_ranges(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_variable_memory_access_rejects_oversized_ranges because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicOversizedMemoryRange.t.sol", + r#" +contract SymbolicOversizedMemoryRange { + function calldataCopy() external pure { + assembly { + calldatacopy(not(0), 0, 1) + } + } + + function codeCopy() external pure { + assembly { + codecopy(not(0), 0, 1) + } + } + + function extcodeCopy() external view { + assembly { + extcodecopy(address(), not(0), 0, 1) + } + } + + function returndataCopy() external view { + assembly { + pop(staticcall(gas(), 4, 0, 1, 0, 1)) + returndatacopy(not(0), 0, 1) + } + } + + function memoryCopyDest() external pure { + assembly { + mcopy(not(0), 0, 1) + } + } + + function memoryCopySource() external pure { + assembly { + mcopy(0, not(0), 1) + } + } + + function hash() external pure { + assembly { + pop(keccak256(not(0), 1)) + } + } + + function log() external { + assembly { + log0(not(0), 1) + } + } + + function ret() external pure { + assembly { + return(not(0), 1) + } + } + + function rev() external pure { + assembly { + revert(not(0), 1) + } + } + + function testOversizedVariableMemoryRanges() public { + verifyOversizedVariableMemoryRanges(true); + } + + function checkOversizedVariableMemoryRanges() public { + verifyOversizedVariableMemoryRanges(false); + } + + function verifyOversizedVariableMemoryRanges(bool capGas) internal { + assertFails(this.calldataCopy.selector, capGas); + assertFails(this.codeCopy.selector, capGas); + assertFails(this.extcodeCopy.selector, capGas); + assertFails(this.returndataCopy.selector, capGas); + assertFails(this.memoryCopyDest.selector, capGas); + assertFails(this.memoryCopySource.selector, capGas); + assertFails(this.hash.selector, capGas); + assertFails(this.log.selector, capGas); + assertFails(this.ret.selector, capGas); + assertFails(this.rev.selector, capGas); + } + + function assertFails(bytes4 selector, bool capGas) internal { + bytes memory input = abi.encodeWithSelector(selector); + (bool ok, bytes memory data) = capGas + ? address(this).call{gas: 100_000}(input) + : address(this).call(input); + assert(!ok); + assert(data.length == 0); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args([ + "test", + "--match-test", + "testOversizedVariableMemoryRanges", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicOversizedMemoryRange.t.sol:SymbolicOversizedMemoryRange +[PASS] testOversizedVariableMemoryRanges() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); + + cmd.forge_fuse(); + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkOversizedVariableMemoryRanges", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicOversizedMemoryRange.t.sol:SymbolicOversizedMemoryRange +[PASS] checkOversizedVariableMemoryRanges() ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_fixed_memory_access_respects_memory_limit(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_fixed_memory_access_respects_memory_limit because z3 is not available" + ); + return; + } + + prj.wipe_contracts(); + prj.update_config(|config| config.memory_limit = 4096); + prj.add_test( + "SymbolicMemoryLimit.t.sol", + r#" +contract SymbolicMemoryLimit { + fallback() external payable { + assembly { + switch callvalue() + case 0 { mstore(4065, 1) } + case 1 { mstore8(4096, 1) } + case 2 { mstore(2048, 1) } + default { mstore8(0, 1) } + } + } + + function checkMemoryLimitExactBoundaries() public pure { + assembly { + mstore(4064, 1) + mstore8(4095, 1) + } + } + + function checkMemoryLimitFirstInvalidBoundaries() public { + bool wordOk; + bool byteOk; + assembly { + wordOk := call(gas(), address(), 0, 0, 0, 0, 0) + byteOk := call(gas(), address(), 1, 0, 0, 0, 0) + } + assert(!wordOk && !byteOk); + } + + function checkMemoryLimitNestedCall() public { + bool ok; + assembly { + mstore(2048, 1) + ok := call(gas(), address(), 2, 0, 0, 0, 0) + } + assert(ok); + } + + function checkMemoryLimitCallInputExpansion() public { + bool ok; + assembly { + ok := call(gas(), address(), 3, 2048, 2048, 0, 0) + } + assert(ok); + } + + function checkMemoryLimitSymbolicCallSize(bool expand) public { + bool ok; + assembly { + let size := mul(expand, 2048) + ok := call(gas(), address(), 3, 2048, size, 0, 0) + } + assert(ok); + } + + function wrappingCallRange() external { + assembly { + pop(call(gas(), address(), 0, not(0), 1, 0, 0)) + } + } + + function nestedWrappingCallRange() external { + this.wrappingCallRange(); + } + + function checkMemoryLimitRejectsWrappingCallRanges() public { + (bool directOk,) = address(this).call(abi.encodeCall(this.wrappingCallRange, ())); + (bool nestedOk,) = address(this).call(abi.encodeCall(this.nestedWrappingCallRange, ())); + assert(!directOk && !nestedOk); + } +} +"#, + ); + + cmd.args(["test", "--match-test", "checkMemoryLimitNestedCall"]).assert_success(); + + cmd.forge_fuse(); + assert_symbolic_witness(cmd.args(["test", "--symbolic", "--match-test", "checkMemoryLimit"])) + .success() + .stdout_eq(str![[r#" +... +Ran 6 tests for test/SymbolicMemoryLimit.t.sol:SymbolicMemoryLimit +[PASS] checkMemoryLimitCallInputExpansion() ([METRICS]) +[PASS] checkMemoryLimitExactBoundaries() ([METRICS]) +[PASS] checkMemoryLimitFirstInvalidBoundaries() ([METRICS]) +[PASS] checkMemoryLimitNestedCall() ([METRICS]) +[PASS] checkMemoryLimitRejectsWrappingCallRanges() ([METRICS]) +[PASS] checkMemoryLimitSymbolicCallSize(bool) ([METRICS]) +Suite result: ok. 6 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_mstore_accepts_unconstrained_symbolic_offset(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_mstore_accepts_unconstrained_symbolic_offset because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicMstoreUnconstrained.t.sol", + r#" +contract SymbolicMstoreUnconstrained { + function checkSymbolicMstore(uint16 offset, uint256 marker) public pure { + uint256 loaded; + assembly { + mstore(offset, marker) + loaded := mload(0x80) + } + + assert(loaded != 0x42); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkSymbolicMstore", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicMstoreUnconstrained.t.sol:SymbolicMstoreUnconstrained +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkSymbolicMstore(uint16,uint256) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic MSTORE offset"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_mstore8_accepts_unconstrained_symbolic_offset(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_mstore8_accepts_unconstrained_symbolic_offset because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicMstore8Unconstrained.t.sol", + r#" +contract SymbolicMstore8Unconstrained { + function checkSymbolicMstore8(uint16 offset, uint256 marker) public pure { + uint256 loaded; + assembly { + mstore8(offset, marker) + loaded := byte(0, mload(0x80)) + } + + assert(loaded != 0xab); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkSymbolicMstore8", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicMstore8Unconstrained.t.sol:SymbolicMstore8Unconstrained +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkSymbolicMstore8(uint16,uint256) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic MSTORE8 offset"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_msize_tracks_read_only_memory_expansion(prj: _, cmd: _) { + skip_unless_z3!("symbolic_msize_tracks_read_only_memory_expansion"); + + prj.add_test( + "SymbolicMsizeAfterRead.t.sol", + r#" +contract SymbolicMsizeAfterRead { + function checkReadOnlyExpansion() public { + uint256 afterHash; + uint256 afterLog; + uint256 afterCopy; + assembly { + pop(keccak256(0x200, 1)) + afterHash := msize() + log0(0x400, 1) + afterLog := msize() + mcopy(0, 0x600, 1) + afterCopy := msize() + } + + assert(afterHash == 0x220); + assert(afterLog == 0x420); + assert(afterCopy == 0x620); + } + + function checkSymbolicReadOnlyExpansion(uint16 offset) public pure { + uint256 afterHash; + assembly { + pop(keccak256(offset, 1)) + afterHash := msize() + } + + assert(afterHash > offset); + } +} +"#, + ); + + cmd.args(["test", "--match-test", "check.*ReadOnlyExpansion"]).assert_success(); + + cmd.forge_fuse(); + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "check.*ReadOnlyExpansion", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 2 tests for test/SymbolicMsizeAfterRead.t.sol:SymbolicMsizeAfterRead +[PASS] checkReadOnlyExpansion() ([METRICS]) +[PASS] checkSymbolicReadOnlyExpansion(uint16) ([METRICS]) +Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_msize_respects_zero_symbolic_copy_size(prj: _, cmd: _) { + skip_unless_z3!("symbolic_msize_respects_zero_symbolic_copy_size"); + + prj.add_test( + "SymbolicMsizeAfterCopy.t.sol", + r#" +contract SymbolicMsizeAfterCopy { + function checkZeroLengthCopy(uint8 n) public pure { + uint256 size = uint256(n & 3); + uint256 beforeSize; + uint256 afterSize; + assembly { + beforeSize := msize() + calldatacopy(0x100, 0, size) + afterSize := msize() + } + + assert(size != 0 || afterSize != beforeSize); + } +} +"#, + ); + + let stdout = + assert_symbolic(cmd.args(["test", "--symbolic", "--match-test", "checkZeroLengthCopy"])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicMsizeAfterCopy.t.sol:SymbolicMsizeAfterCopy +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] calldata=0xac831bba0000000000000000000000000000000000000000000000000000000000000000 args=[0]] checkZeroLengthCopy(uint8) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic counterexample did not replay"), "{stdout}"); +} + +// SHA3 over a symbolic offset or a constrained or bounded symbolic size is modeled. +#[forgetest_init] +fn symbolic_sha3_accepts_symbolic_operands(prj: _, cmd: _) { + skip_unless_z3!("symbolic_sha3_accepts_symbolic_operands"); + + prj.add_test( + "SymbolicSha3.t.sol", + r#" +contract SymbolicSha3 { + function checkSymbolicSha3(uint16 offset, uint256 marker) public pure { + bytes32 digest; + bytes32 expected; + assembly { + mstore(0x80, marker) + digest := keccak256(offset, 32) + expected := keccak256(0x80, 32) + } + + if (offset == 0x80) { + assert(digest == expected); + } + } +} +"#, + ); + + prj.add_test( + "SymbolicSha3ConstrainedSize.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicSha3ConstrainedSize is Test { + function checkConstrainedSha3Size(uint16 size, uint256 marker) public { + vm.assume(size == 32); + + bytes32 digest; + bytes32 expected; + assembly { + mstore(0x80, marker) + digest := keccak256(0x80, size) + expected := keccak256(0x80, 32) + } + + assertEq(digest, expected); + } +} +"#, + ); + + prj.add_test( + "SymbolicSha3BoundedSize.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicSha3BoundedSize is Test { + function checkBoundedSha3Size(uint8 rawSize, uint256 marker) public { + uint256 size = uint256(rawSize & 32); + + bytes32 digest; + bytes32 sameDigest; + assembly { + mstore(0x80, marker) + digest := keccak256(0x80, size) + sameDigest := keccak256(0x80, size) + } + + assertEq(digest, sameDigest); + } +} +"#, + ); + + let stdout = assert_symbolic(cmd.args([ + "test", + "--symbolic", + "--match-test", + "^(checkSymbolicSha3|checkConstrainedSha3Size|checkBoundedSha3Size)\\(", + ])) + .success() + .stdout_eq( + str![[r#" +... +Ran 1 test for test/SymbolicSha3BoundedSize.t.sol:SymbolicSha3BoundedSize +[PASS] checkBoundedSha3Size(uint8,uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicSha3ConstrainedSize.t.sol:SymbolicSha3ConstrainedSize +[PASS] checkConstrainedSha3Size(uint16,uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicSha3.t.sol:SymbolicSha3 +[PASS] checkSymbolicSha3(uint16,uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]] + .unordered(), + ) + .get_output() + .stdout_lossy(); + for reason in ["symbolic SHA3 offset", "symbolic SHA3 size"] { + assert!(!stdout.contains(reason), "{stdout}"); + } +} + +// LOG with a symbolic offset or a bounded symbolic size is modeled. +#[forgetest_init] +fn symbolic_log_accepts_symbolic_operands(prj: _, cmd: _) { + skip_unless_z3!("symbolic_log_accepts_symbolic_operands"); + + prj.add_test( + "SymbolicLogOffset.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicLogOffset is Test { + function checkSymbolicLogOffset(uint16 offset, uint256 marker) public { + vm.recordLogs(); + assembly { + mstore(0x80, marker) + log1(offset, 32, 0x1234) + } + + Vm.Log[] memory logs = vm.getRecordedLogs(); + assertEq(logs.length, 1); + if (offset == 0x80) { + assertEq(abi.decode(logs[0].data, (uint256)), marker); + } + } +} +"#, + ); + + prj.add_test( + "SymbolicLogSize.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicLogSize is Test { + function checkSymbolicLogSize(uint8 rawSize) public { + uint256 size = uint256(rawSize & 3); + + assembly { + mstore(0x80, shl(232, 0x010203)) + log1(0x80, size, 0x1234) + } + } +} +"#, + ); + + let stdout = assert_symbolic(cmd.args([ + "test", + "--symbolic", + "--match-test", + "^(checkSymbolicLogOffset|checkSymbolicLogSize)\\(", + ])) + .success() + .stdout_eq( + str![[r#" +... +Ran 1 test for test/SymbolicLogSize.t.sol:SymbolicLogSize +[PASS] checkSymbolicLogSize(uint8) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicLogOffset.t.sol:SymbolicLogOffset +[PASS] checkSymbolicLogOffset(uint16,uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]] + .unordered(), + ) + .get_output() + .stdout_lossy(); + for reason in ["symbolic LOG offset", "symbolic LOG size"] { + assert!(!stdout.contains(reason), "{stdout}"); + } +} + +// RETURNDATACOPY with symbolic offsets, destination or bounded size is modeled. +#[forgetest_init] +fn symbolic_returndatacopy_accepts_symbolic_operands(prj: _, cmd: _) { + skip_unless_z3!("symbolic_returndatacopy_accepts_symbolic_operands"); + + prj.add_test( + "SymbolicReturndataCopyConstrained.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicReturndataCopyHelper { + function pair(uint256 marker) external pure returns (uint256, uint256) { + return (11, marker); + } +} + +contract SymbolicReturndataCopyConstrained is Test { + SymbolicReturndataCopyHelper helper; + + function setUp() public { + helper = new SymbolicReturndataCopyHelper(); + } + + function checkConstrainedReturndataCopy(uint16 offset, uint256 marker) public { + vm.assume(offset == 32); + + bytes4 selector = SymbolicReturndataCopyHelper.pair.selector; + address target = address(helper); + bool ok; + uint256 copied; + assembly { + mstore(0x80, selector) + mstore(0x84, marker) + ok := staticcall(gas(), target, 0x80, 36, 0, 0) + returndatacopy(0xa0, offset, 32) + copied := mload(0xa0) + } + + assertTrue(ok); + assertEq(copied, marker); + } +} +"#, + ); + + prj.add_test( + "SymbolicReturndataCopyOffset.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicReturndataCopyOffsetHelper { + function pair(uint256 marker) external pure returns (uint256, uint256) { + return (11, marker); + } +} + +contract SymbolicReturndataCopyOffset is Test { + SymbolicReturndataCopyOffsetHelper helper; + + function setUp() public { + helper = new SymbolicReturndataCopyOffsetHelper(); + } + + function checkSymbolicReturndataCopyOffset(uint8 rawOffset, uint256 marker) public { + uint256 offset = uint256(rawOffset); + vm.assume(offset <= 32); + + bytes4 selector = SymbolicReturndataCopyOffsetHelper.pair.selector; + address target = address(helper); + bool ok; + uint256 copied; + assembly { + mstore(0x80, selector) + mstore(0x84, marker) + ok := staticcall(gas(), target, 0x80, 36, 0, 0) + returndatacopy(0xa0, offset, 32) + copied := mload(0xa0) + } + + assertTrue(ok); + if (offset == 32) { + assertEq(copied, marker); + } + } +} +"#, + ); + + prj.add_test( + "SymbolicReturndataCopyDest.t.sol", + r#" +contract SymbolicReturndataCopyDestHelper { + function echo(uint256 marker) external pure returns (uint256) { + return marker; + } +} + +contract SymbolicReturndataCopyDest { + SymbolicReturndataCopyDestHelper helper = new SymbolicReturndataCopyDestHelper(); + + function checkSymbolicReturndataCopyDest(uint16 dest, uint256 marker) public { + (bool ok,) = address(helper).call( + abi.encodeWithSelector(SymbolicReturndataCopyDestHelper.echo.selector, marker) + ); + require(ok); + + uint256 copied; + assembly { + returndatacopy(dest, 0, 32) + copied := mload(0x80) + } + + if (dest == 0x80) { + assert(copied == marker); + } + } +} +"#, + ); + + prj.add_test( + "SymbolicReturndataCopySize.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicReturndataCopySizeHelper { + function pair(uint256 marker) external pure returns (uint256, uint256) { + return (11, marker); + } +} + +contract SymbolicReturndataCopySize is Test { + SymbolicReturndataCopySizeHelper helper; + + function setUp() public { + helper = new SymbolicReturndataCopySizeHelper(); + } + + function checkSymbolicReturndataCopySize(uint8 rawSize, uint256 marker) public { + uint256 size = uint256(rawSize); + vm.assume(size <= 32); + + bytes4 selector = SymbolicReturndataCopySizeHelper.pair.selector; + address target = address(helper); + bool ok; + uint256 copied; + assembly { + mstore(0x80, selector) + mstore(0x84, marker) + ok := staticcall(gas(), target, 0x80, 36, 0, 0) + returndatacopy(0xa0, 32, size) + copied := mload(0xa0) + } + + assertTrue(ok); + if (size == 32) { + assertEq(copied, marker); + } + } +} +"#, + ); + + let stdout = assert_symbolic(cmd.args([ + "test", + "--symbolic", + "--match-test", + "^(checkConstrainedReturndataCopy|checkSymbolicReturndataCopyOffset|checkSymbolicReturndataCopyDest|checkSymbolicReturndataCopySize)\\(", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicReturndataCopySize.t.sol:SymbolicReturndataCopySize +[PASS] checkSymbolicReturndataCopySize(uint8,uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicReturndataCopyOffset.t.sol:SymbolicReturndataCopyOffset +[PASS] checkSymbolicReturndataCopyOffset(uint8,uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicReturndataCopyDest.t.sol:SymbolicReturndataCopyDest +[PASS] checkSymbolicReturndataCopyDest(uint16,uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicReturndataCopyConstrained.t.sol:SymbolicReturndataCopyConstrained +[PASS] checkConstrainedReturndataCopy(uint16,uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]].unordered()) + .get_output() + .stdout_lossy(); + for reason in [ + "symbolic RETURNDATACOPY dest", + "symbolic RETURNDATACOPY offset", + "symbolic RETURNDATACOPY size", + ] { + assert!(!stdout.contains(reason), "{stdout}"); + } +} + +#[forgetest_init] +fn symbolic_returndatacopy_reverts_on_out_of_bounds_offset_with_symbolic_size(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_returndatacopy_reverts_on_out_of_bounds_offset_with_symbolic_size because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicReturndataCopyOobOffset.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicReturndataCopyOobOffsetHelper { + function pair(uint256 marker) external pure returns (uint256, uint256) { + return (11, marker); + } +} + +contract SymbolicReturndataCopyOobOffsetTrigger { + SymbolicReturndataCopyOobOffsetHelper public helper; + + constructor(SymbolicReturndataCopyOobOffsetHelper _helper) { + helper = _helper; + } + + function copy(uint256 offset, uint256 size) external { + bytes4 selector = SymbolicReturndataCopyOobOffsetHelper.pair.selector; + address target = address(helper); + assembly { + mstore(0x80, selector) + mstore(0x84, 0) + pop(staticcall(gas(), target, 0x80, 36, 0, 0)) + returndatacopy(0, offset, size) + } + } +} + +contract SymbolicReturndataCopyOobOffset is Test { + SymbolicReturndataCopyOobOffsetHelper helper; + SymbolicReturndataCopyOobOffsetTrigger trigger; + + function setUp() public { + helper = new SymbolicReturndataCopyOobOffsetHelper(); + trigger = new SymbolicReturndataCopyOobOffsetTrigger(helper); + } + + function checkOutOfBoundsOffsetForcedZeroSizeReverts(uint256 size) public { + vm.assume(size <= 0); + vm.expectRevert(); + trigger.copy(65, size); + } + + function checkOutOfBoundsClearsReturnData(uint256 size) public { + vm.assume(size <= 0); + (bool ok, bytes memory data) = address(trigger).call( + abi.encodeCall(SymbolicReturndataCopyOobOffsetTrigger.copy, (65, size)) + ); + assertFalse(ok); + assertEq(data.length, 0); + } + +} +"#, + ); + + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkOutOfBoundsOffsetForcedZeroSizeReverts|checkOutOfBoundsClearsReturnData", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 2 tests for test/SymbolicReturndataCopyOobOffset.t.sol:SymbolicReturndataCopyOobOffset +[PASS] checkOutOfBoundsClearsReturnData(uint256) ([METRICS]) +[PASS] checkOutOfBoundsOffsetForcedZeroSizeReverts(uint256) ([METRICS]) +Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_return_revert_accept_symbolic_offset(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_return_revert_accept_symbolic_offset because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicReturnRevertOffset.t.sol", + r#" +contract SymbolicReturnRevertHelper { + function ret(uint16 offset, uint256 marker) external pure returns (uint256) { + assembly { + mstore(0x80, marker) + return(offset, 32) + } + } + + function rev(uint16 offset, uint256 marker) external pure { + assembly { + mstore(0x80, marker) + revert(offset, 32) + } + } +} + +contract SymbolicReturnRevertOffset { + SymbolicReturnRevertHelper helper; + + function setUp() public { + helper = new SymbolicReturnRevertHelper(); + } + + function checkSymbolicReturnOffset(uint16 offset, uint256 marker) public view { + uint256 value = helper.ret(offset, marker); + if (offset == 0x80) { + assert(value == marker); + } + } + + function checkSymbolicRevertOffset(uint16 offset, uint256 marker) public view { + (bool ok, bytes memory data) = + address(helper).staticcall(abi.encodeCall(SymbolicReturnRevertHelper.rev, (offset, marker))); + assert(!ok); + if (offset == 0x80) { + assert(abi.decode(data, (uint256)) == marker); + } + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-contract", + "SymbolicReturnRevertOffset", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 2 tests for test/SymbolicReturnRevertOffset.t.sol:SymbolicReturnRevertOffset +[PASS] checkSymbolicReturnOffset(uint16,uint256) ([METRICS]) +[PASS] checkSymbolicRevertOffset(uint16,uint256) ([METRICS]) +Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic RETURN offset"), "{stdout}"); + assert!(!stdout.contains("symbolic REVERT offset"), "{stdout}"); +} + +// MCOPY with a symbolic source and RETURN / REVERT with a bounded symbolic size are modeled. +#[forgetest_init] +fn symbolic_mcopy_return_revert_accept_symbolic_operands(prj: _, cmd: _) { + skip_unless_z3!("symbolic_mcopy_return_revert_accept_symbolic_operands"); + + prj.add_test( + "SymbolicMcopy.t.sol", + r#" +contract SymbolicMcopy { + function checkSymbolicMcopy(uint16 src, uint256 marker) public pure { + uint256 copied; + assembly { + mstore(0x80, marker) + mcopy(0, src, 32) + copied := mload(0) + } + + if (src == 0x80) { + assert(copied == marker); + } + } +} +"#, + ); + + prj.add_test( + "SymbolicReturnSize.t.sol", + r#" +contract SymbolicReturnSizeHelper { + fallback() external { + assembly { + calldatacopy(0x00, 0x00, calldatasize()) + return(0x00, calldatasize()) + } + } +} + +contract SymbolicReturnSize { + SymbolicReturnSizeHelper helper; + + function setUp() public { + helper = new SymbolicReturnSizeHelper(); + } + + function checkSymbolicReturnSize(uint8 rawSize, uint256 marker) public view { + uint256 size = uint256(rawSize & 32); + bool ok; + uint256 returnedSize; + address target = address(helper); + assembly { + mstore(0x80, marker) + ok := staticcall(gas(), target, 0x80, size, 0x00, 0x00) + returnedSize := returndatasize() + } + + assert(ok); + assert(returnedSize == size); + } +} +"#, + ); + + prj.add_test( + "SymbolicRevertSize.t.sol", + r#" +contract SymbolicRevertSizeHelper { + fallback() external { + assembly { + calldatacopy(0x00, 0x00, calldatasize()) + revert(0x00, calldatasize()) + } + } +} + +contract SymbolicRevertSize { + SymbolicRevertSizeHelper helper; + + function setUp() public { + helper = new SymbolicRevertSizeHelper(); + } + + function checkSymbolicRevertSize(uint8 rawSize, uint256 marker) public view { + uint256 size = uint256(rawSize & 32); + bool ok; + uint256 returnedSize; + address target = address(helper); + assembly { + mstore(0x80, marker) + ok := staticcall(gas(), target, 0x80, size, 0x00, 0x00) + returnedSize := returndatasize() + } + + assert(!ok); + assert(returnedSize == size); + } +} +"#, + ); + + let stdout = assert_symbolic(cmd.args([ + "test", + "--symbolic", + "--match-test", + "^(checkSymbolicMcopy|checkSymbolicReturnSize|checkSymbolicRevertSize)\\(", + ])) + .success() + .stdout_eq( + str![[r#" +... +Ran 1 test for test/SymbolicRevertSize.t.sol:SymbolicRevertSize +[PASS] checkSymbolicRevertSize(uint8,uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicReturnSize.t.sol:SymbolicReturnSize +[PASS] checkSymbolicReturnSize(uint8,uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test for test/SymbolicMcopy.t.sol:SymbolicMcopy +[PASS] checkSymbolicMcopy(uint16,uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]] + .unordered(), + ) + .get_output() + .stdout_lossy(); + for reason in ["symbolic MCOPY src", "symbolic RETURN size", "symbolic REVERT size"] { + assert!(!stdout.contains(reason), "{stdout}"); + } +} + +// A chain of hashes over the previous hash shares every earlier preimage. Naming and searching such +// expressions must stay linear in the chain length; before the fix, six rounds ran out of memory. +#[forgetest_init] +fn symbolic_chained_keccak_stays_linear(prj: _, cmd: _) { + skip_unless_z3!("symbolic_chained_keccak_stays_linear"); + + prj.add_test( + "SymbolicKeccakChain.t.sol", + r#" +contract SymbolicKeccakChain { + uint256 public sink; + + function checkKeccakChain(uint256 seed) external { + uint256 s = seed; + for (uint256 i; i < 16; ++i) { + s = uint256(keccak256(abi.encode(s))) + 1; + } + sink = s; + } +} +"#, + ); + + cmd.args(["test", "--symbolic", "--match-test", "checkKeccakChain"]) + .assert_success() + .stdout_eq(str![[r#" +... +[PASS] checkKeccakChain(uint256) [..] +... +"#]]); +} diff --git a/crates/forge/tests/cli/test_cmd/symbolic_opcodes.rs b/crates/forge/tests/cli/test_cmd/symbolic_opcodes.rs new file mode 100644 index 0000000000000..a640ab716071d --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/symbolic_opcodes.rs @@ -0,0 +1,779 @@ +use foundry_common::sh_eprintln; +use foundry_test_utils::{forgetest_init, str, util::OutputExt}; + +use super::symbolic_helpers::{assert_symbolic_witness, z3_available}; +use crate::skip_unless_z3; + +#[forgetest_init] +fn symbolic_opcode_byte_and_signextend_accept_symbolic_index(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_opcode_byte_and_signextend_accept_symbolic_index because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicByteSignextend.t.sol", + r#" +contract SymbolicByteSignextend { + function checkSymbolicByteIndex(uint8 idx) public pure { + bytes32 word = hex"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"; + uint256 got; + assembly { + got := byte(idx, word) + } + if (idx < 32) { + assert(got == idx); + } else { + assert(got == 0); + } + } + + function checkSymbolicSignextendIndex(uint8 idx) public pure { + uint256 value = 0x80; + uint256 got; + assembly { + got := signextend(idx, value) + } + if (idx == 0) { + assert(got == type(uint256).max - 0x7f); + } else { + assert(got == 0x80); + } + } +} +"#, + ); + + let stdout = + assert_symbolic_witness(cmd.args(["test", "--symbolic", "--match-test", "checkSymbolic"])) + .success() + .stdout_eq(str![[r#" +... +Ran 2 tests for test/SymbolicByteSignextend.t.sol:SymbolicByteSignextend +[PASS] checkSymbolicByteIndex(uint8) ([METRICS]) +[PASS] checkSymbolicSignextendIndex(uint8) ([METRICS]) +Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic BYTE index"), "{stdout}"); + assert!(!stdout.contains("symbolic SIGNEXTEND index"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_shift_opcodes_accept_symbolic_amount(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_shift_opcodes_accept_symbolic_amount because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicShift.t.sol", + r#" +contract SymbolicShift { + function checkSymbolicShiftAmount(uint16 shift) public pure { + uint256 left; + uint256 right; + uint256 signed; + assembly { + left := shl(shift, 1) + right := shr(shift, shl(255, 1)) + signed := sar(shift, not(0)) + } + + if (shift == 5) { + assert(left == 32); + } + if (shift >= 256) { + assert(left == 0); + assert(right == 0); + assert(signed == type(uint256).max); + } + if (shift == 255) { + assert(right == 1); + assert(signed == type(uint256).max); + } + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkSymbolicShiftAmount", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicShift.t.sol:SymbolicShift +[PASS] checkSymbolicShiftAmount(uint16) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic shift amount"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_exp_accepts_larger_bounded_symbolic_base(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_exp_accepts_larger_bounded_symbolic_base because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicExp.t.sol", + r#" +contract SymbolicExp { + function checkSymbolicExpBase(uint8 x) public pure { + uint256 y = uint256(x) ** 16; + if (x == 2) { + assert(y == 65536); + } + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkSymbolicExpBase", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicExp.t.sol:SymbolicExp +[PASS] checkSymbolicExpBase(uint8) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic EXP base"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_exp_accepts_bounded_symbolic_exponent(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_exp_accepts_bounded_symbolic_exponent because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicExpExponent.t.sol", + r#" +contract SymbolicExpExponent { + function checkSymbolicExpExponent(uint8 raw) public pure { + uint256 exponent = uint256(raw) & 7; + uint256 y = uint256(3) ** exponent; + if (exponent == 5) { + assert(y == 243); + } + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkSymbolicExpExponent", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicExpExponent.t.sol:SymbolicExpExponent +[PASS] checkSymbolicExpExponent(uint8) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic EXP exponent"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_exp_accepts_wider_symbolic_exponent_for_concrete_base(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_exp_accepts_wider_symbolic_exponent_for_concrete_base because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicExpWideExponent.t.sol", + r#" +contract SymbolicExpWideExponent { + function checkSymbolicExpWideExponent(uint8 raw) public pure { + uint256 exponent = uint256(raw) & 63; + uint256 y = uint256(2) ** exponent; + if (exponent == 40) { + assert(y == 1099511627776); + } + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkSymbolicExpWideExponent", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicExpWideExponent.t.sol:SymbolicExpWideExponent +[PASS] checkSymbolicExpWideExponent(uint8) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic EXP exponent"), "{stdout}"); +} + +// The engine does not model gas consumption, so `GAS` / `gasleft()` must fail +// closed instead of returning a concrete max value or a symbolic approximation +// that can produce non-replaying counterexamples. +#[forgetest_init] +fn symbolic_gasleft_reports_unsupported(prj: _, cmd: _) { + skip_unless_z3!("symbolic_gasleft_reports_unsupported"); + + prj.add_test( + "SymbolicGasLeftBound.t.sol", + r#" +contract SymbolicGasLeftBound { + function checkGasLeftIsBounded() public view { + assert(gasleft() > 2 ** 200); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkGasLeftIsBounded", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicGasLeftBound.t.sol:SymbolicGasLeftBound +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: GAS/gasleft() not modeled] checkGasLeftIsBounded() ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_gas_can_be_used_as_call_operand(prj: _, cmd: _) { + skip_unless_z3!("symbolic_gas_can_be_used_as_call_operand"); + + prj.add_test( + "SymbolicGasCallOperand.t.sol", + r#" +contract SymbolicGasCallOperandTarget { + function ping(uint256 value) external pure returns (uint256) { + return value + 1; + } +} + +contract SymbolicGasCallOperand { + SymbolicGasCallOperandTarget target; + + function setUp() public { + target = new SymbolicGasCallOperandTarget(); + } + + function checkGasOnlyFeedsCall(uint128 raw) public view { + uint256 value = uint256(raw); + bytes4 selector = SymbolicGasCallOperandTarget.ping.selector; + address targetAddress = address(target); + bool ok; + uint256 out; + assembly { + let ptr := mload(0x40) + mstore(ptr, selector) + mstore(add(ptr, 4), value) + ok := staticcall(gas(), targetAddress, ptr, 36, ptr, 32) + out := mload(ptr) + } + + assert(ok); + assert(out == value + 1); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkGasOnlyFeedsCall", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicGasCallOperand.t.sol:SymbolicGasCallOperand +[PASS] checkGasOnlyFeedsCall(uint128) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("GAS/gasleft() not modeled"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_explicit_call_gas_reports_unsupported(prj: _, cmd: _) { + skip_unless_z3!("symbolic_explicit_call_gas_reports_unsupported"); + + prj.add_test( + "SymbolicExplicitCallGas.t.sol", + r#" +contract SymbolicExplicitCallGasTarget { + function ping() external pure returns (uint256) { + return 1; + } +} + +contract SymbolicExplicitCallGas { + SymbolicExplicitCallGasTarget target; + + function setUp() public { + target = new SymbolicExplicitCallGasTarget(); + } + + function checkCallGasZero() public { + (bool ok,) = address(target).call{gas: 0}(""); + assert(ok); + } + + function checkCallGasOne() public { + (bool ok,) = address(target).call{gas: 1}(""); + assert(ok); + } + + function checkCallGasThirtyTwo() public { + (bool ok,) = address(target).call{gas: 32}(""); + assert(ok); + } + + function checkStaticCallGasLarge() public view { + address targetAddress = address(target); + assembly { + if iszero(staticcall(100000, targetAddress, 0, 0, 0, 0)) { revert(0, 0) } + } + } + + function checkDelegateCallGasSymbolic(uint256 gasLimit) public { + address targetAddress = address(target); + assembly { + if iszero(delegatecall(gasLimit, targetAddress, 0, 0, 0, 0)) { revert(0, 0) } + } + } + + function checkCallCodeGasComputed(uint256 gasLimit) public { + address targetAddress = address(target); + assembly { + if iszero(callcode(add(gasLimit, 1), targetAddress, 0, 0, 0, 0, 0)) { revert(0, 0) } + } + } + + function checkTransferGasCap() public { + payable(address(1)).transfer(0); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args(["test", "--symbolic", "--match-test", "check"])) + .failure() + .stdout_eq(str![[r#" +... +Ran 7 tests for test/SymbolicExplicitCallGas.t.sol:SymbolicExplicitCallGas +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: explicit CALL gas limit not modeled] checkCallCodeGasComputed(uint256) ([METRICS]) +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: explicit CALL gas limit not modeled] checkCallGasOne() ([METRICS]) +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: explicit CALL gas limit not modeled] checkCallGasThirtyTwo() ([METRICS]) +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: explicit CALL gas limit not modeled] checkCallGasZero() ([METRICS]) +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: explicit CALL gas limit not modeled] checkDelegateCallGasSymbolic(uint256) ([METRICS]) +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: explicit CALL gas limit not modeled] checkStaticCallGasLarge() ([METRICS]) +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: explicit CALL gas limit not modeled] checkTransferGasCap() ([METRICS]) +Suite result: FAILED. 0 passed; 7 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_gas_derived_call_operand_reports_unsupported(prj: _, cmd: _) { + skip_unless_z3!("symbolic_gas_derived_call_operand_reports_unsupported"); + + prj.add_test( + "SymbolicDerivedGasCallOperand.t.sol", + r#" +contract SymbolicDerivedGasCallOperandTarget { + function ping() external pure returns (uint256) { + return 1; + } +} + +contract SymbolicDerivedGasCallOperand { + SymbolicDerivedGasCallOperandTarget target; + + function setUp() public { + target = new SymbolicDerivedGasCallOperandTarget(); + } + + function checkDerivedGasCallOperand() public view { + bytes4 selector = SymbolicDerivedGasCallOperandTarget.ping.selector; + address targetAddress = address(target); + bool ok; + assembly { + let ptr := mload(0x40) + mstore(ptr, selector) + ok := staticcall(sub(gas(), 1), targetAddress, ptr, 4, ptr, 32) + } + assert(ok); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkDerivedGasCallOperand", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicDerivedGasCallOperand.t.sol:SymbolicDerivedGasCallOperand +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: explicit CALL gas limit not modeled] checkDerivedGasCallOperand() ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +// Only the bytes that actually reach the callee matter: the 31 high bytes of a word whose +// low byte depends on `gasleft()` are provably zero, so copying just those into CALL input +// must stay supported. +#[forgetest_init] +fn symbolic_gas_excluded_call_calldata_bytes_supported(prj: _, cmd: _) { + skip_unless_z3!("symbolic_gas_excluded_call_calldata_bytes_supported"); + + prj.add_test( + "SymbolicGasExcludedCallData.t.sol", + r#" +contract SymbolicGasExcludedCallDataTarget { + fallback() external {} +} + +contract SymbolicGasExcludedCallData { + SymbolicGasExcludedCallDataTarget target; + + function setUp() public { + target = new SymbolicGasExcludedCallDataTarget(); + } + + function checkGasExcludedFromCallData() public view { + address targetAddress = address(target); + bool ok; + assembly { + let ptr := mload(0x40) + mstore(ptr, and(gas(), 0xff)) + ok := staticcall(gas(), targetAddress, ptr, 31, 0, 0) + } + assert(ok); + } + + function checkGasIncludedInCallData() public view { + address targetAddress = address(target); + bool ok; + assembly { + let ptr := mload(0x40) + mstore(ptr, and(gas(), 0xff)) + ok := staticcall(gas(), targetAddress, ptr, 32, 0, 0) + } + assert(ok); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-contract", + "SymbolicGasExcludedCallData", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 2 tests for test/SymbolicGasExcludedCallData.t.sol:SymbolicGasExcludedCallData +[PASS] checkGasExcludedFromCallData() ([METRICS]) +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: GAS/gasleft() not modeled] checkGasIncludedInCallData() ([METRICS]) +Suite result: FAILED. 1 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_gas_in_call_calldata_reports_unsupported(prj: _, cmd: _) { + skip_unless_z3!("symbolic_gas_in_call_calldata_reports_unsupported"); + + prj.add_test( + "SymbolicGasCallData.t.sol", + r#" +contract SymbolicGasCallDataTarget { + fallback() external {} +} + +contract SymbolicGasCallData { + SymbolicGasCallDataTarget target; + + function setUp() public { + target = new SymbolicGasCallDataTarget(); + } + + function checkGasInCallData() public view { + address targetAddress = address(target); + bool ok; + assembly { + let ptr := mload(0x40) + mstore(ptr, gas()) + ok := staticcall(gas(), targetAddress, ptr, 32, 0, 0) + } + assert(ok); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args(["test", "--symbolic", "--match-test", "checkGasInCallData"])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicGasCallData.t.sol:SymbolicGasCallData +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: GAS/gasleft() not modeled] checkGasInCallData() ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_gas_as_call_target_reports_unsupported(prj: _, cmd: _) { + skip_unless_z3!("symbolic_gas_as_call_target_reports_unsupported"); + + prj.add_test( + "SymbolicGasCallTarget.t.sol", + r#" +contract SymbolicGasCallTarget { + function checkGasAsCallTarget() public view { + bool ok; + assembly { + ok := staticcall(gas(), gas(), 0, 0, 0, 0) + } + assert(ok); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkGasAsCallTarget", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicGasCallTarget.t.sol:SymbolicGasCallTarget +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: GAS/gasleft() not modeled] checkGasAsCallTarget() ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_gas_as_call_input_bounds_reports_unsupported(prj: _, cmd: _) { + skip_unless_z3!("symbolic_gas_as_call_input_bounds_reports_unsupported"); + + prj.add_test( + "SymbolicGasCallInputBounds.t.sol", + r#" +contract SymbolicGasCallInputBoundsTarget { + fallback() external {} +} + +contract SymbolicGasCallInputBounds { + SymbolicGasCallInputBoundsTarget target; + + function setUp() public { + target = new SymbolicGasCallInputBoundsTarget(); + } + + function checkGasAsCallInputOffset() public view { + address targetAddress = address(target); + bool ok; + assembly { + ok := staticcall(gas(), targetAddress, gas(), 0, 0, 0) + } + assert(ok); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkGasAsCallInputOffset", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicGasCallInputBounds.t.sol:SymbolicGasCallInputBounds +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: GAS/gasleft() not modeled] checkGasAsCallInputOffset() ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +// Plan-compliant target behavior for the `GAS` / `gasleft()` opcode: any +// symbolic path that branches on `gasleft()` should taint the result as +// Incomplete (Unsupported), because gas is not modeled symbolically and a +// bounded-symbolic approximation produces non-replaying counterexamples. +// +// The contract below has two branches gated by `gasleft()`: +// - the `low gas` branch is concretely unreachable under any normal forge transaction gas limit; +// - the `high gas` branch is the only one a concrete replay can ever take. +// +// A correct symbolic engine should refuse to draw conclusions: rather than +// PASS (if it silently treated `gasleft` as always high) or FAIL with a +// non-replaying counterexample (if it lets Z3 pick `gasleft = 50`), it should +// emit a `[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic +// execution feature: GAS/gasleft() not modeled]` result. +#[forgetest_init] +fn symbolic_gasleft_branch_reports_unsupported(prj: _, cmd: _) { + skip_unless_z3!("symbolic_gasleft_branch_reports_unsupported"); + + prj.add_test( + "SymbolicGasLeftIncomplete.t.sol", + r#" +contract SymbolicGasLeftIncomplete { + // The `gasleft() < 100` branch is concretely unreachable under any + // normal forge transaction gas limit. A correct symbolic engine that + // does not model gas must not let Z3 pick `gasleft = 50`, take this + // branch, and report a counterexample that will never replay; the + // result should taint as Incomplete instead. + function checkGasGuardedBranch(uint256 input) public view { + if (gasleft() < 100) { + assert(input != 0xdead); + } + } +} +"#, + ); + + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkGasGuardedBranch", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicGasLeftIncomplete.t.sol:SymbolicGasLeftIncomplete +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: GAS/gasleft() not modeled] checkGasGuardedBranch(uint256) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +// Plan-compliant target behavior for the symbolic Keccak heuristic: any +// result reached on a path whose proof obligation reduces to a Keccak +// property (e.g. `keccak(x) != 0` by uninterpreted-output assumption) +// must surface explicit Keccak/SHA3 vocabulary — either tainted as +// Incomplete or carrying a user-facing warning — because the engine does +// not model SHA3 collision resistance as a real cryptographic proof. +#[forgetest_init] +fn symbolic_keccak_dependent_safe_result_must_taint_incomplete(prj: _, cmd: _) { + skip_unless_z3!("symbolic_keccak_dependent_safe_result_must_taint_incomplete"); + + prj.add_test( + "SymbolicKeccakHeuristic.t.sol", + r#" +contract SymbolicKeccakHeuristic { + // The engine treats keccak as an uninterpreted function whose output + // is never zero by construction, so this assertion holds symbolically. + // That is a modeling assumption, not a cryptographic proof — the SAFE + // verdict must surface as Incomplete or carry a Keccak warning. + function checkKeccakNeverZero(uint256 x) public pure { + assert(keccak256(abi.encodePacked(x)) != bytes32(0)); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkKeccakNeverZero", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicKeccakHeuristic.t.sol:SymbolicKeccakHeuristic +[FAIL: incomplete symbolic execution (Error): solver error: solver model does not satisfy path constraints involving symbolic Keccak heuristic] checkKeccakNeverZero(uint256) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + // Require explicit Keccak/SHA3 vocabulary in the result line; a bare + // "incomplete" for unrelated reasons (e.g. solver-error) does not + // satisfy the plan's "heuristic paths are visible, not silently + // presented as proof-grade" acceptance criterion. + let lowered = stdout.to_lowercase(); + let has_keccak_signal = lowered.contains("keccak heuristic") + || lowered.contains("sha3 heuristic") + || lowered.contains("symbolic keccak") + || lowered.contains("symbolic sha3") + || lowered.contains("heuristic keccak"); + assert!( + has_keccak_signal, + "expected Keccak-heuristic taint or warning in output, got:\n{stdout}" + ); +} diff --git a/crates/forge/tests/cli/test_cmd/symbolic_parity/crytic.rs b/crates/forge/tests/cli/test_cmd/symbolic_parity/crytic.rs new file mode 100644 index 0000000000000..4eb552cf7cd08 --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/symbolic_parity/crytic.rs @@ -0,0 +1,90 @@ +use super::assert_symbolic_witness; +use crate::skip_unless_z3; +use foundry_test_utils::{forgetest_init, str}; + +// --------------------------------------------------------------------------- +// crytic/properties ERC20 — sum-of-balances invariant on a BUGGY token. +// --------------------------------------------------------------------------- +// The reference invariant from https://github.com/crytic/properties is +// `sum(balanceOf) == totalSupply`. We use a buggy `_transfer` that mints on +// transfer to a specific address — symbolic execution should expose it. +#[forgetest_init] +fn crytic_properties_erc20_sum_invariant_buggy(prj: _, cmd: _) { + skip_unless_z3!("crytic_properties_erc20_sum_invariant_buggy"); + + prj.add_test( + "CryticPropertiesErc20Parity.t.sol", + r#" +import "forge-std/Test.sol"; + +contract BuggyToken { + mapping(address => uint256) public balanceOf; + uint256 public totalSupply; + address constant TRACKED_A = address(0xA11CE); + address constant TRACKED_B = address(0xB0B); + + constructor() { + balanceOf[TRACKED_A] = 50; + balanceOf[TRACKED_B] = 50; + totalSupply = 100; + } + + function transfer(address to, uint256 amount) external { + if (balanceOf[msg.sender] < amount) return; + unchecked { + balanceOf[msg.sender] -= amount; + } + // BUG: doubles the credit when `to` is a specific address. + if (to == TRACKED_A) { + balanceOf[to] += amount * 2; + } else { + balanceOf[to] += amount; + } + } +} + +contract CryticPropertiesErc20Parity is Test { + BuggyToken token; + address constant TRACKED_A = address(0xA11CE); + address constant TRACKED_B = address(0xB0B); + + function setUp() public { + token = new BuggyToken(); + targetContract(address(token)); + targetSender(TRACKED_A); + targetSender(TRACKED_B); + } + + /// forge-config: default.symbolic.invariant_depth = 2 + function invariant_sumOfBalances() public view { + assertEq(token.balanceOf(TRACKED_A) + token.balanceOf(TRACKED_B), token.totalSupply()); + } +} +"#, + ); + + // Witness sequence picks arbitrary `transfer(address,uint256)` args. + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "invariant_sumOfBalances", + ])) + .failure() + .stdout_eq(str![[r#" +... +Failing tests: +Encountered 1 failing test in test/CryticPropertiesErc20Parity.t.sol:CryticPropertiesErc20Parity +[FAIL: [..]] + [Sequence] (original: [..], shrunk: 1) + [SENDER] [SENDER] calldata=transfer(address,uint256) [ARGS] + invariant_sumOfBalances() ([METRICS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]]); +} diff --git a/crates/forge/tests/cli/test_cmd/symbolic_parity/devdacian.rs b/crates/forge/tests/cli/test_cmd/symbolic_parity/devdacian.rs new file mode 100644 index 0000000000000..513330ba82267 --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/symbolic_parity/devdacian.rs @@ -0,0 +1,53 @@ +use super::assert_symbolic_witness; +use crate::skip_unless_z3; +use foundry_test_utils::{forgetest_init, str}; + +// --------------------------------------------------------------------------- +// devdacian/solidity-fuzzing-comparison — "Rarely False" challenge (#6). +// --------------------------------------------------------------------------- +// Only Halmos & Certora solved this upstream; Echidna, Medusa, and +// Foundry-fuzz all failed. A perfect minimal regression for the symbolic +// engine on modular / divisibility reasoning (`bv-urem` over bounded `n`). +#[forgetest_init] +fn devdacian_rarely_false_parity(prj: _, cmd: _) { + skip_unless_z3!("devdacian_rarely_false_parity"); + + prj.add_test( + "DevdacianRarelyFalse.t.sol", + r#" +import "forge-std/Test.sol"; + +contract DevdacianRarelyFalse is Test { + uint256 constant private OFFSET = 1234; + uint256 constant private POW = 80; + + function checkRarelyFalse(uint256 n) public pure { + // Match upstream input precondition: n in [1, type(uint256).max - OFFSET]. + vm.assume(n >= 1); + vm.assume(n <= type(uint256).max - OFFSET); + + // Upstream assertion: `t(_rarelyFalse(n + OFFSET, POW), ...)` + // where `_rarelyFalse(x, e)` returns `false` iff `x % 2**e == 0`. + // So the assertion should hold unless `(n + OFFSET) % 2**POW == 0`. + assert((n + OFFSET) % (1 << POW) != 0); + } +} +"#, + ); + + // Modular constraint with non-power-of-two offset uniquely solvable by SMT + // (bv-urem over bounded n); fuzzers cannot stumble on it. + assert_symbolic_witness(cmd.args(["test", "--symbolic", "--match-test", "checkRarelyFalse"])) + .failure() + .stdout_eq(str![[r#" +... +Failing tests: +Encountered 1 failing test in test/DevdacianRarelyFalse.t.sol:DevdacianRarelyFalse +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkRarelyFalse(uint256) ([METRICS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +"#]]); +} diff --git a/crates/forge/tests/cli/test_cmd/symbolic_parity/echidna.rs b/crates/forge/tests/cli/test_cmd/symbolic_parity/echidna.rs new file mode 100644 index 0000000000000..d396d614bc917 --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/symbolic_parity/echidna.rs @@ -0,0 +1,462 @@ +use super::{assert_relevant_lines, assert_symbolic_witness, json_test_result, read_artifact_ref}; +use crate::skip_unless_z3; +use foundry_test_utils::{forgetest_init, str, util::OutputExt}; + +// --------------------------------------------------------------------------- +// Echidna flags.sol — canonical multi-flag puzzle. +// --------------------------------------------------------------------------- +// Source: https://github.com/crytic/echidna/blob/master/tests/solidity/basic/flags.sol +// Echidna finds a sequence that falsifies `echidna_sometimesfalse`. +// We port it as a stateful symbolic invariant with bounded depth under the +// default per-call invariant check, so the engine already reports the shortest +// failing prefix and minimization has nothing left to shrink. +#[forgetest_init] +fn echidna_flags_parity(prj: _, cmd: _) { + skip_unless_z3!("echidna_flags_parity"); + + prj.add_test( + "EchidnaFlagsParity.t.sol", + r#" +import "forge-std/Test.sol"; + +contract EchidnaFlagsTarget { + bool public flag0 = true; + bool public flag1 = true; + + function set0(int256 val) public { + if (val % 100 == 0) flag0 = false; + } + + function set1(int256 val) public { + if (val % 10 == 0 && !flag0) flag1 = false; + } +} + +contract EchidnaFlagsParity is Test { + EchidnaFlagsTarget target; + + function setUp() public { + target = new EchidnaFlagsTarget(); + targetContract(address(target)); + } + + /// forge-config: default.symbolic.invariant_depth = 2 + function invariant_flag1_holds() public view { + assertTrue(target.flag1()); + } +} +"#, + ); + + let output = cmd + .args([ + "test", + "--symbolic", + "--json", + "--match-test", + "invariant_flag1_holds", + "--fuzz-seed", + "1", + ]) + .assert_failure() + .get_output() + .stdout + .clone(); + + let result = json_test_result(&output, "invariant_flag1_holds()"); + let failures = result["invariant_failures"].as_array().expect("invariant failures"); + let failure = failures.first().expect("invariant failure"); + let minimization = &failure["minimization"]; + assert_eq!(failure["artifact"], minimization["minimized"]); + // With the default check interval the engine reports the shortest failing prefix, so the + // two-call witness is already minimal and minimization must leave it alone. + assert_eq!(minimization["original_sequence_len"], 2); + assert_eq!(minimization["minimized_sequence_len"], 2); + assert_eq!(minimization["original_calldata_bytes"], minimization["minimized_calldata_bytes"]); + + let original = read_artifact_ref(&minimization["original"]); + let minimized = read_artifact_ref(&minimization["minimized"]); + assert_eq!(original["replay"]["status"], "confirmed"); + assert_eq!(minimized["replay"]["status"], "confirmed"); + assert_eq!(minimized["calls"].as_array().unwrap().len(), 2); + + let calls = minimized["calls"].as_array().unwrap(); + assert_eq!(calls[0]["function_name"], "set0"); + assert_eq!(calls[0]["args"], "0"); + assert_eq!(calls[1]["function_name"], "set1"); + assert_eq!(calls[1]["args"], "0"); + + let artifact_path = minimization["minimized"]["path"].as_str().unwrap(); + let replay_stdout = cmd + .forge_fuse() + .args(["test", "--replay-symbolic-artifact", artifact_path]) + .assert_failure() + .get_output() + .stdout_lossy(); + assert_relevant_lines( + &replay_stdout, + str![[r#" +[FAIL: +"#]], + ); +} + +// --------------------------------------------------------------------------- +// Echidna basic/revert.sol — stateful revert counterexample shrinking. +// --------------------------------------------------------------------------- +// Source: https://github.com/crytic/echidna/blob/master/tests/solidity/basic/revert.sol +// Echidna's suite asserts this shrinks to one `f(int,address,address)` call. +// The invariant is only checked at the terminal depth so there is a longer +// sequence to shrink. +#[forgetest_init] +fn echidna_revert_magic_args_parity(prj: _, cmd: _) { + skip_unless_z3!("echidna_revert_magic_args_parity"); + + prj.add_test( + "EchidnaRevertParity.t.sol", + r#" +import "forge-std/Test.sol"; + +contract EchidnaRevertTarget { + int256 private state; + + function f(int256 x, address, address z) public { + require(z != address(0)); + state = x; + } + + function echidna_fails_on_revert() public view returns (bool) { + if (state < 0) revert(); + return true; + } +} + +contract EchidnaRevertParity is Test { + EchidnaRevertTarget target; + + function setUp() public { + target = new EchidnaRevertTarget(); + targetContract(address(target)); + } + + /// forge-config: default.symbolic.invariant_depth = 3 + /// forge-config: default.invariant.check_interval = 0 + /// forge-config: default.invariant.runs = 1 + /// forge-config: default.invariant.depth = 20 + /// forge-config: default.invariant.shrink_run_limit = 10000 + function invariant_no_revert() public view { + assertTrue(target.echidna_fails_on_revert()); + } +} +"#, + ); + + let output = cmd + .args([ + "test", + "--symbolic", + "--json", + "--match-test", + "invariant_no_revert", + "--fuzz-seed", + "1", + ]) + .assert_failure() + .get_output() + .stdout + .clone(); + + let result = json_test_result(&output, "invariant_no_revert()"); + let failure = result["invariant_failures"].as_array().unwrap().first().unwrap(); + let minimization = &failure["minimization"]; + assert_eq!(failure["artifact"], minimization["minimized"]); + assert_eq!(minimization["minimized_sequence_len"], 1); + assert!( + minimization["original_sequence_len"].as_u64().unwrap() + > minimization["minimized_sequence_len"].as_u64().unwrap() + ); + assert!(minimization["accepted"].as_u64().unwrap() > 0); + + let minimized = read_artifact_ref(&minimization["minimized"]); + assert_eq!(minimized["replay"]["status"], "confirmed"); + let call = &minimized["calls"][0]; + assert_eq!(call["function_name"], "f"); + let args = call["args"].as_str().unwrap(); + let args: Vec<_> = args.split(',').map(str::trim).collect(); + assert_eq!(args.len(), 3); + assert_eq!(args[0], "-1"); + assert_eq!(args[1], "0x0000000000000000000000000000000000000000"); + assert_ne!(args[2], "0x0000000000000000000000000000000000000000"); + + let replay_stdout = cmd + .forge_fuse() + .args([ + "test", + "--replay-symbolic-artifact", + minimization["minimized"]["path"].as_str().unwrap(), + ]) + .assert_failure() + .get_output() + .stdout_lossy(); + assert_relevant_lines( + &replay_stdout, + str![[r#" +invariant_no_revert() +"#]], + ); +} + +// --------------------------------------------------------------------------- +// Echidna values/darray.sol — dynamic address[] calldata shrinking. +// --------------------------------------------------------------------------- +// Source: https://github.com/crytic/echidna/blob/master/tests/solidity/values/darray.sol +// Echidna shrinks to one address element: `[0x123456]`. +#[forgetest_init] +fn echidna_dynamic_address_array_parity(prj: _, cmd: _) { + skip_unless_z3!("echidna_dynamic_address_array_parity"); + + prj.add_test( + "EchidnaDarrayParity.t.sol", + r#" +contract EchidnaDarrayParity { + address constant TARGET = address(0x123456); + + /// forge-config: default.symbolic.array_lengths = [4] + function checkDarray(address[] memory xs) public pure { + for (uint256 i; i < xs.length; i++) { + assert(xs[i] != TARGET); + } + } +} +"#, + ); + + let output = cmd + .args(["test", "--symbolic", "--json", "--match-test", "checkDarray"]) + .assert_failure() + .get_output() + .stdout + .clone(); + + let result = json_test_result(&output, "checkDarray(address[])"); + let symbolic = &result["symbolic"]; + assert_eq!(symbolic["status"], "fail_counterexample"); + assert_eq!(symbolic["minimization"]["minimized"], symbolic["artifact"]); + assert!(symbolic["minimization"]["accepted"].as_u64().unwrap() > 0); + + let minimized = read_artifact_ref(&symbolic["artifact"]); + assert_eq!(minimized["replay"]["status"], "confirmed"); + assert_eq!(minimized["calls"][0]["args"], "[0x0000000000000000000000000000000000123456]"); + + let replay_stdout = cmd + .forge_fuse() + .args([ + "test", + "--replay-symbolic-artifact", + symbolic["artifact"]["path"].as_str().unwrap(), + ]) + .assert_failure() + .get_output() + .stdout_lossy(); + assert_relevant_lines( + &replay_stdout, + str![[r#" +args=[[0x0000000000000000000000000000000000123456]] +"#]], + ); +} + +// --------------------------------------------------------------------------- +// Echidna basic/darray-mutation.sol — dynamic bytes calldata shrinking. +// --------------------------------------------------------------------------- +// Source: https://github.com/crytic/echidna/blob/master/tests/solidity/basic/darray-mutation.sol +// Echidna shrinks to the required `abc` prefix with a length still greater than +// 16; Foundry also zeroes irrelevant suffix bytes. +#[forgetest_init] +fn echidna_bytes_mutation_parity(prj: _, cmd: _) { + skip_unless_z3!("echidna_bytes_mutation_parity"); + + prj.add_test( + "EchidnaBytesMutationParity.t.sol", + r#" +contract EchidnaBytesMutationParity { + /// forge-config: default.symbolic.array_lengths = [18] + function checkMutated(bytes memory bs) public pure { + assert(bs.length <= 16 || bs[0] != 0x61 || bs[1] != 0x62 || bs[2] != 0x63); + } +} +"#, + ); + + let output = cmd + .args(["test", "--symbolic", "--json", "--match-test", "checkMutated"]) + .assert_failure() + .get_output() + .stdout + .clone(); + + let result = json_test_result(&output, "checkMutated(bytes)"); + let symbolic = &result["symbolic"]; + assert_eq!(symbolic["status"], "fail_counterexample"); + assert_eq!(symbolic["minimization"]["minimized"], symbolic["artifact"]); + assert!(symbolic["minimization"]["accepted"].as_u64().unwrap() > 0); + + let minimized = read_artifact_ref(&symbolic["artifact"]); + assert_eq!(minimized["replay"]["status"], "confirmed"); + assert_eq!(minimized["calls"][0]["args"], "0x6162630000000000000000000000000000"); + + let replay_stdout = cmd + .forge_fuse() + .args([ + "test", + "--replay-symbolic-artifact", + symbolic["artifact"]["path"].as_str().unwrap(), + ]) + .assert_failure() + .get_output() + .stdout_lossy(); + assert_relevant_lines( + &replay_stdout, + str![[r#" +args=[0x6162630000000000000000000000000000] +"#]], + ); +} + +// --------------------------------------------------------------------------- +// Echidna values/payable.sol — transaction value minimization. +// --------------------------------------------------------------------------- +// Source: https://github.com/crytic/echidna/blob/master/tests/solidity/values/payable.sol +// Echidna shrinks the value-bearing call to value 129. +#[forgetest_init] +fn echidna_payable_value_parity(prj: _, cmd: _) { + skip_unless_z3!("echidna_payable_value_parity"); + + prj.add_test( + "EchidnaPayableParity.t.sol", + r#" +import "forge-std/Test.sol"; + +contract EchidnaPayableTarget { + bool public touched; + + function payable_function() public payable { + if (msg.value >= 129) touched = true; + } + + function another_function(uint256) public {} +} + +contract EchidnaPayableParity is Test { + EchidnaPayableTarget target; + address sender = address(0xBEEF); + + function setUp() public { + target = new EchidnaPayableTarget(); + vm.deal(sender, 1000 ether); + targetSender(sender); + targetContract(address(target)); + } + + /// forge-config: default.invariant.runs = 1 + /// forge-config: default.invariant.depth = 20 + /// forge-config: default.invariant.shrink_run_limit = 10000 + function invariant_payable_zero() public view { + assertFalse(target.touched()); + } +} +"#, + ); + + let output = cmd + .args([ + "test", + "--symbolic", + "--json", + "--match-test", + "invariant_payable_zero", + "--fuzz-seed", + "1", + ]) + .assert_failure() + .get_output() + .stdout + .clone(); + + let result = json_test_result(&output, "invariant_payable_zero()"); + let failure = result["invariant_failures"].as_array().unwrap().first().unwrap(); + let minimization = &failure["minimization"]; + assert_eq!(failure["artifact"], minimization["minimized"]); + assert_eq!(minimization["minimized_sequence_len"], 1); + assert!( + minimization["original_sequence_len"].as_u64().unwrap() + > minimization["minimized_sequence_len"].as_u64().unwrap() + ); + assert!(minimization["accepted"].as_u64().unwrap() > 0); + + let minimized = read_artifact_ref(&minimization["minimized"]); + assert_eq!(minimized["replay"]["status"], "confirmed"); + let call = &minimized["calls"][0]; + assert_eq!(call["function_name"], "payable_function"); + assert_eq!(call["value"].as_str().unwrap_or_default(), "0x81"); + + let replay_stdout = cmd + .forge_fuse() + .args([ + "test", + "--replay-symbolic-artifact", + minimization["minimized"]["path"].as_str().unwrap(), + ]) + .assert_failure() + .get_output() + .stdout_lossy(); + assert_relevant_lines( + &replay_stdout, + str![[r#" +invariant_payable_zero() +"#]], + ); +} + +// --------------------------------------------------------------------------- +// Echidna overflow mode — Solidity 0.8 over/underflow detection. +// --------------------------------------------------------------------------- +// Mirrors Echidna's `--test-mode overflow` on a buggy add. +#[forgetest_init] +fn echidna_overflow_unchecked_add(prj: _, cmd: _) { + skip_unless_z3!("echidna_overflow_unchecked_add"); + + prj.add_test( + "EchidnaOverflowParity.t.sol", + r#" +contract EchidnaOverflowParity { + // Buggy: uses unchecked so overflow silently wraps; assertion catches it. + function checkNoOverflow(uint256 a, uint256 b) public pure { + unchecked { + uint256 sum = a + b; + // This holds only if a + b doesn't overflow. + assert(sum >= a); + } + } +} +"#, + ); + + // Witness values (a, b) are free uint256 — Z3 picks any pair where a+b + // overflows. Redact `calldata=` and `args=[...]` via + // [`assert_symbolic_witness`] so the snapshot captures the shape but not + // the solver's arbitrary choice. + assert_symbolic_witness(cmd.args(["test", "--symbolic", "--match-test", "checkNoOverflow"])) + .failure() + .stdout_eq(str![[r#" +... +Failing tests: +Encountered 1 failing test in test/EchidnaOverflowParity.t.sol:EchidnaOverflowParity +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkNoOverflow(uint256,uint256) ([METRICS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +"#]]); +} diff --git a/crates/forge/tests/cli/test_cmd/symbolic_parity/foundry_issues.rs b/crates/forge/tests/cli/test_cmd/symbolic_parity/foundry_issues.rs new file mode 100644 index 0000000000000..69aa3d0af722a --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/symbolic_parity/foundry_issues.rs @@ -0,0 +1,69 @@ +use super::assert_symbolic_witness; +use crate::skip_unless_z3; +use foundry_test_utils::{forgetest_init, str}; + +// --------------------------------------------------------------------------- +// foundry-rs/foundry#9782 — omitted struct-hash field. +// --------------------------------------------------------------------------- +// `toPoolId` omits `extension`. Breaking the property requires the four hashed +// fields to match while `extension` differs. Random fuzzing missed this narrow +// case, while symbolic execution solves it without searching for a hash collision. +#[forgetest_init] +fn issue_9782_struct_hash_field_omission(prj: _, cmd: _) { + skip_unless_z3!("issue_9782_struct_hash_field_omission"); + + prj.add_source( + "PoolId.sol", + r#" +struct PoolKey { + address token0; + address token1; + uint128 fee; + uint32 tickSpacing; + address extension; +} + +function toPoolId(PoolKey memory key) pure returns (bytes32 result) { + // Intentionally broken: omits `extension` from the hash. + result = keccak256(abi.encode(key.token0, key.token1, key.fee, key.tickSpacing)); +} +"#, + ); + + prj.add_test( + "PoolId.t.sol", + r#" +import "forge-std/Test.sol"; +import {PoolKey, toPoolId} from "../src/PoolId.sol"; + +contract PoolIdSymbolicTest is Test { + using {toPoolId} for PoolKey; + + function check_toPoolId_aligns_with_eq(PoolKey memory pk0, PoolKey memory pk1) external pure { + bytes32 pk0Id = pk0.toPoolId(); + bytes32 pk1Id = pk1.toPoolId(); + + assertEq( + pk0.token0 == pk1.token0 && pk0.token1 == pk1.token1 && pk0.fee == pk1.fee + && pk0.tickSpacing == pk1.tickSpacing && pk0.extension == pk1.extension, + pk0Id == pk1Id + ); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args(["test", "--symbolic", "--match-test", "check_toPoolId"])) + .failure() + .stdout_eq(str![[r#" +... +Failing tests: +Encountered 1 failing test in test/PoolId.t.sol:PoolIdSymbolicTest +[FAIL: assertion failed: false != true; counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] check_toPoolId_aligns_with_eq((address,address,uint128,uint32,address),(address,address,uint128,uint32,address)) ([METRICS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +"#]]); +} diff --git a/crates/forge/tests/cli/test_cmd/symbolic_parity/halmos.rs b/crates/forge/tests/cli/test_cmd/symbolic_parity/halmos.rs new file mode 100644 index 0000000000000..8f2b481c340fa --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/symbolic_parity/halmos.rs @@ -0,0 +1,136 @@ +use super::assert_symbolic; +use crate::skip_unless_z3; +use foundry_test_utils::{forgetest_init, str}; + +// --------------------------------------------------------------------------- +// MakerDAO MiniVat — linear smoke version. +// --------------------------------------------------------------------------- +// Stand-in for https://github.com/a16z/halmos/blob/main/examples/invariants/src/MiniVat.sol +// that drops `rate`/`Art` and reduces the invariant to `urn == debt`. Every +// mutation moves them by the same scalar so the engine has nothing to do +// beyond confirming two identical `+=`/`-=` produce equal state. Kept to +// exercise the symbolic invariant harness on a trivially preserved property; +// the canonical Fundamental Equation of DAI variant lives below. +#[forgetest_init] +fn minivat_linear_smoke_parity(prj: _, cmd: _) { + skip_unless_z3!("minivat_linear_smoke_parity"); + + prj.add_test( + "MiniVatLinear.t.sol", + r#" +import "forge-std/Test.sol"; + +contract MiniVat { + uint256 public urn; + uint256 public debt; + + function frob(uint8 amt) external { + unchecked { + urn += amt; + debt += amt; + } + } + + function repay(uint8 amt) external { + if (urn < amt) return; + unchecked { + urn -= amt; + debt -= amt; + } + } +} + +contract MiniVatLinear is Test { + MiniVat vat; + + function setUp() public { + vat = new MiniVat(); + targetContract(address(vat)); + } + + /// forge-config: default.symbolic.invariant_depth = 2 + /// forge-config: default.symbolic.width = 2048 + function invariant_debtEqualsUrn() public view { + assertEq(vat.urn(), vat.debt()); + } +} +"#, + ); + + assert_symbolic(cmd.args(["test", "--symbolic", "--match-test", "invariant_debtEqualsUrn"])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/MiniVatLinear.t.sol:MiniVatLinear +[PASS] invariant_debtEqualsUrn() ([METRICS]) +... +"#]]); +} + +// --------------------------------------------------------------------------- +// MakerDAO MiniVat — Fundamental Equation of DAI. +// --------------------------------------------------------------------------- +// Faithful port of https://github.com/a16z/halmos/blob/main/examples/invariants/test/MiniVat.t.sol +// proving `debt == Art * rate` across arbitrary interleavings of `init` / +// `frob` / `fold`. This is a nonlinear multi-variable algebraic identity: +// `frob(dart)` adds `dart * rate` to debt, `fold(delta)` mutates `rate` and +// adds `Art * delta` to debt. +// +// Currently `#[ignore]`: this is a nonlinear invariant proof over symbolic +// products. The hard-arithmetic fallback can find concrete counterexamples, +// but it cannot certify that every path is safe. Re-enable when nonlinear +// invariant proofs are supported. +#[forgetest_init] +#[ignore = "engine gap: nonlinear bv-mul (Art * rate, symbolic*symbolic) returns solver unknown"] +fn minivat_fundamental_equation_parity(prj: _, cmd: _) { + skip_unless_z3!("minivat_fundamental_equation_parity"); + + prj.add_test( + "MiniVatFundamental.t.sol", + r#" +import "forge-std/Test.sol"; + +contract MiniVat { + uint256 public Art; + uint256 public rate; + uint256 public debt; + + function init() public { + require(rate == 0, "rate not zero"); + rate = 10**27; + } + + function frob(uint128 dart) public { + unchecked { + Art += dart; + debt += uint256(dart) * rate; + } + } + + function fold(uint128 delta) public { + unchecked { + rate += delta; + debt += Art * uint256(delta); + } + } +} + +contract MiniVatFundamental is Test { + MiniVat vat; + + function setUp() public { + vat = new MiniVat(); + targetContract(address(vat)); + } + + /// forge-config: default.symbolic.invariant_depth = 2 + /// forge-config: default.symbolic.width = 2048 + function invariant_dai() public view { + assertEq(vat.debt(), vat.Art() * vat.rate(), "Fundamental Equation of DAI"); + } +} +"#, + ); + + assert_symbolic(cmd.args(["test", "--symbolic", "--match-test", "invariant_dai"])).failure(); +} diff --git a/crates/forge/tests/cli/test_cmd/symbolic_parity/hevm.rs b/crates/forge/tests/cli/test_cmd/symbolic_parity/hevm.rs new file mode 100644 index 0000000000000..7dc44eb565c7d --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/symbolic_parity/hevm.rs @@ -0,0 +1,37 @@ +use super::assert_symbolic_witness; +use crate::skip_unless_z3; +use foundry_test_utils::{forgetest_init, str}; + +// hevm-style symbolic calldata constraint: a magic calldata value should be +// solved directly rather than found through random fuzzing. +#[forgetest_init] +fn hevm_symbolic_calldata_constraint(prj: _, cmd: _) { + skip_unless_z3!("hevm_symbolic_calldata_constraint"); + + prj.add_test( + "HevmCalldataConstraint.t.sol", + r#" +contract HevmCalldataConstraint { + function checkMagic(bytes4 selector, uint256 x) public pure { + require(selector == 0xdeadbeef); + require(x == 0x1234); + assert(false); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args(["test", "--symbolic", "--match-test", "checkMagic"])) + .failure() + .stdout_eq(str![[r#" +... +Failing tests: +Encountered 1 failing test in test/HevmCalldataConstraint.t.sol:HevmCalldataConstraint +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkMagic(bytes4,uint256) ([METRICS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +"#]]); +} diff --git a/crates/forge/tests/cli/test_cmd/symbolic_parity/ityfuzz.rs b/crates/forge/tests/cli/test_cmd/symbolic_parity/ityfuzz.rs new file mode 100644 index 0000000000000..74c4cd1092d30 --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/symbolic_parity/ityfuzz.rs @@ -0,0 +1,75 @@ +use super::assert_symbolic; +use crate::skip_unless_z3; +use foundry_test_utils::{forgetest_init, str}; + +// --------------------------------------------------------------------------- +// ItyFuzz paper "SimpleState" — narrow-state walk fuzzers struggle with. +// --------------------------------------------------------------------------- +// Already partially covered in symbolic_conformance.rs as a passing case. +// Here we add the version with the BUG (final phase reachable via specific +// sequence of magic numbers) and assert the symbolic engine catches it. +#[forgetest_init] +fn ityfuzz_simple_state_buggy(prj: _, cmd: _) { + skip_unless_z3!("ityfuzz_simple_state_buggy"); + + prj.add_test( + "IfFuzzSimpleStateBuggy.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SimpleStateMachine { + uint256 public phase; + + function step1(uint256 v) external { + if (v == 1337) phase = 1; + } + + function step2(uint256 v) external { + if (phase == 1 && v == 7331) phase = 2; + } + + function step3(uint256 v) external { + if (phase == 2 && v == 12345) phase = 3; + } +} + +contract IfFuzzSimpleStateBuggy is Test { + SimpleStateMachine sm; + + function setUp() public { + sm = new SimpleStateMachine(); + targetContract(address(sm)); + } + + /// forge-config: default.symbolic.invariant_depth = 3 + function invariant_phaseUnderThree() public view { + assertLt(sm.phase(), 3); + } +} +"#, + ); + + // Magic numbers (1337, 7331, 12345) are forced by the branch structure. + // Symbolic senders and target addresses are masked via the [SENDER] + // redaction in `assert_symbolic`. + assert_symbolic(cmd.args(["test", "--symbolic", "--match-test", "invariant_phaseUnderThree"])) + .failure() + .stdout_eq(str![[r#" +... +Failing tests: +Encountered 1 failing test in test/IfFuzzSimpleStateBuggy.t.sol:IfFuzzSimpleStateBuggy +[FAIL: assertion failed: 3 >= 3] + [Sequence] (original: 3, shrunk: 3) + [SENDER] [SENDER] calldata=step1(uint256) args=[1337] + [SENDER] [SENDER] calldata=step2(uint256) args=[7331] + [SENDER] [SENDER] calldata=step3(uint256) args=[12345 [1.234e4]] + invariant_phaseUnderThree() ([METRICS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]]); +} diff --git a/crates/forge/tests/cli/test_cmd/symbolic_parity/kevm.rs b/crates/forge/tests/cli/test_cmd/symbolic_parity/kevm.rs new file mode 100644 index 0000000000000..1f71f348d5669 --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/symbolic_parity/kevm.rs @@ -0,0 +1,44 @@ +use super::assert_symbolic; +use crate::skip_unless_z3; +use foundry_test_utils::{forgetest_init, str}; + +// KEVM/Kontrol-style opcode semantic smoke test for CREATE2 + RETURNDATACOPY. +#[forgetest_init] +fn kevm_create2_returndatacopy_semantics(prj: _, cmd: _) { + skip_unless_z3!("kevm_create2_returndatacopy_semantics"); + + prj.add_test( + "KevmOpcodeSemantics.t.sol", + r#" +contract Created { + function ping() external pure returns (uint256) { + return 0x42; + } +} + +contract KevmOpcodeSemantics { + function checkCreate2AndReturndata(bytes32 salt) public { + address deployed; + bytes memory initcode = type(Created).creationCode; + assembly { + deployed := create2(0, add(initcode, 0x20), mload(initcode), salt) + } + require(deployed != address(0)); + + (bool ok, bytes memory data) = deployed.staticcall(abi.encodeWithSignature("ping()")); + require(ok); + assert(abi.decode(data, (uint256)) == 0x42); + } +} +"#, + ); + + assert_symbolic(cmd.args(["test", "--symbolic", "--match-test", "checkCreate2AndReturndata"])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/KevmOpcodeSemantics.t.sol:KevmOpcodeSemantics +[PASS] checkCreate2AndReturndata(bytes32) ([METRICS]) +... +"#]]); +} diff --git a/crates/forge/tests/cli/test_cmd/symbolic_parity/manticore.rs b/crates/forge/tests/cli/test_cmd/symbolic_parity/manticore.rs new file mode 100644 index 0000000000000..cf999d8d2234e --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/symbolic_parity/manticore.rs @@ -0,0 +1,63 @@ +use super::assert_symbolic_witness; +use crate::skip_unless_z3; +use foundry_test_utils::{forgetest_init, str}; + +// Manticore-style multi-transaction state exploration: the bug requires a +// setup transaction before the final assertion can fail. +#[forgetest_init] +fn manticore_multitx_state_machine(prj: _, cmd: _) { + skip_unless_z3!("manticore_multitx_state_machine"); + + prj.add_test( + "ManticoreMultiTx.t.sol", + r#" +import "forge-std/Test.sol"; + +contract ArmTarget { + bool public armed; + + function arm(uint256 key) public { + if (key == 0xfeed) armed = true; + } +} + +contract ManticoreMultiTx is Test { + ArmTarget target; + + function setUp() public { + target = new ArmTarget(); + targetContract(address(target)); + } + + /// forge-config: default.symbolic.invariant_depth = 2 + function invariant_neverArmed() public view { + assert(!target.armed()); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "invariant_neverArmed", + ])) + .failure() + .stdout_eq(str![[r#" +... +Failing tests: +Encountered 1 failing test in test/ManticoreMultiTx.t.sol:ManticoreMultiTx +[FAIL: panic: assertion failed (0x01)] + [Sequence] (original: 1, shrunk: 1) + [SENDER] [SENDER] calldata=arm(uint256) [ARGS] + invariant_neverArmed() ([METRICS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +[SEED] (use `--fuzz-seed` to reproduce) + +"#]]); +} diff --git a/crates/forge/tests/cli/test_cmd/symbolic_parity/medusa.rs b/crates/forge/tests/cli/test_cmd/symbolic_parity/medusa.rs new file mode 100644 index 0000000000000..134d44d797aa5 --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/symbolic_parity/medusa.rs @@ -0,0 +1,42 @@ +use super::assert_symbolic; +use crate::skip_unless_z3; +use foundry_test_utils::{forgetest_init, str}; + +// --------------------------------------------------------------------------- +// Medusa-style assertion: magic-constant trap on a single value. +// --------------------------------------------------------------------------- +// Source equivalent: crytic/medusa/tests/contracts/assertions/* — assert that +// a specific symbolic input does NOT hit a particular branch. The symbolic +// engine should find the magic value directly via Z3. +#[forgetest_init] +fn medusa_assertion_magic_constant(prj: _, cmd: _) { + skip_unless_z3!("medusa_assertion_magic_constant"); + + prj.add_test( + "MedusaAssertionParity.t.sol", + r#" +contract MedusaAssertionParity { + function checkNoMagic(uint256 x) public pure { + // The magic constant is deep enough that random fuzzing struggles + // to hit it within a CI budget, but the SMT solver finds it instantly. + assert(x != 0xDEADBEEFCAFEBABE); + } +} +"#, + ); + + // Magic constant is uniquely determined → snapshot the full output. + assert_symbolic(cmd.args(["test", "--symbolic", "--match-test", "checkNoMagic"])) + .failure() + .stdout_eq(str![[r#" +... +Failing tests: +Encountered 1 failing test in test/MedusaAssertionParity.t.sol:MedusaAssertionParity +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] calldata=0xda659cbe000000000000000000000000000000000000000000000000deadbeefcafebabe args=[16045690984503098046 [1.604e19]]] checkNoMagic(uint256) ([METRICS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +"#]]); +} diff --git a/crates/forge/tests/cli/test_cmd/symbolic_parity/mod.rs b/crates/forge/tests/cli/test_cmd/symbolic_parity/mod.rs new file mode 100644 index 0000000000000..006d95f00d79f --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/symbolic_parity/mod.rs @@ -0,0 +1,28 @@ +//! Symbolic parity tests against the standard fuzzer corpora. +//! +//! Each submodule mirrors one upstream corpus (Echidna, Medusa, Halmos, hevm, +//! KEVM, Manticore, ItyFuzz, crytic/properties, devdacian, OpenZeppelin, +//! Scribble, SWC). Cases are tiny, bounded reproductions so they can run in +//! CI as unit tests against the symbolic engine. The goal is to verify the +//! engine finds the same counterexamples (or proves the property) the +//! corresponding fuzzers do on these benchmarks. Foundry issue regressions +//! live in `foundry_issues`. + +pub use crate::test_cmd::symbolic_helpers::{ + assert_relevant_lines, assert_symbolic, assert_symbolic_witness, json_test_result, + read_artifact_ref, +}; + +mod crytic; +mod devdacian; +mod echidna; +mod foundry_issues; +mod halmos; +mod hevm; +mod ityfuzz; +mod kevm; +mod manticore; +mod medusa; +mod openzeppelin; +mod scribble; +mod swc; diff --git a/crates/forge/tests/cli/test_cmd/symbolic_parity/openzeppelin.rs b/crates/forge/tests/cli/test_cmd/symbolic_parity/openzeppelin.rs new file mode 100644 index 0000000000000..bea47af24d0c0 --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/symbolic_parity/openzeppelin.rs @@ -0,0 +1,270 @@ +use super::{assert_symbolic, assert_symbolic_witness}; +use crate::skip_unless_z3; +use foundry_test_utils::{forgetest_init, str}; + +// --------------------------------------------------------------------------- +// ERC4626 inflation / first-depositor donation attack. +// --------------------------------------------------------------------------- +// Classic DeFi bug: vault without virtual offset lets attacker donate raw +// assets after depositing 1 wei, so the next depositor's shares round to 0. +#[forgetest_init] +fn erc4626_inflation_attack(prj: _, cmd: _) { + skip_unless_z3!("erc4626_inflation_attack"); + + prj.add_test( + "Erc4626Inflation.t.sol", + r#" +import "forge-std/Test.sol"; + +contract BuggyVault { + uint256 public totalAssets; + uint256 public totalShares; + mapping(address => uint256) public sharesOf; + + function deposit(uint256 assets) external returns (uint256 shares) { + if (totalShares == 0) { + shares = assets; + } else { + // BUG: no virtual-offset, no rounding-up — shares can round to 0. + shares = (assets * totalShares) / totalAssets; + } + sharesOf[msg.sender] += shares; + totalShares += shares; + totalAssets += assets; + } + + function donate(uint256 assets) external { + totalAssets += assets; + } +} + +contract Erc4626Inflation is Test { + function checkDepositReturnsShares(uint64 first, uint128 donation, uint64 second) public { + vm.assume(first > 0); + vm.assume(second > 0); + BuggyVault v = new BuggyVault(); + v.deposit(first); + v.donate(donation); + uint256 shares = v.deposit(second); + // Property: any non-zero deposit MUST return non-zero shares. + assert(shares > 0); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkDepositReturnsShares", + ])) + .failure() + .stdout_eq(str![[r#" +... +Failing tests: +Encountered 1 failing test in test/Erc4626Inflation.t.sol:Erc4626Inflation +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkDepositReturnsShares(uint64,uint128,uint64) ([METRICS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +"#]]); +} + +// --------------------------------------------------------------------------- +// ERC20 approve overwrite race — classic non-atomic approve footgun. +// --------------------------------------------------------------------------- +// The canonical front-run sequence is encoded literally in the test body: +// owner `approve(N1)` → spender `transferFrom(N1)` → owner `approve(N2)` → +// spender `transferFrom(N2)`. The engine does NOT discover the interleaving +// itself; it solves for symbolic `N1`, `N2` over this fixed call sequence and +// witnesses the property violation `balanceOf(spender) > max(N1, N2)`. +#[forgetest_init] +fn erc20_approve_race(prj: _, cmd: _) { + skip_unless_z3!("erc20_approve_race"); + + prj.add_test( + "Erc20ApproveRace.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SimpleToken { + mapping(address => uint256) public balanceOf; + mapping(address => mapping(address => uint256)) public allowance; + + constructor(address owner, uint256 supply) { + balanceOf[owner] = supply; + } + + function approve(address spender, uint256 amount) external { + allowance[msg.sender][spender] = amount; + } + + function transferFrom(address from, address to, uint256 amount) external { + require(allowance[from][msg.sender] >= amount); + require(balanceOf[from] >= amount); + allowance[from][msg.sender] -= amount; + balanceOf[from] -= amount; + balanceOf[to] += amount; + } +} + +contract Erc20ApproveRace is Test { + function checkApproveRace(uint64 n1, uint64 n2) public { + vm.assume(n1 > 0); + vm.assume(n2 > 0); + address owner = address(0xA11CE); + address spender = address(0xB0B); + uint256 supply = uint256(n1) + uint256(n2); + SimpleToken t = new SimpleToken(owner, supply); + + // Owner: approve N1, then later changes to N2. + // Spender front-runs the second approve and pulls N1, then pulls N2. + vm.prank(owner); + t.approve(spender, n1); + + vm.prank(spender); + t.transferFrom(owner, spender, n1); + + vm.prank(owner); + t.approve(spender, n2); + + vm.prank(spender); + t.transferFrom(owner, spender, n2); + + // Property: spender can never pull more than max(N1, N2) in total. + uint256 maxIntended = n1 > n2 ? n1 : n2; + assert(t.balanceOf(spender) <= maxIntended); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args(["test", "--symbolic", "--match-test", "checkApproveRace"])) + .failure() + .stdout_eq(str![[r#" +... +Failing tests: +Encountered 1 failing test in test/Erc20ApproveRace.t.sol:Erc20ApproveRace +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkApproveRace(uint64,uint64) ([METRICS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +"#]]); +} + +// --------------------------------------------------------------------------- +// ERC721 ownership uniqueness — bounded stateful proof. +// --------------------------------------------------------------------------- +// Two-token minimal NFT: ownership is unique per id and never falls to the +// zero address after mint. Symbolic invariant should PROVE within depth 2. +#[forgetest_init] +fn erc721_unique_ownership_passes(prj: _, cmd: _) { + skip_unless_z3!("erc721_unique_ownership_passes"); + + prj.add_test( + "Erc721Ownership.t.sol", + r#" +import "forge-std/Test.sol"; + +contract MiniNft { + mapping(uint256 => address) public ownerOf; + address constant A = address(0xA); + address constant B = address(0xB); + + constructor() { + ownerOf[1] = A; + ownerOf[2] = B; + } + + function transferFrom(uint256 id, address to) external { + require(id == 1 || id == 2); + require(to != address(0)); + require(msg.sender == ownerOf[id]); + ownerOf[id] = to; + } +} + +contract Erc721Ownership is Test { + MiniNft nft; + + function setUp() public { + nft = new MiniNft(); + targetContract(address(nft)); + } + + /// forge-config: default.symbolic.invariant_depth = 2 + function invariant_ownerNonZero() public view { + assertTrue(nft.ownerOf(1) != address(0)); + assertTrue(nft.ownerOf(2) != address(0)); + } +} +"#, + ); + + assert_symbolic(cmd.args(["test", "--symbolic", "--match-test", "invariant_ownerNonZero"])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/Erc721Ownership.t.sol:Erc721Ownership +[PASS] invariant_ownerNonZero() ([METRICS]) +... +"#]]); +} + +// --------------------------------------------------------------------------- +// ERC4626 deposit → withdraw round-trip — single user, no fee. +// --------------------------------------------------------------------------- +// Property: a user who deposits and then withdraws should get back exactly +// what they put in. Engine should PROVE this for an honest 1:1 vault. +#[forgetest_init] +fn erc4626_roundtrip_passes(prj: _, cmd: _) { + skip_unless_z3!("erc4626_roundtrip_passes"); + + prj.add_test( + "Erc4626Roundtrip.t.sol", + r#" +contract HonestVault { + // 1:1 vault — no nonlinear share<->asset math so Z3 can prove the + // round-trip without falling into nonlinear bv-mul/div. Realistic vaults + // require nonlinear reasoning that Z3 currently times out on (see the + // mulDiv note above); that variant belongs in a nightly suite. + mapping(address => uint256) public sharesOf; + + function deposit(uint256 assets) external returns (uint256 shares) { + shares = assets; + sharesOf[msg.sender] += shares; + } + + function withdraw(uint256 shares) external returns (uint256 assets) { + require(sharesOf[msg.sender] >= shares); + assets = shares; + sharesOf[msg.sender] -= shares; + } +} + +contract Erc4626Roundtrip { + function checkSingleUserRoundtrip(uint64 amount) public { + if (amount == 0) return; + HonestVault v = new HonestVault(); + uint256 shares = v.deposit(amount); + uint256 returned = v.withdraw(shares); + assert(returned == amount); + } +} +"#, + ); + + assert_symbolic(cmd.args(["test", "--symbolic", "--match-test", "checkSingleUserRoundtrip"])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/Erc4626Roundtrip.t.sol:Erc4626Roundtrip +[PASS] checkSingleUserRoundtrip(uint64) ([METRICS]) +... +"#]]); +} diff --git a/crates/forge/tests/cli/test_cmd/symbolic_parity/scribble.rs b/crates/forge/tests/cli/test_cmd/symbolic_parity/scribble.rs new file mode 100644 index 0000000000000..bd5e74985af40 --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/symbolic_parity/scribble.rs @@ -0,0 +1,50 @@ +use super::assert_symbolic_witness; +use crate::skip_unless_z3; +use foundry_test_utils::{forgetest_init, str}; + +// Scribble/Harvey-style instrumented property: the annotation is represented +// as an inserted assert, which is what the engine ultimately has to prove. +#[forgetest_init] +fn scribble_instrumented_erc20_supply_property(prj: _, cmd: _) { + skip_unless_z3!("scribble_instrumented_erc20_supply_property"); + + prj.add_test( + "ScribbleInstrumentedSupply.t.sol", + r#" +contract ScribbleInstrumentedSupply { + mapping(address => uint256) balanceOf; + uint256 totalSupply; + + function mint(address to, uint256 amount) public { + require(to != address(0)); + balanceOf[to] += amount; + totalSupply += amount + 1; + } + + function checkSupplyAnnotation(address to, uint8 amount) public { + mint(to, amount); + assert(totalSupply == balanceOf[to]); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkSupplyAnnotation", + ])) + .failure() + .stdout_eq(str![[r#" +... +Failing tests: +Encountered 1 failing test in test/ScribbleInstrumentedSupply.t.sol:ScribbleInstrumentedSupply +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkSupplyAnnotation(address,uint8) ([METRICS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +"#]]); +} diff --git a/crates/forge/tests/cli/test_cmd/symbolic_parity/swc.rs b/crates/forge/tests/cli/test_cmd/symbolic_parity/swc.rs new file mode 100644 index 0000000000000..8deb2dbba56f2 --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/symbolic_parity/swc.rs @@ -0,0 +1,43 @@ +use super::assert_symbolic_witness; +use crate::skip_unless_z3; +use foundry_test_utils::{forgetest_init, str}; + +// SWC-104 unchecked low-level call: failure is reachable when the callee +// returns false, but the caller still marks the operation as complete. +#[forgetest_init] +fn swc_unchecked_low_level_call(prj: _, cmd: _) { + skip_unless_z3!("swc_unchecked_low_level_call"); + + prj.add_test( + "SwcUncheckedCall.t.sol", + r#" +contract SwcUncheckedCallTarget { + bool ok; + + function run(address target) public { + target.call(abi.encodeWithSignature("missing()")); + ok = true; + } + + function checkUncheckedCall(address target) public { + run(target); + assert(!ok); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args(["test", "--symbolic", "--match-test", "checkUncheckedCall"])) + .failure() + .stdout_eq(str![[r#" +... +Failing tests: +Encountered 1 failing test in test/SwcUncheckedCall.t.sol:SwcUncheckedCallTarget +[FAIL: incomplete symbolic execution (Stuck): unsupported symbolic execution feature: symbolic CALL target] checkUncheckedCall(address) ([METRICS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +"#]]); +} diff --git a/crates/forge/tests/cli/test_cmd/symbolic_precompiles.rs b/crates/forge/tests/cli/test_cmd/symbolic_precompiles.rs new file mode 100644 index 0000000000000..254ef844e8241 --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/symbolic_precompiles.rs @@ -0,0 +1,870 @@ +use super::symbolic_helpers::{assert_symbolic, assert_symbolic_witness, z3_available}; +use foundry_common::sh_eprintln; +use foundry_test_utils::{forgetest_init, str, util::OutputExt}; + +#[forgetest_init] +fn symbolic_precompiles_execute_concrete_inputs(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_precompiles_execute_concrete_inputs because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicPrecompiles.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicPrecompiles is Test { + function checkHashPrecompiles(uint256) public { + assertEq( + sha256(bytes("abc")), + 0xba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad + ); + assertEq(ripemd160(bytes("abc")), bytes20(hex"8eb208f7e05d987a9b044a8e98c6b087f15a0bfc")); + } + + function checkIdentityPrecompile(uint256) public { + (bool ok, bytes memory out) = address(4).staticcall(hex"0102030405"); + assert(ok); + assertEq(out, hex"0102030405"); + } + + function checkModexpPrecompile(uint256) public { + bytes memory input = abi.encodePacked(uint256(1), uint256(1), uint256(1), bytes1(0x02), bytes1(0x05), bytes1(0x0d)); + (bool ok, bytes memory out) = address(5).staticcall(input); + assert(ok); + assertEq(out, hex"06"); + } + + function checkBn254Precompiles(uint256) public { + bytes memory emptyPointPair = new bytes(128); + (bool okAdd, bytes memory addOut) = address(6).staticcall(emptyPointPair); + assert(okAdd); + assertEq(addOut.length, 64); + assertEq(keccak256(addOut), keccak256(new bytes(64))); + + bytes memory emptyMul = new bytes(96); + (bool okMul, bytes memory mulOut) = address(7).staticcall(emptyMul); + assert(okMul); + assertEq(mulOut.length, 64); + assertEq(keccak256(mulOut), keccak256(new bytes(64))); + + (bool okPair, bytes memory pairOut) = address(8).staticcall(""); + assert(okPair); + assertEq(pairOut, abi.encode(uint256(1))); + } + + function checkBlake2fPrecompile(uint256) public { + bytes memory input = new bytes(213); + (bool ok, bytes memory out) = address(9).staticcall(input); + assert(ok); + assertEq(out.length, 64); + assertEq(keccak256(out), 0x209b39a06473bccc5b67b4f26619611f672409e4480527289d64c51b9a382bb6); + + (bool invalidOk, bytes memory invalidOut) = address(9).staticcall(hex"00"); + assert(!invalidOk); + assertEq(invalidOut.length, 0); + } + + function checkKzgPointEvaluationPrecompile(uint256) public { + bytes memory input = abi.encodePacked( + hex"01e798154708fe7789429634053cbf9f99b619f9f084048927333fce637f549b", + hex"73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000000", + hex"1522a4a7f34e1ea350ae07c29c96c7e79655aa926122e95fe69fcbd932ca49e9", + hex"8f59a8d2a1a625a17f3fea0fe5eb8c896db3764f3185481bc22f91b4aaffcca25f26936857bc3a7c2539ea8ec3a952b7", + hex"a62ad71d14c5719385c0686f1871430475bf3a00f0aa3f7b8dd99a9abc2160744faf0070725e00b60ad9a026a15b1a8c" + ); + + (bool ok, bytes memory out) = address(0x0a).staticcall(input); + assert(ok); + assertEq( + out, + hex"000000000000000000000000000000000000000000000000000000000000100073eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000001" + ); + + bytes memory shortInput = new bytes(191); + (bool shortOk, bytes memory shortOut) = address(0x0a).staticcall(shortInput); + assert(!shortOk); + assertEq(shortOut.length, 0); + + input[0] = 0x02; + (bool mismatchOk, bytes memory mismatchOut) = address(0x0a).staticcall(input); + assert(!mismatchOk); + assertEq(mismatchOut.length, 0); + } + + function checkEcrecoverPrecompile(uint256) public { + bytes32 digest = keccak256("foundry-symbolic-precompile"); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(1, digest); + assertEq(ecrecover(digest, v, r, s), vm.addr(1)); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-contract", + "SymbolicPrecompiles", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 7 tests for test/SymbolicPrecompiles.t.sol:SymbolicPrecompiles +[PASS] checkBlake2fPrecompile(uint256) ([METRICS]) +[PASS] checkBn254Precompiles(uint256) ([METRICS]) +[PASS] checkEcrecoverPrecompile(uint256) ([METRICS]) +[PASS] checkHashPrecompiles(uint256) ([METRICS]) +[PASS] checkIdentityPrecompile(uint256) ([METRICS]) +[PASS] checkKzgPointEvaluationPrecompile(uint256) ([METRICS]) +[PASS] checkModexpPrecompile(uint256) ([METRICS]) +Suite result: ok. 7 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_hash_precompiles_accept_symbolic_input(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_hash_precompiles_accept_symbolic_input because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicPrecompileInput.t.sol", + r#" +contract SymbolicPrecompileInput { + /// forge-config: default.symbolic.array_lengths = [2] + function checkSymbolicHashDeterminism(bytes memory data) public { + bytes32 shaA = sha256(data); + bytes32 shaB = sha256(data); + assert(shaA == shaB); + + bytes20 ripemdA = ripemd160(data); + bytes20 ripemdB = ripemd160(data); + assert(ripemdA == ripemdB); + } + + function checkSymbolicEcrecoverDeterminism( + bytes32 digest, + uint8 v, + bytes32 r, + bytes32 s + ) public { + address first = ecrecover(digest, v, r, s); + address second = ecrecover(digest, v, r, s); + + assert(first == second); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-contract", + "SymbolicPrecompileInput", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 2 tests for test/SymbolicPrecompileInput.t.sol:SymbolicPrecompileInput +[PASS] checkSymbolicEcrecoverDeterminism(bytes32,uint8,bytes32,bytes32) ([METRICS]) +[PASS] checkSymbolicHashDeterminism(bytes) ([METRICS]) +Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic precompile input"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_ecrecover_return_data_conformance(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_ecrecover_return_data_conformance because z3 is not available" + ); + return; + } + + prj.add_test( + "EcrecoverReturnData.t.sol", + r#" +contract EcrecoverReturnData { + function checkInvalidRecoveryId(bytes32 digest, bytes32 r, bytes32 s) public view { + bytes memory input = abi.encode(digest, uint256(29), r, s); + bool ok; + uint256 size; + uint256 output; + assembly { + let dest := mload(0x40) + mstore(dest, 0x1234) + ok := staticcall(gas(), 1, add(input, 0x20), mload(input), dest, 32) + size := returndatasize() + output := mload(dest) + } + assert(ok); + assert(size == 0); + assert(output == 0x1234); + } + + function checkRecoveryOutput(bytes32 digest, uint8 v, bytes32 r, bytes32 s) public view { + (bool ok, bytes memory output) = address(1).staticcall(abi.encode(digest, v, r, s)); + assert(ok); + assert(output.length == 0 || output.length == 32); + if (output.length == 32) { + assert(uint256(bytes32(output)) >> 160 == 0); + } + } + + function testFuzzConformance(bytes32 digest, uint8 v, bytes32 r, bytes32 s) public view { + checkInvalidRecoveryId(digest, r, s); + checkRecoveryOutput(digest, v, r, s); + } +} +"#, + ); + + cmd.args(["test", "--match-test", "testFuzzConformance", "--fuzz-runs", "16"]) + .assert_success() + .stdout_eq(str![[r#" +... +[PASS] testFuzzConformance(bytes32,uint8,bytes32,bytes32) (runs: 16, [..]) +... +"#]]); + assert_symbolic(cmd.forge_fuse().args(["test", "--symbolic", "--match-test", "check"])) + .success() + .stdout_eq(str![[r#" +... +[PASS] checkInvalidRecoveryId(bytes32,bytes32,bytes32) ([METRICS]) +[PASS] checkRecoveryOutput(bytes32,uint8,bytes32,bytes32) ([METRICS]) +... +"#]]); +} + +#[forgetest_init] +fn symbolic_identity_precompile_accepts_symbolic_input(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_identity_precompile_accepts_symbolic_input because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicIdentityPrecompileInput.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicIdentityPrecompileInput is Test { + /// forge-config: default.symbolic.array_lengths = [2] + function checkSymbolicIdentity(bytes memory data) public { + (bool ok, bytes memory out) = address(4).staticcall(data); + assert(ok); + assert(out.length == data.length); + for (uint256 i; i < data.length; ++i) { + assert(out[i] == data[i]); + } + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkSymbolicIdentity", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicIdentityPrecompileInput.t.sol:SymbolicIdentityPrecompileInput +[PASS] checkSymbolicIdentity(bytes) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic precompile input"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_advanced_precompiles_accept_symbolic_payloads(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_advanced_precompiles_accept_symbolic_payloads because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicAdvancedPrecompileInput.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicAdvancedPrecompileInput is Test { + function checkSymbolicModexp(bytes1 base) public { + bytes memory input = abi.encodePacked( + uint256(1), + uint256(1), + uint256(1), + base, + bytes1(0x05), + bytes1(0x0d) + ); + (bool okA, bytes memory outA) = address(5).staticcall(input); + (bool okB, bytes memory outB) = address(5).staticcall(input); + + assert(okA); + assert(okB); + assertEq(outA.length, 1); + assertEq(outA.length, outB.length); + assertEq(keccak256(outA), keccak256(outB)); + } + + function checkSymbolicBlake2f(bytes1 tweak) public { + bytes memory input = new bytes(213); + input[10] = tweak; + + (bool okA, bytes memory outA) = address(9).staticcall(input); + (bool okB, bytes memory outB) = address(9).staticcall(input); + + assert(okA); + assert(okB); + assertEq(outA.length, 64); + assertEq(outA.length, outB.length); + assertEq(keccak256(outA), keccak256(outB)); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-contract", + "SymbolicAdvancedPrecompileInput", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 2 tests for test/SymbolicAdvancedPrecompileInput.t.sol:SymbolicAdvancedPrecompileInput +[PASS] checkSymbolicBlake2f(bytes1) ([METRICS]) +[PASS] checkSymbolicModexp(bytes1) ([METRICS]) +Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic precompile input"), "{stdout}"); + assert!(!stdout.contains("symbolic precompile length header"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_precompiles_accept_symbolic_input_size(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_precompiles_accept_symbolic_input_size because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicPrecompileInputSize.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicPrecompileInputSize is Test { + function checkSymbolicIdentityInputSize(uint256 size) public { + vm.assume(size <= 4); + + bool ok; + uint256 rdsize; + bytes32 word; + assembly { + let inPtr := mload(0x40) + let outPtr := add(inPtr, 0x40) + mstore(inPtr, 0x0102030400000000000000000000000000000000000000000000000000000000) + mstore(outPtr, 0) + ok := staticcall(gas(), 4, inPtr, size, outPtr, 4) + rdsize := returndatasize() + word := mload(outPtr) + } + + bytes32 expected; + if (size > 0) expected |= bytes32(uint256(0x01) << 248); + if (size > 1) expected |= bytes32(uint256(0x02) << 240); + if (size > 2) expected |= bytes32(uint256(0x03) << 232); + if (size > 3) expected |= bytes32(uint256(0x04) << 224); + + assert(ok); + assertEq(rdsize, size); + assertEq(word, expected); + } + + function checkSymbolicShaInputSize(uint256 size) public { + vm.assume(size <= 4); + + bool okA; + bool okB; + bytes32 outA; + bytes32 outB; + assembly { + let inPtr := mload(0x40) + let outPtrA := add(inPtr, 0x40) + let outPtrB := add(inPtr, 0x80) + mstore(inPtr, 0x0102030400000000000000000000000000000000000000000000000000000000) + okA := staticcall(gas(), 2, inPtr, size, outPtrA, 32) + okB := staticcall(gas(), 2, inPtr, size, outPtrB, 32) + outA := mload(outPtrA) + outB := mload(outPtrB) + } + + assert(okA); + assert(okB); + assertEq(outA, outB); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-contract", + "SymbolicPrecompileInputSize", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 2 tests for test/SymbolicPrecompileInputSize.t.sol:SymbolicPrecompileInputSize +[PASS] checkSymbolicIdentityInputSize(uint256) ([METRICS]) +[PASS] checkSymbolicShaInputSize(uint256) ([METRICS]) +Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic precompile CALL input size"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_kzg_precompile_models_symbolic_witnesses(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_kzg_precompile_models_symbolic_witnesses because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicKzgPrecompileInput.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicKzgPrecompileInput is Test { + function checkSymbolicKzgInvalidWitnessReturnsCounterexample(bytes32 z) public { + bytes memory input = abi.encodePacked( + hex"01e798154708fe7789429634053cbf9f99b619f9f084048927333fce637f549b", + z, + hex"1522a4a7f34e1ea350ae07c29c96c7e79655aa926122e95fe69fcbd932ca49e9", + hex"8f59a8d2a1a625a17f3fea0fe5eb8c896db3764f3185481bc22f91b4aaffcca25f26936857bc3a7c2539ea8ec3a952b7", + hex"a62ad71d14c5719385c0686f1871430475bf3a00f0aa3f7b8dd99a9abc2160744faf0070725e00b60ad9a026a15b1a8c" + ); + + (bool ok,) = address(0x0a).staticcall(input); + assert(ok); + } + + function checkSymbolicKzgSuccessWitnessReturnsCounterexample( + bytes32 versionedHash, + bytes32 z, + bytes32 y, + bytes32 commitmentPrefix, + bytes16 commitmentSuffix, + bytes32 proofPrefix, + bytes16 proofSuffix + ) public { + bytes memory input = abi.encodePacked( + versionedHash, + z, + y, + commitmentPrefix, + commitmentSuffix, + proofPrefix, + proofSuffix + ); + + (bool ok,) = address(0x0a).staticcall(input); + if (ok) assert(false); + } + + function checkSymbolicKzgVersionedHashMismatchReturnsCounterexample(bytes31 digestTail) public { + bytes memory input = abi.encodePacked( + bytes1(0x01), + digestTail, + hex"73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000000", + hex"1522a4a7f34e1ea350ae07c29c96c7e79655aa926122e95fe69fcbd932ca49e9", + hex"8f59a8d2a1a625a17f3fea0fe5eb8c896db3764f3185481bc22f91b4aaffcca25f26936857bc3a7c2539ea8ec3a952b7", + hex"a62ad71d14c5719385c0686f1871430475bf3a00f0aa3f7b8dd99a9abc2160744faf0070725e00b60ad9a026a15b1a8c" + ); + + (bool ok,) = address(0x0a).staticcall(input); + assert(ok); + } + + function checkSymbolicKzgProofInvalidWitnessReturnsCounterexample( + bytes32 proofPrefix, + bytes16 proofSuffix + ) public { + bytes memory input = abi.encodePacked( + hex"01e798154708fe7789429634053cbf9f99b619f9f084048927333fce637f549b", + hex"73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000000", + hex"1522a4a7f34e1ea350ae07c29c96c7e79655aa926122e95fe69fcbd932ca49e9", + hex"8f59a8d2a1a625a17f3fea0fe5eb8c896db3764f3185481bc22f91b4aaffcca25f26936857bc3a7c2539ea8ec3a952b7", + proofPrefix, + proofSuffix + ); + + (bool ok,) = address(0x0a).staticcall(input); + assert(ok); + } + + function checkSymbolicKzgCommitmentMismatchReturnsCounterexample( + bytes32 commitmentPrefix, + bytes16 commitmentSuffix + ) public { + bytes memory input = abi.encodePacked( + hex"01e798154708fe7789429634053cbf9f99b619f9f084048927333fce637f549b", + hex"73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000000", + hex"1522a4a7f34e1ea350ae07c29c96c7e79655aa926122e95fe69fcbd932ca49e9", + commitmentPrefix, + commitmentSuffix, + hex"a62ad71d14c5719385c0686f1871430475bf3a00f0aa3f7b8dd99a9abc2160744faf0070725e00b60ad9a026a15b1a8c" + ); + + (bool ok,) = address(0x0a).staticcall(input); + assert(ok); + } + + function checkSymbolicKzgKnownVersionMismatchFails(bytes32 z) public { + bytes memory input = abi.encodePacked( + hex"02e798154708fe7789429634053cbf9f99b619f9f084048927333fce637f549b", + z, + hex"1522a4a7f34e1ea350ae07c29c96c7e79655aa926122e95fe69fcbd932ca49e9", + hex"8f59a8d2a1a625a17f3fea0fe5eb8c896db3764f3185481bc22f91b4aaffcca25f26936857bc3a7c2539ea8ec3a952b7", + hex"a62ad71d14c5719385c0686f1871430475bf3a00f0aa3f7b8dd99a9abc2160744faf0070725e00b60ad9a026a15b1a8c" + ); + + (bool ok, bytes memory out) = address(0x0a).staticcall(input); + assert(!ok); + assertEq(out.length, 0); + } + + function checkTaikoStylePackedBytes1ArrayKzgCallReturnsCounterexample(uint256) public { + bytes1[48] memory commitment; + bytes1[48] memory proof; + bytes memory commitmentBytes = + hex"8f59a8d2a1a625a17f3fea0fe5eb8c896db3764f3185481bc22f91b4aaffcca25f26936857bc3a7c2539ea8ec3a952b7"; + bytes memory proofBytes = + hex"a62ad71d14c5719385c0686f1871430475bf3a00f0aa3f7b8dd99a9abc2160744faf0070725e00b60ad9a026a15b1a8c"; + + for (uint256 i; i < 48; ++i) { + commitment[i] = commitmentBytes[i]; + proof[i] = proofBytes[i]; + } + + (bool ok,) = address(0x0a).staticcall( + abi.encodePacked( + hex"01e798154708fe7789429634053cbf9f99b619f9f084048927333fce637f549b", + uint256(0x73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000000), + uint256(0x1522a4a7f34e1ea350ae07c29c96c7e79655aa926122e95fe69fcbd932ca49e9), + commitment, + proof + ) + ); + + assert(ok); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-contract", + "SymbolicKzgPrecompileInput", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 7 tests for test/SymbolicKzgPrecompileInput.t.sol:SymbolicKzgPrecompileInput +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkSymbolicKzgCommitmentMismatchReturnsCounterexample(bytes32,bytes16) ([METRICS]) +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkSymbolicKzgInvalidWitnessReturnsCounterexample(bytes32) ([METRICS]) +[PASS] checkSymbolicKzgKnownVersionMismatchFails(bytes32) ([METRICS]) +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkSymbolicKzgProofInvalidWitnessReturnsCounterexample(bytes32,bytes16) ([METRICS]) +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkSymbolicKzgSuccessWitnessReturnsCounterexample(bytes32,bytes32,bytes32,bytes32,bytes16,bytes32,bytes16) ([METRICS]) +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkSymbolicKzgVersionedHashMismatchReturnsCounterexample(bytes31) ([METRICS]) +[FAIL: incomplete symbolic execution (Stuck): symbolic depth limit exceeded (10000)] checkTaikoStylePackedBytes1ArrayKzgCallReturnsCounterexample(uint256) ([METRICS]) +Suite result: FAILED. 1 passed; 6 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic KZG point-evaluation precompile"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_kzg_precompile_explores_invalid_symbolic_length(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_kzg_precompile_explores_invalid_symbolic_length because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicKzgInvalidLength.t.sol", + r#" +contract SymbolicKzgInvalidLength { + function checkKzgInvalidLengthIsNotDropped(uint8 size) public { + bytes memory input = abi.encodePacked( + hex"01e798154708fe7789429634053cbf9f99b619f9f084048927333fce637f549b", + hex"73eda753299d7d483339d80809a1d80553bda402fffe5bfeffffffff00000000", + hex"1522a4a7f34e1ea350ae07c29c96c7e79655aa926122e95fe69fcbd932ca49e9", + hex"8f59a8d2a1a625a17f3fea0fe5eb8c896db3764f3185481bc22f91b4aaffcca25f26936857bc3a7c2539ea8ec3a952b7", + hex"a62ad71d14c5719385c0686f1871430475bf3a00f0aa3f7b8dd99a9abc2160744faf0070725e00b60ad9a026a15b1a8c" + ); + + bool ok; + assembly { + ok := staticcall(gas(), 0x0a, add(input, 0x20), size, 0, 0) + } + + if (!ok) assert(size == 0); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-contract", + "SymbolicKzgInvalidLength", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicKzgInvalidLength.t.sol:SymbolicKzgInvalidLength +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkKzgInvalidLengthIsNotDropped(uint8) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_kzg_precompile_inactive_before_cancun(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_kzg_precompile_inactive_before_cancun because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicPreCancunKzg.t.sol", + r#" +contract SymbolicPreCancunKzg { + function checkAddress0aIsEmptyAccountBeforeCancun(uint256) public { + bytes memory input = new bytes(191); + (bool ok, bytes memory out) = address(0x0a).staticcall(input); + + assert(ok); + assert(out.length == 0); + + uint256 codeSize; + bytes32 codeHash; + assembly { + codeSize := extcodesize(0x0a) + codeHash := extcodehash(0x0a) + } + + assert(codeSize == 0); + assert(codeHash == bytes32(0)); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--evm-version", + "shanghai", + "--match-contract", + "SymbolicPreCancunKzg", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicPreCancunKzg.t.sol:SymbolicPreCancunKzg +[PASS] checkAddress0aIsEmptyAccountBeforeCancun(uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_precompile_codehash_matches_account_state(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_precompile_codehash_matches_account_state because z3 is not available" + ); + return; + } + + prj.add_test( + "PrecompileAccounts.t.sol", + r#" +import "forge-std/Test.sol"; + +contract PrecompileAccounts is Test { + function setUp() public { + vm.deal(address(2), 1); + vm.setNonce(address(3), 1); + } + + function checkPrecompileAccounts() public { + assertPrecompileAccounts(); + } + + function testPrecompileAccounts() public { + assertPrecompileAccounts(); + } + + function assertPrecompileAccounts() internal { + for (uint160 i = 1; i <= 10; ++i) { + address target = address(i); + assert(target.code.length == 0); + assert(target.codehash == (i == 2 || i == 3 ? keccak256("") : bytes32(0))); + } + + (bool ok, bytes memory out) = address(4).staticcall(hex"010203"); + assert(ok); + assert(keccak256(out) == keccak256(hex"010203")); + assert(address(4).codehash == bytes32(0)); + + vm.deal(address(4), 1); + assert(address(4).codehash == keccak256("")); + vm.setNonce(address(5), 1); + assert(address(5).codehash == keccak256("")); + } +} +"#, + ); + + cmd.args([ + "test", + "--json", + "--evm-version", + "cancun", + "--match-test", + "testPrecompileAccounts", + ]) + .assert_json_stdout(str![[r#" +{ + "test/PrecompileAccounts.t.sol:PrecompileAccounts": { + "test_results": { + "testPrecompileAccounts()": { + "status": "Success", + "...": "{...}" + } + }, + "...": "{...}" + } +} +"#]]); + + cmd.forge_fuse() + .args([ + "test", + "--symbolic", + "--json", + "--evm-version", + "cancun", + "--match-test", + "checkPrecompileAccounts", + ]) + .assert_json_stdout(str![[r#" +{ + "test/PrecompileAccounts.t.sol:PrecompileAccounts": { + "test_results": { + "checkPrecompileAccounts()": { + "status": "Success", + "symbolic": { + "status": "pass", + "...": "{...}" + }, + "...": "{...}" + } + }, + "...": "{...}" + } +} +"#]]); +} + +#[forgetest_init] +fn symbolic_kzg_precompile_residual_reports_incomplete(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_kzg_precompile_residual_reports_incomplete because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicKzgResidual.t.sol", + r#" +contract SymbolicKzgResidual { + function checkUnmodeledKzgResidual(bytes32 z) public { + bytes memory input = abi.encodePacked( + hex"01e798154708fe7789429634053cbf9f99b619f9f084048927333fce637f549b", + z, + hex"1522a4a7f34e1ea350ae07c29c96c7e79655aa926122e95fe69fcbd932ca49e9", + hex"8f59a8d2a1a625a17f3fea0fe5eb8c896db3764f3185481bc22f91b4aaffcca25f26936857bc3a7c2539ea8ec3a952b7", + hex"a62ad71d14c5719385c0686f1871430475bf3a00f0aa3f7b8dd99a9abc2160744faf0070725e00b60ad9a026a15b1a8c" + ); + + address(0x0a).staticcall(input); + } +} +"#, + ); + + let stdout = cmd + .args(["test", "--symbolic", "--match-contract", "SymbolicKzgResidual"]) + .assert_failure() + .get_output() + .stdout_lossy(); + + assert!( + stdout.contains("symbolic KZG point-evaluation precompile residual not modeled"), + "{stdout}" + ); +} diff --git a/crates/forge/tests/cli/test_cmd/symbolic_storage.rs b/crates/forge/tests/cli/test_cmd/symbolic_storage.rs new file mode 100644 index 0000000000000..50e7ce149de39 --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/symbolic_storage.rs @@ -0,0 +1,487 @@ +use foundry_common::sh_eprintln; +use foundry_test_utils::{forgetest_init, str, util::OutputExt}; + +use super::symbolic_helpers::{assert_symbolic, assert_symbolic_witness, z3_available}; +use crate::skip_unless_z3; + +#[forgetest_init] +fn symbolic_mapping_storage_finds_counterexample(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_mapping_storage_finds_counterexample because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicMappingStorage.t.sol", + r#" +contract SymbolicMappingStorage { + mapping(address => uint256) values; + + function checkMapping(address who, uint256 value) public { + values[who] = value; + if (values[who] == 7) { + assert(false); + } + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args(["test", "--symbolic", "--match-test", "checkMapping"])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicMappingStorage.t.sol:SymbolicMappingStorage +[FAIL: panic: assertion failed (0x01); counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkMapping(address,uint256) ([METRICS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic SHA3"), "{stdout}"); + assert!(!stdout.contains("symbolic SSTORE key"), "{stdout}"); + assert!(!stdout.contains("symbolic SLOAD key"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_nested_mapping_storage_round_trips(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_nested_mapping_storage_round_trips because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicNestedMappingStorage.t.sol", + r#" +contract SymbolicNestedMappingStorage { + mapping(address => mapping(address => uint256)) allowances; + + function checkNestedMapping(address owner, address spender, uint256 value) public { + allowances[owner][spender] = value; + assert(allowances[owner][spender] == value); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkNestedMapping", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicNestedMappingStorage.t.sol:SymbolicNestedMappingStorage +[PASS] checkNestedMapping(address,address,uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic SHA3"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_vm_store_load_accepts_symbolic_slot(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_vm_store_load_accepts_symbolic_slot because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicVmStoreLoadSlot.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicVmStoreLoadSlot is Test { + function checkStoreLoad(bytes32 slot, bytes32 value) public { + vm.store(address(this), slot, value); + assert(vm.load(address(this), slot) == value); + } +} +"#, + ); + + let stdout = + assert_symbolic_witness(cmd.args(["test", "--symbolic", "--match-test", "checkStoreLoad"])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicVmStoreLoadSlot.t.sol:SymbolicVmStoreLoadSlot +[PASS] checkStoreLoad(bytes32,bytes32) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic vm.store slot"), "{stdout}"); + assert!(!stdout.contains("symbolic vm.load slot"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_mapping_dynamic_array_storage_round_trips(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_mapping_dynamic_array_storage_round_trips because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicMappingDynamicArrayStorage.t.sol", + r#" +import "forge-std/Test.sol"; + +contract SymbolicMappingDynamicArrayStorage is Test { + mapping(address => uint256[]) values; + + function checkMappingArray(address owner, uint256 index, uint256 value) public { + values[owner].push(0); + values[owner].push(0); + values[owner].push(0); + + vm.assume(index < values[owner].length); + values[owner][index] = value; + assert(values[owner][index] == value); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkMappingArray", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicMappingDynamicArrayStorage.t.sol:SymbolicMappingDynamicArrayStorage +[PASS] checkMappingArray(address,uint256,uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic SHA3"), "{stdout}"); + assert!(!stdout.contains("symbolic SSTORE key"), "{stdout}"); + assert!(!stdout.contains("symbolic SLOAD key"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_packed_storage_round_trips(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_packed_storage_round_trips because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicPackedStorage.t.sol", + r#" +contract SymbolicPackedStorage { + uint128 left; + uint128 right; + bool flag; + address owner; + + function checkPacked(uint128 a, uint128 b, bool enabled, address who) public { + left = a; + right = b; + flag = enabled; + owner = who; + + assert(left == a); + assert(right == b); + assert(flag == enabled); + assert(owner == who); + } +} +"#, + ); + + assert_symbolic_witness(cmd.args(["test", "--symbolic", "--match-test", "checkPacked"])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicPackedStorage.t.sol:SymbolicPackedStorage +[PASS] checkPacked(uint128,uint128,bool,address) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); +} + +#[forgetest_init] +fn symbolic_erc20_storage_paths_round_trip(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_erc20_storage_paths_round_trip because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicErc20Storage.t.sol", + r#" +contract SymbolicErc20Storage { + mapping(address => uint256) balanceOf; + mapping(address => mapping(address => uint256)) allowance; + + function checkErc20Storage(address owner, address spender, uint256 amount) public { + balanceOf[owner] = amount; + allowance[owner][spender] = amount; + + assert(balanceOf[owner] == amount); + assert(allowance[owner][spender] == amount); + assert(balanceOf[owner] == allowance[owner][spender]); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkErc20Storage", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicErc20Storage.t.sol:SymbolicErc20Storage +[PASS] checkErc20Storage(address,address,uint256) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic SHA3"), "{stdout}"); + assert!(!stdout.contains("symbolic SSTORE key"), "{stdout}"); + assert!(!stdout.contains("symbolic SLOAD key"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_erc20_transfer_from_storage_paths_do_not_alias(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_erc20_transfer_from_storage_paths_do_not_alias because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicErc20TransferFromStorage.t.sol", + r#" +contract SymbolicErc20TransferFromStorage { + mapping(address => uint256) balanceOf; + mapping(address => mapping(address => uint256)) allowance; + + function checkTransferFromStorage( + address owner, + address spender, + address recipient, + uint96 balance, + uint96 approval, + uint96 amount + ) public { + balanceOf[owner] = balance; + allowance[owner][spender] = approval; + + uint256 beforeTotal = balanceOf[owner] + balanceOf[recipient]; + if (owner != recipient && amount <= balanceOf[owner] && amount <= allowance[owner][spender]) { + allowance[owner][spender] -= amount; + balanceOf[owner] -= amount; + balanceOf[recipient] += amount; + + assert(balanceOf[owner] + balanceOf[recipient] == beforeTotal); + assert(allowance[owner][spender] == uint256(approval) - amount); + } + } +} +"#, + ); + + assert_symbolic(cmd.args(["test", "--symbolic", "--match-test", "checkTransferFromStorage"])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicErc20TransferFromStorage.t.sol:SymbolicErc20TransferFromStorage +[PASS] checkTransferFromStorage(address,address,address,uint96,uint96,uint96) ([METRICS]) +... +"#]]); +} + +#[forgetest_init] +fn symbolic_svm_storage_helpers_are_supported(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_svm_storage_helpers_are_supported because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicSvmStorageHelpers.t.sol", + r#" +interface Svm { + function enableSymbolicStorage(address target) external; + function setArbitraryStorage(address target) external; + function snapshotStorage(address target) external returns (uint256); + function snapshotState() external returns (uint256); +} + +contract SymbolicSvmStorageHelpers { + address constant SVM_ADDRESS = address(0xF3993A62377BCd56AE39D773740A5390411E8BC9); + + function checkSvmStorageHelpers(bytes32 slot, bytes32 value) public { + Svm(SVM_ADDRESS).enableSymbolicStorage(address(this)); + Svm(SVM_ADDRESS).setArbitraryStorage(address(this)); + uint256 stateSnapshot = Svm(SVM_ADDRESS).snapshotState(); + uint256 storageSnapshot = Svm(SVM_ADDRESS).snapshotStorage(address(this)); + + bytes32 loaded; + assembly { + sstore(slot, value) + loaded := sload(slot) + } + + assert(loaded == value); + assert(storageSnapshot == stateSnapshot + 1); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkSvmStorageHelpers", + ])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/SymbolicSvmStorageHelpers.t.sol:SymbolicSvmStorageHelpers +[PASS] checkSvmStorageHelpers(bytes32,bytes32) ([METRICS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic Halmos compatibility cheatcode"), "{stdout}"); +} + +#[forgetest_init] +fn symbolic_generic_storage_exposes_arbitrary_uninitialized_reads(prj: _, cmd: _) { + if !z3_available() { + let _ = sh_eprintln!( + "skipping symbolic_generic_storage_exposes_arbitrary_uninitialized_reads because z3 is not available" + ); + return; + } + + prj.add_test( + "SymbolicGenericStorage.t.sol", + r#" +interface Svm { + function setArbitraryStorage(address target) external; +} + +contract SymbolicGenericStorage { + address constant SVM_ADDRESS = address(0xF3993A62377BCd56AE39D773740A5390411E8BC9); + + mapping(address => uint256) balanceOf; + + /// forge-config: default.symbolic.storage_layout = "generic" + function checkNativeGenericStorage(address owner) public view { + assert(balanceOf[owner] == 0); + } + + function checkSvmArbitraryStorage(address owner) public { + Svm(SVM_ADDRESS).setArbitraryStorage(address(this)); + assert(balanceOf[owner] == 0); + } +} +"#, + ); + + let stdout = assert_symbolic_witness(cmd.args([ + "test", + "--symbolic", + "--match-contract", + "SymbolicGenericStorage", + ])) + .failure() + .stdout_eq(str![[r#" +... +Ran 2 tests for test/SymbolicGenericStorage.t.sol:SymbolicGenericStorage +[FAIL: incomplete symbolic execution (Error): symbolic counterexample did not replay] checkNativeGenericStorage(address) ([METRICS]) +[FAIL: EvmError: Revert; counterexample: [SENDER] [SENDER] [CALLDATA] [ARGS]] checkSvmArbitraryStorage(address) ([METRICS]) +Suite result: FAILED. 0 passed; 2 failed; 0 skipped; [ELAPSED] +... +"#]]) + .get_output() + .stdout_lossy(); + + assert!(!stdout.contains("symbolic SLOAD key"), "{stdout}"); + assert!(!stdout.contains("symbolic Halmos compatibility cheatcode"), "{stdout}"); +} + +// Reading an unwritten mapping at a symbolic key must yield a fresh symbolic +// value, not a concrete zero. The assertion below claims that no caller is an +// admin; because nothing has ever written to `isAdmin`, the solver can satisfy +// `isAdmin[user] == true`. That candidate does not replay concretely from the +// default concrete storage value, so Forge must report Incomplete instead of a +// user-facing counterexample. +#[forgetest_init] +fn symbolic_sload_unwritten_mapping_default_layout(prj: _, cmd: _) { + skip_unless_z3!("symbolic_sload_unwritten_mapping_default_layout"); + + prj.add_test( + "SymbolicSLoadUnwrittenMapping.t.sol", + r#" +contract SymbolicSLoadUnwrittenMapping { + mapping(address => bool) isAdmin; + + function checkNoUserIsAdmin(address user) public view { + assert(!isAdmin[user]); + } +} +"#, + ); + + assert_symbolic(cmd.args([ + "test", + "--symbolic", + "--match-test", + "checkNoUserIsAdmin", + ])) + .failure() + .stdout_eq(str![[r#" +... +Failing tests: +Encountered 1 failing test in test/SymbolicSLoadUnwrittenMapping.t.sol:SymbolicSLoadUnwrittenMapping +[FAIL: incomplete symbolic execution (Error): symbolic counterexample did not replay] checkNoUserIsAdmin(address) ([METRICS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +"#]]); +} diff --git a/crates/forge/tests/cli/test_cmd/table.rs b/crates/forge/tests/cli/test_cmd/table.rs new file mode 100644 index 0000000000000..b202cd8226f99 --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/table.rs @@ -0,0 +1,273 @@ +//! Table tests. + +use foundry_config::fs_permissions::PathPermission; +use foundry_test_utils::{forgetest_init, str}; + +#[forgetest_init] +fn should_run_table_tests(prj: _, cmd: _) { + prj.initialize_default_contracts(); + prj.add_test( + "CounterTable.t.sol", + r#" +import "forge-std/Test.sol"; +import {Counter} from "../src/Counter.sol"; + +contract CounterTableTest is Test { + Counter counter = new Counter(); + + uint256[] public fixtureAmount = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + bool[] public fixtureSwap = [true, true, false, true, false, true, false, true, false, true]; + bool[] public fixtureDiffSwap = [true, false]; + function fixtureNoFixture() public returns (address[] memory) { + } + + function tableWithNoParamFail() public { + counter.increment(); + } + + function tableWithParamNoFixtureFail(uint256 noFixture) public { + require(noFixture != 100); + counter.increment(); + } + + function tableSingleParamPass(uint256 amount) public { + require(amount != 100, "Amount cannot be 100"); + counter.increment(); + } + + function tableSingleParamFail(uint256 amount) public { + require(amount != 10, "Amount cannot be 10"); + counter.increment(); + } + + function tableMultipleParamsNoParamFail(uint256 amount, bool noSwap) public { + require(amount != 100 && noSwap, "Amount cannot be 100"); + counter.increment(); + } + + function tableMultipleParamsDifferentFixturesFail(uint256 amount, bool diffSwap) public { + require(amount != 100 && diffSwap, "Amount cannot be 100"); + counter.increment(); + } + + function tableMultipleParamsFail(uint256 amount, bool swap) public { + require(amount == 3 && swap, "Cannot swap"); + counter.increment(); + } + + function tableMultipleParamsPass(uint256 amount, bool swap) public { + if (amount == 3 && swap) { + revert(); + } + counter.increment(); + } +} + "#, + ); + + cmd.args(["test", "--mc", "CounterTable", "-vvvvv"]).assert_failure().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 8 tests for test/CounterTable.t.sol:CounterTableTest +[FAIL: 2 fixtures defined for diffSwap (expected 10)] tableMultipleParamsDifferentFixturesFail(uint256,bool) ([GAS]) +[FAIL: Cannot swap; counterexample: calldata=0x717892ca00000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001 args=[1, true]] tableMultipleParamsFail(uint256,bool) (runs: 1, [AVG_GAS]) +Traces: + [..] CounterTableTest::tableMultipleParamsFail(1, true) + └─ ← [Revert] Cannot swap + +Backtrace: + at CounterTableTest.tableMultipleParamsFail (test/CounterTable.t.sol:[..]:[..]) + +[FAIL: No fixture defined for param noSwap] tableMultipleParamsNoParamFail(uint256,bool) ([GAS]) +[PASS] tableMultipleParamsPass(uint256,bool) (runs: 10, [AVG_GAS]) +Traces: + [..] CounterTableTest::tableMultipleParamsPass(10, true) + ├─ [..] Counter::increment() + │ ├─ storage changes: + │ │ @ 0: 0 → 1 + │ └─ ← [Stop] + └─ ← [Stop] + +[FAIL: Amount cannot be 10; counterexample: calldata=0x44fa2375000000000000000000000000000000000000000000000000000000000000000a args=[10]] tableSingleParamFail(uint256) (runs: 10, [AVG_GAS]) +Traces: + [..] CounterTableTest::tableSingleParamFail(10) + └─ ← [Revert] Amount cannot be 10 + +Backtrace: + at CounterTableTest.tableSingleParamFail (test/CounterTable.t.sol:[..]:[..]) + +[PASS] tableSingleParamPass(uint256) (runs: 10, [AVG_GAS]) +Traces: + [..] CounterTableTest::tableSingleParamPass(10) + ├─ [..] Counter::increment() + │ ├─ storage changes: + │ │ @ 0: 0 → 1 + │ └─ ← [Stop] + └─ ← [Stop] + +[FAIL: Table test should have at least one parameter] tableWithNoParamFail() ([GAS]) +[FAIL: Table test should have at least one fixture] tableWithParamNoFixtureFail(uint256) ([GAS]) +Suite result: FAILED. 2 passed; 6 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 2 tests passed, 6 failed, 0 skipped (8 total tests) + +Failing tests: +Encountered 6 failing tests in test/CounterTable.t.sol:CounterTableTest +[FAIL: 2 fixtures defined for diffSwap (expected 10)] tableMultipleParamsDifferentFixturesFail(uint256,bool) ([GAS]) +[FAIL: Cannot swap; counterexample: calldata=0x717892ca00000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001 args=[1, true]] tableMultipleParamsFail(uint256,bool) (runs: 1, [AVG_GAS]) +[FAIL: No fixture defined for param noSwap] tableMultipleParamsNoParamFail(uint256,bool) ([GAS]) +[FAIL: Amount cannot be 10; counterexample: calldata=0x44fa2375000000000000000000000000000000000000000000000000000000000000000a args=[10]] tableSingleParamFail(uint256) (runs: 10, [AVG_GAS]) +[FAIL: Table test should have at least one parameter] tableWithNoParamFail() ([GAS]) +[FAIL: Table test should have at least one fixture] tableWithParamNoFixtureFail(uint256) ([GAS]) + +Encountered a total of 6 failing tests, 2 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 6 failed tests +Tip: Run `forge test --debug --match-test ` to inspect one failing test in the debugger + +"#]]); +} + +#[forgetest_init] +fn does_not_evaluate_unused_fixtures_for_unit_test_filter(prj: _, cmd: _) { + let marker = prj.root().join("fixture-called.txt"); + prj.update_config(|config| config.fs_permissions.add(PathPermission::write(prj.root()))); + prj.add_test( + "UnusedFixtures.t.sol", + r#" +interface Vm { + function projectRoot() external view returns (string memory path); + function writeFile(string calldata path, string calldata data) external; +} + +contract UnusedFixturesTest { + Vm internal constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + function fixtureAmount() public returns (uint256[] memory values) { + vm.writeFile(string.concat(vm.projectRoot(), "/fixture-called.txt"), "called"); + values = new uint256[](1); + values[0] = 1; + } + + function testUnit() public pure {} + + function tableUsesFixture(uint256 amount) public pure { + amount; + } +} + "#, + ); + + cmd.args(["test", "--match-test", "testUnit", "-q"]).assert_success(); + assert!(!marker.exists(), "unit-only run evaluated table fixture"); + + cmd.forge_fuse(); + cmd.args(["test", "--match-test", "tableUsesFixture", "-q"]).assert_success(); + assert!(marker.exists(), "table run did not evaluate table fixture"); +} + +// Table tests should show logs and contribute to coverage. +// +#[forgetest_init] +fn should_show_logs_and_add_coverage(prj: _, cmd: _) { + prj.add_source( + "Counter.sol", + r#" +contract Counter { + uint256 public number; + + function setNumber(uint256 a, uint256 b) public { + if (a == 1) { + number = b + 1; + } else if (a == 2) { + number = b + 2; + } else if (a == 3) { + number = b + 3; + } else { + number = a + b; + } + } +} + "#, + ); + prj.add_test( + "CounterTest.t.sol", + r#" +import "forge-std/Test.sol"; +import {Counter} from "../src/Counter.sol"; + +contract CounterTest is Test { + struct TestCase { + uint256 a; + uint256 b; + uint256 expected; + } + + Counter public counter; + + function setUp() public { + counter = new Counter(); + } + + function fixtureNumbers() public pure returns (TestCase[] memory) { + TestCase[] memory entries = new TestCase[](4); + entries[0] = TestCase(1, 5, 6); + entries[1] = TestCase(2, 10, 12); + entries[2] = TestCase(3, 11, 14); + entries[3] = TestCase(4, 11, 15); + return entries; + } + + function tableSetNumberTest(TestCase memory numbers) public { + console.log("expected", numbers.expected); + counter.setNumber(numbers.a, numbers.b); + require(counter.number() == numbers.expected, "test failed"); + } +} + "#, + ); + + cmd.args(["test", "-vvv"]).assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/CounterTest.t.sol:CounterTest +[PASS] tableSetNumberTest((uint256,uint256,uint256)) (runs: 4, [AVG_GAS]) +Logs: + expected 6 + expected 12 + expected 14 + expected 15 + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); + + cmd.forge_fuse().args(["coverage"]).assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! +Analysing contracts... +Running tests... + +Ran 1 test for test/CounterTest.t.sol:CounterTest +[PASS] tableSetNumberTest((uint256,uint256,uint256)) (runs: 4, [AVG_GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +╭-----------------+---------------+---------------+---------------+---------------╮ +| File | % Lines | % Statements | % Branches | % Funcs | ++=================================================================================+ +| src/Counter.sol | 100.00% (8/8) | 100.00% (7/7) | 100.00% (6/6) | 100.00% (1/1) | +|-----------------+---------------+---------------+---------------+---------------| +| Total | 100.00% (8/8) | 100.00% (7/7) | 100.00% (6/6) | 100.00% (1/1) | +╰-----------------+---------------+---------------+---------------+---------------╯ + +"#]]); +} diff --git a/crates/forge/tests/cli/test_cmd/trace.rs b/crates/forge/tests/cli/test_cmd/trace.rs new file mode 100644 index 0000000000000..b68ec940163ab --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/trace.rs @@ -0,0 +1,813 @@ +//! Tests for tracing functionality + +use foundry_test_utils::str; + +#[forgetest_init] +fn conflicting_signatures(prj: _, cmd: _) { + prj.add_test( + "ConflictingSignatures.t.sol", + r#" +pragma solidity ^0.8.18; + +import "forge-std/Test.sol"; + +contract ReturnsNothing { + function func() public pure {} +} + +contract ReturnsString { + function func() public pure returns (string memory) { + return "string"; + } +} + +contract ReturnsUint { + function func() public pure returns (uint256) { + return 1; + } +} + +contract ConflictingSignaturesTest is Test { + ReturnsNothing retsNothing; + ReturnsString retsString; + ReturnsUint retsUint; + + function setUp() public { + retsNothing = new ReturnsNothing(); + retsString = new ReturnsString(); + retsUint = new ReturnsUint(); + } + + /// Tests that traces are decoded properly when multiple + /// functions have the same 4byte signature, but different + /// return values. + function testTraceWithConflictingSignatures() public { + retsNothing.func(); + retsString.func(); + retsUint.func(); + } +} +"#, + ); + + cmd.args(["test", "-vvvvv"]).assert_success().stdout_eq(str![[r#" +... +Ran 1 test for test/ConflictingSignatures.t.sol:ConflictingSignaturesTest +[PASS] testTraceWithConflictingSignatures() ([GAS]) +Traces: + [..] ConflictingSignaturesTest::setUp() + ├─ [..] → new ReturnsNothing@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f + │ └─ ← [Return] 106 bytes of code + ├─ [..] → new ReturnsString@0x2e234DAe75C793f67A35089C9d99245E1C58470b + │ └─ ← [Return] 334 bytes of code + ├─ [..] → new ReturnsUint@0xF62849F9A0B5Bf2913b396098F7c7019b51A820a + │ └─ ← [Return] 175 bytes of code + └─ ← [Stop] + + [..] ConflictingSignaturesTest::testTraceWithConflictingSignatures() + ├─ [..] ReturnsNothing::func() [staticcall] + │ └─ ← [Stop] + ├─ [..] ReturnsString::func() [staticcall] + │ └─ ← [Return] "string" + ├─ [..] ReturnsUint::func() [staticcall] + │ └─ ← [Return] 1 + └─ ← [Stop] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +} + +#[forgetest_init] +fn trace_struct_outputs_use_called_contract_abi(prj: _, cmd: _) { + prj.add_test( + "TraceStructOutputs.t.sol", + r#" +pragma solidity ^0.8.18; + +import "forge-std/Test.sol"; + +contract AAAMuchWow { + struct MuchWowStruct { + uint256 c; + uint256 d; + } + + mapping(uint256 => MuchWowStruct) internal items; + + constructor() { + items[2] = MuchWowStruct({ c: 3, d: 4 }); + } + + function item(uint256 id) public view returns (MuchWowStruct memory) { + return items[id]; + } +} + +contract ZWow { + struct WowStruct { + uint256 a; + uint256 b; + } + + mapping(uint256 => WowStruct) internal items; + + constructor() { + items[1] = WowStruct({ a: 1, b: 2 }); + } + + function item(uint256 id) public view returns (WowStruct memory) { + return items[id]; + } +} + +contract TraceStructOutputsTest is Test { + AAAMuchWow muchWow; + ZWow wow; + + function setUp() public { + muchWow = new AAAMuchWow(); + wow = new ZWow(); + } + + function testStructTracesUseContractAbi() public view { + ZWow.WowStruct memory wowItem = wow.item(1); + AAAMuchWow.MuchWowStruct memory muchWowItem = muchWow.item(2); + + assertEq(wowItem.a, 1); + assertEq(wowItem.b, 2); + assertEq(muchWowItem.c, 3); + assertEq(muchWowItem.d, 4); + } +} +"#, + ); + + cmd.args(["test", "--mt", "testStructTracesUseContractAbi", "-vvvv"]) + .assert_success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/TraceStructOutputs.t.sol:TraceStructOutputsTest +[PASS] testStructTracesUseContractAbi() ([GAS]) +Traces: + [..] TraceStructOutputsTest::testStructTracesUseContractAbi() + ├─ [..] ZWow::item(1) [staticcall] + │ └─ ← [Return] WowStruct({ a: 1, b: 2 }) + ├─ [..] AAAMuchWow::item(2) [staticcall] + │ └─ ← [Return] MuchWowStruct({ c: 3, d: 4 }) + └─ ← [Stop] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +} + +#[forgetest_init] +fn trace_event_struct_with_function_pointer(prj: _, cmd: _) { + prj.add_test( + "StructWithFunctionEvent.t.sol", + r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +contract StructWithFunctionEvent { + struct Action { + uint256 id; + function(uint256) external callback; + } + + uint256 public i; + + event ActionLogged(Action action); + + function sampleCallback(uint256 value) external { + i += value; + } + + function testEmitFunctionInStruct() external { + Action memory myAction = Action({id: 1337, callback: this.sampleCallback}); + emit ActionLogged(myAction); + } +} +"#, + ); + + cmd.args(["test", "--mt", "testEmitFunctionInStruct", "-vvvv"]).assert_success().stdout_eq( + str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/StructWithFunctionEvent.t.sol:StructWithFunctionEvent +[PASS] testEmitFunctionInStruct() ([GAS]) +Traces: + [..] StructWithFunctionEvent::testEmitFunctionInStruct() + ├─ emit ActionLogged(action: Action({ id: 1337, callback: 0x[..]7cfdab90 })) + └─ ← [Stop] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]], + ); +} + +#[forgetest_init] +fn trace_test(prj: _, cmd: _) { + prj.add_test( + "Trace.t.sol", + r#" +pragma solidity ^0.8.18; + +import "forge-std/Test.sol"; + +contract RecursiveCall { + TraceTest factory; + + event Depth(uint256 depth); + event ChildDepth(uint256 childDepth); + event CreatedChild(uint256 childDepth); + + constructor(address _factory) { + factory = TraceTest(_factory); + } + + function recurseCall(uint256 neededDepth, uint256 depth) public returns (uint256) { + if (depth == neededDepth) { + this.negativeNum(); + return neededDepth; + } + + uint256 childDepth = this.recurseCall(neededDepth, depth + 1); + emit ChildDepth(childDepth); + + this.someCall(); + emit Depth(depth); + + return depth; + } + + function recurseCreate(uint256 neededDepth, uint256 depth) public returns (uint256) { + if (depth == neededDepth) { + return neededDepth; + } + + RecursiveCall child = factory.create(); + emit CreatedChild(depth + 1); + + uint256 childDepth = child.recurseCreate(neededDepth, depth + 1); + emit ChildDepth(childDepth); + emit Depth(depth); + + return depth; + } + + function someCall() public pure {} + + function negativeNum() public pure returns (int256) { + return -1000000000; + } +} + +contract TraceTest is Test { + uint256 nodeId = 0; + RecursiveCall first; + + function setUp() public { + first = this.create(); + } + + function create() public returns (RecursiveCall) { + RecursiveCall node = new RecursiveCall(address(this)); + vm.label(address(node), string(abi.encodePacked("Node ", uintToString(nodeId++)))); + + return node; + } + + function testRecurseCall() public { + first.recurseCall(8, 0); + } + + function testRecurseCreate() public { + first.recurseCreate(8, 0); + } +} + +function uintToString(uint256 value) pure returns (string memory) { + // Taken from OpenZeppelin + if (value == 0) { + return "0"; + } + uint256 temp = value; + uint256 digits; + while (temp != 0) { + digits++; + temp /= 10; + } + bytes memory buffer = new bytes(digits); + while (value != 0) { + digits -= 1; + buffer[digits] = bytes1(uint8(48 + uint256(value % 10))); + value /= 10; + } + return string(buffer); +} +"#, + ); + + cmd.args(["test", "-vvvvv"]).assert_success().stdout_eq(str![[r#" +... +Ran 2 tests for test/Trace.t.sol:TraceTest +[PASS] testRecurseCall() ([GAS]) +Traces: + [..] TraceTest::setUp() + ├─ [..] TraceTest::create() + │ ├─ [405132] → new Node 0@0x2e234DAe75C793f67A35089C9d99245E1C58470b + │ │ └─ ← [Return] 1911 bytes of code + │ ├─ [0] VM::label(Node 0: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], "Node 0") + │ │ └─ ← [Return] + │ └─ ← [Return] Node 0: [0x2e234DAe75C793f67A35089C9d99245E1C58470b] + └─ ← [Stop] + + [..] TraceTest::testRecurseCall() + ├─ [..] Node 0::recurseCall(8, 0) + │ ├─ [..] Node 0::recurseCall(8, 1) + │ │ ├─ [..] Node 0::recurseCall(8, 2) + │ │ │ ├─ [..] Node 0::recurseCall(8, 3) + │ │ │ │ ├─ [..] Node 0::recurseCall(8, 4) + │ │ │ │ │ ├─ [..] Node 0::recurseCall(8, 5) + │ │ │ │ │ │ ├─ [..] Node 0::recurseCall(8, 6) + │ │ │ │ │ │ │ ├─ [..] Node 0::recurseCall(8, 7) + │ │ │ │ │ │ │ │ ├─ [..] Node 0::recurseCall(8, 8) + │ │ │ │ │ │ │ │ │ ├─ [..] Node 0::negativeNum() [staticcall] + │ │ │ │ │ │ │ │ │ │ └─ ← [Return] -1000000000 [-1e9] + │ │ │ │ │ │ │ │ │ └─ ← [Return] 8 + │ │ │ │ │ │ │ │ ├─ emit ChildDepth(childDepth: 8) + │ │ │ │ │ │ │ │ ├─ [..] Node 0::someCall() [staticcall] + │ │ │ │ │ │ │ │ │ └─ ← [Stop] + │ │ │ │ │ │ │ │ ├─ emit Depth(depth: 7) + │ │ │ │ │ │ │ │ └─ ← [Return] 7 + │ │ │ │ │ │ │ ├─ emit ChildDepth(childDepth: 7) + │ │ │ │ │ │ │ ├─ [..] Node 0::someCall() [staticcall] + │ │ │ │ │ │ │ │ └─ ← [Stop] + │ │ │ │ │ │ │ ├─ emit Depth(depth: 6) + │ │ │ │ │ │ │ └─ ← [Return] 6 + │ │ │ │ │ │ ├─ emit ChildDepth(childDepth: 6) + │ │ │ │ │ │ ├─ [..] Node 0::someCall() [staticcall] + │ │ │ │ │ │ │ └─ ← [Stop] + │ │ │ │ │ │ ├─ emit Depth(depth: 5) + │ │ │ │ │ │ └─ ← [Return] 5 + │ │ │ │ │ ├─ emit ChildDepth(childDepth: 5) + │ │ │ │ │ ├─ [..] Node 0::someCall() [staticcall] + │ │ │ │ │ │ └─ ← [Stop] + │ │ │ │ │ ├─ emit Depth(depth: 4) + │ │ │ │ │ └─ ← [Return] 4 + │ │ │ │ ├─ emit ChildDepth(childDepth: 4) + │ │ │ │ ├─ [..] Node 0::someCall() [staticcall] + │ │ │ │ │ └─ ← [Stop] + │ │ │ │ ├─ emit Depth(depth: 3) + │ │ │ │ └─ ← [Return] 3 + │ │ │ ├─ emit ChildDepth(childDepth: 3) + │ │ │ ├─ [..] Node 0::someCall() [staticcall] + │ │ │ │ └─ ← [Stop] + │ │ │ ├─ emit Depth(depth: 2) + │ │ │ └─ ← [Return] 2 + │ │ ├─ emit ChildDepth(childDepth: 2) + │ │ ├─ [..] Node 0::someCall() [staticcall] + │ │ │ └─ ← [Stop] + │ │ ├─ emit Depth(depth: 1) + │ │ └─ ← [Return] 1 + │ ├─ emit ChildDepth(childDepth: 1) + │ ├─ [..] Node 0::someCall() [staticcall] + │ │ └─ ← [Stop] + │ ├─ emit Depth(depth: 0) + │ └─ ← [Return] 0 + └─ ← [Stop] + +[PASS] testRecurseCreate() ([GAS]) +Traces: + [..] TraceTest::setUp() + ├─ [..] TraceTest::create() + │ ├─ [405132] → new Node 0@0x2e234DAe75C793f67A35089C9d99245E1C58470b + │ │ └─ ← [Return] 1911 bytes of code + │ ├─ [0] VM::label(Node 0: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], "Node 0") + │ │ └─ ← [Return] + │ └─ ← [Return] Node 0: [0x2e234DAe75C793f67A35089C9d99245E1C58470b] + └─ ← [Stop] + + [..] TraceTest::testRecurseCreate() + ├─ [..] Node 0::recurseCreate(8, 0) + │ ├─ [..] TraceTest::create() + │ │ ├─ [405132] → new Node 1@0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9 + │ │ │ ├─ storage changes: + │ │ │ │ @ 0: 0 → 0x0000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e1496 + │ │ │ └─ ← [Return] 1911 bytes of code + │ │ ├─ [0] VM::label(Node 1: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], "Node 1") + │ │ │ └─ ← [Return] + │ │ ├─ storage changes: + │ │ │ @ 32: 1 → 2 + │ │ └─ ← [Return] Node 1: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9] + │ ├─ emit CreatedChild(childDepth: 1) + │ ├─ [..] Node 1::recurseCreate(8, 1) + │ │ ├─ [..] TraceTest::create() + │ │ │ ├─ [405132] → new Node 2@0xc7183455a4C133Ae270771860664b6B7ec320bB1 + │ │ │ │ ├─ storage changes: + │ │ │ │ │ @ 0: 0 → 0x0000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e1496 + │ │ │ │ └─ ← [Return] 1911 bytes of code + │ │ │ ├─ [0] VM::label(Node 2: [0xc7183455a4C133Ae270771860664b6B7ec320bB1], "Node 2") + │ │ │ │ └─ ← [Return] + │ │ │ ├─ storage changes: + │ │ │ │ @ 32: 2 → 3 + │ │ │ └─ ← [Return] Node 2: [0xc7183455a4C133Ae270771860664b6B7ec320bB1] + │ │ ├─ emit CreatedChild(childDepth: 2) + │ │ ├─ [..] Node 2::recurseCreate(8, 2) + │ │ │ ├─ [..] TraceTest::create() + │ │ │ │ ├─ [405132] → new Node 3@0xa0Cb889707d426A7A386870A03bc70d1b0697598 + │ │ │ │ │ ├─ storage changes: + │ │ │ │ │ │ @ 0: 0 → 0x0000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e1496 + │ │ │ │ │ └─ ← [Return] 1911 bytes of code + │ │ │ │ ├─ [0] VM::label(Node 3: [0xa0Cb889707d426A7A386870A03bc70d1b0697598], "Node 3") + │ │ │ │ │ └─ ← [Return] + │ │ │ │ ├─ storage changes: + │ │ │ │ │ @ 32: 3 → 4 + │ │ │ │ └─ ← [Return] Node 3: [0xa0Cb889707d426A7A386870A03bc70d1b0697598] + │ │ │ ├─ emit CreatedChild(childDepth: 3) + │ │ │ ├─ [..] Node 3::recurseCreate(8, 3) + │ │ │ │ ├─ [..] TraceTest::create() + │ │ │ │ │ ├─ [405132] → new Node 4@0x1d1499e622D69689cdf9004d05Ec547d650Ff211 + │ │ │ │ │ │ ├─ storage changes: + │ │ │ │ │ │ │ @ 0: 0 → 0x0000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e1496 + │ │ │ │ │ │ └─ ← [Return] 1911 bytes of code + │ │ │ │ │ ├─ [0] VM::label(Node 4: [0x1d1499e622D69689cdf9004d05Ec547d650Ff211], "Node 4") + │ │ │ │ │ │ └─ ← [Return] + │ │ │ │ │ ├─ storage changes: + │ │ │ │ │ │ @ 32: 4 → 5 + │ │ │ │ │ └─ ← [Return] Node 4: [0x1d1499e622D69689cdf9004d05Ec547d650Ff211] + │ │ │ │ ├─ emit CreatedChild(childDepth: 4) + │ │ │ │ ├─ [..] Node 4::recurseCreate(8, 4) + │ │ │ │ │ ├─ [..] TraceTest::create() + │ │ │ │ │ │ ├─ [405132] → new Node 5@0xA4AD4f68d0b91CFD19687c881e50f3A00242828c + │ │ │ │ │ │ │ ├─ storage changes: + │ │ │ │ │ │ │ │ @ 0: 0 → 0x0000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e1496 + │ │ │ │ │ │ │ └─ ← [Return] 1911 bytes of code + │ │ │ │ │ │ ├─ [0] VM::label(Node 5: [0xA4AD4f68d0b91CFD19687c881e50f3A00242828c], "Node 5") + │ │ │ │ │ │ │ └─ ← [Return] + │ │ │ │ │ │ ├─ storage changes: + │ │ │ │ │ │ │ @ 32: 5 → 6 + │ │ │ │ │ │ └─ ← [Return] Node 5: [0xA4AD4f68d0b91CFD19687c881e50f3A00242828c] + │ │ │ │ │ ├─ emit CreatedChild(childDepth: 5) + │ │ │ │ │ ├─ [..] Node 5::recurseCreate(8, 5) + │ │ │ │ │ │ ├─ [..] TraceTest::create() + │ │ │ │ │ │ │ ├─ [405132] → new Node 6@0x03A6a84cD762D9707A21605b548aaaB891562aAb + │ │ │ │ │ │ │ │ ├─ storage changes: + │ │ │ │ │ │ │ │ │ @ 0: 0 → 0x0000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e1496 + │ │ │ │ │ │ │ │ └─ ← [Return] 1911 bytes of code + │ │ │ │ │ │ │ ├─ [0] VM::label(Node 6: [0x03A6a84cD762D9707A21605b548aaaB891562aAb], "Node 6") + │ │ │ │ │ │ │ │ └─ ← [Return] + │ │ │ │ │ │ │ ├─ storage changes: + │ │ │ │ │ │ │ │ @ 32: 6 → 7 + │ │ │ │ │ │ │ └─ ← [Return] Node 6: [0x03A6a84cD762D9707A21605b548aaaB891562aAb] + │ │ │ │ │ │ ├─ emit CreatedChild(childDepth: 6) + │ │ │ │ │ │ ├─ [..] Node 6::recurseCreate(8, 6) + │ │ │ │ │ │ │ ├─ [..] TraceTest::create() + │ │ │ │ │ │ │ │ ├─ [405132] → new Node 7@0xD6BbDE9174b1CdAa358d2Cf4D57D1a9F7178FBfF + │ │ │ │ │ │ │ │ │ ├─ storage changes: + │ │ │ │ │ │ │ │ │ │ @ 0: 0 → 0x0000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e1496 + │ │ │ │ │ │ │ │ │ └─ ← [Return] 1911 bytes of code + │ │ │ │ │ │ │ │ ├─ [0] VM::label(Node 7: [0xD6BbDE9174b1CdAa358d2Cf4D57D1a9F7178FBfF], "Node 7") + │ │ │ │ │ │ │ │ │ └─ ← [Return] + │ │ │ │ │ │ │ │ ├─ storage changes: + │ │ │ │ │ │ │ │ │ @ 32: 7 → 8 + │ │ │ │ │ │ │ │ └─ ← [Return] Node 7: [0xD6BbDE9174b1CdAa358d2Cf4D57D1a9F7178FBfF] + │ │ │ │ │ │ │ ├─ emit CreatedChild(childDepth: 7) + │ │ │ │ │ │ │ ├─ [..] Node 7::recurseCreate(8, 7) + │ │ │ │ │ │ │ │ ├─ [..] TraceTest::create() + │ │ │ │ │ │ │ │ │ ├─ [405132] → new Node 8@0x15cF58144EF33af1e14b5208015d11F9143E27b9 + │ │ │ │ │ │ │ │ │ │ ├─ storage changes: + │ │ │ │ │ │ │ │ │ │ │ @ 0: 0 → 0x0000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e1496 + │ │ │ │ │ │ │ │ │ │ └─ ← [Return] 1911 bytes of code + │ │ │ │ │ │ │ │ │ ├─ [0] VM::label(Node 8: [0x15cF58144EF33af1e14b5208015d11F9143E27b9], "Node 8") + │ │ │ │ │ │ │ │ │ │ └─ ← [Return] + │ │ │ │ │ │ │ │ │ ├─ storage changes: + │ │ │ │ │ │ │ │ │ │ @ 32: 8 → 9 + │ │ │ │ │ │ │ │ │ └─ ← [Return] Node 8: [0x15cF58144EF33af1e14b5208015d11F9143E27b9] + │ │ │ │ │ │ │ │ ├─ emit CreatedChild(childDepth: 8) + │ │ │ │ │ │ │ │ ├─ [..] Node 8::recurseCreate(8, 8) + │ │ │ │ │ │ │ │ │ └─ ← [Return] 8 + │ │ │ │ │ │ │ │ ├─ emit ChildDepth(childDepth: 8) + │ │ │ │ │ │ │ │ ├─ emit Depth(depth: 7) + │ │ │ │ │ │ │ │ └─ ← [Return] 7 + │ │ │ │ │ │ │ ├─ emit ChildDepth(childDepth: 7) + │ │ │ │ │ │ │ ├─ emit Depth(depth: 6) + │ │ │ │ │ │ │ └─ ← [Return] 6 + │ │ │ │ │ │ ├─ emit ChildDepth(childDepth: 6) + │ │ │ │ │ │ ├─ emit Depth(depth: 5) + │ │ │ │ │ │ └─ ← [Return] 5 + │ │ │ │ │ ├─ emit ChildDepth(childDepth: 5) + │ │ │ │ │ ├─ emit Depth(depth: 4) + │ │ │ │ │ └─ ← [Return] 4 + │ │ │ │ ├─ emit ChildDepth(childDepth: 4) + │ │ │ │ ├─ emit Depth(depth: 3) + │ │ │ │ └─ ← [Return] 3 + │ │ │ ├─ emit ChildDepth(childDepth: 3) + │ │ │ ├─ emit Depth(depth: 2) + │ │ │ └─ ← [Return] 2 + │ │ ├─ emit ChildDepth(childDepth: 2) + │ │ ├─ emit Depth(depth: 1) + │ │ └─ ← [Return] 1 + │ ├─ emit ChildDepth(childDepth: 1) + │ ├─ emit Depth(depth: 0) + │ └─ ← [Return] 0 + └─ ← [Stop] + +Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 2 tests passed, 0 failed, 0 skipped (2 total tests) + +"#]]); +} + +#[forgetest_init] +fn trace_test_detph(prj: _, cmd: _) { + prj.add_test( + "Trace.t.sol", + r#" +pragma solidity ^0.8.18; + +import "forge-std/Test.sol"; + +contract RecursiveCall { + TraceTest factory; + + event Depth(uint256 depth); + event ChildDepth(uint256 childDepth); + event CreatedChild(uint256 childDepth); + + constructor(address _factory) { + factory = TraceTest(_factory); + } + + function recurseCall(uint256 neededDepth, uint256 depth) public returns (uint256) { + if (depth == neededDepth) { + this.negativeNum(); + return neededDepth; + } + + uint256 childDepth = this.recurseCall(neededDepth, depth + 1); + emit ChildDepth(childDepth); + + this.someCall(); + emit Depth(depth); + + return depth; + } + + function recurseCreate(uint256 neededDepth, uint256 depth) public returns (uint256) { + if (depth == neededDepth) { + return neededDepth; + } + + RecursiveCall child = factory.create(); + emit CreatedChild(depth + 1); + + uint256 childDepth = child.recurseCreate(neededDepth, depth + 1); + emit ChildDepth(childDepth); + emit Depth(depth); + + return depth; + } + + function someCall() public pure {} + + function negativeNum() public pure returns (int256) { + return -1000000000; + } +} + +contract TraceTest is Test { + uint256 nodeId = 0; + RecursiveCall first; + + function setUp() public { + first = this.create(); + } + + function create() public returns (RecursiveCall) { + RecursiveCall node = new RecursiveCall(address(this)); + vm.label(address(node), string(abi.encodePacked("Node ", uintToString(nodeId++)))); + + return node; + } + + function testRecurseCall() public { + first.recurseCall(8, 0); + } + + function testRecurseCreate() public { + first.recurseCreate(8, 0); + } +} + +function uintToString(uint256 value) pure returns (string memory) { + // Taken from OpenZeppelin + if (value == 0) { + return "0"; + } + uint256 temp = value; + uint256 digits; + while (temp != 0) { + digits++; + temp /= 10; + } + bytes memory buffer = new bytes(digits); + while (value != 0) { + digits -= 1; + buffer[digits] = bytes1(uint8(48 + uint256(value % 10))); + value /= 10; + } + return string(buffer); +} +"#, + ); + + cmd.args(["test", "-vvvvv", "--trace-depth", "3"]).assert_success().stdout_eq(str![[r#" +... +Ran 2 tests for test/Trace.t.sol:TraceTest +[PASS] testRecurseCall() ([GAS]) +Traces: + [..] TraceTest::setUp() + ├─ [..] TraceTest::create() + │ ├─ [405132] → new Node 0@0x2e234DAe75C793f67A35089C9d99245E1C58470b + │ │ └─ ← [Return] 1911 bytes of code + │ ├─ [0] VM::label(Node 0: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], "Node 0") + │ │ └─ ← [Return] + │ └─ ← [Return] Node 0: [0x2e234DAe75C793f67A35089C9d99245E1C58470b] + └─ ← [Stop] + + [..] TraceTest::testRecurseCall() + ├─ [..] Node 0::recurseCall(8, 0) + │ ├─ [..] Node 0::recurseCall(8, 1) + │ │ ├─ [..] Node 0::recurseCall(8, 2) + │ │ │ └─ ← [Return] 2 + │ │ ├─ emit ChildDepth(childDepth: 2) + │ │ ├─ [..] Node 0::someCall() [staticcall] + │ │ │ └─ ← [Stop] + │ │ ├─ emit Depth(depth: 1) + │ │ └─ ← [Return] 1 + │ ├─ emit ChildDepth(childDepth: 1) + │ ├─ [..] Node 0::someCall() [staticcall] + │ │ └─ ← [Stop] + │ ├─ emit Depth(depth: 0) + │ └─ ← [Return] 0 + └─ ← [Stop] + +[PASS] testRecurseCreate() ([GAS]) +Traces: + [..] TraceTest::setUp() + ├─ [..] TraceTest::create() + │ ├─ [405132] → new Node 0@0x2e234DAe75C793f67A35089C9d99245E1C58470b + │ │ └─ ← [Return] 1911 bytes of code + │ ├─ [0] VM::label(Node 0: [0x2e234DAe75C793f67A35089C9d99245E1C58470b], "Node 0") + │ │ └─ ← [Return] + │ └─ ← [Return] Node 0: [0x2e234DAe75C793f67A35089C9d99245E1C58470b] + └─ ← [Stop] + + [..] TraceTest::testRecurseCreate() + ├─ [..] Node 0::recurseCreate(8, 0) + │ ├─ [..] TraceTest::create() + │ │ ├─ [405132] → new Node 1@0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9 + │ │ │ ├─ storage changes: + │ │ │ │ @ 0: 0 → 0x0000000000000000000000007fa9385be102ac3eac297483dd6233d62b3e1496 + │ │ │ └─ ← [Return] 1911 bytes of code + │ │ ├─ [0] VM::label(Node 1: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9], "Node 1") + │ │ │ └─ ← [Return] + │ │ ├─ storage changes: + │ │ │ @ 32: 1 → 2 + │ │ └─ ← [Return] Node 1: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9] + │ ├─ emit CreatedChild(childDepth: 1) + │ ├─ [..] Node 1::recurseCreate(8, 1) + │ │ ├─ [..] TraceTest::create() + │ │ │ ├─ storage changes: + │ │ │ │ @ 32: 2 → 3 + │ │ │ └─ ← [Return] Node 2: [0xc7183455a4C133Ae270771860664b6B7ec320bB1] + │ │ ├─ emit CreatedChild(childDepth: 2) + │ │ ├─ [..] Node 2::recurseCreate(8, 2) + │ │ │ └─ ← [Return] 2 + │ │ ├─ emit ChildDepth(childDepth: 2) + │ │ ├─ emit Depth(depth: 1) + │ │ └─ ← [Return] 1 + │ ├─ emit ChildDepth(childDepth: 1) + │ ├─ emit Depth(depth: 0) + │ └─ ← [Return] 0 + └─ ← [Stop] + +Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 2 tests passed, 0 failed, 0 skipped (2 total tests) + +"#]]); +} + +// Tests that deploy helpers generated by dynamic test linking are not reported as removed sources. +#[forgetest_init] +fn no_removed_source_warning_for_deploy_helpers(prj: _, cmd: _) { + prj.add_source( + "Greeter.sol", + r#" +pragma solidity ^0.8.18; + +contract Greeter { + uint256 public number; + + constructor(uint256 _number) { + number = _number; + } +} +"#, + ); + prj.add_test( + "Greeter.t.sol", + r#" +pragma solidity ^0.8.18; + +import {Test} from "forge-std/Test.sol"; +import {Greeter} from "../src/Greeter.sol"; + +contract GreeterTest is Test { + function test_deploy() public { + assertEq(new Greeter(1).number(), 1); + } +} +"#, + ); + + cmd.args(["test", "--decode-internal", "--mt", "test_deploy"]) + .assert_success() + .stderr_eq(str![""]); +} + +// Nested transactions must share the suspended trace depth and restore isolation context. +#[forgetest_init] +fn execute_transaction_nested_tracing(prj: _, cmd: _) { + prj.add_test( + "ExecuteTransactionTracing.t.sol", + &include_str!("../../../../../testdata/default/cheats/ExecuteTransactionTracing.t.sol") + .replace("utils/Test.sol", "forge-std/Test.sol"), + ); + + for isolate in [false, true] { + cmd.forge_fuse(); + cmd.env("FOUNDRY_ISOLATE", isolate.to_string()); + cmd.args(["test", "-vvv"]).assert_success().stdout_eq(str![[r#" +... +Ran 8 tests for test/ExecuteTransactionTracing.t.sol:ExecuteTransactionTracingTest +[PASS] test_nested_create() ([GAS]) +[PASS] test_nested_halt() ([GAS]) +[PASS] test_nested_invalid_nonce() ([GAS]) +[PASS] test_nested_leaf() ([GAS]) +[PASS] test_nested_revert() ([GAS]) +[PASS] test_nested_subcall() ([GAS]) +[PASS] test_nested_transaction_creates_child() ([GAS]) +[PASS] test_recursive_transaction() ([GAS]) +Suite result: ok. 8 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 8 tests passed, 0 failed, 0 skipped (8 total tests) + +"#]]); + + cmd.forge_fuse(); + cmd.env("FOUNDRY_ISOLATE", isolate.to_string()); + cmd.args(["test", "--mt", "test_nested_subcall", "-vvvv"]).assert_success().stdout_eq( + str![[r#" +No files changed, compilation skipped + +Ran 1 test for test/ExecuteTransactionTracing.t.sol:ExecuteTransactionTracingTest +[PASS] test_nested_subcall() ([GAS]) +Traces: + [..] ExecuteTransactionTracingTest::test_nested_subcall() +... + ├─ [..] ExecuteTransactionTracingTest::execute([..]) + │ ├─ [..] VM::executeTransaction([..]) + │ │ ├─ [..] ExecuteTransactionTracingTest::leaf() + │ │ │ └─ ← [Stop] + │ │ └─ ← [Return] 0x + │ ├─ [..] ExecuteTransactionTracingTest::leaf() + │ │ └─ ← [Stop] + │ └─ ← [Stop] + ├─ [..] ExecuteTransactionTracingTest::leaf() + │ └─ ← [Stop] + └─ ← [Stop] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]], + ); + } +} diff --git a/crates/forge/tests/cli/test_optimizer.rs b/crates/forge/tests/cli/test_optimizer.rs index de0f57022a96b..82b2655b51876 100644 --- a/crates/forge/tests/cli/test_optimizer.rs +++ b/crates/forge/tests/cli/test_optimizer.rs @@ -1,823 +1,1707 @@ -//! Tests for the `forge test` with preprocessed cache. +//! Tests for commands using the preprocessed cache. -// Test cache is invalidated when `forge build` if optimize test option toggled. -forgetest_init!(toggle_invalidate_cache_on_build, |prj, cmd| { - prj.update_config(|config| { - config.dynamic_test_linking = true; - }); - // All files are built with optimized tests. - cmd.args(["build"]).with_no_redact().assert_success().stdout_eq(str![[r#" -... -Compiling 23 files with [..] +use foundry_compilers::artifacts::{EvmVersion, remappings::Remapping}; +use foundry_config::{CompilationRestrictions, SettingsOverrides}; + +#[cfg(unix)] +use foundry_compilers::artifacts::{SolcInput, output_selection::OutputSelection}; +#[cfg(unix)] +use std::{fs, os::unix::fs::PermissionsExt, path::PathBuf}; + +// +#[forgetest] +fn preprocess_parenthesized_new(prj: _, cmd: _) { + prj.add_source( + "Target.sol", + r#" +contract Empty { + constructor() payable {} +} +contract Target { + uint256 public immutable n; + constructor(uint256 number) payable { n = number; } +} +"#, + ); + prj.add_test( + "Value.t.sol", + r#" +import {Empty, Target} from "../src/Target.sol"; +contract ValueTest { + function test_empty() public { + Empty a = (new Empty){value: 1 ether}(); + Empty b = ((new Empty)){value: 2 ether}(); + Empty c = (new Empty)(); + Empty d = new Empty{value: 3 ether}(); + require(address(a).balance == 1 ether); + require(address(b).balance == 2 ether); + require(address(c).balance == 0); + require(address(d).balance == 3 ether); + } + function test_arguments() public { + Target a = (new Target){value: 1 ether}(42); + Target b = ((new Target)){value: 2 ether}({number: 7}); + Target c = ((new Target))(8); + Target d = (new Target{value: 3 ether})(9); + require(a.n() == 42 && address(a).balance == 1 ether); + require(b.n() == 7 && address(b).balance == 2 ether); + require(c.n() == 8 && address(c).balance == 0); + require(d.n() == 9 && address(d).balance == 3 ether); + } + function test_salt() public { + Target target = (new Target){salt: bytes32(uint256(1)), value: 1 ether}(42); + address expected = address(uint160(uint256(keccak256(abi.encodePacked( + bytes1(0xff), address(this), bytes32(uint256(1)), + keccak256(abi.encodePacked(type(Target).creationCode, abi.encode(uint256(42)))) + ))))); + require(address(target) == expected); + require(target.n() == 42 && address(target).balance == 1 ether); + } + function test_try() public { + try (new Target){value: 1 ether}(42) returns (Target target) { + require(target.n() == 42 && address(target).balance == 1 ether); + } catch { revert("deployment failed"); } + } +} +"#, + ); + for dynamic_test_linking in [false, true] { + prj.update_config(|config| config.dynamic_test_linking = dynamic_test_linking); + cmd.forge_fuse().args(["test", "--force"]).assert_success().stdout_eq(str![[r#" ... +Ran 4 tests for test/Value.t.sol:ValueTest +[PASS] test_arguments() ([GAS]) +[PASS] test_empty() ([GAS]) +[PASS] test_salt() ([GAS]) +[PASS] test_try() ([GAS]) +Suite result: ok. 4 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 4 tests passed, 0 failed, 0 skipped (4 total tests) "#]]); - // No files are rebuilt. - cmd.with_no_redact().assert_success().stdout_eq(str![[r#" -... + } + + cmd.forge_fuse() + .args(["test", "--match-test", "test_arguments", "-vvvv"]) + .assert_success() + .stdout_eq(str![[r#" No files changed, compilation skipped -... -"#]]); +Ran 1 test for test/Value.t.sol:ValueTest +[PASS] test_arguments() ([GAS]) +Traces: + [[..]] ValueTest::test_arguments() + ├─ [0] VM::deployCode("src/Target.sol:Target", 0x000000000000000000000000000000000000000000000000000000000000002a, 1000000000000000000 [1e18]) + │ ├─ [[..]] → new Target@0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f + │ │ └─ ← [Return] 203 bytes of code + │ └─ ← [Return] Target: [0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f] + ├─ [0] VM::deployCode("src/Target.sol:Target", 0x0000000000000000000000000000000000000000000000000000000000000007, 2000000000000000000 [2e18]) + │ ├─ [[..]] → new Target@0x2e234DAe75C793f67A35089C9d99245E1C58470b + │ │ └─ ← [Return] 203 bytes of code + │ └─ ← [Return] Target: [0x2e234DAe75C793f67A35089C9d99245E1C58470b] + ├─ [0] VM::deployCode("src/Target.sol:Target", 0x0000000000000000000000000000000000000000000000000000000000000008) + │ ├─ [[..]] → new Target@0xF62849F9A0B5Bf2913b396098F7c7019b51A820a + │ │ └─ ← [Return] 203 bytes of code + │ └─ ← [Return] Target: [0xF62849F9A0B5Bf2913b396098F7c7019b51A820a] + ├─ [0] VM::deployCode("src/Target.sol:Target", 0x0000000000000000000000000000000000000000000000000000000000000009, 3000000000000000000 [3e18]) + │ ├─ [[..]] → new Target@0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9 + │ │ └─ ← [Return] 203 bytes of code + │ └─ ← [Return] Target: [0x5991A2dF15A8F6A256D3Ec51E99254Cd3fb576A9] + ├─ [303] Target::n() [staticcall] + │ └─ ← [Return] 42 + ├─ [303] Target::n() [staticcall] + │ └─ ← [Return] 7 + ├─ [303] Target::n() [staticcall] + │ └─ ← [Return] 8 + ├─ [303] Target::n() [staticcall] + │ └─ ← [Return] 9 + └─ ← [Stop] - // Toggle test optimizer off. - prj.update_config(|config| { - config.dynamic_test_linking = false; - }); - // All files are rebuilt with preprocessed cache false. - cmd.with_no_redact().assert_success().stdout_eq(str![[r#" -... -Compiling 23 files with [..] -... +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) "#]]); -}); +} -// Test cache is invalidated when `forge test` if optimize test option toggled. -forgetest_init!(toggle_invalidate_cache_on_test, |prj, cmd| { +// +#[forgetest] +fn preprocess_remapped_bytecode_dependencies(prj: _, cmd: _) { prj.update_config(|config| { config.dynamic_test_linking = true; + config.remappings = vec!["@p/=src/".parse::().unwrap().into()]; }); - // All files are built with optimized tests. - cmd.args(["test"]).with_no_redact().assert_success().stdout_eq(str![[r#" -... -Compiling 21 files with [..] + let source = r#" +pragma solidity ^0.8.0; +contract Impl { + constructor(uint256) {} + function v() external pure returns (uint256) { return 111; } +} +"#; + prj.add_source("Impl.sol", source); + prj.add_test( + "Impl.t.sol", + r#" +import {Impl} from "@p/Impl.sol"; +contract ImplTest { + function test_new() public { + require(new Impl(1).v() == 111, "stale implementation"); + } + function test_creationCode() public { + bytes memory code = abi.encodePacked(type(Impl).creationCode, abi.encode(uint256(1))); + address deployed; + assembly { deployed := create(0, add(code, 32), mload(code)) } + require(Impl(deployed).v() == 111, "stale implementation"); + } +} +"#, + ); + cmd.env("RUST_LOG", "error"); + cmd.args(["test"]).assert_success().stderr_eq("").stdout_eq(str![[r#" ... +Ran 2 tests for test/Impl.t.sol:ImplTest +[PASS] test_creationCode() ([GAS]) +[PASS] test_new() ([GAS]) +Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 2 tests passed, 0 failed, 0 skipped (2 total tests) "#]]); - // No files are rebuilt. - cmd.with_no_redact().assert_success().stdout_eq(str![[r#" -... + + // A body-only edit must reach both dynamically linked bytecode references. + prj.add_source("Impl.sol", &source.replace("return 111", "return 222")); + prj.forge_command().arg("build").with_no_redact().assert_success().stdout_eq(str![[r#" +Compiling 1 files with [..] +[..] +Compiler run successful! + +"#]]); + cmd.forge_fuse().arg("test").assert_failure().stdout_eq(str![[r#" No files changed, compilation skipped -... +Ran 2 tests for test/Impl.t.sol:ImplTest +[FAIL: stale implementation] test_creationCode() ([GAS]) +[FAIL: stale implementation] test_new() ([GAS]) +Suite result: FAILED. 0 passed; 2 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 2 failed, 0 skipped (2 total tests) + +Failing tests: +Encountered 2 failing tests in test/Impl.t.sol:ImplTest +[FAIL: stale implementation] test_creationCode() ([GAS]) +[FAIL: stale implementation] test_new() ([GAS]) + +Encountered a total of 2 failing tests, 0 tests succeeded +... "#]]); +} - // Toggle test optimizer off. +// +#[forgetest] +fn preprocess_external_bytecode_dependencies(prj: _, cmd: _) { prj.update_config(|config| { - config.dynamic_test_linking = false; + config.dynamic_test_linking = true; + config.remappings = vec!["@dep/=lib/dep/src/".parse::().unwrap().into()]; }); - // All files are rebuilt with preprocessed cache false. - cmd.with_no_redact().assert_success().stdout_eq(str![[r#" + let source = r#" +pragma solidity ^0.8.0; +contract Impl { + function v() external pure returns (uint256) { return 111; } +} +"#; + prj.create_file("lib/dep/src/RemappedImpl.sol", source); + prj.create_file("external/RelativeImpl.sol", source); + prj.add_test( + "ExternalImpl.t.sol", + r#" +pragma solidity ^0.8.0; +import {Impl as RemappedImpl} from "@dep/RemappedImpl.sol"; +import {Impl as RelativeImpl} from "../external/RelativeImpl.sol"; +contract ExternalImplTest { + function test_remapped() public { + require(new RemappedImpl().v() == 111, "stale remapped implementation"); + } + function test_relative() public { + require(new RelativeImpl().v() == 111, "stale relative implementation"); + } +} +"#, + ); + cmd.args(["test"]).assert_success(); + + // External deployments remain native, so body-only edits must rebuild their importer. + let changed = source.replace("return 111", "return 222"); + prj.create_file("lib/dep/src/RemappedImpl.sol", &changed); + prj.create_file("external/RelativeImpl.sol", &changed); + cmd.forge_fuse().arg("test").with_no_redact().assert_failure().stdout_eq(str![[r#" +Compiling 3 files with [..] +[..] +Compiler run successful! ... -Compiling 21 files with [..] +[FAIL: stale relative implementation] test_relative() ([..]) +[FAIL: stale remapped implementation] test_remapped() ([..]) ... - "#]]); -}); +} -// Counter contract without interface instantiated in CounterTest -// -// ├── src -// │ └── Counter.sol -// └── test -// └── Counter.t.sol -forgetest_init!(preprocess_contract_with_no_interface, |prj, cmd| { - prj.wipe_contracts(); +#[forgetest] +fn preprocess_external_dependencies_invalidate_independently(prj: _, cmd: _) { prj.update_config(|config| { config.dynamic_test_linking = true; + config.remappings = vec!["@dep/=lib/dep/src/".parse::().unwrap().into()]; }); - - prj.add_source( - "Counter.sol", + let source = r#" +pragma solidity ^0.8.0; +contract Impl { function v() external pure returns (uint256) { return 111; } } +"#; + prj.create_file("lib/dep/src/RemappedImpl.sol", source); + prj.create_file("external/RelativeImpl.sol", source); + let remapped_test = |expected| { + format!( + r#" +pragma solidity ^0.8.0; +import {{Impl}} from "@dep/RemappedImpl.sol"; +contract RemappedTest {{ + function test_remapped() public {{ + require(new Impl().v() == {expected}, "stale remapped implementation"); + }} +}} +"#, + ) + }; + prj.add_test("Remapped.t.sol", &remapped_test(111)); + prj.add_test( + "Relative.t.sol", r#" -contract Counter { - uint256 public number; - - function setNumber(uint256 newNumber) public { - number = newNumber; +pragma solidity ^0.8.0; +import {Impl} from "../external/RelativeImpl.sol"; +contract RelativeTest { + function test_relative() public { + require(new Impl().v() == 111, "stale relative implementation"); } +} +"#, + ); + cmd.args(["test"]).assert_success(); - function increment() public { - number++; - } + let changed = source.replace("return 111", "return 222"); + prj.create_file("lib/dep/src/RemappedImpl.sol", &changed); + cmd.forge_fuse().arg("test").assert_failure().stdout_eq(str![[r#" +... +[FAIL: stale remapped implementation] test_remapped() ([..]) +... +"#]]); + + // Make the first importer green without touching the second, then verify the relative edge. + prj.add_test("Remapped.t.sol", &remapped_test(222)); + prj.create_file("external/RelativeImpl.sol", &changed); + cmd.forge_fuse().arg("test").assert_failure().stdout_eq(str![[r#" +... +[FAIL: stale relative implementation] test_relative() ([..]) +... +"#]]); } - "#, - ) - .unwrap(); +#[forgetest] +fn preprocess_native_bytecode_forms(prj: _, cmd: _) { + prj.update_config(|config| config.dynamic_test_linking = true); + let source = r#" +pragma solidity ^0.8.0; +contract Impl { + function v() external pure returns (uint256) { return 111; } +} +"#; + prj.add_source("Impl.sol", source); prj.add_test( - "Counter.t.sol", + "Native.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(); - counter.setNumber(0); +pragma solidity ^0.8.0; +import {Impl} from "../src/Impl.sol"; +interface Vm { function etch(address, bytes calldata) external; } +function make() returns (Impl) { return new Impl(); } +contract NativeTest { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + function test_runtime_code() public { + address target = address(0xBEEF); + vm.etch(target, type(Impl).runtimeCode); + (bool ok, bytes memory out) = target.staticcall(abi.encodeCall(Impl.v, ())); + require(ok && abi.decode(out, (uint256)) == 111, "stale runtime bytecode"); } - - function test_Increment() public { - counter.increment(); - assertEq(counter.number(), 1); + function test_free_function() public { + require(make().v() == 111, "stale free function bytecode"); } +} +"#, + ); + cmd.args(["test"]).assert_success(); - function test_SetNumber() public { - counter.setNumber(1); - assertEq(counter.number(), 1); + prj.add_source("Impl.sol", &source.replace("return 111", "return 222")); + cmd.forge_fuse().arg("test").assert_failure().stdout_eq(str![[r#" +... +[FAIL: stale free function bytecode] test_free_function() ([..]) +[FAIL: stale runtime bytecode] test_runtime_code() ([..]) +... +"#]]); +} + +#[forgetest] +fn preprocess_same_file_free_function_dependency(prj: _, cmd: _) { + prj.update_config(|config| config.dynamic_test_linking = true); + let source = r#" +pragma solidity ^0.8.0; +contract Impl { function v() external pure returns (uint256) { return 111; } } +"#; + prj.add_source("Impl.sol", source); + prj.add_test( + "FreeFunction.t.sol", + r#" +pragma solidity ^0.8.0; +import {Impl} from "../src/Impl.sol"; +function make() returns (Impl) { return new Impl(); } +contract FreeFunctionTest { + function test_free_function() public { + require(make().v() == 111, "stale free function bytecode"); } } - "#, - ) - .unwrap(); - // All 20 files are compiled on first run. - cmd.args(["test"]).with_no_redact().assert_success().stdout_eq(str![[r#" +"#, + ); + cmd.args(["test"]).assert_success(); + + prj.add_source("Impl.sol", &source.replace("return 111", "return 222")); + cmd.forge_fuse().arg("test").assert_failure().stdout_eq(str![[r#" ... -Compiling 21 files with [..] +[FAIL: stale free function bytecode] test_free_function() ([..]) ... - "#]]); +} - // Change Counter implementation to fail both tests. - prj.add_source( - "Counter.sol", +#[forgetest] +fn preprocess_script_native_dependency(prj: _, cmd: _) { + prj.update_config(|config| config.dynamic_test_linking = true); + let source = r#" +pragma solidity ^0.8.0; +contract Impl { function v() external pure returns (uint256) { return 111; } } +"#; + prj.add_source("Impl.sol", source); + prj.add_script( + "Native.s.sol", r#" -contract Counter { - uint256 public number; - - function setNumber(uint256 newNumber) public { - number = 12345; +pragma solidity ^0.8.0; +import {Impl} from "../src/Impl.sol"; +contract NativeScript { + function run() public { + require(new Impl().v() == 111, "stale script bytecode"); } +} +"#, + ); + cmd.args(["script", "script/Native.s.sol:NativeScript"]).assert_success(); - function increment() public { - number++; - number++; + prj.add_source("Impl.sol", &source.replace("return 111", "return 222")); + cmd.forge_fuse().args(["script", "script/Native.s.sol:NativeScript"]).assert_failure(); +} + +#[forgetest] +fn preprocess_imported_free_function_dependency(prj: _, cmd: _) { + prj.update_config(|config| config.dynamic_test_linking = true); + let source = r#" +pragma solidity ^0.8.0; +contract Impl { function v() external pure returns (uint256) { return 111; } } +"#; + prj.add_source("Impl.sol", source); + prj.add_source( + "Factory.sol", + r#" +pragma solidity ^0.8.0; +import {Impl} from "./Impl.sol"; +/* +This padding deliberately places the imported function's expression beyond the end of the test +source. Recursive dependency analysis must use the callee's source map rather than slicing the +importer with the callee's offsets. +.................................................................................................... +.................................................................................................... +.................................................................................................... +*/ +function make() returns (Impl) { return new Impl(); } +"#, + ); + prj.add_test( + "FreeFunction.t.sol", + r#" +pragma solidity ^0.8.0; +import {make} from "../src/Factory.sol"; +contract FreeFunctionTest { + function test_free_function() public { + require(make().v() == 111, "stale imported free function bytecode"); } } - "#, - ) - .unwrap(); - // Assert that only 1 file is compiled (Counter source contract) and both tests fail. - cmd.with_no_redact().assert_failure().stdout_eq(str![[r#" -... -Compiling 1 files with [..] +"#, + ); + cmd.args(["test"]).assert_success(); + + prj.add_source("Impl.sol", &source.replace("return 111", "return 222")); + cmd.forge_fuse().arg("test").assert_failure().stdout_eq(str![[r#" ... -[FAIL: assertion failed: 12347 != 1] test_Increment() (gas: [..]) -[FAIL: assertion failed: 12345 != 1] test_SetNumber() (gas: [..]) +[FAIL: stale imported free function bytecode] test_free_function() ([..]) ... - "#]]); +} - // Change Counter implementation to fail single test. +#[forgetest] +fn preprocess_function_reference_dependencies(prj: _, cmd: _) { + prj.update_config(|config| config.dynamic_test_linking = true); + let source = r#" +pragma solidity ^0.8.0; +contract Impl { function v() external pure returns (uint256) { return 111; } } +"#; + prj.add_source("Impl.sol", source); prj.add_source( - "Counter.sol", + "Factory.sol", r#" -contract Counter { - uint256 public number; - - function setNumber(uint256 newNumber) public { - number = 1; +pragma solidity ^0.8.0; +import {Impl} from "./Impl.sol"; +function makeRef() returns (Impl) { return new Impl(); } +function make() returns (Impl) { return new Impl(); } +function make(uint256) returns (Impl) { return new Impl(); } +"#, + ); + prj.add_test( + "FunctionReference.t.sol", + r#" +pragma solidity ^0.8.0; +import {Impl} from "../src/Impl.sol"; +import {make, makeRef} from "../src/Factory.sol"; +contract FunctionReferenceTest { + function test_function_reference() public { + function () internal returns (Impl) factory = makeRef; + require(factory().v() == 111, "stale function reference bytecode"); } - - function increment() public { - number++; + function test_overloaded_function() public { + require(make().v() == 111, "stale overloaded function bytecode"); } } - "#, - ) - .unwrap(); - // Assert that only 1 file is compiled (Counter source contract) and only one test fails. - cmd.with_no_redact().assert_failure().stdout_eq(str![[r#" -... -Compiling 1 files with [..] +"#, + ); + cmd.args(["test"]).assert_success(); + + prj.add_source("Impl.sol", &source.replace("return 111", "return 222")); + cmd.forge_fuse().arg("test").assert_failure().stdout_eq(str![[r#" ... -[FAIL: assertion failed: 2 != 1] test_Increment() (gas: [..]) -[PASS] test_SetNumber() (gas: [..]) +[FAIL: stale function reference bytecode] test_function_reference() ([..]) +[FAIL: stale overloaded function bytecode] test_overloaded_function() ([..]) ... - "#]]); -}); - -// Counter contract with interface instantiated in CounterTest -// -// ├── src -// │ ├── Counter.sol -// │ └── interface -// │ └── CounterIf.sol -// └── test -// └── Counter.t.sol -forgetest_init!(preprocess_contract_with_interface, |prj, cmd| { - prj.wipe_contracts(); - prj.update_config(|config| { - config.dynamic_test_linking = true; - }); +} - prj.add_source( - "interface/CounterIf.sol", +#[forgetest] +fn preprocess_shared_free_function_is_not_rewritten(prj: _, cmd: _) { + prj.update_config(|config| config.dynamic_test_linking = true); + prj.add_source("Impl.sol", "contract Impl {}"); + prj.add_test( + "SharedFreeFunction.t.sol", r#" -interface CounterIf { - function number() external returns (uint256); - - function setNumber(uint256 newNumber) external; +pragma solidity ^0.8.0; +import {Impl} from "../src/Impl.sol"; +function make() returns (Impl) { return new Impl(); } +contract FirstTest { + function test_first() public { require(address(make()) != address(0)); } +} +contract SecondTest { + function test_second() public { require(address(make()) != address(0)); } +} +"#, + ); - function increment() external; + cmd.args(["test"]).assert_success(); } - "#, - ) - .unwrap(); - prj.add_source( - "Counter.sol", - r#" -import {CounterIf} from "./interface/CounterIf.sol"; -contract Counter is CounterIf { - uint256 public number; - uint256 public anotherNumber; - function setNumber(uint256 newNumber) public { - number = newNumber; +#[forgetest] +fn preprocess_try_call_option_dependency_is_not_rewritten(prj: _, cmd: _) { + prj.update_config(|config| config.dynamic_test_linking = true); + prj.add_source("Created.sol", "contract Created {}"); + prj.add_source("Salt.sol", "contract Salt {}"); + prj.add_test( + "TryCallOption.t.sol", + r#" +pragma solidity ^0.8.0; +import {Created} from "../src/Created.sol"; +import {Salt} from "../src/Salt.sol"; +contract TryCallOptionTest { + function test_try_call_option() public { + try new Created{salt: keccak256(type(Salt).creationCode)}() returns (Created created) { + require(address(created) != address(0)); + } catch { revert(); } } +} +"#, + ); - function increment() public { - number++; - } + cmd.args(["test"]).assert_success(); } - "#, - ) - .unwrap(); +#[forgetest] +fn preprocess_internal_library_dependency(prj: _, cmd: _) { + prj.update_config(|config| config.dynamic_test_linking = true); + let source = r#" +pragma solidity ^0.8.0; +contract Impl { function v() external pure returns (uint256) { return 111; } } +"#; + prj.add_source("Impl.sol", source); + prj.add_source( + "FactoryLib.sol", + r#" +pragma solidity ^0.8.0; +import {Impl} from "./Impl.sol"; +library FactoryLib { + function make() internal returns (Impl) { return new Impl(); } +} +"#, + ); prj.add_test( - "Counter.t.sol", + "Library.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 = Counter(address(new Counter())); - counter.setNumber(0); +pragma solidity ^0.8.0; +import {FactoryLib} from "../src/FactoryLib.sol"; +contract LibraryTest { + function test_library() public { + require(FactoryLib.make().v() == 111, "stale library bytecode"); } +} +"#, + ); + cmd.args(["test"]).assert_success(); - function test_Increment() public { - counter.increment(); - assertEq(counter.number(), 1); - } + prj.add_source("Impl.sol", &source.replace("return 111", "return 222")); + cmd.forge_fuse().arg("test").assert_failure().stdout_eq(str![[r#" +... +[FAIL: stale library bytecode] test_library() ([..]) +... +"#]]); +} - function test_SetNumber() public { - counter.setNumber(1); - assertEq(counter.number(), 1); +#[forgetest] +fn preprocess_namespace_library_dependency(prj: _, cmd: _) { + prj.update_config(|config| config.dynamic_test_linking = true); + let source = r#" +pragma solidity ^0.8.0; +contract Impl { function v() external pure returns (uint256) { return 111; } } +"#; + prj.add_source("Impl.sol", source); + prj.add_source( + "FactoryLib.sol", + r#" +pragma solidity ^0.8.0; +import {Impl} from "./Impl.sol"; +library FactoryLib { + function make() internal returns (Impl) { return new Impl(); } +} +"#, + ); + prj.add_test( + "NamespaceLibrary.t.sol", + r#" +pragma solidity ^0.8.0; +import "../src/FactoryLib.sol" as Factories; +contract NamespaceLibraryTest { + function test_namespace_library() public { + require( + Factories.FactoryLib.make().v() == 111, + "stale namespace library bytecode" + ); } } - "#, - ) - .unwrap(); - // All 21 files are compiled on first run. - cmd.args(["test"]).with_no_redact().assert_success().stdout_eq(str![[r#" +"#, + ); + cmd.args(["test"]).assert_success(); + + prj.add_source("Impl.sol", &source.replace("return 111", "return 222")); + cmd.forge_fuse().arg("test").assert_failure().stdout_eq(str![[r#" ... -Compiling 22 files with [..] +[FAIL: stale namespace library bytecode] test_namespace_library() ([..]) ... - "#]]); +} - // Change only CounterIf interface. +#[forgetest] +fn preprocess_using_library_dependency(prj: _, cmd: _) { + prj.update_config(|config| config.dynamic_test_linking = true); + let source = r#" +pragma solidity ^0.8.0; +contract Impl { function v() external pure returns (uint256) { return 111; } } +"#; + prj.add_source("Impl.sol", source); prj.add_source( - "interface/CounterIf.sol", + "FactoryLib.sol", r#" -interface CounterIf { - function anotherNumber() external returns (uint256); - - function number() external returns (uint256); - - function setNumber(uint256 newNumber) external; - - function increment() external; +pragma solidity ^0.8.0; +import {Impl} from "./Impl.sol"; +library FactoryLib { + function make(uint256) internal returns (Impl) { return new Impl(); } } - "#, - ) - .unwrap(); - // All 3 files (interface, implementation and test) are compiled. - cmd.with_no_redact().assert_success().stdout_eq(str![[r#" +"#, + ); + prj.add_test( + "UsingLibrary.t.sol", + r#" +pragma solidity ^0.8.0; +import {FactoryLib} from "../src/FactoryLib.sol"; +contract UsingLibraryTest { + using FactoryLib for uint256; + function test_using_library() public { + require(uint256(0).make().v() == 111, "stale using library bytecode"); + } +} +"#, + ); + cmd.args(["test"]).assert_success(); + + prj.add_source("Impl.sol", &source.replace("return 111", "return 222")); + cmd.forge_fuse().arg("test").assert_failure().stdout_eq(str![[r#" ... -Compiling 3 files with [..] +[FAIL: stale using library bytecode] test_using_library() ([..]) ... - "#]]); +} - // Change Counter implementation to fail both tests. +#[forgetest] +fn preprocess_try_constructor_argument_dependencies(prj: _, cmd: _) { + prj.update_config(|config| config.dynamic_test_linking = true); + let source = r#" +pragma solidity ^0.8.0; +contract Impl { function v() external pure returns (uint256) { return 111; } } +"#; + prj.add_source("Impl.sol", source); prj.add_source( - "Counter.sol", + "FactoryLib.sol", r#" -import {CounterIf} from "./interface/CounterIf.sol"; -contract Counter is CounterIf { - uint256 public number; - uint256 public anotherNumber; - - function setNumber(uint256 newNumber) public { - number = 12345; +pragma solidity ^0.8.0; +import {Impl} from "./Impl.sol"; +library FactoryLib { + function make() internal returns (Impl) { return new Impl(); } +} +"#, + ); + prj.add_source( + "Receiver.sol", + r#" +pragma solidity ^0.8.0; +contract Receiver { + uint256 public immutable value; + constructor(uint256 value_) { value = value_; } +} +"#, + ); + prj.add_test( + "TryPlainLibrary.t.sol", + r#" +pragma solidity ^0.8.0; +import {FactoryLib} from "../src/FactoryLib.sol"; +import {Receiver} from "../src/Receiver.sol"; +contract TryPlainLibraryTest { + function test_try_plain_library() public { + try new Receiver(FactoryLib.make().v()) returns (Receiver receiver) { + require(receiver.value() == 111, "stale try plain library bytecode"); + } catch { revert(); } } - - function increment() public { - number++; - number++; +} +"#, + ); + prj.add_test( + "TryNamespaceLibrary.t.sol", + r#" +pragma solidity ^0.8.0; +import "../src/FactoryLib.sol" as Factories; +import {Receiver} from "../src/Receiver.sol"; +contract TryNamespaceLibraryTest { + function test_try_namespace_library() public { + try new Receiver(Factories.FactoryLib.make().v()) returns (Receiver receiver) { + require(receiver.value() == 111, "stale try namespace library bytecode"); + } catch { revert(); } } } - "#, - ) - .unwrap(); - // Assert that only 1 file is compiled (Counter source contract) and both tests fail. - cmd.with_no_redact().assert_failure().stdout_eq(str![[r#" +"#, + ); + cmd.args(["test"]).assert_success(); + + prj.add_source("Impl.sol", &source.replace("return 111", "return 222")); + cmd.forge_fuse().arg("test").assert_failure().stdout_eq(str![[r#" ... -Compiling 1 files with [..] +[FAIL: stale try namespace library bytecode] test_try_namespace_library() ([..]) ... -[FAIL: assertion failed: 12347 != 1] test_Increment() (gas: [..]) -[FAIL: assertion failed: 12345 != 1] test_SetNumber() (gas: [..]) +[FAIL: stale try plain library bytecode] test_try_plain_library() ([..]) ... +"#]]); +} +#[forgetest] +fn preprocess_external_inheritance_dependency(prj: _, cmd: _) { + prj.update_config(|config| config.dynamic_test_linking = true); + let source = r#" +pragma solidity ^0.8.0; +contract Base { function v() public pure returns (uint256) { return 111; } } +"#; + prj.create_file("external/Base.sol", source); + prj.add_test( + "Inheritance.t.sol", + r#" +pragma solidity ^0.8.0; +import {Base} from "../external/Base.sol"; +contract InheritanceTest is Base { + function test_inherited() public pure { + require(v() == 111, "stale inherited bytecode"); + } +} +"#, + ); + cmd.args(["test"]).assert_success(); + + prj.create_file("external/Base.sol", &source.replace("return 111", "return 222")); + cmd.forge_fuse().arg("test").assert_failure().stdout_eq(str![[r#" +... +[FAIL: stale inherited bytecode] test_inherited() ([..]) +... "#]]); -}); +} -// - Counter contract instantiated in CounterMock -// - CounterMock instantiated in CounterTest -// -// ├── src -// │ └── Counter.sol -// └── test -// ├── Counter.t.sol -// └── mock -// └── CounterMock.sol -forgetest_init!(preprocess_mock_without_inheritance, |prj, cmd| { - prj.wipe_contracts(); +#[forgetest] +fn preprocess_expanded_source_context_invalidates_prior_artifacts(prj: _, cmd: _) { + prj.update_config(|config| config.dynamic_test_linking = true); + let source = r#" +pragma solidity ^0.8.0; +contract Impl { function v() external pure returns (uint256) { return 111; } } +"#; + prj.add_source("Impl.sol", source); + prj.create_file("vendor/pkg/src/Impl.sol", source); + prj.add_test( + "A.t.sol", + r#" +pragma solidity ^0.8.0; +import {Impl} from "../src/Impl.sol"; +contract ATest { + function test_a() public { require(new Impl().v() == 111, "stale A bytecode"); } +} +"#, + ); + prj.add_test( + "B.t.sol", + r#" +pragma solidity ^0.8.0; +import {Impl} from "../vendor/pkg/src/Impl.sol"; +contract BTest { function test_b() public { new Impl(); } } +"#, + ); + + cmd.args(["build", "test/A.t.sol"]).assert_success(); + cmd.forge_fuse().args(["build", "test/B.t.sol"]).assert_success(); + + prj.add_source("Impl.sol", &source.replace("return 111", "return 222")); + cmd.forge_fuse().args(["test", "--match-path", "test/A.t.sol"]).assert_failure().stdout_eq( + str![[r#" +... +[FAIL: stale A bytecode] test_a() ([..]) +... +"#]], + ); +} + +#[forgetest] +fn preprocess_nested_absolute_imports(prj: _, cmd: _) { prj.update_config(|config| { config.dynamic_test_linking = true; + config.remappings = vec!["dep/=lib/dep/".parse::().unwrap().into()]; }); + prj.create_file( + "lib/dep/src/Child.sol", + r#" +pragma solidity ^0.8.0; +contract Child {} +"#, + ); + prj.create_file( + "lib/dep/src/Base.sol", + r#" +pragma solidity ^0.8.0; +import "src/Child.sol"; +contract Base { function make() external returns (Child) { return new Child(); } } +"#, + ); + prj.add_test( + "Nested.t.sol", + r#" +pragma solidity ^0.8.0; +import {Base} from "dep/src/Base.sol"; +contract NestedTest { function test_nested() public { new Base(); } } +"#, + ); + + cmd.args(["test"]).assert_success(); +} +#[forgetest] +fn preprocess_analysis_failure_is_conservative(prj: _, cmd: _) { + prj.update_config(|config| config.dynamic_test_linking = true); + let source = r#" +pragma solidity ^0.8.0; +contract Impl { function v() external pure returns (uint256) { return 111; } } +"#; + prj.add_source("Impl.sol", source); prj.add_source( - "Counter.sol", + "Derived.sol", r#" -contract Counter { - uint256 public number; - - function setNumber(uint256 newNumber) public { - number = newNumber; +pragma solidity ^0.8.0; +import {Impl} from "./Impl.sol"; +contract Derived is Impl {} +"#, + ); + prj.add_test( + "Fallback.t.sol", + r#" +pragma solidity ^0.8.0; +import {Impl} from "../src/Impl.sol"; +import {Derived} from "../src/Derived.sol"; +contract FallbackTest { + function test_fallback() public { + new Derived(); + require(new Impl().v() == 111, "stale conservative bytecode"); } +} +"#, + ); + cmd.args(["test"]).assert_success(); - function increment() public { - number++; - } + prj.add_source("Impl.sol", &source.replace("return 111", "return 222")); + cmd.forge_fuse().arg("test").assert_failure().stdout_eq(str![[r#" +... +[FAIL: stale conservative bytecode] test_fallback() ([..]) +... +"#]]); } - "#, - ) - .unwrap(); +// +#[forgetest] +fn preprocess_remapping_identity(prj: _, cmd: _) { + prj.update_config(|config| { + config.dynamic_test_linking = true; + config.remappings = ["@p/=src/../src/", "src/=lib/alternate/"] + .into_iter() + .map(|remapping| remapping.parse::().unwrap().into()) + .collect(); + }); + let source = r#" +contract Impl { + struct Args { uint256 value; } + constructor(Args memory args) {} + function v() external pure returns (uint256) { return 111; } +} +"#; + prj.add_source("Impl.sol", source); + prj.create_file( + "lib/alternate/Impl.sol", + r#" +contract Impl { + struct Args { address value; } + constructor(Args memory args) {} + function v() external pure returns (uint256) { return 999; } +} +"#, + ); prj.add_test( - "mock/CounterMock.sol", + "Impl.t.sol", r#" -import {Test} from "forge-std/Test.sol"; -import {Counter} from "src/Counter.sol"; +import {Impl as Implementation} from "@p/Impl.sol"; +contract ImplTest { + function test_new() public { + require( + new Implementation(Implementation.Args({value: 1})).v() == 111, + "stale implementation" + ); + } +} +contract EmptyTest {} +"#, + ); + cmd.args(["test"]).assert_success(); + + // Ambiguous source-unit references stay native and are invalidated after a body-only edit. + prj.add_source("Impl.sol", &source.replace("return 111", "return 222")); + cmd.forge_fuse().arg("test").with_no_redact().assert_failure().stdout_eq(str![[r#" +Compiling 3 files with [..] +[..] +Compiler run successful! -contract CounterMock { - Counter counter = new Counter(); +Ran 1 test for test/Impl.t.sol:ImplTest +[FAIL: stale implementation] test_new() (gas: [..]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; finished in [..] ([..] CPU time) - function setNumber(uint256 newNumber) public { - counter.setNumber(newNumber); - } +Ran 1 test suite in [..] ([..] CPU time): 0 tests passed, 1 failed, 0 skipped (1 total tests) - function increment() public { - counter.increment(); - } +Failing tests: +Encountered 1 failing test in test/Impl.t.sol:ImplTest +[FAIL: stale implementation] test_new() (gas: [..]) - function number() public returns (uint256) { - return counter.number(); - } +Encountered a total of 1 failing tests, 0 tests succeeded +... +"#]]); } - "#, - ) - .unwrap(); + +// +#[forgetest] +fn preprocess_remapping_context_uses_running_test(prj: _, cmd: _) { + prj.update_config(|config| { + config.dynamic_test_linking = true; + config.remappings = + vec!["test/suite/:src/=lib/alternate/".parse::().unwrap().into()]; + }); + let source = r#" +contract Impl { + function v() external pure returns (uint256) { return 111; } +} +"#; + prj.add_source("Impl.sol", source); + prj.create_file("lib/alternate/Impl.sol", &source.replace("return 111", "return 999")); prj.add_test( - "Counter.t.sol", + "support/Helper.sol", r#" -import {Test} from "forge-std/Test.sol"; -import {CounterMock} from "./mock/CounterMock.sol"; - -contract CounterTest is Test { - CounterMock public counter; - - function setUp() public { - counter = new CounterMock(); - counter.setNumber(0); +import {Impl} from "src/Impl.sol"; +contract Helper { + function create() public returns (Impl) { return new Impl(); } +} +"#, + ); + prj.add_test( + "suite/Impl.t.sol", + r#" +import {Helper} from "../support/Helper.sol"; +contract ImplTest is Helper { + function test_new() public { + require(create().v() == 111, "stale implementation"); } +} +"#, + ); + cmd.args(["test"]).assert_success(); - function test_Increment() public { - counter.increment(); - assertEq(counter.number(), 1); - } + prj.add_source("Impl.sol", &source.replace("return 111", "return 222")); + cmd.forge_fuse().arg("test").assert_failure().stdout_eq(str![[r#" +... +[FAIL: stale implementation] test_new() ([GAS]) +... +"#]]); +} - function test_SetNumber() public { - counter.setNumber(1); - assertEq(counter.number(), 1); +// +#[forgetest] +fn preprocess_remapped_helper_source(prj: _, cmd: _) { + prj.update_config(|config| { + config.dynamic_test_linking = true; + config.remappings = ["@p/=src/", "test/:foundry-pp/=lib/alternate/"] + .into_iter() + .map(|remapping| remapping.parse::().unwrap().into()) + .collect(); + }); + let source = r#" +contract Impl { + constructor(uint256) {} + function v() external pure returns (uint256) { return 111; } +} +"#; + prj.add_source("Impl.sol", source); + prj.add_test( + "Impl.t.sol", + r#" +import {Impl} from "@p/Impl.sol"; +contract ImplTest { + function test_new() public { + require(new Impl(1).v() == 111, "stale implementation"); } } - "#, - ) - .unwrap(); - // 20 files plus one mock file are compiled on first run. - cmd.args(["test"]).with_no_redact().assert_success().stdout_eq(str![[r#" +"#, + ); + cmd.args(["test"]).assert_success(); + + prj.add_source("Impl.sol", &source.replace("return 111", "return 222")); + cmd.forge_fuse().arg("test").assert_failure().stdout_eq(str![[r#" ... -Compiling 22 files with [..] +[FAIL: stale implementation] test_new() ([GAS]) ... - "#]]); +} - // Change Counter contract implementation to fail both tests. +// +#[forgetest] +fn preprocess_ambiguous_artifact_stays_native(prj: _, cmd: _) { + prj.update_config(|config| config.dynamic_test_linking = true); + let source = r#" +contract Impl { + function v() external pure returns (uint256) { return 111; } +} +"#; + prj.add_source("Impl.sol", source); + prj.create_file("vendor/pkg/src/Impl.sol", source); prj.add_source( - "Counter.sol", + "UsesLib.sol", r#" -contract Counter { - uint256 public number; - - function setNumber(uint256 newNumber) public { - number = 12345; - } - - function increment() public { - number++; - number++; +import {Impl as LibImpl} from "vendor/pkg/src/Impl.sol"; +contract UsesLib { + function create() public returns (LibImpl) { return new LibImpl(); } +} +"#, + ); + prj.forge_command().arg("build").assert_success(); + + let test = r#" +import {Impl} from "../src/Impl.sol"; +contract ImplTest { + function test_new() public { + require(new Impl().v() == 111, "stale implementation"); } } - "#, - ) - .unwrap(); - // Assert that only 1 file is compiled (Counter source contract) and both tests fail. - cmd.with_no_redact().assert_failure().stdout_eq(str![[r#" -... -Compiling 1 files with [..] +"#; + prj.add_test("Impl.t.sol", test); + cmd.args(["test"]).assert_success(); + + // A genuinely narrower request must retain the native fallback classification. + prj.add_test("Impl.t.sol", &format!("\n{test}")); + cmd.forge_fuse().args(["build", "test/Impl.t.sol"]).assert_success(); + cmd.forge_fuse().arg("test").assert_success(); + + prj.add_source("Impl.sol", &source.replace("return 111", "return 222")); + cmd.forge_fuse().arg("test").assert_failure().stdout_eq(str![[r#" ... -[FAIL: assertion failed: 12347 != 1] test_Increment() (gas: [..]) -[FAIL: assertion failed: 12345 != 1] test_SetNumber() (gas: [..]) +[FAIL: stale implementation] test_new() ([GAS]) ... - "#]]); +} - // Change CounterMock contract implementation to pass both tests. +// +#[forgetest] +fn preprocess_remapped_mock_inheritance(prj: _, cmd: _) { + prj.update_config(|config| { + config.dynamic_test_linking = true; + config.remappings = vec!["@p/=src/".parse::().unwrap().into()]; + }); + let source = r#" +contract Impl { + function v() public pure returns (uint256) { return 111; } +} +"#; + prj.add_source("Impl.sol", source); prj.add_test( - "mock/CounterMock.sol", + "ImplMock.sol", r#" -import {Test} from "forge-std/Test.sol"; -import {Counter} from "src/Counter.sol"; - -contract CounterMock { - Counter counter = new Counter(); - - function setNumber(uint256 newNumber) public { - } - - function increment() public { - } - - function number() public returns (uint256) { - return 1; +import {Impl} from "@p/Impl.sol"; +contract ImplMock is Impl {} +"#, + ); + prj.add_test( + "Impl.t.sol", + r#" +import {ImplMock} from "./ImplMock.sol"; +contract ImplTest is ImplMock { + function test_inherited() public pure { + require(v() == 111, "stale implementation"); } } - "#, - ) - .unwrap(); - // Assert that mock and test files are compiled and no test fails. - cmd.with_no_redact().assert_success().stdout_eq(str![[r#" -... -Compiling 2 files with [..] -... -[PASS] test_Increment() (gas: [..]) -[PASS] test_SetNumber() (gas: [..]) +"#, + ); + cmd.args(["test"]).assert_success().stdout_eq(str![[r#" ... +Ran 1 test for test/Impl.t.sol:ImplTest +[PASS] test_inherited() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) "#]]); -}); -// - CounterMock contract is Counter contract -// - CounterMock instantiated in CounterTest -// -// ├── src -// │ └── Counter.sol -// └── test -// ├── Counter.t.sol -// └── mock -// └── CounterMock.sol -forgetest_init!(preprocess_mock_with_inheritance, |prj, cmd| { - prj.wipe_contracts(); - prj.update_config(|config| { - config.dynamic_test_linking = true; - }); + // Inherited bytecode stays native, so the test must be rebuilt as well. + prj.add_source("Impl.sol", &source.replace("return 111", "return 222")); + prj.forge_command().arg("build").with_no_redact().assert_success().stdout_eq(str![[r#" +Compiling 3 files with [..] +[..] +Compiler run successful! - prj.add_source( - "Counter.sol", - r#" -contract Counter { - uint256 public number; +"#]]); + cmd.forge_fuse().arg("test").assert_failure().stdout_eq(str![[r#" +No files changed, compilation skipped - function setNumber(uint256 newNumber) public { - number = newNumber; - } +Ran 1 test for test/Impl.t.sol:ImplTest +[FAIL: stale implementation] test_inherited() ([GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] - function increment() public { - number++; - } +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/Impl.t.sol:ImplTest +[FAIL: stale implementation] test_inherited() ([GAS]) + +Encountered a total of 1 failing tests, 0 tests succeeded +... +"#]]); } - "#, - ) - .unwrap(); - prj.add_test( - "mock/CounterMock.sol", - r#" -import {Counter} from "src/Counter.sol"; +// +#[cfg(unix)] +#[forgetest] +fn preprocess_remapped_symlinked_source(prj: _, cmd: _) { + use std::{fs, os::unix::fs::symlink}; -contract CounterMock is Counter { + fs::remove_dir_all(prj.root().join("src")).unwrap(); + fs::create_dir_all(prj.root().join(".shared/src")).unwrap(); + symlink(".shared/src", prj.root().join("src")).unwrap(); + prj.update_config(|config| { + config.dynamic_test_linking = true; + config.remappings = vec!["@p/=src/".parse::().unwrap().into()]; + }); + let source = r#" +contract Impl { + function v() external pure returns (uint256) { return 111; } } - "#, - ) - .unwrap(); +"#; + prj.add_source("Impl.sol", source); prj.add_test( - "Counter.t.sol", + "Impl.t.sol", r#" -import {Test} from "forge-std/Test.sol"; -import {CounterMock} from "./mock/CounterMock.sol"; +import {Impl} from "@p/Impl.sol"; +contract ImplTest { + function test_new() public { + require(new Impl().v() == 111, "stale implementation"); + } +} +"#, + ); + cmd.args(["test"]).assert_success(); -contract CounterTest is Test { - CounterMock public counter; + prj.add_source("Impl.sol", &source.replace("return 111", "return 222")); + cmd.forge_fuse().arg("test").with_no_redact().assert_failure().stdout_eq(str![[r#" +Compiling 3 files with [..] +[..] +Compiler run successful! - function setUp() public { - counter = new CounterMock(); - counter.setNumber(0); - } +Ran 1 test for test/Impl.t.sol:ImplTest +[FAIL: stale implementation] test_new() (gas: [..]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; finished in [..] ([..] CPU time) - function test_Increment() public { - counter.increment(); - assertEq(counter.number(), 1); - } +Ran 1 test suite in [..] ([..] CPU time): 0 tests passed, 1 failed, 0 skipped (1 total tests) - function test_SetNumber() public { - counter.setNumber(1); - assertEq(counter.number(), 1); - } +Failing tests: +Encountered 1 failing test in test/Impl.t.sol:ImplTest +[FAIL: stale implementation] test_new() (gas: [..]) + +Encountered a total of 1 failing tests, 0 tests succeeded +... +"#]]); } - "#, + +#[cfg(unix)] +#[forgetest_init] +fn abi_commands_reuse_preprocessed_cache(prj: _, cmd: _) { + prj.initialize_default_contracts(); + prj.update_config(|config| config.dynamic_test_linking = true); + cmd.arg("build").assert_success(); + // Discovery keeps an unprocessed ABI cache separate from the dynamic-linking artifacts. + cmd.forge_fuse().args(["test", "--match-contract", "CounterTest"]).assert_success(); + + let solc = prj.root().join("fake-solc"); + let invoked = prj.root().join("fake-solc.invoked"); + fs::write( + &solc, + r#"#!/bin/sh +if [ "$1" = "--version" ]; then + echo "solc, the solidity compiler commandline interface" + echo "Version: 0.8.35+commit.69074fbd" + exit 0 +fi +cat > "$0.invoked" +exit 1 +"#, ) .unwrap(); - // 20 files plus one mock file are compiled on first run. - cmd.args(["test"]).with_no_redact().assert_success().stdout_eq(str![[r#" + let mut permissions = fs::metadata(&solc).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&solc, permissions).unwrap(); + prj.update_config(|config| { + config.solc = Some(foundry_config::SolcReq::Local(solc.clone())); + }); + + cmd.forge_fuse().args(["test", "--match-contract", "CounterTest"]).assert_success().stdout_eq( + str![[r#" ... -Compiling 22 files with [..] +Ran 2 tests for test/Counter.t.sol:CounterTest ... +Ran 1 test suite [ELAPSED]: 2 tests passed, 0 failed, 0 skipped (2 total tests) -"#]]); +"#]], + ); + assert!(!invoked.exists(), "filtered test compilation did not reuse cached discovery"); - // Change Counter contract implementation to fail both tests. - prj.add_source( - "Counter.sol", - r#" -contract Counter { - uint256 public number; + cmd.forge_fuse().args(["selectors", "list"]).assert_success(); + assert!(!invoked.exists(), "selector compilation did not reuse the preprocessed cache"); - function setNumber(uint256 newNumber) public virtual { - number = 12345; + // A new, unselected test is available only through discovery's secondary cache. + prj.update_config(|config| { + config.solc = Some(foundry_config::SolcReq::Version( + foundry_test_utils::util::SOLC_VERSION.parse().unwrap(), + )); + }); + prj.add_test("Other.t.sol", "contract OtherTest { function test_other() public {} }"); + cmd.forge_fuse().args(["test", "--match-contract", "CounterTest"]).assert_success(); + let abi_cache = prj.cache().with_file_name("solidity-files-cache.json.abi"); + assert!(abi_cache.is_dir()); + assert!(!prj.artifacts().join("Other.t.sol").exists()); + prj.update_config(|config| { + config.solc = Some(foundry_config::SolcReq::Local(solc.clone())); + }); + cmd.forge_fuse().args(["test", "--match-contract", "CounterTest"]).assert_success(); + assert!(!invoked.exists(), "partial-cache discovery invoked solc"); + + // Editing either a selected or unselected test must leave unrelated ABI entries cached. + // + for name in ["Other.t.sol", "Counter.t.sol"] { + let path = prj.root().join("test").join(name); + let source = fs::read_to_string(&path).unwrap(); + fs::write(&path, format!("{source}\n// edit.\n")).unwrap(); + cmd.forge_fuse().args(["test", "--match-contract", "CounterTest"]).assert_failure(); + let input = serde_json::from_slice::(&fs::read(&invoked).unwrap()).unwrap(); + let tests = + input.sources.keys().filter(|path| path.starts_with("test")).collect::>(); + assert_eq!(tests, vec![&PathBuf::from(format!("test/{name}"))]); + let expected = OutputSelection::common_output_selection(["abi".to_string()]); + assert_eq!(input.settings.output_selection.0[&format!("test/{name}")], expected.0["*"]); + for output in input.settings.output_selection.0.values().flat_map(|s| s.values()).flatten() + { + assert_eq!(output, "abi"); + } + fs::remove_file(&invoked).unwrap(); + + prj.update_config(|config| { + config.solc = Some(foundry_config::SolcReq::Version( + foundry_test_utils::util::SOLC_VERSION.parse().unwrap(), + )); + }); + cmd.forge_fuse().args(["test", "--match-contract", "CounterTest"]).assert_success(); + prj.update_config(|config| { + config.solc = Some(foundry_config::SolcReq::Local(solc.clone())); + }); + cmd.forge_fuse().args(["test", "--match-contract", "CounterTest"]).assert_success(); + assert!(!invoked.exists(), "edited discovery was not cached"); } - function increment() public virtual { - number++; - number++; + // Disabling caching must bypass both stores, even after warming them. + prj.update_config(|config| config.cache = false); + cmd.forge_fuse().args(["test", "--match-contract", "CounterTest"]).assert_failure(); + assert!(invoked.exists(), "cache=false reused cached discovery"); + let input = serde_json::from_slice::(&fs::read(&invoked).unwrap()).unwrap(); + // A bytecode compile could also fail here; prove that discovery itself invoked Solc. + let expected = OutputSelection::common_output_selection(["abi".to_string()]); + assert!(!input.settings.output_selection.0.is_empty()); + for selection in input.settings.output_selection.0.values() { + assert_eq!( + selection, &expected.0["*"], + "cache=false must recompile ABI discovery before attempting bytecode compilation", + ); } + cmd.forge_fuse().arg("clean").assert_success(); + assert!(!abi_cache.exists()); } - "#, - ) - .unwrap(); - // Assert Counter source contract and CounterTest test contract (as it imports mock) are - // compiled and both tests fail. - cmd.with_no_redact().assert_failure().stdout_eq(str![[r#" -... -Compiling 2 files with [..] -... -[FAIL: assertion failed: 12347 != 1] test_Increment() (gas: [..]) -[FAIL: assertion failed: 12345 != 1] test_SetNumber() (gas: [..]) -... -"#]]); +// +#[forgetest_init] +fn filtered_tests_compile_unimported_test_fixtures(prj: _, cmd: _) { + prj.update_config(|config| config.solc = None); + prj.add_raw_test( + "fixtures/Fixture.sol", + r#" +pragma solidity 0.7.6; - // Change mock implementation to pass both tests. +contract Fixture { + function version() external pure returns (uint256) { + return 1; + } +} +"#, + ); prj.add_test( - "mock/CounterMock.sol", + "Fixture.t.sol", r#" -import {Counter} from "src/Counter.sol"; +import {Test} from "forge-std/Test.sol"; -contract CounterMock is Counter { - function setNumber(uint256 newNumber) public override { - number = newNumber; - } +interface IFixture { + function version() external pure returns (uint256); +} - function increment() public override { - number++; +contract FixtureTest is Test { + function testFixture() public { + address fixture = vm.deployCode("test/fixtures/Fixture.sol:Fixture"); + assertEq(IFixture(fixture).version(), 1); } } - "#, - ) - .unwrap(); - // Assert that CounterMock and CounterTest files are compiled and no test fails. - cmd.with_no_redact().assert_success().stdout_eq(str![[r#" -... -Compiling 2 files with [..] -... -[PASS] test_Increment() (gas: [..]) -[PASS] test_SetNumber() (gas: [..]) +"#, + ); + + cmd.args(["test", "--match-contract", "FixtureTest"]).assert_success().stdout_eq(str![[r#" ... +Ran 1 test for test/Fixture.t.sol:FixtureTest +[PASS] testFixture() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] -"#]]); -}); +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) -// - CounterMock contract is Counter contract -// - CounterMock instantiated in CounterTest -// -// ├── src -// │ └── Counter.sol -// └── test -// ├── Counter.t.sol -// └── mock -// └── CounterMock.sol -forgetest_init!(preprocess_mock_to_non_mock, |prj, cmd| { - prj.wipe_contracts(); - prj.update_config(|config| { - config.dynamic_test_linking = true; - }); +"#]]); - prj.add_source( - "Counter.sol", + prj.add_raw_test( + "fixtures/Fixture.sol", r#" -contract Counter { - uint256 public number; +pragma solidity 0.7.6; - function setNumber(uint256 newNumber) public { - number = newNumber; +contract Fixture { + function version() external pure returns (uint256) { + return 2; } +} +"#, + ); + prj.add_test( + "Fixture.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; - function increment() public { - number++; +interface IFixture { + function version() external pure returns (uint256); +} + +contract FixtureTest is Test { + function testFixture() public { + address fixture = vm.deployCode("test/fixtures/Fixture.sol:Fixture"); + assertEq(IFixture(fixture).version(), 2); } } - "#, - ) - .unwrap(); +"#, + ); - prj.add_test( - "mock/CounterMock.sol", - r#" -import {Counter} from "src/Counter.sol"; + cmd.assert_success().stdout_eq(str![[r#" +... +Ran 1 test for test/Fixture.t.sol:FixtureTest +[PASS] testFixture() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] -contract CounterMock is Counter { +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); } - "#, - ) - .unwrap(); + +// +#[forgetest_init] +fn path_filtered_tests_compile_unimported_test_fixtures(prj: _, cmd: _) { + prj.update_config(|config| { + config.solc = None; + config.dynamic_test_linking = false; + }); + prj.add_raw_script("Broken.s.sol", "this is not valid Solidity"); + prj.add_raw_test( + "fixtures/Fixture.sol", + r#" +pragma solidity 0.7.6; + +contract Fixture {} +"#, + ); prj.add_test( - "Counter.t.sol", + "Fixture.t.sol", r#" import {Test} from "forge-std/Test.sol"; -import {CounterMock} from "./mock/CounterMock.sol"; - -contract CounterTest is Test { - CounterMock public counter; - function setUp() public { - counter = new CounterMock(); - counter.setNumber(0); +contract FixtureTest is Test { + function testFixture() public { + assertGt(vm.getCode("test/fixtures/Fixture.sol:Fixture").length, 0); } +} +"#, + ); - function test_Increment() public { - counter.increment(); - assertEq(counter.number(), 1); + cmd.args(["test", "--match-path", "test/Fixture.t.sol"]).assert_success().stdout_eq(str![[ + r#" +... +Ran 1 test for test/Fixture.t.sol:FixtureTest +[PASS] testFixture() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"# + ]]); +} + +// +#[forgetest_init] +fn filtered_tests_preserve_compilation_restrictions(prj: _, cmd: _) { + prj.wipe_contracts(); + prj.add_lib( + "dep/src/Clz.sol", + r#" +library Clz { + function msb(uint128 bitmap) internal pure returns (uint256 res) { + assembly { + res := sub(255, clz(bitmap)) + } } +} +"#, + ); + prj.add_source( + "Root.sol", + r#" +import "../lib/dep/src/Clz.sol"; - function test_SetNumber() public { - counter.setNumber(1); - assertEq(counter.number(), 1); +contract Root { + function msb(uint128 bitmap) external pure returns (uint256) { + return Clz.msb(bitmap); } } - "#, - ) - .unwrap(); - // 20 files plus one mock file are compiled on first run. - cmd.args(["test"]).with_no_redact().assert_success().stdout_eq(str![[r#" -... -Compiling 22 files with [..] -... +"#, + ); + prj.add_test("RootTest.sol", "contract RootTest { function testFoo() public pure {} }"); + prj.update_config(|config| { + config.evm_version = EvmVersion::Prague; + config.additional_compiler_profiles = vec![SettingsOverrides { + name: "osaka".to_string(), + via_ir: None, + evm_version: Some(EvmVersion::Osaka), + optimizer: None, + optimizer_runs: None, + bytecode_hash: None, + }]; + config.compilation_restrictions = vec![CompilationRestrictions { + paths: "src/Root.sol".parse().unwrap(), + version: None, + via_ir: None, + bytecode_hash: None, + min_optimizer_runs: None, + optimizer_runs: None, + max_optimizer_runs: None, + min_evm_version: None, + evm_version: Some(EvmVersion::Osaka), + max_evm_version: None, + }]; + }); -"#]]); - cmd.with_no_redact().assert_success().stdout_eq(str![[r#" + cmd.args(["test", "--match-path", "test/RootTest.sol"]).assert_success().stdout_eq(str![[r#" ... -No files changed, compilation skipped +Ran 1 test for test/RootTest.sol:RootTest +[PASS] testFoo() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] ... - "#]]); +} - // Change mock implementation to fail tests, no inherit from Counter. +#[forgetest_init] +fn filtered_tests_support_overlapping_source_roots(prj: _, cmd: _) { + prj.update_config(|config| { + config.script = ".".into(); + config.dynamic_test_linking = true; + }); + prj.add_source("SourceFixture.sol", "contract SourceFixture {}"); + prj.add_source( + "Child.sol", + "contract Child { function value() external pure returns (uint256) { return 1; } }", + ); + prj.add_source( + "Factory.sol", + "import {Child} from './Child.sol'; contract Factory { function create() external returns (Child) { return new Child(); } }", + ); + prj.add_test("fixtures/Fixture.sol", "contract Fixture {}"); prj.add_test( - "mock/CounterMock.sol", + "Fixture.t.sol", r#" import {Test} from "forge-std/Test.sol"; -import {Counter} from "src/Counter.sol"; - -contract CounterMock { - uint256 public number; - function setNumber(uint256 newNumber) public { - number = 1234; +import {Child} from "../src/Child.sol"; +import {Factory} from "../src/Factory.sol"; + +contract FixtureTest is Test { + function testFixture() public { + assertGt(vm.getCode("test/fixtures/Fixture.sol:Fixture").length, 0); + assertGt(vm.getCode("src/SourceFixture.sol:SourceFixture").length, 0); + Factory factory = new Factory(); + Child child = factory.create(); + assertEq(child.value(), 1); } +} +"#, + ); - function increment() public { - number = 5678; - } + cmd.args(["test", "--match-contract", "FixtureTest"]).assert_success().stdout_eq(str![[r#" +... +Ran 1 test for test/Fixture.t.sol:FixtureTest +[PASS] testFixture() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); } - "#, - ) - .unwrap(); - // Assert that CounterMock and CounterTest files are compiled and tests fail. - cmd.with_no_redact().assert_failure().stdout_eq(str![[r#" + +// Test cache is invalidated when `forge build` if optimize test option toggled. +#[forgetest_init] +fn toggle_invalidate_cache_on_build(prj: _, cmd: _) { + prj.initialize_default_contracts(); + prj.update_config(|config| { + config.dynamic_test_linking = true; + }); + // All files are built with optimized tests. + cmd.args(["build"]).with_no_redact().assert_success().stdout_eq(str![[r#" ... -Compiling 2 files with [..] +Compiling 23 files with [..] ... -[FAIL: assertion failed: 5678 != 1] test_Increment() (gas: [..]) -[FAIL: assertion failed: 1234 != 1] test_SetNumber() (gas: [..]) + +"#]]); + // No files are rebuilt. + cmd.with_no_redact().assert_success().stdout_eq(str![[r#" +... +No files changed, compilation skipped ... "#]]); -}); -// ├── src -// │ ├── CounterA.sol -// │ ├── CounterB.sol -// │ ├── Counter.sol -// │ └── v1 -// │ └── Counter.sol -// └── test -// └── Counter.t.sol -forgetest_init!(preprocess_multiple_contracts_with_constructors, |prj, cmd| { - prj.wipe_contracts(); + // Toggle test optimizer off. + prj.update_config(|config| { + config.dynamic_test_linking = false; + }); + // All files are rebuilt with preprocessed cache false. + cmd.with_no_redact().assert_success().stdout_eq(str![[r#" +... +Compiling 23 files with [..] +... + +"#]]); +} + +// Test cache is invalidated when `forge test` if optimize test option toggled. +#[forgetest_init] +fn toggle_invalidate_cache_on_test(prj: _, cmd: _) { + prj.initialize_default_contracts(); prj.update_config(|config| { config.dynamic_test_linking = true; }); + // All files are built with optimized tests. + cmd.args(["test"]).with_no_redact().assert_success().stdout_eq(str![[r#" +... +Compiling 21 files with [..] +... + +"#]]); + // No files are rebuilt. + cmd.with_no_redact().assert_success().stdout_eq(str![[r#" +... +No files changed, compilation skipped +... + +"#]]); + + // Toggle test optimizer off. + prj.update_config(|config| { + config.dynamic_test_linking = false; + }); + // All files are rebuilt with preprocessed cache false. + cmd.with_no_redact().assert_success().stdout_eq(str![[r#" +... +Compiling 21 files with [..] +... + +"#]]); +} +// +#[forgetest_init] +fn unchecked_artifacts_support_dynamic_linking(prj: _, cmd: _) { + prj.update_config(|config| { + config.dynamic_test_linking = true; + config.unchecked_cheatcode_artifacts = true; + }); prj.add_source( "Counter.sol", r#" +library Math { + function double(uint256 x) public pure returns (uint256) { + return x * 2; + } +} + contract Counter { uint256 public number; - function setNumber(uint256 newNumber) public { - number = newNumber; - } - - function increment() public { - number++; + constructor(uint256 number_) { + number = Math.double(number_); } } - "#, - ) - .unwrap(); +"#, + ); prj.add_source( - "CounterA.sol", + "nested/Counter.sol", r#" -contract CounterA { - uint256 public number; - address public owner; - - constructor(uint256 _newNumber, address _owner) { - number = _newNumber; - owner = _owner; +library Math { + function triple(uint256 x) public pure returns (uint256) { + return x * 3; } +} - function increment() public { - number++; +contract Counter { + uint256 public number; + + constructor(uint256 number_) { + number = Math.triple(number_); } } - "#, - ) - .unwrap(); - // Contract with constructor args without name. - prj.add_source( - "CounterB.sol", +"#, + ); + prj.add_test( + "Counter.t.sol", r#" -contract CounterB { - uint256 public number; +import {Test} from "forge-std/Test.sol"; +import {Counter as DoubleCounter} from "../src/Counter.sol"; +import {Counter as TripleCounter} from "../src/nested/Counter.sol"; - constructor(uint256) { - number = 1; +contract CounterTest is Test { + function testNew() public { + DoubleCounter doubleCounter = new DoubleCounter(21); + TripleCounter tripleCounter = new TripleCounter(21); + assertEq(doubleCounter.number(), 42); + assertEq(tripleCounter.number(), 63); } +} +"#, + ); - function increment() public { - number++; - } + cmd.args(["test", "--match-test", "testNew"]).assert_success().stdout_eq(str![[r#" +... +Ran 1 test for test/Counter.t.sol:CounterTest +[PASS] testNew() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); } - "#, - ) - .unwrap(); + +// Counter contract without interface instantiated in CounterTest +// +// ├── src +// │ └── Counter.sol +// └── test +// └── Counter.t.sol +#[forgetest_init] +fn preprocess_contract_with_no_interface(prj: _, cmd: _) { + prj.update_config(|config| { + config.dynamic_test_linking = true; + }); + prj.add_source( - "v1/Counter.sol", + "Counter.sol", r#" contract Counter { uint256 public number; - constructor(uint256 _number) { - number = _number; + function setNumber(uint256 newNumber) public { + number = newNumber; } function increment() public { @@ -825,141 +1709,136 @@ contract Counter { } } "#, - ) - .unwrap(); + ); prj.add_test( "Counter.t.sol", r#" import {Test} from "forge-std/Test.sol"; -import {Counter} from "src/Counter.sol"; -import "src/CounterA.sol"; -import "src/CounterB.sol"; -import {Counter as CounterV1} from "src/v1/Counter.sol"; +import {Counter} from "../src/Counter.sol"; contract CounterTest is Test { - function test_Increment_In_Counter() public { - Counter counter = new Counter(); - counter.increment(); - assertEq(counter.number(), 1); - } - - function test_Increment_In_Counter_V1() public { - CounterV1 counter = new CounterV1(1234); - counter.increment(); - assertEq(counter.number(), 1235); - } + Counter public counter; - function test_Increment_In_Counter_A() public { - CounterA counter = new CounterA(1234, address(this)); - counter.increment(); - assertEq(counter.number(), 1235); + function setUp() public { + counter = new Counter(); + counter.setNumber(0); } - function test_Increment_In_Counter_A_with_named_args() public { - CounterA counter = new CounterA({_newNumber: 1234, _owner: address(this)}); + function test_Increment() public { counter.increment(); - assertEq(counter.number(), 1235); + assertEq(counter.number(), 1); } - function test_Increment_In_Counter_B() public { - CounterB counter = new CounterB(1234); - counter.increment(); - assertEq(counter.number(), 2); + function test_SetNumber() public { + counter.setNumber(1); + assertEq(counter.number(), 1); } } "#, - ) - .unwrap(); - // 22 files plus one mock file are compiled on first run. + ); + // All files are compiled on first run. cmd.args(["test"]).with_no_redact().assert_success().stdout_eq(str![[r#" ... -Compiling 24 files with [..] -... -[PASS] test_Increment_In_Counter() (gas: [..]) -[PASS] test_Increment_In_Counter_A() (gas: [..]) -[PASS] test_Increment_In_Counter_A_with_named_args() (gas: [..]) -[PASS] test_Increment_In_Counter_B() (gas: [..]) -[PASS] test_Increment_In_Counter_V1() (gas: [..]) +Compiling 21 files with [..] ... "#]]); - // Change v1/Counter to fail test. + // Change Counter implementation to fail both tests. prj.add_source( - "v1/Counter.sol", + "Counter.sol", r#" contract Counter { uint256 public number; - constructor(uint256 _number) { - number = _number; + function setNumber(uint256 newNumber) public { + number = 12345; } function increment() public { - number = 12345; + number++; + number++; } } "#, - ) - .unwrap(); - // Only v1/Counter should be compiled and test should fail. + ); + // Assert that only 1 file is compiled (Counter source contract) and both tests fail. cmd.with_no_redact().assert_failure().stdout_eq(str![[r#" ... Compiling 1 files with [..] ... -[PASS] test_Increment_In_Counter() (gas: [..]) -[PASS] test_Increment_In_Counter_A() (gas: [..]) -[PASS] test_Increment_In_Counter_A_with_named_args() (gas: [..]) -[PASS] test_Increment_In_Counter_B() (gas: [..]) -[FAIL: assertion failed: 12345 != 1235] test_Increment_In_Counter_V1() (gas: [..]) +[FAIL: assertion failed: 12347 != 1] test_Increment() (gas: [..]) +[FAIL: assertion failed: 12345 != 1] test_SetNumber() (gas: [..]) ... "#]]); - // Change CounterA to fail test. + // Change Counter implementation to fail single test. prj.add_source( - "CounterA.sol", + "Counter.sol", r#" -contract CounterA { +contract Counter { uint256 public number; - address public owner; - constructor(uint256 _newNumber, address _owner) { - number = _newNumber; - owner = _owner; + function setNumber(uint256 newNumber) public { + number = 1; } function increment() public { - number = 12345; + number++; } } "#, - ) - .unwrap(); - // Only CounterA should be compiled and test should fail. + ); + // Assert that only 1 file is compiled (Counter source contract) and only one test fails. cmd.with_no_redact().assert_failure().stdout_eq(str![[r#" ... Compiling 1 files with [..] ... -[PASS] test_Increment_In_Counter() (gas: [..]) -[FAIL: assertion failed: 12345 != 1235] test_Increment_In_Counter_A() (gas: [..]) -[FAIL: assertion failed: 12345 != 1235] test_Increment_In_Counter_A_with_named_args() (gas: [..]) -[PASS] test_Increment_In_Counter_B() (gas: [..]) -[FAIL: assertion failed: 12345 != 1235] test_Increment_In_Counter_V1() (gas: [..]) +[FAIL: assertion failed: 2 != 1] test_Increment() (gas: [..]) +[PASS] test_SetNumber() (gas: [..]) ... "#]]); +} + +// Counter contract with interface instantiated in CounterTest +// +// ├── src +// │ ├── Counter.sol +// │ └── interface +// │ └── CounterIf.sol +// └── test +// └── Counter.t.sol +#[forgetest_init] +fn preprocess_contract_with_interface(prj: _, cmd: _) { + prj.update_config(|config| { + config.dynamic_test_linking = true; + }); - // Change CounterB to fail test. prj.add_source( - "CounterB.sol", + "interface/CounterIf.sol", r#" -contract CounterB { +interface CounterIf { + function number() external returns (uint256); + + function setNumber(uint256 newNumber) external; + + function increment() external; +} + "#, + ); + prj.add_source( + "Counter.sol", + r#" +import {CounterIf} from "./interface/CounterIf.sol"; +contract Counter is CounterIf { uint256 public number; + uint256 public anotherNumber; - constructor(uint256) { - number = 100; + function setNumber(uint256 newNumber) public { + number = newNumber; } function increment() public { @@ -967,58 +1846,108 @@ contract CounterB { } } "#, - ) - .unwrap(); - // Only CounterB should be compiled and test should fail. - cmd.with_no_redact().assert_failure().stdout_eq(str![[r#" + ); + + prj.add_test( + "Counter.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 = Counter(address(new Counter())); + counter.setNumber(0); + } + + function test_Increment() public { + counter.increment(); + assertEq(counter.number(), 1); + } + + function test_SetNumber() public { + counter.setNumber(1); + assertEq(counter.number(), 1); + } +} + "#, + ); + // All 21 files are compiled on first run. + cmd.args(["test"]).with_no_redact().assert_success().stdout_eq(str![[r#" ... -Compiling 1 files with [..] +Compiling 22 files with [..] ... -[PASS] test_Increment_In_Counter() (gas: [..]) -[FAIL: assertion failed: 12345 != 1235] test_Increment_In_Counter_A() (gas: [..]) -[FAIL: assertion failed: 12345 != 1235] test_Increment_In_Counter_A_with_named_args() (gas: [..]) -[FAIL: assertion failed: 101 != 2] test_Increment_In_Counter_B() (gas: [..]) -[FAIL: assertion failed: 12345 != 1235] test_Increment_In_Counter_V1() (gas: [..]) + +"#]]); + + // Change only CounterIf interface. + prj.add_source( + "interface/CounterIf.sol", + r#" +interface CounterIf { + function anotherNumber() external returns (uint256); + + function number() external returns (uint256); + + function setNumber(uint256 newNumber) external; + + function increment() external; +} + "#, + ); + // All 3 files (interface, implementation and test) are compiled. + cmd.with_no_redact().assert_success().stdout_eq(str![[r#" +... +Compiling 3 files with [..] ... "#]]); - // Change Counter to fail test. + // Change Counter implementation to fail both tests. prj.add_source( "Counter.sol", r#" -contract Counter { +import {CounterIf} from "./interface/CounterIf.sol"; +contract Counter is CounterIf { uint256 public number; + uint256 public anotherNumber; function setNumber(uint256 newNumber) public { - number = newNumber; + number = 12345; } function increment() public { - number = 12345; + number++; + number++; } } "#, - ) - .unwrap(); - // Only Counter should be compiled and test should fail. + ); + // Assert that only 1 file is compiled (Counter source contract) and both tests fail. cmd.with_no_redact().assert_failure().stdout_eq(str![[r#" ... Compiling 1 files with [..] ... -[FAIL: assertion failed: 12345 != 1] test_Increment_In_Counter() (gas: [..]) -[FAIL: assertion failed: 12345 != 1235] test_Increment_In_Counter_A() (gas: [..]) -[FAIL: assertion failed: 12345 != 1235] test_Increment_In_Counter_A_with_named_args() (gas: [..]) -[FAIL: assertion failed: 101 != 2] test_Increment_In_Counter_B() (gas: [..]) -[FAIL: assertion failed: 12345 != 1235] test_Increment_In_Counter_V1() (gas: [..]) +[FAIL: assertion failed: 12347 != 1] test_Increment() (gas: [..]) +[FAIL: assertion failed: 12345 != 1] test_SetNumber() (gas: [..]) ... "#]]); -}); +} -// Test preprocessing contracts with payable constructor, value and salt named args. -forgetest_init!(preprocess_contracts_with_payable_constructor_and_salt, |prj, cmd| { - prj.wipe_contracts(); +// - Counter contract instantiated in CounterMock +// - CounterMock instantiated in CounterTest +// +// ├── src +// │ └── Counter.sol +// └── test +// ├── Counter.t.sol +// └── mock +// └── CounterMock.sol +#[forgetest_init] +fn preprocess_mock_without_inheritance(prj: _, cmd: _) { prj.update_config(|config| { config.dynamic_test_linking = true; }); @@ -1029,10 +1958,6 @@ forgetest_init!(preprocess_contracts_with_payable_constructor_and_salt, |prj, cm contract Counter { uint256 public number; - constructor(uint256 _number) payable { - number = msg.value; - } - function setNumber(uint256 newNumber) public { number = newNumber; } @@ -1042,133 +1967,139 @@ contract Counter { } } "#, - ) - .unwrap(); - prj.add_source( - "CounterWithSalt.sol", + ); + + prj.add_test( + "mock/CounterMock.sol", r#" -contract CounterWithSalt { - uint256 public number; +import {Test} from "forge-std/Test.sol"; +import {Counter} from "src/Counter.sol"; - constructor(uint256 _number) payable { - number = msg.value; - } +contract CounterMock { + Counter counter = new Counter(); function setNumber(uint256 newNumber) public { - number = newNumber; + counter.setNumber(newNumber); } function increment() public { - number++; + counter.increment(); + } + + function number() public returns (uint256) { + return counter.number(); } } "#, - ) - .unwrap(); - + ); prj.add_test( "Counter.t.sol", r#" import {Test} from "forge-std/Test.sol"; -import {Counter} from "src/Counter.sol"; -import {CounterWithSalt} from "src/CounterWithSalt.sol"; +import {CounterMock} from "./mock/CounterMock.sol"; contract CounterTest is Test { - function test_Increment_In_Counter() public { - Counter counter = Counter(address(new Counter{value: 111}(1))); + CounterMock public counter; + + function setUp() public { + counter = new CounterMock(); + counter.setNumber(0); + } + + function test_Increment() public { counter.increment(); - assertEq(counter.number(), 112); + assertEq(counter.number(), 1); } - function test_Increment_In_Counter_With_Salt() public { - CounterWithSalt counter = new CounterWithSalt{value: 111, salt: bytes32("preprocess_counter_with_salt")}(1); - assertEq(address(counter), 0x223e63BE3BF01DD04f852d70f1bE217017055f49); + function test_SetNumber() public { + counter.setNumber(1); + assertEq(counter.number(), 1); } } "#, - ) - .unwrap(); - + ); + // 20 files plus one mock file are compiled on first run. cmd.args(["test"]).with_no_redact().assert_success().stdout_eq(str![[r#" ... Compiling 22 files with [..] ... -[PASS] test_Increment_In_Counter() (gas: [..]) -[PASS] test_Increment_In_Counter_With_Salt() (gas: [..]) -... "#]]); - // Change contract to fail test. + // Change Counter contract implementation to fail both tests. prj.add_source( "Counter.sol", r#" contract Counter { uint256 public number; - constructor(uint256 _number) payable { - number = msg.value + _number; - } - function setNumber(uint256 newNumber) public { - number = newNumber; + number = 12345; } function increment() public { number++; + number++; } } "#, - ) - .unwrap(); - // Only Counter should be compiled and test should fail. + ); + // Assert that only 1 file is compiled (Counter source contract) and both tests fail. cmd.with_no_redact().assert_failure().stdout_eq(str![[r#" ... Compiling 1 files with [..] ... -[FAIL: assertion failed: 113 != 112] test_Increment_In_Counter() (gas: [..]) -[PASS] test_Increment_In_Counter_With_Salt() (gas: [..]) +[FAIL: assertion failed: 12347 != 1] test_Increment() (gas: [..]) +[FAIL: assertion failed: 12345 != 1] test_SetNumber() (gas: [..]) ... "#]]); - // Change contract with salt to fail test too. - prj.add_source( - "CounterWithSalt.sol", + // Change CounterMock contract implementation to pass both tests. + prj.add_test( + "mock/CounterMock.sol", r#" -contract CounterWithSalt { - uint256 public number; +import {Test} from "forge-std/Test.sol"; +import {Counter} from "src/Counter.sol"; - constructor(uint256 _number) payable { - number = msg.value + _number; - } +contract CounterMock { + Counter counter = new Counter(); function setNumber(uint256 newNumber) public { - number = newNumber; } function increment() public { - number++; + } + + function number() public returns (uint256) { + return 1; } } "#, - ) - .unwrap(); - // Only Counter should be compiled and test should fail. - cmd.with_no_redact().assert_failure().stdout_eq(str![[r#" + ); + // Assert that mock and test files are compiled and no test fails. + cmd.with_no_redact().assert_success().stdout_eq(str![[r#" ... -Compiling 1 files with [..] +Compiling 2 files with [..] ... -[FAIL: assertion failed: 113 != 112] test_Increment_In_Counter() (gas: [..]) -[FAIL: assertion failed: 0x11acEfcD29A1BA964A05C0E7F3901054BEfb17c0 != 0x223e63BE3BF01DD04f852d70f1bE217017055f49] test_Increment_In_Counter_With_Salt() (gas: [..]) +[PASS] test_Increment() (gas: [..]) +[PASS] test_SetNumber() (gas: [..]) ... "#]]); -}); +} -// Counter contract with constructor reverts and emitted events. -forgetest_init!(preprocess_contract_with_require_and_emit, |prj, cmd| { - prj.wipe_contracts(); +// - CounterMock contract is Counter contract +// - CounterMock instantiated in CounterTest +// +// ├── src +// │ └── Counter.sol +// └── test +// ├── Counter.t.sol +// └── mock +// └── CounterMock.sol +#[forgetest_init] +fn preprocess_mock_with_inheritance(prj: _, cmd: _) { prj.update_config(|config| { config.dynamic_test_linking = true; }); @@ -1177,415 +2108,3066 @@ forgetest_init!(preprocess_contract_with_require_and_emit, |prj, cmd| { "Counter.sol", r#" contract Counter { - event CounterCreated(uint256 number); uint256 public number; - constructor(uint256 no) { - require(no != 1, "ctor revert"); - emit CounterCreated(10); - } + function setNumber(uint256 newNumber) public { + number = newNumber; + } + + function increment() public { + number++; + } } "#, - ) - .unwrap(); + ); + + prj.add_test( + "mock/CounterMock.sol", + r#" +import {Counter} from "src/Counter.sol"; +contract CounterMock is Counter { +} + "#, + ); prj.add_test( "Counter.t.sol", r#" import {Test} from "forge-std/Test.sol"; -import {Counter} from "../src/Counter.sol"; +import {CounterMock} from "./mock/CounterMock.sol"; contract CounterTest is Test { - function test_assert_constructor_revert() public { - vm.expectRevert("ctor revert"); - new Counter(1); + CounterMock public counter; + + function setUp() public { + counter = new CounterMock(); + counter.setNumber(0); } - function test_assert_constructor_emit() public { - vm.expectEmit(true, true, true, true); - emit Counter.CounterCreated(10); + function test_Increment() public { + counter.increment(); + assertEq(counter.number(), 1); + } - new Counter(11); + function test_SetNumber() public { + counter.setNumber(1); + assertEq(counter.number(), 1); } } "#, - ) - .unwrap(); - // All 20 files are compiled on first run. + ); + // 20 files plus one mock file are compiled on first run. cmd.args(["test"]).with_no_redact().assert_success().stdout_eq(str![[r#" ... -Compiling 21 files with [..] +Compiling 22 files with [..] ... "#]]); - // Change Counter implementation to revert with different message. + // Change Counter contract implementation to fail both tests. prj.add_source( "Counter.sol", r#" contract Counter { - event CounterCreated(uint256 number); uint256 public number; - constructor(uint256 no) { - require(no != 1, "ctor revert update"); - emit CounterCreated(10); + function setNumber(uint256) public virtual { + number = 12345; + } + + function increment() public virtual { + number++; + number++; } } "#, - ) - .unwrap(); - // Assert that only 1 file is compiled (Counter source contract) and revert test fails. + ); + // Assert Counter source contract and CounterTest test contract (as it imports mock) are + // compiled and both tests fail. cmd.with_no_redact().assert_failure().stdout_eq(str![[r#" ... -Compiling 1 files with [..] +Compiling 3 files with [..] ... -[PASS] test_assert_constructor_emit() (gas: [..]) -[FAIL: Error != expected error: ctor revert update != ctor revert] test_assert_constructor_revert() (gas: [..]) +[FAIL: assertion failed: 12347 != 1] test_Increment() (gas: [..]) +[FAIL: assertion failed: 12345 != 1] test_SetNumber() (gas: [..]) ... "#]]); - // Change Counter implementation and don't revert. - prj.add_source( - "Counter.sol", + // Change mock implementation to pass both tests. + prj.add_test( + "mock/CounterMock.sol", r#" -contract Counter { - event CounterCreated(uint256 number); - uint256 public number; +import {Counter} from "src/Counter.sol"; - constructor(uint256 no) { - require(no != 0, "ctor revert"); - emit CounterCreated(10); +contract CounterMock is Counter { + function setNumber(uint256 newNumber) public override { + number = newNumber; + } + + function increment() public override { + number++; } } "#, - ) - .unwrap(); - // Assert that only 1 file is compiled (Counter source contract) and revert test fails. - cmd.with_no_redact().assert_failure().stdout_eq(str![[r#" + ); + // Assert that CounterMock and CounterTest files are compiled and no test fails. + cmd.with_no_redact().assert_success().stdout_eq(str![[r#" ... -Compiling 1 files with [..] +Compiling 2 files with [..] ... -[PASS] test_assert_constructor_emit() (gas: [..]) -[FAIL: next call did not revert as expected] test_assert_constructor_revert() (gas: [..]) +[PASS] test_Increment() (gas: [..]) +[PASS] test_SetNumber() (gas: [..]) ... "#]]); +} - // Change Counter implementation to emit different event. - prj.add_source( - "Counter.sol", +#[forgetest] +fn preprocess_contract_to_free_function_clears_dependencies(prj: _, cmd: _) { + prj.update_config(|config| config.dynamic_test_linking = true); + let dependency = + "contract Dep { function value() public pure returns (uint256) { return 1; } }"; + prj.add_source("Dep.sol", dependency); + prj.add_test( + "Helper.sol", r#" -contract Counter { - event CounterCreated(uint256 number); - uint256 public number; +import {Dep} from "../src/Dep.sol"; +contract Helper { + function dependencyCode() internal pure returns (bytes memory) { + return type(Dep).creationCode; + } +} +"#, + ); + prj.add_test( + "Consumer.t.sol", + r#" +import {Helper} from "./Helper.sol"; +contract ConsumerTest is Helper { + function test_helper() public pure { + require(dependencyCode().length > 0); + } +} +"#, + ); + cmd.args(["test"]).assert_success(); - constructor(uint256 no) { - require(no != 0, "ctor revert"); - emit CounterCreated(100); + prj.add_test("Helper.sol", "function helperValue() pure returns (uint256) { return 1; }"); + prj.add_test( + "Consumer.t.sol", + r#" +import {helperValue} from "./Helper.sol"; +contract ConsumerTest { + function test_helper() public pure { + require(helperValue() == 1); } } - "#, - ) - .unwrap(); - // Assert that only 1 file is compiled (Counter source contract) and emit test fails. - cmd.with_no_redact().assert_failure().stdout_eq(str![[r#" -... +"#, + ); + cmd.assert_success(); + + // Neither the contractless helper nor its rebuilt importer still depends on Dep. + for value in [2, 3] { + prj.add_source("Dep.sol", &dependency.replace("return 1", &format!("return {value}"))); + cmd.with_no_redact().assert_success().stdout_eq(str![[r#" Compiling 1 files with [..] -... -[FAIL: expected an emit, but no logs were emitted afterwards. you might have mismatched events or not enough events were emitted] test_assert_constructor_emit() (gas: [..]) -[FAIL: next call did not revert as expected] test_assert_constructor_revert() (gas: [..]) -... +[..] +Compiler run successful! + +Ran 1 test for test/Consumer.t.sol:ConsumerTest +[PASS] test_helper() (gas: [..]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [..] + +Ran 1 test suite [..]: 1 tests passed, 0 failed, 0 skipped (1 total tests) "#]]); -}); + } +} -// -forgetest_init!(preprocess_contract_with_constructor_args_struct, |prj, cmd| { - prj.wipe_contracts(); +// - CounterMock contract is Counter contract +// - CounterMock instantiated in CounterTest +// +// ├── src +// │ └── Counter.sol +// └── test +// ├── Counter.t.sol +// └── mock +// └── CounterMock.sol +#[forgetest_init] +fn preprocess_mock_to_non_mock(prj: _, cmd: _) { prj.update_config(|config| { config.dynamic_test_linking = true; }); - prj.add_source( - "Counter.sol", - r#" + let source = r#" contract Counter { - struct ConstructorArgs { - uint256 _number; + uint256 public number; + + function setNumber(uint256 newNumber) public { + number = newNumber; } - constructor(uint256 no) { + function increment() public { + number++; } } - "#, - ) - .unwrap(); + "#; + prj.add_source("Counter.sol", source); prj.add_test( - "Counter.t.sol", + "mock/CounterMock.sol", r#" -import {Test} from "forge-std/Test.sol"; -import {Counter} from "../src/Counter.sol"; +import {Counter} from "src/Counter.sol"; -contract CounterTest is Test { - function test_assert_constructor_revert() public { - Counter counter = new Counter(1); - } +contract CounterMock is Counter { } "#, - ) - .unwrap(); - // All 20 files should properly compile. - cmd.args(["test"]).with_no_redact().assert_success().stdout_eq(str![[r#" -... -Compiling 21 files with [..] -... - -"#]]); -}); - -// Test preprocessed contracts with decode internal fns. -#[cfg(not(feature = "isolate-by-default"))] -forgetest_init!(preprocess_contract_with_decode_internal, |prj, cmd| { - prj.update_config(|config| { - config.dynamic_test_linking = true; - }); - + ); prj.add_test( "Counter.t.sol", r#" import {Test} from "forge-std/Test.sol"; -import {Counter} from "../src/Counter.sol"; +import {CounterMock} from "./mock/CounterMock.sol"; contract CounterTest is Test { - Counter public counter; + CounterMock public counter; function setUp() public { - create_counter(0); + counter = new CounterMock(); + counter.setNumber(0); } function test_Increment() public { - create_counter(0); counter.increment(); assertEq(counter.number(), 1); } - function create_counter(uint256 number) internal { - counter = new Counter(); - counter.setNumber(number); + function test_SetNumber() public { + counter.setNumber(1); + assertEq(counter.number(), 1); } } "#, - ) - .unwrap(); + ); + // 20 files plus one mock file are compiled on first run. + cmd.args(["test"]).with_no_redact().assert_success().stdout_eq(str![[r#" +... +Compiling 22 files with [..] +... - cmd.args(["test", "--decode-internal", "-vvvv"]).assert_success().stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! +"#]]); + cmd.with_no_redact().assert_success().stdout_eq(str![[r#" +... +No files changed, compilation skipped +... -Ran 1 test for test/Counter.t.sol:CounterTest -[PASS] test_Increment() ([GAS]) -Traces: - [..] CounterTest::test_Increment() - ├─ [0] VM::deployCode("src/Counter.sol:Counter") - │ ├─ [96345] → new Counter@0x2e234DAe75C793f67A35089C9d99245E1C58470b - │ │ └─ ← [Return] 481 bytes of code - │ └─ ← [Return] Counter: [0x2e234DAe75C793f67A35089C9d99245E1C58470b] - ├─ [..] Counter::setNumber(0) - │ └─ ← [Stop] - ├─ [..] Counter::increment() - │ └─ ← [Stop] - ├─ [..] Counter::number() [staticcall] - │ └─ ← [Return] 1 - ├─ [..] StdAssertions::assertEq(1, 1) - │ ├─ [0] VM::assertEq(1, 1) [staticcall] - │ │ └─ ← [Return] - │ └─ ← - └─ ← [Stop] +"#]]); -Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + // Change mock implementation to fail tests, no inherit from Counter. + prj.add_test( + "mock/CounterMock.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {Counter} from "src/Counter.sol"; -Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) +contract CounterMock { + uint256 public number; + function setNumber(uint256 newNumber) public { + number = 1234; + } + + function increment() public { + number = 5678; + } +} + "#, + ); + // Assert that CounterMock and CounterTest files are compiled and tests fail. + cmd.with_no_redact().assert_failure().stdout_eq(str![[r#" +... +Compiling 2 files with [..] +... +[FAIL: assertion failed: 5678 != 1] test_Increment() (gas: [..]) +[FAIL: assertion failed: 1234 != 1] test_SetNumber() (gas: [..]) +... "#]]); -}); -// -// Preprocess test contracts with try constructor statements. -forgetest_init!(preprocess_contract_with_try_ctor_stmt, |prj, cmd| { - prj.wipe_contracts(); + // The former mock classification must not rebuild importers after a source body-only edit. + prj.add_source("Counter.sol", &source.replace("number++", "number += 2")); + prj.forge_command().arg("build").with_no_redact().assert_success().stdout_eq(str![[r#" +Compiling 1 files with [..] +[..] +Compiler run successful! + +"#]]); +} + +// +// - CounterMock contract is Counter contract +// - CounterMock declared in CounterTest +// +// ├── src +// │ └── Counter.sol +// └── test +// ├── Counter.t.sol +#[forgetest_init] +fn preprocess_mock_declared_in_test_contract(prj: _, cmd: _) { + prj.update_config(|config| { + config.dynamic_test_linking = true; + }); + + prj.add_source( + "Counter.sol", + r#" +contract Counter { + function add(uint256 x, uint256 y) public pure returns (uint256) { + return x + y; + } +} + "#, + ); + + prj.add_test( + "Counter.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {Counter} from "src/Counter.sol"; + +contract CounterMock is Counter {} + +contract CounterTest is Test { + function test_add() public { + CounterMock impl = new CounterMock(); + assertEq(impl.add(2, 2), 4); + } +} + "#, + ); + // 20 files plus one mock file are compiled on first run. + cmd.args(["test"]).with_no_redact().assert_success().stdout_eq(str![[r#" +... +Compiling 21 files with [..] +... + +"#]]); + cmd.with_no_redact().assert_success().stdout_eq(str![[r#" +... +No files changed, compilation skipped +... + +"#]]); + + // Change Counter implementation to fail tests. + prj.add_source( + "Counter.sol", + r#" +contract Counter { + function add(uint256 x, uint256 y) public pure returns (uint256) { + return x + y + 1; + } +} + "#, + ); + // Assert that Counter and CounterTest files are compiled and tests fail. + cmd.with_no_redact().assert_failure().stdout_eq(str![[r#" +... +Compiling 2 files with [..] +... +[FAIL: assertion failed: 5 != 4] test_add() (gas: [..]) +... + +"#]]); +} + +// ├── src +// │ ├── CounterA.sol +// │ ├── CounterB.sol +// │ ├── Counter.sol +// │ └── v1 +// │ └── Counter.sol +// └── test +// └── Counter.t.sol +#[forgetest_init] +fn preprocess_multiple_contracts_with_constructors(prj: _, cmd: _) { prj.update_config(|config| { config.dynamic_test_linking = true; }); + prj.add_source( + "Counter.sol", + r#" +contract Counter { + uint256 public number; + + function setNumber(uint256 newNumber) public { + number = newNumber; + } + + function increment() public { + number++; + } +} + "#, + ); prj.add_source( "CounterA.sol", r#" contract CounterA { - uint256 number; + uint256 public number; + address public owner; + + constructor(uint256 _newNumber, address _owner) { + number = _newNumber; + owner = _owner; + } + + function increment() public { + number++; + } } "#, - ) - .unwrap(); + ); + // Contract with constructor args without name. prj.add_source( "CounterB.sol", r#" contract CounterB { - uint256 number; - constructor(uint256 a) payable { - require(a > 0, "ctor failure"); - number = a; + uint256 public number; + + constructor(uint256) { + number = 1; + } + + function increment() public { + number++; } } "#, - ) - .unwrap(); + ); prj.add_source( - "CounterC.sol", + "v1/Counter.sol", r#" -contract CounterC { - uint256 number; - constructor(uint256 a) { - require(a > 0, "ctor failure"); - number = a; +contract Counter { + uint256 public number; + + constructor(uint256 _number) { + number = _number; + } + + function increment() public { + number++; } } "#, - ) - .unwrap(); + ); prj.add_test( "Counter.t.sol", r#" import {Test} from "forge-std/Test.sol"; -import {CounterA} from "../src/CounterA.sol"; -import {CounterB} from "../src/CounterB.sol"; -import {CounterC} from "../src/CounterC.sol"; +import {Counter} from "src/Counter.sol"; +import "src/CounterA.sol"; +import "src/CounterB.sol"; +import {Counter as CounterV1} from "src/v1/Counter.sol"; contract CounterTest is Test { - function test_try_counterA_creation() public { - try new CounterA() {} catch { - revert(); - } + function test_Increment_In_Counter() public { + Counter counter = new Counter(); + counter.increment(); + assertEq(counter.number(), 1); } - function test_try_counterB_creation() public { - try new CounterB(1) {} catch { - revert(); - } + function test_Increment_In_Counter_V1() public { + CounterV1 counter = new CounterV1(1234); + counter.increment(); + assertEq(counter.number(), 1235); } - function test_try_counterB_creation_with_salt() public { - try new CounterB{value: 111, salt: bytes32("preprocess_counter_with_salt")}(1) {} catch { - revert(); - } + function test_Increment_In_Counter_A() public { + CounterA counter = new CounterA(1234, address(this)); + counter.increment(); + assertEq(counter.number(), 1235); } - function test_try_counterC_creation() public { - try new CounterC(2) { - new CounterC(1); - } catch { - revert(); - } + function test_Increment_In_Counter_A_with_named_args() public { + CounterA counter = new CounterA({_newNumber: 1234, _owner: address(this)}); + counter.increment(); + assertEq(counter.number(), 1235); + } + + function test_Increment_In_Counter_B() public { + CounterB counter = new CounterB(1234); + counter.increment(); + assertEq(counter.number(), 2); } } "#, - ) - .unwrap(); - // All 23 files should properly compile, tests pass. + ); + // 22 files plus one mock file are compiled on first run. cmd.args(["test"]).with_no_redact().assert_success().stdout_eq(str![[r#" ... -Compiling 23 files with [..] +Compiling 24 files with [..] ... -[PASS] test_try_counterA_creation() (gas: [..]) -[PASS] test_try_counterB_creation() (gas: [..]) -[PASS] test_try_counterB_creation_with_salt() (gas: [..]) -[PASS] test_try_counterC_creation() (gas: [..]) +[PASS] test_Increment_In_Counter() (gas: [..]) +[PASS] test_Increment_In_Counter_A() (gas: [..]) +[PASS] test_Increment_In_Counter_A_with_named_args() (gas: [..]) +[PASS] test_Increment_In_Counter_B() (gas: [..]) +[PASS] test_Increment_In_Counter_V1() (gas: [..]) ... "#]]); - // Change CounterB to fail test. + // Change v1/Counter to fail test. prj.add_source( - "CounterB.sol", + "v1/Counter.sol", r#" -contract CounterB { - uint256 number; - constructor(uint256 a) payable { - require(a > 11, "ctor failure"); - number = a; +contract Counter { + uint256 public number; + + constructor(uint256 _number) { + number = _number; + } + + function increment() public { + number = 12345; } } "#, - ) - .unwrap(); - // Only CounterB should compile. - cmd.assert_failure().stdout_eq(str![[r#" + ); + // Only v1/Counter should be compiled and test should fail. + cmd.with_no_redact().assert_failure().stdout_eq(str![[r#" ... Compiling 1 files with [..] ... -[PASS] test_try_counterA_creation() (gas: [..]) -[FAIL: EvmError: Revert] test_try_counterB_creation() (gas: [..]) -[FAIL: EvmError: Revert] test_try_counterB_creation_with_salt() (gas: [..]) -[PASS] test_try_counterC_creation() (gas: [..]) +[PASS] test_Increment_In_Counter() (gas: [..]) +[PASS] test_Increment_In_Counter_A() (gas: [..]) +[PASS] test_Increment_In_Counter_A_with_named_args() (gas: [..]) +[PASS] test_Increment_In_Counter_B() (gas: [..]) +[FAIL: assertion failed: 12345 != 1235] test_Increment_In_Counter_V1() (gas: [..]) ... "#]]); - // Change CounterC to fail test in try statement. + // Change CounterA to fail test. prj.add_source( - "CounterC.sol", + "CounterA.sol", r#" -contract CounterC { - uint256 number; - constructor(uint256 a) { - require(a > 1, "ctor failure"); - number = a; +contract CounterA { + uint256 public number; + address public owner; + + constructor(uint256 _newNumber, address _owner) { + number = _newNumber; + owner = _owner; + } + + function increment() public { + number = 12345; } } "#, - ) - .unwrap(); - // Only CounterC should compile. - cmd.assert_failure().stdout_eq(str![[r#" -... + ); + // Only CounterA should be compiled and test should fail. + cmd.with_no_redact().assert_failure().stdout_eq(str![[r#" +... Compiling 1 files with [..] ... -[PASS] test_try_counterA_creation() (gas: [..]) -[FAIL: EvmError: Revert] test_try_counterB_creation() (gas: [..]) -[FAIL: EvmError: Revert] test_try_counterB_creation_with_salt() (gas: [..]) -[FAIL: ctor failure] test_try_counterC_creation() (gas: [..]) +[PASS] test_Increment_In_Counter() (gas: [..]) +[FAIL: assertion failed: 12345 != 1235] test_Increment_In_Counter_A() (gas: [..]) +[FAIL: assertion failed: 12345 != 1235] test_Increment_In_Counter_A_with_named_args() (gas: [..]) +[PASS] test_Increment_In_Counter_B() (gas: [..]) +[FAIL: assertion failed: 12345 != 1235] test_Increment_In_Counter_V1() (gas: [..]) ... "#]]); - // Change CounterC to fail test in try statement. + // Change CounterB to fail test. prj.add_source( - "CounterC.sol", + "CounterB.sol", r#" -contract CounterC { - uint256 number; - constructor(uint256 a) { - require(a > 2, "ctor failure"); - number = a; +contract CounterB { + uint256 public number; + + constructor(uint256) { + number = 100; + } + + function increment() public { + number++; } } "#, - ) - .unwrap(); - // Only CounterC should compile and revert. - cmd.assert_failure().stdout_eq(str![[r#" + ); + // Only CounterB should be compiled and test should fail. + cmd.with_no_redact().assert_failure().stdout_eq(str![[r#" ... Compiling 1 files with [..] ... -[PASS] test_try_counterA_creation() (gas: [..]) -[FAIL: EvmError: Revert] test_try_counterB_creation() (gas: [..]) -[FAIL: EvmError: Revert] test_try_counterB_creation_with_salt() (gas: [..]) -[FAIL: EvmError: Revert] test_try_counterC_creation() (gas: [..]) +[PASS] test_Increment_In_Counter() (gas: [..]) +[FAIL: assertion failed: 12345 != 1235] test_Increment_In_Counter_A() (gas: [..]) +[FAIL: assertion failed: 12345 != 1235] test_Increment_In_Counter_A_with_named_args() (gas: [..]) +[FAIL: assertion failed: 101 != 2] test_Increment_In_Counter_B() (gas: [..]) +[FAIL: assertion failed: 12345 != 1235] test_Increment_In_Counter_V1() (gas: [..]) +... + +"#]]); + + // Change Counter to fail test. + prj.add_source( + "Counter.sol", + r#" +contract Counter { + uint256 public number; + + function setNumber(uint256 newNumber) public { + number = newNumber; + } + + function increment() public { + number = 12345; + } +} + "#, + ); + // Only Counter should be compiled and test should fail. + cmd.with_no_redact().assert_failure().stdout_eq(str![[r#" +... +Compiling 1 files with [..] +... +[FAIL: assertion failed: 12345 != 1] test_Increment_In_Counter() (gas: [..]) +[FAIL: assertion failed: 12345 != 1235] test_Increment_In_Counter_A() (gas: [..]) +[FAIL: assertion failed: 12345 != 1235] test_Increment_In_Counter_A_with_named_args() (gas: [..]) +[FAIL: assertion failed: 101 != 2] test_Increment_In_Counter_B() (gas: [..]) +[FAIL: assertion failed: 12345 != 1235] test_Increment_In_Counter_V1() (gas: [..]) +... + +"#]]); +} + +// Test preprocessing contracts with payable constructor, value and salt named args. +#[forgetest_init] +fn flaky_preprocess_contracts_with_payable_constructor_and_salt(prj: _, cmd: _) { + prj.update_config(|config| { + config.dynamic_test_linking = true; + }); + + prj.add_source( + "Counter.sol", + r#" +contract Counter { + uint256 public number; + + constructor(uint256 _number) payable { + number = msg.value; + } + + function setNumber(uint256 newNumber) public { + number = newNumber; + } + + function increment() public { + number++; + } +} + "#, + ); + prj.add_source( + "CounterWithSalt.sol", + r#" +contract CounterWithSalt { + uint256 public number; + + constructor(uint256 _number) payable { + number = msg.value; + } + + function setNumber(uint256 newNumber) public { + number = newNumber; + } + + function increment() public { + number++; + } +} + "#, + ); + + prj.add_test( + "Counter.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {Counter} from "src/Counter.sol"; +import {CounterWithSalt} from "src/CounterWithSalt.sol"; + +contract CounterTest is Test { + function test_Increment_In_Counter() public { + Counter counter = Counter(address(new Counter{value: 111}(1))); + counter.increment(); + assertEq(counter.number(), 112); + } + + function test_Increment_In_Counter_With_Salt() public { + CounterWithSalt counter = new CounterWithSalt{value: 111, salt: bytes32("preprocess_counter_with_salt")}(1); + assertGt(uint160(address(counter)), 0); + counter.increment(); + assertEq(counter.number(), 112); + } +} + "#, + ); + + cmd.args(["test"]).with_no_redact().assert_success().stdout_eq(str![[r#" +... +Compiling 22 files with [..] +... +[PASS] test_Increment_In_Counter() (gas: [..]) +[PASS] test_Increment_In_Counter_With_Salt() (gas: [..]) +... + +"#]]); + + // Change contract to fail test. + prj.add_source( + "Counter.sol", + r#" +contract Counter { + uint256 public number; + + constructor(uint256 _number) payable { + number = msg.value + _number; + } + + function setNumber(uint256 newNumber) public { + number = newNumber; + } + + function increment() public { + number++; + } +} + "#, + ); + // Only Counter should be compiled and test should fail. + cmd.with_no_redact().assert_failure().stdout_eq(str![[r#" +... +Compiling 1 files with [..] +... +[FAIL: assertion failed: 113 != 112] test_Increment_In_Counter() (gas: [..]) +[PASS] test_Increment_In_Counter_With_Salt() (gas: [..]) +... + +"#]]); + + // Change contract with salt to fail test too. + prj.add_source( + "CounterWithSalt.sol", + r#" +contract CounterWithSalt { + uint256 public number; + + constructor(uint256 _number) payable { + number = msg.value + _number; + } + + function setNumber(uint256 newNumber) public { + number = newNumber; + } + + function increment() public { + number++; + } +} + "#, + ); + // Only Counter should be compiled and test should fail. + cmd.with_no_redact().assert_failure().stdout_eq(str![[r#" +... +Compiling 1 files with [..] +... +[FAIL: assertion failed: 113 != 112] test_Increment_In_Counter() (gas: [..]) +[FAIL: assertion failed: 113 != 112] test_Increment_In_Counter_With_Salt() (gas: [..]) ... "#]]); -}); +} + +// Counter contract with constructor reverts and emitted events. +#[forgetest_init] +fn preprocess_contract_with_require_and_emit(prj: _, cmd: _) { + prj.update_config(|config| { + config.dynamic_test_linking = true; + }); + + prj.add_source( + "Counter.sol", + r#" +contract Counter { + event CounterCreated(uint256 number); + uint256 public number; + + constructor(uint256 no) { + require(no != 1, "ctor revert"); + emit CounterCreated(10); + } +} + "#, + ); + + prj.add_test( + "Counter.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {Counter} from "../src/Counter.sol"; + +contract CounterTest is Test { + function test_assert_constructor_revert() public { + vm.expectRevert("ctor revert"); + new Counter(1); + } + + function test_assert_constructor_emit() public { + vm.expectEmit(true, true, true, true); + emit Counter.CounterCreated(10); + + new Counter(11); + } +} + "#, + ); + // All 20 files are compiled on first run. + cmd.args(["test"]).with_no_redact().assert_success().stdout_eq(str![[r#" +... +Compiling 21 files with [..] +... + +"#]]); + + // Change Counter implementation to revert with different message. + prj.add_source( + "Counter.sol", + r#" +contract Counter { + event CounterCreated(uint256 number); + uint256 public number; + + constructor(uint256 no) { + require(no != 1, "ctor revert update"); + emit CounterCreated(10); + } +} + "#, + ); + // Assert that only 1 file is compiled (Counter source contract) and revert test fails. + cmd.with_no_redact().assert_failure().stdout_eq(str![[r#" +... +Compiling 1 files with [..] +... +[PASS] test_assert_constructor_emit() (gas: [..]) +[FAIL: Error != expected error: ctor revert update != ctor revert] test_assert_constructor_revert() (gas: [..]) +... + +"#]]); + + // Change Counter implementation and don't revert. + prj.add_source( + "Counter.sol", + r#" +contract Counter { + event CounterCreated(uint256 number); + uint256 public number; + + constructor(uint256 no) { + require(no != 0, "ctor revert"); + emit CounterCreated(10); + } +} + "#, + ); + // Assert that only 1 file is compiled (Counter source contract) and revert test fails. + cmd.with_no_redact().assert_failure().stdout_eq(str![[r#" +... +Compiling 1 files with [..] +... +[PASS] test_assert_constructor_emit() (gas: [..]) +[FAIL: next call did not revert as expected] test_assert_constructor_revert() (gas: [..]) +... + +"#]]); + + // Change Counter implementation to emit different event. + prj.add_source( + "Counter.sol", + r#" +contract Counter { + event CounterCreated(uint256 number); + uint256 public number; + + constructor(uint256 no) { + require(no != 0, "ctor revert"); + emit CounterCreated(100); + } +} + "#, + ); + // Assert that only 1 file is compiled (Counter source contract) and emit test fails. + cmd.with_no_redact().assert_failure().stdout_eq(str![[r#" +... +Compiling 1 files with [..] +... +[FAIL: expected an emit, but no logs were emitted afterwards. you might have mismatched events or not enough events were emitted] test_assert_constructor_emit() (gas: [..]) +[FAIL: next call did not revert as expected] test_assert_constructor_revert() (gas: [..]) +... + +"#]]); +} + +// +#[forgetest_init] +fn preprocess_contract_with_constructor_args_struct(prj: _, cmd: _) { + prj.update_config(|config| { + config.dynamic_test_linking = true; + }); + + prj.add_source( + "Counter.sol", + r#" +contract Counter { + struct ConstructorArgs { + uint256 _number; + } + + constructor(uint256 no) { + } +} + "#, + ); + + prj.add_test( + "Counter.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {Counter} from "../src/Counter.sol"; + +contract CounterTest is Test { + function test_assert_constructor_revert() public { + Counter counter = new Counter(1); + } +} + "#, + ); + // All 20 files should properly compile. + cmd.args(["test"]).with_no_redact().assert_success().stdout_eq(str![[r#" +... +Compiling 21 files with [..] +... + +"#]]); +} + +// Test preprocessed contracts with decode internal fns. +#[forgetest_init] +fn preprocess_contract_with_decode_internal(prj: _, cmd: _) { + prj.initialize_default_contracts(); + prj.update_config(|config| { + config.dynamic_test_linking = true; + }); + + prj.add_test( + "Counter.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 { + create_counter(0); + } + + function test_Increment() public { + create_counter(0); + counter.increment(); + assertEq(counter.number(), 1); + } + + function create_counter(uint256 number) internal { + counter = new Counter(); + counter.setNumber(number); + } +} + "#, + ); + + cmd.args(["test", "--decode-internal", "-vvvv"]).assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/Counter.t.sol:CounterTest +[PASS] test_Increment() ([GAS]) +Traces: + [..] CounterTest::test_Increment() + ├─ [0] VM::deployCode("src/Counter.sol:Counter") + │ ├─ [[..]] → new Counter@0xF62849F9A0B5Bf2913b396098F7c7019b51A820a + │ │ └─ ← [Return] 481 bytes of code + │ └─ ← [Return] Counter: [0xF62849F9A0B5Bf2913b396098F7c7019b51A820a] + ├─ [..] Counter::setNumber(0) + │ └─ ← [Stop] + ├─ [..] Counter::increment() + │ └─ ← [Stop] + ├─ [..] Counter::number() [staticcall] + │ └─ ← [Return] 1 + ├─ [..] StdAssertions::assertEq(uint256,uint256)(1, 1) + │ └─ ← + └─ ← [Stop] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +} + +// +// Preprocess test contracts with try constructor statements. +// Synthetic deployments must respect static execution without changing native try boundaries. +#[forgetest] +fn preprocess_static_deployment(prj: _, cmd: _) { + prj.add_source( + "Target.sol", + r#" +contract Empty { + constructor() payable {} +} +contract Target { + uint256 public value; + address public sender; + constructor(uint256 x) payable { value = x; sender = msg.sender; } +} +"#, + ); + prj.add_test( + "StaticDeployment.t.sol", + r#" +import {Empty, Target} from "../src/Target.sol"; + +interface Vm { + function getNonce(address account) external view returns (uint64); +} + +contract StaticDeploymentTest { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + function deploy() external returns (Target) { return new Target(7); } + function deploy2() external returns (Target) { return new Target{salt: bytes32(uint256(1))}(7); } + function tryDeploy() external { try new Target(7) {} catch {} } + function tryDeploy2() external { try new Target{salt: bytes32(uint256(1))}(7) {} catch {} } + function creationCode() external view returns (bytes memory) { return type(Target).creationCode; } + + function checkStatic(bytes memory data) internal { + uint64 nonce = vm.getNonce(address(this)); + uint256 balance = address(this).balance; + (bool ok, bytes memory result) = address(this).staticcall(data); + require(!ok, "static deployment succeeded"); + require(result.length == 0, "unexpected revert data"); + require(vm.getNonce(address(this)) == nonce, "nonce changed"); + require(address(this).balance == balance, "balance changed"); + } + + function test_static_create() public { checkStatic(abi.encodeCall(this.deploy, ())); } + function test_static_create2() public { checkStatic(abi.encodeCall(this.deploy2, ())); } + function test_static_try_create() public { checkStatic(abi.encodeCall(this.tryDeploy, ())); } + function test_static_try_create2() public { checkStatic(abi.encodeCall(this.tryDeploy2, ())); } + + function test_regular_deployment() public { + Target a = this.deploy(); + Target b = this.deploy2(); + require(a.value() == 7 && b.value() == 7); + require(a.sender() == address(this) && b.sender() == address(this)); + } + + function test_static_creation_code() public { + (bool ok, bytes memory result) = address(this).staticcall(abi.encodeCall(this.creationCode, ())); + require(ok && abi.decode(result, (bytes)).length > 0); + } + + function test_manual_deploy_code_static() public { + string memory empty = "src/Target.sol:Empty"; + string memory target = "src/Target.sol:Target"; + bytes memory args = abi.encode(uint256(7)); + bytes32 salt = bytes32(uint256(1)); + bytes[] memory calls = new bytes[](8); + calls[0] = abi.encodeWithSignature("deployCode(string)", empty); + calls[1] = abi.encodeWithSignature("deployCode(string,bytes)", target, args); + calls[2] = abi.encodeWithSignature("deployCode(string,uint256)", empty, 1); + calls[3] = abi.encodeWithSignature("deployCode(string,bytes,uint256)", target, args, 1); + calls[4] = abi.encodeWithSignature("deployCode(string,bytes32)", empty, salt); + calls[5] = abi.encodeWithSignature("deployCode(string,bytes,bytes32)", target, args, salt); + calls[6] = abi.encodeWithSignature("deployCode(string,uint256,bytes32)", empty, 1, salt); + calls[7] = abi.encodeWithSignature("deployCode(string,bytes,uint256,bytes32)", target, args, 1, salt); + uint64 nonce = vm.getNonce(address(this)); + for (uint256 i; i < calls.length; ++i) { + (bool ok, bytes memory result) = address(vm).staticcall(calls[i]); + require(!ok && result.length == 0, "static deployCode succeeded"); + } + require(vm.getNonce(address(this)) == nonce, "nonce changed"); + } +} +"#, + ); + + for dynamic_test_linking in [false, true] { + prj.update_config(|config| config.dynamic_test_linking = dynamic_test_linking); + for force in [true, false] { + cmd.forge_fuse().arg("test"); + if force { + cmd.arg("--force"); + } + cmd.assert_success().stdout_eq(str![[r#" +... +Ran 7 tests for test/StaticDeployment.t.sol:StaticDeploymentTest +[PASS] test_manual_deploy_code_static() ([GAS]) +[PASS] test_regular_deployment() ([GAS]) +[PASS] test_static_create() ([GAS]) +[PASS] test_static_create2() ([GAS]) +[PASS] test_static_creation_code() ([GAS]) +[PASS] test_static_try_create() ([GAS]) +[PASS] test_static_try_create2() ([GAS]) +Suite result: ok. 7 passed; 0 failed; 0 skipped; [ELAPSED] +... +"#]]); + } + } +} + +#[forgetest_init] +fn preprocess_contract_with_try_ctor_stmt(prj: _, cmd: _) { + prj.update_config(|config| { + config.dynamic_test_linking = true; + }); + + prj.add_source( + "CounterA.sol", + r#" +contract CounterA { + uint256 number; +} + "#, + ); + prj.add_source( + "CounterB.sol", + r#" +contract CounterB { + uint256 number; + constructor(uint256 a) payable { + require(a > 0, "ctor failure"); + number = a; + } +} + "#, + ); + prj.add_source( + "CounterC.sol", + r#" +contract CounterC { + uint256 number; + constructor(uint256 a) { + require(a > 0, "ctor failure"); + number = a; + } +} + "#, + ); + + prj.add_test( + "Counter.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {CounterA} from "../src/CounterA.sol"; +import {CounterB} from "../src/CounterB.sol"; +import {CounterC} from "../src/CounterC.sol"; + +contract CounterTest is Test { + function test_try_counterA_creation() public { + try new CounterA() {} catch { + revert(); + } + } + + function test_try_counterB_creation() public { + try new CounterB(1) {} catch { + revert(); + } + } + + function test_try_counterB_creation_with_salt() public { + try new CounterB{value: 111, salt: bytes32("preprocess_counter_with_salt")}(1) {} catch { + revert(); + } + } + + function test_try_counterC_creation() public { + try new CounterC(2) { + new CounterC(1); + } catch { + revert(); + } + } +} + "#, + ); + // All 23 files should properly compile, tests pass. + cmd.args(["test"]).with_no_redact().assert_success().stdout_eq(str![[r#" +... +Compiling 23 files with [..] +... +[PASS] test_try_counterA_creation() (gas: [..]) +[PASS] test_try_counterB_creation() (gas: [..]) +[PASS] test_try_counterB_creation_with_salt() (gas: [..]) +[PASS] test_try_counterC_creation() (gas: [..]) +... + +"#]]); + + // Change CounterB to fail test. + prj.add_source( + "CounterB.sol", + r#" +contract CounterB { + uint256 number; + constructor(uint256 a) payable { + require(a > 11, "ctor failure"); + number = a; + } +} + "#, + ); + // CounterB and its native try-deployment consumer should compile. + cmd.assert_failure().stdout_eq(str![[r#" +... +Compiling 2 files with [..] +... +[PASS] test_try_counterA_creation() (gas: [..]) +[FAIL: EvmError: Revert] test_try_counterB_creation() (gas: [..]) +[FAIL: EvmError: Revert] test_try_counterB_creation_with_salt() (gas: [..]) +[PASS] test_try_counterC_creation() (gas: [..]) +... + +"#]]); + + // Change CounterC to fail test in try statement. + prj.add_source( + "CounterC.sol", + r#" +contract CounterC { + uint256 number; + constructor(uint256 a) { + require(a > 1, "ctor failure"); + number = a; + } +} + "#, + ); + // CounterC and its native try-deployment consumer should compile. + cmd.assert_failure().stdout_eq(str![[r#" +... +Compiling 2 files with [..] +... +[PASS] test_try_counterA_creation() (gas: [..]) +[FAIL: EvmError: Revert] test_try_counterB_creation() (gas: [..]) +[FAIL: EvmError: Revert] test_try_counterB_creation_with_salt() (gas: [..]) +[FAIL: ctor failure] test_try_counterC_creation() (gas: [..]) +... + +"#]]); + + // Change CounterC to fail test in try statement. + prj.add_source( + "CounterC.sol", + r#" +contract CounterC { + uint256 number; + constructor(uint256 a) { + require(a > 2, "ctor failure"); + number = a; + } +} + "#, + ); + // CounterC and its native try-deployment consumer should compile and revert. + cmd.assert_failure().stdout_eq(str![[r#" +... +Compiling 2 files with [..] +... +[PASS] test_try_counterA_creation() (gas: [..]) +[FAIL: EvmError: Revert] test_try_counterB_creation() (gas: [..]) +[FAIL: EvmError: Revert] test_try_counterB_creation_with_salt() (gas: [..]) +[FAIL: EvmError: Revert] test_try_counterC_creation() (gas: [..]) +... + +"#]]); +} + +// +// Preprocess test contracts when active prank. +#[forgetest_init] +fn preprocess_contract_with_active_prank(prj: _, cmd: _) { + prj.update_config(|config| { + config.dynamic_test_linking = true; + }); + + prj.add_source( + "Counter.sol", + r#" +contract Counter { + uint256 public number; + address public deployer; + address public origin; + + constructor() { + deployer = msg.sender; + origin = tx.origin; + } +} + "#, + ); + + prj.add_test( + "Counter.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {Counter} from "../src/Counter.sol"; + +contract CounterTest is Test { + function test_deployer() public { + address deployer = makeAddr("deployer"); + address origin = makeAddr("origin"); + vm.startPrank(deployer, origin); + Counter first = new Counter{salt: 0}(); + Counter second = new Counter{salt: bytes32(uint256(1))}(); + assertEq(first.deployer(), deployer); + assertEq(first.origin(), origin); + assertEq(second.deployer(), deployer); + assertEq(second.origin(), origin); + } + + function test_consecutive_single_call_pranks() public { + address firstDeployer = makeAddr("firstDeployer"); + address firstOrigin = makeAddr("firstOrigin"); + vm.prank(firstDeployer, firstOrigin); + Counter first = new Counter(); + + address secondDeployer = makeAddr("secondDeployer"); + address secondOrigin = makeAddr("secondOrigin"); + vm.prank(secondDeployer, secondOrigin); + Counter second = new Counter(); + + assertEq(first.deployer(), firstDeployer); + assertEq(first.origin(), firstOrigin); + assertEq(second.deployer(), secondDeployer); + assertEq(second.origin(), secondOrigin); + + Counter unpranked = new Counter(); + assertEq(unpranked.deployer(), address(this)); + assertEq(unpranked.origin(), tx.origin); + } +} + "#, + ); + // Test should pass. + cmd.args(["test"]).assert_success().stdout_eq(str![[r#" +[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 2 tests for test/Counter.t.sol:CounterTest +[PASS] test_consecutive_single_call_pranks() ([GAS]) +[PASS] test_deployer() ([GAS]) +Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 2 tests passed, 0 failed, 0 skipped (2 total tests) + +"#]]); +} + +// Preprocess test contracts with try constructor statements that bind return type. +#[forgetest_init] +fn preprocess_contract_with_try_ctor_stmt_and_returns(prj: _, cmd: _) { + prj.update_config(|config| { + config.dynamic_test_linking = true; + }); + + prj.add_source( + "Counter.sol", + r#" +contract Counter { + uint256 number; + constructor(uint256 a) payable { + require(a > 0, "ctor failure"); + number = a; + } +} + "#, + ); + prj.add_test( + "CounterReturns.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {Counter} from "../src/Counter.sol"; + +contract CounterReturnsTest is Test { + function test_try_counter_creation_returns_custom_type() public { + try new Counter(1) returns (Counter c) { + c; + } catch Error(string memory reason) { + require(keccak256(bytes(reason)) == keccak256("ctor failure")); + } + } +} + "#, + ); + + cmd.args(["test"]).with_no_redact().assert_success().stdout_eq(str![[r#" +... +Compiling 21 files with [..] +... +[PASS] test_try_counter_creation_returns_custom_type() (gas: [..]) +... + +"#]]); + + // The typed deployment remains native so its constructor revert reaches the catch clause. + prj.add_source( + "Counter.sol", + r#" +contract Counter { + uint256 number; + constructor(uint256 a) payable { + require(a == 0, "ctor failure"); + number = a; + } +} + "#, + ); + cmd.assert_success(); +} + +#[forgetest] +fn preprocess_typed_try_new_preserves_catches(prj: _, cmd: _) { + let targets = r#" +error ConstructorError(uint256 value); + +contract RevertString { + constructor() { revert("first reason"); } +} + +contract CustomError { + constructor() { revert ConstructorError(7); } +} + +contract Panic { + constructor() { assert(false); } +} + +contract EmptyRevert { + constructor() { assembly { revert(0, 0) } } +} +"#; + prj.add_source("Targets.sol", targets); + prj.add_test( + "TypedTry.t.sol", + r#" +import * as Targets from "../src/Targets.sol"; + +contract TypedTryTest { + bool public constructorCaught; + + constructor() { + try new Targets.EmptyRevert() returns (Targets.EmptyRevert) { + revert("constructor deployment succeeded"); + } catch (bytes memory reason) { + constructorCaught = reason.length == 0; + } + } + + function test_constructor_context() public view { + require(constructorCaught, "constructor catch missed"); + } + + function test_error_string() public { + try new Targets.RevertString() returns (Targets.RevertString) { + revert("deployment succeeded"); + } catch Error(string memory reason) { + require(keccak256(bytes(reason)) == keccak256("first reason"), "changed reason"); + } + } + + function test_custom_error() public { + try new Targets.CustomError() returns (Targets.CustomError) { + revert("deployment succeeded"); + } catch (bytes memory reason) { + require(bytes4(reason) == Targets.ConstructorError.selector, "wrong custom error"); + } + } + + function test_panic() public { + try new Targets.Panic() returns (Targets.Panic) { + revert("deployment succeeded"); + } catch Panic(uint256 code) { + require(code == 1, "wrong panic"); + } + } + + function test_empty_revert() public { + try new Targets.EmptyRevert() returns (Targets.EmptyRevert) { + revert("deployment succeeded"); + } catch (bytes memory reason) { + require(reason.length == 0, "non-empty revert"); + } + } +} +"#, + ); + + for dynamic_test_linking in [false, true] { + prj.update_config(|config| config.dynamic_test_linking = dynamic_test_linking); + prj.add_source("Targets.sol", targets); + cmd.forge_fuse().args(["test", "--force"]).assert_success(); + cmd.forge_fuse().arg("test").assert_success(); + prj.add_source("Targets.sol", &targets.replace("first reason", "second reason")); + cmd.forge_fuse().arg("test").assert_failure().stdout_eq(str![[r#" +... +Ran 5 tests for test/TypedTry.t.sol:TypedTryTest +[PASS] test_constructor_context() ([GAS]) +[PASS] test_custom_error() ([GAS]) +[PASS] test_empty_revert() ([GAS]) +[FAIL: changed reason] test_error_string() ([GAS]) +[PASS] test_panic() ([GAS]) +Suite result: FAILED. 4 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); + } +} + +// Test that `type(Contract).creationCode` can be used in view functions. +// https://github.com/foundry-rs/foundry/issues/13086 +#[forgetest_init] +fn preprocess_creation_code_in_view_function(prj: _, cmd: _) { + prj.update_config(|config| { + config.dynamic_test_linking = true; + }); + + prj.add_source( + "Target.sol", + r#" +contract Target { + uint256 public immutable value; + constructor(uint256 _value) { value = _value; } +} + "#, + ); + + prj.add_test( + "Target.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {Target} from "../src/Target.sol"; + +contract TargetTest is Test { + function computeAddress(address factory, uint256 salt, uint256 value) internal view returns (address) { + bytes32 hash = keccak256( + abi.encodePacked( + bytes1(0xff), + factory, + salt, + keccak256(abi.encodePacked(type(Target).creationCode, abi.encode(value))) + ) + ); + return address(uint160(uint256(hash))); + } + + function testComputeAddress() public view { + computeAddress(address(this), 1, 100); + } +} + "#, + ); + + cmd.args(["build"]).assert_success(); +} + +// +#[forgetest_init] +fn preprocess_custom_layout_contract(prj: _, cmd: _) { + prj.update_config(|config| { + config.dynamic_test_linking = true; + config.solc = Some(foundry_config::SolcReq::Version(semver::Version::new(0, 8, 35))); + }); + + prj.add_source( + "Target.sol", + r#" +contract Target layout at erc7201("test.Target") { + uint256 public value; + + constructor(uint256 value_) { + value = value_; + } +} + "#, + ); + + prj.add_test( + "Target.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {Target} from "../src/Target.sol"; + +contract TargetTest is Test { + function testDirectNew() public { + Target target = new Target(42); + assertEq(target.value(), 42); + } + + function targetCreationCode() public view returns (bytes memory) { + return type(Target).creationCode; + } +} + "#, + ); + + cmd.args(["test"]).assert_success(); +} + +// Test that `type(Contract).creationCode` keeps native pure semantics when dynamic linking is +// enabled. +#[forgetest_init] +fn preprocess_creation_code_in_pure_function(prj: _, cmd: _) { + prj.update_config(|config| { + config.dynamic_test_linking = true; + }); + + prj.add_source( + "Target.sol", + r#" +contract Target { + uint256 public immutable value; + constructor(uint256 _value) { value = _value; } +} + "#, + ); + + prj.add_test( + "Target.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {Target} from "../src/Target.sol"; + +contract TargetTest is Test { + function computeAddress(address factory, uint256 salt, uint256 value) internal pure returns (address) { + bytes32 hash = keccak256( + abi.encodePacked( + bytes1(0xff), + factory, + salt, + keccak256(abi.encodePacked(type(Target).creationCode, abi.encode(value))) + ) + ); + return address(uint160(uint256(hash))); + } + + function testComputeAddress() public pure { + computeAddress(address(0xBEEF), 1, 100); + } +} + "#, + ); + + cmd.args(["build"]).assert_success(); +} + +// Constant initializers must stay native and retain their bytecode dependencies. +#[forgetest] +fn preprocess_creation_code_in_constant_initializer(prj: _, cmd: _) { + let target = r#" +contract Target { + function value() external pure returns (uint256) { return 111; } +} +"#; + prj.add_test( + "ConstantCode.t.sol", + r#" +import {Target} from "../src/Target.sol"; + +contract ConstantCodeTest { + bytes constant CODE = type(Target).creationCode; + bytes32 constant CODE_HASH = keccak256(type(Target).creationCode); + bytes32 immutable initialHash = keccak256(type(Target).creationCode); + + function test_constant_code() public { + require(CODE_HASH == initialHash, "stale code hash"); + bytes memory code = CODE; + address deployed; + assembly { deployed := create(0, add(code, 32), mload(code)) } + require(Target(deployed).value() == 111, "changed value"); + } +} +"#, + ); + + for dynamic_test_linking in [false, true] { + prj.update_config(|config| config.dynamic_test_linking = dynamic_test_linking); + prj.add_source("Target.sol", target); + cmd.forge_fuse().args(["test", "--force"]).assert_success(); + cmd.forge_fuse().arg("test").assert_success().stdout_eq(str![[r#" +No files changed, compilation skipped +... +"#]]); + + prj.add_source("Target.sol", &target.replace("111", "222")); + for force in [false, true] { + cmd.forge_fuse().arg("test"); + if force { + cmd.arg("--force"); + } + cmd.assert_failure().stdout_eq(str![[r#" +... +Ran 1 test for test/ConstantCode.t.sol:ConstantCodeTest +[FAIL: changed value] test_constant_code() ([GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); + } + + prj.add_source("Target.sol", target); + cmd.forge_fuse().arg("test").assert_success(); + } +} + +// Test that `type(Contract).creationCode` keeps native pure semantics when it is used in a +// modifier body that is applied to a pure function. +#[forgetest_init] +fn preprocess_creation_code_in_modifier_used_by_pure_function(prj: _, cmd: _) { + prj.update_config(|config| { + config.dynamic_test_linking = true; + }); + + prj.add_source( + "Target.sol", + r#" +contract Target {} + "#, + ); + + prj.add_test( + "ModifierCreationCode.t.sol", + r#" +import {Target} from "../src/Target.sol"; + +contract ModifierCreationCodeTest { + modifier usesCreationCode() { + bytes memory code = type(Target).creationCode; + code; + _; + } + + function testModifierCreationCode() public pure usesCreationCode {} +} + "#, + ); + + cmd.args(["build"]).assert_success(); +} + +// Nested call options are copied verbatim and must retain native dependency edges. +#[forgetest] +fn preprocess_nested_deployment_options(prj: _, cmd: _) { + let other = r#" +contract Other { + function value() external pure returns (uint256) { return 111; } +} +"#; + prj.add_source("Other.sol", other); + prj.add_source( + "Target.sol", + r#" +contract Empty { constructor() payable {} } +contract Target { + uint256 public immutable value; + constructor(uint256 value_) payable { value = value_; } +} +"#, + ); + prj.add_test( + "Options.t.sol", + r#" +import {Other} from "../src/Other.sol"; +import {Empty, Target} from "../src/Target.sol"; +contract OptionsTest { + function expected() internal pure returns (bytes32) { + return keccak256(type(Empty).creationCode); + } + function test_salt() public { + Empty target = new Empty{salt: bytes32(new Other().value())}(); + address predicted = address(uint160(uint256(keccak256(abi.encodePacked( + bytes1(0xff), address(this), bytes32(uint256(111)), expected() + ))))); + require(address(target) == predicted, "changed salt"); + } + function test_value() public { + Empty target = new Empty{value: new Other().value()}(); + require(address(target).balance == 111, "changed value"); + } + function test_arguments() public { + Target target = new Target{value: new Other().value()}(new Other().value()); + require(address(target).balance == 111 && target.value() == 111, "changed arguments"); + } + function test_try() public { + try new Target{value: new Other().value()}(42) returns (Target target) { + require(address(target).balance == 111, "changed try value"); + } catch { revert("deployment failed"); } + } +} +"#, + ); + for dynamic_test_linking in [false, true] { + prj.update_config(|config| config.dynamic_test_linking = dynamic_test_linking); + prj.add_source("Other.sol", other); + cmd.forge_fuse().args(["test", "--force"]).assert_success(); + cmd.forge_fuse().arg("test").assert_success(); + prj.add_source("Other.sol", &other.replace("return 111", "return 222")); + cmd.forge_fuse().arg("test").assert_failure().stdout_eq(str![[r#" +... +Ran 4 tests for test/Options.t.sol:OptionsTest +[FAIL: changed arguments] test_arguments() ([GAS]) +[FAIL: changed salt] test_salt() ([GAS]) +[FAIL: changed try value] test_try() ([GAS]) +[FAIL: changed value] test_value() ([GAS]) +Suite result: FAILED. 0 passed; 4 failed; 0 skipped; [ELAPSED] +... +"#]]); + cmd.forge_fuse().args(["test", "--force"]).assert_failure().stdout_eq(str![[r#" +... +Ran 4 tests for test/Options.t.sol:OptionsTest +[FAIL: changed arguments] test_arguments() ([GAS]) +[FAIL: changed salt] test_salt() ([GAS]) +[FAIL: changed try value] test_try() ([GAS]) +[FAIL: changed value] test_value() ([GAS]) +Suite result: FAILED. 0 passed; 4 failed; 0 skipped; [ELAPSED] +... +"#]]); + } +} + +// Constructor helper fields use type spans, independent of parameter data-location spelling. +#[forgetest] +fn preprocess_constructor_parameter_types(prj: _, cmd: _) { + let target = r#" +contract Target { + uint256 public value; + constructor( + bytes + memory + a, + string/* before */memory/* after */b, + uint256[] memory c, + bytes memory, + function(bytes memory) external returns (bytes memory) callback + ) { + require(a.length == 1 && bytes(b).length == 2 && c.length == 3); + require(callback(a).length == 1); + value = 111; + } +} +contract Named { + uint256 public value; + constructor(bytes/* before */memory/* after */data) { + require(data.length == 1); + value = 111; + } +} +"#; + prj.add_source("Target.sol", target); + prj.add_test( + "Parameters.t.sol", + r#" +import {Target, Named} from "../src/Target.sol"; +contract ParametersTest { + function echo(bytes memory data) external pure returns (bytes memory) { return data; } + function test_positional() public { + Target target = new Target(hex"01", "ab", new uint256[](3), hex"", this.echo); + require(target.value() == 111, "changed positional"); + } + function test_named() public { + Named target = new Named({data: hex"01"}); + require(target.value() == 111, "changed named"); + } +} +"#, + ); + for dynamic_test_linking in [false, true] { + prj.update_config(|config| config.dynamic_test_linking = dynamic_test_linking); + prj.add_source("Target.sol", target); + cmd.forge_fuse().args(["test", "--force"]).assert_success(); + cmd.forge_fuse().arg("test").assert_success(); + prj.add_source("Target.sol", &target.replace("value = 111", "value = 222")); + cmd.forge_fuse().arg("test").assert_failure().stdout_eq(str![[r#" +... +Ran 2 tests for test/Parameters.t.sol:ParametersTest +[FAIL: changed named] test_named() ([GAS]) +[FAIL: changed positional] test_positional() ([GAS]) +Suite result: FAILED. 0 passed; 2 failed; 0 skipped; [ELAPSED] +... +"#]]); + cmd.forge_fuse().args(["test", "--force"]).assert_failure().stdout_eq(str![[r#" +... +Ran 2 tests for test/Parameters.t.sol:ParametersTest +[FAIL: changed named] test_named() ([GAS]) +[FAIL: changed positional] test_positional() ([GAS]) +Suite result: FAILED. 0 passed; 2 failed; 0 skipped; [ELAPSED] +... +"#]]); + } +} + +#[forgetest] +fn preprocess_private_constructor_array_dimensions(prj: _, cmd: _) { + let targets = r#" +contract Nested { + uint256 private constant N = 2; + constructor(uint256[N][3] memory xs) { require(xs[2][1] == 7); } + function value() external pure returns (uint256) { return 111; } +} +contract Expression { + uint256 private constant N = 2; + constructor(uint256[N + 1] memory xs) { require(xs[2] == 7); } + function value() external pure returns (uint256) { return 111; } +} +contract Unnamed { + uint256 private constant N = 2; + constructor(uint256[N] memory) {} + function value() external pure returns (uint256) { return 111; } +} +contract Literal { + constructor(uint256[2] memory) {} + function value() external pure returns (uint256) { return 111; } +} +"#; + prj.add_test( + "PrivateDimensions.t.sol", + r#" +import {Expression, Literal, Nested, Unnamed} from "../src/Targets.sol"; + +contract PrivateDimensionsTest { + function test_nested() public { + uint256[2][3] memory xs; + xs[2][1] = 7; + require(new Nested(xs).value() == 111, "changed value"); + } + function test_expression() public { + uint256[3] memory xs; + xs[2] = 7; + require(new Expression(xs).value() == 111, "changed value"); + } + function test_unnamed() public { + require(new Unnamed([uint256(1), 2]).value() == 111, "changed value"); + } + function test_literal() public { + require(new Literal([uint256(1), 2]).value() == 111, "changed value"); + } +} +"#, + ); + + for dynamic_test_linking in [false, true] { + prj.update_config(|config| config.dynamic_test_linking = dynamic_test_linking); + prj.add_source("Targets.sol", targets); + cmd.forge_fuse().args(["test", "--force"]).assert_success(); + cmd.forge_fuse().arg("test").assert_success(); + + prj.add_source("Targets.sol", &targets.replace("111", "222")); + for force in [false, true] { + cmd.forge_fuse().arg("test"); + if force { + cmd.arg("--force"); + } + cmd.assert_failure().stdout_eq(str![[r#" +... +Ran 4 tests for test/PrivateDimensions.t.sol:PrivateDimensionsTest +[FAIL: changed value] test_expression() ([GAS]) +[FAIL: changed value] test_literal() ([GAS]) +[FAIL: changed value] test_nested() ([GAS]) +[FAIL: changed value] test_unnamed() ([GAS]) +Suite result: FAILED. 0 passed; 4 failed; 0 skipped; [ELAPSED] +... +"#]]); + } + + prj.add_source("Targets.sol", targets); + cmd.forge_fuse().arg("test").assert_success(); + } + + cmd.forge_fuse() + .args(["test", "--match-test", "test_literal", "-vvvv"]) + .assert_success() + .stdout_eq(str![[r#" +... +Traces: +... + ├─ [0] VM::deployCode("src/Targets.sol:Literal", 0x[..]) +... +"#]]); +} + +#[forgetest] +fn preprocess_generated_interface_name_collision(prj: _, cmd: _) { + let target = r#" +contract Target { + function value() external pure returns (uint256) { return 111; } +} +"#; + prj.add_source("Target.sol", target); + prj.add_source( + "ImportedNames.sol", + "interface VmContractHelper2 {} interface VmContractHelper2_ {}", + ); + prj.add_test( + "Collision.t.sol", + r#" +import {Target} from "../src/Target.sol"; +import "../src/ImportedNames.sol"; + +contract CollisionTest { + function test_value() public { + require(new Target().value() == 111, "changed value"); + } +} +"#, + ); + + for dynamic_test_linking in [false, true] { + prj.update_config(|config| config.dynamic_test_linking = dynamic_test_linking); + prj.add_source("Target.sol", target); + cmd.forge_fuse().args(["test", "--force"]).assert_success(); + cmd.forge_fuse().arg("test").assert_success(); + prj.add_source("Target.sol", &target.replace("return 111", "return 222")); + cmd.forge_fuse().arg("test").assert_failure().stdout_eq(str![[r#" +... +Ran 1 test for test/Collision.t.sol:CollisionTest +[FAIL: changed value] test_value() ([GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); + } +} + +#[forgetest] +fn preprocess_generated_constructor_helper_name_collision(prj: _, cmd: _) { + let base = r#" +contract Base { + struct FoundryPpConstructorArgs { uint256 unused; } + function encodeArgs0() public pure returns (uint256) { return 0; } +} +"#; + let target = r#" +import {Base} from "./Base.sol"; +contract Target is Base { + uint256 public value; + constructor(uint256, uint256 foundry_pp_ctor_arg1) { + value = foundry_pp_ctor_arg1 + 110; + } +} +contract DeployHelper0 {} +function encodeArgs0() pure returns (uint256) { return 0; } +"#; + prj.add_source("Base.sol", base); + prj.add_source("Target.sol", target); + prj.add_test( + "Collision.t.sol", + r#" +import {Target} from "../src/Target.sol"; + +contract DeployHelper0_ {} +function encodeArgs0_() pure returns (uint256) { return 0; } + +contract CollisionTest { + function test_value() public { + require(new Target(0, 1).value() == 111, "changed value"); + } +} +"#, + ); + + for dynamic_test_linking in [false, true] { + prj.update_config(|config| config.dynamic_test_linking = dynamic_test_linking); + prj.add_source("Base.sol", base); + prj.add_source("Target.sol", target); + cmd.forge_fuse().args(["test", "--force"]).assert_success(); + cmd.forge_fuse().arg("test").assert_success(); + prj.add_source("Target.sol", &target.replace("+ 110", "+ 220")); + cmd.forge_fuse().arg("test").assert_failure().stdout_eq(str![[r#" +... +Ran 1 test for test/Collision.t.sol:CollisionTest +[FAIL: changed value] test_value() ([GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); + } +} + +#[forgetest] +fn preprocess_try_adapter_name_collision(prj: _, cmd: _) { + let target = r#" +contract Target { + function value() external pure returns (uint256) { return 111; } +} +"#; + prj.add_source("Target.sol", target); + prj.add_test( + "Collision.t.sol", + r#" +import {Target} from "../src/Target.sol"; + +contract CollisionTest { + function addressToTarget1(address target) public pure returns (Target) { + return Target(target); + } + + function addressToTarget1_(address target) public pure returns (Target) { + return Target(target); + } + + function test_value() public { + try new Target() returns (Target target) { + require(target.value() == 111, "changed value"); + } catch { + revert("deployment failed"); + } + } +} +"#, + ); + + for dynamic_test_linking in [false, true] { + prj.update_config(|config| config.dynamic_test_linking = dynamic_test_linking); + prj.add_source("Target.sol", target); + cmd.forge_fuse().args(["test", "--force"]).assert_success(); + cmd.forge_fuse().arg("test").assert_success(); + prj.add_source("Target.sol", &target.replace("return 111", "return 222")); + cmd.forge_fuse().arg("test").assert_failure().stdout_eq(str![[r#" +... +Ran 1 test for test/Collision.t.sol:CollisionTest +[FAIL: changed value] test_value() ([GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); + } +} + +#[forgetest] +fn preprocess_payable_try_adapter(prj: _, cmd: _) { + let target = r#" +contract Target { + uint256 public value; + constructor(uint256 offset) payable { value = offset + 111; } + receive() external payable {} +} +"#; + prj.add_source("Target.sol", target); + prj.add_test( + "Payable.t.sol", + r#" +import {Target} from "../src/Target.sol"; + +contract PayableTest { + function test_value() public { + try new Target(0) returns (Target target) { + require(target.value() == 111, "changed value"); + } catch { + revert("deployment failed"); + } + } +} +"#, + ); + + for dynamic_test_linking in [false, true] { + prj.update_config(|config| config.dynamic_test_linking = dynamic_test_linking); + prj.add_source("Target.sol", target); + cmd.forge_fuse().args(["test", "--force"]).assert_success(); + cmd.forge_fuse().arg("test").assert_success(); + prj.add_source("Target.sol", &target.replace("+ 111", "+ 222")); + cmd.forge_fuse().arg("test").assert_failure().stdout_eq(str![[r#" +... +Ran 1 test for test/Payable.t.sol:PayableTest +[FAIL: changed value] test_value() ([GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); + } +} + +// Windows filenames cannot contain double quotes. +#[cfg(unix)] +#[forgetest] +fn preprocess_generated_path_string_escaping(prj: _, cmd: _) { + let target = r#" +contract Zero { + function value() external pure returns (uint256) { return 111; } +} +contract Args { + uint256 public value; + constructor(uint256 offset) { value = offset + 111; } +} +"#; + prj.add_source("Quoted\"Path.sol", target); + prj.add_test( + "Quoted.t.sol", + r#" +import {Zero, Args} from "../src/Quoted\"Path.sol"; + +contract QuotedTest { + function test_zero() public { + require(new Zero().value() == 111, "changed zero"); + } + + function test_args() public { + require(new Args(0).value() == 111, "changed args"); + } +} +"#, + ); + + for dynamic_test_linking in [false, true] { + prj.update_config(|config| config.dynamic_test_linking = dynamic_test_linking); + prj.add_source("Quoted\"Path.sol", target); + cmd.forge_fuse().args(["test", "--force"]).assert_success(); + cmd.forge_fuse().arg("test").assert_success(); + prj.add_source( + "Quoted\"Path.sol", + &target.replace("+ 111", "+ 222").replace("return 111", "return 222"), + ); + cmd.forge_fuse().arg("test").assert_failure().stdout_eq(str![[r#" +... +Ran 2 tests for test/Quoted.t.sol:QuotedTest +[FAIL: changed args] test_args() ([GAS]) +[FAIL: changed zero] test_zero() ([GAS]) +Suite result: FAILED. 0 passed; 2 failed; 0 skipped; [ELAPSED] +... +"#]]); + } +} + +// Windows directory names cannot contain colons. +#[cfg(unix)] +#[forgetest] +fn preprocess_colon_in_artifact_path(prj: _, cmd: _) { + let target = r#" +contract Zero { + function value() external pure returns (uint256) { return 111; } +} +contract Args { + uint256 public value; + constructor(uint256 offset) { value = offset + 111; } +} +"#; + prj.add_source("Colon:Path.sol", target); + prj.add_source( + "Parsed:Wrong.sol", + "contract Parsed { function value() external pure returns (uint256) { return 111; } }", + ); + prj.add_test( + "Colon.t.sol", + r#" +import {Zero, Args} from "../src/Colon:Path.sol"; +import {Parsed} from "../src/Parsed:Wrong.sol"; + +contract ColonTest { + function test_zero() public { + require(new Zero().value() == 111, "changed zero"); + } + + function test_args() public { + require(new Args(0).value() == 111, "changed args"); + } + + function test_parseable_identifier() public { + require(new Parsed().value() == 111, "changed parsed"); + } + + function test_running_profile_bytecode() public { + require( + keccak256(address(new Zero()).code) == keccak256(type(Zero).runtimeCode), + "wrong profile bytecode" + ); + } +} +"#, + ); + prj.update_config(|config| { + config.additional_compiler_profiles = vec![SettingsOverrides { + name: "optimized".to_owned(), + via_ir: None, + evm_version: None, + optimizer: Some(true), + optimizer_runs: Some(10_000), + bytecode_hash: None, + }]; + config.compilation_restrictions = vec![CompilationRestrictions { + paths: "test/Colon.t.sol".parse().unwrap(), + version: None, + via_ir: None, + bytecode_hash: None, + min_optimizer_runs: Some(10_000), + optimizer_runs: None, + max_optimizer_runs: None, + min_evm_version: None, + evm_version: None, + max_evm_version: None, + }]; + }); + + for dynamic_test_linking in [false, true] { + prj.update_config(|config| config.dynamic_test_linking = dynamic_test_linking); + prj.add_source("Colon:Path.sol", target); + prj.add_source( + "Parsed:Wrong.sol", + "contract Parsed { function value() external pure returns (uint256) { return 111; } }", + ); + cmd.forge_fuse().args(["test", "--force"]).assert_success(); + cmd.forge_fuse().arg("test").assert_success(); + prj.add_source( + "Colon:Path.sol", + &target.replace("+ 111", "+ 222").replace("return 111", "return 222"), + ); + prj.add_source( + "Parsed:Wrong.sol", + "contract Parsed { function value() external pure returns (uint256) { return 222; } }", + ); + cmd.forge_fuse().arg("test").assert_failure().stdout_eq(str![[r#" +... +Ran 4 tests for test/Colon.t.sol:ColonTest +[FAIL: changed args] test_args() ([GAS]) +[FAIL: changed parsed] test_parseable_identifier() ([GAS]) +[PASS] test_running_profile_bytecode() ([GAS]) +[FAIL: changed zero] test_zero() ([GAS]) +Suite result: FAILED. 1 passed; 3 failed; 0 skipped; [ELAPSED] +... +"#]]); + } +} + +#[forgetest] +fn preprocess_create_isolation_boundary(prj: _, cmd: _) { + prj.add_source( + "Target.sol", + r#" +interface Callback { + function read() external view returns (uint256); + function write() external; +} +contract Target { + uint256 public observed; + constructor() { + observed = Callback(msg.sender).read(); + Callback(msg.sender).write(); + } +} +"#, + ); + for isolate in [false, true] { + prj.update_config(|config| { + config.isolate = isolate; + config.evm_version = EvmVersion::Cancun; + }); + prj.add_test( + "Isolation.t.sol", + &format!( + r#" +import {{Target}} from '../src/Target.sol'; +contract IsolationTest {{ + function read() external view returns (uint256 n) {{ assembly {{ n := tload(0) }} }} + function write() external {{ assembly {{ tstore(0, 22) }} }} + function test_create() public {{ + assembly {{ tstore(0, 11) }} + Target target = new Target(); + require(target.observed() == {}, "constructor boundary"); + uint256 n; + assembly {{ n := tload(0) }} + require(n == {}, "outer boundary"); + }} + function test_create2() public {{ + assembly {{ tstore(0, 11) }} + Target target = new Target{{salt: bytes32(uint256(7))}}(); + require(target.observed() == 11, "salted constructor boundary"); + uint256 n; + assembly {{ n := tload(0) }} + require(n == 22, "salted outer boundary"); + }} +}} +"#, + if isolate { 0 } else { 11 }, + if isolate { 11 } else { 22 }, + ), + ); + for dynamic in [false, true] { + prj.update_config(|config| config.dynamic_test_linking = dynamic); + cmd.forge_fuse().args(["test", "--force"]).assert_success().stdout_eq(str![[r#" +... +Ran 2 tests for test/Isolation.t.sol:IsolationTest +[PASS] test_create() ([GAS]) +[PASS] test_create2() ([GAS]) +Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 2 tests passed, 0 failed, 0 skipped (2 total tests) + +"#]]); + } + } + // Confirm the isolation regression exercised a rewritten CREATE and balanced nested traces. + cmd.forge_fuse() + .args(["test", "--match-test", r"^test_create\(", "-vvvv"]) + .assert_success() + .stdout_eq(str![[r#" +No files changed, compilation skipped + +Ran 1 test for test/Isolation.t.sol:IsolationTest +[PASS] test_create() ([GAS]) +Traces: + [[..]] IsolationTest::test_create() + ├─ [0] VM::deployCode("src/Target.sol:Target") + │ ├─ [[..]] → new Target@[..] + │ │ ├─ [[..]] IsolationTest::read() [staticcall] + │ │ │ └─ ← [Return] 0 + │ │ ├─ [[..]] IsolationTest::write() + │ │ │ └─ ← [Stop] + │ │ └─ ← [Return] [..] bytes of code + │ └─ ← [Return] Target: [..] + ├─ [[..]] Target::observed() [staticcall] + │ └─ ← [Return] 0 + └─ ← [Stop] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); +} + +#[forgetest] +fn preprocess_constructor_validation(prj: _, cmd: _) { + // These constraints would disappear with the original new-expression. Solc must reject + // exactly the same source in both modes, including implicit and explicit constructors. + for (target, expression) in [ + ("contract Target {}", "new Target(7)"), + ("contract Target {}", "new Target({oops: 7})"), + ("contract Target { constructor() {} }", "new Target(7)"), + ("contract Target {}", "new Target{gas: 100000}()"), + ("contract Target {}", "new Target{value: 0}()"), + ("contract Target { constructor(uint256 x) {} }", "new Target{gas: 100000}(7)"), + ("contract Target { constructor(uint256 x) {} }", "new Target{value: 0}(7)"), + ("contract Target { constructor(uint256 x) {} }", "new Target({oops: 7})"), + ("contract Target { constructor(uint256 x, uint256 y) {} }", "new Target({x: 7, x: 8})"), + ("library Target {}", "new Target()"), + ("abstract contract Target { function f() external virtual; }", "new Target()"), + ] { + prj.add_source("Target.sol", target); + prj.add_test( + "Invalid.t.sol", + &format!( + "import '../src/Target.sol'; contract InvalidTest {{ function test_invalid() public {{ {expression}; }} }}" + ), + ); + prj.update_config(|config| config.dynamic_test_linking = false); + let native = cmd + .forge_fuse() + .args(["build", "--force"]) + .assert_failure() + .get_output() + .stderr + .clone(); + prj.update_config(|config| config.dynamic_test_linking = true); + cmd.forge_fuse().args(["build", "--force"]).assert_failure().stderr_eq(native); + } +} + +#[forgetest] +fn preprocess_constructor_abi_coder_validation(prj: _, cmd: _) { + // Encoding in the generated helper must not bypass the caller's ABI-coder restrictions. + for (parameters, arguments) in + [("S memory s", "Target.S(7)"), ("uint256[][] memory xs", "new uint256[][](0)")] + { + prj.add_source( + "Target.sol", + &format!( + "pragma abicoder v2; contract Target {{ struct S {{ uint256 n; }} constructor({parameters}) {{}} }}" + ), + ); + for pragma in + ["pragma abicoder v1;", "pragma abicoder v2;", "pragma experimental ABIEncoderV2;"] + { + prj.add_test( + "Coder.t.sol", + &format!( + "{pragma} import '../src/Target.sol'; contract CoderTest {{ function test_construct() public {{ new Target({arguments}); }} }}" + ), + ); + prj.update_config(|config| config.dynamic_test_linking = false); + if pragma == "pragma abicoder v1;" { + let native = cmd + .forge_fuse() + .args(["build", "--force"]) + .assert_failure() + .get_output() + .stderr + .clone(); + prj.update_config(|config| config.dynamic_test_linking = true); + cmd.forge_fuse().args(["build", "--force"]).assert_failure().stderr_eq(native); + } else { + cmd.forge_fuse().args(["test", "--force"]).assert_success(); + prj.update_config(|config| config.dynamic_test_linking = true); + cmd.forge_fuse().args(["test", "--force"]).assert_success(); + } + } + } +} + +#[forgetest] +fn preprocess_constructor_abi_v1_native_cache(prj: _, cmd: _) { + prj.add_test( + "Coder.t.sol", + r#" +pragma abicoder v1; +import "../src/Target.sol"; +contract CoderTest { + function test_construct() public { + require(new Target(7).value() == 7, "stale constructor"); + } +} +"#, + ); + for dynamic in [false, true] { + prj.update_config(|config| config.dynamic_test_linking = dynamic); + prj.add_source( + "Target.sol", + "contract Target { uint256 public value; constructor(uint256 n) { value = n; } }", + ); + cmd.forge_fuse().args(["test", "--force"]).assert_success(); + cmd.forge_fuse().arg("test").assert_success(); + prj.add_source( + "Target.sol", + "contract Target { uint256 public value; constructor(uint256 n) { value = n + 1; } }", + ); + for force in [false, true] { + cmd.forge_fuse().arg("test"); + if force { + cmd.arg("--force"); + } + cmd.assert_failure().stdout_eq(str![[r#" +... +[FAIL: stale constructor] test_construct() ([GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); + } + } +} + +#[forgetest] +fn preprocess_constructor_evm_version_validation(prj: _, cmd: _) { + // The compilation target controls CREATE2 validation, independently of runtime settings. + prj.update_config(|config| { + config.solc = Some(foundry_config::SolcReq::Version(semver::Version::new(0, 8, 26))); + }); + prj.add_source("Target.sol", "pragma solidity ^0.8.0; contract Target {}"); + for (evm_version, salt, valid) in [ + (EvmVersion::Byzantium, "{salt: bytes32(0)}", false), + (EvmVersion::Byzantium, "", true), + (EvmVersion::Constantinople, "{salt: bytes32(0)}", true), + ] { + prj.add_test( + "Version.t.sol", + &format!("pragma solidity ^0.8.0; import '../src/Target.sol'; contract VersionTest {{ function test_construct() public {{ new Target{salt}(); }} }}"), + ); + prj.update_config(|config| { + config.evm_version = evm_version; + config.dynamic_test_linking = false; + }); + if valid { + cmd.forge_fuse().args(["test", "--force"]).assert_success(); + prj.update_config(|config| config.dynamic_test_linking = true); + cmd.forge_fuse().args(["test", "--force"]).assert_success(); + } else { + let native = cmd + .forge_fuse() + .args(["build", "--force"]) + .assert_failure() + .get_output() + .stderr + .clone(); + prj.update_config(|config| config.dynamic_test_linking = true); + cmd.forge_fuse().args(["build", "--force"]).assert_failure().stderr_eq(native); + } + } +} + +#[forgetest] +fn preprocess_return_data_observation(prj: _, cmd: _) { + prj.add_source("Target.sol", "contract Target {}"); + // Each case has its own contract and helper source, so another observer cannot mask a + // discovery failure. Abstract bases keep inherited tests in the derived suite only. + for (case, helper, declarations) in [ + ( + "create", + "", + r#" +contract ReturnDataTest { + function test_return_data() public { + new Target(); + uint256 n; + assembly { n := returndatasize() } + require(n == 0, "create buffer"); + } +}"#, + ), + ( + "namespace_create2", + "function size() view returns (uint256 n) { assembly { n := returndatasize() } }", + r#" +import * as Observe from '../src/Observe.sol'; +contract ReturnDataTest { + function test_return_data() public { + new Target{salt: bytes32(uint256(7))}(); + require(Observe.size() == 0, "create2 buffer"); + } +}"#, + ), + ( + "inherited_producer", + "function size() view returns (uint256 n) { assembly { n := returndatasize() } }", + r#" +import * as Observe from '../src/Observe.sol'; +abstract contract Base { function make() internal { new Target(); } } +contract ReturnDataTest is Base { + function test_return_data() public { + make(); + require(Observe.size() == 0, "inherited producer buffer"); + } +}"#, + ), + ( + "inherited_observer", + "", + r#" +abstract contract Base { + function make() internal virtual; + function test_return_data() public { + make(); + uint256 n; + assembly { n := returndatasize() } + require(n == 0, "inherited observer buffer"); + } +} +contract ReturnDataTest is Base { + function make() internal override { new Target(); } +}"#, + ), + ( + "using_initializer", + "library Reader { function size(uint256) internal view returns (uint256 n) { assembly { n := returndatasize() } } }", + r#" +import {Reader} from '../src/Observe.sol'; +contract ReturnDataTest { + using Reader for uint256; + Target target = new Target(); + uint256 observed = uint256(0).size(); + function test_return_data() public view { + require(observed == 0, "initializer buffer"); + } +}"#, + ), + ( + "using_function", + "function size(uint256) view returns (uint256 n) { assembly { n := returndatasize() } }", + r#" +import {size} from '../src/Observe.sol'; +contract ReturnDataTest { + using {size} for uint256; + function test_return_data() public { + new Target(); + require(uint256(0).size() == 0, "using function buffer"); + } +}"#, + ), + ( + "aliased_using", + "function size(uint256) view returns (uint256 n) { assembly { n := returndatasize() } }", + r#" +import {size as readSize} from '../src/Observe.sol'; +contract ReturnDataTest { + using {readSize} for uint256; + function test_return_data() public { + new Target(); + require(uint256(0).readSize() == 0, "aliased using function buffer"); + } +}"#, + ), + ( + "library", + "library Reader { function size(uint256) internal view returns (uint256 n) { assembly { n := returndatasize() } } }", + r#" +import {Reader} from '../src/Observe.sol'; +contract ReturnDataTest { + function test_return_data() public { + new Target(); + require(Reader.size(0) == 0, "library buffer"); + } +}"#, + ), + ( + "parenthesized_library", + "library Reader { function size() internal pure returns (uint256 n) { assembly { n := returndatasize() } } }", + r#" +import {Reader} from '../src/Observe.sol'; +contract ReturnDataTest { + function test_return_data() public { + new Target(); + require((Reader).size() == 0, "parenthesized library buffer"); + } +}"#, + ), + ( + "unary_operator", + "type Word is uint256; using {size as -} for Word global; function size(Word) pure returns (Word) { uint256 n; assembly { n := returndatasize() } return Word.wrap(n); }", + r#" +import {Word} from '../src/Observe.sol'; +contract ReturnDataTest { + function test_return_data() public { + new Target(); + require(Word.unwrap(-Word.wrap(0)) == 0, "unary operator buffer"); + } +}"#, + ), + ( + "binary_operator", + "type Word is uint256; using {size as +} for Word global; function size(Word, Word) pure returns (Word) { uint256 n; assembly { n := returndatasize() } return Word.wrap(n); }", + r#" +import {Word} from '../src/Observe.sol'; +contract ReturnDataTest { + function test_return_data() public { + new Target(); + require(Word.unwrap(Word.wrap(0) + Word.wrap(0)) == 0, "binary operator buffer"); + } +}"#, + ), + ( + "creation_code", + "", + r#" +contract ReturnDataTest { + function ping() external pure returns (uint256) { return 77; } + function test_return_data() public { + this.ping(); + bytes memory code = type(Target).creationCode; + uint256 value; + assembly { returndatacopy(0, 0, 32) value := mload(0) } + require(code.length > 0 && value == 77, "creation code buffer"); + } +}"#, + ), + ( + "modifier", + "", + r#" +contract ReturnDataTest { + modifier check() { + _; + uint256 n; + assembly { n := returndatasize() } + require(n == 0, "modifier buffer"); + } + function test_return_data() public check { new Target(); } +}"#, + ), + ( + "function_pointer", + "function size() view returns (uint256 n) { assembly { n := returndatasize() } }", + r#" +import {size} from '../src/Observe.sol'; +contract ReturnDataTest { + function test_return_data() public { + function() internal view returns (uint256) observe = size; + new Target(); + require(observe() == 0, "function pointer buffer"); + } +}"#, + ), + ] { + prj.add_source("Observe.sol", helper); + prj.add_test( + "ReturnData.t.sol", + &format!("import '../src/Target.sol'; {declarations}") + .replace("test_return_data", &format!("test_{case}")), + ); + for dynamic in [false, true] { + prj.update_config(|config| config.dynamic_test_linking = dynamic); + cmd.forge_fuse().args(["test", "--force"]).assert_success().stdout_eq(format!( + r#"... +Ran 1 test for test/ReturnData.t.sol:ReturnDataTest +[PASS] test_{case}() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) +"# + )); + } + } +} + +#[forgetest] +fn preprocess_inline_verbatim_diagnostics(prj: _, cmd: _) { + prj.add_source("Target.sol", "contract Target {}"); + for expression in ["let n := verbatim_0i_1o(hex\"3d\")", "verbatim_3i_0o(hex\"3e\", 0, 0, 0)"] { + prj.add_test( + "Verbatim.t.sol", + &format!("import '../src/Target.sol'; contract VerbatimTest {{ function test_verbatim() public {{ new Target(); assembly {{ {expression} }} }} }}"), + ); + prj.update_config(|config| config.dynamic_test_linking = false); + let native = cmd + .forge_fuse() + .args(["build", "--force"]) + .assert_failure() + .get_output() + .stderr + .clone(); + prj.update_config(|config| config.dynamic_test_linking = true); + let mut expected = b"Warning: dynamic test linking disabled for 1 files: error: unsupported verbatim builtin\n".to_vec(); + expected.extend(native); + cmd.forge_fuse().args(["build", "--force"]).assert_failure().stderr_eq(expected); + } +} + +#[forgetest] +fn preprocess_imported_constant_dependencies(prj: _, cmd: _) { + let target = + "contract Target { function value() external pure returns (uint256) { return 11; } }"; + for (import, value) in [ + ("import {CODE} from '../src/Constants.sol';", "CODE"), + ("import {CODE as ALIAS} from '../src/Constants.sol';", "ALIAS"), + ("import {ALIAS} from '../src/Constants.sol';", "ALIAS"), + ("import * as Constants from '../src/Constants.sol';", "Constants.CODE"), + ] { + prj.add_source( + "Constants.sol", + "import './Target.sol'; bytes constant CODE = type(Target).creationCode; bytes constant ALIAS = CODE;", + ); + prj.add_test( + "Constants.t.sol", + &format!( + r#" +{import} +import {{Target}} from '../src/Target.sol'; +contract ConstantsTest {{ + function test_value() public {{ + bytes memory code = {value}; + address deployed; + assembly {{ deployed := create(0, add(code, 32), mload(code)) }} + require(Target(deployed).value() == 11, "value"); + }} +}} +"# + ), + ); + for dynamic in [false, true] { + prj.update_config(|config| config.dynamic_test_linking = dynamic); + prj.add_source("Target.sol", target); + cmd.forge_fuse().args(["test", "--force"]).assert_success(); + cmd.forge_fuse().arg("test").assert_success().stdout_eq(str![[r#" +No files changed, compilation skipped + +Ran 1 test for test/Constants.t.sol:ConstantsTest +[PASS] test_value() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); + prj.add_source("Target.sol", &target.replace("11", "22")); + for force in [false, true] { + cmd.forge_fuse().arg("test"); + if force { + cmd.arg("--force"); + } + cmd.assert_failure().stdout_eq(str![[r#" +... +Ran 1 test for test/Constants.t.sol:ConstantsTest +[FAIL: value] test_value() ([GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +... +"#]]); + } + prj.add_source("Target.sol", target); + cmd.forge_fuse().arg("test").assert_success(); + } + } +} + +// +#[forgetest] +fn filtered_tests_preserve_cyclic_import_order(prj: _, cmd: _) { + prj.update_config(|config| { + config.solc = Some(foundry_config::SolcReq::Version(semver::Version::new(0, 8, 26))); + config.evm_version = EvmVersion::Cancun; + }); + prj.add_test( + "helpers/Base.sol", + r#" +pragma solidity ^0.8.24; +import "./Derived.sol"; +contract Base {} +"#, + ); + prj.add_test( + "helpers/Derived.sol", + r#" +pragma solidity ^0.8.24; +import "./Base.sol"; +contract Derived is Base {} +"#, + ); + // This root makes solc visit Derived before Base. Dropping it reverses traversal of the + // import cycle and produces error 2449, even though inheritance itself is acyclic. + prj.add_test( + "Anchor.t.sol", + r#" +pragma solidity ^0.8.24; +import "./helpers/Derived.sol"; +contract Anchor { + function testOther() public pure { revert("must remain filtered out"); } +} +"#, + ); + prj.add_test( + "Selected.t.sol", + r#" +pragma solidity ^0.8.24; +contract Selected { + function testSelected() public pure {} +} +"#, + ); + // Test compilation should continue to omit unrelated scripts, including on retry. + prj.add_raw_script("Broken.s.sol", "this is not valid Solidity"); + + for dynamic_test_linking in [false, true] { + prj.update_config(|config| config.dynamic_test_linking = dynamic_test_linking); + for filter in [ + ["--match-test", "testSelected"], + ["--match-contract", "Selected"], + ["--match-path", "test/Selected.t.sol"], + ] { + cmd.forge_fuse().args(["test", "--force"]).args(filter).assert_success().stdout_eq( + str![[r#" +... +Ran 1 test for test/Selected.t.sol:Selected +[PASS] testSelected() ([GAS]) +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]], + ); + // Reusing the artifacts from the retry must preserve the test selection. + cmd.forge_fuse().args(["test"]).args(filter).assert_success(); + } + + cmd.forge_fuse() + .args(["test", "--force", "--match-test", "^__nomatch__$", "--json"]) + .assert_json_stdout("{}"); + } +} + +// +#[forgetest] +fn preprocess_analysis_fallback_uses_import_invalidation(prj: _, cmd: _) { + prj.update_config(|config| { + config.dynamic_test_linking = true; + config.solc = Some(foundry_config::SolcReq::Version("0.6.12".parse().unwrap())); + config.evm_version = EvmVersion::Istanbul; + config.lint.lint_on_build = false; + }); + let implementation = "pragma solidity 0.6.12; contract Impl { function value() external pure returns (uint256) { return 111; } }"; + prj.add_raw_source("Impl.sol", implementation); + prj.add_raw_source( + "Middle.sol", + "pragma solidity 0.6.12; import './Impl.sol'; contract Middle is Impl {}", + ); + prj.add_raw_source("Unrelated.sol", "pragma solidity 0.6.12; contract Unrelated {}"); + let test = r#" +pragma solidity 0.6.12; +import "../src/Middle.sol"; +contract FallbackTest { + // Valid in solc 0.6.12, but unavailable in Solar's analysis. + uint256 timestamp = now; + function test_fallback() public { + require(new Middle().value() == 111, "stale fallback bytecode"); + } +} +"#; + prj.add_raw_test("Fallback.t.sol", test); + prj.add_raw_test( + "Independent.t.sol", + "pragma solidity 0.6.12; contract IndependentTest { function test_independent() public pure {} }", + ); + cmd.args(["build"]).assert_success().stderr_eq(str![[r#" +Warning: dynamic test linking disabled for 2 files: error: unresolved symbol `now` + +"#]]); + + prj.add_raw_source("Unrelated.sol", "pragma solidity 0.6.12; contract Unrelated {} // edit"); + cmd.forge_fuse() + .arg("build") + .with_no_redact() + .assert_success() + .stdout_eq(str![[r#" +Compiling 1 files with Solc 0.6.12 +Solc 0.6.12 finished in [..] +Compiler run successful! + +"#]]) + .stderr_eq(str![""]); + + prj.add_raw_test("Independent.t.sol", "pragma solidity 0.6.12; contract IndependentTest { function test_independent() public pure {} } // edit"); + cmd.forge_fuse() + .arg("build") + .with_no_redact() + .assert_success() + .stdout_eq(str![[r#" +Compiling 1 files with Solc 0.6.12 +Solc 0.6.12 finished in [..] +Compiler run successful! + +"#]]) + .stderr_eq(str![""]); + + prj.add_raw_source("Impl.sol", &implementation.replace("return 111", "return 222")); + cmd.forge_fuse() + .arg("test") + .with_no_redact() + .assert_failure() + .stdout_eq(str![[r#" +Compiling 3 files with Solc 0.6.12 +Solc 0.6.12 finished in [..] +Compiler run successful! +... +[FAIL: stale fallback bytecode] test_fallback() ([..]) +... +"#]]) + .stderr_eq(str![[r#" +Warning: dynamic test linking disabled for 1 files: error: unresolved symbol `now` + +"#]]); +} diff --git a/crates/forge/tests/cli/utils.rs b/crates/forge/tests/cli/utils.rs index 35b1bf73a7bfd..ebea2e3a723c7 100644 --- a/crates/forge/tests/cli/utils.rs +++ b/crates/forge/tests/cli/utils.rs @@ -1,8 +1,12 @@ //! Various helper functions use alloy_chains::NamedChain; -use alloy_primitives::Address; -use alloy_signer_local::PrivateKeySigner; +use std::{ + io::Read, + path::Path, + process::{Child, Command, Output, Stdio}, + thread::{self, JoinHandle}, +}; /// Returns the current millis since unix epoch. /// @@ -14,14 +18,8 @@ pub fn millis_since_epoch() -> u128 { .as_millis() } -pub fn etherscan_key(chain: NamedChain) -> Option { - match chain { - NamedChain::Fantom | NamedChain::FantomTestnet => { - std::env::var("FTMSCAN_API_KEY").or_else(|_| std::env::var("FANTOMSCAN_API_KEY")).ok() - } - NamedChain::OptimismKovan => std::env::var("OP_KOVAN_API_KEY").ok(), - _ => std::env::var("ETHERSCAN_API_KEY").ok(), - } +pub fn etherscan_key() -> Option { + std::env::var("ETHERSCAN_API_KEY").ok() } pub fn network_rpc_key(chain: &str) -> Option { @@ -29,9 +27,20 @@ pub fn network_rpc_key(chain: &str) -> Option { std::env::var(key).ok() } +/// Resolves the deployer key for `chain`, most specific first: +/// +/// 1. `_PRIVATE_KEY`, to point one network at its own account. +/// 2. `TESTNET_DEPLOYER_PRIVATE_KEY`, the shared throwaway deployer these tests fund. +/// 3. `TEST_PRIVATE_KEY`, kept for existing local setups. +/// +/// Prefer the dedicated name over `TEST_PRIVATE_KEY`: it is generic enough that an unrelated value +/// left in the environment would otherwise deploy from an account the caller did not intend. pub fn network_private_key(chain: &str) -> Option { let key = format!("{}_PRIVATE_KEY", chain.to_uppercase().replace('-', "_")); - std::env::var(key).or_else(|_| std::env::var("TEST_PRIVATE_KEY")).ok() + std::env::var(key) + .or_else(|_| std::env::var("TESTNET_DEPLOYER_PRIVATE_KEY")) + .or_else(|_| std::env::var("TEST_PRIVATE_KEY")) + .ok() } /// Represents external input required for executing verification requests @@ -41,121 +50,32 @@ pub struct EnvExternalities { pub pk: String, pub etherscan: String, pub verifier: String, + pub verifier_url: Option, } impl EnvExternalities { - pub fn address(&self) -> Option
{ - let pk: PrivateKeySigner = self.pk.parse().ok()?; - Some(pk.address()) - } - - pub fn goerli() -> Option { - Some(Self { - chain: NamedChain::Goerli, - rpc: network_rpc_key("goerli")?, - pk: network_private_key("goerli")?, - etherscan: etherscan_key(NamedChain::Goerli)?, - verifier: "etherscan".to_string(), - }) - } - - pub fn ftm_testnet() -> Option { + /// Externalities for a deploy + verify run of `chain` against `verifier`. + /// + /// `network` is the name used to look up `_RPC_URL` and `_PRIVATE_KEY`, and + /// can differ from the canonical `NamedChain::as_str` spelling. Blockscout instances have no + /// shared registry, so they must be given an explicit `verifier_url`. + /// + /// Returns `None` when the network is not configured, which is how these tests stay inert + /// outside of the nightly workflow that supplies the funded deployer key. + pub fn deploy_verify( + chain: NamedChain, + network: &str, + verifier: &str, + verifier_url: Option<&str>, + ) -> Option { Some(Self { - chain: NamedChain::FantomTestnet, - rpc: network_rpc_key("ftm_testnet")?, - pk: network_private_key("ftm_testnet")?, - etherscan: etherscan_key(NamedChain::FantomTestnet)?, - verifier: "etherscan".to_string(), - }) - } - - pub fn optimism_kovan() -> Option { - Some(Self { - chain: NamedChain::OptimismKovan, - rpc: network_rpc_key("op_kovan")?, - pk: network_private_key("op_kovan")?, - etherscan: etherscan_key(NamedChain::OptimismKovan)?, - verifier: "etherscan".to_string(), - }) - } - - pub fn arbitrum_goerli() -> Option { - Some(Self { - chain: NamedChain::ArbitrumGoerli, - rpc: network_rpc_key("arbitrum-goerli")?, - pk: network_private_key("arbitrum-goerli")?, - etherscan: etherscan_key(NamedChain::ArbitrumGoerli)?, - verifier: "blockscout".to_string(), - }) - } - - pub fn mumbai() -> Option { - Some(Self { - chain: NamedChain::PolygonMumbai, - rpc: network_rpc_key("mumbai")?, - pk: network_private_key("mumbai")?, - etherscan: etherscan_key(NamedChain::PolygonMumbai)?, - verifier: "etherscan".to_string(), - }) - } - - pub fn sepolia_etherscan() -> Option { - Some(Self { - chain: NamedChain::Sepolia, - rpc: network_rpc_key("sepolia")?, - pk: network_private_key("sepolia")?, - etherscan: etherscan_key(NamedChain::Sepolia)?, - verifier: "etherscan".to_string(), - }) - } - - pub fn sepolia_sourcify() -> Option { - Some(Self { - chain: NamedChain::Sepolia, - rpc: network_rpc_key("sepolia")?, - pk: network_private_key("sepolia")?, - etherscan: String::new(), - verifier: "sourcify".to_string(), - }) - } - - pub fn sepolia_sourcify_with_etherscan_api_key_set() -> Option { - Some(Self { - chain: NamedChain::Sepolia, - rpc: network_rpc_key("sepolia")?, - pk: network_private_key("sepolia")?, - etherscan: etherscan_key(NamedChain::Sepolia)?, - verifier: "sourcify".to_string(), - }) - } - - pub fn sepolia_blockscout() -> Option { - Some(Self { - chain: NamedChain::Sepolia, - rpc: network_rpc_key("sepolia")?, - pk: network_private_key("sepolia")?, - etherscan: String::new(), - verifier: "blockscout".to_string(), - }) - } - - pub fn sepolia_blockscout_with_etherscan_api_key_set() -> Option { - Some(Self { - chain: NamedChain::Sepolia, - rpc: network_rpc_key("sepolia")?, - pk: network_private_key("sepolia")?, - etherscan: etherscan_key(NamedChain::Sepolia)?, - verifier: "blockscout".to_string(), - }) - } - - pub fn sepolia_empty_verifier() -> Option { - Some(Self { - chain: NamedChain::Sepolia, - rpc: network_rpc_key("sepolia")?, - pk: network_private_key("sepolia")?, - etherscan: String::new(), - verifier: String::new(), + chain, + rpc: network_rpc_key(network)?, + pk: network_private_key(network)?, + // Only Etherscan authenticates; Sourcify and Blockscout take no key. + etherscan: if verifier == "etherscan" { etherscan_key()? } else { String::new() }, + verifier: verifier.to_string(), + verifier_url: verifier_url.map(str::to_string), }) } @@ -182,13 +102,15 @@ pub fn parse_deployed_address(out: &str) -> Option { None } -pub fn parse_verification_guid(out: &str) -> Option { - for line in out.lines() { - if line.contains("GUID") { - return Some(line.replace("GUID:", "").replace('`', "").trim().to_string()); - } - } - None +pub fn assert_debug_dump_identifies_contract(dump_path: &Path, address: &str, contract_name: &str) { + let dump: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(dump_path).unwrap()).unwrap(); + let identified = dump["contracts"]["identified_contracts"].as_object().unwrap(); + let target_identified = identified.iter().any(|(identified_address, name)| { + identified_address.eq_ignore_ascii_case(address) + && name.as_str().is_some_and(|name| name == contract_name) + }); + assert!(target_identified, "forked target was not identified in debugger dump: {identified:?}"); } /// Generates a string containing the code of a Solidity contract. @@ -210,3 +132,62 @@ contract LargeContract {{ " ) } + +/// Generates a Solidity contract with both runtime and initcode bytecode at +/// least `n` bytes long, by embedding an `n`-byte hex constant returned from +/// an external `pure` function. +pub fn generate_large_runtime_contract(n: usize) -> String { + let data = vec![0xff; n]; + let hex = alloy_primitives::hex::encode(data); + format!( + "\ +contract LargeRuntime {{ + function data() external pure returns (bytes memory) {{ + return hex\"{hex}\"; + }} +}} +" + ) +} + +/// A spawned child process that is killed when dropped. +pub struct KillOnDrop { + child: Option, + stderr: Option>>, +} + +impl KillOnDrop { + pub fn spawn(command: &mut Command) -> Self { + let mut child = command.stdout(Stdio::null()).stderr(Stdio::piped()).spawn().unwrap(); + let mut child_stderr = child.stderr.take().unwrap(); + let stderr = thread::spawn(move || { + let mut stderr = Vec::new(); + child_stderr.read_to_end(&mut stderr).unwrap(); + stderr + }); + Self { child: Some(child), stderr: Some(stderr) } + } + + pub fn is_running(&mut self) -> bool { + self.child.as_mut().unwrap().try_wait().unwrap().is_none() + } + + pub fn kill_and_wait(mut self) -> Output { + let mut child = self.child.take().unwrap(); + child.kill().unwrap(); + let status = child.wait().unwrap(); + Output { status, stdout: Vec::new(), stderr: self.stderr.take().unwrap().join().unwrap() } + } +} + +impl Drop for KillOnDrop { + fn drop(&mut self) { + if let Some(child) = &mut self.child { + let _ = child.kill(); + let _ = child.wait(); + } + if let Some(stderr) = self.stderr.take() { + let _ = stderr.join(); + } + } +} diff --git a/crates/forge/tests/cli/verify.rs b/crates/forge/tests/cli/verify.rs index 51a2d82e1b40e..115bcecbeb782 100644 --- a/crates/forge/tests/cli/verify.rs +++ b/crates/forge/tests/cli/verify.rs @@ -2,42 +2,32 @@ //! and Sourcify. use crate::utils::{self, EnvExternalities}; +use alloy_chains::NamedChain; +use alloy_network::Ethereum; +use alloy_primitives::{Address, U256, address, hex}; +use anvil::{NodeConfig, spawn}; +use axum::{ + Form, Router, + extract::Query, + http::{StatusCode, header}, + response::IntoResponse, + routing::post, +}; +use forge_script_sequence::ScriptSequence; +use foundry_cli::utils::parse_json; use foundry_common::retry::Retry; +use foundry_compilers::PathStyle; +use foundry_evm::traces::CallKind; use foundry_test_utils::{ - forgetest, - util::{OutputExt, TestCommand, TestProject}, + forgetest, str, + util::{OutputExt, SOLC_VERSION, TestCommand, TestProject}, }; -use std::time::Duration; - -/// Adds a `Unique` contract to the source directory of the project that can be imported as -/// `import {Unique} from "./unique.sol";` -fn add_unique(prj: &TestProject) { - let timestamp = utils::millis_since_epoch(); - prj.add_source( - "unique", - &format!( - r#" -contract Unique {{ - uint public _timestamp = {timestamp}; -}} -"# - ), - ) - .unwrap(); -} - -fn add_verify_target(prj: &TestProject) { - prj.add_source( - "Verify.sol", - r#" -import {Unique} from "./unique.sol"; -contract Verify is Unique { -function doStuff() external {} -} -"#, - ) - .unwrap(); -} +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, + time::Duration, +}; +use tokio::net::TcpListener; fn add_single_verify_target_file(prj: &TestProject) { let timestamp = utils::millis_since_epoch(); @@ -52,268 +42,939 @@ function doStuff() external {{}} "# ); - prj.add_source("Verify.sol", &contract).unwrap(); + prj.add_source("Verify.sol", &contract); } -fn add_verify_target_with_constructor(prj: &TestProject) { - prj.add_source( - "Verify.sol", - r#" -import {Unique} from "./unique.sol"; -contract Verify is Unique { - struct SomeStruct { - uint256 a; - string str; - } - - constructor(SomeStruct memory st, address owner) {} -} -"#, - ) - .unwrap(); -} - -#[expect(clippy::disallowed_macros)] fn parse_verification_result(cmd: &mut TestCommand, retries: u32) -> eyre::Result<()> { // Give Etherscan some time to verify the contract. Retry::new(retries, Duration::from_secs(30)).run(|| -> eyre::Result<()> { let output = cmd.execute(); - let out = String::from_utf8_lossy(&output.stdout); - println!("{out}"); - if out.contains("Contract successfully verified") { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + test_debug!("stdout: {stdout}\nstderr: {stderr}"); + // A retried attempt can land on a submission that already succeeded, which reports the + // contract as verified rather than verifying it again. + if stderr.contains("Contract successfully verified") || stderr.contains("already verified") + { return Ok(()); } - eyre::bail!( - "Failed to get verification, stdout: {}, stderr: {}", - out, - String::from_utf8_lossy(&output.stderr) - ) + eyre::bail!("Failed to get verification, stdout: {stdout}, stderr: {stderr}"); }) } -fn verify_check( - guid: String, - chain: String, - etherscan_api_key: Option, - verifier: Option, +/// Deploys a freshly stamped contract and verifies it, as two separate commands. +/// +/// Deploy and verify are kept apart on purpose: Sourcify indexes from its own node, and submitting +/// straight after the broadcast fails the job outright with `contract_not_deployed` rather than +/// leaving it pending, so the retry has to re-run the whole `verify-contract` command. +fn deploy_and_verify_on_chain( + info: Option, + prj: TestProject, mut cmd: TestCommand, ) { - let mut args = vec!["verify-check", &guid, "--chain-id", &chain]; + // only execute if the network is configured + let Some(info) = info else { return }; + + test_debug!("deploying on {} for verification via {}", info.chain, info.verifier); + add_single_verify_target_file(&prj); - if let Some(etherscan_api_key) = ðerscan_api_key { - args.push("--etherscan-api-key"); - args.push(etherscan_api_key); + let contract_path = "src/Verify.sol:Verify"; + let output = cmd + .forge_fuse() + .arg("create") + .args(info.create_args()) + .args([contract_path, "--broadcast"]) + .assert_success() + .get_output() + .stdout_lossy(); + let address = utils::parse_deployed_address(output.as_str()) + .unwrap_or_else(|| panic!("Failed to parse deployer {output}")); + + let mut args = vec![ + "--chain-id".to_string(), + (info.chain as u64).to_string(), + address, + contract_path.to_string(), + "--watch".to_string(), + ]; + + if !info.etherscan.is_empty() { + args.push("--etherscan-api-key".to_string()); + args.push(info.etherscan.clone()); } - if let Some(verifier) = &verifier { - args.push("--verifier"); - args.push(verifier); + if !info.verifier.is_empty() { + args.push("--verifier".to_string()); + args.push(info.verifier.clone()); } - cmd.forge_fuse().args(args); - parse_verification_result(&mut cmd, 6).expect("Failed to verify check") + // Blockscout instances share no registry, so the URL has to be passed explicitly. + if let Some(verifier_url) = &info.verifier_url { + args.push("--verifier-url".to_string()); + args.push(verifier_url.clone()); + } + + cmd.forge_fuse().arg("verify-contract").root_arg().args(args); + + parse_verification_result(&mut cmd, 6) + .unwrap_or_else(|err| panic!("Failed to verify on {}: {err}", info.chain)); } -fn await_verification_response(info: EnvExternalities, mut cmd: TestCommand) { - let guid = { - // Give Etherscan some time to detect the transaction. - Retry::new(5, Duration::from_secs(60)) - .run(|| -> eyre::Result { - let output = cmd.execute(); - let out = String::from_utf8_lossy(&output.stdout); - utils::parse_verification_guid(&out).ok_or_else(|| { - eyre::eyre!( - "Failed to get guid, stdout: {}, stderr: {}", - out, - String::from_utf8_lossy(&output.stderr) - ) - }) - }) - .expect("Failed to get verify guid") - }; +// Blockscout instances are per-chain deployments with no shared registry, so `--verifier +// blockscout` has to be given the URL explicitly. +const SEPOLIA_BLOCKSCOUT_URL: &str = "https://eth-sepolia.blockscout.com/api"; +const ROBINHOOD_TESTNET_BLOCKSCOUT_URL: &str = "https://explorer.testnet.chain.robinhood.com/api"; + +/// End-to-end `create` + `verify-contract` coverage across the testnets we support. +/// +/// Each test is inert unless `_RPC_URL` and `TESTNET_DEPLOYER_PRIVATE_KEY` are set, and +/// they are wired up by the nightly `test-deploy-verify` workflow rather than by PR CI: they spend +/// testnet funds and depend on three external verifier services. +/// +/// Etherscan v2 covers Hoodi, Sepolia, Base Sepolia, Arbitrum Sepolia and Monad testnet. Robinhood +/// testnet is not on the v2 chainlist, so it is covered by Sourcify and its own Blockscout instance +/// instead. HyperEVM testnet (998) is covered by Sourcify. +macro_rules! deploy_verify_tests { + ($($name:ident: $chain:expr, $network:literal, $verifier:literal, $url:expr;)*) => {$( + #[forgetest] + fn $name(prj: _, cmd: _) { + deploy_and_verify_on_chain( + EnvExternalities::deploy_verify($chain, $network, $verifier, $url), + prj, + cmd, + ); + } + )*}; +} - // verify-check - let etherscan = (!info.etherscan.is_empty()).then_some(info.etherscan.clone()); - let verifier = (!info.verifier.is_empty()).then_some(info.verifier.clone()); - verify_check(guid, info.chain.to_string(), etherscan, verifier, cmd); +deploy_verify_tests! { + deploy_verify_hyperevm_testnet_sourcify: NamedChain::HyperliquidTestnet, "hyperevm-testnet", "sourcify", None; + + deploy_verify_hoodi_etherscan: NamedChain::Hoodi, "hoodi", "etherscan", None; + deploy_verify_hoodi_sourcify: NamedChain::Hoodi, "hoodi", "sourcify", None; + + deploy_verify_sepolia_etherscan: NamedChain::Sepolia, "sepolia", "etherscan", None; + deploy_verify_sepolia_sourcify: NamedChain::Sepolia, "sepolia", "sourcify", None; + deploy_verify_sepolia_blockscout: + NamedChain::Sepolia, "sepolia", "blockscout", Some(SEPOLIA_BLOCKSCOUT_URL); + + deploy_verify_base_sepolia_etherscan: + NamedChain::BaseSepolia, "base-sepolia", "etherscan", None; + deploy_verify_base_sepolia_sourcify: + NamedChain::BaseSepolia, "base-sepolia", "sourcify", None; + + deploy_verify_arbitrum_sepolia_etherscan: + NamedChain::ArbitrumSepolia, "arbitrum-sepolia", "etherscan", None; + deploy_verify_arbitrum_sepolia_sourcify: + NamedChain::ArbitrumSepolia, "arbitrum-sepolia", "sourcify", None; + + deploy_verify_monad_testnet_etherscan: + NamedChain::MonadTestnet, "monad-testnet", "etherscan", None; + deploy_verify_monad_testnet_sourcify: + NamedChain::MonadTestnet, "monad-testnet", "sourcify", None; + + deploy_verify_robinhood_testnet_sourcify: + NamedChain::RobinhoodTestnet, "robinhood-testnet", "sourcify", None; + deploy_verify_robinhood_testnet_blockscout: + NamedChain::RobinhoodTestnet, "robinhood-testnet", "blockscout", + Some(ROBINHOOD_TESTNET_BLOCKSCOUT_URL); } -fn deploy_contract( - info: &EnvExternalities, - contract_path: &str, - prj: TestProject, - cmd: &mut TestCommand, -) -> String { - add_unique(&prj); - add_verify_target(&prj); +// Tests that verify properly validates verifier arguments. +// +#[forgetest_init] +async fn can_validate_verifier_settings(prj: _, cmd: _) { + prj.initialize_default_contracts(); + // Build the project to create the cache. + cmd.forge_fuse().arg("build").assert_success(); + // Argument validation should not depend on a public block explorer being available. + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let verifier_url = format!("http://{}", listener.local_addr().unwrap()); + let app = Router::new() + .route( + "/api", + post(|Form(form): Form>| async move { + if form.get("apikey").map(String::as_str) == Some("verifier-key") { + r#"{"status":"1","message":"OK","result":"Pass - Verified"}"# + } else { + r#"{"status":"0","message":"NOTOK","result":"Invalid API Key"}"# + } + }), + ) + .fallback(|Query(query): Query>| async move { + assert_eq!(query.get("module").map(String::as_str), Some("contract")); + assert_eq!(query.get("action").map(String::as_str), Some("getabi")); + assert_eq!( + query["address"].parse::
().unwrap(), + address!("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2") + ); + r#"{"status":"1","message":"OK","result":"[]"}"# + }); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + // Use the explicit chain ID so validation does not depend on a public RPC endpoint. + // No verifier URL. + cmd.forge_fuse() + .args([ + "verify-contract", + "--chain-id", + "4202", + "--verifier", + "blockscout", + "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "src/Counter.sol:Counter", + ]) + .assert_failure() + .stderr_eq(str![[r#" +Start verifying contract `0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2` deployed on 4202 +Error: No verifier URL specified for verifier blockscout + +"#]]); + + // Unknown Etherscan chain. + cmd.forge_fuse() + .args([ + "verify-contract", + "--chain-id", + "4202", + "--verifier", + "etherscan", + "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "src/Counter.sol:Counter", + ]) + .assert_failure() + .stderr_eq(str![[r#" +Start verifying contract `0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2` deployed on 4202 +Error: No known Etherscan API URL for chain `4202`. To fix this, please: +1. Specify a `url` when using Etherscan verifier +2. Verify the chain `4202` is correct + +"#]]); + + cmd.forge_fuse() + .args([ + "verify-contract", + "--chain-id", + "4202", + "--verifier", + "blockscout", + "--verifier-url", + verifier_url.as_str(), + "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "src/Counter.sol:Counter", + ]) + .assert_success() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Start verifying contract `0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2` deployed on 4202 + +Verifying on blockscout... +Contract [src/Counter.sol:Counter] "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" is already verified. Skipping verification. + +"#]]); + + // Unknown Etherscan chain, explicit Blockscout verifier, and ETHERSCAN_API_KEY set. + // This should still use the explicit verifier URL instead of trying to resolve an + // Etherscan API URL for the chain. + let cmd = cmd.forge_fuse(); + cmd.env("ETHERSCAN_API_KEY", "dummy"); + cmd.args([ + "verify-contract", + "--chain-id", + "4202", + "--verifier", + "blockscout", + "--verifier-url", + verifier_url.as_str(), + "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "src/Counter.sol:Counter", + ]) + .assert_success() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Start verifying contract `0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2` deployed on 4202 + +Verifying on blockscout... +Contract [src/Counter.sol:Counter] "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" is already verified. Skipping verification. + +"#]]); + // Status checks prefer the verifier key over the Etherscan key. + cmd.forge_fuse() + .args(["verify-check", "job-id", "--verifier", "custom"]) + .args(["--verifier-url", &format!("{verifier_url}/api")]) + .args(["--verifier-api-key", "verifier-key", "--etherscan-api-key", "fallback-key"]) + .args(["--chain", "1", "--retries", "1", "--delay", "0"]) + .assert_success() + .stdout_eq(str![""]) + .stderr_eq(str![[r#" +Checking verification status on mainnet +Contract verification status: +Response: `OK` +Details: `Pass - Verified` +Contract successfully verified + +"#]]); + server.abort(); +} + +// Tests that `forge script --broadcast --verify` fails before broadcasting when +// the verifier rejects the API key (credential preflight check). +#[forgetest_init] +async fn script_fails_early_on_bad_verifier_credentials(prj: _, cmd: _) { + prj.add_script( + "Deploy.s.sol", + r#" +import "forge-std/Script.sol"; +contract Noop {} +contract Deploy is Script { + function run() external { + vm.startBroadcast(); + new Noop(); + vm.stopBroadcast(); + } +} +"#, + ); + + let (_api, handle) = spawn(NodeConfig::test()).await; + let wallet = handle.dev_wallets().next().unwrap(); + let pk = hex::encode(wallet.credential().to_bytes()); + + let (verifier_url, _server) = + spawn_mock_verifier(r#"{"status":"0","message":"NOTOK","result":"Invalid API Key"}"#).await; + let output = cmd .forge_fuse() - .arg("create") - .args(info.create_args()) - .arg(contract_path) - .assert_success() - .get_output() - .stdout_lossy(); - utils::parse_deployed_address(output.as_str()) - .unwrap_or_else(|| panic!("Failed to parse deployer {output}")) + .args([ + "script", + "script/Deploy.s.sol:Deploy", + "--rpc-url", + handle.http_endpoint().as_str(), + "--private-key", + pk.as_str(), + "--broadcast", + "--verify", + "--verifier", + "custom", + "--verifier-url", + verifier_url.as_str(), + "--verifier-api-key", + "FAKE_KEY_1234", + ]) + .execute(); + + assert!(!output.status.success(), "expected command to fail"); + let stderr = output.stderr_lossy(); + assert!( + stderr.contains("Verification preflight check failed"), + "expected preflight error in stderr, got: {stderr}" + ); + // The broadcast phase prints "ONCHAIN EXECUTION COMPLETE" and "Sending transactions"; + // neither must appear if the preflight check stopped execution before broadcasting. + let stdout = output.stdout_lossy(); + assert!( + !stdout.contains("ONCHAIN EXECUTION COMPLETE") && !stdout.contains("Sending transactions"), + "transactions were broadcast but preflight check should have prevented it: {stdout}" + ); } -#[expect(clippy::disallowed_macros)] -fn verify_on_chain(info: Option, prj: TestProject, mut cmd: TestCommand) { - // only execute if keys present - if let Some(info) = info { - println!("verifying on {}", info.chain); - - let contract_path = "src/Verify.sol:Verify"; - let address = deploy_contract(&info, contract_path, prj, &mut cmd); - - let mut args = vec![ - "--chain-id".to_string(), - info.chain.to_string(), - address, - contract_path.to_string(), - ]; - - if !info.etherscan.is_empty() { - args.push("--etherscan-api-key".to_string()); - args.push(info.etherscan.clone()); - } +/// Spawns a local HTTP server that returns the given body for Etherscan-style ABI requests. +async fn spawn_mock_verifier(body: &'static str) -> (String, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let app = + Router::new().fallback(move |Query(query): Query>| async move { + if query.get("module").is_some_and(|value| value == "contract") + && query.get("action").is_some_and(|value| value == "getabi") + && query.contains_key("address") + && query.contains_key("apikey") + { + body + } else { + r#"{"status":"0","message":"NOTOK","result":"Contract source code not verified"}"# + } + }); + let handle = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (format!("http://{addr}"), handle) +} - if !info.verifier.is_empty() { - args.push("--verifier".to_string()); - args.push(info.verifier.clone()); +/// Spawns a local HTTP server mocking the full Etherscan verification flow: the `getabi` preflight +/// returns "not verified", `verifysourcecode` returns a submission GUID, and `checkverifystatus` +/// reports success. Returns the server URL and the GUID it emits. +async fn spawn_full_mock_verifier() -> (String, &'static str, tokio::task::JoinHandle<()>) { + const GUID: &str = "mockguid1976000000000000000000000000000000000000000"; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let app = Router::new().fallback(move |uri: axum::http::Uri, body: String| async move { + let combined = format!("{}&{body}", uri.query().unwrap_or_default()); + if combined.contains("verifysourcecode") { + format!(r#"{{"status":"1","message":"OK","result":"{GUID}"}}"#) + } else if combined.contains("checkverifystatus") { + r#"{"status":"1","message":"OK","result":"Pass - Verified"}"#.to_string() + } else { + r#"{"status":"0","message":"NOTOK","result":"Contract source code not verified"}"# + .to_string() } - cmd.forge_fuse().arg("verify-contract").root_arg().args(args); + }); + let handle = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + (format!("http://{addr}"), GUID, handle) +} + +// Tests that `forge create --broadcast --verify --json` keeps stdout clean (valid JSON only) and +// does not leak the verification submission GUID/URL into stdout, while still reporting it on +// stderr. +#[forgetest] +async fn create_verify_json_keeps_stdout_clean(prj: _, cmd: _) { + prj.initialize_default_contracts(); + let (_api, handle) = spawn(NodeConfig::test()).await; + let wallet = handle.dev_wallets().next().unwrap(); + let pk = hex::encode(wallet.credential().to_bytes()); + + let (verifier_url, guid, _server) = spawn_full_mock_verifier().await; - await_verification_response(info, cmd) + let output = cmd + .forge_fuse() + .args([ + "create", + "src/Counter.sol:Counter", + "--rpc-url", + handle.http_endpoint().as_str(), + "--private-key", + pk.as_str(), + "--broadcast", + "--verify", + "--verifier", + "custom", + "--verifier-url", + verifier_url.as_str(), + "--verifier-api-key", + "VALID_KEY", + "--json", + ]) + .execute(); + + assert!(output.status.success(), "expected command to succeed"); + + let stdout = output.stdout_lossy(); + // stdout must be a single valid JSON document (the deployment result). + serde_json::from_str::(stdout.trim()) + .unwrap_or_else(|e| panic!("stdout is not valid JSON ({e}): {stdout}")); + // The verification submission GUID must not pollute stdout. + assert!(!stdout.contains(guid), "verification GUID leaked into stdout: {stdout}"); + + // The GUID/URL is still reported to the user on stderr. + let stderr = output.stderr_lossy(); + assert!(stderr.contains(guid), "expected verification GUID on stderr, got: {stderr}"); +} + +// Tests that `forge script --broadcast --verify --json` keeps stdout clean (valid JSON Lines only) +// and does not leak the verification submission GUID/URL into stdout, while still reporting it on +// stderr. +#[forgetest_init] +async fn script_verify_json_keeps_stdout_clean(prj: _, cmd: _) { + prj.add_script( + "Deploy.s.sol", + r#" +import "forge-std/Script.sol"; +contract Noop {} +contract Deploy is Script { + function run() external { + vm.startBroadcast(); + new Noop(); + vm.stopBroadcast(); } } +"#, + ); + + let (_api, handle) = spawn(NodeConfig::test()).await; + let wallet = handle.dev_wallets().next().unwrap(); + let pk = hex::encode(wallet.credential().to_bytes()); + + let (verifier_url, guid, _server) = spawn_full_mock_verifier().await; -#[expect(clippy::disallowed_macros)] -fn guess_constructor_args(info: Option, prj: TestProject, mut cmd: TestCommand) { - // only execute if keys present - if let Some(info) = info { - println!("verifying on {}", info.chain); - add_unique(&prj); - add_verify_target_with_constructor(&prj); - - let contract_path = "src/Verify.sol:Verify"; - let output = cmd - .arg("create") - .args(info.create_args()) - .arg(contract_path) - .args(vec![ - "--constructor-args", - "(239,SomeString)", - "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", - ]) - .assert_success() - .get_output() - .stdout_lossy(); - - let address = utils::parse_deployed_address(output.as_str()) - .unwrap_or_else(|| panic!("Failed to parse deployer {output}")); - - cmd.forge_fuse().arg("verify-contract").root_arg().args([ - "--rpc-url".to_string(), - info.rpc.to_string(), - address, - contract_path.to_string(), - "--etherscan-api-key".to_string(), - info.etherscan.to_string(), - "--verifier".to_string(), - info.verifier.to_string(), - "--guess-constructor-args".to_string(), - ]); - - await_verification_response(info, cmd) + let output = cmd + .forge_fuse() + .args([ + "script", + "script/Deploy.s.sol:Deploy", + "--rpc-url", + handle.http_endpoint().as_str(), + "--private-key", + pk.as_str(), + "--broadcast", + "--verify", + "--verifier", + "custom", + "--verifier-url", + verifier_url.as_str(), + "--verifier-api-key", + "VALID_KEY", + "--json", + ]) + .execute(); + + assert!(output.status.success(), "expected command to succeed"); + + let stdout = output.stdout_lossy(); + for line in stdout.lines().filter(|line| !line.trim().is_empty()) { + serde_json::from_str::(line) + .unwrap_or_else(|e| panic!("stdout line is not valid JSON ({e}): {line}")); } + assert!(!stdout.contains(guid), "verification GUID leaked into stdout: {stdout}"); + + let stderr = output.stderr_lossy(); + assert!(stderr.contains(guid), "expected verification GUID on stderr, got: {stderr}"); } -#[expect(clippy::disallowed_macros)] -/// Executes create --verify on the given chain -fn create_verify_on_chain(info: Option, prj: TestProject, mut cmd: TestCommand) { - // only execute if keys present - if let Some(info) = info { - println!("verifying on {}", info.chain); - add_single_verify_target_file(&prj); - - let contract_path = "src/Verify.sol:Verify"; - let output = cmd - .arg("create") - .args(info.create_args()) - .args([contract_path, "--etherscan-api-key", info.etherscan.as_str(), "--verify"]) - .assert_success() - .get_output() - .stdout_lossy(); - - assert!(output.contains("Contract successfully verified"), "{}", output); +// Regression test for . The contract created +// by the script is compiled in a different project and can only be identified from its factory's +// verified Standard JSON input. +#[forgetest_init] +async fn script_verifies_external_create2_contract(prj: _, cmd: _) { + const SUBMISSION_KEY: &str = "submission-key"; + const GUID: &str = "external-create2-guid"; + + let external = TestProject::new("external-create2", PathStyle::Dapptools); + foundry_test_utils::util::initialize(external.root()); + external.add_source( + "External.sol", + r#" +contract Child { uint256 public immutable value; constructor(uint256 value_) { value = value_; } } +contract Executor { + function deploy(bytes memory initCode) external returns (address deployed) { + assembly { deployed := create2(0, add(initCode, 32), mload(initCode), 0) } + require(deployed != address(0)); + } +} +contract ExternalFactory { + Executor public immutable executor; + constructor() { executor = new Executor(); } + function deployChild(uint256 value) external returns (address) { + return executor.deploy(abi.encodePacked(type(Child).creationCode, abi.encode(value))); } } +"#, + ); + external.write_config(foundry_config::Config { + solc: Some(foundry_config::SolcReq::Version(SOLC_VERSION.parse().unwrap())), + optimizer: Some(true), + optimizer_runs: Some(777), + ..Default::default() + }); + + let (_api, anvil) = spawn(NodeConfig::test()).await; + let wallet = anvil.dev_wallets().next().unwrap(); + let private_key = hex::encode(wallet.credential().to_bytes()); + let rpc = anvil.http_endpoint(); + let create = external + .forge_command() + .forge_fuse() + .args([ + "create", + "src/External.sol:ExternalFactory", + "--broadcast", + "--json", + "--rpc-url", + rpc.as_str(), + "--private-key", + private_key.as_str(), + ]) + .assert_success() + .get_output() + .stdout_lossy(); + let create: serde_json::Value = serde_json::from_str(&create).unwrap(); + let factory = create["deployedTo"].as_str().unwrap().to_string(); + let selector = &alloy_primitives::keccak256("executor()")[..4]; + let call: serde_json::Value = reqwest::Client::new() + .post(rpc.as_str()) + .json(&serde_json::json!({ + "jsonrpc": "2.0", "id": 1, "method": "eth_call", + "params": [{"to": factory, "data": hex::encode_prefixed(selector)}, "latest"] + })) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let executor_result = call["result"].as_str().unwrap(); + let executor = format!("0x{}", &executor_result[executor_result.len() - 40..]); + + let standard_json = external + .forge_command() + .forge_fuse() + .args([ + "verify-contract", + factory.as_str(), + "src/External.sol:ExternalFactory", + "--show-standard-json-input", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + let standard_json: serde_json::Value = serde_json::from_str(standard_json.trim()).unwrap(); + assert_eq!(standard_json["settings"]["optimizer"]["runs"], 777); -// tests `create && contract-verify && verify-check` on Fantom testnet if correct env vars are set -forgetest!(can_verify_random_contract_fantom_testnet, |prj, cmd| { - verify_on_chain(EnvExternalities::ftm_testnet(), prj, cmd); -}); - -// tests `create && contract-verify && verify-check` on Optimism kovan if correct env vars are set -forgetest!(can_verify_random_contract_optimism_kovan, |prj, cmd| { - verify_on_chain(EnvExternalities::optimism_kovan(), prj, cmd); -}); - -// tests `create && contract-verify && verify-check` on Sepolia testnet if correct env vars are set -forgetest!(can_verify_random_contract_sepolia, |prj, cmd| { - // Implicitly tests `--verifier etherscan` on Sepolia testnet - verify_on_chain(EnvExternalities::sepolia_etherscan(), prj, cmd); -}); - -// tests `create --verify on Sepolia testnet if correct env vars are set -// SEPOLIA_RPC_URL=https://rpc.sepolia.org -// TEST_PRIVATE_KEY=0x... -// ETHERSCAN_API_KEY= -forgetest!(can_create_verify_random_contract_sepolia_etherscan, |prj, cmd| { - // Implicitly tests `--verifier etherscan` on Sepolia testnet - create_verify_on_chain(EnvExternalities::sepolia_etherscan(), prj, cmd); -}); - -// tests `create --verify --verifier sourcify` on Sepolia testnet -forgetest!(can_create_verify_random_contract_sepolia_sourcify, |prj, cmd| { - verify_on_chain(EnvExternalities::sepolia_sourcify(), prj, cmd); -}); - -// tests `create --verify --verifier sourcify` with etherscan api key set -// -forgetest!( - can_create_verify_random_contract_sepolia_sourcify_with_etherscan_api_key_set, - |prj, cmd| { - verify_on_chain(EnvExternalities::sepolia_sourcify_with_etherscan_api_key_set(), prj, cmd); + // Use the compiler build recorded by Forge's artifact, not a guessed release suffix. + let artifact: serde_json::Value = serde_json::from_reader( + std::fs::File::open(external.artifacts().join("External.sol/ExternalFactory.json")) + .unwrap(), + ) + .unwrap(); + let metadata: serde_json::Value = + parse_json(artifact["rawMetadata"].as_str().unwrap()).unwrap(); + let compiler_version = format!("v{}", metadata["compiler"]["version"].as_str().unwrap()); + + #[derive(Default)] + struct Requests { + sources: Vec>, + submissions: Vec>, } -); - -// tests `create --verify --verifier blockscout` on Sepolia testnet -forgetest!(can_create_verify_random_contract_sepolia_blockscout, |prj, cmd| { - verify_on_chain(EnvExternalities::sepolia_blockscout(), prj, cmd); -}); - -// tests `create --verify --verifier blockscout` on Sepolia testnet with etherscan api key set -forgetest!( - can_create_verify_random_contract_sepolia_blockscout_with_etherscan_api_key_set, - |prj, cmd| { - verify_on_chain( - EnvExternalities::sepolia_blockscout_with_etherscan_api_key_set(), - prj, - cmd, + let requests = Arc::new(Mutex::new(Requests::default())); + let state = requests.clone(); + let source_json = standard_json.clone(); + let factory_for_server = factory.to_lowercase(); + let compiler_version_for_server = compiler_version.clone(); + + // Verification requests are trusted and must retain normal redirect handling. External source + // discovery uses the same selected endpoint, but remains on the non-redirecting client. + let redirect_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let redirect_url = format!("http://{}", redirect_listener.local_addr().unwrap()); + let redirect_state = requests.clone(); + let redirect_app = Router::new().fallback(move |uri: axum::http::Uri, body: String| { + let state = redirect_state.clone(); + async move { + let encoded = if body.is_empty() { uri.query().unwrap_or_default() } else { &body }; + let form = url::form_urlencoded::parse(encoded.as_bytes()) + .into_owned().collect::>(); + match form.get("action").map(String::as_str) { + Some("verifysourcecode") => { + state.lock().unwrap().submissions.push(form); + format!(r#"{{"status":"1","message":"OK","result":"{GUID}"}}"#) + } + Some("checkverifystatus") => { + r#"{"status":"1","message":"OK","result":"Pass - Verified"}"#.to_string() + } + _ => r#"{"status":"0","message":"NOTOK","result":"Contract source code not verified"}"#.to_string(), + } + } + }); + let _redirect_server = + tokio::spawn(async move { axum::serve(redirect_listener, redirect_app).await.unwrap() }); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let verifier_url = format!("http://{}", listener.local_addr().unwrap()); + let app = Router::new().fallback(move |uri: axum::http::Uri, body: String| { + let state = state.clone(); + let source_json = source_json.clone(); + let factory = factory_for_server.clone(); + let compiler_version = compiler_version_for_server.clone(); + let redirect_url = redirect_url.clone(); + async move { + let encoded = if body.is_empty() { uri.query().unwrap_or_default() } else { &body }; + let form = url::form_urlencoded::parse(encoded.as_bytes()) + .into_owned().collect::>(); + match form.get("action").map(String::as_str) { + Some("getsourcecode") => { + state.lock().unwrap().sources.push(form.clone()); + if form.get("address").is_some_and(|address| address.to_lowercase() == factory) { + serde_json::json!({"status":"1","message":"OK","result":[{ + "SourceCode": source_json.to_string(), "CompilerVersion": compiler_version, + "ContractName": "ExternalFactory", "ABI": "[]", + "OptimizationUsed": "1", "OptimizationRuns": "777", + "ConstructorArguments": "", "EVMVersion": "osaka", "IsProxy": "0" + }]}).to_string().into_response() + } else { + r#"{"status":"0","message":"NOTOK","result":"Contract source code not verified"}"#.into_response() + } + } + Some("verifysourcecode" | "checkverifystatus") => ( + StatusCode::TEMPORARY_REDIRECT, + [(header::LOCATION, redirect_url)], + ) + .into_response(), + _ => r#"{"status":"0","message":"NOTOK","result":"Contract source code not verified"}"#.into_response(), + } + } + }); + let _server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + prj.add_source( + "IExternalFactory.sol", + "interface IExternalFactory { function deployChild(uint256) external returns (address); }", + ); + prj.add_script("Deploy.s.sol", &format!(r#" +import "forge-std/Script.sol"; +import {{IExternalFactory}} from "../src/IExternalFactory.sol"; +contract Deploy is Script {{ + function run() external {{ vm.startBroadcast(); IExternalFactory({factory}).deployChild(42); vm.stopBroadcast(); }} +}} +"#)); + + let output = cmd + .forge_fuse() + .args([ + "script", + "script/Deploy.s.sol:Deploy", + "--broadcast", + "--verify", + "--verify-external", + "--verifier", + "custom", + "--verifier-url", + verifier_url.as_str(), + "--verifier-api-key", + SUBMISSION_KEY, + "--rpc-url", + rpc.as_str(), + "--private-key", + private_key.as_str(), + ]) + .execute(); + assert!(output.status.success(), "script failed: {}", output.stderr_lossy()); + + assert!(!prj.root().join("src/External.sol").exists()); + assert!(!prj.artifacts().join("External.sol/Child.json").exists()); + let submission = { + let requests = requests.lock().unwrap(); + assert!(requests.sources.len() >= 2, "source requests: {:?}", requests.sources); + let source_addresses = requests + .sources + .iter() + .map(|request| request["address"].to_lowercase()) + .collect::>(); + let expected = [executor.to_lowercase(), factory.to_lowercase()]; + assert!( + source_addresses.windows(2).any(|addresses| addresses == expected), + "source request order: {source_addresses:?}" ); - } -); - -// tests `create && contract-verify --guess-constructor-args && verify-check` on Goerli testnet if -// correct env vars are set -forgetest!(can_guess_constructor_args, |prj, cmd| { - guess_constructor_args(EnvExternalities::goerli(), prj, cmd); -}); - -// tests `create && verify-contract && verify-check` on sepolia with default sourcify verifier -forgetest!(can_verify_random_contract_sepolia_default_sourcify, |prj, cmd| { - verify_on_chain(EnvExternalities::sepolia_empty_verifier(), prj, cmd); -}); + assert!(requests.sources.iter().all(|request| request["apikey"] == SUBMISSION_KEY)); + assert_eq!(requests.submissions.len(), 1, "script stderr: {}", output.stderr_lossy()); + requests.submissions[0].clone() + }; + assert_eq!(submission["apikey"], SUBMISSION_KEY); + assert_eq!(submission["contractname"], "src/External.sol:Child"); + assert_eq!(submission["codeformat"], "solidity-standard-json-input"); + assert_eq!( + serde_json::from_str::(&submission["sourceCode"]).unwrap(), + standard_json + ); + assert_eq!(submission["compilerversion"], compiler_version); + assert_eq!(submission["constructorArguements"], format!("{:064x}", 42)); + let broadcast_path = prj.root().join("broadcast/Deploy.s.sol/31337/run-latest.json"); + let broadcast = std::fs::read_to_string(&broadcast_path).unwrap(); + let mut sequence: ScriptSequence = serde_json::from_str(&broadcast).unwrap(); + let child_address = submission["contractaddress"].parse::
().unwrap(); + let child = sequence + .transactions + .iter() + .flat_map(|transaction| &transaction.additional_contracts) + .find(|contract| contract.address == child_address) + .expect("submitted contract is not a traced nested creation"); + assert_eq!(child.call_kind, CallKind::Create2); + assert_eq!( + &child.creator_code_addresses[..2], + &[executor.parse::
().unwrap(), factory.parse::
().unwrap()] + ); + + let value_selector = &alloy_primitives::keccak256("value()")[..4]; + let value: serde_json::Value = reqwest::Client::new() + .post(rpc.as_str()) + .json(&serde_json::json!({ + "jsonrpc": "2.0", "id": 2, "method": "eth_call", + "params": [{"to": child_address, "data": hex::encode_prefixed(value_selector)}, "latest"] + })) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(value["result"].as_str().unwrap().parse::().unwrap(), U256::from(42)); + + // Old broadcast logs do not contain creator provenance. An explicitly requested external + // verification must report that skipped attempt as a failure rather than `All (0)` success. + sequence + .transactions + .iter_mut() + .flat_map(|transaction| &mut transaction.additional_contracts) + .find(|contract| contract.address == child_address) + .unwrap() + .creator_code_addresses + .clear(); + sequence.recovery_generation = None; + std::fs::write(&broadcast_path, serde_json::to_vec_pretty(&sequence).unwrap()).unwrap(); + let cache_path = prj.root().join("cache/Deploy.s.sol/31337/run-latest.json"); + let mut cache: serde_json::Value = foundry_common::fs::read_json_file(&cache_path).unwrap(); + cache.as_object_mut().unwrap().remove("recovery_generation"); + std::fs::write(cache_path, serde_json::to_vec_pretty(&cache).unwrap()).unwrap(); + std::fs::remove_file(prj.root().join("cache/Deploy.s.sol/31337/run-latest.json.recovery.json")) + .unwrap(); + + let failed = prj + .forge_command() + .forge_fuse() + .args([ + "script", + "script/Deploy.s.sol:Deploy", + "--broadcast", + "--resume", + "--verify", + "--verify-external", + "--verifier", + "custom", + "--verifier-url", + verifier_url.as_str(), + "--verifier-api-key", + SUBMISSION_KEY, + "--rpc-url", + rpc.as_str(), + "--private-key", + private_key.as_str(), + ]) + .execute(); + assert!(!failed.status.success(), "resume unexpectedly succeeded: {}", failed.stderr_lossy()); + let stderr = failed.stderr_lossy(); + assert!(stderr.contains("creator provenance is unavailable"), "{stderr}"); + assert!(stderr.contains("Not all (0 / 1) contracts were verified"), "{stderr}"); + assert!(!stderr.contains("All (0) contracts were verified"), "{stderr}"); +} + +// Tests that the preflight check passes (does not block deploy) when the verifier responds +// with ContractCodeNotVerified (the normal "valid key, unknown address" response). +#[forgetest] +async fn create_preflight_passes_on_contract_not_verified(prj: _, cmd: _) { + prj.initialize_default_contracts(); + let (_api, handle) = spawn(NodeConfig::test()).await; + let wallet = handle.dev_wallets().next().unwrap(); + let pk = hex::encode(wallet.credential().to_bytes()); + + // Server returns a well-formed "source code not verified" Etherscan response. + let (verifier_url, _server) = spawn_mock_verifier( + r#"{"status":"0","message":"NOTOK","result":"Contract source code not verified"}"#, + ) + .await; + + let output = cmd + .forge_fuse() + .args([ + "create", + "src/Counter.sol:Counter", + "--rpc-url", + handle.http_endpoint().as_str(), + "--private-key", + pk.as_str(), + "--verify", + "--verifier", + "custom", + "--verifier-url", + verifier_url.as_str(), + "--verifier-api-key", + "VALID_KEY", + ]) + .execute(); + + // Preflight must pass — the command may fail for other reasons (e.g. post-deploy + // verification), but it must NOT fail with the preflight error. + let stderr = output.stderr_lossy(); + assert!( + !stderr.contains("Verification preflight check failed"), + "preflight should not block on ContractCodeNotVerified, got: {stderr}" + ); +} + +// Tests that the preflight check fails (blocks deploy) when the verifier explicitly +// rejects the API key with an InvalidApiKey response. +#[forgetest] +async fn create_preflight_fails_on_invalid_api_key(prj: _, cmd: _) { + prj.initialize_default_contracts(); + let (_api, handle) = spawn(NodeConfig::test()).await; + let wallet = handle.dev_wallets().next().unwrap(); + let pk = hex::encode(wallet.credential().to_bytes()); + + // Server returns a well-formed "invalid API key" Etherscan response. + let (verifier_url, _server) = + spawn_mock_verifier(r#"{"status":"0","message":"NOTOK","result":"Invalid API Key"}"#).await; + + let output = cmd + .forge_fuse() + .args([ + "create", + "src/Counter.sol:Counter", + "--rpc-url", + handle.http_endpoint().as_str(), + "--private-key", + pk.as_str(), + "--verify", + "--verifier", + "custom", + "--verifier-url", + verifier_url.as_str(), + "--verifier-api-key", + "BAD_KEY", + ]) + .execute(); + + assert!(!output.status.success(), "expected command to fail"); + let stderr = output.stderr_lossy(); + assert!( + stderr.contains("Verification preflight check failed"), + "expected preflight error in stderr, got: {stderr}" + ); + let stdout = output.stdout_lossy(); + assert!( + !stdout.contains("Contract Address"), + "contract was deployed but preflight check should have prevented it" + ); +} + +// Tests that the preflight check does NOT block deployment when the verifier responds +// with a rate-limit error (transient, not an auth failure). +#[forgetest] +async fn create_preflight_warns_on_rate_limit(prj: _, cmd: _) { + prj.initialize_default_contracts(); + let (_api, handle) = spawn(NodeConfig::test()).await; + let wallet = handle.dev_wallets().next().unwrap(); + let pk = hex::encode(wallet.credential().to_bytes()); + + // Server returns a well-formed "rate limit exceeded" Etherscan response. + let (verifier_url, _server) = spawn_mock_verifier( + r#"{"status":"0","message":"NOTOK","result":"Max rate limit reached"}"#, + ) + .await; + + let output = cmd + .forge_fuse() + .args([ + "create", + "src/Counter.sol:Counter", + "--rpc-url", + handle.http_endpoint().as_str(), + "--private-key", + pk.as_str(), + "--verify", + "--verifier", + "blockscout", + "--verifier-url", + verifier_url.as_str(), + "--verifier-api-key", + "VALID_KEY", + ]) + .execute(); + + // Rate limit must not block the deploy. + let stderr = output.stderr_lossy(); + assert!( + !stderr.contains("Verification preflight check failed"), + "preflight should not block on rate limit, got: {stderr}" + ); + assert!( + stderr.contains("verifier credential check inconclusive"), + "preflight should warn on rate limit, got: {stderr}" + ); +} diff --git a/crates/forge/tests/cli/verify_bytecode.rs b/crates/forge/tests/cli/verify_bytecode.rs index 48fa1984fe06a..70dbcf95b682d 100644 --- a/crates/forge/tests/cli/verify_bytecode.rs +++ b/crates/forge/tests/cli/verify_bytecode.rs @@ -1,13 +1,113 @@ +use crate::utils; +use alloy_chains::Chain; +use alloy_network::ReceiptResponse; +use alloy_primitives::{Address, B256, Bytes, hex}; +use alloy_provider::Provider; +use axum::{Json, Router, extract::Query}; use foundry_compilers::artifacts::{BytecodeHash, EvmVersion}; use foundry_config::Config; +use foundry_evm_networks::NetworkConfigs; use foundry_test_utils::{ - TestCommand, TestProject, forgetest_async, + TestCommand, TestProject, + etherscan::fetch_etherscan_source_flattened, rpc::{next_etherscan_api_key, next_http_archive_rpc_url}, util::OutputExt, }; +use std::{collections::HashMap, fs}; +use tokio::net::TcpListener; + +#[forgetest_init] +async fn can_verify_bytecode_with_local_creation_data_fork(prj: _, cmd: _) { + prj.initialize_default_contracts(); + cmd.forge_fuse().arg("build").assert_success(); + + let artifact: serde_json::Value = serde_json::from_slice( + &fs::read(prj.paths().artifacts.join("Counter.sol/Counter.json")).unwrap(), + ) + .unwrap(); + let bytecode = + Bytes::from(hex::decode(artifact["bytecode"]["object"].as_str().unwrap()).unwrap()); + + let (api, handle) = anvil::spawn(anvil::NodeConfig::test()).await; + let rpc = handle.http_endpoint(); + let provider = handle.http_provider(); + let accounts = handle.dev_accounts().take(3).collect::>(); + api.anvil_set_auto_mine(false).await.unwrap(); + let gas_price = provider.get_gas_price().await.unwrap(); + let _: B256 = provider + .client() + .request( + "eth_sendTransaction", + [serde_json::json!({ + "from": accounts[1], + "to": accounts[2], + "value": "0x1", + "gasPrice": format!("0x{:x}", gas_price + 1), + })], + ) + .await + .unwrap(); + let transaction_hash: B256 = provider + .client() + .request( + "eth_sendTransaction", + [serde_json::json!({ + "from": accounts[0], + "data": bytecode, + "gasPrice": format!("0x{gas_price:x}"), + })], + ) + .await + .unwrap(); + api.mine_one().await.unwrap(); + let receipt = provider.get_transaction_receipt(transaction_hash).await.unwrap().unwrap(); + assert_eq!(receipt.transaction_index(), Some(1)); + let address = receipt.contract_address().unwrap().to_string(); + + // Local explorer data forces a nonempty creation-block replay and shared completion path. + let creation_data = serde_json::json!({"status":"1", "message":"OK", "result":[{ + "contractAddress": address, + "contractCreator": accounts[0], + "txHash": transaction_hash, + }]}); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/api", listener.local_addr().unwrap()); + let app = Router::new().fallback(move |Query(query): Query>| { + let response = if query.get("action").is_some_and(|action| action == "getcontractcreation") + { + creation_data.clone() + } else { + serde_json::json!({"status":"1", "message":"OK", "result":[]}) + }; + async move { Json(response) } + }); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + cmd.forge_fuse(); + cmd.args([ + "verify-bytecode", + &address, + "src/Counter.sol:Counter", + "--rpc-url", + &rpc, + "--verifier", + "etherscan", + "--verifier-url", + &url, + "--etherscan-api-key", + "test", + "--json", + ]) + .assert_json_stdout( + r#"[ + {"bytecode_type":"creation", "match_type":"full"}, + {"bytecode_type":"runtime", "match_type":"full"} + ]"#, + ); + server.abort(); +} #[expect(clippy::too_many_arguments)] -fn test_verify_bytecode( +async fn test_verify_bytecode( prj: TestProject, mut cmd: TestCommand, addr: &str, @@ -17,19 +117,17 @@ fn test_verify_bytecode( verifier: &str, verifier_url: &str, expected_matches: (&str, &str), + chain: Chain, ) { let etherscan_key = next_etherscan_api_key(); let rpc_url = next_http_archive_rpc_url(); - // fetch and flatten source code - let source_code = cmd - .cast_fuse() - .args(["source", addr, "--flatten", "--etherscan-api-key", ðerscan_key]) - .assert_success() - .get_output() - .stdout_lossy(); + // fetch and flatten source code using the library directly + let source_code = fetch_etherscan_source_flattened(addr, ðerscan_key, chain) + .await + .expect("failed to fetch source code from etherscan"); - prj.add_source(contract_name, &source_code).unwrap(); + prj.add_source(contract_name, &source_code); prj.write_config(config); let etherscan_key = next_etherscan_api_key(); @@ -65,7 +163,7 @@ fn test_verify_bytecode( } #[expect(clippy::too_many_arguments)] -fn test_verify_bytecode_with_ignore( +async fn test_verify_bytecode_with_ignore( prj: TestProject, mut cmd: TestCommand, addr: &str, @@ -75,28 +173,17 @@ fn test_verify_bytecode_with_ignore( verifier_url: &str, expected_matches: (&str, &str), ignore: &str, - chain: &str, + chain: Chain, ) { let etherscan_key = next_etherscan_api_key(); let rpc_url = next_http_archive_rpc_url(); - // fetch and flatten source code - let source_code = cmd - .cast_fuse() - .args([ - "source", - addr, - "--flatten", - "--etherscan-api-key", - ðerscan_key, - "--chain", - chain, - ]) - .assert_success() - .get_output() - .stdout_lossy(); + // fetch and flatten source code using the library directly + let source_code = fetch_etherscan_source_flattened(addr, ðerscan_key, chain) + .await + .expect("failed to fetch source code from etherscan"); - prj.add_source(contract_name, &source_code).unwrap(); + prj.add_source(contract_name, &source_code); prj.write_config(config); let output = cmd @@ -144,7 +231,9 @@ fn test_verify_bytecode_with_ignore( ); } } -forgetest_async!(can_verify_bytecode_no_metadata, |prj, cmd| { + +#[forgetest] +async fn flaky_verify_bytecode_no_metadata(prj: _, cmd: _) { test_verify_bytecode( prj, cmd, @@ -160,12 +249,15 @@ forgetest_async!(can_verify_bytecode_no_metadata, |prj, cmd| { ..Default::default() }, "etherscan", - "https://api.etherscan.io/v2/api", + "https://api.etherscan.io/v2/api?chainid=1", ("partial", "partial"), - ); -}); + Chain::mainnet(), + ) + .await; +} -forgetest_async!(can_verify_bytecode_with_metadata, |prj, cmd| { +#[forgetest] +async fn flaky_verify_bytecode_with_metadata(prj: _, cmd: _) { test_verify_bytecode( prj, cmd, @@ -179,13 +271,16 @@ forgetest_async!(can_verify_bytecode_with_metadata, |prj, cmd| { ..Default::default() }, "etherscan", - "https://api.etherscan.io/v2/api", + "https://api.etherscan.io/v2/api?chainid=1", ("partial", "partial"), - ); -}); + Chain::mainnet(), + ) + .await; +} // Test non-CREATE2 deployed contract with blockscout -forgetest_async!(can_verify_bytecode_with_blockscout, |prj, cmd| { +#[forgetest] +async fn flaky_verify_bytecode_with_blockscout(prj: _, cmd: _) { test_verify_bytecode( prj, cmd, @@ -201,11 +296,14 @@ forgetest_async!(can_verify_bytecode_with_blockscout, |prj, cmd| { "blockscout", "https://eth.blockscout.com/api", ("partial", "partial"), - ); -}); + Chain::mainnet(), + ) + .await; +} // Test CREATE2 deployed contract with blockscout -forgetest_async!(can_vb_create2_with_blockscout, |prj, cmd| { +#[forgetest] +async fn flaky_verify_bytecode_create2_with_blockscout(prj: _, cmd: _) { test_verify_bytecode( prj, cmd, @@ -223,11 +321,14 @@ forgetest_async!(can_vb_create2_with_blockscout, |prj, cmd| { "blockscout", "https://eth.blockscout.com/api", ("partial", "partial"), - ); -}); + Chain::mainnet(), + ) + .await; +} // Test `--constructor-args` -forgetest_async!(can_verify_bytecode_with_constructor_args, |prj, cmd| { +#[forgetest] +async fn flaky_verify_bytecode_with_constructor_args(prj: _, cmd: _) { let constructor_args = vec![ "0x39053D51B77DC0d36036Fc1fCc8Cb819df8Ef37A", "0x91E677b07F7AF907ec9a428aafA9fc14a0d3A338", @@ -246,13 +347,104 @@ forgetest_async!(can_verify_bytecode_with_constructor_args, |prj, cmd| { ..Default::default() }, "etherscan", - "https://api.etherscan.io/v2/api", + "https://api.etherscan.io/v2/api?chainid=1", ("partial", "partial"), + Chain::mainnet(), + ) + .await; +} + +// Wrong `--constructor-args` used to verify clean, because supplied args that were not the tail +// of the creation code were silently replaced by the real ones. +#[forgetest] +async fn flaky_verify_bytecode_warns_on_wrong_constructor_args(prj: _, cmd: _) { + let etherscan_key = next_etherscan_api_key(); + let rpc_url = next_http_archive_rpc_url(); + let addr = "0x70f44C13944d49a236E3cD7a94f48f5daB6C619b"; + + let source_code = fetch_etherscan_source_flattened(addr, ðerscan_key, Chain::mainnet()) + .await + .expect("failed to fetch source code from etherscan"); + prj.add_source("StrategyManager", &source_code); + prj.write_config(Config { + evm_version: EvmVersion::London, + optimizer: Some(true), + optimizer_runs: Some(200), + ..Default::default() + }); + + let etherscan_key = next_etherscan_api_key(); + let run = cmd + .forge_fuse() + .args([ + "verify-bytecode", + addr, + "StrategyManager", + "--etherscan-api-key", + ðerscan_key, + "--verifier", + "etherscan", + "--verifier-url", + "https://api.etherscan.io/v2/api?chainid=1", + "--rpc-url", + &rpc_url, + // Three zero addresses instead of the real constructor arguments. + "--constructor-args", + "0x0000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000", + ]) + .assert_success(); + let output = run.get_output(); + + // The warning goes to stderr, the match verdict to stdout. + let stderr = output.stderr_lossy(); + let stdout = output.stdout_lossy(); + + assert!( + stderr.contains( + "Provided constructor args could not be validated against deployment creation code" + ), + "expected a warning that the supplied args do not match the deployment, got:\n{stderr}" + ); + assert!( + !stdout.contains("Creation code matched"), + "wrong constructor args must not produce a creation match, got:\n{stdout}" ); -}); + + // Ignoring creation verification must still compare the runtime produced by the supplied + // arguments. StrategyManager embeds its constructor arguments as immutables, so they produce + // a runtime mismatch. + let etherscan_key = next_etherscan_api_key(); + cmd.forge_fuse() + .args([ + "verify-bytecode", + addr, + "StrategyManager", + "--etherscan-api-key", + ðerscan_key, + "--verifier", + "etherscan", + "--verifier-url", + "https://api.etherscan.io/v2/api?chainid=1", + "--rpc-url", + &rpc_url, + "--constructor-args", + "0x0000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000", + "0x0000000000000000000000000000000000000000", + "--ignore", + "creation", + "--json", + ]) + .assert_json_stdout( + r#"[{"bytecode_type":"runtime","match_type":null,"message":"Runtime code did not match - this may be due to varying compiler settings"}]"#, + ); +} // `--ignore` tests -forgetest_async!(can_ignore_creation, |prj, cmd| { +#[forgetest] +async fn flaky_verify_bytecode_can_ignore_creation(prj: _, cmd: _) { test_verify_bytecode_with_ignore( prj, cmd, @@ -267,14 +459,16 @@ forgetest_async!(can_ignore_creation, |prj, cmd| { ..Default::default() }, "etherscan", - "https://api.etherscan.io/v2/api", + "https://api.etherscan.io/v2/api?chainid=1", ("ignored", "partial"), "creation", - "1", - ); -}); + Chain::mainnet(), + ) + .await; +} -forgetest_async!(can_ignore_runtime, |prj, cmd| { +#[forgetest] +async fn flaky_verify_bytecode_can_ignore_runtime(prj: _, cmd: _) { test_verify_bytecode_with_ignore( prj, cmd, @@ -289,34 +483,507 @@ forgetest_async!(can_ignore_runtime, |prj, cmd| { ..Default::default() }, "etherscan", - "https://api.etherscan.io/v2/api", + "https://api.etherscan.io/v2/api?chainid=1", ("partial", "ignored"), "runtime", - "1", + Chain::mainnet(), + ) + .await; +} + +// Test that verification fails when source code doesn't match deployed bytecode +#[forgetest] +async fn flaky_can_verify_bytecode_fails_on_source_mismatch(prj: _, cmd: _) { + let etherscan_key = next_etherscan_api_key(); + let rpc_url = next_http_archive_rpc_url(); + + // Fetch real source code using the library directly + let real_source = fetch_etherscan_source_flattened( + "0xba2492e52F45651B60B8B38d4Ea5E2390C64Ffb1", + ðerscan_key, + Chain::mainnet(), + ) + .await + .expect("failed to fetch source code from etherscan"); + + prj.add_source("SystemConfig", &real_source); + prj.write_config(Config { + evm_version: EvmVersion::London, + optimizer_runs: Some(999999), + optimizer: Some(true), + cbor_metadata: false, + bytecode_hash: BytecodeHash::None, + ..Default::default() + }); + // Build once with correct source (creates cache). Linting is unrelated to bytecode + // verification here and can dominate runtime on the flattened Etherscan source. + cmd.forge_fuse().args(["build", "--no-lint"]).assert_success(); + + let source_code = r#" + contract SystemConfig { + uint256 public constant MODIFIED_VALUE = 999; + + function someFunction() public pure returns (uint256) { + return MODIFIED_VALUE; + } + } + "#; + + // Now replace with different incorrect source code + prj.add_source("SystemConfig", source_code); + let etherscan_key = next_etherscan_api_key(); + let args = vec![ + "verify-bytecode", + "0xba2492e52F45651B60B8B38d4Ea5E2390C64Ffb1", + "SystemConfig", + "--etherscan-api-key", + ðerscan_key, + "--verifier", + "etherscan", + "--verifier-url", + "https://api.etherscan.io/v2/api?chainid=1", + "--rpc-url", + &rpc_url, + ]; + let output = cmd.forge_fuse().args(args).assert_success().get_output().stderr_lossy(); + + // Verify that bytecode does NOT match (recompiled with incorrect source) + assert!(output.contains("Error: Creation code did not match".to_string().as_str())); + assert!(output.contains("Error: Runtime code did not match".to_string().as_str())); +} + +// Tests that `verify-bytecode` works without any external block explorer, relying only on the +// local project and an RPC endpoint. +// +#[forgetest_init] +async fn can_verify_bytecode_without_explorer(prj: _, cmd: _) { + prj.initialize_default_contracts(); + + let (_api, handle) = anvil::spawn(anvil::NodeConfig::test()).await; + let rpc = handle.http_endpoint(); + let wallet = handle.dev_wallets().next().unwrap(); + let pk = alloy_primitives::hex::encode(wallet.credential().to_bytes()); + + // Deploy the template contract; first tx of the default dev account. + cmd.forge_fuse(); + cmd.unset_env("ETHERSCAN_API_KEY"); + cmd.unset_env("VERIFIER_API_KEY"); + cmd.unset_env("VERIFIER_URL"); + let output = cmd + .args([ + "create", + "./src/Counter.sol:Counter", + "--rpc-url", + rpc.as_str(), + "--private-key", + pk.as_str(), + "--broadcast", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + let address = output + .lines() + .find_map(|line| line.strip_prefix("Deployed to: ")) + .expect("contract address in `forge create` output") + .to_string(); + + // Bare contract names should compile only their uniquely resolved source. + prj.add_source("Broken", "contract Broken { uint256 public value = doesNotExist; }"); + + // The local anvil chain has no block explorer: the command must still verify the runtime + // bytecode and only warn about the unavailable explorer data. + cmd.forge_fuse(); + cmd.unset_env("ETHERSCAN_API_KEY"); + cmd.unset_env("VERIFIER_API_KEY"); + cmd.unset_env("VERIFIER_URL"); + let assert = cmd + .args(["verify-bytecode", &address, "Counter", "--rpc-url", rpc.as_str()]) + .assert_success(); + let output = assert.get_output(); + let stdout = output.stdout_lossy(); + let stderr = output.stderr_lossy(); + + assert!(stdout.contains("Runtime code matched"), "{stdout}"); + assert!(stderr.contains("Creation data is unavailable"), "{stderr}"); + + #[cfg(feature = "base")] + { + cmd.forge_fuse(); + cmd.unset_env("ETHERSCAN_API_KEY"); + cmd.unset_env("VERIFIER_API_KEY"); + cmd.unset_env("VERIFIER_URL"); + let output = cmd + .args([ + "verify-bytecode", + &address, + "Counter", + "--rpc-url", + rpc.as_str(), + "--network", + "base", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + assert!(output.contains("Runtime code matched"), "{output}"); + } + + // Dependencies and projects with Vyper sources retain full-project compilation. The unrelated + // invalid source therefore makes both builds fail. + prj.create_file("lib/Dependency.sol", "contract Dependency {}"); + prj.add_source("UsesDependency", "import '../lib/Dependency.sol'; contract UsesDependency {}"); + cmd.forge_fuse() + .args(["verify-bytecode", &address, "Dependency", "--rpc-url", rpc.as_str()]) + .assert_failure(); + let vyper_source = prj.add_raw_source("Counter.vy", "invalid Vyper source"); + cmd.forge_fuse() + .args(["verify-bytecode", &address, "Counter", "--rpc-url", rpc.as_str()]) + .assert_failure(); + fs::remove_file(vyper_source).unwrap(); + + // `--ignore runtime` must skip the runtime fallback as well: with no creation data either, + // there is nothing left to verify. + cmd.forge_fuse(); + cmd.unset_env("ETHERSCAN_API_KEY"); + cmd.unset_env("VERIFIER_API_KEY"); + cmd.unset_env("VERIFIER_URL"); + let assert = cmd + .args([ + "verify-bytecode", + &address, + "Counter", + "--rpc-url", + rpc.as_str(), + "--ignore", + "runtime", + ]) + .assert_success(); + let output = assert.get_output(); + let stdout = output.stdout_lossy(); + let stderr = output.stderr_lossy(); + + assert!(!stdout.contains("Runtime code matched"), "{stdout}"); + assert!(stderr.contains("Creation data is unavailable"), "{stderr}"); + + // An explicitly configured but broken verifier must surface an error instead of being + // silently treated as "no explorer". + cmd.forge_fuse(); + cmd.unset_env("ETHERSCAN_API_KEY"); + cmd.unset_env("VERIFIER_API_KEY"); + cmd.unset_env("VERIFIER_URL"); + cmd.args([ + "verify-bytecode", + &address, + "Counter", + "--rpc-url", + rpc.as_str(), + "--verifier-url", + "this-is-not-a-url", + ]) + .assert_failure(); +} + +#[forgetest_init] +async fn can_verify_bytecode_with_libraries(prj: _, cmd: _) { + prj.update_config(|config| config.libraries.clear()); + prj.add_source( + "Libraries", + r#" +library FirstLib { + function compute(uint256 value) external pure returns (uint256) { + return value + 1; + } +} + +library SecondLib { + function compute(uint256 value) external pure returns (uint256) { + return value * 2; + } +} +"#, ); -}); - -// Test predeploy contracts -// TODO: Add test utils for base such as basescan keys and alchemy keys. -// WETH9 Predeploy -// forgetest_async!(can_verify_predeploys, |prj, cmd| { -// test_verify_bytecode_with_ignore( -// prj, -// cmd, -// "0x4200000000000000000000000000000000000006", -// "WETH9", -// Config { -// evm_version: EvmVersion::default(), -// optimizer: Some(true), -// optimizer_runs: 10000, -// cbor_metadata: true, -// bytecode_hash: BytecodeHash::Bzzr1, -// ..Default::default() -// }, -// "etherscan", -// "https://api.basescan.org/api", -// ("ignored", "partial"), -// "creation", -// "base", -// ); -// }); + prj.add_source( + "LinkedContract", + r#" +import {FirstLib, SecondLib} from "./Libraries.sol"; + +contract LinkedContract { + uint256 public immutable initial; + + constructor() { + initial = SecondLib.compute(FirstLib.compute(20)); + } + + function compute(uint256 value) external view returns (uint256) { + return SecondLib.compute(FirstLib.compute(value)); + } +} +"#, + ); + + let (_api, handle) = anvil::spawn(anvil::NodeConfig::test()).await; + let rpc = handle.http_endpoint(); + let wallet = handle.dev_wallets().next().unwrap(); + let pk = hex::encode(wallet.credential().to_bytes()); + + cmd.forge_fuse(); + cmd.unset_env("DAPP_LIBRARIES"); + cmd.unset_env("FOUNDRY_LIBRARIES"); + cmd.unset_env("FOUNDRY_CONFIG"); + let output = cmd + .args([ + "create", + "src/Libraries.sol:FirstLib", + "--rpc-url", + rpc.as_str(), + "--private-key", + pk.as_str(), + "--broadcast", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + let first_lib = utils::parse_deployed_address(&output) + .unwrap_or_else(|| panic!("Failed to parse deployed library address: {output}")); + + cmd.forge_fuse(); + cmd.unset_env("DAPP_LIBRARIES"); + cmd.unset_env("FOUNDRY_LIBRARIES"); + cmd.unset_env("FOUNDRY_CONFIG"); + let output = cmd + .args([ + "create", + "src/Libraries.sol:SecondLib", + "--rpc-url", + rpc.as_str(), + "--private-key", + pk.as_str(), + "--broadcast", + ]) + .assert_success() + .get_output() + .stdout_lossy(); + let second_lib = utils::parse_deployed_address(&output) + .unwrap_or_else(|| panic!("Failed to parse deployed library address: {output}")); + + let first_lib_spec = format!("src/Libraries.sol:FirstLib:{first_lib}"); + let second_lib_spec = format!("src/Libraries.sol:SecondLib:{second_lib}"); + + cmd.forge_fuse(); + cmd.unset_env("DAPP_LIBRARIES"); + cmd.unset_env("FOUNDRY_LIBRARIES"); + cmd.unset_env("FOUNDRY_CONFIG"); + let output = cmd + .args([ + "create", + "src/LinkedContract.sol:LinkedContract", + "--rpc-url", + rpc.as_str(), + "--private-key", + pk.as_str(), + "--broadcast", + "--libraries", + first_lib_spec.as_str(), + "--libraries", + second_lib_spec.as_str(), + ]) + .assert_success() + .get_output() + .stdout_lossy(); + let contract = utils::parse_deployed_address(&output) + .unwrap_or_else(|| panic!("Failed to parse deployed contract address: {output}")); + + // Explicit CLI values must take precedence over configured addresses for the same libraries. + prj.update_config(|config| { + config.libraries = vec![ + "src/Libraries.sol:FirstLib:0x1111111111111111111111111111111111111111".to_string(), + "src/Libraries.sol:SecondLib:0x2222222222222222222222222222222222222222".to_string(), + ]; + }); + + // Ensure verification recompiles with its own linker arguments rather than reusing the + // artifacts produced by `forge create`. + prj.clear(); + + cmd.forge_fuse(); + cmd.unset_env("DAPP_LIBRARIES"); + cmd.unset_env("FOUNDRY_LIBRARIES"); + cmd.unset_env("FOUNDRY_CONFIG"); + cmd.unset_env("ETHERSCAN_API_KEY"); + cmd.unset_env("VERIFIER_API_KEY"); + cmd.unset_env("VERIFIER_URL"); + let assert = cmd + .args([ + "verify-bytecode", + contract.as_str(), + "src/LinkedContract.sol:LinkedContract", + "--rpc-url", + rpc.as_str(), + "--libraries", + first_lib_spec.as_str(), + "--libraries", + second_lib_spec.as_str(), + ]) + .assert_success(); + let output = assert.get_output(); + let stdout = output.stdout_lossy(); + let stderr = output.stderr_lossy(); + + assert!(stdout.contains("Runtime code matched with status full"), "{stdout}"); + assert!(stderr.contains("Creation data is unavailable"), "{stderr}"); + + // Multi-library environment values continue to be parsed by the configuration provider. + prj.update_config(|config| config.libraries.clear()); + prj.clear(); + + cmd.forge_fuse(); + cmd.env("DAPP_LIBRARIES", format!("{first_lib_spec},{second_lib_spec}")); + cmd.unset_env("FOUNDRY_LIBRARIES"); + cmd.unset_env("FOUNDRY_CONFIG"); + cmd.unset_env("ETHERSCAN_API_KEY"); + cmd.unset_env("VERIFIER_API_KEY"); + cmd.unset_env("VERIFIER_URL"); + let assert = cmd + .args([ + "verify-bytecode", + contract.as_str(), + "src/LinkedContract.sol:LinkedContract", + "--rpc-url", + rpc.as_str(), + ]) + .assert_success(); + let output = assert.get_output(); + let stdout = output.stdout_lossy(); + let stderr = output.stderr_lossy(); + + assert!(stdout.contains("Runtime code matched with status full"), "{stdout}"); + assert!(stderr.contains("Creation data is unavailable"), "{stderr}"); +} + +#[forgetest_init] +async fn can_verify_bytecode_tempo_aa_deployments(prj: _, cmd: _) { + prj.initialize_default_contracts(); + // Constructor gas depends on the intrinsic gas of the whole batch, not only the creation call. + prj.add_source("GasLeft.sol", "contract GasLeft { uint256 public immutable gas = gasleft(); }"); + + let (api, handle) = + anvil::spawn(anvil::NodeConfig::test_tempo().with_chain_id(Some(31337u64))).await; + let rpc = handle.http_endpoint(); + let provider = handle.http_provider(); + let wallet = handle.dev_wallets().next().unwrap(); + let deployer = wallet.address().to_string(); + let pk = hex::encode(wallet.credential().to_bytes()); + + // Advance the protocol nonce so it differs from the nonce of each nonce lane below. + let mut deployments = Vec::new(); + let lanes: [&[&str]; 3] = + [&[], &["--tempo.nonce-key", "5", "--nonce", "0"], &["--tempo.expires", "30"]]; + for (index, lane) in lanes.into_iter().enumerate() { + cmd.forge_fuse() + .args([ + "create", + "./src/Counter.sol:Counter", + "--rpc-url", + rpc.as_str(), + "--private-key", + pk.as_str(), + "--broadcast", + ]) + .args(lane); + let output = cmd.assert_success().get_output().stdout_lossy(); + if index == 0 { + continue; + } + let field = + |prefix| output.lines().find_map(|line| line.strip_prefix(prefix)).unwrap().to_string(); + deployments.push(( + "src/Counter.sol:Counter", + field("Deployed to: "), + field("Transaction hash: "), + )); + } + + // Batch a creation with a follow-up call. + cmd.forge_fuse().arg("build").assert_success(); + let artifact: serde_json::Value = serde_json::from_slice( + &fs::read(prj.paths().artifacts.join("GasLeft.sol/GasLeft.json")).unwrap(), + ) + .unwrap(); + api.anvil_set_auto_mine(false).await.unwrap(); + let tx_hash: B256 = provider + .client() + .request( + "eth_sendTransaction", + [serde_json::json!({ + "from": deployer, + "type": "0x76", + "gas": "0x1e8480", + "calls": [ + {"to": null, "value": "0x0", "input": artifact["bytecode"]["object"]}, + {"to": Address::with_last_byte(0x22), "value": "0x0", "input": "0x"}, + ], + })], + ) + .await + .unwrap(); + api.mine_one().await.unwrap(); + let receipt: serde_json::Value = + provider.client().request("eth_getTransactionReceipt", [tx_hash]).await.unwrap(); + assert_eq!(receipt["status"], "0x1"); + deployments.push(( + "src/GasLeft.sol:GasLeft", + receipt["contractAddress"].as_str().unwrap().to_string(), + tx_hash.to_string(), + )); + + for (contract, address, tx_hash) in deployments { + let creation_data = serde_json::json!({"status":"1", "message":"OK", "result":[{ + "contractAddress": address, + "contractCreator": deployer, + "txHash": tx_hash, + }]}); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/api", listener.local_addr().unwrap()); + let app = Router::new().fallback(move |Query(query): Query>| { + let response = + if query.get("action").is_some_and(|action| action == "getcontractcreation") { + creation_data.clone() + } else { + serde_json::json!({"status":"1", "message":"OK", "result":[]}) + }; + async move { Json(response) } + }); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + + // The runtime replay must keep the lane's own nonce and the full batch. AA creation code + // is not read from the batched calls yet, so only runtime is compared. + for (networks, network_args) in [ + (NetworkConfigs::default(), &[][..]), + (NetworkConfigs::with_tempo(), &[][..]), + (NetworkConfigs::with_ethereum(), &["--network", "tempo"][..]), + ] { + prj.update_config(|config| config.networks = networks); + cmd.forge_fuse() + .args([ + "verify-bytecode", + &address, + contract, + "--rpc-url", + &rpc, + "--verifier", + "etherscan", + "--verifier-url", + &url, + "--etherscan-api-key", + "test", + "--ignore", + "creation", + "--json", + ]) + .args(network_args) + .assert_json_stdout(r#"[{"bytecode_type":"runtime", "match_type":"full"}]"#); + } + server.abort(); + } +} diff --git a/crates/forge/tests/cli/version.rs b/crates/forge/tests/cli/version.rs index 0df88b83a501d..a35a818e0ac7a 100644 --- a/crates/forge/tests/cli/version.rs +++ b/crates/forge/tests/cli/version.rs @@ -1,13 +1,15 @@ use foundry_test_utils::{forgetest, str}; -forgetest!(print_short_version, |_prj, cmd| { +#[forgetest] +fn print_short_version(cmd: _) { cmd.arg("-V").assert_success().stdout_eq(str![[r#" forge [..]-[..] ([..] [..]) "#]]); -}); +} -forgetest!(print_long_version, |_prj, cmd| { +#[forgetest] +fn print_long_version(cmd: _) { cmd.arg("--version").assert_success().stdout_eq(str![[r#" forge Version: [..] Commit SHA: [..] @@ -15,4 +17,4 @@ Build Timestamp: [..] Build Profile: [..] "#]]); -}); +} diff --git a/crates/forge/tests/cli/watch.rs b/crates/forge/tests/cli/watch.rs new file mode 100644 index 0000000000000..3528f61c74aaa --- /dev/null +++ b/crates/forge/tests/cli/watch.rs @@ -0,0 +1,61 @@ +#[cfg(unix)] +use rexpect::{ + Encoding, + process::{signal, wait::WaitStatus}, + reader::Options, + spawn_with_options, +}; + +#[forgetest] +#[cfg(unix)] +fn watch_yields_stdin_to_tests(prj: _) { + const TIMEOUT_MS: u64 = 30_000; + + prj.add_test( + "Prompt.t.sol", + r#" +interface Vm { + function prompt(string calldata promptText) external returns (string memory input); +} + +contract PromptTest { + Vm private constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + function testPrompt() public { + string memory input = vm.prompt("Enter ok"); + require(keccak256(bytes(input)) == keccak256("ok"), "unexpected input"); + } +} +"#, + ); + + let mut command = std::process::Command::new(env!("CARGO_BIN_EXE_forge")); + command + .current_dir(prj.root()) + .env_remove("CI") + .env("NO_COLOR", "1") + .env("TERM", "dumb") + .args(["test", "--watch", "--match-test", "testPrompt"]); + + let mut session = spawn_with_options( + command, + Options { + timeout_ms: Some(TIMEOUT_MS), + strip_ansi_escape_codes: true, + encoding: Encoding::UTF8, + }, + ) + .unwrap(); + + session.exp_string("Enter ok:").unwrap(); + session.send_line("ok").unwrap(); + session.exp_string("[PASS] testPrompt()").unwrap(); + session.exp_string("[Command was successful").unwrap(); + + session.process.signal(signal::SIGINT).unwrap(); + let output = session.exp_eof().unwrap(); + assert!( + matches!(session.process.wait().unwrap(), WaitStatus::Exited(_, 0)), + "watch command failed: {output}" + ); +} diff --git a/crates/forge/tests/fixtures/BaseEvm.t.sol b/crates/forge/tests/fixtures/BaseEvm.t.sol new file mode 100644 index 0000000000000..e431877bd8252 --- /dev/null +++ b/crates/forge/tests/fixtures/BaseEvm.t.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity >=0.8.20; + +interface IActivationRegistry { + function admin() external view returns (address); +} + +interface IB20Factory { + function isB20(address token) external view returns (bool); +} + +interface Vm { + function deployCode(string calldata artifactPath) external returns (address); +} + +contract NestedBaseCanary { + IActivationRegistry internal constant ACTIVATION_REGISTRY = + IActivationRegistry(0x8453000000000000000000000000000000000001); + + address public activationAdmin; + + constructor() { + activationAdmin = ACTIVATION_REGISTRY.admin(); + } +} + +contract BaseEvmTest { + Vm internal constant vm = Vm(address(bytes20(uint160(uint256(keccak256("hevm cheat code")))))); + address internal constant ACTIVATION_REGISTRY = 0x8453000000000000000000000000000000000001; + address internal constant B20_FACTORY = address(bytes20(hex"B20F000000000000000000000000000000000000")); + address internal constant MAINNET_BERYL_ACTIVATION_ADMIN = + address(bytes20(hex"ce3a3bee7e72e2a24079f3c0cb3b97740ed425a9")); + + function test_azul_excludes_beryl_precompiles() public view { + (bool activationSuccess, bytes memory activationOutput) = + ACTIVATION_REGISTRY.staticcall(abi.encodeWithSelector(IActivationRegistry.admin.selector)); + require(activationSuccess, "activation registry call failed"); + require(activationOutput.length == 0, "activation registry is installed"); + + (bool factorySuccess, bytes memory factoryOutput) = + B20_FACTORY.staticcall(abi.encodeCall(IB20Factory.isB20, (B20_FACTORY))); + require(factorySuccess, "B20 factory call failed"); + require(factoryOutput.length == 0, "B20 factory is installed"); + } + + function test_beryl_installs_native_precompiles() public view { + require( + IActivationRegistry(ACTIVATION_REGISTRY).admin() == MAINNET_BERYL_ACTIVATION_ADMIN, + "unexpected activation admin" + ); + require(!IB20Factory(B20_FACTORY).isB20(B20_FACTORY), "factory identified as B20"); + } + + function test_beryl_nested_deploy_code_uses_base_evm() public { + address deployed = vm.deployCode("test/BaseEvm.t.sol:NestedBaseCanary"); + require( + NestedBaseCanary(deployed).activationAdmin() == MAINNET_BERYL_ACTIVATION_ADMIN, + "nested EVM used the wrong activation admin" + ); + } +} diff --git a/crates/forge/tests/fixtures/BaseForkWrites.t.sol b/crates/forge/tests/fixtures/BaseForkWrites.t.sol new file mode 100644 index 0000000000000..3a3e630855b17 --- /dev/null +++ b/crates/forge/tests/fixtures/BaseForkWrites.t.sol @@ -0,0 +1,49 @@ +interface IActivationRegistry { + function admin() external view returns (address); + function isActivated(bytes32 feature) external view returns (bool); + function activate(bytes32 feature) external; +} + +interface IB20Factory { + function getB20Address(uint8 variant, address sender, bytes32 salt) external view returns (address); + function isB20(address token) external view returns (bool); +} + +interface Vm { + function prank(address sender) external; + function deal(address account, uint256 newBalance) external; +} + +contract BaseForkWritesTest { + Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + address constant ACTIVATION_REGISTRY = 0x8453000000000000000000000000000000000001; + address constant B20_FACTORY = 0xB20f000000000000000000000000000000000000; + bytes32 constant B20_ASSET = 0xcdcc772fe4cbdb1029f822861176d09e646db96723d4c1e82ddfdeb8163ef54c; + + // The factory returns data from every function, so Solidity emits no `extcodesize` check + // against it and it needs no code — which is why Base leaves it code-less on chain. Planting a + // sentinel here anyway would let an `isContract` probe pass locally and revert on Base. + function test_factory_is_code_less_and_still_callable() public view { + require(B20_FACTORY.code.length == 0, "factory must stay code-less, as on Base"); + + address token = + IB20Factory(B20_FACTORY).getB20Address(0, address(this), bytes32(uint256(1))); + require(token != address(0), "high-level factory call should return an address"); + require(!IB20Factory(B20_FACTORY).isB20(address(this)), "test contract is not a B20"); + } + + // `activate` returns nothing, so Solidity guards the high-level call with an `extcodesize` + // check. That check is why a code-less precompile makes the caller revert before the + // precompile runs, so this must stay a high-level call rather than a raw `.call()`. + function test_fork_activation_write() public { + address admin = IActivationRegistry(ACTIVATION_REGISTRY).admin(); + require(admin != address(0), "activation admin unresolved"); + require(!IActivationRegistry(ACTIVATION_REGISTRY).isActivated(B20_ASSET), "already active"); + + vm.deal(admin, 1 ether); + vm.prank(admin); + IActivationRegistry(ACTIVATION_REGISTRY).activate(B20_ASSET); + + require(IActivationRegistry(ACTIVATION_REGISTRY).isActivated(B20_ASSET), "not activated"); + } +} diff --git a/crates/forge/tests/fixtures/CreateXScript.sol b/crates/forge/tests/fixtures/CreateXScript.sol new file mode 100644 index 0000000000000..94180d39247e5 --- /dev/null +++ b/crates/forge/tests/fixtures/CreateXScript.sol @@ -0,0 +1,165 @@ +import "forge-std/Script.sol"; +import {Vm} from "forge-std/Vm.sol"; +contract CreateXGuardSaltMinimal { + enum SenderBytes { + MsgSender, + ZeroAddress, + Random + } + + enum RedeployProtectionFlag { + True, + False, + Unspecified + } + + error InvalidSalt(address emitter); + address internal constant _SELF = address(0xba5Ed099633D3B313e4D5F7bdc1305d3c28ba5Ed); + + function _guard(bytes32 salt) internal view returns (bytes32 guardedSalt) { + (SenderBytes senderBytes, RedeployProtectionFlag redeployProtectionFlag) = _parseSalt({salt: salt}); + + if (senderBytes == SenderBytes.MsgSender && redeployProtectionFlag == RedeployProtectionFlag.True) { + guardedSalt = keccak256(abi.encode(msg.sender, block.chainid, salt)); + } else if (senderBytes == SenderBytes.MsgSender && redeployProtectionFlag == RedeployProtectionFlag.False) { + guardedSalt = _efficientHash({a: bytes32(uint256(uint160(msg.sender))), b: salt}); + } else if (senderBytes == SenderBytes.MsgSender) { + revert InvalidSalt({emitter: _SELF}); + } else if (senderBytes == SenderBytes.ZeroAddress && redeployProtectionFlag == RedeployProtectionFlag.True) { + guardedSalt = _efficientHash({a: bytes32(block.chainid), b: salt}); + } else if ( + senderBytes == SenderBytes.ZeroAddress && redeployProtectionFlag == RedeployProtectionFlag.Unspecified + ) { + revert InvalidSalt({emitter: _SELF}); + } else { + guardedSalt = (salt != _generateSalt()) ? keccak256(abi.encode(salt)) : salt; + } + } + + function _parseSalt( + bytes32 salt + ) internal view returns (SenderBytes senderBytes, RedeployProtectionFlag redeployProtectionFlag) { + if (address(bytes20(salt)) == msg.sender && bytes1(salt[20]) == hex"01") { + (senderBytes, redeployProtectionFlag) = (SenderBytes.MsgSender, RedeployProtectionFlag.True); + } else if (address(bytes20(salt)) == msg.sender && bytes1(salt[20]) == hex"00") { + (senderBytes, redeployProtectionFlag) = (SenderBytes.MsgSender, RedeployProtectionFlag.False); + } else if (address(bytes20(salt)) == msg.sender) { + (senderBytes, redeployProtectionFlag) = (SenderBytes.MsgSender, RedeployProtectionFlag.Unspecified); + } else if (address(bytes20(salt)) == address(0) && bytes1(salt[20]) == hex"01") { + (senderBytes, redeployProtectionFlag) = (SenderBytes.ZeroAddress, RedeployProtectionFlag.True); + } else if (address(bytes20(salt)) == address(0) && bytes1(salt[20]) == hex"00") { + (senderBytes, redeployProtectionFlag) = (SenderBytes.ZeroAddress, RedeployProtectionFlag.False); + } else if (address(bytes20(salt)) == address(0)) { + (senderBytes, redeployProtectionFlag) = (SenderBytes.ZeroAddress, RedeployProtectionFlag.Unspecified); + } else if (bytes1(salt[20]) == hex"01") { + (senderBytes, redeployProtectionFlag) = (SenderBytes.Random, RedeployProtectionFlag.True); + } else if (bytes1(salt[20]) == hex"00") { + (senderBytes, redeployProtectionFlag) = (SenderBytes.Random, RedeployProtectionFlag.False); + } else { + (senderBytes, redeployProtectionFlag) = (SenderBytes.Random, RedeployProtectionFlag.Unspecified); + } + } + + + function _efficientHash(bytes32 a, bytes32 b) internal pure returns (bytes32 hash) { + assembly ("memory-safe") { + mstore(0x00, a) + mstore(0x20, b) + hash := keccak256(0x00, 0x40) + } + } + + +function _generateSalt() internal view returns (bytes32 salt) { + unchecked { + salt = keccak256( + abi.encode( + blockhash(block.number - 32), + block.coinbase, + block.number, + block.timestamp, + block.prevrandao, + block.chainid, + msg.sender + ) + ); + } + } +} + +interface CreateX { + function deployCreate3(bytes32 salt, bytes memory initCode) external payable returns (address newContract); + function computeCreate3Address(bytes32 salt) external view returns (address computedAddress); +} + +struct UNISWAP_ADDRESSES { + address payable UNISWAP_POSITION_MANAGER; + address UNISWAP_PERMIT2; + address UNISWAP_POOL_MANAGER; + address UNISWAP_STATE_VIEW; +} + +contract CreateXScript is Script, CreateXGuardSaltMinimal { + mapping(uint256 => UNISWAP_ADDRESSES) uniswapAddresses; + + function run() public { + bytes32 SALT = keccak256(hex"f39Fd6e51aad88F6F4ce6aB8827279cffFb922660077e9ad43da87c100f02196"); + + // // Local test, addresses mirror Base + uniswapAddresses[1616161] = UNISWAP_ADDRESSES({ + UNISWAP_POSITION_MANAGER: payable( + 0x7C5f5A4bBd8fD63184577525326123B519429bDc + ), + UNISWAP_PERMIT2: 0x000000000022D473030F116dDEE9F6B43aC78BA3, + UNISWAP_POOL_MANAGER: 0x498581fF718922c3f8e6A244956aF099B2652b2b, + UNISWAP_STATE_VIEW: 0xA3c0c9b65baD0b08107Aa264b0f3dB444b867A71 + }); + + + CreateX createx = CreateX(0xba5Ed099633D3B313e4D5F7bdc1305d3c28ba5Ed); + + address calculatedC1Address = createx.computeCreate3Address(_guard(bytes32(uint256(SALT) + 2))); + address calculatedC2Address = createx.computeCreate3Address(_guard(bytes32(uint256(SALT) + 1))); + + vm.startBroadcast(); + + bytes memory c0CreationCode = hex"60803461011457601f610f2b38819003918201601f19168301916001600160401b038311848410176101185780849260e094604052833981010312610114576100478161012c565b60208201519091906001600160a01b038116036101145760c081610070604061009e940161012c565b5061007d6060820161012c565b5061008a6080820161012c565b5061009760a0820161012c565b500161012c565b506001600160a01b03168015610101575f80546001600160a01b031981168317825560405192916001600160a01b03909116907f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e09080a3610dea90816101418239f35b631e4fbdf760e01b5f525f60045260245ffd5b5f80fd5b634e487b7160e01b5f52604160045260245ffd5b51906001600160a01b03821682036101145756fe6080806040526004361015610012575f80fd5b5f905f3560e01c9081630abd5fb9146109a457508063188b3c0f14610987578063222d23501461095f578063335abd45146109375780633c0299e51461090f57806346ae0668146108e75780634c25cf26146108ca57806351f18899146108a2578063527203e31461081357806359ac19c1146107c7578063691a0f1e146106555780636c3dd13e14610637578063715018a6146105dd578063787a08a6146105bf5780637c85a183146105a15780638a14117a146105835780638b211c02146105655780638da5cb5b1461053e578063a0beb08414610512578063aaba5271146104aa578063ad55af4b14610481578063b531d7f3146102d8578063ba405a0c146102af578063d2ca211514610291578063d6fbf20214610273578063dd7b883814610255578063e13cbe3814610237578063e2c6505614610219578063ea515b87146101f05763f2fde38b14610168575f80fd5b346101ed5760203660031901126101ed576101816109be565b610189610b6d565b6001600160a01b031680156101d95781546001600160a01b03198116821783556001600160a01b03167f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e08380a380f35b631e4fbdf760e01b82526004829052602482fd5b80fd5b50346101ed57806003193601126101ed576012546040516001600160a01b039091168152602090f35b50346101ed57806003193601126101ed576020600c54604051908152f35b50346101ed57806003193601126101ed576020601054604051908152f35b50346101ed57806003193601126101ed576020600854604051908152f35b50346101ed57806003193601126101ed576020600a54604051908152f35b50346101ed57806003193601126101ed576020600554604051908152f35b50346101ed57806003193601126101ed57600f546040516001600160a01b039091168152602090f35b50346101ed576101803660031901126101ed5760a435610124356001600160a01b03811690610104359060c4359083900361047d57610144356001600160a01b03811694908590036104795761032c610b6d565b8260011b8381046002148415171561043d57826b033b2e3c9fd0803ce8000000036b033b2e3c9fd0803ce800000081116104655761037381670de0b6b3a764000084610d02565b91846b033b2e3c9fd0803ce80000001461045157670de0b6b3a764000090091515810180911161043d57811061042e57600435600555602435600655604435600755606435600855608435600955600a55600d5560e435600b55600c556bffffffffffffffffffffffff60a01b600e541617600e556bffffffffffffffffffffffff60a01b600f541617600f55610164356010557fd9d41def84794db66ae5ab5d041caacb905afc49e9ca3604aa41bb1ea8a582e28180a180f35b635a2ee95360e11b8652600486fd5b634e487b7160e01b87526011600452602487fd5b634e487b7160e01b89526012600452602489fd5b634e487b7160e01b88526011600452602488fd5b8580fd5b8480fd5b50346101ed57806003193601126101ed576004546040516001600160a01b039091168152602090f35b50346101ed5760603660031901126101ed576044356104c7610b6d565b8015610503576004356014556024356013556015557f4bbf648868a599cc55656d8bca2c460f8c2136d467ea4c033a8aaaee493cdfbf8180a180f35b63166cb78960e01b8252600482fd5b50346101ed5760203660031901126101ed5760206105366105316109be565b610a1a565b604051908152f35b50346101ed57806003193601126101ed57546040516001600160a01b039091168152602090f35b50346101ed57806003193601126101ed576020600d54604051908152f35b50346101ed57806003193601126101ed576020600654604051908152f35b50346101ed57806003193601126101ed576020600b54604051908152f35b50346101ed57806003193601126101ed576020600754604051908152f35b50346101ed57806003193601126101ed576105f6610b6d565b80546001600160a01b03198116825581906001600160a01b03167f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e08280a380f35b50346101ed57806003193601126101ed576020600954604051908152f35b503461075b57606036600319011261075b5761066f6109be565b600e5460243590604435906001600160a01b03163303610796576011546001600160a01b031692831561075f57833b1561075b575f80916064604051809481936324d5fda360e01b835260018060a01b0316988960048401528860248401528760448401525af1801561075057610715575b507f20dbc16160364bc8d282b93f41c769d3b024f0f8844f96fae2d74b2b71d844719160409182519182526020820152a280f35b6040919450916107465f7f20dbc16160364bc8d282b93f41c769d3b024f0f8844f96fae2d74b2b71d84471946109d4565b5f949150916106e1565b6040513d5f823e3d90fd5b5f80fd5b60405162461bcd60e51b815260206004820152600f60248201526e6e6f2d63756c742d666163746f727960881b6044820152606490fd5b60405162461bcd60e51b81526020600482015260096024820152686f6e6c792d686f6f6b60b81b6044820152606490fd5b3461075b575f36600319011261075b57600154600254600354600454604080516001600160a01b0395861681529385166020850152918416918301919091529091166060820152608090f35b3461075b575f36600319011261075b57610180600554600654600754600854600954600a54600d54600b5491600c549360018060a01b03600e54169560018060a01b03600f541697601054996040519b8c5260208c015260408b015260608a0152608089015260a088015260c087015260e0860152610100850152610120840152610140830152610160820152f35b3461075b575f36600319011261075b576001546040516001600160a01b039091168152602090f35b3461075b575f36600319011261075b576020601554604051908152f35b3461075b575f36600319011261075b57600e546040516001600160a01b039091168152602090f35b3461075b575f36600319011261075b576011546040516001600160a01b039091168152602090f35b3461075b575f36600319011261075b576002546040516001600160a01b039091168152602090f35b3461075b575f36600319011261075b576003546040516001600160a01b039091168152602090f35b3461075b575f36600319011261075b576020601454604051908152f35b3461075b575f36600319011261075b576020906013548152f35b600435906001600160a01b038216820361075b57565b90601f8019910116810190811067ffffffffffffffff8211176109f657604052565b634e487b7160e01b5f52604160045260245ffd5b519062ffffff8216820361075b57565b600f546001600160a01b03908116908216808214610b5e57819282819210610b53575b5060018060a01b03600454169260405160a0810181811067ffffffffffffffff8211176109f65760809160a091604052600180831b0384168152600180831b0385166020820152610bb86040820152603c60608201525f8382015220602460405180978193633205590760e21b835260048301525afa938415610750575f94610ae5575b506001600160a01b03841615610add57610ada93610c51565b90565b505050505f90565b9093506080813d608011610b4b575b81610b01608093836109d4565b8101031261075b578051906001600160a01b038216820361075b5760208101518060020b0361075b57606081610b3c6040610b439401610a0a565b5001610a0a565b50925f610ac1565b3d9150610af4565b92508190505f610a3d565b505050670de0b6b3a764000090565b5f546001600160a01b03163303610b8057565b63118cdaa760e01b5f523360045260245ffd5b5f908015610c4b578080600114610c4357600214610c3c5760016101338210166001600b83101617610c2e579060019060025b60018111610bf25750825f19048211610bde57500290565b634e487b7160e01b81526011600452602490fd5b92805f19048111610c1a5760018416610c11575b80029260011c610bc6565b80920291610c06565b634e487b7160e01b82526011600452602482fd5b6002900a919080610bde5750565b5050600490565b505050600190565b50505f90565b5f9390926001600160a01b039182169291168203610cb75750610c7e916001600160a01b03169050610b93565b90610c96600160c01b670de0b6b3a764000084610d02565b91600160c01b90670de0b6b3a7640000900915158201809211610bde575090565b9192506001600160a01b0390911603610cf357610ada90610ce0906001600160a01b0316610b93565b670de0b6b3a7640000600160c01b610d02565b63e6f2de8760e01b5f5260045ffd5b91818302915f1981850993838086109503948086039514610d925784831115610d7a5790829109815f0382168092046002816003021880820260020302808202600203028082026002030280820260020302808202600203028091026002030293600183805f03040190848311900302920304170290565b82634e487b715f52156003026011186020526024601cfd5b505080925015610da0570490565b634e487b7160e01b5f52601260045260245ffdfea26469706673582212204c94689568baebf5fff57a2f3e072a1fb7067e2c83f098eac9e838fbfdded18664736f6c634300081a0033"; + + bytes memory c0DeploymentCode = abi.encodePacked( + c0CreationCode, + abi.encode( + 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, + uniswapAddresses[block.chainid].UNISWAP_POSITION_MANAGER, + uniswapAddresses[block.chainid].UNISWAP_PERMIT2, + uniswapAddresses[block.chainid].UNISWAP_POOL_MANAGER, + uniswapAddresses[block.chainid].UNISWAP_STATE_VIEW, + calculatedC1Address, + calculatedC2Address + ) + ); + + address c0Address = createx.deployCreate3( + SALT, + c0DeploymentCode + ); + + + bytes memory c2CreationCode = hex"60803460b857601f6112a038819003918201601f19168301916001600160401b0383118484101760bc5780849260209460405283398101031260b857516001600160a01b0381169081900360b857801560a5575f80546001600160a01b031981168317825560405192916001600160a01b03909116907f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e09080a36111cf90816100d18239f35b631e4fbdf760e01b5f525f60045260245ffd5b5f80fd5b634e487b7160e01b5f52604160045260245ffdfe6080806040526004361015610012575f80fd5b5f905f3560e01c9081630d31612614610f52575080631f94e50314610e4757806324d5fda314610ccb57806343fe85ba14610c8e5780634fb6df17146109c35780634ff013711461099a5780635eeb1bbc1461095b578063646ec81d1461086b5780636f8c57831461080a578063712232d5146107d1578063715018a61461077757806373c376581461073e5780637734d07614610685578063862a8eff146106445780638c730f2a146106185780638da5cb5b146105f1578063a05ff6b8146105ae578063aed19359146101bb578063d48418a1146101825763f2fde38b146100fa575f80fd5b3461017f57602036600319011261017f57610113610f86565b61011b6110de565b6001600160a01b0316801561016b5781546001600160a01b03198116821783556001600160a01b03167f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e08380a380f35b631e4fbdf760e01b82526004829052602482fd5b80fd5b503461017f57602036600319011261017f576020906040906001600160a01b036101aa610f86565b168152600883522054604051908152f35b503461017f57608036600319011261017f576101d5610f86565b9060243567ffffffffffffffff81116105aa576101f6903690600401610fd4565b60443567ffffffffffffffff81116105a657610216903690600401610fd4565b926064356001600160a01b038116908190036105a257835460405163ea515b8760e01b81526001600160a01b0390911695906020816004818a5afa908115610597578691610559575b506001600160a01b031693841561054a578560409461031d946102e360016020809b9a976102b9600583809a8e519b818d9251918291018484015e8101641021b7b4b760d91b838201520301601a1981018b520189610fb2565b8a519c818e9251918291018484015e8101604360f81b838201520301601e1981018c52018a610fb2565b61032f8751998a9788968795637b17ab9560e01b875260018060a01b0316600487015286602487015260c0604487015260c48601906110ba565b848103600319016064860152906110ba565b9060848301528260a483015203925af19182156104835781908293610502575b506001600160a01b0316808252600360209081526040808420805460ff191660011790555163188b3c0f60e01b8152939192919084600481885afa9384156104c35782946104ce575b5060018060a01b03169283825260066020526040822055604051630abd5fb960e01b8152602081600481885afa9081156104c3578291610490575b5060049460209185845260088352604084205560405195868092632612e79360e11b82525afa93841561048357819461044a575b50602083928195604084867fad5759c1cafdae1264943f6fa2482923381aa1c5fe5325f77329f99db30053ed9652600785522055604051908152a2604051908152f35b93506020843d60201161047b575b8161046560209383610fb2565b81010312610477579251926020610407565b5f80fd5b3d9150610458565b50604051903d90823e3d90fd5b90506020813d6020116104bb575b816104ab60209383610fb2565b81010312610477575160046103d3565b3d915061049e565b6040513d84823e3d90fd5b9093506020813d6020116104fa575b816104ea60209383610fb2565b810103126104775751925f610398565b3d91506104dd565b9250506040823d604011610542575b8161051e60409383610fb2565b8101031261017f5761053b6020610534846110a6565b93016110a6565b915f61034f565b3d9150610511565b6370b649dd60e01b8652600486fd5b90506020813d60201161058f575b8161057460209383610fb2565b8101031261058b57610585906110a6565b5f61025f565b8580fd5b3d9150610567565b6040513d88823e3d90fd5b8380fd5b8280fd5b5080fd5b503461017f57604036600319011261017f576020906040906001600160a01b036105d6610f86565b168152600a8352818120602435825283522054604051908152f35b503461017f578060031936011261017f57546040516001600160a01b039091168152602090f35b503461017f57602036600319011261017f57602061063c610637610f86565b61105b565b604051908152f35b503461017f57602036600319011261017f576020906001600160a01b03610669610f86565b16815260018252604060018060a01b0391205416604051908152f35b503461017f57606036600319011261017f5761069f610f86565b906106a8610f9c565b916106b16110de565b6001600160a01b03168082526002602052604082205490929060ff161561072f578282933b1561072b576040516340c10f1960e01b81526001600160a01b039290921660048301526044803560248401528391839190829084905af180156104c35761071a5750f35b8161072491610fb2565b61017f5780f35b5050fd5b638c5d908560e01b8252600482fd5b503461017f57602036600319011261017f576020906040906001600160a01b03610766610f86565b168152600783522054604051908152f35b503461017f578060031936011261017f576107906110de565b80546001600160a01b03198116825581906001600160a01b03167f8be0079c531659141344cd1fd0a4f28419497f9722a3daafe3b4186f6b6457e08280a380f35b503461017f57602036600319011261017f576020906040906001600160a01b036107f9610f86565b168152600683522054604051908152f35b503461017f57602036600319011261017f576080906040906001600160a01b03610832610f86565b16815260096020522060ff8154169060018101549060036002820154910154916040519315158452602084015260408301526060820152f35b503461017f57602036600319011261017f57610885610f86565b6004546001600160a01b0316330361094c576001600160a01b0316808252600260209081526040808420805460ff191660011790558284526005909152822054156108f2575b7fa4d13a5bef1890eddfd4d435735b46ed074a5e4b8f0a421cd526aeeef8141d3a8280a280f35b80825260056020524260408320558082526005602052807faed14053ca40d8865c112ef238ff360fe2189d5730000f57a6b88caf3198650e60408085205483865260076020528186205482519182526020820152a26108cb565b635651e6f560e11b8252600482fd5b503461017f57602036600319011261017f5760209060ff906040906001600160a01b03610986610f86565b168152600384522054166040519015158152f35b503461017f578060031936011261017f576004546040516001600160a01b039091168152602090f35b5034610477576040366003190112610477576109dd610f86565b602435908115610c7f576001600160a01b03165f818152600960205260409081902090516080810167ffffffffffffffff811182821017610c6b5760405260ff82541615801582526001830154916020810192835260606003600286015495604084019687520154910152610c5d57825f52600160205260018060a01b0360405f20541691825f52600260205260ff60405f20541615610c4e57815190835f52600a60205260405f20825f5260205260405f2054918215610c3f57845f52600660205260405f20548015610c305790879291865f52600860205260405f2054670de0b6b3a76400009290815b610bf0575050610af292610ae5610af7969593610aed93611104565b90519061102a565b61102a565b61103d565b8015610be157833b156104775760405163079cc67960e41b8152336004820152602481018690525f8160448183895af18015610bd657610bc1575b50823b1561058b576040516340c10f1960e01b8152336004820152602481018290528690818160448183895af180156104c357610bac575b505090516040805195865260208601929092529084015233927f142894e15d18cf7c1106a97eefe825087bf328fa8730129e3f89134321b3a23490606090a480f35b81610bb691610fb2565b61058b57855f610b6a565b610bce9196505f90610fb2565b5f945f610b32565b6040513d5f823e3d90fd5b639a57326560e01b5f5260045ffd5b909192939450600180831614610c1a575b80610c0b91611104565b899493929160011c9081610ac9565b92610c2884610c0b92611104565b939050610c01565b63374c934360e11b5f5260045ffd5b632959df1160e21b5f5260045ffd5b638c5d908560e01b5f5260045ffd5b62ad587d60e81b5f5260045ffd5b634e487b7160e01b5f52604160045260245ffd5b6313aab74360e01b5f5260045ffd5b34610477576020366003190112610477576001600160a01b03610caf610f86565b165f526002602052602060ff60405f2054166040519015158152f35b3461047757606036600319011261047757610ce4610f86565b60243560443591610cf36110de565b8115610e38578215610e29576001600160a01b039081165f8181526001602052604090205490939116918215610e1a57825f52600560205260405f205415610e0b57835f52600960205260405f2080549060ff8216610dfc57600384916001610d5b8861105b565b9460ff19161781558360018201558460028201550155610d7b838361102a565b92845f52600a60205260405f20825f5260205260405f20908154948501809511610de8577fc8ed84018dd91b7f9ef452fe95948d9dad8e47361ad1dd5886c54398e838e24094610de39255604051938493846040919493926060820195825260208201520152565b0390a3005b634e487b7160e01b5f52601160045260245ffd5b630931f96360e41b5f5260045ffd5b63277e703d60e21b5f5260045ffd5b63325ddddd60e01b5f5260045ffd5b639fbba64f60e01b5f5260045ffd5b637294708f60e11b5f5260045ffd5b3461047757604036600319011261047757610e60610f86565b610e68610f9c565b6004546001600160a01b03163303610f43576001600160a01b03169081158015610f32575b610f23576001600160a01b03165f8181526002602052604090205490919060ff1615610c4e575f818152600160205260409020546001600160a01b0316610f11575f81815260016020526040812080546001600160a01b031916841790557f0cb63fc6bb9f844b450c8052989a65f162ecac87757ca2c3921e0e9b7c4ccff69080a3005b631535136360e31b5f5260045260245ffd5b63e6c4247b60e01b5f5260045ffd5b506001600160a01b03811615610e8d565b635651e6f560e11b5f5260045ffd5b34610477576020366003190112610477576020906001600160a01b03610f76610f86565b165f526005825260405f20548152f35b600435906001600160a01b038216820361047757565b602435906001600160a01b038216820361047757565b90601f8019910116810190811067ffffffffffffffff821117610c6b57604052565b81601f820112156104775780359067ffffffffffffffff8211610c6b5760405192611009601f8401601f191660200185610fb2565b8284526020838301011161047757815f926020809301838601378301015290565b81810292918115918404141715610de857565b8115611047570490565b634e487b7160e01b5f52601260045260245ffd5b6001600160a01b03165f8181526005602052604090205415610e0b57805f52600560205260405f20544203428111610de8576110a3915f52600760205260405f20549061103d565b90565b51906001600160a01b038216820361047757565b805180835260209291819084018484015e5f828201840152601f01601f1916010190565b5f546001600160a01b031633036110f157565b63118cdaa760e01b5f523360045260245ffd5b9190915f838202915f19858209918380841093039280840393146111865782670de0b6b3a7640000111561117457507faccb18165bd6fe31ae1cf318dc5b51eee0e1ba569b88cd74c1773b91fac106699394670de0b6b3a7640000910990828211900360ee1b910360121c170290565b634e487b71905260116020526024601cfd5b505050670de0b6b3a7640000919250049056fea2646970667358221220e8dac8dec112cb258e7bd8c159fd1da98ea741bb7b01073a1f31609d4cf729a064736f6c634300081a0033"; + bytes memory c2DeploymentCode = abi.encodePacked( + c2CreationCode, + abi.encode(c0Address) + ); + + address c2Address = createx.deployCreate3( + bytes32(uint256(SALT) + 1), + c2DeploymentCode + ); + + // Commenting out this check causes script to pass + if (c2Address != calculatedC2Address) { + console.log("address mismatch"); + } + + vm.stopBroadcast(); + } +} \ No newline at end of file diff --git a/crates/forge/tests/fixtures/ExpectCallFailures.t.sol b/crates/forge/tests/fixtures/ExpectCallFailures.t.sol index a917e5de1565f..4b68a4271a3f3 100644 --- a/crates/forge/tests/fixtures/ExpectCallFailures.t.sol +++ b/crates/forge/tests/fixtures/ExpectCallFailures.t.sol @@ -80,6 +80,13 @@ contract ExpectCallFailureTest is DSTest { this.exposed_callTargetNTimes(target, 3, 3, 1); } + function testShouldFailExpectDelegateCallWithCall() public { + Contract target = new Contract(); + bytes memory data = abi.encodeWithSelector(target.add.selector, 1, 2); + vm.expectDelegateCall(address(target), data); + target.add(1, 2); + } + function testShouldFailExpectInnerCall() public { Contract inner = new Contract(); NestedContract target = new NestedContract(inner); @@ -131,6 +138,15 @@ contract ExpectCallFailureTest is DSTest { vm.expectCall(address(target), abi.encodeWithSelector(target.add.selector)); this.exposed_callTargetNTimes(target, 5, 5, 1); } + + /// Ensure that a test frame that halts reports the unmet expectation as a revert. + function testShouldFailExpectCallWhenTestHalts() public { + Contract target = new Contract(); + vm.expectCall(address(target), abi.encodeWithSelector(target.add.selector, 1, 2)); + assembly { + invalid() + } + } } contract ExpectCallCountFailureTest is DSTest { diff --git a/crates/forge/tests/fixtures/ExpectEmitFailures.t.sol b/crates/forge/tests/fixtures/ExpectEmitFailures.t.sol index 8d7918e364766..75b141241f9fd 100644 --- a/crates/forge/tests/fixtures/ExpectEmitFailures.t.sol +++ b/crates/forge/tests/fixtures/ExpectEmitFailures.t.sol @@ -109,7 +109,8 @@ contract Emitter { /// Emulates `Emitter` in #760 contract LowLevelCaller { function f() external { - address(this).call(abi.encodeWithSignature("g()")); + (bool success,) = address(this).call(abi.encodeWithSignature("g()")); + require(success, "call failed"); } function g() public {} diff --git a/crates/forge/tests/fixtures/ExpectEmitParamFailures.t.sol b/crates/forge/tests/fixtures/ExpectEmitParamFailures.t.sol new file mode 100644 index 0000000000000..c34944996cb5a --- /dev/null +++ b/crates/forge/tests/fixtures/ExpectEmitParamFailures.t.sol @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.18; + +import "./test.sol"; +import "./Vm.sol"; + +import {EventEmitter, SelectiveEmitter, ParamNumberingEmitter, AnonymousEmitter, ManyParamsEmitter} from "./ExpectEmitParamHarness.sol"; + +contract ExpectEmitParamFailures is DSTest { + Vm constant vm = Vm(HEVM_ADDRESS); + // Contract instances + EventEmitter public eventEmitter; + SelectiveEmitter public selectiveEmitter; + ParamNumberingEmitter public paramNumberingEmitter; + AnonymousEmitter public anonymousEmitter; + ManyParamsEmitter public manyParamsEmitter; + + // Event declarations for tests + event SimpleEvent(uint256 indexed a, uint256 indexed b, uint256 c); + event ComplexEvent(address indexed sender, uint256 indexed id, bytes data); + event TestEvent(uint256 indexed a, uint256 indexed b, uint256 c); + + event MixedEventNumbering( + uint256 indexed param0, + address indexed param1, + uint256 param2, + uint256 param3, + address param4 + ); + + // Anonymous event for tests + event AnonymousIndexed(uint256 indexed a, uint256 b, address c) anonymous; + + // Event with many parameters + event ManyParams(uint256 a, uint256 b, uint256 c, uint256 d, uint256 e); + + function setUp() public { + eventEmitter = new EventEmitter(); + selectiveEmitter = new SelectiveEmitter(); + paramNumberingEmitter = new ParamNumberingEmitter(); + anonymousEmitter = new AnonymousEmitter(); + manyParamsEmitter = new ManyParamsEmitter(); + } + + function testIndexedParamMismatch() public { + vm.expectEmit(true, true, true, true); + emit SimpleEvent(100, 200, 300); + eventEmitter.emitSimple(100, 999, 300); // Second indexed param (b) mismatch + } + + function testNonIndexedParamMismatch() public { + vm.expectEmit(true, true, true, true); + emit SimpleEvent(100, 200, 300); + eventEmitter.emitSimple(100, 200, 999); // Non-indexed param (c) mismatch + } + + function testMultipleMismatches() public { + vm.expectEmit(true, true, true, true); + emit SimpleEvent(100, 200, 300); + eventEmitter.emitSimple(999, 888, 777); // All params mismatch + } + + function testSelectiveChecks() public { + vm.expectEmit(true, false, true, true); // checkTopic2=false + emit TestEvent(100, 200, 300); + selectiveEmitter.emitEvent(100, 999, 300); // Topic2 different but not checked + } + + function testParameterNumbering() public { + vm.expectEmit(true, true, true, true); + emit MixedEventNumbering( + 100, + address(0x1234), + 300, + 400, + address(0x5678) + ); + paramNumberingEmitter.emitEvent( + 100, + address(0x1234), + 999, + 400, + address(0x5678) + ); // param2 mismatch + } + + function testCompletelyDifferentEvent() public { + vm.expectEmit(true, true, true, true); + emit SimpleEvent(100, 200, 300); + eventEmitter.emitComplex(address(this), 42, hex"deadbeef"); // Different event type + } + + function testAnonymousEventMismatch() public { + vm.expectEmitAnonymous(true, false, false, false, true); // Check topic0 and data + emit AnonymousIndexed(100, 200, address(0x1234)); + anonymousEmitter.emitAnonymousIndexed(999, 200, address(0x1234)); // param0 mismatch + } + + function testManyParameterMismatches() public { + vm.expectEmit(true, true, true, true); + // Event with 5 non-indexed parameters + emit ManyParams(100, 200, 300, 400, 500); + // All 5 parameters differ - should show each one individually + manyParamsEmitter.emitManyParams(111, 222, 333, 444, 555); + } + + function testMixedEventNonIndexedMismatch() public { + // For SimpleEvent: 'a' and 'b' are indexed, 'c' is non-indexed + vm.expectEmit(true, true, true, true); + emit SimpleEvent(100, 200, 300); + // Same indexed params (100, 200) but different non-indexed param + eventEmitter.emitSimple(100, 200, 999); + } +} diff --git a/crates/forge/tests/fixtures/ExpectEmitParamHarness.sol b/crates/forge/tests/fixtures/ExpectEmitParamHarness.sol new file mode 100644 index 0000000000000..95d8faa07f029 --- /dev/null +++ b/crates/forge/tests/fixtures/ExpectEmitParamHarness.sol @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: UNLICENSED + +// Harness contract are in a separate file so that the selectors cache can be populated on `forge b`. +contract EventEmitter { + event SimpleEvent(uint256 indexed a, uint256 indexed b, uint256 c); + event ComplexEvent(address indexed sender, uint256 indexed id, bytes data); + + function emitSimple(uint256 a, uint256 b, uint256 c) public { + emit SimpleEvent(a, b, c); + } + + function emitComplex(address sender, uint256 id, bytes memory data) public { + emit ComplexEvent(sender, id, data); + } + + function emitSimpleMultipleTimes( + uint256 a, + uint256 b, + uint256 c, + uint256 times + ) public { + for (uint256 i = 0; i < times; i++) { + emit SimpleEvent(a, b, c); + } + } +} + +contract SelectiveEmitter { + event TestEvent(uint256 indexed a, uint256 indexed b, uint256 c); + + function emitEvent(uint256 a, uint256 b, uint256 c) public { + emit TestEvent(a, b, c); + } +} + +contract ParamNumberingEmitter { + // Event with 2 indexed and 3 non-indexed parameters + event MixedEventNumbering( + uint256 indexed param0, // param 0 (indexed) + address indexed param1, // param 1 (indexed) + uint256 param2, // param 2 (non-indexed) + uint256 param3, // param 3 (non-indexed) + address param4 // param 4 (non-indexed) + ); + + function emitEvent( + uint256 p0, + address p1, + uint256 p2, + uint256 p3, + address p4 + ) public { + emit MixedEventNumbering(p0, p1, p2, p3, p4); + } +} + +contract AnonymousEmitter { + // Anonymous event with indexed parameter + event AnonymousIndexed(uint256 indexed a, uint256 b, address c) anonymous; + + function emitAnonymousIndexed(uint256 a, uint256 b, address c) public { + emit AnonymousIndexed(a, b, c); + } +} + +contract ManyParamsEmitter { + // Event with many non-indexed parameters to trigger raw data display + event ManyParams(uint256 a, uint256 b, uint256 c, uint256 d, uint256 e); + + function emitManyParams( + uint256 a, + uint256 b, + uint256 c, + uint256 d, + uint256 e + ) public { + emit ManyParams(a, b, c, d, e); + } +} diff --git a/crates/forge/tests/fixtures/ExpectRevertFailures.t.sol b/crates/forge/tests/fixtures/ExpectRevertFailures.t.sol index 7e482c6673155..bdc8c2afa43c5 100644 --- a/crates/forge/tests/fixtures/ExpectRevertFailures.t.sol +++ b/crates/forge/tests/fixtures/ExpectRevertFailures.t.sol @@ -233,6 +233,60 @@ contract ExpectRevertWithReverterFailureTest is DSTest { aContract.doNotRevert(); aContract.callAndRevert(); } + + // + // Regression: must fail because 0xdead is not the actual reverter when a + // top-level CREATE constructor reverts directly. + function testShouldFailExpectRevertWrongReverterTopLevelCreate() public { + vm.expectRevert(address(0xdead)); + new DContract(); + } + + // + // Regression: must fail because the reverter address argument is enforced + // even when an exact-bytes pattern is also supplied for a top-level CREATE. + function testShouldFailExpectRevertWithBytesWrongReverterTopLevelCreate() public { + vm.expectRevert(abi.encodePacked("Reverted by DContract"), address(0xdead)); + new DContract(); + } + + // + // Regression: must fail because the reverter address argument is enforced + // for `expectPartialRevert(bytes4, address)` against a top-level CREATE. + function testShouldFailExpectPartialRevertWrongReverterTopLevelCreate() public { + vm.expectPartialRevert(bytes4(keccak256("Error(string)")), address(0xdead)); + new DContract(); + } + + // + // Regression: must fail when the innermost reverting frame is a nested + // CREATE and the reverter address argument does not match the would-be + // deployed address of the failed (innermost) deployment. + function testShouldFailExpectRevertWrongReverterNestedCreate() public { + vm.expectRevert(address(0xdead)); + new NestedDContractCreator(); + } + + // + // Regression: documents innermost-wins semantics for nested CREATEs — the + // matched reverter is the *innermost* would-be-deployed address. Supplying + // the outer address must fail. + function testShouldFailExpectRevertNestedCreateOuterAddress() public { + // Outer = NestedDContractCreator at this contract's next nonce. Under + // innermost-wins, the matched reverter is the inner DContract, not the + // outer. + address outer = vm.computeCreateAddress(address(this), vm.getNonce(address(this))); + vm.expectRevert(outer); + new NestedDContractCreator(); + } +} + +// Used by `testShouldFailExpectRevertWrongReverterNestedCreate`: a contract whose +// constructor directly creates another contract that reverts. +contract NestedDContractCreator { + constructor() { + new DContract(); + } } contract ExpectRevertCountFailureTest is DSTest { diff --git a/crates/forge/tests/fixtures/SimpleContractTestNonVerbose.json b/crates/forge/tests/fixtures/SimpleContractTestNonVerbose.json index e9d992751b868..d8e517219b74f 100644 --- a/crates/forge/tests/fixtures/SimpleContractTestNonVerbose.json +++ b/crates/forge/tests/fixtures/SimpleContractTestNonVerbose.json @@ -1,25 +1,25 @@ { - "src/Simple.t.sol:SimpleContractTest": { - "duration": "{...}", - "test_results": { - "test()": { - "status": "Success", - "reason": null, - "counterexample": null, - "logs": [], - "decoded_logs": [], - "kind": { - "Unit": { - "gas": "{...}" - } - }, - "traces": [], - "labeled_addresses": {}, - "duration": "{...}", - "breakpoints": {}, - "gas_snapshots": {} - } + "src/Simple.t.sol:SimpleContractTest": { + "duration": "{...}", + "test_results": { + "test()": { + "status": "Success", + "reason": null, + "counterexample": null, + "logs": [], + "decoded_logs": [], + "kind": { + "Unit": { + "gas": "{...}" + } }, - "warnings": [] - } -} \ No newline at end of file + "traces": [], + "labeled_addresses": {}, + "duration": "{...}", + "breakpoints": {}, + "gas_snapshots": {} + } + }, + "warnings": [] + } +} diff --git a/crates/forge/tests/fixtures/SimpleContractTestVerbose.json b/crates/forge/tests/fixtures/SimpleContractTestVerbose.json index 8659312887de6..0995e90eda4f6 100644 --- a/crates/forge/tests/fixtures/SimpleContractTestVerbose.json +++ b/crates/forge/tests/fixtures/SimpleContractTestVerbose.json @@ -45,15 +45,14 @@ "value": "0x0", "data": "{...}", "output": "{...}", + "bytecode": null, "gas_used": "{...}", "gas_limit": "{...}", + "gas_refund_counter": 0, "status": "Return", "steps": [], - "decoded": { - "label": null, - "return_data": null, - "call_data": null - } + "step_deltas": [], + "decoded": null }, "logs": [], "ordering": [] @@ -86,58 +85,7703 @@ "value": "0x0", "data": "0xf8a8fd6d", "output": "0x", + "bytecode": null, "gas_used": "{...}", "gas_limit": "{...}", + "gas_refund_counter": 0, "status": "Stop", - "steps": [], - "decoded": { - "label": null, - "return_data": null, - "call_data": null - } + "steps": [ + { + "pc": 0, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 2, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 4, + "op": 82, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 12, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 5, + "op": 52, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 6, + "op": 128, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 7, + "op": 21, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 8, + "op": 97, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 11, + "op": 87, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 10, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 15, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 16, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 17, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 19, + "op": 54, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 20, + "op": 16, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 21, + "op": 97, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 24, + "op": 87, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 10, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 25, + "op": 95, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 26, + "op": 53, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 27, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 29, + "op": 28, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 30, + "op": 128, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 31, + "op": 99, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 36, + "op": 20, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 37, + "op": 97, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 40, + "op": 87, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 10, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 41, + "op": 128, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 42, + "op": 99, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 47, + "op": 20, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 48, + "op": 97, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 51, + "op": 87, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 10, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 52, + "op": 128, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 53, + "op": 99, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 58, + "op": 20, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 59, + "op": 97, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 62, + "op": 87, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 10, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 138, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 139, + "op": 97, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 142, + "op": 97, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 145, + "op": 86, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 8, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 602, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 603, + "op": 95, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 604, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 606, + "op": 81, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 607, + "op": 97, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 610, + "op": 144, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 611, + "op": 97, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 614, + "op": 86, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 8, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1035, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1036, + "op": 97, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1039, + "op": 128, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1040, + "op": 97, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1043, + "op": 131, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1044, + "op": 57, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 72, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1045, + "op": 1, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1046, + "op": 144, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1047, + "op": 86, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 8, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 615, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 616, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 618, + "op": 81, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 619, + "op": 128, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 620, + "op": 145, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 621, + "op": 3, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 622, + "op": 144, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 623, + "op": 95, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 624, + "op": 240, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 1056944078, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 625, + "op": 128, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 626, + "op": 21, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571399, + "gas_refund_counter": 0, + "gas_used": 149361, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 627, + "op": 128, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571396, + "gas_refund_counter": 0, + "gas_used": 149364, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 628, + "op": 21, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571393, + "gas_refund_counter": 0, + "gas_used": 149367, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 629, + "op": 97, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571390, + "gas_refund_counter": 0, + "gas_used": 149370, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 632, + "op": 87, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571387, + "gas_refund_counter": 0, + "gas_used": 149373, + "gas_cost": 10, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 640, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571377, + "gas_refund_counter": 0, + "gas_used": 149383, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 641, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571376, + "gas_refund_counter": 0, + "gas_used": 149384, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 642, + "op": 144, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571374, + "gas_refund_counter": 0, + "gas_used": 149386, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 643, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571371, + "gas_refund_counter": 0, + "gas_used": 149389, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 644, + "op": 128, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571369, + "gas_refund_counter": 0, + "gas_used": 149391, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 645, + "op": 115, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571366, + "gas_refund_counter": 0, + "gas_used": 149394, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 666, + "op": 22, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571363, + "gas_refund_counter": 0, + "gas_used": 149397, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 667, + "op": 99, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571360, + "gas_refund_counter": 0, + "gas_used": 149400, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 672, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571357, + "gas_refund_counter": 0, + "gas_used": 149403, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 674, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571354, + "gas_refund_counter": 0, + "gas_used": 149406, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 676, + "op": 81, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571351, + "gas_refund_counter": 0, + "gas_used": 149409, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 677, + "op": 130, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571348, + "gas_refund_counter": 0, + "gas_used": 149412, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 678, + "op": 99, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571345, + "gas_refund_counter": 0, + "gas_used": 149415, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 683, + "op": 22, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571342, + "gas_refund_counter": 0, + "gas_used": 149418, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 684, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571339, + "gas_refund_counter": 0, + "gas_used": 149421, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 686, + "op": 27, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571336, + "gas_refund_counter": 0, + "gas_used": 149424, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 687, + "op": 129, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571333, + "gas_refund_counter": 0, + "gas_used": 149427, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 688, + "op": 82, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571330, + "gas_refund_counter": 0, + "gas_used": 149430, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 689, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571327, + "gas_refund_counter": 0, + "gas_used": 149433, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 691, + "op": 1, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571324, + "gas_refund_counter": 0, + "gas_used": 149436, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 692, + "op": 97, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571321, + "gas_refund_counter": 0, + "gas_used": 149439, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 695, + "op": 145, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571318, + "gas_refund_counter": 0, + "gas_used": 149442, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 696, + "op": 144, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571315, + "gas_refund_counter": 0, + "gas_used": 149445, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 697, + "op": 97, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571312, + "gas_refund_counter": 0, + "gas_used": 149448, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 700, + "op": 86, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571309, + "gas_refund_counter": 0, + "gas_used": 149451, + "gas_cost": 8, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1617, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571301, + "gas_refund_counter": 0, + "gas_used": 149459, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1618, + "op": 95, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571300, + "gas_refund_counter": 0, + "gas_used": 149460, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1619, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571298, + "gas_refund_counter": 0, + "gas_used": 149462, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1621, + "op": 130, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571295, + "gas_refund_counter": 0, + "gas_used": 149465, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1622, + "op": 1, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571292, + "gas_refund_counter": 0, + "gas_used": 149468, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1623, + "op": 144, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571289, + "gas_refund_counter": 0, + "gas_used": 149471, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1624, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571286, + "gas_refund_counter": 0, + "gas_used": 149474, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1625, + "op": 97, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571284, + "gas_refund_counter": 0, + "gas_used": 149476, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1628, + "op": 95, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571281, + "gas_refund_counter": 0, + "gas_used": 149479, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1629, + "op": 131, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571279, + "gas_refund_counter": 0, + "gas_used": 149481, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1630, + "op": 1, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571276, + "gas_refund_counter": 0, + "gas_used": 149484, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1631, + "op": 132, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571273, + "gas_refund_counter": 0, + "gas_used": 149487, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1632, + "op": 97, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571270, + "gas_refund_counter": 0, + "gas_used": 149490, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1635, + "op": 86, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571267, + "gas_refund_counter": 0, + "gas_used": 149493, + "gas_cost": 8, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1602, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571259, + "gas_refund_counter": 0, + "gas_used": 149501, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1603, + "op": 97, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571258, + "gas_refund_counter": 0, + "gas_used": 149502, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1606, + "op": 129, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571255, + "gas_refund_counter": 0, + "gas_used": 149505, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1607, + "op": 97, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571252, + "gas_refund_counter": 0, + "gas_used": 149508, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1610, + "op": 86, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571249, + "gas_refund_counter": 0, + "gas_used": 149511, + "gas_cost": 8, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1569, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571241, + "gas_refund_counter": 0, + "gas_used": 149519, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1570, + "op": 95, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571240, + "gas_refund_counter": 0, + "gas_used": 149520, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1571, + "op": 97, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571238, + "gas_refund_counter": 0, + "gas_used": 149522, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1574, + "op": 97, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571235, + "gas_refund_counter": 0, + "gas_used": 149525, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1577, + "op": 97, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571232, + "gas_refund_counter": 0, + "gas_used": 149528, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1580, + "op": 132, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571229, + "gas_refund_counter": 0, + "gas_used": 149531, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1581, + "op": 97, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571226, + "gas_refund_counter": 0, + "gas_used": 149534, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1584, + "op": 86, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571223, + "gas_refund_counter": 0, + "gas_used": 149537, + "gas_cost": 8, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1542, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571215, + "gas_refund_counter": 0, + "gas_used": 149545, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1543, + "op": 95, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571214, + "gas_refund_counter": 0, + "gas_used": 149546, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1544, + "op": 129, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571212, + "gas_refund_counter": 0, + "gas_used": 149548, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1545, + "op": 144, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571209, + "gas_refund_counter": 0, + "gas_used": 149551, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1546, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571206, + "gas_refund_counter": 0, + "gas_used": 149554, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1547, + "op": 145, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571204, + "gas_refund_counter": 0, + "gas_used": 149556, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1548, + "op": 144, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571201, + "gas_refund_counter": 0, + "gas_used": 149559, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1549, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571198, + "gas_refund_counter": 0, + "gas_used": 149562, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1550, + "op": 86, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571196, + "gas_refund_counter": 0, + "gas_used": 149564, + "gas_cost": 8, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1585, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571188, + "gas_refund_counter": 0, + "gas_used": 149572, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1586, + "op": 97, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571187, + "gas_refund_counter": 0, + "gas_used": 149573, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1589, + "op": 86, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571184, + "gas_refund_counter": 0, + "gas_used": 149576, + "gas_cost": 8, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1560, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571176, + "gas_refund_counter": 0, + "gas_used": 149584, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1561, + "op": 95, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571175, + "gas_refund_counter": 0, + "gas_used": 149585, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1562, + "op": 129, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571173, + "gas_refund_counter": 0, + "gas_used": 149587, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1563, + "op": 144, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571170, + "gas_refund_counter": 0, + "gas_used": 149590, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1564, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571167, + "gas_refund_counter": 0, + "gas_used": 149593, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1565, + "op": 145, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571165, + "gas_refund_counter": 0, + "gas_used": 149595, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1566, + "op": 144, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571162, + "gas_refund_counter": 0, + "gas_used": 149598, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1567, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571159, + "gas_refund_counter": 0, + "gas_used": 149601, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1568, + "op": 86, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571157, + "gas_refund_counter": 0, + "gas_used": 149603, + "gas_cost": 8, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1590, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571149, + "gas_refund_counter": 0, + "gas_used": 149611, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1591, + "op": 97, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571148, + "gas_refund_counter": 0, + "gas_used": 149612, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1594, + "op": 86, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571145, + "gas_refund_counter": 0, + "gas_used": 149615, + "gas_cost": 8, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1551, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571137, + "gas_refund_counter": 0, + "gas_used": 149623, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1552, + "op": 95, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571136, + "gas_refund_counter": 0, + "gas_used": 149624, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1553, + "op": 129, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571134, + "gas_refund_counter": 0, + "gas_used": 149626, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1554, + "op": 144, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571131, + "gas_refund_counter": 0, + "gas_used": 149629, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1555, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571128, + "gas_refund_counter": 0, + "gas_used": 149632, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1556, + "op": 145, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571126, + "gas_refund_counter": 0, + "gas_used": 149634, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1557, + "op": 144, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571123, + "gas_refund_counter": 0, + "gas_used": 149637, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1558, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571120, + "gas_refund_counter": 0, + "gas_used": 149640, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1559, + "op": 86, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571118, + "gas_refund_counter": 0, + "gas_used": 149642, + "gas_cost": 8, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1595, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571110, + "gas_refund_counter": 0, + "gas_used": 149650, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1596, + "op": 144, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571109, + "gas_refund_counter": 0, + "gas_used": 149651, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1597, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571106, + "gas_refund_counter": 0, + "gas_used": 149654, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1598, + "op": 145, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571104, + "gas_refund_counter": 0, + "gas_used": 149656, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1599, + "op": 144, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571101, + "gas_refund_counter": 0, + "gas_used": 149659, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1600, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571098, + "gas_refund_counter": 0, + "gas_used": 149662, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1601, + "op": 86, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571096, + "gas_refund_counter": 0, + "gas_used": 149664, + "gas_cost": 8, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1611, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571088, + "gas_refund_counter": 0, + "gas_used": 149672, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1612, + "op": 130, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571087, + "gas_refund_counter": 0, + "gas_used": 149673, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1613, + "op": 82, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571084, + "gas_refund_counter": 0, + "gas_used": 149676, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1614, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571081, + "gas_refund_counter": 0, + "gas_used": 149679, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1615, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571079, + "gas_refund_counter": 0, + "gas_used": 149681, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1616, + "op": 86, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571077, + "gas_refund_counter": 0, + "gas_used": 149683, + "gas_cost": 8, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1636, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571069, + "gas_refund_counter": 0, + "gas_used": 149691, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1637, + "op": 146, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571068, + "gas_refund_counter": 0, + "gas_used": 149692, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1638, + "op": 145, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571065, + "gas_refund_counter": 0, + "gas_used": 149695, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1639, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571062, + "gas_refund_counter": 0, + "gas_used": 149698, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1640, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571060, + "gas_refund_counter": 0, + "gas_used": 149700, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1641, + "op": 86, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571058, + "gas_refund_counter": 0, + "gas_used": 149702, + "gas_cost": 8, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 701, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571050, + "gas_refund_counter": 0, + "gas_used": 149710, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 702, + "op": 95, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571049, + "gas_refund_counter": 0, + "gas_used": 149711, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 703, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571047, + "gas_refund_counter": 0, + "gas_used": 149713, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 705, + "op": 81, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571044, + "gas_refund_counter": 0, + "gas_used": 149716, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 706, + "op": 128, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571041, + "gas_refund_counter": 0, + "gas_used": 149719, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 707, + "op": 131, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571038, + "gas_refund_counter": 0, + "gas_used": 149722, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 708, + "op": 3, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571035, + "gas_refund_counter": 0, + "gas_used": 149725, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 709, + "op": 129, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571032, + "gas_refund_counter": 0, + "gas_used": 149728, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 710, + "op": 95, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571029, + "gas_refund_counter": 0, + "gas_used": 149731, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 711, + "op": 135, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571027, + "gas_refund_counter": 0, + "gas_used": 149733, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 712, + "op": 128, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571024, + "gas_refund_counter": 0, + "gas_used": 149736, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 713, + "op": 59, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073571021, + "gas_refund_counter": 0, + "gas_used": 149739, + "gas_cost": 100, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 714, + "op": 21, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073570921, + "gas_refund_counter": 0, + "gas_used": 149839, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 715, + "op": 128, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073570918, + "gas_refund_counter": 0, + "gas_used": 149842, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 716, + "op": 21, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073570915, + "gas_refund_counter": 0, + "gas_used": 149845, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 717, + "op": 97, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073570912, + "gas_refund_counter": 0, + "gas_used": 149848, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 720, + "op": 87, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073570909, + "gas_refund_counter": 0, + "gas_used": 149851, + "gas_cost": 10, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 724, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073570899, + "gas_refund_counter": 0, + "gas_used": 149861, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 725, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073570898, + "gas_refund_counter": 0, + "gas_used": 149862, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 726, + "op": 90, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073570896, + "gas_refund_counter": 0, + "gas_used": 149864, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 727, + "op": 241, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073570894, + "gas_refund_counter": 0, + "gas_used": 149866, + "gas_cost": 1056796351, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 728, + "op": 21, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073527076, + "gas_refund_counter": 0, + "gas_used": 193684, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 729, + "op": 128, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073527073, + "gas_refund_counter": 0, + "gas_used": 193687, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 730, + "op": 21, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073527070, + "gas_refund_counter": 0, + "gas_used": 193690, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 731, + "op": 97, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073527067, + "gas_refund_counter": 0, + "gas_used": 193693, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 734, + "op": 87, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073527064, + "gas_refund_counter": 0, + "gas_used": 193696, + "gas_cost": 10, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 742, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073527054, + "gas_refund_counter": 0, + "gas_used": 193706, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 743, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073527053, + "gas_refund_counter": 0, + "gas_used": 193707, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 744, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073527051, + "gas_refund_counter": 0, + "gas_used": 193709, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 745, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073527049, + "gas_refund_counter": 0, + "gas_used": 193711, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 746, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073527047, + "gas_refund_counter": 0, + "gas_used": 193713, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 747, + "op": 97, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073527045, + "gas_refund_counter": 0, + "gas_used": 193715, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 750, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073527042, + "gas_refund_counter": 0, + "gas_used": 193718, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 752, + "op": 97, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073527039, + "gas_refund_counter": 0, + "gas_used": 193721, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 755, + "op": 86, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073527036, + "gas_refund_counter": 0, + "gas_used": 193724, + "gas_cost": 8, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 814, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073527028, + "gas_refund_counter": 0, + "gas_used": 193732, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 815, + "op": 97, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073527027, + "gas_refund_counter": 0, + "gas_used": 193733, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 818, + "op": 129, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073527024, + "gas_refund_counter": 0, + "gas_used": 193736, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 819, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073527021, + "gas_refund_counter": 0, + "gas_used": 193739, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 821, + "op": 81, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073527018, + "gas_refund_counter": 0, + "gas_used": 193742, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 822, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073527015, + "gas_refund_counter": 0, + "gas_used": 193745, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 824, + "op": 1, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073527012, + "gas_refund_counter": 0, + "gas_used": 193748, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 825, + "op": 97, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073527009, + "gas_refund_counter": 0, + "gas_used": 193751, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 828, + "op": 145, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073527006, + "gas_refund_counter": 0, + "gas_used": 193754, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 829, + "op": 144, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073527003, + "gas_refund_counter": 0, + "gas_used": 193757, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 830, + "op": 97, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073527000, + "gas_refund_counter": 0, + "gas_used": 193760, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 833, + "op": 86, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526997, + "gas_refund_counter": 0, + "gas_used": 193763, + "gas_cost": 8, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1657, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526989, + "gas_refund_counter": 0, + "gas_used": 193771, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1658, + "op": 95, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526988, + "gas_refund_counter": 0, + "gas_used": 193772, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1659, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526986, + "gas_refund_counter": 0, + "gas_used": 193774, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1661, + "op": 130, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526983, + "gas_refund_counter": 0, + "gas_used": 193777, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1662, + "op": 1, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526980, + "gas_refund_counter": 0, + "gas_used": 193780, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1663, + "op": 144, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526977, + "gas_refund_counter": 0, + "gas_used": 193783, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1664, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526974, + "gas_refund_counter": 0, + "gas_used": 193786, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1665, + "op": 97, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526972, + "gas_refund_counter": 0, + "gas_used": 193788, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1668, + "op": 95, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526969, + "gas_refund_counter": 0, + "gas_used": 193791, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1669, + "op": 131, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526967, + "gas_refund_counter": 0, + "gas_used": 193793, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1670, + "op": 1, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526964, + "gas_refund_counter": 0, + "gas_used": 193796, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1671, + "op": 132, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526961, + "gas_refund_counter": 0, + "gas_used": 193799, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1672, + "op": 97, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526958, + "gas_refund_counter": 0, + "gas_used": 193802, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1675, + "op": 86, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526955, + "gas_refund_counter": 0, + "gas_used": 193805, + "gas_cost": 8, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1642, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526947, + "gas_refund_counter": 0, + "gas_used": 193813, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1643, + "op": 97, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526946, + "gas_refund_counter": 0, + "gas_used": 193814, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1646, + "op": 129, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526943, + "gas_refund_counter": 0, + "gas_used": 193817, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1647, + "op": 97, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526940, + "gas_refund_counter": 0, + "gas_used": 193820, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1650, + "op": 86, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526937, + "gas_refund_counter": 0, + "gas_used": 193823, + "gas_cost": 8, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1551, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526929, + "gas_refund_counter": 0, + "gas_used": 193831, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1552, + "op": 95, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526928, + "gas_refund_counter": 0, + "gas_used": 193832, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1553, + "op": 129, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526926, + "gas_refund_counter": 0, + "gas_used": 193834, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1554, + "op": 144, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526923, + "gas_refund_counter": 0, + "gas_used": 193837, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1555, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526920, + "gas_refund_counter": 0, + "gas_used": 193840, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1556, + "op": 145, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526918, + "gas_refund_counter": 0, + "gas_used": 193842, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1557, + "op": 144, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526915, + "gas_refund_counter": 0, + "gas_used": 193845, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1558, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526912, + "gas_refund_counter": 0, + "gas_used": 193848, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1559, + "op": 86, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526910, + "gas_refund_counter": 0, + "gas_used": 193850, + "gas_cost": 8, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1651, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526902, + "gas_refund_counter": 0, + "gas_used": 193858, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1652, + "op": 130, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526901, + "gas_refund_counter": 0, + "gas_used": 193859, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1653, + "op": 82, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526898, + "gas_refund_counter": 0, + "gas_used": 193862, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1654, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526895, + "gas_refund_counter": 0, + "gas_used": 193865, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1655, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526893, + "gas_refund_counter": 0, + "gas_used": 193867, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1656, + "op": 86, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526891, + "gas_refund_counter": 0, + "gas_used": 193869, + "gas_cost": 8, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1676, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526883, + "gas_refund_counter": 0, + "gas_used": 193877, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1677, + "op": 146, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526882, + "gas_refund_counter": 0, + "gas_used": 193878, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1678, + "op": 145, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526879, + "gas_refund_counter": 0, + "gas_used": 193881, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1679, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526876, + "gas_refund_counter": 0, + "gas_used": 193884, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1680, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526874, + "gas_refund_counter": 0, + "gas_used": 193886, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1681, + "op": 86, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526872, + "gas_refund_counter": 0, + "gas_used": 193888, + "gas_cost": 8, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 834, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526864, + "gas_refund_counter": 0, + "gas_used": 193896, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 835, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526863, + "gas_refund_counter": 0, + "gas_used": 193897, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 837, + "op": 81, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526860, + "gas_refund_counter": 0, + "gas_used": 193900, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 838, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526857, + "gas_refund_counter": 0, + "gas_used": 193903, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 840, + "op": 129, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526854, + "gas_refund_counter": 0, + "gas_used": 193906, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 841, + "op": 131, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526851, + "gas_refund_counter": 0, + "gas_used": 193909, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 842, + "op": 3, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526848, + "gas_refund_counter": 0, + "gas_used": 193912, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 843, + "op": 3, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526845, + "gas_refund_counter": 0, + "gas_used": 193915, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 844, + "op": 129, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526842, + "gas_refund_counter": 0, + "gas_used": 193918, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 845, + "op": 82, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526839, + "gas_refund_counter": 0, + "gas_used": 193921, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 846, + "op": 144, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526836, + "gas_refund_counter": 0, + "gas_used": 193924, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 847, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526833, + "gas_refund_counter": 0, + "gas_used": 193927, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 849, + "op": 82, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526830, + "gas_refund_counter": 0, + "gas_used": 193930, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 850, + "op": 127, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526827, + "gas_refund_counter": 0, + "gas_used": 193933, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 883, + "op": 123, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526824, + "gas_refund_counter": 0, + "gas_used": 193936, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 912, + "op": 25, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526821, + "gas_refund_counter": 0, + "gas_used": 193939, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 913, + "op": 22, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526818, + "gas_refund_counter": 0, + "gas_used": 193942, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 914, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526815, + "gas_refund_counter": 0, + "gas_used": 193945, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 916, + "op": 130, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526812, + "gas_refund_counter": 0, + "gas_used": 193948, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 917, + "op": 1, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526809, + "gas_refund_counter": 0, + "gas_used": 193951, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 918, + "op": 128, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526806, + "gas_refund_counter": 0, + "gas_used": 193954, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 919, + "op": 81, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526803, + "gas_refund_counter": 0, + "gas_used": 193957, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 920, + "op": 123, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526800, + "gas_refund_counter": 0, + "gas_used": 193960, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 949, + "op": 131, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526797, + "gas_refund_counter": 0, + "gas_used": 193963, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 950, + "op": 129, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526794, + "gas_refund_counter": 0, + "gas_used": 193966, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 951, + "op": 131, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526791, + "gas_refund_counter": 0, + "gas_used": 193969, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 952, + "op": 22, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526788, + "gas_refund_counter": 0, + "gas_used": 193972, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 953, + "op": 23, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526785, + "gas_refund_counter": 0, + "gas_used": 193975, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 954, + "op": 131, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526782, + "gas_refund_counter": 0, + "gas_used": 193978, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 955, + "op": 82, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526779, + "gas_refund_counter": 0, + "gas_used": 193981, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 956, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526776, + "gas_refund_counter": 0, + "gas_used": 193984, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 957, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526774, + "gas_refund_counter": 0, + "gas_used": 193986, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 958, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526772, + "gas_refund_counter": 0, + "gas_used": 193988, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 959, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526770, + "gas_refund_counter": 0, + "gas_used": 193990, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 960, + "op": 97, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526768, + "gas_refund_counter": 0, + "gas_used": 193992, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 963, + "op": 86, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526765, + "gas_refund_counter": 0, + "gas_used": 193995, + "gas_cost": 8, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 967, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526757, + "gas_refund_counter": 0, + "gas_used": 194003, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 968, + "op": 97, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526756, + "gas_refund_counter": 0, + "gas_used": 194004, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 971, + "op": 129, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526753, + "gas_refund_counter": 0, + "gas_used": 194007, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 972, + "op": 97, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526750, + "gas_refund_counter": 0, + "gas_used": 194010, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 975, + "op": 97, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526747, + "gas_refund_counter": 0, + "gas_used": 194013, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 978, + "op": 97, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526744, + "gas_refund_counter": 0, + "gas_used": 194016, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 981, + "op": 86, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526741, + "gas_refund_counter": 0, + "gas_used": 194019, + "gas_cost": 8, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1024, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526733, + "gas_refund_counter": 0, + "gas_used": 194027, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1025, + "op": 97, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526732, + "gas_refund_counter": 0, + "gas_used": 194028, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1028, + "op": 129, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526729, + "gas_refund_counter": 0, + "gas_used": 194031, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1029, + "op": 144, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526726, + "gas_refund_counter": 0, + "gas_used": 194034, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1030, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526723, + "gas_refund_counter": 0, + "gas_used": 194037, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1031, + "op": 145, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526721, + "gas_refund_counter": 0, + "gas_used": 194039, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1032, + "op": 144, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526718, + "gas_refund_counter": 0, + "gas_used": 194042, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1033, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526715, + "gas_refund_counter": 0, + "gas_used": 194045, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1034, + "op": 86, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526713, + "gas_refund_counter": 0, + "gas_used": 194047, + "gas_cost": 8, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 982, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526705, + "gas_refund_counter": 0, + "gas_used": 194055, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 983, + "op": 99, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526704, + "gas_refund_counter": 0, + "gas_used": 194056, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 988, + "op": 22, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526701, + "gas_refund_counter": 0, + "gas_used": 194059, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 989, + "op": 86, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526698, + "gas_refund_counter": 0, + "gas_used": 194062, + "gas_cost": 8, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 993, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526690, + "gas_refund_counter": 0, + "gas_used": 194070, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 994, + "op": 95, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526689, + "gas_refund_counter": 0, + "gas_used": 194071, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 995, + "op": 106, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526687, + "gas_refund_counter": 0, + "gas_used": 194073, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1007, + "op": 144, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526684, + "gas_refund_counter": 0, + "gas_used": 194076, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1008, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526681, + "gas_refund_counter": 0, + "gas_used": 194079, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1009, + "op": 95, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526679, + "gas_refund_counter": 0, + "gas_used": 194081, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1010, + "op": 95, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526677, + "gas_refund_counter": 0, + "gas_used": 194083, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1011, + "op": 131, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526675, + "gas_refund_counter": 0, + "gas_used": 194085, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1012, + "op": 81, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526672, + "gas_refund_counter": 0, + "gas_used": 194088, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1013, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526669, + "gas_refund_counter": 0, + "gas_used": 194091, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1015, + "op": 133, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526666, + "gas_refund_counter": 0, + "gas_used": 194094, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1016, + "op": 1, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526663, + "gas_refund_counter": 0, + "gas_used": 194097, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1017, + "op": 132, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526660, + "gas_refund_counter": 0, + "gas_used": 194100, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1018, + "op": 90, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526657, + "gas_refund_counter": 0, + "gas_used": 194103, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1019, + "op": 250, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073526655, + "gas_refund_counter": 0, + "gas_used": 194105, + "gas_cost": 1056752842, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1020, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073524055, + "gas_refund_counter": 0, + "gas_used": 196705, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1021, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073524053, + "gas_refund_counter": 0, + "gas_used": 196707, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1022, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073524051, + "gas_refund_counter": 0, + "gas_used": 196709, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 1023, + "op": 86, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073524049, + "gas_refund_counter": 0, + "gas_used": 196711, + "gas_cost": 8, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 990, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073524041, + "gas_refund_counter": 0, + "gas_used": 196719, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 991, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073524040, + "gas_refund_counter": 0, + "gas_used": 196720, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 992, + "op": 86, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073524038, + "gas_refund_counter": 0, + "gas_used": 196722, + "gas_cost": 8, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 964, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073524030, + "gas_refund_counter": 0, + "gas_used": 196730, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 965, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073524029, + "gas_refund_counter": 0, + "gas_used": 196731, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 966, + "op": 86, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073524027, + "gas_refund_counter": 0, + "gas_used": 196733, + "gas_cost": 8, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 756, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073524019, + "gas_refund_counter": 0, + "gas_used": 196741, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 757, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073524018, + "gas_refund_counter": 0, + "gas_used": 196742, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 758, + "op": 86, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073524016, + "gas_refund_counter": 0, + "gas_used": 196744, + "gas_cost": 8, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 146, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073524008, + "gas_refund_counter": 0, + "gas_used": 196752, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 147, + "op": 0, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1073524007, + "gas_refund_counter": 0, + "gas_used": 196753, + "gas_cost": 0, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": "Stop", + "immediate_bytes": null, + "decoded": null + } + ], + "step_deltas": [], + "decoded": null }, "logs": [], "ordering": [ + { + "Step": 0 + }, + { + "Step": 1 + }, + { + "Step": 2 + }, + { + "Step": 3 + }, + { + "Step": 4 + }, + { + "Step": 5 + }, + { + "Step": 6 + }, + { + "Step": 7 + }, + { + "Step": 8 + }, + { + "Step": 9 + }, + { + "Step": 10 + }, + { + "Step": 11 + }, + { + "Step": 12 + }, + { + "Step": 13 + }, + { + "Step": 14 + }, + { + "Step": 15 + }, + { + "Step": 16 + }, + { + "Step": 17 + }, + { + "Step": 18 + }, + { + "Step": 19 + }, + { + "Step": 20 + }, + { + "Step": 21 + }, + { + "Step": 22 + }, + { + "Step": 23 + }, + { + "Step": 24 + }, + { + "Step": 25 + }, + { + "Step": 26 + }, + { + "Step": 27 + }, + { + "Step": 28 + }, + { + "Step": 29 + }, + { + "Step": 30 + }, + { + "Step": 31 + }, + { + "Step": 32 + }, + { + "Step": 33 + }, + { + "Step": 34 + }, + { + "Step": 35 + }, + { + "Step": 36 + }, + { + "Step": 37 + }, + { + "Step": 38 + }, + { + "Step": 39 + }, + { + "Step": 40 + }, + { + "Step": 41 + }, + { + "Step": 42 + }, + { + "Step": 43 + }, + { + "Step": 44 + }, + { + "Step": 45 + }, + { + "Step": 46 + }, + { + "Step": 47 + }, + { + "Step": 48 + }, + { + "Step": 49 + }, + { + "Step": 50 + }, + { + "Step": 51 + }, + { + "Step": 52 + }, + { + "Step": 53 + }, + { + "Step": 54 + }, + { + "Step": 55 + }, + { + "Step": 56 + }, + { + "Step": 57 + }, + { + "Step": 58 + }, + { + "Step": 59 + }, + { + "Step": 60 + }, + { + "Step": 61 + }, + { + "Step": 62 + }, + { + "Step": 63 + }, { "Call": 0 }, { - "Call": 1 + "Step": 64 }, { - "Call": 2 - } - ] - }, - { - "parent": 0, - "children": [], - "idx": 1, - "trace": { - "depth": 1, - "success": true, - "caller": "0x7fa9385be102ac3eac297483dd6233d62b3e1496", - "address": "0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f", - "maybe_precompile": false, - "selfdestruct_address": null, - "selfdestruct_refund_target": null, - "selfdestruct_transferred_value": null, - "kind": "CREATE", - "value": "0x0", - "data": "{...}", - "output": "{...}", - "gas_used": "{...}", - "gas_limit": "{...}", - "status": "Return", - "steps": [], - "decoded": { - "label": null, - "return_data": null, - "call_data": null + "Step": 65 + }, + { + "Step": 66 + }, + { + "Step": 67 + }, + { + "Step": 68 + }, + { + "Step": 69 + }, + { + "Step": 70 + }, + { + "Step": 71 + }, + { + "Step": 72 + }, + { + "Step": 73 + }, + { + "Step": 74 + }, + { + "Step": 75 + }, + { + "Step": 76 + }, + { + "Step": 77 + }, + { + "Step": 78 + }, + { + "Step": 79 + }, + { + "Step": 80 + }, + { + "Step": 81 + }, + { + "Step": 82 + }, + { + "Step": 83 + }, + { + "Step": 84 + }, + { + "Step": 85 + }, + { + "Step": 86 + }, + { + "Step": 87 + }, + { + "Step": 88 + }, + { + "Step": 89 + }, + { + "Step": 90 + }, + { + "Step": 91 + }, + { + "Step": 92 + }, + { + "Step": 93 + }, + { + "Step": 94 + }, + { + "Step": 95 + }, + { + "Step": 96 + }, + { + "Step": 97 + }, + { + "Step": 98 + }, + { + "Step": 99 + }, + { + "Step": 100 + }, + { + "Step": 101 + }, + { + "Step": 102 + }, + { + "Step": 103 + }, + { + "Step": 104 + }, + { + "Step": 105 + }, + { + "Step": 106 + }, + { + "Step": 107 + }, + { + "Step": 108 + }, + { + "Step": 109 + }, + { + "Step": 110 + }, + { + "Step": 111 + }, + { + "Step": 112 + }, + { + "Step": 113 + }, + { + "Step": 114 + }, + { + "Step": 115 + }, + { + "Step": 116 + }, + { + "Step": 117 + }, + { + "Step": 118 + }, + { + "Step": 119 + }, + { + "Step": 120 + }, + { + "Step": 121 + }, + { + "Step": 122 + }, + { + "Step": 123 + }, + { + "Step": 124 + }, + { + "Step": 125 + }, + { + "Step": 126 + }, + { + "Step": 127 + }, + { + "Step": 128 + }, + { + "Step": 129 + }, + { + "Step": 130 + }, + { + "Step": 131 + }, + { + "Step": 132 + }, + { + "Step": 133 + }, + { + "Step": 134 + }, + { + "Step": 135 + }, + { + "Step": 136 + }, + { + "Step": 137 + }, + { + "Step": 138 + }, + { + "Step": 139 + }, + { + "Step": 140 + }, + { + "Step": 141 + }, + { + "Step": 142 + }, + { + "Step": 143 + }, + { + "Step": 144 + }, + { + "Step": 145 + }, + { + "Step": 146 + }, + { + "Step": 147 + }, + { + "Step": 148 + }, + { + "Step": 149 + }, + { + "Step": 150 + }, + { + "Step": 151 + }, + { + "Step": 152 + }, + { + "Step": 153 + }, + { + "Step": 154 + }, + { + "Step": 155 + }, + { + "Step": 156 + }, + { + "Step": 157 + }, + { + "Step": 158 + }, + { + "Step": 159 + }, + { + "Step": 160 + }, + { + "Step": 161 + }, + { + "Step": 162 + }, + { + "Step": 163 + }, + { + "Step": 164 + }, + { + "Step": 165 + }, + { + "Step": 166 + }, + { + "Step": 167 + }, + { + "Step": 168 + }, + { + "Step": 169 + }, + { + "Step": 170 + }, + { + "Step": 171 + }, + { + "Step": 172 + }, + { + "Step": 173 + }, + { + "Step": 174 + }, + { + "Step": 175 + }, + { + "Step": 176 + }, + { + "Step": 177 + }, + { + "Step": 178 + }, + { + "Step": 179 + }, + { + "Step": 180 + }, + { + "Step": 181 + }, + { + "Step": 182 + }, + { + "Step": 183 + }, + { + "Step": 184 + }, + { + "Step": 185 + }, + { + "Step": 186 + }, + { + "Step": 187 + }, + { + "Step": 188 + }, + { + "Step": 189 + }, + { + "Step": 190 + }, + { + "Step": 191 + }, + { + "Step": 192 + }, + { + "Step": 193 + }, + { + "Step": 194 + }, + { + "Call": 1 + }, + { + "Step": 195 + }, + { + "Step": 196 + }, + { + "Step": 197 + }, + { + "Step": 198 + }, + { + "Step": 199 + }, + { + "Step": 200 + }, + { + "Step": 201 + }, + { + "Step": 202 + }, + { + "Step": 203 + }, + { + "Step": 204 + }, + { + "Step": 205 + }, + { + "Step": 206 + }, + { + "Step": 207 + }, + { + "Step": 208 + }, + { + "Step": 209 + }, + { + "Step": 210 + }, + { + "Step": 211 + }, + { + "Step": 212 + }, + { + "Step": 213 + }, + { + "Step": 214 + }, + { + "Step": 215 + }, + { + "Step": 216 + }, + { + "Step": 217 + }, + { + "Step": 218 + }, + { + "Step": 219 + }, + { + "Step": 220 + }, + { + "Step": 221 + }, + { + "Step": 222 + }, + { + "Step": 223 + }, + { + "Step": 224 + }, + { + "Step": 225 + }, + { + "Step": 226 + }, + { + "Step": 227 + }, + { + "Step": 228 + }, + { + "Step": 229 + }, + { + "Step": 230 + }, + { + "Step": 231 + }, + { + "Step": 232 + }, + { + "Step": 233 + }, + { + "Step": 234 + }, + { + "Step": 235 + }, + { + "Step": 236 + }, + { + "Step": 237 + }, + { + "Step": 238 + }, + { + "Step": 239 + }, + { + "Step": 240 + }, + { + "Step": 241 + }, + { + "Step": 242 + }, + { + "Step": 243 + }, + { + "Step": 244 + }, + { + "Step": 245 + }, + { + "Step": 246 + }, + { + "Step": 247 + }, + { + "Step": 248 + }, + { + "Step": 249 + }, + { + "Step": 250 + }, + { + "Step": 251 + }, + { + "Step": 252 + }, + { + "Step": 253 + }, + { + "Step": 254 + }, + { + "Step": 255 + }, + { + "Step": 256 + }, + { + "Step": 257 + }, + { + "Step": 258 + }, + { + "Step": 259 + }, + { + "Step": 260 + }, + { + "Step": 261 + }, + { + "Step": 262 + }, + { + "Step": 263 + }, + { + "Step": 264 + }, + { + "Step": 265 + }, + { + "Step": 266 + }, + { + "Step": 267 + }, + { + "Step": 268 + }, + { + "Step": 269 + }, + { + "Step": 270 + }, + { + "Step": 271 + }, + { + "Step": 272 + }, + { + "Step": 273 + }, + { + "Step": 274 + }, + { + "Step": 275 + }, + { + "Step": 276 + }, + { + "Step": 277 + }, + { + "Step": 278 + }, + { + "Step": 279 + }, + { + "Step": 280 + }, + { + "Step": 281 + }, + { + "Step": 282 + }, + { + "Step": 283 + }, + { + "Step": 284 + }, + { + "Step": 285 + }, + { + "Step": 286 + }, + { + "Step": 287 + }, + { + "Step": 288 + }, + { + "Step": 289 + }, + { + "Step": 290 + }, + { + "Step": 291 + }, + { + "Step": 292 + }, + { + "Step": 293 + }, + { + "Step": 294 + }, + { + "Step": 295 + }, + { + "Step": 296 + }, + { + "Step": 297 + }, + { + "Step": 298 + }, + { + "Step": 299 + }, + { + "Step": 300 + }, + { + "Step": 301 + }, + { + "Step": 302 + }, + { + "Step": 303 + }, + { + "Step": 304 + }, + { + "Step": 305 + }, + { + "Step": 306 + }, + { + "Step": 307 + }, + { + "Step": 308 + }, + { + "Step": 309 + }, + { + "Step": 310 + }, + { + "Step": 311 + }, + { + "Step": 312 + }, + { + "Step": 313 + }, + { + "Step": 314 + }, + { + "Step": 315 + }, + { + "Step": 316 + }, + { + "Step": 317 + }, + { + "Step": 318 + }, + { + "Step": 319 + }, + { + "Step": 320 + }, + { + "Step": 321 + }, + { + "Step": 322 + }, + { + "Step": 323 + }, + { + "Step": 324 + }, + { + "Step": 325 + }, + { + "Step": 326 + }, + { + "Step": 327 + }, + { + "Step": 328 + }, + { + "Step": 329 + }, + { + "Step": 330 + }, + { + "Step": 331 + }, + { + "Call": 2 + }, + { + "Step": 332 + }, + { + "Step": 333 + }, + { + "Step": 334 + }, + { + "Step": 335 + }, + { + "Step": 336 + }, + { + "Step": 337 + }, + { + "Step": 338 + }, + { + "Step": 339 + }, + { + "Step": 340 + }, + { + "Step": 341 + }, + { + "Step": 342 + }, + { + "Step": 343 + }, + { + "Step": 344 + }, + { + "Step": 345 + }, + { + "Step": 346 + } + ] + }, + { + "parent": 0, + "children": [], + "idx": 1, + "trace": { + "depth": 1, + "success": true, + "caller": "0x7fa9385be102ac3eac297483dd6233d62b3e1496", + "address": "0x5615deb798bb3e4dfa0139dfa1b3d433cc23b72f", + "maybe_precompile": false, + "selfdestruct_address": null, + "selfdestruct_refund_target": null, + "selfdestruct_transferred_value": null, + "kind": "CREATE", + "value": "0x0", + "data": "{...}", + "output": "{...}", + "bytecode": null, + "gas_used": "{...}", + "gas_limit": "{...}", + "gas_refund_counter": 0, + "status": "Return", + "steps": [ + { + "pc": 0, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 2, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056912053, + "gas_refund_counter": 0, + "gas_used": 3, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 4, + "op": 82, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056912050, + "gas_refund_counter": 0, + "gas_used": 6, + "gas_cost": 12, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 5, + "op": 52, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056912038, + "gas_refund_counter": 0, + "gas_used": 18, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 6, + "op": 128, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056912036, + "gas_refund_counter": 0, + "gas_used": 20, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 7, + "op": 21, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056912033, + "gas_refund_counter": 0, + "gas_used": 23, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 8, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056912030, + "gas_refund_counter": 0, + "gas_used": 26, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 10, + "op": 87, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056912027, + "gas_refund_counter": 0, + "gas_used": 29, + "gas_cost": 10, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 14, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056912017, + "gas_refund_counter": 0, + "gas_used": 39, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 15, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056912016, + "gas_refund_counter": 0, + "gas_used": 40, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 16, + "op": 97, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056912014, + "gas_refund_counter": 0, + "gas_used": 42, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 19, + "op": 128, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056912011, + "gas_refund_counter": 0, + "gas_used": 45, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 20, + "op": 97, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056912008, + "gas_refund_counter": 0, + "gas_used": 48, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 23, + "op": 95, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056912005, + "gas_refund_counter": 0, + "gas_used": 51, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 24, + "op": 57, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056912003, + "gas_refund_counter": 0, + "gas_used": 53, + "gas_cost": 54, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 25, + "op": 95, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056911949, + "gas_refund_counter": 0, + "gas_used": 107, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 26, + "op": 243, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056911947, + "gas_refund_counter": 0, + "gas_used": 109, + "gas_cost": 0, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": "Return", + "immediate_bytes": null, + "decoded": null + } + ], + "step_deltas": [], + "decoded": null + }, + "logs": [], + "ordering": [ + { + "Step": 0 + }, + { + "Step": 1 + }, + { + "Step": 2 + }, + { + "Step": 3 + }, + { + "Step": 4 + }, + { + "Step": 5 + }, + { + "Step": 6 + }, + { + "Step": 7 + }, + { + "Step": 8 + }, + { + "Step": 9 + }, + { + "Step": 10 + }, + { + "Step": 11 + }, + { + "Step": 12 + }, + { + "Step": 13 + }, + { + "Step": 14 + }, + { + "Step": 15 + }, + { + "Step": 16 } - }, - "logs": [], - "ordering": [] + ] }, { "parent": 0, @@ -156,18 +7800,2607 @@ "value": "0x0", "data": "0xe26d14740000000000000000000000000000000000000000000000000000000000000064", "output": "0x", + "bytecode": null, "gas_used": "{...}", "gas_limit": "{...}", + "gas_refund_counter": 0, "status": "Stop", - "steps": [], - "decoded": { - "label": null, - "return_data": null, - "call_data": null - } + "steps": [ + { + "pc": 0, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 2, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 4, + "op": 82, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 12, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 5, + "op": 52, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 6, + "op": 128, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 7, + "op": 21, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 8, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 10, + "op": 87, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 10, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 14, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 15, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 16, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 18, + "op": 54, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 19, + "op": 16, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 20, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 22, + "op": 87, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 10, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 23, + "op": 95, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 24, + "op": 53, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 25, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 27, + "op": 28, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 28, + "op": 128, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 29, + "op": 99, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 34, + "op": 20, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 35, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 37, + "op": 87, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": "{...}", + "gas_refund_counter": 0, + "gas_used": "{...}", + "gas_cost": 10, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 38, + "op": 128, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796155, + "gas_refund_counter": 0, + "gas_used": 96, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 39, + "op": 99, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796152, + "gas_refund_counter": 0, + "gas_used": 99, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 44, + "op": 20, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796149, + "gas_refund_counter": 0, + "gas_used": 102, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 45, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796146, + "gas_refund_counter": 0, + "gas_used": 105, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 47, + "op": 87, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796143, + "gas_refund_counter": 0, + "gas_used": 108, + "gas_cost": 10, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 78, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796133, + "gas_refund_counter": 0, + "gas_used": 118, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 79, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796132, + "gas_refund_counter": 0, + "gas_used": 119, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 81, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796129, + "gas_refund_counter": 0, + "gas_used": 122, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 83, + "op": 128, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796126, + "gas_refund_counter": 0, + "gas_used": 125, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 84, + "op": 54, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796123, + "gas_refund_counter": 0, + "gas_used": 128, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 85, + "op": 3, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796121, + "gas_refund_counter": 0, + "gas_used": 130, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 86, + "op": 129, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796118, + "gas_refund_counter": 0, + "gas_used": 133, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 87, + "op": 1, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796115, + "gas_refund_counter": 0, + "gas_used": 136, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 88, + "op": 144, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796112, + "gas_refund_counter": 0, + "gas_used": 139, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 89, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796109, + "gas_refund_counter": 0, + "gas_used": 142, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 91, + "op": 145, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796106, + "gas_refund_counter": 0, + "gas_used": 145, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 92, + "op": 144, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796103, + "gas_refund_counter": 0, + "gas_used": 148, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 93, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796100, + "gas_refund_counter": 0, + "gas_used": 151, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 95, + "op": 86, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796097, + "gas_refund_counter": 0, + "gas_used": 154, + "gas_cost": 8, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 202, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796089, + "gas_refund_counter": 0, + "gas_used": 162, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 203, + "op": 95, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796088, + "gas_refund_counter": 0, + "gas_used": 163, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 204, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796086, + "gas_refund_counter": 0, + "gas_used": 165, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 206, + "op": 130, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796083, + "gas_refund_counter": 0, + "gas_used": 168, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 207, + "op": 132, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796080, + "gas_refund_counter": 0, + "gas_used": 171, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 208, + "op": 3, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796077, + "gas_refund_counter": 0, + "gas_used": 174, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 209, + "op": 18, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796074, + "gas_refund_counter": 0, + "gas_used": 177, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 210, + "op": 21, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796071, + "gas_refund_counter": 0, + "gas_used": 180, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 211, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796068, + "gas_refund_counter": 0, + "gas_used": 183, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 213, + "op": 87, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796065, + "gas_refund_counter": 0, + "gas_used": 186, + "gas_cost": 10, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 220, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796055, + "gas_refund_counter": 0, + "gas_used": 196, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 221, + "op": 95, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796054, + "gas_refund_counter": 0, + "gas_used": 197, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 222, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796052, + "gas_refund_counter": 0, + "gas_used": 199, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 224, + "op": 132, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796049, + "gas_refund_counter": 0, + "gas_used": 202, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 225, + "op": 130, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796046, + "gas_refund_counter": 0, + "gas_used": 205, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 226, + "op": 133, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796043, + "gas_refund_counter": 0, + "gas_used": 208, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 227, + "op": 1, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796040, + "gas_refund_counter": 0, + "gas_used": 211, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 228, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796037, + "gas_refund_counter": 0, + "gas_used": 214, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 230, + "op": 86, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796034, + "gas_refund_counter": 0, + "gas_used": 217, + "gas_cost": 8, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 184, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796026, + "gas_refund_counter": 0, + "gas_used": 225, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 185, + "op": 95, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796025, + "gas_refund_counter": 0, + "gas_used": 226, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 186, + "op": 129, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796023, + "gas_refund_counter": 0, + "gas_used": 228, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 187, + "op": 53, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796020, + "gas_refund_counter": 0, + "gas_used": 231, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 188, + "op": 144, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796017, + "gas_refund_counter": 0, + "gas_used": 234, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 189, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796014, + "gas_refund_counter": 0, + "gas_used": 237, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 190, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796012, + "gas_refund_counter": 0, + "gas_used": 239, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 192, + "op": 129, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796009, + "gas_refund_counter": 0, + "gas_used": 242, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 193, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796006, + "gas_refund_counter": 0, + "gas_used": 245, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 195, + "op": 86, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056796003, + "gas_refund_counter": 0, + "gas_used": 248, + "gas_cost": 8, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 165, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795995, + "gas_refund_counter": 0, + "gas_used": 256, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 166, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795994, + "gas_refund_counter": 0, + "gas_used": 257, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 168, + "op": 129, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795991, + "gas_refund_counter": 0, + "gas_used": 260, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 169, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795988, + "gas_refund_counter": 0, + "gas_used": 263, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 171, + "op": 86, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795985, + "gas_refund_counter": 0, + "gas_used": 266, + "gas_cost": 8, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 116, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795977, + "gas_refund_counter": 0, + "gas_used": 274, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 117, + "op": 95, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795976, + "gas_refund_counter": 0, + "gas_used": 275, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 118, + "op": 129, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795974, + "gas_refund_counter": 0, + "gas_used": 277, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 119, + "op": 144, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795971, + "gas_refund_counter": 0, + "gas_used": 280, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 120, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795968, + "gas_refund_counter": 0, + "gas_used": 283, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 121, + "op": 145, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795966, + "gas_refund_counter": 0, + "gas_used": 285, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 122, + "op": 144, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795963, + "gas_refund_counter": 0, + "gas_used": 288, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 123, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795960, + "gas_refund_counter": 0, + "gas_used": 291, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 124, + "op": 86, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795958, + "gas_refund_counter": 0, + "gas_used": 293, + "gas_cost": 8, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 172, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795950, + "gas_refund_counter": 0, + "gas_used": 301, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 173, + "op": 129, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795949, + "gas_refund_counter": 0, + "gas_used": 302, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 174, + "op": 20, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795946, + "gas_refund_counter": 0, + "gas_used": 305, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 175, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795943, + "gas_refund_counter": 0, + "gas_used": 308, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 177, + "op": 87, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795940, + "gas_refund_counter": 0, + "gas_used": 311, + "gas_cost": 10, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 181, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795930, + "gas_refund_counter": 0, + "gas_used": 321, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 182, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795929, + "gas_refund_counter": 0, + "gas_used": 322, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 183, + "op": 86, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795927, + "gas_refund_counter": 0, + "gas_used": 324, + "gas_cost": 8, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 196, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795919, + "gas_refund_counter": 0, + "gas_used": 332, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 197, + "op": 146, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795918, + "gas_refund_counter": 0, + "gas_used": 333, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 198, + "op": 145, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795915, + "gas_refund_counter": 0, + "gas_used": 336, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 199, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795912, + "gas_refund_counter": 0, + "gas_used": 339, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 200, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795910, + "gas_refund_counter": 0, + "gas_used": 341, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 201, + "op": 86, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795908, + "gas_refund_counter": 0, + "gas_used": 343, + "gas_cost": 8, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 231, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795900, + "gas_refund_counter": 0, + "gas_used": 351, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 232, + "op": 145, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795899, + "gas_refund_counter": 0, + "gas_used": 352, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 233, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795896, + "gas_refund_counter": 0, + "gas_used": 355, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 234, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795894, + "gas_refund_counter": 0, + "gas_used": 357, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 235, + "op": 146, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795892, + "gas_refund_counter": 0, + "gas_used": 359, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 236, + "op": 145, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795889, + "gas_refund_counter": 0, + "gas_used": 362, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 237, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795886, + "gas_refund_counter": 0, + "gas_used": 365, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 238, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795884, + "gas_refund_counter": 0, + "gas_used": 367, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 239, + "op": 86, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795882, + "gas_refund_counter": 0, + "gas_used": 369, + "gas_cost": 8, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 96, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795874, + "gas_refund_counter": 0, + "gas_used": 377, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 97, + "op": 96, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795873, + "gas_refund_counter": 0, + "gas_used": 378, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 99, + "op": 86, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795870, + "gas_refund_counter": 0, + "gas_used": 381, + "gas_cost": 8, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 107, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795862, + "gas_refund_counter": 0, + "gas_used": 389, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 108, + "op": 128, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795861, + "gas_refund_counter": 0, + "gas_used": 390, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 109, + "op": 95, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795858, + "gas_refund_counter": 0, + "gas_used": 393, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 110, + "op": 129, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795856, + "gas_refund_counter": 0, + "gas_used": 395, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 111, + "op": 144, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795853, + "gas_refund_counter": 0, + "gas_used": 398, + "gas_cost": 3, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 112, + "op": 85, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056795850, + "gas_refund_counter": 0, + "gas_used": 401, + "gas_cost": 22100, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": { + "key": "0x0", + "value": "0x64", + "had_value": "0x0", + "reason": "SSTORE" + }, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 113, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056773750, + "gas_refund_counter": 0, + "gas_used": 22501, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 114, + "op": 80, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056773748, + "gas_refund_counter": 0, + "gas_used": 22503, + "gas_cost": 2, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 115, + "op": 86, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056773746, + "gas_refund_counter": 0, + "gas_used": 22505, + "gas_cost": 8, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 100, + "op": 91, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056773738, + "gas_refund_counter": 0, + "gas_used": 22513, + "gas_cost": 1, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": null, + "immediate_bytes": null, + "decoded": null + }, + { + "pc": 101, + "op": 0, + "stack": null, + "push_stack": null, + "memory": null, + "returndata": "0x", + "gas_remaining": 1056773737, + "gas_refund_counter": 0, + "gas_used": 22514, + "gas_cost": 0, + "state_gas_cost": null, + "state_gas_reservoir": null, + "storage_change": null, + "status": "Stop", + "immediate_bytes": null, + "decoded": null + } + ], + "step_deltas": [], + "decoded": null }, "logs": [], - "ordering": [] + "ordering": [ + { + "Step": 0 + }, + { + "Step": 1 + }, + { + "Step": 2 + }, + { + "Step": 3 + }, + { + "Step": 4 + }, + { + "Step": 5 + }, + { + "Step": 6 + }, + { + "Step": 7 + }, + { + "Step": 8 + }, + { + "Step": 9 + }, + { + "Step": 10 + }, + { + "Step": 11 + }, + { + "Step": 12 + }, + { + "Step": 13 + }, + { + "Step": 14 + }, + { + "Step": 15 + }, + { + "Step": 16 + }, + { + "Step": 17 + }, + { + "Step": 18 + }, + { + "Step": 19 + }, + { + "Step": 20 + }, + { + "Step": 21 + }, + { + "Step": 22 + }, + { + "Step": 23 + }, + { + "Step": 24 + }, + { + "Step": 25 + }, + { + "Step": 26 + }, + { + "Step": 27 + }, + { + "Step": 28 + }, + { + "Step": 29 + }, + { + "Step": 30 + }, + { + "Step": 31 + }, + { + "Step": 32 + }, + { + "Step": 33 + }, + { + "Step": 34 + }, + { + "Step": 35 + }, + { + "Step": 36 + }, + { + "Step": 37 + }, + { + "Step": 38 + }, + { + "Step": 39 + }, + { + "Step": 40 + }, + { + "Step": 41 + }, + { + "Step": 42 + }, + { + "Step": 43 + }, + { + "Step": 44 + }, + { + "Step": 45 + }, + { + "Step": 46 + }, + { + "Step": 47 + }, + { + "Step": 48 + }, + { + "Step": 49 + }, + { + "Step": 50 + }, + { + "Step": 51 + }, + { + "Step": 52 + }, + { + "Step": 53 + }, + { + "Step": 54 + }, + { + "Step": 55 + }, + { + "Step": 56 + }, + { + "Step": 57 + }, + { + "Step": 58 + }, + { + "Step": 59 + }, + { + "Step": 60 + }, + { + "Step": 61 + }, + { + "Step": 62 + }, + { + "Step": 63 + }, + { + "Step": 64 + }, + { + "Step": 65 + }, + { + "Step": 66 + }, + { + "Step": 67 + }, + { + "Step": 68 + }, + { + "Step": 69 + }, + { + "Step": 70 + }, + { + "Step": 71 + }, + { + "Step": 72 + }, + { + "Step": 73 + }, + { + "Step": 74 + }, + { + "Step": 75 + }, + { + "Step": 76 + }, + { + "Step": 77 + }, + { + "Step": 78 + }, + { + "Step": 79 + }, + { + "Step": 80 + }, + { + "Step": 81 + }, + { + "Step": 82 + }, + { + "Step": 83 + }, + { + "Step": 84 + }, + { + "Step": 85 + }, + { + "Step": 86 + }, + { + "Step": 87 + }, + { + "Step": 88 + }, + { + "Step": 89 + }, + { + "Step": 90 + }, + { + "Step": 91 + }, + { + "Step": 92 + }, + { + "Step": 93 + }, + { + "Step": 94 + }, + { + "Step": 95 + }, + { + "Step": 96 + }, + { + "Step": 97 + }, + { + "Step": 98 + }, + { + "Step": 99 + }, + { + "Step": 100 + }, + { + "Step": 101 + }, + { + "Step": 102 + }, + { + "Step": 103 + }, + { + "Step": 104 + }, + { + "Step": 105 + }, + { + "Step": 106 + }, + { + "Step": 107 + }, + { + "Step": 108 + }, + { + "Step": 109 + }, + { + "Step": 110 + }, + { + "Step": 111 + }, + { + "Step": 112 + }, + { + "Step": 113 + }, + { + "Step": 114 + }, + { + "Step": 115 + }, + { + "Step": 116 + }, + { + "Step": 117 + }, + { + "Step": 118 + }, + { + "Step": 119 + }, + { + "Step": 120 + }, + { + "Step": 121 + }, + { + "Step": 122 + } + ] }, { "parent": 0, @@ -186,15 +10419,14 @@ "value": "0x0", "data": "{...}", "output": "0x", + "bytecode": null, "gas_used": "{...}", "gas_limit": "{...}", + "gas_refund_counter": 0, "status": "Stop", "steps": [], - "decoded": { - "label": null, - "return_data": null, - "call_data": null - } + "step_deltas": [], + "decoded": null }, "logs": [], "ordering": [] diff --git a/crates/forge/tests/fixtures/TempoEvmVersionGas.t.sol b/crates/forge/tests/fixtures/TempoEvmVersionGas.t.sol new file mode 100644 index 0000000000000..22e97d17aa628 --- /dev/null +++ b/crates/forge/tests/fixtures/TempoEvmVersionGas.t.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity >=0.8.20; + +import {Test} from "forge-std/Test.sol"; + +interface EvmVersionVm { + function setEvmVersion(string calldata version) external; + function getEvmVersion() external pure returns (string memory); +} + +contract TempoEvmVersionGasTest is Test { + EvmVersionVm constant forks = EvmVersionVm(address(vm)); + + function deploy(bytes memory initcode) external returns (uint256 used) { + address deployed; + uint256 start = gasleft(); + assembly { + deployed := create(0, add(initcode, 32), mload(initcode)) + } + used = start - gasleft(); + require(deployed != address(0), "deployment failed"); + } + + function checkGas(bytes memory initcode) internal { + uint256 before = this.deploy(initcode); + string memory current = forks.getEvmVersion(); + forks.setEvmVersion(current); + assertEq(this.deploy(initcode), before, "setEvmVersion changed gas"); + } + + function testCodeDepositGas() public { + // Return 24,000 zero bytes of runtime code, as in issue #17276. + checkGas(hex"615dc05ff3"); + } + + function testNewStorageSlotGas() public { + checkGas(hex"60015f5500"); + } + + function testEmptyAccountGas() public { + checkGas(hex"00"); + } + + function testLegacyEthereumAliasGas() public { + // Repeating the legacy mapping must preserve the selected network's gas schedule. + forks.setEvmVersion("osaka"); + uint256 before = this.deploy(hex"615dc05ff3"); + forks.setEvmVersion("osaka"); + assertEq(this.deploy(hex"615dc05ff3"), before); + assertGt(before, 24_000_000); + } + + function testLegacyNativeRevisionChange() public { + forks.setEvmVersion("tempo:T2"); + assertEq(forks.getEvmVersion(), "t2"); + uint256 before = this.deploy(hex"615dc05ff3"); + forks.setEvmVersion("T2"); + assertEq(this.deploy(hex"615dc05ff3"), before); + assertGt(before, 24_000_000); + } +} diff --git a/crates/forge/tests/fixtures/TempoImplicitApproval.t.sol b/crates/forge/tests/fixtures/TempoImplicitApproval.t.sol new file mode 100644 index 0000000000000..ac1fa966d3f46 --- /dev/null +++ b/crates/forge/tests/fixtures/TempoImplicitApproval.t.sol @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity >=0.8.20; + +import {Test, Vm} from "forge-std/Test.sol"; + +interface EvmVm { + function getEvmVersion() external pure returns (string memory evm); + function setEvmVersion(string calldata evm) external; + function isImplicitlyApproved(address spender) external view returns (bool); + function assumeImplicitApproval(address spender) external view; +} + +interface IAddressRegistry { + function isImplicitlyApproved(address addr) external view returns (bool); +} + +interface ITIP20 { + function approve(address spender, uint256 amount) external returns (bool); + function allowance(address owner, address spender) external view returns (uint256); + function balanceOf(address account) external view returns (uint256); + function transfer(address to, uint256 amount) external returns (bool); + function transferFrom(address from, address to, uint256 amount) external returns (bool); + + event Transfer(address indexed from, address indexed to, uint256 amount); + event Approval(address indexed owner, address indexed spender, uint256 amount); +} + +interface IStablecoinDEX { + function place(address token, uint128 amount, bool isBid, int16 tick) + external + returns (uint128 orderId); + function MIN_ORDER_AMOUNT() external pure returns (uint128); +} + +contract TempoImplicitApprovalTest is Test { + EvmVm constant evm = EvmVm(address(bytes20(uint160(uint256(keccak256("hevm cheat code")))))); + + // Well-known Tempo precompile addresses (mirrors of `tempo-contracts` constants). + address constant TIP_FEE_MANAGER = 0xfeEC000000000000000000000000000000000000; + address constant STABLECOIN_DEX = 0xDEc0000000000000000000000000000000000000; + address constant TIP20_CHANNEL_RESERVE = 0x4d50500000000000000000000000000000000000; + address constant ADDRESS_REGISTRY = 0xfDC0000000000000000000000000000000000000; + address constant PATH_USD = 0x20C0000000000000000000000000000000000000; + // AlphaUSD has `quote_token = PATH_USD`, so a DEX bid on ALPHA escrows PATH_USD. + address constant ALPHA_USD = 0x20C0000000000000000000000000000000000001; + + // Foundry's default test sender; genesis mints the fee tokens to this address. + address constant TEST_SENDER = 0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38; + + /// On non-T5 specs the implicit list is empty. + function test_cheatcode_isImplicitlyApproved_reports_pre_t5() public { + evm.setEvmVersion("T4"); + assertEq(evm.getEvmVersion(), "t4"); + assertFalse(evm.isImplicitlyApproved(TIP_FEE_MANAGER)); + assertFalse(evm.isImplicitlyApproved(STABLECOIN_DEX)); + assertFalse(evm.isImplicitlyApproved(TIP20_CHANNEL_RESERVE)); + assertFalse(evm.isImplicitlyApproved(address(0xBEEF))); + } + + /// On T5 the three listed precompiles are implicitly approved; nothing else is. + function test_isImplicitlyApproved_t5() public view { + assertTrue(evm.isImplicitlyApproved(TIP_FEE_MANAGER)); + assertTrue(evm.isImplicitlyApproved(STABLECOIN_DEX)); + assertTrue(evm.isImplicitlyApproved(TIP20_CHANNEL_RESERVE)); + assertFalse(evm.isImplicitlyApproved(address(0xBEEF))); + assertFalse(evm.isImplicitlyApproved(TEST_SENDER)); + } + + /// The cheatcode and the on-chain AddressRegistry selector agree. + function test_cheatcode_matches_registry_selector_t5() public view { + address[4] memory probes = [ + TIP_FEE_MANAGER, + STABLECOIN_DEX, + TIP20_CHANNEL_RESERVE, + address(0xBEEF) + ]; + for (uint256 i = 0; i < probes.length; i++) { + bool fromCheat = evm.isImplicitlyApproved(probes[i]); + bool fromRegistry = IAddressRegistry(ADDRESS_REGISTRY).isImplicitlyApproved(probes[i]); + assertEq(fromCheat, fromRegistry, "cheatcode and registry disagree"); + } + } + + /// Standard `approve` + `allowance` + `transferFrom` and their events are unchanged. + function test_standard_approve_flow_unchanged_t5() public { + ITIP20 token = ITIP20(PATH_USD); + + address owner = TEST_SENDER; + address spender = address(0xCAFE); + address recipient = address(0xBEEF); + + vm.recordLogs(); + + vm.prank(owner); + assertTrue(token.approve(spender, 1_000)); + assertEq(token.allowance(owner, spender), 1_000); + + uint256 ownerBefore = token.balanceOf(owner); + uint256 recipientBefore = token.balanceOf(recipient); + + vm.prank(spender); + assertTrue(token.transferFrom(owner, recipient, 250)); + + assertEq(token.balanceOf(owner), ownerBefore - 250); + assertEq(token.balanceOf(recipient), recipientBefore + 250); + assertEq(token.allowance(owner, spender), 750); + + Vm.Log[] memory entries = vm.getRecordedLogs(); + bytes32 transferSig = keccak256("Transfer(address,address,uint256)"); + bytes32 approvalSig = keccak256("Approval(address,address,uint256)"); + bool sawTransfer; + bool sawApproval; + for (uint256 i = 0; i < entries.length; i++) { + if (entries[i].emitter != PATH_USD) continue; + if (entries[i].topics.length == 0) continue; + if (entries[i].topics[0] == transferSig) sawTransfer = true; + if (entries[i].topics[0] == approvalSig) sawApproval = true; + } + assertTrue(sawTransfer, "standard Transfer event missing"); + assertTrue(sawApproval, "standard Approval event missing"); + } + + /// A bid on the DEX pulls the quote token from the sender with no prior approve, leaves + /// allowance at zero, and still emits a standard `Transfer` event. + function test_implicit_spender_pulls_without_approve_t5() public { + IStablecoinDEX dex = IStablecoinDEX(STABLECOIN_DEX); + ITIP20 quote = ITIP20(PATH_USD); + + address payer = TEST_SENDER; + uint128 amount = dex.MIN_ORDER_AMOUNT(); + + assertEq(quote.allowance(payer, STABLECOIN_DEX), 0, "precondition: no prior approval"); + + uint256 payerBefore = quote.balanceOf(payer); + uint256 dexBefore = quote.balanceOf(STABLECOIN_DEX); + + // Fail if any `approve(STABLECOIN_DEX, *)` is made on PATH_USD during the flow. + vm.expectCall( + PATH_USD, + abi.encodeWithSelector(ITIP20.approve.selector, STABLECOIN_DEX), + 0 + ); + + vm.recordLogs(); + + // is_bid=true at tick=0 escrows `amount` units of the quote token (PATH_USD). + vm.prank(payer); + uint128 orderId = dex.place(ALPHA_USD, amount, true, 0); + assertGt(uint256(orderId), 0, "order id must be non-zero"); + + assertEq(quote.balanceOf(payer), payerBefore - amount, "payer balance not debited"); + assertEq(quote.balanceOf(STABLECOIN_DEX), dexBefore + amount, "DEX balance not credited"); + assertEq(quote.allowance(payer, STABLECOIN_DEX), 0, "allowance must remain zero"); + + Vm.Log[] memory entries = vm.getRecordedLogs(); + bytes32 transferSig = keccak256("Transfer(address,address,uint256)"); + bool sawTransfer; + for (uint256 i = 0; i < entries.length; i++) { + if (entries[i].emitter != PATH_USD) continue; + if (entries[i].topics.length == 0) continue; + if (entries[i].topics[0] == transferSig) { + sawTransfer = true; + break; + } + } + assertTrue(sawTransfer, "Transfer event missing"); + } + + /// Control: an unlisted spender still needs a prior approve. + function test_non_implicit_spender_requires_approve_t5() public { + ITIP20 token = ITIP20(PATH_USD); + address payer = TEST_SENDER; + address spender = address(0xBEEF); + + assertFalse(evm.isImplicitlyApproved(spender)); + assertEq(token.allowance(payer, spender), 0); + + vm.prank(spender); + vm.expectRevert(); + token.transferFrom(payer, spender, 1); + } + + /// `assumeImplicitApproval` is a no-op for a listed spender. + function test_assumeImplicitApproval_positive_t5() public view { + evm.assumeImplicitApproval(TIP_FEE_MANAGER); + } +} diff --git a/crates/forge/tests/fixtures/backtraces/Backtrace.t.sol b/crates/forge/tests/fixtures/backtraces/Backtrace.t.sol new file mode 100644 index 0000000000000..c6e8d6d72504f --- /dev/null +++ b/crates/forge/tests/fixtures/backtraces/Backtrace.t.sol @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "../src/test.sol"; +import "../src/Vm.sol"; +import "../src/SimpleRevert.sol"; +import "../src/NestedCalls.sol"; +import "../src/DelegateCall.sol"; +import "../src/StaticCall.sol"; + +contract BacktraceTest is DSTest { + SimpleRevert simpleRevert; + NestedCalls nestedCalls; + DelegateTarget delegateTarget; + DelegateCaller delegateCaller; + StaticTarget staticTarget; + StaticCaller staticCaller; + + function setUp() public { + simpleRevert = new SimpleRevert(); + nestedCalls = new NestedCalls(); + delegateTarget = new DelegateTarget(); + delegateCaller = new DelegateCaller(address(delegateTarget)); + staticTarget = new StaticTarget(); + staticCaller = new StaticCaller(address(staticTarget)); + } + + // Simple revert test + function testSimpleRevert() public { + simpleRevert.doRevert("Simple revert message"); + } + + // Require failure test + function testRequireFail() public { + simpleRevert.doRequire(0); + } + + // Assert failure test + function testAssertFail() public { + simpleRevert.doAssert(); + } + + // Custom error test + function testCustomError() public { + simpleRevert.doCustomError(); + } + + // Nested calls test + function testNestedCalls() public { + nestedCalls.nestedCall(5); + } + + // Internal call chain test + function testInternalCallsSameSource() public { + nestedCalls.callChain1(); + } + + // Test internal calls within test contract + function testInternalCallChain() public { + internalCall1(); + } + + function internalCall1() internal { + internalCall2(); + } + + function internalCall2() internal { + internalCall3(); + } + + function internalCall3() internal pure { + revert("Failed at internal level 3"); + } + + // Delegate call revert test + function testDelegateCallRevert() public { + delegateCaller.delegateFail(); + } + + // Delegate call require test + function testDelegateCallRequire() public { + delegateCaller.delegateCompute(0, 5); + } + + // Static call revert test + function testStaticCallRevert() public view { + staticCaller.staticCallFail(); + } + + // Static call require test + function testStaticCallRequire() public view { + staticCaller.staticCompute(0); + } +} diff --git a/crates/forge/tests/fixtures/backtraces/DelegateCall.sol b/crates/forge/tests/fixtures/backtraces/DelegateCall.sol new file mode 100644 index 0000000000000..6946e527c1a1a --- /dev/null +++ b/crates/forge/tests/fixtures/backtraces/DelegateCall.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/// @title DelegateCall - Testing delegate call traces +contract DelegateTarget { + function fail() public pure { + revert("Delegate target failed"); + } + + function compute(uint256 a, uint256 b) public pure returns (uint256) { + require(a > 0, "a must be positive"); + require(b > 0, "b must be positive"); + return a + b; + } +} + +contract DelegateCaller { + address public target; + + constructor(address _target) { + target = _target; + } + + function delegateFail() public { + (bool success,) = target.delegatecall(abi.encodeWithSignature("fail()")); + require(success, "Delegate call failed"); + } + + function delegateCompute(uint256 a, uint256 b) public returns (uint256) { + (bool success, bytes memory data) = + target.delegatecall(abi.encodeWithSignature("compute(uint256,uint256)", a, b)); + require(success, "Delegate compute failed"); + return abi.decode(data, (uint256)); + } +} diff --git a/crates/forge/tests/fixtures/backtraces/ForkBacktrace.t.sol b/crates/forge/tests/fixtures/backtraces/ForkBacktrace.t.sol new file mode 100644 index 0000000000000..45149ffc72e86 --- /dev/null +++ b/crates/forge/tests/fixtures/backtraces/ForkBacktrace.t.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "../src/test.sol"; +import "../src/ForkedERC20Wrapper.sol"; + +contract ForkBacktraceTest is DSTest { + ForkedERC20Wrapper wrapper; + + address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + address constant CIRCLE = 0x55FE002aefF02F77364de339a1292923A15844B8; + + function setUp() public { + wrapper = new ForkedERC20Wrapper(); + } + + function testTransferWithoutBalance() public { + wrapper.transferWithoutBalance(address(0xdead), 1000000); + } + + function testTransferFromWithoutApproval() public { + wrapper.transferFromWithoutApproval(CIRCLE, address(0xdead), 1000000); + } + + function testRequireNonZeroBalance() public view { + wrapper.requireNonZeroBalance(address(wrapper)); + } + + function testNestedFailure() public { + wrapper.nestedFailure(); + } + + function testDirectOnChainRevert() public { + // Try to call transfer directly on USDC without having balance + (bool success,) = USDC.call(abi.encodeWithSignature("transfer(address,uint256)", address(0xdead), 1000000)); + require(success, "USDC transfer failed"); + } +} diff --git a/crates/forge/tests/fixtures/backtraces/ForkedERC20Wrapper.sol b/crates/forge/tests/fixtures/backtraces/ForkedERC20Wrapper.sol new file mode 100644 index 0000000000000..e146e05d8ea00 --- /dev/null +++ b/crates/forge/tests/fixtures/backtraces/ForkedERC20Wrapper.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +interface IERC20 { + function transfer(address to, uint256 amount) external returns (bool); + function transferFrom(address from, address to, uint256 amount) external returns (bool); + function balanceOf(address account) external view returns (uint256); +} + +contract ForkedERC20Wrapper { + address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; + + function transferWithoutBalance(address recipient, uint256 amount) public { + IERC20(USDC).transfer(recipient, amount); + } + + function transferFromWithoutApproval(address from, address to, uint256 amount) public { + IERC20(USDC).transferFrom(from, to, amount); + } + + function requireNonZeroBalance(address account) public view { + uint256 balance = IERC20(USDC).balanceOf(account); + require(balance > 0, "Account has zero USDC balance"); + } + + function nestedFailure() public { + internalCall(); + } + + function internalCall() internal { + transferWithoutBalance(address(0xdead), 1000000); + } +} diff --git a/crates/forge/tests/fixtures/backtraces/LibraryBacktrace.t.sol b/crates/forge/tests/fixtures/backtraces/LibraryBacktrace.t.sol new file mode 100644 index 0000000000000..13fcd841cc13f --- /dev/null +++ b/crates/forge/tests/fixtures/backtraces/LibraryBacktrace.t.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "../src/test.sol"; +import "../src/Vm.sol"; +import "../src/LibraryConsumer.sol"; +import "../src/libraries/ExternalMathLib.sol"; + +contract LibraryBacktraceTest is DSTest { + Vm constant vm = Vm(HEVM_ADDRESS); + LibraryConsumer consumer; + address constant EXTERNAL_LIB_ADDRESS = 0x1234567890123456789012345678901234567890; + + function setUp() public { + // Deploy the external library at the configured address + bytes memory libraryBytecode = type(ExternalMathLib).runtimeCode; + vm.etch(EXTERNAL_LIB_ADDRESS, libraryBytecode); + + // Deploy consumer contract + consumer = new LibraryConsumer(); + } + + // Internal library tests (should show inlined source locations) + + /// @notice Test division by zero in internal library + function testInternalDivisionByZero() public { + consumer.internalDivide(100, 0); + } + + /// @notice Test underflow in internal library + function testInternalUnderflow() public { + consumer.internalSubtract(10, 20); + } + + /// @notice Test overflow in internal library + function testInternalOverflow() public { + consumer.internalMultiply(type(uint256).max, 2); + } + + /// @notice Test require in internal library + function testInternalRequire() public { + consumer.internalCheckPositive(0); + } + + // External library tests (should show delegatecall to library address) + + /// @notice Test division by zero in external library + function testExternalDivisionByZero() public { + consumer.externalDivide(100, 0); + } + + /// @notice Test underflow in external library + function testExternalUnderflow() public { + consumer.externalSubtract(10, 20); + } + + /// @notice Test overflow in external library + function testExternalOverflow() public { + consumer.externalMultiply(type(uint256).max, 2); + } + + /// @notice Test require in external library + function testExternalRequire() public { + consumer.externalCheckPositive(0); + } + + // Mixed library usage test + + /// @notice Test mixed library usage with failure in external library + function testMixedLibraryFailure() public { + // This will fail at the external library division step (50 - 50 = 0, then divide by 0) + consumer.mixedCalculation(50, 50, 0); + } +} diff --git a/crates/forge/tests/fixtures/backtraces/LibraryConsumer.sol b/crates/forge/tests/fixtures/backtraces/LibraryConsumer.sol new file mode 100644 index 0000000000000..e62124915cd3e --- /dev/null +++ b/crates/forge/tests/fixtures/backtraces/LibraryConsumer.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "./libraries/InternalMathLib.sol"; +import "./libraries/ExternalMathLib.sol"; + +/// @title LibraryConsumer - A contract that uses both internal and external libraries +contract LibraryConsumer { + using InternalMathLib for uint256; + + uint256 public result; + + // Internal library functions (inlined into contract bytecode) + + /// @notice Perform division using internal library + function internalDivide(uint256 a, uint256 b) public returns (uint256) { + result = a.div(b); + return result; + } + + /// @notice Perform multiplication using internal library + function internalMultiply(uint256 a, uint256 b) public returns (uint256) { + result = a.mul(b); + return result; + } + + /// @notice Perform subtraction using internal library + function internalSubtract(uint256 a, uint256 b) public returns (uint256) { + result = a.sub(b); + return result; + } + + /// @notice Check positive value using internal library + function internalCheckPositive(uint256 value) public returns (uint256) { + result = InternalMathLib.requirePositive(value); + return result; + } + + // External library functions (delegatecall to deployed library) + + /// @notice Perform division using external library + function externalDivide(uint256 a, uint256 b) public returns (uint256) { + result = ExternalMathLib.div(a, b); + return result; + } + + /// @notice Perform multiplication using external library + function externalMultiply(uint256 a, uint256 b) public returns (uint256) { + result = ExternalMathLib.mul(a, b); + return result; + } + + /// @notice Perform subtraction using external library + function externalSubtract(uint256 a, uint256 b) public returns (uint256) { + result = ExternalMathLib.sub(a, b); + return result; + } + + /// @notice Check positive value using external library + function externalCheckPositive(uint256 value) public returns (uint256) { + result = ExternalMathLib.requirePositive(value); + return result; + } + + // Mixed usage example + + /// @notice Complex calculation using both libraries + function mixedCalculation(uint256 a, uint256 b, uint256 c) public returns (uint256) { + // First use internal library + uint256 step1 = a.sub(b); + // Then use external library + uint256 step2 = ExternalMathLib.div(step1, c); + // Back to internal + result = step2.mul(10); + return result; + } +} diff --git a/crates/forge/tests/fixtures/backtraces/MultipleLibraryBacktrace.t.sol b/crates/forge/tests/fixtures/backtraces/MultipleLibraryBacktrace.t.sol new file mode 100644 index 0000000000000..de8864f8c408b --- /dev/null +++ b/crates/forge/tests/fixtures/backtraces/MultipleLibraryBacktrace.t.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "../src/test.sol"; +import "../src/MultipleLibraryConsumer.sol"; + +contract MultipleLibraryBacktraceTest is DSTest { + MultipleLibraryConsumer consumer; + + function setUp() public { + consumer = new MultipleLibraryConsumer(); + } + + /// @notice Test that FirstMathLib shows correctly in backtrace + function testFirstLibraryError() public { + consumer.useFirstLib(10, 0); // Division by zero in FirstMathLib + } + + /// @notice Test that SecondMathLib shows correctly in backtrace + function testSecondLibraryError() public { + consumer.useSecondLib(5, 10); // Underflow in SecondMathLib + } + + /// @notice Test that ThirdMathLib shows correctly in backtrace + function testThirdLibraryError() public { + consumer.useThirdLib(10, 0); // Modulo by zero in ThirdMathLib + } + + /// @notice Test complex failure in the first library + function testAllLibrariesFirstFails() public { + consumer.useAllLibraries(10, 0, 5); // Division by zero in FirstMathLib + } +} diff --git a/crates/forge/tests/fixtures/backtraces/MultipleLibraryConsumer.sol b/crates/forge/tests/fixtures/backtraces/MultipleLibraryConsumer.sol new file mode 100644 index 0000000000000..fffdbc5475f7e --- /dev/null +++ b/crates/forge/tests/fixtures/backtraces/MultipleLibraryConsumer.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "./libraries/MultipleLibraries.sol"; + +/// @title MultipleLibraryConsumer - A contract that uses multiple libraries from the same file +contract MultipleLibraryConsumer { + using FirstMathLib for uint256; + using SecondMathLib for uint256; + using ThirdMathLib for uint256; + + uint256 public result; + + /// @notice Test division from FirstMathLib + function useFirstLib(uint256 a, uint256 b) public returns (uint256) { + result = a.divide(b); // Should show FirstMathLib in backtrace + return result; + } + + /// @notice Test subtraction from SecondMathLib + function useSecondLib(uint256 a, uint256 b) public returns (uint256) { + result = a.subtract(b); // Should show SecondMathLib in backtrace + return result; + } + + /// @notice Test modulo from ThirdMathLib + function useThirdLib(uint256 a, uint256 b) public returns (uint256) { + result = a.modulo(b); // Should show ThirdMathLib in backtrace + return result; + } + + /// @notice Complex calculation using all three libraries + function useAllLibraries(uint256 a, uint256 b, uint256 c) public returns (uint256) { + uint256 step1 = a.divide(b); // FirstMathLib + uint256 step2 = step1.add(c); // SecondMathLib + result = step2.modulo(10); // ThirdMathLib + return result; + } +} diff --git a/crates/forge/tests/fixtures/backtraces/NestedCalls.sol b/crates/forge/tests/fixtures/backtraces/NestedCalls.sol new file mode 100644 index 0000000000000..67a4546a46f6b --- /dev/null +++ b/crates/forge/tests/fixtures/backtraces/NestedCalls.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/// @title NestedCalls - Testing nested call stack traces +contract NestedCalls { + uint256 public depth; + + function nestedCall(uint256 maxDepth) public { + depth++; + if (depth >= maxDepth) { + revert("Maximum depth reached"); + } + this.nestedCall(maxDepth); + } + + function callChain1() public pure { + callChain2(); + } + + function callChain2() internal pure { + callChain3(); + } + + function callChain3() internal pure { + revert("Failed at chain level 3"); + } +} diff --git a/crates/forge/tests/fixtures/backtraces/SimpleRevert.sol b/crates/forge/tests/fixtures/backtraces/SimpleRevert.sol new file mode 100644 index 0000000000000..02e46459dae79 --- /dev/null +++ b/crates/forge/tests/fixtures/backtraces/SimpleRevert.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/// @title SimpleRevert - Basic revert testing contract +contract SimpleRevert { + function doRevert(string memory reason) public pure { + revert(reason); + } + + function doRequire(uint256 value) public pure { + require(value > 0, "Value must be greater than zero"); + } + + function doAssert() public pure { + assert(false); + } + + error CustomError(uint256 code, address sender); + + function doCustomError() public view { + revert CustomError(42, msg.sender); + } +} diff --git a/crates/forge/tests/fixtures/backtraces/StaticCall.sol b/crates/forge/tests/fixtures/backtraces/StaticCall.sol new file mode 100644 index 0000000000000..7129ab6fcb3bd --- /dev/null +++ b/crates/forge/tests/fixtures/backtraces/StaticCall.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/// @title StaticCall - Testing static call traces +contract StaticTarget { + function viewFail() public pure { + revert("Static call failed"); + } + + function compute(uint256 value) public pure returns (uint256) { + require(value > 0, "Value must be positive"); + return value * 2; + } +} + +contract StaticCaller { + address public target; + + constructor(address _target) { + target = _target; + } + + function staticCallFail() public view { + (bool success,) = target.staticcall(abi.encodeWithSignature("viewFail()")); + require(success, "Static call reverted"); + } + + function staticCompute(uint256 value) public view returns (uint256) { + (bool success, bytes memory data) = target.staticcall(abi.encodeWithSignature("compute(uint256)", value)); + require(success, "Static compute failed"); + return abi.decode(data, (uint256)); + } +} diff --git a/crates/forge/tests/fixtures/backtraces/libraries/ExternalMathLib.sol b/crates/forge/tests/fixtures/backtraces/libraries/ExternalMathLib.sol new file mode 100644 index 0000000000000..609ba41f86db0 --- /dev/null +++ b/crates/forge/tests/fixtures/backtraces/libraries/ExternalMathLib.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/// @title ExternalMathLib - A library with external functions that needs separate deployment +library ExternalMathLib { + error DivisionByZero(); + error Overflow(); + error Underflow(); + + /// @notice External division function + function div(uint256 a, uint256 b) external pure returns (uint256) { + if (b == 0) revert DivisionByZero(); + return a / b; + } + + /// @notice External multiplication with overflow check + function mul(uint256 a, uint256 b) external pure returns (uint256) { + if (a == 0) return 0; + uint256 c = a * b; + if (c / a != b) revert Overflow(); + return c; + } + + /// @notice External subtraction with underflow check + function sub(uint256 a, uint256 b) external pure returns (uint256) { + if (b > a) revert Underflow(); + return a - b; + } + + /// @notice External function with require statement + function requirePositive(uint256 value) external pure returns (uint256) { + require(value > 0, "ExternalMathLib: value must be positive"); + return value * 2; + } +} diff --git a/crates/forge/tests/fixtures/backtraces/libraries/InternalMathLib.sol b/crates/forge/tests/fixtures/backtraces/libraries/InternalMathLib.sol new file mode 100644 index 0000000000000..e4337df046701 --- /dev/null +++ b/crates/forge/tests/fixtures/backtraces/libraries/InternalMathLib.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/// @title InternalMathLib - A library with internal functions that gets inlined +library InternalMathLib { + error DivisionByZero(); + error Overflow(); + error Underflow(); + + /// @notice Internal division function + function div(uint256 a, uint256 b) internal pure returns (uint256) { + if (b == 0) revert DivisionByZero(); + return a / b; + } + + /// @notice Internal multiplication with overflow check + function mul(uint256 a, uint256 b) internal pure returns (uint256) { + if (a == 0) return 0; + uint256 c = a * b; + if (c / a != b) revert Overflow(); + return c; + } + + /// @notice Internal subtraction with underflow check + function sub(uint256 a, uint256 b) internal pure returns (uint256) { + if (b > a) revert Underflow(); + return a - b; + } + + /// @notice Internal function with require statement + function requirePositive(uint256 value) internal pure returns (uint256) { + require(value > 0, "InternalMathLib: value must be positive"); + return value * 2; + } +} diff --git a/crates/forge/tests/fixtures/backtraces/libraries/MultipleLibraries.sol b/crates/forge/tests/fixtures/backtraces/libraries/MultipleLibraries.sol new file mode 100644 index 0000000000000..9a9a3c3d77c66 --- /dev/null +++ b/crates/forge/tests/fixtures/backtraces/libraries/MultipleLibraries.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/// @title FirstMathLib - First library in the file +library FirstMathLib { + error FirstLibError(); + + function divide(uint256 a, uint256 b) internal pure returns (uint256) { + if (b == 0) { + revert FirstLibError(); + } + return a / b; + } + + function multiply(uint256 a, uint256 b) internal pure returns (uint256) { + return a * b; + } +} + +/// @title SecondMathLib - Second library in the same file +library SecondMathLib { + error SecondLibError(); + + function subtract(uint256 a, uint256 b) internal pure returns (uint256) { + if (b > a) { + revert SecondLibError(); + } + return a - b; + } + + function add(uint256 a, uint256 b) internal pure returns (uint256) { + return a + b; + } +} + +/// @title ThirdMathLib - Third library in the same file +library ThirdMathLib { + error ThirdLibError(); + + function modulo(uint256 a, uint256 b) internal pure returns (uint256) { + if (b == 0) { + revert ThirdLibError(); + } + return a % b; + } + + function power(uint256 base, uint256 exp) internal pure returns (uint256) { + uint256 result = 1; + for (uint256 i = 0; i < exp; i++) { + result *= base; + } + return result; + } +} diff --git a/crates/forge/tests/fixtures/bind-with-serde-with/Cargo.lock b/crates/forge/tests/fixtures/bind-with-serde-with/Cargo.lock new file mode 100644 index 0000000000000..8f7831321b3d0 --- /dev/null +++ b/crates/forge/tests/fixtures/bind-with-serde-with/Cargo.lock @@ -0,0 +1,4357 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "alloy" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50ab0cd8afe573d1f7dc2353698a51b1f93aec362c8211e28cfd3948c6adba39" +dependencies = [ + "alloy-consensus", + "alloy-contract", + "alloy-core", + "alloy-eips", + "alloy-genesis", + "alloy-network", + "alloy-provider", + "alloy-rpc-client", + "alloy-rpc-types", + "alloy-serde", + "alloy-signer", + "alloy-signer-local", + "alloy-transport", + "alloy-transport-http", + "alloy-trie", +] + +[[package]] +name = "alloy-chains" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fdcfed8f106be3df944054aaa42bc13ae103a3ac8a9f4b08d4f053e3a743f8" +dependencies = [ + "alloy-primitives", + "num_enum", + "phf", +] + +[[package]] +name = "alloy-consensus" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f16daaf7e1f95f62c6c3bf8a3fc3d78b08ae9777810c0bb5e94966c7cd57ef0" +dependencies = [ + "alloy-eips", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "alloy-trie", + "alloy-tx-macros", + "auto_impl", + "borsh", + "c-kzg", + "derive_more", + "either", + "k256", + "once_cell", + "rand 0.8.8", + "secp256k1 0.30.0", + "serde", + "serde_json", + "serde_with", + "thiserror", +] + +[[package]] +name = "alloy-consensus-any" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "118998d9015332ab1b4720ae1f1e3009491966a0349938a1f43ff45a8a4c6299" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "serde", +] + +[[package]] +name = "alloy-contract" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ac9e0c34dc6bce643b182049cdfcca1b8ce7d9c260cbdd561f511873b7e26cd" +dependencies = [ + "alloy-consensus", + "alloy-dyn-abi", + "alloy-json-abi", + "alloy-network", + "alloy-network-primitives", + "alloy-primitives", + "alloy-provider", + "alloy-rpc-types-eth", + "alloy-sol-types", + "alloy-transport", + "futures", + "futures-util", + "serde_json", + "thiserror", + "tracing", +] + +[[package]] +name = "alloy-core" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa2d25cf04344ea5eeb47e0cd21c794e646a029959bf5700bd8c05342c0353fe" +dependencies = [ + "alloy-dyn-abi", + "alloy-json-abi", + "alloy-primitives", + "alloy-rlp", + "alloy-sol-types", +] + +[[package]] +name = "alloy-dyn-abi" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "201b9e973fe90b2effd9ab356d4f2a46ab56046ba9d46f163367553e72c045b0" +dependencies = [ + "alloy-json-abi", + "alloy-primitives", + "alloy-sol-type-parser", + "alloy-sol-types", + "itoa", + "serde", + "serde_json", + "winnow", +] + +[[package]] +name = "alloy-eip2124" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "741bdd7499908b3aa0b159bba11e71c8cddd009a2c2eb7a06e825f1ec87900a5" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "crc", + "serde", + "thiserror", +] + +[[package]] +name = "alloy-eip2930" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64579d931b3f8eacc7c9ab0b220e87e9c4816e5c724ede1947b55c2f8e92ae5" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "borsh", + "serde", +] + +[[package]] +name = "alloy-eip7702" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2919c5a56a1007492da313e7a3b6d45ef5edc5d33416fdec63c0d7a2702a0d20" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "borsh", + "serde", + "thiserror", +] + +[[package]] +name = "alloy-eip7928" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b827a6d7784fe3eb3489d40699407a4cdcce74271421a01bdffe60cf573bb16" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "borsh", + "once_cell", + "serde", + "thiserror", +] + +[[package]] +name = "alloy-eips" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6ef28c9fdad22d4eec52d894f5f2673a0895f1e5ef196734568e68c0f6caca8" +dependencies = [ + "alloy-eip2124", + "alloy-eip2930", + "alloy-eip7702", + "alloy-eip7928", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "auto_impl", + "borsh", + "c-kzg", + "derive_more", + "either", + "serde", + "serde_with", + "sha2", +] + +[[package]] +name = "alloy-genesis" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbf9480307b09d22876efb67d30cadd9013134c21f3a17ec9f93fd7536d38024" +dependencies = [ + "alloy-eips", + "alloy-primitives", + "alloy-serde", + "alloy-trie", + "borsh", + "serde", + "serde_with", +] + +[[package]] +name = "alloy-json-abi" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dba4e59c3581a39e03e0b0b4a46ee9c41315b5d843b1e903271952b32bedb7c" +dependencies = [ + "alloy-primitives", + "alloy-sol-type-parser", + "serde", + "serde_json", +] + +[[package]] +name = "alloy-json-rpc" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "422d110f1c40f1f8d0e5562b0b649c35f345fccb7093d9f02729943dcd1eef71" +dependencies = [ + "alloy-primitives", + "alloy-sol-types", + "http", + "serde", + "serde_json", + "thiserror", + "tracing", +] + +[[package]] +name = "alloy-network" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7197a66d94c4de1591cdc16a9bcea5f8cccd0da81b865b49aef97b1b4016e0fa" +dependencies = [ + "alloy-consensus", + "alloy-consensus-any", + "alloy-eips", + "alloy-json-rpc", + "alloy-network-primitives", + "alloy-primitives", + "alloy-rpc-types-any", + "alloy-rpc-types-eth", + "alloy-serde", + "alloy-signer", + "alloy-sol-types", + "async-trait", + "auto_impl", + "derive_more", + "futures-utils-wasm", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "alloy-network-primitives" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb82711d59a43fdfd79727c99f270b974c784ec4eb5728a0d0d22f26716c87ef" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-primitives", + "alloy-serde", + "serde", +] + +[[package]] +name = "alloy-primitives" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce7b00f0cb42c66ec353076ded1dff1fbf818f6e0e26c40c8a8456c04483fca4" +dependencies = [ + "alloy-rlp", + "bytes", + "cfg-if", + "const-hex", + "derive_more", + "fixed-cache", + "foldhash", + "hashbrown 0.17.1", + "indexmap 2.14.0", + "itoa", + "k256", + "keccak-asm", + "paste", + "proptest", + "rand 0.9.5", + "rapidhash", + "ruint", + "rustc-hash", + "secp256k1 0.31.1", + "serde", + "sha3", +] + +[[package]] +name = "alloy-provider" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf6b18b929ef1d078b834c3631e9c925177f3b23ddc6fa08a722d13047205876" +dependencies = [ + "alloy-chains", + "alloy-consensus", + "alloy-eips", + "alloy-json-rpc", + "alloy-network", + "alloy-network-primitives", + "alloy-primitives", + "alloy-rpc-client", + "alloy-rpc-types-eth", + "alloy-signer", + "alloy-sol-types", + "alloy-transport", + "alloy-transport-http", + "async-stream", + "async-trait", + "auto_impl", + "dashmap", + "either", + "futures", + "futures-utils-wasm", + "lru", + "parking_lot", + "pin-project", + "reqwest", + "serde", + "serde_json", + "thiserror", + "tokio", + "tracing", + "url", + "wasmtimer", +] + +[[package]] +name = "alloy-rlp" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24671b1f62edcf0f9b62994c7bf72cd621a04a4b99f5020ece1a647b40e2f103" +dependencies = [ + "alloy-rlp-derive", + "arrayvec", + "bytes", +] + +[[package]] +name = "alloy-rlp-derive" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d4311c03125e8a18296504560b9de3d75ecbd0dcda7f71e6cf2a196d57e6fba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "alloy-rpc-client" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fcc9604042ca80bd37aa5e232ea1cd851f337e31e2babbbb345bc0b1c30de3" +dependencies = [ + "alloy-json-rpc", + "alloy-primitives", + "alloy-transport", + "alloy-transport-http", + "futures", + "pin-project", + "reqwest", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tower", + "tracing", + "url", + "wasmtimer", +] + +[[package]] +name = "alloy-rpc-types" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4faad925d3a669ffc15f43b3deec7fbdf2adeb28a4d6f9cf4bc661698c0f8f4b" +dependencies = [ + "alloy-primitives", + "alloy-rpc-types-eth", + "alloy-serde", + "serde", +] + +[[package]] +name = "alloy-rpc-types-any" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3823026d1ed239a40f12364fac50726c8daf1b6ab8077a97212c5123910429ed" +dependencies = [ + "alloy-consensus-any", + "alloy-rpc-types-eth", + "alloy-serde", +] + +[[package]] +name = "alloy-rpc-types-eth" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59c095f92c4e1ff4981d89e9aa02d5f98c762a1980ab66bec49c44be11349da2" +dependencies = [ + "alloy-consensus", + "alloy-consensus-any", + "alloy-eips", + "alloy-network-primitives", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "alloy-sol-types", + "itertools 0.14.0", + "serde", + "serde_json", + "serde_with", + "thiserror", +] + +[[package]] +name = "alloy-serde" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ece63b89294b8614ab3f483560c08d016930f842bf36da56bf0b764a15c11e" +dependencies = [ + "alloy-primitives", + "serde", + "serde_json", +] + +[[package]] +name = "alloy-signer" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f447aefab0f1c0649f71edc33f590992d4e122bc35fb9cdbbf67d4421ace85" +dependencies = [ + "alloy-primitives", + "async-trait", + "auto_impl", + "either", + "elliptic-curve", + "k256", + "thiserror", +] + +[[package]] +name = "alloy-signer-local" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f721f4bf2e4812e5505aaf5de16ef3065a8e26b9139ac885862d00b5a55a659a" +dependencies = [ + "alloy-consensus", + "alloy-network", + "alloy-primitives", + "alloy-signer", + "async-trait", + "k256", + "rand 0.8.8", + "thiserror", +] + +[[package]] +name = "alloy-sol-macro" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64558980fb038cd34b4285ec2b36a2a8bd8d4ddd13b3f6e42d97cb5ee29938e" +dependencies = [ + "alloy-sol-macro-expander", + "alloy-sol-macro-input", + "proc-macro-error3", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "alloy-sol-macro-expander" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffb0e793abdbaea9d01259493c8272c3af295d03389e70a2acdf53b57ad1edf" +dependencies = [ + "alloy-json-abi", + "alloy-sol-macro-input", + "const-hex", + "heck", + "indexmap 2.14.0", + "proc-macro-error3", + "proc-macro2", + "quote", + "sha3", + "syn 2.0.119", + "syn-solidity", +] + +[[package]] +name = "alloy-sol-macro-input" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32c2c0ec8425d9663dac939ba75750f17d2933d2f020814b4f8fde4a60da6d26" +dependencies = [ + "alloy-json-abi", + "const-hex", + "dunce", + "heck", + "macro-string", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.119", + "syn-solidity", +] + +[[package]] +name = "alloy-sol-type-parser" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b37db6a7ad8170596345f864522f789cc7af093f516d6cdf34bbdcfba8adab" +dependencies = [ + "serde", + "winnow", +] + +[[package]] +name = "alloy-sol-types" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f40e33a0f588dde548c3b6767a71e61b593421a8c1b253b9cf1441dc7cf7ab5" +dependencies = [ + "alloy-json-abi", + "alloy-primitives", + "alloy-sol-macro", + "serde", +] + +[[package]] +name = "alloy-transport" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8098f965442a9feb620965ba4b4be5e2b320f4ec5a3fff6bfa9e1ff7ef42bed1" +dependencies = [ + "alloy-json-rpc", + "auto_impl", + "base64", + "derive_more", + "futures", + "futures-utils-wasm", + "parking_lot", + "serde", + "serde_json", + "thiserror", + "tokio", + "tower", + "tracing", + "url", + "wasmtimer", +] + +[[package]] +name = "alloy-transport-http" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8597d36d546e1dab822345ad563243ec3920e199322cb554ce56c8ef1a1e2e7" +dependencies = [ + "alloy-json-rpc", + "alloy-transport", + "itertools 0.14.0", + "reqwest", + "serde_json", + "tower", + "tracing", + "url", +] + +[[package]] +name = "alloy-trie" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f14b5d9b2c2173980202c6ff470d96e7c5e202c65a9f67884ad565226df7fbb" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "derive_more", + "nybbles", + "serde", + "smallvec", + "thiserror", + "tracing", +] + +[[package]] +name = "alloy-tx-macros" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d69722eddcdf1ce096c3ab66cf8116999363f734eb36fe94a148f4f71c85da84" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "ark-ff" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b3235cc41ee7a12aaaf2c575a2ad7b46713a8a50bda2fc3b003a04845c05dd6" +dependencies = [ + "ark-ff-asm 0.3.0", + "ark-ff-macros 0.3.0", + "ark-serialize 0.3.0", + "ark-std 0.3.0", + "derivative", + "num-bigint", + "num-traits", + "paste", + "rustc_version 0.3.3", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec847af850f44ad29048935519032c33da8aa03340876d351dfab5660d2966ba" +dependencies = [ + "ark-ff-asm 0.4.2", + "ark-ff-macros 0.4.2", + "ark-serialize 0.4.2", + "ark-std 0.4.0", + "derivative", + "digest 0.10.7", + "itertools 0.10.5", + "num-bigint", + "num-traits", + "paste", + "rustc_version 0.4.1", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a177aba0ed1e0fbb62aa9f6d0502e9b46dad8c2eab04c14258a1212d2557ea70" +dependencies = [ + "ark-ff-asm 0.5.0", + "ark-ff-macros 0.5.0", + "ark-serialize 0.5.0", + "ark-std 0.5.0", + "arrayvec", + "digest 0.10.7", + "educe", + "itertools 0.13.0", + "num-bigint", + "num-traits", + "paste", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7a806ac6c8307b929df4645776290a50ee2aac754ad09d8bdf73391309e43af" +dependencies = [ + "ark-ff-asm 0.6.0", + "ark-ff-macros 0.6.0", + "ark-serialize 0.6.0", + "ark-std 0.6.0", + "digest 0.10.7", + "educe", + "num-bigint", + "num-traits", + "zeroize", +] + +[[package]] +name = "ark-ff-asm" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db02d390bf6643fb404d3d22d31aee1c4bc4459600aef9113833d17e786c6e44" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-asm" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed4aa4fe255d0bc6d79373f7e31d2ea147bcf486cba1be5ba7ea85abdb92348" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-asm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ark-ff-asm" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1479009684adc073dff49a1025d3a7065b317a9ead25aaaca38cdc70058ba8a2" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ark-ff-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fd794a08ccb318058009eefdf15bcaaaaf6f8161eb3345f907222bac38b20" +dependencies = [ + "num-bigint", + "num-traits", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ark-ff-macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0691ed21ef00ef89c1e9bda832eba493dda3ec2f8d892fb25b705f73f06bb8" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ark-serialize" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d6c2b318ee6e10f8c2853e73a83adc0ccb88995aa978d8a3408d492ab2ee671" +dependencies = [ + "ark-std 0.3.0", + "digest 0.9.0", +] + +[[package]] +name = "ark-serialize" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" +dependencies = [ + "ark-std 0.4.0", + "digest 0.10.7", + "num-bigint", +] + +[[package]] +name = "ark-serialize" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7" +dependencies = [ + "ark-std 0.5.0", + "arrayvec", + "digest 0.10.7", + "num-bigint", +] + +[[package]] +name = "ark-serialize" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a74dd304fd536fb95d0a328e72be759209cc496a9da094c5bc56e5fea4f9e86b" +dependencies = [ + "ark-serialize-derive", + "ark-std 0.6.0", + "digest 0.10.7", + "num-bigint", + "serde_with", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f153690697a2b91e5e1251ff98411ee5371500a111a0fd317a70e588eb300f9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ark-std" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1df2c09229cbc5a028b1d70e00fdb2acee28b1055dfb5ca73eea49c5a25c4e7c" +dependencies = [ + "num-traits", + "rand 0.8.8", +] + +[[package]] +name = "ark-std" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" +dependencies = [ + "num-traits", + "rand 0.8.8", +] + +[[package]] +name = "ark-std" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a" +dependencies = [ + "num-traits", + "rand 0.8.8", +] + +[[package]] +name = "ark-std" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "367c9c827ed431bff6868b7aa926e05b16eb46603cc8b6e768e4a5553fa1d155" +dependencies = [ + "num-traits", + "rand 0.8.8", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "auto_impl" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitcoin-consensus-encoding" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6712f9c6fd6785b3b270884e57c441c403dc5d7e19ca45368c97c7a1de3000ec" +dependencies = [ + "bitcoin-internals", + "hex-conservative 1.2.0", + "serde", +] + +[[package]] +name = "bitcoin-internals" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5" + +[[package]] +name = "bitcoin-io" +version = "0.1.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb5de036369d1ac59d3c1819ebc4d850f89466f5401c571a285b6ed564a4cb78" +dependencies = [ + "bitcoin-consensus-encoding", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" +dependencies = [ + "bitcoin-io", + "hex-conservative 0.2.2", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "blst" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c20659f9bbee16cbbd2f7393e40ab6309f5a98f76a2eb57a995ec508b72387fe" +dependencies = [ + "cc", + "glob", + "threadpool", + "zeroize", +] + +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byte-slice-cast" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "c-kzg" +version = "2.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d04308254695569fdb9bfe3bacc1c91837a670d0806605eb82d63748fbd3a6" +dependencies = [ + "blst", + "cc", + "glob", + "hex", + "libc", + "once_cell", + "serde", +] + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-hex" +version = "1.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e2a781ebdf4467d1428dc4593067825fb646f6871475098d8577421af73558" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "proptest", + "serde_core", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "serde", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version 0.4.1", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "serdect", + "signature", + "spki", +] + +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +dependencies = [ + "serde", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "serdect", + "subtle", + "zeroize", +] + +[[package]] +name = "enum-ordinalize" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89dd01549b09589510cf0647475075d12071456586d70f5c75c98ae2a5537677" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fastrlp" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139834ddba373bbdd213dffe02c8d110508dcf1726c2be27e8d1f7d7e1856418" +dependencies = [ + "arrayvec", + "auto_impl", + "bytes", +] + +[[package]] +name = "fastrlp" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce8dba4714ef14b8274c371879b175aa55b16b30f269663f19d576f380018dc4" +dependencies = [ + "arrayvec", + "auto_impl", + "bytes", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "fixed-cache" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fe63500644ef0269fe6b744e7e5dc5c20b5eebf3d881bc2be53f194636f6583" +dependencies = [ + "equivalent", + "rapidhash", +] + +[[package]] +name = "fixed-hash" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534" +dependencies = [ + "byteorder", + "rand 0.8.8", + "rustc-hex", + "static_assertions", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "foundry-contracts" +version = "0.1.0" +dependencies = [ + "alloy", + "serde", + "serde_with", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "futures-utils-wasm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42012b0f064e01aa58b545fe3727f90f7dd4020f4a3ea735b50344965f5a57e9" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", + "serde", + "serde_core", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex-conservative" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35431185f361ccf3ffc58254628af5f1f5d5f28531da2e02e5d6c82bbc282a10" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "279259b0ac81c89d11c290495fdcfa96ea3643b7df311c138b6fe8ca5237f0f8" +dependencies = [ + "idna_mapping", + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "idna_mapping" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11c13906586a4b339310541a274dd927aff6fcbb5b8e3af90634c4b31681c792" +dependencies = [ + "unicode-joining-type", +] + +[[package]] +name = "impl-codec" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba6a270039626615617f3f36d15fc827041df3b78c439da2cadfa47455a77f2f" +dependencies = [ + "parity-scale-codec", +] + +[[package]] +name = "impl-trait-for-tuples" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version 0.4.1", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "once_cell", + "serdect", + "sha2", +] + +[[package]] +name = "keccak" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", +] + +[[package]] +name = "keccak-asm" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5dc2c0d691cbf7595cde551ced329cca99c2387c2cbc97754c5d0cd045d3ee" +dependencies = [ + "digest 0.10.7", + "sha3-asm", +] + +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "macro-string" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a9dbbfc75d2688ed057456ce8a3ee3f48d12eec09229f560f3643b9f275653" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "nybbles" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d49ff0c0d00d4a502b39df9af3a525e1efeb14b9dabb5bb83335284c1309210" +dependencies = [ + "alloy-rlp", + "cfg-if", + "proptest", + "ruint", + "serde", + "smallvec", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "parity-scale-codec" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799781ae679d79a948e13d4824a40970bfa500058d245760dd857301059810fa" +dependencies = [ + "arrayvec", + "bitvec", + "byte-slice-cast", + "const_format", + "impl-trait-for-tuples", + "parity-scale-codec-derive", + "rustversion", + "serde", +] + +[[package]] +name = "parity-scale-codec-derive" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b4653168b563151153c9e4c08ebed57fb8262bebfa79711552fa983c623e7a" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "primitive-types" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b34d9fd68ae0b74a41b21c03c2f62847aa0ffea044eee893b4c140b37e244e2" +dependencies = [ + "fixed-hash", + "impl-codec", + "uint", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro-error-attr3" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82366fd7d8b7a440d66d13418820c69df9b3908bcb1a0476d7f5ce5d12f5a04d" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error3" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b511283ea8a74b4b39447b128c5d00f03a356b7424554b13e298a5550100d9ac" +dependencies = [ + "proc-macro-error-attr3", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bitflags 2.13.1", + "num-traits", + "rand 0.9.5", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "unarray", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", + "serde", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", + "serde", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", + "serde", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "rapidhash" +version = "4.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e" +dependencies = [ + "rustversion", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rlp" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb919243f34364b6bd2fc10ef797edbfa75f33c252e7998527479c6d6b47e1ec" +dependencies = [ + "bytes", + "rustc-hex", +] + +[[package]] +name = "ruint" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5e99bff0393163bb25029a6af25d3d8d202ba5b5438a74d1bd8789f5c822970" +dependencies = [ + "alloy-rlp", + "ark-ff 0.3.0", + "ark-ff 0.4.2", + "ark-ff 0.5.0", + "ark-ff 0.6.0", + "bytes", + "fastrlp 0.3.1", + "fastrlp 0.4.0", + "num-bigint", + "num-integer", + "num-traits", + "parity-scale-codec", + "primitive-types", + "proptest", + "rand 0.8.8", + "rand 0.9.5", + "rlp", + "ruint-macro", + "serde_core", + "valuable", + "zeroize", +] + +[[package]] +name = "ruint-macro" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc-hex" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e75f6a532d0fd9f7f13144f392b6ad56a32696bfcd9c78f797f16bbb6f072d6" + +[[package]] +name = "rustc_version" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0dfe2087c51c460008730de8b57e6a320782fbfb312e1f4d520e6c6fae155ee" +dependencies = [ + "semver 0.11.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver 1.0.28", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "serdect", + "subtle", + "zeroize", +] + +[[package]] +name = "secp256k1" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" +dependencies = [ + "bitcoin_hashes", + "rand 0.8.8", + "secp256k1-sys 0.10.1", + "serde", +] + +[[package]] +name = "secp256k1" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3c81b43dc2d8877c216a3fccf76677ee1ebccd429566d3e67447290d0c42b2" +dependencies = [ + "bitcoin_hashes", + "rand 0.9.5", + "secp256k1-sys 0.11.0", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + +[[package]] +name = "secp256k1-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcb913707158fadaf0d8702c2db0e857de66eb003ccfdda5924b5f5ac98efb38" +dependencies = [ + "cc", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f301af10236f6df4160f7c3f04eec6dbc70ace82d23326abad5edee88801c6b6" +dependencies = [ + "semver-parser", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "semver-parser" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9900206b54a3527fdc7b8a938bffd94a568bac4f4aa8113b209df75a09c0dec2" +dependencies = [ + "pest", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_with" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +dependencies = [ + "base64", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serdect" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha3" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +dependencies = [ + "digest 0.11.3", + "keccak", +] + +[[package]] +name = "sha3-asm" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6287fd675f713484342a89cbf0a386abef5f15919cfad607e5e1f19e1e15331" +dependencies = [ + "cc", + "cfg-if", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version 0.4.1", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn-solidity" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6415502cd1e9ed58b3ceb415164b812d5572757b1a6f0e280ae806723c1fab" +dependencies = [ + "paste", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "threadpool" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" +dependencies = [ + "num_cpus", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "uint" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f64bba2c53b04fcab63c01a7d7427eadc821e3bc48c34dc9ba29c501164b52" +dependencies = [ + "byteorder", + "crunchy", + "hex", + "static_assertions", +] + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-joining-type" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8d00a78170970967fdb83f9d49b92f959ab2bb829186b113e4f4604ad98e180" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasmtimer" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c598d6b99ea013e35844697fc4670d08339d5cda15588f193c6beedd12f644b" +dependencies = [ + "futures", + "js-sys", + "parking_lot", + "pin-utils", + "slab", + "wasm-bindgen", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/forge/tests/fixtures/bind/Cargo.lock b/crates/forge/tests/fixtures/bind/Cargo.lock new file mode 100644 index 0000000000000..fd318f6dba3d4 --- /dev/null +++ b/crates/forge/tests/fixtures/bind/Cargo.lock @@ -0,0 +1,4356 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "alloy" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50ab0cd8afe573d1f7dc2353698a51b1f93aec362c8211e28cfd3948c6adba39" +dependencies = [ + "alloy-consensus", + "alloy-contract", + "alloy-core", + "alloy-eips", + "alloy-genesis", + "alloy-network", + "alloy-provider", + "alloy-rpc-client", + "alloy-rpc-types", + "alloy-serde", + "alloy-signer", + "alloy-signer-local", + "alloy-transport", + "alloy-transport-http", + "alloy-trie", +] + +[[package]] +name = "alloy-chains" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fdcfed8f106be3df944054aaa42bc13ae103a3ac8a9f4b08d4f053e3a743f8" +dependencies = [ + "alloy-primitives", + "num_enum", + "phf", +] + +[[package]] +name = "alloy-consensus" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f16daaf7e1f95f62c6c3bf8a3fc3d78b08ae9777810c0bb5e94966c7cd57ef0" +dependencies = [ + "alloy-eips", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "alloy-trie", + "alloy-tx-macros", + "auto_impl", + "borsh", + "c-kzg", + "derive_more", + "either", + "k256", + "once_cell", + "rand 0.8.8", + "secp256k1 0.30.0", + "serde", + "serde_json", + "serde_with", + "thiserror", +] + +[[package]] +name = "alloy-consensus-any" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "118998d9015332ab1b4720ae1f1e3009491966a0349938a1f43ff45a8a4c6299" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "serde", +] + +[[package]] +name = "alloy-contract" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ac9e0c34dc6bce643b182049cdfcca1b8ce7d9c260cbdd561f511873b7e26cd" +dependencies = [ + "alloy-consensus", + "alloy-dyn-abi", + "alloy-json-abi", + "alloy-network", + "alloy-network-primitives", + "alloy-primitives", + "alloy-provider", + "alloy-rpc-types-eth", + "alloy-sol-types", + "alloy-transport", + "futures", + "futures-util", + "serde_json", + "thiserror", + "tracing", +] + +[[package]] +name = "alloy-core" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa2d25cf04344ea5eeb47e0cd21c794e646a029959bf5700bd8c05342c0353fe" +dependencies = [ + "alloy-dyn-abi", + "alloy-json-abi", + "alloy-primitives", + "alloy-rlp", + "alloy-sol-types", +] + +[[package]] +name = "alloy-dyn-abi" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "201b9e973fe90b2effd9ab356d4f2a46ab56046ba9d46f163367553e72c045b0" +dependencies = [ + "alloy-json-abi", + "alloy-primitives", + "alloy-sol-type-parser", + "alloy-sol-types", + "itoa", + "serde", + "serde_json", + "winnow", +] + +[[package]] +name = "alloy-eip2124" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "741bdd7499908b3aa0b159bba11e71c8cddd009a2c2eb7a06e825f1ec87900a5" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "crc", + "serde", + "thiserror", +] + +[[package]] +name = "alloy-eip2930" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64579d931b3f8eacc7c9ab0b220e87e9c4816e5c724ede1947b55c2f8e92ae5" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "borsh", + "serde", +] + +[[package]] +name = "alloy-eip7702" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2919c5a56a1007492da313e7a3b6d45ef5edc5d33416fdec63c0d7a2702a0d20" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "borsh", + "serde", + "thiserror", +] + +[[package]] +name = "alloy-eip7928" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b827a6d7784fe3eb3489d40699407a4cdcce74271421a01bdffe60cf573bb16" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "borsh", + "once_cell", + "serde", + "thiserror", +] + +[[package]] +name = "alloy-eips" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6ef28c9fdad22d4eec52d894f5f2673a0895f1e5ef196734568e68c0f6caca8" +dependencies = [ + "alloy-eip2124", + "alloy-eip2930", + "alloy-eip7702", + "alloy-eip7928", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "auto_impl", + "borsh", + "c-kzg", + "derive_more", + "either", + "serde", + "serde_with", + "sha2", +] + +[[package]] +name = "alloy-genesis" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbf9480307b09d22876efb67d30cadd9013134c21f3a17ec9f93fd7536d38024" +dependencies = [ + "alloy-eips", + "alloy-primitives", + "alloy-serde", + "alloy-trie", + "borsh", + "serde", + "serde_with", +] + +[[package]] +name = "alloy-json-abi" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dba4e59c3581a39e03e0b0b4a46ee9c41315b5d843b1e903271952b32bedb7c" +dependencies = [ + "alloy-primitives", + "alloy-sol-type-parser", + "serde", + "serde_json", +] + +[[package]] +name = "alloy-json-rpc" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "422d110f1c40f1f8d0e5562b0b649c35f345fccb7093d9f02729943dcd1eef71" +dependencies = [ + "alloy-primitives", + "alloy-sol-types", + "http", + "serde", + "serde_json", + "thiserror", + "tracing", +] + +[[package]] +name = "alloy-network" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7197a66d94c4de1591cdc16a9bcea5f8cccd0da81b865b49aef97b1b4016e0fa" +dependencies = [ + "alloy-consensus", + "alloy-consensus-any", + "alloy-eips", + "alloy-json-rpc", + "alloy-network-primitives", + "alloy-primitives", + "alloy-rpc-types-any", + "alloy-rpc-types-eth", + "alloy-serde", + "alloy-signer", + "alloy-sol-types", + "async-trait", + "auto_impl", + "derive_more", + "futures-utils-wasm", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "alloy-network-primitives" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb82711d59a43fdfd79727c99f270b974c784ec4eb5728a0d0d22f26716c87ef" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-primitives", + "alloy-serde", + "serde", +] + +[[package]] +name = "alloy-primitives" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce7b00f0cb42c66ec353076ded1dff1fbf818f6e0e26c40c8a8456c04483fca4" +dependencies = [ + "alloy-rlp", + "bytes", + "cfg-if", + "const-hex", + "derive_more", + "fixed-cache", + "foldhash", + "hashbrown 0.17.1", + "indexmap 2.14.0", + "itoa", + "k256", + "keccak-asm", + "paste", + "proptest", + "rand 0.9.5", + "rapidhash", + "ruint", + "rustc-hash", + "secp256k1 0.31.1", + "serde", + "sha3", +] + +[[package]] +name = "alloy-provider" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf6b18b929ef1d078b834c3631e9c925177f3b23ddc6fa08a722d13047205876" +dependencies = [ + "alloy-chains", + "alloy-consensus", + "alloy-eips", + "alloy-json-rpc", + "alloy-network", + "alloy-network-primitives", + "alloy-primitives", + "alloy-rpc-client", + "alloy-rpc-types-eth", + "alloy-signer", + "alloy-sol-types", + "alloy-transport", + "alloy-transport-http", + "async-stream", + "async-trait", + "auto_impl", + "dashmap", + "either", + "futures", + "futures-utils-wasm", + "lru", + "parking_lot", + "pin-project", + "reqwest", + "serde", + "serde_json", + "thiserror", + "tokio", + "tracing", + "url", + "wasmtimer", +] + +[[package]] +name = "alloy-rlp" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24671b1f62edcf0f9b62994c7bf72cd621a04a4b99f5020ece1a647b40e2f103" +dependencies = [ + "alloy-rlp-derive", + "arrayvec", + "bytes", +] + +[[package]] +name = "alloy-rlp-derive" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d4311c03125e8a18296504560b9de3d75ecbd0dcda7f71e6cf2a196d57e6fba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "alloy-rpc-client" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fcc9604042ca80bd37aa5e232ea1cd851f337e31e2babbbb345bc0b1c30de3" +dependencies = [ + "alloy-json-rpc", + "alloy-primitives", + "alloy-transport", + "alloy-transport-http", + "futures", + "pin-project", + "reqwest", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tower", + "tracing", + "url", + "wasmtimer", +] + +[[package]] +name = "alloy-rpc-types" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4faad925d3a669ffc15f43b3deec7fbdf2adeb28a4d6f9cf4bc661698c0f8f4b" +dependencies = [ + "alloy-primitives", + "alloy-rpc-types-eth", + "alloy-serde", + "serde", +] + +[[package]] +name = "alloy-rpc-types-any" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3823026d1ed239a40f12364fac50726c8daf1b6ab8077a97212c5123910429ed" +dependencies = [ + "alloy-consensus-any", + "alloy-rpc-types-eth", + "alloy-serde", +] + +[[package]] +name = "alloy-rpc-types-eth" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59c095f92c4e1ff4981d89e9aa02d5f98c762a1980ab66bec49c44be11349da2" +dependencies = [ + "alloy-consensus", + "alloy-consensus-any", + "alloy-eips", + "alloy-network-primitives", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "alloy-sol-types", + "itertools 0.14.0", + "serde", + "serde_json", + "serde_with", + "thiserror", +] + +[[package]] +name = "alloy-serde" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ece63b89294b8614ab3f483560c08d016930f842bf36da56bf0b764a15c11e" +dependencies = [ + "alloy-primitives", + "serde", + "serde_json", +] + +[[package]] +name = "alloy-signer" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f447aefab0f1c0649f71edc33f590992d4e122bc35fb9cdbbf67d4421ace85" +dependencies = [ + "alloy-primitives", + "async-trait", + "auto_impl", + "either", + "elliptic-curve", + "k256", + "thiserror", +] + +[[package]] +name = "alloy-signer-local" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f721f4bf2e4812e5505aaf5de16ef3065a8e26b9139ac885862d00b5a55a659a" +dependencies = [ + "alloy-consensus", + "alloy-network", + "alloy-primitives", + "alloy-signer", + "async-trait", + "k256", + "rand 0.8.8", + "thiserror", +] + +[[package]] +name = "alloy-sol-macro" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64558980fb038cd34b4285ec2b36a2a8bd8d4ddd13b3f6e42d97cb5ee29938e" +dependencies = [ + "alloy-sol-macro-expander", + "alloy-sol-macro-input", + "proc-macro-error3", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "alloy-sol-macro-expander" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffb0e793abdbaea9d01259493c8272c3af295d03389e70a2acdf53b57ad1edf" +dependencies = [ + "alloy-json-abi", + "alloy-sol-macro-input", + "const-hex", + "heck", + "indexmap 2.14.0", + "proc-macro-error3", + "proc-macro2", + "quote", + "sha3", + "syn 2.0.119", + "syn-solidity", +] + +[[package]] +name = "alloy-sol-macro-input" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32c2c0ec8425d9663dac939ba75750f17d2933d2f020814b4f8fde4a60da6d26" +dependencies = [ + "alloy-json-abi", + "const-hex", + "dunce", + "heck", + "macro-string", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.119", + "syn-solidity", +] + +[[package]] +name = "alloy-sol-type-parser" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b37db6a7ad8170596345f864522f789cc7af093f516d6cdf34bbdcfba8adab" +dependencies = [ + "serde", + "winnow", +] + +[[package]] +name = "alloy-sol-types" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f40e33a0f588dde548c3b6767a71e61b593421a8c1b253b9cf1441dc7cf7ab5" +dependencies = [ + "alloy-json-abi", + "alloy-primitives", + "alloy-sol-macro", + "serde", +] + +[[package]] +name = "alloy-transport" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8098f965442a9feb620965ba4b4be5e2b320f4ec5a3fff6bfa9e1ff7ef42bed1" +dependencies = [ + "alloy-json-rpc", + "auto_impl", + "base64", + "derive_more", + "futures", + "futures-utils-wasm", + "parking_lot", + "serde", + "serde_json", + "thiserror", + "tokio", + "tower", + "tracing", + "url", + "wasmtimer", +] + +[[package]] +name = "alloy-transport-http" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8597d36d546e1dab822345ad563243ec3920e199322cb554ce56c8ef1a1e2e7" +dependencies = [ + "alloy-json-rpc", + "alloy-transport", + "itertools 0.14.0", + "reqwest", + "serde_json", + "tower", + "tracing", + "url", +] + +[[package]] +name = "alloy-trie" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f14b5d9b2c2173980202c6ff470d96e7c5e202c65a9f67884ad565226df7fbb" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "derive_more", + "nybbles", + "serde", + "smallvec", + "thiserror", + "tracing", +] + +[[package]] +name = "alloy-tx-macros" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d69722eddcdf1ce096c3ab66cf8116999363f734eb36fe94a148f4f71c85da84" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "ark-ff" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b3235cc41ee7a12aaaf2c575a2ad7b46713a8a50bda2fc3b003a04845c05dd6" +dependencies = [ + "ark-ff-asm 0.3.0", + "ark-ff-macros 0.3.0", + "ark-serialize 0.3.0", + "ark-std 0.3.0", + "derivative", + "num-bigint", + "num-traits", + "paste", + "rustc_version 0.3.3", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec847af850f44ad29048935519032c33da8aa03340876d351dfab5660d2966ba" +dependencies = [ + "ark-ff-asm 0.4.2", + "ark-ff-macros 0.4.2", + "ark-serialize 0.4.2", + "ark-std 0.4.0", + "derivative", + "digest 0.10.7", + "itertools 0.10.5", + "num-bigint", + "num-traits", + "paste", + "rustc_version 0.4.1", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a177aba0ed1e0fbb62aa9f6d0502e9b46dad8c2eab04c14258a1212d2557ea70" +dependencies = [ + "ark-ff-asm 0.5.0", + "ark-ff-macros 0.5.0", + "ark-serialize 0.5.0", + "ark-std 0.5.0", + "arrayvec", + "digest 0.10.7", + "educe", + "itertools 0.13.0", + "num-bigint", + "num-traits", + "paste", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7a806ac6c8307b929df4645776290a50ee2aac754ad09d8bdf73391309e43af" +dependencies = [ + "ark-ff-asm 0.6.0", + "ark-ff-macros 0.6.0", + "ark-serialize 0.6.0", + "ark-std 0.6.0", + "digest 0.10.7", + "educe", + "num-bigint", + "num-traits", + "zeroize", +] + +[[package]] +name = "ark-ff-asm" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db02d390bf6643fb404d3d22d31aee1c4bc4459600aef9113833d17e786c6e44" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-asm" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed4aa4fe255d0bc6d79373f7e31d2ea147bcf486cba1be5ba7ea85abdb92348" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-asm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ark-ff-asm" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1479009684adc073dff49a1025d3a7065b317a9ead25aaaca38cdc70058ba8a2" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ark-ff-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fd794a08ccb318058009eefdf15bcaaaaf6f8161eb3345f907222bac38b20" +dependencies = [ + "num-bigint", + "num-traits", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ark-ff-macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0691ed21ef00ef89c1e9bda832eba493dda3ec2f8d892fb25b705f73f06bb8" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ark-serialize" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d6c2b318ee6e10f8c2853e73a83adc0ccb88995aa978d8a3408d492ab2ee671" +dependencies = [ + "ark-std 0.3.0", + "digest 0.9.0", +] + +[[package]] +name = "ark-serialize" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" +dependencies = [ + "ark-std 0.4.0", + "digest 0.10.7", + "num-bigint", +] + +[[package]] +name = "ark-serialize" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7" +dependencies = [ + "ark-std 0.5.0", + "arrayvec", + "digest 0.10.7", + "num-bigint", +] + +[[package]] +name = "ark-serialize" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a74dd304fd536fb95d0a328e72be759209cc496a9da094c5bc56e5fea4f9e86b" +dependencies = [ + "ark-serialize-derive", + "ark-std 0.6.0", + "digest 0.10.7", + "num-bigint", + "serde_with", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f153690697a2b91e5e1251ff98411ee5371500a111a0fd317a70e588eb300f9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ark-std" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1df2c09229cbc5a028b1d70e00fdb2acee28b1055dfb5ca73eea49c5a25c4e7c" +dependencies = [ + "num-traits", + "rand 0.8.8", +] + +[[package]] +name = "ark-std" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" +dependencies = [ + "num-traits", + "rand 0.8.8", +] + +[[package]] +name = "ark-std" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a" +dependencies = [ + "num-traits", + "rand 0.8.8", +] + +[[package]] +name = "ark-std" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "367c9c827ed431bff6868b7aa926e05b16eb46603cc8b6e768e4a5553fa1d155" +dependencies = [ + "num-traits", + "rand 0.8.8", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "auto_impl" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitcoin-consensus-encoding" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6712f9c6fd6785b3b270884e57c441c403dc5d7e19ca45368c97c7a1de3000ec" +dependencies = [ + "bitcoin-internals", + "hex-conservative 1.2.0", + "serde", +] + +[[package]] +name = "bitcoin-internals" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5" + +[[package]] +name = "bitcoin-io" +version = "0.1.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb5de036369d1ac59d3c1819ebc4d850f89466f5401c571a285b6ed564a4cb78" +dependencies = [ + "bitcoin-consensus-encoding", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" +dependencies = [ + "bitcoin-io", + "hex-conservative 0.2.2", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "blst" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c20659f9bbee16cbbd2f7393e40ab6309f5a98f76a2eb57a995ec508b72387fe" +dependencies = [ + "cc", + "glob", + "threadpool", + "zeroize", +] + +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byte-slice-cast" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "c-kzg" +version = "2.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d04308254695569fdb9bfe3bacc1c91837a670d0806605eb82d63748fbd3a6" +dependencies = [ + "blst", + "cc", + "glob", + "hex", + "libc", + "once_cell", + "serde", +] + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-hex" +version = "1.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e2a781ebdf4467d1428dc4593067825fb646f6871475098d8577421af73558" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "proptest", + "serde_core", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "serde", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version 0.4.1", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "serdect", + "signature", + "spki", +] + +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +dependencies = [ + "serde", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "serdect", + "subtle", + "zeroize", +] + +[[package]] +name = "enum-ordinalize" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89dd01549b09589510cf0647475075d12071456586d70f5c75c98ae2a5537677" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fastrlp" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139834ddba373bbdd213dffe02c8d110508dcf1726c2be27e8d1f7d7e1856418" +dependencies = [ + "arrayvec", + "auto_impl", + "bytes", +] + +[[package]] +name = "fastrlp" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce8dba4714ef14b8274c371879b175aa55b16b30f269663f19d576f380018dc4" +dependencies = [ + "arrayvec", + "auto_impl", + "bytes", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "fixed-cache" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fe63500644ef0269fe6b744e7e5dc5c20b5eebf3d881bc2be53f194636f6583" +dependencies = [ + "equivalent", + "rapidhash", +] + +[[package]] +name = "fixed-hash" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534" +dependencies = [ + "byteorder", + "rand 0.8.8", + "rustc-hex", + "static_assertions", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "foundry-contracts" +version = "0.1.0" +dependencies = [ + "alloy", + "serde", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "futures-utils-wasm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42012b0f064e01aa58b545fe3727f90f7dd4020f4a3ea735b50344965f5a57e9" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", + "serde", + "serde_core", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex-conservative" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35431185f361ccf3ffc58254628af5f1f5d5f28531da2e02e5d6c82bbc282a10" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "279259b0ac81c89d11c290495fdcfa96ea3643b7df311c138b6fe8ca5237f0f8" +dependencies = [ + "idna_mapping", + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "idna_mapping" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11c13906586a4b339310541a274dd927aff6fcbb5b8e3af90634c4b31681c792" +dependencies = [ + "unicode-joining-type", +] + +[[package]] +name = "impl-codec" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba6a270039626615617f3f36d15fc827041df3b78c439da2cadfa47455a77f2f" +dependencies = [ + "parity-scale-codec", +] + +[[package]] +name = "impl-trait-for-tuples" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version 0.4.1", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "once_cell", + "serdect", + "sha2", +] + +[[package]] +name = "keccak" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", +] + +[[package]] +name = "keccak-asm" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5dc2c0d691cbf7595cde551ced329cca99c2387c2cbc97754c5d0cd045d3ee" +dependencies = [ + "digest 0.10.7", + "sha3-asm", +] + +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "macro-string" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a9dbbfc75d2688ed057456ce8a3ee3f48d12eec09229f560f3643b9f275653" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "nybbles" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d49ff0c0d00d4a502b39df9af3a525e1efeb14b9dabb5bb83335284c1309210" +dependencies = [ + "alloy-rlp", + "cfg-if", + "proptest", + "ruint", + "serde", + "smallvec", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "parity-scale-codec" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799781ae679d79a948e13d4824a40970bfa500058d245760dd857301059810fa" +dependencies = [ + "arrayvec", + "bitvec", + "byte-slice-cast", + "const_format", + "impl-trait-for-tuples", + "parity-scale-codec-derive", + "rustversion", + "serde", +] + +[[package]] +name = "parity-scale-codec-derive" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b4653168b563151153c9e4c08ebed57fb8262bebfa79711552fa983c623e7a" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "primitive-types" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b34d9fd68ae0b74a41b21c03c2f62847aa0ffea044eee893b4c140b37e244e2" +dependencies = [ + "fixed-hash", + "impl-codec", + "uint", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro-error-attr3" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82366fd7d8b7a440d66d13418820c69df9b3908bcb1a0476d7f5ce5d12f5a04d" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error3" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b511283ea8a74b4b39447b128c5d00f03a356b7424554b13e298a5550100d9ac" +dependencies = [ + "proc-macro-error-attr3", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bitflags 2.13.1", + "num-traits", + "rand 0.9.5", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "unarray", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", + "serde", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", + "serde", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", + "serde", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "rapidhash" +version = "4.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e" +dependencies = [ + "rustversion", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rlp" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb919243f34364b6bd2fc10ef797edbfa75f33c252e7998527479c6d6b47e1ec" +dependencies = [ + "bytes", + "rustc-hex", +] + +[[package]] +name = "ruint" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5e99bff0393163bb25029a6af25d3d8d202ba5b5438a74d1bd8789f5c822970" +dependencies = [ + "alloy-rlp", + "ark-ff 0.3.0", + "ark-ff 0.4.2", + "ark-ff 0.5.0", + "ark-ff 0.6.0", + "bytes", + "fastrlp 0.3.1", + "fastrlp 0.4.0", + "num-bigint", + "num-integer", + "num-traits", + "parity-scale-codec", + "primitive-types", + "proptest", + "rand 0.8.8", + "rand 0.9.5", + "rlp", + "ruint-macro", + "serde_core", + "valuable", + "zeroize", +] + +[[package]] +name = "ruint-macro" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc-hex" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e75f6a532d0fd9f7f13144f392b6ad56a32696bfcd9c78f797f16bbb6f072d6" + +[[package]] +name = "rustc_version" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0dfe2087c51c460008730de8b57e6a320782fbfb312e1f4d520e6c6fae155ee" +dependencies = [ + "semver 0.11.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver 1.0.28", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "serdect", + "subtle", + "zeroize", +] + +[[package]] +name = "secp256k1" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" +dependencies = [ + "bitcoin_hashes", + "rand 0.8.8", + "secp256k1-sys 0.10.1", + "serde", +] + +[[package]] +name = "secp256k1" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3c81b43dc2d8877c216a3fccf76677ee1ebccd429566d3e67447290d0c42b2" +dependencies = [ + "bitcoin_hashes", + "rand 0.9.5", + "secp256k1-sys 0.11.0", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + +[[package]] +name = "secp256k1-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcb913707158fadaf0d8702c2db0e857de66eb003ccfdda5924b5f5ac98efb38" +dependencies = [ + "cc", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f301af10236f6df4160f7c3f04eec6dbc70ace82d23326abad5edee88801c6b6" +dependencies = [ + "semver-parser", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "semver-parser" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9900206b54a3527fdc7b8a938bffd94a568bac4f4aa8113b209df75a09c0dec2" +dependencies = [ + "pest", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_with" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +dependencies = [ + "base64", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serdect" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" +dependencies = [ + "base16ct", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha3" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +dependencies = [ + "digest 0.11.3", + "keccak", +] + +[[package]] +name = "sha3-asm" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6287fd675f713484342a89cbf0a386abef5f15919cfad607e5e1f19e1e15331" +dependencies = [ + "cc", + "cfg-if", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version 0.4.1", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn-solidity" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6415502cd1e9ed58b3ceb415164b812d5572757b1a6f0e280ae806723c1fab" +dependencies = [ + "paste", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "threadpool" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" +dependencies = [ + "num_cpus", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "uint" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f64bba2c53b04fcab63c01a7d7427eadc821e3bc48c34dc9ba29c501164b52" +dependencies = [ + "byteorder", + "crunchy", + "hex", + "static_assertions", +] + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-joining-type" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8d00a78170970967fdb83f9d49b92f959ab2bb829186b113e4f4604ad98e180" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasmtimer" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c598d6b99ea013e35844697fc4670d08339d5cda15588f193c6beedd12f644b" +dependencies = [ + "futures", + "js-sys", + "parking_lot", + "pin-utils", + "slab", + "wasm-bindgen", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/forge/tests/fixtures/colored_traces.svg b/crates/forge/tests/fixtures/colored_traces.svg index e4181676bcc86..d89cd4b4cef5f 100644 --- a/crates/forge/tests/fixtures/colored_traces.svg +++ b/crates/forge/tests/fixtures/colored_traces.svg @@ -1,9 +1,9 @@ - + , - ) -> Self { - let mut disabled_ranges: HashMap> = HashMap::new(); - let mut disabled_blocks: HashMap = HashMap::new(); - - let mut prev_sp = Span::DUMMY; - for (sp, item) in items { - if cfg!(debug_assertions) { - assert!(sp >= prev_sp, "InlineConfig::new: unsorted items: {sp:?} < {prev_sp:?}"); - prev_sp = sp; - } - - let Ok((file, comment_range)) = source_map.span_to_source(sp) else { continue }; - let src = file.src.as_str(); - match item { - InlineConfigItem::DisableNextItem(lints) => { - if let Some(next_item) = find_next_item(sp.hi().to_usize()) { - for lint in lints { - disabled_ranges.entry(lint).or_default().push(DisabledRange { - start: next_item.lo().to_usize(), - end: next_item.hi().to_usize(), - loose: false, - }); - } - } - } - InlineConfigItem::DisableLine(lints) => { - let start = src[..comment_range.start].rfind('\n').map_or(0, |i| i); - let end = src[comment_range.end..] - .find('\n') - .map_or(src.len(), |i| comment_range.end + i); - - for lint in lints { - disabled_ranges.entry(lint).or_default().push(DisabledRange { - start: start + file.start_pos.to_usize(), - end: end + file.start_pos.to_usize(), - loose: false, - }) - } - } - InlineConfigItem::DisableNextLine(lints) => { - if let Some(offset) = src[comment_range.end..].find('\n') { - let start = comment_range.end + offset + 1; - if start < src.len() { - let end = src[start..].find('\n').map_or(src.len(), |i| start + i); - for lint in lints { - disabled_ranges.entry(lint).or_default().push(DisabledRange { - start: start + file.start_pos.to_usize(), - end: end + file.start_pos.to_usize(), - loose: false, - }) - } - } - } - } - InlineConfigItem::DisableStart(lints) => { - for lint in lints { - disabled_blocks - .entry(lint) - .and_modify(|(_, depth, _)| *depth += 1) - .or_insert(( - sp.hi().to_usize(), - 1, - // Use file end as fallback for unclosed blocks - file.start_pos.to_usize() + src.len(), - )); - } - } - InlineConfigItem::DisableEnd(lints) => { - for lint in lints { - if let Some((start, depth, _)) = disabled_blocks.get_mut(&lint) { - *depth = depth.saturating_sub(1); - - if *depth == 0 { - let start = *start; - _ = disabled_blocks.remove(&lint); - - disabled_ranges.entry(lint).or_default().push(DisabledRange { - start, - end: sp.lo().to_usize(), - loose: false, - }) - } - } - } - } - } - } - - for (lint, (start, _, file_end)) in disabled_blocks { - disabled_ranges.entry(lint).or_default().push(DisabledRange { - start, - end: file_end, - loose: false, - }); - } - - Self { disabled_ranges } - } - - /// Check if the lint location is in a disabled range. - #[inline] - pub fn is_disabled(&self, span: Span, lint: &str) -> bool { - if let Some(ranges) = self.disabled_ranges.get(lint) { - return ranges.iter().any(|range| range.includes(span.to_range())); - } - - if let Some(ranges) = self.disabled_ranges.get("all") { - return ranges.iter().any(|range| range.includes(span.to_range())); - } - - false - } -} - -/// An AST visitor that finds the first `Item` that starts after a given offset. -#[derive(Debug, Default)] -struct NextItemFinderAst<'ast> { - /// The offset to search after. - offset: usize, - _pd: PhantomData<&'ast ()>, -} - -impl<'ast> NextItemFinderAst<'ast> { - fn new(offset: usize) -> Self { - Self { offset, _pd: PhantomData } - } - - /// Finds the next AST item which a span that begins after the `offset`. - fn find(&mut self, ast: &'ast SourceUnit<'ast>) -> Option { - match self.visit_source_unit(ast) { - ControlFlow::Break(span) => Some(span), - ControlFlow::Continue(()) => None, - } - } -} - -impl<'ast> VisitAst<'ast> for NextItemFinderAst<'ast> { - type BreakValue = Span; - - fn visit_item(&mut self, item: &'ast Item<'ast>) -> ControlFlow { - // Check if this item starts after the offset. - if item.span.lo().to_usize() > self.offset { - return ControlFlow::Break(item.span); - } - - // Otherwise, continue traversing inside this item. - self.walk_item(item) - } -} - -/// A HIR visitor that finds the first `Item` that starts after a given offset. -#[derive(Debug)] -struct NextItemFinderHir<'hir> { - hir: &'hir hir::Hir<'hir>, - /// The offset to search after. - offset: usize, -} - -impl<'hir> NextItemFinderHir<'hir> { - fn new(offset: usize, hir: &'hir hir::Hir<'hir>) -> Self { - Self { offset, hir } - } - - /// Finds the next HIR item which a span that begins after the `offset`. - fn find(&mut self, id: hir::SourceId) -> Option { - match self.visit_nested_source(id) { - ControlFlow::Break(span) => Some(span), - ControlFlow::Continue(()) => None, - } - } -} - -impl<'hir> VisitHir<'hir> for NextItemFinderHir<'hir> { - type BreakValue = Span; - - fn hir(&self) -> &'hir hir::Hir<'hir> { - self.hir - } - - fn visit_item(&mut self, item: hir::Item<'hir, 'hir>) -> ControlFlow { - // Check if this item starts after the offset. - if item.span().lo().to_usize() > self.offset { - return ControlFlow::Break(item.span()); - } - - // If the item is before the offset, skip traverse. - if item.span().hi().to_usize() < self.offset { - return ControlFlow::Continue(()); - } - - // Otherwise, continue traversing inside this item. - self.walk_item(item) - } -} diff --git a/crates/lint/src/lib.rs b/crates/lint/src/lib.rs index 14d34cc30b306..3a43fe520f7e4 100644 --- a/crates/lint/src/lib.rs +++ b/crates/lint/src/lib.rs @@ -1,7 +1,7 @@ #![doc = include_str!("../README.md")] #![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] +#![cfg_attr(docsrs, feature(doc_cfg))] +#![allow(elided_lifetimes_in_paths)] -pub mod inline_config; pub mod linter; pub mod sol; diff --git a/crates/lint/src/linter/early.rs b/crates/lint/src/linter/early.rs deleted file mode 100644 index ada64ee853eb3..0000000000000 --- a/crates/lint/src/linter/early.rs +++ /dev/null @@ -1,163 +0,0 @@ -use solar_ast::{self as ast, visit::Visit}; -use solar_interface::data_structures::Never; -use std::ops::ControlFlow; - -use super::LintContext; - -/// Trait for lints that operate directly on the AST. -/// Its methods mirror `ast::visit::Visit`, with the addition of `LintCotext`. -pub trait EarlyLintPass<'ast>: Send + Sync { - fn check_expr(&mut self, _ctx: &LintContext<'_>, _expr: &'ast ast::Expr<'ast>) {} - fn check_item_struct(&mut self, _ctx: &LintContext<'_>, _struct: &'ast ast::ItemStruct<'ast>) {} - fn check_item_function( - &mut self, - _ctx: &LintContext<'_>, - _func: &'ast ast::ItemFunction<'ast>, - ) { - } - fn check_variable_definition( - &mut self, - _ctx: &LintContext<'_>, - _var: &'ast ast::VariableDefinition<'ast>, - ) { - } - fn check_import_directive( - &mut self, - _ctx: &LintContext<'_>, - _import: &'ast ast::ImportDirective<'ast>, - ) { - } - fn check_using_directive( - &mut self, - _ctx: &LintContext<'_>, - _using: &'ast ast::UsingDirective<'ast>, - ) { - } - fn check_item_contract( - &mut self, - _ctx: &LintContext<'_>, - _contract: &'ast ast::ItemContract<'ast>, - ) { - } - fn check_doc_comment(&mut self, _ctx: &LintContext<'_>, _cmnt: &'ast ast::DocComment) {} - // TODO: Add methods for each required AST node type - - /// Should be called after the source unit has been visited. Enables lints that require - /// knowledge of the entire AST to perform their analysis. - fn check_full_source_unit( - &mut self, - _ctx: &LintContext<'ast>, - _ast: &'ast ast::SourceUnit<'ast>, - ) { - } -} - -/// Visitor struct for `EarlyLintPass`es -pub struct EarlyLintVisitor<'a, 's, 'ast> { - pub ctx: &'a LintContext<'s>, - pub passes: &'a mut [Box + 's>], -} - -impl<'a, 's, 'ast> EarlyLintVisitor<'a, 's, 'ast> -where - 's: 'ast, -{ - pub fn new( - ctx: &'a LintContext<'s>, - passes: &'a mut [Box + 's>], - ) -> Self { - Self { ctx, passes } - } - - /// Extends the [`Visit`] trait functionality with a hook that can run after the initial - /// traversal. - pub fn post_source_unit(&mut self, ast: &'ast ast::SourceUnit<'ast>) { - for pass in self.passes.iter_mut() { - pass.check_full_source_unit(self.ctx, ast); - } - } -} - -impl<'s, 'ast> Visit<'ast> for EarlyLintVisitor<'_, 's, 'ast> -where - 's: 'ast, -{ - type BreakValue = Never; - - fn visit_doc_comment(&mut self, cmnt: &'ast ast::DocComment) -> ControlFlow { - for pass in self.passes.iter_mut() { - pass.check_doc_comment(self.ctx, cmnt) - } - self.walk_doc_comment(cmnt) - } - - fn visit_expr(&mut self, expr: &'ast ast::Expr<'ast>) -> ControlFlow { - for pass in self.passes.iter_mut() { - pass.check_expr(self.ctx, expr) - } - self.walk_expr(expr) - } - - fn visit_variable_definition( - &mut self, - var: &'ast ast::VariableDefinition<'ast>, - ) -> ControlFlow { - for pass in self.passes.iter_mut() { - pass.check_variable_definition(self.ctx, var) - } - self.walk_variable_definition(var) - } - - fn visit_item_struct( - &mut self, - strukt: &'ast ast::ItemStruct<'ast>, - ) -> ControlFlow { - for pass in self.passes.iter_mut() { - pass.check_item_struct(self.ctx, strukt) - } - self.walk_item_struct(strukt) - } - - fn visit_item_function( - &mut self, - func: &'ast ast::ItemFunction<'ast>, - ) -> ControlFlow { - for pass in self.passes.iter_mut() { - pass.check_item_function(self.ctx, func) - } - self.walk_item_function(func) - } - - fn visit_import_directive( - &mut self, - import: &'ast ast::ImportDirective<'ast>, - ) -> ControlFlow { - for pass in self.passes.iter_mut() { - pass.check_import_directive(self.ctx, import); - } - self.walk_import_directive(import) - } - - fn visit_using_directive( - &mut self, - using: &'ast ast::UsingDirective<'ast>, - ) -> ControlFlow { - for pass in self.passes.iter_mut() { - pass.check_using_directive(self.ctx, using); - } - self.walk_using_directive(using) - } - - fn visit_item_contract( - &mut self, - contract: &'ast ast::ItemContract<'ast>, - ) -> ControlFlow { - for pass in self.passes.iter_mut() { - pass.check_item_contract(self.ctx, contract); - } - self.walk_item_contract(contract) - } - - // TODO: Add methods for each required AST node type, mirroring `solar_ast::visit::Visit` method - // sigs + adding `LintContext` -} diff --git a/crates/lint/src/linter/late.rs b/crates/lint/src/linter/late.rs deleted file mode 100644 index 597f228b92e34..0000000000000 --- a/crates/lint/src/linter/late.rs +++ /dev/null @@ -1,198 +0,0 @@ -use solar_interface::data_structures::Never; -use solar_sema::hir; -use std::ops::ControlFlow; - -use super::LintContext; - -/// Trait for lints that operate on the HIR (High-level Intermediate Representation). -/// Its methods mirror `hir::visit::Visit`, with the addition of `LintCotext`. -pub trait LateLintPass<'hir>: Send + Sync { - fn check_nested_source( - &mut self, - _ctx: &LintContext<'_>, - _hir: &'hir hir::Hir<'hir>, - _id: hir::SourceId, - ) { - } - fn check_nested_item( - &mut self, - _ctx: &LintContext<'_>, - _hir: &'hir hir::Hir<'hir>, - _id: &'hir hir::ItemId, - ) { - } - fn check_nested_contract( - &mut self, - _ctx: &LintContext<'_>, - _hir: &'hir hir::Hir<'hir>, - _id: &'hir hir::ContractId, - ) { - } - fn check_nested_function( - &mut self, - _ctx: &LintContext<'_>, - _hir: &'hir hir::Hir<'hir>, - _id: &'hir hir::FunctionId, - ) { - } - fn check_nested_var( - &mut self, - _ctx: &LintContext<'_>, - _hir: &'hir hir::Hir<'hir>, - _id: &'hir hir::VariableId, - ) { - } - fn check_item( - &mut self, - _ctx: &LintContext<'_>, - _hir: &'hir hir::Hir<'hir>, - _item: hir::Item<'hir, 'hir>, - ) { - } - fn check_contract( - &mut self, - _ctx: &LintContext<'_>, - _hir: &'hir hir::Hir<'hir>, - _contract: &'hir hir::Contract<'hir>, - ) { - } - fn check_function( - &mut self, - _ctx: &LintContext<'_>, - _hir: &'hir hir::Hir<'hir>, - _func: &'hir hir::Function<'hir>, - ) { - } - fn check_modifier( - &mut self, - _ctx: &LintContext<'_>, - _hir: &'hir hir::Hir<'hir>, - _mod: &'hir hir::Modifier<'hir>, - ) { - } - fn check_var( - &mut self, - _ctx: &LintContext<'_>, - _hir: &'hir hir::Hir<'hir>, - _var: &'hir hir::Variable<'hir>, - ) { - } - fn check_expr( - &mut self, - _ctx: &LintContext<'_>, - _hir: &'hir hir::Hir<'hir>, - _expr: &'hir hir::Expr<'hir>, - ) { - } - fn check_call_args( - &mut self, - _ctx: &LintContext<'_>, - _hir: &'hir hir::Hir<'hir>, - _args: &'hir hir::CallArgs<'hir>, - ) { - } - fn check_stmt( - &mut self, - _ctx: &LintContext<'_>, - _hir: &'hir hir::Hir<'hir>, - _stmt: &'hir hir::Stmt<'hir>, - ) { - } - fn check_ty( - &mut self, - _ctx: &LintContext<'_>, - _hir: &'hir hir::Hir<'hir>, - _ty: &'hir hir::Type<'hir>, - ) { - } -} - -/// Visitor struct for `LateLintPass`es -pub struct LateLintVisitor<'a, 's, 'hir> { - ctx: &'a LintContext<'s>, - passes: &'a mut [Box + 's>], - hir: &'hir hir::Hir<'hir>, -} - -impl<'a, 's, 'hir> LateLintVisitor<'a, 's, 'hir> -where - 's: 'hir, -{ - pub fn new( - ctx: &'a LintContext<'s>, - passes: &'a mut [Box + 's>], - hir: &'hir hir::Hir<'hir>, - ) -> Self { - Self { ctx, passes, hir } - } -} - -impl<'s, 'hir> hir::Visit<'hir> for LateLintVisitor<'_, 's, 'hir> -where - 's: 'hir, -{ - type BreakValue = Never; - - fn hir(&self) -> &'hir hir::Hir<'hir> { - self.hir - } - - fn visit_nested_source(&mut self, id: hir::SourceId) -> ControlFlow { - for pass in self.passes.iter_mut() { - pass.check_nested_source(self.ctx, self.hir, id); - } - self.walk_nested_source(id) - } - - fn visit_contract( - &mut self, - contract: &'hir hir::Contract<'hir>, - ) -> ControlFlow { - for pass in self.passes.iter_mut() { - pass.check_contract(self.ctx, self.hir, contract); - } - self.walk_contract(contract) - } - - fn visit_function(&mut self, func: &'hir hir::Function<'hir>) -> ControlFlow { - for pass in self.passes.iter_mut() { - pass.check_function(self.ctx, self.hir, func); - } - self.walk_function(func) - } - - fn visit_item(&mut self, item: hir::Item<'hir, 'hir>) -> ControlFlow { - for pass in self.passes.iter_mut() { - pass.check_item(self.ctx, self.hir, item); - } - self.walk_item(item) - } - - fn visit_var(&mut self, var: &'hir hir::Variable<'hir>) -> ControlFlow { - for pass in self.passes.iter_mut() { - pass.check_var(self.ctx, self.hir, var); - } - self.walk_var(var) - } - - fn visit_expr(&mut self, expr: &'hir hir::Expr<'hir>) -> ControlFlow { - for pass in self.passes.iter_mut() { - pass.check_expr(self.ctx, self.hir, expr); - } - self.walk_expr(expr) - } - - fn visit_stmt(&mut self, stmt: &'hir hir::Stmt<'hir>) -> ControlFlow { - for pass in self.passes.iter_mut() { - pass.check_stmt(self.ctx, self.hir, stmt); - } - self.walk_stmt(stmt) - } - - fn visit_ty(&mut self, ty: &'hir hir::Type<'hir>) -> ControlFlow { - for pass in self.passes.iter_mut() { - pass.check_ty(self.ctx, self.hir, ty); - } - self.walk_ty(ty) - } -} diff --git a/crates/lint/src/linter/mod.rs b/crates/lint/src/linter/mod.rs index dcd86d16e2c6a..95dd1cb3614e3 100644 --- a/crates/lint/src/linter/mod.rs +++ b/crates/lint/src/linter/mod.rs @@ -1,174 +1,22 @@ -mod early; -mod late; - -pub use early::{EarlyLintPass, EarlyLintVisitor}; -pub use late::{LateLintPass, LateLintVisitor}; - use foundry_compilers::Language; -use foundry_config::lint::Severity; -use solar_interface::{ - Session, Span, - diagnostics::{DiagBuilder, DiagId, DiagMsg, MultiSpan, Style}, -}; -use solar_sema::ParsingContext; +use foundry_config::DenyLevel; +use solar::sema::Compiler; use std::path::PathBuf; -use crate::inline_config::InlineConfig; +pub use solar_lint::{ + EarlyLintPass, EarlyLintVisitor, LateLintPass, LateLintVisitor, Lint, LintContext, LintPolicy, + ProjectLintContext as ProjectLintEmitter, ProjectLintPass, ProjectSource, Suggestion, + SuggestionKind, +}; -/// Trait representing a generic linter for analyzing and reporting issues in smart contract source -/// code files. A linter can be implemented for any smart contract language supported by Foundry. -/// -/// # Type Parameters -/// -/// - `Language`: Represents the target programming language. Must implement the [`Language`] trait. -/// - `Lint`: Represents the types of lints performed by the linter. Must implement the [`Lint`] -/// trait. -/// -/// # Required Methods -/// -/// - `init`: Creates a new solar `Session` with the appropriate linter configuration. -/// - `early_lint`: Scans the source files (using the AST) emitting a diagnostic for lints found. -/// - `late_lint`: Scans the source files (using the HIR) emitting a diagnostic for lints found. -pub trait Linter: Send + Sync + Clone { +/// Trait representing a linter for a language supported by Foundry. +pub trait Linter: Send + Sync { + /// The target language. type Language: Language; + /// The lint metadata type. type Lint: Lint; - fn init(&self) -> Session; - fn early_lint<'sess>(&self, input: &[PathBuf], pcx: ParsingContext<'sess>); - fn late_lint<'sess>(&self, input: &[PathBuf], pcx: ParsingContext<'sess>); -} - -pub trait Lint { - fn id(&self) -> &'static str; - fn severity(&self) -> Severity; - fn description(&self) -> &'static str; - fn help(&self) -> &'static str; -} - -pub struct LintContext<'s> { - sess: &'s Session, - with_description: bool, - pub inline_config: InlineConfig, -} - -impl<'s> LintContext<'s> { - pub fn new(sess: &'s Session, with_description: bool, config: InlineConfig) -> Self { - Self { sess, with_description, inline_config: config } - } - - pub fn session(&self) -> &'s Session { - self.sess - } - - /// Helper method to emit diagnostics easily from passes - pub fn emit(&self, lint: &'static L, span: Span) { - if self.inline_config.is_disabled(span, lint.id()) { - return; - } - - let desc = if self.with_description { lint.description() } else { "" }; - let diag: DiagBuilder<'_, ()> = self - .sess - .dcx - .diag(lint.severity().into(), desc) - .code(DiagId::new_str(lint.id())) - .span(MultiSpan::from_span(span)) - .help(lint.help()); - - diag.emit(); - } - - /// Emit a diagnostic with a code fix proposal. - /// - /// For Diff snippets, if no span is provided, it will use the lint's span. - /// If unable to get code from the span, it will fall back to a Block snippet. - pub fn emit_with_fix(&self, lint: &'static L, span: Span, snippet: Snippet) { - if self.inline_config.is_disabled(span, lint.id()) { - return; - } - - // Convert the snippet to ensure we have the appropriate type - let snippet = match snippet { - Snippet::Diff { desc, span: diff_span, add } => { - // Use the provided span or fall back to the lint span - let target_span = diff_span.unwrap_or(span); - - // Check if we can get the original code - if self.span_to_snippet(target_span).is_some() { - Snippet::Diff { desc, span: Some(target_span), add } - } else { - // Fall back to Block if we can't get the original code - Snippet::Block { desc, code: add } - } - } - // Block snippets remain unchanged - block => block, - }; - - let desc = if self.with_description { lint.description() } else { "" }; - let diag: DiagBuilder<'_, ()> = self - .sess - .dcx - .diag(lint.severity().into(), desc) - .code(DiagId::new_str(lint.id())) - .span(MultiSpan::from_span(span)) - .highlighted_note(snippet.to_note(self)) - .help(lint.help()); - - diag.emit(); - } - - pub fn span_to_snippet(&self, span: Span) -> Option { - self.sess.source_map().span_to_snippet(span).ok() - } -} - -#[derive(Debug, Clone, Eq, PartialEq)] -pub enum Snippet { - /// Represents a code block. Can have an optional description. - Block { desc: Option<&'static str>, code: String }, - /// Represents a code diff. Can have an optional description and a span for the code to remove. - Diff { desc: Option<&'static str>, span: Option, add: String }, -} - -impl Snippet { - pub fn to_note(self, ctx: &LintContext<'_>) -> Vec<(DiagMsg, Style)> { - let mut output = Vec::new(); - match self.desc() { - Some(desc) => { - output.push((DiagMsg::from(desc), Style::NoStyle)); - output.push((DiagMsg::from("\n\n"), Style::NoStyle)); - } - None => output.push((DiagMsg::from(" \n"), Style::NoStyle)), - } - match self { - Self::Diff { span, add, .. } => { - // Get the original code from the span if provided - if let Some(span) = span - && let Some(rmv) = ctx.span_to_snippet(span) - { - for line in rmv.lines() { - output.push((DiagMsg::from(format!("- {line}\n")), Style::Removal)); - } - } - for line in add.lines() { - output.push((DiagMsg::from(format!("+ {line}\n")), Style::Addition)); - } - } - Self::Block { code, .. } => { - for line in code.lines() { - output.push((DiagMsg::from(format!("- {line}\n")), Style::NoStyle)); - } - } - } - output.push((DiagMsg::from("\n"), Style::NoStyle)); - output - } - - pub fn desc(&self) -> Option<&'static str> { - match self { - Self::Diff { desc, .. } => *desc, - Self::Block { desc, .. } => *desc, - } - } + /// Runs all configured lints against an already parsed Solar compiler. + fn lint(&self, input: &[PathBuf], deny: DenyLevel, compiler: &mut Compiler) + -> eyre::Result<()>; } diff --git a/crates/lint/src/sol/analysis/access_control.rs b/crates/lint/src/sol/analysis/access_control.rs new file mode 100644 index 0000000000000..a68211dd894c6 --- /dev/null +++ b/crates/lint/src/sol/analysis/access_control.rs @@ -0,0 +1,365 @@ +//! Access-control guard detection: whether a function dominates its body with a check comparing +//! the caller against contract state, and which state that check depends on. + +use super::{ + branch_always_exits, is_require_or_assert, is_sender_member, lhs_local_var, loop_stmts, + stmt_expr, tuple_elems, underlying_var, visit_stmts, +}; +use solar::sema::{ + Gcx, + hir::{self, BinOpKind, Expr, ExprKind, FunctionId, Stmt, StmtKind, UnOpKind, VariableId}, +}; +use std::{collections::HashSet, iter, ops::ControlFlow}; + +/// True when the function or one of its modifiers contains a dominating access check. +pub fn is_protected<'gcx>(gcx: Gcx<'gcx>, func_id: FunctionId) -> bool { + modifiers_and_self(gcx, func_id).any(|id| has_access_guard(gcx, id, &mut HashSet::new())) +} + +/// The modifiers of `func_id` that resolve to functions, followed by `func_id` itself. +pub fn modifiers_and_self<'gcx>( + gcx: Gcx<'gcx>, + func_id: FunctionId, +) -> impl Iterator + 'gcx { + gcx.hir + .function(func_id) + .modifiers + .iter() + .filter_map(move |modifier| { + gcx.hir.function(func_id).contract.map_or_else( + || modifier.id.as_function(), + |contract| gcx.resolve_modifier_target(contract, modifier), + ) + }) + .chain(iter::once(func_id)) +} + +/// Whether `func_id` checks the caller before its `_` placeholder (anywhere for functions): a +/// guarding `if`, a `require`/`assert` on an access check, or a call into a function that does. +/// Bodyless declarations (interface functions, virtual modifiers) fall back to a name heuristic. +pub fn has_access_guard<'gcx>( + gcx: Gcx<'gcx>, + func_id: FunctionId, + seen: &mut HashSet, +) -> bool { + if !seen.insert(func_id) { + return false; + } + let func = gcx.hir.function(func_id); + match func.body { + Some(body) => for_each_guard(gcx, body, seen, &mut |_| ControlFlow::Break(())).is_break(), + None => looks_like_access_control(func), + } +} + +/// State variables the access checks of `func_id` and its modifiers (up to `_`) depend on. +pub fn guard_vars<'gcx>(gcx: Gcx<'gcx>, func_id: FunctionId) -> HashSet { + let mut out = HashSet::new(); + for id in modifiers_and_self(gcx, func_id) { + let Some(body) = gcx.hir.function(id).body else { continue }; + let mut seen = HashSet::from([id]); + let _ = for_each_guard(gcx, body, &mut HashSet::from([id]), &mut |guard| { + match guard { + Guard::Check(cond) => expr_state_vars(gcx, cond, &mut seen, &mut out), + Guard::Call(callee_id) => function_state_vars(gcx, callee_id, &mut seen, &mut out), + } + ControlFlow::Continue(()) + }); + } + out +} + +/// A function whose name marks it as an access check (`auth`, `onlyOwner`, `_checkRole`, ...) +/// and that returns nothing, so calling it for its effect is meaningful. +pub fn looks_like_access_control(func: &hir::Function<'_>) -> bool { + let Some(name) = func.name else { return false }; + if !func.returns.is_empty() { + return false; + } + let lower = name.as_str().to_ascii_lowercase(); + matches!(lower.as_str(), "auth" | "requiresauth" | "restricted") + || ["only", "check", "_check"].iter().any(|prefix| { + ["admin", "guardian", "manager", "owner", "role"] + .iter() + .any(|role| lower.starts_with(&format!("{prefix}{role}"))) + }) +} + +/// `Some(true)` when `expr` holding means the caller is authorized, `Some(false)` when it means +/// the caller is *not* authorized, `None` when `expr` is not an access check. An access check +/// reads `msg.sender`/`tx.origin` (directly, through `aliases` or through a helper) and state +/// (directly or through a helper). +pub fn access_check_polarity<'gcx>( + gcx: Gcx<'gcx>, + expr: &Expr<'_>, + aliases: &HashSet, +) -> Option { + let is_check = |sender: &Expr<'_>, authority: &Expr<'_>| { + expr_reads_sender(gcx, sender, &mut HashSet::new(), aliases) + && expr_reads_state(gcx, authority) + }; + match &expr.peel_parens().kind { + ExprKind::Unary(op, inner) if op.kind == UnOpKind::Not => { + access_check_polarity(gcx, inner, aliases).map(|polarity| !polarity) + } + ExprKind::Binary(lhs, op, rhs) if matches!(op.kind, BinOpKind::And | BinOpKind::Or) => { + // `a && b` is authorized as soon as one side is; `a || b` is unauthorized as soon as + // one side is. The opposite polarity needs both sides. + let dominant = op.kind == BinOpKind::And; + let lhs = access_check_polarity(gcx, lhs, aliases); + let rhs = access_check_polarity(gcx, rhs, aliases); + if lhs == Some(dominant) || rhs == Some(dominant) { + Some(dominant) + } else if lhs == Some(!dominant) && rhs == Some(!dominant) { + Some(!dominant) + } else { + None + } + } + ExprKind::Binary(lhs, op, rhs) + if matches!(op.kind, BinOpKind::Eq | BinOpKind::Ne) + && (is_check(lhs, rhs) || is_check(rhs, lhs)) => + { + Some(op.kind == BinOpKind::Eq) + } + _ => is_check(expr, expr).then_some(true), + } +} + +/// Applies `stmt` to the set of locals holding a `msg.sender`-derived value: a local initialized +/// or assigned from a value that reads the sender becomes an alias, and one reassigned from +/// anything else stops being one. +fn update_sender_aliases<'gcx>( + gcx: Gcx<'gcx>, + stmt: &Stmt<'gcx>, + aliases: &mut HashSet, +) { + let reads_sender = |value: Option<&Expr<'_>>, aliases: &HashSet| { + value.is_some_and(|value| expr_reads_sender(gcx, value, &mut HashSet::new(), aliases)) + }; + // A tuple assignment is simultaneous, so every right-hand side is classified against the + // aliases as they were before the statement, and the locals are updated afterwards. + let updates: Vec<(VariableId, bool)> = match stmt.kind { + StmtKind::DeclSingle(var_id) => match gcx.hir.variable(var_id).initializer { + Some(value) => vec![(var_id, reads_sender(Some(value), aliases))], + None => return, + }, + StmtKind::DeclMulti(var_ids, value) => var_ids + .iter() + .enumerate() + .filter_map(|(i, var_id)| { + let value = + tuple_elems(value).map_or(Some(value), |elems| elems.get(i).copied().flatten()); + var_id.map(|var_id| (var_id, reads_sender(value, aliases))) + }) + .collect(), + StmtKind::Expr(expr) => match &expr.peel_parens().kind { + ExprKind::Assign(lhs, _, rhs) => { + let mut updates = Vec::new(); + collect_sender_alias_updates(gcx, lhs, Some(rhs), aliases, &mut updates); + updates + } + _ => return, + }, + _ => return, + }; + // Solidity commits tuple writes right-to-left, which matters when a local occurs more than + // once in the destination. + for (var_id, reads_sender) in updates.into_iter().rev() { + if reads_sender { + aliases.insert(var_id); + } else { + aliases.remove(&var_id); + } + } +} + +/// Recursively pairs tuple destinations with tuple literal elements. Any other right-hand side, +/// such as a call returning a tuple, applies to every destination local. +fn collect_sender_alias_updates( + gcx: Gcx<'_>, + lhs: &Expr<'_>, + rhs: Option<&Expr<'_>>, + aliases: &HashSet, + updates: &mut Vec<(VariableId, bool)>, +) { + if let Some(lhs_elems) = tuple_elems(lhs) { + for (i, lhs) in lhs_elems.iter().enumerate() { + let Some(lhs) = lhs else { continue }; + let rhs = rhs.and_then(|rhs| { + tuple_elems(rhs).map_or(Some(rhs), |elems| elems.get(i).copied().flatten()) + }); + collect_sender_alias_updates(gcx, lhs, rhs, aliases, updates); + } + } else if let Some(var_id) = lhs_local_var(gcx, lhs) { + let reads_sender = + rhs.is_some_and(|rhs| expr_reads_sender(gcx, rhs, &mut HashSet::new(), aliases)); + updates.push((var_id, reads_sender)); + } +} + +/// Whether `expr` reads `msg.sender`/`tx.origin`, one of `aliases`, or calls a user function that +/// reads the sender. +pub fn expr_reads_sender<'gcx>( + gcx: Gcx<'gcx>, + expr: &Expr<'_>, + seen: &mut HashSet, + aliases: &HashSet, +) -> bool { + expr.visit(&mut |e| { + let reads = is_sender_member(gcx, e) + || underlying_var(gcx, e).is_some_and(|v| aliases.contains(&v)) + || matches!(&e.kind, ExprKind::Call(callee, ..) + if matches!(callee.peel_parens().kind, ExprKind::Ident(_)) + && gcx.resolved_function(callee).is_some_and(|id| function_reads_sender(gcx, id, seen))); + if reads { ControlFlow::Break(()) } else { ControlFlow::Continue(()) } + }) + .is_break() +} + +/// Whether the body of `func_id` reads `msg.sender`/`tx.origin`, following calls. +pub fn function_reads_sender<'gcx>( + gcx: Gcx<'gcx>, + func_id: FunctionId, + seen: &mut HashSet, +) -> bool { + seen.insert(func_id) + && gcx.hir.function(func_id).body.is_some_and(|body| { + visit_stmts(&gcx.hir, body.stmts, |stmt| { + let reads = stmt_expr(&gcx.hir, stmt) + .is_some_and(|expr| expr_reads_sender(gcx, expr, seen, &HashSet::new())); + if reads { ControlFlow::Break(()) } else { ControlFlow::Continue(()) } + }) + .is_break() + }) +} + +/// State variables read by `expr`, following calls into user functions. +pub fn expr_state_vars<'gcx>( + gcx: Gcx<'gcx>, + expr: &Expr<'_>, + seen: &mut HashSet, + out: &mut HashSet, +) { + let _ = expr.visit(&mut |e| { + if let Some(var_id) = underlying_var(gcx, e) + && gcx.hir.variable(var_id).kind.is_state() + { + out.insert(var_id); + } + if let ExprKind::Call(callee, ..) = &e.kind + && matches!(callee.peel_parens().kind, ExprKind::Ident(_)) + && let Some(callee_id) = gcx.resolved_function(callee) + { + function_state_vars(gcx, callee_id, seen, out); + } + ControlFlow::<()>::Continue(()) + }); +} + +/// State variables read by the body of `func_id`, following calls into user functions. +pub fn function_state_vars<'gcx>( + gcx: Gcx<'gcx>, + func_id: FunctionId, + seen: &mut HashSet, + out: &mut HashSet, +) { + if seen.insert(func_id) + && let Some(body) = gcx.hir.function(func_id).body + { + let _ = visit_stmts(&gcx.hir, body.stmts, |stmt| { + if let Some(expr) = stmt_expr(&gcx.hir, stmt) { + expr_state_vars(gcx, expr, seen, out); + } + ControlFlow::Continue(()) + }); + } +} + +fn expr_reads_state<'gcx>(gcx: Gcx<'gcx>, expr: &Expr<'_>) -> bool { + let mut vars = HashSet::new(); + expr_state_vars(gcx, expr, &mut HashSet::new(), &mut vars); + !vars.is_empty() +} + +/// An access check among the dominating statements of a function body. +enum Guard<'a> { + /// The condition of a guarding `if` or of a `require`/`assert`. + Check(&'a Expr<'a>), + /// A call into a function that itself checks the caller. + Call(FunctionId), +} + +/// Calls `f` for every access check that dominates `body` (runs unconditionally before the `_` +/// placeholder) until it breaks. `seen` guards the recursion into called functions. +fn for_each_guard<'gcx>( + gcx: Gcx<'gcx>, + body: hir::Block<'gcx>, + seen: &mut HashSet, + f: &mut impl FnMut(Guard<'_>) -> ControlFlow<()>, +) -> ControlFlow<()> { + let mut stmts = Vec::new(); + let _ = dominating_stmts(body.stmts, &mut stmts); + // Aliases as of each statement: a check is evaluated against the locals that read the sender + // at that point, so a reassignment neither validates a later check nor invalidates an earlier + // one. + let mut aliases = HashSet::new(); + for stmt in stmts { + if let StmtKind::If(cond, then_stmt, else_stmt) = stmt.kind { + let exits = match access_check_polarity(gcx, cond, &aliases) { + Some(false) => branch_always_exits(gcx, then_stmt), + Some(true) => else_stmt.is_some_and(|expr| branch_always_exits(gcx, expr)), + None => false, + }; + if exits { + f(Guard::Check(cond))?; + } + continue; + } + update_sender_aliases(gcx, stmt, &mut aliases); + let Some(expr) = stmt_expr(&gcx.hir, stmt) else { continue }; + expr.visit(&mut |e| { + match &e.kind { + ExprKind::Call(callee, args) if is_require_or_assert(gcx, callee) => { + if let Some(cond) = args.exprs().next() + && access_check_polarity(gcx, cond, &aliases) == Some(true) + { + f(Guard::Check(cond))?; + } + } + ExprKind::Call(callee, ..) + if matches!(callee.peel_parens().kind, ExprKind::Ident(_)) => + { + if let Some(callee_id) = gcx.resolved_function(callee) + && (looks_like_access_control(gcx.hir.function(callee_id)) + || has_access_guard(gcx, callee_id, seen)) + { + f(Guard::Call(callee_id))?; + } + } + _ => {} + } + ControlFlow::Continue(()) + })?; + } + ControlFlow::Continue(()) +} + +/// Collects into `out` the statements that run unconditionally before the `_` placeholder (all of +/// them for functions), descending into blocks and loops. Breaks when the placeholder is reached. +fn dominating_stmts<'gcx>( + stmts: impl IntoIterator>, + out: &mut Vec<&'gcx Stmt<'gcx>>, +) -> ControlFlow<()> { + for stmt in stmts { + match stmt.kind { + StmtKind::Placeholder => return ControlFlow::Break(()), + StmtKind::Block(block) | StmtKind::UncheckedBlock(block) => { + dominating_stmts(block.stmts, out)?; + } + StmtKind::Loop(block, source) => dominating_stmts(loop_stmts(block, source), out)?, + _ => out.push(stmt), + } + } + ControlFlow::Continue(()) +} diff --git a/crates/lint/src/sol/analysis/exprs.rs b/crates/lint/src/sol/analysis/exprs.rs new file mode 100644 index 0000000000000..2716342375837 --- /dev/null +++ b/crates/lint/src/sol/analysis/exprs.rs @@ -0,0 +1,418 @@ +//! Expression-shape probes over Solar HIR (and a few AST-level ones). + +use solar::{ + ast::{self, LitKind, UnOpKind}, + interface::{Symbol, kw, sym}, + sema::{ + Gcx, + builtins::Builtin, + hir::{ + self, CallArgs, ElementaryType, Expr, ExprKind, FunctionId, ItemId, Res, TypeKind, + VariableId, + }, + ty::{CallableParamSource, TyKind}, + }, +}; +use std::ops::ControlFlow; + +/// True if `expr` resolves to the given builtin name. +pub fn is_builtin(gcx: Gcx<'_>, expr: &Expr<'_>, name: Symbol) -> bool { + gcx.resolved_builtin(expr).is_some_and(|builtin| builtin.name() == name) +} + +/// `msg.sender`. +pub fn is_msg_sender(gcx: Gcx<'_>, expr: &Expr<'_>) -> bool { + gcx.resolved_builtin(expr) == Some(Builtin::MsgSender) +} + +/// `msg.sender` or `tx.origin`. +pub fn is_sender_member(gcx: Gcx<'_>, expr: &Expr<'_>) -> bool { + matches!(gcx.resolved_builtin(expr), Some(Builtin::MsgSender | Builtin::TxOrigin)) +} + +/// True if `callee` resolves to the builtin `require` or `assert`. +pub fn is_require_or_assert(gcx: Gcx<'_>, callee: &Expr<'_>) -> bool { + matches!(gcx.resolved_builtin(callee), Some(Builtin::Require | Builtin::Assert)) +} + +/// `revert(...)`, `revert Err(...)`-style builtin revert call (any form). +pub fn is_revert_call(gcx: Gcx<'_>, expr: &Expr<'_>) -> bool { + matches!(&expr.peel_parens().kind, ExprKind::Call(callee, ..) if is_builtin(gcx, callee, kw::Revert)) +} + +/// A literal zero/false or an elementary cast or arithmetic negation of one. +pub fn is_zero_value(expr: &Expr<'_>) -> bool { + match &expr.peel_parens().kind { + ExprKind::Lit(lit) => match &lit.kind { + LitKind::Number(value) => value.is_zero(), + LitKind::Address(value) => value.is_zero(), + LitKind::Bool(value) => !value, + _ => false, + }, + ExprKind::Call(callee, args) if cast_type(callee).is_some() => { + let mut exprs = args.exprs(); + exprs.len() == 1 && exprs.next().is_some_and(is_zero_value) + } + ExprKind::Payable(inner) => is_zero_value(inner), + ExprKind::Unary(op, inner) if op.kind == UnOpKind::Neg => is_zero_value(inner), + _ => false, + } +} + +/// `revert(...)`, `selfdestruct(...)`, `require(false, ...)` or `assert(false)`. +pub fn is_exit_call(gcx: Gcx<'_>, expr: &Expr<'_>) -> bool { + let ExprKind::Call(callee, args) = &expr.peel_parens().kind else { return false }; + match gcx.resolved_builtin(callee) { + Some(Builtin::Revert | Builtin::RevertMsg | Builtin::Selfdestruct) => true, + Some(Builtin::Require | Builtin::Assert) => { + args.exprs().next().is_some_and(is_literal_false) + } + _ => false, + } +} + +/// The boolean literal `false`. +pub fn is_literal_false(expr: &Expr<'_>) -> bool { + matches!(&expr.peel_parens().kind, ExprKind::Lit(lit) if matches!(lit.kind, LitKind::Bool(false))) +} + +/// The integer literal `0`. +pub fn is_literal_zero(expr: &Expr<'_>) -> bool { + matches!(&expr.peel_parens().kind, ExprKind::Lit(lit) + if matches!(&lit.kind, LitKind::Number(n) if n.is_zero())) +} + +/// `address(...)` / `address payable(...)` cast head. +pub fn is_address_cast(callee: &Expr<'_>) -> bool { + matches!( + &callee.peel_parens().kind, + ExprKind::Type(hir::Type { kind: TypeKind::Elementary(ElementaryType::Address(_)), .. }) + ) +} + +/// `IFoo(...)` contract / interface cast head. +pub fn is_contract_cast(gcx: Gcx<'_>, callee: &Expr<'_>) -> bool { + gcx.type_of_expr(callee.peel_parens().id).is_some_and( + |ty| matches!(ty.kind, TyKind::Type(inner) if matches!(inner.kind, TyKind::Contract(_))), + ) +} + +/// `address(...)` or `IFoo(...)` cast head. +pub fn is_address_like_cast(gcx: Gcx<'_>, callee: &Expr<'_>) -> bool { + is_address_cast(callee) || is_contract_cast(gcx, callee) +} + +/// `address(this)`, `payable(this)`, `IFoo(this)`, `IFoo(address(this))`, or bare `this`. +pub fn is_address_self(gcx: Gcx<'_>, expr: &Expr<'_>) -> bool { + let expr = expr.peel_parens(); + match &expr.kind { + ExprKind::Payable(inner) => is_address_self(gcx, inner), + ExprKind::Call(callee, args) if is_address_like_cast(gcx, callee) => { + args.exprs().next().is_some_and(|expr| is_address_self(gcx, expr)) + } + _ => is_builtin(gcx, expr, sym::this), + } +} + +/// The variable a bare identifier refers to, looking through parens, `payable(...)` and +/// address-like casts (`address(x)`, `IFoo(x)`). +pub fn underlying_var(gcx: Gcx<'_>, expr: &Expr<'_>) -> Option { + match &expr.peel_parens().kind { + ExprKind::Ident(_) => gcx.resolved_variable(expr), + ExprKind::Call(callee, args) if is_address_like_cast(gcx, callee) => { + args.exprs().next().and_then(|arg| underlying_var(gcx, arg)) + } + ExprKind::Payable(inner) => underlying_var(gcx, inner), + _ => None, + } +} + +/// The local (non-state) variable a bare identifier refers to. +pub fn lhs_local_var(gcx: Gcx<'_>, lhs: &Expr<'_>) -> Option { + let ExprKind::Ident(_) = lhs.peel_parens().kind else { return None }; + gcx.resolved_variable(lhs).filter(|&v| !gcx.hir.variable(v).kind.is_state()) +} + +/// State variables written by an lvalue: peels index/slice/member/payable/unary/delete wrappers +/// and tuple destructuring. Duplicates are removed. +pub fn state_lhs_vars(gcx: Gcx<'_>, lhs: &Expr<'_>) -> Vec { + let mut vars = Vec::new(); + for_each_lhs_var(gcx, lhs, &mut |v| { + if gcx.hir.variable(v).kind.is_state() && !vars.contains(&v) { + vars.push(v); + } + }); + vars +} + +/// Calls `f` for each resolved variable at the root of an lvalue, peeling +/// index/slice/member/payable/unary/delete wrappers and tuple destructuring. +pub fn for_each_lhs_var(gcx: Gcx<'_>, expr: &Expr<'_>, f: &mut impl FnMut(VariableId)) { + match &expr.peel_parens().kind { + ExprKind::Ident(_) => { + if let Some(var) = gcx.resolved_variable(expr) { + f(var); + } + } + ExprKind::Index(base, _) + | ExprKind::Slice(base, ..) + | ExprKind::Member(base, _) + | ExprKind::Payable(base) + | ExprKind::Unary(_, base) + | ExprKind::Delete(base) => for_each_lhs_var(gcx, base, f), + ExprKind::Tuple(exprs) => exprs.iter().flatten().for_each(|e| for_each_lhs_var(gcx, e, f)), + _ => {} + } +} + +/// The elements of a tuple expression (through parens). +pub fn tuple_elems<'gcx>(expr: &'gcx Expr<'gcx>) -> Option<&'gcx [Option<&'gcx Expr<'gcx>>]> { + match &expr.peel_parens().kind { + ExprKind::Tuple(elems) => Some(elems), + _ => None, + } +} + +/// The argument bound to `param` of `function_id` in `args`, positional or named. +pub fn arg_for_param<'gcx>( + gcx: Gcx<'gcx>, + function_id: FunctionId, + param: VariableId, + args: &CallArgs<'gcx>, +) -> Option<&'gcx Expr<'gcx>> { + let function = gcx.hir.function(function_id); + let idx = function.parameters.iter().position(|p| *p == param)?; + let names = gcx.callable_param_names(CallableParamSource::Function { + id: function_id, + skips_receiver: false, + }); + args.argument_for_parameter(idx, Some(&names)) +} + +/// The function an internal call made from within `contract_id` dispatches to: a virtual call +/// resolves to the most derived override, `super.f` to the next base implementation, and a +/// qualified `Base.f` to that declaration exactly. `None` for external and unresolved callees. +pub fn dispatched_function( + gcx: Gcx<'_>, + contract_id: hir::ContractId, + callee: &Expr<'_>, +) -> Option { + let callee = callee.peel_parens(); + let function_id = gcx.resolved_callee(callee.id)?.res.as_function()?; + match &callee.kind { + ExprKind::Member(base, _) => { + let solar::sema::ty::TyKind::Type(ty) = gcx.type_of_expr(base.id)?.kind else { + return None; + }; + match ty.kind { + solar::sema::ty::TyKind::Contract(_) => Some(function_id), + solar::sema::ty::TyKind::Super(defining) => { + Some(gcx.resolve_super_function(contract_id, defining, function_id)) + } + _ => None, + } + } + ExprKind::Ident(_) => Some(gcx.resolve_virtual_function(contract_id, function_id)), + _ => None, + } +} + +/// The item a bare identifier refers to, if any. +pub fn referenced_item(gcx: Gcx<'_>, expr: &Expr<'_>) -> Option { + let ExprKind::Ident(_) = expr.peel_parens().kind else { return None }; + match gcx.resolved_expr(expr)? { + Res::Item(id) => Some(id), + _ => None, + } +} + +/// Receiver of `.{call,delegatecall,transfer,send}` (value-bearing sinks), including the +/// `.call{value: x}(...)` option form. +pub fn address_call_receiver<'a>(callee: &'a Expr<'a>) -> Option<&'a Expr<'a>> { + let (inner, _) = callee.split_call_options(); + let ExprKind::Member(receiver, name) = &inner.kind else { return None }; + matches!(name.name, kw::Call | kw::Delegatecall | sym::transfer | sym::send).then_some(receiver) +} + +/// True if a HIR call carries an explicit `gas:` option. +pub fn is_call_with_gas_limit(expr: &Expr<'_>) -> bool { + expr.peel_parens().as_call().is_some_and(|(_, _, opts)| { + opts.is_some_and(|opts| opts.args.iter().any(|opt| opt.name.name == kw::Gas)) + }) +} + +/// AST-level: `target.call(...)`, `.delegatecall(...)`, `.staticcall(...)`, with or without +/// `{value: x}` options. +pub const fn is_low_level_call(expr: &ast::Expr<'_>) -> bool { + if let ast::ExprKind::Call(call_expr, _) = &expr.kind { + let callee = match &call_expr.kind { + ast::ExprKind::CallOptions(inner, _) => inner, + _ => call_expr, + }; + if let ast::ExprKind::Member(_, member) = &callee.kind { + return matches!(member.name, kw::Call | kw::Delegatecall | kw::Staticcall); + } + } + false +} + +/// The lvalue written by an assignment, `delete` or increment/decrement expression. +pub const fn write_target<'gcx>(expr: &'gcx Expr<'gcx>) -> Option<&'gcx Expr<'gcx>> { + match &expr.kind { + ExprKind::Assign(target, ..) | ExprKind::Delete(target) => Some(target), + ExprKind::Unary(op, target) if op.kind.has_side_effects() => Some(target), + _ => None, + } +} + +/// The functions reachable through the runtime dispatch of a most-derived contract: its +/// interface functions plus the inherited `fallback`/`receive`, if any. +pub fn runtime_entry_points(gcx: Gcx<'_>, contract_id: hir::ContractId) -> Vec { + let bases = gcx.hir.contract(contract_id).linearized_bases; + let mut entries: Vec<_> = + gcx.interface_functions(contract_id).all().iter().map(|f| f.id).collect(); + entries.extend(bases.iter().find_map(|&cid| gcx.hir.contract(cid).fallback)); + entries.extend(bases.iter().find_map(|&cid| gcx.hir.contract(cid).receive)); + entries +} + +/// The elementary type an explicit cast head `T(...)` converts to. +pub fn cast_type(callee: &Expr<'_>) -> Option { + match &callee.peel_parens().kind { + ExprKind::Type(hir::Type { kind: TypeKind::Elementary(ty), .. }) => Some(*ty), + _ => None, + } +} + +/// `address` / `address payable` or a contract/interface type. +pub const fn var_is_address_like(var: &hir::Variable<'_>) -> bool { + matches!( + var.ty.kind, + TypeKind::Elementary(ElementaryType::Address(_)) | TypeKind::Custom(ItemId::Contract(_)) + ) +} + +/// AST-level boolean literal, through parens. +pub fn ast_bool_literal(expr: &ast::Expr<'_>) -> Option { + match &expr.peel_parens().kind { + ast::ExprKind::Lit(ast::Lit { kind: LitKind::Bool(value), .. }, _) => Some(*value), + _ => None, + } +} + +/// Calls `f` on every direct sub-expression of `expr`, in evaluation order. +pub fn for_each_child<'gcx>(expr: &'gcx Expr<'gcx>, f: &mut impl FnMut(&'gcx Expr<'gcx>)) { + match &expr.kind { + ExprKind::Assign(lhs, _, rhs) | ExprKind::Binary(lhs, _, rhs) => { + f(lhs); + f(rhs); + } + ExprKind::Unary(_, inner) + | ExprKind::Delete(inner) + | ExprKind::Member(inner, _) + | ExprKind::Payable(inner) => f(inner), + ExprKind::Call(callee, args) => { + let (callee, opts) = callee.split_call_options(); + f(callee); + opts.iter().flat_map(|opts| opts.args).for_each(|opt| f(&opt.value)); + args.exprs().for_each(f); + } + ExprKind::CallOptions(callee, opts) => { + f(callee); + opts.args.iter().for_each(|opt| f(&opt.value)); + } + ExprKind::Index(base, index) => { + f(base); + index.iter().copied().for_each(f); + } + ExprKind::Slice(base, start, end) => { + f(base); + [*start, *end].into_iter().flatten().for_each(f); + } + ExprKind::Ternary(cond, true_expr, false_expr) => { + f(cond); + f(true_expr); + f(false_expr); + } + ExprKind::Array(exprs) => exprs.iter().for_each(f), + ExprKind::Tuple(exprs) => exprs.iter().flatten().copied().for_each(f), + ExprKind::Ident(_) + | ExprKind::Lit(_) + | ExprKind::New(_) + | ExprKind::TypeCall(_) + | ExprKind::Type(_) + | ExprKind::YulMember(..) + | ExprKind::Err(_) => {} + } +} + +/// True if `pred` holds for `expr` or any of its sub-expressions. +pub fn any_subexpr(expr: &Expr<'_>, mut pred: impl FnMut(&Expr<'_>) -> bool) -> bool { + expr.visit(&mut |e| if pred(e) { ControlFlow::Break(()) } else { ControlFlow::Continue(()) }) + .is_break() +} + +/// True if evaluating `expr` performs an assignment, `delete` or increment/decrement. +pub fn has_side_effect(expr: &Expr<'_>) -> bool { + any_subexpr(expr, |e| match &e.kind { + ExprKind::Assign(..) | ExprKind::Delete(_) => true, + ExprKind::Unary(op, _) => op.kind.has_side_effects(), + _ => false, + }) +} + +/// True when `callee` names a zero-parameter function whose body returns an expression matching +/// `pred`. +pub fn callee_no_arg_returns<'gcx>( + gcx: Gcx<'gcx>, + callee: &'gcx Expr<'gcx>, + mut pred: impl FnMut(&'gcx Expr<'gcx>) -> bool, +) -> bool { + gcx.type_of_expr(callee.peel_parens().id).is_some_and( + |ty| matches!(ty.kind, TyKind::Fn(f) if f.is_internal() || f.is_delegate_call()), + ) && gcx + .resolved_function(callee) + .is_some_and(|fid| function_no_arg_returns(gcx, fid, &mut pred)) +} + +/// True when `fid` takes no parameters and its body is `return e;` or `namedRet = e;` (optionally +/// followed by a bare `return;`) with `pred(e)`. +pub fn function_no_arg_returns<'gcx>( + gcx: Gcx<'gcx>, + fid: FunctionId, + pred: &mut impl FnMut(&'gcx Expr<'gcx>) -> bool, +) -> bool { + let f = gcx.hir.function(fid); + let Some(body) = f.body else { return false }; + let stmts = match body.stmts { + [rest @ .., last] if matches!(last.kind, hir::StmtKind::Return(None)) => rest, + stmts => stmts, + }; + let [stmt] = stmts else { return false }; + f.parameters.is_empty() + && match &stmt.kind { + hir::StmtKind::Return(Some(e)) => pred(e), + hir::StmtKind::Expr(e) => { + matches!(&e.peel_parens().kind, ExprKind::Assign(lhs, None, rhs) + if f.returns.len() == 1 && underlying_var(gcx, lhs) == Some(f.returns[0]) && pred(rhs)) + } + _ => false, + } +} + +/// Package-root directory names of the OpenZeppelin distributions (npm scope and git submodules). +pub const OPENZEPPELIN_ROOTS: &[&str] = + &["@openzeppelin", "openzeppelin-contracts", "openzeppelin-contracts-upgradeable"]; + +/// True if the source file of `source_id` lives under one of the given package-root directory +/// names (matched as whole, case-insensitive path components). +pub fn source_in_package(hir: &hir::Hir<'_>, source_id: hir::SourceId, roots: &[&str]) -> bool { + let solar::interface::source_map::FileName::Real(path) = &hir.source(source_id).file.name + else { + return false; + }; + path.components().any(|component| { + matches!(component, std::path::Component::Normal(name) + if roots.iter().any(|root| name.eq_ignore_ascii_case(root))) + }) +} diff --git a/crates/lint/src/sol/analysis/helper_cache.rs b/crates/lint/src/sol/analysis/helper_cache.rs new file mode 100644 index 0000000000000..0f99c5d68a203 --- /dev/null +++ b/crates/lint/src/sol/analysis/helper_cache.rs @@ -0,0 +1,62 @@ +use std::{ + collections::{HashMap, HashSet, VecDeque}, + hash::Hash, +}; + +pub const DEFAULT_HELPER_ANALYSIS_CACHE_LIMIT: usize = 65_536; + +/// Bounded memo table for lint analyses that inline internal helper calls. +#[derive(Debug)] +pub struct HelperAnalysisCache { + entries: HashMap, + in_progress: HashSet, + order: VecDeque, + max_entries: usize, +} + +impl HelperAnalysisCache +where + K: Clone + Eq + Hash, +{ + pub fn new(max_entries: usize) -> Self { + Self { + entries: HashMap::new(), + in_progress: HashSet::new(), + order: VecDeque::new(), + max_entries, + } + } + + pub fn is_in_progress(&self, key: &K) -> bool { + self.in_progress.contains(key) + } + + pub fn get(&self, key: &K) -> Option<&V> { + self.entries.get(key) + } + + pub fn start(&mut self, key: K) { + self.in_progress.insert(key); + } + + pub fn finish(&mut self, key: K, value: V) { + self.in_progress.remove(&key); + if self.max_entries == 0 { + return; + } + + if !self.entries.contains_key(&key) { + self.order.push_back(key.clone()); + } + self.entries.insert(key, value); + + while self.entries.len() > self.max_entries { + if let Some(oldest) = self.order.pop_front() { + self.entries.remove(&oldest); + self.in_progress.remove(&oldest); + } else { + break; + } + } + } +} diff --git a/crates/lint/src/sol/analysis/mod.rs b/crates/lint/src/sol/analysis/mod.rs new file mode 100644 index 0000000000000..40f4639f95529 --- /dev/null +++ b/crates/lint/src/sol/analysis/mod.rs @@ -0,0 +1,15 @@ +//! Shared, side-effect-free HIR probes reused by Solidity lints. + +mod access_control; +mod exprs; +mod helper_cache; +mod modifier_outcome; +mod stmts; +mod types; + +pub use access_control::*; +pub use exprs::*; +pub use helper_cache::*; +pub use modifier_outcome::*; +pub use stmts::*; +pub use types::*; diff --git a/crates/lint/src/sol/analysis/modifier_outcome.rs b/crates/lint/src/sol/analysis/modifier_outcome.rs new file mode 100644 index 0000000000000..0f66df209ece3 --- /dev/null +++ b/crates/lint/src/sol/analysis/modifier_outcome.rs @@ -0,0 +1,151 @@ +//! Control-flow summary of modifier bodies with respect to the `_` placeholder. + +use super::is_literal_false; +use solar::sema::{ + Gcx, + builtins::Builtin, + hir::{Block, Expr, ExprKind, LoopSource, Stmt, StmtKind}, +}; + +/// Summary of how control flow can leave a statement or block *without* having executed the +/// placeholder (`_`) or reverted. +/// +/// Each flag tracks whether there is at least one such path. If every path reaches `_` or reverts, +/// all flags are `false` (`Outcome::COVERED`). +#[derive(Clone, Copy)] +pub struct Outcome { + /// Control can reach the end of the construct normally and continue to the next statement. + falls_through: bool, + /// Control can exit the modifier via `return` before reaching `_`. + returns: bool, + /// Control can exit the enclosing loop via `break` before reaching `_`. + breaks: bool, + /// Control can jump to the enclosing loop's next iteration via `continue` before reaching `_`. + continues: bool, +} + +impl Outcome { + /// Every path reaches `_` or reverts. + const COVERED: Self = + Self { falls_through: false, returns: false, breaks: false, continues: false }; + const FALLTHROUGH: Self = Self { falls_through: true, ..Self::COVERED }; + const RETURNS: Self = Self { returns: true, ..Self::COVERED }; + const BREAKS: Self = Self { breaks: true, ..Self::COVERED }; + const CONTINUES: Self = Self { continues: true, ..Self::COVERED }; + + /// Whether the modifier body can finish without executing `_`. Only fall-through and `return` + /// reach the modifier's end; `break`/`continue` are always consumed by an enclosing loop. + pub const fn can_skip_placeholder(self) -> bool { + self.falls_through || self.returns + } + + const fn merge(self, other: Self) -> Self { + Self { + falls_through: self.falls_through || other.falls_through, + returns: self.returns || other.returns, + breaks: self.breaks || other.breaks, + continues: self.continues || other.continues, + } + } +} + +pub fn block_outcome(gcx: Gcx<'_>, block: Block<'_>) -> Outcome { + let mut outcome = Outcome::FALLTHROUGH; + for stmt in block.stmts { + // Once a statement cannot fall through, the rest of the block is unreachable. + if !outcome.falls_through { + return outcome; + } + let stmt_outcome = stmt_outcome(gcx, stmt); + outcome = Outcome { + falls_through: stmt_outcome.falls_through, + returns: outcome.returns || stmt_outcome.returns, + breaks: outcome.breaks || stmt_outcome.breaks, + continues: outcome.continues || stmt_outcome.continues, + }; + } + outcome +} + +fn stmt_outcome(gcx: Gcx<'_>, stmt: &Stmt<'_>) -> Outcome { + match &stmt.kind { + StmtKind::Placeholder => Outcome::COVERED, + StmtKind::Return(_) => Outcome::RETURNS, + StmtKind::Break => Outcome::BREAKS, + StmtKind::Continue => Outcome::CONTINUES, + StmtKind::Expr(expr) => call_outcome(gcx, expr).unwrap_or(Outcome::FALLTHROUGH), + StmtKind::Revert(_) => Outcome::COVERED, + StmtKind::Block(block) + | StmtKind::UncheckedBlock(block) + | StmtKind::AssemblyBlock(block) => block_outcome(gcx, *block), + StmtKind::If(_, then_stmt, else_stmt) => { + let then_outcome = stmt_outcome(gcx, then_stmt); + let else_outcome = + else_stmt.map_or(Outcome::FALLTHROUGH, |stmt| stmt_outcome(gcx, stmt)); + then_outcome.merge(else_outcome) + } + StmtKind::Loop(block, source) => { + // `for`/`while`/`do-while` are all desugared to a `Loop` whose body holds the condition + // as a synthetic `else break`. The loop can be left (and thus fall through to the + // following statement) via a `break`, including that synthetic condition break; a loop + // without any `break` (e.g. `for (;;)`) never falls through. For `do-while` the + // condition sits *after* the body, so a `continue` in the body also reaches it and can + // exit the loop. `break`/`continue` are otherwise consumed by the loop; only `return` + // keeps escaping toward the modifier's end. + let body = block_outcome(gcx, *block); + let falls_through = + body.breaks || (matches!(source, LoopSource::DoWhile) && body.continues); + Outcome { falls_through, returns: body.returns, ..Outcome::COVERED } + } + StmtKind::Try(try_stmt) => { + // Every execution enters exactly one clause (the `returns` clause on success or a + // matching `catch`), or the call reverts uncaught. There is no implicit fall-through + // path that skips all clauses, so start from `COVERED`. + let mut outcome = Outcome::COVERED; + for clause in try_stmt.clauses { + outcome = outcome.merge(block_outcome(gcx, clause.block)); + } + outcome + } + StmtKind::Switch(switch) => { + // A Yul `switch` value that matches no `case` falls through unless a `default` clause + // is present (stored last, with no constant). + let has_default = switch.cases.last().is_some_and(|case| case.constant.is_none()); + let mut outcome = if has_default { Outcome::COVERED } else { Outcome::FALLTHROUGH }; + for case in switch.cases { + outcome = outcome.merge(block_outcome(gcx, case.body)); + } + outcome + } + StmtKind::DeclSingle(_) + | StmtKind::DeclMulti(_, _) + | StmtKind::Emit(_) + | StmtKind::Err(_) => Outcome::FALLTHROUGH, + } +} + +/// Classifies a statement-level call expression that terminates the current path before reaching +/// `_`, if any. Covers both the Solidity `revert`/`revert(...)` builtins and the Yul halting +/// builtins reachable when recursing into an `assembly { .. }` block. +/// +/// - Failing halts (`revert`, Yul `revert`/`invalid`) leave every path either reverting or reaching +/// `_`, so they are [`Outcome::COVERED`] (not flagged). +/// - Successful halts (Yul `return`/`stop`, `selfdestruct`) let the surrounding call finish +/// *without* running the modified function body, which is exactly what this lint flags, so they +/// behave like a `return` ([`Outcome::RETURNS`]). +fn call_outcome(gcx: Gcx<'_>, expr: &Expr<'_>) -> Option { + let ExprKind::Call(callee, args) = &expr.peel_parens().kind else { return None }; + match gcx.resolved_builtin(callee)? { + Builtin::Revert | Builtin::RevertMsg | Builtin::YulRevert | Builtin::YulInvalid => { + Some(Outcome::COVERED) + } + Builtin::Require | Builtin::Assert if args.exprs().next().is_some_and(is_literal_false) => { + Some(Outcome::COVERED) + } + Builtin::YulReturn + | Builtin::YulStop + | Builtin::YulSelfdestruct + | Builtin::Selfdestruct => Some(Outcome::RETURNS), + _ => None, + } +} diff --git a/crates/lint/src/sol/analysis/stmts.rs b/crates/lint/src/sol/analysis/stmts.rs new file mode 100644 index 0000000000000..c571bf9b0ad60 --- /dev/null +++ b/crates/lint/src/sol/analysis/stmts.rs @@ -0,0 +1,197 @@ +//! Statement-shape probes over Solar HIR. + +use super::is_exit_call; +use solar::{ + ast::FunctionKind, + sema::{ + Gcx, + hir::{self, Expr, FunctionId, LoopSource, Stmt, StmtKind, Visit}, + }, +}; +use std::ops::ControlFlow; + +/// Runs `f` on every statement (pre-order, nested ones included) until it breaks. +struct StmtVisitor<'gcx, F> { + hir: &'gcx hir::Hir<'gcx>, + f: F, +} + +impl<'gcx, F: FnMut(&'gcx Stmt<'gcx>) -> ControlFlow<()>> Visit<'gcx> for StmtVisitor<'gcx, F> { + type BreakValue = (); + + fn hir(&self) -> &'gcx hir::Hir<'gcx> { + self.hir + } + + fn visit_stmt(&mut self, stmt: &'gcx Stmt<'gcx>) -> ControlFlow<()> { + (self.f)(stmt)?; + self.walk_stmt(stmt) + } +} + +/// Runs `f` on every statement of `stmts` and their nested statements (pre-order) until it breaks. +pub fn visit_stmts<'gcx>( + hir: &'gcx hir::Hir<'gcx>, + stmts: impl IntoIterator>, + f: impl FnMut(&'gcx Stmt<'gcx>) -> ControlFlow<()>, +) -> ControlFlow<()> { + let mut visitor = StmtVisitor { hir, f }; + stmts.into_iter().try_for_each(|stmt| visitor.visit_stmt(stmt)) +} + +/// The expression directly owned by `stmt` (nested statements excluded). +pub fn stmt_expr<'gcx>( + hir: &'gcx hir::Hir<'gcx>, + stmt: &'gcx Stmt<'gcx>, +) -> Option<&'gcx Expr<'gcx>> { + match stmt.kind { + StmtKind::DeclSingle(var_id) => hir.variable(var_id).initializer, + StmtKind::DeclMulti(_, expr) + | StmtKind::Expr(expr) + | StmtKind::Emit(expr) + | StmtKind::Revert(expr) + | StmtKind::Return(Some(expr)) + | StmtKind::If(expr, ..) => Some(expr), + StmtKind::Try(try_stmt) => Some(&try_stmt.expr), + _ => None, + } +} + +/// True when executing `stmt` provably prevents control from continuing past it: `return`, +/// `revert`, `selfdestruct`, `require(false, ..)` / `assert(false)`, a block containing any such +/// statement, an `if` whose both arms exit, a `try` whose every clause exits, or a `do-while` +/// whose body exits without `break`/`continue`. +pub fn branch_always_exits(gcx: Gcx<'_>, stmt: &Stmt<'_>) -> bool { + match &stmt.kind { + StmtKind::Return(_) | StmtKind::Revert(_) => true, + StmtKind::Expr(expr) => is_exit_call(gcx, expr), + StmtKind::Block(b) | StmtKind::UncheckedBlock(b) => { + b.stmts.iter().any(|expr| branch_always_exits(gcx, expr)) + } + StmtKind::If(_, t, Some(e)) => branch_always_exits(gcx, t) && branch_always_exits(gcx, e), + StmtKind::Loop(block, LoopSource::DoWhile) => { + let user = do_while_user_stmts(block.stmts); + !stmts_break_or_continue(user) && user.iter().any(|expr| branch_always_exits(gcx, expr)) + } + StmtKind::Try(t) => { + !t.clauses.is_empty() + && t.clauses + .iter() + .all(|c| c.block.stmts.iter().any(|expr| branch_always_exits(gcx, expr))) + } + _ => false, + } +} + +/// The `for` update statement of a loop, which runs after every iteration. +pub const fn loop_update<'gcx>(source: LoopSource<'gcx>) -> Option<&'gcx Stmt<'gcx>> { + match source { + LoopSource::For { update } => update, + LoopSource::While | LoopSource::DoWhile => None, + } +} + +/// The statements of one loop iteration: the body followed by the `for` update, if any. +pub fn loop_stmts<'gcx>( + block: hir::Block<'gcx>, + source: LoopSource<'gcx>, +) -> impl Iterator> + Clone { + block.stmts.iter().chain(loop_update(source)) +} + +/// Number of `_` placeholders in `stmts`, recursing into nested control flow. +pub fn count_placeholders(stmts: &[Stmt<'_>]) -> usize { + stmts.iter().map(count_placeholders_in_stmt).sum() +} + +fn count_placeholders_in_stmt(stmt: &Stmt<'_>) -> usize { + match &stmt.kind { + StmtKind::Placeholder => 1, + StmtKind::Block(b) | StmtKind::UncheckedBlock(b) => count_placeholders(b.stmts), + StmtKind::Loop(b, source) => loop_stmts(*b, *source).map(count_placeholders_in_stmt).sum(), + StmtKind::If(_, t, e) => { + count_placeholders_in_stmt(t) + e.as_ref().map_or(0, |e| count_placeholders_in_stmt(e)) + } + StmtKind::Try(t) => t.clauses.iter().map(|c| count_placeholders(c.block.stmts)).sum(), + _ => 0, + } +} + +/// Collects the statements executed before the first placeholder of a modifier body, following +/// nested blocks. Returns `None` when the placeholder is not reached unconditionally (e.g. it is +/// inside an `if`, loop or `try`). +pub fn stmts_before_placeholder<'a, 'gcx>( + stmts: &'a [Stmt<'gcx>], + out: &mut Vec<&'a Stmt<'gcx>>, +) -> Option<()> { + for (i, stmt) in stmts.iter().enumerate() { + match &stmt.kind { + StmtKind::Placeholder => { + out.extend(&stmts[..i]); + return Some(()); + } + StmtKind::Block(b) | StmtKind::UncheckedBlock(b) if count_placeholders(b.stmts) > 0 => { + out.extend(&stmts[..i]); + return stmts_before_placeholder(b.stmts, out); + } + _ if count_placeholders_in_stmt(stmt) > 0 => return None, + _ => {} + } + } + None +} + +/// Strips the trailing `if (cond) break;` that lowers `do { ... } while (cond);`. +pub fn do_while_user_stmts<'a, 'gcx>(stmts: &'a [Stmt<'gcx>]) -> &'a [Stmt<'gcx>] { + match stmts.split_last() { + Some((last, rest)) if is_loop_termination_if(last) => rest, + _ => stmts, + } +} + +/// `if (...) break;` as synthesized by the `do-while` lowering. +pub fn is_loop_termination_if(stmt: &Stmt<'_>) -> bool { + let StmtKind::If(_, t, e) = &stmt.kind else { return false }; + is_break_stmt(t) || e.as_ref().is_some_and(|e| is_break_stmt(e)) +} + +/// `break`, possibly wrapped in single-statement blocks. +pub fn is_break_stmt(stmt: &Stmt<'_>) -> bool { + match &stmt.kind { + StmtKind::Break => true, + StmtKind::Block(b) | StmtKind::UncheckedBlock(b) => { + b.stmts.len() == 1 && is_break_stmt(&b.stmts[0]) + } + _ => false, + } +} + +/// `break`/`continue` targeting the current loop (nested loops shadow them). +pub fn stmts_break_or_continue(stmts: &[Stmt<'_>]) -> bool { + stmts.iter().any(|stmt| match &stmt.kind { + StmtKind::Break | StmtKind::Continue => true, + StmtKind::Block(b) | StmtKind::UncheckedBlock(b) => stmts_break_or_continue(b.stmts), + StmtKind::If(_, t, e) => { + stmts_break_or_continue(std::slice::from_ref(*t)) + || e.is_some_and(|e| stmts_break_or_continue(std::slice::from_ref(e))) + } + StmtKind::Try(t) => t.clauses.iter().any(|c| stmts_break_or_continue(c.block.stmts)), + _ => false, + }) +} + +/// The statements a modifier runs before its unique `_;`, when that placeholder is reached +/// unconditionally. `None` for non-modifiers, bodiless modifiers and conditional placeholders. +pub fn modifier_prefix<'gcx>( + hir: &'gcx hir::Hir<'gcx>, + fid: FunctionId, +) -> Option>> { + let modifier = hir.function(fid); + let body = modifier.body.filter(|_| modifier.kind == FunctionKind::Modifier)?; + if count_placeholders(body.stmts) != 1 { + return None; + } + let mut prefix = Vec::new(); + stmts_before_placeholder(body.stmts, &mut prefix)?; + Some(prefix) +} diff --git a/crates/lint/src/sol/analysis/types.rs b/crates/lint/src/sol/analysis/types.rs new file mode 100644 index 0000000000000..8c93d457c4452 --- /dev/null +++ b/crates/lint/src/sol/analysis/types.rs @@ -0,0 +1,47 @@ +//! Type probes using Solar's type-checker results. + +use solar::sema::{ + Gcx, Ty, + hir::{self, ContractId, Expr, TypeKind, VariableId}, + ty::TyKind, +}; + +/// True if `vid` is typed as `address`/`address payable`. +pub fn is_address_type(hir: &hir::Hir<'_>, vid: VariableId) -> bool { + matches!(hir.variable(vid).ty.kind, TypeKind::Elementary(hir::ElementaryType::Address(_))) +} + +/// True if `id`'s elementary type matches the given ABI string. +pub fn is_elementary(hir: &hir::Hir<'_>, id: VariableId, abi: &str) -> bool { + matches!(&hir.variable(id).ty.kind, TypeKind::Elementary(ty) if ty.to_abi_str() == abi) +} + +/// `address` / `address payable` after peeling references. +pub fn ty_is_address(ty: Ty<'_>) -> bool { + ty.peel_refs().is_address() +} + +/// The contract a type denotes, through references and `type(C)`. +pub fn ty_contract_id(ty: Ty<'_>) -> Option { + match ty.peel_refs().kind { + TyKind::Contract(id) => Some(id), + TyKind::Type(ty) => ty_contract_id(ty), + _ => None, + } +} + +/// True when `expr`'s type-checked static type is `address` / `address payable`. +pub fn expr_is_address<'gcx>(gcx: Gcx<'gcx>, expr: &Expr<'gcx>) -> bool { + gcx.type_of_expr(expr.peel_parens().id).is_some_and(ty_is_address) +} + +/// Static contract type of a method-call receiver or direct contract/library reference. +pub fn receiver_contract_id<'gcx>(gcx: Gcx<'gcx>, recv: &Expr<'gcx>) -> Option { + gcx.type_of_expr(recv.peel_parens().id).and_then(ty_contract_id) +} + +/// The only element of `iter`, or `None` when it has zero or several. +pub fn unique(mut iter: impl Iterator) -> Option { + let first = iter.next()?; + iter.next().is_none().then_some(first) +} diff --git a/crates/lint/src/sol/codesize/mod.rs b/crates/lint/src/sol/codesize/mod.rs new file mode 100644 index 0000000000000..6998538fe54c0 --- /dev/null +++ b/crates/lint/src/sol/codesize/mod.rs @@ -0,0 +1,7 @@ +use crate::sol::SolLint; + +mod unwrapped_modifier_logic; + +register_lints!( + unwrapped_modifier_logic: (UnwrappedModifierLogic, late, (UNWRAPPED_MODIFIER_LOGIC)); +); diff --git a/crates/lint/src/sol/codesize/unwrapped_modifier_logic.rs b/crates/lint/src/sol/codesize/unwrapped_modifier_logic.rs new file mode 100644 index 0000000000000..e4a306e345425 --- /dev/null +++ b/crates/lint/src/sol/codesize/unwrapped_modifier_logic.rs @@ -0,0 +1,213 @@ +use super::UnwrappedModifierLogic; +use crate::{ + linter::{LateLintPass, LintContext, Suggestion}, + sol::{ + Severity, SolLint, + analysis::{ + block_outcome, count_placeholders, for_each_lhs_var, referenced_item, write_target, + }, + }, +}; +use solar::{ + ast::{ContractKind, FunctionKind}, + interface::diagnostics::Applicability, + sema::{ + Gcx, + hir::{self, Expr, ExprKind, Function, ItemId, Stmt, StmtKind, Visit as _}, + }, +}; +use std::ops::ControlFlow; + +declare_forge_lint!( + UNWRAPPED_MODIFIER_LOGIC, + Severity::CodeSize, + "unwrapped-modifier-logic", + "modifier logic can be wrapped to reduce code size" +); + +impl<'gcx> LateLintPass<'gcx> for UnwrappedModifierLogic { + fn check_function(&mut self, ctx: &LintContext, gcx: Gcx<'gcx>, func: &'gcx Function<'gcx>) { + let (FunctionKind::Modifier, Some(body), Some(name)) = (func.kind, func.body, func.name) + else { + return; + }; + if block_outcome(gcx, body).can_skip_placeholder() { + return; + } + // Only a single, top-level placeholder can be split around: extracting a placeholder + // nested in an `if`/loop/`try` into a helper would change behavior. + if count_placeholders(body.stmts) != 1 { + return; + } + let Some(idx) = body.stmts.iter().position(|s| matches!(s.kind, StmtKind::Placeholder)) + else { + return; + }; + let (before, after) = (&body.stmts[..idx], &body.stmts[idx + 1..]); + if let Some(suggestion) = snippet(ctx, gcx, func, name.as_str(), before, after) { + ctx.emit_with_suggestion( + &UNWRAPPED_MODIFIER_LOGIC, + func.span.to(func.body_span), + suggestion, + ); + } + } +} + +/// A call to a non-builtin function or to a library function: the only statement cheap enough to +/// leave inline. +fn is_plain_call(gcx: Gcx<'_>, expr: &Expr<'_>) -> bool { + let ExprKind::Call(callee, ..) = &expr.kind else { return false }; + match &callee.kind { + ExprKind::Ident(_) => gcx.resolved_builtin(callee).is_none(), + ExprKind::Member(base, _) => { + matches!(referenced_item(gcx, base), Some(ItemId::Contract(id)) + if gcx.hir.contract(id).kind == ContractKind::Library) + } + _ => false, + } +} + +/// Whether `stmts` should move into a helper: anything but a single plain call requires wrapping. +/// Inline assembly is left alone; its authors know how to manage code size and have a reason to +/// use it in a modifier. +fn requires_wrapping(gcx: Gcx<'_>, stmts: &[Stmt<'_>]) -> bool { + let (mut calls, mut other) = (0, false); + for stmt in stmts { + match &stmt.kind { + StmtKind::Placeholder => {} + StmtKind::Expr(expr) if is_plain_call(gcx, expr) => calls += 1, + StmtKind::AssemblyBlock(_) | StmtKind::Switch(_) | StmtKind::Err(_) => return false, + _ => other = true, + } + } + other || calls > 1 +} + +fn snippet<'gcx>( + ctx: &LintContext, + gcx: Gcx<'gcx>, + func: &'gcx Function<'gcx>, + name: &str, + before: &'gcx [Stmt<'gcx>], + after: &'gcx [Stmt<'gcx>], +) -> Option { + let hir = &gcx.hir; + let (wrap_before, wrap_after) = (requires_wrapping(gcx, before), requires_wrapping(gcx, after)); + if !(wrap_before || wrap_after) { + return None; + } + + // Extracted helpers only receive the modifier's parameters, so a local declared before the + // placeholder and used after it, or a parameter mutated before it and read after it (the + // helper would only mutate its by-value copy), makes the rewrite unsafe. Only top-level + // declarations matter: nested ones are scoped to their block. + let mut shared = Vec::new(); + for stmt in before { + match &stmt.kind { + StmtKind::DeclSingle(id) => shared.push(*id), + StmtKind::DeclMulti(ids, _) => shared.extend(ids.iter().flatten()), + _ => {} + } + } + if wrap_before { + any_expr(hir, before, |expr| { + if let Some(lvalue) = write_target(expr) { + for_each_lhs_var(gcx, lvalue, &mut |v| { + if func.parameters.contains(&v) && !shared.contains(&v) { + shared.push(v); + } + }); + } + false + }); + } + if any_expr(hir, after, |expr| gcx.resolved_variable(expr).is_some_and(|v| shared.contains(&v))) + { + return None; + } + + let (mut param_list, mut param_decls) = (Vec::new(), Vec::new()); + for &var_id in func.parameters { + let var = hir.variable(var_id); + // Unnamed parameters cannot be forwarded to the helper. + let Some(ident) = var.name else { continue }; + let ty = ctx.span_to_snippet(var.ty.span).unwrap_or_else(|| "/* unknown type */".into()); + param_list.push(ident.to_string()); + param_decls.push(format!("{ty} {ident}")); + } + let (param_list, param_decls) = (param_list.join(", "), param_decls.join(", ")); + let body_indent = " ".repeat( + ctx.get_span_indentation(before.first().or(after.first()).map_or(func.span, |s| s.span)), + ); + let mod_indent = " ".repeat(ctx.get_span_indentation(func.span)); + let (before_suffix, after_suffix) = + if wrap_before && wrap_after { ("Before", "After") } else { ("", "") }; + + // A side that needs wrapping becomes a helper call plus the helper definition; the other side + // is preserved verbatim so the rewrite never drops statements. + let side = |stmts: &[Stmt<'_>], wrap: bool, suffix: &str| -> Option<(Vec, String)> { + if !wrap { + let lines = stmts + .iter() + .map(|s| Some(format!("{body_indent}{}", ctx.span_to_snippet(s.span)?))) + .collect::>()?; + return Some((lines, String::new())); + } + let body = stmts + .iter() + .map(|s| Some(format!("\n{body_indent}{}", ctx.span_to_snippet(s.span)?))) + .collect::>()?; + Some(( + vec![format!("{body_indent}_{name}{suffix}({param_list});")], + format!( + "\n\n{mod_indent}function _{name}{suffix}({param_decls}) internal {{{body}\n{mod_indent}}}" + ), + )) + }; + let (before_lines, before_helper) = side(before, wrap_before, before_suffix)?; + let (after_lines, after_helper) = side(after, wrap_after, after_suffix)?; + let body = before_lines + .into_iter() + .chain([format!("{body_indent}_;")]) + .chain(after_lines) + .collect::>() + .join("\n"); + let replacement = format!( + "modifier {name}({param_decls}) {{\n{body}\n{mod_indent}}}{before_helper}{after_helper}" + ); + Some( + Suggestion::fix(replacement, Applicability::MachineApplicable) + .with_desc("wrap modifier logic to reduce code size"), + ) +} + +/// Visits every expression under `stmts`, stopping as soon as `f` returns `true`. +fn any_expr<'gcx>( + hir: &'gcx hir::Hir<'gcx>, + stmts: &'gcx [Stmt<'gcx>], + f: impl FnMut(&'gcx Expr<'gcx>) -> bool, +) -> bool { + let mut finder = ExprFinder { hir, f }; + stmts.iter().any(|stmt| finder.visit_stmt(stmt).is_break()) +} + +struct ExprFinder<'gcx, F> { + hir: &'gcx hir::Hir<'gcx>, + f: F, +} + +impl<'gcx, F: FnMut(&'gcx Expr<'gcx>) -> bool> hir::Visit<'gcx> for ExprFinder<'gcx, F> { + type BreakValue = (); + + fn hir(&self) -> &'gcx hir::Hir<'gcx> { + self.hir + } + + fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow<()> { + if (self.f)(expr) { + return ControlFlow::Break(()); + } + self.walk_expr(expr) + } +} diff --git a/crates/lint/src/sol/gas/cache_array_length.rs b/crates/lint/src/sol/gas/cache_array_length.rs new file mode 100644 index 0000000000000..176cc5786101e --- /dev/null +++ b/crates/lint/src/sol/gas/cache_array_length.rs @@ -0,0 +1,161 @@ +use super::CacheArrayLength; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{Severity, SolLint, analysis::for_each_lhs_var}, +}; +use solar::{ + ast::ElementaryType, + interface::{Span, data_structures::Never, sym}, + sema::{ + Gcx, + builtins::Builtin, + hir::{ + self, BinOpKind, Expr, ExprKind, LoopSource, StateMutability, Stmt, StmtKind, + VariableId, Visit as _, + }, + ty::TyKind, + }, +}; +use std::ops::ControlFlow; + +declare_forge_lint!( + CACHE_ARRAY_LENGTH, + Severity::Gas, + "cache-array-length", + "array length is read on every loop iteration; cache it outside the loop" +); + +impl<'gcx> LateLintPass<'gcx> for CacheArrayLength { + fn check_stmt(&mut self, ctx: &LintContext, gcx: Gcx<'gcx>, stmt: &'gcx Stmt<'gcx>) { + let StmtKind::Loop(block, LoopSource::For { .. }) = &stmt.kind else { return }; + // `for (init; cond; update) body` lowers to `loop { if (cond) { body } else break }` with + // the update kept on the loop source. + let Some(Stmt { kind: StmtKind::If(condition, _, Some(else_stmt)), .. }) = + block.stmts.first() + else { + return; + }; + if !matches!(else_stmt.kind, StmtKind::Break) { + return; + } + + let mut reads = Vec::new(); + collect_length_reads(gcx, condition, &mut reads); + if reads.is_empty() { + return; + } + + let mut facts = LoopFacts { gcx, written: Vec::new(), skip: false }; + let _ = facts.visit_stmt(stmt); + if facts.skip { + return; + } + for (span, var) in reads { + if !facts.written.contains(&var) { + ctx.emit(&CACHE_ARRAY_LENGTH, span); + } + } + } +} + +/// Collects `.length` reads compared against an identifier in `expr`. +fn collect_length_reads<'gcx>( + gcx: Gcx<'gcx>, + expr: &'gcx Expr<'gcx>, + reads: &mut Vec<(Span, VariableId)>, +) { + let ExprKind::Binary(lhs, op, rhs) = &expr.peel_parens().kind else { return }; + match op.kind { + BinOpKind::And | BinOpKind::Or => { + collect_length_reads(gcx, lhs, reads); + collect_length_reads(gcx, rhs, reads); + } + kind if kind.is_cmp() => { + for (side, other) in [(lhs, rhs), (rhs, lhs)] { + let side = side.peel_parens(); + if matches!(other.peel_parens().kind, ExprKind::Ident(_)) + && let ExprKind::Member(base, member) = &side.kind + && member.name == sym::length + && let Some(var) = state_dyn_array(gcx, base) + { + reads.push((side.span, var)); + } + } + } + _ => {} + } +} + +/// Loop body facts that make caching unsafe: variables written, array length mutations and +/// calls that may mutate state. +struct LoopFacts<'gcx> { + gcx: Gcx<'gcx>, + written: Vec, + skip: bool, +} + +impl<'gcx> hir::Visit<'gcx> for LoopFacts<'gcx> { + type BreakValue = Never; + + fn hir(&self) -> &'gcx hir::Hir<'gcx> { + &self.gcx.hir + } + + fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow { + match &expr.kind { + ExprKind::Assign(lhs, ..) | ExprKind::Delete(lhs) => { + self.skip |= is_array_like(self.gcx, lhs); + for_each_lhs_var(self.gcx, lhs, &mut |v| self.written.push(v)); + } + ExprKind::Unary(op, inner) if op.kind.has_side_effects() => { + for_each_lhs_var(self.gcx, inner, &mut |v| self.written.push(v)); + } + ExprKind::Call(callee, ..) => { + self.skip |= call_may_mutate_state(self.gcx, callee); + } + _ => {} + } + self.walk_expr(expr) + } +} + +/// Whether a call may write storage; array `push`/`pop` count since they change the length. +fn call_may_mutate_state<'gcx>(gcx: Gcx<'gcx>, callee: &'gcx Expr<'gcx>) -> bool { + let callee = callee.peel_parens(); + match &callee.kind { + ExprKind::Type(_) => false, + ExprKind::Member(..) + if matches!( + gcx.resolved_builtin(callee), + Some(Builtin::ArrayPush0 | Builtin::ArrayPush | Builtin::ArrayPop) + ) => + { + true + } + _ => !matches!( + gcx.type_of_expr(callee.id).map(|ty| ty.peel_refs().kind), + Some(TyKind::Fn(f)) if f.state_mutability <= StateMutability::View + ), + } +} + +fn is_array_like<'gcx>(gcx: Gcx<'gcx>, expr: &Expr<'gcx>) -> bool { + gcx.type_of_expr(expr.peel_parens().id).is_some_and(|ty| { + matches!( + ty.peel_refs().kind, + TyKind::DynArray(_) | TyKind::Elementary(ElementaryType::Bytes) + ) + }) +} + +/// The state variable `expr` names, if it is a dynamic array. +fn state_dyn_array<'gcx>(gcx: Gcx<'gcx>, expr: &Expr<'gcx>) -> Option { + let expr = expr.peel_parens(); + let var = gcx.resolved_variable(expr)?; + (gcx.hir.variable(var).is_state_variable() + && matches!( + gcx.type_of_expr(expr.id).map(|ty| ty.peel_refs().kind), + Some(TyKind::DynArray(_)) + )) + .then_some(var) +} diff --git a/crates/lint/src/sol/gas/costly_loop.rs b/crates/lint/src/sol/gas/costly_loop.rs new file mode 100644 index 0000000000000..53c39e9bd3650 --- /dev/null +++ b/crates/lint/src/sol/gas/costly_loop.rs @@ -0,0 +1,80 @@ +use super::CostlyLoop; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{Severity, SolLint, analysis::write_target}, +}; +use solar::{ + ast::DataLocation, + interface::data_structures::Never, + sema::{ + Gcx, Hir, + builtins::Builtin, + hir::{self, Expr, ExprKind, Function, Stmt, StmtKind, Visit as _}, + }, +}; +use std::ops::ControlFlow; + +declare_forge_lint!(COSTLY_LOOP, Severity::Gas, "costly-loop", "storage write inside a loop"); + +impl<'gcx> LateLintPass<'gcx> for CostlyLoop { + fn check_function(&mut self, ctx: &LintContext, gcx: Gcx<'gcx>, func: &'gcx Function<'gcx>) { + let mut finder = LoopWriteFinder { ctx, gcx, loop_depth: 0 }; + let _ = finder.visit_function(func); + } +} + +struct LoopWriteFinder<'a, 'gcx> { + ctx: &'a LintContext<'a, 'a>, + gcx: Gcx<'gcx>, + loop_depth: u32, +} + +impl<'gcx> hir::Visit<'gcx> for LoopWriteFinder<'_, 'gcx> { + type BreakValue = Never; + + fn hir(&self) -> &'gcx Hir<'gcx> { + &self.gcx.hir + } + + fn visit_stmt(&mut self, stmt: &'gcx Stmt<'gcx>) -> ControlFlow { + let is_loop = matches!(stmt.kind, StmtKind::Loop(..)); + self.loop_depth += is_loop as u32; + let flow = self.walk_stmt(stmt); + self.loop_depth -= is_loop as u32; + flow + } + + fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow { + if self.loop_depth > 0 + && write_target(expr).is_some_and(|lvalue| lvalue_is_state_var(self.gcx, lvalue)) + { + self.ctx.emit(&COSTLY_LOOP, expr.span); + } + self.walk_expr(expr) + } +} + +/// Returns `true` if the lvalue expression ultimately writes to a storage variable. +/// +/// Peels through index accesses, member accesses, and slices to find a state variable or an +/// expression that returns a storage reference. +fn lvalue_is_state_var(gcx: Gcx<'_>, expr: &Expr<'_>) -> bool { + let expr = expr.peel_parens(); + match &expr.kind { + ExprKind::Ident(_) => { + gcx.resolved_variable(expr).is_some_and(|id| gcx.hir.variable(id).is_state_variable()) + } + ExprKind::Call(callee, ..) => { + gcx.resolved_builtin(callee) == Some(Builtin::ArrayPush0) + || gcx + .type_of_expr(expr.id) + .is_some_and(|ty| ty.loc() == Some(DataLocation::Storage)) + } + ExprKind::Index(base, _) + | ExprKind::Slice(base, _, _) + | ExprKind::Member(base, _) + | ExprKind::Payable(base) => lvalue_is_state_var(gcx, base), + ExprKind::Tuple(exprs) => exprs.iter().flatten().any(|e| lvalue_is_state_var(gcx, e)), + _ => false, + } +} diff --git a/crates/lint/src/sol/gas/custom_errors.rs b/crates/lint/src/sol/gas/custom_errors.rs new file mode 100644 index 0000000000000..ee6ecd60e602c --- /dev/null +++ b/crates/lint/src/sol/gas/custom_errors.rs @@ -0,0 +1,39 @@ +use super::CustomErrors; +use crate::{ + linter::{EarlyLintPass, LintContext}, + sol::{Severity, SolLint}, +}; +use solar::{ + ast::{CallArgs, CallArgsKind, Expr, ExprKind, LitKind}, + interface::{kw, sym}, +}; + +declare_forge_lint!( + CUSTOM_ERRORS, + Severity::Gas, + "custom-errors", + "`revert` or `require` call does not use a custom error" +); + +impl<'ast> EarlyLintPass<'ast> for CustomErrors { + fn check_expr(&mut self, ctx: &LintContext, expr: &'ast Expr<'ast>) { + let ExprKind::Call(callee, CallArgs { kind: CallArgsKind::Unnamed(args), .. }) = &expr.kind + else { + return; + }; + let ExprKind::Ident(ident) = &callee.kind else { return }; + // `require(cond)` / `require(cond, "reason")` and `revert()` / `revert("reason")`. + let lint = match ident.name { + sym::require => args.len() == 1 || args.get(1).is_some_and(|e| is_string_literal(e)), + kw::Revert => args.first().is_none_or(|e| is_string_literal(e)), + _ => false, + }; + if lint { + ctx.emit(&CUSTOM_ERRORS, expr.span); + } + } +} + +const fn is_string_literal(expr: &Expr<'_>) -> bool { + matches!(&expr.kind, ExprKind::Lit(lit, _) if matches!(lit.kind, LitKind::Str(..))) +} diff --git a/crates/lint/src/sol/gas/external_function.rs b/crates/lint/src/sol/gas/external_function.rs new file mode 100644 index 0000000000000..516c6f5bcbb90 --- /dev/null +++ b/crates/lint/src/sol/gas/external_function.rs @@ -0,0 +1,252 @@ +use super::ExternalFunction; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{Severity, SolLint, analysis::is_builtin}, +}; +use solar::{ + ast::{ContractKind, DataLocation, Visibility}, + interface::{data_structures::Never, sym}, + sema::{ + Gcx, + hir::{ + self, ContractId, Expr, ExprKind, FunctionId, Stmt, StmtKind, VariableId, Visit as _, + }, + ty::TyKind, + }, +}; +use std::{ + cell::RefCell, + collections::{HashMap, HashSet}, + ops::ControlFlow, + rc::Rc, +}; + +declare_forge_lint!( + EXTERNAL_FUNCTION, + Severity::Gas, + "external-function", + "`public` function can be declared `external`" +); + +#[derive(Default)] +struct ProjectIndex { + /// Functions referenced by name anywhere in the project: internal calls (`foo()`) and + /// function pointers (`fn = foo;`). + referenced: HashSet, + /// Contracts containing a `super` access, keyed by its resolved function. + super_called: HashMap>, +} + +thread_local! { + /// Project index keyed by the HIR address, which is stable for the whole lint run, so the + /// index is built once instead of once per contract. + static PROJECT_INDEX: RefCell)>> = const { RefCell::new(None) }; +} + +fn project_index(gcx: Gcx<'_>) -> Rc { + let key = std::ptr::from_ref(&gcx.hir) as usize; + PROJECT_INDEX.with_borrow_mut(|slot| match slot { + Some((cached_key, index)) if *cached_key == key => index.clone(), + _ => slot.insert((key, Rc::new(build_project_index(gcx)))).1.clone(), + }) +} + +fn build_project_index(gcx: Gcx<'_>) -> ProjectIndex { + let hir = &gcx.hir; + let mut builder = IndexBuilder { gcx, index: ProjectIndex::default(), contract: None }; + for func in hir.functions() { + builder.contract = func.contract; + let _ = builder.visit_function(func); + } + // State variable initializers run in the synthesized constructor. + for var in hir.variables().filter(|var| var.is_state_variable()) { + builder.contract = var.contract; + let _ = builder.visit_var(var); + } + builder.index +} + +struct IndexBuilder<'gcx> { + gcx: Gcx<'gcx>, + index: ProjectIndex, + /// Contract being walked, to attribute `super.` accesses to the caller. + contract: Option, +} + +impl<'gcx> hir::Visit<'gcx> for IndexBuilder<'gcx> { + type BreakValue = Never; + + fn hir(&self) -> &'gcx hir::Hir<'gcx> { + &self.gcx.hir + } + + fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow { + match &expr.kind { + ExprKind::Ident(_) => self.index.referenced.extend(self.gcx.resolved_function(expr)), + ExprKind::Member(base, _) if is_builtin(self.gcx, base, sym::super_) => { + if let Some(cid) = self.contract + && let Some(fid) = self.gcx.resolved_function(expr) + { + self.index.super_called.entry(fid).or_default().insert(cid); + } + } + ExprKind::Member(base, _) + if matches!(self.gcx.type_of_expr(base.id).map(|ty| ty.kind), + Some(TyKind::Type(ty)) if matches!(ty.kind, TyKind::Contract(_))) => + { + self.index.referenced.extend(self.gcx.resolved_function(expr)); + } + _ => {} + } + self.walk_expr(expr) + } +} + +impl<'gcx> LateLintPass<'gcx> for ExternalFunction { + fn check_nested_contract( + &mut self, + ctx: &LintContext, + gcx: Gcx<'gcx>, + contract_id: ContractId, + ) { + let contract = gcx.hir.contract(contract_id); + // Libraries have different `external` semantics (delegatecall vs inlining); interfaces + // have no bodies. + if !ctx.is_lint_enabled(EXTERNAL_FUNCTION.id) + || !matches!(contract.kind, ContractKind::Contract | ContractKind::AbstractContract) + || contract.linearization_failed() + { + return; + } + let index = project_index(gcx); + + for fid in contract.functions() { + let func = gcx.hir.function(fid); + // Overrides can only widen visibility (`external` -> `public`), so the base chain is + // flagged instead; abstract declarations must stay `public` to be overridable. + let Some(name) = func.name else { continue }; + if func.visibility != Visibility::Public + || !func.is_ordinary() + || func.override_ + || func.body.is_none() + { + continue; + } + // Only reference parameters currently in `memory` yield meaningful savings. + if !func.parameters.iter().any(|&p| is_memory_reference(gcx, p)) { + continue; + } + let mut finder = ParamEscapeFinder { gcx, params: func.parameters }; + if finder.visit_function(func).is_break() { + continue; + } + let super_called = index.super_called.iter().any(|(&target, callers)| { + callers.iter().any(|&caller| { + gcx.hir.contracts_enumerated().any(|(cid, contract)| { + contract.linearized_bases.contains(&caller) + && gcx.resolve_super_function(cid, caller, target) == fid + }) + }) + }); + // A reference to this function's virtual target in a descendant also uses its slot. + let override_referenced = gcx + .hir + .contracts_enumerated() + .filter(|(cid, c)| *cid == contract_id || c.linearized_bases.contains(&contract_id)) + .any(|(cid, _)| index.referenced.contains(&gcx.resolve_virtual_function(cid, fid))); + if !super_called && !override_referenced { + ctx.emit(&EXTERNAL_FUNCTION, name.span); + } + } + } +} + +/// Breaks when a parameter is written, aliased, passed to a callee or modifier that could mutate +/// it through the internal-call memory-reference aliasing rule. +struct ParamEscapeFinder<'a, 'gcx> { + gcx: Gcx<'gcx>, + params: &'a [VariableId], +} + +impl ParamEscapeFinder<'_, '_> { + fn is_param(&self, expr: &Expr<'_>) -> bool { + root_var_is(self.gcx, expr, &|v| self.params.contains(&v)) + } +} + +impl<'gcx> hir::Visit<'gcx> for ParamEscapeFinder<'_, 'gcx> { + type BreakValue = (); + + fn hir(&self) -> &'gcx hir::Hir<'gcx> { + &self.gcx.hir + } + + fn visit_modifier(&mut self, modifier: &'gcx hir::Modifier<'gcx>) -> ControlFlow<()> { + if modifier.args.exprs().any(|arg| self.is_param(arg)) { + return ControlFlow::Break(()); + } + self.walk_modifier(modifier) + } + + fn visit_stmt(&mut self, stmt: &'gcx Stmt<'gcx>) -> ControlFlow<()> { + if let StmtKind::DeclSingle(vid) = &stmt.kind + && let var = self.gcx.hir.variable(*vid) + && is_memory_reference(self.gcx, *vid) + && var.initializer.is_some_and(|init| self.is_param(init)) + { + return ControlFlow::Break(()); + } + self.walk_stmt(stmt) + } + + fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow<()> { + let escapes = match &expr.kind { + ExprKind::Assign(lhs, op, rhs) => { + self.is_param(lhs) + || (op.is_none() + && root_var_is(self.gcx, lhs, &|v| { + let var = self.gcx.hir.variable(v); + var.is_local_variable() && is_memory_reference(self.gcx, v) + }) + && self.is_param(rhs)) + } + ExprKind::Delete(inner) => self.is_param(inner), + ExprKind::Unary(op, inner) => op.kind.has_side_effects() && self.is_param(inner), + ExprKind::Call(callee, args) => { + let (callee, opts) = callee.split_call_options(); + !self + .gcx + .type_of_expr(callee.id) + .is_some_and(|ty| matches!(ty.kind, TyKind::Type(_))) + && (args.exprs().any(|arg| self.is_param(arg)) + || opts.is_some_and(|opts| { + opts.args.iter().any(|opt| self.is_param(&opt.value)) + }) + || matches!(&callee.peel_parens().kind, ExprKind::Member(receiver, _) + if self.is_param(receiver))) + } + _ => false, + }; + if escapes { + return ControlFlow::Break(()); + } + self.walk_expr(expr) + } +} + +fn is_memory_reference(gcx: Gcx<'_>, var: VariableId) -> bool { + gcx.type_of_item(var.into()).loc() == Some(DataLocation::Memory) +} + +/// Whether the variable at the root of `expr` (through parens, members, indexes and slices) +/// satisfies `pred`. +fn root_var_is(gcx: Gcx<'_>, expr: &Expr<'_>, pred: &impl Fn(VariableId) -> bool) -> bool { + match &expr.peel_parens().kind { + ExprKind::Ident(_) => gcx.resolved_variable(expr).is_some_and(pred), + ExprKind::Member(base, _) + | ExprKind::Payable(base) + | ExprKind::Index(base, _) + | ExprKind::Slice(base, ..) => root_var_is(gcx, base, pred), + _ => false, + } +} diff --git a/crates/lint/src/sol/gas/immutable.rs b/crates/lint/src/sol/gas/immutable.rs new file mode 100644 index 0000000000000..d1490bde1a8e7 --- /dev/null +++ b/crates/lint/src/sol/gas/immutable.rs @@ -0,0 +1,211 @@ +use super::UnchangedStateVariables; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{ + Severity, SolLint, + analysis::{for_each_lhs_var, is_contract_cast, loop_stmts, write_target}, + }, +}; +use solar::{ + ast::{ContractKind, ElementaryType}, + interface::{data_structures::Never, kw, sym}, + sema::{ + Gcx, + hir::{self, Expr, ExprKind, ItemId, Stmt, StmtKind, TypeKind, VariableId, Visit as _}, + }, +}; +use std::{collections::HashSet, ops::ControlFlow}; + +declare_forge_lint!( + COULD_BE_IMMUTABLE, + Severity::Gas, + "could-be-immutable", + "state variable could be declared `immutable`" +); + +declare_forge_lint!( + COULD_BE_CONSTANT, + Severity::Gas, + "could-be-constant", + "state variable could be declared `constant`" +); + +impl<'gcx> LateLintPass<'gcx> for UnchangedStateVariables { + fn check_nested_contract( + &mut self, + ctx: &LintContext, + gcx: Gcx<'gcx>, + contract_id: hir::ContractId, + ) { + let contract = gcx.hir.contract(contract_id); + // Only the most derived contract sees every write of its inheritance chain. + if contract.kind == ContractKind::Interface + || gcx.hir.contracts().any(|c| c.linearized_bases[1..].contains(&contract_id)) + { + return; + } + + // Constants accept any elementary type (value types plus `string`/`bytes`) and contract + // types, which is the broader filter and covers both lints. + let candidates = contract + .linearized_bases + .iter() + .flat_map(|&id| gcx.hir.contract(id).variables()) + .filter(|&id| { + let var = gcx.hir.variable(id); + var.mutability.is_none() + && matches!( + var.ty.kind, + TypeKind::Elementary(_) | TypeKind::Custom(ItemId::Contract(_)) + ) + }); + let functions = contract + .linearized_bases + .iter() + .flat_map(|&id| gcx.hir.contract(id).all_functions()) + .map(|id| gcx.hir.function(id)); + + // Inline assembly can write arbitrary storage slots. + if functions + .clone() + .any(|f| f.body.is_some_and(|body| body.stmts.iter().any(has_assembly_or_unknown))) + { + return; + } + + // Writes performed as side effects of state variable initializers block `constant` but are + // not valid `immutable` assignments, so they are tracked separately. + let mut initializer_writes = WriteCollector { gcx, writes: HashSet::new() }; + for id in candidates.clone() { + if let Some(init) = gcx.hir.variable(id).initializer { + let _ = initializer_writes.visit_expr(init); + } + } + // Modifier bodies are visited as ordinary functions, so their writes count as runtime. + let mut constructor_writes = WriteCollector { gcx, writes: HashSet::new() }; + let mut runtime_writes = WriteCollector { gcx, writes: HashSet::new() }; + for function in functions { + let collector = if function.is_constructor() { + &mut constructor_writes + } else { + &mut runtime_writes + }; + let _ = collector.visit_function(function); + } + + for var_id in candidates { + if runtime_writes.writes.contains(&var_id) { + continue; + } + let var = gcx.hir.variable(var_id); + let span = var.name.map_or(var.span, |name| name.span); + let constant_initializer = + var.initializer.is_some_and(|expr| is_compile_time_constant(gcx, expr)); + let written_in_constructor = constructor_writes.writes.contains(&var_id); + let immutable_type = gcx.type_of_item(var_id.into()).is_value_type(); + if constant_initializer + && !written_in_constructor + && !initializer_writes.writes.contains(&var_id) + { + ctx.emit(&COULD_BE_CONSTANT, span); + } else if immutable_type + && (written_in_constructor || (var.initializer.is_some() && !constant_initializer)) + { + ctx.emit(&COULD_BE_IMMUTABLE, span); + } + } + } +} + +fn has_assembly_or_unknown(stmt: &Stmt<'_>) -> bool { + match &stmt.kind { + StmtKind::AssemblyBlock(_) | StmtKind::Switch(_) | StmtKind::Err(_) => true, + StmtKind::Block(b) | StmtKind::UncheckedBlock(b) => { + b.stmts.iter().any(has_assembly_or_unknown) + } + StmtKind::Loop(b, source) => loop_stmts(*b, *source).any(has_assembly_or_unknown), + StmtKind::If(_, t, e) => { + has_assembly_or_unknown(t) || e.is_some_and(has_assembly_or_unknown) + } + StmtKind::Try(t) => { + t.clauses.iter().any(|c| c.block.stmts.iter().any(has_assembly_or_unknown)) + } + _ => false, + } +} + +/// Collects every variable at the root of an assigned, deleted or incremented lvalue. +struct WriteCollector<'gcx> { + gcx: Gcx<'gcx>, + writes: HashSet, +} + +impl<'gcx> hir::Visit<'gcx> for WriteCollector<'gcx> { + type BreakValue = Never; + + fn hir(&self) -> &'gcx hir::Hir<'gcx> { + &self.gcx.hir + } + + fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow { + if let Some(lvalue) = write_target(expr) { + for_each_lhs_var(self.gcx, lvalue, &mut |v| { + self.writes.insert(v); + }); + } + self.walk_expr(expr) + } +} + +fn is_compile_time_constant(gcx: Gcx<'_>, expr: &Expr<'_>) -> bool { + let is_const = |e: &Expr<'_>| is_compile_time_constant(gcx, e); + match &expr.kind { + ExprKind::Lit(_) | ExprKind::Type(_) | ExprKind::TypeCall(_) => true, + ExprKind::Ident(_) => { + gcx.resolved_variable(expr).is_some_and(|v| gcx.hir.variable(v).is_constant()) + } + ExprKind::Unary(op, inner) => !op.kind.has_side_effects() && is_const(inner), + ExprKind::Binary(lhs, _, rhs) => is_const(lhs) && is_const(rhs), + ExprKind::Ternary(c, t, f) => is_const(c) && is_const(t) && is_const(f), + ExprKind::Tuple(exprs) => exprs.iter().flatten().all(|e| is_const(e)), + ExprKind::Call(callee, args) => { + let (callee, opts) = callee.split_call_options(); + is_constant_call(gcx, callee) + && args.exprs().all(is_const) + && opts.is_none_or(|opts| opts.args.iter().all(|arg| is_const(&arg.value))) + } + // `type(T).min`/`type(T).max` for integer/enum types; `type(I).interfaceId` for + // interface types. + ExprKind::Member(base, member) => match (&base.kind, member.name) { + (ExprKind::TypeCall(ty), sym::min | sym::max) => matches!( + ty.kind, + TypeKind::Elementary(ElementaryType::Int(_) | ElementaryType::UInt(_)) + | TypeKind::Custom(ItemId::Enum(_)) + ), + (ExprKind::TypeCall(ty), sym::interfaceId) => matches!( + ty.kind, + TypeKind::Custom(ItemId::Contract(cid)) + if gcx.hir.contract(cid).kind == ContractKind::Interface + ), + _ => false, + }, + _ => false, + } +} + +/// Type casts (`address(0xCAFE)`, `IToken(addr)`) and the hashing / modular arithmetic builtins. +fn is_constant_call(gcx: Gcx<'_>, callee: &Expr<'_>) -> bool { + matches!(callee.kind, ExprKind::Type(_)) + || is_contract_cast(gcx, callee) + || gcx.resolved_builtin(callee).is_some_and(|b| { + matches!( + b.name(), + kw::Keccak256 + | kw::Addmod + | kw::Mulmod + | sym::sha256 + | sym::ripemd160 + | sym::ecrecover + ) + }) +} diff --git a/crates/lint/src/sol/gas/keccak.rs b/crates/lint/src/sol/gas/keccak.rs index 0bb129aef50aa..6a853a2d9de81 100644 --- a/crates/lint/src/sol/gas/keccak.rs +++ b/crates/lint/src/sol/gas/keccak.rs @@ -1,101 +1,41 @@ use super::AsmKeccak256; use crate::{ linter::{LateLintPass, LintContext}, - sol::{Severity, SolLint}, + sol::{Severity, SolLint, analysis::is_builtin}, +}; +use solar::{ + interface::kw, + sema::{ + Gcx, + hir::{self, ExprKind, StmtKind}, + }, }; -use solar_ast::{self as ast, Span}; -use solar_interface::kw; -use solar_sema::hir::{self}; declare_forge_lint!( ASM_KECCAK256, Severity::Gas, "asm-keccak256", - "use of inefficient hashing mechanism; consider using inline assembly" + "high-level `keccak256` call is a candidate for gas optimization" ); -impl<'hir> LateLintPass<'hir> for AsmKeccak256 { - fn check_stmt( - &mut self, - ctx: &LintContext<'_>, - hir: &'hir hir::Hir<'hir>, - stmt: &'hir hir::Stmt<'hir>, - ) { - let check_expr_and_emit_lint = - |expr: &'hir hir::Expr<'hir>, assign: Option, is_return: bool| { - if let Some(hash_arg) = extract_keccak256_arg(expr) { - self.emit_lint( - ctx, - hir, - stmt.span, - expr, - hash_arg, - AsmContext { _assign: assign, _is_return: is_return }, - ); - } - }; - - match stmt.kind { - hir::StmtKind::DeclSingle(var_id) => { - let var = hir.variable(var_id); - if let Some(init) = var.initializer { - // Constants should be optimized by the compiler, so no gas savings apply. - if !matches!(var.mutability, Some(hir::VarMut::Constant)) { - check_expr_and_emit_lint(init, var.name, false); - } - } - } - // Expressions that don't (directly) assign to a variable - hir::StmtKind::Expr(expr) - | hir::StmtKind::Emit(expr) - | hir::StmtKind::Revert(expr) - | hir::StmtKind::DeclMulti(_, expr) - | hir::StmtKind::If(expr, ..) => check_expr_and_emit_lint(expr, None, false), - hir::StmtKind::Return(Some(expr)) => check_expr_and_emit_lint(expr, None, true), - _ => (), +impl<'gcx> LateLintPass<'gcx> for AsmKeccak256 { + fn check_stmt(&mut self, ctx: &LintContext, gcx: Gcx<'gcx>, stmt: &'gcx hir::Stmt<'gcx>) { + let expr = match stmt.kind { + StmtKind::DeclSingle(var_id) => gcx.hir.variable(var_id).initializer, + StmtKind::Expr(expr) + | StmtKind::Emit(expr) + | StmtKind::Revert(expr) + | StmtKind::DeclMulti(_, expr) + | StmtKind::If(expr, ..) + | StmtKind::Return(Some(expr)) => Some(expr), + _ => None, + }; + if let Some(expr) = expr + && let ExprKind::Call(callee, args) = &expr.kind + && args.len() == 1 + && is_builtin(gcx, callee, kw::Keccak256) + { + ctx.emit(&ASM_KECCAK256, expr.span); } } } - -impl AsmKeccak256 { - /// Emits lints (when possible with fix suggestions) for inefficient `keccak256` calls. - fn emit_lint( - &self, - ctx: &LintContext<'_>, - _hir: &hir::Hir<'_>, - _stmt_span: Span, - call: &hir::Expr<'_>, - _hash: &hir::Expr<'_>, - _asm_ctx: AsmContext, - ) { - ctx.emit(&ASM_KECCAK256, call.span); - } -} - -/// If the expression is a call to `keccak256` with one argument, returns that argument. -fn extract_keccak256_arg<'hir>(expr: &'hir hir::Expr<'hir>) -> Option<&'hir hir::Expr<'hir>> { - let hir::ExprKind::Call( - callee, - hir::CallArgs { kind: hir::CallArgsKind::Unnamed(args), .. }, - .., - ) = &expr.kind - else { - return None; - }; - - let is_keccak = if let hir::ExprKind::Ident([hir::Res::Builtin(builtin)]) = callee.kind { - matches!(builtin.name(), kw::Keccak256) - } else { - return None; - }; - - if is_keccak && args.len() == 1 { Some(&args[0]) } else { None } -} - -// -- HELPER FUNCTIONS AND STRUCTS ---------------------------------------------------------------- - -#[derive(Debug, Clone, Copy)] -struct AsmContext { - _assign: Option, - _is_return: bool, -} diff --git a/crates/lint/src/sol/gas/mod.rs b/crates/lint/src/sol/gas/mod.rs index 9664f3baf6dcc..07e5becfae4f6 100644 --- a/crates/lint/src/sol/gas/mod.rs +++ b/crates/lint/src/sol/gas/mod.rs @@ -1,6 +1,23 @@ -use crate::sol::{EarlyLintPass, LateLintPass, SolLint}; +use crate::sol::SolLint; +mod cache_array_length; +mod costly_loop; +mod custom_errors; +mod external_function; +mod immutable; mod keccak; -use keccak::ASM_KECCAK256; +mod unused_state_variables; +mod var_read_using_this; +mod write_after_write; -register_lints!((AsmKeccak256, late, (ASM_KECCAK256))); +register_lints!( + keccak: (AsmKeccak256, late, (ASM_KECCAK256)); + cache_array_length: (CacheArrayLength, late, (CACHE_ARRAY_LENGTH)); + costly_loop: (CostlyLoop, late, (COSTLY_LOOP)); + custom_errors: (CustomErrors, early, (CUSTOM_ERRORS)); + immutable: (UnchangedStateVariables, late, (COULD_BE_IMMUTABLE, COULD_BE_CONSTANT)); + external_function: (ExternalFunction, late, (EXTERNAL_FUNCTION)); + unused_state_variables: (UnusedStateVariables, late, (UNUSED_STATE_VARIABLES)); + var_read_using_this: (VarReadUsingThis, late, (VAR_READ_USING_THIS)); + write_after_write: (WriteAfterWrite, late, (WRITE_AFTER_WRITE)); +); diff --git a/crates/lint/src/sol/gas/unused_state_variables.rs b/crates/lint/src/sol/gas/unused_state_variables.rs new file mode 100644 index 0000000000000..cf1e5a40f04d9 --- /dev/null +++ b/crates/lint/src/sol/gas/unused_state_variables.rs @@ -0,0 +1,70 @@ +use super::UnusedStateVariables; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{Severity, SolLint}, +}; +use solar::{ + ast::ContractKind, + interface::data_structures::Never, + sema::{ + Gcx, + hir::{self, Visit as _}, + }, +}; +use std::{collections::HashSet, ops::ControlFlow}; + +declare_forge_lint!( + UNUSED_STATE_VARIABLES, + Severity::Gas, + "unused-state-variables", + "state variable is never used" +); + +impl<'gcx> LateLintPass<'gcx> for UnusedStateVariables { + fn check_contract( + &mut self, + ctx: &LintContext, + gcx: Gcx<'gcx>, + contract: &'gcx hir::Contract<'gcx>, + ) { + if contract.kind == ContractKind::Interface { + return; + } + + // Functions (including modifier call args) and state variable initializers cover every + // variable reference in the contract. + let mut collector = UsedVarCollector { gcx, used: HashSet::new() }; + for func_id in contract.all_functions() { + let _ = collector.visit_nested_function(func_id); + } + for var_id in contract.variables() { + let _ = collector.visit_nested_var(var_id); + } + + // Constants and immutables do not occupy storage slots. + for var_id in contract.variables() { + let var = gcx.hir.variable(var_id); + if !var.is_constant() && !var.is_immutable() && !collector.used.contains(&var_id) { + ctx.emit(&UNUSED_STATE_VARIABLES, var.span); + } + } + } +} + +struct UsedVarCollector<'gcx> { + gcx: Gcx<'gcx>, + used: HashSet, +} + +impl<'gcx> hir::Visit<'gcx> for UsedVarCollector<'gcx> { + type BreakValue = Never; + + fn hir(&self) -> &'gcx hir::Hir<'gcx> { + &self.gcx.hir + } + + fn visit_expr(&mut self, expr: &'gcx hir::Expr<'gcx>) -> ControlFlow { + self.used.extend(self.gcx.resolved_variable(expr)); + self.walk_expr(expr) + } +} diff --git a/crates/lint/src/sol/gas/var_read_using_this.rs b/crates/lint/src/sol/gas/var_read_using_this.rs new file mode 100644 index 0000000000000..52aa8ad03494f --- /dev/null +++ b/crates/lint/src/sol/gas/var_read_using_this.rs @@ -0,0 +1,139 @@ +use super::VarReadUsingThis; +use crate::{ + linter::{LateLintPass, LintContext, Suggestion}, + sol::{Severity, SolLint, analysis::is_builtin}, +}; +use solar::{ + ast::{ContractKind, StateMutability}, + interface::{Symbol, data_structures::Never, diagnostics::Applicability, sym}, + sema::{ + Gcx, + hir::{self, CallArgs, Expr, ExprId, ExprKind, Function, Stmt, StmtKind, Visit as _}, + }, +}; +use std::ops::ControlFlow; + +declare_forge_lint!( + VAR_READ_USING_THIS, + Severity::Gas, + "var-read-using-this", + "call through `this` to a `view` or `pure` function incurs a `STATICCALL`" +); + +impl<'gcx> LateLintPass<'gcx> for VarReadUsingThis { + fn check_nested_contract( + &mut self, + ctx: &LintContext, + gcx: Gcx<'gcx>, + contract_id: hir::ContractId, + ) { + let contract = gcx.hir.contract(contract_id); + // `this` only exists in (abstract) contracts: libraries have none, interfaces no bodies. + if !matches!(contract.kind, ContractKind::Contract | ContractKind::AbstractContract) { + return; + } + + let mut finder = ThisReadFinder { ctx, gcx, try_target: None }; + // State variable initializers run in the synthesized constructor. + for var_id in contract.variables() { + let _ = finder.visit_nested_var(var_id); + } + // Only bodies defined in this contract; inherited ones are walked with their own contract. + for fid in contract.all_functions() { + let _ = finder.visit_nested_function(fid); + } + } +} + +struct ThisReadFinder<'a, 'gcx> { + ctx: &'a LintContext<'a, 'a>, + gcx: Gcx<'gcx>, + /// The expression tried by the enclosing `try` statement, which must stay an external call. + try_target: Option, +} + +impl<'gcx> hir::Visit<'gcx> for ThisReadFinder<'_, 'gcx> { + type BreakValue = Never; + + fn hir(&self) -> &'gcx hir::Hir<'gcx> { + &self.gcx.hir + } + + fn visit_stmt(&mut self, stmt: &'gcx Stmt<'gcx>) -> ControlFlow { + if let StmtKind::Try(try_stmt) = &stmt.kind { + self.try_target = Some(try_stmt.expr.id); + } + self.walk_stmt(stmt) + } + + fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow { + if self.try_target != Some(expr.id) { + self.check_call(expr); + } + self.walk_expr(expr) + } +} + +impl ThisReadFinder<'_, '_> { + /// Flags `this.(args)` when `` resolves to a `view`/`pure` function of the + /// current contract. + fn check_call(&self, expr: &Expr<'_>) { + let Some((callee, args, opts)) = expr.as_call() else { return }; + let ExprKind::Member(base, member) = &callee.peel_parens().kind else { return }; + if !is_builtin(self.gcx, base, sym::this) { + return; + } + let Some(function_id) = self.gcx.resolved_function(callee) else { return }; + let func = self.gcx.hir.function(function_id); + if !matches!(func.state_mutability, StateMutability::View | StateMutability::Pure) { + return; + } + // With call options like `{gas: ...}` the external call is deliberate: flag the gas waste + // without an auto-fix. + let suggestion = + if opts.is_some() { None } else { suggestion(self.ctx, func, member.name, args) }; + match suggestion { + Some(suggestion) => { + self.ctx.emit_with_suggestion(&VAR_READ_USING_THIS, expr.span, suggestion); + } + None => self.ctx.emit(&VAR_READ_USING_THIS, expr.span), + } + } +} + +fn suggestion( + ctx: &LintContext, + func: &Function<'_>, + name: Symbol, + args: &CallArgs<'_>, +) -> Option { + if !func.is_getter() { + // Ordinary `view`/`pure` functions may be `external`, requiring a refactor to call them. + return Some( + Suggestion::example(format!("call directly without `this.`: `{name}(...)`")) + .with_desc("avoid the `STATICCALL` by invoking the function directly"), + ); + } + // Struct getters destructure their fields, so a direct read is not equivalent. + if func.returns.len() != 1 { + return Some( + Suggestion::example(format!("read the state variable directly: `{name}`")) + .with_desc("read the state variable directly instead of via `this.`"), + ); + } + if args.is_empty() { + return Some( + Suggestion::fix(name.to_string(), Applicability::MachineApplicable) + .with_desc("consider reading the state variable directly"), + ); + } + // Mapping/array getter: `name[arg1][arg2]...`. + let mut indexed = name.to_string(); + for arg in args.exprs() { + indexed += &format!("[{}]", ctx.span_to_snippet(arg.span)?.trim()); + } + Some( + Suggestion::fix(indexed, Applicability::MaybeIncorrect) + .with_desc("consider accessing storage directly"), + ) +} diff --git a/crates/lint/src/sol/gas/write_after_write.rs b/crates/lint/src/sol/gas/write_after_write.rs new file mode 100644 index 0000000000000..3d2c88a1d72b6 --- /dev/null +++ b/crates/lint/src/sol/gas/write_after_write.rs @@ -0,0 +1,272 @@ +use super::WriteAfterWrite; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{Severity, SolLint, analysis::loop_update}, +}; +use solar::{ + interface::Span, + sema::{ + Gcx, + hir::{ + BinOpKind, Block, CallArgs, CallOptions, Expr, ExprKind, Function, Stmt, StmtKind, + VariableId, + }, + }, +}; +use std::collections::HashMap; + +declare_forge_lint!( + WRITE_AFTER_WRITE, + Severity::Gas, + "write-after-write", + "redundant storage write; value overwritten before being read" +); + +impl<'gcx> LateLintPass<'gcx> for WriteAfterWrite { + fn check_function(&mut self, ctx: &LintContext, gcx: Gcx<'gcx>, func: &'gcx Function<'gcx>) { + if let Some(body) = func.body { + Analyzer { ctx, gcx, pending: HashMap::new() }.check_block(body); + } + } +} + +/// Tracks state variable writes that no later read has observed yet; a second write to such a +/// variable makes the pending one redundant. +struct Analyzer<'a, 'gcx> { + ctx: &'a LintContext<'a, 'a>, + gcx: Gcx<'gcx>, + pending: HashMap, +} + +impl Analyzer<'_, '_> { + /// Returns whether control flow continues past the block. + fn check_block(&mut self, block: Block<'_>) -> bool { + block.stmts.iter().all(|stmt| self.check_stmt(stmt)) + } + + /// Returns whether control flow continues past the statement. + fn check_stmt(&mut self, stmt: &Stmt<'_>) -> bool { + match &stmt.kind { + StmtKind::Expr(expr) => self.process_expr(expr), + StmtKind::DeclSingle(var_id) => { + if let Some(init) = self.gcx.hir.variable(*var_id).initializer { + self.reads(init); + } + } + StmtKind::DeclMulti(_, expr) => self.reads(expr), + // `emit` only logs, so unlike a call it cannot observe pending writes. + StmtKind::Emit(expr) => match &expr.peel_parens().kind { + ExprKind::Call(callee, args) => { + let (callee, opts) = callee.split_call_options(); + self.read_call_parts(callee, args, opts); + } + _ => self.reads(expr), + }, + // Terminal statements: the code after them is unreachable and can never overwrite + // the pending writes. + StmtKind::Return(expr) => { + if let Some(expr) = expr { + self.reads(expr); + } + self.pending.clear(); + return false; + } + StmtKind::Revert(expr) => { + self.reads(expr); + self.pending.clear(); + return false; + } + StmtKind::Break | StmtKind::Continue => { + self.pending.clear(); + return false; + } + // Branches are analyzed in isolation so intra-branch pairs are still caught, while + // outer pending writes are dropped since any branch may observe or skip them. + StmtKind::If(cond, then_stmt, else_stmt) => { + self.reads(cond); + let then_continues = self.isolated(|this| this.check_stmt(then_stmt)); + if let Some(else_stmt) = else_stmt { + let else_continues = self.isolated(|this| this.check_stmt(else_stmt)); + return then_continues || else_continues; + } + } + // A loop may run zero times, so it never stops the outer flow. + StmtKind::Loop(block, source) => { + self.isolated(|this| { + this.check_block(*block) + && loop_update(*source).is_none_or(|update| this.check_stmt(update)) + }); + } + StmtKind::Try(try_stmt) => { + self.reads(&try_stmt.expr); + for clause in try_stmt.clauses { + self.isolated(|this| this.check_block(clause.block)); + } + } + // Nested blocks are sequential: they share pending writes and propagate terminal flow. + StmtKind::Block(block) | StmtKind::UncheckedBlock(block) => { + return self.check_block(*block); + } + // The placeholder runs the modified function; inline assembly and errors are opaque. + StmtKind::Placeholder + | StmtKind::AssemblyBlock(_) + | StmtKind::Switch(_) + | StmtKind::Err(_) => self.pending.clear(), + } + true + } + + /// Runs `f` on a fresh pending set and discards its result set afterwards. + fn isolated(&mut self, f: impl FnOnce(&mut Self) -> T) -> T { + self.pending.clear(); + let result = f(self); + self.pending.clear(); + result + } + + /// Tracks the writes and reads of an expression evaluated for its side effects. + fn process_expr(&mut self, expr: &Expr<'_>) { + let expr = expr.peel_parens(); + match &expr.kind { + ExprKind::Assign(lhs, op, rhs) => { + // Compound assignments read the LHS before writing it. + self.reads(rhs); + if op.is_some() { + self.reads(lhs); + if let Some(var) = self.state_var(lhs) { + self.pending.insert(var, expr.span); + } + } else { + self.write_lhs(lhs, expr.span); + } + } + // Pre/post increment and decrement read the variable, then write it. + ExprKind::Unary(op, inner) if op.kind.has_side_effects() => { + self.reads(inner); + if let Some(var) = self.state_var(inner) { + self.pending.insert(var, expr.span); + } + } + // `delete x` is a pure write. + ExprKind::Delete(inner) => match self.state_var(inner) { + Some(var) => self.write(var, expr.span), + None => self.reads(inner), + }, + // Any call may observe storage through re-entrancy or view semantics. + ExprKind::Call(callee, args) => { + let (callee, opts) = callee.split_call_options(); + self.read_call_parts(callee, args, opts); + self.pending.clear(); + } + _ => self.reads(expr), + } + } + + /// Records assignment writes, preserving each tuple component's span. + fn write_lhs(&mut self, lhs: &Expr<'_>, span: Span) { + match &lhs.peel_parens().kind { + ExprKind::Tuple(exprs) => { + exprs.iter().flatten().for_each(|e| self.write_lhs(e, e.span)); + } + _ => match self.state_var(lhs) { + Some(var) => self.write(var, span), + // Index/member access: computing the slot reads the base. + None => self.reads(lhs), + }, + } + } + + fn write(&mut self, var: VariableId, span: Span) { + if let Some(prev_span) = self.pending.insert(var, span) { + self.ctx.emit(&WRITE_AFTER_WRITE, prev_span); + } + } + + /// Removes every state variable read by `expr` from the pending writes. Nested writes are + /// tracked through [`Self::process_expr`]. + fn reads(&mut self, expr: &Expr<'_>) { + let expr = expr.peel_parens(); + match &expr.kind { + ExprKind::Ident(_) => { + if let Some(var) = self.gcx.resolved_variable(expr) { + self.pending.remove(&var); + } + } + ExprKind::Assign(..) | ExprKind::Delete(_) => self.process_expr(expr), + // Short-circuit operands and ternary arms may not execute, so they are isolated to + // avoid false positives on the conditional path. + ExprKind::Binary(lhs, op, rhs) if matches!(op.kind, BinOpKind::And | BinOpKind::Or) => { + self.reads(lhs); + self.isolated(|this| this.reads(rhs)); + } + ExprKind::Ternary(cond, then_expr, else_expr) => { + self.reads(cond); + self.isolated(|this| this.reads(then_expr)); + self.isolated(|this| this.reads(else_expr)); + } + ExprKind::Call(callee, args) => { + let (callee, opts) = callee.split_call_options(); + self.read_call_parts(callee, args, opts); + self.pending.clear(); + } + ExprKind::CallOptions(callee, opts) => { + self.reads(callee); + for opt in opts.args { + self.reads(&opt.value); + } + } + ExprKind::Binary(lhs, _, rhs) => { + self.reads(lhs); + self.reads(rhs); + } + ExprKind::Member(inner, _) => { + if let Some(var) = self.state_var(expr) { + self.pending.remove(&var); + } + self.reads(inner); + } + ExprKind::Unary(_, inner) | ExprKind::Payable(inner) => { + self.reads(inner); + } + ExprKind::Index(base, index) => { + self.reads(base); + if let Some(index) = index { + self.reads(index); + } + } + ExprKind::Slice(base, start, end) => { + self.reads(base); + for expr in [*start, *end].into_iter().flatten() { + self.reads(expr); + } + } + ExprKind::Tuple(exprs) => exprs.iter().flatten().for_each(|e| self.reads(e)), + ExprKind::Array(exprs) => exprs.iter().for_each(|e| self.reads(e)), + ExprKind::Lit(_) | ExprKind::New(_) | ExprKind::TypeCall(_) | ExprKind::Type(_) => {} + ExprKind::YulMember(..) | ExprKind::Err(_) => self.pending.clear(), + } + } + + /// Callee, arguments and call options are all evaluated before the call itself. + fn read_call_parts( + &mut self, + callee: &Expr<'_>, + args: &CallArgs<'_>, + opts: Option<&CallOptions<'_>>, + ) { + self.reads(callee); + for arg in args.exprs() { + self.reads(arg); + } + for opt in opts.into_iter().flat_map(|opts| opts.args) { + self.reads(&opt.value); + } + } + + /// The state variable a bare identifier refers to. + fn state_var(&self, expr: &Expr<'_>) -> Option { + self.gcx + .resolved_variable(expr) + .filter(|&var| self.gcx.hir.variable(var).is_state_variable()) + } +} diff --git a/crates/lint/src/sol/high/arbitrary_send_erc20.rs b/crates/lint/src/sol/high/arbitrary_send_erc20.rs new file mode 100644 index 0000000000000..2604672017832 --- /dev/null +++ b/crates/lint/src/sol/high/arbitrary_send_erc20.rs @@ -0,0 +1,1035 @@ +use super::ArbitrarySendErc20; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{ + Severity, SolLint, + analysis::{ + arg_for_param, branch_always_exits, expr_is_address, is_address_like_cast, + is_address_self, is_address_type, is_elementary, is_msg_sender, is_require_or_assert, + loop_update, modifier_prefix, receiver_contract_id, state_lhs_vars, tuple_elems, + underlying_var, + }, + }, +}; +use solar::{ + ast::{BinOpKind, StateMutability, UnOpKind, Visibility}, + interface::{Span, Symbol, data_structures::Never}, + sema::{ + Gcx, + hir::{ + self, CallArgs, ContractId, ContractKind, Expr, ExprKind, FunctionId, FunctionKind, + Hir, ItemId, LoopSource, Modifier, Stmt, StmtKind, TypeKind, VariableId, Visit, + }, + }, +}; +use std::{ + cell::RefCell, + collections::{HashMap, HashSet}, + hash::Hash, + ops::ControlFlow, + rc::Rc, +}; + +declare_forge_lint!( + ARBITRARY_SEND_ERC20, + Severity::High, + "arbitrary-send-erc20", + "`transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`" +); + +declare_forge_lint!( + ARBITRARY_SEND_ERC20_PERMIT, + Severity::High, + "arbitrary-send-erc20-permit", + "`transferFrom` uses an arbitrary `from` after `permit`" +); + +/// Recursion budget for `_msgSender()`-style helper chains. +const HELPER_DEPTH: u8 = 3; + +impl<'gcx> LateLintPass<'gcx> for ArbitrarySendErc20 { + fn check_function( + &mut self, + ctx: &LintContext, + gcx: Gcx<'gcx>, + func: &'gcx hir::Function<'gcx>, + ) { + // Library functions forward `from` from their caller; the call site is flagged instead. + if matches!(func.state_mutability, StateMutability::Pure | StateMutability::View) + || func.is_constructor() + || func.contract.is_some_and(|cid| gcx.hir.contract(cid).kind == ContractKind::Library) + { + return; + } + let Some(body) = func.body else { return }; + // A modifier prefix that always exits makes the body unreachable. + if func.modifiers.iter().any(|m| { + m.id.as_function() + .and_then(|fid| modifier_prefix(&gcx.hir, fid)) + .is_some_and(|p| p.iter().any(|s| branch_always_exits(gcx, s))) + }) { + return; + } + let mut a = Analyzer::new(gcx); + if let Some(cid) = func.contract { + a.seed_immutable_facts(cid); + } + a.seed_callsite_facts(func); + for m in func.modifiers { + a.hoist_modifier_facts(m); + } + a.visit_stmts(body.stmts); + for (span, lint) in a.hits { + ctx.emit(lint, span); + } + } +} + +/// Identifier correlating permit and sink token receivers: `token` or `cfg.token`. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +enum TokenKey { + Var(VariableId), + Field(VariableId, Symbol), +} + +impl TokenKey { + fn touches(self, v: VariableId) -> bool { + match self { + Self::Var(x) | Self::Field(x, _) => x == v, + } + } +} + +/// An EIP-2612 permit with `spender == address(this)` seen earlier on the current path. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +struct PermitRecord { + token: TokenKey, + owner: VariableId, +} + +/// Outstanding EIP-3156 repayment licensed by a prior `onFlashLoan` call. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +struct PendingRepayment { + receiver: VariableId, + token: VariableId, + amount: VariableId, + fee: VariableId, +} + +/// An ERC20 `transferFrom`-shaped sink. +struct Sink<'gcx> { + from: &'gcx Expr<'gcx>, + to: &'gcx Expr<'gcx>, + amount: &'gcx Expr<'gcx>, + token: Option, +} + +/// Facts about an assignment's RHS, captured before any write. +#[derive(Clone, Copy, Default)] +struct Rhs { + safe: bool, + is_self: bool, + alias: Option, + sum: Option<(VariableId, VariableId)>, +} + +/// Path-sensitive facts. +#[derive(Clone, Default)] +struct State { + /// Locals and `immutable`/`constant` state proven equal to `msg.sender` or `address(this)`. + /// Mutable storage may be rewritten between the check and the sink. + safe_vars: HashSet, + /// Subset of `safe_vars` proven equal to `address(this)`; recognises permit spenders. + self_vars: HashSet, + /// Permits seen on this path, keyed by canonical token / owner. + permits: HashSet, + /// Pending flash-loan repayments; each `onFlashLoan` call licenses one consumption. + repayments: HashMap, + /// `x = y` records `x -> canonical(y)`. + aliases: HashMap, + /// `x = a + b` records `x -> (a, b)`, matched against flash-repayment sums. + sum_of: HashMap, +} + +impl State { + fn meet(&self, other: &Self) -> Self { + Self { + safe_vars: self.safe_vars.intersection(&other.safe_vars).copied().collect(), + self_vars: self.self_vars.intersection(&other.self_vars).copied().collect(), + permits: self.permits.intersection(&other.permits).copied().collect(), + repayments: self + .repayments + .iter() + .filter_map(|(k, a)| other.repayments.get(k).map(|b| (*k, *a.min(b)))) + .collect(), + aliases: common_entries(&self.aliases, &other.aliases), + sum_of: common_entries(&self.sum_of, &other.sum_of), + } + } +} + +fn common_entries( + a: &HashMap, + b: &HashMap, +) -> HashMap { + a.iter().filter(|(k, v)| b.get(k) == Some(v)).map(|(k, v)| (*k, *v)).collect() +} + +struct Analyzer<'gcx> { + gcx: Gcx<'gcx>, + /// Gates the `using ... for address` sink form on a Solady-shaped library being present. + state: State, + /// States at `break`/`continue` of each enclosing loop, innermost last. + loop_exits: Vec>, + /// Repayment counts as they stood at the entry of each enclosing loop's body, innermost + /// last. A repayment can only be consumed inside a loop if its count exceeds this floor, + /// i.e. it was freshly minted since that loop was entered - a repayment already pending + /// beforehand must not license a sink the single-pass body walk merely happens to contain, + /// since at runtime that same sink may re-execute every iteration against the one license. + /// Unlike `State`, this is never snapshotted or restored around `if`/`try` branches - see + /// `invalidate`'s comment on why it must never be mutated in place mid-analysis. + loop_repayment_floors: Vec>, + /// Every variable written on any path. + written: HashSet, + hits: Vec<(Span, &'static SolLint)>, +} + +impl<'gcx> Analyzer<'gcx> { + fn new(gcx: Gcx<'gcx>) -> Self { + Self { + gcx, + state: State::default(), + loop_exits: Vec::new(), + loop_repayment_floors: Vec::new(), + written: HashSet::new(), + hits: Vec::new(), + } + } + + /// Seeds facts about `immutable`/`constant` state of `cid` from declaration initializers and + /// the constructor body. + fn seed_immutable_facts(&mut self, cid: ContractId) { + for v in self.gcx.hir.contract(cid).variables() { + let var = self.gcx.hir.variable(v); + if (var.is_immutable() || var.is_constant()) + && let Some(init) = var.initializer + { + if self.is_safe(init) { + self.state.safe_vars.insert(v); + } + if self.is_self_expr(init) { + self.state.self_vars.insert(v); + } + } + } + if let Some(ctor) = self.gcx.hir.contract(cid).ctor + && let Some(body) = self.gcx.hir.function(ctor).body + { + let mut a = Self::new(self.gcx); + a.visit_stmts(body.stmts); + let is_state = |v: &&VariableId| self.gcx.hir.variable(**v).kind.is_state(); + self.state.safe_vars.extend(a.state.safe_vars.iter().filter(is_state)); + self.state.self_vars.extend(a.state.self_vars.iter().filter(is_state)); + } + } + + /// Seeds parameters of an internal function or modifier that every invocation site in the + /// compilation unit passes a safe argument for. + fn seed_callsite_facts(&mut self, func: &'gcx hir::Function<'gcx>) { + if !is_internal_only(func) { + return; + } + let index = callsite_index(self.gcx); + let Some((fid, _)) = + self.gcx.hir.functions_enumerated().find(|(_, f)| std::ptr::eq(*f, func)) + else { + return; + }; + let Some(Some(facts)) = index.get(&fid) else { return }; + for (¶m, &(safe, is_self)) in func.parameters.iter().zip(facts) { + if safe { + self.state.safe_vars.insert(param); + } + if is_self { + self.state.self_vars.insert(param); + } + } + } + + /// Hoists `require(param == msg.sender | address(this))` guards from the prefix of modifier + /// `m` onto the caller's argument variables. + fn hoist_modifier_facts(&mut self, m: &'gcx Modifier<'gcx>) { + let Some(fid) = m.id.as_function() else { return }; + let Some(prefix) = modifier_prefix(&self.gcx.hir, fid) else { return }; + let modifier = self.gcx.hir.function(fid); + let mut a = Self::new(self.gcx); + for stmt in prefix { + a.stmt(stmt); + } + for ¶m in modifier.parameters { + // A fact about a rewritten parameter says nothing about the caller's variable. + if !a.written.contains(¶m) + && let Some(caller) = arg_for_param(self.gcx, fid, param, &m.args) + .and_then(|expr| underlying_var(self.gcx, expr)) + && self.is_safe_target(caller) + { + if a.state.safe_vars.contains(¶m) { + self.state.safe_vars.insert(caller); + } + if a.state.self_vars.contains(¶m) { + self.state.self_vars.insert(caller); + } + } + } + } + + /// `msg.sender`, `address(this)` or a tracked-safe variable. + fn is_safe(&self, expr: &Expr<'_>) -> bool { + origin_matches(self.gcx, expr, HELPER_DEPTH, &self.state.safe_vars, |gcx, e| { + is_msg_sender(gcx, e) || is_address_self(gcx, e) + }) + } + + /// `address(this)` or a tracked self alias. + fn is_self_expr(&self, expr: &Expr<'_>) -> bool { + origin_matches(self.gcx, expr, HELPER_DEPTH, &self.state.self_vars, is_address_self) + } + + fn is_safe_target(&self, v: VariableId) -> bool { + let var = self.gcx.hir.variable(v); + !var.kind.is_state() || var.is_immutable() || var.is_constant() + } + + /// Follows the alias chain to its root; bounded to guard against cycles. + fn canonical(&self, v: VariableId) -> VariableId { + let mut cur = v; + for _ in 0..8 { + match self.state.aliases.get(&cur) { + Some(next) if *next != cur => cur = *next, + _ => break, + } + } + cur + } + + fn canonical_key(&self, key: TokenKey) -> TokenKey { + match key { + TokenKey::Var(v) => TokenKey::Var(self.canonical(v)), + TokenKey::Field(v, name) => TokenKey::Field(self.canonical(v), name), + } + } + + /// Drops every fact about `v`. + fn invalidate(&mut self, v: VariableId) { + let s = &mut self.state; + s.safe_vars.remove(&v); + s.self_vars.remove(&v); + s.aliases.retain(|k, dst| *k != v && *dst != v); + s.sum_of.retain(|k, (a, b)| *k != v && *a != v && *b != v); + s.permits.retain(|p| !p.token.touches(v) && p.owner != v); + s.repayments.retain(|r, _| ![r.receiver, r.token, r.amount, r.fee].contains(&v)); + // Deliberately NOT purged here: `loop_repayment_floors` is shared, mutate-in-place + // analyzer state, not part of `State` - unlike everything above, it isn't snapshotted + // and restored around `if`/`try` branches. Purging a floor entry here would leak across + // sibling branches (e.g. a `then` that reassigns `v` would permanently drop the floor + // before the `else` branch, sharing the same loop, is ever analyzed), turning a real + // repeated-pull vulnerability into a false negative. Reassigning a repayment's own key + // variable mid-loop and re-minting is a rare enough shape that the resulting false + // positive (see `badFlashLoanReassignThenRemintInLoop`) is the safer trade-off. + } + + fn eval_rhs(&self, rhs: Option<&Expr<'_>>) -> Rhs { + let Some(rhs) = rhs else { return Rhs::default() }; + Rhs { + safe: self.is_safe(rhs), + is_self: self.is_self_expr(rhs), + alias: underlying_var(self.gcx, rhs).map(|v| self.canonical(v)), + sum: sum_operands(self.gcx, rhs), + } + } + + fn assign_var(&mut self, target: VariableId, rhs: Rhs) { + self.written.insert(target); + self.invalidate(target); + if !self.is_safe_target(target) { + return; + } + if rhs.safe { + self.state.safe_vars.insert(target); + } + if rhs.is_self { + self.state.self_vars.insert(target); + } + if let Some(alias) = rhs.alias + && alias != target + { + self.state.aliases.insert(target, alias); + } + if let Some(sum) = rhs.sum { + self.state.sum_of.insert(target, sum); + } + } + + /// Handles single and tuple LHS; `rhs == None` is an unknown value (`delete`). + fn assign_lhs(&mut self, lhs: &Expr<'_>, rhs: Option<&Expr<'_>>) { + // Writing `cfg.token` drops permits keyed on that field. + if let ExprKind::Member(base, ident) = &lhs.peel_parens().kind + && let Some(base) = underlying_var(self.gcx, base) + { + let key = TokenKey::Field(self.canonical(base), ident.name); + self.state.permits.retain(|p| p.token != key); + } + if let Some(elems) = tuple_elems(lhs) { + let rhs = rhs.and_then(tuple_elems); + // Evaluate every slot before writing any, so `(x, y) = (y, x)` stays consistent. + let slots: Vec<_> = elems + .iter() + .enumerate() + .map(|(i, l)| (*l, self.eval_rhs(rhs.and_then(|r| r.get(i).copied().flatten())))) + .collect(); + for (lhs, rhs) in slots { + if let Some(v) = lhs.and_then(|expr| underlying_var(self.gcx, expr)) { + self.assign_var(v, rhs); + } + } + } else if let Some(v) = underlying_var(self.gcx, lhs) { + let rhs = self.eval_rhs(rhs); + self.assign_var(v, rhs); + } + } + + /// Records variables proven safe by `pred` (`!pred` when `negate`). + fn add_facts(&mut self, pred: &Expr<'_>, negate: bool) { + match &pred.peel_parens().kind { + ExprKind::Binary(lhs, op, rhs) => { + let (eq, and, or) = if negate { + (BinOpKind::Ne, BinOpKind::Or, BinOpKind::And) + } else { + (BinOpKind::Eq, BinOpKind::And, BinOpKind::Or) + }; + if op.kind == and { + self.add_facts(lhs, negate); + self.add_facts(rhs, negate); + } else if op.kind == or { + // Only facts established by both disjuncts hold. + let before = self.state.clone(); + self.add_facts(lhs, negate); + let after_lhs = std::mem::replace(&mut self.state, before); + self.add_facts(rhs, negate); + self.state = after_lhs.meet(&self.state); + } else if op.kind == eq { + for (a, b) in [(lhs, rhs), (rhs, lhs)] { + if let Some(v) = underlying_var(self.gcx, b) + && self.is_safe_target(v) + { + if self.is_safe(a) { + self.state.safe_vars.insert(v); + } + if self.is_self_expr(a) { + self.state.self_vars.insert(v); + } + } + } + } + } + ExprKind::Unary(op, inner) if op.kind == UnOpKind::Not => { + self.add_facts(inner, !negate); + } + _ => {} + } + } + + /// EIP-2612 `token.permit(owner, , ...)` or the OpenZeppelin-style wrapper + /// `Lib.safePermit(token, owner, , ...)`. + fn match_permit_call(&self, expr: &Expr<'gcx>) -> Option { + let ExprKind::Call(callee, ..) = &expr.kind else { return None }; + let ExprKind::Member(recv, ident) = &callee.peel_parens().kind else { return None }; + let (token, owner, spender) = match ident.name.as_str() { + "permit" => { + let a = canonical_args(self.gcx, expr, 7)?; + (*recv, a[0], a[1]) + } + "safePermit" + if receiver_contract_id(self.gcx, recv).is_some_and(|cid| { + self.gcx.hir.contract(cid).kind == ContractKind::Library + }) => + { + let a = canonical_args(self.gcx, expr, 8)?; + (a[0], a[1], a[2]) + } + _ => return None, + }; + if !self.is_self_expr(spender) { + return None; + } + Some(PermitRecord { + token: self.canonical_key(token_key(self.gcx, token)?), + owner: self.canonical(underlying_var(self.gcx, owner)?), + }) + } + + fn permit_covers(&self, sink: &Sink<'_>) -> bool { + let (Some(token), Some(owner)) = (sink.token, underlying_var(self.gcx, sink.from)) else { + return false; + }; + self.state.permits.contains(&PermitRecord { + token: self.canonical_key(token), + owner: self.canonical(owner), + }) + } + + /// `expr` is `amount + fee` (either order), or a local bound to that sum. + fn amount_matches(&self, expr: &Expr<'_>, amount: VariableId, fee: VariableId) -> bool { + let sum = sum_operands(self.gcx, expr).or_else(|| { + underlying_var(self.gcx, expr).and_then(|v| self.state.sum_of.get(&v).copied()) + }); + matches!(sum, Some(pair) if pair == (amount, fee) || pair == (fee, amount)) + } + + /// Consumes one pending repayment matched by a sink pulling `amount + fee` from the flash-loan + /// receiver back to `address(this)`. + fn consume_repayment(&mut self, sink: &Sink<'_>) -> bool { + let (Some(from), Some(TokenKey::Var(token))) = + (underlying_var(self.gcx, sink.from), sink.token) + else { + return false; + }; + if !self.is_self_expr(sink.to) { + return false; + } + let candidates: Vec = self + .state + .repayments + .keys() + .copied() + .filter(|r| { + r.receiver == from + && r.token == token + && self.amount_matches(sink.amount, r.amount, r.fee) + }) + .collect(); + // `amount + fee` and `fee + amount` both match, so an outer (pre-loop) repayment and an + // in-loop fresh one can both structurally match the same sink. Prefer one this loop + // hasn't already spent its entry-floor allowance on, rather than an arbitrary HashMap + // iteration order picking the stale one and rejecting a genuinely fresh license. + let floor = self.loop_repayment_floors.last(); + let is_fresh = |r: &PendingRepayment, repayments: &HashMap| { + let count = repayments.get(r).copied().unwrap_or(0); + let floor = floor.and_then(|f| f.get(r)).copied().unwrap_or(0); + count > floor + }; + let Some(rep) = candidates.into_iter().find(|r| is_fresh(r, &self.state.repayments)) else { + return false; + }; + match self.state.repayments.get_mut(&rep) { + Some(count) if *count > 1 => *count -= 1, + _ => { + self.state.repayments.remove(&rep); + } + } + true + } + + /// Visits `stmts` up to the first that cannot fall through; returns whether the end is + /// reachable. + fn visit_stmts(&mut self, stmts: &'gcx [Stmt<'gcx>]) -> bool { + stmts.iter().all(|s| self.stmt(s)) + } + + /// Visits `stmt`, returning whether control can fall through it. + fn stmt(&mut self, stmt: &'gcx Stmt<'gcx>) -> bool { + match &stmt.kind { + StmtKind::Block(b) | StmtKind::UncheckedBlock(b) => return self.visit_stmts(b.stmts), + StmtKind::Break | StmtKind::Continue => { + let state = self.state.clone(); + if let Some(exits) = self.loop_exits.last_mut() { + exits.push(state); + } + return false; + } + StmtKind::If(cond, then, else_) => { + let _ = self.visit_expr(cond); + let before = self.state.clone(); + self.add_facts(cond, false); + let then_falls = self.stmt(then); + let after_then = std::mem::replace(&mut self.state, before); + self.add_facts(cond, true); + let else_falls = else_.is_none_or(|e| self.stmt(e)); + match (then_falls, else_falls) { + (true, true) => self.state = after_then.meet(&self.state), + (true, false) => self.state = after_then, + _ => {} + } + return then_falls || else_falls; + } + StmtKind::Loop(block, source) => { + // Only facts holding on every exit survive. `for`/`while` bodies may not run at + // all; `do-while` bodies run at least once. + let baseline = (!matches!(source, LoopSource::DoWhile)).then(|| self.state.clone()); + self.loop_exits.push(baseline.into_iter().collect()); + // The body is walked as a single static pass, but a real loop may run it many + // times. Record the repayment counts as they stand on entry so `consume_repayment` + // can refuse to spend a repayment that was already pending beforehand - such a + // license must not cover a sink the single-pass body walk merely happens to + // contain, since at runtime that sink may re-execute every iteration against the + // one license. A repayment minted (and consumed) inside this same body pass is + // unaffected, since its count exceeds the floor recorded here; repayments the body + // never touches at all pass through the eventual `meet` unchanged, so code after + // the loop still sees them. + // + // This has no notion of a loop shape that provably runs its body at most once + // (`do { .. } while (false)`, `while (cond) { ..; break; }`), so those flag a + // pull they can't actually repeat. Accepted: a narrow false positive on an + // otherwise-safe idiom, traded for never missing a real repeated-pull drain. + self.loop_repayment_floors.push(self.state.repayments.clone()); + if self.visit_stmts(block.stmts) + && loop_update(*source).is_none_or(|update| self.stmt(update)) + { + let state = self.state.clone(); + self.loop_exits.last_mut().expect("pushed above").push(state); + } + self.loop_repayment_floors.pop().expect("pushed above"); + let exits = self.loop_exits.pop().expect("pushed above"); + let falls = !exits.is_empty(); + if let Some(joined) = exits.into_iter().reduce(|a, b| a.meet(&b)) { + self.state = joined; + } + return falls; + } + StmtKind::Try(t) => { + // Only the success clause sees the effects of the tried call. + let before = self.state.clone(); + let _ = self.visit_expr(&t.expr); + let after_call = self.state.clone(); + let mut joined = None::; + for (i, clause) in t.clauses.iter().enumerate() { + self.state = if i == 0 { after_call.clone() } else { before.clone() }; + if self.visit_stmts(clause.block.stmts) { + joined = Some( + joined.map_or_else(|| self.state.clone(), |j| j.meet(&self.state)), + ); + } + } + let falls = joined.is_some(); + self.state = joined.unwrap_or(after_call); + return falls; + } + StmtKind::DeclSingle(vid) => { + if let Some(init) = self.gcx.hir.variable(*vid).initializer { + let rhs = self.eval_rhs(Some(init)); + self.assign_var(*vid, rhs); + } + } + StmtKind::DeclMulti(vars, init) => { + for (vid, rhs) in vars.iter().zip(tuple_elems(init).into_iter().flatten()) { + if let (Some(vid), Some(rhs)) = (vid, rhs) { + let rhs = self.eval_rhs(Some(rhs)); + self.assign_var(*vid, rhs); + } + } + } + _ => {} + } + let _ = self.walk_stmt(stmt); + !branch_always_exits(self.gcx, stmt) + } +} + +impl<'gcx> Visit<'gcx> for Analyzer<'gcx> { + type BreakValue = Never; + + fn hir(&self) -> &'gcx Hir<'gcx> { + &self.gcx.hir + } + + fn visit_stmt(&mut self, stmt: &'gcx Stmt<'gcx>) -> ControlFlow { + self.stmt(stmt); + ControlFlow::Continue(()) + } + + fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow { + match &expr.kind { + // `rhs` may not execute: its facts and writes survive only if they also hold without + // it, while `lhs` facts flow into `rhs`. Sinks in `rhs` are still reported. + ExprKind::Binary(lhs, op, rhs) if matches!(op.kind, BinOpKind::And | BinOpKind::Or) => { + let _ = self.visit_expr(lhs); + let skipped = self.state.clone(); + self.add_facts(lhs, op.kind == BinOpKind::Or); + let _ = self.visit_expr(rhs); + self.state = skipped.meet(&self.state); + } + ExprKind::Call(callee, args) if is_require_or_assert(self.gcx, callee) => { + // Sinks inside the predicate run before the guard takes effect. + let _ = self.walk_expr(expr); + if let Some(cond) = args.exprs().next() { + self.add_facts(cond, false); + } + } + ExprKind::Call(callee, ..) => { + if let Some(rep) = match_flash_loan_call(self.gcx, expr) { + *self.state.repayments.entry(rep).or_insert(0) += 1; + } else if let Some(permit) = self.match_permit_call(expr) { + self.state.permits.insert(permit); + } else if let Some(sink) = match_sink(self.gcx, expr) + && !self.is_safe(sink.from) + && !self.consume_repayment(&sink) + { + // A prior permit does not make the sink safe: a non-permit token with a + // fallback (e.g. WETH) silently accepts the permit. + let lint = if self.permit_covers(&sink) { + &ARBITRARY_SEND_ERC20_PERMIT + } else { + &ARBITRARY_SEND_ERC20 + }; + self.hits.push((expr.span, lint)); + } + // Arguments are evaluated before the callee runs: walk them first, then drop facts + // about state the callee writes. + let _ = self.walk_expr(expr); + if let Some(fid) = self.gcx.resolved_function(callee) + && matches!(callee.peel_parens().kind, ExprKind::Ident(_)) + { + for v in state_writes(self.gcx, fid) { + self.invalidate(v); + } + } + } + ExprKind::Assign(lhs, _, rhs) => { + self.assign_lhs(lhs, Some(rhs)); + let _ = self.walk_expr(expr); + } + ExprKind::Delete(target) => { + self.assign_lhs(target, None); + let _ = self.walk_expr(expr); + } + _ => { + let _ = self.walk_expr(expr); + } + } + ControlFlow::Continue(()) + } +} + +/// True when `expr` is `base(..)` or a variable in `vars`, through parens, `payable(..)`, casts, +/// ternaries whose both arms qualify and no-arg helpers whose body returns such an expression. +fn origin_matches( + gcx: Gcx<'_>, + expr: &Expr<'_>, + depth: u8, + vars: &HashSet, + base: fn(Gcx<'_>, &Expr<'_>) -> bool, +) -> bool { + let expr = expr.peel_parens(); + match &expr.kind { + ExprKind::Payable(inner) => return origin_matches(gcx, inner, depth, vars, base), + ExprKind::Call(callee, args) if is_address_like_cast(gcx, callee) => { + return args.exprs().next().is_some_and(|e| origin_matches(gcx, e, depth, vars, base)); + } + _ => {} + } + base(gcx, expr) + || match &expr.kind { + ExprKind::Ident(_) => gcx.resolved_variable(expr).is_some_and(|v| vars.contains(&v)), + ExprKind::Ternary(_, t, f) => { + origin_matches(gcx, t, depth, vars, base) + && origin_matches(gcx, f, depth, vars, base) + } + ExprKind::Call(callee, args) if depth > 0 && args.exprs().next().is_none() => gcx + .resolved_function(callee) + .filter(|_| matches!(callee.peel_parens().kind, ExprKind::Ident(_))) + .is_some_and(|fid| { + let f = gcx.hir.function(fid); + f.parameters.is_empty() + && matches!(f.body.map(|b| b.stmts), Some([stmt]) + if matches!(&stmt.kind, StmtKind::Return(Some(e)) + if origin_matches(gcx, e, depth - 1, vars, base))) + }), + _ => false, + } +} + +/// `a + b` with both operands variables. +fn sum_operands(gcx: Gcx<'_>, expr: &Expr<'_>) -> Option<(VariableId, VariableId)> { + match &expr.peel_parens().kind { + ExprKind::Binary(lhs, op, rhs) if op.kind == BinOpKind::Add => { + underlying_var(gcx, lhs).zip(underlying_var(gcx, rhs)) + } + _ => None, + } +} + +/// `token` or `cfg.token` receiver key, through casts and `payable(..)`. +fn token_key(gcx: Gcx<'_>, expr: &Expr<'_>) -> Option { + if let Some(v) = underlying_var(gcx, expr) { + return Some(TokenKey::Var(v)); + } + match &expr.peel_parens().kind { + ExprKind::Member(base, ident) => { + Some(TokenKey::Field(underlying_var(gcx, base)?, ident.name)) + } + _ => None, + } +} + +/// Call arguments in declaration order, with the expected arity. +fn canonical_args<'gcx>( + gcx: Gcx<'gcx>, + expr: &Expr<'gcx>, + arity: usize, +) -> Option>> { + let ExprKind::Call(_, args) = &expr.kind else { return None }; + if args.len() != arity { + return None; + } + (0..arity).map(|index| gcx.call_arg(expr, index)).collect() +} + +/// EIP-3156 `receiver.onFlashLoan(initiator, token, amount, fee, data)` on a receiver type +/// declaring the exact signature. Literal arguments yield `None`. +fn match_flash_loan_call<'gcx>(gcx: Gcx<'gcx>, expr: &Expr<'gcx>) -> Option { + let ExprKind::Call(callee, ..) = &expr.kind else { return None }; + let ExprKind::Member(recv, ident) = &callee.peel_parens().kind else { return None }; + if ident.name.as_str() != "onFlashLoan" { + return None; + } + let a = canonical_args(gcx, expr, 5)?; + let cid = receiver_contract_id(gcx, recv)?; + if !interface_has_function( + gcx, + cid, + "onFlashLoan(address,address,uint256,uint256,bytes)", + &["address", "address", "uint256", "uint256", "bytes"], + &["bytes32"], + ) { + return None; + } + Some(PendingRepayment { + receiver: underlying_var(gcx, recv)?, + token: underlying_var(gcx, a[1])?, + amount: underlying_var(gcx, a[2])?, + fee: underlying_var(gcx, a[3])?, + }) +} + +/// `recv.transferFrom(from, to, amt)` / `recv.safeTransferFrom(from, to, amt)` on a contract +/// declaring ERC20's `transferFrom(address,address,uint256) returns (bool)` (ERC721's same-named +/// overload is excluded), `addr.safeTransferFrom(..)` via `using SafeTransferLib for address`, +/// or the library form `Lib.safeTransferFrom(token, from, to, amt)`. +fn match_sink<'gcx>(gcx: Gcx<'gcx>, expr: &'gcx Expr<'gcx>) -> Option> { + let ExprKind::Call(callee, ..) = &expr.kind else { return None }; + let ExprKind::Member(recv, ident) = &callee.peel_parens().kind else { return None }; + let name = ident.name.as_str(); + if matches!(name, "transferFrom" | "safeTransferFrom") + && let Some(a) = canonical_args(gcx, expr, 3) + { + let erc20 = receiver_contract_id(gcx, recv).is_some_and(|cid| has_transfer_from(gcx, cid)); + let attached = gcx.resolved_call(expr).is_some_and(|resolved| { + resolved.attached + && resolved + .res + .as_function() + .and_then(|fid| gcx.hir.function(fid).contract) + .is_some_and(|cid| library_has_safe_transfer_from(gcx, cid)) + }); + if erc20 || (name == "safeTransferFrom" && attached && expr_is_address(gcx, recv)) { + return Some(Sink { from: a[0], to: a[1], amount: a[2], token: token_key(gcx, recv) }); + } + } + if name == "safeTransferFrom" + && let Some(a) = canonical_args(gcx, expr, 4) + && let Some(cid) = receiver_contract_id(gcx, recv) + && gcx.hir.contract(cid).kind == ContractKind::Library + && library_has_safe_transfer_from(gcx, cid) + { + return Some(Sink { from: a[1], to: a[2], amount: a[3], token: token_key(gcx, a[0]) }); + } + None +} + +/// State variables written by `fid` or by the internal functions it calls (one level deep). +fn state_writes<'gcx>(gcx: Gcx<'gcx>, fid: FunctionId) -> HashSet { + let mut w = StateWrites { gcx, out: HashSet::new(), callees: Vec::new() }; + w.scan(fid); + for callee in std::mem::take(&mut w.callees) { + w.scan(callee); + } + w.out +} + +struct StateWrites<'gcx> { + gcx: Gcx<'gcx>, + out: HashSet, + callees: Vec, +} + +impl StateWrites<'_> { + fn scan(&mut self, fid: FunctionId) { + if let Some(body) = self.gcx.hir.function(fid).body { + for stmt in body.stmts { + let _ = self.visit_stmt(stmt); + } + } + } +} + +impl<'gcx> Visit<'gcx> for StateWrites<'gcx> { + type BreakValue = Never; + + fn hir(&self) -> &'gcx Hir<'gcx> { + &self.gcx.hir + } + + fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow { + match &expr.kind { + ExprKind::Assign(lhs, ..) | ExprKind::Delete(lhs) => { + self.out.extend(state_lhs_vars(self.gcx, lhs)); + } + ExprKind::Call(callee, ..) + if matches!(callee.peel_parens().kind, ExprKind::Ident(_)) => + { + self.callees.extend(self.gcx.resolved_function(callee)); + } + _ => {} + } + self.walk_expr(expr) + } +} + +/// Internal functions and modifiers are only reachable from the compilation unit, so their +/// parameters can be proven safe from the invocation sites seen there. +const fn is_internal_only(f: &hir::Function<'_>) -> bool { + !f.parameters.is_empty() + && (matches!(f.kind, FunctionKind::Modifier) + || (f.kind.is_function() + && matches!(f.visibility, Visibility::Private | Visibility::Internal))) +} + +/// Per internal function, whether every call site passes a statically safe / self argument for +/// each parameter; `None` when some call site could not be matched to the parameters. +type CallsiteFacts = HashMap>>; + +thread_local! { + static CALLSITE_INDEX: RefCell)>> = const { RefCell::new(None) }; +} + +/// The call-site index of `hir`, built once per compilation unit. +fn callsite_index<'gcx>(gcx: Gcx<'gcx>) -> Rc { + let key = std::ptr::from_ref(&gcx.hir) as usize; + CALLSITE_INDEX.with(|cell| { + let mut slot = cell.borrow_mut(); + if let Some((cached_key, index)) = &*slot + && *cached_key == key + { + return index.clone(); + } + let mut c = CallsiteCollector { gcx, out: HashMap::new() }; + for (_, func) in gcx.hir.functions_enumerated() { + for m in func.modifiers { + if let ItemId::Function(fid) = m.id { + c.record(fid, &m.args); + } + } + for stmt in func.body.map_or(&[][..], |b| b.stmts) { + let _ = c.visit_stmt(stmt); + } + } + let index = Rc::new(c.out); + *slot = Some((key, index.clone())); + index + }) +} + +struct CallsiteCollector<'gcx> { + gcx: Gcx<'gcx>, + out: CallsiteFacts, +} + +impl<'gcx> CallsiteCollector<'gcx> { + fn record(&mut self, fid: FunctionId, args: &'gcx CallArgs<'gcx>) { + let f = self.gcx.hir.function(fid); + if !is_internal_only(f) { + return; + } + let call_args = + f.parameters.iter().map(|&p| arg_for_param(self.gcx, fid, p, args)).collect(); + let entry = + self.out.entry(fid).or_insert_with(|| Some(vec![(true, true); f.parameters.len()])); + let (Some(facts), Some(call_args)) = (entry.as_mut(), call_args) else { + *entry = None; + return; + }; + let none = HashSet::new(); + for ((safe, is_self), arg) in facts.iter_mut().zip::>(call_args) { + *safe &= origin_matches(self.gcx, arg, HELPER_DEPTH, &none, |gcx, e| { + is_msg_sender(gcx, e) || is_address_self(gcx, e) + }); + *is_self &= origin_matches(self.gcx, arg, HELPER_DEPTH, &none, is_address_self); + } + } +} + +impl<'gcx> Visit<'gcx> for CallsiteCollector<'gcx> { + type BreakValue = Never; + + fn hir(&self) -> &'gcx Hir<'gcx> { + &self.gcx.hir + } + + fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow { + if let ExprKind::Call(callee, args) = &expr.kind + && matches!(callee.peel_parens().kind, ExprKind::Ident(_)) + && let Some(fid) = self.gcx.resolved_function(callee) + { + self.record(fid, args); + } + self.walk_expr(expr) + } +} + +/// ERC20's `transferFrom(address,address,uint256) returns (bool)`. +fn has_transfer_from(gcx: Gcx<'_>, cid: ContractId) -> bool { + interface_has_function( + gcx, + cid, + "transferFrom(address,address,uint256)", + &["address", "address", "uint256"], + &["bool"], + ) +} + +fn interface_has_function( + gcx: Gcx<'_>, + cid: ContractId, + signature: &str, + params: &[&str], + returns: &[&str], +) -> bool { + gcx.interface_functions(cid).all().iter().any(|function| { + let f = gcx.hir.function(function.id); + gcx.item_signature(function.id.into()) == signature + && f.parameters.len() == params.len() + && f.returns.len() == returns.len() + && f.parameters.iter().zip(params).all(|(id, abi)| is_elementary(&gcx.hir, *id, abi)) + && f.returns.iter().zip(returns).all(|(id, abi)| is_elementary(&gcx.hir, *id, abi)) + }) +} + +/// 4-arg `safeTransferFrom(token, address, address, uint256)` where `token` is `address` (Solady) +/// or an ERC20 contract type (OpenZeppelin `SafeERC20`); ERC721/1155 helpers are excluded since +/// their `transferFrom` has no return value. +fn library_has_safe_transfer_from(gcx: Gcx<'_>, cid: ContractId) -> bool { + let hir = &gcx.hir; + hir.contract(cid).functions().any(|fid| { + let f = hir.function(fid); + let [token, from, to, amount] = f.parameters else { return false }; + let token_ok = match hir.variable(*token).ty.kind { + TypeKind::Custom(ItemId::Contract(token_cid)) => has_transfer_from(gcx, token_cid), + _ => is_address_type(hir, *token), + }; + f.name.is_some_and(|n| n.name.as_str() == "safeTransferFrom") + && token_ok + && is_address_type(hir, *from) + && is_address_type(hir, *to) + && is_elementary(hir, *amount, "uint256") + }) +} diff --git a/crates/lint/src/sol/high/arbitrary_send_eth.rs b/crates/lint/src/sol/high/arbitrary_send_eth.rs new file mode 100644 index 0000000000000..1e5306114c516 --- /dev/null +++ b/crates/lint/src/sol/high/arbitrary_send_eth.rs @@ -0,0 +1,993 @@ +use super::ArbitrarySendEth; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{ + Severity, SolLint, + analysis::{ + DEFAULT_HELPER_ANALYSIS_CACHE_LIMIT, HelperAnalysisCache, arg_for_param, + branch_always_exits, callee_no_arg_returns, function_no_arg_returns, + is_address_like_cast, is_address_self, is_builtin, is_contract_cast, is_literal_zero, + is_msg_sender, is_require_or_assert, is_sender_member, loop_stmts, loop_update, + modifier_prefix, referenced_item, tuple_elems, underlying_var, var_is_address_like, + }, + }, +}; +use solar::{ + ast::{BinOpKind, LitKind, StateMutability, UnOpKind}, + interface::{Span, data_structures::Never, sym}, + sema::{ + Gcx, + builtins::Builtin, + hir::{ + self, CallArgs, ContractId, ContractKind, ElementaryType, Expr, ExprKind, FunctionId, + Hir, ItemId, LoopSource, Modifier, Res, Stmt, StmtKind, TypeKind, Variable, VariableId, + Visit, + }, + ty::TyKind, + }, +}; +use std::{collections::HashSet, ops::ControlFlow}; + +declare_forge_lint!( + ARBITRARY_SEND_ETH, + Severity::High, + "arbitrary-send-eth", + "ETH is sent to a user-controlled destination; restrict the destination or the caller" +); + +/// Recursion budget for `_msgSender()`-style helper chains. +const HELPER_DEPTH: u8 = 3; + +/// Recursion budget for self-alias chains. +const SELF_ALIAS_DEPTH: u8 = 8; + +/// Cap on inlined helper calls (covers `ctor → _init → _initInner → _initLeaf`). +const HELPER_CALL_DEPTH: usize = 4; + +impl<'gcx> LateLintPass<'gcx> for ArbitrarySendEth { + fn check_function( + &mut self, + ctx: &LintContext, + gcx: Gcx<'gcx>, + func: &'gcx hir::Function<'gcx>, + ) { + if matches!(func.state_mutability, StateMutability::Pure | StateMutability::View) + || func.is_constructor() + || func.contract.is_some_and(|cid| gcx.hir.contract(cid).kind == ContractKind::Library) + { + return; + } + let Some(body) = func.body else { return }; + + // Modifier arguments are evaluated by the caller before any modifier guard runs. + let mut args = Analyzer::new(gcx); + for arg in func.modifiers.iter().flat_map(|m| m.args.exprs()) { + let _ = args.visit_expr(arg); + } + for span in args.hits { + ctx.emit(&ARBITRARY_SEND_ETH, span); + } + + let mut a = Analyzer::new(gcx); + for m in func.modifiers { + a.hoist_modifier_facts(m); + } + a.visit_stmts(body.stmts); + if !a.hits.is_empty() && !func.modifiers.iter().any(|m| a.guards.modifier_restricts(m)) { + for span in a.hits { + ctx.emit(&ARBITRARY_SEND_ETH, span); + } + } + } +} + +/// Path-sensitive facts. +#[derive(Clone, Default)] +struct State { + /// Locals (and function pointers) proven to denote a safe destination on this path. + safe_vars: HashSet, + /// True once a caller-restricting guard has fired on this path. + caller_restricted: bool, +} + +impl State { + fn meet(&self, other: &Self) -> Self { + Self { + safe_vars: self.safe_vars.intersection(&other.safe_vars).copied().collect(), + caller_restricted: self.caller_restricted && other.caller_restricted, + } + } +} + +struct Analyzer<'gcx> { + gcx: Gcx<'gcx>, + guards: CallerGuards<'gcx>, + state: State, + /// States at `break`/`continue` of each enclosing loop, innermost last. + loop_exits: Vec>, + /// Every variable written on any path. + written: HashSet, + hits: Vec, +} + +impl<'gcx> Analyzer<'gcx> { + fn new(gcx: Gcx<'gcx>) -> Self { + Self { + gcx, + guards: CallerGuards::new(gcx), + state: State::default(), + loop_exits: Vec::new(), + written: HashSet::new(), + hits: Vec::new(), + } + } + + /// Hoists `require(param == msg.sender)`-style guards from the prefix of modifier `m` onto + /// the caller's argument variables. + fn hoist_modifier_facts(&mut self, m: &'gcx Modifier<'gcx>) { + let ItemId::Function(fid) = m.id else { return }; + let Some(prefix) = modifier_prefix(&self.gcx.hir, fid) else { return }; + let modifier = self.gcx.hir.function(fid); + let mut a = Self::new(self.gcx); + for stmt in prefix { + a.stmt(stmt); + } + for ¶m in modifier.parameters { + if a.state.safe_vars.contains(¶m) + && !a.written.contains(¶m) + && let Some(caller) = arg_for_param(self.gcx, fid, param, &m.args) + .and_then(|expr| underlying_var(self.gcx, expr)) + && self.is_safe_target(caller) + { + self.state.safe_vars.insert(caller); + } + } + } + + /// True when `expr` denotes a destination that is fixed at deploy time or is the caller + /// itself: `msg.sender`, `tx.origin`, `address(this)`, address or zero literals, + /// `immutable`/`constant` state, tracked locals and `this.f` function pointers. + fn is_safe(&self, expr: &'gcx Expr<'gcx>) -> bool { + self.is_safe_inner(expr, HELPER_DEPTH) + } + + fn is_safe_inner(&self, expr: &'gcx Expr<'gcx>, depth: u8) -> bool { + let expr = peel_casts(self.gcx, expr); + match &expr.kind { + ExprKind::Member(base, _) => { + is_sender_member(self.gcx, expr) || is_address_self(self.gcx, base) + } + ExprKind::Lit(_) => is_trusted_literal(expr), + ExprKind::Ident(_) => { + is_builtin(self.gcx, expr, sym::this) + || self.gcx.resolved_variable(expr).is_some_and(|v| self.is_safe_var(v)) + } + ExprKind::Ternary(_, t, f) => { + self.is_safe_inner(t, depth) && self.is_safe_inner(f, depth) + } + ExprKind::Call(callee, args) => { + depth > 0 + && args.exprs().next().is_none() + && callee_no_arg_returns(self.gcx, callee, |e| self.is_safe_inner(e, depth - 1)) + } + _ => false, + } + } + + fn is_safe_var(&self, v: VariableId) -> bool { + let var = self.gcx.hir.variable(v); + self.state.safe_vars.contains(&v) + || (var.kind.is_state() && (var.is_immutable() || var.is_constant())) + } + + /// Only locals and `immutable`/`constant` state can carry a safe-fact: mutable storage may be + /// rewritten between the check and the sink. + fn is_safe_target(&self, v: VariableId) -> bool { + let var = self.gcx.hir.variable(v); + !var.kind.is_state() || var.is_immutable() || var.is_constant() + } + + /// `target = rhs`; `rhs == None` is an unknown value. + fn assign_var(&mut self, target: VariableId, rhs: Option<&'gcx Expr<'gcx>>) { + self.written.insert(target); + self.state.safe_vars.remove(&target); + if !self.gcx.hir.variable(target).kind.is_state() && rhs.is_some_and(|r| self.is_safe(r)) { + self.state.safe_vars.insert(target); + } + } + + /// Handles single and tuple LHS; tuple slots align with a tuple-literal RHS. + fn assign_lhs(&mut self, lhs: &'gcx Expr<'gcx>, rhs: Option<&'gcx Expr<'gcx>>) { + if let Some(elems) = tuple_elems(lhs) { + let rhs = rhs.and_then(tuple_elems); + for (i, lhs) in elems.iter().enumerate() { + if let Some(lhs) = lhs { + self.assign_lhs(lhs, rhs.and_then(|r| r.get(i).copied().flatten())); + } + } + } else if let Some(v) = underlying_var(self.gcx, lhs) { + self.assign_var(v, rhs); + } + } + + /// Records variables proven equal to a safe destination by `pred` (`!pred` when `negate`). + fn add_facts(&mut self, pred: &'gcx Expr<'gcx>, negate: bool) { + match &pred.peel_parens().kind { + ExprKind::Binary(lhs, op, rhs) => { + let (eq, and, or) = if negate { + (BinOpKind::Ne, BinOpKind::Or, BinOpKind::And) + } else { + (BinOpKind::Eq, BinOpKind::And, BinOpKind::Or) + }; + if op.kind == and { + self.add_facts(lhs, negate); + self.add_facts(rhs, negate); + } else if op.kind == or { + // Only facts established by both disjuncts hold. + let before = self.state.clone(); + self.add_facts(lhs, negate); + let after_lhs = std::mem::replace(&mut self.state, before); + self.add_facts(rhs, negate); + self.state = after_lhs.meet(&self.state); + } else if op.kind == eq { + for (a, b) in [(lhs, rhs), (rhs, lhs)] { + if self.is_safe(a) + && let Some(v) = underlying_var(self.gcx, b) + && self.is_safe_target(v) + { + self.state.safe_vars.insert(v); + } + } + } + } + ExprKind::Unary(op, inner) if op.kind == UnOpKind::Not => { + self.add_facts(inner, !negate); + } + _ => {} + } + } + + /// Applies a guard known to hold (`holds`) or fail on the current path. + fn note_guard(&mut self, cond: &'gcx Expr<'gcx>, holds: bool) { + self.add_facts(cond, !holds); + if self.guards.cond_restricts(cond, holds) { + self.state.caller_restricted = true; + } + } + + /// Visits `stmts` up to the first that cannot fall through; returns whether the end is + /// reachable. + fn visit_stmts(&mut self, stmts: &'gcx [Stmt<'gcx>]) -> bool { + stmts.iter().all(|s| self.stmt(s)) + } + + /// Visits `stmt`, returning whether control can fall through it. + fn stmt(&mut self, stmt: &'gcx Stmt<'gcx>) -> bool { + match &stmt.kind { + StmtKind::Block(b) | StmtKind::UncheckedBlock(b) => return self.visit_stmts(b.stmts), + StmtKind::Break | StmtKind::Continue => { + let state = self.state.clone(); + if let Some(exits) = self.loop_exits.last_mut() { + exits.push(state); + } + return false; + } + StmtKind::If(cond, then, else_) => { + let _ = self.visit_expr(cond); + let before = self.state.clone(); + self.note_guard(cond, true); + let then_falls = self.stmt(then); + let after_then = std::mem::replace(&mut self.state, before); + self.note_guard(cond, false); + let else_falls = else_.is_none_or(|e| self.stmt(e)); + match (then_falls, else_falls) { + (true, true) => self.state = after_then.meet(&self.state), + (true, false) => self.state = after_then, + _ => {} + } + return then_falls || else_falls; + } + StmtKind::Loop(block, source) => { + // Only facts holding on every exit survive. `for`/`while` bodies may not run at + // all; `do-while` bodies run at least once. + let baseline = (!matches!(source, LoopSource::DoWhile)).then(|| self.state.clone()); + self.loop_exits.push(baseline.into_iter().collect()); + if self.visit_stmts(block.stmts) + && loop_update(*source).is_none_or(|update| self.stmt(update)) + { + let state = self.state.clone(); + self.loop_exits.last_mut().expect("pushed above").push(state); + } + let exits = self.loop_exits.pop().expect("pushed above"); + let falls = !exits.is_empty(); + if let Some(joined) = exits.into_iter().reduce(|a, b| a.meet(&b)) { + self.state = joined; + } + return falls; + } + StmtKind::Try(t) => { + let _ = self.visit_expr(&t.expr); + let outer = self.state.clone(); + let mut joined = None::; + for clause in t.clauses { + self.state = outer.clone(); + if self.visit_stmts(clause.block.stmts) { + joined = Some( + joined.map_or_else(|| self.state.clone(), |j| j.meet(&self.state)), + ); + } + } + let falls = joined.is_some(); + self.state = joined.unwrap_or(outer); + return falls; + } + StmtKind::DeclSingle(vid) => { + if let Some(init) = self.gcx.hir.variable(*vid).initializer { + self.assign_var(*vid, Some(init)); + } + } + StmtKind::DeclMulti(vars, init) => { + for (vid, rhs) in vars.iter().zip(tuple_elems(init).into_iter().flatten()) { + if let (Some(vid), Some(rhs)) = (vid, rhs) { + self.assign_var(*vid, Some(rhs)); + } + } + } + _ => {} + } + let _ = self.walk_stmt(stmt); + !branch_always_exits(self.gcx, stmt) + } +} + +impl<'gcx> Visit<'gcx> for Analyzer<'gcx> { + type BreakValue = Never; + + fn hir(&self) -> &'gcx Hir<'gcx> { + &self.gcx.hir + } + + fn visit_stmt(&mut self, stmt: &'gcx Stmt<'gcx>) -> ControlFlow { + self.stmt(stmt); + ControlFlow::Continue(()) + } + + fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow { + match &expr.kind { + // `rhs` may not execute: its facts and writes survive only if they also hold without + // it, while `lhs` facts flow into `rhs`. + ExprKind::Binary(lhs, op, rhs) if matches!(op.kind, BinOpKind::And | BinOpKind::Or) => { + let _ = self.visit_expr(lhs); + let skipped = self.state.clone(); + self.add_facts(lhs, op.kind == BinOpKind::Or); + let _ = self.visit_expr(rhs); + self.state = skipped.meet(&self.state); + } + ExprKind::Ternary(cond, t, f) => { + let _ = self.visit_expr(cond); + let before = self.state.clone(); + self.add_facts(cond, false); + let _ = self.visit_expr(t); + let after_t = std::mem::replace(&mut self.state, before); + self.add_facts(cond, true); + let _ = self.visit_expr(f); + self.state = after_t.meet(&self.state); + } + ExprKind::Call(callee, args) if is_require_or_assert(self.gcx, callee) => { + // Sinks inside the predicate run before the guard takes effect. + let _ = self.walk_expr(expr); + if let Some(cond) = args.exprs().next() { + self.note_guard(cond, true); + } + } + ExprKind::Call(..) => { + if !self.state.caller_restricted + && let Some(dest) = match_sink(self.gcx, expr) + && !self.is_safe(dest) + { + self.hits.push(expr.span); + } + let _ = self.walk_expr(expr); + } + ExprKind::Assign(lhs, _, rhs) => { + self.assign_lhs(lhs, Some(rhs)); + let _ = self.walk_expr(expr); + } + ExprKind::Delete(target) => { + self.assign_lhs(target, None); + let _ = self.walk_expr(expr); + } + _ => { + let _ = self.walk_expr(expr); + } + } + ControlFlow::Continue(()) + } +} + +/// Destination of an ETH-sending call: `selfdestruct(x)`, `x.{call,send,transfer}`, +/// `f{value: v}()`, `IFoo(x).f{value: v}()` and common OpenZeppelin/Solady helpers. Sends to +/// `address(this)` or of a literal-zero amount are not sinks. +fn match_sink<'gcx>(gcx: Gcx<'gcx>, expr: &'gcx Expr<'gcx>) -> Option<&'gcx Expr<'gcx>> { + let (callee, args, opts) = expr.as_call()?; + if gcx.resolved_builtin(callee) == Some(Builtin::Selfdestruct) { + return args.exprs().next().filter(|dest| !is_address_self(gcx, dest)); + } + if opts.is_some_and(|o| { + o.args.iter().any(|a| a.name.name == sym::value && !is_literal_zero(&a.value)) + }) { + return match &callee.kind { + ExprKind::Member(recv, _) => (!is_address_self(gcx, recv)).then_some(recv), + _ => expr_is_function(gcx, callee).then_some(callee), + }; + } + let ExprKind::Member(recv, member) = &callee.kind else { return None }; + if matches!( + gcx.resolved_builtin(callee), + Some(Builtin::AddressPayableTransfer | Builtin::AddressPayableSend) + ) { + return (!is_address_self(gcx, recv) && !is_literal_zero(args.exprs().next()?)) + .then_some(recv); + } + match_eth_library_call(gcx, expr, recv, member.name.as_str(), args) +} + +/// Destination of an OpenZeppelin `Address` / Solady `SafeTransferLib` ETH helper, called either +/// statically (`Lib.f(to, ...)`) or via `using ... for address` (`to.f(...)`). +fn match_eth_library_call<'gcx>( + gcx: Gcx<'gcx>, + call: &Expr<'gcx>, + recv: &'gcx Expr<'gcx>, + name: &str, + args: &'gcx CallArgs<'gcx>, +) -> Option<&'gcx Expr<'gcx>> { + // Amount position and accepted arities, in the static form. + let (amount, arities): (Option, &[usize]) = match name { + "sendValue" | "safeTransferETH" | "safeMoveETH" => (Some(1), &[2]), + "forceSafeTransferETH" => (Some(1), &[2, 3]), + "trySafeTransferETH" => (Some(1), &[3]), + "functionCallWithValue" => (Some(2), &[3, 4]), + "safeTransferAllETH" => (None, &[1]), + "forceSafeTransferAllETH" => (None, &[1, 2]), + "trySafeTransferAllETH" => (None, &[2]), + _ => return None, + }; + let using = gcx.resolved_call(call).is_some_and(|resolved| resolved.attached); + let is_lib = matches!(referenced_item(gcx, recv), Some(ItemId::Contract(cid)) + if gcx.hir.contract(cid).kind == ContractKind::Library); + if (!using && !is_lib) || !arities.contains(&(args.len() + usize::from(using))) { + return None; + } + let dest = if using { recv } else { gcx.call_arg(call, 0)? }; + if let Some(i) = amount + && is_literal_zero(gcx.call_arg(call, i - usize::from(using))?) + { + return None; + } + (!is_address_self(gcx, dest)).then_some(dest) +} + +/// Recognises guards that restrict `msg.sender` to a deploy-time-fixed principal, backed by a +/// memoised analysis of which state variables may alias `address(this)`. +struct CallerGuards<'gcx> { + gcx: Gcx<'gcx>, + alias_cache: HelperAnalysisCache<(VariableId, u8), bool>, + /// Functions currently being inlined, to stop recursion. + stack: Vec, +} + +impl<'gcx> CallerGuards<'gcx> { + fn new(gcx: Gcx<'gcx>) -> Self { + Self { + gcx, + alias_cache: HelperAnalysisCache::new(DEFAULT_HELPER_ANALYSIS_CACHE_LIMIT), + stack: Vec::new(), + } + } + + /// True when modifier `m` reverts unless `msg.sender` is a trusted principal. + fn modifier_restricts(&mut self, m: &Modifier<'_>) -> bool { + let ItemId::Function(fid) = m.id else { return false }; + modifier_prefix(&self.gcx.hir, fid) + .is_some_and(|prefix| prefix.into_iter().any(|s| self.stmt_restricts(s))) + } + + fn stmt_restricts(&mut self, stmt: &'gcx Stmt<'gcx>) -> bool { + match &stmt.kind { + StmtKind::Expr(e) => self.expr_restricts(e), + StmtKind::Block(b) | StmtKind::UncheckedBlock(b) => { + b.stmts.iter().any(|s| self.stmt_restricts(s)) + } + StmtKind::If(cond, then, else_) => { + let then_exits = branch_always_exits(self.gcx, then); + let else_exits = else_.is_some_and(|e| branch_always_exits(self.gcx, e)); + // `if (!guard) revert;` restricts by itself; otherwise every non-exiting branch + // must restrict. + (then_exits != else_exits && self.cond_restricts(cond, else_exits)) + || ((then_exits || self.stmt_restricts(then)) + && (else_exits || else_.is_some_and(|e| self.stmt_restricts(e)))) + } + _ => false, + } + } + + /// `require(guard)` / `assert(guard)`, or a call to an internal helper whose body restricts + /// the caller and cannot `return` early. + fn expr_restricts(&mut self, expr: &'gcx Expr<'gcx>) -> bool { + let ExprKind::Call(callee, args) = &expr.peel_parens().kind else { return false }; + if is_require_or_assert(self.gcx, callee) { + return args.exprs().next().is_some_and(|c| self.cond_restricts(c, true)); + } + self.gcx + .resolved_function(callee) + .filter(|_| matches!(callee.peel_parens().kind, ExprKind::Ident(_))) + .is_some_and(|fid| { + if self.stack.contains(&fid) { + return false; + } + let Some(body) = self.gcx.hir.function(fid).body else { return false }; + // A trailing bare `return;` is a normal exit and cannot bypass an earlier guard. + let mut stmts = body.stmts; + while let [rest @ .., last] = stmts + && matches!(last.kind, StmtKind::Return(None)) + { + stmts = rest; + } + if stmts.iter().any(stmt_contains_return) { + return false; + } + self.stack.push(fid); + let restricts = stmts.iter().any(|s| self.stmt_restricts(s)); + self.stack.pop(); + restricts + }) + } + + /// True when `cond` (holding iff `holds`) entails `msg.sender == ` on every path. + fn cond_restricts(&mut self, cond: &'gcx Expr<'gcx>, holds: bool) -> bool { + match &cond.peel_parens().kind { + ExprKind::Binary(lhs, op, rhs) => { + let (eq, any, all) = if holds { + (BinOpKind::Eq, BinOpKind::And, BinOpKind::Or) + } else { + (BinOpKind::Ne, BinOpKind::Or, BinOpKind::And) + }; + if op.kind == any { + self.cond_restricts(lhs, holds) || self.cond_restricts(rhs, holds) + } else if op.kind == all { + self.cond_restricts(lhs, holds) && self.cond_restricts(rhs, holds) + } else if op.kind == eq { + [(lhs, rhs), (rhs, lhs)].into_iter().any(|(a, b)| { + is_msg_sender_like(self.gcx, a, HELPER_DEPTH) + && self.is_trusted_principal(b, HELPER_DEPTH) + }) + } else { + false + } + } + ExprKind::Unary(op, inner) if op.kind == UnOpKind::Not => { + self.cond_restricts(inner, !holds) + } + _ => false, + } + } + + /// Conservatively recognises deploy-time-fixed caller principals: address/zero literals and + /// state (or statically indexed state) that cannot alias `address(this)`, possibly behind a + /// no-arg getter. Parameters, locals, `msg.sender`, `tx.origin` and `this` are rejected. + fn is_trusted_principal(&mut self, expr: &'gcx Expr<'gcx>, depth: u8) -> bool { + let expr = peel_casts(self.gcx, expr); + match &expr.kind { + ExprKind::Lit(_) => is_trusted_literal(expr), + ExprKind::Ident(_) => self.gcx.resolved_variable(expr).is_some_and(|v| { + self.gcx.hir.variable(v).kind.is_state() + && !self.state_var_aliases_self(v, SELF_ALIAS_DEPTH) + }), + ExprKind::Member(base, _) => self.is_trusted_principal(base, depth), + ExprKind::Index(base, idx) => { + self.is_trusted_principal(base, depth) + && idx.is_none_or(|i| index_is_static(self.gcx, i)) + } + ExprKind::Call(callee, args) => { + depth > 0 + && args.exprs().next().is_none() + && callee_no_arg_returns(self.gcx, callee, |e| { + self.is_trusted_principal(e, depth - 1) + }) + } + _ => false, + } + } + + /// True when state variable `v` may hold `address(this)`: through its initializer or an + /// assignment in any function of its contract or a derived contract. + fn state_var_aliases_self(&mut self, v: VariableId, depth: u8) -> bool { + let var = self.gcx.hir.variable(v); + if depth == 0 || !var.kind.is_state() { + return false; + } + let key = (v, depth); + if let Some(cached) = self.alias_cache.get(&key) { + return *cached; + } + if self.alias_cache.is_in_progress(&key) { + return false; + } + self.alias_cache.start(key); + let aliases = var + .initializer + .is_some_and(|init| self.rhs_carries_self(var, init, depth - 1, &HashSet::new())) + || var.contract.is_some_and(|cid| { + self.gcx.hir.contracts_enumerated().any(|(c, contract)| { + (c == cid || contract.linearized_bases.contains(&cid)) + && self.contract_assigns_self(c, v, depth - 1) + }) + }); + self.alias_cache.finish(key, aliases); + aliases + } + + /// Whether assigning `rhs` to `target` may plant `address(this)` in it: an address-typed + /// target must receive `address(this)` itself, an aggregate may embed it anywhere. + fn rhs_carries_self( + &mut self, + target: &Variable<'_>, + rhs: &'gcx Expr<'gcx>, + depth: u8, + locals: &HashSet, + ) -> bool { + if var_is_address_like(target) { + self.expr_resolves_to_self(rhs, depth) + || lhs_root_var(self.gcx, rhs).is_some_and(|v| locals.contains(&v)) + } else { + self.expr_may_contain_self(rhs, depth, locals) + } + } + + /// True when `expr` may embed `address(this)` (or a local carrying it) anywhere. + fn expr_may_contain_self( + &mut self, + expr: &'gcx Expr<'gcx>, + depth: u8, + locals: &HashSet, + ) -> bool { + if self.expr_resolves_to_self(expr, depth) + || lhs_root_var(self.gcx, expr).is_some_and(|v| locals.contains(&v)) + { + return true; + } + if depth == 0 { + return false; + } + let children: Vec<&'gcx Expr<'gcx>> = match &peel_casts(self.gcx, expr).kind { + ExprKind::Call(_, args) => args.exprs().collect(), + ExprKind::Ternary(_, t, f) => vec![t, f], + ExprKind::Tuple(elems) => elems.iter().copied().flatten().collect(), + ExprKind::Array(elems) => elems.iter().collect(), + _ => Vec::new(), + }; + children.into_iter().any(|e| self.expr_may_contain_self(e, depth - 1, locals)) + } + + /// True when `expr` may evaluate to `address(this)`. + fn expr_resolves_to_self(&mut self, expr: &'gcx Expr<'gcx>, depth: u8) -> bool { + let expr = peel_casts(self.gcx, expr); + if is_address_self(self.gcx, expr) { + return true; + } + if depth == 0 { + return false; + } + match &expr.kind { + ExprKind::Ident(_) | ExprKind::Member(..) | ExprKind::Index(..) => { + lhs_root_var(self.gcx, expr).is_some_and(|v| self.state_var_aliases_self(v, depth)) + } + ExprKind::Call(callee, args) if args.exprs().next().is_none() => { + self.gcx.resolved_function(callee).is_some_and(|fid| { + function_no_arg_returns(self.gcx, fid, &mut |e| { + self.expr_resolves_to_self(e, depth - 1) + }) + }) + } + ExprKind::Call(callee, args) => identity_helper_arg(self.gcx, callee, args) + .is_some_and(|a| self.expr_resolves_to_self(a, depth - 1)), + ExprKind::Ternary(_, t, f) => { + self.expr_resolves_to_self(t, depth - 1) || self.expr_resolves_to_self(f, depth - 1) + } + ExprKind::Assign(_, _, rhs) => self.expr_resolves_to_self(rhs, depth - 1), + _ => false, + } + } + + /// Scans every function of `cid` for an assignment that may plant `address(this)` in `v`. + fn contract_assigns_self(&mut self, cid: ContractId, v: VariableId, depth: u8) -> bool { + self.gcx.hir.contract(cid).all_functions().any(|fid| { + let mut scan = SelfAssignScan { + guards: &mut *self, + target: v, + depth, + found: false, + stack: Vec::new(), + locals: HashSet::new(), + }; + scan.scan_function(fid, None); + scan.found + }) + } +} + +/// Scans one function, its modifiers / base constructors and inlined internal helpers for an +/// assignment that may plant `address(this)` into `target`. +struct SelfAssignScan<'a, 'gcx> { + guards: &'a mut CallerGuards<'gcx>, + target: VariableId, + depth: u8, + found: bool, + stack: Vec, + /// Locals that may (path-insensitively) carry `address(this)`. + locals: HashSet, +} + +impl<'gcx> SelfAssignScan<'_, 'gcx> { + fn may_contain_self(&mut self, expr: &'gcx Expr<'gcx>) -> bool { + self.guards.expr_may_contain_self(expr, self.depth, &self.locals) + } + + fn note_local(&mut self, v: VariableId, rhs: &'gcx Expr<'gcx>) { + if !self.guards.gcx.hir.variable(v).kind.is_state() && self.may_contain_self(rhs) { + self.locals.insert(v); + } + } + + fn assign(&mut self, lhs: &'gcx Expr<'gcx>, rhs: &'gcx Expr<'gcx>) { + if let Some(elems) = tuple_elems(lhs) { + let rhs = tuple_elems(rhs); + for (i, lhs) in elems.iter().enumerate() { + if let Some(lhs) = lhs + && let Some(rhs) = rhs.and_then(|r| r.get(i).copied().flatten()) + { + self.assign(lhs, rhs); + } + } + } else if let Some(v) = lhs_root_var(self.guards.gcx, lhs) { + if v == self.target { + let var = self.guards.gcx.hir.variable(v); + self.found |= self.guards.rhs_carries_self(var, rhs, self.depth, &self.locals); + } else { + self.note_local(v, rhs); + } + } + } + + /// Scans `fid`, seeding its parameters from `args` when given. + fn scan_function(&mut self, fid: FunctionId, args: Option<&'gcx CallArgs<'gcx>>) { + if self.found || self.stack.len() >= HELPER_CALL_DEPTH || self.stack.contains(&fid) { + return; + } + let f = self.guards.gcx.hir.function(fid); + let Some(body) = f.body else { return }; + let saved = self.locals.clone(); + for ¶m in f.parameters { + if let Some(arg) = + args.and_then(|args| arg_for_param(self.guards.gcx, fid, param, args)) + && self.may_contain_self(arg) + { + self.locals.insert(param); + } + } + self.stack.push(fid); + for m in f.modifiers { + if let Some(invoked) = invoked_function(&self.guards.gcx.hir, m) { + self.scan_function(invoked, Some(&m.args)); + } + } + for stmt in body.stmts { + let _ = self.visit_stmt(stmt); + } + self.stack.pop(); + self.locals = saved; + } +} + +impl<'gcx> Visit<'gcx> for SelfAssignScan<'_, 'gcx> { + type BreakValue = Never; + + fn hir(&self) -> &'gcx Hir<'gcx> { + &self.guards.gcx.hir + } + + fn visit_stmt(&mut self, stmt: &'gcx Stmt<'gcx>) -> ControlFlow { + if self.found { + return ControlFlow::Continue(()); + } + match &stmt.kind { + StmtKind::DeclSingle(vid) => { + if let Some(init) = self.guards.gcx.hir.variable(*vid).initializer { + self.note_local(*vid, init); + } + } + StmtKind::DeclMulti(vars, init) => { + for (vid, rhs) in vars.iter().zip(tuple_elems(init).into_iter().flatten()) { + if let (Some(vid), Some(rhs)) = (vid, rhs) { + self.note_local(*vid, rhs); + } + } + } + _ => {} + } + self.walk_stmt(stmt) + } + + fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow { + if self.found { + return ControlFlow::Continue(()); + } + match &expr.peel_parens().kind { + ExprKind::Assign(lhs, _, rhs) => self.assign(lhs, rhs), + ExprKind::Call(callee, args) => match &callee.peel_parens().kind { + // `target.push()` on an array / bytes state variable. + ExprKind::Member(recv, member) => { + if member.name.as_str() == "push" + && lhs_root_var(self.guards.gcx, recv) == Some(self.target) + && expr_is_array_or_bytes(self.guards.gcx, recv) + && args.exprs().any(|a| self.may_contain_self(a)) + { + self.found = true; + } + } + _ => { + if let Some(fid) = self.guards.gcx.resolved_function(callee) { + self.scan_function(fid, Some(args)); + } + } + }, + _ => {} + } + self.walk_expr(expr) + } +} + +/// The function invoked by a modifier or base-constructor invocation. +fn invoked_function(hir: &Hir<'_>, m: &Modifier<'_>) -> Option { + match m.id { + ItemId::Function(fid) => Some(fid), + ItemId::Contract(cid) => hir.contract(cid).ctor, + _ => None, + } +} + +/// Argument returned verbatim (modulo casts) by an identity helper call `id(x)` / `Lib.id(x)`. +fn identity_helper_arg<'gcx>( + gcx: Gcx<'gcx>, + callee: &'gcx Expr<'gcx>, + args: &'gcx CallArgs<'gcx>, +) -> Option<&'gcx Expr<'gcx>> { + gcx.resolved_function(callee).and_then(|fid| { + let f = gcx.hir.function(fid); + let [stmt] = f.body?.stmts else { return None }; + let StmtKind::Return(Some(ret)) = &stmt.kind else { return None }; + let param = underlying_var(gcx, peel_casts(gcx, ret))?; + (f.parameters.len() == args.len() && f.returns.len() == 1 && f.parameters.contains(¶m)) + .then(|| arg_for_param(gcx, fid, param, args)) + .flatten() + }) +} + +/// Variable at the root of an lvalue, through member / index accesses and address casts. +fn lhs_root_var(gcx: Gcx<'_>, lhs: &Expr<'_>) -> Option { + match &lhs.peel_parens().kind { + ExprKind::Member(base, _) | ExprKind::Index(base, _) | ExprKind::Payable(base) => { + lhs_root_var(gcx, base) + } + ExprKind::Call(callee, args) if is_address_like_cast(gcx, callee) => { + args.exprs().next().and_then(|expr| lhs_root_var(gcx, expr)) + } + _ => underlying_var(gcx, lhs), + } +} + +/// True when an index expression only depends on literals and state: no locals, parameters, +/// builtins (`msg.sender`) or non-cast calls. +fn index_is_static(gcx: Gcx<'_>, expr: &Expr<'_>) -> bool { + expr.visit(&mut |e| match &e.kind { + ExprKind::Lit(_) + | ExprKind::Type(_) + | ExprKind::Payable(_) + | ExprKind::Unary(..) + | ExprKind::Binary(..) + | ExprKind::Member(..) + | ExprKind::Index(..) + | ExprKind::Ternary(..) + | ExprKind::Tuple([Some(_)]) => ControlFlow::Continue(()), + ExprKind::Ident(_) + if gcx.resolved_expr(e).is_some_and(|res| match res { + Res::Item(ItemId::Variable(v)) => gcx.hir.variable(v).kind.is_state(), + Res::Builtin(_) => false, + _ => true, + }) => + { + ControlFlow::Continue(()) + } + ExprKind::Call(callee, ..) + if matches!(callee.peel_parens().kind, ExprKind::Type(_)) + || is_contract_cast(gcx, callee) => + { + ControlFlow::Continue(()) + } + _ => ControlFlow::Break(()), + }) + .is_continue() +} + +/// True when any statement of a helper body is a `return` (bare or valued). +fn stmt_contains_return(stmt: &Stmt<'_>) -> bool { + match &stmt.kind { + StmtKind::Return(_) => true, + StmtKind::Block(b) | StmtKind::UncheckedBlock(b) => { + b.stmts.iter().any(stmt_contains_return) + } + StmtKind::Loop(b, source) => loop_stmts(*b, *source).any(stmt_contains_return), + StmtKind::If(_, t, e) => { + stmt_contains_return(t) || e.is_some_and(|e| stmt_contains_return(e)) + } + StmtKind::Try(t) => { + t.clauses.iter().any(|c| c.block.stmts.iter().any(stmt_contains_return)) + } + _ => false, + } +} + +/// `msg.sender` modulo parens, casts, `payable(..)` and no-arg helpers such as `_msgSender()`. +fn is_msg_sender_like<'gcx>(gcx: Gcx<'gcx>, expr: &'gcx Expr<'gcx>, depth: u8) -> bool { + let expr = peel_casts(gcx, expr); + is_msg_sender(gcx, expr) + || matches!(&expr.kind, ExprKind::Call(callee, args) + if depth > 0 + && args.exprs().next().is_none() + && callee_no_arg_returns(gcx, callee, |e| is_msg_sender_like(gcx, e, depth - 1))) +} + +/// Looks through parens, `payable(..)`, address-like casts and integer casts. +fn peel_casts<'a>(gcx: Gcx<'_>, expr: &'a Expr<'a>) -> &'a Expr<'a> { + let expr = expr.peel_parens(); + match &expr.kind { + ExprKind::Payable(inner) => peel_casts(gcx, inner), + ExprKind::Call(callee, args) + if is_address_like_cast(gcx, callee) || is_numeric_cast(callee) => + { + args.exprs().next().map_or(expr, |expr| peel_casts(gcx, expr)) + } + _ => expr, + } +} + +/// `uint(..)` / `int(..)` cast head. +fn is_numeric_cast(callee: &Expr<'_>) -> bool { + matches!( + &callee.peel_parens().kind, + ExprKind::Type(hir::Type { + kind: TypeKind::Elementary(ElementaryType::UInt(_) | ElementaryType::Int(_)), + .. + }) + ) +} + +/// An address literal or the integer literal `0`. +fn is_trusted_literal(expr: &Expr<'_>) -> bool { + matches!(&expr.kind, ExprKind::Lit(lit) if matches!(lit.kind, LitKind::Address(_))) + || is_literal_zero(expr) +} + +fn expr_is_function<'gcx>(gcx: Gcx<'gcx>, expr: &'gcx Expr<'gcx>) -> bool { + gcx.type_of_expr(expr.peel_parens().id) + .is_some_and(|ty| matches!(ty.peel_refs().kind, TyKind::Fn(_))) +} + +fn expr_is_array_or_bytes<'gcx>(gcx: Gcx<'gcx>, expr: &'gcx Expr<'gcx>) -> bool { + gcx.type_of_expr(expr.peel_parens().id).is_some_and(|ty| { + matches!( + ty.peel_refs().kind, + TyKind::Array(..) | TyKind::DynArray(_) | TyKind::Elementary(ElementaryType::Bytes) + ) + }) +} diff --git a/crates/lint/src/sol/high/controlled_delegatecall.rs b/crates/lint/src/sol/high/controlled_delegatecall.rs new file mode 100644 index 0000000000000..fc82041ea84f0 --- /dev/null +++ b/crates/lint/src/sol/high/controlled_delegatecall.rs @@ -0,0 +1,503 @@ +use super::ControlledDelegatecall; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{ + Severity, SolLint, + analysis::{ + arg_for_param, branch_always_exits, count_placeholders, do_while_user_stmts, + has_side_effect, is_address_like_cast, is_loop_termination_if, is_require_or_assert, + loop_update, stmts_before_placeholder, stmts_break_or_continue, tuple_elems, + var_is_address_like, + }, + }, +}; +use solar::{ + ast::{BinOpKind, LitKind, UnOpKind}, + interface::{Span, sym}, + sema::{ + Gcx, + builtins::Builtin, + hir::{ + self, ElementaryType, Expr, ExprKind, FunctionKind, ItemId, LoopSource, Res, Stmt, + StmtKind, TypeKind, VariableId, Visit, + }, + }, +}; +use std::{collections::HashSet, ops::ControlFlow}; + +declare_forge_lint!( + CONTROLLED_DELEGATECALL, + Severity::High, + "controlled-delegatecall", + "`delegatecall` target is not provably trusted" +); + +/// How many levels of no-argument helper functions are inlined when checking a target. +const HELPER_DEPTH: u8 = 3; + +impl<'gcx> LateLintPass<'gcx> for ControlledDelegatecall { + fn check_function( + &mut self, + ctx: &LintContext, + gcx: Gcx<'gcx>, + func: &'gcx hir::Function<'gcx>, + ) { + let Some(body) = func.body else { return }; + let mut analyzer = Analyzer::new(gcx); + for modifier in func.modifiers { + analyzer.safe_vars.extend(modifier_safe_vars(gcx, modifier)); + } + let _ = analyzer.visit_stmts(body.stmts); + for span in analyzer.hits { + ctx.emit(&CONTROLLED_DELEGATECALL, span); + } + } +} + +/// Flow-sensitive walk tracking which local address variables provably hold a trusted target. +/// +/// `visit_stmt` breaks when control cannot fall through to the next statement. +struct Analyzer<'gcx> { + gcx: Gcx<'gcx>, + safe_vars: HashSet, + /// Every variable written during the walk. + assigned: HashSet, + /// Per enclosing loop, the states at each `break`/`continue`. + loop_exits: Vec>>, + hits: Vec, +} + +fn intersect(a: &HashSet, b: &HashSet) -> HashSet { + a.intersection(b).copied().collect() +} + +impl<'gcx> Analyzer<'gcx> { + fn new(gcx: Gcx<'gcx>) -> Self { + Self { + gcx, + safe_vars: HashSet::new(), + assigned: HashSet::new(), + loop_exits: Vec::new(), + hits: Vec::new(), + } + } + + fn visit_stmts(&mut self, stmts: &'gcx [Stmt<'gcx>]) -> ControlFlow<()> { + stmts.iter().try_for_each(|stmt| self.visit_stmt(stmt)) + } + + fn is_trusted_target(&self, expr: &'gcx Expr<'gcx>) -> bool { + self.is_trusted_target_inner(expr, HELPER_DEPTH) + } + + fn is_trusted_target_inner(&self, expr: &'gcx Expr<'gcx>, depth: u8) -> bool { + match &expr.peel_parens().kind { + ExprKind::Lit(lit) => match &lit.kind { + LitKind::Address(_) => true, + LitKind::Number(n) => n.is_zero(), + _ => false, + }, + ExprKind::Ident(_) => self.gcx.resolved_expr(expr).is_some_and(|res| match res { + Res::Builtin(builtin) => builtin.name() == sym::this, + Res::Item(ItemId::Variable(vid)) => { + let var = self.gcx.hir.variable(vid); + (var.is_constant() && var_is_address_like(var)) || self.safe_vars.contains(&vid) + } + _ => false, + }), + ExprKind::Call(callee, args) if is_cast(self.gcx, callee) => { + args.exprs().next().is_some_and(|arg| self.is_trusted_target_inner(arg, depth)) + } + ExprKind::Payable(inner) => self.is_trusted_target_inner(inner, depth), + ExprKind::Ternary(_, if_true, if_false) => { + self.is_trusted_target_inner(if_true, depth) + && self.is_trusted_target_inner(if_false, depth) + } + ExprKind::Assign(_, _, rhs) => self.is_trusted_target_inner(rhs, depth), + ExprKind::Call(callee, args) => { + depth > 0 + && args.exprs().next().is_none() + && no_arg_helper_return(self.gcx, callee) + .is_some_and(|ret| self.is_trusted_target_inner(ret, depth - 1)) + } + _ => false, + } + } + + /// Local or constant address-like variable: the only kind a comparison can vouch for. + fn is_trusted_fact_target(&self, var: VariableId) -> bool { + let variable = self.gcx.hir.variable(var); + (!variable.kind.is_state() || variable.is_constant()) && var_is_address_like(variable) + } + + /// Records a write to `var`; it is safe afterwards only if local, address-like and `trusted`. + fn assign(&mut self, var: VariableId, trusted: bool) { + self.assigned.insert(var); + self.safe_vars.remove(&var); + let variable = self.gcx.hir.variable(var); + if trusted && !variable.kind.is_state() && var_is_address_like(variable) { + self.safe_vars.insert(var); + } + } + + fn assign_expr(&mut self, lhs: &'gcx Expr<'gcx>, rhs: Option<&'gcx Expr<'gcx>>) { + if let Some(var) = underlying_var(self.gcx, lhs) { + self.assign(var, rhs.is_some_and(|rhs| self.is_trusted_target(rhs))); + } + } + + fn handle_assign( + &mut self, + lhs: &'gcx Expr<'gcx>, + op: Option, + rhs: &'gcx Expr<'gcx>, + ) { + let rhs = op.is_none().then_some(rhs); + let Some(lhs_elems) = tuple_elems(lhs) else { return self.assign_expr(lhs, rhs) }; + let rhs_elems = rhs.and_then(tuple_elems); + for (i, lhs_elem) in lhs_elems.iter().enumerate() { + if let Some(lhs_elem) = lhs_elem { + let rhs_elem = rhs_elems.and_then(|elems| elems.get(i).copied().flatten()); + self.assign_expr(lhs_elem, rhs_elem); + } + } + } + + fn is_controlled_delegatecall(&self, expr: &'gcx Expr<'gcx>) -> bool { + let ExprKind::Call(callee, ..) = &expr.peel_parens().kind else { return false }; + let ExprKind::Member(receiver, _) = &callee.peel_parens().kind else { return false }; + self.gcx.resolved_builtin(callee) == Some(Builtin::AddressDelegatecall) + && !self.is_trusted_target(receiver) + } + + /// Learns which variables are trusted when `pred` evaluates to `!negate`. + fn add_facts(&mut self, pred: &'gcx Expr<'gcx>, negate: bool) { + if !has_side_effect(pred) { + self.add_facts_unchecked(pred, negate); + } + } + + fn add_facts_unchecked(&mut self, pred: &'gcx Expr<'gcx>, negate: bool) { + match &pred.peel_parens().kind { + ExprKind::Binary(lhs, op, rhs) => { + let (eq, and_op, or_op) = if negate { + (BinOpKind::Ne, BinOpKind::Or, BinOpKind::And) + } else { + (BinOpKind::Eq, BinOpKind::And, BinOpKind::Or) + }; + if op.kind == and_op { + self.add_facts_unchecked(lhs, negate); + self.add_facts_unchecked(rhs, negate); + } else if op.kind == or_op { + // Only facts established by both disjuncts hold. + let baseline = self.safe_vars.clone(); + self.add_facts_unchecked(lhs, negate); + let lhs_added = std::mem::replace(&mut self.safe_vars, baseline.clone()); + self.add_facts_unchecked(rhs, negate); + let rhs_added = std::mem::replace(&mut self.safe_vars, baseline); + self.safe_vars.extend(intersect(&lhs_added, &rhs_added)); + } else if op.kind == eq { + for (safe, candidate) in [(lhs, rhs), (rhs, lhs)] { + if self.is_trusted_target(safe) + && let Some(var) = underlying_var(self.gcx, candidate) + && self.is_trusted_fact_target(var) + { + self.safe_vars.insert(var); + } + } + } + } + ExprKind::Unary(op, inner) if op.kind == UnOpKind::Not => { + self.add_facts_unchecked(inner, !negate); + } + _ => {} + } + } + + /// Visits `arm` under the assumption that `cond == !negate`, returning the resulting state when + /// the arm falls through. + fn visit_arm( + &mut self, + cond: &'gcx Expr<'gcx>, + negate: bool, + arm: impl FnOnce(&mut Self) -> ControlFlow<()>, + ) -> Option> { + self.add_facts(cond, negate); + arm(self).is_continue().then(|| self.safe_vars.clone()) + } + + /// Joins the states of two arms; `None` marks an arm that does not fall through. + fn join( + &mut self, + a: Option>, + b: Option>, + ) -> ControlFlow<()> { + match (a, b) { + (Some(a), Some(b)) => self.safe_vars = intersect(&a, &b), + (Some(state), None) | (None, Some(state)) => self.safe_vars = state, + (None, None) => return ControlFlow::Break(()), + } + ControlFlow::Continue(()) + } +} + +impl<'gcx> Visit<'gcx> for Analyzer<'gcx> { + type BreakValue = (); + + fn hir(&self) -> &'gcx hir::Hir<'gcx> { + &self.gcx.hir + } + + fn visit_stmt(&mut self, stmt: &'gcx Stmt<'gcx>) -> ControlFlow<()> { + match &stmt.kind { + StmtKind::Block(block) | StmtKind::UncheckedBlock(block) => { + self.visit_stmts(block.stmts) + } + StmtKind::If(cond, then, else_) => { + let _ = self.visit_expr(cond); + let baseline = self.safe_vars.clone(); + let then_state = self.visit_arm(cond, false, |this| this.visit_stmt(then)); + self.safe_vars = baseline; + let else_state = self.visit_arm(cond, true, |this| match else_ { + Some(else_) => this.visit_stmt(else_), + None => ControlFlow::Continue(()), + }); + self.join(then_state, else_state) + } + StmtKind::Loop(block, LoopSource::DoWhile) + if !stmts_break_or_continue(do_while_user_stmts(block.stmts)) => + { + // Without `break`/`continue` the body runs straight through once, then the + // lowered `if (!cond) break;` condition is evaluated. + self.visit_stmts(do_while_user_stmts(block.stmts))?; + if let Some(last) = block.stmts.last() + && is_loop_termination_if(last) + && let StmtKind::If(cond, ..) = &last.kind + { + let _ = self.visit_expr(cond); + } + ControlFlow::Continue(()) + } + StmtKind::Loop(block, source) => { + // The state after the loop is what holds on every way out: never entering the + // body, each `break`/`continue`, and falling off the end of an iteration. + self.loop_exits.push(vec![self.safe_vars.clone()]); + let falls_through = self.visit_stmts(block.stmts).is_continue() + && loop_update(*source) + .is_none_or(|update| self.visit_stmt(update).is_continue()); + let mut exits = self.loop_exits.pop().expect("loop frame"); + if falls_through { + exits.push(self.safe_vars.clone()); + } + self.safe_vars = + exits.iter().skip(1).fold(exits[0].clone(), |a, b| intersect(&a, b)); + ControlFlow::Continue(()) + } + StmtKind::Break | StmtKind::Continue => { + if let Some(exits) = self.loop_exits.last_mut() { + exits.push(self.safe_vars.clone()); + } + ControlFlow::Break(()) + } + StmtKind::Try(stmt_try) => { + let _ = self.visit_expr(&stmt_try.expr); + let outer = self.safe_vars.clone(); + let mut joined = None; + for clause in stmt_try.clauses { + self.safe_vars = outer.clone(); + if self.visit_stmts(clause.block.stmts).is_continue() { + joined = Some(match joined { + Some(state) => intersect(&state, &self.safe_vars), + None => self.safe_vars.clone(), + }); + } + } + self.safe_vars = joined.unwrap_or(outer); + ControlFlow::Continue(()) + } + StmtKind::Err(_) => { + self.safe_vars.clear(); + ControlFlow::Continue(()) + } + StmtKind::DeclSingle(var) => { + let init = self.gcx.hir.variable(*var).initializer; + self.assign(*var, init.is_some_and(|init| self.is_trusted_target(init))); + self.walk_stmt(stmt) + } + StmtKind::DeclMulti(vars, init) => { + let inits = tuple_elems(init); + for (i, var) in vars.iter().enumerate() { + if let Some(var) = var { + let init = inits.and_then(|elems| elems.get(i).copied().flatten()); + self.assign(*var, init.is_some_and(|init| self.is_trusted_target(init))); + } + } + self.walk_stmt(stmt) + } + _ => { + let _ = self.walk_stmt(stmt); + if branch_always_exits(self.gcx, stmt) { + ControlFlow::Break(()) + } else { + ControlFlow::Continue(()) + } + } + } + } + + fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow<()> { + if self.is_controlled_delegatecall(expr) { + self.hits.push(expr.span); + } + match &expr.kind { + ExprKind::Binary(lhs, op, rhs) if matches!(op.kind, BinOpKind::And | BinOpKind::Or) => { + let _ = self.visit_expr(lhs); + let skipped_rhs = self.safe_vars.clone(); + let ran_rhs = + self.visit_arm(lhs, op.kind == BinOpKind::Or, |this| this.visit_expr(rhs)); + self.join(Some(skipped_rhs), ran_rhs) + } + ExprKind::Ternary(cond, if_true, if_false) => { + let _ = self.visit_expr(cond); + let baseline = self.safe_vars.clone(); + let true_state = self.visit_arm(cond, false, |this| this.visit_expr(if_true)); + self.safe_vars = baseline; + let false_state = self.visit_arm(cond, true, |this| this.visit_expr(if_false)); + self.join(true_state, false_state) + } + ExprKind::Call(callee, args) if is_require_or_assert(self.gcx, callee) => { + let _ = self.walk_expr(expr); + let mut args = args.exprs(); + if let Some(cond) = args.next() + && !args.any(has_side_effect) + { + self.add_facts(cond, false); + } + ControlFlow::Continue(()) + } + ExprKind::Assign(lhs, op, rhs) => { + let _ = self.walk_expr(expr); + self.handle_assign(lhs, *op, rhs); + ControlFlow::Continue(()) + } + ExprKind::Delete(target) => { + // `delete` zeroes the target, and the zero address is trusted. + if let Some(var) = underlying_var(self.gcx, target) { + self.assign(var, true); + } + self.walk_expr(expr) + } + _ => self.walk_expr(expr), + } + } +} + +/// The variable a bare identifier refers to, looking through parens, `payable(...)` and +/// address-like casts or integer casts preserving all 160 address bits. +fn underlying_var(gcx: Gcx<'_>, expr: &Expr<'_>) -> Option { + match &expr.peel_parens().kind { + ExprKind::Ident(_) => gcx.resolved_variable(expr), + ExprKind::Call(callee, args) + if is_address_like_cast(gcx, callee) + || matches!( + &callee.peel_parens().kind, + ExprKind::Type(hir::Type { + kind: TypeKind::Elementary(ElementaryType::Int(size) | ElementaryType::UInt(size)), + .. + }) if size.bytes() >= 20 + ) => + { + args.exprs().next().and_then(|expr| underlying_var(gcx, expr)) + } + ExprKind::Payable(inner) => underlying_var(gcx, inner), + _ => None, + } +} + +/// `address(..)`, `IFoo(..)`, `uintN(..)`, `intN(..)` or `bytes(..)` cast head. +fn is_cast(gcx: Gcx<'_>, callee: &Expr<'_>) -> bool { + is_address_like_cast(gcx, callee) + || matches!( + &callee.peel_parens().kind, + ExprKind::Type(hir::Type { + kind: TypeKind::Elementary( + ElementaryType::Int(_) | ElementaryType::UInt(_) | ElementaryType::Bytes + ), + .. + }) + ) +} + +/// The expression returned by a non-virtual, non-overriding, parameterless helper whose body is a +/// single `return ;` or ` = ;` (optionally followed by a bare `return;`). +fn no_arg_helper_return<'gcx>( + gcx: Gcx<'gcx>, + callee: &'gcx Expr<'gcx>, +) -> Option<&'gcx Expr<'gcx>> { + let fid = gcx + .resolved_function(callee) + .filter(|_| matches!(callee.peel_parens().kind, ExprKind::Ident(_)))?; + let func = gcx.hir.function(fid); + if func.virtual_ || func.override_ || !func.parameters.is_empty() { + return None; + } + let body = func.body?; + let stmts = match body.stmts.split_last() { + Some((last, rest)) if matches!(last.kind, StmtKind::Return(None)) => rest, + _ => body.stmts, + }; + let [stmt] = stmts else { return None }; + match &stmt.kind { + StmtKind::Return(Some(expr)) => Some(expr), + StmtKind::Expr(expr) => match &expr.peel_parens().kind { + ExprKind::Assign(lhs, None, rhs) + if func.returns.len() == 1 && underlying_var(gcx, lhs) == Some(func.returns[0]) => + { + Some(rhs) + } + _ => None, + }, + _ => None, + } +} + +/// Caller variables proven trusted by the statements a modifier runs before `_`: a parameter that +/// is bound to the variable, never reassigned in the prefix, and safe when `_` is reached. +fn modifier_safe_vars<'gcx>( + gcx: Gcx<'gcx>, + invocation: &'gcx hir::Modifier<'gcx>, +) -> Vec { + let Some(fid) = invocation.id.as_function() else { return Vec::new() }; + let modifier = gcx.hir.function(fid); + let Some(body) = modifier.body else { return Vec::new() }; + let mut prefix = Vec::new(); + if modifier.kind != FunctionKind::Modifier + || count_placeholders(body.stmts) != 1 + || stmts_before_placeholder(body.stmts, &mut prefix).is_none() + { + return Vec::new(); + } + let bindings: Vec<_> = modifier + .parameters + .iter() + .filter_map(|¶m| { + let arg = arg_for_param(gcx, fid, param, &invocation.args)?; + Some((param, underlying_var(gcx, arg)?)) + }) + .collect(); + if bindings.is_empty() { + return Vec::new(); + } + + let mut analyzer = Analyzer::new(gcx); + let _ = prefix.iter().try_for_each(|stmt| analyzer.visit_stmt(stmt)); + bindings + .into_iter() + .filter(|&(param, caller_var)| { + !analyzer.assigned.contains(¶m) + && analyzer.safe_vars.contains(¶m) + && analyzer.is_trusted_fact_target(caller_var) + }) + .map(|(_, caller_var)| caller_var) + .collect() +} diff --git a/crates/lint/src/sol/high/encode_packed_collision.rs b/crates/lint/src/sol/high/encode_packed_collision.rs new file mode 100644 index 0000000000000..ad3be43f8df0a --- /dev/null +++ b/crates/lint/src/sol/high/encode_packed_collision.rs @@ -0,0 +1,45 @@ +use super::EncodedPackedCollision; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{Severity, SolLint}, +}; +use solar::{ + ast::LitKind, + sema::{ + Gcx, + builtins::Builtin, + hir::{Expr, ExprKind}, + }, +}; + +declare_forge_lint!( + ENCODE_PACKED_COLLISION, + Severity::High, + "encode-packed-collision", + "`abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible" +); + +impl<'gcx> LateLintPass<'gcx> for EncodedPackedCollision { + fn check_expr(&mut self, ctx: &LintContext, gcx: Gcx<'gcx>, expr: &'gcx Expr<'gcx>) { + let ExprKind::Call(callee, args) = &expr.kind else { return }; + if gcx.resolved_builtin(callee) != Some(Builtin::AbiEncodePacked) { + return; + } + // Only non-literal dynamic args count: a top-level string/hex/unicode literal is a + // compile-time constant. With at most one non-literal dynamic arg the packed encoding + // is still injective, so there is no collision risk. + let dynamic_count = + args.exprs().filter(|arg| !is_str_lit(arg) && is_dynamic_arg(gcx, arg)).count(); + if dynamic_count >= 2 { + ctx.emit(&ENCODE_PACKED_COLLISION, expr.span); + } + } +} + +fn is_str_lit(expr: &Expr<'_>) -> bool { + matches!(expr.peel_parens().kind, ExprKind::Lit(lit) if matches!(lit.kind, LitKind::Str(..))) +} + +fn is_dynamic_arg<'gcx>(gcx: Gcx<'gcx>, expr: &Expr<'gcx>) -> bool { + gcx.type_of_expr(expr.peel_parens().id).is_some_and(|ty| ty.peel_refs().is_dynamically_sized()) +} diff --git a/crates/lint/src/sol/high/enumerable_loop_removal.rs b/crates/lint/src/sol/high/enumerable_loop_removal.rs new file mode 100644 index 0000000000000..163b53cae44d2 --- /dev/null +++ b/crates/lint/src/sol/high/enumerable_loop_removal.rs @@ -0,0 +1,538 @@ +use super::EnumerableLoopRemoval; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{ + Severity, SolLint, + analysis::{branch_always_exits, loop_update, write_target}, + }, +}; +use alloy_primitives::U256; +use solar::{ + ast::{LitKind, UnOpKind}, + interface::Symbol, + sema::{ + Gcx, + hir::{ + self, BinOpKind, Expr, ExprKind, Hir, LoopSource, Stmt, StmtKind, VarKind, VariableId, + Visit, + }, + }, +}; +use std::{convert::Infallible, ops::ControlFlow}; + +declare_forge_lint!( + ENUMERABLE_LOOP_REMOVAL, + Severity::High, + "enumerable-loop-removal", + "`remove` on an `EnumerableSet` inside a loop that iterates it with `at` can corrupt the iteration" +); + +// The detector reports only the shape it can judge without a flow analysis: a loop whose own +// index is written exclusively by simple unconditional increments, reads the set with `at` at +// that bare index, and removes from the same set in a straight-line body. Other shapes are +// deliberately unreported even when they corrupt iteration; set operands that cannot be +// identified statically are conservatively treated as possible aliases. + +impl<'gcx> LateLintPass<'gcx> for EnumerableLoopRemoval { + fn check_function( + &mut self, + ctx: &LintContext, + gcx: Gcx<'gcx>, + func: &'gcx hir::Function<'gcx>, + ) { + if let Some(body) = func.body { + LoopFinder { gcx, ctx, bindings: Vec::new() }.walk_body(body.stmts); + } + } +} + +/// Walks a function body in statement order and, for each loop, flags the EnumerableSet `remove` +/// calls that corrupt that loop's own iteration. The walk keeps, at every point, what each local +/// `storage` reference last named, so each loop is judged against the bindings standing where it +/// runs rather than against every binding of the function. +struct LoopFinder<'ctx, 's, 'c, 'gcx> { + gcx: Gcx<'gcx>, + ctx: &'ctx LintContext<'s, 'c>, + /// What each local `storage` reference names where the walk stands, the latest entry + /// winning; `None` once a write leaves it without one answer (a conditional branch, a loop + /// body, or an unreadable shape). + bindings: Vec<(VariableId, Option)>, +} + +impl<'gcx> LoopFinder<'_, '_, '_, 'gcx> { + fn walk_body(&mut self, stmts: impl IntoIterator>) { + for stmt in stmts { + self.walk_stmt(stmt); + } + } + + fn walk_stmt(&mut self, stmt: &'gcx Stmt<'gcx>) { + // A `for` desugars to `Block { init; Loop(For) }`; its index lives partly in the init, + // which runs once, on the straight line entering the loop. + if let StmtKind::Block(block) = &stmt.kind + && let Some((last, init)) = block.stmts.split_last() + && let StmtKind::Loop(body, source @ LoopSource::For { .. }) = &last.kind + { + self.walk_body(init); + return self.enter_loop(init, body.stmts, loop_update(*source)); + } + match &stmt.kind { + // A bare block runs on the straight line: what it binds stays bound past it. + StmtKind::Block(block) | StmtKind::UncheckedBlock(block) => self.walk_body(block.stmts), + StmtKind::Loop(body, source) => self.enter_loop(&[], body.stmts, loop_update(*source)), + // Which branch ran is not tracked: everything the statement writes stops naming one + // thing, and what a branch binds for its own statements ends with the branch. + StmtKind::If(_, then, else_) => { + self.poison_writes(std::slice::from_ref(stmt)); + let mark = self.bindings.len(); + self.walk_stmt(then); + self.bindings.truncate(mark); + if let Some(else_) = else_ { + self.walk_stmt(else_); + self.bindings.truncate(mark); + } + } + StmtKind::Try(try_) => { + self.poison_writes(std::slice::from_ref(stmt)); + let mark = self.bindings.len(); + for clause in try_.clauses { + self.walk_body(clause.block.stmts); + self.bindings.truncate(mark); + } + } + _ => self.apply_bindings(stmt), + } + } + + /// Analyzes one loop, then walks inside it for the nested ones. A write anywhere in the loop + /// may have run on an earlier turn by the time any of its statements runs again, so + /// everything the loop writes, init included, stops naming one thing before the loop is + /// judged, and stays so past it. + fn enter_loop( + &mut self, + init: &'gcx [Stmt<'gcx>], + body: &'gcx [Stmt<'gcx>], + update: Option<&'gcx Stmt<'gcx>>, + ) { + self.poison_writes(init); + self.poison_writes(body.iter().chain(update)); + self.analyze_loop(user_body(body).iter().chain(update)); + let mark = self.bindings.len(); + self.walk_body(body.iter().chain(update)); + self.bindings.truncate(mark); + } + + /// Applies one straight-line statement to the bindings: everything it writes stops naming + /// one thing, then a declaration or plain assignment binds its reference to what the + /// right-hand side names right here (resolved eagerly, so a later write to a reference the + /// right-hand side reads does not reach back into this binding). + fn apply_bindings(&mut self, stmt: &'gcx Stmt<'gcx>) { + self.poison_writes(std::slice::from_ref(stmt)); + let bindings = &mut self.bindings; + let mut bind = |var: VariableId, value: &Expr<'_>| { + let path = set_path(self.gcx, value, bindings, &mut Vec::new()); + bindings.push((var, path)); + }; + match &stmt.kind { + StmtKind::DeclSingle(var) => { + if let Some(init) = self.gcx.hir.variable(*var).initializer { + bind(*var, init); + } + } + StmtKind::Expr(expr) => { + if let ExprKind::Assign(target, None, value) = &expr.peel_parens().kind + && let ExprKind::Ident(_) = &target.peel_parens().kind + && let Some(var) = self.gcx.resolved_variable(target) + { + bind(var, value); + } + } + _ => {} + } + } + + /// Marks everything the statements write as no longer naming one thing. + fn poison_writes(&mut self, stmts: impl IntoIterator>) { + let mut written = Vec::new(); + collect_writes(self.gcx, stmts, &mut written); + self.bindings.extend(written.into_iter().map(|var| (var, None))); + } + + /// Flags the removals in a straight-line loop body that remove from a set the loop reads with + /// `at` at an unconditional ascending cadence. + fn analyze_loop(&mut self, body: impl Iterator> + Clone) { + // Control flow would make the corruption depend on the path taken, which is not tracked; + // without an ascending index there is no upward walk for swap-and-pop to disturb. + if !body_is_straight_line(self.gcx, body.clone()) { + return; + } + let cadence = ascending_cadence(self.gcx, body.clone()); + if cadence.is_empty() { + return; + } + let (mut iterated, mut removes) = (Vec::new(), Vec::new()); + let mut calls = ExprWalker { + hir: &self.gcx.hir, + prune_unreachable: true, + f: |expr: &'gcx Expr<'gcx>| { + let Some(call) = enumerable_set_call(self.gcx, &self.bindings, expr) else { + return; + }; + match call.op { + SetOp::At => { + if call + .index + .and_then(Expr::as_variable) + .is_some_and(|i| cadence.contains(&i)) + { + iterated.push(call.set); + } + } + SetOp::Remove => removes.push((call.set, expr.span)), + } + }, + }; + for stmt in body { + let _ = calls.visit_stmt(stmt); + } + for (removed, span) in removes { + // Two readable paths name the same set exactly when equal; an unreadable one may. + let corrupts = iterated.iter().any(|iterated| { + removed.as_ref().zip(iterated.as_ref()).is_none_or(|(a, b)| a == b) + }); + if corrupts { + self.ctx.emit(&ENUMERABLE_LOOP_REMOVAL, span); + } + } + } +} + +/// Calls `f` on every expression under the visited statements. With `prune_unreachable`, the +/// arms of `&&`/`||`/`?:` that a literal boolean condition proves unreachable are skipped. +struct ExprWalker<'gcx, F> { + hir: &'gcx Hir<'gcx>, + prune_unreachable: bool, + f: F, +} + +impl<'gcx, F: FnMut(&'gcx Expr<'gcx>)> Visit<'gcx> for ExprWalker<'gcx, F> { + type BreakValue = Infallible; + + fn hir(&self) -> &'gcx Hir<'gcx> { + self.hir + } + + fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow { + (self.f)(expr); + if !self.prune_unreachable { + return self.walk_expr(expr); + } + match &expr.kind { + ExprKind::Binary(left, op, right) + if matches!(op.kind, BinOpKind::And | BinOpKind::Or) => + { + self.visit_expr(left)?; + let short_circuits = matches!( + (op.kind, literal_bool(left)), + (BinOpKind::And, Some(false)) | (BinOpKind::Or, Some(true)) + ); + if !short_circuits { + self.visit_expr(right)?; + } + ControlFlow::Continue(()) + } + ExprKind::Ternary(condition, true_expr, false_expr) => { + self.visit_expr(condition)?; + match literal_bool(condition) { + Some(true) => self.visit_expr(true_expr), + Some(false) => self.visit_expr(false_expr), + None => { + self.visit_expr(true_expr)?; + self.visit_expr(false_expr) + } + } + } + _ => self.walk_expr(expr), + } + } +} + +/// The user-written body of a loop, peeled out of the synthetic condition guard the lowering +/// wraps it in: `for`/`while` become a single `if (cond) { body } else break`, `do-while` appends +/// `if (cond) continue; else break;`. Without peeling, the guard's `break`/`continue` would read +/// as user control flow. A body of another shape is returned unchanged. +fn user_body<'gcx>(body: &'gcx [Stmt<'gcx>]) -> &'gcx [Stmt<'gcx>] { + let is_break = |stmt: &Stmt<'_>| matches!(stmt.kind, StmtKind::Break); + match body { + [only] => match &only.kind { + StmtKind::If(_, then, Some(else_)) if is_break(else_) => std::slice::from_ref(*then), + _ => body, + }, + [rest @ .., last] => match &last.kind { + StmtKind::If(_, then, Some(else_)) + if matches!(then.kind, StmtKind::Continue) && is_break(else_) => + { + rest + } + _ => body, + }, + [] => body, + } +} + +/// Whether every statement of a loop body runs on one straight line: no branch, jump, terminal +/// statement, inline assembly or nested loop (bare blocks are transparent). Any of these could +/// let control skip a removal or the cadence step, or leave the loop before a shifted slot is +/// read, none of which this detector tracks. +fn body_is_straight_line<'gcx>( + gcx: Gcx<'_>, + stmts: impl IntoIterator>, +) -> bool { + stmts.into_iter().all(|stmt| { + !branch_always_exits(gcx, stmt) + && match &stmt.kind { + StmtKind::Block(block) | StmtKind::UncheckedBlock(block) => { + body_is_straight_line(gcx, block.stmts) + } + StmtKind::If(..) + | StmtKind::Try(..) + | StmtKind::Loop(..) + | StmtKind::AssemblyBlock(..) + | StmtKind::Break + | StmtKind::Continue => false, + _ => true, + } + }) +} + +/// The loop's own indices that step upward unconditionally: bare identifiers whose every write +/// on the straight line of the body (bare blocks included) is a supported ascending step. A +/// reset, a no-op step, a decrement or composite arithmetic disqualifies the variable. +fn ascending_cadence<'gcx>( + gcx: Gcx<'gcx>, + body: impl IntoIterator>, +) -> Vec { + let (mut cadence, mut other_writes) = (Vec::new(), Vec::new()); + collect_cadence_writes(gcx, body, &mut cadence, &mut other_writes); + cadence.retain(|var| !other_writes.contains(var)); + cadence +} + +fn collect_cadence_writes<'gcx>( + gcx: Gcx<'gcx>, + stmts: impl IntoIterator>, + cadence: &mut Vec, + other_writes: &mut Vec, +) { + for stmt in stmts { + let mut written = match &stmt.kind { + StmtKind::Block(block) | StmtKind::UncheckedBlock(block) => { + collect_cadence_writes(gcx, block.stmts, cadence, other_writes); + continue; + } + StmtKind::DeclSingle(var) => vec![*var], + StmtKind::DeclMulti(vars, _) => vars.iter().flatten().copied().collect(), + _ => Vec::new(), + }; + collect_writes(gcx, std::slice::from_ref(stmt), &mut written); + let ascending = match &stmt.kind { + StmtKind::Expr(expr) => ascending_step(gcx, expr.peel_parens()), + _ => None, + }; + for var in written { + if ascending != Some(var) { + other_writes.push(var); + } else if !cadence.contains(&var) { + cadence.push(var); + } + } + } +} + +/// The bare identifier an expression steps upward by one of the simple ascending forms: +/// `i++`/`++i`, `i += `, `i = i + ` or its commutation. +fn ascending_step<'gcx>(gcx: Gcx<'gcx>, expr: &'gcx Expr<'gcx>) -> Option { + let variable = |expr: &Expr<'_>| { + gcx.resolved_variable(expr) + .filter(|_| matches!(expr.peel_parens().kind, ExprKind::Ident(_))) + }; + match &expr.kind { + ExprKind::Unary(op, operand) if matches!(op.kind, UnOpKind::PreInc | UnOpKind::PostInc) => { + variable(operand) + } + ExprKind::Assign(lhs, Some(op), rhs) + if op.kind == BinOpKind::Add && is_positive_literal(rhs) => + { + variable(lhs) + } + ExprKind::Assign(lhs, None, rhs) => { + let target = variable(lhs)?; + let ExprKind::Binary(left, op, right) = &rhs.peel_parens().kind else { return None }; + (op.kind == BinOpKind::Add + && ((variable(left) == Some(target) && is_positive_literal(right)) + || (is_positive_literal(left) && variable(right) == Some(target)))) + .then_some(target) + } + _ => None, + } +} + +fn is_positive_literal(expr: &Expr<'_>) -> bool { + matches!(&expr.peel_parens().kind, ExprKind::Lit(lit) + if matches!(&lit.kind, LitKind::Number(value) if !value.is_zero())) +} + +fn literal_bool(expr: &Expr<'_>) -> Option { + match &expr.peel_parens().kind { + ExprKind::Lit(lit) => match lit.kind { + LitKind::Bool(value) => Some(value), + _ => None, + }, + _ => None, + } +} + +/// The variables a statement list writes through expressions, nested loops included: +/// assignments (tuple targets included), increments, decrements and deletes. Member and indexed +/// targets do not write their base variable. +fn collect_writes<'gcx>( + gcx: Gcx<'gcx>, + stmts: impl IntoIterator>, + out: &mut Vec, +) { + fn lvalue_variables(gcx: Gcx<'_>, expr: &Expr<'_>, out: &mut Vec) { + match &expr.peel_parens().kind { + ExprKind::Ident(_) => out.extend(gcx.resolved_variable(expr)), + ExprKind::Tuple(exprs) => { + exprs.iter().flatten().for_each(|expr| lvalue_variables(gcx, expr, out)); + } + _ => {} + } + } + let mut writes = ExprWalker { + hir: &gcx.hir, + prune_unreachable: false, + f: |expr: &Expr<'_>| { + if let Some(target) = write_target(expr) { + lvalue_variables(gcx, target, out) + } + }, + }; + for stmt in stmts { + let _ = writes.visit_stmt(stmt); + } +} + +#[derive(PartialEq, Eq, Clone, Copy)] +enum SetOp { + At, + Remove, +} + +/// A resolved EnumerableSet call. +struct SetCall<'gcx> { + op: SetOp, + set: Option, + /// The `index` argument of `at`. + index: Option<&'gcx Expr<'gcx>>, +} + +/// The EnumerableSet `at` or `remove` a call dispatches to. Resolving through the type checker +/// covers the `using for` method form, the library-qualified form and import aliases. The library +/// is identified only by its kind and exact `EnumerableSet` name, not its source or behavior. +fn enumerable_set_call<'gcx>( + gcx: Gcx<'gcx>, + bindings: &Bindings, + expr: &'gcx Expr<'gcx>, +) -> Option> { + let ExprKind::Call(callee, ..) = &expr.kind else { return None }; + let function_id = gcx.resolved_function(callee)?; + let function = gcx.hir.function(function_id); + let contract = gcx.hir.contract(function.contract?); + if !contract.kind.is_library() || contract.name.as_str() != "EnumerableSet" { + return None; + } + let op = match function.name?.as_str() { + "at" => SetOp::At, + "remove" => SetOp::Remove, + _ => return None, + }; + // The set operand is the bound receiver in the method form and the first argument in the + // library-qualified form; the index of `at` sits right after it. + let (set_expr, index_arg) = match &callee.peel_parens().kind { + ExprKind::Member(receiver, _) + if gcx.resolved_call(expr).is_some_and(|resolved| resolved.attached) => + { + (Some(&**receiver), 0) + } + _ => (gcx.call_arg(expr, 0), 1), + }; + Some(SetCall { + op, + set: set_expr.and_then(|expr| set_path(gcx, expr, bindings, &mut Vec::new())), + index: gcx.call_arg(expr, index_arg), + }) +} + +/// One step of the storage path naming a set: a struct field or a literal mapping key. +#[derive(PartialEq, Eq, Clone, Copy)] +enum Step { + Field(Symbol), + Key(U256), +} + +/// The storage location a set expression names: a base variable and the steps taken from it. +/// Two expressions name the same set exactly when they are the same path. +#[derive(PartialEq, Eq, Clone)] +struct SetPath { + base: VariableId, + steps: Vec, +} + +/// What each local `storage` reference names at the point being analyzed, the latest entry +/// winning; `None` marks a reference no straight-line reading gives one answer for. +type Bindings = [(VariableId, Option)]; + +/// The path a set expression names, or `None` when it cannot be read: an index that varies, a +/// call result, a reference without one straight-line binding, anything the analysis would have +/// to evaluate. +fn set_path( + gcx: Gcx<'_>, + expr: &Expr<'_>, + bindings: &Bindings, + seen: &mut Vec, +) -> Option { + match &expr.peel_parens().kind { + ExprKind::Ident(_) => { + let var = gcx.resolved_variable(expr)?; + if seen.contains(&var) { + return None; + } + seen.push(var); + let variable = gcx.hir.variable(var); + if !matches!(variable.kind, VarKind::Statement) { + return Some(SetPath { base: var, steps: Vec::new() }); + } + // A local `storage` reference is another name for the set its last binding gave it. + // One declared inside the analyzed loop has no entry and is bound by its initializer + // anew each turn; a tuple-destructured one has neither and may name any set. + match bindings.iter().rev().find(|(bound, _)| *bound == var) { + Some((_, binding)) => binding.clone(), + None => set_path(gcx, variable.initializer?, bindings, seen), + } + } + ExprKind::Member(base, field) => { + let mut path = set_path(gcx, base, bindings, seen)?; + path.steps.push(Step::Field(field.name)); + Some(path) + } + ExprKind::Index(base, Some(index)) => { + let ExprKind::Lit(lit) = &index.peel_parens().kind else { return None }; + let LitKind::Number(key) = &lit.kind else { return None }; + let mut path = set_path(gcx, base, bindings, seen)?; + path.steps.push(Step::Key(*key)); + Some(path) + } + _ => None, + } +} diff --git a/crates/lint/src/sol/high/function_selector_collision.rs b/crates/lint/src/sol/high/function_selector_collision.rs new file mode 100644 index 0000000000000..9837a6e4b1ac5 --- /dev/null +++ b/crates/lint/src/sol/high/function_selector_collision.rs @@ -0,0 +1,773 @@ +use super::FunctionSelectorCollision; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{ + Severity, SolLint, + analysis::{ + arg_for_param, branch_always_exits, expr_is_address, is_address_cast, + is_require_or_assert, ty_contract_id, + }, + }, +}; +use alloy_primitives::Selector; +use solar::{ + ast::{LitKind, UnOpKind}, + interface::{data_structures::Never, kw, sym}, + sema::{ + Gcx, + builtins::Builtin, + hir::{ + self, BinOpKind, ContractId, ContractKind, Expr, ExprKind, LoopSource, Stmt, StmtKind, + VariableId, Visit, + }, + }, +}; +use std::{ + collections::{HashMap, HashSet}, + ops::ControlFlow, +}; + +/// Path-state cap above which selector constraints are widened to "any selector". +const MAX_LOOP_PATH_STATES: usize = 128; + +declare_forge_lint!( + FUNCTION_SELECTOR_COLLISION, + Severity::High, + "function-selector-collision", + "proxy and implementation functions have colliding selectors" +); + +impl<'gcx> LateLintPass<'gcx> for FunctionSelectorCollision { + fn check_nested_contract(&mut self, ctx: &LintContext, gcx: Gcx<'gcx>, proxy_id: ContractId) { + let proxy = gcx.hir.contract(proxy_id); + if proxy.kind != ContractKind::Contract || proxy.linearization_failed() { + return; + } + let Some(fallback_id) = proxy.fallback else { return }; + let fallback = gcx.hir.function(fallback_id); + let Some(body) = fallback.body else { return }; + + let mut collector = DelegateTargetCollector { + gcx, + contract_id: proxy_id, + current_inputs: Vec::new(), + paths: vec![PathState::default()], + placeholder: None, + return_controls: vec![Vec::new()], + continuation_cache: HashMap::new(), + loop_controls: Vec::new(), + targets: Vec::new(), + }; + collector.visit_modifier_chain(Continuation { + modifiers: fallback.modifiers, + index: 0, + body, + body_input: fallback + .parameters + .first() + .map(|&var| CalldataInput { var, modifier: None }), + }); + + let proxy_functions = gcx.interface_functions(proxy_id); + for target in collector.targets { + let implementation = gcx.hir.contract(target.contract); + if target.contract == proxy_id + || implementation.kind == ContractKind::Library + || implementation.linearization_failed() + { + continue; + } + + for proxy_function in proxy_functions.all() { + for implementation_function in gcx.interface_functions(target.contract).all() { + let selector = proxy_function.selector; + if selector != implementation_function.selector + || !target.filters.iter().any(|filter| filter.allows(selector)) + { + continue; + } + let proxy_signature = gcx.item_signature(proxy_function.id.into()); + let implementation_signature = + gcx.item_signature(implementation_function.id.into()); + if proxy_signature == implementation_signature { + continue; + } + let msg = format!( + "proxy function `{}.{proxy_signature}` collides with implementation function `{}.{implementation_signature}` at selector `{selector}`", + proxy.name.as_str(), + implementation.name.as_str(), + ); + ctx.emit_with_msg(&FUNCTION_SELECTOR_COLLISION, proxy.name.span, msg); + } + } + } + } +} + +/// The `msg.sig` constraints known to hold on a path. +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] +struct SelectorFilter { + required: Option, + excluded: Vec, +} + +impl SelectorFilter { + fn allows(&self, selector: Selector) -> bool { + self.required.is_none_or(|required| required == selector) + && !self.excluded.contains(&selector) + } + + /// Narrows the filter by `msg.sig == selector` being `matches`; `None` if contradictory. + fn with_guard(mut self, selector: Selector, matches: bool) -> Option { + if matches { + if self.excluded.contains(&selector) + || self.required.is_some_and(|required| required != selector) + { + return None; + } + self.required = Some(selector); + } else { + if self.required == Some(selector) { + return None; + } + if self.required.is_none() && !self.excluded.contains(&selector) { + self.excluded.push(selector); + self.excluded.sort_unstable(); + } + } + Some(self) + } +} + +struct DelegateTarget { + contract: ContractId, + filters: Vec, +} + +/// A parameter that initially holds the full `msg.data`: the fallback's own, or one of the +/// `modifier`-th applied modifier (the same modifier may be applied several times). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +struct CalldataInput { + var: VariableId, + modifier: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] +struct PathState { + selector_filter: SelectorFilter, + modified_inputs: Vec, +} + +impl PathState { + fn input_unmodified(&self, input: CalldataInput) -> bool { + !self.modified_inputs.contains(&input) + } + + fn mark_input_modified(&mut self, input: CalldataInput) { + if self.input_unmodified(input) { + self.modified_inputs.push(input); + self.modified_inputs.sort_unstable(); + } + } + + fn clear_inputs(&mut self, inputs: &[CalldataInput]) { + self.modified_inputs.retain(|input| !inputs.contains(input)); + } +} + +#[derive(Default)] +struct LoopControl { + breaks: Vec, + continues: Vec, +} + +/// What `_` resumes: the rest of the modifier chain and the function body. +#[derive(Clone, Copy)] +struct Continuation<'gcx> { + modifiers: &'gcx [hir::Modifier<'gcx>], + index: usize, + body: hir::Block<'gcx>, + body_input: Option, +} + +fn lvalue_contains_var(gcx: Gcx<'_>, expr: &Expr<'_>, target: VariableId) -> bool { + match &expr.peel_parens().kind { + ExprKind::Ident(_) => gcx.resolved_variable(expr) == Some(target), + ExprKind::Tuple(exprs) => { + exprs.iter().flatten().any(|expr| lvalue_contains_var(gcx, expr, target)) + } + _ => false, + } +} + +fn extend_unique(items: &mut Vec, new_items: impl IntoIterator) { + for item in new_items { + if !items.contains(&item) { + items.push(item); + } + } +} + +fn dedup(paths: &mut Vec) { + let mut seen = HashSet::with_capacity(paths.len()); + paths.retain(|path| seen.insert(path.clone())); +} + +struct DelegateTargetCollector<'gcx> { + gcx: Gcx<'gcx>, + contract_id: ContractId, + /// Full-calldata inputs visible in the block being visited. + current_inputs: Vec, + /// Live path states; empty means the current point is unreachable. + paths: Vec, + placeholder: Option>, + /// Per function/modifier frame, the states at each `return`. + return_controls: Vec>, + continuation_cache: HashMap<(usize, PathState), Vec>, + loop_controls: Vec, + targets: Vec, +} + +impl<'gcx> DelegateTargetCollector<'gcx> { + fn visit_modifier_chain(&mut self, cont: Continuation<'gcx>) { + let previous_inputs = + std::mem::replace(&mut self.current_inputs, cont.body_input.into_iter().collect()); + if let Some(invocation) = cont.modifiers.get(cont.index) { + for arg in invocation.args.exprs() { + let _ = self.visit_expr(arg); + } + if let Some(modifier_id) = + self.gcx.resolve_modifier_target(self.contract_id, invocation) + && let Some(modifier_body) = self.gcx.hir.function(modifier_id).body + { + let modifier = self.gcx.hir.function(modifier_id); + let params: Vec<_> = modifier + .parameters + .iter() + .map(|&var| CalldataInput { var, modifier: Some(cont.index) }) + .collect(); + // Parameters bound to a full-calldata argument inherit its provenance. + let bindings: Vec<_> = params + .iter() + .filter_map(|¶m| { + let arg = + arg_for_param(self.gcx, modifier_id, param.var, &invocation.args)?; + Some((param, full_calldata_source(self.gcx, arg, &self.current_inputs)?)) + }) + .collect(); + for path in &mut self.paths { + path.clear_inputs(¶ms); + for &(param, source) in &bindings { + if source.is_some_and(|source| !path.input_unmodified(source)) { + path.mark_input_modified(param); + } + } + } + let inputs = bindings.iter().map(|&(input, _)| input).collect(); + let next = Continuation { index: cont.index + 1, ..cont }; + self.visit_block(modifier_body, Some(next), inputs); + for path in &mut self.paths { + path.clear_inputs(¶ms); + } + if let Some(returns) = self.return_controls.last_mut() { + for path in returns { + path.clear_inputs(¶ms); + } + } + } else { + self.visit_modifier_chain(Continuation { index: cont.index + 1, ..cont }); + } + } else { + self.visit_block(cont.body, None, self.current_inputs.clone()); + } + self.current_inputs = previous_inputs; + } + + fn visit_block( + &mut self, + block: hir::Block<'gcx>, + placeholder: Option>, + inputs: Vec, + ) { + let previous = std::mem::replace(&mut self.placeholder, placeholder); + let previous_inputs = std::mem::replace(&mut self.current_inputs, inputs); + for stmt in block.stmts { + let _ = self.visit_stmt(stmt); + } + self.placeholder = previous; + self.current_inputs = previous_inputs; + } + + /// Runs the continuation once per distinct incoming path state, memoizing the outcome. + fn visit_continuation(&mut self, cont: Continuation<'gcx>) { + let mut output_paths = Vec::new(); + for input in std::mem::take(&mut self.paths) { + let key = (cont.index, input); + if let Some(cached) = self.continuation_cache.get(&key) { + extend_unique(&mut output_paths, cached.iter().cloned()); + continue; + } + self.paths.push(key.1.clone()); + self.return_controls.push(Vec::new()); + self.visit_modifier_chain(cont); + let mut result = std::mem::take(&mut self.paths); + let returns = self.return_controls.pop().expect("return control stack is not empty"); + extend_unique(&mut result, returns); + self.continuation_cache.insert(key, result.clone()); + extend_unique(&mut output_paths, result); + } + self.paths = output_paths; + } + + /// Records `contract` as a delegatecall target reachable under the current paths' selector + /// filters (only paths on which `required_input` still holds the full calldata count). + fn record_target(&mut self, contract: ContractId, required_input: Option) { + let mut filters = Vec::new(); + extend_unique( + &mut filters, + self.paths + .iter() + .filter(|path| required_input.is_none_or(|input| path.input_unmodified(input))) + .map(|path| path.selector_filter.clone()), + ); + if filters.is_empty() { + return; + } + let target = match self.targets.iter().position(|target| target.contract == contract) { + Some(index) => &mut self.targets[index], + None => { + self.targets.push(DelegateTarget { contract, filters: Vec::new() }); + self.targets.last_mut().expect("target was just pushed") + } + }; + if target.filters.contains(&SelectorFilter::default()) { + return; + } + extend_unique(&mut target.filters, filters); + if target.filters.len() > MAX_LOOP_PATH_STATES { + target.filters = vec![SelectorFilter::default()]; + } + } + + /// Splits the live paths into those where `expr` is true and those where it is false. + fn visit_condition(&mut self, expr: &'gcx Expr<'gcx>) -> (Vec, Vec) { + match &expr.peel_parens().kind { + ExprKind::Lit(lit) => { + let paths = std::mem::take(&mut self.paths); + match lit.kind { + LitKind::Bool(true) => (paths, Vec::new()), + LitKind::Bool(false) => (Vec::new(), paths), + _ => (paths.clone(), paths), + } + } + ExprKind::Unary(op, inner) if op.kind == UnOpKind::Not => { + let (true_paths, false_paths) = self.visit_condition(inner); + (false_paths, true_paths) + } + ExprKind::Binary(lhs, op, rhs) if matches!(op.kind, BinOpKind::And | BinOpKind::Or) => { + let (lhs_true, lhs_false) = self.visit_condition(lhs); + if op.kind == BinOpKind::And { + self.paths = lhs_true; + let (rhs_true, mut rhs_false) = self.visit_condition(rhs); + extend_unique(&mut rhs_false, lhs_false); + (rhs_true, rhs_false) + } else { + self.paths = lhs_false; + let (mut rhs_true, rhs_false) = self.visit_condition(rhs); + extend_unique(&mut rhs_true, lhs_true); + (rhs_true, rhs_false) + } + } + ExprKind::Ternary(condition, true_expr, false_expr) => { + let (condition_true, condition_false) = self.visit_condition(condition); + self.paths = condition_true; + let (mut true_paths, mut false_paths) = self.visit_condition(true_expr); + self.paths = condition_false; + let (false_arm_true, false_arm_false) = self.visit_condition(false_expr); + extend_unique(&mut true_paths, false_arm_true); + extend_unique(&mut false_paths, false_arm_false); + (true_paths, false_paths) + } + _ => { + let _ = self.visit_expr(expr); + let paths = std::mem::take(&mut self.paths); + let guard = selector_guard(self.gcx, expr); + let branch = |condition_is_true: bool| { + paths + .iter() + .filter_map(|path| { + let mut path = path.clone(); + if let Some((selector, matches)) = guard { + path.selector_filter = path + .selector_filter + .with_guard(selector, matches == condition_is_true)?; + } + Some(path) + }) + .collect() + }; + (branch(true), branch(false)) + } + } + } + + /// Visits loop-body statements: `break` paths are added to `exits`, and the paths reaching + /// the next iteration (fall-through and `continue`) are returned. + fn visit_loop_stmts( + &mut self, + stmts: &'gcx [Stmt<'gcx>], + exits: &mut Vec, + ) -> Vec { + self.loop_controls.push(LoopControl::default()); + for stmt in stmts { + let _ = self.visit_stmt(stmt); + } + let mut next = std::mem::take(&mut self.paths); + let control = self.loop_controls.pop().expect("loop control stack is not empty"); + extend_unique(exits, control.breaks); + extend_unique(&mut next, control.continues); + next + } + + /// Collapses `paths` to one unconstrained state keeping only the inputs modified on all of + /// them. + fn widen_loop_paths(paths: &mut Vec) { + let Some(first) = paths.first() else { return }; + let mut modified_inputs = first.modified_inputs.clone(); + modified_inputs + .retain(|input| paths.iter().skip(1).all(|path| path.modified_inputs.contains(input))); + *paths = vec![PathState { selector_filter: SelectorFilter::default(), modified_inputs }]; + } + + /// One iteration of a `for` loop with an update statement: `if (cond) { body } else break` + /// followed by the update, which `continue` also reaches. Returns the back-edge paths and the + /// loop-exit paths. + fn visit_for_iteration( + &mut self, + block: &hir::Block<'gcx>, + update: &'gcx Stmt<'gcx>, + ) -> Option<(Vec, Vec)> { + let [stmt] = block.stmts else { return None }; + let (condition, body, else_stmt) = match &stmt.kind { + StmtKind::If(condition, then_stmt, else_stmt) => { + (Some(*condition), *then_stmt, *else_stmt) + } + _ => (None, stmt, None), + }; + + let mut exits = Vec::new(); + if let Some(condition) = condition { + let (true_paths, false_paths) = self.visit_condition(condition); + self.paths = false_paths; + if let Some(else_stmt) = else_stmt { + let fallthrough = + self.visit_loop_stmts(std::slice::from_ref(else_stmt), &mut exits); + extend_unique(&mut exits, fallthrough); + } else { + extend_unique(&mut exits, std::mem::take(&mut self.paths)); + } + self.paths = true_paths; + } + + self.paths = self.visit_loop_stmts(std::slice::from_ref(body), &mut exits); + let _ = self.visit_stmt(update); + Some((std::mem::take(&mut self.paths), exits)) + } + + /// Iterates the loop body to a fixpoint over the set of distinct entry states. + fn visit_loop(&mut self, block: &hir::Block<'gcx>, source: LoopSource<'gcx>) { + let mut pending = std::mem::take(&mut self.paths); + let mut seen = HashSet::new(); + let mut exits = Vec::new(); + + loop { + pending.retain(|path| seen.insert(path.clone())); + if pending.is_empty() { + break; + } + + self.paths = std::mem::take(&mut pending); + let next = if let LoopSource::For { update: Some(update) } = source + && let Some((next, for_exits)) = self.visit_for_iteration(block, update) + { + extend_unique(&mut exits, for_exits); + next + } else if matches!(source, LoopSource::DoWhile) + && let Some((condition, body)) = block.stmts.split_last() + { + // `continue` in a do-while body still evaluates the condition. + self.paths = self.visit_loop_stmts(body, &mut exits); + self.visit_loop_stmts(std::slice::from_ref(condition), &mut exits) + } else { + self.visit_loop_stmts(block.stmts, &mut exits) + }; + extend_unique(&mut pending, next); + if seen.len() + pending.len() > MAX_LOOP_PATH_STATES { + Self::widen_loop_paths(&mut pending); + } + if exits.len() > MAX_LOOP_PATH_STATES { + Self::widen_loop_paths(&mut exits); + } + } + + self.paths = exits; + } + + /// Visits two alternative branches from the given entry paths and joins their outcomes. + fn visit_branches( + &mut self, + true_paths: Vec, + true_branch: impl FnOnce(&mut Self), + false_paths: Vec, + false_branch: impl FnOnce(&mut Self), + ) { + self.paths = true_paths; + true_branch(self); + let mut joined = std::mem::take(&mut self.paths); + self.paths = false_paths; + false_branch(self); + joined.append(&mut self.paths); + self.paths = joined; + dedup(&mut self.paths); + } +} + +impl<'gcx> Visit<'gcx> for DelegateTargetCollector<'gcx> { + type BreakValue = Never; + + fn hir(&self) -> &'gcx hir::Hir<'gcx> { + &self.gcx.hir + } + + fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow { + if self.paths.is_empty() { + return ControlFlow::Continue(()); + } + match &expr.kind { + ExprKind::Ternary(condition, true_expr, false_expr) => { + let (true_paths, false_paths) = self.visit_condition(condition); + self.visit_branches( + true_paths, + |this| _ = this.visit_expr(true_expr), + false_paths, + |this| _ = this.visit_expr(false_expr), + ); + } + ExprKind::Binary(_, op, _) if matches!(op.kind, BinOpKind::And | BinOpKind::Or) => { + let (mut true_paths, false_paths) = self.visit_condition(expr); + extend_unique(&mut true_paths, false_paths); + self.paths = true_paths; + dedup(&mut self.paths); + } + ExprKind::Call(callee, args) => { + let (callee, opts) = callee.split_call_options(); + let _ = self.visit_expr(callee); + for arg in opts.iter().flat_map(|opts| opts.args) { + let _ = self.visit_expr(&arg.value); + } + let mut args = args.exprs(); + if is_require_or_assert(self.gcx, callee) { + let Some(condition) = args.next() else { return ControlFlow::Continue(()) }; + let args: Vec<_> = args.collect(); + let (true_paths, false_paths) = self.visit_condition(condition); + // Remaining arguments are evaluated before `require`/`assert` decides + // whether to revert, so their targets and side effects apply on both paths; + // only the passing paths continue. + self.paths = true_paths; + for &arg in &args { + let _ = self.visit_expr(arg); + } + let continuing_paths = std::mem::take(&mut self.paths); + self.paths = false_paths; + for arg in args { + let _ = self.visit_expr(arg); + } + self.paths = continuing_paths; + } else { + for arg in args { + let _ = self.visit_expr(arg); + } + if let Some((target, required_input)) = + delegated_contract(self.gcx, &self.current_inputs, expr) + { + self.record_target(target, required_input); + } + } + } + _ => { + let mutated_inputs: Vec<_> = match &expr.peel_parens().kind { + ExprKind::Assign(lhs, _, _) => self + .current_inputs + .iter() + .copied() + .filter(|input| lvalue_contains_var(self.gcx, lhs, input.var)) + .collect(), + _ => Vec::new(), + }; + let _ = self.walk_expr(expr); + for path in &mut self.paths { + for &input in &mutated_inputs { + path.mark_input_modified(input); + } + } + } + } + ControlFlow::Continue(()) + } + + fn visit_stmt(&mut self, stmt: &'gcx Stmt<'gcx>) -> ControlFlow { + if self.paths.is_empty() { + return ControlFlow::Continue(()); + } + match &stmt.kind { + StmtKind::If(condition, then_stmt, else_stmt) => { + let (true_paths, false_paths) = self.visit_condition(condition); + self.visit_branches( + true_paths, + |this| _ = this.visit_stmt(then_stmt), + false_paths, + |this| { + if let Some(else_stmt) = else_stmt { + let _ = this.visit_stmt(else_stmt); + } + }, + ); + } + StmtKind::Try(try_) => { + let _ = self.visit_expr(&try_.expr); + let paths = std::mem::take(&mut self.paths); + let mut joined = Vec::new(); + for clause in try_.clauses { + self.paths = paths.clone(); + for &var in clause.args { + let _ = self.visit_nested_var(var); + } + for stmt in clause.block.stmts { + let _ = self.visit_stmt(stmt); + } + joined.append(&mut self.paths); + } + self.paths = joined; + dedup(&mut self.paths); + } + StmtKind::Loop(block, source) => self.visit_loop(block, *source), + StmtKind::Break | StmtKind::Continue => { + let paths = std::mem::take(&mut self.paths); + if let Some(control) = self.loop_controls.last_mut() { + let destination = if matches!(stmt.kind, StmtKind::Break) { + &mut control.breaks + } else { + &mut control.continues + }; + extend_unique(destination, paths); + } + } + StmtKind::Placeholder => { + if let Some(cont) = self.placeholder { + self.visit_continuation(cont); + } + } + StmtKind::Return(expr) => { + if let Some(expr) = expr { + let _ = self.visit_expr(expr); + } + let paths = std::mem::take(&mut self.paths); + let returns = + self.return_controls.last_mut().expect("return control stack is not empty"); + extend_unique(returns, paths); + } + StmtKind::AssemblyBlock(_) => { + // Inline assembly may rewrite any calldata parameter in scope. + for path in &mut self.paths { + for &input in &self.current_inputs { + path.mark_input_modified(input); + } + } + } + _ => { + let _ = self.walk_stmt(stmt); + if branch_always_exits(self.gcx, stmt) { + self.paths.clear(); + } + } + } + ControlFlow::Continue(()) + } +} + +/// `msg.sig == F.selector` / `msg.sig != F.selector` as `(selector, matches)`. +fn selector_guard(gcx: Gcx<'_>, expr: &Expr<'_>) -> Option<(Selector, bool)> { + let ExprKind::Binary(lhs, op, rhs) = &expr.peel_parens().kind else { return None }; + let matches = match op.kind { + BinOpKind::Eq => true, + BinOpKind::Ne => false, + _ => return None, + }; + let selector = if gcx.resolved_builtin(lhs) == Some(Builtin::MsgSig) { + rhs + } else if gcx.resolved_builtin(rhs) == Some(Builtin::MsgSig) { + lhs + } else { + return None; + }; + let selector = selector.peel_parens(); + let ExprKind::Member(function, member) = &selector.kind else { return None }; + if member.name != sym::selector + || gcx.resolved_builtin(selector) != Some(Builtin::FunctionSelector) + { + return None; + } + let function = gcx.resolved_function(function)?; + Some((gcx.function_selector(function), matches)) +} + +/// The statically typed implementation contract of a proxy-style `.delegatecall()`, with the calldata input that must be unmodified for the forwarding to be complete. +fn delegated_contract<'gcx>( + gcx: Gcx<'gcx>, + full_calldata_inputs: &[CalldataInput], + expr: &'gcx Expr<'gcx>, +) -> Option<(ContractId, Option)> { + let (callee, args, _) = expr.peel_parens().as_call()?; + let ExprKind::Member(receiver, member) = &callee.kind else { return None }; + let required_input = full_calldata_source(gcx, args.exprs().next()?, full_calldata_inputs)?; + if member.name != kw::Delegatecall + || gcx.resolved_builtin(callee) != Some(Builtin::AddressDelegatecall) + || !expr_is_address(gcx, receiver) + { + return None; + } + typed_contract_behind_address_cast(gcx, receiver).map(|contract| (contract, required_input)) +} + +fn typed_contract_behind_address_cast<'gcx>( + gcx: Gcx<'gcx>, + expr: &'gcx Expr<'gcx>, +) -> Option { + let expr = expr.peel_parens(); + if let Some(id) = gcx.type_of_expr(expr.id).and_then(ty_contract_id) { + return Some(id); + } + match &expr.kind { + ExprKind::Call(callee, args) if is_address_cast(callee) => { + args.exprs().next().and_then(|arg| typed_contract_behind_address_cast(gcx, arg)) + } + ExprKind::Payable(inner) => typed_contract_behind_address_cast(gcx, inner), + _ => None, + } +} + +/// `Some(None)` for `msg.data`, `Some(Some(input))` for a known full-calldata input, `None` +/// otherwise. +fn full_calldata_source( + gcx: Gcx<'_>, + expr: &Expr<'_>, + full_calldata_inputs: &[CalldataInput], +) -> Option> { + if gcx.resolved_builtin(expr) == Some(Builtin::MsgData) { + return Some(None); + } + let ExprKind::Ident(_) = &expr.peel_parens().kind else { return None }; + let id = gcx.resolved_variable(expr)?; + full_calldata_inputs.iter().copied().find(|input| input.var == id).map(Some) +} diff --git a/crates/lint/src/sol/high/incorrect_exp.rs b/crates/lint/src/sol/high/incorrect_exp.rs new file mode 100644 index 0000000000000..427159809fba6 --- /dev/null +++ b/crates/lint/src/sol/high/incorrect_exp.rs @@ -0,0 +1,80 @@ +use super::IncorrectExp; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{Severity, SolLint}, +}; +use alloy_primitives::U256; +use solar::{ + ast::{BinOpKind, LitKind}, + sema::{ + Gcx, + hir::{self, ElementaryType, Expr, ExprKind, Lit, TypeKind}, + }, +}; + +declare_forge_lint!( + INCORRECT_EXP, + Severity::High, + "incorrect-exp", + "`^` is bitwise xor, not exponentiation; use `**`" +); + +impl<'gcx> LateLintPass<'gcx> for IncorrectExp { + fn check_expr(&mut self, ctx: &LintContext, _gcx: Gcx<'gcx>, expr: &'gcx hir::Expr<'gcx>) { + // `a ^ b` between integer literals is almost always a mistake for `a ** b`: `^` is bitwise + // xor in Solidity, so `10 ^ 18` is `24`, not `10 ** 18`. + // + // To stay precise, the base is restricted to `2` and `10` (bit widths and decimals, the + // only bases people write as powers) and hex operands are left alone. This mirrors GCC's + // and Clang's `-Wxor-used-as-pow`; Clippy's `suspicious_xor_used_as_pow`, which drops the + // base restriction, is allow-by-default precisely because of the resulting false positives. + if let ExprKind::Binary(lhs, op, rhs) = &expr.kind + && op.kind == BinOpKind::BitXor + && let Some(base) = plain_decimal_int_lit(ctx, lhs) + && (base == U256::from(2u64) || base == U256::from(10u64)) + && plain_decimal_int_lit(ctx, rhs).is_some() + { + ctx.emit(&INCORRECT_EXP, expr.span); + } + } +} + +/// Returns the value of a plain decimal integer literal, looking through parentheses and integer +/// casts (`uint256(10)`), or `None` for anything else. +/// +/// Only literals written as plain decimal digits (`10`, `1_000`) qualify. Hex literals (`0x..`) are +/// bitwise intent, and scientific notation (`1e1`, which solar evaluates to `10`) is not the plain +/// integer literal the `^`/`**` typo involves. A sub-denomination (`2 wei`, `2 seconds`) is dropped +/// from the HIR but still present in the source span, so requiring the span to be exactly the +/// digits filters it out too. All of these are left alone: this lint prefers a false negative to +/// a false positive that would annoy developers. +fn plain_decimal_int_lit(ctx: &LintContext, expr: &Expr<'_>) -> Option { + let expr = peel_int_casts(expr); + let ExprKind::Lit(Lit { kind: LitKind::Number(value), symbol, .. }) = &expr.kind else { + return None; + }; + let s = symbol.as_str(); + (!s.is_empty() + && s.bytes().all(|b| b.is_ascii_digit() || b == b'_') + && ctx.span_to_snippet(expr.span).is_some_and(|src| src.trim() == s)) + .then_some(*value) +} + +/// Looks through parentheses and integer casts (`uint256(x)`, `int8(x)`), returning the innermost +/// operand. A misplaced `^` can hide behind such a cast (`uint256(10) ^ 18`), which a bare literal +/// check would miss. Non-integer casts (`bytes32(x)`, `address(x)`) are left alone, since xor of a +/// `bytesN` bit pattern is a legitimate operation. +fn peel_int_casts<'a, 'gcx>(expr: &'a Expr<'gcx>) -> &'a Expr<'gcx> { + let expr = expr.peel_parens(); + if let ExprKind::Call(callee, args) = &expr.kind + && let ExprKind::Type(hir::Type { + kind: TypeKind::Elementary(ElementaryType::Int(_) | ElementaryType::UInt(_)), + .. + }) = &callee.peel_parens().kind + && args.len() == 1 + && let Some(inner) = args.exprs().next() + { + return peel_int_casts(inner); + } + expr +} diff --git a/crates/lint/src/sol/high/incorrect_shift.rs b/crates/lint/src/sol/high/incorrect_shift.rs index dbc8327138a98..0a3ea9155aacc 100644 --- a/crates/lint/src/sol/high/incorrect_shift.rs +++ b/crates/lint/src/sol/high/incorrect_shift.rs @@ -3,7 +3,13 @@ use crate::{ linter::{EarlyLintPass, LintContext}, sol::{Severity, SolLint}, }; -use solar_ast::{BinOp, BinOpKind, Expr, ExprKind}; +use alloy_primitives::U256; +use solar::{ + ast::{LitKind, Stmt, StmtKind, visit::Visit, yul}, + data_structures::Never, + interface::kw, +}; +use std::ops::ControlFlow; declare_forge_lint!( INCORRECT_SHIFT, @@ -13,28 +19,33 @@ declare_forge_lint!( ); impl<'ast> EarlyLintPass<'ast> for IncorrectShift { - fn check_expr(&mut self, ctx: &LintContext<'_>, expr: &'ast Expr<'ast>) { - if let ExprKind::Binary( - left_expr, - BinOp { kind: BinOpKind::Shl | BinOpKind::Shr, .. }, - right_expr, - ) = &expr.kind - && contains_incorrect_shift(left_expr, right_expr) - { - ctx.emit(&INCORRECT_SHIFT, expr.span); + fn check_stmt(&mut self, ctx: &LintContext, stmt: &'ast Stmt<'ast>) { + if let StmtKind::Assembly(assembly) = &stmt.kind { + let _ = ShiftChecker { ctx }.visit_yul_block(&assembly.block); } } } -// TODO: come up with a better heuristic. Treat initial impl as a PoC. -// Checks if the left operand is a literal and the right operand is not, indicating a potential -// reversed shift operation. -fn contains_incorrect_shift<'ast>( - left_expr: &'ast Expr<'ast>, - right_expr: &'ast Expr<'ast>, -) -> bool { - let is_left_literal = matches!(left_expr.kind, ExprKind::Lit(..)); - let is_right_not_literal = !matches!(right_expr.kind, ExprKind::Lit(..)); +struct ShiftChecker<'a, 's> { + ctx: &'a LintContext<'s, 'a>, +} + +impl<'ast> Visit<'ast> for ShiftChecker<'_, '_> { + type BreakValue = Never; - is_left_literal && is_right_not_literal + fn visit_yul_expr(&mut self, expr: &'ast yul::Expr<'ast>) -> ControlFlow { + // A computed shift of a literal suggests swapped arguments, except `shl(n, 1)`, + // which constructs a single-bit mask. + if let yul::ExprKind::Call(call) = &expr.kind + && matches!(call.name.name, kw::Shl | kw::Shr | kw::Sar) + && let [left, right] = call.arguments.as_ref() + && !matches!(left.kind, yul::ExprKind::Lit(_)) + && let yul::ExprKind::Lit(lit) = &right.kind + && !(call.name.name == kw::Shl + && matches!(lit.kind, LitKind::Number(value) if value == U256::ONE)) + { + self.ctx.emit(&INCORRECT_SHIFT, expr.span); + } + self.walk_yul_expr(expr) + } } diff --git a/crates/lint/src/sol/high/mod.rs b/crates/lint/src/sol/high/mod.rs index b27907dd1e4a9..6e3e95a6bf938 100644 --- a/crates/lint/src/sol/high/mod.rs +++ b/crates/lint/src/sol/high/mod.rs @@ -1,13 +1,34 @@ -use crate::sol::{EarlyLintPass, LateLintPass, SolLint}; +use crate::sol::SolLint; +mod arbitrary_send_erc20; +mod arbitrary_send_eth; +mod controlled_delegatecall; +mod encode_packed_collision; +mod enumerable_loop_removal; +mod function_selector_collision; +mod incorrect_exp; mod incorrect_shift; +mod protected_vars; +mod reentrancy; +mod rtlo; mod unchecked_calls; - -use incorrect_shift::INCORRECT_SHIFT; -use unchecked_calls::{ERC20_UNCHECKED_TRANSFER, UNCHECKED_CALL}; +mod unprotected_initializer; register_lints!( - (IncorrectShift, early, (INCORRECT_SHIFT)), - (UncheckedCall, early, (UNCHECKED_CALL)), - (UncheckedTransferERC20, early, (ERC20_UNCHECKED_TRANSFER)) + arbitrary_send_erc20: + (ArbitrarySendErc20, late, (ARBITRARY_SEND_ERC20, ARBITRARY_SEND_ERC20_PERMIT)); + arbitrary_send_eth: (ArbitrarySendEth, late, (ARBITRARY_SEND_ETH)); + controlled_delegatecall: (ControlledDelegatecall, late, (CONTROLLED_DELEGATECALL)); + encode_packed_collision: (EncodedPackedCollision, late, (ENCODE_PACKED_COLLISION)); + enumerable_loop_removal: (EnumerableLoopRemoval, late, (ENUMERABLE_LOOP_REMOVAL)); + function_selector_collision: (FunctionSelectorCollision, late, (FUNCTION_SELECTOR_COLLISION)); + incorrect_exp: (IncorrectExp, late, (INCORRECT_EXP)); + incorrect_shift: (IncorrectShift, early, (INCORRECT_SHIFT)); + protected_vars: (ProtectedVars, late, (PROTECTED_VARS)); + reentrancy: (ReentrancyEth, late, (REENTRANCY_BALANCE, REENTRANCY_ETH, REENTRANCY_NO_ETH)); + unchecked_calls: + (UncheckedCall, early, (UNCHECKED_CALL)), + (UncheckedTransferERC20, late, (ERC20_UNCHECKED_TRANSFER)); + unprotected_initializer: (UnprotectedInitializer, late, (UNPROTECTED_INITIALIZER)); + rtlo: (Rtlo, early, (RTLO)); ); diff --git a/crates/lint/src/sol/high/protected_vars.rs b/crates/lint/src/sol/high/protected_vars.rs new file mode 100644 index 0000000000000..a5724de91166d --- /dev/null +++ b/crates/lint/src/sol/high/protected_vars.rs @@ -0,0 +1,1189 @@ +//! Slither-compatible protected-variable control-flow analysis. +//! +//! Storage references are tracked as may-alias sets across internal calls and control-flow joins. +//! Calls are memoized by their storage, slot, and guard context so recursive propagation +//! terminates. + +use super::ProtectedVars; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{ + Severity, SolLint, + analysis::{ + branch_always_exits, is_builtin, is_loop_termination_if, lhs_local_var, loop_update, + runtime_entry_points, + }, + }, +}; +use solar::{ + ast::{BinOpKind, ContractKind, DataLocation, FunctionKind}, + interface::sym, + sema::{ + Gcx, + builtins::Builtin, + hir::{ + self, CallArgs, ContractId, ExprId, ExprKind, FunctionId, ItemId, LoopSource, + NatSpecKind, Res, StmtKind, VariableId, + }, + ty::{CallableParamSource, Ty, TyAbiPrinter, TyAbiPrinterMode, TyKind}, + }, +}; +use std::collections::{HashMap, HashSet}; + +type StorageRoots = HashSet; +type RootMap = HashMap; + +declare_forge_lint!( + PROTECTED_VARS, + Severity::High, + "protected-vars", + "protected variable is written without its required protection" +); + +impl<'gcx> LateLintPass<'gcx> for ProtectedVars { + fn check_nested_contract( + &mut self, + ctx: &LintContext, + gcx: Gcx<'gcx>, + contract_id: ContractId, + ) { + let contract = gcx.hir.contract(contract_id); + if !matches!(contract.kind, ContractKind::Contract | ContractKind::AbstractContract) + || contract.linearization_failed() + || !is_most_derived_contract(&gcx.hir, contract_id) + { + return; + } + let bases = contract.linearized_bases; + + let protected = protected_variables(gcx, bases); + if protected.is_empty() { + return; + } + let targets = protection_targets(gcx, bases); + + // The effective runtime dispatch surface: most-derived overrides plus the inherited + // fallback/receive functions. + let entries = runtime_entry_points(gcx, contract_id); + + for entry_id in entries { + let entry = gcx.hir.function(entry_id); + let span = entry.name.map_or(entry.keyword_span(), |name| name.span); + let context = if entry.contract == Some(contract_id) { + String::new() + } else { + format!(" in most-derived contract `{}`", contract.name) + }; + let mut writes: Vec<_> = analyze_entry(gcx, bases, entry_id).into_iter().collect(); + writes.sort_unstable_by_key(|(var_id, _)| *var_id); + for (var_id, guards) in writes { + let Some(requirements) = protected.get(&var_id) else { continue }; + let variable = gcx + .hir + .variable(var_id) + .name + .map_or("".to_string(), |n| n.to_string()); + for requirement in requirements { + let msg = match requirement { + Some(signature) => { + if targets.get(signature).is_some_and(|target| guards.contains(target)) + { + continue; + } + format!( + "protected variable `{variable}` is written without `{signature}`{context}" + ) + } + None => format!( + "protected variable `{variable}` has a malformed write-protection annotation{context}" + ), + }; + ctx.emit_with_msg(&PROTECTED_VARS, span, msg); + } + } + } + } +} + +/// Slither analyzes the effective entry points of leaf contracts so inherited declarations are +/// interpreted in the context in which they are ultimately deployed. +fn is_most_derived_contract(hir: &hir::Hir<'_>, contract_id: ContractId) -> bool { + !hir.contract_ids().any(|candidate_id| { + candidate_id != contract_id + && hir.contract(candidate_id).linearized_bases[1..].contains(&contract_id) + }) +} + +/// Protected state variables with their `@custom:security write-protection=""` requirements; +/// `None` marks a malformed annotation. +fn protected_variables( + gcx: Gcx<'_>, + bases: &[ContractId], +) -> HashMap>> { + let mut protected = HashMap::new(); + for var_id in bases.iter().flat_map(|&cid| gcx.hir.contract(cid).variables()) { + let var = gcx.hir.variable(var_id); + if !var.kind.is_state() { + continue; + } + let mut requirements = Vec::new(); + for item in gcx.natspec_doc_comments(var.doc) { + let NatSpecKind::Custom { name } = item.kind else { continue }; + let content = item.content(); + let Some(index) = write_protection_token(content) else { continue }; + if name.as_str() != "security" { + continue; + } + let requirement = content[index + "write-protection".len()..] + .strip_prefix("=\"") + .and_then(|value| value.split_once('"')) + .map(|(signature, _)| signature) + .filter(|signature| !signature.is_empty()) + .map(str::to_owned); + if !requirements.contains(&requirement) { + requirements.push(requirement); + } + } + if !requirements.is_empty() { + protected.insert(var_id, requirements); + } + } + protected +} + +/// Byte offset of a standalone `write-protection` token in `content`. +fn write_protection_token(content: &str) -> Option { + let is_token_char = |c: char| c.is_alphanumeric() || matches!(c, '_' | '-'); + content.match_indices("write-protection").find_map(|(index, token)| { + let before = content[..index].chars().next_back(); + let after = content[index + token.len()..].chars().next(); + (!before.is_some_and(is_token_char) && !after.is_some_and(is_token_char)).then_some(index) + }) +} + +/// Guard functions and modifiers by Slither signature. Functions take precedence over modifiers; +/// within a kind, linearization order keeps the most-derived declaration and drops shadowed ones. +fn protection_targets(gcx: Gcx<'_>, bases: &[ContractId]) -> HashMap { + let mut targets = HashMap::new(); + for kind in [FunctionKind::Function, FunctionKind::Modifier] { + for fid in bases.iter().flat_map(|&cid| gcx.hir.contract(cid).functions()) { + let function = gcx.hir.function(fid); + if function.kind == kind && function.name.is_some() { + targets.entry(callable_signature(gcx, fid)).or_insert(fid); + } + } + } + targets +} + +fn callable_signature(gcx: Gcx<'_>, function_id: FunctionId) -> String { + let function = gcx.hir.function(function_id); + let params = function.parameters.iter().map(|¶meter| { + let ty = gcx.type_of_item(parameter.into()); + if function.kind == FunctionKind::Modifier { + source_type_signature(gcx, ty) + } else { + slither_function_parameter(gcx, ty, &mut HashSet::new()) + } + }); + format!("{}({})", function.name.unwrap().as_str(), params.collect::>().join(",")) +} + +/// Formats the source-level types used by Slither modifier signatures. +fn source_type_signature<'gcx>(gcx: Gcx<'gcx>, ty: Ty<'gcx>) -> String { + let mut signature = ty.display(gcx).to_string(); + for prefix in ["contract ", "struct ", "enum "] { + signature = signature.replace(prefix, ""); + } + for suffix in + [" storage", " memory", " calldata", " external", " internal", " pure", " view", " payable"] + { + signature = signature.replace(suffix, ""); + } + signature.replace("function ", "function").replace("returns ", "returns") +} + +/// Formats the Solidity-signature types used by Slither function lookup. +fn slither_function_parameter<'gcx>( + gcx: Gcx<'gcx>, + ty: Ty<'gcx>, + seen_structs: &mut HashSet, +) -> String { + match ty.kind { + TyKind::Fn(_) | TyKind::Mapping(..) => source_type_signature(gcx, ty), + TyKind::Ref(inner, _) => slither_function_parameter(gcx, inner, seen_structs), + TyKind::DynArray(inner) => { + format!("{}[]", slither_function_parameter(gcx, inner, seen_structs)) + } + TyKind::Array(inner, length) => { + format!("{}[{length}]", slither_function_parameter(gcx, inner, seen_structs)) + } + TyKind::Struct(struct_id) => { + if !seen_structs.insert(struct_id) { + return source_type_signature(gcx, ty); + } + let fields = gcx + .struct_field_types(struct_id) + .iter() + .map(|&field| slither_function_parameter(gcx, field, seen_structs)) + .collect::>(); + format!("({})", fields.join(",")) + } + _ => { + let mut signature = String::new(); + TyAbiPrinter::new(gcx, &mut signature, TyAbiPrinterMode::Signature) + .print(ty) + .expect("writing to a String cannot fail"); + signature + } + } +} + +#[derive(Clone, Default, PartialEq, Eq)] +struct AliasState { + /// Storage pointer locals to the state variables they may alias. + storage: RootMap, + /// Yul locals holding a `.slot` to the state variables they may denote. + slots: RootMap, +} + +#[derive(Clone, Default, PartialEq, Eq)] +struct FlowState { + aliases: AliasState, + /// Guards that have run on every path reaching this point. + guards: HashSet, +} + +impl FlowState { + fn merge(&self, other: &Self) -> Self { + fn merge_roots(lhs: &RootMap, rhs: &RootMap) -> RootMap { + let mut merged = lhs.clone(); + for (&var_id, roots) in rhs { + merged.entry(var_id).or_default().extend(roots); + } + merged + } + Self { + aliases: AliasState { + storage: merge_roots(&self.aliases.storage, &other.aliases.storage), + slots: merge_roots(&self.aliases.slots, &other.aliases.slots), + }, + guards: self.guards.intersection(&other.guards).copied().collect(), + } + } +} + +/// Joins `state` into the accumulated state of the paths that reach a point. +fn join(destination: &mut Option, state: FlowState) { + *destination = Some(match destination.take() { + Some(current) => current.merge(&state), + None => state, + }); +} + +/// A finite call-graph key that distinguishes storage aliases without depending on values. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +struct CallContext { + function_id: FunctionId, + storage: Vec<(VariableId, Vec)>, + slots: Vec<(VariableId, Vec)>, + guards: Vec, +} + +impl CallContext { + fn new( + function_id: FunctionId, + function: &hir::Function<'_>, + aliases: &AliasState, + guards: &HashSet, + ) -> Self { + let roots = |aliases: &RootMap| { + function + .parameters + .iter() + .filter_map(|¶meter| { + let mut roots: Vec<_> = aliases.get(¶meter)?.iter().copied().collect(); + roots.sort_unstable(); + Some((parameter, roots)) + }) + .collect() + }; + let mut guards: Vec<_> = guards.iter().copied().collect(); + guards.sort_unstable(); + Self { function_id, storage: roots(&aliases.storage), slots: roots(&aliases.slots), guards } + } +} + +#[derive(Clone, PartialEq, Eq)] +struct CallSummary { + /// Storage roots that may be returned in each return slot. + returns: Vec, + /// Guards that hold after the call. + guards: HashSet, + /// Whether the call can complete normally. + completes: bool, +} + +/// States collected at `break`/`continue` statements of the innermost loop. +#[derive(Default)] +struct LoopFlow { + breaks: Option, + continues: Option, +} + +/// What `_` resumes: the rest of the modifier chain and the function body. +#[derive(Clone, Copy)] +struct ModifierContinuation<'gcx> { + modifiers: &'gcx [hir::Modifier<'gcx>], + next: usize, + body: hir::Block<'gcx>, +} + +/// Runs the entry to a fixpoint over the memoized call summaries and returns, per written state +/// variable, the guards that held on every path to some write. +fn analyze_entry<'gcx>( + gcx: Gcx<'gcx>, + bases: &'gcx [ContractId], + entry_id: FunctionId, +) -> HashMap> { + let mut call_summaries = HashMap::new(); + let mut previous_writes = HashMap::new(); + loop { + let mut analyzer = EntryAnalyzer { + gcx, + bases, + writes: HashMap::new(), + aliases: AliasState::default(), + guards: HashSet::new(), + call_returns: HashMap::new(), + call_summaries: call_summaries.clone(), + seen_calls: HashSet::new(), + evaluated_calls: HashSet::new(), + stack: Vec::new(), + return_stack: Vec::new(), + return_flow: Vec::new(), + loop_flow: Vec::new(), + modifier_continuations: Vec::new(), + assembly_depth: 0, + }; + analyzer.analyze_function(entry_id); + if analyzer.writes == previous_writes && analyzer.call_summaries == call_summaries { + return analyzer.writes; + } + previous_writes = analyzer.writes; + call_summaries = analyzer.call_summaries; + } +} + +struct EntryAnalyzer<'gcx> { + gcx: Gcx<'gcx>, + bases: &'gcx [ContractId], + /// Written state variables to the guards that held at every write. + writes: HashMap>, + aliases: AliasState, + guards: HashSet, + /// Storage roots returned by each analyzed call expression. + call_returns: HashMap>, + call_summaries: HashMap, + /// Contexts currently being analyzed (recursion detection). + seen_calls: HashSet, + /// Contexts already analyzed in this pass. + evaluated_calls: HashSet, + stack: Vec, + /// Per active function, the storage roots flowing into each return slot. + return_stack: Vec>, + /// Per active function, the joined state at its `return` statements. + return_flow: Vec>, + loop_flow: Vec, + modifier_continuations: Vec>, + assembly_depth: usize, +} + +impl<'gcx> EntryAnalyzer<'gcx> { + fn analyze_function(&mut self, function_id: FunctionId) -> CallSummary { + let function = self.gcx.hir.function(function_id); + let empty_returns = || function.returns.iter().map(|_| StorageRoots::new()).collect(); + let Some(body) = function.body else { + return CallSummary { + returns: empty_returns(), + guards: self.guards.clone(), + completes: true, + }; + }; + self.stack.push(function_id); + self.return_stack.push(empty_returns()); + self.return_flow.push(None); + let completes = self.analyze_modifier_chain(function.modifiers, 0, body); + if completes && !body.stmts.iter().any(|expr| branch_always_exits(self.gcx, expr)) { + self.capture_named_returns(); + } + let returned = self.return_flow.pop().expect("return flow frame").is_some(); + let returns = self.return_stack.pop().expect("return frame"); + self.stack.pop(); + CallSummary { returns, guards: self.guards.clone(), completes: completes || returned } + } + + /// Analyzes the modifier at `index` (or the body once the chain is exhausted). Returns whether + /// control can complete normally. + fn analyze_modifier_chain( + &mut self, + modifiers: &'gcx [hir::Modifier<'gcx>], + index: usize, + body: hir::Block<'gcx>, + ) -> bool { + let Some(modifier) = modifiers.get(index) else { + let previous_returns = self.return_flow.last_mut().and_then(Option::take); + let falls_through = self.analyze_block(body); + let mut completions = self.return_flow.last_mut().and_then(Option::take); + + // Returns from the function body resume in each enclosing modifier postlude. They + // therefore become ordinary placeholder completions here and must not also escape + // the whole modifier chain through `return_flow`: a reverting postlude can still + // prevent the call from completing. Keep only returns captured in modifier prefixes + // outside the body continuation. + *self.return_flow.last_mut().expect("return flow frame") = previous_returns; + + if falls_through { + join(&mut completions, self.flow_state()); + } + let Some(state) = completions else { return false }; + self.set_flow_state(state); + return true; + }; + if !modifier.args.exprs().all(|argument| self.analyze_expr(argument)) { + return false; + } + + let Some(declared_id) = modifier.id.as_function() else { return false }; + let Some(modifier_id) = self.gcx.resolve_modifier_target(self.bases[0], modifier) else { + return false; + }; + self.guards.insert(modifier_id); + let arguments = self.ordered_call_arguments(declared_id, modifier.args, None); + let bound = self.argument_aliases(modifier_id, &arguments); + for parameter in self.gcx.hir.function(modifier_id).parameters { + self.aliases.storage.remove(parameter); + } + self.aliases.storage.extend(bound.storage); + + let Some(modifier_body) = self.gcx.hir.function(modifier_id).body else { return false }; + self.modifier_continuations.push(ModifierContinuation { modifiers, next: index + 1, body }); + let completes = self.analyze_block(modifier_body); + self.modifier_continuations.pop(); + completes + } + + fn analyze_call( + &mut self, + function_id: FunctionId, + arguments: &[&'gcx hir::Expr<'gcx>], + ) -> CallSummary { + let bound = self.argument_aliases(function_id, arguments); + let saved_aliases = std::mem::replace(&mut self.aliases, bound); + + let function = self.gcx.hir.function(function_id); + let context = CallContext::new(function_id, function, &self.aliases, &self.guards); + let cached = if self.seen_calls.contains(&context) { + // Recursive call: assume the summary so far, or a non-completing call. + Some(self.call_summaries.get(&context).cloned().unwrap_or_else(|| CallSummary { + returns: Vec::new(), + guards: self.guards.clone(), + completes: false, + })) + } else if self.evaluated_calls.contains(&context) { + self.call_summaries.get(&context).cloned() + } else { + None + }; + let summary = match cached { + Some(summary) => { + self.guards = summary.guards.clone(); + summary + } + None => { + self.seen_calls.insert(context.clone()); + self.evaluated_calls.insert(context.clone()); + let summary = self.analyze_function(function_id); + self.seen_calls.remove(&context); + self.call_summaries.insert(context, summary.clone()); + summary + } + }; + self.aliases = saved_aliases; + summary + } + + /// Aliases of `function_id`'s parameters when bound to `arguments` under the current state. + fn argument_aliases( + &self, + function_id: FunctionId, + arguments: &[&'gcx hir::Expr<'gcx>], + ) -> AliasState { + let function = self.gcx.hir.function(function_id); + let mut bound = AliasState::default(); + for (¶meter, &argument) in function.parameters.iter().zip(arguments) { + if self.gcx.hir.variable(parameter).data_location == Some(DataLocation::Storage) { + let roots = self.storage_roots(argument); + if !roots.is_empty() { + bound.storage.insert(parameter, roots); + } + } + if function.is_yul { + let roots = self.slot_roots(argument); + if !roots.is_empty() { + bound.slots.insert(parameter, roots); + } + } + } + bound + } + + fn analyze_block(&mut self, block: hir::Block<'gcx>) -> bool { + block.stmts.iter().all(|statement| self.analyze_stmt(statement)) + } + + /// Analyzes each alternative from the current state and joins the states of those that + /// complete (plus `merged`, the state of any implicit fall-through path). Returns whether any + /// path completes. + fn analyze_alternatives( + &mut self, + alternatives: impl IntoIterator, + mut merged: Option, + analyze: impl Fn(&mut Self, T) -> bool, + ) -> bool { + let before = self.flow_state(); + for alternative in alternatives { + self.set_flow_state(before.clone()); + if analyze(self, alternative) { + join(&mut merged, self.flow_state()); + } + } + let Some(merged) = merged else { return false }; + self.set_flow_state(merged); + true + } + + /// Analyzes a statement, returning whether control can continue past it. + fn analyze_stmt(&mut self, statement: &'gcx hir::Stmt<'gcx>) -> bool { + match statement.kind { + StmtKind::DeclSingle(variable_id) => { + let Some(initializer) = self.gcx.hir.variable(variable_id).initializer else { + return true; + }; + if !self.analyze_expr(initializer) { + return false; + } + self.alias_local_from_expr(variable_id, initializer); + true + } + StmtKind::DeclMulti(variables, expression) => { + if !self.analyze_expr(expression) { + return false; + } + for (index, variable_id) in variables.iter().enumerate() { + if let Some(variable_id) = variable_id { + let roots = + self.storage_roots_for_output(expression, index, variables.len()); + self.alias_local(*variable_id, roots); + } + } + true + } + StmtKind::Emit(expression) | StmtKind::Expr(expression) => { + self.analyze_expr(expression) && !branch_always_exits(self.gcx, statement) + } + StmtKind::Revert(expression) => { + self.analyze_expr(expression); + false + } + StmtKind::Return(Some(expression)) => { + if self.analyze_expr(expression) { + self.set_return_aliases(expression); + self.capture_return_flow(); + } + false + } + StmtKind::Return(None) => { + self.capture_named_returns(); + self.capture_return_flow(); + false + } + StmtKind::Block(block) | StmtKind::UncheckedBlock(block) => self.analyze_block(block), + StmtKind::AssemblyBlock(block) => { + self.assembly_depth += 1; + let continues = self.analyze_block(block); + self.assembly_depth -= 1; + continues + } + StmtKind::Loop(block, source) => self.analyze_loop(block, source), + StmtKind::If(condition, then_statement, else_statement) => { + self.analyze_expr(condition) + && self.analyze_alternatives( + [Some(then_statement), else_statement], + None, + |this, arm| arm.is_none_or(|arm| this.analyze_stmt(arm)), + ) + } + StmtKind::Try(try_statement) => { + self.analyze_expr(&try_statement.expr) + && self.analyze_alternatives(try_statement.clauses, None, |this, clause| { + this.analyze_block(clause.block) + }) + } + StmtKind::Switch(switch) => { + if !self.analyze_expr(switch.selector) { + return false; + } + // A value matching no `case` falls through unless a `default` (stored last, with + // no constant) is present. + let has_default = switch.cases.last().is_some_and(|case| case.constant.is_none()); + let fallthrough = (!has_default).then(|| self.flow_state()); + self.analyze_alternatives(switch.cases, fallthrough, |this, case| { + this.analyze_block(case.body) + }) + } + StmtKind::Break | StmtKind::Continue => { + let state = self.flow_state(); + if let Some(flow) = self.loop_flow.last_mut() { + let destination = if matches!(statement.kind, StmtKind::Break) { + &mut flow.breaks + } else { + &mut flow.continues + }; + join(destination, state); + } + false + } + StmtKind::Placeholder => match self.modifier_continuations.last().copied() { + Some(cont) => self.analyze_modifier_chain(cont.modifiers, cont.next, cont.body), + None => true, + }, + StmtKind::Err(_) => true, + } + } + + /// Iterates the loop body from the joined loop-head state until the alias/guard state stops + /// changing. Returns whether the loop can be left normally. + fn analyze_loop(&mut self, block: hir::Block<'gcx>, source: LoopSource<'gcx>) -> bool { + let mut head = self.flow_state(); + let mut exits = None; + loop { + self.set_flow_state(head.clone()); + let (mut breaks, continues, completes) = self.analyze_loop_stmts(|this| { + this.analyze_block(block) + && loop_update(source).is_none_or(|update| this.analyze_stmt(update)) + }); + let mut backedges = completes.then(|| self.flow_state()); + // `continue` in a do-while still evaluates the lowered `if (!cond) break;`. + let epilogue = matches!(source, LoopSource::DoWhile) + .then(|| block.stmts.last()) + .flatten() + .filter(|epilogue| is_loop_termination_if(epilogue)); + match (continues, epilogue) { + (Some(state), Some(epilogue)) => { + self.set_flow_state(state); + let (epilogue_breaks, epilogue_continues, completes) = + self.analyze_loop_stmts(|this| this.analyze_stmt(epilogue)); + if completes { + join(&mut backedges, self.flow_state()); + } + breaks = breaks.into_iter().chain(epilogue_breaks).reduce(|a, b| a.merge(&b)); + backedges = + backedges.into_iter().chain(epilogue_continues).reduce(|a, b| a.merge(&b)); + } + (Some(state), None) => join(&mut backedges, state), + (None, _) => {} + } + if let Some(breaks) = breaks { + join(&mut exits, breaks); + } + let Some(backedges) = backedges else { break }; + let next = head.merge(&backedges); + if next == head { + break; + } + head = next; + } + let Some(exits) = exits else { return false }; + self.set_flow_state(exits); + true + } + + /// Runs `analyze` inside a fresh loop frame, returning the `break` state, the `continue` state + /// and whether the body completed. + fn analyze_loop_stmts( + &mut self, + analyze: impl FnOnce(&mut Self) -> bool, + ) -> (Option, Option, bool) { + self.loop_flow.push(LoopFlow::default()); + let completes = analyze(self); + let flow = self.loop_flow.pop().expect("loop flow frame"); + (flow.breaks, flow.continues, completes) + } + + fn flow_state(&self) -> FlowState { + FlowState { aliases: self.aliases.clone(), guards: self.guards.clone() } + } + + fn set_flow_state(&mut self, state: FlowState) { + self.aliases = state.aliases; + self.guards = state.guards; + } + + fn capture_return_flow(&mut self) { + let state = self.flow_state(); + if let Some(exits) = self.return_flow.last_mut() { + join(exits, state); + } + } + + /// Analyzes an expression, returning whether its evaluation can complete. + fn analyze_expr(&mut self, expression: &'gcx hir::Expr<'gcx>) -> bool { + match &expression.peel_parens().kind { + ExprKind::Assign(lhs, operator, rhs) => { + if !(self.analyze_expr(rhs) && self.analyze_expr(lhs)) { + return false; + } + self.apply_assignment(lhs, rhs, operator.is_some()); + true + } + ExprKind::Delete(inner) => { + if !self.analyze_expr(inner) { + return false; + } + self.record_write(inner); + true + } + ExprKind::Unary(operator, inner) => { + if !self.analyze_expr(inner) { + return false; + } + if operator.kind.has_side_effects() { + self.record_write(inner); + } + true + } + ExprKind::Call(callee, args) => { + let (callee, options) = callee.split_call_options(); + if !(self.analyze_expr(callee) + && options + .iter() + .flat_map(|options| options.args) + .all(|option| self.analyze_expr(&option.value)) + && args.exprs().all(|argument| self.analyze_expr(argument))) + { + return false; + } + + if let ExprKind::Member(base, _) = &callee.peel_parens().kind + && let Some( + builtin @ (Builtin::ArrayPush0 | Builtin::ArrayPush | Builtin::ArrayPop), + ) = self.gcx.resolved_builtin(callee) + { + self.record_write(base); + if builtin == Builtin::ArrayPush0 { + let roots = self.storage_roots(base); + self.store_call_returns(expression.id, vec![roots]); + } + } + + if self.gcx.resolved_builtin(callee) == Some(Builtin::YulSstore) + && let Some(slot) = args.exprs().next() + { + let roots = self.slot_roots(slot); + self.record_roots(roots); + } + + if let Some((_, function_id, receiver)) = self.resolved_internal_call(callee) { + self.guards.insert(function_id); + let arguments = receiver + .into_iter() + .chain( + (0..args.len()) + .filter_map(|index| self.gcx.call_arg(expression, index)), + ) + .collect::>(); + let summary = self.analyze_call(function_id, &arguments); + self.store_call_returns(expression.id, summary.returns); + return summary.completes; + } + true + } + ExprKind::CallOptions(callee, options) => { + self.analyze_expr(callee) + && options.args.iter().all(|option| self.analyze_expr(&option.value)) + } + ExprKind::Binary(lhs, operator, rhs) => { + self.analyze_expr(lhs) + && if matches!(operator.kind, BinOpKind::And | BinOpKind::Or) { + // The right operand may be skipped. + self.analyze_alternatives([Some(*rhs), None], None, |this, rhs| { + rhs.is_none_or(|rhs| this.analyze_expr(rhs)) + }) + } else { + self.analyze_expr(rhs) + } + } + ExprKind::Index(base, index) => { + self.analyze_expr(base) && index.is_none_or(|index| self.analyze_expr(index)) + } + ExprKind::Slice(base, start, end) => { + self.analyze_expr(base) + && start.is_none_or(|start| self.analyze_expr(start)) + && end.is_none_or(|end| self.analyze_expr(end)) + } + ExprKind::Member(base, _) | ExprKind::YulMember(base, _) | ExprKind::Payable(base) => { + self.analyze_expr(base) + } + ExprKind::Ternary(condition, if_true, if_false) => { + self.analyze_expr(condition) + && self.analyze_alternatives([*if_true, *if_false], None, |this, arm| { + this.analyze_expr(arm) + }) + } + ExprKind::Array(expressions) => { + expressions.iter().all(|expression| self.analyze_expr(expression)) + } + ExprKind::Tuple(expressions) => { + expressions.iter().flatten().all(|expression| self.analyze_expr(expression)) + } + ExprKind::New(_) + | ExprKind::TypeCall(_) + | ExprKind::Type(_) + | ExprKind::Ident(_) + | ExprKind::Lit(_) + | ExprKind::Err(_) => true, + } + } + + fn record_write(&mut self, expression: &hir::Expr<'_>) { + self.record_roots(self.storage_roots(expression)); + } + + fn record_roots(&mut self, roots: StorageRoots) { + for variable_id in roots { + self.writes + .entry(variable_id) + .and_modify(|guards| guards.retain(|guard| self.guards.contains(guard))) + .or_insert_with(|| self.guards.clone()); + } + } + + fn set_storage_alias(&mut self, variable_id: VariableId, roots: StorageRoots) { + let variable = self.gcx.hir.variable(variable_id); + if !variable.kind.is_state() + && variable.data_location == Some(DataLocation::Storage) + && !roots.is_empty() + { + self.aliases.storage.insert(variable_id, roots); + } else { + self.aliases.storage.remove(&variable_id); + } + } + + fn set_slot_alias(&mut self, variable_id: VariableId, roots: StorageRoots) { + if roots.is_empty() { + self.aliases.slots.remove(&variable_id); + } else { + self.aliases.slots.insert(variable_id, roots); + } + } + + /// Storage pointer locals alias the roots they are bound to; inside assembly, Yul locals also + /// track the slots they hold. + fn alias_local(&mut self, variable_id: VariableId, roots: StorageRoots) { + if self.assembly_depth > 0 { + self.set_slot_alias(variable_id, roots.clone()); + } + self.set_storage_alias(variable_id, roots); + } + + fn alias_local_from_expr(&mut self, variable_id: VariableId, expression: &hir::Expr<'_>) { + if self.assembly_depth > 0 { + let roots = self.slot_roots(expression); + self.set_slot_alias(variable_id, roots); + } + let roots = self.storage_roots(expression); + self.set_storage_alias(variable_id, roots); + } + + fn apply_assignment( + &mut self, + lhs: &'gcx hir::Expr<'gcx>, + rhs: &'gcx hir::Expr<'gcx>, + compound: bool, + ) { + let lhs = lhs.peel_parens(); + if compound { + return self.record_write(lhs); + } + match &lhs.kind { + // `pointer.slot := x` retargets a storage pointer. + ExprKind::YulMember(base, member) + if member.as_str() == "slot" + && let Some(local) = lhs_local_var(self.gcx, base) => + { + let roots = self.slot_roots(rhs); + self.set_storage_alias(local, roots); + } + ExprKind::Tuple(expressions) => { + for (index, expression) in expressions.iter().enumerate() { + let Some(expression) = expression else { continue }; + if let Some(local) = lhs_local_var(self.gcx, expression) { + let roots = self.storage_roots_for_output(rhs, index, expressions.len()); + self.alias_local(local, roots); + } else { + self.record_write(expression); + } + } + } + _ => match lhs_local_var(self.gcx, lhs) { + Some(local) => self.alias_local_from_expr(local, rhs), + None => self.record_write(lhs), + }, + } + } + + fn set_return_aliases(&mut self, expression: &'gcx hir::Expr<'gcx>) { + let Some(&function_id) = self.stack.last() else { return }; + let outputs = self.gcx.hir.function(function_id).returns.len(); + let roots: Vec<_> = (0..outputs) + .map(|index| self.storage_roots_for_output(expression, index, outputs)) + .collect(); + self.extend_returns(roots); + } + + fn capture_named_returns(&mut self) { + let Some(&function_id) = self.stack.last() else { return }; + let function = self.gcx.hir.function(function_id); + let aliases = if function.is_yul { &self.aliases.slots } else { &self.aliases.storage }; + let roots: Vec<_> = function + .returns + .iter() + .map(|return_id| aliases.get(return_id).cloned().unwrap_or_default()) + .collect(); + self.extend_returns(roots); + } + + fn extend_returns(&mut self, roots: Vec) { + if let Some(frame) = self.return_stack.last_mut() { + for (returned, roots) in frame.iter_mut().zip(roots) { + returned.extend(roots); + } + } + } + + /// Storage roots flowing into output `index` of `outputs` from `expression`. + fn storage_roots_for_output( + &self, + expression: &hir::Expr<'_>, + index: usize, + outputs: usize, + ) -> StorageRoots { + match &expression.peel_parens().kind { + ExprKind::Tuple(expressions) if outputs > 1 => expressions + .get(index) + .copied() + .flatten() + .map_or_else(StorageRoots::new, |expression| self.storage_roots(expression)), + ExprKind::Call(..) => self + .call_returns + .get(&expression.id) + .and_then(|returns| returns.get(index)) + .cloned() + .unwrap_or_default(), + _ if outputs == 1 && index == 0 => self.storage_roots(expression), + _ => StorageRoots::new(), + } + } + + fn store_call_returns(&mut self, expression_id: ExprId, returns: Vec) { + if returns.is_empty() { + return; + } + let stored = self.call_returns.entry(expression_id).or_default(); + if stored.len() < returns.len() { + stored.resize_with(returns.len(), StorageRoots::new); + } + for (stored, returned) in stored.iter_mut().zip(returns) { + stored.extend(returned); + } + } + + /// Call arguments in declaration order of `declared_id`'s parameters, the attached receiver + /// first. + fn ordered_call_arguments( + &self, + declared_id: FunctionId, + arguments: CallArgs<'gcx>, + receiver: Option<&'gcx hir::Expr<'gcx>>, + ) -> Vec<&'gcx hir::Expr<'gcx>> { + let names = self.gcx.callable_param_names(CallableParamSource::Function { + id: declared_id, + skips_receiver: receiver.is_some(), + }); + let arguments = (0..names.len()) + .filter_map(|index| arguments.argument_for_parameter(index, Some(&names))); + receiver.into_iter().chain(arguments).collect() + } + + /// `(declared, dispatched, attached receiver)` for a call that executes contract code in this + /// storage context: bare identifiers, `using for` attached calls, library calls and + /// `super.`/`Base.`/`Lib.` qualified calls. + fn resolved_internal_call( + &self, + callee: &'gcx hir::Expr<'gcx>, + ) -> Option<(FunctionId, FunctionId, Option<&'gcx hir::Expr<'gcx>>)> { + let resolved = self.gcx.resolved_callee(callee.peel_parens().id)?; + let function_id = resolved.res.as_function()?; + let attached = resolved.attached; + match &callee.peel_parens().kind { + ExprKind::Ident(_) => Some((function_id, self.dispatch_function(function_id), None)), + ExprKind::Member(base, _) if attached => Some((function_id, function_id, Some(base))), + ExprKind::Member(base, _) + if self.is_library_function(function_id) + || is_static_internal_base(self.gcx, base) => + { + Some((function_id, function_id, None)) + } + _ => None, + } + } + + fn is_library_function(&self, function_id: FunctionId) -> bool { + self.gcx + .hir + .function(function_id) + .contract + .is_some_and(|contract_id| self.gcx.hir.contract(contract_id).kind.is_library()) + } + + /// The most-derived override of a virtual function or modifier in the analyzed hierarchy. + fn dispatch_function(&self, function_id: FunctionId) -> FunctionId { + self.gcx.resolve_virtual_function(self.bases[0], function_id) + } + + /// State variables an lvalue may write: state roots, aliased storage pointers and + /// storage-returning calls. + fn storage_roots(&self, expression: &hir::Expr<'_>) -> StorageRoots { + let mut roots = StorageRoots::new(); + self.collect_storage_roots(expression, &mut roots); + roots + } + + fn collect_storage_roots(&self, expression: &hir::Expr<'_>, roots: &mut StorageRoots) { + let expression = expression.peel_parens(); + match &expression.kind { + ExprKind::Ident(_) => { + if let Some(variable_id) = self.gcx.resolved_variable(expression) { + if self.gcx.hir.variable(variable_id).kind.is_state() { + roots.insert(variable_id); + } else if let Some(aliases) = self.aliases.storage.get(&variable_id) { + roots.extend(aliases); + } + } + } + ExprKind::Index(base, _) + | ExprKind::Slice(base, ..) + | ExprKind::Member(base, _) + | ExprKind::YulMember(base, _) + | ExprKind::Payable(base) + | ExprKind::Unary(_, base) + | ExprKind::Delete(base) => self.collect_storage_roots(base, roots), + ExprKind::Tuple(expressions) => { + for expression in expressions.iter().flatten() { + self.collect_storage_roots(expression, roots); + } + } + ExprKind::Ternary(_, if_true, if_false) => { + self.collect_storage_roots(if_true, roots); + self.collect_storage_roots(if_false, roots); + } + ExprKind::Call(..) => { + roots.extend(self.call_returns.get(&expression.id).into_iter().flatten().flatten()); + } + _ => {} + } + } + + /// State variables whose slot a Yul expression may evaluate to. + fn slot_roots(&self, expression: &hir::Expr<'_>) -> StorageRoots { + let mut roots = StorageRoots::new(); + self.collect_slot_roots(expression, &mut roots); + roots + } + + fn collect_slot_roots(&self, expression: &hir::Expr<'_>, roots: &mut StorageRoots) { + let expression = expression.peel_parens(); + match &expression.kind { + ExprKind::Ident(_) => { + if let Some(variable_id) = self.gcx.resolved_variable(expression) + && let Some(aliases) = self.aliases.slots.get(&variable_id) + { + roots.extend(aliases); + } + } + ExprKind::YulMember(base, member) if member.as_str() == "slot" => { + self.collect_storage_roots(base, roots); + } + ExprKind::Call(..) if self.call_returns.contains_key(&expression.id) => { + roots.extend(self.call_returns[&expression.id].iter().flatten()); + } + // Any other expression may propagate a slot computed from its operands. + ExprKind::Call(callee, args) => { + let (callee, options) = callee.split_call_options(); + self.collect_slot_roots(callee, roots); + for option in options.iter().flat_map(|options| options.args) { + self.collect_slot_roots(&option.value, roots); + } + for argument in args.exprs() { + self.collect_slot_roots(argument, roots); + } + } + ExprKind::Assign(lhs, _, rhs) | ExprKind::Binary(lhs, _, rhs) => { + self.collect_slot_roots(lhs, roots); + self.collect_slot_roots(rhs, roots); + } + ExprKind::Index(base, index) => { + for expression in [Some(*base), *index].into_iter().flatten() { + self.collect_slot_roots(expression, roots); + } + } + ExprKind::Slice(base, start, end) => { + for expression in [Some(*base), *start, *end].into_iter().flatten() { + self.collect_slot_roots(expression, roots); + } + } + ExprKind::Ternary(condition, if_true, if_false) => { + for expression in [condition, if_true, if_false] { + self.collect_slot_roots(expression, roots); + } + } + ExprKind::Member(base, _) + | ExprKind::YulMember(base, _) + | ExprKind::Payable(base) + | ExprKind::Unary(_, base) + | ExprKind::Delete(base) => self.collect_slot_roots(base, roots), + ExprKind::Array(expressions) => { + for expression in *expressions { + self.collect_slot_roots(expression, roots); + } + } + ExprKind::Tuple(expressions) => { + for expression in expressions.iter().flatten() { + self.collect_slot_roots(expression, roots); + } + } + _ => {} + } + } +} + +/// `super.f`, `Base.f` or `Lib.f`: a statically dispatched internal call. +fn is_static_internal_base(gcx: Gcx<'_>, base: &hir::Expr<'_>) -> bool { + is_builtin(gcx, base, sym::super_) + || matches!( + gcx.resolved_expr(base), + Some(Res::Item(ItemId::Contract(_)) | Res::Namespace(_)) + ) +} diff --git a/crates/lint/src/sol/high/reentrancy.rs b/crates/lint/src/sol/high/reentrancy.rs new file mode 100644 index 0000000000000..7b35c59789c2f --- /dev/null +++ b/crates/lint/src/sol/high/reentrancy.rs @@ -0,0 +1,2010 @@ +use super::ReentrancyEth; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{ + Severity, SolLint, + analysis::{ + DEFAULT_HELPER_ANALYSIS_CACHE_LIMIT, HelperAnalysisCache, arg_for_param, + branch_always_exits, cast_type, count_placeholders, expr_is_address, for_each_child, + for_each_lhs_var, is_address_cast, is_builtin, is_require_or_assert, lhs_local_var, + loop_update, state_lhs_vars, stmts_before_placeholder, tuple_elems, + }, + }, +}; +use alloy_primitives::U256; +use solar::{ + ast::{BinOpKind, FunctionKind, LitKind, StateMutability, UnOpKind, Visibility}, + interface::{Span, Symbol, data_structures::Never, kw, sym}, + sema::{ + Gcx, + builtins::Builtin, + hir::{ + self, CallArgs, CallOptions, Expr, ExprKind, FunctionId, ItemId, LoopSource, Stmt, + StmtKind, VariableId, Visit, + }, + ty::{TyFnKind, TyKind}, + }, +}; +use std::{ + collections::{BTreeMap, BTreeSet, HashMap, HashSet}, + ops::ControlFlow, +}; + +/// Gas stipend forwarded by `transfer`/`send`; a call capped at or below it cannot reenter. +const REENTRANCY_GAS_STIPEND: u64 = 2_300; + +declare_forge_lint!( + REENTRANCY_BALANCE, + Severity::High, + "reentrancy-balance", + "external call can be reentered before a stale contract balance is checked" +); + +declare_forge_lint!( + REENTRANCY_ETH, + Severity::High, + "reentrancy-eth", + "state read before ETH transfer is written after the transfer" +); + +declare_forge_lint!( + REENTRANCY_NO_ETH, + Severity::Med, + "reentrancy-no-eth", + "state read before external call is written after the call" +); + +impl<'gcx> LateLintPass<'gcx> for ReentrancyEth { + fn check_function( + &mut self, + ctx: &LintContext, + gcx: Gcx<'gcx>, + func: &'gcx hir::Function<'gcx>, + ) { + let Some(body) = func.body.filter(|_| is_entry_point(func)) else { return }; + let mut analyzer = Analyzer::new(ctx, gcx, func); + if analyzer.has_enabled_lints() { + analyzer.analyze_callable(func, body, &mut FlowState::default()); + } + } +} + +/// Non-view functions an external caller can invoke: public/external functions, `fallback` and +/// `receive`. +fn is_entry_point(func: &hir::Function<'_>) -> bool { + !is_view_or_pure(func.state_mutability) + && !func.is_constructor() + && (func.is_special() + || (func.kind.is_function() + && matches!(func.visibility, Visibility::Public | Visibility::External))) +} + +const fn is_view_or_pure(mutability: StateMutability) -> bool { + matches!(mutability, StateMutability::Pure | StateMutability::View) +} + +type PathPredicates = BTreeMap; +type PathAlternatives = BTreeSet; + +/// Facts that hold along one execution path. +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] +struct FlowState { + /// State variables read so far. + state_reads: BTreeSet, + /// Reentrant calls made after a state read, with the state read before each, awaiting a + /// later write to that state. + pending_calls: BTreeMap<(Span, ReentrantCallKind), BTreeSet>, + /// Internal functions a function-typed local may point to. + internal_function_targets: BTreeMap>, + /// Locals holding `address(this)`, with the path predicates under which they do. + self_address_local_paths: BTreeMap, + /// Locals derived from `address(this).balance`, with the predicates under which they are. + balance_local_paths: BTreeMap, + /// Supported arithmetic forms of local values; an empty set means unknown. + balance_local_forms: BTreeMap>, + /// Balance occurrences retained through arbitrary arithmetic on local values. + balance_local_dependencies: BTreeMap>, + /// Locals holding a comparison against a balance made stale by the given calls. + balance_comparison_locals: BTreeMap>, + /// External calls after which cached balance locals are stale. + pending_balance_calls: BTreeMap, + /// Reentrancy-guard locks written or bypassed while active. + invalidated_balance_guards: BTreeSet, + /// Boolean facts known to hold on this path. + path_predicates: PathPredicates, +} + +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +enum PathPredicate { + Boolean(VariableId), + Equality(Operand, Operand), +} + +impl PathPredicate { + fn mentions(self, f: impl Fn(VariableId) -> bool) -> bool { + match self { + Self::Boolean(var_id) => f(var_id), + Self::Equality(lhs, rhs) => { + [lhs, rhs].into_iter().any(|op| matches!(op, Operand::Variable(v) if f(v))) + } + } + } +} + +/// A local variable or constant appearing in a predicate or lock expression. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +enum Operand { + Variable(VariableId), + Number(U256), + Boolean(bool), +} + +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +enum ReentrantCallKind { + Eth, + NoEth, +} + +/// One signed balance read, retaining the calls across which it was cached. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +struct BalanceTerm { + negative: bool, + stale_calls: BTreeSet, +} + +/// A supported additive source pattern on one path. An empty term list is balance-independent. +/// At most two balance terms are retained: a stale/current comparison needs one of each. More +/// terms are rejected rather than cancelled, since Solidity arithmetic may wrap or revert. +#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +struct BalanceForm { + terms: Vec, + path: PathPredicates, +} + +impl BalanceForm { + fn combine(&self, rhs: &Self, subtract: bool) -> Option { + if self.terms.len() + rhs.terms.len() > 2 || !paths_compatible(&self.path, &rhs.path) { + return None; + } + let mut terms = self.terms.clone(); + terms.extend(rhs.terms.iter().cloned().map(|mut term| { + term.negative ^= subtract; + term + })); + Some(Self { + terms, + path: self.path.iter().chain(&rhs.path).map(|(p, v)| (*p, *v)).collect(), + }) + } + + fn constrained(&self, active: &PathPredicates) -> Option { + paths_compatible(&self.path, active).then(|| Self { + terms: self.terms.clone(), + path: self.path.iter().chain(active).map(|(p, v)| (*p, *v)).collect(), + }) + } +} + +/// Balance-related facts about a value flowing into a local, parameter or return slot. +#[derive(Clone, Debug, Default)] +struct BalanceValue { + /// Supported arithmetic forms, separate from occurrence-based dependencies. + forms: BTreeSet, + /// Individual balance occurrences, without arithmetic signs or identities. + dependencies: BTreeSet, + /// Predicates under which the value derives from `address(this).balance`. + balance_paths: PathAlternatives, + /// Predicates under which the value is `address(this)`. + self_address_paths: PathAlternatives, + /// Calls whose stale balance the value compares against. + stale_comparisons: BTreeSet, +} + +impl BalanceValue { + fn merge(&mut self, other: Self) { + self.forms.extend(other.forms); + self.dependencies.extend(other.dependencies); + self.balance_paths.extend(other.balance_paths); + self.self_address_paths.extend(other.self_address_paths); + self.stale_comparisons.extend(other.stale_comparisons); + } + + /// The same value seen from a path on which `predicates` hold. + fn constrained(&self, predicates: &PathPredicates) -> Self { + Self { + forms: self.forms.iter().filter_map(|form| form.constrained(predicates)).collect(), + dependencies: self + .dependencies + .iter() + .filter_map(|form| form.constrained(predicates)) + .collect(), + balance_paths: constrain_paths(&self.balance_paths, predicates), + self_address_paths: constrain_paths(&self.self_address_paths, predicates), + ..self.clone() + } + } +} + +impl FlowState { + fn push_call(&mut self, span: Span, kind: ReentrantCallKind) { + if !self.state_reads.is_empty() { + self.pending_calls.entry((span, kind)).or_default().extend(&self.state_reads); + } + } + + fn push_balance_call(&mut self, span: Span) { + if self + .balance_local_paths + .values() + .any(|paths| paths.iter().any(|path| paths_compatible(path, &self.path_predicates))) + { + self.pending_balance_calls + .entry(span) + .or_default() + .insert(self.path_predicates.clone()); + for forms in self + .balance_local_forms + .values_mut() + .chain(self.balance_local_dependencies.values_mut()) + { + *forms = std::mem::take(forms) + .into_iter() + .map(|mut form| { + if paths_compatible(&form.path, &self.path_predicates) { + for term in &mut form.terms { + term.stale_calls.insert(span); + } + } + form + }) + .collect(); + } + } + } + + fn merge(&mut self, other: &Self) { + self.state_reads.extend(&other.state_reads); + merge_maps(&mut self.pending_calls, &other.pending_calls); + self.merge_balance(other); + } + + fn merge_balance(&mut self, other: &Self) { + merge_maps(&mut self.internal_function_targets, &other.internal_function_targets); + merge_maps(&mut self.self_address_local_paths, &other.self_address_local_paths); + merge_maps(&mut self.balance_local_paths, &other.balance_local_paths); + merge_maps(&mut self.balance_local_forms, &other.balance_local_forms); + merge_maps(&mut self.balance_local_dependencies, &other.balance_local_dependencies); + merge_maps(&mut self.balance_comparison_locals, &other.balance_comparison_locals); + self.invalidated_balance_guards.extend(&other.invalidated_balance_guards); + merge_maps(&mut self.pending_balance_calls, &other.pending_balance_calls); + } + + fn balance_only(&self) -> Self { + Self { state_reads: BTreeSet::new(), pending_calls: BTreeMap::new(), ..self.clone() } + } + + /// Records that `predicate` holds; returns `false` if that contradicts a known fact. + fn constrain_path(&mut self, (predicate, value): (PathPredicate, bool)) -> bool { + match self.path_predicates.get(&predicate) { + Some(existing) => *existing == value, + None => { + self.path_predicates.insert(predicate, value); + for paths in self.self_address_local_paths.values_mut() { + *paths = constrain_paths(paths, &self.path_predicates); + } + true + } + } + } +} + +fn merge_maps( + into: &mut BTreeMap>, + from: &BTreeMap>, +) { + for (key, values) in from { + into.entry(*key).or_default().extend(values.iter().cloned()); + } +} + +/// Replaces `state` with the union of the reachable `branches`, keeping only the path predicates +/// all of them agree on. Returns whether any branch was reachable. +fn join_branches( + state: &mut FlowState, + branches: impl IntoIterator>, +) -> bool { + *state = FlowState::default(); + let mut predicates = None; + for branch in branches.into_iter().flatten() { + state.merge(&branch); + predicates = Some(match predicates { + Some(common) => common_path_predicates(&common, &branch.path_predicates), + None => branch.path_predicates, + }); + } + let reachable = predicates.is_some(); + state.path_predicates = predicates.unwrap_or_default(); + reachable +} + +struct Analyzer<'ctx, 's, 'c, 'gcx> { + ctx: &'ctx LintContext<'s, 'c>, + gcx: Gcx<'gcx>, + emitted: HashSet, + emitted_balance: HashSet, + call_stack: Vec, + inline_cache: HelperAnalysisCache)>, + /// First function on the given call stack reachable again from a callee, per callee. + recursive_cuts: HashMap<(FunctionId, BTreeSet), Option>, + direct_internal_calls: HashMap>, + reentrancy_eth_enabled: bool, + reentrancy_no_eth_enabled: bool, + reentrancy_balance_enabled: bool, + /// Set while re-running code only to refine the balance analysis. + balance_only_analysis: bool, + /// Balance facts about the return values of each analysed internal call site. + call_balance_values: HashMap>, + /// Return-value accumulators of the internal calls currently being inlined. + return_collectors: Vec<(FunctionId, Vec)>, + /// Reentrancy-guard locks held by the modifiers wrapping the code being analysed. + active_balance_guards: Vec, + /// The lock guarding every entry point of each deployable contract, if any. + balance_reentry_lock: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +struct InlineCallKey { + func_id: FunctionId, + /// First active function that can cut recursion from this callee. + recursive_cut: Option, + balance_only: bool, + active_balance_guards: Vec, + parameter_predicates: Vec>, + state: FlowState, +} + +/// What to analyse when a modifier's `_` is reached: the remaining modifiers, the function body +/// and the reentrancy-guard lock the current modifier holds. +type ModifierContinuation<'gcx> = + (&'gcx [hir::Modifier<'gcx>], usize, hir::Block<'gcx>, Option); + +impl<'ctx, 's, 'c, 'gcx> Analyzer<'ctx, 's, 'c, 'gcx> { + fn new( + ctx: &'ctx LintContext<'s, 'c>, + gcx: Gcx<'gcx>, + entry: &'gcx hir::Function<'gcx>, + ) -> Self { + let reentrancy_balance_enabled = ctx.is_lint_enabled(REENTRANCY_BALANCE.id); + Self { + ctx, + gcx, + emitted: HashSet::new(), + emitted_balance: HashSet::new(), + call_stack: Vec::new(), + inline_cache: HelperAnalysisCache::new(DEFAULT_HELPER_ANALYSIS_CACHE_LIMIT), + recursive_cuts: HashMap::new(), + direct_internal_calls: HashMap::new(), + reentrancy_eth_enabled: ctx.is_lint_enabled(REENTRANCY_ETH.id), + reentrancy_no_eth_enabled: ctx.is_lint_enabled(REENTRANCY_NO_ETH.id), + reentrancy_balance_enabled, + balance_only_analysis: false, + call_balance_values: HashMap::new(), + return_collectors: Vec::new(), + active_balance_guards: Vec::new(), + balance_reentry_lock: reentrancy_balance_enabled + .then(|| balance_reentry_lock(gcx, entry)) + .flatten(), + } + } + + const fn has_enabled_lints(&self) -> bool { + self.reentrancy_eth_enabled + || self.reentrancy_no_eth_enabled + || self.reentrancy_balance_enabled + } + + /// Analyses `func` (modifiers first, then `body`); returns whether it can fall through. + fn analyze_callable( + &mut self, + func: &'gcx hir::Function<'gcx>, + body: hir::Block<'gcx>, + state: &mut FlowState, + ) -> bool { + self.analyze_modifier_chain(func.modifiers, 0, body, state) + } + + fn analyze_modifier_chain( + &mut self, + modifiers: &'gcx [hir::Modifier<'gcx>], + index: usize, + body: hir::Block<'gcx>, + state: &mut FlowState, + ) -> bool { + let Some(modifier) = modifiers.get(index) else { + return self.analyze_block(body, None, state); + }; + for arg in modifier.args.exprs() { + self.analyze_expr(arg, state); + } + let Some((modifier_id, modifier_func, modifier_body)) = modifier + .id + .as_function() + .filter(|id| !self.call_stack.contains(id)) + .map(|id| (id, self.gcx.hir.function(id))) + .and_then(|(id, func)| Some((id, func, func.body?))) + else { + return self.analyze_modifier_chain(modifiers, index + 1, body, state); + }; + + self.seed_balance_parameters(modifier_id, &modifier.args, state); + self.call_stack.push(modifier_id); + let balance_guard = self + .reentrancy_balance_enabled + .then(|| standard_reentrancy_guard_lock(self.gcx, modifier_func)) + .flatten(); + let continuation = Some((modifiers, index + 1, body, balance_guard)); + let falls_through = self.analyze_block(modifier_body, continuation, state); + self.call_stack.pop(); + self.clear_function_locals(modifier_id, state); + falls_through + } + + /// Analyzes one loop iteration: the body, then the `for` update if the body completes. + fn analyze_iteration( + &mut self, + block: hir::Block<'gcx>, + source: LoopSource<'gcx>, + placeholder: Option>, + state: &mut FlowState, + ) -> bool { + self.analyze_block(block, placeholder, state) + && loop_update(source) + .is_none_or(|update| self.analyze_stmt(update, placeholder, state)) + } + + fn analyze_block( + &mut self, + block: hir::Block<'gcx>, + placeholder: Option>, + state: &mut FlowState, + ) -> bool { + block.stmts.iter().all(|stmt| self.analyze_stmt(stmt, placeholder, state)) + } + + /// Analyses `stmt`; returns whether control can continue past it. + fn analyze_stmt( + &mut self, + stmt: &'gcx Stmt<'gcx>, + placeholder: Option>, + state: &mut FlowState, + ) -> bool { + match stmt.kind { + StmtKind::DeclSingle(var_id) => { + if let Some(init) = self.gcx.hir.variable(var_id).initializer { + self.analyze_expr(init, state); + self.update_internal_function_target(state, var_id, init); + if self.reentrancy_balance_enabled { + self.bind_locals(state, &[Some(var_id)], init, None); + } + } else if self.reentrancy_balance_enabled { + self.set_self_address_paths(state, var_id, PathAlternatives::new()); + } + true + } + StmtKind::DeclMulti(vars, expr) => { + self.analyze_expr(expr, state); + if self.reentrancy_balance_enabled { + self.bind_locals(state, vars, expr, None); + } + true + } + StmtKind::Expr(expr) | StmtKind::Emit(expr) => { + self.analyze_expr(expr, state); + true + } + StmtKind::Revert(expr) => { + self.analyze_expr(expr, state); + false + } + StmtKind::Block(block) | StmtKind::UncheckedBlock(block) => { + self.analyze_block(block, placeholder, state) + } + StmtKind::Return(expr) => { + if let Some(expr) = expr { + self.analyze_expr(expr, state); + } + if self.reentrancy_balance_enabled { + self.record_return(expr, state); + } + false + } + StmtKind::Break | StmtKind::Continue => false, + StmtKind::Loop(block, source) => { + let before_loop = state.clone(); + let mut body_state = state.clone(); + self.analyze_iteration(block, source, placeholder, &mut body_state); + // One bounded second iteration exposes loop-carried balance checks while leaving + // the established ETH and no-ETH analysis unchanged. + let second_iteration = self.reentrancy_balance_enabled.then(|| { + let mut second = body_state.balance_only(); + self.analyze_with_only_balance(|this| { + this.analyze_iteration(block, source, placeholder, &mut second) + }); + second + }); + join_branches(state, [Some(before_loop), Some(body_state)]); + if let Some(second) = second_iteration { + state.path_predicates = + common_path_predicates(&state.path_predicates, &second.path_predicates); + state.merge_balance(&second); + } + true + } + StmtKind::If(cond, then_stmt, else_stmt) => { + self.analyze_expr(cond, state); + if self.reentrancy_balance_enabled + && (branch_stops_current_path(self.gcx, then_stmt) + || else_stmt.is_some_and(|expr| branch_stops_current_path(self.gcx, expr))) + { + self.emit_balance_calls(cond, state); + } + let (mut then_state, mut else_state) = (state.clone(), state.clone()); + let (then_reachable, else_reachable) = + self.split_on(cond, &mut then_state, &mut else_state); + let then_falls_through = + then_reachable && self.analyze_stmt(then_stmt, placeholder, &mut then_state); + let else_falls_through = else_reachable + && else_stmt.is_none_or(|e| self.analyze_stmt(e, placeholder, &mut else_state)); + join_branches( + state, + [ + then_falls_through.then_some(then_state), + else_falls_through.then_some(else_state), + ], + ) + } + StmtKind::Try(try_stmt) => { + self.analyze_expr(&try_stmt.expr, state); + let clauses = try_stmt + .clauses + .iter() + .map(|clause| { + let mut clause_state = state.clone(); + self.analyze_block(clause.block, placeholder, &mut clause_state) + .then_some(clause_state) + }) + .collect::>(); + join_branches(state, clauses) + } + StmtKind::Placeholder => { + let Some((modifiers, index, body, balance_guard)) = placeholder else { + return true; + }; + if let Some(lock_var) = balance_guard { + state.invalidated_balance_guards.remove(&lock_var); + self.active_balance_guards.push(lock_var); + } + let falls_through = self.analyze_modifier_chain(modifiers, index, body, state); + if balance_guard.is_some() { + self.active_balance_guards.pop(); + } + falls_through + } + StmtKind::AssemblyBlock(_) | StmtKind::Switch(_) => { + state.invalidated_balance_guards.extend(&self.active_balance_guards); + state.internal_function_targets.clear(); + state.self_address_local_paths.clear(); + true + } + StmtKind::Err(_) => true, + } + } + + /// Constrains the branch states of a conditional on `cond`; returns whether each is reachable. + fn split_on( + &self, + cond: &'gcx Expr<'gcx>, + then_state: &mut FlowState, + else_state: &mut FlowState, + ) -> (bool, bool) { + let predicate = + self.reentrancy_balance_enabled.then(|| path_predicate(self.gcx, cond)).flatten(); + let Some((predicate, value)) = predicate else { return (true, true) }; + ( + then_state.constrain_path((predicate, value)), + else_state.constrain_path((predicate, !value)), + ) + } + + fn analyze_expr(&mut self, expr: &'gcx Expr<'gcx>, state: &mut FlowState) { + match &expr.kind { + ExprKind::Assign(lhs, op, rhs) => { + if op.is_some() { + self.analyze_expr(lhs, state); + } + self.analyze_expr(rhs, state); + self.analyze_lhs_indices(lhs, state); + self.record_write(lhs, state); + if let Some(var_id) = lhs_local_var(self.gcx, lhs) { + if op.is_none() { + self.update_internal_function_target(state, var_id, rhs); + } else { + state.internal_function_targets.remove(&var_id); + } + } + if self.reentrancy_balance_enabled { + let targets = match tuple_elems(lhs) { + Some(elems) => elems + .iter() + .map(|e| e.and_then(|e| lhs_local_var(self.gcx, e))) + .collect(), + None => vec![lhs_local_var(self.gcx, lhs)], + }; + self.bind_locals(state, &targets, rhs, op.map(|op| op.kind)); + } + } + ExprKind::Delete(inner) => { + self.analyze_lhs_indices(inner, state); + self.record_write(inner, state); + if let Some(var_id) = lhs_local_var(self.gcx, inner) { + state.internal_function_targets.remove(&var_id); + if self.reentrancy_balance_enabled { + self.clear_local(state, var_id); + } + } + } + ExprKind::Unary(op, inner) => { + self.analyze_expr(inner, state); + if op.kind.has_side_effects() { + self.record_write(inner, state); + if self.reentrancy_balance_enabled + && let Some(var_id) = lhs_local_var(self.gcx, inner) + { + self.set_self_address_paths(state, var_id, PathAlternatives::new()); + } + } + } + ExprKind::Call(callee, args) => { + let (callee, opts) = callee.split_call_options(); + let mut operands = vec![callee]; + operands.extend(opts.iter().flat_map(|opts| opts.args).map(|opt| &opt.value)); + operands.extend(args.exprs()); + + let before_operands = state.clone(); + for operand in &operands { + self.analyze_expr(operand, state); + } + // Solidity does not specify operand evaluation order. Reversing the operands + // covers both relative orders for each pair without changing the shared + // reentrancy analysis. + if self.reentrancy_balance_enabled && operands.len() > 1 { + let mut reverse_state = before_operands.balance_only(); + self.analyze_with_only_balance(|this| { + for operand in operands.iter().rev() { + this.analyze_expr(operand, &mut reverse_state); + } + }); + state.merge_balance(&reverse_state); + } + + if self.reentrancy_balance_enabled + && is_require_or_assert(self.gcx, callee) + && let Some(cond) = args.exprs().next() + { + self.emit_balance_calls(cond, state); + } + + for func_id in self.internal_callees(callee, state) { + let returns = self.analyze_internal_call(func_id, args, state); + self.merge_call_balance_values(expr.span, returns); + } + if !state.state_reads.is_empty() + && let Some(kind) = self.reentrant_call_kind(callee, opts) + { + state.push_call(expr.span, kind); + } + if self.reentrancy_balance_enabled + && call_options_allow_reentrancy(self.gcx, opts) + && callee_can_reenter(self.gcx, callee) + && !self.balance_guard_blocks_call(state, callee) + { + state.push_balance_call(expr.span); + } + if call_uses_delegate_context(self.gcx, callee) { + state.invalidated_balance_guards.extend(&self.active_balance_guards); + } + } + ExprKind::Binary(lhs, op, rhs) + if self.reentrancy_balance_enabled + && matches!(op.kind, BinOpKind::And | BinOpKind::Or) => + { + self.analyze_expr(lhs, state); + let rhs_outcome = op.kind == BinOpKind::And; + let (mut short_state, mut rhs_state) = (state.clone(), state.clone()); + let short_reachable = + constrain_boolean_outcome(self.gcx, lhs, !rhs_outcome, &mut short_state); + let rhs_reachable = + constrain_boolean_outcome(self.gcx, lhs, rhs_outcome, &mut rhs_state); + if rhs_reachable { + self.analyze_expr(rhs, &mut rhs_state); + } + join_branches( + state, + [short_reachable.then_some(short_state), rhs_reachable.then_some(rhs_state)], + ); + } + ExprKind::Ternary(cond, true_expr, false_expr) => { + self.analyze_expr(cond, state); + let (mut true_state, mut false_state) = (state.clone(), state.clone()); + let (true_reachable, false_reachable) = + self.split_on(cond, &mut true_state, &mut false_state); + if true_reachable { + self.analyze_expr(true_expr, &mut true_state); + } + if false_reachable { + self.analyze_expr(false_expr, &mut false_state); + } + join_branches( + state, + [true_reachable.then_some(true_state), false_reachable.then_some(false_state)], + ); + } + ExprKind::Ident(_) => state.state_reads.extend( + self.gcx + .resolved_variable(expr) + .into_iter() + .filter(|v| self.gcx.hir.variable(*v).kind.is_state()), + ), + _ => for_each_child(expr, &mut |child| self.analyze_expr(child, state)), + } + } + + /// Evaluates the index and slice operands of an lvalue; the written root itself is no read. + fn analyze_lhs_indices(&mut self, expr: &'gcx Expr<'gcx>, state: &mut FlowState) { + match &expr.kind { + ExprKind::Index(base, index) => { + self.analyze_lhs_indices(base, state); + if let Some(index) = index { + self.analyze_expr(index, state); + } + } + ExprKind::Slice(base, start, end) => { + self.analyze_lhs_indices(base, state); + for bound in [start, end].into_iter().flatten() { + self.analyze_expr(bound, state); + } + } + ExprKind::Member(base, _) | ExprKind::Payable(base) => { + self.analyze_lhs_indices(base, state); + } + ExprKind::Tuple(exprs) => { + for expr in exprs.iter().flatten() { + self.analyze_lhs_indices(expr, state); + } + } + _ => {} + } + } + + /// Handles a write to `lhs`: reports pending reentrant calls that read the written state, + /// invalidates written guard locks and forgets path facts about written locals. + fn record_write(&mut self, lhs: &'gcx Expr<'gcx>, state: &mut FlowState) { + let written = state_lhs_vars(self.gcx, lhs); + self.emit_pending_calls(state, &written); + state + .invalidated_balance_guards + .extend(written.iter().filter(|v| self.active_balance_guards.contains(v))); + for_each_lhs_var(self.gcx, lhs, &mut |var_id| { + if !self.gcx.hir.variable(var_id).kind.is_state() { + forget_path_predicates(state, var_id); + } + }); + } + + fn analyze_internal_call( + &mut self, + func_id: FunctionId, + args: &CallArgs<'gcx>, + state: &mut FlowState, + ) -> Vec { + let func = self.gcx.hir.function(func_id); + let Some(body) = func.body.filter(|_| !self.call_stack.contains(&func_id)) else { + return Vec::new(); + }; + + self.seed_balance_parameters(func_id, args, state); + let parameter_predicates = if self.reentrancy_balance_enabled { + func.parameters + .iter() + .map(|¶m| { + arg_for_param(self.gcx, func_id, param, args) + .and_then(|arg| path_predicate(self.gcx, arg)) + }) + .collect() + } else { + Vec::new() + }; + + let key = InlineCallKey { + func_id, + recursive_cut: self.first_recursive_cut(func_id), + balance_only: self.balance_only_analysis, + active_balance_guards: self.active_balance_guards.clone(), + parameter_predicates: parameter_predicates.clone(), + state: state.clone(), + }; + if self.inline_cache.is_in_progress(&key) { + self.clear_function_locals(func_id, state); + return Vec::new(); + } + if let Some((cached, returns)) = self.inline_cache.get(&key).cloned() { + *state = if self.balance_only_analysis { cached.balance_only() } else { cached }; + return returns; + } + + self.inline_cache.start(key.clone()); + if self.reentrancy_balance_enabled { + let slots = vec![BalanceValue::default(); func.returns.len()]; + self.return_collectors.push((func_id, slots)); + } + // Call-site results belong to this invocation; only its evaluation alternatives merge. + let caller_balance_values = std::mem::take(&mut self.call_balance_values); + self.call_stack.push(func_id); + let mut after = state.clone(); + let falls_through = self.analyze_callable(func, body, &mut after); + self.call_stack.pop(); + + let mut returns = Vec::new(); + if self.reentrancy_balance_enabled { + if falls_through { + self.record_return(None, &after); + } + returns = self.return_collectors.pop().expect("return collector is active").1; + remap_return_paths( + &self.gcx.hir, + func_id, + func.parameters, + ¶meter_predicates, + &mut returns, + ); + } + self.call_balance_values = caller_balance_values; + self.clear_function_locals(func_id, &mut after); + if self.balance_only_analysis { + after = after.balance_only(); + } + + self.inline_cache.finish(key, (after.clone(), returns.clone())); + *state = after; + returns + } + + /// Internal functions a call through `callee` may reach, following function-typed locals. + fn internal_callees( + &self, + callee: &'gcx Expr<'gcx>, + state: &FlowState, + ) -> BTreeSet { + if let Some(targets) = + lhs_local_var(self.gcx, callee).and_then(|v| state.internal_function_targets.get(&v)) + { + return targets.clone(); + } + static_internal_callee(self.gcx, callee).into_iter().collect() + } + + fn update_internal_function_target( + &self, + state: &mut FlowState, + var_id: VariableId, + value: &'gcx Expr<'gcx>, + ) { + let targets = self.internal_callees(value, state); + state.internal_function_targets.remove(&var_id); + if !targets.is_empty() { + state.internal_function_targets.insert(var_id, targets); + } + } + + fn merge_call_balance_values(&mut self, span: Span, values: Vec) { + let stored = self.call_balance_values.entry(span).or_default(); + if stored.len() < values.len() { + stored.resize_with(values.len(), BalanceValue::default); + } + for (stored, value) in stored.iter_mut().zip(values) { + stored.merge(value); + } + } + + fn analyze_with_only_balance(&mut self, f: impl FnOnce(&mut Self) -> T) -> T { + let saved = ( + self.reentrancy_eth_enabled, + self.reentrancy_no_eth_enabled, + self.balance_only_analysis, + ); + (self.reentrancy_eth_enabled, self.reentrancy_no_eth_enabled, self.balance_only_analysis) = + (false, false, true); + let result = f(self); + (self.reentrancy_eth_enabled, self.reentrancy_no_eth_enabled, self.balance_only_analysis) = + saved; + result + } + + fn first_recursive_cut(&mut self, func_id: FunctionId) -> Option { + if self.call_stack.is_empty() { + return None; + } + let key = (func_id, self.call_stack.iter().copied().collect::>()); + if let Some(cut) = self.recursive_cuts.get(&key) { + return *cut; + } + let cut = self.first_recursive_cut_from(func_id, &key.1, &mut HashSet::new()); + self.recursive_cuts.insert(key, cut); + cut + } + + /// Depth-first search from `func_id` for the first callee that is currently `active`. + fn first_recursive_cut_from( + &mut self, + func_id: FunctionId, + active: &BTreeSet, + seen: &mut HashSet, + ) -> Option { + if !seen.insert(func_id) { + return None; + } + self.direct_internal_calls(func_id).into_iter().find_map(|callee| { + if active.contains(&callee) { + Some(callee) + } else { + self.first_recursive_cut_from(callee, active, seen) + } + }) + } + + /// Internal functions and modifiers `func_id` invokes directly. + fn direct_internal_calls(&mut self, func_id: FunctionId) -> Vec { + if let Some(calls) = self.direct_internal_calls.get(&func_id) { + return calls.clone(); + } + let mut collector = CallCollector { gcx: self.gcx, calls: BTreeSet::new() }; + let _ = collector.visit_function(self.gcx.hir.function(func_id)); + let calls = collector.calls.into_iter().collect::>(); + self.direct_internal_calls.insert(func_id, calls.clone()); + calls + } + + fn emit_pending_calls(&mut self, state: &FlowState, written_vars: &[VariableId]) { + for (&(span, kind), reads) in &state.pending_calls { + let Some(var_id) = written_vars.iter().find(|v| reads.contains(v)) else { continue }; + if !self.emitted.insert(span) { + continue; + } + let (lint, what) = match kind { + ReentrantCallKind::Eth => (&REENTRANCY_ETH, "uncapped ETH transfer"), + ReentrantCallKind::NoEth => (&REENTRANCY_NO_ETH, "external call"), + }; + let name = self + .gcx + .hir + .variable(*var_id) + .name + .map_or_else(|| "state".to_string(), |name| name.to_string()); + let msg = format!("{what} can be reentered before `{name}` is updated"); + self.ctx.emit_with_msg(lint, span, msg); + } + } + + /// Reports pending balance calls whose stale balance `guard` compares against. + fn emit_balance_calls(&mut self, guard: &'gcx Expr<'gcx>, state: &FlowState) { + for (&span, call) in &state.pending_balance_calls { + if !self.emitted_balance.contains(&span) + && self.guard_has_stale_balance_comparison(guard, span, call, state) + { + self.ctx.emit(&REENTRANCY_BALANCE, span); + self.emitted_balance.insert(span); + } + } + } + + /// True if `expr` compares a balance read before the pending `call` against one read after. + fn guard_has_stale_balance_comparison( + &self, + expr: &'gcx Expr<'gcx>, + span: Span, + call: &PathAlternatives, + state: &FlowState, + ) -> bool { + let expr = expr.peel_parens(); + let recurse = |e, s| self.guard_has_stale_balance_comparison(e, span, call, s); + match &expr.kind { + ExprKind::Binary(lhs, op, rhs) if matches!(op.kind, BinOpKind::And | BinOpKind::Or) => { + if recurse(lhs, state) { + return true; + } + let mut rhs_state = state.clone(); + constrain_boolean_outcome(self.gcx, lhs, op.kind == BinOpKind::And, &mut rhs_state) + && recurse(rhs, &rhs_state) + } + ExprKind::Binary(lhs, op, rhs) => { + let is_comparison = matches!( + op.kind, + BinOpKind::Lt + | BinOpKind::Le + | BinOpKind::Gt + | BinOpKind::Ge + | BinOpKind::Eq + | BinOpKind::Ne + ); + (is_comparison && { + let lhs_dependencies = self.balance_operand_dependencies(lhs, state); + let rhs_dependencies = self.balance_operand_dependencies(rhs, state); + let direct = lhs_dependencies.iter().any(|lhs| { + rhs_dependencies.iter().any(|rhs| { + paths_compatible(&lhs.path, &rhs.path) + && call.iter().any(|path| { + paths_compatible(path, &lhs.path) + && paths_compatible(path, &rhs.path) + }) + && lhs.terms.iter().any(|a| { + rhs.terms.iter().any(|b| { + a.stale_calls.contains(&span) + != b.stale_calls.contains(&span) + }) + }) + }) + }); + let lhs = self.balance_forms(lhs, state); + let rhs = self.balance_forms(rhs, state); + direct + || lhs.iter().any(|lhs| { + rhs.iter().filter_map(|rhs| lhs.combine(rhs, true)).any(|form| { + let [a, b] = form.terms.as_slice() else { return false }; + a.negative != b.negative + && a.stale_calls.contains(&span) + != b.stale_calls.contains(&span) + && call.iter().any(|path| paths_compatible(path, &form.path)) + }) + }) + }) || recurse(lhs, state) + || recurse(rhs, state) + } + ExprKind::Unary(_, inner) | ExprKind::Payable(inner) => recurse(inner, state), + ExprKind::Ternary(cond, true_expr, false_expr) => { + [*cond, *true_expr, *false_expr].into_iter().any(|e| recurse(e, state)) + } + ExprKind::Call(..) => match cast_args(expr) { + Some(args) => args.exprs().any(|arg| recurse(arg, state)), + None => self.call_balance_values.get(&expr.span).is_some_and(|values| { + values.iter().any(|value| value.stale_comparisons.contains(&span)) + }), + }, + ExprKind::Ident(_) => self.gcx.resolved_variable(expr).is_some_and(|var_id| { + state + .balance_comparison_locals + .get(&var_id) + .is_some_and(|calls| calls.contains(&span)) + }), + _ => false, + } + } + + /// Binds the components of `rhs` to the local `targets` they are assigned to. + fn bind_locals( + &mut self, + state: &mut FlowState, + targets: &[Option], + rhs: &'gcx Expr<'gcx>, + op: Option, + ) { + if targets.iter().all(Option::is_none) { + return; + } + let values = self.balance_values(rhs, state); + for (var_id, value) in targets.iter().zip(values) { + let Some(var_id) = *var_id else { continue }; + self.set_balance_local(state, var_id, &value, op); + let paths = + if op.is_some() { PathAlternatives::new() } else { value.self_address_paths }; + self.set_self_address_paths(state, var_id, paths); + } + } + + fn set_self_address_paths( + &self, + state: &mut FlowState, + var_id: VariableId, + paths: PathAlternatives, + ) { + state.self_address_local_paths.remove(&var_id); + if !paths.is_empty() { + state.self_address_local_paths.insert(var_id, paths); + } + } + + /// Makes `var_id` hold `value`; a compound assignment also keeps what the local held before. + fn set_balance_local( + &self, + state: &mut FlowState, + var_id: VariableId, + value: &BalanceValue, + op: Option, + ) { + let forms = match op { + None => value.forms.clone(), + Some(op @ (BinOpKind::Add | BinOpKind::Sub)) => state + .balance_local_forms + .get(&var_id) + .into_iter() + .flatten() + .flat_map(|lhs| { + value.forms.iter().filter_map(move |rhs| lhs.combine(rhs, op == BinOpKind::Sub)) + }) + .collect(), + Some(_) => BTreeSet::new(), + }; + state.balance_local_forms.insert(var_id, forms); + let mut dependencies = value.dependencies.clone(); + let mut balance_paths = value.balance_paths.clone(); + let mut stale_comparisons = value.stale_comparisons.clone(); + if op.is_some() { + dependencies.extend( + state + .balance_local_dependencies + .get(&var_id) + .into_iter() + .flatten() + .filter_map(|form| form.constrained(&state.path_predicates)), + ); + balance_paths + .extend(state.balance_local_paths.get(&var_id).into_iter().flatten().cloned()); + stale_comparisons + .extend(state.balance_comparison_locals.get(&var_id).into_iter().flatten()); + } + state.balance_local_dependencies.insert(var_id, dependencies); + state.balance_local_paths.remove(&var_id); + state.balance_comparison_locals.remove(&var_id); + if !balance_paths.is_empty() { + state.balance_local_paths.insert(var_id, balance_paths); + } + if !stale_comparisons.is_empty() { + state.balance_comparison_locals.insert(var_id, stale_comparisons); + } + } + + /// Balance facts of each component of `rhs`, one per destructuring target. + fn balance_values(&self, rhs: &'gcx Expr<'gcx>, state: &FlowState) -> Vec { + if let Some(elems) = tuple_elems(rhs) { + return elems + .iter() + .map(|e| e.map(|e| self.balance_dependency(e, state)).unwrap_or_default()) + .collect(); + } + match self.call_balance_values.get(&rhs.peel_parens().span) { + Some(values) if !values.is_empty() => { + values.iter().map(|v| v.constrained(&state.path_predicates)).collect() + } + _ => vec![self.balance_dependency(rhs, state)], + } + } + + fn balance_dependency(&self, expr: &'gcx Expr<'gcx>, state: &FlowState) -> BalanceValue { + let pending = &state.pending_balance_calls; + BalanceValue { + forms: self.balance_forms(expr, state), + dependencies: self.balance_operand_dependencies(expr, state), + balance_paths: self.expr_balance_paths(expr, state), + self_address_paths: self.self_address_path(expr, state), + stale_comparisons: pending + .iter() + .filter(|(span, call)| { + self.guard_has_stale_balance_comparison(expr, **span, call, state) + }) + .map(|(span, _)| *span) + .collect(), + } + } + + /// Predicates under which `expr` evaluates to `address(this)`. + fn self_address_path(&self, expr: &Expr<'_>, state: &FlowState) -> PathAlternatives { + let expr = expr.peel_parens(); + match &expr.kind { + ExprKind::Payable(inner) => self.self_address_path(inner, state), + ExprKind::Call(callee, args) + if callee.split_call_options().1.is_none() + && is_address_cast(callee) + && args.len() == 1 => + { + self.self_address_path(args.exprs().next().expect("one argument"), state) + } + ExprKind::Call(..) => self + .call_balance_values + .get(&expr.span) + .and_then(|values| values.first()) + .map(|value| constrain_paths(&value.self_address_paths, &state.path_predicates)) + .unwrap_or_default(), + ExprKind::Ident(_) if is_builtin(self.gcx, expr, sym::this) => { + BTreeSet::from([state.path_predicates.clone()]) + } + ExprKind::Ident(_) => self + .gcx + .resolved_variable(expr) + .into_iter() + .filter_map(|v| state.self_address_local_paths.get(&v)) + .flat_map(|paths| constrain_paths(paths, &state.path_predicates)) + .collect(), + _ => PathAlternatives::new(), + } + } + + /// Predicates under which `expr` is `.balance`. + fn self_balance_paths(&self, expr: &Expr<'_>, state: &FlowState) -> PathAlternatives { + match &expr.peel_parens().kind { + ExprKind::Member(base, member) if member.name == kw::Balance => { + self.self_address_path(base, state) + } + _ => PathAlternatives::new(), + } + } + + /// Predicates under which `expr` derives from the contract's own balance. + fn expr_balance_paths(&self, expr: &'gcx Expr<'gcx>, state: &FlowState) -> PathAlternatives { + let expr = expr.peel_parens(); + let self_balance = self.self_balance_paths(expr, state); + if !self_balance.is_empty() { + return self_balance; + } + let recurse = |e| self.expr_balance_paths(e, state); + match &expr.kind { + ExprKind::Ident(_) => self + .gcx + .resolved_variable(expr) + .into_iter() + .filter_map(|v| state.balance_local_paths.get(&v)) + .flat_map(|paths| constrain_paths(paths, &state.path_predicates)) + .collect(), + ExprKind::Unary(_, inner) | ExprKind::Payable(inner) => recurse(inner), + ExprKind::Binary(lhs, _, rhs) => [*lhs, *rhs].into_iter().flat_map(recurse).collect(), + ExprKind::Ternary(cond, true_expr, false_expr) => { + [*cond, *true_expr, *false_expr].into_iter().flat_map(recurse).collect() + } + ExprKind::Call(..) => match cast_args(expr) { + Some(args) => args.exprs().flat_map(recurse).collect(), + None => self + .call_balance_values + .get(&expr.span) + .into_iter() + .flatten() + .flat_map(|value| constrain_paths(&value.balance_paths, &state.path_predicates)) + .collect(), + }, + _ => PathAlternatives::new(), + } + } + + /// Retains balance occurrences across comparison operands without assuming arithmetic + /// identities. + fn balance_operand_dependencies( + &self, + expr: &'gcx Expr<'gcx>, + state: &FlowState, + ) -> BTreeSet { + let recurse = |expr| self.balance_operand_dependencies(expr, state); + match &expr.peel_parens().kind { + ExprKind::Binary(lhs, _, rhs) => [*lhs, *rhs].into_iter().flat_map(recurse).collect(), + ExprKind::Unary(_, inner) | ExprKind::Payable(inner) => recurse(inner), + ExprKind::Ternary(cond, true_expr, false_expr) => { + let mut then_state = state.clone(); + let mut else_state = state.clone(); + let (then_reachable, else_reachable) = + self.split_on(cond, &mut then_state, &mut else_state); + [(true_expr, then_state, then_reachable), (false_expr, else_state, else_reachable)] + .into_iter() + .filter(|(_, _, reachable)| *reachable) + .flat_map(|(expr, state, _)| self.balance_operand_dependencies(expr, &state)) + .collect() + } + ExprKind::Call(..) if let Some(args) = cast_args(expr) => { + args.exprs().flat_map(recurse).collect() + } + ExprKind::Ident(_) => self + .gcx + .resolved_variable(expr) + .into_iter() + .filter_map(|var| state.balance_local_dependencies.get(&var)) + .flatten() + .filter_map(|form| form.constrained(&state.path_predicates)) + .collect(), + ExprKind::Call(..) => self + .call_balance_values + .get(&expr.peel_parens().span) + .into_iter() + .flatten() + .flat_map(|value| &value.dependencies) + .filter_map(|form| form.constrained(&state.path_predicates)) + .collect(), + _ => self.balance_forms(expr, state), + } + } + + /// Preserves balance terms through addition/subtraction and value-preserving integer casts. + /// Other operators may form independent offsets, but cannot transform balance terms. + /// Unsupported expressions return no forms, rather than an independent offset. + fn balance_forms(&self, expr: &'gcx Expr<'gcx>, state: &FlowState) -> BTreeSet { + let expr = expr.peel_parens(); + let paths = self.self_balance_paths(expr, state); + if !paths.is_empty() { + return paths + .into_iter() + .map(|path| BalanceForm { + terms: vec![BalanceTerm { negative: false, stale_calls: BTreeSet::new() }], + path, + }) + .collect(); + } + let independent = || { + BTreeSet::from([BalanceForm { + path: state.path_predicates.clone(), + ..BalanceForm::default() + }]) + }; + match &expr.kind { + ExprKind::Lit(_) => independent(), + ExprKind::Ident(_) => { + let Some(var) = self.gcx.resolved_variable(expr) else { + return BTreeSet::new(); + }; + match state.balance_local_forms.get(&var) { + Some(forms) => forms + .iter() + .filter_map(|form| form.constrained(&state.path_predicates)) + .collect(), + None => independent(), + } + } + ExprKind::Ternary(cond, true_expr, false_expr) => { + let mut then_state = state.clone(); + let mut else_state = state.clone(); + let (then_reachable, else_reachable) = + self.split_on(cond, &mut then_state, &mut else_state); + [(true_expr, then_state, then_reachable), (false_expr, else_state, else_reachable)] + .into_iter() + .filter(|(_, _, reachable)| *reachable) + .flat_map(|(expr, state, _)| self.balance_forms(expr, &state)) + .collect() + } + ExprKind::Binary(lhs, op, rhs) => { + let lhs = self.balance_forms(lhs, state); + let rhs = self.balance_forms(rhs, state); + lhs.iter() + .flat_map(|lhs| { + rhs.iter().filter_map(|rhs| { + if matches!(op.kind, BinOpKind::Add | BinOpKind::Sub) + || (lhs.terms.is_empty() && rhs.terms.is_empty()) + { + lhs.combine(rhs, op.kind == BinOpKind::Sub) + } else { + None + } + }) + }) + .collect() + } + ExprKind::Call(callee, args) if cast_type(callee).is_some() && args.len() == 1 => { + let inner = args.exprs().next().expect("one argument"); + let preserving = match ( + self.gcx.type_of_expr(inner.peel_parens().id), + self.gcx.type_of_expr(expr.peel_parens().id), + ) { + (Some(from), Some(to)) => { + from.is_integer() + && to.is_integer() + && from.is_signed() == to.is_signed() + && from.convert_implicit_to(to, self.gcx) + } + _ => false, + }; + self.balance_forms(inner, state) + .into_iter() + .filter(|form| preserving || form.terms.is_empty()) + .collect() + } + ExprKind::Call(..) => self + .call_balance_values + .get(&expr.span) + .into_iter() + .flatten() + .flat_map(|value| { + value.forms.iter().filter_map(|form| form.constrained(&state.path_predicates)) + }) + .collect(), + _ => BTreeSet::new(), + } + } + + /// Binds the balance facts of the call arguments to the callee's parameters. + fn seed_balance_parameters( + &mut self, + func_id: FunctionId, + args: &CallArgs<'gcx>, + state: &mut FlowState, + ) { + if !self.reentrancy_balance_enabled { + return; + } + for ¶m in self.gcx.hir.function(func_id).parameters { + match arg_for_param(self.gcx, func_id, param, args) { + Some(arg) => self.bind_locals(state, &[Some(param)], arg, None), + None => self.clear_local(state, param), + } + } + } + + fn clear_local(&self, state: &mut FlowState, var_id: VariableId) { + self.set_balance_local(state, var_id, &BalanceValue::default(), None); + self.set_self_address_paths(state, var_id, PathAlternatives::new()); + } + + /// Accumulates the values returned by `return expr;` (or the named return variables when + /// the function falls through) into the innermost return collector. + fn record_return(&mut self, expr: Option<&'gcx Expr<'gcx>>, state: &FlowState) { + let Some(&(func_id, _)) = self.return_collectors.last() else { return }; + let values = match expr { + Some(expr) => self.balance_values(expr, state), + None => self + .gcx + .hir + .function(func_id) + .returns + .iter() + .map(|&var_id| self.local_value(var_id, state)) + .collect(), + }; + let (_, stored) = self.return_collectors.last_mut().expect("return collector is active"); + for (stored, value) in stored.iter_mut().zip(values) { + stored.merge(value); + } + } + + /// The balance facts currently recorded for the local `var_id`. + fn local_value(&self, var_id: VariableId, state: &FlowState) -> BalanceValue { + BalanceValue { + forms: state.balance_local_forms.get(&var_id).cloned().unwrap_or_default(), + dependencies: state + .balance_local_dependencies + .get(&var_id) + .cloned() + .unwrap_or_default(), + balance_paths: state.balance_local_paths.get(&var_id).cloned().unwrap_or_default(), + self_address_paths: state + .self_address_local_paths + .get(&var_id) + .cloned() + .unwrap_or_default(), + stale_comparisons: state + .balance_comparison_locals + .get(&var_id) + .cloned() + .unwrap_or_default(), + } + } + + /// Drops every fact about locals of `func_id` once its inlined body is left. + fn clear_function_locals(&self, func_id: FunctionId, state: &mut FlowState) { + let owned = owned_by(&self.gcx.hir, func_id); + state.internal_function_targets.retain(|v, _| !owned(*v)); + state.self_address_local_paths.retain(|v, _| !owned(*v)); + state.balance_local_paths.retain(|v, _| !owned(*v)); + state.balance_local_forms.retain(|v, _| !owned(*v)); + state.balance_local_dependencies.retain(|v, _| !owned(*v)); + state.balance_comparison_locals.retain(|v, _| !owned(*v)); + state.path_predicates.retain(|predicate, _| !predicate.mentions(&owned)); + } + + fn reentrant_call_kind( + &self, + callee: &'gcx Expr<'gcx>, + opts: Option<&CallOptions<'gcx>>, + ) -> Option { + if self.reentrancy_eth_enabled && is_uncapped_value_call(self.gcx, callee, opts) { + Some(ReentrantCallKind::Eth) + } else if self.reentrancy_no_eth_enabled + && !call_sends_eth(self.gcx, opts) + && callee_can_reenter(self.gcx, callee) + { + Some(ReentrantCallKind::NoEth) + } else { + None + } + } + + /// True if the contract-wide reentrancy lock is held and intact around the call. + fn balance_guard_blocks_call(&self, state: &FlowState, callee: &'gcx Expr<'gcx>) -> bool { + !call_uses_delegate_context(self.gcx, callee) + && self.balance_reentry_lock.is_some_and(|lock| { + self.active_balance_guards.contains(&lock) + && !state.invalidated_balance_guards.contains(&lock) + }) + } +} + +/// Collects the internal functions and modifiers a function invokes directly. +struct CallCollector<'gcx> { + gcx: Gcx<'gcx>, + calls: BTreeSet, +} + +impl<'gcx> Visit<'gcx> for CallCollector<'gcx> { + type BreakValue = Never; + + fn hir(&self) -> &'gcx hir::Hir<'gcx> { + &self.gcx.hir + } + + fn visit_modifier( + &mut self, + modifier: &'gcx hir::Modifier<'gcx>, + ) -> ControlFlow { + self.calls.extend(modifier.id.as_function()); + self.visit_call_args(&modifier.args) + } + + fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow { + collect_internal_calls(self.gcx, expr, &mut self.calls); + ControlFlow::Continue(()) + } +} + +fn collect_internal_calls(gcx: Gcx<'_>, expr: &Expr<'_>, calls: &mut BTreeSet) { + if let ExprKind::Call(callee, ..) = &expr.kind { + calls.extend(static_internal_callee(gcx, callee)); + } + for_each_child(expr, &mut |child| collect_internal_calls(gcx, child, calls)); +} + +/// Internal function statically named by `callee`: a bare identifier or `super.f`. +fn static_internal_callee(gcx: Gcx<'_>, callee: &Expr<'_>) -> Option { + let callee = callee.peel_parens(); + let direct = match &callee.kind { + ExprKind::Ident(_) => true, + ExprKind::Member(base, _) => is_builtin(gcx, base, sym::super_), + _ => false, + }; + let TyKind::Fn(function) = gcx.type_of_expr(callee.id).filter(|_| direct)?.kind else { + return None; + }; + function.is_internal().then_some(function.function_id).flatten() +} + +/// The variables of `func_id`. +fn owned_by(hir: &hir::Hir<'_>, func_id: FunctionId) -> impl Fn(VariableId) -> bool { + move |var_id| hir.variable(var_id).parent == Some(ItemId::Function(func_id)) +} + +/// The boolean fact `expr` establishes when it evaluates to `true`: a local flag, its negation, +/// or an `==`/`!=` between locals and literals. +fn path_predicate(gcx: Gcx<'_>, expr: &Expr<'_>) -> Option<(PathPredicate, bool)> { + match &expr.peel_parens().kind { + ExprKind::Ident(_) => Some((PathPredicate::Boolean(lhs_local_var(gcx, expr)?), true)), + ExprKind::Unary(op, inner) if op.kind == UnOpKind::Not => { + path_predicate(gcx, inner).map(|(predicate, value)| (predicate, !value)) + } + ExprKind::Binary(lhs, op, rhs) if matches!(op.kind, BinOpKind::Eq | BinOpKind::Ne) => { + let (lhs, rhs) = (predicate_operand(gcx, lhs)?, predicate_operand(gcx, rhs)?); + let predicate = PathPredicate::Equality(lhs.min(rhs), lhs.max(rhs)); + Some((predicate, op.kind == BinOpKind::Eq)) + } + _ => None, + } +} + +fn predicate_operand(gcx: Gcx<'_>, expr: &Expr<'_>) -> Option { + match &expr.peel_parens().kind { + ExprKind::Ident(_) => Some(Operand::Variable(lhs_local_var(gcx, expr)?)), + ExprKind::Lit(lit) => match lit.kind { + LitKind::Number(value) => Some(Operand::Number(value)), + LitKind::Bool(value) => Some(Operand::Boolean(value)), + _ => None, + }, + _ => None, + } +} + +/// Records in `state` that `expr` evaluated to `outcome`; returns `false` if that is impossible. +fn constrain_boolean_outcome( + gcx: Gcx<'_>, + expr: &Expr<'_>, + outcome: bool, + state: &mut FlowState, +) -> bool { + if let Some((predicate, value)) = path_predicate(gcx, expr) { + return state.constrain_path((predicate, value == outcome)); + } + match &expr.peel_parens().kind { + // `a && b` being true (or `a || b` being false) fixes both operands. + ExprKind::Binary(lhs, op, rhs) + if op.kind == if outcome { BinOpKind::And } else { BinOpKind::Or } => + { + constrain_boolean_outcome(gcx, lhs, outcome, state) + && constrain_boolean_outcome(gcx, rhs, outcome, state) + } + _ => true, + } +} + +fn common_path_predicates(lhs: &PathPredicates, rhs: &PathPredicates) -> PathPredicates { + lhs.iter().filter(|(p, v)| rhs.get(p) == Some(v)).map(|(p, v)| (*p, *v)).collect() +} + +fn paths_compatible(lhs: &PathPredicates, rhs: &PathPredicates) -> bool { + lhs.iter().all(|(p, v)| rhs.get(p).is_none_or(|other| other == v)) +} + +/// Keeps the `paths` compatible with `active`, extended by the active predicates. +fn constrain_paths(paths: &PathAlternatives, active: &PathPredicates) -> PathAlternatives { + paths + .iter() + .filter(|path| paths_compatible(path, active)) + .map(|path| path.iter().chain(active).map(|(p, v)| (*p, *v)).collect()) + .collect() +} + +/// Rewrites the callee-local predicates in the returned `values` into the caller's terms. +fn remap_return_paths( + hir: &hir::Hir<'_>, + func_id: FunctionId, + parameters: &[VariableId], + parameter_predicates: &[Option<(PathPredicate, bool)>], + values: &mut [BalanceValue], +) { + let owned = owned_by(hir, func_id); + let remap = |mut path: PathPredicates| { + for (¶meter, &argument) in parameters.iter().zip(parameter_predicates) { + let Some(parameter_value) = path.remove(&PathPredicate::Boolean(parameter)) else { + continue; + }; + if let Some((predicate, argument_value)) = argument { + let mapped = parameter_value == argument_value; + if path.get(&predicate).is_some_and(|existing| *existing != mapped) { + return None; + } + path.insert(predicate, mapped); + } + } + path.retain(|predicate, _| !predicate.mentions(&owned)); + Some(path) + }; + for value in values { + value.balance_paths = + std::mem::take(&mut value.balance_paths).into_iter().filter_map(remap).collect(); + value.self_address_paths = + std::mem::take(&mut value.self_address_paths).into_iter().filter_map(remap).collect(); + for forms in [&mut value.forms, &mut value.dependencies] { + *forms = std::mem::take(forms) + .into_iter() + .filter_map(|form| Some(BalanceForm { path: remap(form.path)?, ..form })) + .collect(); + } + } +} + +/// Drops every path fact that mentions the (re)assigned local `var_id`. +fn forget_path_predicates(state: &mut FlowState, var_id: VariableId) { + let mentions = |predicate: &PathPredicate| predicate.mentions(|v| v == var_id); + state.path_predicates.retain(|p, _| !mentions(p)); + let strip = |paths: &mut PathAlternatives| { + *paths = paths + .iter() + .map(|path| path.iter().filter(|(p, _)| !mentions(p)).map(|(p, v)| (*p, *v)).collect()) + .collect(); + }; + for forms in + state.balance_local_forms.values_mut().chain(state.balance_local_dependencies.values_mut()) + { + *forms = std::mem::take(forms) + .into_iter() + .map(|mut form| { + form.path.retain(|p, _| !mentions(p)); + form + }) + .collect(); + } + state.balance_local_paths.values_mut().for_each(strip); + state.self_address_local_paths.values_mut().for_each(strip); + state.pending_balance_calls.values_mut().for_each(strip); +} + +/// Arguments of a plain type conversion such as `uint256(x)` or `address(x)`. +fn cast_args<'a>(expr: &'a Expr<'a>) -> Option<&'a CallArgs<'a>> { + let (callee, args, opts) = expr.peel_parens().as_call()?; + (opts.is_none() && matches!(callee.kind, ExprKind::Type(_) | ExprKind::TypeCall(_))) + .then_some(args) +} + +fn call_option<'a>(opts: Option<&'a CallOptions<'a>>, name: Symbol) -> Option<&'a Expr<'a>> { + opts?.args.iter().find(|opt| opt.name.name == name).map(|opt| &opt.value) +} + +fn call_sends_eth(gcx: Gcx<'_>, opts: Option<&CallOptions<'_>>) -> bool { + call_option(opts, sym::value).is_some_and(|value| !is_zero_value(gcx, value)) +} + +/// `.call{value: v}(...)` with a non-zero value and no gas cap other than `gasleft()`. +fn is_uncapped_value_call(gcx: Gcx<'_>, callee: &Expr<'_>, opts: Option<&CallOptions<'_>>) -> bool { + matches!(&callee.peel_parens().kind, ExprKind::Member(_, member) if member.name == kw::Call) + && call_sends_eth(gcx, opts) + && call_option(opts, kw::Gas).is_none_or(|gas| { + matches!(&gas.peel_parens().kind, ExprKind::Call(callee, args) + if callee.split_call_options().1.is_none() + && args.is_empty() + && is_builtin(gcx, callee, sym::gasleft)) + }) +} + +/// True unless a `gas:` option provably leaves the callee too little gas to reenter. +fn call_options_allow_reentrancy(gcx: Gcx<'_>, opts: Option<&CallOptions<'_>>) -> bool { + let Some(gas) = call_option(opts, kw::Gas) else { return true }; + let sends_eth = call_sends_eth(gcx, opts); + match const_value(gcx, gas, None, &mut BTreeSet::new()) { + Some(Operand::Number(gas)) => { + gas > U256::from(REENTRANCY_GAS_STIPEND) || (sends_eth && !gas.is_zero()) + } + _ => true, + } +} + +fn is_zero_value(gcx: Gcx<'_>, expr: &Expr<'_>) -> bool { + matches!(const_value(gcx, expr, None, &mut BTreeSet::new()), Some(Operand::Number(n)) if n.is_zero()) +} + +/// `break`/`continue` or anything that exits the function, so the current path stops here. +fn branch_stops_current_path(gcx: Gcx<'_>, stmt: &Stmt<'_>) -> bool { + match &stmt.kind { + StmtKind::Break | StmtKind::Continue => true, + StmtKind::Block(block) | StmtKind::UncheckedBlock(block) => { + block.stmts.iter().any(|expr| branch_stops_current_path(gcx, expr)) + } + StmtKind::If(_, then_stmt, Some(else_stmt)) => { + branch_stops_current_path(gcx, then_stmt) && branch_stops_current_path(gcx, else_stmt) + } + _ => branch_always_exits(gcx, stmt), + } +} + +/// The lock state variable of a standard reentrancy guard modifier: it rejects re-entry, sets +/// the lock, runs `_` exactly once and restores the lock right after. +fn standard_reentrancy_guard_lock( + gcx: Gcx<'_>, + modifier: &hir::Function<'_>, +) -> Option { + if !matches!(modifier.kind, FunctionKind::Modifier) || !modifier.modifiers.is_empty() { + return None; + } + let stmts = modifier.body?.stmts; + if count_placeholders(stmts) != 1 { + return None; + } + let mut activation = Vec::new(); + stmts_before_placeholder(stmts, &mut activation)?; + let (lock_var, entered) = guard_activation(gcx, &activation, &mut BTreeSet::new())?; + let index = stmts.iter().position(|s| count_placeholders(std::slice::from_ref(s)) == 1)?; + let (restored_var, restored) = guard_restoration(gcx, stmts.get(index + 1)?)?; + (lock_var == restored_var && entered != restored).then_some(lock_var) +} + +/// The lock and value set by the last of `stmts` (directly or via an argument-less helper), if +/// an earlier statement rejects that value. +fn guard_activation( + gcx: Gcx<'_>, + stmts: &[&Stmt<'_>], + seen: &mut BTreeSet, +) -> Option<(VariableId, Operand)> { + let (activation, prefix) = stmts.split_last()?; + if let Some((lock_var, entered)) = state_lock_assignment(gcx, activation) { + return prefix + .iter() + .any(|stmt| stmt_rejects_lock_value(gcx, stmt, lock_var, entered)) + .then_some((lock_var, entered)); + } + let helper_id = simple_internal_call(gcx, activation)?; + let helper = gcx.hir.function(helper_id); + if !helper.modifiers.is_empty() || !seen.insert(helper_id) { + return None; + } + let body = helper.body?.stmts.iter().collect::>(); + let result = guard_activation(gcx, &body, seen); + seen.remove(&helper_id); + result +} + +/// The lock and value restored by `stmt` (directly or via a single-statement helper). +fn guard_restoration(gcx: Gcx<'_>, stmt: &Stmt<'_>) -> Option<(VariableId, Operand)> { + state_lock_assignment(gcx, stmt).or_else(|| { + let helper = gcx.hir.function(simple_internal_call(gcx, stmt)?); + let [stmt] = helper.modifiers.is_empty().then_some(helper.body?.stmts)? else { + return None; + }; + state_lock_assignment(gcx, stmt) + }) +} + +/// `f();` naming exactly one function. +fn simple_internal_call(gcx: Gcx<'_>, stmt: &Stmt<'_>) -> Option { + let StmtKind::Expr(expr) = stmt.kind else { return None }; + let (callee, args, opts) = expr.peel_parens().as_call()?; + (opts.is_none() && args.is_empty() && matches!(callee.kind, ExprKind::Ident(_))) + .then(|| gcx.resolved_function(callee)) + .flatten() +} + +/// `lock = ;` on a state variable. +fn state_lock_assignment(gcx: Gcx<'_>, stmt: &Stmt<'_>) -> Option<(VariableId, Operand)> { + let StmtKind::Expr(expr) = stmt.kind else { return None }; + let ExprKind::Assign(lhs, None, rhs) = &expr.peel_parens().kind else { return None }; + let ExprKind::Ident(_) = &lhs.peel_parens().kind else { return None }; + let lock_var = gcx.resolved_variable(lhs).filter(|&v| gcx.hir.variable(v).kind.is_state())?; + Some((lock_var, const_value(gcx, rhs, None, &mut BTreeSet::new())?)) +} + +/// True if `stmt` reverts whenever `lock_var` holds `entered`. +fn stmt_rejects_lock_value( + gcx: Gcx<'_>, + stmt: &Stmt<'_>, + lock_var: VariableId, + entered: Operand, +) -> bool { + let eval = |cond| match const_value(gcx, cond, Some((lock_var, entered)), &mut BTreeSet::new()) + { + Some(Operand::Boolean(value)) => Some(value), + _ => None, + }; + match stmt.kind { + StmtKind::Expr(expr) => { + let ExprKind::Call(callee, args) = &expr.peel_parens().kind else { return false }; + is_require_or_assert(gcx, callee) + && args.exprs().next().is_some_and(|cond| eval(cond) == Some(false)) + } + StmtKind::If(cond, then_stmt, else_stmt) => match eval(cond) { + Some(true) => branch_always_exits(gcx, then_stmt), + Some(false) => else_stmt.is_some_and(|expr| branch_always_exits(gcx, expr)), + None => false, + }, + _ => false, + } +} + +/// Constant-folds `expr` over literals, `constant` variables, casts, `!` and `==`/`!=`; `lock` +/// supplies the value of the lock variable. +fn const_value( + gcx: Gcx<'_>, + expr: &Expr<'_>, + lock: Option<(VariableId, Operand)>, + seen: &mut BTreeSet, +) -> Option { + let expr = expr.peel_parens(); + match &expr.kind { + ExprKind::Lit(lit) => match lit.kind { + LitKind::Bool(value) => Some(Operand::Boolean(value)), + LitKind::Number(value) => Some(Operand::Number(value)), + _ => None, + }, + ExprKind::Ident(_) => { + let var_id = gcx.resolved_variable(expr)?; + if let Some((lock_var, entered)) = lock + && lock_var == var_id + { + return Some(entered); + } + let var = gcx.hir.variable(var_id); + if !var.is_constant() || !seen.insert(var_id) { + return None; + } + let value = const_value(gcx, var.initializer?, lock, seen); + seen.remove(&var_id); + value + } + ExprKind::Unary(op, inner) if op.kind == UnOpKind::Not => { + match const_value(gcx, inner, lock, seen)? { + Operand::Boolean(value) => Some(Operand::Boolean(!value)), + _ => None, + } + } + ExprKind::Binary(lhs, op, rhs) if matches!(op.kind, BinOpKind::Eq | BinOpKind::Ne) => { + let lhs = const_value(gcx, lhs, lock, seen)?; + let rhs = const_value(gcx, rhs, lock, seen)?; + Some(Operand::Boolean((lhs == rhs) == (op.kind == BinOpKind::Eq))) + } + ExprKind::Call(..) => { + let args = cast_args(expr).filter(|args| args.len() == 1)?; + const_value(gcx, args.exprs().next()?, lock, seen) + } + _ => None, + } +} + +/// The reentrancy-guard lock, if any, that protects every entry point of every deployable +/// contract exposing `entry`. +fn balance_reentry_lock<'gcx>( + gcx: Gcx<'gcx>, + entry: &'gcx hir::Function<'gcx>, +) -> Option { + let entry_id = gcx.hir.function_ids().find(|&id| std::ptr::eq(gcx.hir.function(id), entry))?; + let defining_contract = entry.contract?; + guard_locks(gcx, entry).into_iter().find(|&lock_var| { + let mut deployed = false; + for contract_id in gcx.hir.contract_ids() { + let contract = gcx.hir.contract(contract_id); + if !contract.can_be_deployed() + || contract.is_abstract() + || !contract.linearized_bases.contains(&defining_contract) + { + continue; + } + let interface = gcx.interface_functions(contract_id); + let special = || [contract.fallback, contract.receive].into_iter().flatten(); + if !interface.iter().any(|f| f.id == entry_id) && !special().any(|id| id == entry_id) { + continue; + } + deployed = true; + let guarded = interface + .iter() + .map(|f| gcx.hir.function(f.id)) + .filter(|f| !is_view_or_pure(f.state_mutability)) + .chain(special().map(|id| gcx.hir.function(id))) + .all(|f| guard_locks(gcx, f).contains(&lock_var)); + if !guarded { + return false; + } + } + deployed + }) +} + +/// Locks of the standard reentrancy guards among `function`'s modifiers. +fn guard_locks(gcx: Gcx<'_>, function: &hir::Function<'_>) -> Vec { + function + .modifiers + .iter() + .filter(|modifier| modifier.args.is_empty()) + .filter_map(|modifier| modifier.id.as_function()) + .filter_map(|id| standard_reentrancy_guard_lock(gcx, gcx.hir.function(id))) + .collect() +} + +/// `delegatecall`/`callcode`, which run the callee in the caller's storage context. +fn call_uses_delegate_context(gcx: Gcx<'_>, callee: &Expr<'_>) -> bool { + let callee = callee.peel_parens(); + gcx.resolved_builtin(callee) == Some(Builtin::AddressDelegatecall) + || matches!(&callee.kind, ExprKind::Member(_, member) if member.name == kw::Callcode) + || gcx.type_of_expr(callee.id).is_some_and( + |ty| matches!(ty.kind, TyKind::Fn(function) if function.is_delegate_call()), + ) +} + +/// An external call that hands control to another contract: a low-level `call`/`callcode`/ +/// `delegatecall` on an address, or a state-changing external function call. +fn callee_can_reenter<'gcx>(gcx: Gcx<'gcx>, callee: &Expr<'gcx>) -> bool { + let callee = callee.peel_parens(); + match gcx.resolved_builtin(callee) { + Some(Builtin::AddressCall | Builtin::AddressDelegatecall) => return true, + Some(Builtin::AddressStaticcall) => return false, + _ => {} + } + match &callee.kind { + ExprKind::Member(receiver, member) + if expr_is_address(gcx, receiver) && member.name == kw::Callcode => + { + true + } + ExprKind::Member(receiver, _) if is_builtin(gcx, receiver, sym::super_) => false, + _ => { + let Some(TyKind::Fn(function)) = gcx.type_of_expr(callee.id).map(|ty| ty.kind) else { + return false; + }; + matches!( + function.kind, + TyFnKind::External | TyFnKind::Declaration | TyFnKind::DelegateCall + ) && !is_view_or_pure(function.state_mutability) + } + } +} diff --git a/crates/lint/src/sol/high/rtlo.rs b/crates/lint/src/sol/high/rtlo.rs new file mode 100644 index 0000000000000..2b52d9aee97c0 --- /dev/null +++ b/crates/lint/src/sol/high/rtlo.rs @@ -0,0 +1,58 @@ +use super::Rtlo; +use crate::{ + linter::{EarlyLintPass, Lint, LintContext}, + sol::{Severity, SolLint}, +}; +use solar::{ + ast, + interface::{BytePos, Span}, +}; + +declare_forge_lint!( + RTLO, + Severity::High, + "rtlo", + "bidirectional Unicode override character can hide malicious code" +); + +impl<'ast> EarlyLintPass<'ast> for Rtlo { + fn check_full_source_unit( + &mut self, + ctx: &LintContext<'ast, '_>, + _unit: &'ast ast::SourceUnit<'ast>, + ) { + if !ctx.is_lint_enabled(RTLO.id()) { + return; + } + + // Scan the raw source so bidi chars in comments are also caught. + let Some(file) = ctx.source_file() else { return }; + + for (offset, ch) in file.src.char_indices() { + let Some(name) = bidi_char_name(ch) else { continue }; + + let lo = file.start_pos + BytePos::from_usize(offset); + let hi = lo + BytePos::from_usize(ch.len_utf8()); + let span = Span::new(lo, hi); + + ctx.emit_with_msg(&RTLO, span, format!("`U+{:04X}` ({name}) detected", ch as u32)); + } + } +} + +const fn bidi_char_name(ch: char) -> Option<&'static str> { + Some(match ch { + '\u{200E}' => "Left-to-Right Mark", + '\u{200F}' => "Right-to-Left Mark", + '\u{202A}' => "Left-to-Right Embedding", + '\u{202B}' => "Right-to-Left Embedding", + '\u{202C}' => "Pop Directional Formatting", + '\u{202D}' => "Left-to-Right Override", + '\u{202E}' => "Right-to-Left Override", + '\u{2066}' => "Left-to-Right Isolate", + '\u{2067}' => "Right-to-Left Isolate", + '\u{2068}' => "First Strong Isolate", + '\u{2069}' => "Pop Directional Isolate", + _ => return None, + }) +} diff --git a/crates/lint/src/sol/high/unchecked_calls.rs b/crates/lint/src/sol/high/unchecked_calls.rs index 65eef5fee6cb2..216bebfa21d7b 100644 --- a/crates/lint/src/sol/high/unchecked_calls.rs +++ b/crates/lint/src/sol/high/unchecked_calls.rs @@ -1,160 +1,95 @@ use super::{UncheckedCall, UncheckedTransferERC20}; use crate::{ - linter::{EarlyLintPass, LintContext}, - sol::{Severity, SolLint}, + linter::{EarlyLintPass, LateLintPass, LintContext}, + sol::{ + Severity, SolLint, + analysis::{is_elementary, is_low_level_call, receiver_contract_id}, + }, +}; +use solar::{ + ast::{ExprKind, Stmt, StmtKind}, + sema::{Gcx, hir}, }; -use solar_ast::{Expr, ExprKind, ItemFunction, Stmt, StmtKind, visit::Visit}; -use solar_interface::kw; -use std::ops::ControlFlow; declare_forge_lint!( UNCHECKED_CALL, Severity::High, "unchecked-call", - "Low-level calls should check the success return value" + "low-level call does not check the success return value" ); declare_forge_lint!( ERC20_UNCHECKED_TRANSFER, Severity::High, "erc20-unchecked-transfer", - "ERC20 'transfer' and 'transferFrom' calls should check the return value" + "ERC20 `transfer` or `transferFrom` call does not check the return value" ); -// -- ERC20 UNCKECKED TRANSFERS ------------------------------------------------------------------- - -/// WARN: can issue false positives. It does not check that the contract being called is an ERC20. -/// TODO: re-implement using `LateLintPass` so that it can't issue false positives. -impl<'ast> EarlyLintPass<'ast> for UncheckedTransferERC20 { - fn check_item_function(&mut self, ctx: &LintContext<'_>, func: &'ast ItemFunction<'ast>) { - if let Some(body) = &func.body { - let mut checker = UncheckedTransferERC20Checker { ctx }; - let _ = checker.visit_block(body); - } - } -} - -/// Visitor that detects unchecked ERC20 transfer calls within function bodies. +/// Checks that calls to functions with the same signature as the ERC20 transfer methods, and which +/// return a boolean, are not ignored. /// -/// Unchecked transfers appear as standalone expression statements. -/// When a transfer's return value is used (in require, assignment, etc.), it's part -/// of a larger expression and won't be flagged. -struct UncheckedTransferERC20Checker<'a, 's> { - ctx: &'a LintContext<'s>, -} - -impl<'ast> Visit<'ast> for UncheckedTransferERC20Checker<'_, '_> { - type BreakValue = (); - - fn visit_stmt(&mut self, stmt: &'ast Stmt<'ast>) -> ControlFlow { +/// WARN: can issue false positives, as it doesn't check that the contract being called sticks to +/// the full ERC20 specification. +impl<'gcx> LateLintPass<'gcx> for UncheckedTransferERC20 { + fn check_stmt(&mut self, ctx: &LintContext, gcx: Gcx<'gcx>, stmt: &'gcx hir::Stmt<'gcx>) { // Only expression statements can contain unchecked transfers. - if let StmtKind::Expr(expr) = &stmt.kind - && is_erc20_transfer_call(expr) + if let hir::StmtKind::Expr(expr) = &stmt.kind + && is_erc20_transfer_call(gcx, expr) { - self.ctx.emit(&ERC20_UNCHECKED_TRANSFER, expr.span); + ctx.emit(&ERC20_UNCHECKED_TRANSFER, expr.span); } - self.walk_stmt(stmt) } } -/// Checks if an expression is an ERC20 `transfer` or `transferFrom` call. -/// `function ERC20.transfer(to, amount)` -/// `function ERC20.transferFrom(from, to, amount)` -/// -/// Validates both the method name and argument count to avoid false positives -/// from other functions that happen to be named "transfer". -fn is_erc20_transfer_call(expr: &Expr<'_>) -> bool { - if let ExprKind::Call(call_expr, args) = &expr.kind { - // Must be a member access pattern: `token.transfer(...)` - if let ExprKind::Member(_, member) = &call_expr.kind { - return (args.len() == 2 && member.as_str() == "transfer") - || (args.len() == 3 && member.as_str() == "transferFrom"); - } +/// Checks if an expression is a call to a contract member matching the ERC20 signature of +/// * `function transfer(address to, uint256 amount) external returns (bool);` +/// * `function transferFrom(address from, address to, uint256 amount) external returns (bool);` +fn is_erc20_transfer_call<'gcx>(gcx: Gcx<'gcx>, expr: &hir::Expr<'gcx>) -> bool { + let Some((callee, call_args, _)) = expr.as_call() else { return false }; + let hir::ExprKind::Member(receiver, func_ident) = &callee.kind else { return false }; + let params: &[&str] = match (func_ident.as_str(), call_args.len()) { + ("transfer", 2) => &["address", "uint256"], + ("transferFrom", 3) => &["address", "address", "uint256"], + _ => return false, + }; + if receiver_contract_id(gcx, receiver).is_none() { + return false; } - false + gcx.resolved_function(callee).is_some_and(|fid| { + let func = gcx.hir.function(fid); + func.name.is_some_and(|name| name.name == func_ident.name) + && func.kind.is_function() + && func.mutates_state() + && func.parameters.len() == params.len() + && func.parameters.iter().zip(params).all(|(id, ty)| is_elementary(&gcx.hir, *id, ty)) + && matches!(func.returns, [ret] if is_elementary(&gcx.hir, *ret, "bool")) + }) } -// -- UNCKECKED LOW-LEVEL CALLS ------------------------------------------------------------------- - +/// Unchecked low-level calls appear as standalone expression statements, or with the success +/// value discarded in a tuple. When the success value is checked (in require, if, etc.), the +/// call is part of a larger expression and is not flagged. impl<'ast> EarlyLintPass<'ast> for UncheckedCall { - fn check_item_function(&mut self, ctx: &LintContext<'_>, func: &'ast ItemFunction<'ast>) { - if let Some(body) = &func.body { - let mut checker = UncheckedCallChecker { ctx }; - let _ = checker.visit_block(body); - } - } -} - -/// Visitor that detects unchecked low-level calls within function bodies. -/// -/// Similar to unchecked transfers, unchecked calls appear as standalone expression -/// statements. When the success value is checked (in require, if, etc.), the call -/// is part of a larger expression and won't be flagged. -struct UncheckedCallChecker<'a, 's> { - ctx: &'a LintContext<'s>, -} - -impl<'ast> Visit<'ast> for UncheckedCallChecker<'_, '_> { - type BreakValue = (); - - fn visit_stmt(&mut self, stmt: &'ast Stmt<'ast>) -> ControlFlow { - match &stmt.kind { - // Check standalone expression statements: `target.call(data);` - StmtKind::Expr(expr) => { - if is_low_level_call(expr) { - self.ctx.emit(&UNCHECKED_CALL, expr.span); - } else if let ExprKind::Assign(lhs, _, rhs) = &expr.kind { - // Check assignments to existing vars: `(, existingVar) = target.call(data);` - if is_low_level_call(rhs) && is_unchecked_tuple_assignment(lhs) { - self.ctx.emit(&UNCHECKED_CALL, expr.span); - } - } + fn check_stmt(&mut self, ctx: &LintContext, stmt: &'ast Stmt<'ast>) { + let span = match &stmt.kind { + // `target.call(data);` and `(, existingVar) = target.call(data);` + StmtKind::Expr(expr) + if is_low_level_call(expr) + || matches!(&expr.kind, ExprKind::Assign(lhs, _, rhs) + if is_low_level_call(rhs) + && matches!(&lhs.kind, ExprKind::Tuple(elements) + if elements.first().is_none_or(|e| e.is_none()))) => + { + expr.span } - // Check multi-variable declarations: `(bool success, ) = target.call(data);` - StmtKind::DeclMulti(vars, expr) => { - if is_low_level_call(expr) && vars.first().is_none_or(|v| v.is_none()) { - self.ctx.emit(&UNCHECKED_CALL, stmt.span); - } + // `(, bytes memory data) = target.call(data);` + StmtKind::DeclMulti(vars, expr) + if is_low_level_call(expr) && vars.first().is_none_or(|v| v.is_none()) => + { + stmt.span } - _ => {} - } - self.walk_stmt(stmt) - } -} - -/// Checks if an expression is a low-level call that should be checked. -/// -/// Detects patterns like: -/// - `target.call(...)` -/// - `target.delegatecall(...)` -/// - `target.staticcall(...)` -/// - `target.call{value: x}(...)` -fn is_low_level_call(expr: &Expr<'_>) -> bool { - if let ExprKind::Call(call_expr, _args) = &expr.kind { - // Check the callee expression - let callee = match &call_expr.kind { - // Handle call options like {value: x} - ExprKind::CallOptions(inner_expr, _) => inner_expr, - // Direct call without options - _ => call_expr, + _ => return, }; - - if let ExprKind::Member(_, member) = &callee.kind { - // Check for low-level call methods - return matches!(member.name, kw::Call | kw::Delegatecall | kw::Staticcall); - } - } - false -} - -/// Checks if a tuple assignment doesn't properly check the success value. -/// -/// Returns true if the first variable (success) is None: `(, bytes memory data) = -/// target.call(...)` -fn is_unchecked_tuple_assignment(expr: &Expr<'_>) -> bool { - if let ExprKind::Tuple(elements) = &expr.kind { - elements.first().is_none_or(|e| e.is_none()) - } else { - false + ctx.emit(&UNCHECKED_CALL, span); } } diff --git a/crates/lint/src/sol/high/unprotected_initializer.rs b/crates/lint/src/sol/high/unprotected_initializer.rs new file mode 100644 index 0000000000000..8cd13907d476f --- /dev/null +++ b/crates/lint/src/sol/high/unprotected_initializer.rs @@ -0,0 +1,236 @@ +use super::UnprotectedInitializer; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{ + Severity, SolLint, + analysis::{is_builtin, runtime_entry_points}, + }, +}; +use alloy_primitives::map::HashSet; +use solar::{ + ast::{ContractKind, DataLocation}, + interface::sym, + sema::{ + Gcx, + builtins::Builtin, + hir::{self, ContractId, Expr, ExprKind, FunctionId, Visit}, + ty::TyKind, + }, +}; +use std::ops::ControlFlow; + +declare_forge_lint!( + UNPROTECTED_INITIALIZER, + Severity::High, + "unprotected-initializer", + "upgradeable initializer is not protected against direct implementation calls" +); + +impl<'gcx> LateLintPass<'gcx> for UnprotectedInitializer { + fn check_nested_contract( + &mut self, + ctx: &LintContext, + gcx: Gcx<'gcx>, + contract_id: ContractId, + ) { + let contract = gcx.hir.contract(contract_id); + if contract.kind != ContractKind::Contract || contract.linearization_failed() { + return; + } + let bases = contract.linearized_bases; + + // The effective runtime dispatch surface: most-derived overrides plus the inherited + // fallback/receive functions. + let entries = runtime_entry_points(gcx, contract_id); + + let upgradeable = bases + .iter() + .any(|&cid| gcx.hir.contract(cid).name.as_str() == "Initializable") + || entries.iter().any(|&fid| has_initializer_modifier(&gcx.hir, gcx.hir.function(fid))); + if !upgradeable { + return; + } + let locked = bases.iter().filter_map(|&cid| gcx.hir.contract(cid).ctor).any(|ctor| { + reaches(gcx, bases, ctor, |expr| { + let ExprKind::Call(callee, ..) = &expr.kind else { return false }; + if !gcx.type_of_expr(callee.peel_parens().id).is_some_and( + |ty| matches!(ty.kind, TyKind::Fn(function) if function.is_internal()), + ) { + return false; + } + gcx.resolved_function(callee).is_some_and(|fid| { + let func = gcx.hir.function(fid); + func.contract.is_some_and(|cid| bases.contains(&cid)) + && func.name.is_some_and(|name| name.as_str() == "_disableInitializers") + }) + }) + }); + if locked { + return; + } + let destructive = entries.iter().any(|&fid| { + !has_modifier_named(&gcx.hir, gcx.hir.function(fid), "onlyProxy") + && reaches(gcx, bases, fid, |expr| is_destructive_call(gcx, expr)) + }); + if !destructive { + return; + } + + for fid in entries { + let func = gcx.hir.function(fid); + if func.is_part_of_external_interface() + && func.mutates_state() + && has_initializer_modifier(&gcx.hir, func) + && !has_modifier_named(&gcx.hir, func, "onlyProxy") + && reaches(gcx, bases, fid, |expr| writes_state(gcx, expr)) + { + ctx.emit(&UNPROTECTED_INITIALIZER, func.name.map_or(func.span, |name| name.span)); + } + } + } +} + +fn has_initializer_modifier(hir: &hir::Hir<'_>, func: &hir::Function<'_>) -> bool { + has_modifier_named(hir, func, "initializer") || has_modifier_named(hir, func, "reinitializer") +} + +fn has_modifier_named(hir: &hir::Hir<'_>, func: &hir::Function<'_>, name: &str) -> bool { + func.modifiers.iter().any(|modifier| { + modifier + .id + .as_function() + .is_some_and(|fid| hir.function(fid).name.is_some_and(|ident| ident.as_str() == name)) + }) +} + +/// True if `hit` matches an expression in `fid`'s body or, transitively, in the body of any +/// internal function it calls. +fn reaches<'gcx>( + gcx: Gcx<'gcx>, + bases: &'gcx [ContractId], + fid: FunctionId, + hit: impl FnMut(&'gcx Expr<'gcx>) -> bool, +) -> bool { + Reach { gcx, bases, defining_contract: None, visited: HashSet::default(), hit } + .visit_function_body(fid) + .is_break() +} + +struct Reach<'gcx, F> { + gcx: Gcx<'gcx>, + bases: &'gcx [ContractId], + defining_contract: Option, + // The predicate and dispatch context are fixed for the entire reachability check. + visited: HashSet, + hit: F, +} + +impl<'gcx, F: FnMut(&'gcx Expr<'gcx>) -> bool> Reach<'gcx, F> { + fn visit_function_body(&mut self, fid: FunctionId) -> ControlFlow<()> { + if !self.visited.insert(fid) { + return ControlFlow::Continue(()); + } + let Some(body) = self.gcx.hir.function(fid).body else { + return ControlFlow::Continue(()); + }; + let previous = self.defining_contract; + self.defining_contract = self.gcx.hir.function(fid).contract; + let result = body.stmts.iter().try_for_each(|stmt| self.visit_stmt(stmt)); + self.defining_contract = previous; + result + } +} + +impl<'gcx, F: FnMut(&'gcx Expr<'gcx>) -> bool> Visit<'gcx> for Reach<'gcx, F> { + type BreakValue = (); + + fn hir(&self) -> &'gcx hir::Hir<'gcx> { + &self.gcx.hir + } + + fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow<()> { + if (self.hit)(expr) { + return ControlFlow::Break(()); + } + if let ExprKind::Call(callee, ..) = &expr.kind + && let Some(fid) = + internal_callee(self.gcx, callee, self.bases[0], self.defining_contract) + { + self.visit_function_body(fid)?; + } + self.walk_expr(expr) + } +} + +/// The selected internal function in the analyzed contract's dispatch context. +fn internal_callee( + gcx: Gcx<'_>, + callee: &Expr<'_>, + contract: ContractId, + defining_contract: Option, +) -> Option { + let callee = callee.peel_parens(); + let fid = gcx.resolved_function(callee)?; + let TyKind::Fn(function) = gcx.type_of_expr(callee.id)?.kind else { return None }; + if !function.is_internal() { + return None; + } + Some(match &callee.kind { + ExprKind::Ident(_) => gcx.resolve_virtual_function(contract, fid), + ExprKind::Member(base, _) if is_builtin(gcx, base, sym::super_) => { + gcx.resolve_super_function(contract, defining_contract?, fid) + } + _ => fid, + }) +} + +/// `x.delegatecall(..)` or `selfdestruct(..)`. +fn is_destructive_call(gcx: Gcx<'_>, expr: &Expr<'_>) -> bool { + let ExprKind::Call(callee, ..) = &expr.kind else { return false }; + matches!( + gcx.resolved_builtin(callee), + Some(Builtin::AddressDelegatecall | Builtin::Selfdestruct) + ) +} + +/// An assignment, `delete`, `++`/`--` or `push`/`pop` whose target lives in contract storage. +fn writes_state(gcx: Gcx<'_>, expr: &Expr<'_>) -> bool { + match &expr.kind { + ExprKind::Assign(lhs, ..) | ExprKind::Delete(lhs) => lhs_writes_state(gcx, lhs), + ExprKind::Unary(op, lhs) => op.kind.has_side_effects() && lhs_writes_state(gcx, lhs), + ExprKind::Call(callee, ..) => { + matches!(&callee.peel_parens().kind, ExprKind::Member(base, member) + if matches!(member.as_str(), "push" | "pop") && references_storage(gcx, base)) + } + _ => false, + } +} + +/// A state variable, or a member/index of an expression that denotes contract storage. +fn lhs_writes_state(gcx: Gcx<'_>, lhs: &Expr<'_>) -> bool { + match &lhs.peel_parens().kind { + ExprKind::Ident(_) => { + gcx.resolved_variable(lhs).is_some_and(|v| gcx.hir.variable(v).kind.is_state()) + } + ExprKind::Index(base, _) | ExprKind::Slice(base, ..) | ExprKind::Member(base, _) => { + references_storage(gcx, base) + } + ExprKind::Tuple(elems) => elems.iter().flatten().any(|elem| lhs_writes_state(gcx, elem)), + _ => false, + } +} + +fn references_storage(gcx: Gcx<'_>, expr: &Expr<'_>) -> bool { + match &expr.peel_parens().kind { + ExprKind::Ident(_) => gcx.resolved_variable(expr).is_some_and(|v| { + let var = gcx.hir.variable(v); + var.kind.is_state() || var.data_location == Some(DataLocation::Storage) + }), + ExprKind::Index(base, _) | ExprKind::Slice(base, ..) | ExprKind::Member(base, _) => { + references_storage(gcx, base) + } + _ => gcx + .type_of_expr(expr.peel_parens().id) + .is_some_and(|ty| ty.loc() == Some(DataLocation::Storage)), + } +} diff --git a/crates/lint/src/sol/info/boolean_cst.rs b/crates/lint/src/sol/info/boolean_cst.rs new file mode 100644 index 0000000000000..1c30fdf5f6dc1 --- /dev/null +++ b/crates/lint/src/sol/info/boolean_cst.rs @@ -0,0 +1,74 @@ +use super::BooleanCst; +use crate::{ + linter::{EarlyLintPass, LintContext}, + sol::{Severity, SolLint, analysis::ast_bool_literal}, +}; +use solar::ast::{BinOpKind, Expr, ExprKind, Stmt, StmtKind, VariableDefinition}; + +declare_forge_lint!(BOOLEAN_CST, Severity::Med, "boolean-cst", "misuse of a boolean constant"); + +impl<'ast> EarlyLintPass<'ast> for BooleanCst { + fn check_stmt(&mut self, ctx: &LintContext, stmt: &'ast Stmt<'ast>) { + match &stmt.kind { + StmtKind::If(cond, ..) + | StmtKind::DoWhile(_, cond) + | StmtKind::For { cond: Some(cond), .. } => { + check_expr(ctx, cond, false); + } + // `while (true)` is the idiomatic infinite loop. + StmtKind::While(cond, _) => check_expr(ctx, cond, ast_bool_literal(cond) == Some(true)), + StmtKind::DeclMulti(_, expr) | StmtKind::Expr(expr) | StmtKind::Return(Some(expr)) => { + check_expr(ctx, expr, true); + } + _ => {} + } + } + + fn check_variable_definition( + &mut self, + ctx: &LintContext, + var: &'ast VariableDefinition<'ast>, + ) { + if let Some(initializer) = &var.initializer { + check_expr(ctx, initializer, true); + } + } +} + +/// Reports boolean literals in `expr` that are not `allow_bare` at the top level: a literal +/// stored, returned or passed as an argument is fine, one combined into a larger expression or +/// used as a condition is a misuse. +fn check_expr(ctx: &LintContext, expr: &Expr<'_>, allow_bare: bool) { + if ast_bool_literal(expr).is_some() { + if !allow_bare { + ctx.emit(&BOOLEAN_CST, expr.span); + } + return; + } + match &expr.kind { + ExprKind::Assign(_, _, rhs) => check_expr(ctx, rhs, true), + // `x == true` is boolean-equal's business. + ExprKind::Binary(left, op, right) + if !(matches!(op.kind, BinOpKind::Eq | BinOpKind::Ne) + && (ast_bool_literal(left).is_some() || ast_bool_literal(right).is_some())) => + { + check_expr(ctx, left, false); + check_expr(ctx, right, false); + } + ExprKind::Call(_, args) => args.exprs().for_each(|arg| check_expr(ctx, arg, true)), + ExprKind::Delete(expr) | ExprKind::Unary(_, expr) => check_expr(ctx, expr, false), + ExprKind::Ternary(cond, true_expr, false_expr) => { + check_expr(ctx, cond, false); + check_expr(ctx, true_expr, false); + check_expr(ctx, false_expr, false); + } + // A tuple is a grouping, not an operation, so it neither grants nor + // removes permission: `return (true, x)` is as fine as `return true`, + // and `if ((true))` is as wrong as `if (true)`. + ExprKind::Tuple(exprs) => exprs + .iter() + .filter_map(|expr| Option::from(expr.as_deref())) + .for_each(|expr| check_expr(ctx, expr, allow_bare)), + _ => {} + } +} diff --git a/crates/lint/src/sol/info/boolean_equal.rs b/crates/lint/src/sol/info/boolean_equal.rs new file mode 100644 index 0000000000000..770a20b398fa3 --- /dev/null +++ b/crates/lint/src/sol/info/boolean_equal.rs @@ -0,0 +1,60 @@ +use super::BooleanEqual; +use crate::{ + linter::{EarlyLintPass, LintContext, Suggestion}, + sol::{Severity, SolLint, analysis::ast_bool_literal}, +}; +use solar::{ + ast::{BinOpKind, Expr, ExprKind}, + interface::diagnostics::Applicability, +}; + +declare_forge_lint!( + BOOLEAN_EQUAL, + Severity::Info, + "boolean-equal", + "boolean comparison to a constant can be simplified" +); + +impl<'ast> EarlyLintPass<'ast> for BooleanEqual { + fn check_expr(&mut self, ctx: &LintContext, expr: &'ast Expr<'ast>) { + let ExprKind::Binary(left, op, right) = &expr.kind else { return }; + if !matches!(op.kind, BinOpKind::Eq | BinOpKind::Ne) { + return; + } + let simplified = match (ast_bool_literal(left), ast_bool_literal(right)) { + (None, None) => return, + (Some(_), Some(_)) => None, + (Some(constant), None) => simplify(ctx, right, op.kind, constant), + (None, Some(constant)) => simplify(ctx, left, op.kind, constant), + }; + match simplified { + Some(simplified) => ctx.emit_with_suggestion( + &BOOLEAN_EQUAL, + expr.span, + Suggestion::fix(simplified, Applicability::MachineApplicable) + .with_desc("consider simplifying to"), + ), + None => ctx.emit(&BOOLEAN_EQUAL, expr.span), + } + } +} + +/// `x == true` / `x != false` simplify to `x`, the other two forms to `!x`. +fn simplify(ctx: &LintContext, expr: &Expr<'_>, op: BinOpKind, constant: bool) -> Option { + let snippet = ctx.span_to_snippet(expr.span)?; + let negate = (op == BinOpKind::Eq) != constant; + let atomic = matches!( + expr.peel_parens().kind, + ExprKind::Call(..) + | ExprKind::CallOptions(..) + | ExprKind::Ident(_) + | ExprKind::Index(..) + | ExprKind::Lit(..) + | ExprKind::Member(..) + ); + Some(match (negate, atomic) { + (false, _) => snippet, + (true, true) => format!("!{snippet}"), + (true, false) => format!("!({snippet})"), + }) +} diff --git a/crates/lint/src/sol/info/cyclomatic_complexity.rs b/crates/lint/src/sol/info/cyclomatic_complexity.rs new file mode 100644 index 0000000000000..160f327d8f570 --- /dev/null +++ b/crates/lint/src/sol/info/cyclomatic_complexity.rs @@ -0,0 +1,92 @@ +use super::CyclomaticComplexity; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{Severity, SolLint}, +}; +use solar::sema::{ + Gcx, + hir::{self, Expr, ExprKind, Hir, Stmt, StmtKind, Visit}, +}; +use std::{convert::Infallible, ops::ControlFlow}; + +declare_forge_lint!( + CYCLOMATIC_COMPLEXITY, + Severity::Info, + "cyclomatic-complexity", + "function has a cyclomatic complexity above 11" +); + +/// The threshold Slither's detector of the same name uses: a function reports when its +/// complexity is strictly above this value. +const MAX_COMPLEXITY: usize = 11; + +impl<'gcx> LateLintPass<'gcx> for CyclomaticComplexity { + fn check_function( + &mut self, + ctx: &LintContext, + gcx: Gcx<'gcx>, + func: &'gcx hir::Function<'gcx>, + ) { + // Modifier definitions are never reported, matching Slither which iterates only + // declared and top-level functions. Yul helpers declared inside `assembly {}` DO + // report: Slither scores them as functions of their own. + if func.kind == hir::FunctionKind::Modifier || func.body.is_none() { + return; + } + // Visiting the whole function rather than only the body statements also counts + // decision points in modifier-invocation and base-constructor call arguments. For a + // structured program the complexity is one plus the decision points. + let mut counter = DecisionCounter { hir: &gcx.hir, decisions: 0 }; + let _ = counter.visit_function(func); + if counter.decisions + 1 > MAX_COMPLEXITY { + // A Yul helper's span starts at its name rather than a `function` keyword. + let span = match func.name { + Some(name) if func.is_yul => name.span, + _ => func.keyword_span(), + }; + ctx.emit(&CYCLOMATIC_COMPLEXITY, span); + } + } +} + +/// Counts the decision points of a function body. For a structured program the cyclomatic +/// complexity `E - N + 2P` of the control-flow graph equals one plus the number of decision +/// points, so no graph needs building. +/// +/// Loops count through their condition: solar desugars every `for`, `while` and `do while` +/// into `Loop { ... if (cond) ... }`, so the synthetic `if` carries the loop's decision and a +/// condition-less `for (;;)` correctly adds nothing. Boolean `&&` / `||` operators are not +/// counted, matching the control-flow graph Slither computes on. +struct DecisionCounter<'gcx> { + hir: &'gcx Hir<'gcx>, + decisions: usize, +} + +impl<'gcx> Visit<'gcx> for DecisionCounter<'gcx> { + type BreakValue = Infallible; + + fn hir(&self) -> &'gcx Hir<'gcx> { + self.hir + } + + fn visit_stmt(&mut self, stmt: &'gcx Stmt<'gcx>) -> ControlFlow { + self.decisions += match &stmt.kind { + StmtKind::If(..) => 1, + // The first clause is the `returns` one; each `catch` clause is a branch. + StmtKind::Try(stmt_try) => stmt_try.clauses.len().saturating_sub(1), + // Each non-default case of a Yul switch is a branch; the `default` clause + // (`constant == None`) opens no decision of its own. + StmtKind::Switch(switch) => { + switch.cases.iter().filter(|c| c.constant.is_some()).count() + } + _ => 0, + }; + self.walk_stmt(stmt) + } + + fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow { + // A ternary is an `if` in expression position. + self.decisions += usize::from(matches!(expr.kind, ExprKind::Ternary(..))); + self.walk_expr(expr) + } +} diff --git a/crates/lint/src/sol/info/event_fields.rs b/crates/lint/src/sol/info/event_fields.rs new file mode 100644 index 0000000000000..78c3d330e2e70 --- /dev/null +++ b/crates/lint/src/sol/info/event_fields.rs @@ -0,0 +1,47 @@ +use super::EventFields; +use crate::{ + linter::{EarlyLintPass, LintContext}, + sol::{Severity, SolLint}, +}; +use solar::ast::{ElementaryType, Item, ItemKind, TypeKind}; + +declare_forge_lint!( + EVENT_FIELDS, + Severity::Info, + "event-fields", + "`address` event parameter is not `indexed`" +); + +impl<'ast> EarlyLintPass<'ast> for EventFields { + fn check_item(&mut self, ctx: &LintContext, item: &'ast Item<'ast>) { + let ItemKind::Event(event) = &item.kind else { return }; + if event.parameters.iter().any(|p| p.indexed) { + return; + } + // The EVM allows 3 indexed parameters in a non-anonymous event and 4 in an anonymous one. + let slots_available = if event.anonymous { 4 } else { 3 }; + // The offending `address` parameters, rendered as `name (type)` in declaration order. + let names: Vec = event + .parameters + .iter() + .enumerate() + .filter_map(|(idx, param)| { + let TypeKind::Elementary(ElementaryType::Address(payable)) = ¶m.ty.kind else { + return None; + }; + let name = + param.name.map_or_else(|| format!("parameter #{}", idx + 1), |n| n.to_string()); + let ty = if *payable { "address payable" } else { "address" }; + Some(format!("`{name}` (`{ty}`)")) + }) + .take(slots_available) + .collect(); + if !names.is_empty() { + let msg = format!( + "event has unindexed fields that may benefit from being indexed: {}", + names.join(", ") + ); + ctx.emit_with_msg(&EVENT_FIELDS, event.name.span, msg); + } + } +} diff --git a/crates/lint/src/sol/info/function_init_state.rs b/crates/lint/src/sol/info/function_init_state.rs new file mode 100644 index 0000000000000..25d8053b03f93 --- /dev/null +++ b/crates/lint/src/sol/info/function_init_state.rs @@ -0,0 +1,88 @@ +use super::FunctionInitState; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{Severity, SolLint}, +}; +use solar::{ + ast::StateMutability, + sema::{ + Gcx, + hir::{ContractId, Expr, FunctionId, Hir, VariableId, Visit}, + }, +}; +use std::{convert::Infallible, ops::ControlFlow}; + +declare_forge_lint!( + FUNCTION_INIT_STATE, + Severity::Info, + "function-init-state", + "state variable initializer depends on a non-pure function or another state variable" +); + +impl<'gcx> LateLintPass<'gcx> for FunctionInitState { + fn check_nested_contract(&mut self, ctx: &LintContext, gcx: Gcx<'gcx>, id: ContractId) { + // State variable initializers run at construction, before the constructor body, in + // base-to-derived order: reading another non-constant state variable or calling a + // non-pure function there observes that partial state. Constants are fixed at compile + // time, so both constant declarations and references to constants are fine. + let contract = gcx.hir.contract(id); + for item_id in contract.items { + let Some(variable) = item_id.as_variable().map(|v| gcx.hir.variable(v)) else { + continue; + }; + if variable.is_state_variable() + && !variable.is_constant() + && let Some(initializer) = variable.initializer + { + let mut finder = ImpureRefFinder { gcx, found: false }; + let _ = finder.visit_expr(initializer); + if finder.found { + ctx.emit(&FUNCTION_INIT_STATE, variable.span); + } + } + } + } +} + +/// Looks for a reference to a non-constant state variable or to a non-pure function anywhere in +/// an initializer expression, arguments of nested calls included. +struct ImpureRefFinder<'gcx> { + gcx: Gcx<'gcx>, + found: bool, +} + +impl<'gcx> Visit<'gcx> for ImpureRefFinder<'gcx> { + type BreakValue = Infallible; + + fn hir(&self) -> &'gcx Hir<'gcx> { + &self.gcx.hir + } + + fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow { + if let Some(variable_id) = self.gcx.resolved_variable(expr) { + self.judge_variable(variable_id); + } else if let Some(function_id) = self.gcx.resolved_function(expr) { + self.judge_function(function_id); + } + self.walk_expr(expr) + } +} + +impl ImpureRefFinder<'_> { + /// A read of another state variable: its initializer may not have run yet. + fn judge_variable(&mut self, variable_id: VariableId) { + let variable = self.gcx.hir.variable(variable_id); + self.found |= variable.is_state_variable() && !variable.is_constant(); + } + + /// A non-pure function observes the same partial state. A variable referenced through its + /// synthesized getter is judged as a read of the variable itself, so a public constant + /// stays fine. + fn judge_function(&mut self, function_id: FunctionId) { + let function = self.gcx.hir.function(function_id); + match function.gettee { + Some(variable_id) => self.judge_variable(variable_id), + None => self.found |= function.state_mutability != StateMutability::Pure, + } + } +} diff --git a/crates/lint/src/sol/info/imports.rs b/crates/lint/src/sol/info/imports.rs index bee8fdd123f5e..e724d129579da 100644 --- a/crates/lint/src/sol/info/imports.rs +++ b/crates/lint/src/sol/info/imports.rs @@ -1,32 +1,28 @@ -use solar_ast::{self as ast, SourceUnit, Span, Symbol, visit::Visit}; -use solar_data_structures::map::FxIndexSet; -use solar_interface::SourceMap; -use std::ops::ControlFlow; - use super::Imports; use crate::{ linter::{EarlyLintPass, LintContext}, sol::{Severity, SolLint}, }; +use solar::{ + ast::{self as ast, SourceUnit, Symbol, visit::Visit}, + data_structures::{Never, map::FxHashSet}, + interface::SourceMap, +}; +use std::ops::ControlFlow; -declare_forge_lint!( - UNUSED_IMPORT, - Severity::Info, - "unused-import", - "unused imports should be removed" -); +declare_forge_lint!(UNUSED_IMPORT, Severity::Info, "unused-import", "unused import"); declare_forge_lint!( UNALIASED_PLAIN_IMPORT, Severity::Info, "unaliased-plain-import", - "use named imports '{A, B}' or alias 'import \"..\" as X'" + "plain import has no alias; use named imports `{A, B}` or alias `import \"..\" as X`" ); impl<'ast> EarlyLintPass<'ast> for Imports { fn check_import_directive( &mut self, - ctx: &LintContext<'_>, + ctx: &LintContext, import: &'ast ast::ImportDirective<'ast>, ) { // Non-aliased plain imports like `import "File.sol";`. @@ -37,72 +33,52 @@ impl<'ast> EarlyLintPass<'ast> for Imports { } } - fn check_full_source_unit(&mut self, ctx: &LintContext<'ast>, ast: &'ast SourceUnit<'ast>) { - let mut checker = UnusedChecker::new(ctx.session().source_map()); + fn check_full_source_unit(&mut self, ctx: &LintContext<'ast, '_>, ast: &'ast SourceUnit<'ast>) { + // Disabled lints are filtered inside `ctx.emit()`, but the full traversal is expensive. + if !ctx.is_lint_enabled(UNUSED_IMPORT.id) { + return; + } + let mut checker = + UsedSymbols { source_map: ctx.session().source_map(), used: FxHashSet::default() }; let _ = checker.visit_source_unit(ast); - checker.check_unused_imports(ast, ctx); - checker.clear(); - } -} - -/// Visitor that collects all used symbols in a source unit. -struct UnusedChecker<'ast> { - used_symbols: FxIndexSet, - source_map: &'ast SourceMap, -} - -impl<'ast> UnusedChecker<'ast> { - fn new(source_map: &'ast SourceMap) -> Self { - Self { source_map, used_symbols: Default::default() } - } - - fn clear(&mut self) { - self.used_symbols.clear(); - } - - /// Mark a symbol as used in a source. - fn mark_symbol_used(&mut self, symbol: Symbol) { - self.used_symbols.insert(symbol); - } + let used = checker.used; - /// Check for unused imports and emit warnings. - fn check_unused_imports(&self, ast: &SourceUnit<'_>, ctx: &LintContext<'_>) { for item in ast.items.iter() { - let span = item.span; let ast::ItemKind::Import(import) = &item.kind else { continue }; match &import.items { - ast::ImportItems::Plain(_) | ast::ImportItems::Glob(_) => { - if let Some(alias) = import.source_alias() - && !self.used_symbols.contains(&alias.name) - { - self.unused_import(ctx, span); - } - } ast::ImportItems::Aliases(symbols) => { for &(orig, alias) in symbols.iter() { let name = alias.unwrap_or(orig); - if !self.used_symbols.contains(&name.name) { - self.unused_import(ctx, orig.span.to(name.span)); + if !used.contains(&name.name) { + ctx.emit(&UNUSED_IMPORT, orig.span.to(name.span)); } } } + ast::ImportItems::Plain(_) | ast::ImportItems::Glob(_) => { + if let Some(alias) = import.source_alias() + && !used.contains(&alias.name) + { + ctx.emit(&UNUSED_IMPORT, item.span); + } + } } } } +} - fn unused_import(&self, ctx: &LintContext<'_>, span: Span) { - ctx.emit(&UNUSED_IMPORT, span); - } +/// Collects every symbol a source unit refers to outside its import directives. +struct UsedSymbols<'ast> { + source_map: &'ast SourceMap, + used: FxHashSet, } -impl<'ast> Visit<'ast> for UnusedChecker<'ast> { - type BreakValue = solar_data_structures::Never; +impl<'ast> Visit<'ast> for UsedSymbols<'ast> { + type BreakValue = Never; fn visit_item(&mut self, item: &'ast ast::Item<'ast>) -> ControlFlow { if let ast::ItemKind::Import(_) = &item.kind { return ControlFlow::Continue(()); } - self.walk_item(item) } @@ -112,64 +88,39 @@ impl<'ast> Visit<'ast> for UnusedChecker<'ast> { ) -> ControlFlow { match &using.list { ast::UsingList::Single(path) => { - self.mark_symbol_used(path.first().name); + self.used.insert(path.first().name); } ast::UsingList::Multiple(items) => { - for (path, _) in items.iter() { - self.mark_symbol_used(path.first().name); - } + self.used.extend(items.iter().map(|(path, _)| path.first().name)); } } - self.walk_using_directive(using) } - fn visit_function_header( - &mut self, - header: &'ast solar_ast::FunctionHeader<'ast>, - ) -> ControlFlow { - // temporary workaround until solar also visits `override` and its paths . - if let Some(ref override_) = header.override_ { - for path in override_.paths.iter() { - _ = self.visit_path(path); - } - } - - self.walk_function_header(header) - } - fn visit_expr(&mut self, expr: &'ast ast::Expr<'ast>) -> ControlFlow { if let ast::ExprKind::Ident(id) = expr.kind { - self.mark_symbol_used(id.name); + self.used.insert(id.name); } - self.walk_expr(expr) } fn visit_path(&mut self, path: &'ast ast::PathSlice) -> ControlFlow { - for id in path.segments() { - self.mark_symbol_used(id.name); - } - + self.used.extend(path.segments().iter().map(|id| id.name)); self.walk_path(path) } fn visit_ty(&mut self, ty: &'ast ast::Type<'ast>) -> ControlFlow { if let ast::TypeKind::Custom(path) = &ty.kind { - self.mark_symbol_used(path.first().name); + self.used.insert(path.first().name); } - self.walk_ty(ty) } - fn visit_doc_comment( - &mut self, - cmnt: &'ast solar_ast::DocComment, - ) -> ControlFlow { + fn visit_doc_comment(&mut self, cmnt: &'ast ast::DocComment) -> ControlFlow { if let Ok(snip) = self.source_map.span_to_snippet(cmnt.span) { for line in snip.lines() { if let Some((_, relevant)) = line.split_once("@inheritdoc") { - self.mark_symbol_used(Symbol::intern(relevant.trim())); + self.used.insert(Symbol::intern(relevant.trim())); } } } diff --git a/crates/lint/src/sol/info/incorrect_using_for.rs b/crates/lint/src/sol/info/incorrect_using_for.rs new file mode 100644 index 0000000000000..66aa7848f5cc7 --- /dev/null +++ b/crates/lint/src/sol/info/incorrect_using_for.rs @@ -0,0 +1,69 @@ +use super::IncorrectUsingFor; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{Severity, SolLint}, +}; +use solar::{ + ast::DataLocation, + sema::{ + Gcx, + hir::{self, UsingDirective, UsingEntryKind}, + }, +}; + +declare_forge_lint!( + INCORRECT_USING_FOR, + Severity::Info, + "incorrect-using-for", + "`using ... for` names a library with no function applicable to the type, so the directive attaches nothing" +); + +impl<'gcx> LateLintPass<'gcx> for IncorrectUsingFor { + fn check_nested_source(&mut self, ctx: &LintContext, gcx: Gcx<'gcx>, id: hir::SourceId) { + for directive in gcx.hir.source(id).usings { + check_directive(ctx, gcx, directive); + } + } + + fn check_nested_contract(&mut self, ctx: &LintContext, gcx: Gcx<'gcx>, id: hir::ContractId) { + for directive in gcx.hir.contract(id).usings { + check_directive(ctx, gcx, directive); + } + } +} + +/// Judges one `using ... for` directive: a library entry that contributes no member to the +/// target type attaches nothing, which means no function of the library accepts the type as +/// its bound first parameter. +fn check_directive<'gcx>(ctx: &LintContext, gcx: Gcx<'gcx>, directive: &'gcx UsingDirective<'gcx>) { + // `using L for *` attaches every function of the library: nothing to validate. + let Some(hir_ty) = &directive.ty else { return }; + // `members_of` expects reference types wrapped in their data location. Storage converts + // implicitly to memory but not to calldata, so a library function whose bound first + // parameter is `calldata` only shows up under the calldata form: each location is probed + // and the directive only flags when none of them attaches. + let base_ty = gcx.type_of_hir_ty(hir_ty); + let tys = [DataLocation::Storage, DataLocation::Memory, DataLocation::Calldata] + .map(|loc| base_ty.with_loc_if_ref(gcx, loc)); + for entry in directive.entries { + // The braced form `using {f} for T` is already type-checked: the compiler rejects a + // function that cannot attach to `T`. + let UsingEntryKind::Library(library_id) = entry.kind else { continue }; + // A member counts when it is an attached, non-private function declared in the library: + // the library form skips private functions, so a private member attached by a braced + // directive in scope does not make this entry live. + let attaches = tys + .iter() + .flat_map(|ty| gcx.members_of(*ty, directive.source, directive.contract)) + .filter(|member| member.attached) + .filter_map(|member| member.ty.function_id()) + .any(|function_id| { + let function = gcx.hir.function(function_id); + function.contract == Some(library_id) + && function.visibility != hir::Visibility::Private + }); + if !attaches { + ctx.emit(&INCORRECT_USING_FOR, entry.span); + } + } +} diff --git a/crates/lint/src/sol/info/inline_assembly.rs b/crates/lint/src/sol/info/inline_assembly.rs new file mode 100644 index 0000000000000..9c16d56a5e3f4 --- /dev/null +++ b/crates/lint/src/sol/info/inline_assembly.rs @@ -0,0 +1,47 @@ +use super::InlineAssembly; +use crate::{ + linter::{EarlyLintPass, LintContext}, + sol::{Severity, SolLint}, +}; +use solar::{ + ast::{Stmt, StmtKind}, + interface::BytePos, +}; + +declare_forge_lint!(INLINE_ASSEMBLY, Severity::Info, "inline-assembly", "inline assembly used"); + +impl<'ast> EarlyLintPass<'ast> for InlineAssembly { + fn check_stmt(&mut self, ctx: &LintContext, stmt: &'ast Stmt<'ast>) { + let StmtKind::Assembly(asm) = &stmt.kind else { return }; + // Keep the diagnostic highlight on the leading `assembly` keyword. + let kw_span = stmt.span.with_hi(stmt.span.lo() + BytePos("assembly".len() as u32)); + let memory_safe = asm.flags.iter().any(|f| f.value.as_str() == "memory-safe") + || has_memory_safe_natspec(ctx, stmt.span.lo()); + let msg = if memory_safe { + "inline assembly declared `memory-safe`" + } else { + "inline assembly used" + }; + ctx.emit_with_msg(&INLINE_ASSEMBLY, kw_span, msg); + } +} + +/// Returns `true` when the lines immediately preceding `stmt_lo` form a `///` NatSpec block +/// containing `@solidity memory-safe-assembly`. +fn has_memory_safe_natspec(ctx: &LintContext, stmt_lo: BytePos) -> bool { + let Some(file) = ctx.source_file() else { return false }; + let Some(before) = stmt_lo + .to_u32() + .checked_sub(file.start_pos.to_u32()) + .and_then(|offset| file.src.get(..offset as usize)) + else { + return false; + }; + before + .lines() + .rev() + .map(str::trim_start) + .filter(|line| !line.is_empty()) + .map_while(|line| line.strip_prefix("///")) + .any(|rest| rest.trim_start().starts_with("@solidity memory-safe-assembly")) +} diff --git a/crates/lint/src/sol/info/interface_naming.rs b/crates/lint/src/sol/info/interface_naming.rs new file mode 100644 index 0000000000000..2331085e93f4c --- /dev/null +++ b/crates/lint/src/sol/info/interface_naming.rs @@ -0,0 +1,59 @@ +use super::InterfaceFileNaming; +use crate::{ + linter::{EarlyLintPass, Lint, LintContext}, + sol::{Severity, SolLint}, +}; +use solar::ast; + +declare_forge_lint!( + INTERFACE_FILE_NAMING, + Severity::Info, + "interface-file-naming", + "interface file name is missing the `I` prefix" +); + +declare_forge_lint!( + INTERFACE_NAMING, + Severity::Info, + "interface-naming", + "interface name is missing the `I` prefix" +); + +impl<'ast> EarlyLintPass<'ast> for InterfaceFileNaming { + fn check_full_source_unit( + &mut self, + ctx: &LintContext<'ast, '_>, + unit: &'ast ast::SourceUnit<'ast>, + ) { + if !ctx.is_lint_enabled(INTERFACE_FILE_NAMING.id()) { + return; + } + // A file whose contract-like items are all interfaces is named after the first one. + let mut contracts = unit.items.iter().filter_map(|item| match &item.kind { + ast::ItemKind::Contract(c) => Some(c), + _ => None, + }); + if let Some(first) = contracts.next() + && std::iter::once(first) + .chain(contracts) + .all(|c| c.kind == ast::ContractKind::Interface) + && let Some(file_name) = file_name(ctx, unit) + && !file_name.starts_with('I') + { + ctx.emit(&INTERFACE_FILE_NAMING, first.name.span); + } + } + + fn check_item_contract(&mut self, ctx: &LintContext, contract: &'ast ast::ItemContract<'ast>) { + if contract.kind == ast::ContractKind::Interface && !contract.name.as_str().starts_with('I') + { + ctx.emit(&INTERFACE_NAMING, contract.name.span); + } + } +} + +fn file_name(ctx: &LintContext, unit: &ast::SourceUnit<'_>) -> Option { + let first_item_span = unit.items.first()?.span; + let file = ctx.session().source_map().lookup_source_file(first_item_span.lo()); + Some(file.name.as_real()?.file_name()?.to_str()?.to_string()) +} diff --git a/crates/lint/src/sol/info/internal_function_used_once.rs b/crates/lint/src/sol/info/internal_function_used_once.rs new file mode 100644 index 0000000000000..f29bc9ee13030 --- /dev/null +++ b/crates/lint/src/sol/info/internal_function_used_once.rs @@ -0,0 +1,180 @@ +use super::InternalFunctionUsedOnce; +use crate::{ + linter::{Lint, ProjectLintEmitter, ProjectLintPass, ProjectSource}, + sol::{Severity, SolLint}, +}; +use solar::{ + interface::{data_structures::Never, source_map::FileName}, + sema::{ + Gcx, + hir::{self, Visit}, + }, +}; +use std::{ + collections::{HashMap, HashSet}, + ops::ControlFlow, +}; + +declare_forge_lint!( + INTERNAL_FUNCTION_USED_ONCE, + Severity::Info, + "internal-function-used-once", + "this internal function is used only once" +); + +impl<'ast> ProjectLintPass<'ast> for InternalFunctionUsedOnce { + fn check_project(&mut self, ctx: &ProjectLintEmitter<'_, '_>, sources: &[ProjectSource<'ast>]) { + if !ctx.is_lint_enabled(INTERNAL_FUNCTION_USED_ONCE.id()) { + return; + } + let gcx = ctx.gcx(); + + // Only functions declared in user-provided files are reported, while references are + // counted across the whole unit, dependencies included. + let input_source_idx: HashMap<_, _> = gcx + .hir + .sources_enumerated() + .filter_map(|(sid, src)| { + let FileName::Real(path) = &src.file.name else { return None }; + Some((sid, sources.iter().position(|s| &s.path == path)?)) + }) + .collect(); + if input_source_idx.is_empty() { + return; + } + + // A function bound as a user-defined operator (`using {f as +} for T`) is out of scope: + // its operator uses are not `Ident`/`Member` references, and the binding requires a + // named function anyway. + let source_usings = gcx.hir.source_ids().flat_map(|id| gcx.hir.source(id).usings); + let contract_usings = gcx.hir.contract_ids().flat_map(|id| gcx.hir.contract(id).usings); + let operator_bound: HashSet<_> = source_usings + .chain(contract_usings) + .flat_map(|directive| directive.entries) + .filter(|entry| entry.operator.is_some()) + .filter_map(|entry| match entry.kind { + hir::UsingEntryKind::Functions(ids) => Some(ids), + _ => None, + }) + .flatten() + .copied() + .collect(); + + let mut counter = + ReferenceCounter { gcx, current: None, callee: None, refs: HashMap::new() }; + for source_id in gcx.hir.source_ids() { + let _ = counter.visit_nested_source(source_id); + } + let refs = counter.refs; + + for function_id in gcx.hir.function_ids() { + let function = gcx.hir.function(function_id); + let Some(&src_idx) = input_source_idx.get(&function.source) else { continue }; + // Only ordinary internal functions with a body qualify. `virtual` functions and + // overrides exist for dynamic dispatch, and a `_`-prefixed name follows the hook + // convention (OpenZeppelin style). + if function.visibility != hir::Visibility::Internal + || !function.is_ordinary() + || function.body.is_none() + || function.virtual_ + || function.override_ + || operator_bound.contains(&function_id) + || function.name.is_none_or(|name| name.as_str().starts_with('_')) + { + continue; + } + // Exactly one reference, and it must be a direct call: zero references is dead + // code, a value-position reference (function pointer, callback) has no call site + // to inline into, a recursive function cannot be inlined, and a reference that + // only enters through a reference cycle has no caller to inline into either. + let Some(info) = refs.get(&function_id) else { continue }; + if info.count == 1 + && !info.used_as_value + && !info.self_referencing + && !only_referenced_within_cycle(&refs, function_id) + { + ctx.emit(&sources[src_idx], &INTERNAL_FUNCTION_USED_ONCE, function.keyword_span()); + } + } + } +} + +/// The references resolving to one function. Self-references are recorded apart rather than +/// counted; `first_from` is `None` for a reference outside any function body. +#[derive(Default)] +struct RefInfo { + count: usize, + used_as_value: bool, + self_referencing: bool, + first_from: Option, +} + +/// Whether a function's single reference only enters it through a reference cycle: the chain +/// of single-reference sources loops back on `start` itself. A loop closing on a later node is +/// someone else's cycle, and `start` hangs off it as an inlineable tail. +fn only_referenced_within_cycle( + refs: &HashMap, + start: hir::FunctionId, +) -> bool { + let mut visited = vec![start]; + let mut current = start; + loop { + let Some(info) = refs.get(¤t) else { return false }; + // A fork (several references) or a reference from outside a function ends the chain. + let Some(next) = info.first_from.filter(|_| info.count == 1) else { return false }; + if visited.contains(&next) { + return next == start; + } + visited.push(next); + current = next; + } +} + +/// Counts, for every function of the unit, the expressions the type checker resolved to it, +/// direct calls and value-position references alike. +struct ReferenceCounter<'gcx> { + gcx: Gcx<'gcx>, + /// The enclosing function, so each reference knows its source. + current: Option, + /// The callee of the call being walked: that reference is a direct call. + callee: Option, + refs: HashMap, +} + +impl<'gcx> hir::Visit<'gcx> for ReferenceCounter<'gcx> { + type BreakValue = Never; + + fn hir(&self) -> &'gcx hir::Hir<'gcx> { + &self.gcx.hir + } + + fn visit_nested_function(&mut self, id: hir::FunctionId) -> ControlFlow { + let previous = self.current.replace(id); + let result = self.visit_function(self.gcx.hir.function(id)); + self.current = previous; + result + } + + fn visit_expr(&mut self, expr: &'gcx hir::Expr<'gcx>) -> ControlFlow { + match &expr.kind { + hir::ExprKind::Call(callee, ..) => self.callee = Some(callee.peel_parens().id), + hir::ExprKind::Ident(..) | hir::ExprKind::Member(..) => { + if let Some(function_id) = self.gcx.resolved_function(expr) { + let is_call = self.callee == Some(expr.id); + let info = self.refs.entry(function_id).or_default(); + if self.current == Some(function_id) { + info.self_referencing = true; + } else { + info.count += 1; + info.used_as_value |= !is_call; + if info.count == 1 { + info.first_from = self.current; + } + } + } + } + _ => {} + } + self.walk_expr(expr) + } +} diff --git a/crates/lint/src/sol/info/literal_instead_of_constant.rs b/crates/lint/src/sol/info/literal_instead_of_constant.rs new file mode 100644 index 0000000000000..cf249ca86871f --- /dev/null +++ b/crates/lint/src/sol/info/literal_instead_of_constant.rs @@ -0,0 +1,169 @@ +use super::LiteralInsteadOfConstant; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{Severity, SolLint}, +}; +use alloy_primitives::{Address, U256}; +use solar::{ + ast::{LitKind, StrKind, UnOpKind}, + interface::Span, + sema::{ + Gcx, + hir::{self, Expr, ExprKind, Hir, Lit, Stmt, StmtKind, Visit}, + ty::TyKind, + }, +}; +use std::{collections::HashMap, convert::Infallible, ops::ControlFlow}; + +declare_forge_lint!( + LITERAL_INSTEAD_OF_CONSTANT, + Severity::Info, + "literal-instead-of-constant", + "this literal appears multiple times in the contract" +); + +impl<'gcx> LateLintPass<'gcx> for LiteralInsteadOfConstant { + fn check_nested_contract(&mut self, ctx: &LintContext, gcx: Gcx<'gcx>, id: hir::ContractId) { + // Group the literals of the contract's own functions and modifiers by semantic value; + // inherited items group with their declaring contract. Collection covers the executable + // expressions: the body statements, and the modifier and base-constructor arguments of + // the header. Parameter and return types stay out, so a fixed array size in a signature + // is a type annotation rather than a repeated value. + let mut collector = LiteralCollector { gcx, groups: HashMap::new() }; + let functions = gcx.hir.contract(id).functions(); + for function in functions.map(|id| gcx.hir.function(id)) { + for modifier in function.modifiers { + let _ = collector.visit_modifier(modifier); + } + for stmt in function.body.iter().flat_map(|body| body.stmts) { + let _ = collector.visit_stmt(stmt); + } + } + // A value used in one single place is fine: only repetitions report. Emissions are + // sorted by position so the output does not depend on the map's iteration order. + let mut repeated: Vec = + collector.groups.into_values().filter(|spans| spans.len() > 1).flatten().collect(); + repeated.sort_by_key(|span| span.lo()); + for span in repeated { + ctx.emit(&LITERAL_INSTEAD_OF_CONSTANT, span); + } + } +} + +/// The semantic value of a literal, the grouping key: two spellings of the same number +/// (`100`, `0x64`, `1e2`) or the same unit-scaled amount (`1 ether`, `1e18`) are one value. +/// A numeric literal under a value-changing unary operator denotes a DISTINCT constant, so +/// `-5` and `~5` never group with the bare `5`. +#[derive(PartialEq, Eq, Hash)] +enum LiteralValue { + Number(Option, U256), + Address(Address), + HexString(Vec), +} + +/// Collects the grouping-relevant literals of a subtree: numbers above 2, address literals +/// and hex string literals. A bare literal indexing an array-like value or bounding a slice +/// stays out as positional, matching Aderyn; a mapping key counts, it is configuration data. +struct LiteralCollector<'gcx> { + gcx: Gcx<'gcx>, + groups: HashMap>, +} + +impl<'gcx> LiteralCollector<'gcx> { + /// Records one literal under its semantic grouping key, `op` being the value-changing + /// unary operator applied to it, if any. + fn record_lit(&mut self, lit: &Lit<'_>, span: Span, op: Option) { + let key = match &lit.kind { + // `0`, `1` and `2` are structural rather than configuration values. + LitKind::Number(v) if *v > U256::from(2u64) => LiteralValue::Number(op, *v), + LitKind::Address(address) => LiteralValue::Address(*address), + LitKind::Str(StrKind::Hex, bytes, _) => { + LiteralValue::HexString(bytes.as_byte_str().to_vec()) + } + _ => return, + }; + self.groups.entry(key).or_default().push(span); + } + + /// Walks `expr` unless it is a bare literal in a positional role. + fn visit_unless_bare_lit(&mut self, expr: &'gcx Expr<'gcx>) { + if !is_lit(expr) { + let _ = self.visit_expr(expr); + } + } +} + +fn is_lit(expr: &Expr<'_>) -> bool { + matches!(expr.peel_parens().kind, ExprKind::Lit(..)) +} + +impl<'gcx> Visit<'gcx> for LiteralCollector<'gcx> { + type BreakValue = Infallible; + + fn hir(&self) -> &'gcx Hir<'gcx> { + &self.gcx.hir + } + + fn visit_stmt(&mut self, stmt: &'gcx Stmt<'gcx>) -> ControlFlow { + // Yul literals commonly encode structural values such as memory offsets, masks, and + // selectors, where extracting a constant would not necessarily improve readability. + if matches!(stmt.kind, StmtKind::AssemblyBlock(_)) { + return ControlFlow::Continue(()); + } + self.walk_stmt(stmt) + } + + fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow { + let is_value_changing = + |op: &hir::UnOp| matches!(op.kind, UnOpKind::Neg | UnOpKind::BitNot); + match &expr.kind { + // A bare literal indexing an array-like value (`arr[3]`) is positional, not a + // magic value; a mapping key (`m[500]`) is configuration data and counts. + ExprKind::Index(base, index) => { + let _ = self.visit_expr(base); + if let Some(index) = index { + let is_mapping = matches!( + self.gcx.type_of_expr(base.peel_parens().id).map(|ty| ty.peel_refs().kind), + Some(TyKind::Mapping(..)) + ); + if is_mapping { + let _ = self.visit_expr(index); + } else { + self.visit_unless_bare_lit(index); + } + } + } + // A bare literal shift amount (`x << 128`, `acc >>= 128`) and bare slice bounds + // (`d[555:600]`) are structural too: slices only exist on array-like values. + ExprKind::Binary(lhs, op, rhs) | ExprKind::Assign(lhs, Some(op), rhs) + if op.kind.is_shift() => + { + let _ = self.visit_expr(lhs); + self.visit_unless_bare_lit(rhs); + } + ExprKind::Slice(base, start, end) => { + let _ = self.visit_expr(base); + for bound in [start, end].into_iter().flatten() { + self.visit_unless_bare_lit(bound); + } + } + ExprKind::Unary(op, operand) if is_value_changing(op) => { + match &operand.peel_parens().kind { + // `-5` / `~5`: record the operator-qualified value without descending into the + // operand, which would re-record the bare magnitude. + ExprKind::Lit(lit) => self.record_lit(lit, expr.span, Some(op.kind)), + // A nested unary over a literal (`-(-5)`, `~~5`) folds to a value that is + // neither this operator's nor the bare literal's; canonicalizing it is not + // worth it, so the chain is skipped rather than miss-keyed. A non-literal + // operand deeper down (`-(-(x + 500))`) still records its own literals. + ExprKind::Unary(inner, inner_operand) + if is_value_changing(inner) && is_lit(inner_operand) => {} + _ => return self.walk_expr(expr), + } + } + ExprKind::Lit(lit) => self.record_lit(lit, lit.span, None), + _ => return self.walk_expr(expr), + } + ControlFlow::Continue(()) + } +} diff --git a/crates/lint/src/sol/info/low_level_calls.rs b/crates/lint/src/sol/info/low_level_calls.rs new file mode 100644 index 0000000000000..8ba31b3be2944 --- /dev/null +++ b/crates/lint/src/sol/info/low_level_calls.rs @@ -0,0 +1,21 @@ +use super::LowLevelCalls; +use crate::{ + linter::{EarlyLintPass, LintContext}, + sol::{Severity, SolLint, analysis::is_low_level_call}, +}; +use solar::ast::Expr; + +declare_forge_lint!( + LOW_LEVEL_CALLS, + Severity::Info, + "low-level-calls", + "low-level call bypasses type checking" +); + +impl<'ast> EarlyLintPass<'ast> for LowLevelCalls { + fn check_expr(&mut self, ctx: &LintContext, expr: &'ast Expr<'ast>) { + if is_low_level_call(expr) { + ctx.emit(&LOW_LEVEL_CALLS, expr.span); + } + } +} diff --git a/crates/lint/src/sol/info/missing_inheritance.rs b/crates/lint/src/sol/info/missing_inheritance.rs new file mode 100644 index 0000000000000..60a77bcc8be82 --- /dev/null +++ b/crates/lint/src/sol/info/missing_inheritance.rs @@ -0,0 +1,145 @@ +use crate::{ + linter::{Lint, ProjectLintEmitter, ProjectLintPass, ProjectSource}, + sol::{Severity, SolLint, info::MissingInheritance}, +}; +use solar::{ + interface::source_map::FileName, + sema::hir::{ContractId, ContractKind, FunctionKind, Hir, ItemId}, +}; +use std::collections::{BTreeSet, HashMap}; + +declare_forge_lint!( + MISSING_INHERITANCE, + Severity::Info, + "missing-inheritance", + "contract has all selectors of an interface it does not explicitly inherit" +); + +impl<'ast> ProjectLintPass<'ast> for MissingInheritance { + fn check_project(&mut self, ctx: &ProjectLintEmitter<'_, '_>, sources: &[ProjectSource<'ast>]) { + if !ctx.is_lint_enabled(MISSING_INHERITANCE.id()) { + return; + } + let gcx = ctx.gcx(); + + // Only user-provided files are analyzed (and emitted against). + let input_source_idx: HashMap<_, _> = gcx + .hir + .sources_enumerated() + .filter_map(|(sid, src)| { + let FileName::Real(path) = &src.file.name else { return None }; + Some((sid, sources.iter().position(|s| &s.path == path)?)) + }) + .collect(); + if input_source_idx.is_empty() { + return; + } + + // Targets are restricted to user input; candidates span the whole HIR so dependency + // interfaces (e.g. OpenZeppelin's `IERC20`) are still matched. + let mut selectors = HashMap::>::new(); + let mut candidates = Vec::new(); + let mut targets = Vec::new(); + for cid in gcx.hir.contract_ids() { + let contract = gcx.hir.contract(cid); + if contract.linearization_failed() { + continue; + } + let sels: BTreeSet<_> = + gcx.interface_functions(cid).all().iter().map(|f| f.selector.0).collect(); + let interface_like = match contract.kind { + ContractKind::Interface => true, + ContractKind::AbstractContract => is_signature_only(&gcx.hir, cid), + ContractKind::Contract | ContractKind::Library => false, + }; + if interface_like { + if !sels.is_empty() { + candidates.push(cid); + } + } else if !contract.kind.is_library() && input_source_idx.contains_key(&contract.source) + { + targets.push(cid); + } + selectors.insert(cid, sels); + } + + // Stable sort key for deterministic dedupe ordering across runs. + let sort_key = |cid| { + let name = &gcx.hir.contract(cid).name; + (name.span, name.as_str()) + }; + for tid in targets { + let target = gcx.hir.contract(tid); + let target_sels = &selectors[&tid]; + if target_sels.is_empty() { + continue; + } + // The target must implement every selector of the candidate, without already + // inheriting it (transitively) or an inherited base covering the candidate. + let mut intended: Vec = candidates + .iter() + .copied() + .filter(|&iid| { + let isel = &selectors[&iid]; + iid != tid + && !target.linearized_bases.contains(&iid) + && isel.is_subset(target_sels) + && !target.linearized_bases.iter().any(|b| { + *b != tid && selectors.get(b).is_some_and(|bsel| isel.is_subset(bsel)) + }) + }) + .collect(); + // Deterministic dedupe by maximal selector set: sort by descending selector count, + // tie-break by (span, name), then drop any candidate whose selector set is a + // subset/superset of a kept one. + intended.sort_by(|&a, &b| { + selectors[&b] + .len() + .cmp(&selectors[&a].len()) + .then_with(|| sort_key(a).cmp(&sort_key(b))) + }); + let mut kept: Vec = Vec::new(); + for iid in intended { + let isel = &selectors[&iid]; + if !kept.iter().any(|kid| { + let ksel = &selectors[kid]; + isel.is_subset(ksel) || ksel.is_subset(isel) + }) { + kept.push(iid); + } + } + + let Some(&src_idx) = input_source_idx.get(&target.source) else { continue }; + for iid in kept { + let msg = format!( + "contract `{}` has all selectors of interface `{}` but does not explicitly inherit from it", + target.name.as_str(), + gcx.hir.contract(iid).name.as_str(), + ); + ctx.emit_with_msg(&sources[src_idx], &MISSING_INHERITANCE, target.name.span, msg); + } + } + } +} + +/// True if `cid` is an "interface-like" abstract contract: signature-only and free of state, +/// constructors, and modifier bodies. Such contracts mirror the role of `interface` and are +/// candidate interfaces for the missing-inheritance check. +fn is_signature_only(hir: &Hir<'_>, cid: ContractId) -> bool { + let mut has_function = false; + for &item_id in hir.contract(cid).items { + match item_id { + ItemId::Variable(_) => return false, + ItemId::Function(fid) => { + let func = hir.function(fid); + match func.kind { + FunctionKind::Function if func.body.is_none() => has_function = true, + FunctionKind::Modifier if func.body.is_none() => {} + _ => return false, + } + } + _ => {} + } + } + has_function +} diff --git a/crates/lint/src/sol/info/mixed_case.rs b/crates/lint/src/sol/info/mixed_case.rs index 4bceb257dbd2c..f8b24ad117ca4 100644 --- a/crates/lint/src/sol/info/mixed_case.rs +++ b/crates/lint/src/sol/info/mixed_case.rs @@ -1,64 +1,95 @@ -use super::{MixedCaseFunction, MixedCaseVariable}; use crate::{ linter::{EarlyLintPass, LintContext}, - sol::{Severity, SolLint}, + sol::{ + Severity, SolLint, + naming::{ + check_mixed_case as check_mixed_case_pure, check_screaming_snake_case, emit_rename, + has_acronym_exception, + }, + }, }; -use solar_ast::{ItemFunction, VariableDefinition}; +use foundry_config::lint::LintSpecificConfig; +use solar::ast::{FunctionHeader, ItemFunction, VariableDefinition, Visibility}; +use std::sync::Arc; declare_forge_lint!( MIXED_CASE_FUNCTION, Severity::Info, "mixed-case-function", - "function names should use mixedCase" + "function name is not `mixedCase`" ); -impl<'ast> EarlyLintPass<'ast> for MixedCaseFunction { - fn check_item_function(&mut self, ctx: &LintContext<'_>, func: &'ast ItemFunction<'ast>) { - if let Some(name) = func.header.name - && !is_mixed_case(name.as_str(), true) - { - ctx.emit(&MIXED_CASE_FUNCTION, name.span); - } - } -} - declare_forge_lint!( MIXED_CASE_VARIABLE, Severity::Info, "mixed-case-variable", - "mutable variables should use mixedCase" + "mutable variable name is not `mixedCase`" ); -impl<'ast> EarlyLintPass<'ast> for MixedCaseVariable { +/// Checks function names when `FUNCTIONS` is set, mutable variable names otherwise. +#[derive(Debug)] +pub(super) struct MixedCasePass { + config: Arc, +} + +pub(super) type MixedCaseFunctionPass = MixedCasePass; +pub(super) type MixedCaseVariablePass = MixedCasePass; + +impl MixedCasePass { + pub(super) const fn new(config: Arc) -> Self { + Self { config } + } +} + +impl<'ast, const FUNCTIONS: bool> EarlyLintPass<'ast> for MixedCasePass { + fn check_item_function(&mut self, ctx: &LintContext, func: &'ast ItemFunction<'ast>) { + if FUNCTIONS + && let Some(name) = func.header.name + && let Some(expected) = + check_mixed_case(name.as_str(), true, &self.config.mixed_case_exceptions) + && !is_constant_getter(&func.header) + { + emit_rename(ctx, &MIXED_CASE_FUNCTION, name.span, expected); + } + } + fn check_variable_definition( &mut self, - ctx: &LintContext<'_>, + ctx: &LintContext, var: &'ast VariableDefinition<'ast>, ) { - if var.mutability.is_none() + if !FUNCTIONS + && var.mutability.is_none() && let Some(name) = var.name - && !is_mixed_case(name.as_str(), false) + && let Some(expected) = + check_mixed_case(name.as_str(), false, &self.config.mixed_case_exceptions) { - ctx.emit(&MIXED_CASE_VARIABLE, name.span); + emit_rename(ctx, &MIXED_CASE_VARIABLE, name.span, expected); } } } -/// Check if a string is mixedCase -/// -/// To avoid false positives like `fn increment()` or `uint256 counter`, -/// lowercase strings are treated as mixedCase. -pub fn is_mixed_case(s: &str, is_fn: bool) -> bool { - if s.len() <= 1 { - return true; +/// Wraps [`check_mixed_case_pure`] with two domain exceptions: foundry test-function prefixes +/// and user-defined infix patterns, which split the name into a lowerCamelCase prefix and an +/// UpperCamelCase suffix (allowing leading digits). +fn check_mixed_case(s: &str, is_fn: bool, allowed_patterns: &[String]) -> Option { + if is_fn && ["test", "invariant_", "statefulFuzz"].iter().any(|prefix| s.starts_with(prefix)) { + return None; } - - // Remove leading/trailing underscores like `heck` does - if s.trim_matches('_') == format!("{}", heck::AsLowerCamelCase(s)).as_str() { - return true; + if has_acronym_exception(s, allowed_patterns, |pre| { + pre == heck::AsLowerCamelCase(pre).to_string() + }) { + return None; } + check_mixed_case_pure(s) +} - // Ignore `fn test*`, `fn invariant_*`, and `fn statefulFuzz*` patterns, as they usually contain - // (allowed) underscores. - is_fn && (s.starts_with("test") || s.starts_with("invariant_") || s.starts_with("statefulFuzz")) +/// Heuristic for a getter of a constant: `SCREAMING_SNAKE_CASE` name, `public view` or `external +/// view`, no parameters and exactly one elementary or custom-typed return value. +fn is_constant_getter(header: &FunctionHeader<'_>) -> bool { + matches!(header.visibility(), Some(Visibility::Public | Visibility::External)) + && header.state_mutability().is_view() + && header.parameters.is_empty() + && matches!(header.returns(), [ret] if ret.ty.kind.is_elementary() || ret.ty.kind.is_custom()) + && header.name.is_some_and(|name| check_screaming_snake_case(name.as_str()).is_none()) } diff --git a/crates/lint/src/sol/info/mod.rs b/crates/lint/src/sol/info/mod.rs index 50f6122325427..5f9acdbb4d8bd 100644 --- a/crates/lint/src/sol/info/mod.rs +++ b/crates/lint/src/sol/info/mod.rs @@ -1,21 +1,63 @@ -use crate::sol::{EarlyLintPass, LateLintPass, SolLint}; +use crate::sol::SolLint; +mod boolean_cst; +mod boolean_equal; +mod cyclomatic_complexity; +mod event_fields; +mod function_init_state; +mod imports; +mod incorrect_using_for; +mod inline_assembly; +mod interface_naming; +mod internal_function_used_once; +mod literal_instead_of_constant; +mod low_level_calls; +mod missing_inheritance; mod mixed_case; -use mixed_case::{MIXED_CASE_FUNCTION, MIXED_CASE_VARIABLE}; - +mod modifier_used_only_once; +mod multi_contract_file; +mod named_struct_fields; mod pascal_case; -use pascal_case::PASCAL_CASE_STRUCT; - +mod pragma_directive; +mod redundant_base_constructor_call; mod screaming_snake_case; -use screaming_snake_case::{SCREAMING_SNAKE_CASE_CONSTANT, SCREAMING_SNAKE_CASE_IMMUTABLE}; - -mod imports; -use imports::{UNALIASED_PLAIN_IMPORT, UNUSED_IMPORT}; +mod todo; +mod too_many_digits; +mod unsafe_cheatcodes; +mod unused_error; register_lints!( - (PascalCaseStruct, early, (PASCAL_CASE_STRUCT)), - (MixedCaseVariable, early, (MIXED_CASE_VARIABLE)), - (MixedCaseFunction, early, (MIXED_CASE_FUNCTION)), - (ScreamingSnakeCase, early, (SCREAMING_SNAKE_CASE_CONSTANT, SCREAMING_SNAKE_CASE_IMMUTABLE)), - (Imports, early, (UNALIASED_PLAIN_IMPORT, UNUSED_IMPORT)) + boolean_cst: (BooleanCst, early, (BOOLEAN_CST)); + boolean_equal: (BooleanEqual, early, (BOOLEAN_EQUAL)); + pascal_case: (PascalCaseStruct, early, (PASCAL_CASE_STRUCT), PascalCaseStructPass::new); + screaming_snake_case: ( + ScreamingSnakeCase, + early, + (SCREAMING_SNAKE_CASE_CONSTANT, SCREAMING_SNAKE_CASE_IMMUTABLE) + ); + mixed_case: + (MixedCaseVariable, early, (MIXED_CASE_VARIABLE), MixedCaseVariablePass::new), + (MixedCaseFunction, early, (MIXED_CASE_FUNCTION), MixedCaseFunctionPass::new); + imports: (Imports, early, (UNALIASED_PLAIN_IMPORT, UNUSED_IMPORT)); + named_struct_fields: (NamedStructFields, late, (NAMED_STRUCT_FIELDS)); + unsafe_cheatcodes: (UnsafeCheatcodes, early, (UNSAFE_CHEATCODE_USAGE)); + multi_contract_file: + (MultiContractFile, early, (MULTI_CONTRACT_FILE), MultiContractFilePass::new); + interface_naming: (InterfaceFileNaming, early, (INTERFACE_FILE_NAMING, INTERFACE_NAMING)); + too_many_digits: (TooManyDigits, early, (TOO_MANY_DIGITS)); + pragma_directive: (PragmaDirective, project, (PRAGMA_INCONSISTENT)); + inline_assembly: (InlineAssembly, early, (INLINE_ASSEMBLY)); + low_level_calls: (LowLevelCalls, early, (LOW_LEVEL_CALLS)); + redundant_base_constructor_call: + (RedundantBaseConstructorCall, late, (REDUNDANT_BASE_CONSTRUCTOR_CALL)); + missing_inheritance: (MissingInheritance, project, (MISSING_INHERITANCE)); + event_fields: (EventFields, early, (EVENT_FIELDS)); + todo: (TodoComment, early, (TODO_COMMENT)); + unused_error: (UnusedError, project, (UNUSED_ERROR)); + literal_instead_of_constant: (LiteralInsteadOfConstant, late, (LITERAL_INSTEAD_OF_CONSTANT)); + function_init_state: (FunctionInitState, late, (FUNCTION_INIT_STATE)); + internal_function_used_once: (InternalFunctionUsedOnce, project, (INTERNAL_FUNCTION_USED_ONCE)); + cyclomatic_complexity: (CyclomaticComplexity, late, (CYCLOMATIC_COMPLEXITY)); + incorrect_using_for: (IncorrectUsingFor, late, (INCORRECT_USING_FOR)); + modifier_used_only_once: (ModifierUsedOnlyOnce, project, (MODIFIER_USED_ONLY_ONCE)); ); diff --git a/crates/lint/src/sol/info/modifier_used_only_once.rs b/crates/lint/src/sol/info/modifier_used_only_once.rs new file mode 100644 index 0000000000000..c40a8c63f5ee3 --- /dev/null +++ b/crates/lint/src/sol/info/modifier_used_only_once.rs @@ -0,0 +1,64 @@ +use super::ModifierUsedOnlyOnce; +use crate::{ + linter::{Lint, ProjectLintEmitter, ProjectLintPass, ProjectSource}, + sol::{Severity, SolLint}, +}; +use solar::{ast::FunctionKind, interface::source_map::FileName, sema::hir}; +use std::collections::HashMap; + +declare_forge_lint!( + MODIFIER_USED_ONLY_ONCE, + Severity::Info, + "modifier-used-only-once", + "this modifier is used only once" +); + +impl<'ast> ProjectLintPass<'ast> for ModifierUsedOnlyOnce { + fn check_project(&mut self, ctx: &ProjectLintEmitter<'_, '_>, sources: &[ProjectSource<'ast>]) { + if !ctx.is_lint_enabled(MODIFIER_USED_ONLY_ONCE.id()) { + return; + } + + // Only modifiers declared in user-provided files are reported, while invocations are + // counted across the whole unit, dependencies included. + let input_source_idx: HashMap<_, _> = ctx + .gcx() + .hir + .sources_enumerated() + .filter_map(|(sid, src)| { + let FileName::Real(path) = &src.file.name else { return None }; + Some((sid, sources.iter().position(|s| &s.path == path)?)) + }) + .collect(); + if input_source_idx.is_empty() { + return; + } + + // Invocations live in each function's resolved modifier list, where base-constructor + // calls carry a contract id and stay out of the count. + let mut counts = HashMap::::new(); + for function_id in ctx.gcx().hir.function_ids() { + for invocation in ctx.gcx().hir.function(function_id).modifiers { + if let hir::ItemId::Function(modifier_id) = invocation.id { + *counts.entry(modifier_id).or_default() += 1; + } + } + } + + for function_id in ctx.gcx().hir.function_ids() { + let function = ctx.gcx().hir.function(function_id); + let Some(&src_idx) = input_source_idx.get(&function.source) else { continue }; + // Only modifier declarations with a body qualify. `virtual` modifiers and overrides + // exist for dynamic dispatch, so inlining them is not an option. Exactly one + // invocation: zero invocations is dead code, a different concern. + if function.kind == FunctionKind::Modifier + && function.body.is_some() + && !function.virtual_ + && !function.override_ + && counts.get(&function_id) == Some(&1) + { + ctx.emit(&sources[src_idx], &MODIFIER_USED_ONLY_ONCE, function.keyword_span()); + } + } + } +} diff --git a/crates/lint/src/sol/info/multi_contract_file.rs b/crates/lint/src/sol/info/multi_contract_file.rs new file mode 100644 index 0000000000000..f095a603553d6 --- /dev/null +++ b/crates/lint/src/sol/info/multi_contract_file.rs @@ -0,0 +1,53 @@ +use crate::{ + linter::{EarlyLintPass, Lint, LintContext}, + sol::{Severity, SolLint}, +}; +use foundry_config::lint::LintSpecificConfig; +use solar::ast; +use std::sync::Arc; + +declare_forge_lint!( + MULTI_CONTRACT_FILE, + Severity::Info, + "multi-contract-file", + "file contains multiple contracts, interfaces or libraries" +); + +#[derive(Debug)] +pub(super) struct MultiContractFilePass { + config: Arc, +} + +impl MultiContractFilePass { + pub(super) const fn new(config: Arc) -> Self { + Self { config } + } +} + +impl<'ast> EarlyLintPass<'ast> for MultiContractFilePass { + fn check_full_source_unit( + &mut self, + ctx: &LintContext<'ast, '_>, + unit: &'ast ast::SourceUnit<'ast>, + ) { + if !ctx.is_lint_enabled(MULTI_CONTRACT_FILE.id()) { + return; + } + // Every non-exempted contract-like item is flagged when there is more than one. + let spans: Vec<_> = unit + .items + .iter() + .filter_map(|item| match &item.kind { + ast::ItemKind::Contract(c) if !self.config.is_exempted(&c.kind) => { + Some(c.name.span) + } + _ => None, + }) + .collect(); + if spans.len() > 1 { + for span in spans { + ctx.emit(&MULTI_CONTRACT_FILE, span); + } + } + } +} diff --git a/crates/lint/src/sol/info/named_struct_fields.rs b/crates/lint/src/sol/info/named_struct_fields.rs new file mode 100644 index 0000000000000..6234ae474a1d9 --- /dev/null +++ b/crates/lint/src/sol/info/named_struct_fields.rs @@ -0,0 +1,55 @@ +use super::NamedStructFields; +use crate::{ + linter::{LateLintPass, LintContext, Suggestion}, + sol::{Severity, SolLint}, +}; +use solar::{ + interface::diagnostics::Applicability, + sema::{ + Gcx, + hir::{CallArgs, CallArgsKind, Expr, ExprKind, ItemId, Res}, + }, +}; + +declare_forge_lint!( + NAMED_STRUCT_FIELDS, + Severity::Info, + "named-struct-fields", + "struct is initialized with positional fields" +); + +impl<'gcx> LateLintPass<'gcx> for NamedStructFields { + fn check_expr(&mut self, ctx: &LintContext, gcx: Gcx<'gcx>, expr: &'gcx Expr<'gcx>) { + let ExprKind::Call(callee, CallArgs { kind: CallArgsKind::Unnamed(args), .. }) = &expr.kind + else { + return; + }; + let Some(Res::Item(ItemId::Struct(struct_id))) = gcx.resolved_expr(callee) else { return }; + // A fix needs one argument per field and every snippet available; otherwise the + // diagnostic is emitted without a suggestion. + let fields = gcx.hir.strukt(struct_id).fields; + let fix = (!fields.is_empty() && fields.len() == args.len()).then(|| { + let assignments = fields + .iter() + .zip(*args) + .map(|(field, arg)| { + Some(format!( + "{}: {}", + gcx.hir.variable(*field).name?, + ctx.span_to_snippet(arg.span)? + )) + }) + .collect::>>()?; + Some(format!("{}({{ {} }})", ctx.span_to_snippet(callee.span)?, assignments.join(", "))) + }); + match fix.flatten() { + Some(fix) => ctx.emit_with_suggestion( + &NAMED_STRUCT_FIELDS, + expr.span, + Suggestion::fix(fix, Applicability::MachineApplicable) + .with_desc("consider using named fields"), + ), + None => ctx.emit(&NAMED_STRUCT_FIELDS, expr.span), + } + } +} diff --git a/crates/lint/src/sol/info/pascal_case.rs b/crates/lint/src/sol/info/pascal_case.rs index b8e719aa0d762..237e0ce5b4d4e 100644 --- a/crates/lint/src/sol/info/pascal_case.rs +++ b/crates/lint/src/sol/info/pascal_case.rs @@ -1,31 +1,43 @@ -use super::PascalCaseStruct; use crate::{ linter::{EarlyLintPass, LintContext}, - sol::{Severity, SolLint}, + sol::{ + Severity, SolLint, + naming::{check_pascal_case, emit_rename, has_acronym_exception}, + }, }; -use solar_ast::ItemStruct; +use foundry_config::lint::LintSpecificConfig; +use solar::ast::ItemStruct; +use std::sync::Arc; declare_forge_lint!( PASCAL_CASE_STRUCT, Severity::Info, "pascal-case-struct", - "structs should use PascalCase" + "struct name is not `PascalCase`" ); -impl<'ast> EarlyLintPass<'ast> for PascalCaseStruct { - fn check_item_struct(&mut self, ctx: &LintContext<'_>, strukt: &'ast ItemStruct<'ast>) { - let name = strukt.name.as_str(); - if name.len() > 1 && !is_pascal_case(name) { - ctx.emit(&PASCAL_CASE_STRUCT, strukt.name.span); - } - } +#[derive(Debug)] +pub(super) struct PascalCaseStructPass { + config: Arc, } -/// Check if a string is PascalCase -pub fn is_pascal_case(s: &str) -> bool { - if s.len() <= 1 { - return true; +impl PascalCaseStructPass { + pub(super) const fn new(config: Arc) -> Self { + Self { config } } +} - s == format!("{}", heck::AsPascalCase(s)).as_str() +impl<'ast> EarlyLintPass<'ast> for PascalCaseStructPass { + fn check_item_struct(&mut self, ctx: &LintContext, strukt: &'ast ItemStruct<'ast>) { + let name = strukt.name.as_str(); + // The acronym exceptions shared with the `mixed-case-*` lints keep `ERC20Data` valid. + if has_acronym_exception(name, &self.config.mixed_case_exceptions, |pre| { + pre == heck::AsUpperCamelCase(pre).to_string() + }) { + return; + } + if let Some(expected) = check_pascal_case(name) { + emit_rename(ctx, &PASCAL_CASE_STRUCT, strukt.name.span, expected); + } + } } diff --git a/crates/lint/src/sol/info/pragma_directive.rs b/crates/lint/src/sol/info/pragma_directive.rs new file mode 100644 index 0000000000000..8ff6ae26dc3a1 --- /dev/null +++ b/crates/lint/src/sol/info/pragma_directive.rs @@ -0,0 +1,53 @@ +use super::PragmaDirective; +use crate::{ + linter::{Lint, ProjectLintEmitter, ProjectLintPass, ProjectSource}, + sol::{Severity, SolLint}, +}; +use solar::ast; + +declare_forge_lint!( + PRAGMA_INCONSISTENT, + Severity::Info, + "pragma-inconsistent", + "inconsistent Solidity pragma version requirements across the project" +); + +impl<'ast> ProjectLintPass<'ast> for PragmaDirective { + fn check_project(&mut self, ctx: &ProjectLintEmitter<'_, '_>, sources: &[ProjectSource<'ast>]) { + if !ctx.is_lint_enabled(PRAGMA_INCONSISTENT.id()) { + return; + } + // Every `pragma solidity` directive across input sources, with its rendered version + // requirement for grouping, in a stable (path, position) order for snapshots. + let mut entries: Vec<(usize, _, String)> = sources + .iter() + .enumerate() + .flat_map(|(idx, source)| { + source.ast.items.iter().filter_map(move |item| match &item.kind { + ast::ItemKind::Pragma(ast::PragmaDirective { + tokens: ast::PragmaTokens::Version(ident, req), + .. + }) if ident.as_str() == "solidity" => Some((idx, item.span, req.to_string())), + _ => None, + }) + }) + .collect(); + entries.sort_by(|a, b| { + sources[a.0].path.cmp(&sources[b.0].path).then(a.1.lo().cmp(&b.1.lo())) + }); + + let mut distinct: Vec<&str> = entries.iter().map(|(_, _, req)| req.as_str()).collect(); + distinct.sort_unstable(); + distinct.dedup(); + if let [(idx, span, _), ..] = entries.as_slice() + && distinct.len() > 1 + { + let msg = format!( + "{} different Solidity pragma version requirements are used: `{}`", + distinct.len(), + distinct.join("`, `") + ); + ctx.emit_with_msg(&sources[*idx], &PRAGMA_INCONSISTENT, *span, msg); + } + } +} diff --git a/crates/lint/src/sol/info/redundant_base_constructor_call.rs b/crates/lint/src/sol/info/redundant_base_constructor_call.rs new file mode 100644 index 0000000000000..def8a5415fad2 --- /dev/null +++ b/crates/lint/src/sol/info/redundant_base_constructor_call.rs @@ -0,0 +1,85 @@ +use super::RedundantBaseConstructorCall; +use crate::{ + linter::{LateLintPass, LintContext, Suggestion}, + sol::{Severity, SolLint}, +}; +use solar::{ + interface::{BytePos, Span, diagnostics::Applicability}, + sema::{Gcx, hir}, +}; + +declare_forge_lint!( + REDUNDANT_BASE_CONSTRUCTOR_CALL, + Severity::Info, + "redundant-base-constructor-call", + "explicit empty base-constructor arguments are redundant" +); + +impl<'gcx> LateLintPass<'gcx> for RedundantBaseConstructorCall { + fn check_contract( + &mut self, + ctx: &LintContext, + gcx: Gcx<'gcx>, + contract: &'gcx hir::Contract<'gcx>, + ) { + // `contract X is A(), B()` clauses: removing only the `()` is enough, `is A` is valid. + for m in contract.bases_args { + try_emit(ctx, &gcx.hir, m, m.args.span); + } + } + + fn check_function( + &mut self, + ctx: &LintContext, + gcx: Gcx<'gcx>, + func: &'gcx hir::Function<'gcx>, + ) { + // `constructor() A() {}` modifier-style base calls. The bare base name `A` is not valid + // in a constructor's modifier list, so the whole `A()` must be removed, along with one + // leading whitespace char to avoid leaving a double space. + if func.kind == hir::FunctionKind::Constructor { + for m in func.modifiers { + try_emit(ctx, &gcx.hir, m, expand_to_leading_ws(ctx, m.span)); + } + } + } +} + +fn try_emit(ctx: &LintContext, hir: &hir::Hir<'_>, m: &hir::Modifier<'_>, fix_span: Span) { + // Base-constructor invocations resolve to a contract; real modifiers resolve to functions. + // `is A` (no parens written) and `A(args...)` with real arguments are not redundant. + let hir::ItemId::Contract(base_id) = m.id else { return }; + if m.args.is_dummy() || !m.args.is_empty() { + return; + } + // Empty `()` is redundant only when the base constructor takes no parameters (or the base + // declares no constructor at all). + if hir.contract(base_id).ctor.is_some_and(|c| !hir.function(c).parameters.is_empty()) { + return; + } + // Only emit a machine-applicable fix if the args span really is just `()` (no comments, + // whitespace, etc. that would silently be dropped). + if ctx.span_to_snippet(m.args.span).is_some_and(|s| s.trim() == "()") { + ctx.emit_with_suggestion( + &REDUNDANT_BASE_CONSTRUCTOR_CALL, + m.args.span, + Suggestion::fix(String::new(), Applicability::MachineApplicable) + .with_span(fix_span) + .with_desc("remove redundant base-constructor call"), + ); + } else { + ctx.emit(&REDUNDANT_BASE_CONSTRUCTOR_CALL, m.args.span); + } +} + +/// Extends `span` to start one byte earlier when that byte is an ASCII space or tab. +fn expand_to_leading_ws(ctx: &LintContext, span: Span) -> Span { + if span.is_dummy() || span.lo() == BytePos(0) { + return span; + } + let lo = span.lo() - BytePos(1); + match ctx.span_to_snippet(Span::new(lo, span.lo())).as_deref() { + Some(" " | "\t") => span.with_lo(lo), + _ => span, + } +} diff --git a/crates/lint/src/sol/info/screaming_snake_case.rs b/crates/lint/src/sol/info/screaming_snake_case.rs index 28ca4c54c7b26..ca51515f912b4 100644 --- a/crates/lint/src/sol/info/screaming_snake_case.rs +++ b/crates/lint/src/sol/info/screaming_snake_case.rs @@ -1,50 +1,41 @@ use super::ScreamingSnakeCase; use crate::{ linter::{EarlyLintPass, LintContext}, - sol::{Severity, SolLint}, + sol::{ + Severity, SolLint, + naming::{check_screaming_snake_case, emit_rename}, + }, }; -use solar_ast::{VarMut, VariableDefinition}; +use solar::ast::{VarMut, VariableDefinition}; declare_forge_lint!( SCREAMING_SNAKE_CASE_CONSTANT, Severity::Info, "screaming-snake-case-const", - "constants should use SCREAMING_SNAKE_CASE" + "constant name is not `SCREAMING_SNAKE_CASE`" ); declare_forge_lint!( SCREAMING_SNAKE_CASE_IMMUTABLE, Severity::Info, "screaming-snake-case-immutable", - "immutables should use SCREAMING_SNAKE_CASE" + "immutable name is not `SCREAMING_SNAKE_CASE`" ); impl<'ast> EarlyLintPass<'ast> for ScreamingSnakeCase { fn check_variable_definition( &mut self, - ctx: &LintContext<'_>, + ctx: &LintContext, var: &'ast VariableDefinition<'ast>, ) { - if let (Some(name), Some(mutability)) = (var.name, var.mutability) { - let name_str = name.as_str(); - if name_str.len() < 2 || is_screaming_snake_case(name_str) { - return; - } - - match mutability { - VarMut::Constant => ctx.emit(&SCREAMING_SNAKE_CASE_CONSTANT, name.span), - VarMut::Immutable => ctx.emit(&SCREAMING_SNAKE_CASE_IMMUTABLE, name.span), - } + if let (Some(name), Some(mutability)) = (var.name, var.mutability) + && let Some(expected) = check_screaming_snake_case(name.as_str()) + { + let lint = match mutability { + VarMut::Constant => &SCREAMING_SNAKE_CASE_CONSTANT, + VarMut::Immutable => &SCREAMING_SNAKE_CASE_IMMUTABLE, + }; + emit_rename(ctx, lint, name.span, expected); } } } - -/// Check if a string is SCREAMING_SNAKE_CASE. Numbers don't need to be preceded by an underscore. -pub fn is_screaming_snake_case(s: &str) -> bool { - if s.len() <= 1 { - return true; - } - - // Remove leading/trailing underscores like `heck` does - s.trim_matches('_') == format!("{}", heck::AsShoutySnakeCase(s)).as_str() -} diff --git a/crates/lint/src/sol/info/todo.rs b/crates/lint/src/sol/info/todo.rs new file mode 100644 index 0000000000000..8229f5c68ce4e --- /dev/null +++ b/crates/lint/src/sol/info/todo.rs @@ -0,0 +1,84 @@ +use super::TodoComment; +use crate::{ + linter::{EarlyLintPass, Lint, LintContext}, + sol::{Severity, SolLint}, +}; +use foundry_common::comments::{Comment, Comments}; +use solar::ast; + +declare_forge_lint!( + TODO_COMMENT, + Severity::Info, + "todo-comment", + "unresolved `TODO` or `FIXME` comment" +); + +const MARKERS: &[&str] = &["TODO", "FIXME"]; + +/// Characters that may directly follow a marker and still count as a real marker. +const TRAILING: &[char] = &[':', '(', ',', ';', '.', ')']; + +impl<'ast> EarlyLintPass<'ast> for TodoComment { + fn check_full_source_unit( + &mut self, + ctx: &LintContext<'ast, '_>, + _ast: &'ast ast::SourceUnit<'ast>, + ) { + if !ctx.is_lint_enabled(TODO_COMMENT.id()) { + return; + } + let Some(file) = ctx.source_file() else { return }; + let comments = Comments::new(file, ctx.session().source_map(), false, false, None); + for comment in comments.iter().filter(|comment| !is_control_comment(comment)) { + let mut found = Vec::new(); + // Unnormalized block comments are stored as one string, so split physical lines here. + for line in comment.lines.iter().flat_map(|line| line.lines()) { + // A bare marker only counts at the start of a line or right after a NatSpec tag. + let mut allow_bare = true; + for token in strip_comment_prefix(line, comment).split_whitespace() { + if let Some(marker) = marker_at_start(token, allow_bare) + && !found.contains(&marker) + { + found.push(marker); + } + if token != "*" { + allow_bare = token.starts_with('@'); + } + } + } + if !found.is_empty() { + let noun = if found.len() > 1 { "comments" } else { "comment" }; + let msg = format!("unresolved `{}` {noun}", found.join(", ")); + ctx.emit_with_msg(&TODO_COMMENT, comment.span, msg); + } + } + } +} + +fn is_control_comment(comment: &Comment) -> bool { + comment.lines.first().is_some_and(|first_line| { + let content = strip_comment_prefix(first_line, comment).trim_start(); + content.starts_with("@compile-flags:") || content.starts_with("forge-lint:") + }) +} + +/// If `token` begins with a marker followed by a valid boundary, return that marker. +fn marker_at_start(token: &str, allow_bare: bool) -> Option<&str> { + MARKERS.iter().copied().find(|m| { + let Some((prefix, suffix)) = token.split_at_checked(m.len()) else { return false }; + if !prefix.eq_ignore_ascii_case(m) { + return false; + } + let mut trailing = suffix.chars(); + match trailing.next() { + None => allow_bare, + // A `.` must end the marker, not start an identifier (`TODO.md`). + Some('.') => !trailing.next().is_some_and(|c| c.is_alphanumeric() || c == '_'), + Some(after) => TRAILING.contains(&after), + } + }) +} + +fn strip_comment_prefix<'a>(line: &'a str, comment: &Comment) -> &'a str { + comment.prefix().and_then(|p| line.strip_prefix(p)).unwrap_or(line) +} diff --git a/crates/lint/src/sol/info/too_many_digits.rs b/crates/lint/src/sol/info/too_many_digits.rs new file mode 100644 index 0000000000000..cb91bee7e096d --- /dev/null +++ b/crates/lint/src/sol/info/too_many_digits.rs @@ -0,0 +1,66 @@ +use super::TooManyDigits; +use crate::{ + linter::{EarlyLintPass, LintContext}, + sol::{Severity, SolLint}, +}; +use solar::{ + ast::{Expr, ExprKind, Lit, LitKind, Stmt, StmtKind, visit::Visit}, + data_structures::Never, +}; +use std::ops::ControlFlow; + +declare_forge_lint!( + TOO_MANY_DIGITS, + Severity::Info, + "too-many-digits", + "numeric literal contains a long sequence of zeros" +); + +impl<'ast> EarlyLintPass<'ast> for TooManyDigits { + fn check_stmt(&mut self, ctx: &LintContext, stmt: &'ast Stmt<'ast>) { + // Yul literals are not `Expr`s, so `check_expr` never sees them. + if let StmtKind::Assembly(assembly) = &stmt.kind { + let _ = YulLiterals { ctx }.visit_yul_block(&assembly.block); + } + } + + fn check_expr(&mut self, ctx: &LintContext, expr: &'ast Expr<'ast>) { + // Skip literals with a sub-denomination, e.g. `1000000 gwei`, `5 minutes`. + if let ExprKind::Lit(lit, None) = &expr.kind { + check_lit(ctx, lit); + } + } +} + +fn check_lit(ctx: &LintContext, lit: &Lit<'_>) { + // Only plain integer literals; `LitKind::Address` is a distinct variant. + if !matches!(lit.kind, LitKind::Number(_)) { + return; + } + let s = lit.symbol.as_str(); + let hex = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")); + // Match Slither's detector: skip only address-shaped hex constants, not all hex constants + // (long padded masks/selectors are still hard to review), and scientific notation (`1e18`). + let is_hex_address = + hex.is_some_and(|h| h.len() == 40 && h.bytes().all(|b| b.is_ascii_hexdigit())); + let is_scientific = hex.is_none() && s.contains(['e', 'E']); + // 5+ consecutive zeros in the literal as written. Underscores are preserved, so + // `1_000_000` passes while `1_000000` is flagged. + if !is_hex_address && !is_scientific && s.contains("00000") { + ctx.emit(&TOO_MANY_DIGITS, lit.span); + } +} + +/// Checks every literal of an assembly block, `case` labels included. +struct YulLiterals<'a, 's> { + ctx: &'a LintContext<'s, 'a>, +} + +impl<'ast> Visit<'ast> for YulLiterals<'_, '_> { + type BreakValue = Never; + + fn visit_lit(&mut self, lit: &'ast Lit<'_>) -> ControlFlow { + check_lit(self.ctx, lit); + ControlFlow::Continue(()) + } +} diff --git a/crates/lint/src/sol/info/unsafe_cheatcodes.rs b/crates/lint/src/sol/info/unsafe_cheatcodes.rs new file mode 100644 index 0000000000000..70c9c2aac265e --- /dev/null +++ b/crates/lint/src/sol/info/unsafe_cheatcodes.rs @@ -0,0 +1,36 @@ +use super::UnsafeCheatcodes; +use crate::{ + linter::{EarlyLintPass, LintContext}, + sol::{Severity, SolLint}, +}; +use solar::ast::{Expr, ExprKind}; + +declare_forge_lint!( + UNSAFE_CHEATCODE_USAGE, + Severity::Info, + "unsafe-cheatcode", + "usage of unsafe cheatcodes that can perform dangerous operations" +); + +const UNSAFE_CHEATCODES: &[&str] = &[ + "ffi", + "readFile", + "readLine", + "writeFile", + "writeLine", + "removeFile", + "closeFile", + "setEnv", + "deriveKey", +]; + +impl<'ast> EarlyLintPass<'ast> for UnsafeCheatcodes { + fn check_expr(&mut self, ctx: &LintContext, expr: &'ast Expr<'ast>) { + if let ExprKind::Call(callee, _) = &expr.kind + && let ExprKind::Member(_, member) = &callee.kind + && UNSAFE_CHEATCODES.contains(&member.as_str()) + { + ctx.emit(&UNSAFE_CHEATCODE_USAGE, member.span); + } + } +} diff --git a/crates/lint/src/sol/info/unused_error.rs b/crates/lint/src/sol/info/unused_error.rs new file mode 100644 index 0000000000000..bb6aa00cf6ca0 --- /dev/null +++ b/crates/lint/src/sol/info/unused_error.rs @@ -0,0 +1,84 @@ +use crate::{ + linter::{Lint, ProjectLintEmitter, ProjectLintPass, ProjectSource}, + sol::{Severity, SolLint, info::UnusedError}, +}; +use solar::{ + ast::ContractKind, + interface::{data_structures::Never, source_map::FileName}, + sema::{ + Gcx, + hir::{self, Visit as _}, + }, +}; +use std::{ + collections::{HashMap, HashSet}, + ops::ControlFlow, +}; + +declare_forge_lint!(UNUSED_ERROR, Severity::Info, "unused-error", "custom error is never used"); + +impl<'ast> ProjectLintPass<'ast> for UnusedError { + fn check_project(&mut self, ctx: &ProjectLintEmitter<'_, '_>, sources: &[ProjectSource<'ast>]) { + if !ctx.is_lint_enabled(UNUSED_ERROR.id()) { + return; + } + let gcx = ctx.gcx(); + + // Only errors declared in user-provided files are reported, while uses are collected + // across the whole unit, so an error declared here and reverted in a dependency (or the + // other way around) is attributed correctly. + let input_source_idx: HashMap<_, _> = gcx + .hir + .sources_enumerated() + .filter_map(|(sid, src)| { + let FileName::Real(path) = &src.file.name else { return None }; + Some((sid, sources.iter().position(|s| &s.path == path)?)) + }) + .collect(); + if input_source_idx.is_empty() { + return; + } + + let mut collector = UsedErrorCollector { gcx, used: HashSet::new() }; + for source_id in gcx.hir.source_ids() { + let _ = collector.visit_nested_source(source_id); + } + + for error_id in gcx.hir.error_ids() { + let error = gcx.hir.error(error_id); + let Some(&src_idx) = input_source_idx.get(&error.source) else { continue }; + // Errors declared in interfaces and abstract contracts are ABI surface meant for + // implementers and off-chain consumers, which may live outside the compiled sources. + let abi_surface = error.contract.is_some_and(|id| { + matches!( + gcx.hir.contract(id).kind, + ContractKind::Interface | ContractKind::AbstractContract + ) + }); + if !abi_surface && !collector.used.contains(&error_id) { + ctx.emit(&sources[src_idx], &UNUSED_ERROR, error.span); + } + } + } +} + +/// Collects every error referenced by an expression anywhere in the unit. +struct UsedErrorCollector<'gcx> { + gcx: Gcx<'gcx>, + used: HashSet, +} + +impl<'gcx> hir::Visit<'gcx> for UsedErrorCollector<'gcx> { + type BreakValue = Never; + + fn hir(&self) -> &'gcx hir::Hir<'gcx> { + &self.gcx.hir + } + + fn visit_expr(&mut self, expr: &'gcx hir::Expr<'gcx>) -> ControlFlow { + if let Some(hir::Res::Item(hir::ItemId::Error(error_id))) = self.gcx.resolved_expr(expr) { + self.used.insert(error_id); + } + self.walk_expr(expr) + } +} diff --git a/crates/lint/src/sol/low/block_timestamp.rs b/crates/lint/src/sol/low/block_timestamp.rs new file mode 100644 index 0000000000000..48128e692c151 --- /dev/null +++ b/crates/lint/src/sol/low/block_timestamp.rs @@ -0,0 +1,298 @@ +use super::BlockTimestamp; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{ + Severity, SolLint, + analysis::{any_subexpr, branch_always_exits, loop_stmts, tuple_elems}, + }, +}; +use solar::{ + ast::Visibility, + sema::{ + Gcx, Hir, + builtins::Builtin, + hir::{BinOpKind, Expr, ExprKind, Function, FunctionId, Stmt, StmtKind, VariableId, Visit}, + }, +}; +use std::{collections::HashSet, convert::Infallible, ops::ControlFlow}; + +declare_forge_lint!( + BLOCK_TIMESTAMP, + Severity::Low, + "block-timestamp", + "usage of `block.timestamp` in a comparison may be manipulated by validators" +); + +impl<'gcx> LateLintPass<'gcx> for BlockTimestamp { + fn check_function(&mut self, ctx: &LintContext, gcx: Gcx<'gcx>, func: &'gcx Function<'gcx>) { + let Some(body) = func.body else { return }; + // The contract's own internal helpers that return `block.timestamp` directly. + let helpers = func + .contract + .map(|c| gcx.hir.contract(c).functions()) + .into_iter() + .flatten() + .filter(|&id| { + let helper = gcx.hir.function(id); + matches!(helper.visibility, Visibility::Internal | Visibility::Private) + && helper.body.is_some_and(|body| returns_timestamp(gcx, body.stmts)) + }) + .collect(); + Checker { ctx, gcx, helpers, aliases: HashSet::new() }.block(body.stmts); + } +} + +/// Flow-sensitive walk reporting comparisons involving `block.timestamp`, a helper returning it, +/// or a local holding a value derived from either. +struct Checker<'a, 's, 'c, 'gcx> { + ctx: &'a LintContext<'s, 'c>, + gcx: Gcx<'gcx>, + helpers: Vec, + /// Locals currently holding a timestamp-derived value. + aliases: HashSet, +} + +impl<'gcx> Checker<'_, '_, '_, 'gcx> { + /// Walks statements in order, stopping at the first one control cannot continue past. + fn block(&mut self, stmts: impl IntoIterator>) { + for stmt in stmts { + let _ = self.visit_stmt(stmt); + if branch_always_exits(self.gcx, stmt) { + break; + } + } + } + + /// Walks an alternative arm on a copy of the current aliases; when control can continue past + /// `stmts`, the aliases the arm leaves are added to `merged`. + fn arm( + &mut self, + merged: &mut HashSet, + stmts: impl IntoIterator>, + walk: impl FnOnce(&mut Self), + ) { + let saved = self.aliases.clone(); + walk(self); + let aliases = std::mem::replace(&mut self.aliases, saved); + if !stmts.into_iter().any(|expr| branch_always_exits(self.gcx, expr)) { + merged.extend(aliases); + } + } + + /// Binds the variables of an lvalue (tuple targets included) to whether they now hold a + /// timestamp-derived value. + fn bind(&mut self, lhs: &Expr<'_>, is_source: bool) { + match &lhs.peel_parens().kind { + ExprKind::Tuple(elems) => elems.iter().flatten().for_each(|e| self.bind(e, is_source)), + ExprKind::Ident(_) => { + if let Some(var) = self.gcx.resolved_variable(lhs) { + self.set_alias(var, is_source); + } + } + _ => {} + } + } + + fn set_alias(&mut self, var: VariableId, is_source: bool) { + if self.gcx.hir.variable(var).is_local_or_return() { + if is_source { + self.aliases.insert(var); + } else { + self.aliases.remove(&var); + } + } + } + + /// Whether each of `n` targets receives a timestamp-derived value from `rhs`: element-wise + /// for a matching tuple, otherwise `rhs` as a whole for every target. + fn source_values(&self, rhs: &Expr<'_>, n: usize) -> Vec { + match tuple_elems(rhs) { + Some(elems) if elems.len() == n => { + elems.iter().map(|e| e.is_some_and(|e| self.is_source_value(e))).collect() + } + _ => vec![self.is_source_value(rhs); n], + } + } + + /// True if the value of `expr` derives from a timestamp source: the source itself, or one + /// flowing through arithmetic, unary operators, a ternary arm or a parenthesized tuple. + fn is_source_value(&self, expr: &Expr<'_>) -> bool { + self.is_source(expr) + || match &expr.peel_parens().kind { + ExprKind::Binary(lhs, op, rhs) if !is_cmp(op.kind) => { + self.is_source_value(lhs) || self.is_source_value(rhs) + } + ExprKind::Unary(_, inner) + | ExprKind::Payable(inner) + | ExprKind::YulMember(inner, _) => self.is_source_value(inner), + ExprKind::Ternary(_, then_expr, else_expr) => { + self.is_source_value(then_expr) || self.is_source_value(else_expr) + } + ExprKind::Tuple([Some(inner)]) => self.is_source_value(inner), + _ => false, + } + } + + /// `block.timestamp`, a call to a helper returning it, or an alias of either. + fn is_source(&self, expr: &Expr<'_>) -> bool { + is_block_timestamp(self.gcx, expr) + || self.gcx.resolved_variable(expr).is_some_and(|var| self.aliases.contains(&var)) + || matches!(&expr.peel_parens().kind, ExprKind::Call(callee, ..) + if self.gcx.resolved_function(callee).is_some_and(|id| self.helpers.contains(&id))) + } +} + +impl<'gcx> Visit<'gcx> for Checker<'_, '_, '_, 'gcx> { + type BreakValue = Infallible; + + fn hir(&self) -> &'gcx Hir<'gcx> { + &self.gcx.hir + } + + fn visit_stmt(&mut self, stmt: &'gcx Stmt<'gcx>) -> ControlFlow { + match &stmt.kind { + StmtKind::DeclSingle(var) => { + if let Some(init) = self.gcx.hir.variable(*var).initializer { + self.visit_expr(init)?; + let is_source = self.is_source_value(init); + self.set_alias(*var, is_source); + } + } + StmtKind::DeclMulti(vars, expr) => { + self.visit_expr(expr)?; + for (var, is_source) in vars.iter().zip(self.source_values(expr, vars.len())) { + if let Some(var) = var { + self.set_alias(*var, is_source); + } + } + } + StmtKind::Block(block) + | StmtKind::UncheckedBlock(block) + | StmtKind::AssemblyBlock(block) => { + self.block(block.stmts); + } + // Only the arms control can continue past contribute their aliases. + StmtKind::If(cond, then_stmt, else_stmt) => { + self.visit_expr(cond)?; + let mut merged = HashSet::new(); + self.arm(&mut merged, std::slice::from_ref(*then_stmt), |s| { + let _ = s.visit_stmt(then_stmt); + }); + match else_stmt { + Some(else_stmt) => { + self.arm(&mut merged, std::slice::from_ref(*else_stmt), |s| { + let _ = s.visit_stmt(else_stmt); + }) + } + None => merged.extend(self.aliases.iter().copied()), + } + self.aliases = merged; + } + StmtKind::Loop(block, source) => { + let mut merged = self.aliases.clone(); + let stmts = loop_stmts(*block, *source); + self.arm(&mut merged, stmts.clone(), |s| s.block(stmts)); + self.aliases = merged; + } + StmtKind::Try(try_stmt) => { + self.visit_expr(&try_stmt.expr)?; + let mut merged = self.aliases.clone(); + for clause in try_stmt.clauses { + self.arm(&mut merged, clause.block.stmts, |s| s.block(clause.block.stmts)); + } + self.aliases = merged; + } + StmtKind::Switch(switch) => { + self.visit_expr(switch.selector)?; + let mut merged = self.aliases.clone(); + for case in switch.cases { + self.arm(&mut merged, case.body.stmts, |s| s.block(case.body.stmts)); + } + self.aliases = merged; + } + _ => self.walk_stmt(stmt)?, + } + ControlFlow::Continue(()) + } + + fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow { + match &expr.peel_parens().kind { + // The right-hand side is evaluated first, against the aliases before the write. + ExprKind::Assign(lhs, op, rhs) => { + self.visit_expr(rhs)?; + if op.is_some() { + self.visit_expr(lhs)?; + let is_source = self.is_source_value(rhs) || self.is_source_value(lhs); + self.bind(lhs, is_source); + } else if let Some(elems) = tuple_elems(lhs) { + for (elem, is_source) in elems.iter().zip(self.source_values(rhs, elems.len())) + { + if let Some(elem) = elem { + self.bind(elem, is_source); + } + } + } else { + let is_source = self.is_source_value(rhs); + self.bind(lhs, is_source); + } + } + ExprKind::Binary(lhs, op, rhs) => { + if is_cmp(op.kind) && (self.contains_source(lhs) || self.contains_source(rhs)) { + self.ctx.emit(&BLOCK_TIMESTAMP, expr.span); + } + self.visit_expr(lhs)?; + self.visit_expr(rhs)?; + } + ExprKind::Ternary(cond, then_expr, else_expr) => { + self.visit_expr(cond)?; + let mut merged = HashSet::new(); + self.arm(&mut merged, &[], |s| { + let _ = s.visit_expr(then_expr); + }); + self.visit_expr(else_expr)?; + self.aliases.extend(merged); + } + _ => self.walk_expr(expr)?, + } + ControlFlow::Continue(()) + } +} + +impl<'gcx> Checker<'_, '_, '_, 'gcx> { + /// True if `expr` or any subexpression is a timestamp source. + fn contains_source(&self, expr: &'gcx Expr<'gcx>) -> bool { + any_subexpr(expr, |e| self.is_source(e)) + } +} + +const fn is_cmp(kind: BinOpKind) -> bool { + matches!( + kind, + BinOpKind::Lt + | BinOpKind::Le + | BinOpKind::Gt + | BinOpKind::Ge + | BinOpKind::Eq + | BinOpKind::Ne + ) +} + +/// `block.timestamp`, or the Yul `timestamp()` builtin. +fn is_block_timestamp(gcx: Gcx<'_>, expr: &Expr<'_>) -> bool { + gcx.resolved_builtin(expr) == Some(Builtin::BlockTimestamp) +} + +/// True if a `return` reachable through plain blocks and `if` arms mentions `block.timestamp`. +fn returns_timestamp(gcx: Gcx<'_>, stmts: &[Stmt<'_>]) -> bool { + stmts.iter().any(|stmt| match &stmt.kind { + StmtKind::Return(Some(expr)) => any_subexpr(expr, |expr| is_block_timestamp(gcx, expr)), + StmtKind::Block(block) | StmtKind::UncheckedBlock(block) => { + returns_timestamp(gcx, block.stmts) + } + StmtKind::If(_, then_stmt, else_stmt) => { + returns_timestamp(gcx, std::slice::from_ref(*then_stmt)) + || else_stmt.is_some_and(|e| returns_timestamp(gcx, std::slice::from_ref(e))) + } + _ => false, + }) +} diff --git a/crates/lint/src/sol/low/calls_loop.rs b/crates/lint/src/sol/low/calls_loop.rs new file mode 100644 index 0000000000000..5cc0a08592344 --- /dev/null +++ b/crates/lint/src/sol/low/calls_loop.rs @@ -0,0 +1,82 @@ +use super::{ + CallsLoop, + payable_loop::{LoopItem, for_each_loop_item}, +}; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{Severity, SolLint}, +}; +use solar::{ + ast::StateMutability, + sema::{ + Gcx, + builtins::Builtin, + hir::{Expr, ExprKind, Function}, + ty::{TyFnKind, TyKind}, + }, +}; + +declare_forge_lint!(CALLS_LOOP, Severity::Low, "calls-loop", "external call inside a loop"); + +impl<'gcx> LateLintPass<'gcx> for CallsLoop { + fn check_function(&mut self, ctx: &LintContext, gcx: Gcx<'gcx>, func: &'gcx Function<'gcx>) { + for_each_loop_item(gcx, func, false, |item| { + if let LoopItem::Expr(expr) = item + && let ExprKind::Call(callee, ..) = &expr.kind + && is_external_call(gcx, callee) + { + ctx.emit(&CALLS_LOOP, expr.span); + } + }); + } +} + +/// An interaction with another contract. +enum ExternalCall { + /// Contract creation or a mutating address builtin. + Opaque, + /// `.staticcall` on an address. + Static, + /// High-level external or library call, including an external function pointer. + Member(StateMutability), +} + +/// Classifies calls by their checked function kind, including function pointers and library +/// delegate calls. Internal `using for` bindings and `super` dispatch stay internal. +fn classify<'gcx>(gcx: Gcx<'gcx>, callee: &Expr<'gcx>) -> Option { + let (callee, _) = callee.split_call_options(); + if matches!( + gcx.resolved_builtin(callee), + Some(Builtin::AddressPayableSend | Builtin::AddressPayableTransfer) + ) { + return Some(ExternalCall::Opaque); + } + let TyKind::Fn(function) = gcx.type_of_expr(callee.id)?.kind else { return None }; + match function.kind() { + TyFnKind::External | TyFnKind::DelegateCall => { + Some(ExternalCall::Member(function.state_mutability)) + } + TyFnKind::BareStaticCall => Some(ExternalCall::Static), + TyFnKind::BareCall | TyFnKind::BareDelegateCall | TyFnKind::Creation => { + Some(ExternalCall::Opaque) + } + _ => None, + } +} + +/// True if calling `callee` interacts with another contract (or deploys one). +pub(super) fn is_external_call<'gcx>(gcx: Gcx<'gcx>, callee: &Expr<'gcx>) -> bool { + classify(gcx, callee).is_some() +} + +/// Like [`is_external_call`], but excludes calls that cannot affect log ordering or observable +/// state: `staticcall` and high-level `view`/`pure` callees (including `this.*`). +pub(super) fn is_state_mutating_external_call<'gcx>(gcx: Gcx<'gcx>, callee: &Expr<'gcx>) -> bool { + match classify(gcx, callee) { + Some(ExternalCall::Opaque) => true, + Some(ExternalCall::Member(mutability)) => { + !matches!(mutability, StateMutability::View | StateMutability::Pure) + } + Some(ExternalCall::Static) | None => false, + } +} diff --git a/crates/lint/src/sol/low/delegatecall_loop.rs b/crates/lint/src/sol/low/delegatecall_loop.rs new file mode 100644 index 0000000000000..600c44bdf5d01 --- /dev/null +++ b/crates/lint/src/sol/low/delegatecall_loop.rs @@ -0,0 +1,29 @@ +use super::{DelegatecallLoop, payable_loop::for_each_payable_loop_expr}; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{Severity, SolLint}, +}; +use solar::sema::{ + Gcx, + builtins::Builtin, + hir::{ExprKind, Function}, +}; + +declare_forge_lint!( + DELEGATECALL_LOOP, + Severity::Low, + "delegatecall-loop", + "payable function uses `delegatecall` inside a loop" +); + +impl<'gcx> LateLintPass<'gcx> for DelegatecallLoop { + fn check_function(&mut self, ctx: &LintContext, gcx: Gcx<'gcx>, func: &'gcx Function<'gcx>) { + for_each_payable_loop_expr(gcx, func, |expr| { + if let ExprKind::Call(callee, ..) = &expr.kind + && gcx.resolved_builtin(callee) == Some(Builtin::AddressDelegatecall) + { + ctx.emit(&DELEGATECALL_LOOP, expr.span); + } + }); + } +} diff --git a/crates/lint/src/sol/low/deprecated_oz_function.rs b/crates/lint/src/sol/low/deprecated_oz_function.rs new file mode 100644 index 0000000000000..c1d7e672d5d06 --- /dev/null +++ b/crates/lint/src/sol/low/deprecated_oz_function.rs @@ -0,0 +1,52 @@ +use super::DeprecatedOzFunction; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{ + Severity, SolLint, + analysis::{OPENZEPPELIN_ROOTS, source_in_package}, + }, +}; +use solar::sema::{ + Gcx, + hir::{Expr, ExprKind, FunctionId}, +}; + +declare_forge_lint!( + DEPRECATED_OZ_FUNCTION, + Severity::Low, + "deprecated-oz-function", + "this OpenZeppelin function is deprecated" +); + +impl<'gcx> LateLintPass<'gcx> for DeprecatedOzFunction { + fn check_expr(&mut self, ctx: &LintContext, gcx: Gcx<'gcx>, expr: &'gcx Expr<'gcx>) { + // A name or member expression typed as a function is a resolved reference, called or + // used as a value: judge the single declaration the type checker selected (overloads, + // overrides, `super.`, `using for` and import aliases already accounted for). + if matches!(expr.kind, ExprKind::Ident(..) | ExprKind::Member(..)) + && let Some(function_id) = gcx.resolved_function(expr) + && is_deprecated_oz(gcx, function_id) + { + ctx.emit(&DEPRECATED_OZ_FUNCTION, expr.span); + } + } +} + +/// Whether `function_id` is one of the functions OpenZeppelin deprecated: `SafeERC20.safeApprove` +/// and `AccessControl._setupRole` (plus their upgradeable variants). Extensions inherit these +/// functions rather than redeclare them, so resolution still lands on the canonical declaration; +/// a same-name function of an unrelated contract or library stays out, and so does a same-name +/// local declaration, which fails the provenance check. +fn is_deprecated_oz(gcx: Gcx<'_>, function_id: FunctionId) -> bool { + let function = gcx.hir.function(function_id); + let (Some(name), Some(contract_id)) = (function.name, function.contract) else { return false }; + if !source_in_package(&gcx.hir, function.source, OPENZEPPELIN_ROOTS) { + return false; + } + let contract = gcx.hir.contract(contract_id); + matches!( + (name.as_str(), contract.kind.is_library(), contract.name.as_str()), + ("safeApprove", true, "SafeERC20" | "SafeERC20Upgradeable") + | ("_setupRole", false, "AccessControl" | "AccessControlUpgradeable") + ) +} diff --git a/crates/lint/src/sol/low/empty_block.rs b/crates/lint/src/sol/low/empty_block.rs new file mode 100644 index 0000000000000..4ca88ed7b5c20 --- /dev/null +++ b/crates/lint/src/sol/low/empty_block.rs @@ -0,0 +1,32 @@ +use super::EmptyBlock; +use crate::{ + linter::{EarlyLintPass, LintContext}, + sol::{Severity, SolLint}, +}; +use solar::ast::{FunctionKind, ItemFunction, StateMutability}; + +declare_forge_lint!(EMPTY_BLOCK, Severity::Low, "empty-block", "empty function body"); + +impl<'ast> EarlyLintPass<'ast> for EmptyBlock { + fn check_item_function(&mut self, ctx: &LintContext, func: &'ast ItemFunction<'ast>) { + // An empty body on a regular function is dead or unfinished code. Bodies whose emptiness + // is the behavior are exempt: constructors, receive/fallback (the empty body accepts + // calls or ether), `virtual` functions (an empty default meant to be overridden), + // functions with modifiers (`initialize() external initializer {}`, + // `_authorizeUpgrade(address) internal override onlyOwner {}`: the modifier carries the + // behavior) and `payable` functions without return values (an empty ether sink; with a + // return value the body silently returns the default, an unfinished stub). Functions + // without a body (interfaces, abstract declarations) have nothing to flag, and an empty + // modifier body is a solc compile error (2883), so it never reaches the linter. + if let Some(body) = &func.body + && body.is_empty() + && matches!(func.kind, FunctionKind::Function) + && func.header.virtual_.is_none() + && func.header.modifiers.is_empty() + && (func.header.state_mutability() != StateMutability::Payable + || func.header.returns.is_some()) + { + ctx.emit(&EMPTY_BLOCK, body.span); + } + } +} diff --git a/crates/lint/src/sol/low/inconsistent_type_names.rs b/crates/lint/src/sol/low/inconsistent_type_names.rs new file mode 100644 index 0000000000000..325961b6b064c --- /dev/null +++ b/crates/lint/src/sol/low/inconsistent_type_names.rs @@ -0,0 +1,87 @@ +use crate::{ + linter::{Lint, ProjectLintEmitter, ProjectLintPass, ProjectSource}, + sol::{Severity, SolLint, low::InconsistentTypeNames}, +}; +use solar::{ + ast::ElementaryType, + interface::{SourceMap, source_map::FileName}, + sema::hir::{self, TypeKind}, +}; +use std::collections::HashMap; + +declare_forge_lint!( + INCONSISTENT_TYPE_NAMES, + Severity::Low, + "inconsistent-type-names", + "contract mixes shorthand and explicit integer type names; use `uint256` and `int256` consistently" +); + +impl<'ast> ProjectLintPass<'ast> for InconsistentTypeNames { + fn check_project(&mut self, ctx: &ProjectLintEmitter<'_, '_>, sources: &[ProjectSource<'ast>]) { + if !ctx.is_lint_enabled(INCONSISTENT_TYPE_NAMES.id()) { + return; + } + let gcx = ctx.gcx(); + let source_map = gcx.sess.source_map(); + let input_sources: HashMap<_, _> = gcx + .hir + .sources_enumerated() + .filter_map(|(sid, src)| { + let FileName::Real(path) = &src.file.name else { return None }; + Some((sid, sources.iter().position(|s| &s.path == path)?)) + }) + .collect(); + + // The spellings each contract uses across all of its variables. + let mut contracts = HashMap::>::new(); + for variable in gcx.hir.variables() { + if let Some(contract_id) = variable.contract + && input_sources.contains_key(&variable.source) + { + int_spellings(source_map, &variable.ty, contracts.entry(contract_id).or_default()); + } + } + + // HIR variable order is stable, so diagnostics remain deterministic across runs. + for variable in gcx.hir.variables() { + let Some(contract_id) = variable.contract else { continue }; + let Some(&source_idx) = input_sources.get(&variable.source) else { continue }; + let Some(contract_names) = contracts.get(&contract_id) else { continue }; + let mut names = Vec::new(); + int_spellings(source_map, &variable.ty, &mut names); + if (names.contains(&"uint") && contract_names.contains(&"uint256")) + || (names.contains(&"int") && contract_names.contains(&"int256")) + { + ctx.emit(&sources[source_idx], &INCONSISTENT_TYPE_NAMES, variable.span); + } + } + } +} + +/// Collects the 256-bit integer spellings (`int`, `int256`, `uint`, `uint256`) of a declared type. +/// +/// Solar normalizes `int`/`uint` to their 256-bit forms in HIR, so the exact source span of an +/// already typed 256-bit integer node recovers which spelling the declaration used; a missing or +/// unexpected snippet is ignored conservatively. Function-type parameters and returns are +/// separate HIR variables, so function types are not recursed here, matching the upstream +/// detector's treatment of arrays and mappings. +fn int_spellings(source_map: &SourceMap, ty: &hir::Type<'_>, out: &mut Vec<&'static str>) { + match &ty.kind { + TypeKind::Elementary(ElementaryType::Int(size) | ElementaryType::UInt(size)) + if size.bits() == 256 => + { + if let Ok(snippet) = source_map.span_to_snippet(ty.span) + && let Some(spelling) = + ["int", "int256", "uint", "uint256"].into_iter().find(|s| *s == snippet) + { + out.push(spelling); + } + } + TypeKind::Array(array) => int_spellings(source_map, &array.element, out), + TypeKind::Mapping(mapping) => { + int_spellings(source_map, &mapping.key, out); + int_spellings(source_map, &mapping.value, out); + } + _ => {} + } +} diff --git a/crates/lint/src/sol/low/incorrect_modifier.rs b/crates/lint/src/sol/low/incorrect_modifier.rs new file mode 100644 index 0000000000000..53505f39f7c2d --- /dev/null +++ b/crates/lint/src/sol/low/incorrect_modifier.rs @@ -0,0 +1,26 @@ +use super::IncorrectModifier; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{Severity, SolLint, analysis::block_outcome}, +}; +use solar::{ + ast::FunctionKind, + sema::{Gcx, hir::Function}, +}; + +declare_forge_lint!( + INCORRECT_MODIFIER, + Severity::Low, + "incorrect-modifier", + "modifier can finish without executing the modified function" +); + +impl<'gcx> LateLintPass<'gcx> for IncorrectModifier { + fn check_function(&mut self, ctx: &LintContext, gcx: Gcx<'gcx>, func: &'gcx Function<'gcx>) { + if func.kind == FunctionKind::Modifier + && func.body.is_some_and(|body| block_outcome(gcx, body).can_skip_placeholder()) + { + ctx.emit(&INCORRECT_MODIFIER, func.span); + } + } +} diff --git a/crates/lint/src/sol/low/missing_events_access_control.rs b/crates/lint/src/sol/low/missing_events_access_control.rs new file mode 100644 index 0000000000000..e33cc85af23f3 --- /dev/null +++ b/crates/lint/src/sol/low/missing_events_access_control.rs @@ -0,0 +1,657 @@ +use super::MissingEventsAccessControl; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{ + Severity, SolLint, + analysis::{ + branch_always_exits, for_each_lhs_var, guard_vars, is_protected, is_sender_member, + is_zero_value, lhs_local_var, loop_stmts, referenced_item, underlying_var, + }, + }, +}; +use solar::{ + ast::{BinOpKind, ContractKind, DataLocation, StateMutability, Visibility}, + interface::{Span, data_structures::Never}, + sema::{ + Gcx, + builtins::Builtin, + hir::{ + self, EventId, Expr, ExprKind, FunctionId, ItemId, LoopSource, Stmt, StmtKind, + VariableId, Visit, + }, + }, +}; +use std::{ + collections::{HashMap, HashSet}, + iter, + ops::ControlFlow, +}; + +declare_forge_lint!( + MISSING_EVENTS_ACCESS_CONTROL, + Severity::Low, + "missing-events-access-control", + "access control changes without an event" +); + +impl<'gcx> LateLintPass<'gcx> for MissingEventsAccessControl { + fn check_contract( + &mut self, + ctx: &LintContext, + gcx: Gcx<'gcx>, + contract: &'gcx hir::Contract<'gcx>, + ) { + if !matches!(contract.kind, ContractKind::Contract | ContractKind::AbstractContract) { + return; + } + + // Every state variable some access check in the contract depends on. + let functions: Vec<_> = contract.all_functions().collect(); + let targets: HashSet<_> = functions.iter().flat_map(|&id| guard_vars(gcx, id)).collect(); + if targets.is_empty() { + return; + } + + for func_id in functions { + let func = gcx.hir.function(func_id); + let is_entry_point = func.kind.is_function() + && matches!(func.visibility, Visibility::Public | Visibility::External) + && !func.is_constructor() + && !func.is_special() + && !matches!(func.state_mutability, StateMutability::Pure | StateMutability::View); + if !is_entry_point || !is_protected(gcx, func_id) { + continue; + } + + let guard_targets = guard_vars(gcx, func_id); + let mut analyzer = WriteAnalyzer { + gcx, + targets: &targets, + guard_targets: &guard_targets, + state: State { + taint: func + .parameters + .iter() + .map(|&p| (p, Sources::from([Source::Var(p)]))) + .collect(), + ..Default::default() + }, + call_stack: Vec::new(), + return_states: Vec::new(), + terminal_states: Vec::new(), + next_write_id: 0, + }; + analyzer.analyze_function(func_id); + + let mut emitted = HashSet::new(); + for write in analyzer.state.writes { + if write.evented || !emitted.insert(write.var_id) { + continue; + } + let name = gcx + .hir + .variable(write.var_id) + .name + .map_or_else(|| "state variable".to_string(), |name| name.to_string()); + ctx.emit_with_msg( + &MISSING_EVENTS_ACCESS_CONTROL, + write.span, + format!("`{name}` is changed without an event but is used for access control"), + ); + } + } + } +} + +/// Calls `f` on every index and slice bound along the spine of an lvalue. +fn for_each_lhs_index<'gcx>(expr: &'gcx Expr<'gcx>, f: &mut impl FnMut(&'gcx Expr<'gcx>)) { + match &expr.peel_parens().kind { + ExprKind::Index(base, index) => { + for_each_lhs_index(base, f); + if let Some(index) = index { + f(index); + } + } + ExprKind::Slice(base, start, end) => { + for_each_lhs_index(base, f); + for bound in start.iter().chain(end) { + f(bound); + } + } + ExprKind::Member(base, _) | ExprKind::Payable(base) | ExprKind::Unary(_, base) => { + for_each_lhs_index(base, f) + } + ExprKind::Tuple(exprs) => exprs.iter().flatten().for_each(|e| for_each_lhs_index(e, f)), + _ => {} + } +} + +// --- Writes without events -------------------------------------------------------------------- + +/// Where a written value may come from: an entry-point parameter or state variable, or the caller. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +enum Source { + Var(VariableId), + Sender, +} + +type Sources = HashSet; + +#[derive(Clone)] +struct StateWrite { + id: usize, + var_id: VariableId, + span: Span, + sources: Sources, + /// The written value is a literal zero/false, so no source is needed for an event to match. + fixed_clear: bool, + evented: bool, +} + +#[derive(Clone, Default)] +struct State { + /// Sources each local may currently hold. + taint: HashMap, + /// Storage-pointer locals and the state variable they alias. + storage_aliases: HashMap, + writes: Vec, + /// Events emitted so far, not yet matched against every write reachable at this point. + emits: Vec<(EventId, Sources)>, +} + +/// Collects writes to `targets` reachable from an entry point and marks those an `emit` covers. +struct WriteAnalyzer<'a, 'gcx> { + gcx: Gcx<'gcx>, + targets: &'a HashSet, + /// Targets checked by this entry point's own guards; clearing one of them is reportable even + /// when the written value carries no source. + guard_targets: &'a HashSet, + state: State, + call_stack: Vec, + /// State at each normal `return`, grouped by active inlined function call. + return_states: Vec>, + /// State at each successful terminal exit, grouped by active inlined function call. + terminal_states: Vec>, + next_write_id: usize, +} + +impl<'gcx> WriteAnalyzer<'_, 'gcx> { + fn analyze_function(&mut self, func_id: FunctionId) { + if self.call_stack.contains(&func_id) { + return; + } + let func = self.gcx.hir.function(func_id); + let Some(body) = func.body else { return }; + self.call_stack.push(func_id); + self.return_states.push(Vec::new()); + self.terminal_states.push(Vec::new()); + for modifier in func.modifiers { + if let Some(modifier_id) = modifier.id.as_function() { + let _ = self.visit_call_args(&modifier.args); + self.analyze_call(modifier_id, |index| modifier.args.exprs().nth(index)); + } + } + for stmt in body.stmts { + let _ = self.visit_stmt(stmt); + } + self.correlate_pending(); + let mut exits = self.return_states.pop().unwrap(); + let terminal_exits = self.terminal_states.pop().unwrap(); + if !exits.is_empty() || !terminal_exits.is_empty() { + if !body.stmts.iter().any(|stmt| branch_always_exits(self.gcx, stmt)) { + exits.push(self.state.clone()); + } + for write in &mut self.state.writes { + // An exit before this write does not require an event for it. + let mut matching = exits + .iter() + .chain(&terminal_exits) + .filter_map(|state| state.writes.iter().find(|other| other.id == write.id)); + if let Some(first) = matching.next() { + write.evented = first.evented && matching.all(|other| other.evented); + } + } + self.state.emits.retain(|event| exits.iter().all(|state| state.emits.contains(event))); + } + if let Some(parent) = self.terminal_states.last_mut() { + parent.extend(terminal_exits); + } + self.call_stack.pop(); + } + + /// Inlines `callee_id` with its parameters bound to argument sources; locals and storage + /// aliases are callee-private, pending writes flow back to the caller. + fn analyze_call( + &mut self, + callee_id: FunctionId, + mut argument: impl FnMut(usize) -> Option<&'gcx Expr<'gcx>>, + ) { + let params = self + .gcx + .hir + .function(callee_id) + .parameters + .iter() + .enumerate() + .filter_map(|(index, ¶m)| { + let sources = self.value_sources(argument(index)?); + (!sources.is_empty()).then_some((param, sources)) + }) + .collect(); + let saved_taint = std::mem::replace(&mut self.state.taint, params); + let saved_aliases = std::mem::take(&mut self.state.storage_aliases); + self.analyze_function(callee_id); + self.state.taint = saved_taint; + self.state.storage_aliases = saved_aliases; + } + + /// Sources flowing into `expr`: `msg.sender`, state variables and tainted locals. + fn value_sources(&self, expr: &Expr<'_>) -> Sources { + let mut out = Sources::new(); + let _ = expr.visit(&mut |e| { + if is_sender_member(self.gcx, e) { + out.insert(Source::Sender); + } + if let Some(var_id) = underlying_var(self.gcx, e) { + if self.gcx.hir.variable(var_id).kind.is_state() { + out.insert(Source::Var(var_id)); + } + if let Some(sources) = self.state.taint.get(&var_id) { + out.extend(sources); + } + } + ControlFlow::<()>::Continue(()) + }); + out + } + + /// State variables written through `lhs`, resolving storage pointers to their roots. + fn lhs_state_vars(&self, lhs: &Expr<'_>) -> Vec { + let mut vars = Vec::new(); + for_each_lhs_var(self.gcx, lhs, &mut |var_id| { + let root = if self.gcx.hir.variable(var_id).kind.is_state() { + Some(var_id) + } else { + self.state.storage_aliases.get(&var_id).copied() + }; + if let Some(root) = root + && !vars.contains(&root) + { + vars.push(root); + } + }); + vars + } + + fn record_writes(&mut self, lhs: &Expr<'_>, sources: &Sources, fixed_clear: bool) { + for var_id in self.lhs_state_vars(lhs) { + if self.targets.contains(&var_id) + && (!sources.is_empty() || (fixed_clear && self.guard_targets.contains(&var_id))) + { + self.state.writes.push(StateWrite { + id: self.next_write_id, + var_id, + span: lhs.span, + sources: sources.clone(), + fixed_clear, + evented: false, + }); + self.next_write_id += 1; + } + } + } + + fn set_taint(&mut self, var_id: VariableId, sources: Sources) { + if sources.is_empty() { + self.state.taint.remove(&var_id); + } else { + self.state.taint.insert(var_id, sources); + } + } + + /// Records `var_id = value`, tracking which state variable a storage pointer aliases. + fn set_local(&mut self, var_id: VariableId, sources: Sources, value: &Expr<'_>) { + self.set_taint(var_id, sources); + let root = (self.gcx.hir.variable(var_id).data_location == Some(DataLocation::Storage)) + .then(|| self.lhs_state_vars(value).into_iter().next()) + .flatten(); + match root { + Some(root) => self.state.storage_aliases.insert(var_id, root), + None => self.state.storage_aliases.remove(&var_id), + }; + } + + /// Records an `emit` as pending, to be matched against writes by `correlate_pending`. Emits + /// are matched independent of statement order within the same straight-line scope, so an + /// `emit` written before the state change it documents is still recognized. + fn record_emit(&mut self, expr: &Expr<'_>) { + let Some(event_id) = emitted_event_id(self.gcx, expr) else { return }; + let event_sources = self.value_sources(expr); + self.state.emits.push((event_id, event_sources)); + } + + /// Matches every pending emit against every not-yet-evented write reachable so far: the + /// event must mention the variable and share a source with the write (or the write must be + /// a fixed clear). + fn correlate_pending(&mut self) { + correlate_pending(self.gcx, &mut self.state); + } +} + +impl<'gcx> Visit<'gcx> for WriteAnalyzer<'_, 'gcx> { + type BreakValue = Never; + + fn hir(&self) -> &'gcx hir::Hir<'gcx> { + &self.gcx.hir + } + + fn visit_stmt(&mut self, stmt: &'gcx Stmt<'gcx>) -> ControlFlow { + match stmt.kind { + StmtKind::DeclSingle(var_id) => { + if let Some(init) = self.gcx.hir.variable(var_id).initializer { + self.visit_expr(init)?; + let sources = self.value_sources(init); + self.set_local(var_id, sources, init); + } + } + StmtKind::DeclMulti(vars, expr) => { + self.visit_expr(expr)?; + let sources = self.value_sources(expr); + for var_id in vars.iter().flatten() { + self.set_taint(*var_id, sources.clone()); + } + } + StmtKind::If(cond, then_stmt, else_stmt) => { + self.visit_expr(cond)?; + let base = self.state.clone(); + self.visit_stmt(then_stmt)?; + self.correlate_pending(); + let then_state = std::mem::replace(&mut self.state, base.clone()); + if let Some(else_stmt) = else_stmt { + self.visit_stmt(else_stmt)?; + self.correlate_pending(); + } + let else_state = std::mem::take(&mut self.state); + self.state = merge_branches( + base, + then_state, + else_state, + branch_always_exits(self.gcx, then_stmt), + else_stmt.is_some_and(|expr| branch_always_exits(self.gcx, expr)), + ); + } + StmtKind::Emit(expr) => { + self.visit_expr(expr)?; + self.record_emit(expr); + } + StmtKind::Return(expr) => { + if let Some(expr) = expr { + self.visit_expr(expr)?; + } + let mut state = self.state.clone(); + correlate_pending(self.gcx, &mut state); + self.return_states.last_mut().unwrap().push(state); + } + StmtKind::Loop(block, source) => { + // A loop body may not run to completion on the iteration containing an emit — a + // `for`/`while` may run zero times, and even a `do-while` (which always starts + // its body) can `break`/`continue`/`revert` past the emit on that first pass — so + // an emit inside any loop must not survive to satisfy a write after the loop, and + // must not retroactively mark a write from BEFORE the loop as evented either. + let n = self.state.writes.len(); + let evented_before: Vec = + self.state.writes.iter().map(|w| w.evented).collect(); + let emits_before = self.state.emits.clone(); + if let LoopSource::For { update: Some(update) } = source + && let [stmt] = block.stmts + && let StmtKind::If(cond, body, Some(exit)) = stmt.kind + && matches!(exit.kind, StmtKind::Break) + { + // Solar lowers the loop condition to `if (cond) body else break`. + // The update belongs to the continuing arm, so correlate its writes + // with body events before discarding that arm's pending emits. + self.visit_expr(cond)?; + let base = self.state.clone(); + self.visit_stmt(body)?; + self.visit_stmt(update)?; + self.correlate_pending(); + let body_state = std::mem::take(&mut self.state); + self.state = merge_branches(base.clone(), body_state, base, false, false); + } else { + for stmt in loop_stmts(block, source) { + self.visit_stmt(stmt)?; + } + } + self.correlate_pending(); + for (write, was_evented) in self.state.writes[..n].iter_mut().zip(evented_before) { + write.evented = was_evented; + } + self.state.emits = emits_before; + } + StmtKind::Try(try_stmt) => { + self.visit_expr(&try_stmt.expr)?; + // Clauses are mutually exclusive: at most one runs. Each gets its own isolated + // copy of the pre-try state so one clause's emit can neither satisfy another + // clause's write nor a write from before the try; `merge_try_clauses` recombines + // them the same way `merge_branches` recombines an `if`'s two arms. + let base = self.state.clone(); + let mut clause_states = Vec::with_capacity(try_stmt.clauses.len()); + let mut continues = Vec::with_capacity(try_stmt.clauses.len()); + for clause in try_stmt.clauses { + self.state = base.clone(); + for stmt in clause.block.stmts { + self.visit_stmt(stmt)?; + } + self.correlate_pending(); + clause_states.push(std::mem::take(&mut self.state)); + continues.push( + !clause.block.stmts.iter().any(|stmt| branch_always_exits(self.gcx, stmt)), + ); + } + self.state = merge_try_clauses(base, clause_states, continues); + } + _ => return self.walk_stmt(stmt), + } + ControlFlow::Continue(()) + } + + fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow { + match &expr.kind { + ExprKind::Assign(lhs, op, rhs) => { + self.visit_expr(rhs)?; + self.visit_expr(lhs)?; + let mut sources = self.value_sources(rhs); + for_each_lhs_index(lhs, &mut |index| sources.extend(self.value_sources(index))); + if op.is_some() { + sources.extend(self.value_sources(lhs)); + } + let local = lhs_local_var(self.gcx, lhs); + let rebinds_storage = op.is_none() + && local.is_some_and(|var_id| { + self.gcx.hir.variable(var_id).data_location == Some(DataLocation::Storage) + }) + && self + .gcx + .type_of_expr(rhs.peel_parens().id) + .is_some_and(|ty| ty.loc() == Some(DataLocation::Storage)); + if !rebinds_storage { + self.record_writes(lhs, &sources, is_zero_value(rhs)); + } + if let Some(local) = local { + self.set_local(local, sources, rhs); + } + ControlFlow::Continue(()) + } + ExprKind::Delete(inner) => { + let mut sources = Sources::new(); + for_each_lhs_index(inner, &mut |index| sources.extend(self.value_sources(index))); + self.record_writes(inner, &sources, true); + self.walk_expr(expr) + } + ExprKind::Call(callee, ..) => { + self.walk_expr(expr)?; + if matches!(callee.peel_parens().kind, ExprKind::Ident(_)) + && let Some(callee_id) = self.gcx.resolved_function(callee) + { + let gcx = self.gcx; + self.analyze_call(callee_id, |index| gcx.call_arg(expr, index)); + } + if self.gcx.resolved_builtin(callee) == Some(Builtin::Selfdestruct) { + let mut state = self.state.clone(); + correlate_pending(self.gcx, &mut state); + self.terminal_states.last_mut().unwrap().push(state); + } + ControlFlow::Continue(()) + } + ExprKind::Binary(lhs, op, rhs) if matches!(op.kind, BinOpKind::And | BinOpKind::Or) => { + self.visit_expr(lhs)?; + self.correlate_pending(); + let base = self.state.clone(); + self.visit_expr(rhs)?; + self.correlate_pending(); + let rhs_state = std::mem::take(&mut self.state); + // The RHS may be skipped, so its events cannot cover writes on that path. + self.state = merge_branches(base.clone(), rhs_state, base, false, false); + ControlFlow::Continue(()) + } + ExprKind::Ternary(cond, then_expr, else_expr) => { + self.visit_expr(cond)?; + self.correlate_pending(); + let base = self.state.clone(); + self.visit_expr(then_expr)?; + self.correlate_pending(); + let then_state = std::mem::replace(&mut self.state, base.clone()); + self.visit_expr(else_expr)?; + self.correlate_pending(); + let else_state = std::mem::take(&mut self.state); + self.state = merge_branches(base, then_state, else_state, false, false); + ControlFlow::Continue(()) + } + _ => self.walk_expr(expr), + } + } +} + +/// Joins the two arms of an `if`: a pending write stays covered only if both arms emitted for it, +/// while taint and aliases come from whichever arms can continue past the `if`. Emits recorded +/// inside either arm are dropped rather than carried past the merge: `correlate_pending` already +/// resolved them against that arm's own writes before this runs, and code after the `if` executes +/// whichever branch ran (or neither), so an emit conditional on one arm must not retroactively +/// satisfy a write reachable only outside it. +fn merge_branches( + base: State, + then_state: State, + else_state: State, + then_exits: bool, + else_exits: bool, +) -> State { + let emits = base.emits.clone(); + let mut writes = base.writes; + for (i, write) in writes.iter_mut().enumerate() { + write.evented = then_state.writes[i].evented && else_state.writes[i].evented; + } + let n = writes.len(); + writes.extend_from_slice(&then_state.writes[n..]); + writes.extend_from_slice(&else_state.writes[n..]); + + let (taint, storage_aliases) = match (then_exits, else_exits) { + (true, true) => (base.taint, base.storage_aliases), + (true, false) => (else_state.taint, else_state.storage_aliases), + (false, true) => (then_state.taint, then_state.storage_aliases), + (false, false) => { + let mut taint = then_state.taint; + for (var_id, sources) in else_state.taint { + taint.entry(var_id).or_default().extend(sources); + } + let storage_aliases = then_state + .storage_aliases + .into_iter() + .filter(|(alias, root)| else_state.storage_aliases.get(alias) == Some(root)) + .collect(); + (taint, storage_aliases) + } + }; + State { taint, storage_aliases, writes, emits } +} + +/// Joins mutually exclusive try/catch clauses. Pre-existing writes need an event in every +/// clause, including exiting clauses: a `return` preserves writes, unlike a `revert`. +/// Taint and aliases only merge from continuing clauses; clause-local emits are discarded. +fn merge_try_clauses(base: State, clause_states: Vec, continues: Vec) -> State { + let n = base.writes.len(); + let mut writes = base.writes; + for (i, write) in writes.iter_mut().enumerate() { + write.evented = clause_states.iter().all(|state| state.writes[i].evented); + } + for state in &clause_states { + writes.extend_from_slice(&state.writes[n..]); + } + + let continuing = || clause_states.iter().zip(&continues).filter(|&(_, &c)| c).map(|(s, _)| s); + + let (taint, storage_aliases) = match continuing().next() { + None => (base.taint, base.storage_aliases), + Some(first) => { + let mut taint = HashMap::new(); + for state in continuing() { + for (&var_id, sources) in &state.taint { + taint + .entry(var_id) + .or_insert_with(Sources::new) + .extend(sources.iter().copied()); + } + } + let mut storage_aliases = first.storage_aliases.clone(); + for state in continuing().skip(1) { + storage_aliases + .retain(|alias, root| state.storage_aliases.get(alias) == Some(root)); + } + (taint, storage_aliases) + } + }; + + State { taint, storage_aliases, writes, emits: base.emits } +} + +fn emitted_event_id(gcx: Gcx<'_>, expr: &Expr<'_>) -> Option { + let ExprKind::Call(callee, ..) = &expr.peel_parens().kind else { return None }; + match referenced_item(gcx, callee)? { + ItemId::Event(event_id) => Some(event_id), + _ => None, + } +} + +/// Whether the event name or one of its parameter names mentions the state variable: its +/// normalized name, its singular form, or a role keyword it contains. +fn event_mentions_state_var(gcx: Gcx<'_>, event_id: EventId, var_id: VariableId) -> bool { + let Some(var_name) = gcx.hir.variable(var_id).name else { return false }; + let var_name = normalize(var_name.as_str()); + let mut keywords = vec![var_name.as_str()]; + keywords.extend(var_name.strip_suffix('s').filter(|singular| !singular.is_empty())); + let roles = ["owner", "admin", "guardian", "manager", "role"]; + keywords.extend(roles.into_iter().filter(|role| var_name.contains(role))); + + let event = gcx.hir.event(event_id); + let param_names = event.parameters.iter().filter_map(|&p| gcx.hir.variable(p).name); + iter::once(event.name).chain(param_names).any(|name| { + let name = normalize(name.as_str()); + keywords.iter().any(|keyword| !keyword.is_empty() && name.contains(keyword)) + }) +} + +fn normalize(name: &str) -> String { + name.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase()).collect() +} + +fn correlate_pending(gcx: Gcx<'_>, state: &mut State) { + for (event_id, event_sources) in &state.emits { + for write in &mut state.writes { + if !write.evented + && (write.fixed_clear || !write.sources.is_disjoint(event_sources)) + && event_mentions_state_var(gcx, *event_id, write.var_id) + { + write.evented = true; + } + } + } +} diff --git a/crates/lint/src/sol/low/missing_events_arithmetic.rs b/crates/lint/src/sol/low/missing_events_arithmetic.rs new file mode 100644 index 0000000000000..6530117fee61f --- /dev/null +++ b/crates/lint/src/sol/low/missing_events_arithmetic.rs @@ -0,0 +1,594 @@ +use super::MissingEventsArithmetic; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{ + Severity, SolLint, + analysis::{ + dispatched_function, is_protected, lhs_local_var, loop_stmts, state_lhs_vars, + underlying_var, + }, + }, +}; +use solar::{ + ast::{ContractKind, StateMutability}, + data_structures::map::FxIndexSet, + interface::Span, + sema::{ + Gcx, + builtins::Builtin, + hir::{ + self, BinOpKind, ContractId, ElementaryType, Expr, ExprKind, FunctionId, StmtKind, + TypeKind, VariableId, Visit, + }, + }, +}; +use std::{ + collections::{HashMap, HashSet}, + ops::ControlFlow, +}; + +declare_forge_lint!( + MISSING_EVENTS_ARITHMETIC, + Severity::Low, + "missing-events-arithmetic", + "critical arithmetic state changes without an event" +); + +impl<'gcx> LateLintPass<'gcx> for MissingEventsArithmetic { + fn check_nested_contract( + &mut self, + ctx: &LintContext, + gcx: Gcx<'gcx>, + contract_id: ContractId, + ) { + let contract = gcx.hir.contract(contract_id); + if contract.kind != ContractKind::Contract || contract.linearization_failed() { + return; + } + + // State variables and their entry points are commonly split across a base/derived pair, + // so candidates come from the whole inheritance chain. + let candidates: HashSet<_> = contract + .linearized_bases + .iter() + .flat_map(|&cid| gcx.hir.contract(cid).variables()) + .filter(|&id| { + let var = gcx.hir.variable(id); + var.kind.is_state() + && !var.is_constant() + && !var.is_immutable() + && matches!( + var.ty.kind, + TypeKind::Elementary(ElementaryType::Int(_) | ElementaryType::UInt(_)) + ) + }) + .collect(); + if candidates.is_empty() { + return; + } + + // The externally reachable functions, with overridden base implementations resolved to + // the most derived one. + let (protected, unprotected): (Vec<_>, Vec<_>) = gcx + .interface_functions(contract_id) + .all() + .iter() + .map(|func| func.id) + .partition(|&id| is_protected(gcx, id)); + let entry_points: Vec<_> = protected + .into_iter() + .filter(|&id| { + !matches!( + gcx.hir.function(id).state_mutability, + StateMutability::Pure | StateMutability::View + ) + }) + .collect(); + if entry_points.is_empty() { + return; + } + + // Candidates that flow into arithmetic reachable from an unprotected function. + let mut uses = UseAnalyzer { + gcx, + contract_id, + targets: &candidates, + mode: Mode::Uses, + taint: HashMap::new(), + used: HashSet::new(), + returned: HashSet::new(), + call_stack: Vec::new(), + }; + for func_id in unprotected { + uses.taint.clear(); + uses.analyze_function(func_id); + } + if uses.used.is_empty() { + return; + } + + for func_id in entry_points { + let mut analyzer = + WriteAnalyzer { gcx, contract_id, targets: &uses.used, call_stack: Vec::new() }; + let mut emitted = HashSet::new(); + for write in analyzer.analyze_entry_point(func_id) { + if !emitted.insert(write.var_id) { + continue; + } + let name = gcx + .hir + .variable(write.var_id) + .name + .map_or_else(|| "state variable".to_string(), |name| name.to_string()); + ctx.emit_with_msg( + &MISSING_EVENTS_ARITHMETIC, + write.span, + format!("`{name}` is changed without an event but is used in arithmetic"), + ); + } + } + } +} + +const fn is_arithmetic_op(kind: BinOpKind) -> bool { + matches!( + kind, + BinOpKind::Add + | BinOpKind::Sub + | BinOpKind::Mul + | BinOpKind::Div + | BinOpKind::Rem + | BinOpKind::Pow + ) +} + +// --- Arithmetic uses -------------------------------------------------------------------------- + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Mode { + /// Record target variables that reach arithmetic operators. + Uses, + /// Record target variables that reach `return` statements. + Returns, +} + +/// Finds target state variables that flow into arithmetic, following locals and internal calls. +struct UseAnalyzer<'a, 'gcx> { + gcx: Gcx<'gcx>, + contract_id: ContractId, + targets: &'a HashSet, + mode: Mode, + /// Target state variables each local may currently hold. + taint: HashMap>, + used: HashSet, + returned: HashSet, + call_stack: Vec, +} + +impl<'gcx> UseAnalyzer<'_, 'gcx> { + fn analyze_function(&mut self, func_id: FunctionId) { + if self.call_stack.contains(&func_id) { + return; + } + let Some(body) = self.gcx.hir.function(func_id).body else { return }; + self.call_stack.push(func_id); + for stmt in body.stmts { + let _ = self.visit_stmt(stmt); + } + self.call_stack.pop(); + } + + /// Analyzes `callee_id` with its parameters bound to the call's argument sources, restoring the + /// caller's taint afterwards. + fn analyze_call(&mut self, callee_id: FunctionId, call: &Expr<'gcx>) { + if self.call_stack.contains(&callee_id) { + return; + } + let params = self + .gcx + .hir + .function(callee_id) + .parameters + .iter() + .enumerate() + .filter_map(|(index, ¶m)| { + let sources = self.sources(self.gcx.call_arg(call, index)?); + (!sources.is_empty()).then_some((param, sources)) + }) + .collect(); + let saved = std::mem::replace(&mut self.taint, params); + self.analyze_function(callee_id); + self.taint = saved; + } + + /// Target state variables `expr` may evaluate to, including through helper return values. + fn sources(&mut self, expr: &Expr<'gcx>) -> HashSet { + let mut out = HashSet::new(); + let _ = expr.visit(&mut |e| { + if let Some(var_id) = underlying_var(self.gcx, e) { + if self.targets.contains(&var_id) { + out.insert(var_id); + } + if let Some(sources) = self.taint.get(&var_id) { + out.extend(sources); + } + } + if let ExprKind::Call(callee, ..) = &e.kind + && let Some(callee_id) = dispatched_function(self.gcx, self.contract_id, callee) + { + out.extend(self.return_sources(callee_id, e)); + } + ControlFlow::<()>::Continue(()) + }); + out + } + + fn return_sources(&mut self, callee_id: FunctionId, call: &Expr<'gcx>) -> HashSet { + let outer_mode = std::mem::replace(&mut self.mode, Mode::Returns); + let outer_returned = std::mem::take(&mut self.returned); + self.analyze_call(callee_id, call); + self.mode = outer_mode; + std::mem::replace(&mut self.returned, outer_returned) + } + + fn set_taint(&mut self, var_id: VariableId, sources: HashSet) { + if sources.is_empty() { + self.taint.remove(&var_id); + } else { + self.taint.insert(var_id, sources); + } + } +} + +impl<'gcx> Visit<'gcx> for UseAnalyzer<'_, 'gcx> { + type BreakValue = solar::interface::data_structures::Never; + + fn hir(&self) -> &'gcx hir::Hir<'gcx> { + &self.gcx.hir + } + + fn visit_stmt(&mut self, stmt: &'gcx hir::Stmt<'gcx>) -> ControlFlow { + match stmt.kind { + StmtKind::DeclSingle(var_id) => { + if let Some(init) = self.gcx.hir.variable(var_id).initializer { + let sources = self.sources(init); + self.set_taint(var_id, sources); + } + } + StmtKind::DeclMulti(vars, expr) => { + let sources = self.sources(expr); + for var_id in vars.iter().flatten() { + self.set_taint(*var_id, sources.clone()); + } + } + StmtKind::Return(Some(expr)) if self.mode == Mode::Returns => { + let sources = self.sources(expr); + self.returned.extend(sources); + } + _ => {} + } + self.walk_stmt(stmt) + } + + fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow { + match &expr.kind { + ExprKind::Assign(lhs, _, rhs) => { + if let Some(local) = lhs_local_var(self.gcx, lhs) { + let sources = self.sources(rhs); + self.set_taint(local, sources); + } + } + ExprKind::Binary(lhs, op, rhs) + if self.mode == Mode::Uses && is_arithmetic_op(op.kind) => + { + let sources = self.sources(lhs); + self.used.extend(sources); + let sources = self.sources(rhs); + self.used.extend(sources); + } + ExprKind::Call(callee, ..) if self.mode == Mode::Uses => { + self.walk_expr(expr)?; + if let Some(callee_id) = dispatched_function(self.gcx, self.contract_id, callee) { + self.analyze_call(callee_id, expr); + } + return ControlFlow::Continue(()); + } + _ => {} + } + self.walk_expr(expr) + } +} + +// --- Writes without events -------------------------------------------------------------------- + +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +struct StateWrite { + var_id: VariableId, + span: Span, +} + +/// Analysis state along one control-flow path. +#[derive(Clone, Default)] +struct WriteState { + /// Locals holding a value that is not a compile-time constant. + dynamic: HashSet, + /// Unique target writes not yet followed by an `emit`, in first-seen order. + /// Deduplication prevents branch joins from multiplying identical pending writes. + writes: FxIndexSet, +} + +fn merge(lhs: Option, rhs: Option) -> Option { + match (lhs, rhs) { + (Some(mut lhs), Some(rhs)) => { + lhs.dynamic.extend(rhs.dynamic); + lhs.writes.extend(rhs.writes); + Some(lhs) + } + (lhs, rhs) => lhs.or(rhs), + } +} + +/// Paths leaving a statement: those continuing to the next statement and those that `return`ed +/// (which skip the rest of the body but still run the modifiers' trailing code). +#[derive(Default)] +struct Flow { + fallthrough: Option, + returned: Option, +} + +impl Flow { + const fn fallthrough(state: WriteState) -> Self { + Self { fallthrough: Some(state), returned: None } + } + + fn merge(self, other: Self) -> Self { + Self { + fallthrough: merge(self.fallthrough, other.fallthrough), + returned: merge(self.returned, other.returned), + } + } + + fn merged(self) -> Option { + merge(self.fallthrough, self.returned) + } +} + +/// Collects writes to target variables that no later `emit` on the same path covers. +struct WriteAnalyzer<'a, 'gcx> { + gcx: Gcx<'gcx>, + contract_id: ContractId, + targets: &'a HashSet, + call_stack: Vec, +} + +impl<'gcx> WriteAnalyzer<'_, 'gcx> { + fn analyze_entry_point(&mut self, func_id: FunctionId) -> FxIndexSet { + let func = self.gcx.hir.function(func_id); + let state = WriteState { + dynamic: func.parameters.iter().copied().collect(), + writes: FxIndexSet::default(), + }; + let mut state = self.analyze_function(func_id, state).merged(); + // Modifier code after `_` runs once the body finished, innermost modifier first, and may + // still emit for the body's writes. + for modifier in func.modifiers.iter().rev() { + let Some(body) = + modifier.id.as_function().and_then(|id| self.gcx.hir.function(id).body) + else { + continue; + }; + let Some(pos) = body.stmts.iter().position(|s| matches!(s.kind, StmtKind::Placeholder)) + else { + continue; + }; + let suffix = &body.stmts[pos + 1..]; + state = state.and_then(|state| self.analyze_stmts(suffix, state).merged()); + } + state.map(|state| state.writes).unwrap_or_default() + } + + fn analyze_function(&mut self, func_id: FunctionId, state: WriteState) -> Flow { + if self.call_stack.contains(&func_id) { + return Flow::fallthrough(state); + } + let Some(body) = self.gcx.hir.function(func_id).body else { + return Flow::fallthrough(state); + }; + self.call_stack.push(func_id); + let flow = self.analyze_stmts(body.stmts, state); + self.call_stack.pop(); + flow + } + + fn analyze_stmts( + &mut self, + stmts: impl IntoIterator>, + state: WriteState, + ) -> Flow { + let mut flow = Flow::fallthrough(state); + for stmt in stmts { + let Some(state) = flow.fallthrough.take() else { break }; + let next = self.analyze_stmt(stmt, state); + flow.fallthrough = next.fallthrough; + flow.returned = merge(flow.returned, next.returned); + } + flow + } + + fn analyze_stmt(&mut self, stmt: &'gcx hir::Stmt<'gcx>, mut state: WriteState) -> Flow { + match stmt.kind { + StmtKind::DeclSingle(var_id) => { + if let Some(init) = self.gcx.hir.variable(var_id).initializer { + self.analyze_expr(init, &mut state); + self.set_dynamic(&mut state, var_id, init); + } + Flow::fallthrough(state) + } + StmtKind::DeclMulti(vars, expr) => { + self.analyze_expr(expr, &mut state); + for var_id in vars.iter().flatten() { + self.set_dynamic(&mut state, *var_id, expr); + } + Flow::fallthrough(state) + } + StmtKind::Block(block) | StmtKind::UncheckedBlock(block) => { + self.analyze_stmts(block.stmts, state) + } + StmtKind::Loop(block, source) => self.analyze_stmts(loop_stmts(block, source), state), + StmtKind::If(cond, then_stmt, else_stmt) => { + self.analyze_expr(cond, &mut state); + let then_flow = self.analyze_stmt(then_stmt, state.clone()); + let else_flow = match else_stmt { + Some(else_stmt) => self.analyze_stmt(else_stmt, state), + None => Flow::fallthrough(state), + }; + then_flow.merge(else_flow) + } + StmtKind::Try(try_stmt) => { + self.analyze_expr(&try_stmt.expr, &mut state); + try_stmt.clauses.iter().fold(Flow::default(), |flow, clause| { + flow.merge(self.analyze_stmts(clause.block.stmts, state.clone())) + }) + } + StmtKind::Expr(expr) => { + self.analyze_expr(expr, &mut state); + Flow::fallthrough(state) + } + StmtKind::Revert(expr) => { + self.analyze_expr(expr, &mut state); + Flow::default() + } + StmtKind::Emit(expr) => { + self.analyze_expr(expr, &mut state); + state.writes.clear(); + Flow::fallthrough(state) + } + StmtKind::Return(expr) => { + if let Some(expr) = expr { + self.analyze_expr(expr, &mut state); + } + Flow { fallthrough: None, returned: Some(state) } + } + _ => Flow::fallthrough(state), + } + } + + fn analyze_expr(&mut self, expr: &'gcx Expr<'gcx>, state: &mut WriteState) { + let _ = expr.visit(&mut |e| { + match &e.kind { + ExprKind::Assign(lhs, op, rhs) => { + let dynamic = self.is_dynamic(state, rhs); + if dynamic || op.is_some_and(|op| is_arithmetic_op(op.kind)) { + self.record_writes(state, lhs); + } + if let Some(local) = lhs_local_var(self.gcx, lhs) { + self.set_dynamic(state, local, rhs); + } + } + ExprKind::Unary(op, inner) if op.kind.has_side_effects() => { + self.record_writes(state, inner); + } + ExprKind::Call(callee, ..) => { + if let Some(callee_id) = dispatched_function(self.gcx, self.contract_id, callee) + { + self.analyze_call(callee_id, e, state); + } + } + _ => {} + } + ControlFlow::<()>::Continue(()) + }); + } + + /// Inlines `callee_id` with its parameters marked dynamic when the matching argument is; the + /// callee's pending writes (and any `emit` clearing them) flow back into the caller. + fn analyze_call(&mut self, callee_id: FunctionId, call: &Expr<'gcx>, state: &mut WriteState) { + let callee_state = WriteState { + dynamic: self + .gcx + .hir + .function(callee_id) + .parameters + .iter() + .enumerate() + .filter(|(index, _)| { + self.gcx.call_arg(call, *index).is_some_and(|arg| self.is_dynamic(state, arg)) + }) + .map(|(_, ¶m)| param) + .collect(), + writes: state.writes.clone(), + }; + if let Some(merged) = self.analyze_function(callee_id, callee_state).merged() { + state.writes = merged.writes; + } + } + + fn record_writes(&self, state: &mut WriteState, lhs: &Expr<'_>) { + for var_id in state_lhs_vars(self.gcx, lhs) { + if self.targets.contains(&var_id) { + state.writes.insert(StateWrite { var_id, span: lhs.span }); + } + } + } + + fn set_dynamic(&self, state: &mut WriteState, var_id: VariableId, value: &Expr<'_>) { + if self.is_dynamic(state, value) { + state.dynamic.insert(var_id); + } else { + state.dynamic.remove(&var_id); + } + } + + /// True unless `expr` is a compile-time constant: reads of mutable state, dynamic locals, + /// calls and `block`/`msg`/`tx` members are all dynamic. + fn is_dynamic(&self, state: &WriteState, expr: &Expr<'_>) -> bool { + expr.visit(&mut |e| { + let dynamic = match &e.kind { + ExprKind::Call(..) => true, + ExprKind::Member(base, _) => { + matches!( + self.gcx.resolved_builtin(base), + Some(Builtin::Block | Builtin::Msg | Builtin::Tx) + ) + } + _ => underlying_var(self.gcx, e).is_some_and(|var_id| { + let var = self.gcx.hir.variable(var_id); + state.dynamic.contains(&var_id) + || (var.kind.is_state() && !var.is_constant() && !var.is_immutable()) + }), + }; + if dynamic { ControlFlow::Break(()) } else { ControlFlow::Continue(()) } + }) + .is_break() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use solar::interface::BytePos; + + #[test] + fn merge_deduplicates_writes_in_order() { + let first = StateWrite { var_id: VariableId::new(0), span: Span::DUMMY }; + let another_span = + StateWrite { var_id: first.var_id, span: Span::new(BytePos(1), BytePos(2)) }; + let another_var = StateWrite { var_id: VariableId::new(1), span: first.span }; + let lhs = WriteState { writes: [first].into_iter().collect(), ..Default::default() }; + let rhs = WriteState { + writes: [first, another_span, another_var].into_iter().collect(), + ..Default::default() + }; + let mut state = merge(Some(lhs), Some(rhs)).unwrap(); + let expected = [first, another_span, another_var].map(|write| (write.var_id, write.span)); + + // Each join must retain one copy per write site, even after repeated branching. + for _ in 0..32 { + assert_eq!( + state.writes.iter().map(|write| (write.var_id, write.span)).collect::>(), + expected, + ); + state = merge(Some(state.clone()), Some(state)).unwrap(); + } + } +} diff --git a/crates/lint/src/sol/low/missing_zero_check.rs b/crates/lint/src/sol/low/missing_zero_check.rs new file mode 100644 index 0000000000000..ce4080977b924 --- /dev/null +++ b/crates/lint/src/sol/low/missing_zero_check.rs @@ -0,0 +1,287 @@ +use super::MissingZeroCheck; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{ + Severity, SolLint, + analysis::{ + address_call_receiver, branch_always_exits, is_address_type, is_require_or_assert, + is_zero_value, lhs_local_var, loop_stmts, underlying_var, + }, + }, +}; +use solar::{ + ast::{self, BinOpKind, UnOpKind}, + interface::data_structures::Never, + sema::{ + Gcx, + hir::{self, ExprKind, StmtKind, VariableId, Visit}, + }, +}; +use std::{ + collections::{HashMap, HashSet}, + ops::ControlFlow, + slice, +}; + +declare_forge_lint!( + MISSING_ZERO_CHECK, + Severity::Low, + "missing-zero-check", + "address parameter is used in a state write or value transfer without a zero-address check" +); + +impl<'gcx> LateLintPass<'gcx> for MissingZeroCheck { + fn check_function( + &mut self, + ctx: &LintContext, + gcx: Gcx<'gcx>, + func: &'gcx hir::Function<'gcx>, + ) { + let is_entry_point = !matches!( + func.state_mutability, + ast::StateMutability::Pure | ast::StateMutability::View + ) && (func.is_constructor() + || (func.kind.is_function() + && matches!(func.visibility, ast::Visibility::Public | ast::Visibility::External))); + let Some(body) = func.body.filter(|_| is_entry_point) else { return }; + + let params: HashSet<_> = + func.parameters.iter().copied().filter(|&id| is_address_type(&gcx.hir, id)).collect(); + if params.is_empty() { + return; + } + + let mut a = Analyzer::new(gcx, ¶ms); + for m in func.modifiers { + let Some(modifier_id) = m.id.as_function() else { continue }; + let modifier = gcx.hir.function(modifier_id); + // Map each direct-ident argument back to the caller's parameter and analyze the + // modifier body as if it were a prefix of the function. + let mapping: HashMap<_, _> = modifier + .parameters + .iter() + .zip(m.args.exprs()) + .filter_map(|(&mp, arg)| { + let caller = underlying_var(gcx, arg).filter(|v| params.contains(v))?; + Some((mp, caller)) + }) + .collect(); + if let Some(body) = modifier.body.filter(|_| !mapping.is_empty()) { + let mut ma = Analyzer::new(gcx, &mapping.keys().copied().collect()); + ma.visit_stmts(body.stmts); + a.guarded.extend(ma.guarded.iter().filter_map(|mp| mapping.get(mp))); + } + } + a.visit_stmts(body.stmts); + + for &p in ¶ms { + if a.sinks.contains(&p) { + ctx.emit(&MISSING_ZERO_CHECK, gcx.hir.variable(p).span); + } + } + } +} + +/// Tracks address-parameter taint, sinks reached, and guards observed in a function body. +struct Analyzer<'gcx> { + gcx: Gcx<'gcx>, + /// Variables transitively derived from candidate parameters, mapped to their sources. + /// Each parameter is initially mapped to itself. + taint: HashMap>, + /// Source parameters that reached a sink. + sinks: HashSet, + /// Source parameters proven non-zero so far. + guarded: HashSet, + sink_depth: u32, +} + +impl<'gcx> Analyzer<'gcx> { + fn new(gcx: Gcx<'gcx>, params: &HashSet) -> Self { + Self { + gcx, + taint: params.iter().map(|&p| (p, HashSet::from([p]))).collect(), + sinks: HashSet::new(), + guarded: HashSet::new(), + sink_depth: 0, + } + } + + fn visit_stmts(&mut self, stmts: impl IntoIterator>) { + for s in stmts { + let _ = self.visit_stmt(s); + } + } + + /// Visits `stmts` without letting their guards escape. + fn scoped_guards( + &mut self, + stmts: impl IntoIterator>, + ) -> HashSet { + let baseline = self.guarded.clone(); + self.visit_stmts(stmts); + std::mem::replace(&mut self.guarded, baseline) + } + + /// Sources proven non-zero when `pred` evaluates to `!negate`. + fn nonzero_facts(&self, pred: &'gcx hir::Expr<'gcx>, negate: bool) -> HashSet { + let nonzero_cmp = if negate { BinOpKind::Eq } else { BinOpKind::Ne }; + match &pred.peel_parens().kind { + ExprKind::Unary(op, inner) if op.kind == UnOpKind::Not => { + self.nonzero_facts(inner, !negate) + } + ExprKind::Binary(lhs, op, rhs) if matches!(op.kind, BinOpKind::And | BinOpKind::Or) => { + let lhs = self.nonzero_facts(lhs, negate); + let rhs = self.nonzero_facts(rhs, negate); + if matches!((op.kind, negate), (BinOpKind::And, false) | (BinOpKind::Or, true)) { + &lhs | &rhs + } else { + &lhs & &rhs + } + } + ExprKind::Binary(lhs, op, rhs) if op.kind == nonzero_cmp => { + let mut facts = HashSet::new(); + for (candidate, zero) in [(lhs, rhs), (rhs, lhs)] { + if is_zero_value(zero) + && let Some(sources) = + underlying_var(self.gcx, candidate).and_then(|v| self.taint.get(&v)) + { + facts.extend(sources); + } + } + facts + } + _ => HashSet::new(), + } + } + + fn taint_sources(&self, expr: &hir::Expr<'_>) -> HashSet { + let mut out = HashSet::new(); + let _ = expr.visit(&mut |e| { + if let Some(srcs) = underlying_var(self.gcx, e).and_then(|v| self.taint.get(&v)) { + out.extend(srcs); + } + ControlFlow::::Continue(()) + }); + out + } + + fn propagate(&mut self, local: VariableId, value: &hir::Expr<'_>) { + // Propagate taint through address-typed locals only; this avoids marking unrelated + // values (e.g. `bool ok = a.send(1)`) as derived from `a`. + if is_address_type(&self.gcx.hir, local) { + let srcs = self.taint_sources(value); + if !srcs.is_empty() { + self.taint.entry(local).or_default().extend(srcs); + } + } + } +} + +impl<'gcx> Visit<'gcx> for Analyzer<'gcx> { + type BreakValue = Never; + + fn hir(&self) -> &'gcx hir::Hir<'gcx> { + &self.gcx.hir + } + + fn visit_stmt(&mut self, stmt: &'gcx hir::Stmt<'gcx>) -> ControlFlow { + match stmt.kind { + StmtKind::If(cond, then, else_) => { + let _ = self.visit_expr(cond); + let baseline = self.guarded.clone(); + + self.guarded.extend(self.nonzero_facts(cond, false)); + let then_guards = self.scoped_guards(slice::from_ref(then)); + + self.guarded = baseline; + self.guarded.extend(self.nonzero_facts(cond, true)); + let else_guards = else_ + .map(|e| self.scoped_guards(slice::from_ref(e))) + .unwrap_or_else(|| self.guarded.clone()); + + // A guard in an exiting branch holds for everything after the `if`; otherwise it + // must hold on both branches. + let then_exits = branch_always_exits(self.gcx, then); + let else_exits = else_.is_some_and(|expr| branch_always_exits(self.gcx, expr)); + self.guarded = match (then_exits, else_exits) { + (true, true) => &then_guards | &else_guards, + (true, false) => else_guards, + (false, true) => then_guards, + (false, false) => &then_guards & &else_guards, + }; + return ControlFlow::Continue(()); + } + // Loop bodies may execute zero times, so guards inside must not persist. + StmtKind::Loop(block, source) => { + self.scoped_guards(loop_stmts(block, source)); + return ControlFlow::Continue(()); + } + // Each try/catch clause is taken on a single path; discard clause-local guards. + StmtKind::Try(t) => { + let _ = self.visit_expr(&t.expr); + for clause in t.clauses { + self.scoped_guards(clause.block.stmts); + } + return ControlFlow::Continue(()); + } + StmtKind::DeclSingle(var_id) => { + if let Some(init) = self.gcx.hir.variable(var_id).initializer { + self.propagate(var_id, init); + } + } + _ => {} + } + self.walk_stmt(stmt) + } + + fn visit_expr(&mut self, expr: &'gcx hir::Expr<'gcx>) -> ControlFlow { + match &expr.kind { + // `require(cond, ..)` / `assert(cond)`: only the first arg is a guard predicate. + ExprKind::Call(callee, args) if is_require_or_assert(self.gcx, callee) => { + let mut iter = args.exprs(); + if let Some(cond) = iter.next() { + self.guarded.extend(self.nonzero_facts(cond, false)); + let _ = self.visit_expr(cond); + } + for rest in iter { + let _ = self.visit_expr(rest); + } + return ControlFlow::Continue(()); + } + // `.call/.delegatecall/.transfer/.send(..)`: receiver is the sink. + ExprKind::Call(callee, args) => { + if let Some(receiver) = address_call_receiver(callee) { + self.sink_depth += 1; + let _ = self.visit_expr(receiver); + self.sink_depth -= 1; + return self.visit_call_args(args); + } + } + ExprKind::Assign(lhs, _, rhs) => { + // Sink: assignment to an address state variable. + if let Some(v) = underlying_var(self.gcx, lhs) + && self.gcx.hir.variable(v).kind.is_state() + && is_address_type(&self.gcx.hir, v) + { + self.sink_depth += 1; + let _ = self.visit_expr(rhs); + self.sink_depth -= 1; + return ControlFlow::Continue(()); + } + if let Some(local) = lhs_local_var(self.gcx, lhs) { + self.propagate(local, rhs); + } + } + ExprKind::Ident(_) => { + if self.sink_depth > 0 + && let Some(srcs) = + underlying_var(self.gcx, expr).and_then(|v| self.taint.get(&v)) + { + self.sinks.extend(srcs.iter().filter(|src| !self.guarded.contains(src))); + } + } + _ => {} + } + self.walk_expr(expr) + } +} diff --git a/crates/lint/src/sol/low/mod.rs b/crates/lint/src/sol/low/mod.rs new file mode 100644 index 0000000000000..c1cfcf79f5296 --- /dev/null +++ b/crates/lint/src/sol/low/mod.rs @@ -0,0 +1,37 @@ +use crate::sol::SolLint; + +mod block_timestamp; +mod calls_loop; +mod delegatecall_loop; +mod deprecated_oz_function; +mod empty_block; +mod inconsistent_type_names; +mod incorrect_modifier; +mod missing_events_access_control; +mod missing_events_arithmetic; +mod missing_zero_check; +mod msg_value_loop; +mod payable_loop; +mod reentrancy_events; +mod require_revert_in_loop; +mod return_bomb; +mod solmate_safe_transfer_lib; + +register_lints!( + block_timestamp: (BlockTimestamp, late, (BLOCK_TIMESTAMP)); + calls_loop: (CallsLoop, late, (CALLS_LOOP)); + delegatecall_loop: (DelegatecallLoop, late, (DELEGATECALL_LOOP)); + deprecated_oz_function: (DeprecatedOzFunction, late, (DEPRECATED_OZ_FUNCTION)); + empty_block: (EmptyBlock, early, (EMPTY_BLOCK)); + incorrect_modifier: (IncorrectModifier, late, (INCORRECT_MODIFIER)); + inconsistent_type_names: (InconsistentTypeNames, project, (INCONSISTENT_TYPE_NAMES)); + msg_value_loop: (MsgValueLoop, late, (MSG_VALUE_LOOP)); + missing_events_access_control: + (MissingEventsAccessControl, late, (MISSING_EVENTS_ACCESS_CONTROL)); + missing_events_arithmetic: (MissingEventsArithmetic, late, (MISSING_EVENTS_ARITHMETIC)); + missing_zero_check: (MissingZeroCheck, late, (MISSING_ZERO_CHECK)); + return_bomb: (ReturnBomb, late, (RETURN_BOMB)); + reentrancy_events: (ReentrancyEvents, late, (REENTRANCY_EVENTS)); + require_revert_in_loop: (RequireRevertInLoop, late, (REQUIRE_REVERT_IN_LOOP)); + solmate_safe_transfer_lib: (SolmateSafeTransferLib, late, (SOLMATE_SAFE_TRANSFER_LIB)); +); diff --git a/crates/lint/src/sol/low/msg_value_loop.rs b/crates/lint/src/sol/low/msg_value_loop.rs new file mode 100644 index 0000000000000..91f0aab3b3d40 --- /dev/null +++ b/crates/lint/src/sol/low/msg_value_loop.rs @@ -0,0 +1,23 @@ +use super::{MsgValueLoop, payable_loop::for_each_payable_loop_expr}; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{Severity, SolLint}, +}; +use solar::sema::{Gcx, builtins::Builtin, hir::Function}; + +declare_forge_lint!( + MSG_VALUE_LOOP, + Severity::Low, + "msg-value-loop", + "payable function uses `msg.value` inside a loop" +); + +impl<'gcx> LateLintPass<'gcx> for MsgValueLoop { + fn check_function(&mut self, ctx: &LintContext, gcx: Gcx<'gcx>, func: &'gcx Function<'gcx>) { + for_each_payable_loop_expr(gcx, func, |expr| { + if gcx.resolved_builtin(expr) == Some(Builtin::MsgValue) { + ctx.emit(&MSG_VALUE_LOOP, expr.span); + } + }); + } +} diff --git a/crates/lint/src/sol/low/payable_loop.rs b/crates/lint/src/sol/low/payable_loop.rs new file mode 100644 index 0000000000000..485c4b2a09513 --- /dev/null +++ b/crates/lint/src/sol/low/payable_loop.rs @@ -0,0 +1,206 @@ +//! Loop-context walker shared by the `*-loop` lints. +//! +//! Visits every statement and expression that executes inside a loop of a function, following +//! its modifier chain through `_` and inlining the internal helpers it calls (including `super` +//! dispatch, resolved against the contract the entry point belongs to). + +use crate::sol::analysis::{is_builtin, loop_stmts}; +use solar::{ + ast::{StateMutability, Visibility}, + interface::sym, + sema::{ + Gcx, + hir::{ + Block, ContractId, Expr, ExprKind, Function, FunctionId, FunctionKind, Hir, Modifier, + Stmt, StmtKind, Visit, + }, + ty::TyKind, + }, +}; +use std::{convert::Infallible, ops::ControlFlow}; + +/// A statement or expression reached inside a loop. +pub(super) enum LoopItem<'gcx> { + Stmt(&'gcx Stmt<'gcx>), + Expr(&'gcx Expr<'gcx>), +} + +/// Calls `f` for every expression executing inside a loop of a payable entry point, including +/// loops in the internal helpers it calls (whether the call itself sits in a loop or not). +pub(super) fn for_each_payable_loop_expr<'gcx>( + gcx: Gcx<'gcx>, + func: &'gcx Function<'gcx>, + mut f: impl FnMut(&'gcx Expr<'gcx>), +) { + if !matches!(func.kind, FunctionKind::Constructor | FunctionKind::Modifier) + && func.state_mutability == StateMutability::Payable + && matches!(func.visibility, Visibility::Public | Visibility::External) + { + for_each_loop_item(gcx, func, true, |item| { + if let LoopItem::Expr(expr) = item { + f(expr); + } + }); + } +} + +/// Calls `f` for every statement and expression executing inside a loop of `func`. Internal +/// helpers called from a loop are inlined; with `follow_calls_outside_loop`, so are helpers +/// called outside one, so that their own loops are reported too. +pub(super) fn for_each_loop_item<'gcx>( + gcx: Gcx<'gcx>, + func: &'gcx Function<'gcx>, + follow_calls_outside_loop: bool, + f: impl FnMut(LoopItem<'gcx>), +) { + let Some(body) = func.body else { return }; + let mut walker = LoopWalker { + gcx, + f, + loop_depth: 0, + placeholder: None, + stack: Vec::new(), + dispatch: func.contract, + current: func.contract, + follow_calls_outside_loop, + }; + walker.visit_modifiers(func.modifiers, 0, body, func.contract); +} + +/// The rest of a modifier chain and the function body it wraps, executed at `_`. +type Continuation<'gcx> = (&'gcx [Modifier<'gcx>], usize, Block<'gcx>, Option); + +struct LoopWalker<'gcx, F> { + gcx: Gcx<'gcx>, + f: F, + loop_depth: usize, + placeholder: Option>, + /// Modifiers and helpers currently being inlined, to cut recursion. + stack: Vec, + /// Contract whose linearization resolves `super`. + dispatch: Option, + /// Contract defining the code being walked. + current: Option, + follow_calls_outside_loop: bool, +} + +impl<'gcx, F: FnMut(LoopItem<'gcx>)> LoopWalker<'gcx, F> { + fn visit_modifiers( + &mut self, + modifiers: &'gcx [Modifier<'gcx>], + index: usize, + body: Block<'gcx>, + contract: Option, + ) { + let Some(modifier) = modifiers.get(index) else { + return self.visit_scoped(body, None, contract); + }; + let _ = self.visit_call_args(&modifier.args); + if let Some(id) = self.dispatch.map_or_else( + || modifier.id.as_function(), + |contract| self.gcx.resolve_modifier_target(contract, modifier), + ) && let Some(modifier_body) = self.gcx.hir.function(id).body + && !self.stack.contains(&id) + { + self.stack.push(id); + let continuation = Some((modifiers, index + 1, body, contract)); + self.visit_scoped(modifier_body, continuation, self.gcx.hir.function(id).contract); + self.stack.pop(); + } else { + self.visit_modifiers(modifiers, index + 1, body, contract); + } + } + + fn visit_scoped( + &mut self, + block: Block<'gcx>, + placeholder: Option>, + contract: Option, + ) { + let saved = (self.placeholder, self.current); + (self.placeholder, self.current) = (placeholder, contract); + for stmt in block.stmts { + let _ = self.visit_stmt(stmt); + } + (self.placeholder, self.current) = saved; + } + + fn visit_call(&mut self, id: FunctionId) { + let func = self.gcx.hir.function(id); + if let Some(body) = func.body + && !self.stack.contains(&id) + { + self.stack.push(id); + self.visit_modifiers(func.modifiers, 0, body, func.contract); + self.stack.pop(); + } + } + + /// The internal function a call dispatches to, if it can be inlined: a helper called + /// directly, through a library/base qualifier or a `using for` binding, or via `super`. + /// Calls on a contract-typed value (`this` included) are external and are not followed. + fn callee(&self, callee: &'gcx Expr<'gcx>) -> Option { + let callee = callee.peel_parens(); + let TyKind::Fn(function) = self.gcx.type_of_expr(callee.id)?.kind else { return None }; + if !function.is_internal() && !function.is_delegate_call() { + return None; + } + let func_id = self.gcx.resolved_function(callee)?; + let ExprKind::Member(base, _) = &callee.kind else { + return Some( + self.dispatch.map_or(func_id, |contract| { + self.gcx.resolve_virtual_function(contract, func_id) + }), + ); + }; + if is_builtin(self.gcx, base, sym::super_) { + return Some(self.gcx.resolve_super_function(self.dispatch?, self.current?, func_id)); + } + Some(func_id) + } +} + +impl<'gcx, F: FnMut(LoopItem<'gcx>)> Visit<'gcx> for LoopWalker<'gcx, F> { + type BreakValue = Infallible; + + fn hir(&self) -> &'gcx Hir<'gcx> { + &self.gcx.hir + } + + fn visit_stmt(&mut self, stmt: &'gcx Stmt<'gcx>) -> ControlFlow { + if self.loop_depth > 0 { + (self.f)(LoopItem::Stmt(stmt)); + } + match stmt.kind { + StmtKind::Loop(block, source) => { + self.loop_depth += 1; + for stmt in loop_stmts(block, source) { + self.visit_stmt(stmt)?; + } + self.loop_depth -= 1; + } + StmtKind::Placeholder => { + if let Some((modifiers, index, body, contract)) = self.placeholder { + self.visit_modifiers(modifiers, index, body, contract); + } + } + _ => self.walk_stmt(stmt)?, + } + ControlFlow::Continue(()) + } + + fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow { + self.walk_expr(expr)?; + let in_loop = self.loop_depth > 0; + if in_loop { + (self.f)(LoopItem::Expr(expr)); + } + if (in_loop || self.follow_calls_outside_loop) + && let ExprKind::Call(callee, ..) = &expr.kind + && let Some(id) = self.callee(callee) + { + self.visit_call(id); + } + ControlFlow::Continue(()) + } +} diff --git a/crates/lint/src/sol/low/reentrancy_events.rs b/crates/lint/src/sol/low/reentrancy_events.rs new file mode 100644 index 0000000000000..77f7c5a7e5789 --- /dev/null +++ b/crates/lint/src/sol/low/reentrancy_events.rs @@ -0,0 +1,520 @@ +use super::{ReentrancyEvents, calls_loop::is_state_mutating_external_call}; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{ + Severity, SolLint, + analysis::{ + DEFAULT_HELPER_ANALYSIS_CACHE_LIMIT, HelperAnalysisCache, dispatched_function, + for_each_child, is_exit_call, loop_stmts, loop_update, + }, + }, +}; +use solar::{ + interface::Span, + sema::{ + Gcx, + hir::{ + self, BinOpKind, Block, ContractId, Expr, ExprKind, Function, FunctionId, LoopSource, + Stmt, StmtKind, + }, + }, +}; +use std::collections::{HashMap, HashSet}; + +declare_forge_lint!( + REENTRANCY_EVENTS, + Severity::Low, + "reentrancy-events", + "event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on" +); + +impl<'gcx> LateLintPass<'gcx> for ReentrancyEvents { + fn check_function(&mut self, ctx: &LintContext, gcx: Gcx<'gcx>, func: &'gcx Function<'gcx>) { + let Some(body) = func.body else { return }; + Analyzer::new(ctx, gcx, func.contract).analyze_callable(func, body, false); + } +} + +type Placeholder<'gcx> = Option<(&'gcx [hir::Modifier<'gcx>], usize, Block<'gcx>)>; + +/// How control can leave a piece of code. Each exit kind records whether an external call was +/// seen on some path reaching it; `None` means no path exits that way. Aborting paths +/// (`revert`, `require(false)`, ...) are simply absent, so they cannot taint later statements. +#[derive(Clone, Copy, Debug, Default)] +struct Exits { + fallthrough: Option, + return_: Option, + break_: Option, + continue_: Option, +} + +impl Exits { + fn fallthrough(tainted: bool) -> Self { + Self { fallthrough: Some(tainted), ..Self::default() } + } + + fn return_(tainted: bool) -> Self { + Self { return_: Some(tainted), ..Self::default() } + } + + fn break_(tainted: bool) -> Self { + Self { break_: Some(tainted), ..Self::default() } + } + + fn continue_(tainted: bool) -> Self { + Self { continue_: Some(tainted), ..Self::default() } + } + + fn merge(&mut self, other: Self) { + self.fallthrough = join(self.fallthrough, other.fallthrough); + self.return_ = join(self.return_, other.return_); + self.break_ = join(self.break_, other.break_); + self.continue_ = join(self.continue_, other.continue_); + } + + /// State of the paths that return to the caller normally. + fn normal(self) -> Option { + join(self.fallthrough, self.return_) + } +} + +/// Joins two path states: reachable if either is, tainted if either is. +fn join(lhs: Option, rhs: Option) -> Option { + match (lhs, rhs) { + (Some(lhs), Some(rhs)) => Some(lhs || rhs), + _ => lhs.or(rhs), + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +struct InlineCallKey { + func_id: FunctionId, + external_call_seen: bool, + suppress_inline_reports: bool, +} + +struct Analyzer<'ctx, 's, 'c, 'gcx> { + ctx: &'ctx LintContext<'s, 'c>, + gcx: Gcx<'gcx>, + /// Contract being analysed; `this.f()` and `super.f()` resolve against it, also inside + /// inlined helpers (runtime `this`). + enclosing_contract: Option, + call_stack: Vec, + inline_cache: HelperAnalysisCache, + /// Whether a helper can ever perform an external call; used where a recursive edge is cut. + external_call_reachability: HashMap, + /// Set when a reachability walk hit a recursive edge, so a negative result is inconclusive. + reachability_cut: bool, + emitted: HashSet, + /// Inside a helper entered with a clean state: the helper's own pass reports its emits. + suppress_inline_reports: bool, + /// Set by an inlined callee with no normal exit, so the enclosing statement aborts. + expr_aborted: bool, +} + +impl<'ctx, 's, 'c, 'gcx> Analyzer<'ctx, 's, 'c, 'gcx> { + fn new( + ctx: &'ctx LintContext<'s, 'c>, + gcx: Gcx<'gcx>, + enclosing_contract: Option, + ) -> Self { + Self { + ctx, + gcx, + enclosing_contract, + call_stack: Vec::new(), + inline_cache: HelperAnalysisCache::new(DEFAULT_HELPER_ANALYSIS_CACHE_LIMIT), + external_call_reachability: HashMap::new(), + reachability_cut: false, + emitted: HashSet::new(), + suppress_inline_reports: false, + expr_aborted: false, + } + } + + fn analyze_callable( + &mut self, + func: &'gcx Function<'gcx>, + body: Block<'gcx>, + entry: bool, + ) -> Exits { + self.analyze_modifier_chain(func.modifiers, 0, body, entry) + } + + fn analyze_modifier_chain( + &mut self, + modifiers: &'gcx [hir::Modifier<'gcx>], + index: usize, + body: Block<'gcx>, + mut entry: bool, + ) -> Exits { + let Some(modifier) = modifiers.get(index) else { + return self.analyze_block(body, None, entry); + }; + for arg in modifier.args.exprs() { + self.expr_aborted = false; + self.analyze_expr(arg, &mut entry); + // An aborting argument means the modifier, and so the body, is never entered. + if self.expr_aborted { + return Exits::default(); + } + } + // A modifier may legitimately appear several times in the chain (`m(false) m(true)`), + // so duplicates are not skipped; `index` strictly increases, and recursion through + // internal calls is handled by `analyze_internal_call`. + let Some((modifier_id, modifier_body)) = self + .enclosing_contract + .map_or_else( + || modifier.id.as_function(), + |contract| self.gcx.resolve_modifier_target(contract, modifier), + ) + .and_then(|id| Some((id, self.gcx.hir.function(id).body?))) + else { + return self.analyze_modifier_chain(modifiers, index + 1, body, entry); + }; + self.call_stack.push(modifier_id); + let exits = self.analyze_block(modifier_body, Some((modifiers, index + 1, body)), entry); + self.call_stack.pop(); + exits + } + + /// Analyzes one loop iteration: the body, then the `for` update on the paths that complete it. + fn analyze_iteration( + &mut self, + block: Block<'gcx>, + source: LoopSource<'gcx>, + placeholder: Placeholder<'gcx>, + entry: bool, + ) -> Exits { + let mut exits = self.analyze_block(block, placeholder, entry); + if let (Some(update), Some(state)) = (loop_update(source), exits.fallthrough.take()) { + exits.merge(self.analyze_stmt(update, placeholder, state)); + } + exits + } + + fn analyze_block( + &mut self, + block: Block<'gcx>, + placeholder: Placeholder<'gcx>, + mut entry: bool, + ) -> Exits { + let mut exits = Exits::default(); + for stmt in block.stmts { + let stmt_exits = self.analyze_stmt(stmt, placeholder, entry); + exits.merge(Exits { fallthrough: None, ..stmt_exits }); + // Only the fallthrough state reaches the next statement; without it the rest is dead. + let Some(next) = stmt_exits.fallthrough else { return exits }; + entry = next; + } + exits.fallthrough = Some(entry); + exits + } + + fn analyze_stmt( + &mut self, + stmt: &'gcx Stmt<'gcx>, + placeholder: Placeholder<'gcx>, + mut entry: bool, + ) -> Exits { + self.expr_aborted = false; + match stmt.kind { + StmtKind::DeclSingle(var_id) => { + if let Some(init) = self.gcx.hir.variable(var_id).initializer { + self.analyze_expr(init, &mut entry); + } + self.unless_aborted(Exits::fallthrough(entry)) + } + StmtKind::DeclMulti(_, expr) | StmtKind::Expr(expr) => { + self.analyze_expr(expr, &mut entry); + if is_exit_call(self.gcx, expr) { + return Exits::default(); + } + self.unless_aborted(Exits::fallthrough(entry)) + } + StmtKind::Block(block) | StmtKind::UncheckedBlock(block) => { + self.analyze_block(block, placeholder, entry) + } + StmtKind::Emit(expr) => { + // Event arguments are evaluated before emitting, so an external call in them + // taints this emit too, and an aborting argument makes it unreachable. + self.analyze_expr(expr, &mut entry); + if self.expr_aborted { + return Exits::default(); + } + if entry && !self.suppress_inline_reports && self.emitted.insert(stmt.span) { + self.ctx.emit(&REENTRANCY_EVENTS, stmt.span); + } + Exits::fallthrough(entry) + } + StmtKind::Revert(expr) => { + self.analyze_expr(expr, &mut entry); + Exits::default() + } + StmtKind::Return(expr) => { + if let Some(expr) = expr { + self.analyze_expr(expr, &mut entry); + } + self.unless_aborted(Exits::return_(entry)) + } + StmtKind::Break => Exits::break_(entry), + StmtKind::Continue => Exits::continue_(entry), + StmtKind::Loop(block, source) => { + // Two passes suffice: the one-bit state can only go from clean to tainted around + // the back-edge, so a second pass from the merged entry catches emits tainted + // only on later iterations. `emitted` dedupes the diagnostics. + let first = self.analyze_iteration(block, source, placeholder, entry); + let back_edge = + entry || first.fallthrough.unwrap_or(false) || first.continue_.unwrap_or(false); + let body = if back_edge == entry { + first + } else { + self.analyze_iteration(block, source, placeholder, back_edge) + }; + // Zero iterations, the end of the body, `break` and `continue` all reach the exit. + let post = entry + || body.fallthrough.unwrap_or(false) + || body.break_.unwrap_or(false) + || body.continue_.unwrap_or(false); + Exits { fallthrough: Some(post), return_: body.return_, ..Exits::default() } + } + StmtKind::If(cond, then_stmt, else_stmt) => { + self.analyze_expr(cond, &mut entry); + if self.expr_aborted { + return Exits::default(); + } + let mut exits = self.analyze_stmt(then_stmt, placeholder, entry); + exits.merge(match else_stmt { + Some(else_stmt) => self.analyze_stmt(else_stmt, placeholder, entry), + None => Exits::fallthrough(entry), + }); + exits + } + StmtKind::Try(try_stmt) => { + self.analyze_expr(&try_stmt.expr, &mut entry); + if self.expr_aborted { + return Exits::default(); + } + let mut exits = Exits::default(); + for clause in try_stmt.clauses { + exits.merge(self.analyze_block(clause.block, placeholder, entry)); + } + exits + } + StmtKind::Placeholder => match placeholder { + Some((modifiers, index, body)) => { + self.analyze_modifier_chain(modifiers, index, body, entry) + } + None => Exits::fallthrough(entry), + }, + // Inline assembly can call out and log; conservatively taint. + StmtKind::AssemblyBlock(_) | StmtKind::Switch(_) | StmtKind::Err(_) => { + Exits::fallthrough(true) + } + } + } + + fn unless_aborted(&self, exits: Exits) -> Exits { + if self.expr_aborted { Exits::default() } else { exits } + } + + fn analyze_expr(&mut self, expr: &'gcx Expr<'gcx>, tainted: &mut bool) { + match &expr.kind { + ExprKind::Call(callee, ..) => { + for_each_child(expr, &mut |child| self.analyze_expr(child, tainted)); + if is_state_mutating_external_call(self.gcx, callee) { + *tainted = true; + } + // Follow internal helpers and `super` dispatch so their external calls taint the + // caller too. + for func_id in self.callees(callee) { + self.analyze_internal_call(func_id, tainted); + } + } + ExprKind::Binary(lhs, op, rhs) if matches!(op.kind, BinOpKind::And | BinOpKind::Or) => { + // The RHS is conditional: model it on a fork so its taint joins the result next + // to the short-circuit path, and an aborting RHS only drops the non-short-circuit + // path. + self.analyze_expr(lhs, tainted); + let lhs_aborted = std::mem::replace(&mut self.expr_aborted, false); + let mut rhs_tainted = *tainted; + self.analyze_expr(rhs, &mut rhs_tainted); + let rhs_aborted = self.expr_aborted; + self.expr_aborted = lhs_aborted; + if !lhs_aborted && !rhs_aborted { + *tainted |= rhs_tainted; + } + } + ExprKind::Ternary(cond, then_expr, else_expr) => { + self.analyze_expr(cond, tainted); + let cond_aborted = std::mem::replace(&mut self.expr_aborted, false); + let mut then_tainted = *tainted; + self.analyze_expr(then_expr, &mut then_tainted); + let then_aborted = std::mem::replace(&mut self.expr_aborted, false); + let mut else_tainted = *tainted; + self.analyze_expr(else_expr, &mut else_tainted); + let else_aborted = self.expr_aborted; + // The ternary aborts iff the condition does or both branches do; aborting + // branches drop their state. + self.expr_aborted = cond_aborted || (then_aborted && else_aborted); + if !(then_aborted && else_aborted) { + *tainted = (!then_aborted && then_tainted) || (!else_aborted && else_tainted); + } + } + _ => for_each_child(expr, &mut |child| self.analyze_expr(child, tainted)), + } + } + + /// Internal functions and `super` targets a call through `callee` dispatches to. + fn callees(&self, callee: &'gcx Expr<'gcx>) -> Vec { + self.enclosing_contract + .map_or_else( + || self.gcx.resolved_function(callee), + |contract| dispatched_function(self.gcx, contract, callee), + ) + .into_iter() + .collect() + } + + fn analyze_internal_call(&mut self, func_id: FunctionId, tainted: &mut bool) { + if self.call_stack.contains(&func_id) { + // Replace the cut recursive edge with the conservative "can this helper ever call + // out?" summary so inline summaries stay stack-insensitive. + *tainted |= self.helper_may_reach_external_call(func_id, &mut HashSet::new()); + return; + } + let func = self.gcx.hir.function(func_id); + let Some(body) = func.body else { return }; + + // Diagnostics inside a helper entered clean are left to the helper's own pass, which + // avoids duplicate reports across callers. + let suppress = self.suppress_inline_reports || !*tainted; + let key = InlineCallKey { + func_id, + external_call_seen: *tainted, + suppress_inline_reports: suppress, + }; + if self.inline_cache.is_in_progress(&key) { + return; + } + let summary = match self.inline_cache.get(&key) { + Some(summary) => *summary, + None => { + let prev_suppress = std::mem::replace(&mut self.suppress_inline_reports, suppress); + self.inline_cache.start(key); + self.call_stack.push(func_id); + let summary = self.analyze_callable(func, body, *tainted); + self.call_stack.pop(); + self.inline_cache.finish(key, summary); + self.suppress_inline_reports = prev_suppress; + summary + } + }; + // The caller continues in the state of the normally returning paths; a callee without + // any aborts the enclosing statement. + match summary.normal() { + Some(after) => *tainted = after, + None => self.expr_aborted = true, + } + } + + /// Conservative summary of whether `func_id` can ever perform an external call. + fn helper_may_reach_external_call( + &mut self, + func_id: FunctionId, + seen: &mut HashSet, + ) -> bool { + if let Some(&cached) = self.external_call_reachability.get(&func_id) { + return cached; + } + if !seen.insert(func_id) { + self.reachability_cut = true; + return false; + } + let outer_cut = std::mem::replace(&mut self.reachability_cut, false); + let func = self.gcx.hir.function(func_id); + let may_reach = func.modifiers.iter().any(|modifier| { + modifier.args.exprs().any(|arg| self.expr_may_reach_external_call(arg, seen)) + || modifier + .id + .as_function() + .is_some_and(|id| self.helper_may_reach_external_call(id, seen)) + }) || func.body.is_some_and(|body| { + body.stmts.iter().any(|stmt| self.stmt_may_reach_external_call(stmt, seen)) + }); + seen.remove(&func_id); + // A negative answer that relied on a cut recursive edge is not conclusive. + if may_reach || !self.reachability_cut { + self.external_call_reachability.insert(func_id, may_reach); + } + self.reachability_cut |= outer_cut; + may_reach + } + + fn stmt_may_reach_external_call( + &mut self, + stmt: &'gcx Stmt<'gcx>, + seen: &mut HashSet, + ) -> bool { + match stmt.kind { + StmtKind::DeclSingle(var_id) => self + .gcx + .hir + .variable(var_id) + .initializer + .is_some_and(|init| self.expr_may_reach_external_call(init, seen)), + StmtKind::DeclMulti(_, expr) + | StmtKind::Expr(expr) + | StmtKind::Emit(expr) + | StmtKind::Revert(expr) => self.expr_may_reach_external_call(expr, seen), + StmtKind::Return(expr) => { + expr.is_some_and(|expr| self.expr_may_reach_external_call(expr, seen)) + } + StmtKind::Block(block) | StmtKind::UncheckedBlock(block) => { + block.stmts.iter().any(|stmt| self.stmt_may_reach_external_call(stmt, seen)) + } + StmtKind::Loop(block, source) => { + loop_stmts(block, source).any(|stmt| self.stmt_may_reach_external_call(stmt, seen)) + } + StmtKind::If(cond, then_stmt, else_stmt) => { + self.expr_may_reach_external_call(cond, seen) + || self.stmt_may_reach_external_call(then_stmt, seen) + || else_stmt.is_some_and(|stmt| self.stmt_may_reach_external_call(stmt, seen)) + } + StmtKind::Try(try_stmt) => { + self.expr_may_reach_external_call(&try_stmt.expr, seen) + || try_stmt.clauses.iter().any(|clause| { + clause + .block + .stmts + .iter() + .any(|stmt| self.stmt_may_reach_external_call(stmt, seen)) + }) + } + StmtKind::AssemblyBlock(_) | StmtKind::Switch(_) => true, + StmtKind::Break | StmtKind::Continue | StmtKind::Placeholder | StmtKind::Err(_) => { + false + } + } + } + + fn expr_may_reach_external_call( + &mut self, + expr: &'gcx Expr<'gcx>, + seen: &mut HashSet, + ) -> bool { + let mut reached = false; + for_each_child(expr, &mut |child| { + reached = reached || self.expr_may_reach_external_call(child, seen); + }); + if reached { + return true; + } + let ExprKind::Call(callee, ..) = &expr.kind else { return false }; + is_state_mutating_external_call(self.gcx, callee) + || self + .callees(callee) + .into_iter() + .any(|id| self.helper_may_reach_external_call(id, seen)) + } +} diff --git a/crates/lint/src/sol/low/require_revert_in_loop.rs b/crates/lint/src/sol/low/require_revert_in_loop.rs new file mode 100644 index 0000000000000..daf6f03241582 --- /dev/null +++ b/crates/lint/src/sol/low/require_revert_in_loop.rs @@ -0,0 +1,46 @@ +use super::{ + RequireRevertInLoop, + payable_loop::{LoopItem, for_each_loop_item}, +}; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{Severity, SolLint}, +}; +use solar::sema::{ + Gcx, + builtins::Builtin, + hir::{Expr, ExprKind, Function, StmtKind}, +}; + +declare_forge_lint!( + REQUIRE_REVERT_IN_LOOP, + Severity::Low, + "require-revert-in-loop", + "`require` or `revert` inside a loop" +); + +impl<'gcx> LateLintPass<'gcx> for RequireRevertInLoop { + fn check_function(&mut self, ctx: &LintContext, gcx: Gcx<'gcx>, func: &'gcx Function<'gcx>) { + for_each_loop_item(gcx, func, false, |item| { + let reported = match item { + LoopItem::Stmt(stmt) => match stmt.kind { + StmtKind::Revert(expr) => Some(expr), + _ => None, + }, + LoopItem::Expr(expr) => is_require_or_revert_call(gcx, expr).then_some(expr), + }; + if let Some(expr) = reported { + ctx.emit(&REQUIRE_REVERT_IN_LOOP, expr.span); + } + }); + } +} + +/// `require(..)`, `revert(..)` or the Yul `revert(..)` builtin. +fn is_require_or_revert_call(gcx: Gcx<'_>, expr: &Expr<'_>) -> bool { + let ExprKind::Call(callee, ..) = &expr.peel_parens().kind else { return false }; + matches!( + gcx.resolved_builtin(callee), + Some(Builtin::Require | Builtin::Revert | Builtin::RevertMsg | Builtin::YulRevert) + ) +} diff --git a/crates/lint/src/sol/low/return_bomb.rs b/crates/lint/src/sol/low/return_bomb.rs new file mode 100644 index 0000000000000..5f96702012b70 --- /dev/null +++ b/crates/lint/src/sol/low/return_bomb.rs @@ -0,0 +1,45 @@ +use super::ReturnBomb; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{Severity, SolLint, analysis::is_call_with_gas_limit}, +}; +use solar::sema::{ + Gcx, Ty, + builtins::Builtin, + hir::{self, ExprKind}, + ty::TyKind, +}; + +declare_forge_lint!( + RETURN_BOMB, + Severity::Low, + "return-bomb", + "external call with a gas limit may copy unbounded return data" +); + +impl<'gcx> LateLintPass<'gcx> for ReturnBomb { + fn check_expr(&mut self, ctx: &LintContext, gcx: Gcx<'gcx>, expr: &'gcx hir::Expr<'gcx>) { + // Flag gas-limited calls that can force the caller to copy unbounded returndata: a + // low-level call on an address, or a call returning dynamic data. + let expr = expr.peel_parens(); + if !is_call_with_gas_limit(expr) { + return; + } + let ExprKind::Call(callee, ..) = &expr.kind else { return }; + let low_level = matches!( + gcx.resolved_builtin(callee), + Some(Builtin::AddressCall | Builtin::AddressDelegatecall | Builtin::AddressStaticcall) + ); + if low_level || gcx.type_of_expr(expr.id).is_some_and(|ty| is_dynamic_ty(gcx, ty)) { + ctx.emit(&RETURN_BOMB, expr.span); + } + } +} + +fn is_dynamic_ty<'gcx>(gcx: Gcx<'gcx>, ty: Ty<'gcx>) -> bool { + let ty = ty.peel_refs(); + match ty.kind { + TyKind::Tuple(elements) => elements.iter().any(|ty| is_dynamic_ty(gcx, *ty)), + _ => ty.is_dynamically_encoded(gcx), + } +} diff --git a/crates/lint/src/sol/low/solmate_safe_transfer_lib.rs b/crates/lint/src/sol/low/solmate_safe_transfer_lib.rs new file mode 100644 index 0000000000000..3cff853c3cc0d --- /dev/null +++ b/crates/lint/src/sol/low/solmate_safe_transfer_lib.rs @@ -0,0 +1,48 @@ +use super::SolmateSafeTransferLib; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{Severity, SolLint, analysis::source_in_package}, +}; +use solar::sema::{ + Gcx, + hir::{Expr, ExprKind, FunctionId}, +}; + +declare_forge_lint!( + SOLMATE_SAFE_TRANSFER_LIB, + Severity::Low, + "solmate-safe-transfer-lib", + "the `SafeTransferLib` from Solmate does not check that the token has code, so a transfer to a token-less address succeeds silently" +); + +impl<'gcx> LateLintPass<'gcx> for SolmateSafeTransferLib { + fn check_expr(&mut self, ctx: &LintContext, gcx: Gcx<'gcx>, expr: &'gcx Expr<'gcx>) { + // A name or member expression typed as a function is a resolved reference, called or + // used as a value: judge the single declaration the type checker selected (overloads, + // overrides, `using for` and import aliases already accounted for). + if matches!(expr.kind, ExprKind::Ident(..) | ExprKind::Member(..)) + && let Some(function_id) = gcx.resolved_function(expr) + && is_unchecked_token_op(gcx, function_id) + { + ctx.emit(&SOLMATE_SAFE_TRANSFER_LIB, expr.span); + } + } +} + +/// Whether `function_id` is one of the token operations of solmate's `SafeTransferLib`. +/// `safeTransferETH` stays out: sending ETH involves no token code. A same-name function of +/// another library (Uniswap's `TransferHelper` style) stays out through the resolution, and so +/// does a same-name library from another package (Solady's `SafeTransferLib` checks token code +/// on the empty-return path), which fails the provenance check: the declaring source must come +/// from a solmate package path (`lib/solmate`, `solmate/...`). Matching a whole path component +/// rather than a substring keeps a vendored or patched copy under a misleading path such as +/// `vendor/solmate-fixed/` from being recognized. +fn is_unchecked_token_op(gcx: Gcx<'_>, function_id: FunctionId) -> bool { + let function = gcx.hir.function(function_id); + let (Some(name), Some(contract_id)) = (function.name, function.contract) else { return false }; + let contract = gcx.hir.contract(contract_id); + matches!(name.as_str(), "safeTransfer" | "safeTransferFrom" | "safeApprove") + && contract.kind.is_library() + && contract.name.as_str() == "SafeTransferLib" + && source_in_package(&gcx.hir, function.source, &["solmate"]) +} diff --git a/crates/lint/src/sol/macros.rs b/crates/lint/src/sol/macros.rs index d1e393e4c4017..a176c43d754c1 100644 --- a/crates/lint/src/sol/macros.rs +++ b/crates/lint/src/sol/macros.rs @@ -1,133 +1,73 @@ -#[macro_export] -macro_rules! link { - ($url:expr) => { - concat!("\x1b]8;;", $url, "\x1b\\", $url, "\x1b]8;;\x1b\\") - }; -} - -/// Macro for defining lints and relevant metadata for the Solidity linter. +/// Declares the static metadata of a lint. /// -/// # Parameters +/// - `$id`: identifier of the generated `SolLint` constant. +/// - `$severity`: the `Severity` of the lint. +/// - `$str_id`: the user-facing lint id used in configuration and diagnostics. +/// - `$desc`: a short description. /// -/// Each lint requires the following input fields: -/// - `$id`: Identifier of the generated `SolLint` constant. -/// - `$severity`: The `Severity` of the lint (e.g. `High`, `Med`, `Low`, `Info`, `Gas`). -/// - `$str_id`: A unique identifier used to reference a specific lint during configuration. -/// - `$desc`: A short description of the lint. -/// -/// # Note -/// Each lint must have a `help` section in the foundry book. This help field is auto-generated by -/// the macro. Because of that, to ensure that new lint rules have their corresponding docs in the -/// book, the existence of the lint rule's help section is validated with a unit test. +/// Each lint must have a markdown page at `crates/lint/docs/.md`; the `help` URL is +/// derived from `$str_id` and validated by a unit test in `crates/lint/src/sol/mod.rs`. #[macro_export] macro_rules! declare_forge_lint { ($id:ident, $severity:expr, $str_id:expr, $desc:expr) => { - // Declare the static `Lint` metadata pub static $id: SolLint = SolLint { id: $str_id, severity: $severity, description: $desc, - help: link!(concat!("https://book.getfoundry.sh/reference/forge/forge-lint#", $str_id)), + help: concat!("https://getfoundry.sh/forge/linting/", $str_id), }; }; - - ($id:ident, $severity:expr, $str_id:expr, $desc:expr) => { - $crate::declare_forge_lint!($id, $severity, $str_id, $desc, ""); - }; } -/// Registers Solidity linter passes that can have both early and late variants. +/// Declares the lint modules of a severity group and registers their passes. /// -/// # Parameters -/// -/// Each pass is declared with: -/// - `$pass_id`: Identifier of the generated struct that will implement the pass trait(s). -/// - `$pass_type`: Either `early`, `late`, or `both` to indicate which traits to implement. -/// - `$lints`: A parenthesized, comma-separated list of `SolLint` constants. -/// -/// # Outputs -/// -/// - Structs for each linting pass -/// - Helper methods to create early and late passes with required lifetimes -/// - `const REGISTERED_LINTS` containing all registered lint objects +/// Each entry is `module: (PassStruct, early|late|project, (LINT, ...) [, constructor]), ...;`. +/// The macro glob-imports each module (which the caller declares with `mod`, so that rustfmt +/// still discovers the files), generates the `PassStruct` marker types, +/// the `REGISTERED_LINTS` slice and a `register_lints` function that adds every pass to Solar's +/// registry. A constructor, when given, receives the lint-specific configuration. #[macro_export] macro_rules! register_lints { - // 1. Internal rule for declaring structs. - ( @declare_structs $( ($pass_id:ident, $pass_type:ident, $lints:tt) ),* $(,)? ) => { - $( - #[derive(Debug, Default, Clone, Copy, Eq, PartialEq)] - pub struct $pass_id; - - impl $pass_id { - register_lints!(@early_impl $pass_id, $pass_type); - register_lints!(@late_impl $pass_id, $pass_type); - } - )* + (@register $registry:ident, $config:ident, $pass:ident, early $(, $ctor:expr)?) => { + register_lints!(@call $registry, $config, $pass, register_early_pass $(, $ctor)?); }; - - // 2. Internal rule for declaring the const array. - ( @declare_consts $( ($pass_id:ident, $pass_type:ident, ($($lint:expr),* $(,)?)) ),* $(,)? ) => { - pub const REGISTERED_LINTS: &[SolLint] = &[ - $( - $($lint,)* - )* - ]; + (@register $registry:ident, $config:ident, $pass:ident, late $(, $ctor:expr)?) => { + register_lints!(@call $registry, $config, $pass, register_late_pass $(, $ctor)?); }; - - // 3. Internal rule for declaring the helper functions. - ( @declare_funcs $( ($pass_id:ident, $pass_type:ident, $lints:tt) ),* $(,)? ) => { - pub fn create_early_lint_passes<'a>() -> Vec<(Box>, SolLint)> { - vec![ - $( - register_lints!(@early_create $pass_id, $pass_type, $lints), - )* - ] - .into_iter() - .flatten() - .collect() - } - - pub fn create_late_lint_passes<'hir>() -> Vec<(Box>, SolLint)> { - vec![ - $( - register_lints!(@late_create $pass_id, $pass_type, $lints), - )* - ] - .into_iter() - .flatten() - .collect() - } + (@register $registry:ident, $config:ident, $pass:ident, project $(, $ctor:expr)?) => { + register_lints!(@call $registry, $config, $pass, register_project_pass $(, $ctor)?); }; - // --- HELPERS ------------------------------------------------------------ - (@early_impl $_pass_id:ident, late) => {}; - (@early_impl $pass_id:ident, $other:ident) => { - pub fn as_early_lint_pass<'a>() -> Box> { - Box::new(Self::default()) - } + (@call $registry:ident, $config:ident, $pass:ident, $method:ident, $ctor:expr) => {{ + let config = std::sync::Arc::clone($config); + $registry.$method($pass::LINT_IDS, move || ($ctor)(std::sync::Arc::clone(&config))); + }}; + (@call $registry:ident, $config:ident, $pass:ident, $method:ident) => { + $registry.$method($pass::LINT_IDS, $pass::default); }; - (@late_impl $_pass_id:ident, early) => {}; - (@late_impl $pass_id:ident, $other:ident) => { - pub fn as_late_lint_pass<'hir>() -> Box> { - Box::new(Self::default()) - } - }; + ( $( $module:ident: $( ($pass:ident, $kind:ident, ($($lint:ident),* $(,)?) $(, $ctor:expr)?) ),+ $(,)? ; )* ) => { + $( + use $module::*; - (@early_create $_pass_id:ident, late, $_lints:tt) => { vec![] }; - (@early_create $pass_id:ident, $other:ident, ($($lint:expr),*)) => { - vec![ $(($pass_id::as_early_lint_pass(), $lint)),* ] - }; + $( + #[derive(Debug, Default, Clone, Copy, Eq, PartialEq)] + pub struct $pass; - (@late_create $_pass_id:ident, early, $_lints:tt) => { vec![] }; - (@late_create $pass_id:ident, $other:ident, ($($lint:expr),*)) => { - vec![ $(($pass_id::as_late_lint_pass(), $lint)),* ] - }; + impl $pass { + const LINT_IDS: &'static [&'static str] = &[$($lint.id),*]; + } + )+ + )* + + pub const REGISTERED_LINTS: &[SolLint] = &[$($($($lint,)*)+)*]; - // --- ENTRY POINT --------------------------------------------------------- - ( $($tokens:tt)* ) => { - register_lints! { @declare_structs $($tokens)* } - register_lints! { @declare_consts $($tokens)* } - register_lints! { @declare_funcs $($tokens)* } + pub fn register_lints( + registry: &mut solar_lint::LintRegistry, + config: &std::sync::Arc, + ) { + let _ = config; + $($( register_lints!(@register registry, config, $pass, $kind $(, $ctor)?); )+)* + } }; } diff --git a/crates/lint/src/sol/med/assert_state_change.rs b/crates/lint/src/sol/med/assert_state_change.rs new file mode 100644 index 0000000000000..2c1baae4430c9 --- /dev/null +++ b/crates/lint/src/sol/med/assert_state_change.rs @@ -0,0 +1,98 @@ +use super::AssertStateChange; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{Severity, SolLint, analysis::for_each_lhs_var}, +}; +use solar::{ + ast::{DataLocation, StateMutability}, + sema::{ + Gcx, + builtins::Builtin, + hir::{Expr, ExprKind}, + }, +}; +use std::ops::ControlFlow; + +declare_forge_lint!( + ASSERT_STATE_CHANGE, + Severity::Med, + "assert-state-change", + "`assert()` contains a state-modifying expression" +); + +impl<'gcx> LateLintPass<'gcx> for AssertStateChange { + fn check_expr(&mut self, ctx: &LintContext, gcx: Gcx<'gcx>, expr: &'gcx Expr<'gcx>) { + let ExprKind::Call(callee, args) = &expr.kind else { return }; + if gcx.resolved_builtin(callee) != Some(Builtin::Assert) { + return; + } + for arg in args.exprs() { + // Point the diagnostic at the first sub-expression that mutates state. + if let ControlFlow::Break(span) = arg.visit(&mut |e| { + if is_state_change(gcx, e) { + ControlFlow::Break(e.span) + } else { + ControlFlow::Continue(()) + } + }) { + ctx.emit_with_msg( + &ASSERT_STATE_CHANGE, + span, + "`assert()` argument contains a state-modifying expression; \ + `assert()` is for invariants, hoist the mutation before the `assert`, \ + or use `require()` for validation", + ); + } + } + } +} + +fn is_state_change<'gcx>(gcx: Gcx<'gcx>, expr: &Expr<'gcx>) -> bool { + match &expr.kind { + ExprKind::Assign(lhs, ..) | ExprKind::Delete(lhs) => is_storage_lvalue(gcx, lhs), + ExprKind::Unary(op, lhs) => op.kind.has_side_effects() && is_storage_lvalue(gcx, lhs), + ExprKind::Call(callee, ..) => is_mutating_call(gcx, callee), + _ => false, + } +} + +/// True if the lvalue is rooted in contract storage: a state variable or a local declared +/// `storage`, which aliases contract storage. +fn is_storage_lvalue(gcx: Gcx<'_>, expr: &Expr<'_>) -> bool { + let mut found = false; + for_each_lhs_var(gcx, expr, &mut |v| { + let v = gcx.hir.variable(v); + found |= v.is_state_variable() || v.data_location == Some(DataLocation::Storage); + }); + found +} + +fn is_mutating_call<'gcx>(gcx: Gcx<'gcx>, callee: &Expr<'gcx>) -> bool { + if let ExprKind::Member(base, _) = &callee.kind { + // `arr.push(..)` / `arr.pop()` on a storage array or `bytes`. The type check keeps + // contract methods that happen to be named push/pop out of this heuristic. + if matches!( + gcx.resolved_builtin(callee), + Some(Builtin::ArrayPush0 | Builtin::ArrayPush | Builtin::ArrayPop) + ) && is_storage_lvalue(gcx, base) + { + return true; + } + // Low-level address calls always transfer value or execute foreign code. The receiver + // must be address-like so contract methods named send/call/transfer are not caught. + if matches!( + gcx.resolved_builtin(callee), + Some( + Builtin::AddressCall + | Builtin::AddressDelegatecall + | Builtin::AddressPayableSend + | Builtin::AddressPayableTransfer + ) + ) { + return true; + } + } + gcx.type_of_expr(callee.peel_parens().id) + .and_then(|ty| ty.state_mutability()) + .is_some_and(|mutability| mutability > StateMutability::View) +} diff --git a/crates/lint/src/sol/med/cheatcode_environment.rs b/crates/lint/src/sol/med/cheatcode_environment.rs new file mode 100644 index 0000000000000..914cac0f4f430 --- /dev/null +++ b/crates/lint/src/sol/med/cheatcode_environment.rs @@ -0,0 +1,1112 @@ +//! Finds raw environment reads whose values can cross a Foundry environment mutation. +//! +//! A bounded source-level interpreter follows scalar locals, tuple assignments, internal calls, +//! and modifier placeholders. An environment setter marks matching live origins; a subsequent use +//! reports the original read. Reads on both sides of a mutation are also reported because a +//! compiler may reuse the earlier read. Branches retain separate states, and return, revert, +//! break, and continue stop the corresponding path. External calls are not inlined: the callee +//! has a separate EVM frame and its return data is already materialized. +//! Differing helper return values and conditional-expression values are discarded instead of +//! combining components from mutually exclusive outcomes. +//! +//! This analysis runs before optimization and never changes executable code. Cheatcodes are +//! recognized by their resolved signature and constant receiver address, including local aliases +//! and helper arguments, rather than by a variable or interface name. Getter results carry no +//! raw-read origin. Heap/storage aliases, indirect calls, low-level cheatcode calls, recursion, +//! and paths beyond the explicit analysis limits are not modeled. This is a source warning, +//! not a proof of a particular optimizer's scheduling decisions. + +use super::CheatcodeEnvironment; +use crate::{ + linter::{Lint, ProjectLintEmitter, ProjectLintPass, ProjectSource}, + sol::{ + Severity, SolLint, + analysis::{arg_for_param, dispatched_function, for_each_child, is_exit_call, loop_update}, + }, +}; +use alloy_primitives::{U256, keccak256, uint}; +use solar::{ + ast::{BinOpKind, ElementaryType, FunctionKind, UnOpKind}, + interface::{Span, diagnostics::DiagId, source_map::FileName}, + sema::{ + Gcx, + builtins::Builtin, + eval::ConstValue, + hir::{self, Expr, ExprKind, Function, FunctionId, Stmt, StmtKind, VariableId}, + ty::TyKind, + }, +}; +use std::collections::HashMap; + +declare_forge_lint!( + ENVIRONMENT_READ_ACROSS_MUTATION, + Severity::Med, + "environment-read-across-mutation", + "environment read may be reused across a Foundry environment mutation" +); + +const CHEATCODE_ADDRESS: U256 = uint!(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D_U256); +const MAX_STEPS: usize = 16_384; +const MAX_PATHS: usize = 32; +const MAX_CALL_DEPTH: usize = 8; +const MAX_LOOP_ITERATIONS: usize = 2; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Environment { + Number, + Timestamp, + ChainId, + Coinbase, + Difficulty, + Prevrandao, + BaseFee, + BlobBaseFee, + GasLimit, + SlotNumber, + GasPrice, + BlockHash, + BlobHash, +} + +impl Environment { + /// Block/configuration fields and blockhash history replaced by fork operations. + const BLOCK: &'static [Self] = &[ + Self::Number, + Self::Timestamp, + Self::ChainId, + Self::Coinbase, + Self::Difficulty, + Self::Prevrandao, + Self::BaseFee, + Self::BlobBaseFee, + Self::GasLimit, + Self::SlotNumber, + Self::BlockHash, + ]; + + /// Snapshots also restore cheatcode overrides of transaction properties. + const ALL: &'static [Self] = &[ + Self::Number, + Self::Timestamp, + Self::ChainId, + Self::Coinbase, + Self::Difficulty, + Self::Prevrandao, + Self::BaseFee, + Self::BlobBaseFee, + Self::GasLimit, + Self::SlotNumber, + Self::BlockHash, + Self::GasPrice, + Self::BlobHash, + ]; + + const fn from_builtin(builtin: Builtin) -> Option { + Some(match builtin { + Builtin::BlockNumber => Self::Number, + Builtin::BlockTimestamp => Self::Timestamp, + Builtin::BlockChainid => Self::ChainId, + Builtin::BlockCoinbase => Self::Coinbase, + Builtin::BlockDifficulty => Self::Difficulty, + Builtin::BlockPrevrandao => Self::Prevrandao, + Builtin::BlockBasefee => Self::BaseFee, + Builtin::BlockBlobbasefee => Self::BlobBaseFee, + Builtin::BlockGaslimit => Self::GasLimit, + Builtin::BlockSlotnum => Self::SlotNumber, + Builtin::TxGasPrice => Self::GasPrice, + Builtin::Blockhash => Self::BlockHash, + Builtin::Blobhash => Self::BlobHash, + _ => return None, + }) + } + + const fn name(self) -> &'static str { + match self { + Self::Number => "block.number", + Self::Timestamp => "block.timestamp", + Self::ChainId => "block.chainid", + Self::Coinbase => "block.coinbase", + Self::Difficulty => "block.difficulty", + Self::Prevrandao => "block.prevrandao", + Self::BaseFee => "block.basefee", + Self::BlobBaseFee => "block.blobbasefee", + Self::GasLimit => "block.gaslimit", + Self::SlotNumber => "block.slotnum", + Self::GasPrice => "tx.gasprice", + Self::BlockHash => "blockhash(...)", + Self::BlobHash => "blobhash(...)", + } + } + + const fn getter(self) -> Option<&'static str> { + Some(match self { + Self::Number => "vm.getBlockNumber()", + Self::Timestamp => "vm.getBlockTimestamp()", + Self::ChainId => "vm.getChainId()", + Self::BlobBaseFee => "vm.getBlobBaseFee()", + Self::BlobHash => "vm.getBlobhashes()", + _ => return None, + }) + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +struct Read { + environment: Environment, + span: Span, + changed: Option, + origin: usize, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +struct Mutation { + span: Span, + function: FunctionId, +} + +/// Exact unsigned and boolean locals used to prune exhausted loops and constant branches. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Scalar { + Uint(U256), + Bool(bool), +} + +impl Scalar { + const fn as_bool(self) -> Option { + if let Self::Bool(value) = self { Some(value) } else { None } + } + + fn binary(self, op: BinOpKind, rhs: Self) -> Option { + Some(match (self, rhs) { + (Self::Uint(lhs), Self::Uint(rhs)) => match op { + BinOpKind::Lt => Self::Bool(lhs < rhs), + BinOpKind::Le => Self::Bool(lhs <= rhs), + BinOpKind::Gt => Self::Bool(lhs > rhs), + BinOpKind::Ge => Self::Bool(lhs >= rhs), + BinOpKind::Eq => Self::Bool(lhs == rhs), + BinOpKind::Ne => Self::Bool(lhs != rhs), + _ => return None, + }, + (Self::Bool(lhs), Self::Bool(rhs)) => Self::Bool(match op { + BinOpKind::And => lhs && rhs, + BinOpKind::Or => lhs || rhs, + BinOpKind::Eq => lhs == rhs, + BinOpKind::Ne => lhs != rhs, + _ => return None, + }), + _ => return None, + }) + } +} + +#[derive(Clone, Default, PartialEq, Eq)] +struct Value { + reads: Vec, + cheatcode: bool, + tuple: Vec, + scalar: Option, +} + +impl Value { + fn merge(&mut self, other: &Self) { + if self.scalar != other.scalar { + self.scalar = None; + } + for read in &other.reads { + if !self.reads.contains(read) { + self.reads.push(*read); + } + } + self.cheatcode |= other.cheatcode; + self.tuple.resize_with(self.tuple.len().max(other.tuple.len()), Self::default); + for (a, b) in self.tuple.iter_mut().zip(&other.tuple) { + a.merge(b); + } + } + + fn change(&mut self, environment: Environment, mutation: Mutation) { + for read in &mut self.reads { + if read.environment == environment { + // Keep the first matching call on this path, not an unrelated or later setter. + read.changed.get_or_insert(mutation); + } + } + for part in &mut self.tuple { + part.change(environment, mutation); + } + } + + /// Refresh origins held outside locals while sibling expressions execute. + fn refresh(&mut self, state: &State) { + for read in &mut self.reads { + read.changed = read.changed.or_else(|| state.changed.get(&read.origin).copied()); + } + for part in &mut self.tuple { + part.refresh(state); + } + } + + fn part(&self, index: usize) -> Self { + self.tuple.get(index).cloned().unwrap_or_default() + } +} + +#[derive(Clone, Copy, Default, PartialEq, Eq)] +enum Flow { + #[default] + Next, + Return, + Break, + Continue, + Halt, +} + +#[derive(Clone, Default)] +struct State { + locals: HashMap, + seen: Value, + changed: HashMap, + return_parameters: Vec, + flow: Flow, + unchecked: bool, +} + +impl State { + fn change(&mut self, environment: Environment, mutation: Mutation) { + for read in self.seen.reads.iter().filter(|read| read.environment == environment) { + self.changed.entry(read.origin).or_insert(mutation); + } + self.seen.change(environment, mutation); + for value in self.locals.values_mut() { + value.change(environment, mutation); + } + } + + fn merge(&mut self, other: &Self) { + for (&origin, &mutation) in &other.changed { + self.changed.entry(origin).or_insert(mutation); + } + self.seen.merge(&other.seen); + for (var, value) in &other.locals { + self.locals.entry(*var).or_default().merge(value); + } + } +} + +// Project sources expose the span-owner policies needed to emit a multi-span diagnostic. +// The pinned Solar late-pass context only supports single-span messages and suggestions. +impl<'ast> ProjectLintPass<'ast> for CheatcodeEnvironment { + fn check_project(&mut self, ctx: &ProjectLintEmitter<'_, '_>, sources: &[ProjectSource<'ast>]) { + if !ctx.is_lint_enabled(ENVIRONMENT_READ_ACROSS_MUTATION.id) { + return; + } + let gcx = ctx.gcx(); + let input_sources = gcx + .hir + .sources_enumerated() + .filter_map(|(id, source)| { + let FileName::Real(path) = &source.file.name else { return None }; + Some((id, sources.iter().find(|source| &source.path == path)?)) + }) + .collect::>(); + for func in gcx.hir.functions() { + if func.body.is_none() + || func.kind == FunctionKind::Modifier + || !input_sources.contains_key(&func.source) + { + continue; + } + let mut checker = Checker { + sources, + gcx, + contract: func.contract, + stack: vec![func.span], + remaining: MAX_STEPS, + }; + let initial = State { return_parameters: func.returns.to_vec(), ..State::default() }; + for state in checker.layer(func, 0, initial) { + if matches!(state.flow, Flow::Next | Flow::Return) { + checker.use_value(&checker.return_values(func, &state)); + } + } + } + } +} + +struct Checker<'a, 'ast, 'gcx> { + sources: &'a [ProjectSource<'ast>], + gcx: Gcx<'gcx>, + contract: Option, + stack: Vec, + remaining: usize, +} + +impl<'gcx> Checker<'_, '_, 'gcx> { + const fn step(&mut self) -> bool { + if self.remaining == 0 { + return false; + } + self.remaining -= 1; + true + } + + fn use_value(&self, value: &Value) { + for read in &value.reads { + if read.changed.is_some() { + self.emit(*read); + } + } + for part in &value.tuple { + self.use_value(part); + } + } + + fn emit(&self, read: Read) { + let Some(mutation) = read.changed else { return }; + let lint = &ENVIRONMENT_READ_ACROSS_MUTATION; + // The primary read can belong to an inherited helper in another source. Use its + // policy, not the mutation's, and never emit diagnostics for dependency-only files. + let Some(source) = self.sources.iter().find(|source| source.file.contains(read.span.lo())) + else { + return; + }; + if !source.policy.is_lint_enabled(lint.id) + || source.policy.is_lint_suppressed(lint.id, read.span) + { + return; + } + let name = read.environment.name(); + let setter = self.gcx.hir.function(mutation.function).name.unwrap(); + let advice = read.environment.getter().map_or_else( + || "capture it through an external helper call instead".to_string(), + |getter| format!("capture it with `{getter}` instead"), + ); + self.gcx + .sess + .dcx + .diag::<()>(lint.level(), format!("`{name}` may be reused across `vm.{setter}`")) + .code(DiagId::new_str(lint.id)) + .span(read.span) + .span_label(mutation.span, format!("`vm.{setter}` changes this environment here")) + .help(advice) + .help(lint.help) + .emit(); + } + + fn read(&mut self, environment: Environment, span: Span, state: &mut State) -> Value { + if !self.step() || state.flow == Flow::Halt { + return Value::default(); + } + for read in &state.seen.reads { + if read.environment == environment && read.changed.is_some() { + self.emit(*read); + } + } + let value = Value { + reads: vec![Read { + environment, + span, + changed: None, + // The decreasing step budget gives repeated evaluations distinct identities. + origin: self.remaining, + }], + ..Value::default() + }; + state.seen.merge(&value); + value + } + + /// Runs the next modifier, substituting the remaining function at each placeholder. + fn layer(&mut self, func: &'gcx Function<'gcx>, index: usize, mut state: State) -> Vec { + if !self.step() || state.flow == Flow::Halt { + return Vec::new(); + } + if let Some(modifier) = func.modifiers.get(index) { + let Some(id) = self.contract.map_or_else( + || modifier.id.as_function(), + |contract| self.gcx.resolve_modifier_target(contract, modifier), + ) else { + return Vec::new(); + }; + let definition = self.gcx.hir.function(id); + let Some(body) = definition.body else { return Vec::new() }; + let values: Vec<_> = definition + .parameters + .iter() + .map(|¶m| { + let value = arg_for_param(self.gcx, id, param, &modifier.args) + .map(|arg| self.expr(arg, &mut state)) + .unwrap_or_default(); + (param, value) + }) + .collect(); + state.locals.extend(values); + self.block(body.stmts, vec![state], Some((func, index + 1))) + } else if let Some(body) = func.body { + self.block(body.stmts, vec![state], None) + } else { + Vec::new() + } + } + + fn return_values(&self, func: &Function<'_>, state: &State) -> Value { + let values: Vec<_> = func + .returns + .iter() + .map(|var| state.locals.get(var).cloned().unwrap_or_default()) + .collect(); + if values.len() == 1 { + values.into_iter().next().unwrap_or_default() + } else { + Value { tuple: values, ..Value::default() } + } + } + + fn block( + &mut self, + stmts: &'gcx [Stmt<'gcx>], + mut states: Vec, + continuation: Option<(&'gcx Function<'gcx>, usize)>, + ) -> Vec { + for stmt in stmts { + let mut next = Vec::new(); + for state in states { + if state.flow == Flow::Next { + next.extend(self.stmt(stmt, state, continuation)); + } else { + next.push(state); + } + if next.len() >= MAX_PATHS { + break; + } + } + next.truncate(MAX_PATHS); + states = next; + } + states + } + + fn stmt( + &mut self, + stmt: &'gcx Stmt<'gcx>, + mut state: State, + continuation: Option<(&'gcx Function<'gcx>, usize)>, + ) -> Vec { + if !self.step() || state.flow == Flow::Halt { + return Vec::new(); + } + match &stmt.kind { + StmtKind::Block(block) => { + return self.block(block.stmts, vec![state], continuation); + } + StmtKind::UncheckedBlock(block) => { + let previous = std::mem::replace(&mut state.unchecked, true); + let mut states = self.block(block.stmts, vec![state], continuation); + for state in &mut states { + state.unchecked = previous; + } + return states; + } + StmtKind::DeclSingle(var) => { + let value = self + .gcx + .hir + .variable(*var) + .initializer + .map(|expr| self.expr(expr, &mut state)) + .unwrap_or_else(|| self.default_value(*var)); + state.locals.insert(*var, value); + } + StmtKind::DeclMulti(vars, expr) => { + let value = self.expr(expr, &mut state); + for (index, var) in vars.iter().enumerate() { + if let Some(var) = var { + state.locals.insert(*var, value.part(index)); + } + } + } + StmtKind::If(cond, then, otherwise) => { + let known = self.expr(cond, &mut state).scalar.and_then(Scalar::as_bool); + let mut states = Vec::new(); + if known != Some(false) { + states.extend(self.stmt(then, state.clone(), continuation)); + } + if known != Some(true) { + states.extend(otherwise.map_or_else( + || vec![state.clone()], + |stmt| self.stmt(stmt, state.clone(), continuation), + )); + } + return states; + } + StmtKind::Loop(body, source) => { + // HIR includes the loop condition and its break edge in the body. + let mut active = vec![state]; + let mut exits = Vec::new(); + for _ in 0..MAX_LOOP_ITERATIONS { + let iteration = self.block(body.stmts, active, continuation); + active = Vec::new(); + for mut state in iteration { + match state.flow { + Flow::Break => { + state.flow = Flow::Next; + exits.push(state); + } + Flow::Next | Flow::Continue => { + state.flow = Flow::Next; + if let Some(update) = loop_update(*source) { + active.extend(self.stmt(update, state, continuation)); + } else { + active.push(state); + } + } + _ => exits.push(state), + } + } + active.truncate(MAX_PATHS); + exits.truncate(MAX_PATHS); + } + // Check the final for/while condition without executing another body. A loop + // exhausted exactly at the iteration budget still reaches following statements. + if !matches!(source, hir::LoopSource::DoWhile) + && let [stmt] = body.stmts + && let StmtKind::If(cond, _, Some(otherwise)) = &stmt.kind + && matches!(otherwise.kind, StmtKind::Break) + { + for mut state in active { + let known = self.expr(cond, &mut state).scalar.and_then(Scalar::as_bool); + if known != Some(true) && state.flow == Flow::Next { + exits.push(state); + } + } + exits.truncate(MAX_PATHS); + } + // Only observed exit edges reach following statements. Do not manufacture an + // exit from an infinite loop or treat an unvisited iteration as execution. + return exits; + } + StmtKind::Return(expr) => { + if let Some(expr) = expr { + let value = self.expr(expr, &mut state); + if state.flow == Flow::Halt { + return vec![state]; + } + for (index, var) in state.return_parameters.iter().enumerate() { + let part = if state.return_parameters.len() == 1 { + value.clone() + } else { + value.part(index) + }; + state.locals.insert(*var, part); + } + } + state.flow = Flow::Return; + } + StmtKind::Break => state.flow = Flow::Break, + StmtKind::Continue => state.flow = Flow::Continue, + StmtKind::Revert(expr) => { + self.expr(expr, &mut state); + state.flow = Flow::Halt; + } + StmtKind::Expr(expr) | StmtKind::Emit(expr) => { + self.expr(expr, &mut state); + if is_exit_call(self.gcx, expr) { + state.flow = Flow::Halt; + } + } + StmtKind::Try(try_stmt) => { + // Argument evaluation happens in the caller even when the external call fails. + // Effects of a reverted cheatcode call itself must not reach a catch clause. + let mut failure = state.clone(); + for_each_child(&try_stmt.expr, &mut |child| { + self.expr(child, &mut failure); + }); + self.expr(&try_stmt.expr, &mut state); + return try_stmt + .clauses + .iter() + .enumerate() + .flat_map(|(index, clause)| { + let input = if index == 0 { &state } else { &failure }; + self.block(clause.block.stmts, vec![input.clone()], continuation) + }) + .take(MAX_PATHS) + .collect(); + } + StmtKind::Placeholder => { + if let Some((func, index)) = continuation { + let mut states = self.layer(func, index, state); + for state in &mut states { + if state.flow == Flow::Return { + state.flow = Flow::Next; + } + } + return states; + } + } + // Assembly can both overwrite locals and terminate the frame. Stop this path rather + // than carrying facts across unmodeled instructions. + StmtKind::AssemblyBlock(_) | StmtKind::Switch(_) | StmtKind::Err(_) => { + return Vec::new(); + } + } + vec![state] + } + + fn bind(&self, lhs: &Expr<'_>, value: Value, state: &mut State) { + match &lhs.peel_parens().kind { + ExprKind::Tuple(parts) => { + for (index, part) in parts.iter().enumerate() { + if let Some(part) = part { + self.bind(part, value.part(index), state); + } + } + } + _ => { + if let Some(var) = self.gcx.resolved_variable(lhs) + && !self.gcx.hir.variable(var).is_state_variable() + { + state.locals.insert(var, value); + } + } + } + } + + /// Evaluate destination components without reading an overwritten local. + fn destination(&mut self, expr: &'gcx Expr<'gcx>, state: &mut State) { + let expr = expr.peel_parens(); + if let ExprKind::Tuple(parts) = &expr.kind { + for part in parts.iter().flatten() { + self.destination(part, state); + } + } else if self.gcx.resolved_variable(expr).is_none() { + for_each_child(expr, &mut |child| { + self.expr(child, state); + }); + } + } + + fn expr(&mut self, expr: &'gcx Expr<'gcx>, state: &mut State) -> Value { + let mut value = self.expr_inner(expr, state); + value.refresh(state); + self.use_value(&value); + value.scalar = value.scalar.or_else(|| { + self.gcx.try_eval_const_value(expr).ok().and_then(|value| { + value.as_bool().map(Scalar::Bool).or_else(|| value.as_u256().map(Scalar::Uint)) + }) + }); + // Only exact unsigned and boolean values participate in branch pruning. + value.scalar = + value.scalar.filter(|scalar| match (scalar, self.gcx.type_of_expr(expr.id)) { + (Scalar::Uint(value), Some(ty)) => match ty.kind { + TyKind::Elementary(ElementaryType::UInt(size)) => { + value.bit_len() <= size.bits() as usize + } + TyKind::IntLiteral(false, ..) => true, + _ => false, + }, + (Scalar::Bool(_), Some(ty)) => { + matches!(ty.kind, TyKind::Elementary(ElementaryType::Bool)) + } + _ => false, + }); + value + } + + fn expr_inner(&mut self, expr: &'gcx Expr<'gcx>, state: &mut State) -> Value { + if !self.step() || state.flow == Flow::Halt { + return Value::default(); + } + let expr = expr.peel_parens(); + if let Some(environment) = + self.gcx.resolved_builtin(expr).and_then(Environment::from_builtin) + && !matches!(environment, Environment::BlockHash | Environment::BlobHash) + { + return self.read(environment, expr.span, state); + } + if let Some(var) = self.gcx.resolved_variable(expr) + && let Some(value) = state.locals.get(&var) + { + self.use_value(value); + return value.clone(); + } + if self.constant_word(expr, 0) == Some(CHEATCODE_ADDRESS) { + return Value { cheatcode: true, ..Value::default() }; + } + match &expr.kind { + ExprKind::Assign(lhs, op, rhs) => { + let mut value = self.expr(rhs, state); + if let Some(op) = op { + let left = self.expr(lhs, state); + let scalar = self.binary_scalar(lhs, op.kind, left.scalar, value.scalar, state); + value.merge(&left); + value.scalar = scalar; + value.cheatcode = false; + } + if op.is_none() { + self.destination(lhs, state); + } + value.refresh(state); + self.bind(lhs, value.clone(), state); + value + } + ExprKind::Delete(lhs) => { + self.destination(lhs, state); + let value = self + .gcx + .resolved_variable(lhs) + .map(|var| self.default_value(var)) + .unwrap_or_default(); + self.bind(lhs, value, state); + Value::default() + } + ExprKind::Unary(op, inner) if op.kind.has_side_effects() => { + let before = self.expr(inner, state); + let mut value = before.clone(); + let binary = if matches!(op.kind, UnOpKind::PreInc | UnOpKind::PostInc) { + BinOpKind::Add + } else { + BinOpKind::Sub + }; + value.scalar = self.binary_scalar( + inner, + binary, + value.scalar, + Some(Scalar::Uint(U256::ONE)), + state, + ); + value.cheatcode = false; + self.bind(inner, value.clone(), state); + if op.kind.is_prefix() { value } else { before } + } + ExprKind::Unary(op, inner) => { + let mut value = self.expr(inner, state); + value.scalar = match (op.kind, value.scalar) { + (UnOpKind::Not, Some(Scalar::Bool(value))) => Some(Scalar::Bool(!value)), + _ => None, + }; + value.cheatcode = false; + value + } + ExprKind::Payable(inner) => self.expr(inner, state), + ExprKind::Tuple(parts) => Value { + tuple: parts + .iter() + .map(|part| part.map(|part| self.expr(part, state)).unwrap_or_default()) + .collect(), + ..Value::default() + }, + ExprKind::Ternary(cond, yes, no) => { + match self.expr(cond, state).scalar.and_then(Scalar::as_bool) { + Some(true) => self.expr(yes, state), + Some(false) => self.expr(no, state), + None => { + let mut alternate = state.clone(); + let mut value = self.expr(yes, state); + let other = self.expr(no, &mut alternate); + if state.flow == Flow::Halt { + *state = alternate; + value = other; + } else if alternate.flow != Flow::Halt { + if value != other { + value = Value::default(); + } + state.merge(&alternate); + } + value + } + } + } + ExprKind::Binary(lhs, op, rhs) if matches!(op.kind, BinOpKind::And | BinOpKind::Or) => { + let mut value = self.expr(lhs, state); + let known = value.scalar.and_then(Scalar::as_bool); + let skip = op.kind == BinOpKind::Or; + if known == Some(skip) { + return value; + } + let before = state.clone(); + let right = self.expr(rhs, state); + let scalar = self.binary_scalar(expr, op.kind, value.scalar, right.scalar, state); + value.merge(&right); + value.scalar = scalar; + if known.is_none() { + if state.flow == Flow::Halt { + *state = before; + } else { + state.merge(&before); + } + } + value.cheatcode = false; + value + } + ExprKind::Binary(lhs, op, rhs) => { + let mut value = self.expr(lhs, state); + let right = self.expr(rhs, state); + let scalar = self.binary_scalar(expr, op.kind, value.scalar, right.scalar, state); + value.merge(&right); + value.scalar = scalar; + value.cheatcode = false; + value + } + ExprKind::Call(callee, args) => { + let (callee, opts) = callee.split_call_options(); + let mut receiver = if let ExprKind::Member(receiver, _) = &callee.peel_parens().kind + { + self.expr(receiver, state) + } else { + Value::default() + }; + for option in opts.iter().flat_map(|opts| opts.args) { + self.expr(&option.value, state); + } + let mut arguments: Vec<_> = + args.exprs().map(|arg| (arg.id, self.expr(arg, state))).collect(); + receiver.refresh(state); + self.use_value(&receiver); + for (_, value) in &mut arguments { + value.refresh(state); + self.use_value(value); + } + if let Some(environment) = + self.gcx.resolved_builtin(callee).and_then(Environment::from_builtin) + && matches!(environment, Environment::BlockHash | Environment::BlobHash) + { + let mut value = self.read(environment, expr.span, state); + // A hash result also depends on its raw environment-derived index. + for (_, argument) in &arguments { + value.merge(argument); + } + value.cheatcode = false; + return value; + } + if receiver.cheatcode + && let Some((function, environments)) = self.mutation(callee) + { + for &environment in environments { + state.change(environment, Mutation { span: expr.span, function }); + } + return Value::default(); + } + if matches!( + self.gcx.type_of_expr(callee.id).map(|ty| ty.kind), + Some(TyKind::Type(_)) + ) { + let mut value = + arguments.into_iter().next().map(|(_, value)| value).unwrap_or_default(); + // Only address-preserving casts keep a proven cheatcode receiver. + value.cheatcode &= self.cast_bits(callee).is_some_and(|bits| bits >= 160); + return value; + } + let target = if let Some(contract) = self.contract { + dispatched_function(self.gcx, contract, callee) + } else if matches!(callee.kind, ExprKind::Ident(_)) { + self.gcx.resolved_function(callee) + } else { + None + }; + if let Some(target) = target + && matches!(self.gcx.type_of_expr(callee.id).map(|ty| ty.kind), Some(TyKind::Fn(f)) if f.is_internal()) + { + let func = self.gcx.hir.function(target); + let bindings = func + .parameters + .iter() + .enumerate() + .map(|(index, ¶m)| { + let value = self + .gcx + .call_arg(expr, index) + .and_then(|arg| arguments.iter().find(|(id, _)| *id == arg.id)) + .map(|(_, value)| value.clone()) + .unwrap_or_default(); + (param, value) + }) + .collect(); + return self.call(target, bindings, state); + } + // External return data and getter results are materialized at the call boundary. + Value::default() + } + _ => { + let mut value = Value::default(); + for_each_child(expr, &mut |child| value.merge(&self.expr(child, state))); + value.cheatcode = false; + value + } + } + } + + fn default_value(&self, var: VariableId) -> Value { + let scalar = match self.gcx.type_of_item(var.into()).kind { + TyKind::Elementary(ElementaryType::UInt(_)) => Some(Scalar::Uint(U256::ZERO)), + TyKind::Elementary(ElementaryType::Bool) => Some(Scalar::Bool(false)), + _ => None, + }; + Value { scalar, ..Value::default() } + } + + fn binary_scalar( + &self, + expr: &Expr<'_>, + op: BinOpKind, + lhs: Option, + rhs: Option, + state: &mut State, + ) -> Option { + let (lhs, rhs) = (lhs?, rhs?); + if matches!(op, BinOpKind::Add | BinOpKind::Sub) + && let (Scalar::Uint(lhs), Scalar::Uint(rhs)) = (lhs, rhs) + && let TyKind::Elementary(ElementaryType::UInt(size)) = + self.gcx.type_of_expr(expr.id)?.kind + { + let (value, overflow) = if op == BinOpKind::Add { + lhs.overflowing_add(rhs) + } else { + lhs.overflowing_sub(rhs) + }; + if !state.unchecked && (overflow || value.bit_len() > size.bits() as usize) { + state.flow = Flow::Halt; + return None; + } + return Some(Scalar::Uint(value & (U256::MAX >> (256 - size.bits() as usize)))); + } + lhs.binary(op, rhs) + } + + fn mutation(&self, callee: &Expr<'_>) -> Option<(FunctionId, &'static [Environment])> { + let function = self.gcx.resolved_function(callee)?; + // Match the ABI signature, including overloads, rather than just the method name. + let environments: &'static [Environment] = match self.gcx.item_signature(function.into()) { + "roll(uint256)" => &[Environment::Number, Environment::BlockHash], + "warp(uint256)" => &[Environment::Timestamp], + "chainId(uint256)" => &[Environment::ChainId], + "coinbase(address)" => &[Environment::Coinbase], + "difficulty(uint256)" | "prevrandao(bytes32)" | "prevrandao(uint256)" => { + &[Environment::Difficulty, Environment::Prevrandao] + } + "fee(uint256)" => &[Environment::BaseFee], + "blobBaseFee(uint256)" => &[Environment::BlobBaseFee], + "txGasPrice(uint256)" => &[Environment::GasPrice], + "setBlockhash(uint256,bytes32)" => &[Environment::BlockHash], + "blobhashes(bytes32[])" => &[Environment::BlobHash], + "selectFork(uint256)" + | "createSelectFork(string)" + | "createSelectFork(string,uint256)" + | "createSelectFork(string,bytes32)" => Environment::ALL, + "rollFork(uint256)" + | "rollFork(bytes32)" + | "rollFork(uint256,uint256)" + | "rollFork(uint256,bytes32)" => Environment::BLOCK, + "revertTo(uint256)" + | "revertToState(uint256)" + | "revertToAndDelete(uint256)" + | "revertToStateAndDelete(uint256)" => Environment::ALL, + _ => return None, + }; + Some((function, environments)) + } + + /// Evaluates only the constant-address forms used by cheatcode declarations. The pinned + /// compiler's integer evaluator does not yet support general casts or keccak calls. + fn constant_word(&self, expr: &Expr<'_>, depth: usize) -> Option { + if depth >= 16 { + return None; + } + let expr = expr.peel_parens(); + if let Ok(value) = self.gcx.try_eval_const(expr) { + return value.as_u256(); + } + if let Some(id) = self.gcx.resolved_variable(expr) { + let var = self.gcx.hir.variable(id); + return var + .is_constant() + .then_some(var.initializer) + .flatten() + .and_then(|init| self.constant_word(init, depth + 1)); + } + match &expr.kind { + ExprKind::Call(callee, args) + if callee.split_call_options().1.is_none() && args.exprs().count() == 1 => + { + let arg = args.exprs().next()?; + if self.gcx.resolved_builtin(callee) == Some(Builtin::Keccak256) { + let ConstValue::String(bytes) = self.gcx.try_eval_const_value(arg).ok()? else { + return None; + }; + return Some(keccak256(bytes.as_byte_str()).into()); + } + let bits = self.cast_bits(callee)?; + let value = self.constant_word(arg, depth + 1)?; + Some(value & (U256::MAX >> (256 - bits))) + } + ExprKind::Payable(inner) => self.constant_word(inner, depth + 1), + _ => None, + } + } + + fn cast_bits(&self, callee: &Expr<'_>) -> Option { + let TyKind::Type(ty) = self.gcx.type_of_expr(callee.id)?.kind else { return None }; + match ty.kind { + TyKind::Contract(_) | TyKind::Elementary(ElementaryType::Address(_)) => Some(160), + TyKind::Elementary(ElementaryType::UInt(size) | ElementaryType::Int(size)) => { + Some(size.bits() as usize) + } + // A bytesN cast can change alignment. Only bytes32 preserves the complete word. + TyKind::Elementary(ElementaryType::FixedBytes(size)) if size.bits() == 256 => Some(256), + _ => None, + } + } + + fn call( + &mut self, + id: FunctionId, + bindings: Vec<(VariableId, Value)>, + state: &mut State, + ) -> Value { + let func = self.gcx.hir.function(id); + if func.body.is_none() + || self.stack.len() >= MAX_CALL_DEPTH + || self.stack.contains(&func.span) + { + return Value::default(); + } + self.stack.push(func.span); + let mut input = state.clone(); + // An unchecked block does not change arithmetic in a called function. + input.unchecked = false; + let caller_returns = std::mem::replace(&mut input.return_parameters, func.returns.to_vec()); + input.locals.extend(bindings); + for var in func.returns { + input.locals.insert(*var, self.default_value(*var)); + } + let mut output = self + .layer(func, 0, input) + .into_iter() + .filter(|s| matches!(s.flow, Flow::Next | Flow::Return)); + let mut value = Value::default(); + if let Some(mut first) = output.next() { + value = self.return_values(func, &first); + self.use_value(&value); + let mut ambiguous = false; + for other in output { + let returned = self.return_values(func, &other); + self.use_value(&returned); + ambiguous |= value != returned; + first.merge(&other); + } + // Component-wise joins can invent tuples that no execution returns, e.g. + // `(block.number, nonVm)` and `(0, vm)`. Keep only an unambiguous return value. + if ambiguous { + value = Value::default(); + } + first.flow = Flow::Next; + first.unchecked = state.unchecked; + first.return_parameters = caller_returns; + *state = first; + } else { + state.flow = Flow::Halt; + } + self.stack.pop(); + value + } +} diff --git a/crates/lint/src/sol/med/dangerous_unary_operator.rs b/crates/lint/src/sol/med/dangerous_unary_operator.rs new file mode 100644 index 0000000000000..33c6b0663c8e2 --- /dev/null +++ b/crates/lint/src/sol/med/dangerous_unary_operator.rs @@ -0,0 +1,40 @@ +use super::DangerousUnaryOperator; +use crate::{ + linter::{EarlyLintPass, LintContext}, + sol::{Severity, SolLint}, +}; +use solar::ast::{Expr, ExprKind, UnOpKind}; + +declare_forge_lint!( + DANGEROUS_UNARY_OPERATOR, + Severity::Med, + "dangerous-unary-operator", + "unary operator fused to `=`: `x =- 1` parses as `x = -1`, not `x -= 1`" +); + +impl<'ast> EarlyLintPass<'ast> for DangerousUnaryOperator { + fn check_expr(&mut self, ctx: &LintContext, expr: &'ast Expr<'ast>) { + // `x =- 1` parses exactly like the intentional `x = -1`, so the AST cannot tell them + // apart: only flag when the source fuses `=` to the unary. The gap between the LHS and + // `rhs.span` (which starts at the leading unary) holds only whitespace, comments and the + // `=` token, and no comment can end with `=`, so the gap ends with `=` exactly when the + // pair is fused. Solidity has no `~=` either, so `=~` is the same trap; unary `+` was + // removed in 0.5.0 and never produces a node. + if let ExprKind::Assign(lhs, None, rhs) = &expr.kind + && leads_with_fusable_unary(rhs) + && ctx.span_to_snippet(lhs.span.between(rhs.span)).is_some_and(|gap| gap.ends_with('=')) + { + ctx.emit(&DANGEROUS_UNARY_OPERATOR, expr.span); + } + } +} + +/// Whether the leftmost operand of `expr` is a `-` or `~` unary, following the left spine of +/// binary and ternary expressions so `x =- a + 1` (`x = (-a) + 1`) is caught as well. +fn leads_with_fusable_unary(expr: &Expr<'_>) -> bool { + match &expr.kind { + ExprKind::Unary(op, _) => matches!(op.kind, UnOpKind::Neg | UnOpKind::BitNot), + ExprKind::Binary(lhs, _, _) | ExprKind::Ternary(lhs, _, _) => leads_with_fusable_unary(lhs), + _ => false, + } +} diff --git a/crates/lint/src/sol/med/div_mul.rs b/crates/lint/src/sol/med/div_mul.rs index 3f8e25b9b8bb8..bc6c1ea3b5ab6 100644 --- a/crates/lint/src/sol/med/div_mul.rs +++ b/crates/lint/src/sol/med/div_mul.rs @@ -1,33 +1,300 @@ use super::DivideBeforeMultiply; use crate::{ - linter::{EarlyLintPass, LintContext}, - sol::{Severity, SolLint}, + linter::{LateLintPass, LintContext}, + sol::{ + Severity, SolLint, + analysis::{is_revert_call, loop_update, tuple_elems}, + }, }; -use solar_ast::{BinOp, BinOpKind, Expr, ExprKind}; +use solar::sema::{ + Gcx, + builtins::Builtin, + hir::{BinOpKind, Block, Expr, ExprKind, Function, Stmt, StmtKind, VariableId}, +}; +use std::collections::HashSet; declare_forge_lint!( DIVIDE_BEFORE_MULTIPLY, Severity::Med, "divide-before-multiply", - "multiplication should occur before division to avoid loss of precision" + "division before multiplication may lose precision" ); -impl<'ast> EarlyLintPass<'ast> for DivideBeforeMultiply { - fn check_expr(&mut self, ctx: &LintContext<'_>, expr: &'ast Expr<'ast>) { - if let ExprKind::Binary(left_expr, BinOp { kind: BinOpKind::Mul, .. }, _) = &expr.kind - && contains_division(left_expr) - { - ctx.emit(&DIVIDE_BEFORE_MULTIPLY, expr.span); +/// Locals whose current value is the result of a division. +type Tainted = HashSet; + +impl<'gcx> LateLintPass<'gcx> for DivideBeforeMultiply { + fn check_function(&mut self, ctx: &LintContext, gcx: Gcx<'gcx>, func: &'gcx Function<'gcx>) { + if let Some(body) = func.body { + check_block(ctx, gcx, body, &mut Tainted::new()); + } + } +} + +/// Checks `block`, returning `false` once control cannot continue past a statement. +fn check_block<'gcx>( + ctx: &LintContext, + gcx: Gcx<'gcx>, + block: Block<'gcx>, + tainted: &mut Tainted, +) -> bool { + block.stmts.iter().all(|stmt| check_stmt(ctx, gcx, stmt, tainted)) +} + +/// Checks the bodies of mutually exclusive branches and keeps the taint of every branch that +/// falls through, on top of the taint before the branch. +fn check_branches<'gcx>( + ctx: &LintContext, + gcx: Gcx<'gcx>, + blocks: impl Iterator>, + tainted: &mut Tainted, +) { + let mut merged = Tainted::new(); + for block in blocks { + let mut branch_tainted = tainted.clone(); + if check_block(ctx, gcx, block, &mut branch_tainted) { + merged.extend(branch_tainted); + } + } + tainted.extend(merged); +} + +fn check_stmt<'gcx>( + ctx: &LintContext, + gcx: Gcx<'gcx>, + stmt: &'gcx Stmt<'gcx>, + tainted: &mut Tainted, +) -> bool { + match &stmt.kind { + StmtKind::DeclSingle(var_id) => { + if let Some(init) = gcx.hir.variable(*var_id).initializer { + check_expr(ctx, gcx, init, tainted); + set_taint(gcx, *var_id, is_division_or_tainted(gcx, init, tainted), tainted); + } + true + } + StmtKind::DeclMulti(vars, expr) => { + check_expr(ctx, gcx, expr, tainted); + for (var_id, is_tainted) in vars.iter().zip(rhs_taints(gcx, expr, vars.len(), tainted)) + { + if let Some(var_id) = var_id { + set_taint(gcx, *var_id, is_tainted, tainted); + } + } + true + } + StmtKind::Expr(expr) => { + check_expr(ctx, gcx, expr, tainted); + !is_revert_call(gcx, expr) + } + StmtKind::Emit(expr) => { + check_expr(ctx, gcx, expr, tainted); + true + } + StmtKind::Revert(expr) | StmtKind::Return(Some(expr)) => { + check_expr(ctx, gcx, expr, tainted); + false + } + StmtKind::Return(None) => false, + StmtKind::If(cond, then_stmt, else_stmt) => { + check_expr(ctx, gcx, cond, tainted); + let mut merged = Tainted::new(); + let mut falls_through = false; + for branch in [Some(*then_stmt), *else_stmt] { + let mut branch_tainted = tainted.clone(); + if branch.is_none_or(|stmt| check_stmt(ctx, gcx, stmt, &mut branch_tainted)) { + merged.extend(branch_tainted); + falls_through = true; + } + } + if falls_through { + *tainted = merged; + } + falls_through + } + StmtKind::Loop(block, source) => { + let mut branch = tainted.clone(); + if check_block(ctx, gcx, *block, &mut branch) + && loop_update(*source) + .is_none_or(|update| check_stmt(ctx, gcx, update, &mut branch)) + { + tainted.extend(branch); + } + true + } + StmtKind::Try(try_stmt) => { + check_expr(ctx, gcx, &try_stmt.expr, tainted); + check_branches(ctx, gcx, try_stmt.clauses.iter().map(|c| c.block), tainted); + true + } + StmtKind::Switch(switch) => { + check_expr(ctx, gcx, switch.selector, tainted); + check_branches(ctx, gcx, switch.cases.iter().map(|c| c.body), tainted); + true + } + StmtKind::Block(block) + | StmtKind::UncheckedBlock(block) + | StmtKind::AssemblyBlock(block) => check_block(ctx, gcx, *block, tainted), + StmtKind::Break | StmtKind::Continue | StmtKind::Placeholder | StmtKind::Err(_) => true, + } +} + +fn check_expr<'gcx>( + ctx: &LintContext, + gcx: Gcx<'gcx>, + expr: &'gcx Expr<'gcx>, + tainted: &mut Tainted, +) { + match &expr.peel_parens().kind { + ExprKind::Assign(lhs, op, rhs) => { + check_expr(ctx, gcx, rhs, tainted); + check_expr(ctx, gcx, lhs, tainted); + match op.map(|op| op.kind) { + None => match tuple_elems(lhs) { + Some(elems) => { + for (lhs, is_tainted) in + elems.iter().zip(rhs_taints(gcx, rhs, elems.len(), tainted)) + { + if let Some(lhs) = lhs { + set_lhs_taint(gcx, lhs, is_tainted, tainted); + } + } + } + None => { + set_lhs_taint(gcx, lhs, is_division_or_tainted(gcx, rhs, tainted), tainted) + } + }, + Some(BinOpKind::Mul) => { + let is_tainted = is_division_or_tainted(gcx, lhs, tainted) + || is_division_or_tainted(gcx, rhs, tainted); + if is_tainted { + ctx.emit(&DIVIDE_BEFORE_MULTIPLY, expr.span); + } + set_lhs_taint(gcx, lhs, is_tainted, tainted); + } + Some(op) => set_lhs_taint(gcx, lhs, op == BinOpKind::Div, tainted), + } + } + ExprKind::Binary(left, op, right) => { + check_expr(ctx, gcx, left, tainted); + check_expr(ctx, gcx, right, tainted); + if op.kind == BinOpKind::Mul + && (is_division_or_tainted(gcx, left, tainted) + || is_division_or_tainted(gcx, right, tainted)) + { + ctx.emit(&DIVIDE_BEFORE_MULTIPLY, expr.span); + } + } + ExprKind::Call(callee, args) => { + let (callee, named_args) = callee.split_call_options(); + check_expr(ctx, gcx, callee, tainted); + for arg in args.exprs() { + check_expr(ctx, gcx, arg, tainted); + } + for arg in named_args.iter().flat_map(|opts| opts.args) { + check_expr(ctx, gcx, &arg.value, tainted); + } + if is_yul_call(gcx, expr, &[Builtin::YulMul]) + && args.exprs().any(|arg| is_division_or_tainted(gcx, arg, tainted)) + { + ctx.emit(&DIVIDE_BEFORE_MULTIPLY, expr.span); + } + } + ExprKind::CallOptions(callee, options) => { + check_expr(ctx, gcx, callee, tainted); + for option in options.args { + check_expr(ctx, gcx, &option.value, tainted); + } + } + ExprKind::Ternary(cond, then_expr, else_expr) => { + check_expr(ctx, gcx, cond, tainted); + let mut then_tainted = tainted.clone(); + check_expr(ctx, gcx, then_expr, &mut then_tainted); + check_expr(ctx, gcx, else_expr, tainted); + tainted.extend(then_tainted); + } + ExprKind::Unary(op, inner) => { + check_expr(ctx, gcx, inner, tainted); + if op.kind.has_side_effects() { + set_lhs_taint(gcx, inner, false, tainted); + } } + ExprKind::Array(exprs) => exprs.iter().for_each(|e| check_expr(ctx, gcx, e, tainted)), + ExprKind::Tuple(exprs) => { + exprs.iter().flatten().for_each(|e| check_expr(ctx, gcx, e, tainted)) + } + ExprKind::Index(base, index) => { + check_expr(ctx, gcx, base, tainted); + if let Some(index) = index { + check_expr(ctx, gcx, index, tainted); + } + } + ExprKind::Slice(base, start, end) => { + check_expr(ctx, gcx, base, tainted); + start.iter().chain(end).for_each(|e| check_expr(ctx, gcx, e, tainted)); + } + ExprKind::Delete(inner) + | ExprKind::Member(inner, _) + | ExprKind::YulMember(inner, _) + | ExprKind::Payable(inner) => check_expr(ctx, gcx, inner, tainted), + ExprKind::Ident(_) + | ExprKind::Lit(_) + | ExprKind::New(_) + | ExprKind::TypeCall(_) + | ExprKind::Type(_) + | ExprKind::Err(_) => {} + } +} + +/// Taint of each of the `n` slots assigned from `rhs`: elementwise for a tuple of matching arity, +/// otherwise the taint of the whole expression. +fn rhs_taints(gcx: Gcx<'_>, rhs: &Expr<'_>, n: usize, tainted: &Tainted) -> Vec { + match tuple_elems(rhs) { + Some(elems) if elems.len() == n => elems + .iter() + .map(|e| e.is_some_and(|e| is_division_or_tainted(gcx, e, tainted))) + .collect(), + _ => vec![is_division_or_tainted(gcx, rhs, tainted); n], } } -fn contains_division<'ast>(expr: &'ast Expr<'ast>) -> bool { - match &expr.kind { - ExprKind::Binary(_, BinOp { kind: BinOpKind::Div, .. }, _) => true, - ExprKind::Tuple(inner_exprs) => inner_exprs.iter().any(|opt_expr| { - if let Some(inner_expr) = opt_expr { contains_division(inner_expr) } else { false } - }), +fn set_lhs_taint(gcx: Gcx<'_>, lhs: &Expr<'_>, is_tainted: bool, tainted: &mut Tainted) { + match &lhs.peel_parens().kind { + ExprKind::Ident(_) => { + if let Some(var_id) = gcx.resolved_variable(lhs) { + set_taint(gcx, var_id, is_tainted, tainted); + } + } + ExprKind::Tuple(exprs) => { + exprs.iter().flatten().for_each(|e| set_lhs_taint(gcx, e, is_tainted, tainted)) + } + _ => {} + } +} + +fn set_taint(gcx: Gcx<'_>, var_id: VariableId, is_tainted: bool, tainted: &mut Tainted) { + if gcx.hir.variable(var_id).is_local_or_return() { + if is_tainted { + tainted.insert(var_id); + } else { + tainted.remove(&var_id); + } + } +} + +/// The value of `expr` is a division result, directly or through a tainted local. +fn is_division_or_tainted(gcx: Gcx<'_>, expr: &Expr<'_>, tainted: &Tainted) -> bool { + match &expr.peel_parens().kind { + ExprKind::Binary(_, op, _) => op.kind == BinOpKind::Div, + ExprKind::Ident(_) => gcx.resolved_variable(expr).is_some_and(|v| tainted.contains(&v)), + ExprKind::Call(..) => is_yul_call(gcx, expr, &[Builtin::YulDiv, Builtin::YulSdiv]), + ExprKind::YulMember(inner, _) => is_division_or_tainted(gcx, inner, tainted), _ => false, } } + +/// A two-argument call to one of the given Yul builtins. +fn is_yul_call(gcx: Gcx<'_>, expr: &Expr<'_>, candidates: &[Builtin]) -> bool { + matches!(&expr.peel_parens().kind, ExprKind::Call(callee, args) + if args.len() == 2 && gcx.resolved_builtin(callee).is_some_and(|b| candidates.contains(&b))) +} diff --git a/crates/lint/src/sol/med/ecrecover.rs b/crates/lint/src/sol/med/ecrecover.rs new file mode 100644 index 0000000000000..ac9a6049c43df --- /dev/null +++ b/crates/lint/src/sol/med/ecrecover.rs @@ -0,0 +1,947 @@ +use super::Ecrecover; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{ + Severity, SolLint, + analysis::{is_exit_call, is_require_or_assert, loop_update, tuple_elems}, + }, +}; +use alloy_primitives::{U256, uint}; +use solar::{ + ast::{BinOpKind, ElementaryType, UnOpKind}, + interface::{Span, Symbol, data_structures::Never}, + sema::{ + Gcx, + builtins::Builtin, + eval::ConstValue, + hir::{ + self, Expr, ExprId, ExprKind, LoopSource, StateMutability, Stmt, StmtKind, TypeKind, + VariableId, Visit, + }, + ty::TyKind, + }, +}; +use std::{ + collections::{HashMap, HashSet}, + mem, + ops::ControlFlow, +}; + +declare_forge_lint!( + ECRECOVER, + Severity::Med, + "ecrecover", + "`ecrecover` call does not reject malleable signatures" +); + +/// Largest canonical secp256k1 `s` value, `n / 2`. +const SECP256K1_HALF_ORDER: U256 = + uint!(0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0_U256); + +impl<'gcx> LateLintPass<'gcx> for Ecrecover { + fn check_function( + &mut self, + ctx: &LintContext, + gcx: Gcx<'gcx>, + func: &'gcx hir::Function<'gcx>, + ) { + let Some(body) = func.body else { return }; + let mut analyzer = Analyzer { + gcx, + returns: func.returns, + state: FlowState::default(), + next_value: 0, + hits: Vec::new(), + deferred: HashMap::new(), + loop_exits: Vec::new(), + loop_next: None, + }; + if analyzer.run_block(body.stmts) { + analyzer.use_return_values(); + } + for span in analyzer.hits { + ctx.emit(&ECRECOVER, span); + } + } +} + +/// A tracked place: a whole variable, or a struct field reached from a variable (`sig.s`). +/// +/// A field that is only ever read has no `values` entry and resolves to `ValueId::Initial(key)`, +/// so the key carries an epoch that [`FlowState::set`] bumps whenever the whole base variable is +/// reassigned; otherwise a guard on `sig.s` would survive `sig = other;`. Writes through memory +/// or storage references also reset the fields of other variables in the same location. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +enum ValueKey { + Var(VariableId), + Field(VariableId, Symbol, u32), +} + +impl ValueKey { + const fn var(self) -> VariableId { + match self { + Self::Var(var) | Self::Field(var, ..) => var, + } + } +} + +/// Symbolic identity of a value: a place's incoming value or the result of an assignment. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +enum ValueId { + Initial(ValueKey), + Assigned(u32), +} + +/// An `ecrecover` result held in a local that has been neither observed nor validated yet. +#[derive(Clone, Copy, PartialEq, Eq)] +struct PendingRecovery { + signature: Option, + span: Span, +} + +#[derive(Clone, Default)] +struct FlowState { + values: HashMap, + /// Values proven to be a canonical (low) `s`. + low_s: HashSet, + pending: HashMap>, + /// Current field epoch per variable, see [`ValueKey`]. + field_epoch: HashMap, +} + +impl FlowState { + fn value(&self, key: ValueKey) -> ValueId { + self.values.get(&key).copied().unwrap_or(ValueId::Initial(key)) + } + + fn field_key(&self, var: VariableId, field: Symbol) -> ValueKey { + ValueKey::Field(var, field, self.field_epoch.get(&var).copied().unwrap_or(0)) + } + + /// Sets the value of `key`. Setting a whole variable drops its tracked fields and starts a + /// new field epoch. + fn set(&mut self, key: ValueKey, value: ValueId) { + if let ValueKey::Var(var) = key { + self.reset_fields(var); + } + self.values.insert(key, value); + } + + fn reset_fields(&mut self, var: VariableId) { + self.values.retain(|key, _| !matches!(key, ValueKey::Field(base, ..) if *base == var)); + *self.field_epoch.entry(var).or_insert(0) += 1; + } + + /// Variables with a tracked place or a proven initial value. + fn tracked_vars(&self) -> HashSet { + let initial = self.low_s.iter().filter_map(|value| match value { + ValueId::Initial(key) => Some(key.var()), + ValueId::Assigned(_) => None, + }); + self.values.keys().map(|key| key.var()).chain(initial).collect() + } + + fn add_pending(&mut self, value: ValueId, recovery: PendingRecovery) { + let recoveries = self.pending.entry(value).or_default(); + if !recoveries.contains(&recovery) { + recoveries.push(recovery); + } + } +} + +/// A place written by an assignment, paired with the expression it receives. +type Pair<'gcx> = (Option, Option<&'gcx Expr<'gcx>>); + +/// The `s` argument of an `ecrecover` call. +type Signature<'gcx> = &'gcx Expr<'gcx>; + +struct Analyzer<'gcx> { + gcx: Gcx<'gcx>, + returns: &'gcx [VariableId], + state: FlowState, + next_value: u32, + hits: Vec, + /// `ecrecover` calls whose result is being stored into a local. Their recovery is captured + /// here instead of being reported at the call site. + deferred: HashMap>, + /// States reaching `break`/`continue` or the end of the innermost loop body. + loop_exits: Vec, + /// Update statement of the innermost `for` loop, which `continue` still executes. + loop_next: Option<&'gcx Stmt<'gcx>>, +} + +impl<'gcx> Analyzer<'gcx> { + const fn fresh_value(&mut self) -> ValueId { + self.next_value += 1; + ValueId::Assigned(self.next_value) + } + + fn join(&mut self, left: FlowState, right: FlowState) -> FlowState { + // Keep the highest epoch so a field read after the join never reuses an identity that + // was reset in either branch. + let mut field_epoch = left.field_epoch.clone(); + for (var, epoch) in &right.field_epoch { + let entry = field_epoch.entry(*var).or_insert(0); + *entry = (*entry).max(*epoch); + } + let mut joined = FlowState { + low_s: left.low_s.intersection(&right.low_s).copied().collect(), + field_epoch, + ..FlowState::default() + }; + let mut merged = HashMap::new(); + let keys: HashSet<_> = left.values.keys().chain(right.values.keys()).copied().collect(); + for key in keys { + let (l, r) = (left.value(key), right.value(key)); + let value = if l == r { + l + } else { + let value = *merged.entry((l, r)).or_insert_with(|| self.fresh_value()); + if left.low_s.contains(&l) && right.low_s.contains(&r) { + joined.low_s.insert(value); + } + value + }; + joined.values.insert(key, value); + for recovery in left.pending.get(&l).into_iter().chain(right.pending.get(&r)).flatten() + { + joined.add_pending(value, *recovery); + } + } + joined + } + + /// Continues from the join of `states`; returns `false` when no path continues. + fn join_all(&mut self, states: Vec) -> bool { + let Some(joined) = states.into_iter().reduce(|l, r| self.join(l, r)) else { return false }; + self.state = joined; + true + } + + fn emit_hit(&mut self, span: Span) { + if !self.hits.contains(&span) { + self.hits.push(span); + } + } + + fn use_value(&mut self, value: ValueId) { + for recovery in self.state.pending.remove(&value).unwrap_or_default() { + self.emit_hit(recovery.span); + } + } + + fn use_return_values(&mut self) { + for &var in self.returns { + self.use_value(self.state.value(ValueKey::Var(var))); + } + } + + fn use_all_pending(&mut self) { + for recoveries in mem::take(&mut self.state.pending).into_values() { + for recovery in recoveries { + self.emit_hit(recovery.span); + } + } + } + + /// Drops pending recoveries whose signature has since been proven canonical. + fn validate_pending(&mut self) { + let low_s = &self.state.low_s; + self.state.pending.retain(|_, recoveries| { + recoveries.retain(|r| !r.signature.is_some_and(|s| low_s.contains(&s))); + !recoveries.is_empty() + }); + } + + fn current_value(&self, expr: &Expr<'_>) -> Option { + match &expr.peel_parens().kind { + ExprKind::Assign(lhs, None, _) => self.current_value(lhs), + _ => self.place_key(expr).map(|key| self.state.value(key)), + } + } + + /// The tracked place an expression denotes: a variable or a `var.field` member, through + /// parens and `uint256(..)`/`bytes32(..)` casts. + fn place_key(&self, expr: &Expr<'_>) -> Option { + match &expr.peel_parens().kind { + ExprKind::Call(callee, args) if is_transparent_cast(callee) && args.len() == 1 => { + self.place_key(args.exprs().next()?) + } + ExprKind::Member(base, field) => { + var_of(self.gcx, base).map(|var| self.state.field_key(var, field.name)) + } + _ => var_of(self.gcx, expr).map(ValueKey::Var), + } + } + + /// Memory and storage variables passed by reference to an internal function (including the + /// receiver of a `using for` call), which the callee may write through. + fn reference_args( + &self, + callee: &'gcx Expr<'gcx>, + args: &'gcx hir::CallArgs<'gcx>, + ) -> Vec { + let callee = callee.peel_parens(); + let Some(TyKind::Fn(function)) = self.gcx.type_of_expr(callee.id).map(|ty| ty.kind) else { + return Vec::new(); + }; + if !function.is_internal() { + return Vec::new(); + } + let receiver = match &callee.kind { + ExprKind::Member(base, _) + if self.gcx.resolved_callee(callee.id).is_some_and(|c| c.attached) => + { + Some(*base) + } + _ => None, + }; + receiver + .into_iter() + .chain(args.exprs()) + .filter_map(|expr| var_of(self.gcx, expr)) + .filter(|&var| { + matches!( + self.gcx.hir.variable(var).data_location, + Some(hir::DataLocation::Memory | hir::DataLocation::Storage) + ) + }) + .collect() + } + + fn is_local(&self, var: VariableId) -> bool { + self.gcx.hir.variable(var).is_local_variable() + } + + /// The call expression and signature argument of a builtin `ecrecover` call. + fn ecrecover_call( + &self, + expr: &'gcx Expr<'gcx>, + ) -> Option<(&'gcx Expr<'gcx>, Signature<'gcx>)> { + let expr = expr.peel_parens(); + let ExprKind::Call(callee, args) = &expr.kind else { return None }; + let callee = self.gcx.resolved_builtin(callee.peel_parens()); + (callee == Some(Builtin::EcRecover) && args.len() == 4) + .then_some(expr) + .zip(args.exprs().nth(3)) + } + + fn pending_recovery(&self, expr: &'gcx Expr<'gcx>) -> Option { + let (call, signature) = self.ecrecover_call(expr)?; + (!self.is_proven_low_s(signature)) + .then(|| PendingRecovery { signature: self.current_value(signature), span: call.span }) + } + + /// The arms of `cond ? then : otherwise` that may execute. + fn live_arms(&self, cond: &Expr<'_>, then: T, otherwise: T) -> impl Iterator { + match self.const_bool(cond) { + Some(true) => [Some(then), None], + Some(false) => [None, Some(otherwise)], + None => [Some(then), Some(otherwise)], + } + .into_iter() + .flatten() + } + + /// `ecrecover` calls whose result becomes the value of `expr`. + fn result_calls(&self, expr: &'gcx Expr<'gcx>, out: &mut Vec) { + let expr = expr.peel_parens(); + if self.ecrecover_call(expr).is_some() { + out.push(expr.id); + } + match &expr.kind { + ExprKind::Ternary(cond, then, otherwise) => { + for arm in self.live_arms(cond, *then, *otherwise) { + self.result_calls(arm, out); + } + } + ExprKind::Assign(_, None, rhs) => self.result_calls(rhs, out), + _ => {} + } + } + + fn const_value(&self, expr: &Expr<'_>) -> Option { + let expr = expr.peel_parens(); + match &expr.kind { + ExprKind::Call(callee, args) if is_transparent_cast(callee) && args.len() == 1 => { + self.const_value(args.exprs().next()?) + } + // Fold arithmetic with wrapping semantics so `unchecked` bounds evaluate. + ExprKind::Binary(lhs, op, rhs) + if matches!(op.kind, BinOpKind::Add | BinOpKind::Sub | BinOpKind::Mul) => + { + let (lhs, rhs) = (self.const_value(lhs)?, self.const_value(rhs)?); + Some(match op.kind { + BinOpKind::Add => lhs.wrapping_add(rhs), + BinOpKind::Sub => lhs.wrapping_sub(rhs), + _ => lhs.wrapping_mul(rhs), + }) + } + _ if self.gcx.resolved_builtin(expr) == Some(Builtin::TypeMax) => { + let TyKind::Elementary(ElementaryType::UInt(size)) = + self.gcx.type_of_expr(expr.id)?.kind + else { + return None; + }; + Some(U256::MAX >> (256 - size.bits())) + } + _ => self.gcx.try_eval_const(expr).ok()?.as_u256(), + } + } + + fn const_bool(&self, expr: &Expr<'_>) -> Option { + match self.gcx.try_eval_const_value(expr).ok()? { + ConstValue::Bool(value) => Some(*value), + _ => None, + } + } + + fn is_proven_low_s(&self, expr: &'gcx Expr<'gcx>) -> bool { + self.const_value(expr).is_some_and(|value| value <= SECP256K1_HALF_ORDER) + || self.current_value(expr).is_some_and(|value| self.state.low_s.contains(&value)) + || matches!(&expr.peel_parens().kind, ExprKind::Ternary(cond, then, otherwise) + if self.live_arms(cond, *then, *otherwise).all(|arm| self.is_proven_low_s(arm))) + } + + /// The `(value, proven low)` a variable takes when assigned `rhs`. + fn assigned(&self, rhs: Option<&'gcx Expr<'gcx>>) -> (Option, bool) { + ( + rhs.and_then(|rhs| self.current_value(rhs)), + rhs.is_some_and(|rhs| self.is_proven_low_s(rhs)), + ) + } + + fn assign(&mut self, key: ValueKey, (value, low_s): (Option, bool)) { + let value = value.unwrap_or_else(|| self.fresh_value()); + if low_s { + self.state.low_s.insert(value); + } + if let ValueKey::Field(var, field, _) = key { + // Writing through a memory or storage reference may write any other variable of + // that location too. + if let Some(location) = self.aliasable_location(var) { + for other in self.state.tracked_vars() { + if other != var && self.aliasable_location(other) == Some(location) { + self.state.reset_fields(other); + } + } + } + let key = self.state.field_key(var, field); + self.state.set(key, value); + } else { + self.state.set(key, value); + } + } + + /// The data location through which `var` may alias other variables. + fn aliasable_location(&self, var: VariableId) -> Option { + let var = self.gcx.hir.variable(var); + if var.kind.is_state() && var.mutability.is_none() { + return Some(hir::DataLocation::Storage); + } + var.data_location + .filter(|loc| matches!(loc, hir::DataLocation::Memory | hir::DataLocation::Storage)) + } + + /// Pairs every place written by `lhs` with the expression it receives. + fn pairs( + &self, + lhs: &'gcx Expr<'gcx>, + rhs: Option<&'gcx Expr<'gcx>>, + out: &mut Vec>, + ) { + let Some(elems) = tuple_elems(lhs) else { return out.push((self.place_key(lhs), rhs)) }; + let rhs_elems = rhs.and_then(tuple_elems); + for (i, lhs) in elems.iter().enumerate() { + let rhs = rhs_elems.and_then(|elems| elems.get(i).copied().flatten()); + match lhs { + Some(lhs) => self.pairs(lhs, rhs, out), + None => out.push((None, rhs)), + } + } + } + + /// Assigns every pair, reading all right-hand sides first so tuple swaps are exact. + fn assign_pairs(&mut self, pairs: &[Pair<'gcx>]) { + let assigned: Vec<_> = + pairs.iter().filter_map(|(key, rhs)| Some(((*key)?, self.assigned(*rhs)))).collect(); + for (key, assigned) in assigned { + self.assign(key, assigned); + } + } + + fn assign_lhs(&mut self, lhs: &'gcx Expr<'gcx>, rhs: Option<&'gcx Expr<'gcx>>) { + let mut pairs = Vec::new(); + self.pairs(lhs, rhs, &mut pairs); + self.assign_pairs(&pairs); + } + + /// Models a statement-level store of `rhs` into `pairs`. Recoveries stored into locals stay + /// pending until the local is read or the signature validated; any other destination + /// observes the result immediately. + fn store(&mut self, pairs: &[Pair<'gcx>], rhs: Option<&'gcx Expr<'gcx>>) { + let mut calls = Vec::new(); + for &(key, rhs) in pairs { + let local = key.filter(|key| self.is_local(key.var())); + let mut result_calls = Vec::new(); + if let Some(rhs) = rhs { + self.result_calls(rhs, &mut result_calls); + } + for call in result_calls { + match local { + Some(_) => self.deferred.insert(call, None), + None => self.deferred.remove(&call), + }; + calls.push((call, local)); + } + } + let local_target = matches!(pairs, [(Some(key), _)] if self.is_local(key.var())); + if let Some(rhs) = rhs { + match &rhs.peel_parens().kind { + ExprKind::Assign(lhs, None, inner) if local_target => self.store_expr(lhs, inner), + // Copying a variable into a local does not observe its value. + _ if local_target && self.current_value(rhs).is_some() => {} + _ => { + let _ = self.visit_expr(rhs); + } + } + } + self.assign_pairs(pairs); + for (call, key) in calls { + if let (Some(key), Some(Some(recovery))) = (key, self.deferred.remove(&call)) { + let value = self.state.value(key); + self.state.add_pending(value, recovery); + } + } + } + + fn store_expr(&mut self, lhs: &'gcx Expr<'gcx>, rhs: &'gcx Expr<'gcx>) { + let mut pairs = Vec::new(); + self.pairs(lhs, Some(rhs), &mut pairs); + self.store(&pairs, Some(rhs)); + } + + /// Visits an lvalue without treating the written variables as reads. + fn visit_lhs(&mut self, lhs: &'gcx Expr<'gcx>) { + if let Some(elems) = tuple_elems(lhs) { + for elem in elems.iter().flatten() { + self.visit_lhs(elem); + } + } else if self.place_key(lhs).is_none() { + let _ = self.visit_expr(lhs); + } + } + + /// Forgets mutable state variables and storage pointers, which any state-mutating call may + /// have written. + fn invalidate_mutable_state(&mut self) { + for var in self.state.tracked_vars() { + let variable = self.gcx.hir.variable(var); + if (variable.kind.is_state() && variable.mutability.is_none()) + || variable.data_location == Some(hir::DataLocation::Storage) + { + self.assign(ValueKey::Var(var), (None, false)); + } + } + } + + fn has_side_effect(&self, expr: &'gcx Expr<'gcx>) -> bool { + SideEffects(self).visit_expr(expr).is_break() + } + + fn assume(&mut self, predicate: &'gcx Expr<'gcx>, negate: bool) { + self.add_facts(predicate, negate); + self.validate_pending(); + } + + fn add_facts(&mut self, predicate: &'gcx Expr<'gcx>, negate: bool) { + // Facts are derived from the current values, which a side effect may have replaced. + if self.has_side_effect(predicate) { + return; + } + match &predicate.peel_parens().kind { + ExprKind::Ternary(cond, then, otherwise) => { + if let Some(value) = self.const_bool(cond) { + self.add_facts(if value { then } else { otherwise }, negate); + } + } + ExprKind::Unary(op, inner) if op.kind == UnOpKind::Not => { + self.add_facts(inner, !negate); + } + ExprKind::Binary(lhs, op, rhs) if matches!(op.kind, BinOpKind::And | BinOpKind::Or) => { + let is_and = op.kind == BinOpKind::And; + // A constant operand either decides the result or defers to the other operand. + for (side, other) in [(lhs, rhs), (rhs, lhs)] { + if let Some(value) = self.const_bool(side) { + if is_and == value { + self.add_facts(other, negate); + } + return; + } + } + if is_and == negate { + // Only facts established by both operands hold. + let baseline = self.state.low_s.clone(); + self.add_facts(lhs, negate); + let from_lhs = mem::replace(&mut self.state.low_s, baseline.clone()); + self.add_facts(rhs, negate); + let from_rhs = mem::replace(&mut self.state.low_s, baseline); + self.state.low_s.extend(from_lhs.intersection(&from_rhs)); + } else { + self.add_facts(lhs, negate); + self.add_facts(rhs, negate); + } + } + ExprKind::Binary(lhs, op, rhs) => { + let op = if negate { negate_comparison(op.kind) } else { op.kind }; + for (candidate, bound, op) in [(lhs, rhs, op), (rhs, lhs, reverse_comparison(op))] { + let (Some(value), Some(bound)) = + (self.current_value(candidate), self.const_value(bound)) + else { + continue; + }; + let proves_low = match op { + BinOpKind::Lt => bound <= SECP256K1_HALF_ORDER + U256::ONE, + BinOpKind::Le | BinOpKind::Eq => bound <= SECP256K1_HALF_ORDER, + _ => false, + }; + if proves_low { + self.state.low_s.insert(value); + } + } + } + _ => {} + } + } + + /// Runs `then` and `otherwise` under the respective assumptions on the already visited + /// `cond`, then continues from the join of the arms that did not exit. + fn branch( + &mut self, + cond: &'gcx Expr<'gcx>, + then: impl FnOnce(&mut Self) -> bool, + otherwise: impl FnOnce(&mut Self) -> bool, + ) -> bool { + if let Some(value) = self.const_bool(cond) { + self.assume(cond, !value); + return if value { then(self) } else { otherwise(self) }; + } + let baseline = self.state.clone(); + self.assume(cond, false); + let then_live = then(self); + let after_then = mem::replace(&mut self.state, baseline); + self.assume(cond, true); + let else_live = otherwise(self); + if then_live && else_live { + let after_else = mem::take(&mut self.state); + self.state = self.join(after_then, after_else); + } else if then_live { + self.state = after_then; + } + then_live || else_live + } + + fn run_block(&mut self, stmts: &'gcx [Stmt<'gcx>]) -> bool { + stmts.iter().all(|stmt| self.run_stmt(stmt)) + } + + /// Runs `stmt`; returns `false` when control cannot continue past it. + fn run_stmt(&mut self, stmt: &'gcx Stmt<'gcx>) -> bool { + match &stmt.kind { + StmtKind::Block(block) | StmtKind::UncheckedBlock(block) => self.run_block(block.stmts), + StmtKind::If(cond, then, otherwise) => { + let _ = self.visit_expr(cond); + self.branch( + cond, + |this| this.run_stmt(then), + |this| otherwise.is_none_or(|otherwise| this.run_stmt(otherwise)), + ) + } + StmtKind::Loop(block, source) => self.run_loop(block, *source), + StmtKind::Try(stmt_try) => { + let _ = self.visit_expr(&stmt_try.expr); + let after_call = self.state.clone(); + let mut live = Vec::new(); + for clause in stmt_try.clauses { + self.state = after_call.clone(); + if self.run_block(clause.block.stmts) { + live.push(self.state.clone()); + } + } + self.join_all(live) + } + StmtKind::Break | StmtKind::Continue => { + if matches!(stmt.kind, StmtKind::Continue) + && let Some(next) = self.loop_next + { + self.run_stmt(next); + } + self.loop_exits.push(self.state.clone()); + false + } + StmtKind::DeclSingle(var) => { + let init = self.gcx.hir.variable(*var).initializer; + self.store(&[(Some(ValueKey::Var(*var)), init)], init); + true + } + StmtKind::DeclMulti(vars, init) => { + let pairs: Vec<_> = match tuple_elems(init) { + Some(elems) => vars + .iter() + .zip(elems) + .map(|(var, rhs)| (var.map(ValueKey::Var), *rhs)) + .collect(), + None => vars.iter().map(|var| (var.map(ValueKey::Var), None)).collect(), + }; + self.store(&pairs, Some(init)); + true + } + StmtKind::Expr(expr) => { + match &expr.peel_parens().kind { + ExprKind::Assign(lhs, None, rhs) => self.store_expr(lhs, rhs), + _ => { + let _ = self.visit_expr(expr); + } + } + !is_exit_call(self.gcx, expr) + } + StmtKind::AssemblyBlock(_) | StmtKind::Err(_) => { + // Inline assembly is opaque and may observe any local before changing it. Flush + // deferred recoveries before discarding facts so assembly cannot hide a warning. + self.use_all_pending(); + self.state = FlowState::default(); + true + } + StmtKind::Return(None) => { + self.use_return_values(); + false + } + StmtKind::Return(Some(expr)) | StmtKind::Revert(expr) => { + let _ = self.visit_expr(expr); + false + } + _ => { + let _ = self.walk_stmt(stmt); + true + } + } + } + + fn run_loop(&mut self, block: &'gcx hir::Block<'gcx>, source: LoopSource<'gcx>) -> bool { + let next = loop_update(source); + let outer_exits = mem::take(&mut self.loop_exits); + let outer_next = mem::replace(&mut self.loop_next, next); + // `do { .. } while (false)` runs exactly once. Any other loop may carry the effects of + // one iteration into the next, so run the body once silently and start over from the + // join of the entry state with every state reaching the back edge. + let single_iteration = matches!(source, LoopSource::DoWhile) + && matches!(block.stmts.last().map(|stmt| &stmt.kind), + Some(StmtKind::If(cond, ..)) if self.const_bool(cond) == Some(false)); + if !single_iteration { + let entry = self.state.clone(); + let hits = self.hits.len(); + self.run_loop_body(block); + self.hits.truncate(hits); + let mut states = mem::take(&mut self.loop_exits); + states.push(entry); + self.join_all(states); + } + self.run_loop_body(block); + let exits = mem::replace(&mut self.loop_exits, outer_exits); + self.loop_next = outer_next; + self.join_all(exits) + } + + fn run_loop_body(&mut self, block: &'gcx hir::Block<'gcx>) { + if self.run_block(block.stmts) && self.loop_next.is_none_or(|next| self.run_stmt(next)) { + self.loop_exits.push(self.state.clone()); + } + } +} + +impl<'gcx> Visit<'gcx> for Analyzer<'gcx> { + type BreakValue = Never; + + fn hir(&self) -> &'gcx hir::Hir<'gcx> { + &self.gcx.hir + } + + fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow { + match &expr.kind { + ExprKind::Ident(_) => { + if let Some(value) = self.current_value(expr) { + self.use_value(value); + } + } + ExprKind::Binary(lhs, op, rhs) if matches!(op.kind, BinOpKind::And | BinOpKind::Or) => { + let _ = self.visit_expr(lhs); + let run_rhs = |this: &mut Self| { + let _ = this.visit_expr(rhs); + true + }; + let skip_rhs = |_: &mut Self| true; + if op.kind == BinOpKind::And { + self.branch(lhs, run_rhs, skip_rhs); + } else { + self.branch(lhs, skip_rhs, run_rhs); + } + } + ExprKind::Ternary(cond, then, otherwise) => { + let _ = self.visit_expr(cond); + self.branch( + cond, + |this| { + let _ = this.visit_expr(then); + true + }, + |this| { + let _ = this.visit_expr(otherwise); + true + }, + ); + } + ExprKind::Call(callee, args) if is_require_or_assert(self.gcx, callee) => { + let _ = self.walk_expr(expr); + if let Some(cond) = args.exprs().next() { + self.assume(cond, false); + } + } + ExprKind::Assign(lhs, None, rhs) => { + self.visit_lhs(lhs); + let _ = self.visit_expr(rhs); + self.assign_lhs(lhs, Some(rhs)); + } + ExprKind::Assign(lhs, Some(_), _) => { + let _ = self.walk_expr(expr); + self.assign_lhs(lhs, None); + } + ExprKind::Delete(target) => { + self.visit_lhs(target); + if let Some(key) = self.place_key(target) { + self.assign(key, (None, true)); + } + } + ExprKind::Unary(op, target) if op.kind.has_side_effects() => { + let _ = self.walk_expr(expr); + if let Some(key) = self.place_key(target) { + self.assign(key, (None, false)); + } + } + ExprKind::Call(callee, args) => { + let _ = self.walk_expr(expr); + if call_may_mutate_state(self.gcx, callee) { + self.invalidate_mutable_state(); + } + for var in self.reference_args(callee, args) { + self.assign(ValueKey::Var(var), (None, false)); + } + if let Some(recovery) = self.pending_recovery(expr) { + match self.deferred.get_mut(&expr.id) { + Some(captured) => *captured = Some(recovery), + None => self.emit_hit(recovery.span), + } + } + } + _ => { + let _ = self.walk_expr(expr); + } + } + ControlFlow::Continue(()) + } +} + +/// Finds writes and state-mutating calls that run when an expression is evaluated. +struct SideEffects<'a, 'gcx>(&'a Analyzer<'gcx>); + +impl<'gcx> Visit<'gcx> for SideEffects<'_, 'gcx> { + type BreakValue = (); + + fn hir(&self) -> &'gcx hir::Hir<'gcx> { + &self.0.gcx.hir + } + + fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow<()> { + match &expr.kind { + ExprKind::Assign(..) | ExprKind::Delete(_) => ControlFlow::Break(()), + ExprKind::Unary(op, _) if op.kind.has_side_effects() => ControlFlow::Break(()), + ExprKind::Call(callee, ..) if call_may_mutate_state(self.0.gcx, callee) => { + ControlFlow::Break(()) + } + ExprKind::Ternary(cond, then, otherwise) => { + self.visit_expr(cond)?; + for arm in self.0.live_arms(cond, *then, *otherwise) { + self.visit_expr(arm)?; + } + ControlFlow::Continue(()) + } + ExprKind::Binary(lhs, op, _) + if matches!(op.kind, BinOpKind::And | BinOpKind::Or) + && self + .0 + .const_bool(lhs) + .is_some_and(|value| (op.kind == BinOpKind::And) != value) => + { + self.visit_expr(lhs) + } + _ => self.walk_expr(expr), + } + } +} + +/// The variable an expression denotes, through parens and `uint256(..)`/`bytes32(..)` casts. +fn var_of(gcx: Gcx<'_>, expr: &Expr<'_>) -> Option { + match &expr.peel_parens().kind { + ExprKind::Ident(_) => gcx.resolved_variable(expr), + ExprKind::Call(callee, args) if is_transparent_cast(callee) && args.len() == 1 => { + args.exprs().next().and_then(|expr| var_of(gcx, expr)) + } + _ => None, + } +} + +fn is_transparent_cast(callee: &Expr<'_>) -> bool { + matches!( + &callee.peel_parens().kind, + ExprKind::Type(hir::Type { + kind: TypeKind::Elementary( + ElementaryType::UInt(size) | ElementaryType::FixedBytes(size) + ), + .. + }) if size.bits() == 256 + ) +} + +const fn negate_comparison(op: BinOpKind) -> BinOpKind { + match op { + BinOpKind::Lt => BinOpKind::Ge, + BinOpKind::Le => BinOpKind::Gt, + BinOpKind::Gt => BinOpKind::Le, + BinOpKind::Ge => BinOpKind::Lt, + BinOpKind::Eq => BinOpKind::Ne, + BinOpKind::Ne => BinOpKind::Eq, + _ => op, + } +} + +const fn reverse_comparison(op: BinOpKind) -> BinOpKind { + match op { + BinOpKind::Lt => BinOpKind::Gt, + BinOpKind::Le => BinOpKind::Ge, + BinOpKind::Gt => BinOpKind::Lt, + BinOpKind::Ge => BinOpKind::Le, + _ => op, + } +} + +fn call_may_mutate_state(gcx: Gcx<'_>, callee: &Expr<'_>) -> bool { + let callee = callee.peel_parens(); + if matches!(callee.kind, ExprKind::Type(_)) { + return false; + } + if let Some(ty) = gcx.type_of_expr(callee.id) + && let TyKind::Fn(function) = ty.peel_refs().kind + { + return function.state_mutability > StateMutability::View; + } + gcx.resolved_builtin(callee).is_none() +} diff --git a/crates/lint/src/sol/med/incorrect_erc20_interface.rs b/crates/lint/src/sol/med/incorrect_erc20_interface.rs new file mode 100644 index 0000000000000..a98633289a75b --- /dev/null +++ b/crates/lint/src/sol/med/incorrect_erc20_interface.rs @@ -0,0 +1,58 @@ +use super::IncorrectERC20Interface; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{Severity, SolLint, analysis::is_elementary}, +}; +use solar::sema::{Gcx, hir}; + +declare_forge_lint!( + INCORRECT_ERC20_INTERFACE, + Severity::Med, + "incorrect-erc20-interface", + "incorrect ERC20 function interface" +); + +/// ERC20 functions as `(name, parameter types, return types)`. +const ERC20_FUNCTIONS: &[(&str, &[&str], &[&str])] = &[ + ("transfer", &["address", "uint256"], &["bool"]), + ("transferFrom", &["address", "address", "uint256"], &["bool"]), + ("approve", &["address", "uint256"], &["bool"]), + ("allowance", &["address", "address"], &["uint256"]), + ("balanceOf", &["address"], &["uint256"]), + ("totalSupply", &[], &["uint256"]), +]; + +impl<'gcx> LateLintPass<'gcx> for IncorrectERC20Interface { + fn check_contract( + &mut self, + ctx: &LintContext, + gcx: Gcx<'gcx>, + contract: &'gcx hir::Contract<'gcx>, + ) { + let inherits = |names: &[&str]| { + contract + .linearized_bases + .iter() + .any(|base| names.contains(&gcx.hir.contract(*base).name.as_str())) + }; + // ERC721 tokens offer functions similar to ERC20 that are not compatible with it. + if !inherits(&["ERC20", "IERC20"]) || inherits(&["ERC721", "IERC721"]) { + return; + } + let matches = |vars: &[hir::VariableId], expected: &[&str]| { + vars.len() == expected.len() + && vars.iter().zip(expected).all(|(&id, &ty)| is_elementary(&gcx.hir, id, ty)) + }; + let functions = contract.items.iter().filter_map(|id| id.as_function()); + for func in functions.map(|id| gcx.hir.function(id)) { + let Some(name) = func.name.filter(|_| func.kind.is_function()) else { continue }; + if ERC20_FUNCTIONS.iter().any(|(n, params, returns)| { + *n == name.as_str() + && matches(func.parameters, params) + && !matches(func.returns, returns) + }) { + ctx.emit(&INCORRECT_ERC20_INTERFACE, func.span); + } + } + } +} diff --git a/crates/lint/src/sol/med/incorrect_erc721_interface.rs b/crates/lint/src/sol/med/incorrect_erc721_interface.rs new file mode 100644 index 0000000000000..3ecd5f02ef869 --- /dev/null +++ b/crates/lint/src/sol/med/incorrect_erc721_interface.rs @@ -0,0 +1,59 @@ +use super::IncorrectERC721Interface; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{Severity, SolLint, analysis::is_elementary}, +}; +use solar::sema::{Gcx, hir}; + +declare_forge_lint!( + INCORRECT_ERC721_INTERFACE, + Severity::Med, + "incorrect-erc721-interface", + "incorrect ERC721 function interface" +); + +/// ERC721 (and ERC165) functions as `(name, parameter types, return types)`. +const ERC721_FUNCTIONS: &[(&str, &[&str], &[&str])] = &[ + ("balanceOf", &["address"], &["uint256"]), + ("ownerOf", &["uint256"], &["address"]), + ("safeTransferFrom", &["address", "address", "uint256", "bytes"], &[]), + ("safeTransferFrom", &["address", "address", "uint256"], &[]), + ("transferFrom", &["address", "address", "uint256"], &[]), + ("approve", &["address", "uint256"], &[]), + ("setApprovalForAll", &["address", "bool"], &[]), + ("getApproved", &["uint256"], &["address"]), + ("isApprovedForAll", &["address", "address"], &["bool"]), + ("supportsInterface", &["bytes4"], &["bool"]), +]; + +impl<'gcx> LateLintPass<'gcx> for IncorrectERC721Interface { + fn check_contract( + &mut self, + ctx: &LintContext, + gcx: Gcx<'gcx>, + contract: &'gcx hir::Contract<'gcx>, + ) { + if !contract + .linearized_bases + .iter() + .any(|base| matches!(gcx.hir.contract(*base).name.as_str(), "ERC721" | "IERC721")) + { + return; + } + let matches = |vars: &[hir::VariableId], expected: &[&str]| { + vars.len() == expected.len() + && vars.iter().zip(expected).all(|(&id, &ty)| is_elementary(&gcx.hir, id, ty)) + }; + let functions = contract.items.iter().filter_map(|id| id.as_function()); + for func in functions.map(|id| gcx.hir.function(id)) { + let Some(name) = func.name.filter(|_| func.kind.is_function()) else { continue }; + if ERC721_FUNCTIONS.iter().any(|(n, params, returns)| { + *n == name.as_str() + && matches(func.parameters, params) + && !matches(func.returns, returns) + }) { + ctx.emit(&INCORRECT_ERC721_INTERFACE, func.span); + } + } + } +} diff --git a/crates/lint/src/sol/med/incorrect_strict_equality.rs b/crates/lint/src/sol/med/incorrect_strict_equality.rs new file mode 100644 index 0000000000000..da2cb0d0baa92 --- /dev/null +++ b/crates/lint/src/sol/med/incorrect_strict_equality.rs @@ -0,0 +1,59 @@ +use super::IncorrectStrictEquality; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{Severity, SolLint, analysis::referenced_item}, +}; +use solar::{ + ast::BinOpKind, + sema::{ + Gcx, + builtins::Builtin, + hir::{Expr, ExprKind, ItemId}, + }, +}; +use std::ops::ControlFlow; + +declare_forge_lint!( + INCORRECT_STRICT_EQUALITY, + Severity::Med, + "incorrect-strict-equality", + "dangerous strict equality check on an externally-influenced value" +); + +impl<'gcx> LateLintPass<'gcx> for IncorrectStrictEquality { + fn check_expr(&mut self, ctx: &LintContext, gcx: Gcx<'gcx>, expr: &'gcx Expr<'gcx>) { + if let ExprKind::Binary(lhs, op, rhs) = &expr.kind + && matches!(op.kind, BinOpKind::Eq | BinOpKind::Ne) + && [lhs, rhs].into_iter().any(|side| { + side.visit(&mut |e| { + if is_externally_influenced(gcx, e) { + ControlFlow::Break(()) + } else { + ControlFlow::Continue(()) + } + }) + .is_break() + }) + { + ctx.emit(&INCORRECT_STRICT_EQUALITY, expr.span); + } + } +} + +/// `
.balance` or `.balanceOf(...)`. +/// +/// `.balance` is only flagged when the receiver is provably an address, so that struct fields named +/// `balance` do not trigger it. `balanceOf` is matched by name (it is overwhelmingly an ERC-20 +/// method), skipping static library calls to avoid internal helpers of the same name. +fn is_externally_influenced<'gcx>(gcx: Gcx<'gcx>, expr: &Expr<'gcx>) -> bool { + match &expr.peel_parens().kind { + ExprKind::Member(..) => gcx.resolved_builtin(expr) == Some(Builtin::AddressBalance), + ExprKind::Call(callee, ..) => { + matches!(&callee.peel_parens().kind, ExprKind::Member(base, m) + if m.as_str() == "balanceOf" + && !matches!(referenced_item(gcx, base), Some(ItemId::Contract(cid)) + if gcx.hir.contract(cid).kind.is_library())) + } + _ => false, + } +} diff --git a/crates/lint/src/sol/med/locked_ether.rs b/crates/lint/src/sol/med/locked_ether.rs new file mode 100644 index 0000000000000..9168f146f6285 --- /dev/null +++ b/crates/lint/src/sol/med/locked_ether.rs @@ -0,0 +1,205 @@ +use super::LockedEther; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{ + Severity, SolLint, + analysis::{ + block_outcome, expr_is_address, is_address_self, is_builtin, is_contract_cast, + is_literal_zero, runtime_entry_points, + }, + }, +}; +use solar::{ + ast::{ContractKind, StateMutability}, + interface::{Span, kw, sym}, + sema::{ + Gcx, + builtins::Builtin, + hir::{ + self, Block, ContractId, ExprKind, FunctionId, ItemId, Res, StmtKind, TypeKind, Visit, + }, + }, +}; +use std::{collections::HashSet, ops::ControlFlow}; + +declare_forge_lint!( + LOCKED_ETHER, + Severity::Med, + "locked-ether", + "contract can receive ETH but has no mechanism to send it out" +); + +impl<'gcx> LateLintPass<'gcx> for LockedEther { + fn check_nested_contract( + &mut self, + ctx: &LintContext, + gcx: Gcx<'gcx>, + contract_id: ContractId, + ) { + let contract = gcx.hir.contract(contract_id); + // Libraries and interfaces cannot hold ETH. + if !ctx.is_lint_enabled(LOCKED_ETHER.id) + || !matches!(contract.kind, ContractKind::Contract | ContractKind::AbstractContract) + || contract.linearization_failed() + { + return; + } + + let receives = |fid: FunctionId| { + let func = gcx.hir.function(fid); + func.state_mutability == StateMutability::Payable + && !always_reverts(gcx, contract_id, func) + }; + // Runtime entries and the constructor are separate inflow channels: only the leaf's own + // constructor receives deployment value, and it has no runtime exit path. + let entries = runtime_entry_points(gcx, contract_id); + if !entries.iter().any(|&fid| receives(fid)) && !contract.ctor.is_some_and(receives) { + return; + } + + // Explore the runtime entries and, transitively, the helpers and modifiers they reach. + // Constructor bodies are excluded so their exits don't count. + let mut visited = HashSet::new(); + let mut checker = SendChecker { gcx, contract_id, worklist: entries }; + while let Some(fid) = checker.worklist.pop() { + let func = gcx.hir.function(fid); + // Any ETH movement inside an always-reverting function rolls back. + if !visited.insert(fid) || always_reverts(gcx, contract_id, func) { + continue; + } + for modifier in func.modifiers { + if checker.visit_call_args(&modifier.args).is_break() { + return; + } + checker.worklist.extend(gcx.resolve_modifier_target(contract_id, modifier)); + } + if let Some(body) = func.body + && body.stmts.iter().any(|stmt| checker.visit_stmt(stmt).is_break()) + { + return; + } + } + + ctx.emit(&LOCKED_ETHER, contract.name.span); + } +} + +/// True if invoking `func` always reverts, through its body or an attached modifier (one that +/// reverts before its first `_` or after its last one). +fn always_reverts(gcx: Gcx<'_>, contract: ContractId, func: &hir::Function<'_>) -> bool { + let reverts = |stmts: &[hir::Stmt<'_>]| { + !block_outcome(gcx, Block { span: Span::DUMMY, stmts }).can_skip_placeholder() + }; + func.body.is_some_and(|body| reverts(body.stmts)) + || func.modifiers.iter().any(|m| { + let Some(body) = + gcx.resolve_modifier_target(contract, m).and_then(|id| gcx.hir.function(id).body) + else { + return false; + }; + let is_placeholder = |s: &hir::Stmt<'_>| matches!(s.kind, StmtKind::Placeholder); + let Some(first) = body.stmts.iter().position(is_placeholder) else { + return reverts(body.stmts); + }; + let last = body.stmts.iter().rposition(is_placeholder).unwrap(); + reverts(&body.stmts[..first]) || reverts(&body.stmts[last + 1..]) + }) +} + +/// HIR visitor that short-circuits on the first ETH-sending expression and queues statically +/// resolved callees for transitive exploration by the outer worklist loop. +struct SendChecker<'gcx> { + gcx: Gcx<'gcx>, + /// The linted contract, which resolves virtual dispatch. + contract_id: ContractId, + worklist: Vec, +} + +impl<'gcx> Visit<'gcx> for SendChecker<'gcx> { + type BreakValue = (); + + fn hir(&self) -> &'gcx hir::Hir<'gcx> { + &self.gcx.hir + } + + /// Inline assembly can contain ETH-sending opcodes (`call`, `selfdestruct`, ...): bail + /// conservatively, as if an exit was found. + fn visit_stmt(&mut self, stmt: &'gcx hir::Stmt<'gcx>) -> ControlFlow<()> { + if matches!(stmt.kind, StmtKind::AssemblyBlock(_) | StmtKind::Switch(_) | StmtKind::Err(_)) + { + return ControlFlow::Break(()); + } + self.walk_stmt(stmt) + } + + fn visit_expr(&mut self, expr: &'gcx hir::Expr<'gcx>) -> ControlFlow<()> { + if expr_sends_ether(self.gcx, expr) { + return ControlFlow::Break(()); + } + if let ExprKind::Call(callee, ..) = &expr.kind { + match self.gcx.resolved_expr(callee) { + Some(Res::Item(ItemId::Function(fid))) => { + // `super.f()`, `Base.f()` and `Lib.f()` name one implementation; every other + // call dispatches through the leaf's linearization. + let direct = matches!(&callee.peel_parens().kind, ExprKind::Member(base, _) + if is_builtin(self.gcx, base, sym::super_) || is_contract_cast(self.gcx, base)); + self.worklist.push(if direct { + fid + } else { + self.gcx.resolve_virtual_function(self.contract_id, fid) + }); + } + // Function-typed variable: the bound target is unknown, treat the call as opaque. + Some(Res::Item(ItemId::Variable(id))) + if matches!(self.gcx.hir.variable(id).ty.kind, TypeKind::Function(_)) => + { + return ControlFlow::Break(()); + } + _ => {} + } + } + self.walk_expr(expr) + } +} + +/// True if `expr` unambiguously moves ETH out of the contract: a non-zero `{value: x}` call +/// option, `.transfer`/`.send` with a non-zero amount, low-level `.delegatecall`/`.callcode` +/// (drainable via `selfdestruct`), or the `selfdestruct` builtin. Only literal `0` is treated as +/// a zero amount, and sends targeting this contract's own address are not exits. +fn expr_sends_ether<'gcx>(gcx: Gcx<'gcx>, expr: &'gcx hir::Expr<'gcx>) -> bool { + let Some((callee, args, opts)) = expr.as_call() else { return false }; + let receiver = match &callee.kind { + ExprKind::Member(receiver, _) => Some(receiver), + _ => None, + }; + if opts.is_some_and(|opts| { + opts.args.iter().any(|arg| arg.name.name == sym::value && !is_literal_zero(&arg.value)) + }) && !receiver.is_some_and(|r| is_address_self(gcx, r)) + { + return true; + } + match &callee.kind { + // Only address-typed receivers can move ETH out: `.transfer`/`.send` on a contract type + // dispatch to a user-defined member. + ExprKind::Member(receiver, member) + if expr_is_address(gcx, receiver) && !is_address_self(gcx, receiver) => + { + match member.name { + // Single-arg form, to tell it apart from ERC20's 2-arg `transfer`. + sym::transfer | sym::send => { + args.len() == 1 && !args.exprs().next().is_some_and(is_literal_zero) + } + kw::Delegatecall | kw::Callcode => true, + kw::Call | kw::Staticcall => false, + // Any other member is a `using for` binding: assume the bound library function + // could move ETH. + _ => true, + } + } + ExprKind::Ident(_) if gcx.resolved_builtin(callee) == Some(Builtin::Selfdestruct) => { + // `selfdestruct(self)` burns the balance in place. + !args.exprs().next().is_some_and(|expr| is_address_self(gcx, expr)) + } + _ => false, + } +} diff --git a/crates/lint/src/sol/med/mapping_deletion.rs b/crates/lint/src/sol/med/mapping_deletion.rs new file mode 100644 index 0000000000000..29a5d6417da4a --- /dev/null +++ b/crates/lint/src/sol/med/mapping_deletion.rs @@ -0,0 +1,27 @@ +use super::MappingDeletion; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{Severity, SolLint}, +}; +use solar::sema::{ + Gcx, + hir::{self, ExprKind}, +}; + +declare_forge_lint!( + MAPPING_DELETION, + Severity::Med, + "mapping-deletion", + "`delete` on a value containing a mapping does not clear the mapping" +); + +impl<'gcx> LateLintPass<'gcx> for MappingDeletion { + fn check_expr(&mut self, ctx: &LintContext, gcx: Gcx<'gcx>, expr: &'gcx hir::Expr<'gcx>) { + if let ExprKind::Delete(operand) = &expr.kind + && let Some(ty) = gcx.type_of_expr(operand.peel_parens().id) + && ty.has_mapping(gcx) + { + ctx.emit(&MAPPING_DELETION, expr.span); + } + } +} diff --git a/crates/lint/src/sol/med/mod.rs b/crates/lint/src/sol/med/mod.rs index 35e55f594fa8f..4ff35b092c134 100644 --- a/crates/lint/src/sol/med/mod.rs +++ b/crates/lint/src/sol/med/mod.rs @@ -1,6 +1,46 @@ -use crate::sol::{EarlyLintPass, LateLintPass, SolLint}; +use crate::sol::SolLint; +mod assert_state_change; +mod cheatcode_environment; +mod dangerous_unary_operator; mod div_mul; -use div_mul::DIVIDE_BEFORE_MULTIPLY; +mod ecrecover; +mod incorrect_erc20_interface; +mod incorrect_erc721_interface; +mod incorrect_strict_equality; +mod locked_ether; +mod mapping_deletion; +mod non_reentrant_not_first; +mod tautological_compare; +mod tautology; +mod tx_origin; +mod uninitialized_local; +mod uninitialized_state_variables; +mod unsafe_oz_erc721_mint; +mod unsafe_typecast; +mod unused_return; +mod weak_prng; -register_lints!((DivideBeforeMultiply, early, (DIVIDE_BEFORE_MULTIPLY))); +register_lints!( + assert_state_change: (AssertStateChange, late, (ASSERT_STATE_CHANGE)); + cheatcode_environment: (CheatcodeEnvironment, project, (ENVIRONMENT_READ_ACROSS_MUTATION)); + dangerous_unary_operator: (DangerousUnaryOperator, early, (DANGEROUS_UNARY_OPERATOR)); + div_mul: (DivideBeforeMultiply, late, (DIVIDE_BEFORE_MULTIPLY)); + ecrecover: (Ecrecover, late, (ECRECOVER)); + incorrect_erc20_interface: (IncorrectERC20Interface, late, (INCORRECT_ERC20_INTERFACE)); + incorrect_erc721_interface: (IncorrectERC721Interface, late, (INCORRECT_ERC721_INTERFACE)); + incorrect_strict_equality: (IncorrectStrictEquality, late, (INCORRECT_STRICT_EQUALITY)); + tautology: (TypeBasedTautology, late, (TYPE_BASED_TAUTOLOGY)); + tx_origin: (TxOrigin, early, (TX_ORIGIN)); + uninitialized_local: (UninitializedLocal, late, (UNINITIALIZED_LOCAL)); + uninitialized_state_variables: + (UninitializedStateVariables, late, (UNINITIALIZED_STATE_VARIABLES)); + unsafe_oz_erc721_mint: (UnsafeOzErc721Mint, late, (UNSAFE_OZ_ERC721_MINT)); + unsafe_typecast: (UnsafeTypecast, late, (UNSAFE_TYPECAST)); + unused_return: (UnusedReturn, late, (UNUSED_RETURN)); + locked_ether: (LockedEther, late, (LOCKED_ETHER)); + mapping_deletion: (MappingDeletion, late, (MAPPING_DELETION)); + non_reentrant_not_first: (NonReentrantNotFirst, late, (NON_REENTRANT_NOT_FIRST)); + weak_prng: (WeakPrng, late, (WEAK_PRNG)); + tautological_compare: (TautologicalCompare, late, (TAUTOLOGICAL_COMPARE)); +); diff --git a/crates/lint/src/sol/med/non_reentrant_not_first.rs b/crates/lint/src/sol/med/non_reentrant_not_first.rs new file mode 100644 index 0000000000000..a7a7ab3377c4b --- /dev/null +++ b/crates/lint/src/sol/med/non_reentrant_not_first.rs @@ -0,0 +1,40 @@ +use super::NonReentrantNotFirst; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{Severity, SolLint}, +}; +use solar::sema::{ + Gcx, + hir::{self, FunctionKind}, +}; + +declare_forge_lint!( + NON_REENTRANT_NOT_FIRST, + Severity::Med, + "non-reentrant-not-first", + "`nonReentrant` is not the first modifier" +); + +impl<'gcx> LateLintPass<'gcx> for NonReentrantNotFirst { + fn check_function( + &mut self, + ctx: &LintContext, + gcx: Gcx<'gcx>, + func: &'gcx hir::Function<'gcx>, + ) { + if !matches!( + func.kind, + FunctionKind::Function | FunctionKind::Fallback | FunctionKind::Receive + ) { + return; + } + for modifier in func.modifiers.iter().skip(1) { + let is_non_reentrant = modifier.id.as_function().is_some_and(|id| { + gcx.hir.function(id).name.is_some_and(|name| name.as_str() == "nonReentrant") + }); + if is_non_reentrant { + ctx.emit(&NON_REENTRANT_NOT_FIRST, modifier.span); + } + } + } +} diff --git a/crates/lint/src/sol/med/tautological_compare.rs b/crates/lint/src/sol/med/tautological_compare.rs new file mode 100644 index 0000000000000..fec1e1e940088 --- /dev/null +++ b/crates/lint/src/sol/med/tautological_compare.rs @@ -0,0 +1,94 @@ +use super::TautologicalCompare; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{Severity, SolLint, analysis::cast_type}, +}; +use solar::{ + ast::{BinOpKind, Lit, LitKind}, + sema::{ + Gcx, + hir::{self, Expr, ExprKind}, + ty::TyKind, + }, +}; + +declare_forge_lint!( + TAUTOLOGICAL_COMPARE, + Severity::Med, + "tautological-compare", + "comparing an expression with itself is always true or false" +); + +impl<'gcx> LateLintPass<'gcx> for TautologicalCompare { + fn check_expr(&mut self, ctx: &LintContext, gcx: Gcx<'gcx>, expr: &'gcx hir::Expr<'gcx>) { + // A UDVT can only be compared through a user-defined operator (`using {f as ==} for T`), + // which dispatches to an arbitrary function, so `x == x` is not tautological for it. + if let ExprKind::Binary(left, op, right) = &expr.kind + && matches!( + op.kind, + BinOpKind::Lt + | BinOpKind::Le + | BinOpKind::Gt + | BinOpKind::Ge + | BinOpKind::Eq + | BinOpKind::Ne + ) + && exprs_equal(left, right) + && !gcx + .type_of_expr(left.peel_parens().id) + .is_some_and(|ty| matches!(ty.peel_refs().kind, TyKind::Udvt(..))) + { + ctx.emit(&TAUTOLOGICAL_COMPARE, expr.span); + } + } +} + +/// Structural equality for side-effect-free expressions. Anything else (notably arbitrary calls, +/// which may return different values, and the mutating `++`/`--`) is treated as unequal. +fn exprs_equal<'gcx>(a: &Expr<'gcx>, b: &Expr<'gcx>) -> bool { + match (&a.peel_parens().kind, &b.peel_parens().kind) { + (ExprKind::Ident(ra), ExprKind::Ident(rb)) => ra == rb, + (ExprKind::Lit(la), ExprKind::Lit(lb)) => literals_equal(la, lb), + (ExprKind::Member(ba, na), ExprKind::Member(bb, nb)) => { + na.name == nb.name && exprs_equal(ba, bb) + } + (ExprKind::Index(ba, ia), ExprKind::Index(bb, ib)) => { + exprs_equal(ba, bb) + && match (ia, ib) { + (Some(ia), Some(ib)) => exprs_equal(ia, ib), + (None, None) => true, + _ => false, + } + } + (ExprKind::Binary(la, opa, ra), ExprKind::Binary(lb, opb, rb)) => { + opa.kind == opb.kind && exprs_equal(la, lb) && exprs_equal(ra, rb) + } + // Only casts to the *same* elementary type are pure conversions: `uint256(x) == uint8(x)` + // is not tautological because the narrower cast can truncate. + (ExprKind::Call(ca, args_a), ExprKind::Call(cb, args_b)) => { + matches!((cast_type(ca), cast_type(cb)), (Some(ea), Some(eb)) if ea == eb) + && args_a.len() == 1 + && args_b.len() == 1 + && args_a.exprs().zip(args_b.exprs()).all(|(ia, ib)| exprs_equal(ia, ib)) + } + (ExprKind::Payable(a), ExprKind::Payable(b)) => exprs_equal(a, b), + (ExprKind::Unary(opa, a), ExprKind::Unary(opb, b)) => { + opa.kind == opb.kind && !opa.kind.has_side_effects() && exprs_equal(a, b) + } + (ExprKind::Ternary(ca, ta, fa), ExprKind::Ternary(cb, tb, fb)) => { + exprs_equal(ca, cb) && exprs_equal(ta, tb) && exprs_equal(fa, fb) + } + _ => false, + } +} + +fn literals_equal(a: &Lit<'_>, b: &Lit<'_>) -> bool { + match (&a.kind, &b.kind) { + (LitKind::Str(ak, av, _), LitKind::Str(bk, bv, _)) => ak == bk && av == bv, + (LitKind::Number(a), LitKind::Number(b)) => a == b, + (LitKind::Rational(a), LitKind::Rational(b)) => a == b, + (LitKind::Address(a), LitKind::Address(b)) => a == b, + (LitKind::Bool(a), LitKind::Bool(b)) => a == b, + _ => false, + } +} diff --git a/crates/lint/src/sol/med/tautology.rs b/crates/lint/src/sol/med/tautology.rs new file mode 100644 index 0000000000000..ad74b9dbadeba --- /dev/null +++ b/crates/lint/src/sol/med/tautology.rs @@ -0,0 +1,194 @@ +use super::TypeBasedTautology; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{Severity, SolLint, analysis::cast_type}, +}; +use alloy_primitives::U256; +use solar::{ + ast::{BinOpKind, LitKind, UnOpKind}, + sema::{ + Gcx, + hir::{ElementaryType, Expr, ExprKind, VariableId}, + ty::TyKind, + }, +}; +use std::cmp::Ordering; + +declare_forge_lint!( + TYPE_BASED_TAUTOLOGY, + Severity::Med, + "type-based-tautology", + "condition is always true or false based on the variable's type" +); + +impl<'gcx> LateLintPass<'gcx> for TypeBasedTautology { + fn check_expr(&mut self, ctx: &LintContext, gcx: Gcx<'gcx>, expr: &'gcx Expr<'gcx>) { + let ExprKind::Binary(left, op, right) = &expr.kind else { return }; + + // A pair of comparisons can cover the complete type range even when neither is + // tautological on its own, e.g. `x > 0 || x == 0` for `uint`. + let is_tautology = if op.kind == BinOpKind::Or { + matches!((comparison_of(gcx, left), comparison_of(gcx, right)), + (Some(l), Some(r)) if is_boundary_composition(&l, &r)) + } else { + split_comparison(expr).is_some_and(|(operand, val, op)| { + elem_type_of(gcx, operand) + .and_then(integer_bounds) + .is_some_and(|range| is_tautology(range, val, op)) + }) + }; + if is_tautology { + ctx.emit(&TYPE_BASED_TAUTOLOGY, expr.span); + } + } +} + +/// A signed integer constant as `(is_negative, magnitude)`, matching how solar stores negated +/// literals (`-128` is `Unary(Neg, Lit(128))`). Zero is always `(false, 0)`. +type Const = (bool, U256); + +fn cmp(a: Const, b: Const) -> Ordering { + match (a.0, b.0) { + (true, false) => Ordering::Less, + (false, true) => Ordering::Greater, + (false, false) => a.1.cmp(&b.1), + (true, true) => b.1.cmp(&a.1), + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +struct Range { + lo: Const, + hi: Const, +} + +fn integer_bounds(ty: ElementaryType) -> Option { + match ty { + ElementaryType::UInt(size) => { + let bits = size.bits(); + let hi = if bits == 256 { U256::MAX } else { (U256::ONE << bits) - U256::ONE }; + Some(Range { lo: (false, U256::ZERO), hi: (false, hi) }) + } + ElementaryType::Int(size) => { + let half = U256::ONE << (size.bits() - 1); + Some(Range { lo: (true, half), hi: (false, half - U256::ONE) }) + } + _ => None, + } +} + +/// True if `x val` has the same truth value for every `x` in `range`. +fn is_tautology(range: Range, val: Const, op: BinOpKind) -> bool { + let (lo, hi) = (cmp(val, range.lo), cmp(val, range.hi)); + match op { + BinOpKind::Gt | BinOpKind::Le => hi.is_ge() || lo.is_lt(), + BinOpKind::Ge | BinOpKind::Lt => lo.is_le() || hi.is_gt(), + BinOpKind::Eq | BinOpKind::Ne => hi.is_gt() || lo.is_lt(), + _ => false, + } +} + +/// A relational/equality comparison between an operand and a constant, normalized to +/// `operand const` (the operator is flipped when the constant is on the left). +fn split_comparison<'gcx>(expr: &'gcx Expr<'gcx>) -> Option<(&'gcx Expr<'gcx>, Const, BinOpKind)> { + let ExprKind::Binary(left, op, right) = &expr.peel_parens().kind else { return None }; + let flipped = match op.kind { + BinOpKind::Lt => BinOpKind::Gt, + BinOpKind::Le => BinOpKind::Ge, + BinOpKind::Gt => BinOpKind::Lt, + BinOpKind::Ge => BinOpKind::Le, + BinOpKind::Eq | BinOpKind::Ne => op.kind, + _ => return None, + }; + match lit_value_of(right) { + Some(val) => Some((left, val, op.kind)), + None => Some((right, lit_value_of(left)?, flipped)), + } +} + +struct Comparison { + variable: VariableId, + cast_path: Vec, + range: Range, + op: BinOpKind, + val: Const, +} + +/// A comparison of one resolved integer variable (possibly cast) against a constant. +fn comparison_of<'gcx>(gcx: Gcx<'gcx>, expr: &'gcx Expr<'gcx>) -> Option { + let (operand, val, op) = split_comparison(expr)?; + let (variable, cast_path, range) = comparison_operand_of(gcx, operand)?; + Some(Comparison { variable, cast_path, range, op, val }) +} + +/// True when two comparisons over the same operand together cover its whole range. +fn is_boundary_composition(l: &Comparison, r: &Comparison) -> bool { + if l.variable != r.variable || l.cast_path != r.cast_path || l.range != r.range { + return false; + } + let Range { lo, hi } = l.range; + let is = |c: &Comparison, op, val| c.op == op && c.val == val; + let at_lo = |c| is(c, BinOpKind::Eq, lo) || is(c, BinOpKind::Le, lo); + let at_hi = |c| is(c, BinOpKind::Eq, hi) || is(c, BinOpKind::Ge, hi); + [(l, r), (r, l)].into_iter().any(|(a, b)| { + (is(a, BinOpKind::Gt, lo) && (at_lo(b) || is(b, BinOpKind::Lt, hi))) + || (is(a, BinOpKind::Lt, hi) && at_hi(b)) + }) +} + +/// The variable an operand compares, the casts applied to it and the range its values span. +/// +/// A same-signed widening cast preserves the value, so it neither changes the range nor +/// distinguishes the operand from the uncast expression; any other cast resets the range to the +/// target type's and becomes part of the operand's identity. +fn comparison_operand_of<'gcx>( + gcx: Gcx<'gcx>, + expr: &'gcx Expr<'gcx>, +) -> Option<(VariableId, Vec, Range)> { + match &expr.peel_parens().kind { + ExprKind::Ident(_) => { + let variable = gcx.resolved_variable(expr)?; + let ty = elem_type_of(gcx, expr)?; + Some((variable, Vec::new(), integer_bounds(ty)?)) + } + ExprKind::Call(callee, args) if args.len() == 1 => { + let ty = cast_type(callee)?; + let inner = args.exprs().next()?; + let (variable, mut cast_path, range) = comparison_operand_of(gcx, inner)?; + let source = elem_type_of(gcx, inner)?; + let widening = match (source, ty) { + (ElementaryType::UInt(from), ElementaryType::UInt(to)) + | (ElementaryType::Int(from), ElementaryType::Int(to)) => from.bits() <= to.bits(), + _ => false, + }; + if widening { + return Some((variable, cast_path, range)); + } + cast_path.push(ty); + Some((variable, cast_path, integer_bounds(ty)?)) + } + _ => None, + } +} + +/// The elementary type selected by the type checker. +fn elem_type_of(gcx: Gcx<'_>, expr: &Expr<'_>) -> Option { + match gcx.type_of_expr(expr.peel_parens().id)?.peel_refs().kind { + TyKind::Elementary(ty) => Some(ty), + _ => None, + } +} + +/// A numeric literal or negated numeric literal. +fn lit_value_of(expr: &Expr<'_>) -> Option { + let (neg, lit) = match &expr.peel_parens().kind { + ExprKind::Lit(lit) => (false, lit), + ExprKind::Unary(op, inner) if op.kind == UnOpKind::Neg => match &inner.peel_parens().kind { + ExprKind::Lit(lit) => (true, lit), + _ => return None, + }, + _ => return None, + }; + let LitKind::Number(n) = lit.kind else { return None }; + Some((neg && !n.is_zero(), n)) +} diff --git a/crates/lint/src/sol/med/tx_origin.rs b/crates/lint/src/sol/med/tx_origin.rs new file mode 100644 index 0000000000000..c5eb9ab2207d2 --- /dev/null +++ b/crates/lint/src/sol/med/tx_origin.rs @@ -0,0 +1,55 @@ +use super::TxOrigin; +use crate::{ + linter::{EarlyLintPass, LintContext}, + sol::{Severity, SolLint}, +}; +use solar::{ + ast::{Expr, ExprKind, Stmt, StmtKind, visit::Visit}, + interface::{kw, sym}, +}; +use std::ops::ControlFlow; + +declare_forge_lint!(TX_ORIGIN, Severity::Med, "tx-origin", "`tx.origin` is used for authorization"); + +impl<'ast> EarlyLintPass<'ast> for TxOrigin { + fn check_stmt(&mut self, ctx: &LintContext, stmt: &'ast Stmt<'ast>) { + if let StmtKind::If(cond, ..) + | StmtKind::DoWhile(_, cond) + | StmtKind::While(cond, _) + | StmtKind::For { cond: Some(cond), .. } = &stmt.kind + { + emit_if_contains_tx_origin(ctx, cond); + } + } + + fn check_expr(&mut self, ctx: &LintContext, expr: &'ast Expr<'ast>) { + if let ExprKind::Call(callee, args) = &expr.kind + && matches!(&callee.kind, ExprKind::Ident(id) if matches!(id.name, sym::require | sym::assert)) + && let Some(cond) = args.exprs().next() + { + emit_if_contains_tx_origin(ctx, cond); + } + } +} + +fn emit_if_contains_tx_origin<'ast>(ctx: &LintContext, expr: &'ast Expr<'ast>) { + if TxOriginFinder.visit_expr(expr).is_break() { + ctx.emit(&TX_ORIGIN, expr.span); + } +} + +struct TxOriginFinder; + +impl<'ast> Visit<'ast> for TxOriginFinder { + type BreakValue = (); + + fn visit_expr(&mut self, expr: &'ast Expr<'ast>) -> ControlFlow<()> { + if let ExprKind::Member(base, member) = &expr.kind + && member.name == kw::Origin + && matches!(&base.kind, ExprKind::Ident(id) if id.name == sym::tx) + { + return ControlFlow::Break(()); + } + self.walk_expr(expr) + } +} diff --git a/crates/lint/src/sol/med/uninitialized_local.rs b/crates/lint/src/sol/med/uninitialized_local.rs new file mode 100644 index 0000000000000..86b6b1c10d500 --- /dev/null +++ b/crates/lint/src/sol/med/uninitialized_local.rs @@ -0,0 +1,202 @@ +use super::UninitializedLocal; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{ + Severity, SolLint, + analysis::{branch_always_exits, for_each_lhs_var, loop_stmts}, + }, +}; +use solar::{ + ast::ElementaryType, + interface::{Span, data_structures::Never}, + sema::{ + Gcx, Hir, + hir::{ + BinOpKind, Block, Expr, ExprKind, Function, LoopSource, Stmt, StmtKind, TypeKind, + UnOpKind, VarKind, VariableId, Visit, + }, + }, +}; +use std::{ + collections::{HashMap, HashSet}, + ops::ControlFlow, +}; + +declare_forge_lint!( + UNINITIALIZED_LOCAL, + Severity::Med, + "uninitialized-local", + "local variable is read before being initialized" +); + +impl<'gcx> LateLintPass<'gcx> for UninitializedLocal { + fn check_function(&mut self, ctx: &LintContext, gcx: Gcx<'gcx>, func: &'gcx Function<'gcx>) { + let Some(body) = func.body else { return }; + let mut checker = + Checker { gcx, hir: &gcx.hir, uninitialized: HashSet::new(), findings: HashMap::new() }; + for stmt in body.stmts { + let _ = checker.visit_stmt(stmt); + } + for span in checker.findings.into_values() { + ctx.emit(&UNINITIALIZED_LOCAL, span); + } + } +} + +struct Checker<'gcx> { + gcx: Gcx<'gcx>, + hir: &'gcx Hir<'gcx>, + /// Value-type locals declared without an initializer that have not yet been written on + /// every path. + uninitialized: HashSet, + /// First read span per variable that was read while uninitialized. + findings: HashMap, +} + +impl Checker<'_> { + fn mark_written(&mut self, lhs: &Expr<'_>) { + for_each_lhs_var(self.gcx, lhs, &mut |v| { + self.uninitialized.remove(&v); + }); + } +} + +/// The loop statement of a conventional `for (uint i; i < n; i++)` header whose counter relies on +/// its implicit zero. The header lowers to `{ decl; loop { if cond { body } else break } }` with +/// the update on the loop source; matching the wrapper's span keeps declarations outside the +/// header distinct. +fn defaulted_counter_loop<'gcx>( + gcx: Gcx<'gcx>, + block: &'gcx Block<'gcx>, +) -> Option<&'gcx Stmt<'gcx>> { + if let [Stmt { kind: StmtKind::DeclSingle(vid), .. }, loop_stmt] = block.stmts + && block.span == loop_stmt.span + && let StmtKind::Loop(body, LoopSource::For { update: Some(update) }) = &loop_stmt.kind + && let var = gcx.hir.variable(*vid) + && var.initializer.is_none() + && matches!(var.ty.kind, TypeKind::Elementary(ElementaryType::UInt(_))) + && let [Stmt { kind: StmtKind::If(cond, _, Some(else_)), .. }] = body.stmts + && matches!(else_.kind, StmtKind::Break) + && let ExprKind::Binary(left, op, right) = &cond.peel_parens().kind + && ((matches!(op.kind, BinOpKind::Lt | BinOpKind::Le) + && gcx.resolved_variable(left) == Some(*vid)) + || (matches!(op.kind, BinOpKind::Gt | BinOpKind::Ge) + && gcx.resolved_variable(right) == Some(*vid))) + && let StmtKind::Expr(update) = &update.kind + && let ExprKind::Unary(op, target) = &update.peel_parens().kind + && matches!(op.kind, UnOpKind::PreInc | UnOpKind::PostInc) + && gcx.resolved_variable(target) == Some(*vid) + { + Some(loop_stmt) + } else { + None + } +} + +impl<'gcx> Visit<'gcx> for Checker<'gcx> { + type BreakValue = Never; + + fn hir(&self) -> &'gcx Hir<'gcx> { + self.hir + } + + fn visit_stmt(&mut self, stmt: &'gcx Stmt<'gcx>) -> ControlFlow { + match &stmt.kind { + StmtKind::Block(block) => { + if let Some(loop_stmt) = defaulted_counter_loop(self.gcx, block) { + // Skip only the counter's declaration; all reads in the loop still run + // through the ordinary checker, including reads of other locals. + return self.visit_stmt(loop_stmt); + } + } + StmtKind::DeclSingle(vid) => { + let v = self.hir.variable(*vid); + if v.kind == VarKind::Statement + && v.initializer.is_none() + && self.gcx.type_of_item((*vid).into()).is_value_type() + { + self.uninitialized.insert(*vid); + } + } + // A variable stays uninitialized if any branch that falls through fails to write it. + StmtKind::If(cond, then, else_) => { + self.visit_expr(cond)?; + let before = self.uninitialized.clone(); + self.visit_stmt(then)?; + let after_then = std::mem::replace(&mut self.uninitialized, before); + if let Some(else_) = else_ { + self.visit_stmt(else_)?; + } + if branch_always_exits(self.gcx, then) { + // Only the else path continues; keep its state. + } else if else_.is_some_and(|expr| branch_always_exits(self.gcx, expr)) { + self.uninitialized = after_then; + } else { + self.uninitialized.extend(after_then); + } + return ControlFlow::Continue(()); + } + // `do-while` runs its body once, so its writes are guaranteed; `for`/`while` may run + // zero times, so theirs are discarded. + StmtKind::Loop(block, source) => { + let before = self.uninitialized.clone(); + for s in loop_stmts(*block, *source) { + self.visit_stmt(s)?; + } + if !matches!(source, LoopSource::DoWhile) { + self.uninitialized = before; + } + return ControlFlow::Continue(()); + } + // Each clause is an independent path, like `if`/`else` branches. + StmtKind::Try(t) => { + self.visit_expr(&t.expr)?; + let before = self.uninitialized.clone(); + let mut merged = HashSet::new(); + for clause in t.clauses { + self.uninitialized = before.clone(); + for s in clause.block.stmts { + self.visit_stmt(s)?; + } + merged.extend(self.uninitialized.drain()); + } + self.uninitialized = merged; + return ControlFlow::Continue(()); + } + _ => {} + } + self.walk_stmt(stmt) + } + + fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow { + match &expr.kind { + // Compound `op=` reads the lhs first; plain `=` reads only the rhs (catches `x = x`). + // The lhs is still walked afterwards for reads inside e.g. an index expression. + ExprKind::Assign(lhs, op, rhs) => { + if op.is_some() { + self.visit_expr(lhs)?; + } + self.visit_expr(rhs)?; + self.mark_written(lhs); + if op.is_none() { + self.visit_expr(lhs)?; + } + ControlFlow::Continue(()) + } + // `delete x` is an explicit write to the zero value, not a read. + ExprKind::Delete(target) => { + self.mark_written(target); + self.visit_expr(target) + } + ExprKind::Ident(_) => { + if let Some(vid) = + self.gcx.resolved_variable(expr).filter(|v| self.uninitialized.contains(v)) + { + self.findings.entry(vid).or_insert(expr.span); + } + ControlFlow::Continue(()) + } + _ => self.walk_expr(expr), + } + } +} diff --git a/crates/lint/src/sol/med/uninitialized_state_variables.rs b/crates/lint/src/sol/med/uninitialized_state_variables.rs new file mode 100644 index 0000000000000..7d7c19261cfae --- /dev/null +++ b/crates/lint/src/sol/med/uninitialized_state_variables.rs @@ -0,0 +1,301 @@ +use super::UninitializedStateVariables; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{ + Severity, SolLint, + analysis::{referenced_item, tuple_elems}, + }, +}; +use solar::{ + ast::ContractKind, + interface::data_structures::Never, + sema::{ + Gcx, Hir, + hir::{ + Contract, ContractId, DataLocation, Expr, ExprKind, Function, Stmt, StmtKind, TypeKind, + VariableId, Visit, + }, + }, +}; +use std::{ + collections::{HashMap, HashSet}, + ops::ControlFlow, +}; + +declare_forge_lint!( + UNINITIALIZED_STATE_VARIABLES, + Severity::Med, + "uninitialized-state", + "state variable is read but never written" +); + +impl<'gcx> LateLintPass<'gcx> for UninitializedStateVariables { + fn check_nested_contract( + &mut self, + ctx: &LintContext, + gcx: Gcx<'gcx>, + contract_id: ContractId, + ) { + let contract = gcx.hir.contract(contract_id); + // Abstract contracts and interfaces are not deployed; a failed C3 linearization leaves + // `linearized_bases` incomplete, so skip rather than produce unsound results. + if matches!(contract.kind, ContractKind::Interface | ContractKind::AbstractContract) + || contract.linearization_failed() + { + return; + } + + // Every read and write in the whole inheritance chain (`linearized_bases[0]` is the + // contract itself) determines whether a variable is ever written. + let bases = contract.linearized_bases; + let mut collector = Collector { + hir: &gcx.hir, + gcx, + read: HashSet::new(), + written: HashSet::new(), + aliases: HashMap::new(), + }; + // Inline assembly can write storage directly; bail out conservatively. + if bases.iter().any(|&cid| collector.visit_contract_items(gcx.hir.contract(cid)).is_break()) + { + return; + } + + for var_id in bases.iter().flat_map(|&cid| gcx.hir.contract(cid).variables()) { + let var = gcx.hir.variable(var_id); + if !var.is_constant() + && !var.is_immutable() + && !matches!(var.ty.kind, TypeKind::Mapping(_)) + && var.initializer.is_none() + && collector.read.contains(&var_id) + && !collector.written.contains(&var_id) + { + ctx.emit(&UNINITIALIZED_STATE_VARIABLES, var.span); + } + } + } +} + +struct Collector<'gcx> { + hir: &'gcx Hir<'gcx>, + gcx: Gcx<'gcx>, + read: HashSet, + written: HashSet, + /// State variables each local `storage` pointer of the current function may reference. + aliases: Aliases, +} + +/// Maps local `storage` pointers to the state variables they may reference. +type Aliases = HashMap>; + +impl<'gcx> Collector<'gcx> { + fn visit_contract_items(&mut self, contract: &'gcx Contract<'gcx>) -> ControlFlow<()> { + contract.all_functions().try_for_each(|fid| self.visit_nested_function(fid))?; + contract.variables().try_for_each(|vid| self.visit_nested_var(vid))?; + contract.bases_args.iter().try_for_each(|m| self.visit_modifier(m)) + } + + /// Marks the variable at the root of an lvalue (through index/slice/member access and tuple + /// destructuring) as written; a write through a `storage` pointer writes its targets. + fn mark_written(&mut self, expr: &Expr<'_>) { + match &expr.peel_parens().kind { + ExprKind::Ident(_) => { + if let Some(id) = self.gcx.resolved_variable(expr) { + self.written.insert(id); + if let Some(targets) = self.aliases.get(&id) { + self.written.extend(targets); + } + } + } + ExprKind::Tuple(exprs) => exprs.iter().flatten().for_each(|e| self.mark_written(e)), + ExprKind::Index(base, _) | ExprKind::Slice(base, ..) | ExprKind::Member(base, _) => { + self.mark_written(base) + } + _ => {} + } + } + + /// A call taking a `storage` parameter may write through its corresponding argument. + fn mark_storage_args(&mut self, call: &'gcx Expr<'gcx>) { + if let ExprKind::Call(callee, ..) = &call.kind + && let Some(parameters) = + self.gcx.type_of_expr(callee.id).and_then(|ty| ty.parameters()) + { + for (index, ty) in parameters.iter().enumerate() { + if ty.is_ref_at(DataLocation::Storage) + && let Some(arg) = self.gcx.call_arg(call, index) + { + self.mark_written(arg); + } + } + } + } +} + +impl<'gcx> Visit<'gcx> for Collector<'gcx> { + type BreakValue = (); + + fn hir(&self) -> &'gcx Hir<'gcx> { + self.hir + } + + fn visit_function(&mut self, func: &'gcx Function<'gcx>) -> ControlFlow<()> { + self.aliases = storage_aliases(self.gcx, func); + func.modifiers.iter().try_for_each(|m| self.visit_modifier(m))?; + func.body.iter().flat_map(|body| body.stmts).try_for_each(|stmt| self.visit_stmt(stmt)) + } + + fn visit_stmt(&mut self, stmt: &'gcx Stmt<'gcx>) -> ControlFlow<()> { + match stmt.kind { + StmtKind::AssemblyBlock(_) | StmtKind::Switch(_) | StmtKind::Err(_) => { + ControlFlow::Break(()) + } + _ => self.walk_stmt(stmt), + } + } + + fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow<()> { + match &expr.kind { + ExprKind::Ident(_) => self.read.extend(self.gcx.resolved_variable(expr)), + // Reassigning a bare storage pointer repoints it rather than writing its target. + ExprKind::Assign(lhs, ..) if !is_storage_pointer(self.gcx, lhs) => { + self.mark_written(lhs) + } + ExprKind::Delete(lhs) => self.mark_written(lhs), + ExprKind::Unary(op, lhs) if op.kind.has_side_effects() => self.mark_written(lhs), + ExprKind::Call(callee, ..) => { + // The receiver of a member call covers `push`/`pop` and `using for` library + // dispatch with a `T storage self` parameter. + if let ExprKind::Member(base, _) = &callee.kind { + self.mark_written(base); + } + self.mark_storage_args(expr); + } + _ => {} + } + self.walk_expr(expr) + } +} + +/// Collects, flow-insensitively, the state variables each local `storage` pointer declared in +/// `func` may reference: every assignment contributes to the pointer's target set, and pointers +/// assigned from other pointers are resolved transitively. +fn storage_aliases<'gcx>(gcx: Gcx<'gcx>, func: &'gcx Function<'gcx>) -> Aliases { + let hir = &gcx.hir; + struct Edges<'gcx> { + gcx: Gcx<'gcx>, + hir: &'gcx Hir<'gcx>, + edges: HashMap>, + } + + impl Edges<'_> { + /// Records `lhs = rhs`, matching tuple destructuring element-wise. + fn record(&mut self, lhs: &Expr<'_>, rhs: &Expr<'_>) { + match (tuple_elems(lhs), tuple_elems(rhs)) { + (Some(targets), Some(values)) => { + for (target, value) in targets.iter().zip(values) { + if let (Some(target), Some(value)) = (target, value) { + self.record(target, value); + } + } + } + _ => { + if let Some(var) = + referenced_item(self.gcx, lhs).and_then(|id| id.as_variable()) + { + self.record_var(var, rhs); + } + } + } + } + + fn record_var(&mut self, var: VariableId, rhs: &Expr<'_>) { + if is_local_storage_var(self.hir, var) { + root_vars(self.gcx, rhs, self.edges.entry(var).or_default()); + } + } + } + + impl<'gcx> Visit<'gcx> for Edges<'gcx> { + type BreakValue = Never; + + fn hir(&self) -> &'gcx Hir<'gcx> { + self.hir + } + + fn visit_stmt(&mut self, stmt: &'gcx Stmt<'gcx>) -> ControlFlow { + match &stmt.kind { + StmtKind::DeclSingle(var) => { + if let Some(init) = self.hir.variable(*var).initializer { + self.record_var(*var, init); + } + } + StmtKind::DeclMulti(vars, init) => { + for (var, value) in vars.iter().zip(tuple_elems(init).unwrap_or_default()) { + if let (Some(var), Some(value)) = (var, value) { + self.record_var(*var, value); + } + } + } + _ => {} + } + self.walk_stmt(stmt) + } + + fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow { + if let ExprKind::Assign(lhs, None, rhs) = &expr.kind { + self.record(lhs, rhs); + } + self.walk_expr(expr) + } + } + + let mut edges = Edges { gcx, hir, edges: HashMap::new() }; + let _ = edges.visit_function(func); + let edges = edges.edges; + let mut aliases = Aliases::new(); + for &pointer in edges.keys() { + let (mut targets, mut seen, mut stack) = (HashSet::new(), HashSet::new(), vec![pointer]); + while let Some(var) = stack.pop() { + if !seen.insert(var) { + continue; + } + if hir.variable(var).kind.is_state() { + targets.insert(var); + } else if let Some(roots) = edges.get(&var) { + stack.extend(roots); + } + } + if !targets.is_empty() { + aliases.insert(pointer, targets); + } + } + aliases +} + +/// The variables an expression is rooted in, through indexing, member access and ternaries. +fn root_vars(gcx: Gcx<'_>, expr: &Expr<'_>, roots: &mut HashSet) { + match &expr.peel_parens().kind { + ExprKind::Ident(_) => roots.extend(gcx.resolved_variable(expr)), + ExprKind::Index(base, _) | ExprKind::Slice(base, ..) | ExprKind::Member(base, _) => { + root_vars(gcx, base, roots) + } + ExprKind::Ternary(_, then, otherwise) => { + root_vars(gcx, then, roots); + root_vars(gcx, otherwise, roots); + } + _ => {} + } +} + +/// A bare local `storage` pointer. +fn is_storage_pointer(gcx: Gcx<'_>, expr: &Expr<'_>) -> bool { + referenced_item(gcx, expr) + .and_then(|id| id.as_variable()) + .is_some_and(|var| is_local_storage_var(&gcx.hir, var)) +} + +fn is_local_storage_var(hir: &Hir<'_>, var: VariableId) -> bool { + let var = hir.variable(var); + !var.kind.is_state() && var.data_location == Some(DataLocation::Storage) +} diff --git a/crates/lint/src/sol/med/unsafe_oz_erc721_mint.rs b/crates/lint/src/sol/med/unsafe_oz_erc721_mint.rs new file mode 100644 index 0000000000000..59b95b5de2a74 --- /dev/null +++ b/crates/lint/src/sol/med/unsafe_oz_erc721_mint.rs @@ -0,0 +1,1359 @@ +use super::UnsafeOzErc721Mint; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{ + Severity, SolLint, + analysis::{ + OPENZEPPELIN_ROOTS, arg_for_param, for_each_lhs_var, is_address_type, is_builtin, + is_literal_false, is_require_or_assert, loop_stmts, source_in_package, underlying_var, + unique, write_target, + }, + }, +}; +use alloy_primitives::U256; +use solar::{ + ast::{ElementaryType, LitKind, StateMutability, Visibility}, + interface::{Span, kw}, + sema::{ + Gcx, + hir::{ + self, BinOpKind, CallArgs, Expr, ExprKind, FunctionId, Hir, ItemId, Stmt, StmtKind, + TypeKind, VariableId, Visit, + }, + ty::{TyFn, TyKind}, + }, +}; +use std::{ops::ControlFlow, slice}; + +declare_forge_lint!( + UNSAFE_OZ_ERC721_MINT, + Severity::Med, + "unsafe-oz-erc721-mint", + "`ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`" +); + +impl<'gcx> LateLintPass<'gcx> for UnsafeOzErc721Mint { + fn check_function( + &mut self, + ctx: &LintContext, + gcx: Gcx<'gcx>, + func: &'gcx hir::Function<'gcx>, + ) { + let cx = Cx { gcx }; + // Only the canonical OZ `_safeMint` wrapper is exempt: it legitimately calls `_mint` + // next to its receiver check. A user-defined `_safeMint` override stays analyzed, since + // it can call `_mint` directly without any check. + if named(func, "_safeMint") + && func + .contract + .is_some_and(|id| is_canonical_erc721(gcx.hir.contract(id).name.as_str())) + && source_in_package(&gcx.hir, func.source, OPENZEPPELIN_ROOTS) + { + return; + } + // A user `_mint` override is part of the mint primitive itself: `super._mint` there is + // delegation (the capped/pausable pattern), and `_safeMint` there would re-enter the + // override through the virtual dispatch. A delegating override reports at its call + // sites instead, where `_safeMint` is the fix. The same holds for a helper such an + // override delegates through. + if (named(func, "_mint") && func.override_) || cx.is_override_delegation_helper(func) { + return; + } + // `ERC721._mint` credits the token without calling `onERC721Received`, so minting to a + // contract that cannot handle ERC721 tokens locks the token; `_safeMint` performs the + // check. Flag calls that resolve to a `_mint` declared in an ERC721 contract. The type + // checker's resolution already accounts for overload selection, override shadowing and + // `super._mint(...)`. + let Some(body) = &func.body else { return }; + for (callee, _, span) in cx.calls(body.stmts) { + let helper = cx.is_override_delegation_helper(gcx.hir.function(callee)); + if cx.unsafe_mint_target(callee, helper, &mut Vec::new()).is_some() { + ctx.emit(&UNSAFE_OZ_ERC721_MINT, span); + } + } + } +} + +/// An unsafe mint target and whether every recursive hop preserves the recipient and token it +/// receives. A callback guard needs both guarantees, while a code-less-recipient proof needs +/// only the first. +#[derive(Clone, Copy)] +struct UnsafeMintTarget { + preserves_recipient: bool, + preserves_token: bool, + preserves_code_length: bool, +} + +/// A resolved call: its target, arguments and span. +type Call<'gcx> = (FunctionId, &'gcx CallArgs<'gcx>, Span); + +/// The analysis context. +#[derive(Clone, Copy)] +struct Cx<'gcx> { + gcx: Gcx<'gcx>, +} + +impl<'gcx> Cx<'gcx> { + /// Whether an internal/private function is reached from a user `_mint` override of a + /// derived contract, making it part of the mint primitive rather than a call site. + fn is_override_delegation_helper(self, function: &'gcx hir::Function<'gcx>) -> bool { + if !is_internal(function) || (function.override_ && named(function, "_mint")) { + return false; + } + let Some(contract_id) = function.contract else { return false }; + let Some(function_id) = self + .gcx + .hir + .contract(contract_id) + .all_functions() + .find(|&id| std::ptr::eq(self.gcx.hir.function(id), function)) + else { + return false; + }; + self.gcx.hir.contract_ids().any(|candidate| { + let candidate = self.gcx.hir.contract(candidate); + candidate.linearized_bases.contains(&contract_id) + && candidate.all_functions().any(|id| { + let f = self.gcx.hir.function(id); + f.override_ + && named(f, "_mint") + && self.function_reaches(id, function_id, &mut Vec::new()) + }) + }) + } + + /// Whether `function_id` calls `target`, directly or through internal functions. + fn function_reaches( + self, + function_id: FunctionId, + target: FunctionId, + seen: &mut Vec, + ) -> bool { + if seen.contains(&function_id) { + return false; + } + seen.push(function_id); + let Some(body) = self.gcx.hir.function(function_id).body else { return false }; + self.calls(body.stmts).iter().any(|&(callee, ..)| { + callee == target + || (is_internal(self.gcx.hir.function(callee)) + && self.function_reaches(callee, target, seen)) + }) + } + + /// Whether `function_id` is a `_mint` whose execution skips the receiver check: the + /// canonical OZ declaration (exact OZ contract name AND an OpenZeppelin source path, so a + /// local contract reusing a name like `ERC721Consecutive` stays out), or a user override + /// whose body calls a `_mint` that is itself unsafe (the capped/pausable pattern forwarding + /// through `super._mint`). An override whose successful paths prove the recipient code-less + /// or reject it after the delegation is a safe wrapper like canonical `_safeMint`. `seen` + /// cuts override cycles, which never reach the canonical declaration. + fn unsafe_mint_target( + self, + function_id: FunctionId, + helper: bool, + seen: &mut Vec, + ) -> Option { + if seen.contains(&function_id) { + return None; + } + seen.push(function_id); + let function = self.gcx.hir.function(function_id); + let is_mint = named(function, "_mint"); + if !(is_mint || (helper && is_internal(function))) { + return None; + } + let contract = self.gcx.hir.contract(function.contract?); + if contract.kind.is_library() { + return None; + } + let canonical = is_canonical_erc721(contract.name.as_str()) + && source_in_package(&self.gcx.hir, function.source, OPENZEPPELIN_ROOTS); + if canonical && named(function, "_safeMint") { + return None; + } + // Most extensions (`ERC721Enumerable`, ...) inherit `_mint` rather than redeclare it, so + // resolution still lands here. + if canonical && is_mint { + return Some(UnsafeMintTarget { + preserves_recipient: true, + preserves_token: true, + preserves_code_length: true, + }); + } + if !(function.override_ || helper) { + return None; + } + let body = function.body.as_ref()?; + // The minted recipient is the override's first address-typed parameter. + let recipient = + function.parameters.iter().copied().find(|&vid| is_address_type(&self.gcx.hir, vid)); + let calls = self.calls(body.stmts); + // Each distinct callee is judged once, with its own copy of `seen`: a cycle is a property + // of one path, and two siblings sharing a transitive target would otherwise silence the + // second. + let mut unsafe_targets = Vec::new(); + let mut unstable_code_targets = Vec::new(); + let mut judged = Vec::new(); + let (mut targets_preserve_recipient, mut targets_preserve_token) = (true, true); + for &(callee, ..) in &calls { + if judged.contains(&callee) { + continue; + } + judged.push(callee); + if let Some(target) = self.unsafe_mint_target(callee, true, &mut seen.clone()) { + unsafe_targets.push(callee); + if !target.preserves_code_length { + unstable_code_targets.push(callee); + } + targets_preserve_recipient &= target.preserves_recipient; + targets_preserve_token &= target.preserves_token; + } + } + let delegations: Vec<_> = + calls.iter().filter(|(callee, ..)| unsafe_targets.contains(callee)).collect(); + if delegations.is_empty() { + return None; + } + // A guard covers the recipient it names, and no other. The recipient is what every + // delegation binds to the callee's first parameter, the token what it binds to the + // second, as the canonical `_mint(address to, uint256 tokenId)` orders them. + let forwards = |index: usize, var: Option| { + var.is_some_and(|var| { + delegations.iter().all(|&&(callee, args, _)| { + self.arg(callee, args, index).and_then(|expr| underlying_var(self.gcx, expr)) + == Some(var) + }) + }) + }; + let only_to_recipient = forwards(0, recipient); + // The token every delegation credits, when they all name the same variable: the + // recipient may accept one token and refuse another, so a guard is only about the token + // it was asked about. A token that is not a plain variable, or a mutable state variable + // that an intervening call may move under the guard's feet, cannot be matched. + let mut token = None; + let mut token_consistent = true; + for &&(callee, args, _) in &delegations { + let minted = self.arg(callee, args, 1).and_then(|expr| underlying_var(self.gcx, expr)); + match minted.filter(|&minted| keeps_its_value(self.gcx, minted)) { + Some(minted) => { + token_consistent &= token.is_none_or(|token| token == minted); + token = Some(minted); + } + None => token_consistent = false, + } + } + // Modifier expansion may supply a code-less proof before the body or a callback guard + // after it. The body is still read in order because reassigning the recipient or token + // can make either guard name a different value from the delegation. + let guarded = |recipient, token, seed| { + let mut walk = self.modifier_coverage_at_body(function, recipient, token, seed); + let mut walker = GuardWalker { + cx: self, + recipient, + token, + delegations: &unsafe_targets, + unstable_code_delegations: &unstable_code_targets, + seen: &mut Vec::new(), + }; + walker.walk(body.stmts, &mut walk); + !walk.failed && !walk.pending + }; + // A proof that the recipient has no code is independent of the token being minted, so + // the recipient stands in both identity slots: a callback guard cannot type-check with + // an address as its token. This lets an address-only helper or modifier establish + // coverage and permits computed or remapped token arguments. + if only_to_recipient + && targets_preserve_recipient + && let Some(recipient) = recipient + && guarded(recipient, recipient, GuardCoverage::None) + { + return None; + } + if only_to_recipient + && token_consistent + && targets_preserve_recipient + && targets_preserve_token + && let Some(recipient) = recipient + && let Some(token) = token + && guarded(recipient, token, GuardCoverage::None) + { + return None; + } + // A guard in an outer override needs every intermediate override to preserve the + // identities it relies on: the recipient for a code-less proof, and both recipient and + // token for a callback. This summary is propagated only to callers; the current + // override's own guard above may legitimately check a remapped local value. + let preserves = |index: usize| { + function.parameters.get(index).is_some_and(|&var| { + !body.stmts.iter().any(|stmt| self.mutates_var(stmt, var)) + && !function.modifiers.iter().any(|modifier| { + modifier.args.exprs().any(|arg| self.expr_mutates_var(arg, var)) + }) + && forwards(index, Some(var)) + }) + }; + // A caller's code-less proof remains valid through this override only when no path can + // change account code before reaching a delegated mint. + let preserves_code_length = recipient + .is_some_and(|recipient| guarded(recipient, recipient, GuardCoverage::CodeLess)); + Some(UnsafeMintTarget { + preserves_recipient: targets_preserve_recipient && preserves(0), + preserves_token: targets_preserve_token && preserves(1), + preserves_code_length, + }) + } + + /// Runs `stmt_matches`/`expr_matches` over a subtree and reports whether either held. + fn any_in_stmts( + self, + stmts: &'gcx [Stmt<'gcx>], + stmt_matches: impl FnMut(&'gcx Stmt<'gcx>) -> bool, + expr_matches: impl FnMut(&'gcx Expr<'gcx>) -> bool, + ) -> bool { + let mut finder = Finder { gcx: self.gcx, stmt_matches, expr_matches }; + stmts.iter().any(|stmt| finder.visit_stmt(stmt).is_break()) + } + + fn any_in_expr( + self, + expr: &'gcx Expr<'gcx>, + expr_matches: impl FnMut(&'gcx Expr<'gcx>) -> bool, + ) -> bool { + Finder { gcx: self.gcx, stmt_matches: |_| false, expr_matches }.visit_expr(expr).is_break() + } + + /// Every resolved call in a subtree, in source order. + fn calls(self, stmts: &'gcx [Stmt<'gcx>]) -> Vec> { + let mut calls = Vec::new(); + self.any_in_stmts( + stmts, + |_| false, + |expr| { + if let ExprKind::Call(_, args) = &expr.kind + && let Some(function_id) = self.resolved_callee(expr) + { + calls.push((function_id, args, expr.span)); + } + false + }, + ); + calls + } + + /// The function a call expression dispatches to, as the type checker resolved it. + fn resolved_callee(self, expr: &Expr<'_>) -> Option { + let ExprKind::Call(callee, ..) = &expr.kind else { return None }; + self.gcx.resolved_function(callee) + } + + fn callee_fn(self, expr: &Expr<'_>) -> Option<&'gcx TyFn<'gcx>> { + let ExprKind::Call(callee, ..) = &expr.kind else { return None }; + match self.gcx.type_of_expr(callee.peel_parens().id)?.kind { + TyKind::Fn(function_ty) => Some(function_ty), + _ => None, + } + } + + /// The declaration a call executes in the current EVM frame. A public function called by + /// name is internal here, while `this.f()` and other external calls run in another frame + /// whose assembly `return` cannot bypass the caller's later statements. + fn resolved_internal_callee(self, expr: &Expr<'_>) -> Option { + let function_ty = self.callee_fn(expr)?; + function_ty.is_internal().then_some(function_ty.function_id).flatten() + } + + /// Whether a call dispatches through an internal function-pointer variable whose target is + /// not available from the callee type. Such a target may contain assembly that leaves the + /// frame, so exit analysis must treat the call conservatively. + fn is_unresolved_internal_pointer_call(self, expr: &Expr<'_>) -> bool { + let ExprKind::Call(callee, ..) = &expr.kind else { return false }; + self.callee_fn(expr).is_some_and(|f| f.is_internal() && f.function_id.is_none()) + && matches!(callee.peel_parens().kind, ExprKind::Ident(_)) + && self.gcx.resolved_variable(callee).is_some() + } + + /// The argument a call binds to the callee's parameter at `index`, positional or named. + fn arg( + self, + function_id: FunctionId, + args: &'gcx CallArgs<'gcx>, + index: usize, + ) -> Option<&'gcx Expr<'gcx>> { + let function = self.gcx.hir.function(function_id); + arg_for_param(self.gcx, function_id, *function.parameters.get(index)?, args) + } + + /// Whether a resolved declaration is the ERC721 receiver hook: the exact name, the exact + /// `(address, address, uint256, bytes)` shape, and an externally callable declaration of a + /// non-library contract. A same-name function of an unrelated interface answers on a + /// different selector, and an attached library or free function runs in the minting + /// contract without any external call. + fn is_receiver_hook(self, function_id: FunctionId) -> bool { + let function = self.gcx.hir.function(function_id); + let Some(contract) = function.contract else { return false }; + let &[from, to, id, data] = function.parameters else { return false }; + let kind = |vid: VariableId| &self.gcx.hir.variable(vid).ty.kind; + named(function, "onERC721Received") + && !self.gcx.hir.contract(contract).kind.is_library() + && matches!(function.visibility, Visibility::Public | Visibility::External) + && is_address_type(&self.gcx.hir, from) + && is_address_type(&self.gcx.hir, to) + && matches!(kind(id), TypeKind::Elementary(ElementaryType::UInt(_))) + && matches!(kind(data), TypeKind::Elementary(ElementaryType::Bytes)) + } + + /// Whether an expression is the accepting answer, `onERC721Received`'s selector: the + /// literal, a conversion of it, a `constant` holding it, or a `selector` member resolving to + /// the receiver hook itself. The member is resolved rather than matched by name: spelled on + /// a same-name function of another shape, `.selector` is a different value. An `immutable` + /// or a state variable is unknown here and does not exempt. + fn is_received_selector(self, expr: &Expr<'gcx>) -> bool { + let expr = expr.peel_parens(); + match &expr.kind { + ExprKind::Lit(lit) => { + matches!(&lit.kind, LitKind::Number(value) if *value == U256::from(ERC721_RECEIVED)) + } + ExprKind::Call(callee, args) + if matches!(callee.peel_parens().kind, ExprKind::Type(..)) => + { + args.len() == 1 + && args.exprs().next().is_some_and(|inner| { + self.selector_cast_preserves(expr, inner) + && self.is_received_selector(inner) + }) + } + ExprKind::Member(base, member) => { + member.as_str() == "selector" + && self.gcx.resolved_function(base).is_some_and(|id| self.is_receiver_hook(id)) + } + // A constant is worth what it holds. + ExprKind::Ident(_) => self.gcx.resolved_variable(expr).is_some_and(|vid| { + let variable = self.gcx.hir.variable(vid); + variable.is_constant() + && variable.initializer.is_some_and(|init| self.is_received_selector(init)) + }), + _ => false, + } + } + + /// Whether a cast preserves the recognized selector's value and byte alignment. A recognized + /// integer is exactly the positive selector, so any integer width of at least 32 bits keeps + /// it. Fixed bytes are left-aligned while integers are right-aligned, so crossing between + /// them is only trusted at the four-byte boundary. + fn selector_cast_preserves(self, cast: &Expr<'_>, inner: &Expr<'_>) -> bool { + let encoding = |expr: &Expr<'_>| match self.gcx.type_of_expr(expr.peel_parens().id)?.kind { + TyKind::IntLiteral(..) => Some(SelectorEncoding::Literal), + TyKind::Elementary(ElementaryType::Int(size) | ElementaryType::UInt(size)) => { + Some(SelectorEncoding::Integer(size.bits())) + } + TyKind::Elementary(ElementaryType::FixedBytes(size)) => { + Some(SelectorEncoding::FixedBytes(size.bytes())) + } + _ => None, + }; + matches!( + (encoding(inner), encoding(cast)), + ( + Some(SelectorEncoding::Literal | SelectorEncoding::Integer(_)), + Some(SelectorEncoding::Integer(32..) | SelectorEncoding::FixedBytes(4)) + ) | (Some(SelectorEncoding::FixedBytes(4)), Some(SelectorEncoding::Integer(32))) + | ( + Some(SelectorEncoding::FixedBytes(4..)), + Some(SelectorEncoding::FixedBytes(4..)) + ) + ) + } + + /// Whether executing `stmt` always reverts, undoing everything the transaction did. Only a + /// revert counts, see [`Self::may_return`] for the escapes that leave the transaction + /// standing. + fn branch_always_reverts(self, stmt: &'gcx Stmt<'gcx>) -> bool { + match &stmt.kind { + StmtKind::Revert(_) => !self.may_return(stmt), + StmtKind::Expr(expr) => is_revert_call(self.gcx, expr) && !self.may_return(stmt), + // Read in order: a `revert` further down is only reached when nothing before it can + // leave the function on its own. + StmtKind::Block(block) | StmtKind::UncheckedBlock(block) => block + .stmts + .iter() + .find_map(|stmt| { + self.branch_always_reverts(stmt) + .then_some(true) + .or_else(|| self.may_return(stmt).then_some(false)) + }) + .unwrap_or(false), + StmtKind::If(cond, then, Some(otherwise)) => { + !self.expr_contains_frame_ending_assembly(cond) + && self.branch_always_reverts(then) + && self.branch_always_reverts(otherwise) + } + _ => false, + } + } + + /// Whether a statement may leave the function while keeping what the transaction already + /// did: a `return`, or the EVM `return`/`stop` an assembly block can hold. Only statements + /// that provably cannot leave answer no. + fn may_return(self, stmt: &'gcx Stmt<'gcx>) -> bool { + self.contains_frame_ending_assembly(slice::from_ref(stmt), &mut Vec::new()) + || match &stmt.kind { + StmtKind::Block(block) | StmtKind::UncheckedBlock(block) => { + block.stmts.iter().any(|stmt| self.may_return(stmt)) + } + StmtKind::Loop(block, source) => { + loop_stmts(*block, *source).any(|stmt| self.may_return(stmt)) + } + StmtKind::If(_, then, otherwise) => { + self.may_return(then) || otherwise.is_some_and(|stmt| self.may_return(stmt)) + } + StmtKind::Return(_) + | StmtKind::AssemblyBlock(_) + | StmtKind::Try(_) + | StmtKind::Switch(_) => true, + _ => false, + } + } + + /// Whether a subtree can reach an assembly block in the same EVM frame, directly or through + /// an internal call. An assembly `return` leaves the frame without running a later revert or + /// what an outer modifier holds after its placeholder. Every assembly block is treated as + /// capable of doing so. + fn contains_frame_ending_assembly( + self, + stmts: &'gcx [Stmt<'gcx>], + seen: &mut Vec, + ) -> bool { + self.any_in_stmts(stmts, is_assembly, |expr| self.call_leaves_frame(expr, seen)) + } + + fn expr_contains_frame_ending_assembly(self, expr: &'gcx Expr<'gcx>) -> bool { + let mut seen = Vec::new(); + self.any_in_expr(expr, |expr| self.call_leaves_frame(expr, &mut seen)) + } + + fn call_leaves_frame(self, expr: &Expr<'_>, seen: &mut Vec) -> bool { + self.is_unresolved_internal_pointer_call(expr) + || self + .resolved_internal_callee(expr) + .is_some_and(|id| self.callable_contains_frame_ending_assembly(id, seen)) + } + + /// Whether a same-frame callable or one of its applied modifiers can reach assembly. The + /// recursion set is a path stack so independent calls are summarized independently. + fn callable_contains_frame_ending_assembly( + self, + function_id: FunctionId, + seen: &mut Vec, + ) -> bool { + if seen.contains(&function_id) { + return false; + } + seen.push(function_id); + let function = self.gcx.hir.function(function_id); + let in_modifiers = function.modifiers.iter().any(|modifier| { + matches!(modifier.id, ItemId::Function(id) + if self.callable_contains_frame_ending_assembly(id, seen)) + }); + let in_body = function + .body + .as_ref() + .is_some_and(|body| self.contains_frame_ending_assembly(body.stmts, seen)); + seen.pop(); + in_modifiers || in_body + } + + /// Whether a statement assigns to `var`: `var = x`, `var += x`, `var++`, `delete var`, or + /// `var` as a component of a tuple assignment. An assembly block is treated as an opaque + /// assignment, since it can rewrite Solidity locals outside the HIR expression tree. + /// Identity is by variable, not by value, so a guard that checked `var` says nothing once + /// `var` is reassigned. + fn mutates_var(self, stmt: &'gcx Stmt<'gcx>, var: VariableId) -> bool { + self.any_in_stmts(slice::from_ref(stmt), is_assembly, |expr| { + assigns_to(self.gcx, expr, var) + }) + } + + fn expr_mutates_var(self, expr: &'gcx Expr<'gcx>, var: VariableId) -> bool { + self.any_in_expr(expr, |expr| assigns_to(self.gcx, expr, var)) + } + + /// Whether a subtree may change the code installed at an account. Inline assembly is opaque + /// and may deploy code even when it contains no HIR call expression. + fn stmts_may_change_account_code( + self, + stmts: &'gcx [Stmt<'gcx>], + delegations: &[FunctionId], + unstable_code_delegations: &[FunctionId], + seen: &mut Vec, + ) -> bool { + self.any_in_stmts(stmts, is_assembly, |expr| { + self.call_may_change_account_code(expr, delegations, unstable_code_delegations, seen) + }) + } + + fn expr_may_change_account_code( + self, + expr: &'gcx Expr<'gcx>, + delegations: &[FunctionId], + unstable_code_delegations: &[FunctionId], + seen: &mut Vec, + ) -> bool { + self.any_in_expr(expr, |expr| { + self.call_may_change_account_code(expr, delegations, unstable_code_delegations, seen) + }) + } + + /// Whether a call may change the code installed at an account. Pure and view calls are + /// stable (external ones execute through `STATICCALL`), while nonpayable/payable calls and + /// contract creation can run `CREATE`/`CREATE2`. Calls to the delegated mint itself are + /// excluded unless it is recursively unstable: the code-length proof is needed precisely + /// until that call begins. + fn call_may_change_account_code( + self, + expr: &Expr<'_>, + delegations: &[FunctionId], + unstable_code_delegations: &[FunctionId], + seen: &mut Vec, + ) -> bool { + let ExprKind::Call(callee, ..) = &expr.kind else { return false }; + let (callee, _) = callee.split_call_options(); + let resolved = self.resolved_callee(expr); + if resolved.is_some_and(|id| delegations.contains(&id)) + && !resolved.is_some_and(|id| unstable_code_delegations.contains(&id)) + { + return false; + } + if matches!(callee.kind, ExprKind::New(_)) { + return true; + } + if !self.callee_fn(expr).is_some_and(|f| { + matches!(f.state_mutability, StateMutability::NonPayable | StateMutability::Payable) + }) { + return false; + } + match self.resolved_internal_callee(expr).filter(|&id| !self.gcx.hir.function(id).virtual_) + { + Some(id) => self.callable_may_change_account_code(id, seen), + None => true, + } + } + + /// Whether a statically known same-frame callable can create code, directly, through an + /// applied modifier, or through another internal call. Recursion cycles alone do not create + /// code; any opaque, virtual, or external state-changing call reached remains conservative. + fn callable_may_change_account_code( + self, + function_id: FunctionId, + seen: &mut Vec, + ) -> bool { + if seen.contains(&function_id) { + return false; + } + seen.push(function_id); + let function = self.gcx.hir.function(function_id); + let may_change = function.modifiers.iter().any(|modifier| { + modifier.args.exprs().any(|arg| self.expr_may_change_account_code(arg, &[], &[], seen)) + }) || function.modifiers.iter().any(|modifier| { + matches!(modifier.id, ItemId::Function(id) + if self.callable_may_change_account_code(id, seen)) + }) || function + .body + .as_ref() + .is_some_and(|body| self.stmts_may_change_account_code(body.stmts, &[], &[], seen)); + seen.pop(); + may_change + } + + /// The callee parameters that receive the caller's recipient and token identities. + fn bound_guard_parameters( + self, + function_id: FunctionId, + args: &'gcx CallArgs<'gcx>, + recipient: VariableId, + token: VariableId, + ) -> Option<(VariableId, VariableId)> { + let parameters = self.gcx.hir.function(function_id).parameters; + let bound_to = |var| { + parameters + .iter() + .enumerate() + .find(|&(index, _)| { + self.arg(function_id, args, index) + .and_then(|expr| underlying_var(self.gcx, expr)) + == Some(var) + }) + .map(|(_, ¶meter)| parameter) + }; + bound_to(recipient).zip(bound_to(token)) + } + + /// Guard coverage a callee's body establishes for the parameters the recipient and the token + /// landed on: the callee guards when a guard ran before any possible successful exit. `seen` + /// cuts recursion cycles. + fn body_guards( + self, + function_id: FunctionId, + recipient: VariableId, + token: VariableId, + seen: &mut Vec, + ) -> GuardCoverage { + if seen.contains(&function_id) { + return GuardCoverage::None; + } + seen.push(function_id); + let function = self.gcx.hir.function(function_id); + // A `virtual` callee may be replaced by an override that drops the guard, and a helper + // carrying modifiers is not credited until their expansion is modeled: one may skip the + // placeholder and let the helper return without ever running its body. The caller + // relies on the values it passed in, so a body that mutates either bound parameter is + // rejected too. + let guarded = match &function.body { + Some(body) + if !function.virtual_ + && function.modifiers.is_empty() + && !body.stmts.iter().any(|stmt| { + self.mutates_var(stmt, recipient) || self.mutates_var(stmt, token) + }) => + { + let mut walk = GuardWalk::default(); + let mut walker = GuardWalker { + cx: self, + recipient, + token, + delegations: &[], + unstable_code_delegations: &[], + seen, + }; + walker.walk(body.stmts, &mut walk); + if walk.escaped { + GuardCoverage::None + } else if walk.future_coverage == GuardCoverage::CodeLess { + GuardCoverage::CodeLess + } else if walk.coverage == GuardCoverage::CodeLess { + // The code-less observation can discharge a mint that preceded the helper, + // but later work invalidated it for a mint after the helper. The mixed + // marker is treated by callers as discharge-only, like a callback. + GuardCoverage::CallbackOrCodeLess + } else { + walk.coverage + } + } + _ => GuardCoverage::None, + }; + seen.pop(); + guarded + } + + /// Coverage in effect when a function body starts after expanding its modifiers in + /// declaration order. Prefixes are walked in execution order so calls in an inner modifier + /// can retire an outer code-length snapshot. A proven tail guard is represented as stable + /// callback coverage while walking the body: it runs after the body and can revert every + /// mint the body made, unless assembly in the body or an inner modifier can bypass it. + fn modifier_coverage_at_body( + self, + function: &'gcx hir::Function<'gcx>, + recipient: VariableId, + token: VariableId, + seed: GuardCoverage, + ) -> GuardWalk { + let mut state = GuardWalk { coverage: seed, future_coverage: seed, ..GuardWalk::default() }; + let body_bypass = function + .body + .as_ref() + .is_some_and(|body| self.contains_frame_ending_assembly(body.stmts, &mut Vec::new())); + let mut has_tail_guard = false; + for (index, modifier) in function.modifiers.iter().enumerate() { + if modifier.args.exprs().any(|arg| { + self.expr_mutates_var(arg, recipient) || self.expr_mutates_var(arg, token) + }) { + state.coverage = GuardCoverage::None; + state.future_coverage = GuardCoverage::None; + has_tail_guard = false; + } + state.retire_code_snapshots_if(|| { + modifier + .args + .exprs() + .any(|arg| self.expr_may_change_account_code(arg, &[], &[], &mut Vec::new())) + }); + let ItemId::Function(modifier_id) = modifier.id else { continue }; + let Some(body) = &self.gcx.hir.function(modifier_id).body else { continue }; + let Some((prefix, suffix)) = modifier_body_sides(body.stmts) else { + // Without a single top-level placeholder, the precise prefix is unknown. Still + // retire an inherited snapshot when any path through the modifier may change + // code. + state.retire_code_snapshots_if(|| { + self.stmts_may_change_account_code(body.stmts, &[], &[], &mut Vec::new()) + }); + continue; + }; + let prefix_may_change_code = + || self.stmts_may_change_account_code(prefix, &[], &[], &mut Vec::new()); + let Some((modifier_recipient, modifier_token)) = + self.bound_guard_parameters(modifier_id, &modifier.args, recipient, token) + else { + state.retire_code_snapshots_if(prefix_may_change_code); + continue; + }; + let parameters_unchanged = !body.stmts.iter().any(|stmt| { + self.mutates_var(stmt, modifier_recipient) || self.mutates_var(stmt, modifier_token) + }); + let mut walker = GuardWalker { + cx: self, + recipient: modifier_recipient, + token: modifier_token, + delegations: &[], + unstable_code_delegations: &[], + seen: &mut Vec::new(), + }; + if parameters_unchanged { + walker.walk(prefix, &mut state); + } else { + state.retire_code_snapshots_if(prefix_may_change_code); + } + let inner_modifier_bypass = function.modifiers[index + 1..].iter().any(|inner| { + matches!(inner.id, ItemId::Function(id) + if self.callable_contains_frame_ending_assembly(id, &mut Vec::new())) + }); + if parameters_unchanged && !body_bypass && !inner_modifier_bypass { + // The body has already minted when the suffix starts. Seed one pending + // delegation: a guard anywhere before a successful suffix exit clears it, while a + // call after that guard cannot make the earlier mint retroactively unsafe. + let mut suffix_walk = GuardWalk { pending: true, ..GuardWalk::default() }; + walker.walk(suffix, &mut suffix_walk); + has_tail_guard |= !suffix_walk.failed && !suffix_walk.pending; + } + } + if has_tail_guard { + state.cover(GuardCoverage::Callback, true); + } + GuardWalk { + coverage: state.coverage, + future_coverage: state.future_coverage, + ..GuardWalk::default() + } + } +} + +/// Breaks out of a subtree at the first statement or expression matching a predicate. +struct Finder<'gcx, S, E> { + gcx: Gcx<'gcx>, + stmt_matches: S, + expr_matches: E, +} + +impl<'gcx, S, E> Visit<'gcx> for Finder<'gcx, S, E> +where + S: FnMut(&'gcx Stmt<'gcx>) -> bool, + E: FnMut(&'gcx Expr<'gcx>) -> bool, +{ + type BreakValue = (); + + fn hir(&self) -> &'gcx Hir<'gcx> { + &self.gcx.hir + } + + fn visit_stmt(&mut self, stmt: &'gcx Stmt<'gcx>) -> ControlFlow<()> { + if (self.stmt_matches)(stmt) { ControlFlow::Break(()) } else { self.walk_stmt(stmt) } + } + + fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow<()> { + if (self.expr_matches)(expr) { ControlFlow::Break(()) } else { self.walk_expr(expr) } + } +} + +/// How a path established that the recipient can receive the mint. Callback evidence remains +/// valid when summarizing a guard helper, while a code-less proof must be retired once a call +/// could deploy code at the recipient address. Whether the evidence can cover a future mint is +/// tracked separately by [`GuardWalk::future_coverage`]. +#[derive(Clone, Copy, Default, PartialEq, Eq)] +enum GuardCoverage { + #[default] + None, + Callback, + CodeLess, + CallbackOrCodeLess, +} + +impl GuardCoverage { + fn is_covered(self) -> bool { + self != Self::None + } + + const fn relies_on_code_length(self) -> bool { + matches!(self, Self::CodeLess | Self::CallbackOrCodeLess) + } + + /// The coverage guaranteed after either branch. If one path relies on a code-less proof, + /// the merged proof does too and remains invalidatable by a later call. + const fn merge_paths(self, other: Self) -> Self { + match (self, other) { + (Self::None, _) | (_, Self::None) => Self::None, + (Self::Callback, Self::Callback) => Self::Callback, + (Self::CodeLess, Self::CodeLess) => Self::CodeLess, + _ => Self::CallbackOrCodeLess, + } + } + + /// Coverage from guards that all execute. A callback check remains valid when a later call + /// invalidates a separate code-length observation. + const fn combine_guards(self, other: Self) -> Self { + match (self, other) { + (Self::Callback, _) | (_, Self::Callback) => Self::Callback, + (Self::CallbackOrCodeLess, _) | (_, Self::CallbackOrCodeLess) => { + Self::CallbackOrCodeLess + } + (Self::CodeLess, _) | (_, Self::CodeLess) => Self::CodeLess, + _ => Self::None, + } + } +} + +/// The straight-line reading of a body: `coverage` once a guard has run on the path, `pending` +/// while a delegated mint has run with no guard before or after it yet, `failed` once a path +/// may leave the function successfully with such a mint standing, `escaped` once one may leave +/// before any guard ran. +#[derive(Clone, Default)] +struct GuardWalk { + /// The guards that have run on every path. This is used when summarizing a guard helper. + coverage: GuardCoverage, + /// Coverage that can satisfy a delegation encountered later. A code-less proof can precede + /// the mint; callback coverage can only appear here when a modifier tail guarantees that the + /// callback runs after the body. + future_coverage: GuardCoverage, + pending: bool, + failed: bool, + escaped: bool, +} + +impl GuardWalk { + /// A guard ran: `coverage` is established, and also for later delegations when `future`. + const fn cover(&mut self, coverage: GuardCoverage, future: bool) { + self.coverage = self.coverage.combine_guards(coverage); + if future { + self.future_coverage = self.future_coverage.combine_guards(coverage); + } + self.pending = false; + } + + /// The recipient or token was reassigned: every guard so far checked a value a later + /// delegation no longer credits, and a mint already pending cannot be covered by a guard to + /// come either. + const fn retire(&mut self) { + self.failed |= self.pending; + self.coverage = GuardCoverage::None; + self.future_coverage = GuardCoverage::None; + } + + /// The path may leave the function successfully here. + fn escape(&mut self) { + self.failed |= self.pending; + self.escaped |= !self.coverage.is_covered(); + } + + /// Retires code-length snapshots when `may_change_code()` holds; a callback acknowledgement + /// is not a snapshot and stays. The check only runs when a snapshot exists. + fn retire_code_snapshots_if(&mut self, may_change_code: impl FnOnce() -> bool) { + let (coverage, future) = + (self.coverage.relies_on_code_length(), self.future_coverage.relies_on_code_length()); + if (coverage || future) && may_change_code() { + if coverage { + self.coverage = GuardCoverage::None; + } + if future { + self.future_coverage = GuardCoverage::None; + } + } + } + + /// The state after either of two branches: coverage holds only when every path checked, + /// while a pending or escaping path taints the whole. + const fn merge(self, other: Self) -> Self { + Self { + coverage: self.coverage.merge_paths(other.coverage), + future_coverage: self.future_coverage.merge_paths(other.future_coverage), + pending: self.pending || other.pending, + failed: self.failed || other.failed, + escaped: self.escaped || other.escaped, + } + } +} + +/// Reads a body in statement order and judges the delegated mints against the guards for +/// `recipient` and `token`. A code-less proof may cover a later delegation, but a callback must +/// run after ownership is established to match `_safeMint`: the receiver can inspect `ownerOf`, +/// balances, or reenter during the hook. Such a callback covers delegations still pending, the +/// revert undoing them, unless a statement in between may leave the function successfully, +/// keeping the unacknowledged token: `super._mint(to, id); if (id == 0) return; require(hook...)` +/// walks out with token zero standing. +/// +/// The recognized guard shapes are a closed set, because a hook call that merely appears inside +/// a condition proves nothing about whether the revert depends on its answer. They are: +/// `require`/`assert` on an acceptance condition, `if (hook != selector) `, +/// `if (hook == selector) {} else `, and any of those reached through a function or +/// modifier. A callback helper receives both identities, the way OpenZeppelin factors +/// `_checkOnERC721Received` out of `_safeMint`; a code-less proof needs only the recipient. +/// +/// Branches are read separately and merged. The branch a `to.code.length` test dedicates to +/// accounts starts covered, an account always accepting the token. A loop body may run zero +/// times, so nothing in one is credited, while the delegations and escapes it may hold still +/// count. +/// +/// Everything else reports, a `try` whose `catch` may swallow the refusal included, and so are +/// an answer stored in a local and a helper returning it as a `bool`. Following the value +/// across statements would take a dataflow analysis this detector does not run. +struct GuardWalker<'a, 'gcx> { + cx: Cx<'gcx>, + recipient: VariableId, + token: VariableId, + /// The callees that are unsafe mints, and those among them that may change account code. + delegations: &'a [FunctionId], + unstable_code_delegations: &'a [FunctionId], + seen: &'a mut Vec, +} + +impl<'gcx> GuardWalker<'_, 'gcx> { + fn walk(&mut self, stmts: &'gcx [Stmt<'gcx>], walk: &mut GuardWalk) { + let cx = self.cx; + // Read in order: what a guard covers and what an exit walks out with depend on what + // already ran. + for stmt in stmts { + let guard = match &stmt.kind { + StmtKind::Expr(expr) => self.guard_expr_coverage(expr), + _ => GuardCoverage::None, + }; + match &stmt.kind { + StmtKind::Block(block) | StmtKind::UncheckedBlock(block) => { + self.walk(block.stmts, walk); + } + StmtKind::Expr(expr) if guard.is_covered() => { + // A guard that also reassigns the recipient or the token cannot be trusted: + // evaluation order decides whether the hook read the value the mint credits. + // Nor can a guard that may leave the frame establish coverage: an assembly + // return in another argument can keep a pending mint before the builtin has + // a chance to revert. + if self.mutates(stmt) { + walk.retire(); + } else if cx.may_return(stmt) { + walk.escape(); + } else if guard.relies_on_code_length() + && self.guard_extra_args_may_change_account_code(expr) + { + if walk.future_coverage.relies_on_code_length() { + walk.future_coverage = GuardCoverage::None; + } + } else { + walk.cover(guard, guard == GuardCoverage::CodeLess); + } + } + StmtKind::If(cond, then, otherwise) => { + // The condition runs before either branch, so an assignment embedded in it, + // `if ((tokenId = tokenId + 1) > 0) {}`, retires coverage exactly as a bare + // assignment statement does, and prevents the comparison from covering. + let condition_mutates = cx.expr_mutates_var(cond, self.recipient) + || cx.expr_mutates_var(cond, self.token); + if condition_mutates { + walk.retire(); + } + if walk.future_coverage.relies_on_code_length() + && self.may_change_account_code(slice::from_ref(stmt), Some(cond)) + { + walk.future_coverage = GuardCoverage::None; + } + if cx.expr_contains_frame_ending_assembly(cond) { + walk.escape(); + } + // The exiting branch must be the one a refusal takes, not the one an + // acceptance does. Continue reading the accepted branch from the covered + // state: it may still reassign the checked values before delegating. + let refusal_then = !condition_mutates + && self.is_hook_comparison(cond, BinOpKind::Ne) + && cx.branch_always_reverts(then); + let refusal_else = !condition_mutates + && self.is_hook_comparison(cond, BinOpKind::Eq) + && otherwise.is_some_and(|otherwise| cx.branch_always_reverts(otherwise)); + if refusal_then || refusal_else { + walk.cover(GuardCoverage::Callback, false); + let accepted = if refusal_then { *otherwise } else { Some(*then) }; + if let Some(accepted) = accepted { + self.walk(slice::from_ref(accepted), walk); + } + continue; + } + // Each branch is read on its own, from what already ran. The branch a + // `to.code.length` test dedicates to accounts starts covered with nothing + // pending: an account always accepts, so the mints already made are as + // satisfied on that path as the ones to come. + let mut then_walk = walk.clone(); + let mut else_walk = walk.clone(); + if self.is_code_length_test(cond, true) { + else_walk.cover(GuardCoverage::CodeLess, true); + } else if self.is_code_length_test(cond, false) { + then_walk.cover(GuardCoverage::CodeLess, true); + } + self.walk(slice::from_ref(then), &mut then_walk); + if let Some(otherwise) = otherwise { + self.walk(slice::from_ref(otherwise), &mut else_walk); + } + *walk = then_walk.merge(else_walk); + } + _ => { + // Reassignment runs first: an assignment inside a delegation's own arguments + // happens before the call. + if self.mutates(stmt) { + walk.retire(); + } + // Unlike a callback acknowledgement, a code-length observation is only a + // snapshot. A later state-changing call can deploy code at that address, so + // it retires coverage for a subsequent delegation. A mint already discharged + // by the observation remains safe. + if walk.future_coverage.relies_on_code_length() + && self.may_change_account_code(slice::from_ref(stmt), None) + { + walk.future_coverage = GuardCoverage::None; + } + // An opaque statement: a delegation anywhere inside it mints, unchecked + // unless already covered, and a possible successful exit walks out with + // whatever is pending. + let delegations = self.delegations; + if !walk.future_coverage.is_covered() + && cx.any_in_stmts( + slice::from_ref(stmt), + |_| false, + |expr| { + cx.resolved_callee(expr).is_some_and(|id| delegations.contains(&id)) + }, + ) + { + walk.pending = true; + } + if cx.may_return(stmt) { + walk.escape(); + } + } + } + } + } + + fn mutates(&self, stmt: &'gcx Stmt<'gcx>) -> bool { + self.cx.mutates_var(stmt, self.recipient) || self.cx.mutates_var(stmt, self.token) + } + + /// [`Cx::stmts_may_change_account_code`] for the walked delegations, over a statement or + /// only the given expression of it. + fn may_change_account_code( + &self, + stmts: &'gcx [Stmt<'gcx>], + expr: Option<&'gcx Expr<'gcx>>, + ) -> bool { + let (delegations, unstable) = (self.delegations, self.unstable_code_delegations); + let mut seen = Vec::new(); + match expr { + Some(expr) => { + self.cx.expr_may_change_account_code(expr, delegations, unstable, &mut seen) + } + None => self.cx.stmts_may_change_account_code(stmts, delegations, unstable, &mut seen), + } + } + + /// A statement expression that guards the recipient and the token: `require`/`assert` on an + /// acceptance condition, or an internal call handing both to a helper that does. Only the + /// condition is read: a hook call sitting in the revert message decides nothing. An external + /// helper would ask from a different contract and cannot establish that the recipient + /// accepts the minting contract's callback. + fn guard_expr_coverage(&mut self, expr: &'gcx Expr<'gcx>) -> GuardCoverage { + let expr = expr.peel_parens(); + let ExprKind::Call(callee, args) = &expr.kind else { return GuardCoverage::None }; + if is_require_or_assert(self.cx.gcx, callee) { + return args + .exprs() + .next() + .map_or(GuardCoverage::None, |cond| self.acceptance_coverage(cond)); + } + let Some(function_id) = self.cx.resolved_internal_callee(expr) else { + return GuardCoverage::None; + }; + let Some((recipient, token)) = + self.cx.bound_guard_parameters(function_id, args, self.recipient, self.token) + else { + return GuardCoverage::None; + }; + self.cx.body_guards(function_id, recipient, token, self.seen) + } + + /// Whether a recognized `require`/`assert` has another argument that may change account + /// code. The first argument is the closed-form acceptance condition itself; its receiver + /// callback is part of the proof. Solidity does not guarantee that the other arguments run + /// before the condition's code-length snapshot. + fn guard_extra_args_may_change_account_code(&self, expr: &'gcx Expr<'gcx>) -> bool { + let ExprKind::Call(callee, args) = &expr.peel_parens().kind else { return false }; + is_require_or_assert(self.cx.gcx, callee) + && args.exprs().skip(1).any(|arg| self.may_change_account_code(&[], Some(arg))) + } + + /// The condition of a `require`/`assert` that passes only if the recipient can receive the + /// token: the recipient is proven code-less, the hook comparison succeeds, or the short + /// circuit `to.code.length == 0 || hook == sel` accepts either case. + fn acceptance_coverage(&self, cond: &'gcx Expr<'gcx>) -> GuardCoverage { + let cond = cond.peel_parens(); + if self.is_code_length_test(cond, false) { + return GuardCoverage::CodeLess; + } + if self.is_hook_comparison(cond, BinOpKind::Eq) { + return GuardCoverage::Callback; + } + let ExprKind::Binary(lhs, op, rhs) = &cond.kind else { return GuardCoverage::None }; + let accepts = |skip, check| { + self.is_code_length_test(skip, false) && self.is_hook_comparison(check, BinOpKind::Eq) + }; + if op.kind == BinOpKind::Or && (accepts(lhs, rhs) || accepts(rhs, lhs)) { + GuardCoverage::CallbackOrCodeLess + } else { + GuardCoverage::None + } + } + + /// `recipient.onERC721Received(..., token, ...)`: the hook, asked of the recipient itself and + /// about the delegated token itself. An answer about a different id decides nothing for the + /// minted one. + fn is_hook_call_on(&self, expr: &'gcx Expr<'gcx>) -> bool { + let expr = expr.peel_parens(); + let Some((callee, args, _)) = expr.as_call() else { return false }; + let ExprKind::Member(receiver, _) = &callee.peel_parens().kind else { return false }; + let Some(function_id) = self.cx.resolved_callee(expr) else { return false }; + self.cx.is_receiver_hook(function_id) + && underlying_var(self.cx.gcx, receiver) == Some(self.recipient) + && self.cx.arg(function_id, args, 2).and_then(|expr| underlying_var(self.cx.gcx, expr)) + == Some(self.token) + } + + /// `recipient.onERC721Received(...) x`, and nothing else. The comparison must be the + /// whole expression: in `to == trusted || hook(to) == selector` the hook never runs for + /// `trusted`. The other operand must be able to hold the accepting answer, and must not be a + /// hook call itself, which would compare the recipient against itself. + fn is_hook_comparison(&self, expr: &'gcx Expr<'gcx>, want: BinOpKind) -> bool { + let ExprKind::Binary(lhs, op, rhs) = &expr.peel_parens().kind else { return false }; + let compares = |hook, answer| { + self.is_hook_call_on(hook) + && !self.is_hook_call_on(answer) + && self.cx.is_received_selector(answer) + }; + op.kind == want && (compares(lhs, rhs) || compares(rhs, lhs)) + } + + /// `recipient.code.length` compared against zero, for one polarity. Nothing else may ride + /// along: in `to.code.length > 0 && id == 5` the second operand decides whether the branch + /// runs. + fn is_code_length_test(&self, expr: &'gcx Expr<'gcx>, has_code: bool) -> bool { + let ExprKind::Binary(lhs, op, rhs) = &expr.peel_parens().kind else { return false }; + let is_code_length = |expr: &Expr<'_>| { + let ExprKind::Member(code, length) = &expr.peel_parens().kind else { return false }; + let ExprKind::Member(base, member) = &code.peel_parens().kind else { return false }; + length.as_str() == "length" + && member.as_str() == "code" + && underlying_var(self.cx.gcx, base) == Some(self.recipient) + }; + let literal = |expr: &Expr<'_>| match &expr.peel_parens().kind { + ExprKind::Lit(lit) => match &lit.kind { + LitKind::Number(value) => u8::try_from(*value).ok(), + _ => None, + }, + _ => None, + }; + let (bound, flipped) = if is_code_length(lhs) { + (literal(rhs), false) + } else if is_code_length(rhs) { + (literal(lhs), true) + } else { + return false; + }; + let Some(bound) = bound else { return false }; + // `length > 0`, `length != 0` and `length >= 1` all say the recipient carries code; + // `== 0`, `< 1` and `<= 0` all say it carries none. Each has a mirror with the operands + // swapped. + match (has_code, op.kind, flipped) { + (true, BinOpKind::Ne, _) + | (true, BinOpKind::Gt, false) + | (true, BinOpKind::Lt, true) + | (false, BinOpKind::Eq, _) + | (false, BinOpKind::Le, false) + | (false, BinOpKind::Ge, true) => bound == 0, + (true, BinOpKind::Ge, false) + | (true, BinOpKind::Le, true) + | (false, BinOpKind::Lt, false) + | (false, BinOpKind::Gt, true) => bound == 1, + _ => false, + } + } +} + +/// The representation of a selector-sized constant at one conversion step. +#[derive(Clone, Copy)] +enum SelectorEncoding { + Literal, + Integer(u16), + FixedBytes(u8), +} + +/// The ERC721 answer meaning the recipient accepts the token, `onERC721Received`'s selector. +const ERC721_RECEIVED: u64 = 0x150b_7a02; + +/// The OpenZeppelin contracts whose `_mint` skips the receiver check. `ERC721` and +/// `ERC721Upgradeable` declare the unchecked `_mint`; in the v4 line, `ERC721Consecutive` and +/// `ERC721ConsecutiveUpgradeable` override it with a construction guard that forwards to the +/// base through `super._mint`, still without a receiver check. In v5 the Consecutive extension +/// overrides `_update` instead, and the two extra names match nothing. +fn is_canonical_erc721(name: &str) -> bool { + matches!( + name, + "ERC721" | "ERC721Upgradeable" | "ERC721Consecutive" | "ERC721ConsecutiveUpgradeable" + ) +} + +fn named(function: &hir::Function<'_>, name: &str) -> bool { + function.name.is_some_and(|n| n.as_str() == name) +} + +const fn is_internal(function: &hir::Function<'_>) -> bool { + matches!(function.visibility, Visibility::Internal | Visibility::Private) +} + +const fn is_assembly(stmt: &Stmt<'_>) -> bool { + matches!(stmt.kind, StmtKind::AssemblyBlock(_)) +} + +/// Whether the variable cannot change between the delegation and the callback guard. An +/// intervening call can reenter and mutate a state variable after the mint reads it but before +/// the guard does. A local, a parameter, a `constant` or an `immutable` cannot be moved that way. +fn keeps_its_value(gcx: Gcx<'_>, variable: VariableId) -> bool { + let variable = gcx.hir.variable(variable); + !variable.kind.is_state() || variable.mutability.is_some() +} + +/// Whether an expression writes `var`: `var = x`, `var += x`, `var++` or `delete var`, directly +/// or as one component of a tuple. +fn assigns_to(gcx: Gcx<'_>, expr: &Expr<'_>, var: VariableId) -> bool { + let Some(target) = write_target(expr) else { return false }; + let mut hit = false; + for_each_lhs_var(gcx, target, &mut |vid| hit |= vid == var); + hit +} + +/// `revert(...)`, `require(false, ...)` and `assert(false)`. +fn is_revert_call(gcx: Gcx<'_>, expr: &Expr<'_>) -> bool { + let ExprKind::Call(callee, args) = &expr.peel_parens().kind else { return false }; + is_builtin(gcx, callee, kw::Revert) + || (is_require_or_assert(gcx, callee) && args.exprs().next().is_some_and(is_literal_false)) +} + +/// The statements before and after a modifier's single top-level placeholder. More complicated +/// expansion shapes are left uncredited rather than guessing which paths execute the body. +fn modifier_body_sides<'gcx>( + stmts: &'gcx [Stmt<'gcx>], +) -> Option<(&'gcx [Stmt<'gcx>], &'gcx [Stmt<'gcx>])> { + let placeholders = + stmts.iter().enumerate().filter(|(_, stmt)| matches!(stmt.kind, StmtKind::Placeholder)); + let index = unique(placeholders.map(|(index, _)| index))?; + Some((&stmts[..index], &stmts[index + 1..])) +} diff --git a/crates/lint/src/sol/med/unsafe_typecast.rs b/crates/lint/src/sol/med/unsafe_typecast.rs new file mode 100644 index 0000000000000..03171546935be --- /dev/null +++ b/crates/lint/src/sol/med/unsafe_typecast.rs @@ -0,0 +1,109 @@ +use super::UnsafeTypecast; +use crate::{ + linter::{LateLintPass, LintContext, Suggestion}, + sol::{Severity, SolLint, analysis::cast_type}, +}; +use solar::{ + ast::{BinOpKind, LitKind, StrKind}, + sema::{ + Gcx, + hir::{self, ElementaryType, ExprKind}, + ty::TyKind, + }, +}; + +declare_forge_lint!( + UNSAFE_TYPECAST, + Severity::Med, + "unsafe-typecast", + "typecast can truncate values" +); + +impl<'gcx> LateLintPass<'gcx> for UnsafeTypecast { + fn check_expr(&mut self, ctx: &LintContext, gcx: Gcx<'gcx>, expr: &'gcx hir::Expr<'gcx>) { + if let ExprKind::Call(call, args) = &expr.kind + && let Some(ty) = cast_type(call) + && args.len() == 1 + && let Some(arg) = args.exprs().next() + && !is_bounded_by_mask(arg, ty) + { + let mut sources = Vec::new(); + source_types(gcx, arg, &mut sources); + if sources.iter().any(|source| is_unsafe_elementary_typecast(*source, ty)) { + ctx.emit_with_suggestion( + &UNSAFE_TYPECAST, + expr.span, + Suggestion::example(format!( + "// casting to '{}' is safe because [explain why]\n// forge-lint: disable-next-line(unsafe-typecast)", + ty.to_abi_str() + )) + .with_desc("consider disabling this lint if you're certain the cast is safe"), + ); + } + } + } +} + +/// `x & MASK` where the mask literal fits the unsigned target bounds the value to its range. +fn is_bounded_by_mask(source: &hir::Expr<'_>, target: ElementaryType) -> bool { + let ElementaryType::UInt(target_size) = target else { return false }; + let ExprKind::Binary(lhs, op, rhs) = &source.peel_parens().kind else { return false }; + op.kind == BinOpKind::BitAnd + && [lhs, rhs].into_iter().any(|expr| { + matches!( + expr.peel_parens().kind, + ExprKind::Lit(hir::Lit { kind: LitKind::Number(mask), .. }) + if mask.bit_len() <= target_size.bits() as usize + ) + }) +} + +/// Collects the ultimate elementary source type(s) of `expr` into `out`, looking through cast +/// chains and unary operators and gathering both sides of binary operations. +fn source_types<'gcx>(gcx: Gcx<'gcx>, expr: &hir::Expr<'gcx>, out: &mut Vec) { + match &expr.kind { + ExprKind::Call(callee, args) if cast_type(callee).is_some() => { + if let Some(inner) = args.exprs().next() { + source_types(gcx, inner, out); + } + } + // Solar types string literals as literal types rather than `string`/`bytes`. + ExprKind::Lit(hir::Lit { kind: LitKind::Str(StrKind::Hex, ..), .. }) => { + out.push(ElementaryType::Bytes) + } + ExprKind::Lit(hir::Lit { kind: LitKind::Str(..), .. }) => out.push(ElementaryType::String), + ExprKind::Unary(_, inner) => source_types(gcx, inner, out), + ExprKind::Binary(lhs, _, rhs) => { + source_types(gcx, lhs, out); + source_types(gcx, rhs, out); + } + _ => { + if let Some(ty) = gcx.type_of_expr(expr.peel_parens().id) { + match ty.peel_refs().kind { + TyKind::Elementary(ty) => out.push(ty), + TyKind::StringLiteral(true, _) => out.push(ElementaryType::String), + TyKind::StringLiteral(false, _) => out.push(ElementaryType::Bytes), + _ => {} + } + } + } + } +} + +/// Whether casting `source` to `target` can lose data, precision or sign. +const fn is_unsafe_elementary_typecast(source: ElementaryType, target: ElementaryType) -> bool { + match (source, target) { + (ElementaryType::UInt(from), ElementaryType::UInt(to)) + | (ElementaryType::Int(from), ElementaryType::Int(to)) => from.bits() > to.bits(), + (ElementaryType::Int(_), ElementaryType::UInt(_)) => true, + (ElementaryType::UInt(from), ElementaryType::Int(to)) => from.bits() >= to.bits(), + (ElementaryType::FixedBytes(from), ElementaryType::FixedBytes(to)) => { + from.bytes() > to.bytes() + } + (ElementaryType::Bytes | ElementaryType::String, ElementaryType::FixedBytes(_)) => true, + // `address` is 160 bits. + (ElementaryType::Address(_), ElementaryType::UInt(to)) => to.bits() < 160, + (ElementaryType::Address(_), ElementaryType::Int(_)) => true, + _ => false, + } +} diff --git a/crates/lint/src/sol/med/unused_return.rs b/crates/lint/src/sol/med/unused_return.rs new file mode 100644 index 0000000000000..77ae1d11c34da --- /dev/null +++ b/crates/lint/src/sol/med/unused_return.rs @@ -0,0 +1,74 @@ +use super::UnusedReturn; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{ + Severity, SolLint, + analysis::{is_elementary, tuple_elems}, + }, +}; +use solar::sema::{ + Gcx, + hir::{Expr, ExprKind, Stmt, StmtKind}, + ty::{TyFnKind, TyKind}, +}; + +declare_forge_lint!( + UNUSED_RETURN, + Severity::Med, + "unused-return", + "return value of an external call is not used" +); + +impl<'gcx> LateLintPass<'gcx> for UnusedReturn { + fn check_stmt(&mut self, ctx: &LintContext, gcx: Gcx<'gcx>, stmt: &'gcx Stmt<'gcx>) { + let (call, span) = match &stmt.kind { + StmtKind::Expr(expr) => match &expr.peel_parens().kind { + // `(x, ) = call()` with an ignored slot. + ExprKind::Assign(lhs, None, rhs) + if tuple_elems(lhs).is_some_and(|e| e.iter().any(Option::is_none)) => + { + (rhs, expr.span) + } + _ => (expr, expr.span), + }, + StmtKind::DeclMulti(vars, expr) if vars.iter().any(Option::is_none) => { + (expr, expr.span) + } + _ => return, + }; + if is_unused_return_call(gcx, call) { + ctx.emit(&UNUSED_RETURN, span); + } + } +} + +/// True if `expr` is an external call whose selected function has return values, excluding ERC20 +/// `transfer`/`transferFrom` member calls (covered by `erc20-unchecked-transfer`). +fn is_unused_return_call<'gcx>(gcx: Gcx<'gcx>, expr: &Expr<'gcx>) -> bool { + let ExprKind::Call(callee, ..) = &expr.peel_parens().kind else { return false }; + let Some(TyKind::Fn(function_ty)) = gcx.type_of_expr(callee.peel_parens().id).map(|ty| ty.kind) + else { + return false; + }; + if !matches!(function_ty.kind(), TyFnKind::External | TyFnKind::DelegateCall) + || function_ty.returns.is_empty() + { + return false; + } + + let ExprKind::Member(_, name) = &callee.peel_parens().kind else { return true }; + let Some(fid) = gcx.resolved_function(callee) else { return false }; + let f = gcx.hir.function(fid); + + let sig = |vars: &[_], expected: &[&str]| { + vars.len() == expected.len() + && vars.iter().zip(expected).all(|(&id, &ty)| is_elementary(&gcx.hir, id, ty)) + }; + let is_erc20_transfer = sig(f.returns, &["bool"]) + && match name.as_str() { + "transfer" => sig(f.parameters, &["address", "uint256"]), + "transferFrom" => sig(f.parameters, &["address", "address", "uint256"]), + _ => false, + }; + !is_erc20_transfer +} diff --git a/crates/lint/src/sol/med/weak_prng.rs b/crates/lint/src/sol/med/weak_prng.rs new file mode 100644 index 0000000000000..c5fd4e478cbdc --- /dev/null +++ b/crates/lint/src/sol/med/weak_prng.rs @@ -0,0 +1,121 @@ +use super::WeakPrng; +use crate::{ + linter::{LateLintPass, LintContext}, + sol::{Severity, SolLint}, +}; +use alloy_primitives::{U256, uint}; +use solar::{ + ast::{BinOp, BinOpKind}, + sema::{ + Gcx, + builtins::Builtin, + hir::{Expr, ExprKind, Hir, SourceId, Visit}, + }, +}; +use std::ops::ControlFlow; + +declare_forge_lint!( + WEAK_PRNG, + Severity::Med, + "weak-prng", + "weak randomness derived from a predictable on-chain value" +); + +impl<'gcx> LateLintPass<'gcx> for WeakPrng { + fn check_nested_source(&mut self, ctx: &LintContext, gcx: Gcx<'gcx>, id: SourceId) { + if ctx.is_lint_enabled(WEAK_PRNG.id) { + let _ = WeakPrngChecker { ctx, gcx }.visit_nested_source(id); + } + } +} + +struct WeakPrngChecker<'a, 's, 'gcx> { + ctx: &'a LintContext<'s, 'a>, + gcx: Gcx<'gcx>, +} + +impl<'gcx> Visit<'gcx> for WeakPrngChecker<'_, '_, 'gcx> { + type BreakValue = (); + + fn hir(&self) -> &'gcx Hir<'gcx> { + &self.gcx.hir + } + + /// Emits once per outermost `<..> % <..>` or `keccak256(..)` fed by a predictable source. + fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow<()> { + let is_randomness = match &expr.peel_parens().kind { + ExprKind::Binary(lhs, BinOp { kind: BinOpKind::Rem, .. }, rhs) => { + !is_timestamp_time_bucket(self.gcx, lhs, rhs) + && (contains_predictable_source(self.gcx, lhs) + || contains_predictable_source(self.gcx, rhs)) + } + ExprKind::Call(callee, args) => { + self.gcx.resolved_builtin(callee) == Some(Builtin::Keccak256) + && args.exprs().any(|arg| contains_predictable_source(self.gcx, arg)) + } + _ => false, + }; + if is_randomness { + self.ctx.emit(&WEAK_PRNG, expr.span); + return ControlFlow::Continue(()); + } + self.walk_expr(expr) + } +} + +fn contains_predictable_source<'gcx>(gcx: Gcx<'gcx>, expr: &'gcx Expr<'gcx>) -> bool { + PredictableSourceFinder { gcx }.visit_expr(expr).is_break() +} + +struct PredictableSourceFinder<'gcx> { + gcx: Gcx<'gcx>, +} + +impl<'gcx> Visit<'gcx> for PredictableSourceFinder<'gcx> { + type BreakValue = (); + + fn hir(&self) -> &'gcx Hir<'gcx> { + &self.gcx.hir + } + + fn visit_expr(&mut self, expr: &'gcx Expr<'gcx>) -> ControlFlow<()> { + match &expr.peel_parens().kind { + // `block.timestamp % 1 days` is a time bucket, not a random draw. + ExprKind::Binary(lhs, BinOp { kind: BinOpKind::Rem, .. }, rhs) + if is_timestamp_time_bucket(self.gcx, lhs, rhs) => + { + ControlFlow::Continue(()) + } + _ if matches!( + self.gcx.resolved_builtin(expr), + Some( + Builtin::BlockTimestamp + | Builtin::BlockNumber + | Builtin::BlockCoinbase + | Builtin::BlockPrevrandao + | Builtin::BlockDifficulty + ) + ) => + { + ControlFlow::Break(()) + } + ExprKind::Call(callee, ..) + if self.gcx.resolved_builtin(callee) == Some(Builtin::Blockhash) => + { + ControlFlow::Break(()) + } + _ => self.walk_expr(expr), + } + } +} + +/// `block.timestamp % `. +fn is_timestamp_time_bucket(gcx: Gcx<'_>, lhs: &Expr<'_>, rhs: &Expr<'_>) -> bool { + const SECONDS_PER_DAY: U256 = uint!(86400_U256); + gcx.resolved_builtin(lhs) == Some(Builtin::BlockTimestamp) + && gcx + .try_eval_const(rhs) + .ok() + .and_then(|v| v.as_u256()) + .is_some_and(|v| v >= SECONDS_PER_DAY && (v % SECONDS_PER_DAY).is_zero()) +} diff --git a/crates/lint/src/sol/mod.rs b/crates/lint/src/sol/mod.rs index 0d357a3e9404b..bd74292ba8b80 100644 --- a/crates/lint/src/sol/mod.rs +++ b/crates/lint/src/sol/mod.rs @@ -1,25 +1,30 @@ -use crate::{ - inline_config::{InlineConfig, InlineConfigItem}, - linter::{ - EarlyLintPass, EarlyLintVisitor, LateLintPass, LateLintVisitor, Lint, LintContext, Linter, +use crate::linter::{Lint, LintPolicy, Linter}; +use foundry_common::{ + comments::{ + Comments, + inline_config::{InlineConfig, InlineConfigItem}, }, + errors::convert_solar_errors, + sh_warn, }; -use foundry_common::comments::Comments; use foundry_compilers::{ProjectPathsConfig, solc::SolcLanguage}; -use foundry_config::lint::Severity; -use rayon::iter::{ParallelBridge, ParallelIterator}; -use solar_ast::{self as ast, visit::Visit as VisitAST}; -use solar_interface::{ - Session, SourceMap, - diagnostics::{self, DiagCtxt, JsonEmitter}, - source_map::{FileName, SourceFile}, +use foundry_config::{ + DenyLevel, + lint::{LintSpecificConfig, Severity}, }; -use solar_sema::{ - ParsingContext, - hir::{self, Visit as VisitHIR}, +use solar::{ + ast, + interface::{ + ColorChoice, Session, + diagnostics::{HumanEmitter, JsonEmitter, Level, SilentEmitter}, + }, + sema::Compiler, }; +use solar_lint::{LintRegistry, LintRunContext, LintRunError, LintSource, LintSuite, run_lints}; use std::{ + collections::HashSet, path::{Path, PathBuf}, + str::FromStr, sync::{Arc, LazyLock}, }; use thiserror::Error; @@ -27,41 +32,185 @@ use thiserror::Error; #[macro_use] pub mod macros; +pub mod analysis; +pub mod codesize; pub mod gas; pub mod high; pub mod info; +pub mod low; pub mod med; +pub mod naming; + +/// Every registered lint, in severity-group order. +fn all_lints() -> impl Iterator { + [ + high::REGISTERED_LINTS, + med::REGISTERED_LINTS, + low::REGISTERED_LINTS, + info::REGISTERED_LINTS, + gas::REGISTERED_LINTS, + codesize::REGISTERED_LINTS, + ] + .into_iter() + .flatten() +} + +static ALL_REGISTERED_LINTS: LazyLock> = + LazyLock::new(|| all_lints().map(|lint| lint.id).collect()); + +static DEFAULT_LINT_SPECIFIC_CONFIG: LazyLock = + LazyLock::new(LintSpecificConfig::default); + +struct OwnedLintPolicy { + inline: Option>>>, + active: Arc>, + sources: Option>>, +} + +struct SourceLintPolicy { + file: Arc, + inline: Arc>>, + active: Vec<&'static str>, +} + +impl LintPolicy for OwnedLintPolicy { + fn is_lint_enabled(&self, id: &str) -> bool { + self.active.contains(&id) + } + + fn is_lint_suppressed(&self, id: &str, span: solar::interface::Span) -> bool { + if !span.is_dummy() + && let Some(sources) = &self.sources + { + // Late passes can follow inheritance or calls into another file. Apply the policy of + // the file that owns the diagnostic span, not the file whose visitor emitted it. + let source = sources + .partition_point(|source| source.file.start_pos <= span.lo()) + .checked_sub(1) + .map(|idx| &sources[idx]) + .filter(|source| source.file.contains(span.lo())); + return source.is_none_or(|source| { + !source.active.contains(&id) || source.inline.is_id_disabled(span, id) + }); + } + self.inline.as_ref().is_some_and(|inline| inline.is_id_disabled(span, id)) + } +} + +/// A reusable collection of Forge lint passes and policy. +#[derive(Clone)] +pub struct ForgeLintSuite { + path_config: ProjectPathsConfig, + severity: Option>, + lints_included: Option>, + lints_excluded: Option>, + registry: Arc, + sources: Option>>, + run_active: Option>>, +} + +impl std::fmt::Debug for ForgeLintSuite { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ForgeLintSuite") + .field("path_config", &self.path_config) + .field("severity", &self.severity) + .field("lints_included", &self.lints_included) + .field("lints_excluded", &self.lints_excluded) + .finish_non_exhaustive() + } +} + +impl ForgeLintSuite { + fn include_lint(&self, lint: SolLint) -> bool { + self.severity.as_ref().is_none_or(|sev| sev.contains(&lint.severity())) + && self.lints_included.as_ref().is_none_or(|incl| incl.contains(&lint)) + && self.lints_excluded.as_ref().is_none_or(|excl| !excl.contains(&lint)) + } + + fn active_lints(&self, path: Option<&Path>) -> Vec<&'static str> { + all_lints() + .filter(|lint| { + self.include_lint(**lint) + && path.is_none_or(|path| { + !self.path_config.is_test_or_script(path) + || matches!( + lint.id, + "unsafe-cheatcode" | "environment-read-across-mutation" + ) + }) + }) + .map(|lint| lint.id) + .collect() + } +} + +impl LintSuite for ForgeLintSuite { + fn registry(&self) -> &LintRegistry { + &self.registry + } -static ALL_REGISTERED_LINTS: LazyLock> = LazyLock::new(|| { - let mut lints = Vec::new(); - lints.extend_from_slice(high::REGISTERED_LINTS); - lints.extend_from_slice(med::REGISTERED_LINTS); - lints.extend_from_slice(info::REGISTERED_LINTS); - lints.extend_from_slice(gas::REGISTERED_LINTS); - lints.into_iter().map(|lint| lint.id()).collect() -}); + fn source_policy(&self, source: LintSource<'_, '_>) -> Arc { + let inline = self + .sources + .as_ref() + .and_then(|sources| { + sources + .binary_search_by_key(&source.file.start_pos, |source| source.file.start_pos) + .ok() + .map(|idx| sources[idx].inline.clone()) + }) + .unwrap_or_else(|| { + let comments = + Comments::new(source.file, source.session.source_map(), false, false, None); + Arc::new(parse_inline_config(source.session, &comments, source.ast)) + }); + Arc::new(OwnedLintPolicy { + inline: Some(inline), + active: self + .run_active + .clone() + .unwrap_or_else(|| Arc::new(self.active_lints(Some(source.path)))), + sources: self.sources.clone(), + }) + } + + fn project_policy(&self) -> Arc { + Arc::new(OwnedLintPolicy { + inline: None, + active: Arc::new(self.active_lints(None)), + sources: None, + }) + } +} /// Linter implementation to analyze Solidity source code responsible for identifying /// vulnerabilities gas optimizations, and best practices. -#[derive(Debug, Clone)] -pub struct SolidityLinter { +#[derive(Debug)] +pub struct SolidityLinter<'a> { path_config: ProjectPathsConfig, severity: Option>, lints_included: Option>, lints_excluded: Option>, with_description: bool, with_json_emitter: bool, + json_emitter_stdout: bool, + report_unused_suppressions: bool, + // lint-specific configuration + lint_specific: &'a LintSpecificConfig, } -impl SolidityLinter { +impl<'a> SolidityLinter<'a> { pub fn new(path_config: ProjectPathsConfig) -> Self { Self { path_config, + with_description: true, severity: None, lints_included: None, lints_excluded: None, - with_description: true, with_json_emitter: false, + json_emitter_stdout: false, + report_unused_suppressions: false, + lint_specific: &DEFAULT_LINT_SPECIFIC_CONFIG, } } @@ -80,188 +229,224 @@ impl SolidityLinter { self } - pub fn with_description(mut self, with: bool) -> Self { + pub const fn with_description(mut self, with: bool) -> Self { self.with_description = with; self } - pub fn with_json_emitter(mut self, with: bool) -> Self { + pub const fn with_json_emitter(mut self, with: bool) -> Self { self.with_json_emitter = with; self } - fn include_lint(&self, lint: SolLint) -> bool { - self.severity.as_ref().is_none_or(|sev| sev.contains(&lint.severity())) - && self.lints_included.as_ref().is_none_or(|incl| incl.contains(&lint)) - && !self.lints_excluded.as_ref().is_some_and(|excl| excl.contains(&lint)) + pub const fn with_json_emitter_stdout(mut self, with: bool) -> Self { + self.json_emitter_stdout = with; + self } - fn process_source_ast<'ast>( - &self, - sess: &'ast Session, - ast: &'ast ast::SourceUnit<'ast>, - file: &SourceFile, - path: &Path, - ) -> Result<(), diagnostics::ErrorGuaranteed> { - // Declare all available passes and lints - let mut passes_and_lints = Vec::new(); - passes_and_lints.extend(high::create_early_lint_passes()); - passes_and_lints.extend(med::create_early_lint_passes()); - passes_and_lints.extend(info::create_early_lint_passes()); - - // Do not apply gas-severity rules on tests and scripts - if !self.path_config.is_test_or_script(path) { - passes_and_lints.extend(gas::create_early_lint_passes()); - } - - // Filter passes based on linter config - let mut passes: Vec>> = passes_and_lints - .into_iter() - .filter_map(|(pass, lint)| if self.include_lint(lint) { Some(pass) } else { None }) - .collect(); - - // Process the inline-config - let comments = Comments::new(file); - let inline_config = parse_inline_config(sess, &comments, InlineConfigSource::Ast(ast)); - - // Initialize and run the early lint visitor - let ctx = LintContext::new(sess, self.with_description, inline_config); - let mut early_visitor = EarlyLintVisitor::new(&ctx, &mut passes); - _ = early_visitor.visit_source_unit(ast); - early_visitor.post_source_unit(ast); + pub const fn with_report_unused_suppressions(mut self, with: bool) -> Self { + self.report_unused_suppressions = with; + self + } - Ok(()) + pub const fn with_lint_specific(mut self, lint_specific: &'a LintSpecificConfig) -> Self { + self.lint_specific = lint_specific; + self } - fn process_source_hir<'hir>( - &self, - sess: &Session, - gcx: &solar_sema::ty::Gcx<'hir>, - source_id: hir::SourceId, - file: &'hir SourceFile, - ) -> Result<(), diagnostics::ErrorGuaranteed> { - // Declare all available passes and lints - let mut passes_and_lints = Vec::new(); - passes_and_lints.extend(high::create_late_lint_passes()); - passes_and_lints.extend(med::create_late_lint_passes()); - passes_and_lints.extend(info::create_late_lint_passes()); - - // Do not apply gas-severity rules on tests and scripts - if let FileName::Real(ref path) = file.name - && !self.path_config.is_test_or_script(path) - { - passes_and_lints.extend(gas::create_late_lint_passes()); + /// Returns an owned lint suite suitable for CLI or LSP execution. + pub fn to_suite(&self) -> ForgeLintSuite { + let lint_specific = Arc::new(self.lint_specific.clone()); + let mut registry = LintRegistry::new(); + high::register_lints(&mut registry, &lint_specific); + med::register_lints(&mut registry, &lint_specific); + low::register_lints(&mut registry, &lint_specific); + info::register_lints(&mut registry, &lint_specific); + gas::register_lints(&mut registry, &lint_specific); + codesize::register_lints(&mut registry, &lint_specific); + + ForgeLintSuite { + path_config: self.path_config.clone(), + severity: self.severity.clone(), + lints_included: self.lints_included.clone(), + lints_excluded: self.lints_excluded.clone(), + registry: Arc::new(registry), + sources: None, + run_active: None, } - - // Filter passes based on config - let mut passes: Vec>> = passes_and_lints - .into_iter() - .filter_map(|(pass, lint)| if self.include_lint(lint) { Some(pass) } else { None }) - .collect(); - - // Process the inline-config - let comments = Comments::new(file); - let inline_config = - parse_inline_config(sess, &comments, InlineConfigSource::Hir((&gcx.hir, source_id))); - - // Run late lint visitor - let ctx = LintContext::new(sess, self.with_description, inline_config); - let mut late_visitor = LateLintVisitor::new(&ctx, &mut passes, &gcx.hir); - - // Visit this specific source - _ = late_visitor.visit_nested_source(source_id); - - Ok(()) } } -impl Linter for SolidityLinter { +impl<'a> Linter for SolidityLinter<'a> { type Language = SolcLanguage; type Lint = SolLint; - /// Build solar session based on the linter config - fn init(&self) -> Session { - let mut builder = Session::builder(); - if self.with_json_emitter { - let map = Arc::::default(); - let json_emitter = JsonEmitter::new(Box::new(std::io::stderr()), map.clone()) + fn lint( + &self, + input: &[PathBuf], + deny: DenyLevel, + compiler: &mut Compiler, + ) -> eyre::Result<()> { + convert_solar_errors(compiler.dcx())?; + + // Cache diagnostic count before linting to isolate from the build phase. + let mut warn_count_before = compiler.dcx().warn_count(); + let mut note_count_before = compiler.dcx().note_count(); + + let ui_testing = std::env::var_os("FOUNDRY_LINT_UI_TESTING").is_some(); + + let sm = compiler.sess().clone_source_map(); + let prev_emitter = compiler.dcx().set_emitter(if self.with_json_emitter { + let writer: Box = if self.json_emitter_stdout && !ui_testing + { + Box::new(std::io::BufWriter::new(std::io::stdout())) + } else { + Box::new(std::io::BufWriter::new(std::io::stderr())) + }; + let json_emitter = JsonEmitter::new(writer, sm, ColorChoice::Never) .rustc_like(true) - .ui_testing(false); - - builder = builder.dcx(DiagCtxt::new(Box::new(json_emitter))).source_map(map); + .ui_testing(ui_testing); + Box::new(json_emitter) } else { - builder = builder.with_stderr_emitter(); - }; + Box::new(HumanEmitter::stderr(Default::default()).source_map(Some(sm))) + }); + let sess = compiler.sess_mut(); + sess.dcx.set_flags_mut(|f| f.track_diagnostics = false); + if ui_testing { + sess.opts.unstable.ui_testing = true; + sess.reconfigure(); + } - // Create a single session for all files - let mut sess = builder.build(); - sess.dcx = sess.dcx.set_flags(|flags| flags.track_diagnostics = false); - sess - } - - /// Run AST-based lints - fn early_lint<'sess>(&self, input: &[PathBuf], mut pcx: ParsingContext<'sess>) { - let sess = pcx.sess; - _ = sess.enter_parallel(|| -> Result<(), diagnostics::ErrorGuaranteed> { - // Load all files into the parsing ctx - pcx.load_files(input)?; - - // Parse the sources - let ast_arena = solar_sema::thread_local::ThreadLocal::new(); - let ast_result = pcx.parse(&ast_arena); - - // Process each source in parallel - ast_result.sources.iter().par_bridge().for_each(|source| { - if let (FileName::Real(path), Some(ast)) = (&source.file.name, &source.ast) - && input.iter().any(|input_path| path.ends_with(input_path)) - { - _ = self.process_source_ast(sess, ast, &source.file, path) + compiler.enter_mut(|compiler| -> eyre::Result<()> { + if compiler.gcx().stage() < Some(solar::config::CompilerStage::Lowering) { + let _ = compiler.lower_asts(); + } + convert_solar_errors(compiler.dcx())?; + if compiler.gcx().stage() < Some(solar::config::CompilerStage::Analysis) { + // Typeck is used as a data source for lints. Its diagnostics are still + // experimental and should not leak into `forge lint` output. + let prev_emitter = + compiler.dcx().set_emitter(Box::new(SilentEmitter::new_boxed(None))); + let _ = compiler.analysis(); + compiler.dcx().set_emitter(prev_emitter); + } + warn_count_before = compiler.dcx().warn_count(); + note_count_before = compiler.dcx().note_count(); + + let gcx = compiler.gcx(); + let mut targets = Vec::with_capacity(input.len()); + let mut seen_sources = HashSet::new(); + for path in input { + let path = self.path_config.root.join(path); + let Some((_, source)) = gcx.get_ast_source(&path) else { + // Issue a warning rather than panicking when some input files use old + // Solidity versions that Solar does not support. + _ = sh_warn!("AST source not found for {}", path.display()); + continue; + }; + if seen_sources.insert(source.file.start_pos) { + targets.push(path); } + } + + let mut suite = self.to_suite(); + let mut sources = targets + .iter() + .map(|path| { + let (_, source) = + gcx.get_ast_source(path).expect("lint target was validated above"); + let ast = source.ast.as_ref().expect("lint target AST was validated above"); + let comments = + Comments::new(&source.file, gcx.sess.source_map(), false, false, None); + SourceLintPolicy { + file: source.file.clone(), + inline: Arc::new(parse_inline_config(gcx.sess, &comments, ast)), + active: suite.active_lints(Some(path)), + } + }) + .collect::>(); + sources.sort_unstable_by_key(|source| source.file.start_pos); + suite.run_active = Some(Arc::new( + suite + .active_lints(None) + .into_iter() + .filter(|id| sources.iter().any(|source| source.active.contains(id))) + .collect(), + )); + suite.sources = Some(Arc::new(sources)); + run_lints( + &suite, + LintRunContext { + gcx, + targets: &targets, + with_description: self.with_description, + with_ansi_help: !self.with_json_emitter, + }, + ) + .unwrap_or_else(|error| match error { + LintRunError::MissingAstSource(path) => { + unreachable!("prevalidated AST source missing for {}", path.display()) + } + LintRunError::MissingAst(path) => { + panic!("AST missing for {}", path.display()) + } + LintRunError::MissingHir(path) => { + panic!("HIR source not found for {}", path.display()) + } + error => panic!("lint run failed: {error}"), }); - Ok(()) - }); - } + if self.report_unused_suppressions + && let Some(sources) = &suite.sources + { + for source in sources.iter() { + for (span, id) in source.inline.unused_suppressions(&source.active) { + gcx.sess + .dcx + .warn(format!("unused lint suppression for '{id}'")) + .span(span) + .emit(); + } + } + } - /// Run HIR-based lints - fn late_lint<'sess>(&self, input: &[PathBuf], mut pcx: ParsingContext<'sess>) { - let sess = pcx.sess; - _ = sess.enter_parallel(|| -> Result<(), diagnostics::ErrorGuaranteed> { - // Load all files into the parsing ctx - pcx.load_files(input)?; + Ok(()) + })?; - // Parse and lower to HIR - let hir_arena = solar_sema::thread_local::ThreadLocal::new(); - let hir_result = pcx.parse_and_lower(&hir_arena); + let sess = compiler.sess_mut(); + sess.dcx.set_emitter(prev_emitter); + if ui_testing { + sess.opts.unstable.ui_testing = false; + sess.reconfigure(); + } - if let Ok(Some(gcx_wrapper)) = hir_result { - let gcx = gcx_wrapper.get(); + let lint_warn_count = compiler.dcx().warn_count().saturating_sub(warn_count_before); + let lint_note_count = compiler.dcx().note_count().saturating_sub(note_count_before); - // Process each source in parallel - gcx.hir.sources_enumerated().par_bridge().for_each(|(source_id, source)| { - if let FileName::Real(ref path) = source.file.name - && input.iter().any(|input_path| path.ends_with(input_path)) - { - _ = self.process_source_hir(sess, &gcx, source_id, &source.file); - } - }); + let (w, n) = (lint_warn_count, lint_note_count); + let denied = match deny { + DenyLevel::Warnings if w > 0 && n > 0 => { + format!("{w} linter warning(s); {n} note(s) were also emitted") } - - Ok(()) - }); + DenyLevel::Warnings if w > 0 => format!("{w} linter warning(s)"), + DenyLevel::Notes if w > 0 && n > 0 => format!("{w} linter warning(s) and {n} note(s)"), + DenyLevel::Notes if w > 0 => format!("{w} linter warning(s)"), + DenyLevel::Notes if n > 0 => format!("{n} linter note(s)"), + _ => return Ok(()), + }; + Err(DeniedLintDiagnostics(format!( + "aborting due to {denied} +" + )) + .into()) } } -enum InlineConfigSource<'ast, 'hir> { - Ast(&'ast ast::SourceUnit<'ast>), - Hir((&'hir hir::Hir<'hir>, hir::SourceId)), -} - -fn parse_inline_config<'ast, 'hir>( +fn parse_inline_config<'ast>( sess: &Session, comments: &Comments, - source: InlineConfigSource<'ast, 'hir>, -) -> InlineConfig { + ast: &'ast ast::SourceUnit<'ast>, +) -> InlineConfig> { let items = comments.iter().filter_map(|comment| { let mut item = comment.lines.first()?.as_str(); if let Some(prefix) = comment.prefix() { @@ -281,12 +466,7 @@ fn parse_inline_config<'ast, 'hir>( } }); - match source { - InlineConfigSource::Ast(ast) => InlineConfig::from_ast(items, ast, sess.source_map()), - InlineConfigSource::Hir((hir, id)) => { - InlineConfig::from_hir(items, hir, id, sess.source_map()) - } - } + InlineConfig::from_ast(items, ast, sess.source_map()) } #[derive(Error, Debug)] @@ -295,6 +475,10 @@ pub enum SolLintError { InvalidId(String), } +#[derive(Error, Debug)] +#[error("{0}")] +pub struct DeniedLintDiagnostics(String); + #[derive(Debug, Clone, Copy, Eq, PartialEq)] pub struct SolLint { id: &'static str, @@ -303,12 +487,18 @@ pub struct SolLint { severity: Severity, } +impl SolLint { + pub const fn severity(self) -> Severity { + self.severity + } +} + impl Lint for SolLint { fn id(&self) -> &'static str { self.id } - fn severity(&self) -> Severity { - self.severity + fn level(&self) -> Level { + self.severity.into() } fn description(&self) -> &'static str { self.description @@ -318,34 +508,310 @@ impl Lint for SolLint { } } -impl<'a> TryFrom<&'a str> for SolLint { - type Error = SolLintError; +impl FromStr for SolLint { + type Err = SolLintError; + + fn from_str(value: &str) -> Result { + all_lints() + .find(|lint| lint.id == value) + .copied() + .ok_or_else(|| SolLintError::InvalidId(value.to_string())) + } +} - fn try_from(value: &'a str) -> Result { - for &lint in high::REGISTERED_LINTS { - if lint.id() == value { - return Ok(lint); +#[cfg(test)] +mod tests { + //! Checks the canonical lint documentation against the registered lints and page template. + + use super::{Severity, all_lints}; + use crate::linter::Lint; + use eyre::{Result, ensure}; + use std::{collections::BTreeSet, fs, path::Path}; + + #[test] + fn registered_lints_have_docs() { + let docs = Path::new(env!("CARGO_MANIFEST_DIR")).join("docs"); + let mut registered = BTreeSet::new(); + let mut errors = Vec::new(); + for lint in all_lints() { + assert!(registered.insert(lint.id().to_owned()), "duplicate lint ID: {}", lint.id()); + let path = docs.join(format!("{}.md", lint.id())); + let result = fs::read_to_string(&path) + .map_err(eyre::Report::from) + .and_then(|text| validate_doc(&text, lint.id(), lint.severity())); + if let Err(error) = result { + errors.push(format!("{}: {error}", path.display())); } } + assert!(!registered.is_empty(), "no registered lints"); + let documented = fs::read_dir(&docs) + .unwrap() + .map(|entry| entry.unwrap().path()) + .filter(|path| path.extension().is_some_and(|ext| ext == "md")) + .map(|path| path.file_stem().unwrap().to_str().unwrap().to_owned()) + .filter(|id| !matches!(id.as_str(), "README" | "_template")) + .collect::>(); + for id in documented.difference(®istered) { + errors.push(format!("{id}.md: no registered lint")); + } + assert!(errors.is_empty(), "invalid lint documentation:\n{}", errors.join("\n")); + } - for &lint in med::REGISTERED_LINTS { - if lint.id() == value { - return Ok(lint); - } + #[test] + fn registered_lints_have_canonical_help_url() { + for lint in all_lints() { + let expected = format!("https://getfoundry.sh/forge/linting/{}", lint.id()); + assert_eq!(lint.help(), expected, "lint `{}` has a non-canonical help URL", lint.id()); } + } - for &lint in info::REGISTERED_LINTS { - if lint.id() == value { - return Ok(lint); - } + #[derive(Debug, PartialEq)] + enum Kind<'a> { + Heading(usize), + Text, + Code(&'a str), + } + + #[derive(Debug)] + struct Token<'a> { + kind: Kind<'a>, + text: String, + line: usize, + } + + fn fence(line: &str) -> Option<(u8, usize, &str)> { + let trimmed = line.trim_start_matches(' '); + if line.len() - trimmed.len() > 3 { + return None; + } + let marker = *trimmed.as_bytes().first()?; + if !matches!(marker, b'`' | b'~') { + return None; + } + let len = trimmed.bytes().take_while(|&byte| byte == marker).count(); + (len >= 3).then(|| (marker, len, trimmed[len..].trim())) + } + + // Fenced examples can contain headings, metadata, and shorter fences; ignore that content. + fn tokens(text: &str) -> Result>> { + let mut result = Vec::new(); + let mut lines = text.lines().enumerate(); + while let Some((index, line)) = lines.next() { + let (kind, text) = if let Some((marker, len, language)) = fence(line) { + let mut code = String::new(); + let mut closed = false; + for (_, line) in lines.by_ref() { + if fence(line).is_some_and(|(end, width, tail)| { + end == marker && width >= len && tail.is_empty() + }) { + closed = true; + break; + } + code.push_str(line); + code.push('\n'); + } + ensure!(closed, "line {}: unclosed code fence", index + 1); + (Kind::Code(language), code) + } else { + let level = line.bytes().take_while(|&byte| byte == b'#').count(); + if (1..=6).contains(&level) && line[level..].starts_with(' ') { + (Kind::Heading(level), line[level..].trim().to_owned()) + } else if line.trim().is_empty() { + continue; + } else { + (Kind::Text, line.trim().to_owned()) + } + }; + result.push(Token { kind, text, line: index + 1 }); } + Ok(result) + } - for &lint in gas::REGISTERED_LINTS { - if lint.id() == value { - return Ok(lint); + fn validate_doc(text: &str, id: &str, severity: Severity) -> Result<()> { + ensure!( + id.starts_with(|c: char| c.is_ascii_lowercase()) + && id.split('-').all(|part| !part.is_empty() + && part.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())), + "lint ID must be kebab-case" + ); + let items = tokens(text)?; + let [title, level, identity, body @ ..] = items.as_slice() else { + eyre::bail!("expected title, severity, ID, and sections"); + }; + ensure!(title.kind == Kind::Heading(1) && !title.text.is_empty(), "start with one # title"); + ensure!( + level.kind == Kind::Text && level.text == format!("**Severity**: `{severity:?}`"), + "line {}: severity must match the registered lint ({severity:?})", + level.line + ); + ensure!( + identity.kind == Kind::Text && identity.text == format!("**ID**: `{id}`"), + "line {}: ID must match the registered lint and filename ({id})", + identity.line + ); + ensure!( + body.first().is_some_and(|t| t.kind == Kind::Heading(2) && t.text == "What it does"), + "start the body with ## What it does (no introductory summary)" + ); + let mut core = Vec::new(); + let mut seen = BTreeSet::new(); + let mut remaining = body; + while let Some((heading, tail)) = remaining.split_first() { + let end = tail.iter().position(|t| matches!(t.kind, Kind::Heading(1 | 2))); + let (content, rest) = tail.split_at(end.unwrap_or(tail.len())); + remaining = rest; + ensure!(heading.kind == Kind::Heading(2), "line {}: only one # title", heading.line); + let name = heading.text.as_str(); + ensure!(seen.insert(name), "line {}: duplicate section {name}", heading.line); + match name { + "What it does" | "Why is this bad?" | "Why restrict this?" | "Example" => { + core.push(name); + } + "Configuration" | "Notes" | "Limitations" | "Known limitations" => {} + _ => eyre::bail!("line {}: unexpected section {name}", heading.line), + } + ensure!( + content + .iter() + .any(|t| !matches!(t.kind, Kind::Heading(_)) && !t.text.trim().is_empty()), + "line {}: {name} must not be empty", + heading.line + ); + for token in content { + ensure!( + !(matches!(token.kind, Kind::Heading(_)) + && matches!(token.text.as_str(), "Bad" | "Good")), + "line {}: use Use instead: rather than Bad/Good headings", + token.line + ); + ensure!( + !(token.kind == Kind::Text + && (token.text.starts_with("**Severity**:") + || token.text.starts_with("**ID**:"))), + "line {}: metadata belongs only below the title", + token.line + ); + } + if matches!(name, "What it does" | "Why is this bad?" | "Why restrict this?") { + ensure!( + content.iter().any(|t| t.kind == Kind::Text), + "{name} needs explanatory prose" + ); + } + if name == "Example" { + let separators = content + .iter() + .enumerate() + .filter(|(_, t)| t.kind == Kind::Text && t.text == "Use instead:") + .map(|(index, _)| index) + .collect::>(); + ensure!(separators.len() == 1, "Example needs exactly one Use instead: separator"); + let split = separators[0]; + for side in [&content[..split], &content[split + 1..]] { + ensure!( + side.iter() + .any(|t| t.kind == Kind::Code("solidity") && !t.text.trim().is_empty()), + "Example needs a nonempty solidity code block on each side of Use instead:" + ); + } } } + ensure!( + matches!( + core.as_slice(), + ["What it does", "Why is this bad?" | "Why restrict this?", "Example"] + ), + "expected What it does, exactly one Why section, then Example" + ); + Ok(()) + } - Err(SolLintError::InvalidId(value.to_string())) + const VALID: &str = "# Example\n\n**Severity**: `Info`\n**ID**: `example`\n\n\ + ## What it does\n\nReports a pattern.\n\n## Why is this bad?\n\nExplains the consequence.\n\n\ + ## Example\n\n```solidity\nbad();\n```\n\nUse instead:\n\n```solidity\ngood();\n```\n"; + + #[test] + fn accepts_documentation_variants() { + for severity in [ + Severity::High, + Severity::Med, + Severity::Low, + Severity::Info, + Severity::Gas, + Severity::CodeSize, + ] { + validate_doc(&VALID.replace("`Info`", &format!("`{severity:?}`")), "example", severity) + .unwrap(); + } + for text in [ + VALID.replace("Why is this bad?", "Why restrict this?"), + VALID.replace('\n', "\r\n"), + VALID.replace("## Why", "## Known limitations\n\nAn exclusion.\n\n## Why") + + "\n## Configuration\n\n```toml\nsetting = true\n```\n\n## Notes\n\nA note.\n\n## Limitations\n\nA limitation.\n", + include_str!("../../docs/_template.md") + .replace("``", "`Info`") + .replace("``", "`example`"), + ] { + validate_doc(&text, "example", Severity::Info).unwrap(); + } + for marker in ["````", "~~~"] { + let text = VALID + .replace("```", marker) + .replace("bad();", "## Example\n**ID**: `other`\nUse instead:\n### Bad\n```"); + validate_doc(&text, "example", Severity::Info).unwrap(); + } + } + + #[test] + fn rejects_invalid_documentation() { + for (from, to, error) in [ + ("# Example", "Example", "one # title"), + ("**Severity**: `Info`", "", "severity"), + ("`Info`", "`High`", "severity"), + ("`example`", "`other`", "ID must match"), + ("## What", "Summary.\n\n## What", "no introductory summary"), + ("## Why is this bad?\n\nExplains the consequence.\n\n", "", "exactly one Why"), + ("Reports a pattern.", "", "must not be empty"), + ("Reports a pattern.", "```solidity\nf();\n```", "explanatory prose"), + ("Use instead:", "", "exactly one Use instead:"), + ("Use instead:", "### Bad", "Bad/Good headings"), + ("Use instead:", "### Good", "Bad/Good headings"), + ("bad();", "", "nonempty solidity"), + ("good();", " ", "nonempty solidity"), + ("```solidity", "```text", "nonempty solidity"), + ("```solidity", "~~~solidity", "unclosed code fence"), + ("```solidity", "````solidity", "unclosed code fence"), + ] { + let text = VALID.replacen(from, to, 1); + let result = validate_doc(&text, "example", Severity::Info).unwrap_err().to_string(); + assert!(result.contains(error), "{from:?} -> {to:?}: {result}"); + } + for (suffix, error) in [ + ("# Extra\n", "only one # title"), + ("## Example\n", "duplicate section"), + ("## Why restrict this?\n\nPolicy.\n", "exactly one Why"), + ("## Scope and controls\n\nText.\n", "unexpected section"), + ("## Notes\n\n### Detail\n", "must not be empty"), + ("**ID**: `example`\n", "metadata belongs only"), + ("Use instead:\n", "exactly one Use instead:"), + ] { + let result = validate_doc(&(VALID.to_owned() + suffix), "example", Severity::Info) + .unwrap_err() + .to_string(); + assert!(result.contains(error), "{suffix:?}: {result}"); + } + let reordered = VALID + .replace("## Why is this bad?", "## Temporary") + .replace("## Example", "## Why is this bad?") + .replace("## Temporary", "## Example"); + assert!(validate_doc(&reordered, "example", Severity::Info).is_err()); + assert!( + validate_doc(VALID.trim_end().trim_end_matches("```"), "example", Severity::Info) + .is_err() + ); + for id in ["Not_kebab", "example-", "example--lint", "1example"] { + let text = VALID.replace("`example`", &format!("`{id}`")); + assert!(validate_doc(&text, id, Severity::Info).is_err(), "{id}"); + } } } diff --git a/crates/lint/src/sol/naming.rs b/crates/lint/src/sol/naming.rs new file mode 100644 index 0000000000000..1cbf416cef73c --- /dev/null +++ b/crates/lint/src/sol/naming.rs @@ -0,0 +1,57 @@ +//! Naming-convention helpers shared by Solidity lints. +//! +//! Each `check_*` returns `Some(suggestion)` when `s` violates the convention, +//! `None` when it already matches. Leading/trailing underscores are preserved. + +use crate::{ + linter::{LintContext, Suggestion}, + sol::SolLint, +}; +use solar::interface::{Span, diagnostics::Applicability}; + +/// `Some(suggestion)` if `s` is not `PascalCase`. +pub fn check_pascal_case(s: &str) -> Option { + suggest(s, preserve_underscores(s, heck::AsPascalCase(s).to_string())) +} + +/// `Some(suggestion)` if `s` is not `SCREAMING_SNAKE_CASE`. +pub fn check_screaming_snake_case(s: &str) -> Option { + suggest(s, preserve_underscores(s, heck::AsShoutySnakeCase(s).to_string())) +} + +/// `Some(suggestion)` if `s` is not `mixedCase`. Pure check — domain +/// exceptions (test-prefixes, allowed patterns, ...) live in the lint. +pub fn check_mixed_case(s: &str) -> Option { + suggest(s, preserve_underscores(s, heck::AsLowerCamelCase(s).to_string())) +} + +/// True if `s` is `
` for one of the configured acronym `patterns`
+/// (e.g. `ERC` in `rescueERC20Tokens`), where `pre` satisfies `pre_is_valid` and `post` is
+/// `UpperCamelCase`. One preserved leading or trailing underscore is tolerated.
+pub fn has_acronym_exception(s: &str, patterns: &[String], pre_is_valid: fn(&str) -> bool) -> bool {
+    patterns.iter().any(|pattern| {
+        let Some((pre, post)) = s.split_once(pattern.as_str()) else { return false };
+        let pre = pre.strip_prefix('_').unwrap_or(pre);
+        let post = post.trim_start_matches(|c: char| c.is_numeric());
+        let post = post.strip_suffix('_').unwrap_or(post);
+        pre_is_valid(pre) && post == heck::AsUpperCamelCase(post).to_string()
+    })
+}
+
+/// Emits `lint` at `span` with a machine-applicable rename to `expected`.
+pub fn emit_rename(ctx: &LintContext, lint: &'static SolLint, span: Span, expected: String) {
+    let suggestion =
+        Suggestion::fix(expected, Applicability::MachineApplicable).with_desc("consider using");
+    ctx.emit_with_suggestion(lint, span, suggestion);
+}
+
+/// Single-character names are exempt from every convention.
+fn suggest(s: &str, expected: String) -> Option {
+    (s.len() > 1 && s != expected).then_some(expected)
+}
+
+fn preserve_underscores(s: &str, body: String) -> String {
+    let prefix = if s.starts_with('_') { "_" } else { "" };
+    let suffix = if s.ends_with('_') { "_" } else { "" };
+    format!("{prefix}{body}{suffix}")
+}
diff --git a/crates/lint/testdata/.gitignore b/crates/lint/testdata/.gitignore
new file mode 100644
index 0000000000000..d8a1d071d339d
--- /dev/null
+++ b/crates/lint/testdata/.gitignore
@@ -0,0 +1,2 @@
+cache/
+out/
diff --git a/crates/lint/testdata/ArbitrarySendErc20.sol b/crates/lint/testdata/ArbitrarySendErc20.sol
new file mode 100644
index 0000000000000..3fadd320dbd7f
--- /dev/null
+++ b/crates/lint/testdata/ArbitrarySendErc20.sol
@@ -0,0 +1,1102 @@
+//@compile-flags: --only-lint arbitrary-send-erc20
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+interface IERC20 {
+    function transfer(address to, uint256 amount) external returns (bool);
+    function transferFrom(address from, address to, uint256 amount) external returns (bool);
+    function approve(address spender, uint256 amount) external returns (bool);
+    function permit(
+        address owner,
+        address spender,
+        uint256 value,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) external;
+}
+
+interface IERC721 {
+    // Same name as ERC20.transferFrom but a different ABI; should NOT match.
+    function transferFrom(address from, address to, uint256 tokenId) external;
+    function safeTransferFrom(address from, address to, uint256 tokenId) external;
+    function safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data) external;
+}
+
+interface IERC3156FlashBorrower {
+    function onFlashLoan(
+        address initiator,
+        address token,
+        uint256 amount,
+        uint256 fee,
+        bytes calldata data
+    ) external returns (bytes32);
+}
+
+// Same method name as EIP-3156, different signature.
+interface IFakeFlashBorrower {
+    function onFlashLoan(bytes calldata data) external;
+}
+
+library SafeERC20 {
+    function safeTransferFrom(
+        IERC20 token,
+        address from,
+        address to,
+        uint256 value
+    ) internal {
+        // Library passthrough — `from` is forwarded by the caller; the lint fires at the
+        // user's call site (see `badLibrary`), not here.
+        require(token.transferFrom(from, to, value), "SafeERC20: transferFrom failed");
+    }
+
+    function safePermit(
+        IERC20 token,
+        address owner,
+        address spender,
+        uint256 value,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) internal {
+        token.permit(owner, spender, value, deadline, v, r, s);
+    }
+}
+
+contract ArbitrarySendErc20 {
+    using SafeERC20 for IERC20;
+
+    IERC20 public token;
+    IERC20 public other;
+    IERC721 public nft;
+    address public owner;
+    address public immutable trustedOwner;
+
+    constructor(address _trustedOwner) {
+        trustedOwner = _trustedOwner;
+    }
+
+    function _msgSender() internal view returns (address) {
+        return msg.sender;
+    }
+
+    // Verifies depth-bounded helper recognition.
+    function _origin() internal view returns (address) {
+        return _msgSender();
+    }
+
+    // -- POSITIVE CASES (should warn) --
+
+    function badPlain(address from, address to, uint256 a) public {
+        token.transferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    function badSafeMember(address from, address to, uint256 a) public {
+        token.safeTransferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    function badLibrary(address from, address to, uint256 a) public {
+        SafeERC20.safeTransferFrom(token, from, to, a); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    // Reassignment kills earlier safety.
+    function badReassign(address from, address to, uint256 a) public {
+        address x = msg.sender;
+        x = from;
+        token.transferFrom(x, to, a); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    // Ternary with one unsafe branch.
+    function badTernary(bool flag, address from, address to, uint256 a) public {
+        address x = flag ? msg.sender : from;
+        token.transferFrom(x, to, a); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    // Tuple destructuring picks the unsafe slot.
+    function badTuple(address from, address to, uint256 a) public {
+        (, address x) = (msg.sender, from);
+        token.transferFrom(x, to, a); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    // Index reads are opaque.
+    function badIndex(address[] calldata senders, address to, uint256 a) public {
+        token.transferFrom(senders[0], to, a); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    function badPermitWrongToken(
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        other.permit(from, address(this), a, deadline, v, r, s);
+        token.transferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    function badPermitWrongSpender(
+        address from,
+        address spender,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        token.permit(from, spender, a, deadline, v, r, s);
+        token.transferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    function badPermitAfter(
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        token.transferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+        token.permit(from, address(this), a, deadline, v, r, s);
+    }
+
+    // Permit on a side branch must not suppress the fall-through path.
+    function badPermitInOtherBranch(
+        bool flag,
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        if (flag) {
+            token.permit(from, address(this), a, deadline, v, r, s);
+        }
+        token.transferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    // Token reassigned after permit invalidates the record.
+    function badPermitTokenReassigned(
+        IERC20 t,
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        t.permit(from, address(this), a, deadline, v, r, s);
+        t = other;
+        t.transferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    // Owner reassigned after permit invalidates the record.
+    function badPermitOwnerReassigned(
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        address x = from;
+        token.permit(x, address(this), a, deadline, v, r, s);
+        x = to;
+        token.transferFrom(x, to, a); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    // Disjunction does not establish equality.
+    function badDisjunction(address from, address to, uint256 a) public {
+        require(from == msg.sender || to == msg.sender, "weak");
+        token.transferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    // Guard scoped to one branch must not leak.
+    function badGuardScoped(bool flag, address from, address to, uint256 a) public {
+        if (flag) {
+            require(from == msg.sender, "ok in this branch");
+        }
+        token.transferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    function badInterfaceCast(address rawToken, address from, address to, uint256 a) public {
+        IERC20(rawToken).transferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    // Sink runs before the equality short-circuits — the guard cannot retroactively
+    // sanitize `from`.
+    function badRequireGuardOrder(address from, address to, uint256 a) public {
+        require(token.transferFrom(from, to, a) && from == msg.sender); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    function badAssertGuardOrder(address from, address to, uint256 a) public {
+        assert(token.transferFrom(from, to, a) && from == msg.sender); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    function badShortCircuitReassignKillsSafe(bool flag, address from, address to, uint256 a) public {
+        address x = msg.sender;
+        bool ok = flag && ((x = from) != address(0));
+        ok;
+        token.transferFrom(x, to, a); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    // Modifier guard placed *after* `_;` cannot be hoisted.
+    modifier lateCheck(address f) {
+        _;
+        require(f == msg.sender, "auth");
+    }
+
+    function badModifierGuardAfterPlaceholder(
+        address from,
+        address to,
+        uint256 a
+    ) public lateCheck(from) {
+        token.transferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    // Multi-`_;` modifier is skipped (placeholder ordering can't be assumed sound).
+    modifier multiPlaceholder(address f) {
+        _;
+        require(f == msg.sender, "auth");
+        _;
+    }
+
+    function badModifierMultiPlaceholder(
+        address from,
+        address to,
+        uint256 a
+    ) public multiPlaceholder(from) {
+        token.transferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    // Loop body kills a previously-safe local.
+    function badLoopKillsSafeLocal(address from, address to, uint256 a) public {
+        address x = msg.sender;
+        for (uint256 i = 0; i < 1; i++) {
+            x = from;
+        }
+        token.transferFrom(x, to, a); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    // Try clause kills a previously-safe local.
+    function badTryClauseKillsSafeLocal(
+        address from,
+        address to,
+        uint256 a,
+        IERC20 t
+    ) public {
+        address x = msg.sender;
+        try t.transfer(to, a) returns (bool) {
+            x = from;
+        } catch {}
+        token.transferFrom(x, to, a); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    // Mutable state vars are not transitive: storage may be rewritten before the sink.
+    function badViaStateVarGuard(address from, address to, uint256 a) public {
+        require(owner == msg.sender, "owner check");
+        require(from == owner, "from check");
+        token.transferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    // State-var token reassigned after permit must invalidate the record.
+    function badPermitStateTokenReassigned(
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        token.permit(from, address(this), a, deadline, v, r, s);
+        token = other;
+        token.transferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    // State-var owner reassigned after permit must invalidate the record.
+    function badPermitStateOwnerReassigned(
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        owner = from;
+        token.permit(owner, address(this), a, deadline, v, r, s);
+        owner = to;
+        token.transferFrom(owner, to, a); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    // State-var token reassigned on only one branch: post-`if` intersection drops the permit.
+    function badPermitStateTokenMaybeReassigned(
+        bool flag,
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        token.permit(from, address(this), a, deadline, v, r, s);
+        if (flag) {
+            token = other;
+        }
+        token.transferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    // -- NEGATIVE CASES (should NOT warn) --
+
+    function okMsgSender(address to, uint256 a) public {
+        token.transferFrom(msg.sender, to, a);
+    }
+
+    function okThis(address to, uint256 a) public {
+        token.transferFrom(address(this), to, a);
+    }
+
+    function okTransitive(address to, uint256 a) public {
+        address tmp = address(msg.sender);
+        token.transferFrom(tmp, to, a);
+    }
+
+    function okPayableCast(address to, uint256 a) public {
+        token.transferFrom(payable(msg.sender), to, a);
+    }
+
+    function okRequireEq(address from, address to, uint256 a) public {
+        require(from == msg.sender, "auth");
+        token.transferFrom(from, to, a);
+    }
+
+    function okAssertEq(address from, address to, uint256 a) public {
+        assert(from == msg.sender);
+        token.transferFrom(from, to, a);
+    }
+
+    function okConjunction(address from, address to, uint256 a) public {
+        require(from == msg.sender && to != address(0), "auth");
+        token.transferFrom(from, to, a);
+    }
+
+    // Short-circuit: the equality holds by the time the sink runs.
+    function okRequireShortCircuit(address from, address to, uint256 a) public {
+        require(from == msg.sender && token.transferFrom(from, to, a));
+    }
+
+    function okIfRevert(address from, address to, uint256 a) public {
+        if (from != msg.sender) revert("auth");
+        token.transferFrom(from, to, a);
+    }
+
+    function okParens(address to, uint256 a) public {
+        token.transferFrom((msg.sender), to, a);
+    }
+
+    function okTernaryBothSafe(bool flag, address to, uint256 a) public {
+        address x = flag ? msg.sender : address(this);
+        token.transferFrom(x, to, a);
+    }
+
+    function okTuple(address to, uint256 a, address ignored) public {
+        (address x, ) = (msg.sender, ignored);
+        token.transferFrom(x, to, a);
+    }
+
+    modifier onlySelf(address f) {
+        require(f == msg.sender, "auth");
+        _;
+    }
+
+    function okModifier(address from, address to, uint256 a) public onlySelf(from) {
+        token.transferFrom(from, to, a);
+    }
+
+    // Mutable storage modifier-arg reassigned before the sink.
+    function badModifierMutableState(address from, address to, uint256 a) public onlySelf(owner) {
+        owner = from;
+        token.transferFrom(owner, to, a); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    // OpenZeppelin's `_msgSender()` resolves to `msg.sender`.
+    function okMsgSenderHelper(address to, uint256 a) public {
+        token.transferFrom(_msgSender(), to, a);
+    }
+
+    // Helper chain `_origin -> _msgSender -> msg.sender` within depth budget.
+    function okMsgSenderHelperChain(address to, uint256 a) public {
+        token.transferFrom(_origin(), to, a);
+    }
+
+    function okMsgSenderHelperGuard(address from, address to, uint256 a) public {
+        require(from == _msgSender(), "auth");
+        token.transferFrom(from, to, a);
+    }
+
+    // `assert(false)` is recognised as an exit.
+    function okIfAssertFalse(address from, address to, uint256 a) public {
+        if (from != msg.sender) {
+            assert(false);
+        }
+        token.transferFrom(from, to, a);
+    }
+
+    // `immutable` state vars can chain: storage cannot be rewritten post-deploy.
+    function okImmutableOwnerChain(address from, address to, uint256 a) public {
+        require(trustedOwner == msg.sender, "owner");
+        require(from == trustedOwner, "from");
+        token.transferFrom(from, to, a);
+    }
+
+    // Same token, spender == this, owner matches `from`, no reassignment.
+    function okPermit(
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        token.permit(from, address(this), a, deadline, v, r, s);
+        token.transferFrom(from, to, a);
+    }
+
+    // ERC721 same-named methods must NOT trigger this lint.
+    function okErc721TransferFrom(address from, address to, uint256 id) public {
+        nft.transferFrom(from, to, id);
+        nft.safeTransferFrom(from, to, id);
+        nft.safeTransferFrom(from, to, id, "");
+    }
+
+    // View / pure functions are out of scope.
+    function viewMethodsExempt(address from, address to, uint256 a) external view returns (uint256) {
+        from;
+        to;
+        a;
+        return 0;
+    }
+
+    // Reassignment that *establishes* safety.
+    function okReassignToSafe(address from, address to, uint256 a) public {
+        address x = from;
+        x = msg.sender;
+        token.transferFrom(x, to, a);
+    }
+
+    function okLibrarySelf(address to, uint256 a) public {
+        SafeERC20.safeTransferFrom(token, address(this), to, a);
+    }
+
+    // Explicit `else` inherits the negated guard.
+    function okExplicitElseGuard(address from, address to, uint256 a) public {
+        if (from != msg.sender) {
+            revert("auth");
+        } else {
+            token.transferFrom(from, to, a);
+        }
+    }
+
+    // EIP-3156 lender repayment: receiver is trusted after `.onFlashLoan(...)`.
+    function okFlashLender(
+        IERC3156FlashBorrower receiver,
+        uint256 amount,
+        uint256 fee,
+        bytes calldata data
+    ) public returns (bool) {
+        token.transfer(address(receiver), amount);
+        receiver.onFlashLoan(msg.sender, address(token), amount, fee, data);
+        token.transferFrom(address(receiver), address(this), amount + fee);
+        return true;
+    }
+
+    function badTupleReassignKillsSafe(address from, address to, uint256 a) public {
+        address x = msg.sender;
+        address y;
+        (x, y) = (from, to);
+        token.transferFrom(x, to, a); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    function badFlashLoanInBranch(
+        bool flag,
+        IERC3156FlashBorrower receiver,
+        uint256 amount,
+        uint256 fee,
+        bytes calldata data
+    ) public {
+        if (flag) {
+            receiver.onFlashLoan(msg.sender, address(token), amount, fee, data);
+        }
+        token.transferFrom(address(receiver), address(this), amount + fee); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    function badFlashLoanReceiverReassigned(
+        IERC3156FlashBorrower receiver,
+        IERC3156FlashBorrower untrusted,
+        uint256 amount,
+        uint256 fee,
+        bytes calldata data
+    ) public {
+        receiver.onFlashLoan(msg.sender, address(token), amount, fee, data);
+        receiver = untrusted;
+        token.transferFrom(address(receiver), address(this), amount + fee); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    function badFakeFlashLoan(
+        IFakeFlashBorrower fake,
+        address to,
+        uint256 a,
+        bytes calldata data
+    ) public {
+        fake.onFlashLoan(data);
+        token.transferFrom(address(fake), to, a); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    // Pull-back token differs from the one passed to the hook.
+    function badFlashLoanWrongToken(
+        IERC3156FlashBorrower receiver,
+        uint256 amount,
+        uint256 fee,
+        bytes calldata data
+    ) public {
+        receiver.onFlashLoan(msg.sender, address(token), amount, fee, data);
+        other.transferFrom(address(receiver), address(this), amount + fee); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    // Pull-back recipient isn't the lender.
+    function badFlashLoanWrongRecipient(
+        IERC3156FlashBorrower receiver,
+        address attacker,
+        uint256 amount,
+        uint256 fee,
+        bytes calldata data
+    ) public {
+        receiver.onFlashLoan(msg.sender, address(token), amount, fee, data);
+        token.transferFrom(address(receiver), attacker, amount + fee); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    // Pull-back amount isn't `amount + fee`.
+    function badFlashLoanWrongAmount(
+        IERC3156FlashBorrower receiver,
+        uint256 amount,
+        uint256 fee,
+        uint256 other,
+        bytes calldata data
+    ) public {
+        receiver.onFlashLoan(msg.sender, address(token), amount, fee, data);
+        token.transferFrom(address(receiver), address(this), other); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    // Second pull-back after the obligation has been consumed.
+    function badFlashLoanDoublePull(
+        IERC3156FlashBorrower receiver,
+        uint256 amount,
+        uint256 fee,
+        bytes calldata data
+    ) public {
+        receiver.onFlashLoan(msg.sender, address(token), amount, fee, data);
+        token.transferFrom(address(receiver), address(this), amount + fee);
+        token.transferFrom(address(receiver), address(this), amount + fee); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    // `fee + amount` (commuted) still matches.
+    function okFlashLoanCommutativeAmount(
+        IERC3156FlashBorrower receiver,
+        uint256 amount,
+        uint256 fee,
+        bytes calldata data
+    ) public {
+        receiver.onFlashLoan(msg.sender, address(token), amount, fee, data);
+        token.transferFrom(address(receiver), address(this), fee + amount);
+    }
+
+    function badNamedMember(address from, address to, uint256 a) public {
+        token.transferFrom({from: from, to: to, amount: a}); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    function badNamedLibrary(address from, address to, uint256 a) public {
+        SafeERC20.safeTransferFrom({token: token, from: from, to: to, value: a}); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    function okNamedMemberSender(address to, uint256 a) public {
+        token.transferFrom({from: msg.sender, to: to, amount: a});
+    }
+
+    function okNamedLibrarySelf(address to, uint256 a) public {
+        SafeERC20.safeTransferFrom({token: token, from: address(this), to: to, value: a});
+    }
+
+    function okNamedPermit(
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        token.permit({
+            owner: from,
+            spender: address(this),
+            value: a,
+            deadline: deadline,
+            v: v,
+            r: r,
+            s: s
+        });
+        token.transferFrom({from: from, to: to, amount: a});
+    }
+
+    // `delete` clears a prior safe-fact.
+    function badDeleteKillsSafe(address from, address to, uint256 a) public {
+        address x = msg.sender;
+        delete x;
+        x = from;
+        token.transferFrom(x, to, a); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    // Flash-loan call on the RHS of `&&` may not execute; its repayment must not leak.
+    function badFlashLoanShortCircuit(
+        bool flag,
+        IERC3156FlashBorrower receiver,
+        bytes32 MAGIC,
+        uint256 amount,
+        uint256 fee,
+        bytes calldata data
+    ) public returns (bool) {
+        bool ok = flag
+            && receiver.onFlashLoan(msg.sender, address(token), amount, fee, data) == MAGIC;
+        token.transferFrom(address(receiver), address(this), amount + fee); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+        return ok;
+    }
+
+    // `do-while` body runs at least once — facts established inside flow out.
+    function okDoWhileEstablishesSafe(address from, address to, uint256 a) public {
+        address x = from;
+        do {
+            x = msg.sender;
+        } while (false);
+        token.transferFrom(x, to, a);
+    }
+
+    // `break` may skip the safe assignment.
+    function badDoWhileBreakSkipsSafe(address from, address to, uint256 a, bool flag) public {
+        address x = from;
+        do {
+            if (flag) break;
+            x = msg.sender;
+        } while (false);
+        token.transferFrom(x, to, a); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    function badDoWhileBreakSkipsResafe(address from, address to, uint256 a, bool flag) public {
+        address x = msg.sender;
+        do {
+            x = from;
+            if (flag) break;
+            x = msg.sender;
+        } while (false);
+        token.transferFrom(x, to, a); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    // `continue` may also skip the safe assignment.
+    function badDoWhileContinueSkipsSafe(address from, address to, uint256 a, bool flag) public {
+        address x = from;
+        do {
+            if (flag) continue;
+            x = msg.sender;
+        } while (false);
+        token.transferFrom(x, to, a); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    // Nested-loop `break` doesn't target the outer do-while.
+    function okDoWhileNestedLoopBreak(address from, address to, uint256 a) public {
+        address x = from;
+        do {
+            for (uint256 i = 0; i < 1; i++) {
+                if (i == 0) break;
+            }
+            x = msg.sender;
+        } while (false);
+        token.transferFrom(x, to, a);
+    }
+
+    // A single flash-loan callback must not license repeated pulls across loop iterations.
+    function badFlashLoanLoopPull(
+        IERC3156FlashBorrower receiver,
+        uint256 amount,
+        uint256 fee,
+        uint256 n,
+        bytes calldata data
+    ) public {
+        receiver.onFlashLoan(msg.sender, address(token), amount, fee, data);
+        for (uint256 i = 0; i < n; i++) {
+            token.transferFrom(address(receiver), address(this), amount + fee); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+        }
+    }
+
+    // Mint-and-consume both inside the same loop body is still safe on every iteration.
+    function okFlashLoanPerIterationMintAndPull(
+        IERC3156FlashBorrower receiver,
+        uint256 amount,
+        uint256 fee,
+        uint256 n,
+        bytes calldata data
+    ) public {
+        for (uint256 i = 0; i < n; i++) {
+            receiver.onFlashLoan(msg.sender, address(token), amount, fee, data);
+            token.transferFrom(address(receiver), address(this), amount + fee);
+        }
+    }
+
+    // A pull *after* a loop that never touches the repayment is still safely licensed by the
+    // pre-loop mint - the loop-entry floor only restricts sinks reached *inside* that loop.
+    function okFlashLoanPostLoopPullStillGuarded(
+        IERC3156FlashBorrower receiver,
+        uint256 amount,
+        uint256 fee,
+        uint256 n,
+        bytes calldata data
+    ) public {
+        receiver.onFlashLoan(msg.sender, address(token), amount, fee, data);
+        for (uint256 i = 0; i < n; i++) {
+            unrelatedSideEffect();
+        }
+        token.transferFrom(address(receiver), address(this), amount + fee);
+    }
+
+    // Same shape, nested: an inner loop unrelated to the repayment must not strip the license
+    // from a pull that happens after both loops return.
+    function okFlashLoanNestedUnrelatedLoop(
+        IERC3156FlashBorrower receiver,
+        uint256 amount,
+        uint256 fee,
+        uint256 n,
+        uint256 m,
+        bytes calldata data
+    ) public {
+        receiver.onFlashLoan(msg.sender, address(token), amount, fee, data);
+        for (uint256 i = 0; i < n; i++) {
+            for (uint256 j = 0; j < m; j++) {
+                unrelatedSideEffect();
+            }
+        }
+        token.transferFrom(address(receiver), address(this), amount + fee);
+    }
+
+    // `do { ... } while (false)` provably runs exactly once, so the repeated-pull hazard the
+    // loop-entry floor guards against doesn't actually apply here - but the lint has no
+    // constant-condition reasoning to prove that, so it conservatively still flags this as if
+    // the license could be replayed. Accepted false positive on an otherwise-safe idiom, in
+    // exchange for never missing a real repeated-pull drain.
+    function badFlashLoanDoWhileFalseSingleIteration(
+        IERC3156FlashBorrower receiver,
+        uint256 amount,
+        uint256 fee,
+        bytes calldata data
+    ) public {
+        receiver.onFlashLoan(msg.sender, address(token), amount, fee, data);
+        do {
+            token.transferFrom(address(receiver), address(this), amount + fee); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+        } while (false);
+    }
+
+    // Same accepted trade-off, spelled as a `while` that always breaks on its first pass - also
+    // provably single-iteration, also unrecognised as such, also intentionally still flagged.
+    function badFlashLoanWhileBreakSingleIteration(
+        IERC3156FlashBorrower receiver,
+        uint256 amount,
+        uint256 fee,
+        bool cond,
+        bytes calldata data
+    ) public {
+        receiver.onFlashLoan(msg.sender, address(token), amount, fee, data);
+        while (cond) {
+            token.transferFrom(address(receiver), address(this), amount + fee); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+            break;
+        }
+    }
+
+    // Reassigning a repayment's own key variable mid-loop invalidates the stale pre-loop record,
+    // and the fresh `onFlashLoan` right after re-mints the same key - but the loop-entry floor
+    // isn't corrected for that, since doing so in-place would leak across sibling `if`/`try`
+    // branches sharing this loop (see the comment on `invalidate`). Accepted false positive on
+    // a rare shape, in exchange for the floor mechanism never producing a branch-sensitive
+    // false negative.
+    function badFlashLoanReassignThenRemintInLoop(
+        IERC3156FlashBorrower receiver,
+        uint256 amount,
+        uint256 fee,
+        uint256 n,
+        bytes calldata data
+    ) public {
+        receiver.onFlashLoan(msg.sender, address(token), amount, fee, data);
+        for (uint256 i = 0; i < n; i++) {
+            receiver = receiver;
+            receiver.onFlashLoan(msg.sender, address(token), amount, fee, data);
+            token.transferFrom(address(receiver), address(this), amount + fee); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+        }
+    }
+
+    function unrelatedSideEffect() internal {}
+
+    // -- MODIFIER BODY SINKS --
+
+    modifier pullBad(address from, address to, uint256 a) {
+        token.transferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+        _;
+    }
+
+    function modifierSinkBad(address from, address to, uint256 a) public pullBad(from, to, a) {}
+
+    modifier guardedPullOk(address from, address to, uint256 a) {
+        require(from == msg.sender, "auth");
+        token.transferFrom(from, to, a);
+        _;
+    }
+
+    function modifierGuardedSinkOk(address from, address to, uint256 a) public guardedPullOk(from, to, a) {}
+
+    modifier pullBeforeGuardBad(address from, address to, uint256 a) {
+        token.transferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+        require(from == msg.sender, "auth");
+        _;
+    }
+
+    function modifierSinkBeforeGuardBad(address from, address to, uint256 a)
+        public
+        pullBeforeGuardBad(from, to, a)
+    {}
+
+    // A modifier is only reachable through its invocations, so a `from` that is safe at every
+    // invocation site is not arbitrary.
+    modifier pullFromCallerOk(address from, address to, uint256 a) {
+        token.transferFrom(from, to, a);
+        _;
+    }
+
+    function modifierSenderInvocationOk(address to, uint256 a) public pullFromCallerOk(msg.sender, to, a) {}
+
+    function modifierSelfInvocationOk(address to, uint256 a) public pullFromCallerOk(address(this), to, a) {}
+
+    modifier pullFromMixedCallersBad(address from, address to, uint256 a) {
+        token.transferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+        _;
+    }
+
+    function modifierMixedSenderInvocation(address to, uint256 a) public pullFromMixedCallersBad(msg.sender, to, a) {}
+
+    function modifierMixedArbitraryInvocation(address from, address to, uint256 a)
+        public
+        pullFromMixedCallersBad(from, to, a)
+    {}
+
+    // Statements after `_;` keep the prefix facts: parameters and locals cannot be changed by the
+    // wrapped function body, and mutable state is never trusted in the first place.
+    modifier guardedSuffixPullOk(address from, address to, uint256 a) {
+        require(from == msg.sender, "auth");
+        _;
+        token.transferFrom(from, to, a);
+    }
+
+    function modifierGuardedSuffixOk(address from, address to, uint256 a)
+        public
+        guardedSuffixPullOk(from, to, a)
+    {}
+
+    modifier suffixPullBad(address from, address to, uint256 a) {
+        _;
+        token.transferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    function modifierSuffixSinkBad(address from, address to, uint256 a) public suffixPullBad(from, to, a) {}
+
+    // -- FALLBACK SINKS --
+    // Only constructors are excluded, so a sink reachable through `fallback`/`receive` is
+    // reported like one in an ordinary function.
+
+    fallback(bytes calldata data) external returns (bytes memory) {
+        address from = abi.decode(data, (address));
+        token.transferFrom(from, msg.sender, 1); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+        return "";
+    }
+}
+
+// Struct / array / mapping receivers.
+contract ContainerReceivers {
+    struct Config {
+        IERC20 token;
+    }
+
+    Config cfg;
+    IERC20[] tokens;
+    mapping(uint256 => IERC20) tokenMap;
+
+    function badStructFieldReceiver(address from, address to, uint256 a) public {
+        cfg.token.transferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    function badArrayElementReceiver(address from, address to, uint256 a) public {
+        tokens[0].transferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    function badMappingValueReceiver(uint256 id, address from, address to, uint256 a) public {
+        tokenMap[id].transferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    function okStructFieldSender(address to, uint256 a) public {
+        cfg.token.transferFrom(msg.sender, to, a);
+    }
+}
+
+// Solady-style: first param is `address`, not a contract type.
+library SafeTransferLib {
+    function safeTransferFrom(address token, address from, address to, uint256 amount) internal {
+        token; from; to; amount; // body intentionally elided.
+    }
+}
+
+contract SoladyCallSites {
+    address token;
+
+    function badSolady(address from, address to, uint256 a) public {
+        SafeTransferLib.safeTransferFrom(token, from, to, a); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    function okSoladySender(address to, uint256 a) public {
+        SafeTransferLib.safeTransferFrom(token, msg.sender, to, a);
+    }
+
+    function okSoladySelf(address to, uint256 a) public {
+        SafeTransferLib.safeTransferFrom(token, address(this), to, a);
+    }
+}
+
+contract InternalForwardedPulls {
+    address token;
+
+    function okDeposit(address to, uint256 a) public {
+        _pull(msg.sender, to, a);
+    }
+
+    function okMint(address to, uint256 a) public {
+        _pull(payable(msg.sender), to, a);
+    }
+
+    function badForward(address from, address to, uint256 a) public {
+        _mixedPull(from, to, a);
+    }
+
+    function okForward(address to, uint256 a) public {
+        _mixedPull(msg.sender, to, a);
+    }
+
+    function _pull(address from, address to, uint256 a) internal {
+        SafeTransferLib.safeTransferFrom(token, from, to, a);
+    }
+
+    function _mixedPull(address from, address to, uint256 a) internal {
+        SafeTransferLib.safeTransferFrom(token, from, to, a); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+}
+
+// `using ... for address`: 3-arg `safeTransferFrom` member call on an `address`.
+contract SoladyUsingForAddress {
+    using SafeTransferLib for address;
+
+    address token;
+
+    function badSoladyMember(address from, address to, uint256 a) public {
+        token.safeTransferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    function okSoladyMemberSender(address to, uint256 a) public {
+        token.safeTransferFrom(msg.sender, to, a);
+    }
+
+    function okSoladyMemberSelf(address to, uint256 a) public {
+        token.safeTransferFrom(address(this), to, a);
+    }
+
+    function okSoladyMemberGuarded(address from, address to, uint256 a) public {
+        require(from == msg.sender, "auth");
+        token.safeTransferFrom(from, to, a);
+    }
+
+    function badNamedSolady(address from, address to, uint256 a) public {
+        SafeTransferLib.safeTransferFrom({token: token, from: from, to: to, amount: a}); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    function okNamedSoladySender(address to, uint256 a) public {
+        SafeTransferLib.safeTransferFrom({token: token, from: msg.sender, to: to, amount: a});
+    }
+}
+
+// ERC721 helper shares the 4-arg shape but is not ERC20.
+library SafeERC721 {
+    function safeTransferFrom(IERC721 nft, address from, address to, uint256 tokenId) internal {
+        nft.safeTransferFrom(from, to, tokenId);
+    }
+}
+
+contract SafeERC721CallSites {
+    IERC721 nft;
+
+    function okSafeErc721ArbitraryFrom(address from, address to, uint256 tokenId) public {
+        SafeERC721.safeTransferFrom(nft, from, to, tokenId);
+    }
+}
+
+// A modifier declared in a base contract is seeded from the invocations in derived contracts.
+abstract contract PullModifierBase {
+    IERC20 internal token;
+
+    modifier pullFromCaller(address from, uint256 a) {
+        token.transferFrom(from, address(this), a);
+        _;
+    }
+
+    modifier pullFromAnyone(address from, uint256 a) {
+        token.transferFrom(from, address(this), a); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+        _;
+    }
+}
+
+contract PullModifierDerived is PullModifierBase {
+    function depositFromSender(uint256 a) external pullFromCaller(msg.sender, a) {}
+
+    function depositFrom(address from, uint256 a) external pullFromAnyone(from, a) {}
+}
+
+interface InheritedERC20 is IERC20 {}
+
+interface InheritedFlashBorrower is IERC3156FlashBorrower {}
+
+contract InheritedERC20CallSites {
+    function arbitraryFrom(InheritedERC20 token, address from, address to, uint256 amount) external {
+        token.transferFrom(from, to, amount); //~WARN: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+    }
+
+    function callerFrom(InheritedERC20 token, address to, uint256 amount) external {
+        token.transferFrom(msg.sender, to, amount);
+    }
+
+    function flashRepayment(InheritedERC20 token, InheritedFlashBorrower receiver, uint256 amount, uint256 fee, bytes calldata data) external {
+        token.transfer(address(receiver), amount);
+        receiver.onFlashLoan(msg.sender, address(token), amount, fee, data);
+        token.transferFrom(address(receiver), address(this), amount + fee);
+    }
+}
diff --git a/crates/lint/testdata/ArbitrarySendErc20.stderr b/crates/lint/testdata/ArbitrarySendErc20.stderr
new file mode 100644
index 0000000000000..248c9081b58bb
--- /dev/null
+++ b/crates/lint/testdata/ArbitrarySendErc20.stderr
@@ -0,0 +1,488 @@
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(from, to, a);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.safeTransferFrom(from, to, a);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     SafeERC20.safeTransferFrom(token, from, to, a);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(x, to, a);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(x, to, a);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(x, to, a);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(senders[0], to, a);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(from, to, a);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(from, to, a);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(from, to, a);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(from, to, a);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     t.transferFrom(from, to, a);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(x, to, a);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(from, to, a);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(from, to, a);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     IERC20(rawToken).transferFrom(from, to, a);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     require(token.transferFrom(from, to, a) && from == msg.sender);
+   │               ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     assert(token.transferFrom(from, to, a) && from == msg.sender);
+   │              ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(x, to, a);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(from, to, a);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(from, to, a);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(x, to, a);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(x, to, a);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(from, to, a);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(from, to, a);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(owner, to, a);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(from, to, a);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(owner, to, a);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(x, to, a);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(address(receiver), address(this), amount + fee);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(address(receiver), address(this), amount + fee);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(address(fake), to, a);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     other.transferFrom(address(receiver), address(this), amount + fee);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(address(receiver), attacker, amount + fee);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(address(receiver), address(this), other);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(address(receiver), address(this), amount + fee);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom({from: from, to: to, amount: a});
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     SafeERC20.safeTransferFrom({token: token, from: from, to: to, value: a});
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(x, to, a);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(address(receiver), address(this), amount + fee);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(x, to, a);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(x, to, a);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(x, to, a);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(address(receiver), address(this), amount + fee);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(address(receiver), address(this), amount + fee);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(address(receiver), address(this), amount + fee);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(address(receiver), address(this), amount + fee);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(from, to, a);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(from, to, a);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(from, to, a);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(from, to, a);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(from, msg.sender, 1);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     cfg.token.transferFrom(from, to, a);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     tokens[0].transferFrom(from, to, a);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     tokenMap[id].transferFrom(from, to, a);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     SafeTransferLib.safeTransferFrom(token, from, to, a);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     SafeTransferLib.safeTransferFrom(token, from, to, a);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.safeTransferFrom(from, to, a);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     SafeTransferLib.safeTransferFrom({token: token, from: from, to: to, amount: a});
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(from, address(this), a);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
+warning[arbitrary-send-erc20]: `transferFrom` uses an arbitrary `from`; require it to equal `msg.sender` or `address(this)`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20.sol:LL:CC
+   │
+LL │ …     token.transferFrom(from, to, amount);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20
+
diff --git a/crates/lint/testdata/ArbitrarySendErc20Permit.sol b/crates/lint/testdata/ArbitrarySendErc20Permit.sol
new file mode 100644
index 0000000000000..9ddc25e38d3e8
--- /dev/null
+++ b/crates/lint/testdata/ArbitrarySendErc20Permit.sol
@@ -0,0 +1,1298 @@
+//@compile-flags: --only-lint arbitrary-send-erc20-permit
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+interface IERC20 {
+    function transfer(address to, uint256 amount) external returns (bool);
+    function transferFrom(address from, address to, uint256 amount) external returns (bool);
+    function approve(address spender, uint256 amount) external returns (bool);
+    function permit(
+        address owner,
+        address spender,
+        uint256 value,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) external;
+}
+
+interface IERC721 {
+    // Same name as ERC20 but a different ABI; permit-variant must not flag.
+    function transferFrom(address from, address to, uint256 tokenId) external;
+    function safeTransferFrom(address from, address to, uint256 tokenId) external;
+}
+
+interface IERC3156FlashBorrower {
+    function onFlashLoan(
+        address initiator,
+        address token,
+        uint256 amount,
+        uint256 fee,
+        bytes calldata data
+    ) external returns (bytes32);
+}
+
+library SafeERC20 {
+    function safeTransferFrom(
+        IERC20 token,
+        address from,
+        address to,
+        uint256 value
+    ) internal {
+        require(token.transferFrom(from, to, value), "SafeERC20: transferFrom failed");
+    }
+
+    function safePermit(
+        IERC20 token,
+        address owner,
+        address spender,
+        uint256 value,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) internal {
+        token.permit(owner, spender, value, deadline, v, r, s);
+    }
+}
+
+// Solady-shaped library that takes the token as a raw `address`. Enables the
+// `using ... for address` sink branch in the analyzer.
+library SafeTransferLib {
+    function safeTransferFrom(address token, address from, address to, uint256 amount) internal {
+        // Body intentionally trivial; matched structurally by signature.
+        (token, from, to, amount);
+    }
+}
+
+struct Config {
+    IERC20 token;
+}
+
+contract ArbitrarySendErc20Permit {
+    using SafeERC20 for IERC20;
+    using SafeTransferLib for address;
+
+    IERC20 public token;
+    IERC20 public other;
+    IERC721 public nft;
+    address public owner;
+    address public immutable trustedOwner;
+    address public immutable vault;
+    Config public cfg;
+
+    constructor(address _trustedOwner) {
+        trustedOwner = _trustedOwner;
+        vault = address(this);
+    }
+
+    function _msgSender() internal view returns (address) {
+        return msg.sender;
+    }
+
+    function _self() internal view returns (address) {
+        return address(this);
+    }
+
+    modifier onlySelf(address f) {
+        require(f == msg.sender, "auth");
+        _;
+    }
+
+    modifier onlyContract(address spender) {
+        require(spender == address(this), "spender");
+        _;
+    }
+
+    // Rewrites its param: the local fact must NOT be hoisted to the caller's var.
+    modifier rewriteSpender(address spender) {
+        spender = address(this);
+        _;
+    }
+
+    // Prefix definitely exits — the wrapped function body is unreachable.
+    modifier neverRunsBody() {
+        return;
+        _;
+    }
+
+    // -- POSITIVE CASES (should warn with `arbitrary-send-erc20-permit`) --
+
+    // Plain permit + arbitrary-`from` transferFrom on the same `(token, owner)`.
+    function badPermitPlain(
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        token.permit(from, address(this), a, deadline, v, r, s);
+        token.transferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from` after `permit`
+    }
+
+    // Same as `badPermitPlain` but the sink is `safeTransferFrom` (member form).
+    function badPermitSafeMember(
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        token.permit(from, address(this), a, deadline, v, r, s);
+        token.safeTransferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from` after `permit`
+    }
+
+    // Library-form sink: `SafeERC20.safeTransferFrom(token, from, to, a)` still triggers
+    // when a permit on the same `(token, from)` was recorded earlier.
+    function badPermitLibrary(
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        token.permit(from, address(this), a, deadline, v, r, s);
+        SafeERC20.safeTransferFrom(token, from, to, a); //~WARN: `transferFrom` uses an arbitrary `from` after `permit`
+    }
+
+    // Named-args form for both permit and transferFrom.
+    function badPermitNamedArgs(
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        token.permit({
+            owner: from,
+            spender: address(this),
+            value: a,
+            deadline: deadline,
+            v: v,
+            r: r,
+            s: s
+        });
+        token.transferFrom({from: from, to: to, amount: a}); //~WARN: `transferFrom` uses an arbitrary `from` after `permit`
+    }
+
+    // Both `then` and `else` permit + sink: each branch sink emits independently.
+    function badPermitBothBranches(
+        bool flag,
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        if (flag) {
+            token.permit(from, address(this), a, deadline, v, r, s);
+            token.transferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from` after `permit`
+        } else {
+            token.permit(from, address(this), a, deadline, v, r, s);
+            token.transferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from` after `permit`
+        }
+    }
+
+    // Permit set on every path before a post-join sink: join intersection keeps the
+    // permit record, so the single sink emits once.
+    function badPermitBothBranchesThenSink(
+        bool flag,
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        if (flag) {
+            token.permit(from, address(this), a, deadline, v, r, s);
+        } else {
+            token.permit(from, address(this), a, deadline, v, r, s);
+        }
+        token.transferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from` after `permit`
+    }
+
+    // Permit + sink inside a single branch.
+    function badPermitInBranch(
+        bool flag,
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        if (flag) {
+            token.permit(from, address(this), a, deadline, v, r, s);
+            token.transferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from` after `permit`
+        }
+    }
+
+    // Permit + sink across an `unchecked` block.
+    function badPermitInUncheckedBlock(
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        token.permit(from, address(this), a, deadline, v, r, s);
+        unchecked {
+            token.transferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from` after `permit`
+        }
+    }
+
+    // Interface-cast token: `IERC20(rawToken).permit(...)` then
+    // `IERC20(rawToken).transferFrom(...)` correlates via the underlying address var.
+    function badPermitInterfaceCast(
+        address rawToken,
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        IERC20(rawToken).permit(from, address(this), a, deadline, v, r, s);
+        IERC20(rawToken).transferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from` after `permit`
+    }
+
+    // Solady-shaped library: token passed as raw `address`; permit recorded under the
+    // same underlying var via the interface cast.
+    function badPermitSoladyAddressToken(
+        address rawToken,
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        IERC20(rawToken).permit(from, address(this), a, deadline, v, r, s);
+        SafeTransferLib.safeTransferFrom(rawToken, from, to, a); //~WARN: `transferFrom` uses an arbitrary `from` after `permit`
+    }
+
+    // `using SafeTransferLib for address;` member form: `rawToken.safeTransferFrom(...)`.
+    function badPermitSoladyUsingForAddress(
+        address rawToken,
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        IERC20(rawToken).permit(from, address(this), a, deadline, v, r, s);
+        rawToken.safeTransferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from` after `permit`
+    }
+
+    // `do { permit } while (false)` establishes the permit on the only path through the
+    // loop body (no `break`/`continue`).
+    function badPermitDoWhileEstablishesPermit(
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        do {
+            token.permit(from, address(this), a, deadline, v, r, s);
+        } while (false);
+        token.transferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from` after `permit`
+    }
+
+    // Try-call succeeds; sink inside the success clause inherits the permit from the
+    // pre-try state. Falls within the try body's isolation scope: established facts from
+    // before the try are visible inside the clause.
+    function badPermitBeforeTrySinkInClause(
+        IERC20 t,
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        token.permit(from, address(this), a, deadline, v, r, s);
+        try t.transfer(to, a) returns (bool) {
+            token.transferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from` after `permit`
+        } catch {}
+    }
+
+    // Spender alias: `address self = address(this)` then `permit(owner, self, …)`.
+    // The local copy is tracked in `self_vars`, so the permit IS recorded.
+    function badPermitSpenderAlias(
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        address self = address(this);
+        token.permit(from, self, a, deadline, v, r, s);
+        token.transferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from` after `permit`
+    }
+
+    // `payable(address(this))` as spender — also recognised.
+    function badPermitPayableSelfSpender(
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        token.permit(from, payable(address(this)), a, deadline, v, r, s);
+        token.transferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from` after `permit`
+    }
+
+    // Two distinct permits on two distinct tokens, sinks in reverse order. Permits don't
+    // consume on use, so both transferFroms must warn.
+    function badTwoPermitsTwoSinksDifferentOrder(
+        address from1,
+        address from2,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        token.permit(from1, address(this), a, deadline, v, r, s);
+        other.permit(from2, address(this), a, deadline, v, r, s);
+        other.transferFrom(from2, to, a); //~WARN: `transferFrom` uses an arbitrary `from` after `permit`
+        token.transferFrom(from1, to, a); //~WARN: `transferFrom` uses an arbitrary `from` after `permit`
+    }
+
+    // Modifier-hoisted `spender == address(this)` guard. The modifier proves the caller
+    // arg is a self alias; the permit IS recorded and the post-permit sink flags the
+    // permit variant.
+    function badPermitModifierSpenderGuard(
+        address from,
+        address spender,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public onlyContract(spender) {
+        token.permit(from, spender, a, deadline, v, r, s);
+        token.transferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from` after `permit`
+    }
+
+    // Equality guard `spender == address(this)` makes `spender` a self alias for the
+    // permit call, so the record IS stored and the subsequent sink flags the permit
+    // variant.
+    function badPermitEqualityGuardSpender(
+        address from,
+        address spender,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        require(spender == address(this), "spender");
+        token.permit(from, spender, a, deadline, v, r, s);
+        token.transferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from` after `permit`
+    }
+
+    // After consuming an EIP-3156 repayment, the *same-shape* second pullback no longer
+    // qualifies as a repayment, but the permit record survives → permit-variant fires.
+    function badPermitPlusFlashDoublePull(
+        IERC3156FlashBorrower receiver,
+        uint256 amount,
+        uint256 fee,
+        bytes calldata data,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        token.permit(address(receiver), address(this), amount + fee, deadline, v, r, s);
+        receiver.onFlashLoan(msg.sender, address(token), amount, fee, data);
+        token.transferFrom(address(receiver), address(this), amount + fee);
+        token.transferFrom(address(receiver), address(this), amount + fee); //~WARN: `transferFrom` uses an arbitrary `from` after `permit`
+    }
+
+    // Permit + double flash-loan: two identical `onFlashLoan` calls each license one
+    // repayment, so two same-shape transferFroms both consume; neither warns. (Negative.)
+    function okPermitDoubleFlashRepayment(
+        IERC3156FlashBorrower receiver,
+        uint256 amount,
+        uint256 fee,
+        bytes calldata data,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        token.permit(address(receiver), address(this), amount + fee, deadline, v, r, s);
+        receiver.onFlashLoan(msg.sender, address(token), amount, fee, data);
+        receiver.onFlashLoan(msg.sender, address(token), amount, fee, data);
+        token.transferFrom(address(receiver), address(this), amount + fee);
+        token.transferFrom(address(receiver), address(this), amount + fee);
+    }
+
+    // Repayment recipient is a local self alias (`address self = address(this)`); the
+    // sink's `to` is the alias, which `is_self_expr` recognises. Repayment is consumed,
+    // no warning.
+    function okPermitFlashRepaymentSelfAlias(
+        IERC3156FlashBorrower receiver,
+        uint256 amount,
+        uint256 fee,
+        bytes calldata data,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        address self = address(this);
+        token.permit(address(receiver), self, amount + fee, deadline, v, r, s);
+        receiver.onFlashLoan(msg.sender, address(token), amount, fee, data);
+        token.transferFrom(address(receiver), self, amount + fee);
+    }
+
+    // Disjunction guard: `from == msg.sender || from == address(this)` proves `from` is
+    // safe (both disjuncts establish the same fact); no warning expected.
+    function okPermitDisjunctionGuard(
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        require(from == msg.sender || from == address(this), "auth");
+        token.permit(from, address(this), a, deadline, v, r, s);
+        token.transferFrom(from, to, a);
+    }
+
+    // -- NEGATIVE CASES (should NOT warn with `arbitrary-send-erc20-permit`) --
+
+    // Caller is `msg.sender` — even if permit silently no-ops, only the caller's own balance
+    // is touched.
+    function okPermitMsgSender(
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        token.permit(msg.sender, address(this), a, deadline, v, r, s);
+        token.transferFrom(msg.sender, address(this), a);
+    }
+
+    // Explicit equality guard makes `from` provably safe before the sink.
+    function okPermitGuarded(
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        require(from == msg.sender, "auth");
+        token.permit(from, address(this), a, deadline, v, r, s);
+        token.transferFrom(from, to, a);
+    }
+
+    // `from = address(this)`: the contract is moving its own tokens.
+    function okPermitSelf(
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        token.permit(address(this), address(this), a, deadline, v, r, s);
+        token.transferFrom(address(this), to, a);
+    }
+
+    // Modifier-hoisted guard: `f == msg.sender` from the modifier proves `from` safe
+    // before the sink runs.
+    function okPermitModifierGuarded(
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public onlySelf(from) {
+        token.permit(from, address(this), a, deadline, v, r, s);
+        token.transferFrom(from, to, a);
+    }
+
+    // `_msgSender()` helper resolves to `msg.sender` within the depth budget.
+    function okPermitMsgSenderHelper(
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        token.permit(_msgSender(), address(this), a, deadline, v, r, s);
+        token.transferFrom(_msgSender(), address(this), a);
+    }
+
+    // Helper used inside an equality guard.
+    function okPermitMsgSenderHelperGuard(
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        require(from == _msgSender(), "auth");
+        token.permit(from, address(this), a, deadline, v, r, s);
+        token.transferFrom(from, to, a);
+    }
+
+    // Ternary with both safe branches.
+    function okPermitTernaryBothSafe(
+        bool flag,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        address from = flag ? msg.sender : address(this);
+        token.permit(from, address(this), a, deadline, v, r, s);
+        token.transferFrom(from, to, a);
+    }
+
+    // Permit spender is *not* `address(this)` — record is not stored, falls through to the
+    // plain `arbitrary-send-erc20` lint (out of scope here under `--only-lint`).
+    function okPermitWrongSpender(
+        address from,
+        address spender,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        token.permit(from, spender, a, deadline, v, r, s);
+        token.transferFrom(from, to, a);
+    }
+
+    // Permit owner differs from sink `from` — no covering record for this `from`.
+    function okPermitOwnerMismatch(
+        address ownerArg,
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        token.permit(ownerArg, address(this), a, deadline, v, r, s);
+        token.transferFrom(from, to, a);
+    }
+
+    // Permit on a different token than the sink — no match.
+    function okPermitWrongToken(
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        other.permit(from, address(this), a, deadline, v, r, s);
+        token.transferFrom(from, to, a);
+    }
+
+    // Sink BEFORE the permit: no covering record at the sink.
+    function okPermitAfterSink(
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        token.transferFrom(from, to, a);
+        token.permit(from, address(this), a, deadline, v, r, s);
+    }
+
+    // ERC721's `transferFrom`/`safeTransferFrom` overloads must not match as sinks.
+    function okErc721NotAffected(
+        address from,
+        address to,
+        uint256 id,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        token.permit(from, address(this), id, deadline, v, r, s);
+        nft.transferFrom(from, to, id);
+        nft.safeTransferFrom(from, to, id);
+    }
+
+    // View / pure functions are out of scope for the pass entirely.
+    function viewExempt(address from, address to, uint256 a) external view returns (uint256) {
+        from; to; a;
+        return 0;
+    }
+
+    // State-var token reassigned after the permit invalidates the record — the sink falls
+    // through to the plain lint, not the permit-variant.
+    function okPermitStateTokenReassigned(
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        token.permit(from, address(this), a, deadline, v, r, s);
+        token = other;
+        token.transferFrom(from, to, a);
+    }
+
+    // State-var owner reassigned after the permit invalidates the record.
+    function okPermitStateOwnerReassigned(
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        owner = to;
+        token.permit(owner, address(this), a, deadline, v, r, s);
+        owner = to;
+        token.transferFrom(owner, to, a);
+    }
+
+    // Permit token reassigned after the permit invalidates the record.
+    function okPermitTokenReassigned(
+        IERC20 t,
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        t.permit(from, address(this), a, deadline, v, r, s);
+        t = other;
+        t.transferFrom(from, to, a);
+    }
+
+    // Interface-cast raw-token key: reassigning the underlying `rawToken` variable kills
+    // the permit record indexed by that var, so the post-reassign sink falls through to
+    // the plain lint (filtered out here).
+    function okPermitInterfaceCastTokenReassigned(
+        address rawToken,
+        address otherRawToken,
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        IERC20(rawToken).permit(from, address(this), a, deadline, v, r, s);
+        rawToken = otherRawToken;
+        IERC20(rawToken).transferFrom(from, to, a);
+    }
+
+    // Permit owner var reassigned after the permit invalidates the record.
+    function okPermitOwnerReassigned(
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        address x = from;
+        token.permit(x, address(this), a, deadline, v, r, s);
+        x = to;
+        token.transferFrom(x, to, a);
+    }
+
+    // Permit on a side branch must not suppress the fall-through path — and conversely,
+    // must not be reported as a permit-variant on the fall-through where no record exists.
+    function okPermitInOtherBranch(
+        bool flag,
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        if (flag) {
+            token.permit(from, address(this), a, deadline, v, r, s);
+        }
+        // Falls back to the plain `arbitrary-send-erc20` lint (filtered out here).
+        token.transferFrom(from, to, a);
+    }
+
+    // `do { if (flag) break; permit; } while (false);` may break before permit — the
+    // post-loop join drops the permit; sink only triggers the plain lint, not the permit
+    // variant.
+    function okPermitDoWhileBreakSkipsPermit(
+        bool flag,
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        do {
+            if (flag) break;
+            token.permit(from, address(this), a, deadline, v, r, s);
+        } while (false);
+        token.transferFrom(from, to, a);
+    }
+
+    // Permit inside a try clause does NOT leak past the try (visit_isolated semantics);
+    // post-try sink falls through to the plain lint, not the permit variant.
+    function okPermitInsideTryDoesNotLeak(
+        IERC20 t,
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        try t.transfer(to, a) returns (bool) {
+            token.permit(from, address(this), a, deadline, v, r, s);
+        } catch {}
+        token.transferFrom(from, to, a);
+    }
+
+    // Catch only runs if the permit reverted; its facts didn't take effect.
+    function okPermitRevertedCatchSink(
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        try token.permit(from, address(this), a, deadline, v, r, s) {
+        } catch {
+            token.transferFrom(from, to, a);
+        }
+    }
+
+    // Success clause inherits the permit fact.
+    function badPermitTryCallSuccessSink(
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        try token.permit(from, address(this), a, deadline, v, r, s) {
+            token.transferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from` after `permit`
+        } catch {}
+    }
+
+    // EIP-3156 repayment matches and suppresses both lints, even when a permit covering
+    // `(token, address(receiver))` was recorded beforehand.
+    function okPermitPlusFlashRepayment(
+        IERC3156FlashBorrower receiver,
+        uint256 amount,
+        uint256 fee,
+        bytes calldata data,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        token.permit(address(receiver), address(this), amount + fee, deadline, v, r, s);
+        receiver.onFlashLoan(msg.sender, address(token), amount, fee, data);
+        token.transferFrom(address(receiver), address(this), amount + fee);
+    }
+
+    // Dead code after a `return` must not trigger either lint (the analyzer stops
+    // visiting unreachable statements at the function-body top level).
+    function okUnreachableAfterReturn(
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        token.permit(from, address(this), a, deadline, v, r, s);
+        return;
+        // dead — no warning expected
+        token.transferFrom(from, to, a);
+    }
+
+    // Code AFTER a `do { ...; return; } while (false);` is dead too: the do-while
+    // definitely exits because the body always returns and there's no break/continue.
+    function okUnreachableAfterExitingDoWhile(
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        do {
+            token.permit(from, address(this), a, deadline, v, r, s);
+            return;
+        } while (false);
+        // dead — no warning expected
+        token.transferFrom(from, to, a);
+    }
+
+    // Dead code in the do-while fast path (no break/continue) must also be skipped.
+    function okUnreachableAfterReturnInDoWhile(
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        do {
+            token.permit(from, address(this), a, deadline, v, r, s);
+            return;
+            // dead — no warning expected
+            token.transferFrom(from, to, a);
+        } while (false);
+    }
+
+    // Dead code inside a nested branch also must not trigger (block-level dead-code
+    // suppression, not only top-level).
+    function okUnreachableAfterReturnInBranch(
+        bool flag,
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        if (flag) {
+            token.permit(from, address(this), a, deadline, v, r, s);
+            return;
+            // dead — no warning expected
+            token.transferFrom(from, to, a);
+        }
+    }
+
+    // Branch returns early; the post-`if` fall-through inherits an empty state, so the
+    // sink falls through to the plain lint (filtered out here), not the permit variant.
+    function okPermitReturningBranchDoesNotLeak(
+        bool flag,
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        if (flag) {
+            token.permit(from, address(this), a, deadline, v, r, s);
+            return;
+        }
+        token.transferFrom(from, to, a);
+    }
+
+    // Modifier that internally rewrites its `spender` parameter must NOT have the
+    // resulting local fact hoisted to the caller's `spender`. The caller-side `spender`
+    // is unproven, so the permit record is NOT stored and the sink falls through to the
+    // plain `arbitrary-send-erc20` lint (out of scope here).
+    function okPermitModifierWritesParam(
+        address from,
+        address spender,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public rewriteSpender(spender) {
+        token.permit(from, spender, a, deadline, v, r, s);
+        token.transferFrom(from, to, a);
+    }
+
+    // Tuple swap must be evaluated simultaneously: after `(x, sp) = (sp, x)` the original
+    // `sp` (self) has moved to `x`; `sp` now holds the original `x` (unproven). A naive
+    // left-to-right model would falsely keep `sp` in `self_vars`. The permit must NOT be
+    // recorded → sink falls through to the plain lint (out of scope here).
+    function okPermitTupleSwapPreservesFacts(
+        address from,
+        address spender,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        address x = spender;
+        address sp = address(this);
+        (x, sp) = (sp, x);
+        token.permit(from, sp, a, deadline, v, r, s);
+        token.transferFrom(from, to, a);
+    }
+
+    // `from = _msgSender()` propagates safety through the local copy.
+    function okPermitFromAssignedMsgSenderHelper(
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        address from = _msgSender();
+        token.permit(from, address(this), a, deadline, v, r, s);
+        token.transferFrom(from, to, a);
+    }
+
+    // `delete token` resets `token`, killing the permit record indexed by it.
+    function okPermitDeleteTokenKillsRecord(
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        token.permit(from, address(this), a, deadline, v, r, s);
+        delete token;
+        token.transferFrom(from, to, a);
+    }
+
+    // Inline `// forge-lint: disable-next-line(arbitrary-send-erc20-permit)` suppresses
+    // the warning at the next statement.
+    function okInlineDisablePermitLint(
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        token.permit(from, address(this), a, deadline, v, r, s);
+        // forge-lint: disable-next-line(arbitrary-send-erc20-permit)
+        token.transferFrom(from, to, a);
+    }
+
+    // Every catch always reverts, so post-try retains the permit set by `t.expr`.
+    function badPermitTryAllCatchesRevert(
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        try token.permit(from, address(this), a, deadline, v, r, s) {} catch {
+            revert("permit failed");
+        }
+        token.transferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from` after `permit`
+    }
+
+    // do-while runs at least once and the only exit is after the permit.
+    function badPermitDoWhileBreakAfterPermit(
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        do {
+            token.permit(from, address(this), a, deadline, v, r, s);
+            break;
+        } while (true);
+        token.transferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from` after `permit`
+    }
+
+    // Permit's owner is a local alias of the sink's `from`.
+    function badPermitOwnerAlias(
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        address ownerAlias = from;
+        token.permit(ownerAlias, address(this), a, deadline, v, r, s);
+        token.transferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from` after `permit`
+    }
+
+    // Reverse: sink's `from` is the alias of the permit's owner.
+    function badPermitOwnerAliasReverse(
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        address from2 = from;
+        token.permit(from, address(this), a, deadline, v, r, s);
+        token.transferFrom(from2, to, a); //~WARN: `transferFrom` uses an arbitrary `from` after `permit`
+    }
+
+    // Sink receiver `t` is a local alias of the permit's token.
+    function badPermitTokenAlias(
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        IERC20 t = token;
+        token.permit(from, address(this), a, deadline, v, r, s);
+        t.transferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from` after `permit`
+    }
+
+    // No-arg helper returning `address(this)` is recognised as the permit spender.
+    function badPermitHelperSelfSpender(
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        token.permit(from, _self(), a, deadline, v, r, s);
+        token.transferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from` after `permit`
+    }
+
+    // Modifier prefix definitely exits; body is unreachable.
+    function okModifierKillsBody(
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public neverRunsBody {
+        token.permit(from, address(this), a, deadline, v, r, s);
+        token.transferFrom(from, to, a);
+    }
+
+    // `repayment = amount + fee` satisfies the flash-repayment consumer as a sum-alias.
+    function okPermitFlashRepaymentSumAlias(
+        IERC3156FlashBorrower receiver,
+        uint256 amount,
+        uint256 fee,
+        bytes calldata data,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        uint256 repayment = amount + fee;
+        token.permit(address(receiver), address(this), repayment, deadline, v, r, s);
+        receiver.onFlashLoan(msg.sender, address(token), amount, fee, data);
+        token.transferFrom(address(receiver), address(this), repayment);
+    }
+
+    // Reassigning the alias kills it; sink no longer correlates with the permit.
+    function okPermitOwnerAliasReassigned(
+        address from,
+        address other,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) public {
+        address ownerAlias = from;
+        token.permit(ownerAlias, address(this), a, deadline, v, r, s);
+        ownerAlias = other;
+        token.transferFrom(ownerAlias, to, a);
+    }
+
+    // Permit and sink both go through `cfg.token` (struct-field token receiver).
+    function badPermitStructFieldToken(
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) external {
+        cfg.token.permit(from, address(this), a, deadline, v, r, s);
+        cfg.token.transferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from` after `permit`
+    }
+
+    // Library wrapper `SafeERC20.safePermit(token, ...)`; later raw transferFrom on the
+    // same token must correlate via the library-form permit match.
+    function badPermitSafeWrapperFollowedBySink(
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) external {
+        SafeERC20.safePermit(token, from, address(this), a, deadline, v, r, s);
+        token.transferFrom(from, to, a); //~WARN: `transferFrom` uses an arbitrary `from` after `permit`
+    }
+
+    // Internal call reassigns the token state var; the prior permit no longer covers it.
+    function _switchToken() internal {
+        token = other;
+    }
+    function okPermitInternalCallSwitchesToken(
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) external {
+        token.permit(from, address(this), a, deadline, v, r, s);
+        _switchToken();
+        token.transferFrom(from, to, a);
+    }
+
+    // Reassigning a struct field drops permits keyed on `Field(base, name)`.
+    function okPermitStructFieldReassigned(
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) external {
+        cfg.token.permit(from, address(this), a, deadline, v, r, s);
+        cfg.token = other;
+        cfg.token.transferFrom(from, to, a);
+    }
+
+    // Every try clause exits; trailing code is unreachable and must not be analyzed.
+    function okPermitTryAllClausesExit(
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) external {
+        try token.permit(from, address(this), a, deadline, v, r, s) {
+            revert("ok");
+        } catch {
+            revert("bad");
+        }
+        token.transferFrom(from, to, a);
+    }
+
+    // Arg-eval order: nested sink in args must see facts live before the outer call's
+    // state-write side effects are applied.
+    function _switchTokenAndReturn(uint256 x) internal returns (uint256) {
+        token = other;
+        return x;
+    }
+    function badPermitNestedSinkBeforeStateWrite(
+        address from,
+        address to,
+        uint256 a,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) external {
+        token.permit(from, address(this), a, deadline, v, r, s);
+        _switchTokenAndReturn(token.transferFrom(from, to, a) ? 0 : 0); //~WARN: `transferFrom` uses an arbitrary `from` after `permit`
+    }
+
+    // Immutable seeded from constructor with `address(this)`; flash-loan repayment
+    // to that immutable must be accepted as self.
+    function okPermitFlashRepaymentToVault(
+        IERC3156FlashBorrower receiver,
+        uint256 amount,
+        uint256 fee,
+        bytes calldata data,
+        uint256 deadline,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) external {
+        token.permit(address(receiver), address(this), amount + fee, deadline, v, r, s);
+        receiver.onFlashLoan(msg.sender, address(token), amount, fee, data);
+        token.transferFrom(address(receiver), vault, amount + fee);
+    }
+}
diff --git a/crates/lint/testdata/ArbitrarySendErc20Permit.stderr b/crates/lint/testdata/ArbitrarySendErc20Permit.stderr
new file mode 100644
index 0000000000000..7224d27695d14
--- /dev/null
+++ b/crates/lint/testdata/ArbitrarySendErc20Permit.stderr
@@ -0,0 +1,248 @@
+warning[arbitrary-send-erc20-permit]: `transferFrom` uses an arbitrary `from` after `permit`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20Permit.sol:LL:CC
+   │
+LL │         token.transferFrom(from, to, a);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20-permit
+
+warning[arbitrary-send-erc20-permit]: `transferFrom` uses an arbitrary `from` after `permit`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20Permit.sol:LL:CC
+   │
+LL │         token.safeTransferFrom(from, to, a);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20-permit
+
+warning[arbitrary-send-erc20-permit]: `transferFrom` uses an arbitrary `from` after `permit`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20Permit.sol:LL:CC
+   │
+LL │         SafeERC20.safeTransferFrom(token, from, to, a);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20-permit
+
+warning[arbitrary-send-erc20-permit]: `transferFrom` uses an arbitrary `from` after `permit`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20Permit.sol:LL:CC
+   │
+LL │         token.transferFrom({from: from, to: to, amount: a});
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20-permit
+
+warning[arbitrary-send-erc20-permit]: `transferFrom` uses an arbitrary `from` after `permit`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20Permit.sol:LL:CC
+   │
+LL │             token.transferFrom(from, to, a);
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20-permit
+
+warning[arbitrary-send-erc20-permit]: `transferFrom` uses an arbitrary `from` after `permit`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20Permit.sol:LL:CC
+   │
+LL │             token.transferFrom(from, to, a);
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20-permit
+
+warning[arbitrary-send-erc20-permit]: `transferFrom` uses an arbitrary `from` after `permit`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20Permit.sol:LL:CC
+   │
+LL │         token.transferFrom(from, to, a);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20-permit
+
+warning[arbitrary-send-erc20-permit]: `transferFrom` uses an arbitrary `from` after `permit`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20Permit.sol:LL:CC
+   │
+LL │             token.transferFrom(from, to, a);
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20-permit
+
+warning[arbitrary-send-erc20-permit]: `transferFrom` uses an arbitrary `from` after `permit`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20Permit.sol:LL:CC
+   │
+LL │             token.transferFrom(from, to, a);
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20-permit
+
+warning[arbitrary-send-erc20-permit]: `transferFrom` uses an arbitrary `from` after `permit`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20Permit.sol:LL:CC
+   │
+LL │         IERC20(rawToken).transferFrom(from, to, a);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20-permit
+
+warning[arbitrary-send-erc20-permit]: `transferFrom` uses an arbitrary `from` after `permit`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20Permit.sol:LL:CC
+   │
+LL │         SafeTransferLib.safeTransferFrom(rawToken, from, to, a);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20-permit
+
+warning[arbitrary-send-erc20-permit]: `transferFrom` uses an arbitrary `from` after `permit`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20Permit.sol:LL:CC
+   │
+LL │         rawToken.safeTransferFrom(from, to, a);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20-permit
+
+warning[arbitrary-send-erc20-permit]: `transferFrom` uses an arbitrary `from` after `permit`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20Permit.sol:LL:CC
+   │
+LL │         token.transferFrom(from, to, a);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20-permit
+
+warning[arbitrary-send-erc20-permit]: `transferFrom` uses an arbitrary `from` after `permit`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20Permit.sol:LL:CC
+   │
+LL │             token.transferFrom(from, to, a);
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20-permit
+
+warning[arbitrary-send-erc20-permit]: `transferFrom` uses an arbitrary `from` after `permit`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20Permit.sol:LL:CC
+   │
+LL │         token.transferFrom(from, to, a);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20-permit
+
+warning[arbitrary-send-erc20-permit]: `transferFrom` uses an arbitrary `from` after `permit`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20Permit.sol:LL:CC
+   │
+LL │         token.transferFrom(from, to, a);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20-permit
+
+warning[arbitrary-send-erc20-permit]: `transferFrom` uses an arbitrary `from` after `permit`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20Permit.sol:LL:CC
+   │
+LL │         other.transferFrom(from2, to, a);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20-permit
+
+warning[arbitrary-send-erc20-permit]: `transferFrom` uses an arbitrary `from` after `permit`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20Permit.sol:LL:CC
+   │
+LL │         token.transferFrom(from1, to, a);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20-permit
+
+warning[arbitrary-send-erc20-permit]: `transferFrom` uses an arbitrary `from` after `permit`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20Permit.sol:LL:CC
+   │
+LL │         token.transferFrom(from, to, a);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20-permit
+
+warning[arbitrary-send-erc20-permit]: `transferFrom` uses an arbitrary `from` after `permit`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20Permit.sol:LL:CC
+   │
+LL │         token.transferFrom(from, to, a);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20-permit
+
+warning[arbitrary-send-erc20-permit]: `transferFrom` uses an arbitrary `from` after `permit`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20Permit.sol:LL:CC
+   │
+LL │ …     token.transferFrom(address(receiver), address(this), amount + fee);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20-permit
+
+warning[arbitrary-send-erc20-permit]: `transferFrom` uses an arbitrary `from` after `permit`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20Permit.sol:LL:CC
+   │
+LL │             token.transferFrom(from, to, a);
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20-permit
+
+warning[arbitrary-send-erc20-permit]: `transferFrom` uses an arbitrary `from` after `permit`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20Permit.sol:LL:CC
+   │
+LL │         token.transferFrom(from, to, a);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20-permit
+
+warning[arbitrary-send-erc20-permit]: `transferFrom` uses an arbitrary `from` after `permit`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20Permit.sol:LL:CC
+   │
+LL │         token.transferFrom(from, to, a);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20-permit
+
+warning[arbitrary-send-erc20-permit]: `transferFrom` uses an arbitrary `from` after `permit`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20Permit.sol:LL:CC
+   │
+LL │         token.transferFrom(from, to, a);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20-permit
+
+warning[arbitrary-send-erc20-permit]: `transferFrom` uses an arbitrary `from` after `permit`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20Permit.sol:LL:CC
+   │
+LL │         token.transferFrom(from2, to, a);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20-permit
+
+warning[arbitrary-send-erc20-permit]: `transferFrom` uses an arbitrary `from` after `permit`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20Permit.sol:LL:CC
+   │
+LL │         t.transferFrom(from, to, a);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20-permit
+
+warning[arbitrary-send-erc20-permit]: `transferFrom` uses an arbitrary `from` after `permit`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20Permit.sol:LL:CC
+   │
+LL │         token.transferFrom(from, to, a);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20-permit
+
+warning[arbitrary-send-erc20-permit]: `transferFrom` uses an arbitrary `from` after `permit`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20Permit.sol:LL:CC
+   │
+LL │         cfg.token.transferFrom(from, to, a);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20-permit
+
+warning[arbitrary-send-erc20-permit]: `transferFrom` uses an arbitrary `from` after `permit`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20Permit.sol:LL:CC
+   │
+LL │         token.transferFrom(from, to, a);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20-permit
+
+warning[arbitrary-send-erc20-permit]: `transferFrom` uses an arbitrary `from` after `permit`
+   ╭▸ ROOT/testdata/ArbitrarySendErc20Permit.sol:LL:CC
+   │
+LL │         _switchTokenAndReturn(token.transferFrom(from, to, a) ? 0 : 0);
+   │                               ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-erc20-permit
+
diff --git a/crates/lint/testdata/ArbitrarySendEth.sol b/crates/lint/testdata/ArbitrarySendEth.sol
new file mode 100644
index 0000000000000..39d5eb5206947
--- /dev/null
+++ b/crates/lint/testdata/ArbitrarySendEth.sol
@@ -0,0 +1,867 @@
+//@compile-flags: --only-lint arbitrary-send-eth
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+import "./ArbitrarySendEth.sol" as Self;
+
+interface IERC20 {
+    function transfer(address to, uint256 amount) external returns (bool);
+}
+
+interface IThing {
+    function ping() external payable;
+}
+
+interface IRegistry {
+    function recipient() external view returns (address payable);
+}
+
+interface IOverloaded {
+    function recipient(uint256 x) external view returns (uint256);
+    function recipient() external view returns (address payable);
+}
+
+interface IOverloadedSameArity {
+    function recipient(uint256 x) external view returns (uint256);
+    function recipient(address x) external view returns (address payable);
+}
+
+library Lib {
+    function pay(address payable dest, uint256 amt) internal { dest.transfer(amt); }
+}
+
+library SenderLib {
+    function self(address a) internal pure returns (address) { return a; }
+}
+
+library Address {
+    function sendValue(address payable to, uint256 amount) internal { (bool ok,) = to.call{value: amount}(""); require(ok); }
+    function functionCallWithValue(address target, bytes memory data, uint256 value) internal returns (bytes memory) {
+        (bool ok, bytes memory ret) = target.call{value: value}(data);
+        require(ok);
+        return ret;
+    }
+    function functionCallWithValue(
+        address target,
+        bytes memory data,
+        uint256 value,
+        string memory errorMessage
+    ) internal returns (bytes memory) {
+        (bool ok, bytes memory ret) = target.call{value: value}(data);
+        require(ok, errorMessage);
+        return ret;
+    }
+}
+
+library SafeTransferLib {
+    function safeTransferETH(address to, uint256 amount) internal { (bool ok,) = to.call{value: amount}(""); require(ok); }
+    function forceSafeTransferETH(address to, uint256 amount) internal { (bool ok,) = to.call{value: amount}(""); require(ok); }
+    function forceSafeTransferETH(address to, uint256 amount, uint256 gasStipend) internal { (bool ok,) = to.call{value: amount, gas: gasStipend}(""); require(ok); }
+    function safeTransferAllETH(address to) internal { (bool ok,) = to.call{value: address(this).balance}(""); require(ok); }
+    function forceSafeTransferAllETH(address to) internal { (bool ok,) = to.call{value: address(this).balance}(""); require(ok); }
+    function forceSafeTransferAllETH(address to, uint256 gasStipend) internal { (bool ok,) = to.call{value: address(this).balance, gas: gasStipend}(""); require(ok); }
+    function trySafeTransferETH(address to, uint256 amount, uint256 gasStipend) internal returns (bool success) { (success,) = to.call{value: amount, gas: gasStipend}(""); }
+    function trySafeTransferAllETH(address to, uint256 gasStipend) internal returns (bool success) { (success,) = to.call{value: address(this).balance, gas: gasStipend}(""); }
+    function safeMoveETH(address to, uint256 amount) internal returns (address vault) {
+        (bool ok,) = to.call{value: amount}("");
+        require(ok);
+        vault = to;
+    }
+}
+
+contract NonLibraryBase {
+    function safeTransferETH(address, uint256) internal pure {}
+}
+
+contract ArbitrarySendEth {
+    using Lib for address payable;
+    using Address for address payable;
+    using Address for address;
+    using SafeTransferLib for address;
+    using SenderLib for address;
+    struct Cfg {
+        address payable beneficiary;
+    }
+    address public mutableOwner;
+    address public immutable trustedOwner;
+    address public constant TREASURY = 0x000000000000000000000000000000000000dEaD;
+    IThing public immutable trustedThing;
+    IERC20 public token;
+    address payable[] public recipients;
+    mapping(address => address payable) public delegates;
+    address[] public admins;
+    mapping(address => uint256) public adminIndex;
+    Cfg public cfg;
+    constructor(address _owner, IThing _thing, address payable seed, uint256 amt) payable { trustedOwner = _owner; trustedThing = _thing; seed.transfer(amt); }
+    function refundCaller() external payable { payable(msg.sender).transfer(msg.value); }
+    function payTxOrigin(uint256 amt) external { payable(tx.origin).transfer(amt); }
+    function selfTopUp(uint256 amt) external { payable(address(this)).transfer(amt); }
+    function selfCall(bytes calldata data, uint256 amt) external returns (bool ok) { (ok,) = address(this).call{value: amt}(data); }
+    function selfInterfaceCall(uint256 amt) external { IThing(address(this)).ping{value: amt}(); }
+    function noValueCall(address t, bytes calldata data) external returns (bool ok) { (ok,) = t.call(data); }
+    function zeroValueCall(address t, bytes calldata data) external returns (bool ok) { (ok,) = t.call{value: 0}(data); }
+    function zeroTransfer(address payable t) external { t.transfer(0); }
+    function payImmutableOwner(uint256 amt) external { payable(trustedOwner).transfer(amt); }
+    function payImmutableThing(uint256 amt) external { trustedThing.ping{value: amt}(); }
+    function payConstantTreasury(uint256 amt) external { payable(TREASURY).transfer(amt); }
+    function payLiteralAddress(uint256 amt) external { payable(0x000000000000000000000000000000000000dEaD).transfer(amt); }
+    function payErc20Transfer(address to, uint256 amt) external { token.transfer(to, amt); }
+    function selfDestructHere() external { selfdestruct(payable(address(this))); }
+    function badTransfer(address payable to, uint256 amt) external { to.transfer(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function badSend(address payable to, uint256 amt) external {
+        bool ok = to.send(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+        ok;
+    }
+    function badCall(address to, uint256 amt, bytes calldata data) external returns (bool ok) { (ok,) = to.call{value: amt}(data); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function badInterfaceCall(IThing t, uint256 amt) external { t.ping{value: amt}(); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function badSelfDestruct(address payable to) external { selfdestruct(to); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function badPayMutableStorage(uint256 amt) external { payable(mutableOwner).transfer(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function setOwner(address newOwner) external { mutableOwner = newOwner; }
+    function badTernaryDest(address payable a, address payable b, bool flag, uint256 amt) external { (flag ? a : b).transfer(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function badArrayDest(uint256 i, uint256 amt) external { recipients[i].transfer(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function badMappingDest(address k, uint256 amt) external { delegates[k].transfer(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function badStructDest(uint256 amt) external { cfg.beneficiary.transfer(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function badSendValueStatic(address payable to, uint256 amt) external { Address.sendValue(to, amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function badSendValueUsingFor(address payable to, uint256 amt) external { to.sendValue(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function badSafeTransferETH(address to, uint256 amt) external { SafeTransferLib.safeTransferETH(to, amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function badForceSafeTransferETH(address to, uint256 amt) external { to.forceSafeTransferETH(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function badSafeTransferAllETHUsingFor(address to) external { to.safeTransferAllETH(); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function badSafeTransferAllETHStatic(address to) external { SafeTransferLib.safeTransferAllETH(to); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function badForceSafeTransferAllETHWithGas(address to, uint256 gasStipend) external { to.forceSafeTransferAllETH(gasStipend); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function badFunctionCallWithValue(address to, bytes calldata data, uint256 amt) external { to.functionCallWithValue(data, amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function badFunctionCallWithValueStaticWithErr(address to, bytes calldata data, uint256 amt) external { Address.functionCallWithValue(to, data, amt, "boom"); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function badFunctionPointer(function() external payable f, uint256 amt) external { f{value: amt}(); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function aliasedSenderOk(uint256 amt) external { address payable to = payable(msg.sender); to.transfer(amt); }
+    function ifGuardedOk(address payable to, uint256 amt) external {
+        if (to == payable(msg.sender)) {
+            to.transfer(amt);
+        }
+    }
+    function requireGuardedOk(address payable to, uint256 amt) external { require(to == payable(msg.sender), "bad to"); to.transfer(amt); }
+    function revertGuardedOk(address payable to, uint256 amt) external {
+        if (to != payable(msg.sender)) {
+            revert();
+        }
+        to.transfer(amt);
+    }
+    function ternarySafeOk(uint256 amt, bool flag) external { address payable t = flag ? payable(msg.sender) : payable(address(this)); t.transfer(amt); }
+    function interfaceCastValidatedOk(address x, uint256 amt) external { require(x == msg.sender); IThing(x).ping{value: amt}(); }
+    function reassignedAfterCheckBad(address payable to, address payable other, uint256 amt) external {
+        require(to == payable(msg.sender));
+        to = other; // kills the safe-fact
+        to.transfer(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function elseBranchBad(address payable to, uint256 amt) external {
+        if (to == payable(msg.sender)) {
+            to.transfer(amt); // safe via positive fact
+        } else {
+            to.transfer(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+        }
+    }
+    function unreachableAfterRevertOk(address payable to, uint256 amt) external {
+        revert();
+        to.transfer(amt); // unreachable; must NOT flag
+    }
+    modifier onlyOwner() { require(msg.sender == trustedOwner, "not owner"); _; }
+    modifier onlyOwnerIfRevert() {
+        if (msg.sender != trustedOwner) {
+            revert();
+        }
+        _;
+    }
+    modifier onlyEOA() { require(msg.sender.code.length == 0, "not eoa"); _; }
+    modifier nonZeroSender() { require(msg.sender != address(0)); _; }
+    modifier whenNotPaused() { require(block.timestamp > 0, "paused"); _; }
+    function withdrawAnywhere(address payable to, uint256 amt) external onlyOwner { to.transfer(amt); }
+    function withdrawAnywhereRevert(address payable to, uint256 amt) external onlyOwnerIfRevert { to.transfer(amt); }
+    function pausedButOpenBad(address payable to, uint256 amt) external whenNotPaused { to.transfer(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function eoaOnlyBad(address payable to, uint256 amt) external onlyEOA { to.transfer(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function nonZeroSenderBad(address payable to, uint256 amt) external nonZeroSender { to.transfer(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    modifier checkSender(address payable who) { require(who == payable(msg.sender), "not sender"); _; }
+    function paramSafeViaModifierOk(address payable to, uint256 amt) external checkSender(to) { to.transfer(amt); }
+    function paramSafeButOtherBad(address payable to, address payable other, uint256 amt) external checkSender(to) { other.transfer(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    modifier maybeOwner(bool b) { if (b) require(msg.sender == trustedOwner); _; }
+    function maybeOwnerBad(address payable to, uint256 amt, bool b) external maybeOwner(b) { to.transfer(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    struct Guard {
+        address principal;
+    }
+    modifier fakeGuard(Guard memory g) { require(msg.sender == g.principal, "bad"); _; }
+    function fakeGuardBad(address payable to, uint256 amt, Guard memory g) external fakeGuard(g) { to.transfer(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function callSiteUnchecked(address payable to, uint256 amt) external { Lib.pay(to, amt); }
+    modifier tautologicalSender() { require(msg.sender == msg.sender); _; }
+    function tautologicalSenderBad(address payable to, uint256 amt) external tautologicalSender { to.transfer(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    modifier txOriginOnly() { require(msg.sender == tx.origin); _; }
+    function txOriginOnlyBad(address payable to, uint256 amt) external txOriginOnly { to.transfer(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function _msgSender() internal view returns (address) { return msg.sender; }
+    modifier onlyOwnerViaHelper() { require(_msgSender() == trustedOwner); _; }
+    function withdrawAnywhereHelper(address payable to, uint256 amt) external onlyOwnerViaHelper { to.transfer(amt); }
+    modifier ifEnabledElseRevert(bool enabled) {
+        if (enabled) {
+            require(msg.sender == trustedOwner);
+        } else {
+            revert();
+        }
+        _;
+    }
+    function withdrawIfEnabled(address payable to, uint256 amt, bool enabled) external ifEnabledElseRevert(enabled) { to.transfer(amt); }
+    function payAddressZeroOk(uint256 amt) external { payable(address(0)).transfer(amt); }
+    modifier tautologicalSenderMemberCall() { require(msg.sender == msg.sender.self()); _; }
+    function tautologicalSenderMemberCallBad(address payable to, uint256 amt) external tautologicalSenderMemberCall { to.transfer(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function _txOrigin() internal view returns (address) { return tx.origin; }
+    modifier txOriginHelperOnly() { require(msg.sender == _txOrigin()); _; }
+    function txOriginHelperOnlyBad(address payable to, uint256 amt) external txOriginHelperOnly { to.transfer(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function numericAmountGuardStillBad(address payable to, uint256 amt) external {
+        require(amt == 1);
+        to.transfer(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    modifier aliasIndexedAdmin(uint256 i) { uint256 j = i; require(msg.sender == admins[j]); _; }
+    function aliasIndexedAdminBad(address payable to, uint256 amt, uint256 i) external aliasIndexedAdmin(i) { to.transfer(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    modifier nestedSenderIndexedAdmin() { require(msg.sender == admins[adminIndex[msg.sender]]); _; }
+    function nestedSenderIndexedAdminBad(address payable to, uint256 amt) external nestedSenderIndexedAdmin { to.transfer(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function _senderIndex() internal view returns (uint256) { return adminIndex[msg.sender]; }
+    modifier hiddenSenderIndexedAdmin() { require(msg.sender == admins[_senderIndex()]); _; }
+    function hiddenSenderIndexedAdminBad(address payable to, uint256 amt) external hiddenSenderIndexedAdmin { to.transfer(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function _calldataIndex() internal pure returns (uint256) { return uint256(uint8(msg.data[4])); }
+    modifier pureCalldataIndexedAdmin() { require(msg.sender == admins[_calldataIndex()]); _; }
+    function pureCalldataIndexedAdminBad(address payable to, uint256 amt) external pureCalldataIndexedAdmin { to.transfer(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function _calldataOwner() internal pure returns (address) { return address(uint160(uint256(bytes32(msg.data[4:36])))); }
+    modifier calldataOwnerGuard() { require(msg.sender == _calldataOwner()); _; }
+    function calldataOwnerGuardBad(address payable to, uint256 amt) external calldataOwnerGuard { to.transfer(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    modifier assignedParamGuard(address payable who) { who = payable(msg.sender); _; }
+    function assignedParamGuardBad(address payable to, uint256 amt) external assignedParamGuard(to) { to.transfer(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    modifier assignedParamCondGuard(address payable who) {
+        if ((who = payable(msg.sender)) == payable(msg.sender)) {}
+        _;
+    }
+    function assignedParamCondGuardBad(address payable to, uint256 amt) external assignedParamCondGuard(to) { to.transfer(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    modifier modifierSends(address payable to, uint256 amt) {
+        to.transfer(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+        _;
+    }
+    function modifierSinkOk(address payable to, uint256 amt)
+        external
+        modifierSends(to, amt)
+    {}
+    function _maybeCheckOwner(bool skip) internal view { if (skip) return; require(msg.sender == trustedOwner); }
+    modifier maybeChecked(bool skip) { _maybeCheckOwner(skip); _; }
+    function helperEarlyReturnBad(address payable to, uint256 amt, bool skip) external maybeChecked(skip) { to.transfer(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function memberReturnTransferBad(IRegistry r, uint256 amt) external { r.recipient().transfer(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function contractLocalFromImmutableOk(uint256 amt) external { IThing t = trustedThing; t.ping{value: amt}(); }
+    function nestedUnreachableOk(address payable to, uint256 amt) external {
+        if (true) {
+            revert();
+            to.transfer(amt); // unreachable; must NOT flag
+        }
+    }
+    function inlineCallerGuardOk(address payable to, uint256 amt) external { require(msg.sender == trustedOwner); to.transfer(amt); }
+    function inlineCallerGuardRevertOk(address payable to, uint256 amt) external { if (msg.sender != trustedOwner) revert(); to.transfer(amt); }
+    function inlineCallerGuardOrderBad(address payable to, uint256 amt) external {
+        to.transfer(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+        require(msg.sender == trustedOwner);
+    }
+    modifier guardedModifierSendOk(address payable to, uint256 amt) { require(msg.sender == trustedOwner); to.transfer(amt); _; }
+    function guardedModifierSendOkCaller(address payable to, uint256 amt)
+        external
+        guardedModifierSendOk(to, amt)
+    {}
+    modifier sinkBeforeGuardBad(address payable to, uint256 amt) {
+        to.transfer(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+        require(msg.sender == trustedOwner);
+        _;
+    }
+    function sinkBeforeGuardBadCaller(address payable to, uint256 amt)
+        external
+        sinkBeforeGuardBad(to, amt)
+    {}
+    function overloadedRecipientBad(IOverloaded r, uint256 amt) external { r.recipient().transfer(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function overloadedSameArityRecipientBad(
+        IOverloadedSameArity r,
+        address who,
+        uint256 amt
+    ) external {
+        r.recipient(who).transfer(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function _consume(bool) internal pure returns (bool) { return true; }
+    modifier consumeArg(bool ok) { require(ok); _; }
+    function modifierArgSinkBad(address payable to, uint256 amt)
+        external
+        onlyOwner
+        consumeArg(_consume(to.send(amt))) //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    {}
+    function conditionalInlineGuardBad(address payable to, uint256 amt, bool b) external {
+        if (b) require(msg.sender == trustedOwner);
+        to.transfer(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    modifier guardedSuffixSendOk(address payable to, uint256 amt) { require(msg.sender == trustedOwner); _; to.transfer(amt); }
+    function guardedSuffixSendOkCaller(address payable to, uint256 amt)
+        external
+        guardedSuffixSendOk(to, amt)
+    {}
+    modifier suffixSendBad(address payable to, uint256 amt) {
+        _;
+        to.transfer(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function suffixSendBadCaller(address payable to, uint256 amt)
+        external
+        suffixSendBad(to, amt)
+    {}
+    function badForceSafeTransferETHStaticWithGas(address to, uint256 amt, uint256 gasStipend) external { SafeTransferLib.forceSafeTransferETH(to, amt, gasStipend); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function badForceSafeTransferETHUsingForWithGas(address to, uint256 amt, uint256 gasStipend) external { to.forceSafeTransferETH(amt, gasStipend); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function badNamedSafeTransferETHStatic(address to, uint256 amt) external { SafeTransferLib.safeTransferETH({amount: amt, to: to}); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function badNamedFunctionCallWithValueStatic(address to, bytes calldata data, uint256 amt) external { Address.functionCallWithValue({value: amt, target: to, data: data}); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function okNamedSafeTransferETHStaticZero(address to) external { SafeTransferLib.safeTransferETH({amount: 0, to: to}); }
+    function badTrySafeTransferETHStatic(address to, uint256 amt, uint256 gas_) external { SafeTransferLib.trySafeTransferETH(to, amt, gas_); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function badTrySafeTransferETHUsingFor(address to, uint256 amt, uint256 gas_) external { to.trySafeTransferETH(amt, gas_); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function badTrySafeTransferAllETHStatic(address to, uint256 gas_) external { SafeTransferLib.trySafeTransferAllETH(to, gas_); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function badTrySafeTransferAllETHUsingFor(address to, uint256 gas_) external { to.trySafeTransferAllETH(gas_); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function badSafeMoveETHStatic(address to, uint256 amt) external { SafeTransferLib.safeMoveETH(to, amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function badSafeMoveETHUsingFor(address to, uint256 amt) external { to.safeMoveETH(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+}
+
+contract NonLibraryStaticOk is NonLibraryBase {
+    function ok(address to, uint256 amt) external pure { NonLibraryBase.safeTransferETH(to, amt); }
+}
+
+interface IBaseRecipient {
+    function recipient() external view returns (address payable);
+}
+
+interface IChildRecipient is IBaseRecipient {}
+
+contract InheritedRecipient {
+    function inheritedRecipientBad(IChildRecipient r, uint256 amt) external { r.recipient().transfer(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+}
+
+contract DisjunctiveCallerRestriction {
+    address public immutable owner;
+    address public immutable guardian;
+    address public immutable backup;
+    uint256 public cap;
+    constructor(address _owner, address _guardian, address _backup) { owner = _owner; guardian = _guardian; backup = _backup; }
+    modifier onlyOwnerOrGuardian() { require(msg.sender == owner || msg.sender == guardian); _; }
+    modifier onlyOwnerOrGuardianOrBackup() {
+        require(
+            msg.sender == owner || msg.sender == guardian || msg.sender == backup
+        );
+        _;
+    }
+    modifier onlyOwnerOrGuardianIfRevert() { if (msg.sender != owner && msg.sender != guardian) revert(); _; }
+    function withdrawOwnerOrGuardianOk(address payable to, uint256 amount) external onlyOwnerOrGuardian { to.transfer(amount); }
+    function withdrawOwnerOrGuardianOrBackupOk(address payable to, uint256 amount) external onlyOwnerOrGuardianOrBackup { to.transfer(amount); }
+    function withdrawIfRevertOk(address payable to, uint256 amount) external onlyOwnerOrGuardianIfRevert { to.transfer(amount); }
+    function mixedDisjunctBad(address payable to, uint256 amount) external {
+        require(msg.sender == owner || amount < cap);
+        to.transfer(amount); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function inlineDestinationDisjunctionOk(address payable to, uint256 amount) external { require(to == payable(msg.sender) || to == payable(address(this))); to.transfer(amount); }
+    function inlineDestinationDisjunctionIfRevertOk(address payable to, uint256 amount) external { if (to != payable(msg.sender) && to != payable(address(this))) revert(); to.transfer(amount); }
+    function asymmetricDestinationDisjunctionBad(address payable to, uint256 amount) external {
+        require(to == payable(msg.sender) || amount > 0);
+        to.transfer(amount); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+}
+
+contract LiteralAndSelfRestrictedCaller {
+    modifier onlySelf() { require(msg.sender == address(this)); _; }
+    modifier onlySelf2() { require(msg.sender == payable(address(this))); _; }
+    modifier onlyHardcodedAdmin() { require(msg.sender == 0x1234567890123456789012345678901234567890); _; }
+    modifier onlyHardcodedAdminCast() { require(msg.sender == payable(0x1234567890123456789012345678901234567890)); _; }
+    modifier onlyZeroAddress() { require(msg.sender == address(0)); _; }
+    function selfRestrictedBad(address payable to, uint256 amount) external onlySelf { to.transfer(amount); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function self2RestrictedBad(address payable to, uint256 amount) external onlySelf2 { to.transfer(amount); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function hardcodedAdminOk(address payable to, uint256 amount) external onlyHardcodedAdmin { to.transfer(amount); }
+    function hardcodedAdminCastOk(address payable to, uint256 amount) external onlyHardcodedAdminCast { to.transfer(amount); }
+    function zeroAddressGuardOk(address payable to, uint256 amount) external onlyZeroAddress { to.transfer(amount); }
+}
+
+// Self-alias trampolines: planting `address(this)` into any state slot through
+// any laundering path (inline init, immutable, chain, struct, mapping, tuple,
+// 5-hop chain, ctor helper, struct literal, ctor local, nested helper, ternary,
+// array push) must reject the slot as a trusted caller principal.
+// (`address(this)` itself as a guard principal is exercised via `selfRestrictedBad`
+// in LiteralAndSelfRestrictedCaller below.)
+contract SelfAliasTrampolineMega {
+    struct Cfg { address self; }
+    address public SELF_INLINE = address(this);
+    address public immutable SELF_IMMUTABLE;
+    address public SELF_CHAIN_SRC = address(this);
+    address public SELF_CHAIN = SELF_CHAIN_SRC;
+    Cfg cfg;
+    mapping(uint256 => address) principals;
+    address tupleSelf;
+    address tupleOther;
+    address dA; address dB; address dC; address dD = address(this);
+    address fA; address fB; address fC; address fD; address fE;
+    address SELF_HELPER;
+    Cfg cfgLit;
+    address SELF_LOCAL;
+    address SELF_NESTED;
+    bool tFlag;
+    address tSeed;
+    address SELF_TERNARY;
+    address[] pushArr;
+    function _initHelper() internal { SELF_HELPER = address(this); }
+    function _initOuter() internal { _initInner(); }
+    function _initInner() internal { SELF_NESTED = address(this); }
+    constructor(address other) {
+        SELF_IMMUTABLE = address(this);
+        cfg.self = address(this);
+        principals[0] = address(this);
+        (tupleSelf, tupleOther) = (address(this), other);
+        dC = dD; dB = dC; dA = dB;
+        fE = address(this); fD = fE; fC = fD; fB = fC; fA = fB;
+        _initHelper();
+        cfgLit = Cfg(address(this));
+        address local = address(this); SELF_LOCAL = local;
+        _initOuter();
+        tSeed = other; SELF_TERNARY = tFlag ? address(this) : tSeed;
+        pushArr.push(address(this));
+    }
+    function dInline(address payable to, uint256 a) external { require(msg.sender == SELF_INLINE); to.transfer(a); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function dImmutable(address payable to, uint256 a) external { require(msg.sender == SELF_IMMUTABLE); to.transfer(a); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function dChain(address payable to, uint256 a) external { require(msg.sender == SELF_CHAIN); to.transfer(a); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function dStruct(address payable to, uint256 a) external { require(msg.sender == cfg.self); to.transfer(a); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function dMapping(address payable to, uint256 a) external { require(msg.sender == principals[0]); to.transfer(a); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function dTuple(address payable to, uint256 a) external { require(msg.sender == tupleSelf); to.transfer(a); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function dDeep(address payable to, uint256 a) external { require(msg.sender == dA); to.transfer(a); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function dFiveHop(address payable to, uint256 a) external { require(msg.sender == fA); to.transfer(a); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function dCtorHelper(address payable to, uint256 a) external { require(msg.sender == SELF_HELPER); to.transfer(a); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function dStructLit(address payable to, uint256 a) external { require(msg.sender == cfgLit.self); to.transfer(a); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function dCtorLocal(address payable to, uint256 a) external { require(msg.sender == SELF_LOCAL); to.transfer(a); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function dNestedHelper(address payable to, uint256 a) external { require(msg.sender == SELF_NESTED); to.transfer(a); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function dTernary(address payable to, uint256 a) external { require(msg.sender == SELF_TERNARY); to.transfer(a); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function dPush(address payable to, uint256 a) external { require(msg.sender == pushArr[0]); to.transfer(a); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+}
+
+// Literal/zero principals + nested-boolean destination guards.
+contract GuardShapesAndLiterals {
+    function bareZeroOk(address payable to, uint256 amt) external { require(msg.sender == address(0)); to.transfer(amt); }
+    function bareAddressLiteralOk(address payable to, uint256 amt) external { require(msg.sender == 0x0000000000000000000000000000000000000001); to.transfer(amt); }
+    function deMorganNestedOk(address payable to, uint256 amt) external { require(!(to != payable(msg.sender) && to != payable(address(this)))); to.transfer(amt); }
+    function conjThenDisjOk(address payable to, uint256 amt) external { require(amt > 0 && (to == payable(msg.sender) || to == payable(address(this)))); to.transfer(amt); }
+    function threeWayDisjOk(address payable to, uint256 amt) external {
+        address payable self = payable(address(this));
+        address payable sender = payable(msg.sender);
+        require(to == self || to == sender || to == payable(0x000000000000000000000000000000000000dEaD));
+        to.transfer(amt);
+    }
+    function threeWayAsymmetricBad(address payable to, uint256 amt) external {
+        require(to == payable(msg.sender) || to == payable(address(this)) || amt > 0);
+        to.transfer(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+}
+
+contract FunctionPointerSinks {
+    function() external payable[] public callbacks;
+    mapping(bytes4 => function() external payable) public handlers;
+    function pushCallback(function() external payable cb) external { callbacks.push(cb); }
+    function fireBad(uint256 i) external payable { callbacks[i]{value: msg.value}(); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function fireMappingBad(bytes4 sel) external payable { handlers[sel]{value: msg.value}(); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function zeroValueFireOk(uint256 i) external { callbacks[i]{value: 0}(); }
+}
+
+contract InlineAssemblyClobber {
+    function asmClobberBad(address payable other, uint256 amount) external {
+        address payable to = payable(msg.sender);
+        assembly {
+            to := other
+        }
+        to.transfer(amount); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function asmAfterSinkOk(address payable other, uint256 amount) external {
+        address payable to = payable(msg.sender);
+        to.transfer(amount);
+        assembly {
+            to := other
+        }
+    }
+}
+
+contract DocumentedLimitations {
+    address public owner;
+    constructor(address _o) { owner = _o; }
+    modifier onlyOwnerMutable() { require(msg.sender == owner); _; }
+    function setOwnerUnprotected(address newOwner) external { owner = newOwner; }
+    function mutableOwnerSuppressesBad(address payable to, uint256 amount) external onlyOwnerMutable { to.transfer(amount); }
+    function externalProtected(address payable to, uint256 amount) external onlyOwnerMutable { _send(to, amount); }
+    function _send(address payable to, uint256 amount) internal { to.transfer(amount); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+}
+
+interface ITryProbe {
+    function ping() external;
+}
+
+// Try/catch fact survival, all-clauses-exiting, isolation, nested-block-revert.
+contract TryClauseTests {
+    address public immutable owner;
+    constructor(address _o) { owner = _o; }
+    function callerFactInBothClausesOk(ITryProbe p, address payable to, uint256 amt) external {
+        try p.ping() { require(msg.sender == owner); } catch { require(msg.sender == owner); }
+        to.transfer(amt);
+    }
+    function destFactInBothClausesOk(ITryProbe p, address payable to, uint256 amt) external {
+        try p.ping() { require(to == payable(msg.sender)); } catch { require(to == payable(msg.sender)); }
+        to.transfer(amt);
+    }
+    function asymmetricTryClauseBad(ITryProbe p, address payable to, uint256 amt) external {
+        try p.ping() { require(msg.sender == owner); } catch {}
+        to.transfer(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function allClausesExitOk(ITryProbe p, address payable to, uint256 amt) external {
+        try p.ping() { revert(); } catch { revert(); }
+        to.transfer(amt); // unreachable
+    }
+    function tryClauseIsolationOk(
+        IBaseRecipient probe, address payable to, address payable other, uint256 amt
+    ) external {
+        require(to == payable(msg.sender));
+        try probe.recipient() returns (address payable) { to = other; }
+        catch { to.transfer(amt); }
+    }
+    function unreachableAfterNestedBlockExitOk(address payable to, uint256 amt) external {
+        { revert(); }
+        to.transfer(amt);
+    }
+}
+
+// Selfdestruct-as-exit + abi.decode receivers merged.
+contract ExitsAndAbiDecode {
+    function selfdestructThenSinkOk(address payable to, uint256 amt) external {
+        selfdestruct(payable(address(this)));
+        to.transfer(amt); // unreachable
+    }
+    function selfdestructInBothBranchesOk(address payable to, uint256 amt, bool b) external {
+        if (b) { selfdestruct(payable(address(this))); } else { revert(); }
+        to.transfer(amt); // both branches exit
+    }
+    function abiDecodePayableTransferBad(bytes calldata data, uint256 amt) external { payable(abi.decode(data, (address))).transfer(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function abiDecodeCallWithValueBad(bytes calldata data, uint256 amt) external returns (bool ok) { (ok,) = abi.decode(data, (address)).call{value: amt}(""); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+}
+
+// Runtime-planted self alias + named-arg ctor helper self alias.
+contract SelfAliasRuntimeAndNamedBad {
+    address selfRuntime;
+    address selfNamed;
+    constructor() {
+        _init({ignored: 1, self_: address(this)});
+    }
+    function _init(uint256 ignored, address self_) internal { ignored; selfNamed = self_; }
+    function plantSelfAlias() external { selfRuntime = address(this); }
+    function dRuntime(address payable to, uint256 a) external {
+        require(msg.sender == selfRuntime);
+        to.transfer(a); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function dNamed(address payable to, uint256 a) external {
+        require(msg.sender == selfNamed);
+        to.transfer(a); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+}
+
+// Wrapper-nested vs conditional placeholder. Nested-in-block is caller-restricting.
+// Nested under `if`: post-guard cannot retroactively restrict body.
+contract PlaceholderShapes {
+    address public immutable owner;
+    constructor(address _o) { owner = _o; }
+    modifier onlyOwnerNested() { { require(msg.sender == owner); _; } }
+    modifier maybePlaceholderThenGuard(bool b) {
+        if (b) { _; }
+        require(msg.sender == owner);
+    }
+    function withdrawOk(address payable to, uint256 amt) external onlyOwnerNested { to.transfer(amt); }
+    function notRestrictedBad(address payable to, uint256 amt, bool b) external maybePlaceholderThenGuard(b) { to.transfer(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+}
+
+contract WrapperPrefixParamReassignmentBad {
+    modifier checkOuterAssign(address payable who) {
+        who = payable(msg.sender);
+        {
+            require(who == payable(msg.sender));
+            _;
+        }
+    }
+    function paramReassignedThenWrapperBad(address payable to, uint256 amount) external checkOuterAssign(to) { to.transfer(amount); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+}
+
+// Ternary side-effect + assignment receiver — small regressions.
+contract TernaryAndAssignBad {
+    function ternarySideEffectBad(bool flag, address payable to, uint256 amt) external {
+        flag ? true : ((to = payable(msg.sender)) == payable(msg.sender));
+        to.transfer(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function assignmentReceiverBad(address payable to, address payable other, uint256 amt) external { (to = other).transfer(amt); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+}
+
+library IdLib {
+    function id(address a) internal pure returns (address) { return a; }
+}
+
+// Merged: 7 self-alias laundering paths into a single contract. Each `pay*`
+// exercises one bypass shape; all are expected to warn.
+contract SelfAliasLaunderingBad {
+    struct Cfg { address self; }
+    address public aNumericCast = address(uint160(address(this)));
+    address public aIdentity;
+    address public aIdentityLib;
+    address public aIdentityNamed;
+    address public aIdentityCast;
+    address public aAggregateCopy;
+    address public aLibraryNoArg;
+    Cfg public cfg;
+    address public aModifierArg;
+    function id(address x) internal pure returns (address) { return x; }
+    function idNamed(uint256 ignored, address a) internal pure returns (address) { return a; }
+    function idCast(address a) internal pure returns (address) { return address(uint160(a)); }
+    modifier plant(address a) { aModifierArg = a; _; }
+    constructor() {
+        aIdentity = id(address(this));
+        aIdentityLib = IdLib.id(address(this));
+        aIdentityNamed = idNamed({a: address(this), ignored: 0});
+        aIdentityCast = idCast(address(this));
+        cfg.self = address(this);
+        aAggregateCopy = cfg.self;
+        aLibraryNoArg = SelfRetLib.self();
+    }
+    function init() external plant(address(this)) {}
+    function payNumeric(address payable to, uint256 a) external {
+        require(msg.sender == aNumericCast);
+        to.transfer(a); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function payIdentity(address payable to, uint256 a) external {
+        require(msg.sender == aIdentity);
+        to.transfer(a); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function payIdentityLib(address payable to, uint256 a) external {
+        require(msg.sender == aIdentityLib);
+        to.transfer(a); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function payIdentityNamed(address payable to, uint256 a) external {
+        require(msg.sender == aIdentityNamed);
+        to.transfer(a); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function payIdentityCast(address payable to, uint256 a) external {
+        require(msg.sender == aIdentityCast);
+        to.transfer(a); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function payAggregateCopy(address payable to, uint256 a) external {
+        require(msg.sender == aAggregateCopy);
+        to.transfer(a); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function payLibraryNoArg(address payable to, uint256 a) external {
+        require(msg.sender == aLibraryNoArg);
+        to.transfer(a); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+    function payModifierArg(address payable to, uint256 a) external {
+        require(msg.sender == aModifierArg);
+        to.transfer(a); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+}
+
+contract InheritedTrustedBase {
+    address internal inheritedTrusted;
+}
+
+contract InheritedTrustedDerivedBad is InheritedTrustedBase {
+    constructor() { inheritedTrusted = address(this); }
+    function pay(address payable to, uint256 amount) external {
+        require(msg.sender == inheritedTrusted);
+        to.transfer(amount); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+}
+
+contract BaseSelfArg {
+    address internal baseSelf;
+    constructor(address a) { baseSelf = a; }
+}
+
+contract DerivedBaseCtorSelfAliasBad is BaseSelfArg {
+    constructor() BaseSelfArg(address(this)) {}
+    function pay(address payable to, uint256 amount) external {
+        require(msg.sender == baseSelf);
+        to.transfer(amount); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+}
+
+contract Pusher {
+    function push(address) external {}
+}
+
+contract PushNameCollisionOk {
+    Pusher public trusted;
+    constructor(Pusher p) { trusted = p; }
+    function touch() external { trusted.push(address(this)); }
+    function pay(address payable to, uint256 amount) external {
+        require(msg.sender == address(trusted));
+        to.transfer(amount); // must NOT warn — `push` here is unrelated to builtin array push
+    }
+}
+
+library OverId {
+    function id(address a) internal pure returns (address) { return a; }
+    function id(address, uint256) internal pure returns (address) { return 0x000000000000000000000000000000000000dEaD; }
+}
+
+contract OverloadedStaticIdentityArityOk {
+    address public trusted;
+    constructor() { trusted = OverId.id(address(this), 1); }
+    function pay(address payable to, uint256 amount) external {
+        require(msg.sender == trusted);
+        to.transfer(amount); // must NOT warn — wrong-arity overload is not identity
+    }
+}
+
+contract BaseSelfChain {
+    address internal chainSelf;
+    constructor(address a) { chainSelf = a; }
+}
+
+contract MidSelfChain is BaseSelfChain {
+    constructor(address a) BaseSelfChain(a) {}
+}
+
+contract LeafSelfChainBad is MidSelfChain {
+    constructor() MidSelfChain(address(this)) {}
+    function pay(address payable to, uint256 amount) external {
+        require(msg.sender == chainSelf);
+        to.transfer(amount); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+}
+
+library SelfRetLib {
+    function self() internal view returns (address) { return address(this); }
+}
+
+contract NumericCastSafeOk {
+    function payZeroViaUint160Ok(uint256 amt) external { payable(address(uint160(0))).transfer(amt); }
+}
+
+contract QualifiedSelfCastOk {
+    function paySelf(uint256 amount) external {
+        Self.IThing(address(this)).ping{value: amount}();
+    }
+}
+
+contract FnPtrFromSelf {
+    receive() external payable {}
+    function receiveEth() external payable {}
+    function selfPtrOk(uint256 amt) external payable {
+        function() external payable cb = this.receiveEth;
+        cb{value: amt}();
+    }
+    function paramPtrBad(function() external payable cb, uint256 amt) external payable {
+        cb{value: amt}(); //~WARN: ETH is sent to a user-controlled destination; restrict the destination or the caller
+    }
+}
+
+contract TrailingReturnHelperOk {
+    address public immutable owner;
+    constructor() { owner = msg.sender; }
+    function _checkOwner() internal view { if (msg.sender != owner) revert(); return; }
+    modifier onlyOwner() { _checkOwner(); _; }
+    function withdraw(address payable to, uint256 amt) external onlyOwner { to.transfer(amt); }
+}
+
+abstract contract OwnableStateGuard {
+    address private _owner;
+
+    function _msgSender() internal view returns (address) { return msg.sender; }
+    function owner() public view returns (address) { return _owner; }
+    function _checkOwner() internal view { require(owner() == _msgSender(), "not owner"); }
+    modifier onlyOwner() { _checkOwner(); _; }
+    function _transferOwnership(address newOwner) internal {
+        address oldOwner = _owner;
+        _owner = newOwner;
+        oldOwner;
+    }
+}
+
+contract OwnableModifierNoSinkRegression is OwnableStateGuard {
+    uint256 public value;
+
+    function init(address newOwner) external { _transferOwnership(newOwner); }
+    function set0(uint256 x) external onlyOwner { value = x; }
+    function set1(uint256 x) external onlyOwner { value = x; }
+    function set2(uint256 x) external onlyOwner { value = x; }
+    function set3(uint256 x) external onlyOwner { value = x; }
+    function set4(uint256 x) external onlyOwner { value = x; }
+    function set5(uint256 x) external onlyOwner { value = x; }
+    function set6(uint256 x) external onlyOwner { value = x; }
+    function set7(uint256 x) external onlyOwner { value = x; }
+    function restrictedPay(address payable to, uint256 amount) external onlyOwner {
+        to.transfer(amount);
+    }
+}
diff --git a/crates/lint/testdata/ArbitrarySendEth.stderr b/crates/lint/testdata/ArbitrarySendEth.stderr
new file mode 100644
index 0000000000000..460ff256f27d1
--- /dev/null
+++ b/crates/lint/testdata/ArbitrarySendEth.stderr
@@ -0,0 +1,840 @@
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function badTransfer(address payable to, uint256 amt) external { to.transfer(amt);
+   │                                                                      ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │         bool ok = to.send(amt);
+   │                   ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function badCall(address to, uint256 amt, bytes calldata data) external returns (bool ok) { (ok,) = to.call{value: amt}(data);
+   │                                                                                                         ━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function badInterfaceCall(IThing t, uint256 amt) external { t.ping{value: amt}();
+   │                                                                 ━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function badSelfDestruct(address payable to) external { selfdestruct(to);
+   │                                                             ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function badPayMutableStorage(uint256 amt) external { payable(mutableOwner).transfer(amt);
+   │                                                           ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function badTernaryDest(address payable a, address payable b, bool flag, uint256 amt) external { (flag ? a : b).transfer(amt);
+   │                                                                                                      ━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function badArrayDest(uint256 i, uint256 amt) external { recipients[i].transfer(amt);
+   │                                                              ━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function badMappingDest(address k, uint256 amt) external { delegates[k].transfer(amt);
+   │                                                                ━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function badStructDest(uint256 amt) external { cfg.beneficiary.transfer(amt);
+   │                                                    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function badSendValueStatic(address payable to, uint256 amt) external { Address.sendValue(to, amt);
+   │                                                                             ━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function badSendValueUsingFor(address payable to, uint256 amt) external { to.sendValue(amt);
+   │                                                                               ━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function badSafeTransferETH(address to, uint256 amt) external { SafeTransferLib.safeTransferETH(to, amt);
+   │                                                                     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function badForceSafeTransferETH(address to, uint256 amt) external { to.forceSafeTransferETH(amt);
+   │                                                                          ━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function badSafeTransferAllETHUsingFor(address to) external { to.safeTransferAllETH();
+   │                                                                   ━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function badSafeTransferAllETHStatic(address to) external { SafeTransferLib.safeTransferAllETH(to);
+   │                                                                 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function badForceSafeTransferAllETHWithGas(address to, uint256 gasStipend) external { to.forceSafeTransferAllETH(gasStipend);
+   │                                                                                           ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │ …to, bytes calldata data, uint256 amt) external { to.functionCallWithValue(data, amt);
+   │                                                   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │ … calldata data, uint256 amt) external { Address.functionCallWithValue(to, data, amt, "boom");
+   │                                          ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function badFunctionPointer(function() external payable f, uint256 amt) external { f{value: amt}();
+   │                                                                                        ━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │         to.transfer(amt);
+   │         ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │             to.transfer(amt);
+   │             ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function pausedButOpenBad(address payable to, uint256 amt) external whenNotPaused { to.transfer(amt);
+   │                                                                                         ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function eoaOnlyBad(address payable to, uint256 amt) external onlyEOA { to.transfer(amt);
+   │                                                                             ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function nonZeroSenderBad(address payable to, uint256 amt) external nonZeroSender { to.transfer(amt);
+   │                                                                                         ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │ …s payable other, uint256 amt) external checkSender(to) { other.transfer(amt);
+   │                                                           ━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function maybeOwnerBad(address payable to, uint256 amt, bool b) external maybeOwner(b) { to.transfer(amt);
+   │                                                                                              ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function fakeGuardBad(address payable to, uint256 amt, Guard memory g) external fakeGuard(g) { to.transfer(amt);
+   │                                                                                                    ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function tautologicalSenderBad(address payable to, uint256 amt) external tautologicalSender { to.transfer(amt);
+   │                                                                                                   ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function txOriginOnlyBad(address payable to, uint256 amt) external txOriginOnly { to.transfer(amt);
+   │                                                                                       ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │ … to, uint256 amt) external tautologicalSenderMemberCall { to.transfer(amt);
+   │                                                            ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function txOriginHelperOnlyBad(address payable to, uint256 amt) external txOriginHelperOnly { to.transfer(amt);
+   │                                                                                                   ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │         to.transfer(amt);
+   │         ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function aliasIndexedAdminBad(address payable to, uint256 amt, uint256 i) external aliasIndexedAdmin(i) { to.transfer(amt);
+   │                                                                                                               ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function nestedSenderIndexedAdminBad(address payable to, uint256 amt) external nestedSenderIndexedAdmin { to.transfer(amt);
+   │                                                                                                               ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function hiddenSenderIndexedAdminBad(address payable to, uint256 amt) external hiddenSenderIndexedAdmin { to.transfer(amt);
+   │                                                                                                               ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function pureCalldataIndexedAdminBad(address payable to, uint256 amt) external pureCalldataIndexedAdmin { to.transfer(amt);
+   │                                                                                                               ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function calldataOwnerGuardBad(address payable to, uint256 amt) external calldataOwnerGuard { to.transfer(amt);
+   │                                                                                                   ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function assignedParamGuardBad(address payable to, uint256 amt) external assignedParamGuard(to) { to.transfer(amt);
+   │                                                                                                       ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function assignedParamCondGuardBad(address payable to, uint256 amt) external assignedParamCondGuard(to) { to.transfer(amt);
+   │                                                                                                               ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │         to.transfer(amt);
+   │         ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function helperEarlyReturnBad(address payable to, uint256 amt, bool skip) external maybeChecked(skip) { to.transfer(amt);
+   │                                                                                                             ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function memberReturnTransferBad(IRegistry r, uint256 amt) external { r.recipient().transfer(amt);
+   │                                                                           ━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │         to.transfer(amt);
+   │         ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │         to.transfer(amt);
+   │         ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function overloadedRecipientBad(IOverloaded r, uint256 amt) external { r.recipient().transfer(amt);
+   │                                                                            ━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │         r.recipient(who).transfer(amt);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │ …     consumeArg(_consume(to.send(amt)))
+   │                           ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │         to.transfer(amt);
+   │         ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │         to.transfer(amt);
+   │         ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │ …6 amt, uint256 gasStipend) external { SafeTransferLib.forceSafeTransferETH(to, amt, gasStipend);
+   │                                        ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │ …, uint256 amt, uint256 gasStipend) external { to.forceSafeTransferETH(amt, gasStipend);
+   │                                                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │ …ic(address to, uint256 amt) external { SafeTransferLib.safeTransferETH({amount: amt, to: to});
+   │                                         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │ …ta data, uint256 amt) external { Address.functionCallWithValue({value: amt, target: to, data: data});
+   │                                   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │ …to, uint256 amt, uint256 gas_) external { SafeTransferLib.trySafeTransferETH(to, amt, gas_);
+   │                                            ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function badTrySafeTransferETHUsingFor(address to, uint256 amt, uint256 gas_) external { to.trySafeTransferETH(amt, gas_);
+   │                                                                                              ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function badTrySafeTransferAllETHStatic(address to, uint256 gas_) external { SafeTransferLib.trySafeTransferAllETH(to, gas_);
+   │                                                                                  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function badTrySafeTransferAllETHUsingFor(address to, uint256 gas_) external { to.trySafeTransferAllETH(gas_);
+   │                                                                                    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function badSafeMoveETHStatic(address to, uint256 amt) external { SafeTransferLib.safeMoveETH(to, amt);
+   │                                                                       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function badSafeMoveETHUsingFor(address to, uint256 amt) external { to.safeMoveETH(amt);
+   │                                                                         ━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function inheritedRecipientBad(IChildRecipient r, uint256 amt) external { r.recipient().transfer(amt);
+   │                                                                               ━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │         to.transfer(amount);
+   │         ━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │         to.transfer(amount);
+   │         ━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function selfRestrictedBad(address payable to, uint256 amount) external onlySelf { to.transfer(amount);
+   │                                                                                        ━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function self2RestrictedBad(address payable to, uint256 amount) external onlySelf2 { to.transfer(amount);
+   │                                                                                          ━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function dInline(address payable to, uint256 a) external { require(msg.sender == SELF_INLINE); to.transfer(a);
+   │                                                                                                    ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function dImmutable(address payable to, uint256 a) external { require(msg.sender == SELF_IMMUTABLE); to.transfer(a);
+   │                                                                                                          ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function dChain(address payable to, uint256 a) external { require(msg.sender == SELF_CHAIN); to.transfer(a);
+   │                                                                                                  ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function dStruct(address payable to, uint256 a) external { require(msg.sender == cfg.self); to.transfer(a);
+   │                                                                                                 ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function dMapping(address payable to, uint256 a) external { require(msg.sender == principals[0]); to.transfer(a);
+   │                                                                                                       ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function dTuple(address payable to, uint256 a) external { require(msg.sender == tupleSelf); to.transfer(a);
+   │                                                                                                 ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function dDeep(address payable to, uint256 a) external { require(msg.sender == dA); to.transfer(a);
+   │                                                                                         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function dFiveHop(address payable to, uint256 a) external { require(msg.sender == fA); to.transfer(a);
+   │                                                                                            ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function dCtorHelper(address payable to, uint256 a) external { require(msg.sender == SELF_HELPER); to.transfer(a);
+   │                                                                                                        ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function dStructLit(address payable to, uint256 a) external { require(msg.sender == cfgLit.self); to.transfer(a);
+   │                                                                                                       ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function dCtorLocal(address payable to, uint256 a) external { require(msg.sender == SELF_LOCAL); to.transfer(a);
+   │                                                                                                      ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function dNestedHelper(address payable to, uint256 a) external { require(msg.sender == SELF_NESTED); to.transfer(a);
+   │                                                                                                          ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function dTernary(address payable to, uint256 a) external { require(msg.sender == SELF_TERNARY); to.transfer(a);
+   │                                                                                                      ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function dPush(address payable to, uint256 a) external { require(msg.sender == pushArr[0]); to.transfer(a);
+   │                                                                                                 ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │         to.transfer(amt);
+   │         ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function fireBad(uint256 i) external payable { callbacks[i]{value: msg.value}();
+   │                                                    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function fireMappingBad(bytes4 sel) external payable { handlers[sel]{value: msg.value}();
+   │                                                            ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │         to.transfer(amount);
+   │         ━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function _send(address payable to, uint256 amount) internal { to.transfer(amount);
+   │                                                                   ━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │         to.transfer(amt);
+   │         ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │ …s calldata data, uint256 amt) external { payable(abi.decode(data, (address))).transfer(amt);
+   │                                           ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │ …amt) external returns (bool ok) { (ok,) = abi.decode(data, (address)).call{value: amt}("");
+   │                                            ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │         to.transfer(a);
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │         to.transfer(a);
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function notRestrictedBad(address payable to, uint256 amt, bool b) external maybePlaceholderThenGuard(b) { to.transfer(amt);
+   │                                                                                                                ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │ …able to, uint256 amount) external checkOuterAssign(to) { to.transfer(amount);
+   │                                                           ━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │         to.transfer(amt);
+   │         ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │     function assignmentReceiverBad(address payable to, address payable other, uint256 amt) external { (to = other).transfer(amt);
+   │                                                                                                       ━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │         to.transfer(a);
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │         to.transfer(a);
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │         to.transfer(a);
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │         to.transfer(a);
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │         to.transfer(a);
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │         to.transfer(a);
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │         to.transfer(a);
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │         to.transfer(a);
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │         to.transfer(amount);
+   │         ━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │         to.transfer(amount);
+   │         ━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │         to.transfer(amount);
+   │         ━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
+warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
+   ╭▸ ROOT/testdata/ArbitrarySendEth.sol:LL:CC
+   │
+LL │         cb{value: amt}();
+   │         ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-eth
+
diff --git a/crates/lint/testdata/AssertStateChange.sol b/crates/lint/testdata/AssertStateChange.sol
new file mode 100644
index 0000000000000..d2a7e515e4529
--- /dev/null
+++ b/crates/lint/testdata/AssertStateChange.sol
@@ -0,0 +1,459 @@
+//@compile-flags: --only-lint assert-state-change
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.0;
+
+contract AssertStateChange {
+    uint256 public counter;
+    bool public flag;
+    mapping(address => uint256) public balances;
+    uint256[] public items;
+
+    // Bad: pre-increment of state variable inside assert
+    function badPreIncrement(uint256 expected) external {
+        assert(++counter == expected); //~WARN: `assert()` argument contains a state-modifying expression
+    }
+
+    // Bad: post-increment of state variable inside assert
+    function badPostIncrement(uint256 expected) external {
+        assert(counter++ == expected); //~WARN: `assert()` argument contains a state-modifying expression
+    }
+
+    // Bad: call to state-mutating internal function
+    function _toggleFlag() internal returns (bool) {
+        flag = !flag;
+        return flag;
+    }
+
+    function badMutatingCall() external {
+        assert(_toggleFlag()); //~WARN: `assert()` argument contains a state-modifying expression
+    }
+
+    // Bad: call to another state-mutating function
+    function _deposit() internal returns (bool) {
+        balances[msg.sender] += msg.value;
+        return true;
+    }
+
+    function badDeposit() external payable {
+        assert(_deposit()); //~WARN: `assert()` argument contains a state-modifying expression
+    }
+
+    // Bad: state variable assignment inside assert
+    function badAssignment(uint256 val) external {
+        assert((counter = val) > 0); //~WARN: `assert()` argument contains a state-modifying expression
+    }
+
+    // Bad: mapping index assignment (state variable lvalue)
+    function badMappingAssign(address user, uint256 amt) external {
+        assert((balances[user] = amt) > 0); //~WARN: `assert()` argument contains a state-modifying expression
+    }
+
+    // Good: pure comparison, no state change
+    function goodComparison(uint256 expected) external view {
+        assert(counter == expected);
+    }
+
+    // Good: view function call inside assert
+    function _getCounter() internal view returns (uint256) {
+        return counter;
+    }
+
+    function goodViewCall(uint256 expected) external view {
+        assert(_getCounter() == expected);
+    }
+
+    // Good: require() with increment is fine (not assert)
+    function goodRequire(uint256 expected) external {
+        require(++counter == expected, "mismatch");
+    }
+
+    // Good: local variable increment, not a state variable
+    function goodLocalInc() external pure returns (uint256) {
+        uint256 local = 0;
+        assert(++local == 1);
+        return local;
+    }
+
+    // Good: local variable assignment, not a state variable
+    function goodLocalAssign(uint256 val) external pure {
+        uint256 local;
+        assert((local = val) > 0);
+    }
+}
+
+interface IToken {
+    function transfer(address to, uint256 amount) external returns (bool);
+    function balanceOf(address account) external view returns (uint256);
+    // Overloaded: one mutating (2 args), one view (1 arg) — different arities so arity
+    // narrowing can correctly distinguish them.
+    function update(address target, uint256 amount) external returns (bool);
+    function update(uint256 n) external view returns (bool);
+    function update(address target) external returns (bool);
+}
+
+// Interface with view functions that share names with low-level address builtins.
+// These must NOT be flagged, the name-only heuristic must not fire when the receiver
+// resolves to a known contract/interface.
+interface IRouter {
+    function send(uint256 amount) external view returns (bool);
+    function call(bytes calldata data) external view returns (bool);
+    function transfer(uint256 amount) external view returns (bool);
+}
+
+contract AssertStateChangeExternal {
+    IToken public token;
+    IRouter public router;
+    address payable public recipient;
+
+    // Bad: .send() always transfers ether (state-changing), returns bool
+    function badSend() external {
+        assert(recipient.send(1 ether)); //~WARN: `assert()` argument contains a state-modifying expression
+    }
+
+    // Bad: interface call to a non-view function
+    function badInterfaceCall(address to, uint256 amt) external {
+        assert(token.transfer(to, amt)); //~WARN: `assert()` argument contains a state-modifying expression
+    }
+
+    // Good: view function on an interface does not mutate state
+    function goodInterfaceView(uint256 expected) external view {
+        assert(token.balanceOf(address(this)) >= expected);
+    }
+
+    // Good: calling the 1-arg view overload `update(uint256)` — arity differs from
+    // the 2-arg mutating overload, so no false positive.
+    function goodOverloadView(uint256 n) external view {
+        assert(token.update(n));
+    }
+
+    function badSameArityOverload(address target) external {
+        assert(token.update(target)); //~WARN: `assert()` argument contains a state-modifying expression
+    }
+
+    // Bad: calling the 2-arg mutating overload `update(address,uint256)` — only
+    // overload with this arity, so correctly flagged.
+    function badOverloadMutating(address target, uint256 amt) external {
+        assert(token.update(target, amt)); //~WARN: `assert()` argument contains a state-modifying expression
+    }
+
+    // Good: view functions on an interface that happen to be named send/call/transfer
+    // must NOT trigger the name-only address heuristic (fix for false positives).
+    function goodViewSend(uint256 n) external view {
+        assert(router.send(n));
+    }
+
+    function goodViewCall(bytes calldata data) external view {
+        assert(router.call(data));
+    }
+
+    function goodViewTransfer(uint256 n) external view {
+        assert(router.transfer(n));
+    }
+}
+
+// ---- push/pop on contract/interface state vars must not be flagged ----
+// These are interface method calls, not builtin array mutations. The name-only heuristic
+// must not fire; resolve_member_overloads handles them precisely via mutates_state().
+interface IQueue {
+    function pop() external view returns (bool);
+    function push(uint256 x) external view returns (bool);
+}
+
+contract AssertStateChangePushPop {
+    IQueue public q;
+
+    function goodInterfacePop() external view {
+        assert(q.pop());
+    }
+
+    function goodInterfacePush(uint256 x) external view {
+        assert(q.push(x));
+    }
+}
+
+// ---- using-for library extension calls ----
+// Solar's resolved call is preferred; a library scan remains as a fallback when resolution is
+// unavailable.
+
+library StorageLib {
+    function bump(uint256[] storage arr) internal returns (bool) {
+        arr.push(1);
+        return true;
+    }
+
+    function peek(uint256[] storage arr) internal view returns (uint256) {
+        return arr.length;
+    }
+}
+
+contract AssertStateChangeUsingFor {
+    using StorageLib for uint256[];
+
+    uint256[] public items;
+
+    function getItems() internal view returns (uint256[] storage) {
+        return items;
+    }
+
+    // Bad: bump() writes to storage via a using-for library extension, must be flagged.
+    function badLibraryExtension() external returns (bool) {
+        assert(items.bump()); //~WARN: `assert()` argument contains a state-modifying expression
+        return true;
+    }
+
+    // Bad: the function call returns the same storage array and must remain storage-backed.
+    function badLibraryExtensionOnStorageReturn() external returns (bool) {
+        assert(getItems().bump()); //~WARN: `assert()` argument contains a state-modifying expression
+        return true;
+    }
+
+    // Good: peek() is a view extension — must NOT be flagged.
+    function goodLibraryView() external view returns (bool) {
+        assert(items.peek() >= 0);
+        return true;
+    }
+}
+
+// Same-arity overloads use the selected function's mutability.
+interface IOverloaded {
+    function check(uint256 n) external returns (bool);   // mutating
+    function check(address a) external view returns (bool); // view
+}
+
+contract AssertStateChangeSameArityOverload {
+    IOverloaded public o;
+
+    // Bad: the selected `check(uint256)` overload mutates state.
+    function badSameArityMutating(uint256 n) external {
+        assert(o.check(n)); //~WARN: `assert()` argument contains a state-modifying expression
+    }
+
+    // Good: the selected `check(address)` overload only reads state.
+    function goodSameArityView(address a) external {
+        assert(o.check(a));
+    }
+}
+
+// ---- storage pointer aliases ----
+// A local variable declared `storage` is an alias into contract storage; assignments
+// through it must be treated as state mutations.
+contract AssertStateChangeStorageAlias {
+    uint256[] public items;
+    mapping(address => uint256) public balances;
+
+    // Good: read-only access through a storage-pointer local should not warn.
+    function goodStorageArrayAliasReadOnly() external view returns (uint256) {
+        uint256[] storage xs = items;
+        assert((xs.length) > 0);
+        return xs.length;
+    }
+
+    // Bad: assignment through a storage-pointer local to an array element.
+    function badStorageArrayAliasAssign() external {
+        uint256[] storage xs = items;
+        assert((xs[0] = 1) > 0); //~WARN: `assert()` argument contains a state-modifying expression
+    }
+
+    // Bad: assignment through a storage-pointer local to a mapping slot.
+    function badStorageMappingAlias(address user, uint256 amt) external {
+        mapping(address => uint256) storage m = balances;
+        assert((m[user] = amt) > 0); //~WARN: `assert()` argument contains a state-modifying expression
+    }
+}
+
+// ---- indexed-into contract/interface receivers (issue: false negatives) ----
+// contract_id_of must resolve tokens[i] and byUser[user] by element/value type,
+// not just plain Ident receivers.
+interface IMutator {
+    function mutate() external returns (bool);
+    function peek() external view returns (bool);
+}
+
+contract AssertStateChangeIndexedContractCall {
+    IMutator[] public tokens;
+    mapping(address => IMutator) public byUser;
+
+    // Bad: mutating call through an array-indexed contract variable.
+    function badIndexedContractCall(uint256 i) external {
+        assert(tokens[i].mutate()); //~WARN: `assert()` argument contains a state-modifying expression
+    }
+
+    // Bad: mutating call through a mapping-indexed contract variable.
+    function badMappingContractCall(address user) external {
+        assert(byUser[user].mutate()); //~WARN: `assert()` argument contains a state-modifying expression
+    }
+
+    // Good: view call through an array-indexed contract variable must NOT warn.
+    function goodIndexedViewCall(uint256 i) external view {
+        assert(tokens[i].peek());
+    }
+
+    // Good: view call through a mapping-indexed contract variable must NOT warn.
+    function goodMappingViewCall(address user) external view {
+        assert(byUser[user].peek());
+    }
+}
+
+// ---- indexed interface array must not trigger builtin push/pop heuristic (issue: false positive) ----
+// is_dynamic_array_or_bytes(queues[i]) must return false because queues[i] is IQueue, not a builtin array.
+contract AssertStateChangeIndexedInterfaceArray {
+    IQueue[] public queues;
+
+    // Good: view interface method on an element of an interface array must NOT be flagged.
+    function goodIndexedInterfacePop(uint256 idx) external view {
+        assert(queues[idx].pop());
+    }
+
+    // Good: same for push — the element type is IQueue, not a builtin array.
+    function goodIndexedInterfacePush(uint256 idx, uint256 x) external view {
+        assert(queues[idx].push(x));
+    }
+}
+
+// ---- library-fallback receiver-type guard ----
+library OtherStorageLib {
+    function bump(bytes storage b) internal returns (bool) {
+        b.push(0x00);
+        return true;
+    }
+}
+
+contract AssertStateChangeUnrelatedLib {
+    uint256[] public items;
+
+    // Good: `items.bump()` has no matching extension (OtherStorageLib.bump takes bytes, not
+    // uint256[]), and `uint256[]` has no member `bump()`. Must NOT be flagged.
+    function goodUnrelatedLib() external view returns (bool) {
+        return items.length == 0;
+    }
+}
+
+// ---- struct-field receiver chain (issue: false negatives) ----
+// cfg.token.transfer(...), the receiver is a struct field of contract type;
+// contract_id_of must walk through the struct field to find the ContractId.
+struct Config {
+    IToken token;
+}
+
+contract AssertStateChangeStructFieldReceiver {
+    Config public cfg;
+
+    // Bad: mutating call through a struct-field contract variable.
+    function badStructFieldCall(address to, uint256 amt) external {
+        assert(cfg.token.transfer(to, amt)); //~WARN: `assert()` argument contains a state-modifying expression
+    }
+
+    // Good: view call through a struct-field contract variable must NOT warn.
+    function goodStructFieldViewCall(address who) external view {
+        assert(cfg.token.balanceOf(who) > 0);
+    }
+}
+
+// ---- function-call-result receiver (issue: false negatives) ----
+// getToken().transfer(...), the receiver is the return value of a function call;
+// contract_id_of must resolve the function's return type to find the ContractId.
+contract AssertStateChangeFnReturnReceiver {
+    IToken private tok;
+
+    function getToken() internal view returns (IToken) {
+        return tok;
+    }
+
+    // Bad: mutating call on the return value of a function.
+    function badFnReturnCall(address to, uint256 amt) external {
+        assert(getToken().transfer(to, amt)); //~WARN: `assert()` argument contains a state-modifying expression
+    }
+
+    // Good: view call on the return value of a function must NOT warn.
+    function goodFnReturnViewCall(address who) external view {
+        assert(getToken().balanceOf(who) > 0);
+    }
+}
+
+// ---- address-mapping index receiver (issue: false negatives) ----
+// payees[user].send(...), the receiver is an element of an address mapping;
+// is_address_like must handle Mapping value types, not only Array element types.
+contract AssertStateChangeAddressMappingReceiver {
+    mapping(address => address payable) public payees;
+
+    // Bad: .send() on a mapping-indexed address value.
+    function badMappingAddressSend(address user) external {
+        assert(payees[user].send(1 ether)); //~WARN: `assert()` argument contains a state-modifying expression
+    }
+
+    // Good: reading a mapping-indexed address value (no call) must NOT warn.
+    function goodMappingAddressRead(address user) external view {
+        assert(payees[user] != address(0));
+    }
+}
+
+// ---- bare internal overloads with different arities ----
+// Bare identifier resolution must filter overload candidates by arity; the 1-arg view overload
+// should not inherit mutability from the unrelated 2-arg mutating overload.
+contract AssertStateChangeBareOverloadArity {
+    uint256 public counter;
+
+    function check(uint256 n) internal view returns (bool) {
+        return n == counter;
+    }
+
+    function check(uint256 n, uint256 delta) internal returns (bool) {
+        counter = n + delta;
+        return true;
+    }
+
+    // Good: selected overload is view, even though a same-name different-arity overload mutates.
+    function goodDifferentArityBareCall(uint256 n) external view {
+        assert(check(n));
+    }
+}
+
+// ---- this receiver ----
+// `this` is a contract-typed receiver even though it does not resolve through a variable.
+contract AssertStateChangeThisReceiver {
+    uint256 public counter;
+
+    function mutate() external returns (bool) {
+        counter++;
+        return true;
+    }
+
+    function badThisCall() external {
+        assert(this.mutate()); //~WARN: `assert()` argument contains a state-modifying expression
+    }
+}
+
+// ---- member-call-result receiver ----
+// factory.token().transfer(...), the receiver is a contract returned from a member call.
+interface ITokenFactory {
+    function token() external view returns (IToken);
+}
+
+contract AssertStateChangeMemberReturnReceiver {
+    ITokenFactory public factory;
+
+    // Bad: mutating call on the contract returned by a member function.
+    function badMemberReturnCall(address to, uint256 amt) external {
+        assert(factory.token().transfer(to, amt)); //~WARN: `assert()` argument contains a state-modifying expression
+    }
+
+    // Good: view call on the contract returned by a member function must NOT warn.
+    function goodMemberReturnViewCall(address who) external view {
+        assert(factory.token().balanceOf(who) > 0);
+    }
+}
+
+// ---- address struct-field receiver ----
+// payee.recipient.send(...), the receiver is an address payable stored inside a struct field.
+struct Payee {
+    address payable recipient;
+}
+
+contract AssertStateChangeAddressStructReceiver {
+    Payee public payee;
+
+    function badStructAddressSend() external {
+        assert(payee.recipient.send(1 ether)); //~WARN: `assert()` argument contains a state-modifying expression
+    }
+}
diff --git a/crates/lint/testdata/AssertStateChange.stderr b/crates/lint/testdata/AssertStateChange.stderr
new file mode 100644
index 0000000000000..5f349a68861f6
--- /dev/null
+++ b/crates/lint/testdata/AssertStateChange.stderr
@@ -0,0 +1,184 @@
+warning[assert-state-change]: `assert()` argument contains a state-modifying expression; `assert()` is for invariants, hoist the mutation before the `assert`, or use `require()` for validation
+   ╭▸ ROOT/testdata/AssertStateChange.sol:LL:CC
+   │
+LL │         assert(++counter == expected);
+   │                ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/assert-state-change
+
+warning[assert-state-change]: `assert()` argument contains a state-modifying expression; `assert()` is for invariants, hoist the mutation before the `assert`, or use `require()` for validation
+   ╭▸ ROOT/testdata/AssertStateChange.sol:LL:CC
+   │
+LL │         assert(counter++ == expected);
+   │                ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/assert-state-change
+
+warning[assert-state-change]: `assert()` argument contains a state-modifying expression; `assert()` is for invariants, hoist the mutation before the `assert`, or use `require()` for validation
+   ╭▸ ROOT/testdata/AssertStateChange.sol:LL:CC
+   │
+LL │         assert(_toggleFlag());
+   │                ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/assert-state-change
+
+warning[assert-state-change]: `assert()` argument contains a state-modifying expression; `assert()` is for invariants, hoist the mutation before the `assert`, or use `require()` for validation
+   ╭▸ ROOT/testdata/AssertStateChange.sol:LL:CC
+   │
+LL │         assert(_deposit());
+   │                ━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/assert-state-change
+
+warning[assert-state-change]: `assert()` argument contains a state-modifying expression; `assert()` is for invariants, hoist the mutation before the `assert`, or use `require()` for validation
+   ╭▸ ROOT/testdata/AssertStateChange.sol:LL:CC
+   │
+LL │         assert((counter = val) > 0);
+   │                 ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/assert-state-change
+
+warning[assert-state-change]: `assert()` argument contains a state-modifying expression; `assert()` is for invariants, hoist the mutation before the `assert`, or use `require()` for validation
+   ╭▸ ROOT/testdata/AssertStateChange.sol:LL:CC
+   │
+LL │         assert((balances[user] = amt) > 0);
+   │                 ━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/assert-state-change
+
+warning[assert-state-change]: `assert()` argument contains a state-modifying expression; `assert()` is for invariants, hoist the mutation before the `assert`, or use `require()` for validation
+   ╭▸ ROOT/testdata/AssertStateChange.sol:LL:CC
+   │
+LL │         assert(recipient.send(1 ether));
+   │                ━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/assert-state-change
+
+warning[assert-state-change]: `assert()` argument contains a state-modifying expression; `assert()` is for invariants, hoist the mutation before the `assert`, or use `require()` for validation
+   ╭▸ ROOT/testdata/AssertStateChange.sol:LL:CC
+   │
+LL │         assert(token.transfer(to, amt));
+   │                ━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/assert-state-change
+
+warning[assert-state-change]: `assert()` argument contains a state-modifying expression; `assert()` is for invariants, hoist the mutation before the `assert`, or use `require()` for validation
+   ╭▸ ROOT/testdata/AssertStateChange.sol:LL:CC
+   │
+LL │         assert(token.update(target));
+   │                ━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/assert-state-change
+
+warning[assert-state-change]: `assert()` argument contains a state-modifying expression; `assert()` is for invariants, hoist the mutation before the `assert`, or use `require()` for validation
+   ╭▸ ROOT/testdata/AssertStateChange.sol:LL:CC
+   │
+LL │         assert(token.update(target, amt));
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/assert-state-change
+
+warning[assert-state-change]: `assert()` argument contains a state-modifying expression; `assert()` is for invariants, hoist the mutation before the `assert`, or use `require()` for validation
+   ╭▸ ROOT/testdata/AssertStateChange.sol:LL:CC
+   │
+LL │         assert(items.bump());
+   │                ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/assert-state-change
+
+warning[assert-state-change]: `assert()` argument contains a state-modifying expression; `assert()` is for invariants, hoist the mutation before the `assert`, or use `require()` for validation
+   ╭▸ ROOT/testdata/AssertStateChange.sol:LL:CC
+   │
+LL │         assert(getItems().bump());
+   │                ━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/assert-state-change
+
+warning[assert-state-change]: `assert()` argument contains a state-modifying expression; `assert()` is for invariants, hoist the mutation before the `assert`, or use `require()` for validation
+   ╭▸ ROOT/testdata/AssertStateChange.sol:LL:CC
+   │
+LL │         assert(o.check(n));
+   │                ━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/assert-state-change
+
+warning[assert-state-change]: `assert()` argument contains a state-modifying expression; `assert()` is for invariants, hoist the mutation before the `assert`, or use `require()` for validation
+   ╭▸ ROOT/testdata/AssertStateChange.sol:LL:CC
+   │
+LL │         assert((xs[0] = 1) > 0);
+   │                 ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/assert-state-change
+
+warning[assert-state-change]: `assert()` argument contains a state-modifying expression; `assert()` is for invariants, hoist the mutation before the `assert`, or use `require()` for validation
+   ╭▸ ROOT/testdata/AssertStateChange.sol:LL:CC
+   │
+LL │         assert((m[user] = amt) > 0);
+   │                 ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/assert-state-change
+
+warning[assert-state-change]: `assert()` argument contains a state-modifying expression; `assert()` is for invariants, hoist the mutation before the `assert`, or use `require()` for validation
+   ╭▸ ROOT/testdata/AssertStateChange.sol:LL:CC
+   │
+LL │         assert(tokens[i].mutate());
+   │                ━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/assert-state-change
+
+warning[assert-state-change]: `assert()` argument contains a state-modifying expression; `assert()` is for invariants, hoist the mutation before the `assert`, or use `require()` for validation
+   ╭▸ ROOT/testdata/AssertStateChange.sol:LL:CC
+   │
+LL │         assert(byUser[user].mutate());
+   │                ━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/assert-state-change
+
+warning[assert-state-change]: `assert()` argument contains a state-modifying expression; `assert()` is for invariants, hoist the mutation before the `assert`, or use `require()` for validation
+   ╭▸ ROOT/testdata/AssertStateChange.sol:LL:CC
+   │
+LL │         assert(cfg.token.transfer(to, amt));
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/assert-state-change
+
+warning[assert-state-change]: `assert()` argument contains a state-modifying expression; `assert()` is for invariants, hoist the mutation before the `assert`, or use `require()` for validation
+   ╭▸ ROOT/testdata/AssertStateChange.sol:LL:CC
+   │
+LL │         assert(getToken().transfer(to, amt));
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/assert-state-change
+
+warning[assert-state-change]: `assert()` argument contains a state-modifying expression; `assert()` is for invariants, hoist the mutation before the `assert`, or use `require()` for validation
+   ╭▸ ROOT/testdata/AssertStateChange.sol:LL:CC
+   │
+LL │         assert(payees[user].send(1 ether));
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/assert-state-change
+
+warning[assert-state-change]: `assert()` argument contains a state-modifying expression; `assert()` is for invariants, hoist the mutation before the `assert`, or use `require()` for validation
+   ╭▸ ROOT/testdata/AssertStateChange.sol:LL:CC
+   │
+LL │         assert(this.mutate());
+   │                ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/assert-state-change
+
+warning[assert-state-change]: `assert()` argument contains a state-modifying expression; `assert()` is for invariants, hoist the mutation before the `assert`, or use `require()` for validation
+   ╭▸ ROOT/testdata/AssertStateChange.sol:LL:CC
+   │
+LL │         assert(factory.token().transfer(to, amt));
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/assert-state-change
+
+warning[assert-state-change]: `assert()` argument contains a state-modifying expression; `assert()` is for invariants, hoist the mutation before the `assert`, or use `require()` for validation
+   ╭▸ ROOT/testdata/AssertStateChange.sol:LL:CC
+   │
+LL │         assert(payee.recipient.send(1 ether));
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/assert-state-change
+
diff --git a/crates/lint/testdata/AssertStateChangeLibDisambig.sol b/crates/lint/testdata/AssertStateChangeLibDisambig.sol
new file mode 100644
index 0000000000000..8eddfaf91b442
--- /dev/null
+++ b/crates/lint/testdata/AssertStateChangeLibDisambig.sol
@@ -0,0 +1,63 @@
+//@compile-flags: --only-lint assert-state-change
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.0;
+
+// ---- library disambiguation: selected extension avoids unrelated libraries ----
+//
+// Two libraries both define bump(uint256[] storage) but with different mutability.
+// Solar's selected extension takes precedence over the global fallback scan.
+
+library MutBumpLib {
+    function bump(uint256[] storage arr) internal returns (bool) {
+        arr.push(1);
+        return true;
+    }
+}
+
+library ViewBumpLib {
+    function bump(uint256[] storage arr) internal view returns (uint256) {
+        return arr.length;
+    }
+}
+
+// Good: ViewBumpLib.bump is view; MutBumpLib.bump is in the compilation unit but NOT
+// bound here via `using for`.
+contract AssertStateChangeLibDisambiguation {
+    using ViewBumpLib for uint256[];
+
+    uint256[] public items;
+
+    function goodViewLibraryExtension() external view returns (uint256) {
+        assert(items.bump() >= 0);
+        return items.length;
+    }
+}
+
+library MemoryView {
+    function inspect(uint256[] memory) internal pure returns (bool) {
+        return true;
+    }
+}
+
+library UnrelatedStorageMutation {
+    function inspect(uint256[] storage values) internal returns (bool) {
+        values.push(1);
+        return true;
+    }
+}
+
+contract AssertStateChangeMemoryExtension {
+    using MemoryView for uint256[];
+
+    uint256[] private storedValues;
+
+    function values() internal view returns (uint256[] storage) {
+        return storedValues;
+    }
+
+    // Good: the selected extension copies its receiver to memory and does not mutate storage.
+    function goodMemoryExtension() external view {
+        assert(values().inspect());
+    }
+}
diff --git a/crates/lint/testdata/BlockTimestamp.sol b/crates/lint/testdata/BlockTimestamp.sol
new file mode 100644
index 0000000000000..2245580f7047a
--- /dev/null
+++ b/crates/lint/testdata/BlockTimestamp.sol
@@ -0,0 +1,127 @@
+//@compile-flags: --only-lint block-timestamp
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+contract BlockTimestamp {
+    uint256 public deadline;
+
+    // SHOULD FAIL:
+
+    function directComparison() public view returns (bool) {
+        return block.timestamp > deadline; //~WARN: usage of `block.timestamp` in a comparison may be manipulated by validators
+    }
+
+    function comparisonEq() public view returns (bool) {
+        return block.timestamp == 0; //~WARN: usage of `block.timestamp` in a comparison may be manipulated by validators
+    }
+
+    function comparisonNe() public view returns (bool) {
+        return block.timestamp != 0; //~WARN: usage of `block.timestamp` in a comparison may be manipulated by validators
+    }
+
+    function comparisonLe() public view returns (bool) {
+        return block.timestamp <= deadline; //~WARN: usage of `block.timestamp` in a comparison may be manipulated by validators
+    }
+
+    function comparisonGe() public view returns (bool) {
+        return block.timestamp >= deadline; //~WARN: usage of `block.timestamp` in a comparison may be manipulated by validators
+    }
+
+    function comparisonLt() public view returns (bool) {
+        return block.timestamp < deadline; //~WARN: usage of `block.timestamp` in a comparison may be manipulated by validators
+    }
+
+    function timestampOnRight() public view returns (bool) {
+        return deadline > block.timestamp; //~WARN: usage of `block.timestamp` in a comparison may be manipulated by validators
+    }
+
+    function timestampInArithmetic() public view returns (bool) {
+        return block.timestamp + 1 > deadline; //~WARN: usage of `block.timestamp` in a comparison may be manipulated by validators
+    }
+
+    function timestampInComplexExpr() public view returns (bool) {
+        return (block.timestamp / 3600) == 0; //~WARN: usage of `block.timestamp` in a comparison may be manipulated by validators
+    }
+
+    function inRequire() public view {
+        require(block.timestamp > deadline); //~WARN: usage of `block.timestamp` in a comparison may be manipulated by validators
+    }
+
+    function inIfCondition() public view returns (uint256) {
+        if (block.timestamp > deadline) { //~WARN: usage of `block.timestamp` in a comparison may be manipulated by validators
+            return 1;
+        }
+        return 0;
+    }
+
+    function timestampInCallArg() public view returns (bool) {
+        return foo(block.timestamp) > 0; //~WARN: usage of `block.timestamp` in a comparison may be manipulated by validators
+    }
+
+    function aliasedComparison() public view returns (bool) {
+        uint256 t = block.timestamp;
+        return t < deadline; //~WARN: usage of `block.timestamp` in a comparison may be manipulated by validators
+    }
+
+    function aliasedRequire() public view {
+        uint256 t = block.timestamp;
+        require(t < deadline); //~WARN: usage of `block.timestamp` in a comparison may be manipulated by validators
+    }
+
+    function helperComparison() public view returns (bool) {
+        return currentTime() < deadline; //~WARN: usage of `block.timestamp` in a comparison may be manipulated by validators
+    }
+
+    // SHOULD PASS:
+
+    function assignOnly() public view returns (uint256) {
+        uint256 t = block.timestamp;
+        return t;
+    }
+
+    function emitOnly() public {
+        emit Timestamp(block.timestamp);
+    }
+
+    function blockNumber() public view returns (bool) {
+        return block.number > 100;
+    }
+
+    function arithmetic() public view returns (uint256) {
+        return block.timestamp + 100;
+    }
+
+    function aliasOverwritten() public view returns (bool) {
+        uint256 t = block.timestamp;
+        t = deadline;
+        return t > 0;
+    }
+
+    function returningBranchAliasDoesNotLeak(bool condition) public view returns (bool) {
+        uint256 t = deadline;
+        if (condition) {
+            t = block.timestamp;
+            return true;
+        }
+        return t > 0;
+    }
+
+    function revertingBranchAliasDoesNotLeak(bool condition) public view returns (bool) {
+        uint256 t = deadline;
+        if (condition) {
+            t = block.timestamp;
+            revert("done");
+        }
+        return t > 0;
+    }
+
+    function foo(uint256 x) internal pure returns (uint256) {
+        return x;
+    }
+
+    function currentTime() internal view returns (uint256) {
+        return block.timestamp;
+    }
+
+    event Timestamp(uint256 ts);
+}
diff --git a/crates/lint/testdata/BlockTimestamp.stderr b/crates/lint/testdata/BlockTimestamp.stderr
new file mode 100644
index 0000000000000..112deb1e650ae
--- /dev/null
+++ b/crates/lint/testdata/BlockTimestamp.stderr
@@ -0,0 +1,120 @@
+warning[block-timestamp]: usage of `block.timestamp` in a comparison may be manipulated by validators
+   ╭▸ ROOT/testdata/BlockTimestamp.sol:LL:CC
+   │
+LL │         return block.timestamp > deadline;
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/block-timestamp
+
+warning[block-timestamp]: usage of `block.timestamp` in a comparison may be manipulated by validators
+   ╭▸ ROOT/testdata/BlockTimestamp.sol:LL:CC
+   │
+LL │         return block.timestamp == 0;
+   │                ━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/block-timestamp
+
+warning[block-timestamp]: usage of `block.timestamp` in a comparison may be manipulated by validators
+   ╭▸ ROOT/testdata/BlockTimestamp.sol:LL:CC
+   │
+LL │         return block.timestamp != 0;
+   │                ━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/block-timestamp
+
+warning[block-timestamp]: usage of `block.timestamp` in a comparison may be manipulated by validators
+   ╭▸ ROOT/testdata/BlockTimestamp.sol:LL:CC
+   │
+LL │         return block.timestamp <= deadline;
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/block-timestamp
+
+warning[block-timestamp]: usage of `block.timestamp` in a comparison may be manipulated by validators
+   ╭▸ ROOT/testdata/BlockTimestamp.sol:LL:CC
+   │
+LL │         return block.timestamp >= deadline;
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/block-timestamp
+
+warning[block-timestamp]: usage of `block.timestamp` in a comparison may be manipulated by validators
+   ╭▸ ROOT/testdata/BlockTimestamp.sol:LL:CC
+   │
+LL │         return block.timestamp < deadline;
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/block-timestamp
+
+warning[block-timestamp]: usage of `block.timestamp` in a comparison may be manipulated by validators
+   ╭▸ ROOT/testdata/BlockTimestamp.sol:LL:CC
+   │
+LL │         return deadline > block.timestamp;
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/block-timestamp
+
+warning[block-timestamp]: usage of `block.timestamp` in a comparison may be manipulated by validators
+   ╭▸ ROOT/testdata/BlockTimestamp.sol:LL:CC
+   │
+LL │         return block.timestamp + 1 > deadline;
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/block-timestamp
+
+warning[block-timestamp]: usage of `block.timestamp` in a comparison may be manipulated by validators
+   ╭▸ ROOT/testdata/BlockTimestamp.sol:LL:CC
+   │
+LL │         return (block.timestamp / 3600) == 0;
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/block-timestamp
+
+warning[block-timestamp]: usage of `block.timestamp` in a comparison may be manipulated by validators
+   ╭▸ ROOT/testdata/BlockTimestamp.sol:LL:CC
+   │
+LL │         require(block.timestamp > deadline);
+   │                 ━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/block-timestamp
+
+warning[block-timestamp]: usage of `block.timestamp` in a comparison may be manipulated by validators
+   ╭▸ ROOT/testdata/BlockTimestamp.sol:LL:CC
+   │
+LL │         if (block.timestamp > deadline) {
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/block-timestamp
+
+warning[block-timestamp]: usage of `block.timestamp` in a comparison may be manipulated by validators
+   ╭▸ ROOT/testdata/BlockTimestamp.sol:LL:CC
+   │
+LL │         return foo(block.timestamp) > 0;
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/block-timestamp
+
+warning[block-timestamp]: usage of `block.timestamp` in a comparison may be manipulated by validators
+   ╭▸ ROOT/testdata/BlockTimestamp.sol:LL:CC
+   │
+LL │         return t < deadline;
+   │                ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/block-timestamp
+
+warning[block-timestamp]: usage of `block.timestamp` in a comparison may be manipulated by validators
+   ╭▸ ROOT/testdata/BlockTimestamp.sol:LL:CC
+   │
+LL │         require(t < deadline);
+   │                 ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/block-timestamp
+
+warning[block-timestamp]: usage of `block.timestamp` in a comparison may be manipulated by validators
+   ╭▸ ROOT/testdata/BlockTimestamp.sol:LL:CC
+   │
+LL │         return currentTime() < deadline;
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/block-timestamp
+
diff --git a/crates/lint/testdata/BooleanCst.sol b/crates/lint/testdata/BooleanCst.sol
new file mode 100644
index 0000000000000..89da1046a2fe5
--- /dev/null
+++ b/crates/lint/testdata/BooleanCst.sol
@@ -0,0 +1,41 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+contract BooleanCst {
+    function check(bool flag) public pure returns (bool) {
+        if (false) {} //~WARN: misuse of a boolean constant
+        if (flag || true) {} //~WARN: misuse of a boolean constant
+        if (flag ? true : false) {}
+        //~^WARN: misuse of a boolean constant
+        //~|WARN: misuse of a boolean constant
+        while (true) {
+            break;
+        }
+
+        bool assigned = true;
+        return assigned && false; //~WARN: misuse of a boolean constant
+    }
+
+    function allowedBareConstants(bool flag) public pure returns (bool) {
+        takesBool(true);
+        takesBool(false);
+        return true;
+    }
+
+    // A tuple only groups, so returning a literal inside one is as fine as
+    // returning it bare. This is how a (bool, T) result is written.
+    function allowedInTuple(bytes memory input) public pure returns (bool, uint256) {
+        if (input.length == 0) return (false, 0);
+        return (true, input.length);
+    }
+
+    // Parentheses do not make a condition acceptable.
+    function stillFlaggedWhenGrouped(bool flag) public pure returns (bool) {
+        if ((true)) {} //~WARN: misuse of a boolean constant
+        return flag || (false); //~WARN: misuse of a boolean constant
+    }
+
+    function takesBool(bool value) internal pure {
+        value;
+    }
+}
diff --git a/crates/lint/testdata/BooleanCst.stderr b/crates/lint/testdata/BooleanCst.stderr
new file mode 100644
index 0000000000000..e226d15175e9d
--- /dev/null
+++ b/crates/lint/testdata/BooleanCst.stderr
@@ -0,0 +1,56 @@
+warning[boolean-cst]: misuse of a boolean constant
+   ╭▸ ROOT/testdata/BooleanCst.sol:LL:CC
+   │
+LL │         if (false) {}
+   │             ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/boolean-cst
+
+warning[boolean-cst]: misuse of a boolean constant
+   ╭▸ ROOT/testdata/BooleanCst.sol:LL:CC
+   │
+LL │         if (flag || true) {}
+   │                     ━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/boolean-cst
+
+warning[boolean-cst]: misuse of a boolean constant
+   ╭▸ ROOT/testdata/BooleanCst.sol:LL:CC
+   │
+LL │         if (flag ? true : false) {}
+   │                    ━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/boolean-cst
+
+warning[boolean-cst]: misuse of a boolean constant
+   ╭▸ ROOT/testdata/BooleanCst.sol:LL:CC
+   │
+LL │         if (flag ? true : false) {}
+   │                           ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/boolean-cst
+
+warning[boolean-cst]: misuse of a boolean constant
+   ╭▸ ROOT/testdata/BooleanCst.sol:LL:CC
+   │
+LL │         return assigned && false;
+   │                            ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/boolean-cst
+
+warning[boolean-cst]: misuse of a boolean constant
+   ╭▸ ROOT/testdata/BooleanCst.sol:LL:CC
+   │
+LL │         if ((true)) {}
+   │             ━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/boolean-cst
+
+warning[boolean-cst]: misuse of a boolean constant
+   ╭▸ ROOT/testdata/BooleanCst.sol:LL:CC
+   │
+LL │         return flag || (false);
+   │                        ━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/boolean-cst
+
diff --git a/crates/lint/testdata/BooleanEqual.sol b/crates/lint/testdata/BooleanEqual.sol
new file mode 100644
index 0000000000000..1d3d35b256239
--- /dev/null
+++ b/crates/lint/testdata/BooleanEqual.sol
@@ -0,0 +1,24 @@
+//@compile-flags: --severity info
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+contract BooleanEqual {
+    function check(bool enabled, bool paused, bool ready, bool done) public pure {
+        if (enabled == true) {} //~NOTE: boolean comparison to a constant can be simplified
+        if (paused == false) {} //~NOTE: boolean comparison to a constant can be simplified
+        if (true != ready) {} //~NOTE: boolean comparison to a constant can be simplified
+        while (done != false) { //~NOTE: boolean comparison to a constant can be simplified
+            break;
+        }
+        for (; enabled == true && paused != false;) {
+            //~^NOTE: boolean comparison to a constant can be simplified
+            //~|NOTE: boolean comparison to a constant can be simplified
+            break;
+        }
+    }
+
+    function returnedComparison(bool enabled) public pure returns (bool) {
+        return enabled == true; //~NOTE: boolean comparison to a constant can be simplified
+    }
+}
diff --git a/crates/lint/testdata/BooleanEqual.stderr b/crates/lint/testdata/BooleanEqual.stderr
new file mode 100644
index 0000000000000..8926460b75fd8
--- /dev/null
+++ b/crates/lint/testdata/BooleanEqual.stderr
@@ -0,0 +1,56 @@
+note[boolean-equal]: boolean comparison to a constant can be simplified
+   ╭▸ ROOT/testdata/BooleanEqual.sol:LL:CC
+   │
+LL │         if (enabled == true) {}
+   │             ━━━━━━━━━━━━━━━ help: consider simplifying to: `enabled`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/boolean-equal
+
+note[boolean-equal]: boolean comparison to a constant can be simplified
+   ╭▸ ROOT/testdata/BooleanEqual.sol:LL:CC
+   │
+LL │         if (paused == false) {}
+   │             ━━━━━━━━━━━━━━━ help: consider simplifying to: `!paused`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/boolean-equal
+
+note[boolean-equal]: boolean comparison to a constant can be simplified
+   ╭▸ ROOT/testdata/BooleanEqual.sol:LL:CC
+   │
+LL │         if (true != ready) {}
+   │             ━━━━━━━━━━━━━ help: consider simplifying to: `!ready`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/boolean-equal
+
+note[boolean-equal]: boolean comparison to a constant can be simplified
+   ╭▸ ROOT/testdata/BooleanEqual.sol:LL:CC
+   │
+LL │         while (done != false) {
+   │                ━━━━━━━━━━━━━ help: consider simplifying to: `done`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/boolean-equal
+
+note[boolean-equal]: boolean comparison to a constant can be simplified
+   ╭▸ ROOT/testdata/BooleanEqual.sol:LL:CC
+   │
+LL │         for (; enabled == true && paused != false;) {
+   │                ━━━━━━━━━━━━━━━ help: consider simplifying to: `enabled`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/boolean-equal
+
+note[boolean-equal]: boolean comparison to a constant can be simplified
+   ╭▸ ROOT/testdata/BooleanEqual.sol:LL:CC
+   │
+LL │         for (; enabled == true && paused != false;) {
+   │                                   ━━━━━━━━━━━━━━━ help: consider simplifying to: `paused`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/boolean-equal
+
+note[boolean-equal]: boolean comparison to a constant can be simplified
+   ╭▸ ROOT/testdata/BooleanEqual.sol:LL:CC
+   │
+LL │         return enabled == true;
+   │                ━━━━━━━━━━━━━━━ help: consider simplifying to: `enabled`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/boolean-equal
+
diff --git a/crates/lint/testdata/CacheArrayLength.sol b/crates/lint/testdata/CacheArrayLength.sol
new file mode 100644
index 0000000000000..9e283d897b8ef
--- /dev/null
+++ b/crates/lint/testdata/CacheArrayLength.sol
@@ -0,0 +1,226 @@
+//@compile-flags: --only-lint cache-array-length
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.20;
+
+contract CacheArrayLength {
+    uint256[] internal items;
+    bytes internal data;
+    mapping(uint256 => uint256[]) internal buckets;
+    uint256[3] internal fixedItems;
+
+    struct Bag {
+        uint256[] values;
+    }
+
+    struct Counter {
+        uint256 length;
+    }
+
+    Bag internal bag;
+    Counter internal counter;
+
+    function storageArrayLength() external view returns (uint256 sum) {
+        for (uint256 i = 0; i < items.length; ++i) { //~NOTE: array length is read on every loop iteration
+            sum += items[i];
+        }
+    }
+
+    function compoundConditionStorageArrayLength(uint256 cap)
+        external
+        view
+        returns (uint256 sum)
+    {
+        for (uint256 i = 0; i < items.length && i < cap; ++i) { //~NOTE: array length is read on every loop iteration
+            sum += items[i];
+        }
+    }
+
+    function reversedCompoundConditionStorageArrayLength(uint256 cap)
+        external
+        view
+        returns (uint256 sum)
+    {
+        for (uint256 i = 0; i < cap && items.length > i; ++i) { //~NOTE: array length is read on every loop iteration
+            sum += items[i];
+        }
+    }
+
+    function memoryArrayLength(uint256[] memory values) public pure returns (uint256 sum) {
+        for (uint256 i = 0; i <= values.length; ++i) {
+            if (i < values.length) {
+                sum += values[i];
+            }
+        }
+    }
+
+    function calldataArrayLength(uint256[] calldata values) external pure returns (uint256 sum) {
+        for (uint256 i = 0; values.length > i; ++i) {
+            sum += values[i];
+        }
+    }
+
+    function bytesLength() external view returns (uint256 sum) {
+        for (uint256 i = 0; i < data.length; ++i) {
+            sum += uint8(data[i]);
+        }
+    }
+
+    function bytesCastLength(string memory value) public pure returns (uint256 sum) {
+        for (uint256 i = 0; i < bytes(value).length; ++i) {
+            sum += uint8(bytes(value)[i]);
+        }
+    }
+
+    function nestedArrayLength(uint256[][] memory values) public pure returns (uint256 sum) {
+        for (uint256 i = 0; i < values[0].length; ++i) {
+            sum += values[0][i];
+        }
+    }
+
+    function mappingValueArrayLength(uint256 key) external view returns (uint256 sum) {
+        for (uint256 i = 0; i < buckets[key].length; ++i) {
+            sum += buckets[key][i];
+        }
+    }
+
+    function structFieldArrayLength() external view returns (uint256 sum) {
+        for (uint256 i = 0; i < bag.values.length; ++i) {
+            sum += bag.values[i];
+        }
+    }
+
+    function compoundCondition(uint256[] memory left, uint256[] memory right)
+        public
+        pure
+        returns (uint256 sum)
+    {
+        for (uint256 i = 0; i < left.length && i < right.length; ++i) {
+            sum += left[i] + right[i];
+        }
+    }
+
+    function functionReturnArrayLength() public pure returns (uint256 sum) {
+        for (uint256 i = 0; i < generatedValues().length; ++i) {
+            sum += i;
+        }
+    }
+
+    function cachedLength(uint256[] memory values) public pure returns (uint256 sum) {
+        uint256 length = values.length;
+        for (uint256 i = 0; i < length; ++i) {
+            sum += values[i];
+        }
+    }
+
+    function fixedArrayLength() external view returns (uint256 sum) {
+        for (uint256 i = 0; i < fixedItems.length; ++i) {
+            sum += fixedItems[i];
+        }
+    }
+
+    function nonArrayLengthField() external view returns (uint256 sum) {
+        for (uint256 i = 0; i < counter.length; ++i) {
+            sum += i;
+        }
+    }
+
+    function storageArrayPushLengthMutation() external {
+        for (uint256 i = 0; i < items.length; ++i) {
+            items.push(i);
+        }
+    }
+
+    function storageArrayPopLengthMutation() external {
+        for (uint256 i = 0; i < items.length; ++i) {
+            items.pop();
+        }
+    }
+
+    function storageArrayAliasLengthMutation() external {
+        uint256[] storage aliasedItems = items;
+        for (uint256 i = 0; i < items.length; ++i) {
+            aliasedItems.push(i);
+        }
+    }
+
+    function storageArrayPostExpressionMutation() external {
+        for (uint256 i = 0; i < items.length; items.pop()) {}
+    }
+
+    function storageArrayHelperMutation() external {
+        for (uint256 i = 0; i < items.length; ++i) {
+            mutateItems(i);
+        }
+    }
+
+    function conditionHelperMutation(uint256 cap) external returns (uint256 sum) {
+        for (uint256 i = 0; i < items.length && mutateAndContinue(i, cap); ++i) {
+            sum += i;
+        }
+    }
+
+    function loopVariantNestedArrayLength(uint256[][] memory values)
+        public
+        pure
+        returns (uint256 sum)
+    {
+        for (uint256 i = 0; i < values[i].length; ++i) {
+            sum += i;
+        }
+    }
+
+    function sideEffectingReturnArrayLength() external returns (uint256 sum) {
+        for (uint256 i = 0; i < mutatingValues().length; ++i) {
+            sum += i;
+        }
+    }
+
+    function pureReturnArrayLengthWithLoopVariantArg(uint256[][] memory values)
+        public
+        pure
+        returns (uint256 sum)
+    {
+        for (uint256 i = 0; i < pickValues(values, i).length; ++i) {
+            sum += i;
+        }
+    }
+
+    function viewPointer(function(uint256) external view check) external view {
+        for (uint256 i = 0; i < items.length; ++i) { //~NOTE: array length is read on every loop iteration
+            check(i);
+        }
+    }
+
+    function mutatingPointer(function(uint256) external check) external {
+        for (uint256 i = 0; i < items.length; ++i) {
+            check(i);
+        }
+    }
+
+    function generatedValues() internal pure returns (uint256[] memory values) {
+        values = new uint256[](3);
+    }
+
+    function mutateItems(uint256 value) internal {
+        items.push(value);
+    }
+
+    function mutateAndContinue(uint256 value, uint256 cap) internal returns (bool) {
+        items.push(value);
+        return value < cap;
+    }
+
+    function mutatingValues() internal returns (uint256[] memory values) {
+        items.push(1);
+        values = new uint256[](3);
+    }
+
+    function pickValues(uint256[][] memory values, uint256 index)
+        internal
+        pure
+        returns (uint256[] memory)
+    {
+        return values[index];
+    }
+}
diff --git a/crates/lint/testdata/CacheArrayLength.stderr b/crates/lint/testdata/CacheArrayLength.stderr
new file mode 100644
index 0000000000000..a44a2e1498b5a
--- /dev/null
+++ b/crates/lint/testdata/CacheArrayLength.stderr
@@ -0,0 +1,32 @@
+note[cache-array-length]: array length is read on every loop iteration; cache it outside the loop
+   ╭▸ ROOT/testdata/CacheArrayLength.sol:LL:CC
+   │
+LL │         for (uint256 i = 0; i < items.length; ++i) {
+   │                                 ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/cache-array-length
+
+note[cache-array-length]: array length is read on every loop iteration; cache it outside the loop
+   ╭▸ ROOT/testdata/CacheArrayLength.sol:LL:CC
+   │
+LL │         for (uint256 i = 0; i < items.length && i < cap; ++i) {
+   │                                 ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/cache-array-length
+
+note[cache-array-length]: array length is read on every loop iteration; cache it outside the loop
+   ╭▸ ROOT/testdata/CacheArrayLength.sol:LL:CC
+   │
+LL │         for (uint256 i = 0; i < cap && items.length > i; ++i) {
+   │                                        ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/cache-array-length
+
+note[cache-array-length]: array length is read on every loop iteration; cache it outside the loop
+   ╭▸ ROOT/testdata/CacheArrayLength.sol:LL:CC
+   │
+LL │         for (uint256 i = 0; i < items.length; ++i) {
+   │                                 ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/cache-array-length
+
diff --git a/crates/lint/testdata/CallsLoop.sol b/crates/lint/testdata/CallsLoop.sol
new file mode 100644
index 0000000000000..adf695e2cdedc
--- /dev/null
+++ b/crates/lint/testdata/CallsLoop.sol
@@ -0,0 +1,255 @@
+//@compile-flags: --only-lint calls-loop
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.15;
+
+interface IReceiver {
+    function ping(uint256 value) external returns (bool);
+    function purePing(uint256 value) external pure returns (bool);
+}
+
+interface IFactory {
+    function getReceiver() external view returns (IReceiver);
+}
+
+library LocalLib {
+    struct Box {
+        uint256 value;
+    }
+
+    function ping(Box storage box_, uint256 value) internal {
+        box_.value = value;
+    }
+
+    function transfer(Box storage box_, uint256 value) internal {
+        box_.value = value;
+    }
+
+    function call(Box storage box_, uint256 value) internal {
+        box_.value = value;
+    }
+
+    function delegatecall(Box storage box_, uint256 value) internal {
+        box_.value = value;
+    }
+
+    function staticcall(Box storage box_, uint256 value) internal view returns (uint256) {
+        return box_.value + value;
+    }
+}
+
+library ReceiverLib {
+    function remember(IReceiver self, uint256 value) internal pure returns (uint256) {
+        self;
+        return value;
+    }
+
+    function notify(IReceiver self, uint256 value) public {
+        self.ping(value);
+    }
+}
+
+struct Target {
+    IReceiver receiver;
+}
+
+contract Receiver {
+    event Ping(uint256 value);
+
+    function ping(uint256 value) external returns (bool) {
+        emit Ping(value);
+        return true;
+    }
+
+    function purePing(uint256) external pure returns (bool) {
+        return true;
+    }
+}
+
+contract CallsLoop {
+    using LocalLib for LocalLib.Box;
+    using ReceiverLib for IReceiver;
+
+    address payable[] public recipients;
+    IReceiver[] public receivers;
+    IReceiver public receiver;
+    IFactory public factory;
+    mapping(address => IReceiver) internal receiverByAddress;
+    Target internal target;
+    LocalLib.Box[] internal boxes;
+    address[] internal scratchTargets;
+    mapping(address => address[]) internal targetLists;
+
+    modifier loopedPlaceholder() {
+        for (uint256 i; i < 1; ++i) {
+            _;
+        }
+    }
+
+    function lowLevelCalls(address[] calldata targets) external {
+        for (uint256 i; i < targets.length; ++i) {
+            (bool success,) = targets[i].call("");
+            require(success);
+        }
+    }
+
+    function ethTransfers() external {
+        uint256 i;
+        while (i < recipients.length) {
+            recipients[i].transfer(1 wei);
+            i++;
+        }
+    }
+
+    function highLevelCalls() external {
+        for (uint256 i; i < receivers.length; ++i) {
+            receivers[i].ping(i);
+        }
+    }
+
+    function callsInterfaceCast(address targetAddress) external {
+        for (uint256 i; i < receivers.length; ++i) {
+            IReceiver(targetAddress).ping(i);
+        }
+    }
+
+    function callsReturnedReceiver() external {
+        for (uint256 i; i < receivers.length; ++i) {
+            getReceiver().ping(i);
+        }
+    }
+
+    function callsChainedReturnedReceiver() external {
+        for (uint256 i; i < receivers.length; ++i) {
+            factory.getReceiver().ping(i);
+        }
+    }
+
+    function callsMappedReceiver(address targetAddress) external {
+        for (uint256 i; i < receivers.length; ++i) {
+            receiverByAddress[targetAddress].ping(i);
+        }
+    }
+
+    function callsStructFieldReceiver() external {
+        for (uint256 i; i < receivers.length; ++i) {
+            target.receiver.ping(i);
+        }
+    }
+
+    function tryCalls() external {
+        for (uint256 i; i < receivers.length; ++i) {
+            try receivers[i].ping(i) returns (bool) {} catch {}
+        }
+    }
+
+    function callsThroughInternalHelper() external {
+        for (uint256 i; i < receivers.length; ++i) {
+            _notify(i);
+        }
+    }
+
+    function callsThroughPureInternalHelper(IReceiver target_) external {
+        for (uint256 i; i < receivers.length; ++i) {
+            _pureNotify(target_, i);
+        }
+    }
+
+    function callsThroughPublicHelper() external {
+        for (uint256 i; i < receivers.length; ++i) {
+            publicNotify(i);
+        }
+    }
+
+    function selfExternalCall() external {
+        for (uint256 i; i < receivers.length; ++i) {
+            this.externalOnly(i);
+        }
+    }
+
+    function callsThroughLoopedModifier() external loopedPlaceholder {
+        receiver.ping(0);
+    }
+
+    function noLoopCall() external {
+        receiver.ping(0);
+    }
+
+    function externalFunctionPointer(function(uint256) external returns (bool) callback) external {
+        for (uint256 i; i < 1; ++i) {
+            callback(i);
+        }
+    }
+
+    function externalViewFunctionPointer(function(uint256) external view returns (bool) callback) external view {
+        for (uint256 i; i < 1; ++i) {
+            callback(i);
+        }
+    }
+
+    function publicLibraryCalls() external {
+        for (uint256 i; i < 1; ++i) {
+            receiver.notify(i);
+            ReceiverLib.notify(receiver, i);
+        }
+    }
+
+    function internalLibraryCallsAreIgnored() external {
+        for (uint256 i; i < boxes.length; ++i) {
+            boxes[i].ping(i);
+            boxes[i].transfer(i);
+            // forge-lint: disable-next-line(unchecked-call)
+            boxes[i].call(i);
+            // forge-lint: disable-next-line(unchecked-call)
+            boxes[i].delegatecall(i);
+            // forge-lint: disable-next-line(unchecked-call)
+            boxes[i].staticcall(i);
+        }
+    }
+
+    function internalLibraryExtensionOnInterfaceIsIgnored() external {
+        for (uint256 i; i < receivers.length; ++i) {
+            receiver.remember(i);
+        }
+    }
+
+    function localInternalCallsAreIgnored() external {
+        for (uint256 i; i < boxes.length; ++i) {
+            _local(i);
+        }
+    }
+
+    function arrayBuiltinsAreIgnored(address targetAddress) external {
+        for (uint256 i; i < receivers.length; ++i) {
+            scratchTargets.push(targetAddress);
+        }
+    }
+
+    function mappingArrayBuiltinsAreIgnored(address targetKey, address targetAddress) external {
+        for (uint256 i; i < receivers.length; ++i) {
+            targetLists[targetKey].push(targetAddress);
+        }
+    }
+
+    function externalOnly(uint256) external {}
+
+    function getReceiver() internal view returns (IReceiver) {
+        return receiver;
+    }
+
+    function _notify(uint256 value) internal {
+        receiver.ping(value);
+    }
+
+    function _pureNotify(IReceiver target_, uint256 value) internal pure {
+        target_.purePing(value);
+    }
+
+    function publicNotify(uint256 value) public {
+        receiver.ping(value);
+    }
+
+    function _local(uint256 value) internal {
+        boxes[0].value = value;
+    }
+}
diff --git a/crates/lint/testdata/CallsLoop.stderr b/crates/lint/testdata/CallsLoop.stderr
new file mode 100644
index 0000000000000..b6047198cb083
--- /dev/null
+++ b/crates/lint/testdata/CallsLoop.stderr
@@ -0,0 +1,160 @@
+warning[calls-loop]: external call inside a loop
+   ╭▸ ROOT/testdata/CallsLoop.sol:LL:CC
+   │
+LL │             (bool success,) = targets[i].call("");
+   │                               ━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/calls-loop
+
+warning[calls-loop]: external call inside a loop
+   ╭▸ ROOT/testdata/CallsLoop.sol:LL:CC
+   │
+LL │             recipients[i].transfer(1 wei);
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/calls-loop
+
+warning[calls-loop]: external call inside a loop
+   ╭▸ ROOT/testdata/CallsLoop.sol:LL:CC
+   │
+LL │             receivers[i].ping(i);
+   │             ━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/calls-loop
+
+warning[calls-loop]: external call inside a loop
+   ╭▸ ROOT/testdata/CallsLoop.sol:LL:CC
+   │
+LL │             IReceiver(targetAddress).ping(i);
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/calls-loop
+
+warning[calls-loop]: external call inside a loop
+   ╭▸ ROOT/testdata/CallsLoop.sol:LL:CC
+   │
+LL │             getReceiver().ping(i);
+   │             ━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/calls-loop
+
+warning[calls-loop]: external call inside a loop
+   ╭▸ ROOT/testdata/CallsLoop.sol:LL:CC
+   │
+LL │             factory.getReceiver().ping(i);
+   │             ━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/calls-loop
+
+warning[calls-loop]: external call inside a loop
+   ╭▸ ROOT/testdata/CallsLoop.sol:LL:CC
+   │
+LL │             factory.getReceiver().ping(i);
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/calls-loop
+
+warning[calls-loop]: external call inside a loop
+   ╭▸ ROOT/testdata/CallsLoop.sol:LL:CC
+   │
+LL │             receiverByAddress[targetAddress].ping(i);
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/calls-loop
+
+warning[calls-loop]: external call inside a loop
+   ╭▸ ROOT/testdata/CallsLoop.sol:LL:CC
+   │
+LL │             target.receiver.ping(i);
+   │             ━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/calls-loop
+
+warning[calls-loop]: external call inside a loop
+   ╭▸ ROOT/testdata/CallsLoop.sol:LL:CC
+   │
+LL │             try receivers[i].ping(i) returns (bool) {} catch {}
+   │                 ━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/calls-loop
+
+warning[calls-loop]: external call inside a loop
+   ╭▸ ROOT/testdata/CallsLoop.sol:LL:CC
+   │
+LL │         receiver.ping(value);
+   │         ━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/calls-loop
+
+warning[calls-loop]: external call inside a loop
+   ╭▸ ROOT/testdata/CallsLoop.sol:LL:CC
+   │
+LL │         target_.purePing(value);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/calls-loop
+
+warning[calls-loop]: external call inside a loop
+   ╭▸ ROOT/testdata/CallsLoop.sol:LL:CC
+   │
+LL │         receiver.ping(value);
+   │         ━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/calls-loop
+
+warning[calls-loop]: external call inside a loop
+   ╭▸ ROOT/testdata/CallsLoop.sol:LL:CC
+   │
+LL │             this.externalOnly(i);
+   │             ━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/calls-loop
+
+warning[calls-loop]: external call inside a loop
+   ╭▸ ROOT/testdata/CallsLoop.sol:LL:CC
+   │
+LL │         receiver.ping(0);
+   │         ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/calls-loop
+
+warning[calls-loop]: external call inside a loop
+   ╭▸ ROOT/testdata/CallsLoop.sol:LL:CC
+   │
+LL │             callback(i);
+   │             ━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/calls-loop
+
+warning[calls-loop]: external call inside a loop
+   ╭▸ ROOT/testdata/CallsLoop.sol:LL:CC
+   │
+LL │             callback(i);
+   │             ━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/calls-loop
+
+warning[calls-loop]: external call inside a loop
+   ╭▸ ROOT/testdata/CallsLoop.sol:LL:CC
+   │
+LL │             receiver.notify(i);
+   │             ━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/calls-loop
+
+warning[calls-loop]: external call inside a loop
+   ╭▸ ROOT/testdata/CallsLoop.sol:LL:CC
+   │
+LL │         self.ping(value);
+   │         ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/calls-loop
+
+warning[calls-loop]: external call inside a loop
+   ╭▸ ROOT/testdata/CallsLoop.sol:LL:CC
+   │
+LL │             ReceiverLib.notify(receiver, i);
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/calls-loop
+
diff --git a/crates/lint/testdata/CallsLoopAllocations.sol b/crates/lint/testdata/CallsLoopAllocations.sol
new file mode 100644
index 0000000000000..06ac9d5d0d4cc
--- /dev/null
+++ b/crates/lint/testdata/CallsLoopAllocations.sol
@@ -0,0 +1,87 @@
+//@compile-flags: --only-lint calls-loop reentrancy-events
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.0;
+
+struct LedgerRow {
+    address wallet;
+    uint256 amount;
+}
+
+interface LengthSource {
+    function nextLength() external returns (uint256);
+    function length() external view returns (uint256);
+}
+
+contract Child {}
+
+contract CallsLoopAllocations {
+    event Allocated();
+    address[] wallets;
+
+    // Memory allocation does not interact with another contract, even for contract arrays.
+    function allocateInLoop(uint256 n) external returns (LedgerRow[] memory rows) {
+        for (uint256 i = 0; i < n; ++i) {
+            rows = new LedgerRow[](wallets.length);
+            uint256[] memory numbers = new uint256[](n);
+            bytes memory data = new bytes(n);
+            string memory text = new string(n);
+            Child[] memory children = new Child[](n);
+            LedgerRow[][] memory nested = new LedgerRow[][](n);
+            emit Allocated();
+        }
+    }
+
+    // The loop walker also follows allocations inside local helpers.
+    function allocateThroughHelper(uint256 n) external returns (LedgerRow[] memory rows) {
+        for (uint256 i = 0; i < n; ++i) {
+            rows = allocate(n);
+            emit Allocated();
+        }
+    }
+
+    function allocate(uint256 n) internal pure returns (LedgerRow[] memory) {
+        return new LedgerRow[](n);
+    }
+
+    // The shared reentrancy classifier must not treat a standalone allocation as an interaction.
+    function allocateThenEmit(uint256 n) external returns (bytes memory data) {
+        data = new bytes(n);
+        emit Allocated();
+    }
+
+    // Only the length calculation is an external call, not the enclosing allocation.
+    function externalLength(LengthSource source) external {
+        for (uint256 i = 0; i < 2; ++i) {
+            uint256[] memory numbers = new uint256[](source.nextLength()); //~WARN: external call inside a loop
+            emit Allocated(); //~WARN: event emitted after an external call
+        }
+    }
+
+    // A view call is still external, but cannot affect log ordering.
+    function viewLength(LengthSource source) external {
+        for (uint256 i = 0; i < 2; ++i) {
+            uint256[] memory numbers = new uint256[](source.length()); //~WARN: external call inside a loop
+            emit Allocated();
+        }
+    }
+
+    // An allocation must not erase an earlier real interaction.
+    function callThenAllocate(LengthSource source) external {
+        uint256 n = source.nextLength();
+        bytes memory data = new bytes(n);
+        emit Allocated(); //~WARN: event emitted after an external call
+    }
+
+    // Actual contract creation remains an external interaction, with or without a salt.
+    function createContracts() external {
+        for (uint256 i = 0; i < 2; ++i) {
+            new Child(); //~WARN: external call inside a loop
+            emit Allocated(); //~WARN: event emitted after an external call
+        }
+        for (uint256 i = 0; i < 2; ++i) {
+            new Child{salt: bytes32(i)}(); //~WARN: external call inside a loop
+            emit Allocated(); //~WARN: event emitted after an external call
+        }
+    }
+}
diff --git a/crates/lint/testdata/CallsLoopAllocations.stderr b/crates/lint/testdata/CallsLoopAllocations.stderr
new file mode 100644
index 0000000000000..034456e28c1c7
--- /dev/null
+++ b/crates/lint/testdata/CallsLoopAllocations.stderr
@@ -0,0 +1,64 @@
+warning[calls-loop]: external call inside a loop
+   ╭▸ ROOT/testdata/CallsLoopAllocations.sol:LL:CC
+   │
+LL │             uint256[] memory numbers = new uint256[](source.nextLength());
+   │                                                      ━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/calls-loop
+
+warning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+   ╭▸ ROOT/testdata/CallsLoopAllocations.sol:LL:CC
+   │
+LL │             emit Allocated();
+   │             ━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events
+
+warning[calls-loop]: external call inside a loop
+   ╭▸ ROOT/testdata/CallsLoopAllocations.sol:LL:CC
+   │
+LL │             uint256[] memory numbers = new uint256[](source.length());
+   │                                                      ━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/calls-loop
+
+warning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+   ╭▸ ROOT/testdata/CallsLoopAllocations.sol:LL:CC
+   │
+LL │         emit Allocated();
+   │         ━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events
+
+warning[calls-loop]: external call inside a loop
+   ╭▸ ROOT/testdata/CallsLoopAllocations.sol:LL:CC
+   │
+LL │             new Child();
+   │             ━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/calls-loop
+
+warning[calls-loop]: external call inside a loop
+   ╭▸ ROOT/testdata/CallsLoopAllocations.sol:LL:CC
+   │
+LL │             new Child{salt: bytes32(i)}();
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/calls-loop
+
+warning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+   ╭▸ ROOT/testdata/CallsLoopAllocations.sol:LL:CC
+   │
+LL │             emit Allocated();
+   │             ━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events
+
+warning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+   ╭▸ ROOT/testdata/CallsLoopAllocations.sol:LL:CC
+   │
+LL │             emit Allocated();
+   │             ━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events
+
diff --git a/crates/lint/testdata/CheatcodeEnvironment.sol b/crates/lint/testdata/CheatcodeEnvironment.sol
new file mode 100644
index 0000000000000..58487fb867f7e
--- /dev/null
+++ b/crates/lint/testdata/CheatcodeEnvironment.sol
@@ -0,0 +1,363 @@
+//@compile-flags: --only-lint environment-read-across-mutation
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+interface Clock {
+    function roll(uint256 height) external;
+    function warp(uint256 time) external;
+    function getBlockNumber() external view returns (uint256);
+    function getBlockTimestamp() external view returns (uint256);
+}
+
+interface WrongSignature {
+    function roll(bytes32 height) external;
+    function warp(uint64 time) external;
+}
+
+contract ClockHelpers {
+    Clock internal constant clock = Clock(address(uint160(uint256(keccak256("hevm cheat code")))));
+
+    function advance(uint256 height) internal { clock.roll(height); }
+    function skip(uint256 delta) internal { clock.warp(clock.getBlockTimestamp() + delta); }
+    function rewind(uint256 delta) internal { clock.warp(clock.getBlockTimestamp() - delta); }
+    function identity(uint256 value) internal pure returns (uint256) { return value; }
+    function advanceReceiver(Clock receiver) internal { receiver.roll(200); }
+
+    modifier warpFirst() { clock.warp(200); _; }
+    modifier warpLast() { _; clock.warp(200); }
+    modifier optionalWarp() virtual { clock.warp(200); _; }
+    function virtualModifier() internal optionalWarp {}
+
+    function inheritedRead() internal view returns (uint256) {
+        return block.number; //~WARN: `block.number` may be reused across `vm.roll`
+    }
+}
+
+contract CheatcodeEnvironment is ClockHelpers {
+    modifier optionalWarp() override { _; }
+
+    function explicitModifier() internal ClockHelpers.optionalWarp {}
+    function restoreNumber() public {
+        clock.roll(100);
+        uint256 saved = block.number; //~WARN: `block.number` may be reused across `vm.roll`
+        clock.roll(200);
+        clock.roll(saved);
+    }
+
+    function restoreTimestamp() public {
+        clock.warp(100);
+        uint256 saved = block.timestamp; //~WARN: `block.timestamp` may be reused across `vm.warp`
+        clock.warp(200);
+        clock.warp(saved);
+    }
+
+    function aliasesAndArithmetic() public returns (uint256) {
+        uint256 saved = block.number; //~WARN: `block.number` may be reused across `vm.roll`
+        uint256 derived = identity(saved + 10);
+        Clock alias_ = clock;
+        alias_.roll(200);
+        return derived;
+    }
+
+    function readsOnBothSides() public returns (uint256, uint256) {
+        uint256 before_ = block.timestamp; //~WARN: `block.timestamp` may be reused across `vm.warp`
+        skip(20);
+        return (before_, block.timestamp);
+    }
+
+    function inheritedHelper() public returns (uint256) {
+        uint256 saved = inheritedRead();
+        advance(200);
+        return saved;
+    }
+
+    function receiverParameter() public returns (uint256) {
+        uint256 saved = block.number; //~WARN: `block.number` may be reused across `vm.roll`
+        advanceReceiver(clock);
+        return saved;
+    }
+
+    function namedArgument() public returns (uint256) {
+        uint256 saved = block.number; //~WARN: `block.number` may be reused across `vm.roll`
+        advanceReceiver({receiver: clock});
+        return saved;
+    }
+
+    function rewindHelper() public returns (uint256) {
+        uint256 saved = block.timestamp; //~WARN: `block.timestamp` may be reused across `vm.warp`
+        rewind(1);
+        return saved;
+    }
+
+    function mutateInModifier() internal warpFirst {}
+
+    function modifierCall() public returns (uint256) {
+        uint256 saved = block.timestamp; //~WARN: `block.timestamp` may be reused across `vm.warp`
+        mutateInModifier();
+        return saved;
+    }
+
+    function modifierSuffix() public warpLast returns (uint256) {
+        return block.timestamp; //~WARN: `block.timestamp` may be reused across `vm.warp`
+    }
+
+    function tupleAlias() public returns (uint256) {
+        (uint256 saved, uint256 other) = (block.number, 7); //~WARN: `block.number` may be reused across `vm.roll`
+        (other, saved) = (saved, other);
+        clock.roll(200);
+        return other;
+    }
+
+    function reachableBranch(bool choose) public returns (uint256) {
+        uint256 saved = block.number; //~WARN: `block.number` may be reused across `vm.roll`
+        if (choose) clock.roll(200);
+        return saved;
+    }
+
+    function loopCarried(bool choose) public {
+        uint256 saved = block.timestamp; //~WARN: `block.timestamp` may be reused across `vm.warp`
+        while (choose) {
+            clock.warp(saved + 1);
+        }
+    }
+
+    // Safe materialization, unrelated calls, overwrites, and unreachable paths stay quiet.
+
+    function getters() public returns (uint256, uint256) {
+        uint256 height = clock.getBlockNumber();
+        uint256 time = clock.getBlockTimestamp();
+        clock.roll(200);
+        clock.warp(200);
+        return (height, time);
+    }
+
+    function firstReadsAfterMutation() public returns (uint256, uint256) {
+        clock.roll(200);
+        clock.warp(200);
+        return (block.number, block.timestamp);
+    }
+
+    function separateEnvironments() public returns (uint256) {
+        uint256 saved = block.number;
+        clock.warp(200);
+        return saved;
+    }
+
+    function separateEnvironmentsTime() public returns (uint256) {
+        uint256 saved = block.timestamp;
+        clock.roll(200);
+        return saved;
+    }
+
+    function ordinaryReceiver(Clock vm) public returns (uint256, uint256) {
+        uint256 height = block.number;
+        uint256 time = block.timestamp;
+        vm.roll(200);
+        vm.warp(200);
+        return (height, time);
+    }
+
+    function unrelatedSignature() public returns (uint256, uint256) {
+        uint256 height = block.number;
+        uint256 time = block.timestamp;
+        WrongSignature vm = WrongSignature(address(clock));
+        vm.roll(bytes32(uint256(200)));
+        vm.warp(uint64(200));
+        return (height, time);
+    }
+
+    function overwritten() public returns (uint256) {
+        uint256 saved = block.number;
+        clock.roll(200);
+        saved = 123;
+        return saved;
+    }
+
+    function tupleOverwrite() public returns (uint256) {
+        (uint256 saved, uint256 safe) = (block.number, 7);
+        clock.roll(200);
+        (saved, safe) = (safe, 12);
+        return saved;
+    }
+
+    function oppositeBranches(bool choose) public returns (uint256) {
+        uint256 saved;
+        if (choose) saved = block.number;
+        else clock.roll(200);
+        return saved;
+    }
+
+    function returningBranch(bool choose) public returns (uint256) {
+        uint256 saved = block.number;
+        if (choose) {
+            clock.roll(200);
+            return 0;
+        }
+        return saved;
+    }
+
+    function revertingBranch(bool choose) public returns (uint256) {
+        uint256 saved = block.timestamp;
+        if (choose) {
+            clock.warp(200);
+            revert();
+        }
+        return saved;
+    }
+
+    function unreachableMutation() public returns (uint256) {
+        uint256 saved = block.number;
+        if (false) clock.roll(200);
+        return saved;
+    }
+
+    function receiverOverwritten(Clock other) public returns (uint256) {
+        uint256 saved = block.number;
+        Clock alias_ = clock;
+        alias_ = other;
+        alias_.roll(200);
+        return saved;
+    }
+
+    function externalRead() external view returns (uint256) { return block.number; }
+
+    function separateFrame() public returns (uint256) {
+        uint256 saved = this.externalRead();
+        clock.roll(200);
+        return saved;
+    }
+
+    function beforeOnly() public {
+        clock.roll(block.number + 1);
+    }
+
+    function unusedCapture() public {
+        uint256 saved = block.timestamp;
+        clock.warp(200);
+    }
+
+    function namedReturn() public returns (uint256 saved) {
+        saved = block.timestamp; //~WARN: `block.timestamp` may be reused across `vm.warp`
+        clock.warp(200);
+        return;
+    }
+
+    function implicitReturn() public returns (uint256 saved) {
+        saved = block.number; //~WARN: `block.number` may be reused across `vm.roll`
+        clock.roll(200);
+    }
+
+    function helperTuple() internal view returns (uint256, uint256) {
+        return (block.number, 7); //~WARN: `block.number` may be reused across `vm.roll`
+    }
+
+    function returnedTuple() public returns (uint256) {
+        (uint256 saved, uint256 other) = helperTuple();
+        clock.roll(200);
+        return saved + other;
+    }
+
+    function noLoopIterations() public returns (uint256) {
+        uint256 saved = block.number;
+        while (false) clock.roll(200);
+        return saved;
+    }
+
+    function breakingLoop() public returns (uint256) {
+        uint256 saved = block.number;
+        while (true) {
+            break;
+            clock.roll(200);
+        }
+        return saved;
+    }
+
+    function continuingLoop(bool choose) public returns (uint256) {
+        uint256 saved = block.number;
+        while (choose) {
+            continue;
+            clock.roll(200);
+        }
+        return saved;
+    }
+
+    function receiverTruncated() public returns (uint256) {
+        uint256 saved = block.number;
+        uint256 address_ = uint256(uint160(address(clock)));
+        Clock(address(uint160(uint8(address_)))).roll(200);
+        return saved;
+    }
+
+    function receiverIncremented() public returns (uint256) {
+        uint256 saved = block.number;
+        uint256 address_ = uint256(uint160(address(clock)));
+        address_++;
+        Clock(address(uint160(address_))).roll(200);
+        return saved;
+    }
+
+    function deletedCapture() public returns (uint256) {
+        uint256 saved = block.number;
+        clock.roll(200);
+        delete saved;
+        return saved;
+    }
+
+    function failingRoll() public returns (uint256) {
+        uint256 saved = block.number;
+        try clock.roll(200) {
+            return 0;
+        } catch {
+            return saved;
+        }
+    }
+
+    function successfulWarp() public returns (uint256) {
+        uint256 saved = block.timestamp; //~WARN: `block.timestamp` may be reused across `vm.warp`
+        try clock.warp(200) {
+            return saved;
+        } catch {
+            return 0;
+        }
+    }
+
+    function compoundOverwrite() public returns (uint256) {
+        uint256 saved = block.number; //~WARN: `block.number` may be reused across `vm.roll`
+        saved += 1;
+        clock.roll(200);
+        return saved;
+    }
+
+    function assemblyOverwrite() public returns (uint256) {
+        uint256 saved = block.number;
+        clock.roll(200);
+        assembly { saved := 7 }
+        return saved;
+    }
+
+    function qualifiedModifier() public returns (uint256) {
+        uint256 saved = block.timestamp; //~WARN: `block.timestamp` may be reused across `vm.warp`
+        explicitModifier();
+        return saved;
+    }
+
+    function overriddenModifier() public returns (uint256) {
+        uint256 saved = block.timestamp;
+        virtualModifier();
+        return saved;
+    }
+
+    function suppressedTime() public returns (uint256) {
+        // forge-lint: disable-next-line(environment-read-across-mutation)
+        uint256 saved = block.timestamp;
+        clock.warp(200);
+        return saved;
+    }
+
+    function suppressed() public returns (uint256) {
+        // forge-lint: disable-next-line(environment-read-across-mutation)
+        uint256 saved = block.number;
+        clock.roll(200);
+        return saved;
+    }
+}
diff --git a/crates/lint/testdata/CheatcodeEnvironment.stderr b/crates/lint/testdata/CheatcodeEnvironment.stderr
new file mode 100644
index 0000000000000..2333b996f1e90
--- /dev/null
+++ b/crates/lint/testdata/CheatcodeEnvironment.stderr
@@ -0,0 +1,222 @@
+warning[environment-read-across-mutation]: `block.number` may be reused across `vm.roll`
+   ╭▸ ROOT/testdata/CheatcodeEnvironment.sol:LL:CC
+   │
+LL │         uint256 saved = block.number;
+   │                         ━━━━━━━━━━━━
+LL │         clock.roll(200);
+   │         ─────────────── `vm.roll` changes this environment here
+   │
+   ├ help: capture it with `vm.getBlockNumber()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.timestamp` may be reused across `vm.warp`
+   ╭▸ ROOT/testdata/CheatcodeEnvironment.sol:LL:CC
+   │
+LL │         uint256 saved = block.timestamp;
+   │                         ━━━━━━━━━━━━━━━
+LL │         clock.warp(200);
+   │         ─────────────── `vm.warp` changes this environment here
+   │
+   ├ help: capture it with `vm.getBlockTimestamp()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.number` may be reused across `vm.roll`
+   ╭▸ ROOT/testdata/CheatcodeEnvironment.sol:LL:CC
+   │
+LL │         uint256 saved = block.number;
+   │                         ━━━━━━━━━━━━
+   ‡
+LL │         alias_.roll(200);
+   │         ──────────────── `vm.roll` changes this environment here
+   │
+   ├ help: capture it with `vm.getBlockNumber()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.timestamp` may be reused across `vm.warp`
+   ╭▸ ROOT/testdata/CheatcodeEnvironment.sol:LL:CC
+   │
+LL │     function skip(uint256 delta) internal { clock.warp(clock.getBlockTimestamp() + delta); }
+   │                                             ───────────────────────────────────────────── `vm.warp` changes this environment here
+   ‡
+LL │         uint256 before_ = block.timestamp;
+   │                           ━━━━━━━━━━━━━━━
+   │
+   ├ help: capture it with `vm.getBlockTimestamp()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.number` may be reused across `vm.roll`
+   ╭▸ ROOT/testdata/CheatcodeEnvironment.sol:LL:CC
+   │
+LL │     function advance(uint256 height) internal { clock.roll(height); }
+   │                                                 ────────────────── `vm.roll` changes this environment here
+   ‡
+LL │         return block.number;
+   │                ━━━━━━━━━━━━
+   │
+   ├ help: capture it with `vm.getBlockNumber()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.number` may be reused across `vm.roll`
+   ╭▸ ROOT/testdata/CheatcodeEnvironment.sol:LL:CC
+   │
+LL │     function advanceReceiver(Clock receiver) internal { receiver.roll(200); }
+   │                                                         ────────────────── `vm.roll` changes this environment here
+   ‡
+LL │         uint256 saved = block.number;
+   │                         ━━━━━━━━━━━━
+   │
+   ├ help: capture it with `vm.getBlockNumber()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.number` may be reused across `vm.roll`
+   ╭▸ ROOT/testdata/CheatcodeEnvironment.sol:LL:CC
+   │
+LL │     function advanceReceiver(Clock receiver) internal { receiver.roll(200); }
+   │                                                         ────────────────── `vm.roll` changes this environment here
+   ‡
+LL │         uint256 saved = block.number;
+   │                         ━━━━━━━━━━━━
+   │
+   ├ help: capture it with `vm.getBlockNumber()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.timestamp` may be reused across `vm.warp`
+   ╭▸ ROOT/testdata/CheatcodeEnvironment.sol:LL:CC
+   │
+LL │     function rewind(uint256 delta) internal { clock.warp(clock.getBlockTimestamp() - delta); }
+   │                                               ───────────────────────────────────────────── `vm.warp` changes this environment here
+   ‡
+LL │         uint256 saved = block.timestamp;
+   │                         ━━━━━━━━━━━━━━━
+   │
+   ├ help: capture it with `vm.getBlockTimestamp()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.timestamp` may be reused across `vm.warp`
+   ╭▸ ROOT/testdata/CheatcodeEnvironment.sol:LL:CC
+   │
+LL │     modifier warpFirst() { clock.warp(200); _; }
+   │                            ─────────────── `vm.warp` changes this environment here
+   ‡
+LL │         uint256 saved = block.timestamp;
+   │                         ━━━━━━━━━━━━━━━
+   │
+   ├ help: capture it with `vm.getBlockTimestamp()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.timestamp` may be reused across `vm.warp`
+   ╭▸ ROOT/testdata/CheatcodeEnvironment.sol:LL:CC
+   │
+LL │     modifier warpLast() { _; clock.warp(200); }
+   │                              ─────────────── `vm.warp` changes this environment here
+   ‡
+LL │         return block.timestamp;
+   │                ━━━━━━━━━━━━━━━
+   │
+   ├ help: capture it with `vm.getBlockTimestamp()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.number` may be reused across `vm.roll`
+   ╭▸ ROOT/testdata/CheatcodeEnvironment.sol:LL:CC
+   │
+LL │         (uint256 saved, uint256 other) = (block.number, 7);
+   │                                           ━━━━━━━━━━━━
+LL │         (other, saved) = (saved, other);
+LL │         clock.roll(200);
+   │         ─────────────── `vm.roll` changes this environment here
+   │
+   ├ help: capture it with `vm.getBlockNumber()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.number` may be reused across `vm.roll`
+   ╭▸ ROOT/testdata/CheatcodeEnvironment.sol:LL:CC
+   │
+LL │         uint256 saved = block.number;
+   │                         ━━━━━━━━━━━━
+LL │         if (choose) clock.roll(200);
+   │                     ─────────────── `vm.roll` changes this environment here
+   │
+   ├ help: capture it with `vm.getBlockNumber()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.timestamp` may be reused across `vm.warp`
+   ╭▸ ROOT/testdata/CheatcodeEnvironment.sol:LL:CC
+   │
+LL │         uint256 saved = block.timestamp;
+   │                         ━━━━━━━━━━━━━━━
+LL │         while (choose) {
+LL │             clock.warp(saved + 1);
+   │             ───────────────────── `vm.warp` changes this environment here
+   │
+   ├ help: capture it with `vm.getBlockTimestamp()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.timestamp` may be reused across `vm.warp`
+   ╭▸ ROOT/testdata/CheatcodeEnvironment.sol:LL:CC
+   │
+LL │         saved = block.timestamp;
+   │                 ━━━━━━━━━━━━━━━
+LL │         clock.warp(200);
+   │         ─────────────── `vm.warp` changes this environment here
+   │
+   ├ help: capture it with `vm.getBlockTimestamp()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.number` may be reused across `vm.roll`
+   ╭▸ ROOT/testdata/CheatcodeEnvironment.sol:LL:CC
+   │
+LL │         saved = block.number;
+   │                 ━━━━━━━━━━━━
+LL │         clock.roll(200);
+   │         ─────────────── `vm.roll` changes this environment here
+   │
+   ├ help: capture it with `vm.getBlockNumber()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.number` may be reused across `vm.roll`
+   ╭▸ ROOT/testdata/CheatcodeEnvironment.sol:LL:CC
+   │
+LL │         return (block.number, 7);
+   │                 ━━━━━━━━━━━━
+   ‡
+LL │         clock.roll(200);
+   │         ─────────────── `vm.roll` changes this environment here
+   │
+   ├ help: capture it with `vm.getBlockNumber()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.timestamp` may be reused across `vm.warp`
+   ╭▸ ROOT/testdata/CheatcodeEnvironment.sol:LL:CC
+   │
+LL │         uint256 saved = block.timestamp;
+   │                         ━━━━━━━━━━━━━━━
+LL │         try clock.warp(200) {
+   │             ─────────────── `vm.warp` changes this environment here
+   │
+   ├ help: capture it with `vm.getBlockTimestamp()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.number` may be reused across `vm.roll`
+   ╭▸ ROOT/testdata/CheatcodeEnvironment.sol:LL:CC
+   │
+LL │         uint256 saved = block.number;
+   │                         ━━━━━━━━━━━━
+LL │         saved += 1;
+LL │         clock.roll(200);
+   │         ─────────────── `vm.roll` changes this environment here
+   │
+   ├ help: capture it with `vm.getBlockNumber()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.timestamp` may be reused across `vm.warp`
+   ╭▸ ROOT/testdata/CheatcodeEnvironment.sol:LL:CC
+   │
+LL │     modifier optionalWarp() virtual { clock.warp(200); _; }
+   │                                       ─────────────── `vm.warp` changes this environment here
+   ‡
+LL │         uint256 saved = block.timestamp;
+   │                         ━━━━━━━━━━━━━━━
+   │
+   ├ help: capture it with `vm.getBlockTimestamp()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
diff --git a/crates/lint/testdata/CheatcodeEnvironmentControlFlow.sol b/crates/lint/testdata/CheatcodeEnvironmentControlFlow.sol
new file mode 100644
index 0000000000000..299180733ba6d
--- /dev/null
+++ b/crates/lint/testdata/CheatcodeEnvironmentControlFlow.sol
@@ -0,0 +1,195 @@
+//@compile-flags: --only-lint environment-read-across-mutation
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+interface FlowClock {
+    function roll(uint256 height) external;
+    function warp(uint256 time) external;
+}
+
+library EnvironmentLibrary {
+    function externalNumber() external view returns (uint256) { return block.number; }
+    function externalTimestamp() external view returns (uint256) { return block.timestamp; }
+    function publicNumber() public view returns (uint256) { return block.number; }
+
+    function internalNumber() internal view returns (uint256) {
+        return block.number; //~WARN: `block.number` may be reused across `vm.roll`
+    }
+
+    function internalTimestamp() internal view returns (uint256) {
+        return block.timestamp; //~WARN: `block.timestamp` may be reused across `vm.warp`
+    }
+}
+
+contract CheatcodeEnvironmentControlFlow {
+    FlowClock constant vm = FlowClock(address(uint160(uint256(keccak256("hevm cheat code")))));
+
+    function externalLibraryFrame() public returns (uint256, uint256, uint256) {
+        uint256 number = EnvironmentLibrary.externalNumber();
+        uint256 time = EnvironmentLibrary.externalTimestamp();
+        uint256 publicNumber = EnvironmentLibrary.publicNumber();
+        vm.roll(200);
+        vm.warp(200);
+        return (number, time, publicNumber);
+    }
+
+    function internalLibraryFrame() public returns (uint256, uint256) {
+        uint256 number = EnvironmentLibrary.internalNumber();
+        uint256 time = EnvironmentLibrary.internalTimestamp();
+        vm.roll(200);
+        vm.warp(200);
+        return (number, time);
+    }
+
+    function oneIteration() public {
+        for (uint256 i; i < 1; ++i) { vm.roll(block.number + 1); }
+        for (uint256 i = 0; i != 1; i++) { vm.warp(block.timestamp + 1); }
+    }
+
+    function noIterations() public returns (uint256) {
+        uint256 saved = block.number;
+        for (uint256 i = 1; i < 1; ++i) { vm.roll(200); }
+        return saved;
+    }
+
+    function countdown() public {
+        for (uint256 i = 1; i > 0; --i) { vm.roll(block.number + 1); }
+        for (uint256 i = 1; i >= 1; i--) { vm.warp(block.timestamp + 1); }
+    }
+
+    function compoundUpdate() public {
+        for (uint256 i; i <= 0; i += 1) { vm.roll(block.number + 1); }
+        for (uint256 i = 1; i != 0; i -= 1) { vm.warp(block.timestamp + 1); }
+    }
+
+    function whileUpdate() public {
+        uint256 i;
+        while (i < 1) { vm.roll(block.number + 1); ++i; }
+        bool done;
+        while (!done) { vm.warp(block.timestamp + 1); done = true; }
+    }
+
+    function postIncrementCondition() public {
+        uint256 i;
+        while (i++ < 1) { vm.roll(block.number + 1); }
+    }
+
+    function twoIterations() public {
+        for (uint256 i; i < 2; ++i) {
+            vm.roll(block.number + 1); //~WARN: `block.number` may be reused across `vm.roll`
+        }
+        for (uint256 i; i < 2; i++) {
+            vm.warp(block.timestamp + 1); //~WARN: `block.timestamp` may be reused across `vm.warp`
+        }
+    }
+
+    function afterOneIteration() public returns (uint256) {
+        uint256 saved = block.number; //~WARN: `block.number` may be reused across `vm.roll`
+        for (uint256 i; i < 1; ++i) { vm.roll(200); }
+        return saved;
+    }
+
+    function unknownBound(uint256 count) public {
+        for (uint256 i; i < count; ++i) {
+            vm.roll(block.number + 1); //~WARN: `block.number` may be reused across `vm.roll`
+        }
+    }
+
+    function wrappedCounter() public {
+        unchecked {
+            for (uint8 i = 255; i > 0; ++i) { vm.roll(block.number + 1); }
+            for (uint8 i; i == 0; --i) { vm.warp(block.timestamp + 1); }
+        }
+    }
+
+    function checkedOverflow() public {
+        for (uint8 i = 255; i > 0; ++i) { vm.roll(block.number + 1); }
+    }
+
+    function checkedIncrement(uint8 i) internal pure returns (uint8) { return i + 1; }
+
+    function checkedHelperFromUnchecked() public {
+        unchecked {
+            for (uint8 i = 255; i > 0; i = checkedIncrement(i)) { vm.roll(block.number + 1); }
+        }
+    }
+
+    function wrappingStillRepeats() public {
+        unchecked {
+            for (uint8 i = 255; i != 254; ++i) {
+                vm.roll(block.number + 1); //~WARN: `block.number` may be reused across `vm.roll`
+            }
+        }
+    }
+
+    function constantBoundAndAssignment() public {
+        uint256 limit = 2 - 1;
+        for (uint256 i; i < limit; i = i + 1) { vm.roll(block.number + 1); }
+    }
+
+    function doWhileOnce() public {
+        uint256 i;
+        do { vm.roll(block.number + 1); ++i; } while (i < 1);
+    }
+
+    function separateNumberOutcomes(bool choose, FlowClock nonVm) internal view returns (uint256, FlowClock) {
+        if (choose) return (block.number, nonVm);
+        return (0, vm);
+    }
+
+    function separateTimestampOutcomes(bool choose, FlowClock nonVm) internal view returns (uint256 saved, FlowClock receiver) {
+        if (choose) { saved = block.timestamp; receiver = nonVm; }
+        else { saved = 0; receiver = vm; }
+    }
+
+    function correlatedNumber(bool choose, FlowClock nonVm) public returns (uint256) {
+        (uint256 saved, FlowClock receiver) = separateNumberOutcomes(choose, nonVm);
+        receiver.roll(200);
+        return saved;
+    }
+
+    function correlatedTimestamp(bool choose, FlowClock nonVm) public returns (uint256) {
+        (uint256 saved, FlowClock receiver) = separateTimestampOutcomes(choose, nonVm);
+        receiver.warp(200);
+        return saved;
+    }
+
+    function correlatedConditional(bool choose, FlowClock nonVm) public returns (uint256) {
+        (uint256 saved, FlowClock receiver) = choose ? (block.number, nonVm) : (uint256(0), vm);
+        receiver.roll(200);
+        return saved;
+    }
+
+    function hazardBeforeAmbiguousReturn(bool choose) internal returns (uint256) {
+        uint256 saved = block.number; //~WARN: `block.number` may be reused across `vm.roll`
+        if (choose) { vm.roll(200); return saved; }
+        return 0;
+    }
+
+    function useAmbiguousReturn(bool choose) public returns (uint256) {
+        return hazardBeforeAmbiguousReturn(choose);
+    }
+
+    function knownNumberOutcome() public returns (uint256, FlowClock) {
+        uint256 saved = block.number; //~WARN: `block.number` may be reused across `vm.roll`
+        return (saved, vm);
+    }
+
+    function unambiguousTuple() public returns (uint256) {
+        (uint256 saved, FlowClock receiver) = knownNumberOutcome();
+        receiver.roll(200);
+        return saved;
+    }
+
+    function identicalOutcomes(bool choose) internal view returns (uint256, FlowClock) {
+        uint256 saved = block.timestamp; //~WARN: `block.timestamp` may be reused across `vm.warp`
+        if (choose) return (saved, vm);
+        return (saved, vm);
+    }
+
+    function identicalTuple(bool choose) public returns (uint256) {
+        (uint256 saved, FlowClock receiver) = identicalOutcomes(choose);
+        receiver.warp(200);
+        return saved;
+    }
+}
diff --git a/crates/lint/testdata/CheatcodeEnvironmentControlFlow.stderr b/crates/lint/testdata/CheatcodeEnvironmentControlFlow.stderr
new file mode 100644
index 0000000000000..ac35f05c31bdf
--- /dev/null
+++ b/crates/lint/testdata/CheatcodeEnvironmentControlFlow.stderr
@@ -0,0 +1,106 @@
+warning[environment-read-across-mutation]: `block.number` may be reused across `vm.roll`
+   ╭▸ ROOT/testdata/CheatcodeEnvironmentControlFlow.sol:LL:CC
+   │
+LL │         return block.number;
+   │                ━━━━━━━━━━━━
+   ‡
+LL │         vm.roll(200);
+   │         ──────────── `vm.roll` changes this environment here
+   │
+   ├ help: capture it with `vm.getBlockNumber()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.timestamp` may be reused across `vm.warp`
+   ╭▸ ROOT/testdata/CheatcodeEnvironmentControlFlow.sol:LL:CC
+   │
+LL │         return block.timestamp;
+   │                ━━━━━━━━━━━━━━━
+   ‡
+LL │         vm.warp(200);
+   │         ──────────── `vm.warp` changes this environment here
+   │
+   ├ help: capture it with `vm.getBlockTimestamp()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.number` may be reused across `vm.roll`
+   ╭▸ ROOT/testdata/CheatcodeEnvironmentControlFlow.sol:LL:CC
+   │
+LL │             vm.roll(block.number + 1);
+   │             ────────━━━━━━━━━━━━───── `vm.roll` changes this environment here
+   │
+   ├ help: capture it with `vm.getBlockNumber()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.timestamp` may be reused across `vm.warp`
+   ╭▸ ROOT/testdata/CheatcodeEnvironmentControlFlow.sol:LL:CC
+   │
+LL │             vm.warp(block.timestamp + 1);
+   │             ────────━━━━━━━━━━━━━━━───── `vm.warp` changes this environment here
+   │
+   ├ help: capture it with `vm.getBlockTimestamp()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.number` may be reused across `vm.roll`
+   ╭▸ ROOT/testdata/CheatcodeEnvironmentControlFlow.sol:LL:CC
+   │
+LL │         uint256 saved = block.number;
+   │                         ━━━━━━━━━━━━
+LL │         for (uint256 i; i < 1; ++i) { vm.roll(200); }
+   │                                       ──────────── `vm.roll` changes this environment here
+   │
+   ├ help: capture it with `vm.getBlockNumber()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.number` may be reused across `vm.roll`
+   ╭▸ ROOT/testdata/CheatcodeEnvironmentControlFlow.sol:LL:CC
+   │
+LL │             vm.roll(block.number + 1);
+   │             ────────━━━━━━━━━━━━───── `vm.roll` changes this environment here
+   │
+   ├ help: capture it with `vm.getBlockNumber()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.number` may be reused across `vm.roll`
+   ╭▸ ROOT/testdata/CheatcodeEnvironmentControlFlow.sol:LL:CC
+   │
+LL │                 vm.roll(block.number + 1);
+   │                 ────────━━━━━━━━━━━━───── `vm.roll` changes this environment here
+   │
+   ├ help: capture it with `vm.getBlockNumber()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.number` may be reused across `vm.roll`
+   ╭▸ ROOT/testdata/CheatcodeEnvironmentControlFlow.sol:LL:CC
+   │
+LL │         uint256 saved = block.number;
+   │                         ━━━━━━━━━━━━
+LL │         if (choose) { vm.roll(200); return saved; }
+   │                       ──────────── `vm.roll` changes this environment here
+   │
+   ├ help: capture it with `vm.getBlockNumber()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.number` may be reused across `vm.roll`
+   ╭▸ ROOT/testdata/CheatcodeEnvironmentControlFlow.sol:LL:CC
+   │
+LL │         uint256 saved = block.number;
+   │                         ━━━━━━━━━━━━
+   ‡
+LL │         receiver.roll(200);
+   │         ────────────────── `vm.roll` changes this environment here
+   │
+   ├ help: capture it with `vm.getBlockNumber()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.timestamp` may be reused across `vm.warp`
+   ╭▸ ROOT/testdata/CheatcodeEnvironmentControlFlow.sol:LL:CC
+   │
+LL │         uint256 saved = block.timestamp;
+   │                         ━━━━━━━━━━━━━━━
+   ‡
+LL │         receiver.warp(200);
+   │         ────────────────── `vm.warp` changes this environment here
+   │
+   ├ help: capture it with `vm.getBlockTimestamp()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
diff --git a/crates/lint/testdata/CheatcodeEnvironmentPending.sol b/crates/lint/testdata/CheatcodeEnvironmentPending.sol
new file mode 100644
index 0000000000000..0a382192e8e63
--- /dev/null
+++ b/crates/lint/testdata/CheatcodeEnvironmentPending.sol
@@ -0,0 +1,75 @@
+//@compile-flags: --only-lint environment-read-across-mutation
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+interface PendingClock {
+    function roll(uint256 height) external;
+    function warp(uint256 time) external;
+}
+
+contract CheatcodeEnvironmentPending {
+    PendingClock constant vm = PendingClock(address(uint160(uint256(keccak256("hevm cheat code")))));
+    uint256[2] a;
+
+    function advance() internal returns (uint256) { vm.roll(200); return 0; }
+    function advanceTime() internal returns (uint256) { vm.warp(200); return 0; }
+    function first(uint256 value, uint256) internal pure returns (uint256) { return value; }
+
+    function assignment() public returns (uint256) {
+        uint256 saved = block.number; //~WARN: `block.number` may be reused across `vm.roll`
+        a[advance()] = 1;
+        return saved;
+    }
+
+    function deletion() public returns (uint256) {
+        uint256 saved = block.number; //~WARN: `block.number` may be reused across `vm.roll`
+        delete a[advance()];
+        return saved;
+    }
+
+    function tuple() public returns (uint256) {
+        (uint256 saved, uint256 ignored) = (block.number, advance()); //~WARN: `block.number` may be reused across `vm.roll`
+        return saved + ignored;
+    }
+
+    function binary() public returns (uint256) {
+        return block.number + advance(); //~WARN: `block.number` may be reused across `vm.roll`
+    }
+
+    function arguments() public returns (uint256) {
+        return first(block.number, advance()); //~WARN: `block.number` may be reused across `vm.roll`
+    }
+
+    function timestamp() public returns (uint256) {
+        (uint256 saved, uint256 ignored) = (block.timestamp, advanceTime()); //~WARN: `block.timestamp` may be reused across `vm.warp`
+        return saved + ignored;
+    }
+
+    function overwritten() public returns (uint256) {
+        uint256 saved = block.number;
+        vm.roll(200);
+        saved = 1;
+        delete saved;
+        return saved;
+    }
+
+    function fresh() public returns (uint256) {
+        (uint256 ignored, uint256 saved) = (advance(), block.number);
+        return saved + ignored;
+    }
+
+    function freshBranches(bool flag) public returns (uint256) {
+        uint256 saved;
+        if (flag) {
+            vm.roll(200);
+            saved = block.number;
+        } else {
+            saved = block.number;
+        }
+        return saved;
+    }
+
+    function otherEnvironment() public returns (uint256) {
+        return first(block.number, advanceTime());
+    }
+}
diff --git a/crates/lint/testdata/CheatcodeEnvironmentPending.stderr b/crates/lint/testdata/CheatcodeEnvironmentPending.stderr
new file mode 100644
index 0000000000000..00c83090618c0
--- /dev/null
+++ b/crates/lint/testdata/CheatcodeEnvironmentPending.stderr
@@ -0,0 +1,72 @@
+warning[environment-read-across-mutation]: `block.number` may be reused across `vm.roll`
+   ╭▸ ROOT/testdata/CheatcodeEnvironmentPending.sol:LL:CC
+   │
+LL │     function advance() internal returns (uint256) { vm.roll(200); return 0; }
+   │                                                     ──────────── `vm.roll` changes this environment here
+   ‡
+LL │         uint256 saved = block.number;
+   │                         ━━━━━━━━━━━━
+   │
+   ├ help: capture it with `vm.getBlockNumber()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.number` may be reused across `vm.roll`
+   ╭▸ ROOT/testdata/CheatcodeEnvironmentPending.sol:LL:CC
+   │
+LL │     function advance() internal returns (uint256) { vm.roll(200); return 0; }
+   │                                                     ──────────── `vm.roll` changes this environment here
+   ‡
+LL │         uint256 saved = block.number;
+   │                         ━━━━━━━━━━━━
+   │
+   ├ help: capture it with `vm.getBlockNumber()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.number` may be reused across `vm.roll`
+   ╭▸ ROOT/testdata/CheatcodeEnvironmentPending.sol:LL:CC
+   │
+LL │     function advance() internal returns (uint256) { vm.roll(200); return 0; }
+   │                                                     ──────────── `vm.roll` changes this environment here
+   ‡
+LL │         (uint256 saved, uint256 ignored) = (block.number, advance());
+   │                                             ━━━━━━━━━━━━
+   │
+   ├ help: capture it with `vm.getBlockNumber()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.number` may be reused across `vm.roll`
+   ╭▸ ROOT/testdata/CheatcodeEnvironmentPending.sol:LL:CC
+   │
+LL │     function advance() internal returns (uint256) { vm.roll(200); return 0; }
+   │                                                     ──────────── `vm.roll` changes this environment here
+   ‡
+LL │         return block.number + advance();
+   │                ━━━━━━━━━━━━
+   │
+   ├ help: capture it with `vm.getBlockNumber()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.number` may be reused across `vm.roll`
+   ╭▸ ROOT/testdata/CheatcodeEnvironmentPending.sol:LL:CC
+   │
+LL │     function advance() internal returns (uint256) { vm.roll(200); return 0; }
+   │                                                     ──────────── `vm.roll` changes this environment here
+   ‡
+LL │         return first(block.number, advance());
+   │                      ━━━━━━━━━━━━
+   │
+   ├ help: capture it with `vm.getBlockNumber()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.timestamp` may be reused across `vm.warp`
+   ╭▸ ROOT/testdata/CheatcodeEnvironmentPending.sol:LL:CC
+   │
+LL │     function advanceTime() internal returns (uint256) { vm.warp(200); return 0; }
+   │                                                         ──────────── `vm.warp` changes this environment here
+   ‡
+LL │         (uint256 saved, uint256 ignored) = (block.timestamp, advanceTime());
+   │                                             ━━━━━━━━━━━━━━━
+   │
+   ├ help: capture it with `vm.getBlockTimestamp()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
diff --git a/crates/lint/testdata/ControlledDelegatecall.sol b/crates/lint/testdata/ControlledDelegatecall.sol
new file mode 100644
index 0000000000000..c670221b79dea
--- /dev/null
+++ b/crates/lint/testdata/ControlledDelegatecall.sol
@@ -0,0 +1,360 @@
+//@compile-flags: --only-lint controlled-delegatecall
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+interface OrdinaryDelegatecall {
+    function delegatecall(bytes calldata data) external returns (bool);
+}
+
+interface OverloadedDelegatecallFactory {
+    function target(uint256 id) external returns (address);
+    function target(bytes calldata data) external returns (OrdinaryDelegatecall);
+}
+
+contract ControlledDelegatecall {
+    struct OrdinaryDelegatecallHolder {
+        OrdinaryDelegatecall target;
+    }
+
+    address public implementation;
+    address public immutable trustedImplementation;
+    address public constant TRUSTED = 0x000000000000000000000000000000000000dEaD;
+    mapping(address => address) public plugins;
+    OrdinaryDelegatecallHolder ordinaryHolder;
+    OrdinaryDelegatecall[] ordinaryTargets;
+    OverloadedDelegatecallFactory overloadedFactory;
+
+    constructor(address _trusted) {
+        trustedImplementation = _trusted;
+    }
+
+    function delegateToParameter(address target, bytes calldata data) external returns (bool ok) {
+        (ok,) = target.delegatecall(data); //~WARN: `delegatecall` target is not provably trusted
+    }
+
+    function delegateToPayableParameter(
+        address payable target,
+        bytes calldata data
+    ) external returns (bool ok) {
+        (ok,) = target.delegatecall(data); //~WARN: `delegatecall` target is not provably trusted
+    }
+
+    function delegateToParameterCast(address target, bytes calldata data) external returns (bool ok) {
+        (ok,) = address(target).delegatecall(data); //~WARN: `delegatecall` target is not provably trusted
+    }
+
+    function delegateToAssignedParameter(address target, bytes calldata data) external returns (bool ok) {
+        address localTarget = target;
+        (ok,) = localTarget.delegatecall(data); //~WARN: `delegatecall` target is not provably trusted
+    }
+
+    function delegateToTernaryParameter(
+        address target,
+        bool useTarget,
+        bytes calldata data
+    ) external returns (bool ok) {
+        (ok,) = (useTarget ? target : TRUSTED).delegatecall(data); //~WARN: `delegatecall` target is not provably trusted
+    }
+
+    function delegateToStorage(bytes calldata data) external returns (bool ok) {
+        (ok,) = implementation.delegatecall(data); //~WARN: `delegatecall` target is not provably trusted
+    }
+
+    function delegateToMapping(address user, bytes calldata data) external returns (bool ok) {
+        (ok,) = plugins[user].delegatecall(data); //~WARN: `delegatecall` target is not provably trusted
+    }
+
+    function delegateToMsgSender(bytes calldata data) external returns (bool ok) {
+        (ok,) = msg.sender.delegatecall(data); //~WARN: `delegatecall` target is not provably trusted
+    }
+
+    function protectedDelegateToParameter(
+        address target,
+        bytes calldata data
+    ) external onlyOwner returns (bool ok) {
+        (ok,) = target.delegatecall(data); //~WARN: `delegatecall` target is not provably trusted
+    }
+
+    function delegateToImmutable(bytes calldata data) external returns (bool ok) {
+        (ok,) = trustedImplementation.delegatecall(data); //~WARN: `delegatecall` target is not provably trusted
+    }
+
+    function delegateToConstant(bytes calldata data) external returns (bool ok) {
+        (ok,) = TRUSTED.delegatecall(data);
+    }
+
+    function delegateToLiteral(bytes calldata data) external returns (bool ok) {
+        (ok,) = address(0x000000000000000000000000000000000000dEaD).delegatecall(data);
+    }
+
+    function delegateToSelf(bytes calldata data) external returns (bool ok) {
+        (ok,) = address(this).delegatecall(data);
+    }
+
+    function ordinaryDelegatecallFunction(OrdinaryDelegatecall target, bytes calldata data) external returns (bool ok) {
+        ok = target.delegatecall(data);
+    }
+
+    function ordinaryDelegatecallFromStruct(bytes calldata data) external returns (bool ok) {
+        ok = ordinaryHolder.target.delegatecall(data);
+    }
+
+    function ordinaryDelegatecallFromArray(uint256 index, bytes calldata data) external returns (bool ok) {
+        ok = ordinaryTargets[index].delegatecall(data);
+    }
+
+    function ordinaryDelegatecallFromOverload(bytes calldata data) external returns (bool ok) {
+        ok = overloadedFactory.target(data).delegatecall(data);
+    }
+
+    function delegateToGuarded(address target, bytes calldata data) external returns (bool ok) {
+        require(target == trustedImplementation);
+        (ok,) = target.delegatecall(data); //~WARN: `delegatecall` target is not provably trusted
+    }
+
+    function delegateToConstantGuarded(address target, bytes calldata data) external returns (bool ok) {
+        require(target == TRUSTED);
+        (ok,) = target.delegatecall(data);
+    }
+
+    function delegateToConstantGuardedByRevert(address target, bytes calldata data) external returns (bool ok) {
+        if (target != TRUSTED) revert();
+        (ok,) = target.delegatecall(data);
+    }
+
+    function delegateToModifierGuarded(address target, bytes calldata data) external onlyTrusted(target) returns (bool ok) {
+        (ok,) = target.delegatecall(data);
+    }
+
+    function id(address target) internal pure returns (address) {
+        return target;
+    }
+
+    function impl() internal view returns (address) {
+        return implementation;
+    }
+
+    function trusted() internal pure returns (address) {
+        return TRUSTED;
+    }
+
+    function delegateToHelperReturn(address target, bytes calldata data) external returns (bool ok) {
+        (ok,) = id(target).delegatecall(data); //~WARN: `delegatecall` target is not provably trusted
+    }
+
+    function delegateToStorageHelperReturn(bytes calldata data) external returns (bool ok) {
+        (ok,) = impl().delegatecall(data); //~WARN: `delegatecall` target is not provably trusted
+    }
+
+    function delegateToTrustedHelperReturn(bytes calldata data) external returns (bool ok) {
+        (ok,) = trusted().delegatecall(data);
+    }
+
+    function delegateToDecoded(bytes calldata blob, bytes calldata data) external returns (bool ok) {
+        (ok,) = abi.decode(blob, (address)).delegatecall(data); //~WARN: `delegatecall` target is not provably trusted
+    }
+
+    function delegateToAssignmentReceiver(address target, bytes calldata data) external returns (bool ok) {
+        address localTarget;
+        (ok,) = (localTarget = target).delegatecall(data); //~WARN: `delegatecall` target is not provably trusted
+    }
+
+    function delegateToTupleReassigned(address target, bytes calldata data) external returns (bool ok) {
+        address localTarget = TRUSTED;
+        (localTarget,) = (target, uint256(0));
+        (ok,) = localTarget.delegatecall(data); //~WARN: `delegatecall` target is not provably trusted
+    }
+
+    function delegateToTupleHelper(address target, bytes calldata data) external returns (bool ok) {
+        address localTarget = TRUSTED;
+        (localTarget,) = pair(target);
+        (ok,) = localTarget.delegatecall(data); //~WARN: `delegatecall` target is not provably trusted
+    }
+
+    function pair(address target) internal pure returns (address, uint256) {
+        return (target, 0);
+    }
+
+    function delegateToBranchJoin(bool useTarget, address target, bytes calldata data) external returns (bool ok) {
+        address localTarget;
+        if (useTarget) {
+            localTarget = target;
+        } else {
+            localTarget = TRUSTED;
+        }
+        (ok,) = localTarget.delegatecall(data); //~WARN: `delegatecall` target is not provably trusted
+    }
+
+    function delegateToImplicitElseJoin(bool useTrusted, address target, bytes calldata data) external returns (bool ok) {
+        address localTarget = target;
+        if (useTrusted) {
+            localTarget = TRUSTED;
+        }
+        (ok,) = localTarget.delegatecall(data); //~WARN: `delegatecall` target is not provably trusted
+    }
+
+    function delegateToLoopJoin(bool useTrusted, address target, bytes calldata data) external returns (bool ok) {
+        address localTarget = target;
+        while (useTrusted) {
+            localTarget = TRUSTED;
+        }
+        (ok,) = localTarget.delegatecall(data); //~WARN: `delegatecall` target is not provably trusted
+    }
+
+    function delegateToExitingBranch(bool useTarget, address target, bytes calldata data) external returns (bool ok) {
+        address localTarget = TRUSTED;
+        if (useTarget) {
+            localTarget = target;
+            revert();
+        }
+        (ok,) = localTarget.delegatecall(data);
+    }
+
+    function delegateAfterReturn(address target, bytes calldata data) external returns (bool ok) {
+        return false;
+        (ok,) = target.delegatecall(data);
+    }
+
+    function delegateToShortCircuit(bool skipAssignment, address target, bytes calldata data) external returns (bool ok) {
+        address localTarget = target;
+        if (skipAssignment || (localTarget = TRUSTED) == TRUSTED) {}
+        (ok,) = localTarget.delegatecall(data); //~WARN: `delegatecall` target is not provably trusted
+    }
+
+    function delegateToTernarySideEffect(bool skipAssignment, address target, bytes calldata data) external returns (bool ok) {
+        address localTarget = target;
+        skipAssignment ? localTarget : (localTarget = TRUSTED);
+        (ok,) = localTarget.delegatecall(data); //~WARN: `delegatecall` target is not provably trusted
+    }
+
+    function delegateToZero(bytes calldata data) external returns (bool ok) {
+        (ok,) = address(0).delegatecall(data);
+    }
+
+    function delegateToNumericCast(bytes calldata data) external returns (bool ok) {
+        (ok,) = address(uint160(0x000000000000000000000000000000000000dEaD)).delegatecall(data);
+    }
+
+    function delegateToDeleted(address target, bytes calldata data) external returns (bool ok) {
+        address localTarget = target;
+        delete localTarget;
+        (ok,) = localTarget.delegatecall(data);
+    }
+
+    function delegateAfterSideEffectingRequire(
+        address target,
+        bytes calldata data
+    ) external returns (bool ok) {
+        require(target == TRUSTED && (target = msg.sender) != address(0));
+        (ok,) = target.delegatecall(data); //~WARN: `delegatecall` target is not provably trusted
+    }
+
+    function delegateAfterSideEffectingRequireMessage(
+        address target,
+        bytes calldata data
+    ) external returns (bool ok) {
+        require(target == TRUSTED, string(abi.encodePacked(target = msg.sender)));
+        (ok,) = target.delegatecall(data); //~WARN: `delegatecall` target is not provably trusted
+    }
+
+    function delegateInSideEffectingIf(
+        address target,
+        bytes calldata data
+    ) external returns (bool ok) {
+        if (target == TRUSTED && (target = msg.sender) != address(0)) {
+            (ok,) = target.delegatecall(data); //~WARN: `delegatecall` target is not provably trusted
+        }
+    }
+
+    function delegateToGetter(bytes calldata data) external returns (bool ok) {
+        (ok,) = this.implementation().delegatecall(data); //~WARN: `delegatecall` target is not provably trusted
+    }
+
+    function delegateToFunctionPointer(
+        function () external returns (address) getTarget,
+        bytes calldata data
+    ) external returns (bool ok) {
+        (ok,) = getTarget().delegatecall(data); //~WARN: `delegatecall` target is not provably trusted
+    }
+
+    function delegateToArrayLiteral(address target, bytes calldata data) external returns (bool ok) {
+        (ok,) = [target][0].delegatecall(data); //~WARN: `delegatecall` target is not provably trusted
+    }
+
+    function delegateAfterDoWhile(bytes calldata data) external returns (bool ok) {
+        address target;
+        do {
+            target = TRUSTED;
+        } while (false);
+        (ok,) = target.delegatecall(data);
+    }
+
+    function delegateAfterDoWhileConditionSideEffect(bytes calldata data) external returns (bool ok) {
+        address target;
+        do {
+            target = TRUSTED;
+        } while ((target = msg.sender) != address(0));
+        (ok,) = target.delegatecall(data); //~WARN: `delegatecall` target is not provably trusted
+    }
+
+    modifier onlyOwner() {
+        require(msg.sender == address(0x000000000000000000000000000000000000bEEF));
+        _;
+    }
+
+    modifier onlyTrusted(address target) {
+        require(target == TRUSTED);
+        _;
+    }
+
+    function setImplementation(address newImplementation) external {
+        implementation = newImplementation;
+    }
+
+    function setPlugin(address user, address plugin) external {
+        plugins[user] = plugin;
+    }
+}
+
+contract ControlledDelegatecallFactory {
+    function impl() external view returns (address) {
+        return msg.sender;
+    }
+}
+
+contract ControlledDelegatecallNew {
+    function delegateToNew(bytes calldata data) external returns (bool ok) {
+        (ok,) = new ControlledDelegatecallFactory().impl().delegatecall(data); //~WARN: `delegatecall` target is not provably trusted
+    }
+}
+
+contract ControlledDelegatecallBase {
+    address public constant TRUSTED = 0x000000000000000000000000000000000000dEaD;
+
+    function impl() public view virtual returns (address) {
+        return TRUSTED;
+    }
+
+    function delegateToVirtualHelper(bytes calldata data) external returns (bool ok) {
+        (ok,) = impl().delegatecall(data); //~WARN: `delegatecall` target is not provably trusted
+    }
+}
+
+contract ControlledDelegatecallBad is ControlledDelegatecallBase {
+    function impl() public view override returns (address) {
+        return msg.sender;
+    }
+}
+
+contract ControlledDelegatecallSuperBase {
+    function impl() public view virtual returns (address) {
+        return msg.sender;
+    }
+}
+
+contract ControlledDelegatecallSuperChild is ControlledDelegatecallSuperBase {
+    function delegateToSuper(bytes calldata data) external returns (bool ok) {
+        (ok,) = super.impl().delegatecall(data); //~WARN: `delegatecall` target is not provably trusted
+    }
+}
diff --git a/crates/lint/testdata/ControlledDelegatecall.stderr b/crates/lint/testdata/ControlledDelegatecall.stderr
new file mode 100644
index 0000000000000..2f71d53be5c15
--- /dev/null
+++ b/crates/lint/testdata/ControlledDelegatecall.stderr
@@ -0,0 +1,256 @@
+warning[controlled-delegatecall]: `delegatecall` target is not provably trusted
+   ╭▸ ROOT/testdata/ControlledDelegatecall.sol:LL:CC
+   │
+LL │         (ok,) = target.delegatecall(data);
+   │                 ━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/controlled-delegatecall
+
+warning[controlled-delegatecall]: `delegatecall` target is not provably trusted
+   ╭▸ ROOT/testdata/ControlledDelegatecall.sol:LL:CC
+   │
+LL │         (ok,) = target.delegatecall(data);
+   │                 ━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/controlled-delegatecall
+
+warning[controlled-delegatecall]: `delegatecall` target is not provably trusted
+   ╭▸ ROOT/testdata/ControlledDelegatecall.sol:LL:CC
+   │
+LL │         (ok,) = address(target).delegatecall(data);
+   │                 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/controlled-delegatecall
+
+warning[controlled-delegatecall]: `delegatecall` target is not provably trusted
+   ╭▸ ROOT/testdata/ControlledDelegatecall.sol:LL:CC
+   │
+LL │         (ok,) = localTarget.delegatecall(data);
+   │                 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/controlled-delegatecall
+
+warning[controlled-delegatecall]: `delegatecall` target is not provably trusted
+   ╭▸ ROOT/testdata/ControlledDelegatecall.sol:LL:CC
+   │
+LL │         (ok,) = (useTarget ? target : TRUSTED).delegatecall(data);
+   │                 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/controlled-delegatecall
+
+warning[controlled-delegatecall]: `delegatecall` target is not provably trusted
+   ╭▸ ROOT/testdata/ControlledDelegatecall.sol:LL:CC
+   │
+LL │         (ok,) = implementation.delegatecall(data);
+   │                 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/controlled-delegatecall
+
+warning[controlled-delegatecall]: `delegatecall` target is not provably trusted
+   ╭▸ ROOT/testdata/ControlledDelegatecall.sol:LL:CC
+   │
+LL │         (ok,) = plugins[user].delegatecall(data);
+   │                 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/controlled-delegatecall
+
+warning[controlled-delegatecall]: `delegatecall` target is not provably trusted
+   ╭▸ ROOT/testdata/ControlledDelegatecall.sol:LL:CC
+   │
+LL │         (ok,) = msg.sender.delegatecall(data);
+   │                 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/controlled-delegatecall
+
+warning[controlled-delegatecall]: `delegatecall` target is not provably trusted
+   ╭▸ ROOT/testdata/ControlledDelegatecall.sol:LL:CC
+   │
+LL │         (ok,) = target.delegatecall(data);
+   │                 ━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/controlled-delegatecall
+
+warning[controlled-delegatecall]: `delegatecall` target is not provably trusted
+   ╭▸ ROOT/testdata/ControlledDelegatecall.sol:LL:CC
+   │
+LL │         (ok,) = trustedImplementation.delegatecall(data);
+   │                 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/controlled-delegatecall
+
+warning[controlled-delegatecall]: `delegatecall` target is not provably trusted
+   ╭▸ ROOT/testdata/ControlledDelegatecall.sol:LL:CC
+   │
+LL │         (ok,) = target.delegatecall(data);
+   │                 ━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/controlled-delegatecall
+
+warning[controlled-delegatecall]: `delegatecall` target is not provably trusted
+   ╭▸ ROOT/testdata/ControlledDelegatecall.sol:LL:CC
+   │
+LL │         (ok,) = id(target).delegatecall(data);
+   │                 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/controlled-delegatecall
+
+warning[controlled-delegatecall]: `delegatecall` target is not provably trusted
+   ╭▸ ROOT/testdata/ControlledDelegatecall.sol:LL:CC
+   │
+LL │         (ok,) = impl().delegatecall(data);
+   │                 ━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/controlled-delegatecall
+
+warning[controlled-delegatecall]: `delegatecall` target is not provably trusted
+   ╭▸ ROOT/testdata/ControlledDelegatecall.sol:LL:CC
+   │
+LL │         (ok,) = abi.decode(blob, (address)).delegatecall(data);
+   │                 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/controlled-delegatecall
+
+warning[controlled-delegatecall]: `delegatecall` target is not provably trusted
+   ╭▸ ROOT/testdata/ControlledDelegatecall.sol:LL:CC
+   │
+LL │         (ok,) = (localTarget = target).delegatecall(data);
+   │                 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/controlled-delegatecall
+
+warning[controlled-delegatecall]: `delegatecall` target is not provably trusted
+   ╭▸ ROOT/testdata/ControlledDelegatecall.sol:LL:CC
+   │
+LL │         (ok,) = localTarget.delegatecall(data);
+   │                 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/controlled-delegatecall
+
+warning[controlled-delegatecall]: `delegatecall` target is not provably trusted
+   ╭▸ ROOT/testdata/ControlledDelegatecall.sol:LL:CC
+   │
+LL │         (ok,) = localTarget.delegatecall(data);
+   │                 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/controlled-delegatecall
+
+warning[controlled-delegatecall]: `delegatecall` target is not provably trusted
+   ╭▸ ROOT/testdata/ControlledDelegatecall.sol:LL:CC
+   │
+LL │         (ok,) = localTarget.delegatecall(data);
+   │                 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/controlled-delegatecall
+
+warning[controlled-delegatecall]: `delegatecall` target is not provably trusted
+   ╭▸ ROOT/testdata/ControlledDelegatecall.sol:LL:CC
+   │
+LL │         (ok,) = localTarget.delegatecall(data);
+   │                 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/controlled-delegatecall
+
+warning[controlled-delegatecall]: `delegatecall` target is not provably trusted
+   ╭▸ ROOT/testdata/ControlledDelegatecall.sol:LL:CC
+   │
+LL │         (ok,) = localTarget.delegatecall(data);
+   │                 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/controlled-delegatecall
+
+warning[controlled-delegatecall]: `delegatecall` target is not provably trusted
+   ╭▸ ROOT/testdata/ControlledDelegatecall.sol:LL:CC
+   │
+LL │         (ok,) = localTarget.delegatecall(data);
+   │                 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/controlled-delegatecall
+
+warning[controlled-delegatecall]: `delegatecall` target is not provably trusted
+   ╭▸ ROOT/testdata/ControlledDelegatecall.sol:LL:CC
+   │
+LL │         (ok,) = localTarget.delegatecall(data);
+   │                 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/controlled-delegatecall
+
+warning[controlled-delegatecall]: `delegatecall` target is not provably trusted
+   ╭▸ ROOT/testdata/ControlledDelegatecall.sol:LL:CC
+   │
+LL │         (ok,) = target.delegatecall(data);
+   │                 ━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/controlled-delegatecall
+
+warning[controlled-delegatecall]: `delegatecall` target is not provably trusted
+   ╭▸ ROOT/testdata/ControlledDelegatecall.sol:LL:CC
+   │
+LL │         (ok,) = target.delegatecall(data);
+   │                 ━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/controlled-delegatecall
+
+warning[controlled-delegatecall]: `delegatecall` target is not provably trusted
+   ╭▸ ROOT/testdata/ControlledDelegatecall.sol:LL:CC
+   │
+LL │             (ok,) = target.delegatecall(data);
+   │                     ━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/controlled-delegatecall
+
+warning[controlled-delegatecall]: `delegatecall` target is not provably trusted
+   ╭▸ ROOT/testdata/ControlledDelegatecall.sol:LL:CC
+   │
+LL │         (ok,) = this.implementation().delegatecall(data);
+   │                 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/controlled-delegatecall
+
+warning[controlled-delegatecall]: `delegatecall` target is not provably trusted
+   ╭▸ ROOT/testdata/ControlledDelegatecall.sol:LL:CC
+   │
+LL │         (ok,) = getTarget().delegatecall(data);
+   │                 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/controlled-delegatecall
+
+warning[controlled-delegatecall]: `delegatecall` target is not provably trusted
+   ╭▸ ROOT/testdata/ControlledDelegatecall.sol:LL:CC
+   │
+LL │         (ok,) = [target][0].delegatecall(data);
+   │                 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/controlled-delegatecall
+
+warning[controlled-delegatecall]: `delegatecall` target is not provably trusted
+   ╭▸ ROOT/testdata/ControlledDelegatecall.sol:LL:CC
+   │
+LL │         (ok,) = target.delegatecall(data);
+   │                 ━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/controlled-delegatecall
+
+warning[controlled-delegatecall]: `delegatecall` target is not provably trusted
+   ╭▸ ROOT/testdata/ControlledDelegatecall.sol:LL:CC
+   │
+LL │         (ok,) = new ControlledDelegatecallFactory().impl().delegatecall(data);
+   │                 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/controlled-delegatecall
+
+warning[controlled-delegatecall]: `delegatecall` target is not provably trusted
+   ╭▸ ROOT/testdata/ControlledDelegatecall.sol:LL:CC
+   │
+LL │         (ok,) = impl().delegatecall(data);
+   │                 ━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/controlled-delegatecall
+
+warning[controlled-delegatecall]: `delegatecall` target is not provably trusted
+   ╭▸ ROOT/testdata/ControlledDelegatecall.sol:LL:CC
+   │
+LL │         (ok,) = super.impl().delegatecall(data);
+   │                 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/controlled-delegatecall
+
diff --git a/crates/lint/testdata/ControlledDelegatecallTruncatedGuard.sol b/crates/lint/testdata/ControlledDelegatecallTruncatedGuard.sol
new file mode 100644
index 0000000000000..e251eee2b90f9
--- /dev/null
+++ b/crates/lint/testdata/ControlledDelegatecallTruncatedGuard.sol
@@ -0,0 +1,81 @@
+//@compile-flags: --only-lint controlled-delegatecall
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+contract ControlledDelegatecallTruncatedGuard {
+    address constant TRUSTED = address(0x42);
+
+    modifier onlyTrusted(address target) {
+        require(target == TRUSTED);
+        _;
+    }
+
+    modifier onlyTrustedTruncated(address target) {
+        require(address(uint160(uint128(uint160(target)))) == TRUSTED);
+        _;
+    }
+
+    // Equality of the low byte leaves the other 152 address bits attacker-controlled.
+    function truncatedGuard(address target, bytes calldata data) external returns (bool ok) {
+        require(address(uint160(uint8(uint160(target)))) == TRUSTED);
+        (ok,) = target.delegatecall(data); //~WARN: `delegatecall` target is not provably trusted
+    }
+
+    // A 152-bit comparison still leaves the highest address byte attacker-controlled.
+    function truncatedUint152Guard(address target, bytes calldata data) external returns (bool ok) {
+        require(address(uint160(uint152(uint160(target)))) == TRUSTED);
+        (ok,) = target.delegatecall(data); //~WARN: `delegatecall` target is not provably trusted
+    }
+
+    // Signed casts below 160 bits must observe the same boundary as unsigned casts.
+    function truncatedInt152Guard(address target, bytes calldata data) external returns (bool ok) {
+        require(int152(int160(uint160(target))) == int152(int160(uint160(TRUSTED))));
+        (ok,) = target.delegatecall(data); //~WARN: `delegatecall` target is not provably trusted
+    }
+
+    // A trusted modifier argument does not authorize the original, untruncated address.
+    function truncatedModifierArgument(address target, bytes calldata data)
+        external onlyTrusted(address(uint160(uint128(uint160(target))))) returns (bool ok)
+    {
+        (ok,) = target.delegatecall(data); //~WARN: `delegatecall` target is not provably trusted
+    }
+
+    // Truncation inside the modifier guard must not vouch for the caller's target either.
+    function truncatedModifierGuard(address target, bytes calldata data)
+        external onlyTrustedTruncated(target) returns (bool ok)
+    {
+        (ok,) = target.delegatecall(data); //~WARN: `delegatecall` target is not provably trusted
+    }
+
+    function fullGuard(address target, bytes calldata data) external returns (bool ok) {
+        require(target == TRUSTED);
+        (ok,) = target.delegatecall(data);
+    }
+
+    function numericGuard(address target, bytes calldata data) external returns (bool ok) {
+        require(address(uint160(target)) == TRUSTED);
+        (ok,) = target.delegatecall(data);
+    }
+
+    function signedNumericGuard(address target, bytes calldata data) external returns (bool ok) {
+        require(int160(uint160(target)) == int160(uint160(TRUSTED)));
+        (ok,) = target.delegatecall(data);
+    }
+
+    function widenedNumericGuard(address target, bytes calldata data) external returns (bool ok) {
+        require(uint256(uint160(target)) == uint256(uint160(TRUSTED)));
+        (ok,) = target.delegatecall(data);
+    }
+
+    function numericModifierArgument(address target, bytes calldata data)
+        external onlyTrusted(address(uint160(target))) returns (bool ok)
+    {
+        (ok,) = target.delegatecall(data);
+    }
+
+    // Evaluating a trusted target is distinct from inferring trust for a truncated variable.
+    function narrowedConstant(bytes calldata data) external returns (bool ok) {
+        (ok,) = address(uint160(uint8(0))).delegatecall(data);
+    }
+}
diff --git a/crates/lint/testdata/ControlledDelegatecallTruncatedGuard.stderr b/crates/lint/testdata/ControlledDelegatecallTruncatedGuard.stderr
new file mode 100644
index 0000000000000..5aa7d2ad96164
--- /dev/null
+++ b/crates/lint/testdata/ControlledDelegatecallTruncatedGuard.stderr
@@ -0,0 +1,40 @@
+warning[controlled-delegatecall]: `delegatecall` target is not provably trusted
+   ╭▸ ROOT/testdata/ControlledDelegatecallTruncatedGuard.sol:LL:CC
+   │
+LL │         (ok,) = target.delegatecall(data);
+   │                 ━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/controlled-delegatecall
+
+warning[controlled-delegatecall]: `delegatecall` target is not provably trusted
+   ╭▸ ROOT/testdata/ControlledDelegatecallTruncatedGuard.sol:LL:CC
+   │
+LL │         (ok,) = target.delegatecall(data);
+   │                 ━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/controlled-delegatecall
+
+warning[controlled-delegatecall]: `delegatecall` target is not provably trusted
+   ╭▸ ROOT/testdata/ControlledDelegatecallTruncatedGuard.sol:LL:CC
+   │
+LL │         (ok,) = target.delegatecall(data);
+   │                 ━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/controlled-delegatecall
+
+warning[controlled-delegatecall]: `delegatecall` target is not provably trusted
+   ╭▸ ROOT/testdata/ControlledDelegatecallTruncatedGuard.sol:LL:CC
+   │
+LL │         (ok,) = target.delegatecall(data);
+   │                 ━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/controlled-delegatecall
+
+warning[controlled-delegatecall]: `delegatecall` target is not provably trusted
+   ╭▸ ROOT/testdata/ControlledDelegatecallTruncatedGuard.sol:LL:CC
+   │
+LL │         (ok,) = target.delegatecall(data);
+   │                 ━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/controlled-delegatecall
+
diff --git a/crates/lint/testdata/CostlyLoop.sol b/crates/lint/testdata/CostlyLoop.sol
new file mode 100644
index 0000000000000..5eb2d46c14453
--- /dev/null
+++ b/crates/lint/testdata/CostlyLoop.sol
@@ -0,0 +1,95 @@
+//@compile-flags: --only-lint costly-loop
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.13;
+
+contract CostlyLoop {
+    uint256 public counter;
+    uint256[] public values;
+    mapping(address => uint256) public balances;
+
+    function getValues() internal view returns (uint256[] storage) {
+        return values;
+    }
+
+    // bad: direct state variable write in loop
+    function badIncrement(uint256 n) public {
+        for (uint256 i = 0; i < n; i++) {
+            counter++;
+        }
+    }
+
+    // bad: assignment to state variable in loop
+    function badAssign(uint256 n) public {
+        for (uint256 i = 0; i < n; i++) {
+            counter = i;
+        }
+    }
+
+    // bad: state variable written in while loop
+    function badWhile(uint256 n) public {
+        uint256 i = 0;
+        while (i < n) {
+            counter += 1;
+            i++;
+        }
+    }
+
+    // bad: mapping write in loop
+    function badMapping(address[] calldata users, uint256 amount) public {
+        for (uint256 i = 0; i < users.length; i++) {
+            balances[users[i]] = amount;
+        }
+    }
+
+    // bad: array element write in loop
+    function badArrayWrite(uint256 n) public {
+        for (uint256 i = 0; i < n; i++) {
+            values[i] = i;
+        }
+    }
+
+    // bad: array element write through a storage reference returned by a function
+    function badStorageReturnWrite(uint256 n) public {
+        for (uint256 i = 0; i < n; i++) {
+            getValues()[i] = i; //~NOTE: storage write inside a loop
+        }
+    }
+
+    // bad: assignment through the storage lvalue returned by array push
+    function badPushReturnWrite(uint256 n) public {
+        for (uint256 i = 0; i < n; i++) {
+            values.push() = i; //~NOTE: storage write inside a loop
+        }
+    }
+
+    // bad: delete state variable in loop
+    function badDelete(uint256 n) public {
+        for (uint256 i = 0; i < n; i++) {
+            delete counter;
+        }
+    }
+
+    // good: local variable written in loop, state written once after
+    function goodLocal(uint256 n) public {
+        uint256 local = counter;
+        for (uint256 i = 0; i < n; i++) {
+            local++;
+        }
+        counter = local;
+    }
+
+    // good: state variable only read in loop
+    function goodRead(uint256 n) public view returns (uint256) {
+        uint256 sum = 0;
+        for (uint256 i = 0; i < n; i++) {
+            sum += counter;
+        }
+        return sum;
+    }
+
+    // good: state variable written outside of any loop
+    function goodOutsideLoop() public {
+        counter = 42;
+    }
+}
diff --git a/crates/lint/testdata/CostlyLoop.stderr b/crates/lint/testdata/CostlyLoop.stderr
new file mode 100644
index 0000000000000..bd1dea9c95253
--- /dev/null
+++ b/crates/lint/testdata/CostlyLoop.stderr
@@ -0,0 +1,64 @@
+note[costly-loop]: storage write inside a loop
+   ╭▸ ROOT/testdata/CostlyLoop.sol:LL:CC
+   │
+LL │             counter++;
+   │             ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/costly-loop
+
+note[costly-loop]: storage write inside a loop
+   ╭▸ ROOT/testdata/CostlyLoop.sol:LL:CC
+   │
+LL │             counter = i;
+   │             ━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/costly-loop
+
+note[costly-loop]: storage write inside a loop
+   ╭▸ ROOT/testdata/CostlyLoop.sol:LL:CC
+   │
+LL │             counter += 1;
+   │             ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/costly-loop
+
+note[costly-loop]: storage write inside a loop
+   ╭▸ ROOT/testdata/CostlyLoop.sol:LL:CC
+   │
+LL │             balances[users[i]] = amount;
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/costly-loop
+
+note[costly-loop]: storage write inside a loop
+   ╭▸ ROOT/testdata/CostlyLoop.sol:LL:CC
+   │
+LL │             values[i] = i;
+   │             ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/costly-loop
+
+note[costly-loop]: storage write inside a loop
+   ╭▸ ROOT/testdata/CostlyLoop.sol:LL:CC
+   │
+LL │             getValues()[i] = i;
+   │             ━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/costly-loop
+
+note[costly-loop]: storage write inside a loop
+   ╭▸ ROOT/testdata/CostlyLoop.sol:LL:CC
+   │
+LL │             values.push() = i;
+   │             ━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/costly-loop
+
+note[costly-loop]: storage write inside a loop
+   ╭▸ ROOT/testdata/CostlyLoop.sol:LL:CC
+   │
+LL │             delete counter;
+   │             ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/costly-loop
+
diff --git a/crates/lint/testdata/CouldBeConstant.sol b/crates/lint/testdata/CouldBeConstant.sol
new file mode 100644
index 0000000000000..74f4a7986f212
--- /dev/null
+++ b/crates/lint/testdata/CouldBeConstant.sol
@@ -0,0 +1,69 @@
+//@compile-flags: --only-lint could-be-constant
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.13;
+
+contract Other {
+    uint256 public x;
+}
+
+interface IToken {
+    function balanceOf(address) external view returns (uint256);
+}
+
+contract CouldBeConstant {
+    // --- triggering cases (constant initializer, no writes anywhere) ---
+
+    uint256 public limit = 100; //~NOTE: state variable could be declared `constant`
+    uint256 internal sum = 1 + 2; //~NOTE: state variable could be declared `constant`
+    bytes32 internal salt = keccak256("foundry"); //~NOTE: state variable could be declared `constant`
+    string internal greeting = "hi"; //~NOTE: state variable could be declared `constant`
+    bytes internal payloadPrefix = hex"0a0b"; //~NOTE: state variable could be declared `constant`
+    uint256 internal derived = ALREADY_CONST + 1; //~NOTE: state variable could be declared `constant`
+    IToken internal token = IToken(address(0xCAFE)); //~NOTE: state variable could be declared `constant`
+    address internal nestedCast = address(uint160(0xCAFE)); //~NOTE: state variable could be declared `constant`
+    uint256 internal maxUint = type(uint256).max; //~NOTE: state variable could be declared `constant`
+    int256 internal minInt = type(int256).min; //~NOTE: state variable could be declared `constant`
+    bytes4 internal iid = type(IToken).interfaceId; //~NOTE: state variable could be declared `constant`
+
+    // --- non-triggering cases ---
+
+    // Already declared constant / immutable.
+    uint256 public constant ALREADY_CONST = 1;
+    uint256 public immutable ALREADY_IMMUTABLE;
+
+    // Non-constant initializer: should be flagged as `could-be-immutable`, not `could-be-constant`.
+    address public deployer = msg.sender;
+
+    // No initializer: not flagged (we require an inline value).
+    uint256 internal noInit;
+
+    // Written in non-constructor function: not flagged.
+    uint256 internal mutated = 1;
+
+    // Written in constructor body: would be `could-be-immutable`, not `could-be-constant`.
+    uint256 internal configured = 1;
+
+    // `new` calls are not compile-time constants.
+    Other internal spawned = new Other();
+
+    // Arrays / mappings are not constant-eligible types.
+    uint256[] internal arr = [uint256(1), 2, 3];
+    mapping(address => uint256) internal balances;
+
+    // Written by a sibling state variable's initializer: not flagged.
+    uint256 internal writtenByInitializer = 0;
+    uint256 internal writerInitializer = (writtenByInitializer = 1);
+
+    // Self-writing initializer: not flagged (initializer is non-constant).
+    uint256 internal selfWriting = (selfWriting = 1);
+
+    constructor(uint256 immutableValue) {
+        ALREADY_IMMUTABLE = immutableValue;
+        configured = immutableValue;
+    }
+
+    function bump() public {
+        mutated += 1;
+    }
+}
diff --git a/crates/lint/testdata/CouldBeConstant.stderr b/crates/lint/testdata/CouldBeConstant.stderr
new file mode 100644
index 0000000000000..d1009c2454027
--- /dev/null
+++ b/crates/lint/testdata/CouldBeConstant.stderr
@@ -0,0 +1,88 @@
+note[could-be-constant]: state variable could be declared `constant`
+   ╭▸ ROOT/testdata/CouldBeConstant.sol:LL:CC
+   │
+LL │     uint256 public limit = 100;
+   │                    ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/could-be-constant
+
+note[could-be-constant]: state variable could be declared `constant`
+   ╭▸ ROOT/testdata/CouldBeConstant.sol:LL:CC
+   │
+LL │     uint256 internal sum = 1 + 2;
+   │                      ━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/could-be-constant
+
+note[could-be-constant]: state variable could be declared `constant`
+   ╭▸ ROOT/testdata/CouldBeConstant.sol:LL:CC
+   │
+LL │     bytes32 internal salt = keccak256("foundry");
+   │                      ━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/could-be-constant
+
+note[could-be-constant]: state variable could be declared `constant`
+   ╭▸ ROOT/testdata/CouldBeConstant.sol:LL:CC
+   │
+LL │     string internal greeting = "hi";
+   │                     ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/could-be-constant
+
+note[could-be-constant]: state variable could be declared `constant`
+   ╭▸ ROOT/testdata/CouldBeConstant.sol:LL:CC
+   │
+LL │     bytes internal payloadPrefix = hex"0a0b";
+   │                    ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/could-be-constant
+
+note[could-be-constant]: state variable could be declared `constant`
+   ╭▸ ROOT/testdata/CouldBeConstant.sol:LL:CC
+   │
+LL │     uint256 internal derived = ALREADY_CONST + 1;
+   │                      ━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/could-be-constant
+
+note[could-be-constant]: state variable could be declared `constant`
+   ╭▸ ROOT/testdata/CouldBeConstant.sol:LL:CC
+   │
+LL │     IToken internal token = IToken(address(0xCAFE));
+   │                     ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/could-be-constant
+
+note[could-be-constant]: state variable could be declared `constant`
+   ╭▸ ROOT/testdata/CouldBeConstant.sol:LL:CC
+   │
+LL │     address internal nestedCast = address(uint160(0xCAFE));
+   │                      ━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/could-be-constant
+
+note[could-be-constant]: state variable could be declared `constant`
+   ╭▸ ROOT/testdata/CouldBeConstant.sol:LL:CC
+   │
+LL │     uint256 internal maxUint = type(uint256).max;
+   │                      ━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/could-be-constant
+
+note[could-be-constant]: state variable could be declared `constant`
+   ╭▸ ROOT/testdata/CouldBeConstant.sol:LL:CC
+   │
+LL │     int256 internal minInt = type(int256).min;
+   │                     ━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/could-be-constant
+
+note[could-be-constant]: state variable could be declared `constant`
+   ╭▸ ROOT/testdata/CouldBeConstant.sol:LL:CC
+   │
+LL │     bytes4 internal iid = type(IToken).interfaceId;
+   │                     ━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/could-be-constant
+
diff --git a/crates/lint/testdata/CouldBeImmutable.sol b/crates/lint/testdata/CouldBeImmutable.sol
new file mode 100644
index 0000000000000..8b3543246ee4e
--- /dev/null
+++ b/crates/lint/testdata/CouldBeImmutable.sol
@@ -0,0 +1,94 @@
+//@compile-flags: --only-lint could-be-immutable
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.13;
+
+contract CouldBeImmutable {
+    uint256 public constant MAX = 10;
+    uint256 public immutable ALREADY_IMMUTABLE;
+
+    address public owner;
+    address public deployer = msg.sender;
+    uint256 private configured;
+    bytes32 internal salt = keccak256(abi.encodePacked(block.timestamp));
+    CouldBeImmutable private peer;
+
+    uint256 private mutableValue;
+    uint256 private assignedInInternal;
+    uint256 private compileTimeConstant = 1 + 2;
+    string private dynamicValue;
+
+    constructor(uint256 configured_, CouldBeImmutable peer_, string memory value) {
+        ALREADY_IMMUTABLE = configured_;
+        owner = msg.sender;
+        configured = configured_;
+        peer = peer_;
+        mutableValue = 1;
+        setInternal(1);
+        dynamicValue = value;
+    }
+
+    function setMutableValue(uint256 newValue) public {
+        mutableValue = newValue;
+    }
+
+    function setInternal(uint256 newValue) internal {
+        assignedInInternal = newValue;
+    }
+}
+
+contract BaseImmutableCandidate {
+    uint256 internal inheritedBase;
+}
+
+contract DerivedImmutableCandidate is BaseImmutableCandidate {
+    constructor(uint256 value) {
+        inheritedBase = value;
+    }
+}
+
+contract BaseConstructorImmutableCandidate {
+    uint256 internal baseConfigured;
+
+    constructor(uint256 value) {
+        baseConfigured = value;
+    }
+}
+
+contract DerivedConstructorImmutableCandidate is BaseConstructorImmutableCandidate {
+    constructor(uint256 value) BaseConstructorImmutableCandidate(value) {}
+}
+
+contract ModifierBodyWrite {
+    uint256 private fromModifier;
+
+    modifier initializesState() {
+        fromModifier = 1;
+        _;
+    }
+
+    constructor() initializesState() {}
+}
+
+contract CrossInitializerWrite {
+    // `x` has a compile-time-constant initializer and is only written by a sibling
+    // state-var initializer. Such side-effect writes are NOT valid `immutable`
+    // assignments (immutables may only be assigned in their own initializer or in
+    // the constructor body), so `x` must NOT be flagged as could-be-immutable.
+    uint256 internal x = 0;
+    uint256 internal y = (x = 1); //~NOTE: state variable could be declared `immutable`
+}
+
+contract AssemblyWrite {
+    uint256 private fromAssembly;
+
+    constructor() {
+        fromAssembly = 1;
+    }
+
+    function mutate() public {
+        assembly {
+            sstore(0, 2)
+        }
+    }
+}
diff --git a/crates/lint/testdata/CouldBeImmutable.stderr b/crates/lint/testdata/CouldBeImmutable.stderr
new file mode 100644
index 0000000000000..015b2835f98b3
--- /dev/null
+++ b/crates/lint/testdata/CouldBeImmutable.stderr
@@ -0,0 +1,64 @@
+note[could-be-immutable]: state variable could be declared `immutable`
+   ╭▸ ROOT/testdata/CouldBeImmutable.sol:LL:CC
+   │
+LL │     address public owner;
+   │                    ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/could-be-immutable
+
+note[could-be-immutable]: state variable could be declared `immutable`
+   ╭▸ ROOT/testdata/CouldBeImmutable.sol:LL:CC
+   │
+LL │     address public deployer = msg.sender;
+   │                    ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/could-be-immutable
+
+note[could-be-immutable]: state variable could be declared `immutable`
+   ╭▸ ROOT/testdata/CouldBeImmutable.sol:LL:CC
+   │
+LL │     uint256 private configured;
+   │                     ━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/could-be-immutable
+
+note[could-be-immutable]: state variable could be declared `immutable`
+   ╭▸ ROOT/testdata/CouldBeImmutable.sol:LL:CC
+   │
+LL │     bytes32 internal salt = keccak256(abi.encodePacked(block.timestamp));
+   │                      ━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/could-be-immutable
+
+note[could-be-immutable]: state variable could be declared `immutable`
+   ╭▸ ROOT/testdata/CouldBeImmutable.sol:LL:CC
+   │
+LL │     CouldBeImmutable private peer;
+   │                              ━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/could-be-immutable
+
+note[could-be-immutable]: state variable could be declared `immutable`
+   ╭▸ ROOT/testdata/CouldBeImmutable.sol:LL:CC
+   │
+LL │     uint256 internal inheritedBase;
+   │                      ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/could-be-immutable
+
+note[could-be-immutable]: state variable could be declared `immutable`
+   ╭▸ ROOT/testdata/CouldBeImmutable.sol:LL:CC
+   │
+LL │     uint256 internal baseConfigured;
+   │                      ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/could-be-immutable
+
+note[could-be-immutable]: state variable could be declared `immutable`
+   ╭▸ ROOT/testdata/CouldBeImmutable.sol:LL:CC
+   │
+LL │     uint256 internal y = (x = 1);
+   │                      ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/could-be-immutable
+
diff --git a/crates/lint/testdata/CustomErrors.sol b/crates/lint/testdata/CustomErrors.sol
new file mode 100644
index 0000000000000..07b4ca1272cc2
--- /dev/null
+++ b/crates/lint/testdata/CustomErrors.sol
@@ -0,0 +1,41 @@
+//@compile-flags: --severity gas
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+error CustomError();
+error CustomErrorWithArg(uint256 value);
+error CustomErrorWithNamedArgs(uint256 x, string message);
+
+contract CustomErrors {
+    // Require examples
+    function requireWithString(uint256 a, uint256 b) public pure {
+        require(a > 0, "Value must be greater than zero"); //~NOTE: `revert` or `require` call does not use a custom error
+        require(a >= 0 && a <= 100 || b == 50, "Complex condition should be linted"); //~NOTE: `revert` or `require` call does not use a custom error
+    }
+
+    // Revert examples
+    function revertWithString() public pure {
+        revert("Something went wrong"); //~NOTE: `revert` or `require` call does not use a custom error
+        revert(""); //~NOTE: `revert` or `require` call does not use a custom error
+        revert(); //~NOTE: `revert` or `require` call does not use a custom error
+    }
+
+    // Custom error examples
+    function customErrors(uint256 value) public pure {
+        require(value > 0, CustomError());
+        require(value < 100, CustomErrorWithArg(value));
+        require(value > 0); //~NOTE: `revert` or `require` call does not use a custom error
+        revert CustomError();
+        revert CustomErrorWithArg(value);
+        revert CustomErrorWithNamedArgs({x: value, message: "test"});
+    }
+
+    // Test inline disable
+    function testDisableShouldNotLint() public pure {
+        // forge-lint: disable-next-line(custom-errors)
+        require(true, "This should not lint");
+        // forge-lint: disable-next-line(custom-errors)
+        revert("This should not lint");
+    }
+}
diff --git a/crates/lint/testdata/CustomErrors.stderr b/crates/lint/testdata/CustomErrors.stderr
new file mode 100644
index 0000000000000..1faf23fe6fefb
--- /dev/null
+++ b/crates/lint/testdata/CustomErrors.stderr
@@ -0,0 +1,48 @@
+note[custom-errors]: `revert` or `require` call does not use a custom error
+   ╭▸ ROOT/testdata/CustomErrors.sol:LL:CC
+   │
+LL │         require(a > 0, "Value must be greater than zero");
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/custom-errors
+
+note[custom-errors]: `revert` or `require` call does not use a custom error
+   ╭▸ ROOT/testdata/CustomErrors.sol:LL:CC
+   │
+LL │ …     require(a >= 0 && a <= 100 || b == 50, "Complex condition should be linted");
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/custom-errors
+
+note[custom-errors]: `revert` or `require` call does not use a custom error
+   ╭▸ ROOT/testdata/CustomErrors.sol:LL:CC
+   │
+LL │         revert("Something went wrong");
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/custom-errors
+
+note[custom-errors]: `revert` or `require` call does not use a custom error
+   ╭▸ ROOT/testdata/CustomErrors.sol:LL:CC
+   │
+LL │         revert("");
+   │         ━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/custom-errors
+
+note[custom-errors]: `revert` or `require` call does not use a custom error
+   ╭▸ ROOT/testdata/CustomErrors.sol:LL:CC
+   │
+LL │         revert();
+   │         ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/custom-errors
+
+note[custom-errors]: `revert` or `require` call does not use a custom error
+   ╭▸ ROOT/testdata/CustomErrors.sol:LL:CC
+   │
+LL │         require(value > 0);
+   │         ━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/custom-errors
+
diff --git a/crates/lint/testdata/CyclomaticComplexity.sol b/crates/lint/testdata/CyclomaticComplexity.sol
new file mode 100644
index 0000000000000..be2b20edea9c3
--- /dev/null
+++ b/crates/lint/testdata/CyclomaticComplexity.sol
@@ -0,0 +1,208 @@
+//@compile-flags: --only-lint cyclomatic-complexity
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+// Tests for `cyclomatic-complexity`: a function whose cyclomatic complexity is strictly above
+// 11 should be split into smaller functions, matching Slither's threshold. The complexity is
+// one plus the number of decision points: each `if` (loop conditions included, so a
+// condition-less `for (;;)` adds nothing), each ternary, each `catch` clause. Boolean `&&` and
+// `||` operators add nothing, matching the control-flow graph Slither computes on.
+
+contract Complexity {
+    uint256 internal count;
+
+    function ping() external pure returns (uint256) {
+        return 1;
+    }
+
+    // 10 decision points: complexity 11, exactly at the threshold, stays clean.
+    function tenBranches(uint256 x) internal {
+        if (x > 0) count++;
+        if (x > 1) count++;
+        if (x > 2) count++;
+        if (x > 3) count++;
+        if (x > 4) count++;
+        if (x > 5) count++;
+        if (x > 6) count++;
+        if (x > 7) count++;
+        if (x > 8) count++;
+        if (x > 9) count++;
+    }
+
+    // 11 decision points: complexity 12, above the threshold.
+    function elevenBranches(uint256 x) internal { //~NOTE: function has a cyclomatic complexity above 11
+        if (x > 0) count++;
+        if (x > 1) count++;
+        if (x > 2) count++;
+        if (x > 3) count++;
+        if (x > 4) count++;
+        if (x > 5) count++;
+        if (x > 6) count++;
+        if (x > 7) count++;
+        if (x > 8) count++;
+        if (x > 9) count++;
+        if (x > 10) count++;
+    }
+
+    // Mixed forms, 11 decision points: a while, a do-while, a conditioned for (one each
+    // through their condition), a condition-less for (nothing), two ternaries, two catch
+    // clauses and four ifs. Complexity 12, above the threshold.
+    function mixedForms(uint256 x) internal { //~NOTE: function has a cyclomatic complexity above 11
+        uint256 i = 0;
+        while (i < x) {
+            i++;
+        }
+        do {
+            i--;
+        } while (i > 0);
+        for (uint256 j = 0; j < x; j++) {
+            count++;
+        }
+        for (;;) {
+            break;
+        }
+        uint256 a = x > 1 ? 1 : 2;
+        uint256 b = x > 2 ? 3 : 4;
+        try this.ping() returns (uint256 v) {
+            count += v;
+        } catch Error(string memory) {
+            count++;
+        } catch {
+            count--;
+        }
+        if (x > 3) count++;
+        if (x > 4) count++;
+        if (x > 5) count++;
+        if (x > 6) count += a + b;
+    }
+
+    // Boolean short-circuits are not decision points here: three ifs, complexity 4.
+    function shortCircuits(uint256 x, bool y) internal {
+        if (x > 0 && x < 10 && y) count++;
+        if (x > 1 || x < 9 || !y) count++;
+        if ((x > 2 && y) || (x < 8 && !y)) count++;
+    }
+
+    // Eleven real Yul cases without a `default`: 11 decisions, complexity 12, above the
+    // threshold. The `default` clause never counts, so adding one changes nothing.
+    function yulSwitchNoDefault(uint256 x) internal { //~NOTE: function has a cyclomatic complexity above 11
+        uint256 r;
+        assembly {
+            switch x
+            case 0 { r := 1 }
+            case 1 { r := 2 }
+            case 2 { r := 3 }
+            case 3 { r := 4 }
+            case 4 { r := 5 }
+            case 5 { r := 6 }
+            case 6 { r := 7 }
+            case 7 { r := 8 }
+            case 8 { r := 9 }
+            case 9 { r := 10 }
+            case 10 { r := 11 }
+        }
+        count = r;
+    }
+
+    // Ten real Yul cases plus a `default`: 10 decisions, complexity 11, at the threshold,
+    // stays clean. The no-op `default {}` opens no branch of its own.
+    function yulSwitchWithDefault(uint256 x) internal {
+        uint256 r;
+        assembly {
+            switch x
+            case 0 { r := 1 }
+            case 1 { r := 2 }
+            case 2 { r := 3 }
+            case 3 { r := 4 }
+            case 4 { r := 5 }
+            case 5 { r := 6 }
+            case 6 { r := 7 }
+            case 7 { r := 8 }
+            case 8 { r := 9 }
+            case 9 { r := 10 }
+            default { r := 0 }
+        }
+        count = r;
+    }
+
+    // Modifier definitions are out of scope, matching Slither which only iterates declared
+    // and top-level functions: twelve decision points stay clean here.
+    modifier complexModifier(uint256 x) {
+        if (x > 0) count++;
+        if (x > 1) count++;
+        if (x > 2) count++;
+        if (x > 3) count++;
+        if (x > 4) count++;
+        if (x > 5) count++;
+        if (x > 6) count++;
+        if (x > 7) count++;
+        if (x > 8) count++;
+        if (x > 9) count++;
+        if (x > 10) count++;
+        if (x > 11) count++;
+        _;
+    }
+
+    // Decision points in modifier-invocation arguments count toward the function: ten body
+    // ifs plus the ternary in the modifier argument make complexity 12.
+    function modifierArgTernary(uint256 x) internal complexModifier(x == 0 ? 1 : 2) { //~NOTE: function has a cyclomatic complexity above 11
+        if (x > 0) count++;
+        if (x > 1) count++;
+        if (x > 2) count++;
+        if (x > 3) count++;
+        if (x > 4) count++;
+        if (x > 5) count++;
+        if (x > 6) count++;
+        if (x > 7) count++;
+        if (x > 8) count++;
+        if (x > 9) count++;
+    }
+}
+
+contract ComplexityBase {
+    constructor(uint256 v) {}
+}
+
+// Decision points in base-constructor call arguments count toward the constructor: ten body
+// ifs plus the ternary in the base call make complexity 12.
+contract ComplexityDerived is ComplexityBase {
+    uint256 internal count;
+
+    constructor(uint256 x) ComplexityBase(x == 0 ? 1 : 2) { //~NOTE: function has a cyclomatic complexity above 11
+        if (x > 0) count++;
+        if (x > 1) count++;
+        if (x > 2) count++;
+        if (x > 3) count++;
+        if (x > 4) count++;
+        if (x > 5) count++;
+        if (x > 6) count++;
+        if (x > 7) count++;
+        if (x > 8) count++;
+        if (x > 9) count++;
+    }
+}
+
+// Yul helper functions declared inside `assembly {}` are functions of their own, matching
+// Slither: a complex helper reports on its name, and its decisions do not count toward the
+// enclosing function, which stays clean here.
+contract ComplexityYulHelper {
+    function throughHelper(uint256 x) internal pure returns (uint256 r) {
+        assembly {
+            function helper(v) -> o { //~NOTE: function has a cyclomatic complexity above 11
+                if gt(v, 0) { o := add(o, 1) }
+                if gt(v, 1) { o := add(o, 1) }
+                if gt(v, 2) { o := add(o, 1) }
+                if gt(v, 3) { o := add(o, 1) }
+                if gt(v, 4) { o := add(o, 1) }
+                if gt(v, 5) { o := add(o, 1) }
+                if gt(v, 6) { o := add(o, 1) }
+                if gt(v, 7) { o := add(o, 1) }
+                if gt(v, 8) { o := add(o, 1) }
+                if gt(v, 9) { o := add(o, 1) }
+                if gt(v, 10) { o := add(o, 1) }
+                if gt(v, 11) { o := add(o, 1) }
+            }
+            r := helper(x)
+        }
+    }
+}
diff --git a/crates/lint/testdata/CyclomaticComplexity.stderr b/crates/lint/testdata/CyclomaticComplexity.stderr
new file mode 100644
index 0000000000000..6e42e42349d31
--- /dev/null
+++ b/crates/lint/testdata/CyclomaticComplexity.stderr
@@ -0,0 +1,48 @@
+note[cyclomatic-complexity]: function has a cyclomatic complexity above 11
+   ╭▸ ROOT/testdata/CyclomaticComplexity.sol:LL:CC
+   │
+LL │     function elevenBranches(uint256 x) internal {
+   │     ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/cyclomatic-complexity
+
+note[cyclomatic-complexity]: function has a cyclomatic complexity above 11
+   ╭▸ ROOT/testdata/CyclomaticComplexity.sol:LL:CC
+   │
+LL │     function mixedForms(uint256 x) internal {
+   │     ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/cyclomatic-complexity
+
+note[cyclomatic-complexity]: function has a cyclomatic complexity above 11
+   ╭▸ ROOT/testdata/CyclomaticComplexity.sol:LL:CC
+   │
+LL │     function yulSwitchNoDefault(uint256 x) internal {
+   │     ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/cyclomatic-complexity
+
+note[cyclomatic-complexity]: function has a cyclomatic complexity above 11
+   ╭▸ ROOT/testdata/CyclomaticComplexity.sol:LL:CC
+   │
+LL │     function modifierArgTernary(uint256 x) internal complexModifier(x == 0 ? 1 : 2) {
+   │     ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/cyclomatic-complexity
+
+note[cyclomatic-complexity]: function has a cyclomatic complexity above 11
+   ╭▸ ROOT/testdata/CyclomaticComplexity.sol:LL:CC
+   │
+LL │     constructor(uint256 x) ComplexityBase(x == 0 ? 1 : 2) {
+   │     ━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/cyclomatic-complexity
+
+note[cyclomatic-complexity]: function has a cyclomatic complexity above 11
+   ╭▸ ROOT/testdata/CyclomaticComplexity.sol:LL:CC
+   │
+LL │             function helper(v) -> o {
+   │                      ━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/cyclomatic-complexity
+
diff --git a/crates/lint/testdata/DangerousUnaryOperator.sol b/crates/lint/testdata/DangerousUnaryOperator.sol
new file mode 100644
index 0000000000000..73a390981ddc4
--- /dev/null
+++ b/crates/lint/testdata/DangerousUnaryOperator.sol
@@ -0,0 +1,37 @@
+//@compile-flags: --only-lint dangerous-unary-operator
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+// Tests for `dangerous-unary-operator`: an assignment whose `=` is fused to a unary operator
+// (`=-`, `=~`) parses as a plain assignment of a unary expression (`x = -1`), not the compound
+// assignment (`x -= 1`) it resembles. The fused unary may also lead a larger RHS (`x =- a + 1`
+// parses as `x = (-a) + 1`). Spaced unary assignments and real compound operators are left alone.
+// `=+` is not testable: unary `+` is a parse error in modern Solidity.
+
+contract DangerousUnaryOperator {
+    function bad(int256 x, uint256 y)
+        external
+        pure
+        returns (int256 a, uint256 b, int256 c, int256 d, int256 e, int256 f)
+    {
+        a =- 1; //~WARN: unary operator fused to
+        b =~ y; //~WARN: unary operator fused to
+        c =-x; //~WARN: unary operator fused to
+        d =- x + 1; //~WARN: unary operator fused to
+        e =- x * 2; //~WARN: unary operator fused to
+        f /* note */=- 1; //~WARN: unary operator fused to
+    }
+
+    function ok(int256 x, uint256 y)
+        external
+        pure
+        returns (int256 a, uint256 b, int256 c, int256 d, int256 e, int256 f)
+    {
+        a = -1; // spaced negation: intentional
+        b = ~y; // spaced bitwise not: intentional
+        c -= 1; // real compound assignment
+        d = x - 1; // subtraction: the RHS does not start with a unary operator
+        e = -x + 1; // spaced negation leading a binary RHS: intentional, must not flag
+        f =/* note */- 1; // a comment between `=` and the unary breaks the fusion
+    }
+}
diff --git a/crates/lint/testdata/DangerousUnaryOperator.stderr b/crates/lint/testdata/DangerousUnaryOperator.stderr
new file mode 100644
index 0000000000000..9a38dad2283a2
--- /dev/null
+++ b/crates/lint/testdata/DangerousUnaryOperator.stderr
@@ -0,0 +1,48 @@
+warning[dangerous-unary-operator]: unary operator fused to `=`: `x =- 1` parses as `x = -1`, not `x -= 1`
+   ╭▸ ROOT/testdata/DangerousUnaryOperator.sol:LL:CC
+   │
+LL │         a =- 1;
+   │         ━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/dangerous-unary-operator
+
+warning[dangerous-unary-operator]: unary operator fused to `=`: `x =- 1` parses as `x = -1`, not `x -= 1`
+   ╭▸ ROOT/testdata/DangerousUnaryOperator.sol:LL:CC
+   │
+LL │         b =~ y;
+   │         ━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/dangerous-unary-operator
+
+warning[dangerous-unary-operator]: unary operator fused to `=`: `x =- 1` parses as `x = -1`, not `x -= 1`
+   ╭▸ ROOT/testdata/DangerousUnaryOperator.sol:LL:CC
+   │
+LL │         c =-x;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/dangerous-unary-operator
+
+warning[dangerous-unary-operator]: unary operator fused to `=`: `x =- 1` parses as `x = -1`, not `x -= 1`
+   ╭▸ ROOT/testdata/DangerousUnaryOperator.sol:LL:CC
+   │
+LL │         d =- x + 1;
+   │         ━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/dangerous-unary-operator
+
+warning[dangerous-unary-operator]: unary operator fused to `=`: `x =- 1` parses as `x = -1`, not `x -= 1`
+   ╭▸ ROOT/testdata/DangerousUnaryOperator.sol:LL:CC
+   │
+LL │         e =- x * 2;
+   │         ━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/dangerous-unary-operator
+
+warning[dangerous-unary-operator]: unary operator fused to `=`: `x =- 1` parses as `x = -1`, not `x -= 1`
+   ╭▸ ROOT/testdata/DangerousUnaryOperator.sol:LL:CC
+   │
+LL │         f /* note */=- 1;
+   │         ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/dangerous-unary-operator
+
diff --git a/crates/lint/testdata/DelegatecallLoop.sol b/crates/lint/testdata/DelegatecallLoop.sol
new file mode 100644
index 0000000000000..c738ca626c3a9
--- /dev/null
+++ b/crates/lint/testdata/DelegatecallLoop.sol
@@ -0,0 +1,300 @@
+//@compile-flags: --only-lint delegatecall-loop
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+library DelegatecallLoopLib {
+    function helper(bytes calldata payload) internal {
+        address target = address(this);
+        (bool ok,) = target.delegatecall(payload); //~WARN: payable function uses `delegatecall` inside a loop
+        require(ok);
+    }
+}
+
+interface OrdinaryDelegatecall {
+    function delegatecall(bytes calldata payload) external returns (bool);
+}
+
+contract ParentOrdinaryDelegatecall {
+    function delegatecall(bytes calldata) public pure virtual returns (bool) {
+        return true;
+    }
+}
+
+contract ExternalDelegatecallReader {
+    function publicDelegate(bytes calldata payload) public {
+        address target = address(this);
+        (bool ok,) = target.delegatecall(payload);
+        require(ok);
+    }
+}
+
+contract ParentDelegatecallHelper {
+    function superDelegate(bytes calldata payload) internal {
+        address target = address(this);
+        (bool ok,) = target.delegatecall(payload); //~WARN: payable function uses `delegatecall` inside a loop
+        require(ok);
+    }
+}
+
+contract LinearizedSuperBase {
+    function next(bytes calldata) internal virtual {}
+}
+
+contract LinearizedSuperDelegate is LinearizedSuperBase {
+    function next(bytes calldata payload) internal virtual override {
+        address target = address(this);
+        (bool ok,) = target.delegatecall(payload); //~WARN: payable function uses `delegatecall` inside a loop
+        require(ok);
+    }
+}
+
+contract LinearizedSuperCaller is LinearizedSuperBase {
+    function callNext(bytes calldata payload) internal {
+        super.next(payload);
+    }
+}
+
+contract DelegatecallLoop is
+    ParentOrdinaryDelegatecall,
+    ParentDelegatecallHelper,
+    LinearizedSuperDelegate,
+    LinearizedSuperCaller
+{
+    function next(bytes calldata payload) internal override(LinearizedSuperBase, LinearizedSuperDelegate) {
+        super.next(payload);
+    }
+
+    function payableForLoop(bytes[] calldata payloads) external payable {
+        address target = address(this);
+        for (uint256 i = 0; i < payloads.length; ++i) {
+            (bool ok,) = target.delegatecall(payloads[i]); //~WARN: payable function uses `delegatecall` inside a loop
+            require(ok);
+        }
+    }
+
+    function payableWhileLoop(bytes[] calldata payloads) external payable {
+        address target = address(this);
+        uint256 i;
+        while (i < payloads.length) {
+            (bool ok,) = target.delegatecall(payloads[i]); //~WARN: payable function uses `delegatecall` inside a loop
+            require(ok);
+            ++i;
+        }
+    }
+
+    function payableDoWhileLoop(bytes[] calldata payloads) external payable {
+        address target = address(this);
+        uint256 i;
+        if (payloads.length == 0) return;
+        do {
+            (bool ok,) = target.delegatecall(payloads[i]); //~WARN: payable function uses `delegatecall` inside a loop
+            require(ok);
+            ++i;
+        } while (i < payloads.length);
+    }
+
+    function payableNestedDelegatecall(bytes[] calldata payloads) external payable {
+        address target = address(this);
+        for (uint256 i = 0; i < payloads.length; ++i) {
+            if (payloads[i].length != 0) {
+                (bool ok,) = target.delegatecall(payloads[i]); //~WARN: payable function uses `delegatecall` inside a loop
+                require(ok);
+            }
+        }
+    }
+
+    function payableForUpdateExpression(bytes[] calldata payloads) external payable {
+        address target = address(this);
+        bool ok = true;
+        for (
+            uint256 i = 0;
+            i < payloads.length;
+            (ok,) = target.delegatecall(payloads[i++]) //~WARN: payable function uses `delegatecall` inside a loop
+        ) {}
+        require(ok);
+    }
+
+    modifier loopDelegatecall(bytes[] calldata payloads) {
+        address target = address(this);
+        for (uint256 i = 0; i < payloads.length; ++i) {
+            (bool ok,) = target.delegatecall(payloads[i]); //~WARN: payable function uses `delegatecall` inside a loop
+            require(ok);
+        }
+        _;
+    }
+
+    function payableModifierLoop(bytes[] calldata payloads) external payable loopDelegatecall(payloads) {}
+
+    modifier loopPlaceholder(uint256 iterations) {
+        for (uint256 i = 0; i < iterations; ++i) {
+            _;
+        }
+    }
+
+    function payableModifierLoopPlaceholder(bytes calldata payload) external payable loopPlaceholder(3) {
+        address target = address(this);
+        (bool ok,) = target.delegatecall(payload); //~WARN: payable function uses `delegatecall` inside a loop
+        require(ok);
+    }
+
+    function payableLoopWithInternalDelegatecall(bytes[] calldata payloads) external payable {
+        for (uint256 i = 0; i < payloads.length; ++i) {
+            delegate(payloads[i]);
+        }
+    }
+
+    function delegate(bytes calldata payload) internal {
+        address target = address(this);
+        (bool ok,) = target.delegatecall(payload); //~WARN: payable function uses `delegatecall` inside a loop
+        require(ok);
+    }
+
+    function payableInternalLoopWithDelegatecall(bytes[] calldata payloads) external payable {
+        delegateInLoop(payloads);
+    }
+
+    function delegateInLoop(bytes[] calldata payloads) internal {
+        address target = address(this);
+        for (uint256 i = 0; i < payloads.length; ++i) {
+            (bool ok,) = target.delegatecall(payloads[i]); //~WARN: payable function uses `delegatecall` inside a loop
+            require(ok);
+        }
+    }
+
+    function payableInternalDelegatecallOutsideLoop(bytes calldata payload) external payable {
+        delegate(payload);
+    }
+
+    function payableLoopWithPublicDelegatecall(bytes[] calldata payloads) external payable {
+        for (uint256 i = 0; i < payloads.length; ++i) {
+            publicDelegate(payloads[i]);
+        }
+    }
+
+    function payableLoopWithExternalDelegatecallReader(
+        ExternalDelegatecallReader reader,
+        bytes[] calldata payloads
+    ) external payable {
+        for (uint256 i = 0; i < payloads.length; ++i) {
+            reader.publicDelegate(payloads[i]);
+        }
+    }
+
+    function publicDelegate(bytes calldata payload) public {
+        address target = address(this);
+        (bool ok,) = target.delegatecall(payload); //~WARN: payable function uses `delegatecall` inside a loop
+        require(ok);
+    }
+
+    function payableLoopWithSuperInternalDelegatecall(bytes[] calldata payloads) external payable {
+        for (uint256 i = 0; i < payloads.length; ++i) {
+            super.superDelegate(payloads[i]);
+        }
+    }
+
+    function payableLoopWithLinearizedSuperDelegatecall(bytes[] calldata payloads) external payable {
+        for (uint256 i = 0; i < payloads.length; ++i) {
+            callNext(payloads[i]);
+        }
+    }
+
+    function payableLoopWithCallAndStaticcall(bytes[] calldata payloads) external payable {
+        address target = address(this);
+        for (uint256 i = 0; i < payloads.length; ++i) {
+            (bool callOk,) = target.call(payloads[i]);
+            (bool staticcallOk,) = target.staticcall(payloads[i]);
+            require(callOk && staticcallOk);
+        }
+    }
+
+    function payableDelegatecallOutsideLoop(bytes calldata payload) external payable {
+        address target = address(this);
+        (bool ok,) = target.delegatecall(payload);
+        require(ok);
+    }
+
+    function payableLoopWithInternalLibraryDelegatecall(bytes[] calldata payloads) external payable {
+        for (uint256 i = 0; i < payloads.length; ++i) {
+            DelegatecallLoopLib.helper(payloads[i]);
+        }
+    }
+
+    function payableLoopCallsSafeOverload(uint256[] calldata values) external payable {
+        for (uint256 i = 0; i < values.length; ++i) {
+            overloaded(values[i]);
+        }
+    }
+
+    function overloaded(uint256 value) internal pure returns (uint256) {
+        return value;
+    }
+
+    function overloaded(bytes calldata payload) internal {
+        address target = address(this);
+        (bool ok,) = target.delegatecall(payload);
+        require(ok);
+    }
+
+    function payableLoopCallsOrdinaryDelegatecall(
+        OrdinaryDelegatecall callee,
+        bytes[] calldata payloads
+    ) external payable {
+        for (uint256 i = 0; i < payloads.length; ++i) {
+            require(callee.delegatecall(payloads[i]));
+        }
+    }
+
+    function payableLoopCallsThisDelegatecall(bytes[] calldata payloads) external payable {
+        for (uint256 i = 0; i < payloads.length; ++i) {
+            require(this.delegatecall(payloads[i]));
+        }
+    }
+
+    function payableLoopCallsSuperDelegatecall(bytes[] calldata payloads) external payable {
+        for (uint256 i = 0; i < payloads.length; ++i) {
+            require(super.delegatecall(payloads[i]));
+        }
+    }
+
+    function payableLoopCallsConditionalDelegatecall(bytes[] calldata payloads, bool flag) external payable {
+        address targetA = address(this);
+        address targetB = address(0xBEEF);
+        for (uint256 i = 0; i < payloads.length; ++i) {
+            (bool ok,) = (flag ? targetA : targetB).delegatecall(payloads[i]); //~WARN: payable function uses `delegatecall` inside a loop
+            require(ok);
+        }
+    }
+
+    function payableLoopCallsConditionalDelegatecallWithBinaryCondition(bytes[] calldata payloads) external payable {
+        address targetA = address(this);
+        address targetB = address(0xBEEF);
+        for (uint256 i = 0; i < payloads.length; ++i) {
+            (bool ok,) = (payloads[i].length != 0 ? targetA : targetB).delegatecall(payloads[i]); //~WARN: payable function uses `delegatecall` inside a loop
+            require(ok);
+        }
+    }
+
+    function payableLoopCallsConditionalOrdinaryDelegatecall(
+        OrdinaryDelegatecall a,
+        OrdinaryDelegatecall b,
+        bytes[] calldata payloads,
+        bool flag
+    ) external payable {
+        for (uint256 i = 0; i < payloads.length; ++i) {
+            require((flag ? a : b).delegatecall(payloads[i]));
+        }
+    }
+
+    function delegatecall(bytes calldata) public pure override returns (bool) {
+        return true;
+    }
+
+    function nonPayableLoop(bytes[] calldata payloads) external {
+        address target = address(this);
+        for (uint256 i = 0; i < payloads.length; ++i) {
+            (bool ok,) = target.delegatecall(payloads[i]);
+            require(ok);
+        }
+    }
+}
diff --git a/crates/lint/testdata/DelegatecallLoop.stderr b/crates/lint/testdata/DelegatecallLoop.stderr
new file mode 100644
index 0000000000000..f5a16fa52ebb1
--- /dev/null
+++ b/crates/lint/testdata/DelegatecallLoop.stderr
@@ -0,0 +1,120 @@
+warning[delegatecall-loop]: payable function uses `delegatecall` inside a loop
+   ╭▸ ROOT/testdata/DelegatecallLoop.sol:LL:CC
+   │
+LL │             (bool ok,) = target.delegatecall(payloads[i]);
+   │                          ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/delegatecall-loop
+
+warning[delegatecall-loop]: payable function uses `delegatecall` inside a loop
+   ╭▸ ROOT/testdata/DelegatecallLoop.sol:LL:CC
+   │
+LL │             (bool ok,) = target.delegatecall(payloads[i]);
+   │                          ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/delegatecall-loop
+
+warning[delegatecall-loop]: payable function uses `delegatecall` inside a loop
+   ╭▸ ROOT/testdata/DelegatecallLoop.sol:LL:CC
+   │
+LL │             (bool ok,) = target.delegatecall(payloads[i]);
+   │                          ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/delegatecall-loop
+
+warning[delegatecall-loop]: payable function uses `delegatecall` inside a loop
+   ╭▸ ROOT/testdata/DelegatecallLoop.sol:LL:CC
+   │
+LL │                 (bool ok,) = target.delegatecall(payloads[i]);
+   │                              ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/delegatecall-loop
+
+warning[delegatecall-loop]: payable function uses `delegatecall` inside a loop
+   ╭▸ ROOT/testdata/DelegatecallLoop.sol:LL:CC
+   │
+LL │             (ok,) = target.delegatecall(payloads[i++])
+   │                     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/delegatecall-loop
+
+warning[delegatecall-loop]: payable function uses `delegatecall` inside a loop
+   ╭▸ ROOT/testdata/DelegatecallLoop.sol:LL:CC
+   │
+LL │             (bool ok,) = target.delegatecall(payloads[i]);
+   │                          ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/delegatecall-loop
+
+warning[delegatecall-loop]: payable function uses `delegatecall` inside a loop
+   ╭▸ ROOT/testdata/DelegatecallLoop.sol:LL:CC
+   │
+LL │         (bool ok,) = target.delegatecall(payload);
+   │                      ━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/delegatecall-loop
+
+warning[delegatecall-loop]: payable function uses `delegatecall` inside a loop
+   ╭▸ ROOT/testdata/DelegatecallLoop.sol:LL:CC
+   │
+LL │         (bool ok,) = target.delegatecall(payload);
+   │                      ━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/delegatecall-loop
+
+warning[delegatecall-loop]: payable function uses `delegatecall` inside a loop
+   ╭▸ ROOT/testdata/DelegatecallLoop.sol:LL:CC
+   │
+LL │             (bool ok,) = target.delegatecall(payloads[i]);
+   │                          ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/delegatecall-loop
+
+warning[delegatecall-loop]: payable function uses `delegatecall` inside a loop
+   ╭▸ ROOT/testdata/DelegatecallLoop.sol:LL:CC
+   │
+LL │         (bool ok,) = target.delegatecall(payload);
+   │                      ━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/delegatecall-loop
+
+warning[delegatecall-loop]: payable function uses `delegatecall` inside a loop
+   ╭▸ ROOT/testdata/DelegatecallLoop.sol:LL:CC
+   │
+LL │         (bool ok,) = target.delegatecall(payload);
+   │                      ━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/delegatecall-loop
+
+warning[delegatecall-loop]: payable function uses `delegatecall` inside a loop
+   ╭▸ ROOT/testdata/DelegatecallLoop.sol:LL:CC
+   │
+LL │         (bool ok,) = target.delegatecall(payload);
+   │                      ━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/delegatecall-loop
+
+warning[delegatecall-loop]: payable function uses `delegatecall` inside a loop
+   ╭▸ ROOT/testdata/DelegatecallLoop.sol:LL:CC
+   │
+LL │         (bool ok,) = target.delegatecall(payload);
+   │                      ━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/delegatecall-loop
+
+warning[delegatecall-loop]: payable function uses `delegatecall` inside a loop
+   ╭▸ ROOT/testdata/DelegatecallLoop.sol:LL:CC
+   │
+LL │ …     (bool ok,) = (flag ? targetA : targetB).delegatecall(payloads[i]);
+   │                    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/delegatecall-loop
+
+warning[delegatecall-loop]: payable function uses `delegatecall` inside a loop
+   ╭▸ ROOT/testdata/DelegatecallLoop.sol:LL:CC
+   │
+LL │ …     (bool ok,) = (payloads[i].length != 0 ? targetA : targetB).delegatecall(payloads[i]);
+   │                    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/delegatecall-loop
+
diff --git a/crates/lint/testdata/DeprecatedOzFunction.sol b/crates/lint/testdata/DeprecatedOzFunction.sol
new file mode 100644
index 0000000000000..e5c0bfbfa6b18
--- /dev/null
+++ b/crates/lint/testdata/DeprecatedOzFunction.sol
@@ -0,0 +1,198 @@
+//@compile-flags: --only-lint deprecated-oz-function
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+import {
+    IERC20,
+    SafeERC20,
+    SafeERC20Upgradeable,
+    AccessControl,
+    AccessControlUpgradeable
+} from "./auxiliary/openzeppelin-contracts/OzMocks.sol";
+import {SafeERC20 as S, IERC20Aux} from "./auxiliary/openzeppelin-contracts/SafeERC20Lib.sol";
+import {
+    SafeERC20 as LocalSafeERC20,
+    AccessControl as LocalAccessControl
+} from "./auxiliary/LocalOzNames.sol";
+import {
+    SafeERC20 as NotOzSafeERC20,
+    AccessControl as NotOzAccessControl
+} from "./auxiliary/not-openzeppelin/OzMocks.sol";
+
+// Tests for `deprecated-oz-function`: OpenZeppelin deprecated `SafeERC20.safeApprove` (replaced
+// by `safeIncreaseAllowance` / `forceApprove`) and `AccessControl._setupRole` (replaced by
+// `_grantRole`). A reference is flagged when it resolves to a function with that name declared
+// in the canonical OZ library or contract (or their upgradeable variants), wherever it sits in
+// the inheritance chain and whatever the call form. The declaration must come from an
+// OpenZeppelin package path: the replacements, same-name functions declared elsewhere and
+// same-name local declarations stay clean.
+
+// A user library with the same function name: resolution keeps it out of scope.
+library TokenUtils {
+    function safeApprove(IERC20 token, address spender, uint256 value) internal {
+        token.approve(spender, value);
+    }
+}
+
+// An extension inheriting the deprecated function without redeclaring it.
+contract AccessControlEnumerable is AccessControl {}
+
+contract UsesSafeApprove {
+    using SafeERC20 for IERC20;
+
+    IERC20 internal token;
+
+    function viaUsingFor(address spender, uint256 amount) internal {
+        token.safeApprove(spender, amount); //~WARN: this OpenZeppelin function is deprecated
+    }
+
+    function viaQualified(address spender, uint256 amount) internal {
+        SafeERC20.safeApprove(token, spender, amount); //~WARN: this OpenZeppelin function is deprecated
+    }
+
+    function replacementsAreClean(address spender, uint256 amount) internal {
+        token.safeIncreaseAllowance(spender, amount);
+        SafeERC20.forceApprove(token, spender, amount);
+    }
+}
+
+contract UsesAliasedImport {
+    IERC20Aux internal token;
+
+    // The import alias renames the call site, not the declared library the call resolves to.
+    function viaAlias(address spender, uint256 amount) internal {
+        S.safeApprove(token, spender, amount); //~WARN: this OpenZeppelin function is deprecated
+    }
+}
+
+contract UsesUpgradeable {
+    using SafeERC20Upgradeable for IERC20;
+
+    IERC20 internal token;
+
+    function viaUpgradeable(address spender, uint256 amount) internal {
+        token.safeApprove(spender, amount); //~WARN: this OpenZeppelin function is deprecated
+    }
+}
+
+// The same function name in a user library: out of scope.
+contract UsesTokenUtils {
+    using TokenUtils for IERC20;
+
+    IERC20 internal token;
+
+    function approveIt(address spender, uint256 amount) internal {
+        token.safeApprove(spender, amount);
+    }
+}
+
+// A local library reusing the exact `SafeERC20` name fails the provenance check: its
+// declaration does not come from an OpenZeppelin package path.
+contract UsesLocalSafeERC20 {
+    using LocalSafeERC20 for IERC20;
+
+    IERC20 internal token;
+
+    function approveIt(address spender, uint256 amount) internal {
+        token.safeApprove(spender, amount);
+    }
+}
+
+contract Roles is AccessControl {
+    constructor(address admin) {
+        _setupRole(bytes32(0), admin); //~WARN: this OpenZeppelin function is deprecated
+    }
+
+    function grant(bytes32 role, address account) internal {
+        _grantRole(role, account);
+    }
+
+    function grantQualified(bytes32 role, address account) internal {
+        AccessControl._setupRole(role, account); //~WARN: this OpenZeppelin function is deprecated
+    }
+}
+
+// The deprecated function is two levels up, through an extension that does not redeclare it.
+contract EnumRoles is AccessControlEnumerable {
+    function setup(bytes32 role, address account) internal {
+        _setupRole(role, account); //~WARN: this OpenZeppelin function is deprecated
+    }
+}
+
+// An override delegating through `super` still uses the deprecated API, and the local
+// override is the dispatch target of plain calls, so only the `super` call reports.
+contract CustomRoles is AccessControl {
+    function _setupRole(bytes32 role, address account) internal override {
+        super._setupRole(role, account); //~WARN: this OpenZeppelin function is deprecated
+    }
+
+    function setup(bytes32 role, address account) internal {
+        _setupRole(role, account);
+    }
+}
+
+contract UpgradeableRoles is AccessControlUpgradeable {
+    function setup(bytes32 role, address account) internal {
+        _setupRole(role, account); //~WARN: this OpenZeppelin function is deprecated
+    }
+}
+
+// A local contract reusing the exact `AccessControl` name fails the provenance check.
+contract LocalRoles is LocalAccessControl {
+    function setup(bytes32 role, address account) internal {
+        _setupRole(role, account);
+    }
+}
+
+// A reference used as a value is a use of the deprecated function too.
+contract RefUser {
+    function pick() internal pure returns (function(IERC20, address, uint256) internal) {
+        return SafeERC20.safeApprove; //~WARN: this OpenZeppelin function is deprecated
+    }
+}
+
+// A standalone `_setupRole` outside the canonical contracts: out of scope.
+contract Standalone {
+    mapping(bytes32 => mapping(address => bool)) internal _roles;
+
+    function _setupRole(bytes32 role, address account) internal {
+        _roles[role][account] = true;
+    }
+
+    function setup(bytes32 role, address account) internal {
+        _setupRole(role, account);
+    }
+}
+
+// The same OpenZeppelin names under a `not-openzeppelin/` path: the substring "openzeppelin"
+// is in the path but is not one of its components, so the provenance check keeps them out.
+contract UsesNotOzNames is NotOzAccessControl {
+    using NotOzSafeERC20 for IERC20;
+
+    IERC20 internal token;
+
+    function approveIt(address spender, uint256 amount) internal {
+        token.safeApprove(spender, amount);
+    }
+
+    function setup(bytes32 role, address account) internal {
+        _setupRole(role, account);
+    }
+}
+
+// A deprecated function referenced as a value in a modifier argument sits in the function
+// header, outside the body: the reference is caught there too, not only inside the body.
+contract ModifierArgRef {
+    modifier runs(function(IERC20, address, uint256) internal callback) {
+        _;
+    }
+
+    IERC20 internal token;
+
+    function act(address spender, uint256 amount)
+        internal
+        runs(SafeERC20.safeApprove) //~WARN: this OpenZeppelin function is deprecated
+    {
+        token.approve(spender, amount);
+    }
+}
diff --git a/crates/lint/testdata/DeprecatedOzFunction.stderr b/crates/lint/testdata/DeprecatedOzFunction.stderr
new file mode 100644
index 0000000000000..cc1f403bf4eda
--- /dev/null
+++ b/crates/lint/testdata/DeprecatedOzFunction.stderr
@@ -0,0 +1,88 @@
+warning[deprecated-oz-function]: this OpenZeppelin function is deprecated
+   ╭▸ ROOT/testdata/DeprecatedOzFunction.sol:LL:CC
+   │
+LL │         token.safeApprove(spender, amount);
+   │         ━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/deprecated-oz-function
+
+warning[deprecated-oz-function]: this OpenZeppelin function is deprecated
+   ╭▸ ROOT/testdata/DeprecatedOzFunction.sol:LL:CC
+   │
+LL │         SafeERC20.safeApprove(token, spender, amount);
+   │         ━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/deprecated-oz-function
+
+warning[deprecated-oz-function]: this OpenZeppelin function is deprecated
+   ╭▸ ROOT/testdata/DeprecatedOzFunction.sol:LL:CC
+   │
+LL │         S.safeApprove(token, spender, amount);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/deprecated-oz-function
+
+warning[deprecated-oz-function]: this OpenZeppelin function is deprecated
+   ╭▸ ROOT/testdata/DeprecatedOzFunction.sol:LL:CC
+   │
+LL │         token.safeApprove(spender, amount);
+   │         ━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/deprecated-oz-function
+
+warning[deprecated-oz-function]: this OpenZeppelin function is deprecated
+   ╭▸ ROOT/testdata/DeprecatedOzFunction.sol:LL:CC
+   │
+LL │         _setupRole(bytes32(0), admin);
+   │         ━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/deprecated-oz-function
+
+warning[deprecated-oz-function]: this OpenZeppelin function is deprecated
+   ╭▸ ROOT/testdata/DeprecatedOzFunction.sol:LL:CC
+   │
+LL │         AccessControl._setupRole(role, account);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/deprecated-oz-function
+
+warning[deprecated-oz-function]: this OpenZeppelin function is deprecated
+   ╭▸ ROOT/testdata/DeprecatedOzFunction.sol:LL:CC
+   │
+LL │         _setupRole(role, account);
+   │         ━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/deprecated-oz-function
+
+warning[deprecated-oz-function]: this OpenZeppelin function is deprecated
+   ╭▸ ROOT/testdata/DeprecatedOzFunction.sol:LL:CC
+   │
+LL │         super._setupRole(role, account);
+   │         ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/deprecated-oz-function
+
+warning[deprecated-oz-function]: this OpenZeppelin function is deprecated
+   ╭▸ ROOT/testdata/DeprecatedOzFunction.sol:LL:CC
+   │
+LL │         _setupRole(role, account);
+   │         ━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/deprecated-oz-function
+
+warning[deprecated-oz-function]: this OpenZeppelin function is deprecated
+   ╭▸ ROOT/testdata/DeprecatedOzFunction.sol:LL:CC
+   │
+LL │         return SafeERC20.safeApprove;
+   │                ━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/deprecated-oz-function
+
+warning[deprecated-oz-function]: this OpenZeppelin function is deprecated
+   ╭▸ ROOT/testdata/DeprecatedOzFunction.sol:LL:CC
+   │
+LL │         runs(SafeERC20.safeApprove)
+   │              ━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/deprecated-oz-function
+
diff --git a/crates/lint/testdata/DivideBeforeMultiply.sol b/crates/lint/testdata/DivideBeforeMultiply.sol
index 694558bfca363..464bafa1a4591 100644
--- a/crates/lint/testdata/DivideBeforeMultiply.sol
+++ b/crates/lint/testdata/DivideBeforeMultiply.sol
@@ -1,18 +1,141 @@
+//@compile-flags: --only-lint divide-before-multiply custom-errors inline-assembly
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
 contract DivideBeforeMultiply {
     function arithmetic() public {
-        (1 / 2) * 3; //~WARN: multiplication should occur before division to avoid loss of precision
+        (1 / 2) * 3; //~WARN: division before multiplication may lose precision
+        3 * (1 / 2); //~WARN: division before multiplication may lose precision
+        4 * ((1 + 2) / 3); //~WARN: division before multiplication may lose precision
         (1 * 2) / 3;
-        ((1 / 2) * 3) * 4; //~WARN: multiplication should occur before division to avoid loss of precision
-        ((1 * 2) / 3) * 4; //~WARN: multiplication should occur before division to avoid loss of precision
-        (1 / 2 / 3) * 4; //~WARN: multiplication should occur before division to avoid loss of precision
-        (1 / (2 + 3)) * 4; //~WARN: multiplication should occur before division to avoid loss of precision
+        ((1 / 2) * 3) * 4; //~WARN: division before multiplication may lose precision
+        ((1 * 2) / 3) * 4; //~WARN: division before multiplication may lose precision
+        (1 / 2 / 3) * 4; //~WARN: division before multiplication may lose precision
+        (1 / (2 + 3)) * 4; //~WARN: division before multiplication may lose precision
         (1 / 2 + 3) * 4;
         (1 / 2 - 3) * 4;
         (1 + 2 / 3) * 4;
         (1 / 2 - 3) * 4;
         ((1 / 2) % 3) * 4;
         1 / (2 * 3 + 3);
-        1 / ((2 / 3) * 3); //~WARN: multiplication should occur before division to avoid loss of precision
+        1 / ((2 / 3) * 3); //~WARN: division before multiplication may lose precision
         1 / ((2 * 3) + 3);
     }
+
+    function assigned(uint256 a, uint256 b, uint256 c) public pure returns (uint256 result) {
+        uint256 q = a / b;
+        result = q * c; //~WARN: division before multiplication may lose precision
+
+        q = a + b;
+        result = q * c;
+    }
+
+    function propagated(uint256 a, uint256 b, uint256 c) public pure returns (uint256) {
+        uint256 q = a / b;
+        uint256 copy = q;
+        return c * copy; //~WARN: division before multiplication may lose precision
+    }
+
+    function branchPropagated(uint256 a, uint256 b, uint256 c, bool condition)
+        public
+        pure
+        returns (uint256 q)
+    {
+        if (condition) {
+            q = a / b;
+        }
+        return q * c; //~WARN: division before multiplication may lose precision
+    }
+
+    function loopPropagated(uint256 a, uint256 b, uint256 c) public pure returns (uint256 q) {
+        for (uint256 i = 0; i < 1; ++i) {
+            q = a / b;
+        }
+        return q * c; //~WARN: division before multiplication may lose precision
+    }
+
+    function returningBranchDoesNotLeak(uint256 a, uint256 b, uint256 c, bool condition)
+        public
+        pure
+        returns (uint256 q)
+    {
+        if (condition) {
+            q = a / b;
+            return q;
+        }
+        return q * c;
+    }
+
+    function revertingBranchDoesNotLeak(uint256 a, uint256 b, uint256 c, bool condition)
+        public
+        pure
+        returns (uint256 q)
+    {
+        if (condition) {
+            q = a / b;
+            revert("done");
+        }
+        return q * c;
+    }
+
+    function compound(uint256 a, uint256 b, uint256 c) public pure returns (uint256 q) {
+        q = a / b;
+        q *= c; //~WARN: division before multiplication may lose precision
+
+        q = a + b;
+        q *= c;
+
+        q = a;
+        q /= b;
+        q *= c; //~WARN: division before multiplication may lose precision
+    }
+
+    function compoundClearsTaint(uint256 a, uint256 b, uint256 c) public pure returns (uint256 q) {
+        q = a / b;
+        q += 1;
+        return q * c;
+    }
+
+    function incrementClearsTaint(uint256 a, uint256 b, uint256 c) public pure returns (uint256 q) {
+        q = a / b;
+        q++;
+        return q * c;
+    }
+
+    function tupleElementWise(uint256 a, uint256 b, uint256 c)
+        public
+        pure
+        returns (uint256 x, uint256 y)
+    {
+        (x, y) = (a / b, c);
+        x = x * c; //~WARN: division before multiplication may lose precision
+        y = y * c;
+    }
+
+    function noBroadRhsTaint(uint256 a, uint256 b, uint256 c) public pure returns (uint256) {
+        uint256 q = (a / b) + 1;
+        uint256 z = helper(a / b);
+        return (q * c) + (z * c) + helper(q);
+    }
+
+    function helper(uint256 value) internal pure returns (uint256) {
+        return value;
+    }
+
+    function yulDirect(uint256 a, uint256 b, uint256 c) public pure returns (uint256 result) {
+        assembly {
+            result := mul(div(a, b), c) //~WARN: division before multiplication may lose precision
+            result := mul(c, sdiv(a, b)) //~WARN: division before multiplication may lose precision
+        }
+    }
+
+    function yulAssigned(uint256 a, uint256 b, uint256 c) public pure returns (uint256 result) {
+        assembly {
+            let q := div(a, b)
+            result := mul(q, c) //~WARN: division before multiplication may lose precision
+
+            q := add(a, b)
+            result := mul(q, c)
+        }
+    }
 }
diff --git a/crates/lint/testdata/DivideBeforeMultiply.stderr b/crates/lint/testdata/DivideBeforeMultiply.stderr
index 1c98f4b13d194..cc0840e4334c5 100644
--- a/crates/lint/testdata/DivideBeforeMultiply.stderr
+++ b/crates/lint/testdata/DivideBeforeMultiply.stderr
@@ -1,48 +1,168 @@
-warning[divide-before-multiply]: multiplication should occur before division to avoid loss of precision
- --> ROOT/testdata/DivideBeforeMultiply.sol:LL:CC
-  |
-3 |         (1 / 2) * 3;
-  |         -----------
-  |
-  = help: https://book.getfoundry.sh/reference/forge/forge-lint#divide-before-multiply
-
-warning[divide-before-multiply]: multiplication should occur before division to avoid loss of precision
- --> ROOT/testdata/DivideBeforeMultiply.sol:LL:CC
-  |
-5 |         ((1 / 2) * 3) * 4;
-  |          -----------
-  |
-  = help: https://book.getfoundry.sh/reference/forge/forge-lint#divide-before-multiply
-
-warning[divide-before-multiply]: multiplication should occur before division to avoid loss of precision
- --> ROOT/testdata/DivideBeforeMultiply.sol:LL:CC
-  |
-6 |         ((1 * 2) / 3) * 4;
-  |         -----------------
-  |
-  = help: https://book.getfoundry.sh/reference/forge/forge-lint#divide-before-multiply
-
-warning[divide-before-multiply]: multiplication should occur before division to avoid loss of precision
- --> ROOT/testdata/DivideBeforeMultiply.sol:LL:CC
-  |
-7 |         (1 / 2 / 3) * 4;
-  |         ---------------
-  |
-  = help: https://book.getfoundry.sh/reference/forge/forge-lint#divide-before-multiply
-
-warning[divide-before-multiply]: multiplication should occur before division to avoid loss of precision
- --> ROOT/testdata/DivideBeforeMultiply.sol:LL:CC
-  |
-8 |         (1 / (2 + 3)) * 4;
-  |         -----------------
-  |
-  = help: https://book.getfoundry.sh/reference/forge/forge-lint#divide-before-multiply
-
-warning[divide-before-multiply]: multiplication should occur before division to avoid loss of precision
-  --> ROOT/testdata/DivideBeforeMultiply.sol:LL:CC
-   |
-15 |         1 / ((2 / 3) * 3);
-   |              -----------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#divide-before-multiply
+note[custom-errors]: `revert` or `require` call does not use a custom error
+   ╭▸ ROOT/testdata/DivideBeforeMultiply.sol:LL:CC
+   │
+LL │             revert("done");
+   │             ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/custom-errors
+
+note[inline-assembly]: inline assembly used
+   ╭▸ ROOT/testdata/DivideBeforeMultiply.sol:LL:CC
+   │
+LL │         assembly {
+   │         ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/inline-assembly
+
+note[inline-assembly]: inline assembly used
+   ╭▸ ROOT/testdata/DivideBeforeMultiply.sol:LL:CC
+   │
+LL │         assembly {
+   │         ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/inline-assembly
+
+warning[divide-before-multiply]: division before multiplication may lose precision
+   ╭▸ ROOT/testdata/DivideBeforeMultiply.sol:LL:CC
+   │
+LL │         (1 / 2) * 3;
+   │         ━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/divide-before-multiply
+
+warning[divide-before-multiply]: division before multiplication may lose precision
+   ╭▸ ROOT/testdata/DivideBeforeMultiply.sol:LL:CC
+   │
+LL │         3 * (1 / 2);
+   │         ━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/divide-before-multiply
+
+warning[divide-before-multiply]: division before multiplication may lose precision
+   ╭▸ ROOT/testdata/DivideBeforeMultiply.sol:LL:CC
+   │
+LL │         4 * ((1 + 2) / 3);
+   │         ━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/divide-before-multiply
+
+warning[divide-before-multiply]: division before multiplication may lose precision
+   ╭▸ ROOT/testdata/DivideBeforeMultiply.sol:LL:CC
+   │
+LL │         ((1 / 2) * 3) * 4;
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/divide-before-multiply
+
+warning[divide-before-multiply]: division before multiplication may lose precision
+   ╭▸ ROOT/testdata/DivideBeforeMultiply.sol:LL:CC
+   │
+LL │         ((1 * 2) / 3) * 4;
+   │         ━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/divide-before-multiply
+
+warning[divide-before-multiply]: division before multiplication may lose precision
+   ╭▸ ROOT/testdata/DivideBeforeMultiply.sol:LL:CC
+   │
+LL │         (1 / 2 / 3) * 4;
+   │         ━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/divide-before-multiply
+
+warning[divide-before-multiply]: division before multiplication may lose precision
+   ╭▸ ROOT/testdata/DivideBeforeMultiply.sol:LL:CC
+   │
+LL │         (1 / (2 + 3)) * 4;
+   │         ━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/divide-before-multiply
+
+warning[divide-before-multiply]: division before multiplication may lose precision
+   ╭▸ ROOT/testdata/DivideBeforeMultiply.sol:LL:CC
+   │
+LL │         1 / ((2 / 3) * 3);
+   │             ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/divide-before-multiply
+
+warning[divide-before-multiply]: division before multiplication may lose precision
+   ╭▸ ROOT/testdata/DivideBeforeMultiply.sol:LL:CC
+   │
+LL │         result = q * c;
+   │                  ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/divide-before-multiply
+
+warning[divide-before-multiply]: division before multiplication may lose precision
+   ╭▸ ROOT/testdata/DivideBeforeMultiply.sol:LL:CC
+   │
+LL │         return c * copy;
+   │                ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/divide-before-multiply
+
+warning[divide-before-multiply]: division before multiplication may lose precision
+   ╭▸ ROOT/testdata/DivideBeforeMultiply.sol:LL:CC
+   │
+LL │         return q * c;
+   │                ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/divide-before-multiply
+
+warning[divide-before-multiply]: division before multiplication may lose precision
+   ╭▸ ROOT/testdata/DivideBeforeMultiply.sol:LL:CC
+   │
+LL │         return q * c;
+   │                ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/divide-before-multiply
+
+warning[divide-before-multiply]: division before multiplication may lose precision
+   ╭▸ ROOT/testdata/DivideBeforeMultiply.sol:LL:CC
+   │
+LL │         q *= c;
+   │         ━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/divide-before-multiply
+
+warning[divide-before-multiply]: division before multiplication may lose precision
+   ╭▸ ROOT/testdata/DivideBeforeMultiply.sol:LL:CC
+   │
+LL │         q *= c;
+   │         ━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/divide-before-multiply
+
+warning[divide-before-multiply]: division before multiplication may lose precision
+   ╭▸ ROOT/testdata/DivideBeforeMultiply.sol:LL:CC
+   │
+LL │         x = x * c;
+   │             ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/divide-before-multiply
+
+warning[divide-before-multiply]: division before multiplication may lose precision
+   ╭▸ ROOT/testdata/DivideBeforeMultiply.sol:LL:CC
+   │
+LL │             result := mul(div(a, b), c)
+   │                       ━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/divide-before-multiply
+
+warning[divide-before-multiply]: division before multiplication may lose precision
+   ╭▸ ROOT/testdata/DivideBeforeMultiply.sol:LL:CC
+   │
+LL │             result := mul(c, sdiv(a, b))
+   │                       ━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/divide-before-multiply
+
+warning[divide-before-multiply]: division before multiplication may lose precision
+   ╭▸ ROOT/testdata/DivideBeforeMultiply.sol:LL:CC
+   │
+LL │             result := mul(q, c)
+   │                       ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/divide-before-multiply
 
diff --git a/crates/lint/testdata/Ecrecover.sol b/crates/lint/testdata/Ecrecover.sol
new file mode 100644
index 0000000000000..8a7e668c4ec8c
--- /dev/null
+++ b/crates/lint/testdata/Ecrecover.sol
@@ -0,0 +1,867 @@
+//@compile-flags: --only-lint ecrecover
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+interface SameName {
+    function ecrecover(bytes32 hash, uint8 v, bytes32 r, bytes32 s) external pure returns (address);
+    function observe(bytes32 value) external pure;
+}
+
+contract Ecrecover {
+    uint256 private constant HALF_ORDER =
+        0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0;
+    uint256 private constant HALF_ORDER_PLUS_ONE = HALF_ORDER + 1;
+    uint256 private constant TOP_BIT_MASK =
+        0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF;
+    bytes32 private storedS;
+    address private storedSigner;
+
+    struct Sig {
+        uint8 v;
+        bytes32 r;
+        bytes32 s;
+    }
+
+    Sig private storedSig;
+
+    function mutateStoredS(bytes32 replacement) internal {
+        storedS = replacement;
+    }
+
+    function mutateAndReturn(bytes32 replacement) internal returns (bytes32) {
+        storedS = replacement;
+        return replacement;
+    }
+
+    function observe(bytes32) internal pure {}
+
+    function observeSigner(address) internal pure {}
+
+    function bare(bytes32 hash, uint8 v, bytes32 r, bytes32 s) external pure returns (address) {
+        return ecrecover(hash, v, r, s); //~WARN: `ecrecover` call does not reject malleable signatures
+    }
+
+    function unreachableConstantBranch(bytes32 hash, uint8 v, bytes32 r, bytes32 s) external pure returns (address) {
+        if (false) return ecrecover(hash, v, r, s);
+        return address(0);
+    }
+
+    function unreachableAfterConstantExit(bytes32 hash, uint8 v, bytes32 r, bytes32 s) external pure returns (address) {
+        if (true) return address(0);
+        return ecrecover(hash, v, r, s);
+    }
+
+    function unreachableConstantLoopBranch(
+        bytes32 hash,
+        uint8 v,
+        bytes32 r,
+        bytes32 s,
+        bool run
+    ) external pure returns (address) {
+        while (run) {
+            if (false) return ecrecover(hash, v, r, s);
+            break;
+        }
+        return address(0);
+    }
+
+    function unreachableShortCircuitLoopEffect(
+        bytes32 hash,
+        uint8 v,
+        bytes32 r,
+        bytes32 s,
+        bytes32 replacement,
+        bool run
+    ) external pure returns (address) {
+        require(uint256(s) <= HALF_ORDER);
+        while (run) {
+            false && (s = replacement) == replacement;
+            return ecrecover(hash, v, r, s);
+        }
+        return address(0);
+    }
+
+    function unreachableTernaryLoopEffect(
+        bytes32 hash,
+        uint8 v,
+        bytes32 r,
+        bytes32 s,
+        bytes32 replacement,
+        bool run
+    ) external pure returns (address) {
+        require(uint256(s) <= HALF_ORDER);
+        while (run) {
+            false ? (s = replacement) : s;
+            return ecrecover(hash, v, r, s);
+        }
+        return address(0);
+    }
+
+    function unreachableShortCircuitAnd(
+        bytes32 hash,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) external pure returns (bool) {
+        return false && ecrecover(hash, v, r, s) != address(0);
+    }
+
+    function reachableShortCircuitAnd(
+        bytes32 hash,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) external pure returns (bool) {
+        return true && ecrecover(hash, v, r, s) != address(0); //~WARN: `ecrecover` call does not reject malleable signatures
+    }
+
+    function unreachableShortCircuitOr(
+        bytes32 hash,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) external pure returns (bool) {
+        return true || ecrecover(hash, v, r, s) != address(0);
+    }
+
+    function unreachableConstantTernary(
+        bytes32 hash,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) external pure returns (address) {
+        return false ? ecrecover(hash, v, r, s) : address(0);
+    }
+
+    function reachableConstantTernary(
+        bytes32 hash,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) external pure returns (address) {
+        return true ? ecrecover(hash, v, r, s) : address(0); //~WARN: `ecrecover` call does not reject malleable signatures
+    }
+
+    function vOnly(bytes32 hash, uint8 v, bytes32 r, bytes32 s) external pure returns (address) {
+        require(v == 27 || v == 28);
+        return ecrecover(hash, v, r, s); //~WARN: `ecrecover` call does not reject malleable signatures
+    }
+
+    function wrongValue(
+        bytes32 hash,
+        uint8 v,
+        bytes32 r,
+        bytes32 s,
+        bytes32 other
+    ) external pure returns (address) {
+        require(uint256(other) <= HALF_ORDER);
+        return ecrecover(hash, v, r, s); //~WARN: `ecrecover` call does not reject malleable signatures
+    }
+
+    function oneBranch(
+        bytes32 hash,
+        uint8 v,
+        bytes32 r,
+        bytes32 s,
+        bool check
+    ) external pure returns (address) {
+        if (check) {
+            require(uint256(s) <= HALF_ORDER);
+        }
+        return ecrecover(hash, v, r, s); //~WARN: `ecrecover` call does not reject malleable signatures
+    }
+
+    function reassigned(
+        bytes32 hash,
+        uint8 v,
+        bytes32 r,
+        bytes32 s,
+        bytes32 replacement
+    ) external pure returns (address) {
+        require(uint256(s) <= HALF_ORDER);
+        s = replacement;
+        return ecrecover(hash, v, r, s); //~WARN: `ecrecover` call does not reject malleable signatures
+    }
+
+    function topBitMask(bytes32 hash, uint8 v, bytes32 r, bytes32 vs) external pure returns (address) {
+        bytes32 s = bytes32(uint256(vs) & TOP_BIT_MASK);
+        return ecrecover(hash, v, r, s); //~WARN: `ecrecover` call does not reject malleable signatures
+    }
+
+    function guardAfterCall(bytes32 hash, uint8 v, bytes32 r, bytes32 s) external pure returns (address) {
+        address signer = ecrecover(hash, v, r, s);
+        require(uint256(s) <= HALF_ORDER);
+        return signer;
+    }
+
+    function guardAfterAssignment(bytes32 hash, uint8 v, bytes32 r, bytes32 s) external pure returns (address) {
+        address signer;
+        signer = ecrecover(hash, v, r, s);
+        require(uint256(s) <= HALF_ORDER);
+        return signer;
+    }
+
+    function guardAfterTupleAssignment(bytes32 hash, uint8 v, bytes32 r, bytes32 s) external pure returns (address) {
+        address signer;
+        (signer,) = (ecrecover(hash, v, r, s), 0);
+        require(uint256(s) <= HALF_ORDER);
+        return signer;
+    }
+
+    function tupleAssignmentUsedBeforeGuard(
+        bytes32 hash,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) external pure returns (address) {
+        address signer;
+        (signer,) = (ecrecover(hash, v, r, s), 0); //~WARN: `ecrecover` call does not reject malleable signatures
+        observeSigner(signer);
+        require(uint256(s) <= HALF_ORDER);
+        return signer;
+    }
+
+    function guardAfterTernaryStorage(
+        bytes32 hash,
+        uint8 v,
+        bytes32 r,
+        bytes32 s,
+        bool useRecovery
+    ) external pure returns (address) {
+        address signer = useRecovery ? ecrecover(hash, v, r, s) : address(0);
+        require(uint256(s) <= HALF_ORDER);
+        return signer;
+    }
+
+    function unguardedTernaryStorage(
+        bytes32 hash,
+        uint8 v,
+        bytes32 r,
+        bytes32 s,
+        bool useRecovery
+    ) external pure returns (address) {
+        address signer = useRecovery ? ecrecover(hash, v, r, s) : address(0); //~WARN: `ecrecover` call does not reject malleable signatures
+        return signer;
+    }
+
+    function guardAfterNestedAssignment(bytes32 hash, uint8 v, bytes32 r, bytes32 s) external pure returns (address) {
+        address first;
+        address second;
+        first = second = ecrecover(hash, v, r, s);
+        require(uint256(s) <= HALF_ORDER);
+        return first;
+    }
+
+    function nestedAssignmentUsed(
+        bytes32 hash,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) external pure returns (address) {
+        address first;
+        address second;
+        first = second = ecrecover(hash, v, r, s); //~WARN: `ecrecover` call does not reject malleable signatures
+        return first;
+    }
+
+    function nestedStateAssignmentBeforeGuard(
+        bytes32 hash,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) external returns (address) {
+        address signer;
+        signer = storedSigner = ecrecover(hash, v, r, s); //~WARN: `ecrecover` call does not reject malleable signatures
+        require(uint256(s) <= HALF_ORDER);
+        return signer;
+    }
+
+    function guardAfterAlias(bytes32 hash, uint8 v, bytes32 r, bytes32 s) external pure returns (address) {
+        address signer = ecrecover(hash, v, r, s);
+        address aliasSigner = signer;
+        require(uint256(s) <= HALF_ORDER);
+        return aliasSigner;
+    }
+
+    function guardedBranchUse(
+        bytes32 hash,
+        uint8 v,
+        bytes32 r,
+        bytes32 s,
+        bool useSigner
+    ) external pure returns (address) {
+        address signer = ecrecover(hash, v, r, s);
+        if (useSigner) {
+            require(uint256(s) <= HALF_ORDER);
+            return signer;
+        }
+        return address(0);
+    }
+
+    function guardAfterNamedReturn(
+        bytes32 hash,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) external pure returns (address signer) {
+        signer = ecrecover(hash, v, r, s);
+        require(uint256(s) <= HALF_ORDER);
+    }
+
+    function usedBeforeGuard(bytes32 hash, uint8 v, bytes32 r, bytes32 s) external pure returns (address) {
+        address signer = ecrecover(hash, v, r, s); //~WARN: `ecrecover` call does not reject malleable signatures
+        observeSigner(signer);
+        require(uint256(s) <= HALF_ORDER);
+        return signer;
+    }
+
+    function storedBeforeGuard(bytes32 hash, uint8 v, bytes32 r, bytes32 s) external {
+        address signer = ecrecover(hash, v, r, s); //~WARN: `ecrecover` call does not reject malleable signatures
+        storedSigner = signer;
+        require(uint256(s) <= HALF_ORDER);
+    }
+
+    function assemblyAfterRecovery(bytes32 hash, uint8 v, bytes32 r, bytes32 s) external pure returns (address) {
+        address signer = ecrecover(hash, v, r, s); //~WARN: `ecrecover` call does not reject malleable signatures
+        assembly {
+            pop(0)
+        }
+        return signer;
+    }
+
+    function emptyAssemblyAfterRecovery(bytes32 hash, uint8 v, bytes32 r, bytes32 s) external pure returns (address) {
+        address signer = ecrecover(hash, v, r, s); //~WARN: `ecrecover` call does not reject malleable signatures
+        assembly {}
+        return signer;
+    }
+
+    function memorySafeAssemblyAfterRecovery(
+        bytes32 hash,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) external pure returns (address) {
+        address signer = ecrecover(hash, v, r, s); //~WARN: `ecrecover` call does not reject malleable signatures
+        assembly ("memory-safe") {
+            pop(0)
+        }
+        return signer;
+    }
+
+    function assemblyBeforeRecovery(bytes32 hash, uint8 v, bytes32 r, bytes32 s) external pure returns (address) {
+        assembly {
+            pop(0)
+        }
+        require(uint256(s) <= HALF_ORDER);
+        return ecrecover(hash, v, r, s);
+    }
+
+    function nonDominatingGuard(
+        bytes32 hash,
+        uint8 v,
+        bytes32 r,
+        bytes32 s,
+        bool check
+    ) external pure returns (address) {
+        address signer = ecrecover(hash, v, r, s); //~WARN: `ecrecover` call does not reject malleable signatures
+        if (check) require(uint256(s) <= HALF_ORDER);
+        return signer;
+    }
+
+    function nonDominatingNamedReturn(
+        bytes32 hash,
+        uint8 v,
+        bytes32 r,
+        bytes32 s,
+        bool check
+    ) external pure returns (address signer) {
+        signer = ecrecover(hash, v, r, s); //~WARN: `ecrecover` call does not reject malleable signatures
+        if (check) require(uint256(s) <= HALF_ORDER);
+    }
+
+    function guardReassignedS(
+        bytes32 hash,
+        uint8 v,
+        bytes32 r,
+        bytes32 s,
+        bytes32 replacement
+    ) external pure returns (address) {
+        address signer = ecrecover(hash, v, r, s); //~WARN: `ecrecover` call does not reject malleable signatures
+        s = replacement;
+        require(uint256(s) <= HALF_ORDER);
+        return signer;
+    }
+
+    function mixedCalls(bytes32 hash, uint8 v, bytes32 r, bytes32 s) external pure returns (address, address) {
+        require(uint256(s) <= HALF_ORDER);
+        address first = ecrecover(hash, v, r, s);
+        s = bytes32(uint256(s) + 1);
+        address second = ecrecover(hash, v, r, s); //~WARN: `ecrecover` call does not reject malleable signatures
+        return (first, second);
+    }
+
+    function requireGuard(bytes32 hash, uint8 v, bytes32 r, bytes32 s) external pure returns (address) {
+        require(uint256(s) <= HALF_ORDER);
+        return ecrecover(hash, v, r, s);
+    }
+
+    function constantDisjunctionGuard(bytes32 hash, uint8 v, bytes32 r, bytes32 s) external pure returns (address) {
+        require(uint256(s) <= HALF_ORDER || false);
+        return ecrecover(hash, v, r, s);
+    }
+
+    function constantConjunctionGuard(bytes32 hash, uint8 v, bytes32 r, bytes32 s) external pure returns (address) {
+        require(true && uint256(s) <= HALF_ORDER);
+        return ecrecover(hash, v, r, s);
+    }
+
+    function constantThenTernaryGuard(bytes32 hash, uint8 v, bytes32 r, bytes32 s) external pure returns (address) {
+        require(true ? uint256(s) <= HALF_ORDER : false);
+        return ecrecover(hash, v, r, s);
+    }
+
+    function constantElseTernaryGuard(bytes32 hash, uint8 v, bytes32 r, bytes32 s) external pure returns (address) {
+        require(false ? false : uint256(s) <= HALF_ORDER);
+        return ecrecover(hash, v, r, s);
+    }
+
+    function constantTernaryGuardControl(bytes32 hash, uint8 v, bytes32 r, bytes32 s) external pure returns (address) {
+        require(false ? uint256(s) <= HALF_ORDER : true);
+        return ecrecover(hash, v, r, s); //~WARN: `ecrecover` call does not reject malleable signatures
+    }
+
+    function unreachableElseGuardEffect(
+        bytes32 hash,
+        uint8 v,
+        bytes32 r,
+        bytes32 s,
+        bytes32 replacement
+    ) external pure returns (address) {
+        require(true ? uint256(s) <= HALF_ORDER : (s = replacement) == replacement);
+        return ecrecover(hash, v, r, s);
+    }
+
+    function unreachableThenGuardEffect(
+        bytes32 hash,
+        uint8 v,
+        bytes32 r,
+        bytes32 s,
+        bytes32 replacement
+    ) external pure returns (address) {
+        require(false ? (s = replacement) == replacement : uint256(s) <= HALF_ORDER);
+        return ecrecover(hash, v, r, s);
+    }
+
+    function selectedGuardEffect(
+        bytes32 hash,
+        uint8 v,
+        bytes32 r,
+        bytes32 s,
+        bytes32 replacement
+    ) external pure returns (address) {
+        require(true ? (s = replacement) == replacement : uint256(s) <= HALF_ORDER);
+        return ecrecover(hash, v, r, s); //~WARN: `ecrecover` call does not reject malleable signatures
+    }
+
+    function assertReversed(bytes32 hash, uint8 v, bytes32 r, bytes32 s) external pure returns (address) {
+        assert(HALF_ORDER >= uint256(s));
+        return ecrecover(hash, v, r, s);
+    }
+
+    function strictBound(bytes32 hash, uint8 v, bytes32 r, bytes32 s) external pure returns (address) {
+        require(uint256(s) < HALF_ORDER_PLUS_ONE);
+        return ecrecover(hash, v, r, s);
+    }
+
+    function revertHighS(bytes32 hash, uint8 v, bytes32 r, bytes32 s) external pure returns (address) {
+        if (uint256(s) > HALF_ORDER) revert();
+        return ecrecover(hash, v, r, s);
+    }
+
+    function returnHighS(bytes32 hash, uint8 v, bytes32 r, bytes32 s) external pure returns (address) {
+        if (uint256(s) > HALF_ORDER) return address(0);
+        return ecrecover(hash, v, r, s);
+    }
+
+    function lowBranch(bytes32 hash, uint8 v, bytes32 r, bytes32 s) external pure returns (address) {
+        if (uint256(s) <= HALF_ORDER) {
+            return ecrecover(hash, v, r, s);
+        }
+        return address(0);
+    }
+
+    function aliasGuard(bytes32 hash, uint8 v, bytes32 r, bytes32 s) external pure returns (address) {
+        bytes32 aliasS = s;
+        require(uint256(aliasS) <= HALF_ORDER);
+        return ecrecover(hash, v, r, s);
+    }
+
+    function guardedAlias(bytes32 hash, uint8 v, bytes32 r, bytes32 s) external pure returns (address) {
+        require(uint256(s) <= HALF_ORDER);
+        bytes32 aliasS = s;
+        return ecrecover(hash, v, r, aliasS);
+    }
+
+    function bothBranches(
+        bytes32 hash,
+        uint8 v,
+        bytes32 r,
+        bytes32 s,
+        bool flag
+    ) external pure returns (address) {
+        if (flag) {
+            require(uint256(s) <= HALF_ORDER);
+        } else {
+            require(uint256(s) < HALF_ORDER_PLUS_ONE);
+        }
+        return ecrecover(hash, v, r, s);
+    }
+
+    function ternary(bytes32 hash, uint8 v, bytes32 r, bytes32 s) external pure returns (address) {
+        return uint256(s) <= HALF_ORDER ? ecrecover(hash, v, r, s) : address(0);
+    }
+
+    function constantS(bytes32 hash, uint8 v, bytes32 r) external pure returns (address) {
+        return ecrecover(hash, v, r, bytes32(0));
+    }
+
+    function constantTernaryS(bytes32 hash, uint8 v, bytes32 r, bytes32 s) external pure returns (address) {
+        return ecrecover(hash, v, r, true ? bytes32(0) : s);
+    }
+
+    function constantTernaryUnsafeS(bytes32 hash, uint8 v, bytes32 r, bytes32 s) external pure returns (address) {
+        return ecrecover(hash, v, r, false ? bytes32(0) : s); //~WARN: `ecrecover` call does not reject malleable signatures
+    }
+
+    function tupleSwapSafe(bytes32 hash, uint8 v, bytes32 r, bytes32 s) external pure returns (address) {
+        bytes32 unsafeS = s;
+        bytes32 safeS = bytes32(0);
+        (unsafeS, safeS) = (safeS, unsafeS);
+        return ecrecover(hash, v, r, unsafeS);
+    }
+
+    function tupleSwapUnsafe(bytes32 hash, uint8 v, bytes32 r, bytes32 s) external pure returns (address) {
+        bytes32 unsafeS = s;
+        bytes32 safeS = bytes32(0);
+        (unsafeS, safeS) = (safeS, unsafeS);
+        return ecrecover(hash, v, r, safeS); //~WARN: `ecrecover` call does not reject malleable signatures
+    }
+
+    function ternarySafe(
+        bytes32 hash,
+        uint8 v,
+        bytes32 r,
+        bool flag
+    ) external pure returns (address) {
+        bytes32 s = flag ? bytes32(0) : bytes32(1);
+        return ecrecover(hash, v, r, s);
+    }
+
+    function branchSafe(
+        bytes32 hash,
+        uint8 v,
+        bytes32 r,
+        bytes32 s,
+        bool flag
+    ) external pure returns (address) {
+        if (flag) {
+            s = bytes32(0);
+        } else {
+            s = bytes32(1);
+        }
+        return ecrecover(hash, v, r, s);
+    }
+
+    function loopCarried(
+        bytes32 hash,
+        uint8 v,
+        bytes32 r,
+        bytes32 s,
+        bytes32 replacement,
+        bool repeat
+    ) external pure returns (address signer) {
+        require(uint256(s) <= HALF_ORDER);
+        while (repeat) {
+            signer = ecrecover(hash, v, r, s); //~WARN: `ecrecover` call does not reject malleable signatures
+            s = replacement;
+        }
+    }
+
+    function forContinue(
+        bytes32 hash,
+        uint8 v,
+        bytes32 r,
+        bytes32 s,
+        bytes32 replacement
+    ) external pure returns (address signer) {
+        require(uint256(s) <= HALF_ORDER);
+        for (uint256 i; i < 2; s = replacement) {
+            signer = ecrecover(hash, v, r, s); //~WARN: `ecrecover` call does not reject malleable signatures
+            ++i;
+            continue;
+        }
+    }
+
+    function guardedLoop(
+        bytes32 hash,
+        uint8 v,
+        bytes32 r,
+        bytes32 s,
+        bytes32 replacement
+    ) external pure returns (address signer) {
+        while (uint256(s) <= HALF_ORDER) {
+            signer = ecrecover(hash, v, r, s);
+            s = replacement;
+        }
+    }
+
+    function singleIteration(
+        bytes32 hash,
+        uint8 v,
+        bytes32 r,
+        bytes32 s,
+        bytes32 replacement
+    ) external pure returns (address signer) {
+        require(uint256(s) <= HALF_ORDER);
+        do {
+            signer = ecrecover(hash, v, r, s);
+            s = replacement;
+        } while (false);
+    }
+
+    function doWhileBreak(
+        bytes32 hash,
+        uint8 v,
+        bytes32 r,
+        bytes32 s,
+        bool skipGuard
+    ) external pure returns (address) {
+        do {
+            if (skipGuard) break;
+            require(uint256(s) <= HALF_ORDER);
+        } while (false);
+        return ecrecover(hash, v, r, s); //~WARN: `ecrecover` call does not reject malleable signatures
+    }
+
+    function stateChangedByCall(
+        bytes32 hash,
+        uint8 v,
+        bytes32 r,
+        bytes32 replacement
+    ) external returns (address) {
+        require(uint256(storedS) <= HALF_ORDER);
+        mutateStoredS(replacement);
+        return ecrecover(hash, v, r, storedS); //~WARN: `ecrecover` call does not reject malleable signatures
+    }
+
+    function statePreservedByPureCall(bytes32 hash, uint8 v, bytes32 r) external view returns (address) {
+        require(uint256(storedS) <= HALF_ORDER);
+        observe(storedS);
+        return ecrecover(hash, v, r, storedS);
+    }
+
+    function stateChangedInTryArgument(
+        SameName helper,
+        bytes32 hash,
+        uint8 v,
+        bytes32 r,
+        bytes32 replacement
+    ) external returns (address) {
+        require(uint256(storedS) <= HALF_ORDER);
+        try helper.observe(mutateAndReturn(replacement)) {
+            return address(0);
+        } catch {}
+        return ecrecover(hash, v, r, storedS); //~WARN: `ecrecover` call does not reject malleable signatures
+    }
+
+    function looseInclusive(bytes32 hash, uint8 v, bytes32 r, bytes32 s) external pure returns (address) {
+        require(uint256(s) <= HALF_ORDER_PLUS_ONE);
+        return ecrecover(hash, v, r, s); //~WARN: `ecrecover` call does not reject malleable signatures
+    }
+
+    function looseStrict(bytes32 hash, uint8 v, bytes32 r, bytes32 s) external pure returns (address) {
+        unchecked {
+            require(uint256(s) < HALF_ORDER_PLUS_ONE + 1);
+        }
+        return ecrecover(hash, v, r, s); //~WARN: `ecrecover` call does not reject malleable signatures
+    }
+
+    function uncheckedWrap(bytes32 hash, uint8 v, bytes32 r, bytes32 s) external pure returns (address) {
+        unchecked {
+            require(uint256(s) <= type(uint256).max + 1);
+        }
+        return ecrecover(hash, v, r, s);
+    }
+
+    function safeAfterLoop(bytes32 hash, uint8 v, bytes32 r, bytes32 s) external pure returns (address) {
+        while (uint256(s) > HALF_ORDER) {
+            return address(0);
+        }
+        return ecrecover(hash, v, r, s);
+    }
+
+    function strictReversed(bytes32 hash, uint8 v, bytes32 r, bytes32 s) external pure returns (address) {
+        require(HALF_ORDER_PLUS_ONE > uint256(s));
+        return ecrecover(hash, v, r, s);
+    }
+
+    function userDefined(
+        SameName helper,
+        bytes32 hash,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) external pure returns (address) {
+        return helper.ecrecover(hash, v, r, s);
+    }
+
+    function structMemberGuarded(bytes32 hash, Sig memory sig) external pure returns (address) {
+        require(uint256(sig.s) <= HALF_ORDER);
+        return ecrecover(hash, sig.v, sig.r, sig.s);
+    }
+
+    function structMemberUnguarded(bytes32 hash, Sig memory sig) external pure returns (address) {
+        return ecrecover(hash, sig.v, sig.r, sig.s); //~WARN: `ecrecover` call does not reject malleable signatures
+    }
+
+    function structMemberReassigned(
+        bytes32 hash,
+        Sig memory sig,
+        Sig memory other
+    ) external pure returns (address) {
+        require(uint256(sig.s) <= HALF_ORDER);
+        sig = other;
+        return ecrecover(hash, sig.v, sig.r, sig.s); //~WARN: `ecrecover` call does not reject malleable signatures
+    }
+
+    function structFieldReassigned(
+        bytes32 hash,
+        Sig memory sig,
+        bytes32 replacement
+    ) external pure returns (address) {
+        require(uint256(sig.s) <= HALF_ORDER);
+        sig.s = replacement;
+        return ecrecover(hash, sig.v, sig.r, sig.s); //~WARN: `ecrecover` call does not reject malleable signatures
+    }
+
+    function structFieldReassignedThroughMemoryAlias(
+        bytes32 hash,
+        Sig memory sig,
+        bytes32 replacement
+    ) external pure returns (address) {
+        Sig memory aliasSig = sig;
+        require(uint256(sig.s) <= HALF_ORDER);
+        aliasSig.s = replacement;
+        return ecrecover(hash, sig.v, sig.r, sig.s); //~WARN: `ecrecover` call does not reject malleable signatures
+    }
+
+    function stateStructMemberGuarded(bytes32 hash) external view returns (address) {
+        require(uint256(storedSig.s) <= HALF_ORDER);
+        return ecrecover(hash, storedSig.v, storedSig.r, storedSig.s);
+    }
+
+    function structMemberIncrementInvalidates(
+        bytes32 hash,
+        Sig memory sig
+    ) external pure returns (address) {
+        require(uint256(sig.s) <= HALF_ORDER);
+        sig.s++;
+        return ecrecover(hash, sig.v, sig.r, sig.s); //~WARN: `ecrecover` call does not reject malleable signatures
+    }
+
+    function structMemberTupleAssignInvalidates(
+        bytes32 hash,
+        Sig memory sig,
+        bytes32 replacement,
+        uint8 newV
+    ) external pure returns (address) {
+        require(uint256(sig.s) <= HALF_ORDER);
+        (sig.s, sig.v) = (replacement, newV);
+        return ecrecover(hash, sig.v, sig.r, sig.s); //~WARN: `ecrecover` call does not reject malleable signatures
+    }
+
+    function structMemberLoopCarried(
+        bytes32 hash,
+        Sig memory sig,
+        bytes32 replacement,
+        bool repeat
+    ) external pure returns (address signer) {
+        require(uint256(sig.s) <= HALF_ORDER);
+        while (repeat) {
+            signer = ecrecover(hash, sig.v, sig.r, sig.s); //~WARN: `ecrecover` call does not reject malleable signatures
+            sig.s = replacement;
+        }
+    }
+
+    function normalizeSig(Sig memory sig) internal pure {
+        sig.s = bytes32(type(uint256).max);
+    }
+
+    function inspectSig(Sig memory) external pure {}
+
+    function structMemberMutatedByCall(bytes32 hash, Sig memory sig) external pure returns (address) {
+        require(uint256(sig.s) <= HALF_ORDER);
+        normalizeSig(sig);
+        return ecrecover(hash, sig.v, sig.r, sig.s); //~WARN: `ecrecover` call does not reject malleable signatures
+    }
+
+    function structMemberMutatedByFunctionPointer(
+        bytes32 hash,
+        Sig memory sig
+    ) external pure returns (address) {
+        function(Sig memory) internal pure fn = normalizeSig;
+        require(uint256(sig.s) <= HALF_ORDER);
+        fn(sig);
+        return ecrecover(hash, sig.v, sig.r, sig.s); //~WARN: `ecrecover` call does not reject malleable signatures
+    }
+
+    function structMemberCopiedByExternalCall(bytes32 hash, Sig memory sig) external view returns (address) {
+        require(uint256(sig.s) <= HALF_ORDER);
+        this.inspectSig(sig);
+        return ecrecover(hash, sig.v, sig.r, sig.s);
+    }
+
+    function structMemberHashedOk(bytes32 hash, Sig memory sig) external pure returns (address) {
+        require(uint256(sig.s) <= HALF_ORDER);
+        bytes32 digest = keccak256(abi.encode(hash, sig.v));
+        return ecrecover(digest, sig.v, sig.r, sig.s);
+    }
+
+    function mutateStoredSig() internal {
+        storedSig.s = bytes32(type(uint256).max);
+    }
+
+    function storagePointerMutatedByCall(bytes32 hash) external returns (address) {
+        Sig storage sig = storedSig;
+        require(uint256(sig.s) <= HALF_ORDER);
+        mutateStoredSig();
+        return ecrecover(hash, sig.v, sig.r, sig.s); //~WARN: `ecrecover` call does not reject malleable signatures
+    }
+
+    function structFieldReassignedThroughStorageAlias(
+        bytes32 hash,
+        bytes32 replacement
+    ) external returns (address) {
+        Sig storage sig = storedSig;
+        Sig storage aliasSig = sig;
+        require(uint256(sig.s) <= HALF_ORDER);
+        aliasSig.s = replacement;
+        return ecrecover(hash, sig.v, sig.r, sig.s); //~WARN: `ecrecover` call does not reject malleable signatures
+    }
+}
+
+contract SameNameEcrecover {
+    function ecrecover(bytes32, uint8, bytes32, bytes32) internal pure returns (address) {
+        return address(1);
+    }
+
+    function userDefinedBare(
+        bytes32 hash,
+        uint8 v,
+        bytes32 r,
+        bytes32 s
+    ) external pure returns (address) {
+        return ecrecover(hash, v, r, s);
+    }
+}
diff --git a/crates/lint/testdata/Ecrecover.stderr b/crates/lint/testdata/Ecrecover.stderr
new file mode 100644
index 0000000000000..b17812c7c23f5
--- /dev/null
+++ b/crates/lint/testdata/Ecrecover.stderr
@@ -0,0 +1,344 @@
+warning[ecrecover]: `ecrecover` call does not reject malleable signatures
+   ╭▸ ROOT/testdata/Ecrecover.sol:LL:CC
+   │
+LL │         return ecrecover(hash, v, r, s);
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/ecrecover
+
+warning[ecrecover]: `ecrecover` call does not reject malleable signatures
+   ╭▸ ROOT/testdata/Ecrecover.sol:LL:CC
+   │
+LL │         return true && ecrecover(hash, v, r, s) != address(0);
+   │                        ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/ecrecover
+
+warning[ecrecover]: `ecrecover` call does not reject malleable signatures
+   ╭▸ ROOT/testdata/Ecrecover.sol:LL:CC
+   │
+LL │         return true ? ecrecover(hash, v, r, s) : address(0);
+   │                       ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/ecrecover
+
+warning[ecrecover]: `ecrecover` call does not reject malleable signatures
+   ╭▸ ROOT/testdata/Ecrecover.sol:LL:CC
+   │
+LL │         return ecrecover(hash, v, r, s);
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/ecrecover
+
+warning[ecrecover]: `ecrecover` call does not reject malleable signatures
+   ╭▸ ROOT/testdata/Ecrecover.sol:LL:CC
+   │
+LL │         return ecrecover(hash, v, r, s);
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/ecrecover
+
+warning[ecrecover]: `ecrecover` call does not reject malleable signatures
+   ╭▸ ROOT/testdata/Ecrecover.sol:LL:CC
+   │
+LL │         return ecrecover(hash, v, r, s);
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/ecrecover
+
+warning[ecrecover]: `ecrecover` call does not reject malleable signatures
+   ╭▸ ROOT/testdata/Ecrecover.sol:LL:CC
+   │
+LL │         return ecrecover(hash, v, r, s);
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/ecrecover
+
+warning[ecrecover]: `ecrecover` call does not reject malleable signatures
+   ╭▸ ROOT/testdata/Ecrecover.sol:LL:CC
+   │
+LL │         return ecrecover(hash, v, r, s);
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/ecrecover
+
+warning[ecrecover]: `ecrecover` call does not reject malleable signatures
+   ╭▸ ROOT/testdata/Ecrecover.sol:LL:CC
+   │
+LL │         (signer,) = (ecrecover(hash, v, r, s), 0);
+   │                      ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/ecrecover
+
+warning[ecrecover]: `ecrecover` call does not reject malleable signatures
+   ╭▸ ROOT/testdata/Ecrecover.sol:LL:CC
+   │
+LL │ …     address signer = useRecovery ? ecrecover(hash, v, r, s) : address(0);
+   │                                      ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/ecrecover
+
+warning[ecrecover]: `ecrecover` call does not reject malleable signatures
+   ╭▸ ROOT/testdata/Ecrecover.sol:LL:CC
+   │
+LL │         first = second = ecrecover(hash, v, r, s);
+   │                          ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/ecrecover
+
+warning[ecrecover]: `ecrecover` call does not reject malleable signatures
+   ╭▸ ROOT/testdata/Ecrecover.sol:LL:CC
+   │
+LL │         signer = storedSigner = ecrecover(hash, v, r, s);
+   │                                 ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/ecrecover
+
+warning[ecrecover]: `ecrecover` call does not reject malleable signatures
+   ╭▸ ROOT/testdata/Ecrecover.sol:LL:CC
+   │
+LL │         address signer = ecrecover(hash, v, r, s);
+   │                          ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/ecrecover
+
+warning[ecrecover]: `ecrecover` call does not reject malleable signatures
+   ╭▸ ROOT/testdata/Ecrecover.sol:LL:CC
+   │
+LL │         address signer = ecrecover(hash, v, r, s);
+   │                          ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/ecrecover
+
+warning[ecrecover]: `ecrecover` call does not reject malleable signatures
+   ╭▸ ROOT/testdata/Ecrecover.sol:LL:CC
+   │
+LL │         address signer = ecrecover(hash, v, r, s);
+   │                          ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/ecrecover
+
+warning[ecrecover]: `ecrecover` call does not reject malleable signatures
+   ╭▸ ROOT/testdata/Ecrecover.sol:LL:CC
+   │
+LL │         address signer = ecrecover(hash, v, r, s);
+   │                          ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/ecrecover
+
+warning[ecrecover]: `ecrecover` call does not reject malleable signatures
+   ╭▸ ROOT/testdata/Ecrecover.sol:LL:CC
+   │
+LL │         address signer = ecrecover(hash, v, r, s);
+   │                          ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/ecrecover
+
+warning[ecrecover]: `ecrecover` call does not reject malleable signatures
+   ╭▸ ROOT/testdata/Ecrecover.sol:LL:CC
+   │
+LL │         address signer = ecrecover(hash, v, r, s);
+   │                          ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/ecrecover
+
+warning[ecrecover]: `ecrecover` call does not reject malleable signatures
+   ╭▸ ROOT/testdata/Ecrecover.sol:LL:CC
+   │
+LL │         signer = ecrecover(hash, v, r, s);
+   │                  ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/ecrecover
+
+warning[ecrecover]: `ecrecover` call does not reject malleable signatures
+   ╭▸ ROOT/testdata/Ecrecover.sol:LL:CC
+   │
+LL │         address signer = ecrecover(hash, v, r, s);
+   │                          ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/ecrecover
+
+warning[ecrecover]: `ecrecover` call does not reject malleable signatures
+   ╭▸ ROOT/testdata/Ecrecover.sol:LL:CC
+   │
+LL │         address second = ecrecover(hash, v, r, s);
+   │                          ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/ecrecover
+
+warning[ecrecover]: `ecrecover` call does not reject malleable signatures
+   ╭▸ ROOT/testdata/Ecrecover.sol:LL:CC
+   │
+LL │         return ecrecover(hash, v, r, s);
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/ecrecover
+
+warning[ecrecover]: `ecrecover` call does not reject malleable signatures
+   ╭▸ ROOT/testdata/Ecrecover.sol:LL:CC
+   │
+LL │         return ecrecover(hash, v, r, s);
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/ecrecover
+
+warning[ecrecover]: `ecrecover` call does not reject malleable signatures
+   ╭▸ ROOT/testdata/Ecrecover.sol:LL:CC
+   │
+LL │         return ecrecover(hash, v, r, false ? bytes32(0) : s);
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/ecrecover
+
+warning[ecrecover]: `ecrecover` call does not reject malleable signatures
+   ╭▸ ROOT/testdata/Ecrecover.sol:LL:CC
+   │
+LL │         return ecrecover(hash, v, r, safeS);
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/ecrecover
+
+warning[ecrecover]: `ecrecover` call does not reject malleable signatures
+   ╭▸ ROOT/testdata/Ecrecover.sol:LL:CC
+   │
+LL │             signer = ecrecover(hash, v, r, s);
+   │                      ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/ecrecover
+
+warning[ecrecover]: `ecrecover` call does not reject malleable signatures
+   ╭▸ ROOT/testdata/Ecrecover.sol:LL:CC
+   │
+LL │             signer = ecrecover(hash, v, r, s);
+   │                      ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/ecrecover
+
+warning[ecrecover]: `ecrecover` call does not reject malleable signatures
+   ╭▸ ROOT/testdata/Ecrecover.sol:LL:CC
+   │
+LL │         return ecrecover(hash, v, r, s);
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/ecrecover
+
+warning[ecrecover]: `ecrecover` call does not reject malleable signatures
+   ╭▸ ROOT/testdata/Ecrecover.sol:LL:CC
+   │
+LL │         return ecrecover(hash, v, r, storedS);
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/ecrecover
+
+warning[ecrecover]: `ecrecover` call does not reject malleable signatures
+   ╭▸ ROOT/testdata/Ecrecover.sol:LL:CC
+   │
+LL │         return ecrecover(hash, v, r, storedS);
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/ecrecover
+
+warning[ecrecover]: `ecrecover` call does not reject malleable signatures
+   ╭▸ ROOT/testdata/Ecrecover.sol:LL:CC
+   │
+LL │         return ecrecover(hash, v, r, s);
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/ecrecover
+
+warning[ecrecover]: `ecrecover` call does not reject malleable signatures
+   ╭▸ ROOT/testdata/Ecrecover.sol:LL:CC
+   │
+LL │         return ecrecover(hash, v, r, s);
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/ecrecover
+
+warning[ecrecover]: `ecrecover` call does not reject malleable signatures
+   ╭▸ ROOT/testdata/Ecrecover.sol:LL:CC
+   │
+LL │         return ecrecover(hash, sig.v, sig.r, sig.s);
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/ecrecover
+
+warning[ecrecover]: `ecrecover` call does not reject malleable signatures
+   ╭▸ ROOT/testdata/Ecrecover.sol:LL:CC
+   │
+LL │         return ecrecover(hash, sig.v, sig.r, sig.s);
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/ecrecover
+
+warning[ecrecover]: `ecrecover` call does not reject malleable signatures
+   ╭▸ ROOT/testdata/Ecrecover.sol:LL:CC
+   │
+LL │         return ecrecover(hash, sig.v, sig.r, sig.s);
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/ecrecover
+
+warning[ecrecover]: `ecrecover` call does not reject malleable signatures
+   ╭▸ ROOT/testdata/Ecrecover.sol:LL:CC
+   │
+LL │         return ecrecover(hash, sig.v, sig.r, sig.s);
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/ecrecover
+
+warning[ecrecover]: `ecrecover` call does not reject malleable signatures
+   ╭▸ ROOT/testdata/Ecrecover.sol:LL:CC
+   │
+LL │         return ecrecover(hash, sig.v, sig.r, sig.s);
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/ecrecover
+
+warning[ecrecover]: `ecrecover` call does not reject malleable signatures
+   ╭▸ ROOT/testdata/Ecrecover.sol:LL:CC
+   │
+LL │         return ecrecover(hash, sig.v, sig.r, sig.s);
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/ecrecover
+
+warning[ecrecover]: `ecrecover` call does not reject malleable signatures
+   ╭▸ ROOT/testdata/Ecrecover.sol:LL:CC
+   │
+LL │             signer = ecrecover(hash, sig.v, sig.r, sig.s);
+   │                      ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/ecrecover
+
+warning[ecrecover]: `ecrecover` call does not reject malleable signatures
+   ╭▸ ROOT/testdata/Ecrecover.sol:LL:CC
+   │
+LL │         return ecrecover(hash, sig.v, sig.r, sig.s);
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/ecrecover
+
+warning[ecrecover]: `ecrecover` call does not reject malleable signatures
+   ╭▸ ROOT/testdata/Ecrecover.sol:LL:CC
+   │
+LL │         return ecrecover(hash, sig.v, sig.r, sig.s);
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/ecrecover
+
+warning[ecrecover]: `ecrecover` call does not reject malleable signatures
+   ╭▸ ROOT/testdata/Ecrecover.sol:LL:CC
+   │
+LL │         return ecrecover(hash, sig.v, sig.r, sig.s);
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/ecrecover
+
+warning[ecrecover]: `ecrecover` call does not reject malleable signatures
+   ╭▸ ROOT/testdata/Ecrecover.sol:LL:CC
+   │
+LL │         return ecrecover(hash, sig.v, sig.r, sig.s);
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/ecrecover
+
diff --git a/crates/lint/testdata/EmptyBlock.sol b/crates/lint/testdata/EmptyBlock.sol
new file mode 100644
index 0000000000000..bd242d1c48ee3
--- /dev/null
+++ b/crates/lint/testdata/EmptyBlock.sol
@@ -0,0 +1,91 @@
+//@compile-flags: --only-lint empty-block
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+// Tests for `empty-block`: an empty body on a regular function is dead or unfinished code.
+// Bodies whose emptiness is the behavior are exempt: constructors, receive/fallback (the empty
+// body accepts calls or ether), `virtual` functions (an empty default meant to be overridden)
+// and `payable` functions (an empty ether sink). Functions without a body (interfaces, abstract
+// declarations) never fire. Nested empty blocks (`if (x) {}`) are out of scope.
+
+// An empty free function is dead code.
+function freeEmpty() pure {} //~WARN: empty function body
+
+library EmptyBlockLib {
+    function libEmpty(uint256 x) internal pure {} //~WARN: empty function body
+}
+
+abstract contract EmptyBlockBase {
+    modifier noop() {
+        _;
+    }
+
+    // Virtual with an empty default body: an intentional extension hook, exempt.
+    function hook() internal virtual {}
+
+    // Virtual without a body: nothing to flag.
+    function toImplement() external virtual;
+
+    // Virtual middle hook stays exempt even when it also overrides.
+    function middleHook() internal virtual {}
+
+    function _authorizeUpgrade(address newImplementation) internal virtual {}
+}
+
+contract EmptyBlockWithArg {
+    constructor(uint256 x) {} // constructor: exempt, even with a parameter
+}
+
+contract EmptyBlock is EmptyBlockBase, EmptyBlockWithArg {
+    uint256 internal value;
+
+    constructor() EmptyBlockWithArg(1) {} // constructor with a base call: exempt
+
+    receive() external payable {} // receive: exempt
+
+    fallback() external payable {} // fallback: exempt
+
+    // Payable with an empty body is an intentional ether sink.
+    function deposit() external payable {}
+
+    // Payable with a return value silently returns the default: an unfinished stub.
+    function payableReturns() external payable returns (uint256) {} //~WARN: empty function body
+
+    function emptyPublic() public {} //~WARN: empty function body
+
+    function emptyExternal() external {} //~WARN: empty function body
+
+    function emptyInternal() internal {} //~WARN: empty function body
+
+    function emptyPrivate() private {} //~WARN: empty function body
+
+    function emptyView() external view {} //~WARN: empty function body
+
+    function emptyPure() external pure {} //~WARN: empty function body
+
+    // A comment does not make the body non-empty.
+    function commentOnly() public { /* nothing to do */ } //~WARN: empty function body
+
+    // Overriding a parent declaration with an empty, non-virtual body is dead code.
+    function toImplement() external override {} //~WARN: empty function body
+
+    // The modifier carries the behavior: `initializer`-style and `onlyOwner`-style guards
+    // commonly wrap an intentionally empty body.
+    function withModifier() public noop {}
+
+    // Virtual override: still an extension hook, exempt.
+    function middleHook() internal virtual override {}
+
+    // The UUPS pattern: the modifier is the whole point of the function.
+    function _authorizeUpgrade(address newImplementation) internal override noop {}
+
+    function nonEmpty() public {
+        value = 1;
+    }
+
+    // Nested empty blocks are out of scope: the body is not empty.
+    function nestedEmptyIf(uint256 x) public {
+        if (x > 0) {}
+        value = x;
+    }
+}
diff --git a/crates/lint/testdata/EmptyBlock.stderr b/crates/lint/testdata/EmptyBlock.stderr
new file mode 100644
index 0000000000000..8d7016a132fbc
--- /dev/null
+++ b/crates/lint/testdata/EmptyBlock.stderr
@@ -0,0 +1,88 @@
+warning[empty-block]: empty function body
+   ╭▸ ROOT/testdata/EmptyBlock.sol:LL:CC
+   │
+LL │ function freeEmpty() pure {}
+   │                           ━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/empty-block
+
+warning[empty-block]: empty function body
+   ╭▸ ROOT/testdata/EmptyBlock.sol:LL:CC
+   │
+LL │     function libEmpty(uint256 x) internal pure {}
+   │                                                ━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/empty-block
+
+warning[empty-block]: empty function body
+   ╭▸ ROOT/testdata/EmptyBlock.sol:LL:CC
+   │
+LL │     function payableReturns() external payable returns (uint256) {}
+   │                                                                  ━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/empty-block
+
+warning[empty-block]: empty function body
+   ╭▸ ROOT/testdata/EmptyBlock.sol:LL:CC
+   │
+LL │     function emptyPublic() public {}
+   │                                   ━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/empty-block
+
+warning[empty-block]: empty function body
+   ╭▸ ROOT/testdata/EmptyBlock.sol:LL:CC
+   │
+LL │     function emptyExternal() external {}
+   │                                       ━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/empty-block
+
+warning[empty-block]: empty function body
+   ╭▸ ROOT/testdata/EmptyBlock.sol:LL:CC
+   │
+LL │     function emptyInternal() internal {}
+   │                                       ━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/empty-block
+
+warning[empty-block]: empty function body
+   ╭▸ ROOT/testdata/EmptyBlock.sol:LL:CC
+   │
+LL │     function emptyPrivate() private {}
+   │                                     ━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/empty-block
+
+warning[empty-block]: empty function body
+   ╭▸ ROOT/testdata/EmptyBlock.sol:LL:CC
+   │
+LL │     function emptyView() external view {}
+   │                                        ━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/empty-block
+
+warning[empty-block]: empty function body
+   ╭▸ ROOT/testdata/EmptyBlock.sol:LL:CC
+   │
+LL │     function emptyPure() external pure {}
+   │                                        ━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/empty-block
+
+warning[empty-block]: empty function body
+   ╭▸ ROOT/testdata/EmptyBlock.sol:LL:CC
+   │
+LL │     function commentOnly() public { /* nothing to do */ }
+   │                                   ━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/empty-block
+
+warning[empty-block]: empty function body
+   ╭▸ ROOT/testdata/EmptyBlock.sol:LL:CC
+   │
+LL │     function toImplement() external override {}
+   │                                              ━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/empty-block
+
diff --git a/crates/lint/testdata/EncodePackedCollision.sol b/crates/lint/testdata/EncodePackedCollision.sol
new file mode 100644
index 0000000000000..8ddd6ce3d1118
--- /dev/null
+++ b/crates/lint/testdata/EncodePackedCollision.sol
@@ -0,0 +1,293 @@
+//@compile-flags: --only-lint encode-packed-collision
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+contract EncodePackedCollision {
+    // SHOULD WARN: two string args
+    function twoStrings(string memory a, string memory b) public pure returns (bytes32) {
+        return keccak256(abi.encodePacked(a, b)); //~WARN: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+    }
+
+    // SHOULD WARN: two bytes args
+    function twoBytes(bytes memory a, bytes memory b) public pure returns (bytes32) {
+        return keccak256(abi.encodePacked(a, b)); //~WARN: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+    }
+
+    // SHOULD WARN: string + bytes
+    function stringAndBytes(string memory a, bytes memory b) public pure returns (bytes32) {
+        return keccak256(abi.encodePacked(a, b)); //~WARN: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+    }
+
+    // SHOULD WARN: dynamic array + string
+    function arrayAndString(uint256[] memory a, string memory b) public pure returns (bytes32) {
+        return keccak256(abi.encodePacked(a, b)); //~WARN: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+    }
+
+    // SHOULD WARN: two dynamic arrays
+    function twoArrays(uint256[] memory a, address[] memory b) public pure returns (bytes32) {
+        return keccak256(abi.encodePacked(a, b)); //~WARN: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+    }
+
+    // SHOULD WARN: three dynamic args, still one call
+    function threeStrings(string memory a, string memory b, string memory c) public pure returns (bytes32) {
+        return keccak256(abi.encodePacked(a, b, c)); //~WARN: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+    }
+
+    // SHOULD PASS: only one dynamic arg
+    function oneString(string memory a, uint256 b) public pure returns (bytes32) {
+        return keccak256(abi.encodePacked(a, b));
+    }
+
+    // SHOULD PASS: no dynamic args
+    function noDynamic(uint256 a, address b) public pure returns (bytes32) {
+        return keccak256(abi.encodePacked(a, b));
+    }
+
+    // SHOULD PASS: fixed bytes are not dynamic
+    function fixedBytes(bytes32 a, bytes32 b) public pure returns (bytes32) {
+        return keccak256(abi.encodePacked(a, b));
+    }
+
+    // SHOULD PASS: fixed-size array is not dynamic
+    function fixedArray(uint256[3] memory a, uint256[3] memory b) public pure returns (bytes32) {
+        return keccak256(abi.encodePacked(a, b));
+    }
+
+    // SHOULD PASS: single string arg
+    function singleString(string memory a) public pure returns (bytes32) {
+        return keccak256(abi.encodePacked(a));
+    }
+
+    // SHOULD WARN: call returning string/bytes
+    function getString() internal pure returns (string memory) { return "x"; }
+    function getBytes() internal pure returns (bytes memory) { return hex"ff"; }
+
+    function callReturns() public pure returns (bytes32) {
+        return keccak256(abi.encodePacked(getString(), getBytes())); //~WARN: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+    }
+
+    // SHOULD WARN: explicit type casts to bytes/string
+    function typeCasts(bytes32 a, bytes32 b) public pure returns (bytes32) {
+        return keccak256(abi.encodePacked(bytes(abi.encode(a)), bytes(abi.encode(b)))); //~WARN: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+    }
+
+    // SHOULD PASS: two string literals are compile-time constants; no runtime variability
+    function stringLiterals() public pure returns (bytes32) {
+        return keccak256(abi.encodePacked("a", "bc"));
+    }
+
+    // SHOULD PASS: single string literal
+    function singleLiteral() public pure returns (bytes32) {
+        return keccak256(abi.encodePacked("abc"));
+    }
+
+    // SHOULD PASS: one literal + one dynamic is still injective
+    function literalPrefixSafe(string memory s) public pure returns (bytes32) {
+        return keccak256(abi.encodePacked("prefix:", s));
+    }
+
+    // SHOULD PASS: hex literal + one dynamic is still injective
+    function hexLiteralSafe(bytes memory b) public pure returns (bytes32) {
+        return keccak256(abi.encodePacked(hex"dead", b));
+    }
+}
+
+// SHOULD WARN: concrete contract overriding inherited name()/symbol(), both return string,
+// so even though contract_item_ids yields two entries per method the lint must still fire.
+interface IToken {
+    function name() external view returns (string memory);
+    function symbol() external view returns (string memory);
+}
+
+contract ConcreteToken is IToken {
+    function name() external pure override returns (string memory) { return "Token"; }
+    function symbol() external pure override returns (string memory) { return "TKN"; }
+}
+
+contract OverrideCollision {
+    function tokenCollision(ConcreteToken token) public view returns (bytes32) {
+        return keccak256(abi.encodePacked(token.name(), token.symbol())); //~WARN: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+    }
+}
+
+// SHOULD NOT WARN: overloaded function, must pick the correct overload based on arg type
+contract OverloadFP {
+    function f(uint256) internal pure returns (string memory) { return ""; }
+    function f(bytes32) internal pure returns (uint256) { return 1; }
+
+    // f(x) resolves to f(bytes32) → returns uint256 (not dynamic); only s is dynamic → no warn
+    function g(bytes32 x, string memory s) public pure returns (bytes32) {
+        return keccak256(abi.encodePacked(f(x), s));
+    }
+}
+
+interface IERC20Metadata {
+    function name() external view returns (string memory);
+    function symbol() external view returns (string memory);
+}
+
+contract MemberCalls {
+    // SHOULD WARN: external member calls returning string
+    function tokenCollision(IERC20Metadata token) public view returns (bytes32) {
+        return keccak256(abi.encodePacked(token.name(), token.symbol())); //~WARN: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+    }
+
+    // SHOULD PASS: single external member call
+    function singleMemberCall(IERC20Metadata token) public view returns (bytes32) {
+        return keccak256(abi.encodePacked(token.name()));
+    }
+}
+
+// Explicit interface casts; IERC20Metadata(addr).name()
+contract InterfaceCast {
+    // SHOULD WARN: interface cast receiver
+    function castCollision(address addr) public view returns (bytes32) {
+        return keccak256(abi.encodePacked(IERC20Metadata(addr).name(), IERC20Metadata(addr).symbol())); //~WARN: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+    }
+}
+
+// Contract-typed struct fields and array elements
+contract TokenRegistry {
+    struct Config { IERC20Metadata token; }
+
+    // SHOULD WARN: struct field receiver
+    function structField(Config memory cfg) public view returns (bytes32) {
+        return keccak256(abi.encodePacked(cfg.token.name(), cfg.token.symbol())); //~WARN: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+    }
+
+    // SHOULD WARN: array index receiver
+    function arrayIndex(IERC20Metadata[] memory tokens) public view returns (bytes32) {
+        return keccak256(abi.encodePacked(tokens[0].name(), tokens[0].symbol())); //~WARN: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+    }
+}
+
+// Member overloads with different arities; f() is dynamic, f(uint256) is not
+contract OverloadArity {
+    function f() internal pure returns (string memory) { return ""; }
+    function f(uint256) internal pure returns (uint256) { return 1; }
+
+    // SHOULD WARN: f() (0 args) unambiguously returns string; s is also dynamic
+    function g(string memory s) public pure returns (bytes32) {
+        return keccak256(abi.encodePacked(f(), s)); //~WARN: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+    }
+}
+
+// Ternary expressions
+contract TernaryDynamic {
+    // SHOULD WARN: ternary where both branches are dynamic strings
+    function ternaryCollision(bool flag, string memory a, string memory b, string memory c) public pure returns (bytes32) {
+        return keccak256(abi.encodePacked(flag ? a : b, c)); //~WARN: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+    }
+}
+
+// Calldata slices
+contract SliceDynamic {
+    // SHOULD WARN: calldata slice (still bytes) + another dynamic arg
+    function sliceCollision(bytes calldata data, string memory s) public pure returns (bytes32) {
+        return keccak256(abi.encodePacked(data[:4], s)); //~WARN: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+    }
+}
+
+// Fixed-size arrays with dynamic element types (e.g. string[2], bytes[2]).
+// Note: solc itself rejects these in abi.encodePacked with "Type not supported in packed mode",
+// so no lint fixture is needed, the compiler prevents the problematic pattern.
+
+// SHOULD WARN: ternary where one branch is a string literal (literal has no expr_type)
+contract TernaryLiteral {
+    function ternaryLiteralCollision(bool flag, string memory a, string memory b) public pure returns (bytes32) {
+        return keccak256(abi.encodePacked(flag ? a : "x", b)); //~WARN: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+    }
+}
+
+// SHOULD WARN: abi.encode() returns bytes, no explicit bytes() cast needed
+contract AbiEncodeNoCast {
+    function abiEncodeCollision(bytes32 a, bytes32 b) public pure returns (bytes32) {
+        return keccak256(abi.encodePacked(abi.encode(a), abi.encode(b))); //~WARN: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+    }
+
+    // SHOULD WARN: nested abi.encodePacked returns bytes
+    function nestedEncodePackedCollision(string memory a, string memory b) public pure returns (bytes32) {
+        return keccak256(abi.encodePacked(abi.encodePacked(a), abi.encodePacked(b))); //~WARN: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+    }
+}
+
+// SHOULD WARN: string.concat() returns string
+contract StringConcatArg {
+    function stringConcatCollision(string memory a, string memory b, string memory c) public pure returns (bytes32) {
+        return keccak256(abi.encodePacked(string.concat(a, b), c)); //~WARN: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+    }
+}
+
+// SHOULD WARN: bytes.concat() returns bytes
+contract BytesConcatArg {
+    function bytesConcatCollision(bytes memory a, bytes memory b, bytes memory c) public pure returns (bytes32) {
+        return keccak256(abi.encodePacked(bytes.concat(a, b), c)); //~WARN: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+    }
+}
+
+// SHOULD WARN: msg.data (bytes calldata) + string
+contract MsgDataCollision {
+    function msgDataAndString(string memory s) external pure returns (bytes32) {
+        return keccak256(abi.encodePacked(msg.data, s)); //~WARN: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+    }
+}
+
+// SHOULD WARN: address.code (bytes memory) + string
+contract AddrCodeCollision {
+    function addrCodeAndString(address addr, string memory s) public view returns (bytes32) {
+        return keccak256(abi.encodePacked(addr.code, s)); //~WARN: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+    }
+
+    function thisCodeAndString(string memory s) public view returns (bytes32) {
+        return keccak256(abi.encodePacked(address(this).code, s)); //~WARN: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+    }
+}
+
+// SHOULD PASS: a user-defined struct field named `code` is not address.code bytes.
+contract StructCodeField {
+    struct S { uint256 code; }
+
+    function structCodeAndString(S memory x, string memory s) public pure returns (bytes32) {
+        return keccak256(abi.encodePacked(x.code, s));
+    }
+}
+
+// SHOULD WARN: new bytes() + string
+contract NewBytesCollision {
+    function newBytesAndString(uint256 n, string memory s) public pure returns (bytes32) {
+        return keccak256(abi.encodePacked(new bytes(n), s)); //~WARN: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+    }
+}
+
+// SHOULD WARN: public string state variable; synthesized getter returns string
+contract PublicGetterToken {
+    string public name = "Token";
+    string public symbol = "TKN";
+}
+contract PublicGetterCollision {
+    function getterCollision(PublicGetterToken t) public view returns (bytes32) {
+        return keccak256(abi.encodePacked(t.name(), t.symbol())); //~WARN: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+    }
+}
+
+// SHOULD WARN: library function returning string
+library StringLib {
+    function toStr(uint256) internal pure returns (string memory) { return ""; }
+}
+contract LibraryCallCollision {
+    function libraryStringCollision(uint256 a, uint256 b) public pure returns (bytes32) {
+        return keccak256(abi.encodePacked(StringLib.toStr(a), StringLib.toStr(b))); //~WARN: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+    }
+}
+
+// SHOULD WARN: overload where the selected overload returns string.
+contract OverloadShouldWarnFN {
+    function f(uint256) internal pure returns (string memory) { return ""; }
+    function f(bytes32) internal pure returns (uint256) { return 0; }
+
+    // f(x) resolves to f(uint256) -> string (dynamic)
+    function g(uint256 x, string memory s) public pure returns (bytes32) {
+        return keccak256(abi.encodePacked(f(x), s)); //~WARN: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+    }
+}
diff --git a/crates/lint/testdata/EncodePackedCollision.stderr b/crates/lint/testdata/EncodePackedCollision.stderr
new file mode 100644
index 0000000000000..2f9c9602e8be5
--- /dev/null
+++ b/crates/lint/testdata/EncodePackedCollision.stderr
@@ -0,0 +1,224 @@
+warning[encode-packed-collision]: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+   ╭▸ ROOT/testdata/EncodePackedCollision.sol:LL:CC
+   │
+LL │ …     return keccak256(abi.encodePacked(a, b));
+   │                        ━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/encode-packed-collision
+
+warning[encode-packed-collision]: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+   ╭▸ ROOT/testdata/EncodePackedCollision.sol:LL:CC
+   │
+LL │ …     return keccak256(abi.encodePacked(a, b));
+   │                        ━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/encode-packed-collision
+
+warning[encode-packed-collision]: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+   ╭▸ ROOT/testdata/EncodePackedCollision.sol:LL:CC
+   │
+LL │ …     return keccak256(abi.encodePacked(a, b));
+   │                        ━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/encode-packed-collision
+
+warning[encode-packed-collision]: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+   ╭▸ ROOT/testdata/EncodePackedCollision.sol:LL:CC
+   │
+LL │ …     return keccak256(abi.encodePacked(a, b));
+   │                        ━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/encode-packed-collision
+
+warning[encode-packed-collision]: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+   ╭▸ ROOT/testdata/EncodePackedCollision.sol:LL:CC
+   │
+LL │ …     return keccak256(abi.encodePacked(a, b));
+   │                        ━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/encode-packed-collision
+
+warning[encode-packed-collision]: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+   ╭▸ ROOT/testdata/EncodePackedCollision.sol:LL:CC
+   │
+LL │ …     return keccak256(abi.encodePacked(a, b, c));
+   │                        ━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/encode-packed-collision
+
+warning[encode-packed-collision]: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+   ╭▸ ROOT/testdata/EncodePackedCollision.sol:LL:CC
+   │
+LL │ …     return keccak256(abi.encodePacked(getString(), getBytes()));
+   │                        ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/encode-packed-collision
+
+warning[encode-packed-collision]: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+   ╭▸ ROOT/testdata/EncodePackedCollision.sol:LL:CC
+   │
+LL │ …     return keccak256(abi.encodePacked(bytes(abi.encode(a)), bytes(abi.encode(b))));
+   │                        ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/encode-packed-collision
+
+warning[encode-packed-collision]: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+   ╭▸ ROOT/testdata/EncodePackedCollision.sol:LL:CC
+   │
+LL │ …     return keccak256(abi.encodePacked(token.name(), token.symbol()));
+   │                        ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/encode-packed-collision
+
+warning[encode-packed-collision]: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+   ╭▸ ROOT/testdata/EncodePackedCollision.sol:LL:CC
+   │
+LL │ …     return keccak256(abi.encodePacked(token.name(), token.symbol()));
+   │                        ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/encode-packed-collision
+
+warning[encode-packed-collision]: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+   ╭▸ ROOT/testdata/EncodePackedCollision.sol:LL:CC
+   │
+LL │ …     return keccak256(abi.encodePacked(IERC20Metadata(addr).name(), IERC20Metadata(addr).symbol()));
+   │                        ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/encode-packed-collision
+
+warning[encode-packed-collision]: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+   ╭▸ ROOT/testdata/EncodePackedCollision.sol:LL:CC
+   │
+LL │ …     return keccak256(abi.encodePacked(cfg.token.name(), cfg.token.symbol()));
+   │                        ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/encode-packed-collision
+
+warning[encode-packed-collision]: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+   ╭▸ ROOT/testdata/EncodePackedCollision.sol:LL:CC
+   │
+LL │ …     return keccak256(abi.encodePacked(tokens[0].name(), tokens[0].symbol()));
+   │                        ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/encode-packed-collision
+
+warning[encode-packed-collision]: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+   ╭▸ ROOT/testdata/EncodePackedCollision.sol:LL:CC
+   │
+LL │ …     return keccak256(abi.encodePacked(f(), s));
+   │                        ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/encode-packed-collision
+
+warning[encode-packed-collision]: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+   ╭▸ ROOT/testdata/EncodePackedCollision.sol:LL:CC
+   │
+LL │ …     return keccak256(abi.encodePacked(flag ? a : b, c));
+   │                        ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/encode-packed-collision
+
+warning[encode-packed-collision]: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+   ╭▸ ROOT/testdata/EncodePackedCollision.sol:LL:CC
+   │
+LL │ …     return keccak256(abi.encodePacked(data[:4], s));
+   │                        ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/encode-packed-collision
+
+warning[encode-packed-collision]: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+   ╭▸ ROOT/testdata/EncodePackedCollision.sol:LL:CC
+   │
+LL │ …     return keccak256(abi.encodePacked(flag ? a : "x", b));
+   │                        ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/encode-packed-collision
+
+warning[encode-packed-collision]: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+   ╭▸ ROOT/testdata/EncodePackedCollision.sol:LL:CC
+   │
+LL │ …     return keccak256(abi.encodePacked(abi.encode(a), abi.encode(b)));
+   │                        ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/encode-packed-collision
+
+warning[encode-packed-collision]: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+   ╭▸ ROOT/testdata/EncodePackedCollision.sol:LL:CC
+   │
+LL │ …     return keccak256(abi.encodePacked(abi.encodePacked(a), abi.encodePacked(b)));
+   │                        ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/encode-packed-collision
+
+warning[encode-packed-collision]: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+   ╭▸ ROOT/testdata/EncodePackedCollision.sol:LL:CC
+   │
+LL │ …     return keccak256(abi.encodePacked(string.concat(a, b), c));
+   │                        ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/encode-packed-collision
+
+warning[encode-packed-collision]: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+   ╭▸ ROOT/testdata/EncodePackedCollision.sol:LL:CC
+   │
+LL │ …     return keccak256(abi.encodePacked(bytes.concat(a, b), c));
+   │                        ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/encode-packed-collision
+
+warning[encode-packed-collision]: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+   ╭▸ ROOT/testdata/EncodePackedCollision.sol:LL:CC
+   │
+LL │ …     return keccak256(abi.encodePacked(msg.data, s));
+   │                        ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/encode-packed-collision
+
+warning[encode-packed-collision]: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+   ╭▸ ROOT/testdata/EncodePackedCollision.sol:LL:CC
+   │
+LL │ …     return keccak256(abi.encodePacked(addr.code, s));
+   │                        ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/encode-packed-collision
+
+warning[encode-packed-collision]: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+   ╭▸ ROOT/testdata/EncodePackedCollision.sol:LL:CC
+   │
+LL │ …     return keccak256(abi.encodePacked(address(this).code, s));
+   │                        ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/encode-packed-collision
+
+warning[encode-packed-collision]: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+   ╭▸ ROOT/testdata/EncodePackedCollision.sol:LL:CC
+   │
+LL │ …     return keccak256(abi.encodePacked(new bytes(n), s));
+   │                        ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/encode-packed-collision
+
+warning[encode-packed-collision]: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+   ╭▸ ROOT/testdata/EncodePackedCollision.sol:LL:CC
+   │
+LL │ …     return keccak256(abi.encodePacked(t.name(), t.symbol()));
+   │                        ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/encode-packed-collision
+
+warning[encode-packed-collision]: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+   ╭▸ ROOT/testdata/EncodePackedCollision.sol:LL:CC
+   │
+LL │ …     return keccak256(abi.encodePacked(StringLib.toStr(a), StringLib.toStr(b)));
+   │                        ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/encode-packed-collision
+
+warning[encode-packed-collision]: `abi.encodePacked()` called with multiple dynamic type arguments; hash collisions possible
+   ╭▸ ROOT/testdata/EncodePackedCollision.sol:LL:CC
+   │
+LL │ …     return keccak256(abi.encodePacked(f(x), s));
+   │                        ━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/encode-packed-collision
+
diff --git a/crates/lint/testdata/EnumerableLoopRemoval.sol b/crates/lint/testdata/EnumerableLoopRemoval.sol
new file mode 100644
index 0000000000000..a9210e42147eb
--- /dev/null
+++ b/crates/lint/testdata/EnumerableLoopRemoval.sol
@@ -0,0 +1,824 @@
+//@compile-flags: --only-lint enumerable-loop-removal
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+import {EnumerableSet as ES} from "./auxiliary/EnumerableSetLib.sol";
+
+// Tests for `enumerable-loop-removal`: EnumerableSet removal is swap-and-pop, so calling
+// `remove` inside a loop that also iterates the same set with `at` at an index the loop advances
+// upward skips elements or reads out-of-bounds indices.
+//
+// The detector reports only the shape it can judge without a flow analysis: an unconditional
+// ascending cadence (`i++`, `i += 1`, `i = i + 1`, or `i = 1 + i` on the straight line of the
+// body), an `at` read at that cadence, a `remove` on the same set, and a straight-line body. Calls
+// resolve through the type checker, so the method-call and library-qualified forms are both
+// covered, and storage-reference aliases are followed to the set they name where the loop runs.
+//
+// Anything outside that shape is left clean rather than guessed at: a conditional or mixed
+// cadence, a composite index, a `break`/`continue`/`return`/`revert`, an unconditional failure
+// such as `require(false)`, inline assembly, a nested loop, or a `try`. Expression-level
+// short-circuits and ternaries remain structural expressions and are scanned, except for arms a
+// literal boolean proves unreachable. Some clean cases are genuine corruptions the detector
+// deliberately does not report; they are grouped under "Documented limitations" below.
+// Distinguishing them would take the flow or value analysis this detector does not run.
+
+// Minimal mirror of OpenZeppelin's EnumerableSet: swap-and-pop removal, index access via `at`.
+library EnumerableSet {
+    struct AddressSet {
+        address[] _values;
+        mapping(address => uint256) _positions;
+    }
+
+    struct UintSet {
+        uint256[] _values;
+        mapping(uint256 => uint256) _positions;
+    }
+
+    function add(AddressSet storage set, address value) internal returns (bool) {
+        if (set._positions[value] != 0) return false;
+        set._values.push(value);
+        set._positions[value] = set._values.length;
+        return true;
+    }
+
+    function at(AddressSet storage set, uint256 index) internal view returns (address) {
+        return set._values[index];
+    }
+
+    function remove(AddressSet storage set, address value) internal returns (bool) {
+        uint256 position = set._positions[value];
+        if (position == 0) return false;
+        uint256 lastIndex = set._values.length - 1;
+        if (position - 1 != lastIndex) {
+            address lastValue = set._values[lastIndex];
+            set._values[position - 1] = lastValue;
+            set._positions[lastValue] = position;
+        }
+        set._values.pop();
+        delete set._positions[value];
+        return true;
+    }
+
+    function length(AddressSet storage set) internal view returns (uint256) {
+        return set._values.length;
+    }
+
+    function at(UintSet storage set, uint256 index) internal view returns (uint256) {
+        return set._values[index];
+    }
+
+    function remove(UintSet storage set, uint256 value) internal returns (bool) {
+        uint256 position = set._positions[value];
+        if (position == 0) return false;
+        uint256 lastIndex = set._values.length - 1;
+        if (position - 1 != lastIndex) {
+            uint256 lastValue = set._values[lastIndex];
+            set._values[position - 1] = lastValue;
+            set._positions[lastValue] = position;
+        }
+        set._values.pop();
+        delete set._positions[value];
+        return true;
+    }
+
+    function length(UintSet storage set) internal view returns (uint256) {
+        return set._values.length;
+    }
+}
+
+// Same method names and shapes, but not an EnumerableSet: must never fire.
+library CustomSet {
+    struct Bag {
+        address[] _values;
+    }
+
+    function at(Bag storage bag, uint256 index) internal view returns (address) {
+        return bag._values[index];
+    }
+
+    function remove(Bag storage bag, uint256 index) internal {
+        bag._values[index] = bag._values[bag._values.length - 1];
+        bag._values.pop();
+    }
+
+    function length(Bag storage bag) internal view returns (uint256) {
+        return bag._values.length;
+    }
+}
+
+contract EnumerableLoopRemoval {
+    using EnumerableSet for EnumerableSet.AddressSet;
+    using EnumerableSet for EnumerableSet.UintSet;
+    using CustomSet for CustomSet.Bag;
+    using Helpers for EnumerableSet.AddressSet;
+
+    struct SetPair {
+        EnumerableSet.AddressSet first;
+        EnumerableSet.AddressSet second;
+    }
+
+    EnumerableSet.AddressSet internal holders;
+    EnumerableSet.AddressSet internal others;
+    EnumerableSet.UintSet internal ids;
+    CustomSet.Bag internal bag;
+    SetPair internal pair;
+    mapping(uint256 => EnumerableSet.AddressSet) internal keyed;
+    address internal pending;
+    uint256 internal cursor;
+    uint256 internal position;
+
+    error Stop();
+
+    // ─────────────────────────────────────────────────────────────────────────────
+    // Reported: unconditional ascending cadence, straight-line body, same set.
+    // ─────────────────────────────────────────────────────────────────────────────
+
+    function removeWhileIteratingFor() public {
+        // `remove` shrinks the set while `at(i)` walks it upward: elements are skipped.
+        for (uint256 i = 0; i < holders.length(); i++) {
+            holders.remove(holders.at(i)); //~WARN: EnumerableSet
+        }
+    }
+
+    function removeWhileIteratingDoWhile() public {
+        uint256 i = 0;
+        do {
+            address value = holders.at(i);
+            holders.remove(value); //~WARN: EnumerableSet
+            i++;
+        } while (i < holders.length());
+    }
+
+    function removeQualifiedForm() public {
+        // The library-qualified form of the unsafe pattern: same set, ascending index.
+        for (uint256 i = 0; i < EnumerableSet.length(holders); i++) {
+            EnumerableSet.remove(holders, EnumerableSet.at(holders, i)); //~WARN: EnumerableSet
+        }
+    }
+
+    function removeMixedForms() public {
+        // The two call forms mixed on the same set corrupt the iteration all the same.
+        for (uint256 i = 0; i < holders.length(); i++) {
+            holders.remove(EnumerableSet.at(holders, i)); //~WARN: EnumerableSet
+        }
+    }
+
+    ES.AddressSet internal imported;
+
+    function removeAliasedImport() public {
+        // An import alias renames the call site, not the declared library the calls resolve to.
+        for (uint256 i = 0; i < ES.length(imported); i++) {
+            ES.remove(imported, ES.at(imported, i)); //~WARN: EnumerableSet
+        }
+    }
+
+    function removeUintSet() public {
+        for (uint256 i = 0; i < ids.length(); i++) {
+            ids.remove(ids.at(i)); //~WARN: EnumerableSet
+        }
+    }
+
+    // A named literal index drains at a fixed position, so it is clean; a named varying index in
+    // the method form still walks the loop.
+    function removeMethodFormNamedVaryingIndex() public {
+        for (uint256 i = 0; i < holders.length(); i++) {
+            holders.remove(holders.at({index: i})); //~WARN: EnumerableSet
+        }
+    }
+
+    function removeNamedArgumentsOnSameSet() public {
+        for (uint256 i = 0; i < holders.length(); i++) {
+            EnumerableSet.remove({ //~WARN: EnumerableSet
+                value: EnumerableSet.at({index: i, set: holders}),
+                set: holders
+            });
+        }
+    }
+
+    // A state cursor advanced by the loop on its straight line walks the set the same way a local
+    // does, and the removal skips the elements swapped into the slots behind the cursor.
+    function removeAtAdvancingStateCursor() public {
+        while (cursor < holders.length()) {
+            holders.remove(holders.at(cursor)); //~WARN: EnumerableSet
+            cursor++;
+        }
+    }
+
+    // A varying mapping key cannot be read, so it may name the iterated set.
+    function removeAtVaryingMappingKey(uint256 key) public {
+        for (uint256 i = 0; i < keyed[1].length(); i++) {
+            keyed[key].remove(keyed[1].at(i)); //~WARN: EnumerableSet
+        }
+    }
+
+    // The alias may stand on either side of the walk; the reference is followed to `holders`.
+    function removeThroughStorageAlias() public {
+        EnumerableSet.AddressSet storage alias_ = holders;
+        for (uint256 i = 0; i < alias_.length(); i++) {
+            holders.remove(alias_.at(i)); //~WARN: EnumerableSet
+        }
+    }
+
+    function removeThroughStorageAliasWhileIteratingTheState() public {
+        EnumerableSet.AddressSet storage alias_ = holders;
+        for (uint256 i = 0; i < holders.length(); i++) {
+            alias_.remove(holders.at(i)); //~WARN: EnumerableSet
+        }
+    }
+
+    // The straight-line rebinding stands where the loop runs: `alias_` names `holders` here.
+    function removeThroughReassignedStorageAlias() public {
+        EnumerableSet.AddressSet storage alias_ = others;
+        alias_ = holders;
+        for (uint256 i = 0; i < holders.length(); i++) {
+            holders.remove(alias_.at(i)); //~WARN: EnumerableSet
+        }
+    }
+
+    // Rebound only under a condition before the loop, the reference has no one binding where the
+    // loop reads it, so it may name the removed set.
+    function removeThroughConditionallyReboundAlias(bool swap) public {
+        EnumerableSet.AddressSet storage alias_ = others;
+        if (swap) {
+            alias_ = holders;
+        }
+        for (uint256 i = 0; i < alias_.length(); i++) {
+            holders.remove(alias_.at(i)); //~WARN: EnumerableSet
+        }
+    }
+
+    // A reference bound to another reference takes what that one named right then; the later
+    // rebinding of `source` does not reach back, so `alias_` still walks `others`.
+    function removeThroughAliasBoundToEarlierAlias() public {
+        EnumerableSet.AddressSet storage source = others;
+        EnumerableSet.AddressSet storage alias_ = source;
+        source = holders;
+        for (uint256 i = 0; i < alias_.length(); i++) {
+            others.remove(alias_.at(i)); //~WARN: EnumerableSet
+        }
+    }
+
+    // A tuple-destructured reference never got a binding the walk can read and carries no
+    // initializer, so it may name the removed set.
+    function removeThroughTupleBoundAlias() public {
+        (EnumerableSet.AddressSet storage first,) = twoSets();
+        for (uint256 i = 0; i < first.length(); i++) {
+            holders.remove(first.at(i)); //~WARN: EnumerableSet
+        }
+    }
+
+    function twoSets()
+        internal
+        view
+        returns (EnumerableSet.AddressSet storage, EnumerableSet.AddressSet storage)
+    {
+        return (holders, holders);
+    }
+
+    // A single storage-reference parameter is one set wherever the caller took it from.
+    function removeThroughSameStorageParameter(EnumerableSet.AddressSet storage set) internal {
+        for (uint256 i = 0; i < set.length(); i++) {
+            set.remove(set.at(i)); //~WARN: EnumerableSet
+        }
+    }
+
+    // The simple ascending step forms all pace the loop: `i += 1`, `i = i + 1`, and `i = 1 + i`
+    // read the same as `i++`, whether in the `for`'s next-step or on the body's straight line.
+    function removeWithCompoundStep() public {
+        for (uint256 i = 0; i < holders.length(); i += 1) {
+            holders.remove(holders.at(i)); //~WARN: EnumerableSet
+        }
+    }
+
+    function removeWithPlainAssignStep() public {
+        for (uint256 i = 0; i < holders.length(); i = i + 1) {
+            holders.remove(holders.at(i)); //~WARN: EnumerableSet
+        }
+    }
+
+    function removeWithCommutedPlainAssignStep() public {
+        for (uint256 i = 0; i < holders.length(); i = 1 + i) {
+            holders.remove(holders.at(i)); //~WARN: EnumerableSet
+        }
+    }
+
+    function removeWithInBodyStep() public {
+        for (uint256 i = 0; i < holders.length();) {
+            holders.remove(holders.at(i)); //~WARN: EnumerableSet
+            i++;
+        }
+    }
+
+    // The step lands inside an `unchecked` block, transparent to the straight-line reading.
+    function removeWithUncheckedStep() public {
+        for (uint256 i = 0; i < holders.length();) {
+            holders.remove(holders.at(i)); //~WARN: EnumerableSet
+            unchecked {
+                ++i;
+            }
+        }
+    }
+
+    // A step of more than one still ascends and still walks into swapped-in tail elements.
+    function removeWithStepOfTwo() public {
+        for (uint256 i = 0; i < holders.length(); i += 2) {
+            holders.remove(holders.at(i)); //~WARN: EnumerableSet
+        }
+    }
+
+    // Expression-level conditions do not make the body structurally branch. The detector scans
+    // both arms and may report a `remove` even when runtime values skip it.
+    function removeInShortCircuitExpression(bool enabled, address target) public {
+        for (uint256 i = 0; i < holders.length(); i++) {
+            pending = holders.at(i);
+            bool removed = enabled && holders.remove(target); //~WARN: EnumerableSet
+            removed;
+        }
+    }
+
+    function removeInTernaryExpression(bool enabled, address target) public {
+        for (uint256 i = 0; i < holders.length(); i++) {
+            pending = holders.at(i);
+            bool removed = enabled ? holders.remove(target) : false; //~WARN: EnumerableSet
+            removed;
+        }
+    }
+
+    // Literal expression conditions are enough to prove these removal arms unreachable.
+    function unreachableRemoveInShortCircuitExpression(address target) public {
+        for (uint256 i = 0; i < holders.length(); i++) {
+            pending = holders.at(i);
+            bool removed = false && holders.remove(target);
+            removed;
+        }
+    }
+
+    function unreachableRemoveInTernaryExpression(address target) public {
+        for (uint256 i = 0; i < holders.length(); i++) {
+            pending = holders.at(i);
+            bool removed = false ? holders.remove(target) : false;
+            removed;
+        }
+    }
+
+    function unreachableAtInShortCircuitExpression(address target) public {
+        for (uint256 i = 0; i < holders.length(); i++) {
+            bool visited = false && holders.at(i) == target;
+            holders.remove(target);
+            visited;
+        }
+    }
+
+    // Conversely, a literal-selected removal is still reachable and reported.
+    function reachableRemoveInTernaryExpression(address target) public {
+        for (uint256 i = 0; i < holders.length(); i++) {
+            pending = holders.at(i);
+            bool removed = true ? holders.remove(target) : false; //~WARN: EnumerableSet
+            removed;
+        }
+    }
+
+    // The remove argument is not correlated with the `at` result. Removing the current tail does
+    // not shift another value, but this still matches the structural same-set pattern.
+    function removeTailWhileReadingAscending() public {
+        for (uint256 i = 0; i < holders.length(); i++) {
+            pending = holders.at(i);
+            holders.remove(holders.at(holders.length() - 1)); //~WARN: EnumerableSet
+        }
+    }
+
+    // ─────────────────────────────────────────────────────────────────────────────
+    // Clean: no ascending cadence, no `at` on the removed set, or a different set.
+    // ─────────────────────────────────────────────────────────────────────────────
+
+    // Draining at the literal index 0 is safe: the swap refills the read position.
+    function removeWhileIteratingWhile() public {
+        while (holders.length() > 0) {
+            address value = holders.at(0);
+            holders.remove(value);
+        }
+    }
+
+    // The `at` walks a different EnumerableSet instance.
+    function removeWithAtOnOtherSet() public {
+        for (uint256 i = 0; i < others.length(); i++) {
+            holders.remove(others.at(i));
+        }
+    }
+
+    // Iterate with `at`, collect, then remove in a second loop that has no `at`.
+    function collectThenRemove() public {
+        address[] memory toRemove = new address[](holders.length());
+        for (uint256 i = 0; i < holders.length(); i++) {
+            toRemove[i] = holders.at(i);
+        }
+        for (uint256 i = 0; i < toRemove.length; i++) {
+            holders.remove(toRemove[i]);
+        }
+    }
+
+    // No `remove` at all.
+    function atOnlyInLoop() public view returns (uint256 count) {
+        for (uint256 i = 0; i < holders.length(); i++) {
+            if (holders.at(i) != address(0)) count++;
+        }
+    }
+
+    function removeOutsideLoop(address value) public {
+        holders.remove(value);
+    }
+
+    // Two fields of one struct are two sets, and so are two literal keys of one mapping.
+    function removeFromAnotherStructField() public {
+        for (uint256 i = 0; i < pair.first.length(); i++) {
+            pair.second.remove(pair.first.at(i));
+        }
+    }
+
+    function removeAtAnotherMappingKey() public {
+        for (uint256 i = 0; i < keyed[1].length(); i++) {
+            keyed[2].remove(keyed[1].at(i));
+        }
+    }
+
+    // The qualified form on another set, written with named arguments.
+    function removeNamedArgumentsOnOtherSet() public {
+        for (uint256 i = 0; i < others.length(); i++) {
+            EnumerableSet.remove({
+                value: EnumerableSet.at({index: i, set: others}),
+                set: holders
+            });
+        }
+    }
+
+    // A named literal index drains at a fixed position, in the method form too.
+    function removeMethodFormNamedLiteralIndex() public {
+        while (holders.length() > 0) {
+            holders.remove(holders.at({index: 0}));
+        }
+    }
+
+    uint256 internal constant FIRST = 0;
+
+    // A literal, a cast of one, a named `constant`, or constant arithmetic names no ascending
+    // cadence: the read drains position zero, which swap-and-pop keeps refilling.
+    function removeAtNamedConstantIndex() public {
+        while (holders.length() > 0) {
+            holders.remove(holders.at(FIRST));
+        }
+    }
+
+    function removeAtCastLiteralIndex() public {
+        while (holders.length() > 0) {
+            holders.remove(holders.at(uint256(0)));
+        }
+    }
+
+    function removeAtConstantArithmeticIndex() public {
+        while (holders.length() > 0) {
+            holders.remove(holders.at(0 + 0));
+        }
+    }
+
+    // A cursor the loop never advances is not an ascending cadence: the read drains the position
+    // swap-and-pop keeps refilling, and the loop ends when the length comes down to it.
+    function removeAtStationaryCursor() public {
+        while (holders.length() > position) {
+            holders.remove(holders.at(position));
+        }
+    }
+
+    // This exact reverse drain removes the current tail, so swap-and-pop does not move another
+    // value. Descending cadences are outside the reported shape regardless; an unsafe descending
+    // case is pinned under the documented limitations below.
+    function removeAtDescendingIndex() public {
+        for (uint256 i = holders.length(); i > 0; i--) {
+            holders.remove(holders.at(i - 1));
+        }
+    }
+
+    function removeAtDescendingAssignedIndex() public {
+        uint256 i = holders.length();
+        while (i > 0) {
+            i = i - 1;
+            holders.remove(holders.at(i));
+        }
+    }
+
+    // The reference is rebound only after the loop: where the loop runs it still names `others`.
+    function removeThroughAliasReboundAfterTheLoop() public {
+        EnumerableSet.AddressSet storage alias_ = others;
+        for (uint256 i = 0; i < alias_.length(); i++) {
+            holders.remove(alias_.at(i));
+        }
+        alias_ = holders;
+    }
+
+    // At the loop `alias_` is `holders`, so removing from `others` touches nothing it iterates.
+    function removeAnotherSetThroughReassignedStorageAlias() public {
+        EnumerableSet.AddressSet storage alias_ = others;
+        alias_ = holders;
+        for (uint256 i = 0; i < holders.length(); i++) {
+            others.remove(alias_.at(i));
+        }
+    }
+
+    // A copy of a cursor the loop never moves carries no cadence: still a fixed-slot drain.
+    function removeAtCopyOfStationaryCursor() public {
+        while (holders.length() > position) {
+            uint256 idx = position;
+            holders.remove(holders.at(idx));
+        }
+    }
+
+    // A no-op step (`i += 0`, likewise `i = i`) does not advance, so it names no ascending
+    // cadence: the loop drains the fixed slot swap-and-pop keeps refilling, which is safe.
+    function removeWithNoOpStep() public {
+        uint256 i = 0;
+        while (holders.length() > i) {
+            holders.remove(holders.at(i));
+            i += 0;
+        }
+    }
+
+    // Every write to a cadence must be a supported ascending step. These both leave `i` at zero,
+    // so swap-and-pop refills the slot read on the next turn.
+    function removeWithIncrementThenDecrement() public {
+        uint256 i = 0;
+        while (holders.length() > i) {
+            holders.remove(holders.at(i));
+            i++;
+            i--;
+        }
+    }
+
+    function removeWithIncrementThenReset() public {
+        uint256 i = 0;
+        while (holders.length() > i) {
+            holders.remove(holders.at(i));
+            i++;
+            i = 0;
+        }
+    }
+
+    // Mentioning the cadence inside an expression does not make it the paced index. Modulo one
+    // always reads slot zero, which the swap refills while the set drains.
+    function removeAtModuloCadence() public {
+        uint256 i = 0;
+        while (holders.length() > 0) {
+            holders.remove(holders.at(i % 1));
+            i++;
+        }
+    }
+
+    // The mutation is rolled back and no next iteration is observable.
+    function removeThenRevert() public {
+        uint256 i = 0;
+        while (holders.length() > i) {
+            holders.remove(holders.at(i));
+            i++;
+            revert();
+        }
+    }
+
+    function removeThenCustomErrorRevert() public {
+        uint256 i = 0;
+        while (holders.length() > i) {
+            holders.remove(holders.at(i));
+            i++;
+            revert Stop();
+        }
+    }
+
+    function removeThenRequireFalse() public {
+        uint256 i = 0;
+        while (holders.length() > i) {
+            holders.remove(holders.at(i));
+            i++;
+            require(false, "stop");
+        }
+    }
+
+    function removeThenAssertFalse() public {
+        uint256 i = 0;
+        while (holders.length() > i) {
+            holders.remove(holders.at(i));
+            i++;
+            assert(false);
+        }
+    }
+
+    // Non-terminal require/assert calls can reach another iteration and remain reported.
+    function removeThenRequireTrue() public {
+        uint256 i = 0;
+        while (holders.length() > i) {
+            holders.remove(holders.at(i)); //~WARN: EnumerableSet
+            i++;
+            require(true);
+        }
+    }
+
+    function removeThenAssertTrue() public {
+        uint256 i = 0;
+        while (holders.length() > i) {
+            holders.remove(holders.at(i)); //~WARN: EnumerableSet
+            i++;
+            assert(true);
+        }
+    }
+
+    // Inline assembly is outside the straight-line analysis. In particular, a Yul revert rolls
+    // back the removal and never reaches another iteration.
+    function removeThenAssemblyRevert() public {
+        uint256 i = 0;
+        while (holders.length() > i) {
+            holders.remove(holders.at(i));
+            i++;
+            assembly {
+                revert(0, 0)
+            }
+        }
+    }
+
+    // A local declared in the body is initialized again on every turn. Its later increment does
+    // not make it an ascending cadence across iterations.
+    function removeWithRepeatedDeclarationReset() public {
+        while (holders.length() > 0) {
+            uint256 i = 0;
+            holders.remove(holders.at(i));
+            i++;
+        }
+    }
+
+    function removeWithRepeatedTupleDeclarationReset() public {
+        while (holders.length() > 0) {
+            (uint256 i, uint256 other) = (0, 0);
+            holders.remove(holders.at(i));
+            i++;
+            other;
+        }
+    }
+
+    function removeWithDeleteReset() public {
+        uint256 i = 0;
+        while (holders.length() > i) {
+            holders.remove(holders.at(i));
+            i++;
+            delete i;
+        }
+    }
+
+    function removeWithTupleReset() public {
+        uint256 i = 0;
+        while (holders.length() > i) {
+            holders.remove(holders.at(i));
+            i++;
+            (i, position) = (0, position);
+        }
+    }
+
+    // Same `at`/`remove` names on a non-EnumerableSet type: out of scope.
+    function customSetIsNotEnumerableSet() public {
+        for (uint256 i = 0; i < bag.length(); i++) {
+            if (bag.at(i) == address(0)) bag.remove(i);
+        }
+    }
+
+    // A user library function named `remove` attached to the set type resolves to the helper, not
+    // to EnumerableSet's swap-and-pop, so it is out of scope.
+    function helperRemoveIsNotEnumerable() public {
+        for (uint256 i = 0; i < holders.length(); i++) {
+            if (holders.at(i) == address(0)) holders.remove(i);
+        }
+    }
+
+    // ─────────────────────────────────────────────────────────────────────────────
+    // Documented limitations: real corruptions left unreported because judging them safely would
+    // take flow or value analysis this detector does not run. It stays silent outside the narrow
+    // supported shape.
+    // ─────────────────────────────────────────────────────────────────────────────
+
+    // A conditional removal whose set the loop keeps iterating: safe only if the mutating path
+    // exits, which is not read here. Not reported: the body has an `if`.
+    function removeUnderConditionKeepsIterating(address target) public {
+        for (uint256 i = 0; i < holders.length(); i++) {
+            if (holders.at(i) == target) holders.remove(target);
+        }
+    }
+
+    // A `continue` comes round again on the shrunk set, but the body is not straight-line.
+    function removeAndContinue(address target) public {
+        for (uint256 i = 0; i < holders.length(); i++) {
+            if (holders.at(i) == target) {
+                holders.remove(target);
+                continue;
+            }
+        }
+    }
+
+    // A removal in a nested loop mutates the set the outer loop walks; nested loops are not read.
+    function removeInInnerLoop() public {
+        for (uint256 i = 0; i < holders.length(); i++) {
+            address value = holders.at(i);
+            for (uint256 j = 0; j < 2; j++) {
+                holders.remove(value);
+            }
+        }
+    }
+
+    // A removal decided by a short-circuit whose other operand can steer past the exit; the body
+    // has a `break`, so it is not read.
+    function removeConditionCarriesAnotherOperand(address target, bool flag) public {
+        for (uint256 i = 0; i < holders.length(); i++) {
+            pending = holders.at(i);
+            if (holders.remove(target) && flag) break;
+        }
+    }
+
+    // A copy of the ascending cadence walks the loop the same way, but copy propagation is not
+    // run: `at(idx)` is not tied back to `i`.
+    function removeAtCopiedIndex() public {
+        for (uint256 i = 0; i < holders.length(); i++) {
+            uint256 idx = i;
+            holders.remove(holders.at(idx));
+        }
+    }
+
+    // Composite indices are outside the deliberately narrow `at(i)` shape, even when their value
+    // equals the cadence and the loop has the same corruption.
+    function removeAtCompositeCadenceIndex() public {
+        for (uint256 i = 0; i < holders.length(); i++) {
+            holders.remove(holders.at(i + 0));
+        }
+    }
+
+    // Unlike the safe reverse drain above, removing another value can move the already-read tail
+    // into a slot this descending walk has not visited. All descending traversals are deliberately
+    // unreported because the detector does not prove the relationship between `at` and `remove`.
+    function removeAnotherValueWhileDescending(address target) public {
+        for (uint256 i = holders.length(); i > 0; i--) {
+            pending = holders.at(i - 1);
+            holders.remove(target);
+        }
+    }
+
+    // A descending walk that also steps up on some turns can revisit a swapped-in slot; the body
+    // has an `if`, so direction is not analyzed.
+    function removeAtDescendingIndexAlsoSteppedUp(bool skipTwo) public {
+        for (uint256 i = holders.length(); i > 0; i--) {
+            holders.remove(holders.at(i - 1));
+            if (skipTwo) {
+                i += 2;
+            }
+        }
+    }
+
+    // A member cursor advanced inside a nested loop paces the outer walk, but a member expression
+    // is not a bare-identifier cadence and the nested loop is not read.
+    function removeWithMemberCursor() public {
+        for (uint256 i = 0; i < holders.length(); i++) {
+            for (uint256 k = 0; k < 1; k++) {
+                pending = holders.at(cursor);
+                cursor++;
+            }
+            holders.remove(pending);
+        }
+    }
+
+    // A removal in a `try` clause that falls through iterates on the shrunk set; `try` is not read.
+    function removeInTryClause() public {
+        for (uint256 i = 0; i < holders.length(); i++) {
+            address value = holders.at(i);
+            try this.probe() {
+                holders.remove(value);
+            } catch {
+                return;
+            }
+        }
+    }
+
+    function probe() external {}
+
+    // A helper handed the same set twice: two distinct storage-reference parameters are read as
+    // distinct sets, so this goes unreported. Reading them as aliased would warn on every helper a
+    // caller uses with two different sets; judging it would take an interprocedural pass.
+    function walkOneRemoveOther(
+        EnumerableSet.AddressSet storage walked,
+        EnumerableSet.AddressSet storage pruned
+    ) internal {
+        for (uint256 i = 0; i < walked.length(); i++) {
+            pruned.remove(walked.at(i));
+        }
+    }
+
+    function helperHandedTheSameSetTwiceIsMissed() public {
+        walkOneRemoveOther(holders, holders);
+    }
+}
+
+// A user library attached to the EnumerableSet type, with its own `remove` overload.
+library Helpers {
+    function remove(EnumerableSet.AddressSet storage, uint256 index) internal pure {
+        index += 1;
+    }
+}
diff --git a/crates/lint/testdata/EnumerableLoopRemoval.stderr b/crates/lint/testdata/EnumerableLoopRemoval.stderr
new file mode 100644
index 0000000000000..31da84a1be408
--- /dev/null
+++ b/crates/lint/testdata/EnumerableLoopRemoval.stderr
@@ -0,0 +1,235 @@
+warning[enumerable-loop-removal]: `remove` on an `EnumerableSet` inside a loop that iterates it with `at` can corrupt the iteration
+   ╭▸ ROOT/testdata/EnumerableLoopRemoval.sol:LL:CC
+   │
+LL │             holders.remove(holders.at(i));
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/enumerable-loop-removal
+
+warning[enumerable-loop-removal]: `remove` on an `EnumerableSet` inside a loop that iterates it with `at` can corrupt the iteration
+   ╭▸ ROOT/testdata/EnumerableLoopRemoval.sol:LL:CC
+   │
+LL │             holders.remove(value);
+   │             ━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/enumerable-loop-removal
+
+warning[enumerable-loop-removal]: `remove` on an `EnumerableSet` inside a loop that iterates it with `at` can corrupt the iteration
+   ╭▸ ROOT/testdata/EnumerableLoopRemoval.sol:LL:CC
+   │
+LL │             EnumerableSet.remove(holders, EnumerableSet.at(holders, i));
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/enumerable-loop-removal
+
+warning[enumerable-loop-removal]: `remove` on an `EnumerableSet` inside a loop that iterates it with `at` can corrupt the iteration
+   ╭▸ ROOT/testdata/EnumerableLoopRemoval.sol:LL:CC
+   │
+LL │             holders.remove(EnumerableSet.at(holders, i));
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/enumerable-loop-removal
+
+warning[enumerable-loop-removal]: `remove` on an `EnumerableSet` inside a loop that iterates it with `at` can corrupt the iteration
+   ╭▸ ROOT/testdata/EnumerableLoopRemoval.sol:LL:CC
+   │
+LL │             ES.remove(imported, ES.at(imported, i));
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/enumerable-loop-removal
+
+warning[enumerable-loop-removal]: `remove` on an `EnumerableSet` inside a loop that iterates it with `at` can corrupt the iteration
+   ╭▸ ROOT/testdata/EnumerableLoopRemoval.sol:LL:CC
+   │
+LL │             ids.remove(ids.at(i));
+   │             ━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/enumerable-loop-removal
+
+warning[enumerable-loop-removal]: `remove` on an `EnumerableSet` inside a loop that iterates it with `at` can corrupt the iteration
+   ╭▸ ROOT/testdata/EnumerableLoopRemoval.sol:LL:CC
+   │
+LL │             holders.remove(holders.at({index: i}));
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/enumerable-loop-removal
+
+warning[enumerable-loop-removal]: `remove` on an `EnumerableSet` inside a loop that iterates it with `at` can corrupt the iteration
+   ╭▸ ROOT/testdata/EnumerableLoopRemoval.sol:LL:CC
+   │
+LL │ ┏             EnumerableSet.remove({
+LL │ ┃                 value: EnumerableSet.at({index: i, set: holders}),
+LL │ ┃                 set: holders
+LL │ ┃             });
+   │ ┗━━━━━━━━━━━━━━┛
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/enumerable-loop-removal
+
+warning[enumerable-loop-removal]: `remove` on an `EnumerableSet` inside a loop that iterates it with `at` can corrupt the iteration
+   ╭▸ ROOT/testdata/EnumerableLoopRemoval.sol:LL:CC
+   │
+LL │             holders.remove(holders.at(cursor));
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/enumerable-loop-removal
+
+warning[enumerable-loop-removal]: `remove` on an `EnumerableSet` inside a loop that iterates it with `at` can corrupt the iteration
+   ╭▸ ROOT/testdata/EnumerableLoopRemoval.sol:LL:CC
+   │
+LL │             keyed[key].remove(keyed[1].at(i));
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/enumerable-loop-removal
+
+warning[enumerable-loop-removal]: `remove` on an `EnumerableSet` inside a loop that iterates it with `at` can corrupt the iteration
+   ╭▸ ROOT/testdata/EnumerableLoopRemoval.sol:LL:CC
+   │
+LL │             holders.remove(alias_.at(i));
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/enumerable-loop-removal
+
+warning[enumerable-loop-removal]: `remove` on an `EnumerableSet` inside a loop that iterates it with `at` can corrupt the iteration
+   ╭▸ ROOT/testdata/EnumerableLoopRemoval.sol:LL:CC
+   │
+LL │             alias_.remove(holders.at(i));
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/enumerable-loop-removal
+
+warning[enumerable-loop-removal]: `remove` on an `EnumerableSet` inside a loop that iterates it with `at` can corrupt the iteration
+   ╭▸ ROOT/testdata/EnumerableLoopRemoval.sol:LL:CC
+   │
+LL │             holders.remove(alias_.at(i));
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/enumerable-loop-removal
+
+warning[enumerable-loop-removal]: `remove` on an `EnumerableSet` inside a loop that iterates it with `at` can corrupt the iteration
+   ╭▸ ROOT/testdata/EnumerableLoopRemoval.sol:LL:CC
+   │
+LL │             holders.remove(alias_.at(i));
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/enumerable-loop-removal
+
+warning[enumerable-loop-removal]: `remove` on an `EnumerableSet` inside a loop that iterates it with `at` can corrupt the iteration
+   ╭▸ ROOT/testdata/EnumerableLoopRemoval.sol:LL:CC
+   │
+LL │             others.remove(alias_.at(i));
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/enumerable-loop-removal
+
+warning[enumerable-loop-removal]: `remove` on an `EnumerableSet` inside a loop that iterates it with `at` can corrupt the iteration
+   ╭▸ ROOT/testdata/EnumerableLoopRemoval.sol:LL:CC
+   │
+LL │             holders.remove(first.at(i));
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/enumerable-loop-removal
+
+warning[enumerable-loop-removal]: `remove` on an `EnumerableSet` inside a loop that iterates it with `at` can corrupt the iteration
+   ╭▸ ROOT/testdata/EnumerableLoopRemoval.sol:LL:CC
+   │
+LL │             set.remove(set.at(i));
+   │             ━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/enumerable-loop-removal
+
+warning[enumerable-loop-removal]: `remove` on an `EnumerableSet` inside a loop that iterates it with `at` can corrupt the iteration
+   ╭▸ ROOT/testdata/EnumerableLoopRemoval.sol:LL:CC
+   │
+LL │             holders.remove(holders.at(i));
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/enumerable-loop-removal
+
+warning[enumerable-loop-removal]: `remove` on an `EnumerableSet` inside a loop that iterates it with `at` can corrupt the iteration
+   ╭▸ ROOT/testdata/EnumerableLoopRemoval.sol:LL:CC
+   │
+LL │             holders.remove(holders.at(i));
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/enumerable-loop-removal
+
+warning[enumerable-loop-removal]: `remove` on an `EnumerableSet` inside a loop that iterates it with `at` can corrupt the iteration
+   ╭▸ ROOT/testdata/EnumerableLoopRemoval.sol:LL:CC
+   │
+LL │             holders.remove(holders.at(i));
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/enumerable-loop-removal
+
+warning[enumerable-loop-removal]: `remove` on an `EnumerableSet` inside a loop that iterates it with `at` can corrupt the iteration
+   ╭▸ ROOT/testdata/EnumerableLoopRemoval.sol:LL:CC
+   │
+LL │             holders.remove(holders.at(i));
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/enumerable-loop-removal
+
+warning[enumerable-loop-removal]: `remove` on an `EnumerableSet` inside a loop that iterates it with `at` can corrupt the iteration
+   ╭▸ ROOT/testdata/EnumerableLoopRemoval.sol:LL:CC
+   │
+LL │             holders.remove(holders.at(i));
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/enumerable-loop-removal
+
+warning[enumerable-loop-removal]: `remove` on an `EnumerableSet` inside a loop that iterates it with `at` can corrupt the iteration
+   ╭▸ ROOT/testdata/EnumerableLoopRemoval.sol:LL:CC
+   │
+LL │             holders.remove(holders.at(i));
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/enumerable-loop-removal
+
+warning[enumerable-loop-removal]: `remove` on an `EnumerableSet` inside a loop that iterates it with `at` can corrupt the iteration
+   ╭▸ ROOT/testdata/EnumerableLoopRemoval.sol:LL:CC
+   │
+LL │             bool removed = enabled && holders.remove(target);
+   │                                       ━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/enumerable-loop-removal
+
+warning[enumerable-loop-removal]: `remove` on an `EnumerableSet` inside a loop that iterates it with `at` can corrupt the iteration
+   ╭▸ ROOT/testdata/EnumerableLoopRemoval.sol:LL:CC
+   │
+LL │             bool removed = enabled ? holders.remove(target) : false;
+   │                                      ━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/enumerable-loop-removal
+
+warning[enumerable-loop-removal]: `remove` on an `EnumerableSet` inside a loop that iterates it with `at` can corrupt the iteration
+   ╭▸ ROOT/testdata/EnumerableLoopRemoval.sol:LL:CC
+   │
+LL │             bool removed = true ? holders.remove(target) : false;
+   │                                   ━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/enumerable-loop-removal
+
+warning[enumerable-loop-removal]: `remove` on an `EnumerableSet` inside a loop that iterates it with `at` can corrupt the iteration
+   ╭▸ ROOT/testdata/EnumerableLoopRemoval.sol:LL:CC
+   │
+LL │             holders.remove(holders.at(holders.length() - 1));
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/enumerable-loop-removal
+
+warning[enumerable-loop-removal]: `remove` on an `EnumerableSet` inside a loop that iterates it with `at` can corrupt the iteration
+   ╭▸ ROOT/testdata/EnumerableLoopRemoval.sol:LL:CC
+   │
+LL │             holders.remove(holders.at(i));
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/enumerable-loop-removal
+
+warning[enumerable-loop-removal]: `remove` on an `EnumerableSet` inside a loop that iterates it with `at` can corrupt the iteration
+   ╭▸ ROOT/testdata/EnumerableLoopRemoval.sol:LL:CC
+   │
+LL │             holders.remove(holders.at(i));
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/enumerable-loop-removal
+
diff --git a/crates/lint/testdata/EnvironmentSetters.sol b/crates/lint/testdata/EnvironmentSetters.sol
new file mode 100644
index 0000000000000..6a9adf2cb0a12
--- /dev/null
+++ b/crates/lint/testdata/EnvironmentSetters.sol
@@ -0,0 +1,378 @@
+//@compile-flags: --only-lint environment-read-across-mutation --evm-version amsterdam
+// SPDX-License-Identifier: MIT
+pragma solidity >=0.8.25;
+
+interface VmEnvironment {
+    function roll(uint256 height) external;
+    function warp(uint256 time) external;
+    function chainId(uint256 id) external;
+    function coinbase(address beneficiary) external;
+    function difficulty(uint256 value) external;
+    function prevrandao(bytes32 value) external;
+    function prevrandao(uint256 value) external;
+    function fee(uint256 value) external;
+    function blobBaseFee(uint256 value) external;
+    function txGasPrice(uint256 value) external;
+    function setBlockhash(uint256 height, bytes32 value) external;
+    function blobhashes(bytes32[] calldata values) external;
+    function getChainId() external view returns (uint256);
+    function getBlobBaseFee() external view returns (uint256);
+    function getBlobhashes() external view returns (bytes32[] memory);
+    function selectFork(uint256 id) external;
+    function createSelectFork(string calldata url) external returns (uint256);
+    function createSelectFork(string calldata url, uint256 height) external returns (uint256);
+    function createSelectFork(string calldata url, bytes32 txHash) external returns (uint256);
+    function rollFork(uint256 height) external;
+    function rollFork(bytes32 txHash) external;
+    function rollFork(uint256 id, uint256 height) external;
+    function rollFork(uint256 id, bytes32 txHash) external;
+    function revertTo(uint256 id) external returns (bool);
+    function revertToState(uint256 id) external returns (bool);
+    function revertToAndDelete(uint256 id) external returns (bool);
+    function revertToStateAndDelete(uint256 id) external returns (bool);
+    function createFork(string calldata url) external returns (uint256);
+    function snapshotState() external returns (uint256);
+    function prank(address sender, address origin) external;
+    function deal(address account, uint256 balance) external;
+    function etch(address account, bytes calldata code) external;
+}
+
+interface WrongEnvironmentSignature {
+    function chainId(uint64 id) external;
+    function coinbase(uint160 beneficiary) external;
+    function fee(bytes32 value) external;
+    function prevrandao(bytes16 value) external;
+    function setBlockhash(bytes32 height, uint256 value) external;
+    function blobhashes(bytes32[1] calldata values) external;
+    function selectFork(bytes32 id) external;
+}
+
+contract EnvironmentSetters {
+    VmEnvironment constant vm = VmEnvironment(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D);
+
+    function captureChainId() public returns (uint256) {
+        uint256 saved = block.chainid; //~WARN: `block.chainid` may be reused across `vm.chainId`
+        vm.chainId(2);
+        return saved;
+    }
+
+    function captureCoinbase() public returns (address) {
+        address saved = block.coinbase; //~WARN: `block.coinbase` may be reused across `vm.coinbase`
+        vm.coinbase(address(2));
+        return saved;
+    }
+
+    function captureDifficulty() public returns (uint256) {
+        uint256 saved = block.difficulty; //~WARN: `block.difficulty` may be reused across `vm.difficulty`
+        vm.difficulty(2);
+        return saved;
+    }
+
+    function capturePrevrandao() public returns (uint256) {
+        uint256 saved = block.prevrandao; //~WARN: `block.prevrandao` may be reused across `vm.prevrandao`
+        vm.prevrandao(bytes32(uint256(2)));
+        return saved;
+    }
+
+    function capturePrevrandaoUint() public returns (uint256) {
+        uint256 saved = block.prevrandao; //~WARN: `block.prevrandao` may be reused across `vm.prevrandao`
+        vm.prevrandao(uint256(2));
+        return saved;
+    }
+
+    function captureBaseFee() public returns (uint256) {
+        uint256 saved = block.basefee; //~WARN: `block.basefee` may be reused across `vm.fee`
+        vm.fee(2);
+        return saved;
+    }
+
+    function captureBlobBaseFee() public returns (uint256) {
+        uint256 saved = block.blobbasefee; //~WARN: `block.blobbasefee` may be reused across `vm.blobBaseFee`
+        vm.blobBaseFee(2);
+        return saved;
+    }
+
+    function captureGasPrice() public returns (uint256) {
+        uint256 saved = tx.gasprice; //~WARN: `tx.gasprice` may be reused across `vm.txGasPrice`
+        vm.txGasPrice(2);
+        return saved;
+    }
+
+    function captureBlockHash() public returns (bytes32) {
+        bytes32 saved = blockhash(1); //~WARN: `blockhash(...)` may be reused across `vm.setBlockhash`
+        vm.setBlockhash(1, bytes32(uint256(2)));
+        return saved;
+    }
+
+    function captureBlobHash() public returns (bytes32) {
+        bytes32 saved = blobhash(0); //~WARN: `blobhash(...)` may be reused across `vm.blobhashes`
+        vm.blobhashes(new bytes32[](1));
+        return saved;
+    }
+
+    function captureSelectFork() public returns (uint256) {
+        uint256 saved = block.gaslimit; //~WARN: `block.gaslimit` may be reused across `vm.selectFork`
+        vm.selectFork(1);
+        return saved;
+    }
+
+    function captureCreateSelectFork() public returns (uint256) {
+        uint256 saved = block.gaslimit; //~WARN: `block.gaslimit` may be reused across `vm.createSelectFork`
+        vm.createSelectFork("rpc");
+        return saved;
+    }
+
+    function captureCreateSelectForkHeight() public returns (uint256) {
+        uint256 saved = block.gaslimit; //~WARN: `block.gaslimit` may be reused across `vm.createSelectFork`
+        vm.createSelectFork("rpc", uint256(2));
+        return saved;
+    }
+
+    function captureCreateSelectForkTransaction() public returns (uint256) {
+        uint256 saved = block.gaslimit; //~WARN: `block.gaslimit` may be reused across `vm.createSelectFork`
+        vm.createSelectFork("rpc", bytes32(uint256(2)));
+        return saved;
+    }
+
+    function captureRollFork() public returns (uint256) {
+        uint256 saved = block.gaslimit; //~WARN: `block.gaslimit` may be reused across `vm.rollFork`
+        vm.rollFork(uint256(2));
+        return saved;
+    }
+
+    function captureRollForkTransaction() public returns (uint256) {
+        uint256 saved = block.gaslimit; //~WARN: `block.gaslimit` may be reused across `vm.rollFork`
+        vm.rollFork(bytes32(uint256(2)));
+        return saved;
+    }
+
+    function captureRollNamedFork() public returns (uint256) {
+        uint256 saved = block.gaslimit; //~WARN: `block.gaslimit` may be reused across `vm.rollFork`
+        vm.rollFork(1, uint256(2));
+        return saved;
+    }
+
+    function captureRollNamedForkTransaction() public returns (uint256) {
+        uint256 saved = block.gaslimit; //~WARN: `block.gaslimit` may be reused across `vm.rollFork`
+        vm.rollFork(1, bytes32(uint256(2)));
+        return saved;
+    }
+
+    function captureRevertTo() public returns (uint256) {
+        uint256 saved = block.gaslimit; //~WARN: `block.gaslimit` may be reused across `vm.revertTo`
+        vm.revertTo(1);
+        return saved;
+    }
+
+    function captureRevertToState() public returns (uint256) {
+        uint256 saved = block.gaslimit; //~WARN: `block.gaslimit` may be reused across `vm.revertToState`
+        vm.revertToState(1);
+        return saved;
+    }
+
+    function captureRevertToAndDelete() public returns (uint256) {
+        uint256 saved = block.gaslimit; //~WARN: `block.gaslimit` may be reused across `vm.revertToAndDelete`
+        vm.revertToAndDelete(1);
+        return saved;
+    }
+
+    function captureRevertToStateAndDelete() public returns (uint256) {
+        uint256 saved = block.gaslimit; //~WARN: `block.gaslimit` may be reused across `vm.revertToStateAndDelete`
+        vm.revertToStateAndDelete(1);
+        return saved;
+    }
+
+    function forkFields() public returns (uint256, uint256, uint256, address, uint256, uint256, uint256, uint256, uint256, bytes32) {
+        uint256 height = block.number; //~WARN: `block.number` may be reused across `vm.selectFork`
+        uint256 time = block.timestamp; //~WARN: `block.timestamp` may be reused across `vm.selectFork`
+        uint256 chain = block.chainid; //~WARN: `block.chainid` may be reused across `vm.selectFork`
+        address beneficiary = block.coinbase; //~WARN: `block.coinbase` may be reused across `vm.selectFork`
+        uint256 difficulty = block.difficulty; //~WARN: `block.difficulty` may be reused across `vm.selectFork`
+        uint256 random = block.prevrandao; //~WARN: `block.prevrandao` may be reused across `vm.selectFork`
+        uint256 base = block.basefee; //~WARN: `block.basefee` may be reused across `vm.selectFork`
+        uint256 blobBase = block.blobbasefee; //~WARN: `block.blobbasefee` may be reused across `vm.selectFork`
+        uint256 limit = block.gaslimit; //~WARN: `block.gaslimit` may be reused across `vm.selectFork`
+        bytes32 hash = blockhash(1); //~WARN: `blockhash(...)` may be reused across `vm.selectFork`
+        vm.selectFork(1);
+        return (height, time, chain, beneficiary, difficulty, random, base, blobBase, limit, hash);
+    }
+
+    function snapshotFields() public returns (uint256, uint256, uint256, address, uint256, uint256, uint256, uint256, uint256, bytes32) {
+        uint256 height = block.number; //~WARN: `block.number` may be reused across `vm.revertToState`
+        uint256 time = block.timestamp; //~WARN: `block.timestamp` may be reused across `vm.revertToState`
+        uint256 chain = block.chainid; //~WARN: `block.chainid` may be reused across `vm.revertToState`
+        address beneficiary = block.coinbase; //~WARN: `block.coinbase` may be reused across `vm.revertToState`
+        uint256 difficulty = block.difficulty; //~WARN: `block.difficulty` may be reused across `vm.revertToState`
+        uint256 random = block.prevrandao; //~WARN: `block.prevrandao` may be reused across `vm.revertToState`
+        uint256 base = block.basefee; //~WARN: `block.basefee` may be reused across `vm.revertToState`
+        uint256 blobBase = block.blobbasefee; //~WARN: `block.blobbasefee` may be reused across `vm.revertToState`
+        uint256 limit = block.gaslimit; //~WARN: `block.gaslimit` may be reused across `vm.revertToState`
+        bytes32 hash = blockhash(1); //~WARN: `blockhash(...)` may be reused across `vm.revertToState`
+        vm.revertToState(1);
+        return (height, time, chain, beneficiary, difficulty, random, base, blobBase, limit, hash);
+    }
+
+    function rollPreservesTransactionOverrides() public returns (uint256, bytes32) {
+        uint256 price = tx.gasprice;
+        bytes32 hash = blobhash(0);
+        vm.rollFork(uint256(2));
+        return (price, hash);
+    }
+
+    function forkSlotNumber() public returns (uint64) {
+        uint64 slot = block.slotnum; //~WARN: `block.slotnum` may be reused across `vm.selectFork`
+        vm.selectFork(1);
+        return slot;
+    }
+
+    function snapshotSlotNumber() public returns (uint64) {
+        uint64 slot = block.slotnum; //~WARN: `block.slotnum` may be reused across `vm.revertToState`
+        vm.revertToState(1);
+        return slot;
+    }
+
+    function rollPreservesSlotNumber() public returns (uint64) {
+        uint64 slot = block.slotnum;
+        vm.roll(2);
+        return slot;
+    }
+
+    function hashWindow() public returns (bytes32) {
+        bytes32 saved = blockhash(1); //~WARN: `blockhash(...)` may be reused across `vm.roll`
+        vm.roll(300);
+        return saved;
+    }
+
+    function hashArgument() public returns (bytes32) {
+        bytes32 saved = blobhash(block.chainid); //~WARN: `block.chainid` may be reused across `vm.chainId`
+        vm.chainId(2);
+        return saved;
+    }
+
+    function forkTransactionOverrides() public returns (uint256, bytes32) {
+        uint256 price = tx.gasprice; //~WARN: `tx.gasprice` may be reused across `vm.selectFork`
+        bytes32 hash = blobhash(0); //~WARN: `blobhash(...)` may be reused across `vm.selectFork`
+        vm.selectFork(2);
+        return (price, hash);
+    }
+
+    function snapshotTransactionOverrides() public returns (uint256, bytes32) {
+        uint256 price = tx.gasprice; //~WARN: `tx.gasprice` may be reused across `vm.revertToState`
+        bytes32 hash = blobhash(0); //~WARN: `blobhash(...)` may be reused across `vm.revertToState`
+        vm.revertToState(2);
+        return (price, hash);
+    }
+
+    function secondRead() public returns (uint256) {
+        block.basefee; //~WARN: `block.basefee` may be reused across `vm.fee`
+        vm.fee(2);
+        return block.basefee;
+    }
+
+    function hashSecondRead() public returns (bytes32) {
+        blobhash(0); //~WARN: `blobhash(...)` may be reused across `vm.blobhashes`
+        vm.blobhashes(new bytes32[](1));
+        return blobhash(0);
+    }
+
+    function aliasAndNamedArguments() public returns (bytes32) {
+        VmEnvironment alias_ = vm;
+        bytes32 saved = blockhash(1); //~WARN: `blockhash(...)` may be reused across `vm.setBlockhash`
+        alias_.setBlockhash({value: bytes32(uint256(2)), height: 1});
+        return saved;
+    }
+
+    function baseFee() external view returns (uint256) { return block.basefee; }
+
+    function materialized() public returns (uint256, uint256, bytes32, uint256) {
+        uint256 chain = vm.getChainId();
+        uint256 fee = vm.getBlobBaseFee();
+        bytes32 hash = vm.getBlobhashes()[0];
+        uint256 base = this.baseFee();
+        vm.chainId(2);
+        vm.blobBaseFee(2);
+        vm.blobhashes(new bytes32[](1));
+        vm.fee(2);
+        return (chain, fee, hash, base);
+    }
+
+    function unrelatedSetters() public returns (uint256, bytes32) {
+        uint256 chain = block.chainid;
+        bytes32 hash = blockhash(1);
+        vm.fee(2);
+        vm.warp(2);
+        vm.blobhashes(new bytes32[](1));
+        return (chain, hash);
+    }
+
+    function ordinaryReceiver(VmEnvironment other) public returns (uint256, bytes32) {
+        uint256 saved = block.basefee;
+        bytes32 hash = blobhash(0);
+        other.fee(2);
+        other.blobhashes(new bytes32[](1));
+        return (saved, hash);
+    }
+
+    function wrongSignatures() public returns (uint256, address, uint256, uint256, bytes32, bytes32) {
+        uint256 chain = block.chainid;
+        address coinbase = block.coinbase;
+        uint256 fee = block.basefee;
+        uint256 random = block.prevrandao;
+        bytes32 blockHash = blockhash(1);
+        bytes32 blobHash = blobhash(0);
+        WrongEnvironmentSignature other = WrongEnvironmentSignature(address(vm));
+        other.chainId(2);
+        other.coinbase(2);
+        other.fee(bytes32(uint256(2)));
+        other.prevrandao(bytes16(0));
+        other.setBlockhash(bytes32(uint256(1)), 2);
+        other.blobhashes([bytes32(uint256(2))]);
+        other.selectFork(bytes32(uint256(2)));
+        return (chain, coinbase, fee, random, blockHash, blobHash);
+    }
+
+    function noCurrentEnvironmentChange() public returns (uint256, address, address) {
+        uint256 chain = block.chainid;
+        address sender = msg.sender;
+        address origin = tx.origin;
+        vm.createFork("rpc");
+        vm.snapshotState();
+        vm.prank(address(1), address(2));
+        return (chain, sender, origin);
+    }
+
+    function mutableStateReads(address account) public returns (uint256, uint256) {
+        uint256 balance = account.balance;
+        uint256 size = account.code.length;
+        vm.deal(account, 2);
+        vm.etch(account, hex"00");
+        return (balance, size);
+    }
+
+    function readAfterSetter() public returns (uint256) {
+        vm.chainId(2);
+        return block.chainid;
+    }
+
+    function overwritten() public returns (uint256) {
+        uint256 saved = block.chainid;
+        vm.chainId(2);
+        saved = 3;
+        return saved;
+    }
+
+    function suppressed() public returns (uint256) {
+        // forge-lint: disable-next-line(environment-read-across-mutation)
+        uint256 saved = block.basefee;
+        vm.fee(2);
+        return saved;
+    }
+
+    function firstMatchingMutation() public returns (uint256) {
+        uint256 saved = block.timestamp; //~WARN: `block.timestamp` may be reused across `vm.warp`
+        vm.roll(100); // Does not change the timestamp.
+        vm.warp(200); // This is the secondary span, not the later matching call.
+        vm.selectFork(1);
+        return saved;
+    }
+}
diff --git a/crates/lint/testdata/EnvironmentSetters.stderr b/crates/lint/testdata/EnvironmentSetters.stderr
new file mode 100644
index 0000000000000..d677d9b80ac6a
--- /dev/null
+++ b/crates/lint/testdata/EnvironmentSetters.stderr
@@ -0,0 +1,615 @@
+warning[environment-read-across-mutation]: `block.chainid` may be reused across `vm.chainId`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         uint256 saved = block.chainid;
+   │                         ━━━━━━━━━━━━━
+LL │         vm.chainId(2);
+   │         ───────────── `vm.chainId` changes this environment here
+   │
+   ├ help: capture it with `vm.getChainId()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.coinbase` may be reused across `vm.coinbase`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         address saved = block.coinbase;
+   │                         ━━━━━━━━━━━━━━
+LL │         vm.coinbase(address(2));
+   │         ─────────────────────── `vm.coinbase` changes this environment here
+   │
+   ├ help: capture it through an external helper call instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.difficulty` may be reused across `vm.difficulty`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         uint256 saved = block.difficulty;
+   │                         ━━━━━━━━━━━━━━━━
+LL │         vm.difficulty(2);
+   │         ──────────────── `vm.difficulty` changes this environment here
+   │
+   ├ help: capture it through an external helper call instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.prevrandao` may be reused across `vm.prevrandao`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         uint256 saved = block.prevrandao;
+   │                         ━━━━━━━━━━━━━━━━
+LL │         vm.prevrandao(bytes32(uint256(2)));
+   │         ────────────────────────────────── `vm.prevrandao` changes this environment here
+   │
+   ├ help: capture it through an external helper call instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.prevrandao` may be reused across `vm.prevrandao`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         uint256 saved = block.prevrandao;
+   │                         ━━━━━━━━━━━━━━━━
+LL │         vm.prevrandao(uint256(2));
+   │         ───────────────────────── `vm.prevrandao` changes this environment here
+   │
+   ├ help: capture it through an external helper call instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.basefee` may be reused across `vm.fee`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         uint256 saved = block.basefee;
+   │                         ━━━━━━━━━━━━━
+LL │         vm.fee(2);
+   │         ───────── `vm.fee` changes this environment here
+   │
+   ├ help: capture it through an external helper call instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.blobbasefee` may be reused across `vm.blobBaseFee`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         uint256 saved = block.blobbasefee;
+   │                         ━━━━━━━━━━━━━━━━━
+LL │         vm.blobBaseFee(2);
+   │         ───────────────── `vm.blobBaseFee` changes this environment here
+   │
+   ├ help: capture it with `vm.getBlobBaseFee()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `tx.gasprice` may be reused across `vm.txGasPrice`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         uint256 saved = tx.gasprice;
+   │                         ━━━━━━━━━━━
+LL │         vm.txGasPrice(2);
+   │         ──────────────── `vm.txGasPrice` changes this environment here
+   │
+   ├ help: capture it through an external helper call instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `blockhash(...)` may be reused across `vm.setBlockhash`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         bytes32 saved = blockhash(1);
+   │                         ━━━━━━━━━━━━
+LL │         vm.setBlockhash(1, bytes32(uint256(2)));
+   │         ─────────────────────────────────────── `vm.setBlockhash` changes this environment here
+   │
+   ├ help: capture it through an external helper call instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `blobhash(...)` may be reused across `vm.blobhashes`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         bytes32 saved = blobhash(0);
+   │                         ━━━━━━━━━━━
+LL │         vm.blobhashes(new bytes32[](1));
+   │         ─────────────────────────────── `vm.blobhashes` changes this environment here
+   │
+   ├ help: capture it with `vm.getBlobhashes()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.gaslimit` may be reused across `vm.selectFork`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         uint256 saved = block.gaslimit;
+   │                         ━━━━━━━━━━━━━━
+LL │         vm.selectFork(1);
+   │         ──────────────── `vm.selectFork` changes this environment here
+   │
+   ├ help: capture it through an external helper call instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.gaslimit` may be reused across `vm.createSelectFork`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         uint256 saved = block.gaslimit;
+   │                         ━━━━━━━━━━━━━━
+LL │         vm.createSelectFork("rpc");
+   │         ────────────────────────── `vm.createSelectFork` changes this environment here
+   │
+   ├ help: capture it through an external helper call instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.gaslimit` may be reused across `vm.createSelectFork`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         uint256 saved = block.gaslimit;
+   │                         ━━━━━━━━━━━━━━
+LL │         vm.createSelectFork("rpc", uint256(2));
+   │         ────────────────────────────────────── `vm.createSelectFork` changes this environment here
+   │
+   ├ help: capture it through an external helper call instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.gaslimit` may be reused across `vm.createSelectFork`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         uint256 saved = block.gaslimit;
+   │                         ━━━━━━━━━━━━━━
+LL │         vm.createSelectFork("rpc", bytes32(uint256(2)));
+   │         ─────────────────────────────────────────────── `vm.createSelectFork` changes this environment here
+   │
+   ├ help: capture it through an external helper call instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.gaslimit` may be reused across `vm.rollFork`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         uint256 saved = block.gaslimit;
+   │                         ━━━━━━━━━━━━━━
+LL │         vm.rollFork(uint256(2));
+   │         ─────────────────────── `vm.rollFork` changes this environment here
+   │
+   ├ help: capture it through an external helper call instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.gaslimit` may be reused across `vm.rollFork`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         uint256 saved = block.gaslimit;
+   │                         ━━━━━━━━━━━━━━
+LL │         vm.rollFork(bytes32(uint256(2)));
+   │         ──────────────────────────────── `vm.rollFork` changes this environment here
+   │
+   ├ help: capture it through an external helper call instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.gaslimit` may be reused across `vm.rollFork`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         uint256 saved = block.gaslimit;
+   │                         ━━━━━━━━━━━━━━
+LL │         vm.rollFork(1, uint256(2));
+   │         ────────────────────────── `vm.rollFork` changes this environment here
+   │
+   ├ help: capture it through an external helper call instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.gaslimit` may be reused across `vm.rollFork`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         uint256 saved = block.gaslimit;
+   │                         ━━━━━━━━━━━━━━
+LL │         vm.rollFork(1, bytes32(uint256(2)));
+   │         ─────────────────────────────────── `vm.rollFork` changes this environment here
+   │
+   ├ help: capture it through an external helper call instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.gaslimit` may be reused across `vm.revertTo`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         uint256 saved = block.gaslimit;
+   │                         ━━━━━━━━━━━━━━
+LL │         vm.revertTo(1);
+   │         ────────────── `vm.revertTo` changes this environment here
+   │
+   ├ help: capture it through an external helper call instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.gaslimit` may be reused across `vm.revertToState`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         uint256 saved = block.gaslimit;
+   │                         ━━━━━━━━━━━━━━
+LL │         vm.revertToState(1);
+   │         ─────────────────── `vm.revertToState` changes this environment here
+   │
+   ├ help: capture it through an external helper call instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.gaslimit` may be reused across `vm.revertToAndDelete`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         uint256 saved = block.gaslimit;
+   │                         ━━━━━━━━━━━━━━
+LL │         vm.revertToAndDelete(1);
+   │         ─────────────────────── `vm.revertToAndDelete` changes this environment here
+   │
+   ├ help: capture it through an external helper call instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.gaslimit` may be reused across `vm.revertToStateAndDelete`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         uint256 saved = block.gaslimit;
+   │                         ━━━━━━━━━━━━━━
+LL │         vm.revertToStateAndDelete(1);
+   │         ──────────────────────────── `vm.revertToStateAndDelete` changes this environment here
+   │
+   ├ help: capture it through an external helper call instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.number` may be reused across `vm.selectFork`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         uint256 height = block.number;
+   │                          ━━━━━━━━━━━━
+   ‡
+LL │         vm.selectFork(1);
+   │         ──────────────── `vm.selectFork` changes this environment here
+   │
+   ├ help: capture it with `vm.getBlockNumber()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.timestamp` may be reused across `vm.selectFork`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         uint256 time = block.timestamp;
+   │                        ━━━━━━━━━━━━━━━
+   ‡
+LL │         vm.selectFork(1);
+   │         ──────────────── `vm.selectFork` changes this environment here
+   │
+   ├ help: capture it with `vm.getBlockTimestamp()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.chainid` may be reused across `vm.selectFork`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         uint256 chain = block.chainid;
+   │                         ━━━━━━━━━━━━━
+   ‡
+LL │         vm.selectFork(1);
+   │         ──────────────── `vm.selectFork` changes this environment here
+   │
+   ├ help: capture it with `vm.getChainId()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.coinbase` may be reused across `vm.selectFork`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         address beneficiary = block.coinbase;
+   │                               ━━━━━━━━━━━━━━
+   ‡
+LL │         vm.selectFork(1);
+   │         ──────────────── `vm.selectFork` changes this environment here
+   │
+   ├ help: capture it through an external helper call instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.difficulty` may be reused across `vm.selectFork`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         uint256 difficulty = block.difficulty;
+   │                              ━━━━━━━━━━━━━━━━
+   ‡
+LL │         vm.selectFork(1);
+   │         ──────────────── `vm.selectFork` changes this environment here
+   │
+   ├ help: capture it through an external helper call instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.prevrandao` may be reused across `vm.selectFork`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         uint256 random = block.prevrandao;
+   │                          ━━━━━━━━━━━━━━━━
+   ‡
+LL │         vm.selectFork(1);
+   │         ──────────────── `vm.selectFork` changes this environment here
+   │
+   ├ help: capture it through an external helper call instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.basefee` may be reused across `vm.selectFork`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         uint256 base = block.basefee;
+   │                        ━━━━━━━━━━━━━
+   ‡
+LL │         vm.selectFork(1);
+   │         ──────────────── `vm.selectFork` changes this environment here
+   │
+   ├ help: capture it through an external helper call instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.blobbasefee` may be reused across `vm.selectFork`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         uint256 blobBase = block.blobbasefee;
+   │                            ━━━━━━━━━━━━━━━━━
+   ‡
+LL │         vm.selectFork(1);
+   │         ──────────────── `vm.selectFork` changes this environment here
+   │
+   ├ help: capture it with `vm.getBlobBaseFee()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.gaslimit` may be reused across `vm.selectFork`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         uint256 limit = block.gaslimit;
+   │                         ━━━━━━━━━━━━━━
+LL │         bytes32 hash = blockhash(1);
+LL │         vm.selectFork(1);
+   │         ──────────────── `vm.selectFork` changes this environment here
+   │
+   ├ help: capture it through an external helper call instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `blockhash(...)` may be reused across `vm.selectFork`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         bytes32 hash = blockhash(1);
+   │                        ━━━━━━━━━━━━
+LL │         vm.selectFork(1);
+   │         ──────────────── `vm.selectFork` changes this environment here
+   │
+   ├ help: capture it through an external helper call instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.number` may be reused across `vm.revertToState`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         uint256 height = block.number;
+   │                          ━━━━━━━━━━━━
+   ‡
+LL │         vm.revertToState(1);
+   │         ─────────────────── `vm.revertToState` changes this environment here
+   │
+   ├ help: capture it with `vm.getBlockNumber()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.timestamp` may be reused across `vm.revertToState`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         uint256 time = block.timestamp;
+   │                        ━━━━━━━━━━━━━━━
+   ‡
+LL │         vm.revertToState(1);
+   │         ─────────────────── `vm.revertToState` changes this environment here
+   │
+   ├ help: capture it with `vm.getBlockTimestamp()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.chainid` may be reused across `vm.revertToState`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         uint256 chain = block.chainid;
+   │                         ━━━━━━━━━━━━━
+   ‡
+LL │         vm.revertToState(1);
+   │         ─────────────────── `vm.revertToState` changes this environment here
+   │
+   ├ help: capture it with `vm.getChainId()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.coinbase` may be reused across `vm.revertToState`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         address beneficiary = block.coinbase;
+   │                               ━━━━━━━━━━━━━━
+   ‡
+LL │         vm.revertToState(1);
+   │         ─────────────────── `vm.revertToState` changes this environment here
+   │
+   ├ help: capture it through an external helper call instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.difficulty` may be reused across `vm.revertToState`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         uint256 difficulty = block.difficulty;
+   │                              ━━━━━━━━━━━━━━━━
+   ‡
+LL │         vm.revertToState(1);
+   │         ─────────────────── `vm.revertToState` changes this environment here
+   │
+   ├ help: capture it through an external helper call instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.prevrandao` may be reused across `vm.revertToState`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         uint256 random = block.prevrandao;
+   │                          ━━━━━━━━━━━━━━━━
+   ‡
+LL │         vm.revertToState(1);
+   │         ─────────────────── `vm.revertToState` changes this environment here
+   │
+   ├ help: capture it through an external helper call instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.basefee` may be reused across `vm.revertToState`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         uint256 base = block.basefee;
+   │                        ━━━━━━━━━━━━━
+   ‡
+LL │         vm.revertToState(1);
+   │         ─────────────────── `vm.revertToState` changes this environment here
+   │
+   ├ help: capture it through an external helper call instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.blobbasefee` may be reused across `vm.revertToState`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         uint256 blobBase = block.blobbasefee;
+   │                            ━━━━━━━━━━━━━━━━━
+   ‡
+LL │         vm.revertToState(1);
+   │         ─────────────────── `vm.revertToState` changes this environment here
+   │
+   ├ help: capture it with `vm.getBlobBaseFee()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.gaslimit` may be reused across `vm.revertToState`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         uint256 limit = block.gaslimit;
+   │                         ━━━━━━━━━━━━━━
+LL │         bytes32 hash = blockhash(1);
+LL │         vm.revertToState(1);
+   │         ─────────────────── `vm.revertToState` changes this environment here
+   │
+   ├ help: capture it through an external helper call instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `blockhash(...)` may be reused across `vm.revertToState`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         bytes32 hash = blockhash(1);
+   │                        ━━━━━━━━━━━━
+LL │         vm.revertToState(1);
+   │         ─────────────────── `vm.revertToState` changes this environment here
+   │
+   ├ help: capture it through an external helper call instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.slotnum` may be reused across `vm.selectFork`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         uint64 slot = block.slotnum;
+   │                       ━━━━━━━━━━━━━
+LL │         vm.selectFork(1);
+   │         ──────────────── `vm.selectFork` changes this environment here
+   │
+   ├ help: capture it through an external helper call instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.slotnum` may be reused across `vm.revertToState`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         uint64 slot = block.slotnum;
+   │                       ━━━━━━━━━━━━━
+LL │         vm.revertToState(1);
+   │         ─────────────────── `vm.revertToState` changes this environment here
+   │
+   ├ help: capture it through an external helper call instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `blockhash(...)` may be reused across `vm.roll`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         bytes32 saved = blockhash(1);
+   │                         ━━━━━━━━━━━━
+LL │         vm.roll(300);
+   │         ──────────── `vm.roll` changes this environment here
+   │
+   ├ help: capture it through an external helper call instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.chainid` may be reused across `vm.chainId`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         bytes32 saved = blobhash(block.chainid);
+   │                                  ━━━━━━━━━━━━━
+LL │         vm.chainId(2);
+   │         ───────────── `vm.chainId` changes this environment here
+   │
+   ├ help: capture it with `vm.getChainId()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `tx.gasprice` may be reused across `vm.selectFork`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         uint256 price = tx.gasprice;
+   │                         ━━━━━━━━━━━
+LL │         bytes32 hash = blobhash(0);
+LL │         vm.selectFork(2);
+   │         ──────────────── `vm.selectFork` changes this environment here
+   │
+   ├ help: capture it through an external helper call instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `blobhash(...)` may be reused across `vm.selectFork`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         bytes32 hash = blobhash(0);
+   │                        ━━━━━━━━━━━
+LL │         vm.selectFork(2);
+   │         ──────────────── `vm.selectFork` changes this environment here
+   │
+   ├ help: capture it with `vm.getBlobhashes()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `tx.gasprice` may be reused across `vm.revertToState`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         uint256 price = tx.gasprice;
+   │                         ━━━━━━━━━━━
+LL │         bytes32 hash = blobhash(0);
+LL │         vm.revertToState(2);
+   │         ─────────────────── `vm.revertToState` changes this environment here
+   │
+   ├ help: capture it through an external helper call instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `blobhash(...)` may be reused across `vm.revertToState`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         bytes32 hash = blobhash(0);
+   │                        ━━━━━━━━━━━
+LL │         vm.revertToState(2);
+   │         ─────────────────── `vm.revertToState` changes this environment here
+   │
+   ├ help: capture it with `vm.getBlobhashes()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.basefee` may be reused across `vm.fee`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         block.basefee;
+   │         ━━━━━━━━━━━━━
+LL │         vm.fee(2);
+   │         ───────── `vm.fee` changes this environment here
+   │
+   ├ help: capture it through an external helper call instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `blobhash(...)` may be reused across `vm.blobhashes`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         blobhash(0);
+   │         ━━━━━━━━━━━
+LL │         vm.blobhashes(new bytes32[](1));
+   │         ─────────────────────────────── `vm.blobhashes` changes this environment here
+   │
+   ├ help: capture it with `vm.getBlobhashes()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `blockhash(...)` may be reused across `vm.setBlockhash`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         bytes32 saved = blockhash(1);
+   │                         ━━━━━━━━━━━━
+LL │         alias_.setBlockhash({value: bytes32(uint256(2)), height: 1});
+   │         ──────────────────────────────────────────────────────────── `vm.setBlockhash` changes this environment here
+   │
+   ├ help: capture it through an external helper call instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
+warning[environment-read-across-mutation]: `block.timestamp` may be reused across `vm.warp`
+   ╭▸ ROOT/testdata/EnvironmentSetters.sol:LL:CC
+   │
+LL │         uint256 saved = block.timestamp;
+   │                         ━━━━━━━━━━━━━━━
+LL │         vm.roll(100); // Does not change the timestamp.
+LL │         vm.warp(200); // This is the secondary span, not the later matching call.
+   │         ──────────── `vm.warp` changes this environment here
+   │
+   ├ help: capture it with `vm.getBlockTimestamp()` instead
+   ╰ help: https://getfoundry.sh/forge/linting/environment-read-across-mutation
+
diff --git a/crates/lint/testdata/EventFields.sol b/crates/lint/testdata/EventFields.sol
new file mode 100644
index 0000000000000..54b7d0783c544
--- /dev/null
+++ b/crates/lint/testdata/EventFields.sol
@@ -0,0 +1,88 @@
+//@compile-flags: --only-lint event-fields
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+// Top-level event with unindexed address.
+event TopLevelTransfer(address from, address to, uint256 value); //~NOTE: event has unindexed fields that may benefit from being indexed: `from` (`address`), `to` (`address`)
+
+// Top-level event already fully OK.
+event TopLevelOk(address indexed from, address indexed to, uint256 value);
+
+interface IEvents {
+    // Interface event with unindexed address.
+    event InterfaceTransfer(address from, uint256 value); //~NOTE: event has unindexed fields that may benefit from being indexed: `from` (`address`)
+}
+
+library LibEvents {
+    // Library event with unindexed id.
+    event LibCreated(uint256 id, uint256 amount);
+}
+
+contract EventFieldsTest {
+    // --- triggering cases -------------------------------------------------
+
+    event Transfer(address from, address to, uint256 value); //~NOTE: event has unindexed fields that may benefit from being indexed: `from` (`address`), `to` (`address`)
+
+    event Mint(address to, uint256 tokenId); //~NOTE: event has unindexed fields that may benefit from being indexed: `to` (`address`)
+
+    event Order(bytes32 orderId, uint256 amount);
+
+    event CreatedAlias(uint id);
+
+    event ScreamingId(uint256 ID);
+
+    event CamelCapsId(uint256 tokenID);
+
+    event SnakeId(bytes32 token_id);
+
+    event PayableAddr(address payable receiver); //~NOTE: event has unindexed fields that may benefit from being indexed: `receiver` (`address payable`)
+
+    // Anonymous events allow up to 4 indexed.
+    event AnonFour(address a, address b, address c, address d) anonymous; //~NOTE: event has unindexed fields that may benefit from being indexed: `a` (`address`), `b` (`address`), `c` (`address`), `d` (`address`)
+
+    // Unnamed param is reported using its positional index.
+    event Unnamed(address, uint256); //~NOTE: event has unindexed fields that may benefit from being indexed: `parameter #1` (`address`)
+
+    // --- non-triggering cases --------------------------------------------
+
+    // Already fully indexed.
+    event TransferOk(address indexed from, address indexed to, uint256 value);
+
+    // Partially indexed: the author has chosen what to index.
+    event PartiallyIndexed(address indexed from, address to, uint256 value);
+
+    // Same rule for non-anonymous partial cap: at least one indexed param ⇒ no warning.
+    event PartialCap(address indexed a, address indexed b, address c, address d);
+
+    // Same rule for anonymous events.
+    event AnonPartial(address indexed a, address indexed b, address indexed c, address d, address e) anonymous;
+
+    // Uint/bytes fields are not flagged.
+    event AmountOnly(uint256 amount);
+    event HashOnly(bytes32 hash);
+
+    event NoFalsePositive(uint256 liquid, bytes32 valid);
+    event NoFalsePositiveCaps(uint256 VALID, uint256 LIQUID, bytes32 GRID);
+
+    // Smaller integer / bytes widths are not flagged even when id-like.
+    event SmallId(uint128 id, bytes16 orderId);
+
+    // Arrays and other non-elementary kinds are out of scope.
+    event Arrays(address[] users, uint256[] ids, bytes32[] orderIds);
+
+    // No params.
+    event NoParams();
+
+    // Capacity already full (3 indexed in non-anonymous): nothing actionable.
+    event FullCap(address indexed a, address indexed b, address indexed c, address d);
+
+    // Anonymous capacity full (4 indexed): nothing actionable.
+    event AnonFullCap(address indexed a, address indexed b, address indexed c, address indexed d, address e) anonymous;
+}
+
+// Custom types (UDVT and contract-typed) are out of scope: AST pass cannot resolve them.
+type UserId is uint256;
+contract Token {}
+event WrappedUdvt(UserId userId);
+event ContractTyped(Token tok);
diff --git a/crates/lint/testdata/EventFields.stderr b/crates/lint/testdata/EventFields.stderr
new file mode 100644
index 0000000000000..5e8b867304794
--- /dev/null
+++ b/crates/lint/testdata/EventFields.stderr
@@ -0,0 +1,56 @@
+note[event-fields]: event has unindexed fields that may benefit from being indexed: `from` (`address`), `to` (`address`)
+   ╭▸ ROOT/testdata/EventFields.sol:LL:CC
+   │
+LL │ event TopLevelTransfer(address from, address to, uint256 value);
+   │       ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/event-fields
+
+note[event-fields]: event has unindexed fields that may benefit from being indexed: `from` (`address`)
+   ╭▸ ROOT/testdata/EventFields.sol:LL:CC
+   │
+LL │     event InterfaceTransfer(address from, uint256 value);
+   │           ━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/event-fields
+
+note[event-fields]: event has unindexed fields that may benefit from being indexed: `from` (`address`), `to` (`address`)
+   ╭▸ ROOT/testdata/EventFields.sol:LL:CC
+   │
+LL │     event Transfer(address from, address to, uint256 value);
+   │           ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/event-fields
+
+note[event-fields]: event has unindexed fields that may benefit from being indexed: `to` (`address`)
+   ╭▸ ROOT/testdata/EventFields.sol:LL:CC
+   │
+LL │     event Mint(address to, uint256 tokenId);
+   │           ━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/event-fields
+
+note[event-fields]: event has unindexed fields that may benefit from being indexed: `receiver` (`address payable`)
+   ╭▸ ROOT/testdata/EventFields.sol:LL:CC
+   │
+LL │     event PayableAddr(address payable receiver);
+   │           ━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/event-fields
+
+note[event-fields]: event has unindexed fields that may benefit from being indexed: `a` (`address`), `b` (`address`), `c` (`address`), `d` (`address`)
+   ╭▸ ROOT/testdata/EventFields.sol:LL:CC
+   │
+LL │     event AnonFour(address a, address b, address c, address d) anonymous;
+   │           ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/event-fields
+
+note[event-fields]: event has unindexed fields that may benefit from being indexed: `parameter #1` (`address`)
+   ╭▸ ROOT/testdata/EventFields.sol:LL:CC
+   │
+LL │     event Unnamed(address, uint256);
+   │           ━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/event-fields
+
diff --git a/crates/lint/testdata/ExternalFunction.sol b/crates/lint/testdata/ExternalFunction.sol
new file mode 100644
index 0000000000000..0524f0414d19d
--- /dev/null
+++ b/crates/lint/testdata/ExternalFunction.sol
@@ -0,0 +1,304 @@
+//@compile-flags: --only-lint external-function
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+interface IExternal {
+    function ping(bytes calldata data) external;
+}
+
+// Library functions are out of scope (different `external` semantics).
+library MathLib {
+    function sumMemory(uint256[] memory xs) public pure returns (uint256 s) {
+        for (uint256 i = 0; i < xs.length; i++) s += xs[i];
+    }
+}
+
+contract ExternalFunction {
+    address public owner;
+    bytes public stored;
+
+    // SHOULD FAIL:
+
+    function setStored(bytes memory data) public { //~NOTE: `public` function can be declared `external`
+        stored = data;
+    }
+
+    function multiArrayConsumer(uint256[] memory xs, address[] memory ys) public { //~NOTE: `public` function can be declared `external`
+        owner = ys[0];
+        if (xs.length > 0) owner = ys[xs.length - 1];
+    }
+
+    function structConsumer(Item memory item) public { //~NOTE: `public` function can be declared `external`
+        owner = item.who;
+    }
+
+    function nestedReferenceConsumer(string memory s, uint256 v) public returns (uint256) { //~NOTE: `public` function can be declared `external`
+        bytes memory bs = bytes(s);
+        return bs.length + v;
+    }
+
+    function calledOnlyExternally(bytes memory payload) public { //~NOTE: `public` function can be declared `external`
+        stored = payload;
+    }
+
+    function localUnaryStillExternal(bytes memory data) public { //~NOTE: `public` function can be declared `external`
+        uint256 i = 0;
+        i++;
+        stored = data;
+    }
+
+    // SHOULD PASS:
+
+    // Already external.
+    function alreadyExternal(bytes calldata data) external {
+        stored = data;
+    }
+
+    // Internal / private — not candidates.
+    function internalHelper(bytes memory data) internal {
+        stored = data;
+    }
+
+    function privateHelper(bytes memory data) private {
+        stored = data;
+    }
+
+    // Value-only signature — savings are negligible.
+    function valueOnly(uint256 a, uint256 b) public {
+        owner = address(uint160(a + b));
+    }
+
+    // Reference param already in calldata.
+    function calldataReferenceOnly(bytes calldata data) public {
+        stored = data;
+    }
+
+    // Called internally — must stay public.
+    function calledInternally(bytes memory data) public {
+        stored = data;
+    }
+
+    function callsCalledInternally(bytes memory data) external {
+        calledInternally(data);
+    }
+
+    // Used as a function pointer — counted as an internal use.
+    function takenAsPointer(bytes memory data) public {
+        stored = data;
+    }
+
+    function usePointer(bytes memory data) external {
+        function (bytes memory) internal ptr = takenAsPointer;
+        ptr(data);
+    }
+
+    // Body writes to a parameter — switching to calldata would not compile.
+    function writesToParam(bytes memory data) public {
+        data = abi.encodePacked(data, uint8(0x01));
+        stored = data;
+    }
+
+    function writesToParamField(Item memory item) public {
+        item.who = msg.sender;
+        owner = item.who;
+    }
+
+    function writesToParamIndex(uint256[] memory xs) public {
+        xs[0] = 1;
+        owner = address(uint160(xs[0]));
+    }
+
+    function deletesParam(uint256[] memory xs) public {
+        delete xs;
+    }
+
+    function incrementsParam(uint256 i, uint256[] memory xs) public {
+        xs[i++] = 1;
+    }
+
+    // Constructor / receive / fallback — never candidates.
+    constructor(bytes memory init) {
+        stored = init;
+    }
+
+    receive() external payable {}
+
+    fallback() external payable {
+        stored = msg.data;
+    }
+
+    struct Item {
+        address who;
+        uint256 amount;
+    }
+}
+
+abstract contract Base {
+    bytes internal _bytes;
+
+    // Abstract — must stay ≥ public for derived contracts to override.
+    function virtualWithoutBody(bytes memory data) public virtual;
+
+    // Reached via `super.calledViaSuper(...)` in `Derived`.
+    function calledViaSuper(bytes memory data) public virtual {
+        _bytes = data;
+    }
+}
+
+contract Derived is Base {
+    // Override of a public base — skipped regardless of internal use.
+    function virtualWithoutBody(bytes memory data) public override {
+        _bytes = data;
+    }
+
+    function callsSuper(bytes memory data) external {
+        super.calledViaSuper(data);
+    }
+}
+
+// Interface functions have no body and are skipped.
+interface IIface {
+    function ifaceFn(bytes calldata data) external;
+}
+
+// `super.` is scoped to the caller's inheritance chain, so a same-named function in
+// an unrelated contract is still flagged.
+abstract contract UnrelatedBase {
+    function isolatedSuperTarget(bytes memory data) public virtual {}
+}
+
+contract UnrelatedDerived is UnrelatedBase {
+    function callsSuper(bytes memory data) external {
+        super.isolatedSuperTarget(data);
+    }
+}
+
+contract UnrelatedSameName {
+    bytes public buf;
+
+    function isolatedSuperTarget(bytes memory data) public { //~NOTE: `public` function can be declared `external`
+        buf = data;
+    }
+}
+
+library ItemLib {
+    struct Item {
+        address who;
+        uint256 amount;
+    }
+
+    function bumpAmount(Item memory item) internal pure {
+        item.amount += 1;
+    }
+
+    function readAmount(Item memory item) internal pure returns (uint256) {
+        return item.amount;
+    }
+}
+
+abstract contract WithGuard {
+    modifier checkBytes(bytes memory data) {
+        require(data.length > 0, "empty");
+        _;
+    }
+
+    modifier checkValues() {
+        require(msg.sender != address(0), "zero");
+        _;
+    }
+}
+
+contract EscapingParams is WithGuard {
+    using ItemLib for ItemLib.Item;
+
+    bytes public stored;
+    address public owner;
+
+    function callsInternalHelper(bytes memory data) public {
+        _mutate(data);
+        stored = data;
+    }
+
+    function _mutate(bytes memory data) internal pure {
+        if (data.length > 0) {
+            data[0] = 0x01;
+        }
+    }
+
+    function usingForReceiver(ItemLib.Item memory item) public {
+        item.bumpAmount();
+        owner = item.who;
+    }
+
+    function aliasInit(ItemLib.Item memory item) public {
+        ItemLib.Item memory alias_ = item;
+        owner = alias_.who;
+    }
+
+    function aliasAssign(ItemLib.Item memory item) public {
+        ItemLib.Item memory tmp;
+        tmp = item;
+        owner = tmp.who;
+    }
+
+    function guardedByModifier(bytes memory data) public checkBytes(data) {
+        stored = data;
+    }
+
+    function readsOnly(ItemLib.Item memory item) public { //~NOTE: `public` function can be declared `external`
+        uint256 amt = item.amount;
+        owner = item.who;
+        stored = abi.encodePacked(amt);
+    }
+
+    function modifierWithoutArgs(bytes memory data) public checkValues { //~NOTE: `public` function can be declared `external`
+        stored = data;
+    }
+
+    function returnsParam(bytes memory data) public returns (bytes memory) { //~NOTE: `public` function can be declared `external`
+        return data;
+    }
+}
+
+contract OverloadedReferences {
+    function consume(bytes memory data) public pure returns (uint256) { //~NOTE: `public` function can be declared `external`
+        return data.length;
+    }
+
+    function consume(uint256[] memory data) public pure returns (uint256) {
+        return data.length;
+    }
+
+    function callArray(uint256[] memory data) external pure returns (uint256) {
+        return consume(data);
+    }
+}
+
+contract OverloadedSuperBase {
+    function consume(bytes memory data) public pure virtual returns (uint256) { //~NOTE: `public` function can be declared `external`
+        return data.length;
+    }
+
+    function consume(uint256[] memory data) public pure virtual returns (uint256) {
+        return data.length;
+    }
+}
+
+contract OverloadedSuperDerived is OverloadedSuperBase {
+    function callArray(uint256[] memory data) external pure returns (uint256) {
+        return super.consume(data);
+    }
+}
+
+contract QualifiedReferenceBase {
+    function consume(bytes memory data) public pure returns (uint256) {
+        return data.length;
+    }
+}
+
+contract QualifiedReferenceDerived is QualifiedReferenceBase {
+    function callBase(bytes memory data) external pure returns (uint256) {
+        return QualifiedReferenceBase.consume(data);
+    }
+}
diff --git a/crates/lint/testdata/ExternalFunction.stderr b/crates/lint/testdata/ExternalFunction.stderr
new file mode 100644
index 0000000000000..1d45f42247286
--- /dev/null
+++ b/crates/lint/testdata/ExternalFunction.stderr
@@ -0,0 +1,96 @@
+note[external-function]: `public` function can be declared `external`
+   ╭▸ ROOT/testdata/ExternalFunction.sol:LL:CC
+   │
+LL │     function setStored(bytes memory data) public {
+   │              ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/external-function
+
+note[external-function]: `public` function can be declared `external`
+   ╭▸ ROOT/testdata/ExternalFunction.sol:LL:CC
+   │
+LL │     function multiArrayConsumer(uint256[] memory xs, address[] memory ys) public {
+   │              ━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/external-function
+
+note[external-function]: `public` function can be declared `external`
+   ╭▸ ROOT/testdata/ExternalFunction.sol:LL:CC
+   │
+LL │     function structConsumer(Item memory item) public {
+   │              ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/external-function
+
+note[external-function]: `public` function can be declared `external`
+   ╭▸ ROOT/testdata/ExternalFunction.sol:LL:CC
+   │
+LL │     function nestedReferenceConsumer(string memory s, uint256 v) public returns (uint256) {
+   │              ━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/external-function
+
+note[external-function]: `public` function can be declared `external`
+   ╭▸ ROOT/testdata/ExternalFunction.sol:LL:CC
+   │
+LL │     function calledOnlyExternally(bytes memory payload) public {
+   │              ━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/external-function
+
+note[external-function]: `public` function can be declared `external`
+   ╭▸ ROOT/testdata/ExternalFunction.sol:LL:CC
+   │
+LL │     function localUnaryStillExternal(bytes memory data) public {
+   │              ━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/external-function
+
+note[external-function]: `public` function can be declared `external`
+   ╭▸ ROOT/testdata/ExternalFunction.sol:LL:CC
+   │
+LL │     function isolatedSuperTarget(bytes memory data) public {
+   │              ━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/external-function
+
+note[external-function]: `public` function can be declared `external`
+   ╭▸ ROOT/testdata/ExternalFunction.sol:LL:CC
+   │
+LL │     function readsOnly(ItemLib.Item memory item) public {
+   │              ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/external-function
+
+note[external-function]: `public` function can be declared `external`
+   ╭▸ ROOT/testdata/ExternalFunction.sol:LL:CC
+   │
+LL │     function modifierWithoutArgs(bytes memory data) public checkValues {
+   │              ━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/external-function
+
+note[external-function]: `public` function can be declared `external`
+   ╭▸ ROOT/testdata/ExternalFunction.sol:LL:CC
+   │
+LL │     function returnsParam(bytes memory data) public returns (bytes memory) {
+   │              ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/external-function
+
+note[external-function]: `public` function can be declared `external`
+   ╭▸ ROOT/testdata/ExternalFunction.sol:LL:CC
+   │
+LL │     function consume(bytes memory data) public pure returns (uint256) {
+   │              ━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/external-function
+
+note[external-function]: `public` function can be declared `external`
+   ╭▸ ROOT/testdata/ExternalFunction.sol:LL:CC
+   │
+LL │     function consume(bytes memory data) public pure virtual returns (uint256) {
+   │              ━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/external-function
+
diff --git a/crates/lint/testdata/FunctionInitState.sol b/crates/lint/testdata/FunctionInitState.sol
new file mode 100644
index 0000000000000..b61224fb16d8a
--- /dev/null
+++ b/crates/lint/testdata/FunctionInitState.sol
@@ -0,0 +1,215 @@
+//@compile-flags: --only-lint function-init-state
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+// Tests for `function-init-state`: state variable initializers run at construction, before the
+// constructor body, in base-to-derived order. An initializer that reads another non-constant
+// state variable or calls a non-pure function observes that partial state (default values,
+// surprising ordering), so the computed value is rarely the one intended. References to
+// constants, calls to pure functions and plain literals are fine, and assignments made inside
+// the constructor body are out of scope.
+
+uint256 constant FILE_LEVEL = 10;
+
+contract Base {
+    function baseValue() internal view returns (uint256) {
+        return address(this).balance;
+    }
+
+    function basePure(uint256 value) internal pure returns (uint256) {
+        return value + 1;
+    }
+}
+
+contract Oracle {
+    uint256 public price = 3;
+}
+
+contract OracleChild is Oracle {}
+
+interface GasTarget {
+    function pureRead() external pure returns (uint256);
+}
+
+contract CreatedWithSalt {}
+
+contract OverrideBase {
+    function maybePure() public view virtual returns (uint256) {
+        return address(this).balance;
+    }
+}
+
+contract OverrideChild is OverrideBase {
+    function maybePure() public pure override returns (uint256) {
+        return 1;
+    }
+
+    // `super` bypasses the local pure override and resolves to the base's view declaration
+    uint256 public fromSuperCall = super.maybePure(); //~NOTE: state variable initializer
+}
+
+library Clock {
+    function stamp(uint256 value) internal view returns (uint256) {
+        return value + block.timestamp;
+    }
+
+    function pureStamp(uint256 value) internal pure returns (uint256) {
+        return value + 1;
+    }
+}
+
+library Consts {
+    uint256 internal constant LC = 3;
+}
+
+contract InitFromFunction is Base {
+    uint256 internal seed = 77;
+
+    uint256 public fromNonPure = set(); //~NOTE: state variable initializer
+
+    uint256 public fromView = read(); //~NOTE: state variable initializer
+
+    uint256 public fromStateRead = seed + 1; //~NOTE: state variable initializer
+
+    // the state reference hides in the argument of a pure call
+    uint256 public fromPureWithStateArg = double(seed); //~NOTE: state variable initializer
+
+    // the state reference hides in an external call option
+    uint256 public fromPureWithStateGas = GasTarget(address(0x1234)).pureRead{gas: seed}(); //~NOTE: state variable initializer
+
+    // the state reference hides in a creation call option
+    CreatedWithSalt public fromStateSalt = new CreatedWithSalt{salt: bytes32(seed)}(); //~NOTE: state variable initializer
+
+    uint256 public fromInherited = baseValue(); //~NOTE: state variable initializer
+
+    // the qualified form of the same inherited view call
+    uint256 public fromQualifiedInherited = Base.baseValue(); //~NOTE: state variable initializer
+
+    // an external call to another contract's getter reads that contract's state
+    uint256 public fromExternalGetter = Oracle(address(0x1234)).price(); //~NOTE: state variable initializer
+
+    // the getter is inherited: it is not among the child's own items
+    uint256 public fromInheritedGetter = OracleChild(address(0x1234)).price(); //~NOTE: state variable initializer
+
+    using Clock for uint256;
+
+    // a `using for` call binds the library function to the value type
+    uint256 public fromUsingView = uint256(5).stamp(); //~NOTE: state variable initializer
+
+    uint256 public fromUsingPure = uint256(5).pureStamp();
+
+    // only the overload the call dispatches to matters: `mix(1)` is the pure one
+    uint256 public fromPureOverload = mix(1);
+
+    uint256 public fromViewOverload = mix(1, 2); //~NOTE: state variable initializer
+
+    // a same-arity overload pair differing by parameter type: `typed(1)` is the pure one
+    uint256 public fromPureSameArityOverload = typed(1);
+
+    uint256 public fromViewSameArityOverload = typed("s"); //~NOTE: state variable initializer
+
+    // dispatch selects the child's pure override, not the view base declaration behind it
+    uint256 public fromPureOverride = OverrideChild(address(0x1234)).maybePure();
+
+    // typed through the base, the resolved declaration is the base's own view one
+    uint256 public fromBaseTypedOverride = OverrideBase(address(0x1234)).maybePure(); //~NOTE: state variable initializer
+
+    // a member reference used as a value selects the child's pure override, like a call would
+    function() external view returns (uint256) internal refPureOverride = OverrideChild(address(0x1234)).maybePure;
+
+    // the same reference typed through the base selects the base's own view declaration
+    function() external view returns (uint256) internal refBaseTyped = OverrideBase(address(0x1234)).maybePure; //~NOTE: state variable initializer
+
+    // a call through a function pointer stored in state reads that variable
+    uint256 public fromPointerCall = fnPtr(); //~NOTE: state variable initializer
+
+    function() internal pure returns (uint256) internal fnPtr = noState;
+
+    uint256 public immutable fromNonPureImmutable = set(); //~NOTE: state variable initializer
+
+    uint256 public fromLiteral = 5;
+
+    uint256 public constant LOCAL_CONSTANT = 7;
+
+    uint256 public fromConstant = LOCAL_CONSTANT + 1;
+
+    uint256 public fromFileConstant = FILE_LEVEL * 2;
+
+    uint256 public fromLibConstant = Consts.LC + 1;
+
+    uint256 public fromQualifiedPure = Base.basePure(21);
+
+    uint256 public fromPure = double(21);
+
+    uint256 internal assignedInConstructor;
+
+    constructor() {
+        // constructor-body assignments run after every initializer: out of scope
+        assignedInConstructor = set();
+    }
+
+    function set() internal returns (uint256) {
+        seed = 78;
+        return seed;
+    }
+
+    function read() internal view returns (uint256) {
+        return seed;
+    }
+
+    function double(uint256 value) internal pure returns (uint256) {
+        return value * 2;
+    }
+
+    function mix(uint256 x) internal pure returns (uint256) {
+        return x + 1;
+    }
+
+    function mix(uint256 x, uint256 y) internal view returns (uint256) {
+        return x + y + seed;
+    }
+
+    function typed(uint256 x) internal pure returns (uint256) {
+        return x + 1;
+    }
+
+    function typed(string memory) internal view returns (uint256) {
+        return seed;
+    }
+
+    function noState() internal pure returns (uint256) {
+        return 4;
+    }
+
+    function localIsFine() external view returns (uint256) {
+        // a local variable initialized from state inside a function body: out of scope
+        uint256 local = seed + read();
+        return local;
+    }
+}
+
+struct Config {
+    address owner;
+    uint8 decimals;
+}
+
+library Defaults {
+    function config() internal pure returns (Config memory) {
+        return Config(address(0), 18);
+    }
+}
+
+// Member access on a reference type inside an initializer used to abort the linter, because the
+// base type reached `members_of` with its data location peeled off. Reading a state variable is
+// still reported, and a pure library call is still fine.
+contract InitFromReferenceType {
+    Config internal config;
+    uint256[] internal ids;
+    bytes internal payload;
+
+    address public owner = config.owner; //~NOTE: state variable initializer
+    uint256 public idCount = ids.length; //~NOTE: state variable initializer
+    uint256 public payloadSize = payload.length; //~NOTE: state variable initializer
+
+    uint8 public defaultDecimals = Defaults.config().decimals;
+}
diff --git a/crates/lint/testdata/FunctionInitState.stderr b/crates/lint/testdata/FunctionInitState.stderr
new file mode 100644
index 0000000000000..eb693a4b1d0e9
--- /dev/null
+++ b/crates/lint/testdata/FunctionInitState.stderr
@@ -0,0 +1,168 @@
+note[function-init-state]: state variable initializer depends on a non-pure function or another state variable
+   ╭▸ ROOT/testdata/FunctionInitState.sol:LL:CC
+   │
+LL │     uint256 public fromSuperCall = super.maybePure();
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-init-state
+
+note[function-init-state]: state variable initializer depends on a non-pure function or another state variable
+   ╭▸ ROOT/testdata/FunctionInitState.sol:LL:CC
+   │
+LL │     uint256 public fromNonPure = set();
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-init-state
+
+note[function-init-state]: state variable initializer depends on a non-pure function or another state variable
+   ╭▸ ROOT/testdata/FunctionInitState.sol:LL:CC
+   │
+LL │     uint256 public fromView = read();
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-init-state
+
+note[function-init-state]: state variable initializer depends on a non-pure function or another state variable
+   ╭▸ ROOT/testdata/FunctionInitState.sol:LL:CC
+   │
+LL │     uint256 public fromStateRead = seed + 1;
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-init-state
+
+note[function-init-state]: state variable initializer depends on a non-pure function or another state variable
+   ╭▸ ROOT/testdata/FunctionInitState.sol:LL:CC
+   │
+LL │     uint256 public fromPureWithStateArg = double(seed);
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-init-state
+
+note[function-init-state]: state variable initializer depends on a non-pure function or another state variable
+   ╭▸ ROOT/testdata/FunctionInitState.sol:LL:CC
+   │
+LL │     uint256 public fromPureWithStateGas = GasTarget(address(0x1234)).pureRead{gas: seed}();
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-init-state
+
+note[function-init-state]: state variable initializer depends on a non-pure function or another state variable
+   ╭▸ ROOT/testdata/FunctionInitState.sol:LL:CC
+   │
+LL │     CreatedWithSalt public fromStateSalt = new CreatedWithSalt{salt: bytes32(seed)}();
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-init-state
+
+note[function-init-state]: state variable initializer depends on a non-pure function or another state variable
+   ╭▸ ROOT/testdata/FunctionInitState.sol:LL:CC
+   │
+LL │     uint256 public fromInherited = baseValue();
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-init-state
+
+note[function-init-state]: state variable initializer depends on a non-pure function or another state variable
+   ╭▸ ROOT/testdata/FunctionInitState.sol:LL:CC
+   │
+LL │     uint256 public fromQualifiedInherited = Base.baseValue();
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-init-state
+
+note[function-init-state]: state variable initializer depends on a non-pure function or another state variable
+   ╭▸ ROOT/testdata/FunctionInitState.sol:LL:CC
+   │
+LL │     uint256 public fromExternalGetter = Oracle(address(0x1234)).price();
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-init-state
+
+note[function-init-state]: state variable initializer depends on a non-pure function or another state variable
+   ╭▸ ROOT/testdata/FunctionInitState.sol:LL:CC
+   │
+LL │     uint256 public fromInheritedGetter = OracleChild(address(0x1234)).price();
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-init-state
+
+note[function-init-state]: state variable initializer depends on a non-pure function or another state variable
+   ╭▸ ROOT/testdata/FunctionInitState.sol:LL:CC
+   │
+LL │     uint256 public fromUsingView = uint256(5).stamp();
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-init-state
+
+note[function-init-state]: state variable initializer depends on a non-pure function or another state variable
+   ╭▸ ROOT/testdata/FunctionInitState.sol:LL:CC
+   │
+LL │     uint256 public fromViewOverload = mix(1, 2);
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-init-state
+
+note[function-init-state]: state variable initializer depends on a non-pure function or another state variable
+   ╭▸ ROOT/testdata/FunctionInitState.sol:LL:CC
+   │
+LL │     uint256 public fromViewSameArityOverload = typed("s");
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-init-state
+
+note[function-init-state]: state variable initializer depends on a non-pure function or another state variable
+   ╭▸ ROOT/testdata/FunctionInitState.sol:LL:CC
+   │
+LL │     uint256 public fromBaseTypedOverride = OverrideBase(address(0x1234)).maybePure();
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-init-state
+
+note[function-init-state]: state variable initializer depends on a non-pure function or another state variable
+   ╭▸ ROOT/testdata/FunctionInitState.sol:LL:CC
+   │
+LL │     function() external view returns (uint256) internal refBaseTyped = OverrideBase(address(0x1234)).maybePure;
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-init-state
+
+note[function-init-state]: state variable initializer depends on a non-pure function or another state variable
+   ╭▸ ROOT/testdata/FunctionInitState.sol:LL:CC
+   │
+LL │     uint256 public fromPointerCall = fnPtr();
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-init-state
+
+note[function-init-state]: state variable initializer depends on a non-pure function or another state variable
+   ╭▸ ROOT/testdata/FunctionInitState.sol:LL:CC
+   │
+LL │     uint256 public immutable fromNonPureImmutable = set();
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-init-state
+
+note[function-init-state]: state variable initializer depends on a non-pure function or another state variable
+   ╭▸ ROOT/testdata/FunctionInitState.sol:LL:CC
+   │
+LL │     address public owner = config.owner;
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-init-state
+
+note[function-init-state]: state variable initializer depends on a non-pure function or another state variable
+   ╭▸ ROOT/testdata/FunctionInitState.sol:LL:CC
+   │
+LL │     uint256 public idCount = ids.length;
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-init-state
+
+note[function-init-state]: state variable initializer depends on a non-pure function or another state variable
+   ╭▸ ROOT/testdata/FunctionInitState.sol:LL:CC
+   │
+LL │     uint256 public payloadSize = payload.length;
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-init-state
+
diff --git a/crates/lint/testdata/FunctionSelectorCollision.sol b/crates/lint/testdata/FunctionSelectorCollision.sol
new file mode 100644
index 0000000000000..32a4844b7de63
--- /dev/null
+++ b/crates/lint/testdata/FunctionSelectorCollision.sol
@@ -0,0 +1,844 @@
+//@compile-flags: --only-lint function-selector-collision
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.20;
+
+interface IImplementation {
+    function gsf() external;
+    function forwarded() external;
+    function shared() external;
+}
+
+interface ILoopSelectors {
+    function f0() external;
+    function f1() external;
+    function f2() external;
+    function f3() external;
+    function f4() external;
+    function f5() external;
+    function f6() external;
+    function f7() external;
+}
+
+// The fallback's typed target designates IImplementation as this proxy's implementation API.
+contract TypedProxy { //~WARN: proxy function `TypedProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+    IImplementation internal immutable implementation;
+
+    constructor(IImplementation implementation_) {
+        implementation = implementation_;
+    }
+
+    function tgeo() external {}
+
+    // An identical signature is function shadowing, not a selector hash collision.
+    function shared() external {}
+
+    fallback() external payable {
+        (bool success,) = address(implementation).delegatecall(msg.data);
+        if (!success) revert();
+    }
+}
+
+contract ConcreteImplementation {
+    function collate_propagate_storage(bytes16) external {}
+}
+
+// Concrete contract types designate an implementation API too.
+contract ConcreteTypedProxy { //~WARN: proxy function `ConcreteTypedProxy.burn(uint256)` collides with implementation function `ConcreteImplementation.collate_propagate_storage(bytes16)` at selector `0x42966c68`
+    ConcreteImplementation internal immutable implementation;
+
+    constructor(ConcreteImplementation implementation_) {
+        implementation = implementation_;
+    }
+
+    function burn(uint256) external {}
+
+    fallback() external payable {
+        (bool success,) = payable(address(implementation)).delegatecall(msg.data);
+        if (!success) revert();
+    }
+}
+
+// Unrelated contracts are not compared without an explicit proxy/implementation relationship.
+contract UnrelatedImplementation {
+    function gsf() external {}
+}
+
+contract UnrelatedContract {
+    function tgeo() external {}
+}
+
+// Address-only targets do not designate an implementation API and stay out of scope.
+contract AddressProxy {
+    address internal immutable implementation;
+
+    constructor(address implementation_) {
+        implementation = implementation_;
+    }
+
+    function tgeo() external {}
+
+    fallback() external payable {
+        (bool success,) = implementation.delegatecall(msg.data);
+        if (!success) revert();
+    }
+}
+
+// A typed delegatecall outside the fallback does not create proxy dispatch behavior.
+contract TypedDelegatecallUser {
+    IImplementation internal immutable implementation;
+
+    constructor(IImplementation implementation_) {
+        implementation = implementation_;
+    }
+
+    function tgeo() external {}
+
+    function forward() external {
+        (bool success,) = address(implementation).delegatecall(msg.data);
+        if (!success) revert();
+    }
+}
+
+// A fallback forwarding a fixed payload is not a general proxy fallback.
+contract FixedPayloadDelegatecall {
+    IImplementation internal immutable implementation;
+
+    constructor(IImplementation implementation_) {
+        implementation = implementation_;
+    }
+
+    function tgeo() external {}
+
+    fallback() external payable {
+        (bool success,) = address(implementation).delegatecall(
+            abi.encodeCall(IImplementation.gsf, ())
+        );
+        if (!success) revert();
+    }
+}
+
+// The parameterized fallback input is exactly equal to msg.data.
+contract ParameterizedFallbackProxy { //~WARN: proxy function `ParameterizedFallbackProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+    IImplementation internal immutable implementation;
+
+    constructor(IImplementation implementation_) {
+        implementation = implementation_;
+    }
+
+    function tgeo() external {}
+
+    fallback(bytes calldata input) external returns (bytes memory) {
+        (bool success, bytes memory returndata) = address(implementation).delegatecall(input);
+        if (!success) revert();
+        return returndata;
+    }
+}
+
+// Reassigned fallback calldata is no longer proven to be the full msg.data.
+contract ReassignedFallbackInputProxy {
+    IImplementation internal immutable implementation;
+
+    constructor(IImplementation implementation_) {
+        implementation = implementation_;
+    }
+
+    function tgeo() external {}
+
+    fallback(bytes calldata input) external returns (bytes memory) {
+        input = input[4:];
+        (bool success, bytes memory returndata) = address(implementation).delegatecall(input);
+        if (!success) revert();
+        return returndata;
+    }
+}
+
+// Mutating the fallback input after forwarding does not change the forwarded calldata.
+contract ReassignedAfterForwardingProxy { //~WARN: proxy function `ReassignedAfterForwardingProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+    IImplementation internal immutable implementation;
+
+    constructor(IImplementation implementation_) {
+        implementation = implementation_;
+    }
+
+    function tgeo() external {}
+
+    fallback(bytes calldata input) external returns (bytes memory) {
+        (bool success, bytes memory returndata) = address(implementation).delegatecall(input);
+        input = input[4:];
+        if (!success) revert();
+        return returndata;
+    }
+}
+
+// Call options execute before the delegatecall and can modify its calldata argument.
+contract ReassignedInCallOptionsProxy {
+    IImplementation internal immutable implementation;
+
+    constructor(IImplementation implementation_) {
+        implementation = implementation_;
+    }
+
+    function tgeo() external {}
+
+    fallback(bytes calldata input) external returns (bytes memory) {
+        (bool success, bytes memory returndata) = address(implementation).delegatecall{
+            gas: (input = input[4:]).length
+        }(input);
+        if (!success) revert();
+        return returndata;
+    }
+}
+
+// Selector-correlated mutation does not suppress other unmodified selector paths.
+contract SelectorCorrelatedMutationProxy { //~WARN: proxy function `SelectorCorrelatedMutationProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+    IImplementation internal immutable implementation;
+
+    constructor(IImplementation implementation_) {
+        implementation = implementation_;
+    }
+
+    function tgeo() external {}
+
+    fallback(bytes calldata input) external returns (bytes memory) {
+        if (msg.sig == IImplementation.forwarded.selector) input = input[4:];
+        (bool success, bytes memory returndata) = address(implementation).delegatecall(input);
+        if (!success) revert();
+        return returndata;
+    }
+}
+
+// A selector guard limits the implementation API reachable through this delegatecall.
+contract SelectorGatedProxy {
+    IImplementation internal immutable implementation;
+
+    constructor(IImplementation implementation_) {
+        implementation = implementation_;
+    }
+
+    function tgeo() external {}
+
+    fallback() external payable {
+        if (msg.sig == IImplementation.forwarded.selector) {
+            (bool success,) = address(implementation).delegatecall(msg.data);
+            if (!success) revert();
+        }
+    }
+}
+
+// An inequality guard excludes its selector from the implementation API.
+contract SelectorExcludedProxy {
+    IImplementation internal immutable implementation;
+
+    constructor(IImplementation implementation_) {
+        implementation = implementation_;
+    }
+
+    function tgeo() external {}
+
+    fallback() external payable {
+        if (msg.sig != IImplementation.gsf.selector) {
+            (bool success,) = address(implementation).delegatecall(msg.data);
+            if (!success) revert();
+        }
+    }
+}
+
+// A selector branch that exits cannot reach delegatecalls after the branch.
+contract SelectorExitGatedProxy {
+    IImplementation internal immutable implementation;
+
+    constructor(IImplementation implementation_) {
+        implementation = implementation_;
+    }
+
+    function tgeo() external {}
+
+    fallback() external payable {
+        if (msg.sig == IImplementation.gsf.selector) revert();
+        (bool success,) = address(implementation).delegatecall(msg.data);
+        if (!success) revert();
+    }
+}
+
+// An exiting try success clause does not make its catch clause unreachable.
+contract TryCatchExitProxy { //~WARN: proxy function `TryCatchExitProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+    IImplementation internal immutable implementation;
+
+    constructor(IImplementation implementation_) {
+        implementation = implementation_;
+    }
+
+    function tgeo() external {}
+
+    fallback() external payable {
+        try implementation.forwarded() {
+            revert();
+        } catch {
+            (bool success,) = address(implementation).delegatecall(msg.data);
+            if (!success) revert();
+        }
+    }
+}
+
+// Calldata mutation in a try success clause does not taint its catch path.
+contract TryCatchMutationProxy { //~WARN: proxy function `TryCatchMutationProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+    IImplementation internal immutable implementation;
+
+    constructor(IImplementation implementation_) {
+        implementation = implementation_;
+    }
+
+    function tgeo() external {}
+
+    fallback(bytes calldata input) external returns (bytes memory) {
+        try implementation.forwarded() {
+            input = input[4:];
+        } catch {}
+        (bool success, bytes memory returndata) = address(implementation).delegatecall(input);
+        if (!success) revert();
+        return returndata;
+    }
+}
+
+// A while loop can fall through without executing an exiting body.
+contract LoopZeroIterationProxy { //~WARN: proxy function `LoopZeroIterationProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+    IImplementation internal immutable implementation;
+
+    constructor(IImplementation implementation_) {
+        implementation = implementation_;
+    }
+
+    function tgeo() external {}
+
+    fallback() external payable {
+        while (msg.value != 0) return;
+        (bool success,) = address(implementation).delegatecall(msg.data);
+        if (!success) revert();
+    }
+}
+
+// Statements after break do not affect the loop's exit path.
+contract LoopBreakProxy { //~WARN: proxy function `LoopBreakProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+    IImplementation internal immutable implementation;
+
+    constructor(IImplementation implementation_) {
+        implementation = implementation_;
+    }
+
+    function tgeo() external {}
+
+    fallback(bytes calldata input) external returns (bytes memory) {
+        while (true) {
+            break;
+            input = input[4:];
+        }
+        (bool success, bytes memory returndata) = address(implementation).delegatecall(input);
+        if (!success) revert();
+        return returndata;
+    }
+}
+
+// Statements after continue do not affect the loop's next iteration or exit path.
+contract LoopContinueProxy { //~WARN: proxy function `LoopContinueProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+    IImplementation internal immutable implementation;
+
+    constructor(IImplementation implementation_) {
+        implementation = implementation_;
+    }
+
+    function tgeo() external {}
+
+    fallback(bytes calldata input) external payable returns (bytes memory) {
+        while (msg.value != 0) {
+            continue;
+            input = input[4:];
+        }
+        (bool success, bytes memory returndata) = address(implementation).delegatecall(input);
+        if (!success) revert();
+        return returndata;
+    }
+}
+
+// Continue in a do-while loop still evaluates the loop condition before exiting.
+contract DoWhileContinueProxy { //~WARN: proxy function `DoWhileContinueProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+    IImplementation internal immutable implementation;
+
+    constructor(IImplementation implementation_) {
+        implementation = implementation_;
+    }
+
+    function tgeo() external {}
+
+    fallback(bytes calldata input) external returns (bytes memory) {
+        do {
+            continue;
+            input = input[4:];
+        } while (false);
+        (bool success, bytes memory returndata) = address(implementation).delegatecall(input);
+        if (!success) revert();
+        return returndata;
+    }
+}
+
+// Continue in a for loop still evaluates the next expression.
+contract ForContinueProxy { //~WARN: proxy function `ForContinueProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+    IImplementation internal immutable implementation;
+
+    constructor(IImplementation implementation_) {
+        implementation = implementation_;
+    }
+
+    function tgeo() external {}
+
+    fallback(bytes calldata input) external {
+        for (
+            ;
+            msg.sig == IImplementation.gsf.selector;
+            address(implementation).delegatecall(input)
+        ) {
+            continue;
+        }
+    }
+}
+
+// A false for-loop condition bypasses its body and next expression.
+contract ForZeroIterationProxy { //~WARN: proxy function `ForZeroIterationProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+    IImplementation internal immutable implementation;
+
+    constructor(IImplementation implementation_) {
+        implementation = implementation_;
+    }
+
+    function tgeo() external {}
+
+    fallback(bytes calldata input) external returns (bytes memory) {
+        for (; false; input = input[4:]) {}
+        (bool success, bytes memory returndata) = address(implementation).delegatecall(input);
+        if (!success) revert();
+        return returndata;
+    }
+}
+
+// Break in a for-loop body bypasses its next expression.
+contract ForBreakProxy { //~WARN: proxy function `ForBreakProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+    IImplementation internal immutable implementation;
+
+    constructor(IImplementation implementation_) {
+        implementation = implementation_;
+    }
+
+    function tgeo() external {}
+
+    fallback(bytes calldata input) external returns (bytes memory) {
+        for (;; input = input[4:]) {
+            break;
+        }
+        (bool success, bytes memory returndata) = address(implementation).delegatecall(input);
+        if (!success) revert();
+        return returndata;
+    }
+}
+
+// Break in a do-while body bypasses its condition.
+contract DoWhileBreakProxy { //~WARN: proxy function `DoWhileBreakProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+    IImplementation internal immutable implementation;
+
+    constructor(IImplementation implementation_) {
+        implementation = implementation_;
+    }
+
+    function tgeo() external {}
+
+    fallback(bytes calldata input) external returns (bytes memory) {
+        do {
+            break;
+        } while ((input = input[4:]).length != 0);
+        (bool success, bytes memory returndata) = address(implementation).delegatecall(input);
+        if (!success) revert();
+        return returndata;
+    }
+}
+
+// A short-circuited operand does not mutate calldata on the skipped path.
+contract ShortCircuitMutationProxy { //~WARN: proxy function `ShortCircuitMutationProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+    IImplementation internal immutable implementation;
+
+    constructor(IImplementation implementation_) {
+        implementation = implementation_;
+    }
+
+    function tgeo() external {}
+
+    fallback(bytes calldata input) external payable returns (bytes memory) {
+        if (msg.value != 0 || (input = input[4:]).length != 0) {}
+        (bool success, bytes memory returndata) = address(implementation).delegatecall(input);
+        if (!success) revert();
+        return returndata;
+    }
+}
+
+// A ternary mutation only affects the selected expression arm.
+contract TernaryMutationProxy { //~WARN: proxy function `TernaryMutationProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+    IImplementation internal immutable implementation;
+
+    constructor(IImplementation implementation_) {
+        implementation = implementation_;
+    }
+
+    function tgeo() external {}
+
+    fallback(bytes calldata input) external payable returns (bytes memory) {
+        msg.value != 0 ? input = input[4:] : input;
+        (bool success, bytes memory returndata) = address(implementation).delegatecall(input);
+        if (!success) revert();
+        return returndata;
+    }
+}
+
+// Only the mutated short-circuit path can fall through to the delegatecall.
+contract ShortCircuitExitProxy {
+    IImplementation internal immutable implementation;
+
+    constructor(IImplementation implementation_) {
+        implementation = implementation_;
+    }
+
+    function tgeo() external {}
+
+    fallback(bytes calldata input) external payable returns (bytes memory) {
+        if (msg.value != 0 || (input = input[4:]).length != 0) return "";
+        (bool success, bytes memory returndata) = address(implementation).delegatecall(input);
+        if (!success) revert();
+        return returndata;
+    }
+}
+
+// Only the mutated ternary path can fall through to the delegatecall.
+contract TernaryExitProxy {
+    IImplementation internal immutable implementation;
+
+    constructor(IImplementation implementation_) {
+        implementation = implementation_;
+    }
+
+    function tgeo() external {}
+
+    fallback(bytes calldata input) external payable returns (bytes memory) {
+        if (msg.value != 0 ? true : (input = input[4:]).length != 0) return "";
+        (bool success, bytes memory returndata) = address(implementation).delegatecall(input);
+        if (!success) revert();
+        return returndata;
+    }
+}
+
+// A selector constraint remains correlated through a short-circuit condition.
+contract CompoundSelectorGatedProxy {
+    IImplementation internal immutable implementation;
+
+    constructor(IImplementation implementation_) {
+        implementation = implementation_;
+    }
+
+    function tgeo() external {}
+
+    fallback() external payable {
+        if (msg.sig == IImplementation.forwarded.selector && msg.value != 0) {
+            (bool success,) = address(implementation).delegatecall(msg.data);
+            if (!success) revert();
+        }
+    }
+}
+
+// A selector constraint remains correlated with its ternary arm's mutation.
+contract TernarySelectorMutationProxy {
+    IImplementation internal immutable implementation;
+
+    constructor(IImplementation implementation_) {
+        implementation = implementation_;
+    }
+
+    function tgeo() external {}
+
+    fallback(bytes calldata input) external returns (bytes memory) {
+        msg.sig == IImplementation.gsf.selector ? input = input[4:] : input;
+        (bool success, bytes memory returndata) = address(implementation).delegatecall(input);
+        if (!success) revert();
+        return returndata;
+    }
+}
+
+// An unreachable false loop body does not designate an implementation target.
+contract FalseLoopProxy {
+    IImplementation internal immutable implementation;
+
+    constructor(IImplementation implementation_) {
+        implementation = implementation_;
+    }
+
+    function tgeo() external {}
+
+    fallback() external {
+        while (false) {
+            address(implementation).delegatecall(msg.data);
+        }
+    }
+}
+
+// An infinite true loop cannot fall through to a later delegatecall.
+contract InfiniteLoopProxy {
+    IImplementation internal immutable implementation;
+
+    constructor(IImplementation implementation_) {
+        implementation = implementation_;
+    }
+
+    function tgeo() external {}
+
+    fallback() external {
+        while (true) {}
+        address(implementation).delegatecall(msg.data);
+    }
+}
+
+// Loop state remains bounded when selector exclusions accumulate in different orders.
+contract LoopStateBounded {
+    fallback() external payable {
+        while (msg.value != 0) {
+            uint256 x = gasleft() % 8;
+            if (x == 0) {
+                if (msg.sig == ILoopSelectors.f0.selector) break;
+            } else if (x == 1) {
+                if (msg.sig == ILoopSelectors.f1.selector) break;
+            } else if (x == 2) {
+                if (msg.sig == ILoopSelectors.f2.selector) break;
+            } else if (x == 3) {
+                if (msg.sig == ILoopSelectors.f3.selector) break;
+            } else if (x == 4) {
+                if (msg.sig == ILoopSelectors.f4.selector) break;
+            } else if (x == 5) {
+                if (msg.sig == ILoopSelectors.f5.selector) break;
+            } else if (x == 6) {
+                if (msg.sig == ILoopSelectors.f6.selector) break;
+            } else {
+                if (msg.sig == ILoopSelectors.f7.selector) break;
+            }
+        }
+    }
+}
+
+// A reachable selector collision is still reported through a selector guard.
+contract CollisionSelectorGatedProxy { //~WARN: proxy function `CollisionSelectorGatedProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+    IImplementation internal immutable implementation;
+
+    constructor(IImplementation implementation_) {
+        implementation = implementation_;
+    }
+
+    function tgeo() external {}
+
+    fallback() external payable {
+        if (IImplementation.gsf.selector == msg.sig) {
+            (bool success,) = address(implementation).delegatecall(msg.data);
+            if (!success) revert();
+        }
+    }
+}
+
+// A successful require continuation inherits its selector constraint.
+contract RequireSelectorGatedProxy {
+    IImplementation internal immutable implementation;
+
+    constructor(IImplementation implementation_) {
+        implementation = implementation_;
+    }
+
+    function tgeo() external {}
+
+    fallback() external payable {
+        require(msg.sig == IImplementation.forwarded.selector);
+        (bool success,) = address(implementation).delegatecall(msg.data);
+        if (!success) revert();
+    }
+}
+
+// Assert guards constrain their successful continuation in the same way.
+contract AssertSelectorGatedProxy {
+    IImplementation internal immutable implementation;
+
+    constructor(IImplementation implementation_) {
+        implementation = implementation_;
+    }
+
+    function tgeo() external {}
+
+    fallback() external payable {
+        assert(msg.sig != IImplementation.gsf.selector);
+        (bool success,) = address(implementation).delegatecall(msg.data);
+        if (!success) revert();
+    }
+}
+
+// A reachable collision remains reported through a require guard.
+contract RequireCollisionSelectorGatedProxy { //~WARN: proxy function `RequireCollisionSelectorGatedProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+    IImplementation internal immutable implementation;
+
+    constructor(IImplementation implementation_) {
+        implementation = implementation_;
+    }
+
+    function tgeo() external {}
+
+    fallback() external payable {
+        require(IImplementation.gsf.selector == msg.sig, "unsupported selector");
+        (bool success,) = address(implementation).delegatecall(msg.data);
+        if (!success) revert();
+    }
+}
+
+// A selector guard in an applied modifier limits the fallback's implementation API.
+contract ModifierSelectorGatedProxy {
+    IImplementation internal immutable implementation;
+
+    constructor(IImplementation implementation_) {
+        implementation = implementation_;
+    }
+
+    modifier onlyForwarded() {
+        if (msg.sig == IImplementation.forwarded.selector) _;
+    }
+
+    function tgeo() external {}
+
+    fallback() external payable onlyForwarded {
+        (bool success,) = address(implementation).delegatecall(msg.data);
+        if (!success) revert();
+    }
+}
+
+// A typed delegatecall in an applied modifier still designates the implementation API.
+contract ModifierDelegatecallProxy { //~WARN: proxy function `ModifierDelegatecallProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+    IImplementation internal immutable implementation;
+
+    constructor(IImplementation implementation_) {
+        implementation = implementation_;
+    }
+
+    modifier forwardsFallback() {
+        (bool success,) = address(implementation).delegatecall(msg.data);
+        if (!success) revert();
+        _;
+    }
+
+    function tgeo() external {}
+
+    fallback() external payable forwardsFallback {}
+}
+
+// A return from the fallback resumes after the placeholder in the outer modifier.
+contract ModifierPostReturnDelegatecallProxy { //~WARN: proxy function `ModifierPostReturnDelegatecallProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+    IImplementation internal immutable implementation;
+
+    constructor(IImplementation implementation_) {
+        implementation = implementation_;
+    }
+
+    modifier forwardsAfterFallback() {
+        _;
+        (bool success,) = address(implementation).delegatecall(msg.data);
+        if (!success) revert();
+    }
+
+    function tgeo() external {}
+
+    fallback() external payable forwardsAfterFallback {
+        return;
+    }
+}
+
+// Full-calldata provenance follows an argument into its modifier parameter.
+contract ModifierParameterizedDelegatecallProxy { //~WARN: proxy function `ModifierParameterizedDelegatecallProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+    IImplementation internal immutable implementation;
+
+    constructor(IImplementation implementation_) {
+        implementation = implementation_;
+    }
+
+    modifier forwardsInput(bytes calldata data) {
+        (bool success,) = address(implementation).delegatecall(data);
+        if (!success) revert();
+        _;
+    }
+
+    function tgeo() external {}
+
+    fallback(bytes calldata input) external forwardsInput(input) returns (bytes memory) {
+        return "";
+    }
+}
+
+// Assembly in a modifier cannot implicitly reassign the fallback's calldata parameter.
+contract ModifierAssemblyScopeProxy { //~WARN: proxy function `ModifierAssemblyScopeProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+    IImplementation internal immutable implementation;
+
+    constructor(IImplementation implementation_) {
+        implementation = implementation_;
+    }
+
+    modifier harmlessAssembly() {
+        assembly {}
+        _;
+    }
+
+    function tgeo() external {}
+
+    fallback(bytes calldata input) external harmlessAssembly returns (bytes memory) {
+        (bool success, bytes memory returndata) = address(implementation).delegatecall(input);
+        if (!success) revert();
+        return returndata;
+    }
+}
+
+// Repeated placeholders are expanded once per distinct incoming path state.
+contract RepeatedModifierPlaceholderProxy { //~WARN: proxy function `RepeatedModifierPlaceholderProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+    IImplementation internal immutable implementation;
+
+    constructor(IImplementation implementation_) {
+        implementation = implementation_;
+    }
+
+    modifier twice() {
+        _;
+        _;
+    }
+
+    function tgeo() external {}
+
+    fallback() external payable twice twice twice twice twice twice twice twice {
+        (bool success,) = address(implementation).delegatecall(msg.data);
+        if (!success) revert();
+    }
+}
+
+library DelegatecallExtension {
+    function delegatecall(address, bytes calldata) internal pure returns (bool, bytes memory) {
+        return (true, "");
+    }
+}
+
+// A user-defined address member with the same name is not an EVM delegatecall.
+contract UserDefinedDelegatecall {
+    using DelegatecallExtension for address;
+
+    IImplementation internal immutable implementation;
+
+    constructor(IImplementation implementation_) {
+        implementation = implementation_;
+    }
+
+    function tgeo() external {}
+
+    fallback() external {
+        address(implementation).delegatecall(msg.data);
+    }
+}
diff --git a/crates/lint/testdata/FunctionSelectorCollision.stderr b/crates/lint/testdata/FunctionSelectorCollision.stderr
new file mode 100644
index 0000000000000..40adb31f11f28
--- /dev/null
+++ b/crates/lint/testdata/FunctionSelectorCollision.stderr
@@ -0,0 +1,192 @@
+warning[function-selector-collision]: proxy function `TypedProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+   ╭▸ ROOT/testdata/FunctionSelectorCollision.sol:LL:CC
+   │
+LL │ contract TypedProxy {
+   │          ━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-selector-collision
+
+warning[function-selector-collision]: proxy function `ConcreteTypedProxy.burn(uint256)` collides with implementation function `ConcreteImplementation.collate_propagate_storage(bytes16)` at selector `0x42966c68`
+   ╭▸ ROOT/testdata/FunctionSelectorCollision.sol:LL:CC
+   │
+LL │ contract ConcreteTypedProxy {
+   │          ━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-selector-collision
+
+warning[function-selector-collision]: proxy function `ParameterizedFallbackProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+   ╭▸ ROOT/testdata/FunctionSelectorCollision.sol:LL:CC
+   │
+LL │ contract ParameterizedFallbackProxy {
+   │          ━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-selector-collision
+
+warning[function-selector-collision]: proxy function `ReassignedAfterForwardingProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+   ╭▸ ROOT/testdata/FunctionSelectorCollision.sol:LL:CC
+   │
+LL │ contract ReassignedAfterForwardingProxy {
+   │          ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-selector-collision
+
+warning[function-selector-collision]: proxy function `SelectorCorrelatedMutationProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+   ╭▸ ROOT/testdata/FunctionSelectorCollision.sol:LL:CC
+   │
+LL │ contract SelectorCorrelatedMutationProxy {
+   │          ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-selector-collision
+
+warning[function-selector-collision]: proxy function `TryCatchExitProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+   ╭▸ ROOT/testdata/FunctionSelectorCollision.sol:LL:CC
+   │
+LL │ contract TryCatchExitProxy {
+   │          ━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-selector-collision
+
+warning[function-selector-collision]: proxy function `TryCatchMutationProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+   ╭▸ ROOT/testdata/FunctionSelectorCollision.sol:LL:CC
+   │
+LL │ contract TryCatchMutationProxy {
+   │          ━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-selector-collision
+
+warning[function-selector-collision]: proxy function `LoopZeroIterationProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+   ╭▸ ROOT/testdata/FunctionSelectorCollision.sol:LL:CC
+   │
+LL │ contract LoopZeroIterationProxy {
+   │          ━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-selector-collision
+
+warning[function-selector-collision]: proxy function `LoopBreakProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+   ╭▸ ROOT/testdata/FunctionSelectorCollision.sol:LL:CC
+   │
+LL │ contract LoopBreakProxy {
+   │          ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-selector-collision
+
+warning[function-selector-collision]: proxy function `LoopContinueProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+   ╭▸ ROOT/testdata/FunctionSelectorCollision.sol:LL:CC
+   │
+LL │ contract LoopContinueProxy {
+   │          ━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-selector-collision
+
+warning[function-selector-collision]: proxy function `DoWhileContinueProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+   ╭▸ ROOT/testdata/FunctionSelectorCollision.sol:LL:CC
+   │
+LL │ contract DoWhileContinueProxy {
+   │          ━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-selector-collision
+
+warning[function-selector-collision]: proxy function `ForContinueProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+   ╭▸ ROOT/testdata/FunctionSelectorCollision.sol:LL:CC
+   │
+LL │ contract ForContinueProxy {
+   │          ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-selector-collision
+
+warning[function-selector-collision]: proxy function `ForZeroIterationProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+   ╭▸ ROOT/testdata/FunctionSelectorCollision.sol:LL:CC
+   │
+LL │ contract ForZeroIterationProxy {
+   │          ━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-selector-collision
+
+warning[function-selector-collision]: proxy function `ForBreakProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+   ╭▸ ROOT/testdata/FunctionSelectorCollision.sol:LL:CC
+   │
+LL │ contract ForBreakProxy {
+   │          ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-selector-collision
+
+warning[function-selector-collision]: proxy function `DoWhileBreakProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+   ╭▸ ROOT/testdata/FunctionSelectorCollision.sol:LL:CC
+   │
+LL │ contract DoWhileBreakProxy {
+   │          ━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-selector-collision
+
+warning[function-selector-collision]: proxy function `ShortCircuitMutationProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+   ╭▸ ROOT/testdata/FunctionSelectorCollision.sol:LL:CC
+   │
+LL │ contract ShortCircuitMutationProxy {
+   │          ━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-selector-collision
+
+warning[function-selector-collision]: proxy function `TernaryMutationProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+   ╭▸ ROOT/testdata/FunctionSelectorCollision.sol:LL:CC
+   │
+LL │ contract TernaryMutationProxy {
+   │          ━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-selector-collision
+
+warning[function-selector-collision]: proxy function `CollisionSelectorGatedProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+   ╭▸ ROOT/testdata/FunctionSelectorCollision.sol:LL:CC
+   │
+LL │ contract CollisionSelectorGatedProxy {
+   │          ━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-selector-collision
+
+warning[function-selector-collision]: proxy function `RequireCollisionSelectorGatedProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+   ╭▸ ROOT/testdata/FunctionSelectorCollision.sol:LL:CC
+   │
+LL │ contract RequireCollisionSelectorGatedProxy {
+   │          ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-selector-collision
+
+warning[function-selector-collision]: proxy function `ModifierDelegatecallProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+   ╭▸ ROOT/testdata/FunctionSelectorCollision.sol:LL:CC
+   │
+LL │ contract ModifierDelegatecallProxy {
+   │          ━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-selector-collision
+
+warning[function-selector-collision]: proxy function `ModifierPostReturnDelegatecallProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+   ╭▸ ROOT/testdata/FunctionSelectorCollision.sol:LL:CC
+   │
+LL │ contract ModifierPostReturnDelegatecallProxy {
+   │          ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-selector-collision
+
+warning[function-selector-collision]: proxy function `ModifierParameterizedDelegatecallProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+   ╭▸ ROOT/testdata/FunctionSelectorCollision.sol:LL:CC
+   │
+LL │ contract ModifierParameterizedDelegatecallProxy {
+   │          ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-selector-collision
+
+warning[function-selector-collision]: proxy function `ModifierAssemblyScopeProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+   ╭▸ ROOT/testdata/FunctionSelectorCollision.sol:LL:CC
+   │
+LL │ contract ModifierAssemblyScopeProxy {
+   │          ━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-selector-collision
+
+warning[function-selector-collision]: proxy function `RepeatedModifierPlaceholderProxy.tgeo()` collides with implementation function `IImplementation.gsf()` at selector `0x67e43e43`
+   ╭▸ ROOT/testdata/FunctionSelectorCollision.sol:LL:CC
+   │
+LL │ contract RepeatedModifierPlaceholderProxy {
+   │          ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/function-selector-collision
+
diff --git a/crates/lint/testdata/Imports.sol b/crates/lint/testdata/Imports.sol
index c017b3ba8bfdc..6ee1c944384b2 100644
--- a/crates/lint/testdata/Imports.sol
+++ b/crates/lint/testdata/Imports.sol
@@ -1,18 +1,23 @@
+//@compile-flags: --severity info
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
 import {
     symbol0 as mySymbol,
     symbol1 as myOtherSymbol,
-    symbol2 as notUsed, //~NOTE: unused imports should be removed
+    symbol2 as notUsed, //~NOTE: unused import
     symbol3,
     symbol4,
     symbol5,
     docSymbol,
     docSymbol2,
-    docSymbolWrongTag, //~NOTE: unused imports should be removed
+    docSymbolWrongTag, //~NOTE: unused import
     eventSymbol,
     BaseContract,
-    symbolNotUsed, //~NOTE: unused imports should be removed
+    symbolNotUsed, //~NOTE: unused import
     IContract,
-    IContractNotUsed //~NOTE: unused imports should be removed
+    IContractNotUsed //~NOTE: unused import
 } from "./auxiliary/ImportsFile.sol";
 
 // forge-lint: disable-next-item
@@ -23,57 +28,53 @@ import {
 // in this case, disabling the following line doesn't do anything
 // forge-lint: disable-next-line
 import {
-    symbolNotUsed3 //~NOTE: unused imports should be removed
+    symbolNotUsed3 //~NOTE: unused import
 } from "./auxiliary/ImportsFile.sol";
 
 import {
     CONSTANT_0,
-    CONSTANT_1 //~NOTE: unused imports should be removed
+    CONSTANT_1 //~NOTE: unused import
 } from "./auxiliary/ImportsConstants.sol";
 
 import {
     MyType,
     MyOtherType,
-    YetAnotherType //~NOTE: unused imports should be removed
+    YetAnotherType //~NOTE: unused import
 } from "./auxiliary/ImportsTypes.sol";
 
-import "./auxiliary/ImportsSomeFile.sol"; //~NOTE: use named imports '{A, B}' or alias 'import ".." as X'
-import "./auxiliary/ImportsAnotherFile.sol"; //~NOTE: use named imports '{A, B}' or alias 'import ".." as X'
+import "./auxiliary/ImportsSomeFile.sol"; //~NOTE: plain import has no alias; use named imports `{A, B}` or alias `import ".." as X`
+import "./auxiliary/ImportsAnotherFile.sol"; //~NOTE: plain import has no alias; use named imports `{A, B}` or alias `import ".." as X`
 
 import "./auxiliary/ImportsSomeFile2.sol" as SomeFile2;
-import "./auxiliary/ImportsAnotherFile2.sol" as AnotherFile2; //~NOTE: unused imports should be removed
+import "./auxiliary/ImportsAnotherFile2.sol" as AnotherFile2; //~NOTE: unused import
 
 import * as Utils from "./auxiliary/ImportsUtils.sol";
-import * as OtherUtils from "./auxiliary/ImportsUtils2.sol"; //~NOTE: unused imports should be removed
+import * as OtherUtils from "./auxiliary/ImportsUtils2.sol"; //~NOTE: unused import
 
 
-contract UnusedImport is IContract, BaseContract {
+abstract contract UnusedImport is IContract, BaseContract {
     using mySymbol for address;
 
-    /// @inheritdoc docSymbol
     uint256 constant MY_CONSTANT = CONSTANT_0;
 
-    /**
-     * @inheritdoc docSymbol2
-     */
     struct FooBar {
         symbol3 foo;
         myOtherSymbol bar;
     }
 
-    /// @wrong docSymbolWrongTag
+    /// @dev docSymbolWrongTag
     SomeFile.Baz public myStruct;
     SomeFile2.Baz public myStruct2;
     symbol4 public myVar;
 
-    function foo(uint256 a, symbol5 b) public view override(BaseContract) returns (uint256) {
-        uint256 c = Utils.calculate(a, b);
+    function foo(uint256 a, symbol5 b) external override(BaseContract) returns (uint256) {
+        uint256 c = Utils.calculate(a, symbol5.unwrap(b));
         emit eventSymbol.foo(c);
         return c;
     }
 
     function convert(address addr) public pure returns (MyOtherType) {
         MyType a = MyType.wrap(123);
-        return MyOtherType.wrap(a);
+        return MyOtherType.wrap(MyType.unwrap(a));
     }
 }
diff --git a/crates/lint/testdata/Imports.stderr b/crates/lint/testdata/Imports.stderr
index d9f4d89e87b56..d2f570093ec8c 100644
--- a/crates/lint/testdata/Imports.stderr
+++ b/crates/lint/testdata/Imports.stderr
@@ -1,88 +1,104 @@
-note[unaliased-plain-import]: use named imports '{A, B}' or alias 'import ".." as X'
-  --> ROOT/testdata/Imports.sol:LL:CC
-   |
-40 | import "./auxiliary/ImportsSomeFile.sol";
-   |        ---------------------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#unaliased-plain-import
+note[unaliased-plain-import]: plain import has no alias; use named imports `{A, B}` or alias `import ".." as X`
+   ╭▸ ROOT/testdata/Imports.sol:LL:CC
+   │
+LL │ import "./auxiliary/ImportsSomeFile.sol";
+   │        ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unaliased-plain-import
 
-note[unaliased-plain-import]: use named imports '{A, B}' or alias 'import ".." as X'
-  --> ROOT/testdata/Imports.sol:LL:CC
-   |
-41 | import "./auxiliary/ImportsAnotherFile.sol";
-   |        ------------------------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#unaliased-plain-import
+note[unaliased-plain-import]: plain import has no alias; use named imports `{A, B}` or alias `import ".." as X`
+   ╭▸ ROOT/testdata/Imports.sol:LL:CC
+   │
+LL │ import "./auxiliary/ImportsAnotherFile.sol";
+   │        ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unaliased-plain-import
 
-note[unused-import]: unused imports should be removed
- --> ROOT/testdata/Imports.sol:LL:CC
-  |
-4 |     symbol2 as notUsed,
-  |     ------------------
-  |
-  = help: https://book.getfoundry.sh/reference/forge/forge-lint#unused-import
+note[unused-import]: unused import
+   ╭▸ ROOT/testdata/Imports.sol:LL:CC
+   │
+LL │     symbol2 as notUsed,
+   │     ━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unused-import
 
-note[unused-import]: unused imports should be removed
-  --> ROOT/testdata/Imports.sol:LL:CC
-   |
-10 |     docSymbolWrongTag,
-   |     -----------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#unused-import
+note[unused-import]: unused import
+   ╭▸ ROOT/testdata/Imports.sol:LL:CC
+   │
+LL │     docSymbol,
+   │     ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unused-import
 
-note[unused-import]: unused imports should be removed
-  --> ROOT/testdata/Imports.sol:LL:CC
-   |
-13 |     symbolNotUsed,
-   |     -------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#unused-import
+note[unused-import]: unused import
+   ╭▸ ROOT/testdata/Imports.sol:LL:CC
+   │
+LL │     docSymbol2,
+   │     ━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unused-import
 
-note[unused-import]: unused imports should be removed
-  --> ROOT/testdata/Imports.sol:LL:CC
-   |
-15 |     IContractNotUsed
-   |     ----------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#unused-import
+note[unused-import]: unused import
+   ╭▸ ROOT/testdata/Imports.sol:LL:CC
+   │
+LL │     docSymbolWrongTag,
+   │     ━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unused-import
 
-note[unused-import]: unused imports should be removed
-  --> ROOT/testdata/Imports.sol:LL:CC
-   |
-26 |     symbolNotUsed3
-   |     --------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#unused-import
+note[unused-import]: unused import
+   ╭▸ ROOT/testdata/Imports.sol:LL:CC
+   │
+LL │     symbolNotUsed,
+   │     ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unused-import
 
-note[unused-import]: unused imports should be removed
-  --> ROOT/testdata/Imports.sol:LL:CC
-   |
-31 |     CONSTANT_1
-   |     ----------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#unused-import
+note[unused-import]: unused import
+   ╭▸ ROOT/testdata/Imports.sol:LL:CC
+   │
+LL │     IContractNotUsed
+   │     ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unused-import
 
-note[unused-import]: unused imports should be removed
-  --> ROOT/testdata/Imports.sol:LL:CC
-   |
-37 |     YetAnotherType
-   |     --------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#unused-import
+note[unused-import]: unused import
+   ╭▸ ROOT/testdata/Imports.sol:LL:CC
+   │
+LL │     symbolNotUsed3
+   │     ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unused-import
 
-note[unused-import]: unused imports should be removed
-  --> ROOT/testdata/Imports.sol:LL:CC
-   |
-44 | import "./auxiliary/ImportsAnotherFile2.sol" as AnotherFile2;
-   | -------------------------------------------------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#unused-import
+note[unused-import]: unused import
+   ╭▸ ROOT/testdata/Imports.sol:LL:CC
+   │
+LL │     CONSTANT_1
+   │     ━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unused-import
 
-note[unused-import]: unused imports should be removed
-  --> ROOT/testdata/Imports.sol:LL:CC
-   |
-47 | import * as OtherUtils from "./auxiliary/ImportsUtils2.sol";
-   | ------------------------------------------------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#unused-import
+note[unused-import]: unused import
+   ╭▸ ROOT/testdata/Imports.sol:LL:CC
+   │
+LL │     YetAnotherType
+   │     ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unused-import
+
+note[unused-import]: unused import
+   ╭▸ ROOT/testdata/Imports.sol:LL:CC
+   │
+LL │ import "./auxiliary/ImportsAnotherFile2.sol" as AnotherFile2;
+   │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unused-import
+
+note[unused-import]: unused import
+   ╭▸ ROOT/testdata/Imports.sol:LL:CC
+   │
+LL │ import * as OtherUtils from "./auxiliary/ImportsUtils2.sol";
+   │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unused-import
 
diff --git a/crates/lint/testdata/InconsistentTypeNames.sol b/crates/lint/testdata/InconsistentTypeNames.sol
new file mode 100644
index 0000000000000..a90df3631c74b
--- /dev/null
+++ b/crates/lint/testdata/InconsistentTypeNames.sol
@@ -0,0 +1,61 @@
+//@compile-flags: --only-lint inconsistent-type-names
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.27;
+
+// Tests for `inconsistent-type-names`: shorthand `uint`/`int` declarations are reported only
+// when the same directly declared contract also contains the corresponding explicit 256-bit type.
+// The analysis follows resolved variable declarations, including nested array/mapping types, but
+// excludes expressions, directives, other contracts, and inherited declarations.
+
+contract InconsistentTypeNames {
+    uint internal shorthandUint; //~WARN: contract mixes shorthand and explicit integer type names; use `uint256` and `int256` consistently
+    uint256 internal explicitUint;
+
+    int internal shorthandInt; //~WARN: contract mixes shorthand and explicit integer type names; use `uint256` and `int256` consistently
+    int256 internal explicitInt;
+
+    struct Record {
+        uint[] values; //~WARN: contract mixes shorthand and explicit integer type names; use `uint256` and `int256` consistently
+        mapping(uint => uint256) indexes; //~WARN: contract mixes shorthand and explicit integer type names; use `uint256` and `int256` consistently
+    }
+
+    event Updated(uint oldValue, uint256 newValue); //~WARN: contract mixes shorthand and explicit integer type names; use `uint256` and `int256` consistently
+    error Difference(int delta, int256 expected); //~WARN: contract mixes shorthand and explicit integer type names; use `uint256` and `int256` consistently
+    function (uint) external returns (uint256) callback; //~WARN: contract mixes shorthand and explicit integer type names; use `uint256` and `int256` consistently
+
+    function update(
+        uint amount, //~WARN: contract mixes shorthand and explicit integer type names; use `uint256` and `int256` consistently
+        uint256 limit
+    ) external returns (int result, int256 expected) { //~WARN: contract mixes shorthand and explicit integer type names; use `uint256` and `int256` consistently
+        uint[] memory values = new uint[](amount); //~WARN: contract mixes shorthand and explicit integer type names; use `uint256` and `int256` consistently
+        shorthandUint = values.length + limit;
+        result = int(shorthandUint);
+        expected = explicitInt;
+    }
+}
+
+contract ConsistentlyImplicit {
+    uint internal value;
+    int internal delta;
+
+    function inspect() external view returns (uint, int) {
+        // Explicit names used by type expressions and casts are not declarations.
+        return (uint(type(uint256).max + value), int(int256(delta)));
+    }
+}
+
+contract ConsistentlyExplicit {
+    uint256 internal value;
+    int256 internal delta;
+}
+
+contract ExplicitBase {
+    uint256 internal value;
+    int256 internal delta;
+}
+
+// Resolved ownership keeps declarations in a base contract out of the child's consistency scope.
+contract ImplicitChild is ExplicitBase {
+    uint internal otherValue;
+    int internal otherDelta;
+}
diff --git a/crates/lint/testdata/InconsistentTypeNames.stderr b/crates/lint/testdata/InconsistentTypeNames.stderr
new file mode 100644
index 0000000000000..6256b8f244ada
--- /dev/null
+++ b/crates/lint/testdata/InconsistentTypeNames.stderr
@@ -0,0 +1,80 @@
+warning[inconsistent-type-names]: contract mixes shorthand and explicit integer type names; use `uint256` and `int256` consistently
+   ╭▸ ROOT/testdata/InconsistentTypeNames.sol:LL:CC
+   │
+LL │     uint internal shorthandUint;
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/inconsistent-type-names
+
+warning[inconsistent-type-names]: contract mixes shorthand and explicit integer type names; use `uint256` and `int256` consistently
+   ╭▸ ROOT/testdata/InconsistentTypeNames.sol:LL:CC
+   │
+LL │     int internal shorthandInt;
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/inconsistent-type-names
+
+warning[inconsistent-type-names]: contract mixes shorthand and explicit integer type names; use `uint256` and `int256` consistently
+   ╭▸ ROOT/testdata/InconsistentTypeNames.sol:LL:CC
+   │
+LL │         uint[] values;
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/inconsistent-type-names
+
+warning[inconsistent-type-names]: contract mixes shorthand and explicit integer type names; use `uint256` and `int256` consistently
+   ╭▸ ROOT/testdata/InconsistentTypeNames.sol:LL:CC
+   │
+LL │ …     mapping(uint => uint256) indexes;
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/inconsistent-type-names
+
+warning[inconsistent-type-names]: contract mixes shorthand and explicit integer type names; use `uint256` and `int256` consistently
+   ╭▸ ROOT/testdata/InconsistentTypeNames.sol:LL:CC
+   │
+LL │     error Difference(int delta, int256 expected);
+   │                      ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/inconsistent-type-names
+
+warning[inconsistent-type-names]: contract mixes shorthand and explicit integer type names; use `uint256` and `int256` consistently
+   ╭▸ ROOT/testdata/InconsistentTypeNames.sol:LL:CC
+   │
+LL │     event Updated(uint oldValue, uint256 newValue);
+   │                   ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/inconsistent-type-names
+
+warning[inconsistent-type-names]: contract mixes shorthand and explicit integer type names; use `uint256` and `int256` consistently
+   ╭▸ ROOT/testdata/InconsistentTypeNames.sol:LL:CC
+   │
+LL │     function (uint) external returns (uint256) callback;
+   │               ━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/inconsistent-type-names
+
+warning[inconsistent-type-names]: contract mixes shorthand and explicit integer type names; use `uint256` and `int256` consistently
+   ╭▸ ROOT/testdata/InconsistentTypeNames.sol:LL:CC
+   │
+LL │         uint amount,
+   │         ━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/inconsistent-type-names
+
+warning[inconsistent-type-names]: contract mixes shorthand and explicit integer type names; use `uint256` and `int256` consistently
+   ╭▸ ROOT/testdata/InconsistentTypeNames.sol:LL:CC
+   │
+LL │     ) external returns (int result, int256 expected) {
+   │                         ━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/inconsistent-type-names
+
+warning[inconsistent-type-names]: contract mixes shorthand and explicit integer type names; use `uint256` and `int256` consistently
+   ╭▸ ROOT/testdata/InconsistentTypeNames.sol:LL:CC
+   │
+LL │ …     uint[] memory values = new uint[](amount);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/inconsistent-type-names
+
diff --git a/crates/lint/testdata/IncorrectERC20Interface.sol b/crates/lint/testdata/IncorrectERC20Interface.sol
new file mode 100644
index 0000000000000..bfc571ec0a474
--- /dev/null
+++ b/crates/lint/testdata/IncorrectERC20Interface.sol
@@ -0,0 +1,44 @@
+//@compile-flags: --severity high med low info
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+interface IERC20 {}
+
+// SHOULD FAIL: Interface named ERC20 with incorrect function signatures
+interface ERC20 {
+    function transfer(address to, uint256 value) external returns (uint256); //~WARN: incorrect ERC20 function interface
+    function approve(address spender, uint256 value) external returns (uint256); //~WARN: incorrect ERC20 function interface
+}
+
+// SHOULD FAIL: Interface inheriting from IERC20 with incorrect function signatures
+interface IERC20Incorrect is IERC20 {
+    function transfer(address to, uint256 value) external returns (uint256); //~WARN: incorrect ERC20 function interface
+    function transferFrom(address from, address to, uint256 value) external returns (uint256); //~WARN: incorrect ERC20 function interface
+    function approve(address spender, uint256 value) external returns (uint256); //~WARN: incorrect ERC20 function interface
+    function allowance(address owner, address spender) external view returns (bool); //~WARN: incorrect ERC20 function interface
+    function balanceOf(address account) external view returns (bool); //~WARN: incorrect ERC20 function interface
+    function totalSupply() external view returns (bool); //~WARN: incorrect ERC20 function interface
+}
+
+// SHOULD PASS: Correct ERC20 interface inheriting from IERC20
+interface IERC20Correct is IERC20 {
+    function transfer(address to, uint256 value) external returns (bool);
+    function transferFrom(address from, address to, uint256 value) external returns (bool);
+    function approve(address spender, uint256 value) external returns (bool);
+    function allowance(address owner, address spender) external view returns (uint256);
+    function balanceOf(address account) external view returns (uint256);
+    function totalSupply() external view returns (uint256);
+}
+
+// SHOULD PASS: Interface named IERC20 with correct function signatures
+interface IERC20NamedCorrect {
+    function transfer(address to, uint256 value) external returns (bool);
+    function balanceOf(address account) external view returns (uint256);
+}
+
+// SHOULD PASS: Contract that is NOT named ERC20 and does not inherit from one
+interface INotERC20 {
+    function transfer(address to, uint256 value) external returns (uint256);
+    function balanceOf(address account) external view returns (bool);
+}
diff --git a/crates/lint/testdata/IncorrectERC20Interface.stderr b/crates/lint/testdata/IncorrectERC20Interface.stderr
new file mode 100644
index 0000000000000..1ed48755fe8b2
--- /dev/null
+++ b/crates/lint/testdata/IncorrectERC20Interface.stderr
@@ -0,0 +1,120 @@
+note[interface-naming]: interface name is missing the `I` prefix
+   ╭▸ ROOT/testdata/IncorrectERC20Interface.sol:LL:CC
+   │
+LL │ interface ERC20 {
+   │           ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/interface-naming
+
+note[multi-contract-file]: file contains multiple contracts, interfaces or libraries
+   ╭▸ ROOT/testdata/IncorrectERC20Interface.sol:LL:CC
+   │
+LL │ interface IERC20 {}
+   │           ━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/multi-contract-file
+
+note[multi-contract-file]: file contains multiple contracts, interfaces or libraries
+   ╭▸ ROOT/testdata/IncorrectERC20Interface.sol:LL:CC
+   │
+LL │ interface ERC20 {
+   │           ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/multi-contract-file
+
+note[multi-contract-file]: file contains multiple contracts, interfaces or libraries
+   ╭▸ ROOT/testdata/IncorrectERC20Interface.sol:LL:CC
+   │
+LL │ interface IERC20Incorrect is IERC20 {
+   │           ━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/multi-contract-file
+
+note[multi-contract-file]: file contains multiple contracts, interfaces or libraries
+   ╭▸ ROOT/testdata/IncorrectERC20Interface.sol:LL:CC
+   │
+LL │ interface IERC20Correct is IERC20 {
+   │           ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/multi-contract-file
+
+note[multi-contract-file]: file contains multiple contracts, interfaces or libraries
+   ╭▸ ROOT/testdata/IncorrectERC20Interface.sol:LL:CC
+   │
+LL │ interface IERC20NamedCorrect {
+   │           ━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/multi-contract-file
+
+note[multi-contract-file]: file contains multiple contracts, interfaces or libraries
+   ╭▸ ROOT/testdata/IncorrectERC20Interface.sol:LL:CC
+   │
+LL │ interface INotERC20 {
+   │           ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/multi-contract-file
+
+warning[incorrect-erc20-interface]: incorrect ERC20 function interface
+   ╭▸ ROOT/testdata/IncorrectERC20Interface.sol:LL:CC
+   │
+LL │     function transfer(address to, uint256 value) external returns (uint256);
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-erc20-interface
+
+warning[incorrect-erc20-interface]: incorrect ERC20 function interface
+   ╭▸ ROOT/testdata/IncorrectERC20Interface.sol:LL:CC
+   │
+LL │     function approve(address spender, uint256 value) external returns (uint256);
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-erc20-interface
+
+warning[incorrect-erc20-interface]: incorrect ERC20 function interface
+   ╭▸ ROOT/testdata/IncorrectERC20Interface.sol:LL:CC
+   │
+LL │     function transfer(address to, uint256 value) external returns (uint256);
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-erc20-interface
+
+warning[incorrect-erc20-interface]: incorrect ERC20 function interface
+   ╭▸ ROOT/testdata/IncorrectERC20Interface.sol:LL:CC
+   │
+LL │     function transferFrom(address from, address to, uint256 value) external returns (uint256);
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-erc20-interface
+
+warning[incorrect-erc20-interface]: incorrect ERC20 function interface
+   ╭▸ ROOT/testdata/IncorrectERC20Interface.sol:LL:CC
+   │
+LL │     function approve(address spender, uint256 value) external returns (uint256);
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-erc20-interface
+
+warning[incorrect-erc20-interface]: incorrect ERC20 function interface
+   ╭▸ ROOT/testdata/IncorrectERC20Interface.sol:LL:CC
+   │
+LL │     function allowance(address owner, address spender) external view returns (bool);
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-erc20-interface
+
+warning[incorrect-erc20-interface]: incorrect ERC20 function interface
+   ╭▸ ROOT/testdata/IncorrectERC20Interface.sol:LL:CC
+   │
+LL │     function balanceOf(address account) external view returns (bool);
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-erc20-interface
+
+warning[incorrect-erc20-interface]: incorrect ERC20 function interface
+   ╭▸ ROOT/testdata/IncorrectERC20Interface.sol:LL:CC
+   │
+LL │     function totalSupply() external view returns (bool);
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-erc20-interface
+
diff --git a/crates/lint/testdata/IncorrectERC721Interface.sol b/crates/lint/testdata/IncorrectERC721Interface.sol
new file mode 100644
index 0000000000000..431b1d5efdc96
--- /dev/null
+++ b/crates/lint/testdata/IncorrectERC721Interface.sol
@@ -0,0 +1,52 @@
+//@compile-flags: --severity high med low info
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+interface IERC721 {}
+
+// SHOULD FAIL: Interface named ERC721 with incorrect function signatures
+interface ERC721 {
+    function balanceOf(address owner) external view returns (bool); //~WARN: incorrect ERC721 function interface
+    function ownerOf(uint256 tokenId) external view returns (bool); //~WARN: incorrect ERC721 function interface
+}
+
+// SHOULD FAIL: Interface inheriting from IERC721 with incorrect function signatures
+interface IERC721Incorrect is IERC721 {
+    function balanceOf(address owner) external view returns (bool); //~WARN: incorrect ERC721 function interface
+    function ownerOf(uint256 tokenId) external view returns (bool); //~WARN: incorrect ERC721 function interface
+    function safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data) external returns (bool); //~WARN: incorrect ERC721 function interface
+    function safeTransferFrom(address from, address to, uint256 tokenId) external returns (bool); //~WARN: incorrect ERC721 function interface
+    function transferFrom(address from, address to, uint256 tokenId) external returns (bool); //~WARN: incorrect ERC721 function interface
+    function approve(address to, uint256 tokenId) external returns (bool); //~WARN: incorrect ERC721 function interface
+    function setApprovalForAll(address operator, bool approved) external returns (bool); //~WARN: incorrect ERC721 function interface
+    function getApproved(uint256 tokenId) external view returns (bool); //~WARN: incorrect ERC721 function interface
+    function isApprovedForAll(address owner, address operator) external view returns (address); //~WARN: incorrect ERC721 function interface
+    function supportsInterface(bytes4 interfaceId) external view returns (uint256); //~WARN: incorrect ERC721 function interface
+}
+
+// SHOULD PASS: Correct ERC721 interface inheriting from IERC721
+interface IERC721Correct is IERC721 {
+    function balanceOf(address owner) external view returns (uint256);
+    function ownerOf(uint256 tokenId) external view returns (address);
+    function safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data) external;
+    function safeTransferFrom(address from, address to, uint256 tokenId) external;
+    function transferFrom(address from, address to, uint256 tokenId) external;
+    function approve(address to, uint256 tokenId) external;
+    function setApprovalForAll(address operator, bool approved) external;
+    function getApproved(uint256 tokenId) external view returns (address);
+    function isApprovedForAll(address owner, address operator) external view returns (bool);
+    function supportsInterface(bytes4 interfaceId) external view returns (bool);
+}
+
+// SHOULD PASS: Interface named IERC721 with correct function signatures
+interface IERC721NamedCorrect {
+    function balanceOf(address owner) external view returns (uint256);
+    function ownerOf(uint256 tokenId) external view returns (address);
+}
+
+// SHOULD PASS: Contract that is NOT named ERC721 and does not inherit from one
+interface INotERC721 {
+    function balanceOf(address owner) external view returns (bool);
+    function ownerOf(uint256 tokenId) external view returns (bool);
+}
diff --git a/crates/lint/testdata/IncorrectERC721Interface.stderr b/crates/lint/testdata/IncorrectERC721Interface.stderr
new file mode 100644
index 0000000000000..49ea340264c6a
--- /dev/null
+++ b/crates/lint/testdata/IncorrectERC721Interface.stderr
@@ -0,0 +1,152 @@
+note[interface-naming]: interface name is missing the `I` prefix
+   ╭▸ ROOT/testdata/IncorrectERC721Interface.sol:LL:CC
+   │
+LL │ interface ERC721 {
+   │           ━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/interface-naming
+
+note[multi-contract-file]: file contains multiple contracts, interfaces or libraries
+   ╭▸ ROOT/testdata/IncorrectERC721Interface.sol:LL:CC
+   │
+LL │ interface IERC721 {}
+   │           ━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/multi-contract-file
+
+note[multi-contract-file]: file contains multiple contracts, interfaces or libraries
+   ╭▸ ROOT/testdata/IncorrectERC721Interface.sol:LL:CC
+   │
+LL │ interface ERC721 {
+   │           ━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/multi-contract-file
+
+note[multi-contract-file]: file contains multiple contracts, interfaces or libraries
+   ╭▸ ROOT/testdata/IncorrectERC721Interface.sol:LL:CC
+   │
+LL │ interface IERC721Incorrect is IERC721 {
+   │           ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/multi-contract-file
+
+note[multi-contract-file]: file contains multiple contracts, interfaces or libraries
+   ╭▸ ROOT/testdata/IncorrectERC721Interface.sol:LL:CC
+   │
+LL │ interface IERC721Correct is IERC721 {
+   │           ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/multi-contract-file
+
+note[multi-contract-file]: file contains multiple contracts, interfaces or libraries
+   ╭▸ ROOT/testdata/IncorrectERC721Interface.sol:LL:CC
+   │
+LL │ interface IERC721NamedCorrect {
+   │           ━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/multi-contract-file
+
+note[multi-contract-file]: file contains multiple contracts, interfaces or libraries
+   ╭▸ ROOT/testdata/IncorrectERC721Interface.sol:LL:CC
+   │
+LL │ interface INotERC721 {
+   │           ━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/multi-contract-file
+
+warning[incorrect-erc721-interface]: incorrect ERC721 function interface
+   ╭▸ ROOT/testdata/IncorrectERC721Interface.sol:LL:CC
+   │
+LL │     function balanceOf(address owner) external view returns (bool);
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-erc721-interface
+
+warning[incorrect-erc721-interface]: incorrect ERC721 function interface
+   ╭▸ ROOT/testdata/IncorrectERC721Interface.sol:LL:CC
+   │
+LL │     function ownerOf(uint256 tokenId) external view returns (bool);
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-erc721-interface
+
+warning[incorrect-erc721-interface]: incorrect ERC721 function interface
+   ╭▸ ROOT/testdata/IncorrectERC721Interface.sol:LL:CC
+   │
+LL │     function balanceOf(address owner) external view returns (bool);
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-erc721-interface
+
+warning[incorrect-erc721-interface]: incorrect ERC721 function interface
+   ╭▸ ROOT/testdata/IncorrectERC721Interface.sol:LL:CC
+   │
+LL │     function ownerOf(uint256 tokenId) external view returns (bool);
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-erc721-interface
+
+warning[incorrect-erc721-interface]: incorrect ERC721 function interface
+   ╭▸ ROOT/testdata/IncorrectERC721Interface.sol:LL:CC
+   │
+LL │     function safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data) external returns (bool);
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-erc721-interface
+
+warning[incorrect-erc721-interface]: incorrect ERC721 function interface
+   ╭▸ ROOT/testdata/IncorrectERC721Interface.sol:LL:CC
+   │
+LL │     function safeTransferFrom(address from, address to, uint256 tokenId) external returns (bool);
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-erc721-interface
+
+warning[incorrect-erc721-interface]: incorrect ERC721 function interface
+   ╭▸ ROOT/testdata/IncorrectERC721Interface.sol:LL:CC
+   │
+LL │     function transferFrom(address from, address to, uint256 tokenId) external returns (bool);
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-erc721-interface
+
+warning[incorrect-erc721-interface]: incorrect ERC721 function interface
+   ╭▸ ROOT/testdata/IncorrectERC721Interface.sol:LL:CC
+   │
+LL │     function approve(address to, uint256 tokenId) external returns (bool);
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-erc721-interface
+
+warning[incorrect-erc721-interface]: incorrect ERC721 function interface
+   ╭▸ ROOT/testdata/IncorrectERC721Interface.sol:LL:CC
+   │
+LL │     function setApprovalForAll(address operator, bool approved) external returns (bool);
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-erc721-interface
+
+warning[incorrect-erc721-interface]: incorrect ERC721 function interface
+   ╭▸ ROOT/testdata/IncorrectERC721Interface.sol:LL:CC
+   │
+LL │     function getApproved(uint256 tokenId) external view returns (bool);
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-erc721-interface
+
+warning[incorrect-erc721-interface]: incorrect ERC721 function interface
+   ╭▸ ROOT/testdata/IncorrectERC721Interface.sol:LL:CC
+   │
+LL │     function isApprovedForAll(address owner, address operator) external view returns (address);
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-erc721-interface
+
+warning[incorrect-erc721-interface]: incorrect ERC721 function interface
+   ╭▸ ROOT/testdata/IncorrectERC721Interface.sol:LL:CC
+   │
+LL │     function supportsInterface(bytes4 interfaceId) external view returns (uint256);
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-erc721-interface
+
diff --git a/crates/lint/testdata/IncorrectExp.sol b/crates/lint/testdata/IncorrectExp.sol
new file mode 100644
index 0000000000000..c8767137185b8
--- /dev/null
+++ b/crates/lint/testdata/IncorrectExp.sol
@@ -0,0 +1,58 @@
+//@compile-flags: --only-lint incorrect-exp
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+// Tests for the `incorrect-exp` lint: `^` (bitwise xor) used where `**` (exponentiation) was meant.
+// The base is restricted to `2` and `10` (the bases people write as powers), mirroring GCC and
+// Clang's `-Wxor-used-as-pow`, so decimal bitmask xors like `255 ^ 128` are not flagged.
+
+contract IncorrectExp {
+    function id(uint256 v) internal pure returns (uint256) {
+        return v;
+    }
+
+    function bad()
+        external
+        pure
+        returns (uint256 a, uint256 b, uint256 c, uint256 d, uint256 e, uint256 f)
+    {
+        a = 10 ^ 18; //~WARN: `^` is bitwise xor, not exponentiation; use `**`
+        b = 2 ^ 64; //~WARN: `^` is bitwise xor, not exponentiation; use `**`
+        c = 2 ^ 256; //~WARN: `^` is bitwise xor, not exponentiation; use `**`
+        d = uint256(10) ^ 18; //~WARN: `^` is bitwise xor, not exponentiation; use `**`
+        e = 2 ^ uint256(64); //~WARN: `^` is bitwise xor, not exponentiation; use `**`
+        f = uint256(uint8(2)) ^ 64; //~WARN: `^` is bitwise xor, not exponentiation; use `**`
+    }
+
+    function ok(uint256 x)
+        external
+        pure
+        returns (
+            uint256 a,
+            uint256 b,
+            uint256 c,
+            uint256 d,
+            uint256 e,
+            uint256 f,
+            uint256 g,
+            uint256 h,
+            uint256 i,
+            bytes32 j,
+            uint256 k,
+            uint256 l
+        )
+    {
+        a = 255 ^ 128; // decimal bitmask (0xFF ^ 0x80), base is not 2 or 10
+        b = 170 ^ 85; // decimal bitmask (0xAA ^ 0x55), base is not 2 or 10
+        c = 3 ^ 4; // base 3: not a base written as a power
+        d = 0xff ^ 0x0f; // hex xor: legitimate bit manipulation
+        e = x ^ 1; // a variable operand, not two literals
+        f = 2 ** 64; // actual exponentiation
+        g = (id(10) ^ 18) + id(0); // a non-cast call, not a literal: must not be unwrapped
+        h = 1e1 ^ 18; // scientific notation base (1e1 evaluates to 10): not a plain integer literal
+        i = 10 ^ 1e1; // scientific notation exponent: not a plain integer literal
+        j = bytes32(uint256(2)) ^ bytes32(uint256(64)); // bytesN xor: not an integer cast
+        k = 2 wei ^ 64; // denominated literal (2 wei == 2): not a plain integer literal
+        l = 10 seconds ^ 18; // denominated literal (10 seconds == 10): not a plain integer literal
+    }
+}
diff --git a/crates/lint/testdata/IncorrectExp.stderr b/crates/lint/testdata/IncorrectExp.stderr
new file mode 100644
index 0000000000000..c39e7c354f80f
--- /dev/null
+++ b/crates/lint/testdata/IncorrectExp.stderr
@@ -0,0 +1,48 @@
+warning[incorrect-exp]: `^` is bitwise xor, not exponentiation; use `**`
+   ╭▸ ROOT/testdata/IncorrectExp.sol:LL:CC
+   │
+LL │         a = 10 ^ 18;
+   │             ━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-exp
+
+warning[incorrect-exp]: `^` is bitwise xor, not exponentiation; use `**`
+   ╭▸ ROOT/testdata/IncorrectExp.sol:LL:CC
+   │
+LL │         b = 2 ^ 64;
+   │             ━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-exp
+
+warning[incorrect-exp]: `^` is bitwise xor, not exponentiation; use `**`
+   ╭▸ ROOT/testdata/IncorrectExp.sol:LL:CC
+   │
+LL │         c = 2 ^ 256;
+   │             ━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-exp
+
+warning[incorrect-exp]: `^` is bitwise xor, not exponentiation; use `**`
+   ╭▸ ROOT/testdata/IncorrectExp.sol:LL:CC
+   │
+LL │         d = uint256(10) ^ 18;
+   │             ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-exp
+
+warning[incorrect-exp]: `^` is bitwise xor, not exponentiation; use `**`
+   ╭▸ ROOT/testdata/IncorrectExp.sol:LL:CC
+   │
+LL │         e = 2 ^ uint256(64);
+   │             ━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-exp
+
+warning[incorrect-exp]: `^` is bitwise xor, not exponentiation; use `**`
+   ╭▸ ROOT/testdata/IncorrectExp.sol:LL:CC
+   │
+LL │         f = uint256(uint8(2)) ^ 64;
+   │             ━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-exp
+
diff --git a/crates/lint/testdata/IncorrectModifier.sol b/crates/lint/testdata/IncorrectModifier.sol
new file mode 100644
index 0000000000000..68ea0ba9cf3ce
--- /dev/null
+++ b/crates/lint/testdata/IncorrectModifier.sol
@@ -0,0 +1,152 @@
+//@compile-flags: --only-lint incorrect-modifier
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+contract IncorrectModifier {
+    bool enabled;
+    bool paused;
+
+    modifier conditionalPlaceholder() { //~WARN: modifier can finish without executing the modified function
+        if (enabled) {
+            _;
+        }
+    }
+
+    modifier returnsBeforePlaceholder() { //~WARN: modifier can finish without executing the modified function
+        if (paused) {
+            return;
+        }
+        _;
+    }
+
+    modifier nestedConditionalPlaceholder() { //~WARN: modifier can finish without executing the modified function
+        if (enabled) {
+            if (!paused) {
+                _;
+            }
+        } else {
+            revert("disabled");
+        }
+    }
+
+    modifier loopOnlyPlaceholder() { //~WARN: modifier can finish without executing the modified function
+        while (enabled) {
+            _;
+        }
+    }
+
+    modifier alwaysExecutes() {
+        _;
+    }
+
+    modifier revertsOrExecutes() {
+        if (!enabled) {
+            revert("disabled");
+        }
+        _;
+    }
+
+    modifier conditionalElseReverts() {
+        if (enabled) {
+            _;
+        } else {
+            revert("disabled");
+        }
+    }
+
+    modifier requireFalseOrExecutes() {
+        if (paused) {
+            require(false, "disabled");
+            return;
+        }
+        _;
+    }
+
+    modifier assertFalseOrExecutes() {
+        if (paused) {
+            assert(false);
+            return;
+        }
+        _;
+    }
+
+    modifier afterPlaceholderIsIrrelevant() {
+        _;
+        if (paused) {
+            return;
+        }
+    }
+
+    // A `do/while` body always runs at least once, so `_` is always reached.
+    modifier doWhileAlwaysRuns() {
+        do {
+            _;
+        } while (enabled);
+    }
+
+    // A loop before an unconditional placeholder still always reaches `_`.
+    modifier loopBeforePlaceholder() {
+        while (enabled) {
+            enabled = false;
+        }
+        _;
+    }
+
+    modifier forLoopBeforePlaceholder() {
+        for (uint256 i = 0; i < 3; i++) {
+            enabled = false;
+        }
+        _;
+    }
+
+    // Every try/catch path reaches `_` or reverts.
+    modifier tryCatchCovered() {
+        try this.poke() {
+            _;
+        } catch {
+            revert("failed");
+        }
+    }
+
+    // A `break` that skips `_` with no later placeholder is still flagged.
+    modifier breakSkipsPlaceholder() { //~WARN: modifier can finish without executing the modified function
+        while (enabled) {
+            if (paused) {
+                break;
+            }
+            _;
+        }
+    }
+
+    // A `do/while` body runs once, but a `continue` reaches the trailing condition, which can then
+    // exit the loop before `_`.
+    modifier doWhileContinueSkipsPlaceholder() { //~WARN: modifier can finish without executing the modified function
+        do {
+            if (paused) {
+                continue;
+            }
+            _;
+        } while (enabled);
+    }
+
+    // Assembly that always reverts before `_` reaches no normal exit, so it is not flagged.
+    modifier yulRevertOrExecutes() {
+        if (!enabled) {
+            assembly {
+                revert(0, 0)
+            }
+        }
+        _;
+    }
+
+    // A `return(...)` in assembly halts successfully without running `_`, so it is flagged.
+    modifier yulReturnBeforePlaceholder() { //~WARN: modifier can finish without executing the modified function
+        assembly {
+            return(0, 0)
+        }
+        _;
+    }
+
+    function poke() external {}
+}
diff --git a/crates/lint/testdata/IncorrectModifier.stderr b/crates/lint/testdata/IncorrectModifier.stderr
new file mode 100644
index 0000000000000..8f9555f6a93cb
--- /dev/null
+++ b/crates/lint/testdata/IncorrectModifier.stderr
@@ -0,0 +1,88 @@
+warning[incorrect-modifier]: modifier can finish without executing the modified function
+   ╭▸ ROOT/testdata/IncorrectModifier.sol:LL:CC
+   │
+LL │ ┏     modifier conditionalPlaceholder() {
+LL │ ┃         if (enabled) {
+LL │ ┃             _;
+LL │ ┃         }
+LL │ ┃     }
+   │ ┗━━━━━┛
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-modifier
+
+warning[incorrect-modifier]: modifier can finish without executing the modified function
+   ╭▸ ROOT/testdata/IncorrectModifier.sol:LL:CC
+   │
+LL │ ┏     modifier returnsBeforePlaceholder() {
+LL │ ┃         if (paused) {
+LL │ ┃             return;
+   ‡ ┃
+LL │ ┃     }
+   │ ┗━━━━━┛
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-modifier
+
+warning[incorrect-modifier]: modifier can finish without executing the modified function
+   ╭▸ ROOT/testdata/IncorrectModifier.sol:LL:CC
+   │
+LL │ ┏     modifier nestedConditionalPlaceholder() {
+LL │ ┃         if (enabled) {
+LL │ ┃             if (!paused) {
+LL │ ┃                 _;
+   ‡ ┃
+LL │ ┃     }
+   │ ┗━━━━━┛
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-modifier
+
+warning[incorrect-modifier]: modifier can finish without executing the modified function
+   ╭▸ ROOT/testdata/IncorrectModifier.sol:LL:CC
+   │
+LL │ ┏     modifier loopOnlyPlaceholder() {
+LL │ ┃         while (enabled) {
+LL │ ┃             _;
+LL │ ┃         }
+LL │ ┃     }
+   │ ┗━━━━━┛
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-modifier
+
+warning[incorrect-modifier]: modifier can finish without executing the modified function
+   ╭▸ ROOT/testdata/IncorrectModifier.sol:LL:CC
+   │
+LL │ ┏     modifier breakSkipsPlaceholder() {
+LL │ ┃         while (enabled) {
+LL │ ┃             if (paused) {
+LL │ ┃                 break;
+   ‡ ┃
+LL │ ┃     }
+   │ ┗━━━━━┛
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-modifier
+
+warning[incorrect-modifier]: modifier can finish without executing the modified function
+   ╭▸ ROOT/testdata/IncorrectModifier.sol:LL:CC
+   │
+LL │ ┏     modifier doWhileContinueSkipsPlaceholder() {
+LL │ ┃         do {
+LL │ ┃             if (paused) {
+LL │ ┃                 continue;
+   ‡ ┃
+LL │ ┃         } while (enabled);
+LL │ ┃     }
+   │ ┗━━━━━┛
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-modifier
+
+warning[incorrect-modifier]: modifier can finish without executing the modified function
+   ╭▸ ROOT/testdata/IncorrectModifier.sol:LL:CC
+   │
+LL │ ┏     modifier yulReturnBeforePlaceholder() {
+LL │ ┃         assembly {
+LL │ ┃             return(0, 0)
+   ‡ ┃
+LL │ ┃     }
+   │ ┗━━━━━┛
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-modifier
+
diff --git a/crates/lint/testdata/IncorrectShift.sol b/crates/lint/testdata/IncorrectShift.sol
index 9377354851c42..f9c28883d2f35 100644
--- a/crates/lint/testdata/IncorrectShift.sol
+++ b/crates/lint/testdata/IncorrectShift.sol
@@ -1,5 +1,6 @@
+//@compile-flags: --only-lint incorrect-shift inline-assembly
 // SPDX-License-Identifier: MIT
-pragma solidity ^0.8.0;
+pragma solidity ^0.8.18;
 
 contract IncorrectShift {
     uint256 stateValue = 100;
@@ -15,14 +16,19 @@ contract IncorrectShift {
         uint256 localShiftAmount = 3;
 
         // SHOULD FAIL:
-        // - Literal << NonLiteral
-        // - Literal >> NonLiteral
+        // - Yul shl/shr with a dynamic first argument and literal second argument
 
-        result = 2 << stateValue; //~WARN: the order of args in a shift operation is incorrect
-        result = 8 >> localValue; //~WARN: the order of args in a shift operation is incorrect
-        result = 16 << (stateValue + 1); //~WARN: the order of args in a shift operation is incorrect
-        result = 32 >> getAmount(); //~WARN: the order of args in a shift operation is incorrect
-        result = 1 << (localValue > 10 ? localShiftAmount : stateShiftAmount); //~WARN: the order of args in a shift operation is incorrect
+        result = 2 << stateValue;
+        result = 8 >> localValue;
+        result = 16 << (stateValue + 1);
+        result = 32 >> getAmount();
+        result = 1 << (localValue > 10 ? localShiftAmount : stateShiftAmount);
+
+        assembly {
+            result := shl(result, 8) //~WARN: the order of args in a shift operation is incorrect
+            result := shr(add(result, 1), 16) //~WARN: the order of args in a shift operation is incorrect
+            result := sar(add(result, 1), 8) //~WARN: the order of args in a shift operation is incorrect
+        }
 
         // SHOULD PASS:
         result = stateValue << 2;
@@ -34,5 +40,12 @@ contract IncorrectShift {
 
         result = 1 << 8;
         result = 255 >> 4;
+
+        assembly {
+            result := shl(8, result)
+            result := shr(16, result)
+            result := sar(8, result)
+            result := shl(8, 1)
+        }
     }
 }
diff --git a/crates/lint/testdata/IncorrectShift.stderr b/crates/lint/testdata/IncorrectShift.stderr
index 46276262da4bd..cd4db2b77e344 100644
--- a/crates/lint/testdata/IncorrectShift.stderr
+++ b/crates/lint/testdata/IncorrectShift.stderr
@@ -1,40 +1,40 @@
 warning[incorrect-shift]: the order of args in a shift operation is incorrect
-  --> ROOT/testdata/IncorrectShift.sol:LL:CC
-   |
-21 |         result = 2 << stateValue;
-   |                  ---------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#incorrect-shift
+   ╭▸ ROOT/testdata/IncorrectShift.sol:LL:CC
+   │
+LL │             result := shl(result, 8)
+   │                       ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-shift
 
 warning[incorrect-shift]: the order of args in a shift operation is incorrect
-  --> ROOT/testdata/IncorrectShift.sol:LL:CC
-   |
-22 |         result = 8 >> localValue;
-   |                  ---------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#incorrect-shift
+   ╭▸ ROOT/testdata/IncorrectShift.sol:LL:CC
+   │
+LL │             result := shr(add(result, 1), 16)
+   │                       ━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-shift
 
 warning[incorrect-shift]: the order of args in a shift operation is incorrect
-  --> ROOT/testdata/IncorrectShift.sol:LL:CC
-   |
-23 |         result = 16 << (stateValue + 1);
-   |                  ----------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#incorrect-shift
+   ╭▸ ROOT/testdata/IncorrectShift.sol:LL:CC
+   │
+LL │             result := sar(add(result, 1), 8)
+   │                       ━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-shift
 
-warning[incorrect-shift]: the order of args in a shift operation is incorrect
-  --> ROOT/testdata/IncorrectShift.sol:LL:CC
-   |
-24 |         result = 32 >> getAmount();
-   |                  -----------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#incorrect-shift
+note[inline-assembly]: inline assembly used
+   ╭▸ ROOT/testdata/IncorrectShift.sol:LL:CC
+   │
+LL │         assembly {
+   │         ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/inline-assembly
 
-warning[incorrect-shift]: the order of args in a shift operation is incorrect
-  --> ROOT/testdata/IncorrectShift.sol:LL:CC
-   |
-25 | ...   result = 1 << (localValue > 10 ? localShiftAmount : stateShiftAmount);
-   |                ------------------------------------------------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#incorrect-shift
+note[inline-assembly]: inline assembly used
+   ╭▸ ROOT/testdata/IncorrectShift.sol:LL:CC
+   │
+LL │         assembly {
+   │         ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/inline-assembly
 
diff --git a/crates/lint/testdata/IncorrectShiftBitMask.sol b/crates/lint/testdata/IncorrectShiftBitMask.sol
new file mode 100644
index 0000000000000..7d1d514657709
--- /dev/null
+++ b/crates/lint/testdata/IncorrectShiftBitMask.sol
@@ -0,0 +1,22 @@
+//@compile-flags: --only-lint incorrect-shift
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.20;
+
+contract IncorrectShiftBitMask {
+    function singleBit(uint256 index, uint256 mask) external pure returns (uint256 result) {
+        assembly ("memory-safe") {
+            result := shl(index, 1)
+            result := or(result, shl(add(index, 1), 0x01))
+            result := and(result, and(shl(index, 1), mask))
+        }
+    }
+
+    function suspiciousShifts(uint256 index) external pure returns (uint256 result) {
+        assembly ("memory-safe") {
+            result := shl(index, 8) //~WARN: the order of args in a shift operation is incorrect
+            result := shr(index, 1) //~WARN: the order of args in a shift operation is incorrect
+            result := sar(index, 1) //~WARN: the order of args in a shift operation is incorrect
+            result := shl(shr(index, 8), 1) //~WARN: the order of args in a shift operation is incorrect
+        }
+    }
+}
diff --git a/crates/lint/testdata/IncorrectShiftBitMask.stderr b/crates/lint/testdata/IncorrectShiftBitMask.stderr
new file mode 100644
index 0000000000000..a7f490532e0f4
--- /dev/null
+++ b/crates/lint/testdata/IncorrectShiftBitMask.stderr
@@ -0,0 +1,32 @@
+warning[incorrect-shift]: the order of args in a shift operation is incorrect
+   ╭▸ ROOT/testdata/IncorrectShiftBitMask.sol:LL:CC
+   │
+LL │             result := shl(index, 8)
+   │                       ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-shift
+
+warning[incorrect-shift]: the order of args in a shift operation is incorrect
+   ╭▸ ROOT/testdata/IncorrectShiftBitMask.sol:LL:CC
+   │
+LL │             result := shr(index, 1)
+   │                       ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-shift
+
+warning[incorrect-shift]: the order of args in a shift operation is incorrect
+   ╭▸ ROOT/testdata/IncorrectShiftBitMask.sol:LL:CC
+   │
+LL │             result := sar(index, 1)
+   │                       ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-shift
+
+warning[incorrect-shift]: the order of args in a shift operation is incorrect
+   ╭▸ ROOT/testdata/IncorrectShiftBitMask.sol:LL:CC
+   │
+LL │             result := shl(shr(index, 8), 1)
+   │                           ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-shift
+
diff --git a/crates/lint/testdata/IncorrectStrictEquality.sol b/crates/lint/testdata/IncorrectStrictEquality.sol
new file mode 100644
index 0000000000000..8508136f7fd9f
--- /dev/null
+++ b/crates/lint/testdata/IncorrectStrictEquality.sol
@@ -0,0 +1,194 @@
+//@compile-flags: --only-lint incorrect-strict-equality
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.0;
+
+interface IERC20 {
+    function balanceOf(address account) external view returns (uint256);
+}
+
+struct Account {
+    uint256 balance;
+    address owner;
+}
+
+struct Holder {
+    Account inner;
+    address operator;
+}
+
+library TokenUtils {
+    function balanceOf(IERC20 token, address account) internal view returns (uint256) {
+        return token.balanceOf(account);
+    }
+}
+
+contract IncorrectStrictEquality {
+    IERC20 public token;
+    uint256 public threshold;
+    address public recipient;
+    Account public account;
+    Holder public holder;
+    address[] public holders;
+    mapping(uint256 => address) public holderOf;
+
+    constructor(address _token) {
+        token = IERC20(_token);
+    }
+
+    function getRecipient() internal view returns (address) {
+        return recipient;
+    }
+
+    function consume(uint256 x) internal pure returns (uint256) {
+        return x;
+    }
+
+    // SHOULD FAIL:
+
+    function ethBalanceEq() public view returns (bool) {
+        return address(this).balance == 1 ether; //~WARN: dangerous strict equality check on an externally-influenced value
+    }
+
+    function ethBalanceNe() public view returns (bool) {
+        return address(this).balance != 0; //~WARN: dangerous strict equality check on an externally-influenced value
+    }
+
+    function ethBalanceOnRight() public view returns (bool) {
+        return 1 ether == address(this).balance; //~WARN: dangerous strict equality check on an externally-influenced value
+    }
+
+    function ethBalanceInArith() public view returns (bool) {
+        return address(this).balance + 1 == 100 ether; //~WARN: dangerous strict equality check on an externally-influenced value
+    }
+
+    function tokenBalanceEq() public view returns (bool) {
+        return token.balanceOf(address(this)) == 0; //~WARN: dangerous strict equality check on an externally-influenced value
+    }
+
+    function tokenBalanceNe() public view returns (bool) {
+        return token.balanceOf(msg.sender) != threshold; //~WARN: dangerous strict equality check on an externally-influenced value
+    }
+
+    function tokenBalanceOnRight() public view returns (bool) {
+        return 0 == token.balanceOf(address(this)); //~WARN: dangerous strict equality check on an externally-influenced value
+    }
+
+    function tokenBalanceInArith() public view returns (bool) {
+        return threshold == token.balanceOf(address(this)) - 1; //~WARN: dangerous strict equality check on an externally-influenced value
+    }
+
+    function inRequireEthBalance() public view {
+        require(address(this).balance == 100 ether, "wrong balance"); //~WARN: dangerous strict equality check on an externally-influenced value
+    }
+
+    function inRequireTokenBalance() public view {
+        require(token.balanceOf(address(this)) == 0, "not empty"); //~WARN: dangerous strict equality check on an externally-influenced value
+    }
+
+    function inIfEthBalance() public view returns (uint256) {
+        if (address(this).balance == 0) { //~WARN: dangerous strict equality check on an externally-influenced value
+            return 1;
+        }
+        return 0;
+    }
+
+    function addressVarBalance() public view returns (bool) {
+        return recipient.balance == 0; //~WARN: dangerous strict equality check on an externally-influenced value
+    }
+
+    function payableBalance(address payable a) public view returns (bool) {
+        return a.balance == 0; //~WARN: dangerous strict equality check on an externally-influenced value
+    }
+
+    function payableCastBalance(address a) public view returns (bool) {
+        return payable(a).balance == 0; //~WARN: dangerous strict equality check on an externally-influenced value
+    }
+
+    function msgSenderBalance() public view returns (bool) {
+        return msg.sender.balance == 0; //~WARN: dangerous strict equality check on an externally-influenced value
+    }
+
+    function txOriginBalance() public view returns (bool) {
+        return tx.origin.balance == 0; //~WARN: dangerous strict equality check on an externally-influenced value
+    }
+
+    function blockCoinbaseBalance() public view returns (bool) {
+        return block.coinbase.balance == 0; //~WARN: dangerous strict equality check on an externally-influenced value
+    }
+
+    function structFieldAddressBalance() public view returns (bool) {
+        return account.owner.balance == 0; //~WARN: dangerous strict equality check on an externally-influenced value
+    }
+
+    function nestedStructFieldAddressBalance() public view returns (bool) {
+        return holder.operator.balance == 0; //~WARN: dangerous strict equality check on an externally-influenced value
+    }
+
+    function arrayElementBalance(uint256 i) public view returns (bool) {
+        return holders[i].balance == 0; //~WARN: dangerous strict equality check on an externally-influenced value
+    }
+
+    function mappingValueBalance(uint256 i) public view returns (bool) {
+        return holderOf[i].balance == 0; //~WARN: dangerous strict equality check on an externally-influenced value
+    }
+
+    function functionReturnBalance() public view returns (bool) {
+        return getRecipient().balance == 0; //~WARN: dangerous strict equality check on an externally-influenced value
+    }
+
+    function ternaryBalance(bool flag) public view returns (bool) {
+        return (flag ? address(this).balance : 0) == 0; //~WARN: dangerous strict equality check on an externally-influenced value
+    }
+
+    function callArgBalance() public view returns (bool) {
+        return consume(address(this).balance) == 0; //~WARN: dangerous strict equality check on an externally-influenced value
+    }
+
+    // SHOULD PASS:
+
+    function ethBalanceGe() public view returns (bool) {
+        return address(this).balance >= 1 ether;
+    }
+
+    function ethBalanceLe() public view returns (bool) {
+        return address(this).balance <= 1 ether;
+    }
+
+    function ethBalanceGt() public view returns (bool) {
+        return address(this).balance > 0;
+    }
+
+    function tokenBalanceGe() public view returns (bool) {
+        return token.balanceOf(address(this)) >= threshold;
+    }
+
+    function plainUintEq(uint256 x) public view returns (bool) {
+        return x == threshold;
+    }
+
+    function msgSenderEq(address account_) public view returns (bool) {
+        return msg.sender == account_;
+    }
+
+    // `msg.value` is intentionally not flagged: exact payment validation is a normal
+    // pattern (see `docs/incorrect-strict-equality.md`).
+    function msgValueEq() public payable returns (bool) {
+        return msg.value == 1 ether;
+    }
+
+    // Struct field named `balance` on a non-address type, must NOT trigger the lint.
+    function structBalanceEq() public view returns (bool) {
+        return account.balance == 0;
+    }
+
+    function localStructBalanceEq() public view returns (bool) {
+        Account memory a;
+        return a.balance == 100;
+    }
+
+    // Static library call named `balanceOf` must NOT trigger the lint.
+    function libraryBalanceOf() public view returns (bool) {
+        return TokenUtils.balanceOf(token, address(this)) == 0;
+    }
+}
diff --git a/crates/lint/testdata/IncorrectStrictEquality.stderr b/crates/lint/testdata/IncorrectStrictEquality.stderr
new file mode 100644
index 0000000000000..2a358c4aa039c
--- /dev/null
+++ b/crates/lint/testdata/IncorrectStrictEquality.stderr
@@ -0,0 +1,192 @@
+warning[incorrect-strict-equality]: dangerous strict equality check on an externally-influenced value
+   ╭▸ ROOT/testdata/IncorrectStrictEquality.sol:LL:CC
+   │
+LL │         return address(this).balance == 1 ether;
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-strict-equality
+
+warning[incorrect-strict-equality]: dangerous strict equality check on an externally-influenced value
+   ╭▸ ROOT/testdata/IncorrectStrictEquality.sol:LL:CC
+   │
+LL │         return address(this).balance != 0;
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-strict-equality
+
+warning[incorrect-strict-equality]: dangerous strict equality check on an externally-influenced value
+   ╭▸ ROOT/testdata/IncorrectStrictEquality.sol:LL:CC
+   │
+LL │         return 1 ether == address(this).balance;
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-strict-equality
+
+warning[incorrect-strict-equality]: dangerous strict equality check on an externally-influenced value
+   ╭▸ ROOT/testdata/IncorrectStrictEquality.sol:LL:CC
+   │
+LL │         return address(this).balance + 1 == 100 ether;
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-strict-equality
+
+warning[incorrect-strict-equality]: dangerous strict equality check on an externally-influenced value
+   ╭▸ ROOT/testdata/IncorrectStrictEquality.sol:LL:CC
+   │
+LL │         return token.balanceOf(address(this)) == 0;
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-strict-equality
+
+warning[incorrect-strict-equality]: dangerous strict equality check on an externally-influenced value
+   ╭▸ ROOT/testdata/IncorrectStrictEquality.sol:LL:CC
+   │
+LL │         return token.balanceOf(msg.sender) != threshold;
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-strict-equality
+
+warning[incorrect-strict-equality]: dangerous strict equality check on an externally-influenced value
+   ╭▸ ROOT/testdata/IncorrectStrictEquality.sol:LL:CC
+   │
+LL │         return 0 == token.balanceOf(address(this));
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-strict-equality
+
+warning[incorrect-strict-equality]: dangerous strict equality check on an externally-influenced value
+   ╭▸ ROOT/testdata/IncorrectStrictEquality.sol:LL:CC
+   │
+LL │ …     return threshold == token.balanceOf(address(this)) - 1;
+   │              ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-strict-equality
+
+warning[incorrect-strict-equality]: dangerous strict equality check on an externally-influenced value
+   ╭▸ ROOT/testdata/IncorrectStrictEquality.sol:LL:CC
+   │
+LL │ …     require(address(this).balance == 100 ether, "wrong balance");
+   │               ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-strict-equality
+
+warning[incorrect-strict-equality]: dangerous strict equality check on an externally-influenced value
+   ╭▸ ROOT/testdata/IncorrectStrictEquality.sol:LL:CC
+   │
+LL │ …     require(token.balanceOf(address(this)) == 0, "not empty");
+   │               ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-strict-equality
+
+warning[incorrect-strict-equality]: dangerous strict equality check on an externally-influenced value
+   ╭▸ ROOT/testdata/IncorrectStrictEquality.sol:LL:CC
+   │
+LL │         if (address(this).balance == 0) {
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-strict-equality
+
+warning[incorrect-strict-equality]: dangerous strict equality check on an externally-influenced value
+   ╭▸ ROOT/testdata/IncorrectStrictEquality.sol:LL:CC
+   │
+LL │         return recipient.balance == 0;
+   │                ━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-strict-equality
+
+warning[incorrect-strict-equality]: dangerous strict equality check on an externally-influenced value
+   ╭▸ ROOT/testdata/IncorrectStrictEquality.sol:LL:CC
+   │
+LL │         return a.balance == 0;
+   │                ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-strict-equality
+
+warning[incorrect-strict-equality]: dangerous strict equality check on an externally-influenced value
+   ╭▸ ROOT/testdata/IncorrectStrictEquality.sol:LL:CC
+   │
+LL │         return payable(a).balance == 0;
+   │                ━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-strict-equality
+
+warning[incorrect-strict-equality]: dangerous strict equality check on an externally-influenced value
+   ╭▸ ROOT/testdata/IncorrectStrictEquality.sol:LL:CC
+   │
+LL │         return msg.sender.balance == 0;
+   │                ━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-strict-equality
+
+warning[incorrect-strict-equality]: dangerous strict equality check on an externally-influenced value
+   ╭▸ ROOT/testdata/IncorrectStrictEquality.sol:LL:CC
+   │
+LL │         return tx.origin.balance == 0;
+   │                ━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-strict-equality
+
+warning[incorrect-strict-equality]: dangerous strict equality check on an externally-influenced value
+   ╭▸ ROOT/testdata/IncorrectStrictEquality.sol:LL:CC
+   │
+LL │         return block.coinbase.balance == 0;
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-strict-equality
+
+warning[incorrect-strict-equality]: dangerous strict equality check on an externally-influenced value
+   ╭▸ ROOT/testdata/IncorrectStrictEquality.sol:LL:CC
+   │
+LL │         return account.owner.balance == 0;
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-strict-equality
+
+warning[incorrect-strict-equality]: dangerous strict equality check on an externally-influenced value
+   ╭▸ ROOT/testdata/IncorrectStrictEquality.sol:LL:CC
+   │
+LL │         return holder.operator.balance == 0;
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-strict-equality
+
+warning[incorrect-strict-equality]: dangerous strict equality check on an externally-influenced value
+   ╭▸ ROOT/testdata/IncorrectStrictEquality.sol:LL:CC
+   │
+LL │         return holders[i].balance == 0;
+   │                ━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-strict-equality
+
+warning[incorrect-strict-equality]: dangerous strict equality check on an externally-influenced value
+   ╭▸ ROOT/testdata/IncorrectStrictEquality.sol:LL:CC
+   │
+LL │         return holderOf[i].balance == 0;
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-strict-equality
+
+warning[incorrect-strict-equality]: dangerous strict equality check on an externally-influenced value
+   ╭▸ ROOT/testdata/IncorrectStrictEquality.sol:LL:CC
+   │
+LL │         return getRecipient().balance == 0;
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-strict-equality
+
+warning[incorrect-strict-equality]: dangerous strict equality check on an externally-influenced value
+   ╭▸ ROOT/testdata/IncorrectStrictEquality.sol:LL:CC
+   │
+LL │         return (flag ? address(this).balance : 0) == 0;
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-strict-equality
+
+warning[incorrect-strict-equality]: dangerous strict equality check on an externally-influenced value
+   ╭▸ ROOT/testdata/IncorrectStrictEquality.sol:LL:CC
+   │
+LL │         return consume(address(this).balance) == 0;
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-strict-equality
+
diff --git a/crates/lint/testdata/IncorrectUsingFor.sol b/crates/lint/testdata/IncorrectUsingFor.sol
new file mode 100644
index 0000000000000..9592b13a4c1f0
--- /dev/null
+++ b/crates/lint/testdata/IncorrectUsingFor.sol
@@ -0,0 +1,168 @@
+//@compile-flags: --only-lint incorrect-using-for
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+// Tests for `incorrect-using-for`: a `using L for T` directive whose library has no function
+// accepting `T` as its bound first parameter attaches nothing, so the directive is dead weight
+// and probably a typo. Attachment follows the type checker, implicit conversions included: a
+// `uint8` binds to a `uint256` parameter and a derived contract to a base parameter. Directives
+// that attach at least one function, `using L for *` and the braced form (already checked by
+// the compiler) stay clean.
+
+struct Point {
+    uint256 x;
+}
+
+library PointLib {
+    function norm(Point storage p) internal view returns (uint256) {
+        return p.x;
+    }
+}
+
+library StringLib {
+    function shout(string memory s) internal pure returns (string memory) {
+        return s;
+    }
+}
+
+library WideLib {
+    function double(uint256 v) internal pure returns (uint256) {
+        return v * 2;
+    }
+}
+
+// One applicable function is enough for the directive to be useful.
+library MixedLib {
+    function onPoint(Point storage p) internal view returns (uint256) {
+        return p.x;
+    }
+
+    function onString(string memory s) internal pure returns (uint256) {
+        return bytes(s).length;
+    }
+}
+
+contract Base {}
+
+contract Derived is Base {}
+
+library BaseLib {
+    function tag(Base) internal pure returns (uint256) {
+        return 1;
+    }
+}
+
+using PointLib for Point global;
+
+// No function of StringLib accepts a Point: the file-level directive attaches nothing.
+using StringLib for Point; //~NOTE: `using ... for` names a library
+
+// A uint8 binds to the uint256 parameter through implicit widening.
+using WideLib for uint8;
+
+contract UsesDirectives {
+    // No function of StringLib accepts a uint256: the contract-level directive attaches
+    // nothing.
+    using StringLib for uint256; //~NOTE: `using ... for` names a library
+
+    using MixedLib for Point;
+
+    // A Derived value binds to the Base parameter through implicit conversion.
+    using BaseLib for Derived;
+
+    // The star form attaches every function of the library: out of scope.
+    using StringLib for *;
+
+    // The braced form is type-checked by the compiler, a mismatch does not compile.
+    using {WideLib.double} for uint256;
+
+    Point internal point;
+
+    function useAttachments(Derived d, uint8 small, string memory s) internal view returns (uint256) {
+        return point.norm() + point.onPoint() + d.tag() + small.double() + bytes(s.shout()).length;
+    }
+}
+
+// A library function whose bound first parameter lives in calldata attaches too: storage does
+// not convert to calldata, so the calldata location is probed on its own.
+library BytesSliceLib {
+    function slice1(bytes calldata b) internal pure returns (bytes calldata) {
+        return b[1:];
+    }
+}
+
+library StringTailLib {
+    function tail(string calldata s) internal pure returns (string calldata) {
+        return s[1:];
+    }
+}
+
+using BytesSliceLib for bytes;
+using StringTailLib for string;
+
+contract UsesCalldataAttachments {
+    function firstByteDropped(bytes calldata b) external pure returns (bytes calldata) {
+        return b.slice1();
+    }
+
+    function firstCharDropped(string calldata s) external pure returns (string calldata) {
+        return s.tail();
+    }
+}
+
+// The library form skips private functions, but a braced directive is allowed to attach one
+// inside its own library. That attached private member must not make the dead library-form
+// directive look useful: `using PrivateLib for Holder` attaches nothing of its own.
+struct Holder {
+    uint256 x;
+}
+
+library PrivateLib {
+    using {PrivateLib.grab} for Holder;
+    using PrivateLib for Holder; //~NOTE: `using ... for` names a library
+
+    function grab(Holder memory h) private pure returns (uint256) {
+        return h.x;
+    }
+
+    function use() internal pure returns (uint256) {
+        Holder memory h = Holder(1);
+        return h.grab();
+    }
+}
+
+// Public and external library functions attach through the library form too, each proven on
+// its own so neither visibility hides the other: both directives are live without any
+// internal function.
+library PublicOnlyLib {
+    function pubDouble(uint256 v) public pure returns (uint256) {
+        return v * 2;
+    }
+}
+
+library ExternalOnlyLib {
+    function extTriple(uint256 v) external pure returns (uint256) {
+        return v * 3;
+    }
+}
+
+contract UsesVisibleAttachments {
+    using PublicOnlyLib for uint256;
+    using ExternalOnlyLib for uint256;
+
+    function double(uint256 x) internal pure returns (uint256) {
+        return x.pubDouble();
+    }
+}
+
+// A library whose only ordinary function takes no parameter attaches nothing: the library
+// form has no bound first parameter to match, so the directive is a no-op.
+library ZeroParamLib {
+    function answer() internal pure returns (uint256) {
+        return 42;
+    }
+}
+
+contract UsesZeroParamLib {
+    using ZeroParamLib for uint256; //~NOTE: `using ... for` names a library
+}
diff --git a/crates/lint/testdata/IncorrectUsingFor.stderr b/crates/lint/testdata/IncorrectUsingFor.stderr
new file mode 100644
index 0000000000000..be4c947d2a0e8
--- /dev/null
+++ b/crates/lint/testdata/IncorrectUsingFor.stderr
@@ -0,0 +1,32 @@
+note[incorrect-using-for]: `using ... for` names a library with no function applicable to the type, so the directive attaches nothing
+   ╭▸ ROOT/testdata/IncorrectUsingFor.sol:LL:CC
+   │
+LL │ using StringLib for Point;
+   │       ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-using-for
+
+note[incorrect-using-for]: `using ... for` names a library with no function applicable to the type, so the directive attaches nothing
+   ╭▸ ROOT/testdata/IncorrectUsingFor.sol:LL:CC
+   │
+LL │     using StringLib for uint256;
+   │           ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-using-for
+
+note[incorrect-using-for]: `using ... for` names a library with no function applicable to the type, so the directive attaches nothing
+   ╭▸ ROOT/testdata/IncorrectUsingFor.sol:LL:CC
+   │
+LL │     using PrivateLib for Holder;
+   │           ━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-using-for
+
+note[incorrect-using-for]: `using ... for` names a library with no function applicable to the type, so the directive attaches nothing
+   ╭▸ ROOT/testdata/IncorrectUsingFor.sol:LL:CC
+   │
+LL │     using ZeroParamLib for uint256;
+   │           ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/incorrect-using-for
+
diff --git a/crates/lint/testdata/InlineAssembly.sol b/crates/lint/testdata/InlineAssembly.sol
new file mode 100644
index 0000000000000..3ea73a76e6880
--- /dev/null
+++ b/crates/lint/testdata/InlineAssembly.sol
@@ -0,0 +1,110 @@
+//@compile-flags: --severity info
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+contract InlineAssembly {
+    function bare() public view returns (uint256 id) {
+        assembly { //~NOTE: inline assembly used
+            id := chainid()
+        }
+    }
+
+    function withMemorySafe() public view returns (uint256 size) {
+        assembly ("memory-safe") { //~NOTE: inline assembly declared `memory-safe`
+            size := extcodesize(address())
+        }
+    }
+
+    function withDialectAndMemorySafe() public view returns (uint256 ptr) {
+        assembly "evmasm" ("memory-safe") { //~NOTE: inline assembly declared `memory-safe`
+            ptr := mload(0x40)
+        }
+    }
+
+    function withNatspecMemorySafe() public view returns (uint256 v) {
+        /// @solidity memory-safe-assembly
+        assembly { //~NOTE: inline assembly declared `memory-safe`
+            v := chainid()
+        }
+    }
+
+    function withNatspecMemorySafeAndOtherDocs() public view returns (uint256 v) {
+        /// @notice does a thing
+        /// @solidity memory-safe-assembly
+        assembly { //~NOTE: inline assembly declared `memory-safe`
+            v := gas()
+        }
+    }
+
+    function plainCommentDoesNotCount() public view returns (uint256 v) {
+        // solidity memory-safe-assembly
+        assembly { //~NOTE: inline assembly used
+            v := chainid()
+        }
+    }
+
+    function nestedInControlFlow(bool flag) public view returns (uint256 v) {
+        if (flag) {
+            assembly { //~NOTE: inline assembly used
+                v := gas()
+            }
+        }
+
+        for (uint256 i = 0; i < 1; ++i) {
+            assembly { //~NOTE: inline assembly used
+                v := add(v, 1)
+            }
+        }
+    }
+
+    function nestedInUnchecked(uint256 x) public pure returns (uint256 v) {
+        unchecked {
+            v = x + 1;
+            assembly { //~NOTE: inline assembly used
+                v := add(v, 1)
+            }
+        }
+    }
+
+    function nestedInTryCatch() public returns (uint256 v) {
+        try this.bare() returns (uint256) {
+            assembly { //~NOTE: inline assembly used
+                v := 1
+            }
+        } catch {
+            assembly { //~NOTE: inline assembly used
+                v := 2
+            }
+        }
+    }
+
+    function suppressed() public view returns (uint256 id) {
+        // forge-lint: disable-next-line(inline-assembly)
+        assembly {
+            id := chainid()
+        }
+    }
+
+    modifier guarded() {
+        assembly { //~NOTE: inline assembly used
+            if iszero(caller()) { revert(0, 0) }
+        }
+        _;
+    }
+
+    function suppressedRegion() public view returns (uint256 a, uint256 b) {
+        // forge-lint: disable-start(inline-assembly)
+        assembly {
+            a := chainid()
+        }
+        assembly ("memory-safe") {
+            b := gas()
+        }
+        // forge-lint: disable-end(inline-assembly)
+    }
+
+    function noAssembly() public pure returns (uint256) {
+        return 42;
+    }
+}
diff --git a/crates/lint/testdata/InlineAssembly.stderr b/crates/lint/testdata/InlineAssembly.stderr
new file mode 100644
index 0000000000000..c84403a490f34
--- /dev/null
+++ b/crates/lint/testdata/InlineAssembly.stderr
@@ -0,0 +1,96 @@
+note[inline-assembly]: inline assembly used
+   ╭▸ ROOT/testdata/InlineAssembly.sol:LL:CC
+   │
+LL │         assembly {
+   │         ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/inline-assembly
+
+note[inline-assembly]: inline assembly declared `memory-safe`
+   ╭▸ ROOT/testdata/InlineAssembly.sol:LL:CC
+   │
+LL │         assembly ("memory-safe") {
+   │         ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/inline-assembly
+
+note[inline-assembly]: inline assembly declared `memory-safe`
+   ╭▸ ROOT/testdata/InlineAssembly.sol:LL:CC
+   │
+LL │         assembly "evmasm" ("memory-safe") {
+   │         ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/inline-assembly
+
+note[inline-assembly]: inline assembly declared `memory-safe`
+   ╭▸ ROOT/testdata/InlineAssembly.sol:LL:CC
+   │
+LL │         assembly {
+   │         ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/inline-assembly
+
+note[inline-assembly]: inline assembly declared `memory-safe`
+   ╭▸ ROOT/testdata/InlineAssembly.sol:LL:CC
+   │
+LL │         assembly {
+   │         ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/inline-assembly
+
+note[inline-assembly]: inline assembly used
+   ╭▸ ROOT/testdata/InlineAssembly.sol:LL:CC
+   │
+LL │         assembly {
+   │         ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/inline-assembly
+
+note[inline-assembly]: inline assembly used
+   ╭▸ ROOT/testdata/InlineAssembly.sol:LL:CC
+   │
+LL │             assembly {
+   │             ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/inline-assembly
+
+note[inline-assembly]: inline assembly used
+   ╭▸ ROOT/testdata/InlineAssembly.sol:LL:CC
+   │
+LL │             assembly {
+   │             ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/inline-assembly
+
+note[inline-assembly]: inline assembly used
+   ╭▸ ROOT/testdata/InlineAssembly.sol:LL:CC
+   │
+LL │             assembly {
+   │             ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/inline-assembly
+
+note[inline-assembly]: inline assembly used
+   ╭▸ ROOT/testdata/InlineAssembly.sol:LL:CC
+   │
+LL │             assembly {
+   │             ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/inline-assembly
+
+note[inline-assembly]: inline assembly used
+   ╭▸ ROOT/testdata/InlineAssembly.sol:LL:CC
+   │
+LL │             assembly {
+   │             ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/inline-assembly
+
+note[inline-assembly]: inline assembly used
+   ╭▸ ROOT/testdata/InlineAssembly.sol:LL:CC
+   │
+LL │         assembly {
+   │         ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/inline-assembly
+
diff --git a/crates/lint/testdata/InternalFunctionUsedOnce.sol b/crates/lint/testdata/InternalFunctionUsedOnce.sol
new file mode 100644
index 0000000000000..54ae8f7f1c104
--- /dev/null
+++ b/crates/lint/testdata/InternalFunctionUsedOnce.sol
@@ -0,0 +1,215 @@
+//@compile-flags: --only-lint internal-function-used-once
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+// Tests for `internal-function-used-once`: an internal function called exactly once in the
+// whole unit can usually be inlined into its caller. The single reference must be a direct
+// call, resolved through the type checker; a reference used as a value (a function pointer
+// assigned, returned or passed as a callback) has no call site to inline into and is out of
+// scope. Also out of scope: functions whose name starts with `_` (hook convention), `virtual`
+// functions and overrides (they exist for dynamic dispatch), functions referenced zero times
+// (dead code is another concern) or more than once, and functions bound as user-defined
+// operators (their operator uses are not name references, and the binding requires a named
+// function).
+
+library MathLib {
+    function onceQualified(uint256 v) internal pure returns (uint256) { //~NOTE: this internal function is used only once
+        return v + 1;
+    }
+
+    function attachedOnce(uint256 v) internal pure returns (uint256) { //~NOTE: this internal function is used only once
+        return v + 2;
+    }
+}
+
+contract Counter {
+    using MathLib for uint256;
+
+    uint256 internal count;
+
+    function helper(uint256 v) internal pure returns (uint256) { //~NOTE: this internal function is used only once
+        return v * 2;
+    }
+
+    function twice(uint256 v) internal pure returns (uint256) {
+        return v + 3;
+    }
+
+    function _hooky(uint256 v) internal pure returns (uint256) {
+        return v + 4;
+    }
+
+    function neverUsed(uint256 v) internal pure returns (uint256) {
+        return v + 5;
+    }
+
+    function recurse(uint256 v) internal pure returns (uint256) {
+        // the self-reference counts too: one external caller makes two references
+        return v == 0 ? 0 : recurse(v - 1);
+    }
+
+    // Referenced exactly once, but as a value assigned to a function pointer rather than a
+    // direct call: there is no call site to inline into, so it stays out of scope.
+    function pointed(uint256 v) internal pure returns (uint256) {
+        return v + 6;
+    }
+
+    function run(uint256 v) internal {
+        count = helper(v) + twice(v) + _hooky(v) + recurse(v) + MathLib.onceQualified(v);
+        count += v.attachedOnce();
+    }
+
+    function runAgain(uint256 v) internal {
+        function(uint256) internal pure returns (uint256) f = pointed;
+        count = twice(f(v));
+    }
+}
+
+// A virtual function and its override exist for dynamic dispatch: out of scope even when
+// referenced once.
+contract VirtualBase {
+    function pick(uint256 v) internal view virtual returns (uint256) {
+        return v + block.number;
+    }
+
+    function useBase(uint256 v) internal view returns (uint256) {
+        // the single static reference to the virtual base: exempt all the same
+        return pick(v);
+    }
+}
+
+contract VirtualChild is VirtualBase {
+    function pick(uint256 v) internal view override returns (uint256) {
+        return v + block.timestamp;
+    }
+
+    function use(uint256 v) internal view returns (uint256) {
+        return pick(v);
+    }
+}
+
+// A free function is internal by nature and counted across the unit.
+function freeOnce(uint256 v) pure returns (uint256) { //~NOTE: this internal function is used only once
+    return v + 7;
+}
+
+contract UsesFree {
+    function go(uint256 v) internal pure returns (uint256) {
+        return freeOnce(v);
+    }
+}
+
+// Functions bound as user-defined operators are out of scope: their operator uses are not
+// name references, so their count would lie, and the binding requires a named function, so
+// inlining is not an option. Both stay clean, with the operator as the only use or next to
+// one direct call.
+type Unit is uint256;
+
+function operatorAdd(Unit a, Unit b) pure returns (Unit) {
+    return Unit.wrap(Unit.unwrap(a) + Unit.unwrap(b));
+}
+
+function operatorSub(Unit a, Unit b) pure returns (Unit) {
+    return Unit.wrap(Unit.unwrap(a) - Unit.unwrap(b));
+}
+
+using {operatorAdd as +, operatorSub as -} for Unit global;
+
+contract UsesOperators {
+    function viaOperatorOnly(Unit a, Unit b) internal pure returns (Unit) {
+        return a + b;
+    }
+
+    function viaOperatorAndCall(Unit a, Unit b) internal pure returns (Unit) {
+        return operatorSub(a - b, b);
+    }
+}
+
+// Recursive functions cannot be inlined into a caller: a self-recursive helper stays out
+// whether or not it has an external caller, and mutually recursive helpers whose only
+// references are the cycle itself have no caller to inline into.
+contract Recursion {
+    function selfRecursiveNoCaller(uint256 v) internal pure returns (uint256) {
+        if (v == 0) {
+            return 0;
+        }
+        return selfRecursiveNoCaller(v - 1);
+    }
+
+    function factorial(uint256 v) internal pure returns (uint256) {
+        if (v <= 1) {
+            return 1;
+        }
+        return v * factorial(v - 1);
+    }
+
+    function useFactorial(uint256 v) internal pure returns (uint256) {
+        return factorial(v);
+    }
+
+    function mutualEven(uint256 v) internal pure returns (bool) {
+        if (v == 0) {
+            return true;
+        }
+        return mutualOdd(v - 1);
+    }
+
+    function mutualOdd(uint256 v) internal pure returns (bool) {
+        if (v == 0) {
+            return false;
+        }
+        return mutualEven(v - 1);
+    }
+}
+
+// A helper hanging off someone else's cycle is still inlineable: the cycle suppression only
+// applies when the cycle contains the candidate itself, not when the single-reference chain
+// runs into a later cycle.
+contract TailOffCycle {
+    function tail(uint256 v) internal pure returns (uint256) { //~NOTE: this internal function is used only once
+        return v + 1;
+    }
+
+    function cycleA(uint256 v) internal pure returns (uint256) {
+        if (v == 0) {
+            return tail(v);
+        }
+        return cycleB(v - 1);
+    }
+
+    function cycleB(uint256 v) internal pure returns (uint256) {
+        return cycleA(v);
+    }
+}
+
+// The remaining value positions: a helper returned as a function pointer and one passed as a
+// callback argument. Neither is a call site to inline into, so both stay out of scope, while
+// the function actually called once (`invoke`) is reported.
+contract ValuePositions {
+    function returned(uint256 v) internal pure returns (uint256) {
+        return v + 1;
+    }
+
+    function passed(uint256 v) internal pure returns (uint256) {
+        return v + 2;
+    }
+
+    function invoke( //~NOTE: this internal function is used only once
+        function(uint256) internal pure returns (uint256) cb,
+        uint256 v
+    ) internal pure returns (uint256) {
+        return cb(v);
+    }
+
+    function pickReturned()
+        internal
+        pure
+        returns (function(uint256) internal pure returns (uint256))
+    {
+        return returned;
+    }
+
+    function usePassed(uint256 v) internal pure returns (uint256) {
+        return invoke(passed, v);
+    }
+}
diff --git a/crates/lint/testdata/InternalFunctionUsedOnce.stderr b/crates/lint/testdata/InternalFunctionUsedOnce.stderr
new file mode 100644
index 0000000000000..f50316be4e88a
--- /dev/null
+++ b/crates/lint/testdata/InternalFunctionUsedOnce.stderr
@@ -0,0 +1,48 @@
+note[internal-function-used-once]: this internal function is used only once
+   ╭▸ ROOT/testdata/InternalFunctionUsedOnce.sol:LL:CC
+   │
+LL │     function onceQualified(uint256 v) internal pure returns (uint256) {
+   │     ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/internal-function-used-once
+
+note[internal-function-used-once]: this internal function is used only once
+   ╭▸ ROOT/testdata/InternalFunctionUsedOnce.sol:LL:CC
+   │
+LL │     function attachedOnce(uint256 v) internal pure returns (uint256) {
+   │     ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/internal-function-used-once
+
+note[internal-function-used-once]: this internal function is used only once
+   ╭▸ ROOT/testdata/InternalFunctionUsedOnce.sol:LL:CC
+   │
+LL │     function helper(uint256 v) internal pure returns (uint256) {
+   │     ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/internal-function-used-once
+
+note[internal-function-used-once]: this internal function is used only once
+   ╭▸ ROOT/testdata/InternalFunctionUsedOnce.sol:LL:CC
+   │
+LL │ function freeOnce(uint256 v) pure returns (uint256) {
+   │ ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/internal-function-used-once
+
+note[internal-function-used-once]: this internal function is used only once
+   ╭▸ ROOT/testdata/InternalFunctionUsedOnce.sol:LL:CC
+   │
+LL │     function tail(uint256 v) internal pure returns (uint256) {
+   │     ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/internal-function-used-once
+
+note[internal-function-used-once]: this internal function is used only once
+   ╭▸ ROOT/testdata/InternalFunctionUsedOnce.sol:LL:CC
+   │
+LL │     function invoke(
+   │     ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/internal-function-used-once
+
diff --git a/crates/lint/testdata/Keccak256.sol b/crates/lint/testdata/Keccak256.sol
index bc63e08dd4b6c..f7c5d35b55b6a 100644
--- a/crates/lint/testdata/Keccak256.sol
+++ b/crates/lint/testdata/Keccak256.sol
@@ -1,8 +1,12 @@
-contract AsmKeccak256 {
+//@compile-flags: --severity gas info
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
 
+contract AsmKeccak256 {
     // constants are optimized by the compiler
     bytes32 constant HASH = keccak256("hello");
-    bytes32 constant OTHER_HASH = keccak256(0x1234);
+    bytes32 constant OTHER_HASH = keccak256(hex"1234");
 
     constructor(uint256 a, uint256 b, address c) {
         // forge-lint: disable-next-line(asm-keccak256)
@@ -12,14 +16,14 @@ contract AsmKeccak256 {
 
         // lints fire before the disabled block
         address c = address(1);
-        bytes32 hash = keccak256(abi.encodePacked(a, b, bytes32(c))); //~NOTE: inefficient hashing mechanism
-        uint256 MixedCase_Variable = 1; //~NOTE: mutable variables should use mixedCase
+        bytes32 hash = keccak256(abi.encodePacked(a, b, bytes32(bytes20(c)))); //~NOTE: high-level `keccak256` call
+        uint256 MixedCase_Variable = 1; //~NOTE: mutable variable name is not `mixedCase`
 
         // forge-lint: disable-start(asm-keccak256) -------------------------------------
         keccak256(abi.encodePacked(a, b)); // disabled                                  |
         //                                                                              |
         // non-disabled lints still fire                                                |
-        uint256 Another_MixedCase = 2; //~NOTE: mutable variables should use mixedCase
+        uint256 Another_MixedCase = 2; //~NOTE: mutable variable name is not `mixedCase`
         //                                                                              |
         // forge-lint: disable-start(asm-keccak256) -------                             |
         keccak256(abi.encodePacked(a, b)); // disabled    |                             |
@@ -28,26 +32,27 @@ contract AsmKeccak256 {
         // forge-lint: disable-end(asm-keccak256) ---------------------------------------
 
         // lints still fire after the disabled block
-        bytes32 afterDisabledBlock = keccak256(abi.encode(a, b, c)); //~NOTE: inefficient hashing mechanism
-        uint256 YetAnother_MixedCase = 3; //~NOTE: mutable variables should use mixedCase
+        bytes32 afterDisabledBlock = keccak256(abi.encode(a, b, c)); //~NOTE: high-level `keccak256` call
+        uint256 YetAnother_MixedCase = 3; //~NOTE: mutable variable name is not `mixedCase`
     }
 
     // forge-lint: disable-next-item(asm-keccak256)
-    function solidityHashDisabled(uint256 a, uint256 b) public view returns (bytes32) {
-        bytes32 hash = keccak256(a);
+    function solidityHashDisabled(uint256 a, uint256 b) public pure returns (bytes32) {
+        bytes32 hash = keccak256(abi.encodePacked(a));
         return keccak256(abi.encodePacked(a, b));
     }
 
-    function solidityHash(bytes calldata z, uint256 a, uint256 b, address c) public view returns (bytes32) {
-        bytes32 loadsFromCalldata = keccak256(z); //~NOTE: inefficient hashing mechanism
+    function solidityHash(bytes calldata z, uint256 a, uint256 b, address c) public pure returns (bytes32) {
+        bytes32 loadsFromCalldata = keccak256(z); //~NOTE: high-level `keccak256` call
         bytes memory y = z;
-        bytes32 loadsFromMemory = keccak256(y); //~NOTE: inefficient hashing mechanism
-        bytes32 lintWithoutFix = keccak256(abi.encodePacked(a, b, c)); //~NOTE: inefficient hashing mechanism
-        return keccak256(abi.encode(a, b, c)); //~NOTE: inefficient hashing mechanism
+        bytes32 loadsFromMemory = keccak256(y); //~NOTE: high-level `keccak256` call
+        bytes32 lintWithoutFix = keccak256(abi.encodePacked(a, b, c)); //~NOTE: high-level `keccak256` call
+        return keccak256(abi.encode(a, b, c)); //~NOTE: high-level `keccak256` call
     }
 
-    function assemblyHash(uint256 a, uint256 b) public view returns (bytes32){
+    function assemblyHash(uint256 a, uint256 b) public pure returns (bytes32) {
         //optimized
+        // forge-lint: disable-next-line(inline-assembly)
         assembly {
             mstore(0x00, a)
             mstore(0x20, b)
@@ -58,27 +63,27 @@ contract AsmKeccak256 {
 
 // forge-lint: disable-next-item(asm-keccak256)
 contract OtherAsmKeccak256 {
-    uint256 Enabled_MixedCase_Variable; //~NOTE: mutable variables should use mixedCase
+    uint256 Enabled_MixedCase_Variable; //~NOTE: mutable variable name is not `mixedCase`
 
-    function contratDisabledHash(uint256 a, uint256 b) public view returns (bytes32) {
+    function contratDisabledHash(uint256 a, uint256 b) public pure returns (bytes32) {
         return keccak256(abi.encode(a, b));
     }
 
-    function contratDisabledHash2(uint256 a, uint256 b) public view returns (bytes32) {
+    function contratDisabledHash2(uint256 a, uint256 b) public pure returns (bytes32) {
         return keccak256(abi.encodePacked(a, b));
     }
 }
 
 contract YetAnotherAsmKeccak256 {
-    function nonDisabledHash(uint256 x, uint256 y) public view returns (bytes32) {
-        bytes32 doesNotUseScratchSpace = keccak256(abi.encode(x, y, x, y, x, y)); //~NOTE: inefficient hashing mechanism
-        bytes32 doesUseScratchSpace = keccak256(abi.encode(x)); //~NOTE: inefficient hashing mechanism
-        return keccak256(abi.encode(doesUseScratchSpace, doesNotUseScratchSpace)); //~NOTE: inefficient hashing mechanism
+    function nonDisabledHash(uint256 x, uint256 y) public pure returns (bytes32) {
+        bytes32 doesNotUseScratchSpace = keccak256(abi.encode(x, y, x, y, x, y)); //~NOTE: high-level `keccak256` call
+        bytes32 doesUseScratchSpace = keccak256(abi.encode(x)); //~NOTE: high-level `keccak256` call
+        return keccak256(abi.encode(doesUseScratchSpace, doesNotUseScratchSpace)); //~NOTE: high-level `keccak256` call
     }
 
     // forge-lint: disable-next-item(asm-keccak256)
-    function functionDisabledHash(uint256 a, uint256 b) public view returns (bytes32) {
-        uint256 Enabled_MixedCase_Variable = 1; //~NOTE: mutable variables should use mixedCase
+    function functionDisabledHash(uint256 a, uint256 b) public pure returns (bytes32) {
+        uint256 Enabled_MixedCase_Variable = 1; //~NOTE: mutable variable name is not `mixedCase`
         return keccak256(abi.encodePacked(a, b));
     }
 }
diff --git a/crates/lint/testdata/Keccak256.stderr b/crates/lint/testdata/Keccak256.stderr
index 32cab3039107d..8d8555ffeb187 100644
--- a/crates/lint/testdata/Keccak256.stderr
+++ b/crates/lint/testdata/Keccak256.stderr
@@ -1,152 +1,144 @@
-note[mixed-case-variable]: mutable variables should use mixedCase
-  --> ROOT/testdata/Keccak256.sol:LL:CC
-   |
-16 |         uint256 MixedCase_Variable = 1;
-   |                 ------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#mixed-case-variable
-
-note[mixed-case-variable]: mutable variables should use mixedCase
-  --> ROOT/testdata/Keccak256.sol:LL:CC
-   |
-22 |         uint256 Another_MixedCase = 2;
-   |                 -----------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#mixed-case-variable
-
-note[mixed-case-variable]: mutable variables should use mixedCase
-  --> ROOT/testdata/Keccak256.sol:LL:CC
-   |
-32 |         uint256 YetAnother_MixedCase = 3;
-   |                 --------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#mixed-case-variable
-
-note[mixed-case-variable]: mutable variables should use mixedCase
-  --> ROOT/testdata/Keccak256.sol:LL:CC
-   |
-61 |     uint256 Enabled_MixedCase_Variable;
-   |             --------------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#mixed-case-variable
-
-note[mixed-case-variable]: mutable variables should use mixedCase
-  --> ROOT/testdata/Keccak256.sol:LL:CC
-   |
-81 |         uint256 Enabled_MixedCase_Variable = 1;
-   |                 --------------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#mixed-case-variable
-
-note[asm-keccak256]: use of inefficient hashing mechanism; consider using inline assembly
-  --> ROOT/testdata/Keccak256.sol:LL:CC
-   |
-15 |         bytes32 hash = keccak256(abi.encodePacked(a, b, bytes32(c)));
-   |                        ---------------------------------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#asm-keccak256
-
-note[asm-keccak256]: use of inefficient hashing mechanism; consider using inline assembly
-  --> ROOT/testdata/Keccak256.sol:LL:CC
-   |
-31 |         bytes32 afterDisabledBlock = keccak256(abi.encode(a, b, c));
-   |                                      ------------------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#asm-keccak256
-
-note[asm-keccak256]: use of inefficient hashing mechanism; consider using inline assembly
-  --> ROOT/testdata/Keccak256.sol:LL:CC
-   |
-37 |         bytes32 hash = keccak256(a);
-   |                        ------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#asm-keccak256
-
-note[asm-keccak256]: use of inefficient hashing mechanism; consider using inline assembly
-  --> ROOT/testdata/Keccak256.sol:LL:CC
-   |
-38 |         return keccak256(abi.encodePacked(a, b));
-   |                ---------------------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#asm-keccak256
-
-note[asm-keccak256]: use of inefficient hashing mechanism; consider using inline assembly
-  --> ROOT/testdata/Keccak256.sol:LL:CC
-   |
-42 |         bytes32 loadsFromCalldata = keccak256(z);
-   |                                     ------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#asm-keccak256
-
-note[asm-keccak256]: use of inefficient hashing mechanism; consider using inline assembly
-  --> ROOT/testdata/Keccak256.sol:LL:CC
-   |
-44 |         bytes32 loadsFromMemory = keccak256(y);
-   |                                   ------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#asm-keccak256
-
-note[asm-keccak256]: use of inefficient hashing mechanism; consider using inline assembly
-  --> ROOT/testdata/Keccak256.sol:LL:CC
-   |
-45 |         bytes32 lintWithoutFix = keccak256(abi.encodePacked(a, b, c));
-   |                                  ------------------------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#asm-keccak256
-
-note[asm-keccak256]: use of inefficient hashing mechanism; consider using inline assembly
-  --> ROOT/testdata/Keccak256.sol:LL:CC
-   |
-46 |         return keccak256(abi.encode(a, b, c));
-   |                ------------------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#asm-keccak256
-
-note[asm-keccak256]: use of inefficient hashing mechanism; consider using inline assembly
-  --> ROOT/testdata/Keccak256.sol:LL:CC
-   |
-64 |         return keccak256(abi.encode(a, b));
-   |                ---------------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#asm-keccak256
-
-note[asm-keccak256]: use of inefficient hashing mechanism; consider using inline assembly
-  --> ROOT/testdata/Keccak256.sol:LL:CC
-   |
-68 |         return keccak256(abi.encodePacked(a, b));
-   |                ---------------------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#asm-keccak256
-
-note[asm-keccak256]: use of inefficient hashing mechanism; consider using inline assembly
-  --> ROOT/testdata/Keccak256.sol:LL:CC
-   |
-74 |         bytes32 doesNotUseScratchSpace = keccak256(abi.encode(x, y, x, y, x, y));
-   |                                          ---------------------------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#asm-keccak256
-
-note[asm-keccak256]: use of inefficient hashing mechanism; consider using inline assembly
-  --> ROOT/testdata/Keccak256.sol:LL:CC
-   |
-75 |         bytes32 doesUseScratchSpace = keccak256(abi.encode(x));
-   |                                       ------------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#asm-keccak256
-
-note[asm-keccak256]: use of inefficient hashing mechanism; consider using inline assembly
-  --> ROOT/testdata/Keccak256.sol:LL:CC
-   |
-76 |         return keccak256(abi.encode(doesUseScratchSpace, doesNotUseScratchSpace));
-   |                ------------------------------------------------------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#asm-keccak256
-
-note[asm-keccak256]: use of inefficient hashing mechanism; consider using inline assembly
-  --> ROOT/testdata/Keccak256.sol:LL:CC
-   |
-82 |         return keccak256(abi.encodePacked(a, b));
-   |                ---------------------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#asm-keccak256
+note[mixed-case-variable]: mutable variable name is not `mixedCase`
+   ╭▸ ROOT/testdata/Keccak256.sol:LL:CC
+   │
+LL │         uint256 MixedCase_Variable = 1;
+   │                 ━━━━━━━━━━━━━━━━━━ help: consider using: `mixedCaseVariable`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/mixed-case-variable
+
+note[mixed-case-variable]: mutable variable name is not `mixedCase`
+   ╭▸ ROOT/testdata/Keccak256.sol:LL:CC
+   │
+LL │         uint256 Another_MixedCase = 2;
+   │                 ━━━━━━━━━━━━━━━━━ help: consider using: `anotherMixedCase`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/mixed-case-variable
+
+note[mixed-case-variable]: mutable variable name is not `mixedCase`
+   ╭▸ ROOT/testdata/Keccak256.sol:LL:CC
+   │
+LL │         uint256 YetAnother_MixedCase = 3;
+   │                 ━━━━━━━━━━━━━━━━━━━━ help: consider using: `yetAnotherMixedCase`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/mixed-case-variable
+
+note[mixed-case-variable]: mutable variable name is not `mixedCase`
+   ╭▸ ROOT/testdata/Keccak256.sol:LL:CC
+   │
+LL │     uint256 Enabled_MixedCase_Variable;
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━ help: consider using: `enabledMixedCaseVariable`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/mixed-case-variable
+
+note[mixed-case-variable]: mutable variable name is not `mixedCase`
+   ╭▸ ROOT/testdata/Keccak256.sol:LL:CC
+   │
+LL │         uint256 Enabled_MixedCase_Variable = 1;
+   │                 ━━━━━━━━━━━━━━━━━━━━━━━━━━ help: consider using: `enabledMixedCaseVariable`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/mixed-case-variable
+
+note[multi-contract-file]: file contains multiple contracts, interfaces or libraries
+   ╭▸ ROOT/testdata/Keccak256.sol:LL:CC
+   │
+LL │ contract AsmKeccak256 {
+   │          ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/multi-contract-file
+
+note[multi-contract-file]: file contains multiple contracts, interfaces or libraries
+   ╭▸ ROOT/testdata/Keccak256.sol:LL:CC
+   │
+LL │ contract OtherAsmKeccak256 {
+   │          ━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/multi-contract-file
+
+note[multi-contract-file]: file contains multiple contracts, interfaces or libraries
+   ╭▸ ROOT/testdata/Keccak256.sol:LL:CC
+   │
+LL │ contract YetAnotherAsmKeccak256 {
+   │          ━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/multi-contract-file
+
+note[asm-keccak256]: high-level `keccak256` call is a candidate for gas optimization
+   ╭▸ ROOT/testdata/Keccak256.sol:LL:CC
+   │
+LL │         bytes32 hash = keccak256(abi.encodePacked(a, b, bytes32(bytes20(c))));
+   │                        ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/asm-keccak256
+
+note[asm-keccak256]: high-level `keccak256` call is a candidate for gas optimization
+   ╭▸ ROOT/testdata/Keccak256.sol:LL:CC
+   │
+LL │         bytes32 afterDisabledBlock = keccak256(abi.encode(a, b, c));
+   │                                      ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/asm-keccak256
+
+note[asm-keccak256]: high-level `keccak256` call is a candidate for gas optimization
+   ╭▸ ROOT/testdata/Keccak256.sol:LL:CC
+   │
+LL │         bytes32 loadsFromCalldata = keccak256(z);
+   │                                     ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/asm-keccak256
+
+note[asm-keccak256]: high-level `keccak256` call is a candidate for gas optimization
+   ╭▸ ROOT/testdata/Keccak256.sol:LL:CC
+   │
+LL │         bytes32 loadsFromMemory = keccak256(y);
+   │                                   ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/asm-keccak256
+
+note[asm-keccak256]: high-level `keccak256` call is a candidate for gas optimization
+   ╭▸ ROOT/testdata/Keccak256.sol:LL:CC
+   │
+LL │         bytes32 lintWithoutFix = keccak256(abi.encodePacked(a, b, c));
+   │                                  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/asm-keccak256
+
+note[asm-keccak256]: high-level `keccak256` call is a candidate for gas optimization
+   ╭▸ ROOT/testdata/Keccak256.sol:LL:CC
+   │
+LL │         return keccak256(abi.encode(a, b, c));
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/asm-keccak256
+
+note[unused-state-variables]: state variable is never used
+   ╭▸ ROOT/testdata/Keccak256.sol:LL:CC
+   │
+LL │     uint256 Enabled_MixedCase_Variable;
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unused-state-variables
+
+note[asm-keccak256]: high-level `keccak256` call is a candidate for gas optimization
+   ╭▸ ROOT/testdata/Keccak256.sol:LL:CC
+   │
+LL │         bytes32 doesNotUseScratchSpace = keccak256(abi.encode(x, y, x, y, x, y));
+   │                                          ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/asm-keccak256
+
+note[asm-keccak256]: high-level `keccak256` call is a candidate for gas optimization
+   ╭▸ ROOT/testdata/Keccak256.sol:LL:CC
+   │
+LL │         bytes32 doesUseScratchSpace = keccak256(abi.encode(x));
+   │                                       ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/asm-keccak256
+
+note[asm-keccak256]: high-level `keccak256` call is a candidate for gas optimization
+   ╭▸ ROOT/testdata/Keccak256.sol:LL:CC
+   │
+LL │         return keccak256(abi.encode(doesUseScratchSpace, doesNotUseScratchSpace));
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/asm-keccak256
 
diff --git a/crates/lint/testdata/LiteralInsteadOfConstant.sol b/crates/lint/testdata/LiteralInsteadOfConstant.sol
new file mode 100644
index 0000000000000..a2fde9b62fb37
--- /dev/null
+++ b/crates/lint/testdata/LiteralInsteadOfConstant.sol
@@ -0,0 +1,250 @@
+//@compile-flags: --only-lint literal-instead-of-constant
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+// Tests for `literal-instead-of-constant`: a literal value appearing more than once in the
+// executable bodies a contract declares (functions, constructors, modifiers) should be a
+// named constant. Grouping compares
+// the semantic value, so `100`, `0x64` and `1e2` are one value, and `1 ether` equals `1e18`.
+// A numeric literal under a unary minus or bitwise-not (`-5`, `~5`) is a distinct value from
+// the bare literal, so it never groups with it.
+// Out of scope: `0`, `1` and `2` (structural values), bare literals indexing an array-like
+// value, bounding a slice or giving a shift amount (positional or structural; mapping keys
+// count, they are configuration data), string and bool literals, single occurrences, and
+// repetitions split across two contracts, and literals inside Yul blocks.
+
+contract Fees {
+    uint256 internal total;
+    uint256[10] internal slots;
+
+    modifier capped() {
+        require(total < 500, "cap"); //~NOTE: this literal appears multiple times
+        _;
+    }
+
+    function addFee(uint256 x) internal capped {
+        total = x * 500; //~NOTE: this literal appears multiple times
+    }
+
+    function structuralValuesAreFine(uint256 x) internal {
+        total = (x + 0) * (x - 1) + 2;
+        total += 0 + 1 + 2;
+    }
+
+    function singleUse(uint256 x) internal {
+        total = x * 999;
+    }
+
+    function indexPositionsAreFine() internal {
+        // the bare `3` indices are positional; `4 + 3` computes an index but `3` is not bare
+        slots[3] = slots[3] + slots[4 + 3];
+    }
+
+    function hexAndDecimalAreOneValue(uint256 x) internal {
+        total = x + 0x64; //~NOTE: this literal appears multiple times
+        total += x + 100; //~NOTE: this literal appears multiple times
+    }
+
+    function etherUnitsAreEvaluated() internal {
+        total = 1 ether; //~NOTE: this literal appears multiple times
+        total += 1e18; //~NOTE: this literal appears multiple times
+    }
+
+    function repeatedAddress() internal view returns (bool) {
+        return msg.sender == 0x1111111111111111111111111111111111111111; //~NOTE: this literal appears multiple times
+    }
+
+    function sameAddressAgain(address a) internal pure returns (bool) {
+        return a == 0x1111111111111111111111111111111111111111; //~NOTE: this literal appears multiple times
+    }
+
+    function repeatedHexString() internal pure returns (bytes memory, bytes memory) {
+        return (hex"deadbeef", hex"deadbeef"); //~NOTE: this literal appears multiple times
+    }
+}
+
+// The same value used once in each of two contracts: grouping is per contract, both clean.
+contract OtherA {
+    function m(uint256 x) internal pure returns (uint256) {
+        return x * 777;
+    }
+}
+
+contract OtherB {
+    function n(uint256 x) internal pure returns (uint256) {
+        return x * 777;
+    }
+}
+
+// A value-changing unary operator makes a distinct constant: `-5`/`~5` never group with `5`.
+contract UnaryLiterals {
+    // `5` and `-5` each appear once, so neither reports. Before the fix the collector keyed the
+    // `5` inside `-5` on the bare magnitude and wrongly reported both as a repeated literal.
+    function signAndBareAreDistinct(int256 x) internal pure returns (int256) {
+        return x * 5 + (-5);
+    }
+
+    // The same negative constant used twice DOES group and reports.
+    function repeatedNegative(int256 x) internal pure returns (int256) {
+        return x * (-7) + (-7); //~NOTE: this literal appears multiple times
+    }
+
+    // The same bitwise-not constant used twice DOES group and reports.
+    function repeatedBitNot(int256 x) internal pure returns (int256) {
+        return (x + ~9) * ~9; //~NOTE: this literal appears multiple times
+    }
+}
+
+// Nested unary operators (`-(-5)`, `~~5`) are not canonicalized, so they never group with a bare
+// literal or a single-unary literal: no false positive even when `-5` also appears once.
+contract NestedUnary {
+    function nestedIsNotGrouped(int256 x) internal pure returns (int256) {
+        return x * (-5) + (-(-5));
+    }
+}
+
+// Skipping a nested-unary chain only applies when the inner operand is itself a literal:
+// literals inside a non-literal operand are still recorded with their own value.
+contract NestedUnaryOperands {
+    int256 internal acc;
+    uint256 internal flags;
+
+    function insideNegation(int256 x) internal {
+        acc = -(-(x + 500)); //~NOTE: this literal appears multiple times
+        acc += 500; //~NOTE: this literal appears multiple times
+    }
+
+    function insideBitNot(uint256 f) internal {
+        flags = ~~(f & 0xff) + 0xff; //~NOTE: this literal appears multiple times
+    }
+}
+
+// A mapping key is configuration data, not a position: repeated keys report, while array
+// indexing and slice bounds stay positional even when repeated.
+contract MappingKeys {
+    mapping(uint256 => uint256) internal m;
+    mapping(address => uint256) internal balances;
+    uint256[10] internal arr;
+
+    function repeatedKeys() internal {
+        m[500] = 1; //~NOTE: this literal appears multiple times
+        m[500] = 2; //~NOTE: this literal appears multiple times
+        balances[0x2222222222222222222222222222222222222222] = 1; //~NOTE: this literal appears multiple times
+        balances[0x2222222222222222222222222222222222222222] = 2; //~NOTE: this literal appears multiple times
+    }
+
+    function positionsStayClean(bytes calldata d) internal view returns (uint256) {
+        uint256 s = arr[3] + arr[3];
+        bytes calldata cut = d[555:600];
+        bytes calldata cut2 = d[555:600];
+        return s + cut.length + cut2.length;
+    }
+}
+
+// Boundary interactions of the index and slice exemptions with the other rules.
+contract IndexEdgeCases {
+    mapping(uint256 => mapping(uint256 => uint256)) internal mm;
+    mapping(int256 => uint256) internal signedKeys;
+
+    // Both levels of a nested mapping lookup are keys: each repeated level reports.
+    function nestedMappingKeys() internal {
+        mm[500][600] = 1; //~NOTE: this literal appears multiple times
+        mm[500][600] = 2; //~NOTE: this literal appears multiple times
+    }
+
+    // A negative mapping key records under its operator-qualified value, so it groups with
+    // other `-5` uses and never with a bare `5`.
+    function negativeKeys() internal {
+        signedKeys[-5] = 1; //~NOTE: this literal appears multiple times
+        signedKeys[-5] = 2; //~NOTE: this literal appears multiple times
+    }
+
+    // A computed slice bound is not a bare literal: the literals inside it are recorded,
+    // consistent with `4 + 3` computing an array index.
+    function computedBound(bytes calldata d, uint256 x) internal pure returns (uint256) {
+        bytes calldata cut = d[x + 555:600]; //~NOTE: this literal appears multiple times
+        return cut.length + 555; //~NOTE: this literal appears multiple times
+    }
+}
+
+// Yul literals are structural more often than they are magic values. They neither group with
+// each other nor with the same value in Solidity.
+contract YulLiterals {
+    uint256 internal y;
+
+    function labels(uint256 x) internal {
+        uint256 r;
+        assembly {
+            mstore(mload(0x40), 500)
+            switch x
+            case 500 { r := 1 }
+            default { r := 0 }
+        }
+        y = r + 500;
+    }
+}
+
+// Bare literal shift amounts are structural, like array positions: repeated amounts stay
+// clean, a shifted literal value still counts, and a computed amount is walked so the
+// literals inside it are recorded.
+contract ShiftAmounts {
+    uint256 internal acc;
+
+    function amountsAreStructural(uint256 x) internal {
+        acc = (x << 128) + (x >> 128);
+        acc >>= 128;
+    }
+
+    function shiftedValueStillCounts(uint256 n) internal {
+        acc = (500 << n) + 500; //~NOTE: this literal appears multiple times
+    }
+
+    function computedAmount(uint256 x, uint256 n) internal {
+        acc = x << (n + 700); //~NOTE: this literal appears multiple times
+        acc += 700; //~NOTE: this literal appears multiple times
+    }
+}
+
+contract Capped {
+    constructor(uint256 cap) {}
+}
+
+// A value repeated between a header (a base-constructor or modifier argument) and a body is a
+// repetition too: the header occurrence is counted, not only the body one.
+contract HeaderLiterals is Capped {
+    uint256 internal x;
+
+    modifier withLimit(uint256 limit) {
+        _;
+    }
+
+    constructor() Capped(4242) { //~NOTE: this literal appears multiple times
+        x = 4242; //~NOTE: this literal appears multiple times
+    }
+
+    function f() internal withLimit(8888) { //~NOTE: this literal appears multiple times
+        x = 8888; //~NOTE: this literal appears multiple times
+    }
+}
+
+// A fixed array size in a parameter or return type is a type annotation, not an executable
+// expression: repeated sizes across signatures stay clean, and one repeated between a
+// signature and a body does not group with it either.
+contract SignatureArraySizes {
+    uint256 internal y;
+
+    function a(uint256[365] calldata v) internal {
+        y = v[0];
+    }
+
+    function b(uint256[365] calldata v) internal {
+        y = v[1];
+    }
+
+    // The single body occurrence does not group with the sizes of the signatures either.
+    function c(uint256[365] calldata v) internal {
+        y = v[0] + 365;
+    }
+
+    function d() internal pure returns (uint256[365] memory r) {}
+}
diff --git a/crates/lint/testdata/LiteralInsteadOfConstant.stderr b/crates/lint/testdata/LiteralInsteadOfConstant.stderr
new file mode 100644
index 0000000000000..fd367851fc525
--- /dev/null
+++ b/crates/lint/testdata/LiteralInsteadOfConstant.stderr
@@ -0,0 +1,304 @@
+note[literal-instead-of-constant]: this literal appears multiple times in the contract
+   ╭▸ ROOT/testdata/LiteralInsteadOfConstant.sol:LL:CC
+   │
+LL │         require(total < 500, "cap");
+   │                         ━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/literal-instead-of-constant
+
+note[literal-instead-of-constant]: this literal appears multiple times in the contract
+   ╭▸ ROOT/testdata/LiteralInsteadOfConstant.sol:LL:CC
+   │
+LL │         total = x * 500;
+   │                     ━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/literal-instead-of-constant
+
+note[literal-instead-of-constant]: this literal appears multiple times in the contract
+   ╭▸ ROOT/testdata/LiteralInsteadOfConstant.sol:LL:CC
+   │
+LL │         total = x + 0x64;
+   │                     ━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/literal-instead-of-constant
+
+note[literal-instead-of-constant]: this literal appears multiple times in the contract
+   ╭▸ ROOT/testdata/LiteralInsteadOfConstant.sol:LL:CC
+   │
+LL │         total += x + 100;
+   │                      ━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/literal-instead-of-constant
+
+note[literal-instead-of-constant]: this literal appears multiple times in the contract
+   ╭▸ ROOT/testdata/LiteralInsteadOfConstant.sol:LL:CC
+   │
+LL │         total = 1 ether;
+   │                 ━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/literal-instead-of-constant
+
+note[literal-instead-of-constant]: this literal appears multiple times in the contract
+   ╭▸ ROOT/testdata/LiteralInsteadOfConstant.sol:LL:CC
+   │
+LL │         total += 1e18;
+   │                  ━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/literal-instead-of-constant
+
+note[literal-instead-of-constant]: this literal appears multiple times in the contract
+   ╭▸ ROOT/testdata/LiteralInsteadOfConstant.sol:LL:CC
+   │
+LL │         return msg.sender == 0x1111111111111111111111111111111111111111;
+   │                              ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/literal-instead-of-constant
+
+note[literal-instead-of-constant]: this literal appears multiple times in the contract
+   ╭▸ ROOT/testdata/LiteralInsteadOfConstant.sol:LL:CC
+   │
+LL │         return a == 0x1111111111111111111111111111111111111111;
+   │                     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/literal-instead-of-constant
+
+note[literal-instead-of-constant]: this literal appears multiple times in the contract
+   ╭▸ ROOT/testdata/LiteralInsteadOfConstant.sol:LL:CC
+   │
+LL │         return (hex"deadbeef", hex"deadbeef");
+   │                 ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/literal-instead-of-constant
+
+note[literal-instead-of-constant]: this literal appears multiple times in the contract
+   ╭▸ ROOT/testdata/LiteralInsteadOfConstant.sol:LL:CC
+   │
+LL │         return (hex"deadbeef", hex"deadbeef");
+   │                                ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/literal-instead-of-constant
+
+note[literal-instead-of-constant]: this literal appears multiple times in the contract
+   ╭▸ ROOT/testdata/LiteralInsteadOfConstant.sol:LL:CC
+   │
+LL │         return x * (-7) + (-7);
+   │                     ━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/literal-instead-of-constant
+
+note[literal-instead-of-constant]: this literal appears multiple times in the contract
+   ╭▸ ROOT/testdata/LiteralInsteadOfConstant.sol:LL:CC
+   │
+LL │         return x * (-7) + (-7);
+   │                            ━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/literal-instead-of-constant
+
+note[literal-instead-of-constant]: this literal appears multiple times in the contract
+   ╭▸ ROOT/testdata/LiteralInsteadOfConstant.sol:LL:CC
+   │
+LL │         return (x + ~9) * ~9;
+   │                     ━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/literal-instead-of-constant
+
+note[literal-instead-of-constant]: this literal appears multiple times in the contract
+   ╭▸ ROOT/testdata/LiteralInsteadOfConstant.sol:LL:CC
+   │
+LL │         return (x + ~9) * ~9;
+   │                           ━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/literal-instead-of-constant
+
+note[literal-instead-of-constant]: this literal appears multiple times in the contract
+   ╭▸ ROOT/testdata/LiteralInsteadOfConstant.sol:LL:CC
+   │
+LL │         acc = -(-(x + 500));
+   │                       ━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/literal-instead-of-constant
+
+note[literal-instead-of-constant]: this literal appears multiple times in the contract
+   ╭▸ ROOT/testdata/LiteralInsteadOfConstant.sol:LL:CC
+   │
+LL │         acc += 500;
+   │                ━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/literal-instead-of-constant
+
+note[literal-instead-of-constant]: this literal appears multiple times in the contract
+   ╭▸ ROOT/testdata/LiteralInsteadOfConstant.sol:LL:CC
+   │
+LL │         flags = ~~(f & 0xff) + 0xff;
+   │                        ━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/literal-instead-of-constant
+
+note[literal-instead-of-constant]: this literal appears multiple times in the contract
+   ╭▸ ROOT/testdata/LiteralInsteadOfConstant.sol:LL:CC
+   │
+LL │         flags = ~~(f & 0xff) + 0xff;
+   │                                ━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/literal-instead-of-constant
+
+note[literal-instead-of-constant]: this literal appears multiple times in the contract
+   ╭▸ ROOT/testdata/LiteralInsteadOfConstant.sol:LL:CC
+   │
+LL │         m[500] = 1;
+   │           ━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/literal-instead-of-constant
+
+note[literal-instead-of-constant]: this literal appears multiple times in the contract
+   ╭▸ ROOT/testdata/LiteralInsteadOfConstant.sol:LL:CC
+   │
+LL │         m[500] = 2;
+   │           ━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/literal-instead-of-constant
+
+note[literal-instead-of-constant]: this literal appears multiple times in the contract
+   ╭▸ ROOT/testdata/LiteralInsteadOfConstant.sol:LL:CC
+   │
+LL │         balances[0x2222222222222222222222222222222222222222] = 1;
+   │                  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/literal-instead-of-constant
+
+note[literal-instead-of-constant]: this literal appears multiple times in the contract
+   ╭▸ ROOT/testdata/LiteralInsteadOfConstant.sol:LL:CC
+   │
+LL │         balances[0x2222222222222222222222222222222222222222] = 2;
+   │                  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/literal-instead-of-constant
+
+note[literal-instead-of-constant]: this literal appears multiple times in the contract
+   ╭▸ ROOT/testdata/LiteralInsteadOfConstant.sol:LL:CC
+   │
+LL │         mm[500][600] = 1;
+   │            ━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/literal-instead-of-constant
+
+note[literal-instead-of-constant]: this literal appears multiple times in the contract
+   ╭▸ ROOT/testdata/LiteralInsteadOfConstant.sol:LL:CC
+   │
+LL │         mm[500][600] = 1;
+   │                 ━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/literal-instead-of-constant
+
+note[literal-instead-of-constant]: this literal appears multiple times in the contract
+   ╭▸ ROOT/testdata/LiteralInsteadOfConstant.sol:LL:CC
+   │
+LL │         mm[500][600] = 2;
+   │            ━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/literal-instead-of-constant
+
+note[literal-instead-of-constant]: this literal appears multiple times in the contract
+   ╭▸ ROOT/testdata/LiteralInsteadOfConstant.sol:LL:CC
+   │
+LL │         mm[500][600] = 2;
+   │                 ━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/literal-instead-of-constant
+
+note[literal-instead-of-constant]: this literal appears multiple times in the contract
+   ╭▸ ROOT/testdata/LiteralInsteadOfConstant.sol:LL:CC
+   │
+LL │         signedKeys[-5] = 1;
+   │                    ━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/literal-instead-of-constant
+
+note[literal-instead-of-constant]: this literal appears multiple times in the contract
+   ╭▸ ROOT/testdata/LiteralInsteadOfConstant.sol:LL:CC
+   │
+LL │         signedKeys[-5] = 2;
+   │                    ━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/literal-instead-of-constant
+
+note[literal-instead-of-constant]: this literal appears multiple times in the contract
+   ╭▸ ROOT/testdata/LiteralInsteadOfConstant.sol:LL:CC
+   │
+LL │         bytes calldata cut = d[x + 555:600];
+   │                                    ━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/literal-instead-of-constant
+
+note[literal-instead-of-constant]: this literal appears multiple times in the contract
+   ╭▸ ROOT/testdata/LiteralInsteadOfConstant.sol:LL:CC
+   │
+LL │         return cut.length + 555;
+   │                             ━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/literal-instead-of-constant
+
+note[literal-instead-of-constant]: this literal appears multiple times in the contract
+   ╭▸ ROOT/testdata/LiteralInsteadOfConstant.sol:LL:CC
+   │
+LL │         acc = (500 << n) + 500;
+   │                ━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/literal-instead-of-constant
+
+note[literal-instead-of-constant]: this literal appears multiple times in the contract
+   ╭▸ ROOT/testdata/LiteralInsteadOfConstant.sol:LL:CC
+   │
+LL │         acc = (500 << n) + 500;
+   │                            ━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/literal-instead-of-constant
+
+note[literal-instead-of-constant]: this literal appears multiple times in the contract
+   ╭▸ ROOT/testdata/LiteralInsteadOfConstant.sol:LL:CC
+   │
+LL │         acc = x << (n + 700);
+   │                         ━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/literal-instead-of-constant
+
+note[literal-instead-of-constant]: this literal appears multiple times in the contract
+   ╭▸ ROOT/testdata/LiteralInsteadOfConstant.sol:LL:CC
+   │
+LL │         acc += 700;
+   │                ━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/literal-instead-of-constant
+
+note[literal-instead-of-constant]: this literal appears multiple times in the contract
+   ╭▸ ROOT/testdata/LiteralInsteadOfConstant.sol:LL:CC
+   │
+LL │     constructor() Capped(4242) {
+   │                          ━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/literal-instead-of-constant
+
+note[literal-instead-of-constant]: this literal appears multiple times in the contract
+   ╭▸ ROOT/testdata/LiteralInsteadOfConstant.sol:LL:CC
+   │
+LL │         x = 4242;
+   │             ━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/literal-instead-of-constant
+
+note[literal-instead-of-constant]: this literal appears multiple times in the contract
+   ╭▸ ROOT/testdata/LiteralInsteadOfConstant.sol:LL:CC
+   │
+LL │     function f() internal withLimit(8888) {
+   │                                     ━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/literal-instead-of-constant
+
+note[literal-instead-of-constant]: this literal appears multiple times in the contract
+   ╭▸ ROOT/testdata/LiteralInsteadOfConstant.sol:LL:CC
+   │
+LL │         x = 8888;
+   │             ━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/literal-instead-of-constant
+
diff --git a/crates/lint/testdata/LockedEther.sol b/crates/lint/testdata/LockedEther.sol
new file mode 100644
index 0000000000000..00d329f07efda
--- /dev/null
+++ b/crates/lint/testdata/LockedEther.sol
@@ -0,0 +1,734 @@
+//@compile-flags: --only-lint locked-ether
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.20;
+
+interface IERC20 {
+    function transfer(address, uint256) external returns (bool);
+}
+
+interface IOneArgTransfer {
+    function transfer(uint256 amount) external;
+    function send(uint256 amount) external;
+}
+
+// SHOULD FAIL:
+
+contract LockedReceive { //~WARN: contract can receive ETH but has no mechanism to send it out
+    receive() external payable {}
+}
+
+contract LockedFallback { //~WARN: contract can receive ETH but has no mechanism to send it out
+    fallback() external payable {}
+}
+
+contract LockedPayableFn { //~WARN: contract can receive ETH but has no mechanism to send it out
+    function deposit() external payable {}
+}
+
+contract LockedPayableCtor { //~WARN: contract can receive ETH but has no mechanism to send it out
+    constructor() payable {}
+}
+
+contract LockedZeroValue { //~WARN: contract can receive ETH but has no mechanism to send it out
+    function deposit() external payable {}
+
+    function noop(address payable to) external {
+        to.transfer(0);
+        bool ok = to.send(0);
+        ok;
+        (bool s,) = to.call{value: 0}("");
+        s;
+    }
+}
+
+contract LockedTokenOnly { //~WARN: contract can receive ETH but has no mechanism to send it out
+    function deposit() external payable {}
+
+    function rescueToken(address token, address to, uint256 amount) external {
+        IERC20(token).transfer(to, amount);
+    }
+}
+
+contract LockedOneArgTokenTransfer { //~WARN: contract can receive ETH but has no mechanism to send it out
+    IOneArgTransfer token;
+
+    function deposit() external payable {}
+
+    function pay(uint256 amount) external {
+        token.transfer(amount);
+        token.send(amount);
+    }
+}
+
+contract Helper {
+    function pay(address payable to, uint256 amount) external {
+        to.transfer(amount);
+    }
+}
+
+contract LockedNotInherited { //~WARN: contract can receive ETH but has no mechanism to send it out
+    function deposit() external payable {}
+}
+
+contract Child { //~WARN: contract can receive ETH but has no mechanism to send it out
+    constructor() payable {}
+}
+
+// Unreachable internal helpers don't count as exits.
+contract LockedUnreachableInternal { //~WARN: contract can receive ETH but has no mechanism to send it out
+    receive() external payable {}
+
+    function _sweep(address payable to, uint256 amount) internal {
+        to.transfer(amount);
+    }
+}
+
+contract LockedUnreachablePrivate { //~WARN: contract can receive ETH but has no mechanism to send it out
+    function deposit() external payable {}
+
+    function _drain(address payable to) private {
+        selfdestruct(to);
+    }
+}
+
+// Overload resolution: the dead 0-arg `_do()` overload must not be followed.
+abstract contract OverloadBase {
+    function _do() internal {
+        payable(msg.sender).transfer(1 ether);
+    }
+    function _do(uint256) internal {}
+}
+
+contract LockedSuperOverload is OverloadBase { //~WARN: contract can receive ETH but has no mechanism to send it out
+    function deposit() external payable {}
+
+    function f() external {
+        super._do(1);
+    }
+}
+
+library OverloadLib {
+    function pay() internal {
+        payable(msg.sender).transfer(1 ether);
+    }
+    function pay(uint256) internal {}
+}
+
+contract LockedLibraryOverload { //~WARN: contract can receive ETH but has no mechanism to send it out
+    function deposit() external payable {}
+
+    function f() external {
+        OverloadLib.pay(1);
+    }
+}
+
+// Overridden base implementation is no longer in the dispatch table.
+abstract contract OverriddenBaseWithExit {
+    function withdraw() external virtual {
+        payable(msg.sender).transfer(address(this).balance);
+    }
+}
+
+contract LockedOverriddenWithdraw is OverriddenBaseWithExit { //~WARN: contract can receive ETH but has no mechanism to send it out
+    receive() external payable {}
+
+    function withdraw() external override {}
+}
+
+// Same dispatch rule for `receive`: only the most-derived one is reachable.
+abstract contract BaseReceiveWithExit {
+    receive() external payable virtual {
+        payable(msg.sender).transfer(msg.value);
+    }
+}
+
+contract LockedOverriddenReceive is BaseReceiveWithExit { //~WARN: contract can receive ETH but has no mechanism to send it out
+    receive() external payable override {}
+}
+
+// Constructor exits don't cover runtime ETH inflows.
+contract LockedCtorExitWithRuntimeReceive { //~WARN: contract can receive ETH but has no mechanism to send it out
+    constructor() payable {
+        payable(msg.sender).transfer(msg.value);
+    }
+
+    receive() external payable {}
+}
+
+// A send followed by an unconditional revert in the same function body never commits.
+contract LockedExitThenRevert { //~WARN: contract can receive ETH but has no mechanism to send it out
+    receive() external payable {}
+
+    function nope(address payable to, uint256 amount) external {
+        to.transfer(amount);
+        revert();
+    }
+}
+
+// The exit lives in an always-reverting helper, so it can never run on-chain.
+contract LockedExitInRevertingHelper { //~WARN: contract can receive ETH but has no mechanism to send it out
+    receive() external payable {}
+
+    function entry(address payable to, uint256 amount) external {
+        _send(to, amount);
+    }
+
+    function _send(address payable to, uint256 amount) internal {
+        to.transfer(amount);
+        revert();
+    }
+}
+
+// SHOULD PASS:
+
+contract OkTransfer {
+    function deposit() external payable {}
+
+    function withdraw(address payable to, uint256 amount) external {
+        to.transfer(amount);
+    }
+}
+
+contract OkSend {
+    receive() external payable {}
+
+    function withdraw(address payable to, uint256 amount) external {
+        bool ok = to.send(amount);
+        require(ok);
+    }
+}
+
+contract OkCallWithValue {
+    function deposit() external payable {}
+
+    function withdraw(address to, uint256 amount) external {
+        (bool ok,) = to.call{value: amount}("");
+        require(ok);
+    }
+}
+
+contract OkSelfdestruct {
+    function deposit() external payable {}
+
+    function close(address payable to) external {
+        selfdestruct(to);
+    }
+}
+
+contract OkNewWithValue {
+    function deposit() external payable {}
+
+    // OK even though `Child` is itself flagged: `new C{value: x}()` is the exit here.
+    function spawn(uint256 amount) external {
+        new Child{value: amount}();
+    }
+}
+
+abstract contract Withdrawable {
+    function _withdraw(address payable to, uint256 amount) internal {
+        to.transfer(amount);
+    }
+}
+
+contract OkInheritedWithdraw is Withdrawable {
+    function deposit() external payable {}
+
+    function withdraw(address payable to, uint256 amount) external {
+        _withdraw(to, amount);
+    }
+}
+
+contract OkTransitive {
+    function deposit() external payable {}
+
+    function withdraw(address payable to, uint256 amount) external {
+        _doSend(to, amount);
+    }
+
+    function _doSend(address payable to, uint256 amount) internal {
+        to.transfer(amount);
+    }
+}
+
+// `this.(...)` member-call dispatch.
+contract OkExternalSelfCall {
+    function deposit() external payable {}
+
+    function withdraw(address payable to, uint256 amount) external {
+        this._doSend(to, amount);
+    }
+
+    function _doSend(address payable to, uint256 amount) external {
+        to.transfer(amount);
+    }
+}
+
+// Inline assembly: opaque, bails when reached.
+contract OkAssemblyExit {
+    function deposit() external payable {}
+
+    function withdraw(address to, uint256 amount) external {
+        assembly {
+            let ok := call(gas(), to, amount, 0, 0, 0, 0)
+            if iszero(ok) { revert(0, 0) }
+        }
+    }
+}
+
+contract OkAssemblySelfdestruct {
+    function deposit() external payable {}
+
+    function close(address to) external {
+        assembly {
+            selfdestruct(to)
+        }
+    }
+}
+
+contract OkDelegatecall {
+    function deposit() external payable {}
+
+    function delegate(address impl, bytes calldata data) external {
+        (bool ok,) = impl.delegatecall(data);
+        require(ok);
+    }
+}
+
+// Payable surface that always reverts.
+contract OkReceiveAlwaysReverts {
+    receive() external payable {
+        revert();
+    }
+}
+
+contract OkFallbackAlwaysReverts {
+    fallback() external payable {
+        revert("disabled");
+    }
+}
+
+contract OkPayableFnAlwaysReverts {
+    function deposit() external payable {
+        revert("disabled");
+    }
+}
+
+contract OkPayableRequireFalse {
+    function deposit() external payable {
+        require(false, "disabled");
+    }
+}
+
+contract OkPayableAssertFalse {
+    function deposit() external payable {
+        assert(false);
+    }
+}
+
+// Modifier always reverts before `_`.
+contract OkPayableModifierReverts {
+    modifier disabled() {
+        revert("disabled");
+        _;
+    }
+
+    function deposit() external payable disabled {}
+}
+
+// Modifier always reverts after `_`.
+contract OkPayableModifierRevertsAfter {
+    modifier disabledAfter() {
+        _;
+        revert("disabled");
+    }
+
+    function deposit() external payable disabledAfter {}
+}
+
+// `super.(...)` member-call dispatch.
+abstract contract SuperWithdrawBase {
+    function _doSend(address payable to, uint256 amount) internal {
+        to.transfer(amount);
+    }
+}
+
+contract OkSuperCall is SuperWithdrawBase {
+    function deposit() external payable {}
+
+    function withdraw(address payable to, uint256 amount) external {
+        super._doSend(to, amount);
+    }
+}
+
+// `super.(...)` whose call site lives in a base contract: resolution must use the
+// call-site contract's own linearization, otherwise it walks past the real exit in
+// `LeafSuperExit` and hits `BaseSuperCallSite`'s empty override instead.
+abstract contract LeafSuperExit {
+    function _exit(address payable to) internal virtual {
+        to.transfer(address(this).balance);
+    }
+}
+
+abstract contract BaseSuperCallSite is LeafSuperExit {
+    function _exit(address payable) internal virtual override {}
+
+    function withdraw(address payable to) external {
+        super._exit(to);
+    }
+}
+
+contract OkSuperFromBase is BaseSuperCallSite {
+    receive() external payable {}
+}
+
+// Same-arity overloads differing only by parameter type. Dispatch must pick the
+// `uint256` variant; following the `address payable` overload would let its
+// unrelated exit silence the lint.
+abstract contract OverloadByType {
+    function _send(uint256) internal {}
+    function _send(address payable to) internal {
+        to.transfer(1 wei);
+    }
+}
+
+contract LockedSameArityOverload is OverloadByType { //~WARN: contract can receive ETH but has no mechanism to send it out
+    receive() external payable {}
+
+    function withdraw(uint256 amount) external {
+        _send(amount);
+    }
+}
+
+// Same-arity overloads disambiguated by named-arg parameter set.
+contract OkNamedArgOverload {
+    function deposit() external payable {}
+
+    function _send(uint256, address payable) internal {}
+    function _send(address payable to, uint256 amount) internal {
+        to.transfer(amount);
+    }
+
+    function withdraw(address payable to, uint256 amount) external {
+        _send({to: to, amount: amount});
+    }
+}
+
+// Self-sends keep ETH inside the contract; they must not count as exits.
+contract LockedThisCallWithValue { //~WARN: contract can receive ETH but has no mechanism to send it out
+    receive() external payable {}
+
+    function deposit() external payable {}
+
+    function loop(uint256 x) external {
+        this.deposit{value: x}();
+    }
+}
+
+contract LockedAddressThisCallWithValue { //~WARN: contract can receive ETH but has no mechanism to send it out
+    receive() external payable {}
+
+    function loop(uint256 x) external {
+        (bool ok,) = address(this).call{value: x}("");
+        require(ok);
+    }
+}
+
+contract LockedTransferToSelf { //~WARN: contract can receive ETH but has no mechanism to send it out
+    receive() external payable {}
+
+    function loop(uint256 x) external {
+        payable(address(this)).transfer(x);
+    }
+}
+
+contract LockedSelfdestructToSelf { //~WARN: contract can receive ETH but has no mechanism to send it out
+    receive() external payable {}
+
+    function nuke() external {
+        selfdestruct(payable(address(this)));
+    }
+}
+
+// Receivers reached through struct fields, mapping/array elements, function returns,
+// and ternaries are valid `address payable` values and must be recognized as exits.
+contract OkStructFieldReceiver {
+    struct Vault {
+        address payable owner;
+    }
+
+    Vault v;
+
+    function deposit() external payable {}
+
+    function withdraw(uint256 x) external {
+        v.owner.transfer(x);
+    }
+}
+
+contract OkMappingReceiver {
+    mapping(uint256 => address payable) recipients;
+
+    function deposit() external payable {}
+
+    function withdraw(uint256 i, uint256 x) external {
+        recipients[i].transfer(x);
+    }
+}
+
+contract OkArrayReceiver {
+    address payable[] recipients;
+
+    function deposit() external payable {}
+
+    function withdraw(uint256 i, uint256 x) external {
+        recipients[i].transfer(x);
+    }
+}
+
+contract OkReturnedAddressReceiver {
+    address payable treasury;
+
+    function deposit() external payable {}
+
+    function _getTreasury() internal view returns (address payable) {
+        return treasury;
+    }
+
+    function withdraw(uint256 x) external {
+        _getTreasury().transfer(x);
+    }
+}
+
+contract OkTernaryReceiver {
+    address payable a;
+    address payable b;
+
+    function deposit() external payable {}
+
+    function withdraw(bool which, uint256 x) external {
+        (which ? a : b).transfer(x);
+    }
+}
+
+// `.send` and low-level `{value:}` on compound receivers reuse the same type-inference
+// path as `.transfer`; pin both shapes to lock them in.
+contract OkMappingSend {
+    mapping(uint256 => address payable) recipients;
+
+    function deposit() external payable {}
+
+    function withdraw(uint256 i, uint256 x) external {
+        bool ok = recipients[i].send(x);
+        require(ok);
+    }
+}
+
+contract OkCallWithValueThroughReturn {
+    address payable treasury;
+
+    function deposit() external payable {}
+
+    function _treasury() internal view returns (address payable) {
+        return treasury;
+    }
+
+    function withdraw(uint256 x) external {
+        (bool ok,) = _treasury().call{value: x}("");
+        require(ok);
+    }
+}
+
+// `super.(...)` whose call site lives in a base that is part of a diamond. The
+// derived contract's linearization places `DiamondRightBranch` before
+// `DiamondLeftBranch`, so resolving `super` from the most-derived linearization would
+// hit `DiamondRightBranch`'s empty override; only the call-site contract's own
+// linearization reaches `DiamondRoot._exit`.
+abstract contract DiamondRoot {
+    function _exit(address payable to) internal virtual {
+        to.transfer(1 wei);
+    }
+}
+
+abstract contract DiamondLeftBranch is DiamondRoot {
+    function _exit(address payable to) internal virtual override {
+        super._exit(to);
+    }
+}
+
+abstract contract DiamondRightBranch is DiamondRoot {
+    function _exit(address payable to) internal virtual override {}
+}
+
+contract OkDiamondSuperFromBase is DiamondLeftBranch, DiamondRightBranch {
+    receive() external payable {}
+
+    function _exit(address payable to)
+        internal
+        override(DiamondLeftBranch, DiamondRightBranch)
+    {
+        DiamondLeftBranch._exit(to);
+    }
+
+    function withdraw(address payable to) external {
+        _exit(to);
+    }
+}
+
+// `using for` and function-pointer dispatch route through bindings Solar's HIR doesn't
+// expose; treat those calls as opaque so the lint doesn't flag valid withdraw paths.
+library SendViaLib {
+    function sweep(address payable to, uint256 amount) internal {
+        to.transfer(amount);
+    }
+}
+
+contract OkUsingFor {
+    using SendViaLib for address payable;
+
+    function deposit() external payable {}
+
+    function withdraw(address payable to, uint256 x) external {
+        to.sweep(x);
+    }
+}
+
+contract OkFunctionPointer {
+    function _doSend(address payable to, uint256 amount) internal {
+        to.transfer(amount);
+    }
+
+    function(address payable, uint256) internal sender = _doSend;
+
+    function deposit() external payable {}
+
+    function withdraw(address payable to, uint256 x) external {
+        sender(to, x);
+    }
+}
+
+// Receiver produced by a method call on a contract-typed value: the inner call's return
+// type must propagate so `.transfer` on it is recognized as an exit.
+contract Registry {
+    address payable private _owner;
+    function getOwner() external view returns (address payable) { return _owner; }
+}
+
+contract OkMemberCallReturnReceiver {
+    Registry registry;
+
+    function deposit() external payable {}
+
+    function withdraw(uint256 x) external {
+        registry.getOwner().transfer(x);
+    }
+}
+
+// `Lib.(...)` member-call dispatch.
+library SendLib {
+    function pay(address payable to, uint256 amount) internal {
+        to.transfer(amount);
+    }
+}
+
+contract OkLibraryCall {
+    function deposit() external payable {}
+
+    function withdraw(address payable to, uint256 amount) external {
+        SendLib.pay(to, amount);
+    }
+}
+
+// Nested library helpers should not be rebound through same-named functions on the
+// consuming contract.
+library SendLibNested {
+    function pay(address payable to, uint256 amount) internal {
+        _send(to, amount);
+    }
+
+    function _send(address payable to, uint256 amount) internal {
+        to.transfer(amount);
+    }
+}
+
+contract OkNestedLibraryCall {
+    function deposit() external payable {}
+
+    function withdraw(address payable to, uint256 amount) external {
+        SendLibNested.pay(to, amount);
+    }
+
+    function _send(address payable, uint256) internal {}
+}
+
+contract OkReferenceTypeArg {
+    function deposit() external payable {}
+
+    function withdraw(address payable to, uint256[] memory amounts) external {
+        _send(to, amounts);
+    }
+
+    function _send(address payable to, uint256[] memory) internal {
+        to.transfer(1 wei);
+    }
+}
+
+// A non-payable derived ctor rejects deployment value even if a base ctor is payable.
+abstract contract PayableBaseCtor { //~WARN: contract can receive ETH but has no mechanism to send it out
+    constructor() payable {}
+}
+
+contract OkNonPayableChildOfPayableBase is PayableBaseCtor {
+    constructor() PayableBaseCtor() {}
+}
+
+// No explicit ctor: synthesized non-payable rejects deployment value.
+contract OkImplicitCtorOfPayableBase is PayableBaseCtor {}
+
+// Inherited external follows a virtual internal hook; the leaf override removes the exit.
+abstract contract VirtualExitBase {
+    function withdraw(address payable to) external { _exit(to); }
+    function _exit(address payable to) internal virtual { to.transfer(1 wei); }
+}
+
+contract LockedOverriddenVirtualHook is VirtualExitBase { //~WARN: contract can receive ETH but has no mechanism to send it out
+    receive() external payable {}
+    function _exit(address payable) internal override {}
+}
+
+// A `return` on one branch keeps the function reachable even if the other branch reverts,
+// so the payable inflow still counts and the contract is locked.
+contract LockedReceiveReturnBeforeRevert { //~WARN: contract can receive ETH but has no mechanism to send it out
+    bool open;
+    receive() external payable {
+        if (open) return;
+        revert();
+    }
+}
+
+// `{value:...}` on a contract/interface cast of self stays in this contract; not an exit.
+interface ISelfSink { function deposit() external payable; }
+
+contract LockedSelfSendViaInterfaceCast is ISelfSink { //~WARN: contract can receive ETH but has no mechanism to send it out
+    receive() external payable {}
+    function deposit() external payable override {}
+    function loop(uint256 x) external {
+        ISelfSink(address(this)).deposit{value: x}();
+    }
+}
+
+contract NotPayable {
+    function ping() external pure returns (bool) {
+        return true;
+    }
+}
+
+library OkLib {
+    function helper() internal pure returns (uint256) {
+        return 1;
+    }
+}
+
+interface IOk {
+    function payme() external payable;
+}
diff --git a/crates/lint/testdata/LockedEther.stderr b/crates/lint/testdata/LockedEther.stderr
new file mode 100644
index 0000000000000..ce69a0883b2b8
--- /dev/null
+++ b/crates/lint/testdata/LockedEther.stderr
@@ -0,0 +1,216 @@
+warning[locked-ether]: contract can receive ETH but has no mechanism to send it out
+   ╭▸ ROOT/testdata/LockedEther.sol:LL:CC
+   │
+LL │ contract LockedReceive {
+   │          ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/locked-ether
+
+warning[locked-ether]: contract can receive ETH but has no mechanism to send it out
+   ╭▸ ROOT/testdata/LockedEther.sol:LL:CC
+   │
+LL │ contract LockedFallback {
+   │          ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/locked-ether
+
+warning[locked-ether]: contract can receive ETH but has no mechanism to send it out
+   ╭▸ ROOT/testdata/LockedEther.sol:LL:CC
+   │
+LL │ contract LockedPayableFn {
+   │          ━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/locked-ether
+
+warning[locked-ether]: contract can receive ETH but has no mechanism to send it out
+   ╭▸ ROOT/testdata/LockedEther.sol:LL:CC
+   │
+LL │ contract LockedPayableCtor {
+   │          ━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/locked-ether
+
+warning[locked-ether]: contract can receive ETH but has no mechanism to send it out
+   ╭▸ ROOT/testdata/LockedEther.sol:LL:CC
+   │
+LL │ contract LockedZeroValue {
+   │          ━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/locked-ether
+
+warning[locked-ether]: contract can receive ETH but has no mechanism to send it out
+   ╭▸ ROOT/testdata/LockedEther.sol:LL:CC
+   │
+LL │ contract LockedTokenOnly {
+   │          ━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/locked-ether
+
+warning[locked-ether]: contract can receive ETH but has no mechanism to send it out
+   ╭▸ ROOT/testdata/LockedEther.sol:LL:CC
+   │
+LL │ contract LockedOneArgTokenTransfer {
+   │          ━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/locked-ether
+
+warning[locked-ether]: contract can receive ETH but has no mechanism to send it out
+   ╭▸ ROOT/testdata/LockedEther.sol:LL:CC
+   │
+LL │ contract LockedNotInherited {
+   │          ━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/locked-ether
+
+warning[locked-ether]: contract can receive ETH but has no mechanism to send it out
+   ╭▸ ROOT/testdata/LockedEther.sol:LL:CC
+   │
+LL │ contract Child {
+   │          ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/locked-ether
+
+warning[locked-ether]: contract can receive ETH but has no mechanism to send it out
+   ╭▸ ROOT/testdata/LockedEther.sol:LL:CC
+   │
+LL │ contract LockedUnreachableInternal {
+   │          ━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/locked-ether
+
+warning[locked-ether]: contract can receive ETH but has no mechanism to send it out
+   ╭▸ ROOT/testdata/LockedEther.sol:LL:CC
+   │
+LL │ contract LockedUnreachablePrivate {
+   │          ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/locked-ether
+
+warning[locked-ether]: contract can receive ETH but has no mechanism to send it out
+   ╭▸ ROOT/testdata/LockedEther.sol:LL:CC
+   │
+LL │ contract LockedSuperOverload is OverloadBase {
+   │          ━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/locked-ether
+
+warning[locked-ether]: contract can receive ETH but has no mechanism to send it out
+   ╭▸ ROOT/testdata/LockedEther.sol:LL:CC
+   │
+LL │ contract LockedLibraryOverload {
+   │          ━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/locked-ether
+
+warning[locked-ether]: contract can receive ETH but has no mechanism to send it out
+   ╭▸ ROOT/testdata/LockedEther.sol:LL:CC
+   │
+LL │ contract LockedOverriddenWithdraw is OverriddenBaseWithExit {
+   │          ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/locked-ether
+
+warning[locked-ether]: contract can receive ETH but has no mechanism to send it out
+   ╭▸ ROOT/testdata/LockedEther.sol:LL:CC
+   │
+LL │ contract LockedOverriddenReceive is BaseReceiveWithExit {
+   │          ━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/locked-ether
+
+warning[locked-ether]: contract can receive ETH but has no mechanism to send it out
+   ╭▸ ROOT/testdata/LockedEther.sol:LL:CC
+   │
+LL │ contract LockedCtorExitWithRuntimeReceive {
+   │          ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/locked-ether
+
+warning[locked-ether]: contract can receive ETH but has no mechanism to send it out
+   ╭▸ ROOT/testdata/LockedEther.sol:LL:CC
+   │
+LL │ contract LockedExitThenRevert {
+   │          ━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/locked-ether
+
+warning[locked-ether]: contract can receive ETH but has no mechanism to send it out
+   ╭▸ ROOT/testdata/LockedEther.sol:LL:CC
+   │
+LL │ contract LockedExitInRevertingHelper {
+   │          ━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/locked-ether
+
+warning[locked-ether]: contract can receive ETH but has no mechanism to send it out
+   ╭▸ ROOT/testdata/LockedEther.sol:LL:CC
+   │
+LL │ contract LockedSameArityOverload is OverloadByType {
+   │          ━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/locked-ether
+
+warning[locked-ether]: contract can receive ETH but has no mechanism to send it out
+   ╭▸ ROOT/testdata/LockedEther.sol:LL:CC
+   │
+LL │ contract LockedThisCallWithValue {
+   │          ━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/locked-ether
+
+warning[locked-ether]: contract can receive ETH but has no mechanism to send it out
+   ╭▸ ROOT/testdata/LockedEther.sol:LL:CC
+   │
+LL │ contract LockedAddressThisCallWithValue {
+   │          ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/locked-ether
+
+warning[locked-ether]: contract can receive ETH but has no mechanism to send it out
+   ╭▸ ROOT/testdata/LockedEther.sol:LL:CC
+   │
+LL │ contract LockedTransferToSelf {
+   │          ━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/locked-ether
+
+warning[locked-ether]: contract can receive ETH but has no mechanism to send it out
+   ╭▸ ROOT/testdata/LockedEther.sol:LL:CC
+   │
+LL │ contract LockedSelfdestructToSelf {
+   │          ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/locked-ether
+
+warning[locked-ether]: contract can receive ETH but has no mechanism to send it out
+   ╭▸ ROOT/testdata/LockedEther.sol:LL:CC
+   │
+LL │ abstract contract PayableBaseCtor {
+   │                   ━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/locked-ether
+
+warning[locked-ether]: contract can receive ETH but has no mechanism to send it out
+   ╭▸ ROOT/testdata/LockedEther.sol:LL:CC
+   │
+LL │ contract LockedOverriddenVirtualHook is VirtualExitBase {
+   │          ━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/locked-ether
+
+warning[locked-ether]: contract can receive ETH but has no mechanism to send it out
+   ╭▸ ROOT/testdata/LockedEther.sol:LL:CC
+   │
+LL │ contract LockedReceiveReturnBeforeRevert {
+   │          ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/locked-ether
+
+warning[locked-ether]: contract can receive ETH but has no mechanism to send it out
+   ╭▸ ROOT/testdata/LockedEther.sol:LL:CC
+   │
+LL │ contract LockedSelfSendViaInterfaceCast is ISelfSink {
+   │          ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/locked-ether
+
diff --git a/crates/lint/testdata/LowLevelCalls.sol b/crates/lint/testdata/LowLevelCalls.sol
new file mode 100644
index 0000000000000..7f0632bcbde0f
--- /dev/null
+++ b/crates/lint/testdata/LowLevelCalls.sol
@@ -0,0 +1,63 @@
+//@compile-flags: --only-lint low-level-calls
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+interface Receiver {
+    function ping(bytes calldata data) external returns (bool);
+}
+
+contract LowLevelCalls {
+    event CallResult(bool, bytes);
+
+    bytes existingData;
+
+    function checkedCall(address target, bytes memory data) public {
+        (bool success, bytes memory result) = target.call(data); //~NOTE: low-level call bypasses type checking
+        require(success, "Call failed");
+        emit CallResult(success, result);
+    }
+
+    function checkedCallWithValue(address payable target, uint256 value) public {
+        (bool success, ) = target.call{value: value}(""); //~NOTE: low-level call bypasses type checking
+        require(success, "Call failed");
+    }
+
+    function checkedDelegateCall(address target, bytes memory data) public returns (bool) {
+        (bool success, ) = target.delegatecall(data); //~NOTE: low-level call bypasses type checking
+        return success;
+    }
+
+    function checkedStaticCall(address target, bytes memory data) public view returns (bytes memory) {
+        (bool success, bytes memory result) = target.staticcall(data); //~NOTE: low-level call bypasses type checking
+        require(success, "Static call failed");
+        return result;
+    }
+
+    function uncheckedCall(address target, bytes memory data) public {
+        target.call(data); //~NOTE: low-level call bypasses type checking
+    }
+
+    function uncheckedCallWithValue(address payable target, uint256 value) public {
+        target.call{value: value}(""); //~NOTE: low-level call bypasses type checking
+    }
+
+    function uncheckedDelegateCall(address target, bytes memory data) public {
+        target.delegatecall(data); //~NOTE: low-level call bypasses type checking
+    }
+
+    function uncheckedStaticCall(address target, bytes memory data) public view {
+        target.staticcall(data); //~NOTE: low-level call bypasses type checking
+    }
+
+    function ignoredSuccess(address target) public {
+        (, existingData) = target.call(""); //~NOTE: low-level call bypasses type checking
+    }
+
+    function nonLowLevelMemberCalls(Receiver receiver, address payable target, bytes calldata data) public {
+        receiver.ping(data);
+        target.transfer(1 wei);
+        bool sent = target.send(1 wei);
+        require(sent, "Send failed");
+    }
+}
diff --git a/crates/lint/testdata/LowLevelCalls.stderr b/crates/lint/testdata/LowLevelCalls.stderr
new file mode 100644
index 0000000000000..ec662fc36f3c5
--- /dev/null
+++ b/crates/lint/testdata/LowLevelCalls.stderr
@@ -0,0 +1,72 @@
+note[low-level-calls]: low-level call bypasses type checking
+   ╭▸ ROOT/testdata/LowLevelCalls.sol:LL:CC
+   │
+LL │         (bool success, bytes memory result) = target.call(data);
+   │                                               ━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/low-level-calls
+
+note[low-level-calls]: low-level call bypasses type checking
+   ╭▸ ROOT/testdata/LowLevelCalls.sol:LL:CC
+   │
+LL │         (bool success, ) = target.call{value: value}("");
+   │                            ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/low-level-calls
+
+note[low-level-calls]: low-level call bypasses type checking
+   ╭▸ ROOT/testdata/LowLevelCalls.sol:LL:CC
+   │
+LL │         (bool success, ) = target.delegatecall(data);
+   │                            ━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/low-level-calls
+
+note[low-level-calls]: low-level call bypasses type checking
+   ╭▸ ROOT/testdata/LowLevelCalls.sol:LL:CC
+   │
+LL │         (bool success, bytes memory result) = target.staticcall(data);
+   │                                               ━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/low-level-calls
+
+note[low-level-calls]: low-level call bypasses type checking
+   ╭▸ ROOT/testdata/LowLevelCalls.sol:LL:CC
+   │
+LL │         target.call(data);
+   │         ━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/low-level-calls
+
+note[low-level-calls]: low-level call bypasses type checking
+   ╭▸ ROOT/testdata/LowLevelCalls.sol:LL:CC
+   │
+LL │         target.call{value: value}("");
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/low-level-calls
+
+note[low-level-calls]: low-level call bypasses type checking
+   ╭▸ ROOT/testdata/LowLevelCalls.sol:LL:CC
+   │
+LL │         target.delegatecall(data);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/low-level-calls
+
+note[low-level-calls]: low-level call bypasses type checking
+   ╭▸ ROOT/testdata/LowLevelCalls.sol:LL:CC
+   │
+LL │         target.staticcall(data);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/low-level-calls
+
+note[low-level-calls]: low-level call bypasses type checking
+   ╭▸ ROOT/testdata/LowLevelCalls.sol:LL:CC
+   │
+LL │         (, existingData) = target.call("");
+   │                            ━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/low-level-calls
+
diff --git a/crates/lint/testdata/MappingDeletion.sol b/crates/lint/testdata/MappingDeletion.sol
new file mode 100644
index 0000000000000..425a322ba7521
--- /dev/null
+++ b/crates/lint/testdata/MappingDeletion.sol
@@ -0,0 +1,81 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+// Tests for the `mapping-deletion` lint, which flags `delete` on a value whose type contains a
+// mapping. `delete` zeroes the non-mapping members but cannot clear the mapping's entries, leaving
+// stale storage behind.
+
+contract MappingDeletion {
+    struct WithMapping {
+        uint256 total;
+        mapping(address => uint256) balances;
+    }
+
+    struct Nested {
+        uint256 x;
+        WithMapping inner;
+    }
+
+    struct Plain {
+        uint256 a;
+        uint256 b;
+    }
+
+    struct Tree {
+        Tree[] children;
+        mapping(address => uint256) balances;
+    }
+
+    WithMapping internal s;
+    Nested internal n;
+    Plain internal p;
+    WithMapping[] internal arr;
+    mapping(uint256 => WithMapping) internal m;
+    mapping(uint256 => uint256) internal plainMap;
+    Tree internal root;
+
+    // SHOULD FAIL: deleting a struct that directly holds a mapping.
+    function badStruct() external {
+        delete s; //~WARN: `delete` on a value containing a mapping does not clear the mapping
+    }
+
+    // SHOULD FAIL: the mapping is reachable through a nested struct.
+    function badNested() external {
+        delete n; //~WARN: `delete` on a value containing a mapping does not clear the mapping
+    }
+
+    // SHOULD FAIL: deleting an array of structs that hold a mapping.
+    function badArray() external {
+        delete arr; //~WARN: `delete` on a value containing a mapping does not clear the mapping
+    }
+
+    // SHOULD FAIL: the mapping value is itself a struct with a mapping.
+    function badMappingValue(uint256 id) external {
+        delete m[id]; //~WARN: `delete` on a value containing a mapping does not clear the mapping
+    }
+
+    // SHOULD FAIL: a single array element is a struct with a mapping.
+    function badArrayElem(uint256 i) external {
+        delete arr[i]; //~WARN: `delete` on a value containing a mapping does not clear the mapping
+    }
+
+    // SHOULD FAIL: a recursive struct reaches a mapping; exercises the recursion guard.
+    function badRecursive() external {
+        delete root; //~WARN: `delete` on a value containing a mapping does not clear the mapping
+    }
+
+    // OK: plain struct, no mapping reachable.
+    function okPlain() external {
+        delete p;
+    }
+
+    // OK: deleting a scalar member.
+    function okScalarField() external {
+        delete s.total;
+    }
+
+    // OK: deleting a single entry of a plain mapping.
+    function okMappingEntry(uint256 k) external {
+        delete plainMap[k];
+    }
+}
diff --git a/crates/lint/testdata/MappingDeletion.stderr b/crates/lint/testdata/MappingDeletion.stderr
new file mode 100644
index 0000000000000..5659f2d2fabcf
--- /dev/null
+++ b/crates/lint/testdata/MappingDeletion.stderr
@@ -0,0 +1,48 @@
+warning[mapping-deletion]: `delete` on a value containing a mapping does not clear the mapping
+   ╭▸ ROOT/testdata/MappingDeletion.sol:LL:CC
+   │
+LL │         delete s;
+   │         ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/mapping-deletion
+
+warning[mapping-deletion]: `delete` on a value containing a mapping does not clear the mapping
+   ╭▸ ROOT/testdata/MappingDeletion.sol:LL:CC
+   │
+LL │         delete n;
+   │         ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/mapping-deletion
+
+warning[mapping-deletion]: `delete` on a value containing a mapping does not clear the mapping
+   ╭▸ ROOT/testdata/MappingDeletion.sol:LL:CC
+   │
+LL │         delete arr;
+   │         ━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/mapping-deletion
+
+warning[mapping-deletion]: `delete` on a value containing a mapping does not clear the mapping
+   ╭▸ ROOT/testdata/MappingDeletion.sol:LL:CC
+   │
+LL │         delete m[id];
+   │         ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/mapping-deletion
+
+warning[mapping-deletion]: `delete` on a value containing a mapping does not clear the mapping
+   ╭▸ ROOT/testdata/MappingDeletion.sol:LL:CC
+   │
+LL │         delete arr[i];
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/mapping-deletion
+
+warning[mapping-deletion]: `delete` on a value containing a mapping does not clear the mapping
+   ╭▸ ROOT/testdata/MappingDeletion.sol:LL:CC
+   │
+LL │         delete root;
+   │         ━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/mapping-deletion
+
diff --git a/crates/lint/testdata/MissingEventsAccessControl.sol b/crates/lint/testdata/MissingEventsAccessControl.sol
new file mode 100644
index 0000000000000..e627576ba53b3
--- /dev/null
+++ b/crates/lint/testdata/MissingEventsAccessControl.sol
@@ -0,0 +1,752 @@
+//@compile-flags: --only-lint missing-events-access-control
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+contract MissingEventsAccessControl {
+    address public owner = msg.sender;
+    address public pendingOwner;
+    address public guardian;
+    address public operator;
+    address public fixedOwner;
+    address public plainAddress;
+    address public observedAddress;
+    uint256 public threshold;
+    mapping(address => bool) public roles;
+    mapping(address => mapping(bytes32 => bool)) public namedRoles;
+    mapping(bytes32 => mapping(address => bool)) public nestedRoles;
+
+    event OwnershipTransferred(address indexed oldOwner, address indexed newOwner);
+    event GuardianUpdated(address indexed guardian);
+    event RoleUpdated(address indexed account, bool enabled);
+    event Logged(address val);
+    event Touched();
+
+    modifier onlyOwner() {
+        require(msg.sender == owner, "not owner");
+        _;
+    }
+
+    modifier loggedModifier() {
+        emit Touched();
+        _;
+    }
+
+    modifier onlyOwnerViaSenderAlias() {
+        address sender = msg.sender;
+        require(sender == owner, "not owner");
+        _;
+    }
+
+    modifier onlyOwnerViaReassignedAlias(address newOwner) {
+        address caller = msg.sender;
+        caller = newOwner;
+        require(caller == owner, "not owner");
+        _;
+    }
+
+    modifier onlyOwnerViaAliasBeforeReassign(address newOwner) {
+        address caller = msg.sender;
+        require(caller == owner, "not owner");
+        caller = newOwner;
+        _;
+    }
+
+    modifier onlyOwnerViaTupleReassignedAlias(address newOwner) {
+        address caller = msg.sender;
+        (caller,) = (newOwner, false);
+        require(caller == owner, "not owner");
+        _;
+    }
+
+    modifier onlyOwnerViaTupleSwappedAlias(address newOwner) {
+        address caller = newOwner;
+        address previous;
+        (caller, previous) = (msg.sender, caller);
+        require(previous == owner, "not owner");
+        _;
+    }
+
+    modifier onlyOwnerViaTupleTransferredAlias(address newOwner) {
+        address caller = msg.sender;
+        address previous;
+        (caller, previous) = (newOwner, caller);
+        require(previous == owner, "not owner");
+        _;
+    }
+
+    modifier onlyOwnerViaTupleRepeatedSenderFirst(address newOwner) {
+        address caller;
+        (caller, caller) = (msg.sender, newOwner);
+        require(caller == owner, "not owner");
+        _;
+    }
+
+    modifier onlyOwnerViaTupleRepeatedCandidateFirst(address newOwner) {
+        address caller;
+        (caller, caller) = (newOwner, msg.sender);
+        require(caller == owner, "not owner");
+        _;
+    }
+
+    modifier onlyOwnerViaNestedTupleAlias() {
+        address caller;
+        address spare;
+        bool live;
+        ((caller, spare), live) = ((msg.sender, address(0)), true);
+        require(live && caller == owner, "not owner");
+        _;
+    }
+
+    modifier onlyOwnerViaNestedTupleReassignedAlias(address newOwner) {
+        address caller = msg.sender;
+        address spare;
+        bool live;
+        ((caller, spare), live) = ((newOwner, address(0)), false);
+        require(caller == owner, "not owner");
+        _;
+    }
+
+    modifier onlyOwnerViaTupleDeclaredAlias() {
+        (address caller, bool live) = (msg.sender, true);
+        require(live && caller == owner, "not owner");
+        _;
+    }
+
+    modifier onlyOwnerViaTupleCallAlias() {
+        (address caller,) = _senderAndFlag();
+        require(caller == owner, "not owner");
+        _;
+    }
+
+    modifier onlyOwnerViaTupleCallAssignedAlias() {
+        address caller;
+        (, caller) = _flagAndSender();
+        require(caller == owner, "not owner");
+        _;
+    }
+
+    modifier onlyOwnerViaTupleCallReassignedAlias(address newOwner) {
+        address caller = msg.sender;
+        (, caller) = _flagAndAddress(newOwner);
+        require(caller == owner, "not owner");
+        _;
+    }
+
+    modifier onlyOwnerViaCheck() {
+        _checkOwner();
+        _;
+    }
+
+    modifier onlyRole() {
+        require(roles[msg.sender], "missing role");
+        _;
+    }
+
+    modifier onlyThreshold(uint256 value) {
+        require(value > threshold, "too small");
+        _;
+    }
+
+    modifier guardAfterPlaceholder() {
+        _;
+        require(msg.sender == owner, "not owner");
+    }
+
+    modifier writesOwner(address newOwner) {
+        owner = newOwner; //~WARN: `owner` is changed without an event but is used for access control
+        _;
+    }
+
+    // SHOULD FAIL:
+
+    function transferOwnership(address newOwner) external onlyOwner {
+        owner = newOwner; //~WARN: `owner` is changed without an event but is used for access control
+    }
+
+    function acceptOwnership() external {
+        if (msg.sender != pendingOwner) revert();
+        owner = pendingOwner; //~WARN: `owner` is changed without an event but is used for access control
+        pendingOwner = address(0); //~WARN: `pendingOwner` is changed without an event but is used for access control
+    }
+
+    function acceptOwnershipFromSender() external {
+        if (msg.sender != pendingOwner) revert();
+        owner = msg.sender; //~WARN: `owner` is changed without an event but is used for access control
+    }
+
+    function setGuardian(address newGuardian) external onlyOwner {
+        address nextGuardian = newGuardian;
+        guardian = nextGuardian; //~WARN: `guardian` is changed without an event but is used for access control
+    }
+
+    function setGuardianInternal(address newGuardian) external onlyOwner {
+        _setGuardian(newGuardian);
+    }
+
+    function _setGuardian(address newGuardian) internal {
+        guardian = newGuardian; //~WARN: `guardian` is changed without an event but is used for access control
+    }
+
+    function grantRole(address account) external onlyOwner {
+        roles[account] = true; //~WARN: `roles` is changed without an event but is used for access control
+    }
+
+    function revokeRole(address account) external onlyOwner {
+        delete roles[account]; //~WARN: `roles` is changed without an event but is used for access control
+    }
+
+    function setNamedRole(address account, bytes32 role, bool enabled) external onlyOwner {
+        namedRoles[account][role] = enabled; //~WARN: `namedRoles` is changed without an event but is used for access control
+    }
+
+    function grantNestedRole(bytes32 role, address account) external onlyNestedRole(bytes32(0)) {
+        _grantNestedRole(role, account);
+    }
+
+    function _grantNestedRole(bytes32 role, address account) internal {
+        nestedRoles[role][account] = true; //~WARN: `nestedRoles` is changed without an event but is used for access control
+    }
+
+    function setOwnerOZStyle(address newOwner) external onlyOwnerViaCheck {
+        owner = newOwner; //~WARN: `owner` is changed without an event but is used for access control
+    }
+
+    function setOwnerWithUnrelatedEventBefore(address newOwner) external onlyOwner {
+        emit Touched();
+        owner = newOwner; //~WARN: `owner` is changed without an event but is used for access control
+    }
+
+    function setOwnerWithUnrelatedEventAfter(address newOwner) external onlyOwner {
+        owner = newOwner; //~WARN: `owner` is changed without an event but is used for access control
+        emit Touched();
+    }
+
+    function setOwnerWithUnrelatedArgEvent(address newOwner) external onlyOwner {
+        owner = newOwner; //~WARN: `owner` is changed without an event but is used for access control
+        emit Logged(newOwner);
+    }
+
+    function setOwnerViaLoggingModifier(address newOwner) external onlyOwner loggedModifier {
+        owner = newOwner; //~WARN: `owner` is changed without an event but is used for access control
+    }
+
+    function setOwnerEventInOtherBranch(address newOwner) external onlyOwner {
+        if (newOwner == address(0)) {
+            emit Touched();
+        }
+        owner = newOwner; //~WARN: `owner` is changed without an event but is used for access control
+    }
+
+    // A matching event emitted only inside one conditionally-taken branch must not satisfy a
+    // write reachable outside that branch: the branch may not run, so the write could still
+    // escape without an event on that path.
+    function setOwnerMatchingEventOnlyInBranch(address newOwner) external onlyOwner {
+        if (newOwner == address(0)) {
+            emit OwnershipTransferred(owner, newOwner);
+        }
+        owner = newOwner; //~WARN: `owner` is changed without an event but is used for access control
+    }
+
+    // Same reasoning for a loop: a `for`/`while` body may run zero times, so a matching event
+    // emitted only inside it must not satisfy a write reachable after the loop.
+    function setOwnerMatchingEventOnlyInLoop(address newOwner, uint256 n) external onlyOwner {
+        for (uint256 i; i < n; i++) {
+            emit OwnershipTransferred(owner, newOwner);
+        }
+        owner = newOwner; //~WARN: `owner` is changed without an event but is used for access control
+    }
+
+    // A `do-while` body always runs its first iteration, but a `break`/`continue`/`revert` can
+    // still skip past an emit on that very iteration, so a matching event inside a `do-while`
+    // must not satisfy a write reachable after it either.
+    function setGuardianMatchingEventOnlyInDoWhileLoop(address newGuardian, bool stop) external onlyOwner {
+        uint256 i;
+        do {
+            if (stop) break;
+            emit GuardianUpdated(newGuardian);
+            i++;
+        } while (i < 1);
+        guardian = newGuardian; //~WARN: `guardian` is changed without an event but is used for access control
+    }
+
+    // Try/catch clauses are mutually exclusive: at most one runs, so a matching event emitted in
+    // the success clause must not satisfy a write reachable after the whole try/catch.
+    function setOwnerMatchingEventOnlyInTryClause(address newOwner) external onlyOwner {
+        try this.noop() {
+            emit OwnershipTransferred(owner, newOwner);
+        } catch {}
+        owner = newOwner; //~WARN: `owner` is changed without an event but is used for access control
+    }
+
+    // The write happens BEFORE the try; a matching event only in the success clause must not
+    // retroactively mark it evented, since the catch path never emits.
+    function setOwnerWriteBeforeTryMatchingEventInSuccessClause(address newOwner) external onlyOwner {
+        owner = newOwner; //~WARN: `owner` is changed without an event but is used for access control
+        try this.noop() {
+            emit OwnershipTransferred(owner, newOwner);
+        } catch {}
+    }
+
+    // The write happens BEFORE the do-while loop; a matching event skipped via `break` must not
+    // retroactively mark it evented.
+    function setGuardianWriteBeforeDoWhileMatchingEventSkippedByBreak(
+        address newGuardian,
+        bool stop
+    ) external onlyOwner {
+        guardian = newGuardian; //~WARN: `guardian` is changed without an event but is used for access control
+        uint256 i;
+        do {
+            if (stop) break;
+            emit GuardianUpdated(newGuardian);
+            i++;
+        } while (i < 1);
+    }
+
+    // A success-clause event must not satisfy a write reachable only via the (mutually
+    // exclusive) catch clause - if the try reverts, the success emit never fired.
+    function setOwnerWriteInCatchMatchingEventInSuccessClause(address newOwner) external onlyOwner {
+        try this.noop() {
+            emit OwnershipTransferred(owner, newOwner);
+        } catch {
+            owner = newOwner; //~WARN: `owner` is changed without an event but is used for access control
+        }
+    }
+
+    // Conditional helper calls must not supply events to an unconditional write.
+    function setOwnerEventInShortCircuitAnd(address newOwner, bool enabled) external onlyOwner {
+        enabled && _logOwner(newOwner);
+        owner = newOwner; //~WARN: `owner` is changed without an event but is used for access control
+    }
+
+    function setOwnerEventInShortCircuitOr(address newOwner, bool enabled) external onlyOwner {
+        enabled || _logOwner(newOwner);
+        owner = newOwner; //~WARN: `owner` is changed without an event but is used for access control
+    }
+
+    function setOwnerEventInTernary(address newOwner, bool enabled) external onlyOwner {
+        enabled ? _logOwner(newOwner) : false;
+        owner = newOwner; //~WARN: `owner` is changed without an event but is used for access control
+    }
+
+    function setOwnerBeforeShortCircuitAnd(address newOwner, bool enabled) external onlyOwner {
+        owner = newOwner; //~WARN: `owner` is changed without an event but is used for access control
+        enabled && _logOwner(newOwner);
+    }
+
+    function setOwnerBeforeShortCircuitOr(address newOwner, bool enabled) external onlyOwner {
+        owner = newOwner; //~WARN: `owner` is changed without an event but is used for access control
+        enabled || _logOwner(newOwner);
+    }
+
+    function setOwnerBeforeTernary(address newOwner, bool enabled) external onlyOwner {
+        owner = newOwner; //~WARN: `owner` is changed without an event but is used for access control
+        enabled ? _logOwner(newOwner) : false;
+    }
+
+    function setOwnerBeforeBothTernaryEvents(address newOwner, bool enabled) external onlyOwner {
+        owner = newOwner;
+        enabled ? _logOwner(newOwner) : _logOwner(newOwner);
+    }
+
+    function setOwnerEventBeforeWriteViaHelper(address newOwner) external onlyOwner {
+        _logOwner(newOwner);
+        owner = newOwner;
+    }
+
+    function setOwnerEventInMandatoryOperand(address newOwner, bool enabled) external onlyOwner {
+        _logOwner(newOwner) && enabled;
+        owner = newOwner;
+    }
+
+    function setOwnerEventInTernaryCondition(address newOwner, bool enabled) external onlyOwner {
+        _logOwner(newOwner) ? enabled : false;
+        owner = newOwner;
+    }
+
+    function _logOwner(address newOwner) internal returns (bool) {
+        emit OwnershipTransferred(owner, newOwner);
+        return true;
+    }
+
+    function noop() external pure {}
+
+    // Deliberately conservative: `revert` and `return` both count as "always exits" for the
+    // AND-rule, even though a `revert`ing clause's write never actually persists (a `return`ing
+    // one's does). Distinguishing them isn't worth the added complexity for a Low-severity lint;
+    // this is a known, disclosed false-positive edge case, not a false negative.
+    function setOwnerWriteBeforeTryRevertingCatchStillRequiresCredit(address newOwner) external onlyOwner {
+        owner = newOwner; //~WARN: `owner` is changed without an event but is used for access control
+        try this.noop() {
+            emit OwnershipTransferred(owner, newOwner);
+        } catch {
+            revert("unreachable in practice");
+        }
+    }
+
+    function setOwnerViaSenderAlias(address newOwner) external onlyOwnerViaSenderAlias {
+        owner = newOwner; //~WARN: `owner` is changed without an event but is used for access control
+    }
+
+    function setOwnerViaReassignedAlias(address newOwner)
+        external
+        onlyOwnerViaReassignedAlias(newOwner)
+    {
+        owner = newOwner;
+    }
+
+    function setOwnerViaAliasBeforeReassign(address newOwner)
+        external
+        onlyOwnerViaAliasBeforeReassign(newOwner)
+    {
+        owner = newOwner; //~WARN: `owner` is changed without an event but is used for access control
+    }
+
+    function setOwnerViaTupleReassignedAlias(address newOwner)
+        external
+        onlyOwnerViaTupleReassignedAlias(newOwner)
+    {
+        owner = newOwner;
+    }
+
+    function setOwnerViaTupleSwappedAlias(address newOwner)
+        external
+        onlyOwnerViaTupleSwappedAlias(newOwner)
+    {
+        owner = newOwner;
+    }
+
+    function setOwnerViaTupleTransferredAlias(address newOwner)
+        external
+        onlyOwnerViaTupleTransferredAlias(newOwner)
+    {
+        owner = newOwner; //~WARN: `owner` is changed without an event but is used for access control
+    }
+
+    function setOwnerViaTupleRepeatedSenderFirst(address newOwner)
+        external
+        onlyOwnerViaTupleRepeatedSenderFirst(newOwner)
+    {
+        owner = newOwner; //~WARN: `owner` is changed without an event but is used for access control
+    }
+
+    function setOwnerViaTupleRepeatedCandidateFirst(address newOwner)
+        external
+        onlyOwnerViaTupleRepeatedCandidateFirst(newOwner)
+    {
+        owner = newOwner;
+    }
+
+    function setOwnerViaNestedTupleAlias(address newOwner) external onlyOwnerViaNestedTupleAlias {
+        owner = newOwner; //~WARN: `owner` is changed without an event but is used for access control
+    }
+
+    function setOwnerViaNestedTupleReassignedAlias(address newOwner)
+        external
+        onlyOwnerViaNestedTupleReassignedAlias(newOwner)
+    {
+        owner = newOwner;
+    }
+
+    function setOwnerViaTupleDeclaredAlias(address newOwner)
+        external
+        onlyOwnerViaTupleDeclaredAlias
+    {
+        owner = newOwner; //~WARN: `owner` is changed without an event but is used for access control
+    }
+
+    function setOwnerViaTupleCallAlias(address newOwner) external onlyOwnerViaTupleCallAlias {
+        owner = newOwner; //~WARN: `owner` is changed without an event but is used for access control
+    }
+
+    function setOwnerViaTupleCallAssignedAlias(address newOwner)
+        external
+        onlyOwnerViaTupleCallAssignedAlias
+    {
+        owner = newOwner; //~WARN: `owner` is changed without an event but is used for access control
+    }
+
+    function setOwnerViaTupleCallReassignedAlias(address newOwner)
+        external
+        onlyOwnerViaTupleCallReassignedAlias(newOwner)
+    {
+        owner = newOwner;
+    }
+
+    function setOwnerInModifier(address newOwner) external onlyOwner writesOwner(newOwner) {}
+
+    function grantRoleViaStorageAlias(address account) external onlyOwner {
+        mapping(address => bool) storage roleSet = roles;
+        roleSet[account] = true; //~WARN: `roles` is changed without an event but is used for access control
+    }
+
+    function setNamedRoleViaStorageAlias(
+        address account,
+        bytes32 role,
+        bool enabled
+    ) external onlyOwner {
+        mapping(bytes32 => bool) storage accountRoles = namedRoles[account];
+        accountRoles[role] = enabled; //~WARN: `namedRoles` is changed without an event but is used for access control
+    }
+
+    // Access-control usage that makes the state critical.
+
+    function ownerAction() external view onlyOwner returns (uint256) {
+        return 1;
+    }
+
+    function pendingOwnerAction() external view {
+        if (msg.sender != pendingOwner) revert();
+    }
+
+    function guardianAction() external view {
+        _checkGuardian();
+    }
+
+    function roleAction() external view onlyRole returns (uint256) {
+        return 2;
+    }
+
+    function namedRoleAction(bytes32 role) external view {
+        require(namedRoles[msg.sender][role], "missing role");
+    }
+
+    modifier onlyNestedRole(bytes32 role) {
+        _checkRole(role);
+        _;
+    }
+
+    function _checkRole(bytes32 role) internal view {
+        _checkRole(role, _msgSender());
+    }
+
+    function _checkRole(bytes32 role, address account) internal view {
+        if (!hasNestedRole(role, account)) revert();
+    }
+
+    function hasNestedRole(bytes32 role, address account) public view returns (bool) {
+        return nestedRoles[role][account];
+    }
+
+    // SHOULD PASS:
+
+    function transferOwnershipWithEvent(address newOwner) external onlyOwner {
+        address oldOwner = owner;
+        owner = newOwner;
+        emit OwnershipTransferred(oldOwner, newOwner);
+    }
+
+    // The event is emitted before the write it documents; statement order within the same
+    // straight-line scope must not matter.
+    function transferOwnershipEventBeforeWrite(address newOwner) external onlyOwner {
+        address oldOwner = owner;
+        emit OwnershipTransferred(oldOwner, newOwner);
+        owner = newOwner;
+    }
+
+    // Same emit-before-write ordering, but both statements are nested inside the same branch.
+    function setGuardianEventBeforeWriteInBranch(address newGuardian) external onlyOwner {
+        if (newGuardian != address(0)) {
+            emit GuardianUpdated(newGuardian);
+            guardian = newGuardian;
+        }
+    }
+
+    function setGuardianWithInternalEvent(address newGuardian) external onlyOwner {
+        _setGuardianWithEvent(newGuardian);
+    }
+
+    function _setGuardianWithEvent(address newGuardian) internal {
+        guardian = newGuardian;
+        emit GuardianUpdated(newGuardian);
+    }
+
+    function grantRoleWithEvent(address account) external onlyOwner {
+        roles[account] = true;
+        emit RoleUpdated(account, true);
+    }
+
+    function proposeOwner(address newOwner) external onlyOwner {
+        pendingOwner = newOwner;
+        emit OwnershipTransferred(owner, newOwner);
+    }
+
+    function renounceOwnershipWithEvent() external onlyOwner {
+        address oldOwner = owner;
+        owner = address(0);
+        emit OwnershipTransferred(oldOwner, address(0));
+    }
+
+    function unprotectedSetOwner(address newOwner) external {
+        owner = newOwner;
+    }
+
+    function setOwnerWithComputedSender(address expected, address newOwner) external {
+        require(msg.sender == computeAddress(expected), "not computed");
+        owner = newOwner;
+    }
+
+    function setOwnerViaOverwrittenLocal(address newOwner) external onlyOwner {
+        address next = newOwner;
+        next = address(0xBEEF);
+        owner = next;
+    }
+
+    function setOwnerAfterRevertingTaint(address newOwner) external onlyOwner {
+        address next = address(0xBEEF);
+        if (newOwner == address(0)) {
+            next = newOwner;
+            revert();
+        }
+        owner = next;
+    }
+
+    function setOwnerGuardAfterPlaceholder(address newOwner) external guardAfterPlaceholder {
+        owner = newOwner;
+    }
+
+    function setPlainAddress(address newValue) external onlyOwner {
+        plainAddress = newValue;
+    }
+
+    function readPlainAddress() external view returns (address) {
+        return plainAddress;
+    }
+
+    function setFixedOwner() external onlyOwner {
+        fixedOwner = address(0xBEEF);
+    }
+
+    function fixedOwnerAction() external view {
+        require(msg.sender == fixedOwner, "not fixed owner");
+    }
+
+    function observesSenderButDoesNotRestrict(address newValue) external {
+        if (msg.sender == owner) {
+            newValue = address(0);
+        }
+        observedAddress = newValue;
+    }
+
+    function senderAliasCycleEntry() external view {
+        senderAliasCycleA();
+    }
+
+    function senderAliasCycleA() internal view returns (address) {
+        address sender = senderAliasCycleB();
+        return sender;
+    }
+
+    function senderAliasCycleB() internal view returns (address) {
+        address sender = senderAliasCycleA();
+        return sender;
+    }
+
+    function setThreshold(uint256 newThreshold) external onlyOwner {
+        threshold = newThreshold;
+    }
+
+    function thresholdAction(uint256 value) external view onlyThreshold(value) returns (uint256) {
+        return value;
+    }
+
+    constructor(address initialOwner) {
+        owner = initialOwner;
+    }
+
+    function _senderAndFlag() internal view returns (address, bool) {
+        return (msg.sender, true);
+    }
+
+    function _flagAndSender() internal view returns (bool, address) {
+        return (true, msg.sender);
+    }
+
+    function _flagAndAddress(address value) internal pure returns (bool, address) {
+        return (true, value);
+    }
+
+    function _checkOwner() internal view {
+        if (owner != _msgSender()) revert();
+    }
+
+    function _checkGuardian() internal view {
+        require(_msgSender() == guardian, "not guardian");
+    }
+
+    function _msgSender() internal view returns (address) {
+        return msg.sender;
+    }
+
+    function computeAddress(address expected) internal pure returns (address) {
+        return expected;
+    }
+}
+
+contract AccessBase {
+    address public baseOwner;
+
+    modifier onlyBaseOwner() {
+        require(msg.sender == baseOwner, "not base owner");
+        _;
+    }
+}
+
+contract MissingEventsAccessControlDerived is AccessBase {
+    function transferBaseOwnership(address newOwner) external onlyBaseOwner {
+        baseOwner = newOwner; //~WARN: `baseOwner` is changed without an event but is used for access control
+    }
+}
+
+abstract contract AbstractAccessBase {
+    address public admin;
+
+    modifier onlyAdmin() virtual;
+}
+
+abstract contract MissingEventsAccessControlAbstractDerived is AbstractAccessBase {
+    function setAdmin(address newAdmin) external onlyAdmin {
+        admin = newAdmin; //~WARN: `admin` is changed without an event but is used for access control
+    }
+
+    function adminAction() external view {
+        require(msg.sender == admin, "not admin");
+    }
+}
+
+abstract contract MissingEventsAccessControlAssertionHelpers {
+    bool private _failed;
+
+    function assertEq(uint128 left, uint128 right) internal view virtual {
+        assertEq(uint256(left), uint256(right));
+    }
+
+    function assertEq(uint256 left, uint256 right) internal view virtual {
+        if (left != right || _failed) {
+            fail();
+        }
+    }
+
+    function fail() internal view virtual {
+        assertEq(uint256(0), uint256(1));
+    }
+}
+
+contract NamedArgumentAccessControl {
+    address public owner = msg.sender;
+
+    function setOwner(address next) external {
+        require(msg.sender == owner);
+        _setOwner({next: next, ignored: address(1)});
+    }
+
+    function _setOwner(address ignored, address next) internal {
+        owner = next; //~WARN: `owner` is changed without an event but is used for access control
+    }
+}
+
+contract MissingEventsAccessControlForgeStdLikeTest is MissingEventsAccessControlAssertionHelpers {
+    function testFuzz_SendMail(uint128 mintAmount, uint128 sendAmount) public view {
+        assertEq(uint256(mintAmount), uint256(sendAmount));
+    }
+}
diff --git a/crates/lint/testdata/MissingEventsAccessControl.stderr b/crates/lint/testdata/MissingEventsAccessControl.stderr
new file mode 100644
index 0000000000000..fd89d4cfd3731
--- /dev/null
+++ b/crates/lint/testdata/MissingEventsAccessControl.stderr
@@ -0,0 +1,352 @@
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │         owner = newOwner;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │         owner = pendingOwner;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `pendingOwner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │         pendingOwner = address(0);
+   │         ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │         owner = msg.sender;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `guardian` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │         guardian = nextGuardian;
+   │         ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `guardian` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │         guardian = newGuardian;
+   │         ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `roles` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │         roles[account] = true;
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `roles` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │         delete roles[account];
+   │                ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `namedRoles` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │         namedRoles[account][role] = enabled;
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `nestedRoles` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │         nestedRoles[role][account] = true;
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │         owner = newOwner;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │         owner = newOwner;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │         owner = newOwner;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │         owner = newOwner;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │         owner = newOwner;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │         owner = newOwner;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │         owner = newOwner;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │         owner = newOwner;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `guardian` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │         guardian = newGuardian;
+   │         ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │         owner = newOwner;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │         owner = newOwner;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `guardian` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │         guardian = newGuardian;
+   │         ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │             owner = newOwner;
+   │             ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │         owner = newOwner;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │         owner = newOwner;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │         owner = newOwner;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │         owner = newOwner;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │         owner = newOwner;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │         owner = newOwner;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │         owner = newOwner;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │         owner = newOwner;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │         owner = newOwner;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │         owner = newOwner;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │         owner = newOwner;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │         owner = newOwner;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │         owner = newOwner;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │         owner = newOwner;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │         owner = newOwner;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │         owner = newOwner;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `roles` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │         roleSet[account] = true;
+   │         ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `namedRoles` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │         accountRoles[role] = enabled;
+   │         ━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `baseOwner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │         baseOwner = newOwner;
+   │         ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `admin` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │         admin = newAdmin;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControl.sol:LL:CC
+   │
+LL │         owner = next;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
diff --git a/crates/lint/testdata/MissingEventsAccessControlForUpdate.sol b/crates/lint/testdata/MissingEventsAccessControlForUpdate.sol
new file mode 100644
index 0000000000000..1a6bbad761937
--- /dev/null
+++ b/crates/lint/testdata/MissingEventsAccessControlForUpdate.sol
@@ -0,0 +1,83 @@
+//@compile-flags: --only-lint missing-events-access-control
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+contract MissingEventsAccessControlForUpdate {
+    address public owner = msg.sender;
+
+    event OwnershipTransferred(address oldOwner, address newOwner);
+
+    modifier onlyOwner() {
+        require(msg.sender == owner, "not owner");
+        _;
+    }
+
+    // The update expression is part of the loop and must be checked for writes.
+    function writeInUpdate(address newOwner, bool enabled) external onlyOwner {
+        for (; enabled; owner = newOwner) { //~WARN: `owner` is changed without an event but is used for access control
+            enabled = false;
+        }
+    }
+
+    function helperWriteInUpdate(address newOwner, bool enabled) external onlyOwner {
+        for (; enabled; _setOwner(newOwner)) {
+            enabled = false;
+        }
+    }
+
+    // An event in the update can cover a write in the same iteration.
+    function eventInUpdate(address newOwner, bool enabled) external onlyOwner {
+        for (; enabled; _logOwner(newOwner)) {
+            owner = newOwner;
+            enabled = false;
+        }
+    }
+
+    // An event in the body can also cover a write in the update.
+    function eventBeforeUpdate(address newOwner, bool enabled) external onlyOwner {
+        for (; enabled; owner = newOwner) {
+            emit OwnershipTransferred(owner, newOwner);
+            enabled = false;
+        }
+    }
+
+    // The update may never run, so its event cannot cover an outside write.
+    function conditionalEventInUpdate(address newOwner, bool enabled) external onlyOwner {
+        for (; enabled; _logOwner(newOwner)) {
+            enabled = false;
+        }
+        owner = newOwner; //~WARN: `owner` is changed without an event but is used for access control
+    }
+
+    function writeBeforeConditionalUpdate(address newOwner, bool enabled) external onlyOwner {
+        owner = newOwner; //~WARN: `owner` is changed without an event but is used for access control
+        for (; enabled; _logOwner(newOwner)) {
+            enabled = false;
+        }
+    }
+
+    // A nested conditional event cannot cover every path to the update.
+    function conditionalBodyEvent(address newOwner, bool enabled, bool logEvent) external onlyOwner {
+        for (; enabled; owner = newOwner) { //~WARN: `owner` is changed without an event but is used for access control
+            if (logEvent) emit OwnershipTransferred(owner, newOwner);
+            enabled = false;
+        }
+    }
+
+    // Loops without a condition still visit their update expression.
+    function writeInUnconditionalUpdate(address newOwner, bool stop) external onlyOwner {
+        for (;; owner = newOwner) { //~WARN: `owner` is changed without an event but is used for access control
+            if (stop) break;
+            stop = true;
+        }
+    }
+
+    function _setOwner(address newOwner) internal {
+        owner = newOwner; //~WARN: `owner` is changed without an event but is used for access control
+    }
+
+    function _logOwner(address newOwner) internal {
+        emit OwnershipTransferred(owner, newOwner);
+    }
+}
diff --git a/crates/lint/testdata/MissingEventsAccessControlForUpdate.stderr b/crates/lint/testdata/MissingEventsAccessControlForUpdate.stderr
new file mode 100644
index 0000000000000..9102ae479792b
--- /dev/null
+++ b/crates/lint/testdata/MissingEventsAccessControlForUpdate.stderr
@@ -0,0 +1,48 @@
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControlForUpdate.sol:LL:CC
+   │
+LL │         for (; enabled; owner = newOwner) {
+   │                         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControlForUpdate.sol:LL:CC
+   │
+LL │         owner = newOwner;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControlForUpdate.sol:LL:CC
+   │
+LL │         owner = newOwner;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControlForUpdate.sol:LL:CC
+   │
+LL │         owner = newOwner;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControlForUpdate.sol:LL:CC
+   │
+LL │         for (; enabled; owner = newOwner) {
+   │                         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControlForUpdate.sol:LL:CC
+   │
+LL │         for (;; owner = newOwner) {
+   │                 ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
diff --git a/crates/lint/testdata/MissingEventsAccessControlPaths.sol b/crates/lint/testdata/MissingEventsAccessControlPaths.sol
new file mode 100644
index 0000000000000..68065532d39dc
--- /dev/null
+++ b/crates/lint/testdata/MissingEventsAccessControlPaths.sol
@@ -0,0 +1,165 @@
+//@compile-flags: --only-lint missing-events-access-control
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+contract MissingEventsAccessControlPaths {
+    address public owner = msg.sender;
+    mapping(address => bool) public roles;
+    mapping(address => bool) private scratch;
+    struct RoleData {
+        address account;
+    }
+    RoleData private roleData;
+
+    event OwnershipTransferred(address oldOwner, address newOwner);
+
+    modifier onlyOwner() {
+        require(msg.sender == owner);
+        _;
+    }
+
+    function useRole() external view {
+        require(roles[msg.sender]);
+    }
+
+    function useRoleData() external view {
+        require(roleData.account == msg.sender);
+    }
+
+    function optionalEventAfterWrite(address newOwner, bool skip) external onlyOwner {
+        owner = newOwner; //~WARN: `owner` is changed without an event but is used for access control
+        _maybeLogOwner(newOwner, skip);
+    }
+
+    function _maybeLogOwner(address newOwner, bool skip) internal {
+        if (skip) return;
+        emit OwnershipTransferred(owner, newOwner);
+    }
+
+    function optionalNestedEvent(address newOwner, bool skip) external onlyOwner {
+        _maybeLogOwnerNested(newOwner, skip);
+        owner = newOwner; //~WARN: `owner` is changed without an event but is used for access control
+    }
+
+    function _maybeLogOwnerNested(address newOwner, bool skip) internal {
+        _maybeLogOwner(newOwner, skip);
+    }
+
+    function guaranteedEvent(address newOwner, bool stop) external onlyOwner {
+        _alwaysLogOwner(newOwner, stop);
+        owner = newOwner;
+    }
+
+    function guaranteedEventAfterWrite(address newOwner, bool stop) external onlyOwner {
+        owner = newOwner;
+        _alwaysLogOwner(newOwner, stop);
+    }
+
+    function _alwaysLogOwner(address newOwner, bool stop) internal {
+        emit OwnershipTransferred(owner, newOwner);
+        if (stop) return;
+    }
+
+    function optionalHelperWrite(address newOwner, bool skip) external onlyOwner {
+        _setOwnerUnlessSkipped(newOwner, skip);
+    }
+
+    function _setOwnerUnlessSkipped(address newOwner, bool skip) internal {
+        if (skip) return;
+        owner = newOwner;
+        emit OwnershipTransferred(owner, newOwner);
+    }
+
+    // Distinct branch writes must not be confused when matching return states.
+    function branchWrites(address newOwner, bool early) external onlyOwner {
+        if (early) {
+            owner = newOwner;
+            emit OwnershipTransferred(owner, newOwner);
+            return;
+        }
+        owner = newOwner;
+        emit OwnershipTransferred(owner, newOwner);
+    }
+
+    function uncoveredReturningBranch(address newOwner, bool early) external onlyOwner {
+        if (early) {
+            owner = newOwner; //~WARN: `owner` is changed without an event but is used for access control
+            return;
+        }
+        owner = newOwner;
+        emit OwnershipTransferred(owner, newOwner);
+    }
+
+    function uncoveredFallthroughBranch(address newOwner, bool early) external onlyOwner {
+        if (early) {
+            owner = newOwner;
+            emit OwnershipTransferred(owner, newOwner);
+            return;
+        }
+        owner = newOwner; //~WARN: `owner` is changed without an event but is used for access control
+    }
+
+    function missingEventOnReturn(address newOwner, bool early) external onlyOwner {
+        owner = newOwner; //~WARN: `owner` is changed without an event but is used for access control
+        if (early) return;
+        emit OwnershipTransferred(owner, newOwner);
+    }
+
+    function missingEventOnSelfdestruct(address newOwner, bool early) external onlyOwner {
+        owner = newOwner; //~WARN: `owner` is changed without an event but is used for access control
+        if (early) {
+            emit OwnershipTransferred(owner, newOwner);
+            return;
+        }
+        selfdestruct(payable(msg.sender));
+    }
+
+    function eventAfterPossibleSelfdestruct(address newOwner, bool stop) external onlyOwner {
+        owner = newOwner; //~WARN: `owner` is changed without an event but is used for access control
+        if (stop) selfdestruct(payable(msg.sender));
+        emit OwnershipTransferred(owner, newOwner);
+    }
+
+    function nestedPossibleSelfdestruct(address newOwner, bool stop) external onlyOwner {
+        owner = newOwner; //~WARN: `owner` is changed without an event but is used for access control
+        _nestedSelfdestruct(stop);
+        emit OwnershipTransferred(owner, newOwner);
+    }
+
+    function _nestedSelfdestruct(bool stop) internal {
+        _maybeSelfdestruct(stop);
+    }
+
+    function _maybeSelfdestruct(bool stop) internal {
+        if (stop) selfdestruct(payable(msg.sender));
+    }
+
+    function writeAfterTerminalOrEvent(address newOwner, bool stop) external onlyOwner {
+        _terminalOrLog(newOwner, stop);
+        owner = newOwner;
+    }
+
+    function _terminalOrLog(address newOwner, bool stop) internal {
+        if (stop) selfdestruct(payable(msg.sender));
+        emit OwnershipTransferred(owner, newOwner);
+    }
+
+    function rebindThenWrite(address account) external onlyOwner {
+        mapping(address => bool) storage selected = roles;
+        selected = scratch;
+        selected[account] = true;
+    }
+
+    function rebindToRolesThenWrite(address account) external onlyOwner {
+        mapping(address => bool) storage selected = scratch;
+        selected = roles;
+        selected[account] = true; //~WARN: `roles` is changed without an event but is used for access control
+    }
+
+    // Assigning memory data to a member through a storage pointer changes the referenced state.
+    function copyMemoryIntoStorage(RoleData memory value) public onlyOwner {
+        RoleData storage selected = roleData;
+        selected.account = value.account; //~WARN: `roleData` is changed without an event but is used for access control
+    }
+}
diff --git a/crates/lint/testdata/MissingEventsAccessControlPaths.stderr b/crates/lint/testdata/MissingEventsAccessControlPaths.stderr
new file mode 100644
index 0000000000000..48e71375ed094
--- /dev/null
+++ b/crates/lint/testdata/MissingEventsAccessControlPaths.stderr
@@ -0,0 +1,80 @@
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControlPaths.sol:LL:CC
+   │
+LL │         owner = newOwner;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControlPaths.sol:LL:CC
+   │
+LL │         owner = newOwner;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControlPaths.sol:LL:CC
+   │
+LL │             owner = newOwner;
+   │             ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControlPaths.sol:LL:CC
+   │
+LL │         owner = newOwner;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControlPaths.sol:LL:CC
+   │
+LL │         owner = newOwner;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControlPaths.sol:LL:CC
+   │
+LL │         owner = newOwner;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControlPaths.sol:LL:CC
+   │
+LL │         owner = newOwner;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `owner` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControlPaths.sol:LL:CC
+   │
+LL │         owner = newOwner;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `roles` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControlPaths.sol:LL:CC
+   │
+LL │         selected[account] = true;
+   │         ━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
+warning[missing-events-access-control]: `roleData` is changed without an event but is used for access control
+   ╭▸ ROOT/testdata/MissingEventsAccessControlPaths.sol:LL:CC
+   │
+LL │         selected.account = value.account;
+   │         ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-access-control
+
diff --git a/crates/lint/testdata/MissingEventsArithmetic.sol b/crates/lint/testdata/MissingEventsArithmetic.sol
new file mode 100644
index 0000000000000..6ed077c95b72d
--- /dev/null
+++ b/crates/lint/testdata/MissingEventsArithmetic.sol
@@ -0,0 +1,726 @@
+//@compile-flags: --only-lint missing-events-arithmetic
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+contract MissingEventsArithmetic {
+    address public owner = msg.sender;
+
+    uint256 public buyPrice;
+    uint256 public sellFeeBps;
+    uint256 public cap;
+    uint256 public rewardRate;
+    uint256 public conditionallyEmittedValue;
+    uint256 public selfIncrementedValue;
+    uint256 public prefixIncrementedValue;
+    uint256 public postfixDecrementedValue;
+    uint256 public stateDelta;
+    uint256 public plainValue;
+    uint256 public fixedValue;
+    uint256 public protectedOnlyValue;
+    uint256 public senderObservedValue;
+    uint256 public senderNonZeroValue;
+    uint256 public wrongPolarityValue;
+    mapping(address => uint256) public balances;
+
+    event BuyPriceUpdated(uint256 newBuyPrice);
+    event CapUpdated(uint256 newCap);
+    event ConditionallyEmittedValueUpdated(uint256 newValue);
+    event Touched();
+
+    modifier onlyOwner() {
+        require(msg.sender == owner, "not owner");
+        _;
+    }
+
+    modifier onlyOwnerViaCheck() {
+        _checkOwner();
+        _;
+    }
+
+    modifier onlyOwnerViaTupleAlias() {
+        (address caller, bool live) = (msg.sender, true);
+        require(live && caller == owner, "not owner");
+        _;
+    }
+
+    modifier onlyOwnerViaReassignedTupleAlias(address candidate) {
+        address caller = msg.sender;
+        (caller,) = (candidate, false);
+        require(caller == owner, "not owner");
+        _;
+    }
+
+    modifier onlyPositive(uint256 value) {
+        require(value > 0, "not positive");
+        _;
+    }
+
+    // SHOULD FAIL:
+
+    function setBuyPrice(uint256 newBuyPrice) external onlyOwner {
+        buyPrice = newBuyPrice; //~WARN: `buyPrice` is changed without an event but is used in arithmetic
+    }
+
+    function setSellFee(uint256 newFee) external onlyOwner {
+        uint256 fee = newFee;
+        sellFeeBps = fee; //~WARN: `sellFeeBps` is changed without an event but is used in arithmetic
+    }
+
+    function setCap(uint256 newCap) external onlyOwner {
+        _setCap(newCap);
+    }
+
+    function _setCap(uint256 newCap) internal {
+        cap = newCap; //~WARN: `cap` is changed without an event but is used in arithmetic
+    }
+
+    function increaseRewardRate(uint256 delta) external onlyOwner {
+        rewardRate += delta; //~WARN: `rewardRate` is changed without an event but is used in arithmetic
+    }
+
+    function setBuyPriceOZStyle(uint256 newBuyPrice) external onlyOwnerViaCheck {
+        buyPrice = newBuyPrice; //~WARN: `buyPrice` is changed without an event but is used in arithmetic
+    }
+
+    function setBuyPriceViaTupleAlias(uint256 newBuyPrice) external onlyOwnerViaTupleAlias {
+        buyPrice = newBuyPrice; //~WARN: `buyPrice` is changed without an event but is used in arithmetic
+    }
+
+    function setConditionallyEmittedValue(uint256 newValue, bool withEvent) external onlyOwner {
+        if (withEvent) {
+            conditionallyEmittedValue = newValue;
+            emit ConditionallyEmittedValueUpdated(newValue);
+        } else {
+            conditionallyEmittedValue = newValue; //~WARN: `conditionallyEmittedValue` is changed without an event but is used in arithmetic
+        }
+    }
+
+    function incrementSelf() external onlyOwner {
+        selfIncrementedValue += 1; //~WARN: `selfIncrementedValue` is changed without an event but is used in arithmetic
+    }
+
+    function incrementByStateDelta() external onlyOwner {
+        selfIncrementedValue += stateDelta; //~WARN: `selfIncrementedValue` is changed without an event but is used in arithmetic
+    }
+
+    function prefixIncrement() external onlyOwner {
+        ++prefixIncrementedValue; //~WARN: `prefixIncrementedValue` is changed without an event but is used in arithmetic
+    }
+
+    function postfixDecrement() external onlyOwner {
+        postfixDecrementedValue--; //~WARN: `postfixDecrementedValue` is changed without an event but is used in arithmetic
+    }
+
+    // Arithmetic usage that makes the values critical.
+
+    function buyQuote(uint256 amount) external view returns (uint256) {
+        return amount / buyPrice;
+    }
+
+    function feeQuote(uint256 amount) external view returns (uint256) {
+        uint256 fee = sellFeeBps;
+        return amount * fee / 10_000;
+    }
+
+    function cappedAmount(uint256 amount) external view returns (uint256) {
+        return amount + cap;
+    }
+
+    function rewardQuote(uint256 amount) external view returns (uint256) {
+        return _rewardQuote(amount, rewardRate);
+    }
+
+    function conditionallyEmittedQuote(uint256 amount) external view returns (uint256) {
+        return amount * conditionallyEmittedValue;
+    }
+
+    function selfIncrementedQuote(uint256 amount) external view returns (uint256) {
+        return amount + selfIncrementedValue;
+    }
+
+    function prefixIncrementedQuote(uint256 amount) external view returns (uint256) {
+        return amount * prefixIncrementedValue;
+    }
+
+    function postfixDecrementedQuote(uint256 amount) external view returns (uint256) {
+        return amount * postfixDecrementedValue;
+    }
+
+    function _rewardQuote(uint256 amount, uint256 rate) internal pure returns (uint256) {
+        return amount * rate;
+    }
+
+    // SHOULD PASS:
+
+    function setBuyPriceWithEvent(uint256 newBuyPrice) external onlyOwner {
+        buyPrice = newBuyPrice;
+        emit BuyPriceUpdated(newBuyPrice);
+    }
+
+    function setCapWithInternalEvent(uint256 newCap) external onlyOwner {
+        _setCapWithEvent(newCap);
+    }
+
+    function _setCapWithEvent(uint256 newCap) internal {
+        cap = newCap;
+        emit CapUpdated(newCap);
+    }
+
+    function setWithUnrelatedEvent(uint256 newBuyPrice) external onlyOwner {
+        emit Touched();
+        buyPrice = newBuyPrice; //~WARN: `buyPrice` is changed without an event but is used in arithmetic
+    }
+
+    function unprotectedSetBuyPrice(uint256 newBuyPrice) external {
+        buyPrice = newBuyPrice;
+    }
+
+    function setBuyPriceViaReassignedTupleAlias(address candidate, uint256 newBuyPrice)
+        external
+        onlyOwnerViaReassignedTupleAlias(candidate)
+    {
+        buyPrice = newBuyPrice;
+    }
+
+    function setPlainValue(uint256 newValue) external onlyOwner {
+        plainValue = newValue;
+    }
+
+    function readPlainValue() external view returns (uint256) {
+        return plainValue;
+    }
+
+    function setFixedValue() external onlyOwner {
+        fixedValue = 100;
+    }
+
+    function fixedQuote(uint256 amount) external view returns (uint256) {
+        return amount * fixedValue;
+    }
+
+    function onlyPositiveSet(uint256 newFee) external onlyPositive(newFee) {
+        sellFeeBps = newFee;
+    }
+
+    function setProtectedOnlyValue(uint256 newValue) external onlyOwner {
+        protectedOnlyValue = newValue;
+    }
+
+    function protectedOnlyQuote(uint256 amount) external view onlyOwner returns (uint256) {
+        return amount * protectedOnlyValue;
+    }
+
+    function observesSenderButDoesNotRestrict(uint256 newValue) external {
+        if (msg.sender == owner) {
+            newValue += 1;
+        }
+        senderObservedValue = newValue;
+    }
+
+    function senderObservedQuote(uint256 amount) external view returns (uint256) {
+        return amount * senderObservedValue;
+    }
+
+    function requiresSenderNonZeroButDoesNotRestrict(uint256 newValue) external {
+        require(msg.sender != address(0), "zero sender");
+        senderNonZeroValue = newValue;
+    }
+
+    function senderNonZeroQuote(uint256 amount) external view returns (uint256) {
+        return amount * senderNonZeroValue;
+    }
+
+    // Returning on the authorized branch is not access control, so this setter stays out of scope.
+    function wrongPolaritySetWithEvent(uint256 newValue) external {
+        if (msg.sender == owner) return;
+        wrongPolarityValue = newValue;
+        emit ConditionallyEmittedValueUpdated(newValue);
+    }
+
+    function wrongPolarityQuote(uint256 amount) external view returns (uint256) {
+        return amount * wrongPolarityValue;
+    }
+
+    function setBalance(address account, uint256 amount) external onlyOwner {
+        balances[account] = amount;
+    }
+
+    function balanceQuote(address account, uint256 amount) external view returns (uint256) {
+        return balances[account] * amount;
+    }
+
+    constructor(uint256 initialBuyPrice) {
+        buyPrice = initialBuyPrice;
+    }
+
+    function _checkOwner() internal view {
+        if (owner != _msgSender()) revert();
+    }
+
+    function _msgSender() internal view returns (address) {
+        return msg.sender;
+    }
+}
+
+contract MissingEventsArithmeticNoProtectedMutatingEntryPoint {
+    address public owner = msg.sender;
+    uint256 public price;
+
+    modifier onlyOwner() {
+        require(msg.sender == owner, "not owner");
+        _;
+    }
+
+    function setPrice(uint256 newPrice) external {
+        price = newPrice;
+    }
+
+    function quote(uint256 amount) external view returns (uint256) {
+        return amount * price;
+    }
+
+    function protectedQuote(uint256 amount) external view onlyOwner returns (uint256) {
+        return amount / price;
+    }
+}
+
+// Reproduction cases for oracle findings.
+
+contract ReproEmittedEventIsSticky {
+    address public owner = msg.sender;
+    uint256 public price;
+    event Touched();
+    event PriceUpdated(uint256);
+
+    modifier onlyOwner() {
+        require(msg.sender == owner);
+        _;
+    }
+
+    function buyQuote(uint256 amount) external view returns (uint256) {
+        return amount / price;
+    }
+
+    function setWithUnrelatedEmitBefore(uint256 newPrice) external onlyOwner {
+        emit Touched();
+        price = newPrice; //~WARN: `price` is changed without an event but is used in arithmetic
+    }
+
+    function setPriceWithEvent(uint256 newPrice) external onlyOwner {
+        price = newPrice;
+        emit PriceUpdated(newPrice);
+    }
+}
+
+contract ReproReturnNotTerminator {
+    address public owner = msg.sender;
+    uint256 public price;
+    event PriceUpdated(uint256);
+
+    modifier onlyOwner() {
+        require(msg.sender == owner);
+        _;
+    }
+
+    function buyQuote(uint256 amount) external view returns (uint256) {
+        return amount / price;
+    }
+
+    function setWithReturnBeforeEmit(uint256 newPrice, bool skip) external onlyOwner {
+        price = newPrice; //~WARN: `price` is changed without an event but is used in arithmetic
+        if (skip) return;
+        emit PriceUpdated(newPrice);
+    }
+}
+
+interface IOracle {
+    function getPrice() external view returns (uint256);
+}
+
+contract ReproTryClausesShareState {
+    address public owner = msg.sender;
+    uint256 public price;
+    address public oracle;
+    event PriceUpdated(uint256);
+
+    modifier onlyOwner() {
+        require(msg.sender == owner);
+        _;
+    }
+
+    function buyQuote(uint256 amount) external view returns (uint256) {
+        return amount / price;
+    }
+
+    function setViaOracle(uint256 fallbackPrice) external onlyOwner {
+        try IOracle(oracle).getPrice() returns (uint256 p) {
+            price = p;
+            emit PriceUpdated(p);
+        } catch {
+            price = fallbackPrice; //~WARN: `price` is changed without an event but is used in arithmetic
+        }
+    }
+}
+
+contract ReproUntaintedAssigns {
+    address public owner = msg.sender;
+    uint256 public price;
+    uint256 public referencePrice;
+
+    modifier onlyOwner() {
+        require(msg.sender == owner);
+        _;
+    }
+
+    function buyQuote(uint256 amount) external view returns (uint256) {
+        return amount / price;
+    }
+
+    function setPriceFromStateVar() external onlyOwner {
+        price = referencePrice; //~WARN: `price` is changed without an event but is used in arithmetic
+    }
+
+    function setPriceFromTimestamp() external onlyOwner {
+        price = block.timestamp; //~WARN: `price` is changed without an event but is used in arithmetic
+    }
+}
+
+contract ReproHelperReturnArithmetic {
+    address public owner = msg.sender;
+    uint256 public rate;
+
+    modifier onlyOwner() {
+        require(msg.sender == owner);
+        _;
+    }
+
+    function _getRate() internal view returns (uint256) {
+        return rate;
+    }
+
+    function rateQuote(uint256 amount) external view returns (uint256) {
+        return amount * _getRate();
+    }
+
+    function setRate(uint256 newRate) external onlyOwner {
+        rate = newRate; //~WARN: `rate` is changed without an event but is used in arithmetic
+    }
+}
+
+contract ReproAccessGuardTooLoose {
+    address public owner = msg.sender;
+    uint256 public price;
+    bool public flag;
+
+    function buyQuote(uint256 amount) external view returns (uint256) {
+        return amount / price;
+    }
+
+    function setWithNonDominatingGuard(uint256 newPrice) external {
+        if (flag) require(msg.sender == owner, "not owner");
+        price = newPrice;
+    }
+}
+
+contract ReproMayExitNotMustExit {
+    error NotOwner();
+
+    address public owner = msg.sender;
+    uint256 public price;
+    bool public flag;
+
+    function buyQuote(uint256 amount) external view returns (uint256) {
+        return amount / price;
+    }
+
+    function setWithWeakGuard(uint256 newPrice) external {
+        if (msg.sender != owner) {
+            if (flag) revert NotOwner();
+        }
+        price = newPrice;
+    }
+}
+
+contract ReproModifierBodyNotAnalyzed {
+    address public owner = msg.sender;
+    uint256 public price;
+    event PriceUpdated(uint256);
+
+    modifier onlyOwner() {
+        require(msg.sender == owner);
+        _;
+    }
+
+    modifier emitAfter() {
+        _;
+        emit PriceUpdated(price);
+    }
+
+    function buyQuote(uint256 amount) external view returns (uint256) {
+        return amount / price;
+    }
+
+    function setPriceWithModifierEvent(uint256 newPrice) external onlyOwner emitAfter {
+        price = newPrice;
+    }
+}
+
+contract ReproAccessCheckTooBroad {
+    address public owner = msg.sender;
+    uint256 public price;
+
+    function buyQuote(uint256 amount) external view returns (uint256) {
+        return amount / price;
+    }
+
+    function setWithOrCondition(uint256 newPrice, uint256 amount) external {
+        require(msg.sender == owner || amount > 0, "no access");
+        price = newPrice;
+    }
+}
+
+// Regression: state variable declared in a base contract, protected entry point and
+// arithmetic use declared in the derived contract.
+contract EventsArithmeticBase {
+    uint256 public baseRate;
+}
+
+contract EventsArithmeticDerived is EventsArithmeticBase {
+    address public owner = msg.sender;
+
+    modifier onlyOwner() {
+        require(msg.sender == owner, "not owner");
+        _;
+    }
+
+    function increaseBaseRate(uint256 delta) external onlyOwner {
+        baseRate += delta; //~WARN: `baseRate` is changed without an event but is used in arithmetic
+    }
+
+    function baseRateQuote(uint256 amount) external view returns (uint256) {
+        return amount * baseRate;
+    }
+}
+
+// Regression: an overridden base implementation is not an entry point of the derived
+// contract, so its write must not be reported when the override emits an event.
+abstract contract EventsArithmeticOverrideBase {
+    uint256 public fee;
+    address public owner = msg.sender;
+
+    modifier onlyOwner() {
+        require(msg.sender == owner, "not owner");
+        _;
+    }
+
+    function setFee(uint256 newFee) external virtual onlyOwner {
+        fee = newFee;
+    }
+
+    function quote(uint256 amount) external view returns (uint256) {
+        return amount * fee;
+    }
+}
+
+contract EventsArithmeticOverrideDerived is EventsArithmeticOverrideBase {
+    event FeeUpdated(uint256 fee);
+
+    function setFee(uint256 newFee) external override onlyOwner {
+        fee = newFee;
+        emit FeeUpdated(newFee);
+    }
+}
+
+// Regression: a `virtual` internal hook dispatches to the derived override, which emits.
+abstract contract EventsArithmeticHookBase {
+    uint256 public hookFee;
+    address public owner = msg.sender;
+
+    modifier onlyOwner() {
+        require(msg.sender == owner, "not owner");
+        _;
+    }
+
+    function setHookFee(uint256 newFee) external onlyOwner {
+        _setHookFee(newFee);
+    }
+
+    function _setHookFee(uint256 newFee) internal virtual {
+        hookFee = newFee;
+    }
+
+    function hookQuote(uint256 amount) external view returns (uint256) {
+        return amount * hookFee;
+    }
+}
+
+contract EventsArithmeticHookDerived is EventsArithmeticHookBase {
+    event HookFeeUpdated(uint256 fee);
+
+    function _setHookFee(uint256 newFee) internal override {
+        hookFee = newFee;
+        emit HookFeeUpdated(newFee);
+    }
+}
+
+// An unimplemented `virtual` hook resolves to the derived implementation, which is inspected.
+abstract contract EventsArithmeticAbstractHookBase {
+    uint256 public abstractFee;
+    address public owner = msg.sender;
+
+    modifier onlyOwner() {
+        require(msg.sender == owner, "not owner");
+        _;
+    }
+
+    function setAbstractFee(uint256 newFee) external onlyOwner {
+        _setAbstractFee(newFee);
+    }
+
+    function _setAbstractFee(uint256 newFee) internal virtual;
+
+    function abstractQuote(uint256 amount) external view returns (uint256) {
+        return amount * abstractFee;
+    }
+}
+
+contract EventsArithmeticAbstractHookDerived is EventsArithmeticAbstractHookBase {
+    function _setAbstractFee(uint256 newFee) internal override {
+        abstractFee = newFee; //~WARN: `abstractFee` is changed without an event but is used in arithmetic
+    }
+}
+
+// An inherited arithmetic expression follows a virtual return helper to the derived override.
+abstract contract EventsArithmeticReturnHookBase {
+    uint256 public returnHookFee;
+    address public owner = msg.sender;
+
+    modifier onlyOwner() {
+        require(msg.sender == owner, "not owner");
+        _;
+    }
+
+    function setReturnHookFee(uint256 newFee) external onlyOwner {
+        returnHookFee = newFee; //~WARN: `returnHookFee` is changed without an event but is used in arithmetic
+    }
+
+    function returnHookQuote(uint256 amount) external view returns (uint256) {
+        return amount * _returnHookFee();
+    }
+
+    function _returnHookFee() internal view virtual returns (uint256);
+}
+
+contract EventsArithmeticReturnHookDerived is EventsArithmeticReturnHookBase {
+    function _returnHookFee() internal view override returns (uint256) {
+        return returnHookFee;
+    }
+}
+
+// Qualified inherited helper calls preserve their Solidity dispatch semantics.
+contract EventsArithmeticQualifiedBase {
+    uint256 public superFee;
+    uint256 public baseFee;
+    address public owner = msg.sender;
+
+    modifier onlyOwner() {
+        require(msg.sender == owner, "not owner");
+        _;
+    }
+
+    function _setSuperFee(uint256 newFee) internal {
+        superFee = newFee; //~WARN: `superFee` is changed without an event but is used in arithmetic
+    }
+
+    function _setBaseFee(uint256 newFee) internal {
+        baseFee = newFee; //~WARN: `baseFee` is changed without an event but is used in arithmetic
+    }
+
+    function _superFee() internal view returns (uint256) {
+        return superFee;
+    }
+
+    function _baseFee() internal view returns (uint256) {
+        return baseFee;
+    }
+}
+
+contract EventsArithmeticQualifiedDerived is EventsArithmeticQualifiedBase {
+    function setSuperFee(uint256 newFee) external onlyOwner {
+        super._setSuperFee(newFee);
+    }
+
+    function setBaseFee(uint256 newFee) external onlyOwner {
+        EventsArithmeticQualifiedBase._setBaseFee(newFee);
+    }
+
+    function superQuote(uint256 amount) external view returns (uint256) {
+        return amount * super._superFee();
+    }
+
+    function baseQuote(uint256 amount) external view returns (uint256) {
+        return amount * EventsArithmeticQualifiedBase._baseFee();
+    }
+}
+
+// A concrete base inherited unchanged is reported once, not once per contract in the hierarchy.
+contract EventsArithmeticConcreteBase {
+    uint256 public concreteFee;
+    address public owner = msg.sender;
+
+    modifier onlyOwner() {
+        require(msg.sender == owner, "not owner");
+        _;
+    }
+
+    function setConcreteFee(uint256 newFee) external onlyOwner {
+        concreteFee = newFee; //~WARN: `concreteFee` is changed without an event but is used in arithmetic
+    }
+
+    function concreteQuote(uint256 amount) external view returns (uint256) {
+        return amount * concreteFee;
+    }
+}
+
+contract EventsArithmeticConcreteDerived is EventsArithmeticConcreteBase {}
+
+contract NamedArgumentArithmetic {
+    address public owner = msg.sender;
+    uint256 public fee;
+
+    function setFee(uint256 next) external {
+        require(msg.sender == owner);
+        _setFee({next: next, ignored: 0});
+    }
+
+    function _setFee(uint256 ignored, uint256 next) internal {
+        fee = next; //~WARN: `fee` is changed without an event but is used in arithmetic
+    }
+
+    function quote() external view returns (uint256) {
+        return _scale({value: fee, ignored: 0});
+    }
+
+    function _scale(uint256 ignored, uint256 value) internal pure returns (uint256) {
+        return value * 2;
+    }
+}
+
+contract ReproAccessNameCalleeResultIgnored {
+    address public owner = msg.sender;
+    uint256 public price;
+
+    function buyQuote(uint256 amount) external view returns (uint256) {
+        return amount / price;
+    }
+
+    function _checkOwner() internal view returns (bool) {
+        return msg.sender == owner;
+    }
+
+    function setPrice(uint256 newPrice) external {
+        _checkOwner();
+        price = newPrice;
+    }
+}
diff --git a/crates/lint/testdata/MissingEventsArithmetic.stderr b/crates/lint/testdata/MissingEventsArithmetic.stderr
new file mode 100644
index 0000000000000..224b9ce46f5cc
--- /dev/null
+++ b/crates/lint/testdata/MissingEventsArithmetic.stderr
@@ -0,0 +1,200 @@
+warning[missing-events-arithmetic]: `buyPrice` is changed without an event but is used in arithmetic
+   ╭▸ ROOT/testdata/MissingEventsArithmetic.sol:LL:CC
+   │
+LL │         buyPrice = newBuyPrice;
+   │         ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-arithmetic
+
+warning[missing-events-arithmetic]: `sellFeeBps` is changed without an event but is used in arithmetic
+   ╭▸ ROOT/testdata/MissingEventsArithmetic.sol:LL:CC
+   │
+LL │         sellFeeBps = fee;
+   │         ━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-arithmetic
+
+warning[missing-events-arithmetic]: `cap` is changed without an event but is used in arithmetic
+   ╭▸ ROOT/testdata/MissingEventsArithmetic.sol:LL:CC
+   │
+LL │         cap = newCap;
+   │         ━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-arithmetic
+
+warning[missing-events-arithmetic]: `rewardRate` is changed without an event but is used in arithmetic
+   ╭▸ ROOT/testdata/MissingEventsArithmetic.sol:LL:CC
+   │
+LL │         rewardRate += delta;
+   │         ━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-arithmetic
+
+warning[missing-events-arithmetic]: `buyPrice` is changed without an event but is used in arithmetic
+   ╭▸ ROOT/testdata/MissingEventsArithmetic.sol:LL:CC
+   │
+LL │         buyPrice = newBuyPrice;
+   │         ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-arithmetic
+
+warning[missing-events-arithmetic]: `buyPrice` is changed without an event but is used in arithmetic
+   ╭▸ ROOT/testdata/MissingEventsArithmetic.sol:LL:CC
+   │
+LL │         buyPrice = newBuyPrice;
+   │         ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-arithmetic
+
+warning[missing-events-arithmetic]: `conditionallyEmittedValue` is changed without an event but is used in arithmetic
+   ╭▸ ROOT/testdata/MissingEventsArithmetic.sol:LL:CC
+   │
+LL │ …     conditionallyEmittedValue = newValue;
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-arithmetic
+
+warning[missing-events-arithmetic]: `selfIncrementedValue` is changed without an event but is used in arithmetic
+   ╭▸ ROOT/testdata/MissingEventsArithmetic.sol:LL:CC
+   │
+LL │         selfIncrementedValue += 1;
+   │         ━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-arithmetic
+
+warning[missing-events-arithmetic]: `selfIncrementedValue` is changed without an event but is used in arithmetic
+   ╭▸ ROOT/testdata/MissingEventsArithmetic.sol:LL:CC
+   │
+LL │         selfIncrementedValue += stateDelta;
+   │         ━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-arithmetic
+
+warning[missing-events-arithmetic]: `prefixIncrementedValue` is changed without an event but is used in arithmetic
+   ╭▸ ROOT/testdata/MissingEventsArithmetic.sol:LL:CC
+   │
+LL │         ++prefixIncrementedValue;
+   │           ━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-arithmetic
+
+warning[missing-events-arithmetic]: `postfixDecrementedValue` is changed without an event but is used in arithmetic
+   ╭▸ ROOT/testdata/MissingEventsArithmetic.sol:LL:CC
+   │
+LL │         postfixDecrementedValue--;
+   │         ━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-arithmetic
+
+warning[missing-events-arithmetic]: `buyPrice` is changed without an event but is used in arithmetic
+   ╭▸ ROOT/testdata/MissingEventsArithmetic.sol:LL:CC
+   │
+LL │         buyPrice = newBuyPrice;
+   │         ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-arithmetic
+
+warning[missing-events-arithmetic]: `price` is changed without an event but is used in arithmetic
+   ╭▸ ROOT/testdata/MissingEventsArithmetic.sol:LL:CC
+   │
+LL │         price = newPrice;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-arithmetic
+
+warning[missing-events-arithmetic]: `price` is changed without an event but is used in arithmetic
+   ╭▸ ROOT/testdata/MissingEventsArithmetic.sol:LL:CC
+   │
+LL │         price = newPrice;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-arithmetic
+
+warning[missing-events-arithmetic]: `price` is changed without an event but is used in arithmetic
+   ╭▸ ROOT/testdata/MissingEventsArithmetic.sol:LL:CC
+   │
+LL │             price = fallbackPrice;
+   │             ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-arithmetic
+
+warning[missing-events-arithmetic]: `price` is changed without an event but is used in arithmetic
+   ╭▸ ROOT/testdata/MissingEventsArithmetic.sol:LL:CC
+   │
+LL │         price = referencePrice;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-arithmetic
+
+warning[missing-events-arithmetic]: `price` is changed without an event but is used in arithmetic
+   ╭▸ ROOT/testdata/MissingEventsArithmetic.sol:LL:CC
+   │
+LL │         price = block.timestamp;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-arithmetic
+
+warning[missing-events-arithmetic]: `rate` is changed without an event but is used in arithmetic
+   ╭▸ ROOT/testdata/MissingEventsArithmetic.sol:LL:CC
+   │
+LL │         rate = newRate;
+   │         ━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-arithmetic
+
+warning[missing-events-arithmetic]: `baseRate` is changed without an event but is used in arithmetic
+   ╭▸ ROOT/testdata/MissingEventsArithmetic.sol:LL:CC
+   │
+LL │         baseRate += delta;
+   │         ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-arithmetic
+
+warning[missing-events-arithmetic]: `abstractFee` is changed without an event but is used in arithmetic
+   ╭▸ ROOT/testdata/MissingEventsArithmetic.sol:LL:CC
+   │
+LL │         abstractFee = newFee;
+   │         ━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-arithmetic
+
+warning[missing-events-arithmetic]: `returnHookFee` is changed without an event but is used in arithmetic
+   ╭▸ ROOT/testdata/MissingEventsArithmetic.sol:LL:CC
+   │
+LL │         returnHookFee = newFee;
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-arithmetic
+
+warning[missing-events-arithmetic]: `superFee` is changed without an event but is used in arithmetic
+   ╭▸ ROOT/testdata/MissingEventsArithmetic.sol:LL:CC
+   │
+LL │         superFee = newFee;
+   │         ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-arithmetic
+
+warning[missing-events-arithmetic]: `baseFee` is changed without an event but is used in arithmetic
+   ╭▸ ROOT/testdata/MissingEventsArithmetic.sol:LL:CC
+   │
+LL │         baseFee = newFee;
+   │         ━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-arithmetic
+
+warning[missing-events-arithmetic]: `concreteFee` is changed without an event but is used in arithmetic
+   ╭▸ ROOT/testdata/MissingEventsArithmetic.sol:LL:CC
+   │
+LL │         concreteFee = newFee;
+   │         ━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-arithmetic
+
+warning[missing-events-arithmetic]: `fee` is changed without an event but is used in arithmetic
+   ╭▸ ROOT/testdata/MissingEventsArithmetic.sol:LL:CC
+   │
+LL │         fee = next;
+   │         ━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-arithmetic
+
diff --git a/crates/lint/testdata/MissingEventsArithmeticRepeatedBranches.sol b/crates/lint/testdata/MissingEventsArithmeticRepeatedBranches.sol
new file mode 100644
index 0000000000000..3566cf3e0eacd
--- /dev/null
+++ b/crates/lint/testdata/MissingEventsArithmeticRepeatedBranches.sol
@@ -0,0 +1,52 @@
+//@compile-flags: --only-lint missing-events-arithmetic
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+abstract contract RepeatedBranchesBase {
+    address public owner = msg.sender;
+    uint256 internal fee;
+    uint256 internal scratch;
+
+    event Updated();
+
+    modifier onlyOwner() {
+        require(msg.sender == owner);
+        _;
+    }
+
+    function quote(uint256 amount) external view returns (uint256) {
+        return amount * fee;
+    }
+
+    function branch(bool flag) internal {
+        if (flag) scratch = 1;
+        if (!flag) scratch = 2;
+    }
+}
+
+contract MissingEventsArithmeticRepeatedBranches is RepeatedBranchesBase {
+    function setFee(uint256 next, bool flag) external onlyOwner {
+        fee = next; //~WARN: `fee` is changed without an event but is used in arithmetic
+        branch(flag);
+        branch(flag);
+        branch(flag);
+        branch(flag);
+        branch(flag);
+        branch(flag);
+        fee += 1;
+        if (flag) return;
+        emit Updated();
+    }
+
+    function setFeeWithEvent(uint256 next, bool flag) external onlyOwner {
+        fee = next;
+        branch(flag);
+        branch(flag);
+        branch(flag);
+        branch(flag);
+        branch(flag);
+        branch(flag);
+        emit Updated();
+    }
+}
diff --git a/crates/lint/testdata/MissingEventsArithmeticRepeatedBranches.stderr b/crates/lint/testdata/MissingEventsArithmeticRepeatedBranches.stderr
new file mode 100644
index 0000000000000..631ada263698b
--- /dev/null
+++ b/crates/lint/testdata/MissingEventsArithmeticRepeatedBranches.stderr
@@ -0,0 +1,8 @@
+warning[missing-events-arithmetic]: `fee` is changed without an event but is used in arithmetic
+   ╭▸ ROOT/testdata/MissingEventsArithmeticRepeatedBranches.sol:LL:CC
+   │
+LL │         fee = next;
+   │         ━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-events-arithmetic
+
diff --git a/crates/lint/testdata/MissingInheritance.sol b/crates/lint/testdata/MissingInheritance.sol
new file mode 100644
index 0000000000000..bd455c54c8fea
--- /dev/null
+++ b/crates/lint/testdata/MissingInheritance.sol
@@ -0,0 +1,131 @@
+//@compile-flags: --only-lint missing-inheritance
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.20;
+
+import {IExternalThing} from "./auxiliary/MissingInheritanceExternal.sol";
+
+interface ISomething {
+    function f1() external returns (uint256);
+}
+
+interface IExtra {
+    function g() external view returns (uint256);
+}
+
+interface IERC20Like {
+    function transfer(address to, uint256 amount) external returns (bool);
+    function balanceOf(address who) external view returns (uint256);
+}
+
+interface IERC20LikeMetadata {
+    function transfer(address to, uint256 amount) external returns (bool);
+    function balanceOf(address who) external view returns (uint256);
+    function name() external view returns (string memory);
+}
+
+// Signature-only abstract contract; treated as a candidate interface.
+abstract contract IFooLike {
+    function foo() external virtual returns (uint256);
+}
+
+// SHOULD FAIL: Implements ISomething but does not explicitly inherit from it.
+contract Something { //~NOTE: contract `Something` has all selectors of interface `ISomething` but does not explicitly inherit from it
+    function f1() external pure returns (uint256) {
+        return 42;
+    }
+}
+
+// SHOULD PASS: Explicitly inherits from ISomething.
+contract SomethingExplicit is ISomething {
+    function f1() external pure override returns (uint256) {
+        return 42;
+    }
+}
+
+// SHOULD PASS: Inherits from ISomething transitively through ISomethingExt.
+interface ISomethingExt is ISomething {}
+
+contract SomethingTransitive is ISomethingExt {
+    function f1() external pure override returns (uint256) {
+        return 7;
+    }
+}
+
+// SHOULD FAIL: Abstract contract with bodies is itself a target.
+abstract contract SomethingBase { //~NOTE: contract `SomethingBase` has all selectors of interface `ISomething` but does not explicitly inherit from it
+    function f1() external virtual returns (uint256) {
+        return 1;
+    }
+}
+
+// SHOULD PASS: Inherited base `SomethingBase` already covers ISomething's selectors.
+contract SomethingDerived is SomethingBase {
+    function f1() external override returns (uint256) {
+        return 2;
+    }
+}
+
+// SHOULD FAIL: Implements both IERC20Like and IERC20LikeMetadata; only the maximal interface is reported.
+contract Token { //~NOTE: contract `Token` has all selectors of interface `IERC20LikeMetadata` but does not explicitly inherit from it
+    function transfer(address, uint256) external pure returns (bool) {
+        return true;
+    }
+    function balanceOf(address) external pure returns (uint256) {
+        return 0;
+    }
+    function name() external pure returns (string memory) {
+        return "T";
+    }
+}
+
+// SHOULD FAIL: Signature-only abstract `IFooLike` is treated as a candidate interface.
+contract Foo { //~NOTE: contract `Foo` has all selectors of interface `IFooLike` but does not explicitly inherit from it
+    function foo() external pure returns (uint256) {
+        return 1;
+    }
+}
+
+// SHOULD PASS: No external functions, nothing to flag.
+contract Empty {
+    uint256 internal x;
+}
+
+// SHOULD PASS: Implements only a strict subset of IERC20Like's selectors.
+contract OnlyTransfer {
+    function transfer(address, uint256) external pure returns (bool) {
+        return true;
+    }
+}
+
+// SHOULD PASS: Libraries are not analyzed as targets.
+library SomeLib {
+    function f1() external pure returns (uint256) {
+        return 1;
+    }
+}
+
+// SHOULD FAIL: Implements two unrelated interfaces; both are reported.
+contract MultiNoInherit { //~NOTE: contract `MultiNoInherit` has all selectors of interface `ISomething` but does not explicitly inherit from it
+    //~^NOTE: contract `MultiNoInherit` has all selectors of interface `IExtra` but does not explicitly inherit from it
+    function f1() external pure returns (uint256) {
+        return 1;
+    }
+    function g() external pure returns (uint256) {
+        return 2;
+    }
+}
+
+// SHOULD FAIL: Implements an interface declared in an external dependency without inheriting it.
+contract External { //~NOTE: contract `External` has all selectors of interface `IExternalThing` but does not explicitly inherit from it
+    function doExternalThing() external pure returns (uint256) {
+        return 1;
+    }
+}
+
+// SHOULD PASS: Explicitly inherits the external dependency interface.
+contract ExternalExplicit is IExternalThing {
+    function doExternalThing() external pure override returns (uint256) {
+        return 1;
+    }
+}
diff --git a/crates/lint/testdata/MissingInheritance.stderr b/crates/lint/testdata/MissingInheritance.stderr
new file mode 100644
index 0000000000000..499f55055f420
--- /dev/null
+++ b/crates/lint/testdata/MissingInheritance.stderr
@@ -0,0 +1,56 @@
+note[missing-inheritance]: contract `Something` has all selectors of interface `ISomething` but does not explicitly inherit from it
+   ╭▸ ROOT/testdata/MissingInheritance.sol:LL:CC
+   │
+LL │ contract Something {
+   │          ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-inheritance
+
+note[missing-inheritance]: contract `SomethingBase` has all selectors of interface `ISomething` but does not explicitly inherit from it
+   ╭▸ ROOT/testdata/MissingInheritance.sol:LL:CC
+   │
+LL │ abstract contract SomethingBase {
+   │                   ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-inheritance
+
+note[missing-inheritance]: contract `Token` has all selectors of interface `IERC20LikeMetadata` but does not explicitly inherit from it
+   ╭▸ ROOT/testdata/MissingInheritance.sol:LL:CC
+   │
+LL │ contract Token {
+   │          ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-inheritance
+
+note[missing-inheritance]: contract `Foo` has all selectors of interface `IFooLike` but does not explicitly inherit from it
+   ╭▸ ROOT/testdata/MissingInheritance.sol:LL:CC
+   │
+LL │ contract Foo {
+   │          ━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-inheritance
+
+note[missing-inheritance]: contract `MultiNoInherit` has all selectors of interface `ISomething` but does not explicitly inherit from it
+   ╭▸ ROOT/testdata/MissingInheritance.sol:LL:CC
+   │
+LL │ contract MultiNoInherit {
+   │          ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-inheritance
+
+note[missing-inheritance]: contract `MultiNoInherit` has all selectors of interface `IExtra` but does not explicitly inherit from it
+   ╭▸ ROOT/testdata/MissingInheritance.sol:LL:CC
+   │
+LL │ contract MultiNoInherit {
+   │          ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-inheritance
+
+note[missing-inheritance]: contract `External` has all selectors of interface `IExternalThing` but does not explicitly inherit from it
+   ╭▸ ROOT/testdata/MissingInheritance.sol:LL:CC
+   │
+LL │ contract External {
+   │          ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-inheritance
+
diff --git a/crates/lint/testdata/MissingZeroCheck.sol b/crates/lint/testdata/MissingZeroCheck.sol
new file mode 100644
index 0000000000000..a75f71d4b94ec
--- /dev/null
+++ b/crates/lint/testdata/MissingZeroCheck.sol
@@ -0,0 +1,334 @@
+//@compile-flags: --only-lint missing-zero-check
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+interface IExternal {
+    function ping() external;
+}
+
+contract MissingZeroCheck {
+    address public owner;
+    address payable public recipient;
+    uint256 public n;
+    mapping(address => bool) public whitelisted;
+
+    modifier nonZero(address a) {
+        require(a != address(0), "zero");
+        _;
+    }
+
+    modifier doesNothing(address a) {
+        _;
+    }
+
+    // SHOULD FAIL:
+
+    function setOwner(address newOwner) external { //~WARN: address parameter is used in a state write or value transfer without a zero-address check
+        owner = newOwner;
+    }
+
+    constructor(address initialOwner) { //~WARN: address parameter is used in a state write or value transfer without a zero-address check
+        owner = initialOwner;
+    }
+
+    function pay(address payable to) external { //~WARN: address parameter is used in a state write or value transfer without a zero-address check
+        to.transfer(1);
+    }
+
+    function lowLevel(address payable to, bytes calldata data) external { //~WARN: address parameter is used in a state write or value transfer without a zero-address check
+        (bool ok,) = to.call(data);
+        require(ok);
+    }
+
+    function withUselessModifier(address a) external doesNothing(a) { //~WARN: address parameter is used in a state write or value transfer without a zero-address check
+        owner = a;
+    }
+
+    function setOwnerViaAlias(address a) external { //~WARN: address parameter is used in a state write or value transfer without a zero-address check
+        address tmp = a;
+        owner = tmp;
+    }
+
+    function setOwnerViaReassign(address a) external { //~WARN: address parameter is used in a state write or value transfer without a zero-address check
+        address tmp;
+        tmp = a;
+        owner = tmp;
+    }
+
+    function setOwnerViaCast(address a) external { //~WARN: address parameter is used in a state write or value transfer without a zero-address check
+        owner = address(uint160(a));
+    }
+
+    function payViaAlias(address payable a) external { //~WARN: address parameter is used in a state write or value transfer without a zero-address check
+        address payable tmp = a;
+        tmp.transfer(1);
+    }
+
+    // Only `b` should be flagged; `a` is guarded.
+    function mixedParams(address a, address b) external { //~WARN: address parameter is used in a state write or value transfer without a zero-address check
+        require(a != address(0));
+        owner = a;
+        recipient = payable(b);
+    }
+
+    // Same param feeds two sinks: should produce a single diagnostic.
+    function bothSinks(address payable a) external { //~WARN: address parameter is used in a state write or value transfer without a zero-address check
+        recipient = a;
+        a.transfer(1);
+    }
+
+    function ternaryAlias(address a, bool flag) external { //~WARN: address parameter is used in a state write or value transfer without a zero-address check
+        address tmp = flag ? a : address(0);
+        owner = tmp;
+    }
+
+    function payableWrap(address a) external { //~WARN: address parameter is used in a state write or value transfer without a zero-address check
+        payable(a).transfer(1);
+    }
+
+    // Modifier called with an expression, not a direct ident: we cannot prove the guard
+    // applies, so we should still flag.
+    function modifierWithExpr(address a) external nonZero(addrIdentity(a)) { //~WARN: address parameter is used in a state write or value transfer without a zero-address check
+        owner = a;
+    }
+
+    function delegateCallSink(address a) external { //~WARN: address parameter is used in a state write or value transfer without a zero-address check
+        (bool ok,) = a.delegatecall("");
+        require(ok);
+    }
+
+    function sendSinkStmt(address payable a) external { //~WARN: address parameter is used in a state write or value transfer without a zero-address check
+        a.send(1);
+    }
+
+    function sendSinkDecl(address payable a) external { //~WARN: address parameter is used in a state write or value transfer without a zero-address check
+        bool ok = a.send(1);
+        require(ok);
+    }
+
+    function multiHopTaint(address a) external { //~WARN: address parameter is used in a state write or value transfer without a zero-address check
+        address x = a;
+        address y = x;
+        owner = y;
+    }
+
+    function guardAfterSink(address a) external { //~WARN: address parameter is used in a state write or value transfer without a zero-address check
+        owner = a;
+        require(a != address(0));
+    }
+
+    function guardOnOneBranch(address a, bool flag) external { //~WARN: address parameter is used in a state write or value transfer without a zero-address check
+        if (flag) {
+            require(a != address(0));
+        }
+        owner = a;
+    }
+
+    function guardInForLoop(address a, uint256 n) external { //~WARN: address parameter is used in a state write or value transfer without a zero-address check
+        for (uint256 i = 0; i < n; i++) {
+            require(a != address(0));
+        }
+        owner = a;
+    }
+
+    function guardInWhileLoop(address a, bool flag) external { //~WARN: address parameter is used in a state write or value transfer without a zero-address check
+        while (flag) {
+            require(a != address(0));
+            flag = false;
+        }
+        owner = a;
+    }
+
+    function guardInTryClause(address a, address payable target) external { //~WARN: address parameter is used in a state write or value transfer without a zero-address check
+        try IExternal(target).ping() {
+            require(a != address(0));
+        } catch {
+            require(a != address(0));
+        }
+        owner = a;
+    }
+
+    function guardNotAgainstZeroSelf(address newOwner) external { //~WARN: address parameter is used in a state write or value transfer without a zero-address check
+        require(newOwner != address(this));
+        owner = newOwner;
+    }
+
+    function guardMappingLookupOnly(address newOwner) external { //~WARN: address parameter is used in a state write or value transfer without a zero-address check
+        require(whitelisted[newOwner]);
+        owner = newOwner;
+    }
+
+    function guardNotAgainstZeroOtherVar(address newOwner) external { //~WARN: address parameter is used in a state write or value transfer without a zero-address check
+        require(newOwner != owner);
+        owner = newOwner;
+    }
+
+    function guardUnrelatedConjunct(address payable to, uint256 amt) external { //~WARN: address parameter is used in a state write or value transfer without a zero-address check
+        require(amt > 0 && to != msg.sender);
+        to.transfer(amt);
+    }
+
+    function guardViaArbitraryCallNotCast(address newOwner) external { //~WARN: address parameter is used in a state write or value transfer without a zero-address check
+        require(newOwner != lookup(0));
+        owner = newOwner;
+    }
+
+    function lookup(uint256) internal view returns (address) {
+        return owner;
+    }
+
+    function guardViaBitwiseNotNotZero(address newOwner) external { //~WARN: address parameter is used in a state write or value transfer without a zero-address check
+        require(newOwner != address(~uint160(0)));
+        owner = newOwner;
+    }
+
+    function guardRequiresZero(address newOwner) external { //~WARN: address parameter is used in a state write or value transfer without a zero-address check
+        require(newOwner == address(0));
+        owner = newOwner;
+    }
+
+    function guardOnOneDisjunct(address newOwner, bool flag) external { //~WARN: address parameter is used in a state write or value transfer without a zero-address check
+        require(newOwner != address(0) || flag);
+        owner = newOwner;
+    }
+
+    function sinkInZeroBranch(address newOwner) external { //~WARN: address parameter is used in a state write or value transfer without a zero-address check
+        if (newOwner == address(0)) owner = newOwner;
+    }
+
+    // SHOULD PASS:
+
+    function setOwnerGuarded(address newOwner) external {
+        require(newOwner != address(0), "zero");
+        owner = newOwner;
+    }
+
+    function setOwnerIfGuarded(address newOwner) external {
+        if (newOwner == address(0)) revert();
+        owner = newOwner;
+    }
+
+    function setOwnerAssertGuarded(address newOwner) external {
+        assert(newOwner != address(0));
+        owner = newOwner;
+    }
+
+    function setOwnerWithModifier(address newOwner) external nonZero(newOwner) {
+        owner = newOwner;
+    }
+
+    function setN(uint256 v) external {
+        n = v;
+    }
+
+    function viewer(address a) external view returns (address) {
+        return a;
+    }
+
+    function pureFn(address a) external pure returns (address) {
+        return a;
+    }
+
+    function internalHelper(address a) internal {
+        owner = a;
+    }
+
+    function callsHelper(address a) external {
+        require(a != address(0));
+        internalHelper(a);
+    }
+
+    function setOwnerViaAliasGuarded(address a) external {
+        require(a != address(0));
+        address tmp = a;
+        owner = tmp;
+    }
+
+    function privateHelper2(address a) private {
+        owner = a;
+    }
+
+    event Deposit(address indexed from);
+    error ZeroAddress();
+
+    function emitOnly(address a) external {
+        emit Deposit(a);
+    }
+
+    function guardViaCustomRevert(address a) external {
+        if (a == address(0)) revert ZeroAddress();
+        owner = a;
+    }
+
+    function noSinkJustPassthrough(address a) external returns (address) {
+        return a;
+    }
+
+    function addrIdentity(address x) internal pure returns (address) {
+        return x;
+    }
+
+    function staticCallOnly(address a) external {
+        (bool ok,) = a.staticcall("");
+        require(ok);
+    }
+
+    // Symmetric guard on both branches: universally checked.
+    function guardOnBothBranches(address a, bool flag) external {
+        if (flag) {
+            require(a != address(0));
+        } else {
+            require(a != address(0));
+        }
+        owner = a;
+    }
+
+    // Inner zero-check guards `a` for the rest of the enclosing branch via early revert.
+    function nestedGuardWithRevert(address a, bool flag) external {
+        if (flag) {
+            if (a == address(0)) revert();
+            owner = a;
+        }
+    }
+
+    // then-branch aborts via `require(false)`; else-branch guards `a`.
+    function setOwnerRequireFalseInThen(address a, bool flag) external {
+        if (flag) require(false, "unreachable");
+        else require(a != address(0));
+        owner = a;
+    }
+
+    // Same shape with `assert(false)` as the exit.
+    function setOwnerAssertFalseInThen(address a, bool flag) external {
+        if (flag) assert(false);
+        else require(a != address(0));
+        owner = a;
+    }
+
+    // then-branch block exits via `return` followed by dead code.
+    function setOwnerReturnThenDeadCode(address a, bool flag) external {
+        if (flag) {
+            return;
+            owner = address(0);
+        } else {
+            require(a != address(0));
+        }
+        owner = a;
+    }
+
+    function setOwnerNegatedOrGuard(address a, address b) external {
+        require(!(a == address(0) || b == address(0)));
+        owner = a;
+        recipient = payable(b);
+    }
+
+    function setOwnerRealCheckPlusUnrelated(address newOwner, uint256 amt) external {
+        require(amt > 0 && newOwner != address(0));
+        owner = newOwner;
+    }
+
+    function sinkInNonzeroBranch(address newOwner) external {
+        if (newOwner != address(0)) owner = newOwner;
+    }
+}
diff --git a/crates/lint/testdata/MissingZeroCheck.stderr b/crates/lint/testdata/MissingZeroCheck.stderr
new file mode 100644
index 0000000000000..a8efa1b491002
--- /dev/null
+++ b/crates/lint/testdata/MissingZeroCheck.stderr
@@ -0,0 +1,256 @@
+warning[missing-zero-check]: address parameter is used in a state write or value transfer without a zero-address check
+   ╭▸ ROOT/testdata/MissingZeroCheck.sol:LL:CC
+   │
+LL │     function setOwner(address newOwner) external {
+   │                       ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-zero-check
+
+warning[missing-zero-check]: address parameter is used in a state write or value transfer without a zero-address check
+   ╭▸ ROOT/testdata/MissingZeroCheck.sol:LL:CC
+   │
+LL │     constructor(address initialOwner) {
+   │                 ━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-zero-check
+
+warning[missing-zero-check]: address parameter is used in a state write or value transfer without a zero-address check
+   ╭▸ ROOT/testdata/MissingZeroCheck.sol:LL:CC
+   │
+LL │     function pay(address payable to) external {
+   │                  ━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-zero-check
+
+warning[missing-zero-check]: address parameter is used in a state write or value transfer without a zero-address check
+   ╭▸ ROOT/testdata/MissingZeroCheck.sol:LL:CC
+   │
+LL │     function lowLevel(address payable to, bytes calldata data) external {
+   │                       ━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-zero-check
+
+warning[missing-zero-check]: address parameter is used in a state write or value transfer without a zero-address check
+   ╭▸ ROOT/testdata/MissingZeroCheck.sol:LL:CC
+   │
+LL │     function withUselessModifier(address a) external doesNothing(a) {
+   │                                  ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-zero-check
+
+warning[missing-zero-check]: address parameter is used in a state write or value transfer without a zero-address check
+   ╭▸ ROOT/testdata/MissingZeroCheck.sol:LL:CC
+   │
+LL │     function setOwnerViaAlias(address a) external {
+   │                               ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-zero-check
+
+warning[missing-zero-check]: address parameter is used in a state write or value transfer without a zero-address check
+   ╭▸ ROOT/testdata/MissingZeroCheck.sol:LL:CC
+   │
+LL │     function setOwnerViaReassign(address a) external {
+   │                                  ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-zero-check
+
+warning[missing-zero-check]: address parameter is used in a state write or value transfer without a zero-address check
+   ╭▸ ROOT/testdata/MissingZeroCheck.sol:LL:CC
+   │
+LL │     function setOwnerViaCast(address a) external {
+   │                              ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-zero-check
+
+warning[missing-zero-check]: address parameter is used in a state write or value transfer without a zero-address check
+   ╭▸ ROOT/testdata/MissingZeroCheck.sol:LL:CC
+   │
+LL │     function payViaAlias(address payable a) external {
+   │                          ━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-zero-check
+
+warning[missing-zero-check]: address parameter is used in a state write or value transfer without a zero-address check
+   ╭▸ ROOT/testdata/MissingZeroCheck.sol:LL:CC
+   │
+LL │     function mixedParams(address a, address b) external {
+   │                                     ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-zero-check
+
+warning[missing-zero-check]: address parameter is used in a state write or value transfer without a zero-address check
+   ╭▸ ROOT/testdata/MissingZeroCheck.sol:LL:CC
+   │
+LL │     function bothSinks(address payable a) external {
+   │                        ━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-zero-check
+
+warning[missing-zero-check]: address parameter is used in a state write or value transfer without a zero-address check
+   ╭▸ ROOT/testdata/MissingZeroCheck.sol:LL:CC
+   │
+LL │     function ternaryAlias(address a, bool flag) external {
+   │                           ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-zero-check
+
+warning[missing-zero-check]: address parameter is used in a state write or value transfer without a zero-address check
+   ╭▸ ROOT/testdata/MissingZeroCheck.sol:LL:CC
+   │
+LL │     function payableWrap(address a) external {
+   │                          ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-zero-check
+
+warning[missing-zero-check]: address parameter is used in a state write or value transfer without a zero-address check
+   ╭▸ ROOT/testdata/MissingZeroCheck.sol:LL:CC
+   │
+LL │     function modifierWithExpr(address a) external nonZero(addrIdentity(a)) {
+   │                               ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-zero-check
+
+warning[missing-zero-check]: address parameter is used in a state write or value transfer without a zero-address check
+   ╭▸ ROOT/testdata/MissingZeroCheck.sol:LL:CC
+   │
+LL │     function delegateCallSink(address a) external {
+   │                               ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-zero-check
+
+warning[missing-zero-check]: address parameter is used in a state write or value transfer without a zero-address check
+   ╭▸ ROOT/testdata/MissingZeroCheck.sol:LL:CC
+   │
+LL │     function sendSinkStmt(address payable a) external {
+   │                           ━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-zero-check
+
+warning[missing-zero-check]: address parameter is used in a state write or value transfer without a zero-address check
+   ╭▸ ROOT/testdata/MissingZeroCheck.sol:LL:CC
+   │
+LL │     function sendSinkDecl(address payable a) external {
+   │                           ━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-zero-check
+
+warning[missing-zero-check]: address parameter is used in a state write or value transfer without a zero-address check
+   ╭▸ ROOT/testdata/MissingZeroCheck.sol:LL:CC
+   │
+LL │     function multiHopTaint(address a) external {
+   │                            ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-zero-check
+
+warning[missing-zero-check]: address parameter is used in a state write or value transfer without a zero-address check
+   ╭▸ ROOT/testdata/MissingZeroCheck.sol:LL:CC
+   │
+LL │     function guardAfterSink(address a) external {
+   │                             ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-zero-check
+
+warning[missing-zero-check]: address parameter is used in a state write or value transfer without a zero-address check
+   ╭▸ ROOT/testdata/MissingZeroCheck.sol:LL:CC
+   │
+LL │     function guardOnOneBranch(address a, bool flag) external {
+   │                               ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-zero-check
+
+warning[missing-zero-check]: address parameter is used in a state write or value transfer without a zero-address check
+   ╭▸ ROOT/testdata/MissingZeroCheck.sol:LL:CC
+   │
+LL │     function guardInForLoop(address a, uint256 n) external {
+   │                             ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-zero-check
+
+warning[missing-zero-check]: address parameter is used in a state write or value transfer without a zero-address check
+   ╭▸ ROOT/testdata/MissingZeroCheck.sol:LL:CC
+   │
+LL │     function guardInWhileLoop(address a, bool flag) external {
+   │                               ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-zero-check
+
+warning[missing-zero-check]: address parameter is used in a state write or value transfer without a zero-address check
+   ╭▸ ROOT/testdata/MissingZeroCheck.sol:LL:CC
+   │
+LL │     function guardInTryClause(address a, address payable target) external {
+   │                               ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-zero-check
+
+warning[missing-zero-check]: address parameter is used in a state write or value transfer without a zero-address check
+   ╭▸ ROOT/testdata/MissingZeroCheck.sol:LL:CC
+   │
+LL │     function guardNotAgainstZeroSelf(address newOwner) external {
+   │                                      ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-zero-check
+
+warning[missing-zero-check]: address parameter is used in a state write or value transfer without a zero-address check
+   ╭▸ ROOT/testdata/MissingZeroCheck.sol:LL:CC
+   │
+LL │     function guardMappingLookupOnly(address newOwner) external {
+   │                                     ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-zero-check
+
+warning[missing-zero-check]: address parameter is used in a state write or value transfer without a zero-address check
+   ╭▸ ROOT/testdata/MissingZeroCheck.sol:LL:CC
+   │
+LL │     function guardNotAgainstZeroOtherVar(address newOwner) external {
+   │                                          ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-zero-check
+
+warning[missing-zero-check]: address parameter is used in a state write or value transfer without a zero-address check
+   ╭▸ ROOT/testdata/MissingZeroCheck.sol:LL:CC
+   │
+LL │     function guardUnrelatedConjunct(address payable to, uint256 amt) external {
+   │                                     ━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-zero-check
+
+warning[missing-zero-check]: address parameter is used in a state write or value transfer without a zero-address check
+   ╭▸ ROOT/testdata/MissingZeroCheck.sol:LL:CC
+   │
+LL │     function guardViaArbitraryCallNotCast(address newOwner) external {
+   │                                           ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-zero-check
+
+warning[missing-zero-check]: address parameter is used in a state write or value transfer without a zero-address check
+   ╭▸ ROOT/testdata/MissingZeroCheck.sol:LL:CC
+   │
+LL │     function guardViaBitwiseNotNotZero(address newOwner) external {
+   │                                        ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-zero-check
+
+warning[missing-zero-check]: address parameter is used in a state write or value transfer without a zero-address check
+   ╭▸ ROOT/testdata/MissingZeroCheck.sol:LL:CC
+   │
+LL │     function guardRequiresZero(address newOwner) external {
+   │                                ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-zero-check
+
+warning[missing-zero-check]: address parameter is used in a state write or value transfer without a zero-address check
+   ╭▸ ROOT/testdata/MissingZeroCheck.sol:LL:CC
+   │
+LL │     function guardOnOneDisjunct(address newOwner, bool flag) external {
+   │                                 ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-zero-check
+
+warning[missing-zero-check]: address parameter is used in a state write or value transfer without a zero-address check
+   ╭▸ ROOT/testdata/MissingZeroCheck.sol:LL:CC
+   │
+LL │     function sinkInZeroBranch(address newOwner) external {
+   │                               ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/missing-zero-check
+
diff --git a/crates/lint/testdata/MixedCase.sol b/crates/lint/testdata/MixedCase.sol
index 75126cf8998e6..770eeeb1a0628 100644
--- a/crates/lint/testdata/MixedCase.sol
+++ b/crates/lint/testdata/MixedCase.sol
@@ -1,32 +1,38 @@
+//@compile-flags: --severity info
+
 // SPDX-License-Identifier: MIT
-pragma solidity ^0.8.0;
+pragma solidity ^0.8.18;
+
+interface IERC20 {
+    function decimals() external view returns(uint8);
+}
 
 contract MixedCaseTest {
     uint256 variableMixedCase;
     uint256 _variableMixedCase;
     uint256 variablemixedcase;
 
-    uint256 Variablemixedcase; //~NOTE: mutable variables should use mixedCase
-    uint256 VARIABLE_MIXED_CASE; //~NOTE: mutable variables should use mixedCase
-    uint256 VariableMixedCase; //~NOTE: mutable variables should use mixedCase
+    uint256 Variablemixedcase; //~NOTE: mutable variable name is not `mixedCase`
+    uint256 VARIABLE_MIXED_CASE; //~NOTE: mutable variable name is not `mixedCase`
+    uint256 VariableMixedCase; //~NOTE: mutable variable name is not `mixedCase`
 
     function foo() public {
         uint256 testVal;
         uint256 testVal123;
 
-        uint256 testVAL; //~NOTE: mutable variables should use mixedCase
-        uint256 TestVal; //~NOTE: mutable variables should use mixedCase
-        uint256 TESTVAL; //~NOTE: mutable variables should use mixedCase
+        uint256 testVAL; //~NOTE: mutable variable name is not `mixedCase`
+        uint256 TestVal; //~NOTE: mutable variable name is not `mixedCase`
+        uint256 TESTVAL; //~NOTE: mutable variable name is not `mixedCase`
     }
 
     function functionMixedCase() public {}
     function _functionMixedCase() internal {}
     function functionmixedcase() public {}
 
-    function Functionmixedcase() public {} //~NOTE: function names should use mixedCase
-    function FUNCTION_MIXED_CASE() public {} //~NOTE: function names should use mixedCase
-    function FunctionMixedCase() public {} //~NOTE: function names should use mixedCase
-    function function_mixed_case() public {} //~NOTE: function names should use mixedCase
+    function Functionmixedcase() public {} //~NOTE: function name is not `mixedCase`
+    function FUNCTION_MIXED_CASE() public {} //~NOTE: function name is not `mixedCase`
+    function FunctionMixedCase() public {} //~NOTE: function name is not `mixedCase`
+    function function_mixed_case() public {} //~NOTE: function name is not `mixedCase`
 
     // mixedCase checks are disabled for functions that starting with:
     // `test`, `invariant_`, and `statefulFuzz`
@@ -50,8 +56,8 @@ contract MixedCaseTest {
     function invariant_MixedCaseDisabled() public {}
     function invariant_mixedcasedisabled() public {}
 
-    function invariantBalance_MixedCase_Enabled() public {} //~NOTE: function names should use mixedCase
-    function invariantbalance_mixedcase_enabled() public {} //~NOTE: function names should use mixedCase
+    function invariantBalance_MixedCase_Enabled() public {} //~NOTE: function name is not `mixedCase`
+    function invariantbalance_mixedcase_enabled() public {} //~NOTE: function name is not `mixedCase`
     function invariantBalanceMixedCaseEnabled() public {}
     function invariantbalancemixedcaseenabled() public {}
 
@@ -59,4 +65,47 @@ contract MixedCaseTest {
     function statefulFuzz_mixedcase_disabled() public {}
     function statefulFuzzMixedCaseDisabled() public {}
     function statefulFuzzmixedcasedisabled() public {}
+
+    // ERC is, by default, an allowed infix
+    function rescueERC6909(address token, address to, uint256 tokenId, uint256 amount) public {}
+    function ERC20DoSomething() public {}
+    function _rescueERC20() public {} // a preserved leading underscore keeps the exception
+    function rescueERC20_() public {} // a preserved trailing underscore keeps the exception
+    function __rescueERC20() public {} //~NOTE: function name is not `mixedCase`
+    function rescueERC20__() public {} //~NOTE: function name is not `mixedCase`
+    function ERC20_DoSomething() public {} // invalid because of the underscore
+    //~^NOTE: function name is not `mixedCase`
+
+    // Common abbreviations are allowed by default: ID, URL, URI, API, JSON, XML, HTML, HTTP, HTTPS
+    uint256 marketID;
+    uint256 userID;
+    uint256 optionID;
+    uint256 apiURL;
+    uint256 baseURL;
+    function parseJSON() public {}
+    function fetchAPIData() public {}
+    function processHTML() public {}
+    function sendHTTPRequest() public {}
+    function handleHTTPSConnection() public {}
+    function getXMLData() public {}
+
+    // SCREAMING_SNAKE_CASE is allowed for functions that are most likely constant getters
+    function MAX_NUMBER() external view returns (uint256) {}
+    function CUSTOM_TYPE_RETURN() external view returns (IERC20) {}
+    function HAS_PARAMS(address addr) external view returns (uint256) {} //~NOTE: function name is not `mixedCase`
+    function HAS_NO_RETURN() external view {} //~NOTE: function name is not `mixedCase`
+    function HAS_MORE_THAN_ONE_RETURN() external view returns (uint256, uint256) {} //~NOTE: function name is not `mixedCase`
+    function NOT_ELEMENTARY_RETURN() external view returns (uint256[] memory) {} //~NOTE: function name is not `mixedCase`
+}
+
+contract PublicConstantGetters {
+    bytes32 private separator;
+
+    // Public getters follow the same naming convention as external getters.
+    function DOMAIN_SEPARATOR() public view returns (bytes32) { return separator; }
+    function PUBLIC_CUSTOM_TYPE() public view returns (IERC20) { return IERC20(address(0)); }
+
+    function PUBLIC_WITH_PARAM(uint256 value) public view returns (uint256) { return value; } //~NOTE: function name is not `mixedCase`
+    function INTERNAL_GETTER() internal view returns (bytes32) { return separator; } //~NOTE: function name is not `mixedCase`
+    function PUBLIC_MUTATOR() public returns (bytes32) { separator = bytes32(uint256(1)); return separator; } //~NOTE: function name is not `mixedCase`
 }
diff --git a/crates/lint/testdata/MixedCase.stderr b/crates/lint/testdata/MixedCase.stderr
index 0976dced8716c..7eda9e4d676b6 100644
--- a/crates/lint/testdata/MixedCase.stderr
+++ b/crates/lint/testdata/MixedCase.stderr
@@ -1,96 +1,200 @@
-note[mixed-case-variable]: mutable variables should use mixedCase
- --> ROOT/testdata/MixedCase.sol:LL:CC
-  |
-9 |     uint256 Variablemixedcase;
-  |             -----------------
-  |
-  = help: https://book.getfoundry.sh/reference/forge/forge-lint#mixed-case-variable
-
-note[mixed-case-variable]: mutable variables should use mixedCase
-  --> ROOT/testdata/MixedCase.sol:LL:CC
-   |
-10 |     uint256 VARIABLE_MIXED_CASE;
-   |             -------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#mixed-case-variable
-
-note[mixed-case-variable]: mutable variables should use mixedCase
-  --> ROOT/testdata/MixedCase.sol:LL:CC
-   |
-11 |     uint256 VariableMixedCase;
-   |             -----------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#mixed-case-variable
-
-note[mixed-case-variable]: mutable variables should use mixedCase
-  --> ROOT/testdata/MixedCase.sol:LL:CC
-   |
-17 |         uint256 testVAL;
-   |                 -------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#mixed-case-variable
-
-note[mixed-case-variable]: mutable variables should use mixedCase
-  --> ROOT/testdata/MixedCase.sol:LL:CC
-   |
-18 |         uint256 TestVal;
-   |                 -------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#mixed-case-variable
-
-note[mixed-case-variable]: mutable variables should use mixedCase
-  --> ROOT/testdata/MixedCase.sol:LL:CC
-   |
-19 |         uint256 TESTVAL;
-   |                 -------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#mixed-case-variable
-
-note[mixed-case-function]: function names should use mixedCase
-  --> ROOT/testdata/MixedCase.sol:LL:CC
-   |
-26 |     function Functionmixedcase() public {}
-   |              -----------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#mixed-case-function
-
-note[mixed-case-function]: function names should use mixedCase
-  --> ROOT/testdata/MixedCase.sol:LL:CC
-   |
-27 |     function FUNCTION_MIXED_CASE() public {}
-   |              -------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#mixed-case-function
-
-note[mixed-case-function]: function names should use mixedCase
-  --> ROOT/testdata/MixedCase.sol:LL:CC
-   |
-28 |     function FunctionMixedCase() public {}
-   |              -----------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#mixed-case-function
-
-note[mixed-case-function]: function names should use mixedCase
-  --> ROOT/testdata/MixedCase.sol:LL:CC
-   |
-29 |     function function_mixed_case() public {}
-   |              -------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#mixed-case-function
-
-note[mixed-case-function]: function names should use mixedCase
-  --> ROOT/testdata/MixedCase.sol:LL:CC
-   |
-53 |     function invariantBalance_MixedCase_Enabled() public {}
-   |              ----------------------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#mixed-case-function
-
-note[mixed-case-function]: function names should use mixedCase
-  --> ROOT/testdata/MixedCase.sol:LL:CC
-   |
-54 |     function invariantbalance_mixedcase_enabled() public {}
-   |              ----------------------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#mixed-case-function
+note[mixed-case-variable]: mutable variable name is not `mixedCase`
+   ╭▸ ROOT/testdata/MixedCase.sol:LL:CC
+   │
+LL │     uint256 Variablemixedcase;
+   │             ━━━━━━━━━━━━━━━━━ help: consider using (notice the capitalization): `variablemixedcase`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/mixed-case-variable
+
+note[mixed-case-variable]: mutable variable name is not `mixedCase`
+   ╭▸ ROOT/testdata/MixedCase.sol:LL:CC
+   │
+LL │     uint256 VARIABLE_MIXED_CASE;
+   │             ━━━━━━━━━━━━━━━━━━━ help: consider using: `variableMixedCase`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/mixed-case-variable
+
+note[mixed-case-variable]: mutable variable name is not `mixedCase`
+   ╭▸ ROOT/testdata/MixedCase.sol:LL:CC
+   │
+LL │     uint256 VariableMixedCase;
+   │             ━━━━━━━━━━━━━━━━━ help: consider using (notice the capitalization): `variableMixedCase`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/mixed-case-variable
+
+note[mixed-case-variable]: mutable variable name is not `mixedCase`
+   ╭▸ ROOT/testdata/MixedCase.sol:LL:CC
+   │
+LL │         uint256 testVAL;
+   │                 ━━━━━━━ help: consider using: `testVal`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/mixed-case-variable
+
+note[mixed-case-variable]: mutable variable name is not `mixedCase`
+   ╭▸ ROOT/testdata/MixedCase.sol:LL:CC
+   │
+LL │         uint256 TestVal;
+   │                 ━━━━━━━ help: consider using: `testVal`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/mixed-case-variable
+
+note[mixed-case-variable]: mutable variable name is not `mixedCase`
+   ╭▸ ROOT/testdata/MixedCase.sol:LL:CC
+   │
+LL │         uint256 TESTVAL;
+   │                 ━━━━━━━ help: consider using: `testval`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/mixed-case-variable
+
+note[mixed-case-function]: function name is not `mixedCase`
+   ╭▸ ROOT/testdata/MixedCase.sol:LL:CC
+   │
+LL │     function Functionmixedcase() public {}
+   │              ━━━━━━━━━━━━━━━━━ help: consider using (notice the capitalization): `functionmixedcase`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/mixed-case-function
+
+note[mixed-case-function]: function name is not `mixedCase`
+   ╭▸ ROOT/testdata/MixedCase.sol:LL:CC
+   │
+LL │     function FUNCTION_MIXED_CASE() public {}
+   │              ━━━━━━━━━━━━━━━━━━━ help: consider using: `functionMixedCase`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/mixed-case-function
+
+note[mixed-case-function]: function name is not `mixedCase`
+   ╭▸ ROOT/testdata/MixedCase.sol:LL:CC
+   │
+LL │     function FunctionMixedCase() public {}
+   │              ━━━━━━━━━━━━━━━━━ help: consider using (notice the capitalization): `functionMixedCase`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/mixed-case-function
+
+note[mixed-case-function]: function name is not `mixedCase`
+   ╭▸ ROOT/testdata/MixedCase.sol:LL:CC
+   │
+LL │     function function_mixed_case() public {}
+   │              ━━━━━━━━━━━━━━━━━━━ help: consider using: `functionMixedCase`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/mixed-case-function
+
+note[mixed-case-function]: function name is not `mixedCase`
+   ╭▸ ROOT/testdata/MixedCase.sol:LL:CC
+   │
+LL │     function invariantBalance_MixedCase_Enabled() public {}
+   │              ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ help: consider using: `invariantBalanceMixedCaseEnabled`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/mixed-case-function
+
+note[mixed-case-function]: function name is not `mixedCase`
+   ╭▸ ROOT/testdata/MixedCase.sol:LL:CC
+   │
+LL │     function invariantbalance_mixedcase_enabled() public {}
+   │              ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ help: consider using: `invariantbalanceMixedcaseEnabled`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/mixed-case-function
+
+note[mixed-case-function]: function name is not `mixedCase`
+   ╭▸ ROOT/testdata/MixedCase.sol:LL:CC
+   │
+LL │     function __rescueERC20() public {}
+   │              ━━━━━━━━━━━━━ help: consider using: `_rescueErc20`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/mixed-case-function
+
+note[mixed-case-function]: function name is not `mixedCase`
+   ╭▸ ROOT/testdata/MixedCase.sol:LL:CC
+   │
+LL │     function rescueERC20__() public {}
+   │              ━━━━━━━━━━━━━ help: consider using: `rescueErc20_`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/mixed-case-function
+
+note[mixed-case-function]: function name is not `mixedCase`
+   ╭▸ ROOT/testdata/MixedCase.sol:LL:CC
+   │
+LL │     function ERC20_DoSomething() public {} // invalid because of the underscore
+   │              ━━━━━━━━━━━━━━━━━ help: consider using: `erc20DoSomething`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/mixed-case-function
+
+note[mixed-case-function]: function name is not `mixedCase`
+   ╭▸ ROOT/testdata/MixedCase.sol:LL:CC
+   │
+LL │     function HAS_PARAMS(address addr) external view returns (uint256) {}
+   │              ━━━━━━━━━━ help: consider using: `hasParams`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/mixed-case-function
+
+note[mixed-case-function]: function name is not `mixedCase`
+   ╭▸ ROOT/testdata/MixedCase.sol:LL:CC
+   │
+LL │     function HAS_NO_RETURN() external view {}
+   │              ━━━━━━━━━━━━━ help: consider using: `hasNoReturn`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/mixed-case-function
+
+note[mixed-case-function]: function name is not `mixedCase`
+   ╭▸ ROOT/testdata/MixedCase.sol:LL:CC
+   │
+LL │     function HAS_MORE_THAN_ONE_RETURN() external view returns (uint256, uint256) {}
+   │              ━━━━━━━━━━━━━━━━━━━━━━━━ help: consider using: `hasMoreThanOneReturn`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/mixed-case-function
+
+note[mixed-case-function]: function name is not `mixedCase`
+   ╭▸ ROOT/testdata/MixedCase.sol:LL:CC
+   │
+LL │     function NOT_ELEMENTARY_RETURN() external view returns (uint256[] memory) {}
+   │              ━━━━━━━━━━━━━━━━━━━━━ help: consider using: `notElementaryReturn`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/mixed-case-function
+
+note[mixed-case-function]: function name is not `mixedCase`
+   ╭▸ ROOT/testdata/MixedCase.sol:LL:CC
+   │
+LL │     function PUBLIC_WITH_PARAM(uint256 value) public view returns (uint256) { return value; }
+   │              ━━━━━━━━━━━━━━━━━ help: consider using: `publicWithParam`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/mixed-case-function
+
+note[mixed-case-function]: function name is not `mixedCase`
+   ╭▸ ROOT/testdata/MixedCase.sol:LL:CC
+   │
+LL │     function INTERNAL_GETTER() internal view returns (bytes32) { return separator; }
+   │              ━━━━━━━━━━━━━━━ help: consider using: `internalGetter`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/mixed-case-function
+
+note[mixed-case-function]: function name is not `mixedCase`
+   ╭▸ ROOT/testdata/MixedCase.sol:LL:CC
+   │
+LL │     function PUBLIC_MUTATOR() public returns (bytes32) { separator = bytes32(uint256(1)); return separator; }
+   │              ━━━━━━━━━━━━━━ help: consider using: `publicMutator`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/mixed-case-function
+
+note[multi-contract-file]: file contains multiple contracts, interfaces or libraries
+   ╭▸ ROOT/testdata/MixedCase.sol:LL:CC
+   │
+LL │ interface IERC20 {
+   │           ━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/multi-contract-file
+
+note[multi-contract-file]: file contains multiple contracts, interfaces or libraries
+   ╭▸ ROOT/testdata/MixedCase.sol:LL:CC
+   │
+LL │ contract MixedCaseTest {
+   │          ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/multi-contract-file
+
+note[multi-contract-file]: file contains multiple contracts, interfaces or libraries
+   ╭▸ ROOT/testdata/MixedCase.sol:LL:CC
+   │
+LL │ contract PublicConstantGetters {
+   │          ━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/multi-contract-file
 
diff --git a/crates/lint/testdata/ModifierUsedOnlyOnce.sol b/crates/lint/testdata/ModifierUsedOnlyOnce.sol
new file mode 100644
index 0000000000000..14ac864e70fc4
--- /dev/null
+++ b/crates/lint/testdata/ModifierUsedOnlyOnce.sol
@@ -0,0 +1,91 @@
+//@compile-flags: --only-lint modifier-used-only-once
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+// Tests for `modifier-used-only-once`: a modifier invoked by exactly one function can usually
+// be inlined as checks at the top of that function. Invocations are taken from the resolved
+// modifier lists, so base-constructor calls are not confused with modifier calls. Out of
+// scope: `virtual` modifiers and overrides (they exist for dynamic dispatch), modifiers never
+// invoked (dead code is another concern) or invoked more than once.
+
+contract Guarded {
+    address internal owner;
+    uint256 internal count;
+
+    modifier onlyOnce() { //~NOTE: this modifier is used only once
+        require(msg.sender == owner, "not owner");
+        _;
+    }
+
+    modifier usedTwice() {
+        require(count < 100, "full");
+        _;
+    }
+
+    modifier neverUsed() {
+        require(owner != address(0), "unset");
+        _;
+    }
+
+    function first() external onlyOnce usedTwice {
+        count++;
+    }
+
+    function second() external usedTwice {
+        count++;
+    }
+}
+
+// A modifier invoked from a constructor counts like any other invocation.
+contract Eager {
+    bool internal ready;
+
+    modifier once() { //~NOTE: this modifier is used only once
+        require(!ready, "done");
+        _;
+    }
+
+    constructor() once() {
+        ready = true;
+    }
+}
+
+// A `virtual` modifier and its override exist for dynamic dispatch: out of scope even when
+// each is invoked once.
+contract ModBase {
+    modifier guard() virtual {
+        _;
+    }
+
+    function useBase() external guard {}
+}
+
+contract ModChild is ModBase {
+    modifier guard() override {
+        require(msg.sender != address(0), "zero");
+        _;
+    }
+
+    function useChild() external guard {}
+}
+
+// A base-constructor call sits in the same resolved list as modifier invocations: it must
+// not be counted as one.
+contract WithArgs {
+    uint256 internal seed;
+
+    constructor(uint256 s) {
+        seed = s;
+    }
+}
+
+contract ChildWithArgs is WithArgs {
+    modifier seeded() { //~NOTE: this modifier is used only once
+        require(seed != 0, "no seed");
+        _;
+    }
+
+    constructor() WithArgs(42) {}
+
+    function go() external seeded {}
+}
diff --git a/crates/lint/testdata/ModifierUsedOnlyOnce.stderr b/crates/lint/testdata/ModifierUsedOnlyOnce.stderr
new file mode 100644
index 0000000000000..e2238c6d6b0ef
--- /dev/null
+++ b/crates/lint/testdata/ModifierUsedOnlyOnce.stderr
@@ -0,0 +1,24 @@
+note[modifier-used-only-once]: this modifier is used only once
+   ╭▸ ROOT/testdata/ModifierUsedOnlyOnce.sol:LL:CC
+   │
+LL │     modifier onlyOnce() {
+   │     ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/modifier-used-only-once
+
+note[modifier-used-only-once]: this modifier is used only once
+   ╭▸ ROOT/testdata/ModifierUsedOnlyOnce.sol:LL:CC
+   │
+LL │     modifier once() {
+   │     ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/modifier-used-only-once
+
+note[modifier-used-only-once]: this modifier is used only once
+   ╭▸ ROOT/testdata/ModifierUsedOnlyOnce.sol:LL:CC
+   │
+LL │     modifier seeded() {
+   │     ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/modifier-used-only-once
+
diff --git a/crates/lint/testdata/MsgValueLoop.sol b/crates/lint/testdata/MsgValueLoop.sol
new file mode 100644
index 0000000000000..6f6a4200cca9d
--- /dev/null
+++ b/crates/lint/testdata/MsgValueLoop.sol
@@ -0,0 +1,217 @@
+//@compile-flags: --only-lint msg-value-loop
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+contract MsgValueBase {
+    function superRead() internal virtual returns (uint256) {
+        return 0;
+    }
+}
+
+contract MsgValueReader is MsgValueBase {
+    function superRead() internal virtual override returns (uint256) {
+        return msg.value; //~WARN: payable function uses `msg.value` inside a loop
+    }
+}
+
+contract MsgValueSuperCaller is MsgValueBase {
+    function callNext() internal returns (uint256) {
+        return super.superRead();
+    }
+}
+
+contract MsgValueSuperOverloadBase {
+    function overloaded(uint256) internal virtual returns (uint256) {
+        return msg.value; //~WARN: payable function uses `msg.value` inside a loop
+    }
+}
+
+contract MsgValueSuperOverloadMask is MsgValueSuperOverloadBase {
+    function overloaded() internal pure returns (uint256) {
+        return 0;
+    }
+}
+
+contract MsgValueSuperOverloadLeaf is MsgValueSuperOverloadMask {
+    uint256 public total;
+
+    function payableLoopWithOverloadedSuperMsgValue(uint256 iterations) external payable {
+        for (uint256 i; i < iterations; ++i) {
+            total += super.overloaded(i);
+        }
+    }
+}
+
+library MsgValueExtension {
+    function extensionRead(uint256 self) internal returns (uint256) {
+        self;
+        return msg.value; //~WARN: payable function uses `msg.value` inside a loop
+    }
+}
+
+library MsgValueUnusedExtension {
+    function extensionRead(uint256 self) internal returns (uint256) {
+        self;
+        return msg.value;
+    }
+}
+
+contract MsgValueExternalReader {
+    function publicReadValue() public payable returns (uint256) {
+        return msg.value;
+    }
+}
+
+contract MsgValueLoop is MsgValueReader, MsgValueSuperCaller {
+    using MsgValueExtension for uint256;
+
+    uint256 public total;
+
+    constructor(uint256 iterations) payable {
+        for (uint256 i; i < iterations; ++i) {
+            total += msg.value;
+        }
+    }
+
+    receive() external payable {
+        for (uint256 i; i < 2; ++i) {
+            total += msg.value; //~WARN: payable function uses `msg.value` inside a loop
+        }
+    }
+
+    fallback() external payable {
+        for (uint256 i; i < 2; ++i) {
+            total += msg.value; //~WARN: payable function uses `msg.value` inside a loop
+        }
+    }
+
+    function superRead() internal override(MsgValueBase, MsgValueReader) returns (uint256) {
+        return super.superRead();
+    }
+
+    function payableForLoop(uint256 iterations) external payable {
+        for (uint256 i; i < iterations; ++i) {
+            total += msg.value; //~WARN: payable function uses `msg.value` inside a loop
+        }
+    }
+
+    function payableWhileLoop(uint256 iterations) external payable {
+        uint256 i;
+        while (i < iterations) {
+            total += msg.value; //~WARN: payable function uses `msg.value` inside a loop
+            ++i;
+        }
+    }
+
+    function payableDoWhileLoop(uint256 iterations) external payable {
+        if (iterations == 0) return;
+        uint256 i;
+        do {
+            total += msg.value; //~WARN: payable function uses `msg.value` inside a loop
+            ++i;
+        } while (i < iterations);
+    }
+
+    function payableForUpdateExpression(uint256 iterations) external payable {
+        uint256 value;
+        for (uint256 i; i < iterations; value = msg.value + i++) {} //~WARN: payable function uses `msg.value` inside a loop
+        total += value;
+    }
+
+    modifier loopPlaceholder(uint256 iterations) {
+        for (uint256 i; i < iterations; ++i) {
+            _;
+        }
+    }
+
+    function payableModifierLoopPlaceholder(uint256 iterations) external payable loopPlaceholder(iterations) {
+        total += msg.value; //~WARN: payable function uses `msg.value` inside a loop
+    }
+
+    function payableLoopWithInternalMsgValue(uint256 iterations) external payable {
+        for (uint256 i; i < iterations; ++i) {
+            total += readValue();
+        }
+    }
+
+    function readValue() internal returns (uint256) {
+        return msg.value; //~WARN: payable function uses `msg.value` inside a loop
+    }
+
+    function payableInternalLoopWithMsgValue(uint256 iterations) external payable {
+        readValueInLoop(iterations);
+    }
+
+    function readValueInLoop(uint256 iterations) internal {
+        for (uint256 i; i < iterations; ++i) {
+            total += msg.value; //~WARN: payable function uses `msg.value` inside a loop
+        }
+    }
+
+    function payableLoopWithPublicMsgValue(uint256 iterations) external payable {
+        for (uint256 i; i < iterations; ++i) {
+            total += publicReadValue();
+        }
+    }
+
+    function publicReadValue() public payable returns (uint256) {
+        return msg.value; //~WARN: payable function uses `msg.value` inside a loop
+    }
+
+    function payableLoopWithSuperMsgValue(uint256 iterations) external payable {
+        for (uint256 i; i < iterations; ++i) {
+            total += callNext();
+        }
+    }
+
+    function payableLoopWithExtensionMsgValue(uint256 iterations) external payable {
+        uint256 value = iterations;
+        for (uint256 i; i < iterations; ++i) {
+            total += value.extensionRead();
+        }
+    }
+
+    function payableLoopWithExternalReader(MsgValueExternalReader reader, uint256 iterations) external payable {
+        for (uint256 i; i < iterations; ++i) {
+            total += reader.publicReadValue();
+        }
+    }
+
+    function payableLoopWithDuplicateHelperA(uint256 iterations) external payable {
+        for (uint256 i; i < iterations; ++i) {
+            total += duplicateReadValue();
+        }
+    }
+
+    function payableLoopWithDuplicateHelperB(uint256 iterations) external payable {
+        for (uint256 i; i < iterations; ++i) {
+            total += duplicateReadValue();
+        }
+    }
+
+    function duplicateReadValue() internal returns (uint256) {
+        return msg.value; //~WARN: payable function uses `msg.value` inside a loop
+    }
+
+    function payableMsgValueOutsideLoop() external payable {
+        total += msg.value;
+    }
+
+    function payableCachedValueInLoop(uint256 iterations) external payable {
+        uint256 value = msg.value;
+        for (uint256 i; i < iterations; ++i) {
+            total += value;
+        }
+    }
+
+    function nonPayableLoop(uint256 iterations) external {
+        nonPayableValueLoop(iterations);
+    }
+
+    function nonPayableValueLoop(uint256 iterations) internal {
+        for (uint256 i; i < iterations; ++i) {
+            total += msg.value;
+        }
+    }
+}
diff --git a/crates/lint/testdata/MsgValueLoop.stderr b/crates/lint/testdata/MsgValueLoop.stderr
new file mode 100644
index 0000000000000..2588b220e0fc7
--- /dev/null
+++ b/crates/lint/testdata/MsgValueLoop.stderr
@@ -0,0 +1,112 @@
+warning[msg-value-loop]: payable function uses `msg.value` inside a loop
+   ╭▸ ROOT/testdata/MsgValueLoop.sol:LL:CC
+   │
+LL │         return msg.value;
+   │                ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/msg-value-loop
+
+warning[msg-value-loop]: payable function uses `msg.value` inside a loop
+   ╭▸ ROOT/testdata/MsgValueLoop.sol:LL:CC
+   │
+LL │             total += msg.value;
+   │                      ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/msg-value-loop
+
+warning[msg-value-loop]: payable function uses `msg.value` inside a loop
+   ╭▸ ROOT/testdata/MsgValueLoop.sol:LL:CC
+   │
+LL │             total += msg.value;
+   │                      ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/msg-value-loop
+
+warning[msg-value-loop]: payable function uses `msg.value` inside a loop
+   ╭▸ ROOT/testdata/MsgValueLoop.sol:LL:CC
+   │
+LL │             total += msg.value;
+   │                      ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/msg-value-loop
+
+warning[msg-value-loop]: payable function uses `msg.value` inside a loop
+   ╭▸ ROOT/testdata/MsgValueLoop.sol:LL:CC
+   │
+LL │             total += msg.value;
+   │                      ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/msg-value-loop
+
+warning[msg-value-loop]: payable function uses `msg.value` inside a loop
+   ╭▸ ROOT/testdata/MsgValueLoop.sol:LL:CC
+   │
+LL │             total += msg.value;
+   │                      ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/msg-value-loop
+
+warning[msg-value-loop]: payable function uses `msg.value` inside a loop
+   ╭▸ ROOT/testdata/MsgValueLoop.sol:LL:CC
+   │
+LL │         for (uint256 i; i < iterations; value = msg.value + i++) {}
+   │                                                 ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/msg-value-loop
+
+warning[msg-value-loop]: payable function uses `msg.value` inside a loop
+   ╭▸ ROOT/testdata/MsgValueLoop.sol:LL:CC
+   │
+LL │         total += msg.value;
+   │                  ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/msg-value-loop
+
+warning[msg-value-loop]: payable function uses `msg.value` inside a loop
+   ╭▸ ROOT/testdata/MsgValueLoop.sol:LL:CC
+   │
+LL │         return msg.value;
+   │                ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/msg-value-loop
+
+warning[msg-value-loop]: payable function uses `msg.value` inside a loop
+   ╭▸ ROOT/testdata/MsgValueLoop.sol:LL:CC
+   │
+LL │             total += msg.value;
+   │                      ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/msg-value-loop
+
+warning[msg-value-loop]: payable function uses `msg.value` inside a loop
+   ╭▸ ROOT/testdata/MsgValueLoop.sol:LL:CC
+   │
+LL │         return msg.value;
+   │                ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/msg-value-loop
+
+warning[msg-value-loop]: payable function uses `msg.value` inside a loop
+   ╭▸ ROOT/testdata/MsgValueLoop.sol:LL:CC
+   │
+LL │         return msg.value;
+   │                ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/msg-value-loop
+
+warning[msg-value-loop]: payable function uses `msg.value` inside a loop
+   ╭▸ ROOT/testdata/MsgValueLoop.sol:LL:CC
+   │
+LL │         return msg.value;
+   │                ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/msg-value-loop
+
+warning[msg-value-loop]: payable function uses `msg.value` inside a loop
+   ╭▸ ROOT/testdata/MsgValueLoop.sol:LL:CC
+   │
+LL │         return msg.value;
+   │                ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/msg-value-loop
+
diff --git a/crates/lint/testdata/MultiContractFile.sol b/crates/lint/testdata/MultiContractFile.sol
new file mode 100644
index 0000000000000..8447d1a1af1ab
--- /dev/null
+++ b/crates/lint/testdata/MultiContractFile.sol
@@ -0,0 +1,14 @@
+//@compile-flags: --severity info
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+contract A {}
+
+contract B {} //~NOTE: file contains multiple contracts, interfaces or libraries
+
+contract C {}
+
+interface I {}
+
+library L {}
diff --git a/crates/lint/testdata/MultiContractFile.stderr b/crates/lint/testdata/MultiContractFile.stderr
new file mode 100644
index 0000000000000..2bd19a105998c
--- /dev/null
+++ b/crates/lint/testdata/MultiContractFile.stderr
@@ -0,0 +1,40 @@
+note[multi-contract-file]: file contains multiple contracts, interfaces or libraries
+   ╭▸ ROOT/testdata/MultiContractFile.sol:LL:CC
+   │
+LL │ contract A {}
+   │          ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/multi-contract-file
+
+note[multi-contract-file]: file contains multiple contracts, interfaces or libraries
+   ╭▸ ROOT/testdata/MultiContractFile.sol:LL:CC
+   │
+LL │ contract B {}
+   │          ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/multi-contract-file
+
+note[multi-contract-file]: file contains multiple contracts, interfaces or libraries
+   ╭▸ ROOT/testdata/MultiContractFile.sol:LL:CC
+   │
+LL │ contract C {}
+   │          ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/multi-contract-file
+
+note[multi-contract-file]: file contains multiple contracts, interfaces or libraries
+   ╭▸ ROOT/testdata/MultiContractFile.sol:LL:CC
+   │
+LL │ interface I {}
+   │           ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/multi-contract-file
+
+note[multi-contract-file]: file contains multiple contracts, interfaces or libraries
+   ╭▸ ROOT/testdata/MultiContractFile.sol:LL:CC
+   │
+LL │ library L {}
+   │         ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/multi-contract-file
+
diff --git a/crates/lint/testdata/MultiContractFile_InterfaceLibrary.sol b/crates/lint/testdata/MultiContractFile_InterfaceLibrary.sol
new file mode 100644
index 0000000000000..b3ecc10687511
--- /dev/null
+++ b/crates/lint/testdata/MultiContractFile_InterfaceLibrary.sol
@@ -0,0 +1,14 @@
+//@compile-flags: --severity info
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+// Interface counts as a contract-like item.
+interface I1 {}
+
+// Library is also a contract-like item and it should be counted.
+library L1 {} //~NOTE: file contains multiple contracts, interfaces or libraries
+
+// Third contract-like item.
+contract C1 {}
+
diff --git a/crates/lint/testdata/MultiContractFile_InterfaceLibrary.stderr b/crates/lint/testdata/MultiContractFile_InterfaceLibrary.stderr
new file mode 100644
index 0000000000000..06868df79082f
--- /dev/null
+++ b/crates/lint/testdata/MultiContractFile_InterfaceLibrary.stderr
@@ -0,0 +1,24 @@
+note[multi-contract-file]: file contains multiple contracts, interfaces or libraries
+   ╭▸ ROOT/testdata/MultiContractFile_InterfaceLibrary.sol:LL:CC
+   │
+LL │ interface I1 {}
+   │           ━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/multi-contract-file
+
+note[multi-contract-file]: file contains multiple contracts, interfaces or libraries
+   ╭▸ ROOT/testdata/MultiContractFile_InterfaceLibrary.sol:LL:CC
+   │
+LL │ library L1 {}
+   │         ━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/multi-contract-file
+
+note[multi-contract-file]: file contains multiple contracts, interfaces or libraries
+   ╭▸ ROOT/testdata/MultiContractFile_InterfaceLibrary.sol:LL:CC
+   │
+LL │ contract C1 {}
+   │          ━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/multi-contract-file
+
diff --git a/crates/lint/testdata/NamedStructFields.sol b/crates/lint/testdata/NamedStructFields.sol
new file mode 100644
index 0000000000000..164598f5b796d
--- /dev/null
+++ b/crates/lint/testdata/NamedStructFields.sol
@@ -0,0 +1,24 @@
+//@compile-flags: --only-lint named-struct-fields
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+contract NamedStructFields {
+    struct Person {
+        string name;
+        uint256 age;
+        address wallet;
+    }
+
+    function namedArgs() public {
+        Person memory person = Person({
+            name: "Alice",
+            age: 25,
+            wallet: address(0)
+        });
+    }
+
+    function positionalArgs() public {
+        Person memory person = Person("Alice", 25, address(0)); //~NOTE: struct is initialized with positional fields
+    }
+}
diff --git a/crates/lint/testdata/NamedStructFields.stderr b/crates/lint/testdata/NamedStructFields.stderr
new file mode 100644
index 0000000000000..2aa28f8ab161e
--- /dev/null
+++ b/crates/lint/testdata/NamedStructFields.stderr
@@ -0,0 +1,8 @@
+note[named-struct-fields]: struct is initialized with positional fields
+   ╭▸ ROOT/testdata/NamedStructFields.sol:LL:CC
+   │
+LL │         Person memory person = Person("Alice", 25, address(0));
+   │                                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ help: consider using named fields: `Person({ name: "Alice", age: 25, wallet: address(0) })`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/named-struct-fields
+
diff --git a/crates/lint/testdata/NonReentrantNotFirst.sol b/crates/lint/testdata/NonReentrantNotFirst.sol
new file mode 100644
index 0000000000000..dea79c3646044
--- /dev/null
+++ b/crates/lint/testdata/NonReentrantNotFirst.sol
@@ -0,0 +1,80 @@
+//@compile-flags: --only-lint non-reentrant-not-first
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+contract NonReentrantNotFirst {
+    modifier nonReentrant() {
+        _;
+    }
+
+    modifier onlyOwner() {
+        _;
+    }
+
+    modifier whenNotPaused() {
+        _;
+    }
+
+    function badSingle(uint256 amount) external onlyOwner nonReentrant { //~WARN: `nonReentrant` is not the first modifier
+        amount;
+    }
+
+    function badMultiple(uint256 amount) external onlyOwner whenNotPaused nonReentrant { //~WARN: `nonReentrant` is not the first modifier
+        amount;
+    }
+
+    function badDuplicate(uint256 amount) external nonReentrant onlyOwner nonReentrant { //~WARN: `nonReentrant` is not the first modifier
+        amount;
+    }
+
+    function goodFirst(uint256 amount) external nonReentrant onlyOwner {
+        amount;
+    }
+
+    function goodOnly(uint256 amount) external nonReentrant {
+        amount;
+    }
+
+    function goodNoGuard(uint256 amount) external onlyOwner whenNotPaused {
+        amount;
+    }
+
+    receive() external payable nonReentrant onlyOwner {}
+}
+
+contract FallbackReceiveNonReentrantNotFirst {
+    modifier nonReentrant() {
+        _;
+    }
+
+    modifier onlyOwner() {
+        _;
+    }
+
+    receive() external payable onlyOwner nonReentrant { //~WARN: `nonReentrant` is not the first modifier
+    }
+
+    fallback() external payable onlyOwner nonReentrant { //~WARN: `nonReentrant` is not the first modifier
+    }
+}
+
+contract BaseReentrancyGuard {
+    modifier nonReentrant() {
+        _;
+    }
+}
+
+contract InheritedNonReentrantNotFirst is BaseReentrancyGuard {
+    modifier onlyOwner() {
+        _;
+    }
+
+    function badInherited() external onlyOwner nonReentrant { //~WARN: `nonReentrant` is not the first modifier
+        msg.sender;
+    }
+
+    function goodInherited() external nonReentrant onlyOwner {
+        msg.sender;
+    }
+}
diff --git a/crates/lint/testdata/NonReentrantNotFirst.stderr b/crates/lint/testdata/NonReentrantNotFirst.stderr
new file mode 100644
index 0000000000000..23e772162125d
--- /dev/null
+++ b/crates/lint/testdata/NonReentrantNotFirst.stderr
@@ -0,0 +1,48 @@
+warning[non-reentrant-not-first]: `nonReentrant` is not the first modifier
+   ╭▸ ROOT/testdata/NonReentrantNotFirst.sol:LL:CC
+   │
+LL │     function badSingle(uint256 amount) external onlyOwner nonReentrant {
+   │                                                           ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/non-reentrant-not-first
+
+warning[non-reentrant-not-first]: `nonReentrant` is not the first modifier
+   ╭▸ ROOT/testdata/NonReentrantNotFirst.sol:LL:CC
+   │
+LL │     function badMultiple(uint256 amount) external onlyOwner whenNotPaused nonReentrant {
+   │                                                                           ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/non-reentrant-not-first
+
+warning[non-reentrant-not-first]: `nonReentrant` is not the first modifier
+   ╭▸ ROOT/testdata/NonReentrantNotFirst.sol:LL:CC
+   │
+LL │     function badDuplicate(uint256 amount) external nonReentrant onlyOwner nonReentrant {
+   │                                                                           ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/non-reentrant-not-first
+
+warning[non-reentrant-not-first]: `nonReentrant` is not the first modifier
+   ╭▸ ROOT/testdata/NonReentrantNotFirst.sol:LL:CC
+   │
+LL │     receive() external payable onlyOwner nonReentrant {
+   │                                          ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/non-reentrant-not-first
+
+warning[non-reentrant-not-first]: `nonReentrant` is not the first modifier
+   ╭▸ ROOT/testdata/NonReentrantNotFirst.sol:LL:CC
+   │
+LL │     fallback() external payable onlyOwner nonReentrant {
+   │                                           ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/non-reentrant-not-first
+
+warning[non-reentrant-not-first]: `nonReentrant` is not the first modifier
+   ╭▸ ROOT/testdata/NonReentrantNotFirst.sol:LL:CC
+   │
+LL │     function badInherited() external onlyOwner nonReentrant {
+   │                                                ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/non-reentrant-not-first
+
diff --git a/crates/lint/testdata/PragmaInconsistentCaretAboveExact.sol b/crates/lint/testdata/PragmaInconsistentCaretAboveExact.sol
new file mode 100644
index 0000000000000..4ee9e3166ac7f
--- /dev/null
+++ b/crates/lint/testdata/PragmaInconsistentCaretAboveExact.sol
@@ -0,0 +1,7 @@
+//@compile-flags: --only-lint pragma-inconsistent
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.0; //~NOTE: 2 different Solidity pragma version requirements are used: `0.8.18`, `^0.8.0`
+pragma solidity 0.8.18;
+
+contract Main {}
diff --git a/crates/lint/testdata/PragmaInconsistentCaretAboveExact.stderr b/crates/lint/testdata/PragmaInconsistentCaretAboveExact.stderr
new file mode 100644
index 0000000000000..1b8ae0d322f6d
--- /dev/null
+++ b/crates/lint/testdata/PragmaInconsistentCaretAboveExact.stderr
@@ -0,0 +1,8 @@
+note[pragma-inconsistent]: 2 different Solidity pragma version requirements are used: `0.8.18`, `^0.8.0`
+   ╭▸ ROOT/testdata/PragmaInconsistentCaretAboveExact.sol:LL:CC
+   │
+LL │ pragma solidity ^0.8.0;
+   │ ━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/pragma-inconsistent
+
diff --git a/crates/lint/testdata/PragmaInconsistentCaretMatchesExact.sol b/crates/lint/testdata/PragmaInconsistentCaretMatchesExact.sol
new file mode 100644
index 0000000000000..ca36461c72d40
--- /dev/null
+++ b/crates/lint/testdata/PragmaInconsistentCaretMatchesExact.sol
@@ -0,0 +1,7 @@
+//@compile-flags: --only-lint pragma-inconsistent
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.20; //~NOTE: 2 different Solidity pragma version requirements are used: `0.8.20`, `^0.8.20`
+pragma solidity 0.8.20;
+
+contract Main {}
diff --git a/crates/lint/testdata/PragmaInconsistentCaretMatchesExact.stderr b/crates/lint/testdata/PragmaInconsistentCaretMatchesExact.stderr
new file mode 100644
index 0000000000000..8ab244eced89b
--- /dev/null
+++ b/crates/lint/testdata/PragmaInconsistentCaretMatchesExact.stderr
@@ -0,0 +1,8 @@
+note[pragma-inconsistent]: 2 different Solidity pragma version requirements are used: `0.8.20`, `^0.8.20`
+   ╭▸ ROOT/testdata/PragmaInconsistentCaretMatchesExact.sol:LL:CC
+   │
+LL │ pragma solidity ^0.8.20;
+   │ ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/pragma-inconsistent
+
diff --git a/crates/lint/testdata/PragmaInconsistentCaretVsTilde.sol b/crates/lint/testdata/PragmaInconsistentCaretVsTilde.sol
new file mode 100644
index 0000000000000..83148638aab1a
--- /dev/null
+++ b/crates/lint/testdata/PragmaInconsistentCaretVsTilde.sol
@@ -0,0 +1,7 @@
+//@compile-flags: --only-lint pragma-inconsistent
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.20; //~NOTE: 2 different Solidity pragma version requirements are used: `^0.8.20`, `~0.8.20`
+pragma solidity ~0.8.20;
+
+contract Main {}
diff --git a/crates/lint/testdata/PragmaInconsistentCaretVsTilde.stderr b/crates/lint/testdata/PragmaInconsistentCaretVsTilde.stderr
new file mode 100644
index 0000000000000..8f2f6e0fc5b55
--- /dev/null
+++ b/crates/lint/testdata/PragmaInconsistentCaretVsTilde.stderr
@@ -0,0 +1,8 @@
+note[pragma-inconsistent]: 2 different Solidity pragma version requirements are used: `^0.8.20`, `~0.8.20`
+   ╭▸ ROOT/testdata/PragmaInconsistentCaretVsTilde.sol:LL:CC
+   │
+LL │ pragma solidity ^0.8.20;
+   │ ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/pragma-inconsistent
+
diff --git a/crates/lint/testdata/PragmaInconsistentOrVsExact.sol b/crates/lint/testdata/PragmaInconsistentOrVsExact.sol
new file mode 100644
index 0000000000000..aa19aed00a6d8
--- /dev/null
+++ b/crates/lint/testdata/PragmaInconsistentOrVsExact.sol
@@ -0,0 +1,7 @@
+//@compile-flags: --only-lint pragma-inconsistent
+
+// SPDX-License-Identifier: MIT
+pragma solidity 0.8.20 || 0.8.21; //~NOTE: 2 different Solidity pragma version requirements are used: `0.8.20`, `0.8.20 || 0.8.21`
+pragma solidity 0.8.20;
+
+contract Main {}
diff --git a/crates/lint/testdata/PragmaInconsistentOrVsExact.stderr b/crates/lint/testdata/PragmaInconsistentOrVsExact.stderr
new file mode 100644
index 0000000000000..6ff1dd1852210
--- /dev/null
+++ b/crates/lint/testdata/PragmaInconsistentOrVsExact.stderr
@@ -0,0 +1,8 @@
+note[pragma-inconsistent]: 2 different Solidity pragma version requirements are used: `0.8.20`, `0.8.20 || 0.8.21`
+   ╭▸ ROOT/testdata/PragmaInconsistentOrVsExact.sol:LL:CC
+   │
+LL │ pragma solidity 0.8.20 || 0.8.21;
+   │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/pragma-inconsistent
+
diff --git a/crates/lint/testdata/PragmaInconsistentRangeVsExact.sol b/crates/lint/testdata/PragmaInconsistentRangeVsExact.sol
new file mode 100644
index 0000000000000..1c2499a7dd9e4
--- /dev/null
+++ b/crates/lint/testdata/PragmaInconsistentRangeVsExact.sol
@@ -0,0 +1,7 @@
+//@compile-flags: --only-lint pragma-inconsistent
+
+// SPDX-License-Identifier: MIT
+pragma solidity >=0.8.0 <0.9.0; //~NOTE: 2 different Solidity pragma version requirements are used: `0.8.20`, `>=0.8.0 <0.9.0`
+pragma solidity 0.8.20;
+
+contract Main {}
diff --git a/crates/lint/testdata/PragmaInconsistentRangeVsExact.stderr b/crates/lint/testdata/PragmaInconsistentRangeVsExact.stderr
new file mode 100644
index 0000000000000..b2580b71217eb
--- /dev/null
+++ b/crates/lint/testdata/PragmaInconsistentRangeVsExact.stderr
@@ -0,0 +1,8 @@
+note[pragma-inconsistent]: 2 different Solidity pragma version requirements are used: `0.8.20`, `>=0.8.0 <0.9.0`
+   ╭▸ ROOT/testdata/PragmaInconsistentRangeVsExact.sol:LL:CC
+   │
+LL │ pragma solidity >=0.8.0 <0.9.0;
+   │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/pragma-inconsistent
+
diff --git a/crates/lint/testdata/PragmaInconsistentThreeDistinct.sol b/crates/lint/testdata/PragmaInconsistentThreeDistinct.sol
new file mode 100644
index 0000000000000..c14fa95e025ca
--- /dev/null
+++ b/crates/lint/testdata/PragmaInconsistentThreeDistinct.sol
@@ -0,0 +1,8 @@
+//@compile-flags: --only-lint pragma-inconsistent
+
+// SPDX-License-Identifier: MIT
+pragma solidity >=0.8.0; //~NOTE: 3 different Solidity pragma version requirements are used: `>=0.8.0`, `^0.8.0`, `~0.8.0`
+pragma solidity ^0.8.0;
+pragma solidity ~0.8.0;
+
+contract Main {}
diff --git a/crates/lint/testdata/PragmaInconsistentThreeDistinct.stderr b/crates/lint/testdata/PragmaInconsistentThreeDistinct.stderr
new file mode 100644
index 0000000000000..ad7059a125441
--- /dev/null
+++ b/crates/lint/testdata/PragmaInconsistentThreeDistinct.stderr
@@ -0,0 +1,8 @@
+note[pragma-inconsistent]: 3 different Solidity pragma version requirements are used: `>=0.8.0`, `^0.8.0`, `~0.8.0`
+   ╭▸ ROOT/testdata/PragmaInconsistentThreeDistinct.sol:LL:CC
+   │
+LL │ pragma solidity >=0.8.0;
+   │ ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/pragma-inconsistent
+
diff --git a/crates/lint/testdata/ProtectedVars.sol b/crates/lint/testdata/ProtectedVars.sol
new file mode 100644
index 0000000000000..eddddc65f1323
--- /dev/null
+++ b/crates/lint/testdata/ProtectedVars.sol
@@ -0,0 +1,533 @@
+//@compile-flags: --only-lint protected-vars
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+contract DirectWrites {
+    /// @custom:security write-protection="onlyOwner()"
+    address public owner;
+
+    modifier onlyOwner() {
+        require(msg.sender == owner);
+        _;
+    }
+
+    function setOwner(address newOwner) external { //~WARN: protected variable `owner` is written without `onlyOwner()`
+        owner = newOwner;
+    }
+
+    function setOwnerProtected(address newOwner) external onlyOwner {
+        owner = newOwner;
+    }
+}
+
+contract InternalCalls {
+    /// @custom:security write-protection="checkOwner()"
+    address public owner;
+
+    function checkOwner() internal view {
+        require(msg.sender == owner);
+    }
+
+    function setThroughHelper(address newOwner) external { //~WARN: protected variable `owner` is written without `checkOwner()`
+        writeOwner(newOwner);
+    }
+
+    function setThroughProtectedHelper(address newOwner) external {
+        protectedWriteOwner(newOwner);
+    }
+
+    function protectedWriteOwner(address newOwner) internal {
+        checkOwner();
+        writeOwner(newOwner);
+    }
+
+    function writeOwner(address newOwner) internal {
+        owner = newOwner;
+    }
+}
+
+contract ModifierPaths {
+    /// @custom:security write-protection="onlyOwner()"
+    address public owner;
+
+    modifier onlyOwner() {
+        _checkOwner();
+        _;
+    }
+
+    modifier writeOwner(address newOwner) {
+        owner = newOwner;
+        _;
+    }
+
+    function _checkOwner() internal view {
+        require(msg.sender == owner);
+    }
+
+    function writeInUnprotectedModifier(address newOwner) //~WARN: protected variable `owner` is written without `onlyOwner()`
+        external
+        writeOwner(newOwner)
+    {}
+
+    function writeInProtectedHelper(address newOwner) external {
+        _writeInProtectedHelper(newOwner);
+    }
+
+    function _writeInProtectedHelper(address newOwner) internal onlyOwner {
+        owner = newOwner;
+    }
+}
+
+contract FunctionFromModifier {
+    /// @custom:security write-protection="checkOwner()"
+    address public owner;
+
+    modifier checked() {
+        checkOwner();
+        _;
+    }
+
+    function checkOwner() internal view {
+        require(msg.sender == owner);
+    }
+
+    function setOwner(address newOwner) external checked {
+        owner = newOwner;
+    }
+}
+
+contract ExactSignatures {
+    /// @custom:security write-protection="onlyRole(bytes32)"
+    bytes32 public role;
+
+    modifier onlyRole(bytes32 expected) {
+        require(role == expected);
+        _;
+    }
+
+    function setRole(bytes32 oldRole, bytes32 newRole) external onlyRole(oldRole) {
+        role = newRole;
+    }
+
+    function setRoleUnprotected(bytes32 newRole) external { //~WARN: protected variable `role` is written without `onlyRole(bytes32)`
+        role = newRole;
+    }
+}
+
+contract ExternalGuardCall {
+    /// @custom:security write-protection="checkOwner()"
+    address public owner;
+
+    function checkOwner() external view {
+        require(msg.sender == owner);
+    }
+
+    function setOwner(address newOwner) external { //~WARN: protected variable `owner` is written without `checkOwner()`
+        this.checkOwner();
+        owner = newOwner;
+    }
+}
+
+contract CollectionWrites {
+    /// @custom:security write-protection="onlyOwner()"
+    address[] public members;
+
+    /// @custom:security write-protection="onlyOwner()"
+    mapping(address => bool) public allowed;
+
+    address public owner;
+
+    modifier onlyOwner() {
+        require(msg.sender == owner);
+        _;
+    }
+
+    function addMember(address member) external { //~WARN: protected variable `members` is written without `onlyOwner()`
+        members.push(member);
+    }
+
+    function clearMember(address member) external { //~WARN: protected variable `allowed` is written without `onlyOwner()`
+        delete allowed[member];
+    }
+
+    function removeMember() external onlyOwner {
+        members.pop();
+    }
+}
+
+contract StorageAliases {
+    struct Settings {
+        address operator;
+    }
+
+    /// @custom:security write-protection="onlyOwner()"
+    Settings internal settings;
+
+    address public owner;
+
+    modifier onlyOwner() {
+        require(msg.sender == owner);
+        _;
+    }
+
+    function setOperator(address operator) external { //~WARN: protected variable `settings` is written without `onlyOwner()`
+        Settings storage current = settings;
+        current.operator = operator;
+    }
+
+    function setOperatorThroughParameter(address operator) external { //~WARN: protected variable `settings` is written without `onlyOwner()`
+        writeSettings(settings, operator);
+    }
+
+    function writeSettings(Settings storage current, address operator) internal {
+        current.operator = operator;
+    }
+}
+
+contract ReachabilitySemantics {
+    /// @custom:security write-protection="checkOwner()"
+    address public owner;
+
+    /// @custom:security write-protection="checkOwner()"
+    mapping(address => address) public owners;
+
+    function checkOwner() internal view {
+        require(msg.sender == owner);
+    }
+
+    function branchGuard(bool guard, address newOwner) external { //~WARN: protected variable `owner` is written without `checkOwner()`
+        if (guard) checkOwner();
+        owner = newOwner;
+    }
+
+    function guardAfterWrite(address newOwner) external { //~WARN: protected variable `owner` is written without `checkOwner()`
+        owner = newOwner;
+        checkOwner();
+    }
+
+    function guardBeforeWrite(address newOwner) external {
+        checkOwner();
+        owner = newOwner;
+    }
+
+    function guardOnEveryBranch(bool branch, address newOwner) external {
+        if (branch) checkOwner();
+        else checkOwner();
+        owner = newOwner;
+    }
+
+    function guardOnEveryTryClause(address newOwner) external {
+        try this.externalCall() {
+            checkOwner();
+        } catch {
+            checkOwner();
+        }
+        owner = newOwner;
+    }
+
+    function guardOnOneReturnPath(bool skip, address newOwner) external { //~WARN: protected variable `owner` is written without `checkOwner()`
+        maybeGuard(skip);
+        owner = newOwner;
+    }
+
+    function ternaryGuard(bool guard, address newOwner) external { //~WARN: protected variable `owner` is written without `checkOwner()`
+        guard ? checkOwnerBool() : true;
+        owner = newOwner;
+    }
+
+    function shortCircuitGuard(bool guard, address newOwner) external { //~WARN: protected variable `owner` is written without `checkOwner()`
+        guard && checkOwnerBool();
+        owner = newOwner;
+    }
+
+    function guardOnEveryTernaryArm(bool guard, address newOwner) external {
+        guard ? checkOwnerBool() : checkOwnerBool();
+        owner = newOwner;
+    }
+
+    function writeAfterRevertingHelper(address newOwner) external {
+        alwaysReverts();
+        owner = newOwner;
+    }
+
+    function writeAfterRecursiveHelper(address newOwner) external {
+        recursiveLoop();
+        owner = newOwner;
+    }
+
+    function deleteWithRevertingIndex() external {
+        delete owners[revertingAddress()];
+    }
+
+    function writeAfterRevertingDoWhile(bool repeat, address newOwner) external {
+        do {
+            alwaysReverts();
+        } while (repeat);
+        owner = newOwner;
+    }
+
+    function writeAfterRevertingWhileCondition(address newOwner) external {
+        while (revertingBool()) {}
+        owner = newOwner;
+    }
+
+    function writeAfterMutualRecursion(address newOwner) external { //~WARN: protected variable `owner` is written without `checkOwner()`
+        recursiveB();
+        recursiveB();
+        owner = newOwner;
+    }
+
+    function writeAfterConditionalRecursion(bool stop, address newOwner) external { //~WARN: protected variable `owner` is written without `checkOwner()`
+        recursiveMaybeWrite(stop, newOwner);
+    }
+
+    function writeAfterDoWhileContinue(bool repeat, address newOwner) external { //~WARN: protected variable `owner` is written without `checkOwner()`
+        do {
+            continue;
+        } while (repeat);
+        owner = newOwner;
+    }
+
+    function maybeGuard(bool skip) internal view {
+        if (skip) return;
+        checkOwner();
+    }
+
+    function checkOwnerBool() internal view returns (bool) {
+        checkOwner();
+        return true;
+    }
+
+    function alwaysReverts() internal pure {
+        revert();
+    }
+
+    function recursiveLoop() internal pure {
+        recursiveLoop();
+    }
+
+    function revertingAddress() internal pure returns (address) {
+        revert();
+    }
+
+    function revertingBool() internal pure returns (bool) {
+        revert();
+    }
+
+    function recursiveA(bool stop) internal pure {
+        if (stop) return;
+        recursiveB();
+    }
+
+    function recursiveB() internal pure {
+        recursiveA(true);
+    }
+
+    function recursiveMaybeWrite(bool stop, address newOwner) internal {
+        if (stop) return;
+        recursiveMaybeWrite(true, newOwner);
+        owner = newOwner;
+    }
+
+    function externalCall() external pure {}
+}
+
+contract BaseProtection {
+    /// @custom:security write-protection="onlyOwner()"
+    address public owner;
+
+    modifier onlyOwner() {
+        require(msg.sender == owner);
+        _;
+    }
+
+    function inheritedUnsafe(address newOwner) public { //~WARN: protected variable `owner` is written without `onlyOwner()`
+        owner = newOwner;
+    }
+
+    function inheritedSafe(address newOwner) public onlyOwner {
+        owner = newOwner;
+    }
+}
+
+contract DerivedProtection is BaseProtection {
+    function derivedUnsafe(address newOwner) external { //~WARN: protected variable `owner` is written without `onlyOwner()`
+        owner = newOwner;
+    }
+
+    function derivedSafe(address newOwner) external onlyOwner {
+        owner = newOwner;
+    }
+}
+
+contract UnresolvedProtection {
+    /// @custom:security write-protection="notDeclared()"
+    address public owner;
+
+    // Invalid requirements fail closed instead of silently disabling the security annotation.
+    function setOwner(address newOwner) external { //~WARN: protected variable `owner` is written without `notDeclared()`
+        owner = newOwner;
+    }
+}
+
+contract MalformedProtection {
+    /// @custom:security write-protection="onlyOwner()
+    address public owner;
+
+    /// @custom:security no-write-protection
+    address public unprotected;
+
+    function setOwner(address newOwner) external { //~WARN: protected variable `owner` has a malformed write-protection annotation
+        owner = newOwner;
+    }
+
+    function setUnprotected(address newValue) external {
+        unprotected = newValue;
+    }
+}
+
+contract ModifierContinuations {
+    /// @custom:security write-protection="checkOwner()"
+    address public owner;
+
+    modifier blocked() {
+        revert();
+        _;
+    }
+
+    modifier passthrough() {
+        _;
+    }
+
+    modifier checkAfter() {
+        _;
+        checkOwner();
+    }
+
+    modifier writeAfter(address newOwner) {
+        _;
+        owner = newOwner;
+    }
+
+    modifier revertAfter() {
+        _;
+        revert();
+    }
+
+    function checkOwner() internal view {
+        require(msg.sender == owner);
+    }
+
+    function unreachableWrite(address newOwner) external blocked {
+        owner = newOwner;
+    }
+
+    function reachableWrite(address newOwner) external passthrough { //~WARN: protected variable `owner` is written without `checkOwner()`
+        owner = newOwner;
+    }
+
+    function guardAfterBody(address newOwner) external checkAfter { //~WARN: protected variable `owner` is written without `checkOwner()`
+        owner = newOwner;
+    }
+
+    function unreachableOuterPost(address newOwner) external writeAfter(newOwner) blocked {}
+
+    function reachableOuterPost(address newOwner) external writeAfter(newOwner) passthrough {} //~WARN: protected variable `owner` is written without `checkOwner()`
+
+    function returnBeforeOuterPost(address newOwner) external writeAfter(newOwner) { //~WARN: protected variable `owner` is written without `checkOwner()`
+        return;
+    }
+
+    function conditionalReturnBeforeOuterPost(bool skip, address newOwner) //~WARN: protected variable `owner` is written without `checkOwner()`
+        external
+        writeAfter(newOwner)
+    {
+        if (skip) return;
+        checkOwner();
+    }
+
+    function returningHelper() internal revertAfter {
+        return;
+    }
+
+    function unreachableAfterReturningHelper(address newOwner) external {
+        returningHelper();
+        owner = newOwner;
+    }
+}
+
+contract YulStoragePointerRetargeting {
+    /// @custom:security write-protection="onlyOwner()"
+    uint256[] internal protectedValues;
+
+    uint256[] internal ordinaryValues;
+
+    function retargetAwayFromProtected() external {
+        uint256[] storage pointer = protectedValues;
+        assembly {
+            pointer.slot := ordinaryValues.slot
+        }
+        pointer.push(1);
+    }
+
+    function retargetToProtected() external { //~WARN: protected variable `protectedValues` is written without `onlyOwner()`
+        uint256[] storage pointer = ordinaryValues;
+        assembly {
+            pointer.slot := protectedValues.slot
+        }
+        pointer.push(1);
+    }
+
+    function retargetAwayOnExhaustiveSwitch(uint256 selector) external {
+        uint256[] storage pointer = protectedValues;
+        assembly {
+            switch selector
+            case 0 { pointer.slot := ordinaryValues.slot }
+            default { pointer.slot := ordinaryValues.slot }
+        }
+        pointer.push(1);
+    }
+}
+
+contract TransientStorageWrites {
+    /// @custom:security write-protection="onlyOwner()"
+    uint256 internal protectedValue;
+
+    function writeTransientStorage() external {
+        assembly {
+            tstore(protectedValue.slot, 1)
+        }
+    }
+
+    function writePersistentStorage() external { //~WARN: protected variable `protectedValue` is written without `onlyOwner()`
+        assembly {
+            sstore(protectedValue.slot, 1)
+        }
+    }
+}
+
+contract YulCallReturnSummaries {
+    /// @custom:security write-protection="onlyOwner()"
+    uint256 internal protectedValue;
+
+    uint256 internal ordinaryValue;
+
+    function storeAtSecondArgument() external {
+        assembly {
+            function pickSecond(first, second) -> result {
+                result := second
+            }
+            sstore(pickSecond(protectedValue.slot, ordinaryValue.slot), 1)
+        }
+    }
+
+    function storeAtFirstArgument() external { //~WARN: protected variable `protectedValue` is written without `onlyOwner()`
+        assembly {
+            function pickFirst(first, second) -> result {
+                result := first
+            }
+            sstore(pickFirst(protectedValue.slot, ordinaryValue.slot), 1)
+        }
+    }
+}
diff --git a/crates/lint/testdata/ProtectedVars.stderr b/crates/lint/testdata/ProtectedVars.stderr
new file mode 100644
index 0000000000000..abc1a83e0fa1e
--- /dev/null
+++ b/crates/lint/testdata/ProtectedVars.stderr
@@ -0,0 +1,232 @@
+warning[protected-vars]: protected variable `owner` is written without `onlyOwner()`
+   ╭▸ ROOT/testdata/ProtectedVars.sol:LL:CC
+   │
+LL │     function setOwner(address newOwner) external {
+   │              ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `owner` is written without `checkOwner()`
+   ╭▸ ROOT/testdata/ProtectedVars.sol:LL:CC
+   │
+LL │     function setThroughHelper(address newOwner) external {
+   │              ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `owner` is written without `onlyOwner()`
+   ╭▸ ROOT/testdata/ProtectedVars.sol:LL:CC
+   │
+LL │     function writeInUnprotectedModifier(address newOwner)
+   │              ━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `role` is written without `onlyRole(bytes32)`
+   ╭▸ ROOT/testdata/ProtectedVars.sol:LL:CC
+   │
+LL │     function setRoleUnprotected(bytes32 newRole) external {
+   │              ━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `owner` is written without `checkOwner()`
+   ╭▸ ROOT/testdata/ProtectedVars.sol:LL:CC
+   │
+LL │     function setOwner(address newOwner) external {
+   │              ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `members` is written without `onlyOwner()`
+   ╭▸ ROOT/testdata/ProtectedVars.sol:LL:CC
+   │
+LL │     function addMember(address member) external {
+   │              ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `allowed` is written without `onlyOwner()`
+   ╭▸ ROOT/testdata/ProtectedVars.sol:LL:CC
+   │
+LL │     function clearMember(address member) external {
+   │              ━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `settings` is written without `onlyOwner()`
+   ╭▸ ROOT/testdata/ProtectedVars.sol:LL:CC
+   │
+LL │     function setOperator(address operator) external {
+   │              ━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `settings` is written without `onlyOwner()`
+   ╭▸ ROOT/testdata/ProtectedVars.sol:LL:CC
+   │
+LL │     function setOperatorThroughParameter(address operator) external {
+   │              ━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `owner` is written without `checkOwner()`
+   ╭▸ ROOT/testdata/ProtectedVars.sol:LL:CC
+   │
+LL │     function branchGuard(bool guard, address newOwner) external {
+   │              ━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `owner` is written without `checkOwner()`
+   ╭▸ ROOT/testdata/ProtectedVars.sol:LL:CC
+   │
+LL │     function guardAfterWrite(address newOwner) external {
+   │              ━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `owner` is written without `checkOwner()`
+   ╭▸ ROOT/testdata/ProtectedVars.sol:LL:CC
+   │
+LL │     function guardOnOneReturnPath(bool skip, address newOwner) external {
+   │              ━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `owner` is written without `checkOwner()`
+   ╭▸ ROOT/testdata/ProtectedVars.sol:LL:CC
+   │
+LL │     function ternaryGuard(bool guard, address newOwner) external {
+   │              ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `owner` is written without `checkOwner()`
+   ╭▸ ROOT/testdata/ProtectedVars.sol:LL:CC
+   │
+LL │     function shortCircuitGuard(bool guard, address newOwner) external {
+   │              ━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `owner` is written without `checkOwner()`
+   ╭▸ ROOT/testdata/ProtectedVars.sol:LL:CC
+   │
+LL │     function writeAfterMutualRecursion(address newOwner) external {
+   │              ━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `owner` is written without `checkOwner()`
+   ╭▸ ROOT/testdata/ProtectedVars.sol:LL:CC
+   │
+LL │     function writeAfterConditionalRecursion(bool stop, address newOwner) external {
+   │              ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `owner` is written without `checkOwner()`
+   ╭▸ ROOT/testdata/ProtectedVars.sol:LL:CC
+   │
+LL │     function writeAfterDoWhileContinue(bool repeat, address newOwner) external {
+   │              ━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `owner` is written without `onlyOwner()`
+   ╭▸ ROOT/testdata/ProtectedVars.sol:LL:CC
+   │
+LL │     function derivedUnsafe(address newOwner) external {
+   │              ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `owner` is written without `onlyOwner()` in most-derived contract `DerivedProtection`
+   ╭▸ ROOT/testdata/ProtectedVars.sol:LL:CC
+   │
+LL │     function inheritedUnsafe(address newOwner) public {
+   │              ━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `owner` is written without `notDeclared()`
+   ╭▸ ROOT/testdata/ProtectedVars.sol:LL:CC
+   │
+LL │     function setOwner(address newOwner) external {
+   │              ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `owner` has a malformed write-protection annotation
+   ╭▸ ROOT/testdata/ProtectedVars.sol:LL:CC
+   │
+LL │     function setOwner(address newOwner) external {
+   │              ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `owner` is written without `checkOwner()`
+   ╭▸ ROOT/testdata/ProtectedVars.sol:LL:CC
+   │
+LL │     function reachableWrite(address newOwner) external passthrough {
+   │              ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `owner` is written without `checkOwner()`
+   ╭▸ ROOT/testdata/ProtectedVars.sol:LL:CC
+   │
+LL │     function guardAfterBody(address newOwner) external checkAfter {
+   │              ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `owner` is written without `checkOwner()`
+   ╭▸ ROOT/testdata/ProtectedVars.sol:LL:CC
+   │
+LL │     function reachableOuterPost(address newOwner) external writeAfter(newOwner) passthrough {}
+   │              ━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `owner` is written without `checkOwner()`
+   ╭▸ ROOT/testdata/ProtectedVars.sol:LL:CC
+   │
+LL │     function returnBeforeOuterPost(address newOwner) external writeAfter(newOwner) {
+   │              ━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `owner` is written without `checkOwner()`
+   ╭▸ ROOT/testdata/ProtectedVars.sol:LL:CC
+   │
+LL │     function conditionalReturnBeforeOuterPost(bool skip, address newOwner)
+   │              ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `protectedValues` is written without `onlyOwner()`
+   ╭▸ ROOT/testdata/ProtectedVars.sol:LL:CC
+   │
+LL │     function retargetToProtected() external {
+   │              ━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `protectedValue` is written without `onlyOwner()`
+   ╭▸ ROOT/testdata/ProtectedVars.sol:LL:CC
+   │
+LL │     function writePersistentStorage() external {
+   │              ━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `protectedValue` is written without `onlyOwner()`
+   ╭▸ ROOT/testdata/ProtectedVars.sol:LL:CC
+   │
+LL │     function storeAtFirstArgument() external {
+   │              ━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
diff --git a/crates/lint/testdata/ProtectedVarsAdvanced.sol b/crates/lint/testdata/ProtectedVarsAdvanced.sol
new file mode 100644
index 0000000000000..72ae98718b95b
--- /dev/null
+++ b/crates/lint/testdata/ProtectedVarsAdvanced.sol
@@ -0,0 +1,404 @@
+//@compile-flags: --only-lint protected-vars
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+contract ExactOverloads {
+    /// @custom:security write-protection="guard(uint256)"
+    uint256 protectedValue;
+
+    function guard(uint256) internal pure {}
+
+    function guard(address) internal pure {}
+
+    function unsafeWrongOverload() external { //~WARN: protected variable `protectedValue` is written without `guard(uint256)`
+        guard(address(0));
+        protectedValue = 1;
+    }
+
+    function safeExactOverload() external {
+        guard(1);
+        protectedValue = 2;
+    }
+}
+
+contract VirtualGuardBase {
+    /// @custom:security write-protection="guard()"
+    uint256 internal protectedValue;
+
+    function guard() internal pure {}
+
+    function hook() internal virtual {
+        guard();
+    }
+
+    function writeThroughHook() external { //~WARN: protected variable `protectedValue` is written without `guard()`
+        hook();
+        protectedValue = 1;
+    }
+}
+
+contract VirtualGuardRemoved is VirtualGuardBase {
+    function hook() internal pure override {}
+}
+
+contract VirtualGuardAddedBase {
+    /// @custom:security write-protection="guard()"
+    uint256 internal protectedValue;
+
+    function guard() internal pure {}
+
+    function hook() internal virtual {}
+
+    function writeThroughHook() external {
+        hook();
+        protectedValue = 1;
+    }
+}
+
+contract VirtualGuardAdded is VirtualGuardAddedBase {
+    function hook() internal pure override {
+        guard();
+    }
+}
+
+contract ModifierGuardBase {
+    /// @custom:security write-protection="guard()"
+    uint256 internal protectedValue;
+
+    function guard() internal pure {}
+
+    modifier checked() virtual {
+        guard();
+        _;
+    }
+
+    function writeWithModifier() external checked { //~WARN: protected variable `protectedValue` is written without `guard()`
+        protectedValue = 1;
+    }
+}
+
+contract ModifierGuardRemoved is ModifierGuardBase {
+    modifier checked() override {
+        _;
+    }
+}
+
+contract ModifierGuardAddedBase {
+    /// @custom:security write-protection="guard()"
+    uint256 internal protectedValue;
+
+    function guard() internal pure {}
+
+    modifier checked() virtual {
+        _;
+    }
+
+    function writeWithModifier() external checked {
+        protectedValue = 1;
+    }
+}
+
+contract ModifierGuardAdded is ModifierGuardAddedBase {
+    modifier checked() override {
+        guard();
+        _;
+    }
+}
+
+struct LibrarySettings {
+    uint256 value;
+}
+
+library SettingsLib {
+    function mutate(LibrarySettings storage settings) internal {
+        settings.value = 1;
+    }
+}
+
+contract AttachedLibraryWrite {
+    using SettingsLib for LibrarySettings;
+
+    /// @custom:security write-protection="guard()"
+    LibrarySettings protectedSettings;
+
+    function guard() internal pure {}
+
+    function unsafeWrite() external { //~WARN: protected variable `protectedSettings` is written without `guard()`
+        protectedSettings.mutate();
+    }
+}
+
+contract AdvancedStorageAliases {
+    struct Settings {
+        uint256 value;
+    }
+
+    /// @custom:security write-protection="guard()"
+    Settings protectedSettings;
+    Settings otherSettings;
+
+    /// @custom:security write-protection="guard()"
+    Settings[] protectedList;
+
+    function guard() internal pure {}
+
+    function settings() internal view returns (Settings storage result) {
+        result = protectedSettings;
+    }
+
+    function selectedSettings(bool chooseProtected) internal view returns (Settings storage) {
+        if (chooseProtected) return protectedSettings;
+        return otherSettings;
+    }
+
+    function returnedAlias() external { //~WARN: protected variable `protectedSettings` is written without `guard()`
+        settings().value = 1;
+    }
+
+    function earlyReturnedAlias(bool chooseProtected) external { //~WARN: protected variable `protectedSettings` is written without `guard()`
+        selectedSettings(chooseProtected).value = 1;
+    }
+
+    function pushedAlias() external { //~WARN: protected variable `protectedList` is written without `guard()`
+        protectedList.push().value = 1;
+    }
+
+    function conditionalAlias(bool chooseProtected) external { //~WARN: protected variable `protectedSettings` is written without `guard()`
+        Settings storage selected = chooseProtected ? protectedSettings : otherSettings;
+        selected.value = 1;
+    }
+
+    function exitedAlias(bool stop) external {
+        Settings storage selected = otherSettings;
+        if (stop) {
+            selected = protectedSettings;
+            return;
+        }
+        selected.value = 1;
+    }
+
+    function reassignOnly() external {
+        Settings storage selected = protectedSettings;
+        selected = otherSettings;
+    }
+
+    function loopAlias() external { //~WARN: protected variable `protectedSettings` is written without `guard()`
+        Settings storage first = otherSettings;
+        Settings storage second = otherSettings;
+        for (uint256 i; i < 2; ++i) {
+            first = second;
+            second = protectedSettings;
+        }
+        first.value = 1;
+    }
+
+    function breakAlias(bool shouldIterate, bool shouldBreak) external { //~WARN: protected variable `protectedSettings` is written without `guard()`
+        Settings storage selected = otherSettings;
+        while (shouldIterate) {
+            selected = protectedSettings;
+            if (shouldBreak) break;
+            selected = otherSettings;
+            shouldIterate = false;
+        }
+        selected.value = 1;
+    }
+
+    function continueAlias(bool shouldIterate, bool shouldContinue) external { //~WARN: protected variable `protectedSettings` is written without `guard()`
+        Settings storage selected = otherSettings;
+        while (shouldIterate) {
+            selected = protectedSettings;
+            if (shouldContinue) {
+                shouldIterate = false;
+                continue;
+            }
+            selected = otherSettings;
+            shouldIterate = false;
+        }
+        selected.value = 1;
+    }
+
+    function recurse(Settings storage current, bool again) internal {
+        if (again) recurse(protectedSettings, false);
+        current.value = 1;
+    }
+
+    function recursiveAlias() external { //~WARN: protected variable `protectedSettings` is written without `guard()`
+        recurse(otherSettings, true);
+    }
+
+    function mutate(Settings storage safe, Settings storage target) internal {
+        if (safe.value == type(uint256).max) revert();
+        target.value = 1;
+    }
+
+    function namedArguments() external { //~WARN: protected variable `protectedSettings` is written without `guard()`
+        mutate({target: protectedSettings, safe: otherSettings});
+    }
+}
+
+contract SignatureType {}
+
+contract ModifierSourceSignature {
+    /// @custom:security write-protection="modifierGuard(SignatureType)"
+    uint256 protectedValue;
+
+    modifier modifierGuard(SignatureType) {
+        _;
+    }
+
+    function safeWrite(SignatureType value) external modifierGuard(value) {
+        protectedValue = 1;
+    }
+
+    function unsafeWrite() external { //~WARN: protected variable `protectedValue` is written without `modifierGuard(SignatureType)`
+        protectedValue = 2;
+    }
+}
+
+contract ModifierFunctionSignature {
+    /// @custom:security write-protection="modifierGuard(function(uint256) returns(bool))"
+    uint256 protectedValue;
+
+    /// @custom:security write-protection="functionGuard(function(uint256) returns(bool))"
+    uint256 functionProtected;
+
+    modifier modifierGuard(function(uint256) external returns (bool)) {
+        _;
+    }
+
+    function functionGuard(function(uint256) external returns (bool)) internal pure {}
+
+    function callback(uint256) external pure returns (bool) {
+        return true;
+    }
+
+    function safeWrite() external modifierGuard(this.callback) {
+        protectedValue = 1;
+    }
+
+    function unsafeWrite() external { //~WARN: protected variable `protectedValue` is written without `modifierGuard(function(uint256) returns(bool))`
+        protectedValue = 2;
+    }
+
+    function safeFunctionWrite() external {
+        functionGuard(this.callback);
+        functionProtected = 1;
+    }
+
+    function unsafeFunctionWrite() external { //~WARN: protected variable `functionProtected` is written without `functionGuard(function(uint256) returns(bool))`
+        functionProtected = 2;
+    }
+}
+
+contract AssemblyWrites {
+    /// @custom:security write-protection="guard()"
+    uint256 protectedValue;
+
+    function guard() internal pure {}
+
+    function directWrite() external { //~WARN: protected variable `protectedValue` is written without `guard()`
+        assembly {
+            sstore(protectedValue.slot, 1)
+        }
+    }
+
+    function helperParameterWrite() external { //~WARN: protected variable `protectedValue` is written without `guard()`
+        assembly {
+            function write(slot) {
+                sstore(slot, 1)
+            }
+            write(protectedValue.slot)
+        }
+    }
+
+    function helperReturnWrite() external { //~WARN: protected variable `protectedValue` is written without `guard()`
+        assembly {
+            let root := protectedValue.slot
+            function getSlot(input) -> slot {
+                slot := input
+            }
+            sstore(getSlot(root), 1)
+        }
+    }
+
+    function multiReturnDeclaration() external {
+        assembly {
+            function pair(first, second) -> a, b {
+                a := first
+                b := second
+            }
+            let protectedSlot, ordinarySlot := pair(protectedValue.slot, 0)
+            sstore(ordinarySlot, 1)
+        }
+    }
+
+    function multiReturnDeclarationWrite() external { //~WARN: protected variable `protectedValue` is written without `guard()`
+        assembly {
+            function pair(first, second) -> a, b {
+                a := first
+                b := second
+            }
+            let protectedSlot, ordinarySlot := pair(protectedValue.slot, 0)
+            sstore(protectedSlot, 1)
+        }
+    }
+
+    function multiReturnAssignment() external {
+        assembly {
+            function pair(first, second) -> a, b {
+                a := first
+                b := second
+            }
+            let protectedSlot := 0
+            let ordinarySlot := 0
+            protectedSlot, ordinarySlot := pair(protectedValue.slot, 0)
+            sstore(ordinarySlot, 1)
+        }
+    }
+
+    function multiReturnAssignmentWrite() external { //~WARN: protected variable `protectedValue` is written without `guard()`
+        assembly {
+            function pair(first, second) -> a, b {
+                a := first
+                b := second
+            }
+            let protectedSlot := 0
+            let ordinarySlot := 0
+            protectedSlot, ordinarySlot := pair(protectedValue.slot, 0)
+            sstore(protectedSlot, 1)
+        }
+    }
+}
+
+contract SharedInheritedEntry {
+    /// @custom:security write-protection="guard()"
+    uint256 protectedValue;
+
+    function guard() internal pure {}
+
+    function unsafeWrite() external {
+        //~^WARN: protected variable `protectedValue` is written without `guard()` in most-derived contract `SharedLeafOne`
+        //~|WARN: protected variable `protectedValue` is written without `guard()` in most-derived contract `SharedLeafTwo`
+        protectedValue = 1;
+    }
+}
+
+contract SharedLeafOne is SharedInheritedEntry {}
+
+contract SharedLeafTwo is SharedInheritedEntry {}
+
+contract SpecialEntryPoints {
+    /// @custom:security write-protection="guard()"
+    uint256 protectedValue;
+
+    function guard() internal pure {}
+
+    fallback() external { //~WARN: protected variable `protectedValue` is written without `guard()`
+        protectedValue = 1;
+    }
+
+    receive() external payable { //~WARN: protected variable `protectedValue` is written without `guard()`
+        protectedValue = 2;
+    }
+}
diff --git a/crates/lint/testdata/ProtectedVarsAdvanced.stderr b/crates/lint/testdata/ProtectedVarsAdvanced.stderr
new file mode 100644
index 0000000000000..57914e6c9ca8e
--- /dev/null
+++ b/crates/lint/testdata/ProtectedVarsAdvanced.stderr
@@ -0,0 +1,200 @@
+warning[protected-vars]: protected variable `protectedValue` is written without `guard(uint256)`
+   ╭▸ ROOT/testdata/ProtectedVarsAdvanced.sol:LL:CC
+   │
+LL │     function unsafeWrongOverload() external {
+   │              ━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `protectedValue` is written without `guard()` in most-derived contract `VirtualGuardRemoved`
+   ╭▸ ROOT/testdata/ProtectedVarsAdvanced.sol:LL:CC
+   │
+LL │     function writeThroughHook() external {
+   │              ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `protectedValue` is written without `guard()` in most-derived contract `ModifierGuardRemoved`
+   ╭▸ ROOT/testdata/ProtectedVarsAdvanced.sol:LL:CC
+   │
+LL │     function writeWithModifier() external checked {
+   │              ━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `protectedSettings` is written without `guard()`
+   ╭▸ ROOT/testdata/ProtectedVarsAdvanced.sol:LL:CC
+   │
+LL │     function unsafeWrite() external {
+   │              ━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `protectedSettings` is written without `guard()`
+   ╭▸ ROOT/testdata/ProtectedVarsAdvanced.sol:LL:CC
+   │
+LL │     function returnedAlias() external {
+   │              ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `protectedSettings` is written without `guard()`
+   ╭▸ ROOT/testdata/ProtectedVarsAdvanced.sol:LL:CC
+   │
+LL │     function earlyReturnedAlias(bool chooseProtected) external {
+   │              ━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `protectedList` is written without `guard()`
+   ╭▸ ROOT/testdata/ProtectedVarsAdvanced.sol:LL:CC
+   │
+LL │     function pushedAlias() external {
+   │              ━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `protectedSettings` is written without `guard()`
+   ╭▸ ROOT/testdata/ProtectedVarsAdvanced.sol:LL:CC
+   │
+LL │     function conditionalAlias(bool chooseProtected) external {
+   │              ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `protectedSettings` is written without `guard()`
+   ╭▸ ROOT/testdata/ProtectedVarsAdvanced.sol:LL:CC
+   │
+LL │     function loopAlias() external {
+   │              ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `protectedSettings` is written without `guard()`
+   ╭▸ ROOT/testdata/ProtectedVarsAdvanced.sol:LL:CC
+   │
+LL │     function breakAlias(bool shouldIterate, bool shouldBreak) external {
+   │              ━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `protectedSettings` is written without `guard()`
+   ╭▸ ROOT/testdata/ProtectedVarsAdvanced.sol:LL:CC
+   │
+LL │     function continueAlias(bool shouldIterate, bool shouldContinue) external {
+   │              ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `protectedSettings` is written without `guard()`
+   ╭▸ ROOT/testdata/ProtectedVarsAdvanced.sol:LL:CC
+   │
+LL │     function recursiveAlias() external {
+   │              ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `protectedSettings` is written without `guard()`
+   ╭▸ ROOT/testdata/ProtectedVarsAdvanced.sol:LL:CC
+   │
+LL │     function namedArguments() external {
+   │              ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `protectedValue` is written without `modifierGuard(SignatureType)`
+   ╭▸ ROOT/testdata/ProtectedVarsAdvanced.sol:LL:CC
+   │
+LL │     function unsafeWrite() external {
+   │              ━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `protectedValue` is written without `modifierGuard(function(uint256) returns(bool))`
+   ╭▸ ROOT/testdata/ProtectedVarsAdvanced.sol:LL:CC
+   │
+LL │     function unsafeWrite() external {
+   │              ━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `functionProtected` is written without `functionGuard(function(uint256) returns(bool))`
+   ╭▸ ROOT/testdata/ProtectedVarsAdvanced.sol:LL:CC
+   │
+LL │     function unsafeFunctionWrite() external {
+   │              ━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `protectedValue` is written without `guard()`
+   ╭▸ ROOT/testdata/ProtectedVarsAdvanced.sol:LL:CC
+   │
+LL │     function directWrite() external {
+   │              ━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `protectedValue` is written without `guard()`
+   ╭▸ ROOT/testdata/ProtectedVarsAdvanced.sol:LL:CC
+   │
+LL │     function helperParameterWrite() external {
+   │              ━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `protectedValue` is written without `guard()`
+   ╭▸ ROOT/testdata/ProtectedVarsAdvanced.sol:LL:CC
+   │
+LL │     function helperReturnWrite() external {
+   │              ━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `protectedValue` is written without `guard()`
+   ╭▸ ROOT/testdata/ProtectedVarsAdvanced.sol:LL:CC
+   │
+LL │     function multiReturnDeclarationWrite() external {
+   │              ━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `protectedValue` is written without `guard()`
+   ╭▸ ROOT/testdata/ProtectedVarsAdvanced.sol:LL:CC
+   │
+LL │     function multiReturnAssignmentWrite() external {
+   │              ━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `protectedValue` is written without `guard()` in most-derived contract `SharedLeafOne`
+   ╭▸ ROOT/testdata/ProtectedVarsAdvanced.sol:LL:CC
+   │
+LL │     function unsafeWrite() external {
+   │              ━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `protectedValue` is written without `guard()` in most-derived contract `SharedLeafTwo`
+   ╭▸ ROOT/testdata/ProtectedVarsAdvanced.sol:LL:CC
+   │
+LL │     function unsafeWrite() external {
+   │              ━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `protectedValue` is written without `guard()`
+   ╭▸ ROOT/testdata/ProtectedVarsAdvanced.sol:LL:CC
+   │
+LL │     fallback() external {
+   │     ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
+warning[protected-vars]: protected variable `protectedValue` is written without `guard()`
+   ╭▸ ROOT/testdata/ProtectedVarsAdvanced.sol:LL:CC
+   │
+LL │     receive() external payable {
+   │     ━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/protected-vars
+
diff --git a/crates/lint/testdata/RedundantBaseConstructorCall.sol b/crates/lint/testdata/RedundantBaseConstructorCall.sol
new file mode 100644
index 0000000000000..c8c481f2ca89c
--- /dev/null
+++ b/crates/lint/testdata/RedundantBaseConstructorCall.sol
@@ -0,0 +1,64 @@
+//@compile-flags: --only-lint redundant-base-constructor-call
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+contract NoCtor {}
+
+contract EmptyCtor {
+    constructor() {}
+}
+
+contract NonEmptyZeroParamCtor {
+    uint256 public x;
+    constructor() {
+        x = 1;
+    }
+}
+
+contract WithArgs {
+    uint256 public y;
+    constructor(uint256 _y) {
+        y = _y;
+    }
+}
+
+// SHOULD FAIL:
+
+contract A1 is NoCtor() {} //~NOTE: explicit empty base-constructor arguments are redundant
+
+contract A2 is EmptyCtor() {} //~NOTE: explicit empty base-constructor arguments are redundant
+
+contract A3 is NonEmptyZeroParamCtor() {} //~NOTE: explicit empty base-constructor arguments are redundant
+
+contract A4 is NoCtor {
+    constructor() NoCtor() {} //~NOTE: explicit empty base-constructor arguments are redundant
+}
+
+contract A5 is EmptyCtor {
+    constructor() EmptyCtor() {} //~NOTE: explicit empty base-constructor arguments are redundant
+}
+
+contract A6 is NoCtor(), EmptyCtor() {}
+//~^ NOTE: explicit empty base-constructor arguments are redundant
+//~| NOTE: explicit empty base-constructor arguments are redundant
+
+contract A7 is NoCtor, WithArgs {
+    modifier m() { _; }
+    constructor(uint256 v) m() WithArgs(v) NoCtor() {}
+    //~^ NOTE: explicit empty base-constructor arguments are redundant
+}
+
+// SHOULD PASS:
+
+contract Ok1 is NoCtor {}
+
+contract Ok2 is EmptyCtor {}
+
+contract Ok3 is WithArgs(1) {}
+
+contract Ok4 is WithArgs {
+    constructor(uint256 v) WithArgs(v) {}
+}
+
+contract Mid is NoCtor {}
+contract Ok5 is Mid {}
diff --git a/crates/lint/testdata/RedundantBaseConstructorCall.stderr b/crates/lint/testdata/RedundantBaseConstructorCall.stderr
new file mode 100644
index 0000000000000..42aa4e572f509
--- /dev/null
+++ b/crates/lint/testdata/RedundantBaseConstructorCall.stderr
@@ -0,0 +1,70 @@
+note[redundant-base-constructor-call]: explicit empty base-constructor arguments are redundant
+   ╭▸ ROOT/testdata/RedundantBaseConstructorCall.sol:LL:CC
+   │
+LL │ contract A1 is NoCtor() {}
+   │                      ━━ help: remove redundant base-constructor call
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/redundant-base-constructor-call
+
+note[redundant-base-constructor-call]: explicit empty base-constructor arguments are redundant
+   ╭▸ ROOT/testdata/RedundantBaseConstructorCall.sol:LL:CC
+   │
+LL │ contract A2 is EmptyCtor() {}
+   │                         ━━ help: remove redundant base-constructor call
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/redundant-base-constructor-call
+
+note[redundant-base-constructor-call]: explicit empty base-constructor arguments are redundant
+   ╭▸ ROOT/testdata/RedundantBaseConstructorCall.sol:LL:CC
+   │
+LL │ contract A3 is NonEmptyZeroParamCtor() {}
+   │                                     ━━ help: remove redundant base-constructor call
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/redundant-base-constructor-call
+
+note[redundant-base-constructor-call]: explicit empty base-constructor arguments are redundant
+   ╭▸ ROOT/testdata/RedundantBaseConstructorCall.sol:LL:CC
+   │
+LL │     constructor() NoCtor() {}
+   │                  ┬──────━━
+   │                  │
+   │                  help: remove redundant base-constructor call
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/redundant-base-constructor-call
+
+note[redundant-base-constructor-call]: explicit empty base-constructor arguments are redundant
+   ╭▸ ROOT/testdata/RedundantBaseConstructorCall.sol:LL:CC
+   │
+LL │     constructor() EmptyCtor() {}
+   │                  ┬─────────━━
+   │                  │
+   │                  help: remove redundant base-constructor call
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/redundant-base-constructor-call
+
+note[redundant-base-constructor-call]: explicit empty base-constructor arguments are redundant
+   ╭▸ ROOT/testdata/RedundantBaseConstructorCall.sol:LL:CC
+   │
+LL │ contract A6 is NoCtor(), EmptyCtor() {}
+   │                      ━━ help: remove redundant base-constructor call
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/redundant-base-constructor-call
+
+note[redundant-base-constructor-call]: explicit empty base-constructor arguments are redundant
+   ╭▸ ROOT/testdata/RedundantBaseConstructorCall.sol:LL:CC
+   │
+LL │ contract A6 is NoCtor(), EmptyCtor() {}
+   │                                   ━━ help: remove redundant base-constructor call
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/redundant-base-constructor-call
+
+note[redundant-base-constructor-call]: explicit empty base-constructor arguments are redundant
+   ╭▸ ROOT/testdata/RedundantBaseConstructorCall.sol:LL:CC
+   │
+LL │     constructor(uint256 v) m() WithArgs(v) NoCtor() {}
+   │                                           ┬──────━━
+   │                                           │
+   │                                           help: remove redundant base-constructor call
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/redundant-base-constructor-call
+
diff --git a/crates/lint/testdata/ReentrancyBalance.sol b/crates/lint/testdata/ReentrancyBalance.sol
new file mode 100644
index 0000000000000..2011abcad4f48
--- /dev/null
+++ b/crates/lint/testdata/ReentrancyBalance.sol
@@ -0,0 +1,1037 @@
+//@compile-flags: --only-lint reentrancy-balance
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+interface IReentrancyBalanceCallback {
+    function pay() external payable;
+    function observe() external view returns (uint256);
+    function amount() external returns (uint256);
+}
+
+interface IReentrancyBalanceSameNames {
+    function call(bytes calldata data) external;
+    function delegatecall(bytes calldata data) external;
+    function staticcall(bytes calldata data) external;
+}
+
+interface IReentrancyBalanceToken {
+    function balanceOf(address account) external view returns (uint256);
+}
+
+interface IReentrancyBalanceViewSameName {
+    function staticcall(bytes calldata data) external view returns (bytes memory);
+}
+
+contract ReentrancyBalance {
+    error InsufficientPayment();
+
+    uint256 private storedBalance;
+
+    modifier checkAfter(uint256 balanceBefore, uint256 amount) {
+        _;
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    modifier checkSelfAfter(address payable self, uint256 balanceBefore, uint256 amount) {
+        _;
+        require(self.balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function requireAfterCall(IReentrancyBalanceCallback callback, uint256 amount) external {
+        uint256 balanceBefore = payable(address(this)).balance;
+        callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+        require(
+            address(payable(address(this))).balance >= balanceBefore + amount,
+            "insufficient payment"
+        );
+    }
+
+    function revertingBranchAfterCall(IReentrancyBalanceCallback callback, uint256 amount) external {
+        uint256 balanceBefore = address(this).balance;
+        callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+        if (address(this).balance < balanceBefore + amount) {
+            revert InsufficientPayment();
+        }
+    }
+
+    function derivedBaseline(IReentrancyBalanceCallback callback, uint256 amount) external {
+        uint256 balanceBefore = address(this).balance;
+        uint256 minimumBalance = balanceBefore + amount;
+        callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+        require(address(this).balance >= minimumBalance, "insufficient payment");
+    }
+
+    function castAndFreshLocal(IReentrancyBalanceCallback callback, uint256 amount) external {
+        uint256 balanceBefore = uint256(address(this).balance);
+        callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+        uint256 balanceAfter = uint256(address(this).balance);
+        require(balanceAfter >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function selfAddressAlias(IReentrancyBalanceCallback callback, uint256 amount) external {
+        address payable self = payable(address(this));
+        uint256 balanceBefore = self.balance;
+        callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+        require(self.balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function helperReturnedSelfAddressAlias(
+        IReentrancyBalanceCallback callback,
+        uint256 amount
+    ) external {
+        address payable self = currentSelf();
+        uint256 balanceBefore = self.balance;
+        callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+        require(self.balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function helperReturnedSelfInlineBalance(
+        IReentrancyBalanceCallback callback,
+        uint256 amount
+    ) external {
+        uint256 balanceBefore = currentSelf().balance;
+        callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function helperReturnedSelfTupleAlias(
+        IReentrancyBalanceCallback callback,
+        uint256 amount
+    ) external {
+        address payable self;
+        (self,) = (currentSelf(), amount);
+        uint256 balanceBefore = self.balance;
+        callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+        require(self.balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function chainedHelperReturnedSelfAlias(
+        IReentrancyBalanceCallback callback,
+        uint256 amount
+    ) external {
+        address payable self = identity(currentSelf());
+        uint256 balanceBefore = self.balance;
+        callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+        require(self.balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function reassignedSelfAddressAlias(
+        IReentrancyBalanceCallback callback,
+        uint256 amount
+    ) external {
+        address payable self = payable(address(this));
+        uint256 balanceBefore = self.balance;
+        self = payable(address(callback));
+        callback.pay();
+        require(self.balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function conditionallyReassignedSelfAddressAlias(
+        IReentrancyBalanceCallback callback,
+        uint256 amount,
+        bool retarget
+    ) external {
+        address payable self = payable(address(this));
+        uint256 balanceBefore = self.balance;
+        if (retarget) {
+            self = payable(address(callback));
+            callback.pay();
+        }
+        require(self.balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function conditionallyRetargetedAfterCall(
+        IReentrancyBalanceCallback callback,
+        uint256 amount,
+        bool retarget
+    ) external {
+        address payable self = payable(address(this));
+        uint256 balanceBefore = self.balance;
+        callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+        if (retarget) self = payable(address(callback));
+        require(self.balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function tupleSelfAddressAlias(IReentrancyBalanceCallback callback, uint256 amount) external {
+        (address payable self, uint256 expected) = (payable(address(this)), amount);
+        uint256 balanceBefore = self.balance;
+        callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+        require(self.balance >= balanceBefore + expected, "insufficient payment");
+    }
+
+    function tupleReassignedSelfAddressAlias(
+        IReentrancyBalanceCallback callback,
+        uint256 amount
+    ) external {
+        address payable self = payable(address(this));
+        uint256 balanceBefore = self.balance;
+        (self, amount) = (payable(address(callback)), amount);
+        callback.pay();
+        require(self.balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function assemblyReassignedSelfAddressAlias(
+        IReentrancyBalanceCallback callback,
+        uint256 amount
+    ) external {
+        address payable self = payable(address(this));
+        uint256 balanceBefore = self.balance;
+        assembly {
+            self := caller()
+        }
+        callback.pay();
+        require(self.balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function helperSelfAddressAlias(IReentrancyBalanceCallback callback, uint256 amount) external {
+        address payable self = payable(address(this));
+        uint256 balanceBefore = self.balance;
+        callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+        checkSelfBalance(self, balanceBefore, amount);
+    }
+
+    function modifierSelfAddressAlias(IReentrancyBalanceCallback callback, uint256 amount)
+        external
+        checkSelfAfter(payable(address(this)), address(this).balance, amount)
+    {
+        callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+    }
+
+    function tupleDeclaration(IReentrancyBalanceCallback callback, uint256 amount) external {
+        (uint256 balanceBefore, uint256 expected) = (address(this).balance, amount);
+        callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+        require(address(this).balance >= balanceBefore + expected, "insufficient payment");
+    }
+
+    function tupleAssignment(IReentrancyBalanceCallback callback, uint256 amount) external {
+        uint256 balanceBefore;
+        uint256 expected;
+        (balanceBefore, expected) = (address(this).balance, amount);
+        callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+        require(address(this).balance >= balanceBefore + expected, "insufficient payment");
+    }
+
+    function returnedBaseline(IReentrancyBalanceCallback callback, uint256 amount) external {
+        uint256 balanceBefore = currentBalance();
+        callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+        checkBalance(balanceBefore, amount);
+    }
+
+    function modifierParameter(IReentrancyBalanceCallback callback, uint256 amount)
+        external
+        checkAfter(address(this).balance, amount)
+    {
+        callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+    }
+
+    function argumentEvaluationOrder(
+        IReentrancyBalanceCallback callback,
+        uint256 amount
+    ) external {
+        uint256 balanceBefore = address(this).balance;
+        consume(
+            callback.amount(), //~WARN: external call can be reentered before a stale contract balance is checked
+            balanceBefore = address(this).balance
+        );
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function gasCaps(
+        IReentrancyBalanceCallback callback,
+        uint256 amount,
+        uint256 gasAmount
+    ) external {
+        uint256 balanceBefore = address(this).balance;
+        callback.pay{gas: 2_300}();
+        callback.pay{gas: 100_000}(); //~WARN: external call can be reentered before a stale contract balance is checked
+        callback.pay{gas: gasAmount}(); //~WARN: external call can be reentered before a stale contract balance is checked
+        callback.pay{gas: gasleft() - 1}(); //~WARN: external call can be reentered before a stale contract balance is checked
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function valueStipendGasCap(
+        IReentrancyBalanceCallback callback,
+        uint256 amount
+    ) external payable {
+        uint256 balanceBefore = address(this).balance;
+        callback.pay{value: 1, gas: 2_300}(); //~WARN: external call can be reentered before a stale contract balance is checked
+        callback.pay{value: amount, gas: 2_300}(); //~WARN: external call can be reentered before a stale contract balance is checked
+        callback.pay{value: 0, gas: 2_300}();
+        callback.pay{value: 1, gas: 0}();
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function sameOperandBalanceDependencies(
+        IReentrancyBalanceCallback callback
+    ) external {
+        uint256 balanceBefore = address(this).balance;
+        callback.pay();
+        require(address(this).balance + balanceBefore > 0, "empty balance");
+    }
+
+    function sameNamedMethods(IReentrancyBalanceSameNames callback, uint256 amount) external {
+        uint256 balanceBefore = address(this).balance;
+        callback.call(""); //~WARN: external call can be reentered before a stale contract balance is checked
+        callback.delegatecall(""); //~WARN: external call can be reentered before a stale contract balance is checked
+        callback.staticcall(""); //~WARN: external call can be reentered before a stale contract balance is checked
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function viewSameNamedMethod(
+        IReentrancyBalanceViewSameName callback,
+        uint256 amount
+    ) external {
+        uint256 balanceBefore = address(this).balance;
+        callback.staticcall("");
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function exitingBranches(
+        IReentrancyBalanceCallback callback,
+        uint256 amount,
+        bool useReturn
+    ) external {
+        uint256 balanceBefore = address(this).balance;
+        callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+        if (useReturn ? address(this).balance < balanceBefore + amount : false) return;
+    }
+
+    function plainRevert(IReentrancyBalanceCallback callback, uint256 amount) external {
+        uint256 balanceBefore = address(this).balance;
+        callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+        if (address(this).balance < balanceBefore + amount) revert();
+    }
+
+    function loopCarriedCheck(IReentrancyBalanceCallback callback, uint256 amount) external {
+        uint256 balanceBefore = address(this).balance;
+        for (uint256 i; i < 2; ++i) {
+            require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+            callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+        }
+    }
+
+    function continueGuard(IReentrancyBalanceCallback callback, uint256 amount) external {
+        uint256 balanceBefore = address(this).balance;
+        for (uint256 i; i < 2; ++i) {
+            callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+            if (address(this).balance < balanceBefore + amount) continue;
+        }
+    }
+
+    function breakGuard(IReentrancyBalanceCallback callback, uint256 amount) external {
+        uint256 balanceBefore = address(this).balance;
+        for (uint256 i; i < 2; ++i) {
+            callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+            if (address(this).balance < balanceBefore + amount) break;
+        }
+    }
+
+    function callThroughHelper(IReentrancyBalanceCallback callback, uint256 amount) external {
+        uint256 balanceBefore = address(this).balance;
+        invoke(callback);
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function callThroughFunctionPointer(
+        IReentrancyBalanceCallback callback,
+        uint256 amount
+    ) external {
+        uint256 balanceBefore = address(this).balance;
+        function(IReentrancyBalanceCallback) internal fn = invoke;
+        fn(callback);
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function callThroughAssignedFunctionPointer(
+        IReentrancyBalanceCallback callback,
+        uint256 amount
+    ) external {
+        uint256 balanceBefore = address(this).balance;
+        function(IReentrancyBalanceCallback) internal fn;
+        fn = invoke;
+        fn(callback);
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function callThroughJoinedFunctionPointer(
+        IReentrancyBalanceCallback callback,
+        uint256 amount,
+        bool callOut
+    ) external {
+        uint256 balanceBefore = address(this).balance;
+        function(IReentrancyBalanceCallback) internal fn;
+        if (callOut) fn = invoke;
+        else fn = doNothing;
+        fn(callback);
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function recursiveHelper(IReentrancyBalanceCallback callback, uint256 amount) external {
+        uint256 balanceBefore = address(this).balance;
+        recurse(callback, 1);
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function checkBeforeInteraction(IReentrancyBalanceCallback callback, uint256 amount) external {
+        uint256 balanceBefore = address(this).balance;
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+        callback.pay();
+    }
+
+    function overwrittenBaseline(IReentrancyBalanceCallback callback, uint256 amount) external {
+        uint256 balanceBefore = address(this).balance;
+        callback.pay();
+        balanceBefore = address(this).balance;
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function stateBaseline(IReentrancyBalanceCallback callback, uint256 amount) external {
+        storedBalance = address(this).balance;
+        callback.pay();
+        require(address(this).balance >= storedBalance + amount, "insufficient payment");
+    }
+
+    function mutuallyExclusivePaths(
+        IReentrancyBalanceCallback callback,
+        uint256 amount,
+        bool saveBalance
+    ) external {
+        uint256 balanceBefore;
+        if (saveBalance) {
+            balanceBefore = address(this).balance;
+        } else {
+            callback.pay();
+            require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+        }
+    }
+
+    function sequentialComplementaryPaths(
+        IReentrancyBalanceCallback callback,
+        uint256 amount,
+        bool takeBaseline
+    ) external {
+        uint256 balanceBefore;
+        if (takeBaseline) balanceBefore = address(this).balance;
+        if (!takeBaseline) callback.pay();
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function sequentialComplementaryComparisonPaths(
+        IReentrancyBalanceCallback callback,
+        uint256 amount,
+        uint256 mode
+    ) external {
+        uint256 balanceBefore = address(this).balance;
+        if (mode == 0) callback.pay();
+        if (mode != 0) {
+            require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+        }
+    }
+
+    function sequentialSameComparisonPath(
+        IReentrancyBalanceCallback callback,
+        uint256 amount,
+        uint256 mode
+    ) external {
+        uint256 balanceBefore = address(this).balance;
+        if (mode == 0) callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+        if (0 == mode) {
+            require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+        }
+    }
+
+    function reassignedComparisonPredicateStillWarns(
+        IReentrancyBalanceCallback callback,
+        uint256 amount,
+        uint256 mode
+    ) external {
+        uint256 balanceBefore = address(this).balance;
+        if (mode == 0) callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+        mode = 1;
+        if (mode != 0) {
+            require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+        }
+    }
+
+    function shortCircuitAndComplementaryPaths(
+        IReentrancyBalanceCallback callback,
+        uint256 amount,
+        bool takeBaseline
+    ) external {
+        uint256 balanceBefore;
+        takeBaseline && (balanceBefore = address(this).balance) > 0;
+        if (!takeBaseline) callback.pay();
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function compoundShortCircuitAndComplementaryPaths(
+        IReentrancyBalanceCallback callback,
+        uint256 amount,
+        bool takeBaseline,
+        bool enabled
+    ) external {
+        uint256 balanceBefore;
+        takeBaseline && enabled && (balanceBefore = address(this).balance) > 0;
+        if (!takeBaseline) callback.pay();
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function shortCircuitOrComplementaryPaths(
+        IReentrancyBalanceCallback callback,
+        uint256 amount,
+        bool takeBaseline
+    ) external {
+        uint256 balanceBefore;
+        takeBaseline || (balanceBefore = address(this).balance) > 0;
+        if (takeBaseline) callback.pay();
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function shortCircuitGuardComplementaryPaths(
+        IReentrancyBalanceCallback callback,
+        uint256 amount,
+        bool takeBaseline
+    ) external {
+        uint256 balanceBefore = address(this).balance;
+        if (!takeBaseline) callback.pay();
+        require(
+            !takeBaseline || address(this).balance >= balanceBefore + amount,
+            "insufficient payment"
+        );
+    }
+
+    function compoundShortCircuitGuardComplementaryPaths(
+        IReentrancyBalanceCallback callback,
+        uint256 amount,
+        bool takeBaseline,
+        bool enabled
+    ) external {
+        uint256 balanceBefore = address(this).balance;
+        if (!takeBaseline) callback.pay();
+        require(
+            takeBaseline && enabled && address(this).balance >= balanceBefore + amount,
+            "insufficient payment"
+        );
+    }
+
+    function sequentialSamePath(
+        IReentrancyBalanceCallback callback,
+        uint256 amount,
+        bool takeBaseline
+    ) external {
+        uint256 balanceBefore;
+        if (takeBaseline) balanceBefore = address(this).balance;
+        if (takeBaseline) callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function conditionalBaselineUnconditionalCall(
+        IReentrancyBalanceCallback callback,
+        uint256 amount,
+        bool takeBaseline
+    ) external {
+        uint256 balanceBefore;
+        if (takeBaseline) balanceBefore = address(this).balance;
+        callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function reassignedPredicateStillWarns(
+        IReentrancyBalanceCallback callback,
+        uint256 amount,
+        bool takeBaseline
+    ) external {
+        uint256 balanceBefore;
+        if (takeBaseline) balanceBefore = address(this).balance;
+        takeBaseline = false;
+        if (!takeBaseline) callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function compoundBaselineStillWarns(
+        IReentrancyBalanceCallback callback,
+        uint256 amount
+    ) external {
+        uint256 balanceBefore = address(this).balance;
+        balanceBefore += amount;
+        callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+        require(address(this).balance >= balanceBefore, "insufficient payment");
+    }
+
+    function loopMutationStillWarns(
+        IReentrancyBalanceCallback callback,
+        uint256 amount,
+        bool takeBaseline
+    ) external {
+        if (!takeBaseline) return;
+        uint256 balanceBefore = address(this).balance;
+        while (takeBaseline) {
+            takeBaseline = false;
+        }
+        if (!takeBaseline) callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function secondLoopIterationMutationStillWarns(
+        IReentrancyBalanceCallback callback,
+        uint256 amount,
+        bool takeBaseline,
+        bool firstIteration
+    ) external {
+        if (!takeBaseline) return;
+        if (!firstIteration) return;
+        uint256 balanceBefore = address(this).balance;
+        for (uint256 i; i < 2; ++i) {
+            if (firstIteration) firstIteration = false;
+            else takeBaseline = false;
+        }
+        if (!takeBaseline) callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function ternaryMutationStillWarns(
+        IReentrancyBalanceCallback callback,
+        uint256 amount,
+        bool takeBaseline
+    ) external {
+        if (!takeBaseline) return;
+        uint256 balanceBefore = address(this).balance;
+        takeBaseline ? (takeBaseline = false) : false;
+        if (!takeBaseline) callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function tryClauseMutationStillWarns(
+        IReentrancyBalanceCallback callback,
+        uint256 amount,
+        bool takeBaseline
+    ) external {
+        if (!takeBaseline) return;
+        uint256 balanceBefore = address(this).balance;
+        try callback.observe() returns (uint256) {
+            takeBaseline = false;
+        } catch {
+            takeBaseline = false;
+        }
+        if (!takeBaseline) callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function helperBaselineComplementaryPath(
+        IReentrancyBalanceCallback callback,
+        uint256 amount,
+        bool takeBaseline
+    ) external {
+        uint256 balanceBefore = conditionalBaseline(takeBaseline);
+        if (!takeBaseline) callback.pay();
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function helperBaselineSamePath(
+        IReentrancyBalanceCallback callback,
+        uint256 amount,
+        bool takeBaseline
+    ) external {
+        uint256 balanceBefore = conditionalBaseline(takeBaseline);
+        if (takeBaseline) callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function comparisonReturnedByHelper(
+        IReentrancyBalanceCallback callback,
+        uint256 amount
+    ) external {
+        uint256 balanceBefore = address(this).balance;
+        callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+        require(
+            isPaid(address(this).balance, balanceBefore, amount), "insufficient payment"
+        );
+    }
+
+    function helperComparisonCompoundAssignment(
+        IReentrancyBalanceCallback callback,
+        uint256 amount
+    ) external {
+        uint256 balanceBefore = address(this).balance;
+        callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+        bool paid = isPaid(address(this).balance, balanceBefore, amount);
+        paid &= amount > 0;
+        require(paid, "insufficient payment");
+    }
+
+    function nonComparisonReturnedByHelper(
+        IReentrancyBalanceCallback callback,
+        uint256 amount
+    ) external {
+        uint256 balanceBefore = address(this).balance;
+        callback.pay();
+        require(hasEnoughCombinedBalance(address(this).balance, balanceBefore, amount));
+    }
+
+    function otherAddressBalance(
+        IReentrancyBalanceCallback callback,
+        address account,
+        uint256 amount
+    ) external {
+        uint256 balanceBefore = account.balance;
+        callback.pay();
+        require(account.balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function tokenBalance(
+        IReentrancyBalanceCallback callback,
+        IReentrancyBalanceToken token,
+        uint256 amount
+    ) external {
+        uint256 balanceBefore = token.balanceOf(address(this));
+        callback.pay();
+        require(
+            token.balanceOf(address(this)) >= balanceBefore + amount, "insufficient payment"
+        );
+    }
+
+    function userDefinedBalanceName(
+        IReentrancyBalanceCallback callback,
+        uint256 amount
+    ) external {
+        uint256 balance = amount;
+        callback.pay();
+        require(address(this).balance >= balance, "insufficient payment");
+    }
+
+    function viewCallCannotReenter(IReentrancyBalanceCallback callback, uint256 amount) external {
+        uint256 balanceBefore = address(this).balance;
+        callback.observe();
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function concreteGasCap(address target, uint256 amount) external {
+        uint256 balanceBefore = address(this).balance;
+        (bool ok,) = target.call{gas: 2_300}("");
+        require(ok, "call failed");
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function noPostCallBalanceCheck(IReentrancyBalanceCallback callback) external {
+        uint256 balanceBefore = address(this).balance;
+        callback.pay();
+        require(balanceBefore > 0, "empty balance");
+    }
+
+    function invoke(IReentrancyBalanceCallback callback) internal {
+        callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+    }
+
+    function currentBalance() internal view returns (uint256) {
+        return address(this).balance;
+    }
+
+    function currentSelf() internal view returns (address payable) {
+        return payable(address(this));
+    }
+
+    function identity(address payable value) internal pure returns (address payable) {
+        return value;
+    }
+
+    function checkBalance(uint256 balanceBefore, uint256 amount) internal view {
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function checkSelfBalance(address payable self, uint256 balanceBefore, uint256 amount)
+        internal
+        view
+    {
+        require(self.balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function isPaid(
+        uint256 current,
+        uint256 balanceBefore,
+        uint256 amount
+    ) internal pure returns (bool) {
+        return current >= balanceBefore + amount;
+    }
+
+    function hasEnoughCombinedBalance(
+        uint256 current,
+        uint256 balanceBefore,
+        uint256 amount
+    ) internal pure returns (bool) {
+        return current + balanceBefore >= amount;
+    }
+
+    function conditionalBaseline(bool takeBaseline) internal view returns (uint256 balanceBefore) {
+        if (takeBaseline) balanceBefore = address(this).balance;
+    }
+
+    function consume(uint256, uint256) internal pure {}
+
+    function doNothing(IReentrancyBalanceCallback) internal pure {}
+
+    function recurse(IReentrancyBalanceCallback callback, uint256 depth) internal {
+        if (depth > 0) recurse(callback, depth - 1);
+        callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+    }
+}
+
+contract ReentrancyBalanceBase {
+    function invokeBase(IReentrancyBalanceCallback callback) internal virtual {
+        callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+    }
+}
+
+contract ReentrancyBalanceDerived is ReentrancyBalanceBase {
+    function selectedOverride(IReentrancyBalanceCallback callback, uint256 amount) external {
+        uint256 balanceBefore = address(this).balance;
+        invokeBase(callback);
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function selectedSuper(IReentrancyBalanceCallback callback, uint256 amount) external {
+        uint256 balanceBefore = address(this).balance;
+        super.invokeBase(callback);
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function invokeBase(IReentrancyBalanceCallback) internal override {}
+}
+
+contract ReentrancyBalanceConstructor {
+    constructor(IReentrancyBalanceCallback callback, uint256 amount) {
+        uint256 balanceBefore = address(this).balance;
+        callback.pay();
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+    }
+}
+
+contract ReentrancyBalanceGuarded {
+    uint256 private locked;
+
+    modifier nonReentrant() {
+        require(locked == 0, "reentrant");
+        locked = 1;
+        _;
+        locked = 0;
+    }
+
+    function guarded(
+        IReentrancyBalanceCallback callback,
+        uint256 amount
+    ) external nonReentrant {
+        uint256 balanceBefore = address(this).balance;
+        callback.pay();
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+    }
+}
+
+contract ReentrancyBalanceHelperGuarded {
+    error ReentrantCall();
+
+    uint256 private constant NOT_ENTERED = 1;
+    uint256 private constant ENTERED = 2;
+    uint256 private status = NOT_ENTERED;
+
+    modifier nonReentrant() {
+        nonReentrantBefore();
+        _;
+        nonReentrantAfter();
+    }
+
+    function guarded(
+        IReentrancyBalanceCallback callback,
+        uint256 amount
+    ) external nonReentrant {
+        uint256 balanceBefore = address(this).balance;
+        callback.pay();
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function nonReentrantBefore() private {
+        if (status == ENTERED) revert ReentrantCall();
+        status = ENTERED;
+    }
+
+    function nonReentrantAfter() private {
+        status = NOT_ENTERED;
+    }
+}
+
+contract ReentrancyBalanceLaterGuard {
+    uint256 private locked;
+
+    modifier onlyOwner() {
+        _;
+    }
+
+    modifier nonReentrant() {
+        require(locked == 0, "reentrant");
+        locked = 1;
+        _;
+        locked = 0;
+    }
+
+    function guarded(
+        IReentrancyBalanceCallback callback,
+        uint256 amount
+    ) external onlyOwner nonReentrant {
+        uint256 balanceBefore = address(this).balance;
+        callback.pay();
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+    }
+}
+
+contract ReentrancyBalanceInvalidatedGuard {
+    uint256 private locked;
+
+    modifier nonReentrant() {
+        require(locked == 0, "reentrant");
+        locked = 1;
+        _;
+        locked = 0;
+    }
+
+    function unlocksBeforeCall(
+        IReentrancyBalanceCallback callback,
+        uint256 amount
+    ) external nonReentrant {
+        uint256 balanceBefore = address(this).balance;
+        locked = 0;
+        callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+    }
+}
+
+contract ReentrancyBalanceNameOnlyGuard {
+    modifier nonReentrant() {
+        _;
+    }
+
+    function guarded(
+        IReentrancyBalanceCallback callback,
+        uint256 amount
+    ) external nonReentrant {
+        uint256 balanceBefore = address(this).balance;
+        callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+    }
+}
+
+contract ReentrancyBalanceCrossFunctionGuard {
+    uint256 private locked;
+
+    modifier nonReentrant() {
+        require(locked == 0, "reentrant");
+        locked = 1;
+        _;
+        locked = 0;
+    }
+
+    function guarded(
+        IReentrancyBalanceCallback callback,
+        uint256 amount
+    ) external nonReentrant {
+        uint256 balanceBefore = address(this).balance;
+        callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+    }
+
+    function unguarded(
+        IReentrancyBalanceCallback callback,
+        uint256 amount
+    ) external {
+        uint256 balanceBefore = address(this).balance;
+        callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+    }
+}
+
+contract ReentrancyBalanceDelegateInvalidatesGuard {
+    uint256 private locked;
+
+    modifier nonReentrant() {
+        require(locked == 0, "reentrant");
+        locked = 1;
+        _;
+        locked = 0;
+    }
+
+    function guarded(
+        address target,
+        IReentrancyBalanceCallback callback,
+        uint256 amount
+    ) external nonReentrant {
+        uint256 balanceBefore = address(this).balance;
+        (bool ok,) = target.delegatecall(""); //~WARN: external call can be reentered before a stale contract balance is checked
+        require(ok, "delegatecall failed");
+        callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+    }
+}
+
+contract ReentrancyBalanceMultiplePlaceholderGuard {
+    uint256 private locked;
+
+    modifier nonReentrantTwice() {
+        require(locked == 0, "reentrant");
+        locked = 1;
+        _;
+        locked = 0;
+        _;
+    }
+
+    function guarded(
+        IReentrancyBalanceCallback callback,
+        uint256 amount
+    ) external nonReentrantTwice {
+        uint256 balanceBefore = address(this).balance;
+        callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+    }
+}
+
+contract ReentrancyBalanceNestedPlaceholderGuard {
+    uint256 private locked;
+
+    modifier nonReentrant() {
+        require(locked == 0, "reentrant");
+        locked = 1;
+        {
+            _;
+        }
+        locked = 0;
+    }
+
+    function guarded(
+        IReentrancyBalanceCallback callback,
+        uint256 amount
+    ) external nonReentrant {
+        uint256 balanceBefore = address(this).balance;
+        callback.pay();
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+    }
+}
+
+contract ReentrancyBalanceNestedPlaceholderUnlock {
+    uint256 private locked;
+
+    modifier unlockedBeforeBody() {
+        require(locked == 0, "reentrant");
+        locked = 1;
+        {
+            locked = 0;
+            _;
+        }
+        locked = 0;
+    }
+
+    function vulnerable(
+        IReentrancyBalanceCallback callback,
+        uint256 amount
+    ) external unlockedBeforeBody {
+        uint256 balanceBefore = address(this).balance;
+        callback.pay(); //~WARN: external call can be reentered before a stale contract balance is checked
+        require(address(this).balance >= balanceBefore + amount, "insufficient payment");
+    }
+}
diff --git a/crates/lint/testdata/ReentrancyBalance.stderr b/crates/lint/testdata/ReentrancyBalance.stderr
new file mode 100644
index 0000000000000..700ff72f9f751
--- /dev/null
+++ b/crates/lint/testdata/ReentrancyBalance.stderr
@@ -0,0 +1,440 @@
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         callback.pay();
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         callback.pay();
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         callback.pay();
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         callback.pay();
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         callback.pay();
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         callback.pay();
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         callback.pay();
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         callback.pay();
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         callback.pay();
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         callback.pay();
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         callback.pay();
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         callback.pay();
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         callback.pay();
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         callback.pay();
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         callback.pay();
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         callback.pay();
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         callback.pay();
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │             callback.amount(),
+   │             ━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         callback.pay{gas: 100_000}();
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         callback.pay{gas: gasAmount}();
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         callback.pay{gas: gasleft() - 1}();
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         callback.pay{value: 1, gas: 2_300}();
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         callback.pay{value: amount, gas: 2_300}();
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         callback.call("");
+   │         ━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         callback.delegatecall("");
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         callback.staticcall("");
+   │         ━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         callback.pay();
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         callback.pay();
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │             callback.pay();
+   │             ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │             callback.pay();
+   │             ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │             callback.pay();
+   │             ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         callback.pay();
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         callback.pay();
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         if (mode == 0) callback.pay();
+   │                        ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         if (mode == 0) callback.pay();
+   │                        ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         if (takeBaseline) callback.pay();
+   │                           ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         callback.pay();
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         if (!takeBaseline) callback.pay();
+   │                            ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         callback.pay();
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         if (!takeBaseline) callback.pay();
+   │                            ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         if (!takeBaseline) callback.pay();
+   │                            ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         if (!takeBaseline) callback.pay();
+   │                            ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         if (!takeBaseline) callback.pay();
+   │                            ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         if (takeBaseline) callback.pay();
+   │                           ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         callback.pay();
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         callback.pay();
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         callback.pay();
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         callback.pay();
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         callback.pay();
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         callback.pay();
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         callback.pay();
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         (bool ok,) = target.delegatecall("");
+   │                      ━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         callback.pay();
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         callback.pay();
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalance.sol:LL:CC
+   │
+LL │         callback.pay();
+   │         ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
diff --git a/crates/lint/testdata/ReentrancyBalanceRelations.sol b/crates/lint/testdata/ReentrancyBalanceRelations.sol
new file mode 100644
index 0000000000000..e6dc4d0384c84
--- /dev/null
+++ b/crates/lint/testdata/ReentrancyBalanceRelations.sol
@@ -0,0 +1,239 @@
+//@compile-flags: --only-lint reentrancy-balance
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+interface IBalanceObservation {
+    function observe() external;
+}
+
+contract ReentrancyBalanceRelations {
+    function assertAfterLowLevelCall(address target, uint256 amount) external {
+        uint256 balanceBefore = address(this).balance;
+        (bool ok,) = target.call(""); //~WARN: external call can be reentered before a stale contract balance is checked
+        require(ok, "call failed");
+        assert(address(this).balance - balanceBefore >= amount);
+    }
+
+    function reversedDelta(IBalanceObservation target, uint256 amount) external {
+        uint256 beforeBalance = address(this).balance;
+        target.observe(); //~WARN: external call can be reentered before a stale contract balance is checked
+        require(amount >= beforeBalance - address(this).balance);
+    }
+
+    function nestedOffsets(IBalanceObservation target, uint256 amount, uint256 fee) external {
+        uint256 beforeBalance = address(this).balance;
+        target.observe(); //~WARN: external call can be reentered before a stale contract balance is checked
+        require((address(this).balance - beforeBalance) - fee >= amount * uint256(2));
+    }
+
+    function tupleDelta(IBalanceObservation target, uint256 amount) external {
+        uint256 beforeBalance = address(this).balance;
+        target.observe(); //~WARN: external call can be reentered before a stale contract balance is checked
+        (uint256 delta, uint256 required) = (uint256(address(this).balance - beforeBalance), amount);
+        require(delta >= required);
+    }
+
+    function helperDelta(IBalanceObservation target, uint256 amount) external {
+        uint256 beforeBalance = address(this).balance;
+        target.observe(); //~WARN: external call can be reentered before a stale contract balance is checked
+        require(difference(address(this).balance, beforeBalance) >= amount);
+    }
+
+    function difference(uint256 current, uint256 previous) internal pure returns (uint256 delta) {
+        delta = current - previous;
+    }
+
+    function compoundDelta(IBalanceObservation target, uint256 amount, uint256 fee) external {
+        uint256 beforeBalance = address(this).balance;
+        target.observe(); //~WARN: external call can be reentered before a stale contract balance is checked
+        uint256 delta = address(this).balance;
+        delta -= beforeBalance;
+        delta -= fee;
+        require(delta >= amount);
+    }
+
+    function unrelatedSubtraction(IBalanceObservation target, uint256 a, uint256 b) external {
+        uint256 beforeBalance = address(this).balance;
+        target.observe();
+        require(a - b > 0);
+    }
+
+    function additiveComparison(IBalanceObservation target, uint256 amount) external {
+        uint256 beforeBalance = address(this).balance;
+        target.observe(); //~WARN: external call can be reentered before a stale contract balance is checked
+        require(address(this).balance >= amount - beforeBalance);
+    }
+
+    function repeatedTerms(IBalanceObservation target, uint256 amount) external {
+        uint256 beforeBalance = address(this).balance;
+        target.observe();
+        require((address(this).balance + beforeBalance) - beforeBalance >= amount);
+    }
+
+    function multipliedBalance(IBalanceObservation target, uint256 amount) external {
+        uint256 beforeBalance = address(this).balance;
+        target.observe();
+        require(address(this).balance * 2 - beforeBalance >= amount);
+    }
+
+    function narrowingCast(IBalanceObservation target, uint128 amount) external {
+        uint256 beforeBalance = address(this).balance;
+        target.observe();
+        require(uint128(address(this).balance - beforeBalance) >= amount);
+    }
+
+    function signChangingCast(IBalanceObservation target, int256 amount) external {
+        uint256 beforeBalance = address(this).balance;
+        target.observe();
+        require(int256(address(this).balance - beforeBalance) >= amount);
+    }
+
+    function unsupportedCompound(IBalanceObservation target, uint256 amount) external {
+        uint256 beforeBalance = address(this).balance;
+        target.observe();
+        uint256 delta = address(this).balance - beforeBalance;
+        delta *= 2;
+        require(delta >= amount);
+    }
+
+    function exclusivePaths(IBalanceObservation target, uint256 amount, bool check) external {
+        uint256 beforeBalance = address(this).balance;
+        if (check) target.observe();
+        if (!check) {
+            uint256 delta = address(this).balance - beforeBalance;
+            require(delta >= amount);
+        }
+    }
+
+    function bothStale(IBalanceObservation target, uint256 amount) external {
+        uint256 beforeBalance = address(this).balance;
+        uint256 delta = address(this).balance - beforeBalance;
+        target.observe();
+        require(delta >= amount);
+    }
+
+    function ternaryFresh(IBalanceObservation target, uint256 amount, bool enabled) external {
+        uint256 beforeBalance = address(this).balance;
+        target.observe(); //~WARN: external call can be reentered before a stale contract balance is checked
+        require((enabled ? address(this).balance : 0) >= beforeBalance + amount);
+    }
+
+    function ternaryDelta(IBalanceObservation target, uint256 amount, bool enabled) external {
+        uint256 beforeBalance = address(this).balance;
+        target.observe(); //~WARN: external call can be reentered before a stale contract balance is checked
+        require((enabled ? 0 : address(this).balance) - beforeBalance >= amount);
+    }
+
+    function ternaryExclusive(IBalanceObservation target, uint256 amount, bool enabled) external {
+        uint256 beforeBalance = address(this).balance;
+        if (enabled) target.observe();
+        require((enabled ? 0 : address(this).balance) >= beforeBalance + amount);
+    }
+
+    function multipliedSaved(IBalanceObservation target) external {
+        uint256 beforeBalance = address(this).balance;
+        target.observe(); //~WARN: external call can be reentered before a stale contract balance is checked
+        require(address(this).balance >= beforeBalance * 2);
+    }
+
+    function multipliedFreshReversed(IBalanceObservation target) external {
+        uint256 beforeBalance = address(this).balance;
+        target.observe(); //~WARN: external call can be reentered before a stale contract balance is checked
+        require(beforeBalance <= address(this).balance * 2);
+    }
+
+    function exclusiveOperands(IBalanceObservation target, bool enabled) external {
+        uint256 beforeBalance = address(this).balance;
+        target.observe();
+        require((enabled ? address(this).balance : 0) >= (enabled ? 0 : beforeBalance * 2));
+    }
+
+    function multipliedLocal(IBalanceObservation target) external {
+        uint256 beforeBalance = address(this).balance;
+        target.observe(); //~WARN: external call can be reentered before a stale contract balance is checked
+        beforeBalance *= 2;
+        require(address(this).balance >= beforeBalance);
+    }
+
+    function transformedBeforeCall(IBalanceObservation target) external {
+        uint256 beforeBalance = address(this).balance * 2;
+        target.observe(); //~WARN: external call can be reentered before a stale contract balance is checked
+        require(address(this).balance >= beforeBalance);
+    }
+
+    function transformedTuple(IBalanceObservation target) external {
+        uint256 beforeBalance = address(this).balance;
+        target.observe(); //~WARN: external call can be reentered before a stale contract balance is checked
+        (uint256 current, uint256 previous) = (address(this).balance * 2, beforeBalance / 2);
+        require(previous <= current);
+    }
+
+    function transformedHelper(IBalanceObservation target) external {
+        uint256 beforeBalance = address(this).balance;
+        target.observe(); //~WARN: external call can be reentered before a stale contract balance is checked
+        require(scale(address(this).balance) >= scaleNamed(beforeBalance));
+    }
+
+    function scale(uint256 value) internal pure returns (uint256) {
+        return value * 2;
+    }
+
+    function scaleNamed(uint256 value) internal pure returns (uint256 result) {
+        result = value;
+        result *= 2;
+    }
+
+    function transformedOverwrite(IBalanceObservation target, uint256 amount) external {
+        uint256 beforeBalance = address(this).balance;
+        target.observe();
+        beforeBalance *= 2;
+        beforeBalance = amount;
+        require(address(this).balance >= beforeBalance);
+    }
+
+    function transformedBothStale(IBalanceObservation target) external {
+        uint256 beforeBalance = address(this).balance * 2;
+        uint256 otherBalance = scale(address(this).balance);
+        target.observe();
+        require(otherBalance >= beforeBalance);
+    }
+
+    function transformedHelperExclusive(IBalanceObservation target, bool enabled) external {
+        uint256 beforeBalance = address(this).balance;
+        target.observe();
+        uint256 current = conditionalScale(address(this).balance, enabled);
+        uint256 previous = conditionalScale(beforeBalance, !enabled);
+        require(current >= previous);
+    }
+
+    function conditionalScale(uint256 value, bool enabled) internal pure returns (uint256) {
+        return enabled ? value * 2 : 0;
+    }
+
+    function transformedSameSide(IBalanceObservation target, uint256 amount) external {
+        uint256 beforeBalance = address(this).balance;
+        target.observe();
+        uint256 current = scale(address(this).balance);
+        uint256 previous = scaleNamed(beforeBalance);
+        require(current - previous >= amount);
+    }
+
+    function nestedHelperBothStale(IBalanceObservation target) external {
+        uint256 saved = wrap(address(this).balance);
+        target.observe();
+        uint256 transformed = wrap(saved);
+        require(transformed >= saved);
+    }
+
+    function nestedHelperFresh(IBalanceObservation target) external {
+        uint256 saved = wrap(address(this).balance);
+        target.observe(); //~WARN: external call can be reentered before a stale contract balance is checked
+        uint256 transformed = wrap(address(this).balance);
+        require(transformed >= saved);
+    }
+
+    function wrap(uint256 value) internal pure returns (uint256) {
+        return scale(value);
+    }
+}
diff --git a/crates/lint/testdata/ReentrancyBalanceRelations.stderr b/crates/lint/testdata/ReentrancyBalanceRelations.stderr
new file mode 100644
index 0000000000000..dfb85f2e02398
--- /dev/null
+++ b/crates/lint/testdata/ReentrancyBalanceRelations.stderr
@@ -0,0 +1,128 @@
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalanceRelations.sol:LL:CC
+   │
+LL │         (bool ok,) = target.call("");
+   │                      ━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalanceRelations.sol:LL:CC
+   │
+LL │         target.observe();
+   │         ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalanceRelations.sol:LL:CC
+   │
+LL │         target.observe();
+   │         ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalanceRelations.sol:LL:CC
+   │
+LL │         target.observe();
+   │         ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalanceRelations.sol:LL:CC
+   │
+LL │         target.observe();
+   │         ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalanceRelations.sol:LL:CC
+   │
+LL │         target.observe();
+   │         ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalanceRelations.sol:LL:CC
+   │
+LL │         target.observe();
+   │         ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalanceRelations.sol:LL:CC
+   │
+LL │         target.observe();
+   │         ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalanceRelations.sol:LL:CC
+   │
+LL │         target.observe();
+   │         ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalanceRelations.sol:LL:CC
+   │
+LL │         target.observe();
+   │         ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalanceRelations.sol:LL:CC
+   │
+LL │         target.observe();
+   │         ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalanceRelations.sol:LL:CC
+   │
+LL │         target.observe();
+   │         ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalanceRelations.sol:LL:CC
+   │
+LL │         target.observe();
+   │         ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalanceRelations.sol:LL:CC
+   │
+LL │         target.observe();
+   │         ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalanceRelations.sol:LL:CC
+   │
+LL │         target.observe();
+   │         ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
+warning[reentrancy-balance]: external call can be reentered before a stale contract balance is checked
+   ╭▸ ROOT/testdata/ReentrancyBalanceRelations.sol:LL:CC
+   │
+LL │         target.observe();
+   │         ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-balance
+
diff --git a/crates/lint/testdata/ReentrancyEth.sol b/crates/lint/testdata/ReentrancyEth.sol
new file mode 100644
index 0000000000000..f905b3926835a
--- /dev/null
+++ b/crates/lint/testdata/ReentrancyEth.sol
@@ -0,0 +1,270 @@
+//@compile-flags: --only-lint reentrancy-eth
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+contract ReentrancyEth {
+    event Withdrawn(address indexed account, uint256 amount);
+
+    mapping(address => uint256) public balances;
+    mapping(address => uint256) public totalPaid;
+    uint256 private constant ZERO = 0;
+    uint256 public totalWithdrawn;
+    uint256 private locked;
+
+    function withdraw() external {
+        uint256 amount = balances[msg.sender];
+        (bool ok,) = payable(msg.sender).call{value: amount}(""); //~WARN: uncapped ETH transfer can be reentered before `balances` is updated
+        require(ok, "transfer failed");
+        balances[msg.sender] = 0;
+    }
+
+    function withdrawWithEvent() external {
+        uint256 amount = balances[msg.sender];
+        (bool ok,) = payable(msg.sender).call{value: amount}("");
+        require(ok, "transfer failed");
+        emit Withdrawn(msg.sender, amount);
+    }
+
+    function branchAfterCall(address payable receiver, bool record) external {
+        (bool ok,) = receiver.call{value: 1 ether}("");
+        require(ok, "transfer failed");
+        if (record) {
+            totalPaid[receiver] += 1 ether;
+        }
+    }
+
+    function deleteAfterCall(address payable receiver) external {
+        (bool ok,) = receiver.call{value: 1 ether}("");
+        require(ok, "transfer failed");
+        delete totalPaid[receiver];
+    }
+
+    function compoundAssignmentReadBeforeCall(address payable receiver) external {
+        totalPaid[receiver] += 1 ether;
+        (bool ok,) = receiver.call{value: 1 ether}(""); //~WARN: uncapped ETH transfer can be reentered before `totalPaid` is updated
+        require(ok, "transfer failed");
+        totalPaid[receiver] = 0;
+    }
+
+    function internalStateChangeAfterCall(address payable receiver) external {
+        uint256 amount = totalPaid[receiver];
+        (bool ok,) = receiver.call{value: amount}(""); //~WARN: uncapped ETH transfer can be reentered before `totalPaid` is updated
+        require(ok, "transfer failed");
+        recordPayment(receiver);
+    }
+
+    function callInInternalHelper(address payable receiver) external {
+        uint256 amount = balances[receiver];
+        sendValue(receiver, amount);
+        balances[receiver] = 0;
+    }
+
+    function helperHeavyCallThenWrite(address payable receiver) external {
+        uint256 amount = balances[receiver];
+        sendValueHeavy(receiver, amount);
+        sendValueHeavy(receiver, amount);
+        sendValueHeavy(receiver, amount);
+        sendValueHeavy(receiver, amount);
+        sendValueHeavy(receiver, amount);
+        sendValueHeavy(receiver, amount);
+        sendValueHeavy(receiver, amount);
+        sendValueHeavy(receiver, amount);
+        sendValueHeavy(receiver, amount);
+        sendValueHeavy(receiver, amount);
+        balances[receiver] = 0;
+    }
+
+    function gasleftIsNotACap(address payable receiver) external {
+        uint256 amount = balances[receiver];
+        (bool ok,) = receiver.call{value: amount, gas: gasleft()}(""); //~WARN: uncapped ETH transfer can be reentered before `balances` is updated
+        require(ok, "transfer failed");
+        balances[receiver] = 0;
+    }
+
+    function parenthesizedValueCall(address payable receiver) external {
+        uint256 amount = balances[receiver];
+        (bool ok,) = (receiver.call){value: amount}(""); //~WARN: uncapped ETH transfer can be reentered before `balances` is updated
+        require(ok, "transfer failed");
+        balances[receiver] = 0;
+    }
+
+    function modifierStateChangeAfterCall() external recordAfter {
+        uint256 amount = balances[msg.sender];
+        (bool ok,) = payable(msg.sender).call{value: amount}(""); //~WARN: uncapped ETH transfer can be reentered before `balances` is updated
+        require(ok, "transfer failed");
+    }
+
+    function guardedByNonReentrant() external nonReentrant {
+        uint256 amount = balances[msg.sender];
+        (bool ok,) = payable(msg.sender).call{value: amount}(""); //~WARN: uncapped ETH transfer can be reentered before `balances` is updated
+        require(ok, "transfer failed");
+        balances[msg.sender] = 0;
+    }
+
+    function nonReentrantWrapsModifier() external nonReentrant callInsideGuardNoWarn {
+        balances[msg.sender] = 0;
+    }
+
+    function outerModifierRunsBeforeNonReentrant() external callBeforeGuard nonReentrant {
+        balances[msg.sender] = 0;
+    }
+
+    function effectsBeforeInteraction() external {
+        uint256 amount = balances[msg.sender];
+        balances[msg.sender] = 0;
+        totalWithdrawn += amount;
+        (bool ok,) = payable(msg.sender).call{value: amount}("");
+        require(ok, "transfer failed");
+    }
+
+    function explicitGasCap(address payable receiver) external {
+        (bool ok,) = receiver.call{value: 1 ether, gas: 5_000}("");
+        require(ok, "transfer failed");
+        totalPaid[receiver] += 1 ether;
+    }
+
+    function stipendTransfer(address payable receiver) external {
+        receiver.transfer(1 ether);
+        totalPaid[receiver] += 1 ether;
+    }
+
+    function noStateChangeAfterCall(address payable receiver) external returns (bool) {
+        (bool ok,) = receiver.call{value: 1 ether}("");
+        return ok;
+    }
+
+    function unrelatedStateWriteAfterCall(address payable receiver) external {
+        uint256 amount = balances[receiver];
+        (bool ok,) = receiver.call{value: amount}("");
+        require(ok, "transfer failed");
+        totalPaid[receiver] += amount;
+    }
+
+    function zeroValueCall(address payable receiver) external {
+        uint256 amount = balances[receiver];
+        (bool ok,) = receiver.call{value: 0}("");
+        require(ok, "transfer failed");
+        balances[receiver] = amount;
+    }
+
+    function zeroConstantValueCall(address payable receiver) external {
+        uint256 amount = balances[receiver];
+        (bool ok,) = receiver.call{value: ZERO}("");
+        require(ok, "transfer failed");
+        balances[receiver] = amount;
+    }
+
+    function mutuallyExclusivePaths(address payable receiver, bool send) external {
+        if (send) {
+            uint256 amount = balances[receiver];
+            (bool ok,) = receiver.call{value: amount}("");
+            require(ok, "transfer failed");
+        } else {
+            balances[receiver] = 0;
+        }
+    }
+
+    function guardedByModifier(address payable receiver) external basicModifier {
+        uint256 amount = balances[receiver];
+        (bool ok,) = receiver.call{value: amount}(""); //~WARN: uncapped ETH transfer can be reentered before `balances` is updated
+        require(ok, "transfer failed");
+        balances[receiver] = 0;
+    }
+
+    constructor(address payable receiver) payable {
+        uint256 amount = balances[receiver];
+        (bool ok,) = receiver.call{value: amount}("");
+        require(ok, "transfer failed");
+        balances[receiver] = 0;
+    }
+
+    modifier recordAfter() {
+        _;
+        balances[msg.sender] = 0;
+    }
+
+    modifier nonReentrant() {
+        require(locked == 0, "reentrant");
+        locked = 1;
+        _;
+        locked = 0;
+    }
+
+    modifier callInsideGuardNoWarn() {
+        uint256 amount = balances[msg.sender];
+        (bool ok,) = payable(msg.sender).call{value: amount}(""); //~WARN: uncapped ETH transfer can be reentered before `balances` is updated
+        require(ok, "transfer failed");
+        _;
+    }
+
+    modifier callBeforeGuard() {
+        uint256 amount = balances[msg.sender];
+        (bool ok,) = payable(msg.sender).call{value: amount}(""); //~WARN: uncapped ETH transfer can be reentered before `balances` is updated
+        require(ok, "transfer failed");
+        _;
+    }
+
+    modifier basicModifier() {
+        _;
+    }
+
+    function sendValue(address payable receiver, uint256 amount) internal {
+        (bool ok,) = receiver.call{value: amount}(""); //~WARN: uncapped ETH transfer can be reentered before `balances` is updated
+        require(ok, "transfer failed");
+    }
+
+    function sendValueHeavy(address payable receiver, uint256 amount) internal {
+        (bool ok,) = receiver.call{value: amount}(""); //~WARN: uncapped ETH transfer can be reentered before `balances` is updated
+        require(ok, "transfer failed");
+    }
+
+    function recordPayment(address receiver) internal {
+        totalPaid[receiver] += 1 ether;
+    }
+}
+
+contract ReentrancyEthNameOnlyGuard {
+    mapping(address => uint256) public balances;
+
+    function withdraw() external nonReentrant {
+        uint256 amount = balances[msg.sender];
+        (bool ok,) = payable(msg.sender).call{value: amount}(""); //~WARN: uncapped ETH transfer can be reentered before `balances` is updated
+        require(ok, "transfer failed");
+        balances[msg.sender] = 0;
+    }
+
+    modifier nonReentrant() {
+        _;
+    }
+}
+
+contract ReentrancyEthRecursiveStackRepro {
+    uint256 x;
+
+    function entry(bool flag) public {
+        if (flag) {
+            a(1);
+            return;
+        }
+        c(1);
+        x = 1;
+    }
+
+    function a(uint256 depth) internal {
+        if (depth > 0) {
+            c(depth - 1);
+        }
+        uint256 y = x;
+        (bool ok,) = msg.sender.call{value: y}(""); //~WARN: uncapped ETH transfer can be reentered before `x` is updated
+        require(ok);
+    }
+
+    function c(uint256 depth) internal {
+        h(depth);
+    }
+
+    function h(uint256 depth) internal {
+        a(depth);
+    }
+}
diff --git a/crates/lint/testdata/ReentrancyEth.stderr b/crates/lint/testdata/ReentrancyEth.stderr
new file mode 100644
index 0000000000000..3a2172896f7cb
--- /dev/null
+++ b/crates/lint/testdata/ReentrancyEth.stderr
@@ -0,0 +1,112 @@
+warning[reentrancy-eth]: uncapped ETH transfer can be reentered before `balances` is updated
+   ╭▸ ROOT/testdata/ReentrancyEth.sol:LL:CC
+   │
+LL │ …     (bool ok,) = payable(msg.sender).call{value: amount}("");
+   │                    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-eth
+
+warning[reentrancy-eth]: uncapped ETH transfer can be reentered before `totalPaid` is updated
+   ╭▸ ROOT/testdata/ReentrancyEth.sol:LL:CC
+   │
+LL │         (bool ok,) = receiver.call{value: 1 ether}("");
+   │                      ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-eth
+
+warning[reentrancy-eth]: uncapped ETH transfer can be reentered before `totalPaid` is updated
+   ╭▸ ROOT/testdata/ReentrancyEth.sol:LL:CC
+   │
+LL │         (bool ok,) = receiver.call{value: amount}("");
+   │                      ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-eth
+
+warning[reentrancy-eth]: uncapped ETH transfer can be reentered before `balances` is updated
+   ╭▸ ROOT/testdata/ReentrancyEth.sol:LL:CC
+   │
+LL │         (bool ok,) = receiver.call{value: amount}("");
+   │                      ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-eth
+
+warning[reentrancy-eth]: uncapped ETH transfer can be reentered before `balances` is updated
+   ╭▸ ROOT/testdata/ReentrancyEth.sol:LL:CC
+   │
+LL │         (bool ok,) = receiver.call{value: amount}("");
+   │                      ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-eth
+
+warning[reentrancy-eth]: uncapped ETH transfer can be reentered before `balances` is updated
+   ╭▸ ROOT/testdata/ReentrancyEth.sol:LL:CC
+   │
+LL │ …     (bool ok,) = receiver.call{value: amount, gas: gasleft()}("");
+   │                    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-eth
+
+warning[reentrancy-eth]: uncapped ETH transfer can be reentered before `balances` is updated
+   ╭▸ ROOT/testdata/ReentrancyEth.sol:LL:CC
+   │
+LL │         (bool ok,) = (receiver.call){value: amount}("");
+   │                      ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-eth
+
+warning[reentrancy-eth]: uncapped ETH transfer can be reentered before `balances` is updated
+   ╭▸ ROOT/testdata/ReentrancyEth.sol:LL:CC
+   │
+LL │ …     (bool ok,) = payable(msg.sender).call{value: amount}("");
+   │                    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-eth
+
+warning[reentrancy-eth]: uncapped ETH transfer can be reentered before `balances` is updated
+   ╭▸ ROOT/testdata/ReentrancyEth.sol:LL:CC
+   │
+LL │ …     (bool ok,) = payable(msg.sender).call{value: amount}("");
+   │                    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-eth
+
+warning[reentrancy-eth]: uncapped ETH transfer can be reentered before `balances` is updated
+   ╭▸ ROOT/testdata/ReentrancyEth.sol:LL:CC
+   │
+LL │ …     (bool ok,) = payable(msg.sender).call{value: amount}("");
+   │                    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-eth
+
+warning[reentrancy-eth]: uncapped ETH transfer can be reentered before `balances` is updated
+   ╭▸ ROOT/testdata/ReentrancyEth.sol:LL:CC
+   │
+LL │ …     (bool ok,) = payable(msg.sender).call{value: amount}("");
+   │                    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-eth
+
+warning[reentrancy-eth]: uncapped ETH transfer can be reentered before `balances` is updated
+   ╭▸ ROOT/testdata/ReentrancyEth.sol:LL:CC
+   │
+LL │         (bool ok,) = receiver.call{value: amount}("");
+   │                      ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-eth
+
+warning[reentrancy-eth]: uncapped ETH transfer can be reentered before `balances` is updated
+   ╭▸ ROOT/testdata/ReentrancyEth.sol:LL:CC
+   │
+LL │ …     (bool ok,) = payable(msg.sender).call{value: amount}("");
+   │                    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-eth
+
+warning[reentrancy-eth]: uncapped ETH transfer can be reentered before `x` is updated
+   ╭▸ ROOT/testdata/ReentrancyEth.sol:LL:CC
+   │
+LL │         (bool ok,) = msg.sender.call{value: y}("");
+   │                      ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-eth
+
diff --git a/crates/lint/testdata/ReentrancyEvents.sol b/crates/lint/testdata/ReentrancyEvents.sol
new file mode 100644
index 0000000000000..ab1c18780b63a
--- /dev/null
+++ b/crates/lint/testdata/ReentrancyEvents.sol
@@ -0,0 +1,646 @@
+//@compile-flags: --only-lint reentrancy-events
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.15;
+
+interface IExternal {
+    function notify(uint256 value) external returns (bool);
+    function peek() external view returns (uint256);
+    function compute(uint256 x) external pure returns (uint256);
+}
+
+// Overloaded interface methods exercise the multi-match member lookup.
+interface IBus {
+    function notify(uint256) external returns (bool);
+    function notify(uint256, bytes calldata) external returns (bool);
+    function peek(uint256) external view returns (uint256);
+    function peek(uint256, uint256) external view returns (uint256);
+}
+
+interface ICall {
+    function poke() external;
+}
+
+library ReadOnlyEventLibrary {
+    function read(uint256 value) public pure returns (uint256) {
+        return value;
+    }
+}
+
+contract FunctionPointerEvents {
+    event Tick();
+
+    function mutableCallback(function() external callback) external {
+        callback();
+        emit Tick(); //~WARN: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+    }
+
+    function viewCallback(function() external view callback) external {
+        callback();
+        emit Tick();
+    }
+
+    function viewLibrary() external {
+        ReadOnlyEventLibrary.read(1);
+        emit Tick();
+    }
+
+    function internalCallback(function() internal callback) internal {
+        callback();
+        emit Tick();
+    }
+}
+
+contract Other {
+    function action(uint256) external returns (bool) {
+        return true;
+    }
+}
+
+contract SuperTransferBase {
+    function transfer(IExternal d) public virtual {
+        d.notify(0);
+    }
+}
+
+contract SuperTransferChild is SuperTransferBase {
+    event Tick();
+
+    function emitAfterSuperTransfer(IExternal d) external {
+        super.transfer(d);
+        emit Tick(); //~WARN: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+    }
+}
+
+contract RecursiveCacheKeyRepro {
+    event Tick();
+
+    ICall ext;
+    bool once;
+
+    function entry(bool flag) external {
+        once = false;
+        if (flag) {
+            helperA();
+            return;
+        }
+        noOp();
+        emit Tick(); //~WARN: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+    }
+
+    function noOp() internal {
+        if (!once) {
+            once = true;
+            helperA();
+        }
+    }
+
+    function helperA() internal {
+        noOp();
+        ext.poke();
+    }
+}
+
+contract ModifierArgReachabilityRepro {
+    event Tick();
+
+    bool flag;
+
+    function ext() external returns (bool) {
+        flag = true;
+        return true;
+    }
+
+    modifier m(bool ok) {
+        _;
+    }
+
+    function entry(uint256 depth, bool useCache) external {
+        if (useCache) {
+            f(depth);
+            return;
+        }
+        c(depth);
+        emit Tick(); //~WARN: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+    }
+
+    function f(uint256 depth) internal {
+        if (depth > 0) {
+            c(depth - 1);
+        }
+        hh();
+    }
+
+    function hh() internal m(this.ext()) {}
+
+    function c(uint256 depth) internal {
+        h(depth);
+    }
+
+    function h(uint256 depth) internal {
+        f(depth);
+    }
+}
+
+library NotifyLib {
+    function notifyVia(IExternal d, uint256 v) internal {
+        d.notify(v);
+    }
+
+    function staticHelper(IExternal d) internal {
+        d.notify(0);
+    }
+}
+
+contract ReentrancyEvents {
+    using NotifyLib for IExternal;
+
+    event Counter(uint256 value);
+    event Paid(address indexed to, uint256 amount);
+    event Tick();
+
+    uint256 public counter;
+    address payable public recipient;
+    IExternal public ext;
+    Other public other;
+
+    // --- Bad cases ----------------------------------------------------------
+
+    function emitAfterHighLevelCall(IExternal d) external {
+        counter += 1;
+        d.notify(counter);
+        emit Counter(counter); //~WARN: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+    }
+
+    function emitAfterLowLevelCall(address target) external {
+        counter += 1;
+        // forge-lint: disable-next-line(unchecked-call)
+        target.call("");
+        emit Counter(counter); //~WARN: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+    }
+
+    function emitAfterTransfer() external {
+        counter += 1;
+        recipient.transfer(1 wei);
+        emit Counter(counter); //~WARN: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+    }
+
+    function emitAfterSend() external {
+        counter += 1;
+        // forge-lint: disable-next-line(unchecked-call)
+        recipient.send(1 wei);
+        emit Counter(counter); //~WARN: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+    }
+
+    function emitAfterSelfExternalCall() external {
+        counter += 1;
+        this.publicHelper();
+        emit Counter(counter); //~WARN: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+    }
+
+    function emitInIfBranch(IExternal d, bool flag) external {
+        d.notify(0);
+        if (flag) {
+            emit Tick(); //~WARN: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+        }
+    }
+
+    function emitAfterCallInBranch(IExternal d, bool flag) external {
+        if (flag) {
+            d.notify(1);
+        }
+        // The "if" branch may have made an external call before reaching this emit.
+        emit Tick(); //~WARN: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+    }
+
+    function emitInLoopAfterCall(IExternal d, uint256 n) external {
+        d.notify(0);
+        for (uint256 i; i < n; ++i) {
+            emit Counter(i); //~WARN: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+        }
+    }
+
+    function emitAfterLoopWithCall(IExternal d, uint256 n) external {
+        for (uint256 i; i < n; ++i) {
+            d.notify(i);
+        }
+        emit Tick(); //~WARN: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+    }
+
+    // Loop back-edge: the emit is clean on iteration 1 but tainted by the call from the
+    // previous iteration on iterations 2..N. The two-pass fixpoint must catch this.
+    function emitInLoopBeforeCall(IExternal d, uint256 n) external {
+        for (uint256 i; i < n; ++i) {
+            emit Counter(i); //~WARN: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+            d.notify(i);
+        }
+    }
+
+    function emitAfterTryCall(IExternal d) external {
+        try d.notify(1) returns (bool) {} catch {}
+        emit Tick(); //~WARN: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+    }
+
+    function emitAfterHelperWithCall() external {
+        _doExternalWork();
+        emit Tick(); //~WARN: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+    }
+
+    function emitAfterHelperHeavyFanout() external {
+        _doExternalWork();
+        _doExternalWork();
+        _doExternalWork();
+        _doExternalWork();
+        _doExternalWork();
+        _doExternalWork();
+        _doExternalWork();
+        _doExternalWork();
+        _doExternalWork();
+        _doExternalWork();
+        emit Tick(); //~WARN: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+    }
+
+    function emitAfterChainedCall() external {
+        counter += 1;
+        Other(other).action(counter);
+        emit Counter(counter); //~WARN: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+    }
+
+    // External call inside the emit's arguments (Solidity evaluates args before emitting).
+    function emitWithExternalCallInArgs(IExternal d) external {
+        emit Counter(d.notify(1) ? 1 : 0); //~WARN: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+    }
+
+    // Internal helper returns early after an external call: caller should still see the taint.
+    function emitAfterHelperEarlyReturn(bool flag) external {
+        _maybeNotify(flag);
+        emit Tick(); //~WARN: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+    }
+
+    // External call in one loop iteration must taint the post-loop state via `break`.
+    function emitAfterBreakWithCall(IExternal d, bool flag) external {
+        for (uint256 i; i < 10; ++i) {
+            if (flag) {
+                d.notify(i);
+                break;
+            }
+        }
+        emit Tick(); //~WARN: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+    }
+
+    // `new Foo(...)` deploys and runs a constructor — an external interaction.
+    function emitAfterNew() external {
+        new Other();
+        emit Tick(); //~WARN: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+    }
+
+    // Same modifier listed twice — the second instance must not be silently dropped.
+    modifier maybeNotify(bool b) {
+        if (b) ext.notify(1);
+        _;
+    }
+
+    function emitAfterDuplicateModifier() external maybeNotify(false) maybeNotify(true) {
+        emit Tick(); //~WARN: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+    }
+
+    // A resolved static library call runs in this frame and can make an external call.
+    function emitAfterLibraryStaticCall() external {
+        NotifyLib.staticHelper(ext);
+        emit Tick(); //~WARN: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+    }
+
+    function emitAfterUsingForCall() external {
+        ext.notifyVia(1);
+        emit Tick();
+    }
+
+    // --- Good cases ---------------------------------------------------------
+
+    function emitBeforeExternalCall(IExternal d) external {
+        counter += 1;
+        emit Counter(counter);
+        d.notify(counter);
+    }
+
+    function emitOnlyNoCalls() external {
+        counter += 1;
+        emit Counter(counter);
+    }
+
+    function emitAfterInternalOnly() external {
+        _internalHelper();
+        emit Tick();
+    }
+
+    function emitAfterPureInternalCall() external {
+        uint256 x = _pureHelper(1);
+        emit Counter(x);
+    }
+
+    function bothBranchesEmitBeforeCall(IExternal d, bool flag) external {
+        if (flag) {
+            emit Tick();
+        } else {
+            emit Counter(0);
+        }
+        d.notify(1);
+    }
+
+    // Internal helper that always reverts: caller's post-call code is unreachable, no warning.
+    function emitAfterAlwaysAbortingHelper() external {
+        _alwaysReverts();
+        emit Tick();
+    }
+
+    // Ternary with one aborting branch: per-branch abort tracking must let the live
+    // branch's taint survive so the subsequent emit is still flagged.
+    function emitAfterTernaryAbortingElse(IExternal d, bool flag) external {
+        uint256 x = flag ? _peeker(d) : _alwaysRevertsU();
+        emit Counter(x); //~WARN: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+    }
+
+    function emitAfterTernaryAbortingThen(IExternal d, bool flag) external {
+        uint256 x = flag ? _alwaysRevertsU() : _peeker(d);
+        emit Counter(x); //~WARN: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+    }
+
+    // Both ternary branches abort: the post-ternary emit is genuinely unreachable.
+    function emitAfterTernaryBothAbort(bool flag) external {
+        uint256 x = flag ? _alwaysRevertsU() : _alwaysRevertsU2();
+        emit Counter(x);
+    }
+
+    // External call inside a loop body followed by a `revert` in the same iteration:
+    // post-loop state must not be tainted because every body path aborts.
+    function emitAfterLoopThatAlwaysReverts(IExternal d) external {
+        for (uint256 i; i < 1; ++i) {
+            d.notify(i);
+            revert("oops");
+        }
+        emit Tick();
+    }
+
+    // External call only on a branch that itself returns early: the function's caller
+    // sees the call, but within this function the post-if fallthrough state is clean,
+    // so an emit on the fallthrough path is fine.
+    function emitOnUntaintedBranch(IExternal d, bool flag) external {
+        if (flag) {
+            d.notify(0);
+            return;
+        }
+        emit Tick();
+    }
+
+    // `selfdestruct(...)` terminates execution; post-statements are unreachable.
+    function emitAfterSelfdestruct(IExternal d) external {
+        d.notify(0);
+        selfdestruct(payable(msg.sender));
+        emit Tick();
+    }
+
+    // `require(false, ...)` and `assert(false)` also abort unconditionally.
+    function emitAfterRequireFalse(IExternal d) external {
+        d.notify(0);
+        require(false, "no");
+        emit Tick();
+    }
+
+    function emitAfterAssertFalse(IExternal d) external {
+        d.notify(0);
+        assert(false);
+        emit Tick();
+    }
+
+    // `staticcall` cannot emit logs or perform state-changing reentrancy.
+    function emitAfterStaticcall(address target) external {
+        // forge-lint: disable-next-line(unchecked-call)
+        target.staticcall("");
+        emit Tick();
+    }
+
+    // High-level `view` external calls are read-only and cannot reorder events.
+    function emitAfterViewCall(IExternal d) external {
+        uint256 v = d.peek();
+        emit Counter(v);
+    }
+
+    // High-level `pure` external calls likewise cannot reorder events.
+    function emitAfterPureCall(IExternal d) external {
+        uint256 v = d.compute(1);
+        emit Counter(v);
+    }
+
+    // Overloaded mutating external method: must flag despite the name collision in
+    // the interface (member lookup used to drop overloads via a unique-name filter).
+    function emitAfterOverloadedMutatingCall(IBus b) external {
+        b.notify(0);
+        emit Tick(); //~WARN: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+    }
+
+    function emitAfterOverloadedMutatingCallTwoArgs(IBus b) external {
+        b.notify(0, "");
+        emit Tick(); //~WARN: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+    }
+
+    // Overloaded but all overloads are `view` — must NOT flag.
+    function emitAfterOverloadedViewCall(IBus b) external {
+        uint256 v = b.peek(0);
+        emit Counter(v);
+    }
+
+    function emitAfterOverloadedViewCallTwoArgs(IBus b) external {
+        uint256 v = b.peek(0, 1);
+        emit Counter(v);
+    }
+
+    // `this.()` compiles to STATICCALL — cannot reorder events.
+    function emitAfterSelfViewCall() external {
+        uint256 v = this.viewSelf();
+        emit Counter(v);
+    }
+
+    function emitAfterSelfPureCall() external {
+        uint256 v = this.pureSelf(1);
+        emit Counter(v);
+    }
+
+    // Only the selected overload matters: the zero-arg self-call is pure, even though a
+    // one-arg overload below is state-mutating.
+    function emitAfterSelfPureOverloadCall() external {
+        uint256 v = this.overloadedSelf();
+        emit Counter(v);
+    }
+
+    // Mutating self-external call still taints subsequent emits.
+    function emitAfterSelfMutatingCall() external {
+        counter += 1;
+        this.publicHelper();
+        emit Counter(counter); //~WARN: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+    }
+
+    // Caller is already tainted, then calls a helper that always reverts. The post-call
+    // emit is unreachable and must NOT be flagged.
+    function emitAfterTaintedAlwaysRevertsHelper(IExternal d) external {
+        d.notify(0);
+        _alwaysReverts();
+        emit Tick();
+    }
+
+    // The shared helper `_helperEmitAfterCall` is invoked from two callers. Its tainted
+    // emit must be reported exactly once (in the helper's self-pass), not duplicated per
+    // caller.
+    function callsSharedHelperA() external {
+        _helperEmitAfterCall();
+    }
+
+    function callsSharedHelperB() external {
+        _helperEmitAfterCall();
+    }
+
+    // --- Helpers ------------------------------------------------------------
+
+    function publicHelper() public {
+        counter += 1;
+    }
+
+    function viewSelf() public view returns (uint256) {
+        return counter;
+    }
+
+    function pureSelf(uint256 a) public pure returns (uint256) {
+        return a + 1;
+    }
+
+    function overloadedSelf() public pure returns (uint256) {
+        return 1;
+    }
+
+    function overloadedSelf(uint256 a) public returns (uint256) {
+        counter += a;
+        return counter;
+    }
+
+    function _doExternalWork() internal {
+        ext.notify(counter);
+    }
+
+    function _maybeNotify(bool flag) internal {
+        if (flag) {
+            ext.notify(1);
+            return;
+        }
+    }
+
+    function _alwaysReverts() internal {
+        ext.notify(0);
+        revert("nope");
+    }
+
+    function _alwaysRevertsU() internal pure returns (uint256) {
+        revert("nope");
+    }
+
+    function _alwaysRevertsU2() internal pure returns (uint256) {
+        revert("nope2");
+    }
+
+    function _peeker(IExternal d) internal returns (uint256) {
+        d.notify(0);
+        return 0;
+    }
+
+    function _internalHelper() internal {
+        counter += 1;
+    }
+
+    function _pureHelper(uint256 a) internal pure returns (uint256) {
+        return a + 1;
+    }
+
+    // Tainted emit inside a shared helper. The lint reports this once (in the helper's
+    // own self-pass), not once per caller.
+    function _helperEmitAfterCall() internal {
+        ext.notify(0);
+        emit Tick(); //~WARN: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+    }
+}
+
+// `super.(...)` is internal base-chain dispatch — must not panic the linter and
+// must not be treated as an external call by itself, but external calls inside the
+// resolved base function must still taint the caller.
+contract Base {
+    IExternal internal ext_;
+    event Tick();
+
+    function doExt() internal {
+        ext_.notify(0);
+    }
+
+    function viewBase() internal view returns (uint256) {
+        return 0;
+    }
+
+    function pureBase() internal pure returns (uint256) {
+        return 1;
+    }
+}
+
+contract Child is Base {
+    event Counter(uint256 value);
+
+    // Clean: super resolves to a base function with no external call.
+    function emitAfterSuperViewCall() external {
+        uint256 v = super.viewBase();
+        emit Counter(v);
+    }
+
+    function emitAfterSuperPureCall() external {
+        uint256 v = super.pureBase();
+        emit Counter(v);
+    }
+
+    // Transitive: the base function makes an external call, so the post-super emit is
+    // tainted exactly like a direct `_helper()` call would be.
+    function emitAfterSuperTaintingCall() external {
+        super.doExt();
+        emit Tick(); //~WARN: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+    }
+}
+
+contract SuperBaseWithTaint {
+    IExternal internal ext__;
+    event Tick();
+
+    function overridden() internal virtual {
+        ext__.notify(0);
+    }
+
+    function arity(uint256) internal {
+        ext__.notify(0);
+    }
+}
+
+contract SuperPureOverride is SuperBaseWithTaint {
+    function overridden() internal pure override {}
+
+    function arity() internal pure {}
+}
+
+contract SuperChild is SuperPureOverride {
+    event Counter(uint256 value);
+
+    // Clean: `super.overridden()` dispatches to the immediate pure override, not the
+    // older base implementation with the same signature that performs an external call.
+    function emitAfterSuperPureOverrideCall() external {
+        super.overridden();
+        emit Tick();
+    }
+
+    // Clean: `super.arity()` dispatches to the zero-arg overload only; the mutating
+    // one-arg overload in the older base must not taint this call.
+    function emitAfterSuperPureArityCall() external {
+        super.arity();
+        emit Counter(0);
+    }
+}
diff --git a/crates/lint/testdata/ReentrancyEvents.stderr b/crates/lint/testdata/ReentrancyEvents.stderr
new file mode 100644
index 0000000000000..93e95a0f8923f
--- /dev/null
+++ b/crates/lint/testdata/ReentrancyEvents.stderr
@@ -0,0 +1,248 @@
+warning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+   ╭▸ ROOT/testdata/ReentrancyEvents.sol:LL:CC
+   │
+LL │ …     emit Tick();
+   │       ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events
+
+warning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+   ╭▸ ROOT/testdata/ReentrancyEvents.sol:LL:CC
+   │
+LL │ …     emit Tick();
+   │       ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events
+
+warning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+   ╭▸ ROOT/testdata/ReentrancyEvents.sol:LL:CC
+   │
+LL │ …     emit Tick();
+   │       ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events
+
+warning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+   ╭▸ ROOT/testdata/ReentrancyEvents.sol:LL:CC
+   │
+LL │ …     emit Tick();
+   │       ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events
+
+warning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+   ╭▸ ROOT/testdata/ReentrancyEvents.sol:LL:CC
+   │
+LL │ …     emit Counter(counter);
+   │       ━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events
+
+warning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+   ╭▸ ROOT/testdata/ReentrancyEvents.sol:LL:CC
+   │
+LL │ …     emit Counter(counter);
+   │       ━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events
+
+warning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+   ╭▸ ROOT/testdata/ReentrancyEvents.sol:LL:CC
+   │
+LL │ …     emit Counter(counter);
+   │       ━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events
+
+warning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+   ╭▸ ROOT/testdata/ReentrancyEvents.sol:LL:CC
+   │
+LL │ …     emit Counter(counter);
+   │       ━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events
+
+warning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+   ╭▸ ROOT/testdata/ReentrancyEvents.sol:LL:CC
+   │
+LL │ …     emit Counter(counter);
+   │       ━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events
+
+warning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+   ╭▸ ROOT/testdata/ReentrancyEvents.sol:LL:CC
+   │
+LL │ …     emit Tick();
+   │       ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events
+
+warning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+   ╭▸ ROOT/testdata/ReentrancyEvents.sol:LL:CC
+   │
+LL │ …     emit Tick();
+   │       ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events
+
+warning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+   ╭▸ ROOT/testdata/ReentrancyEvents.sol:LL:CC
+   │
+LL │ …     emit Counter(i);
+   │       ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events
+
+warning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+   ╭▸ ROOT/testdata/ReentrancyEvents.sol:LL:CC
+   │
+LL │ …     emit Tick();
+   │       ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events
+
+warning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+   ╭▸ ROOT/testdata/ReentrancyEvents.sol:LL:CC
+   │
+LL │ …     emit Counter(i);
+   │       ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events
+
+warning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+   ╭▸ ROOT/testdata/ReentrancyEvents.sol:LL:CC
+   │
+LL │ …     emit Tick();
+   │       ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events
+
+warning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+   ╭▸ ROOT/testdata/ReentrancyEvents.sol:LL:CC
+   │
+LL │ …     emit Tick();
+   │       ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events
+
+warning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+   ╭▸ ROOT/testdata/ReentrancyEvents.sol:LL:CC
+   │
+LL │ …     emit Tick();
+   │       ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events
+
+warning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+   ╭▸ ROOT/testdata/ReentrancyEvents.sol:LL:CC
+   │
+LL │ …     emit Counter(counter);
+   │       ━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events
+
+warning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+   ╭▸ ROOT/testdata/ReentrancyEvents.sol:LL:CC
+   │
+LL │ …     emit Counter(d.notify(1) ? 1 : 0);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events
+
+warning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+   ╭▸ ROOT/testdata/ReentrancyEvents.sol:LL:CC
+   │
+LL │ …     emit Tick();
+   │       ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events
+
+warning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+   ╭▸ ROOT/testdata/ReentrancyEvents.sol:LL:CC
+   │
+LL │ …     emit Tick();
+   │       ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events
+
+warning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+   ╭▸ ROOT/testdata/ReentrancyEvents.sol:LL:CC
+   │
+LL │ …     emit Tick();
+   │       ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events
+
+warning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+   ╭▸ ROOT/testdata/ReentrancyEvents.sol:LL:CC
+   │
+LL │ …     emit Tick();
+   │       ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events
+
+warning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+   ╭▸ ROOT/testdata/ReentrancyEvents.sol:LL:CC
+   │
+LL │ …     emit Tick();
+   │       ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events
+
+warning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+   ╭▸ ROOT/testdata/ReentrancyEvents.sol:LL:CC
+   │
+LL │ …     emit Counter(x);
+   │       ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events
+
+warning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+   ╭▸ ROOT/testdata/ReentrancyEvents.sol:LL:CC
+   │
+LL │ …     emit Counter(x);
+   │       ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events
+
+warning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+   ╭▸ ROOT/testdata/ReentrancyEvents.sol:LL:CC
+   │
+LL │ …     emit Tick();
+   │       ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events
+
+warning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+   ╭▸ ROOT/testdata/ReentrancyEvents.sol:LL:CC
+   │
+LL │ …     emit Tick();
+   │       ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events
+
+warning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+   ╭▸ ROOT/testdata/ReentrancyEvents.sol:LL:CC
+   │
+LL │ …     emit Counter(counter);
+   │       ━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events
+
+warning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+   ╭▸ ROOT/testdata/ReentrancyEvents.sol:LL:CC
+   │
+LL │ …     emit Tick();
+   │       ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events
+
+warning[reentrancy-events]: event emitted after an external call; reentrancy can reorder or fabricate logs that off-chain consumers rely on
+   ╭▸ ROOT/testdata/ReentrancyEvents.sol:LL:CC
+   │
+LL │ …     emit Tick();
+   │       ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-events
+
diff --git a/crates/lint/testdata/ReentrancyNoEth.sol b/crates/lint/testdata/ReentrancyNoEth.sol
new file mode 100644
index 0000000000000..0ef1430fffd28
--- /dev/null
+++ b/crates/lint/testdata/ReentrancyNoEth.sol
@@ -0,0 +1,160 @@
+//@compile-flags: --only-lint reentrancy-no-eth
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+interface IHook {
+    function notify(uint256 amount) external;
+    function overloaded(uint256 amount) external;
+    function overloaded(bool flag) external view returns (bool);
+    function balanceOf(address account) external view returns (uint256);
+    function key() external returns (address);
+}
+
+contract ReentrancyNoEth {
+    mapping(address => uint256) public balances;
+    mapping(address => uint256) public credits;
+    uint256 private locked;
+
+    function externalCallThenWrite(IHook hook) external {
+        uint256 amount = balances[msg.sender];
+        hook.notify(amount); //~WARN: external call can be reentered before `balances` is updated
+        balances[msg.sender] = 0;
+    }
+
+    function lowLevelCallThenWrite(address target) external {
+        uint256 amount = balances[msg.sender];
+        (bool ok,) = target.call(""); //~WARN: external call can be reentered before `balances` is updated
+        require(ok, "call failed");
+        balances[msg.sender] = amount + 1;
+    }
+
+    function delegateCallThenWrite(address target, bytes calldata data) external {
+        uint256 amount = credits[msg.sender];
+        (bool ok,) = target.delegatecall(data); //~WARN: external call can be reentered before `credits` is updated
+        require(ok, "delegatecall failed");
+        credits[msg.sender] = amount;
+    }
+
+    function internalHelperCallThenWrite(IHook hook) external {
+        uint256 amount = balances[msg.sender];
+        notifyHook(hook, amount);
+        balances[msg.sender] = 0;
+    }
+
+    function helperHeavyCallThenWrite(IHook hook) external {
+        uint256 amount = balances[msg.sender];
+        notifyHookHeavy(hook, amount);
+        notifyHookHeavy(hook, amount);
+        notifyHookHeavy(hook, amount);
+        notifyHookHeavy(hook, amount);
+        notifyHookHeavy(hook, amount);
+        notifyHookHeavy(hook, amount);
+        notifyHookHeavy(hook, amount);
+        notifyHookHeavy(hook, amount);
+        notifyHookHeavy(hook, amount);
+        notifyHookHeavy(hook, amount);
+        balances[msg.sender] = 0;
+    }
+
+    function modifierWriteAfterCall(IHook hook) external writeAfter {
+        uint256 amount = balances[msg.sender];
+        hook.notify(amount); //~WARN: external call can be reentered before `balances` is updated
+    }
+
+    function guardedByNonReentrant(IHook hook) external nonReentrant {
+        uint256 amount = balances[msg.sender];
+        hook.notify(amount); //~WARN: external call can be reentered before `balances` is updated
+        balances[msg.sender] = 0;
+    }
+
+    function effectsBeforeInteraction(IHook hook) external {
+        uint256 amount = balances[msg.sender];
+        balances[msg.sender] = 0;
+        hook.notify(amount);
+    }
+
+    function viewCallThenWrite(IHook hook) external {
+        uint256 amount = balances[msg.sender];
+        hook.balanceOf(msg.sender);
+        balances[msg.sender] = amount;
+    }
+
+    function mutatingOverloadThenWrite(IHook hook) external {
+        uint256 amount = balances[msg.sender];
+        hook.overloaded(amount); //~WARN: external call can be reentered before `balances` is updated
+        balances[msg.sender] = 0;
+    }
+
+    function viewOverloadThenWrite(IHook hook) external {
+        uint256 amount = balances[msg.sender];
+        hook.overloaded(true);
+        balances[msg.sender] = amount;
+    }
+
+    function viewOverloadWithBoolExpressionThenWrite(IHook hook, bool flag) external {
+        uint256 amount = balances[msg.sender];
+        hook.overloaded(flag && true);
+        balances[msg.sender] = amount;
+    }
+
+    function unrelatedStateWriteAfterCall(IHook hook) external {
+        uint256 amount = balances[msg.sender];
+        hook.notify(amount);
+        credits[msg.sender] = amount;
+    }
+
+    function callInAssignmentIndexThenWrite(IHook hook) external {
+        uint256 amount = balances[msg.sender];
+        balances[hook.key()] = amount; //~WARN: external call can be reentered before `balances` is updated
+    }
+
+    function callInDeleteIndexThenWrite(IHook hook) external {
+        uint256 amount = balances[msg.sender];
+        delete balances[hook.key()]; //~WARN: external call can be reentered before `balances` is updated
+    }
+
+    function ethTransferIsHandledByReentrancyEth(address payable target) external {
+        uint256 amount = balances[msg.sender];
+        (bool ok,) = target.call{value: amount}("");
+        require(ok, "transfer failed");
+        balances[msg.sender] = 0;
+    }
+
+    function recursiveEntryPoint(uint256 depth, address target) public {
+        if (depth != 0) {
+            uint256 snapshot = locked;
+            recursiveEntryPoint(depth - 1, target);
+            locked = snapshot;
+        } else {
+            (bool ok,) = target.call(""); //~WARN: external call can be reentered before `locked` is updated
+            require(ok, "call failed");
+        }
+    }
+
+    constructor(IHook hook) {
+        uint256 amount = balances[msg.sender];
+        hook.notify(amount);
+        balances[msg.sender] = 0;
+    }
+
+    modifier writeAfter() {
+        _;
+        balances[msg.sender] = 0;
+    }
+
+    modifier nonReentrant() {
+        require(locked == 0, "reentrant");
+        locked = 1;
+        _;
+        locked = 0;
+    }
+
+    function notifyHook(IHook hook, uint256 amount) internal {
+        hook.notify(amount); //~WARN: external call can be reentered before `balances` is updated
+    }
+
+    function notifyHookHeavy(IHook hook, uint256 amount) internal {
+        hook.notify(amount); //~WARN: external call can be reentered before `balances` is updated
+    }
+}
diff --git a/crates/lint/testdata/ReentrancyNoEth.stderr b/crates/lint/testdata/ReentrancyNoEth.stderr
new file mode 100644
index 0000000000000..2bd37011d0662
--- /dev/null
+++ b/crates/lint/testdata/ReentrancyNoEth.stderr
@@ -0,0 +1,88 @@
+warning[reentrancy-no-eth]: external call can be reentered before `balances` is updated
+   ╭▸ ROOT/testdata/ReentrancyNoEth.sol:LL:CC
+   │
+LL │         hook.notify(amount);
+   │         ━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-no-eth
+
+warning[reentrancy-no-eth]: external call can be reentered before `balances` is updated
+   ╭▸ ROOT/testdata/ReentrancyNoEth.sol:LL:CC
+   │
+LL │         (bool ok,) = target.call("");
+   │                      ━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-no-eth
+
+warning[reentrancy-no-eth]: external call can be reentered before `credits` is updated
+   ╭▸ ROOT/testdata/ReentrancyNoEth.sol:LL:CC
+   │
+LL │         (bool ok,) = target.delegatecall(data);
+   │                      ━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-no-eth
+
+warning[reentrancy-no-eth]: external call can be reentered before `balances` is updated
+   ╭▸ ROOT/testdata/ReentrancyNoEth.sol:LL:CC
+   │
+LL │         hook.notify(amount);
+   │         ━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-no-eth
+
+warning[reentrancy-no-eth]: external call can be reentered before `balances` is updated
+   ╭▸ ROOT/testdata/ReentrancyNoEth.sol:LL:CC
+   │
+LL │         hook.notify(amount);
+   │         ━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-no-eth
+
+warning[reentrancy-no-eth]: external call can be reentered before `balances` is updated
+   ╭▸ ROOT/testdata/ReentrancyNoEth.sol:LL:CC
+   │
+LL │         hook.notify(amount);
+   │         ━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-no-eth
+
+warning[reentrancy-no-eth]: external call can be reentered before `balances` is updated
+   ╭▸ ROOT/testdata/ReentrancyNoEth.sol:LL:CC
+   │
+LL │         hook.notify(amount);
+   │         ━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-no-eth
+
+warning[reentrancy-no-eth]: external call can be reentered before `balances` is updated
+   ╭▸ ROOT/testdata/ReentrancyNoEth.sol:LL:CC
+   │
+LL │         hook.overloaded(amount);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-no-eth
+
+warning[reentrancy-no-eth]: external call can be reentered before `balances` is updated
+   ╭▸ ROOT/testdata/ReentrancyNoEth.sol:LL:CC
+   │
+LL │         balances[hook.key()] = amount;
+   │                  ━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-no-eth
+
+warning[reentrancy-no-eth]: external call can be reentered before `balances` is updated
+   ╭▸ ROOT/testdata/ReentrancyNoEth.sol:LL:CC
+   │
+LL │         delete balances[hook.key()];
+   │                         ━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-no-eth
+
+warning[reentrancy-no-eth]: external call can be reentered before `locked` is updated
+   ╭▸ ROOT/testdata/ReentrancyNoEth.sol:LL:CC
+   │
+LL │             (bool ok,) = target.call("");
+   │                          ━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/reentrancy-no-eth
+
diff --git a/crates/lint/testdata/RequireRevertInLoop.sol b/crates/lint/testdata/RequireRevertInLoop.sol
new file mode 100644
index 0000000000000..f53a8c87b5a14
--- /dev/null
+++ b/crates/lint/testdata/RequireRevertInLoop.sol
@@ -0,0 +1,185 @@
+//@compile-flags: --only-lint require-revert-in-loop
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+error BadItem(uint256 index);
+
+library RequireRevertInLoopLib {
+    function validateExtension(uint256 value) internal pure {
+        require(value != 0, "zero"); //~WARN: `require` or `revert` inside a loop
+    }
+}
+
+contract RequireRevertInLoop {
+    using RequireRevertInLoopLib for uint256;
+
+    function requireInsideLoop(uint256[] calldata values) external pure {
+        for (uint256 i; i < values.length; ++i) {
+            require(values[i] != 0, "zero"); //~WARN: `require` or `revert` inside a loop
+        }
+    }
+
+    function revertInsideLoop(uint256[] calldata values) external pure {
+        uint256 i;
+        while (i < values.length) {
+            if (values[i] == 0) {
+                revert BadItem(i); //~WARN: `require` or `revert` inside a loop
+            }
+            ++i;
+        }
+    }
+
+    function builtinRevertInsideLoop(uint256[] calldata values) external pure {
+        for (uint256 i; i < values.length; ++i) {
+            if (values[i] == 0) {
+                revert("zero"); //~WARN: `require` or `revert` inside a loop
+            }
+        }
+    }
+
+    function helperRequireInsideLoop(uint256[] calldata values) external pure {
+        for (uint256 i; i < values.length; ++i) {
+            validate(values[i]);
+        }
+    }
+
+    function validate(uint256 value) internal pure {
+        require(value != 0, "zero"); //~WARN: `require` or `revert` inside a loop
+    }
+
+    function helperRevertInsideLoop(uint256[] calldata values) external pure {
+        for (uint256 i; i < values.length; ++i) {
+            validateWithRevert(values[i], i);
+        }
+    }
+
+    function validateWithRevert(uint256 value, uint256 index) internal pure {
+        if (value == 0) {
+            revert BadItem(index); //~WARN: `require` or `revert` inside a loop
+        }
+    }
+
+    function helperWithOwnLoopCalledOutsideLoop(uint256[] calldata values) external pure {
+        validateLoop(values);
+    }
+
+    function helperWithOwnLoopCalledInsideLoop(uint256[][] calldata batches) external pure {
+        for (uint256 i; i < batches.length; ++i) {
+            validateLoop(batches[i]);
+        }
+    }
+
+    function validateLoop(uint256[] calldata values) internal pure {
+        for (uint256 i; i < values.length; ++i) {
+            require(values[i] != 0, "zero"); //~WARN: `require` or `revert` inside a loop
+        }
+    }
+
+    function sharedHelperRequireInsideFirstLoop(uint256[] calldata values) external pure {
+        for (uint256 i; i < values.length; ++i) {
+            sharedValidate(values[i]);
+        }
+    }
+
+    function sharedHelperRequireInsideSecondLoop(uint256[] calldata values) external pure {
+        for (uint256 i; i < values.length; ++i) {
+            sharedValidate(values[i]);
+        }
+    }
+
+    function sharedValidate(uint256 value) internal pure {
+        require(value != 0, "zero");
+        //~^WARN: `require` or `revert` inside a loop
+    }
+
+    function requireInLoopCondition(uint256 iterations) external pure {
+        uint256 i;
+        while (conditionWithRequire(i, iterations)) {
+            ++i;
+        }
+    }
+
+    function conditionWithRequire(uint256 i, uint256 iterations) internal pure returns (bool) {
+        require(iterations < 100, "too many"); //~WARN: `require` or `revert` inside a loop
+        return i < iterations;
+    }
+
+    function requireInForLoopUpdate(uint256 iterations) external pure {
+        for (uint256 i; i < iterations; i = incrementWithRequire(i)) {}
+    }
+
+    function incrementWithRequire(uint256 i) internal pure returns (uint256) {
+        require(i < 100, "too many"); //~WARN: `require` or `revert` inside a loop
+        return i + 1;
+    }
+
+    function extensionRequireInsideLoop(uint256[] calldata values) external pure {
+        for (uint256 i; i < values.length; ++i) {
+            values[i].validateExtension();
+        }
+    }
+
+    function externalSelfCallInsideLoop(uint256[] calldata values) external view {
+        for (uint256 i; i < values.length; ++i) {
+            this.externalValidate(values[i]);
+        }
+    }
+
+    function externalValidate(uint256 value) external pure {
+        require(value != 0, "zero");
+    }
+
+    function overloadedHelperInsideLoop(uint256[] calldata values) external pure {
+        for (uint256 i; i < values.length; ++i) {
+            overloadedValidate(values[i]);
+        }
+    }
+
+    function overloadedValidate(uint256 value) internal pure {
+        require(value != 0, "zero"); //~WARN: `require` or `revert` inside a loop
+    }
+
+    function overloadedValidate(address value) internal pure {
+        require(value != address(0), "zero");
+    }
+
+    modifier repeated(uint256 iterations) {
+        for (uint256 i; i < iterations; ++i) {
+            _;
+        }
+    }
+
+    function modifierPlaceholderLoop(uint256 iterations, bool ok) external pure repeated(iterations) {
+        require(ok, "not ok"); //~WARN: `require` or `revert` inside a loop
+    }
+
+    function yulRevertInsideLoop(uint256 iterations) external pure {
+        for (uint256 i; i < iterations; ++i) {
+            assembly {
+                revert(0, 0) //~WARN: `require` or `revert` inside a loop
+            }
+        }
+    }
+
+    function requireOutsideLoop(uint256 value) external pure {
+        require(value != 0, "zero");
+    }
+
+    function helperRequireOutsideLoop(uint256 value) external pure {
+        validateOutsideLoop(value);
+    }
+
+    function validateOutsideLoop(uint256 value) internal pure {
+        require(value != 0, "zero");
+    }
+
+    function loopWithoutRequireOrRevert(uint256[] calldata values) external pure returns (uint256 sum) {
+        for (uint256 i; i < values.length; ++i) {
+            if (values[i] == 0) {
+                continue;
+            }
+            sum += values[i];
+        }
+    }
+}
diff --git a/crates/lint/testdata/RequireRevertInLoop.stderr b/crates/lint/testdata/RequireRevertInLoop.stderr
new file mode 100644
index 0000000000000..bdb77a6d23329
--- /dev/null
+++ b/crates/lint/testdata/RequireRevertInLoop.stderr
@@ -0,0 +1,104 @@
+warning[require-revert-in-loop]: `require` or `revert` inside a loop
+   ╭▸ ROOT/testdata/RequireRevertInLoop.sol:LL:CC
+   │
+LL │             require(values[i] != 0, "zero");
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/require-revert-in-loop
+
+warning[require-revert-in-loop]: `require` or `revert` inside a loop
+   ╭▸ ROOT/testdata/RequireRevertInLoop.sol:LL:CC
+   │
+LL │                 revert BadItem(i);
+   │                 ━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/require-revert-in-loop
+
+warning[require-revert-in-loop]: `require` or `revert` inside a loop
+   ╭▸ ROOT/testdata/RequireRevertInLoop.sol:LL:CC
+   │
+LL │                 revert("zero");
+   │                 ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/require-revert-in-loop
+
+warning[require-revert-in-loop]: `require` or `revert` inside a loop
+   ╭▸ ROOT/testdata/RequireRevertInLoop.sol:LL:CC
+   │
+LL │         require(value != 0, "zero");
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/require-revert-in-loop
+
+warning[require-revert-in-loop]: `require` or `revert` inside a loop
+   ╭▸ ROOT/testdata/RequireRevertInLoop.sol:LL:CC
+   │
+LL │             revert BadItem(index);
+   │             ━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/require-revert-in-loop
+
+warning[require-revert-in-loop]: `require` or `revert` inside a loop
+   ╭▸ ROOT/testdata/RequireRevertInLoop.sol:LL:CC
+   │
+LL │             require(values[i] != 0, "zero");
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/require-revert-in-loop
+
+warning[require-revert-in-loop]: `require` or `revert` inside a loop
+   ╭▸ ROOT/testdata/RequireRevertInLoop.sol:LL:CC
+   │
+LL │         require(value != 0, "zero");
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/require-revert-in-loop
+
+warning[require-revert-in-loop]: `require` or `revert` inside a loop
+   ╭▸ ROOT/testdata/RequireRevertInLoop.sol:LL:CC
+   │
+LL │         require(iterations < 100, "too many");
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/require-revert-in-loop
+
+warning[require-revert-in-loop]: `require` or `revert` inside a loop
+   ╭▸ ROOT/testdata/RequireRevertInLoop.sol:LL:CC
+   │
+LL │         require(i < 100, "too many");
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/require-revert-in-loop
+
+warning[require-revert-in-loop]: `require` or `revert` inside a loop
+   ╭▸ ROOT/testdata/RequireRevertInLoop.sol:LL:CC
+   │
+LL │         require(value != 0, "zero");
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/require-revert-in-loop
+
+warning[require-revert-in-loop]: `require` or `revert` inside a loop
+   ╭▸ ROOT/testdata/RequireRevertInLoop.sol:LL:CC
+   │
+LL │         require(value != 0, "zero");
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/require-revert-in-loop
+
+warning[require-revert-in-loop]: `require` or `revert` inside a loop
+   ╭▸ ROOT/testdata/RequireRevertInLoop.sol:LL:CC
+   │
+LL │         require(ok, "not ok");
+   │         ━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/require-revert-in-loop
+
+warning[require-revert-in-loop]: `require` or `revert` inside a loop
+   ╭▸ ROOT/testdata/RequireRevertInLoop.sol:LL:CC
+   │
+LL │                 revert(0, 0)
+   │                 ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/require-revert-in-loop
+
diff --git a/crates/lint/testdata/ReturnBomb.sol b/crates/lint/testdata/ReturnBomb.sol
new file mode 100644
index 0000000000000..8160adc0418d2
--- /dev/null
+++ b/crates/lint/testdata/ReturnBomb.sol
@@ -0,0 +1,466 @@
+//@compile-flags: --only-lint return-bomb
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+interface IReturnBombTarget {
+    function fetch() external returns (bytes memory);
+    function value() external returns (uint256);
+}
+
+interface IReturnBombStructTarget {
+    struct Result {
+        bytes data;
+    }
+
+    function fetch() external returns (Result memory);
+}
+
+interface IReturnBombFalsePositiveTarget {
+    function call(bytes calldata payload) external returns (uint256);
+}
+
+interface IReturnBombOverloadedTarget {
+    function fetch(uint256 value) external returns (uint256);
+    function fetch(bytes calldata payload) external returns (bytes memory);
+}
+
+interface IReturnBombOverloadedReverseTarget {
+    function fetch(bytes calldata payload) external returns (bytes memory);
+    function fetch(uint256 value) external returns (uint256);
+}
+
+interface IReturnBombAmbiguousOverloadedTarget {
+    function fetch(uint256 value) external returns (bytes memory);
+    function fetch(bool value) external returns (uint256);
+}
+
+interface IReturnBombWideningTarget {
+    function fetch(uint256 value) external returns (bytes memory);
+}
+
+interface IReturnBombLiteralRangeTarget {
+    function fetch(uint8 value) external returns (uint256);
+    function fetch(uint256 value) external returns (bytes memory);
+}
+
+interface IReturnBombNegativeLiteralTarget {
+    function fetch(int8 value) external returns (uint256);
+    function fetch(uint256 value) external returns (bytes memory);
+}
+
+contract ReturnBombBaseArgument {}
+
+contract ReturnBombDerivedArgument is ReturnBombBaseArgument {}
+
+interface IReturnBombBaseArgumentTarget {
+    function fetch(ReturnBombBaseArgument value) external returns (bytes memory);
+}
+
+interface IReturnBombFixedArrayOverloadedTarget {
+    function fetch(uint256[2] calldata values) external returns (bytes memory);
+    function fetch(uint256[3] calldata values) external returns (uint256);
+}
+
+uint256 constant RETURN_BOMB_FIXED_ARRAY_BASE = 1;
+uint256 constant RETURN_BOMB_FIXED_ARRAY_LENGTH = RETURN_BOMB_FIXED_ARRAY_BASE + 1;
+
+interface IReturnBombConstantFixedArrayTarget {
+    function fetch(uint256[RETURN_BOMB_FIXED_ARRAY_LENGTH] calldata values) external returns (bytes memory);
+}
+
+contract ReturnBombCreatedTarget {
+    function fetch() external returns (bytes memory) {
+        return "";
+    }
+}
+
+contract ReturnBomb {
+    event DynamicReturn(bytes result);
+
+    error DynamicReturnError(bytes result);
+
+    bytes initializedData = this.ownDynamicReturn{gas: 10_000}(); //~WARN: external call with a gas limit may copy unbounded return data
+
+    struct Result {
+        bytes data;
+    }
+
+    struct TargetSlot {
+        IReturnBombTarget target;
+    }
+
+    struct FunctionPointerSlot {
+        function() external returns (bytes memory) fetcher;
+    }
+
+    bytes existingData;
+    bytes[] results;
+    Result storedResult;
+    mapping(uint256 => IReturnBombTarget) mappedTargets;
+    IReturnBombTarget storedTarget;
+
+    // SHOULD PASS: Calls without a gas cap.
+    function valueOnlyOption(address payable target, bytes memory payload, uint256 value) public {
+        (bool success, bytes memory result) = target.call{value: value}(payload);
+        require(success, "Call failed");
+        require(result.length >= 0);
+    }
+
+    // SHOULD FAIL: Gas-capped low-level calls that copy unbounded returndata.
+    function ignoresReturnData(address target, bytes memory payload, uint256 gasLimit) public {
+        (bool success, ) = target.call{gas: gasLimit}(payload); //~WARN: external call with a gas limit may copy unbounded return data
+        require(success, "Call failed");
+    }
+
+    function standaloneLowLevelCall(address target, bytes memory payload, uint256 gasLimit) public {
+        target.call{gas: gasLimit}(payload); //~WARN: external call with a gas limit may copy unbounded return data
+    }
+
+    function lowLevelCall(address target, bytes memory payload, uint256 gasLimit) public {
+        (bool success, bytes memory result) = target.call{gas: gasLimit}(payload); //~WARN: external call with a gas limit may copy unbounded return data
+        require(success, "Call failed");
+        require(result.length >= 0);
+    }
+
+    function lowLevelCallLiteralGas(address target, bytes memory payload) public {
+        (bool success, bytes memory result) = target.call{gas: 10_000}(payload); //~WARN: external call with a gas limit may copy unbounded return data
+        require(success, "Call failed");
+        require(result.length >= 0);
+    }
+
+    function lowLevelCallWithValue(address payable target, bytes memory payload, uint256 gasLimit, uint256 value) public {
+        (bool success, bytes memory result) = target.call{value: value, gas: gasLimit}(payload); //~WARN: external call with a gas limit may copy unbounded return data
+        require(success, "Call failed");
+        require(result.length >= 0);
+    }
+
+    function delegateCall(address target, bytes memory payload, uint256 gasLimit) public {
+        (bool success, bytes memory result) = target.delegatecall{gas: gasLimit}(payload); //~WARN: external call with a gas limit may copy unbounded return data
+        require(success, "Delegatecall failed");
+        require(result.length >= 0);
+    }
+
+    function staticCall(address target, bytes memory payload, uint256 gasLimit) public view {
+        (bool success, bytes memory result) = target.staticcall{gas: gasLimit}(payload); //~WARN: external call with a gas limit may copy unbounded return data
+        require(success, "Staticcall failed");
+        require(result.length >= 0);
+    }
+
+    function msgSenderCall(bytes memory payload, uint256 gasLimit) public {
+        msg.sender.call{gas: gasLimit}(payload); //~WARN: external call with a gas limit may copy unbounded return data
+    }
+
+    function blockCoinbaseCall(bytes memory payload, uint256 gasLimit) public {
+        block.coinbase.call{gas: gasLimit}(payload); //~WARN: external call with a gas limit may copy unbounded return data
+    }
+
+    function txOriginCall(bytes memory payload, uint256 gasLimit) public {
+        tx.origin.call{gas: gasLimit}(payload); //~WARN: external call with a gas limit may copy unbounded return data
+    }
+
+    function getAddress() public view returns (address) {
+        return address(this);
+    }
+
+    function returnedAddressLowLevelCall(bytes memory payload, uint256 gasLimit) public {
+        getAddress().call{gas: gasLimit}(payload); //~WARN: external call with a gas limit may copy unbounded return data
+    }
+
+    function ecrecoverLowLevelCall(
+        bytes32 h,
+        uint8 v,
+        bytes32 r,
+        bytes32 s,
+        bytes memory payload,
+        uint256 gasLimit
+    ) public {
+        ecrecover(h, v, r, s).call{gas: gasLimit}(payload); //~WARN: external call with a gas limit may copy unbounded return data
+    }
+
+    function existingBytes(address target, bytes memory payload, uint256 gasLimit) public {
+        (bool success, bytes memory result) = target.call{gas: gasLimit}(payload); //~WARN: external call with a gas limit may copy unbounded return data
+        (success, existingData) = target.call{gas: gasLimit}(result); //~WARN: external call with a gas limit may copy unbounded return data
+        require(success, "Call failed");
+    }
+
+    function directReturn(address target, bytes memory payload, uint256 gasLimit) public returns (bool, bytes memory) {
+        return target.call{gas: gasLimit}(payload); //~WARN: external call with a gas limit may copy unbounded return data
+    }
+
+    function memberBytes(address target, bytes memory payload, uint256 gasLimit) public {
+        bool success;
+        (success, storedResult.data) = target.call{gas: gasLimit}(payload); //~WARN: external call with a gas limit may copy unbounded return data
+        require(success, "Call failed");
+    }
+
+    function indexedBytes(address target, bytes memory payload, uint256 gasLimit) public {
+        bool success;
+        results.push();
+        (success, results[0]) = target.call{gas: gasLimit}(payload); //~WARN: external call with a gas limit may copy unbounded return data
+        require(success, "Call failed");
+    }
+
+    function highLevelDynamicReturn(IReturnBombTarget target, uint256 gasLimit) public {
+        bytes memory result = target.fetch{gas: gasLimit}(); //~WARN: external call with a gas limit may copy unbounded return data
+        require(result.length >= 0);
+    }
+
+    function standaloneHighLevelDynamicReturn(IReturnBombTarget target, uint256 gasLimit) public {
+        target.fetch{gas: gasLimit}(); //~WARN: external call with a gas limit may copy unbounded return data
+    }
+
+    function nestedHighLevelDynamicReturn(IReturnBombTarget target, uint256 gasLimit) public {
+        require(target.fetch{gas: gasLimit}().length >= 0); //~WARN: external call with a gas limit may copy unbounded return data
+    }
+
+    function directHighLevelDynamicReturn(IReturnBombTarget target, uint256 gasLimit) public returns (bytes memory) {
+        return target.fetch{gas: gasLimit}(); //~WARN: external call with a gas limit may copy unbounded return data
+    }
+
+    function highLevelDynamicStructReturn(IReturnBombStructTarget target, uint256 gasLimit) public {
+        IReturnBombStructTarget.Result memory result = target.fetch{gas: gasLimit}(); //~WARN: external call with a gas limit may copy unbounded return data
+        require(result.data.length >= 0);
+    }
+
+    function indexedHighLevelDynamicReturn(IReturnBombTarget[] memory targets, uint256 index, uint256 gasLimit) public {
+        bytes memory result = targets[index].fetch{gas: gasLimit}(); //~WARN: external call with a gas limit may copy unbounded return data
+        require(result.length >= 0);
+    }
+
+    function mappedHighLevelDynamicReturn(uint256 index, uint256 gasLimit) public {
+        bytes memory result = mappedTargets[index].fetch{gas: gasLimit}(); //~WARN: external call with a gas limit may copy unbounded return data
+        require(result.length >= 0);
+    }
+
+    function memberHighLevelDynamicReturn(TargetSlot memory slot, uint256 gasLimit) public {
+        bytes memory result = slot.target.fetch{gas: gasLimit}(); //~WARN: external call with a gas limit may copy unbounded return data
+        require(result.length >= 0);
+    }
+
+    function getTarget() public view returns (IReturnBombTarget) {
+        return storedTarget;
+    }
+
+    function returnedTargetHighLevelDynamicReturn(uint256 gasLimit) public {
+        bytes memory result = getTarget().fetch{gas: gasLimit}(); //~WARN: external call with a gas limit may copy unbounded return data
+        require(result.length >= 0);
+    }
+
+    function ownDynamicReturn() external returns (bytes memory) {
+        return "";
+    }
+
+    function externalStaticOverload(uint256 value) external returns (uint256) {
+        return value;
+    }
+
+    function externalStaticOverload(bool) internal returns (bytes memory) {
+        return "";
+    }
+
+    function thisHighLevelDynamicReturn(uint256 gasLimit) public {
+        this.ownDynamicReturn{gas: gasLimit}(); //~WARN: external call with a gas limit may copy unbounded return data
+    }
+
+    modifier withDynamicReturn(bytes memory) {
+        _;
+    }
+
+    function thisHighLevelDynamicReturnInModifierArgument(uint256 gasLimit)
+        public
+        withDynamicReturn(this.ownDynamicReturn{gas: gasLimit}()) //~WARN: external call with a gas limit may copy unbounded return data
+    {}
+
+    function thisExternalStaticOverloadIgnoresInternal(uint256 value, uint256 gasLimit) public {
+        uint256 result = this.externalStaticOverload{gas: gasLimit}(value + 1);
+        require(result >= 0);
+    }
+
+    function highLevelStaticReturn(IReturnBombTarget target, uint256 gasLimit) public {
+        uint256 result = target.value{gas: gasLimit}();
+        require(result >= 0);
+    }
+
+    function highLevelDynamicReturnInIf(IReturnBombTarget target, uint256 gasLimit) public {
+        if (target.fetch{gas: gasLimit}().length > 0) {} //~WARN: external call with a gas limit may copy unbounded return data
+    }
+
+    function highLevelDynamicReturnInEmit(IReturnBombTarget target, uint256 gasLimit) public {
+        emit DynamicReturn(target.fetch{gas: gasLimit}()); //~WARN: external call with a gas limit may copy unbounded return data
+    }
+
+    function highLevelDynamicReturnInRevert(IReturnBombTarget target, uint256 gasLimit) public {
+        revert DynamicReturnError(target.fetch{gas: gasLimit}()); //~WARN: external call with a gas limit may copy unbounded return data
+    }
+
+    function highLevelDynamicReturnInTry(IReturnBombTarget target, uint256 gasLimit) public {
+        try target.fetch{gas: gasLimit}() returns (bytes memory) { //~WARN: external call with a gas limit may copy unbounded return data
+        } catch {}
+    }
+
+    function highLevelMethodNamedCall(IReturnBombFalsePositiveTarget target, bytes calldata payload, uint256 gasLimit) public {
+        uint256 result = target.call{gas: gasLimit}(payload);
+        require(result >= 0);
+    }
+
+    function overloadedDynamicReturn(
+        IReturnBombOverloadedTarget target,
+        bytes calldata payload,
+        uint256 gasLimit
+    ) public {
+        bytes memory result = target.fetch{gas: gasLimit}(payload); //~WARN: external call with a gas limit may copy unbounded return data
+        require(result.length >= 0);
+    }
+
+    function overloadedStaticReturn(
+        IReturnBombOverloadedReverseTarget target,
+        uint256 value,
+        uint256 gasLimit
+    ) public {
+        uint256 result = target.fetch{gas: gasLimit}(value);
+        require(result >= 0);
+    }
+
+    function overloadedUnknownArgumentDynamicReturn(
+        IReturnBombAmbiguousOverloadedTarget target,
+        uint256 value,
+        uint256 gasLimit
+    ) public {
+        bytes memory result = target.fetch{gas: gasLimit}(value + 1); //~WARN: external call with a gas limit may copy unbounded return data
+        require(result.length >= 0);
+    }
+
+    function integerWideningDynamicReturn(
+        IReturnBombWideningTarget target,
+        uint8 value,
+        uint256 gasLimit
+    ) public {
+        bytes memory result = target.fetch{gas: gasLimit}(value); //~WARN: external call with a gas limit may copy unbounded return data
+        require(result.length >= 0);
+    }
+
+    function overloadedBooleanExpressionStaticReturn(
+        IReturnBombAmbiguousOverloadedTarget target,
+        uint256 value,
+        uint256 gasLimit
+    ) public {
+        uint256 result = target.fetch{gas: gasLimit}(value > 0);
+        require(result >= 0);
+    }
+
+    function overloadedLiteralRangeDynamicReturn(
+        IReturnBombLiteralRangeTarget target,
+        uint256 gasLimit
+    ) public {
+        bytes memory result = target.fetch{gas: gasLimit}(300); //~WARN: external call with a gas limit may copy unbounded return data
+        require(result.length >= 0);
+    }
+
+    function overloadedNegativeLiteralStaticReturn(
+        IReturnBombNegativeLiteralTarget target,
+        uint256 gasLimit
+    ) public {
+        uint256 result = target.fetch{gas: gasLimit}(-1);
+        require(result >= 0);
+    }
+
+    function derivedContractArgumentDynamicReturn(
+        IReturnBombBaseArgumentTarget target,
+        ReturnBombDerivedArgument value,
+        uint256 gasLimit
+    ) public {
+        bytes memory result = target.fetch{gas: gasLimit}(value); //~WARN: external call with a gas limit may copy unbounded return data
+        require(result.length >= 0);
+    }
+
+    function fixedArrayOverloadDynamicReturn(
+        IReturnBombFixedArrayOverloadedTarget target,
+        uint256[2] calldata values,
+        uint256 gasLimit
+    ) public {
+        bytes memory result = target.fetch{gas: gasLimit}(values); //~WARN: external call with a gas limit may copy unbounded return data
+        require(result.length >= 0);
+    }
+
+    function constantFixedArrayLengthDynamicReturn(
+        IReturnBombConstantFixedArrayTarget target,
+        uint256[RETURN_BOMB_FIXED_ARRAY_BASE + 1] calldata values,
+        uint256 gasLimit
+    ) public {
+        bytes memory result = target.fetch{gas: gasLimit}(values); //~WARN: external call with a gas limit may copy unbounded return data
+        require(result.length >= 0);
+    }
+
+    function ternaryHighLevelDynamicReturn(
+        IReturnBombTarget first,
+        IReturnBombTarget second,
+        bool useFirst,
+        uint256 gasLimit
+    ) public {
+        bytes memory result = (useFirst ? first : second).fetch{gas: gasLimit}(); //~WARN: external call with a gas limit may copy unbounded return data
+        require(result.length >= 0);
+    }
+
+    function ternaryLowLevelCall(
+        address first,
+        address second,
+        bool useFirst,
+        bytes memory payload,
+        uint256 gasLimit
+    ) public {
+        (useFirst ? first : second).call{gas: gasLimit}(payload); //~WARN: external call with a gas limit may copy unbounded return data
+    }
+
+    function castDynamicReturn(address target, uint256 gasLimit) public {
+        bytes memory result = IReturnBombTarget(target).fetch{gas: gasLimit}(); //~WARN: external call with a gas limit may copy unbounded return data
+        require(result.length >= 0);
+    }
+
+    function castOverloadedDynamicReturn(
+        address target,
+        bytes calldata payload,
+        uint256 gasLimit
+    ) public {
+        bytes memory result = IReturnBombOverloadedTarget(target).fetch{gas: gasLimit}(payload); //~WARN: external call with a gas limit may copy unbounded return data
+        require(result.length >= 0);
+    }
+
+    function externalFunctionPointerDynamicReturn(
+        function() external returns (bytes memory) fetcher,
+        uint256 gasLimit
+    ) public {
+        bytes memory result = fetcher{gas: gasLimit}(); //~WARN: external call with a gas limit may copy unbounded return data
+        require(result.length >= 0);
+    }
+
+    function externalFunctionPointerMemberDynamicReturn(
+        FunctionPointerSlot memory slot,
+        uint256 gasLimit
+    ) public {
+        bytes memory result = slot.fetcher{gas: gasLimit}(); //~WARN: external call with a gas limit may copy unbounded return data
+        require(result.length >= 0);
+    }
+
+    function getFetcher() public view returns (function() external returns (bytes memory)) {
+        return this.ownDynamicReturn;
+    }
+
+    function returnedExternalFunctionPointerDynamicReturn(uint256 gasLimit) public {
+        bytes memory result = getFetcher(){gas: gasLimit}(); //~WARN: external call with a gas limit may copy unbounded return data
+        require(result.length >= 0);
+    }
+
+    function externalFunctionPointerReturnedTargetDynamicReturn(
+        function() external returns (IReturnBombTarget) targetGetter,
+        uint256 gasLimit
+    ) public {
+        bytes memory result = targetGetter().fetch{gas: gasLimit}(); //~WARN: external call with a gas limit may copy unbounded return data
+        require(result.length >= 0);
+    }
+
+    function newTargetDynamicReturn(uint256 gasLimit) public {
+        bytes memory result = (new ReturnBombCreatedTarget()).fetch{gas: gasLimit}(); //~WARN: external call with a gas limit may copy unbounded return data
+        require(result.length >= 0);
+    }
+}
diff --git a/crates/lint/testdata/ReturnBomb.stderr b/crates/lint/testdata/ReturnBomb.stderr
new file mode 100644
index 0000000000000..ba8f428b86051
--- /dev/null
+++ b/crates/lint/testdata/ReturnBomb.stderr
@@ -0,0 +1,392 @@
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │     bytes initializedData = this.ownDynamicReturn{gas: 10_000}();
+   │                             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │         (bool success, ) = target.call{gas: gasLimit}(payload);
+   │                            ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │         target.call{gas: gasLimit}(payload);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │ …     (bool success, bytes memory result) = target.call{gas: gasLimit}(payload);
+   │                                             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │ …     (bool success, bytes memory result) = target.call{gas: 10_000}(payload);
+   │                                             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │ …     (bool success, bytes memory result) = target.call{value: value, gas: gasLimit}(payload);
+   │                                             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │ …     (bool success, bytes memory result) = target.delegatecall{gas: gasLimit}(payload);
+   │                                             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │ …     (bool success, bytes memory result) = target.staticcall{gas: gasLimit}(payload);
+   │                                             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │         msg.sender.call{gas: gasLimit}(payload);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │         block.coinbase.call{gas: gasLimit}(payload);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │         tx.origin.call{gas: gasLimit}(payload);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │         getAddress().call{gas: gasLimit}(payload);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │         ecrecover(h, v, r, s).call{gas: gasLimit}(payload);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │ …     (bool success, bytes memory result) = target.call{gas: gasLimit}(payload);
+   │                                             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │ …     (success, existingData) = target.call{gas: gasLimit}(result);
+   │                                 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │         return target.call{gas: gasLimit}(payload);
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │ …     (success, storedResult.data) = target.call{gas: gasLimit}(payload);
+   │                                      ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │ …     (success, results[0]) = target.call{gas: gasLimit}(payload);
+   │                               ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │         bytes memory result = target.fetch{gas: gasLimit}();
+   │                               ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │         target.fetch{gas: gasLimit}();
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │         require(target.fetch{gas: gasLimit}().length >= 0);
+   │                 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │         return target.fetch{gas: gasLimit}();
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │ …     IReturnBombStructTarget.Result memory result = target.fetch{gas: gasLimit}();
+   │                                                      ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │ …     bytes memory result = targets[index].fetch{gas: gasLimit}();
+   │                             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │ …     bytes memory result = mappedTargets[index].fetch{gas: gasLimit}();
+   │                             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │ …     bytes memory result = slot.target.fetch{gas: gasLimit}();
+   │                             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │ …     bytes memory result = getTarget().fetch{gas: gasLimit}();
+   │                             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │         this.ownDynamicReturn{gas: gasLimit}();
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │ …     withDynamicReturn(this.ownDynamicReturn{gas: gasLimit}())
+   │                         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │         if (target.fetch{gas: gasLimit}().length > 0) {}
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │         emit DynamicReturn(target.fetch{gas: gasLimit}());
+   │                            ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │ …     revert DynamicReturnError(target.fetch{gas: gasLimit}());
+   │                                 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │ …     try target.fetch{gas: gasLimit}() returns (bytes memory) {
+   │           ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │ …     bytes memory result = target.fetch{gas: gasLimit}(payload);
+   │                             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │ …     bytes memory result = target.fetch{gas: gasLimit}(value + 1);
+   │                             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │ …     bytes memory result = target.fetch{gas: gasLimit}(value);
+   │                             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │         bytes memory result = target.fetch{gas: gasLimit}(300);
+   │                               ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │ …     bytes memory result = target.fetch{gas: gasLimit}(value);
+   │                             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │ …     bytes memory result = target.fetch{gas: gasLimit}(values);
+   │                             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │ …     bytes memory result = target.fetch{gas: gasLimit}(values);
+   │                             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │ …     bytes memory result = (useFirst ? first : second).fetch{gas: gasLimit}();
+   │                             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │ …     (useFirst ? first : second).call{gas: gasLimit}(payload);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │ …     bytes memory result = IReturnBombTarget(target).fetch{gas: gasLimit}();
+   │                             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │ …     bytes memory result = IReturnBombOverloadedTarget(target).fetch{gas: gasLimit}(payload);
+   │                             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │         bytes memory result = fetcher{gas: gasLimit}();
+   │                               ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │         bytes memory result = slot.fetcher{gas: gasLimit}();
+   │                               ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │         bytes memory result = getFetcher(){gas: gasLimit}();
+   │                               ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │ …     bytes memory result = targetGetter().fetch{gas: gasLimit}();
+   │                             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
+warning[return-bomb]: external call with a gas limit may copy unbounded return data
+   ╭▸ ROOT/testdata/ReturnBomb.sol:LL:CC
+   │
+LL │ …     bytes memory result = (new ReturnBombCreatedTarget()).fetch{gas: gasLimit}();
+   │                             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/return-bomb
+
diff --git a/crates/lint/testdata/Rtlo.sol b/crates/lint/testdata/Rtlo.sol
new file mode 100644
index 0000000000000..884ec957dfae3
--- /dev/null
+++ b/crates/lint/testdata/Rtlo.sol
@@ -0,0 +1,81 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+// Tests for the `rtlo` lint, which detects "Trojan Source" bidirectional
+// formatting characters (CVE-2021-42574). These have no legitimate use in
+// Solidity source and can be used to hide malicious code.
+//
+// Note: solc itself rejects unbalanced directional override markers (error
+// 8936), so each test uses a balanced opening/closing pair. Our lint flags
+// each occurrence individually regardless of balance.
+
+contract Rtlo {
+    // SHOULD FAIL: every codepoint in the Trojan-Source set is flagged.
+    // Each line below contains two bidi characters (an opener and its closer)
+    // and produces two diagnostics.
+
+    string public lre = unicode"‪_‬";
+    //~^WARN: `U+202A` (Left-to-Right Embedding) detected
+    //~|WARN: `U+202C` (Pop Directional Formatting) detected
+
+    string public rle = unicode"‫_‬";
+    //~^WARN: `U+202B` (Right-to-Left Embedding) detected
+    //~|WARN: `U+202C` (Pop Directional Formatting) detected
+
+    string public pdf = unicode"‪‬";
+    //~^WARN: `U+202A` (Left-to-Right Embedding) detected
+    //~|WARN: `U+202C` (Pop Directional Formatting) detected
+
+    string public lro = unicode"‭_‬";
+    //~^WARN: `U+202D` (Left-to-Right Override) detected
+    //~|WARN: `U+202C` (Pop Directional Formatting) detected
+
+    string public rlo = unicode"‮_‬";
+    //~^WARN: `U+202E` (Right-to-Left Override) detected
+    //~|WARN: `U+202C` (Pop Directional Formatting) detected
+
+    string public lri = unicode"⁦_⁩";
+    //~^WARN: `U+2066` (Left-to-Right Isolate) detected
+    //~|WARN: `U+2069` (Pop Directional Isolate) detected
+
+    string public rli = unicode"⁧_⁩";
+    //~^WARN: `U+2067` (Right-to-Left Isolate) detected
+    //~|WARN: `U+2069` (Pop Directional Isolate) detected
+
+    string public fsi = unicode"⁨_⁩";
+    //~^WARN: `U+2068` (First Strong Isolate) detected
+    //~|WARN: `U+2069` (Pop Directional Isolate) detected
+
+    string public pdi = unicode"⁦⁩";
+    //~^WARN: `U+2066` (Left-to-Right Isolate) detected
+    //~|WARN: `U+2069` (Pop Directional Isolate) detected
+
+    // SHOULD FAIL: bidi controls inside a block comment are also detected.
+    /* hidden‮ /* text ‬ */ uint256 inBlockComment;
+    //~^WARN: `U+202E` (Right-to-Left Override) detected
+    //~|WARN: `U+202C` (Pop Directional Formatting) detected
+
+    // SHOULD FAIL: bidi controls inside a line comment are also detected. The
+    // expectation markers must come on separate lines because the ui-test
+    // parser only treats the first comment on a line as a marker.
+    // sneaky‮ payload ‬ trailing
+    //~^WARN: `U+202E` (Right-to-Left Override) detected
+    //~|WARN: `U+202C` (Pop Directional Formatting) detected
+
+    // SHOULD PASS: inline-config disable suppresses the diagnostic.
+    // forge-lint: disable-next-line(rtlo)
+    string public suppressedLine = unicode"‮_‬";
+
+    // forge-lint: disable-start(rtlo)
+    string public suppressedA = unicode"‮_‬";
+    string public suppressedB = unicode"⁦_⁩";
+    // forge-lint: disable-end(rtlo)
+
+    // SHOULD PASS: plain ASCII source, no bidi controls.
+    string public clean = "no bidi here";
+
+    // SHOULD FAIL: LRM/RLM marks (U+200E/U+200F) are also flagged.
+    string public marks = unicode"left‎right‏end";
+    //~^WARN: `U+200E` (Left-to-Right Mark) detected
+    //~|WARN: `U+200F` (Right-to-Left Mark) detected
+}
diff --git a/crates/lint/testdata/Rtlo.stderr b/crates/lint/testdata/Rtlo.stderr
new file mode 100644
index 0000000000000..e9e32d9d703a4
--- /dev/null
+++ b/crates/lint/testdata/Rtlo.stderr
@@ -0,0 +1,192 @@
+warning[rtlo]: `U+202A` (Left-to-Right Embedding) detected
+   ╭▸ ROOT/testdata/Rtlo.sol:LL:CC
+   │
+LL │     string public lre = unicode"�_�";
+   │                                 ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/rtlo
+
+warning[rtlo]: `U+202C` (Pop Directional Formatting) detected
+   ╭▸ ROOT/testdata/Rtlo.sol:LL:CC
+   │
+LL │     string public lre = unicode"�_�";
+   │                                   ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/rtlo
+
+warning[rtlo]: `U+202B` (Right-to-Left Embedding) detected
+   ╭▸ ROOT/testdata/Rtlo.sol:LL:CC
+   │
+LL │     string public rle = unicode"�_�";
+   │                                 ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/rtlo
+
+warning[rtlo]: `U+202C` (Pop Directional Formatting) detected
+   ╭▸ ROOT/testdata/Rtlo.sol:LL:CC
+   │
+LL │     string public rle = unicode"�_�";
+   │                                   ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/rtlo
+
+warning[rtlo]: `U+202A` (Left-to-Right Embedding) detected
+   ╭▸ ROOT/testdata/Rtlo.sol:LL:CC
+   │
+LL │     string public pdf = unicode"��";
+   │                                 ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/rtlo
+
+warning[rtlo]: `U+202C` (Pop Directional Formatting) detected
+   ╭▸ ROOT/testdata/Rtlo.sol:LL:CC
+   │
+LL │     string public pdf = unicode"��";
+   │                                  ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/rtlo
+
+warning[rtlo]: `U+202D` (Left-to-Right Override) detected
+   ╭▸ ROOT/testdata/Rtlo.sol:LL:CC
+   │
+LL │     string public lro = unicode"�_�";
+   │                                 ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/rtlo
+
+warning[rtlo]: `U+202C` (Pop Directional Formatting) detected
+   ╭▸ ROOT/testdata/Rtlo.sol:LL:CC
+   │
+LL │     string public lro = unicode"�_�";
+   │                                   ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/rtlo
+
+warning[rtlo]: `U+202E` (Right-to-Left Override) detected
+   ╭▸ ROOT/testdata/Rtlo.sol:LL:CC
+   │
+LL │     string public rlo = unicode"�_�";
+   │                                 ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/rtlo
+
+warning[rtlo]: `U+202C` (Pop Directional Formatting) detected
+   ╭▸ ROOT/testdata/Rtlo.sol:LL:CC
+   │
+LL │     string public rlo = unicode"�_�";
+   │                                   ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/rtlo
+
+warning[rtlo]: `U+2066` (Left-to-Right Isolate) detected
+   ╭▸ ROOT/testdata/Rtlo.sol:LL:CC
+   │
+LL │     string public lri = unicode"�_�";
+   │                                 ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/rtlo
+
+warning[rtlo]: `U+2069` (Pop Directional Isolate) detected
+   ╭▸ ROOT/testdata/Rtlo.sol:LL:CC
+   │
+LL │     string public lri = unicode"�_�";
+   │                                   ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/rtlo
+
+warning[rtlo]: `U+2067` (Right-to-Left Isolate) detected
+   ╭▸ ROOT/testdata/Rtlo.sol:LL:CC
+   │
+LL │     string public rli = unicode"�_�";
+   │                                 ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/rtlo
+
+warning[rtlo]: `U+2069` (Pop Directional Isolate) detected
+   ╭▸ ROOT/testdata/Rtlo.sol:LL:CC
+   │
+LL │     string public rli = unicode"�_�";
+   │                                   ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/rtlo
+
+warning[rtlo]: `U+2068` (First Strong Isolate) detected
+   ╭▸ ROOT/testdata/Rtlo.sol:LL:CC
+   │
+LL │     string public fsi = unicode"�_�";
+   │                                 ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/rtlo
+
+warning[rtlo]: `U+2069` (Pop Directional Isolate) detected
+   ╭▸ ROOT/testdata/Rtlo.sol:LL:CC
+   │
+LL │     string public fsi = unicode"�_�";
+   │                                   ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/rtlo
+
+warning[rtlo]: `U+2066` (Left-to-Right Isolate) detected
+   ╭▸ ROOT/testdata/Rtlo.sol:LL:CC
+   │
+LL │     string public pdi = unicode"��";
+   │                                 ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/rtlo
+
+warning[rtlo]: `U+2069` (Pop Directional Isolate) detected
+   ╭▸ ROOT/testdata/Rtlo.sol:LL:CC
+   │
+LL │     string public pdi = unicode"��";
+   │                                  ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/rtlo
+
+warning[rtlo]: `U+202E` (Right-to-Left Override) detected
+   ╭▸ ROOT/testdata/Rtlo.sol:LL:CC
+   │
+LL │     /* hidden� /* text � */ uint256 inBlockComment;
+   │              ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/rtlo
+
+warning[rtlo]: `U+202C` (Pop Directional Formatting) detected
+   ╭▸ ROOT/testdata/Rtlo.sol:LL:CC
+   │
+LL │     /* hidden� /* text � */ uint256 inBlockComment;
+   │                        ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/rtlo
+
+warning[rtlo]: `U+202E` (Right-to-Left Override) detected
+   ╭▸ ROOT/testdata/Rtlo.sol:LL:CC
+   │
+LL │     // sneaky� payload � trailing
+   │              ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/rtlo
+
+warning[rtlo]: `U+202C` (Pop Directional Formatting) detected
+   ╭▸ ROOT/testdata/Rtlo.sol:LL:CC
+   │
+LL │     // sneaky� payload � trailing
+   │                        ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/rtlo
+
+warning[rtlo]: `U+200E` (Left-to-Right Mark) detected
+   ╭▸ ROOT/testdata/Rtlo.sol:LL:CC
+   │
+LL │     string public marks = unicode"left‎right‏end";
+   │                                       ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/rtlo
+
+warning[rtlo]: `U+200F` (Right-to-Left Mark) detected
+   ╭▸ ROOT/testdata/Rtlo.sol:LL:CC
+   │
+LL │     string public marks = unicode"left‎right‏end";
+   │                                            ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/rtlo
+
diff --git a/crates/lint/testdata/RtloCommentsOnly.sol b/crates/lint/testdata/RtloCommentsOnly.sol
new file mode 100644
index 0000000000000..a5e7d258d74e5
--- /dev/null
+++ b/crates/lint/testdata/RtloCommentsOnly.sol
@@ -0,0 +1,15 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+// Bidi chars that only appear in comments (outside any item) must still be
+// reported.
+
+// hidden‮ payload ‬ trailing
+//~^WARN: `U+202E` (Right-to-Left Override) detected
+//~|WARN: `U+202C` (Pop Directional Formatting) detected
+
+/* block‮ comment ‬ end */
+//~^WARN: `U+202E` (Right-to-Left Override) detected
+//~|WARN: `U+202C` (Pop Directional Formatting) detected
+
+contract RtloCommentsOnly {}
diff --git a/crates/lint/testdata/RtloCommentsOnly.stderr b/crates/lint/testdata/RtloCommentsOnly.stderr
new file mode 100644
index 0000000000000..06628d65f289f
--- /dev/null
+++ b/crates/lint/testdata/RtloCommentsOnly.stderr
@@ -0,0 +1,32 @@
+warning[rtlo]: `U+202E` (Right-to-Left Override) detected
+   ╭▸ ROOT/testdata/RtloCommentsOnly.sol:LL:CC
+   │
+LL │ // hidden� payload � trailing
+   │          ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/rtlo
+
+warning[rtlo]: `U+202C` (Pop Directional Formatting) detected
+   ╭▸ ROOT/testdata/RtloCommentsOnly.sol:LL:CC
+   │
+LL │ // hidden� payload � trailing
+   │                    ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/rtlo
+
+warning[rtlo]: `U+202E` (Right-to-Left Override) detected
+   ╭▸ ROOT/testdata/RtloCommentsOnly.sol:LL:CC
+   │
+LL │ /* block� comment � end */
+   │         ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/rtlo
+
+warning[rtlo]: `U+202C` (Pop Directional Formatting) detected
+   ╭▸ ROOT/testdata/RtloCommentsOnly.sol:LL:CC
+   │
+LL │ /* block� comment � end */
+   │                   ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/rtlo
+
diff --git a/crates/lint/testdata/ScreamingSnakeCase.sol b/crates/lint/testdata/ScreamingSnakeCase.sol
index ccfe596d9e9dd..8800b00d5768c 100644
--- a/crates/lint/testdata/ScreamingSnakeCase.sol
+++ b/crates/lint/testdata/ScreamingSnakeCase.sol
@@ -1,23 +1,25 @@
+//@compile-flags: --severity info
+
 // SPDX-License-Identifier: MIT
-pragma solidity ^0.8.0;
+pragma solidity ^0.8.18;
 
 contract ScreamingSnakeCaseTest {
     uint256 constant _SCREAMING_SNAKE_CASE = 0;
     uint256 constant SCREAMING_SNAKE_CASE = 0;
     uint256 constant SCREAMINGSNAKECASE = 0;
 
-    uint256 constant screamingSnakeCase = 0; //~NOTE: constants should use SCREAMING_SNAKE_CASE
-    uint256 constant screaming_snake_case = 0; //~NOTE: constants should use SCREAMING_SNAKE_CASE
-    uint256 constant ScreamingSnakeCase = 0; //~NOTE: constants should use SCREAMING_SNAKE_CASE
-    uint256 constant SCREAMING_snake_case = 0; //~NOTE: constants should use SCREAMING_SNAKE_CASE
+    uint256 constant screamingSnakeCase = 0; //~NOTE: constant name is not `SCREAMING_SNAKE_CASE`
+    uint256 constant screaming_snake_case = 0; //~NOTE: constant name is not `SCREAMING_SNAKE_CASE`
+    uint256 constant ScreamingSnakeCase = 0; //~NOTE: constant name is not `SCREAMING_SNAKE_CASE`
+    uint256 constant SCREAMING_snake_case = 0; //~NOTE: constant name is not `SCREAMING_SNAKE_CASE`
 
     uint256 immutable _SCREAMING_SNAKE_CASE_1 = 0;
     uint256 immutable SCREAMING_SNAKE_CASE_1 = 0;
     uint256 immutable SCREAMINGSNAKECASE0 = 0;
     uint256 immutable SCREAMINGSNAKECASE_ = 0;
 
-    uint256 immutable screamingSnakeCase0 = 0; //~NOTE: immutables should use SCREAMING_SNAKE_CASE
-    uint256 immutable screaming_snake_case0 = 0; //~NOTE: immutables should use SCREAMING_SNAKE_CASE
-    uint256 immutable ScreamingSnakeCase0 = 0; //~NOTE: immutables should use SCREAMING_SNAKE_CASE
-    uint256 immutable SCREAMING_snake_case_0 = 0; //~NOTE: immutables should use SCREAMING_SNAKE_CASE
+    uint256 immutable screamingSnakeCase0 = 0; //~NOTE: immutable name is not `SCREAMING_SNAKE_CASE`
+    uint256 immutable screaming_snake_case0 = 0; //~NOTE: immutable name is not `SCREAMING_SNAKE_CASE`
+    uint256 immutable ScreamingSnakeCase0 = 0; //~NOTE: immutable name is not `SCREAMING_SNAKE_CASE`
+    uint256 immutable SCREAMING_snake_case_0 = 0; //~NOTE: immutable name is not `SCREAMING_SNAKE_CASE`
 }
diff --git a/crates/lint/testdata/ScreamingSnakeCase.stderr b/crates/lint/testdata/ScreamingSnakeCase.stderr
index b511f09d254f3..76610659581ae 100644
--- a/crates/lint/testdata/ScreamingSnakeCase.stderr
+++ b/crates/lint/testdata/ScreamingSnakeCase.stderr
@@ -1,64 +1,64 @@
-note[screaming-snake-case-const]: constants should use SCREAMING_SNAKE_CASE
- --> ROOT/testdata/ScreamingSnakeCase.sol:LL:CC
-  |
-9 |     uint256 constant screamingSnakeCase = 0;
-  |                      ------------------
-  |
-  = help: https://book.getfoundry.sh/reference/forge/forge-lint#screaming-snake-case-const
+note[screaming-snake-case-const]: constant name is not `SCREAMING_SNAKE_CASE`
+   ╭▸ ROOT/testdata/ScreamingSnakeCase.sol:LL:CC
+   │
+LL │     uint256 constant screamingSnakeCase = 0;
+   │                      ━━━━━━━━━━━━━━━━━━ help: consider using: `SCREAMING_SNAKE_CASE`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/screaming-snake-case-const
 
-note[screaming-snake-case-const]: constants should use SCREAMING_SNAKE_CASE
-  --> ROOT/testdata/ScreamingSnakeCase.sol:LL:CC
-   |
-10 |     uint256 constant screaming_snake_case = 0;
-   |                      --------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#screaming-snake-case-const
+note[screaming-snake-case-const]: constant name is not `SCREAMING_SNAKE_CASE`
+   ╭▸ ROOT/testdata/ScreamingSnakeCase.sol:LL:CC
+   │
+LL │     uint256 constant screaming_snake_case = 0;
+   │                      ━━━━━━━━━━━━━━━━━━━━ help: consider using: `SCREAMING_SNAKE_CASE`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/screaming-snake-case-const
 
-note[screaming-snake-case-const]: constants should use SCREAMING_SNAKE_CASE
-  --> ROOT/testdata/ScreamingSnakeCase.sol:LL:CC
-   |
-11 |     uint256 constant ScreamingSnakeCase = 0;
-   |                      ------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#screaming-snake-case-const
+note[screaming-snake-case-const]: constant name is not `SCREAMING_SNAKE_CASE`
+   ╭▸ ROOT/testdata/ScreamingSnakeCase.sol:LL:CC
+   │
+LL │     uint256 constant ScreamingSnakeCase = 0;
+   │                      ━━━━━━━━━━━━━━━━━━ help: consider using: `SCREAMING_SNAKE_CASE`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/screaming-snake-case-const
 
-note[screaming-snake-case-const]: constants should use SCREAMING_SNAKE_CASE
-  --> ROOT/testdata/ScreamingSnakeCase.sol:LL:CC
-   |
-12 |     uint256 constant SCREAMING_snake_case = 0;
-   |                      --------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#screaming-snake-case-const
+note[screaming-snake-case-const]: constant name is not `SCREAMING_SNAKE_CASE`
+   ╭▸ ROOT/testdata/ScreamingSnakeCase.sol:LL:CC
+   │
+LL │     uint256 constant SCREAMING_snake_case = 0;
+   │                      ━━━━━━━━━━━━━━━━━━━━ help: consider using: `SCREAMING_SNAKE_CASE`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/screaming-snake-case-const
 
-note[screaming-snake-case-immutable]: immutables should use SCREAMING_SNAKE_CASE
-  --> ROOT/testdata/ScreamingSnakeCase.sol:LL:CC
-   |
-19 |     uint256 immutable screamingSnakeCase0 = 0;
-   |                       -------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#screaming-snake-case-immutable
+note[screaming-snake-case-immutable]: immutable name is not `SCREAMING_SNAKE_CASE`
+   ╭▸ ROOT/testdata/ScreamingSnakeCase.sol:LL:CC
+   │
+LL │     uint256 immutable screamingSnakeCase0 = 0;
+   │                       ━━━━━━━━━━━━━━━━━━━ help: consider using: `SCREAMING_SNAKE_CASE0`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/screaming-snake-case-immutable
 
-note[screaming-snake-case-immutable]: immutables should use SCREAMING_SNAKE_CASE
-  --> ROOT/testdata/ScreamingSnakeCase.sol:LL:CC
-   |
-20 |     uint256 immutable screaming_snake_case0 = 0;
-   |                       ---------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#screaming-snake-case-immutable
+note[screaming-snake-case-immutable]: immutable name is not `SCREAMING_SNAKE_CASE`
+   ╭▸ ROOT/testdata/ScreamingSnakeCase.sol:LL:CC
+   │
+LL │     uint256 immutable screaming_snake_case0 = 0;
+   │                       ━━━━━━━━━━━━━━━━━━━━━ help: consider using: `SCREAMING_SNAKE_CASE0`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/screaming-snake-case-immutable
 
-note[screaming-snake-case-immutable]: immutables should use SCREAMING_SNAKE_CASE
-  --> ROOT/testdata/ScreamingSnakeCase.sol:LL:CC
-   |
-21 |     uint256 immutable ScreamingSnakeCase0 = 0;
-   |                       -------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#screaming-snake-case-immutable
+note[screaming-snake-case-immutable]: immutable name is not `SCREAMING_SNAKE_CASE`
+   ╭▸ ROOT/testdata/ScreamingSnakeCase.sol:LL:CC
+   │
+LL │     uint256 immutable ScreamingSnakeCase0 = 0;
+   │                       ━━━━━━━━━━━━━━━━━━━ help: consider using: `SCREAMING_SNAKE_CASE0`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/screaming-snake-case-immutable
 
-note[screaming-snake-case-immutable]: immutables should use SCREAMING_SNAKE_CASE
-  --> ROOT/testdata/ScreamingSnakeCase.sol:LL:CC
-   |
-22 |     uint256 immutable SCREAMING_snake_case_0 = 0;
-   |                       ----------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#screaming-snake-case-immutable
+note[screaming-snake-case-immutable]: immutable name is not `SCREAMING_SNAKE_CASE`
+   ╭▸ ROOT/testdata/ScreamingSnakeCase.sol:LL:CC
+   │
+LL │     uint256 immutable SCREAMING_snake_case_0 = 0;
+   │                       ━━━━━━━━━━━━━━━━━━━━━━ help: consider using: `SCREAMING_SNAKE_CASE_0`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/screaming-snake-case-immutable
 
diff --git a/crates/lint/testdata/SolarMappingArrayValue.sol b/crates/lint/testdata/SolarMappingArrayValue.sol
new file mode 100644
index 0000000000000..0b6751a698ba4
--- /dev/null
+++ b/crates/lint/testdata/SolarMappingArrayValue.sol
@@ -0,0 +1,16 @@
+//@compile-flags: --only-lint reentrancy-events
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+contract SolarMappingArrayValue {
+    struct Checkpoint {
+        mapping(uint256 => uint32[8]) values;
+    }
+
+    mapping(address => Checkpoint) internal checkpoints;
+
+    function read(address account, uint256 slot, uint256 index) external view returns (uint32) {
+        return checkpoints[account].values[slot][index];
+    }
+}
diff --git a/crates/lint/testdata/SolmateSafeTransferLib.sol b/crates/lint/testdata/SolmateSafeTransferLib.sol
new file mode 100644
index 0000000000000..e3681806c9904
--- /dev/null
+++ b/crates/lint/testdata/SolmateSafeTransferLib.sol
@@ -0,0 +1,152 @@
+//@compile-flags: --only-lint solmate-safe-transfer-lib
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+import {IToken, SafeTransferLib} from "./auxiliary/solmate/SolmateMocks.sol";
+import {SafeTransferLib as STL, ITokenAux} from "./auxiliary/solmate/SolmateSafeTransferLib.sol";
+import {SafeTransferLib as SoladySTL} from "./auxiliary/solady/SafeTransferLib.sol";
+import {SafeTransferLib as FixedSTL, IToken as ITokenFixed} from "./auxiliary/solmate-fixed/SafeTransferLib.sol";
+
+// Tests for `solmate-safe-transfer-lib`: the released solmate v6 `SafeTransferLib` treats a
+// call that returns no data as a success without checking that the token has code, so a token
+// operation against a token-less address is a silent no-op. A reference is flagged when it
+// resolves to `safeTransfer` / `safeTransferFrom` / `safeApprove` declared in a library named
+// exactly `SafeTransferLib` whose source comes from a solmate package path, whatever the call
+// form. `safeTransferETH` involves no token code and stays clean, as do same-name functions
+// declared in other libraries and same-name libraries from other packages (Solady).
+// Note: this file's own path names solmate, so the canonical mirror and the out-of-package
+// probes live in auxiliary files whose paths decide their provenance.
+
+// Same function names in another library (Uniswap's TransferHelper style): out of scope.
+library TransferHelper {
+    function safeTransfer(IToken token, address to, uint256 amount) internal {
+        token.transfer(to, amount);
+    }
+
+    function safeTransferFrom(IToken token, address from, address to, uint256 amount) internal {
+        token.transferFrom(from, to, amount);
+    }
+}
+
+contract UsesSolmate {
+    using SafeTransferLib for IToken;
+
+    IToken internal token;
+
+    function viaUsingFor(address to, uint256 amount) internal {
+        token.safeTransfer(to, amount); //~WARN: the `SafeTransferLib` from Solmate does not check
+    }
+
+    function viaQualified(address from, address to, uint256 amount) internal {
+        SafeTransferLib.safeTransferFrom(token, from, to, amount); //~WARN: the `SafeTransferLib` from Solmate does not check
+    }
+
+    function viaApprove(address spender, uint256 amount) internal {
+        token.safeApprove(spender, amount); //~WARN: the `SafeTransferLib` from Solmate does not check
+    }
+
+    function ethTransferIsClean(address to, uint256 amount) internal {
+        SafeTransferLib.safeTransferETH(to, amount);
+    }
+}
+
+contract UsesAliasedImport {
+    ITokenAux internal token;
+
+    // The import alias renames the call site, not the declared library the call resolves to.
+    function viaAlias(address to, uint256 amount) internal {
+        STL.safeTransfer(token, to, amount); //~WARN: the `SafeTransferLib` from Solmate does not check
+    }
+}
+
+// The same operations through Uniswap-style TransferHelper: out of scope.
+contract UsesTransferHelper {
+    using TransferHelper for IToken;
+
+    IToken internal token;
+
+    function viaHelper(address to, uint256 amount) internal {
+        token.safeTransfer(to, amount);
+    }
+
+    function viaHelperQualified(address from, address to, uint256 amount) internal {
+        TransferHelper.safeTransferFrom(token, from, to, amount);
+    }
+}
+
+// The granular form binds a single function: it resolves to the same declaration.
+contract GranularUsing {
+    using {SafeTransferLib.safeTransfer} for IToken;
+
+    IToken internal token;
+
+    function viaGranular(address to, uint256 amount) internal {
+        token.safeTransfer(to, amount); //~WARN: the `SafeTransferLib` from Solmate does not check
+    }
+}
+
+// Calls in a constructor or a modifier are calls like any other.
+contract EagerPayer {
+    using SafeTransferLib for IToken;
+
+    IToken internal token;
+
+    constructor(address to) {
+        token.safeTransfer(to, 1); //~WARN: the `SafeTransferLib` from Solmate does not check
+    }
+
+    modifier paying(address to, uint256 amount) {
+        token.safeTransfer(to, amount); //~WARN: the `SafeTransferLib` from Solmate does not check
+        _;
+    }
+
+    function noop(address to, uint256 amount) external paying(to, amount) {}
+}
+
+// A free function is analyzed like a contract function.
+function freePay(IToken token, address to, uint256 amount) {
+    SafeTransferLib.safeTransfer(token, to, amount); //~WARN: the `SafeTransferLib` from Solmate does not check
+}
+
+// A reference used as a value is a use of the unchecked operation too.
+contract RefUser {
+    function pick() internal pure returns (function(IToken, address, uint256) internal) {
+        return SafeTransferLib.safeTransfer; //~WARN: the `SafeTransferLib` from Solmate does not check
+    }
+}
+
+// A contract's own `safeTransfer` is not the solmate library: out of scope.
+contract NotALibrary {
+    function safeTransfer(IToken token, address to, uint256 amount) internal {
+        token.transfer(to, amount);
+    }
+
+    function viaContract(IToken token, address to, uint256 amount) internal {
+        safeTransfer(token, to, amount);
+    }
+}
+
+// Solady's SafeTransferLib shares the library and function names but checks token code, and
+// its path does not name solmate: the provenance check keeps it out of scope.
+contract UsesSolady {
+    using SoladySTL for IToken;
+
+    IToken internal token;
+
+    function viaSolady(address to, uint256 amount) internal {
+        token.safeTransfer(to, amount);
+    }
+}
+
+// A same-name `SafeTransferLib` under a `solmate-fixed/` path: the string "solmate" is a
+// substring of that path but not one of its components, so the provenance check must treat
+// it as unrelated code and keep it out of scope.
+contract UsesFixedSolmate {
+    using FixedSTL for ITokenFixed;
+
+    ITokenFixed internal token;
+
+    function viaFixed(address to, uint256 amount) internal {
+        token.safeTransfer(to, amount);
+    }
+}
diff --git a/crates/lint/testdata/SolmateSafeTransferLib.stderr b/crates/lint/testdata/SolmateSafeTransferLib.stderr
new file mode 100644
index 0000000000000..61ca3e48f0a59
--- /dev/null
+++ b/crates/lint/testdata/SolmateSafeTransferLib.stderr
@@ -0,0 +1,72 @@
+warning[solmate-safe-transfer-lib]: the `SafeTransferLib` from Solmate does not check that the token has code, so a transfer to a token-less address succeeds silently
+   ╭▸ ROOT/testdata/SolmateSafeTransferLib.sol:LL:CC
+   │
+LL │         token.safeTransfer(to, amount);
+   │         ━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/solmate-safe-transfer-lib
+
+warning[solmate-safe-transfer-lib]: the `SafeTransferLib` from Solmate does not check that the token has code, so a transfer to a token-less address succeeds silently
+   ╭▸ ROOT/testdata/SolmateSafeTransferLib.sol:LL:CC
+   │
+LL │         SafeTransferLib.safeTransferFrom(token, from, to, amount);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/solmate-safe-transfer-lib
+
+warning[solmate-safe-transfer-lib]: the `SafeTransferLib` from Solmate does not check that the token has code, so a transfer to a token-less address succeeds silently
+   ╭▸ ROOT/testdata/SolmateSafeTransferLib.sol:LL:CC
+   │
+LL │         token.safeApprove(spender, amount);
+   │         ━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/solmate-safe-transfer-lib
+
+warning[solmate-safe-transfer-lib]: the `SafeTransferLib` from Solmate does not check that the token has code, so a transfer to a token-less address succeeds silently
+   ╭▸ ROOT/testdata/SolmateSafeTransferLib.sol:LL:CC
+   │
+LL │         STL.safeTransfer(token, to, amount);
+   │         ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/solmate-safe-transfer-lib
+
+warning[solmate-safe-transfer-lib]: the `SafeTransferLib` from Solmate does not check that the token has code, so a transfer to a token-less address succeeds silently
+   ╭▸ ROOT/testdata/SolmateSafeTransferLib.sol:LL:CC
+   │
+LL │         token.safeTransfer(to, amount);
+   │         ━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/solmate-safe-transfer-lib
+
+warning[solmate-safe-transfer-lib]: the `SafeTransferLib` from Solmate does not check that the token has code, so a transfer to a token-less address succeeds silently
+   ╭▸ ROOT/testdata/SolmateSafeTransferLib.sol:LL:CC
+   │
+LL │         token.safeTransfer(to, 1);
+   │         ━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/solmate-safe-transfer-lib
+
+warning[solmate-safe-transfer-lib]: the `SafeTransferLib` from Solmate does not check that the token has code, so a transfer to a token-less address succeeds silently
+   ╭▸ ROOT/testdata/SolmateSafeTransferLib.sol:LL:CC
+   │
+LL │         token.safeTransfer(to, amount);
+   │         ━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/solmate-safe-transfer-lib
+
+warning[solmate-safe-transfer-lib]: the `SafeTransferLib` from Solmate does not check that the token has code, so a transfer to a token-less address succeeds silently
+   ╭▸ ROOT/testdata/SolmateSafeTransferLib.sol:LL:CC
+   │
+LL │     SafeTransferLib.safeTransfer(token, to, amount);
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/solmate-safe-transfer-lib
+
+warning[solmate-safe-transfer-lib]: the `SafeTransferLib` from Solmate does not check that the token has code, so a transfer to a token-less address succeeds silently
+   ╭▸ ROOT/testdata/SolmateSafeTransferLib.sol:LL:CC
+   │
+LL │         return SafeTransferLib.safeTransfer;
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/solmate-safe-transfer-lib
+
diff --git a/crates/lint/testdata/SoloInterfaces.sol b/crates/lint/testdata/SoloInterfaces.sol
new file mode 100644
index 0000000000000..f9cc2ee8aa235
--- /dev/null
+++ b/crates/lint/testdata/SoloInterfaces.sol
@@ -0,0 +1,15 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+interface SoloInterfaces {
+    function foo() external;
+}
+
+interface I2 {
+    function foo() external;
+}
+
+interface I3 {
+    function foo() external;
+}
+
diff --git a/crates/lint/testdata/StructPascalCase.sol b/crates/lint/testdata/StructPascalCase.sol
index 0ec638efe8211..82647726fc78e 100644
--- a/crates/lint/testdata/StructPascalCase.sol
+++ b/crates/lint/testdata/StructPascalCase.sol
@@ -1,5 +1,7 @@
+//@compile-flags: --severity info
+
 // SPDX-License-Identifier: MIT
-pragma solidity ^0.8.0;
+pragma solidity ^0.8.18;
 
 contract StructPascalCaseTest {
     struct PascalCase {
@@ -10,27 +12,53 @@ contract StructPascalCaseTest {
         uint256 a;
     }
 
-    struct _PascalCase { //~NOTE: structs should use PascalCase
+    // Already valid PascalCase with a preserved leading underscore - not flagged.
+    struct _PascalCase {
+        uint256 a;
+    }
+
+    struct _otherCase { //~NOTE: struct name is not `PascalCase`
+        uint256 a;
+    }
+
+    struct pascalCase { //~NOTE: struct name is not `PascalCase`
+        uint256 a;
+    }
+
+    struct pascalcase { //~NOTE: struct name is not `PascalCase`
+        uint256 a;
+    }
+
+    struct pascal_case { //~NOTE: struct name is not `PascalCase`
+        uint256 a;
+    }
+
+    struct PASCAL_CASE { //~NOTE: struct name is not `PascalCase`
+        uint256 a;
+    }
+
+    struct PASCALCASE { //~NOTE: struct name is not `PascalCase`
         uint256 a;
     }
 
-    struct pascalCase { //~NOTE: structs should use PascalCase
+    // Configured acronym exception ("ERC") - not flagged, with or without preserved underscores.
+    struct ERC20Data {
         uint256 a;
     }
 
-    struct pascalcase { //~NOTE: structs should use PascalCase
+    struct _ERC20Data {
         uint256 a;
     }
 
-    struct pascal_case { //~NOTE: structs should use PascalCase
+    struct ERC20Data_ {
         uint256 a;
     }
 
-    struct PASCAL_CASE { //~NOTE: structs should use PascalCase
+    struct __ERC20Data { //~NOTE: struct name is not `PascalCase`
         uint256 a;
     }
 
-    struct PASCALCASE { //~NOTE: structs should use PascalCase
+    struct ERC20Data__ { //~NOTE: struct name is not `PascalCase`
         uint256 a;
     }
 }
diff --git a/crates/lint/testdata/StructPascalCase.stderr b/crates/lint/testdata/StructPascalCase.stderr
index 7858c18cbf33d..9da0b33ea6c57 100644
--- a/crates/lint/testdata/StructPascalCase.stderr
+++ b/crates/lint/testdata/StructPascalCase.stderr
@@ -1,48 +1,64 @@
-note[pascal-case-struct]: structs should use PascalCase
-  --> ROOT/testdata/StructPascalCase.sol:LL:CC
-   |
-13 |     struct _PascalCase {
-   |            -----------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#pascal-case-struct
-
-note[pascal-case-struct]: structs should use PascalCase
-  --> ROOT/testdata/StructPascalCase.sol:LL:CC
-   |
-17 |     struct pascalCase {
-   |            ----------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#pascal-case-struct
-
-note[pascal-case-struct]: structs should use PascalCase
-  --> ROOT/testdata/StructPascalCase.sol:LL:CC
-   |
-21 |     struct pascalcase {
-   |            ----------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#pascal-case-struct
-
-note[pascal-case-struct]: structs should use PascalCase
-  --> ROOT/testdata/StructPascalCase.sol:LL:CC
-   |
-25 |     struct pascal_case {
-   |            -----------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#pascal-case-struct
-
-note[pascal-case-struct]: structs should use PascalCase
-  --> ROOT/testdata/StructPascalCase.sol:LL:CC
-   |
-29 |     struct PASCAL_CASE {
-   |            -----------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#pascal-case-struct
-
-note[pascal-case-struct]: structs should use PascalCase
-  --> ROOT/testdata/StructPascalCase.sol:LL:CC
-   |
-33 |     struct PASCALCASE {
-   |            ----------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#pascal-case-struct
+note[pascal-case-struct]: struct name is not `PascalCase`
+   ╭▸ ROOT/testdata/StructPascalCase.sol:LL:CC
+   │
+LL │     struct _otherCase {
+   │            ━━━━━━━━━━ help: consider using (notice the capitalization): `_OtherCase`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/pascal-case-struct
+
+note[pascal-case-struct]: struct name is not `PascalCase`
+   ╭▸ ROOT/testdata/StructPascalCase.sol:LL:CC
+   │
+LL │     struct pascalCase {
+   │            ━━━━━━━━━━ help: consider using: `PascalCase`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/pascal-case-struct
+
+note[pascal-case-struct]: struct name is not `PascalCase`
+   ╭▸ ROOT/testdata/StructPascalCase.sol:LL:CC
+   │
+LL │     struct pascalcase {
+   │            ━━━━━━━━━━ help: consider using: `Pascalcase`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/pascal-case-struct
+
+note[pascal-case-struct]: struct name is not `PascalCase`
+   ╭▸ ROOT/testdata/StructPascalCase.sol:LL:CC
+   │
+LL │     struct pascal_case {
+   │            ━━━━━━━━━━━ help: consider using: `PascalCase`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/pascal-case-struct
+
+note[pascal-case-struct]: struct name is not `PascalCase`
+   ╭▸ ROOT/testdata/StructPascalCase.sol:LL:CC
+   │
+LL │     struct PASCAL_CASE {
+   │            ━━━━━━━━━━━ help: consider using: `PascalCase`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/pascal-case-struct
+
+note[pascal-case-struct]: struct name is not `PascalCase`
+   ╭▸ ROOT/testdata/StructPascalCase.sol:LL:CC
+   │
+LL │     struct PASCALCASE {
+   │            ━━━━━━━━━━ help: consider using: `Pascalcase`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/pascal-case-struct
+
+note[pascal-case-struct]: struct name is not `PascalCase`
+   ╭▸ ROOT/testdata/StructPascalCase.sol:LL:CC
+   │
+LL │     struct __ERC20Data {
+   │            ━━━━━━━━━━━ help: consider using: `_Erc20Data`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/pascal-case-struct
+
+note[pascal-case-struct]: struct name is not `PascalCase`
+   ╭▸ ROOT/testdata/StructPascalCase.sol:LL:CC
+   │
+LL │     struct ERC20Data__ {
+   │            ━━━━━━━━━━━ help: consider using: `Erc20Data_`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/pascal-case-struct
 
diff --git a/crates/lint/testdata/TautologicalCompare.sol b/crates/lint/testdata/TautologicalCompare.sol
new file mode 100644
index 0000000000000..083acc399d936
--- /dev/null
+++ b/crates/lint/testdata/TautologicalCompare.sol
@@ -0,0 +1,75 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+// Tests for the `tautological-compare` lint, which flags a relational or equality comparison whose
+// two sides are the same side-effect-free expression (always true or false).
+
+type Weird is uint256;
+
+function weirdEq(Weird a, Weird b) pure returns (bool) {
+    return Weird.unwrap(a) + Weird.unwrap(b) > 10;
+}
+
+using {weirdEq as ==} for Weird global;
+
+contract TautologicalCompare {
+    mapping(uint256 => uint256) internal m;
+
+    function pick(uint256 v) internal view returns (uint256) {
+        return m[v];
+    }
+
+    function bad(
+        uint256 x,
+        uint256 y,
+        uint256 i,
+        uint256[] calldata arr,
+        int256 s
+    ) external view {
+        require(x >= x); //~WARN: comparing an expression with itself is always true or false
+        require(y == y); //~WARN: comparing an expression with itself is always true or false
+        if (arr[i] < arr[i]) {} //~WARN: comparing an expression with itself is always true or false
+        if (m[x] != m[x]) {} //~WARN: comparing an expression with itself is always true or false
+        if (msg.sender == msg.sender) {} //~WARN: comparing an expression with itself is always true or false
+        require(x <= (x)); //~WARN: comparing an expression with itself is always true or false
+        if (arr[0] < arr[0]) {} //~WARN: comparing an expression with itself is always true or false
+        if (m[1] != m[1]) {} //~WARN: comparing an expression with itself is always true or false
+        if (address(this) == address(this)) {} //~WARN: comparing an expression with itself is always true or false
+        if (uint256(x) == uint256(x)) {} //~WARN: comparing an expression with itself is always true or false
+        if (-s == -s) {} //~WARN: comparing an expression with itself is always true or false
+    }
+
+    // Split from `bad` to keep each function below the cyclomatic-complexity threshold.
+    function badOperators(uint256 x, uint256 y, bool flag, address addr) external pure {
+        if (!flag == !flag) {} //~WARN: comparing an expression with itself is always true or false
+        if (~x == ~x) {} //~WARN: comparing an expression with itself is always true or false
+        if ((flag ? x : y) == (flag ? x : y)) {} //~WARN: comparing an expression with itself is always true or false
+        if (payable(addr) == payable(addr)) {} //~WARN: comparing an expression with itself is always true or false
+        if ((x + y) == (x + y)) {} //~WARN: comparing an expression with itself is always true or false
+        if ((x & y) == (x & y)) {} //~WARN: comparing an expression with itself is always true or false
+        if ((x < y) == (x < y)) {} //~WARN: comparing an expression with itself is always true or false
+    }
+
+    function ok(uint256 x, uint256 y, uint256 i, uint256 j, uint256[] calldata arr, bool flag)
+        external
+        view
+    {
+        require(x >= y); // different identifiers
+        if (arr[i] < arr[j]) {} // different index
+        require(pick(x) == pick(x)); // calls excluded: sides may differ
+        require(x + 1 > x); // not a self-comparison
+        require(uint256(x) == uint256(y)); // same cast, different operand
+        // forge-lint: disable-next-line(unsafe-typecast)
+        require(uint256(x) == uint8(x)); // casts to different types: uint8 can truncate, not tautological
+        if (++x == ++x) {} // `++` mutates: not tautological
+        if ((flag ? x : y) == (flag ? y : x)) {} // different ternary branches
+        if ((x + y) == (x - y)) {} // different binary operator
+        if ((pick(x) + y) == (pick(x) + y)) {} // call operand: sides may differ
+    }
+
+    // A user-defined `==` (see `using {weirdEq as ==}`) dispatches to `weirdEq`, which may return
+    // either value, so the comparison is not tautological and must not be flagged.
+    function okUserDefinedOperator(Weird w) external pure {
+        require(w == w);
+    }
+}
diff --git a/crates/lint/testdata/TautologicalCompare.stderr b/crates/lint/testdata/TautologicalCompare.stderr
new file mode 100644
index 0000000000000..50c42574d04c2
--- /dev/null
+++ b/crates/lint/testdata/TautologicalCompare.stderr
@@ -0,0 +1,144 @@
+warning[tautological-compare]: comparing an expression with itself is always true or false
+   ╭▸ ROOT/testdata/TautologicalCompare.sol:LL:CC
+   │
+LL │         require(x >= x);
+   │                 ━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/tautological-compare
+
+warning[tautological-compare]: comparing an expression with itself is always true or false
+   ╭▸ ROOT/testdata/TautologicalCompare.sol:LL:CC
+   │
+LL │         require(y == y);
+   │                 ━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/tautological-compare
+
+warning[tautological-compare]: comparing an expression with itself is always true or false
+   ╭▸ ROOT/testdata/TautologicalCompare.sol:LL:CC
+   │
+LL │         if (arr[i] < arr[i]) {}
+   │             ━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/tautological-compare
+
+warning[tautological-compare]: comparing an expression with itself is always true or false
+   ╭▸ ROOT/testdata/TautologicalCompare.sol:LL:CC
+   │
+LL │         if (m[x] != m[x]) {}
+   │             ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/tautological-compare
+
+warning[tautological-compare]: comparing an expression with itself is always true or false
+   ╭▸ ROOT/testdata/TautologicalCompare.sol:LL:CC
+   │
+LL │         if (msg.sender == msg.sender) {}
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/tautological-compare
+
+warning[tautological-compare]: comparing an expression with itself is always true or false
+   ╭▸ ROOT/testdata/TautologicalCompare.sol:LL:CC
+   │
+LL │         require(x <= (x));
+   │                 ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/tautological-compare
+
+warning[tautological-compare]: comparing an expression with itself is always true or false
+   ╭▸ ROOT/testdata/TautologicalCompare.sol:LL:CC
+   │
+LL │         if (arr[0] < arr[0]) {}
+   │             ━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/tautological-compare
+
+warning[tautological-compare]: comparing an expression with itself is always true or false
+   ╭▸ ROOT/testdata/TautologicalCompare.sol:LL:CC
+   │
+LL │         if (m[1] != m[1]) {}
+   │             ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/tautological-compare
+
+warning[tautological-compare]: comparing an expression with itself is always true or false
+   ╭▸ ROOT/testdata/TautologicalCompare.sol:LL:CC
+   │
+LL │         if (address(this) == address(this)) {}
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/tautological-compare
+
+warning[tautological-compare]: comparing an expression with itself is always true or false
+   ╭▸ ROOT/testdata/TautologicalCompare.sol:LL:CC
+   │
+LL │         if (uint256(x) == uint256(x)) {}
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/tautological-compare
+
+warning[tautological-compare]: comparing an expression with itself is always true or false
+   ╭▸ ROOT/testdata/TautologicalCompare.sol:LL:CC
+   │
+LL │         if (-s == -s) {}
+   │             ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/tautological-compare
+
+warning[tautological-compare]: comparing an expression with itself is always true or false
+   ╭▸ ROOT/testdata/TautologicalCompare.sol:LL:CC
+   │
+LL │         if (!flag == !flag) {}
+   │             ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/tautological-compare
+
+warning[tautological-compare]: comparing an expression with itself is always true or false
+   ╭▸ ROOT/testdata/TautologicalCompare.sol:LL:CC
+   │
+LL │         if (~x == ~x) {}
+   │             ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/tautological-compare
+
+warning[tautological-compare]: comparing an expression with itself is always true or false
+   ╭▸ ROOT/testdata/TautologicalCompare.sol:LL:CC
+   │
+LL │         if ((flag ? x : y) == (flag ? x : y)) {}
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/tautological-compare
+
+warning[tautological-compare]: comparing an expression with itself is always true or false
+   ╭▸ ROOT/testdata/TautologicalCompare.sol:LL:CC
+   │
+LL │         if (payable(addr) == payable(addr)) {}
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/tautological-compare
+
+warning[tautological-compare]: comparing an expression with itself is always true or false
+   ╭▸ ROOT/testdata/TautologicalCompare.sol:LL:CC
+   │
+LL │         if ((x + y) == (x + y)) {}
+   │             ━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/tautological-compare
+
+warning[tautological-compare]: comparing an expression with itself is always true or false
+   ╭▸ ROOT/testdata/TautologicalCompare.sol:LL:CC
+   │
+LL │         if ((x & y) == (x & y)) {}
+   │             ━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/tautological-compare
+
+warning[tautological-compare]: comparing an expression with itself is always true or false
+   ╭▸ ROOT/testdata/TautologicalCompare.sol:LL:CC
+   │
+LL │         if ((x < y) == (x < y)) {}
+   │             ━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/tautological-compare
+
diff --git a/crates/lint/testdata/Todo.sol b/crates/lint/testdata/Todo.sol
new file mode 100644
index 0000000000000..9f7b036264e1d
--- /dev/null
+++ b/crates/lint/testdata/Todo.sol
@@ -0,0 +1,100 @@
+//@compile-flags: --only-lint todo-comment
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+contract Todo {
+    // see TODO_ITEMS_LIMIT for the cap
+    uint256 constant TODO_ITEM_LIST = 10;
+
+    uint8 x = 1; //TODO: validate this
+
+    // ToDo: implement access control
+    function unfinished() public {}
+
+    // todo-list should not be treated as an unresolved marker if whole words are required
+    function todolist() public {}
+
+    // Read todo.md before editing this contract
+    function todoMarkdown() public {}
+
+    // FixMe: this is broken
+    function buggy() public {}
+
+    // TODO. This should still be treated as an unresolved marker
+    function sentenceMarker() public {}
+
+    /*TODO(alice): this one should be fixed */
+    function buggy2() public {}
+
+    /* ToDo: revisit this math */
+    function math() public {}
+
+    /// ToDo: document this properly
+    function documented() public {}
+
+    // check other tickets in todo list
+    function todoInBetween() public {}
+
+    // FIXME first, TODO second, and fixme third
+    function combined() public {}
+
+    // TODO: first, todo: second, FIXME: third, fixme: fourth
+    function deduplicated() public {}
+
+    // a perfectly normal comment, no markers
+    function clean() public {}
+
+    // forge-lint: disable-next-line(todo-comment)
+    // TODO: this marker is intentionally suppressed
+    function suppressed() public {}
+
+    // TODO
+    function bareMarker() public {}
+
+    // TODO implement access control
+    function bareTodoSentence() public {}
+
+    // FIXME this check is wrong
+    function bareFixmeSentence() public {}
+
+    // check this later TODO
+    function trailingBare() public {}
+
+    /*
+        TODO: this is a block comment with a marker, should be detected
+    */
+    function blockComment() public {}
+
+    /* Context for this block comment.
+     * TODO implement the starred block-comment work
+     */
+    function starredBareBlockComment() public {}
+
+    /* Context for this block comment.
+       FIXME implement the plain block-comment work
+    */
+    function plainBareBlockComment() public {}
+
+    /// @notice This function does something important
+    /// @dev TODO: implement the actual logic here
+    function natSpec() public pure returns (uint256) {
+        return 42;
+    }
+
+    ///@dev TODO: compact NatSpec should also be detected
+    function compactNatSpec() public {}
+
+    /// @dev TODO implement the actual logic here
+    function bareNatSpec() public {}
+
+    /**
+     * @dev TODO implement the block NatSpec logic here
+     */
+    function bareBlockNatSpec() public {}
+
+    function noFalsePositiveInStrings() public pure returns (string memory) {
+        // The marker below is inside a string literal, must NOT fire:
+        return "this TODO is just data, not a comment";
+    }
+}
diff --git a/crates/lint/testdata/Todo.stderr b/crates/lint/testdata/Todo.stderr
new file mode 100644
index 0000000000000..2a8739d01d219
--- /dev/null
+++ b/crates/lint/testdata/Todo.stderr
@@ -0,0 +1,160 @@
+note[todo-comment]: unresolved `TODO` comment
+   ╭▸ ROOT/testdata/Todo.sol:LL:CC
+   │
+LL │     uint8 x = 1; //TODO: validate this
+   │                  ━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/todo-comment
+
+note[todo-comment]: unresolved `TODO` comment
+   ╭▸ ROOT/testdata/Todo.sol:LL:CC
+   │
+LL │     // ToDo: implement access control
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/todo-comment
+
+note[todo-comment]: unresolved `FIXME` comment
+   ╭▸ ROOT/testdata/Todo.sol:LL:CC
+   │
+LL │     // FixMe: this is broken
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/todo-comment
+
+note[todo-comment]: unresolved `TODO` comment
+   ╭▸ ROOT/testdata/Todo.sol:LL:CC
+   │
+LL │     // TODO. This should still be treated as an unresolved marker
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/todo-comment
+
+note[todo-comment]: unresolved `TODO` comment
+   ╭▸ ROOT/testdata/Todo.sol:LL:CC
+   │
+LL │     /*TODO(alice): this one should be fixed */
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/todo-comment
+
+note[todo-comment]: unresolved `TODO` comment
+   ╭▸ ROOT/testdata/Todo.sol:LL:CC
+   │
+LL │     /* ToDo: revisit this math */
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/todo-comment
+
+note[todo-comment]: unresolved `TODO` comment
+   ╭▸ ROOT/testdata/Todo.sol:LL:CC
+   │
+LL │     /// ToDo: document this properly
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/todo-comment
+
+note[todo-comment]: unresolved `FIXME` comment
+   ╭▸ ROOT/testdata/Todo.sol:LL:CC
+   │
+LL │     // FIXME first, TODO second, and fixme third
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/todo-comment
+
+note[todo-comment]: unresolved `TODO, FIXME` comments
+   ╭▸ ROOT/testdata/Todo.sol:LL:CC
+   │
+LL │     // TODO: first, todo: second, FIXME: third, fixme: fourth
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/todo-comment
+
+note[todo-comment]: unresolved `TODO` comment
+   ╭▸ ROOT/testdata/Todo.sol:LL:CC
+   │
+LL │     // TODO
+   │     ━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/todo-comment
+
+note[todo-comment]: unresolved `TODO` comment
+   ╭▸ ROOT/testdata/Todo.sol:LL:CC
+   │
+LL │     // TODO implement access control
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/todo-comment
+
+note[todo-comment]: unresolved `FIXME` comment
+   ╭▸ ROOT/testdata/Todo.sol:LL:CC
+   │
+LL │     // FIXME this check is wrong
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/todo-comment
+
+note[todo-comment]: unresolved `TODO` comment
+   ╭▸ ROOT/testdata/Todo.sol:LL:CC
+   │
+LL │ ┏     /*
+LL │ ┃         TODO: this is a block comment with a marker, should be detected
+LL │ ┃     */
+   │ ┗━━━━━━┛
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/todo-comment
+
+note[todo-comment]: unresolved `TODO` comment
+   ╭▸ ROOT/testdata/Todo.sol:LL:CC
+   │
+LL │ ┏     /* Context for this block comment.
+LL │ ┃      * TODO implement the starred block-comment work
+LL │ ┃      */
+   │ ┗━━━━━━━┛
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/todo-comment
+
+note[todo-comment]: unresolved `FIXME` comment
+   ╭▸ ROOT/testdata/Todo.sol:LL:CC
+   │
+LL │ ┏     /* Context for this block comment.
+LL │ ┃        FIXME implement the plain block-comment work
+LL │ ┃     */
+   │ ┗━━━━━━┛
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/todo-comment
+
+note[todo-comment]: unresolved `TODO` comment
+   ╭▸ ROOT/testdata/Todo.sol:LL:CC
+   │
+LL │     /// @dev TODO: implement the actual logic here
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/todo-comment
+
+note[todo-comment]: unresolved `TODO` comment
+   ╭▸ ROOT/testdata/Todo.sol:LL:CC
+   │
+LL │     ///@dev TODO: compact NatSpec should also be detected
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/todo-comment
+
+note[todo-comment]: unresolved `TODO` comment
+   ╭▸ ROOT/testdata/Todo.sol:LL:CC
+   │
+LL │     /// @dev TODO implement the actual logic here
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/todo-comment
+
+note[todo-comment]: unresolved `TODO` comment
+   ╭▸ ROOT/testdata/Todo.sol:LL:CC
+   │
+LL │ ┏     /**
+LL │ ┃      * @dev TODO implement the block NatSpec logic here
+LL │ ┃      */
+   │ ┗━━━━━━━┛
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/todo-comment
+
diff --git a/crates/lint/testdata/TooManyDigits.sol b/crates/lint/testdata/TooManyDigits.sol
new file mode 100644
index 0000000000000..35651a4f697b9
--- /dev/null
+++ b/crates/lint/testdata/TooManyDigits.sol
@@ -0,0 +1,86 @@
+//@compile-flags: --only-lint too-many-digits inline-assembly
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+contract TooManyDigits {
+    // SHOULD FAIL: plain decimal integer literals with 5+ consecutive zeros.
+
+    uint256 stateA = 1000000000000000000; //~NOTE: numeric literal contains a long sequence of zeros
+    uint256 stateB = 100000; //~NOTE: numeric literal contains a long sequence of zeros
+    uint256 fortyDigitDecimal = 1000000000000000000000000000000000000000; //~NOTE: numeric literal contains a long sequence of zeros
+
+    function asReturn() public pure returns (uint256) {
+        return 10000000; //~NOTE: numeric literal contains a long sequence of zeros
+    }
+
+    function asComparison(uint256 x) public pure returns (bool) {
+        return x == 1000000; //~NOTE: numeric literal contains a long sequence of zeros
+    }
+
+    function asArg(address to) public {
+        _send(to, 50000000000); //~NOTE: numeric literal contains a long sequence of zeros
+    }
+
+    function asArraySize() public pure {
+        uint256[100000] memory _arr; //~NOTE: numeric literal contains a long sequence of zeros
+    }
+
+    // Zero-run in the middle (not just trailing).
+    uint256 middleZeros = 123000007; //~NOTE: numeric literal contains a long sequence of zeros
+
+    // Underscores that don't actually break up the zero run.
+    uint256 badGrouping = 1_000000; //~NOTE: numeric literal contains a long sequence of zeros
+
+    // Underscore right after a single digit, leaving a 5-zero group.
+    uint256 badGrouping2 = 1_00000; //~NOTE: numeric literal contains a long sequence of zeros
+
+    // SHOULD PASS:
+
+    // Boundary: 4 consecutive zeros (one short of the threshold).
+    uint256 fourZeros = 10000;
+
+    // Uppercase scientific notation.
+    uint256 sciUpper = 1E18;
+
+    // Scientific notation.
+    uint256 sci = 1e18;
+
+    // Underscore-separated digit groups.
+    uint256 grouped = 1_000_000_000_000_000_000;
+
+    // Sub-denominations.
+    uint256 oneEther = 1 ether;
+    uint256 oneGwei = 1 gwei;
+    uint256 fiveMin = 5 minutes;
+
+    // Address literal (distinct AST kind, not flagged).
+    address adr = 0x1234567890123456789012345678901234567890;
+
+    // Non-address hex literals with long zero runs are flagged for Slither parity.
+    bytes32 mask = 0x0000000000000000000000000000000000000000000000000000000000000001; //~NOTE: numeric literal contains a long sequence of zeros
+    uint256 hexNum = 0x100000; //~NOTE: numeric literal contains a long sequence of zeros
+    uint256 hexWithE = 0x7fffffe07fffffe03ff000000000000; //~NOTE: numeric literal contains a long sequence of zeros
+    uint256 hexWithUpperE = 0x7FFFFFE07FFFFFE03FF000000000000; //~NOTE: numeric literal contains a long sequence of zeros
+
+    function inAssembly() public pure returns (uint256 result) {
+        assembly {
+            result := 100000 //~NOTE: numeric literal contains a long sequence of zeros
+            result := add(result, 0x100000) //~NOTE: numeric literal contains a long sequence of zeros
+            result := add(result, 0x7fffffe07fffffe03ff000000000000) //~NOTE: numeric literal contains a long sequence of zeros
+            result := add(result, 10000)
+            result := add(result, 0x1234567890123456789012345678901234567890)
+        }
+    }
+
+    // Small literals (< 5 consecutive zeros).
+    uint256 small1 = 100;
+    uint256 small2 = 9999;
+    uint256 small3 = 1234;
+    uint256 spread = 101010;
+
+    // Boolean literal.
+    bool flag = true;
+
+    function _send(address, uint256) internal pure {}
+}
diff --git a/crates/lint/testdata/TooManyDigits.stderr b/crates/lint/testdata/TooManyDigits.stderr
new file mode 100644
index 0000000000000..700e14513249c
--- /dev/null
+++ b/crates/lint/testdata/TooManyDigits.stderr
@@ -0,0 +1,144 @@
+note[too-many-digits]: numeric literal contains a long sequence of zeros
+   ╭▸ ROOT/testdata/TooManyDigits.sol:LL:CC
+   │
+LL │     uint256 stateA = 1000000000000000000;
+   │                      ━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/too-many-digits
+
+note[too-many-digits]: numeric literal contains a long sequence of zeros
+   ╭▸ ROOT/testdata/TooManyDigits.sol:LL:CC
+   │
+LL │     uint256 stateB = 100000;
+   │                      ━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/too-many-digits
+
+note[too-many-digits]: numeric literal contains a long sequence of zeros
+   ╭▸ ROOT/testdata/TooManyDigits.sol:LL:CC
+   │
+LL │     uint256 fortyDigitDecimal = 1000000000000000000000000000000000000000;
+   │                                 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/too-many-digits
+
+note[too-many-digits]: numeric literal contains a long sequence of zeros
+   ╭▸ ROOT/testdata/TooManyDigits.sol:LL:CC
+   │
+LL │         return 10000000;
+   │                ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/too-many-digits
+
+note[too-many-digits]: numeric literal contains a long sequence of zeros
+   ╭▸ ROOT/testdata/TooManyDigits.sol:LL:CC
+   │
+LL │         return x == 1000000;
+   │                     ━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/too-many-digits
+
+note[too-many-digits]: numeric literal contains a long sequence of zeros
+   ╭▸ ROOT/testdata/TooManyDigits.sol:LL:CC
+   │
+LL │         _send(to, 50000000000);
+   │                   ━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/too-many-digits
+
+note[too-many-digits]: numeric literal contains a long sequence of zeros
+   ╭▸ ROOT/testdata/TooManyDigits.sol:LL:CC
+   │
+LL │         uint256[100000] memory _arr;
+   │                 ━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/too-many-digits
+
+note[too-many-digits]: numeric literal contains a long sequence of zeros
+   ╭▸ ROOT/testdata/TooManyDigits.sol:LL:CC
+   │
+LL │     uint256 middleZeros = 123000007;
+   │                           ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/too-many-digits
+
+note[too-many-digits]: numeric literal contains a long sequence of zeros
+   ╭▸ ROOT/testdata/TooManyDigits.sol:LL:CC
+   │
+LL │     uint256 badGrouping = 1_000000;
+   │                           ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/too-many-digits
+
+note[too-many-digits]: numeric literal contains a long sequence of zeros
+   ╭▸ ROOT/testdata/TooManyDigits.sol:LL:CC
+   │
+LL │     uint256 badGrouping2 = 1_00000;
+   │                            ━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/too-many-digits
+
+note[too-many-digits]: numeric literal contains a long sequence of zeros
+   ╭▸ ROOT/testdata/TooManyDigits.sol:LL:CC
+   │
+LL │     bytes32 mask = 0x0000000000000000000000000000000000000000000000000000000000000001;
+   │                    ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/too-many-digits
+
+note[too-many-digits]: numeric literal contains a long sequence of zeros
+   ╭▸ ROOT/testdata/TooManyDigits.sol:LL:CC
+   │
+LL │     uint256 hexNum = 0x100000;
+   │                      ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/too-many-digits
+
+note[too-many-digits]: numeric literal contains a long sequence of zeros
+   ╭▸ ROOT/testdata/TooManyDigits.sol:LL:CC
+   │
+LL │     uint256 hexWithE = 0x7fffffe07fffffe03ff000000000000;
+   │                        ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/too-many-digits
+
+note[too-many-digits]: numeric literal contains a long sequence of zeros
+   ╭▸ ROOT/testdata/TooManyDigits.sol:LL:CC
+   │
+LL │     uint256 hexWithUpperE = 0x7FFFFFE07FFFFFE03FF000000000000;
+   │                             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/too-many-digits
+
+note[too-many-digits]: numeric literal contains a long sequence of zeros
+   ╭▸ ROOT/testdata/TooManyDigits.sol:LL:CC
+   │
+LL │             result := 100000
+   │                       ━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/too-many-digits
+
+note[too-many-digits]: numeric literal contains a long sequence of zeros
+   ╭▸ ROOT/testdata/TooManyDigits.sol:LL:CC
+   │
+LL │             result := add(result, 0x100000)
+   │                                   ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/too-many-digits
+
+note[too-many-digits]: numeric literal contains a long sequence of zeros
+   ╭▸ ROOT/testdata/TooManyDigits.sol:LL:CC
+   │
+LL │             result := add(result, 0x7fffffe07fffffe03ff000000000000)
+   │                                   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/too-many-digits
+
+note[inline-assembly]: inline assembly used
+   ╭▸ ROOT/testdata/TooManyDigits.sol:LL:CC
+   │
+LL │         assembly {
+   │         ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/inline-assembly
+
diff --git a/crates/lint/testdata/TxOrigin.sol b/crates/lint/testdata/TxOrigin.sol
new file mode 100644
index 0000000000000..48518fd66bb45
--- /dev/null
+++ b/crates/lint/testdata/TxOrigin.sol
@@ -0,0 +1,65 @@
+//@compile-flags: --only-lint tx-origin
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+contract TxOrigin {
+    address public owner;
+    mapping(address => bool) public allowed;
+
+    constructor() {
+        owner = msg.sender;
+    }
+
+    modifier onlyOwner() {
+        require(tx.origin == owner, "not owner"); //~WARN: `tx.origin` is used for authorization
+        _;
+    }
+
+    function guardedByIf() external view {
+        if (tx.origin != owner) { //~WARN: `tx.origin` is used for authorization
+            revert("not owner");
+        }
+    }
+
+    function guardedByPredicate() external view {
+        assert(isOwner(tx.origin)); //~WARN: `tx.origin` is used for authorization
+    }
+
+    function guardedByWhile() external view {
+        while (tx.origin == owner) { //~WARN: `tx.origin` is used for authorization
+            break;
+        }
+    }
+
+    function guardedByFor() external view {
+        for (; tx.origin == owner;) { //~WARN: `tx.origin` is used for authorization
+            break;
+        }
+    }
+
+    function guardedByDoWhile() external view {
+        do {
+        } while (tx.origin == owner); //~WARN: `tx.origin` is used for authorization
+    }
+
+    function guardedByMapping() external view {
+        require(allowed[tx.origin], "not allowed"); //~WARN: `tx.origin` is used for authorization
+        require(allowed[tx.origin] == true, "not allowed"); //~WARN: `tx.origin` is used for authorization
+    }
+
+    function guardedByTernary() external view {
+        require(tx.origin == owner ? true : false, "not owner"); //~WARN: `tx.origin` is used for authorization
+    }
+
+    function readForLogging() external view returns (address) {
+        return tx.origin;
+    }
+
+    function explicitSenderCheck() external view {
+        require(msg.sender == owner, "not owner");
+    }
+
+    function isOwner(address account) internal view returns (bool) {
+        return account == owner;
+    }
+}
diff --git a/crates/lint/testdata/TxOrigin.stderr b/crates/lint/testdata/TxOrigin.stderr
new file mode 100644
index 0000000000000..a2fa1d08171eb
--- /dev/null
+++ b/crates/lint/testdata/TxOrigin.stderr
@@ -0,0 +1,72 @@
+warning[tx-origin]: `tx.origin` is used for authorization
+   ╭▸ ROOT/testdata/TxOrigin.sol:LL:CC
+   │
+LL │         require(tx.origin == owner, "not owner");
+   │                 ━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/tx-origin
+
+warning[tx-origin]: `tx.origin` is used for authorization
+   ╭▸ ROOT/testdata/TxOrigin.sol:LL:CC
+   │
+LL │         if (tx.origin != owner) {
+   │             ━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/tx-origin
+
+warning[tx-origin]: `tx.origin` is used for authorization
+   ╭▸ ROOT/testdata/TxOrigin.sol:LL:CC
+   │
+LL │         assert(isOwner(tx.origin));
+   │                ━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/tx-origin
+
+warning[tx-origin]: `tx.origin` is used for authorization
+   ╭▸ ROOT/testdata/TxOrigin.sol:LL:CC
+   │
+LL │         while (tx.origin == owner) {
+   │                ━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/tx-origin
+
+warning[tx-origin]: `tx.origin` is used for authorization
+   ╭▸ ROOT/testdata/TxOrigin.sol:LL:CC
+   │
+LL │         for (; tx.origin == owner;) {
+   │                ━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/tx-origin
+
+warning[tx-origin]: `tx.origin` is used for authorization
+   ╭▸ ROOT/testdata/TxOrigin.sol:LL:CC
+   │
+LL │         } while (tx.origin == owner);
+   │                  ━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/tx-origin
+
+warning[tx-origin]: `tx.origin` is used for authorization
+   ╭▸ ROOT/testdata/TxOrigin.sol:LL:CC
+   │
+LL │         require(allowed[tx.origin], "not allowed");
+   │                 ━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/tx-origin
+
+warning[tx-origin]: `tx.origin` is used for authorization
+   ╭▸ ROOT/testdata/TxOrigin.sol:LL:CC
+   │
+LL │         require(allowed[tx.origin] == true, "not allowed");
+   │                 ━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/tx-origin
+
+warning[tx-origin]: `tx.origin` is used for authorization
+   ╭▸ ROOT/testdata/TxOrigin.sol:LL:CC
+   │
+LL │         require(tx.origin == owner ? true : false, "not owner");
+   │                 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/tx-origin
+
diff --git a/crates/lint/testdata/TypeBasedTautology.sol b/crates/lint/testdata/TypeBasedTautology.sol
new file mode 100644
index 0000000000000..c9f8d9da9787a
--- /dev/null
+++ b/crates/lint/testdata/TypeBasedTautology.sol
@@ -0,0 +1,213 @@
+//@compile-flags: --only-lint type-based-tautology
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+contract TypeBasedTautology {
+    // --- uint: comparisons against 0 ---
+
+    function uintGeZero(uint256 x) public pure returns (bool) {
+        return x >= 0; //~WARN: condition is always true or false based on the variable's type
+    }
+
+    function uintLtZero(uint256 x) public pure returns (bool) {
+        return x < 0; //~WARN: condition is always true or false based on the variable's type
+    }
+
+    function uintGtZero(uint256 x) public pure returns (bool) {
+        return x > 0; // ok – can be false when x == 0
+    }
+
+    function uintLeZero(uint256 x) public pure returns (bool) {
+        return x <= 0; // ok, equivalent to x == 0, not a tautology
+    }
+
+    // --- uint: comparisons with out-of-range constants ---
+
+    function uint8LtMax256(uint8 x) public pure returns (bool) {
+        return x < 256; //~WARN: condition is always true or false based on the variable's type
+    }
+
+    function uint8GtMax255(uint8 x) public pure returns (bool) {
+        return x > 255; //~WARN: condition is always true or false based on the variable's type
+    }
+
+    function uint8GeMax256(uint8 x) public pure returns (bool) {
+        return x >= 256; //~WARN: condition is always true or false based on the variable's type
+    }
+
+    function uint8LeMax255(uint8 x) public pure returns (bool) {
+        return x <= 255; //~WARN: condition is always true or false based on the variable's type
+    }
+
+    function uint8GtMax254(uint8 x) public pure returns (bool) {
+        return x > 254; // ok – false when x == 254 or below
+    }
+
+    function uint8LtMax255(uint8 x) public pure returns (bool) {
+        return x < 255; // ok – false when x == 255
+    }
+
+    // --- flipped operands (constant on left) ---
+
+    function zeroGtUint(uint256 x) public pure returns (bool) {
+        return 0 > x; //~WARN: condition is always true or false based on the variable's type
+    }
+
+    function zeroLeUint(uint256 x) public pure returns (bool) {
+        return 0 <= x; //~WARN: condition is always true or false based on the variable's type
+    }
+
+    function val256GtUint8(uint8 x) public pure returns (bool) {
+        return 256 > x; //~WARN: condition is always true or false based on the variable's type
+    }
+
+    function val256LeUint8(uint8 x) public pure returns (bool) {
+        return 256 <= x; //~WARN: condition is always true or false based on the variable's type
+    }
+
+    // --- int: boundary comparisons ---
+
+    function int8GeMin(int8 x) public pure returns (bool) {
+        return x >= -128; //~WARN: condition is always true or false based on the variable's type
+    }
+
+    function int8LtMin(int8 x) public pure returns (bool) {
+        return x < -128; //~WARN: condition is always true or false based on the variable's type
+    }
+
+    function int8GtMax(int8 x) public pure returns (bool) {
+        return x > 127; //~WARN: condition is always true or false based on the variable's type
+    }
+
+    function int8LeMax(int8 x) public pure returns (bool) {
+        return x <= 127; //~WARN: condition is always true or false based on the variable's type
+    }
+
+    function int8GeAlmostMin(int8 x) public pure returns (bool) {
+        return x >= -127; // ok
+    }
+
+    function int8LeAlmostMax(int8 x) public pure returns (bool) {
+        return x <= 126; // ok
+    }
+
+    // --- explicit casts ---
+
+    function castedUint8GtMax(uint256 raw) public pure returns (bool) {
+        // forge-lint: disable-next-line(unsafe-typecast)
+        return uint8(raw) > 255; //~WARN: condition is always true or false based on the variable's type
+    }
+
+    function castedInt8LtMin(int256 raw) public pure returns (bool) {
+        // forge-lint: disable-next-line(unsafe-typecast)
+        return int8(raw) < -128; //~WARN: condition is always true or false based on the variable's type
+    }
+
+    // --- eq / ne with out-of-range constants ---
+    // (solc rejects comparisons with sign-mismatched literals, e.g. uint == -1 or int8 == 128)
+
+    function uint8EqOutOfRange(uint8 x) public pure returns (bool) {
+        return x == 256; //~WARN: condition is always true or false based on the variable's type
+    }
+
+    function uint8NeOutOfRange(uint8 x) public pure returns (bool) {
+        return x != 256; //~WARN: condition is always true or false based on the variable's type
+    }
+
+    function int8EqBelowMin(int8 x) public pure returns (bool) {
+        return x == -129; //~WARN: condition is always true or false based on the variable's type
+    }
+
+    function uint8EqInRange(uint8 x) public pure returns (bool) {
+        return x == 255; // ok, 255 is the maximum of uint8, not out-of-range
+    }
+
+    function int8EqAtMin(int8 x) public pure returns (bool) {
+        return x == -128; // ok, -128 is within int8 range
+    }
+
+    // --- boolean compositions over type boundaries ---
+
+    function uintLowerBoundaryOr(uint256 x) public pure returns (bool) {
+        return x > 0 || x == 0; //~WARN: condition is always true or false based on the variable's type
+    }
+
+    function uintUpperBoundaryOr(uint8 x) public pure returns (bool) {
+        return x < 255 || x == 255; //~WARN: condition is always true or false based on the variable's type
+    }
+
+    function intLowerBoundaryOr(int8 x) public pure returns (bool) {
+        return x > -128 || x == -128; //~WARN: condition is always true or false based on the variable's type
+    }
+
+    function intUpperBoundaryOr(int8 x) public pure returns (bool) {
+        return x < 127 || x == 127; //~WARN: condition is always true or false based on the variable's type
+    }
+
+    function interiorBoundaryOr(int8 x) public pure returns (bool) {
+        return x > 0 || x == 0; // ok, negative values are not covered
+    }
+
+    function differentVariableOr(uint256 x, uint256 y) public pure returns (bool) {
+        return x > 0 || y == 0; // ok, the comparisons refer to different values
+    }
+
+    // --- boundary compositions over explicit casts ---
+
+    function castedUint8LowerBoundaryOr(uint256 raw) public pure returns (bool) {
+        // forge-lint: disable-next-line(unsafe-typecast)
+        return uint8(raw) > 0 || uint8(raw) == 0; //~WARN: condition is always true or false based on the variable's type
+    }
+
+    function uint8OppositeBoundaryOr(uint8 x) public pure returns (bool) {
+        return x > 0 || x < 255; //~WARN: condition is always true or false based on the variable's type
+    }
+
+    function castedWideningUpperBoundaryOr(uint8 x) public pure returns (bool) {
+        return uint16(x) < 255 || uint16(x) == 255; //~WARN: condition is always true or false based on the variable's type
+    }
+
+    function castedWideningMixedPathOr(uint8 amount) public pure returns (bool) {
+        return uint256(amount) > 0 || amount == 0; //~WARN: condition is always true or false based on the variable's type
+    }
+
+    function castedAndUncastedDifferentRangeOr(uint256 raw) public pure returns (bool) {
+        // forge-lint: disable-next-line(unsafe-typecast)
+        return uint8(raw) > 0 || raw == 0; // ok -- the effective ranges differ
+    }
+
+    function nestedCastedDifferentRangeOr(uint256 raw) public pure returns (bool) {
+        // forge-lint: disable-next-line(unsafe-typecast)
+        return uint256(uint8(raw)) > 0 || raw == 0; // ok -- the inner cast can change the value
+    }
+
+    function nestedCastedSamePathOr(uint256 raw) public pure returns (bool) {
+        // forge-lint: disable-next-line(unsafe-typecast)
+        return uint256(uint8(raw)) > 0 || uint256(uint8(raw)) == 0; //~WARN: condition is always true or false based on the variable's type
+    }
+
+    function uintNegativeZeroLowerBoundaryOr(uint256 x) public pure returns (bool) {
+        return x > -0 || x == -0; //~WARN: condition is always true or false based on the variable's type
+    }
+
+    struct Amount {
+        uint256 value;
+    }
+
+    function structField(Amount memory amount) public pure returns (bool) {
+        return amount.value >= 0; //~WARN: condition is always true or false based on the variable's type
+    }
+
+    function arrayElement(uint256[] memory amounts) public pure returns (bool) {
+        return amounts[0] < 0; //~WARN: condition is always true or false based on the variable's type
+    }
+
+    function returnedValue() public pure returns (bool) {
+        return integerValue() >= 0; //~WARN: condition is always true or false based on the variable's type
+    }
+
+    function integerValue() internal pure returns (uint256) {
+        return 1;
+    }
+}
diff --git a/crates/lint/testdata/TypeBasedTautology.stderr b/crates/lint/testdata/TypeBasedTautology.stderr
new file mode 100644
index 0000000000000..593dd40c8f089
--- /dev/null
+++ b/crates/lint/testdata/TypeBasedTautology.stderr
@@ -0,0 +1,256 @@
+warning[type-based-tautology]: condition is always true or false based on the variable's type
+   ╭▸ ROOT/testdata/TypeBasedTautology.sol:LL:CC
+   │
+LL │         return x >= 0;
+   │                ━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/type-based-tautology
+
+warning[type-based-tautology]: condition is always true or false based on the variable's type
+   ╭▸ ROOT/testdata/TypeBasedTautology.sol:LL:CC
+   │
+LL │         return x < 0;
+   │                ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/type-based-tautology
+
+warning[type-based-tautology]: condition is always true or false based on the variable's type
+   ╭▸ ROOT/testdata/TypeBasedTautology.sol:LL:CC
+   │
+LL │         return x < 256;
+   │                ━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/type-based-tautology
+
+warning[type-based-tautology]: condition is always true or false based on the variable's type
+   ╭▸ ROOT/testdata/TypeBasedTautology.sol:LL:CC
+   │
+LL │         return x > 255;
+   │                ━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/type-based-tautology
+
+warning[type-based-tautology]: condition is always true or false based on the variable's type
+   ╭▸ ROOT/testdata/TypeBasedTautology.sol:LL:CC
+   │
+LL │         return x >= 256;
+   │                ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/type-based-tautology
+
+warning[type-based-tautology]: condition is always true or false based on the variable's type
+   ╭▸ ROOT/testdata/TypeBasedTautology.sol:LL:CC
+   │
+LL │         return x <= 255;
+   │                ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/type-based-tautology
+
+warning[type-based-tautology]: condition is always true or false based on the variable's type
+   ╭▸ ROOT/testdata/TypeBasedTautology.sol:LL:CC
+   │
+LL │         return 0 > x;
+   │                ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/type-based-tautology
+
+warning[type-based-tautology]: condition is always true or false based on the variable's type
+   ╭▸ ROOT/testdata/TypeBasedTautology.sol:LL:CC
+   │
+LL │         return 0 <= x;
+   │                ━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/type-based-tautology
+
+warning[type-based-tautology]: condition is always true or false based on the variable's type
+   ╭▸ ROOT/testdata/TypeBasedTautology.sol:LL:CC
+   │
+LL │         return 256 > x;
+   │                ━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/type-based-tautology
+
+warning[type-based-tautology]: condition is always true or false based on the variable's type
+   ╭▸ ROOT/testdata/TypeBasedTautology.sol:LL:CC
+   │
+LL │         return 256 <= x;
+   │                ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/type-based-tautology
+
+warning[type-based-tautology]: condition is always true or false based on the variable's type
+   ╭▸ ROOT/testdata/TypeBasedTautology.sol:LL:CC
+   │
+LL │         return x >= -128;
+   │                ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/type-based-tautology
+
+warning[type-based-tautology]: condition is always true or false based on the variable's type
+   ╭▸ ROOT/testdata/TypeBasedTautology.sol:LL:CC
+   │
+LL │         return x < -128;
+   │                ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/type-based-tautology
+
+warning[type-based-tautology]: condition is always true or false based on the variable's type
+   ╭▸ ROOT/testdata/TypeBasedTautology.sol:LL:CC
+   │
+LL │         return x > 127;
+   │                ━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/type-based-tautology
+
+warning[type-based-tautology]: condition is always true or false based on the variable's type
+   ╭▸ ROOT/testdata/TypeBasedTautology.sol:LL:CC
+   │
+LL │         return x <= 127;
+   │                ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/type-based-tautology
+
+warning[type-based-tautology]: condition is always true or false based on the variable's type
+   ╭▸ ROOT/testdata/TypeBasedTautology.sol:LL:CC
+   │
+LL │         return uint8(raw) > 255;
+   │                ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/type-based-tautology
+
+warning[type-based-tautology]: condition is always true or false based on the variable's type
+   ╭▸ ROOT/testdata/TypeBasedTautology.sol:LL:CC
+   │
+LL │         return int8(raw) < -128;
+   │                ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/type-based-tautology
+
+warning[type-based-tautology]: condition is always true or false based on the variable's type
+   ╭▸ ROOT/testdata/TypeBasedTautology.sol:LL:CC
+   │
+LL │         return x == 256;
+   │                ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/type-based-tautology
+
+warning[type-based-tautology]: condition is always true or false based on the variable's type
+   ╭▸ ROOT/testdata/TypeBasedTautology.sol:LL:CC
+   │
+LL │         return x != 256;
+   │                ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/type-based-tautology
+
+warning[type-based-tautology]: condition is always true or false based on the variable's type
+   ╭▸ ROOT/testdata/TypeBasedTautology.sol:LL:CC
+   │
+LL │         return x == -129;
+   │                ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/type-based-tautology
+
+warning[type-based-tautology]: condition is always true or false based on the variable's type
+   ╭▸ ROOT/testdata/TypeBasedTautology.sol:LL:CC
+   │
+LL │         return x > 0 || x == 0;
+   │                ━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/type-based-tautology
+
+warning[type-based-tautology]: condition is always true or false based on the variable's type
+   ╭▸ ROOT/testdata/TypeBasedTautology.sol:LL:CC
+   │
+LL │         return x < 255 || x == 255;
+   │                ━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/type-based-tautology
+
+warning[type-based-tautology]: condition is always true or false based on the variable's type
+   ╭▸ ROOT/testdata/TypeBasedTautology.sol:LL:CC
+   │
+LL │         return x > -128 || x == -128;
+   │                ━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/type-based-tautology
+
+warning[type-based-tautology]: condition is always true or false based on the variable's type
+   ╭▸ ROOT/testdata/TypeBasedTautology.sol:LL:CC
+   │
+LL │         return x < 127 || x == 127;
+   │                ━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/type-based-tautology
+
+warning[type-based-tautology]: condition is always true or false based on the variable's type
+   ╭▸ ROOT/testdata/TypeBasedTautology.sol:LL:CC
+   │
+LL │         return uint8(raw) > 0 || uint8(raw) == 0;
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/type-based-tautology
+
+warning[type-based-tautology]: condition is always true or false based on the variable's type
+   ╭▸ ROOT/testdata/TypeBasedTautology.sol:LL:CC
+   │
+LL │         return x > 0 || x < 255;
+   │                ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/type-based-tautology
+
+warning[type-based-tautology]: condition is always true or false based on the variable's type
+   ╭▸ ROOT/testdata/TypeBasedTautology.sol:LL:CC
+   │
+LL │         return uint16(x) < 255 || uint16(x) == 255;
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/type-based-tautology
+
+warning[type-based-tautology]: condition is always true or false based on the variable's type
+   ╭▸ ROOT/testdata/TypeBasedTautology.sol:LL:CC
+   │
+LL │         return uint256(amount) > 0 || amount == 0;
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/type-based-tautology
+
+warning[type-based-tautology]: condition is always true or false based on the variable's type
+   ╭▸ ROOT/testdata/TypeBasedTautology.sol:LL:CC
+   │
+LL │ …     return uint256(uint8(raw)) > 0 || uint256(uint8(raw)) == 0;
+   │              ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/type-based-tautology
+
+warning[type-based-tautology]: condition is always true or false based on the variable's type
+   ╭▸ ROOT/testdata/TypeBasedTautology.sol:LL:CC
+   │
+LL │         return x > -0 || x == -0;
+   │                ━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/type-based-tautology
+
+warning[type-based-tautology]: condition is always true or false based on the variable's type
+   ╭▸ ROOT/testdata/TypeBasedTautology.sol:LL:CC
+   │
+LL │         return amount.value >= 0;
+   │                ━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/type-based-tautology
+
+warning[type-based-tautology]: condition is always true or false based on the variable's type
+   ╭▸ ROOT/testdata/TypeBasedTautology.sol:LL:CC
+   │
+LL │         return amounts[0] < 0;
+   │                ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/type-based-tautology
+
+warning[type-based-tautology]: condition is always true or false based on the variable's type
+   ╭▸ ROOT/testdata/TypeBasedTautology.sol:LL:CC
+   │
+LL │         return integerValue() >= 0;
+   │                ━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/type-based-tautology
+
diff --git a/crates/lint/testdata/UncheckedCall.sol b/crates/lint/testdata/UncheckedCall.sol
index a9fdf7db4b9db..ee000e99f6b5d 100644
--- a/crates/lint/testdata/UncheckedCall.sol
+++ b/crates/lint/testdata/UncheckedCall.sol
@@ -1,5 +1,7 @@
+//@compile-flags: --only-lint unchecked-call
+
 // SPDX-License-Identifier: MIT
-pragma solidity ^0.8.0;
+pragma solidity ^0.8.18;
 
 contract UncheckedCall {
     event CallResult(bool, bytes);
@@ -42,8 +44,8 @@ contract UncheckedCall {
     // Edge case: pre-existing variable assignment
     bool success;
     function checkWithExistingVar(address target) public {
-        (bool, ) = target.call("");
-        (bool, existingData) = target.call("");
+        (success, ) = target.call("");
+        // (success, existingData) = target.call("");
     }
 
     // Edge case: send and transfer are not low-level calls (they automatically revert on failure)
@@ -56,34 +58,34 @@ contract UncheckedCall {
 
     // SHOULD FAIL: Unchecked low-level calls
     function uncheckedCall(address target, bytes memory data) public {
-        target.call(data); //~WARN: Low-level calls should check the success return value
+        target.call(data); //~WARN: low-level call does not check the success return value
     }
 
     function uncheckedCallWithValue(address payable target, uint256 value) public {
-        target.call{value: value}(""); //~WARN: Low-level calls should check the success return value
+        target.call{value: value}(""); //~WARN: low-level call does not check the success return value
     }
 
     function uncheckedDelegateCall(address target, bytes memory data) public {
-        target.delegatecall(data); //~WARN: Low-level calls should check the success return value
+        target.delegatecall(data); //~WARN: low-level call does not check the success return value
     }
 
     function uncheckedStaticCall(address target, bytes memory data) public {
-        target.staticcall(data); //~WARN: Low-level calls should check the success return value
+        target.staticcall(data); //~WARN: low-level call does not check the success return value
     }
 
     function multipleUncheckedCalls(address target1, address target2) public {
-        target1.call(""); //~WARN: Low-level calls should check the success return value
-        target2.delegatecall(""); //~WARN: Low-level calls should check the success return value
+        target1.call(""); //~WARN: low-level call does not check the success return value
+        target2.delegatecall(""); //~WARN: low-level call does not check the success return value
     }
 
     function ignoredReturnWithPartialTuple(address target) public {
-        (, bytes memory data) = target.call(""); //~WARN: Low-level calls should check the success return value
+        (, bytes memory data) = target.call(""); //~WARN: low-level call does not check the success return value
         // Only capturing data, not checking success
     }
 
-    bytes memory existingData;
+    bytes existingData;
     function ignoredReturnExistingVar(address target) public {
-        (, existingData) = target.call(""); //~WARN: Low-level calls should check the success return value
+        (, existingData) = target.call(""); //~WARN: low-level call does not check the success return value
     }
 
 }
diff --git a/crates/lint/testdata/UncheckedCall.stderr b/crates/lint/testdata/UncheckedCall.stderr
index 123e02022bf91..db8f4b16d4d4b 100644
--- a/crates/lint/testdata/UncheckedCall.stderr
+++ b/crates/lint/testdata/UncheckedCall.stderr
@@ -1,64 +1,64 @@
-warning[unchecked-call]: Low-level calls should check the success return value
-  --> ROOT/testdata/UncheckedCall.sol:LL:CC
-   |
-59 |         target.call(data);
-   |         -----------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#unchecked-call
+warning[unchecked-call]: low-level call does not check the success return value
+   ╭▸ ROOT/testdata/UncheckedCall.sol:LL:CC
+   │
+LL │         target.call(data);
+   │         ━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unchecked-call
 
-warning[unchecked-call]: Low-level calls should check the success return value
-  --> ROOT/testdata/UncheckedCall.sol:LL:CC
-   |
-63 |         target.call{value: value}("");
-   |         -----------------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#unchecked-call
+warning[unchecked-call]: low-level call does not check the success return value
+   ╭▸ ROOT/testdata/UncheckedCall.sol:LL:CC
+   │
+LL │         target.call{value: value}("");
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unchecked-call
 
-warning[unchecked-call]: Low-level calls should check the success return value
-  --> ROOT/testdata/UncheckedCall.sol:LL:CC
-   |
-67 |         target.delegatecall(data);
-   |         -------------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#unchecked-call
+warning[unchecked-call]: low-level call does not check the success return value
+   ╭▸ ROOT/testdata/UncheckedCall.sol:LL:CC
+   │
+LL │         target.delegatecall(data);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unchecked-call
 
-warning[unchecked-call]: Low-level calls should check the success return value
-  --> ROOT/testdata/UncheckedCall.sol:LL:CC
-   |
-71 |         target.staticcall(data);
-   |         -----------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#unchecked-call
+warning[unchecked-call]: low-level call does not check the success return value
+   ╭▸ ROOT/testdata/UncheckedCall.sol:LL:CC
+   │
+LL │         target.staticcall(data);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unchecked-call
 
-warning[unchecked-call]: Low-level calls should check the success return value
-  --> ROOT/testdata/UncheckedCall.sol:LL:CC
-   |
-75 |         target1.call("");
-   |         ----------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#unchecked-call
+warning[unchecked-call]: low-level call does not check the success return value
+   ╭▸ ROOT/testdata/UncheckedCall.sol:LL:CC
+   │
+LL │         target1.call("");
+   │         ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unchecked-call
 
-warning[unchecked-call]: Low-level calls should check the success return value
-  --> ROOT/testdata/UncheckedCall.sol:LL:CC
-   |
-76 |         target2.delegatecall("");
-   |         ------------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#unchecked-call
+warning[unchecked-call]: low-level call does not check the success return value
+   ╭▸ ROOT/testdata/UncheckedCall.sol:LL:CC
+   │
+LL │         target2.delegatecall("");
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unchecked-call
 
-warning[unchecked-call]: Low-level calls should check the success return value
-  --> ROOT/testdata/UncheckedCall.sol:LL:CC
-   |
-80 |         (, bytes memory data) = target.call("");
-   |         ----------------------------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#unchecked-call
+warning[unchecked-call]: low-level call does not check the success return value
+   ╭▸ ROOT/testdata/UncheckedCall.sol:LL:CC
+   │
+LL │         (, bytes memory data) = target.call("");
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unchecked-call
 
-warning[unchecked-call]: Low-level calls should check the success return value
-  --> ROOT/testdata/UncheckedCall.sol:LL:CC
-   |
-86 |         (, existingData) = target.call("");
-   |         ----------------------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#unchecked-call
+warning[unchecked-call]: low-level call does not check the success return value
+   ╭▸ ROOT/testdata/UncheckedCall.sol:LL:CC
+   │
+LL │         (, existingData) = target.call("");
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unchecked-call
 
diff --git a/crates/lint/testdata/UncheckedTransferERC20.sol b/crates/lint/testdata/UncheckedTransferERC20.sol
index 5c3f53354078d..889a110b2fae4 100644
--- a/crates/lint/testdata/UncheckedTransferERC20.sol
+++ b/crates/lint/testdata/UncheckedTransferERC20.sol
@@ -1,5 +1,7 @@
+//@compile-flags: --only-lint erc20-unchecked-transfer
+
 // SPDX-License-Identifier: MIT
-pragma solidity ^0.8.0;
+pragma solidity ^0.8.18;
 
 interface IERC20 {
     function transfer(address to, uint256 amount) external returns (bool);
@@ -7,39 +9,61 @@ interface IERC20 {
     function approve(address spender, uint256 amount) external returns (bool);
 }
 
+interface IERC20Wrapper {
+    function transfer(address to, uint256 amount) external;
+    function transferFrom(address from, address to, uint256 amount) external;
+}
+
 contract UncheckedTransfer {
     IERC20 public token;
+    IERC20Wrapper public tokenWrapper;
     mapping(address => uint256) public balances;
 
     constructor(address _token) {
         token = IERC20(_token);
+        tokenWrapper = IERC20Wrapper(_token);
     }
 
     // SHOULD FAIL: Unchecked transfer calls
     function uncheckedTransfer(address to, uint256 amount) public {
-        token.transfer(to, amount); //~WARN: ERC20 'transfer' and 'transferFrom' calls should check the return value
+        IERC20(address(token)).transfer(to, amount); //~WARN: ERC20 `transfer` or `transferFrom` call does not check the return value
+        token.transfer(to, amount); //~WARN: ERC20 `transfer` or `transferFrom` call does not check the return value
+        token.transfer({to: to, amount: amount}); //~WARN: ERC20 `transfer` or `transferFrom` call does not check the return value
     }
 
     function uncheckedTransferFrom(address from, address to, uint256 amount) public {
-        token.transferFrom(from, to, amount); //~WARN: ERC20 'transfer' and 'transferFrom' calls should check the return value
-    }
-
-    function multipleUnchecked(address to, uint256 amount) public {
-        token.transfer(to, amount / 2); //~WARN: ERC20 'transfer' and 'transferFrom' calls should check the return value
-        token.transfer(to, amount / 2); //~WARN: ERC20 'transfer' and 'transferFrom' calls should check the return value
+        IERC20(address(token)).transferFrom(from, to, amount); //~WARN: ERC20 `transfer` or `transferFrom` call does not check the return value
+        token.transferFrom(from, to, amount); //~WARN: ERC20 `transfer` or `transferFrom` call does not check the return value
+        token.transferFrom({from: from, to: to, amount: amount}); //~WARN: ERC20 `transfer` or `transferFrom` call does not check the return value
     }
 
     function uncheckedInLoop(address[] memory recipients, uint256[] memory amounts) public {
-        for (uint i = 0; i < recipients.length; i++) {
-            token.transfer(recipients[i], amounts[i]); //~WARN: ERC20 'transfer' and 'transferFrom' calls should check the return value
+        for (uint256 i = 0; i < recipients.length; i++) {
+            IERC20(address(token)).transfer(recipients[i], amounts[i]); //~WARN: ERC20 `transfer` or `transferFrom` call does not check the return value
+            token.transfer(recipients[i], amounts[i]); //~WARN: ERC20 `transfer` or `transferFrom` call does not check the return value
         }
     }
 
+    // SHOULD PASS: Function with same params but NO boolean return
+    function proxyCheckedTransfer(address to, uint256 amount) public {
+        IERC20Wrapper(address(token)).transfer(to, amount);
+        tokenWrapper.transfer(to, amount);
+    }
+
+    function proxyCheckedTransferFrom(address from, address to, uint256 amount) public {
+        IERC20Wrapper(address(token)).transferFrom(from, to, amount);
+        tokenWrapper.transferFrom(from, to, amount);
+    }
+
     // SHOULD PASS: Properly checked transfer calls
     function checkedTransferWithRequire(address to, uint256 amount) public {
         require(token.transfer(to, amount), "Transfer failed");
     }
 
+    function checkedTransferWithNamedArgs(address to, uint256 amount) public {
+        require(token.transfer({to: to, amount: amount}), "Transfer failed");
+    }
+
     function checkedTransferWithVariable(address to, uint256 amount) public {
         bool success = token.transfer(to, amount);
         require(success, "Transfer failed");
@@ -63,18 +87,58 @@ contract UncheckedTransfer {
     function checkedTransferInExpression(address to, uint256 amount) public {
         if (token.transfer(to, amount)) {
             balances[to] += amount;
+        } else {
+            revert("Transfer failed");
         }
     }
 
     function checkedTransferInRequireWithLogic(address to, uint256 amount) public {
-        require(
-            amount > 0 && token.transfer(to, amount),
-            "Invalid amount or transfer failed"
-        );
+        require(amount > 0 && token.transfer(to, amount), "Invalid amount or transfer failed");
     }
 
-    // Edge case: approve is not a transfer function, should not be flagged
     function uncheckedApprove(address spender, uint256 amount) public {
         token.approve(spender, amount);
     }
 }
+
+library Currency {
+    function transfer(address currency, address to, uint256 amount) internal {
+        // transfer and check output internally
+    }
+    function transferFrom(address currency, address from, address to, uint256 amount) internal {
+        // transfer and check output internally
+    }
+}
+
+contract UncheckedTransferUsingCurrencyLib {
+    using Currency for address;
+
+    address public token;
+    mapping(address => uint256) public balances;
+
+    constructor(address _token) {
+        token = _token;
+    }
+
+    // SHOULD PASS: Function with same params but NO boolean return
+    function currencyTransfer(address to, uint256 amount) public {
+        token.transfer(to, amount);
+        token.transfer(to, amount);
+    }
+
+    function currencyTransferFrom(address from, address to, uint256 amount) public {
+        token.transferFrom(from, to, amount);
+        token.transferFrom(from, to, amount);
+    }
+}
+
+interface IOverloadedTransfer {
+    function transfer(address to, uint256 amount) external returns (bool);
+    function transfer(address to, bytes32 referenceId) external returns (uint256);
+}
+
+contract SelectedTransferOverload {
+    function transferReference(IOverloadedTransfer token, address to, bytes32 referenceId) external {
+        token.transfer(to, referenceId);
+    }
+}
diff --git a/crates/lint/testdata/UncheckedTransferERC20.stderr b/crates/lint/testdata/UncheckedTransferERC20.stderr
index c72ff19d1745c..d62bbc37d82cc 100644
--- a/crates/lint/testdata/UncheckedTransferERC20.stderr
+++ b/crates/lint/testdata/UncheckedTransferERC20.stderr
@@ -1,40 +1,64 @@
-warning[erc20-unchecked-transfer]: ERC20 'transfer' and 'transferFrom' calls should check the return value
-  --> ROOT/testdata/UncheckedTransferERC20.sol:LL:CC
-   |
-20 |         token.transfer(to, amount);
-   |         --------------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#erc20-unchecked-transfer
-
-warning[erc20-unchecked-transfer]: ERC20 'transfer' and 'transferFrom' calls should check the return value
-  --> ROOT/testdata/UncheckedTransferERC20.sol:LL:CC
-   |
-24 |         token.transferFrom(from, to, amount);
-   |         ------------------------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#erc20-unchecked-transfer
-
-warning[erc20-unchecked-transfer]: ERC20 'transfer' and 'transferFrom' calls should check the return value
-  --> ROOT/testdata/UncheckedTransferERC20.sol:LL:CC
-   |
-28 |         token.transfer(to, amount / 2);
-   |         ------------------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#erc20-unchecked-transfer
-
-warning[erc20-unchecked-transfer]: ERC20 'transfer' and 'transferFrom' calls should check the return value
-  --> ROOT/testdata/UncheckedTransferERC20.sol:LL:CC
-   |
-29 |         token.transfer(to, amount / 2);
-   |         ------------------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#erc20-unchecked-transfer
-
-warning[erc20-unchecked-transfer]: ERC20 'transfer' and 'transferFrom' calls should check the return value
-  --> ROOT/testdata/UncheckedTransferERC20.sol:LL:CC
-   |
-34 |             token.transfer(recipients[i], amounts[i]);
-   |             -----------------------------------------
-   |
-   = help: https://book.getfoundry.sh/reference/forge/forge-lint#erc20-unchecked-transfer
+warning[erc20-unchecked-transfer]: ERC20 `transfer` or `transferFrom` call does not check the return value
+   ╭▸ ROOT/testdata/UncheckedTransferERC20.sol:LL:CC
+   │
+LL │         IERC20(address(token)).transfer(to, amount);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/erc20-unchecked-transfer
+
+warning[erc20-unchecked-transfer]: ERC20 `transfer` or `transferFrom` call does not check the return value
+   ╭▸ ROOT/testdata/UncheckedTransferERC20.sol:LL:CC
+   │
+LL │         token.transfer(to, amount);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/erc20-unchecked-transfer
+
+warning[erc20-unchecked-transfer]: ERC20 `transfer` or `transferFrom` call does not check the return value
+   ╭▸ ROOT/testdata/UncheckedTransferERC20.sol:LL:CC
+   │
+LL │         token.transfer({to: to, amount: amount});
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/erc20-unchecked-transfer
+
+warning[erc20-unchecked-transfer]: ERC20 `transfer` or `transferFrom` call does not check the return value
+   ╭▸ ROOT/testdata/UncheckedTransferERC20.sol:LL:CC
+   │
+LL │ …     IERC20(address(token)).transferFrom(from, to, amount);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/erc20-unchecked-transfer
+
+warning[erc20-unchecked-transfer]: ERC20 `transfer` or `transferFrom` call does not check the return value
+   ╭▸ ROOT/testdata/UncheckedTransferERC20.sol:LL:CC
+   │
+LL │         token.transferFrom(from, to, amount);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/erc20-unchecked-transfer
+
+warning[erc20-unchecked-transfer]: ERC20 `transfer` or `transferFrom` call does not check the return value
+   ╭▸ ROOT/testdata/UncheckedTransferERC20.sol:LL:CC
+   │
+LL │ …     token.transferFrom({from: from, to: to, amount: amount});
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/erc20-unchecked-transfer
+
+warning[erc20-unchecked-transfer]: ERC20 `transfer` or `transferFrom` call does not check the return value
+   ╭▸ ROOT/testdata/UncheckedTransferERC20.sol:LL:CC
+   │
+LL │ …     IERC20(address(token)).transfer(recipients[i], amounts[i]);
+   │       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/erc20-unchecked-transfer
+
+warning[erc20-unchecked-transfer]: ERC20 `transfer` or `transferFrom` call does not check the return value
+   ╭▸ ROOT/testdata/UncheckedTransferERC20.sol:LL:CC
+   │
+LL │             token.transfer(recipients[i], amounts[i]);
+   │             ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/erc20-unchecked-transfer
 
diff --git a/crates/lint/testdata/UninitializedLocal.sol b/crates/lint/testdata/UninitializedLocal.sol
new file mode 100644
index 0000000000000..aeb2538a28f7f
--- /dev/null
+++ b/crates/lint/testdata/UninitializedLocal.sol
@@ -0,0 +1,219 @@
+//@compile-flags: --only-lint uninitialized-local
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.0;
+
+type LocalAmount is uint256;
+
+contract UninitializedLocal {
+    enum Status { Pending, Done }
+
+    function enumValue() public pure returns (Status) {
+        Status status;
+        return status; //~WARN: local variable is read before being initialized
+    }
+
+    function contractValue() public pure returns (IFoo) {
+        IFoo target;
+        return target; //~WARN: local variable is read before being initialized
+    }
+
+    function userDefinedValue() public pure returns (uint256) {
+        LocalAmount amount;
+        return LocalAmount.unwrap(amount); //~WARN: local variable is read before being initialized
+    }
+
+    function functionValue() public pure returns (uint256) {
+        function() internal pure returns (uint256) callback;
+        return callback(); //~WARN: local variable is read before being initialized
+    }
+
+    function initializedValues() public pure returns (Status, IFoo, LocalAmount) {
+        Status status;
+        IFoo target;
+        LocalAmount amount;
+        status = Status.Done;
+        target = IFoo(address(1));
+        amount = LocalAmount.wrap(1);
+        return (status, target, amount);
+    }
+
+    struct Point {
+        uint256 x;
+        uint256 y;
+    }
+
+    address public owner;
+
+    // SHOULD WARN:
+
+    // Classic slither example: `to` is never assigned, defaults to address(0).
+    function withdraw() public {
+        address payable to;
+        to.transfer(address(this).balance); //~WARN: local variable is read before being initialized
+    }
+
+    // Returning an uninitialized uint silently returns 0.
+    function getAmount() public pure returns (uint256) {
+        uint256 amount;
+        return amount; //~WARN: local variable is read before being initialized
+    }
+
+    // Reading an uninitialized var in an expression.
+    function compute(uint256 b) public pure returns (uint256) {
+        uint256 a;
+        return a + b; //~WARN: local variable is read before being initialized
+    }
+
+    // Reading an uninitialized var in the initializer of another local.
+    function alias_() public pure returns (address) {
+        address to;
+        address from = to; //~WARN: local variable is read before being initialized
+        return from;
+    }
+
+    // Only one branch assigns, so still uninitialized on the path that skips the branch.
+    function conditional(bool flag, address addr) public {
+        address payable to;
+        if (flag) {
+            to = payable(addr);
+        }
+        to.transfer(1); //~WARN: local variable is read before being initialized
+    }
+
+    // Compound assignment reads x before writing, so x is uninitialized.
+    function compoundRead() public pure returns (uint256) {
+        uint256 x;
+        x += 1; //~WARN: local variable is read before being initialized
+        return x;
+    }
+
+    // SHOULD NOT WARN:
+
+    // Assigned before read.
+    function assignedFirst() public pure returns (uint256) {
+        uint256 x;
+        x = 5;
+        return x;
+    }
+
+    // Has an explicit initializer.
+    function initialized() public pure returns (uint256) {
+        uint256 y = 0;
+        return y;
+    }
+
+    // Both branches assign before read.
+    function bothBranches(bool flag, address a, address b) public {
+        address payable to;
+        if (flag) {
+            to = payable(a);
+        } else {
+            to = payable(b);
+        }
+        to.transfer(1);
+    }
+
+    // Function parameter, not a statement-declared local.
+    function paramUsed(uint256 v) public pure returns (uint256) {
+        return v;
+    }
+
+    // State variable, not a local.
+    function stateUsed() public view returns (address) {
+        return owner;
+    }
+
+    // Tuple destructuring writes both variables: no warning.
+    function tupleWrite() public pure returns (uint256) {
+        uint256 a;
+        uint256 b;
+        (a, b) = foo();
+        return a + b;
+    }
+
+    // Partial tuple: second slot written, first skipped: no warning on x.
+    function tupleSkipFirst() public pure returns (uint256) {
+        uint256 x;
+        (, x) = foo();
+        return x;
+    }
+
+    // delete is an explicit write to zero: no warning.
+    function deleteWrite() public pure returns (uint256) {
+        uint256 x;
+        delete x;
+        return x;
+    }
+
+    // Reference types have well-defined defaults: no warning.
+    function memoryArray() public pure returns (uint256) {
+        uint256[] memory a;
+        return a.length;
+    }
+
+    function memoryBytes() public pure returns (uint256) {
+        bytes memory b;
+        return b.length;
+    }
+
+    function memoryString() public pure returns (uint256) {
+        string memory s;
+        return bytes(s).length;
+    }
+
+    function memoryStruct() public pure returns (uint256) {
+        Point memory p;
+        return p.x;
+    }
+
+    // do-while body always executes once: write is guaranteed.
+    function doWhileWrite(bool cond) public pure returns (uint256) {
+        uint256 x;
+        do {
+            x = 5;
+        } while (cond);
+        return x;
+    }
+
+    // Only then-branch writes but it always returns: x is initialized on the fall-through path.
+    function thenAlwaysReturns(bool flag) public pure returns (uint256) {
+        uint256 x;
+        if (flag) {
+            return 0;
+        } else {
+            x = 5;
+        }
+        return x;
+    }
+
+    // Only else-branch writes but it always returns: x is initialized on the fall-through path.
+    function elseAlwaysReturns(bool flag) public pure returns (uint256) {
+        uint256 x;
+        if (flag) {
+            x = 5;
+        } else {
+            return 0;
+        }
+        return x;
+    }
+
+    // SHOULD WARN: try/catch — write only in success clause, uninitialized on revert path.
+    function tryWrite(address target) public returns (uint256) {
+        uint256 x;
+        try IFoo(target).getValue() returns (uint256 v) {
+            x = v;
+        } catch {
+            // x not written
+        }
+        return x; //~WARN: local variable is read before being initialized
+    }
+
+    function foo() internal pure returns (uint256, uint256) {
+        return (1, 2);
+    }
+}
+
+interface IFoo {
+    function getValue() external returns (uint256);
+}
diff --git a/crates/lint/testdata/UninitializedLocal.stderr b/crates/lint/testdata/UninitializedLocal.stderr
new file mode 100644
index 0000000000000..694b5d8171e5a
--- /dev/null
+++ b/crates/lint/testdata/UninitializedLocal.stderr
@@ -0,0 +1,88 @@
+warning[uninitialized-local]: local variable is read before being initialized
+   ╭▸ ROOT/testdata/UninitializedLocal.sol:LL:CC
+   │
+LL │         return status;
+   │                ━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/uninitialized-local
+
+warning[uninitialized-local]: local variable is read before being initialized
+   ╭▸ ROOT/testdata/UninitializedLocal.sol:LL:CC
+   │
+LL │         return target;
+   │                ━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/uninitialized-local
+
+warning[uninitialized-local]: local variable is read before being initialized
+   ╭▸ ROOT/testdata/UninitializedLocal.sol:LL:CC
+   │
+LL │         return LocalAmount.unwrap(amount);
+   │                                   ━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/uninitialized-local
+
+warning[uninitialized-local]: local variable is read before being initialized
+   ╭▸ ROOT/testdata/UninitializedLocal.sol:LL:CC
+   │
+LL │         return callback();
+   │                ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/uninitialized-local
+
+warning[uninitialized-local]: local variable is read before being initialized
+   ╭▸ ROOT/testdata/UninitializedLocal.sol:LL:CC
+   │
+LL │         to.transfer(address(this).balance);
+   │         ━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/uninitialized-local
+
+warning[uninitialized-local]: local variable is read before being initialized
+   ╭▸ ROOT/testdata/UninitializedLocal.sol:LL:CC
+   │
+LL │         return amount;
+   │                ━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/uninitialized-local
+
+warning[uninitialized-local]: local variable is read before being initialized
+   ╭▸ ROOT/testdata/UninitializedLocal.sol:LL:CC
+   │
+LL │         return a + b;
+   │                ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/uninitialized-local
+
+warning[uninitialized-local]: local variable is read before being initialized
+   ╭▸ ROOT/testdata/UninitializedLocal.sol:LL:CC
+   │
+LL │         address from = to;
+   │                        ━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/uninitialized-local
+
+warning[uninitialized-local]: local variable is read before being initialized
+   ╭▸ ROOT/testdata/UninitializedLocal.sol:LL:CC
+   │
+LL │         to.transfer(1);
+   │         ━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/uninitialized-local
+
+warning[uninitialized-local]: local variable is read before being initialized
+   ╭▸ ROOT/testdata/UninitializedLocal.sol:LL:CC
+   │
+LL │         x += 1;
+   │         ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/uninitialized-local
+
+warning[uninitialized-local]: local variable is read before being initialized
+   ╭▸ ROOT/testdata/UninitializedLocal.sol:LL:CC
+   │
+LL │         return x;
+   │                ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/uninitialized-local
+
diff --git a/crates/lint/testdata/UninitializedLocalFor.sol b/crates/lint/testdata/UninitializedLocalFor.sol
new file mode 100644
index 0000000000000..362b101ffa196
--- /dev/null
+++ b/crates/lint/testdata/UninitializedLocalFor.sol
@@ -0,0 +1,99 @@
+//@compile-flags: --only-lint uninitialized-local
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.0;
+
+contract UninitializedLocalFor {
+    // Header counters deliberately start at zero, including reads in the loop body.
+    function counters(uint256 n) public pure returns (uint256 sum) {
+        for (uint256 i; i < n; ++i) {
+            sum += i;
+        }
+        for (uint8 j; j <= 10; j++) {
+            sum += j;
+        }
+        for (uint256 k; n > (k); (k)++) {
+            for (uint256 m; n >= m; ++m) {
+                sum += k + m;
+            }
+        }
+    }
+
+    // A declaration outside the header must not match the synthetic wrapper.
+    function outsideHeader(uint256 n) public pure {
+        {
+            uint256 i;
+            for (; i < n; ++i) {} //~WARN: local variable is read before being initialized
+        }
+    }
+
+    // Only the loop counter is exempt; an uninitialized bound is still a read.
+    function missingBound() public pure {
+        uint256 n;
+        for (uint256 i; i < n; ++i) {} //~WARN: local variable is read before being initialized
+    }
+
+    // The loop might run, so findings in its body must not be rolled back.
+    function bodyRead(uint256 n) public pure returns (uint256 sum) {
+        for (uint256 i; i < n; ++i) {
+            uint256 amount;
+            sum += amount; //~WARN: local variable is read before being initialized
+        }
+    }
+
+    // The loop might not run, so its writes do not initialize another local afterwards.
+    function zeroIterations(uint256 n) public pure returns (uint256) {
+        uint256 amount;
+        for (uint256 i; i < n; ++i) {
+            amount = i;
+        }
+        return amount; //~WARN: local variable is read before being initialized
+    }
+
+    // A header declaration alone does not make an unrelated local a counter.
+    function unrelatedHeader(uint256 n) public pure returns (uint256 sum) {
+        uint256 i = 0;
+        for (uint256 amount; i < n; ++i) {
+            sum += amount; //~WARN: local variable is read before being initialized
+        }
+    }
+
+    // Descending from an implicit zero is not the ascending-counter idiom.
+    function decrement(uint256 n) public pure {
+        for (uint256 i; i < n; --i) {} //~WARN: local variable is read before being initialized
+    }
+
+    // A condition that happens to read a local is not enough without its increment.
+    function differentUpdate(uint256 n) public pure {
+        for (uint256 i; i < n; --n) {} //~WARN: local variable is read before being initialized
+    }
+
+    // Loops without a header update keep their existing diagnostics.
+    function bodyUpdate(uint256 n) public pure {
+        for (uint256 i; i < n;) { //~WARN: local variable is read before being initialized
+            ++i;
+        }
+    }
+
+    // A standalone compound read still relies on an unintended default.
+    function compoundRead() public pure returns (uint256) {
+        uint256 amount;
+        amount += 1; //~WARN: local variable is read before being initialized
+        return amount;
+    }
+
+    // Nested counter exemptions must not suppress another local's read.
+    function nestedRead(uint256 n) public pure returns (uint256 sum) {
+        for (uint256 i; i < n; ++i) {
+            for (uint256 j; j < n; ++j) {
+                uint256 amount;
+                sum += amount; //~WARN: local variable is read before being initialized
+            }
+        }
+    }
+
+    // The exception is limited to unsigned counters.
+    function signedCounter(int256 n) public pure {
+        for (int256 i; i < n; ++i) {} //~WARN: local variable is read before being initialized
+    }
+}
diff --git a/crates/lint/testdata/UninitializedLocalFor.stderr b/crates/lint/testdata/UninitializedLocalFor.stderr
new file mode 100644
index 0000000000000..33a314c9019fa
--- /dev/null
+++ b/crates/lint/testdata/UninitializedLocalFor.stderr
@@ -0,0 +1,88 @@
+warning[uninitialized-local]: local variable is read before being initialized
+   ╭▸ ROOT/testdata/UninitializedLocalFor.sol:LL:CC
+   │
+LL │             for (; i < n; ++i) {}
+   │                    ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/uninitialized-local
+
+warning[uninitialized-local]: local variable is read before being initialized
+   ╭▸ ROOT/testdata/UninitializedLocalFor.sol:LL:CC
+   │
+LL │         for (uint256 i; i < n; ++i) {}
+   │                             ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/uninitialized-local
+
+warning[uninitialized-local]: local variable is read before being initialized
+   ╭▸ ROOT/testdata/UninitializedLocalFor.sol:LL:CC
+   │
+LL │             sum += amount;
+   │                    ━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/uninitialized-local
+
+warning[uninitialized-local]: local variable is read before being initialized
+   ╭▸ ROOT/testdata/UninitializedLocalFor.sol:LL:CC
+   │
+LL │         return amount;
+   │                ━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/uninitialized-local
+
+warning[uninitialized-local]: local variable is read before being initialized
+   ╭▸ ROOT/testdata/UninitializedLocalFor.sol:LL:CC
+   │
+LL │             sum += amount;
+   │                    ━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/uninitialized-local
+
+warning[uninitialized-local]: local variable is read before being initialized
+   ╭▸ ROOT/testdata/UninitializedLocalFor.sol:LL:CC
+   │
+LL │         for (uint256 i; i < n; --i) {}
+   │                         ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/uninitialized-local
+
+warning[uninitialized-local]: local variable is read before being initialized
+   ╭▸ ROOT/testdata/UninitializedLocalFor.sol:LL:CC
+   │
+LL │         for (uint256 i; i < n; --n) {}
+   │                         ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/uninitialized-local
+
+warning[uninitialized-local]: local variable is read before being initialized
+   ╭▸ ROOT/testdata/UninitializedLocalFor.sol:LL:CC
+   │
+LL │         for (uint256 i; i < n;) {
+   │                         ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/uninitialized-local
+
+warning[uninitialized-local]: local variable is read before being initialized
+   ╭▸ ROOT/testdata/UninitializedLocalFor.sol:LL:CC
+   │
+LL │         amount += 1;
+   │         ━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/uninitialized-local
+
+warning[uninitialized-local]: local variable is read before being initialized
+   ╭▸ ROOT/testdata/UninitializedLocalFor.sol:LL:CC
+   │
+LL │                 sum += amount;
+   │                        ━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/uninitialized-local
+
+warning[uninitialized-local]: local variable is read before being initialized
+   ╭▸ ROOT/testdata/UninitializedLocalFor.sol:LL:CC
+   │
+LL │         for (int256 i; i < n; ++i) {}
+   │                        ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/uninitialized-local
+
diff --git a/crates/lint/testdata/UninitializedStateVariables.sol b/crates/lint/testdata/UninitializedStateVariables.sol
new file mode 100644
index 0000000000000..1063bb2f9be03
--- /dev/null
+++ b/crates/lint/testdata/UninitializedStateVariables.sol
@@ -0,0 +1,597 @@
+//@compile-flags: --only-lint uninitialized-state
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.0;
+
+// ── Basic: read but never written ────────────────────────────────────────────
+
+contract Bad {
+    uint256 public balance; //~WARN: state variable is read but never written
+    address public owner; //~WARN: state variable is read but never written
+
+    function getBalance() public view returns (uint256) {
+        return balance;
+    }
+
+    function isOwner(address addr) public view returns (bool) {
+        return addr == owner;
+    }
+}
+
+// ── Good: various write patterns ─────────────────────────────────────────────
+
+contract GoodConstructor {
+    address public owner;
+
+    constructor() {
+        owner = msg.sender;
+    }
+
+    function getOwner() public view returns (address) {
+        return owner;
+    }
+}
+
+contract GoodFunction {
+    uint256 public counter;
+
+    function inc() external {
+        counter += 1;
+    }
+
+    function get() public view returns (uint256) {
+        return counter;
+    }
+}
+
+contract GoodInlineInit {
+    uint256 public value = 42;
+
+    function get() public view returns (uint256) {
+        return value;
+    }
+}
+
+// ── push / pop mutate a dynamic array ────────────────────────────────────────
+
+contract GoodPushPop {
+    uint256[] public items;
+
+    function add(uint256 v) external {
+        items.push(v);
+    }
+
+    function remove() external {
+        items.pop();
+    }
+
+    function len() public view returns (uint256) {
+        return items.length;
+    }
+}
+
+// ── Inheritance: concrete base analyzed independently from derived ────────────
+// Each concrete contract is checked on its own; Base can be deployed directly
+// and `data` is never written in its scope, so it is flagged. DerivedGood
+// writes `data` in its constructor, so no warning fires for DerivedGood.
+
+contract Base {
+    uint256 public data; //~WARN: state variable is read but never written
+
+    function getData() public view returns (uint256) {
+        return data;
+    }
+}
+
+contract DerivedGood is Base {
+    constructor() {
+        data = 1;
+    }
+}
+
+// ── Inheritance: base var never written anywhere ──────────────────────────────
+
+contract BaseUnwritten {
+    uint256 public val; //~WARN: state variable is read but never written
+
+    function getVal() public view returns (uint256) {
+        return val;
+    }
+}
+
+contract DerivedUnwritten is BaseUnwritten {}
+
+// ── Modifier args: reading a state var as arg is a read, not a write ──────────
+
+contract WithModifier {
+    uint256 public cap; //~WARN: state variable is read but never written
+    uint256 public used;
+
+    modifier limited(uint256 max) {
+        require(used <= max);
+        _;
+    }
+
+    // cap is read as a modifier argument; used is written in the function body.
+    function foo() external limited(cap) {
+        used += 1;
+    }
+}
+
+// ── Constants and immutables are always initialized ───────────────────────────
+
+contract SkipConstAndImmutable {
+    uint256 constant MAX = 100;
+    uint256 immutable LIMIT;
+
+    constructor(uint256 limit) {
+        LIMIT = limit;
+    }
+
+    function check(uint256 x) public view returns (bool) {
+        return x <= MAX && x <= LIMIT;
+    }
+}
+
+// ── Never read: caught by unused-state-variables, not this lint ───────────────
+
+contract SkipNeverRead {
+    uint256 neverReadNorWritten;
+}
+
+// ── Assembly: bail conservatively, no false positives ────────────────────────
+
+contract WithAssembly {
+    uint256 public slot0; // would look unwritten, but assembly might write it
+
+    function store(uint256 v) external {
+        assembly {
+            sstore(slot0.slot, v)
+        }
+    }
+
+    function load() public view returns (uint256 result) {
+        assembly {
+            result := sload(slot0.slot)
+        }
+    }
+}
+
+// ── Mappings written via delete ───────────────────────────────────────────────
+
+contract GoodMappingDelete {
+    mapping(address => uint256) public balances;
+
+    function clear(address user) external {
+        delete balances[user];
+    }
+
+    function get(address user) public view returns (uint256) {
+        return balances[user];
+    }
+}
+
+// ── Synthetic getter counts as a read ────────────────────────────────────────
+// A `public` state variable gets a compiler-synthesised getter. The HIR
+// includes that getter as a function, so the variable is detected as read.
+
+contract GetterOnly {
+    uint256 public x; //~WARN: state variable is read but never written
+}
+
+contract GetterOnlyWritten {
+    uint256 public x;
+
+    constructor() {
+        x = 1;
+    }
+}
+
+// ── Base-constructor-arg: state var read in `constructor() A(x) {}` ───────────
+// The modifier-arg `AcceptsArg(initVal)` on the derived constructor reads
+// `initVal` from state; if it is never written the lint fires.
+
+contract AcceptsArg {
+    constructor(uint256) {}
+}
+
+contract BaseCtorArgRead is AcceptsArg {
+    uint256 public initVal; //~WARN: state variable is read but never written
+    constructor() AcceptsArg(initVal) {}
+}
+
+contract BaseCtorArgWritten is AcceptsArg {
+    uint256 public initVal;
+    constructor(uint256 v) AcceptsArg(initVal) {
+        initVal = v; // written in body, so not flagged
+    }
+}
+
+// ── Abstract contracts: not directly deployed, skip entirely ──────────────────
+
+abstract contract AbstractBase {
+    uint256 public unset; // would fire if abstract were not skipped
+
+    function getUnset() public view returns (uint256) {
+        return unset;
+    }
+}
+
+// ── Mappings: always default-initialized, skip to avoid false positives ───────
+
+contract ReadOnlyMapping {
+    mapping(address => uint256) public balances; // public getter reads it; no explicit write
+
+    function get(address a) public view returns (uint256) {
+        return balances[a];
+    }
+}
+
+// ── Storage-ref internal call: _set(data, v) writes `data` via storage ref ───
+
+contract StorageRefCall {
+    struct Data { uint256 val; }
+    Data public slot;
+
+    function _set(Data storage target, uint256 v) internal {
+        target.val = v;
+    }
+
+    function set(uint256 v) external {
+        _set(slot, v);
+    }
+
+    function get() public view returns (uint256) {
+        return slot.val;
+    }
+}
+
+// ── Library dispatch: data.set(v) writes `data` via `using Lib for Data` ────
+
+library DataLib {
+    struct Data { uint256 val; }
+    function set(Data storage self, uint256 v) internal {
+        self.val = v;
+    }
+    function get(Data storage self) internal view returns (uint256) {
+        return self.val;
+    }
+}
+
+contract LibraryDispatch {
+    using DataLib for DataLib.Data;
+    DataLib.Data public slot;
+
+    function set(uint256 v) external {
+        slot.set(v);
+    }
+
+    function get() public view returns (uint256) {
+        return slot.get();
+    }
+}
+
+// ── Concrete base with a concrete derived that writes the variable ────────────
+// Deploying the base directly leaves `x` uninitialized; the derived constructor
+// writing `x` only matters for that derived deployment.
+
+contract BaseDeployable {
+    uint256 public x; //~WARN: state variable is read but never written
+    function get() external view returns (uint256) { return x; }
+}
+
+contract DerivedFromBaseDeployable is BaseDeployable {
+    constructor() { x = 42; }
+}
+
+// ── Overloaded internal function ──────────────────────────────────────────────
+// Only the selected overload determines whether an argument can be written.
+
+contract OverloadUnion {
+    struct S { uint256 v; }
+    uint256 public x; //~WARN: state variable is read but never written
+    S internal data;
+
+    function f(uint256) internal {}
+    function f(S storage) internal {}
+
+    function callIt() external { f(x); }
+    function read() external view returns (uint256) { return x; }
+}
+
+// ── Overloaded internal function: storage overload is the correct match ───────
+// `set(slot)` where `slot` is `Data storage`; only `set(Data storage)` can
+// accept a struct argument. Previously flagged as uninitialized with `all_storage`.
+
+contract OverloadStorageWrite {
+    struct Data { uint256 val; }
+    Data public slot;
+
+    function set(Data storage target, uint256 v) internal { target.val = v; }
+    function set(uint256) internal {}
+
+    function write(uint256 v) external { set(slot, v); }
+    function get() public view returns (uint256) { return slot.val; }
+}
+
+// ── Storage-ref internal call with named arguments ────────────────────────────
+// `_set({v: v, target: slot})` passes `slot` as the storage parameter even
+// though it appears second in source order; the lint must match by name.
+
+contract NamedArgStorage {
+    struct Data { uint256 val; }
+    Data public slot;
+
+    function _set(Data storage target, uint256 v) internal { target.val = v; }
+    function set(uint256 v) external { _set({v: v, target: slot}); }
+    function get() public view returns (uint256) { return slot.val; }
+}
+
+// ── Named args, out-of-order: positional loop must not misfire ───────────────
+// `_set({target: slot, v: x})` passes `x` as `v` (not storage) and `slot` as
+// `target` (storage).  Before the fix the positional enumerate loop would see
+// arg[1]=x against parameter[1]=target (storage) and falsely mark x as written.
+
+contract NamedArgMisfire {
+    struct Data { uint256 val; }
+    Data slot;
+    uint256 public x; //~WARN: state variable is read but never written
+
+    function _set(uint256 v, Data storage target) internal { target.val = v; }
+    function callIt() external { _set({target: slot, v: x}); }
+    function read() external view returns (uint256) { return x; }
+}
+
+// ── Initializer side effect: _init() writes `y` while initializing `x` ───────
+
+contract InitSideEffect {
+    uint256 public y; // written via _init(), must NOT warn
+    uint256 public x = _init();
+
+    function _init() internal returns (uint256) {
+        y = 1;
+        return 2;
+    }
+
+    function getY() external view returns (uint256) { return y; }
+}
+
+// ── payable() cast on a member-call receiver is not a write ──────────────────
+// `payable(owner).transfer(...)` reads `owner` to obtain an address; it does
+// not write to the state variable.
+
+contract PayableTransfer {
+    address public owner; //~WARN: state variable is read but never written
+
+    function withdraw() external {
+        payable(owner).transfer(address(this).balance);
+    }
+
+    function getOwner() public view returns (address) { return owner; }
+}
+
+// ── Qualified inherited helper: Contract.f(slot) writes via storage ref ───────
+// `BaseSetter._set(slot, v)` is a member callee; the lint must look up `_set`
+// in `BaseSetter` and detect the `storage` parameter to avoid a false positive.
+
+contract BaseSetter {
+    struct Data { uint256 val; }
+    function _set(Data storage target, uint256 v) internal { target.val = v; }
+}
+
+contract QualifiedInheritedWrite is BaseSetter {
+    Data public slot;
+
+    function write(uint256 v) external { BaseSetter._set(slot, v); }
+    function get() public view returns (uint256) { return slot.val; }
+}
+
+// ── super.f(slot) writes via storage ref in parent ───────────────────────────
+// `super._set(slot, v)` uses the `super` builtin as a member base; the lint
+// must search the linearized inheritance chain for `_set` and detect the
+// `storage` parameter.
+
+contract SuperWrite is BaseSetter {
+    Data public slot;
+
+    function write(uint256 v) external { super._set(slot, v); }
+    function get() public view returns (uint256) { return slot.val; }
+}
+
+// ── super.f(x): child storage overload must NOT suppress warning ──────────────
+// The child defines `_init(Data storage)` but `super._init(slot)` resolves to
+// the parent's `_init(Data memory)` (no storage parameter), so `slot` is never
+// actually written through the super call and the warning must still fire.
+
+contract SuperNonStorageBase {
+    struct Data { uint256 val; }
+    function _init(Data memory) internal virtual {}
+}
+
+contract SuperNonStorageChild is SuperNonStorageBase {
+    Data public slot; //~WARN: state variable is read but never written
+
+    // Child overload with storage param – must NOT affect super dispatch lookup
+    function _init(Data storage target) internal { target.val = 1; }
+
+    function use_() external view returns (uint256) { return slot.val; }
+    function init() external { super._init(slot); }
+}
+
+contract StoragePointerAlias {
+    struct Data { uint256 val; }
+    Data public slot;
+
+    function set(uint256 v) external {
+        Data storage p = slot;
+        p.val = v;
+    }
+
+    function get() public view returns (uint256) { return slot.val; }
+}
+
+contract StoragePointerChainedAlias {
+    struct Data { uint256 val; }
+    Data public slot;
+
+    function set(uint256 v) external {
+        Data storage p = slot;
+        Data storage q = p;
+        q.val = v;
+    }
+
+    function get() public view returns (uint256) { return slot.val; }
+}
+
+contract StoragePointerReadOnly {
+    struct Data { uint256 val; }
+    Data public slot; //~WARN: state variable is read but never written
+
+    function get() external view returns (uint256) {
+        Data storage p = slot;
+        return p.val;
+    }
+}
+
+contract StoragePointerArrayElement {
+    struct Data { uint256 val; }
+    Data[2] public items;
+
+    function set(uint256 i, uint256 v) external {
+        Data storage p = items[i];
+        p.val = v;
+    }
+
+    function get(uint256 i) public view returns (uint256) { return items[i].val; }
+}
+
+// Alias tracking is flow-insensitive: a pointer may reference every target it is ever assigned.
+contract StoragePointerReassigned {
+    struct Data { uint256 val; }
+    Data public slotA;
+    Data public slotB;
+
+    function set(bool chooseB, uint256 v) external {
+        Data storage p = slotA;
+        if (chooseB) p = slotB;
+        p.val = v;
+    }
+
+    function getA() public view returns (uint256) { return slotA.val; }
+    function getB() public view returns (uint256) { return slotB.val; }
+}
+
+contract StoragePointerReassignedInLoop {
+    struct Data { uint256 val; }
+    Data public slotA;
+    Data public slotB;
+    Data public slotC;
+
+    function set(uint256 count, uint256 v) external {
+        Data storage p = slotA;
+        Data storage q = slotB;
+        for (uint256 i; i < count; ++i) {
+            p = q;
+            q = slotC;
+        }
+        p.val = v;
+    }
+
+    function getA() public view returns (uint256) { return slotA.val; }
+    function getB() public view returns (uint256) { return slotB.val; }
+    function getC() public view returns (uint256) { return slotC.val; }
+}
+
+contract StoragePointerTernary {
+    struct Data { uint256 val; }
+    Data public slotA;
+    Data public slotB;
+
+    function set(bool chooseB, uint256 v) external {
+        Data storage p = chooseB ? slotB : slotA;
+        p.val = v;
+    }
+
+    function getA() public view returns (uint256) { return slotA.val; }
+    function getB() public view returns (uint256) { return slotB.val; }
+}
+
+// Repointing a storage pointer is not a write to either target.
+contract StoragePointerRepointOnly {
+    struct Data { uint256 val; }
+    Data public slotA; //~WARN: state variable is read but never written
+    Data public slotB; //~WARN: state variable is read but never written
+
+    function get() external view returns (uint256) {
+        Data storage p = slotA;
+        p = slotB;
+        return p.val;
+    }
+}
+
+contract StoragePointerStorageArg {
+    struct Data { uint256 val; }
+    Data public slot;
+
+    function _set(Data storage target, uint256 v) internal { target.val = v; }
+
+    function set(uint256 v) external {
+        Data storage p = slot;
+        _set(p, v);
+    }
+
+    function get() public view returns (uint256) { return slot.val; }
+}
+
+contract StoragePointerPush {
+    uint256[] public items;
+
+    function add(uint256 v) external {
+        uint256[] storage arr = items;
+        arr.push(v);
+    }
+
+    function count() external view returns (uint256) { return items.length; }
+}
+
+contract StoragePointerTupleDeclaration {
+    struct Data { uint256 val; }
+    Data public slotA;
+    Data public slotB;
+
+    function set(uint256 v) external {
+        (Data storage p, Data storage q) = (slotA, slotB);
+        p.val = v;
+        (q, ) = (slotA, uint256(0));
+        q.val = v;
+    }
+
+    function getA() public view returns (uint256) { return slotA.val; }
+    function getB() public view returns (uint256) { return slotB.val; }
+}
+
+contract StoragePointerInModifier {
+    struct Data { uint256 val; }
+    Data public slot;
+
+    modifier bump() {
+        Data storage p = slot;
+        p.val += 1;
+        _;
+    }
+
+    function get() external view bump returns (uint256) { return slot.val; }
+}
+
+// A pointer passed to a `memory` parameter is copied, not written.
+contract StoragePointerMemoryArg {
+    struct Data { uint256 val; }
+    Data public slot; //~WARN: state variable is read but never written
+
+    function _read(Data memory d) internal pure returns (uint256) { return d.val; }
+
+    function get() external view returns (uint256) {
+        Data storage p = slot;
+        return _read(p);
+    }
+}
diff --git a/crates/lint/testdata/UninitializedStateVariables.stderr b/crates/lint/testdata/UninitializedStateVariables.stderr
new file mode 100644
index 0000000000000..d149de3883076
--- /dev/null
+++ b/crates/lint/testdata/UninitializedStateVariables.stderr
@@ -0,0 +1,128 @@
+warning[uninitialized-state]: state variable is read but never written
+   ╭▸ ROOT/testdata/UninitializedStateVariables.sol:LL:CC
+   │
+LL │     uint256 public balance;
+   │     ━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/uninitialized-state
+
+warning[uninitialized-state]: state variable is read but never written
+   ╭▸ ROOT/testdata/UninitializedStateVariables.sol:LL:CC
+   │
+LL │     address public owner;
+   │     ━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/uninitialized-state
+
+warning[uninitialized-state]: state variable is read but never written
+   ╭▸ ROOT/testdata/UninitializedStateVariables.sol:LL:CC
+   │
+LL │     uint256 public data;
+   │     ━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/uninitialized-state
+
+warning[uninitialized-state]: state variable is read but never written
+   ╭▸ ROOT/testdata/UninitializedStateVariables.sol:LL:CC
+   │
+LL │     uint256 public val;
+   │     ━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/uninitialized-state
+
+warning[uninitialized-state]: state variable is read but never written
+   ╭▸ ROOT/testdata/UninitializedStateVariables.sol:LL:CC
+   │
+LL │     uint256 public cap;
+   │     ━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/uninitialized-state
+
+warning[uninitialized-state]: state variable is read but never written
+   ╭▸ ROOT/testdata/UninitializedStateVariables.sol:LL:CC
+   │
+LL │     uint256 public x;
+   │     ━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/uninitialized-state
+
+warning[uninitialized-state]: state variable is read but never written
+   ╭▸ ROOT/testdata/UninitializedStateVariables.sol:LL:CC
+   │
+LL │     uint256 public initVal;
+   │     ━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/uninitialized-state
+
+warning[uninitialized-state]: state variable is read but never written
+   ╭▸ ROOT/testdata/UninitializedStateVariables.sol:LL:CC
+   │
+LL │     uint256 public x;
+   │     ━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/uninitialized-state
+
+warning[uninitialized-state]: state variable is read but never written
+   ╭▸ ROOT/testdata/UninitializedStateVariables.sol:LL:CC
+   │
+LL │     uint256 public x;
+   │     ━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/uninitialized-state
+
+warning[uninitialized-state]: state variable is read but never written
+   ╭▸ ROOT/testdata/UninitializedStateVariables.sol:LL:CC
+   │
+LL │     uint256 public x;
+   │     ━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/uninitialized-state
+
+warning[uninitialized-state]: state variable is read but never written
+   ╭▸ ROOT/testdata/UninitializedStateVariables.sol:LL:CC
+   │
+LL │     address public owner;
+   │     ━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/uninitialized-state
+
+warning[uninitialized-state]: state variable is read but never written
+   ╭▸ ROOT/testdata/UninitializedStateVariables.sol:LL:CC
+   │
+LL │     Data public slot;
+   │     ━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/uninitialized-state
+
+warning[uninitialized-state]: state variable is read but never written
+   ╭▸ ROOT/testdata/UninitializedStateVariables.sol:LL:CC
+   │
+LL │     Data public slot;
+   │     ━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/uninitialized-state
+
+warning[uninitialized-state]: state variable is read but never written
+   ╭▸ ROOT/testdata/UninitializedStateVariables.sol:LL:CC
+   │
+LL │     Data public slotA;
+   │     ━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/uninitialized-state
+
+warning[uninitialized-state]: state variable is read but never written
+   ╭▸ ROOT/testdata/UninitializedStateVariables.sol:LL:CC
+   │
+LL │     Data public slotB;
+   │     ━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/uninitialized-state
+
+warning[uninitialized-state]: state variable is read but never written
+   ╭▸ ROOT/testdata/UninitializedStateVariables.sol:LL:CC
+   │
+LL │     Data public slot;
+   │     ━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/uninitialized-state
+
diff --git a/crates/lint/testdata/UnprotectedInitializer.sol b/crates/lint/testdata/UnprotectedInitializer.sol
new file mode 100644
index 0000000000000..9714e6e8c7548
--- /dev/null
+++ b/crates/lint/testdata/UnprotectedInitializer.sol
@@ -0,0 +1,399 @@
+//@compile-flags: --only-lint unprotected-initializer
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+abstract contract Initializable {
+    bool private _initialized;
+
+    modifier initializer() {
+        _;
+    }
+
+    modifier reinitializer(uint64) {
+        _;
+    }
+
+    modifier onlyInitializing() {
+        _;
+    }
+
+    function _disableInitializers() internal {
+        _initialized = true;
+    }
+}
+
+contract UnprotectedInitializer is Initializable {
+    address public owner;
+
+    function initialize(address owner_) public initializer { //~WARN: upgradeable initializer is not protected against direct implementation calls
+        owner = owner_;
+    }
+
+    function execute(address target, bytes calldata data) external {
+        (bool ok,) = target.delegatecall(data);
+        require(ok);
+    }
+}
+
+contract UnprotectedReinitializer is Initializable {
+    uint256 public fee;
+
+    function initializeV2(uint256 fee_) external reinitializer(2) { //~WARN: upgradeable initializer is not protected against direct implementation calls
+        fee = fee_;
+    }
+
+    function execute(address target, bytes calldata data) external {
+        (bool ok,) = target.delegatecall(data);
+        require(ok);
+    }
+}
+
+contract UnprotectedNamedInitialize is Initializable {
+    address public admin;
+
+    function initialize(address admin_) external {
+        admin = admin_;
+    }
+
+    function execute(address target, bytes calldata data) external {
+        (bool ok,) = target.delegatecall(data);
+        require(ok);
+    }
+}
+
+contract UnprotectedInternalWrite is Initializable {
+    address public owner;
+
+    function initialize(address owner_) public initializer { //~WARN: upgradeable initializer is not protected against direct implementation calls
+        _setOwner(owner_);
+    }
+
+    function _setOwner(address owner_) internal {
+        owner = owner_;
+    }
+
+    function execute(address target, bytes calldata data) external {
+        (bool ok,) = target.delegatecall(data);
+        require(ok);
+    }
+}
+
+contract UnprotectedArrayWrite is Initializable {
+    address[] public owners;
+
+    function initialize(address owner_) public initializer { //~WARN: upgradeable initializer is not protected against direct implementation calls
+        owners.push(owner_);
+    }
+
+    function execute(address target, bytes calldata data) external {
+        (bool ok,) = target.delegatecall(data);
+        require(ok);
+    }
+}
+
+contract NoDestructiveSink is Initializable {
+    address public owner;
+
+    function initialize(address owner_) public initializer {
+        _shared();
+        _shared();
+        owner = owner_;
+    }
+
+    function _shared() internal pure {
+        _leaf();
+        _leaf();
+    }
+
+    function _leaf() internal pure {}
+}
+
+contract ProtectedDisableInitializers is Initializable {
+    address public owner;
+
+    constructor() {
+        _disableInitializers();
+    }
+
+    function initialize(address owner_) public initializer {
+        owner = owner_;
+    }
+}
+
+contract ConstructorInitializerReinitializer is Initializable {
+    uint256 public fee;
+
+    constructor() initializer {}
+
+    function initializeV2(uint256 fee_) external reinitializer(2) { //~WARN: upgradeable initializer is not protected against direct implementation calls
+        fee = fee_;
+    }
+
+    function execute(address target, bytes calldata data) external {
+        (bool ok,) = target.delegatecall(data);
+        require(ok);
+    }
+}
+
+contract ProtectedThroughHelper is Initializable {
+    address public owner;
+
+    constructor() {
+        _lockImplementation();
+    }
+
+    function _lockImplementation() internal {
+        _disableInitializers();
+    }
+
+    function initialize(address owner_) public initializer {
+        owner = owner_;
+    }
+}
+
+library UnrelatedInitializerLock {
+    function _disableInitializers() internal pure {}
+}
+
+contract UnrelatedInitializerLockCall is Initializable {
+    address public owner;
+
+    constructor() {
+        UnrelatedInitializerLock._disableInitializers();
+    }
+
+    function initialize(address owner_) public initializer { //~WARN: upgradeable initializer is not protected against direct implementation calls
+        owner = owner_;
+    }
+
+    function execute(address target, bytes calldata data) external {
+        (bool ok,) = target.delegatecall(data);
+        require(ok);
+    }
+}
+
+contract OnlyProxyInitializer is Initializable {
+    address public owner;
+
+    modifier onlyProxy() {
+        _;
+    }
+
+    function initialize(address owner_) external initializer onlyProxy {
+        owner = owner_;
+    }
+
+    function execute(address target, bytes calldata data) external {
+        (bool ok,) = target.delegatecall(data);
+        require(ok);
+    }
+}
+
+contract OnlyProxySink is Initializable {
+    address public owner;
+
+    modifier onlyProxy() {
+        _;
+    }
+
+    function initialize(address owner_) external initializer {
+        owner = owner_;
+    }
+
+    function execute(address target, bytes calldata data) external onlyProxy {
+        (bool ok,) = target.delegatecall(data);
+        require(ok);
+    }
+}
+
+contract DangerousBase {
+    function execute(address target, bytes calldata data) external virtual {
+        (bool ok,) = target.delegatecall(data);
+        require(ok);
+    }
+}
+
+contract OverridesDangerousBase is Initializable, DangerousBase {
+    address public owner;
+
+    function initialize(address owner_) external initializer {
+        owner = owner_;
+    }
+
+    function execute(address, bytes calldata) external pure override {}
+}
+
+contract OverloadedDangerousBase is Initializable, DangerousBase {
+    address public owner;
+
+    function initialize(address owner_) external initializer { //~WARN: upgradeable initializer is not protected against direct implementation calls
+        owner = owner_;
+    }
+
+    function execute(uint256, bytes calldata) external pure {}
+}
+
+contract NoStateWriteInitializer is Initializable {
+    event Initialized(address indexed account);
+
+    function initialize(address account) external initializer {
+        emit Initialized(account);
+    }
+
+    function execute(address target, bytes calldata data) external {
+        (bool ok,) = target.delegatecall(data);
+        require(ok);
+    }
+}
+
+contract NonUpgradeableInitializeName {
+    address public owner;
+
+    function initialize(address owner_) external {
+        owner = owner_;
+    }
+}
+
+contract InheritedInitializerBase is Initializable {
+    address public owner;
+
+    function initialize(address owner_) public initializer { //~WARN: upgradeable initializer is not protected against direct implementation calls
+        owner = owner_;
+    }
+}
+
+contract InheritedInitializerDerived is InheritedInitializerBase {
+    function execute(address target, bytes calldata data) external {
+        (bool ok,) = target.delegatecall(data);
+        require(ok);
+    }
+}
+
+contract StorageAliasInitializer is Initializable {
+    struct Layout {
+        address owner;
+    }
+
+    bytes32 private constant SLOT = keccak256("foundry.storage.alias.initializer");
+
+    function _getLayout() private pure returns (Layout storage s) {
+        bytes32 slot = SLOT;
+        assembly {
+            s.slot := slot
+        }
+    }
+
+    function initialize(address owner_) public initializer { //~WARN: upgradeable initializer is not protected against direct implementation calls
+        Layout storage s = _getLayout();
+        s.owner = owner_;
+    }
+
+    function execute(address target, bytes calldata data) external {
+        (bool ok,) = target.delegatecall(data);
+        require(ok);
+    }
+}
+
+contract StorageReturnInitializer is Initializable {
+    struct Layout {
+        address owner;
+    }
+
+    Layout private layout;
+    Layout private alternateLayout;
+
+    function getLayout() internal view returns (Layout storage) {
+        return layout;
+    }
+
+    function getAlternateLayout() internal view returns (Layout storage) {
+        return alternateLayout;
+    }
+
+    function initialize(address owner_) public initializer { //~WARN: upgradeable initializer is not protected against direct implementation calls
+        getLayout().owner = owner_;
+    }
+
+    function initializeConditional(bool useAlternate, address owner_) public initializer { //~WARN: upgradeable initializer is not protected against direct implementation calls
+        (useAlternate ? getAlternateLayout() : getLayout()).owner = owner_;
+    }
+
+    function execute(address target, bytes calldata data) external {
+        (bool ok,) = target.delegatecall(data);
+        require(ok);
+    }
+}
+
+// Repeated edges and recursive calls must not hide a later state write.
+contract RepeatedInitializerCalls is Initializable, DangerousBase {
+    address public owner;
+
+    function initialize(address owner_) public initializer { //~WARN: upgradeable initializer is not protected against direct implementation calls
+        _left(owner_);
+        _right(owner_);
+    }
+
+    function _left(address owner_) internal {
+        _shared(owner_);
+    }
+
+    function _right(address owner_) internal {
+        _shared(owner_);
+        owner = owner_;
+    }
+
+    function _shared(address owner_) internal {
+        _left(owner_);
+    }
+}
+
+contract SelectedInitializerOverload is Initializable, DangerousBase {
+    uint256 public value;
+
+    function initialize(address account) external initializer {
+        _initialize(account);
+    }
+
+    function _initialize(address) internal pure {}
+
+    function _initialize(uint256 next) internal {
+        value = next;
+    }
+}
+
+contract ExternalLockBase {
+    bool private initialized;
+
+    modifier initializer() {
+        _;
+    }
+
+    function _disableInitializers() public {
+        initialized = true;
+    }
+}
+
+contract ExternalLockInitializer is ExternalLockBase, DangerousBase {
+    address public owner;
+
+    constructor(ExternalLockBase other) {
+        other._disableInitializers();
+    }
+
+    function initialize(address next) external initializer { //~WARN: upgradeable initializer is not protected against direct implementation calls
+        owner = next;
+    }
+}
+
+contract InternalPublicLockInitializer is ExternalLockBase, DangerousBase {
+    address public owner;
+
+    constructor() {
+        _disableInitializers();
+    }
+
+    function initialize(address next) external initializer {
+        owner = next;
+    }
+}
diff --git a/crates/lint/testdata/UnprotectedInitializer.stderr b/crates/lint/testdata/UnprotectedInitializer.stderr
new file mode 100644
index 0000000000000..9b12d068306b8
--- /dev/null
+++ b/crates/lint/testdata/UnprotectedInitializer.stderr
@@ -0,0 +1,104 @@
+warning[unprotected-initializer]: upgradeable initializer is not protected against direct implementation calls
+   ╭▸ ROOT/testdata/UnprotectedInitializer.sol:LL:CC
+   │
+LL │     function initialize(address owner_) public initializer {
+   │              ━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unprotected-initializer
+
+warning[unprotected-initializer]: upgradeable initializer is not protected against direct implementation calls
+   ╭▸ ROOT/testdata/UnprotectedInitializer.sol:LL:CC
+   │
+LL │     function initializeV2(uint256 fee_) external reinitializer(2) {
+   │              ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unprotected-initializer
+
+warning[unprotected-initializer]: upgradeable initializer is not protected against direct implementation calls
+   ╭▸ ROOT/testdata/UnprotectedInitializer.sol:LL:CC
+   │
+LL │     function initialize(address owner_) public initializer {
+   │              ━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unprotected-initializer
+
+warning[unprotected-initializer]: upgradeable initializer is not protected against direct implementation calls
+   ╭▸ ROOT/testdata/UnprotectedInitializer.sol:LL:CC
+   │
+LL │     function initialize(address owner_) public initializer {
+   │              ━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unprotected-initializer
+
+warning[unprotected-initializer]: upgradeable initializer is not protected against direct implementation calls
+   ╭▸ ROOT/testdata/UnprotectedInitializer.sol:LL:CC
+   │
+LL │     function initializeV2(uint256 fee_) external reinitializer(2) {
+   │              ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unprotected-initializer
+
+warning[unprotected-initializer]: upgradeable initializer is not protected against direct implementation calls
+   ╭▸ ROOT/testdata/UnprotectedInitializer.sol:LL:CC
+   │
+LL │     function initialize(address owner_) public initializer {
+   │              ━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unprotected-initializer
+
+warning[unprotected-initializer]: upgradeable initializer is not protected against direct implementation calls
+   ╭▸ ROOT/testdata/UnprotectedInitializer.sol:LL:CC
+   │
+LL │     function initialize(address owner_) external initializer {
+   │              ━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unprotected-initializer
+
+warning[unprotected-initializer]: upgradeable initializer is not protected against direct implementation calls
+   ╭▸ ROOT/testdata/UnprotectedInitializer.sol:LL:CC
+   │
+LL │     function initialize(address owner_) public initializer {
+   │              ━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unprotected-initializer
+
+warning[unprotected-initializer]: upgradeable initializer is not protected against direct implementation calls
+   ╭▸ ROOT/testdata/UnprotectedInitializer.sol:LL:CC
+   │
+LL │     function initialize(address owner_) public initializer {
+   │              ━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unprotected-initializer
+
+warning[unprotected-initializer]: upgradeable initializer is not protected against direct implementation calls
+   ╭▸ ROOT/testdata/UnprotectedInitializer.sol:LL:CC
+   │
+LL │     function initialize(address owner_) public initializer {
+   │              ━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unprotected-initializer
+
+warning[unprotected-initializer]: upgradeable initializer is not protected against direct implementation calls
+   ╭▸ ROOT/testdata/UnprotectedInitializer.sol:LL:CC
+   │
+LL │     function initializeConditional(bool useAlternate, address owner_) public initializer {
+   │              ━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unprotected-initializer
+
+warning[unprotected-initializer]: upgradeable initializer is not protected against direct implementation calls
+   ╭▸ ROOT/testdata/UnprotectedInitializer.sol:LL:CC
+   │
+LL │     function initialize(address owner_) public initializer {
+   │              ━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unprotected-initializer
+
+warning[unprotected-initializer]: upgradeable initializer is not protected against direct implementation calls
+   ╭▸ ROOT/testdata/UnprotectedInitializer.sol:LL:CC
+   │
+LL │     function initialize(address next) external initializer {
+   │              ━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unprotected-initializer
+
diff --git a/crates/lint/testdata/UnsafeCheatcodes.sol b/crates/lint/testdata/UnsafeCheatcodes.sol
new file mode 100644
index 0000000000000..ecec9ecfeedf4
--- /dev/null
+++ b/crates/lint/testdata/UnsafeCheatcodes.sol
@@ -0,0 +1,66 @@
+//@compile-flags: --severity info
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+import {Test} from "./auxiliary/Test.sol";
+
+contract UnsafeCheatcodes is Test {
+    function testSafeCheatcodes() public {
+        vm.prank(address(0x1));
+        vm.deal(address(0x1), 1 ether);
+        vm.warp(block.timestamp + 1);
+        vm.roll(block.number + 1);
+        vm.assume(true);
+        vm.expectRevert();
+    }
+
+    function testDirectFfi() public {
+        string[] memory inputs = new string[](1);
+        vm.ffi(inputs); //~NOTE: usage of unsafe cheatcodes that can perform dangerous operations
+    }
+
+    function testDirectReadFile() public {
+        vm.readFile("test.txt"); //~NOTE: usage of unsafe cheatcodes that can perform dangerous operations
+    }
+
+    function testDirectReadLine() public {
+        vm.readLine("test.txt"); //~NOTE: usage of unsafe cheatcodes that can perform dangerous operations
+    }
+
+    function testDirectWriteFile() public {
+        vm.writeFile("test.txt", "data"); //~NOTE: usage of unsafe cheatcodes that can perform dangerous operations
+    }
+
+    function testDirectWriteLine() public {
+        vm.writeLine("test.txt", "data"); //~NOTE: usage of unsafe cheatcodes that can perform dangerous operations
+    }
+
+    function testDirectRemoveFile() public {
+        vm.removeFile("test.txt"); //~NOTE: usage of unsafe cheatcodes that can perform dangerous operations
+    }
+
+    function testDirectCloseFile() public {
+        vm.closeFile("test.txt"); //~NOTE: usage of unsafe cheatcodes that can perform dangerous operations
+    }
+
+    function testDirectSetEnv() public {
+        vm.setEnv("KEY", "value"); //~NOTE: usage of unsafe cheatcodes that can perform dangerous operations
+    }
+
+    function testDirectDeriveKey() public {
+        vm.deriveKey("mnemonic", 0); //~NOTE: usage of unsafe cheatcodes that can perform dangerous operations
+    }
+
+    function testAssignmentFfi() public {
+        string[] memory inputs = new string[](1);
+        bytes memory result = vm.ffi(inputs); //~NOTE: usage of unsafe cheatcodes that can perform dangerous operations
+    }
+
+    function testMultipleUnsafe() public {
+        vm.ffi(new string[](1)); //~NOTE: usage of unsafe cheatcodes that can perform dangerous operations
+        vm.setEnv("KEY", "value"); //~NOTE: usage of unsafe cheatcodes that can perform dangerous operations
+        vm.readFile("test.txt"); //~NOTE: usage of unsafe cheatcodes that can perform dangerous operations
+    }
+
+}
diff --git a/crates/lint/testdata/UnsafeCheatcodes.stderr b/crates/lint/testdata/UnsafeCheatcodes.stderr
new file mode 100644
index 0000000000000..5b8b429942e80
--- /dev/null
+++ b/crates/lint/testdata/UnsafeCheatcodes.stderr
@@ -0,0 +1,104 @@
+note[unsafe-cheatcode]: usage of unsafe cheatcodes that can perform dangerous operations
+   ╭▸ ROOT/testdata/UnsafeCheatcodes.sol:LL:CC
+   │
+LL │         vm.ffi(inputs);
+   │            ━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-cheatcode
+
+note[unsafe-cheatcode]: usage of unsafe cheatcodes that can perform dangerous operations
+   ╭▸ ROOT/testdata/UnsafeCheatcodes.sol:LL:CC
+   │
+LL │         vm.readFile("test.txt");
+   │            ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-cheatcode
+
+note[unsafe-cheatcode]: usage of unsafe cheatcodes that can perform dangerous operations
+   ╭▸ ROOT/testdata/UnsafeCheatcodes.sol:LL:CC
+   │
+LL │         vm.readLine("test.txt");
+   │            ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-cheatcode
+
+note[unsafe-cheatcode]: usage of unsafe cheatcodes that can perform dangerous operations
+   ╭▸ ROOT/testdata/UnsafeCheatcodes.sol:LL:CC
+   │
+LL │         vm.writeFile("test.txt", "data");
+   │            ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-cheatcode
+
+note[unsafe-cheatcode]: usage of unsafe cheatcodes that can perform dangerous operations
+   ╭▸ ROOT/testdata/UnsafeCheatcodes.sol:LL:CC
+   │
+LL │         vm.writeLine("test.txt", "data");
+   │            ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-cheatcode
+
+note[unsafe-cheatcode]: usage of unsafe cheatcodes that can perform dangerous operations
+   ╭▸ ROOT/testdata/UnsafeCheatcodes.sol:LL:CC
+   │
+LL │         vm.removeFile("test.txt");
+   │            ━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-cheatcode
+
+note[unsafe-cheatcode]: usage of unsafe cheatcodes that can perform dangerous operations
+   ╭▸ ROOT/testdata/UnsafeCheatcodes.sol:LL:CC
+   │
+LL │         vm.closeFile("test.txt");
+   │            ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-cheatcode
+
+note[unsafe-cheatcode]: usage of unsafe cheatcodes that can perform dangerous operations
+   ╭▸ ROOT/testdata/UnsafeCheatcodes.sol:LL:CC
+   │
+LL │         vm.setEnv("KEY", "value");
+   │            ━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-cheatcode
+
+note[unsafe-cheatcode]: usage of unsafe cheatcodes that can perform dangerous operations
+   ╭▸ ROOT/testdata/UnsafeCheatcodes.sol:LL:CC
+   │
+LL │         vm.deriveKey("mnemonic", 0);
+   │            ━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-cheatcode
+
+note[unsafe-cheatcode]: usage of unsafe cheatcodes that can perform dangerous operations
+   ╭▸ ROOT/testdata/UnsafeCheatcodes.sol:LL:CC
+   │
+LL │         bytes memory result = vm.ffi(inputs);
+   │                                  ━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-cheatcode
+
+note[unsafe-cheatcode]: usage of unsafe cheatcodes that can perform dangerous operations
+   ╭▸ ROOT/testdata/UnsafeCheatcodes.sol:LL:CC
+   │
+LL │         vm.ffi(new string[](1));
+   │            ━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-cheatcode
+
+note[unsafe-cheatcode]: usage of unsafe cheatcodes that can perform dangerous operations
+   ╭▸ ROOT/testdata/UnsafeCheatcodes.sol:LL:CC
+   │
+LL │         vm.setEnv("KEY", "value");
+   │            ━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-cheatcode
+
+note[unsafe-cheatcode]: usage of unsafe cheatcodes that can perform dangerous operations
+   ╭▸ ROOT/testdata/UnsafeCheatcodes.sol:LL:CC
+   │
+LL │         vm.readFile("test.txt");
+   │            ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-cheatcode
+
diff --git a/crates/lint/testdata/UnsafeOzErc721Mint.sol b/crates/lint/testdata/UnsafeOzErc721Mint.sol
new file mode 100644
index 0000000000000..17371d989884f
--- /dev/null
+++ b/crates/lint/testdata/UnsafeOzErc721Mint.sol
@@ -0,0 +1,2589 @@
+//@compile-flags: --only-lint unsafe-oz-erc721-mint
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+import {
+    ERC721,
+    ERC721Upgradeable,
+    ERC721Consecutive
+} from "./auxiliary/openzeppelin-contracts/Erc721Mocks.sol";
+import {ERC721 as LocalERC721} from "./auxiliary/not-openzeppelin/Erc721Mocks.sol";
+
+// Tests for `unsafe-oz-erc721-mint`: `ERC721._mint` credits a token without checking that the
+// recipient can receive it (no `onERC721Received` call), so minting to a non-receiver contract
+// locks the token; `_safeMint` performs the check. A call is flagged when it resolves to a
+// function named `_mint` declared in a contract named exactly `ERC721`, `ERC721Upgradeable`,
+// `ERC721Consecutive` or `ERC721ConsecutiveUpgradeable` (the v4 Consecutive extensions forward
+// to the base without the check) whose source comes from an OpenZeppelin package path, or to a
+// user `_mint` override that transitively delegates to one of those, unless every successful path
+// proves the recipient code-less or rejects it after the delegation. Calls to `_safeMint`, calls
+// made inside the canonical `_safeMint` wrapper, `_mint` functions of other contracts and
+// same-name local contracts stay clean.
+
+// Same `_mint` shape on a fungible token: transfers need no receiver check, out of scope.
+contract ERC20 {
+    mapping(address => uint256) internal _balances;
+
+    function _mint(address account, uint256 amount) internal {
+        _balances[account] += amount;
+    }
+}
+
+contract DirectNft is ERC721 {
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+
+    function mintSuper(address to, uint256 id) external {
+        super._mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+
+    function mintQualified(address to, uint256 id) external {
+        ERC721._mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+
+    function mintSafe(address to, uint256 id) external {
+        _safeMint(to, id);
+    }
+}
+
+contract BaseNft is ERC721 {}
+
+// The ERC721 base is two levels up: resolution through the linearized bases still finds it.
+contract IndirectNft is BaseNft {
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+
+}
+
+contract UpgradeableNft is ERC721Upgradeable {
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// A consumer of the extension resolves `_mint` to the Consecutive override: still unchecked.
+// The mirror lives under the OpenZeppelin auxiliary path; its own `super._mint` sits inside a
+// `_mint` override and stays exempt.
+contract ConsecutiveNft is ERC721Consecutive {
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// A user-defined `_safeMint` override is not the canonical OZ wrapper: calling `_mint`
+// directly inside it skips the receiver check and must stay analyzed.
+contract BrokenSafeNft is ERC721 {
+    function _safeMint(address to, uint256 tokenId) internal override {
+        _mint(to, tokenId); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// A safe override in a contract whose name happens to contain "ERC721": the call resolves
+// to the local override, so nothing fires, since matching is on the exact base name.
+contract SafeERC721Override is ERC721 {
+    function _mint(address to, uint256 tokenId) internal override {
+        _owners[tokenId] = to;
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id);
+    }
+}
+
+contract Token is ERC20 {
+    function mint(address account, uint256 amount) external {
+        _mint(account, amount);
+    }
+}
+
+// Overriding `_mint` without calling the base makes the override the dispatch target:
+// the plain call is safe, only an explicit `super._mint` still reaches the base.
+contract SafeOverride is ERC721 {
+    function _mint(address to, uint256 tokenId) internal override {
+        _owners[tokenId] = to;
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id);
+    }
+
+    function mintSuper(address to, uint256 id) external {
+        super._mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// A `_mint` override delegating to the base with its own guard (the capped/pausable
+// pattern) is an unsafe call target: the path still reaches the unchecked base. Direct
+// calls dispatching to it report; the `super._mint` inside the override itself does not,
+// since the override is the mint primitive and `_safeMint` there would re-enter it through
+// the virtual dispatch.
+contract CappedNft is ERC721 {
+    uint256 internal total;
+    uint256 internal constant CAP = 10;
+
+    function _mint(address to, uint256 tokenId) internal override {
+        require(total < CAP, "cap");
+        total++;
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// The delegation is judged transitively: an override that simply forwards still reaches the
+// unchecked base, so its direct callers report, while `_safeMint` stays the clean path.
+contract DelegatingOverrideNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        super._mint(to, tokenId);
+    }
+
+    function unsafeMint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+
+    function safeMint(address to, uint256 id) external {
+        _safeMint(to, id);
+    }
+}
+
+// Delegation through an ordinary helper still makes the override unsafe. The diagnostic belongs
+// at the override's caller, where replacing `_mint` with `_safeMint` does not recurse.
+contract HelperDelegatingOverrideNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal override {
+        mintUnchecked(to, tokenId);
+    }
+
+    function mintUnchecked(address to, uint256 tokenId) private {
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+
+    function mintDirect(address to, uint256 id) external {
+        mintUnchecked(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// A same-name overload can also be the ordinary helper that an override delegates through. Its
+// direct base call stays suppressed, while callers of both the override and overload report.
+contract OverloadDelegatingOverrideNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal override {
+        _mint(to, tokenId, "");
+    }
+
+    function _mint(address to, uint256 tokenId, bytes memory data) internal {
+        data;
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+
+    function mintWithData(address to, uint256 id, bytes memory data) external {
+        _mint(to, id, data); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// Helpers unrelated to an override still report at their direct unsafe call, not again at every
+// caller.
+contract OrdinaryHelperNft is ERC721 {
+    function mintUnchecked(address to, uint256 tokenId) private {
+        super._mint(to, tokenId); //~WARN: `ERC721._mint` does not check
+    }
+
+    function mint(address to, uint256 id) external {
+        mintUnchecked(to, id);
+    }
+}
+
+// A user `_safeMint` is not assumed safe by name: this override delegates to a broken wrapper
+// that skips the receiver check.
+contract BrokenSafeDelegatingOverrideNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal override {
+        _safeMint(to, tokenId);
+    }
+
+    function _safeMint(address to, uint256 tokenId) internal override {
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+contract BaseInheritedHelperNft is ERC721 {
+    function mintUnchecked(address to, uint256 tokenId) internal {
+        super._mint(to, tokenId);
+    }
+}
+
+// Helper delegation can cross an inheritance boundary without moving the diagnostic back into the
+// shared base helper.
+contract InheritedHelperDelegatingOverrideNft is BaseInheritedHelperNft {
+    function _mint(address to, uint256 tokenId) internal override {
+        mintUnchecked(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+
+    function mintDirect(address to, uint256 id) external {
+        mintUnchecked(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+contract MiddleSafe is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        _owners[tokenId] = to;
+    }
+}
+
+// `super` dispatches to the closest declaration in the linearization: MiddleSafe's safe
+// override, not the ERC721 base behind it.
+contract ChildOfMiddle is MiddleSafe {
+    function mintSuper(address to, uint256 id) external {
+        super._mint(to, id);
+    }
+}
+
+// A local overload with a different arity is the only candidate a one-argument call can
+// dispatch to: out of scope. The two-argument call still resolves to the base `_mint`.
+contract OverloadNft is ERC721 {
+    function _mint(address to) internal {
+        _owners[0] = to;
+    }
+
+    function mintOne(address to) external {
+        _mint(to);
+    }
+
+    function mintTwo(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// A genuine same-arity overload with different parameter types is not an override of the base
+// `_mint(address, uint256)`: the two-argument uint call still dispatches to the unsafe base.
+contract DataNft is ERC721 {
+    function _mint(address to, bytes memory data) internal {
+        _owners[uint256(uint160(to))] = to;
+        data;
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+
+    function mintWithData(address to, bytes memory data) external {
+        _mint(to, data);
+    }
+}
+
+// A library is not the OZ ERC721 contract, even with ERC721 in its name: out of scope.
+library ERC721Lib {
+    function _mint(address to, uint256 id) internal pure {}
+}
+
+contract UsesLib {
+    function mint(address to, uint256 id) external pure {
+        ERC721Lib._mint(to, id);
+    }
+}
+
+// Calls in a modifier or a constructor are calls like any other.
+contract EagerNft is ERC721 {
+    modifier premint(address to, uint256 id) {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+        _;
+    }
+
+    constructor(address to) {
+        _mint(to, 0); //~WARN: `ERC721._mint` does not check
+    }
+
+    function noop(address to, uint256 id) external premint(to, id) {}
+}
+
+// A local `_mint` on a contract that is not an ERC721: out of scope.
+contract Standalone {
+    uint256 internal _supply;
+
+    function _mint(uint256 amount) internal {
+        _supply += amount;
+    }
+
+    function mint(uint256 amount) external {
+        _mint(amount);
+    }
+}
+
+// A local contract reusing the exact `ERC721ConsecutiveUpgradeable` name, unrelated to
+// OpenZeppelin: the provenance check keeps its `_mint` out of scope.
+contract ERC721ConsecutiveUpgradeable {
+    mapping(uint256 => address) internal _holders;
+
+    function _mint(address account, uint256 amount) internal {
+        _holders[amount] = account;
+    }
+}
+
+contract UsesConsecutiveUpgradeableName is ERC721ConsecutiveUpgradeable {
+    function mint(address account, uint256 amount) external {
+        _mint(account, amount);
+    }
+}
+
+interface IERC721Receiver {
+    function onERC721Received(address operator, address from, uint256 tokenId, bytes calldata data)
+        external
+        returns (bytes4);
+}
+
+// A same-name hook carrying another shape, for the case below.
+interface INotAReceiver {
+    function onERC721Received(address operator) external returns (bytes4);
+}
+
+// Calling the hook before ownership is established is not equivalent to `_safeMint`: the
+// recipient may accept before the mint but reject after it based on `ownerOf` or its balance.
+contract CheckedOverrideNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        if (to.code.length > 0) {
+            require(
+                IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                    == IERC721Receiver.onERC721Received.selector,
+                "unsafe receiver"
+            );
+        }
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// The guard may follow the mint, as the canonical `_safeMint` does: the revert undoes it.
+contract CheckedAfterMintNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        super._mint(to, tokenId);
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id);
+    }
+}
+
+// A reverting refusal branch still runs the callback too early when it precedes the mint.
+contract CheckedByRevertNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        if (
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                != IERC721Receiver.onERC721Received.selector
+        ) {
+            revert("unsafe receiver");
+        }
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// The account short circuit is safe for accounts, but contract recipients are called before
+// ownership is established and can make their decision based on the missing state.
+contract CheckedOrAccountNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        require(
+            to.code.length == 0
+                || IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                    == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// A callback in a modifier prefix still precedes the wrapped mint and cannot replace `_safeMint`.
+contract CheckedByModifierNft is ERC721 {
+    modifier checked(address to, uint256 tokenId) {
+        require(
+            to.code.length == 0
+                || IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                    == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+        _;
+    }
+
+    function _mint(address to, uint256 tokenId) internal virtual override checked(to, tokenId) {
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// The guard sits after the placeholder: the body's plain `return` still routes through the
+// modifier's tail, so the check runs on every path that keeps the token.
+contract ModifierTailGuardNft is ERC721 {
+    modifier checkedAfter(address to, uint256 tokenId) {
+        _;
+        require(
+            to.code.length == 0
+                || IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                    == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+    }
+
+    function _mint(address to, uint256 tokenId)
+        internal
+        virtual
+        override
+        checkedAfter(to, tokenId)
+    {
+        super._mint(to, tokenId);
+        if (tokenId == 0) {
+            return;
+        }
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id);
+    }
+}
+
+// The same tail guard over a body holding an assembly block: the EVM `return` it can hold
+// leaves the call frame without ever coming back to the modifier, keeping the token unchecked.
+contract ModifierTailAssemblyNft is ERC721 {
+    modifier checkedAfter(address to, uint256 tokenId) {
+        _;
+        require(
+            to.code.length == 0
+                || IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                    == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+    }
+
+    function _mint(address to, uint256 tokenId)
+        internal
+        virtual
+        override
+        checkedAfter(to, tokenId)
+    {
+        super._mint(to, tokenId);
+        assembly {
+            return(0, 0)
+        }
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// The hook sits in the condition, but a first operand can satisfy it on its own: the call never
+// runs for a trusted recipient, which is then minted to unchecked.
+contract ShortCircuitGuardNft is ERC721 {
+    address internal trusted;
+
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        require(
+            to == trusted
+                || IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                    == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// The hook rides in the revert message, where its answer decides nothing.
+contract MessageArgumentNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        require(
+            tokenId > 0,
+            string(
+                abi.encodePacked(
+                    IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                )
+            )
+        );
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// The hook is asked of a guardian, so the recipient never answered.
+contract GuardianHookNft is ERC721 {
+    mapping(address => address) internal guardians;
+
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        require(
+            IERC721Receiver(guardians[to]).onERC721Received(msg.sender, address(0), tokenId, "")
+                == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// The exiting branch is the one an acceptance takes, so a refusal falls through to the mint.
+contract InvertedGuardNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        if (
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                == IERC721Receiver.onERC721Received.selector
+        ) {
+            revert("accepted");
+        }
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// `to.code.length >= 1` selects contracts, but their callback still runs before ownership is set.
+contract CheckedAtLeastOneNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        if (to.code.length >= 1) {
+            require(
+                IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                    == IERC721Receiver.onERC721Received.selector,
+                "unsafe receiver"
+            );
+        }
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// The refusal only returns, and the token was already credited.
+contract ReturnGuardNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        super._mint(to, tokenId);
+        if (
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                != IERC721Receiver.onERC721Received.selector
+        ) {
+            return;
+        }
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// The guard sits in a `virtual` helper, which an override may replace with an empty body: the
+// analyzed declaration is not the one the call dispatches to.
+contract VirtualCheckNft is ERC721 {
+    function _check(address to, uint256 tokenId) internal virtual {
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+    }
+
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        if (to.code.length > 0) {
+            _check(to, tokenId);
+        }
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// The reverting branch lets a privileged caller `return` first, keeping the token it was already
+// credited: a block reverts only when nothing before the revert can leave the function.
+contract EarlyReturnGuardNft is ERC721 {
+    address internal owner;
+
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        super._mint(to, tokenId);
+        if (
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                != IERC721Receiver.onERC721Received.selector
+        ) {
+            if (msg.sender == owner) {
+                return;
+            }
+            revert("unsafe receiver");
+        }
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// The accepting answer moved into a constant does not become accepting: a recipient answering
+// zero refused the token.
+contract WrongConstantNft is ERC721 {
+    bytes4 private constant REFUSED = 0x00000000;
+
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "") == REFUSED,
+            "unsafe receiver"
+        );
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// The second mint hands the base an address the guard never named.
+contract SecondRecipientNft is ERC721 {
+    address internal treasury;
+
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+        super._mint(to, tokenId);
+        super._mint(treasury, tokenId + 1);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// The mint sits in the accepting branch and a sibling statement reverts. Nothing locks here, but
+// reading that takes the order of the statements, so the wrapper reports: a conservative report.
+contract FallThroughRevertNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        if (to.code.length > 0) {
+            if (
+                IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                    == IERC721Receiver.onERC721Received.selector
+            ) {
+                super._mint(to, tokenId);
+                return;
+            }
+            revert("unsafe receiver");
+        }
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+contract ForwardingBaseOne is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        super._mint(to, tokenId);
+    }
+}
+
+contract ForwardingBaseTwo is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        super._mint(to, tokenId);
+    }
+}
+
+// The two bases reach the same unchecked `_mint`, and the second one mints elsewhere.
+contract DiamondNft is ForwardingBaseOne, ForwardingBaseTwo {
+    function _mint(address to, uint256 tokenId)
+        internal
+        virtual
+        override(ForwardingBaseOne, ForwardingBaseTwo)
+    {
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+        ForwardingBaseOne._mint(to, tokenId);
+        ForwardingBaseTwo._mint(address(1), tokenId + 1);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// The assembly block can hold the EVM `return`, so the `revert` after it may never run.
+contract AssemblyEscapeNft is ERC721 {
+    address internal owner;
+
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        super._mint(to, tokenId);
+        if (
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                != IERC721Receiver.onERC721Received.selector
+        ) {
+            if (msg.sender == owner) {
+                assembly {
+                    return(0, 0)
+                }
+            }
+            revert("unsafe receiver");
+        }
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// The accepting answer settled at deployment is unknown here, so it does not exempt.
+contract ImmutableAnswerNft is ERC721 {
+    bytes4 private immutable answer;
+
+    constructor(bytes4 expected) {
+        answer = expected;
+    }
+
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "") == answer,
+            "unsafe receiver"
+        );
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// Named mint arguments preserve the checked identities for a callback that follows the mint.
+contract NamedArgumentNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        super._mint({tokenId: tokenId, to: to});
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id);
+    }
+}
+
+// `assert` reverts on refusal as `require` does when it follows the mint.
+contract CheckedByAssertNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        super._mint(to, tokenId);
+        assert(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                == IERC721Receiver.onERC721Received.selector
+        );
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id);
+    }
+}
+
+// The accepting answer may be held by a named constant in a post-mint callback guard.
+contract NamedSelectorNft is ERC721 {
+    bytes4 private constant RECEIVED = 0x150b7a02;
+
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        super._mint(to, tokenId);
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "") == RECEIVED,
+            "unsafe receiver"
+        );
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id);
+    }
+}
+
+// The answer gates the revert, but against a literal that is not the accepting one: a recipient
+// answering zero refused the token and is minted to anyway.
+contract WrongAnswerNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "") == bytes4(0),
+            "unsafe receiver"
+        );
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// A loop body may never run, so a guard inside one governs nothing.
+contract LoopGuardNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        for (uint256 i = 0; i < tokenId; i++) {
+            require(
+                IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                    == IERC721Receiver.onERC721Received.selector,
+                "unsafe receiver"
+            );
+        }
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// A helper returning the answer as a `bool` reverts on refusal through its caller, but the value
+// is not followed across the call, so the wrapper reports.
+contract BoolHelperNft is ERC721 {
+    function _accepts(address to, uint256 tokenId) private returns (bool) {
+        return IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+            == IERC721Receiver.onERC721Received.selector;
+    }
+
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        require(_accepts(to, tokenId), "unsafe receiver");
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// A helper can discharge a mint that preceded it. Later state-changing work in the helper cannot
+// retroactively invalidate the completed code-less or callback check.
+contract CheckedViaHelperNft is ERC721 {
+    function _requireReceiver(address to, uint256 tokenId) private {
+        require(
+            to.code.length == 0
+                || IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                    == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+        (bool ok,) = to.call("");
+        ok;
+    }
+
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        super._mint(to, tokenId);
+        _requireReceiver(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id);
+    }
+}
+
+// A `try` hands the refusal to its `catch`, which may swallow it: the mint goes through even
+// for a recipient that reverts, so the override is not a safe wrapper.
+contract TryCatchOverrideNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        try IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "") returns (
+            bytes4
+        ) {} catch {}
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// The hook runs, but its answer is discarded: a recipient refusing the token is still minted to.
+contract IgnoredAnswerNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "");
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// The guard is real but a condition unrelated to the recipient decides whether it runs, so it
+// governs nothing: every other token id reaches the unchecked base.
+contract ConditionalGuardNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        if (tokenId == 123456789) {
+            require(
+                IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                    == IERC721Receiver.onERC721Received.selector,
+                "unsafe receiver"
+            );
+        }
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// The `.code` comparison is not alone here, so the guard does not govern the mint.
+contract PartialGuardNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        if (to.code.length > 0 && tokenId == 5) {
+            require(
+                IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                    == IERC721Receiver.onERC721Received.selector,
+                "unsafe receiver"
+            );
+        }
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// The answer is checked, but through a local: the value is not followed across statements, so
+// the wrapper is reported. A documented limit, on the safe side.
+contract StoredAnswerNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        bytes4 answer = IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "");
+        require(answer == IERC721Receiver.onERC721Received.selector, "unsafe receiver");
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// The guarded call lands on a same-name hook of another interface, so the shape rules it out.
+contract WrongHookNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        require(
+            INotAReceiver(to).onERC721Received(msg.sender) == bytes4(0x150b7a02), "unsafe receiver"
+        );
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// A `.code` guard is a contract check, not a receiver check: direct callers still report.
+contract CodeGuardedOverrideNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        require(to.code.length > 0, "no receiver");
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// A code-less recipient cannot need the ERC721 receiver callback, so this is safe.
+contract AccountOnlyNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        require(to.code.length == 0, "accounts only");
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id);
+    }
+}
+
+// A code-less proof is about the recipient, not the token. Remapping the token after the proof
+// does not make the recipient require a callback.
+contract AccountOnlyRemappedTokenNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        require(to.code.length == 0, "accounts only");
+        tokenId += 1;
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id);
+    }
+}
+
+contract AccountOnlyComputedTokenNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        require(to.code.length == 0, "accounts only");
+        super._mint(to, tokenId + 1);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id);
+    }
+}
+
+contract AccountOnlyHelperNft is ERC721 {
+    function _requireAccount(address to) private view {
+        require(to.code.length == 0, "accounts only");
+    }
+
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        _requireAccount(to);
+        super._mint(to, tokenId + 1);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id);
+    }
+}
+
+contract AccountOnlyModifierNft is ERC721 {
+    modifier accountOnly(address to) {
+        require(to.code.length == 0, "accounts only");
+        _;
+    }
+
+    function _mint(address to, uint256 tokenId) internal virtual override accountOnly(to) {
+        tokenId += 1;
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id);
+    }
+}
+
+contract Create2NonReceiver {}
+
+// A code-length check is only a snapshot. An internal call can deploy a contract at a
+// precomputed `to` address before the delegation, so the override still needs `_safeMint` at
+// its call site.
+contract AccountOnlyThenInternalCallNft is ERC721 {
+    function _deployRecipient(uint256 salt) private {
+        new Create2NonReceiver{salt: bytes32(salt)}();
+    }
+
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        require(to.code.length == 0, "accounts only");
+        _deployRecipient(tokenId);
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// The same invalidation applies to an external call, whose callee may run CREATE2 before the
+// delegated mint.
+contract AccountOnlyThenExternalCallNft is ERC721 {
+    function deployRecipient(uint256 salt) external {
+        new Create2NonReceiver{salt: bytes32(salt)}();
+    }
+
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        require(to.code.length == 0, "accounts only");
+        this.deployRecipient(tokenId);
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// A call hidden in another `require` argument can run after the code-length expression, so the
+// recognized condition does not make the whole guard statement stable.
+contract AccountOnlyGuardArgumentCallNft is ERC721 {
+    function _deployAndReturnMessage(uint256 salt) private returns (string memory) {
+        new Create2NonReceiver{salt: bytes32(salt)}();
+        return "accounts only";
+    }
+
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        require(to.code.length == 0, _deployAndReturnMessage(tokenId));
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// A statically known internal helper that only writes storage cannot deploy code and leaves the
+// snapshot valid.
+contract AccountOnlyThenStorageCallNft is ERC721 {
+    uint256 private calls;
+
+    function _incrementCalls() private {
+        calls++;
+    }
+
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        require(to.code.length == 0, "accounts only");
+        _incrementCalls();
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id);
+    }
+}
+
+// Pure and view calls cannot deploy code, so they do not retire an account-only proof.
+contract AccountOnlyThenViewCallNft is ERC721 {
+    function _observe() private view returns (bool) {
+        return address(this).code.length > 0;
+    }
+
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        require(to.code.length == 0, "accounts only");
+        require(_observe(), "not deployed");
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id);
+    }
+}
+
+// A receiver callback before the mint is not proof of post-mint acceptance, regardless of an
+// unrelated state-changing call between the callback and delegation.
+contract CallbackThenInternalCallNft is ERC721 {
+    function _deployOther(uint256 salt) private {
+        new Create2NonReceiver{salt: bytes32(salt)}();
+    }
+
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+        _deployOther(tokenId);
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// Modifier prefixes execute from outer to inner. The inner deployment invalidates the outer
+// account-only snapshot before the body delegates.
+contract AccountOnlyOuterDeployInnerNft is ERC721 {
+    modifier accountOnly(address to) {
+        require(to.code.length == 0, "accounts only");
+        _;
+    }
+
+    modifier deployBefore(uint256 salt) {
+        new Create2NonReceiver{salt: bytes32(salt)}();
+        _;
+    }
+
+    function _mint(address to, uint256 tokenId)
+        internal
+        virtual
+        override
+        accountOnly(to)
+        deployBefore(tokenId)
+    {
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// A modifier with a nested placeholder is not expanded precisely, but its deployment still
+// retires an outer snapshot on the path that reaches the body.
+contract AccountOnlyOuterNestedDeployInnerNft is ERC721 {
+    bool private enabled;
+
+    modifier accountOnly(address to) {
+        require(to.code.length == 0, "accounts only");
+        _;
+    }
+
+    modifier deployBefore(uint256 salt) {
+        new Create2NonReceiver{salt: bytes32(salt)}();
+        if (enabled) {
+            _;
+        }
+    }
+
+    function _mint(address to, uint256 tokenId)
+        internal
+        virtual
+        override
+        accountOnly(to)
+        deployBefore(tokenId)
+    {
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// Modifier arguments are evaluated as their modifier is entered. An inner argument can therefore
+// replace the function recipient after an outer modifier checked the original value.
+contract AccountOnlyOuterMutatingModifierArgNft is ERC721 {
+    modifier accountOnly(address to) {
+        require(to.code.length == 0, "accounts only");
+        _;
+    }
+
+    modifier passthrough(address) {
+        _;
+    }
+
+    function _mint(address to, uint256 tokenId)
+        internal
+        virtual
+        override
+        accountOnly(to)
+        passthrough(to = address(1))
+    {
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// An outer tail guard also captures the original argument. A later modifier-argument assignment
+// must retire that deferred proof before the body mints the replacement recipient.
+contract AccountOnlyTailOuterMutatingModifierArgNft is ERC721 {
+    modifier accountOnlyAfter(address checked) {
+        _;
+        require(checked.code.length == 0, "accounts only");
+    }
+
+    modifier passthrough(address) {
+        _;
+    }
+
+    function _mint(address to, uint256 tokenId)
+        internal
+        virtual
+        override
+        accountOnlyAfter(to)
+        passthrough(to = address(1))
+    {
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// A call after the placeholder cannot retroactively invalidate the snapshot that covered the
+// delegated mint in the body.
+contract AccountOnlyThenModifierTailCallNft is ERC721 {
+    modifier accountOnly(address to, uint256 salt) {
+        require(to.code.length == 0, "accounts only");
+        _;
+        new Create2NonReceiver{salt: bytes32(salt)}();
+    }
+
+    function _mint(address to, uint256 tokenId)
+        internal
+        virtual
+        override
+        accountOnly(to, tokenId)
+    {
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id);
+    }
+}
+
+// Conversely, a tail code-length guard runs after a state-changing body call and reverts if that
+// call deployed the recipient, so every successful mint remains covered.
+contract AccountOnlyModifierTailGuardNft is ERC721 {
+    modifier accountOnlyAfter(address to) {
+        _;
+        require(to.code.length == 0, "accounts only");
+    }
+
+    function _deployRecipient(uint256 salt) private {
+        new Create2NonReceiver{salt: bytes32(salt)}();
+    }
+
+    function _mint(address to, uint256 tokenId)
+        internal
+        virtual
+        override
+        accountOnlyAfter(to)
+    {
+        _deployRecipient(tokenId);
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id);
+    }
+}
+
+// A bare read of the recipient's `.code` guards nothing at all: the value is never used to
+// gate the delegated mint, so direct callers report.
+contract BareCodeReadNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        uint256 len = to.code.length;
+        len;
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// A construction guard on `address(this)` says nothing about the recipient and leaves the path
+// to the unchecked base wide open for arbitrary ones, so direct callers of the override report.
+contract ConstructionGuardedNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        require(address(this).code.length > 0, "no construction mint");
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// A successful exit sits between the mint and the guard: token zero is credited and the
+// function leaves before the hook ever runs, so the guard does not govern that path.
+contract MintThenExitGuardNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        super._mint(to, tokenId);
+        if (tokenId == 0) {
+            return;
+        }
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// Named helper arguments bind by parameter name, not by source position: the helper checks
+// `checked`, which the call binds to the guardian, so the recipient is never asked.
+contract NamedHelperArgsNft is ERC721 {
+    address internal guardian;
+
+    function _check(address checked, address other, uint256 id) internal {
+        other;
+        require(
+            IERC721Receiver(checked).onERC721Received(msg.sender, address(0), id, "")
+                == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+    }
+
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        super._mint(to, tokenId);
+        _check({other: to, checked: guardian, id: tokenId});
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// The helper holds a real guard shape, but asks about a fixed token, not the delegated one.
+contract HelperWrongTokenNft is ERC721 {
+    function _check(address to, uint256 id) internal {
+        id;
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), 0, "")
+                == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+    }
+
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        super._mint(to, tokenId);
+        _check(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// A mutable state token is not a stable identity: an intervening call can reenter and change it
+// after the mint reads the old value but before the callback guard reads the new one.
+contract StateTokenNft is ERC721 {
+    uint256 internal nextId;
+
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        tokenId;
+        super._mint(to, nextId);
+        this.bumpNextId();
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), nextId, "")
+                == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+    }
+
+    function bumpNextId() external {
+        nextId++;
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// The hook is asked about another token than the one the delegation mints: the recipient may
+// accept the former and refuse the latter.
+contract WrongTokenHookNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        super._mint(to, tokenId);
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId + 1, "")
+                == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// A member spelled `onERC721Received.selector` on another interface is not the accepting
+// answer: the one-parameter hook hashes to a different selector, so a recipient answering
+// the real one still fails this comparison, and one answering this value never accepted.
+contract ForeignSelectorNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                == INotAReceiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// The delegation truncates the recipient through a lossy cast chain: the minted address is
+// usually not the one the guard asked, so the cast does not preserve the recipient.
+contract TruncatedRecipientNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        super._mint(address(uint160(uint8(uint160(to)))), tokenId);
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// A callback modifier prefix is too early to cover the mint; the body also remaps its token.
+contract ModifierGuardRemapTokenNft is ERC721 {
+    modifier checked(address to, uint256 tokenId) {
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+        _;
+    }
+
+    function _mint(address to, uint256 tokenId) internal virtual override checked(to, tokenId) {
+        tokenId = tokenId + 1;
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// A callback modifier prefix is too early to cover the mint; the body also redirects its
+// recipient.
+contract ModifierGuardRedirectNft is ERC721 {
+    address internal attacker;
+
+    modifier checked(address to, uint256 tokenId) {
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+        _;
+    }
+
+    function _mint(address to, uint256 tokenId) internal virtual override checked(to, tokenId) {
+        to = attacker;
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// A tail-guard modifier checks the entry value on the way out; the body still mints a
+// reassigned token the modifier's captured argument never covered.
+contract ModifierTailRemapNft is ERC721 {
+    modifier checkedAfter(address to, uint256 tokenId) {
+        _;
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+    }
+
+    function _mint(address to, uint256 tokenId)
+        internal
+        virtual
+        override
+        checkedAfter(to, tokenId)
+    {
+        tokenId = tokenId + 1;
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// A post-mint guard reassigns the token inside another hook argument. Conservatively, mutation
+// during the guard retires correlation even though the third argument was evaluated first.
+contract GuardArgReassignNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        super._mint(to, tokenId);
+        require(
+            IERC721Receiver(to).onERC721Received(
+                msg.sender, address(0), tokenId, abi.encodePacked(tokenId = tokenId + 1)
+            ) == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// The token is reassigned inside an `if` condition between the mint and callback, so the hook
+// acknowledges a different value from the one already credited.
+contract CondAssignTokenNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        super._mint(to, tokenId);
+        if ((tokenId = tokenId + 1) > 0) {}
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// The recipient is redirected inside an `if` condition after the mint, so the hook asks another
+// address to accept the already credited token.
+contract CondAssignRecipientNft is ERC721 {
+    address internal attacker;
+
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        super._mint(to, tokenId);
+        if ((to = attacker) != address(0)) {}
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// A post-increment between the mint and callback makes the hook acknowledge the next token.
+contract CondIncrementTokenNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        super._mint(to, tokenId);
+        if (tokenId++ > 0) {}
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// A local is reassigned after the mint, so the later callback checks another token despite using
+// the same variable name.
+contract LocalTokenReassignNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        uint256 id = tokenId;
+        super._mint(to, id);
+        id = tokenId + 1;
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), id, "")
+                == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// The recipient parameter is reassigned after the mint, so the callback asks another address.
+contract RecipientReassignNft is ERC721 {
+    address internal attacker;
+
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        super._mint(to, tokenId);
+        to = attacker;
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// The token parameter is remapped after the mint, so the callback acknowledges the offset token.
+contract ParamTokenRemapNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        super._mint(to, tokenId);
+        tokenId = tokenId + 1;
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// The account branch a `to.code.length` test seeds as covered redirects the recipient to a
+// contract and mints unchecked: the seed assumed an account, which the reassignment defeats.
+contract AccountBranchReassignNft is ERC721 {
+    address internal attacker;
+
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        if (to.code.length > 0) {
+            require(
+                IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                    == IERC721Receiver.onERC721Received.selector,
+                "unsafe receiver"
+            );
+            super._mint(to, tokenId);
+        } else {
+            to = attacker;
+            super._mint(to, tokenId);
+        }
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// The check after the mint may be skipped for accounts: an account accepts the token already
+// credited as it accepts any other, and the contract branch reverts on refusal.
+contract MintThenAccountSkipNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        super._mint(to, tokenId);
+        if (to.code.length > 0) {
+            require(
+                IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                    == IERC721Receiver.onERC721Received.selector,
+                "unsafe receiver"
+            );
+        }
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id);
+    }
+}
+
+// The same skip written as an early return: the path that leaves is the account one, which
+// keeps the token rightfully, and the contract path still reaches the guard.
+contract MintThenReversedSkipNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        super._mint(to, tokenId);
+        if (to.code.length == 0) {
+            return;
+        }
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id);
+    }
+}
+
+// A conditional mint is still covered by the unconditional guard after it: whichever path
+// credited the token, the revert undoes it.
+contract ConditionalMintNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        if (tokenId % 2 == 0) {
+            super._mint(to, tokenId);
+        }
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id);
+    }
+}
+
+// A post-mint callback behind a condition covers the mint when every branch performs one.
+contract EitherBranchGuardNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        super._mint(to, tokenId);
+        if (tokenId % 2 == 0) {
+            require(
+                IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                    == IERC721Receiver.onERC721Received.selector,
+                "unsafe receiver"
+            );
+        } else {
+            require(
+                IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                    == IERC721Receiver.onERC721Received.selector,
+                "unsafe receiver too"
+            );
+        }
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id);
+    }
+}
+
+// A revert between the mint and the guard is not an escape: no path through it keeps the token.
+contract RevertBetweenNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        super._mint(to, tokenId);
+        if (tokenId == 0) {
+            revert("zero");
+        }
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id);
+    }
+}
+
+// The round trip through `uint160` keeps the address in fact, but the peel does not follow
+// numeric intermediates: a conservative report, on the safe side.
+contract RoundTripCastNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+        super._mint(address(uint160(to)), tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// A `payable` conversion keeps the address: the minted recipient is the guarded one.
+contract PayableRecipientNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        super._mint(payable(to), tokenId);
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id);
+    }
+}
+
+// An attached library function is not the recipient answering: `to.onERC721Received(...)`
+// resolves to the library's internal function, which runs in this contract without any
+// external call, so the recipient never acknowledged the token.
+library AttachedReceiverCheck {
+    function onERC721Received(address, address, uint256, bytes memory)
+        internal
+        pure
+        returns (bytes4)
+    {
+        return 0x150b7a02;
+    }
+}
+
+contract AttachedHookNft is ERC721 {
+    using AttachedReceiverCheck for address;
+
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        require(
+            to.onERC721Received(address(0), tokenId, "")
+                == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// The same attachment called with named arguments: name binding would land the token on the
+// right parameter despite the shifted first argument, so only the resolved declaration itself,
+// a library function no external call ever reaches, rules the shape out.
+library NamedAttachedCheck {
+    function onERC721Received(address self, address from, uint256 tokenId, bytes memory data)
+        internal
+        pure
+        returns (bytes4)
+    {
+        self;
+        from;
+        tokenId;
+        data;
+        return 0x150b7a02;
+    }
+}
+
+contract NamedAttachedHookNft is ERC721 {
+    using NamedAttachedCheck for address;
+
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        require(
+            to.onERC721Received({from: address(0), tokenId: tokenId, data: ""})
+                == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// A contract named exactly `ERC721` but declared under a `not-openzeppelin/` path: its path
+// contains the substring "openzeppelin" but no OpenZeppelin package-root component, so the
+// provenance check keeps its `_mint` out of scope.
+contract UsesLocalErc721 is LocalERC721 {
+    function mint(address to, uint256 id) external {
+        _mint(to, id);
+    }
+}
+
+// Recursive unsafe targets preserve both identities, so an outer callback after the recursive
+// delegation covers the canonical mint.
+contract RecursiveIdentityBaseNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        super._mint(to, tokenId);
+    }
+}
+
+contract RecursiveIdentityCheckedNft is RecursiveIdentityBaseNft {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        super._mint(to, tokenId);
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id);
+    }
+}
+
+// An intermediate override remaps the token before reaching the canonical mint. The outer
+// guard acknowledged the original token, so it cannot exempt callers through this target.
+contract RecursiveTokenRemapBaseNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        super._mint(to, tokenId + 1);
+    }
+}
+
+contract RecursiveTokenRemapCheckedNft is RecursiveTokenRemapBaseNft {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// The same recursive identity requirement applies to the recipient: the intermediate target
+// credits another address even though the outer override checked `to`.
+contract RecursiveRecipientRemapBaseNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        to = address(1);
+        super._mint(to, tokenId);
+    }
+}
+
+contract RecursiveRecipientRemapCheckedNft is RecursiveRecipientRemapBaseNft {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// A code-less proof survives the recursive token remap above because only the recipient's
+// identity matters.
+contract RecursiveCodeLessTokenRemapNft is RecursiveTokenRemapBaseNft {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        require(to.code.length == 0, "accounts only");
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id);
+    }
+}
+
+// An intermediate override can invalidate an outer code-length proof before it reaches the
+// canonical mint, so the stability summary follows the recursive delegation chain too.
+contract RecursiveCodeDeployBaseNft is ERC721 {
+    function _deployRecipient(uint256 salt) private {
+        new Create2NonReceiver{salt: bytes32(salt)}();
+    }
+
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        _deployRecipient(tokenId);
+        super._mint(to, tokenId);
+    }
+}
+
+contract RecursiveCodeLessThenDeployNft is RecursiveCodeDeployBaseNft {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        require(to.code.length == 0, "accounts only");
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+contract RecursiveModifierDeployBaseNft is ERC721 {
+    modifier deployBefore(uint256 salt) {
+        new Create2NonReceiver{salt: bytes32(salt)}();
+        _;
+    }
+
+    function _mint(address to, uint256 tokenId)
+        internal
+        virtual
+        override
+        deployBefore(tokenId)
+    {
+        super._mint(to, tokenId);
+    }
+}
+
+// Modifier prefixes are included in recursive code-stability summaries too.
+contract RecursiveCodeLessThenModifierDeployNft is RecursiveModifierDeployBaseNft {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        require(to.code.length == 0, "accounts only");
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+contract RecursiveModifierArgRedirectBaseNft is ERC721 {
+    modifier passthrough(address) {
+        _;
+    }
+
+    function _mint(address to, uint256 tokenId)
+        internal
+        virtual
+        override
+        passthrough(to = address(1))
+    {
+        super._mint(to, tokenId);
+    }
+}
+
+// Recursive identity and code-stability summaries include modifier-argument assignments.
+contract RecursiveCodeLessThenModifierArgRedirectNft is RecursiveModifierArgRedirectBaseNft {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        require(to.code.length == 0, "accounts only");
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// It does not survive a recursive recipient remap: the canonical mint credits an address whose
+// code length the outer override never checked.
+contract RecursiveCodeLessRecipientRemapNft is RecursiveRecipientRemapBaseNft {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        require(to.code.length == 0, "accounts only");
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// Even in the accepted branch, the callback ran before ownership was established.
+contract AcceptedBranchMintNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        if (
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                == IERC721Receiver.onERC721Received.selector
+        ) {
+            super._mint(to, tokenId);
+        } else {
+            revert("unsafe receiver");
+        }
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// Recognizing the refusal guard does not skip its accepted branch. It changes the token before
+// the later mint, invalidating the coverage established by the comparison.
+contract AcceptedBranchTokenRemapNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        if (
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                == IERC721Receiver.onERC721Received.selector
+        ) {
+            tokenId += 1;
+        } else {
+            revert("unsafe receiver");
+        }
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// A helper's body is not guaranteed to run when one of its modifiers can skip the placeholder.
+contract ModifiedGuardHelperNft is ERC721 {
+    bool internal checksEnabled;
+
+    modifier whenChecksEnabled() {
+        if (checksEnabled) {
+            _;
+        }
+    }
+
+    function _check(address to, uint256 tokenId) private whenChecksEnabled {
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+    }
+
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        _check(to, tokenId);
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// An inner sibling modifier can leave the whole frame after its placeholder, bypassing an outer
+// modifier's tail guard.
+contract SiblingModifierTailEscapeNft is ERC721 {
+    modifier checkedAfter(address to, uint256 tokenId) {
+        _;
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+    }
+
+    modifier escapeAfter() {
+        _;
+        assembly {
+            return(0, 0)
+        }
+    }
+
+    function _mint(address to, uint256 tokenId)
+        internal
+        virtual
+        override
+        checkedAfter(to, tokenId)
+        escapeAfter
+    {
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// The reverse order is safe: the inner tail guard runs before the outer modifier can leave the
+// frame, so modifier bypass analysis must respect expansion order.
+contract InnerModifierTailGuardNft is ERC721 {
+    modifier escapeAfter() {
+        _;
+        assembly {
+            return(0, 0)
+        }
+    }
+
+    modifier checkedAfter(address to, uint256 tokenId) {
+        _;
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+    }
+
+    function _mint(address to, uint256 tokenId)
+        internal
+        virtual
+        override
+        escapeAfter
+        checkedAfter(to, tokenId)
+    {
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id);
+    }
+}
+
+// An ordinary internal helper before the refusal revert does not leave the current frame, so
+// the branch still always reverts and the wrapper remains safe.
+contract RefusalNoopHelperNft is ERC721 {
+    function _noop() private pure {}
+
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        super._mint(to, tokenId);
+        if (
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                != IERC721Receiver.onERC721Received.selector
+        ) {
+            _noop();
+            revert("unsafe receiver");
+        }
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id);
+    }
+}
+
+// An internal helper can transitively execute an assembly return in the caller's frame. The
+// later revert is then unreachable and cannot make the refusal branch safe.
+contract RefusalHelperAssemblyEscapeNft is ERC721 {
+    function _escape() private pure {
+        assembly {
+            return(0, 0)
+        }
+    }
+
+    function _escapeIndirectly() private pure {
+        _escape();
+    }
+
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        super._mint(to, tokenId);
+        if (
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                != IERC721Receiver.onERC721Received.selector
+        ) {
+            _escapeIndirectly();
+            revert("unsafe receiver");
+        }
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// An internal function-pointer target is unresolved at its call site. It can reach assembly that
+// leaves the current frame before the later revert, so the refusal branch is not proven safe.
+contract RefusalPointerAssemblyEscapeNft is ERC721 {
+    function _escape() private pure {
+        assembly {
+            return(0, 0)
+        }
+    }
+
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        function() internal escape = _escape;
+        super._mint(to, tokenId);
+        if (
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                != IERC721Receiver.onERC721Received.selector
+        ) {
+            escape();
+            revert("unsafe receiver");
+        }
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// A same-width selector conversion preserves the accepting answer for a post-mint callback.
+contract PreservingSelectorCastNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        super._mint(to, tokenId);
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                == bytes4(uint32(0x150b7a02)),
+            "unsafe receiver"
+        );
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id);
+    }
+}
+
+// The `uint8` conversion truncates the selector to `0x02`; later widening cannot restore it.
+contract TruncatedSelectorCastNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        super._mint(to, tokenId);
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                == bytes4(uint32(uint8(uint32(0x150b7a02)))),
+            "unsafe receiver"
+        );
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// Helper recursion detection is path scoped. After the first check and mutation, an independent
+// second call to the same helper follows the mint and covers the remapped token.
+contract RepeatedGuardHelperNft is ERC721 {
+    function _check(address to, uint256 tokenId) private {
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+    }
+
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        _check(to, tokenId);
+        tokenId += 1;
+        super._mint(to, tokenId);
+        _check(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id);
+    }
+}
+
+// A recognized `if` comparison cannot restore coverage after one of its other hook arguments
+// reassigns the token. The hook may have seen the old value while the later mint credits the new
+// one, just as with a mutating `require` argument.
+contract IfGuardArgReassignNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        if (
+            IERC721Receiver(to).onERC721Received(
+                msg.sender, address(0), tokenId, abi.encodePacked(tokenId = tokenId + 1)
+            ) != IERC721Receiver.onERC721Received.selector
+        ) {
+            revert("unsafe receiver");
+        }
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// A helper receives value-type copies. Reassigning its token parameter before the check means
+// it acknowledges a different value from the unchanged token its caller later mints.
+contract RemappedGuardHelperNft is ERC721 {
+    function _check(address to, uint256 tokenId) private {
+        tokenId += 1;
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+    }
+
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        _check(to, tokenId);
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// Modifier parameters are copies too: this guard asks about the incremented local token while
+// the wrapped body still mints the function parameter passed to the modifier.
+contract RemappedGuardModifierNft is ERC721 {
+    modifier checked(address to, uint256 tokenId) {
+        tokenId += 1;
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+        _;
+    }
+
+    function _mint(address to, uint256 tokenId) internal virtual override checked(to, tokenId) {
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// A public helper called by bare name dispatches internally in the same frame and can cover a
+// mint that precedes it.
+contract PublicGuardHelperNft is ERC721 {
+    function _check(address to, uint256 tokenId) public {
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+    }
+
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        super._mint(to, tokenId);
+        _check(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id);
+    }
+}
+
+contract ExternalReceiverGuard {
+    function check(address to, uint256 tokenId) external {
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+    }
+}
+
+// A cross-contract helper calls the hook from its own address. A receiver can accept that caller
+// while refusing the NFT contract itself, so this does not prove that the mint is receivable.
+contract ExternalGuardHelperNft is ERC721 {
+    ExternalReceiverGuard internal guard;
+
+    constructor(ExternalReceiverGuard guard_) {
+        guard = guard_;
+    }
+
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        guard.check(to, tokenId);
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// A guard's error argument can leave the entire frame through an internal assembly helper before
+// `require` reverts, preserving the mint that already ran.
+contract GuardArgumentAssemblyEscapeNft is ERC721 {
+    function _escapeMessage() private pure returns (string memory) {
+        assembly {
+            return(0, 0)
+        }
+    }
+
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        super._mint(to, tokenId);
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                == IERC721Receiver.onERC721Received.selector,
+            _escapeMessage()
+        );
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// Inline assembly can rewrite a Solidity local without producing an HIR assignment expression.
+// Coverage from the earlier guard must not survive that opaque block.
+contract AssemblyTokenRemapNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+        assembly {
+            tokenId := add(tokenId, 1)
+        }
+        super._mint(to, tokenId);
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id); //~WARN: `ERC721._mint` does not check
+    }
+}
+
+// A fresh callback after the opaque assembly block and mint checks the remapped token.
+contract AssemblyTokenRemapThenGuardNft is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        assembly {
+            tokenId := add(tokenId, 1)
+        }
+        super._mint(to, tokenId);
+        require(
+            IERC721Receiver(to).onERC721Received(msg.sender, address(0), tokenId, "")
+                == IERC721Receiver.onERC721Received.selector,
+            "unsafe receiver"
+        );
+    }
+
+    function mint(address to, uint256 id) external {
+        _mint(to, id);
+    }
+}
diff --git a/crates/lint/testdata/UnsafeOzErc721Mint.stderr b/crates/lint/testdata/UnsafeOzErc721Mint.stderr
new file mode 100644
index 0000000000000..788bfa4a7c53b
--- /dev/null
+++ b/crates/lint/testdata/UnsafeOzErc721Mint.stderr
@@ -0,0 +1,808 @@
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         super._mint(to, id);
+   │         ━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         ERC721._mint(to, id);
+   │         ━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, tokenId);
+   │         ━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         super._mint(to, id);
+   │         ━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         mintUnchecked(to, id);
+   │         ━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id, data);
+   │         ━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         super._mint(to, tokenId);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         mintUnchecked(to, id);
+   │         ━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, 0);
+   │         ━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
+warning[unsafe-oz-erc721-mint]: `ERC721._mint` does not check that the recipient can receive the token; use `_safeMint`
+   ╭▸ ROOT/testdata/UnsafeOzErc721Mint.sol:LL:CC
+   │
+LL │         _mint(to, id);
+   │         ━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-oz-erc721-mint
+
diff --git a/crates/lint/testdata/UnsafeTypecast.sol b/crates/lint/testdata/UnsafeTypecast.sol
new file mode 100644
index 0000000000000..01c0be0fbcec9
--- /dev/null
+++ b/crates/lint/testdata/UnsafeTypecast.sol
@@ -0,0 +1,467 @@
+//@compile-flags: --severity high med low info
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+// forge-lint: disable-start(mixed-case-variable)
+contract UnsafeTypecast {
+    // Unsigned upcasts are always safe.
+    function upcastSafeUint() public pure {
+        uint8 a = type(uint8).max;
+        uint16 b = uint16(a);
+        uint24 c = uint24(b);
+        uint32 d = uint32(c);
+        uint40 e = uint40(d);
+        uint48 f = uint48(e);
+        uint56 g = uint56(f);
+        uint64 h = uint64(g);
+        uint72 i = uint72(h);
+        uint80 j = uint80(i);
+        uint88 k = uint88(j);
+        uint96 l = uint96(k);
+        uint104 m = uint104(l);
+        uint112 n = uint112(m);
+        uint120 o = uint120(n);
+        uint128 p = uint128(o);
+        uint136 q = uint136(p);
+        uint144 r = uint144(q);
+        uint152 s = uint152(r);
+        uint160 t = uint160(s);
+        uint168 u = uint168(t);
+        uint176 v = uint176(u);
+        uint184 w = uint184(v);
+        uint192 x = uint192(w);
+        uint200 y = uint200(x);
+        uint208 z = uint208(y);
+        uint216 A = uint216(z);
+        uint224 B = uint224(A);
+        uint232 C = uint232(B);
+        uint240 D = uint240(C);
+        uint248 E = uint248(D);
+        uint256 F = uint256(E);
+    }
+
+    // Signed upcasts are safe.
+    function upcastSafeInt() public pure {
+        int8 a = type(int8).max;
+        int16 b = int16(a);
+        int24 c = int24(b);
+        int32 d = int32(c);
+        int40 e = int40(d);
+        int48 f = int48(e);
+        int56 g = int56(f);
+        int64 h = int64(g);
+        int72 i = int72(h);
+        int80 j = int80(i);
+        int88 k = int88(j);
+        int96 l = int96(k);
+        int104 m = int104(l);
+        int112 n = int112(m);
+        int120 o = int120(n);
+        int128 p = int128(o);
+        int136 q = int136(p);
+        int144 r = int144(q);
+        int152 s = int152(r);
+        int160 t = int160(s);
+        int168 u = int168(t);
+        int176 v = int176(u);
+        int184 w = int184(v);
+        int192 x = int192(w);
+        int200 y = int200(x);
+        int208 z = int208(y);
+        int216 A = int216(z);
+        int224 B = int224(A);
+        int232 C = int232(B);
+        int240 D = int240(C);
+        int248 E = int248(D);
+        int256 F = int256(E);
+    }
+
+    function upcastSafeBytes() public pure {
+        bytes1 a = 0xFF;
+        bytes2 b = bytes2(a);
+        bytes3 c = bytes3(b);
+        bytes4 d = bytes4(c);
+        bytes5 e = bytes5(d);
+        bytes6 f = bytes6(e);
+        bytes7 g = bytes7(f);
+        bytes8 h = bytes8(g);
+        bytes9 i = bytes9(h);
+        bytes10 j = bytes10(i);
+        bytes11 k = bytes11(j);
+        bytes12 l = bytes12(k);
+        bytes13 m = bytes13(l);
+        bytes14 n = bytes14(m);
+        bytes15 o = bytes15(n);
+        bytes16 p = bytes16(o);
+        bytes17 q = bytes17(p);
+        bytes18 r = bytes18(q);
+        bytes19 s = bytes19(r);
+        bytes20 t = bytes20(s);
+        bytes21 u = bytes21(t);
+        bytes22 v = bytes22(u);
+        bytes23 w = bytes23(v);
+        bytes24 x = bytes24(w);
+        bytes25 y = bytes25(x);
+        bytes26 z = bytes26(y);
+        bytes27 A = bytes27(z);
+        bytes28 B = bytes28(A);
+        bytes29 C = bytes29(B);
+        bytes30 D = bytes30(C);
+        bytes31 E = bytes31(D);
+        bytes32 F = bytes32(E);
+    }
+
+    function safeSizeUint() public pure {
+        uint256(type(uint256).max);
+        uint248(type(uint248).max);
+        uint240(type(uint240).max);
+        uint232(type(uint232).max);
+        uint224(type(uint224).max);
+        uint216(type(uint216).max);
+        uint208(type(uint208).max);
+        uint200(type(uint200).max);
+        uint192(type(uint192).max);
+        uint184(type(uint184).max);
+        uint176(type(uint176).max);
+        uint168(type(uint168).max);
+        uint160(type(uint160).max);
+        uint152(type(uint152).max);
+        uint144(type(uint144).max);
+        uint136(type(uint136).max);
+        uint128(type(uint128).max);
+        uint120(type(uint120).max);
+        uint112(type(uint112).max);
+        uint104(type(uint104).max);
+        uint96(type(uint96).max);
+        uint88(type(uint88).max);
+        uint80(type(uint80).max);
+        uint72(type(uint72).max);
+        uint64(type(uint64).max);
+        uint56(type(uint56).max);
+        uint48(type(uint48).max);
+        uint40(type(uint40).max);
+        uint32(type(uint32).max);
+        uint24(type(uint24).max);
+        uint16(type(uint16).max);
+        uint8(type(uint8).max);
+    }
+
+    function safeSizeInt() public pure {
+        int256(type(int256).max);
+        int248(type(int248).max);
+        int240(type(int240).max);
+        int232(type(int232).max);
+        int224(type(int224).max);
+        int216(type(int216).max);
+        int208(type(int208).max);
+        int200(type(int200).max);
+        int192(type(int192).max);
+        int184(type(int184).max);
+        int176(type(int176).max);
+        int168(type(int168).max);
+        int160(type(int160).max);
+        int152(type(int152).max);
+        int144(type(int144).max);
+        int136(type(int136).max);
+        int128(type(int128).max);
+        int120(type(int120).max);
+        int112(type(int112).max);
+        int104(type(int104).max);
+        int96(type(int96).max);
+        int88(type(int88).max);
+        int80(type(int80).max);
+        int72(type(int72).max);
+        int64(type(int64).max);
+        int56(type(int56).max);
+        int48(type(int48).max);
+        int40(type(int40).max);
+        int32(type(int32).max);
+        int24(type(int24).max);
+        int16(type(int16).max);
+        int8(type(int8).max);
+    }
+
+    function sameSizeAddressSafe() public pure {
+        address a = 0x1234567890123456789012345678901234567890;
+        uint160 b = uint160(a);
+        bytes20 c = bytes20(a);
+        address d = address(a);
+        // The following tests, `downcastUnsafeUint` and `downcastUnsafeBytes`, verify that other downcasts
+        // would also throw. Additionally, the compiler prevents direct casting of addresses to smaller types.
+    }
+
+    function downcastUnsafeUint() public pure {
+        uint256 a = type(uint256).max;
+        uint248 b = uint248(a); //~WARN: typecast can truncate values
+        uint240 c = uint240(b); //~WARN: typecast can truncate values
+        uint232 d = uint232(c); //~WARN: typecast can truncate values
+        uint224 e = uint224(d); //~WARN: typecast can truncate values
+        uint216 f = uint216(e); //~WARN: typecast can truncate values
+        uint208 g = uint208(f); //~WARN: typecast can truncate values
+        uint200 h = uint200(g); //~WARN: typecast can truncate values
+        uint192 i = uint192(h); //~WARN: typecast can truncate values
+        uint184 j = uint184(i); //~WARN: typecast can truncate values
+        uint176 k = uint176(j); //~WARN: typecast can truncate values
+        uint168 l = uint168(k); //~WARN: typecast can truncate values
+        uint160 m = uint160(l); //~WARN: typecast can truncate values
+        uint152 n = uint152(m); //~WARN: typecast can truncate values
+        uint144 o = uint144(n); //~WARN: typecast can truncate values
+        uint136 p = uint136(o); //~WARN: typecast can truncate values
+        uint128 q = uint128(p); //~WARN: typecast can truncate values
+        uint120 r = uint120(q); //~WARN: typecast can truncate values
+        uint112 s = uint112(r); //~WARN: typecast can truncate values
+        uint104 t = uint104(s); //~WARN: typecast can truncate values
+        uint96 u = uint96(t); //~WARN: typecast can truncate values
+        uint88 v = uint88(u); //~WARN: typecast can truncate values
+        uint80 w = uint80(v); //~WARN: typecast can truncate values
+        uint72 x = uint72(w); //~WARN: typecast can truncate values
+        uint64 y = uint64(x); //~WARN: typecast can truncate values
+        uint56 z = uint56(y); //~WARN: typecast can truncate values
+        uint48 A = uint48(z); //~WARN: typecast can truncate values
+        uint40 B = uint40(A); //~WARN: typecast can truncate values
+        uint32 C = uint32(B); //~WARN: typecast can truncate values
+        uint24 D = uint24(C); //~WARN: typecast can truncate values
+        uint16 E = uint16(D); //~WARN: typecast can truncate values
+        uint8 F = uint8(E); //~WARN: typecast can truncate values
+    }
+
+    function downcastUnsafeInt() public pure {
+        int256 a = type(int256).max;
+        int248 b = int248(a); //~WARN: typecast can truncate values
+        int240 c = int240(b); //~WARN: typecast can truncate values
+        int232 d = int232(c); //~WARN: typecast can truncate values
+        int224 e = int224(d); //~WARN: typecast can truncate values
+        int216 f = int216(e); //~WARN: typecast can truncate values
+        int208 g = int208(f); //~WARN: typecast can truncate values
+        int200 h = int200(g); //~WARN: typecast can truncate values
+        int192 i = int192(h); //~WARN: typecast can truncate values
+        int184 j = int184(i); //~WARN: typecast can truncate values
+        int176 k = int176(j); //~WARN: typecast can truncate values
+        int168 l = int168(k); //~WARN: typecast can truncate values
+        int160 m = int160(l); //~WARN: typecast can truncate values
+        int152 n = int152(m); //~WARN: typecast can truncate values
+        int144 o = int144(n); //~WARN: typecast can truncate values
+        int136 p = int136(o); //~WARN: typecast can truncate values
+        int128 q = int128(p); //~WARN: typecast can truncate values
+        int120 r = int120(q); //~WARN: typecast can truncate values
+        int112 s = int112(r); //~WARN: typecast can truncate values
+        int104 t = int104(s); //~WARN: typecast can truncate values
+        int96 u = int96(t); //~WARN: typecast can truncate values
+        int88 v = int88(u); //~WARN: typecast can truncate values
+        int80 w = int80(v); //~WARN: typecast can truncate values
+        int72 x = int72(w); //~WARN: typecast can truncate values
+        int64 y = int64(x); //~WARN: typecast can truncate values
+        int56 z = int56(y); //~WARN: typecast can truncate values
+        int48 A = int48(z); //~WARN: typecast can truncate values
+        int40 B = int40(A); //~WARN: typecast can truncate values
+        int32 C = int32(B); //~WARN: typecast can truncate values
+        int24 D = int24(C); //~WARN: typecast can truncate values
+        int16 E = int16(D); //~WARN: typecast can truncate values
+        int8 F = int8(E); //~WARN: typecast can truncate values
+    }
+
+    function downcastUnsafeBytes() public pure {
+        bytes32 a = bytes32(type(uint256).max);
+        bytes31 b = bytes31(a); //~WARN: typecast can truncate values
+        bytes30 c = bytes30(b); //~WARN: typecast can truncate values
+        bytes29 d = bytes29(c); //~WARN: typecast can truncate values
+        bytes28 e = bytes28(d); //~WARN: typecast can truncate values
+        bytes27 f = bytes27(e); //~WARN: typecast can truncate values
+        bytes26 g = bytes26(f); //~WARN: typecast can truncate values
+        bytes25 h = bytes25(g); //~WARN: typecast can truncate values
+        bytes24 i = bytes24(h); //~WARN: typecast can truncate values
+        bytes23 j = bytes23(i); //~WARN: typecast can truncate values
+        bytes22 k = bytes22(j); //~WARN: typecast can truncate values
+        bytes21 l = bytes21(k); //~WARN: typecast can truncate values
+        bytes20 m = bytes20(l); //~WARN: typecast can truncate values
+        bytes19 n = bytes19(m); //~WARN: typecast can truncate values
+        bytes18 o = bytes18(n); //~WARN: typecast can truncate values
+        bytes17 p = bytes17(o); //~WARN: typecast can truncate values
+        bytes16 q = bytes16(p); //~WARN: typecast can truncate values
+        bytes15 r = bytes15(q); //~WARN: typecast can truncate values
+        bytes14 s = bytes14(r); //~WARN: typecast can truncate values
+        bytes13 t = bytes13(s); //~WARN: typecast can truncate values
+        bytes12 u = bytes12(t); //~WARN: typecast can truncate values
+        bytes11 v = bytes11(u); //~WARN: typecast can truncate values
+        bytes10 w = bytes10(v); //~WARN: typecast can truncate values
+        bytes9 x = bytes9(w); //~WARN: typecast can truncate values
+        bytes8 y = bytes8(x); //~WARN: typecast can truncate values
+        bytes7 z = bytes7(y); //~WARN: typecast can truncate values
+        bytes6 A = bytes6(z); //~WARN: typecast can truncate values
+        bytes5 B = bytes5(A); //~WARN: typecast can truncate values
+        bytes4 C = bytes4(B); //~WARN: typecast can truncate values
+        bytes3 D = bytes3(C); //~WARN: typecast can truncate values
+        bytes2 E = bytes2(D); //~WARN: typecast can truncate values
+        bytes1 F = bytes1(E); //~WARN: typecast can truncate values
+    }
+
+    function unsignedSignedUnsafe() public pure {
+        uint256 a = type(uint256).max;
+        int256 b = int256(a); //~WARN: typecast can truncate values
+        uint248 c = type(uint248).max;
+        int248 d = int248(c); //~WARN: typecast can truncate values
+        uint240 e = type(uint240).max;
+        int240 f = int240(e); //~WARN: typecast can truncate values
+        uint232 g = type(uint232).max;
+        int232 h = int232(g); //~WARN: typecast can truncate values
+        uint224 i = type(uint224).max;
+        int224 j = int224(i); //~WARN: typecast can truncate values
+        uint216 k = type(uint216).max;
+        int216 l = int216(k); //~WARN: typecast can truncate values
+        uint208 m = type(uint208).max;
+        int208 n = int208(m); //~WARN: typecast can truncate values
+        uint200 o = type(uint200).max;
+        int200 p = int200(o); //~WARN: typecast can truncate values
+        uint192 q = type(uint192).max;
+        int192 r = int192(q); //~WARN: typecast can truncate values
+        uint184 s = type(uint184).max;
+        int184 t = int184(s); //~WARN: typecast can truncate values
+        uint176 u = type(uint176).max;
+        int176 v = int176(u); //~WARN: typecast can truncate values
+        uint168 w = type(uint168).max;
+        int168 x = int168(w); //~WARN: typecast can truncate values
+        uint160 y = type(uint160).max;
+        int160 z = int160(y); //~WARN: typecast can truncate values
+        uint152 A = type(uint152).max;
+        int152 B = int152(A); //~WARN: typecast can truncate values
+        uint144 C = type(uint144).max;
+        int144 D = int144(C); //~WARN: typecast can truncate values
+        uint136 E = type(uint136).max;
+        int136 F = int136(E); //~WARN: typecast can truncate values
+        uint128 G = type(uint128).max;
+        int128 H = int128(G); //~WARN: typecast can truncate values
+        uint120 I = type(uint120).max;
+        int120 J = int120(I); //~WARN: typecast can truncate values
+        uint112 K = type(uint112).max;
+        int112 L = int112(K); //~WARN: typecast can truncate values
+        uint104 M = type(uint104).max;
+        int104 N = int104(M); //~WARN: typecast can truncate values
+        uint96 O = type(uint96).max;
+        int96 P = int96(O); //~WARN: typecast can truncate values
+        uint88 Q = type(uint88).max;
+        int88 R = int88(Q); //~WARN: typecast can truncate values
+        uint80 S = type(uint80).max;
+        int80 T = int80(S); //~WARN: typecast can truncate values
+        uint72 U = type(uint72).max;
+        int72 V = int72(U); //~WARN: typecast can truncate values
+        uint64 W = type(uint64).max;
+        int64 X = int64(W); //~WARN: typecast can truncate values
+        uint56 Y = type(uint56).max;
+        int56 Z = int56(Y); //~WARN: typecast can truncate values
+        uint48 AA = type(uint48).max;
+        int48 BB = int48(AA); //~WARN: typecast can truncate values
+        uint40 CC = type(uint40).max;
+        int40 DD = int40(CC); //~WARN: typecast can truncate values
+        uint32 EE = type(uint32).max;
+        int32 FF = int32(EE); //~WARN: typecast can truncate values
+        uint24 GG = type(uint24).max;
+        int24 HH = int24(GG); //~WARN: typecast can truncate values
+        uint16 II = type(uint16).max;
+        int16 JJ = int16(II); //~WARN: typecast can truncate values
+        uint8 KK = type(uint8).max;
+        int8 LL = int8(KK); //~WARN: typecast can truncate values
+    }
+
+    function signedUnsignedUnsafe() public pure {
+        int256 a = -1;
+        uint256 b = uint256(a); //~WARN: typecast can truncate values
+        int248 c = -1;
+        uint248 d = uint248(c); //~WARN: typecast can truncate values
+        int240 e = -1;
+        uint240 f = uint240(e); //~WARN: typecast can truncate values
+        int232 g = -1;
+        uint232 h = uint232(g); //~WARN: typecast can truncate values
+        int224 i = -1;
+        uint224 j = uint224(i); //~WARN: typecast can truncate values
+        int216 k = -1;
+        uint216 l = uint216(k); //~WARN: typecast can truncate values
+        int208 m = -1;
+        uint208 n = uint208(m); //~WARN: typecast can truncate values
+        int200 o = -1;
+        uint200 p = uint200(o); //~WARN: typecast can truncate values
+        int192 q = -1;
+        uint192 r = uint192(q); //~WARN: typecast can truncate values
+        int184 s = -1;
+        uint184 t = uint184(s); //~WARN: typecast can truncate values
+        int176 u = -1;
+        uint176 v = uint176(u); //~WARN: typecast can truncate values
+        int168 w = -1;
+        uint168 x = uint168(w); //~WARN: typecast can truncate values
+        int160 y = -1;
+        uint160 z = uint160(y); //~WARN: typecast can truncate values
+        int152 A = -1;
+        uint152 B = uint152(A); //~WARN: typecast can truncate values
+        int144 C = -1;
+        uint144 D = uint144(C); //~WARN: typecast can truncate values
+        int136 E = -1;
+        uint136 F = uint136(E); //~WARN: typecast can truncate values
+        int128 G = -1;
+        uint128 H = uint128(G); //~WARN: typecast can truncate values
+        int120 I = -1;
+        uint120 J = uint120(I); //~WARN: typecast can truncate values
+        int112 K = -1;
+        uint112 L = uint112(K); //~WARN: typecast can truncate values
+        int104 M = -1;
+        uint104 N = uint104(M); //~WARN: typecast can truncate values
+        int96 O = -1;
+        uint96 P = uint96(O); //~WARN: typecast can truncate values
+        int88 Q = -1;
+        uint88 R = uint88(Q); //~WARN: typecast can truncate values
+        int80 S = -1;
+        uint80 T = uint80(S); //~WARN: typecast can truncate values
+        int72 U = -1;
+        uint72 V = uint72(U); //~WARN: typecast can truncate values
+        int64 W = -1;
+        uint64 X = uint64(W); //~WARN: typecast can truncate values
+        int56 Y = -1;
+        uint56 Z = uint56(Y); //~WARN: typecast can truncate values
+        int48 AA = -1;
+        uint48 BB = uint48(AA); //~WARN: typecast can truncate values
+        int40 CC = -1;
+        uint40 DD = uint40(CC); //~WARN: typecast can truncate values
+        int32 EE = -1;
+        uint32 FF = uint32(EE); //~WARN: typecast can truncate values
+        int24 GG = -1;
+        uint24 HH = uint24(GG); //~WARN: typecast can truncate values
+        int16 II = -1;
+        uint16 JJ = uint16(II); //~WARN: typecast can truncate values
+        int8 KK = -1;
+        uint8 LL = uint8(KK); //~WARN: typecast can truncate values
+    }
+
+    function downcastDynamicUnsafe() public pure {
+        bytes memory data = "hello world";
+        bytes32 dataSlice = bytes32(data); //~WARN: typecast can truncate values
+        string memory str = "hello world";
+        bytes32 strSlice = bytes32(bytes(str)); //~WARN: typecast can truncate values
+    }
+}
+
+contract Repros {
+    function downcastBoundedByMaskSafe(uint256 value, uint256 length) public pure {
+        uint8(value & 0xff);
+        uint8(0x7f & value);
+        uint8((0x80 + length) & 0xfe);
+        uint16(value & 0xffff);
+    }
+
+    function longDynamicBytesDoNotPanic() public pure {
+        bytes memory stringToBytes = bytes("Initializable: contract is already initialized");
+    }
+
+    function nestedCastsAreEvaluatedAtAllDepths(uint64 a, int128 b) internal pure returns (uint64) {
+        uint64 aAloneIsSafe = uint64(uint128(int128(uint128(a))));
+
+        uint128 aPlusB = uint128(int128(uint128(a)) + b);
+        //~^WARN: typecast can truncate values
+
+        uint64 unsafe = uint64(aPlusB);
+        //~^WARN: typecast can truncate values
+
+        return uint64(uint128(int128(uint128(a)) + b));
+        //~^WARN: typecast can truncate values
+        //~|WARN: typecast can truncate values
+    }
+}
+// forge-lint: disable-end(mixed-case-variable)
diff --git a/crates/lint/testdata/UnsafeTypecast.stderr b/crates/lint/testdata/UnsafeTypecast.stderr
new file mode 100644
index 0000000000000..3ecc4b0ddfebc
--- /dev/null
+++ b/crates/lint/testdata/UnsafeTypecast.stderr
@@ -0,0 +1,2298 @@
+note[multi-contract-file]: file contains multiple contracts, interfaces or libraries
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │ contract UnsafeTypecast {
+   │          ━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/multi-contract-file
+
+note[multi-contract-file]: file contains multiple contracts, interfaces or libraries
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │ contract Repros {
+   │          ━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/multi-contract-file
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint248 b = uint248(a);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint248' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint240 c = uint240(b);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint240' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint232 d = uint232(c);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint232' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint224 e = uint224(d);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint224' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint216 f = uint216(e);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint216' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint208 g = uint208(f);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint208' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint200 h = uint200(g);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint200' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint192 i = uint192(h);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint192' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint184 j = uint184(i);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint184' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint176 k = uint176(j);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint176' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint168 l = uint168(k);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint168' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint160 m = uint160(l);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint160' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint152 n = uint152(m);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint152' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint144 o = uint144(n);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint144' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint136 p = uint136(o);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint136' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint128 q = uint128(p);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint128' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint120 r = uint120(q);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint120' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint112 s = uint112(r);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint112' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint104 t = uint104(s);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint104' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint96 u = uint96(t);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint96' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint88 v = uint88(u);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint88' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint80 w = uint80(v);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint80' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint72 x = uint72(w);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint72' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint64 y = uint64(x);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint64' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint56 z = uint56(y);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint56' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint48 A = uint48(z);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint48' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint40 B = uint40(A);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint40' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint32 C = uint32(B);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint32' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint24 D = uint24(C);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint24' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint16 E = uint16(D);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint16' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint8 F = uint8(E);
+   │                   ━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint8' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int248 b = int248(a);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int248' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int240 c = int240(b);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int240' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int232 d = int232(c);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int232' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int224 e = int224(d);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int224' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int216 f = int216(e);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int216' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int208 g = int208(f);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int208' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int200 h = int200(g);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int200' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int192 i = int192(h);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int192' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int184 j = int184(i);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int184' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int176 k = int176(j);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int176' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int168 l = int168(k);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int168' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int160 m = int160(l);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int160' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int152 n = int152(m);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int152' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int144 o = int144(n);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int144' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int136 p = int136(o);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int136' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int128 q = int128(p);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int128' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int120 r = int120(q);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int120' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int112 s = int112(r);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int112' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int104 t = int104(s);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int104' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int96 u = int96(t);
+   │                   ━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int96' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int88 v = int88(u);
+   │                   ━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int88' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int80 w = int80(v);
+   │                   ━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int80' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int72 x = int72(w);
+   │                   ━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int72' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int64 y = int64(x);
+   │                   ━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int64' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int56 z = int56(y);
+   │                   ━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int56' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int48 A = int48(z);
+   │                   ━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int48' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int40 B = int40(A);
+   │                   ━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int40' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int32 C = int32(B);
+   │                   ━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int32' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int24 D = int24(C);
+   │                   ━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int24' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int16 E = int16(D);
+   │                   ━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int16' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int8 F = int8(E);
+   │                  ━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int8' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         bytes31 b = bytes31(a);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'bytes31' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         bytes30 c = bytes30(b);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'bytes30' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         bytes29 d = bytes29(c);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'bytes29' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         bytes28 e = bytes28(d);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'bytes28' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         bytes27 f = bytes27(e);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'bytes27' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         bytes26 g = bytes26(f);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'bytes26' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         bytes25 h = bytes25(g);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'bytes25' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         bytes24 i = bytes24(h);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'bytes24' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         bytes23 j = bytes23(i);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'bytes23' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         bytes22 k = bytes22(j);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'bytes22' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         bytes21 l = bytes21(k);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'bytes21' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         bytes20 m = bytes20(l);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'bytes20' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         bytes19 n = bytes19(m);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'bytes19' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         bytes18 o = bytes18(n);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'bytes18' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         bytes17 p = bytes17(o);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'bytes17' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         bytes16 q = bytes16(p);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'bytes16' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         bytes15 r = bytes15(q);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'bytes15' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         bytes14 s = bytes14(r);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'bytes14' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         bytes13 t = bytes13(s);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'bytes13' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         bytes12 u = bytes12(t);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'bytes12' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         bytes11 v = bytes11(u);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'bytes11' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         bytes10 w = bytes10(v);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'bytes10' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         bytes9 x = bytes9(w);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'bytes9' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         bytes8 y = bytes8(x);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'bytes8' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         bytes7 z = bytes7(y);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'bytes7' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         bytes6 A = bytes6(z);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'bytes6' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         bytes5 B = bytes5(A);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'bytes5' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         bytes4 C = bytes4(B);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'bytes4' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         bytes3 D = bytes3(C);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'bytes3' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         bytes2 E = bytes2(D);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'bytes2' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         bytes1 F = bytes1(E);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'bytes1' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int256 b = int256(a);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int256' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int248 d = int248(c);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int248' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int240 f = int240(e);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int240' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int232 h = int232(g);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int232' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int224 j = int224(i);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int224' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int216 l = int216(k);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int216' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int208 n = int208(m);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int208' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int200 p = int200(o);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int200' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int192 r = int192(q);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int192' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int184 t = int184(s);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int184' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int176 v = int176(u);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int176' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int168 x = int168(w);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int168' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int160 z = int160(y);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int160' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int152 B = int152(A);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int152' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int144 D = int144(C);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int144' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int136 F = int136(E);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int136' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int128 H = int128(G);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int128' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int120 J = int120(I);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int120' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int112 L = int112(K);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int112' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int104 N = int104(M);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int104' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int96 P = int96(O);
+   │                   ━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int96' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int88 R = int88(Q);
+   │                   ━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int88' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int80 T = int80(S);
+   │                   ━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int80' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int72 V = int72(U);
+   │                   ━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int72' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int64 X = int64(W);
+   │                   ━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int64' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int56 Z = int56(Y);
+   │                   ━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int56' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int48 BB = int48(AA);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int48' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int40 DD = int40(CC);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int40' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int32 FF = int32(EE);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int32' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int24 HH = int24(GG);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int24' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int16 JJ = int16(II);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int16' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         int8 LL = int8(KK);
+   │                   ━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'int8' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint256 b = uint256(a);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint256' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint248 d = uint248(c);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint248' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint240 f = uint240(e);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint240' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint232 h = uint232(g);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint232' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint224 j = uint224(i);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint224' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint216 l = uint216(k);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint216' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint208 n = uint208(m);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint208' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint200 p = uint200(o);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint200' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint192 r = uint192(q);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint192' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint184 t = uint184(s);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint184' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint176 v = uint176(u);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint176' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint168 x = uint168(w);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint168' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint160 z = uint160(y);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint160' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint152 B = uint152(A);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint152' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint144 D = uint144(C);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint144' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint136 F = uint136(E);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint136' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint128 H = uint128(G);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint128' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint120 J = uint120(I);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint120' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint112 L = uint112(K);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint112' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint104 N = uint104(M);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint104' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint96 P = uint96(O);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint96' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint88 R = uint88(Q);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint88' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint80 T = uint80(S);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint80' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint72 V = uint72(U);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint72' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint64 X = uint64(W);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint64' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint56 Z = uint56(Y);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint56' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint48 BB = uint48(AA);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint48' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint40 DD = uint40(CC);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint40' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint32 FF = uint32(EE);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint32' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint24 HH = uint24(GG);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint24' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint16 JJ = uint16(II);
+   │                     ━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint16' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint8 LL = uint8(KK);
+   │                    ━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint8' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         bytes32 dataSlice = bytes32(data);
+   │                             ━━━━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'bytes32' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         bytes32 strSlice = bytes32(bytes(str));
+   │                            ━━━━━━━━━━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'bytes32' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint128 aPlusB = uint128(int128(uint128(a)) + b);
+   │                          ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint128' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         uint64 unsafe = uint64(aPlusB);
+   │                         ━━━━━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint64' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         return uint64(uint128(int128(uint128(a)) + b));
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint64' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
+warning[unsafe-typecast]: typecast can truncate values
+   ╭▸ ROOT/testdata/UnsafeTypecast.sol:LL:CC
+   │
+LL │         return uint64(uint128(int128(uint128(a)) + b));
+   │                       ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ├ note: consider disabling this lint if you're certain the cast is safe
+   │       
+   │       // casting to 'uint128' is safe because [explain why]
+   │       // forge-lint: disable-next-line(unsafe-typecast)
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/unsafe-typecast
+
diff --git a/crates/lint/testdata/UnusedError.sol b/crates/lint/testdata/UnusedError.sol
new file mode 100644
index 0000000000000..c258daf1df11d
--- /dev/null
+++ b/crates/lint/testdata/UnusedError.sol
@@ -0,0 +1,79 @@
+//@compile-flags: --only-lint unused-error
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.27;
+
+// Tests for `unused-error`: a custom error declaration that is never referenced anywhere in the
+// compiled sources. Any resolved reference counts as a use: `revert Err()`, the qualified
+// `revert Lib.Err()`, `require(cond, Err())` (0.8.26+), and `Err.selector`, including through
+// `abi.encodeWithSelector`. Errors declared in interfaces and abstract contracts are exempt:
+// they are ABI surface for implementers and off-chain consumers.
+
+import "./UnusedError.sol" as Self;
+import "./auxiliary/UnusedErrorReexport.sol" as R;
+import "./auxiliary/UnusedErrorHidden.sol" as H;
+
+error UnusedFileLevel(); //~NOTE: custom error is never used
+
+error UsedFileLevel(uint256 value);
+
+// used only through the alias re-export chain `R.Written.selector`
+error UsedViaAliasReexport();
+
+// shares its name with the library in the hidden-import fixture: the raw transitive walk
+// used to mark it used through `H.Hidden.id()`, the resolved scope must not mark it used
+error Hidden(); //~NOTE: custom error is never used
+
+library Errors {
+    error UnusedInLibrary(); //~NOTE: custom error is never used
+
+    error UsedViaQualifiedRevert();
+    error UsedViaQualifiedSelector();
+    error UsedViaChainedSelector();
+}
+
+interface IVault {
+    // interface errors are ABI surface for implementers and clients: exempt
+    error InterfaceOnly();
+}
+
+abstract contract BaseVault {
+    // abstract contract errors are part of the inheritance API: exempt
+    error AbstractOnly();
+
+    // declared in the base, reverted only by the concrete child: used
+    error RaisedByChild();
+}
+
+contract Vault is BaseVault {
+    error UnusedInContract(); //~NOTE: custom error is never used
+    error AlsoUnused(uint256 balance); //~NOTE: custom error is never used
+
+    error UsedInRevert();
+    error UsedInRequire();
+    error UsedViaSelector();
+    error UsedViaEncodeWithSelector(uint256 amount);
+
+    function f(uint256 x) external pure returns (bytes memory data) {
+        // plain revert statement
+        if (x == 1) revert UsedInRevert();
+        // qualified revert through the library
+        if (x == 2) revert Errors.UsedViaQualifiedRevert();
+        // require with a custom error (0.8.26+)
+        require(x != 3, UsedInRequire());
+        // revert with a file-level error
+        if (x == 4) revert UsedFileLevel(x);
+        // reverting the error declared in the abstract base
+        if (x == 5) revert RaisedByChild();
+        // selector-only uses
+        bytes4 s = UsedViaSelector.selector;
+        bytes4 q = Errors.UsedViaQualifiedSelector.selector;
+        // member chain through a module alias
+        bytes4 c = Self.Errors.UsedViaChainedSelector.selector;
+        // alias re-export chain through a module namespace
+        bytes4 a = R.Written.selector;
+        // `H.Hidden` is the library bound in that namespace, not the file-level error
+        uint256 one = H.Hidden.id();
+        data = abi.encodeWithSelector(UsedViaEncodeWithSelector.selector, x);
+        data = bytes.concat(data, s, q, c, a, bytes32(one));
+    }
+}
diff --git a/crates/lint/testdata/UnusedError.stderr b/crates/lint/testdata/UnusedError.stderr
new file mode 100644
index 0000000000000..343a4227b2de1
--- /dev/null
+++ b/crates/lint/testdata/UnusedError.stderr
@@ -0,0 +1,40 @@
+note[unused-error]: custom error is never used
+   ╭▸ ROOT/testdata/UnusedError.sol:LL:CC
+   │
+LL │ error UnusedFileLevel();
+   │ ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unused-error
+
+note[unused-error]: custom error is never used
+   ╭▸ ROOT/testdata/UnusedError.sol:LL:CC
+   │
+LL │ error Hidden();
+   │ ━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unused-error
+
+note[unused-error]: custom error is never used
+   ╭▸ ROOT/testdata/UnusedError.sol:LL:CC
+   │
+LL │     error UnusedInLibrary();
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unused-error
+
+note[unused-error]: custom error is never used
+   ╭▸ ROOT/testdata/UnusedError.sol:LL:CC
+   │
+LL │     error UnusedInContract();
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unused-error
+
+note[unused-error]: custom error is never used
+   ╭▸ ROOT/testdata/UnusedError.sol:LL:CC
+   │
+LL │     error AlsoUnused(uint256 balance);
+   │     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unused-error
+
diff --git a/crates/lint/testdata/UnusedReturn.sol b/crates/lint/testdata/UnusedReturn.sol
new file mode 100644
index 0000000000000..04833568cb523
--- /dev/null
+++ b/crates/lint/testdata/UnusedReturn.sol
@@ -0,0 +1,148 @@
+//@compile-flags: --only-lint unused-return
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.20;
+
+interface IOracle {
+    function getPrice(address token) external returns (uint256);
+    function latest(address token) external returns (uint256, bool);
+    function update() external returns (bool);
+    function noReturn() external;
+}
+
+// Same name+arity as IOracle.getPrice but one overload has no return value.
+interface IOracleOverloaded {
+    function getPrice(address token) external returns (uint256);
+    function getPrice(uint256 id) external;
+}
+
+interface IERC20 {
+    function transfer(address to, uint256 amount) external returns (bool);
+    function transferFrom(address from, address to, uint256 amount) external returns (bool);
+}
+
+contract UnusedReturn {
+    IOracle public oracle;
+    IOracleOverloaded public oracleOverloaded;
+    IERC20 public token;
+
+    constructor(address _oracle, address _token) {
+        oracle = IOracle(_oracle);
+        oracleOverloaded = IOracleOverloaded(_oracle);
+        token = IERC20(_token);
+    }
+
+    // SHOULD FAIL: uint256 return discarded
+    function bad1(address t) external {
+        oracle.getPrice(t); //~WARN: return value of an external call is not used
+    }
+
+    // SHOULD FAIL: bool return discarded (non-ERC20 function)
+    function bad2() external {
+        oracle.update(); //~WARN: return value of an external call is not used
+    }
+
+    // SHOULD FAIL: explicit interface cast, IOracle(addr).getPrice(t)
+    function bad3(address oracleAddr, address t) external {
+        IOracle(oracleAddr).getPrice(t); //~WARN: return value of an external call is not used
+    }
+
+    // SHOULD PASS: return value stored in local variable
+    function good1(address t) external returns (uint256) {
+        uint256 price = oracle.getPrice(t);
+        return price;
+    }
+
+    // SHOULD PASS: return value used directly in expression
+    function good2(address t) external returns (uint256) {
+        return oracle.getPrice(t);
+    }
+
+    // SHOULD PASS: function has no return value
+    function good3() external {
+        oracle.noReturn();
+    }
+
+    // SHOULD PASS: ERC20 transfer excluded (handled by erc20-unchecked-transfer)
+    function good4(address to, uint256 amt) external {
+        token.transfer(to, amt);
+    }
+
+    // SHOULD PASS: ERC20 transferFrom excluded
+    function good5(address from, address to, uint256 amt) external {
+        token.transferFrom(from, to, amt);
+    }
+
+    // The selected address overload returns a value.
+    function badOverload(address t) external {
+        oracleOverloaded.getPrice(t); //~WARN: return value of an external call is not used
+    }
+
+    function goodOverload(uint256 id) external {
+        oracleOverloaded.getPrice(id);
+    }
+
+    // SHOULD FAIL: named-arg call, arity should still be 1
+    function bad4(address t) external {
+        oracle.getPrice({token: t}); //~WARN: return value of an external call is not used
+    }
+
+    // SHOULD FAIL: parenthesized receiver
+    function bad5(address t) external {
+        (oracle).getPrice(t); //~WARN: return value of an external call is not used
+    }
+
+    // SHOULD FAIL: parenthesized interface cast receiver
+    function bad6(address oracleAddr, address t) external {
+        (IOracle(oracleAddr)).getPrice(t); //~WARN: return value of an external call is not used
+    }
+
+    // SHOULD FAIL: tuple return has an ignored slot
+    function bad7(address t) external {
+        (uint256 price, ) = oracle.latest(t); //~WARN: return value of an external call is not used
+        price = price + 1;
+    }
+
+    // SHOULD FAIL: tuple assignment has an ignored slot
+    function bad8(address t) external {
+        uint256 price;
+        (price, ) = oracle.latest(t); //~WARN: return value of an external call is not used
+        price = price + 1;
+    }
+
+    // SHOULD PASS: captured return alone is considered used
+    function good7(address t) external {
+        uint256 price = oracle.getPrice(t);
+        price = 1;
+    }
+
+    // SHOULD PASS: captured return is read before overwrite
+    function good8(address t) external returns (uint256) {
+        uint256 price = oracle.getPrice(t);
+        uint256 out = price;
+        price = 1;
+        return out;
+    }
+
+    // SHOULD PASS: captured return read on a branch is still used
+    function good9(address t, bool cond) external returns (uint256) {
+        uint256 price = oracle.getPrice(t);
+        if (cond) return price;
+        return 0;
+    }
+
+    // SHOULD PASS: tuple return values are read
+    function good10(address t) external returns (uint256) {
+        (uint256 price, bool ok) = oracle.latest(t);
+        uint256 out = price;
+        bool ready = ok;
+        if (ready) return out;
+        return 0;
+    }
+
+    // SHOULD PASS: captured ERC20 transfer remains excluded
+    function good11(address to, uint256 amt) external {
+        bool ok = token.transfer(to, amt);
+        ok = true;
+    }
+}
diff --git a/crates/lint/testdata/UnusedReturn.stderr b/crates/lint/testdata/UnusedReturn.stderr
new file mode 100644
index 0000000000000..ee873275b9f58
--- /dev/null
+++ b/crates/lint/testdata/UnusedReturn.stderr
@@ -0,0 +1,72 @@
+warning[unused-return]: return value of an external call is not used
+   ╭▸ ROOT/testdata/UnusedReturn.sol:LL:CC
+   │
+LL │         oracle.getPrice(t);
+   │         ━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unused-return
+
+warning[unused-return]: return value of an external call is not used
+   ╭▸ ROOT/testdata/UnusedReturn.sol:LL:CC
+   │
+LL │         oracle.update();
+   │         ━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unused-return
+
+warning[unused-return]: return value of an external call is not used
+   ╭▸ ROOT/testdata/UnusedReturn.sol:LL:CC
+   │
+LL │         IOracle(oracleAddr).getPrice(t);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unused-return
+
+warning[unused-return]: return value of an external call is not used
+   ╭▸ ROOT/testdata/UnusedReturn.sol:LL:CC
+   │
+LL │         oracleOverloaded.getPrice(t);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unused-return
+
+warning[unused-return]: return value of an external call is not used
+   ╭▸ ROOT/testdata/UnusedReturn.sol:LL:CC
+   │
+LL │         oracle.getPrice({token: t});
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unused-return
+
+warning[unused-return]: return value of an external call is not used
+   ╭▸ ROOT/testdata/UnusedReturn.sol:LL:CC
+   │
+LL │         (oracle).getPrice(t);
+   │         ━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unused-return
+
+warning[unused-return]: return value of an external call is not used
+   ╭▸ ROOT/testdata/UnusedReturn.sol:LL:CC
+   │
+LL │         (IOracle(oracleAddr)).getPrice(t);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unused-return
+
+warning[unused-return]: return value of an external call is not used
+   ╭▸ ROOT/testdata/UnusedReturn.sol:LL:CC
+   │
+LL │         (uint256 price, ) = oracle.latest(t);
+   │                             ━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unused-return
+
+warning[unused-return]: return value of an external call is not used
+   ╭▸ ROOT/testdata/UnusedReturn.sol:LL:CC
+   │
+LL │         (price, ) = oracle.latest(t);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unused-return
+
diff --git a/crates/lint/testdata/UnusedReturnFunctionPointer.sol b/crates/lint/testdata/UnusedReturnFunctionPointer.sol
new file mode 100644
index 0000000000000..1595d9d4a400e
--- /dev/null
+++ b/crates/lint/testdata/UnusedReturnFunctionPointer.sol
@@ -0,0 +1,36 @@
+//@compile-flags: --only-lint unused-return
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.20;
+
+contract UnusedReturnFunctionPointer {
+    function values() external pure returns (uint256, uint256) {
+        return (1, 2);
+    }
+
+    function internalValue() internal pure returns (uint256) {
+        return 1;
+    }
+
+    function noValue() external pure {}
+
+    function externalCalls() external {
+        function() external returns (uint256, uint256) callback = this.values;
+        callback(); //~WARN: return value of an external call is not used
+        (, uint256 value) = callback(); //~WARN: return value of an external call is not used
+        (, value) = callback(); //~WARN: return value of an external call is not used
+    }
+
+    function externalParameter(function() external returns (uint256, uint256) callback) external {
+        callback(); //~WARN: return value of an external call is not used
+    }
+
+    function noReturnValue() external {
+        function() external callback = this.noValue;
+        callback();
+    }
+
+    function internalCall() external pure {
+        function() internal pure returns (uint256) callback = internalValue;
+        callback();
+    }
+}
diff --git a/crates/lint/testdata/UnusedReturnFunctionPointer.stderr b/crates/lint/testdata/UnusedReturnFunctionPointer.stderr
new file mode 100644
index 0000000000000..8903a2604ef6a
--- /dev/null
+++ b/crates/lint/testdata/UnusedReturnFunctionPointer.stderr
@@ -0,0 +1,32 @@
+warning[unused-return]: return value of an external call is not used
+   ╭▸ ROOT/testdata/UnusedReturnFunctionPointer.sol:LL:CC
+   │
+LL │         callback();
+   │         ━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unused-return
+
+warning[unused-return]: return value of an external call is not used
+   ╭▸ ROOT/testdata/UnusedReturnFunctionPointer.sol:LL:CC
+   │
+LL │         (, uint256 value) = callback();
+   │                             ━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unused-return
+
+warning[unused-return]: return value of an external call is not used
+   ╭▸ ROOT/testdata/UnusedReturnFunctionPointer.sol:LL:CC
+   │
+LL │         (, value) = callback();
+   │         ━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unused-return
+
+warning[unused-return]: return value of an external call is not used
+   ╭▸ ROOT/testdata/UnusedReturnFunctionPointer.sol:LL:CC
+   │
+LL │         callback();
+   │         ━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unused-return
+
diff --git a/crates/lint/testdata/UnusedReturnInternal.sol b/crates/lint/testdata/UnusedReturnInternal.sol
new file mode 100644
index 0000000000000..ddae9cda72700
--- /dev/null
+++ b/crates/lint/testdata/UnusedReturnInternal.sol
@@ -0,0 +1,47 @@
+//@compile-flags: --only-lint unused-return
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.20;
+
+library Challenge {
+    function compute(uint256 value) internal pure returns (uint256, uint256) {
+        return (value, value);
+    }
+
+    function computePublic(uint256 value) public pure returns (uint256, uint256) {
+        return (value, value);
+    }
+}
+
+contract ChallengeBase {
+    function computeBase() public pure returns (uint256, uint256) {
+        return (1, 2);
+    }
+}
+
+contract UnusedReturnInternal is ChallengeBase {
+    using Challenge for uint256;
+
+    function internalCalls(uint256 value) external pure returns (uint256 challenge) {
+        // Internal library calls may discard all or part of their result.
+        Challenge.compute(value);
+        (, uint256 result) = Challenge.compute(value);
+        (, challenge) = Challenge.compute(result);
+        value.compute();
+        (, challenge) = value.compute();
+
+        // A public base function is still called internally through its name or super.
+        ChallengeBase.computeBase();
+        (, challenge) = ChallengeBase.computeBase();
+        super.computeBase();
+    }
+
+    function externalCalls(uint256 value) external view {
+        // Public library calls and calls through this remain external.
+        Challenge.computePublic(value); //~WARN: return value of an external call is not used
+        (, uint256 result) = Challenge.computePublic(value); //~WARN: return value of an external call is not used
+        (, result) = Challenge.computePublic(value); //~WARN: return value of an external call is not used
+        value.computePublic(); //~WARN: return value of an external call is not used
+        (, result) = value.computePublic(); //~WARN: return value of an external call is not used
+        this.computeBase(); //~WARN: return value of an external call is not used
+    }
+}
diff --git a/crates/lint/testdata/UnusedReturnInternal.stderr b/crates/lint/testdata/UnusedReturnInternal.stderr
new file mode 100644
index 0000000000000..f09294372afbf
--- /dev/null
+++ b/crates/lint/testdata/UnusedReturnInternal.stderr
@@ -0,0 +1,48 @@
+warning[unused-return]: return value of an external call is not used
+   ╭▸ ROOT/testdata/UnusedReturnInternal.sol:LL:CC
+   │
+LL │         Challenge.computePublic(value);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unused-return
+
+warning[unused-return]: return value of an external call is not used
+   ╭▸ ROOT/testdata/UnusedReturnInternal.sol:LL:CC
+   │
+LL │         (, uint256 result) = Challenge.computePublic(value);
+   │                              ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unused-return
+
+warning[unused-return]: return value of an external call is not used
+   ╭▸ ROOT/testdata/UnusedReturnInternal.sol:LL:CC
+   │
+LL │         (, result) = Challenge.computePublic(value);
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unused-return
+
+warning[unused-return]: return value of an external call is not used
+   ╭▸ ROOT/testdata/UnusedReturnInternal.sol:LL:CC
+   │
+LL │         value.computePublic();
+   │         ━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unused-return
+
+warning[unused-return]: return value of an external call is not used
+   ╭▸ ROOT/testdata/UnusedReturnInternal.sol:LL:CC
+   │
+LL │         (, result) = value.computePublic();
+   │         ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unused-return
+
+warning[unused-return]: return value of an external call is not used
+   ╭▸ ROOT/testdata/UnusedReturnInternal.sol:LL:CC
+   │
+LL │         this.computeBase();
+   │         ━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unused-return
+
diff --git a/crates/lint/testdata/UnusedStateVariables.sol b/crates/lint/testdata/UnusedStateVariables.sol
new file mode 100644
index 0000000000000..0116812c4d597
--- /dev/null
+++ b/crates/lint/testdata/UnusedStateVariables.sol
@@ -0,0 +1,52 @@
+//@compile-flags: --only-lint unused-state-variables
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+contract UnusedVars {
+    uint256 unused; //~NOTE: state variable is never used
+    uint256 usedInRead;
+    uint256 usedInWrite;
+    address usedInBoth;
+    uint256 constant CONST = 1; // skip constant
+    uint256 immutable IMMUT; // skip immutable
+
+    constructor() {
+        usedInBoth = msg.sender;
+    }
+
+    function read() external view returns (uint256) {
+        return usedInRead;
+    }
+
+    function write(uint256 v) external {
+        usedInWrite = v;
+    }
+
+    function both() external view returns (address) {
+        return usedInBoth;
+    }
+}
+
+// State variables used only as modifier call arguments must not be flagged.
+contract UsedInModifierArg {
+    uint256 limit;
+    uint256 unused; //~NOTE: state variable is never used
+
+    modifier limitedBy(uint256 max) {
+        require(msg.value <= max);
+        _;
+    }
+
+    function foo() external payable limitedBy(limit) {}
+}
+
+contract MultiUnused {
+    uint256 firstUnused; //~NOTE: state variable is never used
+    uint256 secondUnused; //~NOTE: state variable is never used
+    uint256 usedVar;
+
+    function use() external view returns (uint256) {
+        return usedVar;
+    }
+}
diff --git a/crates/lint/testdata/UnusedStateVariables.stderr b/crates/lint/testdata/UnusedStateVariables.stderr
new file mode 100644
index 0000000000000..f898246497d35
--- /dev/null
+++ b/crates/lint/testdata/UnusedStateVariables.stderr
@@ -0,0 +1,32 @@
+note[unused-state-variables]: state variable is never used
+   ╭▸ ROOT/testdata/UnusedStateVariables.sol:LL:CC
+   │
+LL │     uint256 unused;
+   │     ━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unused-state-variables
+
+note[unused-state-variables]: state variable is never used
+   ╭▸ ROOT/testdata/UnusedStateVariables.sol:LL:CC
+   │
+LL │     uint256 unused;
+   │     ━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unused-state-variables
+
+note[unused-state-variables]: state variable is never used
+   ╭▸ ROOT/testdata/UnusedStateVariables.sol:LL:CC
+   │
+LL │     uint256 firstUnused;
+   │     ━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unused-state-variables
+
+note[unused-state-variables]: state variable is never used
+   ╭▸ ROOT/testdata/UnusedStateVariables.sol:LL:CC
+   │
+LL │     uint256 secondUnused;
+   │     ━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unused-state-variables
+
diff --git a/crates/lint/testdata/UnwrappedModifierLogic.sol b/crates/lint/testdata/UnwrappedModifierLogic.sol
new file mode 100644
index 0000000000000..62001d4611a27
--- /dev/null
+++ b/crates/lint/testdata/UnwrappedModifierLogic.sol
@@ -0,0 +1,264 @@
+//@compile-flags: --severity code-size
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+library Lib {
+    function onlyOwner(address sender) internal {}
+}
+
+contract C {
+    function onlyOwner(address sender) public {}
+}
+
+/**
+ * @title UnwrappedModifierLogicTest
+ * @notice Test cases for the unwrapped-modifier-logic lint
+ * @dev This lint helps optimize gas by preventing modifier code duplication.
+ *      Solidity inlines modifier code at each usage point instead of using jumps,
+ *      so any logic in modifiers gets duplicated, increasing deployment costs.
+ */
+contract UnwrappedModifierLogicTest {
+    // Helpers
+
+    C immutable c;
+
+    event DidSomething(address who);
+    mapping(address => bool) isOwner;
+    mapping(address => mapping(bytes32 => bool)) hasRole;
+
+    /// -----------------------------------------------------------------------
+    /// Exceptions (assembly block)
+    /// -----------------------------------------------------------------------
+
+    modifier freeTempMemory() {
+        uint256 m;
+        assembly ("memory-safe") {
+            m := mload(0x40)
+        }
+        _;
+        assembly ("memory-safe") {
+            mstore(0x40, m)
+        }
+    }
+
+    modifier assemblyBlock(address sender) {
+        assembly {
+            let x := sender
+        }
+        _;
+    }
+
+    /// -----------------------------------------------------------------------
+    /// Good patterns (only 1 valid statement before or after placeholder)
+    /// -----------------------------------------------------------------------
+
+    function checkPublic(address sender) public {}
+    function checkPrivate(address sender) private {}
+    function checkInternal(address sender) internal {}
+
+    modifier onlyOwnerLibrary() {
+        Lib.onlyOwner(msg.sender);
+        _;
+    }
+
+    modifier onlyOwnerPublic() {
+        checkPublic(msg.sender);
+        _;
+    }
+
+    modifier onlyOwnerPrivate() {
+        checkPrivate(msg.sender);
+        _;
+    }
+
+    modifier onlyOwnerInternal() {
+        checkInternal(msg.sender);
+        _;
+    }
+
+    modifier onlyOwnerBeforeAfter() {
+        checkPublic(msg.sender);
+        _;
+        checkPrivate(msg.sender);
+    }
+
+    /// -----------------------------------------------------------------------
+    /// Bad patterns (multiple valid statements before or after placeholder)
+    /// -----------------------------------------------------------------------
+
+    // Bad because there are multiple valid function calls before the placeholder
+    modifier multipleBeforePlaceholder() { //~NOTE: modifier logic can be wrapped to reduce code size
+        checkPublic(msg.sender); // These should become _multipleBeforePlaceholder()
+        checkPrivate(msg.sender);
+        checkInternal(msg.sender);
+        _;
+    }
+
+    // Bad because there are multiple valid function calls after the placeholder
+    modifier multipleAfterPlaceholder() { //~NOTE: modifier logic can be wrapped to reduce code size
+        _;
+        checkPublic(msg.sender); // These should become _multipleAfterPlaceholder()
+        checkPrivate(msg.sender);
+        checkInternal(msg.sender);
+    }
+
+    // Bad because there are multiple valid statements both before and after
+    modifier multipleBeforeAfterPlaceholder(address sender) { //~NOTE: modifier logic can be wrapped to reduce code size
+        checkPublic(sender); // These should become _multipleBeforeAfterPlaceholderBefore(sender)
+        checkPrivate(sender);
+        _;
+        checkInternal(sender); // These should become _multipleBeforeAfterPlaceholderAfter(sender)
+        checkPublic(sender);
+    }
+
+    // Bad because there are multiple valid function calls before the placeholder.
+    // The single call after the placeholder must be preserved in the rewrite.
+    modifier beforeWrappedAfterKept(address sender) { //~NOTE: modifier logic can be wrapped to reduce code size
+        checkPublic(sender); // These should become _beforeWrappedAfterKept(sender)
+        checkPrivate(sender);
+        _;
+        checkInternal(sender); // This should stay in the modifier
+    }
+
+    // Bad because there are multiple valid function calls after the placeholder.
+    // The single call before the placeholder must be preserved in the rewrite.
+    modifier afterWrappedBeforeKept(address sender) { //~NOTE: modifier logic can be wrapped to reduce code size
+        checkPublic(sender); // This should stay in the modifier
+        _;
+        checkPrivate(sender); // These should become _afterWrappedBeforeKept(sender)
+        checkInternal(sender);
+    }
+
+    // Bad because there are multiple valid function calls after the placeholder.
+    // The assembly block before the placeholder must be preserved in the rewrite.
+    modifier keepAssemblyBefore() { //~NOTE: modifier logic can be wrapped to reduce code size
+        assembly ("memory-safe") {
+            mstore(0x00, 0)
+        }
+        _;
+        checkPublic(msg.sender); // These should become _keepAssemblyBefore()
+        checkPrivate(msg.sender);
+    }
+
+    /// -----------------------------------------------------------------------
+    /// Bad patterns (uses built-in control flow)
+    /// -----------------------------------------------------------------------
+
+    // Bad because `require` built-in is used.
+    modifier onlyOwner() { //~NOTE: modifier logic can be wrapped to reduce code size
+        require(isOwner[msg.sender], "Not owner"); // _onlyOwner();
+        _;
+    }
+
+    // Bad because `if/revert` is used.
+    modifier onlyRole(bytes32 role) { //~NOTE: modifier logic can be wrapped to reduce code size
+        if(!hasRole[msg.sender][role]) revert("Not authorized"); // _onlyRole(role);
+        _;
+    }
+
+    // Bad because `assert` built-in is used.
+    modifier onlyRoleOrOpenRole(bytes32 role) { //~NOTE: modifier logic can be wrapped to reduce code size
+        assert(hasRole[msg.sender][role] || hasRole[address(0)][role]); // _onlyRoleOrOpenRole(role);
+        _;
+    }
+
+    // Bad because `assert` built-in is used (ensures we can parse multiple params).
+    modifier onlyRoleOrAdmin(bytes32 role, address admin) { //~NOTE: modifier logic can be wrapped to reduce code size
+        assert(hasRole[msg.sender][role] || msg.sender == admin); // _onlyRoleOrAdmin(role, admin);
+        _;
+    }
+
+    /// -----------------------------------------------------------------------
+    /// Bad patterns (other invalid expressions and statements)
+    /// -----------------------------------------------------------------------
+
+    // Only call expressions are allowed (public/private/internal functions).
+    modifier assign(address sender) { //~NOTE: modifier logic can be wrapped to reduce code size
+        bool _isOwner = true;
+        isOwner[sender] = _isOwner;
+        _;
+    }
+
+    // Only call expressions are allowed (public/private/internal functions).
+    modifier uncheckedBlock(address sender) { //~NOTE: modifier logic can be wrapped to reduce code size
+        unchecked {
+            sender;
+        }
+        _;
+    }
+
+    // Only call expressions are allowed (public/private/internal functions).
+    modifier emitEvent(address sender) { //~NOTE: modifier logic can be wrapped to reduce code size
+        emit DidSomething(sender);
+        _;
+    }
+
+    /// -----------------------------------------------------------------------
+    /// Bad patterns (contract calls)
+    /// -----------------------------------------------------------------------
+
+    // Bad because there's an external call.
+    modifier onlyOwnerContract(address sender) { //~NOTE: modifier logic can be wrapped to reduce code size
+        c.onlyOwner(sender);
+        _;
+    }
+
+    /// -----------------------------------------------------------------------
+    /// Exceptions (locals declared before the placeholder and used after it)
+    /// -----------------------------------------------------------------------
+
+    function gasLeft() internal view returns (uint256) {
+        return gasleft();
+    }
+
+    function _payMeSubsidizedGasAfter(uint256 pre, uint256 amount) internal {}
+
+    // No lint: `pre` cannot be forwarded to the extracted helper.
+    modifier payMeSubsidizedGas(uint256 amount) {
+        uint256 pre = gasLeft();
+        _;
+        _payMeSubsidizedGasAfter(pre, amount);
+    }
+
+    // No lint: `pre` is shared across the placeholder, even though both sides would
+    // otherwise be wrapped.
+    modifier payMeSubsidizedGasAndLog(uint256 amount) {
+        uint256 pre = gasLeft();
+        checkPublic(msg.sender);
+        _;
+        checkPrivate(msg.sender);
+        _payMeSubsidizedGasAfter(pre, amount);
+    }
+
+    // No lint: `pre` is used after the placeholder, and the assembly block prevents
+    // wrapping the declaring side.
+    modifier trackFreeMemory() {
+        uint256 pre;
+        assembly ("memory-safe") {
+            pre := mload(0x40)
+        }
+        _;
+        checkPublic(msg.sender);
+        require(pre != 0, "no free memory");
+    }
+
+    // No lint: `m` is used inside the assembly block after the placeholder.
+    modifier restoreFreeMemory() {
+        uint256 m = gasLeft();
+        checkPublic(msg.sender);
+        _;
+        assembly ("memory-safe") {
+            mstore(0x40, m)
+        }
+    }
+
+    // No lint: `amount` is mutated before the placeholder and read after it.
+    // Extracting the pre-placeholder side into a helper would mutate only the helper's
+    // by-value parameter copy, while the post-placeholder code would see the original value.
+    modifier mutatedParamUsedAfter(uint256 amount) {
+        amount += 1;
+        _;
+        _payMeSubsidizedGasAfter(amount, amount);
+    }
+}
diff --git a/crates/lint/testdata/UnwrappedModifierLogic.stderr b/crates/lint/testdata/UnwrappedModifierLogic.stderr
new file mode 100644
index 0000000000000..c2c8c1c7ea1da
--- /dev/null
+++ b/crates/lint/testdata/UnwrappedModifierLogic.stderr
@@ -0,0 +1,345 @@
+note[unwrapped-modifier-logic]: modifier logic can be wrapped to reduce code size
+   ╭▸ ROOT/testdata/UnwrappedModifierLogic.sol:LL:CC
+   │
+LL │ ┏     modifier multipleBeforePlaceholder() {
+LL │ ┃         checkPublic(msg.sender); // These should become _multipleBeforePlaceholder()
+LL │ ┃         checkPrivate(msg.sender);
+LL │ ┃         checkInternal(msg.sender);
+LL │ ┃         _;
+LL │ ┃     }
+   │ ┗━━━━━┛
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unwrapped-modifier-logic
+help: wrap modifier logic to reduce code size
+   ╭╴
+LL ±     modifier multipleBeforePlaceholder() {
+LL +         _multipleBeforePlaceholder();
+LL +         _;
+LL +     }
+LL + 
+LL +     function _multipleBeforePlaceholder() internal {
+LL +         checkPublic(msg.sender);
+LL +         checkPrivate(msg.sender);
+LL +         checkInternal(msg.sender);
+LL +     }
+   ╰╴
+
+note[unwrapped-modifier-logic]: modifier logic can be wrapped to reduce code size
+   ╭▸ ROOT/testdata/UnwrappedModifierLogic.sol:LL:CC
+   │
+LL │ ┏     modifier multipleAfterPlaceholder() {
+LL │ ┃         _;
+LL │ ┃         checkPublic(msg.sender); // These should become _multipleAfterPlaceholder()
+LL │ ┃         checkPrivate(msg.sender);
+LL │ ┃         checkInternal(msg.sender);
+LL │ ┃     }
+   │ ┗━━━━━┛
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unwrapped-modifier-logic
+help: wrap modifier logic to reduce code size
+   ╭╴
+LL ±     modifier multipleAfterPlaceholder() {
+LL +         _;
+LL +         _multipleAfterPlaceholder();
+LL +     }
+LL + 
+LL +     function _multipleAfterPlaceholder() internal {
+LL +         checkPublic(msg.sender);
+LL +         checkPrivate(msg.sender);
+LL +         checkInternal(msg.sender);
+LL +     }
+   ╰╴
+
+note[unwrapped-modifier-logic]: modifier logic can be wrapped to reduce code size
+   ╭▸ ROOT/testdata/UnwrappedModifierLogic.sol:LL:CC
+   │
+LL │ ┏     modifier multipleBeforeAfterPlaceholder(address sender) {
+LL │ ┃         checkPublic(sender); // These should become _multipleBeforeAfterPlaceholderBefore(sender)
+LL │ ┃         checkPrivate(sender);
+LL │ ┃         _;
+LL │ ┃         checkInternal(sender); // These should become _multipleBeforeAfterPlaceholderAfter(sender)
+LL │ ┃         checkPublic(sender);
+LL │ ┃     }
+   │ ┗━━━━━┛
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unwrapped-modifier-logic
+help: wrap modifier logic to reduce code size
+   ╭╴
+LL ±     modifier multipleBeforeAfterPlaceholder(address sender) {
+LL +         _multipleBeforeAfterPlaceholderBefore(sender);
+LL +         _;
+LL +         _multipleBeforeAfterPlaceholderAfter(sender);
+LL +     }
+LL + 
+LL +     function _multipleBeforeAfterPlaceholderBefore(address sender) internal {
+LL +         checkPublic(sender);
+LL +         checkPrivate(sender);
+LL +     }
+LL + 
+LL +     function _multipleBeforeAfterPlaceholderAfter(address sender) internal {
+LL +         checkInternal(sender);
+LL +         checkPublic(sender);
+LL +     }
+   ╰╴
+
+note[unwrapped-modifier-logic]: modifier logic can be wrapped to reduce code size
+   ╭▸ ROOT/testdata/UnwrappedModifierLogic.sol:LL:CC
+   │
+LL │ ┏     modifier beforeWrappedAfterKept(address sender) {
+LL │ ┃         checkPublic(sender); // These should become _beforeWrappedAfterKept(sender)
+LL │ ┃         checkPrivate(sender);
+LL │ ┃         _;
+LL │ ┃         checkInternal(sender); // This should stay in the modifier
+LL │ ┃     }
+   │ ┗━━━━━┛
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unwrapped-modifier-logic
+help: wrap modifier logic to reduce code size
+   ╭╴
+LL ±     modifier beforeWrappedAfterKept(address sender) {
+LL +         _beforeWrappedAfterKept(sender);
+LL +         _;
+LL +         checkInternal(sender);
+LL +     }
+LL + 
+LL +     function _beforeWrappedAfterKept(address sender) internal {
+LL +         checkPublic(sender);
+LL +         checkPrivate(sender);
+LL +     }
+   ╰╴
+
+note[unwrapped-modifier-logic]: modifier logic can be wrapped to reduce code size
+   ╭▸ ROOT/testdata/UnwrappedModifierLogic.sol:LL:CC
+   │
+LL │ ┏     modifier afterWrappedBeforeKept(address sender) {
+LL │ ┃         checkPublic(sender); // This should stay in the modifier
+LL │ ┃         _;
+LL │ ┃         checkPrivate(sender); // These should become _afterWrappedBeforeKept(sender)
+LL │ ┃         checkInternal(sender);
+LL │ ┃     }
+   │ ┗━━━━━┛
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unwrapped-modifier-logic
+help: wrap modifier logic to reduce code size
+   ╭╴
+LL ±     modifier afterWrappedBeforeKept(address sender) {
+LL +         checkPublic(sender);
+LL +         _;
+LL +         _afterWrappedBeforeKept(sender);
+LL +     }
+LL + 
+LL +     function _afterWrappedBeforeKept(address sender) internal {
+LL +         checkPrivate(sender);
+LL +         checkInternal(sender);
+LL +     }
+   ╰╴
+
+note[unwrapped-modifier-logic]: modifier logic can be wrapped to reduce code size
+   ╭▸ ROOT/testdata/UnwrappedModifierLogic.sol:LL:CC
+   │
+LL │ ┏     modifier keepAssemblyBefore() {
+LL │ ┃         assembly ("memory-safe") {
+LL │ ┃             mstore(0x00, 0)
+   ‡ ┃
+LL │ ┃         checkPrivate(msg.sender);
+LL │ ┃     }
+   │ ┗━━━━━┛
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unwrapped-modifier-logic
+help: wrap modifier logic to reduce code size
+   ╭╴
+LL ±     modifier keepAssemblyBefore() {
+LL +         assembly ("memory-safe") {
+LL +             mstore(0x00, 0)
+LL +         }
+LL +         _;
+LL +         _keepAssemblyBefore();
+LL +     }
+LL + 
+LL +     function _keepAssemblyBefore() internal {
+LL +         checkPublic(msg.sender);
+LL +         checkPrivate(msg.sender);
+LL +     }
+   ╰╴
+
+note[unwrapped-modifier-logic]: modifier logic can be wrapped to reduce code size
+   ╭▸ ROOT/testdata/UnwrappedModifierLogic.sol:LL:CC
+   │
+LL │ ┏     modifier onlyOwner() {
+LL │ ┃         require(isOwner[msg.sender], "Not owner"); // _onlyOwner();
+LL │ ┃         _;
+LL │ ┃     }
+   │ ┗━━━━━┛
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unwrapped-modifier-logic
+help: wrap modifier logic to reduce code size
+   ╭╴
+LL ±     modifier onlyOwner() {
+LL +         _onlyOwner();
+LL +         _;
+LL +     }
+LL + 
+LL +     function _onlyOwner() internal {
+LL +         require(isOwner[msg.sender], "Not owner");
+LL +     }
+   ╰╴
+
+note[unwrapped-modifier-logic]: modifier logic can be wrapped to reduce code size
+   ╭▸ ROOT/testdata/UnwrappedModifierLogic.sol:LL:CC
+   │
+LL │ ┏     modifier onlyRole(bytes32 role) {
+LL │ ┃         if(!hasRole[msg.sender][role]) revert("Not authorized"); // _onlyRole(role);
+LL │ ┃         _;
+LL │ ┃     }
+   │ ┗━━━━━┛
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unwrapped-modifier-logic
+help: wrap modifier logic to reduce code size
+   ╭╴
+LL ±     modifier onlyRole(bytes32 role) {
+LL +         _onlyRole(role);
+LL +         _;
+LL +     }
+LL + 
+LL +     function _onlyRole(bytes32 role) internal {
+LL +         if(!hasRole[msg.sender][role]) revert("Not authorized");
+LL +     }
+   ╰╴
+
+note[unwrapped-modifier-logic]: modifier logic can be wrapped to reduce code size
+   ╭▸ ROOT/testdata/UnwrappedModifierLogic.sol:LL:CC
+   │
+LL │ ┏     modifier onlyRoleOrOpenRole(bytes32 role) {
+LL │ ┃         assert(hasRole[msg.sender][role] || hasRole[address(0)][role]); // _onlyRoleOrOpenRole(role);
+LL │ ┃         _;
+LL │ ┃     }
+   │ ┗━━━━━┛
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unwrapped-modifier-logic
+help: wrap modifier logic to reduce code size
+   ╭╴
+LL ±     modifier onlyRoleOrOpenRole(bytes32 role) {
+LL +         _onlyRoleOrOpenRole(role);
+LL +         _;
+LL +     }
+LL + 
+LL +     function _onlyRoleOrOpenRole(bytes32 role) internal {
+LL +         assert(hasRole[msg.sender][role] || hasRole[address(0)][role]);
+LL +     }
+   ╰╴
+
+note[unwrapped-modifier-logic]: modifier logic can be wrapped to reduce code size
+   ╭▸ ROOT/testdata/UnwrappedModifierLogic.sol:LL:CC
+   │
+LL │ ┏     modifier onlyRoleOrAdmin(bytes32 role, address admin) {
+LL │ ┃         assert(hasRole[msg.sender][role] || msg.sender == admin); // _onlyRoleOrAdmin(role, admin);
+LL │ ┃         _;
+LL │ ┃     }
+   │ ┗━━━━━┛
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unwrapped-modifier-logic
+help: wrap modifier logic to reduce code size
+   ╭╴
+LL ±     modifier onlyRoleOrAdmin(bytes32 role, address admin) {
+LL +         _onlyRoleOrAdmin(role, admin);
+LL +         _;
+LL +     }
+LL + 
+LL +     function _onlyRoleOrAdmin(bytes32 role, address admin) internal {
+LL +         assert(hasRole[msg.sender][role] || msg.sender == admin);
+LL +     }
+   ╰╴
+
+note[unwrapped-modifier-logic]: modifier logic can be wrapped to reduce code size
+   ╭▸ ROOT/testdata/UnwrappedModifierLogic.sol:LL:CC
+   │
+LL │ ┏     modifier assign(address sender) {
+LL │ ┃         bool _isOwner = true;
+LL │ ┃         isOwner[sender] = _isOwner;
+LL │ ┃         _;
+LL │ ┃     }
+   │ ┗━━━━━┛
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unwrapped-modifier-logic
+help: wrap modifier logic to reduce code size
+   ╭╴
+LL ±     modifier assign(address sender) {
+LL +         _assign(sender);
+LL +         _;
+LL +     }
+LL + 
+LL +     function _assign(address sender) internal {
+LL +         bool _isOwner = true;
+LL +         isOwner[sender] = _isOwner;
+LL +     }
+   ╰╴
+
+note[unwrapped-modifier-logic]: modifier logic can be wrapped to reduce code size
+   ╭▸ ROOT/testdata/UnwrappedModifierLogic.sol:LL:CC
+   │
+LL │ ┏     modifier uncheckedBlock(address sender) {
+LL │ ┃         unchecked {
+LL │ ┃             sender;
+   ‡ ┃
+LL │ ┃     }
+   │ ┗━━━━━┛
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unwrapped-modifier-logic
+help: wrap modifier logic to reduce code size
+   ╭╴
+LL ±     modifier uncheckedBlock(address sender) {
+LL +         _uncheckedBlock(sender);
+LL +         _;
+LL +     }
+LL + 
+LL +     function _uncheckedBlock(address sender) internal {
+LL +         unchecked {
+LL +             sender;
+LL +         }
+LL +     }
+   ╰╴
+
+note[unwrapped-modifier-logic]: modifier logic can be wrapped to reduce code size
+   ╭▸ ROOT/testdata/UnwrappedModifierLogic.sol:LL:CC
+   │
+LL │ ┏     modifier emitEvent(address sender) {
+LL │ ┃         emit DidSomething(sender);
+LL │ ┃         _;
+LL │ ┃     }
+   │ ┗━━━━━┛
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unwrapped-modifier-logic
+help: wrap modifier logic to reduce code size
+   ╭╴
+LL ±     modifier emitEvent(address sender) {
+LL +         _emitEvent(sender);
+LL +         _;
+LL +     }
+LL + 
+LL +     function _emitEvent(address sender) internal {
+LL +         emit DidSomething(sender);
+LL +     }
+   ╰╴
+
+note[unwrapped-modifier-logic]: modifier logic can be wrapped to reduce code size
+   ╭▸ ROOT/testdata/UnwrappedModifierLogic.sol:LL:CC
+   │
+LL │ ┏     modifier onlyOwnerContract(address sender) {
+LL │ ┃         c.onlyOwner(sender);
+LL │ ┃         _;
+LL │ ┃     }
+   │ ┗━━━━━┛
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/unwrapped-modifier-logic
+help: wrap modifier logic to reduce code size
+   ╭╴
+LL ±     modifier onlyOwnerContract(address sender) {
+LL +         _onlyOwnerContract(sender);
+LL +         _;
+LL +     }
+LL + 
+LL +     function _onlyOwnerContract(address sender) internal {
+LL +         c.onlyOwner(sender);
+LL +     }
+   ╰╴
+
diff --git a/crates/lint/testdata/VarReadUsingThis.sol b/crates/lint/testdata/VarReadUsingThis.sol
new file mode 100644
index 0000000000000..19d82710869b5
--- /dev/null
+++ b/crates/lint/testdata/VarReadUsingThis.sol
@@ -0,0 +1,287 @@
+//@compile-flags: --only-lint var-read-using-this
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.20;
+
+library Lib {
+    function foo() internal pure returns (uint256) {
+        return 1;
+    }
+}
+
+interface IThing {
+    function value() external view returns (uint256);
+}
+
+contract Other {
+    uint256 public value;
+}
+
+contract Base {
+    uint256 public baseVar;
+
+    function basePublicView() public view returns (uint256) {
+        return baseVar;
+    }
+}
+
+contract VarReadUsingThis is Base {
+    uint256 public counter;
+    mapping(uint256 => address) public owners;
+    uint256[] public items;
+    mapping(uint256 => mapping(address => uint256)) public balances;
+    uint256 internal internalVar;
+    uint256 private privateVar;
+
+    // State-variable initializer is walked too (runs in the synthesized constructor).
+    uint256 public initFromThis = uint256(uint160(address(this))) + this.counter(); //~NOTE: call through `this` to a `view` or `pure` function
+
+    event Counted(uint256);
+    error BadCount(uint256);
+
+    struct Info {
+        uint256 a;
+        uint256 b;
+    }
+    Info public info;
+
+    Other public other;
+
+    modifier withArg(uint256) {
+        _;
+    }
+
+    constructor() {
+        // Reading `this` in the constructor reverts at runtime, but the lint still applies.
+        counter = this.counter(); //~NOTE: call through `this` to a `view` or `pure` function
+    }
+
+    receive() external payable {
+        items.push(this.counter()); //~NOTE: call through `this` to a `view` or `pure` function
+    }
+
+    // SHOULD FAIL:
+
+    function simpleGetter() external view returns (uint256) {
+        return this.counter(); //~NOTE: call through `this` to a `view` or `pure` function
+    }
+
+    function mappingGetter(uint256 k) external view returns (address) {
+        return this.owners(k); //~NOTE: call through `this` to a `view` or `pure` function
+    }
+
+    function arrayGetter(uint256 i) external view returns (uint256) {
+        return this.items(i); //~NOTE: call through `this` to a `view` or `pure` function
+    }
+
+    function nestedMappingGetter(uint256 a, address b) external view returns (uint256) {
+        return this.balances(a, b); //~NOTE: call through `this` to a `view` or `pure` function
+    }
+
+    // Edge case: struct getter has multiple returns; emitted without an auto-fix.
+    function structGetter() external view returns (uint256, uint256) {
+        return this.info(); //~NOTE: call through `this` to a `view` or `pure` function
+    }
+
+    function publicViewLocal() public view returns (uint256) {
+        return counter;
+    }
+
+    function callPublicView() external view returns (uint256) {
+        return this.publicViewLocal(); //~NOTE: call through `this` to a `view` or `pure` function
+    }
+
+    // Improvement over Slither: external view/pure called via `this` is also flagged.
+    function externalViewLocal() external view returns (uint256) {
+        return counter;
+    }
+
+    function callExternalView() external view returns (uint256) {
+        return this.externalViewLocal(); //~NOTE: call through `this` to a `view` or `pure` function
+    }
+
+    function inheritedStateVar() external view returns (uint256) {
+        return this.baseVar(); //~NOTE: call through `this` to a `view` or `pure` function
+    }
+
+    function inheritedView() external view returns (uint256) {
+        return this.basePublicView(); //~NOTE: call through `this` to a `view` or `pure` function
+    }
+
+    function parenAroundThis() external view returns (uint256) {
+        return (this).counter(); //~NOTE: call through `this` to a `view` or `pure` function
+    }
+
+    function parenAroundCallee() external view returns (uint256) {
+        return (this.counter)(); //~NOTE: call through `this` to a `view` or `pure` function
+    }
+
+    // Edge case: call options like `{gas: ...}` are flagged but no auto-fix is offered.
+    function withCallOptions() external view returns (uint256) {
+        return this.publicViewLocal{gas: 10000}(); //~NOTE: call through `this` to a `view` or `pure` function
+    }
+
+    modifier checkCounter() {
+        require(this.counter() > 0, "zero"); //~NOTE: call through `this` to a `view` or `pure` function
+        _;
+    }
+
+    function gated() external checkCounter {}
+
+    // Modifier-invocation arguments are walked (this would be missed by a body-only walk).
+    function gatedWithArg() external view withArg(this.counter()) returns (uint256) { //~NOTE: call through `this` to a `view` or `pure` function
+        return 0;
+    }
+
+    function takesUint(uint256 x) public pure returns (uint256) {
+        return x;
+    }
+
+    // Both inner and outer `this.X(...)` calls are flagged.
+    function nestedCalls() external view returns (uint256) {
+        return this.publicViewLocal() + this.counter(); //~NOTE: call through `this` to a `view` or `pure` function
+        //~^NOTE: call through `this` to a `view` or `pure` function
+    }
+
+    function nestedAsArg() external view returns (uint256) {
+        return this.takesUint(this.counter()); //~NOTE: call through `this` to a `view` or `pure` function
+        //~^NOTE: call through `this` to a `view` or `pure` function
+    }
+
+    function inEmit() external {
+        emit Counted(this.counter()); //~NOTE: call through `this` to a `view` or `pure` function
+    }
+
+    function inRevert() external view {
+        revert BadCount(this.counter()); //~NOTE: call through `this` to a `view` or `pure` function
+    }
+
+    function inIfCondition() external view returns (uint256) {
+        if (this.counter() > 0) { //~NOTE: call through `this` to a `view` or `pure` function
+            return 1;
+        }
+        return 0;
+    }
+
+    function inTernary(bool b) external view returns (uint256) {
+        return b ? this.counter() : this.publicViewLocal(); //~NOTE: call through `this` to a `view` or `pure` function
+        //~^NOTE: call through `this` to a `view` or `pure` function
+    }
+
+    function inLoop(uint256 n) external view returns (uint256) {
+        uint256 sum;
+        for (uint256 i = 0; i < n; ++i) {
+            sum += this.counter(); //~NOTE: call through `this` to a `view` or `pure` function
+        }
+        return sum;
+    }
+
+    function inUnchecked() external view returns (uint256) {
+        unchecked {
+            return this.counter() + 1; //~NOTE: call through `this` to a `view` or `pure` function
+        }
+    }
+
+    // Inner `this.X(...)` inside a `try` argument is still flagged.
+    function tryWithNestedRead() external returns (uint256) {
+        try this.externalViewLocal() returns (uint256 v) {
+            return v + this.counter(); //~NOTE: call through `this` to a `view` or `pure` function
+        } catch {
+            return 0;
+        }
+    }
+
+    // SHOULD PASS:
+
+    function directAccess() external view returns (uint256) {
+        return counter;
+    }
+
+    // Edge case: function reference (no call) must not be flagged.
+    function functionReference() external view returns (function() external view returns (uint256)) {
+        return this.publicViewLocal;
+    }
+
+    function callOnOther() external view returns (uint256) {
+        return other.value();
+    }
+
+    function publicMutating() public {
+        counter += 1;
+    }
+
+    function callMutating() external {
+        this.publicMutating();
+    }
+
+    function internalView() internal view returns (uint256) {
+        return counter;
+    }
+
+    function callInternalView() external view returns (uint256) {
+        return internalView();
+    }
+
+    // Edge case: `super` is a delegatecall mechanism, not `this`.
+    function viaSuper() external view returns (uint256) {
+        return super.basePublicView();
+    }
+
+    function callLib() external pure returns (uint256) {
+        return Lib.foo();
+    }
+
+    // Edge case: `try this.X()` requires an external call by Solidity rules,
+    // so the outer call is intentional and must not be flagged.
+    function tryExternalView() external returns (uint256) {
+        try this.externalViewLocal() returns (uint256 v) {
+            return v;
+        } catch {
+            return 0;
+        }
+    }
+
+    // Edge case: explicit interface cast through `address(this)` is not followed.
+    function viaAddressThisCast() external view returns (uint256) {
+        return Other(address(this)).value();
+    }
+
+    // Edge case: `this.balance` is a builtin address member, not a function call.
+    function thisBalance() external view returns (uint256) {
+        return address(this).balance;
+    }
+
+    // Edge case: `this.foo.selector` is a function-pointer member access, not a call.
+    function selectorAccess() external view returns (bytes4) {
+        return this.publicViewLocal.selector;
+    }
+
+    // Edge case: inline `disable-next-line` must suppress the diagnostic.
+    function suppressed() external view returns (uint256) {
+        // forge-lint: disable-next-line(var-read-using-this)
+        return this.counter();
+    }
+
+    // Same-arity overloads use the selected function's mutability.
+    function ambiguous(uint256 x) public view returns (uint256) {
+        return x;
+    }
+    function ambiguous(address) public {
+        counter += 1;
+    }
+    function callAmbiguous() external view returns (uint256) {
+        return this.ambiguous(0); //~NOTE: call through `this` to a `view` or `pure` function
+    }
+    function callMutatingOverload() external {
+        this.ambiguous(address(0));
+    }
+}
+
+// Abstract contracts have `this`, so the lint still applies.
+abstract contract AbstractCase {
+    uint256 public abstractVar;
+
+    function readAbstract() external view returns (uint256) {
+        return this.abstractVar(); //~NOTE: call through `this` to a `view` or `pure` function
+    }
+}
diff --git a/crates/lint/testdata/VarReadUsingThis.stderr b/crates/lint/testdata/VarReadUsingThis.stderr
new file mode 100644
index 0000000000000..1ce3337a88705
--- /dev/null
+++ b/crates/lint/testdata/VarReadUsingThis.stderr
@@ -0,0 +1,288 @@
+note[var-read-using-this]: call through `this` to a `view` or `pure` function incurs a `STATICCALL`
+   ╭▸ ROOT/testdata/VarReadUsingThis.sol:LL:CC
+   │
+LL │ …6(uint160(address(this))) + this.counter();
+   │                              ━━━━━━━━━━━━━━ help: consider reading the state variable directly: `counter`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/var-read-using-this
+
+note[var-read-using-this]: call through `this` to a `view` or `pure` function incurs a `STATICCALL`
+   ╭▸ ROOT/testdata/VarReadUsingThis.sol:LL:CC
+   │
+LL │         counter = this.counter();
+   │                   ━━━━━━━━━━━━━━ help: consider reading the state variable directly: `counter`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/var-read-using-this
+
+note[var-read-using-this]: call through `this` to a `view` or `pure` function incurs a `STATICCALL`
+   ╭▸ ROOT/testdata/VarReadUsingThis.sol:LL:CC
+   │
+LL │         items.push(this.counter());
+   │                    ━━━━━━━━━━━━━━ help: consider reading the state variable directly: `counter`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/var-read-using-this
+
+note[var-read-using-this]: call through `this` to a `view` or `pure` function incurs a `STATICCALL`
+   ╭▸ ROOT/testdata/VarReadUsingThis.sol:LL:CC
+   │
+LL │         return this.counter();
+   │                ━━━━━━━━━━━━━━ help: consider reading the state variable directly: `counter`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/var-read-using-this
+
+note[var-read-using-this]: call through `this` to a `view` or `pure` function incurs a `STATICCALL`
+   ╭▸ ROOT/testdata/VarReadUsingThis.sol:LL:CC
+   │
+LL │         return this.owners(k);
+   │                ━━━━━━━━━━━━━━ help: consider accessing storage directly: `owners[k]`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/var-read-using-this
+
+note[var-read-using-this]: call through `this` to a `view` or `pure` function incurs a `STATICCALL`
+   ╭▸ ROOT/testdata/VarReadUsingThis.sol:LL:CC
+   │
+LL │         return this.items(i);
+   │                ━━━━━━━━━━━━━ help: consider accessing storage directly: `items[i]`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/var-read-using-this
+
+note[var-read-using-this]: call through `this` to a `view` or `pure` function incurs a `STATICCALL`
+   ╭▸ ROOT/testdata/VarReadUsingThis.sol:LL:CC
+   │
+LL │         return this.balances(a, b);
+   │                ━━━━━━━━━━━━━━━━━━━ help: consider accessing storage directly: `balances[a][b]`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/var-read-using-this
+
+note[var-read-using-this]: call through `this` to a `view` or `pure` function incurs a `STATICCALL`
+   ╭▸ ROOT/testdata/VarReadUsingThis.sol:LL:CC
+   │
+LL │         return this.info();
+   │                ━━━━━━━━━━━
+   │
+   ├ note: read the state variable directly instead of via `this.`
+   │       
+   │       read the state variable directly: `info`
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/var-read-using-this
+
+note[var-read-using-this]: call through `this` to a `view` or `pure` function incurs a `STATICCALL`
+   ╭▸ ROOT/testdata/VarReadUsingThis.sol:LL:CC
+   │
+LL │         return this.publicViewLocal();
+   │                ━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ├ note: avoid the `STATICCALL` by invoking the function directly
+   │       
+   │       call directly without `this.`: `publicViewLocal(...)`
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/var-read-using-this
+
+note[var-read-using-this]: call through `this` to a `view` or `pure` function incurs a `STATICCALL`
+   ╭▸ ROOT/testdata/VarReadUsingThis.sol:LL:CC
+   │
+LL │         return this.externalViewLocal();
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ├ note: avoid the `STATICCALL` by invoking the function directly
+   │       
+   │       call directly without `this.`: `externalViewLocal(...)`
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/var-read-using-this
+
+note[var-read-using-this]: call through `this` to a `view` or `pure` function incurs a `STATICCALL`
+   ╭▸ ROOT/testdata/VarReadUsingThis.sol:LL:CC
+   │
+LL │         return this.baseVar();
+   │                ━━━━━━━━━━━━━━ help: consider reading the state variable directly: `baseVar`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/var-read-using-this
+
+note[var-read-using-this]: call through `this` to a `view` or `pure` function incurs a `STATICCALL`
+   ╭▸ ROOT/testdata/VarReadUsingThis.sol:LL:CC
+   │
+LL │         return this.basePublicView();
+   │                ━━━━━━━━━━━━━━━━━━━━━
+   │
+   ├ note: avoid the `STATICCALL` by invoking the function directly
+   │       
+   │       call directly without `this.`: `basePublicView(...)`
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/var-read-using-this
+
+note[var-read-using-this]: call through `this` to a `view` or `pure` function incurs a `STATICCALL`
+   ╭▸ ROOT/testdata/VarReadUsingThis.sol:LL:CC
+   │
+LL │         return (this).counter();
+   │                ━━━━━━━━━━━━━━━━ help: consider reading the state variable directly: `counter`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/var-read-using-this
+
+note[var-read-using-this]: call through `this` to a `view` or `pure` function incurs a `STATICCALL`
+   ╭▸ ROOT/testdata/VarReadUsingThis.sol:LL:CC
+   │
+LL │         return (this.counter)();
+   │                ━━━━━━━━━━━━━━━━ help: consider reading the state variable directly: `counter`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/var-read-using-this
+
+note[var-read-using-this]: call through `this` to a `view` or `pure` function incurs a `STATICCALL`
+   ╭▸ ROOT/testdata/VarReadUsingThis.sol:LL:CC
+   │
+LL │         return this.publicViewLocal{gas: 10000}();
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/var-read-using-this
+
+note[var-read-using-this]: call through `this` to a `view` or `pure` function incurs a `STATICCALL`
+   ╭▸ ROOT/testdata/VarReadUsingThis.sol:LL:CC
+   │
+LL │         require(this.counter() > 0, "zero");
+   │                 ━━━━━━━━━━━━━━ help: consider reading the state variable directly: `counter`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/var-read-using-this
+
+note[var-read-using-this]: call through `this` to a `view` or `pure` function incurs a `STATICCALL`
+   ╭▸ ROOT/testdata/VarReadUsingThis.sol:LL:CC
+   │
+LL │     function gatedWithArg() external view withArg(this.counter()) returns (uint256) {
+   │                                                   ━━━━━━━━━━━━━━ help: consider reading the state variable directly: `counter`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/var-read-using-this
+
+note[var-read-using-this]: call through `this` to a `view` or `pure` function incurs a `STATICCALL`
+   ╭▸ ROOT/testdata/VarReadUsingThis.sol:LL:CC
+   │
+LL │         return this.publicViewLocal() + this.counter();
+   │                ━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ├ note: avoid the `STATICCALL` by invoking the function directly
+   │       
+   │       call directly without `this.`: `publicViewLocal(...)`
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/var-read-using-this
+
+note[var-read-using-this]: call through `this` to a `view` or `pure` function incurs a `STATICCALL`
+   ╭▸ ROOT/testdata/VarReadUsingThis.sol:LL:CC
+   │
+LL │         return this.publicViewLocal() + this.counter();
+   │                                         ━━━━━━━━━━━━━━ help: consider reading the state variable directly: `counter`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/var-read-using-this
+
+note[var-read-using-this]: call through `this` to a `view` or `pure` function incurs a `STATICCALL`
+   ╭▸ ROOT/testdata/VarReadUsingThis.sol:LL:CC
+   │
+LL │         return this.takesUint(this.counter());
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ├ note: avoid the `STATICCALL` by invoking the function directly
+   │       
+   │       call directly without `this.`: `takesUint(...)`
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/var-read-using-this
+
+note[var-read-using-this]: call through `this` to a `view` or `pure` function incurs a `STATICCALL`
+   ╭▸ ROOT/testdata/VarReadUsingThis.sol:LL:CC
+   │
+LL │         return this.takesUint(this.counter());
+   │                               ━━━━━━━━━━━━━━ help: consider reading the state variable directly: `counter`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/var-read-using-this
+
+note[var-read-using-this]: call through `this` to a `view` or `pure` function incurs a `STATICCALL`
+   ╭▸ ROOT/testdata/VarReadUsingThis.sol:LL:CC
+   │
+LL │         emit Counted(this.counter());
+   │                      ━━━━━━━━━━━━━━ help: consider reading the state variable directly: `counter`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/var-read-using-this
+
+note[var-read-using-this]: call through `this` to a `view` or `pure` function incurs a `STATICCALL`
+   ╭▸ ROOT/testdata/VarReadUsingThis.sol:LL:CC
+   │
+LL │         revert BadCount(this.counter());
+   │                         ━━━━━━━━━━━━━━ help: consider reading the state variable directly: `counter`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/var-read-using-this
+
+note[var-read-using-this]: call through `this` to a `view` or `pure` function incurs a `STATICCALL`
+   ╭▸ ROOT/testdata/VarReadUsingThis.sol:LL:CC
+   │
+LL │         if (this.counter() > 0) {
+   │             ━━━━━━━━━━━━━━ help: consider reading the state variable directly: `counter`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/var-read-using-this
+
+note[var-read-using-this]: call through `this` to a `view` or `pure` function incurs a `STATICCALL`
+   ╭▸ ROOT/testdata/VarReadUsingThis.sol:LL:CC
+   │
+LL │         return b ? this.counter() : this.publicViewLocal();
+   │                    ━━━━━━━━━━━━━━ help: consider reading the state variable directly: `counter`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/var-read-using-this
+
+note[var-read-using-this]: call through `this` to a `view` or `pure` function incurs a `STATICCALL`
+   ╭▸ ROOT/testdata/VarReadUsingThis.sol:LL:CC
+   │
+LL │         return b ? this.counter() : this.publicViewLocal();
+   │                                     ━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ├ note: avoid the `STATICCALL` by invoking the function directly
+   │       
+   │       call directly without `this.`: `publicViewLocal(...)`
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/var-read-using-this
+
+note[var-read-using-this]: call through `this` to a `view` or `pure` function incurs a `STATICCALL`
+   ╭▸ ROOT/testdata/VarReadUsingThis.sol:LL:CC
+   │
+LL │             sum += this.counter();
+   │                    ━━━━━━━━━━━━━━ help: consider reading the state variable directly: `counter`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/var-read-using-this
+
+note[var-read-using-this]: call through `this` to a `view` or `pure` function incurs a `STATICCALL`
+   ╭▸ ROOT/testdata/VarReadUsingThis.sol:LL:CC
+   │
+LL │             return this.counter() + 1;
+   │                    ━━━━━━━━━━━━━━ help: consider reading the state variable directly: `counter`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/var-read-using-this
+
+note[var-read-using-this]: call through `this` to a `view` or `pure` function incurs a `STATICCALL`
+   ╭▸ ROOT/testdata/VarReadUsingThis.sol:LL:CC
+   │
+LL │             return v + this.counter();
+   │                        ━━━━━━━━━━━━━━ help: consider reading the state variable directly: `counter`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/var-read-using-this
+
+note[var-read-using-this]: call through `this` to a `view` or `pure` function incurs a `STATICCALL`
+   ╭▸ ROOT/testdata/VarReadUsingThis.sol:LL:CC
+   │
+LL │         return this.ambiguous(0);
+   │                ━━━━━━━━━━━━━━━━━
+   │
+   ├ note: avoid the `STATICCALL` by invoking the function directly
+   │       
+   │       call directly without `this.`: `ambiguous(...)`
+   │       
+   │       
+   ╰ help: https://getfoundry.sh/forge/linting/var-read-using-this
+
+note[var-read-using-this]: call through `this` to a `view` or `pure` function incurs a `STATICCALL`
+   ╭▸ ROOT/testdata/VarReadUsingThis.sol:LL:CC
+   │
+LL │         return this.abstractVar();
+   │                ━━━━━━━━━━━━━━━━━━ help: consider reading the state variable directly: `abstractVar`
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/var-read-using-this
+
diff --git a/crates/lint/testdata/WeakPrng.sol b/crates/lint/testdata/WeakPrng.sol
new file mode 100644
index 0000000000000..59c1531b6ba88
--- /dev/null
+++ b/crates/lint/testdata/WeakPrng.sol
@@ -0,0 +1,180 @@
+//@compile-flags: --only-lint weak-prng
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+interface Receiver {
+    function record(uint256 value) external;
+}
+
+contract WeakPrngBase {
+    constructor(uint256 seed) {}
+}
+
+contract WeakPrng is WeakPrngBase(block.timestamp % 10) { //~WARN: weak randomness derived from a predictable on-chain value
+    uint256 constant DAY = 1 days;
+    uint256 constant MINUTE = 1 minutes;
+    uint256 public deadline;
+    uint256 public initializedSeed = block.timestamp % 10; //~WARN: weak randomness derived from a predictable on-chain value
+
+    modifier seeded(uint256 seed) {
+        _;
+    }
+
+    // SHOULD FAIL:
+
+    function timestampModulo(uint256 upper) external view returns (uint256) {
+        return block.timestamp % upper; //~WARN: weak randomness derived from a predictable on-chain value
+    }
+
+    function blockNumberModulo(uint256 upper) external view returns (uint256) {
+        return block.number % upper; //~WARN: weak randomness derived from a predictable on-chain value
+    }
+
+    function blockhashModulo(uint256 upper) external view returns (uint256) {
+        return uint256(blockhash(block.number - 1)) % upper; //~WARN: weak randomness derived from a predictable on-chain value
+    }
+
+    function hashTimestamp(uint256 upper) external view returns (uint256) {
+        return uint256(keccak256(abi.encodePacked(block.timestamp, msg.sender))) % upper; //~WARN: weak randomness derived from a predictable on-chain value
+    }
+
+    function hashEncodePackedPrevrandao() external view returns (bytes32) {
+        return keccak256(abi.encodePacked(block.prevrandao, msg.sender)); //~WARN: weak randomness derived from a predictable on-chain value
+    }
+
+    function hashEncodeCoinbase() external view returns (bytes32) {
+        return keccak256(abi.encode(block.coinbase, msg.sender)); //~WARN: weak randomness derived from a predictable on-chain value
+    }
+
+    function hashEncodeWithSelectorTimestamp() external view returns (bytes32) {
+        return keccak256(abi.encodeWithSelector(Receiver.record.selector, block.timestamp)); //~WARN: weak randomness derived from a predictable on-chain value
+    }
+
+    function hashEncodeWithSignatureTimestamp() external view returns (bytes32) {
+        return keccak256(abi.encodeWithSignature("record(uint256)", block.timestamp)); //~WARN: weak randomness derived from a predictable on-chain value
+    }
+
+    function hashEncodeCallTimestamp() external view returns (bytes32) {
+        return keccak256(abi.encodeCall(Receiver.record, (block.timestamp))); //~WARN: weak randomness derived from a predictable on-chain value
+    }
+
+    function hashDifficulty() external view returns (bytes32) {
+        return keccak256(abi.encodePacked(block.difficulty)); //~WARN: weak randomness derived from a predictable on-chain value
+    }
+
+    function hashBlockhash() external view returns (bytes32) {
+        return keccak256(abi.encodePacked(blockhash(block.number - 1))); //~WARN: weak randomness derived from a predictable on-chain value
+    }
+
+    function modifierArgument() external seeded(block.timestamp % 10) {} //~WARN: weak randomness derived from a predictable on-chain value
+
+    function timestampMinuteBound() external view returns (uint256) {
+        return block.timestamp % 60; //~WARN: weak randomness derived from a predictable on-chain value
+    }
+
+    function timestampConstantMinute() external view returns (uint256) {
+        return block.timestamp % MINUTE; //~WARN: weak randomness derived from a predictable on-chain value
+    }
+
+    function timestampTenMinuteBound() external view returns (uint256) {
+        return block.timestamp % 600; //~WARN: weak randomness derived from a predictable on-chain value
+    }
+
+    function timestampMinutesSubdenomination() external view returns (uint256) {
+        return block.timestamp % 1 minutes; //~WARN: weak randomness derived from a predictable on-chain value
+    }
+
+    function timestampTenMinutesSubdenomination() external view returns (uint256) {
+        return block.timestamp % 10 minutes; //~WARN: weak randomness derived from a predictable on-chain value
+    }
+
+    function reversedTimestampBucket() external view returns (uint256) {
+        return 1 days % block.timestamp; //~WARN: weak randomness derived from a predictable on-chain value
+    }
+
+    // SHOULD PASS:
+
+    function timestampOnly() external view returns (uint256) {
+        return block.timestamp;
+    }
+
+    function blockNumberOnly() external view returns (uint256) {
+        return block.number;
+    }
+
+    function schedulingOnly() external view returns (bool) {
+        return block.timestamp > deadline;
+    }
+
+    function timestampTimeBucket() external view returns (uint256) {
+        return block.timestamp % 1 days;
+    }
+
+    function timestampConstantTimeBucket() external view returns (uint256) {
+        return block.timestamp % DAY;
+    }
+
+    function timestampShiftedTimeBucket() external view returns (uint256) {
+        return block.timestamp % (DAY << 1);
+    }
+
+    function timestampNumericTimeBucket() external view returns (uint256) {
+        return block.timestamp % 86400;
+    }
+
+    function timestampExpressionTimeBucket() external view returns (uint256) {
+        return block.timestamp % (24 * 60 * 60);
+    }
+
+    function nestedTimestampTimeBucket() external view returns (uint256) {
+        return (block.timestamp % 1 days) % 10;
+    }
+
+    function hashInput(bytes memory data) external pure returns (bytes32) {
+        return keccak256(data);
+    }
+
+    function encodePackedPrevrandao() external view returns (bytes memory) {
+        return abi.encodePacked(block.prevrandao, msg.sender);
+    }
+
+    function encodeCoinbase() external view returns (bytes memory) {
+        return abi.encode(block.coinbase, msg.sender);
+    }
+
+    function encodeWithSelectorTimestamp() external view returns (bytes memory) {
+        return abi.encodeWithSelector(Receiver.record.selector, block.timestamp);
+    }
+
+    function encodeWithSignatureTimestamp() external view returns (bytes memory) {
+        return abi.encodeWithSignature("record(uint256)", block.timestamp);
+    }
+
+    function encodeCallTimestamp() external view returns (bytes memory) {
+        return abi.encodeCall(Receiver.record, (block.timestamp));
+    }
+
+    function encodePackedInput(address account) external pure returns (bytes memory) {
+        return abi.encodePacked(account);
+    }
+
+    function moduloInput(uint256 seed, uint256 upper) external pure returns (uint256) {
+        return seed % upper;
+    }
+
+    function localValueNotTracked(uint256 upper) external view returns (uint256) {
+        uint256 seed = block.timestamp;
+        return seed % upper;
+    }
+}
+
+contract UserDefinedBlock {
+    struct BlockData {
+        uint256 timestamp;
+        uint256 number;
+    }
+
+    function modulo(BlockData memory block) external pure returns (uint256) {
+        return block.timestamp % block.number;
+    }
+}
diff --git a/crates/lint/testdata/WeakPrng.stderr b/crates/lint/testdata/WeakPrng.stderr
new file mode 100644
index 0000000000000..b9878e6c7fbeb
--- /dev/null
+++ b/crates/lint/testdata/WeakPrng.stderr
@@ -0,0 +1,160 @@
+warning[weak-prng]: weak randomness derived from a predictable on-chain value
+   ╭▸ ROOT/testdata/WeakPrng.sol:LL:CC
+   │
+LL │ contract WeakPrng is WeakPrngBase(block.timestamp % 10) {
+   │                                   ━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/weak-prng
+
+warning[weak-prng]: weak randomness derived from a predictable on-chain value
+   ╭▸ ROOT/testdata/WeakPrng.sol:LL:CC
+   │
+LL │     uint256 public initializedSeed = block.timestamp % 10;
+   │                                      ━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/weak-prng
+
+warning[weak-prng]: weak randomness derived from a predictable on-chain value
+   ╭▸ ROOT/testdata/WeakPrng.sol:LL:CC
+   │
+LL │         return block.timestamp % upper;
+   │                ━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/weak-prng
+
+warning[weak-prng]: weak randomness derived from a predictable on-chain value
+   ╭▸ ROOT/testdata/WeakPrng.sol:LL:CC
+   │
+LL │         return block.number % upper;
+   │                ━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/weak-prng
+
+warning[weak-prng]: weak randomness derived from a predictable on-chain value
+   ╭▸ ROOT/testdata/WeakPrng.sol:LL:CC
+   │
+LL │         return uint256(blockhash(block.number - 1)) % upper;
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/weak-prng
+
+warning[weak-prng]: weak randomness derived from a predictable on-chain value
+   ╭▸ ROOT/testdata/WeakPrng.sol:LL:CC
+   │
+LL │ …     return uint256(keccak256(abi.encodePacked(block.timestamp, msg.sender))) % upper;
+   │              ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/weak-prng
+
+warning[weak-prng]: weak randomness derived from a predictable on-chain value
+   ╭▸ ROOT/testdata/WeakPrng.sol:LL:CC
+   │
+LL │ …     return keccak256(abi.encodePacked(block.prevrandao, msg.sender));
+   │              ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/weak-prng
+
+warning[weak-prng]: weak randomness derived from a predictable on-chain value
+   ╭▸ ROOT/testdata/WeakPrng.sol:LL:CC
+   │
+LL │         return keccak256(abi.encode(block.coinbase, msg.sender));
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/weak-prng
+
+warning[weak-prng]: weak randomness derived from a predictable on-chain value
+   ╭▸ ROOT/testdata/WeakPrng.sol:LL:CC
+   │
+LL │ …     return keccak256(abi.encodeWithSelector(Receiver.record.selector, block.timestamp));
+   │              ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/weak-prng
+
+warning[weak-prng]: weak randomness derived from a predictable on-chain value
+   ╭▸ ROOT/testdata/WeakPrng.sol:LL:CC
+   │
+LL │ …     return keccak256(abi.encodeWithSignature("record(uint256)", block.timestamp));
+   │              ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/weak-prng
+
+warning[weak-prng]: weak randomness derived from a predictable on-chain value
+   ╭▸ ROOT/testdata/WeakPrng.sol:LL:CC
+   │
+LL │ …     return keccak256(abi.encodeCall(Receiver.record, (block.timestamp)));
+   │              ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/weak-prng
+
+warning[weak-prng]: weak randomness derived from a predictable on-chain value
+   ╭▸ ROOT/testdata/WeakPrng.sol:LL:CC
+   │
+LL │         return keccak256(abi.encodePacked(block.difficulty));
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/weak-prng
+
+warning[weak-prng]: weak randomness derived from a predictable on-chain value
+   ╭▸ ROOT/testdata/WeakPrng.sol:LL:CC
+   │
+LL │ …     return keccak256(abi.encodePacked(blockhash(block.number - 1)));
+   │              ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/weak-prng
+
+warning[weak-prng]: weak randomness derived from a predictable on-chain value
+   ╭▸ ROOT/testdata/WeakPrng.sol:LL:CC
+   │
+LL │     function modifierArgument() external seeded(block.timestamp % 10) {}
+   │                                                 ━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/weak-prng
+
+warning[weak-prng]: weak randomness derived from a predictable on-chain value
+   ╭▸ ROOT/testdata/WeakPrng.sol:LL:CC
+   │
+LL │         return block.timestamp % 60;
+   │                ━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/weak-prng
+
+warning[weak-prng]: weak randomness derived from a predictable on-chain value
+   ╭▸ ROOT/testdata/WeakPrng.sol:LL:CC
+   │
+LL │         return block.timestamp % MINUTE;
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/weak-prng
+
+warning[weak-prng]: weak randomness derived from a predictable on-chain value
+   ╭▸ ROOT/testdata/WeakPrng.sol:LL:CC
+   │
+LL │         return block.timestamp % 600;
+   │                ━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/weak-prng
+
+warning[weak-prng]: weak randomness derived from a predictable on-chain value
+   ╭▸ ROOT/testdata/WeakPrng.sol:LL:CC
+   │
+LL │         return block.timestamp % 1 minutes;
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/weak-prng
+
+warning[weak-prng]: weak randomness derived from a predictable on-chain value
+   ╭▸ ROOT/testdata/WeakPrng.sol:LL:CC
+   │
+LL │         return block.timestamp % 10 minutes;
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/weak-prng
+
+warning[weak-prng]: weak randomness derived from a predictable on-chain value
+   ╭▸ ROOT/testdata/WeakPrng.sol:LL:CC
+   │
+LL │         return 1 days % block.timestamp;
+   │                ━━━━━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/weak-prng
+
diff --git a/crates/lint/testdata/WriteAfterWrite.sol b/crates/lint/testdata/WriteAfterWrite.sol
new file mode 100644
index 0000000000000..88985695493a9
--- /dev/null
+++ b/crates/lint/testdata/WriteAfterWrite.sol
@@ -0,0 +1,375 @@
+//@compile-flags: --only-lint write-after-write
+
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.13;
+
+contract WriteAfterWrite {
+    uint256 public x;
+    uint256 public y;
+    mapping(address => uint256) public balances;
+    mapping(uint256 => uint256) internal slots;
+
+    // bad: first write to x is dead
+    function bad1() public {
+        x = 1;
+        x = 2;
+    }
+
+    // bad: x written twice without read in between
+    function bad2(uint256 v) public {
+        x = 0;
+        x = v;
+    }
+
+    // bad: delete followed by write is also a redundant delete
+    function bad3(uint256 v) public {
+        delete x;
+        x = v;
+    }
+
+    // bad: parenthesized assignment statement is still caught
+    function bad4(uint256 v) public {
+        (x = 1);
+        x = v;
+    }
+
+    // bad: nested assignment used as initializer; LHS is still a write
+    function bad5_nested(uint256 v) public {
+        x = 1;
+        uint256 z = (x = v);
+        z;
+    }
+
+    // bad: intra-branch write-after-write inside an if body
+    function bad5(bool flag, uint256 v) public {
+        if (flag) {
+            x = 0;
+            x = v;
+        }
+    }
+
+    // good: x is read between writes
+    function good1() public returns (uint256) {
+        x = 1;
+        return x;
+    }
+
+    // good: compound assignment reads x before writing
+    function good2() public {
+        x = 1;
+        x += 1;
+    }
+
+    // good: x written, then y written (different vars)
+    function good3(uint256 v) public {
+        x = v;
+        y = v;
+    }
+
+    // good: mapping writes may target different slots
+    function good4(address a, address b, uint256 v) public {
+        balances[a] = v;
+        balances[b] = v;
+    }
+
+    // good: write in one branch, then write after the if (outer pending is cleared)
+    function good5(bool flag, uint256 v) public {
+        if (flag) {
+            x = 1;
+        }
+        x = v;
+    }
+
+    // good: x used as RHS before second write
+    function good6(uint256 v) public {
+        x = 1;
+        y = x + v;
+        x = v;
+    }
+
+    // good: any function call conservatively clears pending
+    function helper() internal view returns (uint256) { return x; }
+    function good7(uint256 v) public {
+        x = 1;
+        helper();
+        x = v;
+    }
+
+    // good: return before second write; first write is not dead
+    function good8(uint256 v) public returns (uint256) {
+        x = v;
+        return x;
+    }
+
+    // good: revert before second write; first write is not dead
+    function good9(uint256 v) public {
+        x = v;
+        revert();
+    }
+
+    // good: inner block shares pending with outer so reads inside count
+    function good10(uint256 v) public {
+        x = 1;
+        { y = x + v; }
+        x = v;
+    }
+
+    // bad: write in call argument is still overwritten before being read
+    function bad6(uint256 v) public {
+        x = 1;
+        helper2(x = v);
+    }
+    function helper2(uint256) internal pure {}
+
+    // good: ternary arms are exclusive; only one branch writes x
+    function good11(bool flag) public {
+        uint256 z = (flag ? (x = 1) : (x = 2));
+        z;
+    }
+
+    // good: && short-circuits; RHS write may not execute
+    function good12(uint256 v) public returns (bool) {
+        x = 1;
+        bool b = (v > 0) && (x = v) > 0;
+        return b;
+    }
+
+    // bad: tuple destructuring writes x; second write to x is dead
+    function bad7(uint256 v) public {
+        (x, y) = (1, 2);
+        x = v;
+    }
+
+    // bad: ++x writes x; x = v overwrites without reading
+    function bad8(uint256 v) public {
+        ++x;
+        x = v;
+    }
+
+    // bad: x = 1; x++ reads then writes; x = v overwrites the x++ write
+    function bad9(uint256 v) public {
+        x = 1;
+        x++;
+        x = v;
+    }
+
+    // bad: write inside call option named arg overwrites pending write
+    function bad10(address payable addr, uint256 v) public {
+        x = 1;
+        addr.call{value: (x = v)}("");
+    }
+
+    // bad: emit between two writes only reads args; pending should survive
+    event MyEvent(uint256 v);
+    function bad11(uint256 v) public {
+        x = 1;
+        emit MyEvent(v);
+        x = v;
+    }
+
+    // bad: compound result overwritten without a read
+    function bad12(uint256 v) public {
+        x += 1;
+        x = v;
+    }
+
+    // bad: subtraction result overwritten without a read
+    function bad13(uint256 v) public {
+        x -= 1;
+        x = v;
+    }
+
+    // bad: only the compound write is dead; it reads the preceding plain write
+    function bad14() public {
+        x = 1;
+        x += 2;
+        x = 3;
+    }
+
+    // bad: reading x does not consume an unrelated pending write to y
+    function bad15() public {
+        y = 1;
+        x += 2;
+        y = 3;
+    }
+
+    // bad: compound assignments nested in other expressions are still writes
+    function bad16() public {
+        uint256 z = (x += 1);
+        x = 2;
+        z;
+    }
+
+    // bad: multiplication assignment is tracked as a write
+    function bad17() public {
+        x *= 2;
+        x = 3;
+    }
+
+    // good: compound assignment reads the earlier write
+    function good16(uint256 v) public {
+        x = v;
+        x *= 2;
+    }
+
+    // good: evaluating an indexed compound LHS must not process its index twice
+    function good17() public {
+        slots[x = 1] += 1;
+    }
+
+    // bad: evaluating the indexed LHS retains its nested write
+    function bad18() public {
+        slots[x = 1] += 1;
+        x = 2;
+    }
+
+    // good: no false positive for writes after a return (unreachable code)
+    function good13(uint256 v) public returns (uint256) {
+        x = v;
+        return x;
+        x = 1;
+        x = 2;
+    }
+
+    // good: x++ reads x before writing, so prior x=1 write is live
+    function good14() public returns (uint256) {
+        x = 1;
+        return x++;
+    }
+
+    // good: pre-inc reads x before writing; write between two is fine
+    function good15(uint256 v) public {
+        x = v;
+        ++x;
+    }
+}
+
+// good: modifier placeholder clears pending so writes on both sides are live
+contract ModifierTest {
+    uint256 public x;
+
+    modifier guarded() {
+        x = 1;
+        _;
+        x = 0;
+    }
+
+    function go() public guarded {}
+}
+
+// bad: write-after-write inside a modifier body
+contract ModifierBad {
+    uint256 public x;
+
+    modifier badMod() {
+        x = 1;
+        x = 2;
+        _;
+    }
+}
+
+contract QualifiedBase {
+    uint256 internal x;
+}
+
+contract QualifiedStateVar is QualifiedBase {
+    // good: a qualified compound assignment reads the preceding write
+    function goodQualifiedCompound() public {
+        x = 1;
+        QualifiedBase.x += 1;
+    }
+
+    // bad: a qualified compound assignment is still tracked as a write
+    function badQualifiedCompound() public {
+        QualifiedBase.x += 1;
+        x = 2;
+    }
+
+    // good: a qualified read consumes the pending compound write
+    function goodQualifiedRead() public {
+        QualifiedBase.x += 1;
+        uint256 observed = QualifiedBase.x;
+        x = 2;
+        observed;
+    }
+}
+
+// good: nested block return makes subsequent writes unreachable — no FP
+contract NestedReturn {
+    uint256 public x;
+
+    function nestedReturn(uint256 v) public {
+        { return; }
+        x = 1;
+        x = 2;
+    }
+
+    function bothBranchesExit(bool c, uint256 v) public {
+        if (c) return; else revert();
+        x = 1;
+        x = 2;
+    }
+
+    function nestedBreak(bool c, uint256 v) public {
+        while (c) {
+            { break; }
+            x = 1;
+            x = 2;
+        }
+    }
+
+    // bad (FP): solar does not yet lower inline assembly to HIR (TODO in solar),
+    // so the assembly statement is invisible and x = 1 appears overwritten.
+    function asmReadsX(uint256 v) public {
+        x = 1;
+        assembly { let z := sload(0) }
+        x = v;
+    }
+}
+
+// UX: tuple span should point at component `x`, not entire tuple LHS
+contract TupleSpanTest {
+    uint256 public x;
+    uint256 public y;
+
+    // bad: only the x component is dead; span should highlight `x`
+    function tuplePartial(uint256 v) public {
+        (x, y) = (1, 2);
+        x = v;
+    }
+}
+
+// Known false negatives (conservative design choices; documented for future updates)
+contract KnownFalseNegatives {
+    uint256 public x;
+    uint256 public y;
+
+    // FN: x = 1 is dead on both paths but the If arm clears outer pending conservatively.
+    function branchMiss(bool c, uint256 v) public {
+        x = 1;
+        if (c) { y = 2; }
+        x = v;
+    }
+
+    // FN: both branch writes are dead but branches are analyzed with fresh maps.
+    function bothBranchesAssign(bool c, uint256 v) public {
+        if (c) x = 1; else x = 2;
+        x = v;
+    }
+
+    // FN: short-circuit RHS clears pending to avoid FP in conditional path,
+    // which also drops the outer x = 1 even when && doesn't touch x.
+    function shortCircuitMiss(bool a, bool b) public {
+        x = 1;
+        bool z = a && b;
+        x = 2;
+    }
+
+    // FN: all calls clear pending (conservative re-entrancy assumption),
+    // so pure/view calls like abi.encode also suppress the WAW.
+    function pureCallMiss() public {
+        x = 1;
+        abi.encode(uint256(0));
+        x = 2;
+    }
+}
diff --git a/crates/lint/testdata/WriteAfterWrite.stderr b/crates/lint/testdata/WriteAfterWrite.stderr
new file mode 100644
index 0000000000000..eaa454e7aa8a3
--- /dev/null
+++ b/crates/lint/testdata/WriteAfterWrite.stderr
@@ -0,0 +1,184 @@
+note[write-after-write]: redundant storage write; value overwritten before being read
+   ╭▸ ROOT/testdata/WriteAfterWrite.sol:LL:CC
+   │
+LL │         x = 1;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/write-after-write
+
+note[write-after-write]: redundant storage write; value overwritten before being read
+   ╭▸ ROOT/testdata/WriteAfterWrite.sol:LL:CC
+   │
+LL │         x = 0;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/write-after-write
+
+note[write-after-write]: redundant storage write; value overwritten before being read
+   ╭▸ ROOT/testdata/WriteAfterWrite.sol:LL:CC
+   │
+LL │         delete x;
+   │         ━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/write-after-write
+
+note[write-after-write]: redundant storage write; value overwritten before being read
+   ╭▸ ROOT/testdata/WriteAfterWrite.sol:LL:CC
+   │
+LL │         (x = 1);
+   │          ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/write-after-write
+
+note[write-after-write]: redundant storage write; value overwritten before being read
+   ╭▸ ROOT/testdata/WriteAfterWrite.sol:LL:CC
+   │
+LL │         x = 1;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/write-after-write
+
+note[write-after-write]: redundant storage write; value overwritten before being read
+   ╭▸ ROOT/testdata/WriteAfterWrite.sol:LL:CC
+   │
+LL │             x = 0;
+   │             ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/write-after-write
+
+note[write-after-write]: redundant storage write; value overwritten before being read
+   ╭▸ ROOT/testdata/WriteAfterWrite.sol:LL:CC
+   │
+LL │         x = 1;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/write-after-write
+
+note[write-after-write]: redundant storage write; value overwritten before being read
+   ╭▸ ROOT/testdata/WriteAfterWrite.sol:LL:CC
+   │
+LL │         (x, y) = (1, 2);
+   │          ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/write-after-write
+
+note[write-after-write]: redundant storage write; value overwritten before being read
+   ╭▸ ROOT/testdata/WriteAfterWrite.sol:LL:CC
+   │
+LL │         ++x;
+   │         ━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/write-after-write
+
+note[write-after-write]: redundant storage write; value overwritten before being read
+   ╭▸ ROOT/testdata/WriteAfterWrite.sol:LL:CC
+   │
+LL │         x++;
+   │         ━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/write-after-write
+
+note[write-after-write]: redundant storage write; value overwritten before being read
+   ╭▸ ROOT/testdata/WriteAfterWrite.sol:LL:CC
+   │
+LL │         x = 1;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/write-after-write
+
+note[write-after-write]: redundant storage write; value overwritten before being read
+   ╭▸ ROOT/testdata/WriteAfterWrite.sol:LL:CC
+   │
+LL │         x = 1;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/write-after-write
+
+note[write-after-write]: redundant storage write; value overwritten before being read
+   ╭▸ ROOT/testdata/WriteAfterWrite.sol:LL:CC
+   │
+LL │         x += 1;
+   │         ━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/write-after-write
+
+note[write-after-write]: redundant storage write; value overwritten before being read
+   ╭▸ ROOT/testdata/WriteAfterWrite.sol:LL:CC
+   │
+LL │         x -= 1;
+   │         ━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/write-after-write
+
+note[write-after-write]: redundant storage write; value overwritten before being read
+   ╭▸ ROOT/testdata/WriteAfterWrite.sol:LL:CC
+   │
+LL │         x += 2;
+   │         ━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/write-after-write
+
+note[write-after-write]: redundant storage write; value overwritten before being read
+   ╭▸ ROOT/testdata/WriteAfterWrite.sol:LL:CC
+   │
+LL │         y = 1;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/write-after-write
+
+note[write-after-write]: redundant storage write; value overwritten before being read
+   ╭▸ ROOT/testdata/WriteAfterWrite.sol:LL:CC
+   │
+LL │         uint256 z = (x += 1);
+   │                      ━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/write-after-write
+
+note[write-after-write]: redundant storage write; value overwritten before being read
+   ╭▸ ROOT/testdata/WriteAfterWrite.sol:LL:CC
+   │
+LL │         x *= 2;
+   │         ━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/write-after-write
+
+note[write-after-write]: redundant storage write; value overwritten before being read
+   ╭▸ ROOT/testdata/WriteAfterWrite.sol:LL:CC
+   │
+LL │         slots[x = 1] += 1;
+   │               ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/write-after-write
+
+note[write-after-write]: redundant storage write; value overwritten before being read
+   ╭▸ ROOT/testdata/WriteAfterWrite.sol:LL:CC
+   │
+LL │         x = 1;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/write-after-write
+
+note[write-after-write]: redundant storage write; value overwritten before being read
+   ╭▸ ROOT/testdata/WriteAfterWrite.sol:LL:CC
+   │
+LL │         QualifiedBase.x += 1;
+   │         ━━━━━━━━━━━━━━━━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/write-after-write
+
+note[write-after-write]: redundant storage write; value overwritten before being read
+   ╭▸ ROOT/testdata/WriteAfterWrite.sol:LL:CC
+   │
+LL │         x = 1;
+   │         ━━━━━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/write-after-write
+
+note[write-after-write]: redundant storage write; value overwritten before being read
+   ╭▸ ROOT/testdata/WriteAfterWrite.sol:LL:CC
+   │
+LL │         (x, y) = (1, 2);
+   │          ━
+   │
+   ╰ help: https://getfoundry.sh/forge/linting/write-after-write
+
diff --git a/crates/lint/testdata/auxiliary/EnumerableSetLib.sol b/crates/lint/testdata/auxiliary/EnumerableSetLib.sol
new file mode 100644
index 0000000000000..1b4b359b77e2f
--- /dev/null
+++ b/crates/lint/testdata/auxiliary/EnumerableSetLib.sol
@@ -0,0 +1,33 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+// Imported by EnumerableLoopRemoval.sol under an alias: the declared library name is what the
+// detector matches, not the name at the call site.
+library EnumerableSet {
+    struct AddressSet {
+        address[] _values;
+        mapping(address => uint256) _positions;
+    }
+
+    function at(AddressSet storage set, uint256 index) internal view returns (address) {
+        return set._values[index];
+    }
+
+    function remove(AddressSet storage set, address value) internal returns (bool) {
+        uint256 position = set._positions[value];
+        if (position == 0) return false;
+        uint256 lastIndex = set._values.length - 1;
+        if (position - 1 != lastIndex) {
+            address lastValue = set._values[lastIndex];
+            set._values[position - 1] = lastValue;
+            set._positions[lastValue] = position;
+        }
+        set._values.pop();
+        delete set._positions[value];
+        return true;
+    }
+
+    function length(AddressSet storage set) internal view returns (uint256) {
+        return set._values.length;
+    }
+}
diff --git a/crates/lint/testdata/auxiliary/ImportsAnotherFile.sol b/crates/lint/testdata/auxiliary/ImportsAnotherFile.sol
index 628b66690c57b..78fdf3ba92f83 100644
--- a/crates/lint/testdata/auxiliary/ImportsAnotherFile.sol
+++ b/crates/lint/testdata/auxiliary/ImportsAnotherFile.sol
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
 contract AnotherFile {
     // This contract is not used
 }
diff --git a/crates/lint/testdata/auxiliary/ImportsAnotherFile2.sol b/crates/lint/testdata/auxiliary/ImportsAnotherFile2.sol
index d4bf7c1b54309..8730cee98a1ad 100644
--- a/crates/lint/testdata/auxiliary/ImportsAnotherFile2.sol
+++ b/crates/lint/testdata/auxiliary/ImportsAnotherFile2.sol
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
 contract AnotherDummy {
     // This file is not used
 }
diff --git a/crates/lint/testdata/auxiliary/ImportsConstants.sol b/crates/lint/testdata/auxiliary/ImportsConstants.sol
index d1f8c758d0bae..0159ab5db0ccb 100644
--- a/crates/lint/testdata/auxiliary/ImportsConstants.sol
+++ b/crates/lint/testdata/auxiliary/ImportsConstants.sol
@@ -1,2 +1,5 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
 uint256 constant CONSTANT_0 = 42;
 uint256 constant CONSTANT_1 = 99;
diff --git a/crates/lint/testdata/auxiliary/ImportsFile.sol b/crates/lint/testdata/auxiliary/ImportsFile.sol
index 64924d4054d3d..d838257fb4fe5 100644
--- a/crates/lint/testdata/auxiliary/ImportsFile.sol
+++ b/crates/lint/testdata/auxiliary/ImportsFile.sol
@@ -1,3 +1,8 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+import "./ImportsTypes.sol";
+
 library symbol0 {
     function isUsed(address) internal pure returns (bool) {
         return true;
@@ -13,10 +18,12 @@ type symbolNotUsed is address;
 type symbolNotUsed2 is address;
 type symbolNotUsed3 is address;
 
-contract BaseContract {}
+abstract contract BaseContract {
+    function foo(uint256 a, symbol5 b) external virtual returns (uint256);
+}
 interface IContract {
-    function foo(uint256 a, uint256 b) external view returns (uint256);
-    function convert(address addr) external pure returns (uint256);
+    function foo(uint256 a, uint248 b) external returns (uint256);
+    function convert(address addr) external pure returns (MyOtherType);
 }
 
 interface IContractNotUsed {
diff --git a/crates/lint/testdata/auxiliary/ImportsSomeFile.sol b/crates/lint/testdata/auxiliary/ImportsSomeFile.sol
index 591c1936b02b7..4cab7e99475e3 100644
--- a/crates/lint/testdata/auxiliary/ImportsSomeFile.sol
+++ b/crates/lint/testdata/auxiliary/ImportsSomeFile.sol
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
 library SomeFile {
     struct Baz {
         uint256 amount;
diff --git a/crates/lint/testdata/auxiliary/ImportsSomeFile2.sol b/crates/lint/testdata/auxiliary/ImportsSomeFile2.sol
index 4ca8ea00a3ec4..d4ac534e8ff19 100644
--- a/crates/lint/testdata/auxiliary/ImportsSomeFile2.sol
+++ b/crates/lint/testdata/auxiliary/ImportsSomeFile2.sol
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
 struct Baz {
     address sender;
     uint256 value;
diff --git a/crates/lint/testdata/auxiliary/ImportsTypes.sol b/crates/lint/testdata/auxiliary/ImportsTypes.sol
index dc035c1b407ce..54eab60cf444e 100644
--- a/crates/lint/testdata/auxiliary/ImportsTypes.sol
+++ b/crates/lint/testdata/auxiliary/ImportsTypes.sol
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
 type MyType is uint256;
 type MyOtherType is uint256;
 type YetAnotherType is bool;
diff --git a/crates/lint/testdata/auxiliary/ImportsUtils.sol b/crates/lint/testdata/auxiliary/ImportsUtils.sol
index 9ca4a764d6c88..975b71939236c 100644
--- a/crates/lint/testdata/auxiliary/ImportsUtils.sol
+++ b/crates/lint/testdata/auxiliary/ImportsUtils.sol
@@ -1,3 +1,10 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
 function process(bool flag) pure returns (uint256) {
     return flag ? 1 : 0;
 }
+
+function calculate(uint256 a, uint256 b) pure returns (uint256) {
+    return a + b;
+}
diff --git a/crates/lint/testdata/auxiliary/ImportsUtils2.sol b/crates/lint/testdata/auxiliary/ImportsUtils2.sol
index 9ca4a764d6c88..975b71939236c 100644
--- a/crates/lint/testdata/auxiliary/ImportsUtils2.sol
+++ b/crates/lint/testdata/auxiliary/ImportsUtils2.sol
@@ -1,3 +1,10 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
 function process(bool flag) pure returns (uint256) {
     return flag ? 1 : 0;
 }
+
+function calculate(uint256 a, uint256 b) pure returns (uint256) {
+    return a + b;
+}
diff --git a/crates/lint/testdata/auxiliary/LocalOzNames.sol b/crates/lint/testdata/auxiliary/LocalOzNames.sol
new file mode 100644
index 0000000000000..3d61950056306
--- /dev/null
+++ b/crates/lint/testdata/auxiliary/LocalOzNames.sol
@@ -0,0 +1,21 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+import {IERC20} from "./openzeppelin-contracts/OzMocks.sol";
+
+// Local declarations reusing the OpenZeppelin names, under a path that does not name
+// OpenZeppelin: the provenance check keeps them out of scope.
+
+library SafeERC20 {
+    function safeApprove(IERC20 token, address spender, uint256 value) internal {
+        token.approve(spender, value);
+    }
+}
+
+contract AccessControl {
+    mapping(bytes32 => mapping(address => bool)) internal _roles;
+
+    function _setupRole(bytes32 role, address account) internal {
+        _roles[role][account] = true;
+    }
+}
diff --git a/crates/lint/testdata/auxiliary/MissingInheritanceExternal.sol b/crates/lint/testdata/auxiliary/MissingInheritanceExternal.sol
new file mode 100644
index 0000000000000..c1087152cd039
--- /dev/null
+++ b/crates/lint/testdata/auxiliary/MissingInheritanceExternal.sol
@@ -0,0 +1,8 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.20;
+
+// Stand-in for an interface declared in an external dependency (e.g. node_modules).
+// The lint must consider it as a candidate even though it is not in the input set.
+interface IExternalThing {
+    function doExternalThing() external returns (uint256);
+}
diff --git a/crates/lint/testdata/auxiliary/Test.sol b/crates/lint/testdata/auxiliary/Test.sol
new file mode 100644
index 0000000000000..640926eb35a15
--- /dev/null
+++ b/crates/lint/testdata/auxiliary/Test.sol
@@ -0,0 +1,27 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+contract Test {
+    Vm vm;
+}
+
+interface Vm {
+    // Unsafe cheatcodes
+    function ffi(string[] calldata) external returns (bytes memory);
+    function readFile(string calldata) external returns (string memory);
+    function readLine(string calldata) external returns (string memory);
+    function writeFile(string calldata, string calldata) external;
+    function writeLine(string calldata, string calldata) external;
+    function removeFile(string calldata) external;
+    function closeFile(string calldata) external;
+    function setEnv(string calldata, string calldata) external;
+    function deriveKey(string calldata, uint32) external returns (uint256);
+
+    // Safe cheatcodes
+    function prank(address) external;
+    function deal(address, uint256) external;
+    function warp(uint256) external;
+    function roll(uint256) external;
+    function assume(bool) external;
+    function expectRevert() external;
+}
diff --git a/crates/lint/testdata/auxiliary/UnusedErrorHidden.sol b/crates/lint/testdata/auxiliary/UnusedErrorHidden.sol
new file mode 100644
index 0000000000000..9feb842408128
--- /dev/null
+++ b/crates/lint/testdata/auxiliary/UnusedErrorHidden.sol
@@ -0,0 +1,13 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.27;
+
+// The import edge back to the fixture is what used to leak its raw items into this file's
+// namespace: only `Errors` is actually bound here, so the fixture's `error Hidden()` must
+// not be reachable through `H.`.
+import {Errors} from "../UnusedError.sol";
+
+library Hidden {
+    function id() internal pure returns (uint256) {
+        return 1;
+    }
+}
diff --git a/crates/lint/testdata/auxiliary/UnusedErrorReexport.sol b/crates/lint/testdata/auxiliary/UnusedErrorReexport.sol
new file mode 100644
index 0000000000000..81a304714a7ec
--- /dev/null
+++ b/crates/lint/testdata/auxiliary/UnusedErrorReexport.sol
@@ -0,0 +1,6 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.27;
+
+// Re-exports the fixture's error under a new name: a use of `R.Written` in the fixture
+// must count as a use of `UsedViaAliasReexport`.
+import {UsedViaAliasReexport as Written} from "../UnusedError.sol";
diff --git a/crates/lint/testdata/auxiliary/not-openzeppelin/Erc721Mocks.sol b/crates/lint/testdata/auxiliary/not-openzeppelin/Erc721Mocks.sol
new file mode 100644
index 0000000000000..097dc4afb17cc
--- /dev/null
+++ b/crates/lint/testdata/auxiliary/not-openzeppelin/Erc721Mocks.sol
@@ -0,0 +1,15 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+// A contract named exactly `ERC721` under a path whose component is `not-openzeppelin`. The
+// substring "openzeppelin" appears in the path, so a substring provenance check would wrongly
+// treat this local declaration as the canonical OpenZeppelin one; a path-component check must
+// treat it as unrelated code whose `_mint` is out of scope.
+
+contract ERC721 {
+    mapping(uint256 => address) internal _owners;
+
+    function _mint(address to, uint256 tokenId) internal virtual {
+        _owners[tokenId] = to;
+    }
+}
diff --git a/crates/lint/testdata/auxiliary/not-openzeppelin/OzMocks.sol b/crates/lint/testdata/auxiliary/not-openzeppelin/OzMocks.sol
new file mode 100644
index 0000000000000..f7aed0fe65eac
--- /dev/null
+++ b/crates/lint/testdata/auxiliary/not-openzeppelin/OzMocks.sol
@@ -0,0 +1,22 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+import {IERC20} from "../openzeppelin-contracts/OzMocks.sol";
+
+// Local declarations reusing the OpenZeppelin names under a `not-openzeppelin/` path. The
+// substring "openzeppelin" appears in the path, so a substring provenance check would wrongly
+// recognize these; a path-component check must treat them as unrelated code out of scope.
+
+library SafeERC20 {
+    function safeApprove(IERC20 token, address spender, uint256 value) internal {
+        token.approve(spender, value);
+    }
+}
+
+contract AccessControl {
+    mapping(bytes32 => mapping(address => bool)) internal _roles;
+
+    function _setupRole(bytes32 role, address account) internal {
+        _roles[role][account] = true;
+    }
+}
diff --git a/crates/lint/testdata/auxiliary/openzeppelin-contracts/Erc721Mocks.sol b/crates/lint/testdata/auxiliary/openzeppelin-contracts/Erc721Mocks.sol
new file mode 100644
index 0000000000000..6d6ad90c12a67
--- /dev/null
+++ b/crates/lint/testdata/auxiliary/openzeppelin-contracts/Erc721Mocks.sol
@@ -0,0 +1,35 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+// Minimal mirrors of the canonical OpenZeppelin ERC721 declarations, under a path that names
+// OpenZeppelin so the provenance check recognizes them.
+
+contract ERC721 {
+    mapping(uint256 => address) internal _owners;
+
+    function _mint(address to, uint256 tokenId) internal virtual {
+        _owners[tokenId] = to;
+    }
+
+    // The wrapper legitimately calls `_mint` after its receiver check: exempt.
+    function _safeMint(address to, uint256 tokenId) internal virtual {
+        _mint(to, tokenId);
+    }
+}
+
+contract ERC721Upgradeable {
+    mapping(uint256 => address) internal _owners;
+
+    function _mint(address to, uint256 tokenId) internal virtual {
+        _owners[tokenId] = to;
+    }
+}
+
+// Mirror of OZ v4's ERC721Consecutive: it overrides `_mint` with a construction guard and
+// forwards to the base without a receiver check, so its `_mint` is as unsafe as the base's.
+contract ERC721Consecutive is ERC721 {
+    function _mint(address to, uint256 tokenId) internal virtual override {
+        require(address(this).code.length > 0, "no construction mint");
+        super._mint(to, tokenId);
+    }
+}
diff --git a/crates/lint/testdata/auxiliary/openzeppelin-contracts/OzMocks.sol b/crates/lint/testdata/auxiliary/openzeppelin-contracts/OzMocks.sol
new file mode 100644
index 0000000000000..fe46565616609
--- /dev/null
+++ b/crates/lint/testdata/auxiliary/openzeppelin-contracts/OzMocks.sol
@@ -0,0 +1,51 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+// Minimal mirrors of the canonical OpenZeppelin declarations, under a path that names
+// OpenZeppelin so the provenance check recognizes them.
+
+interface IERC20 {
+    function approve(address spender, uint256 value) external returns (bool);
+    function allowance(address owner, address spender) external view returns (uint256);
+}
+
+library SafeERC20 {
+    function safeApprove(IERC20 token, address spender, uint256 value) internal {
+        require(value == 0 || token.allowance(address(this), spender) == 0, "non-zero");
+        token.approve(spender, value);
+    }
+
+    function safeIncreaseAllowance(IERC20 token, address spender, uint256 value) internal {
+        token.approve(spender, token.allowance(address(this), spender) + value);
+    }
+
+    function forceApprove(IERC20 token, address spender, uint256 value) internal {
+        token.approve(spender, value);
+    }
+}
+
+library SafeERC20Upgradeable {
+    function safeApprove(IERC20 token, address spender, uint256 value) internal {
+        token.approve(spender, value);
+    }
+}
+
+contract AccessControl {
+    mapping(bytes32 => mapping(address => bool)) internal _roles;
+
+    function _grantRole(bytes32 role, address account) internal virtual {
+        _roles[role][account] = true;
+    }
+
+    function _setupRole(bytes32 role, address account) internal virtual {
+        _grantRole(role, account);
+    }
+}
+
+contract AccessControlUpgradeable {
+    mapping(bytes32 => mapping(address => bool)) internal _roles;
+
+    function _setupRole(bytes32 role, address account) internal virtual {
+        _roles[role][account] = true;
+    }
+}
diff --git a/crates/lint/testdata/auxiliary/openzeppelin-contracts/SafeERC20Lib.sol b/crates/lint/testdata/auxiliary/openzeppelin-contracts/SafeERC20Lib.sol
new file mode 100644
index 0000000000000..abdadec96c887
--- /dev/null
+++ b/crates/lint/testdata/auxiliary/openzeppelin-contracts/SafeERC20Lib.sol
@@ -0,0 +1,14 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+// Imported by DeprecatedOzFunction.sol under an alias: the declared library name is what the
+// detector matches, not the name at the call site.
+interface IERC20Aux {
+    function approve(address spender, uint256 value) external returns (bool);
+}
+
+library SafeERC20 {
+    function safeApprove(IERC20Aux token, address spender, uint256 value) internal {
+        token.approve(spender, value);
+    }
+}
diff --git a/crates/lint/testdata/auxiliary/solady/SafeTransferLib.sol b/crates/lint/testdata/auxiliary/solady/SafeTransferLib.sol
new file mode 100644
index 0000000000000..ffbf9025aa72b
--- /dev/null
+++ b/crates/lint/testdata/auxiliary/solady/SafeTransferLib.sol
@@ -0,0 +1,15 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+import {IToken} from "../solmate/SolmateMocks.sol";
+
+// A same-name library from another package: Solady's SafeTransferLib checks that the token
+// has code on the empty-return success path, so it does not have the solmate v6 pitfall.
+// Its path does not name solmate, which is what keeps it out of scope.
+
+library SafeTransferLib {
+    function safeTransfer(IToken token, address to, uint256 amount) internal {
+        require(address(token).code.length > 0, "no code");
+        token.transfer(to, amount);
+    }
+}
diff --git a/crates/lint/testdata/auxiliary/solmate-fixed/SafeTransferLib.sol b/crates/lint/testdata/auxiliary/solmate-fixed/SafeTransferLib.sol
new file mode 100644
index 0000000000000..9755b44ff71e2
--- /dev/null
+++ b/crates/lint/testdata/auxiliary/solmate-fixed/SafeTransferLib.sol
@@ -0,0 +1,26 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+// A same-name `SafeTransferLib` under a path whose component is `solmate-fixed`, not `solmate`.
+// The substring "solmate" appears in the path, so a substring provenance check would wrongly
+// flag it; a path-component check must treat it as unrelated third-party/local code.
+
+interface IToken {
+    function transfer(address to, uint256 amount) external returns (bool);
+    function transferFrom(address from, address to, uint256 amount) external returns (bool);
+    function approve(address spender, uint256 amount) external returns (bool);
+}
+
+library SafeTransferLib {
+    function safeTransfer(IToken token, address to, uint256 amount) internal {
+        require(token.transfer(to, amount), "transfer failed");
+    }
+
+    function safeTransferFrom(IToken token, address from, address to, uint256 amount) internal {
+        require(token.transferFrom(from, to, amount), "transferFrom failed");
+    }
+
+    function safeApprove(IToken token, address spender, uint256 amount) internal {
+        require(token.approve(spender, amount), "approve failed");
+    }
+}
diff --git a/crates/lint/testdata/auxiliary/solmate/SolmateMocks.sol b/crates/lint/testdata/auxiliary/solmate/SolmateMocks.sol
new file mode 100644
index 0000000000000..9d33bea3961e0
--- /dev/null
+++ b/crates/lint/testdata/auxiliary/solmate/SolmateMocks.sol
@@ -0,0 +1,29 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+// Minimal mirror of solmate's SafeTransferLib, under a path that names solmate so the
+// provenance check recognizes it: the token operations next to the ETH one.
+
+interface IToken {
+    function transfer(address to, uint256 amount) external returns (bool);
+    function transferFrom(address from, address to, uint256 amount) external returns (bool);
+    function approve(address spender, uint256 amount) external returns (bool);
+}
+
+library SafeTransferLib {
+    function safeTransferETH(address to, uint256 amount) internal {
+        payable(to).transfer(amount);
+    }
+
+    function safeTransfer(IToken token, address to, uint256 amount) internal {
+        token.transfer(to, amount);
+    }
+
+    function safeTransferFrom(IToken token, address from, address to, uint256 amount) internal {
+        token.transferFrom(from, to, amount);
+    }
+
+    function safeApprove(IToken token, address spender, uint256 amount) internal {
+        token.approve(spender, amount);
+    }
+}
diff --git a/crates/lint/testdata/auxiliary/solmate/SolmateSafeTransferLib.sol b/crates/lint/testdata/auxiliary/solmate/SolmateSafeTransferLib.sol
new file mode 100644
index 0000000000000..25aaa6fdb55ca
--- /dev/null
+++ b/crates/lint/testdata/auxiliary/solmate/SolmateSafeTransferLib.sol
@@ -0,0 +1,14 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.18;
+
+// Imported by SolmateSafeTransferLib.sol under an alias: the declared library name is what the
+// detector matches, not the name at the call site.
+interface ITokenAux {
+    function transfer(address to, uint256 amount) external returns (bool);
+}
+
+library SafeTransferLib {
+    function safeTransfer(ITokenAux token, address to, uint256 amount) internal {
+        token.transfer(to, amount);
+    }
+}
diff --git a/crates/macros/Cargo.toml b/crates/macros/Cargo.toml
index de1872973f755..f44cf78bb33d1 100644
--- a/crates/macros/Cargo.toml
+++ b/crates/macros/Cargo.toml
@@ -22,4 +22,3 @@ doc = false
 proc-macro2.workspace = true
 quote.workspace = true
 syn.workspace = true
-proc-macro-error2 = "2"
diff --git a/crates/macros/src/cheatcodes.rs b/crates/macros/src/cheatcodes.rs
index 677e3ea7e8cfb..eb8abb3742348 100644
--- a/crates/macros/src/cheatcodes.rs
+++ b/crates/macros/src/cheatcodes.rs
@@ -2,23 +2,35 @@ use proc_macro2::{Ident, Span, TokenStream};
 use quote::quote;
 use syn::{Attribute, Data, DataStruct, DeriveInput, Error, Result};
 
-pub fn derive_cheatcode(input: &DeriveInput) -> Result {
+// Stable proc macros cannot emit warnings, so treat validation diagnostics as errors.
+macro_rules! emit_warning {
+    ($errors:expr, $span:expr, $($t:tt)*) => {
+        $errors.push(Error::new($span, format!($($t)*)))
+    };
+}
+
+pub fn derive_cheatcode(input: &DeriveInput, errors: &mut Vec) -> Result {
     let name = &input.ident;
     let name_s = name.to_string();
     match &input.data {
-        Data::Struct(s) if name_s.ends_with("Call") => derive_call(name, s, &input.attrs),
+        Data::Struct(s) if name_s.ends_with("Call") => derive_call(name, s, &input.attrs, errors),
         Data::Struct(_) if name_s.ends_with("Return") => Ok(TokenStream::new()),
-        Data::Struct(s) => derive_struct(name, s, &input.attrs),
-        Data::Enum(e) if name_s.ends_with("Calls") => derive_calls_enum(name, e),
-        Data::Enum(e) if name_s.ends_with("Errors") => derive_errors_events_enum(name, e, false),
-        Data::Enum(e) if name_s.ends_with("Events") => derive_errors_events_enum(name, e, true),
-        Data::Enum(e) => derive_enum(name, e, &input.attrs),
+        Data::Struct(s) => derive_struct(name, s, &input.attrs, errors),
+        Data::Enum(e) if name_s.ends_with("Calls") => derive_calls_enum(e),
+        Data::Enum(e) if name_s.ends_with("Errors") => derive_errors_events_enum(e, false),
+        Data::Enum(e) if name_s.ends_with("Events") => derive_errors_events_enum(e, true),
+        Data::Enum(e) => derive_enum(name, e, &input.attrs, errors),
         Data::Union(_) => Err(Error::new(name.span(), "unions are not supported")),
     }
 }
 
 /// Implements `CheatcodeDef` for a function call struct.
-fn derive_call(name: &Ident, data: &DataStruct, attrs: &[Attribute]) -> Result {
+fn derive_call(
+    name: &Ident,
+    data: &DataStruct,
+    attrs: &[Attribute],
+    errors: &mut Vec,
+) -> Result {
     let mut group = None::;
     let mut status = None::;
     let mut safety = None::;
@@ -50,7 +62,7 @@ fn derive_call(name: &Ident, data: &DataStruct, attrs: &[Attribute]) -> Result Result Result Result Result {
-    if input.variants.iter().any(|v| v.fields.len() != 1) {
-        return Err(syn::Error::new(name.span(), "expected all variants to have a single field"));
+fn sorted_variant_types(input: &syn::DataEnum) -> Result> {
+    if let Some(v) = input.variants.iter().find(|v| v.fields.len() != 1) {
+        return Err(syn::Error::new(v.ident.span(), "expected variant to have a single field"));
     }
 
-    // keep original order for matching
+    let mut variants: Vec<_> = input.variants.iter().collect();
+    variants.sort_by_key(|v| &v.ident);
+    Ok(variants.into_iter().map(|v| &v.fields.iter().next().unwrap().ty).collect())
+}
+
+/// Generates the `CHEATCODES` constant and implements `CheatcodeImpl` dispatch for an enum.
+fn derive_calls_enum(input: &syn::DataEnum) -> Result {
+    let variant_tys = sorted_variant_types(input)?;
     let variant_names = input.variants.iter().map(|v| &v.ident);
 
-    let mut variants = input.variants.iter().collect::>();
-    variants.sort_by(|a, b| a.ident.cmp(&b.ident));
-    let variant_tys = variants.iter().map(|v| {
-        assert_eq!(v.fields.len(), 1);
-        &v.fields.iter().next().unwrap().ty
-    });
     Ok(quote! {
         /// All the cheatcodes in [this contract](self).
         pub const CHEATCODES: &'static [&'static Cheatcode<'static>] = &[#(<#variant_tys as CheatcodeDef>::CHEATCODE,)*];
@@ -129,14 +142,8 @@ fn derive_calls_enum(name: &Ident, input: &syn::DataEnum) -> Result
     })
 }
 
-fn derive_errors_events_enum(
-    name: &Ident,
-    input: &syn::DataEnum,
-    events: bool,
-) -> Result {
-    if input.variants.iter().any(|v| v.fields.len() != 1) {
-        return Err(syn::Error::new(name.span(), "expected all variants to have a single field"));
-    }
+fn derive_errors_events_enum(input: &syn::DataEnum, events: bool) -> Result {
+    let variant_tys = sorted_variant_types(input)?;
 
     let (ident, ty_assoc_name, ty, doc) = if events {
         ("VM_EVENTS", "EVENT", "Event", "events")
@@ -148,12 +155,6 @@ fn derive_errors_events_enum(
     let ty = Ident::new(ty, Span::call_site());
     let doc = format!("All the {doc} in [this contract](self).");
 
-    let mut variants = input.variants.iter().collect::>();
-    variants.sort_by(|a, b| a.ident.cmp(&b.ident));
-    let variant_tys = variants.iter().map(|v| {
-        assert_eq!(v.fields.len(), 1);
-        &v.fields.iter().next().unwrap().ty
-    });
     Ok(quote! {
         #[doc = #doc]
         pub const #ident: &'static [&'static #ty<'static>] = &[#(#variant_tys::#ty_assoc_name,)*];
@@ -164,6 +165,7 @@ fn derive_struct(
     name: &Ident,
     input: &syn::DataStruct,
     attrs: &[Attribute],
+    errors: &mut Vec,
 ) -> Result {
     let name_s = name.to_string();
 
@@ -193,11 +195,11 @@ fn derive_struct(
             StructKind::Event => "n",
             StructKind::Struct => "",
         };
-        emit_warning!(name.span(), "missing documentation for a{n} {}", kind.as_str());
+        emit_warning!(errors, name.span(), "missing documentation for a{n} {}", kind.as_str());
     }
 
     if kind == StructKind::Struct {
-        check_named_fields(input, name);
+        check_named_fields(input, name, errors);
     }
 
     let def = match kind {
@@ -210,9 +212,7 @@ fn derive_struct(
                 let ty = &def[..ty_end];
                 let ty_start = ty.rfind(';').or_else(|| ty.find('{')).expect("bad struct def") + 1;
                 let ty = ty[ty_start..].trim();
-                if ty.is_empty() {
-                    panic!("bad struct def: {def:?}")
-                }
+                assert!(!ty.is_empty(), "bad struct def: {def:?}");
 
                 let doc = get_docstring(&f.attrs);
                 let doc = doc.trim();
@@ -269,7 +269,7 @@ enum StructKind {
 }
 
 impl StructKind {
-    fn as_str(self) -> &'static str {
+    const fn as_str(self) -> &'static str {
         match self {
             Self::Struct => "struct",
             Self::Error => "error",
@@ -278,20 +278,25 @@ impl StructKind {
     }
 }
 
-fn derive_enum(name: &Ident, input: &syn::DataEnum, attrs: &[Attribute]) -> Result {
+fn derive_enum(
+    name: &Ident,
+    input: &syn::DataEnum,
+    attrs: &[Attribute],
+    errors: &mut Vec,
+) -> Result {
     let name_s = name.to_string();
     let doc = get_docstring(attrs);
     let doc_end = doc.find("```solidity").expect("bad docstring");
     let doc = doc[..doc_end].trim();
     if doc.is_empty() {
-        emit_warning!(name.span(), "missing documentation for an enum");
+        emit_warning!(errors, name.span(), "missing documentation for an enum");
     }
     let variants = input.variants.iter().filter(|v| v.discriminant.is_none()).map(|v| {
         let name = v.ident.to_string();
         let doc = get_docstring(&v.attrs);
         let doc = doc.trim();
         if doc.is_empty() {
-            emit_warning!(v.ident.span(), "missing documentation for a variant");
+            emit_warning!(errors, v.ident.span(), "missing documentation for a variant");
         }
         quote! {
             EnumVariant {
@@ -312,10 +317,10 @@ fn derive_enum(name: &Ident, input: &syn::DataEnum, attrs: &[Attribute]) -> Resu
     })
 }
 
-fn check_named_fields(data: &DataStruct, ident: &Ident) {
+fn check_named_fields(data: &DataStruct, ident: &Ident, errors: &mut Vec) {
     for field in &data.fields {
         if field.ident.is_none() {
-            emit_warning!(ident, "all params must be named");
+            emit_warning!(errors, ident.span(), "all params must be named");
         }
     }
 }
diff --git a/crates/macros/src/console_fmt.rs b/crates/macros/src/console_fmt.rs
index 2372756c2efc7..245ba6e5b78e2 100644
--- a/crates/macros/src/console_fmt.rs
+++ b/crates/macros/src/console_fmt.rs
@@ -7,7 +7,7 @@ use syn::{
 pub fn console_fmt(input: &DeriveInput) -> TokenStream {
     let name = &input.ident;
     let tokens = match &input.data {
-        Data::Struct(s) => derive_struct(s),
+        Data::Struct(s) => derive_struct(s, name),
         Data::Enum(e) => derive_enum(e),
         Data::Union(_) => return quote!(compile_error!("Unions are unsupported");),
     };
@@ -18,8 +18,8 @@ pub fn console_fmt(input: &DeriveInput) -> TokenStream {
     }
 }
 
-fn derive_struct(s: &DataStruct) -> TokenStream {
-    let imp = impl_struct(s).unwrap_or_else(|| quote!(String::new()));
+fn derive_struct(s: &DataStruct, name: &Ident) -> TokenStream {
+    let imp = impl_struct(s, name).unwrap_or_else(|| quote!(String::new()));
     quote! {
         fn fmt(&self, _spec: FormatSpec) -> String {
             #imp
@@ -27,7 +27,7 @@ fn derive_struct(s: &DataStruct) -> TokenStream {
     }
 }
 
-fn impl_struct(s: &DataStruct) -> Option {
+fn impl_struct(s: &DataStruct, name: &Ident) -> Option {
     if s.fields.is_empty() {
         return None;
     }
@@ -36,21 +36,54 @@ fn impl_struct(s: &DataStruct) -> Option {
         return None;
     }
 
+    let members = s.fields.members().collect::>();
     let fields = s.fields.iter().collect::>();
+
+    // Detect table call structs: name must start with "table" (from the ABI function name) and
+    // all fields must be Vec types (Solidity arrays). Both conditions together prevent
+    // accidental table rendering for unrelated structs that happen to have Vec fields.
+    let is_table = name.to_string().starts_with("table")
+        && !fields.is_empty()
+        && fields.iter().all(|f| match &f.ty {
+            Type::Path(path) => path.path.segments.last().is_some_and(|seg| seg.ident == "Vec"),
+            _ => false,
+        });
+    if is_table {
+        let member_ref = |m: &Member| match m {
+            Member::Named(ident) => quote!(&self.#ident),
+            Member::Unnamed(idx) => quote!(&self.#idx),
+        };
+        let imp = if members.len() == 1 {
+            let vals = member_ref(&members[0]);
+            quote! {
+                let values: ::std::vec::Vec<&dyn ConsoleFmt> =
+                    (#vals).iter().map(|v| v as &dyn ConsoleFmt).collect();
+                console_table_format(None, &values)
+            }
+        } else {
+            let keys = member_ref(&members[0]);
+            let vals = member_ref(&members[1]);
+            quote! {
+                let keys: ::std::vec::Vec<&dyn ConsoleFmt> =
+                    (#keys).iter().map(|v| v as &dyn ConsoleFmt).collect();
+                let values: ::std::vec::Vec<&dyn ConsoleFmt> =
+                    (#vals).iter().map(|v| v as &dyn ConsoleFmt).collect();
+                console_table_format(Some(&keys), &values)
+            }
+        };
+        return Some(imp);
+    }
+
     let first_ty = match &fields.first().unwrap().ty {
         Type::Path(path) => path.path.segments.last().unwrap().ident.to_string(),
         _ => String::new(),
     };
 
-    let members = s.fields.members().collect::>();
     let args: Punctuated = members
         .into_iter()
         .map(|member| match member {
             Member::Named(ident) => quote!(&self.#ident),
-            // For Tuple structs generated by the sol!.
-            // These are generated only in case of a single unnamed field, hence it is safe to
-            // hardcode the index to `.0`.
-            Member::Unnamed(_) => quote!(&self.0),
+            Member::Unnamed(idx) => quote!(&self.#idx),
         })
         .collect();
 
@@ -84,9 +117,7 @@ fn derive_enum(e: &DataEnum) -> TokenStream {
 
         let fields: Punctuated = fields
             .enumerate()
-            .map(|(i, field)| {
-                field.ident.as_ref().cloned().unwrap_or_else(|| format_ident!("__var_{i}"))
-            })
+            .map(|(i, field)| field.ident.clone().unwrap_or_else(|| format_ident!("__var_{i}")))
             .collect();
 
         if fields.len() != 1 {
diff --git a/crates/macros/src/lib.rs b/crates/macros/src/lib.rs
index bbe24e6f1f049..a9708b476fb59 100644
--- a/crates/macros/src/lib.rs
+++ b/crates/macros/src/lib.rs
@@ -3,10 +3,7 @@
 //! Internal Foundry proc-macros.
 
 #![cfg_attr(not(test), warn(unused_crate_dependencies))]
-#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))]
-
-#[macro_use]
-extern crate proc_macro_error2;
+#![cfg_attr(docsrs, feature(doc_cfg))]
 
 use proc_macro::TokenStream;
 use syn::{DeriveInput, Error, parse_macro_input};
@@ -21,8 +18,11 @@ pub fn console_fmt(input: TokenStream) -> TokenStream {
 }
 
 #[proc_macro_derive(Cheatcode, attributes(cheatcode))]
-#[proc_macro_error]
 pub fn cheatcode(input: TokenStream) -> TokenStream {
     let input = parse_macro_input!(input as DeriveInput);
-    cheatcodes::derive_cheatcode(&input).unwrap_or_else(Error::into_compile_error).into()
+    let mut errors = Vec::new();
+    let mut output =
+        cheatcodes::derive_cheatcode(&input, &mut errors).unwrap_or_else(Error::into_compile_error);
+    output.extend(errors.into_iter().map(Error::into_compile_error));
+    output.into()
 }
diff --git a/crates/primitives/Cargo.toml b/crates/primitives/Cargo.toml
new file mode 100644
index 0000000000000..8655a5bb46c28
--- /dev/null
+++ b/crates/primitives/Cargo.toml
@@ -0,0 +1,74 @@
+[package]
+name = "foundry-primitives"
+
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+authors.workspace = true
+license.workspace = true
+homepage.workspace = true
+repository.workspace = true
+
+[lints]
+workspace = true
+
+[dependencies]
+alloy-consensus.workspace = true
+alloy-eips.workspace = true
+alloy-network.workspace = true
+alloy-primitives.workspace = true
+alloy-provider.workspace = true
+alloy-rlp.workspace = true
+alloy-rpc-types = { workspace = true, features = ["trace"] }
+alloy-rpc-types-eth.workspace = true
+alloy-serde.workspace = true
+alloy-signer.workspace = true
+alloy-evm.workspace = true
+op-alloy-consensus = { workspace = true, features = ["serde", "alloy-compat"], optional = true }
+op-alloy-rpc-types = { workspace = true, optional = true }
+alloy-op-evm = { workspace = true, optional = true }
+op-revm = { workspace = true, optional = true }
+
+# Base
+base-common-consensus = { workspace = true, optional = true, features = [
+    "alloy-compat",
+    "evm",
+    "k256",
+    "serde",
+    "std",
+] }
+base-common-evm = { workspace = true, optional = true, features = [
+    "blst",
+    "c-kzg",
+    "memory_limit",
+    "optional_block_gas_limit",
+    "optional_eip3607",
+    "optional_no_base_fee",
+    "serde",
+    "std",
+] }
+base-common-rpc-types = { workspace = true, optional = true, features = ["serde", "std"] }
+
+revm.workspace = true
+serde_json.workspace = true
+serde = { version = "1.0", features = ["derive"] }
+derive_more.workspace = true
+tempo-primitives.workspace = true
+tempo-alloy.workspace = true
+tempo-revm.workspace = true
+
+[features]
+default = ["optimism"]
+base = [
+    "dep:base-common-consensus",
+    "dep:base-common-evm",
+    "dep:base-common-rpc-types",
+    "dep:op-alloy-consensus",
+    "dep:op-revm",
+]
+optimism = [
+    "dep:op-alloy-consensus",
+    "dep:op-alloy-rpc-types",
+    "dep:alloy-op-evm",
+    "dep:op-revm",
+]
diff --git a/crates/primitives/src/lib.rs b/crates/primitives/src/lib.rs
new file mode 100644
index 0000000000000..c96dbbcf5b783
--- /dev/null
+++ b/crates/primitives/src/lib.rs
@@ -0,0 +1,6 @@
+//! Foundry primitives
+mod network;
+mod transaction;
+
+pub use network::*;
+pub use transaction::*;
diff --git a/crates/primitives/src/network/header.rs b/crates/primitives/src/network/header.rs
new file mode 100644
index 0000000000000..237b0a4223a8d
--- /dev/null
+++ b/crates/primitives/src/network/header.rs
@@ -0,0 +1,223 @@
+use alloy_consensus::{BlockHeader, Header};
+use alloy_primitives::{Address, B64, B256, Bloom, Bytes, Sealable, U256};
+use alloy_rlp::{BufMut, Decodable, Encodable, Result};
+use std::ops::Deref;
+use tempo_primitives::TempoHeader;
+
+/// Consensus header used by Foundry's multi-network tooling.
+///
+/// The variant order is significant for untagged serde deserialization. [`Self::Tempo`] must stay
+/// first because Ethereum headers ignore unknown fields and would otherwise silently deserialize
+/// Tempo state dumps as [`Self::Ethereum`].
+#[derive(Clone, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)]
+#[serde(untagged)]
+#[allow(clippy::large_enum_variant)]
+pub enum FoundryHeader {
+    /// Tempo consensus header.
+    Tempo(TempoHeader),
+    /// Ethereum consensus header.
+    Ethereum(Header),
+}
+
+impl Default for FoundryHeader {
+    fn default() -> Self {
+        Self::Ethereum(Header::default())
+    }
+}
+
+impl FoundryHeader {
+    /// Creates a Tempo header from its Ethereum-shaped fields.
+    pub const fn tempo(inner: Header) -> Self {
+        Self::Tempo(TempoHeader {
+            general_gas_limit: inner.gas_limit,
+            shared_gas_limit: 0,
+            timestamp_millis_part: 0,
+            inner,
+            consensus_context: None,
+        })
+    }
+
+    /// Returns the Tempo header when this is a Tempo block.
+    pub const fn as_tempo(&self) -> Option<&TempoHeader> {
+        match self {
+            Self::Tempo(header) => Some(header),
+            Self::Ethereum(_) => None,
+        }
+    }
+
+    /// Returns the inner Ethereum-shaped header.
+    pub const fn inner(&self) -> &Header {
+        match self {
+            Self::Tempo(header) => &header.inner,
+            Self::Ethereum(header) => header,
+        }
+    }
+
+    const fn inner_mut(&mut self) -> &mut Header {
+        match self {
+            Self::Tempo(header) => &mut header.inner,
+            Self::Ethereum(header) => header,
+        }
+    }
+
+    /// Sets the transactions root shared by Ethereum and Tempo headers.
+    pub const fn set_transactions_root(&mut self, transactions_root: B256) {
+        self.inner_mut().transactions_root = transactions_root;
+    }
+
+    /// Sets the ommers root shared by Ethereum and Tempo headers.
+    pub const fn set_ommers_hash(&mut self, ommers_hash: B256) {
+        self.inner_mut().ommers_hash = ommers_hash;
+    }
+
+    /// Consumes the wrapper and returns the inner Ethereum-shaped header.
+    pub fn into_inner(self) -> Header {
+        match self {
+            Self::Tempo(header) => header.inner,
+            Self::Ethereum(header) => header,
+        }
+    }
+
+    /// Computes the canonical network header hash.
+    pub fn hash_slow(&self) -> B256 {
+        match self {
+            Self::Tempo(header) => header.hash_slow(),
+            Self::Ethereum(header) => header.hash_slow(),
+        }
+    }
+}
+
+impl From
for FoundryHeader { + fn from(value: Header) -> Self { + Self::Ethereum(value) + } +} + +impl From for FoundryHeader { + fn from(value: TempoHeader) -> Self { + Self::Tempo(value) + } +} + +impl AsRef for FoundryHeader { + fn as_ref(&self) -> &Self { + self + } +} + +impl Deref for FoundryHeader { + type Target = Header; + + fn deref(&self) -> &Self::Target { + self.inner() + } +} + +impl Encodable for FoundryHeader { + fn encode(&self, out: &mut dyn BufMut) { + match self { + Self::Tempo(header) => header.encode(out), + Self::Ethereum(header) => header.encode(out), + } + } + + fn length(&self) -> usize { + match self { + Self::Tempo(header) => header.length(), + Self::Ethereum(header) => header.length(), + } + } +} + +impl Decodable for FoundryHeader { + fn decode(buf: &mut &[u8]) -> Result { + // Tempo headers start with scalar gas-limit fields, while Ethereum headers start with a + // 32-byte parent hash, so trying Tempo first cannot misclassify a valid Ethereum header. + let mut tempo_buf = *buf; + if let Ok(header) = TempoHeader::decode(&mut tempo_buf) { + *buf = tempo_buf; + return Ok(Self::Tempo(header)); + } + + Header::decode(buf).map(Self::Ethereum) + } +} + +impl Sealable for FoundryHeader { + fn hash_slow(&self) -> B256 { + Self::hash_slow(self) + } +} + +macro_rules! delegate_header_methods { + ($($method:ident -> $return_type:ty),+ $(,)?) => { + $( + fn $method(&self) -> $return_type { + self.inner().$method() + } + )+ + }; +} + +impl BlockHeader for FoundryHeader { + delegate_header_methods! { + parent_hash -> B256, + ommers_hash -> B256, + beneficiary -> Address, + state_root -> B256, + transactions_root -> B256, + receipts_root -> B256, + withdrawals_root -> Option, + logs_bloom -> Bloom, + difficulty -> U256, + number -> u64, + gas_limit -> u64, + gas_used -> u64, + timestamp -> u64, + mix_hash -> Option, + nonce -> Option, + base_fee_per_gas -> Option, + blob_gas_used -> Option, + excess_blob_gas -> Option, + parent_beacon_block_root -> Option, + requests_hash -> Option, + block_access_list_hash -> Option, + slot_number -> Option, + } + + fn extra_data(&self) -> &Bytes { + self.inner().extra_data() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rlp_roundtrip_preserves_network_header() { + for header in [ + Header { number: 1, ..Default::default() }.into(), + FoundryHeader::tempo(Header { number: 2, gas_limit: 30_000_000, ..Default::default() }), + ] { + let encoded = alloy_rlp::encode(&header); + let decoded = FoundryHeader::decode(&mut encoded.as_ref()).unwrap(); + + assert_eq!(decoded, header); + assert_eq!(decoded.hash_slow(), header.hash_slow()); + if let Some(tempo) = header.as_tempo() { + assert_eq!(header.hash_slow(), tempo.hash_slow()); + } + } + } + + #[test] + fn serde_roundtrip_preserves_tempo_fields() { + let header = + FoundryHeader::tempo(Header { number: 1, gas_limit: 30_000_000, ..Default::default() }); + let value = serde_json::to_value(&header).unwrap(); + + assert_eq!(value["mainBlockGeneralGasLimit"], "0x1c9c380"); + assert_eq!(serde_json::from_value::(value).unwrap(), header); + } +} diff --git a/crates/primitives/src/network/mod.rs b/crates/primitives/src/network/mod.rs new file mode 100644 index 0000000000000..b58b6d710bb13 --- /dev/null +++ b/crates/primitives/src/network/mod.rs @@ -0,0 +1,68 @@ +use alloy_network::Network; + +mod header; +#[cfg(any(feature = "base", feature = "optimism"))] +mod optimism; +mod receipt; + +use alloy_provider::fillers::{ + BlobGasFiller, ChainIdFiller, GasFiller, JoinFill, NonceFiller, RecommendedFillers, +}; + +pub use header::*; +pub use receipt::*; + +#[cfg(feature = "optimism")] +pub use optimism::FoundryTransactionResponse; + +/// Default JSON-RPC transaction response when the `optimism` feature is disabled. +#[cfg(not(feature = "optimism"))] +pub type FoundryTransactionResponse = alloy_rpc_types_eth::Transaction; + +/// Foundry network type. +/// +/// This network type supports Foundry-specific transaction types, including +/// op-stack deposit transactions, alongside standard Ethereum transaction types. +/// +/// Note: This is a basic implementation ("for now") that provides the core Network +/// trait definitions. Full Foundry-specific RPC types will be implemented in future work. +/// Currently, this uses Ethereum's Network configuration as a compatibility layer. +#[derive(Debug, Clone, Copy)] +pub struct FoundryNetwork { + _private: (), +} + +// Use Ethereum's Network trait implementation as the basis. +// This provides compatibility with the alloy-network ecosystem while we build +// out Foundry-specific RPC types. +impl Network for FoundryNetwork { + type TxType = crate::FoundryTxType; + + type TxEnvelope = crate::FoundryTxEnvelope; + + type UnsignedTx = crate::FoundryTypedTx; + + type ReceiptEnvelope = crate::FoundryReceiptEnvelope; + + type Header = FoundryHeader; + + type TransactionRequest = crate::FoundryTransactionRequest; + + type TransactionResponse = FoundryTransactionResponse; + + type ReceiptResponse = crate::FoundryTxReceipt; + + type HeaderResponse = alloy_rpc_types_eth::Header; + + type BlockResponse = + alloy_rpc_types_eth::Block; +} + +impl RecommendedFillers for FoundryNetwork { + type RecommendedFillers = + JoinFill>>; + + fn recommended_fillers() -> Self::RecommendedFillers { + Default::default() + } +} diff --git a/crates/primitives/src/network/optimism.rs b/crates/primitives/src/network/optimism.rs new file mode 100644 index 0000000000000..c7e44c03ce35a --- /dev/null +++ b/crates/primitives/src/network/optimism.rs @@ -0,0 +1,47 @@ +//! OP-stack-specific helpers and type aliases used by [`super::FoundryNetwork`] and +//! [`super::FoundryTxReceipt`]. + +use crate::FoundryReceiptEnvelope; +use alloy_consensus::{Receipt, ReceiptWithBloom, TxReceipt}; +use alloy_primitives::U64; +use alloy_rpc_types::Log; +use alloy_serde::OtherFields; +use op_alloy_consensus::{OpDepositReceipt, OpDepositReceiptWithBloom}; + +/// JSON-RPC transaction response type used by [`super::FoundryNetwork`]. +#[cfg(feature = "optimism")] +pub type FoundryTransactionResponse = op_alloy_rpc_types::Transaction; + +/// Build a [`FoundryReceiptEnvelope::Deposit`] from a `ReceiptWithBloom` plus the OP +/// deposit-specific fields decoded from the [`OtherFields`] of an `AnyTransactionReceipt`. +pub(super) fn build_deposit_receipt_envelope( + receipt_with_bloom: ReceiptWithBloom>, + other: &OtherFields, +) -> FoundryReceiptEnvelope { + // These fields may not be present in all receipts, so missing/invalid values are None. + let deposit_nonce = other + .get_deserialized::("depositNonce") + .transpose() + .ok() + .flatten() + .map(|v| v.to::()); + let deposit_receipt_version = other + .get_deserialized::("depositReceiptVersion") + .transpose() + .ok() + .flatten() + .map(|v| v.to::()); + + FoundryReceiptEnvelope::Deposit(OpDepositReceiptWithBloom { + receipt: OpDepositReceipt { + inner: Receipt { + status: alloy_consensus::Eip658Value::Eip658(receipt_with_bloom.status()), + cumulative_gas_used: receipt_with_bloom.cumulative_gas_used(), + logs: receipt_with_bloom.receipt.logs, + }, + deposit_nonce, + deposit_receipt_version, + }, + logs_bloom: receipt_with_bloom.logs_bloom, + }) +} diff --git a/crates/primitives/src/network/receipt.rs b/crates/primitives/src/network/receipt.rs new file mode 100644 index 0000000000000..0ba1bb60753a1 --- /dev/null +++ b/crates/primitives/src/network/receipt.rs @@ -0,0 +1,278 @@ +use crate::FoundryReceiptEnvelope; +use alloy_network::{AnyReceiptEnvelope, AnyTransactionReceipt, ReceiptResponse}; +use alloy_primitives::{Address, B256, BlockHash, TxHash}; +use alloy_rpc_types::{ConversionError, Log, TransactionReceipt}; +use alloy_serde::WithOtherFields; +use derive_more::AsRef; +use serde::{Deserialize, Serialize}; +use tempo_primitives::TEMPO_TX_TYPE_ID; + +#[cfg(any(feature = "base", feature = "optimism"))] +use super::optimism::build_deposit_receipt_envelope; + +#[cfg(feature = "base")] +use alloy_consensus::ReceiptWithBloom; +#[cfg(feature = "base")] +use alloy_serde::OtherFields; +#[cfg(feature = "base")] +use base_common_consensus::Eip8130Receipt; +#[cfg(feature = "base")] +use base_common_evm::EIP8130_TRANSACTION_TYPE; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, AsRef)] +pub struct FoundryTxReceipt(pub WithOtherFields>>); + +impl<'de> Deserialize<'de> for FoundryTxReceipt { + fn deserialize>(deserializer: D) -> Result { + #[allow(unused_mut)] + let mut receipt = + WithOtherFields::>>::deserialize( + deserializer, + )?; + #[cfg(feature = "base")] + if let FoundryReceiptEnvelope::Eip8130(inner) = &mut receipt.inner.inner { + inner.receipt.phase_statuses = + rpc_phase_statuses(&receipt.other).map_err(serde::de::Error::custom)?; + } + Ok(Self(receipt)) + } +} + +#[cfg(feature = "base")] +fn rpc_phase_statuses(other: &OtherFields) -> serde_json::Result> { + #[derive(Deserialize)] + struct PhaseStatuses(#[serde(with = "alloy_serde::quantity::vec")] Vec); + Ok(other + .get_deserialized::>("phaseStatuses") + .transpose()? + .flatten() + .map(|statuses| statuses.0) + .unwrap_or_default()) +} + +impl FoundryTxReceipt { + pub fn new(inner: TransactionReceipt>) -> Self { + Self(WithOtherFields::new(inner)) + } + + /// Creates a new receipt with a timestamp in the other fields. + /// This avoids extra block lookups when timestamp is needed later. + pub fn with_timestamp( + inner: TransactionReceipt>, + timestamp: u64, + ) -> Self { + let mut receipt = WithOtherFields::new(inner); + receipt + .other + .insert("blockTimestamp".to_string(), serde_json::to_value(timestamp).unwrap()); + Self(receipt) + } + + /// Adds a `feePayer` field to the receipt. + pub fn with_fee_payer(mut self, fee_payer: Address) -> Self { + self.0.other.insert("feePayer".to_string(), serde_json::to_value(fee_payer).unwrap()); + self + } + + /// Adds a `feeToken` field to the receipt. + pub fn with_fee_token(mut self, fee_token: Address) -> Self { + self.0.other.insert("feeToken".to_string(), serde_json::to_value(fee_token).unwrap()); + self + } + + /// Get block timestamp from other fields if present. + pub fn block_timestamp(&self) -> Option { + self.0.other.get_deserialized::("blockTimestamp").transpose().ok().flatten() + } +} + +impl ReceiptResponse for FoundryTxReceipt { + fn contract_address(&self) -> Option
{ + self.0.contract_address + } + + fn status(&self) -> bool { + self.0.inner.status() + } + + fn block_hash(&self) -> Option { + self.0.block_hash + } + + fn block_number(&self) -> Option { + self.0.block_number + } + + fn transaction_hash(&self) -> TxHash { + self.0.transaction_hash + } + + fn transaction_index(&self) -> Option { + self.0.transaction_index() + } + + fn gas_used(&self) -> u64 { + self.0.gas_used() + } + + fn effective_gas_price(&self) -> u128 { + self.0.effective_gas_price() + } + + fn blob_gas_used(&self) -> Option { + self.0.blob_gas_used() + } + + fn blob_gas_price(&self) -> Option { + self.0.blob_gas_price() + } + + fn from(&self) -> Address { + self.0.from() + } + + fn to(&self) -> Option
{ + self.0.to() + } + + fn cumulative_gas_used(&self) -> u64 { + self.0.cumulative_gas_used() + } + + fn state_root(&self) -> Option { + self.0.state_root() + } +} + +impl TryFrom for FoundryTxReceipt { + type Error = ConversionError; + + fn try_from(receipt: AnyTransactionReceipt) -> Result { + let WithOtherFields { + inner: + TransactionReceipt { + transaction_hash, + transaction_index, + block_hash, + block_number, + gas_used, + contract_address, + effective_gas_price, + from, + to, + blob_gas_price, + blob_gas_used, + inner: AnyReceiptEnvelope { inner: receipt_with_bloom, r#type }, + }, + other, + } = receipt.0; + + #[cfg(feature = "base")] + let phase_statuses = if r#type == EIP8130_TRANSACTION_TYPE { + rpc_phase_statuses(&other).map_err(|err| ConversionError::Custom(err.to_string()))? + } else { + Vec::new() + }; + + Ok(Self(WithOtherFields { + inner: TransactionReceipt { + transaction_hash, + transaction_index, + block_hash, + block_number, + gas_used, + contract_address, + effective_gas_price, + from, + to, + blob_gas_price, + blob_gas_used, + inner: match r#type { + 0x00 => FoundryReceiptEnvelope::Legacy(receipt_with_bloom), + 0x01 => FoundryReceiptEnvelope::Eip2930(receipt_with_bloom), + 0x02 => FoundryReceiptEnvelope::Eip1559(receipt_with_bloom), + 0x03 => FoundryReceiptEnvelope::Eip4844(receipt_with_bloom), + 0x04 => FoundryReceiptEnvelope::Eip7702(receipt_with_bloom), + #[cfg(feature = "base")] + EIP8130_TRANSACTION_TYPE => FoundryReceiptEnvelope::Eip8130(ReceiptWithBloom { + receipt: Eip8130Receipt::new(receipt_with_bloom.receipt, phase_statuses), + logs_bloom: receipt_with_bloom.logs_bloom, + }), + TEMPO_TX_TYPE_ID => FoundryReceiptEnvelope::Tempo(receipt_with_bloom), + #[cfg(any(feature = "base", feature = "optimism"))] + 0x7E => build_deposit_receipt_envelope(receipt_with_bloom, &other), + // Chains anvil can fork but not execute, such as Arbitrum and its Orbit + // rollups, mint their own transaction types. Keep those receipts verbatim + // instead of failing the whole request. + ty => FoundryReceiptEnvelope::Unknown(AnyReceiptEnvelope { + inner: receipt_with_bloom, + r#type: ty, + }), + }, + }, + other, + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(feature = "base")] + #[test] + fn eip8130_rpc_receipt_preserves_phase_statuses() { + let receipt: AnyTransactionReceipt = serde_json::from_value(serde_json::json!({ + "type": "0x79", "status": "0x1", "cumulativeGasUsed": "0x1", "gasUsed": "0x1", + "logs": [], "logsBloom": alloy_primitives::Bloom::ZERO, + "transactionHash": B256::ZERO, "from": Address::ZERO + })) + .unwrap(); + for (statuses, expected) in [ + (None, vec![]), + (Some(serde_json::Value::Null), vec![]), + (Some(serde_json::json!([])), vec![]), + (Some(serde_json::json!(["0x1", "0x0"])), vec![1, 0]), + ] { + let mut original = receipt.clone(); + if let Some(statuses) = statuses { + original.0.other.insert("phaseStatuses".into(), statuses); + } + let converted = FoundryTxReceipt::try_from(original.clone()).unwrap(); + assert_eq!(converted.0.inner.inner.eip8130_phase_statuses(), expected); + let json = serde_json::to_value(&converted).unwrap(); + let typed: FoundryTxReceipt = serde_json::from_value(json.clone()).unwrap(); + assert_eq!(typed, converted); + let roundtrip: AnyTransactionReceipt = serde_json::from_value(json).unwrap(); + assert_eq!(roundtrip, original); + } + let mut invalid = receipt; + invalid.0.other.insert("phaseStatuses".into(), serde_json::json!(["invalid"])); + assert!( + serde_json::from_value::(serde_json::to_value(&invalid).unwrap()) + .is_err() + ); + assert!(FoundryTxReceipt::try_from(invalid).is_err()); + } + + // + #[test] + fn test_receipt_convert() { + let s = r#"{"type":"0x4","status":"0x1","cumulativeGasUsed":"0x903fd1","logs":[{"address":"0x0000d9fcd47bf761e7287d8ee09917d7e2100000","topics":["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef","0x0000000000000000000000000000000000000000000000000000000000000000","0x000000000000000000000000234ce51365b9c417171b6dad280f49143e1b0547"],"data":"0x00000000000000000000000000000000000000000000032139b42c3431700000","blockHash":"0xd26b59c1d8b5bfa9362d19eb0da3819dfe0b367987a71f6d30908dd45e0d7a60","blockNumber":"0x159663e","blockTimestamp":"0x68411f7b","transactionHash":"0x17a6af73d1317e69cfc3cac9221bd98261d40f24815850a44dbfbf96652ae52a","transactionIndex":"0x22","logIndex":"0x158","removed":false}],"logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000000000000000008100000000000000000000000000000000000000000000000020000200000000000000800000000800000000000000010000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000","transactionHash":"0x17a6af73d1317e69cfc3cac9221bd98261d40f24815850a44dbfbf96652ae52a","transactionIndex":"0x22","blockHash":"0xd26b59c1d8b5bfa9362d19eb0da3819dfe0b367987a71f6d30908dd45e0d7a60","blockNumber":"0x159663e","gasUsed":"0x28ee7","effectiveGasPrice":"0x4bf02090","from":"0x234ce51365b9c417171b6dad280f49143e1b0547","to":"0x234ce51365b9c417171b6dad280f49143e1b0547","contractAddress":null}"#; + let receipt: AnyTransactionReceipt = serde_json::from_str(s).unwrap(); + let _converted = FoundryTxReceipt::try_from(receipt).unwrap(); + } + + // Arbitrum and its Orbit rollups mint transaction types anvil cannot execute; forked receipts + // for them must still convert, keeping the original type byte. + #[test] + fn test_arbitrum_internal_receipt_convert() { + let s = r#"{"type":"0x6a","status":"0x1","cumulativeGasUsed":"0x0","logs":[],"logsBloom":"0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000","transactionHash":"0x7c9e0e2b0f2ffbd0a1ee3e2e2b6ff5a2ff8b6a0f1c0b4a5c1d5a8b6c9d0e1f22","transactionIndex":"0x0","blockHash":"0x3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b","blockNumber":"0x159663e","gasUsed":"0x0","effectiveGasPrice":"0x0","from":"0x00000000000000000000000000000000000a4b05","to":"0x00000000000000000000000000000000000a4b05","contractAddress":null,"gasUsedForL1":"0x0","l1BlockNumber":"0x1499e2c"}"#; + let receipt: AnyTransactionReceipt = serde_json::from_str(s).unwrap(); + let converted = FoundryTxReceipt::try_from(receipt).unwrap(); + + assert!(converted.0.inner.inner.is_unknown()); + assert_eq!(converted.0.inner.inner.ty(), 0x6a); + assert_eq!(serde_json::to_value(&converted).unwrap()["type"], "0x6a"); + } +} diff --git a/crates/primitives/src/transaction/base.rs b/crates/primitives/src/transaction/base.rs new file mode 100644 index 0000000000000..afbe323c63bf9 --- /dev/null +++ b/crates/primitives/src/transaction/base.rs @@ -0,0 +1,233 @@ +//! Base-specific transaction conversions. + +use super::FoundryTxEnvelope; +use alloy_consensus::Typed2718; +use alloy_evm::{FromRecoveredTx, FromTxWithEncoded}; +use alloy_network::eip2718::Encodable2718; +use alloy_primitives::{Address, Bytes}; +use alloy_rpc_types::TransactionRequest; +use base_common_consensus::{BaseTxEnvelope, TxEip8130}; +use base_common_evm::{BaseTransaction, DepositTransactionParts, EIP8130_TRANSACTION_TYPE}; +use base_common_rpc_types::{BaseTransactionRequest, Eip8130RequestFields}; +use revm::context::TxEnv; +use serde::Serialize; + +impl FromRecoveredTx for BaseTransaction { + fn from_recovered_tx(tx: &FoundryTxEnvelope, caller: Address) -> Self { + let encoded = tx.encoded_2718().into(); + Self::from_encoded_tx(tx, caller, encoded) + } +} + +impl FromTxWithEncoded for BaseTransaction { + fn from_encoded_tx(tx: &FoundryTxEnvelope, caller: Address, encoded: Bytes) -> Self { + match tx { + FoundryTxEnvelope::Legacy(signed) => Self { + base: TxEnv::from_recovered_tx(signed, caller), + enveloped_tx: Some(encoded), + deposit: Default::default(), + eip8130: None, + }, + FoundryTxEnvelope::Eip2930(signed) => Self { + base: TxEnv::from_recovered_tx(signed, caller), + enveloped_tx: Some(encoded), + deposit: Default::default(), + eip8130: None, + }, + FoundryTxEnvelope::Eip1559(signed) => Self { + base: TxEnv::from_recovered_tx(signed, caller), + enveloped_tx: Some(encoded), + deposit: Default::default(), + eip8130: None, + }, + FoundryTxEnvelope::Eip4844(signed) => Self { + base: TxEnv::from_recovered_tx(signed, caller), + enveloped_tx: Some(encoded), + deposit: Default::default(), + eip8130: None, + }, + FoundryTxEnvelope::Eip7702(signed) => Self { + base: TxEnv::from_recovered_tx(signed, caller), + enveloped_tx: Some(encoded), + deposit: Default::default(), + eip8130: None, + }, + #[cfg(any(feature = "base", feature = "optimism"))] + FoundryTxEnvelope::Deposit(sealed) => { + let deposit = sealed.inner(); + let base = TxEnv { + tx_type: deposit.ty(), + caller, + gas_limit: deposit.gas_limit, + kind: deposit.to, + value: deposit.value, + data: deposit.input.clone(), + ..Default::default() + }; + Self { + base, + enveloped_tx: None, + deposit: DepositTransactionParts { + source_hash: deposit.source_hash, + mint: Some(deposit.mint), + is_system_transaction: deposit.is_system_transaction, + }, + eip8130: None, + } + } + #[cfg(feature = "optimism")] + FoundryTxEnvelope::PostExec(_) => { + unreachable!("post-execution transaction in Base context") + } + FoundryTxEnvelope::Eip8130(signed) => { + let envelope = BaseTxEnvelope::Eip8130(signed.clone()); + Self::from_encoded_tx(&envelope, caller, encoded) + } + FoundryTxEnvelope::Tempo(_) => unreachable!("Tempo transaction in Base context"), + FoundryTxEnvelope::Celo(_) => unreachable!("CIP-64 transaction in base context"), + } + } +} + +/// Projects the complete AA body into a simulation request without inventing a single call. +pub(super) fn simulation_request( + tx: TxEip8130, + from: Option
, + sender_auth: Option, + payer_auth: Option, +) -> BaseTransactionRequest { + let inner = TransactionRequest { + transaction_type: Some(EIP8130_TRANSACTION_TYPE), + chain_id: Some(tx.chain_id), + from, + nonce: Some(tx.nonce_sequence), + gas: Some(tx.gas_limit), + max_fee_per_gas: Some(tx.max_fee_per_gas), + max_priority_fee_per_gas: Some(tx.max_priority_fee_per_gas), + ..Default::default() + }; + let fields = Eip8130RequestFields { + nonce_key: Some(tx.nonce_key), + account_changes: Some(tx.account_changes), + calls: Some(tx.calls), + valid_after: Some(tx.valid_after), + valid_before: Some(tx.valid_before), + metadata: Some(tx.metadata), + sender: tx.sender, + sender_auth, + payer: tx.payer, + payer_auth, + ..Default::default() + }; + // The upstream request exposes no constructor for its private AA fields. + #[derive(Serialize)] + struct SimulationRequest { + #[serde(flatten)] + inner: TransactionRequest, + #[serde(flatten)] + fields: Eip8130RequestFields, + } + let value = serde_json::to_value(SimulationRequest { inner, fields }) + .expect("serializable Base simulation request"); + serde_json::from_value(value).expect("compatible Base simulation fields") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{FoundryTransactionRequest, FoundryTypedTx}; + use alloy_network::NetworkTransactionBuilder; + use alloy_primitives::U256; + use base_common_consensus::{AccountChange, Call, Delegation, Eip8130Signed}; + + #[test] + fn eip8130_simulation_projection() { + let body = TxEip8130 { + chain_id: 8453, + sender: Some(Address::repeat_byte(1)), + payer: Some(Address::repeat_byte(2)), + nonce_key: U256::from(7), + nonce_sequence: 9, + gas_limit: 100_000, + max_fee_per_gas: 20, + max_priority_fee_per_gas: 3, + valid_after: 100, + valid_before: 200, + calls: vec![vec![Call { + to: Address::repeat_byte(3), + data: Bytes::from_static(b"call"), + }]], + account_changes: vec![AccountChange::Delegation(Delegation { + target: Address::repeat_byte(4), + })], + metadata: Bytes::from_static(b"metadata"), + }; + let signed = Eip8130Signed::new( + body.clone(), + Bytes::from_static(b"sender"), + Bytes::from_static(b"payer"), + ); + let envelope = FoundryTxEnvelope::Eip8130(signed.clone()); + for (request, from, sender_auth, payer_auth) in [ + ( + FoundryTransactionRequest::from(FoundryTypedTx::Eip8130(body.clone())), + None, + None, + None, + ), + ( + FoundryTransactionRequest::from(envelope), + body.sender, + Some(signed.sender_auth().clone()), + Some(signed.payer_auth().clone()), + ), + ] { + assert!(!request.can_build()); + let base = request.as_base().unwrap(); + assert_eq!( + base.as_eip8130(), + Some(&Eip8130RequestFields { + nonce_key: Some(body.nonce_key), + account_changes: Some(body.account_changes.clone()), + calls: Some(body.calls.clone()), + valid_after: Some(body.valid_after), + valid_before: Some(body.valid_before), + metadata: Some(body.metadata.clone()), + sender: body.sender, + payer: body.payer, + sender_auth, + payer_auth, + ..Default::default() + }) + ); + assert_eq!( + base.as_ref(), + &TransactionRequest { + transaction_type: Some(EIP8130_TRANSACTION_TYPE), + chain_id: Some(body.chain_id), + from, + nonce: Some(body.nonce_sequence), + gas: Some(body.gas_limit), + max_fee_per_gas: Some(body.max_fee_per_gas), + max_priority_fee_per_gas: Some(body.max_priority_fee_per_gas), + ..Default::default() + } + ); + let roundtrip: FoundryTransactionRequest = + serde_json::from_value(serde_json::to_value(&request).unwrap()).unwrap(); + assert_eq!(request, roundtrip); + } + } + #[test] + fn eip8130_conversion_preserves_supplied_encoding() { + let envelope = FoundryTxEnvelope::Eip8130(Eip8130Signed::new( + TxEip8130::default(), + Bytes::new(), + Bytes::new(), + )); + let encoded = Bytes::from_static(b"already encoded"); + let env = + BaseTransaction::::from_encoded_tx(&envelope, Address::ZERO, encoded.clone()); + assert_eq!(env.enveloped_tx, Some(encoded)); + } +} diff --git a/crates/primitives/src/transaction/celo.rs b/crates/primitives/src/transaction/celo.rs new file mode 100644 index 0000000000000..cfb553c92c823 --- /dev/null +++ b/crates/primitives/src/transaction/celo.rs @@ -0,0 +1,240 @@ +//! CIP-64 transaction encoding. Execution-time fee currency accounting is not implemented. + +use alloy_consensus::{ + SignableTransaction, Transaction, TxEip1559, Typed2718, + transaction::{RlpEcdsaDecodableTx, RlpEcdsaEncodableTx}, +}; +use alloy_eips::{eip2718::IsTyped2718, eip2930::AccessList, eip7702::SignedAuthorization}; +use alloy_primitives::{Address, B256, Bytes, ChainId, Signature, TxKind, U256}; +use alloy_rlp::{BufMut, Decodable, Encodable}; +use serde::{Deserialize, Serialize}; + +/// Celo dynamic fee transaction type introduced by CIP-64. +pub const CIP64_TX_TYPE: u8 = 0x7b; + +/// An EIP-1559-shaped transaction with a signed fee currency field. +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TxCip64 { + /// Ordinary EIP-1559 transaction fields. + #[serde(flatten)] + pub inner: TxEip1559, + /// Currency used to pay transaction fees, or native CELO when absent. + #[serde(default)] + pub fee_currency: Option
, +} + +impl RlpEcdsaEncodableTx for TxCip64 { + fn rlp_encoded_fields_length(&self) -> usize { + self.inner.rlp_encoded_fields_length() + + self.fee_currency.map_or(1, |address| address.length()) + } + + fn rlp_encode_fields(&self, out: &mut dyn BufMut) { + self.inner.rlp_encode_fields(out); + match self.fee_currency { + Some(address) => address.encode(out), + None => out.put_u8(alloy_rlp::EMPTY_STRING_CODE), + } + } +} + +impl RlpEcdsaDecodableTx for TxCip64 { + const DEFAULT_TX_TYPE: u8 = CIP64_TX_TYPE; + + fn rlp_decode_fields(buf: &mut &[u8]) -> alloy_rlp::Result { + let inner = TxEip1559::rlp_decode_fields(buf)?; + let fee_currency = match buf.first() { + Some(&alloy_rlp::EMPTY_STRING_CODE) => { + *buf = &buf[1..]; + None + } + _ => Some(Address::decode(buf)?), + }; + Ok(Self { inner, fee_currency }) + } +} + +impl Typed2718 for TxCip64 { + fn ty(&self) -> u8 { + CIP64_TX_TYPE + } +} + +impl IsTyped2718 for TxCip64 { + fn is_type(type_id: u8) -> bool { + type_id == CIP64_TX_TYPE + } +} + +impl SignableTransaction for TxCip64 { + fn set_chain_id(&mut self, chain_id: ChainId) { + self.inner.chain_id = chain_id; + } + + fn encode_for_signing(&self, out: &mut dyn BufMut) { + out.put_u8(CIP64_TX_TYPE); + self.rlp_encode(out); + } + + fn payload_len_for_signature(&self) -> usize { + self.rlp_encoded_length() + 1 + } +} + +impl Transaction for TxCip64 { + fn chain_id(&self) -> Option { + self.inner.chain_id() + } + + fn nonce(&self) -> u64 { + self.inner.nonce() + } + + fn gas_limit(&self) -> u64 { + self.inner.gas_limit() + } + + fn gas_price(&self) -> Option { + self.inner.gas_price() + } + + fn max_fee_per_gas(&self) -> u128 { + self.inner.max_fee_per_gas() + } + + fn max_priority_fee_per_gas(&self) -> Option { + self.inner.max_priority_fee_per_gas() + } + + fn max_fee_per_blob_gas(&self) -> Option { + self.inner.max_fee_per_blob_gas() + } + + fn priority_fee_or_price(&self) -> u128 { + self.inner.priority_fee_or_price() + } + + fn is_dynamic_fee(&self) -> bool { + self.inner.is_dynamic_fee() + } + + fn kind(&self) -> TxKind { + self.inner.kind() + } + + fn is_create(&self) -> bool { + self.inner.is_create() + } + + fn value(&self) -> U256 { + self.inner.value() + } + + fn input(&self) -> &Bytes { + self.inner.input() + } + + fn access_list(&self) -> Option<&AccessList> { + self.inner.access_list() + } + + fn blob_versioned_hashes(&self) -> Option<&[B256]> { + self.inner.blob_versioned_hashes() + } + + fn authorization_list(&self) -> Option<&[SignedAuthorization]> { + self.inner.authorization_list() + } + + fn effective_gas_price(&self, base_fee: Option) -> u128 { + self.inner.effective_gas_price(base_fee) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{FoundryTransactionRequest, FoundryTxEnvelope, FoundryTypedTx}; + use alloy_eips::eip2718::{Decodable2718, Encodable2718}; + use alloy_primitives::{address, hex}; + use serde_json::json; + + #[test] + fn cip64_encoding_and_signing_payload() { + let tx = TxCip64 { + inner: TxEip1559 { + chain_id: 1, + max_priority_fee_per_gas: 1, + max_fee_per_gas: 2, + gas_limit: 21_000, + to: address!("1111111111111111111111111111111111111111").into(), + ..Default::default() + }, + fee_currency: Some(address!("2222222222222222222222222222222222222222")), + }; + // CIP-64 appends feeCurrency after accessList, before the signature fields. + assert_eq!( + tx.encoded_for_signing(), + hex!( + "7bf4018001028252089411111111111111111111111111111111111111118080c0942222222222222222222222222222222222222222" + ) + ); + let signing_hash = tx.signature_hash(); + let signed = tx.clone().into_signed(Signature::new(U256::ONE, U256::from(2), false)); + let envelope = FoundryTxEnvelope::Celo(signed); + let encoded = envelope.encoded_2718(); + assert_eq!( + encoded, + hex!( + "7bf7018001028252089411111111111111111111111111111111111111118080c0942222222222222222222222222222222222222222800102" + ) + ); + assert_eq!(FoundryTxEnvelope::decode_2718(&mut encoded.as_slice()).unwrap(), envelope); + let mut native = tx; + native.fee_currency = None; + assert_ne!(native.signature_hash(), signing_hash); + let native = FoundryTxEnvelope::Celo(native.into_signed(Signature::new( + U256::ONE, + U256::from(2), + false, + ))); + assert_eq!( + FoundryTxEnvelope::decode_2718(&mut native.encoded_2718().as_slice()).unwrap(), + native + ); + } + + #[test] + fn cip64_request_classification_and_conflicts() { + let currency = "0x2222222222222222222222222222222222222222"; + let request = json!({"feeCurrency":currency,"chainId":"0x1","nonce":"0x0","gas":"0x5208","maxFeePerGas":"0x2","maxPriorityFeePerGas":"0x1","to":"0x1111111111111111111111111111111111111111"}); + let parsed: FoundryTransactionRequest = serde_json::from_value(request.clone()).unwrap(); + let typed = parsed.build_typed_tx().unwrap(); + assert!(matches!(typed, FoundryTypedTx::Celo(_))); + assert_eq!( + serde_json::to_value(FoundryTransactionRequest::from(typed)).unwrap()["feeCurrency"], + currency + ); + for (field, value) in [ + ("type", json!("0x2")), + ("gasPrice", json!("0x1")), + ("authorizationList", json!([])), + ("blobVersionedHashes", json!([])), + ("feeToken", json!(currency)), + ] { + let mut invalid = request.clone(); + invalid[field] = value; + assert!( + serde_json::from_value::(invalid).is_err(), + "{field}" + ); + } + let native: FoundryTransactionRequest = + serde_json::from_value(json!({"type":"0x7b"})).unwrap(); + assert!(matches!(native, FoundryTransactionRequest::Celo(_))); + let ordinary: FoundryTransactionRequest = + serde_json::from_value(json!({"feeCurrency":null})).unwrap(); + assert!(ordinary.is_ethereum()); + } +} diff --git a/crates/primitives/src/transaction/deposit.rs b/crates/primitives/src/transaction/deposit.rs new file mode 100644 index 0000000000000..be9b616009bcf --- /dev/null +++ b/crates/primitives/src/transaction/deposit.rs @@ -0,0 +1,68 @@ +//! Deposit helpers shared by Base and Optimism. + +use super::FoundryReceiptEnvelope; +use alloy_primitives::{B256, U256}; +use alloy_serde::OtherFields; +use op_alloy_consensus::OpDepositReceipt; +use op_revm::transaction::deposit::DepositTransactionParts; + +/// Converts `OtherFields` to `DepositTransactionParts`, produces error with missing fields. +pub fn get_deposit_tx_parts( + other: &OtherFields, +) -> Result> { + let mut missing = Vec::new(); + let source_hash = + other.get_deserialized::("sourceHash").transpose().ok().flatten().unwrap_or_else( + || { + missing.push("sourceHash"); + Default::default() + }, + ); + let mint = other + .get_deserialized::("mint") + .transpose() + .unwrap_or_else(|_| { + missing.push("mint"); + Default::default() + }) + .map(|value| value.saturating_to::()); + let is_system_transaction = + other.get_deserialized::("isSystemTx").transpose().ok().flatten().unwrap_or_else( + || { + missing.push("isSystemTx"); + Default::default() + }, + ); + if missing.is_empty() { + Ok(DepositTransactionParts { source_hash, mint, is_system_transaction }) + } else { + Err(missing) + } +} + +/// Deposit accessors shared by Base and Optimism. +impl FoundryReceiptEnvelope { + /// Return the receipt's deposit_nonce if it is a deposit receipt. + pub const fn deposit_nonce(&self) -> Option { + match self.as_deposit_receipt() { + Some(receipt) => receipt.deposit_nonce, + None => None, + } + } + + /// Return the receipt's deposit version if it is a deposit receipt. + pub const fn deposit_receipt_version(&self) -> Option { + match self.as_deposit_receipt() { + Some(receipt) => receipt.deposit_receipt_version, + None => None, + } + } + + /// Returns the deposit receipt if it is a deposit receipt. + pub const fn as_deposit_receipt(&self) -> Option<&OpDepositReceipt> { + match self { + Self::Deposit(t) => Some(&t.receipt), + _ => None, + } + } +} diff --git a/crates/primitives/src/transaction/envelope.rs b/crates/primitives/src/transaction/envelope.rs new file mode 100644 index 0000000000000..1f004d238e4ca --- /dev/null +++ b/crates/primitives/src/transaction/envelope.rs @@ -0,0 +1,1061 @@ +use super::{CIP64_TX_TYPE, TxCip64}; +use alloy_consensus::{ + SignableTransaction, Signed, TransactionEnvelope, TxEip1559, TxEip2930, TxEnvelope, TxLegacy, + TxType, Typed2718, + crypto::RecoveryError, + transaction::{ + SignerRecoverable, TxEip7702, TxHashRef, + eip4844::{TxEip4844Variant, TxEip4844WithSidecar}, + }, +}; +use alloy_evm::{FromRecoveredTx, FromTxWithEncoded}; +use alloy_network::{ + AnyRpcTransaction, AnyTxEnvelope, TransactionResponse, eip2718::Encodable2718, +}; +use alloy_primitives::{Address, B256, Bytes, Signature, TxHash}; +use alloy_rpc_types::ConversionError; +use revm::context::TxEnv; +use tempo_primitives::{AASigned, TEMPO_TX_TYPE_ID, TempoSignature, TempoTransaction}; +use tempo_revm::TempoTxEnv; + +#[cfg(all(feature = "base", not(feature = "optimism")))] +use op_alloy_consensus::{DEPOSIT_TX_TYPE_ID, TxDeposit}; + +#[cfg(any(feature = "base", feature = "optimism"))] +use alloy_consensus::Sealed; + +#[cfg(feature = "base")] +use base_common_consensus::{BaseTxEnvelope, Eip8130Signed, TxEip8130}; +#[cfg(feature = "base")] +use base_common_evm::EIP8130_TRANSACTION_TYPE; +#[cfg(feature = "base")] +use base_common_rpc_types::Transaction; + +#[cfg(feature = "optimism")] +use alloy_consensus::Transaction as _; +#[cfg(feature = "optimism")] +use op_alloy_consensus::{ + DEPOSIT_TX_TYPE_ID, POST_EXEC_TX_TYPE_ID, PostExecPayload, TxDeposit, TxPostExec, +}; + +// +/// Container type for signed, typed transactions. +// NOTE(onbjerg): Boxing `Tempo(AASigned)` breaks `TransactionEnvelope` derive macro trait bounds. +#[allow(clippy::large_enum_variant)] +#[derive(Clone, Debug, TransactionEnvelope)] +#[envelope( + tx_type_name = FoundryTxType, + typed = FoundryTypedTx, +)] +pub enum FoundryTxEnvelope { + /// Legacy transaction type + #[envelope(ty = 0)] + Legacy(Signed), + /// [EIP-2930] transaction. + /// + /// [EIP-2930]: https://eips.ethereum.org/EIPS/eip-2930 + #[envelope(ty = 1)] + Eip2930(Signed), + /// [EIP-1559] transaction. + /// + /// [EIP-1559]: https://eips.ethereum.org/EIPS/eip-1559 + #[envelope(ty = 2)] + Eip1559(Signed), + /// [EIP-4844] transaction. + /// + /// [EIP-4844]: https://eips.ethereum.org/EIPS/eip-4844 + #[envelope(ty = 3)] + Eip4844(Signed), + /// [EIP-7702] transaction. + /// + /// [EIP-7702]: https://eips.ethereum.org/EIPS/eip-7702 + #[envelope(ty = 4)] + Eip7702(Signed), + /// OP stack deposit transaction. + /// + /// See . + #[cfg(any(feature = "base", feature = "optimism"))] + #[envelope(ty = 126)] + Deposit(Sealed), + /// OP stack post-execution synthetic transaction. + #[cfg(feature = "optimism")] + #[envelope(ty = 0x7D)] + PostExec(Sealed), + /// Base EIP-8130 account-abstraction transaction. + #[cfg(feature = "base")] + #[envelope(ty = 0x79, typed = TxEip8130)] + Eip8130(Eip8130Signed), + /// Tempo transaction type. + /// + /// See . + #[envelope(ty = 0x76, typed = TempoTransaction)] + Tempo(AASigned), + /// Celo CIP-64 dynamic fee transaction. + #[envelope(ty = 0x7b)] + Celo(Signed), +} + +impl FoundryTxEnvelope { + /// Returns `true` if this is a legacy transaction. + #[inline] + pub const fn is_legacy(&self) -> bool { + matches!(self, Self::Legacy(_)) + } + + /// Returns `true` if this is an EIP-2930 transaction. + #[inline] + pub const fn is_eip2930(&self) -> bool { + matches!(self, Self::Eip2930(_)) + } + + /// Returns `true` if this is an EIP-1559 transaction. + #[inline] + pub const fn is_eip1559(&self) -> bool { + matches!(self, Self::Eip1559(_)) + } + + /// Returns `true` if this is an EIP-4844 transaction. + #[inline] + pub const fn is_eip4844(&self) -> bool { + matches!(self, Self::Eip4844(_)) + } + + /// Returns `true` if this is an EIP-7702 transaction. + #[inline] + pub const fn is_eip7702(&self) -> bool { + matches!(self, Self::Eip7702(_)) + } + + /// Returns `true` if this is an OP stack deposit transaction. + #[cfg(any(feature = "base", feature = "optimism"))] + #[inline] + pub const fn is_deposit(&self) -> bool { + matches!(self, Self::Deposit(_)) + } + + /// Returns `true` if this is an OP stack post-execution synthetic transaction. + #[cfg(feature = "optimism")] + #[inline] + pub const fn is_post_exec(&self) -> bool { + matches!(self, Self::PostExec(_)) + } + + /// Returns `true` if this is a Base EIP-8130 transaction. + #[cfg(feature = "base")] + #[inline] + pub const fn is_eip8130(&self) -> bool { + matches!(self, Self::Eip8130(_)) + } + + /// Converts the transaction into an Ethereum [`TxEnvelope`]. + /// + /// Returns an error if the transaction is not part of the standard Ethereum transaction types. + pub fn try_into_eth(self) -> Result { + match self { + Self::Legacy(tx) => Ok(TxEnvelope::Legacy(tx)), + Self::Eip2930(tx) => Ok(TxEnvelope::Eip2930(tx)), + Self::Eip1559(tx) => Ok(TxEnvelope::Eip1559(tx)), + Self::Eip4844(tx) => Ok(TxEnvelope::Eip4844(tx)), + Self::Eip7702(tx) => Ok(TxEnvelope::Eip7702(tx)), + #[cfg(any(feature = "base", feature = "optimism"))] + Self::Deposit(_) => Err(self), + #[cfg(feature = "optimism")] + Self::PostExec(_) => Err(self), + #[cfg(feature = "base")] + Self::Eip8130(_) => Err(self), + Self::Tempo(_) | Self::Celo(_) => Err(self), + } + } + + pub const fn sidecar(&self) -> Option<&TxEip4844WithSidecar> { + match self { + Self::Eip4844(signed_variant) => match signed_variant.tx() { + TxEip4844Variant::TxEip4844WithSidecar(with_sidecar) => Some(with_sidecar), + _ => None, + }, + _ => None, + } + } + + /// Drops pooled sidecars so the transaction uses its canonical block-body representation. + pub fn into_canonical(self) -> Self { + match self { + Self::Eip4844(tx) => Self::Eip4844(tx.map(TxEip4844Variant::drop_sidecar)), + tx => tx, + } + } + + /// Returns the hash of the transaction. + /// + /// # Note + /// + /// If this transaction has the Impersonated signature then this returns a modified unique + /// hash. This allows us to treat impersonated transactions as unique. + pub fn hash(&self) -> B256 { + *self.tx_hash() + } + + /// Returns `true` if this is a Tempo transaction. + pub const fn is_tempo(&self) -> bool { + matches!(self, Self::Tempo(_)) + } + + /// Returns `true` if this is a Tempo transaction with a nonzero nonce key. + pub fn has_nonzero_tempo_nonce_key(&self) -> bool { + matches!(self, Self::Tempo(tx) if !tx.tx().nonce_key.is_zero()) + } + + /// Recovers the Ethereum address which was used to sign the transaction. + pub fn recover(&self) -> Result { + Ok(match self { + Self::Legacy(tx) => tx.recover_signer()?, + Self::Eip2930(tx) => tx.recover_signer()?, + Self::Eip1559(tx) => tx.recover_signer()?, + Self::Celo(tx) => tx.recover_signer()?, + Self::Eip4844(tx) => tx.recover_signer()?, + Self::Eip7702(tx) => tx.recover_signer()?, + #[cfg(any(feature = "base", feature = "optimism"))] + Self::Deposit(tx) => tx.from, + #[cfg(feature = "optimism")] + Self::PostExec(tx) => tx.inner().signer_address(), + #[cfg(feature = "base")] + Self::Eip8130(tx) => tx.recover_sender()?, + Self::Tempo(tx) => tx.signature().recover_signer(&tx.signature_hash())?, + }) + } + + /// EIP-2718 encodes a transaction held in its JSON-RPC form. + /// + /// [`AnyTxEnvelope`] panics rather than encode a transaction type alloy does not model, so + /// anything that is not plain Ethereum is routed through [`Self`], which knows the types + /// Foundry supports. Chains that can be forked but not executed, such as Arbitrum and its + /// Orbit rollups, mint types with no Foundry envelope; only their RPC representation is ever + /// available, which is not enough to reconstruct their consensus encoding. + pub fn encode_rpc_2718(transaction: &AnyRpcTransaction) -> Result { + if let AnyTxEnvelope::Ethereum(envelope) = &*transaction.inner.inner { + return Ok(envelope.encoded_2718().into()); + } + + Ok(Self::try_from(transaction.clone())?.encoded_2718().into()) + } +} + +impl FoundryTxType { + /// Returns `true` if this is a legacy transaction type. + pub const fn is_legacy(&self) -> bool { + matches!(self, Self::Legacy) + } + + /// Returns `true` if this is an EIP-2930 transaction type. + pub const fn is_eip2930(&self) -> bool { + matches!(self, Self::Eip2930) + } + + /// Returns `true` if this is an EIP-1559 transaction type. + pub const fn is_eip1559(&self) -> bool { + matches!(self, Self::Eip1559) + } + + /// Returns `true` if this is an EIP-4844 transaction type. + pub const fn is_eip4844(&self) -> bool { + matches!(self, Self::Eip4844) + } + + /// Returns `true` if this is an EIP-7702 transaction type. + pub const fn is_eip7702(&self) -> bool { + matches!(self, Self::Eip7702) + } + + /// Returns `true` if this is an OP stack deposit transaction type. + #[cfg(any(feature = "base", feature = "optimism"))] + pub const fn is_deposit(&self) -> bool { + matches!(self, Self::Deposit) + } + + /// Returns `true` if this is an OP stack post-execution synthetic transaction type. + #[cfg(feature = "optimism")] + pub const fn is_post_exec(&self) -> bool { + matches!(self, Self::PostExec) + } + + /// Returns `true` if this is a Base EIP-8130 transaction type. + #[cfg(feature = "base")] + pub const fn is_eip8130(&self) -> bool { + matches!(self, Self::Eip8130) + } + + /// Returns `true` if this is a Tempo transaction type. + pub const fn is_tempo(&self) -> bool { + matches!(self, Self::Tempo) + } + + /// Returns `true` if this is a Celo CIP-64 transaction type. + pub const fn is_celo(&self) -> bool { + matches!(self, Self::Celo) + } +} + +impl FoundryTypedTx { + /// Builds an envelope with a dummy signature for an impersonated account. + /// + /// The signature uses `r = 1` and `s = 1` because clients reject zero scalar values. + pub fn into_impersonated(self) -> FoundryTxEnvelope { + let signature = Signature::from_scalars_and_parity( + B256::with_last_byte(1), + B256::with_last_byte(1), + false, + ); + match self { + Self::Legacy(tx) => FoundryTxEnvelope::Legacy(tx.into_signed(signature)), + Self::Eip2930(tx) => FoundryTxEnvelope::Eip2930(tx.into_signed(signature)), + Self::Eip1559(tx) => FoundryTxEnvelope::Eip1559(tx.into_signed(signature)), + Self::Celo(tx) => FoundryTxEnvelope::Celo(tx.into_signed(signature)), + Self::Eip7702(tx) => FoundryTxEnvelope::Eip7702(tx.into_signed(signature)), + Self::Eip4844(tx) => FoundryTxEnvelope::Eip4844(tx.into_signed(signature)), + #[cfg(any(feature = "base", feature = "optimism"))] + Self::Deposit(tx) => FoundryTxEnvelope::Deposit(Sealed::new(tx)), + #[cfg(feature = "optimism")] + Self::PostExec(_) => { + unreachable!("op post-exec txs should not be impersonated") + } + #[cfg(feature = "base")] + Self::Eip8130(_) => { + unreachable!("EIP-8130 requires a signed raw transaction envelope") + } + Self::Tempo(tx) => { + let tempo_sig: TempoSignature = signature.into(); + FoundryTxEnvelope::Tempo(tx.into_signed(tempo_sig)) + } + } + } + + /// Returns `true` if this is an OP stack deposit transaction. + #[cfg(any(feature = "base", feature = "optimism"))] + pub const fn is_deposit(&self) -> bool { + matches!(self, Self::Deposit(_)) + } + + /// Returns `true` if this is an OP stack post-execution synthetic transaction. + #[cfg(feature = "optimism")] + pub const fn is_post_exec(&self) -> bool { + matches!(self, Self::PostExec(_)) + } + + /// Returns `true` if this is a Base EIP-8130 transaction. + #[cfg(feature = "base")] + pub const fn is_eip8130(&self) -> bool { + matches!(self, Self::Eip8130(_)) + } + + /// Returns `true` if this is a Tempo transaction. + pub const fn is_tempo(&self) -> bool { + matches!(self, Self::Tempo(_)) + } +} + +impl TxHashRef for FoundryTxEnvelope { + fn tx_hash(&self) -> &TxHash { + match self { + Self::Legacy(t) => t.hash(), + Self::Eip2930(t) => t.hash(), + Self::Eip1559(t) => t.hash(), + Self::Celo(t) => t.hash(), + Self::Eip4844(t) => t.hash(), + Self::Eip7702(t) => t.hash(), + #[cfg(any(feature = "base", feature = "optimism"))] + Self::Deposit(t) => t.hash_ref(), + #[cfg(feature = "optimism")] + Self::PostExec(t) => t.hash_ref(), + #[cfg(feature = "base")] + Self::Eip8130(t) => t.hash(), + Self::Tempo(t) => t.hash(), + } + } +} + +impl SignerRecoverable for FoundryTxEnvelope { + fn recover_signer(&self) -> Result { + self.recover() + } + + fn recover_signer_unchecked(&self) -> Result { + self.recover() + } +} + +impl TryFrom for TxEnvelope { + type Error = FoundryTxEnvelope; + + fn try_from(envelope: FoundryTxEnvelope) -> Result { + envelope.try_into_eth() + } +} + +impl From for FoundryTxEnvelope { + fn from(tx: TxEnvelope) -> Self { + match tx { + TxEnvelope::Legacy(tx) => Self::Legacy(tx), + TxEnvelope::Eip2930(tx) => Self::Eip2930(tx), + TxEnvelope::Eip1559(tx) => Self::Eip1559(tx), + TxEnvelope::Eip4844(tx) => Self::Eip4844(tx), + TxEnvelope::Eip7702(tx) => Self::Eip7702(tx), + } + } +} + +impl From for FoundryTxEnvelope { + fn from(tx: tempo_primitives::TempoTxEnvelope) -> Self { + match tx { + tempo_primitives::TempoTxEnvelope::Legacy(tx) => Self::Legacy(tx), + tempo_primitives::TempoTxEnvelope::Eip2930(tx) => Self::Eip2930(tx), + tempo_primitives::TempoTxEnvelope::Eip1559(tx) => Self::Eip1559(tx), + tempo_primitives::TempoTxEnvelope::Eip7702(tx) => Self::Eip7702(tx), + tempo_primitives::TempoTxEnvelope::AA(tx) => Self::Tempo(tx), + } + } +} + +impl TryFrom for FoundryTxEnvelope { + type Error = ConversionError; + + fn try_from(value: AnyRpcTransaction) -> Result { + #[cfg(feature = "base")] + if value.ty() == EIP8130_TRANSACTION_TYPE { + let rpc = serde_json::from_value::( + serde_json::to_value(&value) + .map_err(|err| ConversionError::Custom(err.to_string()))?, + ) + .map_err(|err| ConversionError::Custom(err.to_string()))?; + return match rpc.inner.into_inner() { + BaseTxEnvelope::Eip8130(tx) => Ok(Self::Eip8130(tx)), + _ => Err(ConversionError::Custom("expected Base EIP-8130 transaction".to_string())), + }; + } + let transaction = value.into_inner(); + let from = transaction.from(); + match transaction.into_inner() { + AnyTxEnvelope::Ethereum(tx) => match tx { + TxEnvelope::Legacy(tx) => Ok(Self::Legacy(tx)), + TxEnvelope::Eip2930(tx) => Ok(Self::Eip2930(tx)), + TxEnvelope::Eip1559(tx) => Ok(Self::Eip1559(tx)), + TxEnvelope::Eip4844(tx) => Ok(Self::Eip4844(tx)), + TxEnvelope::Eip7702(tx) => Ok(Self::Eip7702(tx)), + }, + AnyTxEnvelope::Unknown(tx) => { + if tx.ty() == CIP64_TX_TYPE { + let mut fields = tx.inner.fields; + fields.insert("hash".into(), serde_json::to_value(tx.hash).unwrap()); + return fields + .deserialize_into::>() + .map(Self::Celo) + .map_err(|err| ConversionError::Custom(err.to_string())); + } + if tx.ty() == TEMPO_TX_TYPE_ID { + let tempo_tx = tx.inner.fields.deserialize_into::().map_err(|e| { + ConversionError::Custom(format!("Failed to deserialize tempo tx: {e}")) + })?; + return Ok(Self::Tempo(tempo_tx)); + } + + #[cfg(all(feature = "base", not(feature = "optimism")))] + { + let mut tx = tx; + if tx.ty() == DEPOSIT_TX_TYPE_ID { + tx.inner + .fields + .insert("from".to_string(), serde_json::to_value(from).unwrap()); + let deposit = + tx.inner.fields.deserialize_into::().map_err(|err| { + ConversionError::Custom(format!( + "Failed to deserialize deposit transaction: {err}" + )) + })?; + return Ok(Self::Deposit(Sealed::new(deposit))); + } + let tx_type = tx.ty(); + Err(ConversionError::Custom(format!( + "Unknown transaction type: 0x{tx_type:02X}" + ))) + } + #[cfg(feature = "optimism")] + { + let mut tx = tx; + let _ = from; + // Try to convert to deposit transaction + if tx.ty() == DEPOSIT_TX_TYPE_ID { + tx.inner + .fields + .insert("from".to_string(), serde_json::to_value(from).unwrap()); + let deposit_tx = + tx.inner.fields.deserialize_into::().map_err(|e| { + ConversionError::Custom(format!( + "Failed to deserialize deposit tx: {e}" + )) + })?; + + return Ok(Self::Deposit(Sealed::new(deposit_tx))); + } + + if tx.ty() == POST_EXEC_TX_TYPE_ID { + // The RPC form carries the RLP-encoded `PostExecPayload` in `input`; + // `TxPostExec`'s own serde form expects the payload fields instead. + let input = tx + .inner + .fields + .get_deserialized::("input") + .ok_or_else(|| { + ConversionError::Custom( + "Post-exec tx is missing `input`".to_string(), + ) + })? + .map_err(|e| { + ConversionError::Custom(format!( + "Failed to deserialize post-exec tx `input`: {e}" + )) + })?; + let payload = + PostExecPayload::from_rlp_bytes(input.as_ref()).map_err(|e| { + ConversionError::Custom(format!( + "Failed to decode post-exec tx payload: {e}" + )) + })?; + + return Ok(Self::PostExec(Sealed::new(TxPostExec::new(payload)))); + } + + let tx_type = tx.ty(); + Err(ConversionError::Custom(format!( + "Unknown transaction type: 0x{tx_type:02X}" + ))) + } + #[cfg(not(any(feature = "base", feature = "optimism")))] + { + let _ = from; + let tx_type = tx.ty(); + Err(ConversionError::Custom(format!( + "Unknown transaction type: 0x{tx_type:02X}" + ))) + } + } + } + } +} + +impl FromRecoveredTx for TxEnv { + fn from_recovered_tx(tx: &FoundryTxEnvelope, caller: Address) -> Self { + match tx { + FoundryTxEnvelope::Legacy(signed_tx) => Self::from_recovered_tx(signed_tx, caller), + FoundryTxEnvelope::Eip2930(signed_tx) => Self::from_recovered_tx(signed_tx, caller), + FoundryTxEnvelope::Eip1559(signed_tx) => Self::from_recovered_tx(signed_tx, caller), + // Anvil preserves the CIP-64 envelope but executes with native EIP-1559 fees. + FoundryTxEnvelope::Celo(signed_tx) => { + Self::from_recovered_tx(&signed_tx.tx().inner, caller) + } + FoundryTxEnvelope::Eip4844(signed_tx) => Self::from_recovered_tx(signed_tx, caller), + FoundryTxEnvelope::Eip7702(signed_tx) => Self::from_recovered_tx(signed_tx, caller), + #[cfg(any(feature = "base", feature = "optimism"))] + FoundryTxEnvelope::Deposit(sealed_tx) => { + let tx = sealed_tx.inner(); + Self { + tx_type: tx.ty(), + caller, + gas_limit: tx.gas_limit, + kind: tx.to, + value: tx.value, + data: tx.input.clone(), + ..Default::default() + } + } + #[cfg(feature = "optimism")] + FoundryTxEnvelope::PostExec(sealed_tx) => { + let tx = sealed_tx.inner(); + Self { + tx_type: tx.ty(), + caller, + kind: tx.kind(), + data: tx.input.clone(), + ..Default::default() + } + } + #[cfg(feature = "base")] + FoundryTxEnvelope::Eip8130(_) => { + unreachable!("EIP-8130 transaction in Ethereum context") + } + FoundryTxEnvelope::Tempo(_) => unreachable!("Tempo tx in Ethereum context"), + } + } +} + +impl FromTxWithEncoded for TxEnv { + fn from_encoded_tx(tx: &FoundryTxEnvelope, sender: Address, _encoded: Bytes) -> Self { + Self::from_recovered_tx(tx, sender) + } +} + +impl FromRecoveredTx for TempoTxEnv { + fn from_recovered_tx(tx: &FoundryTxEnvelope, caller: Address) -> Self { + match tx { + FoundryTxEnvelope::Legacy(signed_tx) => { + Self::from(TxEnv::from_recovered_tx(signed_tx, caller)) + } + FoundryTxEnvelope::Eip2930(signed_tx) => { + Self::from(TxEnv::from_recovered_tx(signed_tx, caller)) + } + FoundryTxEnvelope::Eip1559(signed_tx) => { + Self::from(TxEnv::from_recovered_tx(signed_tx, caller)) + } + FoundryTxEnvelope::Eip4844(signed_tx) => { + Self::from(TxEnv::from_recovered_tx(signed_tx, caller)) + } + FoundryTxEnvelope::Eip7702(signed_tx) => { + Self::from(TxEnv::from_recovered_tx(signed_tx, caller)) + } + #[cfg(any(feature = "base", feature = "optimism"))] + FoundryTxEnvelope::Deposit(_) => unreachable!("Deposit tx in Tempo context"), + #[cfg(feature = "optimism")] + FoundryTxEnvelope::PostExec(_) => unreachable!("Post-exec tx in Tempo context"), + #[cfg(feature = "base")] + FoundryTxEnvelope::Eip8130(_) => { + unreachable!("EIP-8130 transaction in Tempo context") + } + FoundryTxEnvelope::Tempo(aa_signed) => Self::from_recovered_tx(aa_signed, caller), + FoundryTxEnvelope::Celo(_) => unreachable!("CIP-64 transaction in Tempo context"), + } + } +} + +impl FromTxWithEncoded for TempoTxEnv { + fn from_encoded_tx(tx: &FoundryTxEnvelope, sender: Address, _encoded: Bytes) -> Self { + Self::from_recovered_tx(tx, sender) + } +} + +impl std::fmt::Display for FoundryTxType { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Legacy => write!(f, "legacy"), + Self::Eip2930 => write!(f, "eip2930"), + Self::Eip1559 => write!(f, "eip1559"), + Self::Eip4844 => write!(f, "eip4844"), + Self::Eip7702 => write!(f, "eip7702"), + #[cfg(any(feature = "base", feature = "optimism"))] + Self::Deposit => write!(f, "deposit"), + #[cfg(feature = "optimism")] + Self::PostExec => write!(f, "post-exec"), + #[cfg(feature = "base")] + Self::Eip8130 => write!(f, "eip8130"), + Self::Tempo => write!(f, "tempo"), + Self::Celo => write!(f, "celo"), + } + } +} + +impl From for FoundryTxType { + fn from(tx: TxType) -> Self { + match tx { + TxType::Legacy => Self::Legacy, + TxType::Eip2930 => Self::Eip2930, + TxType::Eip1559 => Self::Eip1559, + TxType::Eip4844 => Self::Eip4844, + TxType::Eip7702 => Self::Eip7702, + } + } +} + +impl From for FoundryTypedTx { + fn from(envelope: FoundryTxEnvelope) -> Self { + match envelope { + FoundryTxEnvelope::Legacy(signed_tx) => Self::Legacy(signed_tx.strip_signature()), + FoundryTxEnvelope::Eip2930(signed_tx) => Self::Eip2930(signed_tx.strip_signature()), + FoundryTxEnvelope::Eip1559(signed_tx) => Self::Eip1559(signed_tx.strip_signature()), + FoundryTxEnvelope::Eip4844(signed_tx) => Self::Eip4844(signed_tx.strip_signature()), + FoundryTxEnvelope::Eip7702(signed_tx) => Self::Eip7702(signed_tx.strip_signature()), + #[cfg(any(feature = "base", feature = "optimism"))] + FoundryTxEnvelope::Deposit(sealed_tx) => Self::Deposit(sealed_tx.unseal()), + #[cfg(feature = "optimism")] + FoundryTxEnvelope::PostExec(sealed_tx) => Self::PostExec(sealed_tx.unseal()), + #[cfg(feature = "base")] + FoundryTxEnvelope::Eip8130(signed_tx) => Self::Eip8130(signed_tx.into_tx()), + FoundryTxEnvelope::Tempo(signed_tx) => Self::Tempo(signed_tx.strip_signature()), + FoundryTxEnvelope::Celo(signed_tx) => Self::Celo(signed_tx.strip_signature()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::{TxKind, U256, address, b256, hex}; + use alloy_rlp::Decodable; + use std::str::FromStr; + + fn signed(tx: T) -> Signed { + Signed::new_unchecked(tx, Signature::test_signature(), B256::ZERO) + } + + /// A plain Ethereum transaction in its JSON-RPC form. + const ETH_RPC_TX: &str = r#"{"type":"0x0","chainId":"0x1","nonce":"0x15","gasPrice":"0x4a817c800","gas":"0xc350","to":"0xf02c1c8e6114b1dbe8937a39260b5b0a374432bb","value":"0xf3dbb76162000","input":"0x68656c6c6f21","r":"0x1b5e176d927f8e9ab405058b2d2457392da3e20f328b16ddabcebc33eaac5fea","s":"0x4ba69724e8f69de52f0125ad8b3c5c2cef33019bac3249e2c0a2192766d1721c","v":"0x25","hash":"0x88df016429689c079f3b2f6ad39fa052532c56795b733da78a91ebe6a713944b","blockHash":"0x1d59ff54b1eb26b013ce3cb5fc9dab3705b415a67127a003c3e61eb445bb8df2","blockNumber":"0x5daf3b","transactionIndex":"0x41","from":"0xa7d9ddbe1f17865597fbd27ec712455208b6b76d"}"#; + + /// An OP-stack post-exec transaction (SDM, type `0x7D`) as returned by + /// `eth_getTransactionByHash`. The RLP-encoded `PostExecPayload` is carried in `input`; the + /// remaining fields are derived placeholders. + #[cfg(feature = "optimism")] + const OP_POST_EXEC_RPC_TX: &str = r#"{"blockHash":"0x72edd91c1b181b566e08846b9fe67e3d746c4e6555e6fb81f0d1acd9465f7322","blockNumber":"0x44ee2d","blockTimestamp":"0x6aadaad9","from":"0x0000000000000000000000000000000000000000","gas":"0x0","gasPrice":"0xfb","hash":"0x748fc6eb383fc0f2a92089556f639d4bdb1d363cb50e1be8acae2df338ba6963","input":"0xf83d018344ee2df7c4028207d0c4038207d0c4048207d0c4058207d0c4068207d0c4078207d0c4088207d0c4098207d0c40a8207d0c40b8207d0c40c8207d0","transactionIndex":"0xd","type":"0x7d","value":"0x0"}"#; + + /// An `ArbitrumInternalTx`, a type alloy models only as [`AnyTxEnvelope::Unknown`]. + const ARBITRUM_INTERNAL_RPC_TX: &str = r#"{"type":"0x6a","chainId":"0xa4b1","nonce":"0x0","gasPrice":"0x0","gas":"0x0","to":"0x00000000000000000000000000000000000a4b05","value":"0x0","input":"0x6bf6a42d","r":"0x0","s":"0x0","v":"0x0","hash":"0xe5ad4cc44e5cd67a464c038af87169fde2bd475f2c00306bd2d55ca2c5e4452e","blockHash":"0x0ce1511da42af573bac6870ef058d63bc4c8552440e97c149d4d539c482b5f7a","blockNumber":"0x1dc83ddc","transactionIndex":"0x0","from":"0x00000000000000000000000000000000000a4b05"}"#; + + #[test] + fn encode_rpc_2718_matches_consensus_encoding() { + let tx: AnyRpcTransaction = serde_json::from_str(ETH_RPC_TX).unwrap(); + let expected = hex!( + "f871158504a817c80082c35094f02c1c8e6114b1dbe8937a39260b5b0a374432bb870f3dbb761620008668656c6c6f2125a01b5e176d927f8e9ab405058b2d2457392da3e20f328b16ddabcebc33eaac5feaa04ba69724e8f69de52f0125ad8b3c5c2cef33019bac3249e2c0a2192766d1721c" + ); + + assert_eq!(FoundryTxEnvelope::encode_rpc_2718(&tx).unwrap(), expected[..]); + } + + #[test] + fn encode_rpc_2718_rejects_unmodeled_type() { + let tx: AnyRpcTransaction = serde_json::from_str(ARBITRUM_INTERNAL_RPC_TX).unwrap(); + + // `AnyTxEnvelope::encode_2718` panics on this type, so it must not be reached. + assert!(FoundryTxEnvelope::encode_rpc_2718(&tx).is_err()); + } + + #[cfg(feature = "optimism")] + #[test] + fn encode_rpc_2718_post_exec_tx() { + let tx: AnyRpcTransaction = serde_json::from_str(OP_POST_EXEC_RPC_TX).unwrap(); + + let encoded = FoundryTxEnvelope::encode_rpc_2718(&tx).unwrap(); + + // The 2718 envelope is the `0x7d` type byte followed by the payload carried in `input`. + let expected = hex!( + "7df83d018344ee2df7c4028207d0c4038207d0c4048207d0c4058207d0c4068207d0c4078207d0c4088207d0c4098207d0c40a8207d0c40b8207d0c40c8207d0" + ); + assert_eq!(encoded, expected[..]); + } + + /// The RPC form carries the payload as RLP in `input`, not as a `PostExecPayload` object, and + /// the recomputed hash matches the one the node reported. + #[cfg(feature = "optimism")] + #[test] + fn post_exec_rpc_tx_decodes() { + let tx: AnyRpcTransaction = serde_json::from_str(OP_POST_EXEC_RPC_TX).unwrap(); + + let envelope = FoundryTxEnvelope::try_from(tx).unwrap(); + let FoundryTxEnvelope::PostExec(sealed) = &envelope else { + panic!("expected a post-exec envelope, got {envelope:?}"); + }; + + assert_eq!( + sealed.hash(), + b256!("0x748fc6eb383fc0f2a92089556f639d4bdb1d363cb50e1be8acae2df338ba6963") + ); + // 11 rebated transactions, at indexes 2..=12, each refunded 2000 gas. + let payload = &sealed.inner().payload; + assert_eq!(payload.block_number, 0x44ee2d); + assert_eq!(payload.gas_refund_entries.len(), 11); + } + + #[test] + fn tx_type_predicates() { + assert!(FoundryTxType::Legacy.is_legacy()); + assert!(FoundryTxType::Eip2930.is_eip2930()); + assert!(FoundryTxType::Eip1559.is_eip1559()); + assert!(FoundryTxType::Eip4844.is_eip4844()); + assert!(FoundryTxType::Eip7702.is_eip7702()); + assert!(FoundryTxType::Tempo.is_tempo()); + assert!(!FoundryTxType::Tempo.is_legacy()); + + #[cfg(any(feature = "base", feature = "optimism"))] + assert!(FoundryTxType::Deposit.is_deposit()); + #[cfg(feature = "base")] + assert!(FoundryTxType::Eip8130.is_eip8130()); + #[cfg(feature = "optimism")] + { + assert!(FoundryTxType::PostExec.is_post_exec()); + assert!(!FoundryTxType::Deposit.is_post_exec()); + } + } + + #[test] + fn typed_tx_predicates() { + assert!(FoundryTypedTx::Legacy(TxLegacy::default()).is_legacy()); + assert!(FoundryTypedTx::Eip2930(TxEip2930::default()).is_eip2930()); + assert!(FoundryTypedTx::Eip1559(TxEip1559::default()).is_eip1559()); + assert!( + FoundryTypedTx::Eip4844(TxEip4844Variant::TxEip4844(Default::default())).is_eip4844() + ); + assert!(FoundryTypedTx::Eip7702(TxEip7702::default()).is_eip7702()); + assert!(FoundryTypedTx::Tempo(TempoTransaction::default()).is_tempo()); + + #[cfg(any(feature = "base", feature = "optimism"))] + assert!(FoundryTypedTx::Deposit(TxDeposit::default()).is_deposit()); + #[cfg(feature = "base")] + assert!(FoundryTypedTx::Eip8130(TxEip8130::default()).is_eip8130()); + #[cfg(feature = "optimism")] + { + assert!(FoundryTypedTx::PostExec(TxPostExec::default()).is_post_exec()); + } + } + + #[test] + fn tx_envelope_predicates() { + assert!(FoundryTxEnvelope::Legacy(signed(TxLegacy::default())).is_legacy()); + assert!(FoundryTxEnvelope::Eip2930(signed(TxEip2930::default())).is_eip2930()); + assert!(FoundryTxEnvelope::Eip1559(signed(TxEip1559::default())).is_eip1559()); + assert!( + FoundryTxEnvelope::Eip4844(signed(TxEip4844Variant::TxEip4844(Default::default()))) + .is_eip4844() + ); + assert!(FoundryTxEnvelope::Eip7702(signed(TxEip7702::default())).is_eip7702()); + + #[cfg(any(feature = "base", feature = "optimism"))] + assert!(FoundryTxEnvelope::Deposit(Sealed::new(TxDeposit::default())).is_deposit()); + #[cfg(feature = "base")] + assert!( + FoundryTxEnvelope::Eip8130(Eip8130Signed::new( + TxEip8130::default(), + Default::default(), + Default::default() + )) + .is_eip8130() + ); + #[cfg(feature = "optimism")] + { + assert!(FoundryTxEnvelope::PostExec(Sealed::new(TxPostExec::default())).is_post_exec()); + } + } + + #[test] + fn impersonated_tx_uses_nonzero_dummy_signature() { + let FoundryTxEnvelope::Legacy(tx) = + FoundryTypedTx::Legacy(TxLegacy::default()).into_impersonated() + else { + panic!("expected legacy transaction"); + }; + + assert_eq!(tx.signature().r(), U256::ONE); + assert_eq!(tx.signature().s(), U256::ONE); + assert!(!tx.signature().v()); + } + + #[test] + fn test_decode_call() { + let bytes_first = &mut &hex::decode("f86b02843b9aca00830186a094d3e8763675e4c425df46cc3b5c0f6cbdac39604687038d7ea4c68000802ba00eb96ca19e8a77102767a41fc85a36afd5c61ccb09911cec5d3e86e193d9c5aea03a456401896b1b6055311536bf00a718568c744d8c1f9df59879e8350220ca18").unwrap()[..]; + let decoded = FoundryTxEnvelope::decode(&mut &bytes_first[..]).unwrap(); + + let tx = TxLegacy { + nonce: 2u64, + gas_price: 1000000000u128, + gas_limit: 100000, + to: TxKind::Call(Address::from_slice( + &hex::decode("d3e8763675e4c425df46cc3b5c0f6cbdac396046").unwrap()[..], + )), + value: U256::from(1000000000000000u64), + input: Bytes::default(), + chain_id: Some(4), + }; + + let signature = Signature::from_str("0eb96ca19e8a77102767a41fc85a36afd5c61ccb09911cec5d3e86e193d9c5ae3a456401896b1b6055311536bf00a718568c744d8c1f9df59879e8350220ca182b").unwrap(); + + let tx = FoundryTxEnvelope::Legacy(Signed::new_unchecked( + tx, + signature, + b256!("0xa517b206d2223278f860ea017d3626cacad4f52ff51030dc9a96b432f17f8d34"), + )); + + assert_eq!(tx, decoded); + } + + #[test] + fn test_decode_create_goerli() { + // test that an example create tx from goerli decodes properly + let tx_bytes = + hex::decode("02f901ee05228459682f008459682f11830209bf8080b90195608060405234801561001057600080fd5b50610175806100206000396000f3fe608060405234801561001057600080fd5b506004361061002b5760003560e01c80630c49c36c14610030575b600080fd5b61003861004e565b604051610045919061011d565b60405180910390f35b60606020600052600f6020527f68656c6c6f2073746174656d696e64000000000000000000000000000000000060405260406000f35b600081519050919050565b600082825260208201905092915050565b60005b838110156100be5780820151818401526020810190506100a3565b838111156100cd576000848401525b50505050565b6000601f19601f8301169050919050565b60006100ef82610084565b6100f9818561008f565b93506101098185602086016100a0565b610112816100d3565b840191505092915050565b6000602082019050818103600083015261013781846100e4565b90509291505056fea264697066735822122051449585839a4ea5ac23cae4552ef8a96b64ff59d0668f76bfac3796b2bdbb3664736f6c63430008090033c080a0136ebffaa8fc8b9fda9124de9ccb0b1f64e90fbd44251b4c4ac2501e60b104f9a07eb2999eec6d185ef57e91ed099afb0a926c5b536f0155dd67e537c7476e1471") + .unwrap(); + let _decoded = FoundryTxEnvelope::decode(&mut &tx_bytes[..]).unwrap(); + } + + #[test] + fn can_recover_sender() { + // random mainnet tx: https://etherscan.io/tx/0x86718885c4b4218c6af87d3d0b0d83e3cc465df2a05c048aa4db9f1a6f9de91f + let bytes = hex::decode("02f872018307910d808507204d2cb1827d0094388c818ca8b9251b393131c08a736a67ccb19297880320d04823e2701c80c001a0cf024f4815304df2867a1a74e9d2707b6abda0337d2d54a4438d453f4160f190a07ac0e6b3bc9395b5b9c8b9e6d77204a236577a5b18467b9175c01de4faa208d9").unwrap(); + + let Ok(FoundryTxEnvelope::Eip1559(tx)) = FoundryTxEnvelope::decode(&mut &bytes[..]) else { + panic!("decoding FoundryTxEnvelope failed"); + }; + + assert_eq!( + tx.hash(), + &b256!("0x86718885c4b4218c6af87d3d0b0d83e3cc465df2a05c048aa4db9f1a6f9de91f") + ); + assert_eq!( + tx.recover_signer().unwrap(), + address!("0x95222290DD7278Aa3Ddd389Cc1E1d165CC4BAfe5") + ); + } + + // Test vector from https://sepolia.etherscan.io/tx/0x9a22ccb0029bc8b0ddd073be1a1d923b7ae2b2ea52100bae0db4424f9107e9c0 + // Blobscan: https://sepolia.blobscan.com/tx/0x9a22ccb0029bc8b0ddd073be1a1d923b7ae2b2ea52100bae0db4424f9107e9c0 + #[test] + fn test_decode_live_4844_tx() { + use alloy_primitives::{address, b256}; + + // https://sepolia.etherscan.io/getRawTx?tx=0x9a22ccb0029bc8b0ddd073be1a1d923b7ae2b2ea52100bae0db4424f9107e9c0 + let raw_tx = alloy_primitives::hex::decode("0x03f9011d83aa36a7820fa28477359400852e90edd0008252089411e9ca82a3a762b4b5bd264d4173a242e7a770648080c08504a817c800f8a5a0012ec3d6f66766bedb002a190126b3549fce0047de0d4c25cffce0dc1c57921aa00152d8e24762ff22b1cfd9f8c0683786a7ca63ba49973818b3d1e9512cd2cec4a0013b98c6c83e066d5b14af2b85199e3d4fc7d1e778dd53130d180f5077e2d1c7a001148b495d6e859114e670ca54fb6e2657f0cbae5b08063605093a4b3dc9f8f1a0011ac212f13c5dff2b2c6b600a79635103d6f580a4221079951181b25c7e654901a0c8de4cced43169f9aa3d36506363b2d2c44f6c49fc1fd91ea114c86f3757077ea01e11fdd0d1934eda0492606ee0bb80a7bf8f35cc5f86ec60fe5031ba48bfd544").unwrap(); + let res = FoundryTxEnvelope::decode(&mut raw_tx.as_slice()).unwrap(); + assert!(res.is_type(3)); + + let tx = match res { + FoundryTxEnvelope::Eip4844(tx) => tx, + _ => unreachable!(), + }; + + assert_eq!(tx.tx().tx().to, address!("0x11E9CA82A3a762b4B5bd264d4173a242e7a77064")); + + assert_eq!( + tx.tx().tx().blob_versioned_hashes, + vec![ + b256!("0x012ec3d6f66766bedb002a190126b3549fce0047de0d4c25cffce0dc1c57921a"), + b256!("0x0152d8e24762ff22b1cfd9f8c0683786a7ca63ba49973818b3d1e9512cd2cec4"), + b256!("0x013b98c6c83e066d5b14af2b85199e3d4fc7d1e778dd53130d180f5077e2d1c7"), + b256!("0x01148b495d6e859114e670ca54fb6e2657f0cbae5b08063605093a4b3dc9f8f1"), + b256!("0x011ac212f13c5dff2b2c6b600a79635103d6f580a4221079951181b25c7e6549") + ] + ); + + let from = tx.recover_signer().unwrap(); + assert_eq!(from, address!("0xA83C816D4f9b2783761a22BA6FADB0eB0606D7B2")); + } + + #[test] + fn can_recover_sender_not_normalized() { + let bytes = hex::decode("f85f800182520894095e7baea6a6c7c4c2dfeb977efac326af552d870a801ba048b55bfa915ac795c431978d8a6a992b628d557da5ff759b307d495a36649353a0efffd310ac743f371de3b9f7f9cb56c0b28ad43601b4ab949f53faa07bd2c804").unwrap(); + + let Ok(FoundryTxEnvelope::Legacy(tx)) = FoundryTxEnvelope::decode(&mut &bytes[..]) else { + panic!("decoding FoundryTxEnvelope failed"); + }; + + assert_eq!(tx.tx().input, Bytes::from(b"")); + assert_eq!(tx.tx().gas_price, 1); + assert_eq!(tx.tx().gas_limit, 21000); + assert_eq!(tx.tx().nonce, 0); + if let TxKind::Call(to) = tx.tx().to { + assert_eq!(to, address!("0x095e7baea6a6c7c4c2dfeb977efac326af552d87")); + } else { + panic!("expected a call transaction"); + } + assert_eq!(tx.tx().value, U256::from(0x0au64)); + assert_eq!( + tx.recover_signer().unwrap(), + address!("0f65fe9276bc9a24ae7083ae28e2660ef72df99e") + ); + } + + #[test] + fn deser_to_type_tx() { + let tx = r#" + { + "type": "0x2", + "chainId": "0x7a69", + "nonce": "0x0", + "gas": "0x5209", + "maxFeePerGas": "0x77359401", + "maxPriorityFeePerGas": "0x1", + "to": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", + "value": "0x0", + "accessList": [], + "input": "0x", + "r": "0x85c2794a580da137e24ccc823b45ae5cea99371ae23ee13860fcc6935f8305b0", + "s": "0x41de7fa4121dab284af4453d30928241208bafa90cdb701fe9bc7054759fe3cd", + "yParity": "0x0", + "hash": "0x8c9b68e8947ace33028dba167354fde369ed7bbe34911b772d09b3c64b861515" + }"#; + + let _typed_tx: FoundryTxEnvelope = serde_json::from_str(tx).unwrap(); + } + + #[test] + fn test_from_recovered_tx_legacy() { + let tx = r#" + { + "type": "0x0", + "chainId": "0x1", + "nonce": "0x0", + "gas": "0x5208", + "gasPrice": "0x1", + "to": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", + "value": "0x1", + "input": "0x", + "r": "0x85c2794a580da137e24ccc823b45ae5cea99371ae23ee13860fcc6935f8305b0", + "s": "0x41de7fa4121dab284af4453d30928241208bafa90cdb701fe9bc7054759fe3cd", + "v": "0x1b", + "hash": "0x8c9b68e8947ace33028dba167354fde369ed7bbe34911b772d09b3c64b861515" + }"#; + + let typed_tx: FoundryTxEnvelope = serde_json::from_str(tx).unwrap(); + let sender = typed_tx.recover().unwrap(); + + // Test TxEnv conversion via FromRecoveredTx trait + let tx_env = TxEnv::from_recovered_tx(&typed_tx, sender); + assert_eq!(tx_env.caller, sender); + assert_eq!(tx_env.gas_limit, 0x5208); + assert_eq!(tx_env.gas_price, 1); + } + + // Test vector from Tempo testnet: + // https://explorer.testnet.tempo.xyz/tx/0x6d6d8c102064e6dee44abad2024a8b1d37959230baab80e70efbf9b0c739c4fd + #[test] + fn test_decode_encode_tempo_tx() { + use alloy_primitives::address; + use tempo_primitives::TEMPO_TX_TYPE_ID; + + let tx_hash: TxHash = + b256!("0x6d6d8c102064e6dee44abad2024a8b1d37959230baab80e70efbf9b0c739c4fd"); + + // Raw transaction from Tempo testnet via eth_getRawTransactionByHash + let raw_tx = hex::decode( + "76f9025e82a5bd808502cb4178008302d178f8fcf85c9420c000000000000000000000000000000000000080b844095ea7b3000000000000000000000000dec00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000989680f89c94dec000000000000000000000000000000000000080b884f8856c0f00000000000000000000000020c000000000000000000000000000000000000000000000000000000000000020c00000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000989680000000000000000000000000000000000000000000000000000000000097d330c0808080809420c000000000000000000000000000000000000180c0b90133027b98b7a8e6c68d7eac741a52e6fdae0560ce3c16ef5427ad46d7a54d0ed86dd41d000000007b2274797065223a22776562617574686e2e676574222c226368616c6c656e6765223a2238453071464a7a50585167546e645473643649456659457776323173516e626966374c4741776e4b43626b222c226f726967696e223a2268747470733a2f2f74656d706f2d6465782e76657263656c2e617070222c2263726f73734f726967696e223a66616c73657dcfd45c3b19745a42f80b134dcb02a8ba099a0e4e7be1984da54734aa81d8f29f74bb9170ae6d25bd510c83fe35895ee5712efe13980a5edc8094c534e23af85eaacc80b21e45fb11f349424dce3a2f23547f60c0ff2f8bcaede2a247545ce8dd87abf0dbb7a5c9507efae2e43833356651b45ac576c2e61cec4e9c0f41fcbf6e", + ) + .unwrap(); + + let tempo_tx = FoundryTxEnvelope::decode(&mut raw_tx.as_slice()).unwrap(); + + // Verify it's a Tempo transaction (type 0x76) + assert!(tempo_tx.is_type(TEMPO_TX_TYPE_ID)); + + let FoundryTxEnvelope::Tempo(ref aa_signed) = tempo_tx else { + panic!("Expected Tempo transaction"); + }; + + // Verify the chain ID + assert_eq!(aa_signed.tx().chain_id, 42429); + + // Verify the fee token + assert_eq!( + aa_signed.tx().fee_token, + Some(address!("0x20C0000000000000000000000000000000000001")) + ); + + // Verify gas limit + assert_eq!(aa_signed.tx().gas_limit, 184696); + + // Verify we have 2 calls + assert_eq!(aa_signed.tx().calls.len(), 2); + + // Verify the hash + assert_eq!(tx_hash, tempo_tx.hash()); + + // Verify round-trip encoding + let mut encoded = Vec::new(); + tempo_tx.encode_2718(&mut encoded); + assert_eq!(raw_tx, encoded); + + // Verify sender recovery (WebAuthn signature) + let sender = tempo_tx.recover().unwrap(); + assert_eq!(sender, address!("0x566Ff0f4a6114F8072ecDC8A7A8A13d8d0C6B45F")); + } +} diff --git a/crates/primitives/src/transaction/mod.rs b/crates/primitives/src/transaction/mod.rs new file mode 100644 index 0000000000000..03a3fc99aa194 --- /dev/null +++ b/crates/primitives/src/transaction/mod.rs @@ -0,0 +1,18 @@ +#[cfg(feature = "base")] +mod base; +mod celo; +#[cfg(any(feature = "base", feature = "optimism"))] +mod deposit; +mod envelope; +#[cfg(feature = "optimism")] +mod optimism; +mod receipt; +mod request; + +pub use celo::{CIP64_TX_TYPE, TxCip64}; +pub use envelope::{FoundryTxEnvelope, FoundryTxType, FoundryTypedTx}; +pub use receipt::FoundryReceiptEnvelope; +pub use request::{Cip64TransactionRequest, FoundryTransactionRequest, TempoTransactionRequest}; + +#[cfg(any(feature = "base", feature = "optimism"))] +pub use deposit::get_deposit_tx_parts; diff --git a/crates/primitives/src/transaction/optimism.rs b/crates/primitives/src/transaction/optimism.rs new file mode 100644 index 0000000000000..31e8151ec2f81 --- /dev/null +++ b/crates/primitives/src/transaction/optimism.rs @@ -0,0 +1,188 @@ +//! OP-stack-specific impls for [`FoundryTxEnvelope`] and [`FoundryTransactionRequest`]. + +use super::{FoundryTransactionRequest, FoundryTxEnvelope}; +use alloy_consensus::{Sealed, Transaction as _, Typed2718}; +use alloy_evm::{FromRecoveredTx, FromTxWithEncoded}; +use alloy_op_evm::OpTx; +use alloy_primitives::{Address, Bytes}; +use op_alloy_consensus::{ + OpTransaction as OpTransactionTrait, OpTxEnvelope, TxDeposit, TxPostExec, +}; +use op_revm::{OpTransaction, transaction::deposit::DepositTransactionParts}; +use revm::context::TxEnv; + +impl OpTransactionTrait for FoundryTxEnvelope { + fn is_deposit(&self) -> bool { + Self::is_deposit(self) + } + + fn as_deposit(&self) -> Option<&Sealed> { + match self { + Self::Deposit(tx) => Some(tx), + _ => None, + } + } + + fn as_post_exec(&self) -> Option<&Sealed> { + if let Self::PostExec(tx) = self { Some(tx) } else { None } + } +} + +impl From for FoundryTxEnvelope { + fn from(tx: OpTxEnvelope) -> Self { + match tx { + OpTxEnvelope::Legacy(tx) => Self::Legacy(tx), + OpTxEnvelope::Eip2930(tx) => Self::Eip2930(tx), + OpTxEnvelope::Eip1559(tx) => Self::Eip1559(tx), + OpTxEnvelope::Eip7702(tx) => Self::Eip7702(tx), + OpTxEnvelope::Deposit(tx) => Self::Deposit(tx), + OpTxEnvelope::PostExec(tx) => Self::PostExec(tx), + } + } +} + +impl FromRecoveredTx for OpTransaction { + fn from_recovered_tx(tx: &FoundryTxEnvelope, caller: Address) -> Self { + match tx { + FoundryTxEnvelope::Legacy(signed_tx) => { + let base = TxEnv::from_recovered_tx(signed_tx, caller); + Self { base, enveloped_tx: None, deposit: Default::default() } + } + FoundryTxEnvelope::Eip2930(signed_tx) => { + let base = TxEnv::from_recovered_tx(signed_tx, caller); + Self { base, enveloped_tx: None, deposit: Default::default() } + } + FoundryTxEnvelope::Eip1559(signed_tx) => { + let base = TxEnv::from_recovered_tx(signed_tx, caller); + Self { base, enveloped_tx: None, deposit: Default::default() } + } + FoundryTxEnvelope::Eip4844(signed_tx) => { + let base = TxEnv::from_recovered_tx(signed_tx, caller); + Self { base, enveloped_tx: None, deposit: Default::default() } + } + FoundryTxEnvelope::Eip7702(signed_tx) => { + let base = TxEnv::from_recovered_tx(signed_tx, caller); + Self { base, enveloped_tx: None, deposit: Default::default() } + } + FoundryTxEnvelope::Deposit(sealed_tx) => { + let deposit_tx = sealed_tx.inner(); + let base = TxEnv { + tx_type: deposit_tx.ty(), + caller, + gas_limit: deposit_tx.gas_limit, + kind: deposit_tx.to, + value: deposit_tx.value, + data: deposit_tx.input.clone(), + ..Default::default() + }; + let deposit = DepositTransactionParts { + source_hash: deposit_tx.source_hash, + mint: Some(deposit_tx.mint), + is_system_transaction: deposit_tx.is_system_transaction, + }; + Self { base, enveloped_tx: None, deposit } + } + FoundryTxEnvelope::PostExec(sealed_tx) => { + let tx = sealed_tx.inner(); + let base = TxEnv { + tx_type: tx.ty(), + caller, + kind: tx.kind(), + data: tx.input.clone(), + ..Default::default() + }; + Self { base, enveloped_tx: None, deposit: Default::default() } + } + #[cfg(feature = "base")] + FoundryTxEnvelope::Eip8130(_) => { + unreachable!("EIP-8130 transaction in Optimism context") + } + FoundryTxEnvelope::Tempo(_) => unreachable!("Tempo tx in Optimism context"), + FoundryTxEnvelope::Celo(_) => unreachable!("CIP-64 transaction in optimism context"), + } + } +} + +impl FromRecoveredTx for OpTx { + fn from_recovered_tx(tx: &FoundryTxEnvelope, caller: Address) -> Self { + Self(OpTransaction::::from_recovered_tx(tx, caller)) + } +} + +impl FromTxWithEncoded for OpTx { + fn from_encoded_tx(tx: &FoundryTxEnvelope, caller: Address, encoded: Bytes) -> Self { + Self(OpTransaction::::from_encoded_tx(tx, caller, encoded)) + } +} + +impl FromTxWithEncoded for OpTransaction { + fn from_encoded_tx(tx: &FoundryTxEnvelope, caller: Address, encoded: Bytes) -> Self { + let mut tx = Self::from_recovered_tx(tx, caller); + tx.enveloped_tx = Some(encoded); + tx + } +} + +impl From> for FoundryTransactionRequest { + fn from(tx: op_alloy_rpc_types::Transaction) -> Self { + tx.inner.into_inner().into() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_network::eip2718::Encodable2718; + use alloy_primitives::{TxHash, b256}; + use alloy_rlp::Decodable; + + #[test] + fn test_from_recovered_tx_legacy_op() { + use alloy_consensus::transaction::SignerRecoverable; + + let tx = r#" + { + "type": "0x0", + "chainId": "0x1", + "nonce": "0x0", + "gas": "0x5208", + "gasPrice": "0x1", + "to": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", + "value": "0x1", + "input": "0x", + "r": "0x85c2794a580da137e24ccc823b45ae5cea99371ae23ee13860fcc6935f8305b0", + "s": "0x41de7fa4121dab284af4453d30928241208bafa90cdb701fe9bc7054759fe3cd", + "v": "0x1b", + "hash": "0x8c9b68e8947ace33028dba167354fde369ed7bbe34911b772d09b3c64b861515" + }"#; + + let typed_tx: FoundryTxEnvelope = serde_json::from_str(tx).unwrap(); + let sender = typed_tx.recover_signer().unwrap(); + + // Test OpTransaction conversion via FromRecoveredTx trait + let op_tx = OpTransaction::::from_recovered_tx(&typed_tx, sender); + assert_eq!(op_tx.base.caller, sender); + assert_eq!(op_tx.base.gas_limit, 0x5208); + } + + #[test] + fn test_decode_encode_deposit_tx() { + // https://sepolia-optimism.etherscan.io/tx/0xbf8b5f08c43e4b860715cd64fc0849bbce0d0ea20a76b269e7bc8886d112fca7 + let tx_hash: TxHash = + b256!("0xbf8b5f08c43e4b860715cd64fc0849bbce0d0ea20a76b269e7bc8886d112fca7"); + + // https://sepolia-optimism.etherscan.io/getRawTx?tx=0xbf8b5f08c43e4b860715cd64fc0849bbce0d0ea20a76b269e7bc8886d112fca7 + let raw_tx = alloy_primitives::hex::decode( + "7ef861a0dfd7ae78bf3c414cfaa77f13c0205c82eb9365e217b2daa3448c3156b69b27ac94778f2146f48179643473b82931c4cd7b8f153efd94778f2146f48179643473b82931c4cd7b8f153efd872386f26fc10000872386f26fc10000830186a08080", + ) + .unwrap(); + let dep_tx = FoundryTxEnvelope::decode(&mut raw_tx.as_slice()).unwrap(); + + let mut encoded = Vec::new(); + dep_tx.encode_2718(&mut encoded); + + assert_eq!(raw_tx, encoded); + + assert_eq!(tx_hash, dep_tx.hash()); + } +} diff --git a/crates/primitives/src/transaction/receipt.rs b/crates/primitives/src/transaction/receipt.rs new file mode 100644 index 0000000000000..fa8b991523c80 --- /dev/null +++ b/crates/primitives/src/transaction/receipt.rs @@ -0,0 +1,858 @@ +use crate::FoundryTxType; +use alloy_consensus::{ + Eip658Value, Receipt, ReceiptEnvelope, ReceiptWithBloom, TxReceipt, Typed2718, +}; +use alloy_network::{ + AnyReceiptEnvelope, + eip2718::{ + Decodable2718, EIP1559_TX_TYPE_ID, EIP2930_TX_TYPE_ID, EIP4844_TX_TYPE_ID, + EIP7702_TX_TYPE_ID, Eip2718Error, Encodable2718, LEGACY_TX_TYPE_ID, + }, +}; +use alloy_primitives::{Bloom, Log, TxHash, logs_bloom}; +use alloy_rlp::{BufMut, Decodable, Encodable, bytes}; +use alloy_rpc_types::{BlockNumHash, trace::otterscan::OtsReceipt}; +use serde::{Deserialize, Serialize}; +use tempo_primitives::TEMPO_TX_TYPE_ID; + +#[cfg(all(feature = "base", not(feature = "optimism")))] +use op_alloy_consensus::{DEPOSIT_TX_TYPE_ID, OpDepositReceipt, OpDepositReceiptWithBloom}; + +#[cfg(feature = "base")] +use base_common_consensus::Eip8130Receipt; +#[cfg(feature = "base")] +use base_common_evm::EIP8130_TRANSACTION_TYPE; + +#[cfg(feature = "optimism")] +use op_alloy_consensus::{ + DEPOSIT_TX_TYPE_ID, OpDepositReceipt, OpDepositReceiptWithBloom, POST_EXEC_TX_TYPE_ID, +}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum FoundryReceiptEnvelope { + #[serde(rename = "0x0", alias = "0x00")] + Legacy(ReceiptWithBloom>), + #[serde(rename = "0x1", alias = "0x01")] + Eip2930(ReceiptWithBloom>), + #[serde(rename = "0x2", alias = "0x02")] + Eip1559(ReceiptWithBloom>), + #[serde(rename = "0x3", alias = "0x03")] + Eip4844(ReceiptWithBloom>), + #[serde(rename = "0x4", alias = "0x04")] + Eip7702(ReceiptWithBloom>), + #[cfg(feature = "optimism")] + #[serde(rename = "0x7D", alias = "0x7d")] + PostExec(ReceiptWithBloom>), + #[cfg(any(feature = "base", feature = "optimism"))] + #[serde(rename = "0x7E", alias = "0x7e")] + Deposit(OpDepositReceiptWithBloom), + #[cfg(feature = "base")] + #[serde(rename = "0x79")] + Eip8130(ReceiptWithBloom>), + #[serde(rename = "0x76")] + Tempo(ReceiptWithBloom>), + /// A receipt with a transaction type Foundry does not model. + /// + /// Anvil cannot execute these transactions, but it must still relay receipts fetched from a + /// forked chain that mints them, for example Arbitrum Nitro's `0x64`-`0x6a` types. The type + /// byte is preserved so the receipt round-trips through RPC and RLP unchanged. + #[serde(untagged)] + Unknown(AnyReceiptEnvelope), +} + +impl FoundryReceiptEnvelope { + /// Creates a new [`FoundryReceiptEnvelope`] from the given parts. + pub fn from_parts( + status: bool, + cumulative_gas_used: u64, + logs: impl IntoIterator, + tx_type: FoundryTxType, + #[cfg(feature = "base")] eip8130_phase_statuses: Vec, + #[cfg_attr(not(any(feature = "base", feature = "optimism")), allow(unused_variables))] + deposit_nonce: Option, + #[cfg_attr(not(any(feature = "base", feature = "optimism")), allow(unused_variables))] + deposit_receipt_version: Option, + ) -> Self { + let logs = logs.into_iter().collect::>(); + let logs_bloom = logs_bloom(logs.iter().map(|l| &l.inner)); + let inner_receipt = + Receipt { status: Eip658Value::Eip658(status), cumulative_gas_used, logs }; + match tx_type { + FoundryTxType::Legacy => { + Self::Legacy(ReceiptWithBloom { receipt: inner_receipt, logs_bloom }) + } + FoundryTxType::Eip2930 => { + Self::Eip2930(ReceiptWithBloom { receipt: inner_receipt, logs_bloom }) + } + FoundryTxType::Eip1559 => { + Self::Eip1559(ReceiptWithBloom { receipt: inner_receipt, logs_bloom }) + } + FoundryTxType::Eip4844 => { + Self::Eip4844(ReceiptWithBloom { receipt: inner_receipt, logs_bloom }) + } + FoundryTxType::Eip7702 => { + Self::Eip7702(ReceiptWithBloom { receipt: inner_receipt, logs_bloom }) + } + #[cfg(feature = "optimism")] + FoundryTxType::PostExec => { + Self::PostExec(ReceiptWithBloom { receipt: inner_receipt, logs_bloom }) + } + #[cfg(any(feature = "base", feature = "optimism"))] + FoundryTxType::Deposit => { + let inner = OpDepositReceiptWithBloom { + receipt: OpDepositReceipt { + inner: inner_receipt, + deposit_nonce, + deposit_receipt_version, + }, + logs_bloom, + }; + Self::Deposit(inner) + } + #[cfg(feature = "base")] + FoundryTxType::Eip8130 => Self::Eip8130(ReceiptWithBloom { + receipt: Eip8130Receipt::new(inner_receipt, eip8130_phase_statuses), + logs_bloom, + }), + FoundryTxType::Celo => Self::Unknown(AnyReceiptEnvelope { + inner: ReceiptWithBloom { receipt: inner_receipt, logs_bloom }, + r#type: super::CIP64_TX_TYPE, + }), + FoundryTxType::Tempo => { + Self::Tempo(ReceiptWithBloom { receipt: inner_receipt, logs_bloom }) + } + } + } +} + +impl FoundryReceiptEnvelope { + pub fn convert_logs_rpc( + self, + block_numhash: BlockNumHash, + block_timestamp: u64, + transaction_hash: TxHash, + transaction_index: u64, + next_log_index: usize, + ) -> FoundryReceiptEnvelope { + let mut index = 0; + self.map_logs(|inner| { + let log = alloy_rpc_types::Log { + inner, + block_hash: Some(block_numhash.hash), + block_number: Some(block_numhash.number), + block_timestamp: Some(block_timestamp), + transaction_hash: Some(transaction_hash), + transaction_index: Some(transaction_index), + log_index: Some((next_log_index + index) as u64), + removed: false, + }; + index += 1; + log + }) + } +} + +impl FoundryReceiptEnvelope { + /// Returns `true` if this is an OP stack deposit receipt. + #[cfg(any(feature = "base", feature = "optimism"))] + pub const fn is_deposit(&self) -> bool { + matches!(self, Self::Deposit(_)) + } + + /// Returns `true` if this is an OP stack post-execution synthetic receipt. + #[cfg(feature = "optimism")] + pub const fn is_post_exec(&self) -> bool { + matches!(self, Self::PostExec(_)) + } + + /// Returns `true` if this is a Base EIP-8130 receipt. + #[cfg(feature = "base")] + pub const fn is_eip8130(&self) -> bool { + matches!(self, Self::Eip8130(_)) + } + + /// Returns EIP-8130 per-phase statuses, or an empty slice for other receipt types. + #[cfg(feature = "base")] + pub fn eip8130_phase_statuses(&self) -> &[u8] { + match self { + Self::Eip8130(receipt) => &receipt.receipt.phase_statuses, + _ => &[], + } + } + + /// Returns `true` if this is a Tempo receipt. + pub const fn is_tempo(&self) -> bool { + matches!(self, Self::Tempo(_)) + } + + /// Returns `true` if the receipt's transaction type is not modelled by Foundry. + pub const fn is_unknown(&self) -> bool { + matches!(self, Self::Unknown(_)) + } + + /// Returns the EIP-2718 type byte of the inner receipt. + pub const fn ty(&self) -> u8 { + match self { + Self::Legacy(_) => LEGACY_TX_TYPE_ID, + Self::Eip2930(_) => EIP2930_TX_TYPE_ID, + Self::Eip1559(_) => EIP1559_TX_TYPE_ID, + Self::Eip4844(_) => EIP4844_TX_TYPE_ID, + Self::Eip7702(_) => EIP7702_TX_TYPE_ID, + #[cfg(feature = "optimism")] + Self::PostExec(_) => POST_EXEC_TX_TYPE_ID, + #[cfg(any(feature = "base", feature = "optimism"))] + Self::Deposit(_) => DEPOSIT_TX_TYPE_ID, + #[cfg(feature = "base")] + Self::Eip8130(_) => EIP8130_TRANSACTION_TYPE, + Self::Tempo(_) => TEMPO_TX_TYPE_ID, + Self::Unknown(r) => r.r#type, + } + } + + /// Return the [`FoundryTxType`] of the inner receipt, or `None` for an unknown type. + pub const fn tx_type(&self) -> Option { + Some(match self { + Self::Legacy(_) => FoundryTxType::Legacy, + Self::Eip2930(_) => FoundryTxType::Eip2930, + Self::Eip1559(_) => FoundryTxType::Eip1559, + Self::Eip4844(_) => FoundryTxType::Eip4844, + Self::Eip7702(_) => FoundryTxType::Eip7702, + #[cfg(feature = "optimism")] + Self::PostExec(_) => FoundryTxType::PostExec, + #[cfg(any(feature = "base", feature = "optimism"))] + Self::Deposit(_) => FoundryTxType::Deposit, + #[cfg(feature = "base")] + Self::Eip8130(_) => FoundryTxType::Eip8130, + Self::Tempo(_) => FoundryTxType::Tempo, + Self::Unknown(_) => return None, + }) + } + + /// Returns the success status of the receipt's transaction. + pub const fn status(&self) -> bool { + self.as_receipt().status.coerce_status() + } + + /// Returns the cumulative gas used at this receipt. + pub const fn cumulative_gas_used(&self) -> u64 { + self.as_receipt().cumulative_gas_used + } + + /// Converts the receipt's log type by applying a function to each log. + /// + /// Returns the receipt with the new log type. + pub fn map_logs(self, f: impl FnMut(T) -> U) -> FoundryReceiptEnvelope { + match self { + Self::Legacy(r) => FoundryReceiptEnvelope::Legacy(r.map_logs(f)), + Self::Eip2930(r) => FoundryReceiptEnvelope::Eip2930(r.map_logs(f)), + Self::Eip1559(r) => FoundryReceiptEnvelope::Eip1559(r.map_logs(f)), + Self::Eip4844(r) => FoundryReceiptEnvelope::Eip4844(r.map_logs(f)), + Self::Eip7702(r) => FoundryReceiptEnvelope::Eip7702(r.map_logs(f)), + #[cfg(feature = "optimism")] + Self::PostExec(r) => FoundryReceiptEnvelope::PostExec(r.map_logs(f)), + #[cfg(any(feature = "base", feature = "optimism"))] + Self::Deposit(r) => FoundryReceiptEnvelope::Deposit( + r.map_receipt(|r: OpDepositReceipt| r.map_logs(f)), + ), + #[cfg(feature = "base")] + Self::Eip8130(r) => { + FoundryReceiptEnvelope::Eip8130(r.map_receipt(|r: Eip8130Receipt| r.map_logs(f))) + } + Self::Tempo(r) => FoundryReceiptEnvelope::Tempo(r.map_logs(f)), + Self::Unknown(r) => FoundryReceiptEnvelope::Unknown(AnyReceiptEnvelope { + inner: r.inner.map_logs(f), + r#type: r.r#type, + }), + } + } + + /// Return the receipt logs. + pub fn logs(&self) -> &[T] { + &self.as_receipt().logs + } + + /// Consumes the type and returns the logs. + pub fn into_logs(self) -> Vec { + self.into_receipt().logs + } + + /// Return the receipt's bloom. + pub const fn logs_bloom(&self) -> &Bloom { + match self { + Self::Legacy(t) => t.bloom_ref(), + Self::Eip2930(t) => t.bloom_ref(), + Self::Eip1559(t) => t.bloom_ref(), + Self::Eip4844(t) => t.bloom_ref(), + Self::Eip7702(t) => t.bloom_ref(), + #[cfg(feature = "optimism")] + Self::PostExec(t) => t.bloom_ref(), + #[cfg(any(feature = "base", feature = "optimism"))] + Self::Deposit(t) => t.bloom_ref(), + #[cfg(feature = "base")] + Self::Eip8130(t) => t.bloom_ref(), + Self::Tempo(t) => t.bloom_ref(), + Self::Unknown(t) => &t.inner.logs_bloom, + } + } + + /// Consumes the type and returns the underlying [`Receipt`]. + pub fn into_receipt(self) -> Receipt { + match self { + Self::Legacy(t) + | Self::Eip2930(t) + | Self::Eip1559(t) + | Self::Eip4844(t) + | Self::Eip7702(t) + | Self::Tempo(t) => t.receipt, + #[cfg(feature = "optimism")] + Self::PostExec(t) => t.receipt, + #[cfg(any(feature = "base", feature = "optimism"))] + Self::Deposit(t) => t.receipt.into_inner(), + #[cfg(feature = "base")] + Self::Eip8130(t) => t.receipt.into_inner(), + Self::Unknown(t) => t.inner.receipt, + } + } + + /// Return the inner receipt. + pub const fn as_receipt(&self) -> &Receipt { + match self { + Self::Legacy(t) + | Self::Eip2930(t) + | Self::Eip1559(t) + | Self::Eip4844(t) + | Self::Eip7702(t) + | Self::Tempo(t) => &t.receipt, + #[cfg(feature = "optimism")] + Self::PostExec(t) => &t.receipt, + #[cfg(any(feature = "base", feature = "optimism"))] + Self::Deposit(t) => &t.receipt.inner, + #[cfg(feature = "base")] + Self::Eip8130(t) => &t.receipt.inner, + Self::Unknown(t) => &t.inner.receipt, + } + } +} + +impl TxReceipt for FoundryReceiptEnvelope +where + T: Clone + core::fmt::Debug + PartialEq + Eq + Send + Sync, +{ + type Log = T; + + fn status_or_post_state(&self) -> Eip658Value { + self.as_receipt().status + } + + fn status(&self) -> bool { + self.status() + } + + /// Return the receipt's bloom. + fn bloom(&self) -> Bloom { + *self.logs_bloom() + } + + fn bloom_cheap(&self) -> Option { + Some(self.bloom()) + } + + /// Returns the cumulative gas used at this receipt. + fn cumulative_gas_used(&self) -> u64 { + self.cumulative_gas_used() + } + + /// Return the receipt logs. + fn logs(&self) -> &[T] { + self.logs() + } +} + +impl Encodable for FoundryReceiptEnvelope { + fn encode(&self, out: &mut dyn bytes::BufMut) { + self.network_encode(out); + } + + fn length(&self) -> usize { + self.network_len() + } +} + +impl Decodable for FoundryReceiptEnvelope { + fn decode(buf: &mut &[u8]) -> alloy_rlp::Result { + Self::network_decode(buf).map_err(Into::into) + } +} + +impl Typed2718 for FoundryReceiptEnvelope { + fn ty(&self) -> u8 { + self.ty() + } +} + +impl Encodable2718 for FoundryReceiptEnvelope { + fn encode_2718_len(&self) -> usize { + match self { + Self::Legacy(r) => r.length(), + Self::Eip2930(r) => 1 + r.length(), + Self::Eip1559(r) => 1 + r.length(), + Self::Eip4844(r) => 1 + r.length(), + Self::Eip7702(r) => 1 + r.length(), + #[cfg(feature = "optimism")] + Self::PostExec(r) => 1 + r.length(), + #[cfg(any(feature = "base", feature = "optimism"))] + Self::Deposit(r) => 1 + r.length(), + #[cfg(feature = "base")] + Self::Eip8130(r) => 1 + r.length(), + Self::Tempo(r) => 1 + r.length(), + Self::Unknown(r) => r.rlp_payload_length(), + } + } + + fn encode_2718(&self, out: &mut dyn BufMut) { + if let Some(ty) = self.type_flag() { + out.put_u8(ty); + } + match self { + Self::Legacy(r) + | Self::Eip2930(r) + | Self::Eip1559(r) + | Self::Eip4844(r) + | Self::Eip7702(r) + | Self::Tempo(r) => r.encode(out), + #[cfg(feature = "optimism")] + Self::PostExec(r) => r.encode(out), + #[cfg(any(feature = "base", feature = "optimism"))] + Self::Deposit(r) => r.encode(out), + #[cfg(feature = "base")] + Self::Eip8130(r) => r.encode(out), + Self::Unknown(r) => r.inner.encode(out), + } + } +} + +impl Decodable2718 for FoundryReceiptEnvelope { + fn typed_decode(ty: u8, buf: &mut &[u8]) -> Result { + #[cfg(feature = "base")] + if ty == EIP8130_TRANSACTION_TYPE { + return Ok(Self::Eip8130(ReceiptWithBloom::decode(buf)?)); + } + #[cfg(all(feature = "base", not(feature = "optimism")))] + if ty == DEPOSIT_TX_TYPE_ID { + return Ok(Self::Deposit(OpDepositReceiptWithBloom::decode(buf)?)); + } + #[cfg(feature = "optimism")] + { + if ty == DEPOSIT_TX_TYPE_ID { + return Ok(Self::Deposit(OpDepositReceiptWithBloom::decode(buf)?)); + } + if ty == POST_EXEC_TX_TYPE_ID { + return Ok(Self::PostExec(ReceiptWithBloom::decode(buf)?)); + } + } + if ty == TEMPO_TX_TYPE_ID { + return Ok(Self::Tempo(ReceiptWithBloom::decode(buf)?)); + } + match ty { + LEGACY_TX_TYPE_ID => Err(Eip2718Error::UnexpectedType(LEGACY_TX_TYPE_ID)), + EIP2930_TX_TYPE_ID | EIP1559_TX_TYPE_ID | EIP4844_TX_TYPE_ID | EIP7702_TX_TYPE_ID => { + match ReceiptEnvelope::typed_decode(ty, buf)? { + ReceiptEnvelope::Eip2930(tx) => Ok(Self::Eip2930(tx)), + ReceiptEnvelope::Eip1559(tx) => Ok(Self::Eip1559(tx)), + ReceiptEnvelope::Eip4844(tx) => Ok(Self::Eip4844(tx)), + ReceiptEnvelope::Eip7702(tx) => Ok(Self::Eip7702(tx)), + _ => { + Err(Eip2718Error::RlpError(alloy_rlp::Error::Custom("unexpected tx type"))) + } + } + } + // Receipts for transaction types Foundry does not model, such as Arbitrum's, are kept + // verbatim so they survive a decode/encode round-trip. + _ => Ok(Self::Unknown(AnyReceiptEnvelope { + inner: ReceiptWithBloom::decode(buf)?, + r#type: ty, + })), + } + } + + fn fallback_decode(buf: &mut &[u8]) -> Result { + match ReceiptEnvelope::fallback_decode(buf)? { + ReceiptEnvelope::Legacy(tx) => Ok(Self::Legacy(tx)), + _ => Err(Eip2718Error::RlpError(alloy_rlp::Error::Custom("unexpected tx type"))), + } + } +} + +impl From> for OtsReceipt { + fn from(receipt: FoundryReceiptEnvelope) -> Self { + Self { + status: receipt.status(), + cumulative_gas_used: receipt.cumulative_gas_used(), + logs: Some(receipt.logs().to_vec()), + logs_bloom: Some(receipt.logs_bloom().to_owned()), + r#type: receipt.ty(), + } + } +} + +#[cfg(test)] +mod tests { + + use super::*; + use alloy_primitives::{Address, B256, Bytes, LogData, address, b256, hex}; + + fn receipt_for(tx_type: FoundryTxType) -> FoundryReceiptEnvelope { + FoundryReceiptEnvelope::::from_parts( + true, + 0, + Vec::new(), + tx_type, + #[cfg(feature = "base")] + Vec::new(), + None, + None, + ) + .map_logs(|log| log.inner) + } + + #[test] + fn rlp_roundtrip() { + fn assert_roundtrip(receipt: FoundryReceiptEnvelope) { + let mut encoded = Vec::new(); + receipt.encode(&mut encoded); + assert_eq!(encoded.len(), receipt.length()); + + let mut encoded = encoded.as_slice(); + let decoded = FoundryReceiptEnvelope::decode(&mut encoded).unwrap(); + assert_eq!(decoded, receipt); + assert!(encoded.is_empty()); + } + + for tx_type in [ + FoundryTxType::Legacy, + FoundryTxType::Eip2930, + FoundryTxType::Eip1559, + FoundryTxType::Eip4844, + FoundryTxType::Eip7702, + FoundryTxType::Tempo, + #[cfg(feature = "base")] + FoundryTxType::Eip8130, + #[cfg(any(feature = "base", feature = "optimism"))] + FoundryTxType::Deposit, + ] { + assert_roundtrip(receipt_for(tx_type)); + } + #[cfg(feature = "optimism")] + assert_roundtrip(receipt_for(FoundryTxType::PostExec)); + + // Varied payload so encodings differ beyond the type byte. + let logs = vec![Log { + address: Address::with_last_byte(0x11), + data: LogData::new_unchecked( + vec![B256::repeat_byte(0x22)], + Bytes::from_static(&[0x01, 0x02, 0x03]), + ), + }]; + let logs_bloom = logs_bloom(&logs); + let receipt = Receipt { status: false.into(), cumulative_gas_used: 0x2a, logs }; + assert_roundtrip(FoundryReceiptEnvelope::Eip1559(ReceiptWithBloom { + receipt: receipt.clone(), + logs_bloom, + })); + // A deposit receipt with set deposit fields; op-alloy encodes them only when `Some`, so + // this catches decode paths that drop them. + #[cfg(any(feature = "base", feature = "optimism"))] + assert_roundtrip(FoundryReceiptEnvelope::Deposit(OpDepositReceiptWithBloom { + receipt: OpDepositReceipt { + inner: receipt, + deposit_nonce: Some(7), + deposit_receipt_version: Some(1), + }, + logs_bloom, + })); + } + + #[test] + fn encode_typed_receipt_uses_rlp_string() { + let receipt = receipt_for(FoundryTxType::Eip2930); + let mut encoded = Vec::new(); + receipt.encode(&mut encoded); + + // Long string containing a 266-byte EIP-2718 envelope, beginning with type 0x01. + assert_eq!(&encoded[..4], &[0xb9, 0x01, 0x0a, EIP2930_TX_TYPE_ID]); + } + + /// Arbitrum's `ArbitrumInternalTx`; anvil forks these chains but cannot execute their + /// transactions, so their receipts must survive as-is. + const ARBITRUM_INTERNAL_TX_TYPE: u8 = 0x6a; + + fn unknown_receipt(ty: u8) -> FoundryReceiptEnvelope { + let logs = vec![Log { + address: Address::with_last_byte(0x64), + data: LogData::new_unchecked( + vec![B256::repeat_byte(0x33)], + Bytes::from_static(&[0xaa, 0xbb]), + ), + }]; + let logs_bloom = logs_bloom(&logs); + FoundryReceiptEnvelope::Unknown(AnyReceiptEnvelope { + inner: ReceiptWithBloom { + receipt: Receipt { status: true.into(), cumulative_gas_used: 0x1234, logs }, + logs_bloom, + }, + r#type: ty, + }) + } + + #[test] + fn unknown_receipt_roundtrips_and_keeps_type() { + let receipt = unknown_receipt(ARBITRUM_INTERNAL_TX_TYPE); + + assert!(receipt.is_unknown()); + assert_eq!(receipt.tx_type(), None); + assert_eq!(receipt.ty(), ARBITRUM_INTERNAL_TX_TYPE); + assert!(receipt.status()); + assert_eq!(receipt.cumulative_gas_used(), 0x1234); + assert_eq!(receipt.logs().len(), 1); + + let mut encoded_2718 = Vec::new(); + receipt.encode_2718(&mut encoded_2718); + assert_eq!(encoded_2718[0], ARBITRUM_INTERNAL_TX_TYPE); + assert_eq!(encoded_2718.len(), receipt.encode_2718_len()); + assert_eq!(FoundryReceiptEnvelope::decode_2718(&mut &encoded_2718[..]).unwrap(), receipt); + + let mut encoded = Vec::new(); + receipt.encode(&mut encoded); + assert_eq!(encoded.len(), receipt.length()); + assert_eq!(FoundryReceiptEnvelope::decode(&mut &encoded[..]).unwrap(), receipt); + } + + #[test] + fn unknown_receipt_serde_roundtrip() { + let receipt = unknown_receipt(ARBITRUM_INTERNAL_TX_TYPE); + let json = serde_json::to_value(&receipt).unwrap(); + assert_eq!(json["type"], "0x6a"); + assert_eq!(serde_json::from_value::(json).unwrap(), receipt); + + // The fallback must not swallow types that have a dedicated variant. + let known = receipt_for(FoundryTxType::Eip1559); + let json = serde_json::to_value(&known).unwrap(); + let decoded = serde_json::from_value::(json).unwrap(); + assert_eq!(decoded, known); + assert!(!decoded.is_unknown()); + } + + #[test] + fn receipt_predicates() { + assert!(receipt_for(FoundryTxType::Legacy).is_legacy()); + assert!(receipt_for(FoundryTxType::Eip2930).is_eip2930()); + assert!(receipt_for(FoundryTxType::Eip1559).is_eip1559()); + assert!(receipt_for(FoundryTxType::Eip4844).is_eip4844()); + assert!(receipt_for(FoundryTxType::Eip7702).is_eip7702()); + assert!(receipt_for(FoundryTxType::Tempo).is_tempo()); + assert!(!receipt_for(FoundryTxType::Tempo).is_legacy()); + + #[cfg(any(feature = "base", feature = "optimism"))] + assert!(receipt_for(FoundryTxType::Deposit).is_deposit()); + #[cfg(feature = "base")] + assert!(receipt_for(FoundryTxType::Eip8130).is_eip8130()); + #[cfg(feature = "optimism")] + { + assert!(receipt_for(FoundryTxType::PostExec).is_post_exec()); + } + } + + #[cfg(feature = "base")] + #[test] + fn eip8130_receipt_preserves_phase_statuses_outside_consensus_encoding() { + let receipt = FoundryReceiptEnvelope::::from_parts( + false, + 42_000, + Vec::new(), + FoundryTxType::Eip8130, + vec![0x01, 0x00], + None, + None, + ) + .map_logs(|log| log.inner); + assert_eq!(receipt.eip8130_phase_statuses(), &[0x01, 0x00]); + + let encoded = receipt.encoded_2718(); + let decoded = FoundryReceiptEnvelope::decode_2718(&mut encoded.as_slice()).unwrap(); + assert!(decoded.eip8130_phase_statuses().is_empty()); + assert_eq!(decoded.encoded_2718(), encoded); + } + + #[test] + fn encode_legacy_receipt() { + let expected = hex::decode("f901668001b9010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f85ff85d940000000000000000000000000000000000000011f842a0000000000000000000000000000000000000000000000000000000000000deada0000000000000000000000000000000000000000000000000000000000000beef830100ff").unwrap(); + + let mut data = vec![]; + let receipt = FoundryReceiptEnvelope::Legacy(ReceiptWithBloom { + receipt: Receipt { + status: false.into(), + cumulative_gas_used: 0x1, + logs: vec![Log { + address: Address::with_last_byte(0x11), + data: LogData::new_unchecked( + vec![ + b256!( + "000000000000000000000000000000000000000000000000000000000000dead" + ), + b256!( + "000000000000000000000000000000000000000000000000000000000000beef" + ), + ], + alloy_primitives::bytes!("0100ff"), + ), + }], + }, + logs_bloom: Bloom::ZERO, + }); + + receipt.encode(&mut data); + + // check that the rlp length equals the length of the expected rlp + assert_eq!(receipt.length(), expected.len()); + assert_eq!(data, expected); + } + + #[test] + fn decode_legacy_receipt() { + let data = hex::decode("f901668001b9010000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f85ff85d940000000000000000000000000000000000000011f842a0000000000000000000000000000000000000000000000000000000000000deada0000000000000000000000000000000000000000000000000000000000000beef830100ff").unwrap(); + + let expected = FoundryReceiptEnvelope::Legacy(ReceiptWithBloom { + receipt: Receipt { + status: false.into(), + cumulative_gas_used: 0x1, + logs: vec![Log { + address: Address::with_last_byte(0x11), + data: LogData::new_unchecked( + vec![ + b256!( + "000000000000000000000000000000000000000000000000000000000000dead" + ), + b256!( + "000000000000000000000000000000000000000000000000000000000000beef" + ), + ], + alloy_primitives::bytes!("0100ff"), + ), + }], + }, + logs_bloom: Bloom::ZERO, + }); + + let receipt = FoundryReceiptEnvelope::decode(&mut &data[..]).unwrap(); + + assert_eq!(receipt, expected); + } + + #[test] + fn encode_tempo_receipt() { + let receipt = FoundryReceiptEnvelope::Tempo(ReceiptWithBloom { + receipt: Receipt { + status: true.into(), + cumulative_gas_used: 157716, + logs: vec![Log { + address: address!("20c0000000000000000000000000000000000000"), + data: LogData::new_unchecked( + vec![ + b256!( + "8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925" + ), + b256!( + "000000000000000000000000566ff0f4a6114f8072ecdc8a7a8a13d8d0c6b45f" + ), + b256!( + "000000000000000000000000dec0000000000000000000000000000000000000" + ), + ], + alloy_primitives::bytes!( + "0000000000000000000000000000000000000000000000000000000000989680" + ), + ), + }], + }, + logs_bloom: Bloom::ZERO, + }); + + assert_eq!(receipt.tx_type(), Some(FoundryTxType::Tempo)); + assert_eq!(receipt.ty(), TEMPO_TX_TYPE_ID); + assert!(receipt.status()); + assert_eq!(receipt.cumulative_gas_used(), 157716); + assert_eq!(receipt.logs().len(), 1); + + // The EIP-2718 encoding starts with the Tempo type byte. + let mut encoded_2718 = Vec::new(); + receipt.encode_2718(&mut encoded_2718); + assert_eq!(encoded_2718[0], TEMPO_TX_TYPE_ID); + + // `Decodable` expects the network format, which wraps the typed payload in an RLP string. + let mut encoded = Vec::new(); + receipt.encode(&mut encoded); + let decoded = FoundryReceiptEnvelope::decode(&mut &encoded[..]).unwrap(); + assert_eq!(receipt, decoded); + } + + #[test] + fn decode_tempo_receipt() { + let receipt = FoundryReceiptEnvelope::Tempo(ReceiptWithBloom { + receipt: Receipt { status: true.into(), cumulative_gas_used: 21000, logs: vec![] }, + logs_bloom: Bloom::ZERO, + }); + + // Encode and decode via 2718. + let mut encoded = Vec::new(); + receipt.encode_2718(&mut encoded); + assert_eq!(encoded[0], TEMPO_TX_TYPE_ID); + + let decoded = FoundryReceiptEnvelope::decode_2718(&mut &encoded[..]).unwrap(); + assert_eq!(receipt, decoded); + } + + #[test] + fn tempo_receipt_from_parts() { + let receipt = FoundryReceiptEnvelope::::from_parts( + true, + 100000, + vec![], + FoundryTxType::Tempo, + #[cfg(feature = "base")] + Vec::new(), + None, + None, + ); + + assert_eq!(receipt.tx_type(), Some(FoundryTxType::Tempo)); + assert!(receipt.status()); + assert_eq!(receipt.cumulative_gas_used(), 100000); + assert!(receipt.logs().is_empty()); + #[cfg(any(feature = "base", feature = "optimism"))] + { + assert!(receipt.deposit_nonce().is_none()); + assert!(receipt.deposit_receipt_version().is_none()); + } + } + + #[test] + fn tempo_receipt_map_logs() { + let receipt = FoundryReceiptEnvelope::Tempo(ReceiptWithBloom { + receipt: Receipt { + status: true.into(), + cumulative_gas_used: 21000, + logs: vec![Log { + address: address!("20c0000000000000000000000000000000000000"), + data: LogData::new_unchecked(vec![], Bytes::default()), + }], + }, + logs_bloom: Bloom::ZERO, + }); + + // Map logs to a different type (just clone in this case) + let mapped = receipt.map_logs(|log| log); + assert_eq!(mapped.logs().len(), 1); + assert_eq!(mapped.tx_type(), Some(FoundryTxType::Tempo)); + } +} diff --git a/crates/primitives/src/transaction/request.rs b/crates/primitives/src/transaction/request.rs new file mode 100644 index 0000000000000..7395af7dec367 --- /dev/null +++ b/crates/primitives/src/transaction/request.rs @@ -0,0 +1,1145 @@ +use super::{CIP64_TX_TYPE, FoundryTxEnvelope, FoundryTxType, FoundryTypedTx, TxCip64}; +use crate::FoundryNetwork; +use alloy_consensus::{BlobTransactionSidecarVariant, EthereumTypedTransaction}; +use alloy_network::{ + BuildResult, NetworkTransactionBuilder, NetworkWallet, TransactionBuilder, + TransactionBuilder4844, TransactionBuilderError, +}; +use alloy_primitives::{Address, ChainId, TxKind, U256}; +use alloy_rpc_types::{AccessList, TransactionInputKind, TransactionRequest}; +use alloy_serde::WithOtherFields; +use core::num::NonZeroU64; +use serde::{Deserialize, Serialize}; +use tempo_primitives::{ + SignatureType, TEMPO_TX_TYPE_ID, TempoTxType, + transaction::{Call, SignedKeyAuthorization, TempoSignedAuthorization}, +}; + +#[cfg(all(feature = "base", not(feature = "optimism")))] +use op_alloy_consensus::{DEPOSIT_TX_TYPE_ID, TxDeposit}; + +#[cfg(any(feature = "base", feature = "optimism"))] +use super::get_deposit_tx_parts; +#[cfg(any(feature = "base", feature = "optimism"))] +use op_revm::transaction::deposit::DepositTransactionParts; + +#[cfg(any(test, feature = "base", feature = "optimism"))] +use alloy_serde::OtherFields; + +#[cfg(feature = "base")] +use base_common_evm::EIP8130_TRANSACTION_TYPE; +#[cfg(feature = "base")] +use base_common_rpc_types::BaseTransactionRequest; + +#[cfg(feature = "optimism")] +use op_alloy_consensus::{DEPOSIT_TX_TYPE_ID, POST_EXEC_TX_TYPE_ID, TxDeposit}; + +pub use tempo_alloy::rpc::TempoTransactionRequest; + +/// Foundry transaction request builder. +/// +/// This is a union of different transaction request types, instantiated from a +/// [`WithOtherFields`]. The specific variant is determined by the transaction +/// type field and/or the presence of certain fields: +/// - **Ethereum**: Default variant when no special fields are present +/// - **Op**: When `sourceHash`, `mint`, and `isSystemTx` fields are present, or transaction type is +/// `DEPOSIT_TX_TYPE_ID` +/// - **Celo**: When `feeCurrency` is non-null, or transaction type is `0x7b` +/// - **Tempo**: When a Tempo-specific field is present, or transaction type is `TEMPO_TX_TYPE_ID` +#[derive(Clone, Debug, PartialEq, Eq)] +#[allow(clippy::large_enum_variant)] +pub enum FoundryTransactionRequest { + Ethereum(TransactionRequest), + #[cfg(feature = "base")] + Base(BaseTransactionRequest), + #[cfg(any(feature = "base", feature = "optimism"))] + Op(WithOtherFields), + Tempo(Box), + /// CIP-64 fields alongside the ordinary transaction request. + Celo(Cip64TransactionRequest), +} + +/// Celo CIP-64 request fields. Anvil currently charges native fees. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Cip64TransactionRequest { + /// Ordinary transaction request fields. + #[serde(flatten)] + pub inner: TransactionRequest, + /// Fee currency retained in the signed envelope. + pub fee_currency: Option
, +} + +const TEMPO_REQUEST_FIELDS: &[&str] = &[ + "feeToken", + "nonceKey", + "calls", + "keyType", + "keyData", + "keyId", + "aaAuthorizationList", + "keyAuthorization", + "validBefore", + "validAfter", + "feePayerSignature", +]; + +impl FoundryTransactionRequest { + /// Returns `true` if this is an Ethereum transaction request. + pub const fn is_ethereum(&self) -> bool { + matches!(self, Self::Ethereum(_)) + } + + /// Returns `true` if this is a Base EIP-8130 request. + #[cfg(feature = "base")] + pub const fn is_base(&self) -> bool { + matches!(self, Self::Base(_)) + } + + /// Returns the native Base request. + #[cfg(feature = "base")] + pub const fn as_base(&self) -> Option<&BaseTransactionRequest> { + match self { + Self::Base(request) => Some(request), + _ => None, + } + } + + /// Returns `true` if this is an OP stack transaction request. + #[cfg(any(feature = "base", feature = "optimism"))] + pub const fn is_op(&self) -> bool { + matches!(self, Self::Op(_)) + } + + /// Returns `true` if this is a Tempo transaction request. + pub const fn is_tempo(&self) -> bool { + matches!(self, Self::Tempo(_)) + } + + /// Create a new [`FoundryTransactionRequest`] from given + /// [`WithOtherFields`]. + #[inline] + pub fn new(inner: WithOtherFields) -> serde_json::Result { + inner.try_into() + } + + /// Consume the [`FoundryTransactionRequest`] and return the inner transaction request. + pub fn into_inner(self) -> TransactionRequest { + match self { + Self::Ethereum(tx) => tx, + Self::Celo(tx) => tx.inner, + #[cfg(feature = "base")] + Self::Base(tx) => tx.into(), + #[cfg(any(feature = "base", feature = "optimism"))] + Self::Op(tx) => tx.inner, + Self::Tempo(tx) => tx.inner, + } + } + + /// Get the deposit transaction parts from the request, calling [`get_deposit_tx_parts`] helper + /// with OtherFields. + /// + /// # Returns + /// - Ok(deposit_tx_parts) if all necessary keys are present to build a deposit transaction. + /// - Err(missing) if some keys are missing to build a deposit transaction. + #[cfg(any(feature = "base", feature = "optimism"))] + pub fn get_deposit_tx_parts(&self) -> Result> { + match self { + Self::Op(tx) => get_deposit_tx_parts(&tx.other), + // Not a deposit transaction request, so missing at least sourceHash, mint, and + // isSystemTx + _ => Err(vec!["sourceHash", "mint", "isSystemTx"]), + } + } + + /// Returns the minimal transaction type this request can be converted into based on the fields + /// that are set. See [`TransactionRequest::preferred_type`]. + pub fn preferred_type(&self) -> FoundryTxType { + match self { + Self::Ethereum(tx) => tx.preferred_type().into(), + #[cfg(feature = "base")] + Self::Base(_) => FoundryTxType::Eip8130, + #[cfg(feature = "optimism")] + Self::Op(tx) if tx.inner.transaction_type == Some(POST_EXEC_TX_TYPE_ID) => { + FoundryTxType::PostExec + } + #[cfg(any(feature = "base", feature = "optimism"))] + Self::Op(_) => FoundryTxType::Deposit, + Self::Tempo(_) => FoundryTxType::Tempo, + Self::Celo(_) => FoundryTxType::Celo, + } + } + + /// Check if all necessary keys are present to build a 4844 transaction, + /// returning a list of keys that are missing. + /// + /// **NOTE:** Inner [`TransactionRequest::complete_4844`] method but "sidecar" key is filtered + /// from error. + pub fn complete_4844(&self) -> Result<(), Vec<&'static str>> { + match self.as_ref().complete_4844() { + Ok(()) => Ok(()), + Err(missing) => { + let filtered: Vec<_> = + missing.into_iter().filter(|&key| key != "sidecar").collect(); + if filtered.is_empty() { Ok(()) } else { Err(filtered) } + } + } + } + + /// Check if all necessary keys are present to build a Deposit transaction, returning a list of + /// keys that are missing. + #[cfg(any(feature = "base", feature = "optimism"))] + pub fn complete_deposit(&self) -> Result<(), Vec<&'static str>> { + self.get_deposit_tx_parts().map(|_| ()) + } + + /// Check if all necessary keys are present to build a Tempo transaction, returning a list of + /// keys that are missing. + pub fn complete_tempo(&self) -> Result<(), Vec<&'static str>> { + match self { + Self::Tempo(tx) => tx.complete_type(TempoTxType::AA).map(|_| ()), + // Not a Tempo transaction request, so missing at least feeToken and nonceKey + _ => Err(vec!["feeToken", "nonceKey"]), + } + } + + fn check_type(&self, ty: FoundryTxType) -> Result<(), Vec<&'static str>> { + match ty { + FoundryTxType::Legacy => self.as_ref().complete_legacy(), + FoundryTxType::Eip2930 => self.as_ref().complete_2930(), + FoundryTxType::Eip1559 => self.as_ref().complete_1559(), + FoundryTxType::Eip4844 => self.as_ref().complete_4844(), + FoundryTxType::Eip7702 => self.as_ref().complete_7702(), + #[cfg(any(feature = "base", feature = "optimism"))] + FoundryTxType::Deposit => self.complete_deposit(), + #[cfg(feature = "optimism")] + FoundryTxType::PostExec => Err(vec!["not implemented for post-exec tx"]), + #[cfg(feature = "base")] + FoundryTxType::Eip8130 => { + Err(vec!["EIP-8130 requires a signed raw transaction envelope"]) + } + FoundryTxType::Tempo => self.complete_tempo(), + FoundryTxType::Celo => self.as_ref().complete_1559(), + } + } + + /// Check if all necessary keys are present to build a transaction. + /// + /// # Returns + /// + /// - Ok(type) if all necessary keys are present to build the preferred type. + /// - Err((type, missing)) if some keys are missing to build the preferred type. + pub fn missing_keys(&self) -> Result)> { + let pref = self.preferred_type(); + let result = if pref.is_eip4844() { self.complete_4844() } else { self.check_type(pref) }; + if let Err(missing) = result { Err((pref, missing)) } else { Ok(pref) } + } + + /// Build a typed transaction from this request. + /// + /// Converts the request into a `FoundryTypedTx`, handling all Ethereum and OP-stack transaction + /// types. + pub fn build_typed_tx(self) -> Result { + if let Self::Celo(request) = self { + let fee_currency = request.fee_currency; + return request + .inner + .clone() + .build_1559() + .map(|inner| FoundryTypedTx::Celo(TxCip64 { inner, fee_currency })) + .map_err(|_| Self::Celo(request)); + } + #[cfg(feature = "base")] + if self.is_base() { + return Err(self); + } + #[cfg(any(feature = "base", feature = "optimism"))] + if let Ok(deposit_tx_parts) = self.get_deposit_tx_parts() { + // Build deposit transaction + return Ok(FoundryTypedTx::Deposit(TxDeposit { + from: self.from().unwrap_or_default(), + source_hash: deposit_tx_parts.source_hash, + to: self.kind().unwrap_or_default(), + mint: deposit_tx_parts.mint.unwrap_or_default(), + value: self.value().unwrap_or_default(), + gas_limit: self.gas_limit().unwrap_or_default(), + is_system_transaction: deposit_tx_parts.is_system_transaction, + input: self.input().cloned().unwrap_or_default(), + })); + } + if self.complete_tempo().is_ok() + && let Self::Tempo(tx_req) = self + { + // Build Tempo transaction + Ok(FoundryTypedTx::Tempo( + tx_req.build_aa().map_err(|e| Self::Tempo(Box::new(e.into_value())))?, + )) + } else if self.as_ref().has_eip4844_fields() && self.blob_sidecar().is_none() { + // if request has eip4844 fields but no blob sidecar (neither eip4844 nor eip7594 + // format), try to build to eip4844 without sidecar + self.into_inner() + .build_4844_without_sidecar() + .map_err(|e| Self::Ethereum(e.into_value())) + .map(|tx| FoundryTypedTx::Eip4844(tx.into())) + } else { + // Use the inner transaction request to build EthereumTypedTransaction + let typed_tx = self.into_inner().build_typed_tx().map_err(Self::Ethereum)?; + // Convert EthereumTypedTransaction to FoundryTypedTx + Ok(match typed_tx { + EthereumTypedTransaction::Legacy(tx) => FoundryTypedTx::Legacy(tx), + EthereumTypedTransaction::Eip2930(tx) => FoundryTypedTx::Eip2930(tx), + EthereumTypedTransaction::Eip1559(tx) => FoundryTypedTx::Eip1559(tx), + EthereumTypedTransaction::Eip4844(tx) => FoundryTypedTx::Eip4844(tx), + EthereumTypedTransaction::Eip7702(tx) => FoundryTypedTx::Eip7702(tx), + }) + } + } +} + +impl Default for FoundryTransactionRequest { + fn default() -> Self { + Self::Ethereum(TransactionRequest::default()) + } +} + +impl Serialize for FoundryTransactionRequest { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::Ethereum(tx) => tx.serialize(serializer), + #[cfg(feature = "base")] + Self::Base(tx) => tx.serialize(serializer), + #[cfg(any(feature = "base", feature = "optimism"))] + Self::Op(tx) => tx.serialize(serializer), + Self::Tempo(tx) => tx.serialize(serializer), + Self::Celo(tx) => tx.serialize(serializer), + } + } +} + +impl<'de> Deserialize<'de> for FoundryTransactionRequest { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + Self::new(WithOtherFields::::deserialize(deserializer)?) + .map_err(serde::de::Error::custom) + } +} + +impl AsRef for FoundryTransactionRequest { + fn as_ref(&self) -> &TransactionRequest { + match self { + Self::Ethereum(tx) => tx, + #[cfg(feature = "base")] + Self::Base(tx) => tx.as_ref(), + #[cfg(any(feature = "base", feature = "optimism"))] + Self::Op(tx) => tx, + Self::Tempo(tx) => tx.as_ref(), + Self::Celo(tx) => &tx.inner, + } + } +} + +impl AsMut for FoundryTransactionRequest { + fn as_mut(&mut self) -> &mut TransactionRequest { + match self { + Self::Ethereum(tx) => tx, + #[cfg(feature = "base")] + Self::Base(tx) => tx.as_mut(), + #[cfg(any(feature = "base", feature = "optimism"))] + Self::Op(tx) => tx, + Self::Tempo(tx) => tx.as_mut(), + Self::Celo(tx) => &mut tx.inner, + } + } +} + +impl TryFrom> for FoundryTransactionRequest { + type Error = serde_json::Error; + + fn try_from(tx: WithOtherFields) -> Result { + if tx.transaction_type == Some(CIP64_TX_TYPE) + || tx.other.get("feeCurrency").is_some_and(|value| !value.is_null()) + { + if tx.transaction_type.is_some_and(|ty| ty != CIP64_TX_TYPE) + || tx.gas_price.is_some() + || tx.has_eip4844_fields() + || tx.authorization_list.is_some() + || TEMPO_REQUEST_FIELDS.iter().any(|field| tx.other.contains_key(*field)) + || [ + "accountChanges", + "metadata", + "sender", + "senderAuth", + "payer", + "payerAuth", + "sourceHash", + "mint", + "isSystemTx", + ] + .iter() + .any(|field| tx.other.get(*field).is_some_and(|value| !value.is_null())) + { + return Err(serde::de::Error::custom("conflicting CIP-64 transaction fields")); + } + let fee_currency = + tx.other.get_deserialized::>("feeCurrency").transpose()?.flatten(); + let mut inner = tx.inner; + inner.transaction_type = Some(CIP64_TX_TYPE); + return Ok(Self::Celo(Cip64TransactionRequest { inner, fee_currency })); + } + #[derive(Deserialize)] + struct NonZeroQuantity(#[serde(with = "alloy_serde::quantity::opt")] Option); + + #[cfg(feature = "base")] + { + let present = |field: &str| tx.other.get(field).is_some_and(|value| !value.is_null()); + let base_fields = + ["accountChanges", "metadata", "sender", "senderAuth", "payer", "payerAuth"] + .iter() + .any(|field| present(field)); + let phased_calls = tx + .other + .get("calls") + .and_then(serde_json::Value::as_array) + .is_some_and(|calls| calls.first().is_some_and(serde_json::Value::is_array)); + let explicit_base = tx.transaction_type == Some(EIP8130_TRANSACTION_TYPE); + if base_fields || phased_calls || explicit_base { + if tx.transaction_type.is_some() && !explicit_base { + return Err(serde::de::Error::custom( + "Base fields conflict with transaction type", + )); + } + if TEMPO_REQUEST_FIELDS.iter().any(|field| { + !matches!(*field, "nonceKey" | "calls" | "validBefore" | "validAfter") + && present(field) + }) { + return Err(serde::de::Error::custom( + "conflicting Base and Tempo request fields", + )); + } + let mut base = + serde_json::from_value::(serde_json::to_value(&tx)?)?; + base.as_mut().transaction_type = Some(EIP8130_TRANSACTION_TYPE); + return Ok(Self::Base(base)); + } + } + + if tx.transaction_type == Some(TEMPO_TX_TYPE_ID) + || TEMPO_REQUEST_FIELDS.iter().any(|field| { + tx.other.get(*field).is_some_and(|value| { + !value.is_null() + && !matches!( + (*field, value), + ( + "calls" | "aaAuthorizationList", + serde_json::Value::Array(values) + ) if values.is_empty() + ) + }) + }) + { + let mut tempo_tx_req: TempoTransactionRequest = tx.inner.into(); + tempo_tx_req.fee_token = + tx.other.get_deserialized::>("feeToken").transpose()?.flatten(); + tempo_tx_req.nonce_key = + tx.other.get_deserialized::>("nonceKey").transpose()?.flatten(); + tempo_tx_req.calls = + tx.other.get_deserialized::>("calls").transpose()?.unwrap_or_default(); + tempo_tx_req.key_type = tx + .other + .get_deserialized::>("keyType") + .transpose()? + .flatten(); + tempo_tx_req.key_data = + tx.other.get_deserialized::>("keyData").transpose()?.flatten(); + tempo_tx_req.key_id = + tx.other.get_deserialized::>("keyId").transpose()?.flatten(); + tempo_tx_req.tempo_authorization_list = tx + .other + .get_deserialized::>("aaAuthorizationList") + .transpose()? + .unwrap_or_default(); + tempo_tx_req.key_authorization = tx + .other + .get_deserialized::>("keyAuthorization") + .transpose()? + .flatten(); + tempo_tx_req.valid_before = tx + .other + .get_deserialized::("validBefore") + .transpose()? + .and_then(|value| value.0); + tempo_tx_req.valid_after = tx + .other + .get_deserialized::("validAfter") + .transpose()? + .and_then(|value| value.0); + tempo_tx_req.fee_payer_signature = + tx.other.get_deserialized::>("feePayerSignature").transpose()?.flatten(); + return Ok(Self::Tempo(Box::new(tempo_tx_req))); + } + #[cfg(all(feature = "base", not(feature = "optimism")))] + if tx.transaction_type == Some(DEPOSIT_TX_TYPE_ID) + || get_deposit_tx_parts(&tx.other).is_ok() + { + return Ok(Self::Op(tx)); + } + #[cfg(feature = "optimism")] + if tx.transaction_type == Some(DEPOSIT_TX_TYPE_ID) + || tx.transaction_type == Some(POST_EXEC_TX_TYPE_ID) + || get_deposit_tx_parts(&tx.other).is_ok() + { + return Ok(Self::Op(tx)); + } + Ok(Self::Ethereum(tx.into_inner())) + } +} + +impl From for FoundryTransactionRequest { + fn from(tx: TransactionRequest) -> Self { + Self::Ethereum(tx) + } +} + +impl From for FoundryTransactionRequest { + fn from(tx: FoundryTypedTx) -> Self { + match tx { + FoundryTypedTx::Legacy(tx) => Self::Ethereum(Into::::into(tx)), + FoundryTypedTx::Eip2930(tx) => Self::Ethereum(Into::::into(tx)), + FoundryTypedTx::Eip1559(tx) => Self::Ethereum(Into::::into(tx)), + FoundryTypedTx::Eip4844(tx) => Self::Ethereum(Into::::into(tx)), + FoundryTypedTx::Eip7702(tx) => Self::Ethereum(Into::::into(tx)), + #[cfg(any(feature = "base", feature = "optimism"))] + FoundryTypedTx::Deposit(tx) => { + let other = OtherFields::from_iter([ + ("sourceHash", serde_json::to_value(tx.source_hash).unwrap()), + ("mint", serde_json::to_value(U256::from(tx.mint)).unwrap()), + ("isSystemTx", serde_json::to_value(tx.is_system_transaction).unwrap()), + ]); + WithOtherFields { inner: Into::::into(tx), other } + .try_into() + .expect("valid deposit transaction request") + } + #[cfg(feature = "optimism")] + FoundryTypedTx::PostExec(tx) => Self::new(WithOtherFields { + inner: Into::::into(tx), + other: OtherFields::default(), + }) + .expect("valid OP post-exec transaction request"), + #[cfg(feature = "base")] + FoundryTypedTx::Eip8130(tx) => { + Self::Base(super::base::simulation_request(tx, None, None, None)) + } + FoundryTypedTx::Tempo(tx) => Self::Tempo(Box::new(tx.into())), + FoundryTypedTx::Celo(tx) => { + let mut inner: TransactionRequest = tx.inner.into(); + inner.transaction_type = Some(CIP64_TX_TYPE); + Self::Celo(Cip64TransactionRequest { inner, fee_currency: tx.fee_currency }) + } + } + } +} + +impl From for FoundryTransactionRequest { + fn from(tx: FoundryTxEnvelope) -> Self { + #[cfg(feature = "base")] + if let FoundryTxEnvelope::Eip8130(tx) = tx { + let from = tx.recover_sender().ok(); + let sender_auth = Some(tx.sender_auth().clone()); + let payer_auth = Some(tx.payer_auth().clone()); + return Self::Base(super::base::simulation_request( + tx.into_tx(), + from, + sender_auth, + payer_auth, + )); + } + FoundryTypedTx::from(tx).into() + } +} + +#[cfg(not(feature = "optimism"))] +impl From> for FoundryTransactionRequest { + fn from(tx: alloy_rpc_types_eth::Transaction) -> Self { + tx.inner.into_inner().into() + } +} + +// TransactionBuilder trait implementation for FoundryNetwork +impl TransactionBuilder for FoundryTransactionRequest { + fn chain_id(&self) -> Option { + self.as_ref().chain_id + } + + fn set_chain_id(&mut self, chain_id: ChainId) { + self.as_mut().chain_id = Some(chain_id); + } + + fn nonce(&self) -> Option { + self.as_ref().nonce + } + + fn set_nonce(&mut self, nonce: u64) { + self.as_mut().nonce = Some(nonce); + } + + fn take_nonce(&mut self) -> Option { + self.as_mut().nonce.take() + } + + fn input(&self) -> Option<&alloy_primitives::Bytes> { + self.as_ref().input.input() + } + + fn set_input>(&mut self, input: T) { + self.as_mut().input.input = Some(input.into()); + } + + fn set_input_kind>( + &mut self, + input: T, + kind: TransactionInputKind, + ) { + let inner = self.as_mut(); + match kind { + TransactionInputKind::Input => inner.input.input = Some(input.into()), + TransactionInputKind::Data => inner.input.data = Some(input.into()), + TransactionInputKind::Both => { + let bytes = input.into(); + inner.input.input = Some(bytes.clone()); + inner.input.data = Some(bytes); + } + } + } + + fn from(&self) -> Option
{ + self.as_ref().from + } + + fn set_from(&mut self, from: Address) { + self.as_mut().from = Some(from); + } + + fn kind(&self) -> Option { + self.as_ref().to + } + + fn clear_kind(&mut self) { + self.as_mut().to = None; + } + + fn set_kind(&mut self, kind: TxKind) { + self.as_mut().to = Some(kind); + } + + fn value(&self) -> Option { + self.as_ref().value + } + + fn set_value(&mut self, value: U256) { + self.as_mut().value = Some(value); + } + + fn gas_price(&self) -> Option { + self.as_ref().gas_price + } + + fn set_gas_price(&mut self, gas_price: u128) { + self.as_mut().gas_price = Some(gas_price); + } + + fn max_fee_per_gas(&self) -> Option { + self.as_ref().max_fee_per_gas + } + + fn set_max_fee_per_gas(&mut self, max_fee_per_gas: u128) { + self.as_mut().max_fee_per_gas = Some(max_fee_per_gas); + } + + fn max_priority_fee_per_gas(&self) -> Option { + self.as_ref().max_priority_fee_per_gas + } + + fn set_max_priority_fee_per_gas(&mut self, max_priority_fee_per_gas: u128) { + self.as_mut().max_priority_fee_per_gas = Some(max_priority_fee_per_gas); + } + + fn gas_limit(&self) -> Option { + self.as_ref().gas + } + + fn set_gas_limit(&mut self, gas_limit: u64) { + self.as_mut().gas = Some(gas_limit); + } + + fn access_list(&self) -> Option<&AccessList> { + self.as_ref().access_list.as_ref() + } + + fn set_access_list(&mut self, access_list: AccessList) { + self.as_mut().access_list = Some(access_list); + } +} + +impl NetworkTransactionBuilder for FoundryTransactionRequest { + fn complete_type(&self, ty: FoundryTxType) -> Result<(), Vec<&'static str>> { + self.check_type(ty) + } + + fn can_submit(&self) -> bool { + self.from().is_some() + } + + fn can_build(&self) -> bool { + #[cfg(feature = "base")] + if self.is_base() { + return false; + } + if let Self::Celo(_) = self { + return self.check_type(FoundryTxType::Celo).is_ok(); + } + if self.as_ref().can_build() || self.complete_tempo().is_ok() { + return true; + } + #[cfg(any(feature = "base", feature = "optimism"))] + if self.complete_deposit().is_ok() { + return true; + } + false + } + + fn output_tx_type(&self) -> FoundryTxType { + self.preferred_type() + } + + fn output_tx_type_checked(&self) -> Option { + let pref = self.preferred_type(); + self.check_type(pref).ok()?; + Some(pref) + } + + /// Prepares [`FoundryTransactionRequest`] by trimming conflicting fields, and filling with + /// default values the mandatory fields. + fn prep_for_submission(&mut self) { + #[cfg(feature = "base")] + if self.is_base() { + return; + } + let preferred_type = self.preferred_type(); + let inner = self.as_mut(); + inner.transaction_type = Some(preferred_type as u8); + inner.gas.is_none().then(|| inner.set_gas_limit(Default::default())); + let is_deposit = { + #[cfg(any(feature = "base", feature = "optimism"))] + { + preferred_type.is_deposit() + } + #[cfg(not(any(feature = "base", feature = "optimism")))] + { + false + } + }; + if !is_deposit && !preferred_type.is_tempo() && !preferred_type.is_celo() { + inner.trim_conflicting_keys(); + inner.populate_blob_hashes(); + } + if !is_deposit { + inner.nonce.is_none().then(|| inner.set_nonce(Default::default())); + } + if preferred_type.is_legacy() || preferred_type.is_eip2930() { + inner.gas_price.is_none().then(|| inner.set_gas_price(Default::default())); + } + if preferred_type.is_eip2930() { + inner.access_list.is_none().then(|| inner.set_access_list(Default::default())); + } + if preferred_type.is_eip1559() + || preferred_type.is_eip4844() + || preferred_type.is_eip7702() + || preferred_type.is_tempo() + || preferred_type.is_celo() + { + inner + .max_priority_fee_per_gas + .is_none() + .then(|| inner.set_max_priority_fee_per_gas(Default::default())); + inner.max_fee_per_gas.is_none().then(|| inner.set_max_fee_per_gas(Default::default())); + } + if preferred_type.is_eip4844() { + inner + .as_ref() + .max_fee_per_blob_gas() + .is_none() + .then(|| inner.as_mut().set_max_fee_per_blob_gas(Default::default())); + } + } + + fn build_unsigned(self) -> BuildResult { + if let Err((tx_type, missing)) = self.missing_keys() { + return Err(TransactionBuilderError::InvalidTransactionRequest(tx_type, missing) + .into_unbuilt(self)); + } + Ok(self.build_typed_tx().expect("checked by missing_keys")) + } + + async fn build>( + self, + wallet: &W, + ) -> Result> { + Ok(wallet.sign_request(self).await?) + } +} + +impl TransactionBuilder4844 for FoundryTransactionRequest { + fn max_fee_per_blob_gas(&self) -> Option { + self.as_ref().max_fee_per_blob_gas() + } + + fn set_max_fee_per_blob_gas(&mut self, max_fee_per_blob_gas: u128) { + self.as_mut().set_max_fee_per_blob_gas(max_fee_per_blob_gas); + } + + fn blob_sidecar(&self) -> Option<&BlobTransactionSidecarVariant> { + self.as_ref().blob_sidecar() + } + + fn set_blob_sidecar(&mut self, sidecar: BlobTransactionSidecarVariant) { + self.as_mut().set_blob_sidecar(sidecar); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::{B256, Bytes, Signature}; + use tempo_primitives::{ + TempoSignature, TempoTransaction, + transaction::{Authorization, KeyAuthorization, PrimitiveSignature}, + }; + + fn default_tx_req() -> TransactionRequest { + TransactionRequest::default() + .with_to(Address::random()) + .with_nonce(1) + .with_value(U256::from(1000000)) + .with_gas_limit(1000000) + .with_max_fee_per_gas(1000000) + .with_max_priority_fee_per_gas(1000000) + } + + #[test] + fn request_predicates() { + let ethereum = FoundryTransactionRequest::default(); + assert!(ethereum.is_ethereum()); + assert!(!ethereum.is_tempo()); + + let tempo = FoundryTransactionRequest::Tempo(Box::default()); + assert!(tempo.is_tempo()); + assert!(!tempo.is_ethereum()); + + #[cfg(any(feature = "base", feature = "optimism"))] + { + let op = FoundryTransactionRequest::Op(WithOtherFields::default()); + assert!(op.is_op()); + assert!(!op.is_ethereum()); + assert!(!op.is_tempo()); + } + } + + #[test] + fn test_routing_ethereum_default() { + let tx = default_tx_req(); + let req: FoundryTransactionRequest = WithOtherFields::new(tx).try_into().unwrap(); + + assert!(req.is_ethereum()); + assert!(matches!(req.build_unsigned(), Ok(FoundryTypedTx::Eip1559(_)))); + } + + #[test] + fn test_routing_tempo_by_fee_token() { + let tx = default_tx_req(); + let mut other = OtherFields::default(); + other.insert("feeToken".to_string(), serde_json::to_value(Address::random()).unwrap()); + + let req: FoundryTransactionRequest = + FoundryTransactionRequest::new(WithOtherFields { inner: tx, other }).unwrap(); + + assert!(req.is_tempo()); + assert!(matches!(req.build_unsigned(), Ok(FoundryTypedTx::Tempo(_)))); + } + + #[cfg(feature = "base")] + #[test] + fn base_and_tempo_request_routing() { + let call = serde_json::json!({ "to": Address::ZERO, "data": "0x", "value": "0x0" }); + for (value, expected_type) in [ + (serde_json::json!({"calls": [call]}), FoundryTxType::Tempo), + (serde_json::json!({"nonceKey": "0x1"}), FoundryTxType::Tempo), + (serde_json::json!({"validBefore": "0x123"}), FoundryTxType::Tempo), + (serde_json::json!({"calls": []}), FoundryTxType::Eip1559), + (serde_json::json!({"calls": [[call]], "validBefore": 123}), FoundryTxType::Eip8130), + (serde_json::json!({"type": "0x79"}), FoundryTxType::Eip8130), + (serde_json::json!({"type": "0x79", "nonceKey": "0x1"}), FoundryTxType::Eip8130), + (serde_json::json!({"type": "0x76", "calls": [call]}), FoundryTxType::Tempo), + ( + serde_json::json!({"sender": Address::ZERO, "feeToken": null}), + FoundryTxType::Eip8130, + ), + ] { + let request: FoundryTransactionRequest = serde_json::from_value(value.clone()) + .unwrap_or_else(|err| panic!("{value}: {err}")); + assert_eq!(request.preferred_type(), expected_type, "{value}"); + if expected_type.is_eip8130() { + assert!(request.is_base(), "{value}"); + assert!(!request.can_build(), "{value}"); + } + let roundtrip: FoundryTransactionRequest = + serde_json::from_value(serde_json::to_value(&request).unwrap()).unwrap(); + assert_eq!(request, roundtrip); + } + for value in [ + serde_json::json!({"type": "0x76", "calls": [[call]]}), + serde_json::json!({"type": "0x79", "feeToken": Address::ZERO}), + serde_json::json!({"type": "0x2", "sender": Address::ZERO}), + serde_json::json!({"type": "0x79", "calls": [call]}), + ] { + assert!( + serde_json::from_value::(value.clone()).is_err(), + "{value}" + ); + } + } + + #[test] + fn test_routing_serialized_non_aa_tempo_request_to_ethereum() { + let request = TempoTransactionRequest { inner: default_tx_req(), ..Default::default() }; + let request = serde_json::from_value::>( + serde_json::to_value(request).unwrap(), + ) + .unwrap(); + + let request = FoundryTransactionRequest::try_from(request).unwrap(); + + assert!(request.is_ethereum()); + assert!(matches!(request.build_unsigned(), Ok(FoundryTypedTx::Eip1559(_)))); + } + + #[test] + #[cfg(any(feature = "base", feature = "optimism"))] + fn test_routing_op_by_deposit_fields() { + let tx = default_tx_req(); + let mut other = OtherFields::default(); + other.insert("sourceHash".to_string(), serde_json::to_value(B256::ZERO).unwrap()); + other.insert("mint".to_string(), serde_json::to_value(U256::from(1000)).unwrap()); + other.insert("isSystemTx".to_string(), serde_json::to_value(false).unwrap()); + + let req: FoundryTransactionRequest = + FoundryTransactionRequest::new(WithOtherFields { inner: tx, other }).unwrap(); + + assert!(req.is_op()); + assert!(matches!(req.build_unsigned(), Ok(FoundryTypedTx::Deposit(_)))); + } + + #[test] + fn test_op_incomplete_routes_to_ethereum() { + let tx = default_tx_req(); + let mut other = OtherFields::default(); + // Only provide 2 of 3 required Op fields + other.insert("sourceHash".to_string(), serde_json::to_value(B256::ZERO).unwrap()); + other.insert("mint".to_string(), serde_json::to_value(U256::from(1000)).unwrap()); + + let req: FoundryTransactionRequest = + FoundryTransactionRequest::new(WithOtherFields { inner: tx, other }).unwrap(); + + assert!(req.is_ethereum()); + assert!(matches!(req.build_unsigned(), Ok(FoundryTypedTx::Eip1559(_)))); + } + + #[test] + fn test_ethereum_with_unrelated_other_fields() { + let tx = default_tx_req(); + let mut other = OtherFields::default(); + other.insert("anotherField".to_string(), serde_json::to_value(123).unwrap()); + + let req: FoundryTransactionRequest = + FoundryTransactionRequest::new(WithOtherFields { inner: tx, other }).unwrap(); + + assert!(req.is_ethereum()); + assert!(matches!(req.build_unsigned(), Ok(FoundryTypedTx::Eip1559(_)))); + } + + #[test] + fn test_serialization_ethereum() { + let tx = default_tx_req(); + let original: FoundryTransactionRequest = WithOtherFields::new(tx).try_into().unwrap(); + + let serialized = serde_json::to_string(&original).unwrap(); + let deserialized: FoundryTransactionRequest = serde_json::from_str(&serialized).unwrap(); + + assert!(deserialized.is_ethereum()); + } + + #[test] + #[cfg(any(feature = "base", feature = "optimism"))] + fn test_serialization_op() { + let tx = default_tx_req(); + let mut other = OtherFields::default(); + other.insert("sourceHash".to_string(), serde_json::to_value(B256::ZERO).unwrap()); + other.insert("mint".to_string(), serde_json::to_value(U256::from(1000)).unwrap()); + other.insert("isSystemTx".to_string(), serde_json::to_value(false).unwrap()); + + let original: FoundryTransactionRequest = + FoundryTransactionRequest::new(WithOtherFields { inner: tx, other }).unwrap(); + + let serialized = serde_json::to_string(&original).unwrap(); + let deserialized: FoundryTransactionRequest = serde_json::from_str(&serialized).unwrap(); + + assert!(deserialized.is_op()); + } + + #[test] + fn test_serialization_tempo() { + let tx = default_tx_req(); + let mut other = OtherFields::default(); + other.insert("feeToken".to_string(), serde_json::to_value(Address::ZERO).unwrap()); + other.insert("nonceKey".to_string(), serde_json::to_value(U256::from(42)).unwrap()); + + let original: FoundryTransactionRequest = + FoundryTransactionRequest::new(WithOtherFields { inner: tx, other }).unwrap(); + + let serialized = serde_json::to_string(&original).unwrap(); + let deserialized: FoundryTransactionRequest = serde_json::from_str(&serialized).unwrap(); + + assert!(deserialized.is_tempo()); + } + + #[test] + fn test_tempo_request_decodes_all_extension_fields() { + let signature = Signature::test_signature(); + let expected = TempoTransactionRequest { + inner: TransactionRequest { + transaction_type: Some(TEMPO_TX_TYPE_ID), + ..default_tx_req() + }, + fee_token: Some(Address::repeat_byte(0x11)), + nonce_key: Some(U256::from(42)), + calls: vec![Call { + to: TxKind::Call(Address::repeat_byte(0x22)), + value: U256::from(7), + input: Bytes::from_static(&[0xde, 0xad]), + }], + key_type: Some(SignatureType::WebAuthn), + key_data: Some(Bytes::from_static(&[0xbe, 0xef])), + key_id: Some(Address::repeat_byte(0x33)), + tempo_authorization_list: vec![TempoSignedAuthorization::new_unchecked( + Authorization { + chain_id: U256::from(4217), + address: Address::repeat_byte(0x44), + nonce: 3, + }, + TempoSignature::default(), + )], + key_authorization: Some( + KeyAuthorization::unrestricted( + 4217, + SignatureType::Secp256k1, + Address::repeat_byte(0x55), + ) + .into_signed(PrimitiveSignature::Secp256k1(signature)), + ), + valid_before: NonZeroU64::new(100), + valid_after: NonZeroU64::new(10), + fee_payer_signature: Some(signature), + }; + let request = serde_json::from_value::>( + serde_json::to_value(&expected).unwrap(), + ) + .unwrap(); + + let decoded = FoundryTransactionRequest::try_from(request).unwrap(); + + let FoundryTransactionRequest::Tempo(decoded) = decoded else { panic!() }; + assert_eq!(*decoded, expected); + } + + #[test] + fn test_malformed_tempo_field_is_rejected() { + let mut other = OtherFields::default(); + other.insert("nonceKey".to_string(), serde_json::json!("not a quantity")); + + let result = + FoundryTransactionRequest::new(WithOtherFields { inner: default_tx_req(), other }); + + assert!(result.is_err()); + } + + #[test] + fn test_tempo_validity_uses_rpc_quantities() { + let request = serde_json::from_value::(serde_json::json!({ + "type": "0x76", + "validBefore": "0x64", + "validAfter": "0xa", + })) + .unwrap(); + + let FoundryTransactionRequest::Tempo(request) = request else { panic!() }; + assert_eq!(request.valid_before, NonZeroU64::new(100)); + assert_eq!(request.valid_after, NonZeroU64::new(10)); + } + + #[test] + fn test_tempo_typed_request_roundtrip_preserves_fields() { + let tx = TempoTransaction { + chain_id: 4217, + nonce: 7, + fee_payer_signature: None, + valid_before: NonZeroU64::new(100), + valid_after: NonZeroU64::new(10), + gas_limit: 100_000, + max_fee_per_gas: 20, + max_priority_fee_per_gas: 2, + fee_token: Some(Address::random()), + access_list: Default::default(), + calls: vec![Call { + to: TxKind::Call(Address::random()), + value: U256::from(42), + input: vec![1, 2, 3].into(), + }], + tempo_authorization_list: Vec::new(), + nonce_key: U256::from(9), + key_authorization: None, + }; + + let request: FoundryTransactionRequest = FoundryTypedTx::Tempo(tx.clone()).into(); + let rebuilt = request.build_unsigned().unwrap(); + + assert_eq!(rebuilt, FoundryTypedTx::Tempo(tx)); + } + + #[test] + #[cfg(any(feature = "base", feature = "optimism"))] + fn test_deposit_typed_tx_roundtrip() { + let deposit_tx = TxDeposit { + from: Address::random(), + source_hash: B256::random(), + to: TxKind::Call(Address::random()), + mint: 1000u128, + value: U256::from(500), + gas_limit: 21000, + is_system_transaction: true, + input: Default::default(), + }; + + let req: FoundryTransactionRequest = FoundryTypedTx::Deposit(deposit_tx.clone()).into(); + + assert!(req.is_op()); + + let parts = req.get_deposit_tx_parts().expect("should parse deposit parts"); + assert_eq!(parts.source_hash, deposit_tx.source_hash); + assert_eq!(parts.mint, Some(deposit_tx.mint)); + assert_eq!(parts.is_system_transaction, deposit_tx.is_system_transaction); + } +} diff --git a/crates/script-sequence/Cargo.toml b/crates/script-sequence/Cargo.toml index 68a03e15bf94b..62fb29c657898 100644 --- a/crates/script-sequence/Cargo.toml +++ b/crates/script-sequence/Cargo.toml @@ -16,7 +16,7 @@ workspace = true [dependencies] foundry-config.workspace = true foundry-common.workspace = true -foundry-compilers = { workspace = true, features = ["full"] } +foundry-compilers.workspace = true serde.workspace = true eyre.workspace = true @@ -25,5 +25,11 @@ walkdir.workspace = true revm-inspectors.workspace = true -alloy-primitives.workspace = true alloy-network.workspace = true +alloy-primitives.workspace = true + +[features] +default = ["optimism"] +monad = ["foundry-common/monad", "foundry-config/monad"] +base = ["foundry-common/base"] +optimism = ["foundry-common/optimism"] diff --git a/crates/script-sequence/src/reader.rs b/crates/script-sequence/src/reader.rs index c73fd9161c916..4be6b42e4a35d 100644 --- a/crates/script-sequence/src/reader.rs +++ b/crates/script-sequence/src/reader.rs @@ -1,8 +1,9 @@ use crate::{ScriptSequence, TransactionWithMetadata}; -use alloy_network::AnyTransactionReceipt; +use alloy_network::{Network, ReceiptResponse}; use eyre::{Result, bail}; use foundry_common::fs; use revm_inspectors::tracing::types::CallKind; +use serde::Deserialize; use std::path::{Component, Path, PathBuf}; /// This type reads broadcast files in the @@ -25,8 +26,8 @@ pub struct BroadcastReader { impl BroadcastReader { /// Create a new `BroadcastReader` instance. pub fn new(contract_name: String, chain_id: u64, broadcast_path: &Path) -> Result { - if !broadcast_path.exists() && !broadcast_path.is_dir() { - bail!("broadcast dir does not exist"); + if !broadcast_path.is_dir() { + bail!("broadcast dir does not exist or is not a directory"); } Ok(Self { @@ -43,16 +44,25 @@ impl BroadcastReader { self } + fn matches_filters(&self, tx: &TransactionWithMetadata) -> bool { + let name_filter = tx.contract_name.as_ref().is_some_and(|cn| *cn == self.contract_name); + let type_filter = self.tx_type.is_empty() || self.tx_type.contains(&tx.call_kind); + name_filter && type_filter + } + /// Read all broadcast files in the broadcast directory. /// /// Example structure: /// /// project-root/broadcast/{script_name}.s.sol/{chain_id}/*.json /// project-root/broadcast/multi/{multichain_script_name}.s.sol-{timestamp}/deploy.json - pub fn read(&self) -> eyre::Result> { + pub fn read(&self) -> eyre::Result>> + where + N::TxEnvelope: for<'d> Deserialize<'d>, + { // 1. Recursively read all .json files in the broadcast directory let mut broadcasts = vec![]; - for entry in walkdir::WalkDir::new(&self.broadcast_path).into_iter() { + for entry in walkdir::WalkDir::new(&self.broadcast_path) { let entry = entry?; let path = entry.path(); @@ -70,7 +80,8 @@ impl BroadcastReader { let multichain_deployments = broadcast .get("deployments") .and_then(|deployments| { - serde_json::from_value::>(deployments.clone()).ok() + serde_json::from_value::>>(deployments.clone()) + .ok() }) .unwrap_or_default(); @@ -78,7 +89,7 @@ impl BroadcastReader { continue; } - let broadcast = fs::read_json_file::(path)?; + let broadcast = fs::read_json_file::>(path)?; broadcasts.push(broadcast); } } @@ -91,7 +102,10 @@ impl BroadcastReader { /// Attempts read the latest broadcast file in the broadcast directory. /// /// This may be the `run-latest.json` file or the broadcast file with the latest timestamp. - pub fn read_latest(&self) -> eyre::Result { + pub fn read_latest(&self) -> eyre::Result> + where + N::TxEnvelope: for<'d> Deserialize<'d>, + { let broadcasts = self.read()?; // Find the broadcast with the latest timestamp @@ -104,7 +118,10 @@ impl BroadcastReader { } /// Applies the filters and sorts the broadcasts by descending timestamp. - pub fn filter_and_sort(&self, broadcasts: Vec) -> Vec { + pub fn filter_and_sort( + &self, + broadcasts: Vec>, + ) -> Vec> { // Apply the filters let mut seqs = broadcasts .into_iter() @@ -113,19 +130,12 @@ impl BroadcastReader { return false; } - broadcast.transactions.iter().any(move |tx| { - let name_filter = - tx.contract_name.as_ref().is_some_and(|cn| *cn == self.contract_name); - - let type_filter = self.tx_type.is_empty() || self.tx_type.contains(&tx.opcode); - - name_filter && type_filter - }) + broadcast.transactions.iter().any(|tx| self.matches_filters(tx)) }) .collect::>(); // Sort by descending timestamp - seqs.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); + seqs.sort_by_key(|s| std::cmp::Reverse(s.timestamp)); seqs } @@ -139,38 +149,25 @@ impl BroadcastReader { /// Transactions that don't have a corresponding receipt are ignored. /// /// Sorts the transactions by descending block number. - pub fn into_tx_receipts( + pub fn into_tx_receipts( &self, - broadcast: ScriptSequence, - ) -> Vec<(TransactionWithMetadata, AnyTransactionReceipt)> { - let transactions = broadcast.transactions.clone(); + broadcast: ScriptSequence, + ) -> Vec<(TransactionWithMetadata, N::ReceiptResponse)> { + let ScriptSequence { transactions, receipts, .. } = broadcast; - let txs = transactions + let mut targets: Vec<_> = transactions .into_iter() - .filter(|tx| { - let name_filter = - tx.contract_name.as_ref().is_some_and(|cn| *cn == self.contract_name); - - let type_filter = self.tx_type.is_empty() || self.tx_type.contains(&tx.opcode); - - name_filter && type_filter + .filter(|tx| self.matches_filters(tx)) + .filter_map(|tx| { + let receipt = receipts + .iter() + .find(|r| tx.hash.is_some_and(|hash| hash == r.transaction_hash()))?; + Some((tx, receipt.clone())) }) - .collect::>(); - - let mut targets = Vec::new(); - for tx in txs.into_iter() { - let maybe_receipt = broadcast - .receipts - .iter() - .find(|receipt| tx.hash.is_some_and(|hash| hash == receipt.transaction_hash)); - - if let Some(receipt) = maybe_receipt { - targets.push((tx, receipt.clone())); - } - } + .collect(); // Sort by descending block number - targets.sort_by(|a, b| b.1.block_number.cmp(&a.1.block_number)); + targets.sort_by_key(|t| std::cmp::Reverse(t.1.block_number())); targets } diff --git a/crates/script-sequence/src/sequence.rs b/crates/script-sequence/src/sequence.rs index cbe50d707411b..e2fdf1d4a6dc4 100644 --- a/crates/script-sequence/src/sequence.rs +++ b/crates/script-sequence/src/sequence.rs @@ -1,5 +1,5 @@ use crate::transaction::TransactionWithMetadata; -use alloy_network::AnyTransactionReceipt; +use alloy_network::{Network, ReceiptResponse}; use alloy_primitives::{TxHash, hex, map::HashMap}; use eyre::{ContextCompat, Result, WrapErr}; use foundry_common::{SELECTOR_LEN, TransactionMaybeSigned, fs, shell}; @@ -8,7 +8,6 @@ use foundry_config::Config; use serde::{Deserialize, Serialize}; use std::{ collections::VecDeque, - io::{BufWriter, Write}, path::PathBuf, time::{Duration, SystemTime, UNIX_EPOCH}, }; @@ -21,12 +20,30 @@ pub struct NestedValue { pub value: String, } +/// Sensitive values from the transactions in a script sequence +#[derive(Clone, Default, Serialize, Deserialize)] +pub struct SensitiveTransactionMetadata { + pub rpc: String, +} + +/// Sensitive info from the script sequence which is saved into the cache folder +#[derive(Clone, Default, Serialize, Deserialize)] +pub struct SensitiveScriptSequence { + pub transactions: VecDeque, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub recovery_generation: Option, +} + /// Helper that saves the transactions sequence and its state on which transactions have been /// broadcasted -#[derive(Clone, Default, Serialize, Deserialize)] -pub struct ScriptSequence { - pub transactions: VecDeque, - pub receipts: Vec, +#[derive(Clone, Serialize, Deserialize)] +#[serde(bound( + serialize = "N::TransactionRequest: Serialize, N::TxEnvelope: Serialize", + deserialize = "N::TransactionRequest: for<'de2> Deserialize<'de2>, N::TxEnvelope: for<'de2> Deserialize<'de2>" +))] +pub struct ScriptSequence { + pub transactions: VecDeque>, + pub receipts: Vec, pub libraries: Vec, pub pending: Vec, #[serde(skip)] @@ -37,33 +54,41 @@ pub struct ScriptSequence { pub timestamp: u128, pub chain: u64, pub commit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub recovery_generation: Option, } -/// Sensitive values from the transactions in a script sequence -#[derive(Clone, Default, Serialize, Deserialize)] -pub struct SensitiveTransactionMetadata { - pub rpc: String, -} - -/// Sensitive info from the script sequence which is saved into the cache folder -#[derive(Clone, Default, Serialize, Deserialize)] -pub struct SensitiveScriptSequence { - pub transactions: VecDeque, +impl Default for ScriptSequence { + fn default() -> Self { + Self { + transactions: Default::default(), + receipts: Default::default(), + libraries: Default::default(), + pending: Default::default(), + paths: Default::default(), + returns: Default::default(), + timestamp: Default::default(), + chain: Default::default(), + commit: Default::default(), + recovery_generation: Default::default(), + } + } } -impl From for SensitiveScriptSequence { - fn from(sequence: ScriptSequence) -> Self { +impl From<&ScriptSequence> for SensitiveScriptSequence { + fn from(sequence: &ScriptSequence) -> Self { Self { transactions: sequence .transactions .iter() .map(|tx| SensitiveTransactionMetadata { rpc: tx.rpc.clone() }) .collect(), + recovery_generation: sequence.recovery_generation, } } } -impl ScriptSequence { +impl ScriptSequence { /// Loads The sequence for the corresponding json file pub fn load( config: &Config, @@ -71,7 +96,10 @@ impl ScriptSequence { target: &ArtifactId, chain_id: u64, dry_run: bool, - ) -> Result { + ) -> Result + where + N::TxEnvelope: for<'d> Deserialize<'d>, + { let (path, sensitive_path) = Self::get_paths(config, sig, target, chain_id, dry_run)?; let mut script_sequence: Self = fs::read_json_file(&path) @@ -82,7 +110,9 @@ impl ScriptSequence { ) .wrap_err(format!("Deployment's sensitive details not found for chain `{chain_id}`."))?; - script_sequence.fill_sensitive(&sensitive_script_sequence); + script_sequence.fill_sensitive(&sensitive_script_sequence).wrap_err(format!( + "Deployment's sensitive details are out of sync with the broadcast file for chain `{chain_id}`; restore matching broadcast and sensitive-cache files before resuming." + ))?; script_sequence.paths = Some((path, sensitive_path)); @@ -92,38 +122,37 @@ impl ScriptSequence { /// Saves the transactions as file if it's a standalone deployment. /// `save_ts` should be set to true for checkpoint updates, which might happen many times and /// could result in us saving many identical files. - pub fn save(&mut self, silent: bool, save_ts: bool) -> Result<()> { + pub fn save(&mut self, silent: bool, save_ts: bool) -> Result<()> + where + N::TxEnvelope: Serialize, + { self.sort_receipts(); if self.transactions.is_empty() { return Ok(()); } - let Some((path, sensitive_path)) = self.paths.clone() else { return Ok(()) }; - self.timestamp = now().as_millis(); let ts_name = format!("run-{}.json", self.timestamp); - let sensitive_script_sequence: SensitiveScriptSequence = self.clone().into(); + let sensitive_script_sequence = SensitiveScriptSequence::from(&*self); + + let Some((path, sensitive_path)) = self.paths.as_ref() else { return Ok(()) }; // broadcast folder writes //../run-latest.json - let mut writer = BufWriter::new(fs::create_file(&path)?); - serde_json::to_writer_pretty(&mut writer, &self)?; - writer.flush()?; + fs::write_pretty_json_file(path, &self)?; if save_ts { //../run-[timestamp].json - fs::copy(&path, path.with_file_name(&ts_name))?; + fs::copy(path, path.with_file_name(&ts_name))?; } // cache folder writes //../run-latest.json - let mut writer = BufWriter::new(fs::create_file(&sensitive_path)?); - serde_json::to_writer_pretty(&mut writer, &sensitive_script_sequence)?; - writer.flush()?; + fs::write_sensitive_json_file(sensitive_path, &sensitive_script_sequence)?; if save_ts { //../run-[timestamp].json - fs::copy(&sensitive_path, sensitive_path.with_file_name(&ts_name))?; + fs::copy(sensitive_path, sensitive_path.with_file_name(&ts_name))?; } if !silent { @@ -145,13 +174,13 @@ impl ScriptSequence { Ok(()) } - pub fn add_receipt(&mut self, receipt: AnyTransactionReceipt) { + pub fn add_receipt(&mut self, receipt: N::ReceiptResponse) { self.receipts.push(receipt); } /// Sorts all receipts with ascending transaction index pub fn sort_receipts(&mut self) { - self.receipts.sort_by_key(|r| (r.block_number, r.transaction_index)); + self.receipts.sort_by_key(|r| (r.block_number(), r.transaction_index())); } pub fn add_pending(&mut self, index: usize, tx_hash: TxHash) { @@ -166,8 +195,8 @@ impl ScriptSequence { } /// Gets paths in the formats - /// `./broadcast/[contract_filename]/[chain_id]/[sig]-[timestamp].json` and - /// `./cache/[contract_filename]/[chain_id]/[sig]-[timestamp].json`. + /// `./broadcast/[contract_filename]/[chain_id]/[sig]-latest.json` and + /// `./cache/[contract_filename]/[chain_id]/[sig]-latest.json`. pub fn get_paths( config: &Config, sig: &str, @@ -175,8 +204,8 @@ impl ScriptSequence { chain_id: u64, dry_run: bool, ) -> Result<(PathBuf, PathBuf)> { - let mut broadcast = config.broadcast.to_path_buf(); - let mut cache = config.cache_path.to_path_buf(); + let mut broadcast = config.broadcast.clone(); + let mut cache = config.cache_path.clone(); let mut common = PathBuf::new(); let target_fname = target.source.file_name().wrap_err("No filename.")?; @@ -194,9 +223,10 @@ impl ScriptSequence { // TODO: ideally we want the name of the function here if sig is calldata let filename = sig_to_file_name(sig); + let filename_with_ext = format!("{filename}-latest.json"); - broadcast.push(format!("{filename}-latest.json")); - cache.push(format!("{filename}-latest.json")); + broadcast.push(&filename_with_ext); + cache.push(&filename_with_ext); Ok((broadcast, cache)) } @@ -207,15 +237,30 @@ impl ScriptSequence { } /// Returns the list of the transactions without the metadata. - pub fn transactions(&self) -> impl Iterator { + pub fn transactions(&self) -> impl Iterator> { self.transactions.iter().map(|tx| tx.tx()) } - pub fn fill_sensitive(&mut self, sensitive: &SensitiveScriptSequence) { - self.transactions - .iter_mut() - .enumerate() - .for_each(|(i, tx)| tx.rpc.clone_from(&sensitive.transactions[i].rpc)); + /// Copies RPC URLs from a matching sensitive-cache sequence. + pub fn fill_sensitive(&mut self, sensitive: &SensitiveScriptSequence) -> Result<()> { + if self.recovery_generation != sensitive.recovery_generation { + eyre::bail!( + "the broadcast file and its sensitive-cache counterpart belong to different recovery generations" + ); + } + let transactions_len = self.transactions.len(); + let sensitive_len = sensitive.transactions.len(); + if transactions_len != sensitive_len { + eyre::bail!( + "sensitive-cache entry count ({sensitive_len}) does not match transaction count \ + ({transactions_len}); the broadcast file and its sensitive-cache counterpart are \ + out of sync" + ); + } + for (i, tx) in self.transactions.iter_mut().enumerate() { + tx.rpc.clone_from(&sensitive.transactions[i].rpc); + } + Ok(()) } } @@ -229,11 +274,14 @@ pub fn sig_to_file_name(sig: &str) -> String { } // assume calldata if `sig` is hex if let Ok(calldata) = hex::decode(sig) { - // in which case we return the function signature - return hex::encode(&calldata[..SELECTOR_LEN]); + // in which case we return the function selector if available + if let Some(selector) = calldata.get(..SELECTOR_LEN) { + return hex::encode(selector); + } + // fallback to original string if calldata is too short to contain selector + return sig.to_string(); } - // return sig as is sig.to_string() } @@ -244,6 +292,75 @@ pub fn now() -> Duration { #[cfg(test)] mod tests { use super::*; + use alloy_network::Ethereum; + + fn sequence_with_two_transactions() -> ScriptSequence { + let mut sequence = ScriptSequence::default(); + for rpc in ["first", "second"] { + let mut tx = TransactionWithMetadata::from_tx_request( + TransactionMaybeSigned::Unsigned(Default::default()), + ); + tx.rpc = rpc.to_string(); + sequence.transactions.push_back(tx); + } + sequence + } + + #[test] + fn fill_sensitive_rejects_mismatched_counts_without_mutation() { + for count in [1, 3] { + let mut sequence = sequence_with_two_transactions(); + let sensitive = SensitiveScriptSequence { + transactions: (0..count) + .map(|_| SensitiveTransactionMetadata { rpc: "replacement".to_string() }) + .collect(), + recovery_generation: None, + }; + assert_eq!( + sequence.fill_sensitive(&sensitive).unwrap_err().to_string(), + format!( + "sensitive-cache entry count ({count}) does not match transaction count (2); \ + the broadcast file and its sensitive-cache counterpart are out of sync" + ) + ); + assert_eq!( + sequence.transactions.iter().map(|tx| tx.rpc.as_str()).collect::>(), + ["first", "second"] + ); + } + } + + #[test] + fn fill_sensitive_restores_matching_cache() { + let mut sequence = sequence_with_two_transactions(); + let sensitive = SensitiveScriptSequence { + transactions: ["restored-first", "restored-second"] + .into_iter() + .map(|rpc| SensitiveTransactionMetadata { rpc: rpc.to_string() }) + .collect(), + recovery_generation: None, + }; + sequence.fill_sensitive(&sensitive).unwrap(); + assert_eq!( + sequence.transactions.iter().map(|tx| tx.rpc.as_str()).collect::>(), + ["restored-first", "restored-second"] + ); + } + + #[test] + fn fill_sensitive_rejects_a_different_recovery_generation() { + let mut sequence = sequence_with_two_transactions(); + sequence.recovery_generation = Some(TxHash::ZERO); + let sensitive = SensitiveScriptSequence { + transactions: Default::default(), + recovery_generation: Some(TxHash::with_last_byte(1)), + }; + + assert_eq!( + sequence.fill_sensitive(&sensitive).unwrap_err().to_string(), + "the broadcast file and its sensitive-cache counterpart belong to different recovery generations" + ); + } #[test] fn can_convert_sig() { @@ -255,5 +372,20 @@ mod tests { .as_str(), "522bb704" ); + // valid calldata with 0x prefix + assert_eq!( + sig_to_file_name( + "0x522bb704000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfFFb92266" + ) + .as_str(), + "522bb704" + ); + // short calldata: should not panic and should return input as-is + assert_eq!(sig_to_file_name("0x1234").as_str(), "0x1234"); + assert_eq!(sig_to_file_name("123").as_str(), "123"); + // invalid hex: should return input as-is + assert_eq!(sig_to_file_name("0xnotahex").as_str(), "0xnotahex"); + // non-hex non-signature: should return input as-is + assert_eq!(sig_to_file_name("not_a_sig_or_hex").as_str(), "not_a_sig_or_hex"); } } diff --git a/crates/script-sequence/src/transaction.rs b/crates/script-sequence/src/transaction.rs index e27b16e5ed6a4..9f64af9628c3d 100644 --- a/crates/script-sequence/src/transaction.rs +++ b/crates/script-sequence/src/transaction.rs @@ -1,3 +1,4 @@ +use alloy_network::Network; use alloy_primitives::{Address, B256, Bytes}; use foundry_common::TransactionMaybeSigned; use revm_inspectors::tracing::types::CallKind; @@ -7,53 +8,69 @@ use serde::{Deserialize, Serialize}; #[serde(rename_all = "camelCase")] pub struct AdditionalContract { #[serde(rename = "transactionType")] - pub opcode: CallKind, + pub call_kind: CallKind, + pub contract_name: Option, pub address: Address, pub init_code: Bytes, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub creator_code_addresses: Vec
, } #[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TransactionWithMetadata { +#[serde( + rename_all = "camelCase", + bound( + serialize = "N::TransactionRequest: Serialize, N::TxEnvelope: Serialize", + deserialize = "N::TransactionRequest: for<'de2> Deserialize<'de2>, N::TxEnvelope: for<'de2> Deserialize<'de2>" + ) +)] +pub struct TransactionWithMetadata { pub hash: Option, #[serde(rename = "transactionType")] - pub opcode: CallKind, + pub call_kind: CallKind, #[serde(default = "default_string")] pub contract_name: Option, #[serde(default = "default_address")] pub contract_address: Option
, #[serde(default = "default_string")] pub function: Option, + pub function_abi: Option, + #[serde(skip)] + pub display_function: Option, #[serde(default = "default_vec_of_strings")] pub arguments: Option>, #[serde(skip)] pub rpc: String, - pub transaction: TransactionMaybeSigned, + pub transaction: TransactionMaybeSigned, + #[serde(default)] pub additional_contracts: Vec, + #[serde(default)] pub is_fixed_gas_limit: bool, } -fn default_string() -> Option { +const fn default_string() -> Option { Some(String::new()) } -fn default_address() -> Option
{ +const fn default_address() -> Option
{ Some(Address::ZERO) } -fn default_vec_of_strings() -> Option> { +const fn default_vec_of_strings() -> Option> { Some(vec![]) } -impl TransactionWithMetadata { - pub fn from_tx_request(transaction: TransactionMaybeSigned) -> Self { +impl TransactionWithMetadata { + pub fn from_tx_request(transaction: TransactionMaybeSigned) -> Self { Self { transaction, hash: Default::default(), - opcode: Default::default(), + call_kind: Default::default(), contract_name: Default::default(), contract_address: Default::default(), function: Default::default(), + function_abi: Default::default(), + display_function: Default::default(), arguments: Default::default(), is_fixed_gas_limit: Default::default(), additional_contracts: Default::default(), @@ -61,15 +78,46 @@ impl TransactionWithMetadata { } } - pub fn tx(&self) -> &TransactionMaybeSigned { + pub const fn tx(&self) -> &TransactionMaybeSigned { &self.transaction } - pub fn tx_mut(&mut self) -> &mut TransactionMaybeSigned { + pub const fn tx_mut(&mut self) -> &mut TransactionMaybeSigned { &mut self.transaction } pub fn is_create2(&self) -> bool { - self.opcode == CallKind::Create2 + self.call_kind == CallKind::Create2 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn additional_contract_creator_code_addresses_are_backward_compatible() { + let old_json = serde_json::json!({ + "transactionType": "CREATE", + "contractName": null, + "address": Address::repeat_byte(0x11), + "initCode": "0x6000" + }); + let contract: AdditionalContract = serde_json::from_value(old_json.clone()).unwrap(); + assert!(contract.creator_code_addresses.is_empty()); + assert_eq!(serde_json::to_value(contract).unwrap(), old_json); + + let creator = Address::repeat_byte(0x22); + let contract = AdditionalContract { + call_kind: CallKind::Create2, + contract_name: None, + address: Address::repeat_byte(0x33), + init_code: Bytes::new(), + creator_code_addresses: vec![creator], + }; + assert_eq!( + serde_json::to_value(contract).unwrap()["creatorCodeAddresses"], + serde_json::json!([creator]) + ); } } diff --git a/crates/script/Cargo.toml b/crates/script/Cargo.toml index b258da28d02f4..ddd5b2fa96a61 100644 --- a/crates/script/Cargo.toml +++ b/crates/script/Cargo.toml @@ -19,35 +19,35 @@ foundry-cli.workspace = true foundry-config.workspace = true foundry-common.workspace = true foundry-evm.workspace = true +foundry-evm-networks.workspace = true +alloy-evm.workspace = true foundry-debugger.workspace = true foundry-cheatcodes.workspace = true -foundry-wallets.workspace = true +foundry-wallets = { workspace = true, features = ["browser", "tempo"] } foundry-linking.workspace = true -foundry-block-explorers.workspace = true forge-script-sequence.workspace = true serde.workspace = true eyre.workspace = true serde_json.workspace = true -dunce.workspace = true -foundry-compilers = { workspace = true, features = ["full"] } +foundry-compilers.workspace = true +reqwest = { workspace = true, features = ["stream"] } tracing.workspace = true clap = { version = "4", features = ["derive", "env", "unicode", "wrap_help"] } semver.workspace = true futures.workspace = true -tokio.workspace = true +tokio = { workspace = true, features = ["io-util", "process", "time"] } +tempfile.workspace = true itertools.workspace = true parking_lot.workspace = true yansi.workspace = true -revm-inspectors.workspace = true alloy-rpc-types.workspace = true alloy-json-abi.workspace = true -dialoguer = { version = "0.11", default-features = false } +dialoguer.workspace = true indicatif.workspace = true alloy-signer.workspace = true -alloy-serde.workspace = true alloy-network.workspace = true alloy-provider.workspace = true alloy-chains.workspace = true @@ -55,6 +55,50 @@ alloy-dyn-abi.workspace = true alloy-primitives.workspace = true alloy-eips.workspace = true alloy-consensus.workspace = true +thiserror.workspace = true + +tempo-alloy.workspace = true +tempo-primitives.workspace = true + +# monad +monad-revm = { workspace = true, optional = true } [dev-dependencies] -tempfile.workspace = true +anvil.workspace = true +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] } +alloy-signer-local.workspace = true + +[features] +default = ["optimism"] +monad = [ + "dep:monad-revm", + "anvil/monad", + "foundry-config/monad", + "foundry-evm/monad", + "foundry-evm-networks/monad", + "foundry-common/monad", + "foundry-cheatcodes/monad", + "foundry-cli/monad", + "forge-script-sequence/monad", + "forge-verify/monad", +] +base = [ + "anvil/base", + "foundry-evm/base", + "foundry-evm-networks/base", + "foundry-common/base", + "foundry-cheatcodes/base", + "foundry-cli/base", + "foundry-debugger/base", + "forge-script-sequence/base", + "forge-verify/base", +] +optimism = [ + "foundry-evm/optimism", + "foundry-evm-networks/optimism", + "foundry-common/optimism", + "foundry-cheatcodes/optimism", + "foundry-cli/optimism", + "forge-script-sequence/optimism", + "forge-verify/optimism", +] diff --git a/crates/script/src/broadcast.rs b/crates/script/src/broadcast.rs index f62387e532d52..7d2be4cbf54f9 100644 --- a/crates/script/src/broadcast.rs +++ b/crates/script/src/broadcast.rs @@ -1,88 +1,134 @@ +use std::{cmp::Ordering, num::NonZeroU64, sync::Arc, time::Duration}; + use crate::{ - ScriptArgs, ScriptConfig, build::LinkedBuildData, progress::ScriptProgress, - sequence::ScriptSequenceKind, verify::BroadcastedState, + ScriptArgs, ScriptConfig, + build::LinkedBuildData, + progress::ScriptProgress, + receipts::is_mined_receipt_for, + recovery::{AttemptKind, DelegatedStatus}, + sequence::{ScriptSequenceKind, completed_transaction_prefix, operation_ordinal}, + session::{ + SignerScope, insert_session_access_key_for_remaining_transactions, + script_session_expected_sender_if_configured, + }, + verify::BroadcastedState, }; use alloy_chains::{Chain, NamedChain}; -use alloy_consensus::TxEnvelope; -use alloy_eips::{BlockId, eip2718::Encodable2718}; -use alloy_network::{AnyNetwork, EthereumWallet, TransactionBuilder}; +use alloy_consensus::{SignableTransaction, Signed, transaction::SignerRecoverable}; +use alloy_eips::eip2718::{Decodable2718, Encodable2718}; +use alloy_network::{ + EthereumWallet, Network, NetworkTransactionBuilder, ReceiptResponse, TransactionBuilder, + TransactionResponse, +}; use alloy_primitives::{ - Address, TxHash, - map::{AddressHashMap, AddressHashSet}, + Address, Bytes, TxHash, TxKind, U256, keccak256, + map::{AddressHashMap, AddressHashSet, HashMap, HashSet}, utils::format_units, }; -use alloy_provider::{Provider, utils::Eip1559Estimation}; +use alloy_provider::{ + Provider, RootProvider, + transport::{RpcError, TransportError}, + utils::Eip1559Estimation, +}; use alloy_rpc_types::TransactionRequest; -use alloy_serde::WithOtherFields; -use eyre::{Context, Result, bail}; -use forge_verify::provider::VerificationProviderType; +use alloy_signer::Signature; +use eyre::{Context, ContextCompat, Result, bail}; +use forge_script_sequence::{ScriptSequence, TransactionWithMetadata}; use foundry_cheatcodes::Wallets; use foundry_cli::utils::{has_batch_support, has_different_gas_calc}; use foundry_common::{ - TransactionMaybeSigned, - provider::{RetryProvider, get_http_provider, try_get_http_provider}, + FoundryTransactionBuilder, TransactionMaybeSigned, + provider::{ + ProviderBuilder, + fee::{estimate_eip1559_fees, resolve_broadcast_eip1559_fees}, + }, shell, + tempo::{ + TempoSponsor, active_tempo_hardfork, maybe_print_fee_token, resolve_and_set_fee_token, + }, }; use foundry_config::Config; -use futures::{StreamExt, future::join_all}; +use foundry_evm::{ + core::{ + constants::DEFAULT_CREATE2_DEPLOYER_CODEHASH, + evm::{FoundryEvmNetwork, TempoEvmNetwork}, + }, + hardfork::TempoHardfork, + traces::CallKind, +}; +use foundry_wallets::{ + TempoAccountsWallet, + wallet_browser::{error::BrowserWalletError, signer::BrowserSigner}, +}; +use futures::{FutureExt, StreamExt, future::join_all, stream::FuturesUnordered}; use itertools::Itertools; -use std::{cmp::Ordering, sync::Arc}; - -pub async fn estimate_gas>( - tx: &mut WithOtherFields, - provider: &P, - estimate_multiplier: u64, -) -> Result<()> { - // if already set, some RPC endpoints might simply return the gas value that is already - // set in the request and omit the estimate altogether, so we remove it here - tx.gas = None; +use tempo_alloy::{ + TempoNetwork, + rpc::{TempoTransactionReceipt, TempoTransactionRequest}, +}; +use tempo_primitives::transaction::{Call, TEMPO_EXPIRING_NONCE_KEY, TempoTxEnvelope}; - tx.set_gas_limit( - provider.estimate_gas(tx.clone()).await.wrap_err("Failed to estimate gas for tx")? - * estimate_multiplier - / 100, - ); - Ok(()) +/// Represents how to send a single transaction. +#[derive(Clone)] +pub enum SendTransactionKind<'a, N: Network> { + Unlocked(N::TransactionRequest), + Raw(N::TransactionRequest, &'a EthereumWallet), + Browser(N::TransactionRequest, &'a BrowserSigner), + Signed(N::TxEnvelope), + AccessKey(N::TransactionRequest, Box), + PreparedRaw(Bytes, TxHash), } -pub async fn next_nonce( - caller: Address, - provider_url: &str, - block_number: Option, -) -> eyre::Result { - let provider = try_get_http_provider(provider_url) - .wrap_err_with(|| format!("bad fork_url provider: {provider_url}"))?; +impl<'a, N: Network> SendTransactionKind<'a, N> +where + N::TxEnvelope: From>, + N::UnsignedTx: SignableTransaction, + N::TransactionRequest: FoundryTransactionBuilder, +{ + /// Prepares the transaction for broadcasting by synchronizing nonce and estimating gas. + /// + /// This method performs two key operations: + /// 1. Nonce synchronization: Waits for the provider's nonce to catch up to the expected + /// transaction nonce when doing sequential broadcast + /// 2. Gas estimation: Re-estimates gas right before broadcasting for chains that require it + #[allow(clippy::too_many_arguments)] + pub async fn prepare( + &mut self, + provider: &RootProvider, + sequential_broadcast: bool, + is_fixed_gas_limit: bool, + estimate_via_rpc: bool, + estimate_multiplier: u64, + tempo_sponsor: Option<&TempoSponsor>, + ) -> Result<()> { + let browser = matches!(self, Self::Browser(..)); + let (tx, tempo_wallet) = match self { + Self::Raw(tx, _) | Self::Unlocked(tx) | Self::Browser(tx, _) => (tx, None), + Self::AccessKey(tx, wallet) => (tx, Some(wallet)), + Self::Signed(_) | Self::PreparedRaw(..) => return Ok(()), + }; - let block_id = block_number.map_or(BlockId::latest(), BlockId::number); - Ok(provider.get_transaction_count(caller).block_id(block_id).await?) -} + reject_access_key_create::(tx, tempo_wallet.is_some())?; -pub async fn send_transaction( - provider: Arc, - mut kind: SendTransactionKind<'_>, - sequential_broadcast: bool, - is_fixed_gas_limit: bool, - estimate_via_rpc: bool, - estimate_multiplier: u64, -) -> Result { - if let SendTransactionKind::Raw(tx, _) | SendTransactionKind::Unlocked(tx) = &mut kind { - if sequential_broadcast { - let from = tx.from.expect("no sender"); + // An expiring nonce is a TIP-1106 discriminator rather than a position in the sender's + // nonce sequence, so there is no provider nonce to wait for. + if sequential_broadcast && tx.nonce_key() != Some(TEMPO_EXPIRING_NONCE_KEY) { + let from = tx.from().expect("no sender"); - let tx_nonce = tx.nonce.expect("no nonce"); + let tx_nonce = tx.nonce().expect("no nonce"); for attempt in 0..5 { let nonce = provider.get_transaction_count(from).await?; match nonce.cmp(&tx_nonce) { Ordering::Greater => { bail!( "EOA nonce changed unexpectedly while sending transactions. Expected {tx_nonce} got {nonce} from provider." - ) + ); } Ordering::Less => { if attempt == 4 { bail!( "After 5 attempts, provider nonce ({nonce}) is still behind expected nonce ({tx_nonce})." - ) + ); } warn!( "Expected nonce ({tx_nonce}) is ahead of provider nonce ({nonce}). Retrying in 1 second..." @@ -90,80 +136,438 @@ pub async fn send_transaction( tokio::time::sleep(std::time::Duration::from_millis(1000)).await; } Ordering::Equal => { - // Nonces are equal, we can proceed + // Nonces are equal, we can proceed. break; } } } } + if let Some(wallet) = tempo_wallet { + **wallet = tx.prepare_with_tempo_wallet(provider, wallet).await?; + } + + let fee_token = if let Some(sponsor) = tempo_sponsor { + sponsor.resolve_and_set_fee_token(Some(provider), tx).await?; + None + } else { + resolve_and_set_fee_token(Some(provider), tx, tx.from()).await? + }; + + // A fee token, sponsor, validity window, or other Tempo field selects + // the AA transaction type. AA requests carry CREATE as their first + // call rather than as the Ethereum transaction `to` field. + convert_tempo_aa_create::(tx); + // Chains which use `eth_estimateGas` are being sent sequentially and require their // gas to be re-estimated right before broadcasting. if !is_fixed_gas_limit && estimate_via_rpc { - estimate_gas(tx, &provider, estimate_multiplier).await?; + estimate_gas(tx, provider, estimate_multiplier, browser).await?; + } + + if let Some(sponsor) = tempo_sponsor { + let from = tx.from().expect("no sender"); + sponsor.attach_and_print::(tx, from).await?; + } else { + maybe_print_fee_token(Some(provider), fee_token).await?; + } + + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + async fn prepare_for_durable_send( + mut self, + provider: &RootProvider, + sequential_broadcast: bool, + is_fixed_gas_limit: bool, + estimate_via_rpc: bool, + estimate_multiplier: u64, + tempo_sponsor: Option<&TempoSponsor>, + ) -> Result> { + self.prepare( + provider, + sequential_broadcast, + is_fixed_gas_limit, + estimate_via_rpc, + estimate_multiplier, + tempo_sponsor, + ) + .await?; + if let Self::Unlocked(tx) = &mut self { + tx.prep_for_submission(); + } + Ok(match self { + Self::Raw(tx, signer) => { + let signed = tx.build(signer).await?; + PreparedTransaction::Raw(Bytes::from(signed.encoded_2718()), signed.trie_hash()) + } + Self::Signed(tx) => { + let hash = tx.trie_hash(); + PreparedTransaction::Raw(Bytes::from(tx.encoded_2718()), hash) + } + Self::AccessKey(tx, wallet) => { + let payload = Bytes::from(tx.sign_with_tempo_wallet(&wallet).await?); + let envelope = N::TxEnvelope::decode_2718_exact(&payload)?; + PreparedTransaction::Raw(payload, envelope.trie_hash()) + } + Self::PreparedRaw(payload, hash) => PreparedTransaction::Raw(payload, hash), + Self::Unlocked(request) => PreparedTransaction::Unlocked(request), + Self::Browser(request, signer) => PreparedTransaction::Browser(request, signer), + }) + } + + /// CREATE uses the protocol account nonce even when the signed nonce is a discriminator. + async fn validate_expiring_create_nonce( + &self, + provider: &RootProvider, + planned: &TransactionWithMetadata, + ) -> Result<()> { + if planned.tx().to().is_some() { + return Ok(()); + } + let nonce_key = match self { + Self::Raw(tx, _) + | Self::Unlocked(tx) + | Self::Browser(tx, _) + | Self::AccessKey(tx, _) => tx.nonce_key(), + Self::Signed(tx) => { + >::from(tx.clone()).nonce_key() + } + Self::PreparedRaw(payload, _) => >::from( + N::TxEnvelope::decode_2718_exact(payload)?, + ) + .nonce_key(), + }; + if nonce_key == Some(TEMPO_EXPIRING_NONCE_KEY) { + let sender = planned.tx().from().context("CREATE is missing its planned sender")?; + let expected = match planned.tx() { + TransactionMaybeSigned::Signed { .. } => { + planned.contract_address.context("CREATE is missing its planned address")? + } + TransactionMaybeSigned::Unsigned(tx) => sender + .create(tx.nonce().context("CREATE is missing its planned account nonce")?), + }; + let actual = sender.create(provider.get_transaction_count(sender).await?); + if actual != expected { + bail!( + "CREATE address changed unexpectedly while sending transactions. Expected {expected} got {actual} from provider nonce." + ); + } } + Ok(()) } +} - let pending = match kind { - SendTransactionKind::Unlocked(tx) => { - debug!("sending transaction from unlocked account {:?}", tx); +/// A prepared submission, with local signatures fixed before checkpointing and sending. +#[derive(Clone)] +enum PreparedTransaction<'a, N: Network> { + Raw(Bytes, TxHash), + Unlocked(N::TransactionRequest), + Browser(N::TransactionRequest, &'a BrowserSigner), +} - // Submit the transaction - provider.send_transaction(tx).await? +impl PreparedTransaction<'_, N> +where + N::TransactionRequest: FoundryTransactionBuilder, +{ + const fn delegated_request(&self) -> Option<&N::TransactionRequest> { + match self { + Self::Unlocked(request) | Self::Browser(request, _) => Some(request), + _ => None, } - SendTransactionKind::Raw(tx, signer) => { - debug!("sending transaction: {:?}", tx); - let signed = tx.build(signer).await?; + } - // Submit the raw transaction - provider.send_raw_transaction(signed.encoded_2718().as_ref()).await? + fn validate_delegated_submission(&self) -> Result<()> { + let Self::Browser(request, signer) = self else { return Ok(()) }; + if request.from().is_some_and(|from| from != signer.address()) { + bail!("Transaction `from` address does not match connected wallet address"); } - SendTransactionKind::Signed(tx) => { - debug!("sending transaction: {:?}", tx); - provider.send_raw_transaction(tx.encoded_2718().as_ref()).await? + if request.chain_id().is_some_and(|chain_id| chain_id != signer.chain_id()) { + bail!("Transaction `chainId` does not match connected wallet chain ID"); } + Ok(()) + } + + fn is_definite_non_submission(&self, error: &eyre::Report) -> bool { + match self { + Self::Browser(..) => is_definite_browser_non_submission(error), + Self::Unlocked(_) => is_definite_unlocked_non_submission(error), + _ => false, + } + } + + async fn send(self, provider: Arc>) -> Result { + match self { + Self::Raw(payload, _) => { + let pending = provider.send_raw_transaction(&payload).await?; + Ok(*pending.tx_hash()) + } + Self::Unlocked(tx) => { + debug!("sending transaction from unlocked account {:?}", tx); + let pending = provider.send_transaction(tx).await?; + Ok(*pending.tx_hash()) + } + Self::Browser(tx, signer) => { + debug!("sending transaction: {:?}", tx); + Ok(signer.send_transaction_via_browser(tx).await?) + } + } + } +} + +fn is_definite_browser_non_submission(error: &eyre::Report) -> bool { + let Some(alloy_signer::Error::Other(error)) = error.downcast_ref::() + else { + return false; }; + matches!( + error.downcast_ref::(), + Some(BrowserWalletError::Rejected { .. } | BrowserWalletError::NotConnected) + ) +} - Ok(*pending.tx_hash()) +fn is_definite_unlocked_non_submission(error: &eyre::Report) -> bool { + matches!( + error.downcast_ref::(), + Some(RpcError::ErrorResp(error)) + if is_unlocked_non_submission(error.code, &error.message) + ) } -/// How to send a single transaction -#[derive(Clone)] -pub enum SendTransactionKind<'a> { - Unlocked(WithOtherFields), - Raw(WithOtherFields, &'a EthereumWallet), - Signed(TxEnvelope), +fn is_unlocked_non_submission(code: i64, message: &str) -> bool { + code == -32602 && message == "No Signer available" +} + +fn tempo_batch_calls( + sequence: &ScriptSequence, + first_operation: usize, +) -> Result> { + sequence + .transactions() + .skip(first_operation) + .enumerate() + .map(|(call_index, tx)| { + if tx.authorization_list().is_some_and(|list| !list.is_empty()) { + bail!( + "--batch does not support EIP-7702 authorization lists \ + (found at transaction {}); use regular broadcast instead.", + call_index + first_operation + 1 + ); + } + if let TransactionMaybeSigned::Unsigned(inner) = tx + && inner.blob_sidecar().is_some() + { + bail!( + "--batch does not support blob (EIP-4844) transactions \ + (found at transaction {}); use regular broadcast instead.", + call_index + first_operation + 1 + ); + } + let to = tx.to().map(TxKind::Call).ok_or_else(|| { + eyre::eyre!( + "Unexpected raw CREATE in --batch mode at position {} — \ + this is a bug; CREATEs should have been rewritten by the inspector.", + call_index + first_operation + 1 + ) + })?; + Ok(Call { + to, + value: tx.value().unwrap_or(U256::ZERO), + input: tx.input().cloned().unwrap_or_default(), + }) + }) + .collect() +} + +fn validate_tempo_batch_request( + request: &TempoTransactionRequest, + sender: Address, + chain: u64, + calls: &[Call], +) -> Result<()> { + if request.inner.from != Some(sender) { + bail!("batch request does not match its planned sender"); + } + let expected = request + .clone() + .build_aa() + .map_err(|error| eyre::eyre!("invalid persisted batch request: {error}"))?; + if expected.chain_id != chain || expected.calls != calls { + bail!("batch request does not match its planned operations"); + } + Ok(()) +} + +fn validate_tempo_batch_payload( + request: &TempoTransactionRequest, + payload: &[u8], + sender: Address, + chain: u64, + calls: &[Call], +) -> Result { + validate_tempo_batch_request(request, sender, chain, calls)?; + let expected = request + .clone() + .build_aa() + .map_err(|error| eyre::eyre!("invalid persisted batch request: {error}"))?; + let envelope = TempoTxEnvelope::decode_2718_exact(payload) + .wrap_err("recovery plan contains an invalid signed batch payload")?; + validate_tempo_batch_envelope(&envelope, sender, chain, calls)?; + let TempoTxEnvelope::AA(signed) = &envelope else { + unreachable!(); + }; + if signed.tx() != &expected { + bail!("signed batch payload does not match its persisted request"); + } + Ok(envelope.trie_hash()) +} + +fn validate_tempo_batch_envelope( + envelope: &TempoTxEnvelope, + sender: Address, + chain: u64, + calls: &[Call], +) -> Result<()> { + let TempoTxEnvelope::AA(signed) = envelope else { + bail!("recovered batch transaction is not a Tempo transaction"); + }; + if signed.recover_signer().wrap_err("recovered batch transaction has an invalid signature")? + != sender + { + bail!("recovered batch transaction does not match its planned sender"); + } + if signed.tx().chain_id != chain || signed.tx().calls != calls { + bail!("recovered batch transaction does not match its planned operations"); + } + Ok(()) +} + +pub(crate) fn remaining_unsigned_transactions_for_recovery( + sequence: &ScriptSequenceKind, +) -> Vec +where + N::TxEnvelope: for<'de> serde::Deserialize<'de> + serde::Serialize, +{ + sequence + .sequences() + .iter() + .enumerate() + .filter(|(sequence_index, _)| sequence.batch_attempt(*sequence_index).is_none()) + .flat_map(|(sequence_index, deployment)| { + remaining_operation_indices(sequence, sequence_index).into_iter().filter_map( + move |index| { + let tx = deployment.transactions[index].tx(); + (tx.is_unsigned() && sequence.signed_payload(sequence_index, index).is_none()) + .then(|| SignerScope { + chain: deployment.chain, + sender: tx.from().expect("missing from"), + }) + }, + ) + }) + .collect() +} + +fn remaining_operation_indices( + sequence: &ScriptSequenceKind, + sequence_index: usize, +) -> Vec +where + N::TxEnvelope: for<'de> serde::Deserialize<'de> + serde::Serialize, +{ + let deployment = &sequence.sequences()[sequence_index]; + deployment + .transactions + .iter() + .enumerate() + .filter_map(|(index, transaction)| { + let hash = sequence + .signed_payload(sequence_index, index) + .map(|signed| signed.hash) + .or_else(|| match sequence.delegated_status(sequence_index, index) { + Some(DelegatedStatus::Pending { hash }) => Some(hash), + _ => None, + }) + .or(transaction.hash) + .or_else(|| match transaction.tx() { + TransactionMaybeSigned::Signed { tx, .. } => Some(tx.trie_hash()), + TransactionMaybeSigned::Unsigned(_) => None, + }); + let completed = hash.is_some_and(|hash| { + deployment.receipts.iter().any(|receipt| receipt.transaction_hash() == hash) + }); + (!completed).then_some(index) + }) + .collect() +} + +fn remaining_sender_addresses(sequence: &ScriptSequenceKind) -> AddressHashSet +where + N::TxEnvelope: for<'de> serde::Deserialize<'de> + serde::Serialize, +{ + sequence + .sequences() + .iter() + .enumerate() + .flat_map(|(sequence_index, deployment)| { + remaining_operation_indices(sequence, sequence_index) + .into_iter() + .filter_map(|index| deployment.transactions[index].tx().from()) + }) + .collect() +} + +const fn should_broadcast_sequentially( + estimate_via_rpc: bool, + slow: bool, + required_signers: usize, + ordering_senders: usize, + batch_supported: bool, +) -> bool { + estimate_via_rpc || slow || required_signers == 0 || ordering_senders != 1 || !batch_supported } /// Represents how to send _all_ transactions -pub enum SendTransactionsKind { +pub enum SendTransactionsKind { /// Send via `eth_sendTransaction` and rely on the `from` address being unlocked. Unlocked(AddressHashSet), - /// Send a signed transaction via `eth_sendRawTransaction` - Raw(AddressHashMap), + /// Send a signed transaction via `eth_sendRawTransaction`, or via browser + Raw { + eth_wallets: AddressHashMap, + browser: Option>, + access_keys: HashMap, + }, } -impl SendTransactionsKind { +impl SendTransactionsKind { /// Returns the [`SendTransactionKind`] for the given address /// /// Returns an error if no matching signer is found or the address is not unlocked pub fn for_sender( &self, + chain: u64, addr: &Address, - tx: WithOtherFields, - ) -> Result> { + tx: N::TransactionRequest, + ) -> Result> { match self { Self::Unlocked(unlocked) => { if !unlocked.contains(addr) { - bail!("Sender address {:?} is not unlocked", addr) + bail!("Sender address {:?} is not unlocked", addr); } Ok(SendTransactionKind::Unlocked(tx)) } - Self::Raw(wallets) => { - if let Some(wallet) = wallets.get(addr) { + Self::Raw { eth_wallets, browser, access_keys } => { + if let Some(wallet) = access_keys.get(&SignerScope::new(chain, *addr)) { + Ok(SendTransactionKind::AccessKey(tx, Box::new(wallet.clone()))) + } else if let Some(wallet) = eth_wallets.get(addr) { Ok(SendTransactionKind::Raw(tx, wallet)) + } else if let Some(b) = browser + && b.address() == *addr + { + Ok(SendTransactionKind::Browser(tx, b)) } else { - bail!("No matching signer for {:?} found", addr) + bail!("No matching signer for {:?} found", addr); } } } @@ -171,34 +575,43 @@ impl SendTransactionsKind { } /// State after we have bundled all -/// [`TransactionWithMetadata`](forge_script_sequence::TransactionWithMetadata) objects into a -/// single [`ScriptSequenceKind`] object containing one or more script sequences. -pub struct BundledState { +/// [`TransactionWithMetadata`] objects into a single [`ScriptSequenceKind`] object containing one +/// or more script sequences. +pub struct BundledState { pub args: ScriptArgs, - pub script_config: ScriptConfig, + pub script_config: ScriptConfig, pub script_wallets: Wallets, + pub browser_wallet: Option>, pub build_data: LinkedBuildData, - pub sequence: ScriptSequenceKind, + pub sequence: ScriptSequenceKind, } -impl BundledState { +impl BundledState { pub async fn wait_for_pending(mut self) -> Result { let progress = ScriptProgress::default(); let progress_ref = &progress; + let config = &self.script_config.config; + let submission_hashes = (0..self.sequence.sequences().len()) + .map(|sequence| self.sequence.submission_hashes(sequence)) + .collect::>(); let futs = self .sequence .sequences_mut() .iter_mut() + .zip(submission_hashes) .enumerate() - .map(|(sequence_idx, sequence)| async move { + .map(|(sequence_idx, (sequence, (durable_hashes, replayable_hashes)))| async move { let rpc_url = sequence.rpc_url(); - let provider = Arc::new(get_http_provider(rpc_url)); + let provider = + Arc::new(ProviderBuilder::from_config_with_url(config, rpc_url)?.build()?); progress_ref .wait_for_pending( sequence_idx, sequence, &provider, self.script_config.config.transaction_timeout, + self.args.confirmations, + (&durable_hashes, &replayable_hashes), ) .await }) @@ -216,18 +629,20 @@ impl BundledState { } /// Broadcasts transactions from all sequences. - pub async fn broadcast(mut self) -> Result { - let required_addresses = self - .sequence - .sequences() - .iter() - .flat_map(|sequence| { - sequence - .transactions() - .filter(|tx| tx.is_unsigned()) - .map(|tx| tx.from().expect("missing from")) - }) - .collect::(); + pub async fn broadcast(mut self) -> Result> { + if self.script_config.tempo.expiring_nonce { + reject_expiring_call_before_create(self.sequence.sequences())?; + } + let remaining_transactions = remaining_unsigned_transactions_for_recovery(&self.sequence); + let ordering_addresses = remaining_sender_addresses(&self.sequence); + let has_unprepared_transactions = + self.sequence.sequences().iter().enumerate().any(|(sequence_index, _)| { + remaining_operation_indices(&self.sequence, sequence_index) + .into_iter() + .any(|index| self.sequence.signed_payload(sequence_index, index).is_none()) + }); + let required_addresses = + remaining_transactions.iter().map(|tx| tx.sender).collect::(); if required_addresses.contains(&Config::DEFAULT_SENDER) { eyre::bail!( @@ -235,198 +650,449 @@ impl BundledState { ); } - let send_kind = if self.args.unlocked { + let send_kind = if required_addresses.is_empty() { + SendTransactionsKind::Raw { + eth_wallets: AddressHashMap::default(), + browser: None, + access_keys: HashMap::default(), + } + } else if self.args.unlocked { SendTransactionsKind::Unlocked(required_addresses.clone()) } else { - let signers = self.script_wallets.into_multi_wallet().into_signers()?; + let expected_session_sender = script_session_expected_sender_if_configured( + &self.script_config.tempo, + &required_addresses, + )?; + + // For addresses without an explicit signer, try the Tempo Accounts store. + let mut access_keys: HashMap = HashMap::default(); + if let Some(expected_session_sender) = expected_session_sender + && let Some(session) = + self.script_config.tempo.session_signer_for_multi_wallet_any_chain( + &self.args.wallets, + Some(expected_session_sender), + )? + { + insert_session_access_key_for_remaining_transactions( + &mut access_keys, + session, + &remaining_transactions, + )?; + } + + let signers: Vec
= self + .script_wallets + .signers() + .map_err(|e| eyre::eyre!("{e}"))? + .into_iter() + .chain(self.browser_wallet.as_ref().map(|b| b.address())) + .collect(); + let mut missing_addresses = Vec::new(); + let accounts_wallet = self + .script_config + .evm_opts + .networks + .is_tempo() + .then(TempoAccountsWallet::try_from_default_store) + .transpose()? + .flatten(); - for addr in &required_addresses { - if !signers.contains_key(addr) { - missing_addresses.push(addr); + for tx in &remaining_transactions { + let scope = *tx; + if !signers.contains(&tx.sender) && !access_keys.contains_key(&scope) { + if let Some(wallet) = accounts_wallet.as_ref() + && wallet.has_account(tx.sender)? + { + access_keys.insert(scope, wallet.clone().with_chain_id(tx.chain)); + } else { + missing_addresses.push(tx.sender); + } } } + missing_addresses.sort_unstable(); + missing_addresses.dedup(); + if !missing_addresses.is_empty() { eyre::bail!( "No associated wallet for addresses: {:?}. Unlocked wallets: {:?}", missing_addresses, - signers.keys().collect::>() + signers ); } - let signers = signers - .into_iter() - .map(|(addr, signer)| (addr, EthereumWallet::new(signer))) - .collect(); + let signers = self.script_wallets.into_multi_wallet().into_signers()?; + let eth_wallets: AddressHashMap = + signers.into_iter().map(|(addr, signer)| (addr, signer.into())).collect(); + + SendTransactionsKind::Raw { eth_wallets, browser: self.browser_wallet, access_keys } + }; - SendTransactionsKind::Raw(signers) + let tempo_sponsor = if has_unprepared_transactions { + self.script_config.tempo.sponsor_config().await?.map(Arc::new) + } else { + None }; + if tempo_sponsor.is_some() + && self.script_config.tempo.sponsor_sig.is_some() + && remaining_transactions.len() > 1 + { + eyre::bail!( + "--tempo.sponsor-sig can only sponsor one remaining script transaction; use --tempo.sponsor-signer for multi-transaction scripts" + ); + } let progress = ScriptProgress::default(); for i in 0..self.sequence.sequences().len() { + let sequence_offset = operation_ordinal(self.sequence.sequences(), i, 0); + let remaining_indices = remaining_operation_indices(&self.sequence, i); + let signed_payloads = (0..self.sequence.sequences()[i].transactions.len()) + .map(|index| { + self.sequence + .signed_payload(i, index) + .map(|signed| (signed.payload.clone(), signed.hash)) + }) + .collect::>(); let mut sequence = self.sequence.sequences_mut().get_mut(i).unwrap(); - let provider = Arc::new(try_get_http_provider(sequence.rpc_url())?); - let already_broadcasted = sequence.receipts.len(); + let provider = Arc::new( + ProviderBuilder::from_config_with_url( + &self.script_config.config, + sequence.rpc_url(), + )? + .build()?, + ); + let unlocked_submission_provider = Arc::new( + ProviderBuilder::from_config_with_url( + &self.script_config.config, + sequence.rpc_url(), + )? + .max_retry(0) + .build()?, + ); let seq_progress = progress.get_sequence_progress(i, sequence); - if already_broadcasted < sequence.transactions.len() { + if !remaining_indices.is_empty() { let is_legacy = Chain::from(sequence.chain).is_legacy() || self.args.legacy; // Make a one-time gas price estimation + let all_signed = + remaining_indices.iter().all(|index| signed_payloads[*index].is_some()); let (gas_price, eip1559_fees) = match ( + all_signed, is_legacy, self.args.with_gas_price, self.args.priority_gas_price, ) { - (true, Some(gas_price), _) => (Some(gas_price.to()), None), - (true, None, _) => (Some(provider.get_gas_price().await?), None), - (false, Some(max_fee_per_gas), Some(max_priority_fee_per_gas)) => ( - None, - Some(Eip1559Estimation { - max_fee_per_gas: max_fee_per_gas.to(), - max_priority_fee_per_gas: max_priority_fee_per_gas.to(), - }), - ), - (false, _, _) => { - let mut fees = provider.estimate_eip1559_fees().await.wrap_err("Failed to estimate EIP1559 fees. This chain might not support EIP1559, try adding --legacy to your command.")?; - - if let Some(gas_price) = self.args.with_gas_price { - fees.max_fee_per_gas = gas_price.to(); + (true, _, _, _) => (None, None), + (false, true, Some(gas_price), _) => (Some(gas_price.to()), None), + (false, true, None, _) => (Some(provider.get_gas_price().await?), None), + (false, false, Some(max_fee_per_gas), Some(max_priority_fee_per_gas)) => { + let max_fee: u128 = max_fee_per_gas.to(); + let max_priority: u128 = max_priority_fee_per_gas.to(); + if max_priority > max_fee { + eyre::bail!( + "--priority-gas-price ({max_priority}) cannot be higher than --with-gas-price ({max_fee})" + ); } + ( + None, + Some(Eip1559Estimation { + max_fee_per_gas: max_fee, + max_priority_fee_per_gas: max_priority, + }), + ) + } + (false, false, _, _) => { + let fees = estimate_eip1559_fees( + &provider, + self.script_config.config.eip1559_fee_estimate, + ) + .await + .wrap_err("Failed to estimate EIP1559 fees. This chain might not support EIP1559, try adding --legacy to your command.")?; - if let Some(priority_gas_price) = self.args.priority_gas_price { - fees.max_priority_fee_per_gas = priority_gas_price.to(); - } + // Browser wallets may suggest their own tip; query it best-effort. + let browser_suggested_tip = if matches!( + &send_kind, + SendTransactionsKind::Raw { browser: Some(_), .. } + ) { + provider.get_max_priority_fee_per_gas().await.ok() + } else { + None + }; - (None, Some(fees)) + let fees = resolve_broadcast_eip1559_fees( + fees, + self.args.with_gas_price.map(|p| p.to()), + self.args.priority_gas_price.map(|p| p.to()), + browser_suggested_tip, + )?; + + (None, Some(fees.estimation())) } }; + // TIP-1106: from T12 the nonce of an expiring nonce transaction is an opaque + // discriminator. Each transaction gets its ordinal in the complete script plan, + // which is monotonic and stable across `--resume`, so that + // otherwise identical transactions keep distinct signing and replay + // hashes. Earlier hardforks only accept 0, which is also the + // fallback when the hardfork cannot be determined. + let tempo_hardfork = if self.script_config.evm_opts.networks.is_tempo() { + active_tempo_hardfork(provider.as_ref()).await.ok() + } else { + None + }; + let expiring_nonce_discriminators = self.script_config.tempo.expiring_nonce + && tempo_hardfork.is_some_and(|fork| fork >= TempoHardfork::T12); + // Iterate through transactions, matching the `from` field with the associated // wallet. Then send the transaction. Panics if we find a unknown `from` - let transactions = sequence - .transactions - .iter() - .skip(already_broadcasted) - .map(|tx_with_metadata| { - let is_fixed_gas_limit = tx_with_metadata.is_fixed_gas_limit; - - let kind = match tx_with_metadata.tx().clone() { - TransactionMaybeSigned::Signed { tx, .. } => { - SendTransactionKind::Signed(tx) + let sequence_chain = sequence.chain; + let mut transactions = Vec::with_capacity(remaining_indices.len()); + for index in remaining_indices { + let tx_with_metadata = &sequence.transactions[index]; + let is_fixed_gas_limit = tx_with_metadata.is_fixed_gas_limit; + + let kind = match (&signed_payloads[index], tx_with_metadata.tx().clone()) { + (Some((payload, hash)), _) => { + SendTransactionKind::PreparedRaw(payload.clone(), *hash) + } + (None, TransactionMaybeSigned::Signed { tx, .. }) => { + if tempo_sponsor.is_some() { + eyre::bail!( + "cannot attach Tempo sponsor signature to an already signed script transaction" + ); } - TransactionMaybeSigned::Unsigned(mut tx) => { - let from = tx.from.expect("No sender for onchain transaction!"); + SendTransactionKind::Signed(tx) + } + (None, TransactionMaybeSigned::Unsigned(mut tx)) => { + let from = tx.from().expect("No sender for onchain transaction!"); - tx.set_chain_id(sequence.chain); + tx.set_chain_id(sequence_chain); - // Set TxKind::Create explicitly to satisfy `check_reqd_fields` in - // alloy - if tx.to.is_none() { - tx.set_create(); - } - - if let Some(gas_price) = gas_price { - tx.set_gas_price(gas_price); - } else { - let eip1559_fees = eip1559_fees.expect("was set above"); - tx.set_max_priority_fee_per_gas( - eip1559_fees.max_priority_fee_per_gas, - ); - tx.set_max_fee_per_gas(eip1559_fees.max_fee_per_gas); - } + // Set TxKind::Create explicitly to satisfy `check_reqd_fields` in + // alloy + if tx.kind().is_none() { + tx.set_create(); + } - send_kind.for_sender(&from, tx)? + if let Some(gas_price) = gas_price { + tx.set_gas_price(gas_price); + } else { + let eip1559_fees = eip1559_fees.expect("was set above"); + tx.set_max_priority_fee_per_gas( + eip1559_fees.max_priority_fee_per_gas, + ); + tx.set_max_fee_per_gas(eip1559_fees.max_fee_per_gas); } - }; - Ok((kind, is_fixed_gas_limit)) - }) - .collect::>>()?; + self.script_config.tempo.apply::( + &mut tx, + expiring_nonce_discriminators + .then_some((sequence_offset + index) as u64), + ); + + send_kind.for_sender(sequence_chain, &from, tx)? + } + }; + transactions.push((kind, is_fixed_gas_limit, index)); + } - let estimate_via_rpc = - has_different_gas_calc(sequence.chain) || self.args.skip_simulation; + let estimate_via_rpc = has_different_gas_calc(sequence.chain) + || self.script_config.evm_opts.networks.is_tempo() + || self.args.skip_simulation; // We only wait for a transaction receipt before sending the next transaction, if // there is more than one signer. There would be no way of assuring // their order otherwise. // Or if the chain does not support batched transactions (eg. Arbitrum). // Or if we need to invoke eth_estimateGas before sending transactions. - let sequential_broadcast = estimate_via_rpc - || self.args.slow - || required_addresses.len() != 1 - || !has_batch_support(sequence.chain); + let sequential_broadcast = should_broadcast_sequentially( + estimate_via_rpc, + self.args.slow, + required_addresses.len(), + ordering_addresses.len(), + has_batch_support(sequence.chain), + ) || transactions.iter().any(|(kind, _, _)| { + matches!( + kind, + SendTransactionKind::Browser(..) | SendTransactionKind::Unlocked(..) + ) + }); - // We send transactions and wait for receipts in batches. - let batch_size = if sequential_broadcast { 1 } else { self.args.batch_size }; - let mut index = already_broadcasted; + // We send transactions and wait for receipts in batches of 100, since some networks + // cannot handle more than that. + let batch_size = if sequential_broadcast { 1 } else { 100 }; for (batch_number, batch) in transactions.chunks(batch_size).enumerate() { - let mut pending_transactions = vec![]; - seq_progress.inner.write().set_status(&format!( "Sending transactions [{} - {}]", batch_number * batch_size, batch_number * batch_size + std::cmp::min(batch_size, batch.len()) - 1 )); - for (kind, is_fixed_gas_limit) in batch { - let fut = send_transaction( - provider.clone(), - kind.clone(), - sequential_broadcast, - *is_fixed_gas_limit, - estimate_via_rpc, - self.args.gas_estimate_multiplier, - ); - pending_transactions.push(fut); + + let mut prepared = Vec::with_capacity(batch.len()); + for (kind, is_fixed_gas_limit, index) in batch { + kind.validate_expiring_create_nonce( + &provider, + &self.sequence.sequences()[i].transactions[*index], + ) + .await?; + let mut kind = kind + .clone() + .prepare_for_durable_send( + &provider, + sequential_broadcast, + *is_fixed_gas_limit, + estimate_via_rpc, + self.args.gas_estimate_multiplier, + tempo_sponsor.as_deref(), + ) + .await?; + if let PreparedTransaction::Raw(payload, hash) = &mut kind { + *hash = self.sequence.persist_signed_payload( + i, + *index, + payload.clone(), + tempo_hardfork.is_none_or(|fork| fork >= TempoHardfork::T1B), + )?; + } + prepared.push((kind, *index)); } - if !pending_transactions.is_empty() { - let mut buffer = futures::stream::iter(pending_transactions).buffered(7); + let mut buffer = FuturesUnordered::new(); + for (kind, index) in prepared { + if let Some(request) = kind.delegated_request() { + kind.validate_delegated_submission()?; + self.sequence.persist_delegated_request(i, index, request.clone())?; + } + let provider = if matches!(kind, PreparedTransaction::Unlocked(_)) { + unlocked_submission_provider.clone() + } else { + provider.clone() + }; + let mut pending = async move { + let res = kind.clone().send(provider).await; + (res, kind, 0, None, index) + } + .boxed(); + if let Some(result) = pending.as_mut().now_or_never() { + buffer.push(futures::future::ready(result).boxed()); + } else { + buffer.push(pending); + } + } - while let Some(tx_hash) = buffer.next().await { - let tx_hash = tx_hash.wrap_err("Failed to send transaction")?; - sequence.add_pending(index, tx_hash); + 'send: while let Some((mut res, kind, attempt, original_res, index)) = + buffer.next().await + { + if res.is_err() + && let PreparedTransaction::Raw(_, hash) = kind + && provider + .get_transaction_by_hash(hash) + .await + .is_ok_and(|transaction| transaction.is_some()) + { + res = Ok(hash); + } + if kind.delegated_request().is_some() + && let Err(error) = res + { + if kind.is_definite_non_submission(&error) { + self.sequence.clear_delegated_request(i, index)?; + return Err(error); + } + self.sequence.persist_delegated_status( + i, + index, + DelegatedStatus::OutcomeUnknown, + )?; + bail!( + "submission outcome for delegated operation {index} is unknown; refusing to risk a duplicate transaction" + ); + } + if res.is_err() && attempt <= 3 { + // Try to resubmit the transaction + let provider = provider.clone(); + let progress = seq_progress.inner.clone(); + buffer.push(Box::pin(async move { + debug!(err=?res, ?attempt, "retrying transaction "); + let attempt = attempt + 1; + progress.write().set_status(&format!( + "retrying transaction {res:?} (attempt {attempt})" + )); + tokio::time::sleep(Duration::from_millis(1000 * attempt)).await; + let r = kind.clone().send(provider).await; + (r, kind, attempt, original_res.or(Some(res)), index) + })); - // Checkpoint save - self.sequence.save(true, false)?; - sequence = self.sequence.sequences_mut().get_mut(i).unwrap(); + continue 'send; + } - seq_progress.inner.write().tx_sent(tx_hash); - index += 1; + // Preserve the original error if any + let tx_hash = res.wrap_err_with(|| { + if let Some(original_res) = original_res { + format!( + "Failed to send transaction after {attempt} attempts {original_res:?}" + ) + } else { + "Failed to send transaction".to_string() + } + })?; + if let PreparedTransaction::Raw(_, expected) = kind + && expected != tx_hash + { + bail!("RPC returned hash {tx_hash} for signed payload {expected}"); } + if kind.delegated_request().is_some() { + self.sequence.persist_delegated_status( + i, + index, + DelegatedStatus::Pending { hash: tx_hash }, + )?; + } + sequence = self.sequence.sequences_mut().get_mut(i).unwrap(); + sequence.add_pending(index, tx_hash); // Checkpoint save self.sequence.save(true, false)?; - sequence = self.sequence.sequences_mut().get_mut(i).unwrap(); - progress - .wait_for_pending( - i, - sequence, - &provider, - self.script_config.config.transaction_timeout, - ) - .await? + seq_progress.inner.write().tx_sent(tx_hash); } + + // Checkpoint save + self.sequence.save(true, false)?; + let (durable_hashes, replayable_hashes) = self.sequence.submission_hashes(i); + sequence = self.sequence.sequences_mut().get_mut(i).unwrap(); + + progress + .wait_for_pending( + i, + sequence, + &provider, + self.script_config.config.transaction_timeout, + self.args.confirmations, + (&durable_hashes, &replayable_hashes), + ) + .await?; + self.sequence.ensure_delegated_outcomes_known(i)?; + // Checkpoint save self.sequence.save(true, false)?; sequence = self.sequence.sequences_mut().get_mut(i).unwrap(); } } - let (total_gas, total_gas_price, total_paid) = - sequence.receipts.iter().fold((0, 0, 0), |acc, receipt| { - let gas_used = receipt.gas_used; - let gas_price = receipt.effective_gas_price as u64; - (acc.0 + gas_used, acc.1 + gas_price, acc.2 + gas_used * gas_price) - }); + let (total_gas, avg_gas_price, total_paid) = fee_totals( + sequence.receipts.iter().map(|r| (r.gas_used(), r.effective_gas_price())), + ); let paid = format_units(total_paid, 18).unwrap_or_else(|_| "N/A".to_string()); - let avg_gas_price = format_units(total_gas_price / sequence.receipts.len() as u64, 9) - .unwrap_or_else(|_| "N/A".to_string()); + let avg_gas_price = avg_gas_price + .and_then(|avg| format_units(avg, 9).ok()) + .unwrap_or_else(|| "N/A".to_string()); let token_symbol = NamedChain::try_from(sequence.chain) .unwrap_or_default() @@ -455,19 +1121,1369 @@ impl BundledState { }) } - pub fn verify_preflight_check(&self) -> Result<()> { + pub async fn verify_preflight_check(&self) -> Result<()> { + if self.args.verify_external && self.script_config.config.offline { + bail!("External contract verification is unavailable in offline mode"); + } + for sequence in self.sequence.sequences() { - if self.args.verifier.verifier == VerificationProviderType::Etherscan - && self - .script_config - .config - .get_etherscan_api_key(Some(sequence.chain.into())) - .is_none() + let chain: Chain = sequence.chain.into(); + // Resolve the API key: CLI arg first, then per-chain config, then global fallback. + let etherscan_key = self + .script_config + .config + .get_etherscan_api_key(Some(chain)) + .or_else(|| self.script_config.config.etherscan_api_key.clone()); + let api_key = + self.args.verifier.resolve_api_key(etherscan_key.as_deref()).map(str::to_owned); + let has_url = self.args.verifier.verifier_url.is_some(); + let is_explicit = self.args.verifier.is_explicitly_set(); + // Presence check: use the fully-resolved provider type so that implicit Etherscan + // selection (key from env/config, no explicit --verifier flag) is validated too. + self.args + .verifier + .resolve(api_key.as_deref(), Some(chain)) + .client(api_key.as_deref(), Some(chain), has_url, is_explicit) + .wrap_err_with(|| { + format!("Verification preflight check failed for chain {}", sequence.chain) + })?; + // Connectivity check: validates credentials are actually accepted by the verifier. + self.args + .verifier + .check_credentials(api_key.as_deref(), chain, &self.script_config.config) + .await + .wrap_err_with(|| { + format!("Verification preflight check failed for chain {}", sequence.chain) + })?; + } + + Ok(()) + } +} + +impl BundledState { + /// Broadcasts all transactions as a single Tempo batch transaction (type 0x76). + /// + /// This method collects all individual transactions from the script and combines them + /// into a single batch transaction for atomic execution on Tempo. + pub async fn broadcast_batch(mut self) -> Result> { + // Batch mode only supports single chain for now + if self.sequence.sequences().len() != 1 { + bail!( + "--batch mode only supports single-chain scripts. \ + Use --multi without --batch for multi-chain." + ); + } + + let batch_resolution = if self.args.resume_tx_hash.is_some() { + self.sequence + .restore_delegated_pending( + self.args.resume_attempt, + self.args.resume_tx_hash, + false, + )? + .map(|(_, _, attempt, hash)| (attempt, hash)) + } else { + None + }; + let mut recovered_batch = self + .sequence + .batch_attempt(0) + .map(|(first, attempt, kind)| (first, attempt, kind.clone())); + + let sequence = self.sequence.sequences().first().unwrap(); + let total_transactions = sequence.transactions.len(); + let completed_prefix = completed_transaction_prefix(sequence)?; + let batch_start = + recovered_batch.as_ref().map(|(first, _, _)| *first).unwrap_or(completed_prefix); + + if batch_start == total_transactions { + sh_println!("No transactions to broadcast in batch mode.")?; + return Ok(BroadcastedState { + args: self.args, + script_config: self.script_config, + build_data: self.build_data, + sequence: self.sequence, + }); + } + + // Reject pre-signed transactions: a batch is a single atomic tx from one sender, + // so any tx already signed by another key would silently be re-attributed. + if let Some((idx, _)) = + sequence.transactions().enumerate().find(|(_, tx)| !tx.is_unsigned()) + { + bail!( + "--batch cannot include pre-signed transactions (found at position {}); \ + batch mode signs a single atomic transaction from one sender.", + idx + 1 + ); + } + + // Collect sender addresses - batch mode requires single sender + let senders: AddressHashSet = sequence + .transactions() + .skip(batch_start) + .filter(|tx| tx.is_unsigned()) + .filter_map(|tx| tx.from()) + .collect(); + + if senders.len() != 1 { + bail!( + "--batch mode requires all transactions to have the same sender. \ + Found {} unique senders: {:?}", + senders.len(), + senders + ); + } + + let sender = *senders.iter().next().unwrap(); + let chain_id = sequence.chain; + let calls = tempo_batch_calls(sequence, batch_start)?; + if let Some((_, _, kind)) = &recovered_batch { + match kind { + AttemptKind::Signed { request: Some(request), payload } => { + let hash = validate_tempo_batch_payload( + request, + &payload.payload, + sender, + chain_id, + &calls, + )?; + if hash != payload.hash { + bail!("recovery plan batch payload does not match its hash"); + } + } + AttemptKind::Delegated { request, .. } => { + validate_tempo_batch_request(request, sender, chain_id, &calls)?; + } + AttemptKind::Legacy { .. } => {} + AttemptKind::Signed { request: None, .. } => { + bail!("recovery plan batch payload has no persisted request"); + } + } + } + + let rpc_url = sequence.rpc_url().to_owned(); + let _ = sequence; + if let Some((attempt_id, hash)) = batch_resolution { + let provider = ProviderBuilder::::from_config_with_url( + &self.script_config.config, + &rpc_url, + )? + .build()?; + let transaction = provider + .get_transaction_by_hash(hash) + .await? + .context("resolved transaction is not available from the recovery endpoint")?; + self.sequence.resolve_delegated_hash(attempt_id, hash, &transaction)?; + recovered_batch = self + .sequence + .batch_attempt(0) + .map(|(first, attempt, kind)| (first, attempt, kind.clone())); + } + + let recovered_hash = recovered_batch.as_ref().and_then(|(_, _, kind)| match kind { + AttemptKind::Signed { payload, .. } => Some(payload.hash), + AttemptKind::Delegated { status: DelegatedStatus::Pending { hash }, .. } + | AttemptKind::Legacy { hash } => Some(*hash), + AttemptKind::Delegated { .. } => None, + }); + if completed_prefix == total_transactions { + sh_println!("No transactions to broadcast in batch mode.")?; + return Ok(BroadcastedState { + args: self.args, + script_config: self.script_config, + build_data: self.build_data, + sequence: self.sequence, + }); + } + + if matches!( + recovered_batch, + Some((_, _, AttemptKind::Delegated { status: DelegatedStatus::OutcomeUnknown, .. })) + ) { + bail!( + "submission outcome for delegated Tempo batch is unknown; refusing to risk a duplicate transaction" + ); + } + + let sequence = self.sequence.sequences_mut().get_mut(0).unwrap(); + + if sender == Config::DEFAULT_SENDER { + bail!( + "You seem to be using Foundry's default sender. Be sure to set your own --sender." + ); + } + + let provider = Arc::new( + ProviderBuilder::::from_config_with_url( + &self.script_config.config, + sequence.rpc_url(), + )? + .build()?, + ); + + if let Some((_, _, AttemptKind::Legacy { hash })) = &recovered_batch { + let transaction = provider + .get_transaction_by_hash(*hash) + .await? + .context("legacy batch transaction is not available for validation")?; + if transaction.tx_hash() != *hash { + bail!("legacy batch endpoint returned a transaction with the wrong hash"); + } + validate_tempo_batch_envelope(transaction.as_ref(), sender, chain_id, &calls)?; + } + + // Resume detection happens before signer resolution, gas estimation, and sponsor attachment + // so that recovering an already-submitted batch tx never requires the original + // signer/sponsor or a fresh estimate. + if recovered_batch.is_none() + && (!sequence.pending.is_empty() + || sequence.transactions.iter().skip(batch_start).any(|tx| tx.hash.is_some())) + { + bail!("batch progress exists without a durable submission attempt"); + } + + if let Some(hash) = recovered_hash { + let mut changed = false; + for tx in sequence.transactions.iter_mut().skip(batch_start) { + if tx.hash != Some(hash) { + tx.hash = Some(hash); + changed = true; + } + } + if !sequence.receipts.iter().any(|receipt| receipt.transaction_hash() == hash) + && !sequence.pending.contains(&hash) { - eyre::bail!("Missing etherscan key for chain {}", sequence.chain); + sequence.pending.push(hash); + changed = true; + } + if changed { + self.sequence.save(true, false)?; + } + } + + if let Some((_, _, AttemptKind::Signed { payload, .. })) = &recovered_batch + && provider.get_transaction_receipt(payload.hash).await?.is_none() + && provider.get_transaction_by_hash(payload.hash).await?.is_none() + { + send_tempo_batch_payload(provider.as_ref(), &payload.payload, payload.hash).await?; + } + + if let Some(tx_hash) = recovered_hash { + sh_println!( + "Resuming batch: tx {tx_hash:#x} already submitted, waiting for receipt..." + )?; + + let timeout = self.script_config.config.transaction_timeout; + let receipt_result = tokio::time::timeout( + Duration::from_secs(timeout), + wait_for_batch_receipt(provider.as_ref(), tx_hash, self.args.confirmations), + ) + .await; + + match receipt_result { + Ok(Ok(Some(receipt))) => { + Self::finish_batch(&mut self.sequence, receipt, tx_hash, batch_start, true)?; + return Ok(BroadcastedState { + args: self.args, + script_config: self.script_config, + build_data: self.build_data, + sequence: self.sequence, + }); + } + Ok(Ok(None)) => { + bail!( + "Tempo batch {tx_hash:#x} is not currently visible; its pending hash remains checkpointed" + ); + } + Ok(Err(e)) => return Err(e), + Err(_) => { + return Err(eyre::eyre!( + "Timeout waiting for batch transaction receipt (tx: {tx_hash:#x}). \ + The transaction hash remains checkpointed; run with --resume to retry." + )); + } + } + } + + // Reborrow after the potential save above. + let sequence = self.sequence.sequences_mut().get_mut(0).unwrap(); + + let tempo_sponsor = self.script_config.tempo.sponsor_config().await?; + + // Get wallet for signing + enum BatchSigner { + Unlocked, + Wallet(EthereumWallet), + TempoKeychain(Box), + } + + let mut batch_signer = if self.args.unlocked { + BatchSigner::Unlocked + } else if let Some(session) = self.script_config.tempo.session_signer_for_multi_wallet( + &self.args.wallets, + Some(sender), + chain_id, + )? { + BatchSigner::TempoKeychain(Box::new(session.access_key)) + } else { + let mut signers = self.script_wallets.into_multi_wallet().into_signers()?; + if let Some(signer) = signers.remove(&sender) { + BatchSigner::Wallet(EthereumWallet::new(signer)) + } else { + // Try the Tempo Accounts store only for Tempo broadcasts. + if self.script_config.evm_opts.networks.is_tempo() + && let Some(wallet) = TempoAccountsWallet::try_from_default_store()? + && wallet.has_account(sender)? + { + BatchSigner::TempoKeychain(Box::new(wallet.with_chain_id(chain_id))) + } else { + bail!("No wallet found for sender {}", sender); + } + } + }; + + // CREATE2 deployer must exist on-chain for any rewritten CREATEs. + let create2_deployer = self.script_config.evm_opts.create2_deployer; + let needs_factory = + sequence.transactions.iter().skip(batch_start).any(|tx| tx.call_kind.is_any_create()); + if needs_factory { + let code = provider.get_code_at(create2_deployer).await?; + if keccak256(&code) != DEFAULT_CREATE2_DEPLOYER_CODEHASH { + bail!( + "CREATE2 deployer {create2_deployer:#x} is not deployed on this Tempo network; \ + --batch requires it. Deploy it first and retry." + ); + } + } + + sh_println!( + "\n## Broadcasting batch transaction with {} call(s) to chain {}...", + calls.len(), + sequence.chain + )?; + + // Build the batch transaction request + let nonce = provider.get_transaction_count(sender).await?; + + // Batch transactions are Tempo-only and always use EIP-1559 style fees. + let fees = estimate_eip1559_fees(&provider, self.script_config.config.eip1559_fee_estimate) + .await?; + let fees = resolve_broadcast_eip1559_fees( + fees, + self.args.with_gas_price.map(|p| p.to()), + self.args.priority_gas_price.map(|p| p.to()), + None, + )?; + let max_fee_per_gas = fees.max_fee_per_gas; + let max_priority_fee_per_gas = fees.max_priority_fee_per_gas; + + let mut batch_tx = TempoTransactionRequest { + inner: TransactionRequest { + from: Some(sender), + to: None, + value: None, + input: Default::default(), + nonce: Some(nonce), + chain_id: Some(chain_id), + max_fee_per_gas: Some(max_fee_per_gas), + max_priority_fee_per_gas: Some(max_priority_fee_per_gas), + ..Default::default() + }, + fee_token: self.script_config.tempo.fee_token, + calls: calls.clone(), + nonce_key: self.script_config.tempo.expiring_nonce.then_some(U256::MAX), + valid_before: self.script_config.tempo.valid_before.and_then(NonZeroU64::new), + ..Default::default() + }; + self.script_config.tempo.apply::(&mut batch_tx, None); + let fee_token = if let Some(sponsor) = &tempo_sponsor { + sponsor.resolve_and_set_fee_token(Some(provider.as_ref()), &mut batch_tx).await?; + None + } else { + resolve_and_set_fee_token(Some(provider.as_ref()), &mut batch_tx, Some(sender)).await? + }; + + if let BatchSigner::TempoKeychain(wallet) = &mut batch_signer { + **wallet = batch_tx.prepare_with_tempo_wallet(provider.as_ref(), wallet).await?; + } + + // Estimate gas for the batch transaction + estimate_gas(&mut batch_tx, provider.as_ref(), self.args.gas_estimate_multiplier, false) + .await?; + + sh_println!("Estimated gas: {}", batch_tx.inner.gas.unwrap_or(0))?; + + if let Some(sponsor) = &tempo_sponsor { + sponsor.attach_and_print::(&mut batch_tx, sender).await?; + } else { + maybe_print_fee_token(Some(provider.as_ref()), fee_token).await?; + } + batch_tx.prep_for_submission(); + + let _ = sequence; + + // Sign and send. + let signed_payload = match &batch_signer { + BatchSigner::Wallet(wallet) => { + Some(Bytes::from(batch_tx.clone().build(wallet).await?.encoded_2718())) + } + BatchSigner::TempoKeychain(wallet) => { + Some(Bytes::from(batch_tx.clone().sign_with_tempo_wallet(wallet).await?)) + } + BatchSigner::Unlocked => None, + }; + let tx_hash = if let Some(payload) = signed_payload { + validate_tempo_batch_payload(&batch_tx, &payload, sender, chain_id, &calls)?; + let expected = self.sequence.persist_batch_signed_payload( + 0, + batch_start, + batch_tx, + payload.clone(), + )?; + send_tempo_batch_payload(provider.as_ref(), &payload, expected).await?; + expected + } else { + let provider = ProviderBuilder::::from_config_with_url( + &self.script_config.config, + &rpc_url, + )? + .max_retry(0) + .build()?; + self.sequence.persist_batch_delegated_request(0, batch_start, batch_tx.clone())?; + let (_, attempt_id, _) = self.sequence.batch_attempt(0).unwrap(); + let pending = match provider.send_transaction(batch_tx).await { + Ok(pending) => pending, + Err(error) => { + let error = eyre::Report::from(error); + if is_definite_unlocked_non_submission(&error) { + self.sequence.clear_delegated_request(0, batch_start)?; + return Err(error); + } + self.sequence.persist_delegated_status( + 0, + batch_start, + DelegatedStatus::OutcomeUnknown, + )?; + return Err(error.wrap_err(format!( + "submission outcome for delegated Tempo batch attempt {attempt_id} is unknown; resolve it with --resume-attempt and --resume-tx-hash, or use --resume-retry only after proving it was not submitted" + ))); + } + }; + let hash = *pending.tx_hash(); + self.sequence.persist_delegated_status( + 0, + batch_start, + DelegatedStatus::Pending { hash }, + )?; + hash + }; + + sh_println!("Batch transaction sent: {:#x}", tx_hash)?; + + // Checkpoint: stamp the batch hash on all remaining transactions (so that resume + // detection finds it regardless of which tx it inspects first), register one entry + // in sequence.pending for drop/timeout tracking, then save. + let sequence = self.sequence.sequences_mut().get_mut(0).unwrap(); + for tx in sequence.transactions.iter_mut().skip(batch_start) { + tx.hash = Some(tx_hash); + } + if !sequence.pending.contains(&tx_hash) { + sequence.pending.push(tx_hash); + } + self.sequence.save(true, false)?; + + // Wait for receipt + let timeout = self.script_config.config.transaction_timeout; + let receipt = tokio::time::timeout( + Duration::from_secs(timeout), + wait_for_batch_receipt(provider.as_ref(), tx_hash, self.args.confirmations), + ) + .await + .map_err(|_| eyre::eyre!("Timeout waiting for batch transaction receipt (tx: {tx_hash:#x}). Run with --resume to retry."))?? + .ok_or_else(|| eyre::eyre!("Batch transaction {tx_hash:#x} was dropped from the mempool. Run with --resume to retry."))?; + + Self::finish_batch(&mut self.sequence, receipt, tx_hash, batch_start, false)?; + Ok(BroadcastedState { + args: self.args, + script_config: self.script_config, + build_data: self.build_data, + sequence: self.sequence, + }) + } + + fn finish_batch( + sequences: &mut ScriptSequenceKind, + receipt: TempoTransactionReceipt, + tx_hash: TxHash, + batch_start: usize, + resumed: bool, + ) -> Result<()> { + if receipt.status() { + sh_println!( + "Batch transaction confirmed in block {}", + receipt.block_number.unwrap_or(0) + )?; + } else { + bail!("Batch transaction failed (reverted)"); + } + + let sequence = sequences.sequences_mut().get_mut(0).unwrap(); + sequence.remove_pending(tx_hash); + + // Receipts are pushed 1:1 with the remaining (not-yet-receipted) transactions. + let remaining_len = sequence.transactions.len() - batch_start; + // Only carry through contract_address for actual deployments; plain calls also + // store the callee in `contract_address`, which would otherwise be copied into + // the receipt and treated as a fresh deployment by downstream consumers + // (broadcast JSON, verifier). + let per_tx_addresses: Vec> = sequence + .transactions + .iter() + .skip(batch_start) + .map(|tx| match tx.call_kind { + CallKind::Create | CallKind::Create2 => tx.contract_address, + _ => None, + }) + .collect(); + + for (idx, addr) in per_tx_addresses.iter().enumerate() { + if let Some(addr) = addr { + sh_println!(" call[{idx}] deployed at: {addr:#x}")?; } } + // gasUsed reflects the whole batch; per-call attribution is unavailable from the receipt. + for addr in &per_tx_addresses { + let mut tx_receipt = receipt.clone(); + tx_receipt.contract_address = *addr; + sequence.receipts.push(tx_receipt); + } + + let chain = sequence.chain; + let _ = sequence; + + sequences.save(true, false)?; + + let total_gas = receipt.gas_used(); + let gas_price = receipt.effective_gas_price(); + let total_paid = u128::from(total_gas).saturating_mul(gas_price); + let paid = format_units(total_paid, 18).unwrap_or_else(|_| "N/A".to_string()); + let gas_price_gwei = format_units(gas_price, 9).unwrap_or_else(|_| "N/A".to_string()); + + let token_symbol = NamedChain::try_from(chain) + .unwrap_or_default() + .native_currency_symbol() + .unwrap_or("ETH"); + let resumed = + resumed.then(|| format!("\n(resumed from previous run, {remaining_len} tx(s))")); + sh_println!( + "\nTotal Paid: {} {} ({} gas * {} gwei){}", + paid.trim_end_matches('0'), + token_symbol, + total_gas, + gas_price_gwei.trim_end_matches('0').trim_end_matches('.'), + resumed.as_deref().unwrap_or_default(), + )?; + + if !shell::is_json() { + sh_println!("\n\n==========================")?; + sh_println!("\nBATCH EXECUTION COMPLETE & SUCCESSFUL.")?; + sh_println!( + "All {} calls executed atomically in a single transaction.", + remaining_len + )?; + } + Ok(()) } } + +async fn send_tempo_batch_payload( + provider: &RootProvider, + payload: &[u8], + expected: TxHash, +) -> Result<()> { + match provider.send_raw_transaction(payload).await { + Ok(pending) if *pending.tx_hash() == expected => Ok(()), + Ok(pending) => { + bail!("RPC returned hash {} for signed batch payload {expected}", pending.tx_hash()) + } + Err(error) => { + if provider.get_transaction_receipt(expected).await.ok().flatten().is_some() + || provider.get_transaction_by_hash(expected).await.ok().flatten().is_some() + { + Ok(()) + } else { + Err(error.into()) + } + } + } +} + +async fn wait_for_batch_receipt( + provider: &RootProvider, + tx_hash: TxHash, + confirmations: u64, +) -> Result> { + loop { + if let Some(receipt) = provider.get_transaction_receipt(tx_hash).await? + && is_mined_receipt_for(&receipt, tx_hash) + && let Some(receipt_block) = receipt.block_number() + { + let latest_block = provider.get_block_number().await?; + if latest_block >= receipt_block.saturating_add(confirmations.saturating_sub(1)) { + return Ok(Some(receipt)); + } + } + + if provider.get_transaction_by_hash(tx_hash).await?.is_none() { + return Ok(None); + } + + tokio::time::sleep(Duration::from_millis(500)).await; + } +} + +pub async fn estimate_gas>( + tx: &mut N::TransactionRequest, + provider: &P, + estimate_multiplier: u64, + browser: bool, +) -> Result<()> +where + N::TransactionRequest: FoundryTransactionBuilder, +{ + // if already set, some RPC endpoints might simply return the gas value that is already + // set in the request and omit the estimate altogether, so we remove it here + tx.reset_gas_limit(); + + let request = if browser { tx.browser_wallet_gas_estimation_request() } else { tx.clone() }; + tx.set_gas_limit( + provider.estimate_gas(request).await.wrap_err("Failed to estimate gas for tx")? + * estimate_multiplier + / 100, + ); + Ok(()) +} + +fn reject_access_key_create( + tx: &N::TransactionRequest, + uses_access_key: bool, +) -> Result<()> +where + N::TransactionRequest: FoundryTransactionBuilder, +{ + if uses_access_key && tx.tempo_calls().iter().any(|(to, _)| to.is_create()) { + bail!("Tempo access-key transactions cannot use CREATE"); + } + Ok(()) +} + +fn convert_tempo_aa_create(tx: &mut N::TransactionRequest) +where + N::TransactionRequest: FoundryTransactionBuilder, +{ + if tx.is_tempo_aa() { + tx.convert_create_to_call(); + } +} + +/// Collection increments the sender's protocol nonce for CALLs, but expiring CALLs do not. +/// Reject a subsequent CREATE before submitting anything, since its simulated address is stale. +/// Called only when expiring nonce mode is enabled. +fn reject_expiring_call_before_create(sequences: &[ScriptSequence]) -> Result<()> +where + N::TransactionRequest: FoundryTransactionBuilder, +{ + let mut called = HashSet::new(); + for sequence in sequences { + for transaction in &sequence.transactions { + let tx = transaction.tx(); + if let Some(sender) = tx.from() { + let scope = (sequence.chain, sender); + if tx.to().is_none() && called.contains(&scope) { + bail!( + "Tempo expiring nonce scripts cannot use CREATE after a CALL from the same sender on the same chain; use CREATE2 or disable --tempo.expiring-nonce" + ); + } + let expiring = match tx { + TransactionMaybeSigned::Unsigned(request) => request.supports_fee_token(), + TransactionMaybeSigned::Signed { tx, .. } => { + >::from(tx.clone()).nonce_key() + == Some(TEMPO_EXPIRING_NONCE_KEY) + } + }; + if expiring && tx.to().is_some() { + called.insert(scope); + } + } + } + } + Ok(()) +} + +/// Returns the total gas used, the average gas price and the total fee paid in wei for the given +/// `(gas_used, effective_gas_price)` receipt pairs. +fn fee_totals(receipts: impl IntoIterator) -> (u64, Option, u128) { + let (count, total_gas, total_gas_price, total_paid) = + receipts.into_iter().fold((0u128, 0u64, 0u128, 0u128), |acc, (gas_used, gas_price)| { + ( + acc.0 + 1, + acc.1 + gas_used, + acc.2.saturating_add(gas_price), + acc.3.saturating_add(u128::from(gas_used).saturating_mul(gas_price)), + ) + }); + (total_gas, total_gas_price.checked_div(count), total_paid) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_consensus::{Eip658Value, Receipt, ReceiptEnvelope, ReceiptWithBloom, TxEnvelope}; + use alloy_network::Ethereum; + use alloy_primitives::{B256, Bloom, address, hex}; + use alloy_provider::mock::Asserter; + use alloy_rpc_types::TransactionReceipt; + use alloy_signer::Signer; + use foundry_common::tempo::PATH_USD_ADDRESS; + use foundry_evm::{backend::Backend, core::evm::EthEvmNetwork, opts::EvmOpts}; + + const ROOT_PRIVATE_KEY: &str = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + const ACCESS_KEY_PRIVATE_KEY: &str = + "0x59c6995e998f97a5a004497e5da3b5d2b2b66a87f064d39c44da0b6d6e4f8ff0"; + const SIGNED_TX: &[u8] = &hex!( + "02f86b0180843b9aca008502540be4008252089400000000000000000000000000000000000000016480c001a070d55e79ed3ac9fc8f51e78eb91fd054720d943d66633f2eb1bc960f0126b0eca052eda05a792680de3181e49bab4093541f75b49d1ecbe443077b3660c836016a" + ); + const OTHER_SIGNED_TX: &[u8] = &hex!( + "02f86b0180843b9aca008502540be4008252089400000000000000000000000000000000000000018080c001a0cce9a61187b5d18a89ecd27ec675e3b3f10d37f165627ef89a15a7fe76395ce8a07537f5bffb358ffbef22cda84b1c92f7211723f9e09ae037e81686805d3e5505" + ); + + #[tokio::test(flavor = "multi_thread")] + async fn next_nonce_uses_exact_fork_hash() { + let (_api, handle) = anvil::spawn(anvil::NodeConfig::test()).await; + let provider = handle.http_provider(); + let sender = handle.dev_accounts().next().unwrap(); + let recipient = Address::with_last_byte(1); + + let receipt = provider + .send_transaction( + TransactionRequest::default().from(sender).to(recipient).value(U256::ONE).into(), + ) + .await + .unwrap() + .get_receipt() + .await + .unwrap(); + let block_number = receipt.block_number.unwrap(); + let evm_opts = EvmOpts { + fork_url: Some(handle.http_endpoint()), + fork_block_number: Some(block_number), + ..Default::default() + }; + let backend = + Backend::::spawn(evm_opts.get_fork(&Config::default(), 31337, None)) + .unwrap(); + assert_eq!(backend.transaction_count(sender).await.unwrap(), 1); + + provider + .raw_request::<_, ()>("anvil_reorg".into(), (1_u64, Vec::::new())) + .await + .unwrap(); + assert_eq!(provider.get_transaction_count(sender).number(block_number).await.unwrap(), 0); + + match backend.transaction_count(sender).await { + Ok(0) => panic!("the exact lookup fell back to the replacement block"), + Ok(1) | Err(_) => {} + Ok(nonce) => panic!("unexpected nonce: {nonce}"), + } + } + + #[test] + fn access_key_signer_takes_precedence_over_same_sender_wallet() { + let root = foundry_wallets::utils::create_private_key_signer(ROOT_PRIVATE_KEY).unwrap(); + let root_address = root.address(); + let access_key = + foundry_wallets::utils::create_local_signer(ACCESS_KEY_PRIVATE_KEY).unwrap(); + let access_key_address = access_key.address(); + let mut eth_wallets = AddressHashMap::default(); + eth_wallets.insert(root_address, EthereumWallet::new(root)); + let mut access_keys = HashMap::default(); + access_keys.insert( + SignerScope::new(4217, root_address), + TempoAccountsWallet::from_secp256k1(root_address, access_key, None).with_chain_id(4217), + ); + let send_kind = + SendTransactionsKind::::Raw { eth_wallets, browser: None, access_keys }; + + let tx = TransactionRequest { from: Some(root_address), ..Default::default() }; + let sender = send_kind.for_sender(4217, &root_address, tx).unwrap(); + + match sender { + SendTransactionKind::AccessKey(_, wallet) => { + assert_eq!(wallet.key_id().unwrap(), access_key_address); + assert_eq!(wallet.account(), root_address); + } + _ => panic!("expected access key signer"), + } + } + + #[test] + fn access_key_signer_is_scoped_to_chain() { + let root = foundry_wallets::utils::create_private_key_signer(ROOT_PRIVATE_KEY).unwrap(); + let root_address = root.address(); + let access_key = + foundry_wallets::utils::create_local_signer(ACCESS_KEY_PRIVATE_KEY).unwrap(); + let mut eth_wallets = AddressHashMap::default(); + eth_wallets.insert(root_address, EthereumWallet::new(root)); + let mut access_keys = HashMap::default(); + access_keys.insert( + SignerScope::new(4217, root_address), + TempoAccountsWallet::from_secp256k1(root_address, access_key, None).with_chain_id(4217), + ); + let send_kind = + SendTransactionsKind::::Raw { eth_wallets, browser: None, access_keys }; + + let tx = TransactionRequest { from: Some(root_address), ..Default::default() }; + let sender = send_kind.for_sender(1, &root_address, tx).unwrap(); + + match sender { + SendTransactionKind::Raw(_, wallet) => { + assert_eq!(wallet.default_signer().address(), root_address); + } + _ => panic!("expected root wallet signer for non-session chain"), + } + } + + #[test] + fn recovered_signed_payload_does_not_require_a_signer() { + let dir = tempfile::tempdir().unwrap(); + let mut sequence = ScriptSequence:: { + chain: 1, + transactions: [planned_tx(SIGNED_TX)].into(), + ..Default::default() + }; + sequence.paths = Some((dir.path().join("broadcast.json"), dir.path().join("cache.json"))); + let mut sequence = ScriptSequenceKind::new_single(sequence, false).unwrap(); + let payload = Bytes::from_static(SIGNED_TX); + sequence.persist_signed_payload(0, 0, payload, true).unwrap(); + + assert!(remaining_unsigned_transactions_for_recovery(&sequence).is_empty()); + } + + #[test] + fn recovered_batch_attempt_does_not_require_a_signer() { + let dir = tempfile::tempdir().unwrap(); + let sender = Address::repeat_byte(0x22); + let mut deployment = ScriptSequence:: { + chain: 1, + transactions: [script_tx(sender)].into(), + ..Default::default() + }; + deployment.paths = Some((dir.path().join("broadcast.json"), dir.path().join("cache.json"))); + let mut sequence = ScriptSequenceKind::new_single(deployment, true).unwrap(); + sequence.persist_batch_delegated_request(0, 0, Default::default()).unwrap(); + + assert!(remaining_unsigned_transactions_for_recovery(&sequence).is_empty()); + } + + #[test] + fn batch_retry_clears_the_shared_delegated_attempt() { + let dir = tempfile::tempdir().unwrap(); + let recovery_path = dir.path().join("cache.json.recovery.json"); + let mut deployment = ScriptSequence:: { + chain: 1, + transactions: [script_tx(Address::ZERO), script_tx(Address::ZERO)].into(), + ..Default::default() + }; + deployment.paths = Some((dir.path().join("broadcast.json"), dir.path().join("cache.json"))); + let mut sequence = ScriptSequenceKind::new_single(deployment, true).unwrap(); + sequence.persist_batch_delegated_request(0, 0, Default::default()).unwrap(); + let (_, attempt, _) = sequence.batch_attempt(0).unwrap(); + + sequence.restore_delegated_pending(Some(attempt), None, true).unwrap(); + + assert!(sequence.batch_attempt(0).is_none()); + drop(sequence); + let recovery: serde_json::Value = + foundry_common::fs::read_json_file(&recovery_path).unwrap(); + assert_eq!(recovery["deployments"][0]["operations"].as_array().unwrap().len(), 2); + assert!(recovery["deployments"][0].get("attempts").is_none()); + } + + #[test] + fn recovered_sender_still_requires_sequential_ordering() { + let dir = tempfile::tempdir().unwrap(); + let unsigned = Address::repeat_byte(0x22); + let mut sequence = ScriptSequence:: { + chain: 1, + transactions: [planned_tx(SIGNED_TX), script_tx(unsigned)].into(), + ..Default::default() + }; + sequence.paths = Some((dir.path().join("broadcast.json"), dir.path().join("cache.json"))); + let mut sequence = ScriptSequenceKind::new_single(sequence, false).unwrap(); + sequence.persist_signed_payload(0, 0, Bytes::from_static(SIGNED_TX), true).unwrap(); + + let required = remaining_unsigned_transactions_for_recovery(&sequence); + assert_eq!(required.iter().map(|tx| tx.sender).collect::>(), [unsigned]); + assert_eq!(remaining_sender_addresses(&sequence).len(), 2); + } + + #[test] + fn signed_only_sequences_remain_sequential() { + assert!(should_broadcast_sequentially(false, false, 0, 1, true)); + assert!(!should_broadcast_sequentially(false, false, 1, 1, true)); + } + + #[test] + fn browser_rejections_are_definite_non_submissions() { + let rejected = + eyre::Report::new(alloy_signer::Error::other(BrowserWalletError::Rejected { + operation: "Transaction", + reason: "Rejected by user".to_string(), + })); + assert!(is_definite_browser_non_submission(&rejected)); + let send_error = eyre::Report::new(alloy_signer::Error::other( + BrowserWalletError::ServerError("replacement transaction underpriced".to_string()), + )); + assert!(!is_definite_browser_non_submission(&send_error)); + let not_connected = + eyre::Report::new(alloy_signer::Error::other(BrowserWalletError::NotConnected)); + assert!(is_definite_browser_non_submission(¬_connected)); + let timeout = eyre::Report::new(alloy_signer::Error::other(BrowserWalletError::Timeout { + operation: "Transaction", + })); + assert!(!is_definite_browser_non_submission(&timeout)); + } + + #[test] + fn only_anvil_no_signer_errors_are_definite_non_submissions() { + assert!(is_unlocked_non_submission(-32602, "No Signer available")); + assert!(!is_unlocked_non_submission(-32603, "No Signer available")); + assert!(!is_unlocked_non_submission(-32602, "transaction rejected")); + } + + #[test] + fn recovered_completion_is_matched_by_hash() { + let dir = tempfile::tempdir().unwrap(); + let mut deployment = ScriptSequence:: { + chain: 1, + transactions: [planned_tx(SIGNED_TX), planned_tx(OTHER_SIGNED_TX)].into(), + ..Default::default() + }; + deployment.paths = Some((dir.path().join("broadcast.json"), dir.path().join("cache.json"))); + let mut sequence = ScriptSequenceKind::new_single(deployment, false).unwrap(); + sequence.persist_signed_payload(0, 0, Bytes::from_static(SIGNED_TX), true).unwrap(); + let second_hash = sequence + .persist_signed_payload(0, 1, Bytes::from_static(OTHER_SIGNED_TX), true) + .unwrap(); + let mut second_receipt = receipt(); + second_receipt.transaction_hash = second_hash; + sequence.sequences_mut()[0].receipts.push(second_receipt); + + assert_eq!(remaining_operation_indices(&sequence, 0), [0]); + } + + #[test] + fn externally_signed_completion_uses_the_persisted_hash() { + let dir = tempfile::tempdir().unwrap(); + let sender = Address::repeat_byte(0x11); + let mut deployment = ScriptSequence:: { + chain: 1, + transactions: [script_tx(sender), script_tx(sender)].into(), + receipts: vec![receipt()], + ..Default::default() + }; + deployment.transactions[1].hash = Some(deployment.receipts[0].transaction_hash()); + deployment.paths = Some((dir.path().join("broadcast.json"), dir.path().join("cache.json"))); + let sequence = ScriptSequenceKind::new_single(deployment, false).unwrap(); + + assert_eq!(remaining_operation_indices(&sequence, 0), [0]); + } + + #[test] + fn duplicate_receipts_do_not_complete_an_unsigned_operation() { + let dir = tempfile::tempdir().unwrap(); + let mut deployment = ScriptSequence:: { + chain: 1, + transactions: [planned_tx(SIGNED_TX), planned_tx(OTHER_SIGNED_TX)].into(), + ..Default::default() + }; + deployment.paths = Some((dir.path().join("broadcast.json"), dir.path().join("cache.json"))); + let mut sequence = ScriptSequenceKind::new_single(deployment, false).unwrap(); + let first_hash = + sequence.persist_signed_payload(0, 0, Bytes::from_static(SIGNED_TX), true).unwrap(); + let mut first_receipt = receipt(); + first_receipt.transaction_hash = first_hash; + sequence.sequences_mut()[0].receipts = vec![first_receipt.clone(), first_receipt]; + + assert_eq!(remaining_operation_indices(&sequence, 0), [1]); + } + + #[test] + fn completed_prefix_rejects_receipt_holes() { + let hash = B256::repeat_byte(0x44); + let mut later = script_tx(Address::ZERO); + later.hash = Some(hash); + let mut receipt = receipt(); + receipt.transaction_hash = hash; + let sequence = ScriptSequence:: { + transactions: [script_tx(Address::ZERO), later].into(), + receipts: vec![receipt], + ..Default::default() + }; + + assert!(completed_transaction_prefix(&sequence).is_err()); + } + + #[tokio::test] + async fn access_key_sets_key_id_before_estimation() { + let root_address = Address::repeat_byte(0x11); + let access_key = + foundry_wallets::utils::create_local_signer(ACCESS_KEY_PRIVATE_KEY).unwrap(); + let access_key_address = access_key.address(); + let access_key_wallet = + TempoAccountsWallet::from_secp256k1(root_address, access_key, None).with_chain_id(4217); + // An explicit fee token keeps preparation from querying the unreachable endpoint. + let mut sender = SendTransactionKind::::AccessKey( + TempoTransactionRequest { + inner: TransactionRequest { from: Some(root_address), ..Default::default() }, + fee_token: Some(PATH_USD_ADDRESS), + ..Default::default() + }, + Box::new(access_key_wallet), + ); + let provider = + RootProvider::::new_http("http://localhost:8545".parse().unwrap()); + + sender.prepare(&provider, false, true, false, 100, None).await.unwrap(); + + match sender { + SendTransactionKind::AccessKey(tx, _) => { + assert_eq!(tx.key_id, Some(access_key_address)); + } + _ => panic!("expected access key transaction"), + } + } + + #[test] + fn tempo_aa_create_moves_deployment_into_calls() { + let mut tx = TempoTransactionRequest { + inner: TransactionRequest { to: Some(TxKind::Create), ..Default::default() }, + fee_token: Some(address!("0x20c0000000000000000000000000000000000000")), + ..Default::default() + }; + + convert_tempo_aa_create::(&mut tx); + + assert!(tx.inner.to.is_none()); + assert_eq!(tx.calls.len(), 1); + assert!(tx.calls[0].to.is_create()); + } + + #[tokio::test] + async fn signed_tempo_batch_is_bound_to_its_calls() { + let signer = foundry_wallets::utils::create_private_key_signer(ROOT_PRIVATE_KEY).unwrap(); + let sender = signer.address(); + let wallet = EthereumWallet::new(signer); + let calls = vec![Call { + to: TxKind::Call(Address::repeat_byte(0x11)), + value: U256::ONE, + input: Bytes::new(), + }]; + let request = TempoTransactionRequest { + inner: TransactionRequest { + from: Some(sender), + nonce: Some(0), + chain_id: Some(4217), + gas: Some(21_000), + max_fee_per_gas: Some(1), + max_priority_fee_per_gas: Some(1), + ..Default::default() + }, + calls: calls.clone(), + ..Default::default() + }; + let payload = request.clone().build(&wallet).await.unwrap().encoded_2718(); + + assert!(validate_tempo_batch_payload(&request, &payload, sender, 4217, &calls).is_ok()); + let other_calls = + vec![Call { to: TxKind::Call(Address::repeat_byte(0x22)), ..calls[0].clone() }]; + assert!( + validate_tempo_batch_payload(&request, &payload, sender, 4217, &other_calls).is_err() + ); + + let other_signer = + foundry_wallets::utils::create_private_key_signer(ACCESS_KEY_PRIVATE_KEY).unwrap(); + let mut other_request = request.clone(); + other_request.inner.from = Some(other_signer.address()); + let other_payload = + other_request.build(&EthereumWallet::new(other_signer)).await.unwrap().encoded_2718(); + assert!( + validate_tempo_batch_payload(&request, &other_payload, sender, 4217, &calls).is_err() + ); + let other_envelope = TempoTxEnvelope::decode_2718_exact(&other_payload).unwrap(); + assert!(validate_tempo_batch_envelope(&other_envelope, sender, 4217, &calls).is_err()); + } + + #[test] + fn tempo_access_key_create_is_rejected_before_preparation() { + let tx = TempoTransactionRequest { + inner: TransactionRequest { to: Some(TxKind::Create), ..Default::default() }, + ..Default::default() + }; + + let error = reject_access_key_create::(&tx, true).unwrap_err(); + + assert!(error.to_string().contains("Tempo access-key transactions cannot use CREATE")); + } + + #[test] + fn expiring_nonce_create_preflight_tracks_chain_and_sender_across_sequences() { + let sender = Address::with_last_byte(1); + let transaction = |sender, to| { + TransactionWithMetadata::from_tx_request(TransactionMaybeSigned::::new( + TempoTransactionRequest { + inner: TransactionRequest { from: Some(sender), to, ..Default::default() }, + ..Default::default() + }, + )) + }; + let mut sequences = [ + ScriptSequence { + chain: 4217, + transactions: [transaction(sender, Some(TxKind::Call(Address::with_last_byte(2))))] + .into(), + ..Default::default() + }, + ScriptSequence { + chain: 4218, + transactions: [transaction(sender, None)].into(), + ..Default::default() + }, + ScriptSequence { + chain: 4217, + transactions: [transaction(sender, None)].into(), + ..Default::default() + }, + ]; + assert_eq!( + reject_expiring_call_before_create(&sequences).unwrap_err().to_string(), + "Tempo expiring nonce scripts cannot use CREATE after a CALL from the same sender on the same chain; use CREATE2 or disable --tempo.expiring-nonce" + ); + sequences[2].chain = 4219; + reject_expiring_call_before_create(&sequences).unwrap(); + sequences[2].chain = 4217; + sequences[2].transactions[0] = transaction(Address::with_last_byte(3), None); + reject_expiring_call_before_create(&sequences).unwrap(); + sequences[2].transactions[0] = + transaction(sender, Some(TxKind::Call(Address::with_last_byte(2)))); + reject_expiring_call_before_create(&sequences).unwrap(); + + let mut ethereum = ScriptSequence:: { + chain: 1, + transactions: [script_tx(sender), script_tx(sender)].into(), + ..Default::default() + }; + ethereum.transactions[0] + .tx_mut() + .as_unsigned_mut() + .unwrap() + .set_to(Address::with_last_byte(2)); + reject_expiring_call_before_create(&[ethereum]).unwrap(); + } + + #[test] + fn fee_totals_do_not_overflow_u64() { + let gwei = 1_000_000_000; + let receipts = [(30_000_000, 1_000 * gwei), (30_000_000, 2_000 * gwei)]; + + assert_eq!( + fee_totals(receipts), + (60_000_000, Some(1_500 * gwei), 90_000_000_000_000_000_000) + ); + } + + #[tokio::test] + async fn batch_receipt_watcher_rejects_foreign_or_incomplete_receipts() { + let hash = B256::repeat_byte(0x42); + let receipt = |field: &str, value: serde_json::Value| { + let mut receipt = serde_json::json!({ + "type": "0x02", "status": "0x1", "cumulativeGasUsed": "0x5208", "logs": [], + "transactionHash": hash, "logsBloom": format!("{:#x}", Bloom::ZERO), + "transactionIndex": "0x0", "blockHash": B256::ZERO, "blockNumber": "0x10", + "gasUsed": "0x5208", "effectiveGasPrice": "0x1", + "from": Address::ZERO, "to": Address::ZERO, "contractAddress": null + }); + receipt[field] = value; + receipt + }; + // Bad receipts fall through to the transaction lookup; valid ones to the block number. + let wait = async |receipt: serde_json::Value, next: serde_json::Value| { + let asserter = Asserter::new(); + let provider: RootProvider = + alloy_provider::ProviderBuilder::default().connect_mocked_client(asserter.clone()); + asserter.push_success(&receipt); + asserter.push_success(&next); + wait_for_batch_receipt(&provider, hash, 1).await.unwrap() + }; + + for (field, value) in [ + ("transactionHash", serde_json::json!(B256::repeat_byte(0x99))), + ("blockHash", serde_json::Value::Null), + ("transactionIndex", serde_json::Value::Null), + ("blockNumber", serde_json::Value::Null), + ] { + let result = wait(receipt(field, value), serde_json::Value::Null).await; + assert!(result.is_none(), "accepted receipt with bad {field}"); + } + let accepted = + wait(receipt("status", serde_json::json!("0x1")), serde_json::json!("0x10")).await; + let accepted = accepted.unwrap(); + assert_eq!(accepted.transaction_hash(), hash); + } + + fn script_tx(from: Address) -> TransactionWithMetadata { + TransactionWithMetadata::from_tx_request(TransactionMaybeSigned::new(TransactionRequest { + from: Some(from), + ..Default::default() + })) + } + + fn planned_tx(payload: &[u8]) -> TransactionWithMetadata { + let envelope = TxEnvelope::decode_2718_exact(payload).unwrap(); + let from = envelope.recover_signer().unwrap(); + let mut request: TransactionRequest = envelope.into(); + request.from = Some(from); + TransactionWithMetadata::from_tx_request(TransactionMaybeSigned::new(request)) + } + + fn receipt() -> TransactionReceipt { + TransactionReceipt { + inner: ReceiptEnvelope::Legacy(ReceiptWithBloom { + receipt: Receipt { + status: Eip658Value::success(), + cumulative_gas_used: 0, + logs: vec![], + }, + logs_bloom: Bloom::ZERO, + }), + transaction_hash: Default::default(), + transaction_index: None, + block_hash: None, + block_number: None, + gas_used: 0, + effective_gas_price: 0, + blob_gas_used: None, + blob_gas_price: None, + from: Address::ZERO, + to: None, + contract_address: None, + } + } + + #[tokio::test] + async fn expiring_create_checks_planned_nonce_including_durable_payloads() { + let request = TempoTransactionRequest { + inner: TransactionRequest { + from: Some(Address::with_last_byte(1)), + to: Some(TxKind::Create), + nonce: Some(7), + chain_id: Some(4217), + gas: Some(100_000), + max_fee_per_gas: Some(10), + max_priority_fee_per_gas: Some(1), + ..Default::default() + }, + nonce_key: Some(TEMPO_EXPIRING_NONCE_KEY), + valid_before: NonZeroU64::new(100), + ..Default::default() + }; + let planned = TransactionWithMetadata::from_tx_request(TransactionMaybeSigned::< + TempoNetwork, + >::new(request.clone())); + let mut request = request; + // The discriminator differs from the planned protocol nonce. + request.inner.nonce = Some(0); + let envelope = TempoTxEnvelope::AA( + request + .clone() + .build_aa() + .unwrap() + .into_signed(tempo_primitives::TempoSignature::default()), + ); + let kinds = [ + SendTransactionKind::Unlocked(request.clone()), + SendTransactionKind::Signed(envelope.clone()), + SendTransactionKind::PreparedRaw( + Bytes::from(envelope.encoded_2718()), + envelope.trie_hash(), + ), + ]; + let sender = request.inner.from.unwrap(); + let mut signed_plan = + TransactionWithMetadata::from_tx_request(TransactionMaybeSigned::Signed { + tx: envelope, + from: sender, + }); + signed_plan.contract_address = Some(sender.create(7)); + for kind in kinds { + for planned in [&planned, &signed_plan] { + for actual in [6, 7, 8] { + let asserter = Asserter::new(); + let provider = alloy_provider::ProviderBuilder::<_, _, TempoNetwork>::default() + .connect_mocked_client(asserter.clone()); + asserter.push_success(&format!("{actual:#x}")); + let result = kind.validate_expiring_create_nonce(&provider, planned).await; + if actual == 7 { + result.unwrap(); + } else { + let expected = format!( + "CREATE address changed unexpectedly while sending transactions. Expected {} got {} from provider nonce.", + sender.create(7), + sender.create(actual) + ); + assert_eq!(result.unwrap_err().to_string(), expected); + } + } + } + } + // Expiring CALLs have no protocol nonce to synchronize. + request.inner.to = Some(TxKind::Call(Address::with_last_byte(2))); + let provider = alloy_provider::ProviderBuilder::<_, _, TempoNetwork>::default() + .connect_mocked_client(Asserter::new()); + SendTransactionKind::Unlocked(request.clone()) + .validate_expiring_create_nonce( + &provider, + &TransactionWithMetadata::from_tx_request(TransactionMaybeSigned::new(request)), + ) + .await + .unwrap(); + } + + #[tokio::test] + async fn preparation_preserves_signed_payload_without_rpc() { + let envelope = TxEnvelope::decode_2718_exact(SIGNED_TX).unwrap(); + let hash = envelope.trie_hash(); + let provider = alloy_provider::ProviderBuilder::new() + .disable_recommended_fillers() + .connect_mocked_client(Asserter::new()); + for input in [ + SendTransactionKind::::Signed(envelope), + SendTransactionKind::PreparedRaw(Bytes::from_static(SIGNED_TX), hash), + ] { + let prepared = input + .prepare_for_durable_send(&provider, true, false, true, 130, None) + .await + .unwrap(); + let PreparedTransaction::Raw(payload, prepared_hash) = prepared else { + panic!("signed transactions must become fixed raw submissions"); + }; + assert_eq!(payload.as_ref(), SIGNED_TX); + assert_eq!(prepared_hash, hash); + } + } +} diff --git a/crates/script/src/build.rs b/crates/script/src/build.rs index 9c3379dca8783..4edc412a5efdd 100644 --- a/crates/script/src/build.rs +++ b/crates/script/src/build.rs @@ -1,14 +1,25 @@ use crate::{ - ScriptArgs, ScriptConfig, broadcast::BundledState, execute::LinkedState, - multi_sequence::MultiChainSequence, sequence::ScriptSequenceKind, + ScriptArgs, ScriptConfig, + broadcast::{BundledState, remaining_unsigned_transactions_for_recovery}, + execute::LinkedState, + multi_sequence::MultiChainSequence, + progress::ScriptProgress, + receipts::is_mined_receipt_for, + recovery::recovery_exists, + sequence::ScriptSequenceKind, + session::{SignerScope, script_session_expected_sender_if_configured}, }; -use alloy_primitives::{B256, Bytes}; +use alloy_network::{AnyNetwork, ReceiptResponse}; +use alloy_primitives::{Address, B256, map::AddressHashSet}; use alloy_provider::Provider; -use eyre::{OptionExt, Result}; +use eyre::{ContextCompat, OptionExt, Result}; use forge_script_sequence::ScriptSequence; use foundry_cheatcodes::Wallets; +use foundry_cli::opts::TempoOpts; use foundry_common::{ - ContractData, ContractsByArtifact, compile::ProjectCompiler, provider::try_get_http_provider, + ContractData, ContractsByArtifact, ContractsByArtifactBuilder, compile::ProjectCompiler, + external_compiler::is_builtin_compiler_source, fs::canonicalize_path, + provider::ProviderBuilder, }; use foundry_compilers::{ ArtifactId, ProjectCompileOutput, @@ -17,12 +28,17 @@ use foundry_compilers::{ info::ContractInfo, utils::source_files_iter, }; -use foundry_evm::traces::debug::ContractSources; +use foundry_evm::{core::evm::FoundryEvmNetwork, traces::debug::ContractSources}; use foundry_linking::Linker; -use std::{path::PathBuf, str::FromStr, sync::Arc}; +use foundry_wallets::{MultiWalletOpts, wallet_browser::signer::BrowserSigner}; +use std::{ + path::{Path, PathBuf}, + str::FromStr, + sync::Arc, +}; /// Container for the compiled contracts. -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct BuildData { /// Root of the project. pub project_root: PathBuf, @@ -39,28 +55,26 @@ impl BuildData { /// Links contracts. Uses CREATE2 linking when possible, otherwise falls back to /// default linking with sender nonce and address. - pub async fn link(self, script_config: &ScriptConfig) -> Result { + pub async fn link( + self, + script_config: &ScriptConfig, + ) -> Result { let create2_deployer = script_config.evm_opts.create2_deployer; - let can_use_create2 = if let Some(fork_url) = &script_config.evm_opts.fork_url { - let provider = try_get_http_provider(fork_url)?; - let deployer_code = provider.get_code_at(create2_deployer).await?; - - !deployer_code.is_empty() - } else { - // If --fork-url is not provided, we are just simulating the script. - true - }; + let can_use_create2 = script_config + .backend + .can_use_create2_deployer(script_config.evm_opts.create2_deployer) + .await?; let known_libraries = script_config.config.libraries_with_remappings()?; let maybe_create2_link_output = can_use_create2 .then(|| { self.get_linker() - .link_with_create2( + .link_with_create2_detailed( known_libraries.clone(), create2_deployer, script_config.config.create2_library_salt, - &self.target, + [&self.target], ) .ok() }) @@ -68,21 +82,28 @@ impl BuildData { let (libraries, predeploy_libs) = if let Some(output) = maybe_create2_link_output { ( - output.libraries, - ScriptPredeployLibraries::Create2( - output.libs_to_deploy, - script_config.config.create2_library_salt, - ), + output.output.libraries, + ScriptPredeployLibraries::Create2 { + onchain: output.linked_libraries, + salt: script_config.config.create2_library_salt, + local: Vec::new(), + }, ) } else { - let output = self.get_linker().link_with_nonce_or_address( + let output = self.get_linker().link_with_nonce_or_address_detailed( known_libraries, script_config.evm_opts.sender, script_config.sender_nonce, [&self.target], )?; - (output.libraries, ScriptPredeployLibraries::Default(output.libs_to_deploy)) + ( + output.output.libraries, + ScriptPredeployLibraries::Default { + onchain: output.linked_libraries, + local: Vec::new(), + }, + ) }; LinkedBuildData::new(libraries, predeploy_libs, self) @@ -91,27 +112,38 @@ impl BuildData { /// Links the build data with the given libraries. Expects supplied libraries set being enough /// to fully link target contract. pub fn link_with_libraries(self, libraries: Libraries) -> Result { - LinkedBuildData::new(libraries, ScriptPredeployLibraries::Default(Vec::new()), self) + LinkedBuildData::new( + libraries, + ScriptPredeployLibraries::Default { onchain: Vec::new(), local: Vec::new() }, + self, + ) } } -#[derive(Debug)] +#[derive(Clone, Debug)] pub enum ScriptPredeployLibraries { - Default(Vec), - Create2(Vec, B256), + Default { + onchain: Vec, + local: Vec, + }, + Create2 { + onchain: Vec, + salt: B256, + local: Vec, + }, } impl ScriptPredeployLibraries { - pub fn libraries_count(&self) -> usize { + pub const fn libraries_count(&self) -> usize { match self { - Self::Default(libs) => libs.len(), - Self::Create2(libs, _) => libs.len(), + Self::Default { onchain, .. } => onchain.len(), + Self::Create2 { onchain, .. } => onchain.len(), } } } /// Container for the linked contracts and their dependencies -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct LinkedBuildData { /// Original build data, might be used to relink this object with different libraries. pub build_data: BuildData, @@ -137,8 +169,14 @@ impl LinkedBuildData { Some(&libraries), )?; - let known_contracts = - ContractsByArtifact::new(build_data.get_linker().get_linked_artifacts(&libraries)?); + let linked_contracts = build_data.get_linker().get_linked_artifacts(&libraries)?; + let known_contracts = ContractsByArtifactBuilder::new( + linked_contracts.iter().map(|(id, artifact)| (id.clone(), artifact.into())), + ) + .with_storage_layouts(build_data.output.artifact_ids().filter_map(|(id, artifact)| { + artifact.storage_layout.as_ref().map(|layout| (id, layout.clone())) + })) + .build(); Ok(Self { build_data, known_contracts, libraries, predeploy_libraries, sources }) } @@ -152,17 +190,18 @@ impl LinkedBuildData { } /// First state basically containing only inputs of the user. -pub struct PreprocessedState { +pub struct PreprocessedState { pub args: ScriptArgs, - pub script_config: ScriptConfig, + pub script_config: ScriptConfig, pub script_wallets: Wallets, + pub browser_wallet: Option>, } -impl PreprocessedState { +impl PreprocessedState { /// Parses user input and compiles the contracts depending on script target. /// After compilation, finds exact [ArtifactId] of the target contract. - pub fn compile(self) -> Result { - let Self { args, script_config, script_wallets } = self; + pub fn compile(self) -> Result> { + let Self { args, script_config, script_wallets, browser_wallet } = self; let project = script_config.config.project()?; let mut target_name = args.target_contract.clone(); @@ -170,30 +209,37 @@ impl PreprocessedState { // If we've received correct path, use it as target_path // Otherwise, parse input as : and use the path from the contract info, if // present. - let target_path = if let Ok(path) = dunce::canonicalize(&args.path) { + let target_path = if let Ok(path) = canonicalize_path(&args.path) { path } else { let contract = ContractInfo::from_str(&args.path)?; target_name = Some(contract.name.clone()); if let Some(path) = contract.path { - dunce::canonicalize(path)? + canonicalize_path(path)? } else { project.find_contract_path(contract.name.as_str())? } }; - #[expect(clippy::redundant_clone)] let sources_to_compile = source_files_iter( project.paths.sources.as_path(), MultiCompilerLanguage::FILE_EXTENSIONS, - ) - .chain([target_path.to_path_buf()]); - - let output = ProjectCompiler::new().files(sources_to_compile).compile(&project)?; + ); + + let compiler = ProjectCompiler::new() + .external_compilers(&script_config.config) + .files(sources_to_compile) + .dynamic_test_linking(script_config.config.dynamic_test_linking); + let compiler = if is_builtin_compiler_source(&target_path) { + compiler.files([target_path.clone()]) + } else { + compiler.target_files([target_path.clone()]) + }; + let output = compiler.compile(&project)?; let mut target_id: Option = None; - // Find target artfifact id by name and path in compilation artifacts. + // Find target artifact id by name and path in compilation artifacts. for (id, contract) in output.artifact_ids().filter(|(id, _)| id.source == target_path) { if let Some(name) = &target_name { if id.name != *name { @@ -219,7 +265,7 @@ impl PreprocessedState { if target_name != id_name { eyre::bail!( "Multiple contracts in the target path. Please specify the contract name with `--tc ContractName`" - ) + ); } } target_id = Some(id); @@ -231,94 +277,194 @@ impl PreprocessedState { args, script_config, script_wallets, + browser_wallet, build_data: BuildData { output, target, project_root: project.root().to_path_buf() }, }) } } /// State after we have determined and compiled target contract to be executed. -pub struct CompiledState { +pub struct CompiledState { pub args: ScriptArgs, - pub script_config: ScriptConfig, + pub script_config: ScriptConfig, pub script_wallets: Wallets, + pub browser_wallet: Option>, pub build_data: BuildData, } -impl CompiledState { +impl CompiledState { /// Uses provided sender address to compute library addresses and link contracts with them. - pub async fn link(self) -> Result { - let Self { args, script_config, script_wallets, build_data } = self; + pub async fn link(self) -> Result> { + let Self { args, script_config, script_wallets, browser_wallet, build_data } = self; let build_data = build_data.link(&script_config).await?; - Ok(LinkedState { args, script_config, script_wallets, build_data }) + Ok(LinkedState { args, script_config, script_wallets, browser_wallet, build_data }) } /// Tries loading the resumed state from the cache files, skipping simulation stage. - pub async fn resume(self) -> Result { + pub async fn resume(mut self) -> Result> { let chain = if self.args.multi { None } else { let fork_url = self.script_config.evm_opts.fork_url.clone().ok_or_eyre("Missing --fork-url field, if you were trying to broadcast a multi-chain sequence, please use --multi flag")?; - let provider = Arc::new(try_get_http_provider(fork_url)?); + let provider = Arc::new(ProviderBuilder::::new(&fork_url).build()?); Some(provider.get_chain_id().await?) }; - let sequence = match self.try_load_sequence(chain, false) { - Ok(sequence) => sequence, - Err(_) => { - // If the script was simulated, but there was no attempt to broadcast yet, - // try to read the script sequence from the `dry-run/` folder - let mut sequence = self.try_load_sequence(chain, true)?; + let mut sequence = if self.sequence_exists(chain, false)? { + self.try_load_sequence(chain, false)? + } else { + // If the script was simulated, but there was no attempt to broadcast yet, + // read the script sequence from the `dry-run/` folder. + let mut sequence = self.try_load_sequence(chain, true)?; + + // Promote the complete dry-run sequence before broadcasting it. + sequence.promote_to_broadcasted( + &self.script_config.config, + &self.args.sig, + &self.build_data.target, + )?; + sequence + }; - // If sequence was in /dry-run, Update its paths so it is not saved into /dry-run - // this time as we are about to broadcast it. - sequence.update_paths_to_broadcasted( + let resolution = sequence.restore_delegated_pending( + self.args.resume_attempt, + self.args.resume_tx_hash, + self.args.resume_retry, + )?; + if !self.args.batch { + if let Some((sequence_index, _, attempt_id, hash)) = resolution { + let provider = ProviderBuilder::::from_config_with_url( &self.script_config.config, - &self.args.sig, - &self.build_data.target, - )?; + sequence.sequences()[sequence_index].rpc_url(), + )? + .build()?; + let transaction = provider + .get_transaction_by_hash(hash) + .await? + .context("resolved transaction is not available from the recovery endpoint")?; + sequence.resolve_delegated_hash(attempt_id, hash, &transaction)?; + } + let progress = ScriptProgress::default(); + for index in 0..sequence.sequences().len() { + let provider = ProviderBuilder::::from_config_with_url( + &self.script_config.config, + sequence.sequences()[index].rpc_url(), + )? + .build()?; + // A saved signed attempt whose response was lost before its hash was recorded is + // reconciled before requesting signers when its receipt shows it was mined. + for operation in 0..sequence.sequences()[index].transactions.len() { + let deployment = &sequence.sequences()[index]; + if let Some(hash) = sequence.signed_payload(index, operation).map(|s| s.hash) + && !deployment.pending.contains(&hash) + && !deployment.receipts.iter().any(|r| r.transaction_hash() == hash) + && let Some(receipt) = provider.get_transaction_receipt(hash).await? + && is_mined_receipt_for(&receipt, hash) + { + sequence.sequences_mut()[index].add_pending(operation, hash); + } + } + if sequence.sequences()[index].pending.is_empty() { + continue; + } + let (durable_hashes, replayable_hashes) = sequence.submission_hashes(index); + let result = progress + .wait_for_pending( + index, + &mut sequence.sequences_mut()[index], + &provider, + self.script_config.config.transaction_timeout, + self.args.confirmations, + (&durable_hashes, &replayable_hashes), + ) + .await; + sequence.save(true, false)?; + result?; + sequence.ensure_delegated_outcomes_known(index)?; + } + } - sequence.save(true, true)?; - sequence + if !self.args.unlocked + && !remaining_unsigned_transactions_for_recovery(&sequence).is_empty() + { + self.script_wallets = + Wallets::new(self.args.wallets.get_multi_wallet().await?, self.args.evm.sender); + self.browser_wallet = self.args.wallets.browser_signer::().await?; + + if self.args.evm.sender.is_none() { + let addresses = self.script_wallets.addresses(); + let sender = self + .args + .maybe_load_private_key()? + .or_else(|| (addresses.len() == 1).then(|| addresses[0])) + .or_else(|| self.browser_wallet.as_ref().map(|wallet| wallet.address())); + if let Some(sender) = sender { + self.script_config.update_sender(sender).await?; + } } - }; + } - let (args, build_data, script_wallets, script_config) = if !self.args.unlocked { - let mut froms = sequence.sequences().iter().flat_map(|s| { - s.transactions - .iter() - .skip(s.receipts.len()) - .map(|t| t.transaction.from().expect("from is missing in script artifact")) - }); - - let available_signers = self - .script_wallets - .signers() - .map_err(|e| eyre::eyre!("Failed to get available signers: {}", e))?; - - if !froms.all(|from| available_signers.contains(&from)) { - // IF we are missing required signers, execute script as we might need to collect - // private keys from the execution. - let executed = self.link().await?.prepare_execution().await?.execute().await?; + let (args, build_data, script_wallets, browser_wallet, script_config) = + if self.args.unlocked { ( - executed.args, - executed.build_data.build_data, - executed.script_wallets, - executed.script_config, + self.args, + self.build_data, + self.script_wallets, + self.browser_wallet, + self.script_config, ) } else { - (self.args, self.build_data, self.script_wallets, self.script_config) - } - } else { - (self.args, self.build_data, self.script_wallets, self.script_config) - }; + let remaining_transactions = + remaining_unsigned_transactions_for_recovery(&sequence); + let remaining_froms = + remaining_transactions.iter().map(|tx| tx.sender).collect::(); + let expected_session_sender = script_session_expected_sender_if_configured( + &self.script_config.tempo, + &remaining_froms, + )?; + let has_available_signers = has_available_script_signers( + &self.script_config.tempo, + &self.args.wallets, + &self.script_wallets, + expected_session_sender, + &remaining_transactions, + )?; + + if has_available_signers { + ( + self.args, + self.build_data, + self.script_wallets, + self.browser_wallet, + self.script_config, + ) + } else { + // IF we are missing required signers, execute script as we might need to + // collect private keys from the execution. + let mut state = self; + state + .script_config + .update_tempo_session_sender(&state.args.wallets, state.args.evm.sender) + .await?; + let executed = state.link().await?.prepare_execution().await?.execute().await?; + ( + executed.args, + executed.build_data.build_data, + executed.script_wallets, + executed.browser_wallet, + executed.script_config, + ) + } + }; // Collect libraries from sequence and link contracts with them. - let libraries = match sequence { - ScriptSequenceKind::Single(ref seq) => Libraries::parse(&seq.libraries)?, - // Library linking is not supported for multi-chain sequences - ScriptSequenceKind::Multi(_) => Libraries::default(), + let libraries = if sequence.is_multi() { + // Library linking is not supported for multi-chain sequences. + Libraries::default() + } else { + Libraries::parse(&sequence.sequences()[0].libraries)? }; let linked_build_data = build_data.link_with_libraries(libraries)?; @@ -327,29 +473,125 @@ impl CompiledState { args, script_config, script_wallets, + browser_wallet, build_data: linked_build_data, sequence, }) } - fn try_load_sequence(&self, chain: Option, dry_run: bool) -> Result { + fn try_load_sequence( + &self, + chain: Option, + dry_run: bool, + ) -> Result> { if let Some(chain) = chain { - let sequence = ScriptSequence::load( + ScriptSequenceKind::load_single( &self.script_config.config, &self.args.sig, &self.build_data.target, chain, dry_run, - )?; - Ok(ScriptSequenceKind::Single(sequence)) + self.args.batch, + ) } else { - let sequence = MultiChainSequence::load( + ScriptSequenceKind::load_multi( &self.script_config.config, &self.args.sig, &self.build_data.target, dry_run, - )?; - Ok(ScriptSequenceKind::Multi(sequence)) + self.args.batch, + ) } } + + fn sequence_exists(&self, chain: Option, dry_run: bool) -> Result { + let paths = if let Some(chain) = chain { + ScriptSequence::::get_paths( + &self.script_config.config, + &self.args.sig, + &self.build_data.target, + chain, + dry_run, + )? + } else { + MultiChainSequence::::get_paths( + &self.script_config.config, + &self.args.sig, + &self.build_data.target, + dry_run, + )? + }; + Ok(compatibility_progress_exists(&paths) || recovery_exists(&paths)?) + } +} + +fn compatibility_progress_exists(paths: &(PathBuf, PathBuf)) -> bool { + [&paths.0, &paths.1].into_iter().any(|path| progress_exists(path)) +} + +fn progress_exists(path: &Path) -> bool { + path.exists() || path.with_extension("previous").exists() +} + +/// Returns whether every scoped signer needed for resume is already available. +/// +/// `Wallets` only tracks signers collected from CLI options and script cheatcodes. A Tempo +/// session signer lives in the Accounts store instead, so resume needs to treat the session +/// root account as available only on the chain covered by the session. +fn has_available_script_signers( + tempo: &TempoOpts, + wallets: &MultiWalletOpts, + script_wallets: &Wallets, + expected_sender: Option
, + remaining: &[SignerScope], +) -> Result { + let signers = script_wallets + .signers() + .map_err(|e| eyre::eyre!("Failed to get available signers: {}", e))?; + if remaining.is_empty() { + return Ok(true); + } + + let session_scope = tempo + .session_signer_for_multi_wallet_any_chain(wallets, expected_sender)? + .map(|s| SignerScope::new(s.session.chain_id, s.access_key.account())); + + Ok(remaining.iter().all(|tx| signers.contains(&tx.sender) || session_scope == Some(*tx))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn backup_counts_as_recoverable_progress() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("run-latest.json"); + std::fs::write(path.with_extension("previous"), b"{}").unwrap(); + + assert!(progress_exists(&path)); + } + + #[test] + fn sensitive_export_counts_as_recoverable_progress() { + let dir = tempfile::tempdir().unwrap(); + let paths = (dir.path().join("run-latest.json"), dir.path().join("run-latest-cache.json")); + std::fs::write(&paths.1, b"{}").unwrap(); + + assert!(compatibility_progress_exists(&paths)); + } + + #[test] + fn has_available_script_signers_skips_session_resolution_when_remaining_empty() { + let has_available = has_available_script_signers( + &TempoOpts { session: Some(B256::repeat_byte(0x99)), ..Default::default() }, + &MultiWalletOpts::default(), + &Wallets::new(Default::default(), None), + None, + &[], + ) + .unwrap(); + + assert!(has_available); + } } diff --git a/crates/script/src/execute.rs b/crates/script/src/execute.rs index 091f021941500..8bd239b475c0a 100644 --- a/crates/script/src/execute.rs +++ b/crates/script/src/execute.rs @@ -6,31 +6,35 @@ use crate::{ }; use alloy_dyn_abi::FunctionExt; use alloy_json_abi::{Function, InternalType, JsonAbi}; +use alloy_network::{AnyNetwork, Network}; use alloy_primitives::{ Address, Bytes, map::{HashMap, HashSet}, }; use alloy_provider::Provider; -use alloy_rpc_types::TransactionInput; use eyre::{OptionExt, Result}; use foundry_cheatcodes::Wallets; use foundry_cli::utils::{ensure_clean_constructor, needs_setup}; use foundry_common::{ ContractsByArtifact, fmt::{format_token, format_token_raw}, - provider::get_http_provider, + provider::ProviderBuilder, }; -use foundry_config::NamedChain; +use foundry_config::{Chain, NamedChain}; use foundry_debugger::Debugger; use foundry_evm::{ + core::evm::FoundryEvmNetwork, decode::decode_console_logs, inspectors::cheatcodes::BroadcastableTransactions, traces::{ - CallTraceDecoder, CallTraceDecoderBuilder, TraceKind, decode_trace_arena, + CallTraceDecoder, CallTraceDecoderBuilder, DebugTraceIdentifier, TraceKind, + debug::ContractSources, + decode_trace_arena, identifier::{SignaturesIdentifier, TraceIdentifiers}, - render_trace_arena, + prune_trace_depth, render_trace_arena_inner, trace_arena_at_depth, }, }; +use foundry_wallets::wallet_browser::signer::BrowserSigner; use futures::future::join_all; use itertools::Itertools; use std::path::Path; @@ -38,10 +42,11 @@ use yansi::Paint; /// State after linking, contains the linked build data along with library addresses and optional /// array of libraries that need to be predeployed. -pub struct LinkedState { +pub struct LinkedState { pub args: ScriptArgs, - pub script_config: ScriptConfig, + pub script_config: ScriptConfig, pub script_wallets: Wallets, + pub browser_wallet: Option>, pub build_data: LinkedBuildData, } @@ -58,11 +63,11 @@ pub struct ExecutionData { pub abi: JsonAbi, } -impl LinkedState { +impl LinkedState { /// Given linked and compiled artifacts, prepares data we need for execution. /// This includes the function to call and the calldata to pass to it. - pub async fn prepare_execution(self) -> Result { - let Self { args, script_config, script_wallets, build_data } = self; + pub async fn prepare_execution(self) -> Result> { + let Self { args, script_config, script_wallets, browser_wallet, build_data } = self; let target_contract = build_data.get_target_contract()?; @@ -76,6 +81,7 @@ impl LinkedState { args, script_config, script_wallets, + browser_wallet, execution_data: ExecutionData { func, calldata, @@ -89,18 +95,28 @@ impl LinkedState { /// Same as [LinkedState], but also contains [ExecutionData]. #[derive(Debug)] -pub struct PreExecutionState { +pub struct PreExecutionState { pub args: ScriptArgs, - pub script_config: ScriptConfig, + pub script_config: ScriptConfig, pub script_wallets: Wallets, + pub browser_wallet: Option>, pub build_data: LinkedBuildData, pub execution_data: ExecutionData, } -impl PreExecutionState { +impl PreExecutionState { /// Executes the script and returns the state after execution. /// Might require executing script twice in cases when we determine sender from execution. - pub async fn execute(mut self) -> Result { + pub async fn execute(self) -> Result> { + self.execute_inner(false).await + } + + /// Executes an optimization candidate while blocking externally observable cheatcodes. + pub(crate) async fn execute_restricted(self) -> Result> { + self.execute_inner(true).await + } + + async fn execute_inner(mut self, restricted: bool) -> Result> { let mut runner = self .script_config .get_runner_with_cheatcodes( @@ -108,6 +124,7 @@ impl PreExecutionState { self.script_wallets.clone(), self.args.debug, self.build_data.build_data.target.clone(), + restricted, ) .await?; let result = self.execute_with_runner(&mut runner).await?; @@ -122,16 +139,21 @@ impl PreExecutionState { args: self.args, script_config: self.script_config, script_wallets: self.script_wallets, + browser_wallet: self.browser_wallet, build_data: self.build_data.build_data, }; - return Box::pin(state.link().await?.prepare_execution().await?.execute()).await; + return Box::pin( + state.link().await?.prepare_execution().await?.execute_inner(restricted), + ) + .await; } Ok(ExecutedState { args: self.args, script_config: self.script_config, script_wallets: self.script_wallets, + browser_wallet: self.browser_wallet, build_data: self.build_data, execution_data: self.execution_data, execution_result: result, @@ -139,7 +161,10 @@ impl PreExecutionState { } /// Executes the script using the provided runner and returns the [ScriptResult]. - pub async fn execute_with_runner(&self, runner: &mut ScriptRunner) -> Result { + pub async fn execute_with_runner( + &self, + runner: &mut ScriptRunner, + ) -> Result> { let (address, mut setup_result) = runner.setup( &self.build_data.predeploy_libraries, self.execution_data.bytecode.clone(), @@ -156,7 +181,9 @@ impl PreExecutionState { setup_result.logs.extend(script_result.logs); setup_result.traces.extend(script_result.traces); setup_result.labeled_addresses.extend(script_result.labeled_addresses); + setup_result.debug_bytecodes.extend(script_result.debug_bytecodes); setup_result.returned = script_result.returned; + setup_result.exit_reason = script_result.exit_reason; setup_result.breakpoints = script_result.breakpoints; match (&mut setup_result.transactions, script_result.transactions) { @@ -179,7 +206,7 @@ impl PreExecutionState { /// them instead. fn maybe_new_sender( &self, - transactions: Option<&BroadcastableTransactions>, + transactions: Option<&BroadcastableTransactions>, ) -> Result> { let mut new_sender = None; @@ -215,16 +242,17 @@ pub struct RpcData { pub total_rpcs: HashSet, /// If true, one of the transactions did not have a rpc. pub missing_rpc: bool, + /// Chain IDs already fetched for each RPC URL. + pub(crate) chain_ids: HashMap, } impl RpcData { /// Iterates over script transactions and collects RPC urls. - fn from_transactions(txs: &BroadcastableTransactions) -> Self { + fn from_transactions(txs: &BroadcastableTransactions) -> Self { let missing_rpc = txs.iter().any(|tx| tx.rpc.is_none()); - let total_rpcs = - txs.iter().filter_map(|tx| tx.rpc.as_ref().cloned()).collect::>(); + let total_rpcs = txs.iter().filter_map(|tx| tx.rpc.clone()).collect::>(); - Self { total_rpcs, missing_rpc } + Self { total_rpcs, missing_rpc, chain_ids: HashMap::default() } } /// Returns true if script might be multi-chain. @@ -234,15 +262,21 @@ impl RpcData { } /// Checks if all RPCs support EIP-3855. Prints a warning if not. - async fn check_shanghai_support(&self) -> Result<()> { - let chain_ids = self.total_rpcs.iter().map(|rpc| async move { - let provider = get_http_provider(rpc); - let id = provider.get_chain_id().await.ok()?; - NamedChain::try_from(id).ok() - }); + async fn check_shanghai_support(&mut self) -> Result<()> { + let chain_ids = + self.total_rpcs.iter().filter(|rpc| !self.chain_ids.contains_key(*rpc)).map( + |rpc| async move { + let provider = ProviderBuilder::::new(rpc).build().ok()?; + Some((rpc.clone(), provider.get_chain_id().await.ok()?)) + }, + ); - let chains = join_all(chain_ids).await; - let iter = chains.iter().flatten().map(|c| (c.supports_shanghai(), c)); + self.chain_ids.extend(join_all(chain_ids).await.into_iter().flatten()); + let iter = self + .chain_ids + .values() + .filter_map(|id| NamedChain::try_from(*id).ok()) + .map(|chain| (chain.supports_shanghai(), chain)); if iter.clone().any(|(s, _)| !s) { let msg = format!( "\ @@ -251,7 +285,7 @@ Unsupported Chain IDs: {}. Contracts deployed with a Solidity version equal or higher than 0.8.20 might not work properly. For more information, please see https://eips.ethereum.org/EIPS/eip-3855", iter.filter(|(supported, _)| !supported) - .map(|(_, chain)| *chain as u64) + .map(|(_, chain)| chain as u64) .format(", ") ); sh_warn!("{msg}")?; @@ -271,48 +305,59 @@ pub struct ExecutionArtifacts { } /// State after the script has been executed. -pub struct ExecutedState { +pub struct ExecutedState { pub args: ScriptArgs, - pub script_config: ScriptConfig, + pub script_config: ScriptConfig, pub script_wallets: Wallets, + pub browser_wallet: Option>, pub build_data: LinkedBuildData, pub execution_data: ExecutionData, - pub execution_result: ScriptResult, + pub execution_result: ScriptResult, } -impl ExecutedState { +impl ExecutedState { /// Collects the data we need for simulation and various post-execution tasks. - pub async fn prepare_simulation(self) -> Result { - let returns = self.get_returns()?; + pub async fn prepare_simulation(self) -> Result> { + self.prepare_simulation_inner(false).await + } - let decoder = self.build_trace_decoder(&self.build_data.known_contracts).await?; + /// Collects simulation data without emitting warnings for an optimization candidate that may + /// be discarded. + pub(crate) async fn prepare_simulation_silent(self) -> Result> { + self.prepare_simulation_inner(true).await + } - let mut txs = self.execution_result.transactions.clone().unwrap_or_default(); + async fn prepare_simulation_inner(self, silent: bool) -> Result> { + let returns = self.get_returns()?; - // Ensure that unsigned transactions have both `data` and `input` populated to avoid - // issues with eth_estimateGas and eth_sendTransaction requests. - for tx in &mut txs { - if let Some(req) = tx.transaction.as_unsigned_mut() { - req.input = - TransactionInput::maybe_both(std::mem::take(&mut req.input).into_input()); - } + let mut rpc_data = RpcData::from_transactions( + self.execution_result.transactions.as_ref().unwrap_or(&Default::default()), + ); + if let Some(identity) = &self.script_config.evm_opts.fork_endpoint + && rpc_data.total_rpcs.contains(&identity.endpoint) + { + rpc_data.chain_ids.insert(identity.endpoint.clone(), identity.execution_chain_id); } - let rpc_data = RpcData::from_transactions(&txs); - if rpc_data.is_multi_chain() { + if rpc_data.is_multi_chain() && !silent { sh_warn!("Multi chain deployment is still under development. Use with caution.")?; if !self.build_data.libraries.is_empty() { eyre::bail!( "Multi chain deployment does not support library linking at the moment." - ) + ); } } - rpc_data.check_shanghai_support().await?; + if !silent { + rpc_data.check_shanghai_support().await?; + } + + let decoder = self.build_trace_decoder(&rpc_data).await?; Ok(PreSimulationState { args: self.args, script_config: self.script_config, script_wallets: self.script_wallets, + browser_wallet: self.browser_wallet, build_data: self.build_data, execution_data: self.execution_data, execution_result: self.execution_result, @@ -321,29 +366,27 @@ impl ExecutedState { } /// Builds [CallTraceDecoder] from the execution result and known contracts. - async fn build_trace_decoder( - &self, - known_contracts: &ContractsByArtifact, - ) -> Result { - let mut decoder = CallTraceDecoderBuilder::new() - .with_labels(self.execution_result.labeled_addresses.clone()) - .with_verbosity(self.script_config.evm_opts.verbosity) - .with_known_contracts(known_contracts) - .with_signature_identifier(SignaturesIdentifier::from_config( - &self.script_config.config, - )?) - .build(); - - let mut identifier = TraceIdentifiers::new().with_local(known_contracts).with_etherscan( - &self.script_config.config, - self.script_config.evm_opts.get_remote_chain_id().await, - )?; - - for (_, trace) in &self.execution_result.traces { - decoder.identify(trace, &mut identifier); - } - - Ok(decoder) + async fn build_trace_decoder(&self, rpc_data: &RpcData) -> Result { + let chain_id = self.script_config.source_chain_id.map(Chain::from).or_else(|| { + self.script_config + .evm_opts + .fork_url + .as_ref() + .and_then(|url| rpc_data.chain_ids.get(url)) + .map(|chain_id| (*chain_id).into()) + }); + let chain_id = match chain_id { + Some(chain_id) => Some(chain_id), + None => self.script_config.evm_opts.get_remote_chain_id().await, + }; + build_trace_decoder_for_context( + &self.args, + &self.script_config, + &self.build_data.known_contracts, + &self.build_data.sources, + &self.execution_result, + chain_id, + ) } /// Collects the return values from the execution result. @@ -361,10 +404,10 @@ impl ExecutedState { ty: "unknown".to_string(), }); - let label = if !output.name.is_empty() { - output.name.to_string() - } else { + let label = if output.name.is_empty() { index.to_string() + } else { + output.name.clone() }; returns.insert( @@ -385,12 +428,59 @@ impl ExecutedState { } } -impl PreSimulationState { +/// Builds a trace decoder for the exact execution context of a script runner. +pub(crate) fn build_trace_decoder_for_context( + args: &ScriptArgs, + script_config: &ScriptConfig, + known_contracts: &ContractsByArtifact, + sources: &ContractSources, + execution_result: &ScriptResult, + chain_id: Option, +) -> Result { + let resolved_hardfork = script_config.hardfork; + let mut tracing = script_config.config.tracing.clone(); + tracing.labels.extend(execution_result.labeled_addresses.clone()); + + let builder = CallTraceDecoderBuilder::new() + .with_tracing_config(&tracing) + .with_known_contracts(known_contracts) + .with_signature_identifier(SignaturesIdentifier::from_config(&script_config.config)?) + .with_networks(script_config.config.networks) + .with_chain_id(chain_id.map(|chain| chain.id())) + .with_hardfork(resolved_hardfork); + let mut decoder = builder.build(); + + // The debugger resolves frame identities before decoding internal calls. + if tracing.decode_internal && !args.debug { + decoder.debug_identifier = Some(DebugTraceIdentifier::new(sources.clone())); + } + + let use_debug_bytecodes = args.debug && !execution_result.debug_bytecodes.is_empty(); + let mut identifier = if use_debug_bytecodes { + TraceIdentifiers::new() + .with_local_and_bytecodes(known_contracts, &execution_result.debug_bytecodes) + } else { + TraceIdentifiers::new().with_local(known_contracts) + } + .with_external(&script_config.config, chain_id)?; + + for (_, trace) in &execution_result.traces { + decoder.identify(trace, &mut identifier); + } + + Ok(decoder) +} + +impl PreSimulationState { pub async fn show_json(&self) -> Result<()> { let mut result = self.execution_result.clone(); + let trace_depth = self.script_config.config.tracing.trace_depth; for (_, trace) in &mut result.traces { decode_trace_arena(trace, &self.execution_artifacts.decoder).await; + if let Some(trace_depth) = trace_depth { + *trace = trace_arena_at_depth(trace, trace_depth); + } } let json_result = JsonResult { @@ -405,7 +495,11 @@ impl PreSimulationState { if !self.execution_result.success { return Err(eyre::eyre!( "script failed: {}", - &self.execution_artifacts.decoder.revert_decoder.decode(&result.returned[..], None) + &self + .execution_artifacts + .decoder + .revert_decoder + .decode(&result.returned[..], result.exit_reason) )); } @@ -413,7 +507,8 @@ impl PreSimulationState { } pub async fn show_traces(&self) -> Result<()> { - let verbosity = self.script_config.evm_opts.verbosity; + let tracing = &self.script_config.config.tracing; + let verbosity = tracing.verbosity; let func = &self.execution_data.func; let result = &self.execution_result; let decoder = &self.execution_artifacts.decoder; @@ -434,7 +529,10 @@ impl PreSimulationState { if should_include { let mut trace = trace.clone(); decode_trace_arena(&mut trace, decoder).await; - sh_println!("{}", render_trace_arena(&trace))?; + if let Some(trace_depth) = tracing.trace_depth { + prune_trace_depth(&mut trace, trace_depth); + } + sh_println!("{}", render_trace_arena_inner(&trace, false, verbosity > 4))?; } } sh_println!()?; @@ -459,10 +557,10 @@ impl PreSimulationState { ty: "unknown".to_string(), }); - let label = if !output.name.is_empty() { - output.name.to_string() - } else { + let label = if output.name.is_empty() { index.to_string() + } else { + output.name.clone() }; sh_println!( "{label}: {internal_type} {value}", @@ -488,7 +586,11 @@ impl PreSimulationState { if !result.success { return Err(eyre::eyre!( "script failed: {}", - &self.execution_artifacts.decoder.revert_decoder.decode(&result.returned[..], None) + &self + .execution_artifacts + .decoder + .revert_decoder + .decode(&result.returned[..], result.exit_reason) )); } @@ -515,8 +617,10 @@ impl PreSimulationState { .collect(), ) .decoder(&self.execution_artifacts.decoder) + .known_contracts(&self.build_data.known_contracts) .sources(self.build_data.sources) .breakpoints(self.execution_result.breakpoints) + .layout(self.args.debug_layout.unwrap_or_default()) .build() } } diff --git a/crates/script/src/lib.rs b/crates/script/src/lib.rs index ba25fb226b2ee..14ef4c736067f 100644 --- a/crates/script/src/lib.rs +++ b/crates/script/src/lib.rs @@ -2,8 +2,9 @@ //! //! Smart contract scripting. +#![recursion_limit = "256"] #![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] +#![cfg_attr(docsrs, feature(doc_cfg))] #[macro_use] extern crate foundry_common; @@ -11,69 +12,98 @@ extern crate foundry_common; #[macro_use] extern crate tracing; -use crate::runner::ScriptRunner; +use crate::{broadcast::BundledState, runner::ScriptRunner, simulate::PreSimulationState}; use alloy_json_abi::{Function, JsonAbi}; +use alloy_network::Network; use alloy_primitives::{ - Address, Bytes, Log, TxKind, U256, hex, + Address, B256, Bytes, Log, U256, hex, map::{AddressHashMap, HashMap}, }; use alloy_signer::Signer; -use broadcast::next_nonce; use build::PreprocessedState; -use clap::{Parser, ValueHint}; +use clap::{Parser, ValueHint, builder::RangedU64ValueParser}; use dialoguer::Confirm; use eyre::{ContextCompat, Result}; use forge_script_sequence::{AdditionalContract, NestedValue}; use forge_verify::{RetryArgs, VerifierArgs}; -use foundry_block_explorers::EtherscanApiVersion; use foundry_cli::{ - opts::{BuildOpts, GlobalArgs}, + opts::{BuildOpts, EvmArgs, GlobalArgs, TempoOpts, TracingArgs}, utils::LoadConfig, }; use foundry_common::{ - CONTRACT_MAX_SIZE, ContractsByArtifact, SELECTOR_LEN, + ContractsByArtifact, SELECTOR_LEN, abi::{encode_function_args, get_func}, - evm::{Breakpoints, EvmArgs}, + compile::ContractSizeLimits, shell, }; -use foundry_compilers::ArtifactId; +use foundry_compilers::{ArtifactId, artifacts::output_selection::ContractOutputSelection}; use foundry_config::{ - Config, figment, + Config, Eip1559FeeEstimatePreset, FoundryHardfork, figment, figment::{ Metadata, Profile, Provider, value::{Dict, Map}, }, }; +use foundry_debugger::DebuggerLayout; use foundry_evm::{ backend::Backend, + core::{ + Breakpoints, FoundryTransaction, + evm::{EthEvmNetwork, EvmEnvFor, FoundryEvmNetwork, SpecFor, TempoEvmNetwork, TxEnvFor}, + }, executors::ExecutorBuilder, inspectors::{ CheatsConfig, cheatcodes::{BroadcastableTransactions, Wallets}, }, - opts::EvmOpts, - traces::{TraceMode, Traces}, + opts::{EvmOpts, ExecutionSpecContext, ForkContext, resolve_execution_spec}, + revm::interpreter::InstructionResult, + traces::{InternalTraceMode, TraceRequirements, Traces}, }; -use foundry_wallets::MultiWalletOpts; +use foundry_evm_networks::{NetworkConfigs, NetworkVariant}; +use foundry_wallets::{MultiWalletOpts, wallet_multi::MultiWallet}; use serde::Serialize; use std::path::PathBuf; +#[cfg(feature = "base")] +use foundry_evm::core::evm::BaseEvmNetwork; + +#[cfg(feature = "monad")] +use foundry_evm::core::evm::MonadEvmNetwork; + +#[cfg(feature = "optimism")] +use foundry_evm::core::evm::OpEvmNetwork; + mod broadcast; mod build; mod execute; +mod library_deployments; mod multi_sequence; mod progress; mod providers; mod receipts; +mod recovery; mod runner; mod sequence; +mod session; mod simulate; mod transaction; mod verify; +mod wallet_session; + +pub use wallet_session::ScriptWalletSessionArgs; // Loads project's figment and merges the build cli arguments into it foundry_config::merge_impl_figment_convert!(ScriptArgs, build, evm); +const DEFAULT_CONFIRMATIONS: u64 = 1; + +/// Shared preparation stops before execution-family-specific on-chain simulation. +enum PreparedScript { + Resume(Box>), + Simulate(Box>), +} + /// CLI arguments for `forge script`. #[derive(Clone, Debug, Default, Parser)] pub struct ScriptArgs { @@ -96,7 +126,7 @@ pub struct ScriptArgs { pub target_contract: Option, /// The signature of the function you want to call in the contract, or raw calldata. - #[arg(long, short, default_value = "run()")] + #[arg(long, short, default_value = "run")] pub sig: String, /// Max priority fee per gas for EIP1559 transactions. @@ -114,15 +144,35 @@ pub struct ScriptArgs { #[arg(long)] pub legacy: bool, + /// How to estimate EIP-1559 fees: `low`, `market` (default), or `aggressive`. + /// + /// The preset sets the priority-fee percentile and the `maxFeePerGas` buffer + /// (`low`: `base_fee * 1.5`, others: `* 2`); `low`'s tighter buffer is more + /// likely to stall if the base fee rises. `--with-gas-price` and + /// `--priority-gas-price` override only `maxFeePerGas` and + /// `maxPriorityFeePerGas` respectively. Ignored for `--legacy`. + #[arg(long = "estimate", value_name = "PRESET")] + pub eip1559_fee_estimate: Option, + /// Broadcasts the transactions. #[arg(long)] pub broadcast: bool, - /// Batch size of transactions. + /// Batch all broadcast transactions into a single Tempo batch transaction. /// - /// This is ignored and set to 1 if batching is not available or `--slow` is enabled. - #[arg(long, default_value = "100")] - pub batch_size: usize, + /// When enabled, all vm.broadcast() calls are collected and sent as a single + /// atomic type 0x76 transaction instead of individual transactions. + /// This provides atomicity (all-or-nothing execution) and gas savings. + #[arg(long)] + pub batch: bool, + + /// Tempo transaction options. + #[command(flatten)] + pub tempo: TempoOpts, + + /// Create a temporary Tempo wallet session, run this script with it, then revoke it. + #[command(flatten)] + pub wallet_session: ScriptWalletSessionArgs, /// Skips on-chain simulation. #[arg(long)] @@ -132,10 +182,18 @@ pub struct ScriptArgs { #[arg(long, short, default_value = "130")] pub gas_estimate_multiplier: u64, - /// Send via `eth_sendTransaction` using the `--sender` argument or `$ETH_FROM` as sender + /// Override the sender's initial nonce for script execution and transaction generation. #[arg( long, - conflicts_with_all = &["private_key", "private_keys", "froms", "ledger", "trezor", "aws"], + value_name = "NONCE", + value_parser = RangedU64ValueParser::::new().range(..u64::MAX), + )] + pub sender_nonce: Option, + + /// Send via `eth_sendTransaction` using the `--sender` argument as sender. + #[arg( + long, + conflicts_with_all = &["private_key", "private_keys", "ledger", "trezor", "aws", "gcp", "turnkey", "browser"], )] pub unlocked: bool, @@ -148,6 +206,26 @@ pub struct ScriptArgs { #[arg(long)] pub resume: bool, + /// Selects the interrupted delegated submission attempt to resolve. + #[arg(long, requires = "resume")] + pub resume_attempt: Option, + + /// Records a transaction hash discovered for an interrupted delegated submission. + #[arg( + long, + requires_all = ["resume", "resume_attempt"], + conflicts_with = "resume_retry" + )] + pub resume_tx_hash: Option, + + /// Retries an interrupted delegated submission proven not to have been submitted. + #[arg( + long, + requires_all = ["resume", "resume_attempt"], + conflicts_with = "resume_tx_hash" + )] + pub resume_retry: bool, + /// If present, --resume or --verify will be assumed to be a multi chain deployment. #[arg(long)] pub multi: bool, @@ -158,6 +236,10 @@ pub struct ScriptArgs { #[arg(long)] pub debug: bool, + /// Debugger layout to use. + #[arg(long = "debug-layout", requires = "debug", value_enum)] + pub debug_layout: Option, + /// Dumps all debugger steps to file. #[arg( long, @@ -169,9 +251,23 @@ pub struct ScriptArgs { /// Makes sure a transaction is sent, /// only after its previous one has been confirmed and succeeded. + /// + /// Transactions are prepared during local script execution, before broadcasting. This flag + /// does not re-run the script or update transaction destinations and calldata derived from + /// simulated return values. + /// + /// State changes or front-running can make those values stale, even with this flag. #[arg(long)] pub slow: bool, + /// The number of confirmations to wait for after broadcasting. + #[arg( + long, + default_value_t = DEFAULT_CONFIRMATIONS, + value_parser = RangedU64ValueParser::::new().range(1..), + )] + pub confirmations: u64, + /// Disables interactive prompts that might appear when deploying big contracts. /// /// For more info on the contract size limit, see EIP-170: @@ -182,18 +278,21 @@ pub struct ScriptArgs { #[arg(long)] pub disable_code_size_limit: bool, + #[command(flatten)] + pub tracing: TracingArgs, + /// The Etherscan (or equivalent) API key #[arg(long, env = "ETHERSCAN_API_KEY", value_name = "KEY")] pub etherscan_api_key: Option, - /// The Etherscan API version. - #[arg(long, env = "ETHERSCAN_API_VERSION", value_name = "VERSION")] - pub etherscan_api_version: Option, - /// Verifies all the contracts found in the receipts of a script, if any. - #[arg(long)] + #[arg(long, requires = "broadcast")] pub verify: bool, + /// Opt-in verification of contracts deployed by external factories using explorer source. + #[arg(long, requires = "verify", conflicts_with_all = ["skip_simulation", "offline"])] + pub verify_external: bool, + /// Gas price for legacy transactions, or max fee per gas for EIP1559 transactions, either /// specified in wei, or as a string with a unit type. /// @@ -227,33 +326,250 @@ pub struct ScriptArgs { } impl ScriptArgs { - pub async fn preprocess(self) -> Result { - let script_wallets = Wallets::new(self.wallets.get_multi_wallet().await?, self.evm.sender); + fn has_tempo_session(&self) -> Result { + Ok(self.tempo.session_id()?.is_some()) + } + + /// Resolves evm_opts (including network inference from fork) using the loaded config. + async fn resolved_evm_opts( + &self, + mut config: Config, + mut evm_opts: EvmOpts, + ) -> Result<(Config, EvmOpts)> { + if self.tempo.is_tempo() || self.has_tempo_session()? { + if evm_opts.networks.has_network_selection() { + let network = evm_opts.networks.execution_network(); + eyre::ensure!( + network.is_tempo(), + "Tempo transaction options conflict with configured network `{}`", + evm_opts.networks.execution_profile_name() + ); + } + // If Tempo tx options or a session are set, select the Tempo network. + evm_opts.networks = NetworkConfigs::with_tempo(); + } + // Discover endpoint source identity and exact hardfork even when execution network is + // explicit. Without an explicit selection this also infers the execution network. + evm_opts.infer_network_from_fork().await?; + config.networks = evm_opts.networks; + + Ok((config, evm_opts)) + } + + async fn preprocess( + self, + mut config: Config, + mut evm_opts: EvmOpts, + executor_builder: ExecutorBuilder, + ) -> Result> { + let args = self; + let mut tempo = args.tempo.clone(); + + let session_sender = if args.resume { + None + } else { + // Initial scripts may only reveal multi-chain transactions during execution. Use the + // session root as the script sender here and validate chain scope during broadcast. + tempo.session_sender_for_multi_wallet(&args.wallets, args.evm.sender)? + }; - let (config, mut evm_opts) = self.load_config_and_evm_opts()?; + // Resume reconciles durable submissions before loading interactive signers. + let (script_wallets, browser_wallet) = if args.resume { + (Wallets::new(MultiWallet::default(), args.evm.sender), None) + } else { + ( + Wallets::new(args.wallets.get_multi_wallet().await?, args.evm.sender), + args.wallets.browser_signer::().await?, + ) + }; - if let Some(sender) = self.maybe_load_private_key()? { + if let Some(sender) = session_sender { evm_opts.sender = sender; + } else if !args.resume + && args.evm.sender.is_none() + && let Some(sender) = args.maybe_load_private_key()? + { + evm_opts.sender = sender; + } else if !args.resume && args.evm.sender.is_none() { + // If no sender was explicitly set via --sender, auto-detect it from available signers: + // use the sole signer's address if there's exactly one, or fall back to the browser + // wallet address if present. + let addresses = script_wallets.addresses(); + if addresses.len() == 1 { + evm_opts.sender = addresses[0]; + } else if let Some(signer) = browser_wallet.as_ref().map(|b| b.address()) { + evm_opts.sender = signer + } } - let script_config = ScriptConfig::new(config, evm_opts).await?; + tempo.resolve_expires(); + config.tracing = args.tracing.resolve(&config.tracing, evm_opts.verbosity); + if args.debug && !config.extra_output.contains(&ContractOutputSelection::StorageLayout) { + config.extra_output.push(ContractOutputSelection::StorageLayout); + } - Ok(PreprocessedState { args: self, script_config, script_wallets }) + let script_config = ScriptConfig::new( + config, + evm_opts, + executor_builder, + args.batch, + tempo, + args.sender_nonce, + ) + .await?; + Ok(PreprocessedState { args, script_config, script_wallets, browser_wallet }) } /// Executes the script + #[allow(clippy::large_stack_frames)] pub async fn run_script(self) -> Result<()> { trace!(target: "script", "executing script command"); - let state = self.preprocess().await?; + if self.tempo.sponsor_url.is_some() { + eyre::bail!( + "--sponsor-url is not supported by forge script; use --tempo.sponsor with \ + --tempo.sponsor-signer or --tempo.sponsor-sig" + ); + } + + if self.wallet_session.enabled { + return self.run_wallet_session_wrapper(); + } + + let (mut config, evm_opts) = self.load_config_and_evm_opts()?; + self.install_missing_dependencies(&mut config)?; + let (config, evm_opts) = self.resolved_evm_opts(config, evm_opts).await?; + + let is_tempo = evm_opts.networks.is_tempo(); + + if self.batch && !is_tempo { + eyre::bail!("--batch mode is only supported on Tempo networks"); + } + + if self.unlocked && self.has_tempo_session()? { + eyre::bail!("--tempo.session/TEMPO_SESSION_ID cannot be combined with --unlocked"); + } + + // Box each branch's future to keep its large async state off `run_script`'s future; + // otherwise `run_command` trips `clippy::large_stack_frames` by a small margin. + match evm_opts.networks.execution_network() { + NetworkVariant::Tempo => { + let batch = self.batch; + Box::pin(async move { + let bundled = match self + .prepare_bundled::( + config, + evm_opts, + ExecutorBuilder::::new(), + ) + .await? + { + Some(bundled) => bundled, + None => return Ok(()), + }; + // batch mode owns its own pending recovery inside broadcast_batch(); running + // the generic wait_for_pending() first would race with that and could + // double-process an already-confirmed batch hash. + let bundled = if batch { bundled } else { bundled.wait_for_pending().await? }; + let broadcasted = if batch { + bundled.broadcast_batch().await? + } else { + bundled.broadcast().await? + }; + if broadcasted.args.verify { + broadcasted.verify().await?; + } + Ok(()) + }) + .await + } + #[cfg(feature = "base")] + NetworkVariant::Base => { + Box::pin(self.run_generic_script::( + config, + evm_opts, + ExecutorBuilder::::new(), + )) + .await + } + #[cfg(feature = "monad")] + NetworkVariant::Monad => { + Box::pin(async move { + let Some(prepared) = self + .prepare_script(config, evm_opts, ExecutorBuilder::::new()) + .await? + else { + return Ok(()); + }; + let bundled = match prepared { + PreparedScript::Resume(bundled) => *bundled, + PreparedScript::Simulate(state) => { + state.fill_monad_metadata().await?.bundle().await? + } + }; + let Some(bundled) = Self::finish_bundle(bundled).await? else { return Ok(()) }; + Self::broadcast_bundle(bundled).await + }) + .await + } + #[cfg(feature = "optimism")] + NetworkVariant::Optimism => { + Box::pin(self.run_generic_script::( + config, + evm_opts, + ExecutorBuilder::::new(), + )) + .await + } + NetworkVariant::Ethereum => { + Box::pin(self.run_generic_script::( + config, + evm_opts, + ExecutorBuilder::::new(), + )) + .await + } + } + } + + /// Prepares the bundled state (compile, simulate, bundle) and returns it + /// for broadcasting, or returns `None` if there's nothing to broadcast + /// (e.g., debug mode, no transactions, missing RPCs). + #[allow(clippy::large_stack_frames)] + async fn prepare_bundled( + self, + config: Config, + evm_opts: EvmOpts, + executor_builder: ExecutorBuilder, + ) -> Result>> { + let Some(prepared) = self.prepare_script(config, evm_opts, executor_builder).await? else { + return Ok(None); + }; + let bundled = match prepared { + PreparedScript::Resume(bundled) => *bundled, + PreparedScript::Simulate(state) => { + state.fill_ordinary_metadata().await?.bundle().await? + } + }; + Self::finish_bundle(bundled).await + } + + /// Compiles and executes the local script, leaving on-chain simulation to its concrete owner. + #[allow(clippy::large_stack_frames)] + async fn prepare_script( + self, + config: Config, + evm_opts: EvmOpts, + executor_builder: ExecutorBuilder, + ) -> Result>> { + let state = self.preprocess::(config, evm_opts, executor_builder).await?; let create2_deployer = state.script_config.evm_opts.create2_deployer; let compiled = state.compile()?; // Move from `CompiledState` to `BundledState` either by resuming or executing and // simulating script. - let bundled = if compiled.args.resume || (compiled.args.verify && !compiled.args.broadcast) - { - compiled.resume().await? + let prepared = if compiled.args.resume { + PreparedScript::Resume(Box::new(compiled.resume().await?)) } else { // Drive state machine to point at which we have everything needed for simulation. let pre_simulation = compiled @@ -264,12 +580,14 @@ impl ScriptArgs { .execute() .await? .prepare_simulation() + .await? + .optimize_library_deployments() .await?; if pre_simulation.args.debug { return match pre_simulation.args.dump.clone() { - Some(path) => pre_simulation.dump_debugger(&path), - None => pre_simulation.run_debugger(), + Some(path) => pre_simulation.dump_debugger(&path).map(|_| None), + None => pre_simulation.run_debugger().map(|_| None), }; } @@ -291,7 +609,7 @@ impl ScriptArgs { sh_warn!("No transactions to broadcast.")?; } - return Ok(()); + return Ok(None); } // Check if there are any missing RPCs and exit early to avoid hard error. @@ -300,18 +618,29 @@ impl ScriptArgs { sh_println!("\nIf you wish to simulate on-chain transactions pass a RPC URL.")?; } - return Ok(()); + return Ok(None); } + let size_limits = pre_simulation.args.contract_size_limits::( + &pre_simulation.script_config.config, + &pre_simulation.script_config.evm_opts, + ); pre_simulation.args.check_contract_sizes( + size_limits, &pre_simulation.execution_result, &pre_simulation.build_data.known_contracts, create2_deployer, )?; - pre_simulation.fill_metadata().await?.bundle().await? + PreparedScript::Simulate(Box::new(pre_simulation)) }; + Ok(Some(prepared)) + } + /// Performs the shared post-simulation output and verification preflight checks. + async fn finish_bundle( + bundled: BundledState, + ) -> Result>> { // Exit early in case user didn't provide any broadcast/verify related flags. if !bundled.args.should_broadcast() { if !shell::is_json() { @@ -324,15 +653,33 @@ impl ScriptArgs { "\nSIMULATION COMPLETE. To broadcast these transactions, add --broadcast and wallet configuration(s) to the previous command. See forge script --help for more." )?; } - return Ok(()); + return Ok(None); } // Exit early if something is wrong with verification options. if bundled.args.verify { - bundled.verify_preflight_check()?; + bundled.verify_preflight_check().await?; } + Ok(Some(bundled)) + } + + async fn run_generic_script( + self, + config: Config, + evm_opts: EvmOpts, + executor_builder: ExecutorBuilder, + ) -> Result<()> { + let bundled = match self.prepare_bundled::(config, evm_opts, executor_builder).await? { + Some(bundled) => bundled, + None => return Ok(()), + }; + // Wait for pending txes and broadcast others. + Self::broadcast_bundle(bundled).await + } + + async fn broadcast_bundle(bundled: BundledState) -> Result<()> { let broadcasted = bundled.wait_for_pending().await?.broadcast().await?; if broadcasted.args.verify { @@ -342,9 +689,13 @@ impl ScriptArgs { Ok(()) } - /// In case the user has loaded *only* one private-key, we can assume that he's using it as the - /// `--sender` + /// In case the user has loaded *only* one private-key or a single remote signer (e.g., + /// Turnkey), we can assume that they're using it as the `--sender`. fn maybe_load_private_key(&self) -> Result> { + if let Some(turnkey_address) = self.wallets.turnkey_address() { + return Ok(Some(turnkey_address)); + } + let maybe_sender = self .wallets .private_keys()? @@ -382,12 +733,16 @@ impl ScriptArgs { let matching_functions = abi.functions().filter(|func| func.name == self.sig).collect::>(); match matching_functions.len() { - 0 => eyre::bail!("Function `{}` not found in the ABI", self.sig), + 0 => { + eyre::bail!("Function `{}` not found in the ABI", self.sig); + } 1 => matching_functions[0], - 2.. => eyre::bail!( - "Multiple functions with the same name `{}` found in the ABI", - self.sig - ), + 2.. => { + eyre::bail!( + "Multiple functions with the same name `{}` found in the ABI", + self.sig + ); + } } }; let data = encode_function_args(func, &self.args)?; @@ -395,14 +750,15 @@ impl ScriptArgs { Ok((func.clone(), data.into())) } - /// Checks if the transaction is a deployment with either a size above the `CONTRACT_MAX_SIZE` - /// or specified `code_size_limit`. + /// Checks if the transaction is a deployment with a size above the active EVM specification's + /// contract size limit or the specified `code_size_limit`. /// /// If `self.broadcast` is enabled, it asks confirmation of the user. Otherwise, it just warns /// the user. - fn check_contract_sizes( + fn check_contract_sizes( &self, - result: &ScriptResult, + size_limits: ContractSizeLimits, + result: &ScriptResult, known_contracts: &ContractsByArtifact, create2_deployer: Address, ) -> Result<()> { @@ -433,14 +789,10 @@ impl ScriptArgs { bytecodes.push((format!("Unknown{unknown_c}"), init_code, deployed_code)); unknown_c += 1; } - continue; } let mut prompt_user = false; - let max_size = match self.evm.env.code_size_limit { - Some(size) => size, - None => CONTRACT_MAX_SIZE, - }; + let max_size = size_limits.runtime; for (data, to) in result.transactions.iter().flat_map(|txes| { txes.iter().filter_map(|tx| { @@ -453,15 +805,13 @@ impl ScriptArgs { let mut offset = 0; // Find if it's a CREATE or CREATE2. Otherwise, skip transaction. - if let Some(TxKind::Call(to)) = to { + if let Some(to) = to { if to == create2_deployer { // Size of the salt prefix. offset = 32; } else { continue; } - } else if let Some(TxKind::Create) = to { - // Pass } // Find artifact with a deployment code same as the data. @@ -490,9 +840,32 @@ impl ScriptArgs { Ok(()) } - /// We only broadcast transactions if --broadcast or --resume was passed. - fn should_broadcast(&self) -> bool { - self.broadcast || self.resume + fn contract_size_limits( + &self, + config: &Config, + evm_opts: &EvmOpts, + ) -> ContractSizeLimits { + self.evm + .env + .code_size_limit + .or(evm_opts.env.code_size_limit) + .or(config.code_size_limit) + .map(ContractSizeLimits::with_runtime_limit) + .or_else(|| { + evm_opts + .networks + .contract_size_limits() + .map(|limits| ContractSizeLimits::new(limits.runtime, limits.initcode)) + }) + .unwrap_or_else(|| { + let spec_id: SpecFor = config.evm_spec_id(); + ContractSizeLimits::for_spec_id(spec_id.into()) + }) + } + + /// We only broadcast transactions if --broadcast, --resume, or --verify was passed. + const fn should_broadcast(&self) -> bool { + self.broadcast || self.resume || self.verify } } @@ -503,26 +876,34 @@ impl Provider for ScriptArgs { fn data(&self) -> Result, figment::Error> { let mut dict = Dict::default(); - if let Some(ref etherscan_api_key) = + + if let Some(etherscan_api_key) = self.etherscan_api_key.as_ref().filter(|s| !s.trim().is_empty()) { dict.insert( "etherscan_api_key".to_string(), - figment::value::Value::from(etherscan_api_key.to_string()), + figment::value::Value::from(etherscan_api_key.clone()), ); } - if let Some(api_version) = &self.etherscan_api_version { - dict.insert("etherscan_api_version".to_string(), api_version.to_string().into()); - } + if let Some(timeout) = self.timeout { dict.insert("transaction_timeout".to_string(), timeout.into()); } + + if let Some(preset) = self.eip1559_fee_estimate { + dict.insert( + "eip1559_fee_estimate".to_string(), + figment::value::Value::from(preset.to_string()), + ); + } + Ok(Map::from([(Config::selected_profile(), dict)])) } } -#[derive(Default, Serialize, Clone)] -pub struct ScriptResult { +#[derive(Serialize, Clone)] +#[serde(bound = "")] +pub struct ScriptResult { pub success: bool, #[serde(rename = "raw_logs")] pub logs: Vec, @@ -530,27 +911,75 @@ pub struct ScriptResult { pub gas_used: u64, pub labeled_addresses: AddressHashMap, #[serde(skip)] - pub transactions: Option, + pub debug_bytecodes: AddressHashMap, + #[serde(skip)] + pub transactions: Option>, pub returned: Bytes, + #[serde(skip)] + pub exit_reason: Option, pub address: Option
, #[serde(skip)] pub breakpoints: Breakpoints, } -impl ScriptResult { - pub fn get_created_contracts(&self) -> Vec { +impl Default for ScriptResult { + fn default() -> Self { + Self { + success: Default::default(), + logs: Default::default(), + traces: Default::default(), + gas_used: Default::default(), + labeled_addresses: Default::default(), + debug_bytecodes: Default::default(), + transactions: Default::default(), + returned: Default::default(), + exit_reason: Default::default(), + address: Default::default(), + breakpoints: Default::default(), + } + } +} + +impl ScriptResult { + pub fn get_created_contracts( + &self, + known_contracts: &ContractsByArtifact, + ) -> Vec { self.traces .iter() .flat_map(|(_, traces)| { - traces.nodes().iter().filter_map(|node| { - if node.trace.kind.is_any_create() { - return Some(AdditionalContract { - opcode: node.trace.kind, - address: node.trace.address, - init_code: node.trace.data.clone(), - }); + let nodes = traces.nodes(); + nodes.iter().filter_map(|node| { + if !node.trace.kind.is_any_create() || !node.trace.success { + return None; } - None + + let mut creator_code_addresses = Vec::new(); + let mut parent = node.parent; + while let Some(parent_idx) = parent { + let ancestor = &nodes[parent_idx]; + if !ancestor.trace.success { + return None; + } + if !ancestor.trace.kind.is_any_create() + && !creator_code_addresses.contains(&ancestor.trace.address) + { + creator_code_addresses.push(ancestor.trace.address); + } + parent = ancestor.parent; + } + + let init_code = node.trace.data.clone(); + let contract_name = known_contracts + .find_by_creation_code(init_code.as_ref()) + .map(|artifact| artifact.0.name.clone()); + Some(AdditionalContract { + call_kind: node.trace.kind, + address: node.trace.address, + contract_name, + init_code, + creator_code_addresses, + }) }) }) .collect() @@ -558,37 +987,78 @@ impl ScriptResult { } #[derive(Serialize)] -struct JsonResult<'a> { +#[serde(bound = "")] +struct JsonResult<'a, N: Network> { logs: Vec, returns: &'a HashMap, #[serde(flatten)] - result: &'a ScriptResult, + result: &'a ScriptResult, } #[derive(Clone, Debug)] -pub struct ScriptConfig { +pub struct ScriptConfig { pub config: Config, pub evm_opts: EvmOpts, + /// Executor construction selected by concrete network dispatch. + pub executor_builder: ExecutorBuilder, + /// Exact network hardfork selected for script execution. + pub hardfork: Option, + /// Source chain used for trace decoding and external identifiers. + pub source_chain_id: Option, pub sender_nonce: u64, - /// Maps a rpc url to a backend - pub backends: HashMap, + sender_nonce_override: Option, + /// Pristine backend shared by preflight and cloned for each execution. + backend: Backend, + /// Whether to batch all broadcast transactions into a single Tempo batch transaction. + pub batch: bool, + /// Tempo transaction options applied to broadcast transactions. + pub tempo: TempoOpts, } -impl ScriptConfig { - pub async fn new(config: Config, evm_opts: EvmOpts) -> Result { - let sender_nonce = if let Some(fork_url) = evm_opts.fork_url.as_ref() { - next_nonce(evm_opts.sender, fork_url, evm_opts.fork_block_number).await? +impl ScriptConfig { + pub(crate) async fn new( + mut config: Config, + mut evm_opts: EvmOpts, + executor_builder: ExecutorBuilder, + batch: bool, + tempo: TempoOpts, + sender_nonce_override: Option, + ) -> Result { + // Linking and execution share the backend created here, before any preflight reads. + prepare_script_source(&mut config, &mut evm_opts, None).await?; + let backend = Backend::spawn(evm_opts.get_fork( + &config, + evm_opts.env.chain_id.unwrap_or_default(), + None, + ))?; + let sender_nonce = if let Some(sender_nonce) = sender_nonce_override { + sender_nonce + } else if evm_opts.fork_url.is_some() { + backend.transaction_count(evm_opts.sender).await? } else { // dapptools compatibility 1 }; - Ok(Self { config, evm_opts, sender_nonce, backends: HashMap::default() }) + Ok(Self { + config, + evm_opts, + executor_builder, + hardfork: None, + source_chain_id: None, + sender_nonce, + sender_nonce_override, + backend, + batch, + tempo, + }) } pub async fn update_sender(&mut self, sender: Address) -> Result<()> { - self.sender_nonce = if let Some(fork_url) = self.evm_opts.fork_url.as_ref() { - next_nonce(sender, fork_url, None).await? + self.sender_nonce = if let Some(sender_nonce) = self.sender_nonce_override { + sender_nonce + } else if self.evm_opts.fork_url.is_some() { + self.backend.transaction_count(sender).await? } else { // dapptools compatibility 1 @@ -597,8 +1067,67 @@ impl ScriptConfig { Ok(()) } - async fn get_runner(&mut self) -> Result { - self._get_runner(None, false).await + async fn select_rpc(&mut self, fork_url: String) -> Result<()> { + self.evm_opts.set_fork_url(fork_url); + self.select_backend().await + } + + async fn select_backend(&mut self) -> Result<()> { + if self.evm_opts.fork_url.is_none() { + if self.backend.fork()?.is_some() { + self.backend.replace_fork(None)?; + } + return Ok(()); + } + if !self.prepare_requested_source().await? { + return Ok(()); + } + self.backend.replace_fork(self.evm_opts.get_fork( + &self.config, + self.evm_opts.env.chain_id.unwrap_or_default(), + None, + )) + } + + /// Prepares the requested RPC source. Returns `false` if the backend already serves it. + async fn prepare_requested_source(&mut self) -> Result { + if let Some(fork) = self.backend.fork()? { + if fork.matches_request(&self.evm_opts) { + return Ok(false); + } + self.evm_opts.invalidate_fork_endpoint_if_source_changed(&fork); + } + let active_networks = self.config.networks; + prepare_script_source(&mut self.config, &mut self.evm_opts, Some(active_networks)).await?; + Ok(true) + } + + /// Resolves the execution spec for `fork_url` without a backend for that RPC. + /// + /// Trace decoders only need the source chain and hardfork, so they do not open a fork cache. + async fn resolve_rpc_execution_spec(&mut self, fork_url: String) -> Result<()> { + self.evm_opts.set_fork_url(fork_url); + if !self.prepare_requested_source().await? { + self.resolve_execution_env().await?; + return Ok(()); + } + let (mut evm_env, _, fork_context) = + self.evm_opts.env_with_fork_context::<_, _, TxEnvFor>().await?; + self.apply_execution_spec(&mut evm_env, fork_context); + Ok(()) + } + + pub(crate) async fn update_tempo_session_sender( + &mut self, + wallets: &MultiWalletOpts, + expected_sender: Option
, + ) -> Result<()> { + if let Some(sender) = + self.tempo.session_sender_for_multi_wallet(wallets, expected_sender)? + { + self.update_sender(sender).await?; + } + Ok(()) } async fn get_runner_with_cheatcodes( @@ -607,74 +1136,766 @@ impl ScriptConfig { script_wallets: Wallets, debug: bool, target: ArtifactId, - ) -> Result { - self._get_runner(Some((known_contracts, script_wallets, target)), debug).await + restricted: bool, + ) -> Result> { + let mut runner = self + ._get_runner(Some((known_contracts, script_wallets, target)), debug, restricted) + .await?; + + // Script execution is synthetic. Keep the Tempo transaction context, but do not charge + // protocol fees for deploying or calling the local script contract. + if self.evm_opts.networks.is_tempo() { + runner.executor.evm_env_mut().cfg_env.disable_fee_charge = true; + } + + Ok(runner) } async fn _get_runner( &mut self, cheats_data: Option<(ContractsByArtifact, Wallets, ArtifactId)>, debug: bool, - ) -> Result { + restricted: bool, + ) -> Result> { trace!("preparing script runner"); - let env = self.evm_opts.evm_env().await?; - - let db = if let Some(fork_url) = self.evm_opts.fork_url.as_ref() { - match self.backends.get(fork_url) { - Some(db) => db.clone(), - None => { - let fork = self.evm_opts.get_fork(&self.config, env.clone()); - let backend = Backend::spawn(fork)?; - self.backends.insert(fork_url.clone(), backend.clone()); - backend - } - } - } else { - // It's only really `None`, when we don't pass any `--fork-url`. And if so, there is - // no need to cache it, since there won't be any onchain simulation that we'd need - // to cache the backend for. - Backend::spawn(None)? - }; + let (evm_env, mut tx_env) = self.resolve_execution_env().await?; + let db = self.backend.clone(); // We need to enable tracing to decode contract names: local or external. - let mut builder = ExecutorBuilder::new() + let mut builder = self + .executor_builder + .clone() .inspectors(|stack| { stack - .trace_mode(if debug { TraceMode::Debug } else { TraceMode::Call }) - .odyssey(self.evm_opts.odyssey) + .logs(self.config.live_logs) + .trace_requirements(script_trace_requirements(&self.config, debug)) .create2_deployer(self.evm_opts.create2_deployer) }) - .spec_id(self.config.evm_spec_id()) .gas_limit(self.evm_opts.gas_limit()) .legacy_assertions(self.config.legacy_assertions); if let Some((known_contracts, script_wallets, target)) = cheats_data { builder = builder.inspectors(|stack| { + let mut cheats_config = CheatsConfig::new( + &self.config, + self.evm_opts.clone(), + Some(known_contracts), + Some(target), + self.batch, + ); + if restricted { + cheats_config.blocked_cheatcodes = + library_deployments::rerun_unsafe_cheatcode_selectors(); + } stack - .cheatcodes( - CheatsConfig::new( - &self.config, - self.evm_opts.clone(), - Some(known_contracts), - Some(target), - ) - .into(), - ) + .cheatcodes(cheats_config.into()) .wallets(script_wallets) .enable_isolation(self.evm_opts.isolate) }); } - Ok(ScriptRunner::new(builder.build(env, db), self.evm_opts.clone())) + // Propagate fee token to the transaction environment so that internal EVM calls + // (e.g. script deployment, setUp) use the correct fee token for Tempo networks. + tx_env.set_fee_token(self.tempo.fee_token); + + let mut runner = ScriptRunner::new( + builder.build(evm_env, tx_env, db, self.evm_opts.networks), + self.evm_opts.clone(), + ) + .with_debug_bytecodes(debug); + + if self.sender_nonce_override.is_some() { + runner.executor.set_nonce(self.evm_opts.sender, self.sender_nonce)?; + } + + Ok(runner) + } + + /// Builds the execution environment and spec from the selected backend. + async fn resolve_execution_env(&mut self) -> Result<(EvmEnvFor, TxEnvFor)> { + let (mut evm_env, tx_env) = self.backend.env(&self.evm_opts).await?; + let fork_context = self.backend.fork()?.as_ref().map(|fork| fork.context()); + self.apply_execution_spec(&mut evm_env, fork_context); + Ok((evm_env, tx_env)) + } + + /// Records the source chain and resolves the execution spec for `fork_context`. + fn apply_execution_spec( + &mut self, + evm_env: &mut EvmEnvFor, + fork_context: Option, + ) { + let fork_chain_id = fork_context.map(|context| context.source_chain_id); + let fork_hardfork = fork_context.and_then(|context| context.hardfork); + self.source_chain_id = fork_chain_id; + self.hardfork = resolve_execution_spec( + self.config.evm_version, + self.config.hardfork, + evm_env, + ExecutionSpecContext::local_or_fork(fork_chain_id, fork_hardfork), + None, + ); } } +async fn prepare_script_source( + config: &mut Config, + evm_opts: &mut EvmOpts, + active_networks: Option, +) -> Result<()> { + if evm_opts.fork_url.is_none() { + return Ok(()); + } + if evm_opts.fork_endpoint.is_none() { + evm_opts.infer_network_from_fork().await?; + } + if let Some(active_networks) = active_networks + && !active_networks.supports_fork_source(&evm_opts.networks) + { + eyre::bail!( + "fork network `{}` is incompatible with the active EVM", + evm_opts.networks.execution_network() + ); + } + config.networks = evm_opts.networks; + if let Some(identity) = evm_opts.fork_endpoint.clone() { + let network_is_inferred = evm_opts.fork_network_is_inferred; + evm_opts.expect_fork_endpoint(identity, network_is_inferred); + } + Ok(()) +} + +const fn script_trace_requirements(config: &Config, debug: bool) -> TraceRequirements { + TraceRequirements::none() + .with_calls(true) + .with_debug(debug) + .with_verbosity(config.tracing.verbosity) + .with_decode_internal(if config.tracing.decode_internal { + InternalTraceMode::Full + } else { + InternalTraceMode::None + }) +} + #[cfg(test)] mod tests { use super::*; - use foundry_config::{NamedChain, UnresolvedEnvVarError}; - use std::fs; + use alloy_chains::NamedChain; + use alloy_network::Ethereum; + use alloy_primitives::address; + use alloy_provider::Provider as _; + use alloy_rpc_types::TransactionRequest; + use alloy_signer::SignerSync as _; + use anvil::{NodeConfig, spawn}; + use foundry_cli::opts::TEMPO_SESSION_ID_ENV; + use foundry_common::tempo::{ + GeneratedSessionKey, SessionAuthorizationRequest, SessionEntry, TEMPO_HOME_ENV, + upsert_session_entry, + }; + use foundry_config::UnresolvedEnvVarError; + use foundry_evm::{ + revm::context::Block as _, + traces::{ + CallKind, CallTrace, CallTraceArena, CallTraceNode, SparsedTraceArena, TraceKind, + }, + }; + use semver::Version; + use std::{ + fs, + num::NonZeroU64, + sync::{Arc, LazyLock}, + }; use tempfile::tempdir; + use tokio::sync::{Mutex, MutexGuard}; + + const SESSION_PRIVATE_KEY: &str = + "0x59c6995e998f97a5a004497e5da3b5d2b2b66a87f064d39c44da0b6d6e4f8ff0"; + const SESSION_ROOT_PRIVATE_KEY: &str = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + const SESSION_ID_HEX: &str = + "0x1111111111111111111111111111111111111111111111111111111111111111"; + const SESSION_ROOT_ADDRESS: &str = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; + static TEMPO_HOME_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); + + fn assert_missing_orphaned_block(error: &eyre::Report) { + let message = format!("{error:#}").to_ascii_lowercase(); + assert!( + message.contains("failed to get block hash") + || message.contains("not found") + || message.contains("unknown block"), + "unexpected orphaned block lookup error: {error:#}" + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn script_fork_context_is_re_resolved_for_a_new_rpc() { + let (api_a, handle_a) = spawn(NodeConfig::test()).await; + let (api_b, handle_b) = spawn(NodeConfig::test()).await; + api_a.anvil_mine(Some(U256::ONE), None).await.unwrap(); + api_b.anvil_mine(Some(U256::from(3)), None).await.unwrap(); + + let evm_opts = EvmOpts { + fork_url: Some(handle_a.http_endpoint()), + sender: handle_a.dev_accounts().next().unwrap(), + ..Default::default() + }; + let mut config = ScriptConfig::::new( + Config::default(), + evm_opts, + ExecutorBuilder::::new(), + false, + TempoOpts::default(), + None, + ) + .await + .unwrap(); + let first = config.backend.fork().unwrap().unwrap(); + assert_eq!(first.number(), 1); + assert_eq!(config.evm_opts.fork_block_number, None); + assert!(first.matches_request(&config.evm_opts)); + + config._get_runner(None, false, false).await.unwrap(); + + config.select_rpc(handle_b.http_endpoint()).await.unwrap(); + let second = config.backend.fork().unwrap().unwrap(); + + assert_eq!(second.number(), 3); + assert_eq!(config.evm_opts.fork_block_number, None); + assert!(second.matches_request(&config.evm_opts)); + assert_ne!(first, second); + } + + #[cfg(feature = "monad")] + #[tokio::test(flavor = "multi_thread")] + async fn script_explicit_network_is_preserved_for_a_new_rpc() { + let (api_a, handle_a) = spawn(NodeConfig::test_monad()).await; + let (api_b, handle_b) = spawn(NodeConfig::test_monad()).await; + api_a.anvil_mine(Some(U256::ONE), None).await.unwrap(); + api_b.anvil_mine(Some(U256::from(3)), None).await.unwrap(); + + let evm_opts = EvmOpts { + fork_url: Some(handle_a.http_endpoint()), + sender: handle_a.dev_accounts().next().unwrap(), + networks: NetworkConfigs::with_ethereum(), + ..Default::default() + }; + let mut config = ScriptConfig::::new( + Config::default(), + evm_opts, + ExecutorBuilder::::new(), + false, + TempoOpts::default(), + None, + ) + .await + .unwrap(); + assert_eq!(config.evm_opts.networks, NetworkConfigs::with_ethereum()); + + config.select_rpc(handle_b.http_endpoint()).await.unwrap(); + let second = config.backend.fork().unwrap().unwrap(); + + assert_eq!(second.number(), 3); + assert_eq!(second.context().network_profile, NetworkConfigs::with_monad()); + assert_eq!(config.evm_opts.networks, NetworkConfigs::with_ethereum()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn script_explicit_fork_block_is_preserved_for_a_new_rpc() { + let (api_a, handle_a) = spawn(NodeConfig::test()).await; + let (api_b, handle_b) = spawn(NodeConfig::test()).await; + api_a.anvil_mine(Some(U256::from(2)), None).await.unwrap(); + api_b.anvil_mine(Some(U256::from(4)), None).await.unwrap(); + + let evm_opts = EvmOpts { + fork_url: Some(handle_a.http_endpoint()), + fork_block_number: Some(1), + sender: handle_a.dev_accounts().next().unwrap(), + ..Default::default() + }; + let mut config = ScriptConfig::::new( + Config::default(), + evm_opts, + ExecutorBuilder::::new(), + false, + TempoOpts::default(), + None, + ) + .await + .unwrap(); + config._get_runner(None, false, false).await.unwrap(); + + config.select_rpc(handle_b.http_endpoint()).await.unwrap(); + let context = config.backend.fork().unwrap().unwrap(); + + assert_eq!(context.number(), 1); + assert_eq!(config.evm_opts.fork_block_number, Some(1)); + assert!(context.matches_request(&config.evm_opts)); + } + + #[tokio::test(flavor = "multi_thread")] + async fn script_explicit_fork_block_can_equal_previous_latest() { + let (api_a, handle_a) = spawn(NodeConfig::test()).await; + let (api_b, handle_b) = spawn(NodeConfig::test()).await; + api_a.anvil_mine(Some(U256::ONE), None).await.unwrap(); + api_b.anvil_mine(Some(U256::ONE), None).await.unwrap(); + + let evm_opts = EvmOpts { + fork_url: Some(handle_a.http_endpoint()), + sender: handle_a.dev_accounts().next().unwrap(), + ..Default::default() + }; + let mut config = ScriptConfig::::new( + Config::default(), + evm_opts, + ExecutorBuilder::::new(), + false, + TempoOpts::default(), + None, + ) + .await + .unwrap(); + let latest = config.backend.fork().unwrap().unwrap(); + assert_eq!(config.evm_opts.fork_block_number, None); + config._get_runner(None, false, false).await.unwrap(); + + config.select_rpc(handle_b.http_endpoint()).await.unwrap(); + config.evm_opts.fork_block_number = Some(1); + config.select_backend().await.unwrap(); + let explicit = config.backend.fork().unwrap().unwrap(); + + assert_eq!(explicit.number(), 1); + assert_eq!(config.evm_opts.fork_block_number, Some(1)); + assert!(explicit.matches_request(&config.evm_opts)); + assert_ne!(latest, explicit); + + config._get_runner(None, false, false).await.unwrap(); + } + + #[tokio::test(flavor = "multi_thread")] + async fn script_fork_cache_distinguishes_same_height_contexts() { + let (api_a, handle_a) = spawn(NodeConfig::test()).await; + let (api_b, handle_b) = spawn(NodeConfig::test()).await; + let state_address = address!("0000000000000000000000000000000000001337"); + let balance_a = U256::ONE; + let balance_b = U256::from(2); + api_a.anvil_set_balance(state_address, balance_a).await.unwrap(); + api_b.anvil_set_balance(state_address, balance_b).await.unwrap(); + let original_prevrandao = B256::with_last_byte(0x42); + api_a.anvil_set_next_block_prevrandao(original_prevrandao).await.unwrap(); + api_a.anvil_mine(Some(U256::ONE), None).await.unwrap(); + api_b.anvil_mine(Some(U256::ONE), None).await.unwrap(); + let url_a = handle_a.http_endpoint(); + let url_b = handle_b.http_endpoint(); + let sender = handle_a.dev_accounts().next().unwrap(); + + let evm_opts = EvmOpts { fork_url: Some(url_a.clone()), sender, ..Default::default() }; + let mut config = ScriptConfig::::new( + Config::default(), + evm_opts, + ExecutorBuilder::::new(), + false, + TempoOpts::default(), + None, + ) + .await + .unwrap(); + let mut runner_a = config._get_runner(None, false, false).await.unwrap(); + assert_eq!(runner_a.executor.get_balance(state_address).unwrap(), balance_a); + let first_a = config.backend.fork().unwrap().unwrap(); + let cache_a = runner_a.executor.backend().active_fork().unwrap().backend().data(); + runner_a.executor.set_balance(state_address, U256::from(3)).unwrap(); + + config.select_rpc(url_b.clone()).await.unwrap(); + let runner_b = config._get_runner(None, false, false).await.unwrap(); + assert_eq!(runner_b.executor.get_balance(state_address).unwrap(), balance_b); + let context_b = config.backend.fork().unwrap().unwrap().clone(); + assert_eq!(context_b.number(), 1); + + config.select_rpc(url_a.clone()).await.unwrap(); + let reused_a = config._get_runner(None, false, false).await.unwrap(); + assert_eq!(reused_a.executor.get_balance(state_address).unwrap(), balance_a); + assert!(Arc::ptr_eq( + &cache_a, + &reused_a.executor.backend().active_fork().unwrap().backend().data() + )); + config.select_rpc(url_b.clone()).await.unwrap(); + + let provider_a = handle_a.http_provider(); + provider_a + .raw_request::<_, ()>("anvil_reorg".into(), (1_u64, Vec::::new())) + .await + .unwrap(); + let replacement = provider_a.get_block_by_number(1.into()).await.unwrap().unwrap(); + assert_eq!(replacement.header.number, first_a.number()); + assert_ne!(replacement.header.hash, first_a.hash()); + assert_ne!(replacement.header.mix_hash, Some(original_prevrandao)); + + config.select_rpc(url_a.clone()).await.unwrap(); + let second_a = config.backend.fork().unwrap().unwrap(); + assert_eq!(second_a.number(), first_a.number()); + assert_eq!(second_a.hash(), replacement.header.hash); + assert_ne!(second_a.hash(), first_a.hash()); + + let runner = config._get_runner(None, false, false).await.unwrap(); + assert_eq!(runner.executor.get_balance(state_address).unwrap(), balance_a); + assert_eq!(runner.executor.evm_env().block_env.prevrandao(), replacement.header.mix_hash); + } + + #[tokio::test(flavor = "multi_thread")] + async fn script_same_endpoint_keeps_backend_block() { + let (api, handle) = spawn(NodeConfig::test()).await; + api.anvil_mine(Some(U256::ONE), None).await.unwrap(); + let url = handle.http_endpoint(); + let evm_opts = EvmOpts { + fork_url: Some(url.clone()), + sender: handle.dev_accounts().next().unwrap(), + ..Default::default() + }; + let mut config = ScriptConfig::::new( + Config::default(), + evm_opts, + ExecutorBuilder::::new(), + false, + TempoOpts::default(), + None, + ) + .await + .unwrap(); + let resolved = config.backend.fork().unwrap().unwrap(); + config._get_runner(None, false, false).await.unwrap(); + + api.anvil_mine(Some(U256::ONE), None).await.unwrap(); + config.select_rpc(url.clone()).await.unwrap(); + let runner = config._get_runner(None, false, false).await.unwrap(); + + assert_eq!(config.backend.fork().unwrap().as_ref(), Some(&resolved)); + assert_eq!(runner.executor.evm_env().block_env.number(), U256::ONE); + assert_eq!(config.evm_opts.fork_block_number, None); + } + + #[tokio::test(flavor = "multi_thread")] + async fn script_fork_changed_headers_replace_cached_backend() { + let (api, handle) = spawn(NodeConfig::test()).await; + api.anvil_mine(Some(U256::ONE), None).await.unwrap(); + let url = handle.http_endpoint(); + let evm_opts = EvmOpts { + fork_url: Some(url.clone()), + sender: handle.dev_accounts().next().unwrap(), + ..Default::default() + }; + let mut config = ScriptConfig::::new( + Config::default(), + evm_opts, + ExecutorBuilder::::new(), + false, + TempoOpts::default(), + None, + ) + .await + .unwrap(); + config._get_runner(None, false, false).await.unwrap(); + let without_headers = config.backend.fork().unwrap().unwrap().clone(); + + config.evm_opts.fork_headers = Some(vec!["x-foundry-test: resolved-fork".to_string()]); + config.select_backend().await.unwrap(); + let with_headers = config.backend.fork().unwrap().unwrap(); + config._get_runner(None, false, false).await.unwrap(); + + assert_ne!(without_headers, with_headers); + assert!(with_headers.matches_request(&config.evm_opts)); + } + + async fn assert_same_url_fork_auth_change_re_resolves( + configure_initial: impl FnOnce(&mut EvmOpts), + configure_changed: impl FnOnce(&mut EvmOpts), + ) { + let (api, handle) = spawn(NodeConfig::test()).await; + let mut evm_opts = EvmOpts { + fork_url: Some(handle.http_endpoint()), + fork_block_number: Some(0), + networks: NetworkConfigs::with_ethereum(), + ..Default::default() + }; + evm_opts.env.chain_id = Some(42); + configure_initial(&mut evm_opts); + + let mut config = ScriptConfig::::new( + Config::default(), + evm_opts, + ExecutorBuilder::::new(), + false, + TempoOpts::default(), + None, + ) + .await + .unwrap(); + config._get_runner(None, false, false).await.unwrap(); + let first = config.backend.fork().unwrap().unwrap().clone(); + let first_instance = first.context().instance_id.unwrap(); + + api.anvil_reset(None).await.unwrap(); + assert_ne!(api.instance_id(), first_instance); + configure_changed(&mut config.evm_opts); + + config.select_backend().await.unwrap(); + let second = config.backend.fork().unwrap().unwrap(); + assert_ne!(second.context().instance_id, Some(first_instance)); + assert_ne!(second, first); + assert_eq!(config.evm_opts.fork_block_number, Some(0)); + assert_eq!(config.evm_opts.networks, NetworkConfigs::with_ethereum()); + assert_eq!(config.evm_opts.env.chain_id, Some(42)); + + config._get_runner(None, false, false).await.unwrap(); + } + + #[tokio::test(flavor = "multi_thread")] + async fn script_same_url_rpc_headers_change_adopts_new_fork_identity() { + assert_same_url_fork_auth_change_re_resolves( + |evm_opts| evm_opts.rpc_headers = Some(vec!["x-fork-source: first".to_string()]), + |evm_opts| evm_opts.rpc_headers = Some(vec!["x-fork-source: second".to_string()]), + ) + .await; + } + + #[tokio::test(flavor = "multi_thread")] + async fn script_same_url_jwt_change_adopts_new_fork_identity() { + const FIRST_JWT: &str = "5c43996d0d150a81f06ae452fce38120d97a4156650aec7487b3384bfe32edae"; + const SECOND_JWT: &str = "cabee703106087906e50f3e75a6ddbab60809f980511d1d1548d449d52220795"; + + assert_same_url_fork_auth_change_re_resolves( + |evm_opts| evm_opts.rpc_jwt = Some(FIRST_JWT.to_string()), + |evm_opts| evm_opts.rpc_jwt = Some(SECOND_JWT.to_string()), + ) + .await; + } + + #[tokio::test(flavor = "multi_thread")] + async fn script_runner_env_preserves_prepared_fork_after_reorg() { + let (api, handle) = spawn(NodeConfig::test()).await; + let prevrandao = B256::with_last_byte(0x42); + api.anvil_set_next_block_prevrandao(prevrandao).await.unwrap(); + api.anvil_mine(Some(U256::ONE), None).await.unwrap(); + + let evm_opts = EvmOpts { + fork_url: Some(handle.http_endpoint()), + sender: handle.dev_accounts().next().unwrap(), + ..Default::default() + }; + let mut config = ScriptConfig::::new( + Config::default(), + evm_opts, + ExecutorBuilder::::new(), + false, + TempoOpts::default(), + None, + ) + .await + .unwrap(); + assert!( + config + .backend + .can_use_create2_deployer(config.evm_opts.create2_deployer) + .await + .unwrap() + ); + config._get_runner(None, false, false).await.unwrap(); + + let provider = handle.http_provider(); + let pinned = config.backend.fork().unwrap().unwrap(); + provider + .raw_request::<_, ()>("anvil_reorg".into(), (1_u64, Vec::::new())) + .await + .unwrap(); + let replacement = provider.get_block_by_number(1.into()).await.unwrap().unwrap(); + assert_ne!(replacement.header.hash, pinned.hash()); + assert_ne!(replacement.header.mix_hash, Some(prevrandao)); + + // Reusing the prepared environment must not adopt the replacement block header. + config.select_rpc(handle.http_endpoint()).await.unwrap(); + match config._get_runner(None, false, false).await { + Ok(runner) => { + assert_eq!(runner.executor.evm_env().block_env.prevrandao(), Some(prevrandao)); + } + Err(error) => assert_missing_orphaned_block(&error), + } + assert_eq!(config.backend.fork().unwrap().as_ref(), Some(&pinned)); + } + + #[test] + fn script_trace_requirements_honor_tracing_verbosity() { + let mut config = Config::default(); + config.tracing.verbosity = 5; + + let tracing = script_trace_requirements(&config, false).into_config().unwrap(); + assert!(tracing.record_state_diff); + } + + #[test] + fn created_contracts_include_successful_creator_code_ancestry() { + let root = Address::repeat_byte(0x11); + let implementation = Address::repeat_byte(0x22); + let helper = Address::repeat_byte(0x33); + let created = Address::repeat_byte(0x44); + let init_code = Bytes::from_static(&[0x60, 0x00]); + let mut arena = CallTraceArena::default(); + arena.nodes_mut()[0].trace = + CallTrace { success: true, address: root, kind: CallKind::Call, ..Default::default() }; + arena.nodes_mut().extend([ + CallTraceNode { + parent: Some(0), + idx: 1, + trace: CallTrace { + success: true, + address: implementation, + kind: CallKind::DelegateCall, + ..Default::default() + }, + ..Default::default() + }, + CallTraceNode { + parent: Some(1), + idx: 2, + trace: CallTrace { + success: true, + address: helper, + kind: CallKind::Call, + ..Default::default() + }, + ..Default::default() + }, + CallTraceNode { + parent: Some(2), + idx: 3, + trace: CallTrace { + success: true, + address: created, + kind: CallKind::Create2, + data: init_code.clone(), + ..Default::default() + }, + ..Default::default() + }, + ]); + let result = ScriptResult:: { + traces: vec![( + TraceKind::Execution, + SparsedTraceArena { + arena, + ignored: Default::default(), + diagnostics: Default::default(), + }, + )], + ..Default::default() + }; + + let contracts = result.get_created_contracts(&ContractsByArtifact::default()); + assert_eq!(contracts.len(), 1); + assert_eq!(contracts[0].address, created); + assert_eq!(contracts[0].init_code, init_code); + assert_eq!(contracts[0].creator_code_addresses, [helper, implementation, root]); + } + + #[test] + fn created_contracts_exclude_creations_rolled_back_by_an_ancestor() { + let mut arena = CallTraceArena::default(); + arena.nodes_mut()[0].trace = CallTrace { success: true, ..Default::default() }; + arena.nodes_mut().extend([ + CallTraceNode { + parent: Some(0), + idx: 1, + trace: CallTrace { success: false, kind: CallKind::Call, ..Default::default() }, + ..Default::default() + }, + CallTraceNode { + parent: Some(1), + idx: 2, + trace: CallTrace { + success: true, + kind: CallKind::Create, + data: Bytes::from_static(&[0x60, 0x00]), + ..Default::default() + }, + ..Default::default() + }, + ]); + let result = ScriptResult:: { + traces: vec![( + TraceKind::Execution, + SparsedTraceArena { + arena, + ignored: Default::default(), + diagnostics: Default::default(), + }, + )], + ..Default::default() + }; + + assert!(result.get_created_contracts(&ContractsByArtifact::default()).is_empty()); + } + + fn active_session_entry( + session_id: B256, + root_account: Address, + chain_id: u64, + ) -> SessionEntry { + let foundry_wallets::WalletSigner::Local(root) = + foundry_wallets::utils::create_private_key_signer(SESSION_ROOT_PRIVATE_KEY).unwrap() + else { + unreachable!("a raw private key always creates a local signer") + }; + assert_eq!(root.address(), root_account); + let key = GeneratedSessionKey::from_private_key(SESSION_PRIVATE_KEY).unwrap(); + let prepared = SessionAuthorizationRequest { + session_id, + root_account, + chain_id, + key_address: key.address(), + expiry: NonZeroU64::new(u64::MAX).unwrap(), + scope: vec![tempo_primitives::transaction::CallScope { + target: Address::repeat_byte(0xaa), + selector_rules: vec![], + }], + spend_limits: vec![], + } + .prepare(0) + .unwrap(); + let signature = root.sign_hash_sync(&prepared.authorization.signature_hash()).unwrap(); + let authorization = prepared + .authorization + .clone() + .into_signed(tempo_primitives::transaction::PrimitiveSignature::Secp256k1(signature)); + prepared.into_active_entry(key, &authorization).unwrap() + } + + struct TempoHomeGuard { + _guard: MutexGuard<'static, ()>, + } + + impl TempoHomeGuard { + async fn set(path: &std::path::Path) -> Self { + let guard = TEMPO_HOME_LOCK.lock().await; + // SAFETY: test-only environment override for Tempo local state. + unsafe { + std::env::remove_var(TEMPO_SESSION_ID_ENV); + std::env::set_var(TEMPO_HOME_ENV, path); + } + Self { _guard: guard } + } + } + + impl Drop for TempoHomeGuard { + fn drop(&mut self) { + // SAFETY: restore process environment after the critical section. + unsafe { + std::env::remove_var(TEMPO_HOME_ENV); + std::env::remove_var(TEMPO_SESSION_ID_ENV); + } + } + } + + fn session_root() -> Address { + SESSION_ROOT_ADDRESS.parse().unwrap() + } #[test] fn can_parse_sig() { @@ -683,6 +1904,519 @@ mod tests { assert_eq!(args.sig, sig); } + #[test] + fn parses_confirmations() { + let args = ScriptArgs::parse_from(["foundry-cli", "Contract.sol"]); + assert_eq!(args.confirmations, DEFAULT_CONFIRMATIONS); + + let args = ScriptArgs::parse_from(["foundry-cli", "Contract.sol", "--confirmations", "6"]); + assert_eq!(args.confirmations, 6); + + let err = + ScriptArgs::try_parse_from(["foundry-cli", "Contract.sol", "--confirmations", "0"]) + .unwrap_err(); + assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation); + } + + #[test] + fn verify_external_requires_verify() { + let err = ScriptArgs::try_parse_from(["foundry-cli", "Contract.sol", "--verify-external"]) + .unwrap_err(); + assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument); + + let args = ScriptArgs::try_parse_from([ + "foundry-cli", + "Contract.sol", + "--broadcast", + "--verify", + "--verify-external", + ]) + .unwrap(); + assert!(args.verify_external); + } + + #[test] + fn verify_external_requires_simulation_and_online_mode() { + for conflicting_arg in ["--skip-simulation", "--offline"] { + let err = ScriptArgs::try_parse_from([ + "foundry-cli", + "Contract.sol", + "--broadcast", + "--verify", + "--verify-external", + conflicting_arg, + ]) + .unwrap_err(); + assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); + } + } + + #[test] + fn rejects_max_sender_nonce() { + let max_valid_nonce = (u64::MAX - 1).to_string(); + let args = ScriptArgs::try_parse_from([ + "foundry-cli", + "Contract.sol", + "--sender-nonce", + &max_valid_nonce, + ]) + .unwrap(); + assert_eq!(args.sender_nonce, Some(u64::MAX - 1)); + + let max_nonce = u64::MAX.to_string(); + let err = ScriptArgs::try_parse_from([ + "foundry-cli", + "Contract.sol", + "--sender-nonce", + &max_nonce, + ]) + .unwrap_err(); + assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation); + } + + #[tokio::test(flavor = "multi_thread")] + async fn update_sender_fork_does_not_read_nonce_from_replacement_block() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let accounts = handle.dev_wallets().collect::>(); + let original_sender = accounts[0].address(); + let replacement_sender = accounts[1].address(); + let provider = handle.http_provider(); + let tx = TransactionRequest::default() + .from(replacement_sender) + .to(original_sender) + .value(U256::ONE); + provider.send_transaction(tx.into()).await.unwrap().get_receipt().await.unwrap(); + + let evm_opts = EvmOpts { + fork_url: Some(handle.http_endpoint()), + sender: original_sender, + ..Default::default() + }; + let mut config = ScriptConfig::::new( + Config::default(), + evm_opts, + ExecutorBuilder::::new(), + false, + TempoOpts::default(), + None, + ) + .await + .unwrap(); + let resolved = config.backend.fork().unwrap().unwrap(); + assert_eq!(config.evm_opts.fork_block_number, None); + assert_eq!(config.sender_nonce, 0); + + provider + .raw_request::<_, ()>("anvil_reorg".into(), (1_u64, Vec::::new())) + .await + .unwrap(); + assert_eq!(provider.get_transaction_count(replacement_sender).number(1).await.unwrap(), 0); + + match config.update_sender(replacement_sender).await { + Ok(()) => assert_eq!(config.sender_nonce, 1), + Err(error) => assert_missing_orphaned_block(&error), + } + assert_eq!(config.backend.fork().unwrap().as_ref(), Some(&resolved)); + } + + #[tokio::test(flavor = "multi_thread")] + async fn sender_nonce_override_does_not_refresh_fork() { + let (_api, handle) = spawn(NodeConfig::test()).await; + let sender = handle.dev_accounts().next().unwrap(); + let evm_opts = + EvmOpts { fork_url: Some(handle.http_endpoint()), sender, ..Default::default() }; + let mut config = ScriptConfig::::new( + Config::default(), + evm_opts, + ExecutorBuilder::::new(), + false, + TempoOpts::default(), + Some(7), + ) + .await + .unwrap(); + + config.evm_opts.set_fork_url("http://127.0.0.1:1".to_string()); + config.update_sender(Address::with_last_byte(0x42)).await.unwrap(); + assert_eq!(config.sender_nonce, 7); + } + + #[tokio::test(flavor = "multi_thread")] + #[cfg(feature = "monad")] + async fn script_runner_preserves_nested_fork_source_chain() { + let (_origin_api, origin_handle) = spawn( + NodeConfig::test_monad() + .with_chain_id(Some(NamedChain::MonadTestnet as u64)) + .with_hardfork(Some(foundry_evm::hardforks::MonadHardfork::MonadNine.into())), + ) + .await; + let (_fork_api, fork_handle) = spawn( + NodeConfig::test_monad() + .with_chain_id(Some(NamedChain::Mainnet as u64)) + .with_no_storage_caching(true) + .with_eth_rpc_url(Some(origin_handle.http_endpoint())) + .with_fork_block_number(Some(0u64)), + ) + .await; + let mut evm_opts = + EvmOpts { fork_url: Some(fork_handle.http_endpoint()), ..Default::default() }; + evm_opts.infer_network_from_fork().await.unwrap(); + let config = Config { networks: evm_opts.networks, ..Default::default() }; + let mut script = ScriptConfig::::new( + config, + evm_opts, + ExecutorBuilder::::new(), + false, + TempoOpts::default(), + None, + ) + .await + .unwrap(); + + let _runner = script._get_runner(None, false, false).await.unwrap(); + + assert_eq!(script.source_chain_id, Some(NamedChain::MonadTestnet as u64)); + assert_eq!( + script.hardfork, + Some(FoundryHardfork::Monad(foundry_evm::hardforks::MonadHardfork::MonadNine)) + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn tempo_runner_fee_charge_matches_execution_context() { + let (_api, handle) = spawn(NodeConfig::test_tempo()).await; + let networks = NetworkConfigs::with_tempo(); + let evm_opts = EvmOpts { + fork_url: Some(handle.http_endpoint()), + sender: handle.dev_accounts().next().unwrap(), + networks, + ..Default::default() + }; + let config = Config { networks, ..Default::default() }; + let mut script = ScriptConfig::::new( + config, + evm_opts, + ExecutorBuilder::::new(), + false, + TempoOpts::default(), + None, + ) + .await + .unwrap(); + + let rpc_runner = script._get_runner(None, false, false).await.unwrap(); + assert!(!rpc_runner.executor.evm_env().cfg_env.disable_fee_charge); + + let target = ArtifactId { + path: PathBuf::from("Script.json"), + name: "Script".to_string(), + source: PathBuf::from("Script.sol"), + version: Version::new(0, 8, 30), + build_id: String::new(), + profile: "default".to_string(), + }; + let synthetic_runner = script + .get_runner_with_cheatcodes( + ContractsByArtifact::default(), + Wallets::new(Default::default(), None), + false, + target, + false, + ) + .await + .unwrap(); + assert!(synthetic_runner.executor.evm_env().cfg_env.disable_fee_charge); + } + + #[test] + fn can_parse_shared_tempo_opts() { + let args = ScriptArgs::parse_from([ + "foundry-cli", + "Contract.sol", + "--tempo.fee-token", + "1", + "--tempo.expires", + "10", + ]); + + assert_eq!( + args.tempo.fee_token, + Some(address!("0x20C0000000000000000000000000000000000001")) + ); + assert_eq!(args.tempo.expires, Some(10)); + } + + #[test] + fn can_parse_sponsor_tempo_opts() { + let args = ScriptArgs::parse_from([ + "foundry-cli", + "Contract.sol", + "--tempo.sponsor", + SESSION_ROOT_ADDRESS, + "--tempo.sponsor-signer", + "env://TEMPO_SPONSOR_PK", + ]); + + assert_eq!(args.tempo.sponsor, Some(session_root())); + assert_eq!(args.tempo.sponsor_signer.as_deref(), Some("env://TEMPO_SPONSOR_PK")); + } + + #[test] + fn can_parse_full_tempo_opts() { + let args = + ScriptArgs::parse_from(["foundry-cli", "Contract.sol", "--tempo.nonce-key", "1"]); + + assert_eq!(args.tempo.nonce_key, Some(U256::ONE)); + } + + #[test] + fn can_parse_tempo_session_opt() { + let args = ScriptArgs::parse_from([ + "foundry-cli", + "Contract.sol", + "--tempo.session", + SESSION_ID_HEX, + ]); + + assert_eq!(args.tempo.session, Some(B256::repeat_byte(0x11)),); + } + + #[tokio::test(flavor = "multi_thread")] + async fn tempo_session_sets_script_sender_to_root_account() { + let temp = tempdir().unwrap(); + let session_id = B256::repeat_byte(0x22); + let root = session_root(); + let chain_id = foundry_common::DEV_CHAIN_ID; + + let _guard = TempoHomeGuard::set(temp.path()).await; + upsert_session_entry(active_session_entry(session_id, root, chain_id)).unwrap(); + + let args = ScriptArgs::parse_from([ + "foundry-cli", + "Contract.sol", + "--tempo.session", + &format!("{session_id:?}"), + ]); + let evm_opts = EvmOpts { + networks: NetworkConfigs::with_tempo(), + env: foundry_evm::opts::Env { chain_id: Some(chain_id), ..Default::default() }, + ..Default::default() + }; + + let state = args + .preprocess::( + Config::default(), + evm_opts, + ExecutorBuilder::::new(), + ) + .await + .unwrap(); + assert_eq!(state.script_config.evm_opts.sender, root); + } + + #[tokio::test(flavor = "multi_thread")] + async fn tempo_session_resume_multi_defers_session_sender_until_reexecution() { + let temp = tempdir().unwrap(); + let session_id = B256::repeat_byte(0x55); + let root = session_root(); + let chain_id = 4217; + + let _guard = TempoHomeGuard::set(temp.path()).await; + upsert_session_entry(active_session_entry(session_id, root, chain_id)).unwrap(); + + let args = ScriptArgs::parse_from([ + "foundry-cli", + "Contract.sol", + "--resume", + "--multi", + "--tempo.session", + &format!("{session_id:?}"), + ]); + let evm_opts = EvmOpts { networks: NetworkConfigs::with_tempo(), ..Default::default() }; + + let state = args + .preprocess::( + Config::default(), + evm_opts, + ExecutorBuilder::::new(), + ) + .await + .unwrap(); + assert_ne!(state.script_config.evm_opts.sender, root); + } + + #[tokio::test(flavor = "multi_thread")] + async fn tempo_session_resume_defers_session_sender_until_reexecution() { + let temp = tempdir().unwrap(); + let session_id = B256::repeat_byte(0x77); + let root = session_root(); + let chain_id = 4217; + + let _guard = TempoHomeGuard::set(temp.path()).await; + upsert_session_entry(active_session_entry(session_id, root, chain_id)).unwrap(); + + let args = ScriptArgs::parse_from([ + "foundry-cli", + "Contract.sol", + "--resume", + "--tempo.session", + &format!("{session_id:?}"), + ]); + let evm_opts = EvmOpts { networks: NetworkConfigs::with_tempo(), ..Default::default() }; + + let state = args + .preprocess::( + Config::default(), + evm_opts, + ExecutorBuilder::::new(), + ) + .await + .unwrap(); + assert_ne!(state.script_config.evm_opts.sender, root); + } + + #[tokio::test(flavor = "multi_thread")] + async fn tempo_session_non_resume_multi_sets_sender_without_chain_validation() { + let temp = tempdir().unwrap(); + let session_id = B256::repeat_byte(0x66); + let root = session_root(); + let chain_id = 4217; + + let _guard = TempoHomeGuard::set(temp.path()).await; + upsert_session_entry(active_session_entry(session_id, root, chain_id)).unwrap(); + + let args = ScriptArgs::parse_from([ + "foundry-cli", + "Contract.sol", + "--multi", + "--tempo.session", + &format!("{session_id:?}"), + ]); + let evm_opts = EvmOpts { networks: NetworkConfigs::with_tempo(), ..Default::default() }; + + let state = args + .preprocess::( + Config::default(), + evm_opts, + ExecutorBuilder::::new(), + ) + .await + .unwrap(); + assert_eq!(state.script_config.evm_opts.sender, root); + } + + #[tokio::test(flavor = "multi_thread")] + async fn tempo_session_initial_broadcast_sets_sender_without_chain_validation() { + let temp = tempdir().unwrap(); + let session_id = B256::repeat_byte(0x88); + let root = session_root(); + let chain_id = 4217; + + let _guard = TempoHomeGuard::set(temp.path()).await; + upsert_session_entry(active_session_entry(session_id, root, chain_id)).unwrap(); + + let args = ScriptArgs::parse_from([ + "foundry-cli", + "Contract.sol", + "--broadcast", + "--tempo.session", + &format!("{session_id:?}"), + ]); + let evm_opts = EvmOpts { networks: NetworkConfigs::with_tempo(), ..Default::default() }; + + let state = args + .preprocess::( + Config::default(), + evm_opts, + ExecutorBuilder::::new(), + ) + .await + .unwrap(); + assert_eq!(state.script_config.evm_opts.sender, root); + } + + #[tokio::test] + async fn tempo_options_preserve_explicit_script_network() { + let temp = tempdir().unwrap(); + let _guard = TempoHomeGuard::set(temp.path()).await; + for options in [ + vec!["--tempo.fee-token", "0x20c0000000000000000000000000000000000000"], + vec![ + "--tempo.session", + "0x4444444444444444444444444444444444444444444444444444444444444444", + ], + ] { + let args = + ScriptArgs::parse_from(["foundry-cli", "Contract.sol"].into_iter().chain(options)); + for (networks, name) in [ + (NetworkConfigs::with_ethereum(), "ethereum"), + (NetworkConfigs::with_celo(), "celo"), + #[cfg(feature = "base")] + (NetworkConfigs::with_base(), "base"), + ] { + let evm_opts = EvmOpts { networks, ..Default::default() }; + let err = args.resolved_evm_opts(Config::default(), evm_opts).await.unwrap_err(); + assert_eq!( + err.to_string(), + format!("Tempo transaction options conflict with configured network `{name}`") + ); + } + } + } + + #[tokio::test] + async fn tempo_session_env_selects_tempo_network() { + let temp = tempdir().unwrap(); + let _guard = TempoHomeGuard::set(temp.path()).await; + let session_id = B256::repeat_byte(0x44); + // SAFETY: serialized by TempoHomeGuard. + unsafe { std::env::set_var(TEMPO_SESSION_ID_ENV, format!("{session_id:?}")) }; + + let args = ScriptArgs::parse_from(["foundry-cli", "Contract.sol"]); + let (config, evm_opts) = args.load_config_and_evm_opts().unwrap(); + let (_, evm_opts) = args.resolved_evm_opts(config, evm_opts).await.unwrap(); + + assert!(evm_opts.networks.is_tempo()); + } + + #[tokio::test] + async fn tempo_session_rejects_explicit_script_wallet_signer() { + let temp = tempdir().unwrap(); + let session_id = B256::repeat_byte(0x33); + let root = session_root(); + let chain_id = foundry_common::DEV_CHAIN_ID; + + let _guard = TempoHomeGuard::set(temp.path()).await; + upsert_session_entry(active_session_entry(session_id, root, chain_id)).unwrap(); + + let args = ScriptArgs::parse_from([ + "foundry-cli", + "Contract.sol", + "--tempo.session", + &format!("{session_id:?}"), + "--private-key", + SESSION_PRIVATE_KEY, + ]); + let evm_opts = EvmOpts { + networks: NetworkConfigs::with_tempo(), + env: foundry_evm::opts::Env { chain_id: Some(chain_id), ..Default::default() }, + ..Default::default() + }; + + let err = match args + .preprocess::( + Config::default(), + evm_opts, + ExecutorBuilder::::new(), + ) + .await + { + Ok(_) => panic!("expected --tempo.session with --private-key to fail"), + Err(err) => err, + }; + assert!(err.to_string().contains("explicit wallet signer"), "{err}"); + } + #[test] fn can_parse_unlocked() { let args = ScriptArgs::parse_from([ @@ -702,7 +2436,7 @@ mod tests { "0x4e59b44847b379578588920ca78fbf26c0b4956c", "--unlocked", "--private-key", - key.to_string().as_str(), + &key.to_string(), ]); assert!(args.is_err()); } @@ -725,10 +2459,13 @@ mod tests { ScriptArgs::parse_from(["foundry-cli", "Contract.sol", "--disable-code-size-limit"]); assert!(args.disable_code_size_limit); - let result = ScriptResult::default(); + let result = ScriptResult::::default(); let contracts = ContractsByArtifact::default(); let create = Address::ZERO; - assert!(args.check_contract_sizes(&result, &contracts, create).is_ok()); + assert!( + args.check_contract_sizes(ContractSizeLimits::default(), &result, &contracts, create) + .is_ok() + ); } #[test] @@ -768,6 +2505,61 @@ mod tests { assert_eq!(args.evm.env.code_size_limit, Some(50000)); } + /// `--code-size-limit` on the CLI should be used by `check_contract_sizes`, not silently + /// ignored in favour of the foundry.toml value (which defaults to None → EIP-170's 24576). + #[test] + fn cli_code_size_limit_is_honoured_by_check() { + let args = ScriptArgs::parse_from([ + "foundry-cli", + "script", + "script/Test.s.sol:TestScript", + "--code-size-limit", + "2147483647", + ]); + // The CLI flag must land in evm_opts so that the size_limits computation in run() picks + // it up via `.evm_opts.env.code_size_limit.or(config.code_size_limit)`. + assert_eq!(args.evm.env.code_size_limit, Some(2147483647)); + } + + #[test] + #[cfg(feature = "monad")] + fn contract_size_limits_use_resolved_monad_network() { + let args = + ScriptArgs::parse_from(["foundry-cli", "script", "script/Test.s.sol:TestScript"]); + let evm_opts = EvmOpts { networks: NetworkConfigs::with_monad(), ..Default::default() }; + + let limits = args.contract_size_limits::(&Config::default(), &evm_opts); + + assert!(limits.runtime > ContractSizeLimits::default().runtime); + assert!(limits.initcode > ContractSizeLimits::default().initcode); + } + + #[test] + #[cfg(feature = "monad")] + fn contract_size_limits_prefer_cli_then_config_over_network() { + let args = ScriptArgs::parse_from([ + "foundry-cli", + "script", + "script/Test.s.sol:TestScript", + "--code-size-limit", + "64", + ]); + let mut config = Config { code_size_limit: Some(128), ..Default::default() }; + let evm_opts = EvmOpts { networks: NetworkConfigs::with_monad(), ..Default::default() }; + assert_eq!( + args.contract_size_limits::(&config, &evm_opts,), + ContractSizeLimits::with_runtime_limit(64) + ); + + let args = + ScriptArgs::parse_from(["foundry-cli", "script", "script/Test.s.sol:TestScript"]); + config.code_size_limit = Some(128); + assert_eq!( + args.contract_size_limits::(&config, &evm_opts,), + ContractSizeLimits::with_runtime_limit(128) + ); + } + #[test] fn can_extract_script_etherscan_key() { let temp = tempdir().unwrap(); @@ -775,10 +2567,10 @@ mod tests { let config = r#" [profile.default] - etherscan_api_key = "mumbai" + etherscan_api_key = "amoy" [etherscan] - mumbai = { key = "https://etherscan-mumbai.com/" } + amoy = { key = "https://etherscan-amoy.com/" } "#; let toml_file = root.join(Config::FILE_NAME); @@ -787,14 +2579,14 @@ mod tests { "foundry-cli", "Contract.sol", "--etherscan-api-key", - "mumbai", + "amoy", "--root", root.as_os_str().to_str().unwrap(), ]); let config = args.load_config().unwrap(); - let mumbai = config.get_etherscan_api_key(Some(NamedChain::PolygonMumbai.into())); - assert_eq!(mumbai, Some("https://etherscan-mumbai.com/".to_string())); + let amoy = config.get_etherscan_api_key(Some(NamedChain::PolygonAmoy.into())); + assert_eq!(amoy, Some("https://etherscan-amoy.com/".to_string())); } #[test] @@ -806,7 +2598,7 @@ mod tests { [profile.default] [rpc_endpoints] - polygonMumbai = "https://polygon-mumbai.g.alchemy.com/v2/${_CAN_EXTRACT_RPC_ALIAS}" + polygonAmoy = "https://polygon-amoy.g.alchemy.com/v2/${_CAN_EXTRACT_RPC_ALIAS}" "#; let toml_file = root.join(Config::FILE_NAME); @@ -815,7 +2607,7 @@ mod tests { "foundry-cli", "DeployV1", "--rpc-url", - "polygonMumbai", + "polygonAmoy", "--root", root.as_os_str().to_str().unwrap(), ]); @@ -828,10 +2620,10 @@ mod tests { std::env::set_var("_CAN_EXTRACT_RPC_ALIAS", "123456"); } let (config, evm_opts) = args.load_config_and_evm_opts().unwrap(); - assert_eq!(config.eth_rpc_url, Some("polygonMumbai".to_string())); + assert_eq!(config.eth_rpc_url, Some("polygonAmoy".to_string())); assert_eq!( evm_opts.fork_url, - Some("https://polygon-mumbai.g.alchemy.com/v2/123456".to_string()) + Some("https://polygon-amoy.g.alchemy.com/v2/123456".to_string()) ); } @@ -844,10 +2636,10 @@ mod tests { [profile.default] [rpc_endpoints] - mumbai = "https://polygon-mumbai.g.alchemy.com/v2/${_EXTRACT_RPC_ALIAS}" + amoy = "https://polygon-amoy.g.alchemy.com/v2/${_EXTRACT_RPC_ALIAS}" [etherscan] - mumbai = { key = "${_POLYSCAN_API_KEY}", chain = 80001, url = "https://api-testnet.polygonscan.com/" } + amoy = { key = "${_ETHERSCAN_API_KEY}", chain = 80002, url = "https://amoy.polygonscan.com/" } "#; let toml_file = root.join(Config::FILE_NAME); @@ -856,9 +2648,9 @@ mod tests { "foundry-cli", "DeployV1", "--rpc-url", - "mumbai", + "amoy", "--etherscan-api-key", - "mumbai", + "amoy", "--root", root.as_os_str().to_str().unwrap(), ]); @@ -870,18 +2662,18 @@ mod tests { std::env::set_var("_EXTRACT_RPC_ALIAS", "123456"); } unsafe { - std::env::set_var("_POLYSCAN_API_KEY", "polygonkey"); + std::env::set_var("_ETHERSCAN_API_KEY", "etherscan_api_key"); } let (config, evm_opts) = args.load_config_and_evm_opts().unwrap(); - assert_eq!(config.eth_rpc_url, Some("mumbai".to_string())); + assert_eq!(config.eth_rpc_url, Some("amoy".to_string())); assert_eq!( evm_opts.fork_url, - Some("https://polygon-mumbai.g.alchemy.com/v2/123456".to_string()) + Some("https://polygon-amoy.g.alchemy.com/v2/123456".to_string()) ); - let etherscan = config.get_etherscan_api_key(Some(80001u64.into())); - assert_eq!(etherscan, Some("polygonkey".to_string())); + let etherscan = config.get_etherscan_api_key(Some(80002u64.into())); + assert_eq!(etherscan, Some("etherscan_api_key".to_string())); let etherscan = config.get_etherscan_api_key(None); - assert_eq!(etherscan, Some("polygonkey".to_string())); + assert_eq!(etherscan, Some("etherscan_api_key".to_string())); } #[test] @@ -893,10 +2685,10 @@ mod tests { [profile.default] [rpc_endpoints] - mumbai = "https://polygon-mumbai.g.alchemy.com/v2/${_SOLE_EXTRACT_RPC_ALIAS}" + amoy = "https://polygon-amoy.g.alchemy.com/v2/${_SOLE_EXTRACT_RPC_ALIAS}" [etherscan] - mumbai = { key = "${_SOLE_POLYSCAN_API_KEY}" } + amoy = { key = "${_SOLE_ETHERSCAN_API_KEY}" } "#; let toml_file = root.join(Config::FILE_NAME); @@ -905,7 +2697,7 @@ mod tests { "foundry-cli", "DeployV1", "--rpc-url", - "mumbai", + "amoy", "--root", root.as_os_str().to_str().unwrap(), ]); @@ -917,17 +2709,17 @@ mod tests { std::env::set_var("_SOLE_EXTRACT_RPC_ALIAS", "123456"); } unsafe { - std::env::set_var("_SOLE_POLYSCAN_API_KEY", "polygonkey"); + std::env::set_var("_SOLE_ETHERSCAN_API_KEY", "etherscan_api_key"); } let (config, evm_opts) = args.load_config_and_evm_opts().unwrap(); assert_eq!( evm_opts.fork_url, - Some("https://polygon-mumbai.g.alchemy.com/v2/123456".to_string()) + Some("https://polygon-amoy.g.alchemy.com/v2/123456".to_string()) ); - let etherscan = config.get_etherscan_api_key(Some(80001u64.into())); - assert_eq!(etherscan, Some("polygonkey".to_string())); + let etherscan = config.get_etherscan_api_key(Some(80002u64.into())); + assert_eq!(etherscan, Some("etherscan_api_key".to_string())); let etherscan = config.get_etherscan_api_key(None); - assert_eq!(etherscan, Some("polygonkey".to_string())); + assert_eq!(etherscan, Some("etherscan_api_key".to_string())); } // @@ -938,6 +2730,16 @@ mod tests { assert!(args.priority_gas_price.is_some()); } + #[test] + fn test_eip1559_fee_estimate() { + // Defaults to unset (config provides `market`). + let args = ScriptArgs::parse_from(["foundry-cli", "DeployV1"]); + assert!(args.eip1559_fee_estimate.is_none()); + + let args = ScriptArgs::parse_from(["foundry-cli", "DeployV1", "--estimate", "aggressive"]); + assert_eq!(args.eip1559_fee_estimate, Some(Eip1559FeeEstimatePreset::Aggressive)); + } + // #[test] fn test_5910() { @@ -950,4 +2752,59 @@ mod tests { ]); assert!(args.with_gas_price.unwrap().is_zero()); } + + #[test] + fn test_priority_gas_price_cannot_exceed_gas_price() { + let args = ScriptArgs::parse_from([ + "foundry-cli", + "--broadcast", + "--with-gas-price", + "100", + "--priority-gas-price", + "200", + "Script", + ]); + // priority (200) > max_fee (100) — broadcast should reject this at runtime + assert!(args.priority_gas_price.unwrap() > args.with_gas_price.unwrap()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn explicit_sender_takes_precedence_over_sole_private_key() { + let temp = tempdir().unwrap(); + let _guard = TempoHomeGuard::set(temp.path()).await; + + let private_key = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + let key_address = address!("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"); + let sender = address!("0x70997970C51812dc3A010C7d01b50e0d17dc79C8"); + + let args = ScriptArgs::parse_from([ + "foundry-cli", + "Contract.sol", + "--private-key", + private_key, + "--sender", + &sender.to_string(), + ]); + let state = args + .preprocess::( + Config::default(), + EvmOpts { sender, ..Default::default() }, + ExecutorBuilder::::new(), + ) + .await + .unwrap(); + assert_eq!(state.script_config.evm_opts.sender, sender); + + let args = + ScriptArgs::parse_from(["foundry-cli", "Contract.sol", "--private-key", private_key]); + let state = args + .preprocess::( + Config::default(), + EvmOpts::default(), + ExecutorBuilder::::new(), + ) + .await + .unwrap(); + assert_eq!(state.script_config.evm_opts.sender, key_address); + } } diff --git a/crates/script/src/library_deployments.rs b/crates/script/src/library_deployments.rs new file mode 100644 index 0000000000000..e3aa8051fc7f5 --- /dev/null +++ b/crates/script/src/library_deployments.rs @@ -0,0 +1,363 @@ +use crate::{ + build::{LinkedBuildData, ScriptPredeployLibraries}, + execute::LinkedState, + simulate::PreSimulationState, +}; +use alloy_network::TransactionBuilder; +use alloy_primitives::{Address, keccak256}; +use eyre::Result; +use foundry_common::{LIBRARY_DEPLOYER, matches_contract_creation, shell}; +use foundry_compilers::ArtifactId; +use foundry_evm::{constants::CHEATCODE_ADDRESS, core::evm::FoundryEvmNetwork}; +use std::collections::BTreeSet; + +struct EligibleTransactions { + required: BTreeSet, + artifacts: Vec, + rpc: String, + baseline_count: usize, +} + +const RERUN_UNSAFE_CHEATCODE_SIGNATURES: &[&str] = &[ + "setEnv(string,string)", + "sign(bytes32)", + "sign(address,bytes32)", + "signCompact(bytes32)", + "signCompact(address,bytes32)", + "rpc(string,string)", + "rpc(string,string,string)", + "rpcJson(string,string)", + "rpcJson(string,string,string)", + "sleep(uint256)", + "prompt(string)", + "promptSecret(string)", + "promptSecretUint(string)", + "promptAddress(string)", + "promptUint(string)", + "dumpState(string)", + "createFork(string)", + "createFork(string,uint256)", + "createFork(string,bytes32)", + "createSelectFork(string)", + "createSelectFork(string,uint256)", + "createSelectFork(string,bytes32)", + "rollFork(uint256)", + "rollFork(bytes32)", + "rollFork(uint256,uint256)", + "rollFork(uint256,bytes32)", + "selectFork(uint256)", + "transact(bytes32)", + "transact(uint256,bytes32)", +]; + +pub(crate) fn rerun_unsafe_cheatcode_selectors() -> Vec<[u8; 4]> { + RERUN_UNSAFE_CHEATCODE_SIGNATURES + .iter() + .map(|signature| { + let hash = keccak256(signature.as_bytes()); + [hash[0], hash[1], hash[2], hash[3]] + }) + .collect() +} + +impl PreSimulationState { + /// Reruns narrowly eligible scripts with libraries unused by direct broadcasts deployed only + /// in the local EVM. + pub async fn optimize_library_deployments(self) -> Result { + let Ok(Some(EligibleTransactions { required, artifacts, rpc, baseline_count })) = + self.eligible() + else { + return Ok(self); + }; + let onchain = match &self.build_data.predeploy_libraries { + ScriptPredeployLibraries::Default { onchain, .. } + | ScriptPredeployLibraries::Create2 { onchain, .. } => onchain, + }; + if required.len() == onchain.len() { + return Ok(self); + } + + let Ok(libraries) = self.script_config.config.libraries_with_remappings() else { + return Ok(self); + }; + let linker = self.build_data.build_data.get_linker(); + let linked = match &self.build_data.predeploy_libraries { + ScriptPredeployLibraries::Default { .. } => linker + .link_with_partition( + libraries, + self.script_config.evm_opts.sender, + self.script_config.sender_nonce, + LIBRARY_DEPLOYER, + &required, + &self.build_data.build_data.target, + ) + .map(|(output, local)| { + let onchain = onchain_libraries(&output, &local); + (output.output, ScriptPredeployLibraries::Default { onchain, local }) + }), + ScriptPredeployLibraries::Create2 { salt, .. } => linker + .link_with_create2_partition( + libraries, + self.script_config.evm_opts.create2_deployer, + *salt, + LIBRARY_DEPLOYER, + &required, + &self.build_data.build_data.target, + ) + .map(|(output, local)| { + let onchain = onchain_libraries(&output, &local); + ( + output.output, + ScriptPredeployLibraries::Create2 { onchain, salt: *salt, local }, + ) + }), + }; + let Ok((output, predeploy_libraries)) = linked else { return Ok(self) }; + let Ok(build_data) = LinkedBuildData::new( + output.libraries, + predeploy_libraries, + self.build_data.build_data.clone(), + ) else { + return Ok(self); + }; + let candidate = LinkedState { + args: self.args.clone(), + script_config: self.script_config.clone(), + script_wallets: self.script_wallets.clone(), + browser_wallet: self.browser_wallet.clone(), + build_data, + } + .prepare_execution() + .await; + let Ok(candidate) = candidate else { return Ok(self) }; + let candidate = candidate.execute_restricted().await; + let Ok(candidate) = candidate else { return Ok(self) }; + if !candidate.execution_result.success { + return Ok(self); + } + let candidate = candidate.prepare_simulation_silent().await; + let Ok(candidate) = candidate else { return Ok(self) }; + if self.execution_result.returned != candidate.execution_result.returned + || self.execution_result.logs != candidate.execution_result.logs + || !self.equivalent_candidate(&candidate, &artifacts, &rpc, baseline_count) + { + return Ok(self); + } + Ok(candidate) + } + + fn eligible(&self) -> Result> { + if !self.execution_result.success + || self.args.skip_simulation + || self.args.debug + || self.args.dump.is_some() + || self.args.batch + || self.args.slow + || self.script_config.config.ffi + || self.script_config.config.live_logs + || shell::is_json() + || shell::verbosity() > 3 + || self.script_config.evm_opts.env.gas_price.is_some() + || self.execution_artifacts.rpc_data.missing_rpc + || self.execution_artifacts.rpc_data.is_multi_chain() + || self.script_config.evm_opts.sender == LIBRARY_DEPLOYER + || self.script_config.config.fs_permissions.permissions.iter().any(|permission| { + matches!( + permission.access, + foundry_config::fs_permissions::FsAccessPermission::Write + | foundry_config::fs_permissions::FsAccessPermission::ReadWrite + ) + }) + || self.used_rerun_unsafe_cheatcode() + { + return Ok(None); + } + let Some(rpc) = self.script_config.evm_opts.fork_url.clone() else { return Ok(None) }; + if !self.script_config.backend.is_in_forking_mode() + || self.execution_artifacts.rpc_data.total_rpcs.len() != 1 + || !self.execution_artifacts.rpc_data.total_rpcs.contains(&rpc) + { + return Ok(None); + } + let onchain = match &self.build_data.predeploy_libraries { + ScriptPredeployLibraries::Default { onchain, .. } + | ScriptPredeployLibraries::Create2 { onchain, .. } => onchain, + }; + if onchain.is_empty() { + return Ok(None); + } + let Some(transactions) = &self.execution_result.transactions else { return Ok(None) }; + if transactions.len() <= onchain.len() + || transactions.iter().any(|tx| { + !tx.transaction.is_unsigned() + || tx.transaction.authorization_list().is_some_and(|list| !list.is_empty()) + }) + { + return Ok(None); + } + for (index, (tx, library)) in transactions.iter().zip(onchain).enumerate() { + let input = tx.transaction.input().map(|input| input.as_ref()).unwrap_or_default(); + let deployment_matches = match &self.build_data.predeploy_libraries { + ScriptPredeployLibraries::Default { .. } => { + tx.transaction.to().is_none() && input == library.bytecode.as_ref() + } + ScriptPredeployLibraries::Create2 { salt, .. } => { + tx.transaction.to() == Some(self.script_config.evm_opts.create2_deployer) + && input.get(..32) == Some(salt.as_slice()) + && input.get(32..) == Some(library.bytecode.as_ref()) + } + }; + let exact = deployment_matches + && tx.rpc.as_ref() == Some(&rpc) + && tx.transaction.from() == Some(self.script_config.evm_opts.sender) + && tx.transaction.nonce() == Some(self.script_config.sender_nonce + index as u64); + if !exact { + return Ok(None); + } + } + + let library_ids = onchain.iter().map(|library| library.id.clone()).collect::>(); + let linker = self.build_data.build_data.get_linker(); + let mut required = BTreeSet::new(); + let mut artifacts = Vec::new(); + for tx in transactions.iter().skip(onchain.len()) { + if tx.rpc.as_ref() != Some(&rpc) + || tx.transaction.from() != Some(self.script_config.evm_opts.sender) + || tx.transaction.to().is_some() + { + return Ok(None); + } + let Some(input) = tx.transaction.input() else { return Ok(None) }; + let matches = self + .build_data + .known_contracts + .iter() + .filter(|(_, contract)| matches_contract_creation(contract, input)) + .map(|(id, _)| id) + .collect::>(); + let [artifact] = matches.as_slice() else { return Ok(None) }; + artifacts.push((*artifact).clone()); + required.extend( + linker.dependencies(artifact)?.into_iter().filter(|id| library_ids.contains(id)), + ); + } + Ok(Some(EligibleTransactions { required, artifacts, rpc, baseline_count: onchain.len() })) + } + + fn equivalent_candidate( + &self, + candidate: &Self, + artifacts: &[ArtifactId], + rpc: &str, + baseline_count: usize, + ) -> bool { + let Some(baseline) = self.execution_result.transactions.as_ref() else { return false }; + let Some(candidate_txs) = candidate.execution_result.transactions.as_ref() else { + return false; + }; + let candidate_count = candidate.build_data.predeploy_libraries.libraries_count(); + let baseline = baseline.iter().skip(baseline_count).collect::>(); + let candidate_txs = candidate_txs.iter().skip(candidate_count).collect::>(); + if baseline.len() != artifacts.len() || candidate_txs.len() != artifacts.len() { + return false; + } + let mut remaps = Vec::new(); + let baseline_libraries = match &self.build_data.predeploy_libraries { + ScriptPredeployLibraries::Default { onchain, .. } + | ScriptPredeployLibraries::Create2 { onchain, .. } => onchain, + }; + let candidate_libraries = match &candidate.build_data.predeploy_libraries { + ScriptPredeployLibraries::Default { onchain, .. } + | ScriptPredeployLibraries::Create2 { onchain, .. } => onchain, + }; + for library in baseline_libraries { + if let Some(other) = candidate_libraries.iter().find(|other| other.id == library.id) { + remaps.push((library.address, other.address)); + } + } + for ((baseline, candidate_tx), artifact) in + baseline.iter().zip(&candidate_txs).zip(artifacts) + { + let (Some(mut old), Some(new)) = ( + baseline.transaction.clone().as_unsigned_mut().cloned(), + candidate_tx.transaction.clone().as_unsigned_mut().cloned(), + ) else { + return false; + }; + if baseline.rpc.as_deref() != Some(rpc) + || candidate_tx.rpc.as_deref() != Some(rpc) + || old.from() != new.from() + || old.to().is_some() + || new.to().is_some() + { + return false; + } + let (Some(old_nonce), Some(new_nonce)) = (old.nonce(), new.nonce()) else { + return false; + }; + if old_nonce.checked_sub(baseline_count as u64) + != new_nonce.checked_sub(candidate_count as u64) + { + return false; + } + let mut input = old.input().cloned().unwrap_or_default().to_vec(); + for (from, to) in &remaps { + replace_addresses(&mut input, *from, *to); + } + old.set_nonce(new_nonce); + old.set_input(input); + let (Ok(old_value), Ok(new_value)) = + (serde_json::to_value(&old), serde_json::to_value(&new)) + else { + return false; + }; + if old_value != new_value + || !candidate.build_data.known_contracts.get(artifact).is_some_and(|contract| { + new.input().is_some_and(|input| matches_contract_creation(contract, input)) + }) + { + return false; + } + remaps.push(( + old.from().unwrap().create(old_nonce), + new.from().unwrap().create(new_nonce), + )); + } + true + } + + fn used_rerun_unsafe_cheatcode(&self) -> bool { + let selectors = rerun_unsafe_cheatcode_selectors(); + self.execution_result.traces.iter().any(|(_, traces)| { + traces.nodes().iter().any(|node| { + node.trace.address == CHEATCODE_ADDRESS + && node.trace.data.get(..4).is_some_and(|selector| { + selectors.iter().any(|blocked| blocked.as_slice() == selector) + }) + }) + }) + } +} + +fn onchain_libraries( + output: &foundry_linking::DetailedLinkOutput, + local: &[foundry_linking::LinkedLibrary], +) -> Vec { + output + .linked_libraries + .iter() + .filter(|library| !local.iter().any(|local| local.id == library.id)) + .cloned() + .collect() +} + +fn replace_addresses(input: &mut [u8], from: Address, to: Address) { + let mut offset = 0; + while let Some(index) = + input[offset..].windows(Address::len_bytes()).position(|window| window == from.as_slice()) + { + let start = offset + index; + input[start..start + Address::len_bytes()].copy_from_slice(to.as_slice()); + offset = start + Address::len_bytes(); + } +} diff --git a/crates/script/src/multi_sequence.rs b/crates/script/src/multi_sequence.rs index 56b9bfd73a08b..84ffed509c8b3 100644 --- a/crates/script/src/multi_sequence.rs +++ b/crates/script/src/multi_sequence.rs @@ -1,3 +1,4 @@ +use alloy_network::Network; use eyre::{ContextCompat, Result, WrapErr}; use forge_script_sequence::{ DRY_RUN_DIR, ScriptSequence, SensitiveScriptSequence, now, sig_to_file_name, @@ -6,15 +7,16 @@ use foundry_common::{fs, shell}; use foundry_compilers::ArtifactId; use foundry_config::Config; use serde::{Deserialize, Serialize}; -use std::{ - io::{BufWriter, Write}, - path::PathBuf, -}; +use std::path::PathBuf; /// Holds the sequences of multiple chain deployments. #[derive(Clone, Default, Serialize, Deserialize)] -pub struct MultiChainSequence { - pub deployments: Vec, +#[serde(bound( + serialize = "N::TransactionRequest: Serialize, N::TxEnvelope: Serialize", + deserialize = "N::TransactionRequest: for<'de2> Deserialize<'de2>, N::TxEnvelope: for<'de2> Deserialize<'de2>" +))] +pub struct MultiChainSequence { + pub deployments: Vec>, #[serde(skip)] pub path: PathBuf, #[serde(skip)] @@ -29,16 +31,16 @@ pub struct SensitiveMultiChainSequence { } impl SensitiveMultiChainSequence { - fn from_multi_sequence(sequence: MultiChainSequence) -> Self { + fn from_multi_sequence(sequence: &MultiChainSequence) -> Self { Self { - deployments: sequence.deployments.into_iter().map(|sequence| sequence.into()).collect(), + deployments: sequence.deployments.iter().map(SensitiveScriptSequence::from).collect(), } } } -impl MultiChainSequence { +impl MultiChainSequence { pub fn new( - deployments: Vec, + deployments: Vec>, sig: &str, target: &ArtifactId, config: &Config, @@ -58,8 +60,8 @@ impl MultiChainSequence { target: &ArtifactId, dry_run: bool, ) -> Result<(PathBuf, PathBuf)> { - let mut broadcast = config.broadcast.to_path_buf(); - let mut cache = config.cache_path.to_path_buf(); + let mut broadcast = config.broadcast.clone(); + let mut cache = config.cache_path.clone(); let mut common = PathBuf::new(); common.push("multi"); @@ -91,7 +93,10 @@ impl MultiChainSequence { } /// Loads the sequences for the multi chain deployment. - pub fn load(config: &Config, sig: &str, target: &ArtifactId, dry_run: bool) -> Result { + pub fn load(config: &Config, sig: &str, target: &ArtifactId, dry_run: bool) -> Result + where + N::TxEnvelope: for<'d> Deserialize<'d>, + { let (path, sensitive_path) = Self::get_paths(config, sig, target, dry_run)?; let mut sequence: Self = foundry_compilers::utils::read_json_file(&path) .wrap_err("Multi-chain deployment not found.")?; @@ -99,9 +104,18 @@ impl MultiChainSequence { foundry_compilers::utils::read_json_file(&sensitive_path) .wrap_err("Multi-chain deployment sensitive details not found.")?; - sequence.deployments.iter_mut().enumerate().for_each(|(i, sequence)| { - sequence.fill_sensitive(&sensitive_sequence.deployments[i]); - }); + let deployments_len = sequence.deployments.len(); + let sensitive_deployments_len = sensitive_sequence.deployments.len(); + if deployments_len != sensitive_deployments_len { + eyre::bail!( + "sensitive-cache deployment count ({sensitive_deployments_len}) does not match \ + deployment count ({deployments_len}); the multi-chain deployment and its \ + sensitive-cache counterpart are out of sync" + ); + } + for (i, deployment) in sequence.deployments.iter_mut().enumerate() { + deployment.fill_sensitive(&sensitive_sequence.deployments[i])?; + } sequence.path = path; sequence.sensitive_path = sensitive_path; @@ -110,18 +124,19 @@ impl MultiChainSequence { } /// Saves the transactions as file if it's a standalone deployment. - pub fn save(&mut self, silent: bool, save_ts: bool) -> Result<()> { + pub fn save(&mut self, silent: bool, save_ts: bool) -> Result<()> + where + N::TxEnvelope: Serialize, + { self.deployments.iter_mut().for_each(|sequence| sequence.sort_receipts()); self.timestamp = now().as_millis(); - let sensitive_sequence = SensitiveMultiChainSequence::from_multi_sequence(self.clone()); + let sensitive_sequence = SensitiveMultiChainSequence::from_multi_sequence(&*self); // broadcast writes //../Contract-latest/run.json - let mut writer = BufWriter::new(fs::create_file(&self.path)?); - serde_json::to_writer_pretty(&mut writer, &self)?; - writer.flush()?; + fs::write_pretty_json_file(&self.path, self)?; if save_ts { //../Contract-[timestamp]/run.json @@ -133,9 +148,7 @@ impl MultiChainSequence { // cache writes //../Contract-latest/run.json - let mut writer = BufWriter::new(fs::create_file(&self.sensitive_path)?); - serde_json::to_writer_pretty(&mut writer, &sensitive_sequence)?; - writer.flush()?; + fs::write_sensitive_json_file(&self.sensitive_path, &sensitive_sequence)?; if save_ts { //../Contract-[timestamp]/run.json @@ -164,3 +177,52 @@ impl MultiChainSequence { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use alloy_network::Ethereum; + + #[test] + fn load_rejects_mismatched_deployment_counts() { + let dir = tempfile::tempdir().unwrap(); + let config = Config { + broadcast: dir.path().join("broadcast"), + cache_path: dir.path().join("cache"), + ..Default::default() + }; + let target = ArtifactId { + path: PathBuf::from("Script.json"), + name: "Script".to_string(), + source: PathBuf::from("Script.sol"), + version: "0.8.30".parse().unwrap(), + build_id: String::new(), + profile: "default".to_string(), + }; + let (path, sensitive_path) = + MultiChainSequence::::get_paths(&config, "run()", &target, false).unwrap(); + let sequence = MultiChainSequence:: { + deployments: vec![ScriptSequence::default()], + path: PathBuf::new(), + sensitive_path: PathBuf::new(), + timestamp: 0, + }; + fs::write_pretty_json_file(&path, &sequence).unwrap(); + for count in [0, 2] { + let sensitive = SensitiveMultiChainSequence { + deployments: vec![SensitiveScriptSequence::default(); count], + }; + fs::write_sensitive_json_file(&sensitive_path, &sensitive).unwrap(); + let err = MultiChainSequence::::load(&config, "run()", &target, false) + .err() + .expect("mismatched counts must fail"); + assert_eq!( + err.to_string(), + format!( + "sensitive-cache deployment count ({count}) does not match deployment count (1); \ + the multi-chain deployment and its sensitive-cache counterpart are out of sync" + ) + ); + } + } +} diff --git a/crates/script/src/progress.rs b/crates/script/src/progress.rs index 09329ad6b05ab..16f155b7644d8 100644 --- a/crates/script/src/progress.rs +++ b/crates/script/src/progress.rs @@ -1,13 +1,15 @@ -use crate::receipts::{TxStatus, check_tx_status, format_receipt}; +use crate::receipts::{PendingReceiptError, TxStatus, check_tx_status, format_receipt}; use alloy_chains::Chain; +use alloy_network::{Network, ReceiptResponse}; use alloy_primitives::{ B256, map::{B256HashMap, HashMap}, }; +use alloy_provider::RootProvider; use eyre::Result; use forge_script_sequence::ScriptSequence; use foundry_cli::utils::init_progress; -use foundry_common::{provider::RetryProvider, shell}; +use foundry_common::shell; use futures::StreamExt; use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use parking_lot::RwLock; @@ -30,7 +32,11 @@ pub struct SequenceProgressState { } impl SequenceProgressState { - pub fn new(sequence_idx: usize, sequence: &ScriptSequence, multi: MultiProgress) -> Self { + pub fn new( + sequence_idx: usize, + sequence: &ScriptSequence, + multi: MultiProgress, + ) -> Self { let mut state = if shell::is_quiet() || shell::is_json() { let top_spinner = ProgressBar::hidden(); let txs = ProgressBar::hidden(); @@ -141,7 +147,11 @@ pub struct SequenceProgress { } impl SequenceProgress { - pub fn new(sequence_idx: usize, sequence: &ScriptSequence, multi: MultiProgress) -> Self { + pub fn new( + sequence_idx: usize, + sequence: &ScriptSequence, + multi: MultiProgress, + ) -> Self { Self { inner: Arc::new(RwLock::new(SequenceProgressState::new(sequence_idx, sequence, multi))), } @@ -158,10 +168,10 @@ pub struct ScriptProgress { impl ScriptProgress { /// Returns a [SequenceProgress] instance for the given sequence index. If it doesn't exist, /// creates one. - pub fn get_sequence_progress( + pub fn get_sequence_progress( &self, sequence_idx: usize, - sequence: &ScriptSequence, + sequence: &ScriptSequence, ) -> SequenceProgress { if let Some(progress) = self.state.read().get(&sequence_idx) { return progress.clone(); @@ -171,8 +181,8 @@ impl ScriptProgress { progress } - /// Traverses a set of pendings and either finds receipts, or clears them from - /// the deployment sequence. + /// Traverses a set of pending transactions and either finds receipts, or clears + /// them from the deployment sequence. /// /// For each `tx_hash`, we check if it has confirmed. If it has /// confirmed, we push the receipt (if successful) or push an error (if @@ -181,13 +191,16 @@ impl ScriptProgress { /// has not confirmed, and cannot be found in the mempool, we remove it from /// the `deploy_sequence.pending` vector so that it will be rebroadcast in /// later steps. - pub async fn wait_for_pending( + pub async fn wait_for_pending( &self, sequence_idx: usize, - deployment_sequence: &mut ScriptSequence, - provider: &RetryProvider, + deployment_sequence: &mut ScriptSequence, + provider: &RootProvider, timeout: u64, + confirmations: u64, + submission_hashes: (&[B256], &[B256]), ) -> Result<()> { + let (durable_hashes, replayable_hashes) = submission_hashes; if deployment_sequence.pending.is_empty() { return Ok(()); } @@ -203,32 +216,68 @@ impl ScriptProgress { .pending .clone() .into_iter() - .map(|tx| check_tx_status(provider, tx, timeout)); + .map(|tx| check_tx_status(provider, tx, timeout, confirmations)); let mut tasks = futures::stream::iter(futs).buffer_unordered(10); let mut errors: Vec = vec![]; + let mut discarded_transactions = false; while let Some((tx_hash, result)) = tasks.next().await { match result { Err(err) => { - errors.push(format!("Failure on receiving a receipt for {tx_hash:?}:\n{err}")); - - seq_progress.inner.write().finish_tx_spinner(tx_hash); + // Check if this is a retry error for pending receipts + if err.downcast_ref::().is_some() { + // We've already retried several times with sleep, but the receipt is still + // pending + if durable_hashes.contains(&tx_hash) { + errors.push(format!( + "Durable submission {tx_hash:?} is still pending; refusing to discard its recovery identity" + )); + } else { + discarded_transactions = true; + deployment_sequence.remove_pending(tx_hash); + } + seq_progress + .inner + .write() + .finish_tx_spinner_with_msg(tx_hash, &err.to_string())?; + } else { + errors.push(format!( + "Failure on receiving a receipt for {tx_hash:?}:\n{err}" + )); + seq_progress.inner.write().finish_tx_spinner(tx_hash); + } } Ok(TxStatus::Dropped) => { - // We want to remove it from pending so it will be re-broadcast. - deployment_sequence.remove_pending(tx_hash); - errors.push(format!("Transaction dropped from the mempool: {tx_hash:?}")); - - seq_progress.inner.write().finish_tx_spinner(tx_hash); + if replayable_hashes.contains(&tx_hash) { + deployment_sequence.remove_pending(tx_hash); + discarded_transactions = true; + } else if durable_hashes.contains(&tx_hash) { + errors.push(format!( + "Durable submission {tx_hash:?} is not currently visible; refusing to discard its recovery identity" + )); + } else { + // We want to remove it from pending so it will be re-broadcast. + deployment_sequence.remove_pending(tx_hash); + discarded_transactions = true; + } + + let msg = format!( + "Transaction {tx_hash:?} is not currently visible to the RPC endpoint." + ); + seq_progress.inner.write().finish_tx_spinner_with_msg(tx_hash, &msg)?; } Ok(TxStatus::Success(receipt)) => { trace!(tx_hash=?tx_hash, "received tx receipt"); - let msg = format_receipt(deployment_sequence.chain.into(), &receipt); + let msg = format_receipt( + deployment_sequence.chain.into(), + &receipt, + Some(deployment_sequence), + ); seq_progress.inner.write().finish_tx_spinner_with_msg(tx_hash, &msg)?; - deployment_sequence.remove_pending(receipt.transaction_hash); + deployment_sequence.remove_pending(receipt.transaction_hash()); deployment_sequence.add_receipt(receipt); } Ok(TxStatus::Revert(receipt)) => { @@ -236,12 +285,16 @@ impl ScriptProgress { // if this is not removed from pending, then the script becomes // un-resumable. Is this desirable on reverts? warn!(tx_hash=?tx_hash, "Transaction Failure"); - deployment_sequence.remove_pending(receipt.transaction_hash); + deployment_sequence.remove_pending(receipt.transaction_hash()); - let msg = format_receipt(deployment_sequence.chain.into(), &receipt); + let msg = format_receipt( + deployment_sequence.chain.into(), + &receipt, + Some(deployment_sequence), + ); seq_progress.inner.write().finish_tx_spinner_with_msg(tx_hash, &msg)?; - errors.push(format!("Transaction Failure: {:?}", receipt.transaction_hash)); + errors.push(format!("Transaction Failure: {:?}", receipt.transaction_hash())); } } } @@ -249,11 +302,20 @@ impl ScriptProgress { // print any errors if !errors.is_empty() { let mut error_msg = errors.join("\n"); - if !deployment_sequence.pending.is_empty() { - error_msg += "\n\n Add `--resume` to your command to try and continue broadcasting - the transactions." + + // Add information about using --resume if necessary + if !deployment_sequence.pending.is_empty() || discarded_transactions { + error_msg += r#" + +Add `--resume` to your command to try and continue broadcasting the transactions. This will attempt to resend transactions that were discarded by the RPC."#; } + eyre::bail!(error_msg); + } else if discarded_transactions { + // If we have discarded transactions but no errors, still inform the user + sh_warn!( + "Some transactions were discarded by the RPC node. Use `--resume` to retry these transactions." + )?; } Ok(()) diff --git a/crates/script/src/providers.rs b/crates/script/src/providers.rs index 2252010f43d3d..5891837b4100a 100644 --- a/crates/script/src/providers.rs +++ b/crates/script/src/providers.rs @@ -1,35 +1,47 @@ +use alloy_network::Network; use alloy_primitives::map::{HashMap, hash_map::Entry}; -use alloy_provider::{Provider, utils::Eip1559Estimation}; +use alloy_provider::{Provider, RootProvider}; use eyre::{Result, WrapErr}; -use foundry_common::provider::{RetryProvider, get_http_provider}; -use foundry_config::Chain; +use foundry_common::provider::{ + ProviderBuilder, + fee::{ResolvedEip1559Fees, estimate_eip1559_fees}, +}; +use foundry_config::{Chain, Config, Eip1559FeeEstimatePreset}; use std::{ops::Deref, sync::Arc}; /// Contains a map of RPC urls to single instances of [`ProviderInfo`]. -#[derive(Default)] -pub struct ProvidersManager { - pub inner: HashMap, +pub struct ProvidersManager { + pub inner: HashMap>, } -impl ProvidersManager { +impl Default for ProvidersManager { + fn default() -> Self { + Self { inner: Default::default() } + } +} + +impl ProvidersManager { /// Get or initialize the RPC provider. pub async fn get_or_init_provider( &mut self, rpc: &str, + chain: Option, is_legacy: bool, - ) -> Result<&ProviderInfo> { + fee_estimate: Eip1559FeeEstimatePreset, + config: &Config, + ) -> Result<&ProviderInfo> { Ok(match self.inner.entry(rpc.to_string()) { Entry::Occupied(entry) => entry.into_mut(), Entry::Vacant(entry) => { - let info = ProviderInfo::new(rpc, is_legacy).await?; + let info = ProviderInfo::new(rpc, chain, is_legacy, fee_estimate, config).await?; entry.insert(info) } }) } } -impl Deref for ProvidersManager { - type Target = HashMap; +impl Deref for ProvidersManager { + type Target = HashMap>; fn deref(&self) -> &Self::Target { &self.inner @@ -38,8 +50,8 @@ impl Deref for ProvidersManager { /// Holds related metadata to each provider RPC. #[derive(Debug)] -pub struct ProviderInfo { - pub provider: Arc, +pub struct ProviderInfo { + pub provider: Arc>, pub chain: u64, pub gas_price: GasPrice, } @@ -48,13 +60,22 @@ pub struct ProviderInfo { #[derive(Debug)] pub enum GasPrice { Legacy(Result), - EIP1559(Result), + EIP1559(Result), } -impl ProviderInfo { - pub async fn new(rpc: &str, mut is_legacy: bool) -> Result { - let provider = Arc::new(get_http_provider(rpc)); - let chain = provider.get_chain_id().await?; +impl ProviderInfo { + pub async fn new( + rpc: &str, + chain: Option, + mut is_legacy: bool, + fee_estimate: Eip1559FeeEstimatePreset, + config: &Config, + ) -> Result { + let provider = Arc::new(ProviderBuilder::from_config_with_url(config, rpc)?.build()?); + let chain = match chain { + Some(chain) => chain, + None => provider.get_chain_id().await?, + }; if let Some(chain) = Chain::from(chain).named() { is_legacy |= chain.is_legacy(); @@ -66,22 +87,36 @@ impl ProviderInfo { ) } else { GasPrice::EIP1559( - provider.estimate_eip1559_fees().await.wrap_err("Failed to get EIP-1559 fees"), + estimate_eip1559_fees(&provider, fee_estimate) + .await + .wrap_err("Failed to get EIP-1559 fees"), ) }; Ok(Self { provider, chain, gas_price }) } - /// Returns the gas price to use + /// Returns the gas price to use. + /// + /// For EIP-1559 chains this is the estimated `maxFeePerGas`. pub fn gas_price(&self) -> Result { - let res = match &self.gas_price { - GasPrice::Legacy(res) => res.as_ref(), - GasPrice::EIP1559(res) => res.as_ref().map(|res| &res.max_fee_per_gas), - }; - match res { - Ok(val) => Ok(*val), - Err(err) => Err(eyre::eyre!("{}", err)), + match &self.gas_price { + GasPrice::Legacy(res) => match res { + Ok(val) => Ok(*val), + Err(err) => Err(eyre::eyre!("{}", err)), + }, + GasPrice::EIP1559(res) => match res { + Ok(fees) => Ok(fees.max_fee_per_gas), + Err(err) => Err(eyre::eyre!("{}", err)), + }, + } + } + + /// Returns the resolved EIP-1559 fee breakdown, if available. + pub const fn eip1559_fees(&self) -> Option<&ResolvedEip1559Fees> { + match &self.gas_price { + GasPrice::EIP1559(Ok(fees)) => Some(fees), + _ => None, } } } diff --git a/crates/script/src/receipts.rs b/crates/script/src/receipts.rs index 4ef87b2af4e12..be5165deed8ea 100644 --- a/crates/script/src/receipts.rs +++ b/crates/script/src/receipts.rs @@ -1,45 +1,76 @@ use alloy_chains::{Chain, NamedChain}; -use alloy_network::AnyTransactionReceipt; +use alloy_network::{Network, ReceiptResponse}; use alloy_primitives::{TxHash, U256, utils::format_units}; -use alloy_provider::{PendingTransactionBuilder, PendingTransactionError, Provider, WatchTxError}; +use alloy_provider::{ + PendingTransactionBuilder, PendingTransactionError, Provider, RootProvider, WatchTxError, +}; use eyre::{Result, eyre}; -use foundry_common::{provider::RetryProvider, retry, retry::RetryError, shell}; +use forge_script_sequence::ScriptSequence; +use foundry_common::{retry, retry::RetryError, shell}; use std::time::Duration; +/// Marker error type for pending receipts +#[derive(Debug, thiserror::Error)] +#[error( + "Received a pending receipt for {tx_hash}, but transaction is still known to the node, retrying" +)] +pub struct PendingReceiptError { + pub tx_hash: TxHash, +} + /// Convenience enum for internal signalling of transaction status -pub enum TxStatus { +pub enum TxStatus { Dropped, - Success(AnyTransactionReceipt), - Revert(AnyTransactionReceipt), + Success(R), + Revert(R), } -impl From for TxStatus { - fn from(receipt: AnyTransactionReceipt) -> Self { - if !receipt.inner.inner.inner.receipt.status.coerce_status() { - Self::Revert(receipt) - } else { - Self::Success(receipt) - } +impl From for TxStatus { + fn from(receipt: R) -> Self { + if receipt.status() { Self::Success(receipt) } else { Self::Revert(receipt) } } } /// Checks the status of a txhash by first polling for a receipt, then for /// mempool inclusion. Returns the tx hash, and a status -pub async fn check_tx_status( - provider: &RetryProvider, +pub async fn check_tx_status( + provider: &RootProvider, hash: TxHash, timeout: u64, -) -> (TxHash, Result) { + confirmations: u64, +) -> (TxHash, Result, eyre::Report>) { let result = retry::Retry::new_no_delay(3) .run_async_until_break(|| async { match PendingTransactionBuilder::new(provider.clone(), hash) .with_timeout(Some(Duration::from_secs(timeout))) + .with_required_confirmations(confirmations) .get_receipt() .await { - Ok(receipt) => Ok(receipt.into()), + Ok(receipt) => { + if is_mined_receipt_for(&receipt, hash) { + return Ok(receipt.into()); + } + + // Receipt is pending or foreign, try to sleep and retry a few times + match provider.get_transaction_by_hash(hash).await { + Ok(Some(_)) => { + // Sleep for a short time to allow the transaction to be mined + tokio::time::sleep(Duration::from_millis(500)).await; + // Transaction is still known to the node, retry + Err(RetryError::Retry(PendingReceiptError { tx_hash: hash }.into())) + } + Ok(None) => { + // Transaction is not known to the node, mark it as dropped + Ok(TxStatus::Dropped) + } + Err(err) => Err(RetryError::Retry(eyre!( + "failed to check if transaction {hash} is still known to the node: {err}" + ))), + } + } Err(e) => match provider.get_transaction_by_hash(hash).await { - Ok(_) => match e { + Ok(Some(_)) => match e { PendingTransactionError::TxWatcher(WatchTxError::Timeout) => { Err(RetryError::Continue(eyre!( "tx is still known to the node, waiting for receipt" @@ -47,7 +78,10 @@ pub async fn check_tx_status( } _ => Err(RetryError::Retry(e.into())), }, - Err(_) => Ok(TxStatus::Dropped), + Ok(None) => Ok(TxStatus::Dropped), + Err(err) => Err(RetryError::Retry(eyre!( + "failed to check if transaction {hash} is still known to the node after receipt error: {err}; receipt error: {e}" + ))), }, } }) @@ -56,38 +90,79 @@ pub async fn check_tx_status( (hash, result) } +/// Returns true if `receipt` is a mined receipt for `hash`. +pub(crate) fn is_mined_receipt_for(receipt: &R, hash: TxHash) -> bool { + receipt.transaction_hash() == hash + && receipt.block_number().is_some() + && receipt.block_hash().is_some() + && receipt.transaction_index().is_some() +} + /// Prints parts of the receipt to stdout -pub fn format_receipt(chain: Chain, receipt: &AnyTransactionReceipt) -> String { - let gas_used = receipt.gas_used; - let gas_price = receipt.effective_gas_price; - let block_number = receipt.block_number.unwrap_or_default(); - let success = receipt.inner.inner.inner.receipt.status.coerce_status(); +pub fn format_receipt( + chain: Chain, + receipt: &N::ReceiptResponse, + sequence: Option<&ScriptSequence>, +) -> String { + let gas_used = receipt.gas_used(); + let gas_price = receipt.effective_gas_price(); + let block_number = receipt.block_number().unwrap_or_default(); + let success = receipt.status(); + + let (contract_name, function) = sequence + .and_then(|seq| { + seq.transactions + .iter() + .find(|tx| tx.hash == Some(receipt.transaction_hash())) + .map(|tx| (tx.contract_name.clone(), tx.function.clone())) + }) + .unwrap_or((None, None)); if shell::is_json() { - let _ = sh_println!( - "{}", - serde_json::json!({ - "chain": chain, - "status": if success { - "success" - } else { - "failed" - }, - "tx_hash": receipt.transaction_hash, - "contract_address": receipt.contract_address.map(|addr| addr.to_string()), - "block_number": block_number, - "gas_used": gas_used, - "gas_price": gas_price, - }) - ); + let mut json = serde_json::json!({ + "chain": chain, + "status": if success { + "success" + } else { + "failed" + }, + "tx_hash": receipt.transaction_hash(), + "contract_address": receipt.contract_address().map(|addr| addr.to_string()), + "block_number": block_number, + "gas_used": gas_used, + "gas_price": gas_price, + }); + + if let Some(name) = &contract_name + && !name.is_empty() + { + json["contract_name"] = serde_json::Value::String(name.clone()); + } + if let Some(func) = &function + && !func.is_empty() + { + json["function"] = serde_json::Value::String(func.clone()); + } + + let _ = sh_println!("{}", json); String::new() } else { + let contract_info = match &contract_name { + Some(name) if !name.is_empty() => format!("\nContract: {name}"), + _ => String::new(), + }; + + let function_info = match &function { + Some(func) if !func.is_empty() => format!("\nFunction: {func}"), + _ => String::new(), + }; + format!( - "\n##### {chain}\n{status} Hash: {tx_hash:?}{contract_address}\nBlock: {block_number}\n{gas}\n\n", + "\n##### {chain}\n{status} Hash: {tx_hash:?}{contract_info}{function_info}{contract_address}\nBlock: {block_number}\n{gas}\n\n", status = if success { "✅ [Success]" } else { "❌ [Failed]" }, - tx_hash = receipt.transaction_hash, - contract_address = if let Some(addr) = &receipt.contract_address { + tx_hash = receipt.transaction_hash(), + contract_address = if let Some(addr) = receipt.contract_address() { format!("\nContract Address: {}", addr.to_checksum(None)) } else { String::new() @@ -113,3 +188,326 @@ pub fn format_receipt(chain: Chain, receipt: &AnyTransactionReceipt) -> String { ) } } + +#[cfg(test)] +mod tests { + use super::*; + use alloy_network::{Ethereum, TransactionBuilder}; + use alloy_primitives::{B256, Bloom}; + use alloy_provider::{ProviderBuilder, mock::Asserter}; + use alloy_rpc_types::{TransactionReceipt, TransactionRequest}; + use std::collections::VecDeque; + + fn mock_receipt(tx_hash: B256, success: bool) -> TransactionReceipt { + serde_json::from_value(serde_json::json!({ + "type": "0x02", "status": if success { "0x1" } else { "0x0" }, + "cumulativeGasUsed": "0x5208", "logs": [], "transactionHash": tx_hash, + "logsBloom": format!("{:#x}", Bloom::ZERO), + "transactionIndex": "0x0", "blockHash": B256::ZERO, "blockNumber": "0x3039", + "gasUsed": "0x5208", "effectiveGasPrice": "0x4a817c800", + "from": "0x0000000000000000000000000000000000000000", + "to": "0x0000000000000000000000000000000000000000", "contractAddress": null + })) + .unwrap() + } + + fn mock_sequence( + tx_hash: B256, + contract: Option<&str>, + func: Option<&str>, + ) -> ScriptSequence { + let tx = serde_json::from_value(serde_json::json!({ + "hash": tx_hash, "transactionType": "CALL", + "contractName": contract, "contractAddress": null, "function": func, + "arguments": null, "additionalContracts": [], "isFixedGasLimit": false, + "transaction": { + "type": "0x02", "chainId": "0x1", "nonce": "0x0", "gas": "0x5208", + "maxFeePerGas": "0x4a817c800", "maxPriorityFeePerGas": "0x3b9aca00", + "to": "0x0000000000000000000000000000000000000000", + "value": "0x0", "input": "0x", "accessList": [] + }, + })) + .unwrap(); + ScriptSequence { transactions: VecDeque::from([tx]), chain: 1, ..Default::default() } + } + + #[test] + fn format_receipt_displays_contract_and_function() { + let hash = B256::repeat_byte(0x42); + let seq = mock_sequence(hash, Some("MyContract"), Some("init(address)")); + let out = format_receipt(Chain::mainnet(), &mock_receipt(hash, true), Some(&seq)); + + assert!(out.contains("Contract: MyContract")); + assert!(out.contains("Function: init(address)")); + assert!(out.contains("✅ [Success]")); + } + + #[test] + fn format_receipt_without_sequence_omits_metadata() { + let hash = B256::repeat_byte(0x42); + let out = format_receipt::(Chain::mainnet(), &mock_receipt(hash, true), None); + + assert!(!out.contains("Contract:")); + assert!(!out.contains("Function:")); + } + + #[test] + fn format_receipt_skips_empty_contract_name() { + let hash = B256::repeat_byte(0x42); + let seq = mock_sequence(hash, Some(""), Some("transfer(address)")); + let out = format_receipt(Chain::mainnet(), &mock_receipt(hash, true), Some(&seq)); + + assert!(!out.contains("Contract:")); + assert!(out.contains("Function: transfer(address)")); + } + + #[test] + fn format_receipt_handles_missing_tx_in_sequence() { + let seq = mock_sequence(B256::repeat_byte(0x99), Some("Other"), Some("other()")); + let out = format_receipt( + Chain::mainnet(), + &mock_receipt(B256::repeat_byte(0x42), true), + Some(&seq), + ); + + assert!(!out.contains("Contract:")); + assert!(!out.contains("Function:")); + } + + #[test] + fn format_receipt_shows_contract_on_failure() { + let hash = B256::repeat_byte(0x42); + let seq = mock_sequence(hash, Some("FailContract"), Some("fail()")); + let out = format_receipt(Chain::mainnet(), &mock_receipt(hash, false), Some(&seq)); + + assert!(out.contains("❌ [Failed]")); + assert!(out.contains("Contract: FailContract")); + } + + #[tokio::test] + async fn check_tx_status_marks_null_transaction_lookup_as_dropped() { + let hash = B256::repeat_byte(0x42); + let asserter = Asserter::new(); + let provider: RootProvider = + ProviderBuilder::default().connect_mocked_client(asserter.clone()); + let not_found: Option = None; + + for _ in 0..50_000 { + asserter.push_success(¬_found); + } + + let null_responder = tokio::spawn({ + let asserter = asserter.clone(); + async move { + let not_found: Option = None; + loop { + for _ in 0..1_000 { + asserter.push_success(¬_found); + } + tokio::task::yield_now().await; + } + } + }); + + let result = + tokio::time::timeout(Duration::from_secs(2), check_tx_status(&provider, hash, 0, 1)) + .await; + null_responder.abort(); + + let (returned_hash, status) = result.expect( + "check_tx_status should not keep waiting when eth_getTransactionByHash returns null", + ); + + assert_eq!(returned_hash, hash); + assert!(matches!(status.unwrap(), TxStatus::Dropped)); + } + + #[tokio::test] + async fn check_tx_status_does_not_mark_lookup_errors_as_dropped() { + let hash = B256::repeat_byte(0x42); + let asserter = Asserter::new(); + let provider: RootProvider = + ProviderBuilder::default().connect_mocked_client(asserter.clone()); + let not_found: Option = None; + + // Initial receipt lookup while registering the pending transaction. + asserter.push_success(¬_found); + for _ in 0..50 { + asserter.push_failure_msg("lookup unavailable"); + } + + let (_, status) = check_tx_status(&provider, hash, 0, 1).await; + let err = match status { + Ok(_) => panic!("transaction lookup errors should not be marked as dropped"), + Err(err) => err.to_string(), + }; + + assert!(err.contains("failed to check if transaction")); + assert!(err.contains("lookup unavailable")); + } + + #[tokio::test] + async fn check_tx_status_rejects_foreign_receipt() { + let hash = B256::repeat_byte(0x42); + let foreign = mock_receipt(B256::repeat_byte(0x99), true); + let asserter = Asserter::new(); + let provider: RootProvider = + ProviderBuilder::default().connect_mocked_client(asserter.clone()); + + // Receipt lookups while registering and polling, then the transaction lookup. + asserter.push_success(&foreign); + asserter.push_success(&foreign); + asserter.push_success(&None::<()>); + + let (_, status) = check_tx_status(&provider, hash, 0, 1).await; + + assert!(matches!(status.unwrap(), TxStatus::Dropped)); + } + + /// Upper bound for the anvil-based `check_tx_status` tests, so a hang fails fast + /// instead of stalling the suite. + const CHECK_TX_TIMEOUT: Duration = Duration::from_secs(15); + + /// A tx hash the node doesn't know about (rejected at submission or dropped from + /// the mempool) must resolve to `TxStatus::Dropped` rather than polling forever. + #[tokio::test(flavor = "multi_thread")] + async fn check_tx_status_unknown_tx_is_dropped() { + let (_api, handle) = anvil::spawn(anvil::NodeConfig::test()).await; + let provider = ProviderBuilder::new() + .connect_http(handle.http_endpoint().parse().unwrap()) + .root() + .clone(); + + // Random hash the node has never seen. + let unknown_hash = B256::repeat_byte(0xab); + + // Inner `timeout=1` bounds the pending-tx watcher; the outer `tokio::time::timeout` + // guarantees the test fails fast if the watcher ever hangs again. + let (returned_hash, status) = tokio::time::timeout( + CHECK_TX_TIMEOUT, + check_tx_status::(&provider, unknown_hash, 1, 1), + ) + .await + .expect("check_tx_status hung on an unknown tx hash"); + + assert_eq!(returned_hash, unknown_hash); + let status = status.expect("unknown tx should resolve to Ok(TxStatus::Dropped)"); + assert!( + matches!(status, TxStatus::Dropped), + "expected TxStatus::Dropped for an unknown tx", + ); + } + + /// A tx that is initially in the mempool (so `eth_getTransactionByHash` returns + /// `Some`) and is later dropped from it must: + /// 1. keep retrying while the node still knows the tx (the `Ok(Some(_))` branch), and + /// 2. resolve to `TxStatus::Dropped` once the node forgets it (the `Ok(None)` branch). + #[tokio::test(flavor = "multi_thread")] + async fn check_tx_status_known_then_dropped_resolves_to_dropped() { + // `no_mining` keeps the tx pending so `get_receipt` always times out and the + // watcher exercises the `WatchTxError::Timeout` + `get_transaction_by_hash` + // branch on every retry. + let (api, handle) = anvil::spawn(anvil::NodeConfig::test().with_no_mining(true)).await; + let signer_provider = + ProviderBuilder::new().connect_http(handle.http_endpoint().parse().unwrap()); + + let mut wallets = handle.dev_wallets(); + let from = wallets.next().unwrap().address(); + let to = wallets.next().unwrap().address(); + let tx = TransactionRequest::default().with_from(from).with_to(to).with_value(U256::ONE); + + let pending = signer_provider.send_transaction(tx).await.unwrap(); + let tx_hash = *pending.tx_hash(); + + // Run `check_tx_status` concurrently; while it loops on `Ok(Some(_))`, drop the + // tx from the mempool so the next iteration sees `Ok(None)` and exits. + let provider = signer_provider.root().clone(); + let watcher = tokio::spawn(async move { + tokio::time::timeout( + CHECK_TX_TIMEOUT, + check_tx_status::(&provider, tx_hash, 1, 1), + ) + .await + }); + + // Give the watcher at least one Continue iteration before evicting the tx. + tokio::time::sleep(Duration::from_millis(1500)).await; + api.anvil_drop_transaction(tx_hash).await.unwrap(); + + let (returned_hash, status) = watcher + .await + .unwrap() + .expect("check_tx_status hung after the tx was dropped from the mempool"); + assert_eq!(returned_hash, tx_hash); + let status = status.expect("dropped tx should resolve to Ok(TxStatus::Dropped)"); + assert!( + matches!(status, TxStatus::Dropped), + "expected TxStatus::Dropped after the tx was evicted from the mempool", + ); + } + + /// A real, mined transaction must resolve to `TxStatus::Success` and not be + /// misclassified as dropped. + #[tokio::test(flavor = "multi_thread")] + async fn check_tx_status_mined_tx_is_success() { + let (_api, handle) = anvil::spawn(anvil::NodeConfig::test()).await; + let signer_provider = + ProviderBuilder::new().connect_http(handle.http_endpoint().parse().unwrap()); + + let mut wallets = handle.dev_wallets(); + let from = wallets.next().unwrap().address(); + let to = wallets.next().unwrap().address(); + let tx = TransactionRequest::default().with_from(from).with_to(to).with_value(U256::ONE); + + // Send and mine the tx so a receipt is immediately available. + let pending = signer_provider.send_transaction(tx).await.unwrap(); + let tx_hash = *pending.tx_hash(); + let _ = pending.get_receipt().await.unwrap(); + + let provider = signer_provider.root().clone(); + let (returned_hash, status) = tokio::time::timeout( + CHECK_TX_TIMEOUT, + check_tx_status::(&provider, tx_hash, 5, 1), + ) + .await + .expect("check_tx_status hung on a mined tx"); + + assert_eq!(returned_hash, tx_hash); + let status = status.expect("mined tx should resolve to Ok(TxStatus::Success)"); + assert!( + matches!(status, TxStatus::Success(_)), + "expected TxStatus::Success for a mined ETH transfer", + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn check_tx_status_waits_for_confirmations() { + let (api, handle) = anvil::spawn(anvil::NodeConfig::test().with_no_mining(true)).await; + let signer_provider = + ProviderBuilder::new().connect_http(handle.http_endpoint().parse().unwrap()); + + let mut wallets = handle.dev_wallets(); + let from = wallets.next().unwrap().address(); + let to = wallets.next().unwrap().address(); + let tx = TransactionRequest::default().with_from(from).with_to(to).with_value(U256::ONE); + + let pending = signer_provider.send_transaction(tx).await.unwrap(); + let tx_hash = *pending.tx_hash(); + api.mine_one().await.unwrap(); + + let provider = signer_provider.root().clone(); + let mut watcher = + tokio::spawn( + async move { check_tx_status::(&provider, tx_hash, 5, 3).await }, + ); + + assert!(tokio::time::timeout(Duration::from_millis(500), &mut watcher).await.is_err()); + + api.anvil_mine(Some(U256::from(2)), None).await.unwrap(); + let (returned_hash, status) = + tokio::time::timeout(CHECK_TX_TIMEOUT, watcher).await.unwrap().unwrap(); + + assert_eq!(returned_hash, tx_hash); + assert!(matches!(status.unwrap(), TxStatus::Success(_))); + } +} diff --git a/crates/script/src/recovery.rs b/crates/script/src/recovery.rs new file mode 100644 index 0000000000000..47b249af47f1e --- /dev/null +++ b/crates/script/src/recovery.rs @@ -0,0 +1,1974 @@ +use crate::sequence::{SequenceData, completed_transaction_prefix, operation_ordinal}; +use alloy_consensus::{Transaction, transaction::SignerRecoverable}; +use alloy_eips::eip2718::{Decodable2718, Encodable2718}; +use alloy_network::{Network, TransactionBuilder, TransactionResponse}; +use alloy_primitives::{Address, B256, Bytes, TxKind, keccak256}; +use eyre::{ContextCompat, Result, WrapErr, bail}; +use forge_script_sequence::TransactionWithMetadata; +use foundry_common::{FoundryTransactionBuilder, TransactionMaybeSigned}; +use serde::{Deserialize, Serialize}; +use std::{ + fs::{File, OpenOptions}, + io::{BufWriter, Write}, + path::{Path, PathBuf}, +}; +use tempfile::NamedTempFile; +use tempo_primitives::transaction::{TEMPO_EXPIRING_NONCE_KEY, TempoTxEnvelope}; + +#[cfg(unix)] +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + +const RECOVERY_VERSION: u32 = 1; + +#[derive(Clone, Serialize, Deserialize)] +#[serde(bound( + serialize = "N::TxEnvelope: Serialize", + deserialize = "N::TxEnvelope: for<'de2> Deserialize<'de2>" +))] +struct RecoveryPlan { + version: u32, + generation: B256, + multi: bool, + batch: bool, + deployments: Vec>, + data: SequenceData, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(bound = "")] +struct RecoveryDeployment { + chain: u64, + batch_id: Option, + operations: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + attempts: Vec>, +} + +#[derive(Clone, Serialize, Deserialize)] +struct RecoveryOperation { + id: OperationId, + fingerprint: B256, + rpc: String, +} + +#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +struct OperationId { + sequence: u32, + index: u32, +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct SignedPayload { + pub(crate) payload: Bytes, + pub(crate) hash: B256, +} + +#[derive(Clone, Serialize, Deserialize)] +struct SubmissionAttempt { + id: B256, + members: Vec, + kind: AttemptKind, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub(crate) enum AttemptKind { + Signed { request: Option, payload: SignedPayload }, + Delegated { request: T, status: DelegatedStatus }, + Legacy { hash: B256 }, +} + +#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "camelCase")] +pub(crate) enum DelegatedStatus { + Prepared, + Pending { hash: B256 }, + OutcomeUnknown, +} + +/// Owns the authoritative recovery snapshot and its process-lifetime writer lock. +pub(crate) struct RecoveryStore { + path: PathBuf, + _lock: RecoveryLock, + plan: RecoveryPlan, +} + +pub(crate) struct RecoveryLock { + path: PathBuf, + _files: Vec, +} + +pub(crate) struct RecoveryRelocation { + lock: RecoveryLock, +} + +impl RecoveryStore +where + N::TxEnvelope: for<'de> Deserialize<'de> + Serialize, +{ + pub(crate) const fn data(&self) -> &SequenceData { + &self.plan.data + } + + pub(crate) const fn data_mut(&mut self) -> &mut SequenceData { + &mut self.plan.data + } + + pub(crate) const fn is_batch(&self) -> bool { + self.plan.batch + } + + pub(crate) fn create(mut data: SequenceData, batch: bool) -> Result { + let paths = data.paths(); + let lock = RecoveryLock::acquire(&paths)?; + let pending = pending_path(&lock.path); + if pending.exists() { + bail!( + "uncommitted recovery snapshot `{}` already exists; resume it before starting a new execution", + pending.display() + ); + } + let generation = B256::random(); + data.set_recovery_generation(generation); + let plan = RecoveryPlan::new(data, batch, generation)?; + write_snapshot(&lock.path, &plan)?; + Self::finish_open(lock, plan) + } + + pub(crate) fn load( + paths: &(PathBuf, PathBuf), + batch: bool, + lock: RecoveryLock, + ) -> Result> + where + N::TxEnvelope: SignerRecoverable, + N::TransactionRequest: FoundryTransactionBuilder, + { + let pending = pending_path(&lock.path); + let (mut plan, recover_pending) = if pending.exists() { + (load_plan(&pending)?, true) + } else if lock.path.exists() { + (load_plan(&lock.path)?, false) + } else { + return Ok(None); + }; + plan.restore_sensitive(); + plan.data.set_paths(paths.clone()); + plan.validate(batch)?; + plan.validate_signed_payloads()?; + if recover_pending { + commit_pending_plan(&pending, &lock.path)?; + } + Self::finish_open(lock, plan).map(Some) + } + + pub(crate) fn import(mut data: SequenceData, batch: bool, lock: RecoveryLock) -> Result + where + N::TxEnvelope: SignerRecoverable, + N::TransactionRequest: FoundryTransactionBuilder, + { + let generation = B256::random(); + data.set_recovery_generation(generation); + let mut plan = RecoveryPlan::new(data, batch, generation)?; + if batch { + plan.import_legacy_batch_attempts()?; + } + plan.validate_signed_payloads()?; + write_snapshot(&lock.path, &plan)?; + Self::finish_open(lock, plan) + } + + fn finish_open(lock: RecoveryLock, plan: RecoveryPlan) -> Result { + #[cfg(unix)] + for path in [&lock.path, &pending_path(&lock.path)] { + if path.exists() { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; + } + } + Ok(Self { path: lock.path.clone(), _lock: lock, plan }) + } + + pub(crate) fn save(&self) -> Result<()> { + self.plan.validate(self.plan.batch)?; + write_snapshot(&self.path, &self.plan) + } + + fn submission_attempts( + &self, + sequence: usize, + index: usize, + ) -> impl Iterator> { + let operation = self + .plan + .deployments + .get(sequence) + .and_then(|deployment| deployment.operations.get(index)) + .map(|operation| operation.id); + self.plan + .deployments + .get(sequence) + .into_iter() + .flat_map(|deployment| &deployment.attempts) + .filter(move |attempt| operation.is_some_and(|id| attempt.members.contains(&id))) + } + + pub(crate) fn signed_payload(&self, sequence: usize, index: usize) -> Option<&SignedPayload> { + self.submission_attempts(sequence, index).find_map(|attempt| match &attempt.kind { + AttemptKind::Signed { payload, .. } => Some(payload), + AttemptKind::Delegated { .. } | AttemptKind::Legacy { .. } => None, + }) + } + + pub(crate) fn delegated_status( + &self, + sequence: usize, + index: usize, + ) -> Option { + self.submission_attempts(sequence, index).find_map(|attempt| match &attempt.kind { + AttemptKind::Delegated { status, .. } => Some(*status), + AttemptKind::Signed { .. } | AttemptKind::Legacy { .. } => None, + }) + } + + pub(crate) fn submission_hashes(&self, sequence: usize) -> (Vec, Vec) { + let mut durable = Vec::new(); + let mut replayable = Vec::new(); + for attempt in self + .plan + .deployments + .get(sequence) + .into_iter() + .flat_map(|deployment| &deployment.attempts) + { + match &attempt.kind { + AttemptKind::Signed { payload, .. } => { + durable.push(payload.hash); + replayable.push(payload.hash); + } + AttemptKind::Delegated { status: DelegatedStatus::Pending { hash }, .. } => { + durable.push(*hash); + } + AttemptKind::Legacy { hash } => durable.push(*hash), + AttemptKind::Delegated { .. } => {} + } + } + (durable, replayable) + } + + pub(crate) fn batch_attempt( + &self, + sequence: usize, + ) -> Option<(usize, B256, &AttemptKind)> { + if !self.plan.batch { + return None; + } + let attempt = self.plan.deployments.get(sequence)?.attempts.first()?; + Some((attempt.members.first()?.index as usize, attempt.id, &attempt.kind)) + } + + pub(crate) fn persist_signed_payload( + &mut self, + sequence: usize, + index: usize, + payload: Bytes, + sender_scoped_replay: bool, + ) -> Result + where + N::TxEnvelope: SignerRecoverable, + N::TransactionRequest: FoundryTransactionBuilder, + { + let deployment = self + .plan + .deployments + .get(sequence) + .context("signed payload deployment is not in the recovery snapshot")?; + let operation = deployment + .operations + .get(index) + .context("signed payload operation is not in the recovery snapshot")?; + let transaction = &self.plan.data.sequences()[sequence].transactions[index]; + let signed = validate_signed_payload::( + payload, + transaction, + deployment.chain, + index, + operation_ordinal(self.plan.data.sequences(), sequence, index), + )?; + let id = operation.id; + // From T1B, expiring replay identities exclude sponsor signatures, so different envelopes + // can still be replays. Compare identities across all sequences of the same chain before + // recording an attempt the node would reject. Keep envelope hashes for receipt tracking. + let replay_hash = expiring_replay_hash(&signed.payload, sender_scoped_replay)?; + for attempt in self + .plan + .deployments + .iter() + .enumerate() + .filter(|(i, other)| { + *i == sequence || (replay_hash.is_some() && other.chain == deployment.chain) + }) + .flat_map(|(_, other)| &other.attempts) + { + if !attempt.members.contains(&id) + && let AttemptKind::Signed { payload, .. } = &attempt.kind + && match replay_hash { + Some(hash) => { + expiring_replay_hash(&payload.payload, sender_scoped_replay)? == Some(hash) + } + None => payload.hash == signed.hash, + } + { + bail!( + "transaction {index} is identical to an earlier transaction of this script ({}) and would be rejected as a replay; identical Tempo expiring nonce transactions are only distinct from the T12 hardfork (TIP-1106)", + signed.hash + ); + } + } + if let Some(existing) = self.signed_payload(sequence, index) { + if existing != &signed { + bail!("refusing to replace an existing signed payload"); + } + return Ok(existing.hash); + } + if deployment.attempts.iter().any(|attempt| attempt.members.contains(&id)) { + bail!("refusing to replace an existing submission attempt"); + } + let hash = signed.hash; + self.persist_attempt( + sequence, + SubmissionAttempt { + id: B256::random(), + members: vec![id], + kind: AttemptKind::Signed { request: None, payload: signed }, + }, + )?; + Ok(hash) + } + + pub(crate) fn persist_delegated_request( + &mut self, + sequence: usize, + index: usize, + request: N::TransactionRequest, + ) -> Result<()> { + let deployment = self + .plan + .deployments + .get(sequence) + .context("delegated operation is not in the recovery snapshot")?; + let id = deployment + .operations + .get(index) + .context("delegated operation is not in the recovery snapshot")? + .id; + if deployment.attempts.iter().any(|attempt| attempt.members.contains(&id)) { + bail!("refusing to replace an existing submission attempt"); + } + self.persist_attempt( + sequence, + SubmissionAttempt { + id: B256::random(), + members: vec![id], + kind: AttemptKind::Delegated { request, status: DelegatedStatus::Prepared }, + }, + ) + } + + pub(crate) fn persist_delegated_status( + &mut self, + sequence: usize, + index: usize, + status: DelegatedStatus, + ) -> Result<()> { + let deployment = self + .plan + .deployments + .get_mut(sequence) + .context("delegated operation is not in the recovery snapshot")?; + let id = deployment + .operations + .get(index) + .context("delegated operation is not in the recovery snapshot")? + .id; + let attempt = deployment + .attempts + .iter_mut() + .find(|attempt| attempt.members.contains(&id)) + .context("delegated operation has no submission attempt")?; + let AttemptKind::Delegated { status: current, .. } = &mut attempt.kind else { + bail!("operation has no delegated submission attempt"); + }; + let previous = *current; + if previous == status { + return Ok(()); + } + *current = status; + if let Err(error) = write_snapshot(&self.path, &self.plan) { + let AttemptKind::Delegated { status, .. } = &mut self.plan.deployments[sequence] + .attempts + .iter_mut() + .find(|attempt| attempt.members.contains(&id)) + .unwrap() + .kind + else { + unreachable!() + }; + *status = previous; + return Err(error); + } + Ok(()) + } + + pub(crate) fn clear_delegated_request(&mut self, sequence: usize, index: usize) -> Result<()> { + let deployment = self + .plan + .deployments + .get_mut(sequence) + .context("delegated operation is not in the recovery snapshot")?; + let id = deployment + .operations + .get(index) + .context("delegated operation is not in the recovery snapshot")? + .id; + let position = deployment + .attempts + .iter() + .position(|attempt| { + attempt.members.contains(&id) + && matches!(&attempt.kind, AttemptKind::Delegated { .. }) + }) + .context("delegated operation has no submission attempt")?; + let attempt = deployment.attempts.remove(position); + if let Err(error) = write_snapshot(&self.path, &self.plan) { + self.plan.deployments[sequence].attempts.insert(position, attempt); + return Err(error); + } + Ok(()) + } + + pub(crate) fn delegated_attempt_location(&self, id: B256) -> Option<(usize, usize)> { + self.plan.deployments.iter().enumerate().find_map(|(sequence, deployment)| { + let attempt = deployment.attempts.iter().find(|attempt| { + attempt.id == id && matches!(attempt.kind, AttemptKind::Delegated { .. }) + })?; + let operation = attempt.members.first()?; + Some((sequence, operation.index as usize)) + }) + } + + pub(crate) fn delegated_attempts(&self) -> Vec<(usize, usize, B256, DelegatedStatus)> { + self.plan + .deployments + .iter() + .enumerate() + .flat_map(|(sequence, deployment)| { + deployment.attempts.iter().filter_map(move |attempt| { + let AttemptKind::Delegated { status, .. } = &attempt.kind else { return None }; + Some((sequence, attempt.members.first()?.index as usize, attempt.id, *status)) + }) + }) + .collect() + } + + pub(crate) fn resolve_delegated_hash( + &mut self, + attempt_id: B256, + hash: B256, + transaction: &N::TransactionResponse, + ) -> Result<(usize, usize)> + where + N::TransactionRequest: FoundryTransactionBuilder, + { + let (sequence, index) = self + .delegated_attempt_location(attempt_id) + .context("no interrupted delegated submission matches --resume-attempt")?; + let deployment = &self.plan.deployments[sequence]; + let attempt = deployment.attempts.iter().find(|attempt| attempt.id == attempt_id).unwrap(); + let AttemptKind::Delegated { request, status } = &attempt.kind else { unreachable!() }; + if !matches!(status, DelegatedStatus::Prepared | DelegatedStatus::OutcomeUnknown) { + bail!("delegated submission attempt {attempt_id} does not require resolution"); + } + validate_delegated_transaction::(transaction, request, deployment.chain, hash)?; + self.persist_delegated_status(sequence, index, DelegatedStatus::Pending { hash })?; + Ok((sequence, index)) + } + + pub(crate) fn persist_batch_signed_payload( + &mut self, + sequence: usize, + first_operation: usize, + request: N::TransactionRequest, + payload: Bytes, + ) -> Result + where + N::TxEnvelope: Decodable2718 + Encodable2718, + { + let signed = SignedPayload { hash: signed_payload_hash::(&payload)?, payload }; + let hash = signed.hash; + self.persist_batch_attempt( + sequence, + first_operation, + AttemptKind::Signed { request: Some(request), payload: signed }, + )?; + Ok(hash) + } + + pub(crate) fn persist_batch_delegated_request( + &mut self, + sequence: usize, + first_operation: usize, + request: N::TransactionRequest, + ) -> Result<()> { + self.persist_batch_attempt( + sequence, + first_operation, + AttemptKind::Delegated { request, status: DelegatedStatus::Prepared }, + ) + } + + fn persist_batch_attempt( + &mut self, + sequence: usize, + first_operation: usize, + kind: AttemptKind, + ) -> Result<()> { + if self.batch_attempt(sequence).is_some() { + bail!("refusing to replace an existing batch submission attempt"); + } + let members = self + .plan + .deployments + .get(sequence) + .context("batch deployment is not in the recovery snapshot")? + .operations + .get(first_operation..) + .context("batch operation is not in the recovery snapshot")? + .iter() + .map(|operation| operation.id) + .collect::>(); + if members.is_empty() { + bail!("batch submission has no operations"); + } + self.persist_attempt(sequence, SubmissionAttempt { id: B256::random(), members, kind }) + } + + fn persist_attempt( + &mut self, + sequence: usize, + attempt: SubmissionAttempt, + ) -> Result<()> { + self.plan + .deployments + .get_mut(sequence) + .context("submission deployment is not in the recovery snapshot")? + .attempts + .push(attempt); + if let Err(error) = write_snapshot(&self.path, &self.plan) { + self.plan.deployments[sequence].attempts.pop(); + return Err(error); + } + Ok(()) + } + + pub(crate) fn prepare_relocation( + &self, + paths: &(PathBuf, PathBuf), + ) -> Result { + let lock = RecoveryLock::acquire(paths)?; + let path = &lock.path; + if paths.0.exists() { + bail!( + "broadcast progress `{}` already exists; refusing to replace it", + paths.0.display() + ); + } + if path.exists() { + let existing: RecoveryPlan = foundry_common::fs::read_json_file(path) + .wrap_err_with(|| format!("recovery snapshot `{}` is corrupt", path.display()))?; + existing.validate(self.plan.batch)?; + self.plan.validate(self.plan.batch)?; + if serde_json::to_value(existing.deployments)? + != serde_json::to_value(&self.plan.deployments)? + { + bail!("destination recovery snapshot does not match the script operations"); + } + } + write_snapshot(path, &self.plan)?; + Ok(RecoveryRelocation { lock }) + } + + pub(crate) fn commit_relocation(&mut self, relocation: RecoveryRelocation) -> Result<()> { + let RecoveryRelocation { lock } = relocation; + let path = &lock.path; + #[cfg(unix)] + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; + self.path.clone_from(path); + self._lock = lock; + Ok(()) + } +} + +impl RecoveryLock { + pub(crate) fn acquire(paths: &(PathBuf, PathBuf)) -> Result { + let path = recovery_path_from_sensitive(&paths.1)?; + let mut lock_paths = + vec![path.with_extension("lock"), paths.0.with_extension("recovery.lock")]; + lock_paths.sort(); + lock_paths.dedup(); + let mut files = Vec::with_capacity(lock_paths.len()); + for lock_path in lock_paths { + if let Some(parent) = lock_path.parent() { + std::fs::create_dir_all(parent)?; + } + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true); + #[cfg(unix)] + options.mode(0o600); + let file = options.open(&lock_path)?; + #[cfg(unix)] + file.set_permissions(std::fs::Permissions::from_mode(0o600))?; + file.try_lock().wrap_err_with(|| { + format!("another process is already modifying recovery state `{}`", path.display()) + })?; + files.push(file); + } + Ok(Self { path, _files: files }) + } +} + +impl RecoveryPlan +where + N::TxEnvelope: for<'de> Deserialize<'de> + Serialize, +{ + fn new(data: SequenceData, batch: bool, generation: B256) -> Result { + let deployments = data + .sequences() + .iter() + .enumerate() + .map(|(sequence, deployment)| { + Ok(RecoveryDeployment { + chain: deployment.chain, + batch_id: batch.then(|| u32::try_from(sequence).expect("too many sequences")), + attempts: Vec::new(), + operations: deployment + .transactions + .iter() + .enumerate() + .map(|(index, transaction)| { + Ok(RecoveryOperation { + id: OperationId { + sequence: u32::try_from(sequence).expect("too many sequences"), + index: u32::try_from(index).expect("too many operations"), + }, + fingerprint: operation_fingerprint(transaction)?, + rpc: transaction.rpc.clone(), + }) + }) + .collect::>>()?, + }) + }) + .collect::>>()?; + Ok(Self { + version: RECOVERY_VERSION, + generation, + multi: data.is_multi(), + batch, + deployments, + data, + }) + } + + fn import_legacy_batch_attempts(&mut self) -> Result<()> { + for (deployment, data) in self.deployments.iter_mut().zip(self.data.sequences().iter()) { + let completed = completed_transaction_prefix(data)?; + if data.pending.is_empty() { + if data + .transactions + .iter() + .skip(completed) + .any(|transaction| transaction.hash.is_some()) + { + bail!("legacy batch progress has operation hashes without a pending hash"); + } + continue; + } + let [hash] = data.pending.as_slice() else { + bail!("cannot import legacy batch progress with multiple pending hashes"); + }; + let first = data + .transactions + .iter() + .position(|transaction| transaction.hash == Some(*hash)) + .context("legacy batch hash is not bound to a recovery operation")?; + if completed != first { + bail!("legacy batch progress omits an incomplete operation"); + } + if data + .transactions + .iter() + .skip(first) + .any(|transaction| transaction.hash != Some(*hash)) + { + bail!("cannot import inconsistent legacy batch operation hashes"); + } + deployment.attempts.push(SubmissionAttempt { + id: B256::random(), + members: deployment.operations[first..] + .iter() + .map(|operation| operation.id) + .collect(), + kind: AttemptKind::Legacy { hash: *hash }, + }); + } + Ok(()) + } + + fn validate(&self, batch: bool) -> Result<()> { + if self.version != RECOVERY_VERSION { + bail!( + "unsupported recovery snapshot version {}; expected version {}", + self.version, + RECOVERY_VERSION + ); + } + if self.batch != batch || self.multi != self.data.is_multi() { + bail!("recovery snapshot does not match the requested script mode"); + } + if self + .data + .sequences() + .iter() + .any(|sequence| sequence.recovery_generation != Some(self.generation)) + { + bail!("recovery snapshot contains inconsistent generations"); + } + let mut saved = self.deployments.clone(); + for deployment in &mut saved { + deployment.attempts.clear(); + } + let expected = Self::new(self.data.clone(), self.batch, self.generation)?; + if serde_json::to_value(saved)? != serde_json::to_value(&expected.deployments)? { + bail!("recovery snapshot does not match the script operations; refusing to resume"); + } + Ok(()) + } + + fn validate_signed_payloads(&self) -> Result<()> + where + N::TxEnvelope: SignerRecoverable, + N::TransactionRequest: FoundryTransactionBuilder, + { + for (sequence, deployment) in self.deployments.iter().enumerate() { + let data = &self.data.sequences()[sequence]; + if self.batch && deployment.attempts.is_empty() { + let completed = completed_transaction_prefix(data)?; + if !data.pending.is_empty() + || data + .transactions + .iter() + .skip(completed) + .any(|transaction| transaction.hash.is_some()) + { + bail!("batch progress exists without a durable submission attempt"); + } + } + if self.batch && deployment.attempts.len() > 1 { + bail!("recovery snapshot contains multiple batch submission attempts"); + } + let mut claimed = vec![false; deployment.operations.len()]; + for attempt in &deployment.attempts { + let Some(first) = attempt.members.first() else { + bail!("recovery snapshot attempt has invalid operation membership"); + }; + if first.sequence as usize != sequence { + bail!("recovery snapshot attempt has invalid operation membership"); + } + let start = first.index as usize; + let end = start + .checked_add(attempt.members.len()) + .context("recovery snapshot attempt has invalid operation membership")?; + let operations = deployment + .operations + .get(start..end) + .context("recovery snapshot attempt has invalid operation membership")?; + if operations + .iter() + .map(|operation| operation.id) + .ne(attempt.members.iter().copied()) + || claimed[start..end].iter().any(|claimed| *claimed) + || if self.batch { + end != deployment.operations.len() + } else { + attempt.members.len() != 1 + } + { + bail!("recovery snapshot attempt has invalid operation membership"); + } + if self.batch { + let completed = completed_transaction_prefix(data)?; + if completed != start && completed != deployment.operations.len() { + bail!("batch submission attempt omits an incomplete operation"); + } + let expected_hash = match &attempt.kind { + AttemptKind::Signed { payload, .. } => Some(payload.hash), + AttemptKind::Delegated { + status: DelegatedStatus::Pending { hash }, + .. + } + | AttemptKind::Legacy { hash } => Some(*hash), + AttemptKind::Delegated { .. } => None, + }; + if data.pending.len() > 1 + || data.pending.iter().any(|hash| Some(*hash) != expected_hash) + || data + .transactions + .iter() + .skip(start) + .filter_map(|transaction| transaction.hash) + .any(|hash| Some(hash) != expected_hash) + { + bail!("batch submission attempt conflicts with script progress"); + } + } + claimed[start..end].fill(true); + match &attempt.kind { + AttemptKind::Signed { request, payload } => { + if request.is_some() != self.batch { + bail!("recovery snapshot signed attempt has an invalid request"); + } + let validated = if let [operation] = operations + && !self.batch + { + validate_signed_payload::( + payload.payload.clone(), + &data.transactions[operation.id.index as usize], + deployment.chain, + operation.id.index as usize, + operation_ordinal( + self.data.sequences(), + sequence, + operation.id.index as usize, + ), + )? + } else { + SignedPayload { + hash: signed_payload_hash::(&payload.payload)?, + payload: payload.payload.clone(), + } + }; + if validated != *payload { + bail!("recovery snapshot signed payload does not match its hash"); + } + } + AttemptKind::Legacy { .. } if !self.batch => { + bail!("ordinary recovery snapshot contains a legacy batch attempt"); + } + AttemptKind::Delegated { .. } | AttemptKind::Legacy { .. } => {} + } + } + } + Ok(()) + } + + fn restore_sensitive(&mut self) { + for (deployment, data) in self.deployments.iter().zip(self.data.sequences_mut().iter_mut()) + { + for (operation, transaction) in + deployment.operations.iter().zip(data.transactions.iter_mut()) + { + transaction.rpc.clone_from(&operation.rpc); + } + } + } +} + +fn operation_fingerprint(transaction: &TransactionWithMetadata) -> Result +where + N::TxEnvelope: for<'de> Deserialize<'de> + Serialize, +{ + let mut transaction = transaction.clone(); + transaction.hash = None; + transaction.rpc.clear(); + let transaction = + serde_json::from_value::>(serde_json::to_value(transaction)?)?; + let mut value = serde_json::to_value(transaction)?; + value.sort_all_objects(); + Ok(keccak256(serde_json::to_vec(&value)?)) +} + +fn load_plan(path: &Path) -> Result> +where + N::TxEnvelope: for<'de> Deserialize<'de>, +{ + foundry_common::fs::read_json_file(path) + .wrap_err_with(|| format!("recovery snapshot `{}` is corrupt", path.display())) +} + +fn pending_path(path: &Path) -> PathBuf { + path.with_extension("pending") +} + +pub(crate) fn recovery_exists(paths: &(PathBuf, PathBuf)) -> Result { + let path = recovery_path_from_sensitive(&paths.1)?; + Ok(path.exists() || pending_path(&path).exists()) +} + +fn commit_pending_plan(pending: &Path, path: &Path) -> Result<()> { + #[cfg(windows)] + if path.exists() { + std::fs::remove_file(path)?; + } + std::fs::rename(pending, path)?; + #[cfg(unix)] + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; + #[cfg(unix)] + File::open(path.parent().context("recovery plan has no parent directory")?)?.sync_all()?; + Ok(()) +} + +fn recovery_path_from_sensitive(sensitive_path: &Path) -> Result { + let filename = sensitive_path.file_name().context("sensitive cache path has no filename")?; + Ok(sensitive_path.with_file_name(format!("{}.recovery.json", filename.to_string_lossy()))) +} + +fn signed_payload_hash(payload: &Bytes) -> Result +where + N::TxEnvelope: Decodable2718 + Encodable2718, +{ + Ok(N::TxEnvelope::decode_2718_exact(payload) + .wrap_err("recovery snapshot contains an invalid signed payload")? + .trie_hash()) +} + +fn validate_signed_payload( + payload: Bytes, + planned: &TransactionWithMetadata, + chain: u64, + index: usize, + ordinal: usize, +) -> Result +where + N::TxEnvelope: SignerRecoverable, + N::TransactionRequest: FoundryTransactionBuilder, +{ + let envelope = N::TxEnvelope::decode_2718_exact(&payload) + .wrap_err("recovery snapshot contains an invalid signed payload")?; + let signer = envelope + .recover_signer() + .wrap_err("recovery snapshot signed payload has an invalid signature")?; + let transaction = planned.tx(); + let chain_matches = match transaction { + TransactionMaybeSigned::Signed { tx, .. } => { + envelope.trie_hash() == tx.trie_hash() && tx.chain_id().is_none_or(|id| id == chain) + } + TransactionMaybeSigned::Unsigned(_) => envelope.chain_id() == Some(chain), + }; + // The planned nonce is the sender's sequential nonce. A Tempo expiring nonce transaction + // never reads nonce state and is signed with 0 or, from T12, with its complete-plan ordinal as + // the TIP-1106 discriminator. Accept sequence-local indices from older recovery snapshots + // too. + let nonce = envelope.nonce(); + let nonce_matches = transaction.nonce() == Some(nonce) + || ((nonce == 0 || nonce == ordinal as u64 || nonce == index as u64) + && >::from(envelope.clone()).nonce_key() + == Some(TEMPO_EXPIRING_NONCE_KEY)); + if transaction.from() != Some(signer) + || !chain_matches + || !nonce_matches + || transaction.to() != envelope.to() + || transaction.value().unwrap_or_default() != envelope.value() + || transaction.input().cloned().unwrap_or_default() != *envelope.input() + || transaction.authorization_list().as_deref().unwrap_or_default() + != envelope.authorization_list().unwrap_or_default() + { + bail!("signed payload does not match its planned transaction"); + } + Ok(SignedPayload { hash: envelope.trie_hash(), payload }) +} + +fn validate_delegated_transaction( + transaction: &N::TransactionResponse, + planned: &N::TransactionRequest, + chain: u64, + hash: B256, +) -> Result<()> +where + N::TransactionRequest: FoundryTransactionBuilder, +{ + // Preserve network-specific execution fields while allowing the signer to choose gas and fees. + let resolved = + >::from(transaction.clone()); + let sender = planned.from().context("delegated request has no sender")?; + let planned_tempo_aa = planned.is_tempo_aa(); + let resolved_tempo_aa = resolved.is_tempo_aa(); + let planned_fields = serde_json::to_value(planned)?; + let resolved_fields = serde_json::to_value(&resolved)?; + let same = |field: &str| planned_fields.get(field) == resolved_fields.get(field); + let same_list = |field: &str| { + let empty = serde_json::Value::Array(Vec::new()); + planned_fields.get(field).filter(|value| !value.is_null()).unwrap_or(&empty) + == resolved_fields.get(field).filter(|value| !value.is_null()).unwrap_or(&empty) + }; + if transaction.tx_hash() != hash + || transaction.from() != sender + || transaction.chain_id() != Some(chain) + || planned.chain_id() != Some(chain) + || transaction.nonce() != planned.nonce().context("delegated request has no nonce")? + || planned_tempo_aa != resolved_tempo_aa + // A missing `to` and `TxKind::Create` both describe a contract creation. + || (!planned_tempo_aa + && (resolved.kind().unwrap_or(TxKind::Create) + != planned.kind().unwrap_or(TxKind::Create) + || resolved.value().unwrap_or_default() != planned.value().unwrap_or_default() + || resolved.input().unwrap_or_default() != planned.input().unwrap_or_default())) + || transaction.authorization_list().unwrap_or_default() + != planned.authorization_list().map(Vec::as_slice).unwrap_or_default() + || planned.access_list().filter(|list| !list.is_empty()) + != resolved.access_list().filter(|list| !list.is_empty()) + || planned.blob_versioned_hashes().filter(|hashes| !hashes.is_empty()) + != resolved.blob_versioned_hashes().filter(|hashes| !hashes.is_empty()) + || (planned_tempo_aa + && canonical_tempo_calls::(planned, &planned_fields)? + != canonical_tempo_calls::(&resolved, &resolved_fields)?) + || !same_list("aaAuthorizationList") + || planned.nonce_key().unwrap_or_default() != resolved.nonce_key().unwrap_or_default() + || planned.fee_token() != resolved.fee_token() + || planned.valid_before() != resolved.valid_before() + || planned.valid_after() != resolved.valid_after() + || !same("keyAuthorization") + || delegated_fee_payer::(planned, sender)? + != delegated_fee_payer::(&resolved, sender)? + { + bail!("resolved transaction does not match its delegated submission attempt"); + } + Ok(()) +} + +fn canonical_tempo_calls( + request: &N::TransactionRequest, + fields: &serde_json::Value, +) -> Result> { + let mut calls = + fields.get("calls").and_then(serde_json::Value::as_array).cloned().unwrap_or_default(); + if let Some(to) = request.kind() { + calls.push(serde_json::to_value(tempo_primitives::transaction::Call { + to, + value: request.value().unwrap_or_default(), + input: request.input().cloned().unwrap_or_default(), + })?); + } + Ok(calls) +} + +fn delegated_fee_payer( + request: &N::TransactionRequest, + sender: Address, +) -> Result> +where + N::TransactionRequest: FoundryTransactionBuilder, +{ + let Some(signature) = request.fee_payer_signature() else { return Ok(None) }; + let hash = request + .compute_sponsor_hash(sender) + .context("failed to compute delegated Tempo sponsor hash")?; + Ok(Some( + signature + .recover_address_from_prehash(&hash) + .wrap_err("failed to recover delegated Tempo sponsor")?, + )) +} + +fn write_snapshot(path: &Path, plan: &RecoveryPlan) -> Result<()> +where + N::TxEnvelope: Serialize, +{ + let pending = pending_path(path); + write_plan(&pending, plan)?; + commit_pending_plan(&pending, path) +} + +fn write_plan(path: &Path, plan: &RecoveryPlan) -> Result<()> +where + N::TxEnvelope: Serialize, +{ + let parent = path.parent().context("recovery plan has no parent directory")?; + std::fs::create_dir_all(parent)?; + let mut tmp = NamedTempFile::new_in(parent)?; + #[cfg(unix)] + tmp.as_file().set_permissions(std::fs::Permissions::from_mode(0o600))?; + { + let mut writer = BufWriter::new(tmp.as_file_mut()); + serde_json::to_writer_pretty(&mut writer, plan)?; + writer.flush()?; + } + tmp.as_file().sync_all()?; + tmp.persist(path).map_err(|error| error.error)?; + #[cfg(unix)] + File::open(parent)?.sync_all()?; + Ok(()) +} + +/// Tempo replay protection excludes sponsor signatures from T1B onward. +fn expiring_replay_hash(payload: &[u8], sender_scoped_replay: bool) -> Result> { + if let Ok(TempoTxEnvelope::AA(tx)) = TempoTxEnvelope::decode_2718_exact(payload) + && tx.tx().is_expiring_nonce_tx() + { + return Ok(Some(if sender_scoped_replay { + tx.expiring_nonce_hash(tx.recover_signer()?) + } else { + *tx.hash() + })); + } + Ok(None) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::multi_sequence::MultiChainSequence; + use alloy_consensus::{ + Eip658Value, Receipt, ReceiptEnvelope, ReceiptWithBloom, SignableTransaction, TxEip1559, + TxEnvelope, transaction::Recovered, + }; + use alloy_network::Ethereum; + use alloy_primitives::{Bloom, Signature, U256, hex}; + use alloy_rpc_types::{Transaction as RpcTransaction, TransactionReceipt, TransactionRequest}; + use alloy_signer::SignerSync; + use forge_script_sequence::ScriptSequence; + use tempo_alloy::{TempoNetwork, rpc::TempoTransactionRequest}; + use tempo_primitives::{ + AASigned, TempoSignature, + transaction::{Call, PrimitiveSignature}, + }; + + const SIGNED_TX: &[u8] = &hex!( + "02f86b0180843b9aca008502540be4008252089400000000000000000000000000000000000000016480c001a070d55e79ed3ac9fc8f51e78eb91fd054720d943d66633f2eb1bc960f0126b0eca052eda05a792680de3181e49bab4093541f75b49d1ecbe443077b3660c836016a" + ); + const OTHER_SIGNED_TX: &[u8] = &hex!( + "02f86b0180843b9aca008502540be4008252089400000000000000000000000000000000000000018080c001a0cce9a61187b5d18a89ecd27ec675e3b3f10d37f165627ef89a15a7fe76395ce8a07537f5bffb358ffbef22cda84b1c92f7211723f9e09ae037e81686805d3e5505" + ); + + const ROOT_PRIVATE_KEY: &str = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + + fn sequence(dir: &Path) -> SequenceData { + let mut transaction = TransactionWithMetadata::from_tx_request( + TransactionMaybeSigned::Unsigned(Default::default()), + ); + transaction.rpc = "http://localhost:8545".to_string(); + let mut sequence = forge_script_sequence::ScriptSequence::default(); + sequence.transactions.push_back(transaction); + sequence.paths = Some((dir.join("broadcast.json"), dir.join("cache.json"))); + SequenceData::Single(sequence) + } + + fn signed_sequence(dir: &Path) -> SequenceData { + let transactions = [SIGNED_TX, OTHER_SIGNED_TX].map(|payload| { + let envelope = TxEnvelope::decode_2718_exact(payload).unwrap(); + let from = envelope.recover_signer().unwrap(); + let mut request: TransactionRequest = envelope.into(); + request.from = Some(from); + TransactionWithMetadata::from_tx_request(TransactionMaybeSigned::new(request)) + }); + let mut sequence = forge_script_sequence::ScriptSequence { + chain: 1, + transactions: transactions.into(), + ..Default::default() + }; + sequence.paths = Some((dir.join("broadcast.json"), dir.join("cache.json"))); + SequenceData::Single(sequence) + } + + fn delegated_transaction(payload: &[u8]) -> (TransactionRequest, RpcTransaction) { + let envelope = TxEnvelope::decode_2718_exact(payload).unwrap(); + let from = envelope.recover_signer().unwrap(); + let mut request: TransactionRequest = envelope.clone().into(); + request.from = Some(from); + let transaction = RpcTransaction { + inner: Recovered::new_unchecked(envelope, from), + block_hash: None, + block_number: None, + transaction_index: None, + effective_gas_price: None, + block_timestamp: None, + }; + (request, transaction) + } + + fn tempo_transaction( + request: TempoTransactionRequest, + from: Address, + ) -> RpcTransaction { + let envelope = + TempoTxEnvelope::AA(request.build_aa().unwrap().into_signed(TempoSignature::default())); + RpcTransaction { + inner: Recovered::new_unchecked(envelope, from), + block_hash: None, + block_number: None, + transaction_index: None, + effective_gas_price: None, + block_timestamp: None, + } + } + + #[test] + fn expiring_signed_payload_accepts_complete_plan_ordinal_and_legacy_index() { + let signer = foundry_wallets::utils::create_local_signer(ROOT_PRIVATE_KEY).unwrap(); + let request = expiring_request(signer.address(), 4217, 8); + let planned = + TransactionWithMetadata::from_tx_request(TransactionMaybeSigned::new(request.clone())); + for nonce in [0, 1, 3, 4] { + let mut request = request.clone(); + request.inner.nonce = Some(nonce); + let payload = sign_tempo_payload(request, &signer); + assert_eq!( + validate_signed_payload::(payload, &planned, 4217, 1, 3).is_ok(), + nonce != 4 + ); + } + } + + #[test] + fn identical_expiring_payload_is_rejected_across_same_chain_sequences() { + let dir = tempfile::tempdir().unwrap(); + let signer = foundry_wallets::utils::create_local_signer(ROOT_PRIVATE_KEY).unwrap(); + // A -> B -> A: the planned nonce on A advances, the signed expiring nonce stays 0. + let deployments = [(4217, 0), (4218, 0), (4217, 1)] + .map(|(chain, nonce)| ScriptSequence:: { + chain, + transactions: [TransactionWithMetadata::from_tx_request( + TransactionMaybeSigned::new(expiring_request(signer.address(), chain, nonce)), + )] + .into(), + ..Default::default() + }) + .into(); + let data = SequenceData::Multi(MultiChainSequence { + deployments, + path: dir.path().join("broadcast.json"), + sensitive_path: dir.path().join("cache.json"), + timestamp: 0, + }); + let payload = sign_tempo_payload(expiring_request(signer.address(), 4217, 0), &signer); + + let mut store = RecoveryStore::create(data, false).unwrap(); + let hash = store.persist_signed_payload(0, 0, payload.clone(), true).unwrap(); + assert_eq!( + store.persist_signed_payload(2, 0, payload, true).unwrap_err().to_string(), + format!( + "transaction 0 is identical to an earlier transaction of this script ({hash}) and would be rejected as a replay; identical Tempo expiring nonce transactions are only distinct from the T12 hardfork (TIP-1106)" + ) + ); + } + + fn sign_tempo_sponsor( + request: &mut TempoTransactionRequest, + from: Address, + sponsor: &impl SignerSync, + ) { + request.fee_payer_signature = None; + let hash = request.compute_sponsor_hash(from).unwrap(); + request.fee_payer_signature = Some(sponsor.sign_hash_sync(&hash).unwrap()); + } + + fn load(paths: &(PathBuf, PathBuf), batch: bool) -> Result> { + let lock = RecoveryLock::acquire(paths)?; + RecoveryStore::load(paths, batch, lock)?.context("missing recovery snapshot") + } + + #[test] + fn snapshot_is_versioned_private_and_locks_writers() { + let dir = tempfile::tempdir().unwrap(); + let data = sequence(dir.path()); + let paths = data.paths(); + let store = RecoveryStore::create(data, false).unwrap(); + let value: serde_json::Value = foundry_common::fs::read_json_file(&store.path).unwrap(); + assert_eq!(value["version"], RECOVERY_VERSION); + assert_eq!(value["deployments"][0]["operations"][0]["id"]["sequence"], 0); + assert_eq!(value["deployments"][0]["operations"][0]["id"]["index"], 0); + assert!(value["deployments"][0]["operations"][0]["fingerprint"].is_string()); + assert!(value["deployments"][0]["operations"][0].get("transaction").is_none()); + assert!(RecoveryLock::acquire(&paths).is_err()); + #[cfg(unix)] + assert_eq!(store.path.metadata().unwrap().permissions().mode() & 0o777, 0o600); + } + + #[test] + fn snapshot_recovers_without_compatibility_exports() { + let dir = tempfile::tempdir().unwrap(); + let data = sequence(dir.path()); + let paths = data.paths(); + let hash = B256::repeat_byte(0x11); + { + let mut store = RecoveryStore::create(data, false).unwrap(); + let deployment = &mut store.data_mut().sequences_mut()[0]; + deployment.transactions[0].hash = Some(hash); + deployment.pending.push(hash); + store.save().unwrap(); + } + for path in [&paths.0, &paths.1] { + assert!(!path.exists()); + } + + let store = load(&paths, false).unwrap(); + let deployment = &store.data().sequences()[0]; + assert_eq!(deployment.transactions[0].hash, Some(hash)); + assert_eq!(deployment.pending, [hash]); + store.data().publish(&paths).unwrap(); + assert!(paths.0.exists()); + assert!(paths.1.exists()); + } + + #[test] + fn snapshot_ignores_stale_compatibility_exports() { + let dir = tempfile::tempdir().unwrap(); + let data = sequence(dir.path()); + let paths = data.paths(); + let hash = B256::repeat_byte(0x22); + { + let mut store = RecoveryStore::create(data.clone(), false).unwrap(); + store.data_mut().sequences_mut()[0].transactions[0].hash = Some(hash); + store.save().unwrap(); + } + data.publish(&paths).unwrap(); + + let store = load(&paths, false).unwrap(); + assert_eq!(store.data().sequences()[0].transactions[0].hash, Some(hash)); + } + + #[test] + fn corrupt_snapshot_fails_closed_even_with_valid_exports() { + let dir = tempfile::tempdir().unwrap(); + let data = sequence(dir.path()); + let paths = data.paths(); + data.publish(&paths).unwrap(); + let snapshot = { + let store = RecoveryStore::create(data, false).unwrap(); + store.path + }; + std::fs::write(snapshot, b"{").unwrap(); + + assert!(load(&paths, false).err().unwrap().to_string().contains("is corrupt")); + } + + #[test] + fn interrupted_snapshot_replacement_is_recovered() { + let dir = tempfile::tempdir().unwrap(); + let data = sequence(dir.path()); + let paths = data.paths(); + let hash = B256::repeat_byte(0x33); + let snapshot = { + let mut store = RecoveryStore::create(data, false).unwrap(); + store.data_mut().sequences_mut()[0].pending.push(hash); + write_plan(&pending_path(&store.path), &store.plan).unwrap(); + store.path.clone() + }; + + let store = load(&paths, false).unwrap(); + assert_eq!(store.data().sequences()[0].pending, [hash]); + assert!(snapshot.exists()); + assert!(!pending_path(&snapshot).exists()); + } + + #[test] + fn changed_operations_fail_closed() { + let dir = tempfile::tempdir().unwrap(); + let mut store = RecoveryStore::create(sequence(dir.path()), false).unwrap(); + store.data_mut().sequences_mut()[0].transactions.clear(); + assert!(store.save().unwrap_err().to_string().contains("does not match")); + } + + #[test] + fn relocation_preserves_the_authoritative_snapshot() { + let dir = tempfile::tempdir().unwrap(); + let data = sequence(dir.path()); + let source_paths = data.paths(); + let mut store = RecoveryStore::create(data, false).unwrap(); + let destination = dir.path().join("broadcast-cache.json"); + let destination_snapshot = recovery_path_from_sensitive(&destination).unwrap(); + let paths = (dir.path().join("broadcasted.json"), destination); + let relocation = store.prepare_relocation(&paths).unwrap(); + assert!(RecoveryLock::acquire(&source_paths).is_err()); + store.commit_relocation(relocation).unwrap(); + assert_eq!(store.path, destination_snapshot); + assert!(destination_snapshot.exists()); + RecoveryLock::acquire(&source_paths).unwrap(); + } + + #[test] + fn signed_payload_is_immutable_and_survives_reload() { + let dir = tempfile::tempdir().unwrap(); + let data = signed_sequence(dir.path()); + let paths = data.paths(); + let expected_hash = { + let mut store = RecoveryStore::create(data, false).unwrap(); + let hash = store.persist_signed_payload(0, 0, SIGNED_TX.into(), true).unwrap(); + assert!(store.persist_signed_payload(0, 0, OTHER_SIGNED_TX.into(), true).is_err()); + hash + }; + + let store = load(&paths, false).unwrap(); + let signed = store.signed_payload(0, 0).unwrap(); + assert_eq!(signed.payload.as_ref(), SIGNED_TX); + assert_eq!(signed.hash, expected_hash); + } + + #[test] + fn signed_payload_is_bound_to_its_planned_operation() { + let dir = tempfile::tempdir().unwrap(); + let mut store = RecoveryStore::create(signed_sequence(dir.path()), false).unwrap(); + + assert!(store.persist_signed_payload(1, 0, SIGNED_TX.into(), true).is_err()); + assert!(store.persist_signed_payload(0, 1, SIGNED_TX.into(), true).is_err()); + } + + #[test] + fn delegated_submission_is_immutable_and_survives_reload() { + let dir = tempfile::tempdir().unwrap(); + let data = sequence(dir.path()); + let paths = data.paths(); + let request = TransactionRequest::default(); + let hash = B256::repeat_byte(0x42); + { + let mut store = RecoveryStore::create(data, false).unwrap(); + store.persist_delegated_request(0, 0, request).unwrap(); + store.persist_delegated_status(0, 0, DelegatedStatus::Pending { hash }).unwrap(); + assert!(store.persist_delegated_request(0, 0, Default::default()).is_err()); + } + + let store = load(&paths, false).unwrap(); + assert!( + matches!(store.delegated_status(0, 0), Some(DelegatedStatus::Pending { hash: value }) if value == hash) + ); + } + + #[test] + fn delegated_resolution_is_bound_to_its_planned_transaction() { + let (mut request, transaction) = delegated_transaction(SIGNED_TX); + let hash = transaction.tx_hash(); + validate_delegated_transaction::(&transaction, &request, 1, hash).unwrap(); + + request.gas = Some(100_000); + request.max_fee_per_gas = Some(10_000); + validate_delegated_transaction::(&transaction, &request, 1, hash).unwrap(); + + request.blob_versioned_hashes = Some(vec![B256::repeat_byte(0x42)]); + assert!( + validate_delegated_transaction::(&transaction, &request, 1, hash).is_err() + ); + + let (other, _) = delegated_transaction(OTHER_SIGNED_TX); + assert!(validate_delegated_transaction::(&transaction, &other, 1, hash).is_err()); + } + + #[test] + fn delegated_resolution_matches_creations_by_kind() { + let from = Address::repeat_byte(0x11); + let transaction = |to: TxKind| { + let envelope = TxEnvelope::Eip1559( + TxEip1559 { + chain_id: 1, + gas_limit: 100_000, + max_fee_per_gas: 1, + max_priority_fee_per_gas: 1, + to, + input: Bytes::from_static(&[0x60, 0x00]), + ..Default::default() + } + .into_signed(Signature::test_signature()), + ); + RpcTransaction { + inner: Recovered::new_unchecked(envelope, from), + block_hash: None, + block_number: None, + transaction_index: None, + effective_gas_price: None, + block_timestamp: None, + } + }; + // Serialize the planned request the same way the recovery snapshot stores it. + let planned = |to: TxKind| { + let mut request: TransactionRequest = transaction(to).inner.into_inner().into(); + request.from = Some(from); + serde_json::from_value::(serde_json::to_value(request).unwrap()) + .unwrap() + }; + let validate = |transaction: &RpcTransaction, planned: &TransactionRequest| { + validate_delegated_transaction::( + transaction, + planned, + 1, + transaction.tx_hash(), + ) + }; + let create = transaction(TxKind::Create); + let call = transaction(TxKind::Call(Address::repeat_byte(0x22))); + assert_eq!(planned(TxKind::Create).to, None); + + validate(&create, &planned(TxKind::Create)).unwrap(); + validate(&call, &planned(TxKind::Call(Address::repeat_byte(0x22)))).unwrap(); + assert!(validate(&create, &planned(TxKind::Call(Address::repeat_byte(0x22)))).is_err()); + assert!(validate(&call, &planned(TxKind::Create)).is_err()); + assert!(validate(&call, &planned(TxKind::Call(Address::repeat_byte(0x33)))).is_err()); + } + + #[test] + fn delegated_tempo_resolution_matches_creations_by_kind() { + let from = Address::repeat_byte(0x11); + let transaction = |to: TxKind| { + let envelope = TempoTxEnvelope::Eip1559( + TxEip1559 { + chain_id: 4217, + gas_limit: 100_000, + max_fee_per_gas: 1, + max_priority_fee_per_gas: 1, + to, + input: Bytes::from_static(&[0x60, 0x00]), + ..Default::default() + } + .into_signed(Signature::test_signature()), + ); + RpcTransaction { + inner: Recovered::new_unchecked(envelope, from), + block_hash: None, + block_number: None, + transaction_index: None, + effective_gas_price: None, + block_timestamp: None, + } + }; + // Serialize the planned request the same way the recovery snapshot stores it. + let planned = |to: TxKind| { + let mut request = >::from(transaction(to)); + request.inner.from = Some(from); + serde_json::from_value::( + serde_json::to_value(request).unwrap(), + ) + .unwrap() + }; + let validate = |transaction: &RpcTransaction, + planned: &TempoTransactionRequest| { + validate_delegated_transaction::( + transaction, + planned, + 4217, + transaction.tx_hash(), + ) + }; + let create = transaction(TxKind::Create); + let call = transaction(TxKind::Call(Address::repeat_byte(0x22))); + assert_eq!(planned(TxKind::Create).inner.to, None); + assert!(!planned(TxKind::Create).is_tempo_aa()); + + validate(&create, &planned(TxKind::Create)).unwrap(); + validate(&call, &planned(TxKind::Call(Address::repeat_byte(0x22)))).unwrap(); + assert!(validate(&create, &planned(TxKind::Call(Address::repeat_byte(0x22)))).is_err()); + assert!(validate(&call, &planned(TxKind::Create)).is_err()); + } + + #[test] + fn delegated_tempo_resolution_checks_nonce_domain_and_calls() { + let from = Address::repeat_byte(0x11); + let resolved = TempoTransactionRequest { + inner: TransactionRequest { + from: Some(from), + gas: Some(0), + max_fee_per_gas: Some(0), + max_priority_fee_per_gas: Some(0), + nonce: Some(0), + chain_id: Some(4217), + ..Default::default() + }, + nonce_key: Some(U256::from(7)), + calls: vec![ + Call { + to: TxKind::Call(Address::repeat_byte(0x22)), + value: U256::ONE, + input: Bytes::from_static(&[0x12]), + }, + Call { + to: TxKind::Call(Address::repeat_byte(0x33)), + value: U256::from(2), + input: Bytes::from_static(&[0x34]), + }, + ], + ..Default::default() + }; + let transaction = tempo_transaction(resolved, from); + let mut request = >::from(transaction.clone()); + request.from = Some(from); + let hash = transaction.tx_hash(); + validate_delegated_transaction::(&transaction, &request, 4217, hash).unwrap(); + assert!( + validate_delegated_transaction::( + &transaction, + &request, + 4217, + B256::ZERO, + ) + .is_err() + ); + + request.valid_before = std::num::NonZeroU64::new(1); + assert!( + validate_delegated_transaction::(&transaction, &request, 4217, hash) + .is_err() + ); + request.valid_before = None; + request.fee_token = Some(Address::repeat_byte(0x44)); + assert!( + validate_delegated_transaction::(&transaction, &request, 4217, hash) + .is_err() + ); + request.fee_token = None; + + let top_level = request.calls.pop().unwrap(); + request.inner.to = Some(top_level.to); + request.inner.value = Some(top_level.value); + request.inner.input = top_level.input.into(); + validate_delegated_transaction::(&transaction, &request, 4217, hash).unwrap(); + + request.nonce_key = Some(U256::from(8)); + assert!( + validate_delegated_transaction::(&transaction, &request, 4217, hash) + .is_err() + ); + request.nonce_key = Some(U256::from(7)); + request.calls.push(Call { + to: request.inner.to.take().unwrap(), + value: request.inner.value.take().unwrap(), + input: request.inner.input.input.take().unwrap(), + }); + request.calls.swap(0, 1); + assert!( + validate_delegated_transaction::(&transaction, &request, 4217, hash) + .is_err() + ); + } + + #[test] + fn delegated_tempo_resolution_checks_sponsor_identity() { + let from = Address::repeat_byte(0x11); + let sponsor = foundry_wallets::utils::create_local_signer( + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + ) + .unwrap(); + let other_sponsor = foundry_wallets::utils::create_local_signer( + "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", + ) + .unwrap(); + assert_ne!(sponsor.address(), other_sponsor.address()); + + let mut planned = TempoTransactionRequest { + inner: TransactionRequest { + from: Some(from), + to: Some(TxKind::Call(Address::repeat_byte(0x22))), + gas: Some(100_000), + max_fee_per_gas: Some(10), + max_priority_fee_per_gas: Some(1), + nonce: Some(0), + chain_id: Some(4217), + ..Default::default() + }, + nonce_key: Some(U256::from(7)), + ..Default::default() + }; + sign_tempo_sponsor(&mut planned, from, &sponsor); + + let mut resolved = planned.clone(); + resolved.inner.gas = Some(120_000); + resolved.inner.max_fee_per_gas = Some(12); + sign_tempo_sponsor(&mut resolved, from, &sponsor); + let transaction = tempo_transaction(resolved.clone(), from); + validate_delegated_transaction::( + &transaction, + &planned, + 4217, + transaction.tx_hash(), + ) + .unwrap(); + + resolved.fee_payer_signature = None; + let transaction = tempo_transaction(resolved.clone(), from); + assert!( + validate_delegated_transaction::( + &transaction, + &planned, + 4217, + transaction.tx_hash(), + ) + .is_err() + ); + + sign_tempo_sponsor(&mut resolved, from, &other_sponsor); + let transaction = tempo_transaction(resolved, from); + assert!( + validate_delegated_transaction::( + &transaction, + &planned, + 4217, + transaction.tx_hash(), + ) + .is_err() + ); + } + + #[test] + fn definite_non_submission_clears_delegated_request() { + let dir = tempfile::tempdir().unwrap(); + let data = sequence(dir.path()); + let paths = data.paths(); + let mut store = RecoveryStore::create(data, false).unwrap(); + store.persist_delegated_request(0, 0, Default::default()).unwrap(); + store.clear_delegated_request(0, 0).unwrap(); + assert!(store.delegated_status(0, 0).is_none()); + drop(store); + + assert!(load(&paths, false).unwrap().delegated_status(0, 0).is_none()); + } + + #[test] + fn batch_attempt_uses_shared_membership_and_survives_reload() { + let dir = tempfile::tempdir().unwrap(); + let data = signed_sequence(dir.path()); + let paths = data.paths(); + let request = TransactionRequest::default(); + { + let mut store = RecoveryStore::create(data, true).unwrap(); + store.persist_batch_signed_payload(0, 0, request.clone(), SIGNED_TX.into()).unwrap(); + assert!(store.persist_signed_payload(0, 1, OTHER_SIGNED_TX.into(), true).is_err()); + } + + let store = load(&paths, true).unwrap(); + let Some((0, _, AttemptKind::Signed { request: Some(saved), .. })) = store.batch_attempt(0) + else { + panic!("expected signed batch attempt"); + }; + assert_eq!(saved, &request); + } + + #[test] + fn batch_attempt_rejects_conflicting_progress() { + let foreign = B256::repeat_byte(0x66); + for (transaction_hash, pending) in [(Some(foreign), vec![]), (None, vec![foreign])] { + let dir = tempfile::tempdir().unwrap(); + let data = signed_sequence(dir.path()); + let paths = data.paths(); + { + let mut store = RecoveryStore::create(data, true).unwrap(); + store + .persist_batch_signed_payload( + 0, + 0, + TransactionRequest::default(), + SIGNED_TX.into(), + ) + .unwrap(); + let deployment = &mut store.data_mut().sequences_mut()[0]; + deployment.transactions[0].hash = transaction_hash; + deployment.pending = pending; + write_snapshot(&store.path, &store.plan).unwrap(); + } + + assert!(load(&paths, true).is_err()); + } + } + + #[test] + fn batch_progress_without_an_attempt_fails_closed() { + let dir = tempfile::tempdir().unwrap(); + let data = signed_sequence(dir.path()); + let paths = data.paths(); + { + let mut store = RecoveryStore::create(data, true).unwrap(); + store.data_mut().sequences_mut()[0].pending.push(B256::repeat_byte(0x77)); + write_snapshot(&store.path, &store.plan).unwrap(); + } + + assert!(load(&paths, true).is_err()); + } + + #[test] + fn legacy_batch_import_requires_one_consistent_pending_hash() { + let dir = tempfile::tempdir().unwrap(); + let mut data = signed_sequence(dir.path()); + let paths = data.paths(); + let hash = B256::repeat_byte(0x55); + let deployment = &mut data.sequences_mut()[0]; + for transaction in &mut deployment.transactions { + transaction.hash = Some(hash); + } + deployment.pending.push(hash); + let store = + RecoveryStore::import(data, true, RecoveryLock::acquire(&paths).unwrap()).unwrap(); + assert!( + matches!(store.batch_attempt(0), Some((0, _, AttemptKind::Legacy { hash: saved })) if *saved == hash) + ); + drop(store); + + let other = tempfile::tempdir().unwrap(); + let mut data = signed_sequence(other.path()); + let paths = data.paths(); + let deployment = &mut data.sequences_mut()[0]; + deployment.transactions[1].hash = Some(hash); + deployment.pending.push(hash); + assert!(RecoveryStore::import(data, true, RecoveryLock::acquire(&paths).unwrap()).is_err()); + } + + #[test] + fn legacy_batch_import_accepts_a_receipted_prefix() { + let dir = tempfile::tempdir().unwrap(); + let mut data = signed_sequence(dir.path()); + let paths = data.paths(); + let completed = B256::repeat_byte(0x44); + let pending = B256::repeat_byte(0x55); + let deployment = &mut data.sequences_mut()[0]; + deployment.transactions[0].hash = Some(completed); + deployment.transactions[1].hash = Some(pending); + let completed_receipt = TransactionReceipt { + inner: ReceiptEnvelope::Legacy(ReceiptWithBloom { + receipt: Receipt { + status: Eip658Value::success(), + cumulative_gas_used: 0, + logs: Vec::new(), + }, + logs_bloom: Bloom::ZERO, + }), + transaction_hash: completed, + transaction_index: None, + block_hash: None, + block_number: None, + gas_used: 0, + effective_gas_price: 0, + blob_gas_used: None, + blob_gas_price: None, + from: Address::ZERO, + to: None, + contract_address: None, + }; + deployment.receipts.push(completed_receipt); + deployment.pending.push(pending); + + let store = + RecoveryStore::import(data, true, RecoveryLock::acquire(&paths).unwrap()).unwrap(); + assert!( + matches!(store.batch_attempt(0), Some((1, _, AttemptKind::Legacy { hash })) if *hash == pending) + ); + } + + #[test] + fn rejected_legacy_batch_import_does_not_publish_a_snapshot() { + let dir = tempfile::tempdir().unwrap(); + let mut data = signed_sequence(dir.path()); + let paths = data.paths(); + data.sequences_mut()[0].transactions[0].hash = Some(B256::repeat_byte(0x55)); + let snapshot = recovery_path_from_sensitive(&paths.1).unwrap(); + + assert!(RecoveryStore::import(data, true, RecoveryLock::acquire(&paths).unwrap()).is_err()); + assert!(!snapshot.exists()); + } + + #[test] + fn expiring_replay_guard_ignores_sponsor_changes_and_keeps_discriminators() { + let dir = tempfile::tempdir().unwrap(); + let sender = foundry_wallets::utils::create_local_signer(ROOT_PRIVATE_KEY).unwrap(); + let sponsor = foundry_wallets::utils::create_local_signer( + "0x59c6995e998f97a5a004497e5da3b5d2b2b66a87f064d39c44da0b6d6e4f8ff0", + ) + .unwrap(); + let request = |nonce| expiring_request(sender.address(), 4217, nonce); + let data = SequenceData::Multi(MultiChainSequence { + deployments: [0, 1] + .map(|nonce| ScriptSequence:: { + chain: 4217, + transactions: [TransactionWithMetadata::from_tx_request( + TransactionMaybeSigned::new(request(nonce)), + )] + .into(), + ..Default::default() + }) + .into(), + path: dir.path().join("broadcast.json"), + sensitive_path: dir.path().join("cache.json"), + timestamp: 0, + }); + let payload = |nonce, sponsor: &_| { + let mut request = request(nonce); + sign_tempo_sponsor(&mut request, sender.address(), sponsor); + sign_tempo_payload(request, &sender) + }; + let first = payload(0, &sender); + let duplicate = payload(0, &sponsor); + assert_ne!( + signed_payload_hash::(&first).unwrap(), + signed_payload_hash::(&duplicate).unwrap() + ); + assert_eq!( + expiring_replay_hash(&first, true).unwrap(), + expiring_replay_hash(&duplicate, true).unwrap() + ); + let mut store = RecoveryStore::create(data, false).unwrap(); + let first_hash = store.persist_signed_payload(0, 0, first, true).unwrap(); + assert_eq!(store.signed_payload(0, 0).unwrap().hash, first_hash); + let snapshot = std::fs::read(&store.path).unwrap(); + let duplicate_hash = signed_payload_hash::(&duplicate).unwrap(); + assert_eq!( + store.persist_signed_payload(1, 0, duplicate.clone(), true).unwrap_err().to_string(), + format!( + "transaction 0 is identical to an earlier transaction of this script ({duplicate_hash}) and would be rejected as a replay; identical Tempo expiring nonce transactions are only distinct from the T12 hardfork (TIP-1106)" + ) + ); + assert!(store.signed_payload(1, 0).is_none()); + assert_eq!(std::fs::read(&store.path).unwrap(), snapshot); + // Older snapshots may already contain a conflicting durable attempt. + let id = store.plan.deployments[1].operations[0].id; + store.plan.deployments[1].attempts.push(SubmissionAttempt { + id: B256::random(), + members: vec![id], + kind: AttemptKind::Signed { + request: None, + payload: SignedPayload { payload: duplicate.clone(), hash: duplicate_hash }, + }, + }); + assert!(store.persist_signed_payload(1, 0, duplicate.clone(), true).is_err()); + store.plan.deployments[1].attempts.clear(); + // T1/T1A use envelope hashes, so the differently sponsored payload is executable. + assert_eq!(store.persist_signed_payload(1, 0, duplicate, false).unwrap(), duplicate_hash); + store.plan.deployments[1].attempts.clear(); + // T12 discriminators distinguish the second operation despite the sponsor change. + let distinct = payload(1, &sponsor); + let distinct_hash = signed_payload_hash::(&distinct).unwrap(); + assert_eq!(store.persist_signed_payload(1, 0, distinct, true).unwrap(), distinct_hash); + assert_eq!(store.signed_payload(1, 0).unwrap().hash, distinct_hash); + } + + fn expiring_request(from: Address, chain_id: u64, nonce: u64) -> TempoTransactionRequest { + TempoTransactionRequest { + inner: TransactionRequest { + from: Some(from), + to: Some(TxKind::Call(Address::with_last_byte(2))), + chain_id: Some(chain_id), + nonce: Some(nonce), + gas: Some(100_000), + max_fee_per_gas: Some(10), + max_priority_fee_per_gas: Some(1), + ..Default::default() + }, + nonce_key: Some(TEMPO_EXPIRING_NONCE_KEY), + valid_before: std::num::NonZeroU64::new(100), + ..Default::default() + } + } + + fn sign_tempo_payload(request: TempoTransactionRequest, signer: &impl SignerSync) -> Bytes { + let tx = request.build_aa().unwrap(); + let signature = signer.sign_hash_sync(&tx.signature_hash()).unwrap(); + Bytes::from( + TempoTxEnvelope::AA(AASigned::new_unhashed( + tx, + TempoSignature::Primitive(PrimitiveSignature::Secp256k1(signature)), + )) + .encoded_2718(), + ) + } +} diff --git a/crates/script/src/runner.rs b/crates/script/src/runner.rs index 2af31ef752e55..51569c6457bdc 100644 --- a/crates/script/src/runner.rs +++ b/crates/script/src/runner.rs @@ -1,13 +1,22 @@ use super::{ScriptConfig, ScriptResult}; use crate::build::ScriptPredeployLibraries; use alloy_eips::eip7702::SignedAuthorization; -use alloy_primitives::{Address, Bytes, TxKind, U256}; -use alloy_rpc_types::TransactionRequest; +use alloy_evm::{ + FromRecoveredTx, + revm::context::{Block, Transaction}, +}; +use alloy_network::TransactionBuilder; +use alloy_primitives::{Address, Bytes, TxKind, U256, map::AddressHashMap}; use eyre::Result; use foundry_cheatcodes::BroadcastableTransaction; +use foundry_common::{LIBRARY_DEPLOYER, TransactionMaybeSigned}; use foundry_config::Config; use foundry_evm::{ constants::CALLER, + core::{ + FoundryBlock, FoundryTransaction, + evm::{EvmEnvFor, FoundryEvmNetwork, TransactionRequestFor, TxEnvFor}, + }, executors::{DeployResult, EvmError, ExecutionErr, Executor, RawCallResult}, opts::EvmOpts, revm::interpreter::{InstructionResult, return_ok}, @@ -17,14 +26,65 @@ use std::collections::VecDeque; /// Drives script execution #[derive(Debug)] -pub struct ScriptRunner { - pub executor: Executor, +pub struct ScriptRunner { + pub executor: Executor, pub evm_opts: EvmOpts, + collect_debug_bytecodes: bool, } -impl ScriptRunner { - pub fn new(executor: Executor, evm_opts: EvmOpts) -> Self { - Self { executor, evm_opts } +impl ScriptRunner { + pub const fn new(executor: Executor, evm_opts: EvmOpts) -> Self { + Self { executor, evm_opts, collect_debug_bytecodes: false } + } + + pub const fn with_debug_bytecodes(mut self, collect_debug_bytecodes: bool) -> Self { + self.collect_debug_bytecodes = collect_debug_bytecodes; + self + } + + fn maybe_debug_bytecodes( + &self, + debug_bytecodes: AddressHashMap, + ) -> AddressHashMap { + if self.collect_debug_bytecodes { debug_bytecodes } else { Default::default() } + } + + fn extend_debug_bytecodes( + &self, + target: &mut AddressHashMap, + debug_bytecodes: AddressHashMap, + ) { + if self.collect_debug_bytecodes { + target.extend(debug_bytecodes); + } + } + + fn deploy_local_libraries( + &mut self, + libraries: &[foundry_linking::LinkedLibrary], + debug_bytecodes: &mut AddressHashMap, + ) -> Result<()> { + if libraries.is_empty() { + return Ok(()); + } + let balance = self.executor.get_balance(LIBRARY_DEPLOYER)?; + let nonce = self.executor.get_nonce(LIBRARY_DEPLOYER)?; + self.executor.set_balance(LIBRARY_DEPLOYER, U256::MAX)?; + self.executor.set_nonce(LIBRARY_DEPLOYER, 0)?; + for library in libraries { + let DeployResult { address, raw } = self + .executor + .deploy(LIBRARY_DEPLOYER, library.bytecode.clone(), U256::ZERO, None) + .map_err(|err| eyre::eyre!("couldn't deploy local library: {err}"))?; + eyre::ensure!( + library.address == address, + "local library deployed at an unexpected address" + ); + self.extend_debug_bytecodes(debug_bytecodes, raw.debug_bytecodes); + } + self.executor.set_balance(LIBRARY_DEPLOYER, balance)?; + self.executor.set_nonce(LIBRARY_DEPLOYER, nonce)?; + Ok(()) } /// Deploys the libraries and broadcast contract. Calls setUp method if requested. @@ -33,9 +93,9 @@ impl ScriptRunner { libraries: &ScriptPredeployLibraries, code: Bytes, setup: bool, - script_config: &ScriptConfig, + script_config: &ScriptConfig, is_broadcast: bool, - ) -> Result<(Address, ScriptResult)> { + ) -> Result<(Address, ScriptResult)> { trace!(target: "script", "executing setUP()"); if !is_broadcast { @@ -44,7 +104,9 @@ impl ScriptRunner { self.executor.set_balance(self.evm_opts.sender, U256::MAX)?; } - if script_config.evm_opts.fork_url.is_none() { + if script_config.evm_opts.fork_url.is_none() + && !script_config.evm_opts.networks.is_tempo() + { self.executor.deploy_create2_deployer()?; } } @@ -57,64 +119,87 @@ impl ScriptRunner { let mut library_transactions = VecDeque::new(); let mut traces = Traces::default(); + let mut debug_bytecodes: AddressHashMap = Default::default(); // Deploy libraries match libraries { - ScriptPredeployLibraries::Default(libraries) => libraries.iter().for_each(|code| { - let result = self - .executor - .deploy(self.evm_opts.sender, code.clone(), U256::ZERO, None) - .expect("couldn't deploy library") - .raw; - - if let Some(deploy_traces) = result.traces { - traces.push((TraceKind::Deployment, deploy_traces)); - } + ScriptPredeployLibraries::Default { onchain, local } => { + self.deploy_local_libraries(local, &mut debug_bytecodes)?; + for library in onchain { + let code = &library.bytecode; + let RawCallResult { + traces: deploy_traces, + debug_bytecodes: deploy_debug_bytecodes, + .. + } = self + .executor + .deploy(self.evm_opts.sender, code.clone(), U256::ZERO, None) + .map_err(|err| eyre::eyre!("couldn't deploy library: {err}"))? + .raw; + + self.extend_debug_bytecodes(&mut debug_bytecodes, deploy_debug_bytecodes); - library_transactions.push_back(BroadcastableTransaction { - rpc: self.evm_opts.fork_url.clone(), - transaction: TransactionRequest { - from: Some(self.evm_opts.sender), - input: code.clone().into(), - nonce: Some(sender_nonce + library_transactions.len() as u64), - ..Default::default() + if let Some(deploy_traces) = deploy_traces { + traces.push((TraceKind::Deployment, deploy_traces)); } - .into(), - }) - }), - ScriptPredeployLibraries::Create2(libraries, salt) => { + + let mut tx_req = TransactionRequestFor::::default() + .with_from(self.evm_opts.sender) + .with_input(code.clone()) + .with_nonce(sender_nonce + library_transactions.len() as u64); + + script_config.tempo.apply::(&mut tx_req, None); + + library_transactions.push_back(BroadcastableTransaction { + rpc: self.evm_opts.fork_url.clone(), + transaction: TransactionMaybeSigned::new(tx_req), + contract_address: None, + }) + } + } + ScriptPredeployLibraries::Create2 { onchain, salt, local } => { + self.deploy_local_libraries(local, &mut debug_bytecodes)?; let create2_deployer = self.executor.create2_deployer(); - for library in libraries { - let address = create2_deployer.create2_from_code(salt, library.as_ref()); + for library in onchain { + let address = + create2_deployer.create2_from_code(salt, library.bytecode.as_ref()); // Skip if already deployed if !self.executor.is_empty_code(address)? { continue; } - let calldata = [salt.as_ref(), library.as_ref()].concat(); - let result = self + let calldata = [salt.as_ref(), library.bytecode.as_ref()].concat(); + let RawCallResult { + traces: deploy_traces, + debug_bytecodes: deploy_debug_bytecodes, + .. + } = self .executor .transact_raw( self.evm_opts.sender, create2_deployer, calldata.clone().into(), - U256::from(0), + U256::ZERO, ) - .expect("couldn't deploy library"); + .map_err(|err| eyre::eyre!("couldn't deploy library: {err}"))?; + + self.extend_debug_bytecodes(&mut debug_bytecodes, deploy_debug_bytecodes); - if let Some(deploy_traces) = result.traces { + if let Some(deploy_traces) = deploy_traces { traces.push((TraceKind::Deployment, deploy_traces)); } + let mut tx_req = TransactionRequestFor::::default() + .with_from(self.evm_opts.sender) + .with_input(calldata) + .with_nonce(sender_nonce + library_transactions.len() as u64) + .with_to(create2_deployer); + + script_config.tempo.apply::(&mut tx_req, None); + library_transactions.push_back(BroadcastableTransaction { rpc: self.evm_opts.fork_url.clone(), - transaction: TransactionRequest { - from: Some(self.evm_opts.sender), - input: calldata.into(), - nonce: Some(sender_nonce + library_transactions.len() as u64), - to: Some(TxKind::Call(create2_deployer)), - ..Default::default() - } - .into(), + transaction: TransactionMaybeSigned::new(tx_req), + contract_address: None, }); } @@ -147,7 +232,13 @@ impl ScriptRunner { // Deploy an instance of the contract let DeployResult { address, - raw: RawCallResult { mut logs, traces: constructor_traces, .. }, + raw: + RawCallResult { + mut logs, + traces: constructor_traces, + debug_bytecodes: constructor_debug_bytecodes, + .. + }, } = self .executor .deploy(CALLER, code, U256::ZERO, None) @@ -163,52 +254,35 @@ impl ScriptRunner { } traces.extend(constructor_traces.map(|traces| (TraceKind::Deployment, traces))); + self.extend_debug_bytecodes(&mut debug_bytecodes, constructor_debug_bytecodes); // Optionally call the `setUp` function - let (success, gas_used, labeled_addresses, transactions) = if !setup { + let (success, gas_used, labeled_addresses, transactions) = if setup { + let result = match self.executor.setup(Some(self.evm_opts.sender), address, None) { + Ok(result) => result, + Err(EvmError::Execution(err)) => err.raw, + Err(err) => return Err(err.into()), + }; + let RawCallResult { + reverted, + traces: setup_traces, + labels, + logs: setup_logs, + gas_used, + debug_bytecodes: setup_debug_bytecodes, + transactions: setup_transactions, + .. + } = result; + traces.extend(setup_traces.map(|traces| (TraceKind::Setup, traces))); + logs.extend_from_slice(&setup_logs); + self.extend_debug_bytecodes(&mut debug_bytecodes, setup_debug_bytecodes); + if let Some(txs) = setup_transactions { + library_transactions.extend(txs); + } + (!reverted, gas_used, labels, Some(library_transactions)) + } else { self.executor.backend_mut().set_test_contract(address); (true, 0, Default::default(), Some(library_transactions)) - } else { - match self.executor.setup(Some(self.evm_opts.sender), address, None) { - Ok(RawCallResult { - reverted, - traces: setup_traces, - labels, - logs: setup_logs, - gas_used, - transactions: setup_transactions, - .. - }) => { - traces.extend(setup_traces.map(|traces| (TraceKind::Setup, traces))); - logs.extend_from_slice(&setup_logs); - - if let Some(txs) = setup_transactions { - library_transactions.extend(txs); - } - - (!reverted, gas_used, labels, Some(library_transactions)) - } - Err(EvmError::Execution(err)) => { - let RawCallResult { - reverted, - traces: setup_traces, - labels, - logs: setup_logs, - gas_used, - transactions, - .. - } = err.raw; - traces.extend(setup_traces.map(|traces| (TraceKind::Setup, traces))); - logs.extend_from_slice(&setup_logs); - - if let Some(txs) = transactions { - library_transactions.extend(txs); - } - - (!reverted, gas_used, labels, Some(library_transactions)) - } - Err(e) => return Err(e.into()), - } }; Ok(( @@ -218,6 +292,7 @@ impl ScriptRunner { success, gas_used, labeled_addresses, + debug_bytecodes: self.maybe_debug_bytecodes(debug_bytecodes), transactions, logs, traces, @@ -228,60 +303,82 @@ impl ScriptRunner { } /// Executes the method that will collect all broadcastable transactions. - pub fn script(&mut self, address: Address, calldata: Bytes) -> Result { + pub fn script( + &mut self, + address: Address, + calldata: Bytes, + ) -> Result> { self.call(self.evm_opts.sender, address, calldata, U256::ZERO, None, false) } /// Runs a broadcastable transaction locally and persists its state. pub fn simulate( &mut self, - from: Address, - to: Option
, - calldata: Option, - value: Option, - authorization_list: Option>, - ) -> Result { - if let Some(to) = to { + transaction: &TransactionMaybeSigned, + is_fixed_gas_limit: bool, + ) -> Result> { + if is_fixed_gas_limit { + let (env, tx) = self.prepare_fixed_gas_env(transaction); + return if tx.is_single_create() { + let result = self.executor.deploy_with_env(env, tx, None); + self.deployment_result(result) + } else { + let result = self.executor.transact_with_env(env, tx)?; + let gas_used = result.gas_used; + Ok(self.call_result(result, gas_used)) + }; + } + + let from = transaction.from().expect("transaction sender missing"); + if let Some(to) = transaction.to() { self.call( from, to, - calldata.unwrap_or_default(), - value.unwrap_or(U256::ZERO), - authorization_list, + transaction.input().cloned().unwrap_or_default(), + transaction.value().unwrap_or_default(), + transaction.authorization_list(), true, ) - } else if to.is_none() { + } else { let res = self.executor.deploy( from, - calldata.expect("No data for create transaction"), - value.unwrap_or(U256::ZERO), + transaction.input().cloned().expect("No data for create transaction"), + transaction.value().unwrap_or_default(), None, ); - let (address, RawCallResult { gas_used, logs, traces, .. }) = match res { + self.deployment_result(res) + } + } + + pub(crate) fn deployment_result( + &self, + res: Result, EvmError>, + ) -> Result> { + let (address, RawCallResult { gas_used, logs, traces, debug_bytecodes, exit_reason, .. }) = + match res { Ok(DeployResult { address, raw }) => (address, raw), Err(EvmError::Execution(err)) => { let ExecutionErr { raw, reason } = *err; sh_err!("Failed with `{reason}`:\n")?; (Address::ZERO, raw) } - Err(e) => eyre::bail!("Failed deploying contract: {e:?}"), + Err(e) => { + eyre::bail!("Failed deploying contract: {e:?}"); + } }; - Ok(ScriptResult { - returned: Bytes::new(), - success: address != Address::ZERO, - gas_used, - logs, - // Manually adjust gas for the trace to add back the stipend/real used gas - traces: traces - .map(|traces| vec![(TraceKind::Execution, traces)]) - .unwrap_or_default(), - address: Some(address), - ..Default::default() - }) - } else { - eyre::bail!("ENS not supported."); - } + Ok(ScriptResult { + returned: Bytes::new(), + success: !address.is_zero(), + gas_used, + logs, + debug_bytecodes: self.maybe_debug_bytecodes(debug_bytecodes), + // Manually adjust gas for the trace to add back the stipend/real used gas + traces: traces.map(|traces| vec![(TraceKind::Execution, traces)]).unwrap_or_default(), + exit_reason, + address: Some(address), + ..Default::default() + }) } /// Executes the call @@ -298,14 +395,14 @@ impl ScriptRunner { value: U256, authorization_list: Option>, commit: bool, - ) -> Result { - let mut res = if let Some(authorization_list) = authorization_list { + ) -> Result> { + let mut res = if let Some(authorization_list) = &authorization_list { self.executor.call_raw_with_authorization( from, to, calldata.clone(), value, - authorization_list, + authorization_list.clone(), )? } else { self.executor.call_raw(from, to, calldata.clone(), value)? @@ -319,17 +416,47 @@ impl ScriptRunner { // Otherwise don't re-execute, or some usecases might be broken: https://github.com/foundry-rs/foundry/issues/3921 if commit { gas_used = self.search_optimal_gas_usage(&res, from, to, &calldata, value)?; - res = self.executor.transact_raw(from, to, calldata, value)?; + res = if let Some(authorization_list) = authorization_list { + self.executor.transact_raw_with_authorization( + from, + to, + calldata, + value, + authorization_list, + )? + } else { + self.executor.transact_raw(from, to, calldata, value)? + } } - let RawCallResult { result, reverted, logs, traces, labels, transactions, .. } = res; - let breakpoints = res.cheatcodes.map(|cheats| cheats.breakpoints).unwrap_or_default(); + Ok(self.call_result(res, gas_used)) + } - Ok(ScriptResult { + pub(crate) fn call_result( + &self, + res: RawCallResult, + gas_used: u64, + ) -> ScriptResult { + let RawCallResult { + result, + reverted, + logs, + traces, + labels, + transactions, + debug_bytecodes, + exit_reason, + cheatcodes, + .. + } = res; + let breakpoints = cheatcodes.map(|cheats| cheats.breakpoints).unwrap_or_default(); + + ScriptResult { returned: result, success: !reverted, gas_used, logs, + debug_bytecodes: self.maybe_debug_bytecodes(debug_bytecodes), traces: traces .map(|traces| { // Manually adjust gas for the trace to add back the stipend/real used gas @@ -339,9 +466,10 @@ impl ScriptRunner { .unwrap_or_default(), labeled_addresses: labels, transactions, + exit_reason, address: None, breakpoints, - }) + } } /// The executor will return the _exact_ gas value this transaction consumed, setting this value @@ -352,7 +480,7 @@ impl ScriptRunner { /// it might be problematic when using `ffi`. fn search_optimal_gas_usage( &mut self, - res: &RawCallResult, + res: &RawCallResult, from: Address, to: Address, calldata: &Bytes, @@ -361,41 +489,218 @@ impl ScriptRunner { let mut gas_used = res.gas_used; if matches!(res.exit_reason, Some(return_ok!())) { // Store the current gas limit and reset it later. - let init_gas_limit = self.executor.env().tx.gas_limit; - - let mut highest_gas_limit = gas_used * 3; - let mut lowest_gas_limit = gas_used; - let mut last_highest_gas_limit = highest_gas_limit; - while (highest_gas_limit - lowest_gas_limit) > 1 { - let mid_gas_limit = (highest_gas_limit + lowest_gas_limit) / 2; - self.executor.env_mut().tx.gas_limit = mid_gas_limit; + let init_gas_limit = self.executor.tx_env().gas_limit(); + + let mut search = GasSearch::new(gas_used); + while let Some(limit) = search.next_limit() { + self.executor.tx_env_mut().set_gas_limit(limit); let res = self.executor.call_raw(from, to, calldata.0.clone().into(), value)?; - match res.exit_reason { - Some(InstructionResult::Revert) - | Some(InstructionResult::OutOfGas) - | Some(InstructionResult::OutOfFunds) => { - lowest_gas_limit = mid_gas_limit; - } - _ => { - highest_gas_limit = mid_gas_limit; - // if last two successful estimations only vary by 10%, we consider this to - // sufficiently accurate - const ACCURACY: u64 = 10; - if (last_highest_gas_limit - highest_gas_limit) * ACCURACY - / last_highest_gas_limit - < 1 - { - // update the gas - gas_used = highest_gas_limit; - break; - } - last_highest_gas_limit = highest_gas_limit; - } - } + search.record(limit, needs_more_gas(res.exit_reason)); } + gas_used = search.gas_used(); // Reset gas limit in the executor. - self.executor.env_mut().tx.gas_limit = init_gas_limit; + self.executor.tx_env_mut().set_gas_limit(init_gas_limit); } Ok(gas_used) } + + /// Prepares a fixed-limit transaction without changing the block or executor gas limit. + pub(crate) fn prepare_fixed_gas_env( + &self, + transaction: &TransactionMaybeSigned, + ) -> (EvmEnvFor, TxEnvFor) { + let from = transaction.from().expect("transaction sender missing"); + let kind = transaction.to().map_or(TxKind::Create, TxKind::Call); + let (mut env, mut tx) = self.executor.prepare_call_env( + from, + kind, + transaction.input().cloned().unwrap_or_default(), + transaction.value().unwrap_or_default(), + ); + env.block_env.set_gas_limit(self.executor.evm_env().block_env.gas_limit()); + match transaction { + TransactionMaybeSigned::Signed { tx: signed, .. } => { + // Keep the complete signed payload, including access lists and network fields. + tx = TxEnvFor::::from_recovered_tx(signed, from); + } + TransactionMaybeSigned::Unsigned(request) => { + let access_list = request.access_list().cloned().unwrap_or_default(); + if !access_list.is_empty() && tx.tx_type() == 0 { + tx.set_tx_type(1); + } + tx.set_access_list(access_list); + if let Some(authorization_list) = transaction.authorization_list() { + tx.set_signed_authorization(authorization_list); + tx.set_tx_type(4); + } + } + } + // Retain the synthetic call's fee and chain normalization. + tx.set_gas_price(0); + tx.set_gas_priority_fee(None); + tx.set_chain_id(Some(env.cfg_env.chain_id)); + tx.set_gas_limit(transaction.gas().expect("fixed gas limit missing") as u64); + (env, tx) + } +} + +/// Gas-search arithmetic shared by ordinary and Monad simulation. +pub(crate) struct GasSearch { + gas_used: u64, + highest: u64, + lowest: u64, + last_highest: u64, + done: bool, +} + +impl GasSearch { + pub(crate) const fn new(gas_used: u64) -> Self { + Self { + gas_used, + highest: gas_used * 3, + lowest: gas_used, + last_highest: gas_used * 3, + done: false, + } + } + + pub(crate) const fn next_limit(&self) -> Option { + if !self.done && self.highest - self.lowest > 1 { + Some((self.highest + self.lowest) / 2) + } else { + None + } + } + + /// Records the outcome of a probe at `limit`, where `needs_more_gas` means the limit was not + /// enough. + pub(crate) const fn record(&mut self, limit: u64, needs_more_gas: bool) { + if needs_more_gas { + self.lowest = limit; + } else { + self.highest = limit; + // Stop when successive successful estimates differ by less than ten percent. + if (self.last_highest - self.highest) * 10 / self.last_highest < 1 { + self.gas_used = self.highest; + self.done = true; + } else { + self.last_highest = self.highest; + } + } + } + + pub(crate) const fn gas_used(&self) -> u64 { + self.gas_used + } +} + +/// Returns whether the gas search treats `exit_reason` as needing more gas. +pub(crate) const fn needs_more_gas(exit_reason: Option) -> bool { + matches!( + exit_reason, + Some( + InstructionResult::Revert | InstructionResult::OutOfGas | InstructionResult::OutOfFunds + ) + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::bytes; + use alloy_signer::SignerSync; + use alloy_signer_local::PrivateKeySigner; + use foundry_evm::{ + backend::Backend, core::evm::TempoEvmNetwork, executors::ExecutorBuilder, revm::DatabaseRef, + }; + use foundry_evm_networks::NetworkConfigs; + use tempo_alloy::primitives::{TempoTransaction, TempoTxEnvelope, transaction::Call}; + + #[test] + fn successful_probes_keep_existing_ten_percent_stop() { + let mut search = GasSearch::new(100); + for expected in [200, 150, 125, 112, 106] { + assert_eq!(search.next_limit(), Some(expected)); + search.record(expected, false); + } + assert_eq!(search.next_limit(), None); + assert_eq!(search.gas_used(), 106); + } + + #[test] + fn unsuccessful_probes_keep_original_estimate() { + let mut search = GasSearch::new(100); + while let Some(limit) = search.next_limit() { + search.record(limit, true); + } + assert_eq!(search.gas_used(), 100); + assert_eq!(GasSearch::new(0).next_limit(), None); + } + + #[test] + fn only_revert_and_running_out_mean_too_little_gas() { + for reason in + [InstructionResult::Revert, InstructionResult::OutOfGas, InstructionResult::OutOfFunds] + { + assert!(needs_more_gas(Some(reason))); + } + for reason in [ + Some(InstructionResult::Return), + Some(InstructionResult::Stop), + Some(InstructionResult::InvalidFEOpcode), + None, + ] { + assert!(!needs_more_gas(reason)); + } + } + + #[test] + fn fixed_gas_tempo_create_first_batch() { + let signer = PrivateKeySigner::random(); + let sender = signer.address(); + let target = sender.create(0); + // The constructor stores seven; the runtime increments that slot when called. + let initcode = bytes!("60075f556008600e5f3960085ff35f546001015f5500"); + for batch in [false, true] { + let mut env = EvmEnvFor::::default(); + env.cfg_env.chain_id = 31337; + env.cfg_env.disable_nonce_check = true; + let executor = ExecutorBuilder::::new().gas_limit(30_000_000).build( + env, + TxEnvFor::::default(), + Backend::spawn(None).unwrap(), + NetworkConfigs::with_tempo(), + ); + let mut runner = ScriptRunner::new(executor, EvmOpts::default()); + let mut calls = + vec![Call { to: TxKind::Create, value: U256::ZERO, input: initcode.clone() }]; + if batch { + calls.push(Call { + to: TxKind::Call(target), + value: U256::ZERO, + input: Bytes::new(), + }); + } + let transaction = TempoTransaction { + chain_id: 31337, + gas_limit: 2_000_000, + calls, + ..Default::default() + }; + let signature = signer.sign_hash_sync(&transaction.signature_hash()).unwrap(); + let transaction = TransactionMaybeSigned::Signed { + tx: TempoTxEnvelope::AA(transaction.into_signed(signature.into())), + from: sender, + }; + let result = runner.simulate(&transaction, true).unwrap(); + assert!(result.success); + assert_eq!( + runner.executor.backend().storage_ref(target, U256::ZERO).unwrap(), + U256::from(if batch { 8 } else { 7 }), + ); + assert!(!runner.executor.is_empty_code(target).unwrap()); + assert_eq!(transaction.gas(), Some(2_000_000)); + assert_eq!(runner.executor.gas_limit(), 30_000_000); + } + } } diff --git a/crates/script/src/sequence.rs b/crates/script/src/sequence.rs index c0a7e9deddca7..7c5c68a8fe68f 100644 --- a/crates/script/src/sequence.rs +++ b/crates/script/src/sequence.rs @@ -1,98 +1,498 @@ -use crate::multi_sequence::MultiChainSequence; -use eyre::Result; +use crate::{ + multi_sequence::MultiChainSequence, + recovery::{AttemptKind, DelegatedStatus, RecoveryLock, RecoveryStore, SignedPayload}, +}; +use alloy_consensus::transaction::SignerRecoverable; +use alloy_eips::eip2718::{Decodable2718, Encodable2718}; +use alloy_network::{Network, ReceiptResponse}; +use alloy_primitives::{B256, Bytes}; +use eyre::{ContextCompat, Result, bail}; use forge_script_sequence::{ScriptSequence, TransactionWithMetadata}; use foundry_cli::utils::Git; -use foundry_common::fmt::UIfmt; +use foundry_common::{FoundryTransactionBuilder, fmt::UIfmt}; use foundry_compilers::ArtifactId; use foundry_config::Config; +use serde::{Deserialize, Serialize}; use std::{ + collections::HashMap, fmt::{Error, Write}, - path::Path, + path::{Path, PathBuf}, }; -/// Format transaction details for display -fn format_transaction(index: usize, tx: &TransactionWithMetadata) -> Result { - let mut output = String::new(); - writeln!(output, "### Transaction {index} ###")?; - writeln!(output, "{}", tx.tx().pretty())?; - - // Show contract name and address if available - if !tx.opcode.is_any_create() - && let (Some(name), Some(addr)) = (&tx.contract_name, &tx.contract_address) - { - writeln!(output, "contract: {name}({addr})")?; +pub(crate) fn completed_transaction_prefix( + sequence: &ScriptSequence, +) -> Result { + let mut receipts = HashMap::<_, usize>::new(); + for receipt in &sequence.receipts { + *receipts.entry(receipt.transaction_hash()).or_default() += 1; } - // Show decoded function if available - if let (Some(func), Some(args)) = (&tx.function, &tx.arguments) { - if args.is_empty() { - writeln!(output, "data (decoded): {func}()")?; - } else { - writeln!(output, "data (decoded): {func}(")?; - for (i, arg) in args.iter().enumerate() { - writeln!(&mut output, " {}{}", arg, if i + 1 < args.len() { "," } else { "" })?; + let mut prefix = 0; + let mut incomplete = false; + for transaction in &sequence.transactions { + let complete = transaction.hash.is_some_and(|hash| { + receipts.get_mut(&hash).is_some_and(|count| { + if *count == 0 { + false + } else { + *count -= 1; + true + } + }) + }); + if complete { + if incomplete { + bail!("script progress contains a receipt after an incomplete operation"); } - writeln!(output, ")")?; + prefix += 1; + } else { + incomplete = true; } } - - writeln!(output)?; - Ok(output) -} - -/// Returns the commit hash of the project if it exists -pub fn get_commit_hash(root: &Path) -> Option { - Git::new(root).commit_hash(true, "HEAD").ok() + if receipts.values().any(|count| *count != 0) { + bail!("script progress contains a receipt without a matching operation"); + } + Ok(prefix) } -pub enum ScriptSequenceKind { - Single(ScriptSequence), - Multi(MultiChainSequence), +#[derive(Clone, Serialize, Deserialize)] +#[serde( + tag = "kind", + content = "sequence", + rename_all = "camelCase", + bound( + serialize = "N::TxEnvelope: Serialize", + deserialize = "N::TxEnvelope: for<'de2> Deserialize<'de2>" + ) +)] +pub(crate) enum SequenceData { + Single(ScriptSequence), + Multi(MultiChainSequence), } -impl ScriptSequenceKind { - pub fn save(&mut self, silent: bool, save_ts: bool) -> Result<()> { +impl SequenceData +where + N::TxEnvelope: Serialize, +{ + fn save(&mut self, silent: bool, save_ts: bool) -> Result<()> { match self { Self::Single(sequence) => sequence.save(silent, save_ts), Self::Multi(sequence) => sequence.save(silent, save_ts), } } - pub fn sequences(&self) -> &[ScriptSequence] { + pub fn sequences(&self) -> &[ScriptSequence] { match self { Self::Single(sequence) => std::slice::from_ref(sequence), Self::Multi(sequence) => &sequence.deployments, } } - pub fn sequences_mut(&mut self) -> &mut [ScriptSequence] { + pub fn sequences_mut(&mut self) -> &mut [ScriptSequence] { match self { Self::Single(sequence) => std::slice::from_mut(sequence), Self::Multi(sequence) => &mut sequence.deployments, } } - /// Updates underlying sequence paths to not be under /dry-run directory. - pub fn update_paths_to_broadcasted( + + pub const fn is_multi(&self) -> bool { + matches!(self, Self::Multi(_)) + } + + pub fn paths(&self) -> (std::path::PathBuf, std::path::PathBuf) { + match self { + Self::Single(sequence) => sequence.paths.clone().expect("sequence paths not set"), + Self::Multi(sequence) => (sequence.path.clone(), sequence.sensitive_path.clone()), + } + } + + pub(crate) fn set_paths(&mut self, paths: (PathBuf, PathBuf)) { + match self { + Self::Single(sequence) => sequence.paths = Some(paths), + Self::Multi(sequence) => { + sequence.path = paths.0; + sequence.sensitive_path = paths.1; + } + } + } + + pub(crate) fn has_recovery_generation(&self) -> bool { + self.sequences().iter().any(|sequence| sequence.recovery_generation.is_some()) + } + + pub(crate) fn set_recovery_generation(&mut self, generation: B256) { + for sequence in self.sequences_mut() { + sequence.recovery_generation = Some(generation); + } + } + + pub(crate) fn publish(&self, paths: &(PathBuf, PathBuf)) -> Result<()> { + let mut export = self.clone(); + export.set_paths(paths.clone()); + export.save(true, false) + } + + fn save_timestamped(&self, paths: &(PathBuf, PathBuf)) -> Result<()> { + match self { + Self::Single(sequence) => { + let filename = format!("run-{}.json", sequence.timestamp); + std::fs::copy(&paths.0, paths.0.with_file_name(&filename))?; + std::fs::copy(&paths.1, paths.1.with_file_name(filename))?; + } + Self::Multi(sequence) => { + let timestamp = sequence.timestamp; + for path in [&paths.0, &paths.1] { + let timestamped = PathBuf::from( + path.to_string_lossy().replace("-latest", &format!("-{timestamp}")), + ); + std::fs::create_dir_all(timestamped.parent().unwrap())?; + std::fs::copy(path, timestamped)?; + } + } + } + Ok(()) + } +} + +pub struct ScriptSequenceKind +where + N::TxEnvelope: for<'d> Deserialize<'d> + Serialize, +{ + recovery: RecoveryStore, +} + +impl ScriptSequenceKind +where + N::TxEnvelope: for<'d> Deserialize<'d> + Serialize, +{ + pub fn new_single(sequence: ScriptSequence, batch: bool) -> Result { + Self::create(SequenceData::Single(sequence), batch) + } + + pub fn new_multi(sequence: MultiChainSequence, batch: bool) -> Result { + Self::create(SequenceData::Multi(sequence), batch) + } + + pub fn load_single( + config: &Config, + sig: &str, + target: &ArtifactId, + chain: u64, + dry_run: bool, + batch: bool, + ) -> Result + where + N::TxEnvelope: SignerRecoverable, + N::TransactionRequest: FoundryTransactionBuilder, + { + let paths = ScriptSequence::::get_paths(config, sig, target, chain, dry_run)?; + Self::load(paths, batch, || { + ScriptSequence::load(config, sig, target, chain, dry_run).map(SequenceData::Single) + }) + } + + pub fn load_multi( + config: &Config, + sig: &str, + target: &ArtifactId, + dry_run: bool, + batch: bool, + ) -> Result + where + N::TxEnvelope: SignerRecoverable, + N::TransactionRequest: FoundryTransactionBuilder, + { + let paths = MultiChainSequence::::get_paths(config, sig, target, dry_run)?; + Self::load(paths, batch, || { + MultiChainSequence::load(config, sig, target, dry_run).map(SequenceData::Multi) + }) + } + + fn load( + paths: (PathBuf, PathBuf), + batch: bool, + load_legacy: impl FnOnce() -> Result>, + ) -> Result + where + N::TxEnvelope: SignerRecoverable, + N::TransactionRequest: FoundryTransactionBuilder, + { + let lock = RecoveryLock::acquire(&paths)?; + let recovery = if let Some(recovery) = RecoveryStore::load(&paths, batch, lock)? { + recovery + } else { + let lock = RecoveryLock::acquire(&paths)?; + let data = load_legacy()?; + if data.has_recovery_generation() { + bail!("recovery exports reference a missing authoritative snapshot"); + } + RecoveryStore::import(data, batch, lock)? + }; + let sequence = Self { recovery }; + sequence.recovery.data().publish(&paths)?; + Ok(sequence) + } + + fn create(data: SequenceData, batch: bool) -> Result { + let paths = data.paths(); + let recovery = RecoveryStore::create(data, batch)?; + let sequence = Self { recovery }; + sequence.recovery.data().publish(&paths)?; + Ok(sequence) + } + + pub fn save(&mut self, silent: bool, save_ts: bool) -> Result<()> { + self.recovery.save()?; + let paths = self.recovery.data().paths(); + self.recovery.data().publish(&paths)?; + if save_ts { + self.recovery.data().save_timestamped(&paths)?; + } + if !silent { + if foundry_common::shell::is_json() { + sh_println!( + "{}", + serde_json::json!({ + "status": "success", + "transactions": paths.0.display().to_string(), + "sensitive": paths.1.display().to_string(), + }) + )?; + } else { + sh_println!("\nTransactions saved to: {}\n", paths.0.display())?; + sh_println!("Sensitive values saved to: {}\n", paths.1.display())?; + } + } + Ok(()) + } + + pub fn sequences(&self) -> &[ScriptSequence] { + self.recovery.data().sequences() + } + + pub fn sequences_mut(&mut self) -> &mut [ScriptSequence] { + self.recovery.data_mut().sequences_mut() + } + + pub(crate) fn signed_payload(&self, sequence: usize, index: usize) -> Option<&SignedPayload> { + self.recovery.signed_payload(sequence, index) + } + + pub(crate) fn submission_hashes(&self, sequence: usize) -> (Vec, Vec) { + self.recovery.submission_hashes(sequence) + } + + pub(crate) fn persist_signed_payload( + &mut self, + sequence: usize, + index: usize, + payload: Bytes, + sender_scoped_replay: bool, + ) -> Result + where + N::TxEnvelope: SignerRecoverable, + N::TransactionRequest: FoundryTransactionBuilder, + { + self.recovery.persist_signed_payload(sequence, index, payload, sender_scoped_replay) + } + + pub(crate) fn delegated_status( + &self, + sequence: usize, + index: usize, + ) -> Option { + self.recovery.delegated_status(sequence, index) + } + + pub(crate) fn persist_delegated_request( + &mut self, + sequence: usize, + index: usize, + request: N::TransactionRequest, + ) -> Result<()> { + self.recovery.persist_delegated_request(sequence, index, request) + } + + pub(crate) fn persist_delegated_status( + &mut self, + sequence: usize, + index: usize, + status: DelegatedStatus, + ) -> Result<()> { + self.recovery.persist_delegated_status(sequence, index, status) + } + + pub(crate) fn clear_delegated_request(&mut self, sequence: usize, index: usize) -> Result<()> { + self.recovery.clear_delegated_request(sequence, index) + } + + pub(crate) fn resolve_delegated_hash( + &mut self, + attempt_id: B256, + hash: B256, + transaction: &N::TransactionResponse, + ) -> Result<()> + where + N::TransactionRequest: FoundryTransactionBuilder, + { + let (sequence, index) = + self.recovery.resolve_delegated_hash(attempt_id, hash, transaction)?; + if !self.recovery.is_batch() { + self.sequences_mut()[sequence].add_pending(index, hash); + } + Ok(()) + } + + pub(crate) fn batch_attempt( + &self, + sequence: usize, + ) -> Option<(usize, B256, &AttemptKind)> { + self.recovery.batch_attempt(sequence) + } + + pub(crate) fn persist_batch_signed_payload( + &mut self, + sequence: usize, + first_operation: usize, + request: N::TransactionRequest, + payload: Bytes, + ) -> Result + where + N::TxEnvelope: Decodable2718 + Encodable2718, + { + self.recovery.persist_batch_signed_payload(sequence, first_operation, request, payload) + } + + pub(crate) fn persist_batch_delegated_request( + &mut self, + sequence: usize, + first_operation: usize, + request: N::TransactionRequest, + ) -> Result<()> { + self.recovery.persist_batch_delegated_request(sequence, first_operation, request) + } + + pub(crate) fn restore_delegated_pending( + &mut self, + attempt_id: Option, + resolved_hash: Option, + retry_unknown: bool, + ) -> Result> { + let resolution_requested = resolved_hash.is_some() || retry_unknown; + if resolution_requested && attempt_id.is_none() { + bail!("--resume-attempt is required to resolve an interrupted submission"); + } + + for (sequence, index, _, status) in self.recovery.delegated_attempts() { + if status == DelegatedStatus::Prepared { + self.recovery.persist_delegated_status( + sequence, + index, + DelegatedStatus::OutcomeUnknown, + )?; + } + } + + let mut pending_resolution = None; + if let Some(attempt_id) = attempt_id { + let (sequence, index) = self + .recovery + .delegated_attempt_location(attempt_id) + .context("no interrupted delegated submission matches --resume-attempt")?; + if !matches!( + self.recovery.delegated_status(sequence, index), + Some(DelegatedStatus::OutcomeUnknown) + ) { + bail!("delegated submission attempt {attempt_id} does not require resolution"); + } + if retry_unknown { + self.recovery.clear_delegated_request(sequence, index)?; + } else if let Some(hash) = resolved_hash { + pending_resolution = Some((sequence, index, attempt_id, hash)); + } else { + bail!("--resume-attempt requires --resume-tx-hash or --resume-retry"); + } + } + + let batch = self.recovery.is_batch(); + for (sequence, index, attempt, status) in self.recovery.delegated_attempts() { + match status { + DelegatedStatus::OutcomeUnknown => { + if pending_resolution.is_some_and(|(_, _, target, _)| target == attempt) { + continue; + } + bail!( + "submission outcome for delegated attempt {attempt} on chain {} is unknown; target it with --resume-attempt and provide --resume-tx-hash, or use --resume-retry only after proving it was not submitted", + self.sequences()[sequence].chain + ); + } + DelegatedStatus::Pending { hash } if !batch => { + let deployment = &mut self.sequences_mut()[sequence]; + if !deployment.receipts.iter().any(|receipt| receipt.transaction_hash() == hash) + { + deployment.add_pending(index, hash); + } + } + DelegatedStatus::Prepared | DelegatedStatus::Pending { .. } => {} + } + } + Ok(pending_resolution) + } + + pub(crate) fn ensure_delegated_outcomes_known(&mut self, sequence: usize) -> Result<()> { + for index in 0..self.sequences()[sequence].transactions.len() { + let Some(DelegatedStatus::Pending { hash }) = + self.recovery.delegated_status(sequence, index) + else { + continue; + }; + let deployment = &self.sequences()[sequence]; + if deployment.pending.contains(&hash) + || deployment.receipts.iter().any(|receipt| receipt.transaction_hash() == hash) + { + continue; + } + let chain = deployment.chain; + bail!( + "delegated submission {hash} for operation {index} on chain {chain} is not currently visible; its recovery identity remains pending" + ); + } + Ok(()) + } + + pub const fn is_multi(&self) -> bool { + self.recovery.data().is_multi() + } + + /// Atomically promotes a dry-run sequence to the broadcast paths. + pub fn promote_to_broadcasted( &mut self, config: &Config, sig: &str, target: &ArtifactId, ) -> Result<()> { - match self { - Self::Single(sequence) => { - sequence.paths = - Some(ScriptSequence::get_paths(config, sig, target, sequence.chain, false)?); + let paths = match self.recovery.data() { + SequenceData::Single(sequence) => { + ScriptSequence::::get_paths(config, sig, target, sequence.chain, false)? } - Self::Multi(sequence) => { - (sequence.path, sequence.sensitive_path) = - MultiChainSequence::get_paths(config, sig, target, false)?; + SequenceData::Multi(_) => { + MultiChainSequence::::get_paths(config, sig, target, false)? } }; - + let relocation = self.recovery.prepare_relocation(&paths)?; + self.recovery.commit_relocation(relocation)?; + self.recovery.data_mut().set_paths(paths.clone()); + self.recovery.save()?; + self.recovery.data().publish(&paths)?; + self.recovery.data().save_timestamped(&paths)?; Ok(()) } - pub fn show_transactions(&self) -> Result<()> { + pub fn show_transactions(&self) -> Result<()> + where + N::TxEnvelope: UIfmt, + N::TransactionRequest: FoundryTransactionBuilder, + { for sequence in self.sequences() { if !sequence.transactions.is_empty() { sh_println!("\nChain {}\n", sequence.chain)?; @@ -107,10 +507,190 @@ impl ScriptSequenceKind { } } -impl Drop for ScriptSequenceKind { +impl Drop for ScriptSequenceKind +where + N::TxEnvelope: for<'d> Deserialize<'d> + Serialize, +{ fn drop(&mut self) { if let Err(err) = self.save(false, true) { error!(?err, "could not save deployment sequence"); } } } + +/// Format transaction details for display +fn format_transaction( + index: usize, + tx: &TransactionWithMetadata, +) -> Result +where + N::TxEnvelope: UIfmt, + N::TransactionRequest: FoundryTransactionBuilder, +{ + let mut output = String::new(); + writeln!(output, "### Transaction {index} ###")?; + writeln!(output, "{}", tx.tx().pretty())?; + + // Show contract name and address if available + if !tx.call_kind.is_any_create() + && let (Some(name), Some(addr)) = (&tx.contract_name, &tx.contract_address) + { + writeln!(output, "contract: {name}({addr})")?; + } + + // Show decoded function if available + if let (Some(func), Some(args)) = (&tx.display_function, &tx.arguments) { + if args.is_empty() { + writeln!(output, "data (decoded): {func}()")?; + } else { + writeln!(output, "data (decoded): {func}(")?; + for (i, arg) in args.iter().enumerate() { + writeln!(&mut output, " {}{}", arg, if i + 1 < args.len() { "," } else { "" })?; + } + writeln!(output, ")")?; + } + } + + writeln!(output)?; + Ok(output) +} + +/// Returns the commit hash of the project if it exists +pub fn get_commit_hash(root: &Path) -> Option { + Git::new(root).commit_hash(true, "HEAD").ok() +} + +/// Returns a stable transaction ordinal across the complete script plan, including completed +/// transactions so that resuming does not change expiring nonce discriminators. +pub(crate) fn operation_ordinal( + sequences: &[ScriptSequence], + sequence: usize, + index: usize, +) -> usize { + sequences[..sequence].iter().map(|sequence| sequence.transactions.len()).sum::() + index +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_network::Ethereum; + use foundry_common::TransactionMaybeSigned; + + #[test] + fn operation_ordinals_include_earlier_sequences_and_completed_transactions() { + let transaction = + TransactionWithMetadata::from_tx_request(TransactionMaybeSigned::::Unsigned( + Default::default(), + )); + let mut sequences = [ + ScriptSequence { + chain: 4217, + transactions: [transaction.clone()].into(), + ..Default::default() + }, + ScriptSequence { + chain: 4218, + transactions: [transaction.clone(), transaction.clone()].into(), + ..Default::default() + }, + ScriptSequence { + chain: 4217, + transactions: [transaction].into(), + ..Default::default() + }, + ]; + assert_eq!(operation_ordinal(&sequences, 0, 0), 0); + assert_eq!(operation_ordinal(&sequences, 1, 1), 2); + assert_eq!(operation_ordinal(&sequences, 2, 0), 3); + sequences[0].transactions[0].hash = Some(B256::repeat_byte(1)); + assert_eq!(operation_ordinal(&sequences, 2, 0), 3); + } + + fn unknown_delegated_sequence() -> (tempfile::TempDir, ScriptSequenceKind, B256) { + let dir = tempfile::tempdir().unwrap(); + let mut deployment = ScriptSequence:: { + chain: 1, + paths: Some((dir.path().join("broadcast.json"), dir.path().join("cache.json"))), + ..Default::default() + }; + deployment.transactions.push_back(TransactionWithMetadata::from_tx_request( + TransactionMaybeSigned::Unsigned(Default::default()), + )); + let mut sequence = ScriptSequenceKind::new_single(deployment, false).unwrap(); + sequence.persist_delegated_request(0, 0, Default::default()).unwrap(); + sequence.persist_delegated_status(0, 0, DelegatedStatus::OutcomeUnknown).unwrap(); + let attempt = sequence.recovery.delegated_attempts()[0].2; + (dir, sequence, attempt) + } + + #[test] + fn publish_keeps_source_paths() { + let dir = tempfile::tempdir().unwrap(); + let source_paths = (dir.path().join("dry-run.json"), dir.path().join("dry-run-cache.json")); + let mut sequence = + ScriptSequence:: { paths: Some(source_paths.clone()), ..Default::default() }; + sequence.transactions.push_back(TransactionWithMetadata::from_tx_request( + TransactionMaybeSigned::Unsigned(Default::default()), + )); + let data = SequenceData::Single(sequence); + + let public = dir.path().join("broadcast.json"); + let sensitive = dir.path().join("broadcast-cache.json"); + let paths = (public, sensitive.clone()); + data.publish(&paths).unwrap(); + + assert_eq!(data.paths(), source_paths); + let value: serde_json::Value = foundry_common::fs::read_json_file(&sensitive).unwrap(); + assert_eq!(value["transactions"].as_array().unwrap().len(), 1); + } + + #[test] + fn any_deployment_generation_blocks_legacy_import() { + let mut data = SequenceData::::Multi(MultiChainSequence { + deployments: vec![ScriptSequence::default(), ScriptSequence::default()], + path: PathBuf::new(), + sensitive_path: PathBuf::new(), + timestamp: 0, + }); + data.sequences_mut()[1].recovery_generation = Some(B256::repeat_byte(0x11)); + + assert!(data.has_recovery_generation()); + } + + #[test] + fn delegated_outcome_requires_explicit_operator_resolution() { + let (_dir, mut sequence, attempt) = unknown_delegated_sequence(); + let hash = B256::repeat_byte(0x42); + + assert_eq!( + sequence.restore_delegated_pending(Some(attempt), Some(hash), false).unwrap(), + Some((0, 0, attempt, hash)) + ); + + assert!(matches!(sequence.delegated_status(0, 0), Some(DelegatedStatus::OutcomeUnknown))); + assert!(sequence.sequences()[0].pending.is_empty()); + + let (_dir, mut sequence, attempt) = unknown_delegated_sequence(); + + sequence.restore_delegated_pending(Some(attempt), None, true).unwrap(); + + assert!(sequence.delegated_status(0, 0).is_none()); + } + + #[test] + fn authoritative_load_does_not_read_legacy_exports() { + let (_dir, sequence, attempt) = unknown_delegated_sequence(); + let paths = sequence.recovery.data().paths(); + drop(sequence); + std::fs::remove_file(&paths.0).unwrap(); + std::fs::remove_file(&paths.1).unwrap(); + + let loaded = ScriptSequenceKind::::load(paths.clone(), false, || { + panic!("authoritative recovery must not load compatibility exports"); + }) + .unwrap(); + assert_eq!(loaded.recovery.delegated_attempts()[0].2, attempt); + assert!(paths.0.exists()); + assert!(paths.1.exists()); + } +} diff --git a/crates/script/src/session.rs b/crates/script/src/session.rs new file mode 100644 index 0000000000000..0ae90278f1548 --- /dev/null +++ b/crates/script/src/session.rs @@ -0,0 +1,157 @@ +use alloy_primitives::{ + Address, + map::{AddressHashSet, HashMap}, +}; +use eyre::Result; +use foundry_cli::opts::TempoOpts; +use foundry_common::tempo::ResolvedSessionSigner; +use foundry_wallets::TempoAccountsWallet; +use itertools::Itertools; + +/// A transaction sender scoped to one chain. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) struct SignerScope { + pub(crate) chain: u64, + pub(crate) sender: Address, +} + +impl SignerScope { + pub(crate) const fn new(chain: u64, sender: Address) -> Self { + Self { chain, sender } + } +} + +/// Returns the single sender a configured Tempo session is allowed to cover. +/// +/// Session signing is intentionally fail-closed: a single session access key represents one root +/// account, so scripts with multiple pending senders must not silently mix the session key with +/// other wallets. +pub(crate) fn script_session_expected_sender_if_configured( + tempo: &TempoOpts, + required_addresses: &AddressHashSet, +) -> Result> { + tempo.session_id()?.map_or(Ok(None), |_| single_session_sender(required_addresses)) +} + +fn single_session_sender(required_addresses: &AddressHashSet) -> Result> { + required_addresses + .iter() + .copied() + .at_most_one() + .map_err(|_| eyre::eyre!("Tempo sessions require a single script sender")) +} + +/// Inserts this session access key when it covers the remaining transaction set. +/// +/// Transactions from the session root on any other chain are rejected up front, so callers do not +/// accidentally fall back to a long-lived root signer for the same session account. +pub(crate) fn insert_session_access_key_for_remaining_transactions( + access_keys: &mut HashMap, + session: ResolvedSessionSigner, + remaining_transactions: &[SignerScope], +) -> Result<()> { + let chain = session.session.chain_id; + let root = session.session.root_account; + if let Some(tx) = + remaining_transactions.iter().find(|tx| tx.sender == root && tx.chain != chain) + { + eyre::bail!( + "Tempo session is for chain {}, but a remaining transaction from session root {} is on chain {}", + chain, + root, + tx.chain, + ); + } + + if remaining_transactions.iter().any(|tx| tx.sender == root) { + access_keys.insert(SignerScope::new(chain, root), session.access_key); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + + use super::*; + use alloy_primitives::B256; + use alloy_signer::Signer; + use foundry_common::tempo::{KeyType, SessionEntry, SessionKeyMaterial, SessionStatus}; + + const ROOT_PRIVATE_KEY: &str = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + const ACCESS_KEY_PRIVATE_KEY: &str = + "0x59c6995e998f97a5a004497e5da3b5d2b2b66a87f064d39c44da0b6d6e4f8ff0"; + + #[test] + fn session_sender_requires_single_root_account() { + let one = Address::repeat_byte(0x11); + let two = Address::repeat_byte(0x22); + let single_sender = [one].into_iter().collect(); + let multiple_senders = [one, two].into_iter().collect(); + + assert_eq!(single_session_sender(&single_sender).unwrap(), Some(one)); + assert!(single_session_sender(&multiple_senders).is_err()); + } + + #[test] + fn session_access_key_rejects_session_root_on_wrong_chain() { + let (session, root_address, _) = session_signer(4217); + let remaining = [SignerScope { chain: 1, sender: root_address }]; + let mut access_keys = HashMap::default(); + + let err = insert_session_access_key_for_remaining_transactions( + &mut access_keys, + session, + &remaining, + ) + .unwrap_err(); + + assert!(access_keys.is_empty()); + let message = err.to_string(); + assert!(message.contains("Tempo session is for chain 4217"), "{message}"); + assert!(message.contains("transaction from session root"), "{message}"); + assert!(message.contains("chain 1"), "{message}"); + } + + #[test] + fn session_access_key_is_inserted_for_session_chain() { + let (session, root_address, access_key_address) = session_signer(4217); + let remaining = [SignerScope { chain: 4217, sender: root_address }]; + let mut access_keys = HashMap::default(); + + insert_session_access_key_for_remaining_transactions(&mut access_keys, session, &remaining) + .unwrap(); + + let wallet = + access_keys.get(&SignerScope::new(4217, root_address)).expect("session access key"); + assert_eq!(wallet.account(), root_address); + assert_eq!(wallet.key_id().unwrap(), access_key_address); + } + + fn session_signer(chain_id: u64) -> (ResolvedSessionSigner, Address, Address) { + let root = foundry_wallets::utils::create_private_key_signer(ROOT_PRIVATE_KEY).unwrap(); + let root_address = root.address(); + let signer = foundry_wallets::utils::create_local_signer(ACCESS_KEY_PRIVATE_KEY).unwrap(); + let key_address = signer.address(); + let access_key = + TempoAccountsWallet::from_secp256k1(root_address, signer, None).with_chain_id(chain_id); + let session = SessionEntry { + session_id: B256::ZERO, + root_account: root_address, + chain_id, + key_address, + expiry: u64::MAX, + scope: None, + limits: None, + status: SessionStatus::Active, + key: Some(SessionKeyMaterial { + key_type: KeyType::Secp256k1, + key: ACCESS_KEY_PRIVATE_KEY.to_string(), + key_authorization: None, + }), + }; + + (ResolvedSessionSigner { session, access_key }, root_address, key_address) + } +} diff --git a/crates/script/src/simulate.rs b/crates/script/src/simulate.rs index a98dd1e0f6dbe..370806cc8a5f6 100644 --- a/crates/script/src/simulate.rs +++ b/crates/script/src/simulate.rs @@ -1,56 +1,146 @@ use super::{ - multi_sequence::MultiChainSequence, providers::ProvidersManager, runner::ScriptRunner, - sequence::ScriptSequenceKind, transaction::ScriptTransactionBuilder, + multi_sequence::MultiChainSequence, + providers::{ProviderInfo, ProvidersManager}, + runner::ScriptRunner, + sequence::ScriptSequenceKind, + transaction::ScriptTransactionBuilder, }; use crate::{ ScriptArgs, ScriptConfig, ScriptResult, broadcast::{BundledState, estimate_gas}, build::LinkedBuildData, - execute::{ExecutionArtifacts, ExecutionData}, + execute::{ExecutionArtifacts, ExecutionData, build_trace_decoder_for_context}, sequence::get_commit_hash, }; -use alloy_chains::NamedChain; +use alloy_chains::{Chain, NamedChain}; +use alloy_evm::revm::context::Block; use alloy_network::TransactionBuilder; -use alloy_primitives::{Address, Bytes, TxKind, U256, map::HashMap, utils::format_units}; +use alloy_primitives::{Address, U256, map::HashMap, utils::format_units}; +use alloy_provider::Provider; use dialoguer::Confirm; use eyre::{Context, Result}; use forge_script_sequence::{ScriptSequence, TransactionWithMetadata}; use foundry_cheatcodes::Wallets; use foundry_cli::utils::{has_different_gas_calc, now}; -use foundry_common::{ContractData, shell}; -use foundry_evm::traces::{decode_trace_arena, render_trace_arena}; -use futures::future::{join_all, try_join_all}; -use parking_lot::RwLock; +use foundry_common::{ + ContractData, ContractsByArtifact, provider::fee::resolve_broadcast_eip1559_fees, shell, + tempo::known_fee_token_symbol, +}; +use foundry_evm::{ + core::{FoundryBlock, evm::FoundryEvmNetwork}, + traces::{ + CallTraceDecoder, Traces, debug::ContractSources, decode_trace_arena, prune_trace_depth, + render_trace_arena_inner, + }, +}; +use foundry_wallets::wallet_browser::signer::BrowserSigner; use std::{ collections::{BTreeMap, VecDeque}, - sync::Arc, + mem, }; +#[cfg(feature = "monad")] +mod monad; + /// Same as [ExecutedState](crate::execute::ExecutedState), but also contains [ExecutionArtifacts] /// which are obtained from [ScriptResult]. /// /// Can be either converted directly to [BundledState] or driven to it through /// [FilledTransactionsState]. -pub struct PreSimulationState { +pub struct PreSimulationState { pub args: ScriptArgs, - pub script_config: ScriptConfig, + pub script_config: ScriptConfig, pub script_wallets: Wallets, + pub browser_wallet: Option>, pub build_data: LinkedBuildData, pub execution_data: ExecutionData, - pub execution_result: ScriptResult, + pub execution_result: ScriptResult, pub execution_artifacts: ExecutionArtifacts, } -impl PreSimulationState { - /// If simulation is enabled, simulates transactions against fork and fills gas estimation and - /// metadata. Otherwise, metadata (e.g. additional contracts, created contract names) is - /// left empty. +type SimulationOutcome = (String, Option>, bool, Traces); + +struct RpcSimulationContext { + runner: R, + decoder: CallTraceDecoder, +} + +async fn build_rpc_simulation_context( + rpc: String, + args: &ScriptArgs, + script_config: &ScriptConfig, + known_contracts: &ContractsByArtifact, + sources: &ContractSources, + execution_result: &ScriptResult, +) -> Result<(String, RpcSimulationContext>)> { + let mut script_config = script_config.clone(); + script_config.select_rpc(rpc.clone()).await?; + let runner = script_config._get_runner(None, false, false).await?; + let decoder = build_trace_decoder_for_context( + args, + &script_config, + known_contracts, + sources, + execution_result, + script_config.source_chain_id.map(Chain::from), + )?; + Ok((rpc, RpcSimulationContext { runner, decoder })) +} + +async fn build_rpc_decoder( + rpc: String, + args: &ScriptArgs, + script_config: &ScriptConfig, + known_contracts: &ContractsByArtifact, + sources: &ContractSources, + execution_result: &ScriptResult, +) -> Result<(String, CallTraceDecoder)> { + let mut script_config = script_config.clone(); + script_config.resolve_rpc_execution_spec(rpc.clone()).await?; + let decoder = build_trace_decoder_for_context( + args, + &script_config, + known_contracts, + sources, + execution_result, + script_config.source_chain_id.map(Chain::from), + )?; + Ok((rpc, decoder)) +} + +impl PreSimulationState { + /// Simulates ordinary transactions against the fork and fills gas estimation and execution + /// metadata. /// - /// Both modes will panic if any of the transactions have None for the `rpc` field. - pub async fn fill_metadata(self) -> Result { - let address_to_abi = self.build_address_to_abi_map(); + /// Panics if any transaction has no `rpc` field. Monad simulation has a concrete owner and + /// must not use this entry point. + pub(crate) async fn fill_ordinary_metadata(self) -> Result> { + if self.args.skip_simulation { + return self.fill_without_simulation().await; + } + + let contexts = self.build_runners().await?.into_iter().collect::>(); + let transactions = + self.transaction_metadata(|rpc| &contexts.get(rpc).expect("invalid rpc url").decoder)?; + let transactions = self.simulate_and_fill_with_contexts(transactions, contexts).await?; + Ok(self.into_filled(transactions)) + } + + /// Fills metadata derived without transaction simulation using each RPC's resolved context. + pub(crate) async fn fill_without_simulation(self) -> Result> { + let decoders = self.build_rpc_decoders().await?; + let transactions = + self.transaction_metadata(|rpc| decoders.get(rpc).expect("invalid rpc url"))?; + sh_println!("\nSKIPPING ON CHAIN SIMULATION.")?; + Ok(self.into_filled(transactions)) + } - let mut transactions = self + fn transaction_metadata<'a>( + &self, + decoder_for_rpc: impl Fn(&str) -> &'a CallTraceDecoder, + ) -> Result>> { + let address_to_abi = self.build_address_to_abi_map(); + let transactions = self .execution_result .transactions .clone() @@ -59,87 +149,79 @@ impl PreSimulationState { .map(|tx| { let rpc = tx.rpc.expect("missing broadcastable tx rpc url"); let sender = tx.transaction.from().expect("all transactions should have a sender"); - let nonce = tx.transaction.nonce().expect("all transactions should have a sender"); + let nonce = tx.transaction.nonce().expect("all transactions should have a nonce"); let to = tx.transaction.to(); + let decoder = decoder_for_rpc(&rpc); let mut builder = ScriptTransactionBuilder::new(tx.transaction, rpc); - if let Some(TxKind::Call(_)) = to { + if to.is_some() { builder.set_call( &address_to_abi, - &self.execution_artifacts.decoder, + decoder, self.script_config.evm_opts.create2_deployer, )?; } else { - builder.set_create(false, sender.create(nonce), &address_to_abi)?; + builder.set_create( + false, + tx.contract_address.unwrap_or_else(|| sender.create(nonce)), + &address_to_abi, + )?; } Ok(builder.build()) }) .collect::>>()?; - if self.args.skip_simulation { - sh_println!("\nSKIPPING ON CHAIN SIMULATION.")?; - } else { - transactions = self.simulate_and_fill(transactions).await?; - } + Ok(transactions) + } - Ok(FilledTransactionsState { + fn into_filled( + self, + transactions: VecDeque>, + ) -> FilledTransactionsState { + FilledTransactionsState { args: self.args, script_config: self.script_config, script_wallets: self.script_wallets, + browser_wallet: self.browser_wallet, build_data: self.build_data, execution_artifacts: self.execution_artifacts, transactions, - }) + } } - /// Builds separate runners and environments for each RPC used in script and executes all - /// transactions in those environments. - /// - /// Collects gas usage and metadata for each transaction. - pub async fn simulate_and_fill( + /// Executes every transaction in its RPC-specific simulation context and collects gas usage + /// and metadata. + async fn simulate_and_fill_with_contexts( &self, - transactions: VecDeque, - ) -> Result> { + transactions: VecDeque>, + mut contexts: HashMap>>, + ) -> Result>> { trace!(target: "script", "executing onchain simulation"); - let runners = Arc::new( - self.build_runners() - .await? - .into_iter() - .map(|(rpc, runner)| (rpc, Arc::new(RwLock::new(runner)))) - .collect::>(), - ); - - let mut final_txs = VecDeque::new(); - - // Executes all transactions from the different forks concurrently. - let futs = transactions + // Execute in transaction order, retaining all outcomes before reporting failures. + self.show_simulation_header()?; + let results = transactions .into_iter() - .map(|mut transaction| async { - let mut runner = runners.get(&transaction.rpc).expect("invalid rpc url").write(); - let tx = transaction.tx_mut(); + .map(|transaction| { + let rpc = transaction.rpc.clone(); + let runner = &mut contexts.get_mut(&rpc).expect("invalid rpc url").runner; - let to = if let Some(TxKind::Call(to)) = tx.to() { Some(to) } else { None }; + let tx = transaction.tx(); + let to = tx.to(); let result = runner - .simulate( - tx.from() - .expect("transaction doesn't have a `from` address at execution time"), - to, - tx.input().map(Bytes::copy_from_slice), - tx.value(), - tx.authorization_list(), - ) + .simulate(tx, transaction.is_fixed_gas_limit) .wrap_err("Internal EVM error during simulation")?; if !result.success { - return Ok((None, false, result.traces)); + return Ok((rpc, None, false, result.traces)); } // Simulate mining the transaction if the user passes `--slow`. if self.args.slow { - runner.executor.env_mut().evm_env.block_env.number += U256::from(1); + let block_number = runner.executor.evm_env().block_env.number() + U256::ONE; + runner.executor.evm_env_mut().block_env.set_number(block_number); } let is_noop_tx = if let Some(to) = to { @@ -149,27 +231,52 @@ impl PreSimulationState { }; let transaction = ScriptTransactionBuilder::from(transaction) - .with_execution_result(&result, self.args.gas_estimate_multiplier) + .with_execution_result( + &result, + self.args.gas_estimate_multiplier, + &self.build_data, + ) .build(); - eyre::Ok((Some(transaction), is_noop_tx, result.traces)) + eyre::Ok((rpc, Some(transaction), is_noop_tx, result.traces)) }) .collect::>(); - if !shell::is_json() && self.script_config.evm_opts.verbosity > 3 { + self.collect_simulation_results(results, &contexts).await + } + + fn show_simulation_header(&self) -> Result<()> { + if !shell::is_json() && self.script_config.config.tracing.verbosity > 3 { sh_println!("==========================")?; sh_println!("Simulated On-chain Traces:\n")?; } + Ok(()) + } + + async fn collect_simulation_results( + &self, + results: Vec>>, + contexts: &HashMap>, + ) -> Result>> { + let mut final_txs = VecDeque::new(); + let tracing = &self.script_config.config.tracing; let mut abort = false; - for res in join_all(futs).await { - let (tx, is_noop_tx, mut traces) = res?; + for res in results { + let (rpc, tx, is_noop_tx, mut traces) = res?; // Transaction will be `None`, if execution didn't pass. - if tx.is_none() || self.script_config.evm_opts.verbosity > 3 { + if !shell::is_json() && (tx.is_none() || tracing.verbosity > 3) { + let decoder = &contexts.get(&rpc).expect("invalid rpc url").decoder; for (_, trace) in &mut traces { - decode_trace_arena(trace, &self.execution_artifacts.decoder).await; - sh_println!("{}", render_trace_arena(trace))?; + decode_trace_arena(trace, decoder).await; + if let Some(trace_depth) = tracing.trace_depth { + prune_trace_depth(trace, trace_depth); + } + sh_println!( + "{}", + render_trace_arena_inner(trace, false, tracing.verbosity > 4) + )?; } } @@ -198,7 +305,7 @@ impl PreSimulationState { } if abort { - eyre::bail!("Simulated execution failed.") + eyre::bail!("Simulated execution failed."); } Ok(final_txs) @@ -222,44 +329,316 @@ impl PreSimulationState { } /// Build [ScriptRunner] forking given RPC for each RPC used in the script. - async fn build_runners(&self) -> Result> { - let rpcs = self.execution_artifacts.rpc_data.total_rpcs.clone(); + async fn build_runners( + &self, + ) -> Result>)>> { + let rpcs = &self.execution_artifacts.rpc_data.total_rpcs; if !shell::is_json() { let n = rpcs.len(); - let s = if n != 1 { "s" } else { "" }; + let s = if n == 1 { "" } else { "s" }; sh_println!("\n## Setting up {n} EVM{s}.")?; } - let futs = rpcs.into_iter().map(|rpc| async move { - let mut script_config = self.script_config.clone(); - script_config.evm_opts.fork_url = Some(rpc.clone()); - let runner = script_config.get_runner().await?; - Ok((rpc.clone(), runner)) - }); - try_join_all(futs).await + // Context construction performs several identity and block probes per endpoint. Resolve + // endpoints serially so setup does not create an unbounded cross-endpoint request burst. + let mut contexts = Vec::with_capacity(rpcs.len()); + for rpc in rpcs.iter().cloned() { + contexts.push( + build_rpc_simulation_context( + rpc, + &self.args, + &self.script_config, + &self.build_data.known_contracts, + &self.build_data.sources, + &self.execution_result, + ) + .await?, + ); + } + Ok(contexts) + } + + /// Builds one trace decoder for every RPC without constructing simulation runners. + async fn build_rpc_decoders(&self) -> Result> { + let rpcs = &self.execution_artifacts.rpc_data.total_rpcs; + // Decoder construction resolves the same endpoint context as a simulation runner. + let mut decoders = HashMap::default(); + for rpc in rpcs.iter().cloned() { + let (rpc, decoder) = build_rpc_decoder( + rpc, + &self.args, + &self.script_config, + &self.build_data.known_contracts, + &self.build_data.sources, + &self.execution_result, + ) + .await?; + decoders.insert(rpc, decoder); + } + Ok(decoders) + } +} + +#[cfg(all(test, feature = "monad"))] +mod tests { + use super::*; + use alloy_primitives::address; + use anvil::{NodeConfig, spawn}; + use foundry_cli::opts::TempoOpts; + use foundry_config::Config; + use foundry_evm::{ + core::{evm::MonadEvmNetwork, opts::EvmOpts}, + executors::ExecutorBuilder, + hardforks::MonadHardfork, + }; + use foundry_evm_networks::NetworkConfigs; + + const RESERVE_BALANCE_ADDRESS: Address = address!("0000000000000000000000000000000000001001"); + + #[tokio::test(flavor = "multi_thread")] + async fn multi_rpc_fork_selects_trace_decoder_for_source_hardfork() { + let (monad_eight_api, monad_eight) = spawn( + NodeConfig::test_monad() + .with_chain_id(Some(NamedChain::Monad as u64)) + .with_hardfork(Some(MonadHardfork::MonadEight.into())), + ) + .await; + let (monad_nine_api, monad_nine) = spawn( + NodeConfig::test_monad() + .with_chain_id(Some(NamedChain::Monad as u64)) + .with_hardfork(Some(MonadHardfork::MonadNine.into())), + ) + .await; + monad_eight_api.mine_one().await.unwrap(); + monad_nine_api.mine_one().await.unwrap(); + let monad_eight_rpc = monad_eight.http_endpoint(); + let monad_nine_rpc = monad_nine.http_endpoint(); + + let mut evm_opts = EvmOpts { + fork_url: Some(monad_eight_rpc.clone()), + fork_block_number: Some(0), + networks: NetworkConfigs::with_monad(), + ..Default::default() + }; + evm_opts.env.chain_id = Some(42); + let script_config = ScriptConfig::::new( + Config::default(), + evm_opts, + ExecutorBuilder::::new(), + false, + TempoOpts::default(), + Some(0), + ) + .await + .unwrap(); + let args = ScriptArgs::default(); + let known_contracts = ContractsByArtifact::default(); + let sources = ContractSources::default(); + let execution_result = ScriptResult::default(); + let contexts = [ + build_rpc_simulation_context( + monad_eight_rpc.clone(), + &args, + &script_config, + &known_contracts, + &sources, + &execution_result, + ) + .await + .unwrap(), + build_rpc_simulation_context( + monad_nine_rpc.clone(), + &args, + &script_config, + &known_contracts, + &sources, + &execution_result, + ) + .await + .unwrap(), + ] + .into_iter() + .collect::>(); + + let monad_eight = contexts.get(&monad_eight_rpc).unwrap(); + let monad_eight_runner = &monad_eight.runner; + assert_eq!(monad_eight_runner.executor.evm_env().cfg_env.chain_id, 42); + assert_eq!(monad_eight_runner.executor.evm_env().block_env.number(), U256::ZERO); + assert_eq!(monad_eight_runner.evm_opts.fork_block_number, Some(0)); + assert!(!monad_eight_runner.evm_opts.fork_block_number_is_inferred); + assert_eq!(monad_eight_runner.evm_opts.networks, NetworkConfigs::with_monad()); + assert!(!monad_eight_runner.evm_opts.fork_network_is_inferred); + assert_eq!(monad_eight.decoder.chain_id, Some(NamedChain::Monad as u64)); + assert_eq!(monad_eight.decoder.hardfork(), Some(MonadHardfork::MonadEight.into())); + assert!(!monad_eight.decoder.precompile_labels().contains_key(&RESERVE_BALANCE_ADDRESS)); + + let monad_nine = contexts.get(&monad_nine_rpc).unwrap(); + let monad_nine_runner = &monad_nine.runner; + assert_eq!(monad_nine_runner.executor.evm_env().cfg_env.chain_id, 42); + assert_eq!(monad_nine_runner.executor.evm_env().block_env.number(), U256::ZERO); + assert_eq!(monad_nine_runner.evm_opts.fork_block_number, Some(0)); + assert!(!monad_nine_runner.evm_opts.fork_block_number_is_inferred); + assert_eq!(monad_nine_runner.evm_opts.networks, NetworkConfigs::with_monad()); + assert!(!monad_nine_runner.evm_opts.fork_network_is_inferred); + assert_eq!(monad_nine.decoder.chain_id, Some(NamedChain::Monad as u64)); + assert_eq!(monad_nine.decoder.hardfork(), Some(MonadHardfork::MonadNine.into())); + assert_eq!( + monad_nine.decoder.precompile_labels().get(&RESERVE_BALANCE_ADDRESS), + Some(&"ReserveBalance".to_string()) + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn skip_simulation_fork_builds_per_rpc_trace_decoders() { + let (monad_eight_api, monad_eight) = spawn( + NodeConfig::test_monad() + .with_chain_id(Some(NamedChain::Monad as u64)) + .with_hardfork(Some(MonadHardfork::MonadEight.into())), + ) + .await; + let (monad_nine_api, monad_nine) = spawn( + NodeConfig::test_monad() + .with_chain_id(Some(NamedChain::Monad as u64)) + .with_hardfork(Some(MonadHardfork::MonadNine.into())), + ) + .await; + monad_eight_api.mine_one().await.unwrap(); + monad_nine_api.mine_one().await.unwrap(); + let monad_eight_rpc = monad_eight.http_endpoint(); + let monad_nine_rpc = monad_nine.http_endpoint(); + + let script_config = ScriptConfig::::new( + Config::default(), + EvmOpts { + fork_url: Some(monad_eight_rpc.clone()), + fork_block_number: Some(0), + networks: NetworkConfigs::with_monad(), + ..Default::default() + }, + ExecutorBuilder::::new(), + false, + TempoOpts::default(), + Some(0), + ) + .await + .unwrap(); + let args = ScriptArgs { skip_simulation: true, ..Default::default() }; + let known_contracts = ContractsByArtifact::default(); + let sources = ContractSources::default(); + let execution_result = ScriptResult::default(); + let decoders = [ + build_rpc_decoder( + monad_eight_rpc.clone(), + &args, + &script_config, + &known_contracts, + &sources, + &execution_result, + ) + .await + .unwrap(), + build_rpc_decoder( + monad_nine_rpc.clone(), + &args, + &script_config, + &known_contracts, + &sources, + &execution_result, + ) + .await + .unwrap(), + ] + .into_iter() + .collect::>(); + + let monad_eight = decoders.get(&monad_eight_rpc).unwrap(); + assert_eq!(monad_eight.hardfork(), Some(MonadHardfork::MonadEight.into())); + assert!(!monad_eight.precompile_labels().contains_key(&RESERVE_BALANCE_ADDRESS)); + + let monad_nine = decoders.get(&monad_nine_rpc).unwrap(); + assert_eq!(monad_nine.hardfork(), Some(MonadHardfork::MonadNine.into())); + assert_eq!( + monad_nine.precompile_labels().get(&RESERVE_BALANCE_ADDRESS), + Some(&"ReserveBalance".to_string()) + ); + } + + #[tokio::test(flavor = "multi_thread")] + async fn multi_rpc_fork_rejects_inferred_network_change() { + let (_monad_api, monad) = spawn(NodeConfig::test_monad()).await; + let (_ethereum_api, ethereum) = spawn(NodeConfig::test()).await; + let ethereum_rpc = ethereum.http_endpoint(); + let script_config = ScriptConfig::::new( + Config::default(), + EvmOpts { fork_url: Some(monad.http_endpoint()), ..Default::default() }, + ExecutorBuilder::::new(), + false, + TempoOpts::default(), + Some(0), + ) + .await + .unwrap(); + assert!(script_config.evm_opts.fork_network_is_inferred); + + let result = build_rpc_simulation_context( + ethereum_rpc.clone(), + &ScriptArgs::default(), + &script_config, + &ContractsByArtifact::default(), + &ContractSources::default(), + &ScriptResult::default(), + ) + .await; + let Err(error) = result else { panic!("inferred cross-network fork should be rejected") }; + assert!( + error + .to_string() + .contains("fork network `ethereum` is incompatible with the active EVM"), + "{error}" + ); + + let result = build_rpc_decoder( + ethereum_rpc, + &ScriptArgs { skip_simulation: true, ..Default::default() }, + &script_config, + &ContractsByArtifact::default(), + &ContractSources::default(), + &ScriptResult::default(), + ) + .await; + let Err(error) = result else { + panic!("skip-simulation decoder should reject an inferred cross-network fork") + }; + assert!( + error + .to_string() + .contains("fork network `ethereum` is incompatible with the active EVM"), + "{error}" + ); } } /// At this point we have converted transactions collected during script execution to /// [TransactionWithMetadata] objects which contain additional metadata needed for broadcasting and /// verification. -pub struct FilledTransactionsState { +pub struct FilledTransactionsState { pub args: ScriptArgs, - pub script_config: ScriptConfig, + pub script_config: ScriptConfig, pub script_wallets: Wallets, + pub browser_wallet: Option>, pub build_data: LinkedBuildData, pub execution_artifacts: ExecutionArtifacts, - pub transactions: VecDeque, + pub transactions: VecDeque>, } -impl FilledTransactionsState { +impl FilledTransactionsState { /// Bundles all transactions of the [`TransactionWithMetadata`] type in a list of /// [`ScriptSequence`]. List length will be higher than 1, if we're dealing with a multi /// chain deployment. /// /// Each transaction will be added with the correct transaction type and gas estimation. - pub async fn bundle(self) -> Result { + pub async fn bundle(mut self) -> Result> { let is_multi_deployment = self.execution_artifacts.rpc_data.total_rpcs.len() > 1; if is_multi_deployment && !self.build_data.libraries.is_empty() { @@ -270,16 +649,24 @@ impl FilledTransactionsState { // Batches sequence of transactions from different rpcs. let mut new_sequence = VecDeque::new(); - let mut manager = ProvidersManager::default(); + let mut manager = ProvidersManager::::default(); let mut sequences = vec![]; // Peeking is used to check if the next rpc url is different. If so, it creates a // [`ScriptSequence`] from all the collected transactions up to this point. - let mut txes_iter = self.transactions.clone().into_iter().peekable(); + let mut txes_iter = mem::take(&mut self.transactions).into_iter().peekable(); while let Some(mut tx) = txes_iter.next() { - let tx_rpc = tx.rpc.to_owned(); - let provider_info = manager.get_or_init_provider(&tx.rpc, self.args.legacy).await?; + let tx_rpc = tx.rpc.clone(); + let provider_info = manager + .get_or_init_provider( + &tx.rpc, + self.execution_artifacts.rpc_data.chain_ids.get(&tx.rpc).copied(), + self.args.legacy, + self.script_config.config.eip1559_fee_estimate, + &self.script_config.config, + ) + .await?; if let Some(tx) = tx.tx_mut().as_unsigned_mut() { // Handles chain specific requirements for unsigned transactions. @@ -287,13 +674,17 @@ impl FilledTransactionsState { } if !self.args.skip_simulation { + let is_fixed_gas_limit = tx.is_fixed_gas_limit; let tx = tx.tx_mut(); - if has_different_gas_calc(provider_info.chain) { + if !is_fixed_gas_limit + && (has_different_gas_calc(provider_info.chain) + || self.script_config.evm_opts.networks.is_tempo()) + { // only estimate gas for unsigned transactions if let Some(tx) = tx.as_unsigned_mut() { trace!("estimating with different gas calculation"); - let gas = tx.gas.expect("gas is set by simulation."); + let gas = tx.gas_limit().expect("gas is set by simulation."); // We are trying to show the user an estimation of the total gas usage. // @@ -310,6 +701,7 @@ impl FilledTransactionsState { tx, &provider_info.provider, self.args.gas_estimate_multiplier, + false, ) .await { @@ -347,68 +739,33 @@ impl FilledTransactionsState { for (rpc, total_gas) in total_gas_per_rpc { let provider_info = manager.get(&rpc).expect("provider is set."); - // Get the native token symbol for the chain using NamedChain - let token_symbol = NamedChain::try_from(provider_info.chain) - .unwrap_or_default() - .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 { - gas_price.to() - } else { - provider_info.gas_price()? - }; - - let estimated_gas_price_raw = format_units(per_gas, 9) - .unwrap_or_else(|_| "[Could not calculate]".to_string()); - let estimated_gas_price = - estimated_gas_price_raw.trim_end_matches('0').trim_end_matches('.'); - - let estimated_amount_raw = format_units(total_gas.saturating_mul(per_gas), 18) - .unwrap_or_else(|_| "[Could not calculate]".to_string()); - let estimated_amount = estimated_amount_raw.trim_end_matches('0'); - - if !shell::is_json() { - sh_println!("\n==========================")?; - sh_println!("\nChain {}", provider_info.chain)?; - - sh_println!("\nEstimated gas price: {} gwei", estimated_gas_price)?; - sh_println!("\nEstimated total gas used for script: {total_gas}")?; - sh_println!("\nEstimated amount required: {estimated_amount} {token_symbol}")?; - sh_println!("\n==========================")?; - } else { - sh_println!( - "{}", - serde_json::json!({ - "chain": provider_info.chain, - "estimated_gas_price": estimated_gas_price, - "estimated_total_gas_used": total_gas, - "estimated_amount_required": estimated_amount, - "token_symbol": token_symbol, - }) - )?; - } + self.print_gas_estimate(provider_info, total_gas).await?; } } let sequence = if sequences.len() == 1 { - ScriptSequenceKind::Single(sequences.pop().expect("empty sequences")) + ScriptSequenceKind::new_single( + sequences.pop().expect("empty sequences"), + self.args.batch, + )? } else { - ScriptSequenceKind::Multi(MultiChainSequence::new( - sequences, - &self.args.sig, - &self.build_data.build_data.target, - &self.script_config.config, - !self.args.broadcast, - )?) + ScriptSequenceKind::new_multi( + MultiChainSequence::new( + sequences, + &self.args.sig, + &self.build_data.build_data.target, + &self.script_config.config, + !self.args.broadcast, + )?, + self.args.batch, + )? }; Ok(BundledState { args: self.args, script_config: self.script_config, script_wallets: self.script_wallets, + browser_wallet: self.browser_wallet, build_data: self.build_data, sequence, }) @@ -419,14 +776,14 @@ impl FilledTransactionsState { &self, multi: bool, chain: u64, - transactions: VecDeque, - ) -> Result { + transactions: VecDeque>, + ) -> Result> { // Paths are set to None for multi-chain sequences parts, because they don't need to be // saved to a separate file. let paths = if multi { None } else { - Some(ScriptSequence::get_paths( + Some(ScriptSequence::::get_paths( &self.script_config.config, &self.args.sig, &self.build_data.build_data.target, @@ -437,6 +794,14 @@ impl FilledTransactionsState { let commit = get_commit_hash(&self.script_config.config.root); + let local_addresses = match &self.build_data.predeploy_libraries { + crate::build::ScriptPredeployLibraries::Default { local, .. } + | crate::build::ScriptPredeployLibraries::Create2 { local, .. } => local.as_slice(), + }; + let local_addresses = local_addresses + .iter() + .map(|library| library.address.to_checksum(None)) + .collect::>(); let libraries = self .build_data .libraries @@ -444,6 +809,7 @@ impl FilledTransactionsState { .iter() .flat_map(|(file, libs)| { libs.iter() + .filter(|(_, address)| !local_addresses.contains(address)) .map(|(name, address)| format!("{}:{name}:{address}", file.to_string_lossy())) }) .collect(); @@ -458,7 +824,119 @@ impl FilledTransactionsState { libraries, chain, commit, + recovery_generation: None, }; Ok(sequence) } + + /// Prints a gas estimate without changing the fees used for broadcasting. + async fn print_gas_estimate( + &self, + provider_info: &ProviderInfo, + total_gas: u128, + ) -> Result<()> { + let token_symbol = if self.script_config.evm_opts.networks.is_tempo() { + self.args.tempo.fee_token.map_or_else( + || "TIP-20".to_string(), + |fee_token| { + known_fee_token_symbol(fee_token) + .map(str::to_string) + .unwrap_or_else(|| fee_token.to_string()) + }, + ) + } else { + NamedChain::try_from(provider_info.chain) + .unwrap_or_default() + .native_currency_symbol() + .unwrap_or("ETH") + .to_string() + }; + + // We don't store it in the transactions, since we want the most updated value. + // Right before broadcasting. + // + // Resolve the fees with the same overrides as the broadcast path so the + // displayed values match what is sent. Skipped when `--with-gas-price` pins + // the max fee directly. + let resolved_eip1559_fees = if self.args.with_gas_price.is_none() { + if let Some(fees) = provider_info.eip1559_fees().copied() { + // `--batch` broadcasts via `broadcast_batch`, which applies no + // browser tip, so skip it here too. Best-effort. + let browser_suggested_tip = if !self.args.batch && self.browser_wallet.is_some() { + provider_info.provider.get_max_priority_fee_per_gas().await.ok() + } else { + None + }; + Some(resolve_broadcast_eip1559_fees( + fees, + None, + self.args.priority_gas_price.map(|p| p.to()), + browser_suggested_tip, + )?) + } else { + None + } + } else { + None + }; + + // `per_gas` is the legacy gas price or, for EIP-1559, the `maxFeePerGas` + // (a base-fee buffer plus the priority fee), which is what the transaction + // can pay at most -- not the spot base fee shown by block explorers. + let per_gas = if let Some(gas_price) = self.args.with_gas_price { + gas_price.to() + } else if let Some(fees) = &resolved_eip1559_fees { + fees.max_fee_per_gas + } else { + provider_info.gas_price()? + }; + + // Format a wei value as a trimmed gwei string. + let fmt_gwei = |wei: u128| { + let raw = format_units(wei, 9).unwrap_or_else(|_| "[Could not calculate]".to_string()); + raw.trim_end_matches('0').trim_end_matches('.').to_string() + }; + + let estimated_gas_price = fmt_gwei(per_gas); + + // (base fee, max priority fee) for the EIP-1559 breakdown. + let fee_breakdown = resolved_eip1559_fees + .map(|fees| (fmt_gwei(fees.base_fee_per_gas), fmt_gwei(fees.max_priority_fee_per_gas))); + + let estimated_amount_raw = format_units(total_gas.saturating_mul(per_gas), 18) + .unwrap_or_else(|_| "[Could not calculate]".to_string()); + let estimated_amount = estimated_amount_raw.trim_end_matches('0'); + + if shell::is_json() { + let mut json = serde_json::json!({ + "chain": provider_info.chain, + "estimated_gas_price": estimated_gas_price, + "estimated_total_gas_used": total_gas, + "estimated_amount_required": estimated_amount, + "token_symbol": token_symbol, + }); + if let Some((base_fee, priority_fee)) = &fee_breakdown { + json["estimated_max_fee_per_gas"] = serde_json::Value::from(estimated_gas_price); + json["estimated_base_fee_per_gas"] = serde_json::Value::from(base_fee.clone()); + json["estimated_max_priority_fee_per_gas"] = + serde_json::Value::from(priority_fee.clone()); + } + sh_println!("{}", json)?; + } else { + sh_println!("\n==========================")?; + sh_println!("\nChain {}", provider_info.chain)?; + + if let Some((base_fee, priority_fee)) = &fee_breakdown { + sh_println!("\nEstimated max fee per gas: {estimated_gas_price} gwei")?; + sh_println!("Estimated base fee per gas: {base_fee} gwei")?; + sh_println!("Estimated max priority fee per gas: {priority_fee} gwei")?; + } else { + sh_println!("\nEstimated gas price: {estimated_gas_price} gwei")?; + } + sh_println!("\nEstimated total gas used for script: {total_gas}")?; + sh_println!("\nEstimated amount required: {estimated_amount} {token_symbol}")?; + sh_println!("\n==========================")?; + } + Ok(()) + } } diff --git a/crates/script/src/simulate/monad.rs b/crates/script/src/simulate/monad.rs new file mode 100644 index 0000000000000..808ddf6f3cd90 --- /dev/null +++ b/crates/script/src/simulate/monad.rs @@ -0,0 +1,468 @@ +//! Sequential Monad simulation owns its block cursor independently of local script execution. + +use super::{PreSimulationState, RpcSimulationContext}; +use crate::{ + ScriptResult, + runner::{GasSearch, ScriptRunner, needs_more_gas}, + simulate::FilledTransactionsState, + transaction::ScriptTransactionBuilder, +}; +use alloy_eips::eip7702::SignedAuthorization; +use alloy_evm::EvmEnv; +use alloy_network::Ethereum; +use alloy_primitives::{Address, Bytes, TxKind, U256, map::HashMap}; +use eyre::{Result, WrapErr}; +use foundry_common::TransactionMaybeSigned; +use foundry_evm::{ + backend::DatabaseExt, + core::{ + FoundryBlock, FoundryTransaction, + evm::{BlockContext, MonadEvmNetwork}, + }, + executors::{DeployResult, EvmError}, + revm::{ + context::{Transaction, TxEnv}, + context_interface::result::Output, + interpreter::return_ok, + }, +}; +use monad_revm::{MonadChainContext, MonadHardfork}; + +struct MonadSimulation { + runner: ScriptRunner, + cursor: Option>, +} + +impl MonadSimulation { + fn new(runner: ScriptRunner) -> Result { + let cursor = runner.executor.backend().block_context_for_synthetic_transaction()?; + Ok(Self { runner, cursor }) + } + + fn context(&self, tx: &TxEnv) -> Result { + self.cursor.as_ref().map_or_else( + || self.runner.executor.backend().chain_context_for_synthetic_transaction(tx), + |cursor| Ok(cursor.next_transaction(tx)), + ) + } + + fn record(&mut self, tx: TxEnv) { + if let Some(cursor) = &mut self.cursor { + cursor.record_transaction(tx); + } + } + + fn prepare_call( + &self, + from: Address, + to: Address, + calldata: Bytes, + value: U256, + authorization_list: Option>, + ) -> (EvmEnv, TxEnv) { + let (env, mut tx) = self.runner.executor.prepare_call_env(from, to.into(), calldata, value); + if let Some(authorization_list) = authorization_list { + tx.set_signed_authorization(authorization_list); + tx.set_tx_type(4); + } + (env, tx) + } + + fn simulate( + &mut self, + transaction: &TransactionMaybeSigned, + is_fixed_gas_limit: bool, + ) -> Result> { + let from = transaction.from().expect("transaction sender missing"); + let calldata = transaction.input().cloned(); + let value = transaction.value().unwrap_or_default(); + let authorization_list = transaction.authorization_list(); + let Some(to) = transaction.to() else { + let (env, tx) = if is_fixed_gas_limit { + self.runner.prepare_fixed_gas_env(transaction) + } else { + self.runner.executor.prepare_call_env( + from, + TxKind::Create, + calldata.expect("No data for create transaction"), + value, + ) + }; + let context = self.context(&tx)?; + let result = self + .runner + .executor + .transact_with_env_and_context(env, tx, context) + .map_err(EvmError::from) + .and_then(|raw| { + // Record before result conversion: even a skip/revert has already committed. + self.record(raw.tx_env.clone()); + let raw = raw.into_result(None)?; + let Some(Output::Create(_, Some(address))) = raw.out else { + panic!("Deployment succeeded, but no address was returned: {raw:#?}"); + }; + self.runner.executor.backend_mut().add_persistent_account(address); + Ok(DeployResult { raw, address }) + }); + return self.runner.deployment_result(result); + }; + + if is_fixed_gas_limit { + let (env, tx) = self.runner.prepare_fixed_gas_env(transaction); + let context = self.context(&tx)?; + let result = self.runner.executor.transact_with_env_and_context(env, tx, context)?; + self.record(result.tx_env.clone()); + let gas_used = result.gas_used; + return Ok(self.runner.call_result(result, gas_used)); + } + + let calldata = calldata.unwrap_or_default(); + let (env, tx) = + self.prepare_call(from, to, calldata.clone(), value, authorization_list.clone()); + let context = self.context(&tx)?; + let result = self.runner.executor.call_with_env_and_context(env, tx, context)?; + let mut gas_used = result.gas_used; + if matches!(result.exit_reason, Some(return_ok!())) { + let initial_limit = self.runner.executor.tx_env().gas_limit(); + let mut search = GasSearch::new(gas_used); + while let Some(limit) = search.next_limit() { + // Keep the existing gas-probe preparation and authorization behavior unchanged. + self.runner.executor.tx_env_mut().set_gas_limit(limit); + let (env, tx) = self.prepare_call(from, to, calldata.clone(), value, None); + let context = self.context(&tx)?; + let result = self.runner.executor.call_with_env_and_context(env, tx, context)?; + search.record(limit, needs_more_gas(result.exit_reason)); + } + gas_used = search.gas_used(); + self.runner.executor.tx_env_mut().set_gas_limit(initial_limit); + } + + let (env, tx) = self.prepare_call(from, to, calldata, value, authorization_list); + let context = self.context(&tx)?; + let result = self.runner.executor.transact_with_env_and_context(env, tx, context)?; + self.record(result.tx_env.clone()); + Ok(self.runner.call_result(result, gas_used)) + } + + fn advance_block(&mut self) { + if let Some(cursor) = &mut self.cursor { + cursor.advance_block(); + } + let block = &mut self.runner.executor.evm_env_mut().block_env; + block.set_number(block.number + U256::ONE); + } +} + +impl PreSimulationState { + pub(crate) async fn fill_monad_metadata( + self, + ) -> Result> { + if self.args.skip_simulation { + return self.fill_without_simulation().await; + } + + let mut contexts = HashMap::default(); + for (rpc, context) in self.build_runners().await? { + contexts.insert( + rpc, + RpcSimulationContext { + runner: MonadSimulation::new(context.runner)?, + decoder: context.decoder, + }, + ); + } + let transactions = + self.transaction_metadata(|rpc| &contexts.get(rpc).expect("invalid rpc url").decoder)?; + self.show_simulation_header()?; + let results = transactions + .into_iter() + .map(|transaction| { + let rpc = transaction.rpc.clone(); + let simulation = &mut contexts.get_mut(&rpc).expect("invalid rpc url").runner; + + let tx = transaction.tx(); + let to = tx.to(); + let result = simulation + .simulate(tx, transaction.is_fixed_gas_limit) + .wrap_err("Internal EVM error during simulation")?; + if !result.success { + return Ok((rpc, None, false, result.traces)); + } + if self.args.slow { + simulation.advance_block(); + } + let is_noop = if let Some(to) = to { + simulation.runner.executor.is_empty_code(to)? + && tx.value().unwrap_or_default().is_zero() + } else { + false + }; + let transaction = ScriptTransactionBuilder::from(transaction) + .with_execution_result( + &result, + self.args.gas_estimate_multiplier, + &self.build_data, + ) + .build(); + eyre::Ok((rpc, Some(transaction), is_noop, result.traces)) + }) + .collect::>(); + let transactions = self.collect_simulation_results(results, &contexts).await?; + Ok(self.into_filled(transactions)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_eips::eip2930::{AccessList, AccessListItem}; + use alloy_network::TransactionBuilder; + use alloy_primitives::{B256, bytes}; + use alloy_rpc_types::TransactionRequest; + use foundry_evm::{ + backend::Backend, + executors::ExecutorBuilder, + opts::EvmOpts, + revm::{DatabaseRef, bytecode::Bytecode, interpreter::InstructionResult}, + }; + use foundry_evm_networks::NetworkConfigs; + + fn simulation() -> MonadSimulation { + let mut env = EvmEnv::::default(); + // Match the simulation environment produced by EvmOpts. + env.cfg_env.disable_nonce_check = true; + let executor = ExecutorBuilder::::new().gas_limit(1 << 20).build( + env, + TxEnv::default(), + Backend::spawn(None).unwrap(), + NetworkConfigs::with_monad(), + ); + let runner = ScriptRunner::new(executor, EvmOpts::default()); + let mut simulation = MonadSimulation::new(runner).unwrap(); + assert!(simulation.cursor.is_none()); + simulation.cursor = Some(BlockContext::new(Vec::new(), Vec::new(), Vec::new())); + simulation + } + + fn assert_transaction_count(simulation: &MonadSimulation, count: usize) { + let cursor = simulation.cursor.as_ref().unwrap(); + assert!(cursor.clone().before_transaction(count).is_ok()); + assert!(cursor.clone().before_transaction(count + 1).is_err()); + } + + #[test] + fn calls_and_gas_probes_record_only_committed_transactions() { + let other = simulation(); + let mut simulation = simulation(); + let sender = Address::with_last_byte(0x42); + let recipient = Address::with_last_byte(0x43); + simulation.runner.executor.set_balance(sender, U256::MAX).unwrap(); + let result = + simulation.simulate(&transaction(sender, Some(recipient), None), false).unwrap(); + assert!(result.success); + assert_transaction_count(&simulation, 1); + assert_eq!(simulation.runner.executor.get_nonce(sender).unwrap(), 1); + + simulation.advance_block(); + assert_transaction_count(&simulation, 0); + assert_eq!(simulation.runner.executor.evm_env().block_env.number, U256::ONE); + assert_transaction_count(&other, 0); + } + + #[test] + fn deployment_and_reverted_call_both_advance_the_cursor() { + let mut simulation = simulation(); + let sender = Address::with_last_byte(0x42); + simulation.runner.executor.set_balance(sender, U256::MAX).unwrap(); + // Deploy runtime code that always reverts. + let initcode = bytes!("6005600c60003960056000f360006000fd"); + let deployment = + simulation.simulate(&transaction(sender, None, Some(initcode)), false).unwrap(); + assert!(deployment.success); + assert_transaction_count(&simulation, 1); + let address = deployment.address.unwrap(); + assert!(!simulation.runner.executor.is_empty_code(address).unwrap()); + let result = simulation.simulate(&transaction(sender, Some(address), None), false).unwrap(); + assert!(!result.success); + assert_transaction_count(&simulation, 2); + assert_eq!(simulation.runner.executor.get_nonce(sender).unwrap(), 2); + } + + #[test] + fn validation_failure_does_not_advance_the_cursor() { + let mut simulation = simulation(); + simulation.runner.executor.set_gas_limit(1); + let sender = Address::with_last_byte(0x42); + let result = simulation + .simulate(&transaction(sender, Some(Address::with_last_byte(0x43)), None), false); + assert!(result.is_err()); + assert_transaction_count(&simulation, 0); + assert_eq!(simulation.runner.executor.get_nonce(sender).unwrap(), 0); + } + + #[test] + fn authorization_preparation_preserves_explicit_empty_list() { + let simulation = simulation(); + let (_, tx) = simulation.prepare_call( + Address::with_last_byte(0x42), + Address::with_last_byte(0x43), + Bytes::new(), + U256::ZERO, + Some(Vec::new()), + ); + assert_eq!(tx.tx_type(), 4); + assert_transaction_count(&simulation, 0); + } + + fn transaction( + from: Address, + to: Option
, + input: Option, + ) -> TransactionMaybeSigned { + TransactionMaybeSigned::new( + TransactionRequest::default() + .with_from(from) + .with_kind(to.map_or(TxKind::Create, TxKind::Call)) + .with_input(input.unwrap_or_default()), + ) + } + + #[test] + fn fixed_gas_calls_and_deployments_preserve_block_gas_limit() { + let mut simulation = simulation(); + let sender = Address::with_last_byte(0x42); + let block_gas_limit = 30_000_000; + simulation.runner.executor.set_balance(sender, U256::MAX).unwrap(); + simulation.runner.executor.evm_env_mut().block_env.set_gas_limit(block_gas_limit); + let executor_gas_limit = simulation.runner.executor.gas_limit(); + + // The constructor stores GASLIMIT in slot zero; the runtime returns GASLIMIT. + let initcode = bytes!("456000556009601060003960096000f34560005260206000f3"); + let deployment = simulation + .simulate(&fixed_transaction(sender, None, Some(initcode), 100_000), true) + .unwrap(); + assert!(deployment.success); + let address = deployment.address.unwrap(); + assert_eq!( + simulation.runner.executor.backend().storage_ref(address, U256::ZERO).unwrap(), + U256::from(block_gas_limit), + ); + assert_transaction_count(&simulation, 1); + assert_eq!(simulation.runner.executor.get_nonce(sender).unwrap(), 1); + + let result = simulation + .simulate(&fixed_transaction(sender, Some(address), None, 50_000), true) + .unwrap(); + assert!(result.success); + assert_eq!(U256::from_be_slice(&result.returned), U256::from(block_gas_limit)); + assert_transaction_count(&simulation, 2); + assert_eq!(simulation.runner.executor.get_nonce(sender).unwrap(), 2); + assert_eq!(simulation.runner.executor.gas_limit(), executor_gas_limit); + assert_eq!(simulation.runner.executor.evm_env().block_env.gas_limit, block_gas_limit); + } + + #[test] + fn fixed_gas_access_lists_do_not_leak_to_following_transactions() { + let mut simulation = simulation(); + simulation.runner.executor.set_spec_id(MonadHardfork::MonadTen); + let sender = Address::with_last_byte(0x42); + let recipient = Address::with_last_byte(0x43); + simulation.runner.executor.set_balance(sender, U256::MAX).unwrap(); + let executor_gas_limit = simulation.runner.executor.gas_limit(); + + // Read 32 distinct storage pages: warm reads fit the cap, while cold reads do not. + // MonadTen warms all 128 slots in a page together. + let mut code = Vec::new(); + for page in 0..32u16 { + code.push(0x61); + code.extend_from_slice(&(page * 128).to_be_bytes()); + code.extend_from_slice(&[0x54, 0x50]); + } + code.push(0x00); + simulation.runner.executor.set_code(recipient, Bytecode::new_raw(code.into())).unwrap(); + let access_list = AccessList(vec![AccessListItem { + address: recipient, + storage_keys: (0..32).map(|page| B256::from(U256::from(page * 128))).collect(), + }]); + let mut transaction = TransactionMaybeSigned::new( + TransactionRequest::default() + .with_from(sender) + .with_to(recipient) + .with_gas_price(0) + .with_gas_limit(100_000) + .with_access_list(access_list), + ); + let result = simulation.simulate(&transaction, true).unwrap(); + assert!(result.success); + assert_transaction_count(&simulation, 1); + assert_eq!(simulation.runner.executor.get_nonce(sender).unwrap(), 1); + + transaction.as_unsigned_mut().unwrap().access_list = None; + let result = simulation.simulate(&transaction, true).unwrap(); + assert!(!result.success); + assert_eq!(result.exit_reason, Some(InstructionResult::OutOfGas)); + assert_transaction_count(&simulation, 2); + assert_eq!(simulation.runner.executor.get_nonce(sender).unwrap(), 2); + + let result = simulation.simulate(&transaction, false).unwrap(); + assert!(result.success); + assert_transaction_count(&simulation, 3); + assert_eq!(simulation.runner.executor.get_nonce(sender).unwrap(), 3); + assert_eq!(simulation.runner.executor.gas_limit(), executor_gas_limit); + } + + #[test] + fn fixed_gas_validation_failure_does_not_advance_the_cursor() { + let mut simulation = simulation(); + let sender = Address::with_last_byte(0x42); + let recipient = Address::with_last_byte(0x43); + simulation.runner.executor.set_balance(sender, U256::MAX).unwrap(); + let result = + simulation.simulate(&fixed_transaction(sender, Some(recipient), None, 1), true); + assert!(result.is_err()); + assert_transaction_count(&simulation, 0); + assert_eq!(simulation.runner.executor.get_nonce(sender).unwrap(), 0); + + let result = simulation + .simulate(&fixed_transaction(sender, Some(recipient), None, 21_000), true) + .unwrap(); + assert!(result.success); + assert_transaction_count(&simulation, 1); + assert_eq!(simulation.runner.executor.get_nonce(sender).unwrap(), 1); + } + + fn fixed_transaction( + from: Address, + to: Option
, + input: Option, + gas_limit: u64, + ) -> TransactionMaybeSigned { + let mut transaction = transaction(from, to, input); + transaction.as_unsigned_mut().unwrap().set_gas_limit(gas_limit); + transaction + } + + #[test] + fn fixed_gas_failed_deployments_advance_the_cursor() { + let mut simulation = simulation(); + let sender = Address::with_last_byte(0x42); + simulation.runner.executor.set_balance(sender, U256::MAX).unwrap(); + + for (index, (initcode, exit_reason)) in [ + (bytes!("60006000fd"), InstructionResult::Revert), + (bytes!("5b600056"), InstructionResult::OutOfGas), + ] + .into_iter() + .enumerate() + { + let result = simulation + .simulate(&fixed_transaction(sender, None, Some(initcode), 60_000), true) + .unwrap(); + assert!(!result.success); + assert_eq!(result.exit_reason, Some(exit_reason)); + assert_eq!(result.address, Some(Address::ZERO)); + assert_transaction_count(&simulation, index + 1); + assert_eq!(simulation.runner.executor.get_nonce(sender).unwrap(), index as u64 + 1); + } + } +} diff --git a/crates/script/src/transaction.rs b/crates/script/src/transaction.rs index daabd681ef5c7..002bf95180557 100644 --- a/crates/script/src/transaction.rs +++ b/crates/script/src/transaction.rs @@ -1,21 +1,22 @@ use super::ScriptResult; +use crate::build::LinkedBuildData; use alloy_dyn_abi::JsonAbiExt; -use alloy_primitives::{Address, B256, TxKind, hex}; +use alloy_network::{Network, TransactionBuilder}; +use alloy_primitives::{Address, B256, Selector, hex}; use eyre::Result; use forge_script_sequence::TransactionWithMetadata; use foundry_common::{ContractData, SELECTOR_LEN, TransactionMaybeSigned, fmt::format_token_raw}; -use foundry_evm::traces::CallTraceDecoder; +use foundry_evm::traces::{CallKind, CallTraceDecoder}; use itertools::Itertools; -use revm_inspectors::tracing::types::CallKind; use std::collections::BTreeMap; #[derive(Debug)] -pub struct ScriptTransactionBuilder { - transaction: TransactionWithMetadata, +pub struct ScriptTransactionBuilder { + transaction: TransactionWithMetadata, } -impl ScriptTransactionBuilder { - pub fn new(transaction: TransactionMaybeSigned, rpc: String) -> Self { +impl ScriptTransactionBuilder { + pub fn new(transaction: TransactionMaybeSigned, rpc: String) -> Self { let mut transaction = TransactionWithMetadata::from_tx_request(transaction); transaction.rpc = rpc; // If tx.gas is already set that means it was specified in script @@ -31,9 +32,14 @@ impl ScriptTransactionBuilder { decoder: &CallTraceDecoder, create2_deployer: Address, ) -> Result<()> { - if let Some(TxKind::Call(to)) = self.transaction.transaction.to() { + if let Some(to) = self.transaction.transaction.to() { + self.transaction.call_kind = CallKind::Call; + self.transaction.contract_address = Some(to); + if to == create2_deployer { - if let Some(input) = self.transaction.transaction.input() { + if let Some(input) = self.transaction.transaction.input() + && input.len() >= 32 + { let (salt, init_code) = input.split_at(32); self.set_create( @@ -41,11 +47,14 @@ impl ScriptTransactionBuilder { create2_deployer.create2_from_code(B256::from_slice(salt), init_code), local_contracts, )?; + } else { + let input_len = + self.transaction.transaction.input().map_or(0, |input| input.len()); + sh_warn!( + "Skipping CREATE2 decoding for call to deployer {create2_deployer}: input length {input_len} is shorter than the 32-byte salt prefix" + )?; } } else { - self.transaction.opcode = CallKind::Call; - self.transaction.contract_address = Some(to); - let Some(data) = self.transaction.transaction.input() else { return Ok(()) }; if data.len() < SELECTOR_LEN { @@ -53,6 +62,7 @@ impl ScriptTransactionBuilder { } let (selector, data) = data.split_at(SELECTOR_LEN); + let selector = Selector::from_slice(selector); let function = if let Some(info) = local_contracts.get(&to) { // This CALL is made to a local contract. @@ -61,11 +71,15 @@ impl ScriptTransactionBuilder { } else { // This CALL is made to an external contract; try to decode it from the given // decoder. - decoder.functions.get(selector).and_then(|v| v.first()) + decoder + .functions_for_selector(to, &selector) + .and_then(|functions| functions.first()) }; if let Some(function) = function { self.transaction.function = Some(function.signature()); + self.transaction.function_abi = Some(function.full_signature()); + self.transaction.display_function = Some(function.name.clone()); let values = function.abi_decode_input(data).inspect_err(|_| { error!( @@ -95,9 +109,9 @@ impl ScriptTransactionBuilder { contracts: &BTreeMap, ) -> Result<()> { if is_create2 { - self.transaction.opcode = CallKind::Create2; + self.transaction.call_kind = CallKind::Create2; } else { - self.transaction.opcode = CallKind::Create; + self.transaction.call_kind = CallKind::Create; } let info = contracts.get(&address); @@ -144,10 +158,12 @@ impl ScriptTransactionBuilder { /// Populates additional data from the transaction execution result. pub fn with_execution_result( mut self, - result: &ScriptResult, + result: &ScriptResult, gas_estimate_multiplier: u64, + linked_build_data: &LinkedBuildData, ) -> Self { - let mut created_contracts = result.get_created_contracts(); + let mut created_contracts = + result.get_created_contracts(&linked_build_data.known_contracts); // Add the additional contracts created in this transaction, so we can verify them later. created_contracts.retain(|contract| { @@ -161,19 +177,135 @@ impl ScriptTransactionBuilder { && let Some(unsigned) = self.transaction.transaction.as_unsigned_mut() { // We inflate the gas used by the user specified percentage - unsigned.gas = Some(result.gas_used * gas_estimate_multiplier / 100); + unsigned.set_gas_limit(result.gas_used * gas_estimate_multiplier / 100); } self } - pub fn build(self) -> TransactionWithMetadata { + pub fn build(self) -> TransactionWithMetadata { self.transaction } } -impl From for ScriptTransactionBuilder { - fn from(transaction: TransactionWithMetadata) -> Self { +impl From> for ScriptTransactionBuilder { + fn from(transaction: TransactionWithMetadata) -> Self { Self { transaction } } } + +#[cfg(test)] +mod tests { + use super::*; + use alloy_network::Ethereum; + use alloy_primitives::{Bytes, address}; + use alloy_rpc_types::TransactionRequest; + + fn call_to_create2_deployer(input: Option) -> TransactionWithMetadata { + let create2_deployer = address!("0000000000000000000000000000000000001234"); + let mut transaction = TransactionRequest::default() + .with_from(Address::repeat_byte(0x11)) + .with_to(create2_deployer) + .with_nonce(0); + if let Some(input) = input { + transaction = transaction.with_input(input); + } + let mut builder = ScriptTransactionBuilder::::new( + TransactionMaybeSigned::new(transaction), + "http://localhost:8545".to_string(), + ); + let decoder = CallTraceDecoder::new(); + builder.set_call(&BTreeMap::new(), decoder, create2_deployer).unwrap(); + builder.build() + } + + #[test] + fn short_create2_input_is_classified_as_call() { + let create2_deployer = address!("0000000000000000000000000000000000001234"); + for input in [None, Some(Bytes::new()), Some(Bytes::from(vec![0xab; 31]))] { + let transaction = call_to_create2_deployer(input); + assert_eq!(transaction.call_kind, CallKind::Call); + assert_eq!(transaction.contract_address, Some(create2_deployer)); + } + } + + #[test] + fn valid_create2_input_is_classified_as_create2() { + let create2_deployer = address!("0000000000000000000000000000000000001234"); + for input in [Bytes::from(vec![0xab; 32]), Bytes::from(vec![0xab; 33])] { + let expected = create2_deployer + .create2_from_code(B256::repeat_byte(0xab), input.get(32..).unwrap()); + let transaction = call_to_create2_deployer(Some(input)); + assert_eq!(transaction.call_kind, CallKind::Create2); + assert_eq!(transaction.contract_address, Some(expected)); + } + } +} + +#[cfg(all(test, feature = "monad"))] +mod monad_tests { + use super::*; + use alloy_network::Ethereum; + use alloy_primitives::{Bytes, address, keccak256}; + use alloy_rpc_types::TransactionRequest; + use foundry_evm::{hardforks::MonadHardfork, traces::CallTraceDecoderBuilder}; + use foundry_evm_networks::NetworkConfigs; + + const STAKING_ADDRESS: Address = address!("0000000000000000000000000000000000001000"); + const RESERVE_BALANCE_ADDRESS: Address = address!("0000000000000000000000000000000000001001"); + + fn monad_decoder(hardfork: MonadHardfork) -> CallTraceDecoder { + CallTraceDecoderBuilder::new() + .with_networks(NetworkConfigs::with_monad()) + .with_chain_id(Some(143)) + .with_hardfork(Some(hardfork.into())) + .build() + } + + fn call_metadata( + address: Address, + signature: &str, + hardfork: MonadHardfork, + ) -> TransactionWithMetadata { + let input = Bytes::copy_from_slice(&keccak256(signature)[..SELECTOR_LEN]); + let selector = Selector::from_slice(&input); + let decoder = monad_decoder(hardfork); + + assert!(!decoder.functions.contains_key(&selector)); + assert!(decoder.functions_for_selector(address, &selector).is_some()); + + let transaction = TransactionRequest::default() + .with_from(Address::repeat_byte(0x11)) + .with_to(address) + .with_nonce(0) + .with_input(input); + let mut builder = ScriptTransactionBuilder::new( + TransactionMaybeSigned::new(transaction), + "http://localhost:8545".to_string(), + ); + builder.set_call(&BTreeMap::new(), &decoder, Address::ZERO).unwrap(); + builder.build() + } + + #[test] + fn address_scoped_monad_calls_populate_metadata() { + let staking = call_metadata(STAKING_ADDRESS, "getEpoch()", MonadHardfork::MonadEight); + assert_eq!(staking.function.as_deref(), Some("getEpoch()")); + assert_eq!( + staking.function_abi.as_deref(), + Some("function getEpoch() returns (uint64 epoch, bool inEpochDelayPeriod)") + ); + assert_eq!(staking.display_function.as_deref(), Some("getEpoch")); + assert_eq!(staking.arguments, Some(Vec::new())); + + let reserve = + call_metadata(RESERVE_BALANCE_ADDRESS, "dippedIntoReserve()", MonadHardfork::MonadNine); + assert_eq!(reserve.function.as_deref(), Some("dippedIntoReserve()")); + assert_eq!( + reserve.function_abi.as_deref(), + Some("function dippedIntoReserve() returns (bool dipped)") + ); + assert_eq!(reserve.display_function.as_deref(), Some("dippedIntoReserve")); + assert_eq!(reserve.arguments, Some(Vec::new())); + } +} diff --git a/crates/script/src/verify.rs b/crates/script/src/verify.rs index 76ab6b4bc3917..822b6afe4d9b8 100644 --- a/crates/script/src/verify.rs +++ b/crates/script/src/verify.rs @@ -3,27 +3,38 @@ use crate::{ build::LinkedBuildData, sequence::{ScriptSequenceKind, get_commit_hash}, }; -use alloy_primitives::{Address, hex}; +use alloy_network::{Network, ReceiptResponse}; +use alloy_primitives::{Address, TxHash, hex}; use eyre::{Result, eyre}; use forge_script_sequence::{AdditionalContract, ScriptSequence}; -use forge_verify::{RetryArgs, VerifierArgs, VerifyArgs, provider::VerificationProviderType}; +use forge_verify::{ + RetryArgs, VerifierArgs, VerifyArgs, provider::ExternalVerificationContext, + sourcify::SOURCIFY_URL, verify::sourcify_api_url, +}; use foundry_cli::opts::{EtherscanOpts, ProjectPathOpts}; -use foundry_common::ContractsByArtifact; +use foundry_common::{ContractsByArtifact, FoundryReceiptResponse}; use foundry_compilers::{Project, artifacts::EvmVersion, info::ContractInfo}; use foundry_config::{Chain, Config}; +use foundry_evm::core::evm::FoundryEvmNetwork; use semver::Version; +mod external; + +use external::{ExternalResolver, MAX_PROVENANCE_ADDRESSES, MatchResult, match_candidates}; + +const MAX_EXTERNAL_JOBS: usize = 32; + /// State after we have broadcasted the script. /// It is assumed that at this point [BroadcastedState::sequence] contains receipts for all /// broadcasted transactions. -pub struct BroadcastedState { +pub struct BroadcastedState { pub args: ScriptArgs, - pub script_config: ScriptConfig, + pub script_config: ScriptConfig, pub build_data: LinkedBuildData, - pub sequence: ScriptSequenceKind, + pub sequence: ScriptSequenceKind, } -impl BroadcastedState { +impl BroadcastedState { pub async fn verify(self) -> Result<()> { let Self { args, script_config, build_data, mut sequence, .. } = self; @@ -33,10 +44,11 @@ impl BroadcastedState { build_data.known_contracts, args.retry, args.verifier, + args.verify_external, ); for sequence in sequence.sequences_mut() { - verify_contracts(sequence, &script_config.config, verify.clone()).await?; + verify_contracts::(sequence, &script_config.config, verify.clone()).await?; } Ok(()) @@ -53,6 +65,10 @@ pub struct VerifyBundle { pub retry: RetryArgs, pub verifier: VerifierArgs, pub via_ir: bool, + pub verify_external: bool, + source_etherscan_url: Option, + source_etherscan_key: Option, + source_sourcify_url: Option, } impl VerifyBundle { @@ -62,6 +78,7 @@ impl VerifyBundle { known_contracts: ContractsByArtifact, retry: RetryArgs, verifier: VerifierArgs, + verify_external: bool, ) -> Self { let num_of_optimizations = if config.optimizer == Some(true) { config.optimizer_runs } else { None }; @@ -76,7 +93,7 @@ impl VerifyBundle { cache_path: Some(project.paths.cache.clone()), lib_paths: project.paths.libraries.clone(), hardhat: config.profile == Config::HARDHAT_PROFILE, - config_path: if config_path.exists() { Some(config_path) } else { None }, + config_path: config_path.exists().then_some(config_path), }; let via_ir = config.via_ir; @@ -89,15 +106,72 @@ impl VerifyBundle { retry, verifier, via_ir, + verify_external, + source_etherscan_url: None, + source_etherscan_key: None, + source_sourcify_url: None, } } /// Configures the chain and sets the etherscan key, if available - pub fn set_chain(&mut self, config: &Config, chain: Chain) { + pub fn set_chain(&mut self, config: &Config, chain: Chain) -> Result<()> { // If dealing with multiple chains, we need to be able to change in between the config // chain_id. - self.etherscan.key = config.get_etherscan_api_key(Some(chain)); + let config_key = source_api_key(config, chain); + let resolved_key = self.verifier.resolve_api_key(config_key.as_deref()).map(str::to_owned); + let provider = self.verifier.resolve(resolved_key.as_deref(), Some(chain)); + + // A selected Etherscan-compatible verifier is both the source and submission endpoint. + // Sourcify credentials must not be sent to the independent Etherscan discovery fallback. + if provider.is_sourcify() { + self.source_sourcify_url = Some( + self.verifier + .verifier_url + .clone() + .or_else(|| sourcify_api_url(chain)) + .unwrap_or_else(|| SOURCIFY_URL.to_string()), + ); + // Etherscan is optional when Sourcify is selected, so an invalid fallback must not + // prevent verification through the selected provider. + self.source_etherscan_url = config + .get_etherscan_config_with_chain(Some(chain)) + .ok() + .flatten() + .map(|config| config.api_url); + self.source_etherscan_key = config_key; + if self + .source_sourcify_url + .as_deref() + .zip(self.source_etherscan_url.as_deref()) + .is_some_and(|(sourcify, etherscan)| same_endpoint(sourcify, etherscan)) + { + self.source_etherscan_url = None; + self.source_etherscan_key = None; + } + } else if let Some(url) = &self.verifier.verifier_url { + // An explicit non-Sourcify endpoint may be private. Do not disclose provenance to the + // public Sourcify service as an implicit fallback. + self.source_sourcify_url = None; + self.source_etherscan_url = Some(url.clone()); + self.source_etherscan_key = resolved_key.clone(); + } else { + self.source_sourcify_url = + Some(sourcify_api_url(chain).unwrap_or_else(|| SOURCIFY_URL.to_string())); + self.source_etherscan_url = config + .get_etherscan_config_with_chain(Some(chain))? + .map(|config| config.api_url) + .or_else(|| { + if provider.is_etherscan() && !chain.is_custom_sourcify() { + chain.etherscan_urls().map(|(api_url, _)| api_url.to_string()) + } else { + None + } + }); + self.source_etherscan_key = resolved_key.clone(); + } + self.etherscan.key = resolved_key; self.etherscan.chain = Some(chain); + Ok(()) } /// Given a `VerifyBundle` and contract details, it tries to generate a valid `VerifyArgs` to @@ -110,15 +184,17 @@ impl VerifyBundle { libraries: &[String], evm_version: EvmVersion, ) -> Option { + let init_code = data.get(create2_offset..)?; for (artifact, contract) in self.known_contracts.iter() { let Some(bytecode) = contract.bytecode() else { continue }; // If it's a CREATE2, the tx.data comes with a 32-byte salt in the beginning // of the transaction - if data.split_at(create2_offset).1.starts_with(bytecode) { - let constructor_args = data.split_at(create2_offset + bytecode.len()).1.to_vec(); + if init_code.starts_with(bytecode) { + let constructor_args = init_code[bytecode.len()..].to_vec(); if artifact.source.extension().is_some_and(|e| e.to_str() == Some("vy")) { warn!("Skipping verification of Vyper contract: {}", artifact.name); + return None; } // Strip artifact profile from contract name when creating contract info. @@ -126,12 +202,12 @@ impl VerifyBundle { path: Some(artifact.source.to_string_lossy().to_string()), name: artifact .name - .strip_suffix(&format!(".{}", &artifact.profile)) + .strip_suffix(&format!(".{}", artifact.profile)) .unwrap_or_else(|| &artifact.name) .to_string(), }; - // We strip the build metadadata information, since it can lead to + // We strip the build metadata information, since it can lead to // etherscan not identifying it correctly. eg: // `v0.8.10+commit.fc410830.Linux.gcc` != `v0.8.10+commit.fc410830` let version = Version::new( @@ -146,6 +222,8 @@ impl VerifyBundle { compiler_version: Some(version.to_string()), constructor_args: Some(hex::encode(constructor_args)), constructor_args_path: None, + no_auto_detect: false, + use_solc: None, num_of_optimizations: self.num_of_optimizations, etherscan: self.etherscan.clone(), rpc: Default::default(), @@ -153,16 +231,19 @@ impl VerifyBundle { force: false, skip_is_verified_check: true, watch: true, + print_submission_result_to_stdout: false, retry: self.retry, libraries: libraries.to_vec(), root: None, verifier: self.verifier.clone(), via_ir: self.via_ir, + license_type: None, evm_version: Some(evm_version), show_standard_json_input: false, guess_constructor_args: false, - compilation_profile: Some(artifact.profile.to_string()), + compilation_profile: Some(artifact.profile.clone()), language: None, + creation_transaction_hash: None, }; return Some(verify); @@ -172,38 +253,230 @@ impl VerifyBundle { } } +fn source_api_key(config: &Config, chain: Chain) -> Option { + config.get_etherscan_api_key(Some(chain)).or_else(|| config.etherscan_api_key.clone()) +} + +enum VerificationJob { + Local(VerifyArgs), + External(VerifyArgs, Box), +} + +impl VerificationJob { + async fn run(self) -> Result<()> { + match self { + Self::Local(args) => args.run().await, + Self::External(args, context) => args.run_with_external_context(*context).await, + } + } +} + +#[allow(clippy::too_many_arguments)] +async fn external_job( + resolver: &mut Option, + config: &Config, + chain: Chain, + verify: &VerifyBundle, + address: Address, + init_code: &[u8], + creators: &[Address], + creation_transaction_hash: TxHash, +) -> Result { + if resolver.is_none() { + *resolver = Some(ExternalResolver::new().map_err(|err| concise(&err.to_string()))?); + } + let resolver = resolver.as_mut().unwrap(); + let mut candidate_sets = Vec::new(); + let mut reasons = Vec::new(); + + for &creator in creators.iter().take(MAX_PROVENANCE_ADDRESSES) { + let sources = [ + ( + "Sourcify", + resolver + .resolve_sourcify(chain, creator, verify.source_sourcify_url.as_deref()) + .await, + ), + ( + "Etherscan", + resolver + .resolve_etherscan( + chain, + creator, + verify.source_etherscan_url.as_deref(), + verify.source_etherscan_key.as_deref(), + ) + .await, + ), + ]; + for (provider, source) in sources { + match source { + Ok(Some(source)) => match resolver.compile(&source).await { + Ok((compiled, has_unresolved_links)) => { + if has_unresolved_links { + reasons.push(format!( + "{} {creator}: contracts with unresolved library links are unsupported", + source.provider + )); + } + candidate_sets.push(compiled); + } + Err(err) => reasons.push(format!( + "{} {creator}: compile failed ({})", + source.provider, + concise(&err) + )), + }, + Ok(None) => {} + Err(err) => reasons.push(format!("{provider} {creator}: {}", concise(&err))), + } + } + } + + let matched = match match_candidates( + init_code, + candidate_sets.iter().flat_map(|candidates| candidates.iter()), + ) { + MatchResult::Unique(matched) => matched, + MatchResult::None => { + let context = if reasons.is_empty() { + "no matching candidates were found".to_string() + } else { + format!("no matching candidates were found; {}", reasons.join("; ")) + }; + return Err(context); + } + MatchResult::Ambiguous(matches) => { + let fqns = matches + .into_iter() + .map(|matched| format!("{}@{}", matched.fqn, matched.version)) + .collect::>(); + return Err(format!("ambiguous external candidates: {}", fqns.join(", "))); + } + }; + + let mut pinned_config = config.clone(); + pinned_config.chain = Some(chain); + let context = ExternalVerificationContext { + config: pinned_config, + compiler_version: matched.version.clone(), + standard_json_input: matched.input, + target: matched.fqn, + }; + let args = VerifyArgs { + address, + contract: None, + compiler_version: Some(matched.version.to_string()), + constructor_args: Some(hex::encode(matched.constructor_args)), + constructor_args_path: None, + no_auto_detect: false, + use_solc: None, + num_of_optimizations: None, + etherscan: verify.etherscan.clone(), + rpc: Default::default(), + flatten: false, + force: false, + skip_is_verified_check: true, + watch: true, + print_submission_result_to_stdout: false, + retry: verify.retry, + libraries: Vec::new(), + root: None, + verifier: verify.verifier.clone(), + via_ir: false, + license_type: None, + evm_version: None, + show_standard_json_input: false, + guess_constructor_args: false, + compilation_profile: None, + language: None, + creation_transaction_hash: Some(creation_transaction_hash), + }; + Ok(VerificationJob::External(args, Box::new(context))) +} + +fn concise(reason: &str) -> String { + const LIMIT: usize = 160; + let mut chars = reason.chars().map(|ch| if ch.is_control() { ' ' } else { ch }); + let reason = chars.by_ref().take(LIMIT).collect::(); + if chars.next().is_some() { format!("{reason}…") } else { reason } +} + +fn take_matching_index( + values: &[T], + consumed: &mut [bool], + predicate: impl Fn(&T) -> bool, +) -> Option { + let index = values + .iter() + .enumerate() + .position(|(index, value)| !consumed[index] && predicate(value))?; + consumed[index] = true; + Some(index) +} + /// Given the broadcast log, it matches transactions with receipts, and tries to verify any /// created contract on etherscan. -async fn verify_contracts( - sequence: &mut ScriptSequence, +async fn verify_contracts( + sequence: &mut ScriptSequence, config: &Config, mut verify: VerifyBundle, ) -> Result<()> { trace!(target: "script", "verifying {} contracts [{}]", verify.known_contracts.len(), sequence.chain); - verify.set_chain(config, sequence.chain.into()); + verify.set_chain(config, sequence.chain.into())?; - if verify.etherscan.has_key() || verify.verifier.verifier != VerificationProviderType::Etherscan - { + if verify.etherscan.has_key() || !verify.verifier.effective_type().is_etherscan() { trace!(target: "script", "prepare future verifications"); - let mut future_verifications = Vec::with_capacity(sequence.receipts.len()); + let mut verification_jobs = Vec::with_capacity(sequence.receipts.len()); let mut unverifiable_contracts = vec![]; + let mut resolver = None; + let mut external_jobs = 0; + let mut skipped_external = 0; + let mut warned_offline = false; + let mut consumed_receipts = vec![false; sequence.receipts.len()]; + + for tx in &sequence.transactions { + let Some(tx_hash) = tx.hash else { + let _ = sh_warn!("Skipping verification for transaction without a hash."); + continue; + }; + let Some(receipt_index) = + take_matching_index(&sequence.receipts, &mut consumed_receipts, |receipt| { + receipt.transaction_hash() == tx_hash + }) + else { + let _ = sh_warn!( + "Skipping verification for transaction {tx_hash}: receipt unavailable." + ); + continue; + }; + let receipt = &mut sequence.receipts[receipt_index]; + let malformed_create2 = + tx.is_create2() && tx.tx().input().is_none_or(|data| data.len() < 32); + if malformed_create2 { + let input_len = tx.tx().input().map_or(0, |data| data.len()); + let _ = sh_warn!( + "Skipping verification for CREATE2 transaction {tx_hash}: input length {input_len} is shorter than the 32-byte salt prefix." + ); + } - // Make sure the receipts have the right order first. - sequence.sort_receipts(); - - for (receipt, tx) in sequence.receipts.iter_mut().zip(sequence.transactions.iter()) { // create2 hash offset - let mut offset = 0; - - if tx.is_create2() { - receipt.contract_address = tx.contract_address; - offset = 32; - } + let offset = if !malformed_create2 + && tx.is_create2() + && let Some(contract_address) = tx.contract_address + { + receipt.set_contract_address(contract_address); + 32 + } else { + 0 + }; // Verify contract created directly from the transaction - if let (Some(address), Some(data)) = (receipt.contract_address, tx.tx().input()) { + if !malformed_create2 + && let (Some(address), Some(data)) = (receipt.contract_address(), tx.tx().input()) + { match verify.get_verify_args( address, offset, @@ -211,13 +484,15 @@ async fn verify_contracts( &sequence.libraries, config.evm_version, ) { - Some(verify) => future_verifications.push(verify.run()), + Some(verify) => verification_jobs.push(VerificationJob::Local(verify)), None => unverifiable_contracts.push(address), }; } // Verify potential contracts created during the transaction execution - for AdditionalContract { address, init_code, .. } in &tx.additional_contracts { + for AdditionalContract { address, init_code, creator_code_addresses, .. } in + &tx.additional_contracts + { match verify.get_verify_args( *address, 0, @@ -225,21 +500,66 @@ async fn verify_contracts( &sequence.libraries, config.evm_version, ) { - Some(verify) => future_verifications.push(verify.run()), - None => unverifiable_contracts.push(*address), + Some(args) => verification_jobs.push(VerificationJob::Local(args)), + None if !verify.verify_external => unverifiable_contracts.push(*address), + None if config.offline => { + skipped_external += 1; + if !warned_offline { + let _ = sh_warn!( + "Skipping external contract verification because offline mode is enabled." + ); + warned_offline = true; + } + } + None if creator_code_addresses.is_empty() => { + skipped_external += 1; + let _ = sh_warn!( + "Skipping external verification for {address}: creator provenance is unavailable (old broadcast logs or skipped simulation)." + ); + } + None if external_jobs >= MAX_EXTERNAL_JOBS => { + skipped_external += 1; + let _ = sh_warn!( + "Skipping external verification for {address}: external job limit exceeded." + ); + } + None => { + external_jobs += 1; + match external_job( + &mut resolver, + config, + sequence.chain.into(), + &verify, + *address, + init_code, + creator_code_addresses, + receipt.transaction_hash(), + ) + .await + { + Ok(job) => verification_jobs.push(job), + Err(reason) => { + skipped_external += 1; + let _ = sh_warn!( + "Skipping external verification for {address}: {reason}" + ); + } + } + } }; } } - trace!(target: "script", "collected {} verification jobs and {} unverifiable contracts", future_verifications.len(), unverifiable_contracts.len()); + trace!(target: "script", "collected {} verification jobs and {} unverifiable contracts", verification_jobs.len(), unverifiable_contracts.len()); check_unverified(sequence, unverifiable_contracts, verify); - let num_verifications = future_verifications.len(); + let num_verifications = verification_jobs.len(); + let num_requested = num_verifications + skipped_external; let mut num_of_successful_verifications = 0; - sh_println!("##\nStart verification for ({num_verifications}) contracts")?; - for verification in future_verifications { - match verification.await { + sh_status!("##\nStart verification for ({num_requested}) contracts")?; + for verification in verification_jobs { + match verification.run().await { Ok(_) => { num_of_successful_verifications += 1; } @@ -249,26 +569,47 @@ async fn verify_contracts( } } - if num_of_successful_verifications < num_verifications { - return Err(eyre!( - "Not all ({num_of_successful_verifications} / {num_verifications}) contracts were verified!" - )); - } + ensure_verification_complete( + num_of_successful_verifications, + num_verifications, + skipped_external, + )?; - sh_println!("All ({num_verifications}) contracts were verified!")?; + sh_status!("All ({num_requested}) contracts were verified!")?; } Ok(()) } -fn check_unverified( - sequence: &ScriptSequence, +fn ensure_verification_complete( + successful: usize, + submitted: usize, + skipped_external: usize, +) -> Result<()> { + let requested = submitted + skipped_external; + if successful < requested { + let skipped = if skipped_external == 0 { + String::new() + } else { + format!("; {skipped_external} external verification(s) were skipped") + }; + return Err(eyre!( + "Not all ({successful} / {requested}) contracts were verified{skipped}!" + )); + } + Ok(()) +} + +fn check_unverified( + sequence: &ScriptSequence, unverifiable_contracts: Vec
, verify: VerifyBundle, ) { if !unverifiable_contracts.is_empty() { let _ = sh_warn!( - "We haven't found any matching bytecode for the following contracts: {:?}.\n\nThis may occur when resuming a verification, but the underlying source code or compiler version has changed.", + "We haven't found any matching bytecode for the following contracts: {:?}.\n\n\ + This may occur when resuming a verification, but the underlying source code or compiler version has changed.\n\ + Run `forge clean` to make sure builds are in sync with project files, then try again. Alternatively, use `forge verify-contract` to verify contracts that are already deployed.", unverifiable_contracts ); @@ -287,3 +628,224 @@ fn check_unverified( } } } + +fn same_endpoint(left: &str, right: &str) -> bool { + let (Ok(left), Ok(right)) = (reqwest::Url::parse(left), reqwest::Url::parse(right)) else { + return false; + }; + left == right +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::Bytes; + use forge_verify::provider::VerificationProviderType; + use foundry_compilers::{ + ArtifactId, + artifacts::{BytecodeObject, CompactBytecode, CompactContractBytecode}, + }; + + fn bundle(config: &Config, verifier: VerifierArgs) -> VerifyBundle { + let project = config.project().unwrap(); + VerifyBundle::new( + &project, + config, + ContractsByArtifact::default(), + RetryArgs::default(), + verifier, + true, + ) + } + + fn bundle_with_bytecode(bytecode: Bytes) -> VerifyBundle { + let config = Config::default(); + let mut verify = bundle(&config, VerifierArgs::default()); + verify.known_contracts = ContractsByArtifact::new([( + ArtifactId { + path: "out/Test.json".into(), + name: "Test".into(), + source: "src/Test.sol".into(), + version: Version::new(0, 8, 30), + build_id: String::new(), + profile: "default".into(), + }, + CompactContractBytecode { + abi: Some(Default::default()), + bytecode: Some(CompactBytecode { + object: BytecodeObject::Bytecode(bytecode), + source_map: None, + link_references: Default::default(), + }), + deployed_bytecode: None, + }, + )]); + verify + } + + #[test] + fn truncated_create2_data_is_unverifiable() { + let bytecode = Bytes::from_static(&[0x60, 0x00]); + let verify = bundle_with_bytecode(bytecode); + let address = Address::ZERO; + + for data in [Bytes::new(), Bytes::from(vec![0; 31])] { + assert!( + verify.get_verify_args(address, 32, &data, &[], EvmVersion::London).is_none(), + "truncated data should not produce verification args" + ); + } + } + + #[test] + fn salt_only_create2_data_is_unverifiable() { + let verify = bundle_with_bytecode(Bytes::from_static(&[0x60, 0x00])); + let address = Address::ZERO; + let salt_only = Bytes::from(vec![0; 32]); + assert!( + verify.get_verify_args(address, 32, &salt_only, &[], EvmVersion::London).is_none(), + "valid salt-only data should not match non-empty bytecode" + ); + } + + #[test] + fn valid_create2_data_produces_verification_args() { + let bytecode = Bytes::from_static(&[0x60, 0x00]); + let verify = bundle_with_bytecode(bytecode.clone()); + let address = Address::ZERO; + let mut data = vec![0; 32]; + data.extend_from_slice(&bytecode); + data.extend_from_slice(&[0xaa, 0xbb]); + let args = verify + .get_verify_args(address, 32, &data, &[], EvmVersion::London) + .expect("valid CREATE2 data should produce verification args"); + assert_eq!(args.constructor_args.as_deref(), Some("aabb")); + } + + #[test] + fn receipt_matching_is_hash_based_and_consumes_duplicate_hashes_in_order() { + let reversed = [(2, "second"), (1, "first")]; + let mut consumed = [false; 2]; + assert_eq!(take_matching_index(&reversed, &mut consumed, |(hash, _)| *hash == 1), Some(1)); + assert_eq!(take_matching_index(&reversed, &mut consumed, |(hash, _)| *hash == 2), Some(0)); + assert_eq!(consumed, [true, true]); + + let batch = [(7, "first"), (7, "second")]; + let mut consumed = [false; 2]; + let first = take_matching_index(&batch, &mut consumed, |(hash, _)| *hash == 7).unwrap(); + let second = take_matching_index(&batch, &mut consumed, |(hash, _)| *hash == 7).unwrap(); + assert_eq!((batch[first].1, batch[second].1), ("first", "second")); + assert!(take_matching_index(&batch, &mut consumed, |(hash, _)| *hash == 7).is_none()); + } + + #[test] + fn source_key_reads_etherscan_config_fallback() { + let mut config = Config { etherscan_api_key: Some("source".into()), ..Default::default() }; + assert_eq!(source_api_key(&config, Chain::mainnet()).as_deref(), Some("source")); + config.etherscan_api_key = None; + assert!(source_api_key(&config, Chain::mainnet()).is_none()); + } + + #[test] + fn source_endpoints_follow_selected_provider_privacy() { + let tempo = Chain::from(4217u64); + let config = Config { etherscan_api_key: Some("ambient".into()), ..Default::default() }; + let mut verify = bundle(&config, VerifierArgs::default()); + verify.set_chain(&config, tempo).unwrap(); + assert_eq!(verify.source_sourcify_url, sourcify_api_url(tempo)); + assert_ne!(verify.source_sourcify_url.as_deref(), Some(SOURCIFY_URL)); + assert!(verify.source_etherscan_url.is_none()); + assert!(verify.source_etherscan_key.is_none()); + + let config = Config::default(); + let mut verify = bundle( + &config, + VerifierArgs { + verifier: Some(VerificationProviderType::Custom), + verifier_api_key: Some("private-key".into()), + verifier_url: Some("https://private.example/api".into()), + }, + ); + verify.set_chain(&config, Chain::mainnet()).unwrap(); + assert!(verify.source_sourcify_url.is_none()); + assert_eq!(verify.source_etherscan_url.as_deref(), Some("https://private.example/api")); + + let mut verify = bundle( + &config, + VerifierArgs { + verifier: Some(VerificationProviderType::Etherscan), + ..Default::default() + }, + ); + verify.set_chain(&config, Chain::mainnet()).unwrap(); + assert_eq!(verify.source_sourcify_url.as_deref(), Some(SOURCIFY_URL)); + } + + #[test] + fn source_endpoint_comparison_normalizes_urls_without_ignoring_routes() { + assert!(same_endpoint("https://CONTRACTS.tempo.xyz:443", "https://contracts.tempo.xyz/")); + assert!(!same_endpoint("https://contracts.tempo.xyz/api", "https://contracts.tempo.xyz/")); + assert!(!same_endpoint( + "https://contracts.tempo.xyz/?chainid=4217", + "https://contracts.tempo.xyz/" + )); + assert!(!same_endpoint("not a URL", "https://contracts.tempo.xyz/")); + } + + #[test] + fn cli_only_etherscan_key_uses_chain_source_endpoint() { + let chain = Chain::mainnet(); + let config = Config::default(); + let mut verify = bundle( + &config, + VerifierArgs { verifier_api_key: Some("cli-key".into()), ..Default::default() }, + ); + + verify.set_chain(&config, chain).unwrap(); + + assert_eq!( + verify.source_etherscan_url.as_deref(), + Some("https://api.etherscan.io/v2/api?chainid=1") + ); + assert_eq!(verify.source_etherscan_key.as_deref(), Some("cli-key")); + assert_eq!(verify.etherscan.key.as_deref(), Some("cli-key")); + } + + #[test] + fn chain_source_endpoint_requires_valid_etherscan_route() { + let config = Config::default(); + for (chain, provider) in [ + (Chain::mainnet(), VerificationProviderType::Custom), + (Chain::from(4217u64), VerificationProviderType::Etherscan), + ] { + let mut verify = bundle( + &config, + VerifierArgs { + verifier: Some(provider), + verifier_api_key: Some("private-key".into()), + ..Default::default() + }, + ); + + verify.set_chain(&config, chain).unwrap(); + + assert!(verify.source_etherscan_url.is_none()); + } + } + + #[test] + fn skipped_external_verifications_make_the_summary_fail() { + let err = ensure_verification_complete(0, 0, 1).unwrap_err().to_string(); + assert!(err.contains("0 / 1")); + assert!(err.contains("1 external verification(s) were skipped")); + ensure_verification_complete(1, 1, 0).unwrap(); + } + + #[test] + fn concise_sanitizes_and_bounds_remote_errors() { + let message = format!("remote\n\u{1b}[31m{}", "x".repeat(200)); + let concise = concise(&message); + assert!(!concise.chars().any(char::is_control)); + assert!(concise.chars().count() <= 161); + } +} diff --git a/crates/script/src/verify/external.rs b/crates/script/src/verify/external.rs new file mode 100644 index 0000000000000..72715e3054710 --- /dev/null +++ b/crates/script/src/verify/external.rs @@ -0,0 +1,1452 @@ +//! Acquisition, compilation, and matching of external Solidity sources. + +use alloy_dyn_abi::{DynSolValue, JsonAbiExt}; +use alloy_json_abi::Constructor; +use alloy_primitives::{Address, Bytes, keccak256}; +use eyre::{Result, bail, eyre}; +use forge_verify::sourcify::SOURCIFY_URL; +use foundry_compilers::{artifacts::CompilerOutput, solc::Solc}; +use foundry_config::{Chain, NamedChain}; +use futures::StreamExt; +use semver::Version; +use serde::Deserialize; +use serde_json::{Value, json}; +use std::{ + collections::{HashMap, HashSet}, + process::Stdio, + sync::Arc, + time::Duration, +}; +use tokio::{ + io::{AsyncRead, AsyncReadExt, AsyncWriteExt}, + process::Command, +}; + +const MAX_INPUT_SIZE: usize = 10 * 1024 * 1024; +const MAX_SOURCES: usize = 256; +const MAX_FETCHES: usize = 64; +const MAX_COMPILER_VERSIONS: usize = 8; +const MAX_COMPILATIONS: usize = 32; +const MAX_CANDIDATES: usize = 512; +const MAX_CREATION_BYTECODE: usize = 64 * 1024 * 1024; +const MAX_RETAINED_METADATA: usize = 8 * 1024 * 1024; +const MAX_RETAINED_SOURCE_INPUT: usize = 64 * 1024 * 1024; +const MAX_SOURCE_PATH: usize = 4096; +const MAX_COMPILER_VERSION: usize = 256; +const MAX_CACHED_ERROR_CHARS: usize = 512; +const MAX_STDOUT: usize = 32 * 1024 * 1024; +const MAX_STDERR: usize = 1024 * 1024; +const COMPILE_TIMEOUT: Duration = Duration::from_secs(60); +pub(super) const MAX_PROVENANCE_ADDRESSES: usize = 16; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(super) enum SourceProvider { + Etherscan { endpoint: String }, + Sourcify { endpoint: String }, +} + +impl SourceProvider { + const fn metadata_len(&self) -> usize { + match self { + Self::Etherscan { endpoint } | Self::Sourcify { endpoint } => endpoint.len(), + } + } +} + +impl std::fmt::Display for SourceProvider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Etherscan { .. } => f.write_str("Etherscan"), + Self::Sourcify { .. } => f.write_str("Sourcify"), + } + } +} + +#[derive(Clone, Debug)] +pub(super) struct ExternalSource { + pub input: Arc, + pub version: Version, + pub provider: SourceProvider, +} + +#[derive(Clone, Debug)] +pub(super) struct Candidate { + pub input: Arc, + pub fingerprint: String, + pub version: Version, + pub fqn: String, + pub constructor: Option, + pub creation_bytecode: Bytes, +} + +#[derive(Clone, Debug)] +pub(super) struct ExternalMatch { + pub input: Arc, + pub version: Version, + pub fqn: String, + pub creation_bytecode: Bytes, + pub constructor_args: Bytes, +} + +#[derive(Clone, Debug)] +pub(super) enum MatchResult { + None, + Unique(ExternalMatch), + Ambiguous(Vec), +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +struct FetchKey { + chain: u64, + provider: SourceProvider, + address: Address, +} + +type CompiledCandidates = Result<(Arc<[Candidate]>, bool), String>; + +/// Per-script-run state. Both caches deliberately retain failures, preventing repeated network or +/// compiler work for the same provenance. +pub(super) struct ExternalResolver { + http: reqwest::Client, + fetch_cache: HashMap, String>>, + compile_cache: HashMap<(Version, String), CompiledCandidates>, + compiler_versions: HashSet, + compilations: usize, + retained_source_input: usize, + retained_candidates: usize, + retained_metadata: usize, + retained_creation_bytecode: usize, +} + +impl ExternalResolver { + pub(super) fn new() -> Result { + let http = reqwest::Client::builder() + .timeout(Duration::from_secs(15)) + .connect_timeout(Duration::from_secs(15)) + .redirect(reqwest::redirect::Policy::none()) + .build()?; + Ok(Self { + http, + fetch_cache: HashMap::new(), + compile_cache: HashMap::new(), + compiler_versions: HashSet::new(), + compilations: 0, + retained_source_input: 0, + retained_candidates: 0, + retained_metadata: 0, + retained_creation_bytecode: 0, + }) + } + + pub(super) async fn resolve_etherscan( + &mut self, + chain: Chain, + address: Address, + endpoint: Option<&str>, + api_key: Option<&str>, + ) -> Result, String> { + let endpoint = endpoint.ok_or_else(|| "Etherscan is not configured".to_string())?; + let provider = SourceProvider::Etherscan { endpoint: endpoint.to_string() }; + let key = FetchKey { chain: chain.id(), provider: provider.clone(), address }; + if let Some(cached) = self.fetch_cache.get(&key) { + return cached.clone(); + } + if self.fetch_cache.len() >= MAX_FETCHES { + return Err("external source fetch limit exceeded".to_string()); + } + let result = async { + let address = address.to_string(); + let response = self + .http + .get(endpoint) + .query(&[ + ("module", "contract"), + ("action", "getsourcecode"), + ("address", address.as_str()), + ("apikey", api_key.unwrap_or_default()), + ]) + .send() + .await + .map_err(|_| "Etherscan request failed".to_string())?; + if !response.status().is_success() { + return Err(format!("Etherscan returned HTTP {}", response.status())); + } + if response.content_length().is_some_and(|length| length > MAX_INPUT_SIZE as u64) { + return Err("Etherscan response exceeds 10 MiB".to_string()); + } + let body = read_capped_body(response, MAX_INPUT_SIZE, "Etherscan").await?; + let (input, version) = parse_etherscan_response(&body)?; + Ok(Some(ExternalSource { input: Arc::new(input), version, provider })) + } + .await; + self.cache_source_result(key, result) + } + + pub(super) async fn resolve_sourcify( + &mut self, + chain: Chain, + address: Address, + endpoint: Option<&str>, + ) -> Result, String> { + let Some(endpoint) = endpoint else { return Ok(None) }; + // Canonical Sourcify cannot observe ephemeral local chains. Avoid a guaranteed external + // request so local external-verification workflows remain hermetic. + if endpoint == SOURCIFY_URL + && matches!( + chain.named(), + Some(NamedChain::Dev | NamedChain::AnvilHardhat | NamedChain::Cannon) + ) + { + return Ok(None); + } + let provider = SourceProvider::Sourcify { endpoint: endpoint.to_string() }; + let key = FetchKey { chain: chain.id(), provider: provider.clone(), address }; + if let Some(cached) = self.fetch_cache.get(&key) { + return cached.clone(); + } + if self.fetch_cache.len() >= MAX_FETCHES { + return Err("external source fetch limit exceeded".to_string()); + } + let result = async { + let endpoint = endpoint.trim_end_matches('/'); + let url = format!( + "{endpoint}/v2/contract/{}/{address}?fields=stdJsonInput,compilation.compilerVersion", + chain.id() + ); + let response = self.http.get(url).send().await.map_err(|_| "Sourcify request failed".to_string())?; + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(None) + } + if !response.status().is_success() { + return Err(format!("Sourcify returned HTTP {}", response.status())) + } + if response.content_length().is_some_and(|length| length > MAX_INPUT_SIZE as u64) { + return Err("Sourcify response exceeds 10 MiB".to_string()) + } + let body = read_capped_body(response, MAX_INPUT_SIZE, "Sourcify").await?; + let response: SourcifyResponse = + serde_json::from_slice(&body).map_err(|e| format!("invalid Sourcify response: {e}"))?; + validate_input(&response.std_json_input).map_err(|e| e.to_string())?; + let version = parse_compiler_version(&response.compilation.compiler_version) + .map_err(|e| e.to_string())?; + Ok(Some(ExternalSource { + input: Arc::new(response.std_json_input), + version, + provider, + })) + } + .await; + self.cache_source_result(key, result) + } + + pub(super) async fn compile( + &mut self, + source: &ExternalSource, + ) -> Result<(Arc<[Candidate]>, bool), String> { + let fingerprint = fingerprint(&source.input).map_err(|e| e.to_string())?; + let key = (source.version.clone(), fingerprint); + if let Some(cached) = self.compile_cache.get(&key) { + return cached.clone(); + } + if self.compilations >= MAX_COMPILATIONS { + return Err("external compilation limit exceeded".to_string()); + } + let new_version = !self.compiler_versions.contains(&source.version); + if new_version && self.compiler_versions.len() >= MAX_COMPILER_VERSIONS { + return Err("external compiler version limit exceeded".to_string()); + } + let version_len = source.version.to_string().len(); + let cache_metadata = key + .1 + .len() + .saturating_add(version_len) + .saturating_add(if new_version { version_len } else { 0 }); + self.compilations += 1; + let result = match compile_source(source).await { + Ok((candidates, has_unresolved_links)) => self + .charge_candidates(candidates, cache_metadata) + .map(|candidates| (candidates, has_unresolved_links)), + Err(err) => Err(err), + }; + let result = match result { + Ok(candidates) => Ok(candidates), + Err(err) => { + let err = bound_cached_error(err); + self.charge_metadata(cache_metadata.saturating_add(err.len()))?; + Err(err) + } + }; + self.compiler_versions.insert(source.version.clone()); + self.compile_cache.insert(key, result.clone()); + result + } + + fn charge_candidates( + &mut self, + candidates: Vec, + cache_metadata: usize, + ) -> Result, String> { + let bytes = candidates.iter().fold(0usize, |total, candidate| { + total.saturating_add(candidate.creation_bytecode.len()) + }); + let metadata = candidates.iter().try_fold(cache_metadata, |total, candidate| { + let constructor = candidate + .constructor + .as_ref() + .map(serde_json::to_vec) + .transpose() + .map_err(|_| "failed to measure candidate constructor".to_string())? + .map_or(0, |constructor| constructor.len()); + Ok::<_, String>( + total + .saturating_add(candidate.fqn.len()) + .saturating_add(candidate.fingerprint.len()) + .saturating_add(candidate.version.to_string().len()) + .saturating_add(constructor), + ) + })?; + if self.retained_candidates.saturating_add(candidates.len()) > MAX_CANDIDATES { + return Err("external cumulative candidate limit exceeded".to_string()); + } + if self.retained_metadata.saturating_add(metadata) > MAX_RETAINED_METADATA { + return Err("external cumulative metadata limit exceeded".to_string()); + } + if self.retained_creation_bytecode.saturating_add(bytes) > MAX_CREATION_BYTECODE { + return Err("external cumulative creation bytecode limit exceeded".to_string()); + } + self.retained_candidates += candidates.len(); + self.retained_metadata += metadata; + self.retained_creation_bytecode += bytes; + Ok(Arc::from(candidates)) + } + + fn cache_source_result( + &mut self, + key: FetchKey, + result: Result, String>, + ) -> Result, String> { + let result = result.map_err(bound_cached_error); + let source_bytes = match &result { + Ok(Some(source)) => serde_json::to_vec(&source.input) + .map_err(|_| "failed to measure Standard JSON input".to_string())? + .len(), + Ok(None) | Err(_) => 0, + }; + let retained = key.provider.metadata_len().saturating_add(match &result { + Ok(Some(source)) => { + source.provider.metadata_len().saturating_add(source.version.to_string().len()) + } + Ok(None) => 0, + Err(err) => err.len(), + }); + if self.retained_source_input.saturating_add(source_bytes) > MAX_RETAINED_SOURCE_INPUT { + return Err("external cumulative source input limit exceeded".to_string()); + } + if self.retained_metadata.saturating_add(retained) > MAX_RETAINED_METADATA { + return Err("external cumulative metadata limit exceeded".to_string()); + } + self.retained_source_input += source_bytes; + self.retained_metadata += retained; + self.fetch_cache.insert(key, result.clone()); + result + } + + fn charge_metadata(&mut self, bytes: usize) -> Result<(), String> { + if self.retained_metadata.saturating_add(bytes) > MAX_RETAINED_METADATA { + return Err("external cumulative metadata limit exceeded".to_string()); + } + self.retained_metadata += bytes; + Ok(()) + } +} + +fn bound_cached_error(error: String) -> String { + let mut chars = error.chars(); + let error = chars.by_ref().take(MAX_CACHED_ERROR_CHARS).collect::(); + if chars.next().is_some() { format!("{error}…") } else { error } +} + +async fn read_capped_body( + response: reqwest::Response, + limit: usize, + provider: &str, +) -> Result, String> { + let mut body = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| format!("invalid {provider} response"))?; + if !append_capped(&mut body, &chunk, limit) { + return Err(format!("{provider} response exceeds 10 MiB")); + } + } + Ok(body) +} + +fn append_capped(output: &mut Vec, chunk: &[u8], limit: usize) -> bool { + if output.len().saturating_add(chunk.len()) > limit { + return false; + } + output.extend_from_slice(chunk); + true +} + +fn parse_etherscan_response(body: &[u8]) -> Result<(Value, Version), String> { + let response: Value = + serde_json::from_slice(body).map_err(|e| format!("invalid Etherscan response: {e}"))?; + if response.get("status").and_then(Value::as_str) != Some("1") { + let message = response.get("result").and_then(Value::as_str).unwrap_or("request failed"); + return Err(format!("Etherscan request failed: {message}")); + } + let item = response + .get("result") + .and_then(Value::as_array) + .and_then(|items| items.first()) + .and_then(Value::as_object) + .ok_or_else(|| "empty Etherscan response".to_string())?; + let compiler_version = item + .get("CompilerVersion") + .and_then(Value::as_str) + .ok_or_else(|| "Etherscan compiler version is missing".to_string())?; + let compiler_version = + parse_compiler_version(compiler_version).map_err(|err| err.to_string())?; + let source = + item.get("SourceCode").ok_or_else(|| "Etherscan source code is missing".to_string())?; + let input = match source { + Value::Object(_) => source.clone(), + Value::String(source) => { + let wrapped = source.starts_with("{{") && source.ends_with("}}"); + let source = if wrapped { &source[1..source.len() - 1] } else { source }; + serde_json::from_str(source) + .map_err(|_| "Etherscan did not return Standard JSON sources".to_string())? + } + _ => return Err("Etherscan did not return Standard JSON sources".to_string()), + }; + validate_input(&input).map_err(|e| e.to_string())?; + Ok((input, compiler_version)) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct SourcifyResponse { + std_json_input: Value, + compilation: SourcifyCompilation, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct SourcifyCompilation { + compiler_version: String, +} + +fn parse_compiler_version(version: &str) -> Result { + if version.len() > MAX_COMPILER_VERSION { + bail!("compiler version exceeds {MAX_COMPILER_VERSION} bytes"); + } + Version::parse(version.trim_start_matches('v')).map_err(Into::into) +} + +fn validate_input(input: &Value) -> Result<()> { + if serde_json::to_vec(input)?.len() > MAX_INPUT_SIZE { + bail!("Standard JSON input exceeds 10 MiB"); + } + let object = input.as_object().ok_or_else(|| eyre!("Standard JSON input is not an object"))?; + if object.get("language").and_then(Value::as_str) != Some("Solidity") { + bail!("source language is not Solidity"); + } + let sources = object + .get("sources") + .and_then(Value::as_object) + .ok_or_else(|| eyre!("Standard JSON sources are missing"))?; + if sources.is_empty() || sources.len() > MAX_SOURCES { + bail!("Standard JSON must contain 1 to 256 sources"); + } + for (path, source) in sources { + validate_identifier(path, "source path")?; + let source = source.as_object().ok_or_else(|| eyre!("invalid source entry"))?; + if source.get("content").and_then(Value::as_str).is_none_or(str::is_empty) { + bail!("every source must contain nonempty literal content"); + } + } + if !object.get("settings").is_some_and(Value::is_object) { + bail!("Standard JSON settings must be an object"); + } + Ok(()) +} + +fn validate_identifier(value: &str, label: &str) -> Result<()> { + if value.is_empty() || value.len() > MAX_SOURCE_PATH { + bail!("{label} must contain 1 to 4096 bytes"); + } + if !value.is_ascii() || value.bytes().any(|byte| byte.is_ascii_control()) { + bail!("{label} must contain printable ASCII characters"); + } + Ok(()) +} + +fn compilation_input(input: &Value) -> Result { + validate_input(input)?; + let mut input = input.clone(); + input["settings"]["outputSelection"] = json!({ + "*": { "*": ["abi", "evm.bytecode.object", "evm.bytecode.linkReferences"] } + }); + Ok(input) +} + +fn fingerprint(input: &Value) -> Result { + validate_input(input)?; + let mut input = input.clone(); + input.sort_all_objects(); + Ok(keccak256(serde_json::to_vec(&input)?).to_string()) +} + +fn compiler_matches(requested: &Version, actual: &Version) -> bool { + requested.major == actual.major + && requested.minor == actual.minor + && requested.patch == actual.patch + && requested.pre == actual.pre + && requested.build.as_str().split('.').take(2).eq(actual.build.as_str().split('.').take(2)) +} + +fn compiler_identity(version: &Version) -> String { + let build = version.build.as_str().split('.').take(2).collect::>().join("."); + format!("{}.{}.{}-{}+{build}", version.major, version.minor, version.patch, version.pre) +} + +async fn compile_source(source: &ExternalSource) -> Result<(Vec, bool), String> { + let input = compilation_input(&source.input).map_err(|e| e.to_string())?; + let svm_version = + Version::new(source.version.major, source.version.minor, source.version.patch); + let solc = match Solc::find_svm_installed_version(&svm_version).map_err(|e| e.to_string())? { + Some(solc) => solc, + None => tokio::time::timeout(Duration::from_secs(60), Solc::install(&svm_version)) + .await + .map_err(|_| "solc installation timed out".to_string())? + .map_err(|e| e.to_string())?, + }; + let mut version_command = Command::new(&solc.solc); + version_command.arg("--version"); + let version_output = + run_bounded_command(version_command, None, 64 * 1024, 64 * 1024, Duration::from_secs(10)) + .await?; + let actual = parse_solc_version_output(&version_output.0)?; + if !compiler_matches(&source.version, &actual) { + return Err(format!("installed solc {actual} does not match requested {}", source.version)); + } + let workdir = + tempfile::tempdir().map_err(|_| "failed to create compiler sandbox".to_string())?; + let raw_input = + serde_json::to_vec(&input).map_err(|_| "failed to encode compiler input".to_string())?; + let mut command = Command::new(&solc.solc); + command + .arg("--standard-json") + .arg("--base-path") + .arg(workdir.path()) + .current_dir(workdir.path()) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + // Older solc versions do not expose an import-callback switch. Literal source validation and + // an empty base/current directory prevent their filesystem importer from resolving a file. + if source.version >= Version::new(0, 8, 22) { + command.arg("--no-import-callback"); + } + let (stdout, stderr, status) = + run_bounded_command(command, Some(raw_input), MAX_STDOUT, MAX_STDERR, COMPILE_TIMEOUT) + .await?; + if !status.success() { + return Err(format!("solc compilation failed: {}", sanitize_remote(&stderr))); + } + let output: CompilerOutput = + serde_json::from_slice(&stdout).map_err(|_| "solc returned invalid JSON".to_string())?; + if output.has_error() { + return Err("solc compilation failed".to_string()); + } + candidates_from_output(source, output) +} + +fn candidates_from_output( + source: &ExternalSource, + output: CompilerOutput, +) -> Result<(Vec, bool), String> { + let fingerprint = fingerprint(&source.input).map_err(|e| e.to_string())?; + let mut candidates = Vec::new(); + let mut creation_bytecode_bytes = 0usize; + let mut has_unresolved_links = false; + for (path, contracts) in output.contracts { + for (name, contract) in contracts { + let Some(abi) = contract.abi else { continue }; + let Some(bytecode) = contract.evm.and_then(|evm| evm.bytecode) else { continue }; + if !bytecode.link_references.is_empty() || bytecode.object.is_unlinked() { + has_unresolved_links = true; + continue; + } + let Some(bytes) = bytecode.object.into_bytes() else { continue }; + if bytes.is_empty() { + continue; + } + validate_identifier(&path.to_string_lossy(), "compiler source path") + .map_err(|e| e.to_string())?; + validate_identifier(&name, "contract name").map_err(|e| e.to_string())?; + if candidates.len() >= MAX_CANDIDATES { + return Err("external candidate limit exceeded".to_string()); + } + if creation_bytecode_bytes.saturating_add(bytes.len()) > MAX_CREATION_BYTECODE { + return Err("external creation bytecode limit exceeded".to_string()); + } + creation_bytecode_bytes += bytes.len(); + candidates.push(Candidate { + input: source.input.clone(), + fingerprint: fingerprint.clone(), + version: source.version.clone(), + fqn: format!("{}:{name}", path.display()), + constructor: abi.constructor().cloned(), + creation_bytecode: bytes, + }); + } + } + Ok((candidates, has_unresolved_links)) +} + +fn parse_solc_version_output(output: &[u8]) -> Result { + let output = + std::str::from_utf8(output).map_err(|_| "invalid solc version output".to_string())?; + output + .lines() + .find_map(|line| line.trim().strip_prefix("Version: ")) + .ok_or_else(|| "solc version output is missing Version line".to_string()) + // Linux solc builds identify GCC as `g++`, whose plus signs are invalid in SemVer build + // metadata. Use the same normalization as foundry-compilers' solc version parser. + .and_then(|version| { + Version::parse(&version.replace(".g++", ".gcc")) + .map_err(|_| "invalid solc version".to_string()) + }) +} + +async fn run_bounded_command( + mut command: Command, + input: Option>, + stdout_limit: usize, + stderr_limit: usize, + timeout: Duration, +) -> Result<(Vec, Vec, std::process::ExitStatus), String> { + let deadline = tokio::time::Instant::now() + timeout; + // Reserve part of the caller's timeout for cancelling I/O, terminating, and reaping. This + // keeps the complete subprocess lifecycle within one absolute deadline. + let cleanup_window = std::cmp::min(timeout / 10, Duration::from_secs(1)); + let execution_deadline = deadline - cleanup_window; + command + .stdin(if input.is_some() { Stdio::piped() } else { Stdio::null() }) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + let mut child = command.spawn().map_err(|_| "failed to start subprocess".to_string())?; + let stdout = + child.stdout.take().ok_or_else(|| "failed to open subprocess stdout".to_string())?; + let stderr = + child.stderr.take().ok_or_else(|| "failed to open subprocess stderr".to_string())?; + let mut input_task = input.map(|input| { + let mut stdin = child.stdin.take().expect("piped stdin"); + tokio::spawn(async move { + stdin.write_all(&input).await?; + stdin.shutdown().await + }) + }); + let mut output_task = tokio::spawn(async move { + tokio::try_join!(read_capped(stdout, stdout_limit), read_capped(stderr, stderr_limit)) + }); + let mut input_joined = false; + let mut output_joined = false; + let lifecycle = async { + let mut completed_output = None; + let status = tokio::select! { + result = child.wait() => { + result.map_err(|_| "failed to wait for subprocess".to_string())? + } + output = &mut output_task => { + output_joined = true; + match output { + Ok(Ok(output)) => { + completed_output = Some(output); + child.wait().await.map_err(|_| "failed to wait for subprocess".to_string())? + } + Ok(Err(err)) => return Err(err), + Err(_) => return Err("subprocess output task failed".to_string()), + } + } + }; + if let Some(task) = input_task.as_mut() { + let input = task.await; + input_joined = true; + input + .map_err(|_| "subprocess input task failed".to_string())? + .map_err(|_| "failed to write subprocess input".to_string())?; + } + let (stdout, stderr) = match completed_output { + Some(output) => output, + None => { + let output = (&mut output_task).await; + output_joined = true; + output.map_err(|_| "subprocess output task failed".to_string())?? + } + }; + Ok((stdout, stderr, status)) + }; + let result = match tokio::time::timeout_at(execution_deadline, lifecycle).await { + Ok(Ok(output)) => Ok(output), + Ok(Err(err)) => Err(err), + Err(_) => Err("subprocess timed out".to_string()), + }; + if result.is_ok() { + return result; + } + + if let Some(task) = &input_task { + task.abort(); + } + output_task.abort(); + let _ = child.start_kill(); + let cleanup = async { + if !input_joined && let Some(task) = input_task { + let _ = task.await; + } + if !output_joined { + let _ = output_task.await; + } + let _ = child.wait().await; + }; + // If the OS does not complete cleanup in its reserved window, dropping the child retains + // `kill_on_drop` as a final best-effort safeguard while preserving the caller's deadline. + let _ = tokio::time::timeout_at(deadline, cleanup).await; + result +} + +async fn read_capped(mut reader: impl AsyncRead + Unpin, limit: usize) -> Result, String> { + let mut output = Vec::new(); + let mut chunk = [0; 8192]; + loop { + let read = + reader.read(&mut chunk).await.map_err(|_| "failed to read solc output".to_string())?; + if read == 0 { + return Ok(output); + } + if output.len().saturating_add(read) > limit { + return Err("solc output limit exceeded".to_string()); + } + output.extend_from_slice(&chunk[..read]); + } +} + +fn sanitize_remote(message: &[u8]) -> String { + let message = String::from_utf8_lossy(message); + let clean = message + .chars() + .map(|ch| if ch.is_control() { ' ' } else { ch }) + .take(160) + .collect::(); + if clean.is_empty() { "no diagnostic".to_string() } else { clean } +} + +fn is_canonical_value(value: &DynSolValue) -> bool { + match value { + DynSolValue::Uint(value, bits) => value.bit_len() <= *bits, + DynSolValue::Int(value, bits) => { + if *bits == 0 || *bits > 256 || bits % 8 != 0 { + return false; + } + let bytes = value.to_be_bytes::<32>(); + let value_start = 32 - bits / 8; + let extension = if bytes[value_start] & 0x80 == 0 { 0 } else { 0xff }; + bytes[..value_start].iter().all(|byte| *byte == extension) + } + DynSolValue::FixedBytes(value, size) => { + *size <= value.len() && value[*size..].iter().all(|byte| *byte == 0) + } + DynSolValue::Array(values) + | DynSolValue::FixedArray(values) + | DynSolValue::Tuple(values) => values.iter().all(is_canonical_value), + DynSolValue::CustomStruct { tuple, .. } => tuple.iter().all(is_canonical_value), + DynSolValue::Bool(_) + | DynSolValue::Address(_) + | DynSolValue::Function(_) + | DynSolValue::Bytes(_) + | DynSolValue::String(_) => true, + } +} + +pub(super) fn match_candidates<'a>( + observed: &[u8], + candidates: impl IntoIterator, +) -> MatchResult { + let mut matches = Vec::new(); + for candidate in candidates { + let bytecode = candidate.creation_bytecode.as_ref(); + if bytecode.is_empty() { + continue; + } + let Some(suffix) = observed.strip_prefix(bytecode) else { continue }; + let valid = match &candidate.constructor { + None => true, + Some(constructor) if constructor.inputs.is_empty() => true, + Some(constructor) => constructor + .abi_decode_input(suffix) + .ok() + .filter(|values| values.iter().all(is_canonical_value)) + .and_then(|values| constructor.abi_encode_input(&values).ok()) + .is_some_and(|encoded| suffix.starts_with(&encoded)), + }; + if valid + && !matches.iter().any(|item: &ExternalMatch| { + item.fqn == candidate.fqn + && compiler_identity(&item.version) == compiler_identity(&candidate.version) + && item.creation_bytecode == candidate.creation_bytecode + && item.constructor_args.as_ref() == suffix + }) + { + matches.push(ExternalMatch { + input: candidate.input.clone(), + version: candidate.version.clone(), + fqn: candidate.fqn.clone(), + creation_bytecode: candidate.creation_bytecode.clone(), + constructor_args: Bytes::copy_from_slice(suffix), + }); + } + } + match matches.len() { + 0 => MatchResult::None, + 1 => MatchResult::Unique(matches.pop().unwrap()), + _ => MatchResult::Ambiguous(matches), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_json_abi::JsonAbi; + use foundry_cli::utils::parse_json; + use tokio::net::TcpListener; + + fn input() -> Value { + json!({ + "language": "Solidity", + "sources": { "A.sol": { "content": "contract A {}", "custom": 1 } }, + "settings": { "optimizer": { "enabled": true }, "outputSelection": {"old": []} }, + "unknown": { "preserved": true } + }) + } + + fn candidate(fqn: &str, abi: JsonAbi) -> Candidate { + Candidate { + input: Arc::new(input()), + fingerprint: "fingerprint".into(), + version: Version::parse("0.8.30+commit.73712a01").unwrap(), + fqn: fqn.into(), + constructor: abi.constructor().cloned(), + creation_bytecode: Bytes::from_static(&[0x60, 0x00]), + } + } + + #[test] + fn validation_preserves_unknown_fields_and_only_changes_output_selection() { + let original = input(); + let compiled = compilation_input(&original).unwrap(); + assert_eq!(compiled["unknown"], original["unknown"]); + assert_eq!(compiled["sources"], original["sources"]); + assert_eq!(compiled["settings"]["optimizer"], original["settings"]["optimizer"]); + assert_ne!( + compiled["settings"]["outputSelection"], + original["settings"]["outputSelection"] + ); + assert_eq!(original["settings"]["outputSelection"], json!({"old": []})); + + let mut url_only = input(); + url_only["sources"]["A.sol"] = json!({"urls": ["ipfs://source"]}); + assert!(validate_input(&url_only).is_err()); + } + + #[test] + fn source_paths_are_bounded_printable_ascii() { + assert!(validate_identifier("src/A.sol", "path").is_ok()); + assert!(validate_identifier("bad\npath.sol", "path").is_err()); + assert!(validate_identifier("café.sol", "path").is_err()); + assert!(validate_identifier(&"x".repeat(MAX_SOURCE_PATH + 1), "path").is_err()); + } + + #[test] + fn cumulative_source_budget_charges_success_once() { + let mut resolver = ExternalResolver::new().unwrap(); + let source = ExternalSource { + input: Arc::new(input()), + version: Version::new(0, 8, 30), + provider: SourceProvider::Sourcify { endpoint: SOURCIFY_URL.into() }, + }; + let key = FetchKey { chain: 1, provider: source.provider.clone(), address: Address::ZERO }; + let charged = resolver.cache_source_result(key.clone(), Ok(Some(source))).unwrap(); + assert!(charged.is_some()); + let charged_bytes = resolver.retained_source_input; + // A cache hit returns the retained value directly and never invokes accounting again. + assert!(resolver.fetch_cache.get(&key).unwrap().is_ok()); + assert_eq!(resolver.retained_source_input, charged_bytes); + + resolver.retained_source_input = MAX_RETAINED_SOURCE_INPUT; + let rejected_key = FetchKey { address: Address::with_last_byte(1), ..key }; + assert!( + resolver + .cache_source_result(rejected_key.clone(), Ok(charged)) + .unwrap_err() + .contains("cumulative") + ); + assert!(!resolver.fetch_cache.contains_key(&rejected_key)); + } + + #[test] + fn cumulative_candidate_metadata_budget_is_enforced() { + let mut resolver = ExternalResolver::new().unwrap(); + let retained_candidate = candidate("A.sol:A", JsonAbi::new()); + let expected = retained_candidate.fqn.len() + + retained_candidate.fingerprint.len() + + retained_candidate.version.to_string().len(); + resolver.charge_candidates(vec![retained_candidate], 0).unwrap(); + assert_eq!(resolver.retained_metadata, expected); + + let mut resolver = ExternalResolver::new().unwrap(); + resolver.retained_metadata = MAX_RETAINED_METADATA; + let error = + resolver.charge_candidates(vec![candidate("A.sol:A", JsonAbi::new())], 0).unwrap_err(); + assert!(error.contains("metadata")); + assert_eq!(resolver.retained_metadata, MAX_RETAINED_METADATA); + assert_eq!(resolver.retained_candidates, 0); + assert_eq!(resolver.retained_creation_bytecode, 0); + + let mut resolver = ExternalResolver::new().unwrap(); + resolver.retained_creation_bytecode = MAX_CREATION_BYTECODE; + let error = + resolver.charge_candidates(vec![candidate("A.sol:A", JsonAbi::new())], 0).unwrap_err(); + assert!(error.contains("bytecode")); + assert_eq!(resolver.retained_metadata, 0); + assert_eq!(resolver.retained_candidates, 0); + assert_eq!(resolver.retained_creation_bytecode, MAX_CREATION_BYTECODE); + } + + #[test] + fn candidate_constructor_metadata_is_charged() { + let abi: JsonAbi = serde_json::from_value(json!([{ + "type": "constructor", "inputs": [{"name":"n", "type":"uint256"}] + }])) + .unwrap(); + let candidate = candidate("A.sol:A", abi); + let constructor_bytes = + serde_json::to_vec(candidate.constructor.as_ref().unwrap()).unwrap(); + let mut resolver = ExternalResolver::new().unwrap(); + resolver.charge_candidates(vec![candidate], 0).unwrap(); + assert!(resolver.retained_metadata >= constructor_bytes.len()); + } + + #[tokio::test] + async fn sourcify_source_discovery_uses_selected_endpoint_on_dev_chain() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("http://{}/private", listener.local_addr().unwrap()); + let response = json!({ + "stdJsonInput": input(), + "compilation": { "compilerVersion": "0.8.30+commit.73712a01" } + }) + .to_string(); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = [0; 4096]; + let bytes_read = socket.read(&mut request).await.unwrap(); + let request = std::str::from_utf8(&request[..bytes_read]).unwrap(); + assert!(request.starts_with("GET /private/v2/contract/31337/"), "{request}"); + socket + .write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response}", + response.len() + ) + .as_bytes(), + ) + .await + .unwrap(); + }); + + let source = ExternalResolver::new() + .unwrap() + .resolve_sourcify(Chain::from(31337), Address::ZERO, Some(&endpoint)) + .await + .unwrap() + .unwrap(); + server.await.unwrap(); + assert_eq!(source.provider, SourceProvider::Sourcify { endpoint: endpoint.clone() }); + assert_ne!(source.provider, SourceProvider::Sourcify { endpoint: SOURCIFY_URL.into() }); + } + + #[tokio::test] + async fn sourcify_cache_identity_includes_selected_endpoint() { + let mut resolver = ExternalResolver::new().unwrap(); + for _ in 0..2 { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("http://{}/private", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = [0; 4096]; + assert!(socket.read(&mut request).await.unwrap() > 0); + socket + .write_all( + b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .await + .unwrap(); + }); + assert!( + resolver + .resolve_sourcify(Chain::from(31337), Address::ZERO, Some(&endpoint)) + .await + .unwrap() + .is_none() + ); + server.await.unwrap(); + } + assert_eq!(resolver.fetch_cache.len(), 2); + } + + #[test] + fn remote_versions_and_cached_errors_are_bounded_and_accounted() { + let long_version = format!("0.8.30+{}", "a".repeat(MAX_COMPILER_VERSION)); + assert!(Version::parse(&long_version).is_ok()); + assert!(parse_compiler_version(&long_version).is_err()); + + let mut resolver = ExternalResolver::new().unwrap(); + let endpoint = "https://explorer.invalid/api"; + let key = FetchKey { + chain: 1, + provider: SourceProvider::Etherscan { endpoint: endpoint.into() }, + address: Address::ZERO, + }; + let error = resolver + .cache_source_result(key, Err("x".repeat(MAX_CACHED_ERROR_CHARS * 2))) + .unwrap_err(); + assert_eq!(error.chars().count(), MAX_CACHED_ERROR_CHARS + 1); + assert_eq!(resolver.retained_metadata, endpoint.len() + error.len()); + + let mut resolver = ExternalResolver::new().unwrap(); + resolver.retained_metadata = MAX_RETAINED_METADATA; + let key = FetchKey { + chain: 1, + provider: SourceProvider::Sourcify { endpoint: SOURCIFY_URL.into() }, + address: Address::ZERO, + }; + let source = ExternalSource { + input: Arc::new(input()), + version: Version::new(0, 8, 30), + provider: key.provider.clone(), + }; + assert!(resolver.cache_source_result(key, Ok(Some(source))).is_err()); + assert_eq!(resolver.retained_source_input, 0); + assert_eq!(resolver.retained_metadata, MAX_RETAINED_METADATA); + } + + #[tokio::test] + async fn etherscan_source_discovery_does_not_follow_redirects() { + let target = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let source = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("http://{}", source.local_addr().unwrap()); + let location = format!("http://{}", target.local_addr().unwrap()); + let server = tokio::spawn(async move { + let (mut socket, _) = source.accept().await.unwrap(); + let mut request = [0; 4096]; + let bytes_read = socket.read(&mut request).await.unwrap(); + assert!(bytes_read > 0); + socket + .write_all( + format!( + "HTTP/1.1 307 Temporary Redirect\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ) + .as_bytes(), + ) + .await + .unwrap(); + }); + + let error = ExternalResolver::new() + .unwrap() + .resolve_etherscan(Chain::mainnet(), Address::ZERO, Some(&endpoint), None) + .await + .unwrap_err(); + server.await.unwrap(); + assert!(error.contains("HTTP 307"), "unexpected discovery error: {error}"); + assert!( + tokio::time::timeout(Duration::from_millis(50), target.accept()).await.is_err(), + "external source discovery followed the redirect" + ); + } + + #[test] + fn etherscan_parser_preserves_raw_standard_json() { + let expected = json!({ + "language": "Solidity", + "sources": { "A.sol": { + "content": "contract A {}", + "urls": ["dweb:/ipfs/source"], + "keccak256": "0x1234" + } }, + "settings": {}, + "unknown": { "preserved": true } + }); + for source in [expected.clone(), Value::String(format!("{{{expected}}}"))] { + let response = json!({ + "status": "1", + "message": "OK", + "result": [{ "SourceCode": source, "CompilerVersion": "v0.8.30+commit.73712a01" }] + }); + let (parsed, version) = + parse_etherscan_response(&serde_json::to_vec(&response).unwrap()).unwrap(); + assert_eq!(parsed, expected); + assert_eq!(version, Version::parse("0.8.30+commit.73712a01").unwrap()); + } + + let flat = json!({ + "status": "1", "result": [{ + "SourceCode": "contract A {}", "CompilerVersion": "v0.8.30+commit.73712a01" + }] + }); + assert!(parse_etherscan_response(&serde_json::to_vec(&flat).unwrap()).is_err()); + } + + #[test] + fn fingerprint_ignores_object_key_order() { + let first = input(); + let second: Value = parse_json( + r#"{"unknown":{"preserved":true},"settings":{"outputSelection":{"old":[]},"optimizer":{"enabled":true}},"sources":{"A.sol":{"custom":1,"content":"contract A {}"}},"language":"Solidity"}"#, + ) + .unwrap(); + assert_eq!(fingerprint(&first).unwrap(), fingerprint(&second).unwrap()); + + let source_a = ExternalSource { + input: Arc::new(first), + version: Version::new(1, 2, 3), + provider: SourceProvider::Sourcify { endpoint: SOURCIFY_URL.into() }, + }; + let source_b = ExternalSource { + provider: SourceProvider::Etherscan { endpoint: "other".into() }, + ..source_a.clone() + }; + assert_eq!(fingerprint(&source_a.input).unwrap(), fingerprint(&source_b.input).unwrap()); + } + + #[test] + fn compiler_version_requires_commit_but_allows_platform_suffix() { + let requested = Version::parse("0.8.30+commit.73712a01").unwrap(); + assert!(compiler_matches( + &requested, + &Version::parse("0.8.30+commit.73712a01.Linux.gcc").unwrap() + )); + assert!(!compiler_matches(&requested, &Version::parse("0.8.30+commit.deadbeef").unwrap())); + assert!(!compiler_matches(&Version::new(0, 8, 30), &requested)); + } + + #[test] + fn parses_real_solc_version_output() { + let output = b"solc, the solidity compiler commandline interface\nVersion: 0.8.30+commit.73712a01.Darwin.appleclang\n"; + assert_eq!( + parse_solc_version_output(output).unwrap(), + Version::parse("0.8.30+commit.73712a01.Darwin.appleclang").unwrap() + ); + let linux = b"Version: 0.8.30+commit.73712a01.Linux.g++\n"; + assert_eq!( + parse_solc_version_output(linux).unwrap(), + Version::parse("0.8.30+commit.73712a01.Linux.gcc").unwrap() + ); + assert!(parse_solc_version_output(b"Version: forged").is_err()); + } + + #[cfg(unix)] + #[tokio::test] + async fn bounded_process_times_out_and_caps_output() { + let mut sleep = Command::new("sh"); + sleep.args(["-c", "sleep 2"]); + let started = tokio::time::Instant::now(); + let error = + run_bounded_command(sleep, None, 16, 16, Duration::from_millis(20)).await.unwrap_err(); + assert!(error.contains("timed out")); + assert!(started.elapsed() < Duration::from_millis(500)); + + // Output closes before the process exits, so the output-task branch wins first. Waiting + // for the process must remain subject to the original deadline. + let mut closed_output = Command::new("sh"); + closed_output.args(["-c", "exec 1>&- 2>&-; sleep 2"]); + assert!( + run_bounded_command(closed_output, None, 16, 16, Duration::from_millis(20)) + .await + .unwrap_err() + .contains("timed out") + ); + + let mut output = Command::new("sh"); + output.args(["-c", "printf 12345"]); + assert!( + run_bounded_command(output, None, 4, 16, Duration::from_secs(1)) + .await + .unwrap_err() + .contains("output limit") + ); + + // The shell exits immediately, but its child retains the output pipe. The same absolute + // deadline must still cover draining output after `child.wait()` completes. + let mut inherited_output = Command::new("sh"); + inherited_output.args(["-c", "sleep 2 &"]); + assert!( + run_bounded_command(inherited_output, None, 16, 16, Duration::from_millis(20)) + .await + .unwrap_err() + .contains("timed out") + ); + + // The direct child exits while a descendant retains stdin without reading it. Awaiting a + // blocked writer must also remain subject to the same deadline. + let mut inherited_input = Command::new("sh"); + inherited_input.args(["-c", "exec 3<&0; sleep 2 >/dev/null 2>&1 &"]); + assert!( + run_bounded_command( + inherited_input, + Some(vec![0; 1024 * 1024]), + 16, + 16, + Duration::from_millis(20) + ) + .await + .unwrap_err() + .contains("timed out") + ); + } + + #[test] + fn static_constructor_matching_accepts_trailing_data_and_preserves_full_suffix() { + let constructor: JsonAbi = serde_json::from_value(json!([{ + "type": "constructor", "inputs": [{"name":"n", "type":"uint256"}] + }])) + .unwrap(); + let candidate = candidate("A.sol:A", constructor); + let mut observed = vec![0x60, 0x00]; + observed.extend([0; 31]); + observed.push(7); + observed.extend([0xaa, 0xbb]); + let MatchResult::Unique(found) = + match_candidates(&observed, std::slice::from_ref(&candidate)) + else { + panic!("expected match") + }; + assert_eq!(found.constructor_args.as_ref(), &observed[2..]); + assert!(matches!(match_candidates(&[0x60, 0x00, 7], &[candidate]), MatchResult::None)); + } + + #[test] + fn dynamic_constructor_matching_requires_a_canonical_prefix() { + let constructor: JsonAbi = serde_json::from_value(json!([{ + "type": "constructor", + "inputs": [{"name":"n", "type":"uint256"}, {"name":"data", "type":"bytes"}] + }])) + .unwrap(); + let candidate = candidate("A.sol:A", constructor); + let mut args = vec![0; 128]; + args[31] = 7; + args[63] = 64; + args[95] = 2; + args[96..98].copy_from_slice(&[0xaa, 0xbb]); + let mut observed = candidate.creation_bytecode.to_vec(); + observed.extend_from_slice(&args); + observed.extend([0xcc, 0xdd, 0xee]); + let MatchResult::Unique(matched) = + match_candidates(&observed, std::slice::from_ref(&candidate)) + else { + panic!("expected match") + }; + assert_eq!(matched.constructor_args.as_ref(), &observed[2..]); + + args[127] = 1; + let mut noncanonical = candidate.creation_bytecode.to_vec(); + noncanonical.extend(args); + assert!(matches!(match_candidates(&noncanonical, &[candidate]), MatchResult::None)); + } + + #[test] + fn narrow_constructor_values_require_canonical_words() { + let constructor: JsonAbi = serde_json::from_value(json!([{ + "type": "constructor", "inputs": [{"name":"n", "type":"uint8"}] + }])) + .unwrap(); + let candidate = candidate("A.sol:A", constructor); + let mut observed = candidate.creation_bytecode.to_vec(); + observed.extend([0; 31]); + observed.push(7); + assert!(matches!( + match_candidates(&observed, std::slice::from_ref(&candidate)), + MatchResult::Unique(_) + )); + + observed[2] = 1; + assert!(matches!(match_candidates(&observed, &[candidate]), MatchResult::None)); + } + + #[test] + fn struct_constructor_values_require_canonical_words() { + let constructor: JsonAbi = serde_json::from_value(json!([{ + "type": "constructor", + "inputs": [{ + "name": "s", + "type": "tuple", + "internalType": "struct A.S", + "components": [{"name": "n", "type": "uint8", "internalType": "uint8"}] + }] + }])) + .unwrap(); + let candidate = candidate("A.sol:A", constructor); + let mut observed = candidate.creation_bytecode.to_vec(); + observed.extend([0; 31]); + observed.push(7); + assert!(matches!( + match_candidates(&observed, std::slice::from_ref(&candidate)), + MatchResult::Unique(_) + )); + + observed[2] = 1; + assert!(matches!(match_candidates(&observed, &[candidate]), MatchResult::None)); + } + + #[test] + fn no_constructor_and_zero_inputs_accept_and_preserve_suffixes() { + let none = candidate("A.sol:A", JsonAbi::new()); + assert!(matches!( + match_candidates(&[0x60, 0x00], std::slice::from_ref(&none)), + MatchResult::Unique(_) + )); + let MatchResult::Unique(matched) = match_candidates(&[0x60, 0x00, 0xaa], &[none]) else { + panic!("expected match") + }; + assert_eq!(matched.constructor_args.as_ref(), &[0xaa]); + + let zero: JsonAbi = + serde_json::from_value(json!([{"type":"constructor","inputs":[]}])).unwrap(); + assert!(matches!( + match_candidates(&[0x60, 0x00], &[candidate("B.sol:B", zero.clone())]), + MatchResult::Unique(_) + )); + let MatchResult::Unique(matched) = + match_candidates(&[0x60, 0x00, 0xbb], &[candidate("B.sol:B", zero)]) + else { + panic!("expected match") + }; + assert_eq!(matched.constructor_args.as_ref(), &[0xbb]); + } + + #[test] + fn ambiguity_collapses_equivalent_deployments_from_different_inputs() { + let a = candidate("A.sol:A", JsonAbi::new()); + let first_input = a.input.clone(); + let mut duplicate = a.clone(); + duplicate.fingerprint = "different-provider-input".into(); + duplicate.input = Arc::new(json!({ "providerSpecific": true })); + duplicate.version = Version::parse("0.8.30+commit.73712a01.Linux.gcc").unwrap(); + let MatchResult::Unique(matched) = match_candidates(&[0x60, 0x00], &[a.clone(), duplicate]) + else { + panic!("equivalent deployments should be deduplicated") + }; + assert!(Arc::ptr_eq(&matched.input, &first_input)); + let mut other_commit = a.clone(); + other_commit.version = Version::parse("0.8.30+commit.deadbeef").unwrap(); + assert!(matches!( + match_candidates(&[0x60, 0x00], &[a.clone(), other_commit]), + MatchResult::Ambiguous(_) + )); + + let constructor: JsonAbi = serde_json::from_value(json!([{ + "type": "constructor", "inputs": [{"name":"n", "type":"uint256"}] + }])) + .unwrap(); + let prefixed = candidate("A.sol:A", constructor); + let mut observed = prefixed.creation_bytecode.to_vec(); + observed.extend([0; 32]); + let exact = Candidate { + constructor: None, + creation_bytecode: Bytes::copy_from_slice(&observed), + ..prefixed.clone() + }; + assert!(matches!( + match_candidates(&observed, &[prefixed, exact]), + MatchResult::Ambiguous(_) + )); + + let b = candidate("B.sol:B", JsonAbi::new()); + let MatchResult::Ambiguous(matches) = match_candidates(&[0x60, 0x00], &[a, b]) else { + panic!("expected ambiguity") + }; + assert_eq!(matches.len(), 2); + } + + #[test] + fn unresolved_library_links_are_flagged_without_dropping_other_candidates() { + let placeholder = format!("__${}$__", "0".repeat(34)); + let output = serde_json::from_value(json!({ + "contracts": { + "A.sol": { + "Linked": { + "abi": [], + "evm": { "bytecode": { + "object": "6000", + "linkReferences": { + "Lib.sol": { "Lib": [{ "start": 0, "length": 20 }] } + } + }} + }, + "Placeholder": { + "abi": [], + "evm": { "bytecode": { "object": placeholder } } + }, + "Plain": { + "abi": [], + "evm": { "bytecode": { "object": "6001" } } + } + } + } + })) + .unwrap(); + let source = ExternalSource { + input: Arc::new(input()), + version: Version::new(0, 8, 30), + provider: SourceProvider::Sourcify { endpoint: SOURCIFY_URL.into() }, + }; + let (candidates, has_unresolved_links) = candidates_from_output(&source, output).unwrap(); + assert!(has_unresolved_links); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].fqn, "A.sol:Plain"); + } + + #[test] + fn capped_chunk_aggregation_rejects_limit_plus_one() { + let mut output = Vec::new(); + assert!(append_capped(&mut output, b"123", 4)); + assert!(!append_capped(&mut output, b"45", 4)); + assert_eq!(output, b"123"); + } + + #[test] + fn empty_creation_bytecode_never_matches() { + let mut empty = candidate("A.sol:A", JsonAbi::new()); + empty.creation_bytecode = Bytes::new(); + assert!(matches!(match_candidates(&[], &[empty]), MatchResult::None)); + } + + #[test] + fn candidates_share_source_input() { + let first = candidate("A.sol:A", JsonAbi::new()); + let second = Candidate { fqn: "A.sol:B".into(), ..first.clone() }; + assert!(Arc::ptr_eq(&first.input, &second.input)); + } + + #[test] + fn remote_diagnostics_are_bounded_and_sanitized() { + let message = format!("secret\n{}", "x".repeat(300)); + let clean = sanitize_remote(message.as_bytes()); + assert!(!clean.contains('\n')); + assert_eq!(clean.chars().count(), 160); + } +} diff --git a/crates/script/src/wallet_session.rs b/crates/script/src/wallet_session.rs new file mode 100644 index 0000000000000..d6bab8b35bf53 --- /dev/null +++ b/crates/script/src/wallet_session.rs @@ -0,0 +1,859 @@ +//! `forge script --session` CLI wrapper support. +//! +//! This module translates forge-specific `--session-*` flags into the outer +//! `cast wallet session --for` invocation and keeps those wrapper details out of the main script +//! execution flow. + +use crate::ScriptArgs; +use alloy_primitives::Address; +use clap::Args; +use eyre::{Result, WrapErr}; +use foundry_cli::{opts::TEMPO_SESSION_ID_ENV, utils::LoadConfig}; +use foundry_config::Config; +use std::{ + ffi::{OsStr, OsString}, + path::{Path, PathBuf}, + process::Command, +}; + +const SESSION_WRAPPER_ENV_REMOVE: &[&str] = &[ + TEMPO_SESSION_ID_ENV, + "ETH_KEYSTORE", + "ETH_KEYSTORE_ACCOUNT", + "ETH_PASSWORD", + "TEMPO_ACCESS_KEY", + "TEMPO_ROOT_ACCOUNT", +]; + +/// Arguments that make `forge script` a thin wrapper around `cast wallet session --for`. +#[derive(Clone, Debug, Default, Args)] +pub struct ScriptWalletSessionArgs { + /// Create a temporary Tempo wallet session for this script run. + #[arg( + long = "session", + id = "wallet_session", + conflicts_with_all = ["tempo_session", "unlocked"] + )] + pub enabled: bool, + + /// Root account that authorizes the temporary session. + #[arg( + long = "session-root", + id = "wallet_session_root", + value_name = "ADDRESS", + requires = "wallet_session" + )] + pub root: Option
, + + /// Session lifetime, expressed as a duration like `10m`, `2h`, or `7d`. + #[arg( + long = "session-expires", + id = "wallet_session_expires", + value_name = "DURATION", + requires = "wallet_session" + )] + pub expires: Option, + + /// Allowed call scope, in `TARGET[:SELECTORS[@RECIPIENTS]]` format. + #[arg( + long = "session-scope", + id = "wallet_session_scope", + value_name = "SCOPE", + requires = "wallet_session" + )] + pub scopes: Vec, + + /// Allowed call target for issue-style `--target ... --selector ...` input. + #[arg( + long = "session-target", + id = "wallet_session_target", + value_name = "ADDRESS", + requires = "wallet_session" + )] + pub target: Option
, + + /// Function selector allowed for `--session-target`, such as `register(address)`. + #[arg( + long = "session-selector", + id = "wallet_session_selector", + value_name = "SELECTOR", + requires = "wallet_session" + )] + pub selectors: Vec, + + /// Token spend limit, in `TOKEN:AMOUNT` or `TOKEN=AMOUNT` format. + #[arg( + long = "session-spend-limit", + id = "wallet_session_spend_limit", + value_name = "LIMIT", + requires = "wallet_session" + )] + pub spend_limits: Vec, + + /// Open an interactive prompt for the root private key. + #[arg( + long = "session-interactive", + id = "wallet_session_interactive", + requires = "wallet_session" + )] + pub interactive: bool, + + /// Root private key that signs the session authorization. + #[arg( + long = "session-private-key", + id = "wallet_session_private_key", + value_name = "RAW_PRIVATE_KEY", + requires = "wallet_session" + )] + pub private_key: Option, + + /// Root mnemonic phrase or mnemonic file path. + #[arg( + long = "session-mnemonic", + id = "wallet_session_mnemonic", + value_name = "MNEMONIC", + requires = "wallet_session" + )] + pub mnemonic: Option, + + /// Passphrase for `--session-mnemonic`. + #[arg( + long = "session-mnemonic-passphrase", + id = "wallet_session_mnemonic_passphrase", + value_name = "PASSPHRASE", + requires = "wallet_session_mnemonic" + )] + pub mnemonic_passphrase: Option, + + /// Wallet derivation path for the root signer. + #[arg( + long = "session-hd-path", + id = "wallet_session_hd_path", + value_name = "PATH", + requires = "wallet_session" + )] + pub hd_path: Option, + + /// Mnemonic or hardware-wallet index for the root signer. + #[arg( + long = "session-mnemonic-index", + id = "wallet_session_mnemonic_index", + value_name = "INDEX", + requires = "wallet_session" + )] + pub mnemonic_index: Option, + + /// Root keystore path. + #[arg( + long = "session-keystore", + id = "wallet_session_keystore", + value_name = "PATH", + requires = "wallet_session" + )] + pub keystore: Option, + + /// Root account name from the default keystore directory. + #[arg( + long = "session-account", + id = "wallet_session_account", + value_name = "ACCOUNT_NAME", + requires = "wallet_session" + )] + pub account: Option, + + /// Root keystore password. + #[arg( + long = "session-password", + id = "wallet_session_password", + value_name = "PASSWORD", + requires = "wallet_session" + )] + pub password: Option, + + /// Root keystore password file. + #[arg( + long = "session-password-file", + id = "wallet_session_password_file", + value_name = "PATH", + requires = "wallet_session" + )] + pub password_file: Option, + + /// Use a Ledger as the root signer. + #[arg(long = "session-ledger", id = "wallet_session_ledger", requires = "wallet_session")] + pub ledger: bool, + + /// Use a Trezor as the root signer. + #[arg(long = "session-trezor", id = "wallet_session_trezor", requires = "wallet_session")] + pub trezor: bool, +} + +impl ScriptArgs { + /// Runs `forge script --session ...` by delegating to `cast wallet session --for`. + /// + /// The outer `cast` process owns the temporary session lifecycle: create the scoped access key, + /// run the reconstructed inner `forge script` command, then revoke the key on exit. + pub(super) fn run_wallet_session_wrapper(&self) -> Result<()> { + let command = self.wallet_session_command_from_env()?; + let mut child = Command::new(&command.program); + child.args(&command.args); + // The outer `cast wallet session` must resolve the root signer from explicit + // `--session-*` inputs, not from stale session/access-key env inherited from the shell. + for key in SESSION_WRAPPER_ENV_REMOVE { + child.env_remove(key); + } + + let status = child.status().wrap_err_with(|| { + format!( + "failed to run `{}` for forge script wallet session", + command.program.to_string_lossy() + ) + })?; + + if status.success() { + Ok(()) + } else { + match status.code() { + Some(code) => { + eyre::bail!("forge script wallet session exited with code {code}"); + } + None => { + eyre::bail!("forge script wallet session terminated by a signal"); + } + } + } + } + + fn wallet_session_command_from_env(&self) -> Result { + let forge = std::env::current_exe().wrap_err("failed to resolve current forge binary")?; + let cast = sibling_binary(&forge, "cast"); + self.wallet_session_command_from_raw_args(std::env::args_os(), forge.into(), cast.into()) + } + + /// Builds the `cast wallet session` command that implements `forge script --session`. + /// + /// `raw_args` is the original `forge script` argv. Wrapper-only `--session-*` flags are removed + /// from the inner command, while the corresponding policy, RPC, and root signer options are + /// translated onto the outer `cast wallet session` invocation. + fn wallet_session_command_from_raw_args( + &self, + raw_args: I, + forge_program: OsString, + cast_program: OsString, + ) -> Result + where + I: IntoIterator, + { + self.wallet_session.validate(self)?; + + // Reconstruct the command that `cast wallet session --for` will run. The inner `forge` + // must not see wrapper-only flags such as `--session-private-key`. + let mut inner = strip_wallet_session_args(raw_args)?; + let Some(program) = inner.first_mut() else { + eyre::bail!("failed to reconstruct forge script command"); + }; + *program = forge_program; + let inner = quote_command(&inner)?; + + let (config, evm_opts) = self.load_config_and_evm_opts()?; + let session = &self.wallet_session; + let mut args = vec![OsString::from("wallet"), OsString::from("session")]; + push_arg(&mut args, "--profile", Config::selected_profile().to_string()); + + // The outer `cast` process creates and later revokes the temporary access key, so it needs + // the session policy, RPC transport settings, and root signer configuration itself. + if let Some(root) = session.root { + push_arg(&mut args, "--root", root.to_string()); + push_arg(&mut args, "--from", root.to_string()); + } + push_opt_arg(&mut args, "--expires", session.expires.as_deref()); + + push_repeated_args(&mut args, "--scope", &session.scopes); + push_opt_arg(&mut args, "--target", session.target); + push_repeated_args(&mut args, "--selector", &session.selectors); + push_repeated_args(&mut args, "--spend-limit", &session.spend_limits); + + if let Some(rpc_url) = evm_opts.fork_url.as_ref() { + push_arg(&mut args, "--rpc-url", rpc_url); + } + push_opt_arg(&mut args, "--chain", evm_opts.env.chain_id); + if config.eth_rpc_accept_invalid_certs { + args.push("--insecure".into()); + } + if config.eth_rpc_no_proxy { + args.push("--no-proxy".into()); + } + push_opt_arg(&mut args, "--rpc-timeout", config.eth_rpc_timeout); + + session.push_root_signer_args(&mut args); + + push_arg(&mut args, "--for", inner); + + Ok(WalletSessionCommand { program: cast_program, args }) + } +} + +impl ScriptWalletSessionArgs { + const STRIP_BOOL_ARGS: &[&str] = + &["--session", "--session-interactive", "--session-ledger", "--session-trezor"]; + + const STRIP_VALUE_ARGS: &[&str] = &[ + "--session-root", + "--session-expires", + "--session-scope", + "--session-target", + "--session-selector", + "--session-spend-limit", + "--session-private-key", + "--session-mnemonic", + "--session-mnemonic-passphrase", + "--session-hd-path", + "--session-mnemonic-index", + "--session-keystore", + "--session-account", + "--session-password", + "--session-password-file", + ]; + + /// Rejects `--session` combinations that cannot be represented by the wallet-session wrapper. + /// + /// Temporary sessions only make sense for a script run that will submit or resume transactions, + /// and debugger runs should stay in the normal in-process execution path. + fn validate(&self, args: &ScriptArgs) -> Result<()> { + if !self.enabled { + return Ok(()); + } + // `cast wallet session --for` creates credentials for the wrapped command; dry-run and + // debugger-only flows do not need a temporary signing session. + if !args.should_broadcast() { + eyre::bail!("forge script --session requires --broadcast or --resume"); + } + if args.debug { + eyre::bail!("forge script --session cannot be used with --debug"); + } + Ok(()) + } + + /// Appends root-signer wallet flags for the outer `cast wallet session` command. + /// + /// `forge script` exposes these as `--session-*` flags so they do not leak into the inner + /// script command. Here they are translated back to the wallet flags that `cast` already + /// understands for signing the session authorization. + fn push_root_signer_args(&self, args: &mut Vec) { + if self.interactive { + args.push("--interactive".into()); + } + for (name, value) in [ + ("--private-key", self.private_key.as_deref()), + ("--mnemonic", self.mnemonic.as_deref()), + ("--mnemonic-passphrase", self.mnemonic_passphrase.as_deref()), + ("--hd-path", self.hd_path.as_deref()), + ("--keystore", self.keystore.as_deref()), + ("--account", self.account.as_deref()), + ("--password", self.password.as_deref()), + ("--password-file", self.password_file.as_deref()), + ] { + push_opt_arg(args, name, value); + } + push_opt_arg(args, "--mnemonic-index", self.mnemonic_index); + if self.ledger { + args.push("--ledger".into()); + } + if self.trezor { + args.push("--trezor".into()); + } + } +} + +#[derive(Debug)] +struct WalletSessionCommand { + program: OsString, + args: Vec, +} + +fn push_arg(args: &mut Vec, name: &'static str, value: impl Into) { + args.push(name.into()); + args.push(value.into()); +} + +fn push_opt_arg( + args: &mut Vec, + name: &'static str, + value: Option, +) { + if let Some(value) = value { + push_arg(args, name, value.to_string()); + } +} + +fn push_repeated_args(args: &mut Vec, name: &'static str, values: &[String]) { + for value in values { + push_arg(args, name, value.as_str()); + } +} + +/// Resolves a sibling Foundry binary first, falling back to `PATH` for source-tree test binaries. +fn sibling_binary(current: &Path, name: &str) -> PathBuf { + let mut binary = current.with_file_name(name); + if cfg!(windows) { + binary.set_extension("exe"); + } + if binary.exists() { binary } else { PathBuf::from(name) } +} + +/// Removes `forge script --session` wrapper flags from the command passed to `--for`. +/// +/// Arguments after `--` and non-UTF-8 values are preserved verbatim because they belong to the +/// script invocation, not to the wrapper's option parser. +fn strip_wallet_session_args(raw_args: I) -> Result> +where + I: IntoIterator, +{ + let mut out = Vec::new(); + let mut args = raw_args.into_iter(); + let mut after_double_dash = false; + + while let Some(arg) = args.next() { + if after_double_dash { + out.push(arg); + continue; + } + if arg == OsStr::new("--") { + after_double_dash = true; + out.push(arg); + continue; + } + + let Some(arg_str) = arg.to_str() else { + out.push(arg); + continue; + }; + + if ScriptWalletSessionArgs::STRIP_BOOL_ARGS.contains(&arg_str) { + continue; + } + let (flag, has_value) = + arg_str.split_once('=').map_or((arg_str, false), |(flag, _)| (flag, true)); + if ScriptWalletSessionArgs::STRIP_VALUE_ARGS.contains(&flag) { + // Support both `--session-root=value` and `--session-root value` while consuming only + // the wrapper option and its value. + if has_value { + continue; + } + args.next().ok_or_else(|| eyre::eyre!("{arg_str} requires a value"))?; + continue; + } + + out.push(arg); + } + + Ok(out) +} + +/// Converts the reconstructed inner argv into the single command string accepted by +/// `cast wallet session --for`. +fn quote_command(args: &[OsString]) -> Result { + args.iter().map(quote_arg).collect::>>().map(|args| args.join(" ")) +} + +/// Quotes one argv item for `--for` while preserving the exact value after shell-style splitting. +fn quote_arg(arg: &OsString) -> Result { + let arg = arg + .to_str() + .ok_or_else(|| eyre::eyre!("forge script wallet session commands must be valid UTF-8"))?; + if arg.is_empty() { + return Ok("\"\"".to_string()); + } + if arg.chars().all(|ch| !ch.is_whitespace() && !matches!(ch, '"' | '\'' | '\\')) { + return Ok(arg.to_string()); + } + + let mut quoted = String::with_capacity(arg.len() + 2); + quoted.push('"'); + for ch in arg.chars() { + if matches!(ch, '"' | '\\') { + quoted.push('\\'); + } + quoted.push(ch); + } + quoted.push('"'); + Ok(quoted) +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + use std::{borrow::Cow, fs}; + use tempfile::tempdir; + + const SESSION_PRIVATE_KEY: &str = + "0x59c6995e998f97a5a004497e5da3b5d2b2b66a87f064d39c44da0b6d6e4f8ff0"; + const SESSION_ROOT_ADDRESS: &str = "0x1111111111111111111111111111111111111111"; + const SESSION_SCOPE_ADDRESS: &str = "0x2222222222222222222222222222222222222222"; + + fn option_value<'a>(args: &'a [Cow<'_, str>], option: &str) -> Option<&'a str> { + args.windows(2) + .find_map(|window| (window[0].as_ref() == option).then_some(window[1].as_ref())) + } + + fn parse_script_args(args: &[&str]) -> ScriptArgs { + ScriptArgs::parse_from(["foundry-cli"].into_iter().chain(args.iter().copied())) + } + + fn raw_forge_script_args<'a>(args: &'a [&'a str]) -> impl Iterator + 'a { + ["forge", "script"].into_iter().chain(args.iter().copied()).map(OsString::from) + } + + fn wallet_session_command( + args: &ScriptArgs, + raw_args: &[&str], + ) -> Result { + args.wallet_session_command_from_raw_args( + raw_forge_script_args(raw_args), + OsString::from("/tmp/forge"), + OsString::from("/tmp/cast"), + ) + } + + fn command_args(command: &WalletSessionCommand) -> Vec> { + command.args.iter().map(|arg| arg.to_string_lossy()).collect() + } + + fn inner_for_command<'a>(args: &'a [Cow<'_, str>]) -> &'a str { + let for_pos = args.iter().position(|arg| arg.as_ref() == "--for").unwrap(); + args[for_pos + 1].as_ref() + } + + fn session_root() -> Address { + SESSION_ROOT_ADDRESS.parse().unwrap() + } + + fn session_target() -> Address { + SESSION_SCOPE_ADDRESS.parse().unwrap() + } + + #[test] + fn can_parse_session_wrapper() { + let root = session_root(); + let target = session_target(); + let args = ScriptArgs::parse_from([ + "foundry-cli", + "Deploy.s.sol", + "--broadcast", + "--session", + "--session-root", + &root.to_string(), + "--session-expires", + "10m", + "--session-target", + &target.to_string(), + "--session-selector", + "register(address)", + "--session-spend-limit", + "PathUSD=0", + "--session-private-key", + SESSION_PRIVATE_KEY, + ]); + + assert!(args.wallet_session.enabled); + assert_eq!(args.wallet_session.root, Some(root)); + assert_eq!(args.wallet_session.expires.as_deref(), Some("10m")); + assert_eq!(args.wallet_session.target, Some(target)); + assert_eq!(args.wallet_session.selectors, ["register(address)"]); + assert_eq!(args.wallet_session.spend_limits, ["PathUSD=0"]); + assert_eq!(args.wallet_session.private_key.as_deref(), Some(SESSION_PRIVATE_KEY)); + } + + #[test] + fn session_wrapper_conflicts_with_existing_session_id() { + let err = ScriptArgs::try_parse_from([ + "foundry-cli", + "Deploy.s.sol", + "--session", + "--tempo.session", + "0x1111111111111111111111111111111111111111111111111111111111111111", + ]) + .unwrap_err(); + + assert!(err.to_string().contains("cannot be used with"), "{err}"); + } + + #[test] + fn session_wrapper_rejects_dry_run() { + let raw_args = [ + "Deploy.s.sol", + "--session", + "--session-root", + SESSION_ROOT_ADDRESS, + "--session-expires", + "10m", + "--session-scope", + SESSION_SCOPE_ADDRESS, + ]; + let args = parse_script_args(&raw_args); + + let err = wallet_session_command(&args, &raw_args).unwrap_err(); + + assert!(err.to_string().contains("requires --broadcast or --resume"), "{err}"); + } + + #[test] + fn session_wrapper_rejects_debug() { + let raw_args = [ + "Deploy.s.sol", + "--broadcast", + "--debug", + "--session", + "--session-root", + SESSION_ROOT_ADDRESS, + "--session-expires", + "10m", + "--session-scope", + SESSION_SCOPE_ADDRESS, + ]; + let args = parse_script_args(&raw_args); + + let err = wallet_session_command(&args, &raw_args).unwrap_err(); + + assert!(err.to_string().contains("cannot be used with --debug"), "{err}"); + } + + #[test] + fn session_wrapper_rewrites_to_cast_session_command() { + let root = session_root(); + let target = session_target(); + let root_arg = root.to_string(); + let target_arg = target.to_string(); + let raw_args = [ + "Deploy.s.sol", + "--broadcast", + "--rpc-url", + "http://127.0.0.1:8545", + "--chain", + "4217", + "--session", + "--session-root", + &root_arg, + "--session-expires", + "10m", + "--session-target", + &target_arg, + "--session-selector", + "register(address)", + "--session-private-key", + SESSION_PRIVATE_KEY, + ]; + let args = parse_script_args(&raw_args); + + let command = wallet_session_command(&args, &raw_args).unwrap(); + + assert_eq!(command.program, OsString::from("/tmp/cast")); + let command_args = command_args(&command); + assert_eq!(command_args[0], "wallet"); + assert_eq!(command_args[1], "session"); + let selected_profile = Config::selected_profile().to_string(); + assert_eq!(option_value(&command_args, "--profile"), Some(selected_profile.as_str())); + assert!(command_args.contains(&"--root".into())); + assert!(command_args.contains(&root.to_string().into())); + assert!(command_args.contains(&"--from".into())); + assert!(command_args.contains(&"--target".into())); + assert!(command_args.contains(&target.to_string().into())); + assert!(command_args.contains(&"--selector".into())); + assert!(command_args.contains(&"register(address)".into())); + assert!(command_args.contains(&"--private-key".into())); + assert!(command_args.contains(&SESSION_PRIVATE_KEY.into())); + + let inner = inner_for_command(&command_args); + assert!(inner.starts_with("/tmp/forge script Deploy.s.sol --broadcast"), "{inner}"); + assert!(inner.contains("--rpc-url http://127.0.0.1:8545"), "{inner}"); + assert!(inner.contains("--chain 4217"), "{inner}"); + assert!(!inner.contains("--session "), "{inner}"); + assert!(!inner.contains("--session-private-key"), "{inner}"); + } + + #[test] + fn session_wrapper_cleans_inherited_tempo_signer_env_for_outer_cast() { + assert_eq!( + SESSION_WRAPPER_ENV_REMOVE, + [ + TEMPO_SESSION_ID_ENV, + "ETH_KEYSTORE", + "ETH_KEYSTORE_ACCOUNT", + "ETH_PASSWORD", + "TEMPO_ACCESS_KEY", + "TEMPO_ROOT_ACCOUNT", + ] + ); + } + + #[test] + fn session_wrapper_uses_project_config_for_cast_session() { + let temp = tempdir().unwrap(); + let project_root = temp.path(); + fs::write( + project_root.join(Config::FILE_NAME), + r#" + [profile.default] + eth_rpc_url = "http://127.0.0.1:8545" + chain_id = 4217 + "#, + ) + .unwrap(); + + let root = session_root(); + let root_arg = root.to_string(); + let project_root_arg = project_root.to_string_lossy(); + let raw_args = [ + "Deploy.s.sol", + "--root", + &project_root_arg, + "--broadcast", + "--session", + "--session-root", + &root_arg, + "--session-expires", + "10m", + "--session-scope", + SESSION_SCOPE_ADDRESS, + ]; + let args = parse_script_args(&raw_args); + + let command = wallet_session_command(&args, &raw_args).unwrap(); + + let command_args = command_args(&command); + assert_eq!(option_value(&command_args, "--rpc-url"), Some("http://127.0.0.1:8545")); + assert_eq!(option_value(&command_args, "--chain"), Some("4217")); + + let inner = inner_for_command(&command_args); + assert!(inner.contains("--root "), "{inner}"); + if !cfg!(windows) { + assert!(inner.contains(project_root.to_string_lossy().as_ref()), "{inner}"); + } + assert!(inner.ends_with("--broadcast"), "{inner}"); + } + + #[test] + fn session_wrapper_forwards_rpc_transport_flags_to_outer_cast() { + let root = session_root(); + let root_arg = root.to_string(); + let raw_args = [ + "Deploy.s.sol", + "--broadcast", + "--rpc-url", + "https://127.0.0.1:8545", + "--insecure", + "--no-proxy", + "--rpc-timeout", + "7", + "--session", + "--session-root", + &root_arg, + "--session-expires", + "10m", + "--session-scope", + SESSION_SCOPE_ADDRESS, + "--session-private-key", + SESSION_PRIVATE_KEY, + ]; + let args = parse_script_args(&raw_args); + + let command = wallet_session_command(&args, &raw_args).unwrap(); + + let command_args = command_args(&command); + assert!(command_args.contains(&"--insecure".into())); + assert!(command_args.contains(&"--no-proxy".into())); + assert_eq!(option_value(&command_args, "--rpc-timeout"), Some("7")); + + let inner = inner_for_command(&command_args); + assert!(inner.contains("--insecure"), "{inner}"); + assert!(inner.contains("--no-proxy"), "{inner}"); + assert!(inner.contains("--rpc-timeout 7"), "{inner}"); + } + + #[test] + fn session_wrapper_leaves_browser_for_inner_forge_validation() { + let raw_args = [ + "Deploy.s.sol", + "--broadcast", + "--session", + "--session-root", + SESSION_ROOT_ADDRESS, + "--session-expires", + "10m", + "--session-scope", + SESSION_SCOPE_ADDRESS, + "--browser", + ]; + let args = parse_script_args(&raw_args); + + let command = wallet_session_command(&args, &raw_args).unwrap(); + let command_args = command_args(&command); + let inner = inner_for_command(&command_args); + + assert!(inner.contains("--browser"), "{inner}"); + } + + #[test] + fn session_wrapper_leaves_script_wallet_signers_for_inner_forge_validation() { + let raw_args = [ + "Deploy.s.sol", + "--broadcast", + "--session", + "--session-root", + SESSION_ROOT_ADDRESS, + "--session-expires", + "10m", + "--session-scope", + SESSION_SCOPE_ADDRESS, + "--private-key", + SESSION_PRIVATE_KEY, + ]; + let args = parse_script_args(&raw_args); + + let command = wallet_session_command(&args, &raw_args).unwrap(); + let command_args = command_args(&command); + let inner = inner_for_command(&command_args); + + assert!(inner.contains("--private-key"), "{inner}"); + assert!(!inner.contains("--session-private-key"), "{inner}"); + } + + #[test] + fn session_wrapper_does_not_infer_root_from_sender() { + let raw_args = [ + "Deploy.s.sol", + "--broadcast", + "--session", + "--sender", + SESSION_ROOT_ADDRESS, + "--session-expires", + "10m", + "--session-scope", + SESSION_SCOPE_ADDRESS, + ]; + let args = parse_script_args(&raw_args); + + let command = wallet_session_command(&args, &raw_args).unwrap(); + let command_args = command_args(&command); + assert_eq!(option_value(&command_args, "--root"), None); + assert_eq!(option_value(&command_args, "--from"), None); + + let inner = inner_for_command(&command_args); + assert!(inner.contains(&format!("--sender {SESSION_ROOT_ADDRESS}")), "{inner}"); + } + + #[test] + fn session_wrapper_leaves_session_policy_requirements_to_cast() { + let raw_args = ["Deploy.s.sol", "--broadcast", "--session"]; + let args = parse_script_args(&raw_args); + + let command = wallet_session_command(&args, &raw_args).unwrap(); + let command_args = command_args(&command); + + assert_eq!(option_value(&command_args, "--root"), None); + assert_eq!(option_value(&command_args, "--expires"), None); + assert_eq!(option_value(&command_args, "--scope"), None); + assert_eq!(option_value(&command_args, "--target"), None); + } +} diff --git a/crates/sol-macro-gen/Cargo.toml b/crates/sol-macro-gen/Cargo.toml index b983dd6a762b8..dad79ae08df12 100644 --- a/crates/sol-macro-gen/Cargo.toml +++ b/crates/sol-macro-gen/Cargo.toml @@ -15,16 +15,21 @@ repository.workspace = true workspace = true [dependencies] +alloy-json-abi.workspace = true alloy-sol-macro-input.workspace = true alloy-sol-macro-expander = { workspace = true, features = ["json"] } foundry-common.workspace = true proc-macro2.workspace = true quote.workspace = true -syn.workspace = true +syn = { workspace = true, features = ["visit-mut"] } prettyplease.workspace = true -serde_json.workspace = true +rayon.workspace = true eyre.workspace = true heck.workspace = true + +[features] +default = ["optimism"] +optimism = ["foundry-common/optimism"] diff --git a/crates/sol-macro-gen/src/sol_macro_gen.rs b/crates/sol-macro-gen/src/sol_macro_gen.rs index b038fd5cf043a..2558cfdfcaa64 100644 --- a/crates/sol-macro-gen/src/sol_macro_gen.rs +++ b/crates/sol-macro-gen/src/sol_macro_gen.rs @@ -9,35 +9,50 @@ //! It contains methods to read the json abi, generate rust bindings from the abi and ultimately //! write the bindings to a crate or modules. +use alloy_json_abi::ToSolConfig; use alloy_sol_macro_expander::expand::expand; use alloy_sol_macro_input::{SolInput, SolInputKind}; use eyre::{Context, OptionExt, Result}; use foundry_common::fs; use proc_macro2::{Span, TokenStream}; +use rayon::prelude::*; use std::{ - env::temp_dir, fmt::Write, + io::{BufWriter, Write as IoWrite}, path::{Path, PathBuf}, - str::FromStr, }; use heck::ToSnakeCase; +const SERDE_ARRAY_IMPL_MAX_LEN: usize = 32; +const SERDE_WITH_DEP: &str = + r#"serde_with = { version = "3.15", default-features = false, features = ["std"] }"#; +const BINDINGS_HEADER: &str = r#"#![allow(unused_imports, unused_attributes, clippy::all, rustdoc::all)] +//! This module contains the sol! generated bindings for solidity contracts. +//! This is autogenerated code. +//! Do not manually edit these files. +//! These files may be overwritten by the codegen system at any time. +"#; + pub struct SolMacroGen { pub path: PathBuf, pub name: String, - pub expansion: Option, + pub expansion: Option, + needs_serde_with: bool, } impl SolMacroGen { - pub fn new(path: PathBuf, name: String) -> Self { - Self { path, name, expansion: None } + pub const fn new(path: PathBuf, name: String) -> Self { + Self { path, name, expansion: None, needs_serde_with: false } } pub fn get_sol_input(&self) -> Result { let path = self.path.to_string_lossy().into_owned(); - let name = proc_macro2::Ident::new(&self.name, Span::call_site()); + let name: syn::Ident = syn::parse_str(&self.name).wrap_err_with(|| { + format!("`{}` is not a valid Rust identifier for generated bindings", self.name) + })?; let tokens = quote::quote! { + #[sol(ignore_unlinked)] #name, #path }; @@ -49,74 +64,44 @@ impl SolMacroGen { } pub struct MultiSolMacroGen { - pub artifacts_path: PathBuf, pub instances: Vec, } impl MultiSolMacroGen { - pub fn new(artifacts_path: &Path, instances: Vec) -> Self { - Self { artifacts_path: artifacts_path.to_path_buf(), instances } + pub const fn new(instances: Vec) -> Self { + Self { instances } } pub fn populate_expansion(&mut self, bindings_path: &Path) -> Result<()> { for instance in &mut self.instances { - let path = bindings_path.join(format!("{}.rs", instance.name.to_lowercase())); + let path = bindings_path.join(format!("{}.rs", instance.name.to_snake_case())); let expansion = fs::read_to_string(path).wrap_err("Failed to read file")?; - - let tokens = TokenStream::from_str(&expansion) + expansion + .parse::() .map_err(|e| eyre::eyre!("Failed to parse TokenStream: {e}"))?; - instance.expansion = Some(tokens); + instance.expansion = Some(expansion); } Ok(()) } - pub fn generate_bindings(&mut self, all_derives: bool) -> Result<()> { - for instance in &mut self.instances { - Self::generate_binding(instance, all_derives).wrap_err_with(|| { + pub fn generate_bindings(&mut self, all_derives: bool, sol_config: &ToSolConfig) -> Result<()> { + self.instances.par_iter_mut().try_for_each(|instance| { + Self::generate_binding(instance, all_derives, sol_config).wrap_err_with(|| { format!( "failed to generate bindings for {}:{}", instance.path.display(), instance.name ) - })?; - } - - Ok(()) + }) + }) } - fn generate_binding(instance: &mut SolMacroGen, all_derives: bool) -> Result<()> { - // TODO: in `get_sol_input` we currently can't handle unlinked bytecode: - let input = match instance.get_sol_input() { - Ok(input) => input.normalize_json()?, - Err(error) => { - // TODO(mattsse): remove after - if error.to_string().contains("expected bytecode, found unlinked bytecode") { - // we attempt to do a little hack here until we have this properly supported by - // removing the bytecode objects from the json file and using a tmpfile (very - // hacky) - let content = std::fs::read_to_string(&instance.path)?; - let mut value = serde_json::from_str::(&content)?; - let obj = value.as_object_mut().expect("valid abi"); - - // clear unlinked bytecode - obj.remove("bytecode"); - obj.remove("deployedBytecode"); - - let tmpdir = temp_dir(); - let mut tmp_file = tmpdir.join(instance.path.file_name().unwrap()); - std::fs::write(&tmp_file, serde_json::to_string(&value)?)?; - - // try again - std::mem::swap(&mut tmp_file, &mut instance.path); - let input = instance.get_sol_input()?.normalize_json()?; - std::mem::swap(&mut tmp_file, &mut instance.path); - input.normalize_json()? - } else { - return Err(error); - } - } - }; - + fn generate_binding( + instance: &mut SolMacroGen, + all_derives: bool, + sol_config: &ToSolConfig, + ) -> Result<()> { + let input = instance.get_sol_input()?.normalize_json_with_config(sol_config.clone())?; let SolInput { attrs: _, path: _, kind } = input; let tokens = match kind { @@ -137,7 +122,13 @@ impl MultiSolMacroGen { _ => unreachable!(), }; - instance.expansion = Some(tokens); + let (tokens, needs_serde_with) = + if all_derives { add_large_array_serde_attrs(tokens)? } else { (tokens, false) }; + + let file = syn::parse2(tokens).wrap_err("failed to parse generated tokens as an AST")?; + instance.expansion = + Some(qualify_shadowed_sibling_module_paths(prettyplease::unparse(&file))); + instance.needs_serde_with = needs_serde_with; Ok(()) } @@ -153,11 +144,12 @@ impl MultiSolMacroGen { alloy_version: Option, alloy_rev: Option, all_derives: bool, + sol_config: &ToSolConfig, ) -> Result<()> { - self.generate_bindings(all_derives)?; + self.generate_bindings(all_derives, sol_config)?; let src = bindings_path.join("src"); - let _ = fs::create_dir_all(&src); + fs::create_dir_all(&src)?; // Write Cargo.toml let cargo_toml_path = bindings_path.join("Cargo.toml"); @@ -189,43 +181,31 @@ edition = "2021" if all_derives { let serde_dep = r#"serde = { version = "1.0", features = ["derive"] }"#; write!(toml_contents, "\n{serde_dep}")?; + if self.instances.iter().any(|instance| instance.needs_serde_with) { + write!(toml_contents, "\n{SERDE_WITH_DEP}")?; + } } fs::write(cargo_toml_path, toml_contents).wrap_err("Failed to write Cargo.toml")?; - let mut lib_contents = String::new(); - write!( - &mut lib_contents, - r#"#![allow(unused_imports, clippy::all, rustdoc::all)] - //! This module contains the sol! generated bindings for solidity contracts. - //! This is autogenerated code. - //! Do not manually edit these files. - //! These files may be overwritten by the codegen system at any time. - "# - )?; + let lib_path = src.join("lib.rs"); + if single_file { + write_single_file(&lib_path, &self.instances).wrap_err("Failed to write lib.rs")?; + return Ok(()); + } - // Write src - let parse_error = |name: &str| { - format!("failed to parse generated tokens as an AST for {name};\nthis is likely a bug") - }; + let mut lib_contents = BINDINGS_HEADER.to_string(); for instance in &self.instances { let contents = instance.expansion.as_ref().unwrap(); - let name = instance.name.to_snake_case(); let path = src.join(format!("{name}.rs")); - let file = syn::parse2(contents.clone()) - .wrap_err_with(|| parse_error(&format!("{}:{}", path.display(), name)))?; - let contents = prettyplease::unparse(&file); - if single_file { - write!(&mut lib_contents, "{contents}")?; - } else { - fs::write(path, contents).wrap_err("failed to write to file")?; - write_mod_name(&mut lib_contents, &name)?; - } + fs::write(path, contents).wrap_err("failed to write to file")?; + write_mod_name(&mut lib_contents, &name)?; } - let lib_path = src.join("lib.rs"); - let lib_file = syn::parse_file(&lib_contents).wrap_err_with(|| parse_error("lib.rs"))?; + let lib_file = syn::parse_file(&lib_contents).wrap_err( + "failed to parse generated tokens as an AST for lib.rs;\nthis is likely a bug", + )?; let lib_contents = prettyplease::unparse(&lib_file); fs::write(lib_path, lib_contents).wrap_err("Failed to write lib.rs")?; @@ -254,43 +234,31 @@ edition = "2021" bindings_path: &Path, single_file: bool, all_derives: bool, + sol_config: &ToSolConfig, ) -> Result<()> { - self.generate_bindings(all_derives)?; + self.generate_bindings(all_derives, sol_config)?; - let _ = fs::create_dir_all(bindings_path); + fs::create_dir_all(bindings_path)?; - let mut mod_contents = r#"#![allow(unused_imports, clippy::all, rustdoc::all)] - //! This module contains the sol! generated bindings for solidity contracts. - //! This is autogenerated code. - //! Do not manually edit these files. - //! These files may be overwritten by the codegen system at any time. - "# - .to_string(); + let mod_path = bindings_path.join("mod.rs"); + if single_file { + write_single_file(&mod_path, &self.instances).wrap_err("Failed to write mod.rs")?; + return Ok(()); + } + let mut mod_contents = BINDINGS_HEADER.to_string(); for instance in &self.instances { let name = instance.name.to_snake_case(); - if !single_file { - // Module - write_mod_name(&mut mod_contents, &name)?; - let mut contents = String::new(); - - write!(contents, "{}", instance.expansion.as_ref().unwrap())?; - let file = syn::parse_file(&contents)?; - - let contents = prettyplease::unparse(&file); - fs::write(bindings_path.join(format!("{name}.rs")), contents) - .wrap_err("Failed to write file")?; - } else { - // Single File - let mut contents = String::new(); - write!(contents, "{}\n\n", instance.expansion.as_ref().unwrap())?; - write!(mod_contents, "{contents}")?; - } + write_mod_name(&mut mod_contents, &name)?; + fs::write( + bindings_path.join(format!("{name}.rs")), + instance.expansion.as_ref().unwrap(), + ) + .wrap_err("Failed to write file")?; } - let mod_path = bindings_path.join("mod.rs"); let mod_file = syn::parse_file(&mod_contents)?; - let mod_contents = prettyplease::unparse(&mod_file); + let mod_contents = qualify_shadowed_sibling_module_paths(prettyplease::unparse(&mod_file)); fs::write(mod_path, mod_contents).wrap_err("Failed to write mod.rs")?; @@ -314,21 +282,20 @@ edition = "2021" alloy_version: Option, alloy_rev: Option, ) -> Result<()> { - if check_cargo_toml { + if check_cargo_toml && !is_mod { self.check_cargo_toml(name, version, crate_path, alloy_version, alloy_rev)?; } - let mut super_contents = String::new(); - write!( - &mut super_contents, - r#"#![allow(unused_imports, clippy::all, rustdoc::all)] - //! This module contains the sol! generated bindings for solidity contracts. - //! This is autogenerated code. - //! Do not manually edit these files. - //! These files may be overwritten by the codegen system at any time. - "# - )?; - if !single_file { + let mut super_contents = BINDINGS_HEADER.to_string(); + if single_file { + for instance in &self.instances { + let contents = instance + .expansion + .as_ref() + .ok_or_eyre(format!("TokenStream for {} does not exist", instance.name))?; + super_contents.push_str(contents); + } + } else { for instance in &self.instances { let name = instance.name.to_snake_case(); let path = if is_mod { @@ -336,42 +303,41 @@ edition = "2021" } else { crate_path.join(format!("src/{name}.rs")) }; - let tokens = instance + let contents = instance .expansion .as_ref() - .ok_or_eyre(format!("TokenStream for {path:?} does not exist"))? - .to_string(); + .ok_or_eyre(format!("TokenStream for {path:?} does not exist"))?; - self.check_file_contents(&path, &tokens)?; + self.check_file_contents(&path, contents)?; write_mod_name(&mut super_contents, &name)?; } - - let super_path = - if is_mod { crate_path.join("mod.rs") } else { crate_path.join("src/lib.rs") }; - self.check_file_contents(&super_path, &super_contents)?; } + let super_path = + if is_mod { crate_path.join("mod.rs") } else { crate_path.join("src/lib.rs") }; + self.check_file_contents(&super_path, &super_contents)?; + Ok(()) } fn check_file_contents(&self, file_path: &Path, expected_contents: &str) -> Result<()> { - eyre::ensure!( - file_path.is_file() && file_path.exists(), - "{} is not a file", - file_path.display() - ); - let file_contents = &fs::read_to_string(file_path).wrap_err("Failed to read file")?; + eyre::ensure!(file_path.is_file(), "{} is not a file", file_path.display()); + let file_contents = fs::read_to_string(file_path).wrap_err("Failed to read file")?; - // Format both - let file_contents = syn::parse_file(file_contents)?; - let formatted_file = prettyplease::unparse(&file_contents); + // Untouched bindings match byte for byte, so only reparse edited or stale files. + if file_contents == expected_contents { + return Ok(()); + } - let expected_contents = syn::parse_file(expected_contents)?; - let formatted_exp = prettyplease::unparse(&expected_contents); + // Only normalize formatting: generated expansions already have qualified paths. + // Format one side at a time so that only one syntax tree is alive. + let formatted_file = prettyplease::unparse(&syn::parse_file(&file_contents)?); + let formatted_exp = prettyplease::unparse(&syn::parse_file(expected_contents)?); eyre::ensure!( formatted_file == formatted_exp, - "File contents do not match expected contents for {file_path:?}" + "File contents do not match expected contents for \"{}\"", + file_path.display() ); Ok(()) } @@ -395,9 +361,13 @@ edition = "2021" let name_check = format!("name = \"{name}\""); let version_check = format!("version = \"{version}\""); let alloy_dep_check = Self::get_alloy_dep(alloy_version, alloy_rev); + let serde_with_consistent = + !self.instances.iter().any(|instance| instance.needs_serde_with) + || cargo_toml_contents.contains(SERDE_WITH_DEP); let toml_consistent = cargo_toml_contents.contains(&name_check) && cargo_toml_contents.contains(&version_check) - && cargo_toml_contents.contains(&alloy_dep_check); + && cargo_toml_contents.contains(&alloy_dep_check) + && serde_with_consistent; eyre::ensure!( toml_consistent, r#"The contents of Cargo.toml do not match the expected output of the latest `sol!` version. @@ -425,11 +395,177 @@ edition = "2021" } } +/// Writes individually validated and formatted expansions without reparsing the combined output. +fn write_single_file(path: &Path, instances: &[SolMacroGen]) -> Result<()> { + let file = fs::create_file(path)?; + let mut writer = BufWriter::new(file); + writer.write_all(BINDINGS_HEADER.as_bytes())?; + for instance in instances { + writer.write_all(instance.expansion.as_ref().unwrap().as_bytes())?; + } + writer.flush()?; + Ok(()) +} + +#[derive(Default)] +struct LargeArraySerdeAttrs { + added: bool, +} + +impl syn::visit_mut::VisitMut for LargeArraySerdeAttrs { + fn visit_item_struct_mut(&mut self, item: &mut syn::ItemStruct) { + for field in &mut item.fields { + if let Some(adapter) = large_array_serde_adapter(&field.ty) { + let adapter = + syn::LitStr::new(&format!("::serde_with::As::<{adapter}>"), Span::call_site()); + field.attrs.insert(0, syn::parse_quote!(#[serde(with = #adapter)])); + self.added = true; + } + } + } +} + +/// Adds `serde_with` adapters where Serde's built-in array implementations stop. +fn add_large_array_serde_attrs(tokens: TokenStream) -> Result<(TokenStream, bool)> { + let mut file = syn::parse2::(tokens) + .wrap_err("failed to parse generated bindings for large array support")?; + let mut visitor = LargeArraySerdeAttrs::default(); + syn::visit_mut::VisitMut::visit_file_mut(&mut visitor, &mut file); + Ok((quote::quote!(#file), visitor.added)) +} + +fn large_array_serde_adapter(ty: &syn::Type) -> Option { + match ty { + syn::Type::Array(array) => { + let syn::Expr::Lit(expr) = &array.len else { return None }; + let syn::Lit::Int(length) = &expr.lit else { return None }; + let element = large_array_serde_adapter(&array.elem); + let is_large = + length.base10_parse::().is_ok_and(|len| len > SERDE_ARRAY_IMPL_MAX_LEN); + if !is_large && element.is_none() { + return None; + } + + let element = element.unwrap_or_else(|| "::serde_with::Same".to_string()); + Some(format!("[{element}; {}]", length.base10_digits())) + } + syn::Type::Path(type_path) if type_path.qself.is_none() => { + let last = type_path.path.segments.last()?; + if last.ident == "Vec" + && let syn::PathArguments::AngleBracketed(arguments) = &last.arguments + && arguments.args.len() == 1 + && let Some(syn::GenericArgument::Type(element)) = arguments.args.first() + && let Some(element) = large_array_serde_adapter(element) + { + Some(format!("::std::vec::Vec<{element}>")) + } else { + None + } + } + _ => None, + } +} + fn write_mod_name(contents: &mut String, name: &str) -> Result<()> { - if syn::parse_str::(&format!("pub mod {name};")).is_ok() { + if syn::parse_str::(name).is_ok() { write!(contents, "pub mod {name};")?; } else { write!(contents, "pub mod r#{name};")?; } Ok(()) } + +/// Qualifies paths to sibling binding modules when a generated item in the current module shadows +/// that module name. +/// +/// Alloy names the event enum for a contract module by appending `Events` to the contract name. If +/// the ABI also contains a sibling contract/interface with that exact name, inherited event +/// parameter types such as `IExampleContractEvents::SomeEventData` resolve to the local event enum +/// instead of the sibling module that owns `SomeEventData`. Qualifying those paths with `super::` +/// keeps the generated binding compiling without changing the upstream `sol!` expansion. +fn qualify_shadowed_sibling_module_paths(mut contents: String) -> String { + let module_names = top_level_module_names(&contents); + let enum_names = public_enum_names(&contents); + + for module_name in module_names { + if enum_names.iter().any(|enum_name| enum_name == &module_name) { + contents = qualify_unqualified_module_paths(&contents, &module_name); + } + } + + contents +} + +fn qualify_unqualified_module_paths(contents: &str, module_name: &str) -> String { + let needle = format!("{module_name}::"); + let replacement = format!("super::{module_name}::"); + let mut qualified = String::with_capacity(contents.len()); + let mut rest = contents; + + while let Some(index) = rest.find(&needle) { + let (before, after) = rest.split_at(index); + qualified.push_str(before); + + let boundary = before + .chars() + .next_back() + .is_none_or(|c| !matches!(c, '_' | '0'..='9' | 'a'..='z' | 'A'..='Z' | ':')); + + if boundary { + qualified.push_str(&replacement); + } else { + qualified.push_str(&needle); + } + + rest = &after[needle.len()..]; + } + + qualified.push_str(rest); + qualified +} + +fn top_level_module_names(contents: &str) -> Vec { + contents + .split("pub mod ") + .skip(1) + .filter_map(|rest| rest.split_whitespace().next()) + .map(|name| name.trim_start_matches("r#").to_string()) + .collect() +} + +fn public_enum_names(contents: &str) -> Vec { + contents + .split("pub enum ") + .skip(1) + .filter_map(|rest| rest.split_whitespace().next()) + .map(|name| name.trim_start_matches("r#").to_string()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::large_array_serde_adapter; + + fn adapter(ty: &str) -> Option { + large_array_serde_adapter(&syn::parse_str(ty).unwrap()) + } + + #[test] + fn builds_large_array_serde_adapters() { + assert_eq!(adapter("[u64; 32]"), None); + assert_eq!(adapter("[u64; 33]"), Some("[::serde_with::Same; 33]".to_string())); + assert_eq!(adapter("[[u64; 48]; 2]"), Some("[[::serde_with::Same; 48]; 2]".to_string())); + assert_eq!( + adapter("alloy::sol_types::private::Vec<[u64; 48]>"), + Some("::std::vec::Vec<[::serde_with::Same; 48]>".to_string()) + ); + } + + #[test] + fn get_sol_input_rejects_invalid_identifier_instead_of_panicking() { + // `$` is valid in Solidity identifiers but not Rust identifiers. + let instance = + super::SolMacroGen::new(std::path::PathBuf::from("Foo.json"), "Foo$Bar".to_string()); + assert!(instance.get_sol_input().is_err()); + } +} diff --git a/crates/solar/Cargo.toml b/crates/solar/Cargo.toml new file mode 100644 index 0000000000000..3563beba8479d --- /dev/null +++ b/crates/solar/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "solar" +description = "Blazingly fast Solidity compiler" + +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[[bin]] +name = "solar" +path = "bin/main.rs" + +[dependencies] +solar-cli.workspace = true + +[features] +default = ["jemalloc", "asm-keccak"] +asm-keccak = ["solar-cli/asm"] +jemalloc = ["solar-cli/jemalloc"] +mimalloc = ["solar-cli/mimalloc"] +tracy-allocator = ["solar-cli/tracy-allocator"] diff --git a/crates/solar/bin/main.rs b/crates/solar/bin/main.rs new file mode 100644 index 0000000000000..e90a2d7e2097d --- /dev/null +++ b/crates/solar/bin/main.rs @@ -0,0 +1,8 @@ +//! The `solar` CLI: a Solidity compiler. + +use solar_cli::utils; + +#[global_allocator] +static ALLOC: utils::Allocator = utils::new_allocator(); + +pub use solar_cli::main; diff --git a/crates/test-utils/Cargo.toml b/crates/test-utils/Cargo.toml index 98bfbbc097048..a9c6430ab0080 100644 --- a/crates/test-utils/Cargo.toml +++ b/crates/test-utils/Cargo.toml @@ -15,29 +15,37 @@ repository.workspace = true workspace = true [dependencies] +foundry-block-explorers.workspace = true foundry-common.workspace = true foundry-compilers = { workspace = true, features = ["project-util"] } foundry-config.workspace = true +foundry-test-macros.workspace = true +alloy-chains.workspace = true alloy-primitives.workspace = true alloy-provider.workspace = true +axum.workspace = true eyre.workspace = true fd-lock = "4.0" parking_lot.workspace = true regex.workspace = true serde_json.workspace = true +tokio = { workspace = true, features = ["net", "rt", "sync"] } tracing.workspace = true tracing-subscriber = { workspace = true, features = ["env-filter"] } rand.workspace = true -snapbox = { version = "0.6", features = ["json", "regex", "term-svg"] } +snapbox.workspace = true tempfile.workspace = true -ui_test = "0.29.2" +ui_test = "0.30.5" +reqwest.workspace = true +svm.workspace = true -## Pinned dependencies. See /Cargo.toml. +# Pinned dependencies. See /Cargo.toml. +[target.'cfg(any())'.dependencies] idna_adapter.workspace = true -zip-extract.workspace = true -[dev-dependencies] -tokio.workspace = true -foundry-block-explorers.workspace = true +[features] +default = ["optimism"] +base = ["foundry-common/base"] +optimism = ["foundry-common/optimism"] diff --git a/crates/test-utils/macros/Cargo.toml b/crates/test-utils/macros/Cargo.toml new file mode 100644 index 0000000000000..afa7490af68e7 --- /dev/null +++ b/crates/test-utils/macros/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "foundry-test-macros" +description = "Foundry testing proc-macros" +publish = false + +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[lib] +proc-macro = true +# proc-macro tests aren't fully supported by cargo-nextest archives +test = false +doc = false + +[dependencies] +proc-macro2.workspace = true +quote.workspace = true +syn = { workspace = true, features = ["full"] } diff --git a/crates/test-utils/macros/src/lib.rs b/crates/test-utils/macros/src/lib.rs new file mode 100644 index 0000000000000..61832237c803a --- /dev/null +++ b/crates/test-utils/macros/src/lib.rs @@ -0,0 +1,140 @@ +//! # foundry-test-macros +//! +//! Internal Foundry testing proc-macros. + +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![cfg_attr(docsrs, feature(doc_cfg))] + +use proc_macro::TokenStream; +use proc_macro2::TokenStream as TokenStream2; +use quote::quote; +use syn::{ + Attribute, Error, Expr, FnArg, Pat, Result, Signature, Type, Visibility, + parse::{Parse, ParseStream}, + parse_quote, +}; + +/// Defines a Forge integration test. +/// +/// The function may take a `prj: _` argument for the `TestProject` and a `cmd: _` argument for the +/// `forge` `TestCommand`, in any order. Either can be left out. An `async` function runs on +/// a multi-threaded Tokio runtime. +/// +/// The attribute optionally takes the project's `PathStyle`, which defaults to +/// `PathStyle::Dapptools`. +/// +/// ```ignore +/// #[forgetest] +/// fn can_build(prj: _, cmd: _) { +/// cmd.arg("build").assert_success(); +/// } +/// +/// #[forgetest(PathStyle::HardHat)] +/// async fn can_build_hardhat(cmd: _) { +/// cmd.arg("build").assert_success(); +/// } +/// ``` +#[proc_macro_attribute] +pub fn forgetest(attr: TokenStream, item: TokenStream) -> TokenStream { + expand(attr, item, Kind::Forge) +} + +/// Same as [`macro@forgetest`], but initializes the project with `forge init` first. +#[proc_macro_attribute] +pub fn forgetest_init(attr: TokenStream, item: TokenStream) -> TokenStream { + expand(attr, item, Kind::ForgeInit) +} + +/// Same as [`macro@forgetest`], but `cmd` is a `cast` command. +#[proc_macro_attribute] +pub fn casttest(attr: TokenStream, item: TokenStream) -> TokenStream { + expand(attr, item, Kind::Cast) +} + +/// A function whose body is kept as unparsed tokens. +struct TestFn { + attrs: Vec, + vis: Visibility, + sig: Signature, + block: TokenStream2, +} + +impl Parse for TestFn { + fn parse(input: ParseStream<'_>) -> Result { + Ok(Self { + attrs: input.call(Attribute::parse_outer)?, + vis: input.parse()?, + sig: input.parse()?, + block: input.parse()?, + }) + } +} + +enum Kind { + Forge, + ForgeInit, + Cast, +} + +fn expand(attr: TokenStream, item: TokenStream, kind: Kind) -> TokenStream { + test_fn(attr, item, kind).unwrap_or_else(Error::into_compile_error).into() +} + +fn test_fn(attr: TokenStream, item: TokenStream, kind: Kind) -> Result { + let style = (!attr.is_empty()).then(|| syn::parse::(attr)).transpose()?; + let TestFn { attrs, vis, mut sig, block } = syn::parse(item)?; + + // The body runs in an inner function that takes the requested arguments, so that unused ones + // are linted. + let mut inner = sig.clone(); + let mut args = Vec::new(); + for input in &mut inner.inputs { + let FnArg::Typed(arg) = input else { + return Err(Error::new_spanned(input, "expected `prj: _` or `cmd: _`")); + }; + let Pat::Ident(pat) = &mut *arg.pat else { + return Err(Error::new_spanned(&arg.pat, "expected `prj` or `cmd`")); + }; + if !matches!(*arg.ty, Type::Infer(_)) { + return Err(Error::new_spanned(&arg.ty, "expected `_`")); + } + let ty = match pat.ident.to_string().as_str() { + "prj" => parse_quote!(::foundry_test_utils::TestProject), + "cmd" => parse_quote!(::foundry_test_utils::TestCommand), + _ => return Err(Error::new_spanned(&pat.ident, "expected `prj` or `cmd`")), + }; + args.push(pat.ident.clone()); + pat.mutability = Some(syn::Token![mut](pat.ident.span())); + arg.attrs.push(parse_quote!(#[allow(unused_mut)])); + *arg.ty = ty; + } + sig.inputs.clear(); + + let ident = &sig.ident; + let name = ident.to_string(); + let style = style.unwrap_or_else(|| { + parse_quote!(::foundry_test_utils::foundry_compilers::PathStyle::Dapptools) + }); + let setup = match kind { + Kind::Forge | Kind::ForgeInit => quote!(setup_forge), + Kind::Cast => quote!(setup_cast), + }; + let init = matches!(kind, Kind::ForgeInit) + .then(|| quote!(::foundry_test_utils::util::initialize(prj.root());)); + let (test, dot_await) = if sig.asyncness.is_some() { + (quote!(#[::tokio::test(flavor = "multi_thread")]), Some(quote!(.await))) + } else { + (quote!(#[test]), None) + }; + + Ok(quote! { + #(#attrs)* + #test + #vis #sig { + #inner #block + let (prj, cmd) = ::foundry_test_utils::util::#setup(#name, #style); + #init + #ident(#(#args),*) #dot_await + } + }) +} diff --git a/crates/test-utils/src/etherscan.rs b/crates/test-utils/src/etherscan.rs new file mode 100644 index 0000000000000..a49fd746e46d9 --- /dev/null +++ b/crates/test-utils/src/etherscan.rs @@ -0,0 +1,32 @@ +//! Etherscan utilities for tests. + +use alloy_chains::Chain; +use alloy_primitives::Address; +use eyre::Result; +use foundry_block_explorers::Client; +use foundry_common::{compile::etherscan_project, flatten}; +use std::str::FromStr; + +/// Fetches the source code of a verified contract from Etherscan, flattens it, and returns it. +/// +/// This provides the same functionality as `cast source --flatten` but using the library directly, +/// avoiding the need to shell out to the `cast` binary. +pub async fn fetch_etherscan_source_flattened( + address: &str, + etherscan_api_key: &str, + chain: Chain, +) -> Result { + let client = Client::builder().chain(chain)?.with_api_key(etherscan_api_key).build()?; + + let address = Address::from_str(address)?; + let metadata = client.contract_source_code(address).await?; + let Some(metadata) = metadata.items.first() else { + eyre::bail!("Empty contract source code for {address}"); + }; + + let tmp = tempfile::tempdir()?; + let project = etherscan_project(metadata, tmp.path())?; + let target_path = project.find_contract_path(&metadata.contract_name)?; + + flatten(project, &target_path) +} diff --git a/crates/test-utils/src/ext.rs b/crates/test-utils/src/ext.rs new file mode 100644 index 0000000000000..071bf2d1a2410 --- /dev/null +++ b/crates/test-utils/src/ext.rs @@ -0,0 +1,446 @@ +use crate::prj::{TestCommand, TestProject, clone_remote, setup_forge}; +use foundry_compilers::PathStyle; +use std::{ + path::{Path, PathBuf}, + process::Command, +}; + +/// External test builder +#[derive(Clone, Debug)] +#[must_use = "ExtTester does nothing unless you `run` it"] +pub struct ExtTester { + pub org: &'static str, + pub name: &'static str, + pub rev: &'static str, + pub style: PathStyle, + pub fork_block: Option, + pub fuzz_runs: u32, + pub args: Vec, + pub envs: Vec<(String, String)>, + pub install_commands: Vec>, + pub python_packages: Vec, + pub verbosity: String, +} + +impl ExtTester { + /// Creates a new external test builder. + pub fn new(org: &'static str, name: &'static str, rev: &'static str) -> Self { + Self { + org, + name, + rev, + style: PathStyle::Dapptools, + fork_block: None, + fuzz_runs: 32, + args: vec![], + envs: vec![], + install_commands: vec![], + python_packages: vec![], + verbosity: "-vvv".to_string(), + } + } + + /// Sets the path style. + pub const fn style(mut self, style: PathStyle) -> Self { + self.style = style; + self + } + + /// Sets the fork block. + pub const fn fork_block(mut self, fork_block: u64) -> Self { + self.fork_block = Some(fork_block); + self + } + + /// Sets the number of fuzz runs. + pub const fn fuzz_runs(mut self, fuzz_runs: u32) -> Self { + self.fuzz_runs = fuzz_runs; + self + } + + /// Adds an argument to the forge command. + pub fn arg(mut self, arg: impl Into) -> Self { + self.args.push(arg.into()); + self + } + + /// Adds multiple arguments to the forge command. + pub fn args(mut self, args: I) -> Self + where + I: IntoIterator, + A: Into, + { + self.args.extend(args.into_iter().map(Into::into)); + self + } + + /// Sets the verbosity + pub fn verbosity(mut self, verbosity: usize) -> Self { + self.verbosity = format!("-{}", "v".repeat(verbosity)); + self + } + + /// Adds an environment variable to the forge command. + pub fn env(mut self, key: impl Into, value: impl Into) -> Self { + self.envs.push((key.into(), value.into())); + self + } + + /// Adds multiple environment variables to the forge command. + pub fn envs(mut self, envs: I) -> Self + where + I: IntoIterator, + K: Into, + V: Into, + { + self.envs.extend(envs.into_iter().map(|(k, v)| (k.into(), v.into()))); + self + } + + /// Adds a command to run after the project is cloned. + /// + /// Commands run in the project's root directory as alternatives, stopping at the first success. + /// If commands are configured and all fail, the test fails before running Forge. + pub fn install_command(mut self, command: &[&str]) -> Self { + self.install_commands.push(command.iter().map(|s| s.to_string()).collect()); + self + } + + /// Adds a Python package to install in a fixture-local virtual environment. + pub fn python_package(mut self, package: impl Into) -> Self { + self.python_packages.push(package.into()); + self + } + + pub fn setup_forge_prj(&self, recursive: bool) -> (TestProject, TestCommand) { + let (prj, mut test_cmd) = setup_forge(self.name, self.style.clone()); + + // Wipe the default structure. + prj.wipe(); + + // Clone the external repository. + let repo_url = format!("https://github.com/{}/{}.git", self.org, self.name); + let root = prj.root().to_str().unwrap(); + clone_remote(&repo_url, root, recursive); + + // Checkout the revision. + if self.rev.is_empty() { + let mut git = Command::new("git"); + git.current_dir(root).args(["log", "-n", "1"]); + test_debug!("$ {git:?}"); + let output = git.output().unwrap(); + assert!(output.status.success(), "git log failed: {output:?}"); + let stdout = String::from_utf8(output.stdout).unwrap(); + let commit = stdout.lines().next().unwrap().split_whitespace().nth(1).unwrap(); + panic!("pin to latest commit: {commit}"); + } + checkout_revision(root, self.rev, recursive); + + // Export fixture-local Python packages, vyper, and forge in the test command. + let mut new_paths = Vec::new(); + if let Some(python_bin_dir) = self.install_python_packages(root) { + new_paths.push(python_bin_dir); + } + if let Some(vyper) = &prj.inner.project().compiler.vyper { + let vyper_dir = vyper.path.parent().expect("vyper path should have a parent"); + new_paths.push(vyper_dir.to_path_buf()); + } + let forge_bin = prj.foundry_bin_path("forge"); + let forge_dir = forge_bin.parent().expect("forge path should have a parent"); + new_paths.push(forge_dir.to_path_buf()); + let existing_path = std::env::var_os("PATH").unwrap_or_default(); + new_paths.extend(std::env::split_paths(&existing_path)); + + let joined_path = std::env::join_paths(new_paths).expect("failed to join PATH"); + test_cmd.env("PATH", joined_path); + + (prj, test_cmd) + } + + fn install_python_packages(&self, root: &str) -> Option { + if self.python_packages.is_empty() { + return None; + } + + let venv = Path::new(root).join(".foundry-ext-venv"); + let mut venv_cmd = Command::new("python3"); + venv_cmd.args(["-m", "venv"]).arg(&venv); + test_debug!("cd {root}; {venv_cmd:?}"); + let status = venv_cmd.current_dir(root).status().expect("failed to create Python venv"); + assert!(status.success(), "python venv creation failed: {status}"); + + let bin_dir = venv.join(if cfg!(windows) { "Scripts" } else { "bin" }); + let pip = bin_dir.join(if cfg!(windows) { "pip.exe" } else { "pip" }); + for package in &self.python_packages { + let mut pip_cmd = Command::new(&pip); + pip_cmd.args(["install", "--disable-pip-version-check"]).arg(package).current_dir(root); + test_debug!("cd {root}; {pip_cmd:?}"); + let status = pip_cmd.status().expect("failed to install Python package"); + assert!(status.success(), "Python package install failed: {status}"); + } + + Some(bin_dir) + } + + pub fn run_install_commands(&self, root: &str) { + for install_command in &self.install_commands { + let mut install_cmd = Command::new(&install_command[0]); + install_cmd.args(&install_command[1..]).current_dir(root); + test_debug!("cd {root}; {install_cmd:?}"); + match install_cmd.status() { + Ok(s) => { + test_debug!("\n\n{install_cmd:?}: {s}"); + if s.success() { + return; + } + } + Err(e) => { + eprintln!("\n\n{install_cmd:?}: {e}"); + } + } + } + assert!(self.install_commands.is_empty(), "all dependency installation commands failed"); + } + + /// Runs the test. + pub fn run(&self) { + let (prj, mut test_cmd) = self.setup_forge_prj(true); + + // Run installation command. + self.run_install_commands(prj.root().to_str().unwrap()); + + // Run the tests. + test_cmd.arg("test"); + test_cmd.args(&self.args); + test_cmd.args([ + format!("--fuzz-runs={}", self.fuzz_runs), + "--ffi".to_string(), + self.verbosity.clone(), + ]); + + test_cmd.envs(self.envs.iter().map(|(k, v)| (k, v))); + if let Some(fork_block) = self.fork_block { + test_cmd.env("FOUNDRY_ETH_RPC_URL", crate::rpc::next_http_archive_rpc_url()); + test_cmd.env("FOUNDRY_FORK_BLOCK_NUMBER", fork_block.to_string()); + } + test_cmd.env("FOUNDRY_INVARIANT_DEPTH", "15"); + test_cmd.env("FOUNDRY_ALLOW_INTERNAL_EXPECT_REVERT", "true"); + + test_cmd.assert_success(); + } +} + +/// Checks out a fixture revision and restores its pinned submodule revisions. +fn checkout_revision(root: &str, rev: &str, recursive: bool) { + checkout_revision_inner(root, rev, recursive, None); +} + +fn checkout_revision_inner(root: &str, rev: &str, recursive: bool, allowed_protocol: Option<&str>) { + let mut git = Command::new("git"); + if let Some(protocol) = allowed_protocol { + git.env("GIT_ALLOW_PROTOCOL", protocol); + } + git.current_dir(root).args(["checkout", rev]); + test_debug!("$ {git:?}"); + let status = git.status().unwrap(); + assert!(status.success(), "git checkout failed: {status}"); + + if recursive { + // The clone initialized submodules from the default branch, not the pinned revision. + for args in [ + &["-c", "submodule.recurse=false", "submodule", "sync"][..], + &["-c", "submodule.recurse=false", "submodule", "update", "--init", "--checkout"][..], + &[ + "submodule", + "foreach", + "--recursive", + "git -c submodule.recurse=false submodule sync && git -c submodule.recurse=false \ + submodule update --init --checkout", + ][..], + ] { + let mut git = Command::new("git"); + if let Some(protocol) = allowed_protocol { + git.env("GIT_ALLOW_PROTOCOL", protocol); + } + git.current_dir(root).args(args); + test_debug!("$ {git:?}"); + let status = git.status().unwrap(); + assert!(status.success(), "git {args:?} failed: {status}"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn git(root: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .current_dir(root) + .args([ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + ]) + .args(args) + .output() + .unwrap(); + assert!(output.status.success(), "{args:?}: {}", String::from_utf8_lossy(&output.stderr)); + String::from_utf8(output.stdout).unwrap().trim().to_string() + } + + #[test] + fn checkout_revision_restores_pinned_submodule() { + let temp = tempfile::tempdir().unwrap(); + let dependency = temp.path().join("dependency"); + let fixture = temp.path().join("fixture"); + fs::create_dir(&dependency).unwrap(); + fs::create_dir(&fixture).unwrap(); + git(&dependency, &["init"]); + fs::write(dependency.join("draft.sol"), "old interface").unwrap(); + git(&dependency, &["add", "."]); + git(&dependency, &["commit", "-m", "old interface"]); + let pinned_dependency = git(&dependency, &["rev-parse", "HEAD"]); + + git(&fixture, &["init"]); + git( + &fixture, + &[ + "-c", + "protocol.file.allow=always", + "submodule", + "add", + dependency.to_str().unwrap(), + "lib/dependency", + ], + ); + git(&fixture, &["commit", "-am", "pin old dependency"]); + let pinned_fixture = git(&fixture, &["rev-parse", "HEAD"]); + git(&fixture, &["config", "submodule.recurse", "false"]); + + let submodule = fixture.join("lib/dependency"); + git(&submodule, &["mv", "draft.sol", "interface.sol"]); + git(&submodule, &["commit", "-m", "rename interface"]); + git(&fixture, &["commit", "-am", "update dependency"]); + let updated_fixture = git(&fixture, &["rev-parse", "HEAD"]); + + // Checking out only the parent leaves the dependency at the newer revision. + checkout_revision(fixture.to_str().unwrap(), &pinned_fixture, false); + assert!(!submodule.join("draft.sol").exists()); + assert!(submodule.join("interface.sol").exists()); + + checkout_revision(fixture.to_str().unwrap(), &updated_fixture, false); + git(&fixture, &["config", "submodule.lib/dependency.update", "merge"]); + checkout_revision(fixture.to_str().unwrap(), &pinned_fixture, true); + assert_eq!(git(&submodule, &["rev-parse", "HEAD"]), pinned_dependency); + assert!(submodule.join("draft.sol").exists()); + assert!(!submodule.join("interface.sol").exists()); + assert!(git(&fixture, &["status", "--porcelain"]).is_empty()); + } + + #[test] + fn checkout_revision_syncs_nested_submodule_after_parent_checkout() { + let temp = tempfile::tempdir().unwrap(); + let old_nested = temp.path().join("old-nested"); + let new_nested = temp.path().join("new-nested"); + let dependency = temp.path().join("dependency"); + let fixture = temp.path().join("fixture"); + let checkout = temp.path().join("checkout"); + + for (repository, file) in [(&old_nested, "old.sol"), (&new_nested, "new.sol")] { + fs::create_dir(repository).unwrap(); + git(repository, &["init"]); + fs::write(repository.join(file), file).unwrap(); + git(repository, &["add", "."]); + git(repository, &["commit", "-m", file]); + } + let pinned_nested = git(&old_nested, &["rev-parse", "HEAD"]); + + fs::create_dir(&dependency).unwrap(); + git(&dependency, &["init"]); + git( + &dependency, + &[ + "-c", + "protocol.file.allow=always", + "submodule", + "add", + old_nested.to_str().unwrap(), + "lib/nested", + ], + ); + git(&dependency, &["commit", "-am", "pin old nested dependency"]); + let pinned_dependency = git(&dependency, &["rev-parse", "HEAD"]); + + fs::create_dir(&fixture).unwrap(); + git(&fixture, &["init"]); + git( + &fixture, + &[ + "-c", + "protocol.file.allow=always", + "submodule", + "add", + dependency.to_str().unwrap(), + "lib/dependency", + ], + ); + git(&fixture, &["commit", "-am", "pin old dependency"]); + let pinned_fixture = git(&fixture, &["rev-parse", "HEAD"]); + + git( + &dependency, + &[ + "config", + "--file", + ".gitmodules", + "submodule.lib/nested.url", + new_nested.to_str().unwrap(), + ], + ); + git(&dependency, &["submodule", "sync"]); + let nested = dependency.join("lib/nested"); + let updated_nested = git(&new_nested, &["rev-parse", "HEAD"]); + git(&nested, &["fetch", "origin"]); + git(&nested, &["checkout", &updated_nested]); + git(&dependency, &["add", "."]); + git(&dependency, &["commit", "-m", "use new nested dependency"]); + let updated_dependency = git(&dependency, &["rev-parse", "HEAD"]); + let fixture_dependency = fixture.join("lib/dependency"); + git(&fixture_dependency, &["fetch", "origin"]); + git(&fixture_dependency, &["checkout", &updated_dependency]); + git(&fixture, &["add", "lib/dependency"]); + git(&fixture, &["commit", "-m", "update dependency"]); + + git( + temp.path(), + &[ + "-c", + "protocol.file.allow=always", + "clone", + "--recursive", + fixture.to_str().unwrap(), + checkout.to_str().unwrap(), + ], + ); + git(&checkout, &["config", "submodule.recurse", "false"]); + + checkout_revision_inner(checkout.to_str().unwrap(), &pinned_fixture, true, Some("file")); + + let checked_out_dependency = checkout.join("lib/dependency"); + let checked_out_nested = checked_out_dependency.join("lib/nested"); + assert_eq!(git(&checked_out_dependency, &["rev-parse", "HEAD"]), pinned_dependency); + assert_eq!(git(&checked_out_nested, &["rev-parse", "HEAD"]), pinned_nested); + assert_eq!( + git(&checked_out_nested, &["remote", "get-url", "origin"]), + old_nested.to_str().unwrap() + ); + assert!(checked_out_nested.join("old.sol").exists()); + assert!(!checked_out_nested.join("new.sol").exists()); + assert!(git(&checkout, &["status", "--porcelain"]).is_empty()); + } +} diff --git a/crates/test-utils/src/fd_lock.rs b/crates/test-utils/src/fd_lock.rs index 1c5a479cd5efd..a131648f325a6 100644 --- a/crates/test-utils/src/fd_lock.rs +++ b/crates/test-utils/src/fd_lock.rs @@ -19,3 +19,9 @@ pub fn new_lock(lock_path: impl AsRef) -> RwLock { } new_lock(lock_path.as_ref()) } + +pub(crate) const LOCK_TOKEN: &[u8] = b"1"; + +pub(crate) fn lock_exists(lock_path: &Path) -> bool { + std::fs::read(lock_path).is_ok_and(|b| b == LOCK_TOKEN) +} diff --git a/crates/test-utils/src/filter.rs b/crates/test-utils/src/filter.rs index 7bef934e2d981..b7813bde14376 100644 --- a/crates/test-utils/src/filter.rs +++ b/crates/test-utils/src/filter.rs @@ -73,13 +73,13 @@ impl Filter { } impl TestFilter for Filter { - fn matches_test(&self, test_name: &str) -> bool { + fn matches_test(&self, test_signature: &str) -> bool { if let Some(exclude) = &self.exclude_tests - && exclude.is_match(test_name) + && exclude.is_match(test_signature) { return false; } - self.test_regex.is_match(test_name) + self.test_regex.is_match(test_signature) } fn matches_contract(&self, contract_name: &str) -> bool { diff --git a/crates/test-utils/src/lib.rs b/crates/test-utils/src/lib.rs index 49c2ccf13bad0..1e669626855de 100644 --- a/crates/test-utils/src/lib.rs +++ b/crates/test-utils/src/lib.rs @@ -3,20 +3,18 @@ //! Internal Foundry testing utilities. #![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] +#![cfg_attr(docsrs, feature(doc_cfg))] // Shouldn't use sh_* macros here, as they don't get captured by the test runner. #![allow(clippy::disallowed_macros)] #[macro_use] extern crate tracing; -// See /Cargo.toml. -use idna_adapter as _; -use zip_extract as _; - // Macros useful for testing. +#[macro_use] mod macros; +pub mod etherscan; pub mod rpc; pub mod fd_lock; @@ -24,9 +22,14 @@ pub mod fd_lock; mod filter; pub use filter::Filter; +mod ext; +pub use ext::ExtTester; + +mod prj; +pub use prj::{TestCommand, TestProject, cargo_profile_dir}; + // Utilities for making it easier to handle tests. pub mod util; -pub use util::{TestCommand, TestProject}; mod script; pub use script::{ScriptOutcome, ScriptTester}; @@ -36,11 +39,42 @@ pub mod ui_runner; // re-exports for convenience pub use foundry_compilers; +pub use foundry_test_macros::{casttest, forgetest, forgetest_init}; pub use snapbox::{self, assert_data_eq, file, str}; /// Initializes tracing for tests. pub fn init_tracing() { - let _ = tracing_subscriber::FmtSubscriber::builder() - .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) - .try_init(); + use std::sync::Once; + static ONCE: Once = Once::new(); + ONCE.call_once(|| { + if std::env::var_os("RUST_BACKTRACE").is_none() { + unsafe { std::env::set_var("RUST_BACKTRACE", "1") }; + } + let _ = tracing_subscriber::FmtSubscriber::builder() + .with_env_filter(env_filter()) + .with_test_writer() + .try_init(); + let _ = ui_test::color_eyre::install(); + }); +} + +fn env_filter() -> tracing_subscriber::EnvFilter { + const DEFAULT_DIRECTIVES: &[&str] = &include!("../../cli/src/utils/default_directives.txt"); + let mut filter = tracing_subscriber::EnvFilter::builder() + .with_default_directive("foundry_test_utils=debug".parse().unwrap()) + .from_env_lossy(); + for &directive in DEFAULT_DIRECTIVES { + filter = filter.add_directive(directive.parse().unwrap()); + } + filter +} + +pub fn test_debug(args: std::fmt::Arguments<'_>) { + init_tracing(); + debug!("{args}"); +} + +pub fn test_trace(args: std::fmt::Arguments<'_>) { + init_tracing(); + trace!("{args}"); } diff --git a/crates/test-utils/src/macros.rs b/crates/test-utils/src/macros.rs index 42843c3c91c10..742f08bfb5f4b 100644 --- a/crates/test-utils/src/macros.rs +++ b/crates/test-utils/src/macros.rs @@ -1,124 +1,13 @@ -/// A macro to generate a new integration test case -/// -/// The `forgetest!` macro's first argument is the name of the test, the second argument is a -/// closure to configure and execute the test. The `TestProject` provides utility functions to setup -/// the project's workspace. The `TestCommand` is a wrapper around the actual `forge` executable -/// that this then executed with the configured command arguments. -/// -/// # Example -/// -/// run `forge init` -/// -/// ```no_run -/// use foundry_test_utils::*; -/// forgetest!(my_test, |prj, cmd| { -/// // adds `init` to forge's command arguments -/// cmd.arg("init"); -/// // executes forge and panics if the command failed or output is empty -/// cmd.assert_success().stdout_eq(str![[r#""#]]); -/// }); -/// ``` -/// -/// Configure a hardhat project layout by adding a `PathStyle::HardHat` argument -/// -/// ```no_run -/// use foundry_test_utils::*; -/// use foundry_test_utils::foundry_compilers::PathStyle; -/// forgetest!(can_clean_hardhat, PathStyle::HardHat, |prj, cmd| { -/// prj.assert_create_dirs_exists(); -/// prj.assert_style_paths_exist(PathStyle::HardHat); -/// cmd.arg("clean"); -/// cmd.assert_empty_stdout(); -/// prj.assert_cleaned(); -/// }); #[macro_export] -macro_rules! forgetest { - ($(#[$attr:meta])* $test:ident, |$prj:ident, $cmd:ident| $e:expr) => { - $crate::forgetest!($(#[$attr])* $test, $crate::foundry_compilers::PathStyle::Dapptools, |$prj, $cmd| $e); - }; - ($(#[$attr:meta])* $test:ident, $style:expr, |$prj:ident, $cmd:ident| $e:expr) => { - #[expect(clippy::disallowed_macros)] - #[test] - $(#[$attr])* - fn $test() { - let (mut $prj, mut $cmd) = $crate::util::setup_forge(stringify!($test), $style); - $e - } - }; +macro_rules! test_debug { + ($($args:tt)*) => { + $crate::test_debug(format_args!($($args)*)) + } } #[macro_export] -macro_rules! forgetest_async { - ($(#[$attr:meta])* $test:ident, |$prj:ident, $cmd:ident| $e:expr) => { - $crate::forgetest_async!($(#[$attr])* $test, $crate::foundry_compilers::PathStyle::Dapptools, |$prj, $cmd| $e); - }; - ($(#[$attr:meta])* $test:ident, $style:expr, |$prj:ident, $cmd:ident| $e:expr) => { - #[expect(clippy::disallowed_macros)] - #[tokio::test(flavor = "multi_thread")] - $(#[$attr])* - async fn $test() { - let (mut $prj, mut $cmd) = $crate::util::setup_forge(stringify!($test), $style); - $e - } - }; -} - -#[macro_export] -macro_rules! casttest { - ($(#[$attr:meta])* $test:ident, $($async:ident)? |$prj:ident, $cmd:ident| $e:expr) => { - $crate::casttest!($(#[$attr])* $test, $crate::foundry_compilers::PathStyle::Dapptools, $($async)? |$prj, $cmd| $e); - }; - ($(#[$attr:meta])* $test:ident, $style:expr, |$prj:ident, $cmd:ident| $e:expr) => { - #[expect(clippy::disallowed_macros)] - #[test] - $(#[$attr])* - fn $test() { - let (mut $prj, mut $cmd) = $crate::util::setup_cast(stringify!($test), $style); - $e - } - }; - ($(#[$attr:meta])* $test:ident, $style:expr, async |$prj:ident, $cmd:ident| $e:expr) => { - #[expect(clippy::disallowed_macros)] - #[tokio::test(flavor = "multi_thread")] - $(#[$attr])* - async fn $test() { - let (mut $prj, mut $cmd) = $crate::util::setup_cast(stringify!($test), $style); - $e - } - }; -} - -/// Same as `forgetest` but returns an already initialized project workspace (`forge init`) -#[macro_export] -macro_rules! forgetest_init { - ($(#[$attr:meta])* $test:ident, |$prj:ident, $cmd:ident| $e:expr) => { - $crate::forgetest_init!($(#[$attr])* $test, $crate::foundry_compilers::PathStyle::Dapptools, |$prj, $cmd| $e); - }; - ($(#[$attr:meta])* $test:ident, $style:expr, |$prj:ident, $cmd:ident| $e:expr) => { - #[test] - $(#[$attr])* - fn $test() { - let (mut $prj, mut $cmd) = $crate::util::setup_forge(stringify!($test), $style); - $crate::util::initialize($prj.root()); - $e - } - }; -} - -/// Setup forge soldeer -#[macro_export] -macro_rules! forgesoldeer { - ($(#[$attr:meta])* $test:ident, |$prj:ident, $cmd:ident| $e:expr) => { - $crate::forgesoldeer!($(#[$attr])* $test, $crate::foundry_compilers::PathStyle::Dapptools, |$prj, $cmd| $e); - }; - ($(#[$attr:meta])* $test:ident, $style:expr, |$prj:ident, $cmd:ident| $e:expr) => { - #[expect(clippy::disallowed_macros)] - #[test] - $(#[$attr])* - fn $test() { - let (mut $prj, mut $cmd) = $crate::util::setup_forge(stringify!($test), $style); - $crate::util::initialize($prj.root()); - $e - } - }; +macro_rules! test_trace { + ($($args:tt)*) => { + $crate::test_trace(format_args!($($args)*)) + } } diff --git a/crates/test-utils/src/prj.rs b/crates/test-utils/src/prj.rs new file mode 100644 index 0000000000000..624d323bcc908 --- /dev/null +++ b/crates/test-utils/src/prj.rs @@ -0,0 +1,958 @@ +use crate::{init_tracing, rpc::rpc_endpoints}; +use eyre::{Result, WrapErr}; +use foundry_compilers::{ + ArtifactOutput, ConfigurableArtifacts, PathStyle, ProjectPathsConfig, artifacts::Contract, + cache::CompilerCache, compilers::multi::MultiCompiler, project_util::TempProject, + solc::SolcSettings, +}; +use foundry_config::Config; +use parking_lot::Mutex; +use regex::Regex; +use snapbox::{Data, IntoData, assert_data_eq, cmd::OutputAssert}; +use std::{ + env, + ffi::OsStr, + fs::{self, File}, + io::{BufWriter, Write}, + path::{Path, PathBuf}, + process::{Command, Output, Stdio}, + sync::{ + Arc, LazyLock, + atomic::{AtomicUsize, Ordering}, + }, +}; + +use crate::util::{SOLC_VERSION, copy_dir_filtered, pretty_err}; + +static CURRENT_DIR_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); + +/// Global test identifier. +static NEXT_ID: AtomicUsize = AtomicUsize::new(0); + +/// Clones a remote repository into the specified directory. Panics if the command fails. +pub fn clone_remote(repo_url: &str, target_dir: &str, recursive: bool) { + let mut cmd = Command::new("git"); + cmd.args(["clone"]); + if recursive { + cmd.args(["--recursive", "--shallow-submodules"]); + } else { + cmd.args(["--depth=1", "--no-checkout", "--filter=blob:none", "--no-recurse-submodules"]); + } + cmd.args([repo_url, target_dir]); + test_debug!("{cmd:?}"); + let status = cmd.status().unwrap(); + assert!(status.success(), "git clone failed: {status}") +} + +/// Setup an empty test project and return a command pointing to the forge +/// executable whose CWD is set to the project's root. +/// +/// The name given will be used to create the directory. Generally, it should +/// correspond to the test name. +#[track_caller] +pub fn setup_forge(name: &str, style: PathStyle) -> (TestProject, TestCommand) { + setup_forge_project(TestProject::new(name, style)) +} + +pub fn setup_forge_project(test: TestProject) -> (TestProject, TestCommand) { + let cmd = test.forge_command(); + (test, cmd) +} + +/// How to initialize a remote git project +#[derive(Clone, Debug)] +pub struct RemoteProject { + id: String, + run_build: bool, + run_commands: Vec>, + path_style: PathStyle, +} + +impl RemoteProject { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + run_build: true, + run_commands: vec![], + path_style: PathStyle::Dapptools, + } + } + + /// Whether to run `forge build` + pub const fn set_build(mut self, run_build: bool) -> Self { + self.run_build = run_build; + self + } + + /// Configures the project's pathstyle + pub const fn path_style(mut self, path_style: PathStyle) -> Self { + self.path_style = path_style; + self + } + + /// Add another command to run after cloning + pub fn cmd(mut self, cmd: impl IntoIterator>) -> Self { + self.run_commands.push(cmd.into_iter().map(Into::into).collect()); + self + } +} + +impl> From for RemoteProject { + fn from(id: T) -> Self { + Self::new(id) + } +} + +/// Setups a new local forge project by cloning and initializing the `RemoteProject` +/// +/// This will +/// 1. clone the prj, like "transmissions1/solmate" +/// 2. run `forge build`, if configured +/// 3. run additional commands +/// +/// # Panics +/// +/// If anything goes wrong during, checkout, build, or other commands are unsuccessful +pub fn setup_forge_remote(prj: impl Into) -> (TestProject, TestCommand) { + try_setup_forge_remote(prj).unwrap() +} + +/// Same as `setup_forge_remote` but not panicking +pub fn try_setup_forge_remote( + config: impl Into, +) -> Result<(TestProject, TestCommand)> { + let config = config.into(); + let mut tmp = TempProject::checkout(&config.id).wrap_err("failed to checkout project")?; + tmp.project_mut().paths = config.path_style.paths(tmp.root())?; + + let prj = TestProject::with_project(tmp); + if config.run_build { + let mut cmd = prj.forge_command(); + cmd.arg("build").assert_success(); + } + for addon in config.run_commands { + debug_assert!(!addon.is_empty()); + let mut cmd = Command::new(&addon[0]); + if addon.len() > 1 { + cmd.args(&addon[1..]); + } + let status = cmd + .current_dir(prj.root()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .wrap_err_with(|| format!("Failed to execute {addon:?}"))?; + eyre::ensure!(status.success(), "Failed to execute command {:?}", addon); + } + + let cmd = prj.forge_command(); + Ok((prj, cmd)) +} + +pub fn setup_cast(name: &str, style: PathStyle) -> (TestProject, TestCommand) { + setup_cast_project(TestProject::new(name, style)) +} + +pub fn setup_cast_project(test: TestProject) -> (TestProject, TestCommand) { + let cmd = test.cast_command(); + (test, cmd) +} + +/// `TestProject` represents a temporary project to run tests against. +/// +/// Test projects are created from a global atomic counter to avoid duplicates. +#[derive(Clone, Debug)] +pub struct TestProject< + T: ArtifactOutput + Default = ConfigurableArtifacts, +> { + /// The Cargo profile directory (`target/`) containing the Foundry binaries built + /// alongside this test executable. + profile_dir: PathBuf, + /// The project in which the test should run. + pub(crate) inner: Arc>, +} + +impl TestProject { + /// Create a new test project with the given name. The name + /// does not need to be distinct for each invocation, but should correspond + /// to a logical grouping of tests. + pub fn new(name: &str, style: PathStyle) -> Self { + let id = NEXT_ID.fetch_add(1, Ordering::SeqCst); + let project = pretty_err(name, TempProject::with_style(&format!("{name}-{id}"), style)); + Self::with_project(project) + } + + pub fn with_project(project: TempProject) -> Self { + init_tracing(); + Self { profile_dir: cargo_profile_dir(), inner: Arc::new(project) } + } + + /// Returns the root path of the project's workspace. + pub fn root(&self) -> &Path { + self.inner.root() + } + + /// Returns the paths config. + pub fn paths(&self) -> &ProjectPathsConfig { + self.inner.paths() + } + + /// Returns the path to the project's `foundry.toml` file. + pub fn config(&self) -> PathBuf { + self.root().join(Config::FILE_NAME) + } + + /// Returns the path to the project's cache file. + pub fn cache(&self) -> &PathBuf { + &self.paths().cache + } + + /// Returns the path to the project's artifacts directory. + pub fn artifacts(&self) -> &PathBuf { + &self.paths().artifacts + } + + /// Removes the project's cache and artifacts directory. + pub fn clear(&self) { + self.clear_cache(); + self.clear_artifacts(); + } + + /// Removes this project's cache file. + pub fn clear_cache(&self) { + let _ = fs::remove_file(self.cache()); + } + + /// Removes this project's artifacts directory. + pub fn clear_artifacts(&self) { + let _ = fs::remove_dir_all(self.artifacts()); + } + + /// Removes the entire cache directory (including fuzz, invariant, and test-failures caches). + pub fn clear_cache_dir(&self) { + let _ = fs::remove_dir_all(self.root().join("cache")); + } + + /// Updates the project's config with the given function. + pub fn update_config(&self, f: impl FnOnce(&mut Config)) { + self._update_config(Box::new(f)); + } + + fn _update_config(&self, f: Box) { + let mut config = self + .config() + .exists() + .then_some(()) + .and_then(|()| Config::load_with_root(self.root()).ok()) + .unwrap_or_default(); + config.remappings.clear(); + f(&mut config); + self.write_config(config); + } + + /// Writes the given config as toml to `foundry.toml`. + #[doc(hidden)] // Prefer `update_config`. + pub fn write_config(&self, config: Config) { + let file = self.config(); + pretty_err(&file, fs::write(&file, config.to_string_pretty().unwrap())); + } + + /// Writes [`rpc_endpoints`] to the project's config. + pub fn add_rpc_endpoints(&self) { + self.update_config(|config| { + config.rpc_endpoints = rpc_endpoints(); + }); + } + + /// Adds a source file to the project. + pub fn add_source(&self, name: &str, contents: &str) -> PathBuf { + self.inner.add_source(name, Self::add_source_prelude(contents)).unwrap() + } + + /// Adds a source file to the project. Prefer using `add_source` instead. + pub fn add_raw_source(&self, name: &str, contents: &str) -> PathBuf { + self.inner.add_source(name, contents).unwrap() + } + + /// Adds a script file to the project. + pub fn add_script(&self, name: &str, contents: &str) -> PathBuf { + self.inner.add_script(name, Self::add_source_prelude(contents)).unwrap() + } + + /// Adds a script file to the project. Prefer using `add_script` instead. + pub fn add_raw_script(&self, name: &str, contents: &str) -> PathBuf { + self.inner.add_script(name, contents).unwrap() + } + + /// Adds a test file to the project. + pub fn add_test(&self, name: &str, contents: &str) -> PathBuf { + self.inner.add_test(name, Self::add_source_prelude(contents)).unwrap() + } + + /// Adds a test file to the project. Prefer using `add_test` instead. + pub fn add_raw_test(&self, name: &str, contents: &str) -> PathBuf { + self.inner.add_test(name, contents).unwrap() + } + + /// Adds a library file to the project. + pub fn add_lib(&self, name: &str, contents: &str) -> PathBuf { + self.inner.add_lib(name, Self::add_source_prelude(contents)).unwrap() + } + + /// Adds a library file to the project. Prefer using `add_lib` instead. + pub fn add_raw_lib(&self, name: &str, contents: &str) -> PathBuf { + self.inner.add_lib(name, contents).unwrap() + } + + fn add_source_prelude(s: &str) -> String { + let mut s = s.to_string(); + if !s.contains("pragma solidity") { + s = format!("pragma solidity ={SOLC_VERSION};\n{s}"); + } + if !s.contains("// SPDX") { + s = format!("// SPDX-License-Identifier: MIT OR Apache-2.0\n{s}"); + } + s + } + + /// Asserts that the `/foundry.toml` file exists. + #[track_caller] + pub fn assert_config_exists(&self) { + assert!(self.config().exists()); + } + + /// Asserts that the `/cache/sol-files-cache.json` file exists. + #[track_caller] + pub fn assert_cache_exists(&self) { + assert!(self.cache().exists()); + } + + /// Asserts that the `/out` file exists. + #[track_caller] + pub fn assert_artifacts_dir_exists(&self) { + assert!(self.paths().artifacts.exists()); + } + + /// Creates all project dirs and ensure they were created + #[track_caller] + pub fn assert_create_dirs_exists(&self) { + self.paths().create_all().unwrap_or_else(|_| panic!("Failed to create project paths")); + CompilerCache::::default() + .write(&self.paths().cache) + .expect("Failed to create cache"); + self.assert_all_paths_exist(); + } + + /// Ensures that the given layout exists + #[track_caller] + pub fn assert_style_paths_exist(&self, style: PathStyle) { + let paths = style.paths(&self.paths().root).unwrap(); + config_paths_exist(&paths, self.inner.project().cached); + } + + /// Copies the project's root directory to the given target, excluding build artifacts. + #[track_caller] + pub fn copy_to(&self, target: impl AsRef) { + let target = target.as_ref(); + pretty_err(target, fs::create_dir_all(target)); + pretty_err(target, copy_dir_filtered(self.root(), target)); + } + + /// Creates a file with contents `contents` in the test project's directory. The + /// file will be deleted when the project is dropped. + pub fn create_file(&self, path: impl AsRef, contents: &str) -> PathBuf { + let path = path.as_ref(); + assert!(path.is_relative(), "create_file(): file path is absolute"); + let path = self.root().join(path); + if let Some(parent) = path.parent() { + pretty_err(parent, std::fs::create_dir_all(parent)); + } + let file = pretty_err(&path, File::create(&path)); + let mut writer = BufWriter::new(file); + pretty_err(&path, writer.write_all(contents.as_bytes())); + path + } + + /// Adds DSTest as a source under "test.sol" + pub fn insert_ds_test(&self) -> PathBuf { + self.add_source("test.sol", include_str!("../../../testdata/utils/DSTest.sol")) + } + + /// Adds custom test utils under the "test/utils" directory. + pub fn insert_utils(&self) { + self.add_test("utils/DSTest.sol", include_str!("../../../testdata/utils/DSTest.sol")); + self.add_test("utils/Test.sol", include_str!("../../../testdata/utils/Test.sol")); + self.add_test("utils/Vm.sol", include_str!("../../../testdata/utils/Vm.sol")); + self.add_test("utils/console.sol", include_str!("../../../testdata/utils/console.sol")); + } + + /// Adds `console.sol` as a source under "console.sol" + pub fn insert_console(&self) -> PathBuf { + let s = include_str!("../../../testdata/utils/console.sol"); + self.add_source("console.sol", s) + } + + /// Adds `Vm.sol` as a source under "Vm.sol" + pub fn insert_vm(&self) -> PathBuf { + let s = include_str!("../../../testdata/utils/Vm.sol"); + self.add_source("Vm.sol", s) + } + + /// Asserts all project paths exist. These are: + /// - sources + /// - artifacts + /// - libs + /// - cache + pub fn assert_all_paths_exist(&self) { + let paths = self.paths(); + config_paths_exist(paths, self.inner.project().cached); + } + + /// Asserts that the artifacts dir and cache don't exist + pub fn assert_cleaned(&self) { + let paths = self.paths(); + assert!(!paths.cache.exists()); + assert!(!paths.artifacts.exists()); + } + + /// Creates a new command that is set to use the forge executable for this project + #[track_caller] + pub fn forge_command(&self) -> TestCommand { + let cmd = self.forge_bin(); + let _lock = CURRENT_DIR_LOCK.lock(); + TestCommand { + project: self.clone(), + cmd, + current_dir_lock: None, + saved_cwd: pretty_err("", std::env::current_dir()), + stdin: None, + redact_output: true, + } + } + + /// Creates a new command that is set to use the cast executable for this project + pub fn cast_command(&self) -> TestCommand { + let mut cmd = self.cast_bin(); + cmd.current_dir(self.inner.root()); + let _lock = CURRENT_DIR_LOCK.lock(); + TestCommand { + project: self.clone(), + cmd, + current_dir_lock: None, + saved_cwd: pretty_err("", std::env::current_dir()), + stdin: None, + redact_output: true, + } + } + + /// Returns the path to the forge executable. + pub fn forge_bin(&self) -> Command { + let mut cmd = Command::new(self.foundry_bin_path("forge")); + cmd.current_dir(self.inner.root()); + // Disable color output for comparisons; can be overridden with `--color always`. + cmd.env("NO_COLOR", "1"); + cmd + } + + /// Returns the path to a sibling Foundry executable in the current test target directory. + pub fn foundry_bin_path(&self, name: &str) -> PathBuf { + canonicalize(self.profile_dir.join(format!("{name}{}", env::consts::EXE_SUFFIX))) + } + + /// Returns the path to a sibling Foundry executable, building it when cargo did not. + pub fn ensure_foundry_bin(&self, name: &str) -> PathBuf { + let bin = self.foundry_bin_path(name); + if bin.exists() { + return bin; + } + + let package = format!("{name}@{}", env!("CARGO_PKG_VERSION")); + let (target_dir, profile) = cargo_build_target_dir_and_profile(&self.profile_dir); + let mut cmd = Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into())); + cmd.args(["build", "-p", &package, "--bin", name, "--manifest-path"]) + .arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../Cargo.toml")) + .arg("--target-dir") + .arg(target_dir); + if let Some(profile) = profile { + cmd.arg("--profile").arg(profile); + } + + let output = cmd.output().expect("build Foundry sibling binary"); + assert!( + output.status.success(), + "failed to build {name} for CLI test\nstdout:\n{}\nstderr:\n{}", + output.stdout_lossy(), + output.stderr_lossy(), + ); + + bin + } + + /// Returns the path to the cast executable. + pub fn cast_bin(&self) -> Command { + let mut cmd = Command::new(self.foundry_bin_path("cast")); + // disable color output for comparisons + cmd.env("NO_COLOR", "1"); + cmd + } + + /// Returns the `Config` as spit out by `forge config` + pub fn config_from_output(&self, args: I) -> Config + where + I: IntoIterator, + A: AsRef, + { + let mut cmd = self.forge_bin(); + cmd.arg("config").arg("--root").arg(self.root()).args(args).arg("--json"); + let output = cmd.output().unwrap(); + let c = lossy_string(&output.stdout); + let config: Config = serde_json::from_str(c.as_ref()).unwrap(); + config.sanitized() + } + + /// Removes all files and dirs inside the project's root dir + pub fn wipe(&self) { + pretty_err(self.root(), fs::remove_dir_all(self.root())); + pretty_err(self.root(), fs::create_dir_all(self.root())); + } + + /// Removes all contract files from `src`, `test`, `script` + pub fn wipe_contracts(&self) { + fn rm_create(path: &Path) { + pretty_err(path, fs::remove_dir_all(path)); + pretty_err(path, fs::create_dir(path)); + } + rm_create(&self.paths().sources); + rm_create(&self.paths().tests); + rm_create(&self.paths().scripts); + } + + /// Initializes the default contracts (Counter.sol, Counter.t.sol, Counter.s.sol). + /// + /// This is useful for tests that need the default contracts created by `forge init`. + /// Most tests should not need this method, as the default behavior is to create an empty + /// project. + pub fn initialize_default_contracts(&self) { + self.add_raw_source( + "Counter.sol", + include_str!("../../forge/assets/solidity/CounterTemplate.sol"), + ); + self.add_raw_test( + "Counter.t.sol", + include_str!("../../forge/assets/solidity/CounterTemplate.t.sol"), + ); + self.add_raw_script( + "Counter.s.sol", + include_str!("../../forge/assets/solidity/CounterTemplate.s.sol"), + ); + } +} + +fn config_paths_exist(paths: &ProjectPathsConfig, cached: bool) { + if cached { + assert!(paths.cache.exists()); + } + assert!(paths.sources.exists()); + assert!(paths.artifacts.exists()); + paths.libraries.iter().for_each(|lib| assert!(lib.exists())); +} + +/// A simple wrapper around a Command with some conveniences. +pub struct TestCommand { + saved_cwd: PathBuf, + /// The project used to launch this command. + project: TestProject, + /// The actual command we use to control the process. + cmd: Command, + // initial: Command, + current_dir_lock: Option>, + stdin: Option>, + /// If true, command output is redacted. + redact_output: bool, +} + +impl TestCommand { + /// Returns a mutable reference to the underlying command. + pub const fn cmd(&mut self) -> &mut Command { + &mut self.cmd + } + + /// Replaces the underlying command. + pub fn set_cmd(&mut self, cmd: Command) -> &mut Self { + self.cmd = cmd; + self + } + + /// Resets the command to the default `forge` command. + pub fn forge_fuse(&mut self) -> &mut Self { + self.set_cmd(self.project.forge_bin()) + } + + /// Resets the command to the default `cast` command. + pub fn cast_fuse(&mut self) -> &mut Self { + self.set_cmd(self.project.cast_bin()) + } + + /// Sets the current working directory. + pub fn set_current_dir(&mut self, p: impl AsRef) { + drop(self.current_dir_lock.take()); + let lock = CURRENT_DIR_LOCK.lock(); + self.current_dir_lock = Some(lock); + let p = p.as_ref(); + pretty_err(p, std::env::set_current_dir(p)); + } + + /// Add an argument to pass to the command. + pub fn arg>(&mut self, arg: A) -> &mut Self { + self.cmd.arg(arg); + self + } + + /// Add any number of arguments to the command. + pub fn args(&mut self, args: I) -> &mut Self + where + I: IntoIterator, + A: AsRef, + { + self.cmd.args(args); + self + } + + /// Set the stdin bytes for the next command. + pub fn stdin(&mut self, stdin: impl Into>) -> &mut Self { + self.stdin = Some(stdin.into()); + self + } + + /// Convenience function to add `--root project.root()` argument + pub fn root_arg(&mut self) -> &mut Self { + let root = self.project.root().to_path_buf(); + self.arg("--root").arg(root) + } + + /// Set the environment variable `k` to value `v` for the command. + pub fn env(&mut self, k: impl AsRef, v: impl AsRef) { + self.cmd.env(k, v); + } + + /// Set the environment variable `k` to value `v` for the command. + pub fn envs(&mut self, envs: I) + where + I: IntoIterator, + K: AsRef, + V: AsRef, + { + self.cmd.envs(envs); + } + + /// Unsets the environment variable `k` for the command. + pub fn unset_env(&mut self, k: impl AsRef) { + self.cmd.env_remove(k); + } + + /// Set the working directory for this command. + /// + /// Note that this does not need to be called normally, since the creation + /// of this TestCommand causes its working directory to be set to the + /// test's directory automatically. + pub fn current_dir>(&mut self, dir: P) -> &mut Self { + self.cmd.current_dir(dir); + self + } + + /// Returns the `Config` as spit out by `forge config` + #[track_caller] + pub fn config(&mut self) -> Config { + self.cmd.args(["config", "--json"]); + let output = self.assert().success().get_output().stdout_lossy(); + self.forge_fuse(); + serde_json::from_str(output.as_ref()).unwrap() + } + + /// Runs `git init` inside the project's dir + #[track_caller] + pub fn git_init(&self) { + let mut cmd = Command::new("git"); + cmd.arg("init").current_dir(self.project.root()); + let output = OutputAssert::new(cmd.output().unwrap()); + output.success(); + } + + /// Runs `git submodule status` inside the project's dir + #[track_caller] + pub fn git_submodule_status(&self) -> Output { + let mut cmd = Command::new("git"); + cmd.arg("submodule").arg("status").current_dir(self.project.root()); + cmd.output().unwrap() + } + + /// Runs `git add .` inside the project's dir + #[track_caller] + pub fn git_add(&self) { + let mut cmd = Command::new("git"); + cmd.current_dir(self.project.root()); + cmd.arg("add").arg("."); + let output = OutputAssert::new(cmd.output().unwrap()); + output.success(); + } + + /// Runs `git commit .` inside the project's dir + #[track_caller] + pub fn git_commit(&self, msg: &str) { + let mut cmd = Command::new("git"); + cmd.current_dir(self.project.root()); + cmd.arg("commit").arg("-m").arg(msg); + let output = OutputAssert::new(cmd.output().unwrap()); + output.success(); + } + + /// Runs the command, returning a [`snapbox`] object to assert the command output. + #[track_caller] + pub fn assert_with(&mut self, f: &[RegexRedaction]) -> OutputAssert { + let assert = OutputAssert::new(self.execute()); + if self.redact_output { + let mut redactions = test_redactions(); + insert_redactions(f, &mut redactions); + return assert.with_assert( + snapbox::Assert::new() + .action_env(snapbox::assert::DEFAULT_ACTION_ENV) + .redact_with(redactions), + ); + } + assert + } + + /// Runs the command, returning a [`snapbox`] object to assert the command output. + #[track_caller] + pub fn assert(&mut self) -> OutputAssert { + self.assert_with(&[]) + } + + /// Runs the command and asserts that it resulted in success. + #[track_caller] + pub fn assert_success(&mut self) -> OutputAssert { + self.assert().success() + } + + /// Runs the command and asserts that it resulted in success, with expected JSON data. + #[track_caller] + pub fn assert_json_stdout(&mut self, expected: impl IntoData) { + self.assert_json_stdout_with_status(true, expected); + } + + /// Runs the command, asserts that it resulted in the expected outcome and JSON stdout, and + /// returns the output assertion. + #[track_caller] + pub fn assert_json_stdout_with_status( + &mut self, + success: bool, + expected: impl IntoData, + ) -> OutputAssert { + let expected = expected.is(snapbox::data::DataFormat::Json).unordered(); + let assert = if success { self.assert_success() } else { self.assert_failure() }; + let stdout = assert.get_output().stdout.clone(); + let actual = stdout.into_data().is(snapbox::data::DataFormat::Json).unordered(); + assert_data_eq!(actual, expected); + assert + } + + /// Runs the command and asserts that it resulted in the expected outcome and JSON data. + #[track_caller] + pub fn assert_json_stderr(&mut self, success: bool, expected: impl IntoData) { + let expected = expected.is(snapbox::data::DataFormat::Json).unordered(); + let stderr = if success { self.assert_success() } else { self.assert_failure() } + .get_output() + .stderr + .clone(); + let actual = stderr.into_data().is(snapbox::data::DataFormat::Json).unordered(); + assert_data_eq!(actual, expected); + } + + /// Runs the command and asserts that it **succeeded** nothing was printed to stdout. + #[track_caller] + pub fn assert_empty_stdout(&mut self) { + self.assert_success().stdout_eq(Data::new()); + } + + /// Runs the command and asserts that it failed. + #[track_caller] + pub fn assert_failure(&mut self) -> OutputAssert { + self.assert().failure() + } + + /// Runs the command and asserts that the exit code is `expected`. + #[track_caller] + pub fn assert_code(&mut self, expected: i32) -> OutputAssert { + self.assert().code(expected) + } + + /// Runs the command and asserts that it **failed** nothing was printed to stderr. + #[track_caller] + pub fn assert_empty_stderr(&mut self) { + self.assert_failure().stderr_eq(Data::new()); + } + + /// Runs the command with a temporary file argument and asserts that the contents of the file + /// match the given data. + #[track_caller] + pub fn assert_file(&mut self, data: impl IntoData) { + self.assert_file_with(|this, path| _ = this.arg(path).assert_success(), data); + } + + /// Creates a temporary file, passes it to `f`, then asserts that the contents of the file match + /// the given data. + #[track_caller] + pub fn assert_file_with(&mut self, f: impl FnOnce(&mut Self, &Path), data: impl IntoData) { + let file = tempfile::NamedTempFile::new().expect("couldn't create temporary file"); + f(self, file.path()); + assert_data_eq!(Data::read_from(file.path(), None), data); + } + + /// Does not apply [`snapbox`] redactions to the command output. + pub const fn with_no_redact(&mut self) -> &mut Self { + self.redact_output = false; + self + } + + /// Executes command, applies stdin function and returns output + #[track_caller] + pub fn execute(&mut self) -> Output { + self.try_execute().unwrap() + } + + #[track_caller] + pub fn try_execute(&mut self) -> std::io::Result { + test_debug!("executing {:?}", self.cmd); + let mut child = + self.cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).stdin(Stdio::piped()).spawn()?; + if let Some(bytes) = self.stdin.take() { + child.stdin.take().unwrap().write_all(&bytes)?; + } + let output = child.wait_with_output()?; + test_debug!("exited with {}", output.status); + test_trace!("\n--- stdout ---\n{}\n--- /stdout ---", output.stdout_lossy()); + test_trace!("\n--- stderr ---\n{}\n--- /stderr ---", output.stderr_lossy()); + Ok(output) + } +} + +impl Drop for TestCommand { + fn drop(&mut self) { + let _lock = self.current_dir_lock.take().unwrap_or_else(|| CURRENT_DIR_LOCK.lock()); + if self.saved_cwd.exists() { + let _ = std::env::set_current_dir(&self.saved_cwd); + } + } +} + +fn test_redactions() -> snapbox::Redactions { + static REDACTIONS: LazyLock = LazyLock::new(|| { + make_redactions(&[ + ("[SOLC_VERSION]", r"Solc( version)? \d+.\d+.\d+"), + ("[ELAPSED]", r"(finished )?in (\d+m )?\d+(\.\d+)?\w?s( \(.*?s CPU time\))?"), + ("[GAS]", r"[Gg]as( used)?: \d+"), + ("[GAS_COST]", r"[Gg]as cost\s*\(\d+\)"), + ("[GAS_LIMIT]", r"[Gg]as limit\s*\(\d+\)"), + ("[AVG_GAS]", r"μ: \d+, ~: \d+"), + ("[FILE]", r"(-->|╭▸).*\.sol"), + ("[FILE]", r"Location(.|\n)*\.rs(.|\n)*Backtrace"), + ("[COMPILING_FILES]", r"Compiling \d+ files?"), + ("[TX_HASH]", r"Transaction hash: 0x[0-9A-Fa-f]{64}"), + ("[ADDRESS]", r"Address: +0x[0-9A-Fa-f]{40}"), + ("[PUBLIC_KEY]", r"Public key: +0x[0-9A-Fa-f]{128}"), + ("[PRIVATE_KEY]", r"Private key: +0x[0-9A-Fa-f]{64}"), + ("[UPDATING_DEPENDENCIES]", r"Updating dependencies in .*"), + ("[SAVED_TRANSACTIONS]", r"Transactions saved to: .*\.json"), + ("[SAVED_SENSITIVE_VALUES]", r"Sensitive values saved to: .*\.json"), + ("[ESTIMATED_GAS_PRICE]", r"Estimated gas price:\s*(\d+(\.\d+)?)\s*gwei"), + ("[ESTIMATED_MAX_FEE_PER_GAS]", r"Estimated max fee per gas:\s*(\d+(\.\d+)?)\s*gwei"), + ("[ESTIMATED_BASE_FEE_PER_GAS]", r"Estimated base fee per gas:\s*(\d+(\.\d+)?)\s*gwei"), + ( + "[ESTIMATED_PRIORITY_FEE_PER_GAS]", + r"Estimated max priority fee per gas:\s*(\d+(\.\d+)?)\s*gwei", + ), + ("[ESTIMATED_TOTAL_GAS_USED]", r"Estimated total gas used for script: \d+"), + ( + "[ESTIMATED_AMOUNT_REQUIRED]", + r"Estimated amount required:\s*(\d+(\.\d+)?)\s*[A-Z]{3}", + ), + ("[SEED]", r"Fuzz seed: 0x[0-9A-Fa-f]+"), + ]) + }); + REDACTIONS.clone() +} + +/// A tuple of a placeholder and a regex replacement string. +pub type RegexRedaction = (&'static str, &'static str); + +/// Creates a [`snapbox`] redactions object from a list of regex redactions. +fn make_redactions(redactions: &[RegexRedaction]) -> snapbox::Redactions { + let mut r = snapbox::Redactions::new(); + insert_redactions(redactions, &mut r); + r +} + +fn insert_redactions(redactions: &[RegexRedaction], r: &mut snapbox::Redactions) { + for &(placeholder, re) in redactions { + r.insert(placeholder, Regex::new(re).expect(re)).expect(re); + } +} + +/// Extension trait for [`Output`]. +pub trait OutputExt { + /// Returns the stdout as lossy string + fn stdout_lossy(&self) -> String; + + /// Returns the stderr as lossy string + fn stderr_lossy(&self) -> String; +} + +impl OutputExt for Output { + fn stdout_lossy(&self) -> String { + lossy_string(&self.stdout) + } + + fn stderr_lossy(&self) -> String { + lossy_string(&self.stderr) + } +} + +pub fn lossy_string(bytes: &[u8]) -> String { + String::from_utf8_lossy(bytes).replace("\r\n", "\n") +} + +fn canonicalize(path: impl AsRef) -> PathBuf { + foundry_common::fs::canonicalize_path(path.as_ref()) + .unwrap_or_else(|_| path.as_ref().to_path_buf()) +} + +fn cargo_build_target_dir_and_profile(profile_dir: &Path) -> (&Path, Option<&str>) { + let target_dir = profile_dir.parent().expect("Cargo target directory"); + let profile = match profile_dir.file_name().and_then(OsStr::to_str) { + // Cargo's dev profile writes to `debug`, so the default `cargo build` profile is correct. + Some("debug") => None, + Some(profile) => Some(profile), + None => panic!("test executable profile directory must be UTF-8"), + }; + (target_dir, profile) +} + +/// Returns the Cargo profile directory (`target/`) for the currently running test +/// executable. +/// +/// Final binaries like `forge` and `cast` are uplifted into `target/` itself, while test +/// executables are compiled into `target//deps/`, or under `target//build/` with +/// Cargo's new build-dir layout, so walk up from the executable's directory until the parent of +/// the `deps` or `build` component. +pub fn cargo_profile_dir() -> PathBuf { + let exe = env::current_exe().expect("test executable path"); + let exe_dir = canonicalize(exe.parent().expect("executable's directory")); + let mut dir = exe_dir.as_path(); + while let Some(parent) = dir.parent() { + if matches!(dir.file_name().and_then(OsStr::to_str), Some("deps" | "build")) { + return parent.to_path_buf(); + } + dir = parent; + } + exe_dir +} diff --git a/crates/test-utils/src/rpc.rs b/crates/test-utils/src/rpc.rs index 4caa334c7d31c..ac66f96a4306e 100644 --- a/crates/test-utils/src/rpc.rs +++ b/crates/test-utils/src/rpc.rs @@ -1,49 +1,113 @@ -//! RPC API keys utilities. +//! RPC testing utilities. +use alloy_primitives::B256; +use axum::{ + Json, Router, + body::Bytes, + http::{HeaderMap, StatusCode}, + response::IntoResponse, + routing::post, +}; use foundry_config::{ - NamedChain, - NamedChain::{Arbitrum, Base, BinanceSmartChainTestnet, Mainnet, Optimism, Polygon, Sepolia}, + NamedChain::{ + self, Arbitrum, Base, BinanceSmartChainTestnet, Celo, Gnosis, Hyperliquid, Mainnet, + Optimism, Polygon, Robinhood, Sepolia, + }, + RpcEndpointUrl, RpcEndpoints, }; use rand::seq::SliceRandom; -use std::sync::{ - LazyLock, - atomic::{AtomicUsize, Ordering}, +use serde_json::{Value, json}; +use std::{ + env, + sync::{ + Arc, LazyLock, Mutex, + atomic::{AtomicBool, AtomicUsize, Ordering}, + }, }; +use tokio::sync::Notify; + +macro_rules! shuffled_list { + ($name:ident, $e:expr $(,)?) => { + static $name: LazyLock> = + LazyLock::new(|| ShuffledList::new($e)); + }; +} -fn shuffled(mut vec: Vec) -> Vec { - vec.shuffle(&mut rand::rng()); - vec +struct ShuffledList { + list: Vec, + index: AtomicUsize, } -// List of public archive reth nodes to use -static RETH_ARCHIVE_HOSTS: LazyLock> = LazyLock::new(|| { - shuffled(vec![ +impl ShuffledList { + fn new(mut list: Vec) -> Self { + assert!(!list.is_empty()); + list.shuffle(&mut rand::rng()); + Self { list, index: AtomicUsize::new(0) } + } + + fn next(&self) -> &T { + let index = self.index.fetch_add(1, Ordering::Relaxed); + &self.list[index % self.list.len()] + } +} + +shuffled_list!( + HTTP_ARCHIVE_DOMAINS, + vec![ // - "reth-ethereum.ithaca.xyz", - ]) -}); + "ethereum.reth.rs/rpc", + ], +); +shuffled_list!( + HTTP_DOMAINS, + vec![ + // + "ethereum.reth.rs/rpc", + ], +); +shuffled_list!( + WS_ARCHIVE_DOMAINS, + vec![ + // + "ethereum.reth.rs/ws", + ], +); +shuffled_list!( + WS_DOMAINS, + vec![ + // + "ethereum.reth.rs/ws", + ], +); -// List of public reth nodes to use (archive and non archive) -static RETH_HOSTS: LazyLock> = LazyLock::new(|| { - shuffled(vec![ +// Public Arbitrum endpoints, rotated so that a retry reaches a different provider. +// +// Every entry must serve archive state: `fork::flaky_test_arb_fork_mining` forks at a pinned block +// far behind the head, which non-archive endpoints such as `arb1.arbitrum.io` reject with +// `missing trie node`. The DRPC keys used for the other chains do not qualify: their Arbitrum quota +// is exhausted and every fork of it fails. +shuffled_list!( + ARBITRUM_URLS, + vec![ // - "reth-ethereum.ithaca.xyz", - "reth-ethereum-full.ithaca.xyz", - ]) -}); + "https://arb-pokt.nodies.app", + "https://arbitrum.gateway.tenderly.co", + ], +); // List of general purpose DRPC keys to rotate through -static DRPC_KEYS: LazyLock> = LazyLock::new(|| { - shuffled(vec![ - // +shuffled_list!( + DRPC_KEYS, + vec![ "Agc9NK9-6UzYh-vQDDM80Tv0A5UnBkUR8I3qssvAG40d", "AjUPUPonSEInt2CZ_7A-ai3hMyxxBlsR8I4EssvAG40d", - ]) -}); + ], +); // List of etherscan keys. -static ETHERSCAN_KEYS: LazyLock> = LazyLock::new(|| { - shuffled(vec![ +shuffled_list!( + ETHERSCAN_KEYS, + vec![ "MCAUM7WPE9XP5UQMZPCKIBUJHPM1C24FP6", "JW6RWCG2C5QF8TANH4KC7AYIF1CX7RB5D1", "ZSMDY6BI2H55MBE3G9CUUQT4XYUDBB6ZSK", @@ -53,18 +117,27 @@ static ETHERSCAN_KEYS: LazyLock> = LazyLock::new(|| { "C7I2G4JTA5EPYS42Z8IZFEIMQNI5GXIJEV", "A15KZUMZXXCK1P25Y1VP1WGIVBBHIZDS74", "3IA6ASNQXN8WKN7PNFX7T72S9YG56X9FPG", - ]) -}); + ], +); -/// Returns the next index to use. -fn next_idx() -> usize { - static NEXT_INDEX: AtomicUsize = AtomicUsize::new(0); - NEXT_INDEX.fetch_add(1, Ordering::SeqCst) -} - -/// Returns the next item in the list to use. -fn next(list: &[T]) -> &T { - &list[next_idx() % list.len()] +/// the RPC endpoints used during tests +pub fn rpc_endpoints() -> RpcEndpoints { + RpcEndpoints::new([ + ("mainnet", RpcEndpointUrl::Url(next_http_archive_rpc_url())), + ("mainnet2", RpcEndpointUrl::Url(next_http_archive_rpc_url())), + ("sepolia", RpcEndpointUrl::Url(next_rpc_endpoint(NamedChain::Sepolia))), + ("optimism", RpcEndpointUrl::Url(next_rpc_endpoint(NamedChain::Optimism))), + ("base", RpcEndpointUrl::Url(next_rpc_endpoint(NamedChain::Base))), + ("arbitrum", RpcEndpointUrl::Url(next_rpc_endpoint(NamedChain::Arbitrum))), + ("polygon", RpcEndpointUrl::Url(next_rpc_endpoint(NamedChain::Polygon))), + ("bsc", RpcEndpointUrl::Url(next_rpc_endpoint(NamedChain::BinanceSmartChain))), + ("avaxTestnet", RpcEndpointUrl::Url("https://api.avax-test.network/ext/bc/C/rpc".into())), + ("moonbeam", RpcEndpointUrl::Url("https://moonbeam.api.onfinality.io/public".into())), + ("polkadotTestnet", RpcEndpointUrl::Url("https://eth-rpc-testnet.polkadot.io".into())), + ("kusama", RpcEndpointUrl::Url("https://eth-rpc-kusama.polkadot.io".into())), + ("polkadot", RpcEndpointUrl::Url("https://eth-rpc.polkadot.io".into())), + ("rpcEnvAlias", RpcEndpointUrl::Env("${RPC_ENV_ALIAS}".into())), + ]) } /// Returns the next _mainnet_ rpc URL in inline @@ -86,58 +159,67 @@ pub fn next_rpc_endpoint(chain: NamedChain) -> String { next_url(false, chain) } +/// Returns the HTTP RPC URL used to fork Tempo mainnet. +/// +/// Set `TEMPO_MAINNET_RPC_URL`, the variable the Tempo CI workflows already use, to route the +/// fork tests through a private endpoint; the public one applies rate limits. +pub fn next_tempo_mainnet_rpc_endpoint() -> String { + let url = + env_rpc_url("TEMPO_MAINNET_RPC_URL").unwrap_or_else(|| "https://rpc.tempo.xyz".to_string()); + test_debug!("next_tempo_mainnet_rpc_endpoint() = {}", debug_url(&url)); + url +} + +/// Returns the HTTP RPC URL used to fork Tempo testnet. +/// +/// Set `TEMPO_TESTNET_RPC_URL` to use a private archive endpoint instead of the public one. +pub fn next_tempo_testnet_rpc_endpoint() -> String { + let url = env_rpc_url("TEMPO_TESTNET_RPC_URL") + .unwrap_or_else(|| "https://rpc.moderato.tempo.xyz".to_string()); + test_debug!("next_tempo_testnet_rpc_endpoint() = {}", debug_url(&url)); + url +} + /// Returns the next WS RPC URL. pub fn next_ws_endpoint(chain: NamedChain) -> String { next_url(true, chain) } -/// Returns a websocket URL that has access to archive state +/// Returns an HTTP URL that has access to archive state pub fn next_http_archive_rpc_url() -> String { next_archive_url(false) } -/// Returns an HTTP URL that has access to archive state +/// Returns a websocket URL that has access to archive state pub fn next_ws_archive_rpc_url() -> String { next_archive_url(true) } /// Returns a URL that has access to archive state. fn next_archive_url(is_ws: bool) -> String { - let urls = archive_urls(is_ws); - let url = next(urls); - eprintln!("--- next_archive_url(is_ws={is_ws}) = {url} ---"); - url.clone() -} - -fn archive_urls(is_ws: bool) -> &'static [String] { - static WS: LazyLock> = LazyLock::new(|| get(true)); - static HTTP: LazyLock> = LazyLock::new(|| get(false)); - - fn get(is_ws: bool) -> Vec { - let mut urls = vec![]; - - for &host in RETH_ARCHIVE_HOSTS.iter() { - if is_ws { - urls.push(format!("wss://{host}/ws")); - } else { - urls.push(format!("https://{host}/rpc")); - } - } - - urls - } - - if is_ws { &WS } else { &HTTP } + let domain = if is_ws { &WS_ARCHIVE_DOMAINS } else { &HTTP_ARCHIVE_DOMAINS }.next(); + let url = if is_ws { format!("wss://{domain}") } else { format!("https://{domain}") }; + test_debug!("next_archive_url(is_ws={is_ws}) = {}", debug_url(&url)); + url } /// Returns the next etherscan api key. pub fn next_etherscan_api_key() -> String { - let key = next(ÐERSCAN_KEYS).to_string(); - eprintln!("--- next_etherscan_api_key() = {key} ---"); + let mut key = env::var("ETHERSCAN_KEY").unwrap_or_default(); + if key.is_empty() { + key = ETHERSCAN_KEYS.next().to_string(); + } + test_debug!("next_etherscan_api_key() = {}...", &key[..6]); key } fn next_url(is_ws: bool, chain: NamedChain) -> String { + let url = next_url_inner(is_ws, chain); + test_debug!("next_url(is_ws={is_ws}, chain={chain:?}) = {}", debug_url(&url)); + url +} + +fn next_url_inner(is_ws: bool, chain: NamedChain) -> String { if matches!(chain, Base) { return "https://mainnet.base.org".to_string(); } @@ -147,32 +229,690 @@ fn next_url(is_ws: bool, chain: NamedChain) -> String { } if matches!(chain, BinanceSmartChainTestnet) { - return "https://bsc-testnet-rpc.publicnode.com".to_string(); + return "https://bsc-testnet.bnbchain.org".to_string(); + } + + if matches!(chain, Celo) { + // Not `celo.drpc.org`: it load balances across upstreams that disagree on the chain head, + // so a fork of it regularly fails to fetch the block it just resolved. + return env_rpc_url("CELO_RPC").unwrap_or_else(|| "https://forno.celo.org".to_string()); + } + + if matches!(chain, Gnosis) { + return env_rpc_url("GNOSIS_RPC") + .unwrap_or_else(|| "https://rpc.gnosischain.com".to_string()); + } + + if matches!(chain, Hyperliquid) { + return env_rpc_url("HYPERLIQUID_RPC") + .unwrap_or_else(|| "https://rpc.hyperliquid.xyz/evm".to_string()); + } + + if matches!(chain, Robinhood) { + return env_rpc_url("ROBINHOOD_RPC") + .unwrap_or_else(|| "https://rpc.mainnet.chain.robinhood.com".to_string()); + } + + if matches!(chain, Sepolia) { + if let Some(rpc_url) = env_rpc_url("ETH_SEPOLIA_RPC") { + return rpc_url; + } + return "https://ethereum-sepolia-rpc.publicnode.com".to_string(); + } + + if matches!(chain, Arbitrum) { + return env_rpc_url("ARBITRUM_RPC").unwrap_or_else(|| (*ARBITRUM_URLS.next()).to_string()); } - let domain = if matches!(chain, Mainnet) { - // For Mainnet pick one of Reth nodes. - let idx = next_idx() % RETH_HOSTS.len(); - let host = RETH_HOSTS[idx]; - if is_ws { format!("{host}/ws") } else { format!("{host}/rpc") } + let reth_works = true; + let domain = if reth_works && matches!(chain, Mainnet) { + *(if is_ws { &WS_DOMAINS } else { &HTTP_DOMAINS }).next() } else { // DRPC for other networks used in tests. - let idx = next_idx() % DRPC_KEYS.len(); - let key = DRPC_KEYS[idx]; - + let key = DRPC_KEYS.next(); let network = match chain { - Arbitrum => "arbitrum", + Mainnet => "ethereum", Polygon => "polygon", Sepolia => "sepolia", _ => "", }; - format!("lb.drpc.org/ogrpc?network={network}&dkey={key}") + &format!("lb.drpc.org/ogrpc?network={network}&dkey={key}") }; - let url = if is_ws { format!("wss://{domain}") } else { format!("https://{domain}") }; + if is_ws { format!("wss://{domain}") } else { format!("https://{domain}") } +} - eprintln!("--- next_url(is_ws={is_ws}, chain={chain:?}) = {url} ---"); - url +/// Returns the RPC URL configured in the `var` environment variable, if it is set and non-empty. +fn env_rpc_url(var: &str) -> Option { + env::var(var).ok().filter(|url| !url.is_empty()) +} + +/// Basic redaction for debugging RPC URLs. +fn debug_url(url: &str) -> impl std::fmt::Display + '_ { + let url = reqwest::Url::parse(url).unwrap(); + format!( + "{scheme}://{host}{path}", + scheme = url.scheme(), + host = url.host_str().unwrap(), + path = url.path().get(..8).unwrap_or(url.path()), + ) +} + +const MONAD_SYSTEM_ADDRESS: &str = "0x6f49a8f621353f12378d0046e7d7e4b9b249dc9e"; + +/// Spawns an RPC proxy that presents one transaction as a canonical Monad protocol envelope. +pub async fn spawn_canonical_monad_system_rpc(endpoint: String, target_hash: B256) -> String { + let target_hash = target_hash.to_string(); + let client = reqwest::Client::new(); + let router = Router::new().route( + "/", + post(move |Json(request): Json| { + let client = client.clone(); + let endpoint = endpoint.clone(); + let target_hash = target_hash.clone(); + async move { + let mut response = client + .post(endpoint) + .json(&request) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + + canonicalize_monad_system_response(&request, &mut response, &target_hash); + + Json(response) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + format!("http://{address}") +} + +/// Spawns an RPC proxy that rejects `method` after forwarding `successful_calls` requests. +/// +/// Rejections use an HTTP 403 response with a vendor-specific JSON-RPC error code. This models +/// gateways that deny unknown or custom methods without using the standard method-not-found code. +pub async fn spawn_rpc_proxy_rejecting_method_after( + endpoint: String, + method: &'static str, + successful_calls: usize, +) -> String { + spawn_rpc_proxy_rejecting_method( + endpoint, + method, + RpcMethodRejection::After(successful_calls), + StatusCode::FORBIDDEN, + -32004, + "method is not allowed", + ) + .await +} + +/// Spawns an RPC proxy whose rejection of `method` can be enabled after startup. +pub async fn spawn_rpc_proxy_rejecting_method_when_enabled( + endpoint: String, + method: &'static str, +) -> (String, Arc) { + let enabled = Arc::new(AtomicBool::new(false)); + let proxy = spawn_rpc_proxy_rejecting_method( + endpoint, + method, + RpcMethodRejection::Enabled(enabled.clone()), + StatusCode::FORBIDDEN, + -32004, + "method is not allowed", + ) + .await; + (proxy, enabled) +} + +/// Spawns an RPC proxy that forwards `successful_calls` requests to `method`, then rejects later +/// requests while the returned switch is enabled. +pub async fn spawn_rpc_proxy_rejecting_method_after_when_enabled( + endpoint: String, + method: &'static str, + successful_calls: usize, +) -> (String, Arc) { + let enabled = Arc::new(AtomicBool::new(true)); + let proxy = spawn_rpc_proxy_rejecting_method( + endpoint, + method, + RpcMethodRejection::AfterWhenEnabled(successful_calls, enabled.clone()), + StatusCode::FORBIDDEN, + -32004, + "method is not allowed", + ) + .await; + (proxy, enabled) +} + +/// Spawns an RPC proxy that returns method-not-found for the first `unavailable_calls` requests to +/// `method`. +pub async fn spawn_rpc_proxy_method_not_found_before( + endpoint: String, + method: &'static str, + unavailable_calls: usize, +) -> String { + spawn_rpc_proxy_rejecting_method( + endpoint, + method, + RpcMethodRejection::Before(unavailable_calls), + StatusCode::OK, + -32601, + "method not found", + ) + .await +} + +/// Spawns an RPC proxy that returns a JSON-RPC internal error for `method` after forwarding +/// `successful_calls` requests. +pub async fn spawn_rpc_proxy_internal_error_after( + endpoint: String, + method: &'static str, + successful_calls: usize, +) -> String { + spawn_rpc_proxy_rejecting_method( + endpoint, + method, + RpcMethodRejection::After(successful_calls), + StatusCode::OK, + -32603, + "internal error", + ) + .await +} + +/// Spawns an RPC proxy that answers `method` with `result` instead of forwarding it upstream. +/// +/// All other methods are forwarded. The returned counter tracks how many `method` calls reached the +/// proxy, which lets tests assert that a request was never sent upstream. +pub async fn spawn_rpc_proxy_canned_method( + endpoint: String, + method: &'static str, + result: Value, +) -> (String, Arc) { + let client = reqwest::Client::new(); + let calls = Arc::new(AtomicUsize::new(0)); + let proxy_calls = calls.clone(); + let router = Router::new().route( + "/", + post(move |Json(request): Json| { + let client = client.clone(); + let endpoint = endpoint.clone(); + let calls = proxy_calls.clone(); + let result = result.clone(); + async move { + if request.get("method").and_then(Value::as_str) == Some(method) { + calls.fetch_add(1, Ordering::Relaxed); + let id = request.get("id").cloned().unwrap_or(Value::Null); + return Json(json!({ + "jsonrpc": "2.0", + "id": id, + "result": result, + })); + } + + let response = client + .post(endpoint) + .json(&request) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + Json(response) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + (format!("http://{address}"), calls) +} + +/// Spawns an RPC proxy that reports the first transaction of every full block under `tx_type`. +/// +/// Chains anvil can fork but not execute, such as Arbitrum and its Orbit rollups, open their +/// blocks with a system transaction of a type Foundry does not model. This reproduces that shape +/// on top of any endpoint, without depending on a public archive node. +pub async fn spawn_rpc_proxy_retyping_first_block_transaction( + endpoint: String, + tx_type: &'static str, +) -> String { + let client = reqwest::Client::new(); + let router = Router::new().route( + "/", + post(move |Json(request): Json| { + let client = client.clone(); + let endpoint = endpoint.clone(); + async move { + let mut response = client + .post(endpoint) + .json(&request) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + let responses = match response.as_array_mut() { + Some(batch) => batch.iter_mut().collect::>(), + None => vec![&mut response], + }; + for response in responses { + if let Some(transactions) = response + .get_mut("result") + .and_then(|result| result.get_mut("transactions")) + .and_then(Value::as_array_mut) + && let Some(first) = transactions.first_mut().and_then(Value::as_object_mut) + { + first.insert("type".to_string(), Value::from(tx_type)); + } + } + Json(response).into_response() + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + format!("http://{address}") +} + +/// Spawns an RPC proxy that forwards every request upstream and passes each `method` result, +/// together with the request params, through `map` before returning it. +pub async fn spawn_rpc_proxy_mapping_method( + endpoint: String, + method: &'static str, + map: impl Fn(&Value, Value) -> Value + Send + Sync + 'static, +) -> String { + let client = reqwest::Client::new(); + let map = Arc::new(map); + let router = Router::new().route( + "/", + post(move |Json(request): Json| { + let client = client.clone(); + let endpoint = endpoint.clone(); + let map = map.clone(); + async move { + let mut response = client + .post(endpoint) + .json(&request) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + if request.get("method").and_then(Value::as_str) == Some(method) + && let Some(result) = response.get_mut("result") + { + let params = request.get("params").cloned().unwrap_or(Value::Null); + *result = map(¶ms, result.take()); + } + Json(response) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + format!("http://{address}") +} + +/// Spawns an RPC proxy that records the params of every request to `method` before forwarding it. +pub async fn spawn_rpc_proxy_recording_method( + endpoint: String, + method: &'static str, +) -> (String, Arc>>) { + let client = reqwest::Client::new(); + let requests = Arc::new(Mutex::new(Vec::new())); + let router = Router::new().route( + "/", + post({ + let requests = requests.clone(); + move |Json(request): Json| { + let client = client.clone(); + let endpoint = endpoint.clone(); + let requests = requests.clone(); + async move { + if request.get("method").and_then(Value::as_str) == Some(method) { + requests + .lock() + .unwrap() + .push(request.get("params").cloned().unwrap_or(Value::Null)); + } + let response = client + .post(endpoint) + .json(&request) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + Json(response) + } + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + (format!("http://{address}"), requests) +} + +/// Spawns an RPC proxy that forwards requests containing the required header and rejects all +/// others. +pub async fn spawn_rpc_proxy_requiring_header( + endpoint: String, + header_name: &'static str, + header_value: &'static str, +) -> String { + let client = reqwest::Client::new(); + let router = Router::new().route( + "/", + post(move |headers: HeaderMap, Json(request): Json| { + let client = client.clone(); + let endpoint = endpoint.clone(); + async move { + if headers.get(header_name).and_then(|value| value.to_str().ok()) + != Some(header_value) + { + let id = request.get("id").cloned().unwrap_or(Value::Null); + return ( + StatusCode::UNAUTHORIZED, + Json(json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": -32000, + "message": "unauthorized", + }, + })), + ) + .into_response(); + } + + let response = client + .post(endpoint) + .json(&request) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + Json(response).into_response() + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + format!("http://{address}") +} + +/// Spawns an RPC proxy that records the params of every request to `method` and holds the first +/// one until `release` is notified, then answers it with `503 Service Unavailable`. +/// +/// `reached` is notified once the first request is held. When `forward` is true, that request is +/// forwarded before it is held and the upstream response is withheld. +pub async fn spawn_rpc_proxy_blocking_first_submission( + endpoint: String, + method: &'static str, + forward: bool, +) -> (String, Arc>>, Arc, Arc) { + let client = reqwest::Client::new(); + let submissions = Arc::new(Mutex::new(Vec::new())); + let reached = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + let router = Router::new().fallback({ + let submissions = submissions.clone(); + let reached = reached.clone(); + let release = release.clone(); + move |body: Bytes| { + let client = client.clone(); + let endpoint = endpoint.clone(); + let submissions = submissions.clone(); + let reached = reached.clone(); + let release = release.clone(); + async move { + let request: Value = serde_json::from_slice(&body).unwrap(); + let first = request.get("method").and_then(Value::as_str) == Some(method) && { + let mut submissions = submissions.lock().unwrap(); + submissions.push(request.get("params").cloned().unwrap_or(Value::Null)); + submissions.len() == 1 + }; + let response = if first && !forward { + Bytes::new() + } else { + client + .post(endpoint) + .header("content-type", "application/json") + .body(body) + .send() + .await + .unwrap() + .bytes() + .await + .unwrap() + }; + if first { + reached.notify_one(); + release.notified().await; + return StatusCode::SERVICE_UNAVAILABLE.into_response(); + } + response.into_response() + } + } + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + (format!("http://{address}"), submissions, reached, release) +} + +#[derive(Clone)] +enum RpcMethodRejection { + Before(usize), + After(usize), + Enabled(Arc), + AfterWhenEnabled(usize, Arc), +} + +impl RpcMethodRejection { + fn rejects(&self, call: usize) -> bool { + match self { + Self::Before(rejected_calls) => call < *rejected_calls, + Self::After(successful_calls) => call >= *successful_calls, + Self::Enabled(enabled) => enabled.load(Ordering::SeqCst), + Self::AfterWhenEnabled(successful_calls, enabled) => { + call >= *successful_calls && enabled.load(Ordering::SeqCst) + } + } + } +} + +async fn spawn_rpc_proxy_rejecting_method( + endpoint: String, + method: &'static str, + rejection: RpcMethodRejection, + rejection_status: StatusCode, + error_code: i64, + error_message: &'static str, +) -> String { + let client = reqwest::Client::new(); + let calls = std::sync::Arc::new(AtomicUsize::new(0)); + let router = Router::new().route( + "/", + post(move |Json(request): Json| { + let client = client.clone(); + let endpoint = endpoint.clone(); + let calls = calls.clone(); + let rejection = rejection.clone(); + async move { + if request.get("method").and_then(Value::as_str) == Some(method) + && rejection.rejects(calls.fetch_add(1, Ordering::Relaxed)) + { + let id = request.get("id").cloned().unwrap_or(Value::Null); + return ( + rejection_status, + Json(json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": error_code, + "message": error_message, + }, + })), + ) + .into_response(); + } + + let response = client + .post(endpoint) + .json(&request) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + Json(response).into_response() + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); + format!("http://{address}") +} + +fn canonicalize_monad_system_response(request: &Value, response: &mut Value, target_hash: &str) { + if let Some(requests) = request.as_array() { + let Some(responses) = response.as_array_mut() else { return }; + for response in responses { + let Some(response_id) = response.get("id") else { continue }; + if let Some(request) = + requests.iter().find(|request| request.get("id") == Some(response_id)) + { + canonicalize_monad_system_result(request, response, target_hash); + } + } + } else { + canonicalize_monad_system_result(request, response, target_hash); + } +} + +fn canonicalize_monad_system_result(request: &Value, response: &mut Value, target_hash: &str) { + let Some(method) = request.get("method").and_then(Value::as_str) else { return }; + let Some(result) = response.get_mut("result") else { return }; + + match method { + "eth_getTransactionByHash" + | "eth_getTransactionByBlockHashAndIndex" + | "eth_getTransactionByBlockNumberAndIndex" => { + canonicalize_monad_system_transaction(result, target_hash); + } + "eth_getBlockByHash" | "eth_getBlockByNumber" => { + if let Some(transactions) = result.get_mut("transactions").and_then(Value::as_array_mut) + { + for transaction in transactions { + canonicalize_monad_system_transaction(transaction, target_hash); + } + } + } + "eth_getTransactionReceipt" => { + canonicalize_monad_system_receipt(result, target_hash); + } + "eth_getBlockReceipts" => { + if let Some(receipts) = result.as_array_mut() { + for receipt in receipts { + canonicalize_monad_system_receipt(receipt, target_hash); + } + } + } + _ => {} + } +} + +fn canonicalize_monad_system_transaction(transaction: &mut Value, target_hash: &str) { + let Some(transaction) = transaction.as_object_mut() else { return }; + if !transaction + .get("hash") + .and_then(Value::as_str) + .is_some_and(|hash| hash.eq_ignore_ascii_case(target_hash)) + { + return; + } + + let tx_type = transaction.get("type").and_then(parse_rpc_quantity).unwrap_or_default(); + let legacy_v = (tx_type != 0) + .then(|| { + let parity = transaction + .get("yParity") + .or_else(|| transaction.get("v")) + .and_then(parse_rpc_quantity) + .filter(|parity| *parity <= 1)?; + let v = if let Some(chain_id) = transaction.get("chainId").and_then(parse_rpc_quantity) + { + chain_id.checked_mul(2)?.checked_add(35 + parity)? + } else { + 27 + parity + }; + Some(format!("0x{v:x}")) + }) + .flatten(); + + transaction.insert("from".to_string(), json!(MONAD_SYSTEM_ADDRESS)); + transaction.insert("gas".to_string(), json!("0x0")); + transaction.insert("gasPrice".to_string(), json!("0x0")); + transaction.insert("type".to_string(), json!("0x0")); + if let Some(v) = legacy_v { + transaction.insert("v".to_string(), json!(v)); + } + for field in [ + "accessList", + "authorizationList", + "blobVersionedHashes", + "maxFeePerBlobGas", + "maxFeePerGas", + "maxPriorityFeePerGas", + "yParity", + ] { + transaction.remove(field); + } +} + +fn parse_rpc_quantity(value: &Value) -> Option { + value.as_u64().or_else(|| { + value.as_str()?.strip_prefix("0x").and_then(|value| u64::from_str_radix(value, 16).ok()) + }) +} + +fn canonicalize_monad_system_receipt(receipt: &mut Value, target_hash: &str) { + let Some(receipt) = receipt.as_object_mut() else { return }; + if !receipt + .get("transactionHash") + .and_then(Value::as_str) + .is_some_and(|hash| hash.eq_ignore_ascii_case(target_hash)) + { + return; + } + + receipt.insert("cumulativeGasUsed".to_string(), json!("0x0")); + receipt.insert("effectiveGasPrice".to_string(), json!("0x0")); + receipt.insert("gasUsed".to_string(), json!("0x0")); + receipt.insert("type".to_string(), json!("0x0")); + receipt.remove("blobGasPrice"); + receipt.remove("blobGasUsed"); } #[cfg(test)] @@ -180,16 +920,80 @@ fn next_url(is_ws: bool, chain: NamedChain) -> String { mod tests { use super::*; use alloy_primitives::address; - use foundry_block_explorers::EtherscanApiVersion; use foundry_config::Chain; + #[test] + fn canonical_monad_system_response_supports_batches() { + let target_hash = B256::with_last_byte(1).to_string(); + let requests = json!([ + { + "jsonrpc": "2.0", + "id": 1, + "method": "eth_getTransactionByHash", + "params": [target_hash], + }, + { + "jsonrpc": "2.0", + "id": 2, + "method": "eth_getTransactionReceipt", + "params": [target_hash], + }, + ]); + let mut responses = json!([ + { + "jsonrpc": "2.0", + "id": 2, + "result": { + "transactionHash": target_hash, + "gasUsed": "0x5208", + }, + }, + { + "jsonrpc": "2.0", + "id": 1, + "result": { + "hash": target_hash, + "chainId": "0x7a69", + "gas": "0x5208", + "gasPrice": "0x1", + "r": "0x1", + "s": "0x1", + "type": "0x2", + "v": "0x1", + "yParity": "0x1", + }, + }, + ]); + + canonicalize_monad_system_response(&requests, &mut responses, &target_hash); + + assert_eq!(responses[0]["result"]["gasUsed"], "0x0"); + assert_eq!(responses[1]["result"]["gas"], "0x0"); + assert_eq!(responses[1]["result"]["from"], MONAD_SYSTEM_ADDRESS); + assert_eq!(responses[1]["result"]["type"], "0x0"); + assert_eq!(responses[1]["result"]["r"], "0x1"); + assert_eq!(responses[1]["result"]["s"], "0x1"); + assert_eq!(responses[1]["result"]["v"], "0xf4f6"); + assert!(responses[1]["result"].get("yParity").is_none()); + } + + #[test] + fn canonical_monad_system_response_ignores_malformed_requests() { + let request = json!({"jsonrpc": "2.0", "id": 1}); + let mut response = json!({"jsonrpc": "2.0", "id": 1, "result": "unchanged"}); + + canonicalize_monad_system_response(&request, &mut response, &B256::ZERO.to_string()); + + assert_eq!(response["result"], "unchanged"); + } + #[tokio::test] #[ignore = "run manually"] async fn test_etherscan_keys() { let address = address!("0xdAC17F958D2ee523a2206206994597C13D831ec7"); let mut first_abi = None; let mut failed = Vec::new(); - for (i, &key) in ETHERSCAN_KEYS.iter().enumerate() { + for (i, &key) in ETHERSCAN_KEYS.list.iter().enumerate() { println!("trying key {i} ({key})"); let client = foundry_block_explorers::Client::builder() @@ -220,36 +1024,6 @@ mod tests { first_abi = Some(abi); } } - if !failed.is_empty() { - panic!("failed keys: {failed:#?}"); - } - } - - #[tokio::test] - #[ignore = "run manually"] - async fn test_etherscan_keys_compatibility() { - let address = address!("0x111111125421cA6dc452d289314280a0f8842A65"); - let etherscan_key = "JQNGFHINKS1W7Y5FRXU4SPBYF43J3NYK46"; - let client = foundry_block_explorers::Client::builder() - .with_api_key(etherscan_key) - .chain(Chain::optimism_mainnet()) - .unwrap() - .build() - .unwrap(); - if client.contract_abi(address).await.is_ok() { - panic!("v1 Optimism key should not work with v2 version") - } - - let client = foundry_block_explorers::Client::builder() - .with_api_key(etherscan_key) - .with_api_version(EtherscanApiVersion::V1) - .chain(Chain::optimism_mainnet()) - .unwrap() - .build() - .unwrap(); - match client.contract_abi(address).await { - Ok(_) => {} - Err(_) => panic!("v1 Optimism key should work with v1 version"), - }; + assert!(failed.is_empty(), "failed keys: {failed:#?}") } } diff --git a/crates/test-utils/src/script.rs b/crates/test-utils/src/script.rs index 2dfd11986d13c..5f919e1a60e2a 100644 --- a/crates/test-utils/src/script.rs +++ b/crates/test-utils/src/script.rs @@ -21,17 +21,7 @@ fn init_script_cmd( cmd.forge_fuse(); cmd.set_current_dir(project_root); - cmd.args([ - "script", - "-R", - "ds-test/=lib/", - "-R", - "cheats/=cheats/", - target_contract, - "--root", - project_root.to_str().unwrap(), - "-vvvvv", - ]); + cmd.args(["script", target_contract, "--root", project_root.to_str().unwrap(), "-vvvvv"]); if let Some(rpc_url) = endpoint { cmd.args(["--fork-url", rpc_url]); @@ -125,11 +115,16 @@ impl ScriptTester { } /// Initialises the test contracts by copying them into the workspace - fn copy_testdata(current_dir: &Path) -> Result<()> { + fn copy_testdata(root: &Path) -> Result<()> { let testdata = Self::testdata_path(); - fs::create_dir_all(current_dir.join("cheats"))?; - fs::copy(testdata.join("cheats/Vm.sol"), current_dir.join("cheats/Vm.sol"))?; - fs::copy(testdata.join("lib/ds-test/src/test.sol"), current_dir.join("lib/test.sol"))?; + let from_dir = testdata.join("utils"); + let to_dir = root.join("utils"); + fs::create_dir_all(&to_dir)?; + for entry in fs::read_dir(&from_dir)? { + let file = &entry?.path(); + let name = file.file_name().unwrap(); + fs::copy(file, to_dir.join(name))?; + } Ok(()) } @@ -163,7 +158,7 @@ impl ScriptTester { /// Adds given address as sender pub fn sender(&mut self, addr: Address) -> &mut Self { - self.args(&["--sender", addr.to_string().as_str()]) + self.args(&["--sender", &addr.to_string()]) } pub fn add_sig(&mut self, contract_name: &str, sig: &str) -> &mut Self { @@ -171,7 +166,7 @@ impl ScriptTester { } pub fn add_create2_deployer(&mut self, create2_deployer: Address) -> &mut Self { - self.args(&["--create2-deployer", create2_deployer.to_string().as_str()]) + self.args(&["--create2-deployer", &create2_deployer.to_string()]) } /// Adds the `--unlocked` flag @@ -224,17 +219,18 @@ impl ScriptTester { } pub fn run(&mut self, expected: ScriptOutcome) -> &mut Self { - let out = self.cmd.execute(); + let out = + if expected.is_err() { self.cmd.assert_failure() } else { self.cmd.assert_success() }; + let out = out.get_output(); let (stdout, stderr) = (lossy_string(&out.stdout), lossy_string(&out.stderr)); trace!(target: "tests", "STDOUT\n{stdout}\n\nSTDERR\n{stderr}"); - if !stdout.contains(expected.as_str()) && !stderr.contains(expected.as_str()) { - panic!( - "--STDOUT--\n{stdout}\n\n--STDERR--\n{stderr}\n\n--EXPECTED--\n{:?} not found in stdout or stderr", - expected.as_str() - ); - } + assert!( + stdout.contains(expected.as_str()) || stderr.contains(expected.as_str()), + "--STDOUT--\n{stdout}\n\n--STDERR--\n{stderr}\n\n--EXPECTED--\n{:?} not found in stdout or stderr", + expected.as_str() + ); self } @@ -282,7 +278,7 @@ pub enum ScriptOutcome { } impl ScriptOutcome { - pub fn as_str(&self) -> &'static str { + pub const fn as_str(&self) -> &'static str { match self { Self::OkNoEndpoint => "If you wish to simulate on-chain transactions pass a RPC URL.", Self::OkSimulation => "SIMULATION COMPLETE. To broadcast these", @@ -306,7 +302,7 @@ impl ScriptOutcome { } } - pub fn is_err(&self) -> bool { + pub const fn is_err(&self) -> bool { match self { Self::OkNoEndpoint | Self::OkSimulation diff --git a/crates/test-utils/src/ui_runner.rs b/crates/test-utils/src/ui_runner.rs index e4414cf954791..c23d4b5ae08c9 100644 --- a/crates/test-utils/src/ui_runner.rs +++ b/crates/test-utils/src/ui_runner.rs @@ -1,5 +1,8 @@ use std::path::Path; -use ui_test::spanned::Spanned; +use ui_test::{ + spanned::Spanned, + status_emitter::{Gha, StatusEmitter}, +}; /// Test runner based on `ui_test`. Adapted from `https://github.com/paradigmxyz/solar/blob/main/tools/tester/src/lib.rs`. pub fn run_tests<'a>(cmd: &str, cmd_path: &'a Path, testdata: &'a Path) -> eyre::Result<()> { @@ -24,11 +27,8 @@ pub fn run_tests<'a>(cmd: &str, cmd_path: &'a Path, testdata: &'a Path) -> eyre: let config = config(cmd, cmd_path, &args, testdata); - let text_emitter = match args.format { - ui_test::Format::Terse => ui_test::status_emitter::Text::quiet(), - ui_test::Format::Pretty => ui_test::status_emitter::Text::verbose(), - }; - let gha_emitter = ui_test::status_emitter::Gha:: { name: "Foundry Lint UI".to_string() }; + let text_emitter: Box = args.format.into(); + let gha_emitter = Gha { name: "Foundry Lint UI".to_string(), group: true }; let status_emitter = (text_emitter, gha_emitter); // run tests on all .sol files @@ -68,11 +68,11 @@ fn config<'a>( }, out_dir_flag: None, input_file_flag: None, - envs: vec![], + envs: vec![("FOUNDRY_LINT_UI_TESTING".into(), Some("1".into()))], cfg_flag: None, }, output_conflict_handling: ui_test::error_on_output_conflict, - bless_command: Some(format!("cargo nextest run {} -- --bless", module_path!())), + bless_command: Some("cargo test -p forge --test ui -- --bless".into()), out_dir: root.join("target").join("ui"), comment_start: "//", diagnostic_extractor: ui_test::diagnostics::rustc::rustc_diagnostics_extractor, @@ -116,7 +116,7 @@ fn config<'a>( } let stdout_filters: &[(&str, &str)] = - &[(&env!("CARGO_PKG_VERSION").replace(".", r"\."), "VERSION")]; + &[(&env!("CARGO_PKG_VERSION").replace('.', r"\."), "VERSION")]; for &(pattern, replacement) in stdout_filters { config.stdout_filter(pattern, replacement); } diff --git a/crates/test-utils/src/util.rs b/crates/test-utils/src/util.rs index 3f23acb75e7d8..7c4e03471323b 100644 --- a/crates/test-utils/src/util.rs +++ b/crates/test-utils/src/util.rs @@ -1,38 +1,23 @@ -use crate::init_tracing; -use eyre::{Result, WrapErr}; -use foundry_compilers::{ - ArtifactOutput, ConfigurableArtifacts, PathStyle, ProjectPathsConfig, - artifacts::Contract, - cache::CompilerCache, - compilers::multi::MultiCompiler, - error::Result as SolcResult, - project_util::{TempProject, copy_dir}, - solc::SolcSettings, -}; +use foundry_compilers::{Project, ProjectCompileOutput, Vyper, utils::RuntimeOrHandle}; use foundry_config::Config; -use parking_lot::Mutex; -use regex::Regex; -use snapbox::{Data, IntoData, assert_data_eq, cmd::OutputAssert}; use std::{ env, - ffi::OsStr, fs::{self, File}, - io::{BufWriter, IsTerminal, Read, Seek, Write}, + io::{Read, Seek, Write}, path::{Path, PathBuf}, - process::{ChildStdin, Command, Output, Stdio}, - sync::{ - Arc, LazyLock, - atomic::{AtomicUsize, Ordering}, - }, + process::Command, + sync::LazyLock, }; -static CURRENT_DIR_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); +/// Directories to skip when copying project directories. +/// These are build artifacts and runtime-generated files that should not be copied to temp +/// workspaces. +const SKIP_DIRS: &[&str] = &["out", "cache", "broadcast"]; -/// The commit of forge-std to use. -pub const FORGE_STD_REVISION: &str = include_str!("../../../testdata/forge-std-rev"); +pub use crate::{ext::*, prj::*}; -/// Stores whether `stdout` is a tty / terminal. -pub static IS_TTY: LazyLock = LazyLock::new(|| std::io::stdout().is_terminal()); +/// The commit of forge-std to use. +pub const FORGE_STD_REVISION: &str = include_str!("../../../testdata/forge-std-rev").trim_ascii(); /// Global default template path. Contains the global template project from which all other /// temp projects are initialized. See [`initialize()`] for more info. @@ -44,199 +29,14 @@ static TEMPLATE_PATH: LazyLock = static TEMPLATE_LOCK: LazyLock = LazyLock::new(|| env::temp_dir().join("foundry-forge-test-template.lock")); -/// Global test identifier. -static NEXT_ID: AtomicUsize = AtomicUsize::new(0); - /// The default Solc version used when compiling tests. -pub const SOLC_VERSION: &str = "0.8.30"; +pub const SOLC_VERSION: &str = "0.8.35"; /// Another Solc version used when compiling tests. /// /// Necessary to avoid downloading multiple versions. pub const OTHER_SOLC_VERSION: &str = "0.8.26"; -/// External test builder -#[derive(Clone, Debug)] -#[must_use = "ExtTester does nothing unless you `run` it"] -pub struct ExtTester { - pub org: &'static str, - pub name: &'static str, - pub rev: &'static str, - pub style: PathStyle, - pub fork_block: Option, - pub args: Vec, - pub envs: Vec<(String, String)>, - pub install_commands: Vec>, -} - -impl ExtTester { - /// Creates a new external test builder. - pub fn new(org: &'static str, name: &'static str, rev: &'static str) -> Self { - Self { - org, - name, - rev, - style: PathStyle::Dapptools, - fork_block: None, - args: vec![], - envs: vec![], - install_commands: vec![], - } - } - - /// Sets the path style. - pub fn style(mut self, style: PathStyle) -> Self { - self.style = style; - self - } - - /// Sets the fork block. - pub fn fork_block(mut self, fork_block: u64) -> Self { - self.fork_block = Some(fork_block); - self - } - - /// Adds an argument to the forge command. - pub fn arg(mut self, arg: impl Into) -> Self { - self.args.push(arg.into()); - self - } - - /// Adds multiple arguments to the forge command. - pub fn args(mut self, args: I) -> Self - where - I: IntoIterator, - A: Into, - { - self.args.extend(args.into_iter().map(Into::into)); - self - } - - /// Adds an environment variable to the forge command. - pub fn env(mut self, key: impl Into, value: impl Into) -> Self { - self.envs.push((key.into(), value.into())); - self - } - - /// Adds multiple environment variables to the forge command. - pub fn envs(mut self, envs: I) -> Self - where - I: IntoIterator, - K: Into, - V: Into, - { - self.envs.extend(envs.into_iter().map(|(k, v)| (k.into(), v.into()))); - self - } - - /// Adds a command to run after the project is cloned. - /// - /// Note that the command is run in the project's root directory, and it won't fail the test if - /// it fails. - pub fn install_command(mut self, command: &[&str]) -> Self { - self.install_commands.push(command.iter().map(|s| s.to_string()).collect()); - self - } - - pub fn setup_forge_prj(&self) -> (TestProject, TestCommand) { - let (prj, mut test_cmd) = setup_forge(self.name, self.style.clone()); - - // Export vyper and forge in test command - workaround for snekmate venom tests. - if let Some(vyper) = &prj.inner.project().compiler.vyper { - let vyper_dir = vyper.path.parent().expect("vyper path should have a parent"); - let forge_bin = prj.exe_root.join(format!("../forge{}", env::consts::EXE_SUFFIX)); - let forge_dir = forge_bin.parent().expect("forge path should have a parent"); - - let existing_path = std::env::var_os("PATH").unwrap_or_default(); - let mut new_paths = vec![vyper_dir.to_path_buf(), forge_dir.to_path_buf()]; - new_paths.extend(std::env::split_paths(&existing_path)); - - let joined_path = std::env::join_paths(new_paths).expect("failed to join PATH"); - test_cmd.env("PATH", joined_path); - } - - // Wipe the default structure. - prj.wipe(); - - // Clone the external repository. - let repo_url = format!("https://github.com/{}/{}.git", self.org, self.name); - let root = prj.root().to_str().unwrap(); - clone_remote(&repo_url, root); - - // Checkout the revision. - if self.rev.is_empty() { - let mut git = Command::new("git"); - git.current_dir(root).args(["log", "-n", "1"]); - println!("$ {git:?}"); - let output = git.output().unwrap(); - if !output.status.success() { - panic!("git log failed: {output:?}"); - } - let stdout = String::from_utf8(output.stdout).unwrap(); - let commit = stdout.lines().next().unwrap().split_whitespace().nth(1).unwrap(); - panic!("pin to latest commit: {commit}"); - } else { - let mut git = Command::new("git"); - git.current_dir(root).args(["checkout", self.rev]); - println!("$ {git:?}"); - let status = git.status().unwrap(); - if !status.success() { - panic!("git checkout failed: {status}"); - } - } - - (prj, test_cmd) - } - - pub fn run_install_commands(&self, root: &str) { - for install_command in &self.install_commands { - let mut install_cmd = Command::new(&install_command[0]); - install_cmd.args(&install_command[1..]).current_dir(root); - println!("cd {root}; {install_cmd:?}"); - match install_cmd.status() { - Ok(s) => { - println!("\n\n{install_cmd:?}: {s}"); - if s.success() { - break; - } - } - Err(e) => { - eprintln!("\n\n{install_cmd:?}: {e}"); - } - } - } - } - - /// Runs the test. - pub fn run(&self) { - // Skip fork tests if the RPC url is not set. - if self.fork_block.is_some() && std::env::var_os("ETH_RPC_URL").is_none() { - eprintln!("ETH_RPC_URL is not set; skipping"); - return; - } - - let (prj, mut test_cmd) = self.setup_forge_prj(); - - // Run installation command. - self.run_install_commands(prj.root().to_str().unwrap()); - - // Run the tests. - test_cmd.arg("test"); - test_cmd.args(&self.args); - test_cmd.args(["--fuzz-runs=32", "--ffi", "-vvv"]); - - test_cmd.envs(self.envs.iter().map(|(k, v)| (k, v))); - if let Some(fork_block) = self.fork_block { - test_cmd.env("FOUNDRY_ETH_RPC_URL", crate::rpc::next_http_archive_rpc_url()); - test_cmd.env("FOUNDRY_FORK_BLOCK_NUMBER", fork_block.to_string()); - } - test_cmd.env("FOUNDRY_INVARIANT_DEPTH", "15"); - test_cmd.env("FOUNDRY_ALLOW_INTERNAL_EXPECT_REVERT", "true"); - - test_cmd.assert_success(); - } -} - /// Initializes a project with `forge init` at the given path from a template directory. /// /// This should be called after an empty project is created like in @@ -252,9 +52,8 @@ impl ExtTester { /// test can initialize the template at a time. /// /// This sets the project's solc version to the [`SOLC_VERSION`]. -#[expect(clippy::disallowed_macros)] pub fn initialize(target: &Path) { - println!("initializing {}", target.display()); + test_debug!("initializing {}", target.display()); let tpath = TEMPLATE_PATH.as_path(); pretty_err(tpath, fs::create_dir_all(tpath)); @@ -262,7 +61,7 @@ pub fn initialize(target: &Path) { // Initialize the global template if necessary. let mut lock = crate::fd_lock::new_lock(TEMPLATE_LOCK.as_path()); let mut _read = lock.read().unwrap(); - if fs::read(&*TEMPLATE_LOCK).unwrap() != b"1" { + if !crate::fd_lock::lock_exists(TEMPLATE_LOCK.as_path()) { // We are the first to acquire the lock: // - initialize a new empty temp project; // - run `forge init`; @@ -273,18 +72,16 @@ pub fn initialize(target: &Path) { // Release the read lock and acquire a write lock, initializing the lock file. drop(_read); - let mut write = lock.write().unwrap(); - let mut data = String::new(); - write.read_to_string(&mut data).unwrap(); - - if data != "1" { + let mut data = Vec::new(); + write.read_to_end(&mut data).unwrap(); + if data != crate::fd_lock::LOCK_TOKEN { // Initialize and build. let (prj, mut cmd) = setup_forge("template", foundry_compilers::PathStyle::Dapptools); - println!("- initializing template dir in {}", prj.root().display()); + test_debug!("- initializing template dir in {}", prj.root().display()); - cmd.args(["init", "--force"]).assert_success(); + cmd.args(["init", "--force", "--empty"]).assert_success(); prj.write_config(Config { solc: Some(foundry_config::SolcReq::Version(SOLC_VERSION.parse().unwrap())), ..Default::default() @@ -304,13 +101,13 @@ pub fn initialize(target: &Path) { // Remove the existing template, if any. let _ = fs::remove_dir_all(tpath); - // Copy the template to the global template path. - pretty_err(tpath, copy_dir(prj.root(), tpath)); + // Copy the template to the global template path, excluding build artifacts. + pretty_err(tpath, copy_dir_filtered(prj.root(), tpath)); // Update lockfile to mark that template is initialized. write.set_len(0).unwrap(); write.seek(std::io::SeekFrom::Start(0)).unwrap(); - write.write_all(b"1").unwrap(); + write.write_all(crate::fd_lock::LOCK_TOKEN).unwrap(); } // Release the write lock and acquire a new read lock. @@ -318,463 +115,100 @@ pub fn initialize(target: &Path) { _read = lock.read().unwrap(); } - println!("- copying template dir from {}", tpath.display()); + test_debug!("- copying template dir from {}", tpath.display()); pretty_err(target, fs::create_dir_all(target)); - pretty_err(target, copy_dir(tpath, target)); + pretty_err(target, copy_dir_filtered(tpath, target)); } -/// Clones a remote repository into the specified directory. Panics if the command fails. -pub fn clone_remote(repo_url: &str, target_dir: &str) { - let mut cmd = Command::new("git"); - cmd.args(["clone", "--recursive", "--shallow-submodules"]); - cmd.args([repo_url, target_dir]); - println!("{cmd:?}"); - let status = cmd.status().unwrap(); - if !status.success() { - panic!("git clone failed: {status}"); - } - println!(); -} +/// Compile the project with a lock for the cache. +pub fn get_compiled(project: &mut Project) -> ProjectCompileOutput { + let lock_file_path = project.sources_path().join(".lock"); + // We need to use a file lock because `cargo-nextest` runs tests in different processes. + // This is similar to `initialize`, see its comments for more details. + let mut lock = crate::fd_lock::new_lock(&lock_file_path); + let read = lock.read().unwrap(); + let out; -/// Setup an empty test project and return a command pointing to the forge -/// executable whose CWD is set to the project's root. -/// -/// The name given will be used to create the directory. Generally, it should -/// correspond to the test name. -#[track_caller] -pub fn setup_forge(name: &str, style: PathStyle) -> (TestProject, TestCommand) { - setup_forge_project(TestProject::new(name, style)) -} - -pub fn setup_forge_project(test: TestProject) -> (TestProject, TestCommand) { - let cmd = test.forge_command(); - (test, cmd) -} - -/// How to initialize a remote git project -#[derive(Clone, Debug)] -pub struct RemoteProject { - id: String, - run_build: bool, - run_commands: Vec>, - path_style: PathStyle, -} - -impl RemoteProject { - pub fn new(id: impl Into) -> Self { - Self { - id: id.into(), - run_build: true, - run_commands: vec![], - path_style: PathStyle::Dapptools, - } + let mut write = None; + if !project.cache_path().exists() || !crate::fd_lock::lock_exists(&lock_file_path) { + drop(read); + write = Some(lock.write().unwrap()); + test_debug!("cache miss for {}", lock_file_path.display()); + } else { + test_debug!("cache hit for {}", lock_file_path.display()); } - /// Whether to run `forge build` - pub fn set_build(mut self, run_build: bool) -> Self { - self.run_build = run_build; - self + if project.compiler.vyper.is_none() { + project.compiler.vyper = Some(get_vyper()); } - /// Configures the project's pathstyle - pub fn path_style(mut self, path_style: PathStyle) -> Self { - self.path_style = path_style; - self - } - - /// Add another command to run after cloning - pub fn cmd(mut self, cmd: impl IntoIterator>) -> Self { - self.run_commands.push(cmd.into_iter().map(Into::into).collect()); - self - } -} - -impl> From for RemoteProject { - fn from(id: T) -> Self { - Self::new(id) - } -} - -/// Setups a new local forge project by cloning and initializing the `RemoteProject` -/// -/// This will -/// 1. clone the prj, like "transmissions1/solmate" -/// 2. run `forge build`, if configured -/// 3. run additional commands -/// -/// # Panics -/// -/// If anything goes wrong during, checkout, build, or other commands are unsuccessful -pub fn setup_forge_remote(prj: impl Into) -> (TestProject, TestCommand) { - try_setup_forge_remote(prj).unwrap() -} + test_debug!("compiling {}", lock_file_path.display()); + out = project.compile().unwrap(); + test_debug!("compiled {}", lock_file_path.display()); -/// Same as `setup_forge_remote` but not panicking -pub fn try_setup_forge_remote( - config: impl Into, -) -> Result<(TestProject, TestCommand)> { - let config = config.into(); - let mut tmp = TempProject::checkout(&config.id).wrap_err("failed to checkout project")?; - tmp.project_mut().paths = config.path_style.paths(tmp.root())?; + assert!(!out.has_compiler_errors(), "Compiled with errors:\n{out}"); - let prj = TestProject::with_project(tmp); - if config.run_build { - let mut cmd = prj.forge_command(); - cmd.arg("build").assert_success(); - } - for addon in config.run_commands { - debug_assert!(!addon.is_empty()); - let mut cmd = Command::new(&addon[0]); - if addon.len() > 1 { - cmd.args(&addon[1..]); - } - let status = cmd - .current_dir(prj.root()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .wrap_err_with(|| format!("Failed to execute {addon:?}"))?; - eyre::ensure!(status.success(), "Failed to execute command {:?}", addon); + if let Some(write) = &mut write { + write.write_all(crate::fd_lock::LOCK_TOKEN).unwrap(); } - let cmd = prj.forge_command(); - Ok((prj, cmd)) -} - -pub fn setup_cast(name: &str, style: PathStyle) -> (TestProject, TestCommand) { - setup_cast_project(TestProject::new(name, style)) + out } -pub fn setup_cast_project(test: TestProject) -> (TestProject, TestCommand) { - let cmd = test.cast_command(); - (test, cmd) -} +/// Installs Vyper if it's not already present. +pub fn get_vyper() -> Vyper { + static VYPER: LazyLock = LazyLock::new(|| std::env::temp_dir().join("vyper")); -/// `TestProject` represents a temporary project to run tests against. -/// -/// Test projects are created from a global atomic counter to avoid duplicates. -#[derive(Clone, Debug)] -pub struct TestProject< - T: ArtifactOutput + Default = ConfigurableArtifacts, -> { - /// The directory in which this test executable is running. - exe_root: PathBuf, - /// The project in which the test should run. - inner: Arc>, -} - -impl TestProject { - /// Create a new test project with the given name. The name - /// does not need to be distinct for each invocation, but should correspond - /// to a logical grouping of tests. - pub fn new(name: &str, style: PathStyle) -> Self { - let id = NEXT_ID.fetch_add(1, Ordering::SeqCst); - let project = pretty_err(name, TempProject::with_style(&format!("{name}-{id}"), style)); - Self::with_project(project) - } - - pub fn with_project(project: TempProject) -> Self { - init_tracing(); - let this = env::current_exe().unwrap(); - let exe_root = this.parent().expect("executable's directory").to_path_buf(); - Self { exe_root, inner: Arc::new(project) } + if let Ok(vyper) = Vyper::new("vyper") { + return vyper; } - - /// Returns the root path of the project's workspace. - pub fn root(&self) -> &Path { - self.inner.root() + if let Ok(vyper) = Vyper::new(&*VYPER) { + return vyper; } + return RuntimeOrHandle::new().block_on(install()); - /// Returns the paths config. - pub fn paths(&self) -> &ProjectPathsConfig { - self.inner.paths() - } - - /// Returns the path to the project's `foundry.toml` file. - pub fn config(&self) -> PathBuf { - self.root().join(Config::FILE_NAME) - } + async fn install() -> Vyper { + #[cfg(target_family = "unix")] + use std::{fs::Permissions, os::unix::fs::PermissionsExt}; - /// Returns the path to the project's cache file. - pub fn cache(&self) -> &PathBuf { - &self.paths().cache - } - - /// Returns the path to the project's artifacts directory. - pub fn artifacts(&self) -> &PathBuf { - &self.paths().artifacts - } - - /// Removes the project's cache and artifacts directory. - pub fn clear(&self) { - self.clear_cache(); - self.clear_artifacts(); - } - - /// Removes this project's cache file. - pub fn clear_cache(&self) { - let _ = fs::remove_file(self.cache()); - } - - /// Removes this project's artifacts directory. - pub fn clear_artifacts(&self) { - let _ = fs::remove_dir_all(self.artifacts()); - } - - /// Updates the project's config with the given function. - pub fn update_config(&self, f: impl FnOnce(&mut Config)) { - self._update_config(Box::new(f)); - } - - fn _update_config(&self, f: Box) { - let mut config = self - .config() - .exists() - .then_some(()) - .and_then(|()| Config::load_with_root(self.root()).ok()) - .unwrap_or_default(); - config.remappings.clear(); - f(&mut config); - self.write_config(config); - } - - /// Writes the given config as toml to `foundry.toml`. - #[doc(hidden)] // Prefer `update_config`. - pub fn write_config(&self, config: Config) { - let file = self.config(); - pretty_err(&file, fs::write(&file, config.to_string_pretty().unwrap())); - } - - /// Adds a source file to the project. - pub fn add_source(&self, name: &str, contents: &str) -> SolcResult { - self.inner.add_source(name, Self::add_source_prelude(contents)) - } - - /// Adds a source file to the project. Prefer using `add_source` instead. - pub fn add_raw_source(&self, name: &str, contents: &str) -> SolcResult { - self.inner.add_source(name, contents) - } - - /// Adds a script file to the project. - pub fn add_script(&self, name: &str, contents: &str) -> SolcResult { - self.inner.add_script(name, Self::add_source_prelude(contents)) - } - - /// Adds a test file to the project. - pub fn add_test(&self, name: &str, contents: &str) -> SolcResult { - self.inner.add_test(name, Self::add_source_prelude(contents)) - } - - /// Adds a library file to the project. - pub fn add_lib(&self, name: &str, contents: &str) -> SolcResult { - self.inner.add_lib(name, Self::add_source_prelude(contents)) - } - - fn add_source_prelude(s: &str) -> String { - let mut s = s.to_string(); - if !s.contains("pragma solidity") { - s = format!("pragma solidity ={SOLC_VERSION};\n{s}"); - } - if !s.contains("// SPDX") { - s = format!("// SPDX-License-Identifier: MIT OR Apache-2.0\n{s}"); - } - s - } - - /// Asserts that the `/foundry.toml` file exists. - #[track_caller] - pub fn assert_config_exists(&self) { - assert!(self.config().exists()); - } - - /// Asserts that the `/cache/sol-files-cache.json` file exists. - #[track_caller] - pub fn assert_cache_exists(&self) { - assert!(self.cache().exists()); - } - - /// Asserts that the `/out` file exists. - #[track_caller] - pub fn assert_artifacts_dir_exists(&self) { - assert!(self.paths().artifacts.exists()); - } - - /// Creates all project dirs and ensure they were created - #[track_caller] - pub fn assert_create_dirs_exists(&self) { - self.paths().create_all().unwrap_or_else(|_| panic!("Failed to create project paths")); - CompilerCache::::default() - .write(&self.paths().cache) - .expect("Failed to create cache"); - self.assert_all_paths_exist(); - } - - /// Ensures that the given layout exists - #[track_caller] - pub fn assert_style_paths_exist(&self, style: PathStyle) { - let paths = style.paths(&self.paths().root).unwrap(); - config_paths_exist(&paths, self.inner.project().cached); - } - - /// Copies the project's root directory to the given target - #[track_caller] - pub fn copy_to(&self, target: impl AsRef) { - let target = target.as_ref(); - pretty_err(target, fs::create_dir_all(target)); - pretty_err(target, copy_dir(self.root(), target)); - } - - /// Creates a file with contents `contents` in the test project's directory. The - /// file will be deleted when the project is dropped. - pub fn create_file(&self, path: impl AsRef, contents: &str) -> PathBuf { - let path = path.as_ref(); - if !path.is_relative() { - panic!("create_file(): file path is absolute"); - } - let path = self.root().join(path); - if let Some(parent) = path.parent() { - pretty_err(parent, std::fs::create_dir_all(parent)); - } - let file = pretty_err(&path, File::create(&path)); - let mut writer = BufWriter::new(file); - pretty_err(&path, writer.write_all(contents.as_bytes())); - path - } - - /// Adds DSTest as a source under "test.sol" - pub fn insert_ds_test(&self) -> PathBuf { - let s = include_str!("../../../testdata/lib/ds-test/src/test.sol"); - self.add_source("test.sol", s).unwrap() - } - - /// Adds `console.sol` as a source under "console.sol" - pub fn insert_console(&self) -> PathBuf { - let s = include_str!("../../../testdata/default/logs/console.sol"); - self.add_source("console.sol", s).unwrap() - } - - /// Adds `Vm.sol` as a source under "Vm.sol" - pub fn insert_vm(&self) -> PathBuf { - let s = include_str!("../../../testdata/cheats/Vm.sol"); - self.add_source("Vm.sol", s).unwrap() - } - - /// Asserts all project paths exist. These are: - /// - sources - /// - artifacts - /// - libs - /// - cache - pub fn assert_all_paths_exist(&self) { - let paths = self.paths(); - config_paths_exist(paths, self.inner.project().cached); - } - - /// Asserts that the artifacts dir and cache don't exist - pub fn assert_cleaned(&self) { - let paths = self.paths(); - assert!(!paths.cache.exists()); - assert!(!paths.artifacts.exists()); - } - - /// Creates a new command that is set to use the forge executable for this project - #[track_caller] - pub fn forge_command(&self) -> TestCommand { - let cmd = self.forge_bin(); - let _lock = CURRENT_DIR_LOCK.lock(); - TestCommand { - project: self.clone(), - cmd, - current_dir_lock: None, - saved_cwd: pretty_err("", std::env::current_dir()), - stdin_fun: None, - redact_output: true, - } - } - - /// Creates a new command that is set to use the cast executable for this project - pub fn cast_command(&self) -> TestCommand { - let mut cmd = self.cast_bin(); - cmd.current_dir(self.inner.root()); - let _lock = CURRENT_DIR_LOCK.lock(); - TestCommand { - project: self.clone(), - cmd, - current_dir_lock: None, - saved_cwd: pretty_err("", std::env::current_dir()), - stdin_fun: None, - redact_output: true, + let path = VYPER.as_path(); + let mut file = File::create(path).unwrap(); + if let Err(e) = file.try_lock() { + if matches!(e, fs::TryLockError::WouldBlock) { + file.lock().unwrap(); + assert!(path.exists()); + return Vyper::new(path).unwrap(); + } + file.lock().unwrap(); } - } - /// Returns the path to the forge executable. - pub fn forge_bin(&self) -> Command { - let forge = self.exe_root.join(format!("../forge{}", env::consts::EXE_SUFFIX)); - let forge = forge.canonicalize().unwrap_or_else(|_| forge.clone()); - let mut cmd = Command::new(forge); - cmd.current_dir(self.inner.root()); - // Disable color output for comparisons; can be overridden with `--color always`. - cmd.env("NO_COLOR", "1"); - cmd - } + let suffix = match svm::platform() { + svm::Platform::MacOsAarch64 => "darwin", + svm::Platform::LinuxAmd64 => "linux", + svm::Platform::WindowsAmd64 => "windows.exe", + platform => panic!( + "unsupported platform {platform:?} for installing vyper, \ + install it manually and add it to $PATH" + ), + }; + let url = format!( + "https://github.com/vyperlang/vyper/releases/download/v0.4.3/vyper.0.4.3+commit.bff19ea2.{suffix}" + ); - /// Returns the path to the cast executable. - pub fn cast_bin(&self) -> Command { - let cast = self.exe_root.join(format!("../cast{}", env::consts::EXE_SUFFIX)); - let cast = cast.canonicalize().unwrap_or_else(|_| cast.clone()); - let mut cmd = Command::new(cast); - // disable color output for comparisons - cmd.env("NO_COLOR", "1"); - cmd - } + test_debug!("downloading vyper from {url}"); + let res = reqwest::Client::builder().build().unwrap().get(url).send().await.unwrap(); - /// Returns the `Config` as spit out by `forge config` - pub fn config_from_output(&self, args: I) -> Config - where - I: IntoIterator, - A: AsRef, - { - let mut cmd = self.forge_bin(); - cmd.arg("config").arg("--root").arg(self.root()).args(args).arg("--json"); - let output = cmd.output().unwrap(); - let c = lossy_string(&output.stdout); - let config: Config = serde_json::from_str(c.as_ref()).unwrap(); - config.sanitized() - } + assert!(res.status().is_success()); - /// Removes all files and dirs inside the project's root dir - pub fn wipe(&self) { - pretty_err(self.root(), fs::remove_dir_all(self.root())); - pretty_err(self.root(), fs::create_dir_all(self.root())); - } + let bytes = res.bytes().await.unwrap(); - /// Removes all contract files from `src`, `test`, `script` - pub fn wipe_contracts(&self) { - fn rm_create(path: &Path) { - pretty_err(path, fs::remove_dir_all(path)); - pretty_err(path, fs::create_dir(path)); - } - rm_create(&self.paths().sources); - rm_create(&self.paths().tests); - rm_create(&self.paths().scripts); - } -} + file.write_all(&bytes).unwrap(); -impl Drop for TestCommand { - fn drop(&mut self) { - let _lock = self.current_dir_lock.take().unwrap_or_else(|| CURRENT_DIR_LOCK.lock()); - if self.saved_cwd.exists() { - let _ = std::env::set_current_dir(&self.saved_cwd); - } - } -} + #[cfg(target_family = "unix")] + file.set_permissions(Permissions::from_mode(0o755)).unwrap(); -fn config_paths_exist(paths: &ProjectPathsConfig, cached: bool) { - if cached { - assert!(paths.cache.exists()); + Vyper::new(path).unwrap() } - assert!(paths.sources.exists()); - assert!(paths.artifacts.exists()); - paths.libraries.iter().for_each(|lib| assert!(lib.exists())); } #[track_caller] @@ -790,296 +224,36 @@ pub fn read_string(path: impl AsRef) -> String { pretty_err(path, std::fs::read_to_string(path)) } -/// A simple wrapper around a Command with some conveniences. -pub struct TestCommand { - saved_cwd: PathBuf, - /// The project used to launch this command. - project: TestProject, - /// The actual command we use to control the process. - cmd: Command, - // initial: Command, - current_dir_lock: Option>, - stdin_fun: Option>, - /// If true, command output is redacted. - redact_output: bool, -} - -impl TestCommand { - /// Returns a mutable reference to the underlying command. - pub fn cmd(&mut self) -> &mut Command { - &mut self.cmd - } - - /// Replaces the underlying command. - pub fn set_cmd(&mut self, cmd: Command) -> &mut Self { - self.cmd = cmd; - self - } - - /// Resets the command to the default `forge` command. - pub fn forge_fuse(&mut self) -> &mut Self { - self.set_cmd(self.project.forge_bin()) - } - - /// Resets the command to the default `cast` command. - pub fn cast_fuse(&mut self) -> &mut Self { - self.set_cmd(self.project.cast_bin()) - } - - /// Sets the current working directory. - pub fn set_current_dir(&mut self, p: impl AsRef) { - drop(self.current_dir_lock.take()); - let lock = CURRENT_DIR_LOCK.lock(); - self.current_dir_lock = Some(lock); - let p = p.as_ref(); - pretty_err(p, std::env::set_current_dir(p)); - } - - /// Add an argument to pass to the command. - pub fn arg>(&mut self, arg: A) -> &mut Self { - self.cmd.arg(arg); - self - } - - /// Add any number of arguments to the command. - pub fn args(&mut self, args: I) -> &mut Self - where - I: IntoIterator, - A: AsRef, - { - self.cmd.args(args); - self - } - - pub fn stdin(&mut self, fun: impl FnOnce(ChildStdin) + 'static) -> &mut Self { - self.stdin_fun = Some(Box::new(fun)); - self - } - - /// Convenience function to add `--root project.root()` argument - pub fn root_arg(&mut self) -> &mut Self { - let root = self.project.root().to_path_buf(); - self.arg("--root").arg(root) - } - - /// Set the environment variable `k` to value `v` for the command. - pub fn env(&mut self, k: impl AsRef, v: impl AsRef) { - self.cmd.env(k, v); - } - - /// Set the environment variable `k` to value `v` for the command. - pub fn envs(&mut self, envs: I) - where - I: IntoIterator, - K: AsRef, - V: AsRef, - { - self.cmd.envs(envs); - } - - /// Unsets the environment variable `k` for the command. - pub fn unset_env(&mut self, k: impl AsRef) { - self.cmd.env_remove(k); - } - - /// Set the working directory for this command. - /// - /// Note that this does not need to be called normally, since the creation - /// of this TestCommand causes its working directory to be set to the - /// test's directory automatically. - pub fn current_dir>(&mut self, dir: P) -> &mut Self { - self.cmd.current_dir(dir); - self - } - - /// Returns the `Config` as spit out by `forge config` - #[track_caller] - pub fn config(&mut self) -> Config { - self.cmd.args(["config", "--json"]); - let output = self.assert().success().get_output().stdout_lossy(); - self.forge_fuse(); - serde_json::from_str(output.as_ref()).unwrap() - } - - /// Runs `git init` inside the project's dir - #[track_caller] - pub fn git_init(&self) { - let mut cmd = Command::new("git"); - cmd.arg("init").current_dir(self.project.root()); - let output = OutputAssert::new(cmd.output().unwrap()); - output.success(); - } - - /// Runs `git submodule status` inside the project's dir - #[track_caller] - pub fn git_submodule_status(&self) -> Output { - let mut cmd = Command::new("git"); - cmd.arg("submodule").arg("status").current_dir(self.project.root()); - cmd.output().unwrap() - } - - /// Runs `git add .` inside the project's dir - #[track_caller] - pub fn git_add(&self) { - let mut cmd = Command::new("git"); - cmd.current_dir(self.project.root()); - cmd.arg("add").arg("."); - let output = OutputAssert::new(cmd.output().unwrap()); - output.success(); - } - - /// Runs `git commit .` inside the project's dir - #[track_caller] - pub fn git_commit(&self, msg: &str) { - let mut cmd = Command::new("git"); - cmd.current_dir(self.project.root()); - cmd.arg("commit").arg("-m").arg(msg); - let output = OutputAssert::new(cmd.output().unwrap()); - output.success(); - } - - /// Runs the command, returning a [`snapbox`] object to assert the command output. - #[track_caller] - pub fn assert(&mut self) -> OutputAssert { - let assert = OutputAssert::new(self.execute()); - if self.redact_output { - return assert.with_assert(test_assert()); - } - assert - } - - /// Runs the command and asserts that it resulted in success. - #[track_caller] - pub fn assert_success(&mut self) -> OutputAssert { - self.assert().success() - } - - /// Runs the command and asserts that it resulted in success, with expected JSON data. - #[track_caller] - pub fn assert_json_stdout(&mut self, expected: impl IntoData) { - let expected = expected.is(snapbox::data::DataFormat::Json).unordered(); - let stdout = self.assert_success().get_output().stdout.clone(); - let actual = stdout.into_data().is(snapbox::data::DataFormat::Json).unordered(); - assert_data_eq!(actual, expected); - } - - /// Runs the command and asserts that it **succeeded** nothing was printed to stdout. - #[track_caller] - pub fn assert_empty_stdout(&mut self) { - self.assert_success().stdout_eq(Data::new()); - } - - /// Runs the command and asserts that it failed. - #[track_caller] - pub fn assert_failure(&mut self) -> OutputAssert { - self.assert().failure() - } - - /// Runs the command and asserts that the exit code is `expected`. - #[track_caller] - pub fn assert_code(&mut self, expected: i32) -> OutputAssert { - self.assert().code(expected) - } - - /// Runs the command and asserts that it **failed** nothing was printed to stderr. - #[track_caller] - pub fn assert_empty_stderr(&mut self) { - self.assert_failure().stderr_eq(Data::new()); - } - - /// Runs the command with a temporary file argument and asserts that the contents of the file - /// match the given data. - #[track_caller] - pub fn assert_file(&mut self, data: impl IntoData) { - self.assert_file_with(|this, path| _ = this.arg(path).assert_success(), data); - } - - /// Creates a temporary file, passes it to `f`, then asserts that the contents of the file match - /// the given data. - #[track_caller] - pub fn assert_file_with(&mut self, f: impl FnOnce(&mut Self, &Path), data: impl IntoData) { - let file = tempfile::NamedTempFile::new().expect("couldn't create temporary file"); - f(self, file.path()); - assert_data_eq!(Data::read_from(file.path(), None), data); - } - - /// Does not apply [`snapbox`] redactions to the command output. - pub fn with_no_redact(&mut self) -> &mut Self { - self.redact_output = false; - self - } - - /// Executes command, applies stdin function and returns output - #[track_caller] - pub fn execute(&mut self) -> Output { - self.try_execute().unwrap() - } - - #[track_caller] - pub fn try_execute(&mut self) -> std::io::Result { - println!("executing {:?}", self.cmd); - let mut child = - self.cmd.stdout(Stdio::piped()).stderr(Stdio::piped()).stdin(Stdio::piped()).spawn()?; - if let Some(fun) = self.stdin_fun.take() { - fun(child.stdin.take().unwrap()); - } - child.wait_with_output() - } -} - -fn test_assert() -> snapbox::Assert { - snapbox::Assert::new() - .action_env(snapbox::assert::DEFAULT_ACTION_ENV) - .redact_with(test_redactions()) +/// Copies the directory at `src` to `dst`, skipping build artifact directories. +/// +/// This is similar to `foundry_compilers::project_util::copy_dir`, but skips directories +/// like `out/`, `cache/`, and `broadcast/` which are build artifacts that should not be +/// copied to temporary test workspaces. +pub fn copy_dir_filtered(src: &Path, dst: &Path) -> std::io::Result<()> { + fs::create_dir_all(dst)?; + copy_dir_filtered_inner(src, dst, true) } -fn test_redactions() -> snapbox::Redactions { - static REDACTIONS: LazyLock = LazyLock::new(|| { - let mut r = snapbox::Redactions::new(); - let redactions = [ - ("[SOLC_VERSION]", r"Solc( version)? \d+.\d+.\d+"), - ("[ELAPSED]", r"(finished )?in \d+(\.\d+)?\w?s( \(.*?s CPU time\))?"), - ("[GAS]", r"[Gg]as( used)?: \d+"), - ("[GAS_COST]", r"[Gg]as cost\s*\(\d+\)"), - ("[GAS_LIMIT]", r"[Gg]as limit\s*\(\d+\)"), - ("[AVG_GAS]", r"μ: \d+, ~: \d+"), - ("[FILE]", r"-->.*\.sol"), - ("[FILE]", r"Location(.|\n)*\.rs(.|\n)*Backtrace"), - ("[COMPILING_FILES]", r"Compiling \d+ files?"), - ("[TX_HASH]", r"Transaction hash: 0x[0-9A-Fa-f]{64}"), - ("[ADDRESS]", r"Address: +0x[0-9A-Fa-f]{40}"), - ("[PUBLIC_KEY]", r"Public key: +0x[0-9A-Fa-f]{128}"), - ("[PRIVATE_KEY]", r"Private key: +0x[0-9A-Fa-f]{64}"), - ("[UPDATING_DEPENDENCIES]", r"Updating dependencies in .*"), - ("[SAVED_TRANSACTIONS]", r"Transactions saved to: .*\.json"), - ("[SAVED_SENSITIVE_VALUES]", r"Sensitive values saved to: .*\.json"), - ("[ESTIMATED_GAS_PRICE]", r"Estimated gas price:\s*(\d+(\.\d+)?)\s*gwei"), - ("[ESTIMATED_TOTAL_GAS_USED]", r"Estimated total gas used for script: \d+"), - ( - "[ESTIMATED_AMOUNT_REQUIRED]", - r"Estimated amount required:\s*(\d+(\.\d+)?)\s*[A-Z]{3}", - ), - ]; - for (placeholder, re) in redactions { - r.insert(placeholder, Regex::new(re).expect(re)).expect(re); +fn copy_dir_filtered_inner(src: &Path, dst: &Path, is_root: bool) -> std::io::Result<()> { + for entry in fs::read_dir(src)? { + let entry = entry?; + let ty = entry.file_type()?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + + if ty.is_dir() { + // Skip build artifact directories at the root level + if is_root + && let Some(name) = entry.file_name().to_str() + && SKIP_DIRS.contains(&name) + { + continue; + } + fs::create_dir_all(&dst_path)?; + copy_dir_filtered_inner(&src_path, &dst_path, false)?; + } else { + fs::copy(&src_path, &dst_path)?; } - r - }); - REDACTIONS.clone() -} - -/// Extension trait for [`Output`]. -pub trait OutputExt { - /// Returns the stdout as lossy string - fn stdout_lossy(&self) -> String; -} - -impl OutputExt for Output { - fn stdout_lossy(&self) -> String { - lossy_string(&self.stdout) } -} - -pub fn lossy_string(bytes: &[u8]) -> String { - String::from_utf8_lossy(bytes).replace("\r\n", "\n") + Ok(()) } diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml new file mode 100644 index 0000000000000..1c5932ea6e7c7 --- /dev/null +++ b/crates/tui/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "foundry-tui" + +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +crossterm.workspace = true +ratatui = { workspace = true, features = ["crossterm"] } diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs new file mode 100644 index 0000000000000..aca46c09d9e6f --- /dev/null +++ b/crates/tui/src/lib.rs @@ -0,0 +1,259 @@ +//! Shared terminal UI utilities for Foundry. + +use crossterm::{ + event::{DisableMouseCapture, EnableMouseCapture, Event, read}, + execute, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, +}; +use ratatui::{ + Frame, Terminal, + backend::{Backend, CrosstermBackend}, +}; +use std::{ + env, + io::{IsTerminal, Result as IoResult, Stdout, Write, stdin, stdout}, + ops::ControlFlow, + panic::{PanicHookInfo, set_hook, take_hook}, + sync::Arc, + thread::panicking, +}; + +/// The default terminal backend used by Foundry TUIs. +pub type CrosstermTerminal = Terminal>; + +type PanicHandler = Box) + 'static + Sync + Send>; + +/// The resolved mode for a requested TUI run. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TuiMode { + /// The process can open an interactive TUI. + Interactive, + /// The process should use a line-oriented fallback. + Fallback(TuiFallbackReason), +} + +impl TuiMode { + /// Returns whether the mode can run an interactive TUI. + pub const fn is_interactive(self) -> bool { + matches!(self, Self::Interactive) + } +} + +/// Why an interactive TUI should not be opened. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TuiFallbackReason { + /// Foundry is running in a CI environment. + Ci, + /// Standard input is not connected to a terminal. + StdinNotTerminal, + /// Standard output is not connected to a terminal. + StdoutNotTerminal, +} + +impl TuiFallbackReason { + /// Returns a short stable description of the fallback reason. + pub const fn as_str(self) -> &'static str { + match self { + Self::Ci => "running in CI", + Self::StdinNotTerminal => "stdin is not a terminal", + Self::StdoutNotTerminal => "stdout is not a terminal", + } + } +} + +/// Runtime environment details used to decide whether a TUI can run interactively. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TuiEnvironment { + /// Whether standard input is connected to a terminal. + pub stdin_is_terminal: bool, + /// Whether standard output is connected to a terminal. + pub stdout_is_terminal: bool, + /// Whether Foundry appears to be running in CI. + pub is_ci: bool, +} + +impl TuiEnvironment { + /// Creates a new environment descriptor. + pub const fn new(stdin_is_terminal: bool, stdout_is_terminal: bool, is_ci: bool) -> Self { + Self { stdin_is_terminal, stdout_is_terminal, is_ci } + } + + /// Detects the current process environment. + pub fn detect() -> Self { + Self::new(stdin().is_terminal(), stdout().is_terminal(), env::var_os("CI").is_some()) + } + + /// Resolves the TUI mode for this environment. + pub const fn mode(self) -> TuiMode { + if self.is_ci { + TuiMode::Fallback(TuiFallbackReason::Ci) + } else if !self.stdin_is_terminal { + TuiMode::Fallback(TuiFallbackReason::StdinNotTerminal) + } else if !self.stdout_is_terminal { + TuiMode::Fallback(TuiFallbackReason::StdoutNotTerminal) + } else { + TuiMode::Interactive + } + } +} + +/// Detects whether a requested TUI should run interactively or fall back to line output. +pub fn tui_mode() -> TuiMode { + TuiEnvironment::detect().mode() +} + +/// An interactive terminal application. +pub trait TuiApp { + /// The reason the application exited. + type Exit; + + /// Draws one frame. + fn draw(&mut self, frame: &mut Frame<'_>); + + /// Handles one terminal event. + fn handle_event(&mut self, event: Event) -> ControlFlow; +} + +/// Runs an interactive terminal application with the default Foundry terminal setup. +pub fn run_app(app: &mut App) -> IoResult { + with_terminal(|terminal| run_app_inner(terminal, app))? +} + +/// Runs an app only when the current environment supports an interactive TUI. +pub fn run_app_if_interactive(app: &mut App) -> IoResult> { + match tui_mode() { + TuiMode::Interactive => run_app(app).map(Some), + TuiMode::Fallback(_) => Ok(None), + } +} + +fn run_app_inner( + terminal: &mut CrosstermTerminal, + app: &mut App, +) -> IoResult { + loop { + terminal.draw(|frame| app.draw(frame))?; + match app.handle_event(read()?) { + ControlFlow::Continue(()) => {} + ControlFlow::Break(reason) => return Ok(reason), + } + } +} + +/// Handles terminal setup and teardown for interactive TUIs. +#[must_use] +pub struct TerminalGuard { + terminal: Terminal, + hook: Option>, +} + +impl TerminalGuard { + /// Runs a closure while the terminal is in alternate-screen raw mode. + pub fn with(terminal: Terminal, mut f: impl FnMut(&mut Terminal) -> T) -> T { + let mut guard = Self { terminal, hook: None }; + guard.setup(); + f(&mut guard.terminal) + } + + fn setup(&mut self) { + let previous = Arc::new(take_hook()); + self.hook = Some(previous.clone()); + // Restore terminal state before displaying the panic message. + set_hook(Box::new(move |info| { + Self::half_restore(&mut stdout()); + (previous)(info) + })); + + let _ = enable_raw_mode(); + let _ = execute!(*self.terminal.backend_mut(), EnterAlternateScreen, EnableMouseCapture); + let _ = self.terminal.hide_cursor(); + let _ = self.terminal.clear(); + } + + fn restore(&mut self) { + // Always restore terminal state on drop, even during panic unwinding. + // The panic hook installed in `setup()` may be replaced by external code, + // so `Drop` must not rely on it as the sole cleanup path. + Self::half_restore(self.terminal.backend_mut()); + + if !panicking() { + let _ = take_hook(); + let prev = self.hook.take().unwrap(); + let prev = match Arc::try_unwrap(prev) { + Ok(prev) => prev, + Err(_) => unreachable!("`self.hook` is not the only reference to the panic hook"), + }; + set_hook(prev); + } + + let _ = self.terminal.show_cursor(); + } + + fn half_restore(w: &mut impl Write) { + let _ = disable_raw_mode(); + let _ = execute!(*w, LeaveAlternateScreen, DisableMouseCapture); + } +} + +impl Drop for TerminalGuard { + #[inline] + fn drop(&mut self) { + self.restore(); + } +} + +/// Runs a closure with the default Foundry terminal setup. +pub fn with_terminal(f: impl FnMut(&mut CrosstermTerminal) -> T) -> IoResult { + let backend = CrosstermBackend::new(stdout()); + let terminal = Terminal::new(backend)?; + Ok(TerminalGuard::with(terminal, f)) +} + +#[cfg(test)] +mod tests { + use super::{TuiEnvironment, TuiFallbackReason, TuiMode}; + + #[test] + fn detects_interactive_mode() { + let env = TuiEnvironment::new(true, true, false); + + assert_eq!(env.mode(), TuiMode::Interactive); + assert!(env.mode().is_interactive()); + } + + #[test] + fn ci_forces_fallback() { + let env = TuiEnvironment::new(true, true, true); + + assert_eq!(env.mode(), TuiMode::Fallback(TuiFallbackReason::Ci)); + assert!(!env.mode().is_interactive()); + } + + #[test] + fn stdin_must_be_terminal() { + let env = TuiEnvironment::new(false, true, false); + + assert_eq!(env.mode(), TuiMode::Fallback(TuiFallbackReason::StdinNotTerminal)); + } + + #[test] + fn stdout_must_be_terminal() { + let env = TuiEnvironment::new(true, false, false); + + assert_eq!(env.mode(), TuiMode::Fallback(TuiFallbackReason::StdoutNotTerminal)); + } + + #[test] + fn ci_reason_takes_precedence() { + let env = TuiEnvironment::new(false, false, true); + + assert_eq!(env.mode(), TuiMode::Fallback(TuiFallbackReason::Ci)); + } + + #[test] + fn fallback_reasons_have_stable_descriptions() { + assert_eq!(TuiFallbackReason::Ci.as_str(), "running in CI"); + assert_eq!(TuiFallbackReason::StdinNotTerminal.as_str(), "stdin is not a terminal"); + assert_eq!(TuiFallbackReason::StdoutNotTerminal.as_str(), "stdout is not a terminal"); + } +} diff --git a/crates/verify/Cargo.toml b/crates/verify/Cargo.toml index d4706a9300b2a..298718f1520f5 100644 --- a/crates/verify/Cargo.toml +++ b/crates/verify/Cargo.toml @@ -18,8 +18,10 @@ foundry-config.workspace = true foundry-cli.workspace = true foundry-common.workspace = true foundry-evm.workspace = true -foundry-evm-core.workspace = true +foundry-evm-networks.workspace = true serde_json.workspace = true +alloy-consensus.workspace = true +alloy-network.workspace = true alloy-json-abi.workspace = true alloy-primitives.workspace = true alloy-rpc-types = { workspace = true, features = ["eth", "trace"] } @@ -28,20 +30,48 @@ serde.workspace = true eyre.workspace = true alloy-provider = { workspace = true, features = ["trace-api"] } tracing.workspace = true -foundry-compilers = { workspace = true, features = ["full"] } +foundry-compilers.workspace = true foundry-block-explorers = { workspace = true, features = ["foundry-compilers"] } revm.workspace = true clap = { version = "4", features = ["derive", "env", "unicode", "wrap_help"] } reqwest = { workspace = true, features = ["json"] } +tokio = { workspace = true, features = ["time"] } async-trait.workspace = true futures.workspace = true semver.workspace = true regex = { workspace = true, default-features = false } yansi.workspace = true itertools.workspace = true +url.workspace = true + +# monad +monad-revm = { workspace = true, optional = true } [dev-dependencies] tokio = { workspace = true, features = ["macros"] } foundry-test-utils.workspace = true tempfile.workspace = true + +[features] +default = ["optimism"] +monad = [ + "dep:monad-revm", + "foundry-common/monad", + "foundry-config/monad", + "foundry-evm/monad", + "foundry-evm-networks/monad", + "foundry-cli/monad", +] +base = [ + "foundry-common/base", + "foundry-evm/base", + "foundry-evm-networks/base", + "foundry-cli/base", +] +optimism = [ + "foundry-common/optimism", + "foundry-evm/optimism", + "foundry-evm-networks/optimism", + "foundry-cli/optimism", +] diff --git a/crates/verify/src/bytecode.rs b/crates/verify/src/bytecode.rs index f94daf8c4e4af..1a54c0563c106 100644 --- a/crates/verify/src/bytecode.rs +++ b/crates/verify/src/bytecode.rs @@ -2,35 +2,73 @@ use crate::{ etherscan::EtherscanVerificationProvider, utils::{ - BytecodeType, JsonResult, check_and_encode_args, check_explorer_args, configure_env_block, - maybe_predeploy_contract, + BytecodeType, JsonResult, check_and_encode_args, check_explorer_args, + load_fork_config_and_evm_opts, maybe_predeploy_contract, validate_encoded_constructor_args, }, verify::VerifierArgs, }; -use alloy_primitives::{Address, Bytes, TxKind, U256, hex}; +use alloy_consensus::Transaction as ConsensusTransaction; +use alloy_network::{AnyNetwork, AnyRpcBlock, AnyRpcTransaction}; +use alloy_primitives::{Address, B256, Bytes, TxKind, U256, hex}; use alloy_provider::{ Provider, ext::TraceApi, - network::{AnyTxEnvelope, TransactionBuilder}, + network::{BlockResponse, ReceiptResponse, TransactionResponse, primitives::BlockTransactions}, }; use alloy_rpc_types::{ - BlockId, BlockNumberOrTag, TransactionInput, TransactionRequest, + BlockId, BlockNumberOrTag, trace::parity::{Action, CreateAction, CreateOutput, TraceOutput}, }; use clap::{Parser, ValueHint}; use eyre::{Context, OptionExt, Result}; +use foundry_block_explorers::contract::Metadata; use foundry_cli::{ opts::EtherscanOpts, utils::{self, LoadConfig, read_constructor_args_file}, }; -use foundry_common::shell; -use foundry_compilers::{artifacts::EvmVersion, info::ContractInfo}; -use foundry_config::{Config, figment, impl_figment_convert}; -use foundry_evm::{constants::DEFAULT_CREATE2_DEPLOYER, utils::configure_tx_req_env}; -use foundry_evm_core::AsEnvMut; -use revm::state::AccountInfo; +use foundry_common::{ + SYSTEM_TRANSACTION_TYPE, is_known_system_sender, + provider::{ProviderBuilder, RetryProvider}, + shell, +}; +use foundry_compilers::info::ContractInfo; +use foundry_config::{Chain, Config, figment, impl_figment_convert}; +use foundry_evm::{ + constants::DEFAULT_CREATE2_DEPLOYER, + core::{ + FoundryChain, FoundryTransaction as _, + env::FromAnyRpcTransaction as _, + evm::{ChainFor, EthEvmNetwork, EvmEnvFor, FoundryEvmNetwork, TempoEvmNetwork, TxEnvFor}, + }, + executors::{Executor, ExecutorBuilder, TracingExecutor}, + opts::{EvmOpts, ForkEndpointIdentity}, + utils::apply_chain_specific_tx_replay_env_changes_for_chain, +}; +use foundry_evm_networks::NetworkVariant; +use revm::{ + context::{Block as _, Transaction as _}, + state::AccountInfo, +}; use std::path::PathBuf; +#[cfg(feature = "base")] +use foundry_evm::core::evm::BaseEvmNetwork; + +#[cfg(feature = "monad")] +use alloy_network::Ethereum; +#[cfg(feature = "monad")] +use foundry_evm::{ + EvmEnv, + core::evm::{BlockContext, MonadEvmNetwork}, +}; +#[cfg(feature = "monad")] +use monad_revm::{MonadChainContext, MonadHardfork}; +#[cfg(feature = "monad")] +use revm::context::TxEnv; + +#[cfg(feature = "optimism")] +use foundry_evm::core::evm::OpEvmNetwork; + impl_figment_convert!(VerifyBytecodeArgs); /// CLI arguments for `forge verify-bytecode`. @@ -76,6 +114,10 @@ pub struct VerifyBytecodeArgs { #[arg(short = 'r', long, value_name = "RPC_URL", env = "ETH_RPC_URL")] pub rpc_url: Option, + /// Specify the network for correct encoding. + #[arg(long, short, num_args = 1, value_name = "NETWORK")] + pub network: Option, + /// Etherscan options. #[command(flatten)] pub etherscan: EtherscanOpts, @@ -84,6 +126,10 @@ pub struct VerifyBytecodeArgs { #[command(flatten)] pub verifier: VerifierArgs, + /// Set pre-linked libraries. + #[arg(long, help_heading = "Linker options")] + pub libraries: Vec, + /// The project's root path. /// /// By default root of the Git repository, if in one, @@ -110,15 +156,11 @@ impl figment::Provider for VerifyBytecodeArgs { dict.insert("etherscan_api_key".into(), api_key.as_str().into()); } - if let Some(api_version) = &self.verifier.verifier_api_version { - dict.insert("etherscan_api_version".into(), api_version.to_string().into()); - } - if let Some(block) = &self.block { dict.insert("block".into(), figment::value::Value::serialize(block)?); } if let Some(rpc_url) = &self.rpc_url { - dict.insert("eth_rpc_url".into(), rpc_url.to_string().into()); + dict.insert("eth_rpc_url".into(), rpc_url.clone().into()); } Ok(figment::value::Map::from([(Config::selected_profile(), dict)])) @@ -126,36 +168,234 @@ impl figment::Provider for VerifyBytecodeArgs { } impl VerifyBytecodeArgs { + async fn resolve_evm_opts(config: &Config) -> Result { + let (_, mut evm_opts) = load_fork_config_and_evm_opts(config)?; + evm_opts.networks = config.networks; + if evm_opts.fork_url.is_none() { + evm_opts.fork_url = Some(config.get_rpc_url_or_localhost_http()?.into_owned()); + } + evm_opts.infer_network_from_fork().await?; + Ok(evm_opts) + } + + async fn ensure_endpoint_identity_unchanged( + config: &Config, + expected: Option<&ForkEndpointIdentity>, + ) -> Result<()> { + let Some(expected) = expected else { return Ok(()) }; + let current = Self::resolve_evm_opts(config).await?.fork_endpoint.ok_or_else(|| { + eyre::eyre!("RPC endpoint identity disappeared while verify-bytecode was running") + })?; + Self::validate_endpoint_identity(expected, ¤t) + } + + fn validate_endpoint_identity( + expected: &ForkEndpointIdentity, + current: &ForkEndpointIdentity, + ) -> Result<()> { + if current != expected { + eyre::bail!( + "RPC endpoint identity changed while verify-bytecode was running; retry against \ + a stable endpoint" + ); + } + Ok(()) + } + + fn apply_endpoint_expectation( + evm_opts: &mut EvmOpts, + endpoint_identity: Option<&ForkEndpointIdentity>, + network_was_inferred: bool, + ) { + if let Some(identity) = endpoint_identity { + evm_opts.expect_fork_endpoint(identity.clone(), network_was_inferred); + } + } + + fn explorer_chain( + configured: Option, + endpoint_identity: Option<&ForkEndpointIdentity>, + ) -> Option { + configured.or_else(|| endpoint_identity.map(|identity| identity.source_chain_id.into())) + } + /// Run the `verify-bytecode` command to verify the bytecode onchain against the locally built /// bytecode. pub async fn run(mut self) -> Result<()> { + let mut config = self.load_config_with_dependencies()?; + config.libraries.append(&mut self.libraries); + + if let Some(network) = self.network { + config.networks = network.into(); + } + let evm_opts = Self::resolve_evm_opts(&config).await?; + let network_was_inferred = evm_opts.fork_network_is_inferred; + let endpoint_identity = evm_opts.fork_endpoint; + config.networks = evm_opts.networks; + + match config.networks.execution_network() { + NetworkVariant::Ethereum => { + self.run_with_network::( + config, + endpoint_identity, + network_was_inferred, + ExecutorBuilder::::new(), + ) + .await + } + #[cfg(feature = "base")] + NetworkVariant::Base => { + self.run_with_network::( + config, + endpoint_identity, + network_was_inferred, + ExecutorBuilder::::new(), + ) + .await + } + #[cfg(feature = "optimism")] + NetworkVariant::Optimism => { + self.run_with_network::( + config, + endpoint_identity, + network_was_inferred, + ExecutorBuilder::::new(), + ) + .await + } + NetworkVariant::Tempo => { + self.run_with_network::( + config, + endpoint_identity, + network_was_inferred, + ExecutorBuilder::::new(), + ) + .await + } + #[cfg(feature = "monad")] + NetworkVariant::Monad => { + let Some(mut verification) = self + .prepare_runtime_verification::( + config, + endpoint_identity, + network_was_inferred, + ExecutorBuilder::::new(), + ) + .await? + else { + return Ok(()); + }; + let context = replay_monad_block_transactions( + &verification.config, + verification.block.as_ref(), + verification.simulation_block, + verification.transaction.tx_hash(), + &mut verification.executor, + &verification.evm_env, + ) + .await?; + verification.finish(context).await + } + } + } + + /// Runs verification for networks whose replay does not require block ancestry. + async fn run_with_network( + self, + config: Config, + endpoint_identity: Option, + network_was_inferred: bool, + executor_builder: ExecutorBuilder, + ) -> Result<()> { + let Some(mut verification) = self + .prepare_runtime_verification::( + config, + endpoint_identity, + network_was_inferred, + executor_builder, + ) + .await? + else { + return Ok(()); + }; + let context = replay_block_transactions( + verification.block.as_ref(), + verification.transaction.tx_hash(), + &mut verification.executor, + &verification.evm_env, + )?; + verification.finish(context).await + } + + async fn prepare_runtime_verification( + mut self, + config: Config, + endpoint_identity: Option, + network_was_inferred: bool, + executor_builder: ExecutorBuilder, + ) -> Result>> + where + FEN: FoundryEvmNetwork, + { // Setup - let config = self.load_config()?; - let provider = utils::get_provider(&config)?; + // `AnyNetwork` rather than `FEN::Network`: chains such as Arbitrum and Celo put + // transaction types the strict Ethereum envelope cannot decode into every block, which + // would fail the full block fetches below for the whole chain. Execution still uses `FEN`. + let provider = ProviderBuilder::::from_config(&config)?.build()?; // If chain is not set, we try to get it from the RPC. // If RPC is not set, the default chain is used. - let chain = match config.get_rpc_url() { - Some(_) => utils::get_chain(config.chain, &provider).await?, - None => config.chain.unwrap_or_default(), + let chain = match ( + Self::explorer_chain(config.chain, endpoint_identity.as_ref()), + config.get_rpc_url(), + ) { + (Some(chain), _) => chain, + (None, Some(_)) => utils::get_chain::(None, &provider).await?, + (None, None) => Default::default(), }; // Set Etherscan options. self.etherscan.chain = Some(chain); self.etherscan.key = config.get_etherscan_config_with_chain(Some(chain))?.map(|c| c.key); - // Etherscan client - let etherscan = - EtherscanVerificationProvider.client(&self.etherscan, &self.verifier, &config)?; + // Whether a block explorer is configured for this chain. Client setup errors are only + // treated as "no explorer available" when no usable verifier, verifier URL, or resolved + // API key is configured. + let has_explorer_config = self.verifier.verifier.is_some() + || self.verifier.verifier_url.is_some() + || self.verifier.verifier_api_key.is_some() + || self.etherscan.key.is_some(); + + // Etherscan client. May be unavailable (e.g. unknown chain, missing configuration), in + // which case verification proceeds with local data only. + let etherscan = match EtherscanVerificationProvider.client( + &self.etherscan, + &self.verifier, + &config, + ) { + Ok(client) => Some(client), + Err(err) => { + if has_explorer_config { + return Err(err); + } + if !shell::is_json() { + sh_warn!( + "Failed to create a block explorer client: {err}. Continuing with the local project configuration." + )?; + } + None + } + }; // Get the bytecode at the address, bailing if it doesn't exist. let code = provider.get_code_at(self.address).await?; + Self::ensure_endpoint_identity_unchanged(&config, endpoint_identity.as_ref()).await?; if code.is_empty() { eyre::bail!("No bytecode found at address {}", self.address); } if !shell::is_json() { - sh_println!( + sh_status!( "Verifying bytecode for contract {} at address {}", self.contract.name, self.address @@ -164,35 +404,74 @@ impl VerifyBytecodeArgs { let mut json_results: Vec = vec![]; - // Get creation tx hash. - let creation_data = etherscan.contract_creation_data(self.address).await; - - // Check if contract is a predeploy - let (creation_data, maybe_predeploy) = maybe_predeploy_contract(creation_data)?; + // Get creation tx hash. An unavailable explorer (missing API key, unsupported chain, + // unverified contract, etc.) must not prevent verification against a local build: fall + // back to verifying the runtime bytecode only. + // See . + let (creation_data, maybe_predeploy) = match ðerscan { + Some(etherscan) => { + let creation_data = etherscan.contract_creation_data(self.address).await; + + // Check if contract is a predeploy + match maybe_predeploy_contract(creation_data) { + Ok(res) => res, + Err(err) => { + if has_explorer_config { + return Err(err); + } + if !shell::is_json() { + sh_warn!( + "Failed to fetch creation data from the block explorer: {err}" + )?; + } + (None, false) + } + } + } + None => (None, false), + }; trace!(maybe_predeploy = ?maybe_predeploy); // Get the constructor args using `source_code` endpoint. - let source_code = etherscan.contract_source_code(self.address).await?; - - // Check if the contract name matches. - let name = source_code.items.first().map(|item| item.contract_name.to_owned()); - if name.as_ref() != Some(&self.contract.name) { - eyre::bail!("Contract name mismatch"); - } + let source_code = match ðerscan { + Some(etherscan) => match etherscan.contract_source_code(self.address).await { + Ok(source_code) => { + if let Some(metadata) = source_code.items.first() { + // Check if the contract name matches. + if metadata.contract_name != self.contract.name { + eyre::bail!("Contract name mismatch"); + } + Some(source_code) + } else { + if !shell::is_json() { + sh_warn!( + "Block explorer returned no source metadata. Continuing with the local project configuration; compiler settings mismatches will not be reported." + )?; + } + None + } + } + Err(err) => { + if has_explorer_config { + return Err(err.into()); + } + if !shell::is_json() { + sh_warn!( + "Failed to fetch contract source code from the block explorer: {err}. Continuing with the local project configuration; compiler settings mismatches will not be reported." + )?; + } + None + } + }, + None => None, + }; // Obtain Etherscan compilation metadata. - let etherscan_metadata = source_code.items.first().unwrap(); + let etherscan_metadata = source_code.as_ref().and_then(|source| source.items.first()); // Obtain local artifact - let artifact = if let Ok(local_bytecode) = - crate::utils::build_using_cache(&self, etherscan_metadata, &config) - { - trace!("using cache"); - local_bytecode - } else { - crate::utils::build_project(&self, &config)? - }; + let artifact = crate::utils::build_project(&self, &config)?; // Get local bytecode (creation code) let local_bytecode = artifact @@ -202,70 +481,102 @@ impl VerifyBytecodeArgs { .ok_or_eyre("Unlinked bytecode is not supported for verification")?; // Get and encode user provided constructor args - let provided_constructor_args = if let Some(path) = self.constructor_args_path.to_owned() { - // Read from file - Some(read_constructor_args_file(path)?) + let provided_constructor_args = if let Some(encoded) = &self.encoded_constructor_args { + Some(validate_encoded_constructor_args(&artifact, hex::decode(encoded)?)?) } else { - self.constructor_args.to_owned() - } - .map(|args| check_and_encode_args(&artifact, args)) - .transpose()? - .or(self.encoded_constructor_args.to_owned().map(hex::decode).transpose()?); + if let Some(path) = self.constructor_args_path.clone() { + // Read from file. + Some(read_constructor_args_file(path)?) + } else { + self.constructor_args.clone() + } + .map(|args| check_and_encode_args(&artifact, args)) + .transpose()? + }; + let args_from_user = provided_constructor_args.is_some(); let mut constructor_args = if let Some(provided) = provided_constructor_args { provided.into() - } else { + } else if let Some(source_code) = &source_code { // If no constructor args were provided, try to retrieve them from the explorer. - check_explorer_args(source_code.clone())? + check_explorer_args(source_code)? + } else { + Bytes::new() }; // This fails only when the contract expects constructor args but NONE were provided OR // retrieved from explorer (in case of predeploys). crate::utils::check_args_len(&artifact, &constructor_args)?; - if maybe_predeploy { + // Without creation data (predeploys, or the explorer being unavailable), the creation + // code cannot be verified. Verify the runtime bytecode instead by deploying the local + // creation code and comparing the resulting runtime code with the onchain one. + if creation_data.is_none() { if !shell::is_json() { - sh_warn!( - "Attempting to verify predeployed contract at {:?}. Ignoring creation code verification.", - self.address - )?; + if maybe_predeploy { + sh_warn!( + "Attempting to verify predeployed contract at {:?}. Ignoring creation code verification.", + self.address + )?; + } else { + sh_warn!("Creation data is unavailable. Ignoring creation code verification.")?; + } } + // Without creation data there is nothing else to verify when the runtime bytecode is + // ignored. + if self.ignore.is_some_and(|b| b.is_runtime()) { + if shell::is_json() { + sh_println!("{}", serde_json::to_string(&json_results)?)?; + } + return Ok(None); + } + + let deploy_block = if maybe_predeploy { + // Deploy at genesis + 0_u64 + } else { + match self.block { + Some(BlockId::Number(BlockNumberOrTag::Number(block))) => block, + Some(_) => { + eyre::bail!("Invalid block number"); + } + None => provider.get_block_number().await?, + } + }; + // Append constructor args to the local_bytecode. trace!(%constructor_args); let mut local_bytecode_vec = local_bytecode.to_vec(); local_bytecode_vec.extend_from_slice(&constructor_args); - // Deploy at genesis - let gen_blk_num = 0_u64; - let (mut fork_config, evm_opts) = config.clone().load_config_and_evm_opts()?; - let (mut env, mut executor) = crate::utils::get_tracing_executor( + let deploy_block_info = provider.get_block(deploy_block.into()).full().await?; + let (mut fork_config, mut evm_opts) = load_fork_config_and_evm_opts(&config)?; + Self::apply_endpoint_expectation( + &mut evm_opts, + endpoint_identity.as_ref(), + network_was_inferred, + ); + let (evm_env, _, mut executor) = crate::utils::get_tracing_executor::( &mut fork_config, - gen_blk_num, - etherscan_metadata.evm_version()?.unwrap_or(EvmVersion::default()), + deploy_block, + deploy_block, + deploy_block_info.as_ref(), evm_opts, + executor_builder.clone(), ) .await?; + Self::ensure_endpoint_identity_unchanged(&config, endpoint_identity.as_ref()).await?; - env.evm_env.block_env.number = U256::ZERO; - let genesis_block = provider.get_block(gen_blk_num.into()).full().await?; - - // Setup genesis tx and env. + // Setup genesis tx_env and evm_evm. let deployer = Address::with_last_byte(0x1); - let mut gen_tx_req = TransactionRequest::default() - .with_from(deployer) - .with_input(Bytes::from(local_bytecode_vec)) - .into_create(); - - if let Some(ref block) = genesis_block { - configure_env_block(&mut env.as_env_mut(), block); - gen_tx_req.max_fee_per_gas = block.header.base_fee_per_gas.map(|g| g as u128); - gen_tx_req.gas = Some(block.header.gas_limit); - gen_tx_req.gas_price = block.header.base_fee_per_gas.map(|g| g as u128); - } - - configure_tx_req_env(&mut env.as_env_mut(), &gen_tx_req, None) - .wrap_err("Failed to configure tx request env")?; + let mut tx_env = TxEnvFor::::default(); + tx_env.set_caller(deployer); + tx_env.set_kind(TxKind::Create); + tx_env.set_data(Bytes::from(local_bytecode_vec)); + tx_env.set_chain_id(Some(evm_env.cfg_env.chain_id)); + tx_env.set_gas_limit(evm_env.block_env.gas_limit()); + tx_env.set_gas_price(evm_env.block_env.basefee() as u128); // Seed deployer account with funds let account_info = AccountInfo { @@ -275,22 +586,30 @@ impl VerifyBytecodeArgs { }; executor.backend_mut().insert_account_info(deployer, account_info); - let fork_address = crate::utils::deploy_contract( - &mut executor, - &env, - config.evm_spec_id(), - gen_tx_req.to, - )?; + let fork_address = if maybe_predeploy || deploy_block == 0 { + crate::utils::deploy_contract::( + &mut executor, + &evm_env, + &tx_env, + TxKind::Create, + ChainFor::::for_transaction(&tx_env), + )? + } else { + executor.deploy_with_env(evm_env.clone(), tx_env.clone(), None)?.address + }; - // Compare runtime bytecode - let (deployed_bytecode, onchain_runtime_code) = crate::utils::get_runtime_codes( + // Compare runtime bytecode. The onchain code is read at `deploy_block` to stay + // anchored to the same height as the local fork. Predeploys keep reading at the + // latest block: their code is stable and genesis state often isn't served by RPCs. + let (deployed_bytecode, onchain_runtime_code) = crate::utils::get_runtime_codes::( &mut executor, &provider, self.address, fork_address, - None, + (!maybe_predeploy).then_some(deploy_block), ) .await?; + Self::ensure_endpoint_identity_unchanged(&config, endpoint_identity.as_ref()).await?; let match_type = crate::utils::match_bytecodes( deployed_bytecode.original_byte_slice(), @@ -312,7 +631,7 @@ impl VerifyBytecodeArgs { sh_println!("{}", serde_json::to_string(&json_results)?)?; } - return Ok(()); + return Ok(None); } // We can unwrap directly as maybe_predeploy is false @@ -322,14 +641,18 @@ impl VerifyBytecodeArgs { let transaction = provider .get_transaction_by_hash(creation_data.transaction_hash) .await - .or_else(|e| eyre::bail!("Couldn't fetch transaction from RPC: {:?}", e))? + .or_else(|e| { + eyre::bail!("Couldn't fetch transaction from RPC: {:?}", e); + })? .ok_or_else(|| { eyre::eyre!("Transaction not found for hash {}", creation_data.transaction_hash) })?; let receipt = provider .get_transaction_receipt(creation_data.transaction_hash) .await - .or_else(|e| eyre::bail!("Couldn't fetch transaction receipt from RPC: {:?}", e))?; + .or_else(|e| { + eyre::bail!("Couldn't fetch transaction receipt from RPC: {:?}", e); + })?; let receipt = if let Some(receipt) = receipt { receipt } else { @@ -339,24 +662,15 @@ impl VerifyBytecodeArgs { ); }; - let mut transaction: TransactionRequest = match transaction.inner.inner.inner() { - AnyTxEnvelope::Ethereum(tx) => tx.clone().into(), - AnyTxEnvelope::Unknown(_) => unreachable!("Unknown transaction type"), - }; + let creation_block = transaction.block_number(); // Extract creation code from creation tx input. - let maybe_creation_code = if receipt.to.is_none() - && receipt.contract_address == Some(self.address) + let maybe_creation_code = if receipt.to().is_none() + && receipt.contract_address() == Some(self.address) { - match &transaction.input.input { - Some(input) => &input[..], - None => unreachable!("creation tx input is None"), - } - } else if receipt.to == Some(DEFAULT_CREATE2_DEPLOYER) { - match &transaction.input.input { - Some(input) => &input[32..], - None => unreachable!("creation tx input is None"), - } + transaction.input().clone() + } else if receipt.to() == Some(DEFAULT_CREATE2_DEPLOYER) { + Bytes::copy_from_slice(&transaction.input()[32..]) } else { // Try to get creation bytecode from tx trace. let traces = provider @@ -373,20 +687,21 @@ impl VerifyBytecodeArgs { _ => None, }); - &creation_bytecode.ok_or_else(|| { + creation_bytecode.ok_or_else(|| { eyre::eyre!( "Could not extract the creation code for contract at address {}", self.address ) })? }; + Self::ensure_endpoint_identity_unchanged(&config, endpoint_identity.as_ref()).await?; // In some cases, Etherscan will return incorrect constructor arguments. If this - // happens, try extracting arguments ourselves. - if !maybe_creation_code.ends_with(&constructor_args) { + // happens, try extracting arguments ourselves. Never replace user-provided arguments. + if !args_from_user && !maybe_creation_code.ends_with(&constructor_args) { trace!("mismatch of constructor args with etherscan"); - // If local bytecode is longer than on-chain one, this is probably not a match. if maybe_creation_code.len() >= local_bytecode.len() { + // If local bytecode is longer than on-chain one, this is probably not a match. constructor_args = Bytes::copy_from_slice(&maybe_creation_code[local_bytecode.len()..]); trace!( @@ -402,20 +717,64 @@ impl VerifyBytecodeArgs { let mut local_bytecode_vec = local_bytecode.to_vec(); local_bytecode_vec.extend_from_slice(&constructor_args); + // A suffix check alone is insufficient for dynamic ABI values: one valid encoding can + // be a suffix of a different valid encoding. Always compare the complete creation code + // when arguments came from the user, even if creation output is ignored. + let creation_match_type = crate::utils::match_bytecodes( + local_bytecode_vec.as_slice(), + &maybe_creation_code, + &constructor_args, + false, + config.bytecode_hash, + ); + if args_from_user + && creation_match_type.is_none() + && self.ignore.is_none_or(|b| !b.is_creation()) + { + let message = + "Provided constructor args could not be validated against deployment creation code"; + if shell::is_json() { + json_results.push(JsonResult { + bytecode_type: BytecodeType::Creation, + match_type: None, + message: Some(message.to_string()), + }); + if self.ignore.is_none_or(|b| !b.is_runtime()) { + json_results.push(JsonResult { + bytecode_type: BytecodeType::Runtime, + match_type: None, + message: Some(message.to_string()), + }); + } + sh_println!("{}", serde_json::to_string(&json_results)?)?; + } else { + sh_warn!("{message}")?; + crate::utils::print_result( + None, + BytecodeType::Creation, + &mut json_results, + etherscan_metadata, + &config, + ); + if self.ignore.is_none_or(|b| !b.is_runtime()) { + crate::utils::print_result( + None, + BytecodeType::Runtime, + &mut json_results, + etherscan_metadata, + &config, + ); + } + } + return Ok(None); + } + trace!(ignore = ?self.ignore); // Check if `--ignore` is set to `creation`. - if !self.ignore.is_some_and(|b| b.is_creation()) { + if self.ignore.is_none_or(|b| !b.is_creation()) { // Compare creation code with locally built bytecode and `maybe_creation_code`. - let match_type = crate::utils::match_bytecodes( - local_bytecode_vec.as_slice(), - maybe_creation_code, - &constructor_args, - false, - config.bytecode_hash, - ); - crate::utils::print_result( - match_type, + creation_match_type, BytecodeType::Creation, &mut json_results, etherscan_metadata, @@ -423,7 +782,7 @@ impl VerifyBytecodeArgs { ); // If the creation code does not match, the runtime also won't match. Hence return. - if match_type.is_none() { + if creation_match_type.is_none() { crate::utils::print_result( None, BytecodeType::Runtime, @@ -434,112 +793,539 @@ impl VerifyBytecodeArgs { if shell::is_json() { sh_println!("{}", serde_json::to_string(&json_results)?)?; } - return Ok(()); + return Ok(None); } } - if !self.ignore.is_some_and(|b| b.is_runtime()) { + if self.ignore.is_none_or(|b| !b.is_runtime()) { + // Runtime verification can only re-deploy local bytecode for direct `CREATE` and the + // default `CREATE2` deployer, so skip custom factory deployments. + if let TxKind::Call(to) = ConsensusTransaction::kind(&transaction) + && to != DEFAULT_CREATE2_DEPLOYER + { + let message = format!( + "Runtime bytecode verification is not supported for this contract: its \ + creation transaction calls custom factory {to}. forge can only verify \ + runtime bytecode for direct CREATE transactions and calls to the default \ + CREATE2 deployer; skipping runtime bytecode verification." + ); + if shell::is_json() { + json_results.push(JsonResult { + bytecode_type: BytecodeType::Runtime, + match_type: None, + message: Some(message), + }); + sh_println!("{}", serde_json::to_string(&json_results)?)?; + } else { + sh_warn!("{message}")?; + } + return Ok(None); + } + // Get contract creation block. let simulation_block = match self.block { Some(BlockId::Number(BlockNumberOrTag::Number(block))) => block, - Some(_) => eyre::bail!("Invalid block number"), - None => { - let provider = utils::get_provider(&config)?; - provider - .get_transaction_by_hash(creation_data.transaction_hash) - .await.or_else(|e| eyre::bail!("Couldn't fetch transaction from RPC: {:?}", e))?.ok_or_else(|| { - eyre::eyre!("Transaction not found for hash {}", creation_data.transaction_hash) - })? - .block_number.ok_or_else(|| { - eyre::eyre!("Failed to get block number of the contract creation tx, specify using the --block flag") - })? + Some(_) => { + eyre::bail!("Invalid block number"); } + None => creation_block.ok_or_else(|| { + eyre::eyre!( + "Failed to get block number of the contract creation tx, specify using the \ + --block flag" + ) + })?, }; - // Fork the chain at `simulation_block`. - let (mut fork_config, evm_opts) = config.clone().load_config_and_evm_opts()?; - let (mut env, mut executor) = crate::utils::get_tracing_executor( + // Fork the chain immediately before `simulation_block`, then execute with the target + // block's environment and effective runtime hardfork. + let block = provider.get_block(simulation_block.into()).full().await?; + let (mut fork_config, mut evm_opts) = load_fork_config_and_evm_opts(&config)?; + Self::apply_endpoint_expectation( + &mut evm_opts, + endpoint_identity.as_ref(), + network_was_inferred, + ); + let (mut evm_env, _tx_env, executor) = crate::utils::get_tracing_executor::( &mut fork_config, simulation_block - 1, // env.fork_block_number - etherscan_metadata.evm_version()?.unwrap_or(EvmVersion::default()), + simulation_block, + block.as_ref(), evm_opts, + executor_builder, ) .await?; - env.evm_env.block_env.number = U256::from(simulation_block); - let block = provider.get_block(simulation_block.into()).full().await?; - - // Workaround for the NonceTooHigh issue as we're not simulating prior txs of the same - // block. - let prev_block_id = BlockId::number(simulation_block - 1); + Self::ensure_endpoint_identity_unchanged(&config, endpoint_identity.as_ref()).await?; + + apply_chain_specific_tx_replay_env_changes_for_chain(&mut evm_env, chain.id()); + return Ok(Some(RuntimeVerification { + address: self.address, + config, + endpoint_identity, + provider, + executor, + evm_env, + block, + simulation_block, + transaction, + local_bytecode_vec, + constructor_args, + json_results, + etherscan_metadata: source_code.and_then(|source| source.items.into_iter().next()), + })); + } + if shell::is_json() { + sh_println!("{}", serde_json::to_string(&json_results)?)?; + } + Ok(None) + } +} - // Use `transaction.from` instead of `creation_data.contract_creator` to resolve - // blockscout creation data discrepancy in case of CREATE2. - let prev_block_nonce = provider - .get_transaction_count(transaction.from.unwrap()) - .block_id(prev_block_id) - .await?; - transaction.set_nonce(prev_block_nonce); +/// Prepared runtime verification, before replaying the creation block's prefix. +struct RuntimeVerification { + address: Address, + config: Config, + endpoint_identity: Option, + provider: RetryProvider, + executor: TracingExecutor, + evm_env: EvmEnvFor, + block: Option, + simulation_block: u64, + transaction: AnyRpcTransaction, + local_bytecode_vec: Vec, + constructor_args: Bytes, + json_results: Vec, + etherscan_metadata: Option, +} - if let Some(ref block) = block { - configure_env_block(&mut env.as_env_mut(), block) +impl RuntimeVerification { + async fn finish(self, target_context: Option>) -> Result<()> { + let Self { + address, + config, + endpoint_identity, + provider, + mut executor, + evm_env, + simulation_block, + transaction, + local_bytecode_vec, + constructor_args, + mut json_results, + etherscan_metadata, + .. + } = self; + let mut tx_env = TxEnvFor::::from_any_rpc_transaction(&transaction)?; + // Read the call from the decoded env: batched transactions have no top-level `to`/`input`. + let kind = tx_env.kind(); + let target_context = + target_context.unwrap_or_else(|| ChainFor::::for_transaction(&tx_env)); + + // Replace the `input` with local creation code in the creation tx. + if let TxKind::Call(to) = kind { + if to == DEFAULT_CREATE2_DEPLOYER { + let mut input = tx_env.input()[..32].to_vec(); // Salt + input.extend_from_slice(&local_bytecode_vec); + tx_env.set_data(Bytes::from(input)); + + // Deploy default CREATE2 deployer + executor.deploy_create2_deployer()?; } + } else { + tx_env.set_data(Bytes::from(local_bytecode_vec)); + } - // Replace the `input` with local creation code in the creation tx. - if let Some(TxKind::Call(to)) = transaction.kind() { - if to == DEFAULT_CREATE2_DEPLOYER { - let mut input = transaction.input.input.unwrap()[..32].to_vec(); // Salt - input.extend_from_slice(&local_bytecode_vec); - transaction.input = TransactionInput::both(Bytes::from(input)); + let fork_address = crate::utils::deploy_contract::( + &mut executor, + &evm_env, + &tx_env, + kind, + target_context, + )?; + + // State committed using deploy_with_env, now get the runtime bytecode from the db. + let (fork_runtime_code, onchain_runtime_code) = crate::utils::get_runtime_codes::( + &mut executor, + &provider, + address, + fork_address, + Some(simulation_block), + ) + .await?; + VerifyBytecodeArgs::ensure_endpoint_identity_unchanged(&config, endpoint_identity.as_ref()) + .await?; - // Deploy default CREATE2 deployer - executor.deploy_create2_deployer()?; - } - } else { - transaction.input = TransactionInput::both(Bytes::from(local_bytecode_vec)); - } + // Compare the onchain runtime bytecode with the runtime code from the fork. + let match_type = crate::utils::match_bytecodes( + fork_runtime_code.original_byte_slice(), + &onchain_runtime_code, + &constructor_args, + true, + config.bytecode_hash, + ); + + crate::utils::print_result( + match_type, + BytecodeType::Runtime, + &mut json_results, + etherscan_metadata.as_ref(), + &config, + ); + if shell::is_json() { + sh_println!("{}", serde_json::to_string(&json_results)?)?; + } + Ok(()) + } +} - // configure_req__env(&mut env, &transaction.inner); - configure_tx_req_env(&mut env.as_env_mut(), &transaction, None) - .wrap_err("Failed to configure tx request env")?; +/// Replays ordinary transactions preceding `target_hash` and returns its execution context. +fn replay_block_transactions( + block: Option<&AnyRpcBlock>, + target_hash: B256, + executor: &mut Executor, + evm_env: &EvmEnvFor, +) -> Result>> { + let Some(block) = block else { return Ok(None) }; + let BlockTransactions::Full(txs) = block.transactions() else { + return Err(eyre::eyre!("Could not get block txs")); + }; + let target_tx = txs + .iter() + .find(|tx| tx.tx_hash() == target_hash) + .ok_or_else(|| eyre::eyre!("transaction {target_hash:?} is missing from its block"))?; + let target_tx_env = TxEnvFor::::from_any_rpc_transaction(target_tx)?; + + for tx in txs { + trace!("replay tx::: {}", tx.tx_hash()); + if tx.tx_hash() == target_hash { + break; + } + if is_known_system_sender(tx.from()) + || tx.transaction_type() == Some(SYSTEM_TRANSACTION_TYPE) + { + continue; + } - let fork_address = crate::utils::deploy_contract( - &mut executor, - &env, - config.evm_spec_id(), - transaction.to, - )?; + let tx_env = TxEnvFor::::from_any_rpc_transaction(tx)?; + let chain_context = ChainFor::::for_transaction(&tx_env); + execute_replay_transaction(executor, evm_env, tx, tx_env, chain_context)?; + } - // State committed using deploy_with_env, now get the runtime bytecode from the db. - let (fork_runtime_code, onchain_runtime_code) = crate::utils::get_runtime_codes( - &mut executor, - &provider, - self.address, - fork_address, - Some(simulation_block), + Ok(Some(ChainFor::::for_transaction(&target_tx_env))) +} + +/// Replays Monad transactions preceding `target_hash` with their ancestry context. +#[cfg(feature = "monad")] +async fn replay_monad_block_transactions( + config: &Config, + block: Option<&AnyRpcBlock>, + block_number: u64, + target_hash: B256, + executor: &mut Executor, + evm_env: &EvmEnv, +) -> Result> { + let block = block.ok_or_else(|| { + eyre::eyre!("block {block_number} is required to reconstruct transaction context") + })?; + let BlockTransactions::Full(txs) = block.transactions() else { + return Err(eyre::eyre!("Could not get block txs")); + }; + let block_context = monad_block_context(config, block_number).await?; + let target_index = txs + .iter() + .position(|tx| tx.tx_hash() == target_hash) + .ok_or_else(|| eyre::eyre!("transaction {target_hash:?} is missing from its block"))?; + + for (index, tx) in txs.iter().enumerate() { + trace!("replay tx::: {}", tx.tx_hash()); + if tx.tx_hash() == target_hash { + break; + } + + let tx_env = TxEnv::from_any_rpc_transaction(tx)?; + let chain_context = block_context.transaction(index); + if is_known_system_sender(tx.from()) + || tx.transaction_type() == Some(SYSTEM_TRANSACTION_TYPE) + { + let _ = executor + .try_transact_system_replay_with_env_and_context( + evm_env.clone(), + tx_env, + chain_context, + ) + .wrap_err_with(|| { + format!( + "Failed to replay system transaction: {:?} in block {}", + tx.tx_hash(), + evm_env.block_env.number() + ) + })?; + continue; + } + + execute_replay_transaction(executor, evm_env, tx, tx_env, chain_context)?; + } + + Ok(Some(block_context.transaction(target_index))) +} + +fn execute_replay_transaction( + executor: &mut Executor, + evm_env: &EvmEnvFor, + tx: &alloy_network::AnyRpcTransaction, + tx_env: TxEnvFor, + chain_context: ChainFor, +) -> Result<()> { + // Transact creations too: batched transactions can mix a creation with calls, so their result + // need not be a deployment. Reverted transactions are committed and replay continues. + executor.transact_with_env_and_context(evm_env.clone(), tx_env, chain_context).wrap_err_with( + || { + format!( + "Failed to execute transaction: {:?} in block {}", + tx.tx_hash(), + evm_env.block_env.number() ) - .await?; + }, + )?; + Ok(()) +} - // Compare the onchain runtime bytecode with the runtime code from the fork. - let match_type = crate::utils::match_bytecodes( - fork_runtime_code.original_byte_slice(), - &onchain_runtime_code, - &constructor_args, - true, - config.bytecode_hash, - ); +/// Fetches the block context Monad needs to reconstruct replay ordering. +#[cfg(feature = "monad")] +async fn monad_block_context( + config: &Config, + block_number: u64, +) -> Result> { + let provider = ProviderBuilder::::from_config(config)?.build()?; + let block = provider.get_block(block_number.into()).full().await?.ok_or_else(|| { + eyre::eyre!("block {block_number} is required to reconstruct transaction context") + })?; + BlockContext::::fetch(&provider, &block).await +} - crate::utils::print_result( - match_type, - BytecodeType::Runtime, - &mut json_results, - etherscan_metadata, +#[cfg(test)] +mod tests { + use super::*; + use alloy_network::{AnyHeader, AnyRpcHeader}; + use foundry_evm::core::backend::Backend; + use foundry_evm_networks::NetworkConfigs; + use foundry_test_utils::rpc::{ + spawn_rpc_proxy_canned_method, spawn_rpc_proxy_method_not_found_before, + }; + use std::sync::atomic::Ordering; + + #[cfg(not(feature = "monad"))] + use foundry_evm::EvmEnv; + #[cfg(not(feature = "monad"))] + use revm::context::TxEnv; + + fn replay_block(transactions: Vec) -> AnyRpcBlock { + AnyRpcBlock::new( + alloy_rpc_types::Block::new( + AnyRpcHeader::from_sealed(AnyHeader::default().seal(B256::ZERO)), + BlockTransactions::Full(transactions), + ) + .into(), + ) + } + + fn replay_transaction( + caller: Address, + nonce: u64, + hash: B256, + transaction_type: u8, + ) -> AnyRpcTransaction { + serde_json::from_value(serde_json::json!({ + "type": format!("0x{transaction_type:x}"), + "hash": hash, "nonce": format!("0x{nonce:x}"), + "from": caller, "to": Address::with_last_byte(0x43), + "value": "0x7", "gas": "0x5208", "gasPrice": "0x0", "input": "0x", + "v": "0x1b", "r": "0x1", "s": "0x1" + })) + .unwrap() + } + + #[test] + fn replay_prefix_excludes_target_and_skips_system_envelopes() { + let caller = Address::with_last_byte(0x42); + let recipient = Address::with_last_byte(0x43); + let target = B256::with_last_byte(2); + let env = EvmEnv::default(); + let mut executor = ExecutorBuilder::::new().build( + env.clone(), + TxEnv::default(), + Backend::spawn(None).unwrap(), + Default::default(), + ); + executor.backend_mut().insert_account_info( + caller, + AccountInfo { balance: U256::from(100), ..Default::default() }, + ); + let block = replay_block(vec![ + // Unsupported envelopes from known system senders must be skipped before conversion. + replay_transaction(foundry_common::MONAD_SYSTEM_ADDRESS, 0, B256::ZERO, u8::MAX), + // System transaction types must also be skipped for otherwise ordinary senders. + replay_transaction(caller, 0, B256::with_last_byte(4), SYSTEM_TRANSACTION_TYPE), + replay_transaction(caller, 0, B256::with_last_byte(1), 0), + replay_transaction(caller, 1, target, 0), + replay_transaction(caller, 2, B256::with_last_byte(3), 0), + ]); + + replay_block_transactions(Some(&block), target, &mut executor, &env).unwrap(); + + assert_eq!(executor.get_balance(recipient).unwrap(), U256::from(7)); + assert_eq!(executor.get_nonce(caller).unwrap(), 1); + } + + #[test] + fn replay_missing_target_does_not_execute_prefix() { + let caller = Address::with_last_byte(0x42); + let env = EvmEnv::default(); + let mut executor = ExecutorBuilder::::new().build( + env.clone(), + TxEnv::default(), + Backend::spawn(None).unwrap(), + Default::default(), + ); + executor.backend_mut().insert_account_info( + caller, + AccountInfo { balance: U256::from(100), ..Default::default() }, + ); + let block = replay_block(vec![replay_transaction(caller, 0, B256::ZERO, 0)]); + + let error = + replay_block_transactions(Some(&block), B256::with_last_byte(1), &mut executor, &env) + .unwrap_err(); + + assert!(error.to_string().contains("missing from its block"), "{error:?}"); + assert_eq!(executor.get_nonce(caller).unwrap(), 0); + assert_eq!(executor.get_balance(caller).unwrap(), U256::from(100)); + } + + #[test] + fn can_parse_tempo_network() { + let args = VerifyBytecodeArgs::parse_from([ + "foundry-cli", + "0x0000000000000000000000000000000000000000", + "src/Counter.sol:Counter", + "--network", + "tempo", + ]); + + assert_eq!(args.network, Some(NetworkVariant::Tempo)); + } + + #[test] + #[cfg(feature = "monad")] + fn can_parse_monad_network() { + let args = VerifyBytecodeArgs::parse_from([ + "foundry-cli", + "0x0000000000000000000000000000000000000000", + "src/Counter.sol:Counter", + "--network", + "monad", + ]); + + assert_eq!(args.network, Some(NetworkVariant::Monad)); + } + + #[tokio::test] + async fn fork_resolution_preserves_configured_profiles_and_revalidates() { + for networks in [ + NetworkConfigs::default(), + NetworkConfigs::with_ethereum(), + NetworkConfigs::with_celo(), + NetworkConfigs::with_tempo(), + #[cfg(feature = "base")] + NetworkConfigs::with_base(), + #[cfg(feature = "monad")] + NetworkConfigs::with_monad(), + #[cfg(feature = "optimism")] + NetworkConfigs::with_optimism(), + ] { + let (endpoint, chain_requests) = spawn_rpc_proxy_canned_method( + String::new(), + "eth_chainId", + serde_json::json!("0x7a69"), + ) + .await; + let endpoint = + spawn_rpc_proxy_method_not_found_before(endpoint, "anvil_nodeInfo", usize::MAX) + .await; + let config = Config { eth_rpc_url: Some(endpoint), networks, ..Default::default() }; + let opts = VerifyBytecodeArgs::resolve_evm_opts(&config).await.unwrap(); + assert_eq!(opts.networks.execution_network(), networks.execution_network()); + assert_eq!(opts.networks.is_celo(), networks.is_celo()); + assert_eq!(opts.fork_network_is_inferred, !networks.has_network_selection()); + assert_eq!(opts.fork_endpoint.as_ref().unwrap().source_chain_id, 31337); + assert_eq!(chain_requests.load(Ordering::Relaxed), 1); + + let config = Config { networks: opts.networks, ..config }; + + VerifyBytecodeArgs::ensure_endpoint_identity_unchanged( &config, - ); + opts.fork_endpoint.as_ref(), + ) + .await + .unwrap(); + assert_eq!(chain_requests.load(Ordering::Relaxed), 2); } + } - if shell::is_json() { - sh_println!("{}", serde_json::to_string(&json_results)?)?; - } - Ok(()) + #[test] + fn verify_bytecode_requires_stable_endpoint_identity() { + let expected = ForkEndpointIdentity { + endpoint: "http://localhost:8545".to_string(), + execution_chain_id: 1, + source_chain_id: 1, + network: NetworkVariant::Ethereum, + network_profile: Default::default(), + reported_hardfork: Some("FutureA".to_string()), + hardfork: None, + instance_id: Some(B256::with_last_byte(1)), + source_fork_block_number: None, + source_fork_block_hash: None, + }; + + assert!(VerifyBytecodeArgs::validate_endpoint_identity(&expected, &expected).is_ok()); + + let mut reset = expected.clone(); + reset.instance_id = Some(B256::with_last_byte(2)); + assert!(VerifyBytecodeArgs::validate_endpoint_identity(&expected, &reset).is_err()); + + let mut changed_hardfork = expected.clone(); + changed_hardfork.reported_hardfork = Some("FutureB".to_string()); + assert!( + VerifyBytecodeArgs::validate_endpoint_identity(&expected, &changed_hardfork).is_err() + ); + + let mut evm_opts = EvmOpts::default(); + VerifyBytecodeArgs::apply_endpoint_expectation(&mut evm_opts, Some(&expected), true); + assert_eq!(evm_opts.expected_fork_endpoint, Some(expected)); + assert!(evm_opts.fork_network_is_inferred); + } + + #[test] + #[cfg(feature = "monad")] + fn nested_endpoint_separates_execution_family_from_explorer_chain() { + let identity = ForkEndpointIdentity { + endpoint: "http://localhost:8545".to_string(), + execution_chain_id: 1, + source_chain_id: 143, + network: NetworkVariant::Monad, + network_profile: NetworkVariant::Monad.into(), + reported_hardfork: None, + hardfork: None, + instance_id: None, + source_fork_block_number: Some(123), + source_fork_block_hash: None, + }; + + assert_eq!(VerifyBytecodeArgs::explorer_chain(None, Some(&identity)).unwrap().id(), 143); + assert_eq!( + VerifyBytecodeArgs::explorer_chain(Some(Chain::from_id(1)), Some(&identity)) + .unwrap() + .id(), + 1 + ); } } diff --git a/crates/verify/src/etherscan/flatten.rs b/crates/verify/src/etherscan/flatten.rs index 86ed596561d7f..81bd48bda4b88 100644 --- a/crates/verify/src/etherscan/flatten.rs +++ b/crates/verify/src/etherscan/flatten.rs @@ -1,7 +1,8 @@ use super::{EtherscanSourceProvider, VerifyArgs}; use crate::provider::VerificationContext; -use eyre::{Context, Result}; +use eyre::Result; use foundry_block_explorers::verify::CodeFormat; +use foundry_common::flatten; use foundry_compilers::{ AggregatedCompilerOutput, artifacts::{BytecodeHash, Source, Sources}, @@ -32,18 +33,15 @@ impl EtherscanSourceProvider for EtherscanFlattenedSource { bch, ); - let source = context - .project - .paths - .clone() - .with_language::() - .flatten(&context.target_path) - .wrap_err("Failed to flatten contract")?; - + let flattened_source = flatten(context.project.clone(), &context.target_path)?; if !args.force { // solc dry run of flattened code - self.check_flattened(source.clone(), &context.compiler_version, &context.target_path) - .map_err(|err| { + self.check_flattened( + flattened_source.clone(), + &context.compiler_version, + &context.target_path, + ) + .map_err(|err| { eyre::eyre!( "Failed to compile the flattened code locally: `{}`\ To skip this solc dry, have a look at the `--force` flag of this command.", @@ -52,7 +50,7 @@ impl EtherscanSourceProvider for EtherscanFlattenedSource { })?; } - Ok((source, context.target_name.clone(), CodeFormat::SingleFile)) + Ok((flattened_source, context.target_name.clone(), CodeFormat::SingleFile)) } } @@ -91,7 +89,7 @@ impl EtherscanFlattenedSource { if out.errors.iter().any(|e| e.is_error()) { let mut o = AggregatedCompilerOutput::::default(); o.extend(version, RawBuildInfo::new(&input, &out, false)?, "default", out); - let diags = o.diagnostics(&[], &[], Default::default()); + let diags = o.diagnostics(&[], &[], Default::default(), Default::default()); eyre::bail!( "\ @@ -113,9 +111,9 @@ Diagnostics: {diags}", /// sanitized variant of the specific version so that it can be installed. This is merely /// intended to ensure the flattened code can be compiled without errors. fn strip_build_meta(version: Version) -> Version { - if version.build != BuildMetadata::EMPTY { - Version::new(version.major, version.minor, version.patch) - } else { + if version.build == BuildMetadata::EMPTY { version + } else { + Version::new(version.major, version.minor, version.patch) } } diff --git a/crates/verify/src/etherscan/mod.rs b/crates/verify/src/etherscan/mod.rs index 5dbcbf6346963..8ddbde2d3149a 100644 --- a/crates/verify/src/etherscan/mod.rs +++ b/crates/verify/src/etherscan/mod.rs @@ -1,31 +1,33 @@ use crate::{ VerifierArgs, - provider::{VerificationContext, VerificationProvider}, - retry::RETRY_CHECK_ON_VERIFY, + provider::{ + ExternalVerificationContext, VerificationContext, VerificationProvider, + VerificationProviderType, + }, + utils::ensure_solc_build_metadata, verify::{ContractLanguage, VerifyArgs, VerifyCheckArgs}, }; -use alloy_json_abi::Function; +use alloy_dyn_abi::JsonAbiExt; use alloy_primitives::hex; use alloy_provider::Provider; use alloy_rpc_types::TransactionTrait; use eyre::{Context, OptionExt, Result, eyre}; use foundry_block_explorers::{ - Client, EtherscanApiVersion, + Client, errors::EtherscanError, - utils::lookup_compiler_version, verify::{CodeFormat, VerifyContract}, }; use foundry_cli::{ opts::EtherscanOpts, utils::{LoadConfig, get_provider, read_constructor_args_file}, }; -use foundry_common::{abi::encode_function_args, retry::RetryError}; +use foundry_common::{abi::encode_args, retry::RetryError}; use foundry_compilers::{Artifact, artifacts::BytecodeObject}; use foundry_config::Config; use foundry_evm::constants::DEFAULT_CREATE2_DEPLOYER; use regex::Regex; -use semver::{BuildMetadata, Version}; -use std::{fmt::Debug, sync::LazyLock}; +use semver::BuildMetadata; +use std::{fmt::Debug, future::Future as StdFuture, pin::Pin, sync::LazyLock, time::Duration}; mod flatten; @@ -51,6 +53,10 @@ trait EtherscanSourceProvider: Send + Sync + Debug { #[async_trait::async_trait] impl VerificationProvider for EtherscanVerificationProvider { + fn provider_type(&self) -> VerificationProviderType { + VerificationProviderType::Etherscan + } + async fn preflight_verify_check( &mut self, args: VerifyArgs, @@ -60,43 +66,123 @@ impl VerificationProvider for EtherscanVerificationProvider { Ok(()) } - async fn verify(&mut self, args: VerifyArgs, context: VerificationContext) -> Result<()> { + async fn submit( + &mut self, + args: VerifyArgs, + context: VerificationContext, + ) -> Result> { let (etherscan, verify_args) = self.prepare_verify_request(&args, &context).await?; + self.submit_verify_request(args, etherscan, verify_args).await + } + + fn submit_external( + &mut self, + args: VerifyArgs, + context: ExternalVerificationContext, + ) -> Pin>> + '_>> { + Box::pin(async move { + let etherscan = self.client(&args.etherscan, &args.verifier, &context.config)?; + let verify_args = self.create_external_verify_request(&args, &context)?; + self.submit_verify_request(args, etherscan, verify_args).await + }) + } + /// Executes the command to check verification status on Etherscan + async fn check(&self, args: VerifyCheckArgs) -> Result<()> { + let config = args.load_config()?; + let etherscan = self.client(&args.etherscan, &args.verifier, &config)?; + args.retry + .into_retry() + .run_async_until_break(|| async { + let resp = etherscan + .check_contract_verification_status(args.id.clone()) + .await + .wrap_err("Failed to request verification status") + .map_err(RetryError::Retry)?; + + trace!(status = %resp.status, "Received Etherscan verification response"); + + let _ = sh_status!( + "Contract verification status:\nResponse: `{}`\nDetails: `{}`", + sanitize_remote_message(&resp.message), + sanitize_remote_message(&resp.result) + ); + + if resp.result == "Pending in queue" + || resp.result.starts_with("Error: contract does not exist") + { + return Err(RetryError::Retry(eyre!("Verification is still pending..."))); + } + + if resp.result == "Unable to verify" { + return Err(RetryError::Retry(eyre!("Unable to verify."))); + } + + if resp.result == "Already Verified" { + let _ = sh_status!("Contract source code already verified"); + return Ok(()); + } + + if resp.status == "0" { + return Err(RetryError::Break(eyre!( + "Contract verification failed:\nStatus: `{}`\nResult: `{}`", + resp.status, + sanitize_remote_message(&resp.result) + ))); + } + + if resp.result == "Pass - Verified" { + let _ = sh_status!("Contract successfully verified"); + } + + Ok(()) + }) + .await + .wrap_err("Checking verification result failed") + } +} + +impl EtherscanVerificationProvider { + async fn submit_verify_request( + &self, + args: VerifyArgs, + etherscan: Client, + verify_args: VerifyContract, + ) -> Result> { if !args.skip_is_verified_check && self.is_contract_verified(ðerscan, &verify_args).await? { - sh_println!( - "\nContract [{}] {:?} is already verified. Skipping verification.", + sh_status!( + "Contract [{}] {:?} is already verified. Skipping verification.", verify_args.contract_name, verify_args.address.to_checksum(None) )?; - return Ok(()); + return Ok(None); } - trace!(?verify_args, "submitting verification request"); + trace!( + provider = "Etherscan", + address = %verify_args.address, + target = %verify_args.contract_name, + "submitting verification request" + ); let resp = args .retry .into_retry() .run_async(|| async { - sh_println!( - "\nSubmitting verification for [{}] {}.", + sh_status!( + "Submitting verification for [{}] {}.", verify_args.contract_name, verify_args.address )?; let resp = etherscan .submit_contract_verification(&verify_args) .await - .wrap_err_with(|| { - // valid json - let args = serde_json::to_string(&verify_args).unwrap(); - error!(?args, "Failed to submit verification"); - format!("Failed to submit contract verification, payload:\n{args}") - })?; + .wrap_err("Failed to submit Etherscan contract verification")?; - trace!(?resp, "Received verification response"); + trace!(status = %resp.status, "Received Etherscan verification response"); if resp.status == "0" { if resp.result == "Contract source code already verified" @@ -108,19 +194,21 @@ impl VerificationProvider for EtherscanVerificationProvider { if resp.result.starts_with("Unable to locate ContractCode at") || resp.result.starts_with("The address is not a smart contract") + || resp.result.starts_with("Address is not a smart-contract") { - warn!("{}", resp.result); - return Err(eyre!("Could not detect the deployment.")); + warn!("{}", sanitize_remote_message(&resp.result)); + return Err(eyre!( + "Could not detect deployment: {}", + sanitize_remote_message(&resp.result) + )); } - warn!("Failed verify submission: {:?}", resp); - sh_err!( - "Encountered an error verifying this contract:\nResponse: `{}`\nDetails: - `{}`", - resp.message, - resp.result - )?; - std::process::exit(1); + warn!(status = %resp.status, "Etherscan verification submission failed"); + eyre::bail!( + "Encountered an error verifying this contract:\nResponse: `{}`\nDetails: `{}`", + sanitize_remote_message(&resp.message), + sanitize_remote_message(&resp.result) + ); } Ok(Some(resp)) @@ -128,79 +216,45 @@ impl VerificationProvider for EtherscanVerificationProvider { .await?; if let Some(resp) = resp { - sh_println!( + let url = etherscan.address_url(args.address); + let display_id = sanitize_remote_message(&resp.result); + sh_status!( "Submitted contract for verification:\n\tResponse: `{}`\n\tGUID: `{}`\n\tURL: {}", - resp.message, - resp.result, - etherscan.address_url(args.address) + sanitize_remote_message(&resp.message), + display_id, + url )?; - - if args.watch { - let check_args = VerifyCheckArgs { - id: resp.result, - etherscan: args.etherscan, - retry: RETRY_CHECK_ON_VERIFY, - verifier: args.verifier, - }; - return self.check(check_args).await; + if args.print_submission_result_to_stdout { + sh_println!("{}\t{}", display_id, url)?; } + Ok(Some(VerifyCheckArgs { + id: resp.result, + etherscan: args.etherscan, + retry: args.retry, + verifier: args.verifier, + })) } else { - sh_println!("Contract source code already verified")?; + sh_status!("Contract source code already verified")?; + Ok(None) } - - Ok(()) } - /// Executes the command to check verification status on Etherscan - async fn check(&self, args: VerifyCheckArgs) -> Result<()> { - let config = args.load_config()?; - let etherscan = self.client(&args.etherscan, &args.verifier, &config)?; - args.retry - .into_retry() - .run_async_until_break(|| async { - let resp = etherscan - .check_contract_verification_status(args.id.clone()) - .await - .wrap_err("Failed to request verification status") - .map_err(RetryError::Retry)?; - - trace!(?resp, "Received verification response"); - - let _ = sh_println!( - "Contract verification status:\nResponse: `{}`\nDetails: `{}`", - resp.message, - resp.result - ); - - if resp.result == "Pending in queue" { - return Err(RetryError::Retry(eyre!("Verification is still pending..."))); - } - - if resp.result == "Unable to verify" { - return Err(RetryError::Retry(eyre!("Unable to verify."))); - } - - if resp.result == "Already Verified" { - let _ = sh_println!("Contract source code already verified"); - return Ok(()); - } - - if resp.status == "0" { - return Err(RetryError::Break(eyre!("Contract failed to verify."))); - } - - if resp.result == "Pass - Verified" { - let _ = sh_println!("Contract successfully verified"); - } - - Ok(()) - }) - .await - .wrap_err("Checking verification result failed") + fn create_external_verify_request( + &self, + args: &VerifyArgs, + context: &ExternalVerificationContext, + ) -> Result { + let source = serde_json::to_string(&context.standard_json_input) + .wrap_err("Failed to serialize standard json input")?; + let compiler_version = format!("v{}", context.compiler_version); + let mut request = + VerifyContract::new(args.address, context.target.clone(), source, compiler_version) + .constructor_arguments(args.constructor_args.clone()) + .code_format(CodeFormat::StandardJsonInput); + apply_license_type(&mut request, args.license_type.as_deref()); + Ok(request) } -} -impl EtherscanVerificationProvider { /// Create a source provider fn source_provider(&self, args: &VerifyArgs) -> Box { if args.flatten { @@ -252,34 +306,23 @@ impl EtherscanVerificationProvider { ) -> Result { let chain = etherscan_opts.chain.unwrap_or_default(); let etherscan_key = etherscan_opts.key(); - let verifier_type = &verifier_args.verifier; + let verifier_type = verifier_args.effective_type(); let verifier_url = verifier_args.verifier_url.as_deref(); // Verifier is etherscan if explicitly set or if no verifier set (default sourcify) but // API key passed. let is_etherscan = verifier_type.is_etherscan() || (verifier_type.is_sourcify() && etherscan_key.is_some()); - let etherscan_config = config.get_etherscan_config_with_chain(Some(chain))?; - - let api_version = verifier_args.verifier_api_version.unwrap_or_else(|| { - if is_etherscan { - etherscan_config.as_ref().map(|c| c.api_version).unwrap_or_default() - } else { - EtherscanApiVersion::V1 - } - }); - - let etherscan_api_url = verifier_url - .or_else(|| { - if api_version == EtherscanApiVersion::V2 { - None - } else { - etherscan_config.as_ref().map(|c| c.api_url.as_str()) - } - }) - .map(str::to_owned); + let etherscan_config = match config.get_etherscan_config_with_chain(Some(chain)) { + Ok(config) => config, + // The selected URL is authoritative, so an invalid lower-priority endpoint must not + // prevent creating the client. + Err(_) if verifier_url.is_some() => None, + Err(err) => return Err(err.into()), + }; - let api_url = etherscan_api_url.as_deref(); + let api_url = + verifier_url.or_else(|| etherscan_config.as_ref().map(|c| c.api_url.as_str())); let base_url = etherscan_config .as_ref() .and_then(|c| c.browser_url.as_deref()) @@ -287,23 +330,29 @@ impl EtherscanVerificationProvider { let etherscan_key = etherscan_key.or_else(|| etherscan_config.as_ref().map(|c| c.key.clone())); - let mut builder = Client::builder().with_api_version(api_version); + let mut builder = Client::builder(); builder = if let Some(api_url) = api_url { // we don't want any trailing slashes because this can cause cloudflare issues: let api_url = api_url.trim_end_matches('/'); - let base_url = if !is_etherscan { - // If verifier is not Etherscan then set base url as api url without /api suffix. - api_url.strip_prefix("/api").unwrap_or(api_url) - } else { + let base_url = if is_etherscan { base_url.unwrap_or(api_url) + } else { + // If verifier is not Etherscan then set base url as api url without /api suffix. + api_url.strip_suffix("/api").unwrap_or(api_url) }; - builder.with_chain_id(chain).with_api_url(api_url)?.with_url(base_url)? + builder.with_api_url(api_url)?.with_url(base_url)? } else { builder.chain(chain)? }; + let http = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .connect_timeout(Duration::from_secs(10)) + .build() + .wrap_err("Failed to create Etherscan HTTP client")?; builder + .with_client(http) .with_api_key(etherscan_key.unwrap_or_default()) .build() .wrap_err("Failed to create Etherscan client") @@ -349,6 +398,8 @@ impl EtherscanVerificationProvider { verify_args = verify_args.via_ir(true); } + apply_license_type(&mut verify_args, args.license_type.as_deref()); + if code_format == CodeFormat::SingleFile { verify_args = if let Some(optimizations) = args.num_of_optimizations { verify_args.optimized().runs(optimizations as u32) @@ -381,23 +432,16 @@ impl EtherscanVerificationProvider { args: &VerifyArgs, context: &VerificationContext, ) -> Result> { - if let Some(ref constructor_args_path) = args.constructor_args_path { + if let Some(constructor_args_path) = &args.constructor_args_path { let abi = context.get_target_abi()?; let constructor = abi .constructor() .ok_or_else(|| eyre!("Can't retrieve constructor info from artifact ABI."))?; - let func = Function { - name: "constructor".to_string(), - inputs: constructor.inputs.clone(), - outputs: vec![], - state_mutability: alloy_json_abi::StateMutability::NonPayable, - }; - let encoded_args = encode_function_args( - &func, - read_constructor_args_file(constructor_args_path.to_path_buf())?, + let values = encode_args( + &constructor.inputs, + read_constructor_args_file(constructor_args_path.clone())?, )?; - let encoded_args = hex::encode(encoded_args); - return Ok(Some(encoded_args[8..].into())); + return Ok(Some(hex::encode(constructor.abi_encode_input(&values)?))); } if args.guess_constructor_args { return Ok(Some(self.guess_constructor_args(args, context).await?)); @@ -429,13 +473,13 @@ impl EtherscanVerificationProvider { .ok_or_eyre("Couldn't fetch transaction receipt from RPC")?; let maybe_creation_code = if receipt.contract_address == Some(args.address) { - transaction.inner.inner.input() + transaction.input() } else if transaction.to() == Some(DEFAULT_CREATE2_DEPLOYER) { - &transaction.inner.inner.input()[32..] + &transaction.input()[32..] } else { eyre::bail!( "Fetching of constructor arguments is not supported for contracts created by contracts" - ) + ); }; let output = context.project.compile_file(&context.target_path)?; @@ -456,41 +500,69 @@ impl EtherscanVerificationProvider { if maybe_creation_code.starts_with(bytecode) { let constructor_args = &maybe_creation_code[bytecode.len()..]; let constructor_args = hex::encode(constructor_args); - sh_println!("Identified constructor arguments: {constructor_args}")?; + sh_status!("Identified constructor arguments: {constructor_args}")?; Ok(constructor_args) } else { - eyre::bail!("Local bytecode doesn't match on-chain bytecode") + eyre::bail!("Local bytecode doesn't match on-chain bytecode"); } } } -/// Given any solc [Version] return a [Version] with build metadata -/// -/// # Example -/// -/// ```ignore -/// use semver::{BuildMetadata, Version}; -/// let version = Version::new(1, 2, 3); -/// let version = ensure_solc_build_metadata(version).await?; -/// assert_ne!(version.build, BuildMetadata::EMPTY); -/// ``` -async fn ensure_solc_build_metadata(version: Version) -> Result { - if version.build != BuildMetadata::EMPTY { - Ok(version) - } else { - Ok(lookup_compiler_version(&version).await?) +fn sanitize_remote_message(message: &str) -> String { + message.chars().map(|ch| if ch.is_control() { ' ' } else { ch }).take(512).collect() +} + +fn apply_license_type(verify_args: &mut VerifyContract, license_type: Option<&str>) { + if let Some(license_type) = license_type { + verify_args.other.insert("licenseType".to_string(), license_type.to_string()); } } #[cfg(test)] mod tests { use super::*; - use crate::provider::VerificationProviderType; use clap::Parser; use foundry_common::fs; - use foundry_test_utils::{forgetest_async, str}; + use foundry_test_utils::{forgetest, str, util::SOLC_VERSION}; use tempfile::tempdir; + #[test] + fn external_request_preserves_raw_input_and_target() { + let args = + VerifyArgs::parse_from(["foundry-cli", "0xd8509bee9c9bf012282ad33aba0d87241baf5064"]); + let input = serde_json::json!({ + "language": "Solidity", + "settings": { "unknownSetting": { "futureField": true } }, + "unknownTopLevel": [1, 2, 3] + }); + let context = ExternalVerificationContext { + config: Config::default(), + compiler_version: "0.8.30+commit.73712a01".parse().unwrap(), + standard_json_input: std::sync::Arc::new(input.clone()), + target: "contracts/Unknown.sol:ExactTarget".to_string(), + }; + + let request = + EtherscanVerificationProvider.create_external_verify_request(&args, &context).unwrap(); + assert_eq!(serde_json::from_str::(&request.source).unwrap(), input); + assert_eq!(request.contract_name, "contracts/Unknown.sol:ExactTarget"); + assert_eq!(request.compiler_version, "v0.8.30+commit.73712a01"); + } + + #[test] + fn applies_license_type_to_verify_request() { + let mut verify_args = VerifyContract::new( + Default::default(), + "Counter".to_string(), + "contract Counter {}".to_string(), + "v0.8.23+commit.f704f362".to_string(), + ); + + apply_license_type(&mut verify_args, Some("13")); + + assert_eq!(verify_args.other.get("licenseType").map(String::as_str), Some("13")); + } + #[test] fn can_extract_etherscan_verify_config() { let temp = tempdir().unwrap(); @@ -500,7 +572,7 @@ mod tests { [profile.default] [etherscan] - mumbai = { key = "dummykey", chain = 80001, url = "https://api-testnet.polygonscan.com/" } + amoy = { key = "dummykey", chain = 80002, url = "https://amoy.polygonscan.com/" } "#; let toml_file = root.join(Config::FILE_NAME); @@ -511,7 +583,7 @@ mod tests { "0xd8509bee9c9bf012282ad33aba0d87241baf5064", "src/Counter.sol:Counter", "--chain", - "mumbai", + "amoy", "--root", root.as_os_str().to_str().unwrap(), ]); @@ -520,7 +592,8 @@ mod tests { let etherscan = EtherscanVerificationProvider::default(); let client = etherscan.client(&args.etherscan, &args.verifier, &config).unwrap(); - assert_eq!(client.etherscan_api_url().as_str(), "https://api-testnet.polygonscan.com/"); + // Custom URL from foundry.toml should be used + assert_eq!(client.etherscan_api_url().as_str(), "https://amoy.polygonscan.com/"); assert!(format!("{client:?}").contains("dummykey")); @@ -529,7 +602,7 @@ mod tests { "0xd8509bee9c9bf012282ad33aba0d87241baf5064", "src/Counter.sol:Counter", "--chain", - "mumbai", + "amoy", "--verifier-url", "https://verifier-url.com/", "--root", @@ -553,7 +626,7 @@ mod tests { [profile.default] [etherscan] - mumbai = { key = "dummykey", chain = 80001, url = "https://api-testnet.polygonscan.com/" } + amoy = { key = "dummykey", chain = 80002, url = "https://amoy.polygonscan.com/" } "#; let toml_file = root.join(Config::FILE_NAME); @@ -566,7 +639,7 @@ mod tests { "--verifier", "etherscan", "--chain", - "mumbai", + "amoy", "--root", root.as_os_str().to_str().unwrap(), ]); @@ -577,7 +650,8 @@ mod tests { let client = etherscan.client(&args.etherscan, &args.verifier, &config).unwrap(); - assert_eq!(client.etherscan_api_url().as_str(), "https://api.etherscan.io/v2/api"); + // Custom URL from foundry.toml should be used + assert_eq!(client.etherscan_api_url().as_str(), "https://amoy.polygonscan.com/"); assert!(format!("{client:?}").contains("dummykey")); let args: VerifyArgs = VerifyArgs::parse_from([ @@ -587,7 +661,7 @@ mod tests { "--verifier", "etherscan", "--chain", - "mumbai", + "amoy", "--verifier-url", "https://verifier-url.com/", "--root", @@ -596,12 +670,11 @@ mod tests { let config = args.load_config().unwrap(); - assert_eq!(args.verifier.verifier, VerificationProviderType::Etherscan); + assert_eq!(args.verifier.effective_type(), VerificationProviderType::Etherscan); let etherscan = EtherscanVerificationProvider::default(); let client = etherscan.client(&args.etherscan, &args.verifier, &config).unwrap(); assert_eq!(client.etherscan_api_url().as_str(), "https://verifier-url.com/"); - assert_eq!(*client.etherscan_api_version(), EtherscanApiVersion::V2); assert!(format!("{client:?}").contains("dummykey")); } @@ -642,9 +715,10 @@ mod tests { ); } - forgetest_async!(respects_path_for_duplicate, |prj, cmd| { - prj.add_source("Counter1", "contract Counter {}").unwrap(); - prj.add_source("Counter2", "contract Counter {}").unwrap(); + #[forgetest] + async fn respects_path_for_duplicate(prj: _, cmd: _) { + prj.add_source("Counter1", "contract Counter {}"); + prj.add_source("Counter2", "contract Counter {}"); cmd.args(["build", "--force"]).assert_success().stdout_eq(str![[r#" [COMPILING_FILES] with [SOLC_VERSION] @@ -665,5 +739,52 @@ Compiler run successful! let mut etherscan = EtherscanVerificationProvider::default(); etherscan.preflight_verify_check(args, context).await.unwrap(); - }); + } + + #[forgetest] + async fn encodes_constructor_args_from_file(prj: _) { + let args_path = prj.root().join("constructor-args.json"); + for (name, source, input, expected) in [ + ( + "Mixed", + "contract Mixed { constructor(uint256, string memory, uint256[] memory) {} }", + r#"["7", "hello", "[1,2]"]"#, + concat!( + "0000000000000000000000000000000000000000000000000000000000000007", + "0000000000000000000000000000000000000000000000000000000000000060", + "00000000000000000000000000000000000000000000000000000000000000a0", + "0000000000000000000000000000000000000000000000000000000000000005", + "68656c6c6f000000000000000000000000000000000000000000000000000000", + "0000000000000000000000000000000000000000000000000000000000000002", + "0000000000000000000000000000000000000000000000000000000000000001", + "0000000000000000000000000000000000000000000000000000000000000002", + ), + ), + ("Empty", "contract Empty { constructor() {} }", "[]", ""), + ] { + prj.add_source(name, source); + fs::write(&args_path, input).unwrap(); + let args = VerifyArgs::parse_from([ + "foundry-cli", + "0x0000000000000000000000000000000000000000", + &format!("src/{name}.sol:{name}"), + "--root", + &prj.root().to_string_lossy(), + "--constructor-args-path", + &args_path.to_string_lossy(), + "--compiler-version", + SOLC_VERSION, + ]); + let context = args.resolve_context().await.unwrap(); + let mut etherscan = EtherscanVerificationProvider::default(); + let encoded = etherscan.constructor_args(&args, &context).await.unwrap(); + assert_eq!(encoded.as_deref(), Some(expected)); + + if name == "Mixed" { + fs::write(&args_path, r#"["7"]"#).unwrap(); + let error = etherscan.constructor_args(&args, &context).await.unwrap_err(); + assert_eq!(error.to_string(), "encode length mismatch: expected 3 types, got 1"); + } + } + } } diff --git a/crates/verify/src/etherscan/standard_json.rs b/crates/verify/src/etherscan/standard_json.rs index 8a38485e48922..848d5808fd77b 100644 --- a/crates/verify/src/etherscan/standard_json.rs +++ b/crates/verify/src/etherscan/standard_json.rs @@ -2,11 +2,6 @@ use super::{EtherscanSourceProvider, VerifyArgs}; use crate::{provider::VerificationContext, verify::ContractLanguage}; use eyre::{Context, Result}; use foundry_block_explorers::verify::CodeFormat; -use foundry_compilers::{ - artifacts::{Source, StandardJsonCompilerInput, vyper::VyperInput}, - solc::SolcLanguage, -}; -use std::path::Path; #[derive(Debug)] pub struct EtherscanStandardJsonSource; @@ -16,12 +11,6 @@ impl EtherscanSourceProvider for EtherscanStandardJsonSource { args: &VerifyArgs, context: &VerificationContext, ) -> Result<(String, String, CodeFormat)> { - let mut input: StandardJsonCompilerInput = context - .project - .standard_json_input(&context.target_path) - .wrap_err("Failed to get standard json input")? - .normalize_evm_version(&context.compiler_version); - let lang = args.detect_language(context); let code_format = match lang { @@ -29,37 +18,13 @@ impl EtherscanSourceProvider for EtherscanStandardJsonSource { ContractLanguage::Vyper => CodeFormat::VyperJson, }; - let mut settings = context.compiler_settings.solc.settings.clone(); - settings.libraries.libs = input - .settings - .libraries - .libs - .into_iter() - .map(|(f, libs)| { - (f.strip_prefix(context.project.root()).unwrap_or(&f).to_path_buf(), libs) - }) - .collect(); - - settings.remappings = input.settings.remappings; - - // remove all incompatible settings - settings.sanitize(&context.compiler_version, SolcLanguage::Solidity); - - input.settings = settings; - let source = match lang { ContractLanguage::Solidity => { + let input = context.get_solc_standard_json_input()?; serde_json::to_string(&input).wrap_err("Failed to parse standard json input")? } ContractLanguage::Vyper => { - let path = Path::new(&context.target_path); - let sources = Source::read_all_from(path, &["vy", "vyi"])?; - let input = VyperInput::new( - sources, - context.clone().compiler_settings.vyper, - &context.compiler_version, - ); - + let input = context.get_vyper_standard_json_input()?; serde_json::to_string(&input).wrap_err("Failed to parse vyper json input")? } }; diff --git a/crates/verify/src/lib.rs b/crates/verify/src/lib.rs index a46fdba901550..181517ac4b21b 100644 --- a/crates/verify/src/lib.rs +++ b/crates/verify/src/lib.rs @@ -1,7 +1,7 @@ //! Smart contract verification. #![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] +#![cfg_attr(docsrs, feature(doc_cfg))] #[macro_use] extern crate foundry_common; @@ -19,10 +19,10 @@ pub use bytecode::VerifyBytecodeArgs; pub mod retry; pub use retry::RetryArgs; -mod sourcify; +pub mod sourcify; pub mod verify; -pub use verify::{VerifierArgs, VerifyArgs, VerifyCheckArgs}; +pub use verify::{VerifierArgs, VerifyArgs, VerifyCheckArgs, parse_etherscan_license_type}; mod types; diff --git a/crates/verify/src/provider.rs b/crates/verify/src/provider.rs index 95546011aa4a8..fb950746b2df1 100644 --- a/crates/verify/src/provider.rs +++ b/crates/verify/src/provider.rs @@ -5,18 +5,25 @@ use crate::{ }; use alloy_json_abi::JsonAbi; use async_trait::async_trait; -use eyre::{OptionExt, Result}; -use foundry_common::compile::ProjectCompiler; +use eyre::{Context, Result}; +use foundry_common::compile::compile_target_abi; use foundry_compilers::{ - Graph, Project, - artifacts::{Metadata, Source, output_selection::OutputSelection}, - compilers::{multi::MultiCompilerParsedSource, solc::SolcCompiler}, + Project, + artifacts::{Source, StandardJsonCompilerInput, vyper::VyperInput}, + compilers::solc::SolcCompiler, multi::MultiCompilerSettings, - solc::Solc, + solc::{Solc, SolcLanguage}, }; -use foundry_config::Config; +use foundry_config::{Chain, Config, EtherscanConfigError}; use semver::Version; -use std::{fmt, path::PathBuf, str::FromStr}; +use std::{ + fmt, + future::Future as StdFuture, + path::{Path, PathBuf}, + pin::Pin, + str::FromStr, + sync::Arc, +}; /// Container with data required for contract verification. #[derive(Debug, Clone)] @@ -29,6 +36,15 @@ pub struct VerificationContext { pub compiler_settings: MultiCompilerSettings, } +/// Caller-provided source material used to verify a contract. +#[derive(Debug, Clone)] +pub struct ExternalVerificationContext { + pub config: Config, + pub compiler_version: Version, + pub standard_json_input: Arc, + pub target: String, +} + impl VerificationContext { pub fn new( target_path: PathBuf, @@ -46,58 +62,51 @@ impl VerificationContext { Ok(Self { config, project, target_name, target_path, compiler_version, compiler_settings }) } - /// Compiles target contract requesting only ABI and returns it. - pub fn get_target_abi(&self) -> Result { - let mut project = self.project.clone(); - project.update_output_selection(|selection| { - *selection = OutputSelection::common_output_selection(["abi".to_string()]) - }); + pub fn get_solc_standard_json_input(&self) -> Result { + let mut input: StandardJsonCompilerInput = self + .project + .standard_json_input(&self.target_path) + .wrap_err("Failed to get standard json input")? + .normalize_evm_version(&self.compiler_version); - let output = ProjectCompiler::new() - .quiet(true) - .files([self.target_path.clone()]) - .compile(&project)?; + let mut settings = self.compiler_settings.solc.settings.clone(); + settings.libraries.libs = input + .settings + .libraries + .libs + .into_iter() + .map(|(f, libs)| { + (f.strip_prefix(self.project.root()).unwrap_or(&f).to_path_buf(), libs) + }) + .collect(); - let artifact = output - .find(&self.target_path, &self.target_name) - .ok_or_eyre("failed to find target artifact when compiling for abi")?; + settings.remappings = input.settings.remappings; + settings.sanitize(&self.compiler_version, SolcLanguage::Solidity); + input.settings = settings; - artifact.abi.clone().ok_or_eyre("target artifact does not have an ABI") + Ok(input) } - /// Compiles target file requesting only metadata and returns it. - pub fn get_target_metadata(&self) -> Result { - let mut project = self.project.clone(); - project.update_output_selection(|selection| { - *selection = OutputSelection::common_output_selection(["metadata".to_string()]); - }); - - let output = ProjectCompiler::new() - .quiet(true) - .files([self.target_path.clone()]) - .compile(&project)?; - - let artifact = output - .find(&self.target_path, &self.target_name) - .ok_or_eyre("failed to find target artifact when compiling for metadata")?; - - artifact.metadata.clone().ok_or_eyre("target artifact does not have an ABI") + /// Creates Vyper standard JSON input for verification. + pub fn get_vyper_standard_json_input(&self) -> Result { + let path = Path::new(&self.target_path); + let sources = Source::read_all_from(path, &["vy", "vyi"])?; + Ok(VyperInput::new(sources, self.compiler_settings.vyper.clone(), &self.compiler_version)) } - /// Returns [Vec] containing imports of the target file. - pub fn get_target_imports(&self) -> Result> { - let mut sources = self.project.paths.read_input_files()?; - sources.insert(self.target_path.clone(), Source::read(&self.target_path)?); - let graph = - Graph::::resolve_sources(&self.project.paths, sources)?; - - Ok(graph.imports(&self.target_path).into_iter().map(Into::into).collect()) + /// Compiles target contract requesting only ABI and returns it. + pub fn get_target_abi(&self) -> Result { + let mut project = self.project.clone(); + compile_target_abi(&mut project, &self.target_path, &self.target_name) } } /// An abstraction for various verification providers such as etherscan, sourcify, blockscout #[async_trait] pub trait VerificationProvider { + /// Returns the provider type, used to assert the selected provider in tests. + fn provider_type(&self) -> VerificationProviderType; + /// This should ensure the verify request can be prepared successfully. /// /// Caution: Implementers must ensure that this _never_ sends the actual verify request @@ -111,8 +120,38 @@ pub trait VerificationProvider { context: VerificationContext, ) -> Result<()>; - /// Sends the actual verify request for the targeted contract. - async fn verify(&mut self, args: VerifyArgs, context: VerificationContext) -> Result<()>; + /// Submits the verification request for the targeted contract. + /// + /// Returns `Some(check_args)` if a follow-up status check is possible (the request was + /// accepted by the provider), or `None` if the submission was a no-op (e.g. the contract + /// was already verified). + async fn submit( + &mut self, + args: VerifyArgs, + context: VerificationContext, + ) -> Result>; + + /// Submits caller-provided Standard JSON input. + fn submit_external( + &mut self, + _args: VerifyArgs, + _context: ExternalVerificationContext, + ) -> Pin>> + '_>> { + Box::pin(async { + Err(eyre::eyre!("external verification is unsupported by this provider")) + }) + } + + /// Convenience wrapper: [`Self::submit`]s and, if `args.watch` is set, polls + /// [`Self::check`] until completion. + async fn verify(&mut self, args: VerifyArgs, context: VerificationContext) -> Result<()> { + let watch = args.watch; + let check_args = self.submit(args, context).await?; + if watch && let Some(check_args) = check_args { + return self.check(check_args).await; + } + Ok(()) + } /// Checks whether the contract is verified. async fn check(&self, args: VerifyCheckArgs) -> Result<()>; @@ -156,7 +195,7 @@ impl fmt::Display for VerificationProviderType { } } -#[derive(Clone, Debug, Default, PartialEq, Eq, clap::ValueEnum)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)] pub enum VerificationProviderType { Etherscan, #[default] @@ -168,48 +207,184 @@ pub enum VerificationProviderType { } impl VerificationProviderType { - /// Returns the corresponding `VerificationProvider` for the key - pub fn client(&self, key: Option<&str>) -> Result> { - let has_key = key.as_ref().is_some_and(|k| !k.is_empty()); - // 1. If no verifier or `--verifier sourcify` is set and no API key provided, use Sourcify. - if !has_key && self.is_sourcify() { - sh_println!( - "Attempting to verify on Sourcify. Pass the --etherscan-api-key to verify on Etherscan, \ - or use the --verifier flag to verify on another provider." - )?; + /// Returns the corresponding `VerificationProvider` for the key. + /// + /// `is_explicit` should be `true` when the user explicitly passed `--verifier`; `false` when + /// the value is the default (Sourcify). An explicit flag always takes precedence over the + /// `ETHERSCAN_API_KEY` environment variable. + pub fn client( + &self, + key: Option<&str>, + chain: Option, + has_url: bool, + is_explicit: bool, + ) -> Result> { + let has_key = key.is_some_and(|k| !k.is_empty()); + + // 1. Explicit `--verifier sourcify` always wins over ETHERSCAN_API_KEY. + if is_explicit && self.is_sourcify() { return Ok(Box::::default()); } - // 2. If `--verifier etherscan` is explicitly set, enforce the API key requirement. + // 2. `--verifier etherscan` (explicit): check chain support and require key. if self.is_etherscan() { + if let Some(chain) = chain + && (chain.etherscan_urls().is_none() || chain.is_custom_sourcify()) + && !has_url + { + eyre::bail!(EtherscanConfigError::UnknownChain( + "when using Etherscan verifier".to_string(), + chain + )); + } if !has_key { - eyre::bail!("ETHERSCAN_API_KEY must be set to use Etherscan as a verifier") + eyre::bail!("ETHERSCAN_API_KEY must be set to use Etherscan as a verifier"); } return Ok(Box::::default()); } - // 3. If `--verifier blockscout | oklink | custom` is explicitly set, use the chosen - // verifier. - if matches!(self, Self::Blockscout | Self::Oklink | Self::Custom) { + // 3. Explicit `--verifier blockscout | oklink | custom`: require a URL. + if is_explicit && matches!(self, Self::Blockscout | Self::Oklink | Self::Custom) { + if !has_url { + eyre::bail!("No verifier URL specified for verifier {}", self); + } return Ok(Box::::default()); } - // 4. If no `--verifier` is specified but `ETHERSCAN_API_KEY` is set, default to Etherscan. + // 4. No explicit `--verifier` but ETHERSCAN_API_KEY is set: prefer Etherscan when the chain + // is supported; otherwise warn and fall through to the Sourcify default. See . + // Custom-Sourcify chains (e.g. Tempo) register Sourcify-compatible URLs under + // etherscan_urls() and are hard-excluded here regardless of whether a URL is present. if has_key { - return Ok(Box::::default()); + if let Some(chain) = chain + && (chain.is_custom_sourcify() || chain.etherscan_urls().is_none() && !has_url) + { + if chain.is_custom_sourcify() { + sh_warn!( + "ETHERSCAN_API_KEY is set but chain {chain} uses a Sourcify-compatible \ + API. Falling back to Sourcify. Pass `--verifier sourcify` to suppress \ + this warning." + )?; + } else { + sh_warn!( + "ETHERSCAN_API_KEY is set but chain {chain} has no known Etherscan API \ + URL. Falling back to Sourcify. Pass --verifier-url or \ + `--verifier ` to override." + )?; + } + // Fall through to branch 5 (Sourcify default) below. + } else { + return Ok(Box::::default()); + } + } + + // 5. No key, no explicit verifier: default to Sourcify. + if self.is_sourcify() { + return Ok(Box::::default()); } - // 5. If no valid provider is specified, bail. + // 6. No valid provider. eyre::bail!( "No valid verification provider specified. Pass the --verifier flag to specify a provider or set the ETHERSCAN_API_KEY environment variable to use Etherscan as a verifier." - ) + ); } - pub fn is_sourcify(&self) -> bool { + pub const fn is_sourcify(&self) -> bool { matches!(self, Self::Sourcify) } - pub fn is_etherscan(&self) -> bool { + pub const fn is_etherscan(&self) -> bool { matches!(self, Self::Etherscan) } + + pub const fn is_custom(&self) -> bool { + matches!(self, Self::Custom) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn etherscan_allows_unknown_chain_with_verifier_url() { + let chain = Chain::from(3658348u64); + let provider = VerificationProviderType::Etherscan + .client(Some("key"), Some(chain), true, true) + .unwrap(); + assert_eq!(provider.provider_type(), VerificationProviderType::Etherscan); + } + + #[test] + fn etherscan_rejects_unknown_chain_without_verifier_url() { + let chain = Chain::from(3658348u64); + let res = VerificationProviderType::Etherscan.client(Some("key"), Some(chain), false, true); + match res { + Ok(_) => panic!("expected unknown-chain error"), + Err(err) => { + assert!(err.to_string().contains("No known Etherscan API URL")); + } + } + } + + // Regression: explicit --verifier etherscan on a custom-Sourcify chain (e.g. Tempo) without + // --verifier-url must be rejected even though etherscan_urls() returns Some for the chain. + #[test] + fn explicit_etherscan_on_custom_sourcify_chain_without_url_bails() { + let tempo = Chain::from(4217u64); // NamedChain::Tempo + let res = VerificationProviderType::Etherscan.client(Some("key"), Some(tempo), false, true); + assert!(res.is_err(), "expected error for Etherscan on custom-Sourcify chain w/o URL"); + } + + // Custom-Sourcify chain with an explicit --verifier-url is allowed for Etherscan. + #[test] + fn explicit_etherscan_on_custom_sourcify_chain_with_url_is_ok() { + let tempo = Chain::from(4217u64); + let provider = VerificationProviderType::Etherscan + .client(Some("key"), Some(tempo), true, true) + .unwrap(); + assert_eq!(provider.provider_type(), VerificationProviderType::Etherscan); + } + + // Implicit ETHERSCAN_API_KEY on a supported chain selects Etherscan; on a custom-Sourcify + // chain it must fall back to Sourcify regardless of whether a --verifier-url is present. + #[test] + fn implicit_etherscan_custom_sourcify_chain_falls_back_to_sourcify() { + // Baseline: implicit key on a normal chain -> Etherscan. + let provider = VerificationProviderType::Sourcify + .client(Some("mykey"), Some(Chain::mainnet()), false, false) + .unwrap(); + assert_eq!(provider.provider_type(), VerificationProviderType::Etherscan); + + // Custom-Sourcify chain without URL -> Sourcify. + let provider = VerificationProviderType::Sourcify + .client(Some("mykey"), Some(Chain::from(4217u64)), false, false) + .expect("expected fallback to Sourcify, got error"); + assert_eq!(provider.provider_type(), VerificationProviderType::Sourcify); + + // Custom-Sourcify chain with URL -> still Sourcify (URL does not override the exclusion). + let provider = VerificationProviderType::Sourcify + .client(Some("mykey"), Some(Chain::from(4217u64)), true, false) + .expect("expected fallback to Sourcify, got error"); + assert_eq!(provider.provider_type(), VerificationProviderType::Sourcify); + } + + // Regression test for : + // when --verifier is not set, ETHERSCAN_API_KEY is set, but the chain has no known + // Etherscan API URL, `client()` must NOT bail; it should warn and fall back to Sourcify. + // (Behavior is verified more strictly via `VerifierArgs::resolve` tests in `verify.rs`.) + #[test] + fn implicit_etherscan_unknown_chain_falls_back_to_sourcify() { + // Baseline: implicit key on a normal chain -> Etherscan. + let provider = VerificationProviderType::Sourcify + .client(Some("mykey"), Some(Chain::mainnet()), false, false) + .unwrap(); + assert_eq!(provider.provider_type(), VerificationProviderType::Etherscan); + + // Unknown chain: same call must fall back to Sourcify, not bail. + let provider = VerificationProviderType::Sourcify + .client(Some("mykey"), Some(Chain::from(3658348u64)), false, false) + .expect("expected fallback to Sourcify, got error"); + assert_eq!(provider.provider_type(), VerificationProviderType::Sourcify); + } } diff --git a/crates/verify/src/retry.rs b/crates/verify/src/retry.rs index 4bc934149175f..7df2826ed8714 100644 --- a/crates/verify/src/retry.rs +++ b/crates/verify/src/retry.rs @@ -37,7 +37,7 @@ impl Default for RetryArgs { impl RetryArgs { /// Converts the arguments into a `Retry` instance. - pub fn into_retry(self) -> Retry { + pub const fn into_retry(self) -> Retry { Retry::new(self.retries, Duration::from_secs(self.delay as u64)) } } diff --git a/crates/verify/src/sourcify.rs b/crates/verify/src/sourcify.rs index a57335d39398b..80727a3bf2b0b 100644 --- a/crates/verify/src/sourcify.rs +++ b/crates/verify/src/sourcify.rs @@ -1,15 +1,20 @@ use crate::{ - provider::{VerificationContext, VerificationProvider}, - verify::{VerifyArgs, VerifyCheckArgs}, + provider::{ + ExternalVerificationContext, VerificationContext, VerificationProvider, + VerificationProviderType, + }, + utils::ensure_solc_build_metadata, + verify::{ContractLanguage, VerifyArgs, VerifyCheckArgs, parse_http_verifier_url}, }; -use alloy_primitives::map::HashMap; +use alloy_primitives::Address; use async_trait::async_trait; -use eyre::Result; -use foundry_common::fs; -use futures::FutureExt; -use reqwest::Url; +use eyre::{Context, Result, eyre}; +use foundry_common::retry::RetryError; +use futures::{FutureExt, StreamExt}; +use reqwest::StatusCode; use serde::{Deserialize, Serialize}; -use std::str::FromStr; +use std::{future::Future as StdFuture, pin::Pin, time::Duration}; +use url::Url; pub static SOURCIFY_URL: &str = "https://sourcify.dev/server/"; @@ -20,182 +25,629 @@ pub struct SourcifyVerificationProvider; #[async_trait] impl VerificationProvider for SourcifyVerificationProvider { + fn provider_type(&self) -> VerificationProviderType { + VerificationProviderType::Sourcify + } + async fn preflight_verify_check( &mut self, args: VerifyArgs, context: VerificationContext, ) -> Result<()> { - let _ = self.prepare_request(&args, &context)?; + let _ = self.prepare_verify_request(&args, &context).await?; Ok(()) } - async fn verify(&mut self, args: VerifyArgs, context: VerificationContext) -> Result<()> { - let body = self.prepare_request(&args, &context)?; + async fn submit( + &mut self, + args: VerifyArgs, + context: VerificationContext, + ) -> Result> { + let body = self.prepare_verify_request(&args, &context).await?; + self.submit_verify_request(args, body, &context.target_name).await + } + + fn submit_external( + &mut self, + args: VerifyArgs, + context: ExternalVerificationContext, + ) -> Pin>> + '_>> { + Box::pin(async move { + let target = context.target.clone(); + let body = Self::prepare_external_verify_request(&args, context); + self.submit_verify_request(args, body, &target).await + }) + } + + async fn check(&self, args: VerifyCheckArgs) -> Result<()> { + let url = Self::get_job_status_url(args.verifier.verifier_url.as_deref(), args.id.clone())?; + + args.retry + .into_retry() + .run_async_until_break(|| async { + let response = verification_client() + .map_err(RetryError::Break)? + .get(&url) + .send() + .await + .wrap_err("Failed to request verification status") + .map_err(RetryError::Retry)?; + + if response.status() == StatusCode::NOT_FOUND { + return Err(RetryError::Break(eyre!( + "No verification job found for ID {}", + sanitize_remote_message(&args.id) + ))); + } + + if !response.status().is_success() { + return Err(RetryError::Retry(eyre!( + "Failed to request verification status with status code {}", + response.status() + ))); + } + + let job_response: SourcifyJobResponse = serde_json::from_slice( + &read_capped_body(response).await.map_err(RetryError::Retry)?, + ) + .wrap_err("Failed to parse job response") + .map_err(RetryError::Retry)?; + + if !job_response.is_job_completed { + return Err(RetryError::Retry(eyre!("Verification is still pending..."))); + } + + if let Some(error) = job_response.error { + if error.custom_code == "already_verified" { + let _ = sh_status!("Contract source code already verified"); + return Ok(()); + } + + return Err(RetryError::Break(eyre!( + "Verification job failed:\nError Code: `{}`\nMessage: `{}`", + sanitize_remote_message(&error.custom_code), + sanitize_remote_message(&error.message) + ))); + } + + if let Some(contract_status) = + job_response.contract.and_then(|contract| contract.match_status) + { + let _ = sh_status!( + "Contract successfully verified:\nStatus: `{}`", + sanitize_remote_message(&contract_status), + ); + } + Ok(()) + }) + .await + .wrap_err("Checking verification result failed") + } +} + +fn sanitize_remote_message(message: &str) -> String { + message.chars().map(|ch| if ch.is_control() { ' ' } else { ch }).take(512).collect() +} + +const MAX_RESPONSE_BODY: usize = 1024 * 1024; + +fn verification_client() -> Result { + reqwest::Client::builder() + .user_agent(foundry_common::DEFAULT_USER_AGENT) + .timeout(Duration::from_secs(30)) + .connect_timeout(Duration::from_secs(10)) + .build() + .wrap_err("Failed to create Sourcify HTTP client") +} + +async fn read_capped_body(response: reqwest::Response) -> Result> { + let mut body = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + eyre::ensure!( + body.len().saturating_add(chunk.len()) <= MAX_RESPONSE_BODY, + "Sourcify response body exceeds 1 MiB" + ); + body.extend_from_slice(&chunk); + } + Ok(body) +} - trace!("submitting verification request {:?}", body); +impl SourcifyVerificationProvider { + fn prepare_external_verify_request( + args: &VerifyArgs, + context: ExternalVerificationContext, + ) -> SourcifyVerifyRequest { + SourcifyVerifyRequest { + std_json_input: (*context.standard_json_input).clone(), + compiler_version: context.compiler_version.to_string(), + contract_identifier: context.target, + creation_transaction_hash: args.creation_transaction_hash.map(|hash| hash.to_string()), + } + } - let client = reqwest::Client::new(); + async fn submit_verify_request( + &self, + args: VerifyArgs, + body: SourcifyVerifyRequest, + target: &str, + ) -> Result> { + if !args.skip_is_verified_check && self.is_contract_verified(&args).await? { + sh_status!( + "Contract [{}] {:?} is already verified. Skipping verification.", + target, + args.address.to_string() + )?; + + return Ok(None); + } + + trace!(provider = "Sourcify", address = %args.address, target, "submitting verification request"); + + let chain_id = args.etherscan.chain.unwrap_or_default().id(); + let url = + Self::get_verify_url(args.verifier.verifier_url.as_deref(), chain_id, args.address)?; + let client = verification_client()?; let resp = args .retry .into_retry() .run_async(|| { async { - sh_println!( - "\nSubmitting verification for [{}] {:?}.", - context.target_name, - args.address.to_string() - )?; + sh_status!("Submitting verification for [{}] {}.", target, args.address)?; let response = client - .post(args.verifier.verifier_url.as_deref().unwrap_or(SOURCIFY_URL)) + .post(&url) .header("Content-Type", "application/json") .body(serde_json::to_string(&body)?) .send() .await?; let status = response.status(); - if !status.is_success() { - let error: serde_json::Value = response.json().await?; - eyre::bail!( - "Sourcify verification request for address ({}) \ - failed with status code {status}\n\ - Details: {error:#}", - args.address, - ); + match status { + StatusCode::CONFLICT => { + sh_status!("Contract source code already fully verified")?; + Ok(None) + } + StatusCode::ACCEPTED => { + let text = read_capped_body(response).await?; + let verify_response: SourcifyVerificationResponse = + serde_json::from_slice(&text) + .wrap_err("Failed to parse Sourcify verification response")?; + Ok(Some(verify_response)) + } + _ => { + eyre::bail!( + "Sourcify verification request for address ({}) failed with status code {status}", + args.address, + ); + } } - - let text = response.text().await?; - Ok(Some(serde_json::from_str::(&text)?)) } .boxed() }) .await?; - self.process_sourcify_response(resp.map(|r| r.result)) + if let Some(resp) = resp { + eyre::ensure!( + !resp.verification_id.is_empty() + && resp.verification_id.len() <= 512 + && !matches!(resp.verification_id.as_str(), "." | ".."), + "Sourcify returned an invalid verification job ID" + ); + let display_id = sanitize_remote_message(&resp.verification_id); + let job_url = Self::get_job_ui_url( + args.verifier.verifier_url.as_deref(), + resp.verification_id.clone(), + )?; + sh_status!( + "Submitted contract for verification:\n\tVerification Job ID: `{}`\n\tURL: {}", + display_id, + job_url + )?; + if args.print_submission_result_to_stdout { + sh_println!("{}\t{}", display_id, job_url)?; + } + Ok(Some(VerifyCheckArgs { + id: resp.verification_id, + etherscan: args.etherscan, + retry: args.retry, + verifier: args.verifier, + })) + } else { + Ok(None) + } } - async fn check(&self, args: VerifyCheckArgs) -> Result<()> { - let resp = args - .retry - .into_retry() - .run_async(|| { - async { - let url = Url::from_str( - args.verifier.verifier_url.as_deref().unwrap_or(SOURCIFY_URL), - )?; - let query = format!( - "check-by-addresses?addresses={}&chainIds={}", - args.id, - args.etherscan.chain.unwrap_or_default().id(), - ); - let url = url.join(&query)?; - let response = reqwest::get(url).await?; - if !response.status().is_success() { - eyre::bail!( - "Failed to request verification status with status code {}", - response.status() - ); - }; - - Ok(Some(response.json::>().await?)) - } - .boxed() - }) - .await?; + /// Returns the Sourcify base URL, failing on an invalid explicit URL instead of falling back + /// to the public endpoint. + fn get_base_url(verifier_url: Option<&str>) -> Result { + let mut url = parse_http_verifier_url(verifier_url.unwrap_or(SOURCIFY_URL), "Sourcify")?; + if !url.path().ends_with('/') { + url.set_path(&format!("{}/", url.path())); + } + Ok(url) + } - self.process_sourcify_response(resp) + fn get_verify_url( + verifier_url: Option<&str>, + chain_id: u64, + contract_address: Address, + ) -> Result { + let base_url = Self::get_base_url(verifier_url)?; + Ok(format!("{base_url}v2/verify/{chain_id}/{contract_address}")) + } + + fn get_job_status_url(verifier_url: Option<&str>, job_id: String) -> Result { + Self::get_job_url(verifier_url, &["v2", "verify"], &job_id) + } + + fn get_job_ui_url(verifier_url: Option<&str>, job_id: String) -> Result { + Self::get_job_url(verifier_url, &["verify-ui", "jobs"], &job_id) + } + + fn get_job_url(verifier_url: Option<&str>, path: &[&str], job_id: &str) -> Result { + let base_url = Self::get_base_url(verifier_url)?; + let job_id = encode_path_segment(job_id); + Ok(format!("{base_url}{}/{job_id}", path.join("/"))) + } + + fn get_lookup_url( + verifier_url: Option<&str>, + chain_id: u64, + contract_address: Address, + ) -> Result { + let base_url = Self::get_base_url(verifier_url)?; + Ok(format!("{base_url}v2/contract/{chain_id}/{contract_address}")) } -} -impl SourcifyVerificationProvider { /// Configures the API request to the sourcify API using the given [`VerifyArgs`]. - fn prepare_request( + async fn prepare_verify_request( &self, args: &VerifyArgs, context: &VerificationContext, ) -> Result { - let metadata = context.get_target_metadata()?; - let imports = context.get_target_imports()?; + // Reject an invalid explicit URL before preparing any source. + Self::get_base_url(args.verifier.verifier_url.as_deref())?; + let lang = args.detect_language(context); + let contract_identifier = format!( + "{}:{}", + context + .target_path + .strip_prefix(context.project.root()) + .unwrap_or(context.target_path.as_path()) + .display(), + context.target_name + ); + let creation_transaction_hash = args.creation_transaction_hash.map(|h| h.to_string()); - let mut files = HashMap::with_capacity_and_hasher(2 + imports.len(), Default::default()); + match lang { + ContractLanguage::Solidity => { + let input = context.get_solc_standard_json_input()?; - let metadata = serde_json::to_string_pretty(&metadata)?; - files.insert("metadata.json".to_string(), metadata); + let std_json_input = serde_json::to_value(&input) + .wrap_err("Failed to serialize standard json input")?; + let compiler_version = + ensure_solc_build_metadata(context.compiler_version.clone()).await?.to_string(); - let contract_path = context.target_path.clone(); - let filename = contract_path.file_name().unwrap().to_string_lossy().to_string(); - files.insert(filename, fs::read_to_string(&contract_path)?); + Ok(SourcifyVerifyRequest { + std_json_input, + compiler_version, + contract_identifier, + creation_transaction_hash, + }) + } + ContractLanguage::Vyper => { + let input = context.get_vyper_standard_json_input()?; + let std_json_input = serde_json::to_value(&input) + .wrap_err("Failed to serialize vyper json input")?; - for import in imports { - let import_entry = format!("{}", import.display()); - files.insert(import_entry, fs::read_to_string(&import)?); + let compiler_version = context.compiler_version.to_string(); + + Ok(SourcifyVerifyRequest { + std_json_input, + compiler_version, + contract_identifier, + creation_transaction_hash, + }) + } } + } - let req = SourcifyVerifyRequest { - address: args.address.to_string(), - chain: args.etherscan.chain.unwrap_or_default().id().to_string(), - files, - chosen_contract: None, - }; + async fn is_contract_verified(&self, args: &VerifyArgs) -> Result { + let chain_id = args.etherscan.chain.unwrap_or_default().id(); + let url = + Self::get_lookup_url(args.verifier.verifier_url.as_deref(), chain_id, args.address)?; - Ok(req) - } + match verification_client()?.get(&url).send().await { + Ok(response) => { + if response.status().is_success() { + let contract_response: SourcifyContractResponse = + serde_json::from_slice(&read_capped_body(response).await?) + .wrap_err("Failed to parse contract response")?; - fn process_sourcify_response( - &self, - response: Option>, - ) -> Result<()> { - let Some([response, ..]) = response.as_deref() else { return Ok(()) }; - match response.status.as_str() { - "perfect" => { - if let Some(ts) = &response.storage_timestamp { - sh_println!("Contract source code already verified. Storage Timestamp: {ts}")?; + let creation_exact = contract_response + .creation_match + .as_ref() + .is_some_and(|s| s == "exact_match"); + + let runtime_exact = contract_response + .runtime_match + .as_ref() + .is_some_and(|s| s == "exact_match"); + + Ok(creation_exact && runtime_exact) } else { - sh_println!("Contract successfully verified")?; + Ok(false) } } - "partial" => { - sh_println!("The recompiled contract partially matches the deployed version")?; - } - "false" => sh_println!("Contract source code is not verified")?, - s => eyre::bail!("Unknown status from sourcify. Status: {s:?}"), + Err(error) => Err(error).wrap_err_with(|| { + format!("Failed to query verification status for {}", args.address) + }), + } + } +} + +fn encode_path_segment(value: &str) -> String { + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + let mut encoded = String::with_capacity(value.len()); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'~') { + encoded.push(char::from(byte)); + } else { + encoded.push('%'); + encoded.push(char::from(HEX[(byte >> 4) as usize])); + encoded.push(char::from(HEX[(byte & 0x0f) as usize])); } - Ok(()) } + encoded } #[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] pub struct SourcifyVerifyRequest { - address: String, - chain: String, - files: HashMap, - #[serde(rename = "chosenContract", skip_serializing_if = "Option::is_none")] - chosen_contract: Option, + std_json_input: serde_json::Value, + compiler_version: String, + contract_identifier: String, + #[serde(skip_serializing_if = "Option::is_none")] + creation_transaction_hash: Option, } #[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct SourcifyVerificationResponse { - result: Vec, + verification_id: String, } #[derive(Debug, Deserialize)] -pub struct SourcifyResponseElement { - status: String, - #[serde(rename = "storageTimestamp")] - storage_timestamp: Option, +#[serde(rename_all = "camelCase")] +pub struct SourcifyJobResponse { + is_job_completed: bool, + contract: Option, + error: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SourcifyContractResponse { + #[serde(rename = "match")] + match_status: Option, + creation_match: Option, + runtime_match: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SourcifyErrorResponse { + custom_code: String, + message: String, } #[cfg(test)] mod tests { use super::*; + use clap::Parser; + use foundry_config::Config; + use foundry_test_utils::{forgetest, util::SOLC_VERSION}; + use serde_json::json; + use std::{ + io::{Read, Write}, + net::TcpListener, + thread, + }; #[test] - fn test_check_addresses_url() { - let url = Url::from_str("https://server-verify.hashscan.io").unwrap(); - let url = url.join("check-by-addresses?addresses=0x1234&chainIds=1").unwrap(); - assert_eq!( - url.as_str(), - "https://server-verify.hashscan.io/check-by-addresses?addresses=0x1234&chainIds=1" - ); + fn external_request_preserves_raw_input_and_target() { + let args = + VerifyArgs::parse_from(["foundry-cli", "0xd8509bee9c9bf012282ad33aba0d87241baf5064"]); + let input = json!({ + "language": "Solidity", + "settings": { "unknownSetting": { "futureField": true } }, + "unknownTopLevel": [1, 2, 3] + }); + let context = ExternalVerificationContext { + config: Config::default(), + compiler_version: "0.8.30+commit.73712a01".parse().unwrap(), + standard_json_input: std::sync::Arc::new(input.clone()), + target: "contracts/Unknown.sol:ExactTarget".to_string(), + }; + + let request = SourcifyVerificationProvider::prepare_external_verify_request(&args, context); + assert_eq!(request.std_json_input, input); + assert_eq!(request.contract_identifier, "contracts/Unknown.sol:ExactTarget"); + assert_eq!(request.compiler_version, "0.8.30+commit.73712a01"); + } + + #[test] + fn job_urls_encode_opaque_ids_as_one_path_segment() { + let id = "job a+b/part?query#fragment\n".to_string(); + let status = SourcifyVerificationProvider::get_job_status_url(None, id.clone()).unwrap(); + let ui = SourcifyVerificationProvider::get_job_ui_url(None, id).unwrap(); + let encoded = "job%20a%2Bb%2Fpart%3Fquery%23fragment%0A"; + assert!(status.ends_with(&format!("v2/verify/{encoded}")), "{status}"); + assert!(ui.ends_with(&format!("verify-ui/jobs/{encoded}")), "{ui}"); + assert_eq!(encode_path_segment("."), "%2E"); + assert_eq!(encode_path_segment(".."), "%2E%2E"); + } + + #[tokio::test] + async fn check_reports_error_with_null_contract() { + let body = json!({ + "isJobCompleted": true, + "contract": null, + "error": { + "customCode": "internal_error", + "message": "too many connections from this IP" + } + }) + .to_string(); + let server = TcpListener::bind("127.0.0.1:0").unwrap(); + let server_url = format!("http://{}/server", server.local_addr().unwrap()); + let server_thread = thread::spawn(move || { + let (mut socket, _) = server.accept().unwrap(); + let mut request = [0; 4096]; + let bytes_read = socket.read(&mut request).unwrap(); + assert!( + std::str::from_utf8(&request[..bytes_read]) + .unwrap() + .starts_with("GET /server/v2/verify/job-id ") + ); + socket + .write_all( + format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .unwrap(); + }); + + let args = VerifyCheckArgs::parse_from([ + "foundry-cli", + "job-id", + "--verifier-url", + &server_url, + "--retries", + "1", + "--delay", + "0", + ]); + let error = SourcifyVerificationProvider::default().check(args).await.unwrap_err(); + server_thread.join().unwrap(); + + let error = format!("{error:?}"); + assert!(error.contains("Error Code: `internal_error`"), "{error}"); + assert!(error.contains("Message: `too many connections from this IP`"), "{error}"); + } + + #[tokio::test] + async fn check_rejects_invalid_explicit_url_instead_of_public_fallback() { + for (verifier_url, expected) in [ + ("127.0.0.1:5555", "invalid Sourcify URL: relative URL without a base"), + ("", "invalid Sourcify URL: relative URL without a base"), + ( + "https://user:password-secret@example.invalid:bad/server?api_key=query-secret", + "invalid Sourcify URL: invalid port number", + ), + ( + "ftp://user:password-secret@example.invalid/server?api_key=query-secret", + "invalid Sourcify URL: URL scheme must be http or https", + ), + ] { + let args = VerifyCheckArgs::parse_from([ + "foundry-cli", + "job-id", + "--verifier-url", + verifier_url, + "--retries", + "1", + "--delay", + "0", + ]); + let error = SourcifyVerificationProvider::default().check(args).await.unwrap_err(); + assert_eq!(format!("{error:#}"), expected); + } + } + + #[tokio::test] + async fn verification_client_follows_redirects() { + let target = TcpListener::bind("127.0.0.1:0").unwrap(); + let target_url = format!("http://{}", target.local_addr().unwrap()); + let target_thread = thread::spawn(move || { + let (mut socket, _) = target.accept().unwrap(); + let mut request = [0; 4096]; + let bytes_read = socket.read(&mut request).unwrap(); + assert!(std::str::from_utf8(&request[..bytes_read]).unwrap().starts_with("GET ")); + socket + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Length: 10\r\nConnection: close\r\n\r\nredirected", + ) + .unwrap(); + }); + + let source = TcpListener::bind("127.0.0.1:0").unwrap(); + let source_url = format!("http://{}", source.local_addr().unwrap()); + let source_thread = thread::spawn(move || { + let (mut socket, _) = source.accept().unwrap(); + let mut request = [0; 4096]; + let bytes_read = socket.read(&mut request).unwrap(); + assert!(bytes_read > 0); + socket + .write_all( + format!( + "HTTP/1.1 307 Temporary Redirect\r\nLocation: {target_url}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ) + .as_bytes(), + ) + .unwrap(); + }); + + let response = verification_client() + .unwrap() + .get(source_url) + .send() + .await + .unwrap() + .text() + .await + .unwrap(); + source_thread.join().unwrap(); + target_thread.join().unwrap(); + assert_eq!(response, "redirected"); + } + + #[forgetest] + async fn creates_correct_verify_request_body(prj: _) { + prj.add_source("Counter", "contract Counter {}"); + + let args = VerifyArgs::parse_from([ + "foundry-cli", + "0xd8509bee9c9bf012282ad33aba0d87241baf5064", + "src/Counter.sol:Counter", + "--compiler-version", + SOLC_VERSION, + "--root", + &prj.root().to_string_lossy(), + ]); + + let context = args.resolve_context().await.unwrap(); + let provider = SourcifyVerificationProvider::default(); + let request = provider.prepare_verify_request(&args, &context).await.unwrap(); + + assert_eq!(request.compiler_version, "0.8.35+commit.47b9dedd"); + assert_eq!(request.contract_identifier, "src/Counter.sol:Counter"); + assert!(request.creation_transaction_hash.is_none()); + + assert!(request.std_json_input.is_object()); + let json_obj = request.std_json_input.as_object().unwrap(); + assert!(json_obj.contains_key("sources")); + assert!(json_obj.contains_key("settings")); + + let sources = json_obj.get("sources").unwrap().as_object().unwrap(); + assert!(sources.contains_key("src/Counter.sol")); + let counter_source = sources.get("src/Counter.sol").unwrap().as_object().unwrap(); + let content = counter_source.get("content").unwrap().as_str().unwrap(); + assert!(content.contains("contract Counter {}")); } } diff --git a/crates/verify/src/types.rs b/crates/verify/src/types.rs index 20d86499642d7..1ad3ffb48f024 100644 --- a/crates/verify/src/types.rs +++ b/crates/verify/src/types.rs @@ -20,16 +20,9 @@ impl FromStr for VerificationType { match s { "full" => Ok(Self::Full), "partial" => Ok(Self::Partial), - _ => eyre::bail!("Invalid verification type"), - } - } -} - -impl From for String { - fn from(v: VerificationType) -> Self { - match v { - VerificationType::Full => "full".to_string(), - VerificationType::Partial => "partial".to_string(), + _ => { + eyre::bail!("Invalid verification type"); + } } } } diff --git a/crates/verify/src/utils.rs b/crates/verify/src/utils.rs index 4e98e55053f62..700e1601451c7 100644 --- a/crates/verify/src/utils.rs +++ b/crates/verify/src/utils.rs @@ -1,30 +1,55 @@ use crate::{bytecode::VerifyBytecodeArgs, types::VerificationType}; -use alloy_dyn_abi::DynSolValue; -use alloy_primitives::{Address, Bytes, TxKind, U256}; -use alloy_provider::{Provider, network::AnyRpcBlock}; +use alloy_dyn_abi::{DynSolValue, JsonAbiExt}; +use alloy_network::{AnyNetwork, AnyRpcBlock}; +use alloy_primitives::{Address, B256, Bytes, ChainId, TxKind, U256}; +use alloy_provider::{Provider, network::BlockResponse}; use alloy_rpc_types::BlockId; use clap::ValueEnum; use eyre::{OptionExt, Result}; use foundry_block_explorers::{ contract::{ContractCreationData, ContractMetadata, Metadata}, errors::EtherscanError, + utils::lookup_compiler_version, }; +use foundry_cli::utils::LoadConfig; use foundry_common::{ - abi::encode_args, compile::ProjectCompiler, ignore_metadata_hash, provider::RetryProvider, - shell, + abi::encode_args, compile::ProjectCompiler, find_matching_contract_artifact, + ignore_metadata_hash, shell, +}; +use foundry_compilers::{ + Graph, + artifacts::{BytecodeHash, CompactContractBytecode}, + compilers::ParsedSource, + multi::{MultiCompilerLanguage, MultiCompilerParser}, + utils::canonicalize, }; -use foundry_compilers::artifacts::{BytecodeHash, CompactContractBytecode, EvmVersion}; use foundry_config::Config; use foundry_evm::{ - Env, EnvMut, constants::DEFAULT_CREATE2_DEPLOYER, executors::TracingExecutor, opts::EvmOpts, - traces::TraceMode, + constants::DEFAULT_CREATE2_DEPLOYER, + core::{ + FoundryBlock as _, + decode::RevertDecoder, + evm::{BlockEnvFor, ChainFor, EvmEnvFor, FoundryEvmNetwork, TxEnvFor}, + }, + executors::{ExecutorBuilder, TracingExecutor}, + opts::EvmOpts, + traces::TraceRequirements, + utils::{apply_chain_and_block_specific_env_changes_for_chain, block_env_from_header}, }; +use foundry_evm_networks::NetworkConfigs; use reqwest::Url; -use revm::{bytecode::Bytecode, database::Database, primitives::hardfork::SpecId}; -use semver::Version; +use revm::{ + bytecode::Bytecode, + context::{Block as _, Transaction as _}, + database::Database, +}; +use semver::{BuildMetadata, Version}; use serde::{Deserialize, Serialize}; use yansi::Paint; +#[cfg(all(test, feature = "monad"))] +use foundry_config::FoundryHardfork; + /// Enum to represent the type of bytecode being verified #[derive(Debug, Serialize, Deserialize, Clone, Copy, ValueEnum)] pub enum BytecodeType { @@ -36,12 +61,12 @@ pub enum BytecodeType { impl BytecodeType { /// Check if the bytecode type is creation - pub fn is_creation(&self) -> bool { + pub const fn is_creation(&self) -> bool { matches!(self, Self::Creation) } /// Check if the bytecode type is runtime - pub fn is_runtime(&self) -> bool { + pub const fn is_runtime(&self) -> bool { matches!(self, Self::Runtime) } } @@ -81,7 +106,33 @@ pub fn build_project( config: &Config, ) -> Result { let project = config.project()?; - let compiler = ProjectCompiler::new(); + let compiler = ProjectCompiler::new().quiet(true); + + let target_path = match args.contract.path() { + Some(path) => Some(canonicalize(project.root().join(path))?), + None => Graph::::resolve(&project.paths).ok().and_then(|graph| { + if graph + .nodes + .iter() + .any(|node| matches!(node.data.language(), MultiCompilerLanguage::Vyper(_))) + { + return None; + } + let mut matches = graph.nodes.iter().filter(|node| { + node.data.contract_names().iter().any(|name| name == &args.contract.name) + }); + let target = matches.next()?; + (matches.next().is_none() + && graph.input_nodes().any(|input| input.path() == target.path())) + .then(|| target.path().to_path_buf()) + }), + }; + if let Some(target_path) = target_path { + let mut output = compiler.files([target_path.clone()]).compile(&project)?; + let artifact = + find_matching_contract_artifact(&mut output, &target_path, Some(&args.contract.name))?; + return Ok(artifact.into_contract_bytecode()); + } let mut output = compiler.compile(&project)?; @@ -92,76 +143,33 @@ pub fn build_project( Ok(artifact.into_contract_bytecode()) } -pub fn build_using_cache( - args: &VerifyBytecodeArgs, - etherscan_settings: &Metadata, - config: &Config, -) -> Result { - let project = config.project()?; - let cache = project.read_cache_file()?; - let cached_artifacts = cache.read_artifacts::()?; - - for (key, value) in cached_artifacts { - let name = args.contract.name.to_owned() + ".sol"; - let version = etherscan_settings.compiler_version.to_owned(); - // Ignores vyper - if version.starts_with("vyper:") { - eyre::bail!("Vyper contracts are not supported") - } - // Parse etherscan version string - let version = version.split('+').next().unwrap_or("").trim_start_matches('v').to_string(); - - // Check if `out/directory` name matches the contract name - if key.ends_with(name.as_str()) { - let name = name.replace(".sol", ".json"); - for artifact in value.into_values().flatten() { - // Check if ABI file matches the name - if !artifact.file.ends_with(&name) { - continue; - } - - // Check if Solidity version matches - if let Ok(version) = Version::parse(&version) - && !(artifact.version.major == version.major - && artifact.version.minor == version.minor - && artifact.version.patch == version.patch) - { - continue; - } - - return Ok(artifact.artifact); - } - } - } - - eyre::bail!("couldn't find cached artifact for contract {}", args.contract.name) -} - pub fn print_result( res: Option, bytecode_type: BytecodeType, json_results: &mut Vec, - etherscan_config: &Metadata, + etherscan_metadata: Option<&Metadata>, config: &Config, ) { if let Some(res) = res { - if !shell::is_json() { + if shell::is_json() { + let json_res = JsonResult { bytecode_type, match_type: Some(res), message: None }; + json_results.push(json_res); + } else { let _ = sh_println!( "{} with status {}", format!("{bytecode_type:?} code matched").green().bold(), res.green().bold() ); - } else { - let json_res = JsonResult { bytecode_type, match_type: Some(res), message: None }; - json_results.push(json_res); } } else if !shell::is_json() { let _ = sh_err!( "{bytecode_type:?} code did not match - this may be due to varying compiler settings" ); - let mismatches = find_mismatch_in_settings(etherscan_config, config); - for mismatch in mismatches { - let _ = sh_eprintln!("{}", mismatch.red().bold()); + if let Some(etherscan_metadata) = etherscan_metadata { + let mismatches = find_mismatch_in_settings(etherscan_metadata, config); + for mismatch in mismatches { + let _ = sh_eprintln!("{}", mismatch.red().bold()); + } } } else { let json_res = JsonResult { @@ -187,6 +195,13 @@ fn is_partial_match( return try_extract_and_compare_bytecode(local_bytecode, bytecode); } + // The constructor args are part of what is being verified: the onchain creation code must + // actually end with them before they can be stripped, otherwise args of the right length + // could never fail the comparison. + if !bytecode.ends_with(constructor_args) { + return false; + } + // If not runtime, extract constructor args from the end of the bytecode bytecode = &bytecode[..bytecode.len() - constructor_args.len()]; local_bytecode = &local_bytecode[..local_bytecode.len() - constructor_args.len()]; @@ -223,12 +238,13 @@ fn find_mismatch_in_settings( ); mismatches.push(str); } - if local_settings.optimizer_runs.is_some_and(|runs| etherscan_settings.runs != runs as u64) - || (local_settings.optimizer_runs.is_none() && etherscan_settings.runs > 0) + // The only caller reaches this with a `Config` from `load_config`, which has run + // `normalize_optimizer_settings`, so `optimizer_runs` is always set by now. + if let Some(local_runs) = local_settings.optimizer_runs + && etherscan_settings.runs != local_runs as u64 { let str = format!( - "Optimizer runs mismatch: local={}, onchain={}", - local_settings.optimizer_runs.unwrap(), + "Optimizer runs mismatch: local={local_runs}, onchain={}", etherscan_settings.runs ); mismatches.push(str); @@ -255,7 +271,9 @@ pub fn maybe_predeploy_contract( maybe_predeploy = true; Ok((None, maybe_predeploy)) } - Err(e) => eyre::bail!("Error fetching creation data from verifier-url: {:?}", e), + Err(e) => { + eyre::bail!("Error fetching creation data from verifier-url: {:?}", e); + } } } @@ -263,21 +281,45 @@ pub fn check_and_encode_args( artifact: &CompactContractBytecode, args: Vec, ) -> Result, eyre::ErrReport> { - if let Some(constructor) = artifact.abi.as_ref().and_then(|abi| abi.constructor()) { - if constructor.inputs.len() != args.len() { - eyre::bail!( - "Mismatch of constructor arguments length. Expected {}, got {}", - constructor.inputs.len(), - args.len() - ); + let Some(constructor) = artifact.abi.as_ref().and_then(|abi| abi.constructor()) else { + if args.is_empty() { + return Ok(Vec::new()); } - encode_args(&constructor.inputs, &args).map(|args| DynSolValue::Tuple(args).abi_encode()) - } else { - Ok(Vec::new()) + eyre::bail!("Contract has no constructor arguments, but arguments were provided"); + }; + if constructor.inputs.len() != args.len() { + eyre::bail!( + "Mismatch of constructor arguments length. Expected {}, got {}", + constructor.inputs.len(), + args.len() + ); } + encode_args(&constructor.inputs, &args).map(|args| DynSolValue::Tuple(args).abi_encode_params()) } -pub fn check_explorer_args(source_code: ContractMetadata) -> Result { +pub fn validate_encoded_constructor_args( + artifact: &CompactContractBytecode, + args: Vec, +) -> Result, eyre::ErrReport> { + let Some(constructor) = artifact.abi.as_ref().and_then(|abi| abi.constructor()) else { + if args.is_empty() { + return Ok(args); + } + eyre::bail!("Contract has no constructor arguments, but encoded arguments were provided"); + }; + let values = constructor + .abi_decode_input(&args) + .map_err(|err| eyre::eyre!("Invalid ABI-encoded constructor arguments: {err}"))?; + let encoded = constructor + .abi_encode_input(&values) + .map_err(|err| eyre::eyre!("Invalid ABI-encoded constructor arguments: {err}"))?; + if encoded != args { + eyre::bail!("Constructor arguments are not canonically ABI-encoded"); + } + Ok(args) +} + +pub fn check_explorer_args(source_code: &ContractMetadata) -> Result { if let Some(args) = source_code.items.first() { Ok(args.constructor_arguments.clone()) } else { @@ -301,85 +343,156 @@ pub fn check_args_len( Ok(()) } -pub async fn get_tracing_executor( +pub fn load_fork_config_and_evm_opts(config: &Config) -> Result<(Config, EvmOpts)> { + let chain = config.chain; + let mut fork_config = config.clone(); + fork_config.chain = None; + + let (mut fork_config, mut evm_opts) = fork_config.load_config_and_evm_opts()?; + fork_config.chain = chain; + if let Some(chain) = chain { + evm_opts.env.chain_id = Some(chain.id()); + } + + Ok((fork_config, evm_opts)) +} + +pub async fn get_tracing_executor( fork_config: &mut Config, fork_blk_num: u64, - evm_version: EvmVersion, + execution_blk_num: u64, + execution_block: Option<&AnyRpcBlock>, evm_opts: EvmOpts, -) -> Result<(Env, TracingExecutor)> { + executor_builder: ExecutorBuilder, +) -> Result<(EvmEnvFor, TxEnvFor, TracingExecutor)> +where + FEN: FoundryEvmNetwork, +{ fork_config.fork_block_number = Some(fork_blk_num); - fork_config.evm_version = evm_version; let create2_deployer = evm_opts.create2_deployer; - let (env, fork, _chain, is_odyssey) = - TracingExecutor::get_fork_material(fork_config, evm_opts).await?; - - let executor = TracingExecutor::new( - env.clone(), - fork, - Some(fork_config.evm_version), - TraceMode::Call, - is_odyssey, + let mut fork = TracingExecutor::::get_fork(fork_config, evm_opts).await?; + let context = fork.context(); + + fork.evm_env.block_env.set_number(U256::from(execution_blk_num)); + if let Some(block) = execution_block { + configure_env_block::( + &mut fork.evm_env, + block, + context.chain().id(), + context.networks(), + ); + } + fork.resolve_spec(fork_config, None); + fork.extend_precompile_labels(fork_config); + + let evm_env = fork.evm_env.clone(); + let tx_env = fork.tx_env.clone(); + let executor = fork.into_executor( + executor_builder, + TraceRequirements::none().with_calls(true), create2_deployer, None, )?; - Ok((env, executor)) + Ok((evm_env, tx_env, executor)) } -pub fn configure_env_block(env: &mut EnvMut<'_>, block: &AnyRpcBlock) { - env.block.timestamp = U256::from(block.header.timestamp); - env.block.beneficiary = block.header.beneficiary; - env.block.difficulty = block.header.difficulty; - env.block.prevrandao = Some(block.header.mix_hash.unwrap_or_default()); - env.block.basefee = block.header.base_fee_per_gas.unwrap_or_default(); - env.block.gas_limit = block.header.gas_limit; +#[cfg(all(test, feature = "monad"))] +fn resolve_runtime_spec( + config: &Config, + source_chain_id: ChainId, + endpoint_hardfork: Option, + evm_env: &mut EvmEnvFor, +) -> Option +where + FEN: FoundryEvmNetwork, +{ + TracingExecutor::::resolve_spec_for_chain( + config, + source_chain_id, + endpoint_hardfork, + evm_env, + None, + ) } -pub fn deploy_contract( - executor: &mut TracingExecutor, - env: &Env, - spec_id: SpecId, - to: Option, -) -> Result { - let env = Env::new_with_spec_id( - env.evm_env.cfg_env.clone(), - env.evm_env.block_env.clone(), - env.tx.clone(), - spec_id, +pub fn configure_env_block( + evm_env: &mut EvmEnvFor, + block: &AnyRpcBlock, + source_chain_id: ChainId, + config: NetworkConfigs, +) where + FEN: FoundryEvmNetwork, +{ + let number = evm_env.block_env.number(); + evm_env.block_env = block_env_from_header::>(block.header()); + evm_env.block_env.set_number(number); + apply_chain_and_block_specific_env_changes_for_chain::( + evm_env, + block, + source_chain_id, + config, ); +} - if to.is_some_and(|to| to.is_call()) { - let TxKind::Call(to) = to.unwrap() else { unreachable!() }; +pub fn deploy_contract( + executor: &mut TracingExecutor, + evm_env: &EvmEnvFor, + tx_env: &TxEnvFor, + to: TxKind, + chain_context: ChainFor, +) -> Result +where + FEN: FoundryEvmNetwork, +{ + // Derive the created address up front: batched transactions return the last call's result, + // not the deployment's. + let address = if let TxKind::Call(to) = to { if to != DEFAULT_CREATE2_DEPLOYER { eyre::bail!( "Transaction `to` address is not the default create2 deployer i.e the tx is not a contract creation tx." ); } - let result = executor.transact_with_env(env)?; + let (salt, init_code) = tx_env.input().split_at(32); + DEFAULT_CREATE2_DEPLOYER.create2_from_code(B256::from_slice(salt), init_code) + } else { + let caller = tx_env.caller(); + caller.create(executor.backend_mut().basic(caller)?.map_or(0, |info| info.nonce)) + }; - trace!(transact_result = ?result.exit_reason); - if result.result.len() != 20 { - eyre::bail!( - "Failed to deploy contract on fork at block: call result is not exactly 20 bytes" - ); - } + let result = + executor.transact_with_env_and_context(evm_env.clone(), tx_env.clone(), chain_context)?; + trace!(transact_result = ?result.exit_reason); - Ok(Address::from_slice(&result.result)) - } else { - let deploy_result = executor.deploy_with_env(env, None)?; - trace!(deploy_result = ?deploy_result.raw.exit_reason); - Ok(deploy_result.address) + if result.reverted { + let decoded_reason = RevertDecoder::new().decode(&result.result, result.exit_reason); + eyre::bail!( + "Failed to deploy contract on fork at block: {decoded_reason}.\n\ + This typically happens when your local bytecode differs from what was actually deployed.\n\ + Common causes:\n\ + - Your contract source is not at the same commit used during deployment\n\ + - Cached build artifacts are stale (try `forge clean && forge build`)\n\ + - Compiler settings (optimizer, evm_version, via_ir) don't match the deployment" + ); + } + if !result.state_changeset.get(&address).is_some_and(|account| account.is_created()) { + eyre::bail!("Transaction did not deploy a contract at the expected address {address}"); } + + Ok(address) } -pub async fn get_runtime_codes( - executor: &mut TracingExecutor, - provider: &RetryProvider, +pub async fn get_runtime_codes( + executor: &mut TracingExecutor, + provider: &impl Provider, address: Address, fork_address: Address, block: Option, -) -> Result<(Bytecode, Bytes)> { +) -> Result<(Bytecode, Bytes)> +where + FEN: FoundryEvmNetwork, +{ let fork_runtime_code = executor .backend_mut() .basic(fork_address)? @@ -397,11 +510,8 @@ pub async fn get_runtime_codes( ) })?; - let onchain_runtime_code = if let Some(block) = block { - provider.get_code_at(address).block_id(BlockId::number(block)).await? - } else { - provider.get_code_at(address).await? - }; + let block_id = block.map_or_else(BlockId::latest, BlockId::number); + let onchain_runtime_code = provider.get_code_at(address).block_id(block_id).await?; Ok((fork_runtime_code, onchain_runtime_code)) } @@ -409,14 +519,312 @@ pub async fn get_runtime_codes( /// Returns `true` if the URL only consists of host. /// /// This is used to check user input url for missing /api path -#[inline] pub fn is_host_only(url: &Url) -> bool { matches!(url.path(), "/" | "") } +/// Wraps a failed verification error with guidance when `--verifier-url` looks misconfigured for +/// the Etherscan provider. Returns `err` untouched when no hint applies. +/// +/// The hint only fires when the Etherscan verifier is active: it requires an API endpoint +/// (typically `/api`). Sourcify, Blockscout, etc. accept host-only URLs, so we leave their +/// errors alone. +pub fn wrap_verifier_url_error( + err: eyre::Error, + verifier_url: Option<&str>, + using_etherscan: bool, +) -> eyre::Error { + let Some(verifier_url) = verifier_url else { return err }; + let url = match Url::parse(verifier_url) { + Ok(mut url) => { + let _ = url.set_username(""); + let _ = url.set_password(None); + url.set_query(None); + url.set_fragment(None); + url + } + Err(url_err) => { + return err.wrap_err(format!("Invalid verifier URL provided: {url_err}")); + } + }; + if is_host_only(&url) && using_etherscan { + return err.wrap_err(format!( + "Verifier `etherscan` requires an API endpoint, but `--verifier-url` is host-only: `{url}`.\n\ + Fixes (pick one):\n\ + - Append the API path, e.g. `--verifier-url {url}api`\n\ + - Switch verifier, e.g. `--verifier sourcify` (works with host-only URLs)" + )); + } + err +} + +/// Given any solc [Version] return a [Version] with build metadata +/// +/// # Example +/// +/// ```ignore +/// use semver::{BuildMetadata, Version}; +/// let version = Version::new(1, 2, 3); +/// let version = ensure_solc_build_metadata(version).await?; +/// assert_ne!(version.build, BuildMetadata::EMPTY); +/// ``` +pub async fn ensure_solc_build_metadata(version: Version) -> Result { + if version.build == BuildMetadata::EMPTY { + Ok(lookup_compiler_version(&version).await?) + } else { + Ok(version) + } +} + #[cfg(test)] mod tests { use super::*; + use crate::verify::VerifierArgs; + use alloy_json_abi::JsonAbi; + use foundry_cli::opts::EtherscanOpts; + use foundry_compilers::PathStyle; + use foundry_config::NamedChain; + use foundry_test_utils::{TestProject, util::SOLC_VERSION}; + + #[cfg(feature = "monad")] + use foundry_evm::{EvmEnv, hardforks::MonadHardfork}; + + #[cfg(feature = "monad")] + fn monad_env(timestamp: u64) -> EvmEnv { + let mut env = EvmEnv::::default(); + env.cfg_env.chain_id = NamedChain::Monad as u64; + env.block_env.set_timestamp(U256::from(timestamp)); + env + } + + #[test] + fn encoded_constructor_args_must_be_canonical() { + let artifact = CompactContractBytecode { + abi: Some(alloy_json_abi::JsonAbi::parse(["constructor(uint256 value)"]).unwrap()), + bytecode: None, + deployed_bytecode: None, + }; + let args = artifact + .abi + .as_ref() + .unwrap() + .constructor() + .unwrap() + .abi_encode_input(&[DynSolValue::Uint(U256::ONE, 256)]) + .unwrap(); + + assert_eq!(validate_encoded_constructor_args(&artifact, args.clone()).unwrap(), args); + + // Arbitrary bytes prepended to valid arguments can overlap the creation bytecode's + // metadata and must not be accepted as part of the constructor arguments. + let overlapping = [alloy_primitives::hex!("a1616101").as_slice(), &args].concat(); + assert!(validate_encoded_constructor_args(&artifact, overlapping).is_err()); + } + + #[test] + fn typed_constructor_args_require_a_constructor() { + let artifact = CompactContractBytecode { + abi: Some(JsonAbi::new()), + bytecode: None, + deployed_bytecode: None, + }; + + assert!(check_and_encode_args(&artifact, vec!["1".to_string()]).is_err()); + assert_eq!(check_and_encode_args(&artifact, Vec::new()).unwrap(), Vec::::new()); + } + + #[test] + fn dynamic_constructor_args_are_encoded_as_top_level_params() { + let artifact = CompactContractBytecode { + abi: Some(alloy_json_abi::JsonAbi::parse(["constructor(string value)"]).unwrap()), + bytecode: None, + deployed_bytecode: None, + }; + + let encoded = check_and_encode_args(&artifact, vec!["hi".to_string()]).unwrap(); + + // Constructor arguments are encoded as top-level ABI parameters, so the first word is the + // offset to the string payload. + let expected = alloy_primitives::hex!( + "0000000000000000000000000000000000000000000000000000000000000020" + "0000000000000000000000000000000000000000000000000000000000000002" + "6869000000000000000000000000000000000000000000000000000000000000" + ); + assert_eq!(encoded, expected); + } + + #[test] + fn creation_code_must_end_with_constructor_args() { + let code = alloy_primitives::hex!("6080604052348015600e575f5ffd5b50607b80601a5f395ff3fe"); + let real_args = [0x11u8; 32]; + let wrong_args = [0x22u8; 32]; + + let onchain = [code.as_slice(), &real_args].concat(); + + // Wrong args of the right length used to report a partial match because the tails were + // stripped from both sides without comparing them. + let local = [code.as_slice(), &wrong_args].concat(); + assert_eq!(match_bytecodes(&local, &onchain, &wrong_args, false, BytecodeHash::Ipfs), None); + + let local = [code.as_slice(), &real_args].concat(); + assert_eq!( + match_bytecodes(&local, &onchain, &real_args, false, BytecodeHash::Ipfs), + Some(VerificationType::Full) + ); + + // A valid dynamic encoding can end with another valid encoding. The suffix alone must + // not establish that the supplied arguments match. + let suffix_args = + DynSolValue::Tuple(vec![DynSolValue::Bytes(real_args.to_vec())]).abi_encode(); + let deployment_args = + DynSolValue::Tuple(vec![DynSolValue::Bytes(suffix_args.clone())]).abi_encode(); + assert!(deployment_args.ends_with(&suffix_args)); + + let onchain = [code.as_slice(), deployment_args.as_slice()].concat(); + let local = [code.as_slice(), suffix_args.as_slice()].concat(); + assert_eq!( + match_bytecodes(&local, &onchain, &suffix_args, false, BytecodeHash::Ipfs), + None + ); + } + + #[test] + fn build_project_finds_artifact_by_relative_contract_path() { + let prj = TestProject::new("verify-bytecode-relative-path", PathStyle::Dapptools); + prj.add_source( + "Counter.sol", + r#" +pragma solidity ^0.8.0; + +contract Counter { + uint256 public number; +} +"#, + ); + prj.add_source( + "Broken.sol", + r#" +pragma solidity ^0.8.0; + +contract Broken { + this is not valid Solidity +} +"#, + ); + + let mut config = Config::load_with_root(prj.root()).unwrap(); + config.solc = Some(SOLC_VERSION.into()); + let args = VerifyBytecodeArgs { + address: Address::ZERO, + contract: "src/Counter.sol:Counter".parse().unwrap(), + block: None, + constructor_args: None, + encoded_constructor_args: None, + constructor_args_path: None, + rpc_url: None, + network: None, + etherscan: EtherscanOpts::default(), + verifier: VerifierArgs::default(), + libraries: Vec::new(), + root: Some(prj.root().to_path_buf()), + ignore: None, + }; + + let artifact = build_project(&args, &config).unwrap(); + + assert!(artifact.bytecode.and_then(|bytecode| bytecode.into_bytes()).is_some()); + } + + #[test] + fn load_fork_config_and_evm_opts_serializes_chain_as_id() { + let config = Config { chain: Some(NamedChain::Mainnet.into()), ..Default::default() }; + + let (fork_config, evm_opts) = load_fork_config_and_evm_opts(&config).unwrap(); + + assert_eq!(fork_config.chain, Some(NamedChain::Mainnet.into())); + assert_eq!(evm_opts.env.chain_id, Some(1)); + } + + #[test] + #[cfg(feature = "monad")] + fn runtime_spec_uses_monad_source_chain_timestamp() { + let monad_nine_timestamp = foundry_evm::hardforks::MonadHardfork::MonadNine + .mainnet_activation_timestamp() + .unwrap(); + + let before_config = Config { + evm_version: foundry_compilers::artifacts::EvmVersion::Osaka, + ..Default::default() + }; + let mut before_env = monad_env(monad_nine_timestamp - 1); + before_env.cfg_env.chain_id = NamedChain::Mainnet as u64; + let before = resolve_runtime_spec::( + &before_config, + NamedChain::Monad as u64, + None, + &mut before_env, + ); + + assert_eq!( + before, + Some(FoundryHardfork::Monad(foundry_evm::hardforks::MonadHardfork::MonadEight)) + ); + assert_eq!(before_env.cfg_env.spec, foundry_evm::hardforks::MonadHardfork::MonadEight); + assert_eq!(before_env.cfg_env.chain_id, NamedChain::Mainnet as u64); + + let after_config = Config { + evm_version: foundry_compilers::artifacts::EvmVersion::Prague, + ..Default::default() + }; + let mut after_env = monad_env(monad_nine_timestamp); + let after = resolve_runtime_spec::( + &after_config, + NamedChain::Monad as u64, + None, + &mut after_env, + ); + + assert_eq!( + after, + Some(FoundryHardfork::Monad(foundry_evm::hardforks::MonadHardfork::MonadNine)) + ); + assert_eq!(after_env.cfg_env.spec, foundry_evm::hardforks::MonadHardfork::MonadNine); + } + + #[test] + #[cfg(feature = "monad")] + fn runtime_spec_and_labels_prefer_explicit_monad_hardfork() { + let mut config = Config { + hardfork: Some(foundry_evm::hardforks::MonadHardfork::MonadEight.into()), + ..Default::default() + }; + let mut env = monad_env( + foundry_evm::hardforks::MonadHardfork::MonadNine + .mainnet_activation_timestamp() + .unwrap(), + ); + let networks = NetworkConfigs::with_monad(); + + let resolved = resolve_runtime_spec::( + &config, + NamedChain::Monad as u64, + Some(foundry_evm::hardforks::MonadHardfork::MonadNine.into()), + &mut env, + ); + TracingExecutor::::extend_precompile_labels( + &mut config, + networks, + resolved, + ); + + assert_eq!( + resolved, + Some(FoundryHardfork::Monad(foundry_evm::hardforks::MonadHardfork::MonadEight)) + ); + assert_eq!(env.cfg_env.spec, foundry_evm::hardforks::MonadHardfork::MonadEight); + assert!(config.labels.values().any(|label| label == "Staking")); + assert!(!config.labels.values().any(|label| label == "ReserveBalance")); + } #[test] fn test_host_only() { @@ -424,4 +832,54 @@ mod tests { assert!(is_host_only(&Url::parse("https://blockscout.net/").unwrap())); assert!(is_host_only(&Url::parse("https://blockscout.net").unwrap())); } + + #[test] + fn wrap_verifier_url_error_passes_through_when_no_url() { + let err = eyre::eyre!("upstream failure"); + let wrapped = wrap_verifier_url_error(err, None, true); + assert_eq!(wrapped.to_string(), "upstream failure"); + } + + #[test] + fn wrap_verifier_url_error_adds_hint_for_host_only_etherscan_url() { + let err = eyre::eyre!("upstream failure"); + let wrapped = wrap_verifier_url_error(err, Some("https://contracts.tempo.xyz"), true); + let msg = format!("{wrapped:#}"); + assert!(msg.contains("host-only"), "message: {msg}"); + assert!(msg.contains("--verifier-url https://contracts.tempo.xyz/api"), "message: {msg}"); + assert!(msg.contains("--verifier sourcify"), "message: {msg}"); + } + + /// Sourcify and other non-etherscan verifiers accept host-only URLs; we must not emit the + /// hint for them, otherwise we would mislead the user into editing a correct URL. + #[test] + fn wrap_verifier_url_error_does_not_hint_for_non_etherscan_provider() { + let err = eyre::eyre!("upstream failure"); + let wrapped = wrap_verifier_url_error(err, Some("https://contracts.tempo.xyz"), false); + assert_eq!(wrapped.to_string(), "upstream failure"); + } + + #[test] + fn wrap_verifier_url_error_reports_invalid_url() { + let err = eyre::eyre!("upstream failure"); + let wrapped = wrap_verifier_url_error(err, Some("not a url"), true); + let msg = format!("{wrapped:#}"); + assert!(msg.contains("Invalid verifier URL"), "message: {msg}"); + assert!(!msg.contains("not a url"), "message: {msg}"); + } + + #[test] + fn wrap_verifier_url_error_redacts_credentials_and_query() { + let err = eyre::eyre!("upstream failure"); + let wrapped = wrap_verifier_url_error( + err, + Some("https://user:secret@example.com?api_key=secret"), + true, + ); + let msg = format!("{wrapped:#}"); + assert!(msg.contains("https://example.com/")); + assert!(!msg.contains("user")); + assert!(!msg.contains("secret")); + assert!(!msg.contains("api_key")); + } } diff --git a/crates/verify/src/verify.rs b/crates/verify/src/verify.rs index 4abb70ac4d9e0..41fc532c6ff41 100644 --- a/crates/verify/src/verify.rs +++ b/crates/verify/src/verify.rs @@ -3,25 +3,136 @@ use crate::{ RetryArgs, etherscan::EtherscanVerificationProvider, - provider::{VerificationContext, VerificationProvider, VerificationProviderType}, - utils::is_host_only, + provider::{ + ExternalVerificationContext, VerificationContext, VerificationProvider, + VerificationProviderType, + }, + sourcify::SourcifyVerificationProvider, + utils::wrap_verifier_url_error, }; -use alloy_primitives::{Address, map::HashSet}; +use alloy_primitives::{Address, TxHash, map::HashSet}; use alloy_provider::Provider; use clap::{Parser, ValueEnum, ValueHint}; -use eyre::Result; -use foundry_block_explorers::EtherscanApiVersion; +use eyre::{Context, Result}; use foundry_cli::{ opts::{EtherscanOpts, RpcOpts}, utils::{self, LoadConfig}, }; use foundry_common::{ContractsByArtifact, compile::ProjectCompiler}; -use foundry_compilers::{artifacts::EvmVersion, compilers::solc::Solc, info::ContractInfo}; -use foundry_config::{Config, SolcReq, figment, impl_figment_convert, impl_figment_convert_cast}; +use foundry_compilers::{artifacts::EvmVersion, info::ContractInfo}; +use foundry_config::{ + Chain, Config, + figment::{ + Error, Metadata, Profile, Provider as FigmentProvider, + value::{Dict, Map, Value}, + }, + impl_figment_convert, impl_figment_convert_cast, +}; use itertools::Itertools; -use reqwest::Url; +use reqwest::{Client, StatusCode, Url}; use semver::BuildMetadata; -use std::path::PathBuf; +use serde::Deserialize; +use std::{path::PathBuf, time::Duration}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum VerifierCredentialProbe { + Accepted, + InvalidApiKey, + Inconclusive, +} + +#[derive(Debug, Deserialize)] +struct EtherscanProbeResponse { + status: String, + result: Option, +} + +fn verifier_credential_probe_query(api_key: Option<&str>) -> Vec<(&'static str, String)> { + let mut query = vec![ + ("module", "contract".to_string()), + ("action", "getabi".to_string()), + ("address", Address::ZERO.to_string()), + ]; + if let Some(api_key) = api_key { + query.push(("apikey", api_key.to_string())); + } + query +} + +fn classify_verifier_credential_response( + status: StatusCode, + body: &str, +) -> VerifierCredentialProbe { + let lower = body.to_lowercase(); + if lower.contains("invalid api key") || lower.contains("invalid_api_key") { + return VerifierCredentialProbe::InvalidApiKey; + } + + if lower.contains("contract source code not verified") + || lower.contains("contract not found") + || lower.contains("contract was not found") + { + return VerifierCredentialProbe::Accepted; + } + + if status == StatusCode::UNAUTHORIZED { + return VerifierCredentialProbe::InvalidApiKey; + } + + if !status.is_success() + || lower.contains("max rate limit reached") + || lower.contains("sorry, you have been blocked") + || lower.contains("checking if the site connection is secure") + { + return VerifierCredentialProbe::Inconclusive; + } + + match serde_json::from_str::(body) { + Ok(resp) if resp.status == "1" => VerifierCredentialProbe::Accepted, + Ok(resp) => resp + .result + .and_then(|result| result.as_str().map(str::to_lowercase)) + .map(|result| { + if result.contains("invalid api key") || result.contains("invalid_api_key") { + VerifierCredentialProbe::InvalidApiKey + } else if result.contains("max rate limit reached") { + VerifierCredentialProbe::Inconclusive + } else if result.contains("contract source code not verified") + || result.contains("contract not found") + || result.contains("contract was not found") + { + VerifierCredentialProbe::Accepted + } else { + VerifierCredentialProbe::Inconclusive + } + }) + .unwrap_or(VerifierCredentialProbe::Inconclusive), + Err(_) => VerifierCredentialProbe::Inconclusive, + } +} + +pub(crate) fn parse_http_verifier_url(url: &str, label: &str) -> Result { + let url = Url::parse(url).wrap_err_with(|| format!("invalid {label} URL"))?; + if !matches!(url.scheme(), "http" | "https") { + eyre::bail!("invalid {label} URL: URL scheme must be http or https"); + } + Ok(url) +} + +async fn probe_verifier_credentials( + url: Url, + api_key: Option<&str>, +) -> Result { + let resp = Client::new() + .get(url) + .query(&verifier_credential_probe_query(api_key)) + .timeout(Duration::from_secs(10)) + .send() + .await?; + let status = resp.status(); + let body = resp.text().await?; + Ok(classify_verifier_credential_response(status, &body)) +} /// The programming language used for smart contract development. /// @@ -30,16 +141,16 @@ use std::path::PathBuf; pub enum ContractLanguage { /// Solidity programming language Solidity, - /// Vyper programming language + /// Vyper programming language Vyper, } /// Verification provider arguments -#[derive(Clone, Debug, Parser)] +#[derive(Clone, Debug, Default, Parser)] pub struct VerifierArgs { /// The contract verification provider to use. - #[arg(long, help_heading = "Verifier options", default_value = "sourcify", value_enum)] - pub verifier: VerificationProviderType, + #[arg(long, help_heading = "Verifier options", value_enum)] + pub verifier: Option, /// The verifier API KEY, if using a custom provider. #[arg(long, help_heading = "Verifier options", env = "VERIFIER_API_KEY")] @@ -48,20 +159,148 @@ pub struct VerifierArgs { /// The verifier URL, if using a custom provider. #[arg(long, help_heading = "Verifier options", env = "VERIFIER_URL")] pub verifier_url: Option, - - /// The verifier API version, if using a custom provider. - #[arg(long, help_heading = "Verifier options", env = "VERIFIER_API_VERSION")] - pub verifier_api_version: Option, } -impl Default for VerifierArgs { - fn default() -> Self { - Self { - verifier: VerificationProviderType::Sourcify, - verifier_api_key: None, - verifier_url: None, - verifier_api_version: None, +impl VerifierArgs { + /// Returns the effective verifier type, defaulting to Sourcify if not explicitly set. + /// + /// Note: this is the *defaulted CLI value*, not the actually-selected provider after + /// considering `ETHERSCAN_API_KEY` / chain support. Use [`Self::resolve`] for that. + pub fn effective_type(&self) -> VerificationProviderType { + self.verifier.unwrap_or_default() + } + + /// Returns true if `--verifier` was explicitly provided by the user. + pub const fn is_explicitly_set(&self) -> bool { + self.verifier.is_some() + } + + /// Resolves the API key with consistent precedence: explicit `--verifier-api-key` first, + /// then the etherscan config key. + pub fn resolve_api_key<'a>(&'a self, etherscan_key: Option<&'a str>) -> Option<&'a str> { + self.verifier_api_key.as_deref().or(etherscan_key) + } + + /// Makes a lightweight network call to validate that credentials are accepted by the verifier. + /// + /// `api_key` must be the already-merged API key (CLI `--verifier-api-key` takes + /// precedence over config), as returned by [`Self::resolve_api_key`]. + pub async fn check_credentials( + &self, + api_key: Option<&str>, + chain: Chain, + config: &Config, + ) -> eyre::Result<()> { + let resolved = self.resolve(api_key, Some(chain)); + match resolved { + VerificationProviderType::Etherscan + | VerificationProviderType::Blockscout + | VerificationProviderType::Oklink => { + let etherscan_opts = + EtherscanOpts { key: api_key.map(str::to_owned), chain: Some(chain) }; + let client = EtherscanVerificationProvider::default().client( + ðerscan_opts, + self, + config, + )?; + match tokio::time::timeout( + Duration::from_secs(10), + probe_verifier_credentials( + client.etherscan_api_url().clone(), + client.api_key(), + ), + ) + .await + { + Err(_) => { + sh_warn!("verifier credential check timed out, proceeding anyway")?; + } + Ok(Ok(VerifierCredentialProbe::Accepted)) => {} + Ok(Ok(VerifierCredentialProbe::InvalidApiKey)) => { + eyre::bail!("verifier credential check failed: invalid API key"); + } + Ok(Ok(VerifierCredentialProbe::Inconclusive) | Err(_)) => { + sh_warn!("verifier credential check inconclusive, proceeding anyway")?; + } + } + } + VerificationProviderType::Custom => { + // Custom verifiers may return Etherscan-shaped responses (HTTP 200 with a JSON + // body) or standard HTTP auth errors (401/403). Check both. + if let Some(url) = &self.verifier_url { + let url = parse_http_verifier_url(url, "verifier")?; + match probe_verifier_credentials(url, api_key).await { + Err(_) => { + sh_warn!("verifier credential check failed, proceeding anyway")?; + } + Ok( + VerifierCredentialProbe::Accepted + | VerifierCredentialProbe::Inconclusive, + ) => {} + Ok(VerifierCredentialProbe::InvalidApiKey) => { + eyre::bail!("verifier credential check failed: invalid API key"); + } + } + } + } + VerificationProviderType::Sourcify => { + // Only probe custom URLs; the default public endpoint is assumed reachable. + if let Some(url) = &self.verifier_url { + let url = parse_http_verifier_url(url, "Sourcify")?; + match Client::new() + .get(url.clone()) + .timeout(Duration::from_secs(10)) + .send() + .await + { + Err(_) => { + sh_warn!( + "Sourcify URL `{url}` could not be reached, proceeding anyway" + )?; + } + Ok(resp) => { + let status = resp.status(); + if !status.is_success() && status != StatusCode::NOT_FOUND { + sh_warn!( + "Sourcify URL `{url}` returned HTTP {status}, proceeding anyway" + )?; + } + } + } + } + } + } + Ok(()) + } + + /// Resolves the actual verification provider that will be used at runtime, taking into + /// account the explicit `--verifier`, the presence of `ETHERSCAN_API_KEY`, and whether the + /// target chain has a known Etherscan API URL. + /// + /// Resolution rules (mirrors [`VerificationProviderType::client`]): + /// 1. If `--verifier` was explicitly set, that wins. + /// 2. Otherwise, if an Etherscan API key is set AND the chain is supported (or a custom + /// `--verifier-url` is provided), use Etherscan. + /// 3. Otherwise, fall back to Sourcify. + pub fn resolve( + &self, + etherscan_key: Option<&str>, + chain: Option, + ) -> VerificationProviderType { + if let Some(v) = self.verifier { + return v; } + let has_key = etherscan_key.is_some_and(|k| !k.is_empty()); + // Custom-Sourcify chains (e.g. Tempo) register Sourcify-compatible URLs under + // etherscan_urls() but are NOT real Etherscan chains. Skip the implicit-Etherscan path + // entirely for them; the caller must use `--verifier etherscan` explicitly to override. + if has_key && !chain.is_some_and(|c| c.is_custom_sourcify()) { + let chain_has_etherscan_url = chain.is_none_or(|c| c.etherscan_urls().is_some()); + if chain_has_etherscan_url || self.verifier_url.is_some() { + return VerificationProviderType::Etherscan; + } + } + VerificationProviderType::Sourcify } } @@ -74,7 +313,7 @@ pub struct VerifyArgs { /// The contract identifier in the form `:`. pub contract: Option, - /// The ABI-encoded constructor arguments. + /// The ABI-encoded constructor arguments. Only for Etherscan. #[arg( long, conflicts_with = "constructor_args_path", @@ -91,6 +330,10 @@ pub struct VerifyArgs { #[arg(long)] pub guess_constructor_args: bool, + /// The hash of the transaction which created the contract. Optional for Sourcify. + #[arg(long)] + pub creation_transaction_hash: Option, + /// The `solc` version to use to build the smart contract. #[arg(long, value_name = "VERSION")] pub compiler_version: Option, @@ -119,6 +362,11 @@ pub struct VerifyArgs { #[arg(long)] pub watch: bool, + /// Print the submission ID and URL to stdout. Only set for standalone `verify-contract`, so + /// embedded verification (`forge create`/`script --verify`) keeps stdout clean. + #[arg(skip)] + pub print_submission_result_to_stdout: bool, + /// Set pre-linked libraries. #[arg(long, help_heading = "Linker options", env = "DAPP_LIBRARIES")] pub libraries: Vec, @@ -141,12 +389,37 @@ pub struct VerifyArgs { #[arg(long)] pub via_ir: bool, + /// The Etherscan license type code or SPDX identifier to include with the verification + /// request. + /// + /// Accepts either an Etherscan numeric license code (see + /// ) or a common SPDX identifier such as + /// `MIT`, `Apache-2.0`, `GPL-3.0-or-later`, or `AGPL-3.0-or-later`. Only used for + /// Etherscan-style verifiers. + #[arg( + long, + value_name = "LICENSE", + help_heading = "Verifier options", + value_parser = parse_etherscan_license_type, + )] + pub license_type: Option, + /// The EVM version to use. /// /// Overrides the version specified in the config. #[arg(long)] pub evm_version: Option, + /// Do not auto-detect the `solc` version. + #[arg(long, help_heading = "Compiler options")] + pub no_auto_detect: bool, + + /// Specify the solc version, path, or executable name on `PATH` to build with. + /// + /// Valid values are in the format `x.y.z`, `solc:x.y.z`, `path/to/solc`, or `solc`. + #[arg(long = "use", help_heading = "Compiler options", value_name = "SOLC_VERSION")] + pub use_solc: Option, + #[command(flatten)] pub etherscan: EtherscanOpts, @@ -166,61 +439,180 @@ pub struct VerifyArgs { pub language: Option, } +pub fn parse_etherscan_license_type(value: &str) -> Result { + let value = value.trim(); + if value.is_empty() { + return Err("license type cannot be empty".into()); + } + + if let Ok(code) = value.parse::() { + return Ok(code.to_string()); + } + + let normalized = normalize_license_type(value); + let code = match normalized.as_str() { + "none" | "no-license" | "unlicensed" => 1, + "unlicense" | "the-unlicense" => 2, + "mit" | "mit-license" => 3, + "gpl-2.0" | "gpl-2.0+" | "gpl-2.0-only" | "gpl-2.0-or-later" | "gplv2" | "gnu-gplv2" => 4, + "gpl-3.0" | "gpl-3.0+" | "gpl-3.0-only" | "gpl-3.0-or-later" | "gplv3" | "gnu-gplv3" => 5, + "lgpl-2.1" | "lgpl-2.1+" | "lgpl-2.1-only" | "lgpl-2.1-or-later" | "lgplv2.1" + | "gnu-lgplv2.1" => 6, + "lgpl-3.0" | "lgpl-3.0+" | "lgpl-3.0-only" | "lgpl-3.0-or-later" | "lgplv3" + | "gnu-lgplv3" => 7, + "bsd-2-clause" => 8, + "bsd-3-clause" => 9, + "mpl-2.0" => 10, + "osl-3.0" => 11, + "apache-2.0" | "apache-license-2.0" => 12, + "agpl-3.0" | "agpl-3.0+" | "agpl-3.0-only" | "agpl-3.0-or-later" | "agplv3" + | "gnu-agplv3" => 13, + "bsl-1.1" | "busl-1.1" | "business-source-license-1.1" => 14, + _ => { + return Err(format!( + "unsupported Etherscan license type `{value}`; expected a numeric code or a \ + supported SPDX identifier such as MIT, Apache-2.0, GPL-3.0-or-later, or \ + AGPL-3.0-or-later" + )); + } + }; + + Ok(code.to_string()) +} + +fn normalize_license_type(value: &str) -> String { + let mut normalized = String::with_capacity(value.len()); + let mut last_was_dash = false; + + for ch in value.trim().chars() { + let ch = match ch { + '_' | ' ' | '\t' | '\n' | '\r' => '-', + _ => ch.to_ascii_lowercase(), + }; + + if ch == '-' { + if !last_was_dash { + normalized.push(ch); + } + last_was_dash = true; + } else { + normalized.push(ch); + last_was_dash = false; + } + } + + normalized.trim_matches('-').to_string() +} + impl_figment_convert!(VerifyArgs); -impl figment::Provider for VerifyArgs { - fn metadata(&self) -> figment::Metadata { - figment::Metadata::named("Verify Provider") +impl FigmentProvider for VerifyArgs { + fn metadata(&self) -> Metadata { + Metadata::named("Verify Provider") } - fn data( - &self, - ) -> Result, figment::Error> { + fn data(&self) -> Result, Error> { let mut dict = self.etherscan.dict(); dict.extend(self.rpc.dict()); if let Some(root) = self.root.as_ref() { - dict.insert("root".to_string(), figment::value::Value::serialize(root)?); + dict.insert("root".to_string(), Value::serialize(root)?); } if let Some(optimizer_runs) = self.num_of_optimizations { - dict.insert("optimizer".to_string(), figment::value::Value::serialize(true)?); - dict.insert( - "optimizer_runs".to_string(), - figment::value::Value::serialize(optimizer_runs)?, - ); + dict.insert("optimizer".to_string(), Value::serialize(true)?); + dict.insert("optimizer_runs".to_string(), Value::serialize(optimizer_runs)?); } if let Some(evm_version) = self.evm_version { - dict.insert("evm_version".to_string(), figment::value::Value::serialize(evm_version)?); + dict.insert("evm_version".to_string(), Value::serialize(evm_version)?); } if self.via_ir { - dict.insert("via_ir".to_string(), figment::value::Value::serialize(self.via_ir)?); + dict.insert("via_ir".to_string(), Value::serialize(self.via_ir)?); } - if let Some(api_key) = &self.verifier.verifier_api_key { - dict.insert("etherscan_api_key".into(), api_key.as_str().into()); + if self.no_auto_detect { + dict.insert("auto_detect_solc".to_string(), Value::serialize(false)?); } - if let Some(api_version) = &self.verifier.verifier_api_version { - dict.insert("etherscan_api_version".into(), api_version.to_string().into()); + if let Some(ref solc) = self.use_solc { + let solc = solc.trim_start_matches("solc:"); + dict.insert("solc".to_string(), Value::serialize(solc)?); } - Ok(figment::value::Map::from([(Config::selected_profile(), dict)])) + if let Some(api_key) = &self.verifier.verifier_api_key { + dict.insert("etherscan_api_key".into(), api_key.as_str().into()); + } + + Ok(Map::from([(Config::selected_profile(), dict)])) } } +struct ProviderRun { + label: VerificationProviderType, + args: VerifyArgs, + provider: Box, + /// If true, a failed run fails the command; otherwise the failure is logged as a warning. + required: bool, +} + +#[derive(Clone)] +enum RunContext { + Local(Box), + External(Box), +} + impl VerifyArgs { /// Run the verify command to submit the contract's source code for verification on etherscan - pub async fn run(mut self) -> Result<()> { - let config = self.load_config()?; + pub async fn run(self) -> Result<()> { + let context = self.resolve_context().await?; + let config = context.config.clone(); + self.run_with_resolved_context(RunContext::Local(Box::new(context)), config).await + } + + /// Runs verification using caller-provided Standard JSON input. + pub async fn run_with_external_context( + self, + context: ExternalVerificationContext, + ) -> Result<()> { + self.validate_external_args()?; + eyre::ensure!( + !context.target.is_empty() + && context.target.len() <= 8193 + && context.target.is_ascii() + && !context.target.bytes().any(|byte| byte.is_ascii_control()), + "external contract identifier must contain bounded printable ASCII" + ); + let config = context.config.clone(); + self.run_with_resolved_context(RunContext::External(Box::new(context)), config).await + } + + fn validate_external_args(&self) -> Result<()> { + eyre::ensure!(!self.flatten, "flattening is unsupported for external verification"); + eyre::ensure!( + !matches!(self.language, Some(ContractLanguage::Vyper)), + "Vyper is unsupported for external verification" + ); + eyre::ensure!( + !self.guess_constructor_args, + "constructor argument guessing is unsupported for external verification" + ); + eyre::ensure!( + self.constructor_args_path.is_none(), + "constructor args paths are unsupported for external verification; provide encoded constructor args" + ); + Ok(()) + } + async fn run_with_resolved_context( + mut self, + context: RunContext, + config: Config, + ) -> Result<()> { if self.guess_constructor_args && config.get_rpc_url().is_none() { eyre::bail!( "You have to provide a valid RPC URL to use --guess-constructor-args feature" - ) + ); } - // If chain is not set, we try to get it from the RPC. - // If RPC is not set, the default chain is used. let chain = match config.get_rpc_url() { Some(_) => { let provider = utils::get_provider(&config)?; @@ -229,67 +621,194 @@ impl VerifyArgs { None => config.chain.unwrap_or_default(), }; - let context = self.resolve_context().await?; - // Set Etherscan options. self.etherscan.chain = Some(chain); - self.etherscan.key = config.get_etherscan_config_with_chain(Some(chain))?.map(|c| c.key); + // `get_etherscan_config_with_chain` returns None for chains with no known Etherscan API + // URL (even when a key was explicitly passed), because `ResolvedEtherscanConfig::create` + // requires `chain.etherscan_urls()`. Fall back to the raw `etherscan_api_key` from config + // so that the key survives for warning/fallback logic in `client()`. + let config_key = match config.get_etherscan_config_with_chain(Some(chain)) { + Ok(config) => config.map(|config| config.key), + // A user-selected URL does not depend on the optional configured endpoint. Explicit + // Sourcify likewise must not fail because an unrelated Etherscan fallback is invalid. + Err(_) + if self.verifier.verifier_url.is_some() + || self.verifier.verifier.is_some_and(|provider| provider.is_sourcify()) => + { + None + } + Err(err) => return Err(err.into()), + } + .or_else(|| config.etherscan_api_key.clone()); + self.etherscan.key = + self.verifier.resolve_api_key(config_key.as_deref()).map(str::to_owned); + + // Capture whether the user explicitly provided a verifier URL *before* any auto-injection. + // This is passed to `client()` so that an auto-injected Sourcify URL does not look like a + // user-supplied Etherscan-compatible URL and cause the wrong provider to be selected. + let had_user_verifier_url = self.verifier.verifier_url.is_some(); + + // Resolve provider BEFORE URL injection so that the auto-injected Sourcify URL cannot + // influence routing. For custom-Sourcify chains (e.g. Tempo), etherscan_urls() returns + // Some but is_custom_sourcify() excludes them from the Etherscan path in resolve(). + let etherscan_key = self.etherscan.key(); + let resolved = self.verifier.resolve(etherscan_key.as_deref(), self.etherscan.chain); + + // For chains with Sourcify-compatible APIs, inject their URL only when we've resolved to + // Sourcify and the user did not already supply a --verifier-url. + if resolved.is_sourcify() + && !had_user_verifier_url + && let Some(url) = sourcify_api_url(chain) + { + self.verifier.verifier_url = Some(url); + } if self.show_standard_json_input { - let args = EtherscanVerificationProvider::default() - .create_verify_request(&self, &context) - .await?; - sh_println!("{}", args.source)?; + match &context { + RunContext::Local(context) => { + let args = EtherscanVerificationProvider::default() + .create_verify_request(&self, context) + .await?; + sh_println!("{}", args.source)?; + } + RunContext::External(context) => { + sh_println!("{}", serde_json::to_string(&context.standard_json_input)?)?; + } + } return Ok(()); } let verifier_url = self.verifier.verifier_url.clone(); - sh_println!("Start verifying contract `{}` deployed on {chain}", self.address)?; + sh_status!("Start verifying contract `{}` deployed on {chain}", self.address)?; if let Some(version) = &self.evm_version { - sh_println!("EVM version: {version}")?; + sh_status!("EVM version: {version}")?; } if let Some(version) = &self.compiler_version { - sh_println!("Compiler version: {version}")?; + sh_status!("Compiler version: {version}")?; } if let Some(optimizations) = &self.num_of_optimizations { - sh_println!("Optimizations: {optimizations}")? + sh_status!("Optimizations: {optimizations}")? } if let Some(args) = &self.constructor_args && !args.is_empty() { - sh_println!("Constructor args: {args}")? + sh_status!("Constructor args: {args}")? } - self.verifier.verifier.client(self.etherscan.key().as_deref())?.verify(self, context).await.map_err(|err| { - if let Some(verifier_url) = verifier_url { - match Url::parse(&verifier_url) { - Ok(url) => { - if is_host_only(&url) { - return err.wrap_err(format!( - "Provided URL `{verifier_url}` is host only.\n Did you mean to use the API endpoint`{verifier_url}/api` ?" - )) - } + let using_etherscan = resolved.is_etherscan(); + let runs = + self.collect_runs(chain, etherscan_key.as_deref(), resolved, had_user_verifier_url)?; + + // Submit every provider before polling any of them + let mut required_err = None; + let mut pending_check = None; + for ProviderRun { label, args, mut provider, required } in runs { + sh_status!("\nVerifying on {label}...")?; + let watch = args.watch; + let submission = match context.clone() { + RunContext::Local(context) => provider.submit(args, *context).await, + RunContext::External(context) => provider.submit_external(args, *context).await, + }; + match submission { + Ok(check_args) => { + if required + && watch + && let Some(check_args) = check_args + { + pending_check = Some((label, provider, check_args)); } - Err(url_err) => { - return err.wrap_err(format!( - "Invalid URL {verifier_url} provided: {url_err}" - )) + } + Err(err) => { + if required { + required_err = Some(wrap_verifier_url_error( + err, + verifier_url.as_deref(), + using_etherscan, + )); + } else { + sh_warn!("{label} verification failed: {err}")?; } } } + } - err - }) + // Poll the primary submission for completion + if required_err.is_none() + && let Some((label, provider, check_args)) = pending_check + { + sh_status!("\nWaiting for {label} verification result...")?; + if let Err(err) = provider.check(check_args).await { + required_err = + Some(wrap_verifier_url_error(err, verifier_url.as_deref(), using_etherscan)); + } + } + + required_err.map_or(Ok(()), Err) + } + + /// Plans the set of verification submissions to run for this invocation. + /// + /// `resolved` is the decision from [`VerifierArgs::resolve`] for the primary verifier. + /// Sourcify is added as an auxiliary run whenever the primary is not Sourcify. + fn collect_runs( + &self, + chain: Chain, + etherscan_key: Option<&str>, + resolved: VerificationProviderType, + had_user_verifier_url: bool, + ) -> Result> { + let mut runs = Vec::new(); + + let primary_provider = resolved.client( + etherscan_key, + self.etherscan.chain, + had_user_verifier_url, + self.verifier.is_explicitly_set(), + )?; + let primary_is_sourcify = resolved.is_sourcify(); + runs.push(ProviderRun { + label: resolved, + args: self.clone(), + provider: primary_provider, + required: true, + }); + + // Skip the auxiliary Sourcify submission when the user appears to be using a + // non-public setup: an explicit `--verifier-url`, `--verifier custom`, or a + // local/dev chain. + let is_private_setup = had_user_verifier_url || resolved.is_custom() || is_dev_chain(chain); + if !primary_is_sourcify && !is_private_setup { + let mut args = self.clone(); + args.verifier.verifier = Some(VerificationProviderType::Sourcify); + args.verifier.verifier_api_key = None; + // For chains with Sourcify-compatible APIs, use the chain's URL from etherscan_urls + // Otherwise, drop the URL so Sourcify falls back to its default. + args.verifier.verifier_url = sourcify_api_url(chain); + args.watch = false; + runs.push(ProviderRun { + label: VerificationProviderType::Sourcify, + args, + provider: Box::::default(), + required: false, + }); + } + + Ok(runs) } /// Returns the configured verification provider pub fn verification_provider(&self) -> Result> { - self.verifier.verifier.client(self.etherscan.key().as_deref()) + self.verifier.effective_type().client( + self.etherscan.key().as_deref(), + self.etherscan.chain, + self.verifier.verifier_url.is_some(), + self.verifier.is_explicitly_set(), + ) } /// Resolves [VerificationContext] object either from entered contract name or by trying to /// match bytecode located at given address. pub async fn resolve_context(&self) -> Result { - let mut config = self.load_config()?; + let mut config = self.load_config_with_dependencies()?; config.libraries.extend(self.libraries.clone()); let project = config.project()?; @@ -306,10 +825,7 @@ impl VerifyArgs { let mut version = if let Some(ref version) = self.compiler_version { version.trim_start_matches('v').parse()? } else if let Some(ref solc) = config.solc { - match solc { - SolcReq::Version(version) => version.to_owned(), - SolcReq::Local(solc) => Solc::new(solc)?.version, - } + solc.try_version()? } else if let Some(entry) = cache.as_ref().and_then(|cache| cache.files.get(&contract_path).cloned()) { @@ -320,7 +836,11 @@ impl VerifyArgs { .unwrap_or_default(); if unique_versions.is_empty() { - eyre::bail!("No matching artifact found for {}", contract.name); + eyre::bail!( + "No matching artifact found for {}. This could be due to:\n\ + - Compiler version mismatch - the contract was compiled with a different Solidity version than what's being used for verification", + contract.name + ); } else if unique_versions.len() > 1 { warn!( "Ambiguous compiler versions found in cache: {}", @@ -328,14 +848,14 @@ impl VerifyArgs { ); eyre::bail!( "Compiler version has to be set in `foundry.toml`. If the project was not deployed with foundry, specify the version through `--compiler-version` flag." - ) + ); } unique_versions.into_iter().next().unwrap().to_owned() } else { eyre::bail!( "If cache is disabled, compiler version must be either provided with `--compiler-version` option or set in foundry.toml" - ) + ); }; let settings = if let Some(profile) = &self.compilation_profile { @@ -344,7 +864,7 @@ impl VerifyArgs { } else if let Some(settings) = project.additional_settings.get(profile.as_str()) { settings } else { - eyre::bail!("Unknown compilation profile: {}", profile) + eyre::bail!("Unknown compilation profile: {}", profile); } } else if let Some((cache, entry)) = cache .as_ref() @@ -372,12 +892,17 @@ impl VerifyArgs { .unwrap_or_default(); if profiles.is_empty() { - eyre::bail!("No matching artifact found for {}", contract.name); + eyre::bail!( + "No matching artifact found for {} with compiler version {}. This could be due to:\n\ + - Compiler version mismatch - the contract was compiled with a different Solidity version", + contract.name, + version + ); } else if profiles.len() > 1 { eyre::bail!( "Ambiguous compilation profiles found in cache: {}, please specify the profile through `--compilation-profile` flag", profiles.iter().join(", ") - ) + ); } let profile = profiles.into_iter().next().unwrap().to_owned(); @@ -386,8 +911,8 @@ impl VerifyArgs { &project.settings } else { eyre::bail!( - "If cache is disabled, compilation profile must be provided with `--compiler-version` option or set in foundry.toml" - ) + "If cache is disabled, compilation profile must be provided with `--compilation-profile` option or set in foundry.toml" + ); }; VerificationContext::new( @@ -399,21 +924,23 @@ impl VerifyArgs { ) } else { if config.get_rpc_url().is_none() { - eyre::bail!("You have to provide a contract name or a valid RPC URL") + eyre::bail!("You have to provide a contract name or a valid RPC URL"); } let provider = utils::get_provider(&config)?; let code = provider.get_code_at(self.address).await?; - let output = ProjectCompiler::new().compile(&project)?; + let output = ProjectCompiler::new().quiet(true).compile(&project)?; let contracts = ContractsByArtifact::new( output.artifact_ids().map(|(id, artifact)| (id, artifact.clone().into())), ); - let Some((artifact_id, _)) = contracts.find_by_deployed_code_exact(&code) else { + let Some((artifact_id, _)) = + contracts.find_by_deployed_code_exact_unambiguous(&code)? + else { eyre::bail!(format!( "Bytecode at {} does not match any local contracts", self.address - )) + )); }; let settings = project @@ -451,7 +978,7 @@ pub struct VerifyCheckArgs { /// /// For Etherscan - Submission GUID. /// - /// For Sourcify - Contract Address. + /// For Sourcify - Verification Job ID. pub id: String, #[command(flatten)] @@ -468,40 +995,103 @@ impl_figment_convert_cast!(VerifyCheckArgs); impl VerifyCheckArgs { /// Run the verify command to submit the contract's source code for verification on etherscan - pub async fn run(self) -> Result<()> { - sh_println!( - "Checking verification status on {}", - self.etherscan.chain.unwrap_or_default() - )?; - self.verifier.verifier.client(self.etherscan.key().as_deref())?.check(self).await + pub async fn run(mut self) -> Result<()> { + self.etherscan.key = + self.verifier.resolve_api_key(self.etherscan.key.as_deref()).map(str::to_owned); + sh_status!("Checking verification status on {}", self.etherscan.chain.unwrap_or_default())?; + self.verifier + .effective_type() + .client( + self.etherscan.key().as_deref(), + self.etherscan.chain, + self.verifier.verifier_url.is_some(), + self.verifier.is_explicitly_set(), + )? + .check(self) + .await } } -impl figment::Provider for VerifyCheckArgs { - fn metadata(&self) -> figment::Metadata { - figment::Metadata::named("Verify Check Provider") +impl FigmentProvider for VerifyCheckArgs { + fn metadata(&self) -> Metadata { + Metadata::named("Verify Check Provider") } - fn data( - &self, - ) -> Result, figment::Error> { + fn data(&self) -> Result, Error> { let mut dict = self.etherscan.dict(); if let Some(api_key) = &self.etherscan.key { dict.insert("etherscan_api_key".into(), api_key.as_str().into()); } - if let Some(api_version) = &self.etherscan.api_version { - dict.insert("etherscan_api_version".into(), api_version.to_string().into()); - } + Ok(Map::from([(Config::selected_profile(), dict)])) + } +} - Ok(figment::value::Map::from([(Config::selected_profile(), dict)])) +/// Returns the Sourcify-compatible API URL for chains that have one registered in `etherscan_urls`. +/// +/// Some chains register their Sourcify-compatible verification API under `etherscan_urls` in +/// alloy-chains. This function returns the properly formatted URL for such chains. +pub fn sourcify_api_url(chain: Chain) -> Option { + if chain.is_custom_sourcify() { + chain.etherscan_urls().map(|(api_url, _)| { + let api_url = api_url.trim_end_matches('/'); + format!("{api_url}/") + }) + } else { + None } } +/// Returns `true` for local/dev chains. +const fn is_dev_chain(chain: Chain) -> bool { + use foundry_config::NamedChain; + matches!(chain.named(), Some(NamedChain::Dev | NamedChain::AnvilHardhat | NamedChain::Cannon)) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn verifier_api_key_takes_precedence_over_etherscan_key() { + let args = VerifierArgs { verifier_api_key: Some("explicit".into()), ..Default::default() }; + assert_eq!(args.resolve_api_key(Some("config")), Some("explicit")); + assert_eq!(VerifierArgs::default().resolve_api_key(Some("config")), Some("config")); + } + + #[tokio::test] + async fn external_context_rejects_control_characters_in_fqn() { + let args = + VerifyArgs::parse_from(["foundry-cli", "0xd8509bee9c9bf012282ad33aba0d87241baf5064"]); + let context = ExternalVerificationContext { + config: Config::default(), + compiler_version: semver::Version::new(0, 8, 30), + standard_json_input: std::sync::Arc::new(serde_json::json!({})), + target: "A.sol:A\nforged".into(), + }; + assert!( + args.run_with_external_context(context) + .await + .unwrap_err() + .to_string() + .contains("printable") + ); + } + + #[test] + fn external_run_context_clone_shares_standard_json() { + let input = std::sync::Arc::new(serde_json::json!({"large": [1, 2, 3]})); + let context = RunContext::External(Box::new(ExternalVerificationContext { + config: Config::default(), + compiler_version: semver::Version::new(0, 8, 30), + standard_json_input: input.clone(), + target: "A.sol:A".into(), + })); + let RunContext::External(context) = &context else { unreachable!() }; + let cloned = context.clone(); + assert!(std::sync::Arc::ptr_eq(&input, &cloned.standard_json_input)); + } + #[test] fn can_parse_verify_contract() { let args: VerifyArgs = VerifyArgs::parse_from([ @@ -509,7 +1099,327 @@ mod tests { "0x0000000000000000000000000000000000000000", "src/Domains.sol:Domains", "--via-ir", + "--license-type", + "13", ]); assert!(args.via_ir); + assert_eq!(args.license_type.as_deref(), Some("13")); + } + + #[test] + fn can_parse_verify_contract_license_type_spdx() { + let args: VerifyArgs = VerifyArgs::parse_from([ + "foundry-cli", + "0x0000000000000000000000000000000000000000", + "src/Domains.sol:Domains", + "--license-type", + "AGPL-3.0-or-later", + ]); + assert_eq!(args.license_type.as_deref(), Some("13")); + + let args: VerifyArgs = VerifyArgs::parse_from([ + "foundry-cli", + "0x0000000000000000000000000000000000000000", + "src/Domains.sol:Domains", + "--license-type", + "MIT", + ]); + assert_eq!(args.license_type.as_deref(), Some("3")); + + let args: VerifyArgs = VerifyArgs::parse_from([ + "foundry-cli", + "0x0000000000000000000000000000000000000000", + "src/Domains.sol:Domains", + "--license-type", + "apache 2.0", + ]); + assert_eq!(args.license_type.as_deref(), Some("12")); + } + + #[test] + fn verify_contract_license_type_is_case_insensitive() { + for variant in ["mit", "MIT", "Mit", "mIt"] { + let args: VerifyArgs = VerifyArgs::parse_from([ + "foundry-cli", + "0x0000000000000000000000000000000000000000", + "src/Domains.sol:Domains", + "--license-type", + variant, + ]); + assert_eq!(args.license_type.as_deref(), Some("3"), "input: {variant}"); + } + } + + #[test] + fn verify_contract_license_type_accepts_numeric_codes() { + for (code, expected) in [("1", "1"), ("14", "14"), ("15", "15")] { + let args: VerifyArgs = VerifyArgs::parse_from([ + "foundry-cli", + "0x0000000000000000000000000000000000000000", + "src/Domains.sol:Domains", + "--license-type", + code, + ]); + assert_eq!(args.license_type.as_deref(), Some(expected)); + } + } + + #[test] + fn errors_on_invalid_verify_contract_license_type() { + let err = VerifyArgs::try_parse_from([ + "foundry-cli", + "0x0000000000000000000000000000000000000000", + "src/Domains.sol:Domains", + "--license-type", + "Unknown-License", + ]) + .unwrap_err(); + assert!(err.to_string().contains("unsupported Etherscan license type")); + } + + #[test] + fn can_parse_new_compiler_flags() { + let args: VerifyArgs = VerifyArgs::parse_from([ + "foundry-cli", + "0x0000000000000000000000000000000000000000", + "src/Domains.sol:Domains", + "--no-auto-detect", + "--use", + "0.8.23", + ]); + assert!(args.no_auto_detect); + assert_eq!(args.use_solc.as_deref(), Some("0.8.23")); + } + + #[test] + fn classify_verifier_probe_accepts_not_verified_response() { + let body = + r#"{"status":"0","message":"NOTOK","result":"Contract source code not verified"}"#; + assert_eq!( + classify_verifier_credential_response(StatusCode::OK, body), + VerifierCredentialProbe::Accepted, + ); + } + + #[test] + fn classify_verifier_probe_rejects_invalid_api_key() { + let body = r#"{"status":"0","message":"NOTOK","result":"Invalid API Key"}"#; + assert_eq!( + classify_verifier_credential_response(StatusCode::OK, body), + VerifierCredentialProbe::InvalidApiKey, + ); + assert_eq!( + classify_verifier_credential_response(StatusCode::UNAUTHORIZED, ""), + VerifierCredentialProbe::InvalidApiKey, + ); + } + + #[test] + fn classify_verifier_probe_treats_transient_errors_as_inconclusive() { + let body = r#"{"status":"0","message":"NOTOK","result":"Max rate limit reached"}"#; + assert_eq!( + classify_verifier_credential_response(StatusCode::OK, body), + VerifierCredentialProbe::Inconclusive, + ); + assert_eq!( + classify_verifier_credential_response( + StatusCode::OK, + "Checking if the site connection is secure", + ), + VerifierCredentialProbe::Inconclusive, + ); + assert_eq!( + classify_verifier_credential_response( + StatusCode::FORBIDDEN, + "Sorry, you have been blocked", + ), + VerifierCredentialProbe::Inconclusive, + ); + assert_eq!( + classify_verifier_credential_response(StatusCode::FORBIDDEN, ""), + VerifierCredentialProbe::Inconclusive, + ); + } + + #[test] + fn parse_http_verifier_url_rejects_unsupported_schemes() { + assert!(parse_http_verifier_url("https://example.com/api", "verifier").is_ok()); + assert!(parse_http_verifier_url("http://example.com/api", "verifier").is_ok()); + + let err = parse_http_verifier_url("gopher://example.com/api", "verifier").unwrap_err(); + assert_eq!(err.to_string(), "invalid verifier URL: URL scheme must be http or https"); + } + + #[test] + fn resolve_explicit_sourcify_overrides_api_key() { + let args = VerifierArgs { + verifier: Some(VerificationProviderType::Sourcify), + verifier_api_key: None, + verifier_url: None, + }; + assert_eq!( + args.resolve(Some("mykey"), Some(Chain::mainnet())), + VerificationProviderType::Sourcify, + ); + } + + #[test] + fn resolve_explicit_etherscan_is_etherscan() { + let args = VerifierArgs { + verifier: Some(VerificationProviderType::Etherscan), + verifier_api_key: None, + verifier_url: None, + }; + assert_eq!( + args.resolve(Some("mykey"), Some(Chain::mainnet())), + VerificationProviderType::Etherscan, + ); + } + + #[test] + fn resolve_implicit_with_key_and_known_chain_uses_etherscan() { + let args = VerifierArgs { verifier: None, verifier_api_key: None, verifier_url: None }; + assert_eq!( + args.resolve(Some("mykey"), Some(Chain::mainnet())), + VerificationProviderType::Etherscan, + ); + } + + #[test] + fn resolve_implicit_with_key_and_unknown_chain_falls_back_to_sourcify() { + let args = VerifierArgs { verifier: None, verifier_api_key: None, verifier_url: None }; + assert_eq!( + args.resolve(Some("mykey"), Some(Chain::from(3658348u64))), + VerificationProviderType::Sourcify, + ); + } + + #[test] + fn resolve_implicit_with_key_and_unknown_chain_but_url_uses_etherscan() { + let args = VerifierArgs { + verifier: None, + verifier_api_key: None, + verifier_url: Some("https://example.com/api".to_string()), + }; + assert_eq!( + args.resolve(Some("mykey"), Some(Chain::from(3658348u64))), + VerificationProviderType::Etherscan, + ); + } + + #[test] + fn resolve_implicit_no_key_falls_back_to_sourcify() { + let args = VerifierArgs { verifier: None, verifier_api_key: None, verifier_url: None }; + assert_eq!(args.resolve(None, Some(Chain::mainnet())), VerificationProviderType::Sourcify,); + } + + // Regression: custom-Sourcify chains (e.g. Tempo) register Sourcify-compatible URLs under + // etherscan_urls(). An implicit ETHERSCAN_API_KEY must NOT route them to Etherscan. + #[test] + fn resolve_implicit_with_key_and_custom_sourcify_chain_falls_back_to_sourcify() { + let tempo = Chain::from(4217u64); // NamedChain::Tempo + assert!(tempo.is_custom_sourcify(), "sanity: Tempo should be is_custom_sourcify"); + let args = VerifierArgs { verifier: None, verifier_api_key: None, verifier_url: None }; + assert_eq!(args.resolve(Some("mykey"), Some(tempo)), VerificationProviderType::Sourcify,); + } + + // Ensure the is_custom_sourcify() guard holds even when a URL is present (e.g. the URL was + // auto-injected by run()). A user-supplied --verifier-url on a custom-Sourcify chain with a + // key should still resolve to Sourcify, not Etherscan. + #[test] + fn resolve_custom_sourcify_chain_with_url_and_key_stays_sourcify() { + let tempo = Chain::from(4217u64); + let args = VerifierArgs { + verifier: None, + verifier_api_key: None, + verifier_url: Some("https://contracts.tempo.xyz/".to_string()), + }; + assert_eq!(args.resolve(Some("mykey"), Some(tempo)), VerificationProviderType::Sourcify,); + } + + #[test] + fn collect_runs_adds_sourcify_provider() { + let args: VerifyArgs = VerifyArgs::parse_from([ + "foundry-cli", + "0x0000000000000000000000000000000000000000", + "src/Counter.sol:Counter", + "--etherscan-api-key", + "k", + ]); + let resolved = args.verifier.resolve(Some("k"), Some(Chain::mainnet())); + let runs = args.collect_runs(Chain::mainnet(), Some("k"), resolved, false).unwrap(); + assert_eq!(runs.len(), 2); + assert_eq!(runs[0].label, VerificationProviderType::Etherscan); + assert!(runs[0].required); + assert_eq!(runs[1].label, VerificationProviderType::Sourcify); + assert!(!runs[1].required); + assert!(runs[1].args.verifier.verifier_api_key.is_none()); + } + + #[test] + fn collect_runs_skips_secondary_when_primary_is_sourcify() { + let args: VerifyArgs = VerifyArgs::parse_from([ + "foundry-cli", + "0x0000000000000000000000000000000000000000", + "src/Counter.sol:Counter", + "--verifier", + "sourcify", + ]); + let resolved = args.verifier.resolve(None, Some(Chain::mainnet())); + let runs = args.collect_runs(Chain::mainnet(), None, resolved, false).unwrap(); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].label, VerificationProviderType::Sourcify); + assert!(runs[0].required); + } + + #[test] + fn collect_runs_skips_secondary_for_custom_verifier() { + let args: VerifyArgs = VerifyArgs::parse_from([ + "foundry-cli", + "0x0000000000000000000000000000000000000000", + "src/Counter.sol:Counter", + "--verifier", + "custom", + "--verifier-url", + "https://internal.example.com/api", + ]); + let runs = args + .collect_runs(Chain::mainnet(), None, VerificationProviderType::Custom, true) + .unwrap(); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].label, VerificationProviderType::Custom); + } + + #[test] + fn collect_runs_skips_secondary_on_dev_chain() { + let args: VerifyArgs = VerifyArgs::parse_from([ + "foundry-cli", + "0x0000000000000000000000000000000000000000", + "src/Counter.sol:Counter", + "--etherscan-api-key", + "k", + ]); + let anvil = Chain::from(31337u64); + let resolved = args.verifier.resolve(Some("k"), Some(anvil)); + let runs = args.collect_runs(anvil, Some("k"), resolved, false).unwrap(); + assert_eq!(runs.len(), 1); + assert!(runs[0].required); + } + + #[test] + fn collect_runs_skips_secondary_with_user_verifier_url() { + let args: VerifyArgs = VerifyArgs::parse_from([ + "foundry-cli", + "0x0000000000000000000000000000000000000000", + "src/Counter.sol:Counter", + "--verifier", + "blockscout", + "--verifier-url", + "https://internal-blockscout.example.com/api", + ]); + let resolved = args.verifier.resolve(None, Some(Chain::mainnet())); + let runs = args.collect_runs(Chain::mainnet(), None, resolved, true).unwrap(); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].label, VerificationProviderType::Blockscout); } } diff --git a/crates/wallets/Cargo.toml b/crates/wallets/Cargo.toml deleted file mode 100644 index 022221b97ae7e..0000000000000 --- a/crates/wallets/Cargo.toml +++ /dev/null @@ -1,55 +0,0 @@ -[package] -name = "foundry-wallets" - -version.workspace = true -edition.workspace = true -rust-version.workspace = true -authors.workspace = true -license.workspace = true -homepage.workspace = true -repository.workspace = true - -[lints] -workspace = true - -[dependencies] -foundry-config.workspace = true - -alloy-primitives.workspace = true -alloy-signer = { workspace = true, features = ["eip712"] } -alloy-signer-local = { workspace = true, features = ["mnemonic", "keystore"] } -alloy-signer-ledger = { workspace = true, features = ["eip712"] } -alloy-signer-trezor.workspace = true -alloy-network.workspace = true -alloy-consensus.workspace = true -alloy-sol-types.workspace = true -alloy-dyn-abi.workspace = true - -# aws-kms -alloy-signer-aws = { workspace = true, features = ["eip712"], optional = true } -aws-config = { workspace = true, default-features = true, optional = true } -aws-sdk-kms = { workspace = true, default-features = false, optional = true } - -# gcp-kms -alloy-signer-gcp = { workspace = true, features = ["eip712"], optional = true } -gcloud-sdk = { version = "0.27.3", features = [ - "google-cloud-kms-v1", - "google-longrunning", -], optional = true } - -async-trait.workspace = true -clap = { version = "4", features = ["derive", "env", "unicode", "wrap_help"] } -derive_builder = "0.20" -eyre.workspace = true -rpassword = "7" -serde.workspace = true -thiserror.workspace = true -tracing.workspace = true -eth-keystore = "0.5.0" - -[dev-dependencies] -tokio = { workspace = true, features = ["macros"] } - -[features] -aws-kms = ["dep:alloy-signer-aws", "dep:aws-config", "dep:aws-sdk-kms"] -gcp-kms = ["dep:alloy-signer-gcp", "dep:gcloud-sdk"] diff --git a/crates/wallets/src/error.rs b/crates/wallets/src/error.rs deleted file mode 100644 index b322ef9797f07..0000000000000 --- a/crates/wallets/src/error.rs +++ /dev/null @@ -1,57 +0,0 @@ -use alloy_primitives::hex::FromHexError; -use alloy_signer::k256::ecdsa; -use alloy_signer_ledger::LedgerError; -use alloy_signer_local::LocalSignerError; -use alloy_signer_trezor::TrezorError; - -#[cfg(feature = "aws-kms")] -use alloy_signer_aws::AwsSignerError; - -#[cfg(feature = "gcp-kms")] -use alloy_signer_gcp::GcpSignerError; - -#[derive(Debug, thiserror::Error)] -pub enum PrivateKeyError { - #[error("Failed to create wallet from private key. Private key is invalid hex: {0}")] - InvalidHex(#[from] FromHexError), - #[error( - "Failed to create wallet from private key. Invalid private key. But env var {0} exists. Is the `$` anchor missing?" - )] - ExistsAsEnvVar(String), -} - -#[derive(Debug, thiserror::Error)] -pub enum WalletSignerError { - #[error(transparent)] - Local(#[from] LocalSignerError), - #[error("Failed to decrypt keystore: incorrect password")] - IncorrectKeystorePassword, - #[error(transparent)] - Ledger(#[from] LedgerError), - #[error(transparent)] - Trezor(#[from] TrezorError), - #[error(transparent)] - #[cfg(feature = "aws-kms")] - Aws(#[from] AwsSignerError), - #[error(transparent)] - #[cfg(feature = "gcp-kms")] - Gcp(#[from] GcpSignerError), - #[error(transparent)] - Io(#[from] std::io::Error), - #[error(transparent)] - InvalidHex(#[from] FromHexError), - #[error(transparent)] - Ecdsa(#[from] ecdsa::Error), - #[error("foundry was not built with support for {0} signer")] - UnsupportedSigner(&'static str), -} - -impl WalletSignerError { - pub fn aws_unsupported() -> Self { - Self::UnsupportedSigner("AWS KMS") - } - - pub fn gcp_unsupported() -> Self { - Self::UnsupportedSigner("Google Cloud KMS") - } -} diff --git a/crates/wallets/src/lib.rs b/crates/wallets/src/lib.rs deleted file mode 100644 index e3be0971e47a9..0000000000000 --- a/crates/wallets/src/lib.rs +++ /dev/null @@ -1,21 +0,0 @@ -//! # foundry-wallets -//! -//! Utilities for working with multiple signers. - -#![cfg_attr(not(test), warn(unused_crate_dependencies))] -#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] - -#[macro_use] -extern crate tracing; - -pub mod error; -pub mod multi_wallet; -pub mod raw_wallet; -pub mod utils; -pub mod wallet; -pub mod wallet_signer; - -pub use multi_wallet::MultiWalletOpts; -pub use raw_wallet::RawWalletOpts; -pub use wallet::WalletOpts; -pub use wallet_signer::{PendingSigner, WalletSigner}; diff --git a/crates/wallets/src/multi_wallet.rs b/crates/wallets/src/multi_wallet.rs deleted file mode 100644 index a4ba3568a6cab..0000000000000 --- a/crates/wallets/src/multi_wallet.rs +++ /dev/null @@ -1,530 +0,0 @@ -use crate::{ - utils, - wallet_signer::{PendingSigner, WalletSigner}, -}; -use alloy_primitives::{Address, map::AddressHashMap}; -use alloy_signer::Signer; -use clap::Parser; -use derive_builder::Builder; -use eyre::Result; -use foundry_config::Config; -use serde::Serialize; -use std::path::PathBuf; - -/// Container for multiple wallets. -#[derive(Debug, Default)] -pub struct MultiWallet { - /// Vector of wallets that require an action to be unlocked. - /// Those are lazily unlocked on the first access of the signers. - pending_signers: Vec, - /// Contains unlocked signers. - signers: AddressHashMap, -} - -impl MultiWallet { - pub fn new(pending_signers: Vec, signers: Vec) -> Self { - let signers = signers.into_iter().map(|signer| (signer.address(), signer)).collect(); - Self { pending_signers, signers } - } - - fn maybe_unlock_pending(&mut self) -> Result<()> { - for pending in self.pending_signers.drain(..) { - let signer = pending.unlock()?; - self.signers.insert(signer.address(), signer); - } - Ok(()) - } - - pub fn signers(&mut self) -> Result<&AddressHashMap> { - self.maybe_unlock_pending()?; - Ok(&self.signers) - } - - pub fn into_signers(mut self) -> Result> { - self.maybe_unlock_pending()?; - Ok(self.signers) - } - - pub fn add_signer(&mut self, signer: WalletSigner) { - self.signers.insert(signer.address(), signer); - } -} - -/// A macro that initializes multiple wallets -/// -/// Should be used with a [`MultiWallet`] instance -macro_rules! create_hw_wallets { - ($self:ident, $create_signer:expr, $signers:ident) => { - let mut $signers = vec![]; - - if let Some(hd_paths) = &$self.hd_paths { - for path in hd_paths { - let hw = $create_signer(Some(path), 0).await?; - $signers.push(hw); - } - } - - if let Some(mnemonic_indexes) = &$self.mnemonic_indexes { - for index in mnemonic_indexes { - let hw = $create_signer(None, *index).await?; - $signers.push(hw); - } - } - - if $signers.is_empty() { - let hw = $create_signer(None, 0).await?; - $signers.push(hw); - } - }; -} - -/// The wallet options can either be: -/// 1. Ledger -/// 2. Trezor -/// 3. Mnemonics (via file path) -/// 4. Keystores (via file path) -/// 5. Private Keys (cleartext in CLI) -/// 6. Private Keys (interactively via secure prompt) -/// 7. AWS KMS -#[derive(Builder, Clone, Debug, Default, Serialize, Parser)] -#[command(next_help_heading = "Wallet options", about = None, long_about = None)] -pub struct MultiWalletOpts { - /// The sender accounts for transactions when using local signers (private keys, keystores, - /// hardware wallets, etc.). - /// - /// These addresses are derived from the provided private keys/signers and specify which - /// accounts to use for signing transactions locally. This is different from --unlocked - /// which uses eth_sendTransaction with pre-unlocked accounts on the RPC. - #[arg( - long, - short = 'a', - help_heading = "Wallet options - raw", - value_name = "ADDRESSES", - env = "ETH_FROM", - num_args(0..), - )] - #[builder(default = "None")] - pub froms: Option>, - - /// Open an interactive prompt to enter your private key. - /// - /// Takes a value for the number of keys to enter. - #[arg( - long, - short, - help_heading = "Wallet options - raw", - default_value = "0", - value_name = "NUM" - )] - pub interactives: u32, - - /// Use the provided private keys. - #[arg(long, help_heading = "Wallet options - raw", value_name = "RAW_PRIVATE_KEYS")] - #[builder(default = "None")] - pub private_keys: Option>, - - /// Use the provided private key. - #[arg( - long, - help_heading = "Wallet options - raw", - conflicts_with = "private_keys", - value_name = "RAW_PRIVATE_KEY" - )] - #[builder(default = "None")] - pub private_key: Option, - - /// Use the mnemonic phrases of mnemonic files at the specified paths. - #[arg(long, alias = "mnemonic-paths", help_heading = "Wallet options - raw")] - #[builder(default = "None")] - pub mnemonics: Option>, - - /// Use a BIP39 passphrases for the mnemonic. - #[arg(long, help_heading = "Wallet options - raw", value_name = "PASSPHRASE")] - #[builder(default = "None")] - pub mnemonic_passphrases: Option>, - - /// The wallet derivation path. - /// - /// Works with both --mnemonic-path and hardware wallets. - #[arg( - long = "mnemonic-derivation-paths", - alias = "hd-paths", - help_heading = "Wallet options - raw", - value_name = "PATH" - )] - #[builder(default = "None")] - pub hd_paths: Option>, - - /// Use the private key from the given mnemonic index. - /// - /// Can be used with --mnemonics, --ledger, --aws and --trezor. - #[arg( - long, - conflicts_with = "hd_paths", - help_heading = "Wallet options - raw", - default_value = "0", - value_name = "INDEXES" - )] - pub mnemonic_indexes: Option>, - - /// Use the keystore by its filename in the given folder. - #[arg( - long = "keystore", - visible_alias = "keystores", - help_heading = "Wallet options - keystore", - value_name = "PATHS", - env = "ETH_KEYSTORE" - )] - #[builder(default = "None")] - pub keystore_paths: Option>, - - /// Use a keystore from the default keystores folder (~/.foundry/keystores) by its filename. - #[arg( - long = "account", - visible_alias = "accounts", - help_heading = "Wallet options - keystore", - value_name = "ACCOUNT_NAMES", - env = "ETH_KEYSTORE_ACCOUNT", - conflicts_with = "keystore_paths" - )] - #[builder(default = "None")] - pub keystore_account_names: Option>, - - /// The keystore password. - /// - /// Used with --keystore. - #[arg( - long = "password", - help_heading = "Wallet options - keystore", - requires = "keystore_paths", - value_name = "PASSWORDS" - )] - #[builder(default = "None")] - pub keystore_passwords: Option>, - - /// The keystore password file path. - /// - /// Used with --keystore. - #[arg( - long = "password-file", - help_heading = "Wallet options - keystore", - requires = "keystore_paths", - value_name = "PATHS", - env = "ETH_PASSWORD" - )] - #[builder(default = "None")] - pub keystore_password_files: Option>, - - /// Use a Ledger hardware wallet. - #[arg(long, short, help_heading = "Wallet options - hardware wallet")] - pub ledger: bool, - - /// Use a Trezor hardware wallet. - #[arg(long, short, help_heading = "Wallet options - hardware wallet")] - pub trezor: bool, - - /// Use AWS Key Management Service. - #[arg(long, help_heading = "Wallet options - remote", hide = !cfg!(feature = "aws-kms"))] - pub aws: bool, - - /// Use Google Cloud Key Management Service. - #[arg(long, help_heading = "Wallet options - remote", hide = !cfg!(feature = "gcp-kms"))] - pub gcp: bool, -} - -impl MultiWalletOpts { - /// Returns [MultiWallet] container configured with provided options. - pub async fn get_multi_wallet(&self) -> Result { - let mut pending = Vec::new(); - let mut signers: Vec = Vec::new(); - - if let Some(ledgers) = self.ledgers().await? { - signers.extend(ledgers); - } - if let Some(trezors) = self.trezors().await? { - signers.extend(trezors); - } - if let Some(aws_signers) = self.aws_signers().await? { - signers.extend(aws_signers); - } - if let Some(gcp_signer) = self.gcp_signers().await? { - signers.extend(gcp_signer); - } - if let Some((pending_keystores, unlocked)) = self.keystores()? { - pending.extend(pending_keystores); - signers.extend(unlocked); - } - if let Some(pks) = self.private_keys()? { - signers.extend(pks); - } - if let Some(mnemonics) = self.mnemonics()? { - signers.extend(mnemonics); - } - if self.interactives > 0 { - pending.extend(std::iter::repeat_n( - PendingSigner::Interactive, - self.interactives as usize, - )); - } - - Ok(MultiWallet::new(pending, signers)) - } - - pub fn private_keys(&self) -> Result>> { - let mut pks = vec![]; - if let Some(private_key) = &self.private_key { - pks.push(private_key); - } - if let Some(private_keys) = &self.private_keys { - for pk in private_keys { - pks.push(pk); - } - } - if !pks.is_empty() { - let wallets = pks - .into_iter() - .map(|pk| utils::create_private_key_signer(pk)) - .collect::>>()?; - Ok(Some(wallets)) - } else { - Ok(None) - } - } - - fn keystore_paths(&self) -> Result>> { - if let Some(keystore_paths) = &self.keystore_paths { - return Ok(Some(keystore_paths.iter().map(PathBuf::from).collect())); - } - if let Some(keystore_account_names) = &self.keystore_account_names { - let default_keystore_dir = Config::foundry_keystores_dir() - .ok_or_else(|| eyre::eyre!("Could not find the default keystore directory."))?; - return Ok(Some( - keystore_account_names - .iter() - .map(|keystore_name| default_keystore_dir.join(keystore_name)) - .collect(), - )); - } - Ok(None) - } - - /// Returns all wallets read from the provided keystores arguments - /// - /// Returns `Ok(None)` if no keystore provided. - pub fn keystores(&self) -> Result, Vec)>> { - if let Some(keystore_paths) = self.keystore_paths()? { - let mut pending = Vec::new(); - let mut signers = Vec::new(); - - let mut passwords_iter = - self.keystore_passwords.clone().unwrap_or_default().into_iter(); - - let mut password_files_iter = - self.keystore_password_files.clone().unwrap_or_default().into_iter(); - - for path in &keystore_paths { - let (maybe_signer, maybe_pending) = utils::create_keystore_signer( - path, - passwords_iter.next().as_deref(), - password_files_iter.next().as_deref(), - )?; - if let Some(pending_signer) = maybe_pending { - pending.push(pending_signer); - } else if let Some(signer) = maybe_signer { - signers.push(signer); - } - } - return Ok(Some((pending, signers))); - } - Ok(None) - } - - pub fn mnemonics(&self) -> Result>> { - if let Some(ref mnemonics) = self.mnemonics { - let mut wallets = vec![]; - - let mut hd_paths_iter = self.hd_paths.clone().unwrap_or_default().into_iter(); - - let mut passphrases_iter = - self.mnemonic_passphrases.clone().unwrap_or_default().into_iter(); - - let mut indexes_iter = self.mnemonic_indexes.clone().unwrap_or_default().into_iter(); - - for mnemonic in mnemonics { - let wallet = utils::create_mnemonic_signer( - mnemonic, - passphrases_iter.next().as_deref(), - hd_paths_iter.next().as_deref(), - indexes_iter.next().unwrap_or(0), - )?; - wallets.push(wallet); - } - return Ok(Some(wallets)); - } - Ok(None) - } - - pub async fn ledgers(&self) -> Result>> { - if self.ledger { - let mut args = self.clone(); - - if let Some(paths) = &args.hd_paths { - if paths.len() > 1 { - eyre::bail!("Ledger only supports one signer."); - } - args.mnemonic_indexes = None; - } - - create_hw_wallets!(args, utils::create_ledger_signer, wallets); - return Ok(Some(wallets)); - } - Ok(None) - } - - pub async fn trezors(&self) -> Result>> { - if self.trezor { - create_hw_wallets!(self, utils::create_trezor_signer, wallets); - return Ok(Some(wallets)); - } - Ok(None) - } - - pub async fn aws_signers(&self) -> Result>> { - #[cfg(feature = "aws-kms")] - if self.aws { - let mut wallets = vec![]; - let aws_keys = std::env::var("AWS_KMS_KEY_IDS") - .or(std::env::var("AWS_KMS_KEY_ID"))? - .split(',') - .map(|k| k.to_string()) - .collect::>(); - - for key in aws_keys { - let aws_signer = WalletSigner::from_aws(key).await?; - wallets.push(aws_signer) - } - - return Ok(Some(wallets)); - } - - Ok(None) - } - - /// Returns a list of GCP signers if the GCP flag is set. - /// - /// The GCP signers are created from the following environment variables: - /// - GCP_PROJECT_ID: The GCP project ID. e.g. `my-project-123456`. - /// - GCP_LOCATION: The GCP location. e.g. `us-central1`. - /// - GCP_KEY_RING: The GCP key ring name. e.g. `my-key-ring`. - /// - GCP_KEY_NAME: The GCP key name. e.g. `my-key`. - /// - GCP_KEY_VERSION: The GCP key version. e.g. `1`. - /// - /// For more information on GCP KMS, see the [official documentation](https://cloud.google.com/kms/docs). - pub async fn gcp_signers(&self) -> Result>> { - #[cfg(feature = "gcp-kms")] - if self.gcp { - let mut wallets = vec![]; - - let project_id = std::env::var("GCP_PROJECT_ID")?; - let location = std::env::var("GCP_LOCATION")?; - let key_ring = std::env::var("GCP_KEY_RING")?; - let key_names = std::env::var("GCP_KEY_NAME")?; - let key_version = std::env::var("GCP_KEY_VERSION")?; - - let gcp_signer = WalletSigner::from_gcp( - project_id, - location, - key_ring, - key_names, - key_version.parse()?, - ) - .await?; - wallets.push(gcp_signer); - - return Ok(Some(wallets)); - } - - Ok(None) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use alloy_primitives::address; - use std::path::Path; - - #[test] - fn parse_keystore_args() { - let args: MultiWalletOpts = - MultiWalletOpts::parse_from(["foundry-cli", "--keystores", "my/keystore/path"]); - assert_eq!(args.keystore_paths, Some(vec!["my/keystore/path".to_string()])); - - unsafe { - std::env::set_var("ETH_KEYSTORE", "MY_KEYSTORE"); - } - let args: MultiWalletOpts = MultiWalletOpts::parse_from(["foundry-cli"]); - assert_eq!(args.keystore_paths, Some(vec!["MY_KEYSTORE".to_string()])); - - unsafe { - std::env::remove_var("ETH_KEYSTORE"); - } - } - - #[test] - fn parse_keystore_password_file() { - let keystore = - Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../cast/tests/fixtures/keystore")); - let keystore_file = keystore - .join("UTC--2022-12-20T10-30-43.591916000Z--ec554aeafe75601aaab43bd4621a22284db566c2"); - - let keystore_password_file = keystore.join("password-ec554").into_os_string(); - - let args: MultiWalletOpts = MultiWalletOpts::parse_from([ - "foundry-cli", - "--keystores", - keystore_file.to_str().unwrap(), - "--password-file", - keystore_password_file.to_str().unwrap(), - ]); - assert_eq!( - args.keystore_password_files, - Some(vec![keystore_password_file.to_str().unwrap().to_string()]) - ); - - let (_, unlocked) = args.keystores().unwrap().unwrap(); - assert_eq!(unlocked.len(), 1); - assert_eq!(unlocked[0].address(), address!("0xec554aeafe75601aaab43bd4621a22284db566c2")); - } - - // https://github.com/foundry-rs/foundry/issues/5179 - #[test] - fn should_not_require_the_mnemonics_flag_with_mnemonic_indexes() { - let wallet_options = vec![ - ("ledger", "--mnemonic-indexes", 1), - ("trezor", "--mnemonic-indexes", 2), - ("aws", "--mnemonic-indexes", 10), - ]; - - for test_case in wallet_options { - let args: MultiWalletOpts = MultiWalletOpts::parse_from([ - "foundry-cli", - &format!("--{}", test_case.0), - test_case.1, - &test_case.2.to_string(), - ]); - - match test_case.0 { - "ledger" => assert!(args.ledger), - "trezor" => assert!(args.trezor), - "aws" => assert!(args.aws), - _ => panic!("Should have matched one of the previous wallet options"), - } - - assert_eq!( - args.mnemonic_indexes.expect("--mnemonic-indexes should have been set")[0], - test_case.2 - ) - } - } -} diff --git a/crates/wallets/src/raw_wallet.rs b/crates/wallets/src/raw_wallet.rs deleted file mode 100644 index c13076dafaf51..0000000000000 --- a/crates/wallets/src/raw_wallet.rs +++ /dev/null @@ -1,62 +0,0 @@ -use crate::{PendingSigner, WalletSigner, utils}; -use clap::Parser; -use eyre::Result; -use serde::Serialize; - -/// A wrapper for the raw data options for `Wallet`, extracted to also be used standalone. -/// The raw wallet options can either be: -/// 1. Private Key (cleartext in CLI) -/// 2. Private Key (interactively via secure prompt) -/// 3. Mnemonic (via file path) -#[derive(Clone, Debug, Default, Serialize, Parser)] -#[command(next_help_heading = "Wallet options - raw", about = None, long_about = None)] -pub struct RawWalletOpts { - /// Open an interactive prompt to enter your private key. - #[arg(long, short)] - pub interactive: bool, - - /// Use the provided private key. - #[arg(long, value_name = "RAW_PRIVATE_KEY")] - pub private_key: Option, - - /// Use the mnemonic phrase of mnemonic file at the specified path. - #[arg(long, alias = "mnemonic-path")] - pub mnemonic: Option, - - /// Use a BIP39 passphrase for the mnemonic. - #[arg(long, value_name = "PASSPHRASE")] - pub mnemonic_passphrase: Option, - - /// The wallet derivation path. - /// - /// Works with both --mnemonic-path and hardware wallets. - #[arg(long = "mnemonic-derivation-path", alias = "hd-path", value_name = "PATH")] - pub hd_path: Option, - - /// Use the private key from the given mnemonic index. - /// - /// Used with --mnemonic-path. - #[arg(long, conflicts_with = "hd_path", default_value_t = 0, value_name = "INDEX")] - pub mnemonic_index: u32, -} - -impl RawWalletOpts { - /// Returns signer configured by provided parameters. - pub fn signer(&self) -> Result> { - if self.interactive { - return Ok(Some(PendingSigner::Interactive.unlock()?)); - } - if let Some(private_key) = &self.private_key { - return Ok(Some(utils::create_private_key_signer(private_key)?)); - } - if let Some(mnemonic) = &self.mnemonic { - return Ok(Some(utils::create_mnemonic_signer( - mnemonic, - self.mnemonic_passphrase.as_deref(), - self.hd_path.as_deref(), - self.mnemonic_index, - )?)); - } - Ok(None) - } -} diff --git a/crates/wallets/src/utils.rs b/crates/wallets/src/utils.rs deleted file mode 100644 index 52125c60521df..0000000000000 --- a/crates/wallets/src/utils.rs +++ /dev/null @@ -1,163 +0,0 @@ -use crate::{PendingSigner, WalletSigner, error::PrivateKeyError}; -use alloy_primitives::{B256, hex::FromHex}; -use alloy_signer_ledger::HDPath as LedgerHDPath; -use alloy_signer_local::PrivateKeySigner; -use alloy_signer_trezor::HDPath as TrezorHDPath; -use eyre::{Context, Result}; -use foundry_config::Config; -use std::{ - fs, - path::{Path, PathBuf}, -}; - -fn ensure_pk_not_env(pk: &str) -> Result<()> { - if !pk.starts_with("0x") && std::env::var(pk).is_ok() { - return Err(PrivateKeyError::ExistsAsEnvVar(pk.to_string()).into()); - } - Ok(()) -} - -/// Validates and sanitizes user inputs, returning configured [WalletSigner]. -pub fn create_private_key_signer(private_key_str: &str) -> Result { - let Ok(private_key) = B256::from_hex(private_key_str) else { - ensure_pk_not_env(private_key_str)?; - eyre::bail!("Failed to decode private key") - }; - match PrivateKeySigner::from_bytes(&private_key) { - Ok(pk) => Ok(WalletSigner::Local(pk)), - Err(err) => { - ensure_pk_not_env(private_key_str)?; - eyre::bail!("Failed to create wallet from private key: {err}") - } - } -} - -/// Creates [WalletSigner] instance from given mnemonic parameters. -/// -/// Mnemonic can be either a file path or a mnemonic phrase. -pub fn create_mnemonic_signer( - mnemonic: &str, - passphrase: Option<&str>, - hd_path: Option<&str>, - index: u32, -) -> Result { - let mnemonic = if Path::new(mnemonic).is_file() { - fs::read_to_string(mnemonic)?.replace('\n', "") - } else { - mnemonic.to_owned() - }; - - Ok(WalletSigner::from_mnemonic(&mnemonic, passphrase, hd_path, index)?) -} - -/// Creates [WalletSigner] instance from given Ledger parameters. -pub async fn create_ledger_signer( - hd_path: Option<&str>, - mnemonic_index: u32, -) -> Result { - let derivation = if let Some(hd_path) = hd_path { - LedgerHDPath::Other(hd_path.to_owned()) - } else { - LedgerHDPath::LedgerLive(mnemonic_index as usize) - }; - - WalletSigner::from_ledger_path(derivation).await.wrap_err_with(|| { - "\ -Could not connect to Ledger device. -Make sure it's connected and unlocked, with no other desktop wallet apps open." - }) -} - -/// Creates [WalletSigner] instance from given Trezor parameters. -pub async fn create_trezor_signer( - hd_path: Option<&str>, - mnemonic_index: u32, -) -> Result { - let derivation = if let Some(hd_path) = hd_path { - TrezorHDPath::Other(hd_path.to_owned()) - } else { - TrezorHDPath::TrezorLive(mnemonic_index as usize) - }; - - WalletSigner::from_trezor_path(derivation).await.wrap_err_with(|| { - "\ -Could not connect to Trezor device. -Make sure it's connected and unlocked, with no other conflicting desktop wallet apps open." - }) -} - -pub fn maybe_get_keystore_path( - maybe_path: Option<&str>, - maybe_name: Option<&str>, -) -> Result> { - let default_keystore_dir = Config::foundry_keystores_dir() - .ok_or_else(|| eyre::eyre!("Could not find the default keystore directory."))?; - Ok(maybe_path - .map(PathBuf::from) - .or_else(|| maybe_name.map(|name| default_keystore_dir.join(name)))) -} - -/// Creates keystore signer from given parameters. -/// -/// If correct password or password file is provided, the keystore is decrypted and a [WalletSigner] -/// is returned. -/// -/// Otherwise, a [PendingSigner] is returned, which can be used to unlock the keystore later, -/// prompting user for password. -pub fn create_keystore_signer( - path: &PathBuf, - maybe_password: Option<&str>, - maybe_password_file: Option<&str>, -) -> Result<(Option, Option)> { - if !path.exists() { - eyre::bail!("Keystore file `{path:?}` does not exist") - } - - if path.is_dir() { - eyre::bail!( - "Keystore path `{path:?}` is a directory. Please specify the keystore file directly." - ) - } - - let password = match (maybe_password, maybe_password_file) { - (Some(password), _) => Ok(Some(password.to_string())), - (_, Some(password_file)) => { - let password_file = Path::new(password_file); - if !password_file.is_file() { - Err(eyre::eyre!("Keystore password file `{password_file:?}` does not exist")) - } else { - Ok(Some( - fs::read_to_string(password_file) - .wrap_err_with(|| { - format!("Failed to read keystore password file at {password_file:?}") - })? - .trim_end() - .to_string(), - )) - } - } - (None, None) => Ok(None), - }?; - - if let Some(password) = password { - let wallet = PrivateKeySigner::decrypt_keystore(path, password) - .wrap_err_with(|| format!("Failed to decrypt keystore {path:?}"))?; - Ok((Some(WalletSigner::Local(wallet)), None)) - } else { - Ok((None, Some(PendingSigner::Keystore(path.clone())))) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_private_key_signer() { - let pk = B256::random(); - let pk_str = pk.to_string(); - assert!(create_private_key_signer(&pk_str).is_ok()); - // skip 0x - assert!(create_private_key_signer(&pk_str[2..]).is_ok()); - } -} diff --git a/crates/wallets/src/wallet.rs b/crates/wallets/src/wallet.rs deleted file mode 100644 index 8e3a4dafb332a..0000000000000 --- a/crates/wallets/src/wallet.rs +++ /dev/null @@ -1,214 +0,0 @@ -use crate::{raw_wallet::RawWalletOpts, utils, wallet_signer::WalletSigner}; -use alloy_primitives::Address; -use clap::Parser; -use eyre::Result; -use serde::Serialize; - -/// The wallet options can either be: -/// 1. Raw (via private key / mnemonic file, see `RawWallet`) -/// 2. Ledger -/// 3. Trezor -/// 4. Keystore (via file path) -/// 5. AWS KMS -/// 6. Google Cloud KMS -#[derive(Clone, Debug, Default, Serialize, Parser)] -#[command(next_help_heading = "Wallet options", about = None, long_about = None)] -pub struct WalletOpts { - /// The sender account. - #[arg( - long, - short, - value_name = "ADDRESS", - help_heading = "Wallet options - raw", - env = "ETH_FROM" - )] - pub from: Option
, - - #[command(flatten)] - pub raw: RawWalletOpts, - - /// Use the keystore in the given folder or file. - #[arg( - long = "keystore", - help_heading = "Wallet options - keystore", - value_name = "PATH", - env = "ETH_KEYSTORE" - )] - pub keystore_path: Option, - - /// Use a keystore from the default keystores folder (~/.foundry/keystores) by its filename - #[arg( - long = "account", - help_heading = "Wallet options - keystore", - value_name = "ACCOUNT_NAME", - env = "ETH_KEYSTORE_ACCOUNT", - conflicts_with = "keystore_path" - )] - pub keystore_account_name: Option, - - /// The keystore password. - /// - /// Used with --keystore. - #[arg( - long = "password", - help_heading = "Wallet options - keystore", - requires = "keystore_path", - value_name = "PASSWORD" - )] - pub keystore_password: Option, - - /// The keystore password file path. - /// - /// Used with --keystore. - #[arg( - long = "password-file", - help_heading = "Wallet options - keystore", - requires = "keystore_path", - value_name = "PASSWORD_FILE", - env = "ETH_PASSWORD" - )] - pub keystore_password_file: Option, - - /// Use a Ledger hardware wallet. - #[arg(long, short, help_heading = "Wallet options - hardware wallet")] - pub ledger: bool, - - /// Use a Trezor hardware wallet. - #[arg(long, short, help_heading = "Wallet options - hardware wallet")] - pub trezor: bool, - - /// Use AWS Key Management Service. - #[arg(long, help_heading = "Wallet options - remote", hide = !cfg!(feature = "aws-kms"))] - pub aws: bool, - - /// Use Google Cloud Key Management Service. - #[arg(long, help_heading = "Wallet options - remote", hide = !cfg!(feature = "gcp-kms"))] - pub gcp: bool, -} - -impl WalletOpts { - pub async fn signer(&self) -> Result { - trace!("start finding signer"); - - let signer = if self.ledger { - utils::create_ledger_signer(self.raw.hd_path.as_deref(), self.raw.mnemonic_index) - .await? - } else if self.trezor { - utils::create_trezor_signer(self.raw.hd_path.as_deref(), self.raw.mnemonic_index) - .await? - } else if self.aws { - let key_id = std::env::var("AWS_KMS_KEY_ID")?; - WalletSigner::from_aws(key_id).await? - } else if self.gcp { - let project_id = std::env::var("GCP_PROJECT_ID")?; - let location = std::env::var("GCP_LOCATION")?; - let keyring = std::env::var("GCP_KEYRING")?; - let key_name = std::env::var("GCP_KEY_NAME")?; - let key_version = std::env::var("GCP_KEY_VERSION")?.parse()?; - WalletSigner::from_gcp(project_id, location, keyring, key_name, key_version).await? - } else if let Some(raw_wallet) = self.raw.signer()? { - raw_wallet - } else if let Some(path) = utils::maybe_get_keystore_path( - self.keystore_path.as_deref(), - self.keystore_account_name.as_deref(), - )? { - let (maybe_signer, maybe_pending) = utils::create_keystore_signer( - &path, - self.keystore_password.as_deref(), - self.keystore_password_file.as_deref(), - )?; - if let Some(pending) = maybe_pending { - pending.unlock()? - } else if let Some(signer) = maybe_signer { - signer - } else { - unreachable!() - } - } else { - eyre::bail!( - "\ -Error accessing local wallet. Did you set a private key, mnemonic or keystore? -Run the command with --help flag for more information or use the corresponding CLI -flag to set your key via: ---private-key, --mnemonic-path, --aws, --gcp, --interactive, --trezor or --ledger. -Alternatively, when using the `cast send` or `cast mktx` commands with a local node -or RPC that has unlocked accounts, the --unlocked or --ethsign flags can be used, -respectively. The sender address can be specified by setting the `ETH_FROM` environment -variable to the desired unlocked account address, or by providing the address directly -using the --from flag." - ) - }; - - Ok(signer) - } -} - -impl From for WalletOpts { - fn from(options: RawWalletOpts) -> Self { - Self { raw: options, ..Default::default() } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use alloy_signer::Signer; - use std::{path::Path, str::FromStr}; - - #[tokio::test] - async fn find_keystore() { - let keystore = - Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../cast/tests/fixtures/keystore")); - let keystore_file = keystore - .join("UTC--2022-12-20T10-30-43.591916000Z--ec554aeafe75601aaab43bd4621a22284db566c2"); - let password_file = keystore.join("password-ec554"); - let wallet: WalletOpts = WalletOpts::parse_from([ - "foundry-cli", - "--from", - "560d246fcddc9ea98a8b032c9a2f474efb493c28", - "--keystore", - keystore_file.to_str().unwrap(), - "--password-file", - password_file.to_str().unwrap(), - ]); - let signer = wallet.signer().await.unwrap(); - assert_eq!( - signer.address(), - Address::from_str("ec554aeafe75601aaab43bd4621a22284db566c2").unwrap() - ); - } - - #[tokio::test] - async fn illformed_private_key_generates_user_friendly_error() { - let wallet = WalletOpts { - raw: RawWalletOpts { - interactive: false, - private_key: Some("123".to_string()), - mnemonic: None, - mnemonic_passphrase: None, - hd_path: None, - mnemonic_index: 0, - }, - from: None, - keystore_path: None, - keystore_account_name: None, - keystore_password: None, - keystore_password_file: None, - ledger: false, - trezor: false, - aws: false, - gcp: false, - }; - match wallet.signer().await { - Ok(_) => { - panic!("illformed private key shouldn't decode") - } - Err(x) => { - assert!( - x.to_string().contains("Failed to decode private key"), - "Error message is not user-friendly" - ); - } - } - } -} diff --git a/crates/wallets/src/wallet_signer.rs b/crates/wallets/src/wallet_signer.rs deleted file mode 100644 index 512d921a7409e..0000000000000 --- a/crates/wallets/src/wallet_signer.rs +++ /dev/null @@ -1,284 +0,0 @@ -use crate::error::WalletSignerError; -use alloy_consensus::SignableTransaction; -use alloy_dyn_abi::TypedData; -use alloy_network::TxSigner; -use alloy_primitives::{Address, B256, ChainId, Signature, hex}; -use alloy_signer::Signer; -use alloy_signer_ledger::{HDPath as LedgerHDPath, LedgerSigner}; -use alloy_signer_local::{MnemonicBuilder, PrivateKeySigner, coins_bip39::English}; -use alloy_signer_trezor::{HDPath as TrezorHDPath, TrezorSigner}; -use alloy_sol_types::{Eip712Domain, SolStruct}; -use async_trait::async_trait; -use std::path::PathBuf; - -#[cfg(feature = "aws-kms")] -use {alloy_signer_aws::AwsSigner, aws_config::BehaviorVersion, aws_sdk_kms::Client as AwsClient}; - -#[cfg(feature = "gcp-kms")] -use { - alloy_signer_gcp::{GcpKeyRingRef, GcpSigner, GcpSignerError, KeySpecifier}, - gcloud_sdk::{ - GoogleApi, - google::cloud::kms::v1::key_management_service_client::KeyManagementServiceClient, - }, -}; - -pub type Result = std::result::Result; - -/// Wrapper enum around different signers. -#[derive(Debug)] -pub enum WalletSigner { - /// Wrapper around local wallet. e.g. private key, mnemonic - Local(PrivateKeySigner), - /// Wrapper around Ledger signer. - Ledger(LedgerSigner), - /// Wrapper around Trezor signer. - Trezor(TrezorSigner), - /// Wrapper around AWS KMS signer. - #[cfg(feature = "aws-kms")] - Aws(AwsSigner), - /// Wrapper around Google Cloud KMS signer. - #[cfg(feature = "gcp-kms")] - Gcp(GcpSigner), -} - -impl WalletSigner { - pub async fn from_ledger_path(path: LedgerHDPath) -> Result { - let ledger = LedgerSigner::new(path, None).await?; - Ok(Self::Ledger(ledger)) - } - - pub async fn from_trezor_path(path: TrezorHDPath) -> Result { - let trezor = TrezorSigner::new(path, None).await?; - Ok(Self::Trezor(trezor)) - } - - pub async fn from_aws(key_id: String) -> Result { - #[cfg(feature = "aws-kms")] - { - let config = aws_config::load_defaults(BehaviorVersion::latest()).await; - let client = AwsClient::new(&config); - - Ok(Self::Aws(AwsSigner::new(client, key_id, None).await?)) - } - - #[cfg(not(feature = "aws-kms"))] - { - let _ = key_id; - Err(WalletSignerError::aws_unsupported()) - } - } - - pub async fn from_gcp( - project_id: String, - location: String, - keyring: String, - key_name: String, - key_version: u64, - ) -> Result { - #[cfg(feature = "gcp-kms")] - { - let keyring = GcpKeyRingRef::new(&project_id, &location, &keyring); - let client = match GoogleApi::from_function( - KeyManagementServiceClient::new, - "https://cloudkms.googleapis.com", - None, - ) - .await - { - Ok(c) => c, - Err(e) => return Err(WalletSignerError::from(GcpSignerError::GoogleKmsError(e))), - }; - - let specifier = KeySpecifier::new(keyring, &key_name, key_version); - - Ok(Self::Gcp(GcpSigner::new(client, specifier, None).await?)) - } - - #[cfg(not(feature = "gcp-kms"))] - { - let _ = project_id; - let _ = location; - let _ = keyring; - let _ = key_name; - let _ = key_version; - Err(WalletSignerError::gcp_unsupported()) - } - } - - pub fn from_private_key(private_key: &B256) -> Result { - Ok(Self::Local(PrivateKeySigner::from_bytes(private_key)?)) - } - - /// Returns a list of addresses available to use with current signer - /// - /// - for Ledger and Trezor signers the number of addresses to retrieve is specified as argument - /// - the result for Ledger signers includes addresses available for both LedgerLive and Legacy - /// derivation paths - /// - for Local and AWS signers the result contains a single address - pub async fn available_senders(&self, max: usize) -> Result> { - let mut senders = Vec::new(); - match self { - Self::Local(local) => { - senders.push(local.address()); - } - Self::Ledger(ledger) => { - for i in 0..max { - if let Ok(address) = - ledger.get_address_with_path(&LedgerHDPath::LedgerLive(i)).await - { - senders.push(address); - } - } - for i in 0..max { - if let Ok(address) = - ledger.get_address_with_path(&LedgerHDPath::Legacy(i)).await - { - senders.push(address); - } - } - } - Self::Trezor(trezor) => { - for i in 0..max { - if let Ok(address) = - trezor.get_address_with_path(&TrezorHDPath::TrezorLive(i)).await - { - senders.push(address); - } - } - } - #[cfg(feature = "aws-kms")] - Self::Aws(aws) => { - senders.push(alloy_signer::Signer::address(aws)); - } - #[cfg(feature = "gcp-kms")] - Self::Gcp(gcp) => { - senders.push(alloy_signer::Signer::address(gcp)); - } - } - Ok(senders) - } - - pub fn from_mnemonic( - mnemonic: &str, - passphrase: Option<&str>, - derivation_path: Option<&str>, - index: u32, - ) -> Result { - let mut builder = MnemonicBuilder::::default().phrase(mnemonic); - - if let Some(passphrase) = passphrase { - builder = builder.password(passphrase) - } - - builder = if let Some(hd_path) = derivation_path { - builder.derivation_path(hd_path)? - } else { - builder.index(index)? - }; - - Ok(Self::Local(builder.build()?)) - } -} - -macro_rules! delegate { - ($s:ident, $inner:ident => $e:expr) => { - match $s { - Self::Local($inner) => $e, - Self::Ledger($inner) => $e, - Self::Trezor($inner) => $e, - #[cfg(feature = "aws-kms")] - Self::Aws($inner) => $e, - #[cfg(feature = "gcp-kms")] - Self::Gcp($inner) => $e, - } - }; -} - -#[async_trait] -impl Signer for WalletSigner { - /// Signs the given hash. - async fn sign_hash(&self, hash: &B256) -> alloy_signer::Result { - delegate!(self, inner => inner.sign_hash(hash)).await - } - - async fn sign_message(&self, message: &[u8]) -> alloy_signer::Result { - delegate!(self, inner => inner.sign_message(message)).await - } - - fn address(&self) -> Address { - delegate!(self, inner => alloy_signer::Signer::address(inner)) - } - - fn chain_id(&self) -> Option { - delegate!(self, inner => inner.chain_id()) - } - - fn set_chain_id(&mut self, chain_id: Option) { - delegate!(self, inner => inner.set_chain_id(chain_id)) - } - - async fn sign_typed_data( - &self, - payload: &T, - domain: &Eip712Domain, - ) -> alloy_signer::Result - where - Self: Sized, - { - delegate!(self, inner => inner.sign_typed_data(payload, domain)).await - } - - async fn sign_dynamic_typed_data( - &self, - payload: &TypedData, - ) -> alloy_signer::Result { - delegate!(self, inner => inner.sign_dynamic_typed_data(payload)).await - } -} - -#[async_trait] -impl TxSigner for WalletSigner { - fn address(&self) -> Address { - delegate!(self, inner => alloy_signer::Signer::address(inner)) - } - - async fn sign_transaction( - &self, - tx: &mut dyn SignableTransaction, - ) -> alloy_signer::Result { - delegate!(self, inner => inner.sign_transaction(tx)).await - } -} - -/// Signers that require user action to be obtained. -#[derive(Debug, Clone)] -pub enum PendingSigner { - Keystore(PathBuf), - Interactive, -} - -impl PendingSigner { - pub fn unlock(self) -> Result { - match self { - Self::Keystore(path) => { - let password = rpassword::prompt_password("Enter keystore password:")?; - match PrivateKeySigner::decrypt_keystore(path, password) { - Ok(signer) => Ok(WalletSigner::Local(signer)), - Err(e) => match e { - // Catch the `MacMismatch` error, which indicates an incorrect password and - // return a more user-friendly `IncorrectKeystorePassword`. - alloy_signer_local::LocalSignerError::EthKeystoreError( - eth_keystore::KeystoreError::MacMismatch, - ) => Err(WalletSignerError::IncorrectKeystorePassword), - _ => Err(WalletSignerError::Local(e)), - }, - } - } - Self::Interactive => { - let private_key = rpassword::prompt_password("Enter private key:")?; - Ok(WalletSigner::from_private_key(&hex::FromHex::from_hex(private_key)?)?) - } - } - } -} diff --git a/deny.toml b/deny.toml index e10c06ec8f3f5..ff425202890d5 100644 --- a/deny.toml +++ b/deny.toml @@ -7,10 +7,22 @@ yanked = "warn" ignore = [ # https://rustsec.org/advisories/RUSTSEC-2024-0436 paste! is unmaintained "RUSTSEC-2024-0436", - # https://rustsec.org/advisories/RUSTSEC-2024-0437 protobuf! Crash due to uncontrolled recursion in protobuf crate. - "RUSTSEC-2024-0437", - # https://rustsec.org/advisories/RUSTSEC-2025-0021 gitoxide uses SHA-1 hash implementations without any collision detection, leaving it vulnerable to hash collision attacks. - "RUSTSEC-2025-0021", + # https://rustsec.org/advisories/RUSTSEC-2026-0173 proc-macro-error2 is unmaintained + "RUSTSEC-2026-0173", + # https://rustsec.org/advisories/RUSTSEC-2026-0194 quick-xml duplicate + # attribute checking is quadratic. + "RUSTSEC-2026-0194", + # https://rustsec.org/advisories/RUSTSEC-2026-0195 quick-xml namespace + # declaration handling can allocate without a bound. No fixed quick-junit, + # inferno, or soldeer release is available for either advisory yet. + "RUSTSEC-2026-0195", + # https://rustsec.org/advisories/RUSTSEC-2026-0247 bitmaps is unmaintained. + # No fixed imbl release is available yet: https://github.com/jneem/imbl/issues/170 + "RUSTSEC-2026-0247", + # RUSTSEC-2025-0167 affects bitmaps byte-slice conversion and mutation APIs. + # Our only consumers, imbl and imbl-sized-chunks, do not call those APIs. + # Remove when imbl replaces bitmaps: https://github.com/jneem/imbl/issues/170 + "RUSTSEC-2025-0167", ] # This section is considered when running `cargo deny check bans`. @@ -45,6 +57,7 @@ allow = [ "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", + "BlueOak-1.0.0", "BSL-1.0", "CC0-1.0", "CDDL-1.0", @@ -65,7 +78,6 @@ exceptions = [ # CC0 is a permissive license but somewhat unclear status for source code # so we prefer to not have dependencies using it # https://tldrlegal.com/license/creative-commons-cc0-1.0-universal - { allow = ["CC0-1.0"], name = "tiny-keccak" }, { allow = ["CC0-1.0"], name = "trezor-client" }, { allow = ["CC0-1.0"], name = "notify" }, { allow = ["CC0-1.0"], name = "dunce" }, @@ -97,9 +109,21 @@ unknown-registry = "warn" # in the allow list is encountered unknown-git = "deny" allow-git = [ - "https://github.com/alloy-rs/alloy", - "https://github.com/foundry-rs/compilers", - "https://github.com/foundry-rs/foundry-fork-db", - "https://github.com/paradigmxyz/revm-inspectors", - "https://github.com/bluealloy/revm", + # Temporary: alloy-chains until HyperliquidTestnet is released. + "https://github.com/alloy-rs/chains", + "https://github.com/foundry-rs/foundry-core", + "https://github.com/paradigmxyz/solar", + # Only for tests. + "https://github.com/rust-cli/rexpect", + # Tempo + "https://github.com/tempoxyz/tempo", + "https://github.com/tempoxyz/mpp-rs", + # Base + "https://github.com/foundry-rs/base", + # Transitive dependency of Tempo + "https://github.com/commonwarexyz/monorepo", + "https://github.com/paradigmxyz/reth", + # Temporary: OP crates until upstream optimism bumps to revm 40. + "https://github.com/foundry-rs/optimism", + "https://github.com/foundry-rs/op-revm", ] diff --git a/docs/dev/README.md b/docs/dev/README.md index cde2a01c31d49..ddaa54f24771e 100644 --- a/docs/dev/README.md +++ b/docs/dev/README.md @@ -1,83 +1,86 @@ -# Developer Docs +# Developer documentation -The Foundry project is organized as a regular [Cargo workspace][cargo-workspace]. +These documents describe contributor workflows and invariants that span multiple Foundry crates. +They are not a second user manual or a manually maintained map of every workspace dependency. -## Installation requirements +## Documentation ownership -- [Rust](https://rustup.rs/) -- Make +Keep each fact in the source that owns it and link to that source elsewhere: -We use `cargo-nextest` as test runner (both locally and in the [CI](#ci)): +| Content | Canonical location | +| --- | --- | +| User-facing guides, configuration, and CLI workflows | [Foundry Book][foundry-book] | +| Lint reference explanations | [`crates/lint/docs/`](../../crates/lint/docs/README.md), imported by the Book | +| Crate and module APIs, invariants, and implementation details | Source Rustdoc, published as [Foundry Rustdoc][foundry-rustdoc] | +| Cross-crate contributor workflows | `docs/dev/` or [`CONTRIBUTING.md`](../../CONTRIBUTING.md) | +| Agent-only repository instructions | [`AGENTS.md`](../../AGENTS.md) | -- [Nextest](https://nexte.st/docs/installation/pre-built-binaries/#with-cargo-binstall) +Do not copy generated CLI reference text or crate dependency lists into `docs/dev`. Update CLI help +or Rustdoc at the source, then link to the generated documentation. -## Recommended +## Setup and validation -If you are working in VSCode, we recommend you install the [rust-analyzer](https://rust-analyzer.github.io/) extension, and use the following VSCode user settings: - -```json -"editor.formatOnSave": true, -"rust-analyzer.rustfmt.extraArgs": ["+nightly"], -"[rust]": { - "editor.defaultFormatter": "rust-lang.rust-analyzer" -} -``` - -Note that we use Rust's latest `nightly` for formatting. If you see `;` being inserted by your code editor it is a good indication you are on `stable`. - -## Getting started - -Build the project. - -```sh -$ make build -``` - -Run all tests. +Install [Rust][rust], Make, and [cargo-nextest][nextest]. Foundry uses the stable toolchain for +normal builds and the latest nightly toolchain for formatting and Clippy. ```sh -$ make test +make build +make test +make pr ``` -Run all tests and linters in preparation for a PR. - -```sh -$ make pr -``` - -## Contents - -- [Architecture](./architecture.md) -- [Cheatcodes](./cheatcodes.md) -- [Debugging](./debugging.md) -- [Scripting](./scripting.md) - -_Note: This is incomplete and possibly outdated_ - -## Getting in Touch - -See also [Getting Help](../../README.md#getting-help) - -## Issue Labels - -Whenever a ticket is initially opened a [`T-needs-triage`](https://github.com/foundry-rs/foundry/issues?q=is%3Aissue+is%3Aopen+label%3AT-needs-triage) label is assigned. This means that a member has yet to correctly label it. - -If this is your first time contributing have a look at our [`first-issue`](https://github.com/foundry-rs/foundry/issues?q=is%3Aissue+is%3Aopen+label%3A%22first+issue%22) tickets. These are tickets we think are a good way to get familiar with the codebase. - -We classify the tickets in two major categories: [`T-feature`](https://github.com/foundry-rs/foundry/issues?q=is%3Aissue+is%3Aopen+label%3AT-feature) and [`T-bug`](https://github.com/foundry-rs/foundry/issues?q=is%3Aissue+is%3Aopen+label%3AT-bug). Additional labels are usually applied to help categorize the ticket for future reference. - -We also make use of [`T-meta`](https://github.com/foundry-rs/foundry/issues?q=is%3Aissue+is%3Aopen+label%3AT-meta) aggregation tickets. These tickets are tickets to collect related features and bugs. - -We also have [`T-discuss`](https://github.com/foundry-rs/foundry/issues?q=is%3Aissue+is%3Aopen+label%3AT-to-discuss) tickets that require further discussion before proceeding on an implementation. Feel free to jump into the conversation! - -## CI - -We use GitHub Actions for continuous integration (CI). - -We use [cargo-nextest][nextest] as the test runner. - -If `make test` passes locally, that's a good sign that CI will be green as well. - -[foundry-book]: https://book.getfoundry.sh -[cargo-workspace]: https://doc.rust-lang.org/book/ch14-03-cargo-workspaces.html -[nextest]: https://nexte.st/ +Use focused unit tests for local logic and integration tests for user-visible workflows. Tests that +use forking must contain `fork` in their name. Forge and Cast CLI tests live under +`crates/forge/tests/cli/` and `crates/cast/tests/cli/`; shared integration fixtures live in +`crates/test-utils`, and Solidity fixtures live under `testdata/`. + +## Maintained guides + +- [Cheatcodes](./cheatcodes.md) explains cheatcode generation, dispatch, and implementation. +- [Debugging](./debugging.md) collects contributor debugging techniques. +- [Editor integrations](../../editors/README.md) covers the VS Code Development Host, + independent client builds, local packaging and Zed installation. +- [External compiler adapters](./external-compiler-adapters.md) defines the executable protocol, + cache contract, and artifact integration for compiler-native EVM projects. +- [Lint rules](./lintrules.md) covers the lint registry, UI fixtures, and documentation contract. +- [Custom EVM integrations](./networks.md) describes network selection, execution ownership, + state lifecycles, tool dispatch, and CI coverage. +- [Output channels](./output-channels.md) defines the stdout/stderr contract for Foundry commands. +- [Scripting](./scripting.md) documents the internal script execution and broadcast pipeline. +- [Showmap corpus replay](./showmap.md) documents the persisted-corpus coverage workflow and file + format. + +## Updating documentation + +Update documentation at the canonical location in the ownership table alongside implementation +changes. For lint reference pages, update [`crates/lint/docs/`](../../crates/lint/docs/README.md) +in the Foundry PR; the Book's +weekly update generates the published pages. Keep CLI help in the command definitions and crate or +module contracts in Rustdoc next to the implementation. +Add or update a guide here only when contributors need a cross-crate workflow or invariant that does +not have a single source owner. + +Every maintained guide must be linked from this index. Prefer links to canonical documentation over +duplicated instructions so updates cannot drift independently. + +## CI and release features + +CI runs tests through cargo-nextest. Nightly and stable release builds derive their enabled +functionality from `RUST_FEATURES` in `.github/workflows/release.yml` and +`.github/workflows/docker-publish.yml`. Keep those lists aligned with the default `FEATURES` in the +root `Makefile` so published binaries expose the same surface as local release builds. + +Maintainers select stable and release-candidate versions, update the workspace version and +`Cargo.lock`, and push the corresponding `vX.Y.Z` or `vX.Y.Z-rcN` tag at the intended commit. +The [release workflow](../../.github/workflows/release.yml) builds the artifacts and generates +PR-based notes in a draft GitHub release. After reviewing the notes and successful build, run the +[finalization workflow](../../.github/workflows/finalize-release.yml) from `master` with that exact +tag. It verifies the tagged workflow and recorded Docker digest before publishing and promoting +eligible Docker aliases. Nightlies continue through the scheduled release workflow. + +For contribution policy and support channels, see [`CONTRIBUTING.md`](../../CONTRIBUTING.md). + +[foundry-book]: https://getfoundry.sh +[foundry-rustdoc]: https://foundry-rs.github.io/foundry/ +[nextest]: https://nexte.st/docs/installation/pre-built-binaries/#with-cargo-binstall +[rust]: https://rustup.rs/ diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md deleted file mode 100644 index d0b9640605550..0000000000000 --- a/docs/dev/architecture.md +++ /dev/null @@ -1,18 +0,0 @@ -# Architecture - -This document describes the high-level architecture of Foundry. - -### `evm/` - -Foundry's EVM tooling. This is built around [`revm`](https://github.com/bluealloy/revm) and has additional -implementation of: - -- [cheatcodes](./cheatcodes.md) a set of solidity calls dedicated to testing which can manipulate the environment in which the execution is run - -### `config/` - -Includes all of Foundry's settings and how to get them - -### `cli/` - -The core `forge` and `cast` cli implementation. Includes all subcommands. diff --git a/docs/dev/cheatcodes.md b/docs/dev/cheatcodes.md index 0c96c4ba7c7f5..b64379f558557 100644 --- a/docs/dev/cheatcodes.md +++ b/docs/dev/cheatcodes.md @@ -123,16 +123,18 @@ These are all the attributes that can be specified on cheatcode functions: - `#[cheatcode(status = )]`: The current status of the cheatcode. E.g. whether it is stable or experimental, etc. Defaults to `Stable`. - `#[cheatcode(safety = )]`: Whether the cheatcode is safe to use inside of scripts. E.g. it does not change state in an unexpected way. Defaults to the group's safety if unspecified. If the group is ambiguous, then it must be specified manually. -Multiple attributes can be specified by separating them with commas, e.g. `#[cheatcode(group = "evm", status = "unstable")]`. +Multiple attributes can be specified by separating them with commas, e.g. `#[cheatcode(group = Evm, status = Experimental)]`. ### `Cheatcode` trait This trait defines the interface that all cheatcode implementations must implement. -There are two methods that can be implemented: +There are three methods that can be implemented: -- `apply`: implemented when the cheatcode is pure and does not need to access EVM data -- `apply_stateful`: implemented when the cheatcode needs to access EVM data -- `apply_full`: implemented when the cheatcode needs to access EVM data and the EVM executor itself, for example to recursively call back into the EVM to execute an arbitrary transaction +| Method | Purpose | When to use | +|--------|---------|-------------| +| `apply` | Pure cheatcodes that don't need EVM data access | For simple state manipulations | +| `apply_stateful` | Cheatcodes that need EVM data access | For operations requiring current EVM state | +| `apply_full` | Cheatcodes that need EVM executor access | For operations requiring recursive EVM calls | Only one of these methods can be implemented. @@ -157,6 +159,20 @@ update of the files. 4. Update the JSON interface by running `cargo cheats` twice. This is expected to fail the first time that this is run after adding a new cheatcode; see [JSON interface](#json-interface) 5. Write an integration test for the cheatcode in [`testdata/default/cheats/`] +### Mapping storage hooks + +The experimental, unsafe `registerMappingSstoreHook` cheatcode registers a post-store callback for +one concrete mapping root in a target's effective storage context. Its callback arguments are +`(account, computedSlot, rootSlot, keys, oldValue, newValue)`. `keys` contains raw `bytes32` storage +words in root-to-leaf order. + +Mapping hooks require complete 64-byte Keccak provenance observed after the latest mapping-hook +registration for the target. Resolution follows the complete chain to its terminal root rather +than stopping at a registered intermediate hash. Scalar, offset, incomplete, unknown, and +previously precomputed slots do not invoke them. Raw and mapping SSTORE hooks cannot be combined +for the same target. Callbacks must authenticate `msg.sender == address(vm)` to prevent external +spoofing. + [`sol!`]: https://docs.rs/alloy-sol-macro/latest/alloy_sol_macro/macro.sol.html [`cheatcodes/spec/src/vm.rs`]: ../../crates/cheatcodes/spec/src/vm.rs [`cheatcodes`]: ../../crates/cheatcodes/ diff --git a/docs/dev/debugging.md b/docs/dev/debugging.md index f72b8f4bfbae3..70ae97e56e62d 100644 --- a/docs/dev/debugging.md +++ b/docs/dev/debugging.md @@ -6,7 +6,7 @@ This is a working document intended to outline some commands contributors can us All crates use [tracing](https://docs.rs/tracing/latest/tracing/) for logging. A console formatter is installed in each binary (`cast`, `forge`, `anvil`). -By setting `RUST_LOG=` you can get a lot more info out of Forge and Cast. For example, running Forge with `RUST_LOG=forge` will emit all logs of the `cli` crate, same for Cast. +By setting `RUST_LOG=` you can get a lot more info out of Forge and Cast. For example, running Forge with `RUST_LOG=forge` will emit all logs from the `forge` package, same for Cast with `RUST_LOG=cast`. The most basic valid filter is a log level, of which these are valid: @@ -16,6 +16,6 @@ The most basic valid filter is a log level, of which these are valid: - `debug` - `trace` -Filters are explained in detail in the [`env_logger` crate docs](https://docs.rs/env_logger). +Filters are explained in detail in the [`tracing-subscriber` crate docs](https://docs.rs/tracing-subscriber). You can also use the `dbg!` macro from Rust's standard library. diff --git a/docs/dev/external-compiler-adapters.md b/docs/dev/external-compiler-adapters.md new file mode 100644 index 0000000000000..bb72ac38271a3 --- /dev/null +++ b/docs/dev/external-compiler-adapters.md @@ -0,0 +1,172 @@ +# External compiler adapters + +Foundry can compile compiler-native EVM projects through explicitly configured executable +adapters. The adapter owns project discovery, dependency resolution, and compiler invocation. +Foundry owns process transport, cache validation, artifact paths, and integration with Forge. +This implements the protocol tracked by [OSS-821][issue]. + +This keeps language-specific parsers and toolchains outside Foundry while making their EVM +artifacts available to the existing build, test, script, inspection, binding, selector, and create +flows. Solidity and Vyper continue to use their built-in compiler paths. + +## Configuration + +Configure each adapter under the active profile: + +```toml +[[profile.default.external_compilers]] +id = "contracts" +command = "/opt/toolchains/fe-foundry-adapter" +args = [] +roots = ["contracts"] + +[profile.default.external_compilers.settings] +compiler = "/opt/toolchains/fe" +optimization = "s" +``` + +`id` is a unique namespace for the adapter's artifacts and cache entries. It may contain ASCII +letters, digits, `.`, `-`, and `_`, but may not be `.` or `..`. `command` is an absolute path or a +path relative to the Foundry project root. Foundry executes it directly without a shell or PATH +lookup. `roots` contains one or more compiler-native project roots relative to the Foundry project. +`args` is an optional argument array, and `settings` is an optional JSON-compatible TOML table +passed through without interpretation. + +Configuring an adapter authorizes that executable to run with the user's privileges. Foundry clears +the child environment and starts it in the Foundry project root, but this process boundary is not +an operating-system sandbox. Adapters that need environment variables or network access must +arrange those requirements themselves through their executable or explicit settings. + +## Transport and lifecycle + +Protocol version 1.0 uses newline-delimited JSON on stdin and stdout. One adapter process serves one +Forge compilation. Requests are sequential, use increasing integer IDs, and receive a response with +the same ID and exactly one of `result` or `error`. Stdout is reserved for protocol messages. The +adapter may use stderr for failure context; structured compiler diagnostics belong in the compile +result. Foundry rejects individual protocol lines larger than 16 MiB. + +Foundry sends three operations in order. + +### `initialize` + +```json +{"id":1,"method":"initialize","params":{"protocols":["1.0"],"host":{"name":"forge","version":"1.8.4"},"target":"evm"}} +{"id":1,"result":{"protocol":"1.0"}} +``` + +The adapter must select exactly `1.0`. Protocol and compiler versions are independent. + +### `discover` + +```json +{"id":2,"method":"discover","params":{"roots":["/project/contracts"],"settings":{"optimization":"s"},"selected_paths":[]}} +{"id":2,"result":{"units":[{"id":"app","compiler":{"name":"fe","version":"26.3.0"},"inputs":["contracts/fe.toml","contracts/src/lib.fe"],"capabilities":["build/1"],"effectiveSettings":{"optimization":"s"},"cacheable":true}]}} +``` + +The adapter returns complete build units. A unit is the smallest compiler-native project that can +be built independently. Its `inputs` must contain every file whose bytes determine compilation, +including manifests, lockfiles, local dependency sources, and compiler resources when needed. +Paths may be absolute or relative to the Foundry root and must resolve to files. The compiler +version must be valid SemVer. + +Every unit must advertise `build/1`. Advertising `forge-tests/1` additionally makes deployable +artifacts from that unit eligible for Forge's test-contract discovery. Build-only artifacts remain +available as known contracts and deployment dependencies during `forge test`; ABI function names +alone do not opt them into execution as test suites. + +`selected_paths` contains explicit paths selected by the calling Forge command. The adapter decides +which units own those paths and must still return each selected unit's complete input closure. An +empty array requests complete discovery, so the adapter must return every active unit. + +### `compile` + +Foundry sends `compile` only on a cache miss or when caching is disabled: + +```json +{"id":3,"method":"compile","params":{"unit":"app","fingerprint":""}} +{"id":3,"result":{"diagnostics":[],"artifacts":[{"source":"contracts/src/lib.fe","name":"Counter","contract":{"abi":[],"evm":{"bytecode":{"object":"0x60006000f3"},"deployedBytecode":{"object":"0x00"}}},"metadata":{"language":"Fe"}}]}} +``` + +The result may contain `error`, `warning`, or `info` diagnostics. Error diagnostics fail the Forge +command. Each artifact supports these fields: + +| Field | Required | Meaning | +| --- | --- | --- | +| `source` | yes | Project-relative path to an existing source file. | +| `name` | yes | Contract name. | +| `contract` | yes | Existing compiler `Contract` JSON: `abi` and `evm` outputs. | +| `metadata` | no | Adapter-owned JSON metadata, serialized into Foundry's `rawMetadata` field. | +| `sourceId` | no | Source ID used by the supplied source maps. | + +`contract.evm.bytecode` and `contract.evm.deployedBytecode` use the existing compiler bytecode +objects (`object`, `sourceMap`, `linkReferences`, and runtime `immutableReferences`). Bytecode must +be fully linked: unresolved objects and nonempty link references are rejected. Runtime bytecode +requires creation bytecode. Foundry derives method identifiers from the ABI and uses its existing +artifact converter to produce ABI/bytecode artifacts. Debugging and additional compiler outputs +remain outside this protocol's initial integration. + +Source paths must be relative and may not contain `.` or `..` components. Unit IDs use the same +restricted character set as adapter IDs. Contract names permit only ASCII letters, digits, and +underscores so binding generation preserves their identity. Source identities are canonicalized +to match path-qualified Forge commands; virtual source files are not supported. Foundry rejects duplicate +`(source, contract)` identities across adapters and conflicts with built-in compiler artifacts. + +After the last response Foundry closes stdin and requires the adapter to exit successfully. A +protocol error, compiler error diagnostic, malformed artifact, or nonzero exit fails the Forge +command; Foundry does not fall back to another compiler. + +## Cache and artifacts + +Discovery runs on every build, including cache hits. For each cacheable unit Foundry hashes the +protocol version, adapter executable path, bytes and arguments, requested and effective settings, +compiler identity, unit descriptor, and the path and contents of every discovered input. +Compilation is independent of the Forge command consuming the result, so `build`, `test`, and +inspection can reuse the same unit. Build variants must be expressed in adapter settings and the +unit's effective settings, rather than inferred from a command name. +The SHA-256 fingerprint indexes the unit result under +`cache/external-compilers//.json`. `cacheable = false`, `cache = false`, and +`--force` bypass reuse. + +Normalized artifacts are written under: + +```text +out/.external////.json +``` + +They are merged into `ProjectCompileOutput`, so Forge's artifact lookup, linking, tracing, scripts, +bindings, selectors, inspection, and contract creation can consume them. Foundry removes artifact +directories for adapters and units that disappear from complete discovery, and replaces the active +unit directory so contracts no longer emitted by a unit are retired. `forge clean` removes these +host-owned artifacts and cache entries with the normal `out` and `cache` directories. + +The adapter cache is unit-scoped and independent of the Solidity/Vyper compiler cache. Read-only +compilations (`forge inspect`, `forge test --list`, and selector queries) may reuse it but do not +publish artifacts or update or retire cache entries. `--force` (or `force = true`) retains Forge's +existing cleanup semantics: it deletes prior artifacts and caches before compilation, including +for read-only commands, which do not republish external outputs. Use `forge build` to restore them. +Declaring a unit cacheable promises that discovery lists its complete build-affecting input closure. Ambient inputs such as time, +randomness, undeclared environment, mutable dependency caches, or unreported compiler resources require `cacheable = false`. + +## Forge integration and limits + +Adapter-aware compilation is enabled for `forge build`, `forge test`, scripts, `forge inspect`, +`forge bind`, `forge selectors`, `forge create`, and `forge coverage`. Adapter-only projects bypass +the built-in "Nothing to compile" exit. Mixed projects compile the external units first, then the +built-in sources, and merge their normalized artifacts only after both compilers succeed. + +Commands that select one external contract before compilation, such as `forge inspect` and +`forge create`, require a path-qualified identifier such as `contracts/src/lib.fe:Counter` because +Foundry cannot infer an adapter-owned source path from a contract name before discovery. + +`forge coverage` can execute external contracts while reporting coverage for supported Solidity +sources. External sources are excluded from source analysis and coverage reports. Source-level +debugging and verification reproduction for external artifacts remain outside the initial integration. +Build and test execution still work without those optional workflows. Cross-compiler source imports +and generated interface dependency scheduling are not supported; use ABI interfaces or artifact +deployment at the language boundary. + +The host does not install adapters, compilers, or native dependencies, and does not interpret +language manifests. Adapter distribution, toolchain selection, dependency setup, protocol +conformance, and native compiler diagnostics remain the adapter maintainer's responsibility. + +[issue]: https://linear.app/tempoxyz/issue/OSS-821 diff --git a/docs/dev/lintrules.md b/docs/dev/lintrules.md index 35701d6e9ce86..3a5f93c2e5126 100644 --- a/docs/dev/lintrules.md +++ b/docs/dev/lintrules.md @@ -7,11 +7,11 @@ It helps enforce best practices and improve code quality within Foundry projects The `forge-lint` system operates by analyzing Solidity source code through a dual-pass system: -1. **Parsing**: Solidity source files are parsed into an Abstract Syntax Tree (AST) using `solar-parse`. This AST represents the syntactic structure of the code. +1. **Parsing**: Solidity source files are parsed into an Abstract Syntax Tree (AST) using `solar`. This AST represents the syntactic structure of the code. 2. **HIR Generation**: The AST is then lowered into a High-level Intermediate Representation (HIR) that includes type information and semantic analysis. 3. **Early Lint Passes**: The `EarlyLintVisitor` traverses the AST, invoking registered "early lint passes" (`EarlyLintPass` implementations) for syntax-level checks. 4. **Late Lint Passes**: The `LateLintVisitor` traverses the HIR, invoking registered "late lint passes" (`LateLintPass` implementations) for semantic analysis. -5. **Emitting Diagnostics**: If a lint pass identifies a violation, it uses the `LintContext` to emit a diagnostic (either `warning` or `note`) that pinpoints the issue. Lints can now also provide code fix suggestions through snippets. +5. **Emitting Diagnostics**: If a lint pass identifies a violation, it uses the `LintContext` to emit a diagnostic (either `warning` or `note`) that pinpoints the issue. Lints can also provide code fix suggestions through the `Suggestion` API, which integrates with solar's diagnostic system to support different applicability levels. ### Key Components @@ -21,13 +21,17 @@ The `forge-lint` system operates by analyzing Solidity source code through a dua - `SolLint`: A struct implementing the `Lint` trait, used to hold the metadata for each specific Solidity lint rule. - **`EarlyLintPass<'ast>` Trait**: Lints that operate directly on AST nodes implement this trait. It contains methods (like `check_expr`, `check_item_function`, etc.) called by the AST visitor. - **`LateLintPass<'hir>` Trait**: Lints that require type information and semantic analysis implement this trait. It contains methods (like `check_contract`, `check_function`, etc.) called by the HIR visitor. -- **`LintContext<'s>`**: Provides contextual information to lint passes during execution, such as access to the session for emitting diagnostics and methods for emitting fixes. +- **`LintContext<'s>`**: Provides contextual information to lint passes during execution, such as access to the session for emitting diagnostics and methods for emitting suggestions. - **`EarlyLintVisitor<'a, 's, 'ast>`**: The visitor that traverses the AST and dispatches checks to the registered `EarlyLintPass` instances. - **`LateLintVisitor<'a, 's, 'hir>`**: The visitor that traverses the HIR and dispatches checks to the registered `LateLintPass` instances. -- **`Snippet` Enum**: Represents code fix suggestions that can be either a code block or a diff, with optional descriptions. +- **`Suggestion` Struct**: Represents code fix suggestions with different kinds (fix or example) and applicability levels, integrated with solar's diagnostic system. ## Developing a new lint rule +We recommend you start by writing out some Solidity code that you want to trigger a lint in [`crates/lint/testdata`](https://github.com/foundry-rs/foundry/tree/master/crates/lint/testdata). Name the file after your lint rule. + +Next, choose whether you want an [early or late lint pass](#choosing-between-early-and-late-passes). If your lint is early, you can use [Solar](https://github.com/paradigmxyz/solar) to dump the AST and find the patterns you need to match on in your lint code using `solar -Zdump=ast crates/lint/testdata/`. If your lint is late, you can use `solar -Zdump=hir crates/lint/testdata/`. + 1. Specify an issue that is being addressed in the PR description. 2. In your PR: @@ -42,20 +46,75 @@ The `forge-lint` system operates by analyzing Solidity source code through a dua // Note: The macro automatically generates a help link to the Foundry book ``` -- Register the pass struct and the lint using `register_lints!` in the `mod.rs` of its corresponding severity category. Specify the pass type (`early`, `late`, or both). Note that a single pass can handle multiple lints: +- Declare the lint module and register its pass(es) with `register_lints!` in the `mod.rs` of its corresponding severity category. Entries are grouped by module (`module: (PassStruct, early | late | project, (LINTS...)), ...;`); a single pass can handle multiple lints and a module can declare several passes: ```rust + mod mixed_case; + mod pascal_case; + mod screaming_snake_case; + register_lints!( - (PascalCaseStruct, early, (PASCAL_CASE_STRUCT)), - (MixedCaseVariable, early, (MIXED_CASE_VARIABLE)), - (MixedCaseFunction, early, (MIXED_CASE_FUNCTION)), - (ScreamingSnakeCase, early, (SCREAMING_SNAKE_CASE_CONSTANT, SCREAMING_SNAKE_CASE_IMMUTABLE)), - (AsmKeccak256, late, (ASM_KECCAK256)) + pascal_case: (PascalCaseStruct, early, (PASCAL_CASE_STRUCT)); + mixed_case: + (MixedCaseVariable, early, (MIXED_CASE_VARIABLE)), + (MixedCaseFunction, early, (MIXED_CASE_FUNCTION)); + screaming_snake_case: + (ScreamingSnakeCase, early, (SCREAMING_SNAKE_CASE_CONSTANT, SCREAMING_SNAKE_CASE_IMMUTABLE)); ); - // The macro automatically generates the pass structs and helper functions + // The macro glob-imports each module and generates the pass structs, `REGISTERED_LINTS` and + // the registration function. ``` +- Reuse the shared HIR probes in `crates/lint/src/sol/analysis/` (expression, statement, type and access-control helpers) instead of reimplementing them in the lint. +- Use Solar's `Gcx` queries for inferred expression types, resolved calls and arguments, and + inheritance. Do not reconstruct type-checker results or choose overloads by name or arity. + Keep lint-specific data-flow and control-flow analysis in the lint or shared probes. + - Implement the appropriate trait logic (`EarlyLintPass` or `LateLintPass`) for your lint. Do it in a new file within the relevant severity module (e.g., `src/sol/med/my_new_lint.rs`). +- Add the canonical documentation at `crates/lint/docs/.md` in the same Foundry PR. + Use [`crates/lint/docs/_template.md`](../../crates/lint/docs/_template.md) as a starting point. + The tests in `crates/lint/src/sol/mod.rs` enforce registry coverage, metadata, help URLs, + and the page structure. Run them with `cargo test -p forge-lint --lib sol::tests`. + The [Foundry Book](https://github.com/foundry-rs/book) validates lint documentation and generates + its lint reference pages and navigation from these files with `import:lints`. + +### Lint writing style + +Follow Clippy's [lint authoring guide](https://doc.rust-lang.org/clippy/development/adding_lints.html), +[diagnostic conventions](https://rustc-dev-guide.rust-lang.org/diagnostics.html), and +[lint naming conventions](https://rust-lang.github.io/rfcs/0344-conventions-galore.html#lints), with +the following Foundry-specific conventions: + +- Use `SCREAMING_SNAKE_CASE` for the Rust static and `kebab-case` for its public lint ID. Name new + lints after the condition they detect so the name reads naturally when enabled or suppressed. + Existing public IDs are configuration and documentation APIs: do not rename them just to adopt + Clippy's `snake_case` spelling or a different naming style. +- Make the primary diagnostic a short, factual description of the detected problem. Use help + messages or suggestion labels for detailed corrective instructions. Start diagnostic text with lowercase + prose and omit the final period for a single sentence; preserve capitalization inside code and + in acronyms. Use normal sentence punctuation for multi-sentence explanations. +- Enclose code, identifiers, types, operators, and literal values in backticks in descriptions, + labels, notes, and help text. Do not add backticks to replacement source code itself. +- Highlight the relevant code and make suggestions actionable. Use `MachineApplicable` only when + the replacement is correct without user intervention; document assumptions and limitations. +- Write each reference page using the [documentation contract](../../crates/lint/docs/README.md). + Explain what is detected, why it matters, and show a minimal triggering example followed by + `Use instead:` and a corrected example. For policy or style choices, use `Why restrict this?` + rather than claiming that the flagged code is inherently bad. Foundry has severity groups, + not a Clippy `restriction` group, so choose the heading based on the lint's purpose. +- Keep reference pages user-facing: explain the problem, its impact, and how to address it. + Omit lint implementation details such as AST/HIR representation, alias tracking, traversal + rules, analysis budgets, diagnostic placement, and comparisons with other detectors. + Include a limitation only when it changes how the reader should interpret or address a warning. + Implementation explanations belong in developer documentation or source comments. +- Start reference pages with `What it does` instead of a duplicate summary. Keep shared lint + controls in the linting guide; omit generic review reminders and repeated explanations. + +Review every diagnostic path and the reference page together. Cover triggering and non-triggering +cases in UI tests, update affected expected output when messages change, and check that examples +match the implemented detector. A structural check cannot establish that a suggested change is +semantically correct. + ### Choosing Between Early and Late Passes - **Use `EarlyLintPass`** for: @@ -71,42 +130,48 @@ The `forge-lint` system operates by analyzing Solidity source code through a dua ### Providing Code Fix Suggestions -Lints can now provide actionable code fix suggestions using the `emit_with_fix` method: +Lints can provide actionable code fix suggestions using the `emit_with_suggestion` method. The `Suggestion` API integrates with solar's diagnostic system and supports different applicability levels: ```rust -// Example: Suggesting a code diff with a span -cx.emit_with_fix( +use solar::interface::diagnostics::Applicability; + +// Example: Suggesting a machine-applicable fix +cx.emit_with_suggestion( lint, node.span, - Snippet::Diff { - desc: Some("use inline assembly for gas optimization"), - span: Some(node.span), // Optional: specify the span to replace - add: optimized_assembly_code, - } + Suggestion::fix( + corrected_name, + Applicability::MachineApplicable, + ) + .with_desc("consider using") ); -// Example: Suggesting a code diff without a span (uses the lint's span) -cx.emit_with_fix( +// Example: Suggesting a fix with a specific span +cx.emit_with_suggestion( lint, node.span, - Snippet::Diff { - desc: Some("rename to follow naming convention"), - span: None, // Will use the lint's span - add: corrected_name, - } + Suggestion::fix( + optimized_code, + Applicability::MaybeIncorrect, + ) + .with_desc("use inline assembly for gas optimization") + .with_span(replacement_span) ); -// Example: Suggesting a code block -cx.emit_with_fix( +// Example: Providing an example (non-applicable suggestion) +cx.emit_with_suggestion( lint, node.span, - Snippet::Block { - desc: Some("suggested implementation"), - code: suggested_code, - } + Suggestion::example("some example") ); ``` +**Applicability Levels:** +- `MachineApplicable`: The suggestion can be applied automatically with high confidence +- `MaybeIncorrect`: The suggestion might not be correct in all cases and should be reviewed +- `HasPlaceholders`: The suggestion contains placeholders that need to be filled in +- `Unspecified`: No applicability specified + 3. Add comprehensive tests in `lint/testdata/`: - Create `MyNewLint.sol` with various examples (triggering and non-triggering cases, edge cases). - If your test requires imports, add those files under `lint/testdata/auxiliary/` so that the ui runner doesn't lint them. @@ -127,11 +192,11 @@ The testing framework runs the linter on the `.sol` file and compares its standa cargo test -p forge --test ui // using nextest - cargo nextest run -p forge test ui + cargo nextest run -p forge --test ui ``` - If you need to generate / bless (re-generate) the output files: ```sh // using the default cargo cmd for running tests - cargo test -p forge --test ui -- --bless + cargo bless-lints ``` diff --git a/docs/dev/networks.md b/docs/dev/networks.md new file mode 100644 index 0000000000000..a3dfc87ef187e --- /dev/null +++ b/docs/dev/networks.md @@ -0,0 +1,268 @@ +# Custom EVM integrations + +Foundry supports execution families whose transaction types, hardforks, precompiles, or state differ +from Ethereum. An integration crosses Foundry, Alloy, and REVM, so it must define ownership at each +layer and preserve that contract across Forge, Cast, Anvil, Chisel, scripts, cheatcodes, and traces. + +## Mental model + +Compile-time support and runtime selection are separate: + +- Cargo features make optional network code available in a binary. Enabling a feature does not + activate that network. +- [`NetworkConfigs`](../../crates/evm/networks/src/lib.rs) records the runtime execution profile. + It is populated by `foundry.toml`, CLI flags, a namespaced hardfork, or fork endpoint discovery. +- [`FoundryEvmNetwork`](../../crates/evm/core/src/evm/mod.rs) associates an Alloy `Network` with a + [`FoundryEvmFactory`](../../crates/evm/core/src/evm/mod.rs). Tool entry points dispatch to a + concrete implementation only after runtime selection. + +`NetworkConfigs` is discovery and selection state, not an execution-policy container. The target +architecture ends its lifetime when a tool selects its concrete `FoundryEvmNetwork`. After that +dispatch, the selected types, factory, context, and narrow resolved inputs are authoritative. +Existing backend fork-position and Celo inspector configuration are named migration exceptions; +they are not precedent for new post-dispatch family checks. + +An optional RPC capability probe must not become a requirement for an otherwise valid endpoint. +Before an endpoint has positively identified a custom execution family, failed optional probes +should fall back to mandatory standard RPC discovery. Once the endpoint has been identified, +subsequent identity checks can remain strict. + +## Single-dispatch invariant + +Each tool must resolve configuration and select an execution family exactly once, at its outermost +execution boundary: + +```text +CLI, config, and RPC discovery + | + v + NetworkConfigs + | + v + concrete FoundryEvmNetwork dispatch +``` + +New code below that boundary must not receive, store, or reconstruct `NetworkConfigs`. In +particular, executors, backends, inspectors, replay helpers, nested EVM construction, and typed +runner state must not add `is_monad()`, `is_tempo()`, or equivalent family checks after the concrete +FEN has been selected. The remaining backend fork-position and Celo inspector consumers should be +removed with their complete state-lifecycle owners rather than hidden behind another generic +policy layer. + +Do not disguise downstream dispatch as: + +- an associated capability boolean on `FoundryEvmNetwork` or `FoundryEvmFactory`; +- a generic policy trait whose ordinary implementations are no-ops; +- a callback table selected from runtime network configuration; +- an optional custom-family context whose presence selects an algorithm; +- an execution-family enum carried alongside an already concrete FEN. + +When behavior belongs to only one execution family, prefer a concrete struct or implementation for +that family. Small concrete duplication is preferable to making every generic path understand a +foreign execution lifecycle. Family-neutral data may cross the dispatch boundary only in the +narrow form required by its owner, such as a chain ID, timestamp, address list, or resolved +precompile set. + +Do not confuse an Alloy `Network` with an execution family. `AnyNetwork` is useful at permissive RPC +decoding boundaries, but decoding a foreign transaction does not give an Ethereum executor that +network's execution semantics. + +## Ownership boundaries + +| Layer | Owns | +| --- | --- | +| Alloy `Network` | RPC transaction requests, envelopes, receipts, and response types | +| Alloy `EvmFactory` and Foundry's `FoundryEvmFactory` adapter | Spec, block and transaction environments, halt reasons, precompiles, and construction of the concrete EVM context | +| REVM context and journal | Execution-time state mutation, checkpoints, commits, and reverts | +| `NetworkConfigs` and hardfork types | Pre-dispatch selection, chain and endpoint inference, feature configuration, and hardfork validation | +| Foundry tools | CLI/config plumbing, one authoritative dispatch per execution entry point, and tool-specific workflows and user-visible behavior | + +After dispatch, `SpecFor` owns hardfork resolution through `ExecutionSpec`: checked +namespaced conversion, native source-chain schedules, local-fork behavior, and metadata for direct +spec overrides. The shared resolver accepts one typed explicit spec and derives its reported +hardfork from that same value. Tracing carries the result, including absent metadata for an +Ethereum or Optimism version override, directly into decoding. Keep the source chain ID separate +from an overridden execution chain ID when supplying historical context. + +Do not encode protocol behavior only as a chain-ID branch in a tool. Put execution semantics in the +network factory or context, selection in the network configuration layer, and tool-specific workflow +behavior in the relevant tool. + +Fork creation is the boundary between a request and selected remote state. `CreateFork` contains +only the request; `MultiFork` prepares the client, block, and identity and owns them with the remote +backend. Scripts, tests, Chisel, and tracing create a pristine `Backend` before preflight and +clone it for execution. Nonce reads, CREATE2 checks, and environments use that backend's selected +block. They do not carry a separate resolved snapshot alongside it. `EvmOpts` retains the requested +selector, so preparing `latest` does not rewrite the request. + +Changing a script RPC explicitly selects another backend through the same fork manager. Exact +identity governs remote-cache reuse; each runner retains its own mutable execution state. Mutation +testing dispatches once for the campaign and shares the pristine typed backend across its baseline +and workers. Each mutation run scopes its fork registry so cheatcode-created forks are released +when the run finishes. Endpoint checks still reject resets and execution-profile changes. + +## Adding an execution family + +Start by writing down which parts differ from Ethereum: RPC envelopes, transaction validation, +hardfork schedule, block environment, precompiles, system calls, fee accounting, or auxiliary +state. Then implement the integration in layers. + +1. Add hardfork parsing and activation rules under `crates/evm/hardforks`. +2. Add the runtime family and its configuration to `crates/evm/networks`. Define explicit selection, + known-chain inference, endpoint metadata, compatibility with fork sources, and legacy aliases if + required. +3. Add the concrete `FoundryEvmNetwork` and `FoundryEvmFactory` adapter under + `crates/evm/core/src/evm/`. Keep conversions between RPC types and execution types at this + boundary. +4. Thread the associated spec, block, transaction, halt, context, and journal types through typed + execution code. Keep custom-family state and algorithms in concrete implementations instead of + adding optional custom state to the generic backend. Avoid converting back to Ethereum types in + generic paths. +5. Identify the outermost execution boundary of every affected tool. Resolve `NetworkConfigs` + there, dispatch once to a concrete FEN, and pass only typed or narrowly resolved inputs below the + boundary. Search Forge, Cast, Anvil, Chisel, scripting, verification, tracing, and cheatcodes for + entry points, then verify that shared runners, executors, inspectors, backends, and replay helpers + do not dispatch again. +6. Feature-gate optional dependencies consistently through every crate and binary that reaches the + integration. A default build, a build with the feature, and the published release build are three + distinct configurations. + +Large integrations should be split into reviewable layers when possible: hardfork and configuration, +core execution, individual tool surfaces, then CI and documentation. Each layer should retain working +non-custom execution paths. + +## Compiler targets and execution hardforks + +Keep the Solidity compiler target (`evm_version`), execution family (`network`), and protocol +revision (`hardfork`) distinct. A Tempo revision uses an Osaka instruction-set baseline with +Tempo's own gas schedule, precompiles, and transaction rules; an Ethereum version name does not +identify a Tempo revision. + +`vm.setEvmVersion` selects execution rules using the active network's version mappings; it does +not change the Solidity compiler target. Existing Ethereum aliases and native Tempo revision names +remain accepted. On Tempo, runtime changes do not rebuild instructions or precompiles; configure +`hardfork = "tempo:T7"` (or the required revision) before execution to select a different revision. + +Execution-time gas refreshes pass through the selected `FoundryEvmFactory`. The default delegates +to the existing context/configuration behavior, preserving downstream `FoundryCfg` implementations +and its blanket implementation for `CfgEnv`. Tempo overrides the factory method to use its +own gas parameters instead of Ethereum prices derived from its instruction-set baseline. + +## State lifecycle + +Custom execution state is often not fully represented by ordinary account storage. For every piece +of auxiliary state, document: + +- where it is initialized for local execution and remote forks; +- whether it belongs to the database, chain context, block context, transaction context, or journal; +- how it is cloned for nested EVMs and isolation mode; +- when it is committed or discarded after success, revert, or halt; +- how snapshots, `revertToState`, state dumps, and Anvil rollback restore it; +- how fork creation, selection, rolling, reset, and RPC replacement reinitialize it; +- how `vm.transact`, script simulation, calls, estimates, access lists, traces, and historical replay + obtain the correct block and transaction context; +- which caches derive from it and how those caches are invalidated. + +If state must survive a nested execution, implement the transfer explicitly at the factory, context, +or journal boundary. Do not rely on cloning the ordinary account database to preserve state owned by +another component. Do not add optional custom-family state to a generic context or return generic +"context update" signals when only one concrete family can use them. + +Both cheatcode inspector adapters use +[`with_inherited_evm`](../../crates/evm/core/src/evm/mod.rs) to pair inherited context setup +with successful write-back. Inspector assembly stays with the adapter, and EVM factories retain +construction ownership. This shared operation still uses the existing Monad journal bridge; it +is not a replacement for the deferred native journal, snapshot, and fork lifecycle migrations. + +Isolated calls and `executeTransaction` share account-state preparation and settlement through +[`prepare_child_state` and `merge_child_state`](../../crates/evm/core/src/evm/mod.rs). +Their transaction context, environment restoration, and native journal handling remain distinct: +an isolated call belongs to its enclosing transaction, while `executeTransaction` runs a fresh one. + +Historical replay retains the RPC envelope's system classification after transaction conversion. +The default nested replay behavior deliberately skips system envelopes unsupported by the selected +execution family without mutating state. A family that supports protocol system envelopes must +override that behavior in its concrete nested EVM and test canonical execution, foreign-envelope +skips, malformed envelopes, and rollback after failed protocol prestate. + +## Tool coverage + +An integration is incomplete until the supported user workflows dispatch to the same execution +family and hardfork semantics. Audit at least: + +- `forge test`, coverage, fuzz replay, gas reports, and inline configuration; +- `forge script`, simulation, broadcasting, resuming, and contract creation; +- `cast call`, `cast run`, transaction decoding, tracing, and gas estimation; +- Anvil startup, mining, RPC calls, fork startup/reset/roll, snapshots, dumps, and replay; +- Chisel startup, cached sessions, and forks; +- cheatcode execution, nested EVMs, isolation mode, and network-specific cheatcode addresses; +- trace decoding, labels, verification, and contract-size or fee rules. + +Unsupported combinations should fail at the selection boundary with a specific error. Ordinary +Ethereum and other enabled families must continue to use their existing path. Once a workflow has +selected a concrete FEN, helpers used by that workflow must not accept a second runtime execution +profile. + +### Script recovery + +The requirements in this subsection are proposed and are not guarantees of the current script +broadcaster. Network integrations must satisfy them as the durable recovery architecture is +implemented. + +Custom transaction fields remain owned by the selected Alloy `Network` and concrete execution +family, but `forge script` must carry their final values through durable submission and resume. A +network integration that changes transaction preparation is incomplete until it identifies every +field that can change transaction identity or semantics, preserves those fields in script recovery +state, and tests interrupted submission with that network's real envelope type. + +Do not reconstruct a custom transaction from family-neutral Ethereum fields during resume. Persist +the final typed or encoded payload at the transaction boundary, including fee assets, validity +windows, sponsorship, authorization, auxiliary calls, or signer metadata required by that family. +Locally signed payloads must be recoverable without the signer; delegated signing with an ambiguous +outcome must stop rather than silently request a second signature. See the +[Forge scripting recovery contract](./scripting.md#recovery-contract) for the shared lifecycle and +failure-injection requirements. + +## Tests and CI + +Use a layered test plan: + +1. Unit-test hardfork parsing, network selection, transaction conversion, and factory behavior. +2. Add conformance-style tests for auxiliary state across commit, revert, nested execution, + snapshots, fork operations, and historical replay. +3. Add hermetic CLI integration tests for each supported tool. Prefer in-process nodes and local RPC + proxies over live providers. +4. Exercise explicit selection, inferred selection, an unknown chain, and a conflicting or disabled + family. Test optional endpoint probes independently from mandatory RPC methods. +5. Exercise the same workflow through each supported dispatch branch and verify that enabling one + family does not change another family's execution. Add a source-boundary check when practical to + prevent `NetworkConfigs` from being introduced into post-dispatch modules. +6. Compile the workspace without optional features, with each feature independently, and with the + release feature set. Verify that enabling one family does not change another family's execution. +7. Add the focused library and integration suites to normal pull-request CI. Do not rely only on a + downstream repository or a nightly flaky-test job for release-critical behavior. + +Keep feature propagation synchronized across workspace manifests, the root `Makefile`, and the +release and Docker workflows. Tests that use forks must contain `fork` in their names. + +## Documentation and releases + +User-facing selection, configuration, and workflows belong in the +[Foundry Book](https://getfoundry.sh). CLI option text belongs in the Clap definitions and is +generated into the book. Trait, context, and state invariants belong in Rustdoc next to their +implementation. Cross-crate integration guidance belongs here. + +### Anvil CIP-64 compatibility + +Anvil in Celo mode accepts CIP-64 (`0x7b`) envelopes and preserves `feeCurrency` in +transaction and receipt RPC responses. `eth_fillTransaction` returns a CIP-64 envelope +when a fee currency is supplied. This is a compatibility mode for local payload execution +and client integration testing: it uses native EIP-1559 fee accounting, interpreting the +numeric fee fields as native currency amounts without exchange-rate conversion. Senders +need native funds. Fee tokens are not debited, and Celo fee-hook gas and logs are not +reproduced. Local receipts use the ordinary receipt payload with the CIP-64 type byte. + +This mode does not initialize Celo core contracts or provide protocol-accurate fee-currency +simulation. A signed transaction priced for a live fee currency can fail native base-fee +or balance checks. Other Anvil execution profiles reject CIP-64 submission and imported +CIP-64 block history. diff --git a/docs/dev/output-channels.md b/docs/dev/output-channels.md new file mode 100644 index 0000000000000..0ad81004003ef --- /dev/null +++ b/docs/dev/output-channels.md @@ -0,0 +1,178 @@ +# Output channels (stdout / stderr) — contract + +This document defines the rules every Foundry CLI command (`forge`, `cast`, `anvil`, +`chisel`, `script`, `verify`) must follow when writing to standard output and standard +error. + +> **Status:** The macros, helpers, and routing primitives described here are +> implemented. The per-command stdout table at the bottom is the **target +> contract** that downstream migration PRs will bring each command into +> compliance with; it does **not** yet describe today's behavior for every +> command. + +## The contract + +> **stdout** is the command's primary result and nothing else. In text mode it is one +> canonical value (or one record per line, tab-separated for multi-column output). +> In `--json` mode it is a single JSON document. +> +> **stderr** is every other byte the command emits: warnings, errors, progress +> indicators, status prose, prompts, banners, ABI dumps, verification chatter. + +## Corollaries + +- `--json` only changes stdout's *format*, never its *channel cleanliness*. +- `--quiet` suppresses stderr diagnostics and progress. The target contract is that + it **never** alters stdout content; today `sh_print!`/`sh_println!` are still + suppressed by `--quiet` and that bypass will be flipped on once the major prose + stdout call sites in `forge`/`script` have been migrated to `sh_status!`. + `sh_err!` is the documented exception: fatal errors are always emitted on stderr. +- `-vvv` adds stderr verbosity. It must never alter stdout content. +- A command that has no primary result (e.g. `forge install`) writes nothing to + stdout by default. +- Prompts are diagnostics: write the question to stderr, read the answer from stdin. + +The acceptance criterion this enables: an agent or shell script can run any +command, discard stderr, and trust that stdout contains only the documented +machine-readable result. + +```sh +forge create … 2>/dev/null # → contract address only +forge test --json 2>/dev/null | jq … # → valid JSON, always +cast call … 2>/dev/null | xargs … # → return value only +``` + +## How to write code that follows the contract + +Use the `sh_*` macros in [`foundry_common::io`]; do not call `println!` / +`eprintln!` directly. A workspace-wide clippy `disallowed-macros` lint +(see [`clippy.toml`](../../clippy.toml)) forbids the four `std::print*` / +`std::eprint*` macros everywhere; the `sh_*` macros expand to `write!` calls on +the global `Shell`, so they are not affected by the lint. + +| Macro | Channel | Suppressed by `--quiet` | Use for | +| ---------------- | ------- | ---------------------------------- | ------------------------------------------------------------------ | +| `sh_println!` | stdout | yes (target: **no**, see above) | The command's primary machine-readable result. | +| `sh_print!` | stdout | yes (target: **no**, see above) | Same as `sh_println!`, no trailing newline. | +| `sh_status!` | stderr | yes | Status prose ("Compiling…", "Deploying contract…"). | +| `sh_progress!` | stderr | yes (also no-op when stderr ≠ tty) | Spinner/progress-bar style transient updates. | +| `sh_warn!` | stderr | yes | Recoverable problems. Adds a "Warning:" prefix. | +| `sh_err!` | stderr | **no** | Errors. Adds an "Error:" prefix. | +| `sh_eprintln!` | stderr | yes | Escape hatch for raw stderr text that doesn't fit the above. | +| `sh_eprint!` | stderr | yes | Same as `sh_eprintln!`, no trailing newline. | +| `prompt!` | stderr (question) + stdin (answer) | yes (question is `sh_eprint!`) | Interactive question/answer. | + +The compilation reporter in `foundry_common::compile` writes its spinner to +stderr in TTY mode (see [`SpinnerReporter`](../../crates/common/src/term.rs)). +The non-TTY fallback still writes to stdout today; flipping it to stderr will +shift many existing snapshot tests and is part of the per-command migration +backlog. Calling `sh_progress!` directly is also acceptable for one-off +progress lines. + +## Decision rule for any `sh_println!` call site + +For each `sh_println!` call you write or review, ask: + +1. **Is this the canonical primary result of the command?** + - Yes → keep `sh_println!` (stdout). + - No → use `sh_status!`, `sh_warn!`, `sh_err!`, or `sh_eprintln!` (stderr). +2. **Does this line mix labels with data** (e.g. `"Deployer: 0x…"`)? + - The label is prose → move the *whole line* to `sh_status!` (stderr). + - The data alone belongs on stdout → emit just the value (`sh_println!`). + - In `--json` mode, both belong inside the single JSON document on stdout. + +## Per-command stdout contract (target) + +This table is the **target contract** that migration PRs will bring each +command into compliance with. It is the source of truth for what each command's +stdout *will* contain after migration, not necessarily what it contains today. +Each row's status is one of: + +- `migrated` — current behavior matches this contract. +- `todo` — current behavior does not match yet; a follow-up PR is needed. + +### `cast` + +| Command | Text mode stdout | `--json` stdout | Status | +| ---------------------- | ---------------------------------------------------- | -------------------------------------------------------------- | ------ | +| `cast call` | Return value (hex / decoded) | JSON of return value | migrated | +| `cast send` | Receipt (or tx hash with `--async`) | JSON receipt (or hex tx hash with `--async`) | migrated | +| `cast tempo zone deposit/withdraw` | Source-chain transaction hash (also when waiting for L1) | Source-chain receipt | migrated | +| `cast tempo zone earn encode-deposit/encode-redeem` | Callback bytes | Success envelope containing callback bytes | migrated | +| `cast estimate` | Gas estimate (decimal) | JSON `{ "gas": "…" }` | migrated | +| `cast rpc` | RPC result (JSON) | JSON | migrated | +| `cast storage` | Single slot value | JSON of layout | migrated | +| `cast logs` | Pretty-printed logs | JSON array | migrated | +| `cast run` | Trace / decoded output | JSON | migrated | +| `cast trace` | Trace | JSON trace | migrated | +| `cast wallet new` | One record per wallet: `address` (keystore) or `address\tprivate_key` (no keystore); omitted when stdout is a tty, where the stderr prose already shows the values | JSON array of `{ address, public_key, path }` (keystore) or `{ address, public_key, private_key }` (no keystore) | migrated | +| `cast wallet sign` | Signature | JSON | migrated | +| `cast wallet sign-auth`| Signed authorization RLP | JSON | migrated | +| `cast erc20 balance` | Balance (decimal) | JSON string | migrated | +| `cast create2` | `address\tsalt` (tab-separated); when mining, omitted when stdout is a tty, where the stderr prose already shows the values | n/a | migrated | +| `cast access-list` | Access list | JSON | migrated | +| `cast interface` | Solidity interface source | JSON ABI array | migrated | +| `cast artifact` | JSON artifact | n/a | migrated | +| `cast creation-code` | Hex bytecode (or disassembly with `--disassemble`) | n/a | migrated | +| `cast constructor-args`| One constructor arg per line | n/a | migrated | +| `cast b2e-payload` | JSON execution payload | n/a | migrated | +| `cast tx-pool` | JSON | JSON | migrated | +| `cast da-estimate` | Gas estimate | JSON | migrated | +| `cast find-block` | Block number | JSON | migrated | +| `cast mktx` | Signed RLP | JSON | migrated | +| `cast batch-mktx` | Signed RLP (or unsigned RLP with `--raw-unsigned`) | n/a | migrated | +| `cast batch-send` | Receipt (or tx hash with `--async`) | JSON receipt (or hex tx hash with `--async`) | migrated | + +### `forge` + +| Command | Text mode stdout | `--json` stdout | Status | +| ---------------------- | ---------------------------------------------------- | ------------------------------------------ | ------ | +| `forge build` | (empty) | JSON build output | todo | +| `forge test` | (empty; exit code = pass/fail) | JSON test results, JUnit XML with `--junit`| todo | +| `forge create` | Deploy: `Deployer:` / `Deployed to:` / `Transaction hash:` lines. Dry-run: `Contract:` / `Transaction:` / `ABI:` lines. Compiler output may precede (tracked under `forge build`). | JSON `{ deployer, deployedTo, transactionHash }` (or dry-run JSON `{ contract, transaction, abi }`) | todo | +| `forge inspect `| Just that field's value (`artifact`/`output` prints the contract artifact JSON) | JSON of that field | migrated | +| `forge install` | (empty) | (empty) | migrated | +| `forge init` | (empty) | (empty) | migrated | +| `forge update` | (empty) | (empty) | migrated | +| `forge remove` | (empty) | (empty) | migrated | +| `forge clone` | (empty) | (empty) | migrated | +| `forge bind` | (empty) | (empty) | migrated | +| `forge bind-json` | (empty) or generated path | JSON | migrated | +| `forge flatten` | Flattened source | n/a | migrated | +| `forge fmt` | (empty) or formatted source with `--check` | n/a | migrated | +| `forge tree` | Dependency tree | JSON | migrated | +| `forge config` | Config TOML | JSON config | migrated | +| `forge selectors` | Selectors output | JSON | migrated | +| `forge eip712` | (empty) | JSON of types | migrated | +| `forge geiger` | Findings | JSON | migrated | +| `forge lint` | (empty; findings on stderr/exit code) | JSON findings | migrated | +| `forge snapshot` | Per-test diff lines (`--diff`); table (`--format table`); (empty) otherwise | n/a | migrated | +| `forge coverage` | Coverage table or report | JSON / LCOV / etc. via `--report` | todo | +| `forge cache` | (empty) or paths | JSON | migrated | +| `forge clean` | (empty) | n/a | migrated | +| `forge completions` | Generated shell completion script | n/a | migrated | +| `forge doc` | (empty) | n/a | migrated | +| `forge soldeer` | Passthrough to `soldeer` crate; foundry adds no wrapper prose | n/a | migrated | +| `forge remappings` | One remapping per line | n/a | migrated | +| `forge compiler` | Compiler info | JSON | migrated | +| `forge verify-contract`| `\t` on submission; empty if already verified | n/a | migrated | +| `forge verify-bytecode`| ` code matched with status ` lines | JSON array of `{ bytecode_type, match_type, message }` | migrated | + +### `anvil`, `chisel`, `script` + +| Command | Text mode stdout | `--json` stdout | Status | +| ------------- | --------------------------------------------- | --------------------- | ------ | +| `anvil` | Banner, accounts, RPC URL on stderr | n/a | todo | +| `chisel` | REPL output | n/a | todo | +| `forge script`| Simulation/broadcast result | JSON | todo | + +Commands not listed here have not been classified yet — please open an issue or +PR before relying on their stdout format. + +## References + +- Macros: [`crates/common/src/io/macros.rs`](../../crates/common/src/io/macros.rs) +- Shell wrapper: [`crates/common/src/io/shell.rs`](../../crates/common/src/io/shell.rs) +- Spinner / progress: [`crates/common/src/term.rs`](../../crates/common/src/term.rs) +- Compilation reporter: [`crates/common/src/compile.rs`](../../crates/common/src/compile.rs) +- Lint configuration: [`clippy.toml`](../../clippy.toml) diff --git a/docs/dev/scripting.md b/docs/dev/scripting.md index 92b65a03ab561..d76deb108fe8a 100644 --- a/docs/dev/scripting.md +++ b/docs/dev/scripting.md @@ -1,135 +1,341 @@ -# Scripting +# Forge scripting internals -- [Scripting](#scripting) - - [High level overview](#high-level-overview) - - [Notes](#notes) - - [Script Execution](#script-execution) - - [Nonce Management](#nonce-management) +This guide describes the current contributor-facing `forge script` lifecycle, persisted resume +state, recovery contract, and known limitations. User-facing CLI instructions belong in the +[Foundry Book](https://getfoundry.sh). -## High level overview +## Ownership -```mermaid -graph TD; - ScriptArgs::run_script-->ScriptArgs::compile; - ScriptArgs::compile-->A{ScriptArgs::execute}; - A-- "(resume || verify) && !broadcast" -->B{ScriptArgs::resume_deployment}; - A-- "broadcast" -->ScriptArgs::handle_broadcastable_transactions; +| Stage | Owner | +| -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| CLI options, network dispatch, and top-level transitions | [`crates/script/src/lib.rs`](../../crates/script/src/lib.rs) | +| Compilation, linking, resume loading, and signer reacquisition | [`crates/script/src/build.rs`](../../crates/script/src/build.rs) | +| Local execution and collection of broadcastable transactions | [`execute.rs`](../../crates/script/src/execute.rs) and [`runner.rs`](../../crates/script/src/runner.rs) | +| On-chain simulation, metadata, and sequence construction | [`simulate.rs`](../../crates/script/src/simulate.rs) and [`transaction.rs`](../../crates/script/src/transaction.rs) | +| Preparation, signer selection, submission, and Tempo batching | [`broadcast.rs`](../../crates/script/src/broadcast.rs) | +| Pending-transaction reconciliation | [`progress.rs`](../../crates/script/src/progress.rs) and [`receipts.rs`](../../crates/script/src/receipts.rs) | +| Public artifacts and sensitive RPC data | [`crates/script-sequence`](../../crates/script-sequence) and [`multi_sequence.rs`](../../crates/script/src/multi_sequence.rs) | +| Post-deployment verification | [`verify.rs`](../../crates/script/src/verify.rs) | - B-- multi-chain --> MultiChainSequence::load - B-- single-chain --> ScriptArgs::resume_single_deployment - ScriptArgs::resume_single_deployment--> ScriptSequence::load - ScriptSequence::load--> receipts::wait_for_pending - receipts::wait_for_pending -- resume --> ScriptArgs::send_transactions +Network selection follows the single-dispatch invariant in +[Custom EVM integrations](./networks.md). Recovery state contains network-specific transaction data, +but it must not become a second execution-family dispatcher. - MultiChainSequence::load --> ScriptArgs::multi_chain_deployment +## Lifecycle - ScriptArgs::multi_chain_deployment-- "resume[..]" --> receipts::wait_for_pending - ScriptArgs::multi_chain_deployment-- "resume[..]" --> ScriptArgs::send_transactions - ScriptArgs::multi_chain_deployment-- "broadcast[..]" --> ScriptArgs::send_transactions +`ScriptArgs::run_script` selects a concrete `FoundryEvmNetwork` and drives the typed state machine. +A new deployment follows this path: - ScriptArgs::send_transactions--verify-->ScriptArgs::verify_contracts +```text +preprocess and compile + | + v +link -> prepare execution -> execute script + | + v +collect broadcastable transactions + | + v +simulate transactions against their RPCs + | + v +attach metadata and construct ScriptSequence values + | + v +reconcile saved pending hashes + | + v +prepare, sign or delegate, and submit remaining transactions + | + v +collect receipts -> save artifacts -> optionally verify +``` +Local script execution and on-chain simulation are separate phases. `PreExecutionState::execute` +runs the Solidity script and collects transactions emitted by broadcast cheatcodes. Simulation +executes only that collected list against remote state. Publishing or resuming must preserve the +saved broadcast plan rather than rebuilding it from a new execution. - ScriptArgs::handle_broadcastable_transactions-->ScriptConfig::collect_rpcs; - ScriptConfig::collect_rpcs-->ScriptArgs::create_script_sequences - ScriptArgs::create_script_sequences--"[..]"-->C{Skip onchain simulation}; - C--yes-->D{"How many sequences?"}; - C--no-->ScriptArgs::fills_transactions_with_gas; - ScriptArgs::fills_transactions_with_gas-->ScriptArgs::onchain_simulation; - ScriptArgs::onchain_simulation-->D; +`--resume` still compiles so contract and verification metadata are available, but it loads the +saved sequence and skips simulation. It may execute the full script again to recover signers +introduced through script cheatcodes, so enabled FFI and other off-chain effects can run again. +Transactions from that execution must not replace or renumber the saved plan. - D--"+1 seq"-->MultiChainSequence::new - D--"1 seq"-->ScriptSequence::new +### Local execution safety and nonce assignment - MultiChainSequence::new-->ScriptArgs::multi_chain_deployment - ScriptSequence::new-->ScriptArgs::single_deployment - ScriptArgs::single_deployment-->ScriptArgs::send_transactions -``` +`ScriptRunner` deploys required libraries and the script contract, calls `setUp()`, then calls the +selected script function. Broadcast cheatcodes collect externally publishable transactions during +those calls. Local execution adjusts the broadcast sender's nonce so CREATE addresses and later +transaction nonces match the intended on-chain sequence. An explicit `--sender-nonce` overrides the +initial provider-derived nonce. -### Notes +Before deploying libraries, the runner sets the sender to that initial nonce. Regular CREATE +library deployments increment it through execution; CREATE2 library deployments use calls instead, +so the runner advances it explicitly by the number of generated library transactions. Deploying the +script contract with the default caller temporarily isolates and then restores that caller's nonce. +At each root `setUp()` or script-function call, the cheatcode inspector decrements the caller nonce +before execution so broadcasts observe the on-chain nonce. This correction is not triggered by the +first `vm.broadcast`, `vm.startBroadcast`, or `vm.getNonce` call. -1. `[..]` - concurrently executed +Script execution protection is installed after deploying the script contract. Alongside the +`ADDRESS` check, the cheatcode inspector rejects `CALLER` in the main script's broadcasting frame +when its actual caller differs from the broadcast sender. Called contracts and deeper callbacks +retain their own caller semantics. Setting `script_execution_protection = false` disables both +checks. This is an opcode guard: it does not track sender values cached before broadcasting or +reads moved outside the broadcast by the compiler. Scripts should pass an explicit deployer +address when constructing transaction arguments. -2. The bit below does not actually influence the state initially defined by `--broadcast`. It only happens because there might be private keys declared inside the script that need to be collected again. `--resume` only resumes **publishing** the transactions, nothing more! +The generated nonce is part of the operation plan. During sequential broadcasting, +`SendTransactionKind::prepare` waits for the provider nonce to reach the planned nonce and fails if +the provider has already advanced past it. Resume must reconcile the saved operation rather than +silently assigning the provider's next nonce, because changing a CREATE nonce changes its address. -```mermaid -graph TD; -ScriptArgs::execute-- "(resume || verify) && !broadcast" -->ScriptArgs::resume_deployment; -``` +## Compatibility sequence model -3. `ScriptArgs::execute` executes the script, while `ScriptArgs::onchain_simulation` only executes the broadcastable transactions collected by `ScriptArgs::execute`. - -## Script Execution - -```mermaid -graph TD; -subgraph ScriptArgs::execute -a[*]-->ScriptArgs::prepare_runner -subgraph ::prepare_runner -ScriptArgs::prepare_runner--fork_url-->Backend::spawn; -ScriptArgs::prepare_runner-->Backend::spawn; -end -Backend::spawn--> -ScriptRunner-->ScriptRunner::setup; -subgraph ::setup -ScriptRunner::setup--libraries-->Executor::deploy; -ScriptRunner::setup--ScriptContract-->Executor::deploy; -Executor::deploy--"setUp()"-->Executor::call_committing; -end -subgraph ::script -Executor::call_committing-->ScriptRunner::script; -ScriptRunner::script--"run()"-->Executor::call; -end -end -Executor::call-. BroadcastableTransactions .->ScriptArgs::handle_broadcastable_transactions; -``` +A `ScriptSequence` compatibility artifact stores: + +- an ordered `transactions` queue containing requests or pre-signed envelopes plus display and + verification metadata; +- separate `receipts` and `pending` hash lists; +- chain ID, libraries, script returns, source commit, and timestamp; +- each transaction's RPC URL in a separate sensitive-cache file. + +Each `TransactionWithMetadata` has an optional transaction hash. The authoritative recovery plan +wraps these transactions with stable operation IDs, immutable attempt records, and recovery state. +Ordinary recovery selects incomplete operations by their own hash associations. Batch recovery +advances only across a validated contiguous prefix whose transaction hashes have matching receipts, +so a receipt hole cannot skip an unfinished earlier batch operation. + +During a new run, `FilledTransactionsState::bundle` creates one sequence for each consecutive run of +transactions using the same RPC. An A -> B -> A RPC order therefore produces three sequences, not +one sequence per endpoint or chain. A single sequence owns its public and sensitive paths. Multiple +sequences are wrapped in one `MultiChainSequence`; only the multichain container is written. + +## Preparation and signer modes + +Before `SendTransactionKind::prepare`, broadcasting selects the chain-specific transaction kind, +estimates fees, and applies general Tempo options. `prepare` may then synchronize the sender nonce, +re-estimate gas, resolve the Tempo fee token, convert Tempo account-abstraction creates, and attach +sponsorship. Those final values can differ from the request produced during simulation, so recovery +persists the fully prepared attempt before submission. A new operation may run preparation, but +resume does not prepare a replacement for an existing attempt. + +| Mode | Submission | Durable checkpoint before submission | +| ----------------------------------- | ---------------------------------------- | -------------------------------------- | +| Pre-signed envelope | `eth_sendRawTransaction` | Final encoded bytes and derived hash | +| Local Ethereum wallet | Sign, then `eth_sendRawTransaction` | Final encoded bytes and derived hash | +| Tempo account or session access key | Sign, then `eth_sendRawTransaction` | Final encoded bytes and derived hash | +| Browser wallet | Wallet-controlled signing and submission | Delegated request with no assumed hash | +| Unlocked RPC account | `eth_sendTransaction` | Delegated request with no assumed hash | + +The broadcaster persists each attempt before invoking its submission API. It records a definitive +hash in the transaction and `pending` afterward. For non-sequential chains it submits up to 100 +transactions concurrently. Completion order is not necessarily plan order; stable operation IDs +associate attempts and responses with their planned transactions. + +A signed send error leaves identical encoded bytes available for replay. An ambiguous browser or +unlocked error marks the delegated attempt `outcome unknown` and blocks automatic replacement; +only a definite local rejection clears that request. Resume never repeats preparation for an +existing attempt. + +## Pending reconciliation and receipts -## Nonce Management - -During the first execution stage on `forge script`, foundry has to adjust the nonce from the sender to make sure the execution and state are as close as possible to its on-chain representation. - -Making sure that `msg.sender` is our signer when calling `setUp()` and `run()` and that its nonce is correct (decreased by one on each call) when calling `vm.broadcast` to create a contract. - -We skip this, if the user hasn't set a sender and they're using the `Config::DEFAULT_SENDER`. - -```mermaid -graph TD - - ScriptRunner::setup-->default_foundry_caller-deployScript; - default_foundry_caller-deployScript-->user_sender-deployLibs; - user_sender-deployLibs-->Contract.setUp; - Contract.setUp-->A0{Executor::call}; - A0-->vm.broadcast; - A0-->vm.startBroadcast; - A0-->vm.getNonce; - - vm.broadcast--> A{cheatcode.corrected_nonce} - vm.startBroadcast-->A - vm.getNonce-->A - - A--true-->continue_setUp; - A--false-->B[sender_nonce=-1]; - B-->C[cheatcode.corrected_nonce=true]; - C-->continue_setUp; - continue_setUp-->end_setUp; - end_setUp-->D{cheatcode.corrected_nonce} - D--true-->E[cheatcode.corrected_nonce=false]; - D--false-->F[sender_nonce=initial_nonce+predeployed_libraries_count]; - E-->ScriptRunner::script; - F-->ScriptRunner::script; - ScriptRunner::script-->Contract.run; - Contract.run-->G{Executor::call}; - G-->H[vm.broadcast]; - G-->I[vm.startBroadcast]; - G-->J[vm.getNonce]; - - H--> K{cheatcode.corrected_nonce} - I-->K - J-->K - - K--true-->continue_run; - K--false-->L[sender_nonce=-1]; - L-->M[cheatcode.corrected_nonce=true]; - M-->continue_run; - continue_run-->end_run; +Before sending new work, `BundledState::wait_for_pending` checks each hash in `pending`. Sequences +from a multichain deployment are checked concurrently. A confirmed success removes the hash from +`pending` and appends its receipt. A revert removes the hash and returns an error without appending +the receipt, which can leave a receipt hole. Receipt-watcher timeouts keep retrying without +consuming the retry budget while the selected RPC still returns the transaction. If that endpoint +returns no transaction, the durable attempt remains the source of identity: signed bytes may be +replayed; delegated attempts with a known hash remain checkpointed for a later plain `--resume`, +while unknown outcomes remain blocked until explicitly resolved. + +An RPC receipt that repeatedly lacks block metadata follows a separate bounded retry path and can +also remove the compatibility hash from `pending`. Neither that incomplete receipt nor one endpoint +returning no transaction proves that a submission never reached the network, so neither condition +permits the recovery store to infer a replacement attempt. + +Receipts are sorted by block number and transaction index before persistence. Their order is not an +operation identity. Associate a receipt with a transaction by hash, as `BroadcastReader` does, +rather than by position. + +## Persistence and resume + +Single-chain runs use these latest-state files: + +```text +broadcast/